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..1612f4a --- /dev/null +++ b/web/src/components/Live/floor/RigBody.tsx @@ -0,0 +1,100 @@ +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; } +}