diff --git a/README.md b/README.md index a9210237..6fd50e32 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ deploy/dokploy.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit | C | See a worker's changes: diff, commit, open a PR | | N | Go to the next worker that's waiting on you | | X | Send a worker home | +| L | Hang a sign over a desk ("Operations", "Code cleanup") | | T / Enter | Chat | | V | Join voice; then hold V to talk | | M | Mute / unmute in voice | diff --git a/docs/controls.md b/docs/controls.md index d604ccc5..eef06daf 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -7,13 +7,14 @@ Back to the [README](../README.md). | W A S D / arrows | Walk (hold Shift to run); on the ladder, W and S climb; in a car, W is the gas, S brakes and reverses, A and D steer | | Space | Jump (you can land on desks, couches and the cars in the garage); in a car, brake | | Mouse drag / wheel | Orbit / zoom the camera | -| E | Interact: hire a worker, open its terminal, read a board, take an issue's note off the board, prompt a board agent, call a meeting in the meeting room, draw on the whiteboard, read the docs at the bookshelf, watch the TV, sit down (or get up), ride the elevator, climb the ladder (or get off it), slide down a fire pole, grab a coffee, take a smoke break, tee off at the golf tee, pet the dog, pick up the basketball (then hold E and let go to shoot), order a drink at the rooftop bar, blow the DJ's air horn, step up to the dart board or the axe lane on the roof (then hold Space and let go to throw), get into one of the cars in the garage (behind the wheel, or beside whoever's driving) or out of it. In the [castle](maps.md#the-castle): sit on the throne, where E is for whoever's first in line (or the Hand of the King, with nobody waiting), and speak to the Hand to send out a new worker | +| E | Interact: hire a worker, open its terminal, read a board, take an issue's note off the board, prompt a board agent, call a meeting in the meeting room, draw on the whiteboard, read the docs at the bookshelf, watch the TV, sit down (or get up), ride the elevator, climb the ladder (or get off it), slide down a fire pole, grab a coffee, take a smoke break, tee off at the golf tee, pet the dog, pick up the basketball (then hold E and let go to shoot), order a drink at the rooftop bar, blow the DJ's air horn, step up to the dart board or the axe lane on the roof (then hold Space and let go to throw), get into one of the cars in the garage (behind the wheel, or beside whoever's driving) or out of it, knock through the north wall past the gong for 2 more desks (at the **🚧 Room to grow** sign). In the [castle](maps.md#the-castle): sit on the throne, where E is for whoever's first in line (or the Hand of the King, with nobody waiting), and speak to the Hand to send out a new worker | | K | On the castle's throne: speak to the Hand of the King, to send out a new worker | | P | Prompt: give a task to a new worker, or to the one at this desk | | C | Changes: the files the worker at this desk changed and their diff; commit, discard or open a PR | | B | Open a shared shell at an empty desk | | R | Resume a sleeping worker (or restart a shell) | | X | Send a worker home (frees the desk; a worker with its own worktree asks what to do with it) | +| L | Hang a big sign over the desk you face (*Operations*, *Code cleanup*), in one of seven colors; again to change it or take it down | | O | Open a pull request for a worker on its own branch, or see the one it has (a worker across several projects gets one in each) | | N | Go to the worker that has waited longest on someone; again for the next one | | F | Hang a picture from the web on a wall (scroll to size it, click to hang it) | diff --git a/docs/features.md b/docs/features.md index 2edbff4f..5966be94 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,7 +20,9 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Minesweeper on the boss's monitor.** In the boss's chair, **E** plays Minesweeper on the monitor: the camera moves in close and the board sits right on the screen. Click to dig, right-click (or Shift-click) to flag, and click a number to dig around it once its mines are flagged. The first click is never a mine. **Stop playing** leaves your game up on the monitor, with its clock paused, until you come back. Walk off to get up. - **Pick your character.** The first time you join, a character select screen lets you choose your name, skin tone, hair style, hair color and shirt, with a spinning preview. No name in mind? Leave it blank (or skip the screen) and you go by the made-up one shown in the box, like *Sunny Otter*, or press **🎲** for another. The office remembers your name and look in that browser, so you only pick once. Change it any time from **βš™οΈ** or by clicking your name under *In the office* (turn on **πŸ‘₯ People** in the **☰** menu). - **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode or Codex, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. -- **Bean bags for the overflow.** There are 16 desks. Once they're all taken, a bean bag comes out on the floor with a low lap desk in front of it. Hire a worker there the same way as at a desk, and the next bean bag comes out, up to 12 around the room. The task queue seats workers on them too. A bean bag stays out while a worker sits on it, and the empty one goes away again as soon as a desk frees up. +- **Signs over the desks.** Press **L** at any desk, empty or not, to hang a big sign from the ceiling over it: *Operations*, *Code cleanup*, *Frontend*, whatever that desk is for. Type up to 32 characters (or pick one of the ideas), choose one of seven colors and press **Enter**. It hangs over the far edge of the desk, facing the chair, so it reads over the shoulder of whoever sits there; while the desk back to back with it has no sign of its own, it says the same on the back, so you can read it from across the room either way. Everyone on the floor sees it, the hint at the desk names it, and it stays up across restarts. **L** again changes it or takes it down. Each floor has its own. +- **Room to grow.** When a floor needs more desks, the north wall past the gong comes down. Walk up to the yellow **🚧 Room to grow** sign on it and press **E**, then **πŸ”¨ Knock through**: the wall goes, and a back office opens behind it with a pair of desks, a rug, a lamp and a window, in a puff of confetti. Knock through once more and it goes back another row, for two more desks (two rows is as far as it goes before the street behind the building). New workers, and the task queue, take its desks once the room's are full, before any bean bag comes out. The sign moves to the back wall, where **E** can also wall the last row back up, once nobody's working there. Each floor is built out on its own, and from outside you see it: the back office sticks out of that storey, on posts down to the lawn. +- **Bean bags for the overflow.** There are 16 desks, and up to 4 more in the back office. Once they're all taken, a bean bag comes out on the floor with a low lap desk in front of it. Hire a worker there the same way as at a desk, and the next bean bag comes out, up to 12 around the room. The task queue seats workers on them too. A bean bag stays out while a worker sits on it, and the empty one goes away again as soon as a desk frees up. - **Shared shells.** Press **B** at an empty desk to open a plain login shell for dev servers, git or tests. It's shared the same way as a Claude terminal. - **Isolated branches.** When you hire with a task, you can tick *own git worktree*. The worker then gets its own `office/` branch under `.agent-office/worktrees/`, so parallel workers never share a checkout. The branch starts from the latest on GitHub, pull requests merged since anyone last pulled included, so a task never undoes the one before it. When you send that worker home you choose whether to keep the worktree and branch, delete the worktree, or delete both, and the office warns you first about uncommitted changes or commits no remote has. `agent-office prune` clears out whatever was kept, once it is safe. Under **βš™οΈ** β†’ *Workers whose pull request merged*, anyone can have them go home by themselves once their pull request merges: as soon as one isn't working or waiting on you and nobody has its terminal open, it leaves and its worktree and branch are deleted. Uncommitted changes, or commits that aren't on GitHub, keep the worktree, as when you send it home. It's off until someone turns it on, and turning it on sends home the ones already merged. - **One task across several projects.** Many systems are split over repositories (an api, an admin, a web app), and one change often needs two or three of them. When you hire a worker in its own worktree, **πŸ—‚οΈ Also work in** lists the building's other floors: pick the projects the task spans (in the hire dialog, or in **πŸ€– Hand to a worker** / **✍️ Ask a worker** from the boards). The worker starts in a workspace folder with a worktree of each project in it, all on the same `office/` branch, and a short brief (as `CLAUDE.md` and `AGENTS.md`) saying which folder is which. It works across them, tests them together and commits in each. **O** then opens a pull request in every project it committed to, and each description lists all of them, so they're reviewed and merged as one change. The issue the task came from is closed by the home floor's PR and linked from the others. Press **O** again for each project's PR, with a button to open the ones still missing. The Changes window has a tab per project, sending the worker home checks and deletes every worktree, and *Workers whose pull request merged* waits until the PRs in every project have merged. The brief can be rewritten in βš™οΈ Settings like the office's other prompts. diff --git a/docs/maps.md b/docs/maps.md index 89c2a6a9..c4e6b04f 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -38,7 +38,7 @@ The easy way is to start from the castle and change only what you want. This one To change the lists (move a pillar, resize the hall and everything in it), start from a copy of the whole castle instead: [`docs/maps/castle.json`](maps/castle.json) is it, as a map of your own called *My castle*. Copy it into the folder and it's in Settings; change what you like from there. -Or write one from nothing. This is about the least a map can be: a hall, a door, tables to seat 28, the board agents, a meeting table and the four boards. Everything else (the throne, the line, the herald, the props) is optional: +Or write one from nothing. This is about the least a map can be: a hall, a door, tables to seat 32, the board agents, a meeting table and the four boards. Everything else (the throne, the line, the herald, the props) is optional: ```json { @@ -50,7 +50,7 @@ Or write one from nothing. This is about the least a map can be: a hall, a door, "door": { "x": 0, "z": 13.6 }, "tables": [ { "name": "West table", "x": -5, "z": 0, "length": 12, "seats": 5 }, - { "name": "Middle table", "x": 0, "z": -2, "length": 10, "seats": 4 }, + { "name": "Middle table", "x": 0, "z": -2, "length": 10, "seats": 6 }, { "name": "East table", "x": 5, "z": 0, "length": 12, "seats": 5 } ], "stations": { @@ -101,7 +101,7 @@ A map that won't load (bad JSON, something outside the hall, too few seats, a pr ### Seats -Every map has the same seats, by id, so that the server, the task queue, meetings and saved workers work on any of them: 16 regular seats (`desk-1` to `desk-16`), 12 more that come out once those are taken (`beanbag-1` to `beanbag-12`), the three board agents' places and the five meeting chairs. The tables' seats are handed out in order: first the side of every table toward the middle of the hall (its inner side), table by table in the order they're listed, then their other sides the same way. Along a table they go from one end to the other, north to south for one that runs along z. So `desk-1` is the first seat on the first table's inner side, and a map's tables must seat at least 28 between them; any seats past that are just bench. +Every map has the same seats, by id, so that the server, the task queue, meetings and saved workers work on any of them: 16 regular seats (`desk-1` to `desk-16`), 4 for the office's back office (`desk-17` to `desk-20`, only sat at once that floor's back office is built out that far), 12 more that come out once those are taken (`beanbag-1` to `beanbag-12`), the three board agents' places and the five meeting chairs. The tables' seats are handed out in order: first the side of every table toward the middle of the hall (its inner side), table by table in the order they're listed, then their other sides the same way. Along a table they go from one end to the other, north to south for one that runs along z. So `desk-1` is the first seat on the first table's inner side, and a map's tables must seat at least 32 between them; any seats past that are just bench. ### Props diff --git a/src/client/interaction.ts b/src/client/interaction.ts index 879b532b..da7b3eb5 100644 --- a/src/client/interaction.ts +++ b/src/client/interaction.ts @@ -1,8 +1,9 @@ +import { canLabel } from '../shared/floorplan'; import type { GhIssue, WorkerInfo } from '../shared/protocol'; import { isAsleep } from '../shared/status'; import type { Interactable } from './world/office'; -export const DESK_KEYS = { KeyE: 'E', KeyP: 'P', KeyR: 'R', KeyX: 'X', KeyB: 'B', KeyC: 'C', KeyO: 'O' } as const; +export const DESK_KEYS = { KeyE: 'E', KeyP: 'P', KeyR: 'R', KeyX: 'X', KeyB: 'B', KeyC: 'C', KeyO: 'O', KeyL: 'L' } as const; export type DeskKey = (typeof DESK_KEYS)[keyof typeof DESK_KEYS]; export interface InteractionState { @@ -23,6 +24,8 @@ export function interactionAvailable(it: Interactable | null, key: DeskKey, stat if (it.kind === 'desk') { if (!it.deskId) return false; + // Any desk can have a sign hung over it, whoever sits there (not a bean bag or a meeting chair). + if (key === 'L') return canLabel(it.deskId); if (!state.worker && state.room) return key === 'E'; if (key === 'B') return !state.worker; if (key === 'P' || key === 'E') return true; diff --git a/src/client/lite.ts b/src/client/lite.ts index 759f26b4..b5adfa1f 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -234,7 +234,8 @@ function hire(deskId: string, prompt: string, worktree: boolean, provider?: Agen function sendToWorker(title: string, text: { context?: string; initial?: string } = {}) { if (!store.project) return toast('Pick a floor first', 'warn'); - const desk = nextFreeSeat((id) => !!store.workerAtDesk(id))?.id; + // The back office's desks too, as far as the floor's built out (see WING). + const desk = nextFreeSeat((id) => !!store.workerAtDesk(id), store.floorPlan.wing)?.id; const awake = [...store.workers.values()].filter((w) => w.kind === 'agent' && !isAsleep(w.status)); if (!desk && !awake.length) return toast('Every desk and bean bag is taken β€” send a worker home first', 'warn'); openAsk({ diff --git a/src/client/main.ts b/src/client/main.ts index 1cf17bf3..f3e57a9d 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -2,8 +2,9 @@ import './style.css'; import * as THREE from 'three'; import { OutlineEffect } from 'three/examples/jsm/effects/OutlineEffect.js'; import { sameLook } from '../shared/avatar'; -import { BALCONY, ELEVATOR, ELEVATOR_CAR, FLOOR, GOLF_HOLE, LADDER, POLE, POLES, SLAB, STATION_AGENT, STOREY, WALL_HEIGHT, WALL_T, beanbagsOut, deskSeat, inElevator, roofDrop, seatPlace, streetBelow, vacantSeats, type DeskDef, type SeatDef, type SeatPlace, type StationKind } from '../shared/layout'; +import { BALCONY, DESK_BY_ID, ELEVATOR, ELEVATOR_CAR, FLOOR, GOLF_HOLE, LADDER, POLE, POLES, SLAB, STATION_AGENT, STOREY, WALL_HEIGHT, WALL_T, WING, beanbagsOut, deskBuilt, deskSeat, inElevator, inWing, roofDrop, seatPlace, streetBelow, vacantSeats, wingMinZ, wingRowZ, type DeskDef, type SeatDef, type SeatPlace, type StationKind } from '../shared/layout'; import { OFFICE_PLAN, seatOn, type MapPlan } from '../shared/maps'; +import { canLabel } from '../shared/floorplan'; import { floorPalette } from '../shared/floors'; import type { AgentEffort, AgentProvider, CarriedIssue, FloorInfo, GhIssue, GongWhy, PeerInfo, WorkerInfo, WorkerTask } from '../shared/protocol'; import { MEETING_PATTERNS, meetingStage } from '../shared/meetings'; @@ -94,6 +95,7 @@ import { onModelsProgress, preloadModels } from './world/models'; import { loadingScreen } from './ui/loading'; import { SlowFrames } from './framerate'; import { offerLite, touchOnly } from './ui/litesuggest'; +import { openDeskLabel, openExpand } from './ui/floorplan'; // The loading screen stays up until there's an office to see (see boot and whoami at the end). const loading = loadingScreen(onModelsProgress); @@ -144,7 +146,7 @@ scene.add(office.group); * The building's map as it's built (see shared/maps and world/world.ts): the office, or a map of * its own (the castle). Only one is in the scene at a time, like the office and the rooftop. */ -const theOffice = officeWorld(office, () => office.stack.state.index > 0); +const theOffice = officeWorld(office, () => office.stack.state.index > 0, () => officeWing()); let world: World = theOffice; /** Whether the building's on the office's own map, with everything that has (the elevator, the balcony, the lounge…). */ const inOffice = () => world === theOffice; @@ -323,6 +325,7 @@ let roof: Rooftop | null = null; function theRoof(): Rooftop { if (!roof) { roof = buildRooftop(office.night, roofFloors()); + roof.setFloors(roofFloors(), floorWings(builtFloors())); roof.group.visible = false; roof.games.onDrop = (at) => sound.toss('drop', at); scene.add(roof.group); @@ -338,7 +341,7 @@ function roofFloors(): number { function syncRoof() { if (!roof) return; const floors = roofFloors(); - roof.setFloors(floors); + roof.setFloors(floors, floorWings(builtFloors())); if (upTop) sky.setRoof(true, roofDrop(floors)); } store.on('floors', syncRoof); @@ -897,13 +900,21 @@ function syncStack() { const up = store.floor === ROOF ? undefined : floors[index + 1]?.name; const down = index > 0 ? floors[index - 1]?.name : undefined; const count = index < 0 ? 1 : floors.length; + const wings = floorWings(floors); // A map of its own is a hall on the ground: nothing under its floor to fall to. player.street = inOffice() ? streetBelow(index) : 0; const s = office.stack.state; - if (s.index === Math.max(0, index) && s.count === count && s.up === up && s.down === down) return; - office.stack.set({ index: Math.max(0, index), count, up, down }); - office.setLevel(Math.max(0, index), count); + const same = s.index === Math.max(0, index) && s.count === count && s.up === up && s.down === down; + if (same && wings.join() === wingsShown) return; + wingsShown = wings.join(); + if (!same) office.stack.set({ index: Math.max(0, index), count, up, down }); + office.setLevel(Math.max(0, index), count, wings); } +/** How far each floor's back office goes, for the building's outside (the one you're on as you see it). */ +function floorWings(floors: FloorInfo[]): number[] { + return floors.map((f) => (f.id === store.floor ? store.floorPlan.wing : (f.wing ?? 0))); +} +let wingsShown = ''; store.on('floors', syncStack); function showMyProfile(p: Profile) { @@ -1003,7 +1014,7 @@ net.onMessage((msg) => { // A hall of its own has nothing outside it to come back to (and its walls may have moved since). const b = plan().bounds; const inRoom = inOffice() || (mine.x > b.minX + 0.3 && mine.x < b.maxX - 0.3 && mine.z > b.minZ + 0.3 && mine.z < b.maxZ - 0.3); - if (sameMap && inRoom && !(inOffice() && inElevator(mine.x, mine.z)) && player.fits(mine.x, mine.z, mine.y)) { + if (sameMap && inRoom && !(inOffice() && (inElevator(mine.x, mine.z) || pastTheWing(mine, officeWing()))) && player.fits(mine.x, mine.z, mine.y)) { placeAt(mine); arrive('back'); } else { @@ -1643,7 +1654,7 @@ function applyMap() { paintFloor(); renderProject(); dressUp(); - syncStack(); + syncPlan(); // They were there already: nobody walks in (and on a welcome, the floor's workers that come next weren't either). const already = seatedAlready; seatedAlready = true; @@ -1851,7 +1862,7 @@ function walkTick(now: number) { if (now < walkingTo.replanAt) return; walkingTo.replanAt = now + 800; // Round the office's rooms and up its stairs; on a map of its own, round what's in the way on its floor. - player.walkPath(inOffice() ? wayTo(player.pos, at) : world.nav.route([player.pos.x, player.pos.z], [at.x, at.z]).slice(1).map(([x, z]) => ({ x, z }))); + player.walkPath(inOffice() ? wayTo(player.pos, at, officeWing()) : world.nav.route([player.pos.x, player.pos.z], [at.x, at.z]).slice(1).map(([x, z]) => ({ x, z }))); } player.onPathEnd = (why) => { @@ -2023,13 +2034,69 @@ function meetingCard(w: WorkerInfo): WorkerTask | undefined { function arrangeSeats() { // Someone sent home still counts until they get up, so a bean bag stays out under them. const free = vacantSeats(store.workers.values(), (id) => departures.seated(id)); - for (const [id, desk] of world.desks) desk.vacancy.visible = free.has(id); - const appeared = world.setBeanbags(beanbagsOut((id) => !free.has(id))); + for (const [id, desk] of world.desks) desk.vacancy.visible = free.has(id) && seatBuilt(id); + const appeared = world.setBeanbags(beanbagsOut((id) => !free.has(id), store.floorPlan.wing)); // One came out right where you're standing (on the office floor, not down in the garage): you end up on top of it. const p = player.pos; for (const c of appeared) if (p.y > -0.1 && p.y < c.top && p.x > c.minX - 0.3 && p.x < c.maxX + 0.3 && p.z > c.minZ - 0.3 && p.z < c.maxZ + 0.3) p.y = c.top; } store.on('workers', syncWorkers); + +/** The floor plan last shown, to tell someone knocking through from arriving on a floor already built out (or back on the office's map). */ +let shownPlan: { floor: string | null; map: string; wing: number } = { floor: null, map: OFFICE_PLAN.id, wing: 0 }; +/** + * How many rows the office's back office is built out where you are: the floor's plan in the office, + * none on the roof or on a map of its own (its hall is its own shape). + */ +function officeWing(): number { + return inOffice() && store.floor !== ROOF ? store.floorPlan.wing : 0; +} +/** + * Whether seat `id` is there to sit at on this floor: a back office desk only once the floor's built + * out that far, on whichever map (the castle names seats for them too, so a worker hired there has + * one on every map). + */ +function seatBuilt(id: string): boolean { + const d = DESK_BY_ID.get(id); + return !d || deskBuilt(d, store.floorPlan.wing); +} +/** + * Standing where a back office would be, further back than this floor's goes (`level` rows): a row + * walled up round you, or a floor you switched to that isn't built out as far as the one you left. + */ +function pastTheWing(p: { x: number; y: number; z: number }, level: number): boolean { + return p.y > -1 && p.y < 3 && p.x > WING.minX - 0.3 && p.x < WING.maxX + 0.3 && p.z < FLOOR.minZ && !inWing(p.x, p.z, level); +} +/** + * The floor's back office, as far as it's built out, and the signs over its desks. Everything that + * finds its way round the floor learns how far it goes; a row knocked through goes up in a puff of + * dust, and anyone standing past where it goes now steps back in first. + */ +function syncPlan() { + const fp = store.floorPlan; + const level = officeWing(); + const was = shownPlan; + shownPlan = { floor: store.floor, map: plan().id, wing: level }; + const p = player.pos; + if (inOffice() && pastTheWing(p, level)) { + // Out to the side aisle of what's left, or back into the room. + const side = p.x < (WING.minX + WING.maxX) / 2 ? WING.minX + 0.6 : WING.maxX - 0.6; + p.set(level ? side : p.x, 0, level ? wingMinZ(level) + 0.6 : FLOOR.minZ + 1.6); + } + office.setWing(level); + office.signs.set(fp.labels, (d) => deskBuilt(d, level)); + player.wing = sound.wing = level; + sky.setWing(level); + syncStack(); + syncRoof(); + arrangeSeats(); + if (was.floor === store.floor && was.map === shownPlan.map && level > was.wing) { + const at = { x: (WING.minX + WING.maxX) / 2, y: 1.2, z: wingRowZ(level) }; + confetti.burst(at.x, 2.4, at.z, 140, 0.8); + sound.toss('thunk', at); + } +} +store.on('floorPlan', syncPlan); // A worker at the meeting table shows its role and round over its head (see meetingCard). store.on('meeting', syncWorkers); // A worker's bubble shows whether it has a pull request open (green) or merged (purple: send it home). @@ -2072,6 +2139,7 @@ function freeDesk(): string | null { let best: string | null = null; let bestD = Infinity; for (const d of plan().desks) { + if (!seatBuilt(d.id)) continue; if (store.workerAtDesk(d.id)) continue; const dist = Math.hypot(d.x - player.pos.x, d.z - player.pos.z); if (dist < bestD) { @@ -2082,9 +2150,9 @@ function freeDesk(): string | null { return best ?? firstFreeSeat() ?? null; } -/** The first seat nobody's at, in the map's order: the desks, then the overflow seats. */ +/** The first seat nobody's at, in the map's order: the desks (as far as the floor's built out), then the overflow seats. */ function firstFreeSeat(): string | undefined { - return [...plan().desks, ...plan().overflow].find((d) => !store.workerAtDesk(d.id))?.id; + return [...plan().desks, ...plan().overflow].find((d) => seatBuilt(d.id) && !store.workerAtDesk(d.id))?.id; } let askedToNotify = false; @@ -2534,6 +2602,7 @@ function interact(target: Interactable | null, key: DeskKey, note = aimedNote) { if (target.kind !== 'issues') note = null; if (key === 'E' && carrying && dropCard(target, carrying, note)) return; if (target.kind === 'desk' && target.deskId) { + if (key === 'L') return openDeskLabel(net, target.deskId); const w = store.workerAtDesk(target.deskId); // Nobody is hired at the meeting table: a meeting seats its own workers there. if (!w && plan().byId.get(target.deskId)?.room) return key === 'E' ? showMeeting() : undefined; @@ -2589,6 +2658,7 @@ function interact(target: Interactable | null, key: DeskKey, note = aimedNote) { else if (target.kind === 'ball') takeBall(); else if (target.kind === 'telescope') telescope.enter(); else if (target.kind === 'car' && target.car !== undefined) getIn(target.car); + else if (target.kind === 'expand') openExpand(net); else if (target.kind === 'herald') hireFromHerald(); } @@ -3429,6 +3499,11 @@ function hintFor(it: Interactable): Hint { if (!beside) return { k, parts: [title(`🏎️ ${def.name}`), aside(`${at} is driving`), key('E', 'Hop in')] }; return { k, parts: [title(`🏎️ ${def.name}`), aside(`${at} and ${beside} Β· full`)] }; } + case 'expand': { + const level = store.floorPlan.wing; + if (level >= WING.rows) return { k: 'full', parts: [title('🏒 Back office'), aside('built all the way out'), key('E', 'Wall a row up')] }; + return { k: String(level), parts: [title(level ? '🚧 Room to grow' : '🚧 Room to grow through the wall'), aside(level ? `${level} of ${WING.rows} rows built` : 'the office can get bigger here'), key('E', level ? 'Another row: 2 more desks' : 'Knock through: 2 more desks')] }; + } } } @@ -3468,14 +3543,18 @@ function carryHint(card: CarriedIssue, it: Interactable | null): Hint { function deskHint(deskId: string): Hint { const w = store.workerAtDesk(deskId); if (!w && plan().byId.get(deskId)?.room) return { k: 'room', parts: [h('span.title', {}, `🀝 ${plan().byId.get(deskId)!.label} Β· free`), key('E', 'Call a meeting')] }; + // The sign over it, if it has one, and L to hang one (or change it). + const sign = store.floorPlan.labels[deskId]?.text; + const labelKey = canLabel(deskId) ? key('L', sign ? 'Sign' : 'Label') : ''; + const deskName = `${sign ? `πŸͺ§ ${sign} Β· ` : ''}${plan().byId.get(deskId)!.label}`; if (!w) { const paused = hiringPaused(); const m = store.machine; const full = officeFull(m); return { - k: `${paused}|${full}|${m.workers}|${m.limit}|${!!m.pressure}`, + k: `${paused}|${full}|${m.workers}|${m.limit}|${!!m.pressure}|${sign}`, parts: [ - h('span.title', {}, `${plan().byId.get(deskId)!.label} Β· empty`), + h('span.title', {}, `${deskName} Β· empty`), ...(full ? [h('span.cost', {}, `🚫 Office full Β· ${m.workers} of ${m.limit} workers`)] : [ @@ -3483,6 +3562,7 @@ function deskHint(deskId: string): Hint { ...(paused ? [h('span.cost', {}, 'πŸ’Έ Budget spent β€” hiring resumes tomorrow')] : [key('E', 'Hire a worker'), key('P', 'Hire with a task')]), key('B', 'Shell'), ]), + labelKey, ], }; } @@ -3491,9 +3571,9 @@ function deskHint(deskId: string): Hint { const spent = w.kind === 'agent' && w.usage ? usageLabel(w.usage, workerProvider) : ''; const shell = w.kind === 'shell'; return { - k: w.status + w.id + (w.pr?.number ?? '') + (w.repos?.map((r) => r.pr?.number ?? '-').join() ?? '') + (w.prOpening ? '!' : '') + doing + spent, + k: w.status + w.id + (w.pr?.number ?? '') + (w.repos?.map((r) => r.pr?.number ?? '-').join() ?? '') + (w.prOpening ? '!' : '') + doing + spent + (sign ?? ''), parts: [ - h('span.title', {}, `${w.name} Β· ${STATUS_LABEL[w.status]}`), + h('span.title', {}, `${sign ? `πŸͺ§ ${sign} Β· ` : ''}${w.name} Β· ${STATUS_LABEL[w.status]}`), doing ? aside(doing) : '', spent ? h('span.cost', { title: usageTitle(w.usage!, workerProvider) }, spent) : '', key('E', 'Open terminal'), @@ -3501,6 +3581,7 @@ function deskHint(deskId: string): Hint { isAsleep(w.status) ? key('R', shell ? 'Restart' : 'Resume') : key('P', shell ? 'Run command' : 'Prompt'), w.repos?.length ? reposKey(w) : w.pr ? key('O', `PR #${w.pr.number}`) : w.prOpening ? aside('⏳ Opening PR…') : prReady(w) ? key('O', 'Open PR') : '', key('X', 'Send home'), + labelKey, ], }; } @@ -3761,8 +3842,8 @@ function officeKey(e: KeyboardEvent): boolean { const deskKey = DESK_KEYS[e.code as keyof typeof DESK_KEYS]; if (deskKey) { const handled = use(target, deskKey); - // P opens a text box, which the key mustn't land in. - if (handled && deskKey === 'P') e.preventDefault(); + // P and L open a text box, which the key mustn't land in. + if (handled && (deskKey === 'P' || deskKey === 'L')) e.preventDefault(); return handled; } switch (e.code) { @@ -3916,7 +3997,7 @@ document.addEventListener('pointerlockchange', () => { const raycaster = new THREE.Raycaster(); const CROSSHAIR = new THREE.Vector2(0, 0); /** How close (meters from your eyes) you must be to use each kind of thing. */ -const REACH: Record = { desk: 4.5, station: 4.5, coffee: 3, issues: 9, pulls: 9, services: 9, queue: 9, tv: 10, decor: 9, smoke: 3, elevator: 4.5, gong: 3.5, dog: 3.2, jukebox: 4, seat: 3, whiteboard: 7, cabinet: 4, ladder: 3, pole: 4, meeting: 7, bar: 3.5, dj: 6, golf: 3.5, ball: 3.2, bookshelf: 4, darts: 4, axe: 5.5, telescope: 3.5, car: 4, herald: 5 }; +const REACH: Record = { desk: 4.5, station: 4.5, coffee: 3, issues: 9, pulls: 9, services: 9, queue: 9, tv: 10, decor: 9, smoke: 3, elevator: 4.5, gong: 3.5, dog: 3.2, jukebox: 4, seat: 3, whiteboard: 7, cabinet: 4, ladder: 3, pole: 4, meeting: 7, bar: 3.5, dj: 6, golf: 3.5, ball: 3.2, bookshelf: 4, darts: 4, axe: 5.5, telescope: 3.5, car: 4, expand: 8, herald: 5 }; const eye = new THREE.Vector3(); /** What the ray through `ndc` lands on first, whether it is within reach (plus `slack` meters), and where it hit. */ diff --git a/src/client/player.ts b/src/client/player.ts index cd56e38c..54ec2005 100644 --- a/src/client/player.ts +++ b/src/client/player.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { FLOOR, SLAB, STREET_Y, WALL_T, type SeatPlace } from '../shared/layout'; +import { FLOOR, SLAB, STREET_Y, WALL_T, WING, inWing, wingMinZ, type SeatPlace } from '../shared/layout'; import type { ViewMode } from './state'; import type { Collider } from './world/office'; @@ -54,6 +54,8 @@ export class PlayerController { * with no street or garage under it to see from. */ room: { minX: number; maxX: number; minZ: number; maxZ: number; wall: number; enclosed: boolean } = { ...FLOOR, wall: WALL_T, enclosed: false }; + /** How many rows the floor's back office is built out (see WING): the camera keeps inside it too. */ + wing = 0; private jitterT = 0; /** How drunk you are (see booze.ts): the view rolls and sways, and you stagger as you walk. */ drunk = 0; @@ -524,8 +526,13 @@ export class PlayerController { // On the ladder or a pole you can be down in a shaft under the floor, but you're still indoors. const rigged = !!this.rig && !this.riding; const under = this.pos.x > R.minX && this.pos.x < R.maxX && this.pos.z > R.minZ && this.pos.z < R.maxZ; - const indoors = (rigged || this.pos.y > -SLAB - 0.5) && under; - if (indoors) { + // In the office's back office, between its walls, and out through where the north wall was into the room. + const back = !R.enclosed && this.pos.y > -SLAB - 0.5 && inWing(this.pos.x, this.pos.z, this.wing); + const indoors = ((rigged || this.pos.y > -SLAB - 0.5) && under) || back; + if (back) { + cam.x = THREE.MathUtils.clamp(cam.x, WING.minX + m, WING.maxX - m); + cam.z = THREE.MathUtils.clamp(cam.z, wingMinZ(this.wing) + m, FLOOR.maxZ - m); + } else if (indoors) { cam.x = THREE.MathUtils.clamp(cam.x, R.minX + m, R.maxX - m); cam.z = THREE.MathUtils.clamp(cam.z, R.minZ + m, R.maxZ - m); } diff --git a/src/client/sound.ts b/src/client/sound.ts index 5800e0ad..2c920695 100644 --- a/src/client/sound.ts +++ b/src/client/sound.ts @@ -8,7 +8,7 @@ * Everything goes through one master gain that Settings turns down or mutes. Voice chat doesn't, and * the jukebox has a volume of its own. */ -import { CABINET, DESKS, DJ_BOOTH, FLOOR, GONG, JUKEBOX, WINDOWS as OPENINGS } from '../shared/layout'; +import { CABINET, DESKS, DJ_BOOTH, FLOOR, GONG, JUKEBOX, WINDOWS as OPENINGS, inWing } from '../shared/layout'; import type { GongWhy } from '../shared/protocol'; import { STREAM } from '../shared/jukebox'; import { TunePlayer } from './music'; @@ -108,6 +108,8 @@ export class OfficeSound { * with nothing of the office's in it (no phones, no fridge). Null in the office. */ private hall: { bounds: { minX: number; maxX: number; minZ: number; maxZ: number }; gong: Pos | null; windows: Pos[] } | null = null; + /** How many rows the floor's back office is built out: in there you're indoors too. */ + wing = 0; private analyser!: AnalyserNode; private buf!: Buffers; private volume = 0.7; @@ -1189,7 +1191,7 @@ export class OfficeSound { return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ ? 'office' : 'out'; } const under = (m: number) => x > FLOOR.minX - m && x < FLOOR.maxX + m && z > FLOOR.minZ - m && z < FLOOR.maxZ + m; - if (under(0) && y > -0.5) return 'office'; + if ((under(0) || inWing(x, z, this.wing)) && y > -0.5) return 'office'; return under(0.3) ? 'garage' : 'out'; } diff --git a/src/client/state.ts b/src/client/state.ts index 0d6cd454..ac88a214 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -2,6 +2,7 @@ import type { AccountsState, ChatLine, FloorInfo, FloorView, GhIssue, GhPull, Gh import type { ScreenState } from './world/laptop'; import { randomLook, sanitizeLook, type Look } from '../shared/avatar'; import type { Decoration } from '../shared/decor'; +import { EMPTY_PLAN, type FloorPlan } from '../shared/floorplan'; import { newer, type WbElement } from '../shared/whiteboard'; import type { DogState } from '../shared/dog'; import { JUKEBOX_TUNES, type JukeboxState } from '../shared/jukebox'; @@ -10,7 +11,7 @@ import type { BallState } from '../shared/hoop'; import { parked, type CarSeat, type CarState } from '../shared/garage'; import { OFFICE_MAP, planOf, type MapPlan } from '../shared/maps'; -export type Topic = 'peers' | 'workers' | 'issues' | 'pulls' | 'chat' | 'project' | 'screens' | 'team' | 'upgrade' | 'services' | 'decor' | 'usage' | 'limits' | 'queue' | 'me' | 'accounts' | 'signins' | 'notify' | 'machine' | 'floors' | 'floor' | 'projectsDir' | 'repos' | 'dog' | 'jukebox' | 'sky' | 'theme' | 'map' | 'leaveOnMerge' | 'whiteboard' | 'drawing' | 'cabinet' | 'cabinetFrame' | 'meeting' | 'prompts' | 'ball' | 'cars'; +export type Topic = 'peers' | 'workers' | 'issues' | 'pulls' | 'chat' | 'project' | 'screens' | 'team' | 'upgrade' | 'services' | 'decor' | 'floorPlan' | 'usage' | 'limits' | 'queue' | 'me' | 'accounts' | 'signins' | 'notify' | 'machine' | 'floors' | 'floor' | 'projectsDir' | 'repos' | 'dog' | 'jukebox' | 'sky' | 'theme' | 'map' | 'leaveOnMerge' | 'whiteboard' | 'drawing' | 'cabinet' | 'cabinetFrame' | 'meeting' | 'prompts' | 'ball' | 'cars'; const zeroUsage = (): Usage => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, calls: 0 }); @@ -190,6 +191,8 @@ class Store { services: ServicesState = { items: [], port: 4600 }; /** Pictures on the walls. */ decor: Decoration[] = []; + /** The signs over the desks, and how far the back office is built out (not the map's plan: see plan()). */ + floorPlan: FloorPlan = EMPTY_PLAN; /** What the lounge jukebox is playing; `since` is when the track started, on performance.now()'s clock. */ jukebox: JukeboxState & { since: number } = { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0, since: 0 }; /** The office's clock minus performance.now(), from the quickest ping (see 'pong'); for the jukebox. */ @@ -306,6 +309,7 @@ class Store { this.queue = v.queue; this.meeting = v.meeting; this.decor = v.decor; + this.floorPlan = v.plan ?? EMPTY_PLAN; this.services = v.services; this.whiteboard = new Map(v.whiteboard.elements.map((e) => [e.id, e])); this.drawing = v.whiteboard.people; @@ -315,7 +319,7 @@ class Store { this.setJukebox(v.jukebox); this.ball = v.ball ?? {}; this.setCars(v.cars ?? parked()); - for (const t of ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'meeting', 'decor', 'services', 'dog', 'jukebox', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'ball', 'cars'] as Topic[]) this.emit(t); + for (const t of ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'meeting', 'decor', 'floorPlan', 'services', 'dog', 'jukebox', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'ball', 'cars'] as Topic[]) this.emit(t); } private setCars(cars: CarState[]) { @@ -458,6 +462,10 @@ class Store { this.decor = msg.items; this.emit('decor'); break; + case 'plan': + this.floorPlan = msg.plan; + this.emit('floorPlan'); + break; case 'jukebox': this.setJukebox(msg.state); this.emit('jukebox'); diff --git a/src/client/style.css b/src/client/style.css index b55e7192..9920c31b 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -659,6 +659,19 @@ body.telescope-active #hud { visibility: hidden; } @media (prefers-reduced-motion: reduce) { .jb-disc.spin { animation: none; } } .jb-icon { flex: none; font-size: 18px; } +/* Signs over the desks, and the back office */ +.modal.desklabel { width: min(520px, 100%); } +.sign-preview { display: grid; place-items: center; min-height: 92px; padding: 12px 18px; border: 3px solid var(--ink); border-radius: 14px; box-shadow: inset 0 0 0 6px rgba(255, 255, 255, .18); font-size: 34px; font-weight: 900; text-align: center; line-height: 1.1; overflow-wrap: anywhere; } +.sign-preview.placeholder { opacity: .45; } +.label-ideas { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.label-ideas .btn { padding: 3px 10px; font-size: 13px; } +.desklabel .swatches { margin-top: 0; } +.modal.expand { width: min(500px, 100%); } +.expand-status p { margin: 0 0 10px; font-weight: 700; } +.expand-rows { display: flex; gap: 8px; margin: 4px 0 12px; } +.expand-rows span { flex: 1; text-align: center; padding: 8px 0; border: 3px dashed var(--muted); border-radius: 12px; font-weight: 800; color: var(--muted); } +.expand-rows span.on { border-style: solid; border-color: var(--ink); background: var(--paper-2); color: var(--ink); } + /* Pictures on the walls */ .modal.hang { width: min(560px, 100%); } .hang .seg { gap: 6px; } diff --git a/src/client/ui/floorplan.ts b/src/client/ui/floorplan.ts new file mode 100644 index 00000000..e58e1b63 --- /dev/null +++ b/src/client/ui/floorplan.ts @@ -0,0 +1,154 @@ +import { LABEL_IDEAS, MAX_LABEL, SIGN_COLORS, cleanLabel, rowDesks, signColor, signInk } from '../../shared/floorplan'; +import { DESK_BY_ID, WING } from '../../shared/layout'; +import type { Net } from '../net'; +import { store } from '../state'; +import { h, openModal } from './dom'; + +const COLOR_KEY = 'agent-office.signColor'; +function lastColor(): string { + try { + return signColor(localStorage.getItem(COLOR_KEY)); + } catch { + return SIGN_COLORS[0].color; + } +} + +/** L at a desk: what the sign over it says (and its color), or take it down. */ +export function openDeskLabel(net: Net, deskId: string) { + const desk = DESK_BY_ID.get(deskId); + if (!desk) return; + const old = store.floorPlan.labels[deskId]; + let color = old?.color ?? lastColor(); + const input = h('input', { type: 'text', maxlength: MAX_LABEL, placeholder: 'Operations', 'aria-label': 'Sign', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; + input.value = old?.text ?? ''; + const preview = h('div.sign-preview', { 'aria-hidden': 'true' }); + const swatches = h('div.swatches', { role: 'radiogroup', 'aria-label': 'Color' }); + const ideas = h('div.label-ideas'); + const submit = h('button.btn.primary', { type: 'submit' }, old ? 'Save' : 'πŸͺ§ Hang it') as HTMLButtonElement; + const remove = old ? (h('button.btn.danger', { type: 'button' }, 'Take it down') as HTMLButtonElement) : null; + const cancel = h('button.btn', { type: 'button' }, 'Cancel'); + const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close' }, 'βœ•'); + const form = h( + 'form.modal.desklabel', + { role: 'dialog', 'aria-label': `Sign over ${desk.label}` }, + h('header', {}, h('h2', {}, `πŸͺ§ Sign over ${desk.label}`), close), + h('div.body', {}, preview, h('label', { style: 'margin-top:14px' }, 'What it says'), input, ideas, h('label', { style: 'margin-top:14px' }, 'Color'), swatches), + h('footer', {}, h('span.grow', {}, 'It hangs from the ceiling over the desk, for everyone on this floor.'), remove, cancel, submit), + ) as HTMLFormElement; + form.noValidate = true; + + const render = () => { + const text = cleanLabel(input.value); + preview.style.background = color; + preview.style.color = signInk(color); + preview.textContent = text || 'Operations'; + preview.classList.toggle('placeholder', !text); + submit.disabled = !text && !old; + submit.textContent = !text && old ? 'Take it down' : old ? 'Save' : 'πŸͺ§ Hang it'; + for (const b of swatches.children) (b as HTMLElement).classList.toggle('sel', (b as HTMLElement).dataset.color === color); + }; + swatches.replaceChildren( + ...SIGN_COLORS.map((c) => + h('button.swatch', { + type: 'button', + role: 'radio', + title: c.name, + 'aria-label': c.name, + 'data-color': c.color, + style: `background:${c.color}`, + onclick: () => { + color = c.color; + try { + localStorage.setItem(COLOR_KEY, color); + } catch { + // private mode: the color just isn't remembered + } + render(); + }, + }), + ), + ); + ideas.replaceChildren( + ...LABEL_IDEAS.map((idea) => + h( + 'button.btn', + { + type: 'button', + onclick: () => { + input.value = idea; + render(); + input.focus(); + }, + }, + idea, + ), + ), + ); + input.addEventListener('input', render); + + const modal = openModal(form, { doing: `πŸͺ§ labeling ${desk.label}` }); + const send = (text: string) => { + net.send({ t: 'desk.label', deskId, text, color }); + modal.close(); + }; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const text = cleanLabel(input.value); + if (text || old) send(text); + }); + remove?.addEventListener('click', () => send('')); + cancel.addEventListener('click', () => modal.close()); + close.addEventListener('click', () => modal.close()); + render(); + input.focus(); + input.select(); +} + +/** E at the sign in the back office (or on the wall where it goes through): build it out, or wall it up. */ +export function openExpand(net: Net) { + const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close' }, 'βœ•'); + const status = h('div.expand-status'); + const expand = h('button.btn.primary', { type: 'button' }) as HTMLButtonElement; + const shrink = h('button.btn', { type: 'button' }, '🧱 Wall up the last row') as HTMLButtonElement; + const el = h( + 'div.modal.expand', + { role: 'dialog', 'aria-label': 'Back office' }, + h('header', {}, h('h2', {}, 'πŸ”¨ Back office'), close), + h('div.body', {}, status), + h('footer', {}, shrink, expand), + ); + const names = (row: number) => + rowDesks(row) + .map((d) => d.label) + .join(' and '); + const render = () => { + const level = store.floorPlan.wing; + const full = level >= WING.rows; + const next = level + 1; + const last = level > 0 ? rowDesks(level) : []; + const busy = last.find((d) => store.workerAtDesk(d.id)); + status.replaceChildren( + h('p', {}, level === 0 ? 'The office has room to grow through the north wall, between the gong and the corner.' : `The back office is built out ${level} of ${WING.rows} rows, with ${level * 2} more desks.`), + h('div.expand-rows', {}, ...Array.from({ length: WING.rows }, (_, i) => h('span', { class: i < level ? 'on' : '', title: names(i + 1) }, i < level ? 'πŸͺ‘πŸͺ‘' : 'Β· Β·'))), + full ? h('p.setting-note', {}, "It can't go back any further.") : h('p.setting-note', {}, `Knocking through brings ${names(next)}, each with its own sign to hang (press L at a desk).`), + busy ? h('p.setting-note.bad', {}, `Someone's at ${busy.label}: send them home before walling that row up.`) : '', + h('p.setting-note', {}, 'It changes the floor for everyone on it, and stays built across restarts.'), + ); + expand.disabled = full; + expand.textContent = full ? 'Built all the way out' : level === 0 ? 'πŸ”¨ Knock through (+2 desks)' : 'πŸ”¨ Another row (+2 desks)'; + shrink.disabled = level === 0 || !!busy; + shrink.style.display = level === 0 ? 'none' : ''; + }; + const off = [store.on('floorPlan', render), store.on('workers', render)]; + const modal = openModal(el, { doing: 'πŸ”¨ in the back office', onClose: () => off.forEach((f) => f()) }); + expand.addEventListener('click', () => { + net.send({ t: 'floor.expand' }); + modal.close(); + }); + shrink.addEventListener('click', () => { + net.send({ t: 'floor.shrink' }); + modal.close(); + }); + close.addEventListener('click', () => modal.close()); + render(); +} diff --git a/src/client/ui/hud.ts b/src/client/ui/hud.ts index 300bbf05..8971efe8 100644 --- a/src/client/ui/hud.ts +++ b/src/client/ui/hud.ts @@ -153,6 +153,8 @@ export function openHelp() { ['B', 'Open a shared shell (dev servers, git, tests) at an empty desk'], ['R', 'Resume a sleeping worker'], ['X', 'Send a worker home (frees the desk)'], + ['L', 'Hang a big sign over the desk you face ("Operations", "Code cleanup"), or change or take down the one there'], + ['🚧', 'Room to grow: E at the sign on the north wall past the gong knocks through into a back office with 2 more desks, and again for 2 more. The same sign walls a row back up'], ['F', 'Hang a picture from the web on a wall. Look at a picture and press E to move, edit or take it down'], ['Q', 'Put back the issue card in your hands (E at a note on the issues board, or βœ‹ Pick it up in an issue; then E at an empty desk, a worker or the queue board), or drop the basketball'], ['🐢', 'Walk up to the office dog and press E to pet it. When a worker needs input, it runs to that desk and barks. Name it in βš™οΈ Settings'], diff --git a/src/client/ui/whereabouts.ts b/src/client/ui/whereabouts.ts index 24144837..429e5697 100644 --- a/src/client/ui/whereabouts.ts +++ b/src/client/ui/whereabouts.ts @@ -1,4 +1,4 @@ -import { BALCONY, DANCE_FLOOR, FIRE_PIT, FLOOR, LOFT, MEETING_ROOM, ROOF_BAR, ROOF_TABLES, SEATING_BY_ID, STAGE, seatAt } from '../../shared/layout'; +import { BALCONY, DANCE_FLOOR, FIRE_PIT, FLOOR, LOFT, MEETING_ROOM, ROOF_BAR, ROOF_TABLES, SEATING_BY_ID, STAGE, WING, inWing, seatAt } from '../../shared/layout'; import type { PeerInfo } from '../../shared/protocol'; import { ROOF } from '../../shared/rooftop'; import { CARS, type CarSeat } from '../../shared/garage'; @@ -33,6 +33,8 @@ export function whereabouts(p: PeerInfo, car?: { car: number; seat: CarSeat }, p if (p.floor === ROOF) return onTheRoof(p); // On a map of its own, the office's rooms aren't where they'd be. if (!office) return undefined; + // Through the north wall in the back office: nobody gets there unless the floor's built out. + if (p.y > -1 && inWing(p.x, p.z, WING.rows)) return 'πŸ—οΈ in the back office'; // Down on the street, or out the back door on the stairs down to it. if (p.y < -1 || p.x < FLOOR.minX || p.x > FLOOR.maxX || p.z < FLOOR.minZ) return '🚢 outside'; if (p.z > FLOOR.maxZ) return p.x >= BALCONY.minX && p.x <= BALCONY.maxX ? 'πŸŒ‡ on the balcony' : '🚢 outside'; diff --git a/src/client/walkto.ts b/src/client/walkto.ts index e07bd01a..a7891423 100644 --- a/src/client/walkto.ts +++ b/src/client/walkto.ts @@ -2,7 +2,7 @@ // shared/nav.ts), and up the stairs to the boss's office or out through the balcony doors when that's // where they are. -import { BALCONY, BALCONY_DOOR, FLOOR, LOFT, STAIRS, WALL_T } from '../shared/layout'; +import { BALCONY, BALCONY_DOOR, FLOOR, LOFT, STAIRS, WALL_T, inWing } from '../shared/layout'; import { route } from '../shared/nav'; export interface Spot { @@ -32,7 +32,9 @@ const WAY_DOWN: Record = { outside: [], }; -function zoneOf(p: Spot): Zone { +function zoneOf(p: Spot, wing: number): Zone { + // The back office is more of the office floor, through where the north wall was. + if (p.y > -1 && p.y < 0.5 && inWing(p.x, p.z, wing)) return 'floor'; if (p.y < -1 || p.x < FLOOR.minX || p.x > FLOOR.maxX || p.z < FLOOR.minZ) return 'outside'; if (p.z > FLOOR.maxZ) return p.x >= BALCONY.minX && p.x <= BALCONY.maxX ? 'balcony' : 'outside'; if (p.x > LOFT.minX && p.z > LOFT.minZ && p.y > LOFT.y - 0.5) return 'loft'; @@ -41,10 +43,13 @@ function zoneOf(p: Spot): Zone { return 'floor'; } -/** The corners of a walk from `from` to `to`, `to` included when it's somewhere you can stand. */ -export function wayTo(from: Spot, to: Spot): { x: number; z: number }[] { - const a = zoneOf(from); - const b = zoneOf(to); +/** + * The corners of a walk from `from` to `to`, `to` included when it's somewhere you can stand, on a + * floor built out `wing` rows into the back office. + */ +export function wayTo(from: Spot, to: Spot, wing = 0): { x: number; z: number }[] { + const a = zoneOf(from, wing); + const b = zoneOf(to, wing); // Across the same room upstairs or on the balcony, or somewhere the office has no map of: straight there. if ((a === b && a !== 'floor') || a === 'outside' || b === 'outside') return [{ x: to.x, z: to.z }]; // Between the stairs and the boss's office at the top of them: through its door. @@ -54,7 +59,7 @@ export function wayTo(from: Spot, to: Spot): { x: number; z: number }[] { const start = out[out.length - 1] ?? from; const end = into[0] ?? to; // Across the office floor; route stops at the nearest place to stand if they're in a chair or on the couch. - const across = route([start.x, start.z], [end.x, end.z]) + const across = route([start.x, start.z], [end.x, end.z], wing) .slice(1) .map(([x, z]) => ({ x, z })); return [...out, ...across, ...into.slice(1), ...(b === 'floor' ? [] : [{ x: to.x, z: to.z }])]; diff --git a/src/client/world/city.ts b/src/client/world/city.ts index c0ca9d1c..62094588 100644 --- a/src/client/world/city.ts +++ b/src/client/world/city.ts @@ -37,7 +37,7 @@ export interface City { * The building has `floors` floors under the roof: the street goes as far down as that is tall, * and the buildings nearby come down to stay under the roof. */ - setFloors(floors: number): void; + setFloors(floors: number, wings?: readonly number[]): void; /** The cars along the streets, the blinking lights on the towers: `night` is how dark it is (0–1). */ update(t: number, dt: number, night: number): void; } @@ -395,7 +395,8 @@ export function buildCity(night: NightParts): City { [6, 18], [16, 18], [-20, -18], - [20, -18], + // Clear of the back office, when a floor's built out into one (see WING). + [21, -20], ]) { const t = tree(r); t.position.set(x, 0, z); @@ -587,16 +588,18 @@ export function buildCity(night: NightParts): City { group.add(mergeByMaterial(sky)); let floorsNow = 0; + let wingsNow = ''; let riseNow = -1; return { group, - setFloors(floors) { + setFloors(floors, wings = []) { floors = Math.max(1, floors); - if (floors === floorsNow) return; + if (floors === floorsNow && wings.join() === wingsNow) return; floorsNow = floors; + wingsNow = wings.join(); const drop = roofDrop(floors); street.position.y = -drop; - building.set(floors, floors); + building.set(floors, floors, wings); // The buildings only change height up to six floors (see rise). const k = Math.min(1, drop / LAID_OUT); if (k !== riseNow) { diff --git a/src/client/world/desksigns.ts b/src/client/world/desksigns.ts new file mode 100644 index 00000000..dbcfa785 --- /dev/null +++ b/src/client/world/desksigns.ts @@ -0,0 +1,177 @@ +import * as THREE from 'three'; +import { DESKS, DESK_BY_ID, DESK_SIZE, WALL_HEIGHT, WING_DESKS, type DeskDef } from '../../shared/layout'; +import { signInk, type DeskLabel } from '../../shared/floorplan'; +import { mergeByMaterial, mesh, roundedBox, toon } from './toon'; + +// Big signs hung from the ceiling over the desks, naming what each one is for ("Operations", "Code +// cleanup"), so you can tell from across the room where to look (see shared/floorplan.ts). Each +// hangs over the far edge of its desk, facing the chair: stand behind whoever sits there and it's +// over their laptop. Desks come in back-to-back pairs, so a pair's two signs hang back to back too, +// and from either side you read the one for the desk on that side; while the other desk has none, +// the sign says the same on its back. + +/** How big a sign is, how high its middle hangs, and how far apart its two cords are. */ +export const SIGN = { width: 1.9, height: 0.62, depth: 0.04, y: 3.35, cords: 1.3 } as const; + +const PX = 1024; + +/** Each desk's partner, back to back with it across the pair. */ +const PARTNER = new Map(); +for (const d of [...DESKS, ...WING_DESKS]) { + const p = [...DESKS, ...WING_DESKS].find((e) => e !== d && Math.abs(e.x - d.x) < 0.01 && Math.abs(Math.abs(e.z - d.z) - DESK_SIZE.depth) < 0.01 && Math.abs(Math.cos(e.rotY) + Math.cos(d.rotY)) < 0.01); + if (p) PARTNER.set(d.id, p.id); +} +const FONT = (size: number) => `800 ${size}px Nunito, ui-rounded, system-ui, sans-serif`; + +/** The text, as large as fits on one line, else on two. */ +function fit(ctx: CanvasRenderingContext2D, text: string, maxW: number, maxH: number): { lines: string[]; size: number } { + const words = text.split(' '); + for (let size = 200; size >= 64; size -= 6) { + ctx.font = FONT(size); + if (size * 1.05 <= maxH && ctx.measureText(text).width <= maxW) return { lines: [text], size }; + if (words.length < 2 || size * 2.1 > maxH) continue; + // Split at the space that leaves the longer line shortest. + let best: string[] | null = null; + let bestW = Infinity; + for (let i = 1; i < words.length; i++) { + const lines = [words.slice(0, i).join(' '), words.slice(i).join(' ')]; + const w = Math.max(...lines.map((l) => ctx.measureText(l).width)); + if (w < bestW) { + bestW = w; + best = lines; + } + } + if (best && bestW <= maxW) return { lines: best, size }; + } + return { lines: [text], size: 64 }; +} + +/** The sign's face: its paint, a line round the edge, and the text in the middle. */ +function paintFace(canvas: HTMLCanvasElement, label: DeskLabel) { + const w = PX; + const h = Math.round((PX * SIGN.height) / SIGN.width); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d')!; + const ink = signInk(label.color); + ctx.fillStyle = label.color; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = ink; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 9; + ctx.beginPath(); + ctx.roundRect(22, 22, w - 44, h - 44, 34); + ctx.stroke(); + ctx.globalAlpha = 1; + const { lines, size } = fit(ctx, label.text, w - 120, h - 70); + ctx.font = FONT(size); + ctx.fillStyle = ink; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const lh = size * 1.02; + lines.forEach((line, i) => ctx.fillText(line, w / 2, h / 2 + (i - (lines.length - 1) / 2) * lh + size * 0.04)); +} + +interface Hung { + root: THREE.Group; + key: string; + canvas: HTMLCanvasElement; + tex: THREE.CanvasTexture; + label: DeskLabel; +} + +export interface DeskSigns { + group: THREE.Group; + /** Hangs a sign for each of `labels`, over the desks `built` says are there (the back office's may not be yet). */ + set(labels: Record, built: (desk: DeskDef) => boolean): void; + /** The sign over a desk, if it has one. */ + get(deskId: string): THREE.Object3D | undefined; +} + +export function buildDeskSigns(): DeskSigns { + const group = new THREE.Group(); + const hung = new Map(); + const cordMat = toon('#2b2d42'); + + const make = (desk: DeskDef, label: DeskLabel, back: boolean): Hung => { + const root = new THREE.Group(); + root.position.set(desk.x, 0, desk.z); + root.rotation.y = desk.rotY; + // Just off the far edge of the desk, so a back-to-back pair's signs don't touch. + const z = -DESK_SIZE.depth / 2 + SIGN.depth / 2 + 0.012; + const parts = new THREE.Group(); + const board = mesh(roundedBox(SIGN.width, SIGN.depth, SIGN.height, 0.08), toon(label.color), 0, SIGN.y, z, false); + board.rotation.x = Math.PI / 2; + parts.add(board); + const top = SIGN.y + SIGN.height / 2; + for (const sx of [-1, 1]) { + const x = (sx * SIGN.cords) / 2; + parts.add(mesh(new THREE.CylinderGeometry(0.011, 0.011, WALL_HEIGHT - top, 5), cordMat, x, (WALL_HEIGHT + top) / 2, z, false)); + parts.add(mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.03, 10), cordMat, x, WALL_HEIGHT - 0.015, z, false)); + parts.add(mesh(new THREE.SphereGeometry(0.028, 8, 6), cordMat, x, top + 0.01, z, false)); + } + root.add(mergeByMaterial(parts)); + const canvas = document.createElement('canvas'); + paintFace(canvas, label); + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 8; + const face = new THREE.Mesh(new THREE.PlaneGeometry(SIGN.width - 0.06, SIGN.height - 0.06), new THREE.MeshBasicMaterial({ map: tex })); + face.position.set(0, SIGN.y, z + SIGN.depth / 2 + 0.003); + root.add(face); + if (back) { + const rear = new THREE.Mesh(face.geometry.clone(), face.material); + rear.position.set(0, SIGN.y, z - SIGN.depth / 2 - 0.003); + rear.rotation.y = Math.PI; + root.add(rear); + } + group.add(root); + return { root, key: keyOf(label, back), canvas, tex, label }; + }; + + const keyOf = (label: DeskLabel, back: boolean) => `${label.text}|${label.color}|${back}`; + const drop = (h: Hung) => { + h.root.removeFromParent(); + h.root.traverse((o) => { + const m = o as THREE.Mesh; + if (!m.isMesh) return; + m.geometry.dispose(); + if (m.material instanceof THREE.MeshBasicMaterial) m.material.dispose(); + }); + h.tex.dispose(); + }; + /** Whether the sign over `id` says it on its back too: its partner across the pair has no sign of its own there. */ + const twoSided = (id: string, labels: Record, built: (desk: DeskDef) => boolean) => { + const other = PARTNER.get(id); + const desk = other ? DESK_BY_ID.get(other) : undefined; + return !!desk && built(desk) && !labels[desk.id]; + }; + + // The office's font may still be on its way the first time a sign is painted. + void document.fonts?.ready.then(() => { + for (const h of hung.values()) { + paintFace(h.canvas, h.label); + h.tex.needsUpdate = true; + } + }); + + return { + group, + get: (deskId) => hung.get(deskId)?.root, + set(labels, built) { + for (const [id, h] of hung) { + const l = labels[id]; + if (l && keyOf(l, twoSided(id, labels, built)) === h.key) continue; + drop(h); + hung.delete(id); + } + for (const [id, label] of Object.entries(labels)) { + const desk = DESK_BY_ID.get(id); + if (!desk) continue; + let h = hung.get(id); + if (!h) hung.set(id, (h = make(desk, label, twoSided(id, labels, built)))); + h.root.visible = built(desk); + } + }, + }; +} diff --git a/src/client/world/office.ts b/src/client/world/office.ts index 68975f01..367a7e11 100644 --- a/src/client/world/office.ts +++ b/src/client/world/office.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { ASHTRAY, BALCONY, BALCONY_DOOR, BEANBAGS, BOARDS, BOOKSHELF, CABINET, DESKS, DESK_SIZE, ELEVATOR, EXIT_DOOR, EXIT_STAIRS, FLOOR, GONG, JUKEBOX, KIOSK, LADDER, LOFT, MACHINE_MONITOR, MEETING_BOARD, MEETING_ROOM, MEETING_SEATS, MEETING_TABLE, PLANTS, SEATING_BY_ID, SLAB, STAIRS, STATIONS, STATION_AGENT, STOREY, STREET_Y, TV, WALL_HEIGHT, WALL_T, WINDOWS, deskSeat, streetBelow, type DeskDef, type Opening, type Side, type StationKind } from '../../shared/layout'; +import { ASHTRAY, BALCONY, BALCONY_DOOR, BEANBAGS, BOARDS, BOOKSHELF, CABINET, DESKS, DESK_SIZE, ELEVATOR, EXIT_DOOR, EXIT_STAIRS, FLOOR, GONG, JUKEBOX, KIOSK, LADDER, LOFT, MACHINE_MONITOR, MEETING_BOARD, MEETING_ROOM, MEETING_SEATS, MEETING_TABLE, PLANTS, SEATING_BY_ID, SLAB, STAIRS, STATIONS, STATION_AGENT, STOREY, STREET_Y, TV, WALL_HEIGHT, WALL_T, WINDOWS, WING, WING_DESKS, deskSeat, plantByWing, streetBelow, wingMinZ, wingRowZ, type DeskDef, type Opening, type Side, type StationKind } from '../../shared/layout'; import { wallFacing, type WallId, type WallRect } from '../../shared/decor'; import { deskPoint } from '../../shared/nav'; import { FLOOR_PALETTES, type FloorPalette } from '../../shared/floors'; @@ -15,9 +15,10 @@ import { buildCabinet, type CabinetModel } from './cabinet'; import { buildWhiteboard, type WhiteboardStand } from './whiteboard'; import { buildGreen, buildTee, type Green, type Tee } from './golf'; import { buildStack, type Stack } from './stack'; -import { buildTower } from './tower'; +import { buildTower, wingWindows } from './tower'; import { buildHoop, type HoopView } from './hoop'; import { buildKitchen } from './kitchen'; +import { buildDeskSigns, type DeskSigns } from './desksigns'; import { HOOP } from '../../shared/hoop'; export interface Collider { @@ -32,7 +33,7 @@ export interface Collider { fence?: boolean; } -export type InteractKind = 'desk' | 'station' | 'issues' | 'pulls' | 'services' | 'queue' | 'tv' | 'coffee' | 'decor' | 'smoke' | 'elevator' | 'gong' | 'dog' | 'jukebox' | 'seat' | 'whiteboard' | 'cabinet' | 'ladder' | 'pole' | 'meeting' | 'bar' | 'dj' | 'golf' | 'ball' | 'bookshelf' | 'darts' | 'axe' | 'telescope' | 'car' | 'herald'; +export type InteractKind = 'desk' | 'station' | 'issues' | 'pulls' | 'services' | 'queue' | 'tv' | 'coffee' | 'decor' | 'smoke' | 'elevator' | 'gong' | 'dog' | 'jukebox' | 'seat' | 'whiteboard' | 'cabinet' | 'ladder' | 'pole' | 'meeting' | 'bar' | 'dj' | 'golf' | 'ball' | 'bookshelf' | 'darts' | 'axe' | 'telescope' | 'car' | 'expand' | 'herald'; /** Something you can use. Its scene object carries it as `userData.interact`, for clicking. */ export interface Interactable { @@ -113,6 +114,12 @@ export interface Office { hoop: HoopView; /** The ceiling, the floor, and the ladder and fire poles between the floors of the building. */ stack: Stack; + /** The back office through the north wall, as far as this floor's built out (see WING). */ + wing: WingView; + /** Builds the back office out `level` rows, or walls it up: the plants in the way go too. */ + setWing(level: number): void; + /** The signs hung over the desks (see shared/floorplan.ts). */ + signs: DeskSigns; /** The sign over the elevator doors: which floor you're on. */ setProjectName(name: string): void; /** Paints the walls, their trim and the floor in a floor's colors, so each project looks like itself. */ @@ -120,9 +127,10 @@ export interface Office { /** * You're on floor `index` of a building `count` floors tall (0 is the bottom one): the rest of the * building goes up over you and down under you, the street that many storeys down, and only the - * bottom floor has its exit door. + * bottom floor has its exit door. `wings` is how far each floor's back office is built out, for + * the building's outside. */ - setLevel(index: number, count: number): void; + setLevel(index: number, count: number, wings?: readonly number[]): void; /** Lights, windows and glass for the sky to change with the time of day and the weather. */ night: NightParts; /** The potted plants round the room, in PLANTS' order. At Christmas world/holiday.ts hides their leaves (plantLeaves()) and stands a little tree in each pot. */ @@ -748,7 +756,8 @@ function buildWalls(group: THREE.Group, colliders: Collider[], openings: Opening const trimMat = looks.trim; const T = WALL_T; const walls: { side: Side; at: number; spans: [number, number, number][] }[] = [ - { side: 'north', at: FLOOR.minZ - T / 2, spans: [[FLOOR.minX - T, FLOOR.maxX + T, WALL_HEIGHT]] }, + // The north wall stops at the back office, whose own bit of wall (buildWing's plug) comes down for it. + { side: 'north', at: FLOOR.minZ - T / 2, spans: [[FLOOR.minX - T, WING.minX, WALL_HEIGHT]] }, { side: 'south', at: FLOOR.maxZ + T / 2, spans: [[FLOOR.minX - T, FLOOR.maxX + T, WALL_HEIGHT]] }, { side: 'west', at: FLOOR.minX - T / 2, spans: [[FLOOR.minZ, FLOOR.maxZ, WALL_HEIGHT]] }, { side: 'east', at: FLOOR.maxX + T / 2, spans: [[FLOOR.minZ, FLOOR.maxZ, WALL_HEIGHT]] }, @@ -824,6 +833,252 @@ function exitPlug(looks: Looks): { group: THREE.Group; collider: Collider } { return { group, collider: { minX: FLOOR.minX - WALL_T, maxX: FLOOR.minX, minZ: o.u - o.width / 2, maxZ: o.u + o.width / 2, top: 99 } }; } +/** + * A straight run of outside wall `at` (z for one along x, x for one along z) from `u0` to `u1`, with + * its outdoor side toward `out` (-1 or +1): painted inside in the floor's colors and outside in the + * building's, and the ends in `endsOut` outside too. It has holes for `holes` (windows), a baseboard + * and a collider. The back office's walls, and the bit of north wall that comes down for it. + */ +function wallRun(into: THREE.Group, cols: Collider[], axis: 'x' | 'z', at: number, u0: number, u1: number, out: 1 | -1, holes: Opening[], looks: Looks, endsOut: [boolean, boolean]) { + const T = WALL_T; + const outside = toon(PALETTE.exterior); + // A box's faces go +x, -x, +y, -y, +z, -z. + const outFace = axis === 'x' ? (out > 0 ? 4 : 5) : out > 0 ? 0 : 1; + const endFaces = axis === 'x' ? [1, 0] : [5, 4]; + const paint = Array.from({ length: 6 }, (_, i) => (i === outFace || endFaces.some((f, k) => f === i && endsOut[k]) ? outside : looks.wall)); + const piece = (a: number, b: number, y0: number, y1: number) => { + if (b - a < 0.001 || y1 - y0 < 0.001) return; + if (y0 < SHADE_HEIGHT && y1 > SHADE_HEIGHT) { + piece(a, b, y0, SHADE_HEIGHT); + piece(a, b, SHADE_HEIGHT, y1); + return; + } + const m = new THREE.Mesh(axis === 'x' ? box(b - a, y1 - y0, T) : box(T, y1 - y0, b - a), paint); + const u = (a + b) / 2; + m.position.set(axis === 'x' ? u : at, (y0 + y1) / 2, axis === 'x' ? at : u); + m.castShadow = y1 <= SHADE_HEIGHT; + m.receiveShadow = true; + into.add(m); + }; + let u = u0; + for (const o of [...holes].sort((a, b) => a.u - b.u)) { + piece(u, o.u - o.width / 2, 0, WALL_HEIGHT); + piece(o.u - o.width / 2, o.u + o.width / 2, 0, o.y0); + piece(o.u - o.width / 2, o.u + o.width / 2, o.y1, WALL_HEIGHT); + u = o.u + o.width / 2; + } + piece(u, u1, 0, WALL_HEIGHT); + const len = u1 - u0; + const mid = (u0 + u1) / 2; + into.add(mesh(axis === 'x' ? box(len, 0.25, T + 0.04) : box(T + 0.04, 0.25, len), looks.trim, axis === 'x' ? mid : at, 0.125, axis === 'x' ? at : mid, false)); + cols.push(axis === 'x' ? { minX: u0, maxX: u1, minZ: at - T / 2, maxZ: at + T / 2, top: 99 } : { minX: at - T / 2, maxX: at + T / 2, minZ: u0, maxZ: u1, top: 99 }); +} + +/** The sign that says there's room to grow, painted for how far the back office is built out. */ +function paintGrowSign(c: HTMLCanvasElement, level: number) { + const g = c.getContext('2d')!; + const w = c.width; + const h = c.height; + const full = level >= WING.rows; + g.fillStyle = full ? '#e9ecef' : '#ffd166'; + g.fillRect(0, 0, w, h); + // Hazard stripes along the top and the bottom, while there's building to do. + if (!full) { + g.save(); + for (const y of [0, h - 34]) { + g.beginPath(); + g.rect(0, y, w, 34); + g.clip(); + g.fillStyle = '#2b2d42'; + for (let x = -40; x < w + 40; x += 56) { + g.beginPath(); + g.moveTo(x, y + 34); + g.lineTo(x + 28, y + 34); + g.lineTo(x + 56, y); + g.lineTo(x + 28, y); + g.fill(); + } + g.restore(); + g.save(); + } + g.restore(); + } + g.fillStyle = '#2b2d42'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = '800 88px Nunito, ui-rounded, system-ui, sans-serif'; + g.fillText(full ? '🏒 As big as it gets' : '🚧 Room to grow', w / 2, h * 0.4); + g.font = '700 46px Nunito, ui-rounded, system-ui, sans-serif'; + g.fillText(full ? 'The back office is built all the way out' : level ? 'Press E to go back another row: 2 more desks' : 'Press E to knock through: 2 more desks', w / 2, h * 0.68); +} + +/** The back office, as far as it's built out (see WING). */ +export interface WingView { + /** How many rows it's built out. */ + level: number; + /** Builds it out `level` rows (or walls it up): walls, floor, ceiling, desks and all. */ + set(level: number): void; +} + +/** + * The back office: the bit of north wall between the gong and the east wall, which comes down when + * the floor's built out, and behind it the bay, a row deeper each time, with a pair of desks down the + * middle of each row, a rug under them, a lamp over them and a window in the east wall. The sign that + * says there's room to grow hangs on whichever wall is at the back. + */ +function buildWing(group: THREE.Group, colliders: Collider[], interactables: Interactable[], desks: Map, looks: Looks, trimMat: THREE.Material, planks: THREE.Material, ceiling: THREE.Material, night: NightParts): WingView { + const T = WALL_T; + const midX = (WING.minX + WING.maxX) / 2; + + // The wall where it goes through, standing while there's none. + const plug = new THREE.Group(); + const plugCols: Collider[] = []; + wallRun(plug, plugCols, 'x', FLOOR.minZ - T / 2, WING.minX, FLOOR.maxX + T, -1, [], looks, [false, true]); + group.add(plug); + + // Each row's desks, and its rug and lamp. + const rows = Array.from({ length: WING.rows }, (_, i) => { + const row = i + 1; + const z = wingRowZ(row); + const extras = new THREE.Group(); + extras.add(mesh(roundedBox(3.4, 0.02, WING.row - 1, 0.5), toon(PALETTE.rugs[(row + 1) % PALETTE.rugs.length]), midX, 0.011, z, false)); + const lamp = pendant(WALL_HEIGHT - 4.05); + lamp.position.set(midX, 4.05, z); + extras.add(lamp); + extras.visible = false; + group.add(extras); + const seats = WING_DESKS.filter((d) => d.wing === row).map((def) => { + const view = buildDesk(def, DESKS.length + WING_DESKS.indexOf(def), trimMat); + view.group.visible = false; + group.add(view.group); + desks.set(def.id, view); + const hw = DESK_SIZE.width / 2 - 0.05; + const hd = DESK_SIZE.depth / 2 - 0.02; + const collider: Collider = { minX: def.x - hw, maxX: def.x + hw, minZ: def.z - hd, maxZ: def.z + hd, top: DESK_SIZE.height }; + const at = deskSeat(def, 1.25); + const it: Interactable = { kind: 'desk', deskId: def.id, x: at.x, z: at.z, radius: 1.3, off: true }; + interactables.push(it); + view.group.userData.interact = it; + return { view, collider, it }; + }); + return { extras, seats }; + }); + + // The sign: on the wall at the back, high enough to read from across the room over the desks. + const canvas = document.createElement('canvas'); + canvas.width = 1024; + canvas.height = 420; + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 8; + const sign = new THREE.Group(); + const board = mesh(roundedBox(2.64, 0.06, 1.12, 0.06), toon(PALETTE.ink), 0, 0, 0, false); + board.rotation.x = Math.PI / 2; + sign.add(board); + const face = new THREE.Mesh(new THREE.PlaneGeometry(2.56, 1.05), new THREE.MeshBasicMaterial({ map: tex })); + face.position.z = 0.032; + sign.add(face); + group.add(sign); + const it: Interactable = { kind: 'expand', x: midX, z: FLOOR.minZ + 0.3, radius: 2.2 }; + interactables.push(it); + sign.userData.interact = it; + + let built: THREE.Object3D[] = []; + let mine: Collider[] = []; + const view: WingView = { + level: -1, + set(level) { + level = Math.max(0, Math.min(WING.rows, level)); + if (level === view.level) return; + view.level = level; + for (const o of built) { + o.removeFromParent(); + o.traverse((m) => { + if ((m as THREE.Mesh).isMesh) (m as THREE.Mesh).geometry.dispose(); + }); + } + built = []; + for (const c of mine) { + const i = colliders.indexOf(c); + if (i >= 0) colliders.splice(i, 1); + } + mine = []; + const take = (o: THREE.Object3D) => { + group.add(o); + built.push(o); + return o; + }; + + plug.visible = level === 0; + if (level === 0) mine.push(...plugCols); + rows.forEach(({ extras, seats }, i) => { + const on = i < level; + extras.visible = on; + for (const s of seats) { + s.view.group.visible = on; + s.it.off = !on; + if (on) mine.push(s.collider); + } + }); + + const back = wingMinZ(level); + if (level > 0) { + const shell = new THREE.Group(); + wallRun(shell, mine, 'z', WING.minX - T / 2, back, FLOOR.minZ - T, -1, [], looks, [false, false]); + const windows = wingWindows(level); + wallRun(shell, mine, 'z', FLOOR.maxX + T / 2, back, FLOOR.minZ, 1, windows, looks, [false, false]); + wallRun(shell, mine, 'x', back - T / 2, WING.minX - T, FLOOR.maxX + T, -1, [], looks, [true, true]); + for (const o of windows) { + shell.add(windowIn(o)); + shell.add(wetPane(o, night.wetGlass)); + } + take(shell); + + // The floor: the room's planks carried on through (the same texture, lined up with it), on a + // slab like the room's; and the ceiling's tiles over it. + const w = WING.maxX - WING.minX; + const d = FLOOR.minZ - back; + const floorGeo = new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2).translate(midX, 0, (back + FLOOR.minZ) / 2); + const uv = floorGeo.getAttribute('uv'); + const pos = floorGeo.getAttribute('position'); + for (let k = 0; k < pos.count; k++) uv.setXY(k, (pos.getX(k) - FLOOR.minX) / (FLOOR.maxX - FLOOR.minX), (FLOOR.maxZ - pos.getZ(k)) / (FLOOR.maxZ - FLOOR.minZ)); + const floor = take(new THREE.Mesh(floorGeo, planks)) as THREE.Mesh; + floor.receiveShadow = true; + // Its edges are the band between the floors outside, its underside concrete. + const band = toon('#e8a87c'); + const concrete = toon('#d3d6dd'); + const slab = new THREE.Mesh(box(w + 2 * T, SLAB - 0.01, d), [band, band, concrete, concrete, band, band]); + slab.position.set(midX, -SLAB / 2 - 0.005, (back - T + FLOOR.minZ - T) / 2); + slab.receiveShadow = true; + take(slab); + const ceilGeo = new THREE.PlaneGeometry(w, d).rotateX(Math.PI / 2).translate(midX, WALL_HEIGHT, (back + FLOOR.minZ) / 2); + const cuv = ceilGeo.getAttribute('uv'); + const cpos = ceilGeo.getAttribute('position'); + for (let k = 0; k < cpos.count; k++) cuv.setXY(k, cpos.getX(k), cpos.getZ(k)); + take(new THREE.Mesh(ceilGeo, ceiling)).receiveShadow = false; + // Its roof, flush with the tops of its walls, for when there's no floor over it. + take(mesh(box(w + 2 * T, 0.02, d + T), toon('#fffaf3'), midX, WALL_HEIGHT + 0.03, (back - T + FLOOR.minZ) / 2, false)); + mine.push({ minX: WING.minX - T, maxX: FLOOR.maxX + T, minZ: back - T, maxZ: FLOOR.minZ - T, bottom: -SLAB, top: 0 }); + mine.push({ minX: WING.minX, maxX: FLOOR.maxX, minZ: back, maxZ: FLOOR.minZ, bottom: WALL_HEIGHT, top: WALL_HEIGHT + SLAB }); + } + colliders.push(...mine); + + // The sign on whichever wall is at the back: low on the old wall, up over the desks and their + // lamps once they're there, where it reads from across the room. + sign.position.set(midX, level ? 5.3 : 2.4, back + 0.05); + it.z = back + 0.3; + paintGrowSign(canvas, level); + tex.needsUpdate = true; + }, + }; + void document.fonts?.ready.then(() => { + paintGrowSign(canvas, Math.max(0, view.level)); + tex.needsUpdate = true; + }); + view.set(0); + return view; +} + /** Makes `obj` somewhere to sit (see SEATING): walk up to it, or look at it, and press E. */ function seatable(obj: THREE.Object3D, seatId: string, radius: number, interactables: Interactable[]) { const seat = SEATING_BY_ID.get(seatId)!; @@ -1051,7 +1306,8 @@ export function buildOffice(): Office { // Floor, and the ceiling, with the ways up and down to the other floors through them (see stack.ts). const floorTex = floorTexture(); looks.planks.push(floorTex); - const stack = buildStack(colliders, new THREE.MeshToonMaterial({ map: floorTex, gradientMap: (toon('#fff') as THREE.MeshToonMaterial).gradientMap })); + const floorMat = new THREE.MeshToonMaterial({ map: floorTex, gradientMap: (toon('#fff') as THREE.MeshToonMaterial).gradientMap }); + const stack = buildStack(colliders, floorMat); stack.set({ index: 0, count: 1 }); group.add(stack.group); interactables.push(...stack.interactables); @@ -1314,13 +1570,18 @@ export function buildOffice(): Office { // Plants around the room const plants: THREE.Group[] = []; - for (const [i, [x, z, s]] of PLANTS.entries()) { + /** The ones in the way into the back office, and their colliders, to put away while it's built. */ + const plantsByWing: { group: THREE.Group; collider: Collider }[] = []; + for (const [i, spot] of PLANTS.entries()) { + const [x, z, s] = spot; const p = plant(floorPlant(i), s); p.position.set(x, 0, z); group.add(p); plants.push(p); const r = 0.3 * s; - colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: 0.5 * s }); + const collider: Collider = { minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: 0.5 * s }; + colliders.push(collider); + if (plantByWing(spot)) plantsByWing.push({ group: p, collider }); } // Ceiling lamps (cartoon pendants), hung on long cords down from the high ceiling. @@ -1338,6 +1599,24 @@ export function buildOffice(): Office { night.halos.push({ at: new THREE.Vector3(x, lampY - 0.12, z), size: 1.3, color: '#ffe08a' }); } + // The back office through the north wall past the gong, walled up until the floor's built out. + const wing = buildWing(group, colliders, interactables, desks, looks, trimMat, floorMat, stack.ceiling, night); + fixture('north', (WING.minX + FLOOR.maxX) / 2, WALL_HEIGHT / 2, FLOOR.maxX - WING.minX, WALL_HEIGHT); + const setWing = (level: number) => { + wing.set(level); + for (const p of plantsByWing) { + const out = wing.level === 0; + if (p.group.visible === out) continue; + p.group.visible = out; + const i = colliders.indexOf(p.collider); + if (out && i < 0) colliders.push(p.collider); + else if (!out && i >= 0) colliders.splice(i, 1); + } + }; + // The signs over the desks. + const signs = buildDeskSigns(); + group.add(signs.group); + const bossScreen = buildLoft(group, colliders, interactables, looks); // Under the loft: the meeting room. const meeting = buildMeetingRoom(group, colliders, interactables, desks, doors, night); @@ -1394,7 +1673,7 @@ export function buildOffice(): Office { } }; - const setLevel = (index: number, count: number) => { + const setLevel = (index: number, count: number, wings: readonly number[] = []) => { const drop = index * STOREY; ground.position.y = -drop; for (const g of groundBase) { @@ -1411,7 +1690,7 @@ export function buildOffice(): Office { const i = colliders.indexOf(plug.collider); if (index > 0 && i < 0) colliders.push(plug.collider); else if (index === 0 && i >= 0) colliders.splice(i, 1); - tower.set(index, count); + tower.set(index, count, wings); }; setLevel(0, 1); @@ -1437,7 +1716,7 @@ export function buildOffice(): Office { hoop.update(dt); }; - return { group, colliders, interactables, desks, setBeanbags, boardMeshes, tvScreen, bossScreen, machineScreen, meetingBoard: meeting.board, meetingSign: meeting.sign, fixtures: () => fixtures, elevator, garageLift, cars, gong, jukebox, cabinet, whiteboard, tee, green, hoop, stack, setProjectName, setLook, setLevel, night, plants, update }; + return { group, colliders, interactables, desks, setBeanbags, boardMeshes, tvScreen, bossScreen, machineScreen, meetingBoard: meeting.board, meetingSign: meeting.sign, fixtures: () => fixtures, elevator, garageLift, cars, gong, jukebox, cabinet, whiteboard, tee, green, hoop, stack, wing, setWing, signs, setProjectName, setLook, setLevel, night, plants, update }; } /** A chair at the meeting table, with its laptop on the table in front of it. */ diff --git a/src/client/world/outside.ts b/src/client/world/outside.ts index 891bb639..1ca72963 100644 --- a/src/client/world/outside.ts +++ b/src/client/world/outside.ts @@ -351,7 +351,8 @@ export function buildStreet(group: THREE.Group, colliders: Collider[], night: Ni [30, 6, 1.25], [-12, -22, 1.2], [4, -24, 1], - [18, -21, 1.1], + // Clear of the back office, when a floor's built out into one (see WING). + [23, -19, 1.1], ]; for (const [x, z, s] of trees) { const t = tree(s); diff --git a/src/client/world/rooftop.ts b/src/client/world/rooftop.ts index 694456e3..aa9cfc8a 100644 --- a/src/client/world/rooftop.ts +++ b/src/client/world/rooftop.ts @@ -35,8 +35,11 @@ export interface Rooftop { interactables: Interactable[]; elevator: Elevator; city: City; - /** The building has `floors` floors under the roof: the street is as far down as that is tall (see City). */ - setFloors(floors: number): void; + /** + * The building has `floors` floors under the roof: the street is as far down as that is tall (see + * City). `wings` is how far each one's back office is built out. + */ + setFloors(floors: number, wings?: readonly number[]): void; /** What looking or clicking can land on: everything but the city far below. */ pickables: THREE.Object3D[]; /** Where drinks are poured, for the sound of one. */ @@ -848,7 +851,7 @@ export function buildRooftop(night: NightParts, floors: number): Rooftop { interactables, elevator, city, - setFloors: (n) => city.setFloors(n), + setFloors: (n, wings) => city.setFloors(n, wings), pickables: group.children.filter((c) => c !== city.group), pourAt: { x: bx + 0.2, y: ROOF_BAR.height + 0.2, z: bz }, games, diff --git a/src/client/world/sky.ts b/src/client/world/sky.ts index 09074c19..21378624 100644 --- a/src/client/world/sky.ts +++ b/src/client/world/sky.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { FLOOR, SLAB, STREET_Y, WALL_T } from '../../shared/layout'; +import { FLOOR, SLAB, STREET_Y, WALL_HEIGHT, WALL_T, WING, wingMinZ } from '../../shared/layout'; import type { SkyState, Theme, Weather } from '../../shared/protocol'; import { guessPlace, sunPosition } from '../../shared/sun'; import type { NightParts } from './outside'; @@ -55,6 +55,8 @@ const uniforms = { skyDrop: { value: 0 }, /** Where the street is, which the haze thins out with height over. */ skyStreet: { value: STREET_Y }, + /** The back office, when the floor's built out into one (see WING): minX, maxX, minZ, maxZ. Empty without. */ + skyWing: { value: new THREE.Vector4(1, 0, 1, 0) }, }; const v3 = (x: number, y: number, z: number) => `vec3(${x.toFixed(3)}, ${y.toFixed(3)}, ${z.toFixed(3)})`; @@ -73,11 +75,15 @@ uniform vec3 skyLampMax; uniform float skyWet; uniform float skySnow; uniform float skyDrop; +uniform vec4 skyWing; -// Inside the office's walls (and up through its open top). +// Inside the office's walls (and up through its open top), or the back office's, up to its ceiling +// and no further: its roof, and the cornice over where the wall came down, are outdoors. float skyInOffice( vec3 p ) { vec3 d = max( ${v3(FLOOR.minX - 0.02, -0.06, FLOOR.minZ - 0.02)} - p, p - ${v3(FLOOR.maxX + 0.02, 40, FLOOR.maxZ + 0.02)} ); - return 1.0 - smoothstep( 0.0, 0.12, length( max( d, 0.0 ) ) ); + vec3 w = max( vec3( skyWing.x, -0.06, skyWing.z ) - p, p - vec3( skyWing.y, ${(WALL_HEIGHT + 0.005).toFixed(3)}, skyWing.w ) ); + float wing = length( max( w, 0.0 ) ) + step( ${(WALL_HEIGHT + 0.005).toFixed(3)}, p.y ); + return 1.0 - smoothstep( 0.0, 0.12, min( length( max( d, 0.0 ) ), wing ) ); } // Under the bottom floor: walled at the back and on the west side, open to the street on the south and east. @@ -358,8 +364,12 @@ function blobTexture(inner: number): THREE.CanvasTexture { return new THREE.CanvasTexture(c); } +/** The back office, walls included, when the floor you're on is built out into one (see Sky.setWing). */ +let wingBox: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null; + /** Is (x, z) under the building, where no rain or snow falls? */ -const sheltered = (x: number, z: number) => x > B.minX - 0.05 && x < B.maxX + 0.05 && z > B.minZ - 0.05 && z < B.maxZ + 0.05; +const sheltered = (x: number, z: number) => + (x > B.minX - 0.05 && x < B.maxX + 0.05 && z > B.minZ - 0.05 && z < B.maxZ + 0.05) || (!!wingBox && x > wingBox.minX - 0.05 && x < wingBox.maxX + 0.05 && z > wingBox.minZ - 0.05 && z < wingBox.maxZ); export class Sky { private preview: { hour?: number; weather?: Weather; intensity?: number } = {}; @@ -569,6 +579,17 @@ export class Sky { uniforms.skyInside.value = on || this.roof ? 0 : 1; } + /** + * The floor you're on is built out `level` rows into the back office (see WING): lit like the + * office inside, and out of the rain. + */ + setWing(level: number) { + const minZ = wingMinZ(level); + wingBox = level > 0 ? { minX: WING.minX - WALL_T, maxX: WING.maxX + WALL_T, minZ: minZ - WALL_T, maxZ: FLOOR.minZ } : null; + if (level > 0) uniforms.skyWing.value.set(WING.minX - 0.02, WING.maxX + 0.02, minZ - 0.02, FLOOR.minZ); + else uniforms.skyWing.value.set(1, 0, 1, 0); + } + /** Under a roof, out of the rain: the building, unless you're up on top of it. */ private sheltered(x: number, z: number): boolean { return this.indoors || (!this.roof && sheltered(x, z)); @@ -582,7 +603,7 @@ export class Sky { /** How lit it is at `p`, 0–1 (1 is a clear day, or a room with its lights on), for your hands. */ lightAt(p: THREE.Vector3): number { if (this.indoors) return 1; - const inside = !this.roof && ((p.x > FLOOR.minX && p.x < FLOOR.maxX && p.z > FLOOR.minZ && p.z < FLOOR.maxZ) || (sheltered(p.x, p.z) && p.y < 0)); + const inside = !this.roof && ((p.x > FLOOR.minX && p.x < FLOOR.maxX && p.z > FLOOR.minZ && p.z < FLOOR.maxZ) || (sheltered(p.x, p.z) && (p.y < 0 || p.z < FLOOR.minZ))); if (inside) return 1; let lamp = 0; if (!this.roof) { diff --git a/src/client/world/stack.ts b/src/client/world/stack.ts index 068bfdcd..11714b54 100644 --- a/src/client/world/stack.ts +++ b/src/client/world/stack.ts @@ -216,6 +216,8 @@ export interface StackState { export interface Stack { group: THREE.Group; interactables: Interactable[]; + /** The ceiling's tiles, for the back office's ceiling to match (its uvs are meters, like a ShapeGeometry's). */ + ceiling: THREE.Material; /** The floor you're on: the ladder, the hatches and the poles go where there are floors to go to. */ set(s: StackState): void; state: StackState; @@ -535,6 +537,7 @@ export function buildStack(colliders: Collider[], planks: THREE.Material): Stack const stack: Stack = { group, interactables, + ceiling: ceilingMat, set, state, poles: () => (state.count > 1 ? POLES : []), diff --git a/src/client/world/tower.ts b/src/client/world/tower.ts index a91fbfbd..800c6328 100644 --- a/src/client/world/tower.ts +++ b/src/client/world/tower.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { BALCONY, BALCONY_DOOR, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, FLOOR, ROOF_BAR, SLAB, STAGE, STOREY, WALL_HEIGHT, WALL_T, WINDOWS, type Opening, type Side } from '../../shared/layout'; +import { BALCONY, BALCONY_DOOR, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, FLOOR, ROOF_BAR, SLAB, STAGE, STOREY, STREET_Y, WALL_HEIGHT, WALL_T, WINDOWS, WING, wingMinZ, wingRowZ, type Opening, type Side } from '../../shared/layout'; import type { Collider } from './office'; import { bulb, type NightParts } from './outside'; import { mergeByMaterial, mesh, toon, toonUnique } from './toon'; @@ -18,11 +18,28 @@ export interface Tower { group: THREE.Group; /** * Builds the outside of every floor but `index`, of `count` stacked from the bottom one (0). An - * `index` of `count` is the roof: every floor, below it, and no top (the roof is its own). + * `index` of `count` is the roof: every floor, below it, and no top (the roof is its own). `wings` + * is how far each floor's back office is built out (see WING), yours included: the others' are + * drawn, and all of them stand on posts down to the street. */ - set(index: number, count: number): void; + set(index: number, count: number, wings?: readonly number[]): void; } +/** A window in each row of a back office, in the building's east wall (office.ts cuts the same ones). */ +export function wingWindows(level: number): Opening[] { + return Array.from({ length: level }, (_, i) => ({ wall: 'east' as const, u: wingRowZ(i + 1), width: 2.4, y0: 1.1, y1: 3.3 })); +} + +/** Where the posts under a back office stand: at the back corners of each row. */ +export function wingPosts(row: number): { x: number; z: number }[] { + const z = wingMinZ(row) - WALL_T + 0.22; + return [ + { x: WING.minX - WALL_T + 0.22, z }, + { x: WING.maxX + WALL_T - 0.22, z }, + ]; +} + + /** Each side of the building: where along it things are (u, from corner to corner, where it meets the next side's plane), and its plane. */ const FACES: Record THREE.Vector3; rotY: number }> = { north: { u0: B.minX - OFF, u1: B.maxX + OFF, at: (u, y) => new THREE.Vector3(u, y, B.minZ - OFF), rotY: Math.PI }, @@ -243,7 +260,57 @@ export function buildTower(colliders: Collider[], night: NightParts): Tower { } }; - const set = (index: number, count: number) => { + /** + * One floor's back office, `y0` up, from outside: its three walls round the bay (windows in the + * east one), the band of its slab, and a roof over the rows the floor above doesn't cover, or an + * underside under the ones the floor below doesn't. + */ + const bay = (parts: THREE.Group, y0: number, level: number, above: number, below: number) => { + const back = wingMinZ(level) - WALL_T; + const west = WING.minX - WALL_T; + const east = B.maxX; + const plane = (w: number, h: number, mat: THREE.Material, x: number, y: number, z: number, rotY: number) => { + if (w < 0.001 || h < 0.001) return; + const m = mesh(new THREE.PlaneGeometry(w, h), mat, x, y, z, false); + m.rotation.y = rotY; + parts.add(m); + }; + // Each wall: its band, then paint round its windows (along z on the side walls, x on the back). + const wall = (u0: number, u1: number, holes: Opening[], at: (u: number) => [number, number], rotY: number) => { + const [bx, bz] = at((u0 + u1) / 2); + plane(Math.abs(u1 - u0), SLAB, band, bx, y0 - SLAB / 2, bz, rotY); + let u = Math.min(u0, u1); + const end = Math.max(u0, u1); + const piece = (a: number, b: number, y1: number, y2: number) => { + const [x, z] = at((a + b) / 2); + plane(b - a, y2 - y1, paint, x, y0 + (y1 + y2) / 2, z, rotY); + }; + for (const o of [...holes].sort((a, b) => a.u - b.u)) { + piece(u, o.u - o.width / 2, 0, WALL_HEIGHT); + piece(o.u - o.width / 2, o.u + o.width / 2, 0, o.y0); + piece(o.u - o.width / 2, o.u + o.width / 2, o.y1, WALL_HEIGHT); + u = o.u + o.width / 2; + } + piece(u, end, 0, WALL_HEIGHT); + }; + const windows = wingWindows(level); + wall(back, B.minZ, [], (z) => [west - OFF, z], -Math.PI / 2); + wall(back, B.minZ, windows, (z) => [east + OFF, z], Math.PI / 2); + wall(west, east, [], (x) => [x, back - OFF], Math.PI); + for (const o of windows) glazing(parts, o, y0, false); + // Over (and under) the rows of it that the floor above's (and below's) back office doesn't cover. + const flat = (from: number, y: number, up: boolean) => { + if (from >= level) return; + const z0 = wingMinZ(level) - WALL_T; + const z1 = from > 0 ? wingMinZ(from) - WALL_T : B.minZ; + const g = new THREE.PlaneGeometry(east - west, z1 - z0).rotateX(up ? -Math.PI / 2 : Math.PI / 2); + parts.add(mesh(g, up ? cornice : concrete, (west + east) / 2, y, (z0 + z1) / 2, false)); + }; + flat(above, y0 + WALL_HEIGHT, true); + flat(below, y0 - SLAB, false); + }; + + const set = (index: number, count: number, wings: readonly number[] = []) => { for (const o of built) { o.removeFromParent(); o.traverse((m) => { @@ -273,6 +340,8 @@ export function buildTower(colliders: Collider[], night: NightParts): Tower { for (const o of WINDOWS) glazing(parts, o, y0, false); glazing(parts, BALCONY_DOOR, y0, true); balcony(parts, y0); + const wing = wings[k] ?? 0; + if (wing > 0) bay(parts, y0, wing, k + 1 < count ? (wings[k + 1] ?? 0) : 0, k > 0 ? (wings[k - 1] ?? 0) : 0); if (k === 0) { // Dark behind the exit door, through its porthole. const back = mesh(new THREE.PlaneGeometry(EXIT_DOOR.width, EXIT_DOOR.y1), behind, FLOOR.minX - 0.02, y0 + EXIT_DOOR.y1 / 2, EXIT_DOOR.u, false); @@ -292,6 +361,19 @@ export function buildTower(colliders: Collider[], night: NightParts): Tower { const under = new THREE.PlaneGeometry(B.maxX - B.minX, B.maxZ - B.minZ).rotateX(Math.PI / 2); parts.add(mesh(under, concrete, (B.minX + B.maxX) / 2, -index * STOREY - SLAB, (B.minZ + B.maxZ) / 2, false)); } + // The back offices stand on posts down to the street: each row's, from under the lowest floor it's built on. + const street = STREET_Y - index * STOREY; + const posts: Collider[] = []; + for (let row = 1; row <= WING.rows; row++) { + const lowest = wings.findIndex((w) => w >= row); + if (lowest < 0 || lowest >= count) continue; + const top = (lowest - index) * STOREY - SLAB; + for (const p of wingPosts(row)) { + parts.add(mesh(new THREE.CylinderGeometry(0.14, 0.14, top - street, 10), concrete, p.x, (top + street) / 2, p.z, false)); + posts.push({ minX: p.x - 0.16, maxX: p.x + 0.16, minZ: p.z - 0.16, maxZ: p.z + 0.16, bottom: street, top }); + } + } + // None of it casts a shadow (the sun lights the office through where its roof would be), and none // takes one from the floor you're on, which would fall on it as though nothing were in between. const merged = mergeByMaterial(parts); @@ -313,6 +395,8 @@ export function buildTower(colliders: Collider[], night: NightParts): Tower { ); colliders.push(...mine); } + mine.push(...posts); + colliders.push(...posts); }; return { group, set }; diff --git a/src/client/world/world.ts b/src/client/world/world.ts index a4ff1132..ddfa3e7f 100644 --- a/src/client/world/world.ts +++ b/src/client/world/world.ts @@ -2,7 +2,7 @@ import type * as THREE from 'three'; import type { FloorPalette } from '../../shared/floors'; import { LOFT, WALL_HEIGHT, WALL_T, FLOOR, type DeskDef } from '../../shared/layout'; import { OFFICE_PLAN, type BoardKey, type MapPlan } from '../../shared/maps'; -import { OFFICE_NAV, wayHome, wayIn, wayToBalcony, type NavGrid, type Pt } from '../../shared/nav'; +import { officeNav, wayHome, wayIn, wayToBalcony, type NavGrid, type Pt } from '../../shared/nav'; import type { Person } from './character'; import type { Area } from './confetti'; import type { Gong } from './gong'; @@ -77,8 +77,11 @@ function ceilingOver(x: number, z: number): number { return loft ? LOFT.y - 0.35 : WALL_HEIGHT - 0.1; } -/** The office as a world. `upstairs` says whether this floor is above the bottom one (no exit door: workers leave by the balcony). */ -export function officeWorld(office: Office, upstairs: () => boolean): World { +/** + * The office as a world. `upstairs` says whether this floor is above the bottom one (no exit door: + * workers leave by the balcony), and `wing` how many rows its back office is built out (see WING). + */ +export function officeWorld(office: Office, upstairs: () => boolean, wing: () => number): World { return { plan: OFFICE_PLAN, group: office.group, @@ -90,10 +93,12 @@ export function officeWorld(office: Office, upstairs: () => boolean): World { meetingBoard: office.meetingBoard, meetingSign: office.meetingSign, gong: office.gong, - nav: OFFICE_NAV, + get nav() { + return officeNav(wing()); + }, ways: { - home: (seat) => (upstairs() ? { way: wayToBalcony(seat), chute: true } : { way: wayHome(seat), chute: false }), - in: (seat) => wayIn(seat), + home: (seat) => (upstairs() ? { way: wayToBalcony(seat, wing()), chute: true } : { way: wayHome(seat, wing()), chute: false }), + in: (seat) => wayIn(seat, wing()), }, rain: [ { area: FLOOR, top: ceilingOver }, diff --git a/src/server/dog.ts b/src/server/dog.ts index 47f2ff3a..9c488ddc 100644 --- a/src/server/dog.ts +++ b/src/server/dog.ts @@ -40,6 +40,8 @@ export interface DogEnv { people(): PeerInfo[]; /** To everyone on this floor. */ send(dog: DogState): void; + /** How many rows the floor's back office is built out, for getting round its desks too (see WING). */ + wing?(): number; } type Leg = Omit & { start: number }; @@ -193,12 +195,12 @@ export class Dog { const pts: Pt[] = [from]; let start = from; // Still under the desk (or on its way in), not just somewhere on the way there. - if (this.exit && !walkable(from[0], from[1])) { + if (this.exit && !walkable(from[0], from[1], this.wing)) { pts.push(this.exit); start = this.exit; } this.exit = undefined; - pts.push(...route(start, to).slice(1)); + pts.push(...route(start, to, this.wing).slice(1)); if (last) pts.push(last); this.go(pts, speed, act, extra); return legSeconds(this.leg) * 1000; @@ -223,6 +225,11 @@ export class Dog { return best; } + /** How far the floor's back office is built out. */ + private get wing(): number { + return this.env.wing?.() ?? 0; + } + /** Picks what to do next. */ private think() { if (this.stopped) return; @@ -262,7 +269,7 @@ export class Dog { let spot: Pt = at; for (let i = 0; i < 30; i++) { const p: Pt = [rand(FLOOR.minX + 1, FLOOR.maxX - 1), rand(FLOOR.minZ + 1, FLOOR.maxZ - 1)]; - if (walkable(p[0], p[1]) && dist(p, at) > 4) { + if (walkable(p[0], p[1], this.wing) && dist(p, at) > 4) { spot = p; break; } @@ -277,7 +284,7 @@ export class Dog { this.mode = 'nap'; let side = this.sideOf(desk); // A bean bag has no desk to get under, so it curls up beside it, on whichever side has room. - if (desk.beanbag && !walkable(...deskPoint(desk, side * 1.05, 0.1))) side = -side; + if (desk.beanbag && !walkable(...deskPoint(desk, side * 1.05, 0.1), this.wing)) side = -side; const approach = desk.beanbag ? deskPoint(desk, side * 1.3, 1.2) : deskPoint(desk, side * 0.8, 1.3); const under = desk.beanbag ? deskPoint(desk, side * 1.05, 0.1) : deskPoint(desk, side * 0.45, 0.15); // Head out toward the chair. @@ -302,7 +309,7 @@ export class Dog { return this.think(); } const person: Pt = [p.x, p.z]; - const behind = nearestWalkable([p.x - Math.sin(p.rotY) * 1.1, p.z - Math.cos(p.rotY) * 1.1]); + const behind = nearestWalkable([p.x - Math.sin(p.rotY) * 1.1, p.z - Math.cos(p.rotY) * 1.1], this.wing); const end = this.leg.path[this.leg.path.length - 1]; const along = this.leg.act === 'sit' && this.leg.following === p.id; // Someone standing still who just turns around doesn't need it circling round behind them. diff --git a/src/server/floor.ts b/src/server/floor.ts index 9979cdd7..95841a4a 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -13,6 +13,7 @@ import type { GhAs } from './signins.js'; import { TaskQueue } from './queue.js'; import { Changes } from './changes.js'; import { Decor } from './decor.js'; +import { FloorPlanStore } from './floorplan.js'; import { Docs } from './docs.js'; import { Dog } from './dog.js'; import { Court } from './court.js'; @@ -111,6 +112,8 @@ export class Floor { readonly queue: TaskQueue; readonly changes: Changes; readonly decor: Decor; + /** The signs over its desks, and how far its back office is built out. */ + readonly plan: FloorPlanStore; readonly jukebox: Jukebox; /** The whiteboard everyone on the floor draws on together. */ readonly whiteboard: Whiteboard; @@ -144,12 +147,15 @@ export class Floor { excludeFromGit(def.dir); this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs); this.docs = new Docs(def.dir); + // Before the workers and the dog: the back office's desks are only there once it's built. + this.plan = new FloorPlanStore(dataDir); // Before the workers, so it hears about the ones who wake up needing input. this.dog = new Dog(def.id, dataDir, { workers: () => this.workers?.list() ?? [], people: () => ctx.peers(this), send: (dog) => ctx.emit(this, { t: 'dog', dog }), + wing: () => this.plan.wing, }); this.workers = new WorkerManager( @@ -186,6 +192,7 @@ export class Floor { ctx.prompts, ctx.runAs, ); + this.workers.wing = () => this.plan.wing; this.github = new GitHub( def.dir, @@ -365,6 +372,7 @@ export class Floor { busy: ws.filter((w) => w.status === 'working').length, waiting: ws.filter((w) => w.kind === 'agent' && (w.status === 'needs_input' || (w.status === 'done' && !w.acked))).length, people: this.ctx.people(this), + wing: this.plan.wing, }; } diff --git a/src/server/floorplan.ts b/src/server/floorplan.ts new file mode 100644 index 00000000..62f341c6 --- /dev/null +++ b/src/server/floorplan.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { canLabel, cleanLabel, cleanPlan, rowDesks, signColor, type DeskLabel, type FloorPlan } from '../shared/floorplan.js'; +import { DESK_BY_ID, WING } from '../shared/layout.js'; + +/** + * A floor's own layout: the signs over its desks, and how far its back office is built out. Saved in + * .agent-office/floorplan.json. + */ +export class FloorPlanStore { + private plan: FloorPlan; + private file: string; + + constructor(dataDir: string) { + this.file = path.join(dataDir, 'floorplan.json'); + this.plan = this.load(); + } + + state(): FloorPlan { + return { wing: this.plan.wing, labels: { ...this.plan.labels } }; + } + + get wing(): number { + return this.plan.wing; + } + + /** Hangs a sign over a desk, or takes it down (no text). What it did, for the toast, or why it couldn't. */ + label(deskId: string, text: unknown, color: unknown, by: string): { label?: DeskLabel; old?: DeskLabel } | string { + if (!canLabel(deskId)) return 'Only a desk can have a sign over it'; + const clean = cleanLabel(text); + const old = this.plan.labels[deskId]; + if (!clean) { + if (!old) return {}; + delete this.plan.labels[deskId]; + this.save(); + return { old }; + } + const label: DeskLabel = { text: clean, color: signColor(color), by, at: Date.now() }; + this.plan.labels[deskId] = label; + this.save(); + return { label, old }; + } + + /** Knocks the back office out another row: the ids of the desks that came with it, or why not. */ + expand(): string[] | string { + if (this.plan.wing >= WING.rows) return "The back office can't go back any further"; + this.plan.wing++; + this.save(); + return rowDesks(this.plan.wing).map((d) => d.id); + } + + /** Walls up the back office's last row, unless someone's working there (`taken`). What went, or why not. */ + shrink(taken: (deskId: string) => boolean): string[] | string { + if (this.plan.wing <= 0) return 'There is no back office to wall up'; + const desks = rowDesks(this.plan.wing); + const busy = desks.find((d) => taken(d.id)); + if (busy) return `Someone's at ${DESK_BY_ID.get(busy.id)?.label ?? 'a desk'} back there: send them home first`; + this.plan.wing--; + this.save(); + return desks.map((d) => d.id); + } + + private load(): FloorPlan { + if (!existsSync(this.file)) return cleanPlan(undefined); + try { + return cleanPlan(JSON.parse(readFileSync(this.file, 'utf8'))); + } catch { + // a broken file just means the office as it comes + return cleanPlan(undefined); + } + } + + private save() { + try { + writeFileSync(this.file, JSON.stringify(this.plan, null, 2), { mode: 0o600 }); + } catch { + // disk issues shouldn't take the office down + } + } +} diff --git a/src/server/queue.ts b/src/server/queue.ts index 3d8304cc..d17453fa 100644 --- a/src/server/queue.ts +++ b/src/server/queue.ts @@ -13,6 +13,8 @@ export interface QueueWorkers { readonly officeDefault?: AgentChoice; list(): WorkerInfo[]; deskOccupied(deskId: string): boolean; + /** How many rows the floor's back office is built out, for its desks (see WING). */ + wing?(): number; spawn(deskId: string, by: string, prompt: string, worktree: boolean, kind: 'agent', provider: AgentProvider, model?: string, effort?: AgentEffort, meeting?: undefined, owner?: string): WorkerInfo | string; /** Resolves with a line about what became of the worker's worktree. */ kill(id: string): Promise<{ note?: string; error?: string }>; @@ -276,9 +278,9 @@ export class TaskQueue { return this.tasks.filter((t) => t.status === 'running').length; } - /** A free desk, else a free bean bag. */ + /** A free desk (in the back office too, as far as it's built), else a free bean bag. */ private freeDesk(): string | undefined { - return nextFreeSeat((id) => this.workers.deskOccupied(id))?.id; + return nextFreeSeat((id) => this.workers.deskOccupied(id), this.workers.wing?.() ?? 0)?.id; } /** diff --git a/src/server/server.ts b/src/server/server.ts index 8a6a53ab..94b5de9d 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -36,6 +36,7 @@ import type { ChatLine, ClientMsg, FloorInfo, FloorView, Me, MeetingRequest, Pee import { GH_COMMENT_MAX, GH_LABEL_MAX, isAgentEffort, isAgentProvider } from '../shared/protocol.js'; import { DESK_BY_ID, elevatorSpot, streetBelow } from '../shared/layout.js'; import { OFFICE_MAP, seatHereOn } from '../shared/maps/index.js'; +import { EMPTY_PLAN } from '../shared/floorplan.js'; import { JUKEBOX_TUNES, STREAM } from '../shared/jukebox.js'; import { checkFrame, scoreText, type CabinetFrame, type CabinetState } from '../shared/cabinet.js'; import { SEARCH_MAX, SEARCH_MIN, searchKey } from '../shared/search.js'; @@ -264,7 +265,7 @@ export async function startServer(cfg: Config) { }; const floorInfos = (): FloorInfo[] => [ ...[...floors.values()].map((f) => ({ ...f.info(), ...(building.isLocal(f.id) ? { local: true } : {}) })), - ...building.pending().map((d) => ({ id: d.id, name: d.name, repo: d.repo, dir: d.dir, palette: d.palette, addedBy: d.addedBy, addedAt: d.addedAt, cloning: true, workers: 0, busy: 0, waiting: 0, people: 0 })), + ...building.pending().map((d) => ({ id: d.id, name: d.name, repo: d.repo, dir: d.dir, palette: d.palette, addedBy: d.addedBy, addedAt: d.addedAt, cloning: true, workers: 0, busy: 0, waiting: 0, people: 0, wing: 0 })), ]; // The elevator's counts change with every worker update; tell everyone at most a few times a second. let floorsSent = ''; @@ -614,6 +615,7 @@ export async function startServer(cfg: Config) { pulls: floor?.github.pulls ?? { items: [], fetchedAt: 0, loading: false }, queue: floor?.queue.state() ?? { tasks: [], maxWorkers: 0 }, decor: floor?.decor.list() ?? [], + plan: floor?.plan.state() ?? EMPTY_PLAN, services: servicesState(floor), dog: floor?.dog.view() ?? null, ball: floor?.court.state() ?? {}, @@ -1134,6 +1136,11 @@ export async function startServer(cfg: Config) { }; const decorChanged = (floor: Floor) => toFloor(floor, { t: 'decor', items: floor.decor.list() }); + /** The floor's signs or back office changed: its people see it, and everyone sees the building's outside change. */ + const planChanged = (floor: Floor) => { + toFloor(floor, { t: 'plan', plan: floor.plan.state() }); + floorsChanged(); + }; const ballChanged = (floor: Floor) => toFloor(floor, { t: 'ball', ball: floor.court.state() }); const carsChanged = (floor: Floor) => toFloor(floor, { t: 'cars', cars: floor.garage.state() }); const jukeboxChanged = (floor: Floor) => toFloor(floor, { t: 'jukebox', state: floor.jukebox.state() }); @@ -2080,6 +2087,37 @@ export async function startServer(cfg: Config) { toastFloor(floor, `${who} took down ${d.title ? `β€œ${d.title}”` : 'a picture'}`); break; } + case 'desk.label': { + const floor = here(); + if (!floor) break; + const deskId = str(msg.deskId, 32); + const r = floor.plan.label(deskId, msg.text, msg.color, who); + if (typeof r === 'string') return warn(c, r); + if (!r.label && !r.old) break; + planChanged(floor); + const desk = DESK_BY_ID.get(deskId)?.label ?? 'a desk'; + if (r.label && r.label.text !== r.old?.text) toastFloor(floor, `πŸͺ§ ${who} hung a sign over ${desk}: β€œ${r.label.text}”`); + else if (!r.label) toastFloor(floor, `πŸͺ§ ${who} took the β€œ${r.old!.text}” sign down from ${desk}`); + break; + } + case 'floor.expand': { + const floor = here(); + if (!floor) break; + const r = floor.plan.expand(); + if (typeof r === 'string') return warn(c, r); + planChanged(floor); + toastFloor(floor, `πŸ”¨ ${who} knocked out the back wall: ${r.map((id) => DESK_BY_ID.get(id)?.label).join(' and ')} are ready for workers`); + break; + } + case 'floor.shrink': { + const floor = here(); + if (!floor) break; + const r = floor.plan.shrink((id) => floor.workers.deskOccupied(id)); + if (typeof r === 'string') return warn(c, r); + planChanged(floor); + toastFloor(floor, `🧱 ${who} walled the back office back up, and ${r.map((id) => DESK_BY_ID.get(id)?.label).join(' and ')} went with it`); + break; + } case 'wb.open': case 'wb.close': { const floor = floorOf(c); diff --git a/src/server/workers.ts b/src/server/workers.ts index 3946e5ce..82ec043e 100644 --- a/src/server/workers.ts +++ b/src/server/workers.ts @@ -12,7 +12,7 @@ import { FAILS_TO_DESPAIR, outputFailed, toolAction } from '../shared/actions.js import { FLAG_BOLD, FLAG_DIM, FLAG_INVERSE, RGB_FLAG, isAgentEffort, isClaudeModel } from '../shared/protocol.js'; import { WORKSPACE_FILES, WORKTREES_DIR, Worktrees, describeWork, workspaceOf, type WorktreeCleanup, type WorktreeRef, type WorktreeState } from './worktrees.js'; import { normalizeRepo } from '../shared/floors.js'; -import { DESK_BY_ID, STATION_AGENT } from '../shared/layout.js'; +import { DESK_BY_ID, STATION_AGENT, deskBuilt } from '../shared/layout.js'; import { QUEUE_AGENT_DISALLOWED_TOOLS, stationBrief } from './stations.js'; import { officePrompt, type PromptSource } from './prompts.js'; import { isBusy } from '../shared/status.js'; @@ -210,6 +210,8 @@ export class WorkerManager { private scrollback: ScrollbackStore; private drops: DropStore; private saveTimer: NodeJS.Timeout; + /** How many rows the floor's back office is built out: its desks past that aren't there to hire at (see WING). */ + wing: () => number = () => 0; constructor( private dir: string, @@ -346,6 +348,7 @@ export class WorkerManager { if (effortError) return effortError; const seat = DESK_BY_ID.get(deskId); if (!seat) return 'Unknown desk'; + if (!deskBuilt(seat, this.wing())) return `${seat.label} isn't built yet: expand the back office first`; if (this.deskOccupied(deskId)) return seat.station ? `The ${STATION_AGENT[seat.station].name} is already there` : `That ${seat.beanbag ? 'bean bag' : 'desk'} is taken`; if (kind === 'shell' && seat.station) return 'A board agent is always an agent, not a shell'; if (seat.station && !prompt?.trim()) return 'Tell the board agent what to do'; diff --git a/src/shared/floorplan.ts b/src/shared/floorplan.ts new file mode 100644 index 00000000..5fc12a0b --- /dev/null +++ b/src/shared/floorplan.ts @@ -0,0 +1,89 @@ +// A floor's own layout on top of the office everyone shares: the signs hung over its desks, and how +// far its back office is built out (see WING in layout.ts). Saved by server/floorplan.ts. + +import { DESKS, WING, WING_DESKS, wingLevel } from './layout.js'; + +/** A sign hanging from the ceiling over a desk, naming what it's for ("Operations", "Code cleanup"). */ +export interface DeskLabel { + text: string; + /** One of SIGN_COLORS. */ + color: string; + by: string; + at: number; +} + +export interface FloorPlan { + /** How many rows the back office is built out (0 is just the room), up to WING.rows. */ + wing: number; + /** Signs by desk id. */ + labels: Record; +} + +export const EMPTY_PLAN: FloorPlan = { wing: 0, labels: {} }; + +/** The longest a sign's text may be, in characters. */ +export const MAX_LABEL = 32; + +/** What a sign can be painted: its board, and the ink its letters are in. */ +export const SIGN_COLORS = [ + { color: '#2b2d42', ink: '#fffaf3', name: 'Navy' }, + { color: '#ef476f', ink: '#fffaf3', name: 'Pink' }, + { color: '#f78c6b', ink: '#2b2d42', name: 'Orange' }, + { color: '#ffd166', ink: '#2b2d42', name: 'Yellow' }, + { color: '#06d6a0', ink: '#2b2d42', name: 'Green' }, + { color: '#118ab2', ink: '#fffaf3', name: 'Blue' }, + { color: '#9b5de5', ink: '#fffaf3', name: 'Purple' }, +] as const; + +/** A few to start from, in the label window. */ +export const LABEL_IDEAS = ['Operations', 'Code cleanup', 'Frontend', 'Backend', 'Bug fixes', 'Docs', 'Infra', 'Research']; + +/** Desks that can have a sign: the room's and the back office's, not the bean bags, kiosks or meeting chairs. */ +const LABELABLE = new Set([...DESKS, ...WING_DESKS].map((d) => d.id)); + +export function canLabel(deskId: string): boolean { + return LABELABLE.has(deskId); +} + +/** A sign's text as it's hung: one line, no control characters, at most MAX_LABEL characters. */ +export function cleanLabel(text: unknown): string { + if (typeof text !== 'string') return ''; + const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim(); + return [...flat].slice(0, MAX_LABEL).join('').trim(); +} + +/** The paint a sign asked for, or the first one when it's none of SIGN_COLORS. */ +export function signColor(color: unknown): string { + return SIGN_COLORS.find((c) => c.color === color)?.color ?? SIGN_COLORS[0].color; +} + +/** The ink for letters on a board painted `color`. */ +export function signInk(color: string): string { + return SIGN_COLORS.find((c) => c.color === color)?.ink ?? SIGN_COLORS[0].ink; +} + +/** A plan read back from disk (or anywhere else it can't be trusted): what's valid of it. */ +export function cleanPlan(raw: unknown): FloorPlan { + const r = raw && typeof raw === 'object' ? (raw as Partial>) : {}; + const labels: Record = {}; + if (r.labels && typeof r.labels === 'object') { + for (const [id, l] of Object.entries(r.labels as Record)) { + if (!canLabel(id) || !l || typeof l !== 'object') continue; + const s = l as Partial>; + const text = cleanLabel(s.text); + if (!text) continue; + labels[id] = { text, color: signColor(s.color), by: typeof s.by === 'string' ? s.by : '?', at: typeof s.at === 'number' ? s.at : 0 }; + } + } + return { wing: wingLevel(r.wing), labels }; +} + +/** The desks a row of the back office brings: `row` from 1. */ +export function rowDesks(row: number) { + return WING_DESKS.filter((d) => d.wing === row); +} + +/** How many more rows the back office can take. */ +export function roomToGrow(plan: FloorPlan): number { + return WING.rows - plan.wing; +} diff --git a/src/shared/layout.ts b/src/shared/layout.ts index 72dd5616..d75a234c 100644 --- a/src/shared/layout.ts +++ b/src/shared/layout.ts @@ -19,6 +19,8 @@ export interface DeskDef { station?: StationKind; /** A chair at the meeting room's table (see MEETING_SEATS): only a meeting seats a worker here. */ room?: boolean; + /** A desk in the back office (see WING): there once the floor is built out this many rows. */ + wing?: number; } const DESK_WIDTH = 2.2; @@ -52,6 +54,60 @@ function buildDesks(): DeskDef[] { export const DESKS: DeskDef[] = buildDesks(); +/** + * The back office: a bay knocked through the north wall between the gong and the east wall, for a + * floor that needs more desks than the room has. Each time someone expands the floor (see + * shared/floorplan.ts), its back wall goes another `row` meters north, with two more desks back to + * back in the middle, up to `rows` times: any further and it would stand in the street behind the + * building (world/city.ts). It runs from `minX` (the gong keeps its bit of wall) to the east wall, + * and from the old north wall back to wingMinZ. + */ +export const WING = { minX: 13.4, maxX: FLOOR.maxX, row: 4.6, rows: 2 } as const; + +/** A floor built out `level` rows, as a whole number from 0 (just the room) to WING.rows. */ +export function wingLevel(level: unknown): number { + return typeof level === 'number' && Number.isFinite(level) ? Math.max(0, Math.min(WING.rows, Math.floor(level))) : 0; +} + +/** How far north the back office's back wall is, built out `level` rows: the north wall with none. */ +export function wingMinZ(level: number): number { + return FLOOR.minZ - wingLevel(level) * WING.row; +} + +/** Whether (x, z) is in the back office, built out `level` rows. */ +export function inWing(x: number, z: number, level: number): boolean { + return level > 0 && x > WING.minX && x < WING.maxX && z <= FLOOR.minZ && z > wingMinZ(level); +} + +/** The middle of the back office's row `row` (1 is the first, through the old north wall). */ +export function wingRowZ(row: number): number { + return FLOOR.minZ - (row - 0.5) * WING.row; +} + +/** + * The back office's desks: a back-to-back pair down the middle of each row, like half a pod, with + * room to walk round either side. The far one's worker faces the room; the near one's faces the back. + */ +export const WING_DESKS: DeskDef[] = Array.from({ length: WING.rows }, (_, i) => { + const z = wingRowZ(i + 1); + const x = (WING.minX + WING.maxX) / 2; + const n = DESKS.length + 2 * i + 1; + return [ + { id: `desk-${n}`, x, z: z - DESK_DEPTH / 2, rotY: Math.PI, label: `Desk ${n}`, wing: i + 1 }, + { id: `desk-${n + 1}`, x, z: z + DESK_DEPTH / 2, rotY: 0, label: `Desk ${n + 1}`, wing: i + 1 }, + ]; +}).flat(); + +/** Whether `desk` is there on a floor built out `level` rows: every desk in the room is. */ +export function deskBuilt(desk: DeskDef, level: number): boolean { + return !desk.wing || desk.wing <= level; +} + +/** Every desk on a floor built out `level` rows: the room's, then the back office's. */ +export function builtDesks(level: number): DeskDef[] { + return [...DESKS, ...WING_DESKS.filter((d) => deskBuilt(d, level))]; +} + /** * Overflow seats: once every desk is taken, bean bags come out around the room, one at a time in * this order. Each faces a window or a wall, with open floor behind it to walk up to. @@ -76,8 +132,8 @@ export const BEANBAGS: DeskDef[] = ( ] as const ).map(([x, z, rotY], i) => ({ id: `beanbag-${i + 1}`, x, z, rotY, label: `Bean bag ${i + 1}`, beanbag: true })); -/** Everywhere a worker can sit: the desks, then the bean bags. */ -export const SEATS: DeskDef[] = [...DESKS, ...BEANBAGS]; +/** Everywhere a worker can sit: the desks, the back office's once it's built out (see deskBuilt), then the bean bags. */ +export const SEATS: DeskDef[] = [...DESKS, ...WING_DESKS, ...BEANBAGS]; /** The boards with an agent standing by: the Issues board, the PR board and the task queue. */ export type StationKind = 'issues' | 'pulls' | 'queue'; @@ -134,21 +190,24 @@ export const MEETING_SEATS: DeskDef[] = ( /** The board on the meeting room's back (south) wall that shows the meeting's output file as it's written. */ export const MEETING_BOARD = { x: MEETING_TABLE.x, y: 1.95, z: FLOOR.maxZ - 0.08, width: 3.6, height: 1.2 } as const; -/** Any place a worker can be by id: the seats, the board agents' kiosks and the meeting room's chairs. */ +/** Any place a worker can be by id: the seats (the back office's included), the board agents' kiosks and the meeting room's chairs. */ export const DESK_BY_ID = new Map([...SEATS, ...STATIONS, ...MEETING_SEATS].map((d) => [d.id, d])); -/** The seat a new worker takes when nobody picks one: the first free desk, else the first free bean bag. */ -export function nextFreeSeat(taken: (id: string) => boolean): DeskDef | undefined { - return SEATS.find((d) => !taken(d.id)); +/** + * The seat a new worker takes when nobody picks one: the first free desk (in the back office too, as + * far as the floor is built out: `wing` rows), else the first free bean bag. + */ +export function nextFreeSeat(taken: (id: string) => boolean, wing = 0): DeskDef | undefined { + return SEATS.find((d) => !taken(d.id) && deskBuilt(d, wing)); } /** - * The bean bags that are out: every one in use, and while every desk is taken, the next free one - * too, so there's always somewhere to hire the next worker. + * The bean bags that are out: every one in use, and while every desk is taken (the back office's + * too, built out `wing` rows), the next free one too, so there's always somewhere to hire the next worker. */ -export function beanbagsOut(taken: (id: string) => boolean): Set { +export function beanbagsOut(taken: (id: string) => boolean, wing = 0): Set { const out = new Set(BEANBAGS.filter((b) => taken(b.id)).map((b) => b.id)); - if (DESKS.every((d) => taken(d.id))) { + if (builtDesks(wing).every((d) => taken(d.id))) { const spare = BEANBAGS.find((b) => !taken(b.id)); if (spare) out.add(spare.id); } @@ -225,6 +284,16 @@ export const PLANTS: readonly (readonly [x: number, z: number, scale: number])[] [8.5, 5, 1.1], ]; +/** A plant by the north wall east of the gong, in the way into the back office: put away once it's built. */ +export function plantByWing([x, z]: readonly [number, number, number]): boolean { + return x > WING.minX && z < FLOOR.minZ + 1.5; +} + +/** The plants standing on a floor built out `level` rows (see WING). */ +export function plantsAt(level: number): readonly (readonly [x: number, z: number, scale: number])[] { + return level > 0 ? PLANTS.filter((p) => !plantByWing(p)) : PLANTS; +} + /** * The whiteboard on wheels everyone draws on together, out on the open floor between the desks and * the lounge, facing into the room (+z). `width` and `height` are its writing surface, whose bottom diff --git a/src/shared/maps/index.ts b/src/shared/maps/index.ts index dd32d389..fdc41db4 100644 --- a/src/shared/maps/index.ts +++ b/src/shared/maps/index.ts @@ -1,4 +1,4 @@ -import { BEANBAGS, BOARDS, DESKS, ELEVATOR, ELEVATOR_CAR, EXIT_DOOR, FLOOR, MEETING_SEATS, SEATING, STATIONS, STATION_AGENT, WALL_HEIGHT, seatHere, seatPlace, type DeskDef, type SeatDef, type SeatPlace, type StationKind } from '../layout.js'; +import { BEANBAGS, BOARDS, DESKS, ELEVATOR, ELEVATOR_CAR, EXIT_DOOR, FLOOR, MEETING_SEATS, SEATING, STATIONS, STATION_AGENT, WALL_HEIGHT, WING_DESKS, seatHere, seatPlace, type DeskDef, type SeatDef, type SeatPlace, type StationKind } from '../layout.js'; import type { Circle, Rect } from '../nav.js'; import { CASTLE } from './castle.js'; import { boxFootprint, isPropKind, propFootprint, propTop } from './props.js'; @@ -36,8 +36,15 @@ const DEFAULT_BOARD_LABEL: Record = { issues: 'Issues', queue: // ---- The office ----------------------------------------------------------------------------------- +/** + * Every map's desks, by id: the office's room, then its back office (see WING), which is only there to + * sit at on a floor built out that far (deskBuilt), on whichever map, so a worker hired there has a + * seat on every map. + */ +const MAP_DESKS: DeskDef[] = [...DESKS, ...WING_DESKS]; + function officePlan(): MapPlan { - const byId = new Map([...DESKS, ...BEANBAGS, ...STATIONS, ...MEETING_SEATS].map((d) => [d.id, d])); + const byId = new Map([...MAP_DESKS, ...BEANBAGS, ...STATIONS, ...MEETING_SEATS].map((d) => [d.id, d])); const boards = {} as Record; for (const k of BOARD_KEYS) boards[k] = { ...BOARDS[k] }; return { @@ -49,7 +56,7 @@ function officePlan(): MapPlan { bounds: { ...FLOOR }, height: WALL_HEIGHT, spawn: { x: ELEVATOR.x, y: 0, z: (ELEVATOR_CAR.minZ + ELEVATOR_CAR.maxZ) / 2, rotY: 0 }, - desks: DESKS, + desks: MAP_DESKS, overflow: BEANBAGS, stations: STATIONS, meeting: MEETING_SEATS, @@ -192,16 +199,16 @@ export function planMap(input: unknown): MapPlan { } }); const all = [...inner, ...outer]; - const need = DESKS.length + BEANBAGS.length; - if (all.length < need) throw new MapError(`its tables seat ${all.length}, and a map needs ${need} (${DESKS.length} seats and ${BEANBAGS.length} more for when they’re all taken)`); + const need = MAP_DESKS.length + BEANBAGS.length; + if (all.length < need) throw new MapError(`its tables seat ${all.length}, and a map needs ${need} (${MAP_DESKS.length} seats and ${BEANBAGS.length} more for when they’re all taken)`); const counts = new Map(); const named = all.slice(0, need).map(({ def, table }) => { const k = (counts.get(table) ?? 0) + 1; counts.set(table, k); return { ...def, label: `${table}, seat ${k}` }; }); - const desks: DeskDef[] = named.slice(0, DESKS.length).map((d, i) => ({ ...d, id: DESKS[i].id })); - const overflow: DeskDef[] = named.slice(DESKS.length).map((d, i) => ({ ...d, id: BEANBAGS[i].id })); + const desks: DeskDef[] = named.slice(0, MAP_DESKS.length).map((d, i) => ({ ...d, id: MAP_DESKS[i].id })); + const overflow: DeskDef[] = named.slice(MAP_DESKS.length).map((d, i) => ({ ...d, id: BEANBAGS[i].id })); // The board agents' lecterns. if (!isObj(c.stations)) throw new MapError('it needs stations: where the Issues, PR and Queue agents stand'); diff --git a/src/shared/nav.ts b/src/shared/nav.ts index d849d41f..8fba585a 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -1,8 +1,10 @@ // Getting around a floor on a coarse grid, round the furniture: the dog's walks (server/dog.ts), a // worker's way out when it's sent home, and on a map of its own (see shared/maps), a worker's walks // about the hall. The office's grid is below; a map builds one from its plan (NavGrid). +// An office floor built out into the back office (see WING) has more of it to get round: the office's +// helpers take how many rows it's built out (`wing`), and each level gets a grid of its own. -import { BALCONY, BALCONY_DOOR, BEANBAGS, BOOKSHELF, CABINET, DESK_SIZE, DESKS, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, EXIT_STAIRS, FLOOR, GONG, JUKEBOX, KIOSK, LADDER, LOFT, MEETING_ROOM, MEETING_SEATS, MEETING_TABLE, PARACHUTE, PLANTS, POLE, POLES, ROAD, STAIRS, STATIONS, WHITEBOARD, type DeskDef } from './layout.js'; +import { BALCONY, BALCONY_DOOR, BEANBAGS, BOOKSHELF, CABINET, DESK_SIZE, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, EXIT_STAIRS, FLOOR, GONG, JUKEBOX, KIOSK, LADDER, LOFT, MEETING_ROOM, MEETING_SEATS, MEETING_TABLE, PARACHUTE, POLE, POLES, ROAD, STAIRS, STATIONS, WHITEBOARD, WING, builtDesks, plantsAt, wingLevel, wingMinZ, type DeskDef } from './layout.js'; export type Pt = [number, number]; @@ -25,19 +27,27 @@ export interface Bounds { minZ: number; maxZ: number; } +/** Whether (x, z) is on the floor, at least `m` in from its walls: for a floor that isn't just its bounds. */ +export type Floorplan = (x: number, z: number, m: number) => boolean; + +/** A floor that fills its bounds. */ +const within = + (b: Bounds): Floorplan => + (x, z, m) => + x > b.minX + m && x < b.maxX - m && z > b.minZ + m && z < b.maxZ - m; /** The desk's own frame: `t` along its width, `s` out toward the side the worker sits on. */ export function deskPoint(d: DeskDef, t: number, s: number): Pt { return [d.x + Math.cos(d.rotY) * t + Math.sin(d.rotY) * s, d.z - Math.sin(d.rotY) * t + Math.cos(d.rotY) * s]; } -/** What's in the way on the floor. The lounge, kitchen and plants are where office.ts (and kitchen.ts) put them. */ -function obstacles(): Obstacles { +/** What's in the way on the office floor, built out `wing` rows. The lounge, kitchen and plants are where office.ts (and kitchen.ts) put them. */ +function obstacles(wing: number): Obstacles { const rects: Rect[] = []; const circles: Circle[] = []; const hw = DESK_SIZE.width / 2; const hd = DESK_SIZE.depth / 2; - for (const d of DESKS) { + for (const d of builtDesks(wing)) { // Desks face Β±z, so their tops are axis-aligned. rects.push([d.x - hw, d.x + hw, d.z - hd, d.z + hd]); const [cx, cz] = deskPoint(d, 0, 0.9); @@ -47,7 +57,7 @@ function obstacles(): Obstacles { rects.push([12.2, 13.8, -0.8, 0.8]); // coffee table circles.push([12.5, 3.5, 0.5], [14.5, -3.4, 0.5]); // beanbags rects.push([-17, -10.75, 11.7, 12.7]); // kitchen counter and fridge - for (const [x, z, s] of PLANTS) circles.push([x, z, 0.3 * s]); + for (const [x, z, s] of plantsAt(wing)) circles.push([x, z, 0.3 * s]); // The loft's posts, the stairs up to it, and the elevator shaft. for (const x of [LOFT.minX + 0.15, (LOFT.minX + LOFT.maxX) / 2]) circles.push([x, LOFT.minZ + 0.15, 0.14]); rects.push([STAIRS.fromX, STAIRS.toX, STAIRS.minZ - 0.1, STAIRS.maxZ]); @@ -99,8 +109,8 @@ function obstacles(): Obstacles { } /** Whether (x, z) is too close to anything in the way, or to the walls, to stand in. */ -function isBlocked(x: number, z: number, b: Bounds, o: Obstacles): boolean { - if (x < b.minX + R || x > b.maxX - R || z < b.minZ + R || z > b.maxZ - R) return true; +function isBlocked(x: number, z: number, on: Floorplan, o: Obstacles): boolean { + if (!on(x, z, R)) return true; for (const [x0, x1, z0, z1] of o.rects) if (x > x0 - R && x < x1 + R && z > z0 - R && z < z1 + R) return true; for (const [cx, cz, r] of o.circles) if (Math.hypot(x - cx, z - cz) < r + R) return true; return false; @@ -112,14 +122,16 @@ export class NavGrid { readonly rows: number; private readonly grid: Uint8Array; + /** `on`: the floor's shape, when it doesn't fill `bounds` (the office built out into its back office). */ constructor( readonly bounds: Bounds, obstacles: Obstacles, + private readonly on: Floorplan = within(bounds), ) { this.cols = Math.ceil((bounds.maxX - bounds.minX) / CELL); this.rows = Math.ceil((bounds.maxZ - bounds.minZ) / CELL); this.grid = new Uint8Array(this.cols * this.rows); - for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) this.grid[r * this.cols + c] = isBlocked(bounds.minX + (c + 0.5) * CELL, bounds.minZ + (r + 0.5) * CELL, bounds, obstacles) ? 1 : 0; + for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) this.grid[r * this.cols + c] = isBlocked(bounds.minX + (c + 0.5) * CELL, bounds.minZ + (r + 0.5) * CELL, on, obstacles) ? 1 : 0; } private colOf(x: number) { @@ -135,8 +147,7 @@ export class NavGrid { } walkable(x: number, z: number): boolean { - const b = this.bounds; - return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ && !this.grid[this.rowOf(z) * this.cols + this.colOf(x)]; + return this.on(x, z, 0) && !this.grid[this.rowOf(z) * this.cols + this.colOf(x)]; } /** Whether it can trot straight from a to b: every cell the line crosses is clear. */ @@ -266,8 +277,11 @@ export class NavGrid { ? [deskPoint(seat, side * 1.05, 0.1), deskPoint(seat, side * 1.05, 1.25)] : seat.station ? [deskPoint(seat, side * 0.95, KIOSK.stand), deskPoint(seat, side * 0.95, -1)] - : // At the meeting table there's less room behind the chair, before the glass. - [deskPoint(seat, side * 0.7, 0.95), deskPoint(seat, side * 0.7, seat.room ? 1.4 : 1.75)]; + : seat.wing + ? // In the back office the chair has its back to a wall or the next row: out to the side of it instead. + [deskPoint(seat, side * 0.7, 0.95), deskPoint(seat, side * 0.95, 1.25)] + : // At the meeting table there's less room behind the chair, before the glass. + [deskPoint(seat, side * 0.7, 0.95), deskPoint(seat, side * 0.7, seat.room ? 1.4 : 1.75)]; // A bean bag or a kiosk can stand with one side up against something (the elevator, by the queue). const blocked = !!(seat.beanbag || seat.station) && !this.walkable(down[0], down[1]); const pts = [down, ...this.route(back, to)]; @@ -292,21 +306,32 @@ export class NavGrid { /** How far it is along `pts`, corner to corner. */ export const pathLength = (pts: Pt[]) => pts.reduce((n, p, i) => (i ? n + Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]) : 0), 0); -/** The office floor downstairs (no stairs, no loft, no elevator). */ -export const OFFICE_NAV = new NavGrid(FLOOR, obstacles()); +/** The office floor downstairs (no stairs, no loft, no elevator), built out `wing` rows, made the first time it's needed. */ +const OFFICE_NAVS: NavGrid[] = []; +export function officeNav(wing = 0): NavGrid { + const level = wingLevel(wing); + // Through where the north wall was, into the back office: between its walls, short of its back one. + const on: Floorplan = (x, z, m) => + (x > FLOOR.minX + m && x < FLOOR.maxX - m && z > FLOOR.minZ + m && z < FLOOR.maxZ - m) || + (level > 0 && x > WING.minX + m && x < WING.maxX - m && z > wingMinZ(level) + m && z < FLOOR.maxZ - m); + return (OFFICE_NAVS[level] ??= new NavGrid({ ...FLOOR, minZ: wingMinZ(level) }, obstacles(level), on)); +} + +/** The office floor as it is until it's built out. */ +export const OFFICE_NAV = officeNav(0); -export function walkable(x: number, z: number): boolean { - return OFFICE_NAV.walkable(x, z); +export function walkable(x: number, z: number, wing = 0): boolean { + return officeNav(wing).walkable(x, z); } /** The middle of the nearest cell it can stand in on the office floor. */ -export function nearestWalkable(p: Pt): Pt { - return OFFICE_NAV.nearestWalkable(p); +export function nearestWalkable(p: Pt, wing = 0): Pt { + return officeNav(wing).nearestWalkable(p); } /** A* over the office floor's grid, then pulled tight: the corners of a route from `from` to `to`, both included. */ -export function route(from: Pt, to: Pt): Pt[] { - return OFFICE_NAV.route(from, to); +export function route(from: Pt, to: Pt, wing = 0): Pt[] { + return officeNav(wing).route(from, to); } class Heap { @@ -374,8 +399,8 @@ const IN_FROM: Pt = [ELEVATOR.x, ELEVATOR_FRONT + 0.5]; * A worker's walk in to its seat when it's called to a meeting: out of the elevator and round the * furniture to beside its chair (the last point), on whichever side is the shorter way, where it hops on. */ -export function wayIn(seat: DeskDef): Pt[] { - return OFFICE_NAV.wayTo(IN_FROM, seat); +export function wayIn(seat: DeskDef, wing = 0): Pt[] { + return officeNav(wing).wayTo(IN_FROM, seat); } /** @@ -384,8 +409,8 @@ export function wayIn(seat: DeskDef): Pt[] { * furniture to the exit door in the west wall, across the landing outside, down the steps to the * street and off along the sidewalk. */ -export function wayHome(seat: DeskDef): Pt[] { - const inside = wayTo(seat, EXIT); +export function wayHome(seat: DeskDef, wing = 0): Pt[] { + const inside = wayTo(seat, EXIT, wing); const { landingZ1, steps, run } = EXIT_STAIRS; return [...inside, [STEPS_X, EXIT_DOOR.u], ...walkOff([STEPS_X, landingZ1 + (steps - 1) * run + 0.6])]; } @@ -394,8 +419,8 @@ export function wayHome(seat: DeskDef): Pt[] { * The same walk on a floor above the bottom one, which has no exit door: round the furniture to the * balcony doors, out across the balcony and up to its railing (PARACHUTE.jump), where it goes over. */ -export function wayToBalcony(seat: DeskDef): Pt[] { - const inside = wayTo(seat, BALCONY_IN); +export function wayToBalcony(seat: DeskDef, wing = 0): Pt[] { + const inside = wayTo(seat, BALCONY_IN, wing); return [...inside, [BALCONY_DOOR.u, BALCONY.minZ + 0.4], [PARACHUTE.jump.x, PARACHUTE.jump.z]]; } @@ -405,6 +430,6 @@ export function walkOff(from: Pt): Pt[] { } /** From beside `seat`, where it hops down, round the furniture to `door` on the office floor. */ -function wayTo(seat: DeskDef, door: Pt): Pt[] { - return OFFICE_NAV.wayFrom(seat, door); +function wayTo(seat: DeskDef, door: Pt, wing: number): Pt[] { + return officeNav(wing).wayFrom(seat, door); } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 09ec5f90..3e248b9d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -5,6 +5,7 @@ import type { BarGame } from './bargames.js'; import type { CabinetFrame, CabinetState, CabinetView } from './cabinet.js'; import type { DecorPlacement, Decoration } from './decor.js'; import type { DogState } from './dog.js'; +import type { FloorPlan } from './floorplan.js'; import type { EmoteId } from './emotes.js'; import type { CarSeat, CarState } from './garage.js'; import type { BallState } from './hoop.js'; @@ -704,6 +705,8 @@ export interface FloorInfo { /** Workers waiting on someone: a question, a permission, or a finished turn nobody looked at. */ waiting: number; people: number; + /** How many rows its back office is built out (see WING), for the building's outside. */ + wing: number; } /** Where the elevator's "add a project" clones to: // on the office's machine. */ @@ -737,6 +740,8 @@ export interface FloorView { queue: QueueState; /** Pictures on this floor's walls. */ decor: Decoration[]; + /** The signs over this floor's desks, and how far its back office is built out. */ + plan: FloorPlan; services: ServicesState; /** The floor's dog; null in a building with no floors yet. */ dog: DogState | null; @@ -1189,6 +1194,11 @@ export type ClientMsg = /** Move, resize, re-frame or swap the image of a picture. */ | { t: 'decor.update'; id: string; decor: Partial } | { t: 'decor.remove'; id: string } + /** Hang a sign over a desk on your floor (a SIGN_COLORS color), or take it down with no text. */ + | { t: 'desk.label'; deskId: string; text: string; color?: string } + /** Knock the back office out another row, with two more desks; or wall its last row back up. */ + | { t: 'floor.expand' } + | { t: 'floor.shrink' } /** Put a tune on the jukebox (a JUKEBOX_TUNES id), or a stream; with neither, turn it back on. */ | { t: 'jukebox.play'; track?: string; url?: string } /** On to the next tune. */ @@ -1337,6 +1347,8 @@ export type ServerMsg = | { t: 'upgrade'; state: UpgradeState } | { t: 'services'; state: ServicesState } | { t: 'decor'; items: Decoration[] } + /** Your floor's signs changed, or its back office was built out or walled up. */ + | { t: 'plan'; plan: FloorPlan } /** What the dog on your floor is up to now: sent at the start of each leg of its day. */ | { t: 'dog'; dog: DogState } /** The basketball on your floor was picked up, thrown, or put back under the hoop. */ diff --git a/tests/floorplan.test.ts b/tests/floorplan.test.ts new file mode 100644 index 00000000..3234030b --- /dev/null +++ b/tests/floorplan.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { FloorPlanStore } from '../src/server/floorplan.js'; +import { MAX_LABEL, SIGN_COLORS, cleanLabel, cleanPlan } from '../src/shared/floorplan.js'; +import { BEANBAGS, DESKS, WING, WING_DESKS, beanbagsOut, builtDesks, nextFreeSeat } from '../src/shared/layout.js'; + +function withDir(fn: (dir: string) => void) { + const dir = mkdtempSync(path.join(tmpdir(), 'agent-office-floorplan-')); + try { + fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('signs go up over desks, get changed and come down, and stay across restarts', () => { + withDir((dir) => { + const plan = new FloorPlanStore(dir); + const hung = plan.label('desk-3', ' Code cleanup ', SIGN_COLORS[4].color, 'Ada'); + assert.ok(typeof hung !== 'string' && hung.label && !hung.old); + assert.deepEqual({ ...plan.state().labels['desk-3'], at: 0 }, { text: 'Code cleanup', color: SIGN_COLORS[4].color, by: 'Ada', at: 0 }); + // A color that isn't one of the signs' is the first one. + const changed = plan.label('desk-3', 'Operations', '#123456', 'Bo'); + assert.ok(typeof changed !== 'string' && changed.old?.text === 'Code cleanup'); + assert.equal(plan.state().labels['desk-3'].color, SIGN_COLORS[0].color); + // Across a restart. + assert.equal(new FloorPlanStore(dir).state().labels['desk-3'].text, 'Operations'); + const down = plan.label('desk-3', '', undefined, 'Bo'); + assert.ok(typeof down !== 'string' && !down.label && down.old?.text === 'Operations'); + assert.deepEqual(new FloorPlanStore(dir).state().labels, {}); + // Taking down a sign that isn't there does nothing. + assert.deepEqual(plan.label('desk-3', ' ', undefined, 'Bo'), {}); + }); +}); + +test('only desks get signs: not bean bags, kiosks or the meeting table', () => { + withDir((dir) => { + const plan = new FloorPlanStore(dir); + for (const id of ['beanbag-1', 'station-queue', 'meeting-1', 'nope']) assert.equal(typeof plan.label(id, 'Ops', undefined, 'Ada'), 'string', id); + // The back office's desks can have one before they're built, ready for when they are. + assert.equal(typeof plan.label(WING_DESKS[0].id, 'Ops', undefined, 'Ada'), 'object'); + }); +}); + +test("a sign's text is one tidy line, no longer than a sign", () => { + assert.equal(cleanLabel('a\nb\tc\u0007d'), 'a b c d'); + assert.equal([...cleanLabel('πŸš€'.repeat(40))].length, MAX_LABEL); + assert.equal(cleanLabel(42), ''); +}); + +test('the back office goes back a row at a time, as far as it can, and walls up only when nobody is there', () => { + withDir((dir) => { + const plan = new FloorPlanStore(dir); + assert.equal(plan.wing, 0); + assert.equal(typeof plan.shrink(() => false), 'string', 'nothing to wall up yet'); + for (let row = 1; row <= WING.rows; row++) { + const came = plan.expand(); + assert.deepEqual(came, WING_DESKS.filter((d) => d.wing === row).map((d) => d.id)); + } + assert.equal(typeof plan.expand(), 'string', "it can't go back any further"); + assert.equal(new FloorPlanStore(dir).wing, WING.rows, 'it stays built across a restart'); + const last = WING_DESKS.filter((d) => d.wing === WING.rows); + assert.match(plan.shrink((id) => id === last[1].id) as string, new RegExp(last[1].label)); + assert.equal(plan.wing, WING.rows); + assert.deepEqual(plan.shrink(() => false), last.map((d) => d.id)); + assert.equal(plan.wing, WING.rows - 1); + }); +}); + +test('a broken or tampered plan file comes back as what is valid of it', () => { + withDir((dir) => { + writeFileSync(path.join(dir, 'floorplan.json'), JSON.stringify({ wing: 99, labels: { 'desk-1': { text: 'Ops', color: 'red' }, 'beanbag-2': { text: 'x' }, 'desk-2': { text: ' ' } } })); + const plan = new FloorPlanStore(dir).state(); + assert.equal(plan.wing, WING.rows); + assert.deepEqual(Object.keys(plan.labels), ['desk-1']); + assert.equal(plan.labels['desk-1'].color, SIGN_COLORS[0].color); + writeFileSync(path.join(dir, 'floorplan.json'), '{nope'); + assert.deepEqual(new FloorPlanStore(dir).state(), cleanPlan(undefined)); + assert.ok(readFileSync(path.join(dir, 'floorplan.json'), 'utf8')); + }); +}); + +test('new workers take the back office desks once it is built, before any bean bag', () => { + const taken = new Set(DESKS.map((d) => d.id)); + assert.equal(nextFreeSeat((id) => taken.has(id))?.id, BEANBAGS[0].id, 'with no back office, the bean bags come out'); + assert.equal(nextFreeSeat((id) => taken.has(id), 1)?.id, WING_DESKS[0].id); + // Bean bags only come out once the back office's desks are taken too. + assert.equal(beanbagsOut((id) => taken.has(id)).size, 1); + assert.equal(beanbagsOut((id) => taken.has(id), 1).size, 0); + for (const d of builtDesks(1)) taken.add(d.id); + assert.equal(beanbagsOut((id) => taken.has(id), 1).size, 1); + assert.equal(nextFreeSeat((id) => taken.has(id), 1)?.id, BEANBAGS[0].id); +}); diff --git a/tests/interaction.test.ts b/tests/interaction.test.ts index 54893b0e..30a045e3 100644 --- a/tests/interaction.test.ts +++ b/tests/interaction.test.ts @@ -35,3 +35,13 @@ test('carried issue actions still consume E at their valid destinations', () => assert.equal(interactionAvailable(target, 'E', state({ carrying: true })), true); } }); + +test('L hangs a sign over any desk, empty or not, but not over a bean bag or a meeting chair', () => { + const worker = { id: 'worker-1', status: 'working' } as InteractionState['worker']; + assert.equal(interactionAvailable(interaction('desk', { deskId: 'desk-1' }), 'L', state()), true); + assert.equal(interactionAvailable(interaction('desk', { deskId: 'desk-1' }), 'L', state({ worker })), true); + assert.equal(interactionAvailable(interaction('desk', { deskId: 'beanbag-1' }), 'L', state()), false); + assert.equal(interactionAvailable(interaction('desk', { deskId: 'meeting-1' }), 'L', state({ room: true })), false); + assert.equal(interactionAvailable(interaction('expand'), 'E', state()), true); + assert.equal(interactionAvailable(null, 'L', state()), false); +}); diff --git a/tests/maps.test.ts b/tests/maps.test.ts index 1dca8393..14d0d191 100644 --- a/tests/maps.test.ts +++ b/tests/maps.test.ts @@ -84,7 +84,7 @@ test("a map that can't be used says why, and the building stays on the office", ]); const why = Object.fromEntries(checked.map((m) => [m.file, m.error ?? ''])); assert.match(why['office.json'], /office is built in code/); - assert.match(why['small.json'], /seat 4, and a map needs 28/); + assert.match(why['small.json'], /seat 4, and a map needs 32/); assert.match(why['far.json'], /outside the hall/); assert.match(why['thing.json'], /spaceship/); assert.match(why['dupe.json'], /built-in map/); diff --git a/tests/nav.test.ts b/tests/nav.test.ts index d60ae1a0..7a0c33f1 100644 --- a/tests/nav.test.ts +++ b/tests/nav.test.ts @@ -1,11 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { BALCONY, BALCONY_DOOR, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, EXIT_STAIRS, FLOOR, MEETING_ROOM, MEETING_SEATS, PARACHUTE, ROAD, SEATS, STATIONS } from '../src/shared/layout.js'; -import { walkable, wayHome, wayIn, wayToBalcony, type Pt } from '../src/shared/nav.js'; +import { BALCONY, BALCONY_DOOR, ELEVATOR, ELEVATOR_FRONT, EXIT_DOOR, EXIT_STAIRS, FLOOR, MEETING_ROOM, MEETING_SEATS, PARACHUTE, ROAD, SEATS, STATIONS, WING, deskBuilt, wingMinZ, type DeskDef } from '../src/shared/layout.js'; +import { route, walkable, wayHome, wayIn, wayToBalcony, type Pt } from '../src/shared/nav.js'; + +/** Every place a worker can be on a floor built out `wing` rows, at each build-out there is. */ +const everywhere = (): [DeskDef, number][] => Array.from({ length: WING.rows + 1 }, (_, wing) => [...SEATS, ...STATIONS, ...MEETING_SEATS].filter((d) => deskBuilt(d, wing)).map((d): [DeskDef, number] => [d, wing])).flat(); test('a worker sent home walks round the furniture, out the exit door and off along the sidewalk', () => { - for (const seat of [...SEATS, ...STATIONS, ...MEETING_SEATS]) { - const way = wayHome(seat); + for (const [seat, wing] of everywhere()) { + const way = wayHome(seat, wing); // It hops down right beside where it sat. assert.ok(Math.hypot(way[0][0] - seat.x, way[0][1] - seat.z) < 1.2, `${seat.id} hops down beside its seat`); const out = way.findIndex(([x]) => x < FLOOR.minX); @@ -18,7 +21,7 @@ test('a worker sent home walks round the furniture, out the exit door and off al for (let k = 0; k <= n; k++) { const x = x0 + ((x1 - x0) * k) / n; const z = z0 + ((z1 - z0) * k) / n; - assert.ok(walkable(x, z), `${seat.id} walks into something at (${x.toFixed(2)}, ${z.toFixed(2)})`); + assert.ok(walkable(x, z, wing), `${seat.id} (built out ${wing}) walks into something at (${x.toFixed(2)}, ${z.toFixed(2)})`); } } // Through the doorway, not the wall beside it. @@ -59,8 +62,8 @@ test('a worker called to a meeting walks from the elevator, in through the meeti }); test('upstairs, with no exit door, a worker sent home walks out onto the balcony to the railing', () => { - for (const seat of [...SEATS, ...STATIONS, ...MEETING_SEATS]) { - const way = wayToBalcony(seat); + for (const [seat, wing] of everywhere()) { + const way = wayToBalcony(seat, wing); assert.ok(Math.hypot(way[0][0] - seat.x, way[0][1] - seat.z) < 1.2, `${seat.id} hops down beside its seat`); const out = way.findIndex(([, z]) => z > FLOOR.maxZ); assert.ok(out > 1, `${seat.id} goes out onto the balcony`); @@ -71,7 +74,7 @@ test('upstairs, with no exit door, a worker sent home walks out onto the balcony for (let k = 0; k <= n; k++) { const x = x0 + ((x1 - x0) * k) / n; const z = z0 + ((z1 - z0) * k) / n; - assert.ok(walkable(x, z), `${seat.id} walks into something at (${x.toFixed(2)}, ${z.toFixed(2)})`); + assert.ok(walkable(x, z, wing), `${seat.id} (built out ${wing}) walks into something at (${x.toFixed(2)}, ${z.toFixed(2)})`); } } // Through the balcony doors, then straight across to the railing. @@ -81,3 +84,19 @@ test('upstairs, with no exit door, a worker sent home walks out onto the balcony assert.ok(jz < BALCONY.maxZ && jz > BALCONY.maxZ - 0.6, `${seat.id} ends up at the railing`); } }); + +test('the back office is only floor once it is built out, and only as far as it goes', () => { + const inside: Pt = [(WING.minX + WING.maxX) / 2 + 1.7, FLOOR.minZ - 1]; + assert.equal(walkable(inside[0], inside[1], 0), false, 'behind the north wall there is nothing'); + for (let wing = 1; wing <= WING.rows; wing++) { + assert.ok(walkable(inside[0], inside[1], wing), `built out ${wing}, its first row is open floor`); + assert.equal(walkable(inside[0], wingMinZ(wing) - 0.5, wing), false, `built out ${wing}, past its back wall is not`); + // And there's a way from the room to the back of it. + const back: Pt = [WING.minX + 0.6, wingMinZ(wing) + 0.6]; + const way = route([0, 0], back, wing); + const [ex, ez] = way[way.length - 1]; + assert.ok(Math.hypot(ex - back[0], ez - back[1]) < 0.6, `built out ${wing}, the route gets to the back of it`); + } + // Nowhere else through the north wall. + assert.equal(walkable(0, FLOOR.minZ - 1, WING.rows), false); +});