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
This commit is contained in:
1 parent
1f89ea7089
commit
c50f17190c
6 files changed
+137
-8
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<Record<AgentActivity, string>> = {
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
Reference in new issue
Block a user