diff --git a/.agents/skills/office-game-feel/SKILL.md b/.agents/skills/office-game-feel/SKILL.md new file mode 100644 index 00000000..9bacf493 --- /dev/null +++ b/.agents/skills/office-game-feel/SKILL.md @@ -0,0 +1,298 @@ +--- +name: office-game-feel +description: Animation, movement, and interaction design for the office visualization — making it feel alive +user-invocable: false +--- + +# Office game feel + +Principles for animation and interaction design in the CodeAssembly office visualization. This skill is about making the office feel alive — movement, timing, attention, and the rhythm of work. + +The visualization is built with [Excalibur.js](https://excaliburjs.com/). The adapter pipeline (`CanonicalRunStatus → LogicalSceneState → OfficeSceneConfig → ResolvedPositions → OfficeDiff → TransitionPlan`) already generates transition plans with waypoints and timing. The scene currently discards them and teleports entities. This skill guides the work of consuming those plans to produce engaging animation. + +## The orchestrator heartbeat + +The single most important animation in the visualization. Everything else is secondary. + +The orchestrator lives in the governor's office. They walk up to the prep area or workshop to dispatch work, collect results, and update the whiteboard. Every errand starts and ends at the governor's desk. This rhythm of departures and returns is the visualization's **heartbeat** — the user watches their agent leave, sees them interact in the workshop, and watches them return with results. + +Get this right first. If the orchestrator moves convincingly between zones, the visualization feels alive even if nothing else animates. + +### Movement pattern + +``` +governor's desk → (walk through doorway) → destination zone + → (brief pause at target: desk, whiteboard, or agent) → interaction + → (walk back through doorway) → governor's desk +``` + +Each errand should take 2-4 seconds total. Faster feels rushed; slower feels laggy. The pause at the destination (500-800ms) is essential — it communicates "something happened here" before the return trip. + +## Settled decisions + +These are not open for re-evaluation: + +- **Thought bubbles are the primary glance-level information channel.** They carry the most important status information. Cycling content, staggered timing, freeze-on-hover, red border for alerts. +- **Time-on-task must be visible without hovering.** Color-coded indicators: green (healthy) → amber (slow) → red (stalled). +- **Notification-driven attention.** The visualization draws the eye to problems; healthy progress stays calm. Don't animate everything equally. +- **Three HUD modes.** Ambient (minimal — status pips, alert-only bubbles), Standard (names, times, all bubbles), Detailed (full filenames, console, action buttons). Toggle with H key. +- **Progressive disclosure.** Glance → hover → click. Each level reveals more detail without cluttering the default view. + +## Agent state machines + +All agents share a base state set. Each state has a distinct visual signature. + +``` +idle → working → done + ↓ + blocked → working (when unblocked) +``` + +| State | Animation | Thought bubble | Visual cue | +| ----------- | ----------------------------------- | --------------------- | --------------------------------- | +| `idle` | Standing, occasional idle animation | None or casual | Neutral posture | +| `working` | At workstation, facing equipment | Role-specific content | Subtle activity indicator | +| `done` | Relaxed, slight position shift | Completion note | Green checkmark or similar | +| `blocked` | Stopped, looking around | "Waiting for..." | Amber indicator, slight fidget | +| `concerned` | Alert posture | Warning/error content | Red indicator, attention-grabbing | + +### State transition cues + +When an agent changes state, signal it briefly: + +- **idle → working**: Agent walks to their station (if not already there), faces their equipment +- **working → done**: Brief flash or particle effect, posture relaxes +- **working → blocked**: Activity indicator changes to amber, thought bubble updates +- **Any → concerned**: Red pulse on the agent's status indicator, thought bubble border turns red + +Keep transition cues under 500ms. They signal change, not celebrate it. + +### Orchestrator states + +The orchestrator has a richer lifecycle than other agents: + +``` +idle (at desk) + → dispatching (walking to target zone) + → monitoring (back at desk, watching) + → collecting (walking to reviewer stations) + → consolidating (at desk, producing output) + → delivering (walking to coder with findings) + → ... (review cycle repeats) + → delivering (placing artifacts on delivery table) + → done +``` + +## Transition types + +The transition planner produces these types. Each needs a distinct visual treatment. + +| Type | What it means | Animation approach | +| ------------------ | ---------------------------------------- | ----------------------------------------------------- | +| `walk` | Entity moves between positions | Smooth movement along waypoints with corridor routing | +| `state_change` | Agent status changes (idle → working) | Brief visual cue at current position | +| `fade_in` | Entity appears (agent assigned to phase) | Fade from transparent, 300-400ms | +| `fade_out` | Entity disappears (agent done, leaves) | Fade to transparent, 300-400ms | +| `artifact_appear` | Artifact created by an agent | Pop-in at agent's position, slight scale bounce | +| `artifact_deliver` | Artifact reaches delivery surface | Slide to destination, settle with subtle bounce | + +### Stagger + +Transitions are staggered at 150ms intervals to avoid simultaneous visual changes. When 3 reviewers start working at once, their state changes should ripple across the room rather than snap simultaneously. This stagger is already computed in the `TransitionPlan.delayMs` field. + +## Movement design + +### Speed + +Target: **3-4 tiles per second** for walking. This feels purposeful without being frantic. The orchestrator covers the longest paths (governor's office to prep area, ~20 tiles), so a full trip takes 5-7 seconds including pauses. + +### Easing + +- **Departure**: Ease-in (slow start, accelerate). Communicates intention before movement. +- **Arrival**: Ease-out (decelerate, stop). Communicates settling into position. +- **Mid-path**: Linear or ease-in-out. Consistent travel speed. + +Excalibur's `EasingFunctions` provides `EaseInOutCubic` for smooth movement. Use `actor.actions.moveTo(x, y, speed)` for simple paths or chain `moveTo` calls for multi-waypoint corridor routes. + +### Pathfinding + +Agents don't free-roam. All paths are pre-computed by the transition planner as waypoint sequences: + +``` +[fromPosition, ...corridorWaypoints, toPosition] +``` + +Corridor waypoints route through doorways. The layout provides `corridorPath(fromZone, toZone)` which returns intermediate positions. Animate along these waypoints sequentially. + +### Facing direction + +- Walking: face the direction of movement (left/right/up/down sprite) +- At workstation: face the equipment (typically direction 3 = up, toward wall-mounted displays) +- Orchestrator at desk: face the camera (direction 0 = down) +- After arriving: brief pause (200ms) before changing facing direction + +## Thought bubbles + +The primary information channel. They do heavy lifting — don't make them an afterthought. + +### Cycling + +Active agents cycle through 2-3 thought bubble messages. Each message displays for 3-4 seconds before crossfading to the next. Stagger the cycling across agents so bubbles don't all change at once. + +### Freeze-on-hover + +When the user hovers over an agent or their bubble, freeze the cycle and keep the current message visible. Resume cycling when the cursor leaves. + +### Severity signaling + +- Normal content: white background, dark border +- Warning content: amber left border +- Error/alert content: red left border, subtle pulse animation + +### Positioning + +Bubbles appear above and slightly to the right of the agent. If the agent is near the top edge of their room, the bubble should appear below instead. Avoid overlapping bubbles from adjacent agents. + +## Time indicators + +Elapsed time per phase must be visible at a glance without hovering. + +### Color thresholds + +Define per-phase expected durations. Show elapsed time as: + +- **Green** (0-100% of expected): healthy pace +- **Amber** (100-200% of expected): slower than usual +- **Red** (200%+ of expected): stalled or problematic + +### Visual form + +A small colored pip or ring near the agent's station. Size: 6-8 pixels. Don't use large progress bars — they compete with the office aesthetic. The pip should feel like a subtle dashboard indicator, not a UI widget. + +## Attention guidance + +The visualization must guide the user's eye to what matters without being noisy. + +### Calm by default + +When everything is healthy, the office should feel like a busy but orderly workplace. Agents work at their stations, the orchestrator makes rounds, thought bubbles cycle quietly. No flashing, no urgency. + +### Problems surface themselves + +When something goes wrong, the visualization should draw attention through: + +1. **Thought bubble severity** — red border, pulse +2. **Time indicator turning red** — a stalled phase is immediately visible +3. **Agent posture change** — blocked or concerned state +4. **Orchestrator behavior** — returns to governor's office without the expected artifact, or makes an extra trip + +Don't add gratuitous attention effects (screen shake, flash, sound) for normal events. Reserve strong visual signals for actual problems. + +### Visual hierarchy of urgency + +``` +Calm (default) + → Notable (amber time indicator, "waiting for..." bubble) + → Concerning (red time indicator, concerned agent state) + → Critical (red pulse, multiple agents blocked, orchestrator returning empty) +``` + +## HUD toggle + +Three display modes, cycled with the H key: + +| Mode | What's visible | When to use | +| ------------ | ----------------------------------------------- | ---------------------------------------------- | +| **Ambient** | Status pips, alert-only bubbles | Watching in the background, everything healthy | +| **Standard** | Agent names, elapsed times, all thought bubbles | Active monitoring, the default | +| **Detailed** | Full filenames, console overlay, action buttons | Debugging, investigating a specific issue | + +Transition between modes with a quick fade (200ms). Don't snap — the user should feel the information density change smoothly. + +## Idle behaviors + +Small ambient animations that make the office feel alive when agents are between tasks. + +### Essential (implement first) + +- Orchestrator idle: occasional look-around animation at desk +- Working agents: subtle head bob or typing motion (if sprite supports it) +- Thought bubble cycling (already described above) + +### Nice-to-have (implement later) + +- Idle agents drift slightly from their exact grid position +- Occasional stretch or coffee-break animation +- Animated objects from the tileset (clock, spinning fan, blinking monitor) + +Keep idle behaviors subtle. They're seasoning, not the main dish. If an idle animation draws more attention than a state change, it's too prominent. + +## Excalibur patterns + +### Actor lifecycle + +```typescript +// Create an agent actor +const agent = new Actor({ pos: vec(x, y) }); +agent.graphics.use(spriteSheet.getSprite(col, row)); +scene.add(agent); + +// Move along waypoints +for (const waypoint of waypoints) { + agent.actions.moveTo(waypoint.x, waypoint.y, speed); +} + +// Remove when done +agent.actions.fade(0, 400).die(); +``` + +### Sprite integration + +Character idle sprites are 32x64 (1x2 tiles), 4 directions per row. Use `SpriteSheet.fromImageSource()` with grid configuration to extract directional frames. + +Furniture singles are 64x96 (2x3 tiles). Render as static sprites at tile positions. + +### Action queues + +Excalibur's `actor.actions` supports chaining: `.moveTo().delay().moveTo().callMethod()`. Use this for the orchestrator's errand pattern: + +```typescript +orchestrator.actions + .moveTo(doorway.x, doorway.y, walkSpeed) + .moveTo(destination.x, destination.y, walkSpeed) + .delay(pauseMs) + .callMethod(() => performInteraction()) + .moveTo(doorway.x, doorway.y, walkSpeed) + .moveTo(desk.x, desk.y, walkSpeed); +``` + +### Consuming TransitionPlan + +The transition planner outputs `Transition[]` with `delayMs` for staggering. Process them with: + +```typescript +for (const transition of plan.transitions) { + setTimeout(() => executeTransition(transition), transition.delayMs); +} +``` + +Each `executeTransition` dispatches to a handler based on `transition.type` (walk, state_change, fade_in, etc.). + +## Reference games + +Draw inspiration from these — specifically their sense of life and activity, not their mechanics: + +- **The Sims** — character pathfinding between rooms, interaction animations at objects, "needs" indicators as subtle UI +- **Two Point Hospital** — staff walking purposefully between rooms, clear room purposes visible from room contents, queue/wait indicators +- **Overcooked** — clear task states (raw → cooking → done → burned), timer urgency, parallel activity +- **Game Dev Tycoon** — development phase progression, team members at desks with visible work states, progress indicators +- **Stardew Valley** — ambient life (NPCs walking routes, seasonal changes), warm pixel art quality + +## What NOT to do + +- Don't animate everything at the same intensity. Healthy progress should be calm. +- Don't add screen shake, flash effects, or sound for normal events. +- Don't make agents wander randomly. All movement is purposeful and driven by the pipeline state. +- Don't prioritize animation polish over readability. A clear static scene beats a confusing animated one. +- Don't implement complex pathfinding. All paths are pre-computed waypoint sequences. +- Don't fight the adapter pipeline. It already produces the right data — consume `TransitionPlan`, don't re-derive transitions from raw state. diff --git a/.agents/skills/office-visual-design/SKILL.md b/.agents/skills/office-visual-design/SKILL.md new file mode 100644 index 00000000..e62405a9 --- /dev/null +++ b/.agents/skills/office-visual-design/SKILL.md @@ -0,0 +1,187 @@ +--- +name: office-visual-design +description: Visual design principles for the office visualization — palette, tile selection, composition, and spatial quality +user-invocable: false +--- + +# Office visual design + +Principles for visual design work on the CodeAssembly office visualization. This skill encodes taste, not mechanics — it helps agents make judgment calls about color, composition, texture, and placement. + +## Settled decisions + +These are not open for re-evaluation: + +- **The visualization is a place, not a diagram.** Rooms, walls, corridors, furnished interiors. The building exists before any data populates it. +- **Warm, light office aesthetic.** Inspired by The Sims, Stardew Valley, Habbo Hotel. No dark mode. No cyberpunk. No near-black backgrounds. Comfortable for extended viewing. +- **Three-zone layout.** Prep area (top-left), workshop (top-right), governor's office (bottom-right). Info panel occupies bottom-left. +- **Decorative items at the periphery.** Plants in corners, bookshelves against walls. Workstations hold functional items only — desks, monitors, boards. No mid-room decoration. +- **LimeZu tilesets are the art source.** Modern Office Revamped + Modern Interiors. No mixing with other tileset families. + +## Palette + +The palette is warm, muted, and professional. Avoid saturated primaries and cool-dominant schemes. + +### Canvas and structure + +| Element | Color | Notes | +| ---------------------- | ---------------------------------------------- | ------------------------------------- | +| Page background | `#e8e4e0` | Light warm gray, surrounds the canvas | +| Room floors | Cream tile (sheet 96,0) | Warm off-white | +| Transition strip | Gray tile (sheet 288,0) | Neutral connector between zones | +| Side walls | `#b0a898` | Warm taupe, solid fill | +| North walls | Tile sheet (32,256) upper + (32,288) baseboard | Two rows tall | +| Floor shadows | Tile sheet (0,0) | Draw at row y+2 below north walls | +| Info panel placeholder | `#ddd9d4` | Slightly darker than page background | + +### Semantic colors + +| Role | Color | Used for | +| -------------------- | ----------------------- | ---------------------------------------------- | +| Analyst phases | `#4488cc` | Architecture, planning — blue | +| Author phases | `#b8960a` | Implementation — amber | +| Reviewer phases | `#cc4444` | Code review, silent failure, test review — red | +| Orchestrator/summary | `#4a9` or similar green | Consolidated findings, final summary | + +### UI text + +| Element | Color | +| -------------- | ---------------------- | +| Primary text | `#333` | +| Secondary text | `#555` | +| Muted text | `#888` | +| Room labels | `rgba(80,80,100,0.55)` | +| Agent labels | `rgba(60,60,80,0.65)` | + +## Tileset catalog + +Base path: `/Users/william/Library/Mobile Documents/com~apple~CloudDocs/Resources/Tilesets/` + +### Modern Office Revamped v1.2 + +Office-specific furniture. The primary source for desks, monitors, whiteboards, and office equipment. + +- **Singles** (individual furniture items, 64x96 PNG each): `4_Modern_Office_singles/32x32/Modern_Office_Singles_32x32_{NNN}.png` +- Key singles used in prototypes: 100 (plant), 130 (monitor), 133 (review monitor), 170 (whiteboard), 172 (analysis board), 174 (data dashboard), 180 (desk), 200 (bookshelf), 227 (coder PC) +- Browse the full catalog (152+ items) to find the right piece. Don't default to the items above — they were chosen quickly. Better options may exist. + +### Modern Interiors + +Broader interiors: rooms, characters, animated objects, UI elements. + +- **Room builder subfiles** (32x32 tile sheets): `1_Interiors/32x32/Room_Bulder_subfiles_32x32/` + - `Room_Builder_Floors_32x32.png` — floor tiles + - `Room_Builder_Walls_32x32.png` — wall tiles + - `Room_Builder_Floor_Shadows_32x32.png` — shadows below north walls +- **Characters** (idle sprites, 32x64): `2_Characters/Old/Single_Characters_Legacy/32x32/{Name}_idle_32x32.png` + - Direction: column 0 = down (facing camera), 1 = left, 2 = right, 3 = up + - Current cast: Adam (orchestrator), Alex (architect), Amelia (planner), Bob (code reviewer), Ash (silent failure reviewer), Dan (coder), Rob (test reviewer) +- **Animated objects** (317 items): `3_Animated_Objects/` — potential source for ambient life + +### Tile picker tool + +A browsable tile picker is available at the artifact path for ticket #293. Use it to explore available tiles with click-to-copy coordinates. + +## Tile construction + +### Grid + +- Tile size: 32x32 pixels +- Furniture singles: 64x96 pixels (2x3 tiles) +- Character idle sprites: 32x64 pixels (1x2 tiles) + +### Wall construction + +North walls are two tiles tall: + +1. Upper wall tile at row `y` — sheet coords (32, 256) +2. Baseboard tile at row `y+1` — sheet coords (32, 288) +3. Floor shadow at row `y+2` — sheet coords (0, 0) on the shadow sheet + +South walls (bottom of upper rooms) are a single baseboard row. + +Side walls are solid-color fills (`#b0a898`) with a subtle inner-edge stroke at `rgba(80,70,60,0.25)`. + +Skip wall tiles at doorway positions (typically 3 tiles wide). + +### Floor layering + +Draw in this order: + +1. Room floors (cream tiles) +2. Transition strip (gray tiles) +3. Gray tiles through doorway openings +4. North walls (on top of floor) +5. Floor shadows (on top of floor, below wall) +6. Side walls +7. Furniture (on top of floor) +8. Characters (on top of everything) + +## Composition principles + +### Place, not diagram + +Every room should look like somewhere people work. Test: if you removed the labels and data overlays, would a viewer recognize this as an office? If it looks like a grid of colored rectangles, it's a diagram. + +### Spatial rhythm + +- Alternate dense areas (workstations, furniture clusters) with breathing room (open floor, corridors). +- A room that's all furniture feels like a warehouse. A room that's all floor feels empty. +- Group related items: desk + monitor + character form a workstation unit. Don't scatter them. + +### Workstation composition + +A workstation is a tight cluster: wall-mounted display (on the north wall) → desk (1-2 tiles below) → character (1 tile below desk, facing up toward the display). Keep this vertical stack consistent across all stations. + +### Furniture as storytelling + +Each piece of furniture should earn its place. Ask: what does this object tell the viewer about this room's purpose? + +- Analysis board + whiteboard → this room is for planning +- Multiple review monitors → this room is for parallel evaluation +- Data dashboard + delivery desks → this room is for coordination and output +- Bookshelf, plant → ambient warmth (periphery only) + +### Negative space + +Leave at least 2 tiles of open floor between workstation clusters. The orchestrator needs walking paths between stations, and visual crowding makes the scene unreadable. + +### Scale and proportion + +Rooms should feel proportional to their occupancy: + +- 2 agents → 11 tiles wide is comfortable +- 4 agents → 23 tiles wide is comfortable +- 1 agent → 17 tiles wide is generous (governor's office includes delivery surface and control console) + +## Quality rubric + +Use these criteria to evaluate visual output. Ordered by importance. + +| Criterion | Question | Failure mode | +| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **Readability** | Can you identify each room, agent, and furniture item at a glance? | Visual clutter, overlapping sprites, text over busy backgrounds | +| **Place quality** | Does this look like a real office, or a technical diagram? | Grid-snapped rectangles, no wall depth, uniform floor | +| **Palette coherence** | Do the colors feel unified and warm? | Jarring accent colors, cool-dominant tones, saturated primaries | +| **Composition** | Is there visual rhythm — dense clusters alternating with open space? | Uniform density, furniture scattered randomly, no breathing room | +| **Periphery rule** | Are decorative items at room edges, not between workstations? | Plants between desks, lamps in walkways, bookshelves blocking paths | +| **Storytelling** | Does the furniture tell you what happens in each room? | Generic desks everywhere, no room differentiation | + +## Build script conventions + +Prototypes use CJS Node.js scripts that: + +1. Read PNG files from the tileset base path +2. Convert to base64 data URIs +3. Embed into a self-contained HTML file with canvas rendering + +Output to the artifact directory for the relevant ticket. Name build scripts `build-{name}.cjs` and generated HTML `{name}.html`. + +## Reference games + +Draw inspiration from these, not their mechanics but their visual quality: + +- **The Sims** — room composition, object interaction proximity, warm interior lighting +- **Stardew Valley** — pixel art warmth, natural placement, seasonal color palettes +- **Habbo Hotel** — isometric room design, furniture density, avatar placement in spaces +- **Two Point Hospital** — room-based organization, clear room purposes, staff at stations diff --git a/packages/factory/src/client/components/OfficeCanvas.tsx b/packages/factory/src/client/components/OfficeCanvas.tsx new file mode 100644 index 00000000..e75b9e5f --- /dev/null +++ b/packages/factory/src/client/components/OfficeCanvas.tsx @@ -0,0 +1,75 @@ +import { DisplayMode, Engine } from 'excalibur'; +import React, { useEffect, useRef } from 'react'; + +import type { CanonicalRunStatus } from '../../shared/types/canonical.js'; +import { useContainerResize } from '../hooks/useContainerResize.js'; +import { CANVAS_HEIGHT_PX, CANVAS_WIDTH_PX } from '../visualizations/office/constants/dimensions.js'; +import { OfficeScene } from '../visualizations/office/scene/OfficeScene.js'; + +import './canvas.css'; + +interface OfficeCanvasProps { + status: CanonicalRunStatus; +} + +/** Renders the office visualization by managing an Excalibur engine lifecycle tied to a canvas element. */ +export function OfficeCanvas({ status }: OfficeCanvasProps): React.JSX.Element { + const canvasRef = useRef(null); + const engineRef = useRef(null); + const initializedRef = useRef(false); + const startFailedRef = useRef(false); + + useContainerResize(canvasRef, engineRef); + + useEffect(() => { + if (!canvasRef.current) return; + + const engine = new Engine({ + canvasElement: canvasRef.current, + width: CANVAS_WIDTH_PX, + height: CANVAS_HEIGHT_PX, + displayMode: DisplayMode.FitContainer, + }); + + const scene = new OfficeScene(status); + engine.addScene('office', scene); + void engine.goToScene('office'); + + void engine + .start() + .then(() => { + initializedRef.current = true; + return; + }) + .catch((error: unknown) => { + startFailedRef.current = true; + console.error('Failed to start Excalibur engine:', error); + }); + + engineRef.current = engine; + + return () => { + initializedRef.current = false; + startFailedRef.current = false; + engine.stop(); + }; + }, []); + + // Update scene when status changes (skip if engine not yet initialized) + useEffect(() => { + if (!engineRef.current || !initializedRef.current) { + if (startFailedRef.current) { + console.warn('OfficeCanvas: status update dropped because engine failed to start'); + } else { + console.debug('OfficeCanvas: status update dropped while engine is initializing'); + } + return; + } + const scene = engineRef.current.scenes.office; + if (scene instanceof OfficeScene) { + scene.updateStatus(status); + } + }, [status]); + + return ; +} diff --git a/packages/factory/src/client/components/__tests__/OfficeCanvas.test.tsx b/packages/factory/src/client/components/__tests__/OfficeCanvas.test.tsx new file mode 100644 index 00000000..d813e549 --- /dev/null +++ b/packages/factory/src/client/components/__tests__/OfficeCanvas.test.tsx @@ -0,0 +1,146 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockRunStatus } from '../../../__test-helpers__/fixtures.js'; + +const { + mockEngineConstructor, + mockEngineStart, + mockEngineStop, + mockEngineAddScene, + mockEngineGoToScene, + mockUpdateStatus, +} = vi.hoisted(() => { + return { + mockEngineConstructor: vi.fn(), + mockEngineStart: vi.fn(), + mockEngineStop: vi.fn(), + mockEngineAddScene: vi.fn(), + mockEngineGoToScene: vi.fn(), + mockUpdateStatus: vi.fn(), + }; +}); + +vi.mock('excalibur', () => { + class MockEngine { + scenes: Record = {}; + constructor(config: Record) { + mockEngineConstructor(config); + } + start() { + mockEngineStart(); + return Promise.resolve(); + } + stop() { + mockEngineStop(); + } + addScene(name: string, scene: unknown) { + mockEngineAddScene(name, scene); + this.scenes[name] = scene; + } + goToScene(name: string) { + mockEngineGoToScene(name); + return Promise.resolve(); + } + } + + return { + Engine: MockEngine, + DisplayMode: { FitContainer: 'FitContainer' }, + }; +}); + +vi.mock('../../visualizations/office/scene/OfficeScene.js', () => ({ + OfficeScene: class MockOfficeScene { + updateStatus = mockUpdateStatus; + }, +})); + +vi.mock('../../visualizations/office/constants/dimensions.js', () => ({ + CANVAS_WIDTH_PX: 1152, + CANVAS_HEIGHT_PX: 704, + TILE_SIZE: 32, +})); + +vi.mock('../canvas.css', () => ({})); + +const { OfficeCanvas } = await import('../OfficeCanvas.js'); +const { OfficeScene } = await import('../../visualizations/office/scene/OfficeScene.js'); + +describe('OfficeCanvas', () => { + beforeEach(() => { + mockEngineConstructor.mockClear(); + mockEngineStart.mockClear(); + mockEngineStop.mockClear(); + mockEngineAddScene.mockClear(); + mockEngineGoToScene.mockClear(); + mockUpdateStatus.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders a canvas element', () => { + const status = createMockRunStatus(); + const { container } = render(); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + }); + + it('creates an Excalibur engine with correct canvas dimensions', () => { + const status = createMockRunStatus(); + render(); + + expect(mockEngineConstructor).toHaveBeenCalledTimes(1); + expect(mockEngineConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + canvasElement: expect.any(HTMLCanvasElement), + width: 1152, + height: 704, + displayMode: 'FitContainer', + }), + ); + }); + + it('adds an OfficeScene under the key "office"', () => { + const status = createMockRunStatus(); + render(); + + expect(mockEngineAddScene).toHaveBeenCalledWith('office', expect.any(OfficeScene)); + expect(mockEngineGoToScene).toHaveBeenCalledWith('office'); + }); + + it('starts the engine on mount', () => { + const status = createMockRunStatus(); + render(); + + expect(mockEngineStart).toHaveBeenCalledTimes(1); + }); + + it('stops the engine on unmount', () => { + const status = createMockRunStatus(); + const { unmount } = render(); + + unmount(); + + expect(mockEngineStop).toHaveBeenCalledTimes(1); + }); + + it('calls updateStatus on the scene when the status prop changes', async () => { + const initialStatus = createMockRunStatus({ status: 'in_progress' }); + const { rerender } = render(); + + // Wait for engine.start() promise to resolve so initializedRef becomes true + await act(async () => { + await Promise.resolve(); + }); + + const updatedStatus = createMockRunStatus({ runId: 'updated-run', status: 'completed' }); + rerender(); + + expect(mockUpdateStatus).toHaveBeenCalledTimes(1); + expect(mockUpdateStatus).toHaveBeenCalledWith(updatedStatus); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts b/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts index d1beac11..99c0954f 100644 --- a/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts +++ b/packages/factory/src/client/visualizations/office/__tests__/office-scene.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { LogicalSceneState } from '../../shared/types.js'; +import type { CanonicalRunStatus, Phases } from '../../../../shared/types/canonical.js'; // Track actors added/removed through the mock let actorCount = 0; @@ -14,8 +14,10 @@ vi.mock('excalibur', () => { class MockActor { pos = { x: 0, y: 0 }; graphics = new MockGraphic(); - constructor(opts?: { pos?: { x: number; y: number } }) { + z = 0; + constructor(opts?: { pos?: { x: number; y: number }; anchor?: { x: number; y: number }; z?: number }) { if (opts?.pos) this.pos = opts.pos; + if (opts?.z !== undefined) this.z = opts.z; } } class MockScene { @@ -28,132 +30,282 @@ vi.mock('excalibur', () => { actorCount--; } } + class MockCanvas { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + class MockImageSource { + image = {}; + url: string; + load = vi.fn().mockResolvedValue(undefined); + constructor(url: string, _opts?: unknown) { + this.url = url; + } + } + const MockSprite = { + from(_imageSource: unknown) { + return {}; + }, + }; return { Actor: MockActor, - Circle: class {}, + Canvas: MockCanvas, Color: { fromHex: (hex: string) => ({ hex }), Transparent: { hex: 'transparent' }, }, - Font: class {}, - Label: MockActor, + ImageFiltering: { Pixel: 'pixel' }, + ImageSource: MockImageSource, Rectangle: class {}, Scene: MockScene, - TextAlign: { Center: 'center' }, + Sprite: MockSprite, + SpriteSheet: { + fromImageSource: vi.fn().mockReturnValue({ + getSprite: vi.fn().mockReturnValue({ + type: 'sprite', + draw: vi.fn(), + }), + }), + }, vec: (x: number, y: number) => ({ x, y }), }; }); +// Mock sprite loader to avoid real image loads +vi.mock('../sprites/office-sprite-loader.js', () => ({ + loadOfficeSprites: vi.fn().mockResolvedValue(undefined), + getFloorSheet: vi.fn().mockReturnValue({ + getSprite: vi.fn().mockReturnValue({ type: 'sprite', draw: vi.fn() }), + }), + getWallSheet: vi.fn().mockReturnValue({ + getSprite: vi.fn().mockReturnValue({ type: 'sprite', draw: vi.fn() }), + }), + getShadowSheet: vi.fn().mockReturnValue({ + getSprite: vi.fn().mockReturnValue({ type: 'sprite', draw: vi.fn() }), + }), + getOfficeSheet: vi.fn().mockReturnValue({ + getSprite: vi.fn().mockReturnValue({ type: 'sprite', draw: vi.fn() }), + }), + getFloorImageSource: vi.fn().mockReturnValue({ image: {} }), + getWallImageSource: vi.fn().mockReturnValue({ image: {} }), + getShadowImageSource: vi.fn().mockReturnValue({ image: {} }), + getOfficeImageSource: vi.fn().mockReturnValue({ image: {} }), + getCharacterSprite: vi.fn().mockReturnValue({ type: 'sprite' }), + getSingleSprite: vi.fn().mockReturnValue({ type: 'sprite' }), +})); + // Import after mocking const { OfficeScene } = await import('../scene/OfficeScene.js'); -/** Build a minimal LogicalSceneState. */ -function logicalScene(overrides: Partial = {}): LogicalSceneState { +/** Build empty phases with all required keys set to undefined. */ +function emptyPhases(overrides: Partial = {}): Phases { return { - runStatus: 'in_progress', - currentPhase: undefined, - agents: [], - orchestrator: { - status: 'idle', - carriedArtifacts: [], - codeBadge: null, - waiting: false, - }, - artifacts: [], + architecture: undefined, + planning: undefined, + implementation: undefined, + parallelReview: undefined, + review: undefined, + codeSimplifier: undefined, + holisticReview: undefined, + ...overrides, + }; +} + +/** Build a minimal CanonicalRunStatus for scene construction. */ +function buildMinimalStatus(overrides: Partial = {}): CanonicalRunStatus { + return { + runId: 'test-run', + projectSlug: 'test-project', + ticketId: '1', + projectRoot: '/tmp/test', + branch: 'test', + task: 'test task', + startedAt: '2026-01-01T00:00:00Z', + completedAt: undefined, + status: 'in_progress', + reason: undefined, + externalPlan: undefined, + mergeBaseSha: undefined, + diffBase: undefined, + maxReviewRounds: undefined, + effort: undefined, + approvalThreshold: undefined, + budgetThreshold: undefined, + mode: undefined, + model: undefined, + waitingForInput: undefined, + phases: emptyPhases(), + phaseDecisions: undefined, + artifacts: undefined, ...overrides, }; } +// Minimum actor count after onInitialize: 1 background + 26 furniture + 1 orchestrator = 28 +const MIN_ACTOR_COUNT_AFTER_INIT = 28; + describe('OfficeScene', () => { it('can be constructed', () => { - const scene = new OfficeScene(); + const scene = new OfficeScene(buildMinimalStatus()); expect(scene).toBeDefined(); }); - it('draws zone rectangles on initialize', () => { + it('draws background and furniture on initialize', () => { actorCount = 0; - const scene = new OfficeScene(); + const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); - // 3 zones x 3 actors each (fill, border, label) = 9 - expect(actorCount).toBe(9); + // At minimum: 1 background + 26 furniture + 1 orchestrator + expect(actorCount).toBeGreaterThanOrEqual(MIN_ACTOR_COUNT_AFTER_INIT); }); - it('places entities when updateState is called', () => { + it('places agents from updateStatus', () => { actorCount = 0; - const scene = new OfficeScene(); + const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); const initialCount = actorCount; - scene.updateState( - logicalScene({ - agents: [{ id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'working' }], + // Add reviewers via parallelReview to get more agents + scene.updateStatus( + buildMinimalStatus({ + phases: emptyPhases({ + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + reviewers: { + codeReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + silentFailure: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + testReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + }, + }, + }), }), ); - // Should have added orchestrator + 1 agent - expect(actorCount).toBe(initialCount + 2); + // With 3 reviewers (up from the default 1), there should be at least 2 more actors + expect(actorCount).toBeGreaterThanOrEqual(initialCount + 2); }); it('handles empty state gracefully', () => { actorCount = 0; - const scene = new OfficeScene(); + const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); + const initialCount = actorCount; - // Should not throw - scene.updateState(logicalScene()); + // Should not throw; re-applying same status should not change count + scene.updateStatus(buildMinimalStatus()); - // 9 zone actors + 1 orchestrator - expect(actorCount).toBe(10); + expect(actorCount).toBe(initialCount); }); - it('clears and replaces entities on subsequent updateState calls', () => { + it('clears and replaces entities on subsequent updateStatus calls', () => { actorCount = 0; - const scene = new OfficeScene(); - scene.onInitialize(); - - scene.updateState( - logicalScene({ - agents: [ - { id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'working' }, - { id: 'a2', role: 'architect', roleType: 'analyst', phase: 'architecture', status: 'idle' }, - ], + // Start with 3 reviewers + const statusWith3Reviewers = buildMinimalStatus({ + phases: emptyPhases({ + parallelReview: { + aggregatedCriticality: undefined, + reviewRoundsUsed: 1, + coderFixCycleRan: false, + selectiveReReview: undefined, + reviewers: { + codeReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + silentFailure: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + testReviewer: { + ran: true, + status: 'completed', + criticality: 'low', + reason: undefined, + reReviewCriticality: undefined, + reReviewError: undefined, + }, + }, + }, }), - ); + }); + const scene = new OfficeScene(statusWith3Reviewers); + scene.onInitialize(); + const withReviewersCount = actorCount; - const firstCount = actorCount; + // Reduce to 1 default reviewer + scene.updateStatus(buildMinimalStatus()); + const withDefaultCount = actorCount; - scene.updateState( - logicalScene({ - agents: [{ id: 'a1', role: 'coder', roleType: 'author', phase: 'implementation', status: 'done' }], - }), - ); - - // Went from 2 agents to 1 agent, so should be one fewer - expect(actorCount).toBe(firstCount - 1); + // Fewer actors because 3 reviewers -> 1 default reviewer + expect(withDefaultCount).toBeLessThan(withReviewersCount); }); it('places artifacts at assigned positions', () => { actorCount = 0; - const scene = new OfficeScene(); + const scene = new OfficeScene(buildMinimalStatus()); scene.onInitialize(); + const initialCount = actorCount; - scene.updateState( - logicalScene({ + scene.updateStatus( + buildMinimalStatus({ + phases: emptyPhases({ + planning: { + status: 'completed', + stepCount: undefined, + artifacts: undefined, + startedAt: '2026-01-01T00:00:00Z', + }, + }), artifacts: [ { - id: 'art1', - label: 'plan', - color: '#00ff00', - status: 'delivered', - producerPhase: 'planning', + phase: 'planning', + type: 'plan', + role: 'planner', + filename: 'plan.md', + roleType: 'planner', + agent: 'planner-1', + createdAt: '2026-01-01T00:00:00Z', }, ], }), ); - // 9 zone actors + 1 orchestrator + 1 artifact - expect(actorCount).toBe(11); + // Should have added at least the artifact + expect(actorCount).toBeGreaterThan(initialCount); }); }); diff --git a/packages/factory/src/client/visualizations/office/constants/dimensions.ts b/packages/factory/src/client/visualizations/office/constants/dimensions.ts index a7f9c2eb..4dd358df 100644 --- a/packages/factory/src/client/visualizations/office/constants/dimensions.ts +++ b/packages/factory/src/client/visualizations/office/constants/dimensions.ts @@ -2,10 +2,10 @@ export const TILE_SIZE = 32; /** Canvas width in tiles. */ -export const CANVAS_COLS = 40; +export const CANVAS_COLS = 36; /** Canvas height in tiles. */ -export const CANVAS_ROWS = 30; +export const CANVAS_ROWS = 22; /** Canvas width in pixels. */ export const CANVAS_WIDTH_PX = CANVAS_COLS * TILE_SIZE; diff --git a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts index 1402bdaf..d9097e5b 100644 --- a/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts +++ b/packages/factory/src/client/visualizations/office/constants/zone-definitions.ts @@ -2,56 +2,59 @@ import type { ZoneDefinition } from '../types.js'; /** * Prep area zone: architecture and planning agents work here. - * Matches prototype tiles: { x: 1, y: 1, w: 11, h: 12 }. - * Display slots (prep-display-0) deferred to a future ticket. + * Layout: cols 1-11, rows 0-11 (11w x 12h). + * Two workstations (architect left, planner right), each facing wall-mounted displays. + * South door opens onto the transition strip at row 12. */ export const PREP_ZONE: ZoneDefinition = { id: 'prep', label: 'Prep area', - bounds: { col: 1, row: 1, width: 11, height: 12 }, + bounds: { col: 1, row: 0, width: 11, height: 12 }, slots: [ { id: 'prep-desk-0', type: 'workstation', tile: { col: 3, row: 5 } }, { id: 'prep-desk-1', type: 'workstation', tile: { col: 8, row: 5 } }, ], - doors: [{ tile: { col: 6, row: 13 }, direction: 'down' }], + doors: [{ tile: { col: 6, row: 12 }, direction: 'down' }], }; /** * Workshop zone: coder and reviewers work here. - * Matches prototype tiles: { x: 13, y: 1, w: 26, h: 12 }. - * Display slots (workshop-display-0) deferred to a future ticket. + * Layout: cols 13-35, rows 0-11 (23w x 12h). + * Coder station at left, up to 5 reviewer stations across the middle-right. + * South door opens onto the transition strip at row 12. */ export const WORKSHOP_ZONE: ZoneDefinition = { id: 'workshop', label: 'The workshop', - bounds: { col: 13, row: 1, width: 26, height: 12 }, + bounds: { col: 13, row: 0, width: 23, height: 12 }, slots: [ - { id: 'workshop-desk-0', type: 'workstation', tile: { col: 16, row: 5 } }, - { id: 'workshop-desk-1', type: 'workstation', tile: { col: 22, row: 10 } }, - { id: 'workshop-desk-2', type: 'workstation', tile: { col: 25, row: 10 } }, - { id: 'workshop-desk-3', type: 'workstation', tile: { col: 28, row: 10 } }, - { id: 'workshop-desk-4', type: 'workstation', tile: { col: 31, row: 10 } }, - { id: 'workshop-desk-5', type: 'workstation', tile: { col: 34, row: 10 } }, + { id: 'workshop-desk-0', type: 'workstation', tile: { col: 15, row: 5 } }, + { id: 'workshop-desk-1', type: 'workstation', tile: { col: 21, row: 7 } }, + { id: 'workshop-desk-2', type: 'workstation', tile: { col: 25, row: 7 } }, + { id: 'workshop-desk-3', type: 'workstation', tile: { col: 28, row: 7 } }, + { id: 'workshop-desk-4', type: 'workstation', tile: { col: 31, row: 7 } }, + { id: 'workshop-desk-5', type: 'workstation', tile: { col: 34, row: 7 } }, ], - doors: [{ tile: { col: 13, row: 7 }, direction: 'left' }], + doors: [{ tile: { col: 25, row: 12 }, direction: 'down' }], }; /** - * Governor's office zone: orchestrator home base with artifact storage. - * Matches prototype tiles: { x: 1, y: 16, w: 38, h: 13 }. - * Display slots (governor-display-0) deferred to a future ticket. + * Governor's office zone: orchestrator home base with delivery surface. + * Layout: cols 19-35, rows 13-21 (17w x 9h). + * Orchestrator desk at left, delivery desks at right, control console on north wall. + * North door opens onto the transition strip at row 12. */ export const GOVERNOR_ZONE: ZoneDefinition = { id: 'governor', label: "Governor's office", - bounds: { col: 1, row: 16, width: 38, height: 13 }, + bounds: { col: 19, row: 13, width: 17, height: 9 }, slots: [ - { id: 'governor-desk-0', type: 'workstation', tile: { col: 5, row: 20 } }, - { id: 'governor-storage-0', type: 'storage', tile: { col: 12, row: 18 } }, - { id: 'governor-storage-1', type: 'storage', tile: { col: 16, row: 18 } }, - { id: 'governor-storage-2', type: 'storage', tile: { col: 20, row: 18 } }, + { id: 'governor-desk-0', type: 'workstation', tile: { col: 23, row: 18 } }, + { id: 'governor-storage-0', type: 'storage', tile: { col: 28, row: 16 } }, + { id: 'governor-storage-1', type: 'storage', tile: { col: 31, row: 16 } }, + { id: 'governor-storage-2', type: 'storage', tile: { col: 34, row: 16 } }, ], - doors: [{ tile: { col: 6, row: 16 }, direction: 'up' }], + doors: [{ tile: { col: 26, row: 13 }, direction: 'up' }], }; /** All zone definitions for the 3-zone office layout. */ diff --git a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts index eba8d742..85e95f1f 100644 --- a/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts +++ b/packages/factory/src/client/visualizations/office/scene/OfficeScene.ts @@ -1,11 +1,23 @@ -import { Actor, Circle, Color, Font, Label, Rectangle, Scene, TextAlign, vec } from 'excalibur'; +import { Actor, Canvas, Color, type ImageSource, Rectangle, Scene, vec } from 'excalibur'; -import { getRoleTypeColor } from '../../../../shared/constants/role-types.js'; +import type { CanonicalRunStatus } from '../../../../shared/types/canonical.js'; +import { mapRunToLogicalScene } from '../../shared/run-to-logical-scene.js'; import type { LogicalSceneState } from '../../shared/types.js'; import { CANVAS_HEIGHT_PX, CANVAS_WIDTH_PX, TILE_SIZE } from '../constants/dimensions.js'; +import { GOVERNOR_ZONE, PREP_ZONE, WORKSHOP_ZONE } from '../constants/zone-definitions.js'; import { createOfficeLayout } from '../layout/office-layout.js'; import { resolvePositions } from '../layout/position-resolver.js'; import { mapLogicalToOffice } from '../mappers/logical-to-office.js'; +import { + getCharacterSprite, + getFloorImageSource, + getOfficeImageSource, + getShadowImageSource, + getSingleSprite, + getWallImageSource, + loadOfficeSprites, +} from '../sprites/office-sprite-loader.js'; +import { DIR_DOWN, DIR_UP, FURNITURE_MANIFEST, resolveCharacterName } from '../sprites/sprite-definitions.js'; import { diffOfficeConfigs } from '../state/office-differ.js'; import type { FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } from '../types.js'; @@ -13,31 +25,33 @@ import type { FacilityLayout, OfficeSceneConfig, Position, ResolvedPositions } f // Visual constants // --------------------------------------------------------------------------- -const AGENT_RADIUS = 12; -const ORCHESTRATOR_RADIUS = 16; const ARTIFACT_WIDTH = 24; const ARTIFACT_HEIGHT = 12; +const SIDE_WALL_COLOR = '#b0a898'; -const ZONE_COLORS: Record = { - prep: '#4A90D9', - workshop: '#D97B4A', - governor: '#6B8E23', -}; - -const ZONE_BORDER_OPACITY = 0.3; -const ZONE_FILL_OPACITY = 0.08; +// Tile source pixel coordinates in sprite sheets +const FLOOR_TILE_SX = 96; // col 3 * 32 +const FLOOR_TILE_SY = 0; // row 0 * 32 +const GRAY_TILE_SX = 288; // col 9 * 32 +const GRAY_TILE_SY = 0; // row 0 * 32 +const WALL_UPPER_SX = 32; // col 1 * 32 +const WALL_UPPER_SY = 256; // row 8 * 32 +const WALL_BASE_SX = 32; // col 1 * 32 +const WALL_BASE_SY = 288; // row 9 * 32 +const SHADOW_SX = 0; // col 0 * 32 +const SHADOW_SY = 0; // row 0 * 32 // --------------------------------------------------------------------------- // OfficeScene // --------------------------------------------------------------------------- /** - * Excalibur scene that renders the office visualization pipeline: - * LogicalSceneState -> adapter -> differ -> position resolver -> transition planner -> render. - * Uses geometric placeholders for all entities. + * Excalibur scene that renders the office visualization with LimeZu tileset sprites. + * Pipeline: CanonicalRunStatus -> LogicalSceneState -> adapter -> differ -> position resolver -> render. */ export class OfficeScene extends Scene { private readonly layout: FacilityLayout; + private status: CanonicalRunStatus; private prevConfig: OfficeSceneConfig | undefined; // Actor registries for incremental updates @@ -45,19 +59,32 @@ export class OfficeScene extends Scene { private artifactActors = new Map(); private orchestratorActor: Actor | undefined; - constructor() { + constructor(status: CanonicalRunStatus) { super(); + this.status = status; this.layout = createOfficeLayout(); this.backgroundColor = Color.fromHex('#e8e4e0'); } override onInitialize(): void { - this.drawZones(); + loadOfficeSprites().catch((error: unknown) => { + console.error('[OfficeScene] Failed to load sprites:', error); + }); + + this.drawBackground(); + this.placeFurniture(); + this.updateStatus(this.status); this.positionCamera(); } - /** Apply a new logical scene state, running the full pipeline. */ - updateState(logical: LogicalSceneState): void { + /** Apply a new run status, running the full visualization pipeline. */ + updateStatus(status: CanonicalRunStatus): void { + this.status = status; + this.updateState(mapRunToLogicalScene(status)); + } + + /** Apply a new logical scene state, running the spatial pipeline. */ + private updateState(logical: LogicalSceneState): void { try { const nextConfig = mapLogicalToOffice(logical); const nextPositions = resolvePositions(nextConfig, this.layout); @@ -67,13 +94,14 @@ export class OfficeScene extends Scene { } else { const diff = diffOfficeConfigs(this.prevConfig, nextConfig); if (diff.hasChanges) { - // Teleport entities to new positions (no animation in this ticket) this.applyFullState(nextConfig, nextPositions); } } this.prevConfig = nextConfig; } catch (error) { + // Intentional silent degradation: the scene freezes at its last good state. + // prevConfig is NOT updated on failure, so the next call will re-attempt a full render. console.error('[OfficeScene] updateState failed:', error); } } @@ -89,7 +117,7 @@ export class OfficeScene extends Scene { for (const agent of config.agents) { const pos = positions.agents.get(agent.id); if (pos !== undefined) { - this.placeAgent(agent.id, agent.roleType, pos); + this.placeAgent(agent.id, agent.phase, pos); } } @@ -120,86 +148,80 @@ export class OfficeScene extends Scene { this.artifactActors.clear(); } - /** Draw zone rectangles as labeled geometric areas. */ - private drawZones(): void { - for (const zone of this.layout.zones) { - const x = zone.bounds.col * TILE_SIZE; - const y = zone.bounds.row * TILE_SIZE; - const width = zone.bounds.width * TILE_SIZE; - const height = zone.bounds.height * TILE_SIZE; - const color = ZONE_COLORS[zone.id] ?? '#888888'; - - // Zone fill - const fill = new Actor({ pos: vec(x + width / 2, y + height / 2) }); - fill.graphics.use( - new Rectangle({ - width, - height, - color: Color.fromHex(color), - }), - ); - fill.graphics.opacity = ZONE_FILL_OPACITY; - this.add(fill); - - // Zone border - const border = new Actor({ pos: vec(x + width / 2, y + height / 2) }); - border.graphics.use( - new Rectangle({ - width, - height, - color: Color.Transparent, - strokeColor: Color.fromHex(color), - lineWidth: 2, - }), - ); - border.graphics.opacity = ZONE_BORDER_OPACITY; - this.add(border); - - // Zone label - const label = new Label({ - text: zone.label, - pos: vec(x + width / 2, y + 12), - font: new Font({ - family: 'monospace', - size: 10, - color: Color.fromHex(color), - textAlign: TextAlign.Center, - }), + /** Draw the tiled background onto a single Canvas graphic actor. */ + private drawBackground(): void { + const floorImage = getFloorImageSource(); + const wallImage = getWallImageSource(); + const shadowImage = getShadowImageSource(); + + const bgCanvas = new Canvas({ + width: CANVAS_WIDTH_PX, + height: CANVAS_HEIGHT_PX, + draw: (ctx) => { + drawFloors(ctx, floorImage); + drawTransitionStrip(ctx, floorImage); + drawNorthWalls(ctx, wallImage); + drawFloorShadows(ctx, shadowImage); + drawSideWalls(ctx); + drawZoneBoundaryWall(ctx); + }, + }); + + const bgActor = new Actor({ pos: vec(0, 0), anchor: vec(0, 0), z: 0 }); + bgActor.graphics.use(bgCanvas); + this.add(bgActor); + } + + /** Place static furniture actors from the manifest. */ + private placeFurniture(): void { + for (const item of FURNITURE_MANIFEST) { + const actor = new Actor({ + pos: vec(item.tx * TILE_SIZE, item.ty * TILE_SIZE), + anchor: vec(0, 0), + z: 1, }); - this.add(label); + + if (item.asset !== undefined) { + actor.graphics.use(getSingleSprite(item.asset)); + } else if (item.region !== undefined) { + const region = item.region; + const officeImage = getOfficeImageSource(); + const regionCanvas = new Canvas({ + width: region.sw, + height: region.sh, + draw: (ctx) => { + ctx.drawImage(officeImage.image, region.sx, region.sy, region.sw, region.sh, 0, 0, region.sw, region.sh); + }, + }); + actor.graphics.use(regionCanvas); + } + + this.add(actor); } } - /** Place a colored circle for an agent at the given position. */ - private placeAgent(agentId: string, roleType: string, pos: Position): void { - const color = getRoleTypeColor(roleType); - const actor = new Actor({ pos: vec(pos.x, pos.y) }); - actor.graphics.use( - new Circle({ - radius: AGENT_RADIUS, - color: Color.fromHex(color), - }), - ); + /** Place a character sprite for an agent at the given position. */ + private placeAgent(agentId: string, phase: string, pos: Position): void { + const characterName = resolveCharacterName(phase, agentId); + const sprite = getCharacterSprite(characterName, DIR_UP); + const actor = new Actor({ pos: vec(pos.x, pos.y), anchor: vec(0.5, 1), z: 2 }); + actor.graphics.use(sprite); this.add(actor); this.agentActors.set(agentId, actor); } - /** Place a larger circle for the orchestrator at the given position. */ + /** Place the orchestrator (Adam) sprite facing the camera. */ private placeOrchestrator(pos: Position): void { - const actor = new Actor({ pos: vec(pos.x, pos.y) }); - actor.graphics.use( - new Circle({ - radius: ORCHESTRATOR_RADIUS, - color: Color.fromHex('#FF55FF'), - }), - ); + const sprite = getCharacterSprite('Adam', DIR_DOWN); + const actor = new Actor({ pos: vec(pos.x, pos.y), anchor: vec(0.5, 1), z: 2 }); + actor.graphics.use(sprite); this.add(actor); this.orchestratorActor = actor; } /** Place a colored rectangle for an artifact at the given position. */ private placeArtifact(artifactId: string, color: string, pos: Position): void { - const actor = new Actor({ pos: vec(pos.x, pos.y) }); + const actor = new Actor({ pos: vec(pos.x, pos.y), z: 3 }); actor.graphics.use( new Rectangle({ width: ARTIFACT_WIDTH, @@ -216,3 +238,106 @@ export class OfficeScene extends Scene { this.camera.pos = vec(CANVAS_WIDTH_PX / 2, CANVAS_HEIGHT_PX / 2); } } + +// --------------------------------------------------------------------------- +// Background drawing helpers (use Canvas2D API directly) +// --------------------------------------------------------------------------- + +/** Draw a single tile from a sprite sheet image onto the canvas. */ +function drawTile( + ctx: CanvasRenderingContext2D, + image: ImageSource, + sx: number, + sy: number, + dx: number, + dy: number, +): void { + ctx.drawImage(image.image, sx, sy, TILE_SIZE, TILE_SIZE, dx, dy, TILE_SIZE, TILE_SIZE); +} + +/** Fill all room areas with cream floor tiles. */ +function drawFloors(ctx: CanvasRenderingContext2D, floorImage: ImageSource): void { + const cols = CANVAS_WIDTH_PX / TILE_SIZE; + const rows = CANVAS_HEIGHT_PX / TILE_SIZE; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + drawTile(ctx, floorImage, FLOOR_TILE_SX, FLOOR_TILE_SY, c * TILE_SIZE, r * TILE_SIZE); + } + } +} + +/** Draw the gray transition strip at row 12 across the full width. */ +function drawTransitionStrip(ctx: CanvasRenderingContext2D, floorImage: ImageSource): void { + const cols = CANVAS_WIDTH_PX / TILE_SIZE; + for (let c = 0; c < cols; c++) { + drawTile(ctx, floorImage, GRAY_TILE_SX, GRAY_TILE_SY, c * TILE_SIZE, 12 * TILE_SIZE); + } +} + +/** Draw north walls (upper + baseboard) for prep, workshop, and governor zones. */ +function drawNorthWalls(ctx: CanvasRenderingContext2D, wallImage: ImageSource): void { + // Prep and workshop north walls at rows 0-1 + const prepStart = PREP_ZONE.bounds.col; + const workshopEnd = WORKSHOP_ZONE.bounds.col + WORKSHOP_ZONE.bounds.width; + + for (let c = prepStart; c < workshopEnd; c++) { + // Skip zone boundary wall column (drawn separately as solid fill) + if (c === 12) continue; + drawTile(ctx, wallImage, WALL_UPPER_SX, WALL_UPPER_SY, c * TILE_SIZE, 0); + drawTile(ctx, wallImage, WALL_BASE_SX, WALL_BASE_SY, c * TILE_SIZE, TILE_SIZE); + } + + // Governor's office north wall at rows 13-14 + const govStart = GOVERNOR_ZONE.bounds.col; + const govEnd = govStart + GOVERNOR_ZONE.bounds.width; + const govDoorCols = new Set(); + for (const door of GOVERNOR_ZONE.doors) { + for (let d = -1; d <= 1; d++) { + govDoorCols.add(door.tile.col + d); + } + } + + for (let c = govStart; c < govEnd; c++) { + if (govDoorCols.has(c)) continue; + drawTile(ctx, wallImage, WALL_UPPER_SX, WALL_UPPER_SY, c * TILE_SIZE, 13 * TILE_SIZE); + drawTile(ctx, wallImage, WALL_BASE_SX, WALL_BASE_SY, c * TILE_SIZE, 14 * TILE_SIZE); + } +} + +/** Draw floor shadow tiles below each north wall. */ +function drawFloorShadows(ctx: CanvasRenderingContext2D, shadowImage: ImageSource): void { + // Shadow at row 2 for prep/workshop walls + const prepStart = PREP_ZONE.bounds.col; + const workshopEnd = WORKSHOP_ZONE.bounds.col + WORKSHOP_ZONE.bounds.width; + for (let c = prepStart; c < workshopEnd; c++) { + drawTile(ctx, shadowImage, SHADOW_SX, SHADOW_SY, c * TILE_SIZE, 2 * TILE_SIZE); + } + + // Shadow at row 15 for governor wall + const govStart = GOVERNOR_ZONE.bounds.col; + const govEnd = govStart + GOVERNOR_ZONE.bounds.width; + for (let c = govStart; c < govEnd; c++) { + drawTile(ctx, shadowImage, SHADOW_SX, SHADOW_SY, c * TILE_SIZE, 15 * TILE_SIZE); + } +} + +/** Draw solid-fill side walls along the left and right borders. */ +function drawSideWalls(ctx: CanvasRenderingContext2D): void { + const rows = CANVAS_HEIGHT_PX / TILE_SIZE; + ctx.fillStyle = SIDE_WALL_COLOR; + + // Left wall (column 0) + ctx.fillRect(0, 0, TILE_SIZE, rows * TILE_SIZE); + + // Right wall (last column) - only for rows covered by zones + const rightCol = CANVAS_WIDTH_PX - TILE_SIZE; + ctx.fillRect(rightCol, 0, TILE_SIZE, 12 * TILE_SIZE); + ctx.fillRect(rightCol, 13 * TILE_SIZE, TILE_SIZE, 9 * TILE_SIZE); +} + +/** Draw the vertical wall strip between the prep and workshop zones. */ +function drawZoneBoundaryWall(ctx: CanvasRenderingContext2D): void { + ctx.fillStyle = SIDE_WALL_COLOR; + ctx.fillRect(12 * TILE_SIZE, 0, TILE_SIZE, 12 * TILE_SIZE); +} diff --git a/packages/factory/src/client/visualizations/office/sprites/__tests__/office-sprite-loader.test.ts b/packages/factory/src/client/visualizations/office/sprites/__tests__/office-sprite-loader.test.ts new file mode 100644 index 00000000..39dd70e7 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/sprites/__tests__/office-sprite-loader.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockImageSourceConstructor, mockImageSourceLoad, mockSpriteSheetFromImageSource, mockSpriteFrom } = vi.hoisted( + () => { + const mockLoad = vi.fn().mockResolvedValue(undefined); + return { + mockImageSourceConstructor: vi.fn(), + mockImageSourceLoad: mockLoad, + mockSpriteSheetFromImageSource: vi.fn(), + mockSpriteFrom: vi.fn(), + }; + }, +); + +vi.mock('excalibur', () => { + class MockImageSource { + url: string; + options: Record; + load = mockImageSourceLoad; + + constructor(url: string, options: Record) { + mockImageSourceConstructor(url, options); + this.url = url; + this.options = options; + } + } + + const MockSpriteSheet = { + fromImageSource: mockSpriteSheetFromImageSource, + }; + + const MockSprite = { + from(_imageSource: unknown) { + mockSpriteFrom(_imageSource); + return { type: 'sprite' }; + }, + }; + + return { + ImageSource: MockImageSource, + ImageFiltering: { Pixel: 'pixel' }, + SpriteSheet: MockSpriteSheet, + Sprite: MockSprite, + }; +}); + +const { + loadOfficeSprites, + clearOfficeSpriteCache, + getFloorSheet, + getWallSheet, + getShadowSheet, + getOfficeSheet, + getCharacterSprite, + getSingleSprite, + getFloorImageSource, + getWallImageSource, + getShadowImageSource, + getOfficeImageSource, +} = await import('../office-sprite-loader.js'); +const { ROOM_SHEET_URLS, CHARACTER_URLS, SINGLE_ASSET_URLS } = await import('../sprite-sheet-urls.js'); + +describe('office-sprite-loader', () => { + beforeEach(() => { + clearOfficeSpriteCache(); + vi.clearAllMocks(); + + mockSpriteSheetFromImageSource.mockReturnValue({ + type: 'sprite-sheet', + getSprite: vi.fn().mockReturnValue({ type: 'sprite' }), + }); + }); + + afterEach(() => { + clearOfficeSpriteCache(); + }); + + describe(loadOfficeSprites, () => { + it('creates 23 ImageSource instances (4 room + 7 character + 12 singles)', async () => { + await loadOfficeSprites(); + + expect(mockImageSourceConstructor).toHaveBeenCalledTimes(23); + }); + + it('loads all 23 image sources', async () => { + await loadOfficeSprites(); + + expect(mockImageSourceLoad).toHaveBeenCalledTimes(23); + }); + + it('passes the correct URL for each room sheet', async () => { + await loadOfficeSprites(); + + expect(mockImageSourceConstructor).toHaveBeenCalledWith(ROOM_SHEET_URLS.floors, expect.anything()); + expect(mockImageSourceConstructor).toHaveBeenCalledWith(ROOM_SHEET_URLS.walls, expect.anything()); + expect(mockImageSourceConstructor).toHaveBeenCalledWith(ROOM_SHEET_URLS.shadows, expect.anything()); + expect(mockImageSourceConstructor).toHaveBeenCalledWith(ROOM_SHEET_URLS.office, expect.anything()); + }); + + it('passes the correct URL for each character', async () => { + await loadOfficeSprites(); + + for (const url of Object.values(CHARACTER_URLS)) { + expect(mockImageSourceConstructor).toHaveBeenCalledWith(url, expect.anything()); + } + }); + + it('passes the correct URL for each single asset', async () => { + await loadOfficeSprites(); + + for (const url of Object.values(SINGLE_ASSET_URLS)) { + expect(mockImageSourceConstructor).toHaveBeenCalledWith(url, expect.anything()); + } + }); + + it('is idempotent (second call is a no-op)', async () => { + await loadOfficeSprites(); + await loadOfficeSprites(); + + expect(mockImageSourceConstructor).toHaveBeenCalledTimes(23); + expect(mockImageSourceLoad).toHaveBeenCalledTimes(23); + }); + + it('deduplicates concurrent calls via shared in-flight promise', async () => { + const first = loadOfficeSprites(); + const second = loadOfficeSprites(); + + await Promise.all([first, second]); + + expect(mockImageSourceConstructor).toHaveBeenCalledTimes(23); + expect(mockImageSourceLoad).toHaveBeenCalledTimes(23); + }); + + it('resets cache on load failure so subsequent calls can retry', async () => { + mockImageSourceLoad.mockRejectedValueOnce(new Error('network error')); + + await expect(loadOfficeSprites()).rejects.toThrow('network error'); + expect(() => getFloorSheet()).toThrow('Office sprites have not been loaded'); + + // Retry succeeds + mockImageSourceLoad.mockResolvedValue(undefined); + await loadOfficeSprites(); + + expect(getFloorSheet()).toBeDefined(); + // 23 for failed attempt + 23 for retry = 46 + expect(mockImageSourceConstructor).toHaveBeenCalledTimes(46); + }); + + it('creates 11 sprite sheets (4 room + 7 character)', async () => { + await loadOfficeSprites(); + + expect(mockSpriteSheetFromImageSource).toHaveBeenCalledTimes(11); + }); + + it('creates 12 single sprites via Sprite.from', async () => { + await loadOfficeSprites(); + + expect(mockSpriteFrom).toHaveBeenCalledTimes(12); + }); + }); + + describe('getters before loading', () => { + it.each([ + ['getFloorSheet', () => getFloorSheet()], + ['getWallSheet', () => getWallSheet()], + ['getShadowSheet', () => getShadowSheet()], + ['getOfficeSheet', () => getOfficeSheet()], + ['getFloorImageSource', () => getFloorImageSource()], + ['getWallImageSource', () => getWallImageSource()], + ['getShadowImageSource', () => getShadowImageSource()], + ['getOfficeImageSource', () => getOfficeImageSource()], + ['getCharacterSprite', () => getCharacterSprite('Adam', 0)], + ['getSingleSprite', () => getSingleSprite('plant100')], + ])('%s throws before loading', (_name, fn) => { + expect(fn).toThrow('Office sprites have not been loaded'); + }); + }); + + describe('getters after loading', () => { + beforeEach(async () => { + await loadOfficeSprites(); + }); + + it('returns room sheet sprite sheets', () => { + expect(getFloorSheet()).toBeDefined(); + expect(getWallSheet()).toBeDefined(); + expect(getShadowSheet()).toBeDefined(); + expect(getOfficeSheet()).toBeDefined(); + }); + + it('returns room image sources', () => { + expect(getFloorImageSource()).toBeDefined(); + expect(getWallImageSource()).toBeDefined(); + expect(getShadowImageSource()).toBeDefined(); + expect(getOfficeImageSource()).toBeDefined(); + }); + + it('returns character sprites for all names and directions', () => { + const names = ['Adam', 'Alex', 'Amelia', 'Ash', 'Bob', 'Dan', 'Rob'] as const; + for (const name of names) { + for (let dir = 0; dir < 4; dir++) { + expect(getCharacterSprite(name, dir)).toBeDefined(); + } + } + }); + + it('returns single sprites for all asset keys', () => { + const keys = [ + 'analysisBoard172', + 'certificate113', + 'certificate114', + 'chartBoard171', + 'dashboard175', + 'deskLamp141', + 'diploma116', + 'execShelf206', + 'modernShelf205', + 'plant100', + 'prepDesk186', + 'workshopDesk183', + ] as const; + for (const key of keys) { + expect(getSingleSprite(key)).toBeDefined(); + } + }); + + it('returns cached instances on repeat calls', () => { + const first = getFloorSheet(); + const second = getFloorSheet(); + expect(first).toBe(second); + }); + }); + + describe(clearOfficeSpriteCache, () => { + it('allows sprites to be reloaded after clearing', async () => { + await loadOfficeSprites(); + clearOfficeSpriteCache(); + await loadOfficeSprites(); + + // 23 + 23 = 46 + expect(mockImageSourceConstructor).toHaveBeenCalledTimes(46); + }); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/sprites/__tests__/sprite-definitions.test.ts b/packages/factory/src/client/visualizations/office/sprites/__tests__/sprite-definitions.test.ts new file mode 100644 index 00000000..f7eda1aa --- /dev/null +++ b/packages/factory/src/client/visualizations/office/sprites/__tests__/sprite-definitions.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { + CHARACTER_FRAME_COLS, + CHARACTER_FRAME_ROWS, + CHARACTER_ROLE_MAP, + CHARACTER_SPRITE_H, + CHARACTER_SPRITE_W, + DIR_DOWN, + DIR_LEFT, + DIR_RIGHT, + DIR_UP, + FURNITURE_MANIFEST, + resolveCharacterName, +} from '../sprite-definitions.js'; + +describe('sprite dimension constants', () => { + it('defines a 4-column x 1-row character sprite layout', () => { + expect(CHARACTER_FRAME_COLS).toBe(4); + expect(CHARACTER_FRAME_ROWS).toBe(1); + }); + + it('defines 32x64 character sprite dimensions (1x2 tiles)', () => { + expect(CHARACTER_SPRITE_W).toBe(32); + expect(CHARACTER_SPRITE_H).toBe(64); + }); + + it('defines four directional indices', () => { + expect(DIR_DOWN).toBe(0); + expect(DIR_LEFT).toBe(1); + expect(DIR_RIGHT).toBe(2); + expect(DIR_UP).toBe(3); + }); +}); + +describe('FURNITURE_MANIFEST', () => { + it('contains furniture items', () => { + expect(FURNITURE_MANIFEST.length).toBeGreaterThan(0); + }); + + it('every item has a label and tile coordinates', () => { + for (const item of FURNITURE_MANIFEST) { + expect(item.label).toBeTruthy(); + expect(typeof item.tx).toBe('number'); + expect(typeof item.ty).toBe('number'); + } + }); + + it('every item has either an asset or a region', () => { + for (const item of FURNITURE_MANIFEST) { + const hasAsset = item.asset !== undefined; + const hasRegion = item.region !== undefined; + expect(hasAsset || hasRegion).toBe(true); + } + }); +}); + +describe('CHARACTER_ROLE_MAP', () => { + it('maps all standard workflow phases', () => { + expect(CHARACTER_ROLE_MAP.architecture).toBe('Alex'); + expect(CHARACTER_ROLE_MAP.planning).toBe('Amelia'); + expect(CHARACTER_ROLE_MAP.implementation).toBe('Dan'); + expect(CHARACTER_ROLE_MAP.review).toBe('Bob'); + expect(CHARACTER_ROLE_MAP.simplifier).toBe('Ash'); + expect(CHARACTER_ROLE_MAP.holistic).toBe('Rob'); + expect(CHARACTER_ROLE_MAP.orchestrator).toBe('Adam'); + }); +}); + +describe(resolveCharacterName, () => { + it('returns the mapped character for non-reviewer phases', () => { + expect(resolveCharacterName('architecture', 'agent-1')).toBe('Alex'); + expect(resolveCharacterName('planning', 'agent-2')).toBe('Amelia'); + expect(resolveCharacterName('implementation', 'agent-3')).toBe('Dan'); + }); + + it('returns a reviewer-pool character for reviewer phases', () => { + const reviewerPool = new Set(['Bob', 'Ash', 'Rob']); + + expect(reviewerPool.has(resolveCharacterName('review', 'test-agent'))).toBe(true); + expect(reviewerPool.has(resolveCharacterName('simplifier', 'test-agent'))).toBe(true); + expect(reviewerPool.has(resolveCharacterName('holistic', 'test-agent'))).toBe(true); + }); + + it('returns the same character for the same agent ID (hash stability)', () => { + const first = resolveCharacterName('review', 'reviewer-abc'); + const second = resolveCharacterName('review', 'reviewer-abc'); + expect(first).toBe(second); + }); + + it('can produce different characters for different agent IDs (hash distribution)', () => { + // Try enough distinct IDs that at least two different characters appear + const characters = new Set(); + for (let i = 0; i < 20; i++) { + characters.add(resolveCharacterName('review', `reviewer-${String(i)}`)); + } + expect(characters.size).toBeGreaterThan(1); + }); + + it('returns "Adam" for an unknown phase', () => { + expect(resolveCharacterName('unknown-phase', 'agent-1')).toBe('Adam'); + }); +}); diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Adam_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Adam_idle_32x32.png new file mode 100644 index 00000000..5cd6fa2c Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Adam_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Alex_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Alex_idle_32x32.png new file mode 100644 index 00000000..9d62a2fa Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Alex_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Amelia_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Amelia_idle_32x32.png new file mode 100644 index 00000000..d9da31da Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Amelia_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Ash_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Ash_idle_32x32.png new file mode 100644 index 00000000..e59c0dd3 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Ash_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Bob_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Bob_idle_32x32.png new file mode 100644 index 00000000..575d41b6 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Bob_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Dan_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Dan_idle_32x32.png new file mode 100644 index 00000000..a02128f7 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Dan_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Black_Shadow_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Black_Shadow_32x32.png new file mode 100644 index 00000000..9b4e4ccb Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Black_Shadow_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_100.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_100.png new file mode 100644 index 00000000..52abf913 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_100.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_113.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_113.png new file mode 100644 index 00000000..1dda2696 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_113.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_114.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_114.png new file mode 100644 index 00000000..894bc4ac Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_114.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_116.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_116.png new file mode 100644 index 00000000..75a75f23 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_116.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_141.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_141.png new file mode 100644 index 00000000..35436615 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_141.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_171.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_171.png new file mode 100644 index 00000000..17ca01de Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_171.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_172.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_172.png new file mode 100644 index 00000000..10570bdb Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_172.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_175.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_175.png new file mode 100644 index 00000000..8aa21947 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_175.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_183.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_183.png new file mode 100644 index 00000000..717f9613 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_183.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_186.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_186.png new file mode 100644 index 00000000..6cfc62d8 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_186.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_205.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_205.png new file mode 100644 index 00000000..ee460be2 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_205.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_206.png b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_206.png new file mode 100644 index 00000000..3912f38a Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Modern_Office_Singles_32x32_206.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Rob_idle_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Rob_idle_32x32.png new file mode 100644 index 00000000..6cde1833 Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Rob_idle_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floor_Shadows_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floor_Shadows_32x32.png new file mode 100644 index 00000000..e41f8d3c Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floor_Shadows_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floors_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floors_32x32.png new file mode 100644 index 00000000..4dd4477f Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Floors_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Walls_32x32.png b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Walls_32x32.png new file mode 100644 index 00000000..21c91e4d Binary files /dev/null and b/packages/factory/src/client/visualizations/office/sprites/assets/Room_Builder_Walls_32x32.png differ diff --git a/packages/factory/src/client/visualizations/office/sprites/office-sprite-loader.ts b/packages/factory/src/client/visualizations/office/sprites/office-sprite-loader.ts new file mode 100644 index 00000000..70dddf20 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/sprites/office-sprite-loader.ts @@ -0,0 +1,248 @@ +import { ImageFiltering, ImageSource, Sprite, SpriteSheet } from 'excalibur'; + +import { + CHARACTER_FRAME_COLS, + CHARACTER_FRAME_ROWS, + CHARACTER_SPRITE_H, + CHARACTER_SPRITE_W, +} from './sprite-definitions.js'; +import { + CHARACTER_URLS, + type CharacterName, + ROOM_SHEET_URLS, + type RoomSheetKey, + SINGLE_ASSET_URLS, + type SingleAssetKey, +} from './sprite-sheet-urls.js'; + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +interface SpriteCache { + roomSheets: Record; + roomImageSources: Record; + characterSprites: Map; + singleSprites: Map; +} + +let cache: SpriteCache | undefined; +let inflight: Promise | undefined; + +// --------------------------------------------------------------------------- +// Loader +// --------------------------------------------------------------------------- + +/** + * Load all office sprite sheets and build the sprite cache. + * The cache is populated synchronously before awaiting image data, so + * getter functions work immediately after invoking this function. + * Concurrent callers share the same in-flight promise. + */ +export function loadOfficeSprites(): Promise { + if (cache !== undefined) return Promise.resolve(); + if (inflight !== undefined) return inflight; + + inflight = doLoad().finally(() => { + inflight = undefined; + }); + return inflight; +} + +/** Perform the actual sprite loading and cache population. */ +async function doLoad(): Promise { + const imageSources: ImageSource[] = []; + + // Create room/office sheet image sources and sprite sheets + function buildSheetFromUrl(url: string): { imageSource: ImageSource; spriteSheet: SpriteSheet } { + const imageSource = new ImageSource(url, { filtering: ImageFiltering.Pixel }); + imageSources.push(imageSource); + const spriteSheet = SpriteSheet.fromImageSource({ + image: imageSource, + grid: { rows: 100, columns: 100, spriteWidth: 32, spriteHeight: 32 }, + }); + return { imageSource, spriteSheet }; + } + + const floors = buildSheetFromUrl(ROOM_SHEET_URLS.floors); + const walls = buildSheetFromUrl(ROOM_SHEET_URLS.walls); + const shadows = buildSheetFromUrl(ROOM_SHEET_URLS.shadows); + const office = buildSheetFromUrl(ROOM_SHEET_URLS.office); + + const roomSheets: Record = { + floors: floors.spriteSheet, + walls: walls.spriteSheet, + shadows: shadows.spriteSheet, + office: office.spriteSheet, + }; + + const roomImageSources: Record = { + floors: floors.imageSource, + walls: walls.imageSource, + shadows: shadows.imageSource, + office: office.imageSource, + }; + + // Create character image sources and extract directional sprites + const characterSprites = new Map(); + + for (const [name, url] of Object.entries(CHARACTER_URLS)) { + const imageSource = new ImageSource(url, { filtering: ImageFiltering.Pixel }); + imageSources.push(imageSource); + + const spriteSheet = SpriteSheet.fromImageSource({ + image: imageSource, + grid: { + rows: CHARACTER_FRAME_ROWS, + columns: CHARACTER_FRAME_COLS, + spriteWidth: CHARACTER_SPRITE_W, + spriteHeight: CHARACTER_SPRITE_H, + }, + }); + + // Extract one sprite per direction (columns 0-3) + for (let dir = 0; dir < CHARACTER_FRAME_COLS; dir++) { + const sprite = spriteSheet.getSprite(dir, 0); + characterSprites.set(`${name}:${String(dir)}`, sprite); + } + } + + // Create furniture single image sources and sprites + const singleSprites = new Map(); + const singleKeys: readonly SingleAssetKey[] = [ + 'analysisBoard172', + 'certificate113', + 'certificate114', + 'chartBoard171', + 'dashboard175', + 'deskLamp141', + 'diploma116', + 'execShelf206', + 'modernShelf205', + 'plant100', + 'prepDesk186', + 'workshopDesk183', + ]; + + for (const key of singleKeys) { + const url = SINGLE_ASSET_URLS[key]; + const imageSource = new ImageSource(url, { filtering: ImageFiltering.Pixel }); + imageSources.push(imageSource); + singleSprites.set(key, Sprite.from(imageSource)); + } + + // Populate cache synchronously so getters work immediately. + cache = { + roomSheets, + roomImageSources, + characterSprites, + singleSprites, + }; + + try { + await Promise.all(imageSources.map((source) => source.load())); + } catch (error: unknown) { + cache = undefined; + throw error; + } +} + +// --------------------------------------------------------------------------- +// Getters +// --------------------------------------------------------------------------- + +/** Return the floor tile sprite sheet. Throws if sprites have not been loaded. */ +export function getFloorSheet(): SpriteSheet { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomSheets.floors; +} + +/** Return the wall tile sprite sheet. Throws if sprites have not been loaded. */ +export function getWallSheet(): SpriteSheet { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomSheets.walls; +} + +/** Return the floor shadow sprite sheet. Throws if sprites have not been loaded. */ +export function getShadowSheet(): SpriteSheet { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomSheets.shadows; +} + +/** Return the office furniture sprite sheet. Throws if sprites have not been loaded. */ +export function getOfficeSheet(): SpriteSheet { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomSheets.office; +} + +/** Return a character sprite for the given name and direction (0-3). */ +export function getCharacterSprite(name: CharacterName, direction: number): Sprite { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + + const key = `${name}:${String(direction)}`; + const sprite = cache.characterSprites.get(key); + if (sprite === undefined) { + throw new Error(`No character sprite found for "${name}" direction ${String(direction)}.`); + } + return sprite; +} + +/** Return the standalone sprite for a furniture single asset. */ +export function getSingleSprite(key: SingleAssetKey): Sprite { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + + const sprite = cache.singleSprites.get(key); + if (sprite === undefined) { + throw new Error(`No single sprite found for key "${key}".`); + } + return sprite; +} + +/** Return the raw ImageSource for the floor sheet (needed for Canvas-based tile drawing). */ +export function getFloorImageSource(): ImageSource { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomImageSources.floors; +} + +/** Return the raw ImageSource for the wall sheet (needed for Canvas-based tile drawing). */ +export function getWallImageSource(): ImageSource { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomImageSources.walls; +} + +/** Return the raw ImageSource for the shadow sheet (needed for Canvas-based tile drawing). */ +export function getShadowImageSource(): ImageSource { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomImageSources.shadows; +} + +/** Return the raw ImageSource for the office furniture sheet (needed for Canvas-based region drawing). */ +export function getOfficeImageSource(): ImageSource { + if (cache === undefined) { + throw new Error('Office sprites have not been loaded. Call loadOfficeSprites() first.'); + } + return cache.roomImageSources.office; +} + +/** Clear the office sprite cache, allowing sprites to be reloaded. Useful for tests. */ +export function clearOfficeSpriteCache(): void { + cache = undefined; +} diff --git a/packages/factory/src/client/visualizations/office/sprites/sprite-definitions.ts b/packages/factory/src/client/visualizations/office/sprites/sprite-definitions.ts new file mode 100644 index 00000000..45d7bb23 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/sprites/sprite-definitions.ts @@ -0,0 +1,126 @@ +import type { CharacterName, SingleAssetKey } from './sprite-sheet-urls.js'; + +// --------------------------------------------------------------------------- +// Character sprite layout +// --------------------------------------------------------------------------- + +/** Number of directional columns in each character idle sprite sheet. */ +export const CHARACTER_FRAME_COLS = 4; + +/** Number of rows in each character idle sprite sheet. */ +export const CHARACTER_FRAME_ROWS = 1; + +/** Width of a single character sprite frame in pixels. */ +export const CHARACTER_SPRITE_W = 32; + +/** Height of a single character sprite frame in pixels (characters are 1x2 tiles). */ +export const CHARACTER_SPRITE_H = 64; + +/** Column index for the down-facing (camera-facing) direction. */ +export const DIR_DOWN = 0; + +/** Column index for the left-facing direction. */ +export const DIR_LEFT = 1; + +/** Column index for the right-facing direction. */ +export const DIR_RIGHT = 2; + +/** Column index for the up-facing direction. */ +export const DIR_UP = 3; + +// --------------------------------------------------------------------------- +// Furniture manifest +// --------------------------------------------------------------------------- + +/** A spritesheet region descriptor for multi-tile composites. */ +interface SpriteRegion { + sheet: 'officeSheet'; + sx: number; + sy: number; + sw: number; + sh: number; +} + +/** A placeable furniture item with position and sprite source. */ +export interface FurnitureItem { + label: string; + tx: number; + ty: number; + asset?: SingleAssetKey; + region?: SpriteRegion; +} + +/** Static furniture manifest defining all office furnishings and their positions. */ +export const FURNITURE_MANIFEST: readonly FurnitureItem[] = [ + // Prep area: wall decor + { label: 'Analysis board', asset: 'analysisBoard172', tx: 3.56, ty: -1.06 }, + { label: 'Certificate 1', asset: 'certificate113', tx: 7.47, ty: -1.03 }, + { label: 'Diploma', asset: 'diploma116', tx: 8.47, ty: -1.03 }, + // Prep area: furniture + { label: 'Desk (architect)', asset: 'prepDesk186', tx: 2, ty: 4 }, + { label: 'Lamp (architect)', asset: 'deskLamp141', tx: 2, ty: 3.53 }, + { label: 'Desk (planner)', asset: 'prepDesk186', tx: 5, ty: 5 }, + { label: 'Lamp (planner)', asset: 'deskLamp141', tx: 5, ty: 4.59 }, + { label: 'Plant (prep NW)', asset: 'plant100', tx: 1, ty: 1 }, + { label: 'Plant (prep S)', asset: 'plant100', tx: 10.81, ty: 8.44 }, + // Workshop: coder + { label: 'Desk (coder)', region: { sheet: 'officeSheet', sx: 32, sy: 1120, sw: 64, sh: 64 }, tx: 17, ty: 7 }, + { label: 'Monitors (coder)', region: { sheet: 'officeSheet', sx: 416, sy: 384, sw: 96, sh: 32 }, tx: 16.5, ty: 6.5 }, + { label: 'Keyboard (coder)', region: { sheet: 'officeSheet', sx: 448, sy: 448, sw: 32, sh: 32 }, tx: 17.5, ty: 7.5 }, + { label: 'Shelf (workshop)', asset: 'modernShelf205', tx: 16.78, ty: 0.88 }, + // Workshop: reviewers + { label: 'Desk (Bob)', asset: 'workshopDesk183', tx: 27, ty: 4 }, + { label: 'Monitor (Bob)', region: { sheet: 'officeSheet', sx: 448, sy: 320, sw: 32, sh: 64 }, tx: 27.03, ty: 4.25 }, + { label: 'Desk (Ash)', asset: 'workshopDesk183', tx: 30, ty: 5 }, + { label: 'Monitor (Ash)', region: { sheet: 'officeSheet', sx: 448, sy: 320, sw: 32, sh: 64 }, tx: 30, ty: 5.25 }, + { label: 'Desk (Rob)', asset: 'workshopDesk183', tx: 33, ty: 4 }, + { label: 'Monitor (Rob)', region: { sheet: 'officeSheet', sx: 448, sy: 320, sw: 32, sh: 64 }, tx: 33, ty: 4.31 }, + // Workshop: decor + { label: 'Dashboard', asset: 'dashboard175', tx: 13.34, ty: 1.19 }, + { label: 'Plant (workshop NW)', asset: 'plant100', tx: 15.59, ty: 1.16 }, + { label: 'Plant (workshop S)', asset: 'plant100', tx: 19.06, ty: 0.97 }, + { label: 'Certificate 2', asset: 'certificate114', tx: 23.56, ty: 12.09 }, + { label: 'Chart board', asset: 'chartBoard171', tx: 29.28, ty: 11.91 }, + // Governor's office + { + label: 'Orchestrator desk', + region: { sheet: 'officeSheet', sx: 320, sy: 0, sw: 160, sh: 128 }, + tx: 20.13, + ty: 16.53, + }, + { label: 'Exec shelf', asset: 'execShelf206', tx: 32.69, ty: 13.22 }, + { label: 'Plant (governor)', asset: 'plant100', tx: 34, ty: 13 }, +] as const; + +// --------------------------------------------------------------------------- +// Character-to-role mapping +// --------------------------------------------------------------------------- + +/** Default character assignment pool for reviewer roles, indexed by slot position. */ +const REVIEWER_CHARACTERS: readonly CharacterName[] = ['Bob', 'Ash', 'Rob']; + +/** Maps workflow phase names to their default character sprite. */ +export const CHARACTER_ROLE_MAP: Record = { + architecture: 'Alex', + planning: 'Amelia', + implementation: 'Dan', + review: 'Bob', + simplifier: 'Ash', + holistic: 'Rob', + orchestrator: 'Adam', +}; + +/** Resolve the character sprite name for a given phase and agent ID. */ +export function resolveCharacterName(phase: string, agentId: string): CharacterName { + // Reviewer phases use a rotating pool based on agent ID hash + if (phase === 'review' || phase === 'simplifier' || phase === 'holistic') { + // Compute a stable index using a djb2-style multiplicative hash for better distribution + let hash = 5381; + for (let i = 0; i < agentId.length; i++) { + hash = Math.trunc(hash * 33 + (agentId.codePointAt(i) ?? 0)); + } + return REVIEWER_CHARACTERS[Math.abs(hash) % REVIEWER_CHARACTERS.length] ?? 'Adam'; + } + + return CHARACTER_ROLE_MAP[phase] ?? 'Adam'; +} diff --git a/packages/factory/src/client/visualizations/office/sprites/sprite-sheet-urls.ts b/packages/factory/src/client/visualizations/office/sprites/sprite-sheet-urls.ts new file mode 100644 index 00000000..f5a91619 --- /dev/null +++ b/packages/factory/src/client/visualizations/office/sprites/sprite-sheet-urls.ts @@ -0,0 +1,83 @@ +// Character idle sprites +import adamUrl from './assets/Adam_idle_32x32.png'; +import alexUrl from './assets/Alex_idle_32x32.png'; +import ameliaUrl from './assets/Amelia_idle_32x32.png'; +import ashUrl from './assets/Ash_idle_32x32.png'; +import bobUrl from './assets/Bob_idle_32x32.png'; +import danUrl from './assets/Dan_idle_32x32.png'; +// Office furniture sheet +import officeUrl from './assets/Modern_Office_Black_Shadow_32x32.png'; +// Furniture singles +import plant100Url from './assets/Modern_Office_Singles_32x32_100.png'; +import certificate113Url from './assets/Modern_Office_Singles_32x32_113.png'; +import certificate114Url from './assets/Modern_Office_Singles_32x32_114.png'; +import diploma116Url from './assets/Modern_Office_Singles_32x32_116.png'; +import deskLamp141Url from './assets/Modern_Office_Singles_32x32_141.png'; +import chartBoard171Url from './assets/Modern_Office_Singles_32x32_171.png'; +import analysisBoard172Url from './assets/Modern_Office_Singles_32x32_172.png'; +import dashboard175Url from './assets/Modern_Office_Singles_32x32_175.png'; +import workshopDesk183Url from './assets/Modern_Office_Singles_32x32_183.png'; +import prepDesk186Url from './assets/Modern_Office_Singles_32x32_186.png'; +import modernShelf205Url from './assets/Modern_Office_Singles_32x32_205.png'; +import execShelf206Url from './assets/Modern_Office_Singles_32x32_206.png'; +import robUrl from './assets/Rob_idle_32x32.png'; +// Room builder sheets +import shadowsUrl from './assets/Room_Builder_Floor_Shadows_32x32.png'; +import floorsUrl from './assets/Room_Builder_Floors_32x32.png'; +import wallsUrl from './assets/Room_Builder_Walls_32x32.png'; + +/** Identifies the four sprite sheet types used for room and office rendering. */ +export type RoomSheetKey = 'floors' | 'walls' | 'shadows' | 'office'; + +/** Identifies individual furniture pieces loaded as standalone sprites. */ +export type SingleAssetKey = + | 'analysisBoard172' + | 'certificate113' + | 'certificate114' + | 'chartBoard171' + | 'dashboard175' + | 'deskLamp141' + | 'diploma116' + | 'execShelf206' + | 'modernShelf205' + | 'plant100' + | 'prepDesk186' + | 'workshopDesk183'; + +/** Identifies character sprites by name. */ +export type CharacterName = 'Adam' | 'Alex' | 'Amelia' | 'Ash' | 'Bob' | 'Dan' | 'Rob'; + +/** Maps each room/office sheet type to the URL of its sprite sheet asset. */ +export const ROOM_SHEET_URLS: Record = { + floors: floorsUrl, + walls: wallsUrl, + shadows: shadowsUrl, + office: officeUrl, +}; + +/** Maps each furniture single key to the URL of its standalone asset. */ +export const SINGLE_ASSET_URLS: Record = { + analysisBoard172: analysisBoard172Url, + certificate113: certificate113Url, + certificate114: certificate114Url, + chartBoard171: chartBoard171Url, + dashboard175: dashboard175Url, + deskLamp141: deskLamp141Url, + diploma116: diploma116Url, + execShelf206: execShelf206Url, + modernShelf205: modernShelf205Url, + plant100: plant100Url, + prepDesk186: prepDesk186Url, + workshopDesk183: workshopDesk183Url, +}; + +/** Maps each character name to the URL of its idle sprite sheet. */ +export const CHARACTER_URLS: Record = { + Adam: adamUrl, + Alex: alexUrl, + Amelia: ameliaUrl, + Ash: ashUrl, + Bob: bobUrl, + Dan: danUrl, + Rob: robUrl, +}; diff --git a/packages/factory/src/client/visualizations/registry.ts b/packages/factory/src/client/visualizations/registry.ts index fd2cea6e..e5d3833f 100644 --- a/packages/factory/src/client/visualizations/registry.ts +++ b/packages/factory/src/client/visualizations/registry.ts @@ -1,11 +1,13 @@ import { CatwalkCanvas } from '../components/CatwalkCanvas.js'; import { FactoryFloorCanvas } from '../components/FactoryFloorCanvas.js'; +import { OfficeCanvas } from '../components/OfficeCanvas.js'; import type { VisualizationComponent } from './types.js'; /** Registry mapping visualization names to their React components. */ export const visualizationRegistry: Record = { catwalk: CatwalkCanvas, 'factory-floor': FactoryFloorCanvas, + office: OfficeCanvas, }; /** Default visualization key used when the `vis` URL param is absent. */