From b3f75620db263533ca485d3f7753682d9fdb83b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:43:26 +0000 Subject: [PATCH 1/2] feat(studio): a command center and a floor that lives The Live floor's harness table seated every phase agent round one table, which crowded the scene with name tags. The harness now waits inside a glass command center: a mission screen names the phase, the crew sits on stools as small chips, and whoever is on walks out through a sliding door onto a pad, speaks, and walks back in. Everyone else has a mood (floorLife, pure and deterministic): they watch the worker, take coffee breaks, stretch, stroll round the table, and doze off at the desk after a long wait. A table whose tickets are all done celebrates with beers, a kick-about and a dance; when the run ships the whole floor joins in. Figures get limbs and per-person looks (floorRig, RigBody) so the poses read. The flat map shows the same moods and the same command center, and the dossier says what an idle person is up to. After a run the 3D floor keeps living at an ambient 30 fps for a few minutes, then settles to on-demand; reduced motion holds still poses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MursiSjrf51muBCzrWus9Y --- docs/changelog.md | 9 + docs/squads.md | 5 +- docs/studio.md | 2 +- .../components/Live/floor/FloorDossier.tsx | 13 + web/src/components/Live/floor/RigBody.tsx | 101 ++ .../components/Live/floor/TeamFloor.test.tsx | 42 + web/src/components/Live/floor/TeamFloor3D.tsx | 929 +++++++++++++----- .../components/Live/floor/TeamFloorFlat.tsx | 148 ++- .../components/Live/floor/floorLife.test.ts | 86 ++ web/src/components/Live/floor/floorLife.ts | 156 +++ web/src/components/Live/floor/floorRig.ts | 312 ++++++ web/src/styles/globals.css | 38 + 12 files changed, 1547 insertions(+), 294 deletions(-) create mode 100644 web/src/components/Live/floor/RigBody.tsx create mode 100644 web/src/components/Live/floor/floorLife.test.ts create mode 100644 web/src/components/Live/floor/floorLife.ts create mode 100644 web/src/components/Live/floor/floorRig.ts diff --git a/docs/changelog.md b/docs/changelog.md index 1ffaa7b..43ffec0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,15 @@ ### Added +- **A command center and a floor that lives.** On the Live floor the harness's + agents wait inside a glass command center instead of crowding a table; whoever + is on walks out through its sliding door onto a pad, speaks, and walks back. + Everyone else has a mood — watching the worker, a coffee break, a stretch, a + walk round the table, a nap after a long wait — shown as a pose, a badge and a + line in their dossier. A table whose tickets are all done celebrates (beers, a + kick-about, a dance), and the whole floor joins in when the run ships. The + flat map shows the same moods; after a run the 3D floor keeps living at an + ambient 30 fps for a few minutes, then settles. - **Dynamic or Strict teams, on the run bar.** Dynamic is the default and says so: the dispatcher picks the teams, how they work and who manages them, and names its pick for the request being typed. Picking teams switches to Strict — diff --git a/docs/squads.md b/docs/squads.md index cf671a6..0a26f50 100644 --- a/docs/squads.md +++ b/docs/squads.md @@ -78,8 +78,9 @@ screen: Picking a team switches to Strict; the ✕ on the button, or *Dynamic* in its menu, goes back. The choice governs one run: the server restores the saved pins -when it ends. The dispatcher says what it decided on the run log, and sits at -the harness table on the floor: +when it ends. The dispatcher says what it decided on the run log, and waits in +the floor's command center (the harness's own room) with the phase agents — +stepping out onto its pad while it speaks: ```text compose dispatcher dynamic — picked from the request and the workspace: 2 teams build in parallel … diff --git a/docs/studio.md b/docs/studio.md index 9bb8b96..518062c 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build. | Route | Page | What it does | |---|---|---| -| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters | +| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket; the harness in a **command center** whose crew walks out onto a pad when it is their turn; idle people who watch, take coffee breaks, stroll and nap, and a done table that celebrates with beers, football and a dance), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters | | `/board` | **Board** | Kanban — add, edit, delete, move, delegate, drag mid-run | | `/review` | **Review** | Pending changes from `permission: review`, as diffs, with per-file apply/reject | | `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution | diff --git a/web/src/components/Live/floor/FloorDossier.tsx b/web/src/components/Live/floor/FloorDossier.tsx index 19341a3..e3773e3 100644 --- a/web/src/components/Live/floor/FloorDossier.tsx +++ b/web/src/components/Live/floor/FloorDossier.tsx @@ -6,6 +6,7 @@ import { teamColor } from '@/components/Board/teamColor'; import { isTypingTarget } from '@/hooks/useKeyboard'; import { agentTrail, workTables, DISPATCHER_ID, type FloorAgent, type FloorModel, type FloorStageSeat, type FloorTeam, type FloorTicket } from './floorModel'; import { TICKET_HEX, TICKET_LABEL, ago, clock, findAgent, findTicket, glyphFor, seatTitle, type FloorSelection } from './floorShared'; +import { MOOD_GLYPH, MOOD_LABEL, floorShipped, moodFor, seedOf, tableParties } from './floorLife'; // ── The dossier: who is this, what are they doing ──────────────────────── // @@ -173,6 +174,7 @@ function AgentDossier({ )} {held && held.title &&
{held.title}
} + {!active && } {/* Management */} @@ -347,6 +349,16 @@ function TicketDossier({ } /** A phase agent on the pipeline's stage: what phase, whether speaking, what was said. */ +/** What someone idle is up to meanwhile — the same mood the stage draws. */ +function MoodLine({ id, since, running, partying, now }: { id: string; since?: number; running: boolean; partying: boolean; now: number }) { + const mood = moodFor({ active: false, running, partying, since: since ?? now, now, seed: seedOf(id) }); + return ( +
+ meanwhile: {MOOD_LABEL[mood]} +
+ ); +} + function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: FloorStageSeat; floor: FloorModel; events: RunEvent[]; running: boolean; now: number; onSelect: (sel: FloorSelection) => void }) { const trail = useMemo(() => agentTrail(events, seat.id, 8), [events, seat.id]); const active = seat.active && running; @@ -371,6 +383,7 @@ function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: F {seat.lastMessage && (
{seat.lastMessage}
)} + {!active && }
{seat.id === DISPATCHER_ID diff --git a/web/src/components/Live/floor/RigBody.tsx b/web/src/components/Live/floor/RigBody.tsx new file mode 100644 index 0000000..603e6cc --- /dev/null +++ b/web/src/components/Live/floor/RigBody.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from 'react'; +import type { ThreeEvent } from '@react-three/fiber'; +import { HIP_Y, HEAD_Y, SHOULDER_Y, type Rig, type RigLook } from './floorRig'; + +// The figure's meshes; floorRig poses them. See floorRig for the local space. + +type Pick = { + onClick: (e: ThreeEvent) => void; + onPointerOver: (e: ThreeEvent) => void; + onPointerOut: () => void; +}; + +/** The figure's meshes. Everything moves through the refs in `rig`. */ +export default function RigBody({ rig, look, pick, children }: { rig: Rig; look: RigLook; pick: Pick; children?: ReactNode }) { + const opacity = look.ghost ? 0.72 : 1; + const transparent = !!look.ghost; + const pants = '#334155'; + return ( + + {/* Legs, on hip pivots. */} + {([-1, 1] as const).map((side) => ( + + + + + + + + + + + ))} + + + + + + {/* Arms, on shoulder pivots; the right hand holds the cup or the beer. */} + {([-1, 1] as const).map((side) => ( + + + + + + + + + + {side > 0 && ( + <> + + + + + + + + + + + + + + + + )} + + ))} + + + + + + {/* Eyes, so a turned head reads as a glance and a nap as a nap. */} + + + + + + + + + {look.hair && ( + + + + + )} + {look.crown && ( + + + + + )} + + + {children} + + ); +} + diff --git a/web/src/components/Live/floor/TeamFloor.test.tsx b/web/src/components/Live/floor/TeamFloor.test.tsx index ed63342..4c0fede 100644 --- a/web/src/components/Live/floor/TeamFloor.test.tsx +++ b/web/src/components/Live/floor/TeamFloor.test.tsx @@ -206,6 +206,48 @@ describe('TeamFloor', () => { expect(within(dossier).getByTestId('dossier-trail')).toHaveTextContent('three tickets, two teams'); }); + it('keeps the command center’s crew inside as chips, and sends whoever is on out to the pad', () => { + const log: RunEvent[] = [{ phase: 'plan', kind: 'agent_start', agent: 'planner', message: 'reading the request', time: at(1) }]; + const floor = buildFloor({ + squads: chart, + tasks, + events: log, + composition: { summary: 'x', phases: [{ id: 'plan', agent: 'planner', enabled: true, when: 'auto' }, { id: 'split', agent: 'splitter', enabled: true, when: 'auto' }] }, + running: true, + now: T0 + 2000, + }); + render(); + const harness = screen.getByTestId('island-harness'); + expect(within(harness).getByText(/Command center/)).toBeInTheDocument(); + // Out on the pad: named, with a bubble. Inside: a chip, no name tag. + expect(within(within(harness).getByTestId('agent-planner')).getByText('planner')).toBeInTheDocument(); + expect(within(within(harness).getByTestId('agent-splitter')).queryByText('splitter')).toBeNull(); + }); + + it('throws a party at a table whose every ticket is done', () => { + const done: SquadsView = { + ...chart, + squads: [{ ...chart.squads![0], done: 2, in_flight: 0, complete: true }, chart.squads![1]], + }; + const doneTasks = [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'done', column: 'done' }), tasks[2]]; + const floor = buildFloor({ squads: done, tasks: doneTasks, events: [], composition: null, running: true, now: T0 }); + render(); + expect(screen.getByTestId('party-backend-go')).toBeInTheDocument(); + expect(screen.queryByTestId('party-frontend-react')).toBeNull(); + const mood = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker').getAttribute('data-mood'); + expect(['cheers', 'football', 'dance']).toContain(mood); + }); + + it('lets people nap once the run has stopped, and says so in the dossier', () => { + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: false, now: T0 + 5000 }); + render(); + const worker = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker'); + expect(worker).toHaveAttribute('data-mood', 'nap'); + expect(within(worker).getByTestId('mood-go-worker')).toHaveTextContent('💤'); + fireEvent.click(worker); + expect(within(screen.getByTestId('floor-dossier')).getByTestId('dossier-mood')).toHaveTextContent('having a nap'); + }); + it('explains an empty floor', () => { const floor = buildFloor({ squads: null, tasks: [], events: [], composition: null, running: false, now: T0 }); render(); diff --git a/web/src/components/Live/floor/TeamFloor3D.tsx b/web/src/components/Live/floor/TeamFloor3D.tsx index 6caeea0..03e21e2 100644 --- a/web/src/components/Live/floor/TeamFloor3D.tsx +++ b/web/src/components/Live/floor/TeamFloor3D.tsx @@ -8,6 +8,9 @@ import { STAGE_GLYPHS } from '@/components/shared/labels'; import { HARNESS_ID, PULSE_TTL_MS, workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorStageSeat, type FloorTeam, type FloorTicket, type TicketState } from './floorModel'; import { LEGEND_STATES, TICKET_HEX, TICKET_LABEL, glyphFor, seatTitle, type FloorSelection } from './floorShared'; import { floorStore, rememberCamera, rememberCameraPrefs } from './floorStore'; +import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife'; +import { lookOf, makeWalk, poseRig, stillPose, turnToward, useRig, walkAt, type Walk } from './floorRig'; +import RigBody from './RigBody'; // ── The team floor, in three dimensions ────────────────────────────────── // @@ -23,14 +26,28 @@ import { floorStore, rememberCamera, rememberCameraPrefs } from './floorStore'; // seat; a reassignment is a spark across the table; a ticket finishing or // failing bursts. Gates paint the table's rim green or red. // +// The harness — the dispatcher and the phase agents who plan, split and +// compose between the tables' typing — lives in the command center: a glass +// room with a mission screen on its back wall. The crew waits inside; whoever +// the run hands the microphone to walks out through the door onto the pad in +// front, says their piece, and walks back in. A room of ten figures stays a +// room, not a crowd of name tags. +// +// Nobody is a statue. Everyone who is not working has a mood (floorLife): +// they watch the worker, sip a coffee, stretch, take a walk round the table, +// and doze off at the desk when nothing comes their way. A table whose every +// ticket is done stands up for a beer, a kick-about and a dance; when the +// whole run ships, the whole floor — command center included — joins in. +// // Everything is clickable: a person or a ticket opens its dossier (owned by // the wrapper), a table focuses the camera, empty floor clears. The camera is // the user's: drag to orbit, wheel to zoom, right-drag to pan, "follow" keeps // whoever is working in the middle. Everything animates on the GPU per frame // while there is something to animate — a run, live tickets, fresh pulses, -// the camera gliding, follow or spin — and the loop drops to on-demand when -// there is not, and stops when the tab is hidden. Under -// prefers-reduced-motion nothing moves at all. +// the camera gliding, follow or spin. After a run the floor keeps its life at +// an ambient 30 fps for a few minutes (the party, the naps) and then settles +// to on-demand; a hidden tab renders nothing. Under prefers-reduced-motion +// nothing moves: everyone holds a still pose of their mood. // // Per-frame work allocates nothing: the lerps, colours and curve samples go // through scratch objects made once, and the wall clock is read once per @@ -80,9 +97,16 @@ const BOARD_W = 3.4; const BOARD_H = 1.9; const BOARD_BACK = SEAT_R + 2.4; const BOARD_BOTTOM = 1.9; -/** The pipeline's stage: a platform at the back of the hall, behind the boards. */ -const STAGE_R = 2.7; -const STAGE_H = 0.22; +/** The command center: the harness's glass room. */ +const HQ_HEX = '#8b5cf6'; +const HQ_WALL_H = 2.7; +const HQ_GLASS_H = 1.05; +const HQ_DOOR_W = 0.95; +const HQ_DOOR_H = 2.05; +/** How long the floor keeps living (ambient frames) after it last changed. */ +const AMBIENT_MS = 4 * 60_000; +/** When this page first saw the floor: the idle clock for anyone the log has not heard from. */ +const FLOOR_BORN = Date.now(); const BURST_S = 1.7; const DISPATCH_S = 5; const FLASH_S = 0.9; @@ -101,6 +125,50 @@ interface Placed { seats: Map; /** Ticket card centres in table space, for threads and bursts. */ slots: Map; + /** The command center's floor plan, for the harness. */ + hq?: HQ; +} + +/** The command center's floor plan, in its own space (the room's centre at the origin). */ +interface HQ { + w: number; + d: number; + /** Where each of the crew waits inside, on a stool. */ + spots: Map; + /** Where whoever is on stands, on the pad outside the door. */ + pads: Map; + doorIn: THREE.Vector3; + doorOut: THREE.Vector3; + padAt: THREE.Vector3; +} + +/** + * commandLayout plans the room: stools in rows facing the front glass, the + * console along the back wall, the door in the middle of the front, and the + * pad outside it — one place on the pad per agent who is on right now. + */ +function commandLayout(agents: FloorAgent[], running: boolean): HQ { + const n = Math.max(agents.length, 1); + const cols = Math.max(3, Math.min(5, Math.ceil(Math.sqrt(n * 2)))); + const rows = Math.ceil(n / cols); + const cellW = 0.95; + const cellD = 1.0; + const w = Math.max(4.8, cols * cellW + 1.5); + const d = Math.max(3.2, rows * cellD + 2.0); + const spots = new Map(); + agents.forEach((a, i) => { + const row = Math.floor(i / cols); + const inRow = Math.min(cols, agents.length - row * cols); + const col = i % cols; + // Stagger alternate rows, so the back row is seen between the heads of the front. + const x = -((inRow - 1) * cellW) / 2 + col * cellW + (row % 2 ? cellW * 0.25 : 0); + spots.set(a.id, new THREE.Vector3(x, 0, -d / 2 + 1.45 + row * cellD)); + }); + const on = running ? agents.filter((a) => a.active) : []; + const padAt = new THREE.Vector3(0, 0, d / 2 + 1.75); + const pads = new Map(); + on.forEach((a, k) => pads.set(a.id, padAt.clone().setX((k - (on.length - 1) / 2) * 1.1))); + return { w, d, spots, pads, doorIn: new THREE.Vector3(0, 0, d / 2 - 0.5), doorOut: new THREE.Vector3(0, 0, d / 2 + 0.55), padAt }; } const ORDER: TicketState[] = ['working', 'review', 'blocked', 'failed', 'queued', 'done']; @@ -128,7 +196,7 @@ function ticketSlots(team: FloorTeam): { shown: FloorTicket[]; more: number; slo } /** Tables on a shallow arc facing the camera; seats around each table. */ -function layout(teams: FloorTeam[]): Placed[] { +function layout(teams: FloorTeam[], running: boolean): Placed[] { const n = teams.length; return teams.map((team, i) => { const x = (i - (n - 1) / 2) * TABLE_GAP; @@ -137,11 +205,13 @@ function layout(teams: FloorTeam[]): Placed[] { const hex = team.internal ? '#8b5cf6' : HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray; const seats = new Map(); if (team.internal) { - // The harness stands on its platform (Stage), at lecterns in an arc. - for (const [id, p] of stageLayout(team.agents, new THREE.Vector3())) { - seats.set(id, { pos: p, angle: Math.PI / 2, screen: p.clone().setY(1.0) }); + // The harness waits in the command center; whoever is on is out on the pad. + const hq = commandLayout(team.agents, running); + for (const a of team.agents) { + const p = hq.pads.get(a.id) ?? hq.spots.get(a.id)!; + seats.set(a.id, { pos: p, angle: Math.PI / 2, screen: p.clone().setY(1.0) }); } - return { team, pos, hex, seats, slots: new Map() }; + return { team, pos, hex, seats, slots: new Map(), hq }; } const manager = team.agents.find((a) => a.seat === 'manager'); const others = team.agents.filter((a) => a.seat !== 'manager'); @@ -178,6 +248,47 @@ function FrameClock() { return null; } +/** + * Asks for ~30 frames a second while `on`, for AMBIENT_MS after `resetKey` + * last changed — so a finished floor keeps partying (or napping) for a while + * without holding the GPU at full rate forever in a forgotten tab. + */ +function AmbientPump({ on, resetKey }: { on: boolean; resetKey: unknown }) { + const invalidate = useThree((s) => s.invalidate); + useEffect(() => { + if (!on) return undefined; + const until = Date.now() + AMBIENT_MS; + const id = window.setInterval(() => { + if (Date.now() > until) { + window.clearInterval(id); + return; + } + invalidate(); + }, 33); + return () => window.clearInterval(id); + }, [on, resetKey, invalidate]); + return null; +} + +/** + * useMood is what a person is doing, re-read every frame and set as state + * only when it changes (a few times a minute), so the labels follow without + * the scene re-rendering per frame. + */ +function useMood(inputs: Omit): Mood { + const [mood, setMood] = useState(() => moodFor({ ...inputs, now: Date.now() })); + const { active, running, partying, since, seed } = inputs; + // Inputs changed (the log moved): re-read at once, even with no frame coming. + useEffect(() => { + setMood(moodFor({ active, running, partying, since, seed, now: Date.now() })); + }, [active, running, partying, since, seed]); + useFrame(() => { + const next = moodFor({ active, running, partying, since, seed, now: frame.now * 1000 }); + if (next !== mood) setMood(next); + }); + return mood; +} + /** Scratch objects for per-frame maths — allocated once, never per frame. */ const _scale = new THREE.Vector3(); const _dir = new THREE.Vector3(); @@ -198,7 +309,7 @@ function useDocumentVisible(): boolean { } export default function TeamFloor3D({ floor, running, dark, reducedMotion, selection, onSelect, pulses, onTicket, onContextLost }: TeamFloor3DProps) { - const placed = useMemo(() => layout(floor.teams), [floor.teams]); + const placed = useMemo(() => layout(floor.teams, running), [floor.teams, running]); const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]); const [focus, setFocus] = useState(null); const [focusDistance, setFocusDistance] = useState(null); @@ -229,13 +340,12 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec [floor.teams], ); const freshPulse = pulses.length > 0 && Date.now() - pulses[pulses.length - 1].at < PULSE_TTL_MS; - const frameloop: 'always' | 'demand' | 'never' = !visible - ? 'never' - : reducedMotion - ? 'demand' - : running || autoRotate || follow || anyLive || freshPulse - ? 'always' - : 'demand'; + const lively = running || autoRotate || follow || anyLive || freshPulse; + const frameloop: 'always' | 'demand' | 'never' = !visible ? 'never' : reducedMotion ? 'demand' : lively ? 'always' : 'demand'; + // Between runs the floor still lives — naps, the party — at an ambient + // frame rate, for a while after it last changed. + const ambient = visible && !reducedMotion && !lively; + const shipped = useMemo(() => floorShipped(floor, running), [floor, running]); // A lost context (GPU reset, VRAM eviction) is reported up; the wrapper // swaps in the flat map and says so. The listener is removed on unmount. @@ -351,6 +461,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec > + {floor.links.map((link) => { @@ -360,16 +471,18 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec return ; })} {placed.map((p) => - p.team.internal ? ( - { @@ -388,6 +501,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec onSelect={onSelect} pulses={pulses} onTicket={onTicket} + partying={tableParties(p.team, shipped)} onFocus={() => { setFocus(p.pos.clone().setY(0.8)); setFocusDistance(13); @@ -504,6 +618,7 @@ function Table({ onSelect, pulses, onTicket, + partying, onFocus, }: { placed: Placed; @@ -514,6 +629,8 @@ function Table({ onSelect: (sel: FloorSelection) => void; pulses: FloorPulse[]; onTicket?: (id: string) => void; + /** Every ticket here is done: beers, a kick-about, a dance. */ + partying: boolean; onFocus: () => void; }) { const { team, pos, hex, seats, slots } = placed; @@ -548,11 +665,29 @@ function Table({ const boardDir = useMemo(() => (managerPos ? managerPos.pos.clone().setY(0).normalize() : new THREE.Vector3(0, 0, -1)), [managerPos]); const signAt = useMemo(() => boardDir.clone().multiplyScalar(BOARD_BACK).setY(BOARD_BOTTOM + BOARD_H + 0.75), [boardDir]); const tableCentre = useMemo(() => new THREE.Vector3(0, TABLE_Y + 0.3, 0), []); + // Where the players stand for the kick-about: behind their chairs, in seat order round the table. + const pitch = useMemo( + () => + team.agents + .filter((a) => !(a.active && running)) + .map((a) => seats.get(a.id)) + .filter((x): x is Seat => !!x) + .sort((a, b) => a.angle - b.angle) + .map((x) => x.pos.clone().setY(0).multiplyScalar((SEAT_R + 0.8) / SEAT_R)), + [team.agents, seats, running], + ); return ( {activeAgent && animate && } + {partying && animate && ( + <> + + + + )} + {partying && pitch.length >= 2 && } {/* Pedestal + top. */} @@ -582,6 +717,7 @@ function Table({ {team.name} + {partying ? '🎉 all done · ' : ''} {team.total > 0 ? `${team.done}/${team.total} · ${pct}%` : running ? 'no tickets yet' : 'idle'} {team.blocked > 0 ? ` · ${team.blocked} blocked` : ''} {team.gate === 'green' ? ' · proved' : team.gate === 'red' ? ' · RED' : team.gate === 'unverified' ? ' · unverified' : ''} @@ -622,6 +758,7 @@ function Table({ selected={selected} tall={a.seat !== 'manager' && i % 2 === 1} lookAt={a.active ? undefined : lookAt} + partying={partying} onSelect={() => onSelect(selected ? null : { kind: 'agent', id: a.id, team: team.id })} /> ); @@ -801,6 +938,44 @@ function TicketCard({ } const UP = new THREE.Vector3(0, 1, 0); +const _walkPos = new THREE.Vector3(); + +/** Where a seated figure is, in its seat's space: on the chair. */ +const SEATED_AT = new THREE.Vector3(0, 0, 0.1); +/** Where it stands when it gets up: behind the chair, facing the table. */ +const STANDING_AT = new THREE.Vector3(0, 0, 0.85); + +/** The stroll: up, round behind the chairs, and back to sit down. */ +const STROLL = [STANDING_AT, new THREE.Vector3(0.85, 0, 1.25), new THREE.Vector3(-0.85, 0, 1.25), STANDING_AT, SEATED_AT]; + +function hoverPick(onSelect: () => void, setHover: (v: boolean) => void) { + return { + onClick: (e: ThreeEvent) => { + e.stopPropagation(); + onSelect(); + }, + onPointerOver: (e: ThreeEvent) => { + e.stopPropagation(); + setHover(true); + document.body.style.cursor = 'pointer'; + }, + onPointerOut: () => { + setHover(false); + document.body.style.cursor = ''; + }, + }; +} + +/** Floating z's over a sleeper. */ +function Zzz() { + return ( + + ); +} function Person({ agent, @@ -813,6 +988,7 @@ function Person({ selected, tall, lookAt, + partying, onSelect, }: { agent: FloorAgent; @@ -827,21 +1003,34 @@ function Person({ tall?: boolean; /** Table-space point this person glances at when idle (the one working). */ lookAt?: THREE.Vector3; + /** The table is done: this person is at the party. */ + partying: boolean; onSelect: () => void; }) { const isManager = agent.seat === 'manager'; const active = agent.active && running; const screen = useRef(null); - const body = useRef(null); - const head = useRef(null); const flash = useRef(null); const halo = useRef(null); const pulse = useRef(null); const code = useRef([]); + const mug = useRef(null); + const lift = useRef(null); const flashStart = useRef(-1); const wasActive = useRef(false); const [hover, setHover] = useState(false); const invalidate = useThree((s) => s.invalidate); + const rig = useRig(); + const seed = useMemo(() => seedOf(agent.id), [agent.id]); + const look = useMemo(() => lookOf(seed), [seed]); + const phase = (seed % 628) / 100; + const mood = useMood({ active, running, partying, since: agent.lastAt ?? FLOOR_BORN, seed }); + const standing = isParty(mood); + // The motion a mood carries: when it began, the stroll's path, how seated. + const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 }); + const walk = useRef(null); + const sit = useRef(standing ? 0 : 1); + const prev = useRef(new THREE.Vector3().copy(standing ? STANDING_AT : SEATED_AT)); // A flash ring when this person starts working: the "message received" // cue, once per activation. @@ -850,11 +1039,11 @@ function Person({ wasActive.current = active; invalidate(); }, [active, invalidate]); - // Hover and selection ease the scale over several frames; on-demand + // Hover, selection and a new mood ease over several frames; on-demand // rendering has to be asked for each of them. useEffect(() => { invalidate(); - }, [hover, selected, invalidate]); + }, [hover, selected, mood, invalidate]); // Seat faces the table centre: rotate the whole person so its screen is // between them and the table. @@ -864,34 +1053,50 @@ function Person({ const local = lookAt.clone().sub(pos).applyAxisAngle(UP, -yaw); return THREE.MathUtils.clamp(Math.atan2(-local.x, -local.z), -1.1, 1.1); }, [lookAt, pos, yaw]); + const glance = mood === 'watch' || mood === 'coffee' ? headYaw : 0; - useFrame(({ clock }) => { + useFrame(({ clock }, delta) => { const t = clock.getElapsedTime(); if (screen.current) { - screen.current.emissiveIntensity = active ? (animate ? 1.2 + Math.sin(t * 6) * 0.5 : 1.4) : 0.12; + screen.current.emissiveIntensity = active ? (animate ? 1.2 + Math.sin(t * 6) * 0.5 : 1.4) : mood === 'nap' ? 0.04 : 0.12; } - if (body.current) { - if (animate) { - // Typing: a small nod while active; a slow breath otherwise. - body.current.position.y = active ? Math.abs(Math.sin(t * 9)) * 0.03 : Math.sin(t * 1.2 + angle) * 0.01; - body.current.rotation.x = active ? Math.sin(t * 9) * 0.04 : 0; + const root = rig.root.current; + if (root) { + if (moodStart.current.mood !== mood || moodStart.current.at < 0) { + moodStart.current = { mood, at: t }; + walk.current = mood === 'stroll' && animate ? makeWalk([root.position.clone(), ...STROLL], t, 0.85) : null; } - const target = hover || selected ? 1.08 : 1; - if (Math.abs(body.current.scale.x - target) > 0.002) { - body.current.scale.lerp(_scale.setScalar(target), 0.2); - invalidate(); + if (!animate) { + root.position.copy(standing ? STANDING_AT : SEATED_AT); + root.rotation.y = 0; + sit.current = standing ? 0 : 1; + stillPose(rig, mood, sit.current, glance); + } else { + let facing = 0; + if (walk.current) { + const dir = walkAt(walk.current, t, _walkPos); + if (dir === null) walk.current = null; + else facing = dir; + root.position.copy(_walkPos); + } else { + root.position.lerp(standing ? STANDING_AT : SEATED_AT, 0.07); + } + const speed = delta > 0 ? root.position.distanceTo(prev.current) / delta : 0; + prev.current.copy(root.position); + root.rotation.y = turnToward(root.rotation.y, walk.current && speed > 0.05 ? facing : 0, 0.15); + // Seated only once back at the chair. + const atChair = root.position.distanceToSquared(SEATED_AT) < 0.02; + sit.current = THREE.MathUtils.lerp(sit.current, !standing && atChair && !walk.current ? 1 : 0, 0.14); + poseRig(rig, { mood, t, since: t - moodStart.current.at, phase, sit: sit.current, speed, glance }); } - } - if (head.current) { - // Idle people glance at whoever is working; the worker watches the screen. - const want = active ? 0 : headYaw; - if (animate && Math.abs(head.current.rotation.y - want) > 0.002) { - head.current.rotation.y = THREE.MathUtils.lerp(head.current.rotation.y, want, 0.06); + const target = hover || selected ? 1.08 : 1; + if (Math.abs(root.scale.x - target) > 0.002) { + root.scale.lerp(_scale.setScalar(target), 0.2); invalidate(); - } else if (!animate) { - head.current.rotation.y = want; } + if (lift.current) lift.current.position.y = (1 - sit.current) * 0.28; } + if (mug.current) mug.current.visible = !active && mood !== 'coffee'; if (flash.current) { const age = flashStart.current < 0 ? Infinity : (performance.now() - flashStart.current) / 1000; const visible = age < 1.2 && animate; @@ -935,22 +1140,8 @@ function Person({ const label = agent.id.length > 18 ? agent.id.slice(0, 17) + '…' : agent.id; const saying = agent.lastMessage && agent.lastMessage.length > 54 ? agent.lastMessage.slice(0, 53) + '…' : agent.lastMessage; - const skin = isManager ? hex : '#e5e7eb'; - const pick = { - onClick: (e: ThreeEvent) => { - e.stopPropagation(); - onSelect(); - }, - onPointerOver: (e: ThreeEvent) => { - e.stopPropagation(); - setHover(true); - document.body.style.cursor = 'pointer'; - }, - onPointerOut: () => { - setHover(false); - document.body.style.cursor = ''; - }, - }; + const pick = hoverPick(onSelect, setHover); + const quiet = !active && !selected && !hover && !isManager; return ( @@ -981,33 +1172,38 @@ function Person({ - {/* Person */} - - - - - - - - - - - {/* Eyes, so a turned head reads as a glance. */} - - - - - - - - - {isManager && ( - - - - - )} - + {/* The person: gets up, walks about, sits back down. */} + + + + {/* Name + the pop-out when working, riding with the person. */} + +
+ {active && ( +
+ + {agent.task ? `on ${agent.task}` : 'working'} + + + {saying && {saying}} +
+ )} + {mood === 'nap' && } + + {moodShows(mood) && } + {label} + {isManager && {team.managerDefault ? ' · manager (default)' : ' · manager'}} + {agent.borrowed && {` · ${agent.seat} from the ${agent.borrowed}`}} + +
+ +
+
{/* Desk + monitor, toward the table (−z after the yaw). */} @@ -1022,153 +1218,322 @@ function Person({ - {/* A mug on the idle desk; it goes when the typing starts. */} - {!active && ( - - - - - )} + {/* The mug waits on the desk — in the hand on a coffee break, gone while typing. */} + + + + {/* Activation flash. */} - {/* Name + the pop-out when working. */} - -
- {active && ( -
- - {agent.task ? `on ${agent.task}` : 'working'} - - - {saying && {saying}} -
- )} - - {label} - {isManager && {team.managerDefault ? ' · manager (default)' : ' · manager'}} - {agent.borrowed && {` · ${agent.seat} from the ${agent.borrowed}`}} - -
-
); } -/** Standing places on the stage: an arc facing the camera, the newest speaker in the middle. */ -function stageLayout(seats: { id: string }[], at: THREE.Vector3): Map { - const out = new Map(); - const n = seats.length; - if (n === 0) return out; - const span = Math.min(Math.PI * 0.95, 0.9 * Math.max(n - 1, 0) + 0.001); - seats.forEach((s, k) => { - const f = n === 1 ? 0.5 : k / (n - 1); - const angle = Math.PI / 2 - span / 2 + f * span; - out.set(s.id, new THREE.Vector3(at.x + Math.cos(angle) * (STAGE_R - 0.5), STAGE_H, at.z + Math.sin(angle) * (STAGE_R - 0.5) * 0.55 - 0.5)); +/** + * The kick-about at a done table: a ball passed round the players behind + * their chairs and back again, lobbed over the table when it has to be. + * Only on the party's football rounds; between them it rests out of sight. + */ +function Football({ spots, animate }: { spots: THREE.Vector3[]; animate: boolean }) { + const ball = useRef(null); + const shadow = useRef(null); + const PASS_S = 1.3; + useFrame(() => { + const m = ball.current; + if (!m) return; + const on = isFootballRound(frame.now * 1000); + m.visible = on; + if (shadow.current) shadow.current.visible = on; + if (!on) return; + const n = spots.length; + const legs = 2 * (n - 1); + const k = animate ? frame.now / PASS_S : 0.5; + const leg = Math.floor(k) % legs; + const u = k - Math.floor(k); + const from = leg < n - 1 ? leg : legs - leg; + const to = leg < n - 1 ? leg + 1 : legs - leg - 1; + const a = spots[from]; + const b = spots[to]; + const h = Math.sin(u * Math.PI) * (0.3 + a.distanceTo(b) * 0.32); + m.position.lerpVectors(a, b, u); + m.position.y = 0.13 + h; + if (animate) { + m.rotation.x += 0.18; + m.rotation.z += 0.07; + } + if (shadow.current) { + shadow.current.position.set(m.position.x, 0.012, m.position.z); + const s = 1 / (1 + h * 0.8); + shadow.current.scale.set(s, s, s); + } }); - return out; + return ( + + + + + + + + + + + + + + + + + ); } /** - * The pipeline's stage: where the run's thinking happens between the tables' - * typing. A platform with a screen naming the phase the run is in and what is - * being said, and the phase agents — planner, splitter, architect… — standing - * at lecterns; whoever is speaking is lit, the rest wait in the wings. + * The command center: the harness's glass room. A mission screen on the back + * wall names the phase; the crew — dispatcher, planner, splitter, composer… — + * wait inside on stools; whoever the run hands the microphone to walks out + * through the sliding door onto the pad in front, says their piece and walks + * back in. The room's name and phase hang above it. */ -function Stage({ +function CommandCenter({ + team, seats, phase, at, - positions, + hq, running, animate, dark, + partying, selection, onSelect, onFocus, }: { + team: FloorTeam; seats: FloorStageSeat[]; phase: FloorPhase | null; at: THREE.Vector3; - /** Each figure's place, relative to the platform (the harness table's seats). */ - positions: Map; + hq: HQ; running: boolean; animate: boolean; dark: boolean; + /** The run shipped: the crew parties too. */ + partying: boolean; selection: FloorSelection; onSelect: (sel: FloorSelection) => void; onFocus: () => void; }) { + const { w, d } = hq; + const out = seats.filter((s) => s.active && running && hq.pads.has(s.id)); const live = running && !!phase; - const glow = useRef(null); + const padGlow = useRef(null); + const screenGlow = useRef(null); + const lamp = useRef(null); + const doorL = useRef(null); + const doorR = useRef(null); + /** Scene seconds until which the door stays open: walkers keep pushing it. */ + const door = useRef({ until: 0 }); + const invalidate = useThree((s) => s.invalidate); + const busy = out.length > 0; useFrame(({ clock }) => { - if (!glow.current) return; const t = clock.getElapsedTime(); - glow.current.emissiveIntensity = live ? (animate ? 0.55 + Math.sin(t * 2.2) * 0.3 : 0.7) : 0.12; + if (padGlow.current) padGlow.current.emissiveIntensity = busy ? (animate ? 0.8 + Math.sin(t * 3) * 0.35 : 1) : 0.15; + if (screenGlow.current) screenGlow.current.emissiveIntensity = live ? (animate ? 0.7 + Math.sin(t * 1.7) * 0.15 : 0.8) : 0.2; + if (lamp.current) lamp.current.emissiveIntensity = busy ? (animate ? 1.2 + Math.sin(t * 8) * 0.8 : 1.5) : 0.2; + // The door slides open for anyone passing, and shuts behind them. + const open = t < door.current.until; + const goal = open ? HQ_DOOR_W * 0.75 : HQ_DOOR_W / 4; + for (const [ref, side] of [[doorL, -1], [doorR, 1]] as const) { + const m = ref.current; + if (!m) continue; + const want = side * goal; + if (!animate) m.position.x = want; + else if (Math.abs(m.position.x - want) > 0.002) { + m.position.x = THREE.MathUtils.lerp(m.position.x, want, 0.14); + invalidate(); + } + } }); + + const glass = ; + const frameMat = ; const message = phase?.message && phase.message.length > 90 ? phase.message.slice(0, 89) + '…' : phase?.message; + const sideW = (w - HQ_DOOR_W) / 2; + const inside = seats.length - out.length; + const pathLen = hq.padAt.z - hq.doorOut.z; return ( - {/* Platform */} - { e.stopPropagation(); onFocus(); }}> - - + {/* The room's floor: click it to go in. */} + { + e.stopPropagation(); + onFocus(); + }} + > + + - - - + {/* Back wall, its console and the mission screen. */} + + + - {/* The screen at the back of the stage. */} - - - - - - - - - - - - + + + + + + + + - - - + + + - +
- phase - {phase ? phase.id : running ? 'starting' : 'idle'} + mission · phase + {phase ? phase.id : partying ? 'shipped 🎉' : running ? 'starting' : 'standing by'} {phase?.agent && {phase.agent}} {message && {message}}
- - - {seats.map((seat, i) => { - const p = positions.get(seat.id); - if (!p) return null; + {seats.map((seat) => { + const spot = hq.spots.get(seat.id); + if (!spot) return null; const selected = selection?.kind === 'agent' && selection.id === seat.id; return ( - onSelect(selected ? null : { kind: 'agent', id: seat.id, team: HARNESS_ID })} /> ); @@ -1177,100 +1542,152 @@ function Stage({ ); } -/** One person standing at a lectern on the stage. */ -function StageFigure({ seat, pos, tall, running, animate, selected, onSelect }: { seat: FloorStageSeat; pos: THREE.Vector3; tall: boolean; running: boolean; animate: boolean; selected: boolean; onSelect: () => void }) { - const active = seat.active && running; - const body = useRef(null); - const ring = useRef(null); +/** One of the crew: on a stool inside, or out on the pad when it is their turn. */ +function CrewFigure({ + seat, + spot, + pad, + hq, + running, + animate, + partying, + selected, + door, + onSelect, +}: { + seat: FloorStageSeat; + spot: THREE.Vector3; + pad?: THREE.Vector3; + hq: HQ; + running: boolean; + animate: boolean; + partying: boolean; + selected: boolean; + door: { current: { until: number } }; + onSelect: () => void; +}) { + const active = seat.active && running && !!pad; + const rig = useRig(); + const lift = useRef(null); const [hover, setHover] = useState(false); const invalidate = useThree((s) => s.invalidate); - const hex = '#8b5cf6'; - const glyph = STAGE_GLYPHS[seat.id] ?? STAGE_GLYPHS[seat.phase ?? ''] ?? '🎓'; + const clock = useThree((s) => s.clock); + const seed = useMemo(() => seedOf(seat.id), [seat.id]); + const look = useMemo(() => lookOf(seed), [seed]); + const phase = (seed % 628) / 100; + const raw = useMood({ active, running, partying, since: seat.lastAt ?? FLOOR_BORN, seed }); + // No pitch in here: the crew dances instead. + const mood: Mood = raw === 'football' ? 'dance' : raw; + const dest = active ? pad! : spot; + const destKey = `${dest.x.toFixed(2)},${dest.z.toFixed(2)}`; + const pos = useRef(dest.clone()); + const prev = useRef(dest.clone()); + const walk = useRef(null); + const planned = useRef(destKey); + const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 }); + const sit = useRef(active || isParty(mood) ? 0 : 1); + + // A new place to be: walk there — through the door when it is on the other side of it. useEffect(() => { + if (planned.current === destKey) return; + planned.current = destKey; + const inside = (v: THREE.Vector3) => v.z < hq.d / 2 - 0.1; + const from = pos.current.clone(); + const pts = [from]; + if (inside(from) && !inside(dest)) pts.push(hq.doorIn, hq.doorOut); + else if (!inside(from) && inside(dest)) pts.push(hq.doorOut, hq.doorIn); + pts.push(dest.clone()); + walk.current = animate ? makeWalk(pts, clock.getElapsedTime(), 1.7) : null; + if (!animate) pos.current.copy(dest); invalidate(); - }, [hover, selected, active, invalidate]); - useFrame(({ clock }) => { - const t = clock.getElapsedTime(); - if (body.current) { - if (animate) { - // Speaking: a lively sway; waiting: a slow breath; dim before they have spoken. - body.current.position.y = active ? Math.abs(Math.sin(t * 5)) * 0.04 : Math.sin(t * 1.1 + pos.x) * 0.01; - body.current.rotation.z = active ? Math.sin(t * 2.5) * 0.05 : 0; + }, [destKey, dest, hq, animate, clock, invalidate]); + useEffect(() => { + invalidate(); + }, [hover, selected, mood, invalidate]); + + useFrame(({ clock: c }, delta) => { + const root = rig.root.current; + if (!root) return; + const t = c.getElapsedTime(); + const inside = pos.current.z < hq.d / 2 - 0.1; + if (moodStart.current.mood !== mood || moodStart.current.at < 0) { + moodStart.current = { mood, at: t }; + if (mood === 'stroll' && animate && inside && !walk.current) { + walk.current = makeWalk([pos.current.clone(), spot.clone().add(new THREE.Vector3(0.38, 0, 0.42)), spot.clone().add(new THREE.Vector3(-0.38, 0, 0.42)), spot.clone()], t, 0.6); } - const target = hover || selected ? 1.08 : 1; - if (Math.abs(body.current.scale.x - target) > 0.002) { - body.current.scale.lerp(_scale.setScalar(target), 0.2); + } + let facing = Math.PI; + let walking = false; + if (walk.current && animate) { + const dir = walkAt(walk.current, t, pos.current); + if (dir === null) walk.current = null; + else { + facing = dir; + walking = true; + if (pos.current.distanceTo(hq.doorIn) < 1.1 || pos.current.distanceTo(hq.doorOut) < 1.1) door.current.until = t + 0.4; invalidate(); } + } else { + walk.current = null; + pos.current.copy(dest); } - if (ring.current) { - ring.current.visible = active || selected; - if (animate) { - const k = (t * 0.9) % 1; - const s = active ? 0.8 + k * 0.9 : 1; - ring.current.scale.set(s, s, s); - (ring.current.material as THREE.MeshBasicMaterial).opacity = active ? 0.55 * (1 - k) : 0.9; - } + root.position.copy(pos.current); + const standing = active || isParty(mood) || walking || !inside; + if (!animate) { + root.rotation.y = Math.PI; + sit.current = standing ? 0 : 1; + stillPose(rig, mood, sit.current, 0, active); + } else { + const speed = delta > 0 ? pos.current.distanceTo(prev.current) / delta : 0; + root.rotation.y = turnToward(root.rotation.y, facing, 0.15); + sit.current = THREE.MathUtils.lerp(sit.current, standing ? 0 : 1, 0.14); + poseRig(rig, { mood, t, since: t - moodStart.current.at, phase, sit: sit.current, speed: walking ? speed : 0, glance: 0, talk: active }); } + prev.current.copy(pos.current); + const target = hover || selected ? 1.1 : 1; + if (Math.abs(root.scale.x - target) > 0.002) { + root.scale.lerp(_scale.setScalar(target), 0.2); + invalidate(); + } + if (lift.current) lift.current.position.y = (1 - sit.current) * 0.28; }); - const pick = { - onClick: (e: ThreeEvent) => { - e.stopPropagation(); - onSelect(); - }, - onPointerOver: (e: ThreeEvent) => { - e.stopPropagation(); - setHover(true); - document.body.style.cursor = 'pointer'; - }, - onPointerOut: () => { - setHover(false); - document.body.style.cursor = ''; - }, - }; + + const pick = hoverPick(onSelect, setHover); + const glyph = STAGE_GLYPHS[seat.id] ?? STAGE_GLYPHS[seat.phase ?? ''] ?? '🎓'; const saying = seat.lastMessage && seat.lastMessage.length > 54 ? seat.lastMessage.slice(0, 53) + '…' : seat.lastMessage; - const tint = seat.spoke ? '#ddd6fe' : '#e5e7eb'; + const named = active || hover || selected; + const title = `${seat.id}${seat.phase ? ` · ${seat.phase}` : ''} · ${active ? 'on the pad' : MOOD_LABEL[mood]} · ${seat.spoke ? 'has spoken' : 'has not spoken yet'} · click for the dossier`; return ( - - - - - - {active && } - {/* Lectern */} - - - - - - - - - - - - - - - - - - - -
- {active && ( -
- {seat.phase ? `${seat.phase} · speaking` : 'speaking'} - - {saying && {saying}} + + + + +
+ {active && ( +
+ {seat.phase ? `${seat.phase} · on air` : 'on air'} + + {saying && {saying}} +
+ )} + {mood === 'nap' && !active && } + {named ? ( + + {moodShows(mood) && !active && } + {seat.id} + {seat.phase && {` · ${seat.phase}`}} + + ) : ( + // Inside, a chip, not a name tag: the room stays a room. + + + {moodShows(mood) && } + + )}
- )} - - {seat.id} - {seat.phase && {` · ${seat.phase}`}} - -
- + + + ); } diff --git a/web/src/components/Live/floor/TeamFloorFlat.tsx b/web/src/components/Live/floor/TeamFloorFlat.tsx index 2a8eb40..d8d45f7 100644 --- a/web/src/components/Live/floor/TeamFloorFlat.tsx +++ b/web/src/components/Live/floor/TeamFloorFlat.tsx @@ -3,6 +3,7 @@ import clsx from 'clsx'; import { teamColor } from '@/components/Board/teamColor'; import { workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorTeam, type FloorTicket, type TicketState } from './floorModel'; import { LEGEND_STATES, TICKET_LABEL, ago, glyphFor, type FloorSelection } from './floorShared'; +import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isParty, moodFor, moodShows, seedOf, tableParties, type Mood } from './floorLife'; // ── The team floor ─────────────────────────────────────────────────────── // @@ -19,6 +20,11 @@ import { LEGEND_STATES, TICKET_LABEL, ago, glyphFor, type FloorSelection } from // minutes at a time, and a thing that breathes reads as alive where a table // reads as stuck. Every motion is CSS, so `prefers-reduced-motion` (honored // globally) freezes it to a still picture with nothing lost. +// +// The people have moods (floorLife) here too — a badge and a little motion: +// a doze, a stroll, a party bounce at a table that is done — and the harness +// is a command center: the crew waits inside as small chips, and whoever is +// on steps out through the door onto the pad with their name and bubble. export interface TeamFloorFlatProps { floor: FloorModel; @@ -69,8 +75,14 @@ interface Placed { agentPos: Map; } +/** The command center's box, centred on its island. */ +const HQ_W = 300; +const HQ_H = 112; +const HQ_DOOR = 36; +const HQ_COLS = 6; + /** Islands are laid out on a shallow arc, so three read as a floor and not a row. */ -function layout(teams: FloorTeam[]): Placed[] { +function layout(teams: FloorTeam[], running: boolean): Placed[] { const n = teams.length; return teams.map((team, i) => { const t = n === 1 ? 0.5 : i / (n - 1); @@ -79,6 +91,20 @@ function layout(teams: FloorTeam[]): Placed[] { const cy = n <= 2 ? H / 2 + 10 : 200 + (i % 2) * 190; const hex = team.internal ? HEX.slate : HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray; const agentPos = new Map(); + if (team.internal) { + // The crew in rows inside the room; whoever is on, out on the pad. + const on = running ? team.agents.filter((a) => a.active) : []; + const inside = team.agents.filter((a) => !on.includes(a)); + const rows = Math.max(1, Math.ceil(inside.length / HQ_COLS)); + inside.forEach((a, i) => { + const row = Math.floor(i / HQ_COLS); + const inRow = Math.min(HQ_COLS, inside.length - row * HQ_COLS); + const col = i % HQ_COLS; + agentPos.set(a.id, { x: cx - ((inRow - 1) * 40) / 2 + col * 40, y: cy + 12 + (row - (rows - 1) / 2) * 28 }); + }); + on.forEach((a, k) => agentPos.set(a.id, { x: cx + (k - (on.length - 1) / 2) * 70, y: cy + HQ_H / 2 + 26 })); + return { team, cx, cy, hex, agentPos }; + } // Manager at the head; everyone else spaced along the front edge of the // island, where the tickets are within reach. const others = team.agents.filter((a) => a.seat !== 'manager'); @@ -94,7 +120,9 @@ function layout(teams: FloorTeam[]): Placed[] { } export default function TeamFloorFlat({ floor, running, now, onTicket, selection = null, onSelect, pulses }: TeamFloorFlatProps) { - const placed = useMemo(() => layout(floor.teams), [floor.teams]); + const placed = useMemo(() => layout(floor.teams, running), [floor.teams, running]); + const shipped = floorShipped(floor, running); + const anyParty = shipped || floor.teams.some((t) => tableParties(t, false)); const working = workTables(floor).length; const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]); @@ -105,10 +133,11 @@ export default function TeamFloorFlat({ floor, running, now, onTicket, selection setTick(now); return undefined; } - if (!running) return undefined; + // The party keeps its clock after the run: the rounds change, people dance. + if (!running && !anyParty) return undefined; const id = window.setInterval(() => setTick(Date.now()), 1000); return () => window.clearInterval(id); - }, [running, now]); + }, [running, now, anyParty]); // Tickets with a pulse still on them flash, whichever kind it was. const flashing = useMemo(() => new Set((pulses ?? []).filter((p) => p.ticket && tick - p.at < 4000).map((p) => p.ticket!)), [pulses, tick]); @@ -158,7 +187,7 @@ export default function TeamFloorFlat({ floor, running, now, onTicket, selection })} {placed.map((p) => ( - + ))} {floor.handoffs.map((h) => ( @@ -187,6 +216,7 @@ function Island({ onSelect, flashing, phase, + partying, }: { placed: Placed; running: boolean; @@ -196,9 +226,10 @@ function Island({ onSelect?: (sel: FloorSelection) => void; flashing: Set; phase: FloorPhase | null; + partying: boolean; }) { const { team, cx, cy, hex, agentPos } = placed; - if (team.internal) return ; + if (team.internal) return ; const pct = team.total > 0 ? Math.round((team.done / team.total) * 100) : 0; const rim = team.gate === 'green' ? '#10b981' : team.gate === 'red' ? '#ef4444' : team.waitingOn.length > 0 ? '#f59e0b' : hex; @@ -218,7 +249,14 @@ function Island({ {team.name} + {partying && ( + + Every ticket here is done — the table is celebrating + 🎉 + + )} + {partying ? '🍻 ' : ''} {team.total > 0 ? `${team.done}/${team.total} done` : running ? 'no tickets yet' : 'idle'} {team.blocked > 0 ? ` · ${team.blocked} blocked` : ''} {team.gate === 'green' ? ' · proved' : team.gate === 'red' ? ' · RED' : team.gate === 'unverified' ? ' · unverified' : ''} @@ -232,7 +270,7 @@ function Island({ {team.agents.map((a) => { const pos = agentPos.get(a.id); if (!pos) return null; - return ; + return ; })} {team.waitingOn.length > 0 && ( @@ -245,31 +283,46 @@ function Island({ } /** - * The harness's own table: drawn quieter than a team's — dashed rim, no desk - * of tickets, no progress — with the phase the run is in where a team shows - * its progress, so it reads as the machinery beside the work, not a team. + * The harness's command center: a room, not a team's island — no tickets, no + * progress. The crew waits inside as small chips; whoever is on steps out + * through the door onto the pad below with their name and bubble; the phase + * the run is in is written on the wall where a team shows its progress. */ -function HarnessIsland({ placed, running, tick, selection, onSelect, phase }: { placed: Placed; running: boolean; tick: number; selection: FloorSelection; onSelect?: (sel: FloorSelection) => void; phase: FloorPhase | null }) { +function HarnessIsland({ placed, running, tick, selection, onSelect, phase, partying }: { placed: Placed; running: boolean; tick: number; selection: FloorSelection; onSelect?: (sel: FloorSelection) => void; phase: FloorPhase | null; partying: boolean }) { const { team, cx, cy, hex, agentPos } = placed; - const active = team.agents.some((a) => a.active); + const out = running ? team.agents.filter((a) => a.active) : []; + const busy = out.length > 0; + const left = cx - HQ_W / 2; + const top = cy - HQ_H / 2; + const bottom = cy + HQ_H / 2; + // The outline, open at the door. + const walls = `M ${cx - HQ_DOOR / 2} ${bottom} H ${left + 14} Q ${left} ${bottom} ${left} ${bottom - 14} V ${top + 14} Q ${left} ${top} ${left + 14} ${top} H ${left + HQ_W - 14} Q ${left + HQ_W} ${top} ${left + HQ_W} ${top + 14} V ${bottom - 14} Q ${left + HQ_W} ${bottom} ${left + HQ_W - 14} ${bottom} H ${cx + HQ_DOOR / 2}`; return ( - + {team.charter} - - - - {team.name} - - - {phase ? `phase · ${phase.id}` : running ? 'starting' : 'standing by'} + + + + {/* The sliding door: open while someone is out on the pad. */} + + + + {busy ? 'on air: someone is out on the pad' : 'nobody on air'} + + {/* The pad and the path to it. */} + + + + {partying ? '🎉 ' : ''}Command center - - not a team · no tickets + + {phase ? `phase · ${phase.id}` : partying ? 'shipped' : running ? 'starting' : 'standing by'} {team.agents.map((a) => { const pos = agentPos.get(a.id); if (!pos) return null; - return ; + const onPad = out.includes(a); + return ; })} ); @@ -391,6 +444,8 @@ function Avatar({ team, selected, onSelect, + partying = false, + compact = false, }: { agent: FloorAgent; x: number; @@ -401,15 +456,28 @@ function Avatar({ team: FloorTeam; selected: boolean; onSelect?: (sel: FloorSelection) => void; + /** The table is done: the party is on. */ + partying?: boolean; + /** A chip, not a name tag: the command center's crew waiting inside. */ + compact?: boolean; }) { - const r = agent.seat === 'manager' ? 17 : 15; + const r = compact ? 10 : agent.seat === 'manager' ? 17 : 15; const pick = onSelect ? () => onSelect({ kind: 'agent', id: agent.id, team: team.id }) : undefined; const label = agent.id.length > 16 ? agent.id.slice(0, 15) + '…' : agent.id; const isManager = agent.seat === 'manager'; const glowing = agent.active && running; + const mood: Mood = moodFor({ active: glowing, running, partying, since: agent.lastAt ?? tick, now: tick, seed: seedOf(agent.id) }); return ( {`${agent.id} · ${isManager ? (team.managerDefault ? 'project manager (run default)' : 'project manager') : team.internal ? 'harness · internal' : agent.seat}` + (agent.borrowed ? ` (the team names none — lent by the ${agent.borrowed})` : '') + - (agent.touched ? ` · ${agent.touched} ticket${agent.touched === 1 ? '' : 's'} touched` : '')} + (agent.touched ? ` · ${agent.touched} ticket${agent.touched === 1 ? '' : 's'} touched` : '') + + ` · ${MOOD_LABEL[mood]}`} - {glowing && } - {glowing && } - - - {glyphFor(agent)} - - - {label} - + + {glowing && } + {glowing && } + + + {glyphFor(agent)} + + {moodShows(mood) && ( + + {MOOD_GLYPH[mood]} + + )} + + {!compact && ( + + {label} + + )} {isManager && ( {team.managerDefault ? 'manager · run default' : 'manager'} diff --git a/web/src/components/Live/floor/floorLife.test.ts b/web/src/components/Live/floor/floorLife.test.ts new file mode 100644 index 0000000..2c24d48 --- /dev/null +++ b/web/src/components/Live/floor/floorLife.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { MOOD_SLOT_MS, PARTY_SLOT_MS, SLEEPY_MS, WATCH_MS, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife'; +import type { FloorTeam } from './floorModel'; + +const T0 = Date.parse('2026-09-10T10:00:00Z'); + +function life(over: Partial = {}): LifeInputs { + return { active: false, running: true, partying: false, since: T0, now: T0, seed: seedOf('go-worker'), ...over }; +} + +function team(over: Partial = {}): FloorTeam { + return { id: 'backend', name: 'Backend', manager: 'triage', managerDefault: false, agents: [], tickets: [], total: 2, done: 2, blocked: 0, inFlight: 0, complete: true, gate: '', waitingOn: [], ...over }; +} + +describe('moodFor', () => { + it('works while working, whatever else is true', () => { + expect(moodFor(life({ active: true, partying: true, since: T0 - SLEEPY_MS * 5 }))).toBe('work'); + }); + + it('watches the worker when just idle', () => { + expect(moodFor(life({ now: T0 + WATCH_MS - 1 }))).toBe('watch'); + }); + + it('is deterministic: the same inputs, the same mood', () => { + const at = life({ now: T0 + 40_000 }); + expect(moodFor(at)).toBe(moodFor({ ...at })); + }); + + it('takes breaks while idle in a live run — coffee, stretches, walks', () => { + const seen = new Set(); + for (let k = 0; k < 60; k++) seen.add(moodFor(life({ now: T0 + WATCH_MS + k * MOOD_SLOT_MS }))); + expect(seen.has('coffee')).toBe(true); + expect(seen.has('stroll')).toBe(true); + expect(seen.has('work')).toBe(false); + }); + + it('never has the whole room change mood at once', () => { + const now = T0 + 30_000; + const moods = new Set(Array.from({ length: 20 }, (_, i) => moodFor(life({ seed: seedOf(`agent-${i}`), now })))); + expect(moods.size).toBeGreaterThan(1); + }); + + it('dozes off after a long wait, and sleeps after a very long one', () => { + let naps = 0; + for (let k = 0; k < 40; k++) if (moodFor(life({ now: T0 + SLEEPY_MS + 1 + k * MOOD_SLOT_MS })) === 'nap') naps++; + expect(naps).toBeGreaterThan(10); + expect(moodFor(life({ now: T0 + SLEEPY_MS * 2 + 1 }))).toBe('nap'); + }); + + it('naps at the desk once the run has stopped', () => { + expect(moodFor(life({ running: false, now: T0 + 60_000 }))).toBe('nap'); + }); + + it('parties at a done table: beers, a dance, and football every third round', () => { + const football = T0 - (T0 % (PARTY_SLOT_MS * 3)) + PARTY_SLOT_MS + 1; + expect(isFootballRound(football)).toBe(true); + expect(moodFor(life({ partying: true, now: football }))).toBe('football'); + const beers = football + PARTY_SLOT_MS; + expect(['cheers', 'dance']).toContain(moodFor(life({ partying: true, now: beers }))); + expect(isParty(moodFor(life({ partying: true, now: beers })))).toBe(true); + }); + + it('badges every mood but working and watching', () => { + expect(moodShows('work')).toBe(false); + expect(moodShows('watch')).toBe(false); + expect(moodShows('nap')).toBe(true); + expect(moodShows('cheers')).toBe(true); + }); +}); + +describe('parties', () => { + it('a done table parties; an unfinished one or the harness does not, until the run ships', () => { + expect(tableParties(team(), false)).toBe(true); + expect(tableParties(team({ complete: false, done: 1 }), false)).toBe(false); + expect(tableParties(team({ id: 'harness', internal: true, total: 0, complete: false }), false)).toBe(false); + expect(tableParties(team({ id: 'harness', internal: true, total: 0, complete: false }), true)).toBe(true); + }); + + it('the floor ships when the run is over and every work table is done', () => { + const harness = team({ id: 'harness', internal: true, total: 0, complete: false }); + expect(floorShipped({ teams: [harness, team()] }, false)).toBe(true); + expect(floorShipped({ teams: [harness, team()] }, true)).toBe(false); + expect(floorShipped({ teams: [harness, team(), team({ id: 'web', complete: false, done: 0 })] }, false)).toBe(false); + expect(floorShipped({ teams: [harness] }, false)).toBe(false); + }); +}); diff --git a/web/src/components/Live/floor/floorLife.ts b/web/src/components/Live/floor/floorLife.ts new file mode 100644 index 0000000..779974c --- /dev/null +++ b/web/src/components/Live/floor/floorLife.ts @@ -0,0 +1,156 @@ +import { workTables, type FloorModel, type FloorTeam } from './floorModel'; + +// ── Life on the floor ──────────────────────────────────────────────────── +// +// A run is mostly waiting: one agent types while the rest of the table has +// nothing to do. Drawn as statues, that reads as a stuck page; drawn as +// people, it reads as an office. So everyone who is not working has a mood — +// they watch whoever is, fetch a coffee, stretch, take a walk round the room, +// and doze off when nothing has come their way for a while. A table whose +// every ticket is done throws a party: beers, a kick-about, a dance. When the +// whole run is over and everything shipped, the whole floor joins in. +// +// It is pure and deterministic — a person's mood is a function of the time, +// how long they have been idle and a seed from their name — so both stages +// (and the dossier) agree on what someone is doing without sharing state, a +// re-render never reshuffles anybody, and it is testable. + +export type Mood = 'work' | 'watch' | 'coffee' | 'stretch' | 'stroll' | 'nap' | 'cheers' | 'football' | 'dance'; + +export const MOOD_GLYPH: Record = { + work: '⌨️', + watch: '👀', + coffee: '☕', + stretch: '🙆', + stroll: '🚶', + nap: '💤', + cheers: '🍻', + football: '⚽', + dance: '🕺', +}; + +export const MOOD_LABEL: Record = { + work: 'working', + watch: 'watching the others work', + coffee: 'on a coffee break', + stretch: 'stretching their legs', + stroll: 'taking a walk round the room', + nap: 'having a nap', + cheers: 'having a beer — the table is done', + football: 'playing football — the table is done', + dance: 'dancing — the table is done', +}; + +/** Moods worth a badge: watching is the default, and working has its own bubble. */ +export function moodShows(mood: Mood): boolean { + return mood !== 'work' && mood !== 'watch'; +} + +/** A party mood: the table is done. */ +export function isParty(mood: Mood): boolean { + return mood === 'cheers' || mood === 'football' || mood === 'dance'; +} + +/** Idle for less than this, a person just watches whoever is working. */ +export const WATCH_MS = 12_000; +/** How long one idle mood lasts before the next is drawn. */ +export const MOOD_SLOT_MS = 9_000; +/** Idle past this, a nap becomes likely… */ +export const DROWSY_MS = 60_000; +/** …and past this, the likeliest thing of all. */ +export const SLEEPY_MS = 150_000; +/** One round of the party: beers, then a kick-about, then beers again. */ +export const PARTY_SLOT_MS = 14_000; + +export interface LifeInputs { + /** Working right now, in a live run. */ + active: boolean; + running: boolean; + /** Every ticket at this person's table is done (or the whole run shipped). */ + partying: boolean; + /** When this person last did anything, in ms — or when the floor was first seen. */ + since: number; + now: number; + /** From seedOf(id): who they are decides what they like doing. */ + seed: number; +} + +/** A stable 32-bit seed from a name (FNV-1a). */ +export function seedOf(id: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < id.length; i++) { + h ^= id.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** Mixes a seed with a slot number into a fresh roll, 0..2^32. */ +function roll(seed: number, slot: number): number { + let h = (seed ^ Math.imul(slot + 0x9e3779b9, 0x85ebca6b)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x7feb352d); + h = Math.imul(h ^ (h >>> 15), 0x846ca68b); + return (h ^ (h >>> 16)) >>> 0; +} + +/** Which party round it is — shared by the whole table, so they play together. */ +export function partyRound(now: number): number { + return Math.floor(now / PARTY_SLOT_MS); +} + +/** Every third party round is a kick-about. */ +export function isFootballRound(now: number): boolean { + return partyRound(now) % 3 === 1; +} + +/** + * moodFor is what a person is doing right now. + * + * Working beats everything. A done table parties — everyone at it together, + * the round decides whether it is beers or football, and a few dance. A live + * run's idle people watch at first, then draw a new mood every MOOD_SLOT_MS + * (each on their own offset, so the room never changes all at once), with + * naps growing likelier the longer nothing comes their way. A run that is not + * live and did not ship is a room of people napping at their desks. + */ +export function moodFor(i: LifeInputs): Mood { + if (i.active && i.running) return 'work'; + if (i.partying) { + const round = partyRound(i.now); + if (round % 3 === 1) return 'football'; + return roll(i.seed, round) % 4 === 0 ? 'dance' : 'cheers'; + } + const idle = Math.max(0, i.now - i.since); + if (!i.running) { + // The run stopped: people drift off one by one, not in unison. + return idle > 4_000 + (i.seed % 20_000) ? 'nap' : 'watch'; + } + if (idle < WATCH_MS) return 'watch'; + if (idle > SLEEPY_MS * 2) return 'nap'; + const offset = i.seed % MOOD_SLOT_MS; + const slot = Math.floor((i.now + offset) / MOOD_SLOT_MS); + const r = roll(i.seed, slot) % 100; + const napChance = idle > SLEEPY_MS ? 60 : idle > DROWSY_MS ? 22 : 0; + if (r < napChance) return 'nap'; + const rest = (r - napChance) / (100 - napChance); + if (rest < 0.34) return 'watch'; + if (rest < 0.58) return 'coffee'; + if (rest < 0.74) return 'stretch'; + return 'stroll'; +} + +/** + * floorShipped: the run is over and every work table finished — the whole + * floor, harness included, can put its feet up. + */ +export function floorShipped(floor: Pick, running: boolean): boolean { + if (running) return false; + const tables = workTables(floor); + return tables.length > 0 && tables.every((t) => t.total > 0 && t.complete); +} + +/** A table parties when its own tickets are all done, or when the whole run shipped. */ +export function tableParties(team: FloorTeam, shipped: boolean): boolean { + if (shipped) return true; + return !team.internal && team.total > 0 && team.complete; +} diff --git a/web/src/components/Live/floor/floorRig.ts b/web/src/components/Live/floor/floorRig.ts new file mode 100644 index 0000000..733f8bf --- /dev/null +++ b/web/src/components/Live/floor/floorRig.ts @@ -0,0 +1,312 @@ +import { useRef, type RefObject } from 'react'; +import * as THREE from 'three'; +import type { Mood } from './floorLife'; + +// ── A person, with limbs ───────────────────────────────────────────────── +// +// Everyone on the 3D floor is the same little figure: legs, a torso, arms on +// shoulder pivots and a head. The pose is set per frame by poseRig from the +// person's mood — typing, sipping a coffee, stretching, walking, dozing with +// their head on the desk, raising a beer, dancing — so a scene of twenty +// people costs twenty sets of transforms and no re-renders. +// +// Local space: the figure faces −z, its feet are at y = 0. Seated, it drops +// onto a chair and its legs swing forward under the desk. + +export interface Rig { + /** Moves and turns the whole figure: walks, standing up. */ + root: RefObject; + /** Bobs and leans the upper body. */ + body: RefObject; + head: RefObject; + armL: RefObject; + armR: RefObject; + legL: RefObject; + legR: RefObject; + beer: RefObject; + cup: RefObject; +} + +export function useRig(): Rig { + return { + root: useRef(null), + body: useRef(null), + head: useRef(null), + armL: useRef(null), + armR: useRef(null), + legL: useRef(null), + legR: useRef(null), + beer: useRef(null), + cup: useRef(null), + }; +} + +/** How far the body drops when seated. */ +export const SIT_DROP = 0.2; +export const HIP_Y = 0.42; +export const SHOULDER_Y = 1.1; +export const HEAD_Y = 1.38; + +export interface RigLook { + shirt: string; + skin: string; + hair?: string; + /** Half-transparent: a seat the pipeline lent, or someone who has not spoken yet. */ + ghost?: boolean; + crown?: boolean; +} + +export interface PoseInputs { + mood: Mood; + /** Elapsed seconds (the scene clock). */ + t: number; + /** Seconds since this mood began. */ + since: number; + /** A per-person phase, so a table does not move in lockstep. */ + phase: number; + /** 0 = standing, 1 = seated; eased by the caller. */ + sit: number; + /** Walking speed right now, units per second (0 when still). */ + speed: number; + /** The head's glance, when watching. */ + glance: number; + /** Working by talking (on the command center's pad), not by typing. */ + talk?: boolean; +} + +const lerp = THREE.MathUtils.lerp; + +/** Eases a rotation toward a goal: poses blend rather than snap. */ +function ease(obj: THREE.Object3D | null, axis: 'x' | 'y' | 'z', goal: number, k = 0.18) { + if (!obj) return; + obj.rotation[axis] = lerp(obj.rotation[axis], goal, k); +} + +/** + * poseRig sets every joint for this frame. Rotations about x swing a limb + * forward (toward −z) for positive angles: an arm at 1.3 types, at π points + * straight up. + */ +export function poseRig(rig: Rig, p: PoseInputs): void { + const { mood, t, since, phase, sit, speed } = p; + const walking = speed > 0.05; + const stride = walking ? Math.sin(t * 9 + phase) * 0.55 : 0; + let armL = 0.25; + let armR = 0.25; + let armLz = 0; + let armRz = 0; + let headX = 0; + let headY = p.glance; + let bodyX = 0; + let bodyY = 0; + let bodyZ = 0; + let bob = 0; + let kick = 0; + let beer = false; + let cup = false; + + switch (mood) { + case 'work': { + if (p.talk) { + // Holding forth: hands that talk, a nod on the point. + armL = 0.7 + Math.sin(t * 2.6 + phase) * 0.45; + armR = 1.3 + Math.sin(t * 3.4 + phase + 1) * 0.55; + armLz = -0.25; + armRz = 0.3; + headX = Math.sin(t * 4) * 0.08; + bob = Math.abs(Math.sin(t * 2.6)) * 0.03; + headY = Math.sin(t * 0.9) * 0.3; + break; + } + // Typing: both hands on the keys, a nod in time. + armL = 1.25 + Math.sin(t * 16 + phase) * 0.1; + armR = 1.25 + Math.sin(t * 16 + phase + 1.7) * 0.1; + bodyX = -0.06 + Math.sin(t * 9) * 0.03; + bob = Math.abs(Math.sin(t * 9)) * 0.02; + headY = 0; + break; + } + case 'watch': { + armL = sit > 0.5 ? 0.9 : 0.2; + armR = sit > 0.5 ? 0.9 : 0.2; + bob = Math.sin(t * 1.2 + phase) * 0.008; + break; + } + case 'coffee': { + // A sip every four seconds, the cup back down between. + cup = true; + const k = ((since + phase) % 4) / 4; + const sip = k > 0.55 && k < 0.85 ? Math.sin(((k - 0.55) / 0.3) * Math.PI) : 0; + armR = 0.95 + sip * 1.35; + armRz = sip * 0.45; + headX = sip * 0.25; + armL = sit > 0.5 ? 0.9 : 0.25; + bob = Math.sin(t * 1.2 + phase) * 0.008; + break; + } + case 'stretch': { + // Arms up, a lean one way then the other, a yawn of a head tilt. + const s = Math.sin(since * 1.4); + armL = Math.PI - 0.15; + armR = Math.PI - 0.15; + armLz = -0.25 - s * 0.15; + armRz = 0.25 - s * 0.15; + bodyZ = s * 0.12; + headX = 0.25; + bob = 0.02; + break; + } + case 'stroll': { + armL = 0.1; + armR = 0.1; + headY = walking ? 0 : Math.sin(t * 0.8 + phase) * 0.6; + break; + } + case 'nap': { + // Head down on folded arms, a slow breath. + armL = sit > 0.5 ? 1.35 : 0.35; + armR = sit > 0.5 ? 1.35 : 0.35; + armLz = sit > 0.5 ? 0.5 : 0; + armRz = sit > 0.5 ? -0.5 : 0; + bodyX = sit > 0.5 ? -0.42 : -0.1; + headX = -0.55; + headY = 0.35; + bob = Math.sin(t * 1.1 + phase) * 0.012; + break; + } + case 'cheers': { + // Beer up, a clink toward the middle every few seconds. + beer = true; + const clink = Math.max(0, Math.sin(t * 2.2 + phase)) ** 6; + armR = 2.3 + clink * 0.5; + armRz = 0.2; + armL = 0.3 + Math.sin(t * 3 + phase) * 0.15; + bob = Math.abs(Math.sin(t * 3 + phase)) * 0.04; + headX = 0.2; + break; + } + case 'football': { + // On the toes, arms out for balance, the odd kick. + armL = 0.5; + armR = 0.5; + armLz = -0.5; + armRz = 0.5; + bob = Math.abs(Math.sin(t * 6 + phase)) * 0.06; + kick = Math.max(0, Math.sin(t * 1.6 + phase)) ** 8 * 1.1; + break; + } + case 'dance': { + const beat = t * 5 + phase; + armL = Math.PI * 0.75 + Math.sin(beat) * 0.6; + armR = Math.PI * 0.75 - Math.sin(beat) * 0.6; + armLz = -0.4; + armRz = 0.4; + bodyY = Math.sin(beat * 0.5) * 0.5; + bodyZ = Math.sin(beat) * 0.12; + bob = Math.abs(Math.sin(beat)) * 0.09; + break; + } + } + + if (walking) { + // Arms swing against the legs; nothing else carries on mid-stride. + armL = 0.1 - stride * 0.8; + armR = 0.1 + stride * 0.8; + armLz = 0; + armRz = 0; + bob = Math.abs(Math.sin(t * 9 + phase)) * 0.05; + bodyX = 0.05; + bodyY = 0; + beer = mood === 'cheers'; + } + + if (rig.body.current) { + rig.body.current.position.y = bob - sit * SIT_DROP; + } + ease(rig.body.current, 'x', bodyX); + ease(rig.body.current, 'y', bodyY); + ease(rig.body.current, 'z', bodyZ); + ease(rig.head.current, 'x', headX, 0.1); + ease(rig.head.current, 'y', headY, 0.06); + ease(rig.armL.current, 'x', armL, 0.22); + ease(rig.armR.current, 'x', armR, 0.22); + ease(rig.armL.current, 'z', armLz, 0.22); + ease(rig.armR.current, 'z', armRz, 0.22); + // Seated, the thighs swing forward under the desk; standing, they walk. + const legSit = sit * 1.45; + if (rig.legL.current) { + rig.legL.current.rotation.x = lerp(rig.legL.current.rotation.x, legSit + stride, 0.3); + rig.legL.current.position.y = HIP_Y - sit * SIT_DROP; + } + if (rig.legR.current) { + rig.legR.current.rotation.x = lerp(rig.legR.current.rotation.x, legSit - stride + kick, 0.3); + rig.legR.current.position.y = HIP_Y - sit * SIT_DROP; + } + if (rig.beer.current) rig.beer.current.visible = beer; + if (rig.cup.current) rig.cup.current.visible = cup && !walking; +} + +/** + * Pose a rig without animation (prefers-reduced-motion): a still picture of + * the same mood. + */ +export function stillPose(rig: Rig, mood: Mood, sit: number, glance: number, talk = false): void { + for (let i = 0; i < 30; i++) poseRig(rig, { mood, t: 0.3, since: 2.5, phase: 0, sit, speed: 0, glance, talk }); +} + +const SKINS = ['#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#ffdbac', '#f5d0b5']; +const HAIRS = ['#2b1d14', '#4a3222', '#a0522d', '#d6b370', '#111827', '#9ca3af', '#7c2d12']; +const SHIRTS = ['#c7d2fe', '#bae6fd', '#bbf7d0', '#fde68a', '#fecdd3', '#e9d5ff', '#fed7aa', '#99f6e4']; + +/** A person's own look, from their seed: skin, hair and a shirt nobody else at the table shares often. */ +export function lookOf(seed: number): { skin: string; hair: string; shirt: string } { + return { skin: SKINS[seed % SKINS.length], hair: HAIRS[(seed >>> 4) % HAIRS.length], shirt: SHIRTS[(seed >>> 9) % SHIRTS.length] }; +} + +// ── Walking ────────────────────────────────────────────────────────────── + +export interface Walk { + points: THREE.Vector3[]; + /** Cumulative length at each point. */ + at: number[]; + total: number; + /** Scene-clock seconds when the walk began. */ + start: number; + speed: number; +} + +export function makeWalk(points: THREE.Vector3[], start: number, speed = 1.6): Walk | null { + const pts = points.filter((p, i) => i === 0 || p.distanceToSquared(points[i - 1]) > 1e-6); + if (pts.length < 2) return null; + const at = [0]; + for (let i = 1; i < pts.length; i++) at.push(at[i - 1] + pts[i].distanceTo(pts[i - 1])); + return { points: pts, at, total: at[at.length - 1], start, speed }; +} + +/** + * walkAt writes the position `t` seconds into a walk to `out`, and returns + * the direction of travel's yaw (for a figure facing −z) or null when the + * walk is over. + */ +export function walkAt(w: Walk, t: number, out: THREE.Vector3): number | null { + const d = Math.max(0, (t - w.start) * w.speed); + if (d >= w.total) { + out.copy(w.points[w.points.length - 1]); + return null; + } + let i = 1; + while (i < w.points.length - 1 && w.at[i] < d) i++; + const a = w.points[i - 1]; + const b = w.points[i]; + const seg = w.at[i] - w.at[i - 1] || 1; + out.copy(a).lerp(b, (d - w.at[i - 1]) / seg); + return Math.atan2(-(b.x - a.x), -(b.z - a.z)); +} + +/** Turns an angle toward a goal the short way round. */ +export function turnToward(current: number, goal: number, k: number): number { + let diff = goal - current; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + return current + diff * k; +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 86bdd79..f636b44 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -517,3 +517,41 @@ .floor3d-screen-say { font-size: 9.5px; line-height: 1.3; opacity: 0.8; max-height: 3.9em; overflow: hidden; } @media (prefers-reduced-motion: reduce) { .floor3d-screen-phase { text-shadow: none; } } .floor-stage-live { animation: floorSelected 1.4s ease-in-out infinite; } + +/* ── Life on the floor: moods, naps, the command center's crew ── */ +.floor3d-mood { display: inline-block; margin-right: 3px; animation: floorMoodBob 2.4s ease-in-out infinite; } +@keyframes floorMoodBob { 0%, 100% { transform: translateY(0) rotate(0); } 50% { transform: translateY(-2px) rotate(-8deg); } } +.floor3d-name-quiet { font-size: 9.5px; font-weight: 500; padding: 1px 6px; opacity: 0.82; } +.floor3d-chip { + display: inline-flex; + gap: 2px; + padding: 1px 5px; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.78); + font-size: 10px; + box-shadow: 0 2px 6px rgba(17, 24, 39, 0.12); + backdrop-filter: blur(3px); +} +.dark .floor3d-chip { background: rgba(17, 24, 39, 0.78); } +.floor3d-zzz { display: inline-flex; gap: 1px; align-self: flex-end; margin-right: -14px; color: #6366f1; font-weight: 800; font-style: italic; pointer-events: none; } +.dark .floor3d-zzz { color: #a5b4fc; } +.floor3d-zzz i { font-style: italic; opacity: 0; animation: floorZzz 2.7s ease-out infinite; } +.floor3d-zzz i:nth-child(1) { font-size: 9px; } +.floor3d-zzz i:nth-child(2) { font-size: 11px; animation-delay: 0.9s; } +.floor3d-zzz i:nth-child(3) { font-size: 14px; animation-delay: 1.8s; } +@keyframes floorZzz { 0% { opacity: 0; transform: translate(0, 4px); } 30% { opacity: 1; } 100% { opacity: 0; transform: translate(8px, -14px); } } + +/* The flat map's moods: badges over the avatars, a doze, a party bounce. */ +.floor-mood { animation: floorMoodBob 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +.floor-avatar-nap .floor-avatar-inner { opacity: 0.75; animation: floorDoze 4s ease-in-out infinite; transform-box: fill-box; transform-origin: bottom center; } +@keyframes floorDoze { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(-9deg) translateY(1px); } } +.floor-avatar-party .floor-avatar-inner { animation: floorParty 0.7s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: bottom center; } +@keyframes floorParty { from { transform: translateY(0) rotate(-4deg); } to { transform: translateY(-5px) rotate(4deg); } } +.floor-avatar-stroll .floor-avatar-inner { animation: floorStroll 6s ease-in-out infinite; } +@keyframes floorStroll { 0%, 100% { transform: translateX(0); } 30% { transform: translateX(10px); } 70% { transform: translateX(-10px); } } +.floor-hq-door { transition: transform 0.4s ease; transform-box: fill-box; } +.floor-party-badge { animation: floorMoodBob 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@media (prefers-reduced-motion: reduce) { + .floor3d-mood, .floor3d-zzz i, .floor-mood, .floor-avatar-nap .floor-avatar-inner, .floor-avatar-party .floor-avatar-inner, .floor-avatar-stroll .floor-avatar-inner, .floor-party-badge { animation: none !important; } + .floor3d-zzz i { opacity: 1; } +} From 8f2b116fed3dbefd38dba2d5ac8579eb20ddd3ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:56:10 +0000 Subject: [PATCH 2/2] style(web): end RigBody.tsx with a single newline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MursiSjrf51muBCzrWus9Y --- web/src/components/Live/floor/RigBody.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/Live/floor/RigBody.tsx b/web/src/components/Live/floor/RigBody.tsx index 603e6cc..1612f4a 100644 --- a/web/src/components/Live/floor/RigBody.tsx +++ b/web/src/components/Live/floor/RigBody.tsx @@ -98,4 +98,3 @@ export default function RigBody({ rig, look, pick, children }: { rig: Rig; look: ); } -