Add the whip to the office toolbar: slackers hustle back to their desks
- New bottom toolbar on the pixel office floor with a Whip tool; toggling it turns the cursor to a crosshair, clicks whip instead of opening the detail card, Escape puts the whip away - Simulation.whip(): an agent inside the snack bar or restroom drops whatever they are carrying and hustles back to their desk at 1.8x speed for a few seconds; agents at their desks, in the CEO office, or in the meeting room are working and stay untouched - Room membership decides the outcome (rectContains against the same floor-plan rooms the renderer draws), so the rule matches what the player sees - Unit tests cover the whip matrix; e2e drives the toolbar toggle in the live shell
This commit is contained in:
1 parent
2bb2acd635
commit
4ab20e679c
6 files changed
+161
-4
No files matched your search
@@ -124,6 +124,9 @@ export const zh: Record<string, string> = {
|
||||
"Edit task": "编辑任务",
|
||||
"Edit {name}": "编辑 {name}",
|
||||
"Edit profile": "编辑个人资料",
|
||||
"Whip": "鞭子",
|
||||
"Office tools": "办公室工具",
|
||||
"Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。",
|
||||
"Avatar": "头像",
|
||||
"Avatar emoji": "头像表情",
|
||||
"Avatar color": "头像颜色",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation
|
||||
import { AgentDetailCard } from "./office/AgentDetailCard";
|
||||
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
|
||||
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
const FPS_INTERVAL = 1000 / 24;
|
||||
|
||||
@@ -29,6 +30,8 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<"select" | "whip">("select");
|
||||
const toolRef = useRef(tool);
|
||||
const simRef = useRef<OfficeSimulation | null>(null);
|
||||
if (simRef.current === null) simRef.current = new OfficeSimulation();
|
||||
|
||||
@@ -59,9 +62,15 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
selectedIdRef.current = selectedId;
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
toolRef.current = tool;
|
||||
}, [tool]);
|
||||
|
||||
useEffect(() => {
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && selectedIdRef.current) setSelectedId(null);
|
||||
if (event.key !== "Escape") return;
|
||||
if (toolRef.current === "whip") setTool("select");
|
||||
else if (selectedIdRef.current) setSelectedId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onEscape);
|
||||
return () => window.removeEventListener("keydown", onEscape);
|
||||
@@ -201,6 +210,10 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
const point = worldPointFrom(event);
|
||||
if (!point) return;
|
||||
const hit = simRef.current!.hitTest(point);
|
||||
if (tool === "whip") {
|
||||
if (hit) simRef.current!.whip(hit.input.id);
|
||||
return;
|
||||
}
|
||||
setSelectedId(hit ? hit.input.id : null);
|
||||
};
|
||||
|
||||
@@ -208,6 +221,10 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
const canvas = canvasRef.current;
|
||||
const point = worldPointFrom(event);
|
||||
if (!canvas || !point) return;
|
||||
if (tool === "whip") {
|
||||
canvas.style.cursor = "crosshair";
|
||||
return;
|
||||
}
|
||||
canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default";
|
||||
};
|
||||
|
||||
@@ -224,6 +241,20 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
<AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="office-toolbar" role="toolbar" aria-label={t("Office tools")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`office-tool ${tool === "whip" ? "active" : ""}`.trim()}
|
||||
aria-pressed={tool === "whip"}
|
||||
title={t("Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.")}
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setTool(tool === "whip" ? "select" : "whip");
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>⚡</span> {t("Whip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildFloorPlan, findPath } from "./floor-plan";
|
||||
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types";
|
||||
import { buildFloorPlan, findPath, rectContains } from "./floor-plan";
|
||||
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, RoomKind, Vec } from "./office-types";
|
||||
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
||||
|
||||
export type AgentBehavior =
|
||||
@@ -21,6 +21,8 @@ export interface AgentSim {
|
||||
behavior: AgentBehavior;
|
||||
carryRemainingMs: number;
|
||||
settledMs: number;
|
||||
/** While positive, the agent hustles (a whip crack sent them home). */
|
||||
hustleMs: number;
|
||||
}
|
||||
|
||||
export interface PlayerSim {
|
||||
@@ -57,6 +59,9 @@ const MEETING_SPREAD_MS = 12_000;
|
||||
const MEAN_MEETING_INTERVAL_MS = 180_000;
|
||||
const CARRY_MIN_MS = 20_000;
|
||||
const CARRY_SPREAD_MS = 20_000;
|
||||
/** How long a whipped agent hustles, and how much faster they move. */
|
||||
const HUSTLE_MS = 6_000;
|
||||
const HUSTLE_MULTIPLIER = 1.8;
|
||||
|
||||
/**
|
||||
* Holds the living state of the office: who sits where and where everyone
|
||||
@@ -111,6 +116,7 @@ export class OfficeSimulation {
|
||||
}
|
||||
|
||||
private stepAgent(agent: AgentSim, dt: number): void {
|
||||
if (agent.hustleMs > 0) agent.hustleMs = Math.max(0, agent.hustleMs - dt);
|
||||
if (agent.carryRemainingMs > 0) {
|
||||
agent.carryRemainingMs -= dt;
|
||||
if (agent.carryRemainingMs <= 0) {
|
||||
@@ -209,7 +215,7 @@ export class OfficeSimulation {
|
||||
|
||||
private walkAlong(agent: AgentSim, dt: number): void {
|
||||
if (agent.behavior.kind !== "walking") return;
|
||||
let budget = AGENT_SPEED * dt;
|
||||
let budget = AGENT_SPEED * (agent.hustleMs > 0 ? HUSTLE_MULTIPLIER : 1) * dt;
|
||||
while (budget > 0) {
|
||||
const target = agent.behavior.path[agent.behavior.next];
|
||||
if (!target) {
|
||||
@@ -253,6 +259,31 @@ export class OfficeSimulation {
|
||||
agent.behavior = { kind: "seated" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
whip(agentId: string): boolean {
|
||||
const agent = this.agents.find((candidate) => candidate.input.id === agentId);
|
||||
if (!agent) return false;
|
||||
const room = this.roomAt({ x: agent.x, y: agent.y });
|
||||
if (room !== "snack" && room !== "restroom") return false;
|
||||
agent.carrying = null;
|
||||
agent.carryRemainingMs = 0;
|
||||
agent.hustleMs = HUSTLE_MS;
|
||||
this.sendHome(agent);
|
||||
return true;
|
||||
}
|
||||
|
||||
private roomAt(point: Vec): RoomKind | null {
|
||||
for (const room of this.plan.rooms) {
|
||||
if (rectContains(room.bounds, point)) return room.kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent whose drawn body contains the world point, preferring
|
||||
* the visually frontmost (largest y). Tests against the same positions the
|
||||
@@ -332,6 +363,7 @@ export class OfficeSimulation {
|
||||
behavior: prev?.behavior ?? { kind: "seated" },
|
||||
carryRemainingMs: prev?.carryRemainingMs ?? 0,
|
||||
settledMs: prev?.settledMs ?? 0,
|
||||
hustleMs: prev?.hustleMs ?? 0,
|
||||
};
|
||||
if (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y)) {
|
||||
this.sendHome(agent);
|
||||
|
||||
@@ -251,3 +251,33 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; border-radius: var(--r-sm); }
|
||||
.sidebar-foot-button:hover .sidebar-foot-name { text-decoration: underline; }
|
||||
.form-error { color: var(--red); font-size: var(--fs-caption); margin-top: 4px; }
|
||||
|
||||
/* Pixel-office bottom toolbar */
|
||||
.office-toolbar {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
|
||||
}
|
||||
.office-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.office-tool:hover { background: var(--hover); color: var(--text-1); }
|
||||
.office-tool.active { background: var(--accent); color: #fff; }
|
||||
@@ -85,6 +85,17 @@ test("the office floor opens agent details on click", async () => {
|
||||
}
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// The bottom toolbar carries the whip tool; in whip mode a click on an
|
||||
// at-desk agent disciplines nobody and opens no card.
|
||||
const whip = page.getByRole("button", { name: "Whip" });
|
||||
await expect(whip).toBeVisible();
|
||||
await whip.click();
|
||||
await expect(whip).toHaveAttribute("aria-pressed", "true");
|
||||
await page.mouse.click(clickX, clickY);
|
||||
await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(whip).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Hired agents take open-floor desks in hire order and are clickable too.
|
||||
await hireAgent(page, "Ava Patel", "Designer", "Design");
|
||||
await hireAgent(page, "Ken Ito", "Engineer", "Engineering");
|
||||
|
||||
@@ -189,6 +189,56 @@ describe("office simulation", () => {
|
||||
expect(sim.agents[0].behavior.kind).toBe("seated");
|
||||
});
|
||||
|
||||
test("the whip sends snack-bar and restroom slackers hustling home, snack abandoned", () => {
|
||||
const sim = new OfficeSimulation(() => 0);
|
||||
sim.sync([agentInput("a"), agentInput("b")]);
|
||||
const snacker = sim.agents[0];
|
||||
const snackSpot = sim.plan.amenities.find((spot) => spot.kind === "snack")!;
|
||||
snacker.x = snackSpot.stand.x;
|
||||
snacker.y = snackSpot.stand.y;
|
||||
snacker.carrying = "snack";
|
||||
snacker.behavior = { kind: "dwelling", spot: snackSpot, remainingMs: 60_000 };
|
||||
|
||||
expect(sim.whip("a")).toBe(true);
|
||||
expect(snacker.behavior.kind).toBe("walking");
|
||||
expect(snacker.carrying).toBeNull();
|
||||
expect(snacker.hustleMs).toBeGreaterThan(0);
|
||||
|
||||
const restroomSpot = sim.plan.amenities.find((spot) => spot.kind === "restroom")!;
|
||||
const rester = sim.agents[1];
|
||||
rester.x = restroomSpot.stand.x;
|
||||
rester.y = restroomSpot.stand.y;
|
||||
rester.behavior = { kind: "dwelling", spot: restroomSpot, remainingMs: 60_000 };
|
||||
expect(sim.whip("b")).toBe(true);
|
||||
expect(rester.behavior.kind).toBe("walking");
|
||||
|
||||
// The whipped walker makes it back to their desk.
|
||||
for (let i = 0; i < 4000 && sim.agents[0].behavior.kind !== "seated"; i++) sim.step(16, IDLE);
|
||||
expect(snacker.x).toBe(snacker.seat.x);
|
||||
expect(snacker.y).toBe(snacker.seat.y);
|
||||
});
|
||||
|
||||
test("the whip does nothing to agents at their desk or in a meeting", () => {
|
||||
const sim = new OfficeSimulation(() => 0);
|
||||
sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite")]);
|
||||
// Seated at the desk: working, untouched.
|
||||
expect(sim.whip("a")).toBe(false);
|
||||
expect(sim.agents[0].behavior.kind).toBe("seated");
|
||||
|
||||
// Dwelling in the meeting room: in a meeting, untouched.
|
||||
const meetingSpot = sim.plan.amenities.find((spot) => spot.kind === "meeting")!;
|
||||
const attendee = sim.agents[1];
|
||||
attendee.x = meetingSpot.stand.x;
|
||||
attendee.y = meetingSpot.stand.y;
|
||||
attendee.behavior = { kind: "dwelling", spot: meetingSpot, remainingMs: 20_000 };
|
||||
expect(sim.whip("b")).toBe(false);
|
||||
expect(attendee.behavior.kind).toBe("dwelling");
|
||||
|
||||
// The CEO in their own office is working, untouched.
|
||||
expect(sim.whip("ceo")).toBe(false);
|
||||
expect(sim.whip("nobody")).toBe(false);
|
||||
});
|
||||
|
||||
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