diff --git a/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts
new file mode 100644
index 00000000..5fa22476
--- /dev/null
+++ b/packages/factory/scripts/sprites/__tests__/orchestrator-poses.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import { ORCHESTRATOR_POSES } from '../orchestrator-poses.ts';
+
+describe('ORCHESTRATOR_POSES', () => {
+ it('exports exactly 12 poses', () => {
+ expect(ORCHESTRATOR_POSES).toHaveLength(12);
+ });
+
+ it('every pixel index is within 0-6 (palette range including beacon bright)', () => {
+ for (const [poseIndex, pose] of ORCHESTRATOR_POSES.entries()) {
+ for (const [partIndex, part] of pose.entries()) {
+ for (const [rowIndex, row] of part.pixels.entries()) {
+ for (const [colIndex, value] of row.entries()) {
+ expect(
+ value,
+ `pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`,
+ ).toBeGreaterThanOrEqual(0);
+ expect(
+ value,
+ `pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`,
+ ).toBeLessThanOrEqual(6);
+ }
+ }
+ }
+ }
+ });
+
+ it('every pose has treads at offsetY 26 (bottom-anchored)', () => {
+ for (const [index, pose] of ORCHESTRATOR_POSES.entries()) {
+ const treadPart = pose[0];
+ expect(treadPart?.offsetY, `pose ${index} treads not at row 26`).toBe(26);
+ }
+ });
+
+ it('beacon on frames use palette index 6', () => {
+ // Frame 4 (Working 1) should have beacon with index 6 pixels
+ const beaconPart = ORCHESTRATOR_POSES[4]?.[5]; // last part = beacon
+ expect(beaconPart).toBeDefined();
+ const hasIndex6 = beaconPart?.pixels.some((row) => row.includes(6)) ?? false;
+ expect(hasIndex6).toBe(true);
+ });
+});
diff --git a/packages/factory/scripts/sprites/__tests__/palettes.test.ts b/packages/factory/scripts/sprites/__tests__/palettes.test.ts
new file mode 100644
index 00000000..6dcfbfc7
--- /dev/null
+++ b/packages/factory/scripts/sprites/__tests__/palettes.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from '../palettes.ts';
+
+describe('ORCHESTRATOR_PALETTE', () => {
+ it('has 7 entries (transparent + 5 shades + beacon bright)', () => {
+ expect(ORCHESTRATOR_PALETTE).toHaveLength(7);
+ expect(ORCHESTRATOR_PALETTE[0]).toBe('');
+ expect(ORCHESTRATOR_PALETTE[6]).toBe('#fff5c0');
+ });
+});
+
+describe('SUBAGENT_PALETTE', () => {
+ it('has 6 entries (transparent + 5 shades) and is unchanged', () => {
+ expect(SUBAGENT_PALETTE).toHaveLength(6);
+ });
+});
diff --git a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts
index ebc98694..2134cd7a 100644
--- a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts
+++ b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts
@@ -276,7 +276,7 @@ describe('pose data integrity', () => {
}
});
- it('all orchestrator pixel values are within the valid palette range [0, 5]', () => {
+ it('all orchestrator pixel values are within the valid palette range [0, 6]', () => {
for (const [poseIndex, pose] of ORCHESTRATOR_POSES.entries()) {
for (const [partIndex, part] of pose.entries()) {
for (const [rowIndex, row] of part.pixels.entries()) {
@@ -288,7 +288,7 @@ describe('pose data integrity', () => {
expect(
value,
`orchestrator pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`,
- ).toBeLessThanOrEqual(5);
+ ).toBeLessThanOrEqual(6);
}
}
}
diff --git a/packages/factory/scripts/sprites/orchestrator-poses.ts b/packages/factory/scripts/sprites/orchestrator-poses.ts
index a2b03d5c..a3ac1190 100644
--- a/packages/factory/scripts/sprites/orchestrator-poses.ts
+++ b/packages/factory/scripts/sprites/orchestrator-poses.ts
@@ -1,143 +1,142 @@
import type { BodyPart, Pose } from './svg-renderer.ts';
// ── Body parts ──────────────────────────────────────────────────────────────
-// Target: ~28px total height (88% of 32), bottom-aligned with feet at row ~30.
-// Layout (top to bottom): Antenna rows 2-4, Head rows 5-12, Torso rows 13-23, Legs rows 24-30.
+// Target: ~30px total height, bottom-aligned with treads at row 26.
+// Layout (top to bottom): Beacon rows 1-2, Head rows 3-10, Torso rows 11-24, Treads rows 26-29.
+// Part composition order per pose (painter's algorithm): treads, torso, arms, head, beacon.
-// 1x3 antenna above head center
-const ANTENNA: BodyPart = {
- pixels: [[5], [4], [3]],
- offsetX: 15,
- offsetY: 2,
+// 3×2 beacon lamp (on — bright)
+const BEACON_ON: BodyPart = {
+ pixels: [
+ [6, 6, 6],
+ [5, 4, 5],
+ ],
+ offsetX: 14,
+ offsetY: 1,
};
-// 12x8 wider head with visor strip
+// 3×2 beacon lamp (off — dark)
+const BEACON_OFF: BodyPart = {
+ pixels: [
+ [3, 2, 3],
+ [2, 1, 2],
+ ],
+ offsetX: 14,
+ offsetY: 1,
+};
+
+// 3×2 beacon lamp (idle — very dim)
+const BEACON_IDLE: BodyPart = {
+ pixels: [
+ [2, 1, 2],
+ [1, 1, 1],
+ ],
+ offsetX: 14,
+ offsetY: 1,
+};
+
+// 12×8 wider head with visor strip
const HEAD: BodyPart = {
pixels: [
[5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3],
[5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
[5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
- [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1],
- [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1],
+ [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], // visor row
+ [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1], // visor row
[5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
[5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
offsetX: 10,
- offsetY: 5,
+ offsetY: 3,
};
-// 14x11 broader torso with shading
-const TORSO: BodyPart = {
- pixels: [
- [5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3],
- [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
- [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
- [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
- [5, 3, 3, 4, 4, 5, 5, 5, 4, 4, 3, 3, 2, 1],
- [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
- [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
- [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
- [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
- [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
- [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
- ],
- offsetX: 9,
- offsetY: 13,
+// Head shifted 1px down (slouching/concerned pose)
+const HEAD_SLOUCH: BodyPart = {
+ ...HEAD,
+ offsetY: HEAD.offsetY + 1,
};
-// 2x7 arm hanging at side (left)
-const ARM_LEFT_DOWN: BodyPart = {
+// Head with dimmed visor (index 3 instead of 5)
+const HEAD_VISOR_DIM: BodyPart = {
pixels: [
- [4, 3],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [1, 1],
+ [5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], // dimmed visor
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1], // dimmed visor
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
- offsetX: 7,
- offsetY: 14,
+ offsetX: 10,
+ offsetY: 3,
};
-// 2x7 arm hanging at side (right)
-const ARM_RIGHT_DOWN: BodyPart = {
+// Head with bright visor (index 6 instead of 5)
+const HEAD_VISOR_BRIGHT: BodyPart = {
pixels: [
- [3, 4],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [1, 1],
+ [5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 1], // bright visor
+ [5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 1], // bright visor
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
- offsetX: 23,
- offsetY: 14,
+ offsetX: 10,
+ offsetY: 3,
};
-// 2x7 arm raised partway (left)
-const ARM_LEFT_UP: BodyPart = {
+// 16×14 broader torso with shading
+const TORSO: BodyPart = {
pixels: [
- [4, 3],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [1, 1],
+ [5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
+ [5, 3, 3, 4, 4, 5, 5, 5, 5, 5, 4, 4, 3, 3, 2, 1], // chest panel band
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
+ [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
- offsetX: 7,
- offsetY: 9,
+ offsetX: 8,
+ offsetY: 11,
};
-// 2x7 arm raised partway (right)
-const ARM_RIGHT_UP: BodyPart = {
+// 18×4 tank treads frame A
+const TREADS_A: BodyPart = {
pixels: [
- [3, 4],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [1, 1],
- ],
- offsetX: 23,
- offsetY: 9,
-};
-
-// 2x7 arm raised high (left, for celebrating)
-const ARM_LEFT_RAISED_HIGH: BodyPart = {
- pixels: [
- [4, 3],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [1, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 1], // alternating pattern
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
offsetX: 7,
- offsetY: 4,
+ offsetY: 26,
};
-// 2x7 arm raised high (right, for celebrating)
-const ARM_RIGHT_RAISED_HIGH: BodyPart = {
+// 18×4 tank treads frame B — row 2 shifted one position
+const TREADS_B: BodyPart = {
pixels: [
- [3, 4],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [2, 3],
- [1, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
+ [1, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1], // shifted pattern
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
],
- offsetX: 23,
- offsetY: 4,
+ offsetX: 7,
+ offsetY: 26,
};
-// 2x7 arm raised high and splayed out (left, for V-shape celebrating)
-const ARM_LEFT_SPLAYED: BodyPart = {
+// 2×8 arm at side (left)
+const ARM_LEFT: BodyPart = {
pixels: [
[4, 3],
[3, 2],
@@ -145,14 +144,15 @@ const ARM_LEFT_SPLAYED: BodyPart = {
[3, 2],
[3, 2],
[3, 2],
+ [3, 2],
[1, 1],
],
- offsetX: 5,
- offsetY: 4,
+ offsetX: 6,
+ offsetY: 12,
};
-// 2x7 arm raised high and splayed out (right, for V-shape celebrating)
-const ARM_RIGHT_SPLAYED: BodyPart = {
+// 2×8 arm at side (right)
+const ARM_RIGHT: BodyPart = {
pixels: [
[3, 4],
[2, 3],
@@ -160,160 +160,55 @@ const ARM_RIGHT_SPLAYED: BodyPart = {
[2, 3],
[2, 3],
[2, 3],
- [1, 1],
- ],
- offsetX: 25,
- offsetY: 4,
-};
-
-// 7x2 arm extended horizontally (left)
-const ARM_LEFT_EXTENDED: BodyPart = {
- pixels: [
- [4, 3, 3, 3, 3, 3, 1],
- [1, 2, 2, 2, 2, 2, 1],
- ],
- offsetX: 2,
- offsetY: 15,
-};
-
-// 7x2 arm extended horizontally (right)
-const ARM_RIGHT_EXTENDED: BodyPart = {
- pixels: [
- [1, 3, 3, 3, 3, 3, 4],
- [1, 2, 2, 2, 2, 2, 1],
- ],
- offsetX: 23,
- offsetY: 15,
-};
-
-// Legs standing straight (pair), wider stance for orchestrator
-const LEG_STAND: BodyPart = {
- pixels: [
- [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0],
- [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0],
- [0, 0, 3, 0, 0, 0, 0, 0, 0, 3, 0, 0],
- [0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 0],
- [0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 0],
- [0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0],
- [1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1],
- ],
- offsetX: 10,
- offsetY: 24,
-};
-
-// Legs with left leg forward, right leg back (walking mid-stride)
-const LEG_FORWARD: BodyPart = {
- pixels: [
- [0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 0],
- [0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 3, 0],
- [0, 3, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0],
- [0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0],
- [0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0],
- [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0],
- [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
- ],
- offsetX: 9,
- offsetY: 24,
-};
-
-// Legs with slight outward splay (for resting)
-const LEG_RELAXED: BodyPart = {
- pixels: [
- [0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0],
- [0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0],
- [3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3],
- [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2],
- [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2],
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1],
- [1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1],
- ],
- offsetX: 10,
- offsetY: 24,
-};
-
-// Arms pulled in close (for concerned)
-const ARM_LEFT_PULLED_IN: BodyPart = {
- pixels: [
- [4, 3],
- [3, 2],
- [3, 2],
- [3, 2],
- [3, 2],
- [1, 1],
- ],
- offsetX: 8,
- offsetY: 15,
-};
-
-const ARM_RIGHT_PULLED_IN: BodyPart = {
- pixels: [
- [3, 4],
- [2, 3],
- [2, 3],
- [2, 3],
[2, 3],
[1, 1],
],
- offsetX: 22,
- offsetY: 15,
-};
-
-// Head shifted down 1px (for slouching/resting)
-const HEAD_SLOUCH: BodyPart = {
- ...HEAD,
- offsetY: HEAD.offsetY + 1,
+ offsetX: 24,
+ offsetY: 12,
};
-// Antenna shifted down 1px to match slouch
-const ANTENNA_SLOUCH: BodyPart = {
- ...ANTENNA,
- offsetY: ANTENNA.offsetY + 1,
-};
+// Shift a body part 1px upward (used for vibration animation)
+function vibrate(part: BodyPart): BodyPart {
+ return { ...part, offsetY: part.offsetY - 1 };
+}
// ── 12 poses in sprite-sheet order ──────────────────────────────────────────
/** Twelve animation poses for the orchestrator robot, ordered by sprite-sheet frame index. */
export const ORCHESTRATOR_POSES: Pose[] = [
- // Frame 0: Idle 1 — standing, arms down
- [LEG_STAND, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA],
+ // Frame 0: Idle 1 — beacon dim (indices 2/1), visor normal, treads A
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_IDLE],
- // Frame 1: Idle 2 — arms shifted 1px (ping-pong variation)
- [
- LEG_STAND,
- TORSO,
- { ...ARM_LEFT_DOWN, offsetX: ARM_LEFT_DOWN.offsetX - 1 },
- { ...ARM_RIGHT_DOWN, offsetX: ARM_RIGHT_DOWN.offsetX + 1 },
- HEAD,
- ANTENNA,
- ],
+ // Frame 1: Idle 2 — beacon dim (indices 2/1), visor dimmed, treads A
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_DIM, BEACON_IDLE],
- // Frame 2: Walking — mid-stride with LEG_FORWARD
- [LEG_FORWARD, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA],
+ // Frame 2: Concerned — head slouched, beacon dim
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_SLOUCH, BEACON_OFF],
- // Frame 3: Resting 1 — relaxed stance
- [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA],
+ // Frame 3: (spare)
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF],
- // Frame 4: Working 1 — both arms raised
- [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_UP, HEAD, ANTENNA],
+ // Frame 4: Working 1 — beacon on, body at normal Y
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_ON],
- // Frame 5: Working 2 — arms extended horizontally
- [LEG_STAND, TORSO, ARM_LEFT_EXTENDED, ARM_RIGHT_EXTENDED, HEAD, ANTENNA],
+ // Frame 5: Working 2 — beacon off, upper body Y-1 (vibrate)
+ [TREADS_A, vibrate(TORSO), vibrate(ARM_LEFT), vibrate(ARM_RIGHT), vibrate(HEAD), vibrate(BEACON_OFF)],
- // Frame 6: Working 3 — arms lowering back (one up, one down)
- [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA],
+ // Frame 6: Walking 1 — beacon on, treads A
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_ON],
- // Frame 7: Resting 2 — slight slouch, head offset 1px down
- [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD_SLOUCH, ANTENNA_SLOUCH],
+ // Frame 7: Walking 2 — beacon off, treads B
+ [TREADS_B, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF],
- // Frame 8: Celebrating 1 — both arms raised high
- [LEG_STAND, TORSO, ARM_LEFT_RAISED_HIGH, ARM_RIGHT_RAISED_HIGH, HEAD, ANTENNA],
+ // Frame 8: Celebrating 1 — beacon on, visor bright, treads A, normal Y
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD_VISOR_BRIGHT, BEACON_ON],
- // Frame 9: Celebrating 2 — arms in V-shape (splayed)
- [LEG_STAND, TORSO, ARM_LEFT_SPLAYED, ARM_RIGHT_SPLAYED, HEAD, ANTENNA],
+ // Frame 9: Celebrating 2 — beacon on, visor normal, treads B, upper body Y-1
+ [TREADS_B, vibrate(TORSO), vibrate(ARM_LEFT), vibrate(ARM_RIGHT), vibrate(HEAD), vibrate(BEACON_ON)],
- // Frame 10: Concerned — hunched, arms pulled in
- [LEG_STAND, TORSO, ARM_LEFT_PULLED_IN, ARM_RIGHT_PULLED_IN, HEAD_SLOUCH, ANTENNA_SLOUCH],
+ // Frame 10: (spare)
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF],
- // Frame 11: Resting 3 — powered-down, arms at sides
- [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA],
+ // Frame 11: (spare)
+ [TREADS_A, TORSO, ARM_LEFT, ARM_RIGHT, HEAD, BEACON_OFF],
];
diff --git a/packages/factory/scripts/sprites/palettes.ts b/packages/factory/scripts/sprites/palettes.ts
index 0feba87b..16456640 100644
--- a/packages/factory/scripts/sprites/palettes.ts
+++ b/packages/factory/scripts/sprites/palettes.ts
@@ -85,10 +85,13 @@ export const SUBAGENT_PALETTE: Palette = generatePalette(
SUBAGENT_HSL.maxLightness,
);
-/** Gold command-unit palette for the orchestrator robot. */
-export const ORCHESTRATOR_PALETTE: Palette = generatePalette(
+const basePalette = generatePalette(
ORCHESTRATOR_HSL.hue,
ORCHESTRATOR_HSL.saturation,
ORCHESTRATOR_HSL.minLightness,
ORCHESTRATOR_HSL.maxLightness,
);
+
+/** Gold command-unit palette for the orchestrator robot, with beacon bright at index 6. */
+// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Palette is a readonly tuple starting with ''; spread loses that refinement
+export const ORCHESTRATOR_PALETTE: Palette = [...basePalette, '#fff5c0'] as Palette;
diff --git a/packages/factory/scripts/sprites/preview-renderer.ts b/packages/factory/scripts/sprites/preview-renderer.ts
index c9027469..859f7aa6 100644
--- a/packages/factory/scripts/sprites/preview-renderer.ts
+++ b/packages/factory/scripts/sprites/preview-renderer.ts
@@ -1,8 +1,8 @@
-// Frame labels matching the pose ordering from sprite-definitions.ts
+// Frame labels for subagent sprite sheet
// Row 0 (frames 0-3): Idle 1, Idle 2, Walking, Resting 1
// Row 1 (frames 4-7): Working 1, Working 2, Working 3, Resting 2
// Row 2 (frames 8-11): Celebrating 1, Celebrating 2, Concerned, Resting 3
-const FRAME_LABELS = [
+const SUBAGENT_FRAME_LABELS = [
'Idle 1',
'Idle 2',
'Walking',
@@ -17,29 +17,53 @@ const FRAME_LABELS = [
'Resting 3',
];
-const ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned', 'Resting'];
+// Frame labels for orchestrator sprite sheet
+// Row 0 (frames 0-3): Idle 1, Idle 2, Concerned, (spare)
+// Row 1 (frames 4-7): Working 1, Working 2, Walking 1, Walking 2
+// Row 2 (frames 8-11): Celebrating 1, Celebrating 2, (spare), (spare)
+const ORCHESTRATOR_FRAME_LABELS = [
+ 'Idle 1',
+ 'Idle 2',
+ 'Concerned',
+ '(spare)',
+ 'Working 1',
+ 'Working 2',
+ 'Walking 1',
+ 'Walking 2',
+ 'Celebrating 1',
+ 'Celebrating 2',
+ '(spare)',
+ '(spare)',
+];
+
+const SUBAGENT_ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned', 'Resting'];
+const ORCHESTRATOR_ANIMATION_NAMES = ['Idle', 'Walking', 'Working', 'Celebrating', 'Concerned'];
/** Renders the 4x3 grid of individually labeled sprite frames as HTML. */
-function renderFrameGridHtml(dataUri: string): string {
- return FRAME_LABELS.map((label, i) => {
- const col = i % 4;
- const row = Math.floor(i / 4);
- return `
+function renderFrameGridHtml(dataUri: string, frameLabels: readonly string[]): string {
+ return frameLabels
+ .map((label, i) => {
+ const col = i % 4;
+ const row = Math.floor(i / 4);
+ return `
`;
- }).join('\n');
+ })
+ .join('\n');
}
/** Renders animation preview cells with element IDs for JS-driven playback. */
-function renderAnimationSectionsHtml(idPrefix: string): string {
- return ANIMATION_NAMES.map((name) => {
- const id = `${idPrefix}-${name.toLowerCase()}`;
- return `
+function renderAnimationSectionsHtml(idPrefix: string, animationNames: readonly string[]): string {
+ return animationNames
+ .map((name) => {
+ const id = `${idPrefix}-${name.toLowerCase()}`;
+ return `
`;
- }).join('\n');
+ })
+ .join('\n');
}
/** Generates a self-contained HTML preview page displaying labeled sprite frames and animated previews. */
@@ -108,22 +132,22 @@ export function renderPreviewHtml(subagentSvg: string, orchestratorSvg: string):
subagent
Frames
-${renderFrameGridHtml(subagentDataUri)}
+${renderFrameGridHtml(subagentDataUri, SUBAGENT_FRAME_LABELS)}
Animations
-${renderAnimationSectionsHtml('subagent')}
+${renderAnimationSectionsHtml('subagent', SUBAGENT_ANIMATION_NAMES)}
orchestrator
Frames
-${renderFrameGridHtml(orchestratorDataUri)}
+${renderFrameGridHtml(orchestratorDataUri, ORCHESTRATOR_FRAME_LABELS)}
Animations
-${renderAnimationSectionsHtml('orchestrator')}
+${renderAnimationSectionsHtml('orchestrator', ORCHESTRATOR_ANIMATION_NAMES)}
@@ -141,7 +165,7 @@ ${renderAnimationSectionsHtml('orchestrator')}
var FREEZE = 'Freeze';
// Animation definitions (duplicated from sprite-definitions.ts)
- var animations = [
+ var subagentAnimations = [
{ name: 'idle', frames: [{x:0,y:0},{x:1,y:0}], duration: 600, strategy: PING_PONG },
{ name: 'walking', frames: [{x:2,y:0}], duration: 200, strategy: LOOP },
{ name: 'working', frames: [{x:0,y:1},{x:1,y:1},{x:2,y:1}], duration: 300, strategy: LOOP },
@@ -150,13 +174,21 @@ ${renderAnimationSectionsHtml('orchestrator')}
{ name: 'resting', frames: [{x:3,y:0},{x:3,y:1},{x:3,y:2}], duration: 500, strategy: PING_PONG }
];
+ var orchestratorAnimations = [
+ { name: 'idle', frames: [{x:0,y:0},{x:1,y:0}], duration: 1200, strategy: PING_PONG },
+ { name: 'walking', frames: [{x:2,y:1},{x:3,y:1}], duration: 300, strategy: LOOP },
+ { name: 'working', frames: [{x:0,y:1},{x:1,y:1}], duration: 500, strategy: LOOP },
+ { name: 'celebrating', frames: [{x:0,y:2},{x:1,y:2}], duration: 250, strategy: LOOP },
+ { name: 'concerned', frames: [{x:2,y:0}], duration: 600, strategy: FREEZE },
+ ];
+
var prefixes = [
- { prefix: 'subagent', uri: subagentUri },
- { prefix: 'orchestrator', uri: orchestratorUri }
+ { prefix: 'subagent', uri: subagentUri, animations: subagentAnimations },
+ { prefix: 'orchestrator', uri: orchestratorUri, animations: orchestratorAnimations }
];
prefixes.forEach(function(cfg) {
- animations.forEach(function(anim) {
+ cfg.animations.forEach(function(anim) {
var el = document.getElementById(cfg.prefix + '-' + anim.name);
if (!el) return;
diff --git a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts
index 86d57354..9f3875e6 100644
--- a/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts
+++ b/packages/factory/src/client/visualizations/catwalk/actors/OrchestratorActor.ts
@@ -1,12 +1,6 @@
import { Actor, BaseAlign, Color, Font, Rectangle, Text, TextAlign, vec, type Vector } from 'excalibur';
-import {
- ACTIVE_OPACITY,
- ORCH_IDLE_OPACITY,
- PULSE_FREQUENCY,
- SCALE_PULSE_MAX,
- SCALE_PULSE_MIN,
-} from '../constants/animation.js';
+import { ACTIVE_OPACITY, ORCH_IDLE_OPACITY } from '../constants/animation.js';
import {
BADGE_OFFSET_Y,
CARRIED_ART_GAP,
@@ -23,10 +17,8 @@ export interface OrchestratorActorConfig {
working: boolean;
}
-/** Renders the orchestrator as an animated sprite on the catwalk rail, supporting walk and pulse animations. */
+/** Renders the orchestrator as an animated sprite on the catwalk rail, supporting walk and working animations. */
export class OrchestratorActor extends Actor {
- private _working = false;
- private _elapsed = 0;
private _carriedChildren: Actor[] = [];
private _badgeChild: Actor | undefined;
@@ -37,7 +29,6 @@ export class OrchestratorActor extends Actor {
const animation = getAnimation('orchestrator', config.working ? 'working' : 'idle');
this.graphics.use(animation);
- this._working = config.working;
this.graphics.opacity = config.working ? ACTIVE_OPACITY : ORCH_IDLE_OPACITY;
}
@@ -48,18 +39,15 @@ export class OrchestratorActor extends Actor {
.toPromise();
}
- /** Fade the orchestrator out to invisible and stop the working pulse. */
+ /** Fade the orchestrator out to invisible and stop the working animation. */
fadeOut(): void {
- this._working = false;
this.actions.fade(0, PAUSE_DURATION);
}
- /** Toggle the pulsing working glow and switch sprite animation. */
+ /** Toggle the working animation and switch sprite animation. */
setWorking(working: boolean): void {
- this._working = working;
const animation = getAnimation('orchestrator', working ? 'working' : 'idle');
this.graphics.use(animation);
- this._elapsed = 0;
if (working) {
this.graphics.opacity = ACTIVE_OPACITY;
} else {
@@ -68,9 +56,8 @@ export class OrchestratorActor extends Actor {
}
}
- /** Switch to the celebrating sprite animation and stop the working pulse. */
+ /** Switch to the celebrating sprite animation and stop the working animation. */
celebrate(): void {
- this._working = false;
this.scale = vec(1, 1);
this.graphics.opacity = ACTIVE_OPACITY;
const animation = getAnimation('orchestrator', 'celebrating');
@@ -125,12 +112,4 @@ export class OrchestratorActor extends Actor {
this.addChild(badge);
this._badgeChild = badge;
}
-
- override onPreUpdate(_engine: unknown, deltaMs: number): void {
- if (!this._working) return;
- this._elapsed += deltaMs;
- const t = Math.sin((this._elapsed * PULSE_FREQUENCY * Math.PI * 2) / 1000);
- const s = SCALE_PULSE_MIN + ((SCALE_PULSE_MAX - SCALE_PULSE_MIN) * (t + 1)) / 2;
- this.scale = vec(s, s);
- }
}
diff --git a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts
index 6bd28a65..72f1b6eb 100644
--- a/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts
+++ b/packages/factory/src/client/visualizations/catwalk/actors/__tests__/actors.test.ts
@@ -145,32 +145,24 @@ describe('OrchestratorActor', () => {
);
});
- it('setWorking(true) enables pulse flag and restores full opacity', () => {
+ it('setWorking(true) sets full opacity', () => {
const actor = new OrchestratorActor({ working: false }, vec(0, 0));
- expect(actor.graphics.opacity).toBe(ORCH_IDLE_OPACITY);
-
actor.setWorking(true);
-
expect(actor.graphics.opacity).toBe(ACTIVE_OPACITY);
-
- // Simulate onPreUpdate — scale should pulse within range
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock
- actor.onPreUpdate(undefined as never, 500);
- expect(actor.scale.x).toBeGreaterThanOrEqual(1);
- expect(actor.scale.x).toBeLessThanOrEqual(1.08);
- expect(actor.scale.y).toBe(actor.scale.x);
});
- it('setWorking(false) disables pulse and sets idle opacity', () => {
+ it('setWorking(false) sets idle opacity and resets scale', () => {
const actor = new OrchestratorActor({ working: true }, vec(0, 0));
actor.setWorking(false);
-
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock
- actor.onPreUpdate(undefined as never, 0);
expect(actor.graphics.opacity).toBe(ORCH_IDLE_OPACITY);
expect(actor.scale).toEqual({ x: 1, y: 1 });
});
+ it('does not have onPreUpdate (no scale pulse)', () => {
+ const ownMethods = Object.getOwnPropertyNames(OrchestratorActor.prototype);
+ expect(ownMethods).not.toContain('onPreUpdate');
+ });
+
it('calls getAnimation with orchestrator type and working state', () => {
new OrchestratorActor({ working: true }, vec(0, 0));
@@ -304,32 +296,17 @@ describe('OrchestratorActor', () => {
expect(mockGetAnimation).toHaveBeenCalledWith('orchestrator', 'celebrating');
});
- it('celebrate() disables working pulse and sets ACTIVE_OPACITY', () => {
+ it('celebrate() sets ACTIVE_OPACITY and resets scale', () => {
const actor = new OrchestratorActor({ working: true }, vec(0, 0));
-
actor.celebrate();
-
expect(actor.graphics.opacity).toBe(ACTIVE_OPACITY);
expect(actor.scale).toEqual({ x: 1, y: 1 });
-
- // Pulse should be disabled -- onPreUpdate should not change scale
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock
- actor.onPreUpdate(undefined as never, 500);
- expect(actor.scale).toEqual({ x: 1, y: 1 });
});
- it('fadeOut fades opacity to 0 and stops the working pulse', () => {
+ it('fadeOut calls actions.fade with opacity 0', () => {
const actor = new OrchestratorActor({ working: true }, vec(0, 0));
-
actor.fadeOut();
-
expect(actor.actions.fade).toHaveBeenCalledWith(0, expect.any(Number));
-
- // Working pulse should be disabled — onPreUpdate should be a no-op
- const opacityBefore = actor.graphics.opacity;
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Engine param unused in test mock
- actor.onPreUpdate(undefined as never, 500);
- expect(actor.graphics.opacity).toBe(opacityBefore);
});
});
diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts
index 6d103e48..73912160 100644
--- a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts
+++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/catwalk-sprite-loader.test.ts
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ ORCH_WORKING_DURATION,
+ ORCH_WORKING_FRAME_COORDINATES,
+ ORCH_WORKING_STRATEGY,
+ WORKING_DURATION,
+ WORKING_FRAME_COORDINATES,
+} from '../sprite-definitions.js';
+
const { mockImageSourceConstructor, mockImageSourceLoad, mockSpriteSheetFromImageSource, mockAnimationFromCoords } =
vi.hoisted(() => {
const mockLoad = vi.fn().mockResolvedValue(undefined);
@@ -127,6 +135,46 @@ describe('catwalk-sprite-loader', () => {
// 2 calls for the failed attempt + 2 for the retry = 4 ImageSource constructions
expect(mockImageSourceConstructor).toHaveBeenCalledTimes(4);
});
+
+ it('orchestrator working animation uses ORCH_WORKING_DURATION (500ms)', async () => {
+ await loadAllCatwalkSprites();
+
+ interface AnimCallConfig {
+ frameCoordinates: Array<{ x: number; y: number }>;
+ durationPerFrameMs: number;
+ strategy: string;
+ }
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- vi.fn() mock calls are untyped; assertion safe in test code
+ const calls = mockAnimationFromCoords.mock.calls as Array<[AnimCallConfig]>;
+ const orchCoord = ORCH_WORKING_FRAME_COORDINATES[0];
+ const workingCall = calls.find(
+ ([config]) =>
+ config.frameCoordinates.length === ORCH_WORKING_FRAME_COORDINATES.length &&
+ config.frameCoordinates[0]?.x === orchCoord?.x &&
+ config.frameCoordinates[0]?.y === orchCoord?.y,
+ );
+ expect(workingCall).toBeDefined();
+ expect(workingCall?.[0].durationPerFrameMs).toBe(ORCH_WORKING_DURATION);
+ expect(workingCall?.[0].strategy).toBe(ORCH_WORKING_STRATEGY);
+ });
+
+ it('subagent working animation still uses original WORKING_DURATION (300ms)', async () => {
+ await loadAllCatwalkSprites();
+
+ interface AnimCallConfig {
+ frameCoordinates: Array<{ x: number; y: number }>;
+ durationPerFrameMs: number;
+ strategy: string;
+ }
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- vi.fn() mock calls are untyped; assertion safe in test code
+ const calls = mockAnimationFromCoords.mock.calls as Array<[AnimCallConfig]>;
+ const subCoord = WORKING_FRAME_COORDINATES[0];
+ const workingCall = calls.find(
+ ([config]) => config.frameCoordinates[0]?.x === subCoord?.x && config.frameCoordinates[0]?.y === subCoord?.y,
+ );
+ expect(workingCall).toBeDefined();
+ expect(workingCall?.[0].durationPerFrameMs).toBe(WORKING_DURATION);
+ });
});
describe('getAnimation', () => {
diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts
new file mode 100644
index 00000000..a36b4612
--- /dev/null
+++ b/packages/factory/src/client/visualizations/catwalk/sprites/__tests__/sprite-definitions.test.ts
@@ -0,0 +1,283 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('excalibur', () => ({
+ AnimationStrategy: {
+ End: 'end',
+ Loop: 'loop',
+ PingPong: 'pingpong',
+ Freeze: 'freeze',
+ },
+}));
+
+const {
+ CELEBRATING_DURATION,
+ CELEBRATING_FRAME_COORDINATES,
+ CELEBRATING_STRATEGY,
+ CONCERNED_DURATION,
+ CONCERNED_FRAME_COORDINATES,
+ CONCERNED_STRATEGY,
+ GRID_COLUMNS,
+ GRID_ROWS,
+ IDLE_DURATION,
+ IDLE_FRAME_COORDINATES,
+ IDLE_STRATEGY,
+ ORCH_CELEBRATING_DURATION,
+ ORCH_CELEBRATING_FRAME_COORDINATES,
+ ORCH_CELEBRATING_STRATEGY,
+ ORCH_CONCERNED_DURATION,
+ ORCH_CONCERNED_FRAME_COORDINATES,
+ ORCH_CONCERNED_STRATEGY,
+ ORCH_IDLE_DURATION,
+ ORCH_IDLE_FRAME_COORDINATES,
+ ORCH_IDLE_STRATEGY,
+ ORCH_WALKING_DURATION,
+ ORCH_WALKING_FRAME_COORDINATES,
+ ORCH_WALKING_STRATEGY,
+ ORCH_WORKING_DURATION,
+ ORCH_WORKING_FRAME_COORDINATES,
+ ORCH_WORKING_STRATEGY,
+ RESTING_DURATION,
+ RESTING_FRAME_COORDINATES,
+ RESTING_STRATEGY,
+ WALKING_DURATION,
+ WALKING_FRAME_COORDINATES,
+ WALKING_STRATEGY,
+ WORKING_DURATION,
+ WORKING_FRAME_COORDINATES,
+ WORKING_STRATEGY,
+} = await import('../sprite-definitions.js');
+
+const { AnimationStrategy } = await import('excalibur');
+
+describe('sprite definitions', () => {
+ describe('grid dimensions', () => {
+ it('has 4 columns', () => {
+ expect(GRID_COLUMNS).toBe(4);
+ });
+
+ it('has 3 rows', () => {
+ expect(GRID_ROWS).toBe(3);
+ });
+ });
+
+ describe('idle frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of IDLE_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 2 frames', () => {
+ expect(IDLE_FRAME_COORDINATES).toHaveLength(2);
+ });
+ });
+
+ describe('walking frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of WALKING_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 1 frame', () => {
+ expect(WALKING_FRAME_COORDINATES).toHaveLength(1);
+ });
+ });
+
+ describe('working frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of WORKING_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 3 frames', () => {
+ expect(WORKING_FRAME_COORDINATES).toHaveLength(3);
+ });
+ });
+
+ describe('celebrating frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of CELEBRATING_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 2 frames', () => {
+ expect(CELEBRATING_FRAME_COORDINATES).toHaveLength(2);
+ });
+ });
+
+ describe('concerned frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of CONCERNED_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 1 frame', () => {
+ expect(CONCERNED_FRAME_COORDINATES).toHaveLength(1);
+ });
+ });
+
+ describe('resting frame coordinates', () => {
+ it('contains coordinates within grid bounds', () => {
+ for (const coord of RESTING_FRAME_COORDINATES) {
+ expect(coord.x).toBeGreaterThanOrEqual(0);
+ expect(coord.x).toBeLessThan(GRID_COLUMNS);
+ expect(coord.y).toBeGreaterThanOrEqual(0);
+ expect(coord.y).toBeLessThan(GRID_ROWS);
+ }
+ });
+
+ it('has 3 frames', () => {
+ expect(RESTING_FRAME_COORDINATES).toHaveLength(3);
+ });
+
+ it('has a positive duration', () => {
+ expect(RESTING_DURATION).toBeGreaterThan(0);
+ });
+
+ it('uses PingPong strategy', () => {
+ expect(RESTING_STRATEGY).toBe(AnimationStrategy.PingPong);
+ });
+ });
+
+ describe('duration constants', () => {
+ it('has a positive idle duration', () => {
+ expect(IDLE_DURATION).toBeGreaterThan(0);
+ });
+
+ it('has a positive walking duration', () => {
+ expect(WALKING_DURATION).toBeGreaterThan(0);
+ });
+
+ it('has a positive working duration', () => {
+ expect(WORKING_DURATION).toBeGreaterThan(0);
+ });
+
+ it('has a positive celebrating duration', () => {
+ expect(CELEBRATING_DURATION).toBeGreaterThan(0);
+ });
+
+ it('has a positive concerned duration', () => {
+ expect(CONCERNED_DURATION).toBeGreaterThan(0);
+ });
+ });
+
+ describe('animation strategies', () => {
+ it('uses PingPong strategy for idle', () => {
+ expect(IDLE_STRATEGY).toBe(AnimationStrategy.PingPong);
+ });
+
+ it('uses Loop strategy for walking', () => {
+ expect(WALKING_STRATEGY).toBe(AnimationStrategy.Loop);
+ });
+
+ it('uses Loop strategy for working', () => {
+ expect(WORKING_STRATEGY).toBe(AnimationStrategy.Loop);
+ });
+
+ it('uses PingPong strategy for celebrating', () => {
+ expect(CELEBRATING_STRATEGY).toBe(AnimationStrategy.PingPong);
+ });
+
+ it('uses Freeze strategy for concerned', () => {
+ expect(CONCERNED_STRATEGY).toBe(AnimationStrategy.Freeze);
+ });
+ });
+
+ describe('orchestrator frame coordinates', () => {
+ it('ORCH_IDLE_FRAME_COORDINATES has 2 frames at row 0', () => {
+ expect(ORCH_IDLE_FRAME_COORDINATES).toEqual([
+ { x: 0, y: 0 },
+ { x: 1, y: 0 },
+ ]);
+ });
+
+ it('ORCH_WORKING_FRAME_COORDINATES has 2 frames at row 1', () => {
+ expect(ORCH_WORKING_FRAME_COORDINATES).toEqual([
+ { x: 0, y: 1 },
+ { x: 1, y: 1 },
+ ]);
+ });
+
+ it('ORCH_WALKING_FRAME_COORDINATES has 2 frames at row 1', () => {
+ expect(ORCH_WALKING_FRAME_COORDINATES).toEqual([
+ { x: 2, y: 1 },
+ { x: 3, y: 1 },
+ ]);
+ });
+
+ it('ORCH_CELEBRATING_FRAME_COORDINATES has 2 frames at row 2', () => {
+ expect(ORCH_CELEBRATING_FRAME_COORDINATES).toEqual([
+ { x: 0, y: 2 },
+ { x: 1, y: 2 },
+ ]);
+ });
+
+ it('ORCH_CONCERNED_FRAME_COORDINATES has 1 frame at (2,0)', () => {
+ expect(ORCH_CONCERNED_FRAME_COORDINATES).toEqual([{ x: 2, y: 0 }]);
+ });
+ });
+
+ describe('orchestrator timing', () => {
+ it('ORCH_WORKING_DURATION is 500ms', () => {
+ expect(ORCH_WORKING_DURATION).toBe(500);
+ });
+
+ it('ORCH_CELEBRATING_DURATION is 250ms', () => {
+ expect(ORCH_CELEBRATING_DURATION).toBe(250);
+ });
+
+ it('ORCH_IDLE_DURATION is 1200ms', () => {
+ expect(ORCH_IDLE_DURATION).toBe(1200);
+ });
+
+ it('ORCH_WALKING_DURATION is 300ms', () => {
+ expect(ORCH_WALKING_DURATION).toBe(300);
+ });
+
+ it('ORCH_CONCERNED_DURATION is 600ms', () => {
+ expect(ORCH_CONCERNED_DURATION).toBe(600);
+ });
+ });
+
+ describe('orchestrator strategies', () => {
+ it('ORCH_WORKING_STRATEGY is Loop', () => {
+ expect(ORCH_WORKING_STRATEGY).toBe(AnimationStrategy.Loop);
+ });
+
+ // Note: This deliberately differs from subagent CELEBRATING_STRATEGY (PingPong).
+ it('ORCH_CELEBRATING_STRATEGY is Loop (not PingPong like subagent)', () => {
+ expect(ORCH_CELEBRATING_STRATEGY).toBe(AnimationStrategy.Loop);
+ });
+
+ it('ORCH_IDLE_STRATEGY is PingPong', () => {
+ expect(ORCH_IDLE_STRATEGY).toBe(AnimationStrategy.PingPong);
+ });
+
+ it('ORCH_WALKING_STRATEGY is Loop', () => {
+ expect(ORCH_WALKING_STRATEGY).toBe(AnimationStrategy.Loop);
+ });
+
+ it('ORCH_CONCERNED_STRATEGY is Freeze', () => {
+ expect(ORCH_CONCERNED_STRATEGY).toBe(AnimationStrategy.Freeze);
+ });
+ });
+});
diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg
index c5c095a6..674aed17 100644
--- a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg
+++ b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts
index 2d5f464f..5e7935e2 100644
--- a/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts
+++ b/packages/factory/src/client/visualizations/catwalk/sprites/catwalk-sprite-loader.ts
@@ -13,6 +13,21 @@ import {
IDLE_DURATION,
IDLE_FRAME_COORDINATES,
IDLE_STRATEGY,
+ ORCH_CELEBRATING_DURATION,
+ ORCH_CELEBRATING_FRAME_COORDINATES,
+ ORCH_CELEBRATING_STRATEGY,
+ ORCH_CONCERNED_DURATION,
+ ORCH_CONCERNED_FRAME_COORDINATES,
+ ORCH_CONCERNED_STRATEGY,
+ ORCH_IDLE_DURATION,
+ ORCH_IDLE_FRAME_COORDINATES,
+ ORCH_IDLE_STRATEGY,
+ ORCH_WALKING_DURATION,
+ ORCH_WALKING_FRAME_COORDINATES,
+ ORCH_WALKING_STRATEGY,
+ ORCH_WORKING_DURATION,
+ ORCH_WORKING_FRAME_COORDINATES,
+ ORCH_WORKING_STRATEGY,
RESTING_DURATION,
RESTING_FRAME_COORDINATES,
RESTING_STRATEGY,
@@ -30,36 +45,92 @@ const SPRITE_TYPES: readonly CatwalkSpriteType[] = ['subagent', 'orchestrator'];
let animationCache: Map> | undefined;
-/** Resolves frame coordinates and timing for the given animation state. */
-function frameConfigForState(state: AgentAnimationState): {
+interface FrameConfig {
frameCoordinates: ReadonlyArray<{ x: number; y: number }>;
- duration: number;
+ durationMs: number;
strategy: import('excalibur').AnimationStrategy;
-} {
+}
+
+/** Resolves frame coordinates and timing for orchestrator sprite states. */
+function orchestratorFrameConfig(state: AgentAnimationState): FrameConfig {
switch (state) {
case 'idle':
- return { frameCoordinates: IDLE_FRAME_COORDINATES, duration: IDLE_DURATION, strategy: IDLE_STRATEGY };
+ return {
+ frameCoordinates: ORCH_IDLE_FRAME_COORDINATES,
+ durationMs: ORCH_IDLE_DURATION,
+ strategy: ORCH_IDLE_STRATEGY,
+ };
case 'walking':
- return { frameCoordinates: WALKING_FRAME_COORDINATES, duration: WALKING_DURATION, strategy: WALKING_STRATEGY };
+ return {
+ frameCoordinates: ORCH_WALKING_FRAME_COORDINATES,
+ durationMs: ORCH_WALKING_DURATION,
+ strategy: ORCH_WALKING_STRATEGY,
+ };
case 'working':
- return { frameCoordinates: WORKING_FRAME_COORDINATES, duration: WORKING_DURATION, strategy: WORKING_STRATEGY };
+ return {
+ frameCoordinates: ORCH_WORKING_FRAME_COORDINATES,
+ durationMs: ORCH_WORKING_DURATION,
+ strategy: ORCH_WORKING_STRATEGY,
+ };
+ case 'celebrating':
+ return {
+ frameCoordinates: ORCH_CELEBRATING_FRAME_COORDINATES,
+ durationMs: ORCH_CELEBRATING_DURATION,
+ strategy: ORCH_CELEBRATING_STRATEGY,
+ };
+ case 'concerned':
+ return {
+ frameCoordinates: ORCH_CONCERNED_FRAME_COORDINATES,
+ durationMs: ORCH_CONCERNED_DURATION,
+ strategy: ORCH_CONCERNED_STRATEGY,
+ };
+ case 'resting':
+ // Orchestrator never enters resting; reuse idle.
+ return {
+ frameCoordinates: ORCH_IDLE_FRAME_COORDINATES,
+ durationMs: ORCH_IDLE_DURATION,
+ strategy: ORCH_IDLE_STRATEGY,
+ };
+ case 'deactivated':
+ // Deactivated agents reuse the idle animation; opacity is handled by the actor.
+ return {
+ frameCoordinates: ORCH_IDLE_FRAME_COORDINATES,
+ durationMs: ORCH_IDLE_DURATION,
+ strategy: ORCH_IDLE_STRATEGY,
+ };
+ default: {
+ const _exhaustive: never = state;
+ return _exhaustive;
+ }
+ }
+}
+
+/** Resolves frame coordinates and timing for subagent sprite states. */
+function subagentFrameConfig(state: AgentAnimationState): FrameConfig {
+ switch (state) {
+ case 'idle':
+ return { frameCoordinates: IDLE_FRAME_COORDINATES, durationMs: IDLE_DURATION, strategy: IDLE_STRATEGY };
+ case 'walking':
+ return { frameCoordinates: WALKING_FRAME_COORDINATES, durationMs: WALKING_DURATION, strategy: WALKING_STRATEGY };
+ case 'working':
+ return { frameCoordinates: WORKING_FRAME_COORDINATES, durationMs: WORKING_DURATION, strategy: WORKING_STRATEGY };
case 'celebrating':
return {
frameCoordinates: CELEBRATING_FRAME_COORDINATES,
- duration: CELEBRATING_DURATION,
+ durationMs: CELEBRATING_DURATION,
strategy: CELEBRATING_STRATEGY,
};
case 'concerned':
return {
frameCoordinates: CONCERNED_FRAME_COORDINATES,
- duration: CONCERNED_DURATION,
+ durationMs: CONCERNED_DURATION,
strategy: CONCERNED_STRATEGY,
};
case 'resting':
- return { frameCoordinates: RESTING_FRAME_COORDINATES, duration: RESTING_DURATION, strategy: RESTING_STRATEGY };
+ return { frameCoordinates: RESTING_FRAME_COORDINATES, durationMs: RESTING_DURATION, strategy: RESTING_STRATEGY };
case 'deactivated':
// Deactivated agents reuse the idle animation; opacity is handled by the actor.
- return { frameCoordinates: IDLE_FRAME_COORDINATES, duration: IDLE_DURATION, strategy: IDLE_STRATEGY };
+ return { frameCoordinates: IDLE_FRAME_COORDINATES, durationMs: IDLE_DURATION, strategy: IDLE_STRATEGY };
default: {
const _exhaustive: never = state;
return _exhaustive;
@@ -67,6 +138,14 @@ function frameConfigForState(state: AgentAnimationState): {
}
}
+/** Resolves frame coordinates and timing for the given animation state and sprite type. */
+function frameConfigForState(state: AgentAnimationState, spriteType: CatwalkSpriteType): FrameConfig {
+ if (spriteType === 'orchestrator') {
+ return orchestratorFrameConfig(state);
+ }
+ return subagentFrameConfig(state);
+}
+
const ALL_STATES: readonly AgentAnimationState[] = [
'idle',
'walking',
@@ -78,14 +157,17 @@ const ALL_STATES: readonly AgentAnimationState[] = [
];
/** Build animation objects for every state from the given sprite sheet. */
-function buildAnimationsForSheet(spriteSheet: SpriteSheet): Map {
+function buildAnimationsForSheet(
+ spriteSheet: SpriteSheet,
+ spriteType: CatwalkSpriteType,
+): Map {
const map = new Map();
for (const state of ALL_STATES) {
- const { frameCoordinates, duration, strategy } = frameConfigForState(state);
+ const { frameCoordinates, durationMs, strategy } = frameConfigForState(state, spriteType);
const animation = Animation.fromSpriteSheetCoordinates({
spriteSheet,
frameCoordinates: [...frameCoordinates],
- durationPerFrame: duration,
+ durationPerFrameMs: durationMs,
strategy,
});
map.set(state, animation);
@@ -119,7 +201,7 @@ export async function loadAllCatwalkSprites(): Promise {
},
});
- cache.set(spriteType, buildAnimationsForSheet(spriteSheet));
+ cache.set(spriteType, buildAnimationsForSheet(spriteSheet, spriteType));
}
// Populate cache synchronously so getAnimation() works immediately.
diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts b/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts
index 4835b580..6af0c446 100644
--- a/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts
+++ b/packages/factory/src/client/visualizations/catwalk/sprites/sprite-definitions.ts
@@ -50,3 +50,45 @@ export const WORKING_STRATEGY = AnimationStrategy.Loop;
export const CELEBRATING_STRATEGY = AnimationStrategy.PingPong;
export const CONCERNED_STRATEGY = AnimationStrategy.Freeze;
export const RESTING_STRATEGY = AnimationStrategy.PingPong;
+
+// ── Orchestrator-specific frame coordinates ──
+// The orchestrator uses a different sprite sheet layout than subagents.
+// Row 0: idle 1 (0,0), idle 2 (1,0), concerned (2,0), spare (3,0)
+// Row 1: working 1 (0,1), working 2 (1,1), walking 1 (2,1), walking 2 (3,1)
+// Row 2: celebrating 1 (0,2), celebrating 2 (1,2), spare (2,2), spare (3,2)
+
+export const ORCH_IDLE_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [
+ { x: 0, y: 0 },
+ { x: 1, y: 0 },
+];
+
+export const ORCH_WORKING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [
+ { x: 0, y: 1 },
+ { x: 1, y: 1 },
+];
+
+export const ORCH_WALKING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [
+ { x: 2, y: 1 },
+ { x: 3, y: 1 },
+];
+
+export const ORCH_CELEBRATING_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [
+ { x: 0, y: 2 },
+ { x: 1, y: 2 },
+];
+
+export const ORCH_CONCERNED_FRAME_COORDINATES: ReadonlyArray<{ x: number; y: number }> = [{ x: 2, y: 0 }];
+
+// Orchestrator timing (per frame)
+export const ORCH_IDLE_DURATION = 1200;
+export const ORCH_WORKING_DURATION = 500;
+export const ORCH_WALKING_DURATION = 300;
+export const ORCH_CELEBRATING_DURATION = 250;
+export const ORCH_CONCERNED_DURATION = 600;
+
+// Orchestrator strategies
+export const ORCH_IDLE_STRATEGY = AnimationStrategy.PingPong;
+export const ORCH_WORKING_STRATEGY = AnimationStrategy.Loop;
+export const ORCH_WALKING_STRATEGY = AnimationStrategy.Loop;
+export const ORCH_CELEBRATING_STRATEGY = AnimationStrategy.Loop;
+export const ORCH_CONCERNED_STRATEGY = AnimationStrategy.Freeze;