Le Bureau des agents in a side pane: its pixel-art office and engine, playing your session. Max is Claude; each subagent walks in, works at a desk of its…

Le Bureau des agents in a side pane of Claude Code: its pixel-art office, drawn by its own engine, playing the session you are in. Max, at the dark desk in the middle, is Claude. Each subagent Claude calls is hired for its trade: it comes in through the door, walks to a desk in its corner (Recherche, Code, Design, Rédaction, Analyse), works there with its screen lit and an icon for each action, brings its report to Max and walks out. The window follows the hour: day, evening, then night, when the lamps, the screens and the neon light the room.
| Day | Night |
|---|---|
![]() | ![]() |
Adapted from Le Bureau des agents (commit
184daa8) by VibesMoney, MIT licensed. See LICENSE. Its engine (src/engine/andshared/activity.ts,shared/protocol.ts) is inhooks/engine/, unchanged except that it draws onhooks/canvas.ts, a small stand-in for the browser canvas (a hooks module has no DOM), and imports without the.tssuffix. New here: the events come from this session instead of aclaude -pthe Bureau starts itself, each frame is encoded as a PNG (hooks/png.ts) for the pane, and the agent list under the picture. The Bureau's interface, sounds, music, voice and interactions are not part of it.
| In the session | In the office |
|---|---|
| A turn starts | Max sits at his desk |
| A tool call (Read, WebSearch, Edit, Bash…) | An icon over the robot, its screen lit; the list shows the action ("Je lis app.ts") |
AskUserQuestion | Max raises his hand |
| A subagent starts (the Agent tool) | A teammate is hired: the trade comes from the agent type (Explore is Recherche, a reviewer or tester is Code, a frontend agent is Design, a writer is Rédaction, a planner or analyst is Analyse, anything else a helper who stands) |
| The subagent ends | It brings its report to Max's desk and leaves; one that failed looks sad first |
| The turn ends, no one at work | Max walks back to the door; confetti when the turn went well |
/clear | The office empties |
Under the picture, a line per agent with what it is doing and for how long, and the session's counts (agents, searches, pages, reads, writes, commands).
On the desktop the office is a picture in the pane, scaled to its width. On a terminal it is a picture where the terminal can show one (kitty, Ghostty), its description elsewhere.
| Command | Does |
|---|---|
/bureau | Open the office |
/bureau-auto | Open it on its own the first time Claude calls a subagent (the default), or only with /bureau |
The office only watches this session: it runs nothing and calls no model. While the pane is open it draws about three frames a second when something moves (a frame takes about 50 ms), one a second otherwise; closed, it draws nothing.
/plugin install bureau@arhun-pluginshooks/register.tsx 141 lines1// bureau: the session's agents in Le Bureau des agents (devlikemasta/bureau-des-agents, MIT), in a side pane. Its
2// pixel-art office and its engine run here unchanged, on a canvas of our own; instead of the agents of a mission the
3// Bureau would start itself, they play this session: Max is Claude, and each subagent Claude calls walks in through
4// the door, works at a desk of its trade, hands its report in and leaves. Nothing runs, nothing is billed: the
5// office only watches.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import { Office } from './office'
11import { renderPane } from './view'
12
13const PANE = 'bureau'
14/** The engine's own clock: its walks and timers advance in small steps. */
15const TICK_MS = 125
16/** A frame (drawn and encoded, about 50 ms) at most this often while something moves, */
17const MOVE_FRAME_MS = 300
18/** and once a second when the office is still, so it keeps blinking and twinkling. */
19const IDLE_MS = 1000
20
21const frameAtom = atom({ plugin: 'bureau', key: 'frame' } as const, 0)
22
23const COMMANDS = [
24 { name: 'bureau', description: "Ouvre le Bureau des agents dans un panneau" },
25 { name: 'bureau-auto', description: 'Ouvre le bureau tout seul quand Claude appelle un agent, ou plus' },
26] as const
27
28// Module state: the office lives here, rebuilt empty on a reload.
29const office = new Office()
30let isPaneOpen = false
31let lastStep = 0
32let lastFrame = 0
33
34async function openPane($: EngineInterface): Promise<boolean> {
35 const r = await $.ui.open({ id: PANE, title: 'Bureau' })
36 isPaneOpen = true
37 return r.isPlaced
38}
39
40/** Something happened in the office: the pane's list says so now, not at the next tick. */
41async function poke($: EngineInterface) {
42 if (isPaneOpen) await update($, frameAtom, n => n + 1)
43}
44
45/** The engine moves on; the pane redraws while something moves, and once a second otherwise. */
46async function tick($: EngineInterface) {
47 const now = await $.clock.now()
48 const dt = lastStep ? (now - lastStep) / 1000 : 0
49 lastStep = now
50 const isMoving = office.step(dt)
51 if (!isPaneOpen) return
52 if (now - lastFrame >= (isMoving ? MOVE_FRAME_MS : IDLE_MS)) {
53 lastFrame = now
54 await update($, frameAtom, n => n + 1)
55 }
56}
57
58export const register: Register = on => {
59 on('session.start', async ($, e, next) => {
60 for (const c of COMMANDS) await $.command.register(c)
61 isPaneOpen = (await $.ui.panes()).some(p => p.id === PANE)
62 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
63 return next(e)
64 })
65
66 // ── Max: the main conversation ─────────────────────────────────────────
67
68 on('turn.start', async ($, e, next) => {
69 office.turnStart(e.text)
70 await poke($)
71 return next(e)
72 })
73
74 on('turn.complete', async ($, e, next) => {
75 const result = await next(e)
76 const now = await $.clock.now()
77 if (e.agentId === undefined) office.turnEnd(e.reason !== 'error', e.durationMs)
78 else office.leave(e.agentId, e.reason !== 'error', now)
79 await poke($)
80 return result
81 })
82
83 on('tool.call', async ($, e, next) => {
84 const input = e as unknown as Record<string, unknown>
85 office.act(e.tool, input, e.tool_use_id, e.agentId)
86 await poke($)
87 if (e.agentId === undefined && e.tool === 'AskUserQuestion') {
88 office.ask(e.tool_use_id)
89 const ran = await next(e)
90 office.answered(e.tool_use_id)
91 return ran
92 }
93 return next(e)
94 })
95
96 // ── A subagent: a teammate walks in ────────────────────────────────────
97
98 on('agent.spawn', async ($, e, next) => {
99 const r = await next(e)
100 if (r.deny !== undefined || !r.agentId) return r
101 office.hire(r.agentId, e.subagentType, e.description, e.prompt, await $.clock.now())
102 if (!isPaneOpen && (await $.store.get('isAuto')) !== false) await openPane($).catch(() => undefined)
103 await poke($)
104 return r
105 })
106
107 // /clear starts a new conversation: the office empties and Max goes back to the door.
108 on('session.end', async ($, e, next) => {
109 const result = await next(e)
110 if (e.reason === 'clear') office.reset()
111 await poke($)
112 return result
113 })
114
115 // ── Commands and the pane ──────────────────────────────────────────────
116
117 on('command.run', { command: 'bureau' }, async $ => {
118 const isPlaced = await openPane($)
119 return { text: isPlaced ? 'Bureau ouvert.' : 'Bureau ouvert ; élargis la fenêtre pour le voir.' }
120 })
121
122 on('command.run', { command: 'bureau-auto' }, async $ => {
123 const isAuto = (await $.store.get('isAuto')) === false
124 await $.store.set('isAuto', isAuto)
125 return { text: isAuto ? 'Le bureau s’ouvre tout seul quand Claude appelle un agent.' : 'Le bureau ne s’ouvre plus qu’avec /bureau.' }
126 })
127
128 on('ui.close', async ($, e, next) => {
129 const result = await next(e)
130 if (e.id === PANE) isPaneOpen = false
131 return result
132 })
133
134 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135 await read($, frameAtom)
136 const now = await $.clock.now()
137 const png = office.frame(new Date(now))
138 return renderPane($.ui.resolve(e) as Parameters<typeof renderPane>[0], e.surface, office, png, e.props.bodyColumns, now)
139 })
140}
141hooks/office.ts 152 lines1// The office: Le Bureau des agents' own world and renderer (hooks/engine, unchanged but for the canvas they draw on),
2// fed with this session's events instead of the events of a `claude -p` the Bureau would run itself. Max is the
3// main conversation; each subagent is a teammate hired for its trade, who walks in, works, hands in its report
4// and leaves. A frame is the engine's 352 × 256 image, encoded as a PNG.
5
6import { describeAct, NAMES } from './engine/activity'
7import { H, W } from './engine/layout'
8import { makeCanvas } from './engine/pixels'
9import type { ActKind, OfficeEvent, PlanItem, Role } from './engine/protocol'
10import { Renderer } from './engine/render'
11import type { TimeOfDay } from './engine/room'
12import { World } from './engine/world'
13import { base64, encodePng } from './png'
14
15export { H, W }
16
17/** The trade of a subagent, from the agent type Claude called it as. */
18export function roleOf(type: string): Role {
19 const t = type.toLowerCase()
20 if (/explor|research|search|find|scout|guide|cherch/.test(t)) return 'recherche'
21 if (/design|frontend|\bui\b|ux|visual|animat/.test(t)) return 'design'
22 if (/review|test|code|refactor|devops|secur|implement|debug|fix|build|migrat|develop/.test(t)) return 'code'
23 if (/doc|writ|spec|copy|content|redac/.test(t)) return 'redaction'
24 if (/plan|analy|data|observ|metric|estimat|audit|rag/.test(t)) return 'analyse'
25 return 'renfort'
26}
27
28export const timeOfDay = (hour: number): TimeOfDay => (hour >= 8 && hour < 18 ? 'day' : hour >= 18 && hour < 21 ? 'evening' : 'night')
29
30/** What the pane lists under the picture: who is in, what they do right now. */
31export type Member = {
32 id: string
33 name: string
34 role: Role
35 task: string
36 label: string
37 startedAt: number
38 endedAt: number | null
39 ok: boolean | null
40}
41
42export class Office {
43 world = new World()
44 private renderer = new Renderer()
45 private canvas = makeCanvas(W, H)
46 private g = this.canvas.getContext('2d')
47 /** The lead's bubble; null between turns. */
48 leadLabel: string | null = null
49 members = new Map<string, Member>()
50 stats = { agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 }
51
52 private apply(ev: OfficeEvent) {
53 this.world.apply(ev, true)
54 }
55
56 private count(kind: ActKind) {
57 const s = this.stats
58 if (kind === 'search') s.searches++
59 else if (kind === 'web') s.pages++
60 else if (kind === 'read') s.reads++
61 else if (kind === 'write' || kind === 'edit') s.writes++
62 else if (kind === 'bash') s.commands++
63 }
64
65 /** A turn of the main conversation starts: Max sits down to it. */
66 turnStart(text: string) {
67 if (!this.world.missionActive) this.apply({ k: 'start', text })
68 this.apply({ k: 'think', who: 'chef' })
69 this.leadLabel = 'Réfléchit'
70 }
71
72 /** A turn of the main conversation ends; with nobody still at work, Max goes back to the door to greet you. */
73 turnEnd(ok: boolean, ms: number) {
74 this.leadLabel = null
75 const busy = [...this.members.values()].some(m => m.endedAt === null)
76 if (!busy) this.apply({ k: 'end', ok, text: '', ms })
77 }
78
79 /** A tool call, by Max (`who` absent) or by a teammate. */
80 act(tool: string, input: Record<string, unknown>, callId: string, who?: string) {
81 const d = describeAct(tool, input)
82 this.count(d.kind)
83 const id = who ?? 'chef'
84 if (!who) {
85 this.leadLabel = d.label
86 const todos = input['todos']
87 if (tool === 'TodoWrite' && Array.isArray(todos)) {
88 const items: PlanItem[] = todos
89 .filter((x): x is Record<string, unknown> => Boolean(x) && typeof x === 'object')
90 .map(x => ({ text: String(x['content'] ?? ''), status: x['status'] === 'completed' ? 'completed' : x['status'] === 'in_progress' ? 'in_progress' : 'pending' }))
91 this.apply({ k: 'plan', items })
92 }
93 } else {
94 const m = this.members.get(who)
95 if (!m) return
96 m.label = d.label
97 }
98 this.apply({ k: 'act', who: id, id: callId, kind: d.kind, tool, label: d.label, detail: d.detail })
99 }
100
101 /** Max asks you something: a hand goes up until you answer. */
102 ask(callId: string) {
103 this.leadLabel = 'Te pose une question'
104 this.apply({ k: 'ask', who: 'chef', requestId: callId, tool: 'AskUserQuestion', label: '', detail: '', full: false })
105 }
106 answered(callId: string) {
107 this.apply({ k: 'ask_done', requestId: callId, allow: true })
108 }
109
110 /** A subagent starts: a teammate of its trade comes in through the door. */
111 hire(id: string, type: string, task: string, prompt: string, now: number) {
112 if (this.members.has(id)) return
113 const role = roleOf(type)
114 const taken = new Set([...this.world.actors.values()].map(a => a.name))
115 const pool = NAMES[role]
116 const name = pool.find(n => !taken.has(n)) ?? `${pool[0]} ${taken.size}`
117 this.members.set(id, { id, name, role, task, label: 'Arrive', startedAt: now, endedAt: null, ok: null })
118 this.stats.agents++
119 this.apply({ k: 'hire', id, role, type, name, task, prompt })
120 }
121
122 /** A subagent's run ended: its report goes on Max's desk, then it leaves. */
123 leave(id: string, ok: boolean, now: number) {
124 const m = this.members.get(id)
125 if (!m || m.endedAt !== null) return
126 m.endedAt = now
127 m.ok = ok
128 m.label = ok ? 'Rend son rapport' : 'A échoué'
129 this.apply({ k: 'leave', who: id, ok, tokens: 0, tools: 0, ms: now - m.startedAt })
130 }
131
132 /** Moves the world on by `dt` seconds; true while something moves. Forgets teammates who have walked out. */
133 step(dt: number): boolean {
134 const moving = this.world.update(dt)
135 for (const [id, m] of this.members) if (m.endedAt !== null && !this.world.actors.has(id)) this.members.delete(id)
136 return moving
137 }
138
139 reset() {
140 this.world.resetAll()
141 this.members.clear()
142 this.leadLabel = null
143 this.stats = { agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 }
144 }
145
146 /** The office as it stands, a PNG in base64. */
147 frame(now: Date): string {
148 this.renderer.draw(this.g, this.world, timeOfDay(now.getHours()), now)
149 return base64(encodePng(this.canvas.data, W, H))
150 }
151}
152hooks/view.tsx 104 lines1// The pane: a header row, the office as a picture (an SVG holding the frame's PNG on the desktop, the PNG itself on
2// a terminal that shows pictures), a line per agent with its bubble, and the session's counts. Host elements and
3// theme colours for the text, as the other mods draw.
4
5import type { Elements, RenderElement, RenderSurface } from 'claude-code'
6
7import { ROLES } from './engine/activity'
8import { H, W, type Member, type Office } from './office'
9
10type TermEls = Elements['terminal']
11type DeskEls = Elements['desktop']
12
13const NAME_W = 9
14const ROLE_W = 15
15const MAX_PX = 704
16
17const clock = (ms: number) => {
18 const s = Math.max(0, Math.floor(ms / 1000))
19 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
20}
21
22function summary(o: Office): string {
23 const team = [...o.members.values()]
24 const working = team.filter(m => m.endedAt === null).length
25 const parts = [o.leadLabel === null ? 'Max t’attend' : 'Max au travail']
26 if (working) parts.push(`${working} agent${working > 1 ? 's' : ''} au travail`)
27 return parts.join(' · ')
28}
29
30export function renderPane(E: TermEls, surface: RenderSurface, o: Office, png: string, columns: number, now: number): RenderElement {
31 const { Box, Text } = E
32 const alt = summary(o)
33 let picture: RenderElement
34 if (surface === 'terminal') {
35 // a picture where the terminal can show one (kitty, Ghostty), its alt elsewhere; cells are about twice as tall as wide
36 const cols = Math.max(20, Math.min(columns, 88))
37 picture = <E.Image key="office" source={{ png }} columns={cols} rows={Math.round((cols * H) / W / 2)} alt={`${alt} (le bureau s’affiche dans un terminal qui montre les images : kitty, Ghostty)`} />
38 } else {
39 const Svg = (E as unknown as DeskEls).Svg
40 const width = Math.max(W, Math.min(MAX_PX, columns * 8))
41 const height = Math.round((width * H) / W)
42 const source =
43 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${W} ${H}">` +
44 `<image href="data:image/png;base64,${png}" width="${W}" height="${H}" style="image-rendering:pixelated" image-rendering="pixelated"/></svg>`
45 picture = <Svg key="office" source={source} alt={alt} width={width} height={height} />
46 }
47
48 const row = (key: string, dot: 'claude' | 'warning' | 'success' | 'error' | 'inactive', name: string, role: string, what: string, time: string) => (
49 <Box key={key} flexDirection="row" columnGap={1}>
50 <Text color={dot}>●</Text>
51 <Box width={NAME_W} flexShrink={0}>
52 <Text wrap="truncate-end">{name}</Text>
53 </Box>
54 <Box width={ROLE_W} flexShrink={0}>
55 <Text dimColor wrap="truncate-end">
56 {role}
57 </Text>
58 </Box>
59 <Box flexGrow={1} flexShrink={1} minWidth={0}>
60 <Text wrap="truncate-end">{what}</Text>
61 </Box>
62 {time ? <Text dimColor>{time}</Text> : null}
63 </Box>
64 )
65 const memberRow = (m: Member) =>
66 row(
67 m.id,
68 m.endedAt === null ? 'claude' : m.ok ? 'success' : 'error',
69 m.name,
70 ROLES[m.role].label,
71 m.endedAt === null ? m.label : `${m.label} · ${m.task}`,
72 clock((m.endedAt ?? now) - m.startedAt),
73 )
74 const s = o.stats
75 const counts = [
76 `${s.agents} agent${s.agents > 1 ? 's' : ''}`,
77 `${s.searches} recherches`,
78 `${s.pages} pages`,
79 `${s.reads} lectures`,
80 `${s.writes} écritures`,
81 `${s.commands} commandes`,
82 ].join(' · ')
83
84 return (
85 <Box key="bureau" flexDirection="column" rowGap={1}>
86 <Box flexDirection="column">
87 <Text wrap="truncate-end">
88 <Text color="claude">▣ </Text>
89 <Text bold>Bureau</Text>
90 <Text dimColor>{` · ${alt}`}</Text>
91 </Text>
92 {picture}
93 </Box>
94 <Box flexDirection="column">
95 {row('chef', o.leadLabel === null ? 'inactive' : 'claude', 'Max', 'Claude, chef', o.leadLabel ?? 'Attend ton prochain message', '')}
96 {[...o.members.values()].map(memberRow)}
97 <Text dimColor wrap="truncate-end">
98 {counts}
99 </Text>
100 </Box>
101 </Box>
102 )
103}
104hooks/engine/activity.ts 164 lines1// Les métiers du bureau, et la traduction en français de ce que fait vraiment chaque agent :
2// chaque outil de Claude Code devient une action (une icône, une animation, une bulle).
3import type { ActKind, MissionStats, OfficeEvent, Role } from './protocol';
4
5export interface RoleInfo {
6 /** le nom du métier, tel qu'il s'affiche dans le bureau */
7 label: string;
8 icon: string;
9 color: string;
10 /** l'agent Claude Code (subagent_type) qui fait ce métier */
11 type: string;
12 what: string;
13}
14
15export const ROLES: Record<Role, RoleInfo> = {
16 chef: { label: 'Chef de projet', icon: '⭐', color: '#FF8A3D', type: '', what: 'Il reçoit ta demande, fait le plan, répartit le travail et assemble le rendu.' },
17 recherche: { label: 'Recherche', icon: '🔎', color: '#4FB3FF', type: 'chercheur', what: 'Cherche sur internet : concurrents, prix, avis, actualités, sources.' },
18 code: { label: 'Code', icon: '💻', color: '#3DDC97', type: 'developpeur', what: 'Code, sites, scripts, automatisations : ce qui se lance et se teste.' },
19 design: { label: 'Design', icon: '🎨', color: '#FF6FA8', type: 'designer', what: 'Pages web soignées, maquettes, visuels, mise en forme.' },
20 redaction: { label: 'Rédaction', icon: '✍️', color: '#A98BFF', type: 'redacteur', what: 'Posts, e-mails, articles, scripts vidéo, rapports.' },
21 analyse: { label: 'Analyse', icon: '📊', color: '#FFC93D', type: 'analyste', what: 'Chiffres, tableaux, fichiers, comparatifs, calculs.' },
22 renfort: { label: 'Renfort', icon: '🤖', color: '#9AA3B5', type: '', what: 'Un coup de main sur une tâche à part.' },
23};
24
25/** Les outils Claude Code de chaque métier (le chef a tous ceux de la séance). */
26export const ROLE_TOOLS: Record<Role, string[]> = {
27 chef: ['Agent', 'TodoWrite', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Bash'],
28 recherche: ['WebSearch', 'WebFetch', 'Read', 'Glob', 'Grep'],
29 code: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebFetch'],
30 design: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch'],
31 redaction: ['Read', 'Write', 'Edit', 'Glob', 'Grep'],
32 analyse: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
33 renfort: [],
34};
35
36/** Ce que veut dire chaque outil, en français (pour la fiche des métiers). */
37export const TOOL_INFO: Record<string, string> = {
38 Agent: 'appelle un membre de l’équipe', TodoWrite: 'écrit et coche le plan', Read: 'lit un fichier', Write: 'crée un fichier',
39 Edit: 'retouche un fichier', Glob: 'cherche des fichiers', Grep: 'cherche dans les fichiers', WebSearch: 'cherche sur internet',
40 WebFetch: 'lit une page web', Bash: 'lance une commande',
41};
42
43/** Le métier d'un agent, d'après le type d'agent que le chef a appelé. */
44export function roleOfType(type: string): Role {
45 for (const [role, info] of Object.entries(ROLES) as [Role, RoleInfo][]) if (info.type && info.type === type) return role;
46 return 'renfort';
47}
48
49/** Les prénoms des robots, par métier (jamais deux fois le même dans une mission). */
50export const NAMES: Record<Role, string[]> = {
51 chef: ['Max'],
52 recherche: ['Iris', 'Hugo', 'Léa', 'Malo', 'Zoé', 'Noé', 'Inès', 'Tom', 'Jade', 'Sacha', 'Lou', 'Nils'],
53 code: ['Ada', 'Linus', 'Grace', 'Alan', 'Margaret', 'Dennis'],
54 design: ['Frida', 'Coco', 'Henri', 'Sonia', 'Saul'],
55 redaction: ['Colette', 'Victor', 'Simone', 'Jules', 'George'],
56 analyse: ['Marie', 'Blaise', 'Émilie', 'Gaspard', 'Ada'],
57 renfort: ['Bob', 'Lulu', 'Gaston', 'Nina', 'Momo', 'Kiki'],
58};
59
60const base = (p: unknown) => (typeof p === 'string' ? p.split(/[\\/]/).filter(Boolean).pop() ?? p : '');
61export const cut = (s: unknown, n = 60) => {
62 const t = String(s ?? '').replace(/\s+/g, ' ').trim();
63 return t.length > n ? t.slice(0, n - 1) + '…' : t;
64};
65export const host = (u: unknown) => {
66 try { return new URL(String(u)).hostname.replace(/^www\./, ''); } catch { return cut(u, 40); }
67};
68
69const MCP_NAMES: Record<string, string> = {
70 gmail: 'Gmail', google_drive: 'Google Drive', notion: 'Notion', google_calendar: 'Google Agenda', calendar: 'Google Agenda',
71 slack: 'Slack', github: 'GitHub', stripe: 'Stripe', hubspot: 'HubSpot', airtable: 'Airtable',
72};
73
74/** Le nom lisible d'un connecteur MCP : mcp__claude_ai_Gmail__search_threads → Gmail */
75export function mcpName(tool: string): { server: string; action: string } {
76 const parts = tool.split('__');
77 const raw = (parts[1] ?? '').replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '');
78 const server = MCP_NAMES[raw.toLowerCase()] ?? raw.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
79 return { server, action: (parts[2] ?? '').replace(/[_-]+/g, ' ') };
80}
81
82/** L'action, sa phrase (courte, pour la bulle) et le détail complet (pour le panneau). */
83export function describeAct(tool: string, input: Record<string, unknown> | undefined): { kind: ActKind; label: string; detail: string } {
84 const i = input ?? {};
85 switch (tool) {
86 case 'WebSearch': return { kind: 'search', label: `Je cherche « ${cut(i.query, 44)} »`, detail: String(i.query ?? '') };
87 case 'WebFetch': return { kind: 'web', label: `Je lis ${host(i.url)}`, detail: String(i.url ?? '') };
88 case 'Read': return { kind: 'read', label: `Je lis ${cut(base(i.file_path), 36)}`, detail: String(i.file_path ?? '') };
89 case 'Glob': return { kind: 'read', label: 'Je fouille le dossier', detail: String(i.pattern ?? '') };
90 case 'Grep': return { kind: 'read', label: `Je cherche « ${cut(i.pattern, 30)} »`, detail: `${i.pattern ?? ''}${i.path ? ' dans ' + i.path : ''}` };
91 case 'LS': return { kind: 'read', label: 'Je regarde le dossier', detail: String(i.path ?? '') };
92 case 'Write': return { kind: 'write', label: `J’écris ${cut(base(i.file_path), 36)}`, detail: String(i.file_path ?? '') };
93 case 'Edit':
94 case 'MultiEdit': return { kind: 'edit', label: `Je retouche ${cut(base(i.file_path), 34)}`, detail: String(i.file_path ?? '') };
95 case 'NotebookEdit': return { kind: 'edit', label: `Je retouche ${cut(base(i.notebook_path), 34)}`, detail: String(i.notebook_path ?? '') };
96 case 'Bash': {
97 const c = String(i.command ?? '').replace(/\s+/g, ' ').trim();
98 return { kind: 'bash', label: `Je lance ${c.length <= 34 ? `« ${c} »` : cut(i.description || c, 40)}`, detail: String(i.command ?? '') };
99 }
100 case 'TodoWrite': return { kind: 'plan', label: 'Je mets le plan à jour', detail: '' };
101 case 'Skill': return { kind: 'skill', label: `J’applique la méthode ${cut(i.skill ?? i.name, 24)}`, detail: String(i.args ?? '') };
102 case 'ToolSearch': return { kind: 'other', label: 'Je cherche le bon outil', detail: String(i.query ?? '') };
103 }
104 if (tool.startsWith('mcp__')) {
105 const { server, action } = mcpName(tool);
106 return { kind: 'mcp', label: `Je passe par ${server}`, detail: action };
107 }
108 return { kind: 'other', label: `J’utilise ${tool}`, detail: '' };
109}
110
111export const emptyStats = (): MissionStats => ({ agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 });
112
113/** Les compteurs de la mission (recherches, pages lues…), mis à jour événement par événement. */
114export function countStats(s: MissionStats, ev: OfficeEvent) {
115 if (ev.k === 'hire') s.agents++;
116 if (ev.k !== 'act') return;
117 if (ev.kind === 'search') s.searches++;
118 else if (ev.kind === 'web') s.pages++;
119 else if (ev.kind === 'read') s.reads++;
120 else if (ev.kind === 'write' || ev.kind === 'edit') s.writes++;
121 else if (ev.kind === 'bash') s.commands++;
122}
123
124/** Remplace le chemin du dossier de la mission par « . » partout où il apparaît en entier. */
125export function relToFolder(text: string, folder: string): string {
126 if (!folder) return text;
127 const esc = folder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128 return text.replace(new RegExp(esc + '(?=$|[/"\'\\s])', 'g'), '.').replace(/(^|[\s"'])\.\//g, '$1');
129}
130
131/** La commande telle qu'elle sera lancée, juste rendue lisible (le dossier de la mission devient « . »). Rien n'est caché. */
132export function readableCommand(cmd: unknown, folder: string): string {
133 return relToFolder(String(cmd ?? ''), folder).trim().replace(/^cd\s+(?:"\."|'\.'|\.)\s*&&\s*/, '');
134}
135
136const FULL_MAX = 72;
137
138/**
139 * La demande de permission : la phrase de la bulle, le détail complet, et `full`
140 * (vrai seulement si la bulle montre toute l'action : on peut alors répondre directement dans la bulle).
141 */
142export function permissionText(tool: string, input: Record<string, unknown> | undefined, folder: string): { label: string; detail: string; full: boolean } {
143 const i = input ?? {};
144 if (tool === 'Bash') {
145 const c = readableCommand(i.command, folder);
146 const full = c.length > 0 && c.length <= FULL_MAX && !c.includes('\n');
147 return { label: `Je peux lancer « ${full ? c : cut(c, 48)} » ?`, detail: c, full };
148 }
149 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
150 const raw = String(i.file_path ?? i.notebook_path ?? '');
151 const rel = relToFolder(raw, folder);
152 const inside = !!folder && raw.startsWith(folder + '/') && !rel.includes('..');
153 return { label: `Je peux ${tool === 'Write' ? 'écrire' : 'modifier'} ${inside ? rel : raw} ?`, detail: raw, full: inside && rel.length <= FULL_MAX };
154 }
155 if (tool === 'WebFetch') return { label: `Je peux lire ${host(i.url)} ?`, detail: String(i.url ?? ''), full: false };
156 const detail = relToFolder(JSON.stringify(i, null, 2), folder);
157 const clipped = detail.length > 1500 ? detail.slice(0, 1499) + '…' : detail;
158 if (tool.startsWith('mcp__')) {
159 const { server, action } = mcpName(tool);
160 return { label: `Je peux passer par ${server} (${action}) ?`, detail: clipped, full: false };
161 }
162 return { label: `Je peux utiliser ${tool} ?`, detail: clipped, full: false };
163}
164hooks/engine/layout.ts 141 lines1// Le plan du bureau : une grille de 22 × 16 cases de 16 pixels, vue de dessus (en 3/4, comme dans
2// les jeux en pixel art). Chaque métier a son coin, ses bureaux et ses chaises ; Max est au centre,
3// devant le tableau du plan ; la porte est en bas, c'est par là qu'arrive l'équipe.
4import type { Role } from './protocol';
5
6export const T = 16;
7export const COLS = 22;
8export const ROWS = 16;
9export const W = COLS * T;
10export const H = ROWS * T;
11
12export type FurnKind =
13 | 'desk' | 'chefDesk' | 'chair' | 'chefChair' | 'plantBig' | 'plantTall' | 'plantSmall' | 'rack' | 'printer'
14 | 'cooler' | 'coffee' | 'sofa' | 'tableLow' | 'shelf' | 'lamp' | 'easel' | 'bin' | 'jukebox';
15
16export interface Furn {
17 id: string;
18 kind: FurnKind;
19 /** case en haut à gauche de l'emprise au sol */
20 tx: number;
21 ty: number;
22 /** emprise au sol, en cases (bloque le passage) */
23 w: number;
24 h: number;
25 role?: Role;
26 seed?: number;
27}
28
29export interface Seat {
30 id: string;
31 role: Role;
32 /** la case où le robot s'arrête avant de s'asseoir */
33 tile: [number, number];
34 /** les pieds du robot une fois assis (en pixels) */
35 x: number;
36 y: number;
37 deskId: string;
38}
39
40export interface Zone { role: Role; label: string; x: number; y: number; w: number; h: number }
41
42/** Les coins du bureau (le tapis au sol et l'étiquette). */
43export const ZONES: Zone[] = [
44 { role: 'recherche', label: 'Recherche', x: 1, y: 3, w: 6, h: 6 },
45 { role: 'code', label: 'Atelier code', x: 15, y: 3, w: 6, h: 3 },
46 { role: 'design', label: 'Studio design', x: 15, y: 6, w: 6, h: 3 },
47 { role: 'redaction', label: 'Rédaction', x: 1, y: 10, w: 6, h: 3 },
48 { role: 'analyse', label: 'Analyse', x: 15, y: 10, w: 4, h: 3 },
49 { role: 'chef', label: 'Max, chef de projet', x: 9, y: 6, w: 4, h: 3 },
50];
51
52const furn: Furn[] = [];
53const seats: Seat[] = [];
54
55function desk(role: Role, tx: number, ty: number) {
56 const id = `desk-${role}-${tx}-${ty}`;
57 furn.push({ id, kind: 'desk', tx, ty, w: 2, h: 1, role, seed: tx * 31 + ty });
58 furn.push({ id: `chair-${id}`, kind: 'chair', tx, ty: ty - 1, w: 0, h: 0, role });
59 seats.push({ id: `seat-${id}`, role, tile: [tx, ty - 1], x: (tx + 1) * T, y: ty * T - 1, deskId: id });
60}
61
62// Recherche : deux rangées de trois bureaux (c'est le coin le plus demandé, un chercheur par sujet)
63for (const ty of [5, 8]) for (const tx of [1, 3, 5]) desk('recherche', tx, ty);
64// Atelier code
65for (const tx of [15, 17, 19]) desk('code', tx, 5);
66// Studio design
67for (const tx of [15, 17]) desk('design', tx, 8);
68// Rédaction
69for (const tx of [1, 3, 5]) desk('redaction', tx, 12);
70// Analyse
71for (const tx of [15, 17]) desk('analyse', tx, 12);
72
73// Le bureau du chef, face à toi
74furn.push({ id: 'chefDesk', kind: 'chefDesk', tx: 9, ty: 7, w: 4, h: 1, role: 'chef' });
75furn.push({ id: 'chefChair', kind: 'chefChair', tx: 10, ty: 6, w: 0, h: 0, role: 'chef' });
76export const CHEF_SEAT: Seat = { id: 'seat-chef', role: 'chef', tile: [10, 6], x: 11 * T, y: 7 * T - 1, deskId: 'chefDesk' };
77
78// Le matériel et la déco
79furn.push({ id: 'rack', kind: 'rack', tx: 14, ty: 3, w: 1, h: 1 });
80furn.push({ id: 'printer', kind: 'printer', tx: 12, ty: 4, w: 1, h: 1 });
81furn.push({ id: 'cooler', kind: 'cooler', tx: 9, ty: 4, w: 1, h: 1 });
82furn.push({ id: 'easel', kind: 'easel', tx: 19, ty: 8, w: 1, h: 1 });
83furn.push({ id: 'plant-design', kind: 'plantBig', tx: 20, ty: 8, w: 1, h: 1, seed: 3 });
84furn.push({ id: 'plant-entry-l', kind: 'plantTall', tx: 8, ty: 14, w: 1, h: 1, seed: 5 });
85furn.push({ id: 'plant-entry-r', kind: 'plantBig', tx: 13, ty: 14, w: 1, h: 1, seed: 9 });
86furn.push({ id: 'plant-analyse', kind: 'plantTall', tx: 20, ty: 11, w: 1, h: 1, seed: 11 });
87furn.push({ id: 'sofa', kind: 'sofa', tx: 2, ty: 14, w: 3, h: 1 });
88furn.push({ id: 'tableLow', kind: 'tableLow', tx: 2, ty: 13, w: 2, h: 0 });
89furn.push({ id: 'lamp', kind: 'lamp', tx: 1, ty: 14, w: 1, h: 1 });
90furn.push({ id: 'shelf', kind: 'shelf', tx: 5, ty: 14, w: 2, h: 1 });
91furn.push({ id: 'coffee', kind: 'coffee', tx: 16, ty: 14, w: 3, h: 1 });
92furn.push({ id: 'jukebox', kind: 'jukebox', tx: 14, ty: 14, w: 2, h: 1 });
93furn.push({ id: 'plant-coffee', kind: 'plantBig', tx: 19, ty: 14, w: 1, h: 1, seed: 21 });
94furn.push({ id: 'lamp-r', kind: 'lamp', tx: 20, ty: 14, w: 1, h: 1 });
95furn.push({ id: 'bin', kind: 'bin', tx: 7, ty: 12, w: 0, h: 0 });
96
97export const FURNITURE = furn;
98export const SEATS = seats;
99
100/** Les points de passage importants. Les pieds d'un robot debout sur une case : (x*16+8, y*16+13). */
101export const SPOTS = {
102 door: [10, 15] as [number, number],
103 doorOut: { x: 11 * T, y: 16 * T + 6 },
104 /** là où Max t'accueille, et où il te présente le rendu */
105 reception: [10, 12] as [number, number],
106 receptionPx: { x: 11 * T, y: 12 * T + 13 },
107 /** devant le tableau du plan */
108 board: [10, 3] as [number, number],
109 boardPx: { x: 11 * T, y: 3 * T + 13 },
110 /** devant le bureau du chef : on y rend son rapport */
111 deliver: [[10, 8], [11, 8], [9, 8], [12, 8], [10, 9], [11, 9]] as [number, number][],
112 /** pour ceux qui n'ont pas de bureau libre */
113 overflow: [[7, 4], [8, 7], [13, 6], [7, 10], [14, 10], [8, 11], [13, 11], [7, 13]] as [number, number][],
114};
115
116/** Le tableau du plan, accroché au mur du fond (en pixels). */
117export const BOARD = { x: 128, y: 7, w: 96, h: 35 };
118export const WINDOWS = [{ x: 22, y: 8, w: 66, h: 32 }, { x: 264, y: 8, w: 66, h: 32 }];
119export const NEON = { x: 97, y: 11 };
120export const CLOCK = { x: 232, y: 13 };
121export const POSTER = { x: 252, y: 12, w: 10, h: 14 };
122export const DOOR = { x: 160, y: 228, w: 32, h: 28 };
123
124/** La grille des cases où l'on peut marcher. */
125export function walkGrid(): boolean[][] {
126 const g: boolean[][] = [];
127 for (let y = 0; y < ROWS; y++) {
128 g.push([]);
129 for (let x = 0; x < COLS; x++) g[y].push(x >= 1 && x <= 20 && y >= 3 && y <= 14);
130 }
131 // la porte
132 g[15][10] = true;
133 g[15][11] = true;
134 for (const f of FURNITURE) {
135 for (let y = f.ty; y < f.ty + f.h; y++) for (let x = f.tx; x < f.tx + f.w; x++) if (g[y]?.[x] !== undefined) g[y][x] = false;
136 }
137 return g;
138}
139
140export const tileFeet = (t: [number, number]) => ({ x: t[0] * T + 8, y: t[1] * T + 13 });
141hooks/engine/pixels.ts 173 lines1// La boîte à outils du pixel art : des toiles minuscules dessinées pixel par pixel, puis agrandies
2// sans flou. Tout le bureau (meubles, robots, icônes) est dessiné ici, en code : aucune image externe.
3
4import { Canvas, type Ctx } from '../canvas';
5export type { Canvas };
6export type G = Ctx;
7
8export function makeCanvas(w: number, h: number): Canvas {
9 return new Canvas(Math.max(1, Math.ceil(w)), Math.max(1, Math.ceil(h)));
10}
11
12export function g2d(c: Canvas): G {
13 const g = c.getContext('2d', { willReadFrequently: false })!;
14 g.imageSmoothingEnabled = false;
15 return g;
16}
17
18export function rect(g: G, x: number, y: number, w: number, h: number, color: string) {
19 g.fillStyle = color;
20 g.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
21}
22
23export function px(g: G, x: number, y: number, color: string) {
24 g.fillStyle = color;
25 g.fillRect(Math.round(x), Math.round(y), 1, 1);
26}
27
28/** Une ligne horizontale ou verticale d'un pixel. */
29export function hline(g: G, x: number, y: number, w: number, color: string) { rect(g, x, y, w, 1, color); }
30export function vline(g: G, x: number, y: number, h: number, color: string) { rect(g, x, y, 1, h, color); }
31
32/** Ajoute un contour d'un pixel autour de tout ce qui est opaque (la silhouette « pixel art »). */
33export function outline(c: Canvas, color: string, opts: { diagonal?: boolean; skipBottom?: boolean } = {}) {
34 const g = c.getContext('2d')!;
35 const w = c.width, h = c.height;
36 const img = g.getImageData(0, 0, w, h);
37 const d = img.data;
38 const solid = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && d[(y * w + x) * 4 + 3] > 40;
39 const out: number[] = [];
40 for (let y = 0; y < h; y++) {
41 for (let x = 0; x < w; x++) {
42 if (solid(x, y)) continue;
43 let hit = solid(x - 1, y) || solid(x + 1, y) || solid(x, y - 1) || (!opts.skipBottom && solid(x, y + 1));
44 if (!hit && opts.diagonal) hit = solid(x - 1, y - 1) || solid(x + 1, y - 1) || solid(x - 1, y + 1) || solid(x + 1, y + 1);
45 if (hit) out.push(x, y);
46 }
47 }
48 g.fillStyle = color;
49 for (let i = 0; i < out.length; i += 2) g.fillRect(out[i], out[i + 1], 1, 1);
50 return c;
51}
52
53/**
54 * Un sprite décrit en texte : une ligne par rangée de pixels, un caractère par pixel.
55 * « . » ou « » = transparent ; les autres caractères renvoient à la palette.
56 */
57export function fromRows(rows: string[], pal: Record<string, string>): Canvas {
58 const h = rows.length;
59 const w = Math.max(...rows.map((r) => r.length));
60 const c = makeCanvas(w, h);
61 const g = g2d(c);
62 for (let y = 0; y < h; y++) {
63 const r = rows[y];
64 for (let x = 0; x < r.length; x++) {
65 const ch = r[x];
66 if (ch === '.' || ch === ' ') continue;
67 const col = pal[ch];
68 if (col) px(g, x, y, col);
69 }
70 }
71 return c;
72}
73
74export function mirror(src: Canvas): Canvas {
75 const c = makeCanvas(src.width, src.height);
76 const g = g2d(c);
77 g.translate(src.width, 0);
78 g.scale(-1, 1);
79 g.drawImage(src, 0, 0);
80 return c;
81}
82
83// ── Couleurs ──
84function parse(hex: string): [number, number, number] {
85 const h = hex.replace('#', '');
86 const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
87 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
88}
89const toHex = (r: number, g: number, b: number) => '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
90
91export function mix(a: string, b: string, t: number): string {
92 const [r1, g1, b1] = parse(a);
93 const [r2, g2, b2] = parse(b);
94 return toHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
95}
96export const lighten = (c: string, t: number) => mix(c, '#ffffff', t);
97export const darken = (c: string, t: number) => mix(c, '#100c1c', t);
98export function rgba(hex: string, a: number) {
99 const [r, g, b] = parse(hex);
100 return `rgba(${r},${g},${b},${a})`;
101}
102
103/** Un petit générateur pseudo-aléatoire, pour que le grain du bois soit toujours le même. */
104export function rng(seed: number) {
105 let s = seed >>> 0 || 1;
106 return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
107}
108
109// ── La palette du bureau (le soir, chez VibesMoney : noir, bois chaud, un seul orange) ──
110export const P = {
111 ink: '#15121d',
112 inkSoft: '#231e2e',
113 // murs
114 wallTop: '#14111c',
115 wallFace: '#262236',
116 wallFace2: '#2c2740',
117 wallLine: '#1f1b2d',
118 wallHi: '#37314d',
119 baseboard: '#191523',
120 // sol : parquet sombre et chaud
121 floor1: '#3f3138',
122 floor2: '#46363d',
123 floor3: '#3a2d34',
124 floorSeam: '#30252c',
125 floorHi: '#504048',
126 // bois clair des bureaux
127 wood: '#c08a57',
128 woodHi: '#d9a46c',
129 woodLight: '#e7bb84',
130 woodFront: '#9b6a3e',
131 woodDark: '#77502e',
132 woodLeg: '#563821',
133 // bois sombre (le bureau du chef)
134 ebony: '#2a2530',
135 ebonyHi: '#3b3443',
136 ebonyFront: '#1e1a24',
137 // métal et matériel
138 metal: '#9a9fb5',
139 metalHi: '#cfd2e0',
140 metalDark: '#62667c',
141 tech: '#262431',
142 techMid: '#363443',
143 techHi: '#4d4a5e',
144 screenOff: '#1a1f2e',
145 // l'orange VibesMoney
146 orange: '#ff8a3d',
147 orangeHi: '#ffb27a',
148 orangeLight: '#ffd2a8',
149 orangeDark: '#d9622a',
150 orangeDeep: '#9e4019',
151 // verdure
152 leafDeep: '#1f4733',
153 leafDark: '#285e40',
154 leaf: '#348050',
155 leafHi: '#52a868',
156 leafLight: '#86cf7c',
157 pot: '#b8643f',
158 potHi: '#d9875a',
159 potDark: '#8a462a',
160 potWhite: '#e9e5ef',
161 potWhiteShade: '#bdb7c9',
162 // papier
163 paper: '#f6f2e9',
164 paperShade: '#d8d1c3',
165 paperLine: '#a19a8c',
166 // tissu des fauteuils
167 fabric: '#2e2a3d',
168 fabricHi: '#403a54',
169 fabricDark: '#211d2c',
170 white: '#f7f5fc',
171 shadow: 'rgba(8,6,16,0.38)',
172};
173hooks/engine/protocol.ts 128 lines1// Le contrat entre le bureau (navigateur) et le pont (serveur Node qui pilote Claude Code).
2// Tout passe par une seule connexion WebSocket, en JSON. Syntaxe « effaçable » uniquement :
3// ce fichier est lu tel quel par Node (sans compilation) et par Vite.
4
5/** Les métiers du bureau. « chef » = l'agent principal, celui à qui tu parles. */
6export type Role = 'chef' | 'recherche' | 'code' | 'design' | 'redaction' | 'analyse' | 'renfort';
7
8/** Ce que fait un agent à un instant : sert à l'animation, à l'icône et à la bulle. */
9export type ActKind = 'search' | 'web' | 'read' | 'write' | 'edit' | 'bash' | 'plan' | 'skill' | 'mcp' | 'other';
10
11export interface PlanItem { text: string; status: 'pending' | 'in_progress' | 'completed' }
12
13/** Un fichier produit par l'équipe, dans le dossier de la mission. */
14export interface FileInfo { path: string; name: string; ext: string; size: number; at: number; by: string }
15
16export interface MissionStats { agents: number; searches: number; pages: number; reads: number; writes: number; commands: number }
17
18export type MissionStatus = 'working' | 'done' | 'stopped' | 'error';
19
20export interface MissionInfo {
21 id: string;
22 title: string;
23 prompt: string;
24 /** le dossier de la mission, sur ton ordinateur */
25 folder: string;
26 createdAt: number;
27 endedAt?: number;
28 /** temps de travail cumulé (toutes les reprises comprises) */
29 workMs: number;
30 status: MissionStatus;
31 sessionId?: string;
32 model?: string;
33 stats: MissionStats;
34 files: FileInfo[];
35 /** le dernier message du chef, une fois la mission rendue */
36 final?: string;
37}
38
39/**
40 * Ce qui se passe dans le bureau. `who` vaut « chef » ou l'identifiant d'un agent de l'équipe
41 * (l'identifiant de l'appel Agent qui l'a fait venir).
42 */
43export type OfficeEvent =
44 | { k: 'start'; text: string }
45 | { k: 'init'; model: string; auth: string }
46 | { k: 'think'; who: string }
47 | { k: 'typing'; who: string; text: string }
48 | { k: 'say'; who: string; text: string }
49 | { k: 'plan'; items: PlanItem[] }
50 | { k: 'hire'; id: string; role: Role; type: string; name: string; task: string; prompt: string }
51 | { k: 'act'; who: string; id: string; kind: ActKind; tool: string; label: string; detail: string }
52 | { k: 'act_done'; who: string; id: string; ok: boolean }
53 /** le rapport qu'un agent rend au chef */
54 | { k: 'report'; who: string; text: string }
55 /** un agent a fini son travail et quitte son poste */
56 | { k: 'leave'; who: string; ok: boolean; tokens: number; tools: number; ms: number }
57 | { k: 'file'; file: FileInfo; isNew: boolean }
58 /** full = la bulle montre l'action en entier (sinon, on répond dans le panneau, où tout est affiché) */
59 | { k: 'ask'; who: string; requestId: string; tool: string; label: string; detail: string; full: boolean }
60 | { k: 'ask_done'; requestId: string; allow: boolean; cancelled?: boolean }
61 | { k: 'end'; ok: boolean; text: string; ms: number; stopped?: boolean }
62 | { k: 'error'; message: string };
63
64/** Un événement daté, tel qu'il est enregistré. */
65export interface Logged { at: number; ev: OfficeEvent }
66
67export interface ClaudeInfo { found: boolean; version?: string; path?: string }
68
69/** Une version enregistrée d'un fichier rendu (à chaque modification par l'équipe). */
70export interface VersionInfo { id: string; at: number; size: number; by: string }
71
72/** Un passage d'un agent au bureau (pour l'historique de l'équipe). */
73export interface TeamEntry {
74 missionId: string;
75 missionTitle: string;
76 at: number;
77 id: string;
78 name: string;
79 role: Role;
80 task: string;
81 status: 'working' | 'done' | 'failed';
82 tools: number;
83 tokens: number;
84 ms: number;
85 report: string;
86}
87
88export interface Settings {
89 /** le cerveau du chef : '' = le modèle par défaut de Claude Code */
90 chefModel: string;
91 /** le cerveau de l'équipe : sonnet, haiku, opus, fable ou inherit (le même que le chef) */
92 teamModel: string;
93 /** auto : l'équipe décide seule et ne demande que pour les gestes risqués ; ask : elle demande avant chaque commande */
94 permission: 'auto' | 'ask';
95}
96
97export interface Quota { fiveHour: number; resetsAt: number | null }
98
99// ── Navigateur → serveur ──
100export type ClientMsg =
101 | { t: 'start'; prompt: string }
102 | { t: 'say'; text: string }
103 | { t: 'stop' }
104 | { t: 'answer'; requestId: string; allow: boolean }
105 | { t: 'new' }
106 | { t: 'open'; missionId: string; file?: string }
107 /** ouvre le dossier du bureau (ou son règlement) */
108 | { t: 'open_root'; rules?: boolean }
109 | { t: 'read'; missionId: string; file: string; version?: string }
110 | { t: 'versions'; missionId: string; file: string }
111 | { t: 'team_history' }
112 | { t: 'replay'; missionId: string }
113 | { t: 'settings'; settings: Partial<Settings> };
114
115// ── Serveur → navigateur ──
116export type ServerMsg =
117 | { t: 'hello'; claude: ClaudeInfo; dir: string; settings: Settings; quota: Quota | null; missions: MissionInfo[]; current: { mission: MissionInfo; log: Logged[] } | null; build?: string }
118 | { t: 'mission'; mission: MissionInfo }
119 | { t: 'ev'; missionId: string; at: number; ev: OfficeEvent }
120 | { t: 'closed'; missionId: string }
121 | { t: 'replay'; mission: MissionInfo; log: Logged[] }
122 | { t: 'file_text'; missionId: string; file: string; text: string; truncated: boolean; version?: string }
123 | { t: 'versions'; missionId: string; file: string; list: VersionInfo[] }
124 | { t: 'team_history'; entries: TeamEntry[] }
125 | { t: 'quota'; quota: Quota }
126 | { t: 'settings'; settings: Settings }
127 | { t: 'toast'; kind: 'info' | 'error'; text: string };
128hooks/engine/render.ts 411 lines1// Le dessin d'une image du bureau : la pièce, puis les meubles et les robots rangés du fond vers
2// l'avant (ceux du fond passent derrière), les effets, et enfin la lumière (le soir et la nuit,
3// la pièce s'assombrit et ce sont les écrans, les lampes et le néon qui éclairent).
4// Tout se dessine sur une toute petite image (352 × 256 pixels), agrandie sans flou : ça ne coûte presque rien.
5import type { ActKind } from './protocol';
6import { furniturePlacement } from './furniture';
7import { BOARD, CLOCK, FURNITURE, H, NEON, T, W, WINDOWS, type Furn } from './layout';
8import { g2d, makeCanvas, P, rgba, rng, type G } from './pixels';
9import { ANCHOR_X, ANCHOR_Y, robotSprite } from './robots';
10import { drawRoom, neon, signs, type TimeOfDay } from './room';
11import { ROLES } from './activity';
12import type { Actor, World } from './world';
13import type { Canvas } from '../canvas';
14
15interface Placed { f: Furn; c: Canvas; x: number; y: number; sortY: number }
16
17const SCREEN: Record<ActKind, string> = {
18 search: '#7fd6ff', web: '#7fffc4', read: '#cfe3ff', write: '#fff0d4', edit: '#fff0d4', bash: '#8affb0',
19 plan: '#ffd27a', skill: '#ffe08a', mcp: '#c9b4ff', other: '#dfe6ff',
20};
21
22const AMBIENT: Record<TimeOfDay, string> = { day: '#ffffff', evening: '#a596b2', night: '#3b3961' };
23
24/** Les couleurs du néon (clique dessus pour passer à la suivante). */
25export const NEON_COLORS: [string, string][] = [
26 [P.orange, P.orangeLight], ['#ff5fa2', '#ffc2dd'], ['#3fd8ff', '#c8f4ff'], ['#3ddc97', '#c6f7de'], ['#a98bff', '#e2d8ff'],
27];
28const DISCO = ['#ff5fa2', '#3fd8ff', '#ffd166', '#3ddc97', '#a98bff', '#ff8a3d'];
29
30export class Renderer {
31 private bg: Canvas | null = null;
32 private bgTod: TimeOfDay | null = null;
33 private light = makeCanvas(W, H);
34 private lg = g2d(this.light);
35 private placed: Placed[] = FURNITURE.map((f) => { const p = furniturePlacement(f); return { f, c: p.s.c, x: p.x, y: p.y, sortY: p.sortY }; });
36 private stars: { x: number; y: number; p: number }[] = [];
37
38 constructor() {
39 const r = rng(7);
40 for (const w of WINDOWS) for (let i = 0; i < 9; i++) this.stars.push({ x: w.x + 2 + Math.floor(r() * (w.w - 4)), y: w.y + 1 + Math.floor(r() * 12), p: r() * 6 });
41 }
42
43 draw(g: G, world: World, tod: TimeOfDay, now: Date) {
44 if (!this.bg || this.bgTod !== tod) { this.bg = drawRoom(tod); this.bgTod = tod; }
45 const t = world.time;
46 g.globalCompositeOperation = 'source-over';
47 g.globalAlpha = 1;
48 g.drawImage(this.bg, 0, 0);
49 if (tod === 'night') this.twinkle(g, t);
50 if (tod === 'day') this.clouds(g, t);
51 this.clockHands(g, now);
52 this.board(g, world, t);
53 // le néon (sa couleur change quand on clique dessus ; il grésille de temps en temps)
54 const [tube, core] = NEON_COLORS[world.neonIndex];
55 if (Math.sin(t * 0.9) <= 0.985) neon(g, tube, core);
56 // les écriteaux : le nom du coin, et combien d'agents y travaillent en ce moment
57 const busy = new Map<string, number>();
58 for (const a of world.actors.values()) if (a.seated && a.role !== 'chef') busy.set(a.role, (busy.get(a.role) ?? 0) + 1);
59 signs(g, (role) => busy.get(role) ?? 0);
60
61 // qui est assis à quel bureau
62 const atDesk = new Map<string, Actor>();
63 for (const a of world.actors.values()) if (a.seated && a.seat) atDesk.set(a.seat.deskId, a);
64
65 // du fond vers l'avant
66 const items: { y: number; k: number; draw: () => void }[] = [];
67 for (const p of this.placed) {
68 const shake = (world.wiggles.get(p.f.id) ?? 0) > t ? Math.round(Math.sin(t * 42)) : 0;
69 items.push({ y: p.sortY, k: 0, draw: () => { g.drawImage(p.c, p.x + shake, p.y); this.furnitureLive(g, p, world, atDesk.get(p.f.id) ?? null, t); } });
70 }
71 for (const a of world.actors.values()) {
72 items.push({ y: a.seated && a.seat ? a.seat.y : a.y, k: 1, draw: () => this.actor(g, a, world) });
73 }
74 items.push({ y: world.cat.sortY, k: 2, draw: () => world.cat.draw(g) });
75 items.sort((a, b) => a.y - b.y || a.k - b.k);
76 for (const it of items) it.draw();
77
78 if (t < world.discoUntil) this.discoBall(g, t);
79 world.fx.draw(g);
80 if (tod === 'day') this.sunbeams(g);
81 else this.lighting(g, world, tod, atDesk, t);
82 if (t < world.discoUntil) this.discoSpots(g, t, world.discoUntil - t);
83 }
84
85 private actor(g: G, a: Actor, world: World) {
86 const seated = a.seated && !!a.seat;
87 const x = Math.round(a.x);
88 // sur la musique, ceux qui ne marchent pas hochent la tête en rythme
89 const y = Math.round(a.y) - (world.musicOn && world.time < world.bobUntil && !a.moving ? 1 : 0);
90 g.globalAlpha = a.alpha;
91 if (!seated) {
92 g.fillStyle = 'rgba(8,6,16,0.32)';
93 g.fillRect(x - 5, y - 1, 10, 2);
94 g.fillRect(x - 4, y - 2, 8, 1);
95 }
96 const sprite = robotSprite(a.role, a.pose, seated ? 'down' : a.dir, a.frame, a.eyes);
97 g.drawImage(sprite, x - ANCHOR_X, y - ANCHOR_Y);
98 g.globalAlpha = 1;
99 }
100
101 /** Les parties vivantes des meubles : écrans allumés, voyants, imprimante, rapports sur le bureau du chef. */
102 private furnitureLive(g: G, p: Placed, world: World, who: Actor | null, t: number) {
103 const f = p.f;
104 if (f.kind === 'desk' && who) {
105 const on = t - who.actAt < 9;
106 const col = on && who.act ? SCREEN[who.act] : '#5b6b8c';
107 switch (f.role) {
108 case 'code': {
109 // le reflet de l'écran sur les bords des moniteurs
110 for (const mx of [1, 22]) {
111 g.fillStyle = col;
112 g.fillRect(p.x + mx, p.y + 1, 9, 1);
113 }
114 break;
115 }
116 case 'design': {
117 const cols = ['#ff6fa8', '#ffd166', '#4fb3ff', '#a98bff', P.orange];
118 for (let i = 0; i < 11; i++) {
119 g.fillStyle = cols[(i + Math.floor(t * (on ? 6 : 1))) % cols.length];
120 g.fillRect(p.x + 10 + i, p.y + 12 + (i % 3 === 0 ? 1 : 0), 1, i % 2 ? 2 : 3);
121 }
122 break;
123 }
124 case 'analyse': {
125 g.fillStyle = '#141a2c';
126 g.fillRect(p.x + 23, p.y + 2, 7, 6);
127 const bars = ['#ffd166', P.orange, '#4fb3ff', '#3ddc97'];
128 for (let i = 0; i < 4; i++) {
129 const h = on ? 2 + Math.floor((Math.sin(t * 3 + i * 1.7) + 1) * 1.6) : 2 + (i % 3);
130 g.fillStyle = bars[i];
131 g.fillRect(p.x + 24 + i + (i > 1 ? 1 : 0), p.y + 7 - h, 1, h);
132 }
133 this.logo(g, p.x + 14, p.y + 10, on, t);
134 break;
135 }
136 default:
137 this.logo(g, p.x + 15, p.y + 10, on, t);
138 }
139 }
140 if (f.kind === 'chefDesk') {
141 if (who) this.logo(g, p.x + 31, p.y + 10, true, t);
142 // les rapports rendus, empilés dans la corbeille
143 const n = Math.min(world.inbox, 7);
144 for (let i = 0; i < n; i++) {
145 g.fillStyle = P.ink;
146 g.fillRect(p.x + 46 + (i % 2), p.y + 10 - i, 12, 3);
147 g.fillStyle = i % 2 ? P.paperShade : P.paper;
148 g.fillRect(p.x + 47 + (i % 2), p.y + 10 - i, 10, 2);
149 }
150 }
151 if (f.kind === 'printer' && t < world.printUntil) {
152 const prog = 1 - (world.printUntil - t) / 1.8;
153 const h = Math.max(1, Math.round(Math.min(1, prog * 1.6) * 6));
154 g.fillStyle = P.paper;
155 g.fillRect(p.x + 4, p.y + 8, 8, h);
156 g.fillStyle = P.paperLine;
157 if (h > 3) g.fillRect(p.x + 5, p.y + 10, 5, 1);
158 g.fillStyle = Math.floor(t * 8) % 2 ? '#3ddc97' : '#1f6b45';
159 g.fillRect(p.x + 13, p.y + 9, 1, 1);
160 }
161 if (f.kind === 'rack') {
162 for (let i = 0; i < 6; i++) {
163 const blink = Math.sin(t * (2 + (i % 3)) + i * 2.3) > -0.2;
164 g.fillStyle = blink ? (i % 4 === 0 ? '#ff8a3d' : i % 3 === 0 ? '#4fb3ff' : '#3ddc97') : '#1f3a2e';
165 g.fillRect(p.x + 3, p.y + 7 + i * 4, 1, 1);
166 g.fillStyle = Math.sin(t * 5 + i) > 0.6 ? '#3ddc97' : '#1f3a2e';
167 g.fillRect(p.x + 5, p.y + 7 + i * 4, 1, 1);
168 }
169 }
170 if (f.kind === 'jukebox') this.jukeboxLights(g, p, world, t);
171 if (f.kind === 'coffee') {
172 g.fillStyle = Math.floor(t * 1.5) % 2 ? P.orange : P.orangeDark;
173 g.fillRect(p.x + 14, p.y + 2, 1, 1);
174 }
175 }
176
177 /** Les néons de l'arche du jukebox : éteints au repos, ils défilent et pulsent sur la musique. */
178 private jukeboxLights(g: G, p: Placed, world: World, t: number) {
179 const on = world.musicOn;
180 const arcs: [number, string, string][] = [[11, '#ff5fa2', '#5a2a3e'], [9, P.orange, '#5a3a24'], [7, '#3fd8ff', '#1f3e4a']];
181 arcs.forEach(([r, col, dim], i) => {
182 for (let a = 0; a <= 16; a++) {
183 const ang = Math.PI + (a / 16) * Math.PI;
184 const x = p.x + 16 + Math.round(Math.cos(ang) * r);
185 const y = p.y + 15 + Math.round(Math.sin(ang) * r);
186 const lit = on && (Math.floor(t * 8) + a + i * 3) % 6 !== 0;
187 g.fillStyle = lit ? col : dim;
188 g.fillRect(x, y, 1, 1);
189 }
190 });
191 if (on) {
192 // le disque tourne
193 const a = t * 8;
194 g.fillStyle = P.orangeLight;
195 g.fillRect(p.x + 16 + Math.round(Math.cos(a) * 3), p.y + 20 + Math.round(Math.sin(a) * 1.5), 1, 1);
196 // les barres du vumètre
197 for (let i = 0; i < 5; i++) {
198 const h = 1 + Math.round((world.musicLevel * 4) * (0.6 + 0.4 * Math.sin(t * 9 + i * 1.7)));
199 g.fillStyle = i % 2 ? '#ff5fa2' : P.orange;
200 g.fillRect(p.x + 7 + i * 4, p.y + 32 - h, 2, h);
201 }
202 }
203 }
204
205 /** La boule à facettes du mode soirée. */
206 private discoBall(g: G, t: number) {
207 const cx = 176, cy = 30;
208 g.fillStyle = '#8a8ea2';
209 g.fillRect(cx, 0, 1, cy - 5);
210 for (let y = -5; y <= 5; y++) {
211 const w = Math.round(Math.sqrt(25 - y * y));
212 for (let x = -w; x <= w; x++) {
213 const k = (x + y + Math.floor(t * 10)) & 3;
214 g.fillStyle = k === 0 ? '#ffffff' : k === 1 ? '#c9cbd6' : k === 2 ? '#8a8ea2' : DISCO[(x * 7 + y * 3 + Math.floor(t * 6)) % DISCO.length];
215 g.fillRect(cx + x, cy + y, 1, 1);
216 }
217 }
218 }
219
220 /** Les taches de lumière colorées qui tournent sur le sol pendant le mode soirée. */
221 private discoSpots(g: G, t: number, left: number) {
222 const fade = Math.min(1, left / 1.5);
223 g.globalCompositeOperation = 'lighter';
224 for (let i = 0; i < 9; i++) {
225 const a = t * (0.7 + i * 0.13) + i * 2.1;
226 const x = 176 + Math.cos(a) * (60 + i * 12);
227 const y = 140 + Math.sin(a * 1.3) * (40 + i * 4);
228 const grd = g.createRadialGradient(x, y, 0, x, y, 14);
229 grd.addColorStop(0, rgba(DISCO[i % DISCO.length], 0.32 * fade));
230 grd.addColorStop(1, rgba(DISCO[i % DISCO.length], 0));
231 g.fillStyle = grd;
232 g.fillRect(x - 14, y - 14, 28, 28);
233 }
234 g.globalCompositeOperation = 'source-over';
235 }
236
237 /** Deux petits nuages qui passent derrière les fenêtres, en journée. */
238 private clouds(g: G, t: number) {
239 for (const w of WINDOWS) {
240 g.save();
241 g.beginPath();
242 g.rect(w.x, w.y, w.w, 11);
243 g.clip();
244 for (let i = 0; i < 2; i++) {
245 const span = w.w + 30;
246 const x = w.x - 15 + ((t * (3 + i * 2) + i * 40 + w.x) % span);
247 const y = w.y + 3 + i * 4;
248 g.fillStyle = 'rgba(255,255,255,0.85)';
249 g.fillRect(Math.round(x), y, 9, 2);
250 g.fillRect(Math.round(x) + 2, y - 1, 5, 1);
251 }
252 g.restore();
253 }
254 }
255
256 /** Le petit cube orange au dos de l'ordinateur, qui s'allume quand l'agent travaille. */
257 private logo(g: G, x: number, y: number, on: boolean, t: number) {
258 g.fillStyle = on ? (Math.sin(t * 6) > -0.6 ? P.orange : P.orangeHi) : '#8a5a3e';
259 g.fillRect(x, y, 2, 2);
260 }
261
262 private clockHands(g: G, now: Date) {
263 const cx = CLOCK.x + 7, cy = CLOCK.y + 7;
264 const m = now.getMinutes() + now.getSeconds() / 60;
265 const h = (now.getHours() % 12) + m / 60;
266 const hand = (angle: number, len: number, color: string) => {
267 g.fillStyle = color;
268 for (let i = 0; i <= len; i++) g.fillRect(Math.round(cx + Math.sin(angle) * i), Math.round(cy - Math.cos(angle) * i), 1, 1);
269 };
270 hand((h / 12) * Math.PI * 2, 3, '#2b2836');
271 hand((m / 60) * Math.PI * 2, 4, '#2b2836');
272 g.fillStyle = P.orange;
273 g.fillRect(cx, cy, 1, 1);
274 }
275
276 private twinkle(g: G, t: number) {
277 for (const s of this.stars) {
278 const a = (Math.sin(t * 1.6 + s.p) + 1) / 2;
279 if (a < 0.35) continue;
280 g.fillStyle = `rgba(255,255,255,${(a * 0.9).toFixed(2)})`;
281 g.fillRect(s.x, s.y, 1, 1);
282 }
283 }
284
285 /** En journée : de grands rayons de soleil qui tombent des fenêtres. */
286 private sunbeams(g: G) {
287 g.save();
288 g.beginPath();
289 g.rect(T, 3 * T, W - 2 * T, H - 4 * T);
290 g.clip();
291 g.globalCompositeOperation = 'lighter';
292 for (const w of WINDOWS) {
293 g.fillStyle = 'rgba(255,236,190,0.06)';
294 g.beginPath();
295 g.moveTo(w.x + 4, 48);
296 g.lineTo(w.x + w.w - 4, 48);
297 g.lineTo(w.x + w.w + 26, 120);
298 g.lineTo(w.x + 30, 120);
299 g.closePath();
300 g.fill();
301 }
302 g.globalCompositeOperation = 'source-over';
303 g.restore();
304 }
305
306 private lighting(g: G, world: World, tod: TimeOfDay, atDesk: Map<string, Actor>, t: number) {
307 const l = this.lg;
308 l.globalCompositeOperation = 'source-over';
309 l.fillStyle = AMBIENT[tod];
310 l.fillRect(0, 0, W, H);
311 l.globalCompositeOperation = 'lighter';
312 const night = tod === 'night';
313 const add = (x: number, y: number, r: number, color: string, a: number) => {
314 const grd = l.createRadialGradient(x, y, 0, x, y, r);
315 grd.addColorStop(0, rgba(color, a));
316 grd.addColorStop(0.55, rgba(color, a * 0.45));
317 grd.addColorStop(1, rgba(color, 0));
318 l.fillStyle = grd;
319 l.fillRect(x - r, y - r, r * 2, r * 2);
320 };
321 // le néon (il grésille de temps en temps)
322 const flick = Math.sin(t * 0.9) > 0.985 ? 0.25 : 1;
323 const neonCol = NEON_COLORS[world.neonIndex][0];
324 add(NEON.x + 12, NEON.y + 12, 52, neonCol, 0.75 * flick);
325 // les fenêtres : coucher de soleil ou clair de lune
326 for (const w of WINDOWS) add(w.x + w.w / 2, w.y + w.h + 8, 58, night ? '#7080d0' : '#ff9a6a', night ? 0.42 : 0.55);
327 // le tableau du plan, éclairé
328 add(176, 28, 52, '#fff6e8', night ? 0.28 : 0.22);
329 // la lampe du chef et le liseré orange de son bureau
330 add(9 * T + 12, 7 * T - 6, 40, '#ffd9a0', 0.8);
331 add(11 * T, 7 * T + 14, 44, P.orange, 0.32);
332 // les lampadaires, le café, le serveur
333 if (!world.lampsOff.has('lamp')) add(1 * T + 8, 14 * T - 12, 52, '#ffcf8a', 0.85);
334 if (!world.lampsOff.has('lamp-r')) add(20 * T + 8, 14 * T - 12, 52, '#ffcf8a', 0.85);
335 // le jukebox, qui pulse sur la musique
336 if (world.musicOn) add(15 * T, 13 * T + 4, 30 + world.musicLevel * 18, ['#ff5fa2', P.orange, '#3fd8ff'][Math.floor(t * 1.5) % 3], 0.35 + world.musicLevel * 0.45);
337 // les yeux du chat brillent dans le noir
338 add(world.cat.x + (world.cat.right ? 4 : -4), world.cat.y - 8, 4, P.orange, 0.5);
339 add(16 * T + 14, 14 * T - 8, 14, P.orange, 0.5);
340 add(14 * T + 4, 3 * T + 6, 18, '#5fd0ff', 0.35);
341 add(11 * T, 15 * T + 8, 30, '#ffe2b8', 0.4);
342 if (t < world.printUntil) add(12 * T + 8, 4 * T - 2, 16, '#9effa8', 0.6);
343 // les écrans éclairent les visages
344 for (const a of atDesk.values()) {
345 const on = t - a.actAt < 9;
346 const col = on && a.act ? SCREEN[a.act] : '#8fa4d0';
347 const sx = a.seat!.x, sy = a.seat!.y - 12;
348 add(sx, sy, on ? 26 : 18, a.role === 'design' && on ? '#ff9fd0' : col, on ? 0.75 : 0.35);
349 }
350 // un projecteur doux suit Max (le patron reste toujours bien visible)
351 const chef = world.chef;
352 add(chef.x, (chef.seated && chef.seat ? chef.seat.y : chef.y) - 8, 26, '#ffe2c4', 0.42);
353 // les yeux des robots
354 for (const a of world.actors.values()) {
355 if (a.alpha < 0.3) continue;
356 add(a.x, (a.seated && a.seat ? a.seat.y : a.y) - 13, 7, P.orange, 0.55);
357 }
358 g.globalCompositeOperation = 'multiply';
359 g.drawImage(this.light, 0, 0);
360 // un léger halo sur ce qui brille
361 g.globalCompositeOperation = 'lighter';
362 const glow = (x: number, y: number, r: number, color: string, a: number) => {
363 const grd = g.createRadialGradient(x, y, 0, x, y, r);
364 grd.addColorStop(0, rgba(color, a));
365 grd.addColorStop(1, rgba(color, 0));
366 g.fillStyle = grd;
367 g.fillRect(x - r, y - r, r * 2, r * 2);
368 };
369 glow(NEON.x + 12, NEON.y + 12, 20, neonCol, 0.32 * flick);
370 for (const a of world.actors.values()) if (a.alpha > 0.3) glow(a.x, (a.seated && a.seat ? a.seat.y : a.y) - 13, 4, P.orange, 0.35);
371 for (const a of atDesk.values()) if (t - a.actAt < 9) glow(a.seat!.x, a.seat!.y - 6, 8, a.act ? SCREEN[a.act] : '#ffffff', 0.18);
372 g.globalCompositeOperation = 'source-over';
373 }
374
375 /** Le plan, au feutre sur le tableau : une ligne par étape, cochée quand elle est faite. */
376 private board(g: G, world: World, t: number) {
377 const items = world.plan.slice(0, 5);
378 const x0 = BOARD.x + 4;
379 items.forEach((it, i) => {
380 const y = BOARD.y + 12 + i * 4;
381 const done = it.status === 'completed';
382 const doing = it.status === 'in_progress';
383 // la case
384 g.fillStyle = done ? '#2fbf71' : doing ? (Math.floor(t * 3) % 2 ? P.orange : P.orangeDark) : '#8a8ea2';
385 g.fillRect(x0, y, 3, 3);
386 if (!done) { g.fillStyle = '#f4f5f9'; g.fillRect(x0 + 1, y + 1, 1, 1); }
387 // la ligne écrite à la main
388 const len = Math.max(18, Math.min(78, Math.round(it.text.length * 1.5)));
389 const ink = done ? '#b4b7c6' : doing ? '#3a2f44' : '#2b2836';
390 g.fillStyle = ink;
391 for (let k = 0; k < len; k++) {
392 if ((k * 7 + i * 3) % 11 === 0) continue; // des petits blancs entre les « mots »
393 g.fillRect(x0 + 6 + k, y + 1 + (Math.sin((k + i * 5) * 0.9) > 0.6 ? -1 : 0), 1, 1);
394 }
395 if (done) { g.fillStyle = '#2fbf71'; g.fillRect(x0 + 5, y + 1, len + 2, 1); }
396 });
397 if (world.plan.length > 5) { g.fillStyle = '#8a8ea2'; g.fillRect(x0 + 6, BOARD.y + 32, 1, 1); g.fillRect(x0 + 8, BOARD.y + 32, 1, 1); g.fillRect(x0 + 10, BOARD.y + 32, 1, 1); }
398 // qui fait quoi : une petite tête de robot par agent, en bas à droite du tableau, cochée quand il a rendu son travail
399 const team = world.assignments.slice(-12);
400 team.forEach((a, i) => {
401 const x = BOARD.x + BOARD.w - 8 - (team.length - 1 - i) * 6;
402 const y = BOARD.y + 27;
403 g.fillStyle = a.status === 'done' ? '#2fbf71' : a.status === 'failed' ? '#e5484d' : ROLES[a.role].color;
404 g.fillRect(x, y, 4, 4);
405 g.fillStyle = '#15121d';
406 g.fillRect(x + 1, y + 1, 2, 1);
407 if (a.status === 'working' && Math.floor(t * 2 + i) % 2) { g.fillStyle = '#ff8a3d'; g.fillRect(x + 1, y + 1, 1, 1); }
408 });
409 }
410}
411hooks/engine/room.ts 255 lines1// La pièce elle-même : le parquet, les tapis de chaque coin, les murs, les grandes fenêtres sur la
2// ville (le ciel suit l'heure), le néon VibesMoney, le tableau du plan, l'horloge et la porte.
3// Tout ça ne bouge pas : c'est dessiné une fois, puis recopié à chaque image.
4import { ROLES } from './activity';
5import { pixelText, textWidth } from './font';
6import { BOARD, CLOCK, H, NEON, T, W, WINDOWS, ZONES } from './layout';
7import { g2d, hline, lighten, makeCanvas, mix, P, px, rect, rng, vline, type G } from './pixels';
8import type { Canvas } from '../canvas';
9
10export type TimeOfDay = 'day' | 'evening' | 'night';
11
12const SKY: Record<TimeOfDay, { bands: string[]; city: string; city2: string; lit: string; litRate: number }> = {
13 day: { bands: ['#4f9fe8', '#68b1ef', '#83c2f3', '#a3d3f6', '#c3e3f8'], city: '#6f80a8', city2: '#8494b9', lit: '#d4e2f5', litRate: 0.08 },
14 evening: { bands: ['#2a2453', '#43306a', '#6c3a72', '#a64a6c', '#dc6a5c', '#ff9d5c'], city: '#1d1631', city2: '#2a2040', lit: '#ffcf6b', litRate: 0.22 },
15 night: { bands: ['#0a0a20', '#0f0f2c', '#151538', '#1c1a45', '#231e50'], city: '#0a0816', city2: '#120f22', lit: '#ffcf6b', litRate: 0.32 },
16};
17
18function floor(g: G) {
19 const r = rng(42);
20 const top = 3 * T;
21 const colors = [P.floor1, P.floor2, P.floor3, P.floor1];
22 // des lames de parquet de 6 pixels, aux raccords décalés
23 for (let y = top; y < H; y += 6) {
24 let x = -Math.floor(r() * 20);
25 while (x < W) {
26 const len = 18 + Math.floor(r() * 22);
27 const col = colors[Math.floor(r() * colors.length)];
28 rect(g, x, y, len, 5, col);
29 hline(g, x, y, len, mix(col, P.floorHi, 0.5));
30 rect(g, x, y + 5, len, 1, P.floorSeam);
31 vline(g, x + len - 1, y, 5, P.floorSeam);
32 if (r() < 0.12) px(g, x + 4 + Math.floor(r() * (len - 8)), y + 2, P.floorSeam);
33 x += len;
34 }
35 }
36}
37
38function rug(g: G, x: number, y: number, w: number, h: number, color: string) {
39 const base = mix(color, '#2b2232', 0.72);
40 const edge = mix(color, '#2b2232', 0.5);
41 const inner = mix(color, '#2b2232', 0.62);
42 const px0 = x * T + 2, py0 = y * T + 2, pw = w * T - 4, ph = h * T - 4;
43 rect(g, px0 + 1, py0, pw - 2, ph, base);
44 rect(g, px0, py0 + 1, pw, ph - 2, base);
45 // bordure
46 hline(g, px0 + 1, py0, pw - 2, edge);
47 hline(g, px0 + 1, py0 + ph - 1, pw - 2, edge);
48 vline(g, px0, py0 + 1, ph - 2, edge);
49 vline(g, px0 + pw - 1, py0 + 1, ph - 2, edge);
50 // un liseré intérieur, et un motif discret
51 hline(g, px0 + 3, py0 + 3, pw - 6, inner);
52 hline(g, px0 + 3, py0 + ph - 4, pw - 6, inner);
53 vline(g, px0 + 3, py0 + 3, ph - 6, inner);
54 vline(g, px0 + pw - 4, py0 + 3, ph - 6, inner);
55 for (let yy = py0 + 7; yy < py0 + ph - 6; yy += 6) for (let xx = px0 + 7 + ((yy / 6) % 2) * 3; xx < px0 + pw - 6; xx += 6) px(g, xx, yy, inner);
56}
57
58function backWall(g: G) {
59 rect(g, 0, 0, W, 5, P.wallTop);
60 rect(g, 0, 5, W, 39, P.wallFace);
61 // boiseries : un panneau bas plus sombre, une cimaise
62 rect(g, 0, 31, W, 13, P.wallFace2);
63 hline(g, 0, 30, W, P.wallHi);
64 hline(g, 0, 31, W, P.wallLine);
65 for (let x = 8; x < W; x += 22) vline(g, x, 33, 9, P.wallLine);
66 hline(g, 0, 5, W, P.wallHi);
67 rect(g, 0, 44, W, 4, P.baseboard);
68 hline(g, 0, 44, W, P.wallHi);
69 // l'ombre du mur sur le sol
70 g.fillStyle = 'rgba(8,6,16,0.35)';
71 g.fillRect(0, 48, W, 2);
72 g.fillStyle = 'rgba(8,6,16,0.18)';
73 g.fillRect(0, 50, W, 2);
74}
75
76function windowPane(g: G, wx: number, wy: number, ww: number, wh: number, tod: TimeOfDay, seed: number) {
77 const sky = SKY[tod];
78 const r = rng(seed);
79 // cadre
80 rect(g, wx - 3, wy - 3, ww + 6, wh + 6, '#1a1724');
81 rect(g, wx - 2, wy - 2, ww + 4, wh + 4, '#3b3550');
82 // le ciel, en bandes (comme dans les vieux jeux)
83 const bh = Math.ceil(wh / sky.bands.length);
84 sky.bands.forEach((c, i) => rect(g, wx, wy + i * bh, ww, bh, c));
85 if (tod === 'evening') {
86 for (let i = 0; i < 9; i++) rect(g, wx + 30 + i - 4, wy + wh - 10 - Math.round(Math.sqrt(16 - (i - 4) ** 2)), 1, 6, '#ffd27a');
87 }
88 if (tod === 'night' && seed === 1) {
89 rect(g, wx + 10, wy + 5, 6, 6, '#f2efd8');
90 rect(g, wx + 9, wy + 6, 8, 4, '#f2efd8');
91 px(g, wx + 12, wy + 7, '#d6d2b8');
92 px(g, wx + 14, wy + 9, '#d6d2b8');
93 }
94 if (tod === 'day') {
95 for (const [cx, cy, cw] of [[wx + 8, wy + 6, 12], [wx + 36, wy + 10, 9]]) {
96 rect(g, cx, cy, cw, 3, '#ffffff');
97 rect(g, cx + 2, cy - 2, cw - 5, 2, '#ffffff');
98 hline(g, cx, cy + 3, cw, '#dcecf8');
99 }
100 }
101 // la ville au loin
102 let x = wx;
103 while (x < wx + ww) {
104 const bw = 4 + Math.floor(r() * 8);
105 const bhh = 7 + Math.floor(r() * 13);
106 const far = r() < 0.4;
107 const top = wy + wh - bhh + (far ? 3 : 0);
108 rect(g, x, top, Math.min(bw, wx + ww - x), wy + wh - top, far ? sky.city2 : sky.city);
109 for (let yy = top + 2; yy < wy + wh - 1; yy += 2) {
110 for (let xx = x + 1; xx < Math.min(x + bw - 1, wx + ww - 1); xx += 2) if (r() < sky.litRate) px(g, xx, yy, sky.lit);
111 }
112 x += bw;
113 }
114 // montants et traverse
115 vline(g, wx + Math.floor(ww / 2), wy, wh, '#3b3550');
116 vline(g, wx + Math.floor(ww / 2) + 1, wy, wh, '#2a2538');
117 hline(g, wx, wy + 11, ww, '#3b3550');
118 // un reflet sur la vitre
119 for (let i = 0; i < 6; i++) px(g, wx + 4 + i, wy + 14 - i, 'rgba(255,255,255,0.18)');
120 // l'appui de fenêtre
121 rect(g, wx - 4, wy + wh + 2, ww + 8, 3, '#4a4560');
122 hline(g, wx - 4, wy + wh + 2, ww + 8, '#625c7c');
123}
124
125function whiteboard(g: G) {
126 const { x, y, w, h } = BOARD;
127 rect(g, x - 2, y - 2, w + 4, h + 4, '#7a7e93');
128 rect(g, x - 1, y - 1, w + 2, h + 2, '#c2c5d4');
129 hline(g, x - 1, y - 1, w + 2, '#e6e8f0');
130 rect(g, x, y, w, h, '#f4f5f9');
131 // un reflet en biais
132 for (let i = 0; i < 18; i++) px(g, x + w - 30 + i, y + 2 + Math.floor(i / 2), '#ffffff');
133 // le rebord et les feutres
134 rect(g, x + 4, y + h + 2, w - 8, 2, '#8a8ea2');
135 hline(g, x + 4, y + h + 2, w - 8, '#b3b6c6');
136 rect(g, x + 12, y + h + 1, 5, 1, '#e5484d');
137 rect(g, x + 19, y + h + 1, 5, 1, '#3d8ad1');
138 rect(g, x + 26, y + h + 1, 5, 1, '#2b2836');
139 rect(g, x + w - 18, y + h + 1, 6, 2, '#e9e5ef');
140 // le titre, au feutre orange
141 pixelText(g, 'LE PLAN', x + 4, y + 3, P.orangeDark);
142 hline(g, x + 4, y + 9, textWidth('LE PLAN'), P.orange);
143}
144
145const SIGN: Record<string, string> = { recherche: 'RECHERCHE', code: 'CODE', design: 'DESIGN', redaction: 'RÉDACTION', analyse: 'ANALYSE', chef: 'CHEF' };
146
147/** Les écriteaux posés au coin de chaque tapis, avec le nombre d'agents au travail dans le coin. */
148export function signs(g: G, busy: (role: string) => number) {
149 for (const z of ZONES) {
150 const n = busy(z.role);
151 const label = (SIGN[z.role] ?? z.label.toUpperCase()) + (n > 0 && z.role !== 'chef' ? ` ${n}` : '');
152 const color = ROLES[z.role].color;
153 const w = textWidth(label) + 6;
154 const x = z.x * T + 4, y = z.role === 'chef' ? (z.y + z.h) * T - 12 : z.y * T + 3;
155 rect(g, x + 1, y, w - 2, 9, '#120f19');
156 rect(g, x, y + 1, w, 7, '#120f19');
157 hline(g, x + 1, y, w - 2, mix(color, '#120f19', 0.45));
158 pixelText(g, label, x + 3, y + 2, lighten(color, 0.15));
159 }
160}
161
162/** Le néon : la tête du robot VibesMoney, en tubes de couleur (orange par défaut). */
163export function neon(g: G, tube: string, core: string) {
164 const { x, y } = NEON;
165 // antenne
166 vline(g, x + 12, y, 3, tube);
167 rect(g, x + 11, y - 2, 3, 2, tube);
168 // contour de la tête
169 rect(g, x + 1, y + 3, 23, 1, tube);
170 rect(g, x + 1, y + 20, 23, 1, tube);
171 rect(g, x + 1, y + 3, 1, 18, tube);
172 rect(g, x + 23, y + 3, 1, 18, tube);
173 hline(g, x + 2, y + 4, 21, core);
174 // la visière et les yeux
175 rect(g, x + 5, y + 8, 15, 1, tube);
176 rect(g, x + 5, y + 15, 15, 1, tube);
177 rect(g, x + 5, y + 8, 1, 8, tube);
178 rect(g, x + 19, y + 8, 1, 8, tube);
179 rect(g, x + 8, y + 10, 3, 3, core);
180 rect(g, x + 14, y + 10, 3, 3, core);
181 // le petit support au mur
182 px(g, x + 3, y + 22, '#3b3550');
183 px(g, x + 21, y + 22, '#3b3550');
184}
185
186function clockFace(g: G) {
187 const { x, y } = CLOCK;
188 rect(g, x + 3, y, 8, 14, '#2b2836');
189 rect(g, x, y + 3, 14, 8, '#2b2836');
190 rect(g, x + 1, y + 1, 12, 12, '#2b2836');
191 rect(g, x + 2, y + 2, 10, 10, '#f2f0f7');
192 rect(g, x + 4, y + 1, 6, 12, '#f2f0f7');
193 rect(g, x + 1, y + 4, 12, 6, '#f2f0f7');
194 px(g, x + 7, y + 2, '#2b2836');
195 px(g, x + 7, y + 11, '#2b2836');
196 px(g, x + 2, y + 7, '#2b2836');
197 px(g, x + 11, y + 7, '#2b2836');
198}
199
200function poster(g: G) {
201 const x = 252, y = 12;
202 rect(g, x, y, 10, 14, '#c08a57');
203 rect(g, x + 1, y + 1, 8, 12, '#16121f');
204 // une courbe qui monte, en orange
205 const pts = [[2, 10], [3, 9], [4, 9], [5, 7], [6, 6], [7, 4], [8, 3]];
206 for (const [px_, py_] of pts) px(g, x + px_, y + py_, P.orange);
207 px(g, x + 8, y + 2, P.orangeLight);
208}
209
210function sideWalls(g: G) {
211 for (const x0 of [0, W - T]) {
212 rect(g, x0, 0, T, H, P.wallTop);
213 const inner = x0 === 0 ? T - 2 : x0;
214 vline(g, inner, 5, H - 5, '#2c2740');
215 vline(g, x0 === 0 ? inner + 1 : inner + 1, 5, H - 5, '#1d1a28');
216 }
217 // l'ombre des murs de côté sur le sol
218 g.fillStyle = 'rgba(8,6,16,0.25)';
219 g.fillRect(T, 3 * T, 2, H - 4 * T);
220 g.fillRect(W - T - 2, 3 * T, 2, H - 4 * T);
221}
222
223function frontWall(g: G) {
224 const y = 15 * T;
225 rect(g, 0, y, 10 * T, T, P.wallTop);
226 rect(g, 12 * T, y, W - 12 * T, T, P.wallTop);
227 hline(g, 0, y, 10 * T, '#2c2740');
228 hline(g, 12 * T, y, W - 12 * T, '#2c2740');
229 // l'encadrement de la porte
230 for (const x of [10 * T - 2, 12 * T]) {
231 rect(g, x, y - 2, 2, T + 2, '#3b3550');
232 vline(g, x, y - 2, T + 2, '#4d4666');
233 }
234 // le paillasson orange
235 const mx = 10 * T + 2, my = 14 * T + 3;
236 rect(g, mx, my, 2 * T - 4, 10, P.orangeDeep);
237 for (let i = 0; i < 4; i++) hline(g, mx + 2, my + 2 + i * 2, 2 * T - 8, i % 2 ? P.orangeDark : P.orange);
238}
239
240/** Dessine toute la pièce (sans les meubles) dans une image de la taille du bureau. */
241export function drawRoom(tod: TimeOfDay): Canvas {
242 const c = makeCanvas(W, H);
243 const g = g2d(c);
244 floor(g);
245 for (const z of ZONES) rug(g, z.x, z.y, z.w, z.h, ROLES[z.role].color);
246 backWall(g);
247 WINDOWS.forEach((w, i) => windowPane(g, w.x, w.y, w.w, w.h, tod, i + 1));
248 whiteboard(g);
249 clockFace(g);
250 poster(g);
251 sideWalls(g);
252 frontWall(g);
253 return c;
254}
255hooks/engine/world.ts 597 lines1// Le monde du bureau : les robots, ce qu'ils font, où ils vont. Chaque événement venu de Claude Code
2// devient une petite mise en scène : un membre de l'équipe arrive par la porte, va à son bureau, tape
3// ou lit selon ce qu'il fait vraiment, se lève pour rendre son rapport au chef, puis repart.
4import type { ActKind, OfficeEvent, PlanItem, Role } from './protocol';
5import { Cat } from './cat';
6import { Fx } from './fx';
7import { iconFor } from './icons';
8import { CHEF_SEAT, SEATS, SPOTS, T, WINDOWS, tileFeet, walkGrid, type Seat } from './layout';
9import { findPath, type Tile } from './path';
10import type { Dir, Eyes, Pose } from './robots';
11
12type Pt = { x: number; y: number };
13
14type Task =
15 | { t: 'walk'; tile: Tile; face?: Dir; then?: Pt }
16 | { t: 'slide'; to: Pt; face?: Dir; speed?: number }
17 | { t: 'sit'; seat: Seat }
18 | { t: 'stand' }
19 | { t: 'wait'; ms: number; pose?: Pose; dir?: Dir; eyes?: Eyes }
20 | { t: 'call'; fn: () => void }
21 | { t: 'fade'; to: number; ms: number };
22
23const WALK_SPEED = 58; // pixels par seconde
24
25export class Actor {
26 id: string;
27 role: Role;
28 name: string;
29 x: number;
30 y: number;
31 dir: Dir = 'down';
32 pose: Pose = 'stand';
33 frame = 0;
34 eyes: Eyes = 'open';
35 seat: Seat | null = null;
36 seated = false;
37 standSpot: Tile | null = null;
38 tasks: Task[] = [];
39 cur: Task | null = null;
40 curT = 0;
41 path: Pt[] = [];
42 moving = false;
43 alpha = 1;
44 act: ActKind | null = null;
45 actAt = -99;
46 thinkAt = -99;
47 carrying = false;
48 asks = 0;
49 cheerUntil = 0;
50 happyUntil = 0;
51 sadUntil = 0;
52 reported = false;
53 delivered = false;
54 left = false;
55 gone = false;
56 animT = 0;
57 nextBlink = 1 + Math.random() * 3;
58 blinkUntil = 0;
59 nextLook = 2 + Math.random() * 3;
60 look: Eyes = 'open';
61 lookUntil = 0;
62 deliverSpot = -1;
63
64 constructor(id: string, role: Role, name: string, x: number, y: number) {
65 this.id = id;
66 this.role = role;
67 this.name = name;
68 this.x = x;
69 this.y = y;
70 }
71
72 get tile(): Tile {
73 if (this.seated && this.seat) return this.seat.tile;
74 return [Math.max(0, Math.floor(this.x / T)), Math.max(0, Math.min(15, Math.floor((this.y - 2) / T)))];
75 }
76
77 /** Le haut de la tête (pour accrocher les bulles). */
78 get head(): Pt { return { x: this.x, y: this.y - 22 }; }
79
80 get busy() { return !!this.cur || this.tasks.length > 0; }
81}
82
83export class World {
84 actors = new Map<string, Actor>();
85 fx = new Fx();
86 grid = walkGrid();
87 private seatOf = new Map<string, string>();
88 private deliverTaken = new Set<number>();
89 time = 0;
90 speed = 1;
91 /** les rapports posés sur le bureau du chef */
92 inbox = 0;
93 printUntil = 0;
94 boardUntil = 0;
95 missionActive = false;
96 plan: PlanItem[] = [];
97 /** qui fait quoi : écrit au tableau, coché quand l'agent a rendu son travail */
98 assignments: { id: string; role: Role; name: string; task: string; status: 'working' | 'done' | 'failed' }[] = [];
99 private planSize = -1;
100 // ── la vie du bureau (et ses œufs de Pâques) ──
101 cat = new Cat();
102 /** la couleur du néon (clique dessus pour en changer) */
103 neonIndex = 0;
104 /** les meubles qui frémissent (plantes…) : identifiant → jusqu'à quand */
105 wiggles = new Map<string, number>();
106 /** les lampes éteintes */
107 lampsOff = new Set<string>();
108 /** le mode soirée (boule à facettes, tout le monde danse) */
109 discoUntil = 0;
110 /** la musique du jukebox : est-ce qu'elle joue, et l'énergie des basses (0 à 1) */
111 musicOn = false;
112 musicLevel = 0;
113 bobUntil = 0;
114 private noteAt = 0;
115 private steamAt = 0;
116 private waveAt = 6;
117 rosterVersion = 0;
118 private rosterListeners = new Set<() => void>();
119
120 constructor() {
121 this.resetAll();
122 }
123
124 get chef() { return this.actors.get('chef')!; }
125
126 onRoster(fn: () => void) { this.rosterListeners.add(fn); return () => this.rosterListeners.delete(fn); }
127 private rosterChanged() {
128 this.rosterVersion++;
129 for (const fn of this.rosterListeners) fn();
130 }
131
132 /** Le bureau vide, Max à l'accueil (début, nouvelle mission, rediffusion). */
133 resetAll() {
134 this.actors.clear();
135 this.seatOf.clear();
136 this.deliverTaken.clear();
137 this.fx = new Fx();
138 this.inbox = 0;
139 this.printUntil = 0;
140 this.boardUntil = 0;
141 this.missionActive = false;
142 this.plan = [];
143 this.assignments = [];
144 this.planSize = -1;
145 const p = SPOTS.receptionPx;
146 this.actors.set('chef', new Actor('chef', 'chef', 'Max', p.x, p.y));
147 this.rosterChanged();
148 }
149
150 // ── Les tâches ──
151 private queue(a: Actor, ...tasks: Task[]) { a.tasks.push(...tasks); }
152
153 private startTask(a: Actor, task: Task) {
154 a.curT = 0;
155 switch (task.t) {
156 case 'walk': {
157 if (a.seated) { a.seated = false; this.standUp(a); }
158 const tiles = findPath(this.grid, a.tile, task.tile);
159 a.path = (tiles ?? [task.tile]).map(tileFeet);
160 if (task.then) a.path.push(task.then);
161 break;
162 }
163 case 'slide':
164 a.path = [task.to];
165 break;
166 case 'sit':
167 a.path = [{ x: task.seat.x, y: task.seat.y }];
168 break;
169 case 'stand':
170 if (a.seated) { a.seated = false; this.standUp(a); }
171 break;
172 }
173 }
174
175 private standUp(a: Actor) {
176 if (!a.seat) return;
177 const f = tileFeet(a.seat.tile);
178 a.x = f.x + (a.seat.x - f.x > 4 ? 6 : 0);
179 a.y = f.y;
180 }
181
182 /** Avance une tâche. Renvoie vrai quand elle est finie. */
183 private runTask(a: Actor, task: Task, dt: number): boolean {
184 switch (task.t) {
185 case 'walk':
186 case 'slide':
187 case 'sit': {
188 const speed = (task.t === 'slide' && task.speed) || (task.t === 'sit' ? 30 : WALK_SPEED);
189 let budget = speed * dt;
190 while (budget > 0 && a.path.length) {
191 const p = a.path[0];
192 const dx = p.x - a.x, dy = p.y - a.y;
193 const d = Math.hypot(dx, dy);
194 if (d > 0.01 && task.t !== 'sit') a.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';
195 if (d <= budget) { a.x = p.x; a.y = p.y; a.path.shift(); budget -= d; }
196 else { a.x += (dx / d) * budget; a.y += (dy / d) * budget; budget = 0; }
197 }
198 a.moving = a.path.length > 0 && task.t !== 'sit';
199 if (a.path.length) return false;
200 a.moving = false;
201 if (task.t === 'sit') { a.seated = true; a.seat = task.seat; a.dir = 'down'; }
202 else if (task.face) a.dir = task.face;
203 return true;
204 }
205 case 'stand':
206 return true;
207 case 'wait':
208 if (task.dir) a.dir = task.dir;
209 return a.curT * 1000 >= task.ms;
210 case 'call':
211 task.fn();
212 return true;
213 case 'fade': {
214 const step = dt / Math.max(0.05, task.ms / 1000);
215 a.alpha = task.to > a.alpha ? Math.min(task.to, a.alpha + step) : Math.max(task.to, a.alpha - step);
216 return a.alpha === task.to;
217 }
218 }
219 }
220
221 // ── Les places ──
222 private assignSeat(role: Role): Seat | null {
223 const free = (r: Role | null) => SEATS.find((s) => (r === null || s.role === r) && !this.seatOf.has(s.id)) ?? null;
224 return free(role) ?? (role === 'renfort' ? free(null) : null);
225 }
226
227 private overflowSpot(): Tile {
228 const used = new Set([...this.actors.values()].map((a) => a.standSpot?.join(',')).filter(Boolean));
229 return SPOTS.overflow.find((t) => !used.has(t.join(','))) ?? SPOTS.overflow[0];
230 }
231
232 private nearestWindow(a: Actor): Pt {
233 const w = a.x < 176 ? WINDOWS[0] : WINDOWS[1];
234 return { x: w.x + w.w / 2, y: w.y + w.h - 4 };
235 }
236
237 /** Les rapports se posent ici, dans la corbeille du bureau du chef. */
238 get inboxPt(): Pt { return { x: 9 * T + 52, y: 7 * T - 2 }; }
239 get printerPt(): Pt { return { x: 12 * T + 8, y: 4 * T - 4 }; }
240
241 // ── Les événements ──
242 apply(ev: OfficeEvent, live: boolean) {
243 const chef = this.chef;
244 switch (ev.k) {
245 case 'start':
246 this.missionActive = true;
247 chef.thinkAt = this.time;
248 if (live) {
249 if (!chef.seated && !chef.busy) this.queue(chef, { t: 'wait', ms: 500, pose: 'wave', dir: 'down' }, { t: 'walk', tile: CHEF_SEAT.tile }, { t: 'sit', seat: CHEF_SEAT });
250 else if (!chef.seated) this.queue(chef, { t: 'walk', tile: CHEF_SEAT.tile }, { t: 'sit', seat: CHEF_SEAT });
251 } else this.snapSeat(chef, CHEF_SEAT);
252 break;
253 case 'think': {
254 const a = this.actors.get(ev.who);
255 if (a) a.thinkAt = this.time;
256 break;
257 }
258 case 'plan': {
259 this.plan = ev.items;
260 const n = ev.items.length;
261 const fresh = n !== this.planSize;
262 this.planSize = n;
263 if (live && fresh) {
264 // Max va écrire le plan au tableau
265 chef.tasks = chef.tasks.filter((t) => t.t !== 'sit');
266 this.queue(chef,
267 { t: 'walk', tile: SPOTS.board, face: 'up', then: SPOTS.boardPx },
268 { t: 'call', fn: () => { this.boardUntil = this.time + 1.6; } },
269 { t: 'wait', ms: 1600, pose: 'write', dir: 'up' },
270 { t: 'call', fn: () => { this.fx.sparkle(SPOTS.boardPx.x, 24, 10, '#ffffff'); } },
271 { t: 'walk', tile: CHEF_SEAT.tile },
272 { t: 'sit', seat: CHEF_SEAT },
273 );
274 } else if (live) {
275 this.fx.sparkle(SPOTS.boardPx.x + (Math.random() - 0.5) * 60, 22, 5, '#3ddc97');
276 }
277 break;
278 }
279 case 'hire': {
280 if (this.actors.has(ev.id)) break;
281 const seat = this.assignSeat(ev.role);
282 const a = new Actor(ev.id, ev.role, ev.name, SPOTS.doorOut.x - 8 + Math.random() * 16, SPOTS.doorOut.y);
283 if (!this.assignments.some((x) => x.id === ev.id)) this.assignments.push({ id: ev.id, role: ev.role, name: ev.name, task: ev.task, status: 'working' });
284 if (seat) { this.seatOf.set(seat.id, a.id); a.seat = seat; }
285 else a.standSpot = this.overflowSpot();
286 this.actors.set(a.id, a);
287 this.rosterChanged();
288 if (live) {
289 a.alpha = 0;
290 a.dir = 'up';
291 this.fx.beam(SPOTS.doorOut.x, 15 * T + 10);
292 this.queue(a,
293 { t: 'fade', to: 1, ms: 250 },
294 { t: 'slide', to: tileFeet(SPOTS.door), speed: 40 },
295 );
296 if (seat) this.queue(a, { t: 'walk', tile: seat.tile }, { t: 'sit', seat });
297 else this.queue(a, { t: 'walk', tile: a.standSpot!, face: 'down' });
298 // Max se tourne vers celui qu'il appelle
299 chef.happyUntil = this.time + 0.8;
300 } else if (seat) this.snapSeat(a, seat);
301 else { const f = tileFeet(a.standSpot!); a.x = f.x; a.y = f.y; }
302 break;
303 }
304 case 'act': {
305 const a = this.actors.get(ev.who);
306 if (!a) break;
307 a.act = ev.kind;
308 a.actAt = this.time;
309 if (!live) break;
310 this.fx.floatIcon(a.x + (a.seated ? 0 : 1), a.y - 24, iconFor(ev.kind));
311 if (ev.kind === 'search' || ev.kind === 'web') this.fx.data(this.nearestWindow(a), { x: a.x, y: a.y - 8 }, ev.kind === 'web' ? '#3ddc97' : '#7fd6ff');
312 break;
313 }
314 case 'report': {
315 const a = this.actors.get(ev.who);
316 if (!a || a.reported) break;
317 a.reported = true;
318 if (live) this.deliver(a);
319 else this.inbox++;
320 break;
321 }
322 case 'leave': {
323 const asg = this.assignments.find((x) => x.id === ev.who);
324 if (asg) asg.status = ev.ok ? 'done' : 'failed';
325 const a = this.actors.get(ev.who);
326 if (!a || a.left) break;
327 a.left = true;
328 if (!live) { this.remove(a); break; }
329 if (ev.ok && !a.reported) { a.reported = true; this.deliver(a); }
330 if (!ev.ok) a.sadUntil = this.time + 2;
331 this.exit(a);
332 break;
333 }
334 case 'file':
335 if (live) {
336 this.printUntil = this.time + 1.8;
337 this.fx.floatIcon(this.printerPt.x, this.printerPt.y - 6, 'paper');
338 }
339 break;
340 case 'ask': {
341 const a = this.actors.get(ev.who) ?? chef;
342 a.asks++;
343 if (live) this.fx.floatIcon(a.x, a.y - 26, 'alert');
344 break;
345 }
346 case 'ask_done':
347 for (const a of this.actors.values()) if (a.asks > 0) { a.asks = Math.max(0, a.asks - 1); break; }
348 break;
349 case 'end':
350 this.missionActive = false;
351 for (const a of this.actors.values()) if (a !== chef && !a.left) { a.left = true; if (live) this.exit(a); else this.remove(a); }
352 if (live) {
353 if (ev.ok) { this.fx.rain(22 * T); chef.happyUntil = this.time + 3; }
354 chef.tasks = [];
355 this.queue(chef,
356 { t: 'walk', tile: SPOTS.reception, then: SPOTS.receptionPx },
357 { t: 'wait', ms: 100, dir: 'down' },
358 { t: 'call', fn: () => {
359 if (ev.ok) {
360 chef.cheerUntil = this.time + 2.6;
361 this.fx.confetti(chef.x, chef.y - 20, 60);
362 } else chef.sadUntil = this.time + 2.5;
363 } },
364 );
365 } else {
366 chef.tasks = [];
367 chef.cur = null;
368 chef.seated = false;
369 chef.x = SPOTS.receptionPx.x;
370 chef.y = SPOTS.receptionPx.y;
371 chef.dir = 'down';
372 }
373 break;
374 case 'error':
375 chef.sadUntil = this.time + 2;
376 break;
377 }
378 }
379
380 private snapSeat(a: Actor, seat: Seat) {
381 a.tasks = [];
382 a.cur = null;
383 a.seat = seat;
384 a.seated = true;
385 a.x = seat.x;
386 a.y = seat.y;
387 a.dir = 'down';
388 a.alpha = 1;
389 }
390
391 /** Il se lève, prend son rapport et va le poser sur le bureau du chef. */
392 private deliver(a: Actor) {
393 let spot = SPOTS.deliver.findIndex((_, i) => !this.deliverTaken.has(i));
394 if (spot < 0) spot = Math.floor(Math.random() * SPOTS.deliver.length);
395 this.deliverTaken.add(spot);
396 a.deliverSpot = spot;
397 this.queue(a,
398 { t: 'stand' },
399 { t: 'call', fn: () => { a.carrying = true; } },
400 { t: 'walk', tile: SPOTS.deliver[spot], face: 'up' },
401 { t: 'call', fn: () => {
402 a.carrying = false;
403 this.fx.paper({ x: a.x, y: a.y - 12 }, this.inboxPt, () => { this.inbox++; this.fx.sparkle(this.inboxPt.x, this.inboxPt.y, 6, '#ffd2a8'); });
404 a.happyUntil = this.time + 1.2;
405 this.chef.happyUntil = this.time + 1.2;
406 } },
407 { t: 'wait', ms: 700, dir: 'up' },
408 { t: 'call', fn: () => { this.deliverTaken.delete(spot); a.delivered = true; } },
409 );
410 }
411
412 /** Il repart par la porte (et libère son bureau). */
413 private exit(a: Actor) {
414 this.queue(a,
415 { t: 'call', fn: () => { if (a.seat) this.seatOf.delete(a.seat.id); } },
416 { t: 'walk', tile: SPOTS.door },
417 { t: 'call', fn: () => this.fx.beam(SPOTS.doorOut.x, 15 * T + 10) },
418 { t: 'slide', to: { x: a.x, y: SPOTS.doorOut.y }, speed: 40 },
419 { t: 'fade', to: 0, ms: 200 },
420 { t: 'call', fn: () => this.remove(a) },
421 );
422 }
423
424 private remove(a: Actor) {
425 if (a.seat && this.seatOf.get(a.seat.id) === a.id) this.seatOf.delete(a.seat.id);
426 if (a.deliverSpot >= 0) this.deliverTaken.delete(a.deliverSpot);
427 a.gone = true;
428 this.actors.delete(a.id);
429 this.rosterChanged();
430 }
431
432 // ── Une image ──
433 /** Fait avancer le monde. Renvoie vrai si quelque chose bouge (pour savoir à quel rythme redessiner). */
434 update(dtReal: number): boolean {
435 const dt = Math.min(0.1, dtReal) * this.speed;
436 this.time += dt;
437 this.fx.update(dt);
438 let moving = this.fx.busy || this.time < this.printUntil || this.time < this.boardUntil;
439 const chef = this.chef;
440 // la vapeur du café
441 if (this.time > this.steamAt) { this.steamAt = this.time + 0.35; this.fx.steam(16 * T + 11, 13 * T + 4); }
442 // le chat
443 if (this.cat.update(dt, this.time, this.grid, (x, y) => this.fx.floatIcon(x, y, 'zzz'))) moving = true;
444 // le jukebox : des notes qui s'envolent quand il joue
445 if (this.musicOn && this.time > this.noteAt) {
446 this.noteAt = this.time + 0.7 + Math.random() * 0.5;
447 this.fx.floatIcon(14 * T + 10 + Math.random() * 12, 13 * T + 2, Math.random() < 0.5 ? 'note' : 'note2');
448 }
449 if (this.musicOn) moving = true;
450 // le mode soirée : des étincelles, et tout le monde danse
451 if (this.time < this.discoUntil) {
452 moving = true;
453 if (Math.random() < 0.25) this.fx.sparkle(32 + Math.random() * 288, 60 + Math.random() * 160, 2, ['#ff6fa8', '#4fb3ff', '#ffd166', '#3ddc97', '#ff8a3d'][Math.floor(Math.random() * 5)]);
454 }
455 for (const until of this.wiggles.values()) if (until > this.time) moving = true;
456 for (const a of this.actors.values()) {
457 if (a.id.startsWith('demo-') && this.time - a.actAt > 2 + Math.random() * 3) {
458 const kinds: ActKind[] = a.role === 'recherche' ? ['search', 'web'] : a.role === 'code' ? ['write', 'bash'] : a.role === 'analyse' ? ['read', 'write'] : ['write', 'edit'];
459 a.act = kinds[Math.floor(Math.random() * kinds.length)];
460 a.actAt = this.time;
461 this.fx.floatIcon(a.x, a.y - 24, iconFor(a.act));
462 }
463 }
464 // Max salue de temps en temps quand il attend à l'accueil
465 if (!this.missionActive && !chef.busy && !chef.seated && this.time > this.waveAt) {
466 this.waveAt = this.time + 9 + Math.random() * 6;
467 this.queue(chef, { t: 'wait', ms: 1200, pose: 'wave', dir: 'down' });
468 }
469 for (const a of [...this.actors.values()]) {
470 if (!a.cur && a.tasks.length) { a.cur = a.tasks.shift()!; this.startTask(a, a.cur); }
471 if (a.cur) {
472 const task = a.cur;
473 a.curT += dt;
474 if (this.runTask(a, task, dt) && a.cur === task) a.cur = null;
475 }
476 this.animate(a, dt);
477 if (a.moving || a.cur || a.seated || a.cheerUntil > this.time) moving = true;
478 }
479 return moving;
480 }
481
482 // ── Les petits gestes du bureau (quand on clique sur les objets) ──
483 neonNext() {
484 this.neonIndex = (this.neonIndex + 1) % 5;
485 this.fx.sparkle(97 + 12, 11 + 11, 10, '#ffffff');
486 }
487
488 wiggle(id: string, x: number, y: number) {
489 this.wiggles.set(id, this.time + 1.1);
490 this.fx.sparkle(x, y, 6, '#86cf7c');
491 }
492
493 coffee() {
494 for (let i = 0; i < 12; i++) this.fx.steam(16 * T + 9 + Math.random() * 6, 13 * T + 4 - Math.random() * 4);
495 this.fx.floatIcon(16 * T + 12, 13 * T - 2, 'coffee');
496 }
497
498 cooler() {
499 for (let i = 0; i < 6; i++) this.fx.sparkle(9 * T + 8, 3 * T + 4 - i, 1, '#bfe3ff');
500 this.fx.floatIcon(9 * T + 8, 3 * T - 4, 'drop');
501 }
502
503 toggleLamp(id: string) {
504 if (this.lampsOff.has(id)) this.lampsOff.delete(id); else this.lampsOff.add(id);
505 }
506
507 /** Le mode soirée : la boule à facettes descend, et toute l'équipe danse. */
508 disco(seconds = 16) {
509 this.discoUntil = this.time + seconds;
510 for (const a of this.actors.values()) a.cheerUntil = this.discoUntil;
511 this.fx.rain(22 * T, 70);
512 }
513
514 /** Pour la visite guidée sans mission : un agent par coin, assis à son bureau, qui travaille. */
515 demoTeam(on: boolean) {
516 const demo: [string, Role, string][] = [['demo-1', 'recherche', 'Iris'], ['demo-2', 'recherche', 'Hugo'], ['demo-3', 'code', 'Ada'], ['demo-4', 'design', 'Frida'], ['demo-5', 'redaction', 'Colette'], ['demo-6', 'analyse', 'Marie']];
517 if (on) {
518 for (const [id, role, name] of demo) {
519 if (this.actors.has(id)) continue;
520 const seat = this.assignSeat(role);
521 if (!seat) continue;
522 const a = new Actor(id, role, name, seat.x, seat.y);
523 this.seatOf.set(seat.id, id);
524 this.snapSeat(a, seat);
525 a.act = Math.random() < 0.5 ? 'search' : 'write';
526 a.actAt = this.time;
527 this.actors.set(id, a);
528 }
529 } else {
530 for (const [id] of demo) { const a = this.actors.get(id); if (a) this.remove(a); }
531 }
532 this.rosterChanged();
533 }
534
535 petCat() {
536 this.cat.pet(this.time);
537 this.fx.floatIcon(this.cat.x, this.cat.y - 12, 'heart');
538 }
539
540 /** La musique : appelé à chaque image avec l'énergie des basses. */
541 setMusic(on: boolean, level: number, beat: boolean) {
542 this.musicOn = on;
543 this.musicLevel = level;
544 if (beat) this.bobUntil = this.time + 0.12;
545 }
546
547 private animate(a: Actor, dt: number) {
548 a.animT += dt;
549 const t = this.time;
550 const recent = t - a.actAt < 9;
551 let pose: Pose;
552 if (a.cur?.t === 'wait' && a.cur.pose) pose = a.cur.pose;
553 else if (a.moving) pose = a.carrying ? 'carry' : 'walk';
554 else if (a.asks > 0) pose = 'raise';
555 else if (a.cheerUntil > t) pose = 'cheer';
556 else if (a.carrying) pose = 'carry';
557 else if (a.seated) {
558 if (recent && a.act && ['write', 'edit', 'bash', 'plan'].includes(a.act)) pose = 'type';
559 else if (recent) pose = 'read';
560 else pose = 'sit';
561 } else pose = 'stand';
562 if (pose !== a.pose) { a.pose = pose; a.animT = 0; }
563 const ft = a.animT;
564 switch (pose) {
565 case 'walk': a.frame = Math.floor(ft * 8) % 4; break;
566 case 'carry': a.frame = a.moving ? Math.floor(ft * 8) % 4 : 0; break;
567 case 'type': a.frame = Math.floor(ft * 7) % 2; break;
568 case 'cheer': case 'wave': a.frame = Math.floor(ft * 6) % 2; break;
569 case 'raise': a.frame = Math.floor(ft * 3) % 2; break;
570 case 'write': a.frame = Math.floor(ft * 5) % 2; break;
571 default: a.frame = 0;
572 }
573 if (pose === 'carry' && a.moving) {
574 // en marchant avec sa feuille : on garde la pose « porter », jambes en mouvement
575 a.frame = Math.floor(ft * 8) % 4;
576 }
577 // le regard
578 if (t > a.nextBlink) { a.blinkUntil = t + 0.12; a.nextBlink = t + 2.2 + Math.random() * 3.5; }
579 if (t > a.nextLook) {
580 const opts: Eyes[] = ['left', 'right', 'open', 'open', 'up'];
581 a.look = opts[Math.floor(Math.random() * opts.length)];
582 a.lookUntil = t + 0.8 + Math.random() * 0.8;
583 a.nextLook = t + 2.5 + Math.random() * 3;
584 }
585 let eyes: Eyes = 'open';
586 if (a.happyUntil > t || pose === 'cheer') eyes = 'happy';
587 else if (a.sadUntil > t) eyes = 'down';
588 else if (pose === 'type') eyes = 'down';
589 else if (pose === 'read') eyes = Math.floor(ft * 2.2) % 2 ? 'left' : 'right';
590 else if (pose === 'write') eyes = 'up';
591 else if (t - a.thinkAt < 2.5 && !a.moving) eyes = 'up';
592 else if (a.lookUntil > t && !a.moving) eyes = a.look;
593 if (a.blinkUntil > t && eyes !== 'happy') eyes = 'blink';
594 a.eyes = eyes;
595 }
596}
597hooks/png.ts 190 lines1// PNG out of an RGBA buffer, for a hooks module that has no canvas to call toDataURL on: 8-bit RGB, each row
2// filtered with Sub, compressed with deflate (LZ77 over a hash chain, fixed Huffman codes). Pixel art is mostly
3// runs, so a 352 × 256 frame comes out at a few tens of kilobytes.
4
5const CRC = (() => {
6 const t = new Uint32Array(256)
7 for (let n = 0; n < 256; n++) {
8 let c = n
9 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
10 t[n] = c >>> 0
11 }
12 return t
13})()
14
15function crc32(bytes: Uint8Array, start: number, end: number): number {
16 let c = 0xffffffff
17 for (let i = start; i < end; i++) c = CRC[(c ^ bytes[i]!) & 255]! ^ (c >>> 8)
18 return (c ^ 0xffffffff) >>> 0
19}
20
21function adler32(bytes: Uint8Array): number {
22 let a = 1
23 let b = 0
24 for (let i = 0; i < bytes.length; i++) {
25 a = (a + bytes[i]!) % 65521
26 b = (b + a) % 65521
27 }
28 return ((b << 16) | a) >>> 0
29}
30
31class Bits {
32 private out: number[] = []
33 private acc = 0
34 private n = 0
35 /** `count` bits of `value`, least significant first, as deflate packs them. */
36 put(value: number, count: number) {
37 for (let i = 0; i < count; i++) {
38 this.acc |= ((value >> i) & 1) << this.n
39 if (++this.n === 8) {
40 this.out.push(this.acc)
41 this.acc = 0
42 this.n = 0
43 }
44 }
45 }
46 /** A Huffman code, most significant bit first. */
47 code(value: number, count: number) {
48 for (let i = count - 1; i >= 0; i--) this.put((value >> i) & 1, 1)
49 }
50 bytes(): Uint8Array {
51 if (this.n > 0) this.out.push(this.acc)
52 return Uint8Array.from(this.out)
53 }
54}
55
56const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
57const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
58const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
59const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
60
61function literal(b: Bits, v: number) {
62 if (v < 144) b.code(0x30 + v, 8)
63 else if (v < 256) b.code(0x190 + v - 144, 9)
64 else if (v < 280) b.code(v - 256, 7)
65 else b.code(0xc0 + v - 280, 8)
66}
67
68function lengthCode(b: Bits, len: number) {
69 let i = LEN_BASE.length - 1
70 while (LEN_BASE[i]! > len) i--
71 literal(b, 257 + i)
72 b.put(len - LEN_BASE[i]!, LEN_EXTRA[i]!)
73}
74
75function distCode(b: Bits, dist: number) {
76 let i = DIST_BASE.length - 1
77 while (DIST_BASE[i]! > dist) i--
78 b.code(i, 5)
79 b.put(dist - DIST_BASE[i]!, DIST_EXTRA[i]!)
80}
81
82/** Raw deflate: one final block of fixed Huffman codes. */
83function deflate(data: Uint8Array): Uint8Array {
84 const b = new Bits()
85 b.put(1, 1) // final block
86 b.put(1, 2) // fixed Huffman
87 const WIN = 32768
88 const head = new Int32Array(1 << 15).fill(-1)
89 const prev = new Int32Array(WIN)
90 const hash = (i: number) => ((data[i]! << 10) ^ (data[i + 1]! << 5) ^ data[i + 2]!) & 0x7fff
91 const insert = (i: number) => {
92 if (i + 2 >= data.length) return
93 const h = hash(i)
94 prev[i % WIN] = head[h]!
95 head[h] = i
96 }
97 let i = 0
98 while (i < data.length) {
99 let bestLen = 0
100 let bestDist = 0
101 if (i + 2 < data.length) {
102 let j = head[hash(i)]!
103 let chain = 48
104 while (j >= 0 && i - j <= WIN && chain-- > 0) {
105 let l = 0
106 while (l < 258 && i + l < data.length && data[j + l] === data[i + l]) l++
107 if (l > bestLen) {
108 bestLen = l
109 bestDist = i - j
110 if (l === 258) break
111 }
112 const nj = prev[j % WIN]!
113 if (nj >= j) break
114 j = nj
115 }
116 }
117 if (bestLen >= 3) {
118 lengthCode(b, bestLen)
119 distCode(b, bestDist)
120 for (let k = 0; k < bestLen; k++) insert(i + k)
121 i += bestLen
122 } else {
123 literal(b, data[i]!)
124 insert(i)
125 i++
126 }
127 }
128 literal(b, 256) // end of block
129 return b.bytes()
130}
131
132function chunk(type: string, body: Uint8Array): Uint8Array {
133 const out = new Uint8Array(12 + body.length)
134 const v = new DataView(out.buffer)
135 v.setUint32(0, body.length)
136 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
137 out.set(body, 8)
138 v.setUint32(8 + body.length, crc32(out, 4, 8 + body.length))
139 return out
140}
141
142/** A PNG of `w × h` RGBA pixels, alpha dropped (the office is opaque). */
143export function encodePng(rgba: Uint8ClampedArray, w: number, h: number): Uint8Array {
144 // filter Sub on every row: the difference with the pixel to the left, mostly zeros in flat colour
145 const raw = new Uint8Array(h * (1 + w * 3))
146 let k = 0
147 for (let y = 0; y < h; y++) {
148 raw[k++] = 1
149 for (let x = 0; x < w; x++) {
150 const i = (y * w + x) * 4
151 for (let c = 0; c < 3; c++) raw[k++] = (rgba[i + c]! - (x > 0 ? rgba[i - 4 + c]! : 0)) & 255
152 }
153 }
154 const z = deflate(raw)
155 const zlib = new Uint8Array(2 + z.length + 4)
156 zlib[0] = 0x78
157 zlib[1] = 0x01
158 zlib.set(z, 2)
159 new DataView(zlib.buffer).setUint32(2 + z.length, adler32(raw))
160 const ihdr = new Uint8Array(13)
161 const hv = new DataView(ihdr.buffer)
162 hv.setUint32(0, w)
163 hv.setUint32(4, h)
164 ihdr[8] = 8 // bit depth
165 ihdr[9] = 2 // RGB
166 const parts = [Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10), chunk('IHDR', ihdr), chunk('IDAT', zlib), chunk('IEND', new Uint8Array(0))]
167 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
168 let o = 0
169 for (const p of parts) {
170 out.set(p, o)
171 o += p.length
172 }
173 return out
174}
175
176const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
177
178export function base64(bytes: Uint8Array): string {
179 let out = ''
180 for (let i = 0; i < bytes.length; i += 3) {
181 const a = bytes[i]!
182 const b = bytes[i + 1]
183 const c = bytes[i + 2]
184 out += B64[a >> 2]! + B64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
185 out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
186 out += c === undefined ? '=' : B64[c & 63]!
187 }
188 return out
189}
190hooks/canvas.ts 293 lines1// A canvas for a hooks module, which has no DOM: just enough of CanvasRenderingContext2D for the office engine of
2// Le Bureau des agents to draw unchanged. Integer pixels on an RGBA buffer, with fillStyle (hex, rgb[a] and radial
3// gradients), fillRect, drawImage, globalAlpha, the source-over, lighter and multiply composites, save/restore,
4// rectangular clips, filled polygons, getImageData, and the translate/scale pair the engine uses to mirror sprites.
5
6type RGBA = [number, number, number, number]
7
8class RadialGradient {
9 private stops: { at: number; c: RGBA }[] = []
10 constructor(
11 readonly x: number,
12 readonly y: number,
13 readonly r: number,
14 ) {}
15 addColorStop(at: number, color: string) {
16 this.stops.push({ at, c: parseColor(color) })
17 this.stops.sort((a, b) => a.at - b.at)
18 }
19 colorAt(px: number, py: number): RGBA {
20 const t = this.r > 0 ? Math.min(1, Math.hypot(px - this.x, py - this.y) / this.r) : 1
21 const s = this.stops
22 if (s.length === 0) return [0, 0, 0, 0]
23 if (t <= s[0]!.at) return s[0]!.c
24 for (let i = 1; i < s.length; i++) {
25 const a = s[i - 1]!
26 const b = s[i]!
27 if (t <= b.at) {
28 const k = b.at > a.at ? (t - a.at) / (b.at - a.at) : 1
29 return [a.c[0] + (b.c[0] - a.c[0]) * k, a.c[1] + (b.c[1] - a.c[1]) * k, a.c[2] + (b.c[2] - a.c[2]) * k, a.c[3] + (b.c[3] - a.c[3]) * k]
30 }
31 }
32 return s[s.length - 1]!.c
33 }
34}
35
36const colorCache = new Map<string, RGBA>()
37
38export function parseColor(s: string): RGBA {
39 const hit = colorCache.get(s)
40 if (hit) return hit
41 let c: RGBA = [0, 0, 0, 1]
42 const t = s.trim()
43 if (t.startsWith('#')) {
44 let h = t.slice(1)
45 if (h.length === 3 || h.length === 4) h = h.split('').map(x => x + x).join('')
46 const n = parseInt(h.slice(0, 6), 16)
47 c = [(n >> 16) & 255, (n >> 8) & 255, n & 255, h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1]
48 } else {
49 const m = /rgba?\(([^)]+)\)/.exec(t)
50 if (m) {
51 const p = m[1]!.split(',').map(x => parseFloat(x))
52 c = [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0, p[3] ?? 1]
53 } else if (t === 'transparent') c = [0, 0, 0, 0]
54 else if (t === 'white') c = [255, 255, 255, 1]
55 else if (t === 'black') c = [0, 0, 0, 1]
56 }
57 colorCache.set(s, c)
58 return c
59}
60
61type Composite = 'source-over' | 'lighter' | 'multiply'
62type Clip = { x0: number; y0: number; x1: number; y1: number }
63type Saved = { alpha: number; op: Composite; fill: string | RadialGradient; clip: Clip; sx: number; sy: number; tx: number; ty: number }
64
65export class Canvas {
66 data: Uint8ClampedArray
67 private ctx: Ctx | null = null
68 constructor(
69 public width: number,
70 public height: number,
71 ) {
72 this.data = new Uint8ClampedArray(width * height * 4)
73 }
74 getContext(_kind?: string, _opts?: unknown): Ctx {
75 this.ctx ??= new Ctx(this)
76 return this.ctx
77 }
78}
79
80export class Ctx {
81 globalAlpha = 1
82 globalCompositeOperation: Composite = 'source-over'
83 fillStyle: string | RadialGradient = '#000'
84 imageSmoothingEnabled = false
85 private clipR: Clip
86 private stack: Saved[] = []
87 private path: [number, number][][] = []
88 // the only transform the engine uses: translate then scale by ±1, to mirror a sprite
89 private sx = 1
90 private sy = 1
91 private tx = 0
92 private ty = 0
93
94 constructor(readonly canvas: Canvas) {
95 this.clipR = { x0: 0, y0: 0, x1: canvas.width, y1: canvas.height }
96 }
97
98 save() {
99 this.stack.push({ alpha: this.globalAlpha, op: this.globalCompositeOperation, fill: this.fillStyle, clip: { ...this.clipR }, sx: this.sx, sy: this.sy, tx: this.tx, ty: this.ty })
100 }
101 restore() {
102 const s = this.stack.pop()
103 if (!s) return
104 this.globalAlpha = s.alpha
105 this.globalCompositeOperation = s.op
106 this.fillStyle = s.fill
107 this.clipR = s.clip
108 this.sx = s.sx
109 this.sy = s.sy
110 this.tx = s.tx
111 this.ty = s.ty
112 }
113 translate(x: number, y: number) {
114 this.tx += x * this.sx
115 this.ty += y * this.sy
116 }
117 scale(x: number, y: number) {
118 this.sx *= x
119 this.sy *= y
120 }
121
122 createRadialGradient(_x0: number, _y0: number, _r0: number, x1: number, y1: number, r1: number) {
123 return new RadialGradient(x1, y1, r1)
124 }
125
126 /** One device pixel: the colour blended in with the alpha and composite of the moment. */
127 private put(x: number, y: number, c: RGBA) {
128 const cl = this.clipR
129 if (x < cl.x0 || y < cl.y0 || x >= cl.x1 || y >= cl.y1) return
130 const d = this.canvas.data
131 const i = (y * this.canvas.width + x) * 4
132 const a = c[3] * this.globalAlpha
133 if (a <= 0) return
134 const op = this.globalCompositeOperation
135 if (op === 'lighter') {
136 d[i] = d[i]! + c[0] * a
137 d[i + 1] = d[i + 1]! + c[1] * a
138 d[i + 2] = d[i + 2]! + c[2] * a
139 d[i + 3] = d[i + 3]! + a * 255
140 return
141 }
142 if (op === 'multiply') {
143 d[i] = d[i]! * (1 - a) + ((d[i]! * c[0]) / 255) * a
144 d[i + 1] = d[i + 1]! * (1 - a) + ((d[i + 1]! * c[1]) / 255) * a
145 d[i + 2] = d[i + 2]! * (1 - a) + ((d[i + 2]! * c[2]) / 255) * a
146 return
147 }
148 const da = d[i + 3]! / 255
149 const oa = a + da * (1 - a)
150 if (oa <= 0) return
151 d[i] = (c[0] * a + d[i]! * da * (1 - a)) / oa
152 d[i + 1] = (c[1] * a + d[i + 1]! * da * (1 - a)) / oa
153 d[i + 2] = (c[2] * a + d[i + 2]! * da * (1 - a)) / oa
154 d[i + 3] = oa * 255
155 }
156
157 /** A user-space rectangle as device pixels, through the mirror transform. */
158 private span(x: number, y: number, w: number, h: number): Clip {
159 const ax = this.tx + this.sx * x
160 const bx = this.tx + this.sx * (x + w)
161 const ay = this.ty + this.sy * y
162 const by = this.ty + this.sy * (y + h)
163 return { x0: Math.round(Math.min(ax, bx)), x1: Math.round(Math.max(ax, bx)), y0: Math.round(Math.min(ay, by)), y1: Math.round(Math.max(ay, by)) }
164 }
165
166 fillRect(x: number, y: number, w: number, h: number) {
167 const r = this.span(x, y, w, h)
168 const f = this.fillStyle
169 if (typeof f === 'string') {
170 const c = parseColor(f)
171 for (let py = r.y0; py < r.y1; py++) for (let px = r.x0; px < r.x1; px++) this.put(px, py, c)
172 } else {
173 for (let py = r.y0; py < r.y1; py++) for (let px = r.x0; px < r.x1; px++) this.put(px, py, f.colorAt(px + 0.5, py + 0.5))
174 }
175 }
176
177 clearRect(x: number, y: number, w: number, h: number) {
178 const r = this.span(x, y, w, h)
179 const d = this.canvas.data
180 for (let py = Math.max(0, r.y0); py < Math.min(this.canvas.height, r.y1); py++)
181 for (let px = Math.max(0, r.x0); px < Math.min(this.canvas.width, r.x1); px++) d.fill(0, (py * this.canvas.width + px) * 4, (py * this.canvas.width + px) * 4 + 4)
182 }
183
184 drawImage(src: Canvas, ...a: number[]) {
185 let sx = 0
186 let sy = 0
187 let sw = src.width
188 let sh = src.height
189 let dx: number
190 let dy: number
191 let dw: number
192 let dh: number
193 if (a.length >= 8) [sx, sy, sw, sh, dx, dy, dw, dh] = a as [number, number, number, number, number, number, number, number]
194 else if (a.length >= 4) [dx, dy, dw, dh] = a as [number, number, number, number]
195 else {
196 ;[dx, dy] = a as [number, number]
197 dw = sw
198 dh = sh
199 }
200 const r = this.span(dx, dy, dw, dh)
201 const flipX = this.sx < 0
202 const flipY = this.sy < 0
203 const s = src.data
204 const W = r.x1 - r.x0
205 const H = r.y1 - r.y0
206 if (W <= 0 || H <= 0) return
207 const c: RGBA = [0, 0, 0, 0]
208 for (let j = 0; j < H; j++) {
209 const v = flipY ? H - 1 - j : j
210 const syy = Math.floor(sy + (v * sh) / H)
211 if (syy < 0 || syy >= src.height) continue
212 for (let i = 0; i < W; i++) {
213 const u = flipX ? W - 1 - i : i
214 const sxx = Math.floor(sx + (u * sw) / W)
215 if (sxx < 0 || sxx >= src.width) continue
216 const k = (syy * src.width + sxx) * 4
217 const alpha = s[k + 3]!
218 if (alpha === 0) continue
219 c[0] = s[k]!
220 c[1] = s[k + 1]!
221 c[2] = s[k + 2]!
222 c[3] = alpha / 255
223 this.put(r.x0 + i, r.y0 + j, c)
224 }
225 }
226 }
227
228 getImageData(x: number, y: number, w: number, h: number) {
229 const out = new Uint8ClampedArray(w * h * 4)
230 const d = this.canvas.data
231 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
232 const sx = x + i
233 const sy = y + j
234 if (sx < 0 || sy < 0 || sx >= this.canvas.width || sy >= this.canvas.height) continue
235 out.set(d.subarray((sy * this.canvas.width + sx) * 4, (sy * this.canvas.width + sx) * 4 + 4), (j * w + i) * 4)
236 }
237 return { data: out, width: w, height: h }
238 }
239
240 // ── paths: rectangles (for clips) and polygons (filled) ──
241 beginPath() {
242 this.path = []
243 }
244 moveTo(x: number, y: number) {
245 this.path.push([[x, y]])
246 }
247 lineTo(x: number, y: number) {
248 if (this.path.length === 0) this.path.push([])
249 this.path[this.path.length - 1]!.push([x, y])
250 }
251 closePath() {}
252 rect(x: number, y: number, w: number, h: number) {
253 this.path.push([
254 [x, y],
255 [x + w, y],
256 [x + w, y + h],
257 [x, y + h],
258 ])
259 }
260 clip() {
261 // the engine clips to one rectangle at a time: its bounds, met with the clip already set
262 const pts = this.path.flat()
263 if (!pts.length) return
264 const r = this.span(Math.min(...pts.map(p => p[0])), Math.min(...pts.map(p => p[1])), 0, 0)
265 const r2 = this.span(Math.max(...pts.map(p => p[0])), Math.max(...pts.map(p => p[1])), 0, 0)
266 const cl = this.clipR
267 this.clipR = { x0: Math.max(cl.x0, Math.min(r.x0, r2.x0)), y0: Math.max(cl.y0, Math.min(r.y0, r2.y0)), x1: Math.min(cl.x1, Math.max(r.x0, r2.x0)), y1: Math.min(cl.y1, Math.max(r.y0, r2.y0)) }
268 }
269 fill() {
270 const f = this.fillStyle
271 const solid = typeof f === 'string' ? parseColor(f) : null
272 for (const poly of this.path) {
273 if (poly.length < 3) continue
274 const pts = poly.map(([x, y]) => [this.tx + this.sx * x, this.ty + this.sy * y] as [number, number])
275 const y0 = Math.floor(Math.min(...pts.map(p => p[1])))
276 const y1 = Math.ceil(Math.max(...pts.map(p => p[1])))
277 for (let py = y0; py < y1; py++) {
278 const cy = py + 0.5
279 const xs: number[] = []
280 for (let i = 0; i < pts.length; i++) {
281 const [ax, ay] = pts[i]!
282 const [bx, by] = pts[(i + 1) % pts.length]!
283 if ((ay <= cy && by > cy) || (by <= cy && ay > cy)) xs.push(ax + ((cy - ay) / (by - ay)) * (bx - ax))
284 }
285 xs.sort((p, q) => p - q)
286 for (let k = 0; k + 1 < xs.length; k += 2) {
287 for (let px = Math.round(xs[k]!); px < Math.round(xs[k + 1]!); px++) this.put(px, py, solid ?? (f as RadialGradient).colorAt(px + 0.5, cy))
288 }
289 }
290 }
291 }
292}
293