Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Actor, BaseAlign, Color, Font, GraphicsGroup, Rectangle, Text, TextAlign, vec, type Vector } from 'excalibur';

import { ART_H, ART_W } from '../constants/dimensions.js';
import { PAUSE_DURATION } from '../constants/timing.js';

export interface ArtifactActorConfig {
label: string;
color: string;
}

/** Renders an artifact as a small colored rectangle with a label, supporting fade-in animation. */
export class ArtifactActor extends Actor {
constructor(config: ArtifactActorConfig, position: Vector) {
super({ pos: position });
Expand Down Expand Up @@ -49,4 +51,10 @@ export class ArtifactActor extends Actor {
updateConfig(_config: ArtifactActorConfig): void {
// Intentionally empty.
}

/** Fade in from invisible. */
fadeIn(): void {
this.graphics.opacity = 0;
this.actions.fade(1, PAUSE_DURATION);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Actor, Color, Rectangle, type Vector } from 'excalibur';
import { Actor, Color, Rectangle, vec, type Vector } from 'excalibur';

import { GATE_W, ORCH_RADIUS } from '../constants/dimensions.js';
import { PAUSE_DURATION } from '../constants/timing.js';

const ORCH_COLOR = '#FFD700';
const GATE_H = Math.round(ORCH_RADIUS * 1.6);
Expand All @@ -9,6 +10,7 @@ export interface GateActorConfig {
open: boolean;
}

/** Renders a gate barrier between adjacent stations, supporting animated open transitions. */
export class GateActor extends Actor {
constructor(config: GateActorConfig, position: Vector) {
super({ pos: position });
Expand All @@ -27,4 +29,10 @@ export class GateActor extends Actor {
updateConfig(config: GateActorConfig): void {
this.graphics.isVisible = !config.open;
}

/** Animate the gate opening by scaling Y to zero over PAUSE_DURATION ms. */
animateOpen(): void {
const scaleSpeed = 1 / (PAUSE_DURATION / 1000);
this.actions.scaleTo(vec(1, 0), vec(scaleSpeed, scaleSpeed));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Actor, BaseAlign, Color, Font, GraphicsGroup, Rectangle, Text, TextAlign, vec, type Vector } from 'excalibur';

import { ORCH_IDLE_OPACITY, ORCH_PULSE_MAX, ORCH_PULSE_MIN, PULSE_FREQUENCY } from '../constants/animation.js';
import { ORCH_RADIUS } from '../constants/dimensions.js';
import { WALK_SPEED } from '../constants/timing.js';

const ORCH_W = Math.round(ORCH_RADIUS * 2.2);
const ORCH_H = Math.round(ORCH_RADIUS * 1.6);
Expand All @@ -10,7 +12,11 @@ export interface OrchestratorActorConfig {
working: boolean;
}

/** Renders the orchestrator as a gold rectangle with label, supporting walk and pulse animations. */
export class OrchestratorActor extends Actor {
private _working = false;
private _elapsed = 0;

constructor(config: OrchestratorActorConfig, position: Vector) {
super({ pos: position });

Expand Down Expand Up @@ -41,10 +47,28 @@ export class OrchestratorActor extends Actor {
});

this.graphics.use(group);
this.graphics.opacity = config.working ? 1 : 0.8;
this._working = config.working;
this.graphics.opacity = config.working ? ORCH_PULSE_MAX : ORCH_IDLE_OPACITY;
}

/** Slide the orchestrator to a new position along the catwalk rail. */
animateMoveTo(pos: Vector): void {
this.actions.moveTo(pos, WALK_SPEED);
}

/** Toggle the pulsing working glow. */
setWorking(working: boolean): void {
this._working = working;
if (!working) {
this._elapsed = 0;
this.graphics.opacity = ORCH_IDLE_OPACITY;
}
}

updateConfig(config: OrchestratorActorConfig): void {
this.graphics.opacity = config.working ? 1 : 0.8;
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);
this.graphics.opacity = ORCH_PULSE_MIN + ((ORCH_PULSE_MAX - ORCH_PULSE_MIN) * (t + 1)) / 2;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Actor, BaseAlign, Circle, Color, Font, GraphicsGroup, Text, TextAlign, vec, type Vector } from 'excalibur';

import { AGENT_PULSE_MAX, AGENT_PULSE_MIN, DEACTIVATED_OPACITY, PULSE_FREQUENCY } from '../constants/animation.js';
import { AGENT_RADIUS } from '../constants/dimensions.js';
import { PAUSE_DURATION } from '../constants/timing.js';
import type { AgentAnimationState } from '../types.js';

export interface StationAgentActorConfig {
Expand All @@ -17,6 +19,8 @@ function opacityForState(state: AgentAnimationState): number {
return 0.3;
case 'resting':
return 0.6;
case 'deactivated':
return DEACTIVATED_OPACITY;
case 'working':
case 'walking':
case 'celebrating':
Expand All @@ -31,8 +35,14 @@ function opacityForState(state: AgentAnimationState): number {

/** Renders a station-bound agent as a colored circle with a role label, dimmed by animation state. */
export class StationAgentActor extends Actor {
private _state: AgentAnimationState;
private _pulsing = false;
private _elapsed = 0;

constructor(config: StationAgentActorConfig, position: Vector) {
super({ pos: position });
this._state = config.state;
this._pulsing = config.state === 'working';

const circle = new Circle({
radius: AGENT_RADIUS,
Expand Down Expand Up @@ -63,7 +73,27 @@ export class StationAgentActor extends Actor {
this.graphics.opacity = opacityForState(config.state);
}

updateConfig(config: StationAgentActorConfig): void {
this.graphics.opacity = opacityForState(config.state);
/** Animate a transition to a new state with opacity fade and optional pulse. */
animateToState(state: AgentAnimationState): void {
this._state = state;
this._pulsing = state === 'working';
if (this._pulsing) {
this._elapsed = 0;
} else {
this.actions.fade(opacityForState(state), PAUSE_DURATION);
}
}

/** Fade in from invisible to state-appropriate opacity. */
fadeIn(): void {
this.graphics.opacity = 0;
this.actions.fade(opacityForState(this._state), PAUSE_DURATION);
}

override onPreUpdate(_engine: unknown, deltaMs: number): void {
if (!this._pulsing) return;
this._elapsed += deltaMs;
const t = Math.sin((this._elapsed * PULSE_FREQUENCY * Math.PI * 2) / 1000);
this.graphics.opacity = AGENT_PULSE_MIN + ((AGENT_PULSE_MAX - AGENT_PULSE_MIN) * (t + 1)) / 2;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ const { mockActorConstructor, mockGraphicsUse } = vi.hoisted(() => {
vi.mock('excalibur', () => {
class MockActor {
config: Record<string, unknown>;
graphics = { use: mockGraphicsUse, opacity: 1, isVisible: true };
graphics = { use: mockGraphicsUse, opacity: 1, isVisible: true, scale: { x: 1, y: 1 } };
pos = { x: 0, y: 0 };
actions = {
moveTo: vi.fn().mockReturnValue({ toPromise: vi.fn().mockResolvedValue(undefined) }),
fade: vi.fn().mockReturnValue({ toPromise: vi.fn().mockResolvedValue(undefined) }),
scaleTo: vi.fn().mockReturnValue({ toPromise: vi.fn().mockResolvedValue(undefined) }),
clearActions: vi.fn(),
};
constructor(config: Record<string, unknown>) {
mockActorConstructor(config);
this.config = config;
Expand Down Expand Up @@ -104,15 +111,31 @@ describe('OrchestratorActor', () => {
expect(actor.graphics.opacity).toBe(0.8);
});

it('updateConfig toggles opacity', () => {
it('animateMoveTo calls actions.moveTo with position and walk speed', () => {
const actor = new OrchestratorActor({ working: false }, vec(0, 100));
actor.animateMoveTo(vec(200, 100));

expect(actor.actions.moveTo).toHaveBeenCalledWith(expect.objectContaining({ x: 200, y: 100 }), expect.any(Number));
});

it('setWorking(true) enables pulse flag', () => {
const actor = new OrchestratorActor({ working: false }, vec(0, 0));
actor.setWorking(true);

// Simulate onPreUpdate — opacity should differ from static value
// 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).toBeGreaterThanOrEqual(0.6);
expect(actor.graphics.opacity).toBeLessThanOrEqual(1);
});

it('setWorking(false) disables pulse and sets idle opacity', () => {
const actor = new OrchestratorActor({ working: true }, vec(0, 0));
expect(actor.graphics.opacity).toBe(1);
actor.setWorking(false);

actor.updateConfig({ working: 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(0.8);

actor.updateConfig({ working: true });
expect(actor.graphics.opacity).toBe(1);
});
});

Expand Down Expand Up @@ -157,16 +180,60 @@ describe('StationAgentActor', () => {
expect(actor.graphics.opacity).toBe(1);
});

it('updateConfig toggles opacity by state', () => {
it('sets opacity to DEACTIVATED_OPACITY for deactivated state', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'deactivated' }, vec(0, 0));

expect(actor.graphics.opacity).toBe(0.15);
});

it('animateToState fades to target opacity via actions.fade', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'idle' }, vec(0, 0));
expect(actor.graphics.opacity).toBe(0.3);
actor.animateToState('resting');

actor.updateConfig({ id: 'a', role: 'arch', color: '#5555FF', state: 'working' });
expect(actor.graphics.opacity).toBe(1);
expect(actor.actions.fade).toHaveBeenCalledWith(0.6, expect.any(Number));
});

actor.updateConfig({ id: 'a', role: 'arch', color: '#5555FF', state: 'resting' });
it('animateToState enables pulse when transitioning to working', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'idle' }, vec(0, 0));
actor.animateToState('working');

// Simulate onPreUpdate
// 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).toBeGreaterThanOrEqual(0.7);
expect(actor.graphics.opacity).toBeLessThanOrEqual(1);
});

it('animateToState disables pulse when leaving working', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'working' }, vec(0, 0));
actor.animateToState('resting');

expect(actor.actions.fade).toHaveBeenCalledWith(0.6, expect.any(Number));

// After leaving working, onPreUpdate should not override the resting opacity.
// Set opacity to the resting value to simulate fade completion, then verify
// that onPreUpdate does not change it (pulse is disabled).
actor.graphics.opacity = 0.6;
// 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(0.6);
});

it('fadeIn sets opacity to 0 then fades to state-appropriate opacity', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'resting' }, vec(0, 0));
actor.fadeIn();

// Opacity should be synchronously set to 0 before fade is called
expect(actor.graphics.opacity).toBe(0);
expect(actor.actions.fade).toHaveBeenCalledWith(0.6, expect.any(Number));
});

it('animateToState to deactivated fades to DEACTIVATED_OPACITY', () => {
const actor = new StationAgentActor({ id: 'a', role: 'arch', color: '#5555FF', state: 'working' }, vec(0, 0));
actor.animateToState('deactivated');

expect(actor.actions.fade).toHaveBeenCalledWith(0.15, expect.any(Number));
});
});

describe('CatwalkStationActor', () => {
Expand Down Expand Up @@ -235,6 +302,15 @@ describe('ArtifactActor', () => {

expect(() => actor.updateConfig({ label: 'updated', color: '#FF0000' })).not.toThrow();
});

it('fadeIn sets opacity to 0 then calls actions.fade to 1', () => {
const actor = new ArtifactActor({ label: 'plan', color: '#AAFFAA' }, vec(0, 0));
actor.fadeIn();

// Opacity should be synchronously set to 0 before fade is called
expect(actor.graphics.opacity).toBe(0);
expect(actor.actions.fade).toHaveBeenCalledWith(1, expect.any(Number));
});
});

describe('GateActor', () => {
Expand Down Expand Up @@ -266,6 +342,16 @@ describe('GateActor', () => {
actor.updateConfig({ open: false });
expect(actor.graphics.isVisible).toBe(true);
});

it('animateOpen scales Y to 0 via actions.scaleTo', () => {
const actor = new GateActor({ open: false }, vec(100, 100));
actor.animateOpen();

expect(actor.actions.scaleTo).toHaveBeenCalledWith(
expect.objectContaining({ x: 1, y: 0 }),
expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }),
);
});
});

describe('ChuteActor', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';

import {
AGENT_PULSE_MAX,
AGENT_PULSE_MIN,
DEACTIVATED_OPACITY,
ORCH_IDLE_OPACITY,
ORCH_PULSE_MAX,
ORCH_PULSE_MIN,
PULSE_FREQUENCY,
} from '../animation.js';

describe('animation constants', () => {
it('exports DEACTIVATED_OPACITY as a number between 0 and 1', () => {
expect(DEACTIVATED_OPACITY).toBeGreaterThan(0);
expect(DEACTIVATED_OPACITY).toBeLessThan(1);
});

it('exports orchestrator pulse range where min < max', () => {
expect(ORCH_PULSE_MIN).toBeLessThan(ORCH_PULSE_MAX);
expect(ORCH_PULSE_MIN).toBeGreaterThan(0);
expect(ORCH_PULSE_MAX).toBeLessThanOrEqual(1);
});

it('exports agent pulse range where min < max', () => {
expect(AGENT_PULSE_MIN).toBeLessThan(AGENT_PULSE_MAX);
expect(AGENT_PULSE_MIN).toBeGreaterThan(0);
expect(AGENT_PULSE_MAX).toBeLessThanOrEqual(1);
});

it('exports PULSE_FREQUENCY as a positive number', () => {
expect(PULSE_FREQUENCY).toBeGreaterThan(0);
});

it('exports ORCH_IDLE_OPACITY between 0 and 1', () => {
expect(ORCH_IDLE_OPACITY).toBeGreaterThan(0);
expect(ORCH_IDLE_OPACITY).toBeLessThan(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Opacity for agents that have completed their role and are no longer active. */
export const DEACTIVATED_OPACITY = 0.15;

/** Orchestrator opacity when not working. */
export const ORCH_IDLE_OPACITY = 0.8;

/** Orchestrator pulse opacity range (working state). */
export const ORCH_PULSE_MIN = 0.6;
export const ORCH_PULSE_MAX = 1;

/** Agent pulse opacity range (working state). */
export const AGENT_PULSE_MIN = 0.7;
export const AGENT_PULSE_MAX = 1;

/** Pulse oscillation frequency in cycles per second. */
export const PULSE_FREQUENCY = 1.5;
Loading