From c50f17190cd8ac389ac22ce294285a5733a7b5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 8 Jul 2026 23:41:33 -0400 Subject: [PATCH] Animate the whip: a crack at the strike point and a flinch on the target Every whip click now lands visibly, wherever the target is standing: - simulation.whip() always registers a WhipCrackEffect at the struck agent and starts their hit reaction; step() ages both away. The discipline rule is unchanged - only snack-bar/restroom slackers are sent hustling home; desk, CEO office, and meeting stay safe - office-renderer draws the strike: a lash sweeping in from the upper right that collapses into an impact starburst with an expanding ring (~450ms), while the struck agent shakes with anime-style pain sparks over their head (~600ms), fading as the flinch drains - The office e2e now collects pageerror during the run and lets the effect play out after a whip-mode click, so a throwing frame in the rAF loop fails the suite instead of dying silently --- src/renderer/src/pages/OfficePage.tsx | 4 +- .../src/pages/office/office-renderer.ts | 73 ++++++++++++++++++- src/renderer/src/pages/office/office-types.ts | 12 +++ src/renderer/src/pages/office/simulation.ts | 23 ++++-- tests/e2e/office.spec.ts | 9 +++ tests/unit/office-simulation.test.ts | 24 ++++++ 6 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 9d9227de..34cb49cc 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -156,6 +156,7 @@ export function OfficePage({ snapshot }: PageProps) { facing: agent.facing, walkPhase: agent.walkPhase, carrying: agent.carrying, + hitReactMs: agent.hitReactMs, })); if (sim.player) { people.push({ @@ -172,9 +173,10 @@ export function OfficePage({ snapshot }: PageProps) { facing: sim.player.facing, walkPhase: sim.player.walkPhase, carrying: null, + hitReactMs: 0, }); } - renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current); + renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current, sim.effects); // The detail card rides beside its agent, even while they walk. const cardBox = cardBoxRef.current; diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 183f41d9..562470f5 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -1,4 +1,4 @@ -import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types"; +import { HIT_REACT_MS, WHIP_CRACK_MS, type AgentActivity, type Camera, type FloorPlan, type Rect, type WhipCrackEffect } from "./office-types"; import { deskScreenRect, drawWorld } from "./scene-renderer"; import { skyScene } from "./sky"; import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; @@ -17,6 +17,8 @@ export interface RenderPerson { facing: Facing; walkPhase: number; carrying: Carrying; + /** Remaining flinch time after a whip strike; 0 when unbothered. */ + hitReactMs: number; } const ACTIVITY_DOT: Partial> = { @@ -45,6 +47,7 @@ export function renderOffice( timeMs: number, clock: Date, selectedId: string | null, + effects: WhipCrackEffect[] = [], ): void { ctx.fillStyle = "#14161c"; ctx.fillRect(0, 0, viewW, viewH); @@ -77,7 +80,10 @@ export function renderOffice( ctx.stroke(); } const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0; - drawPerson(ctx, person.x, person.y, person.appearance, { + // A struck agent shakes for the flinch duration, hardest right after the hit. + const flinchStrength = person.hitReactMs > 0 ? person.hitReactMs / HIT_REACT_MS : 0; + const shakeX = flinchStrength > 0 ? Math.sin(person.hitReactMs * 0.09) * 2.5 * flinchStrength : 0; + drawPerson(ctx, person.x + shakeX, person.y, person.appearance, { facing: person.facing, walkPhase: person.walkPhase, working, @@ -85,6 +91,7 @@ export function renderOffice( timeMs, animSeed: person.animSeed, }, person.isCeo); + if (flinchStrength > 0) drawPainMarks(ctx, person.x + shakeX, person.y, flinchStrength); const dot = ACTIVITY_DOT[person.activity]; if (dot) { ctx.fillStyle = dot; @@ -94,10 +101,72 @@ export function renderOffice( } } for (const person of ordered) drawNameLabel(ctx, person); + for (const effect of effects) drawWhipCrack(ctx, effect); ctx.restore(); } +/** Anime-style pain sparks over a flinching agent's head. */ +function drawPainMarks(ctx: CanvasRenderingContext2D, x: number, y: number, strength: number): void { + ctx.save(); + ctx.globalAlpha = Math.min(1, strength * 1.3); + ctx.strokeStyle = "#ff5d5d"; + ctx.lineWidth = 1.5; + const headY = y - 42; + for (const [dx, angle] of [[-9, 2.4], [9, 0.7], [0, 1.55]] as Array<[number, number]>) { + const cx = x + dx; + const cy = headY - Math.abs(dx) * 0.2; + ctx.beginPath(); + ctx.moveTo(cx - Math.cos(angle) * 3, cy - Math.sin(angle) * 3); + ctx.lineTo(cx + Math.cos(angle) * 3, cy + Math.sin(angle) * 3); + ctx.stroke(); + } + ctx.restore(); +} + +/** + * The strike itself: a lash sweeping in from the upper right that collapses + * into an impact starburst with an expanding ring, all gone in under half a + * second. + */ +function drawWhipCrack(ctx: CanvasRenderingContext2D, effect: WhipCrackEffect): void { + const progress = Math.min(1, effect.ageMs / WHIP_CRACK_MS); + ctx.save(); + + if (progress < 0.4) { + // Lash phase: a tapered stroke whipping down toward the target. + const sweep = progress / 0.4; + const startX = effect.x + 34 - sweep * 10; + const startY = effect.y - 34 + sweep * 6; + ctx.strokeStyle = "#ffe9a3"; + ctx.lineWidth = 2.5 - sweep; + ctx.globalAlpha = 0.95; + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.quadraticCurveTo(effect.x + 18 - sweep * 6, effect.y - 26, effect.x + sweep * 2, effect.y - sweep * 2); + ctx.stroke(); + } + + // Impact starburst, brightest at contact, shrinking as it fades. + const fade = 1 - progress; + const reach = 5 + progress * 9; + ctx.globalAlpha = fade; + ctx.strokeStyle = progress < 0.25 ? "#ffffff" : "#ffd75e"; + ctx.lineWidth = 1.5; + for (let ray = 0; ray < 6; ray++) { + const angle = (Math.PI / 3) * ray + 0.4; + ctx.beginPath(); + ctx.moveTo(effect.x + Math.cos(angle) * 3, effect.y + Math.sin(angle) * 3); + ctx.lineTo(effect.x + Math.cos(angle) * reach, effect.y + Math.sin(angle) * reach); + ctx.stroke(); + } + ctx.globalAlpha = fade * 0.55; + ctx.beginPath(); + ctx.arc(effect.x, effect.y, 4 + progress * 15, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +} + function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void { ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif"; const width = Math.ceil(ctx.measureText(person.name).width) + 10; diff --git a/src/renderer/src/pages/office/office-types.ts b/src/renderer/src/pages/office/office-types.ts index 2fa9d3c4..e6cb762d 100644 --- a/src/renderer/src/pages/office/office-types.ts +++ b/src/renderer/src/pages/office/office-types.ts @@ -61,6 +61,18 @@ export interface FloorPlan { entrance: Vec; } +/** A whip crack in flight: drawn at the strike point, fading as it ages. */ +export interface WhipCrackEffect { + x: number; + y: number; + ageMs: number; +} + +/** How long the crack flash stays on screen. */ +export const WHIP_CRACK_MS = 450; +/** How long a struck agent visibly flinches. */ +export const HIT_REACT_MS = 600; + export type AgentActivity = "active" | "thinking" | "busy" | "idle"; /** Snapshot-derived facts the office simulation needs about one agent. */ diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index 91ebee16..f9ea2440 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -1,5 +1,5 @@ import { buildFloorPlan, findPath, rectContains } from "./floor-plan"; -import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, RoomKind, Vec } from "./office-types"; +import { HIT_REACT_MS, WHIP_CRACK_MS, type AmenitySpot, type Camera, type FloorPlan, type OfficeAgentInput, type Rect, type RoomKind, type Vec, type WhipCrackEffect } from "./office-types"; import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; export type AgentBehavior = @@ -23,6 +23,8 @@ export interface AgentSim { settledMs: number; /** While positive, the agent hustles (a whip crack sent them home). */ hustleMs: number; + /** While positive, the agent flinches from a whip strike. */ + hitReactMs: number; } export interface PlayerSim { @@ -72,6 +74,8 @@ export class OfficeSimulation { plan: FloorPlan; agents: AgentSim[] = []; player: PlayerSim | null = null; + /** Transient whip-crack flashes, aged by step() and dropped when spent. */ + effects: WhipCrackEffect[] = []; private openDeskDemand = -1; private readonly random: () => number; @@ -112,11 +116,16 @@ export class OfficeSimulation { const dt = Math.min(dtMs, MAX_STEP_MS); if (this.player) this.stepPlayer(this.player, dt, input); for (const agent of this.agents) this.stepAgent(agent, dt); + if (this.effects.length > 0) { + for (const effect of this.effects) effect.ageMs += dt; + this.effects = this.effects.filter((effect) => effect.ageMs < WHIP_CRACK_MS); + } if (this.random() < dt / MEAN_MEETING_INTERVAL_MS) this.conveneMeeting(); } private stepAgent(agent: AgentSim, dt: number): void { if (agent.hustleMs > 0) agent.hustleMs = Math.max(0, agent.hustleMs - dt); + if (agent.hitReactMs > 0) agent.hitReactMs = Math.max(0, agent.hitReactMs - dt); if (agent.carryRemainingMs > 0) { agent.carryRemainingMs -= dt; if (agent.carryRemainingMs <= 0) { @@ -260,14 +269,17 @@ export class OfficeSimulation { } /** - * Cracks the whip at an agent. Slacking in the snack bar or restroom sends - * them hustling back to their desk (snack abandoned); agents at their desk, - * in the CEO office, or in a meeting are working and stay untouched. - * Returns true when the whip connected. + * Cracks the whip at an agent. The crack flash and the target's flinch + * play wherever the strike lands — desk, corridor, anywhere. Only slackers + * in the snack bar or restroom are additionally sent hustling back to + * their desk (snack abandoned); agents at their desk, in the CEO office, + * or in a meeting keep working. Returns true when someone was sent home. */ whip(agentId: string): boolean { const agent = this.agents.find((candidate) => candidate.input.id === agentId); if (!agent) return false; + this.effects.push({ x: agent.x, y: agent.y - 18, ageMs: 0 }); + agent.hitReactMs = HIT_REACT_MS; const room = this.roomAt({ x: agent.x, y: agent.y }); if (room !== "snack" && room !== "restroom") return false; agent.carrying = null; @@ -364,6 +376,7 @@ export class OfficeSimulation { carryRemainingMs: prev?.carryRemainingMs ?? 0, settledMs: prev?.settledMs ?? 0, hustleMs: prev?.hustleMs ?? 0, + hitReactMs: prev?.hitReactMs ?? 0, }; if (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y)) { this.sendHome(agent); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 79849a5a..7c53b9af 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -22,6 +22,10 @@ test("the office floor opens agent details on click", async () => { try { const page = await app.firstWindow(); + // The office renders in a rAF loop; a throwing frame (e.g. a broken + // effect draw) surfaces only as a page error, so collect them. + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole("button", { name: "Get started" }).click(); await page.getByLabel("API key").fill("office-e2e-key"); @@ -93,6 +97,9 @@ test("the office floor opens agent details on click", async () => { await expect(whip).toHaveAttribute("aria-pressed", "true"); await page.mouse.click(clickX, clickY); await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible(); + // Let the crack flash and flinch play out a few frames - a throwing + // effect draw would land in pageErrors and fail the final assertion. + await page.waitForTimeout(700); await page.keyboard.press("Escape"); await expect(whip).toHaveAttribute("aria-pressed", "false"); @@ -118,6 +125,8 @@ test("the office floor opens agent details on click", async () => { const outDir = resolve("test-results/screenshots"); mkdirSync(outDir, { recursive: true }); await page.screenshot({ path: join(outDir, "19-office-populated.png"), fullPage: false }); + + expect(pageErrors).toEqual([]); } finally { await app.close(); } diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index e73dbcfb..d96fb900 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -239,6 +239,30 @@ describe("office simulation", () => { expect(sim.whip("nobody")).toBe(false); }); + test("every whip strike plays a crack and a flinch, wherever the target is", () => { + const sim = new OfficeSimulation(() => 0); + sim.sync([agentInput("a")]); + const agent = sim.agents[0]; + + // Struck at the desk: no discipline effect, but the animation still fires. + expect(sim.whip("a")).toBe(false); + expect(agent.behavior.kind).toBe("seated"); + expect(agent.hitReactMs).toBeGreaterThan(0); + expect(sim.effects).toHaveLength(1); + expect(sim.effects[0]).toMatchObject({ x: agent.x, ageMs: 0 }); + + // A second strike stacks another crack; a missing target adds nothing. + sim.whip("a"); + expect(sim.effects).toHaveLength(2); + sim.whip("nobody"); + expect(sim.effects).toHaveLength(2); + + // Both the crack flash and the flinch drain away with time. + for (let i = 0; i < 50; i++) sim.step(16, IDLE); + expect(sim.effects).toHaveLength(0); + expect(agent.hitReactMs).toBe(0); + }); + test("clicks hit the agent drawn at that spot, frontmost first", () => { const sim = new OfficeSimulation(); sim.sync([agentInput("a"), agentInput("b")]);