diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index a59e1981..222a4f42 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -343,7 +343,6 @@ export const zh: Record = { "That file could not be read as an image.": "无法将该文件读取为图片。", "Conversation messages": "会话消息", "Office tools": "办公室工具", - "Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.": "有工作的智能体会一直待在工位;只有空闲的智能体才会去咖啡机、零食区或会议桌。", "Avatar": "头像", "Avatar emoji": "头像表情", "Avatar color": "头像颜色", @@ -709,8 +708,6 @@ export const zh: Record = { "Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。", "Stop waiting": "停止等待", "People to mention": "可提及的人", - "Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.": "只有任务会被执行:智能体接手任务并完成工作。目标和项目用来归组任务;文档和知识是智能体在工作中读写的内容。", - "Agents read and write these while working on tasks. Nothing here starts work; a task does.": "智能体在执行任务时读写这些内容。这里的内容不会自行启动工作,只有任务会。", "{title}: use the arrow keys to read each value": "{title}:用方向键逐项读取数值", "{rate}% of judged work accepted": "已评判的工作中 {rate}% 被接受", "No work judged yet": "还没有评判过的工作", diff --git a/src/renderer/src/pages/LibraryPage.tsx b/src/renderer/src/pages/LibraryPage.tsx index 889e8cf5..d32d9a27 100644 --- a/src/renderer/src/pages/LibraryPage.tsx +++ b/src/renderer/src/pages/LibraryPage.tsx @@ -1,4 +1,3 @@ -import { HintBubble } from "../shared/HintBubble"; import { oneOf, usePersistentState } from "../shared/view-state"; import { useMemo } from "react"; import { DocumentsPanel } from "./library/DocumentsPanel"; @@ -50,7 +49,6 @@ export function LibraryPage({ snapshot, actions, focus }: PageProps) {

{tab === "documents" ? t("Documents") : t("Knowledge")}

- {t("Agents read and write these while working on tasks. Nothing here starts work; a task does.")} {tab === "documents" ? : } diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 526909d7..eb5cc62a 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -259,12 +259,11 @@ export function OfficePage({ snapshot }: PageProps) { aria-label={t("Show a person")} value={selectedId ?? ""} onChange={(event) => setSelectedId(event.target.value || null)} - style={{ width: 200, height: 30 }} + style={{ width: 200 }} > {inputs.map((input) => )} - {t("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")}
); diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index 4288ad12..13e61d19 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -1,4 +1,3 @@ -import { HintBubble } from "../shared/HintBubble"; import { oneOf, usePersistentState } from "../shared/view-state"; import { useMemo, useState } from "react"; import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui"; @@ -114,7 +113,6 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {

{t("Work")}

- {t("Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.")} {scope && (
diff --git a/src/renderer/src/pages/office/iso.ts b/src/renderer/src/pages/office/iso.ts index cbf43726..07e3a35a 100644 --- a/src/renderer/src/pages/office/iso.ts +++ b/src/renderer/src/pages/office/iso.ts @@ -36,14 +36,9 @@ export function setGroundTransform(ctx: CanvasRenderingContext2D, frame: IsoFram ctx.transform(K, K / 2, -K, K / 2, frame.originX, SKY_MARGIN); } -/** Draws face-space art onto the south face of the back wall at plan y = `wallY`: x runs along the wall, y down from its top. */ -export function setNorthWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallY: number): void { - ctx.transform(K, K / 2, 0, 1, frame.originX - wallY * K, SKY_MARGIN + (wallY * K) / 2 - BACK_WALL_H); -} - -/** Draws face-space art onto the east face of the back wall at plan x = `wallX`: x runs south along the wall, y down from its top. */ -export function setWestWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallX: number): void { - ctx.transform(-K, K / 2, 0, 1, frame.originX + wallX * K, SKY_MARGIN + (wallX * K) / 2 - BACK_WALL_H); +/** Draws face-space art onto a south-facing face at plan y = `planeY` whose top is at height `topZ`: x runs along the face, y down from its top. */ +export function setSouthFaceTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, planeY: number, topZ: number): void { + ctx.transform(K, K / 2, 0, 1, frame.originX - planeY * K, SKY_MARGIN + (planeY * K) / 2 - topZ); } export interface BoxColors { diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 921258b6..61ce5518 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -1,7 +1,7 @@ import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types"; import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer"; import { depthOf, toIso, type IsoFrame } from "./iso"; -import { skyScene } from "./sky"; +import { skyScene, weatherFor } from "./sky"; import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; import type { DayPart, Season } from "../../shared/palette-clock"; @@ -77,9 +77,9 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle sctx.translate(-Math.round(input.camera.x), -Math.round(input.camera.y)); drawGround(sctx, input.frame, input.plan); - drawBackWalls(sctx, input.frame, input.plan, skyScene(input.clock)); + drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs }); const lit = new Set(input.people.filter((person) => person.desk && (person.activity === "active" || person.activity === "thinking")).map((person) => person.desk!)); - const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit), ...input.people.map((person) => personDrawable(input, person))]; + const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit, input.timeMs), ...input.people.map((person) => personDrawable(input, person))]; items.sort((a, b) => a.depth - b.depth); for (const item of items) item.draw(sctx); diff --git a/src/renderer/src/pages/office/props.ts b/src/renderer/src/pages/office/props.ts new file mode 100644 index 00000000..8ee9c7a2 --- /dev/null +++ b/src/renderer/src/pages/office/props.ts @@ -0,0 +1,87 @@ +import type { Rect } from "./office-types"; +import { drawBox, setSouthFaceTransform, toIso, type IsoFrame } from "./iso"; + +/** Hand angles in radians, clockwise from twelve, for a local time. */ +export function clockHands(date: Date): { hour: number; minute: number } { + const minutes = date.getMinutes(); + return { + hour: (((date.getHours() % 12) + minutes / 60) / 12) * 2 * Math.PI, + minute: (minutes / 60) * 2 * Math.PI, + }; +} + +/** A 16-pixel wall clock in wall-face coordinates (x along the wall, y down), showing the real time. */ +export function drawWallClock(ctx: CanvasRenderingContext2D, x: number, y: number, date: Date): void { + ctx.fillStyle = "#3f3831"; + ctx.fillRect(x + 2, y, 12, 16); + ctx.fillRect(x, y + 2, 16, 12); + ctx.fillStyle = "#f2efe8"; + ctx.fillRect(x + 2, y + 2, 12, 12); + ctx.fillStyle = "#8a8378"; + for (const [tx, ty] of [[7, 2], [12, 7], [7, 12], [2, 7]] as const) ctx.fillRect(x + tx, y + ty, 2, 1); + const hands = clockHands(date); + hand(ctx, x + 8, y + 8, hands.minute, 5, "#2b2622"); + hand(ctx, x + 8, y + 8, hands.hour, 3, "#b3543f"); +} + +function hand(ctx: CanvasRenderingContext2D, cx: number, cy: number, angle: number, length: number, color: string): void { + ctx.fillStyle = color; + for (let step = 0; step <= length; step++) { + ctx.fillRect(Math.round(cx + Math.sin(angle) * step - 0.5), Math.round(cy - Math.cos(angle) * step - 0.5), 1, 1); + } +} + +/** A desk monitor's screen on its south face: scrolling code and a blinking cursor while its agent works, a standby light otherwise. */ +export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, on: boolean, timeMs: number, seed: number): void { + ctx.save(); + setSouthFaceTransform(ctx, frame, monitor.y + monitor.h, z + height); + const glass = { x: monitor.x + 2, y: 2, w: monitor.w - 4, h: height - 4 }; + ctx.fillStyle = on ? "#0f2a1c" : "#20242c"; + ctx.fillRect(glass.x, glass.y, glass.w, glass.h); + if (on) { + const scroll = Math.floor(timeMs / 350); + ctx.fillStyle = "#37d38a"; + for (let row = 0; row < 4; row++) { + const width = 4 + ((seed * 7 + (scroll + row) * 13) % (glass.w - 6)); + ctx.fillRect(glass.x + 1 + (row % 2) * 2, glass.y + 1 + row * 2, width, 1); + } + if (Math.floor(timeMs / 500) % 2 === 0) ctx.fillRect(glass.x + glass.w - 3, glass.y + glass.h - 2, 2, 1); + } else { + ctx.fillStyle = "#e0a23a"; + ctx.fillRect(glass.x + glass.w - 2, glass.y + glass.h - 1, 1, 1); + } + ctx.restore(); +} + +/** The snack bar's coffee machine: body, cap, a lit button and a cup, with steam curling up from the cup. */ +export function drawCoffeeMachine(ctx: CanvasRenderingContext2D, frame: IsoFrame, foot: Rect, z: number, timeMs: number): void { + drawBox(ctx, frame, foot, z, 14, { top: "#454b52", south: "#2e3238", east: "#23262b" }); + drawBox(ctx, frame, { x: foot.x + 1, y: foot.y + 1, w: foot.w - 2, h: 5 }, z + 14, 3, { top: "#5a616a", south: "#3c4249", east: "#30353b" }); + ctx.save(); + setSouthFaceTransform(ctx, frame, foot.y + foot.h, z + 14); + ctx.fillStyle = Math.floor(timeMs / 700) % 2 === 0 ? "#e2553b" : "#8f3426"; + ctx.fillRect(foot.x + 2, 2, 2, 2); + ctx.fillStyle = "#d8d3c8"; + ctx.fillRect(foot.x + foot.w / 2 - 2, 9, 4, 4); + ctx.restore(); + const cup = toIso(frame, { x: foot.x + foot.w / 2, y: foot.y + foot.h }, z + 6); + for (let puff = 0; puff < 3; puff++) { + const rise = ((timeMs / 120 + puff * 5) % 15); + ctx.fillStyle = `rgba(242,242,245,${0.75 * (1 - rise / 15)})`; + ctx.fillRect(Math.round(cup.x + Math.sin((rise + puff) / 2) * 1.5), Math.round(cup.y - 4 - rise), 1, 1); + } +} + +/** A toilet against the back wall: tank and bowl in porcelain white, the bowl's opening shaded. */ +export function drawToilet(ctx: CanvasRenderingContext2D, frame: IsoFrame, cx: number, backY: number): void { + const porcelain = { top: "#f4f6f8", south: "#d9dee4", east: "#c3c9d0" }; + drawBox(ctx, frame, { x: cx - 6, y: backY, w: 12, h: 5 }, 0, 16, porcelain); + drawBox(ctx, frame, { x: cx - 5, y: backY + 5, w: 10, h: 11 }, 0, 8, porcelain); + drawBox(ctx, frame, { x: cx - 3, y: backY + 7, w: 6, h: 7 }, 8, 0, { top: "#9fb2c4", south: "#9fb2c4", east: "#9fb2c4" }); +} + +/** A wash basin set into a counter top at height `z`: a pale rim around a shaded bowl. */ +export function drawBasin(ctx: CanvasRenderingContext2D, frame: IsoFrame, basin: Rect, z: number): void { + drawBox(ctx, frame, basin, z, 0, { top: "#e4ebf2", south: "#e4ebf2", east: "#e4ebf2" }); + drawBox(ctx, frame, { x: basin.x + 4, y: basin.y + 3, w: basin.w - 8, h: basin.h - 6 }, z, 0, { top: "#8fa3b7", south: "#8fa3b7", east: "#8fa3b7" }); +} diff --git a/src/renderer/src/pages/office/scene-renderer.ts b/src/renderer/src/pages/office/scene-renderer.ts index b3a6beef..cc7d0634 100644 --- a/src/renderer/src/pages/office/scene-renderer.ts +++ b/src/renderer/src/pages/office/scene-renderer.ts @@ -1,7 +1,8 @@ import type { FloorPlan, Rect, RoomSpec } from "./office-types"; import { WALL } from "./floor-plan"; -import { drawWindowSky, type SkyScene } from "./sky"; -import { BACK_WALL_H, depthOf, drawBox, setGroundTransform, setNorthWallTransform, type BoxColors, type IsoFrame } from "./iso"; +import { drawWeather, drawWindowSky, type SkyScene, type Weather } from "./sky"; +import { BACK_WALL_H, depthOf, drawBox, setGroundTransform, setSouthFaceTransform, type BoxColors, type IsoFrame } from "./iso"; +import { drawBasin, drawCoffeeMachine, drawMonitorScreen, drawToilet, drawWallClock } from "./props"; import type { Season } from "../../shared/palette-clock"; /** One thing in the scene that people can stand in front of or behind; drawn in depth order with them. */ @@ -10,9 +11,14 @@ export interface Drawable { draw: (ctx: CanvasRenderingContext2D) => void; } +/** A piece of the scene and the plan footprint it sorts by. */ +export interface SceneDrawable extends Drawable { + foot: Rect; +} + /** Interior walls are cut away to this height so rooms stay visible. */ const INNER_WALL_H = 14; -/** Longest stretch of a block that sorts as one piece. */ +/** Longest side of a block that sorts as one piece. */ const PIECE = 32; const PALETTE = { @@ -33,10 +39,9 @@ const PALETTE = { desk: "#8a6d4c", deskExec: "#6a5238", monitor: "#4b5058", - screenOff: "#20242c", - screenOn: "#37d38a", chair: "#3d4148", frame: "#3f3831", + panelSeam: "#5d5246", } as const; const LEAVES: Record = { @@ -88,27 +93,42 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void { } } -/** The tall north and west walls, which everything stands in front of, with windows and wall art on the north face. */ -export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan, sky: SkyScene): void { +/** What the back walls show: the sky and weather outside, and the time for the wall clock. */ +export interface Outside { + sky: SkyScene; + weather: Weather; + clock: Date; + timeMs: number; +} + +/** The tall north and west walls, which everything stands in front of, with panels, windows, a clock and wall art on the north face. */ +export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan, outside: Outside): void { const { w, h } = plan.world; const wall = faces(PALETTE.wall); drawBox(ctx, frame, { x: 0, y: WALL, w: WALL, h: 280 }, 0, BACK_WALL_H, wall); drawBox(ctx, frame, { x: 0, y: 344, w: WALL, h: h - 344 - WALL }, 0, BACK_WALL_H, wall); drawBox(ctx, frame, { x: 0, y: 0, w, h: WALL }, 0, BACK_WALL_H, { ...wall, top: PALETTE.wallTop }); ctx.save(); - setNorthWallTransform(ctx, frame, WALL); + setSouthFaceTransform(ctx, frame, WALL, BACK_WALL_H); + ctx.fillStyle = PALETTE.wallTop; + ctx.fillRect(0, 0, w, 2); + ctx.fillStyle = PALETTE.panelSeam; + for (let x = 32; x < w; x += 32) ctx.fillRect(x, 2, 1, BACK_WALL_H - 5); ctx.fillStyle = PALETTE.baseboard; ctx.fillRect(0, BACK_WALL_H - 3, w, 3); for (const room of plan.rooms) { windowRects(room).forEach((rect, index) => { + const seed = Math.round(room.bounds.x) + index * 53; ctx.fillStyle = PALETTE.frame; ctx.fillRect(rect.x - 2, rect.y - 2, rect.w + 4, rect.h + 4); - drawWindowSky(ctx, rect, sky, Math.round(room.bounds.x) + index * 53); + drawWindowSky(ctx, rect, outside.sky, seed); + drawWeather(ctx, rect, outside.weather, outside.timeMs, seed); ctx.fillStyle = PALETTE.frame; ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h); }); } drawWallArt(ctx); + drawWallClock(ctx, 664, 10, outside.clock); ctx.restore(); } @@ -134,23 +154,34 @@ function drawWallArt(ctx: CanvasRenderingContext2D): void { ctx.fillRect(432, 19, 40, 3); ctx.fillStyle = "#6d9c6b"; ctx.fillRect(432, 26, 33, 3); + // Restroom mirror over the sinks. + ctx.fillStyle = PALETTE.frame; + ctx.fillRect(934, 8, 84, 22); + ctx.fillStyle = "#b9d3e6"; + ctx.fillRect(936, 10, 80, 18); + ctx.fillStyle = "#e3f0f8"; + ctx.fillRect(944, 12, 2, 12); + ctx.fillRect(948, 12, 1, 8); } -/** A block sorted by its footprint centre; one resting on `support` sorts just after every piece of it underneath, so no part of that surface paints over it. */ -export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): Drawable { - const centerDepth = (rect: Rect) => depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }); - if (!support) return { depth: centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) }; +/** A block sorted by its footprint centre; one resting on `support` touches exactly one piece of it (overhanging its outer edge is fine) and sorts just after that piece. */ +export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): SceneDrawable { + return { foot, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) }; +} + +function restingDepth(foot: Rect, support: Rect): number { const under = split(support).filter((piece) => overlaps(piece, foot)); - if (under.length === 0) throw new Error("A resting block must sit over its support."); - return { - depth: Math.max(...under.map(centerDepth)) + 0.25, - draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors), - }; + if (under.length !== 1) throw new Error("A resting block must touch exactly one piece of its support."); + return centerDepth(under[0]!) + 0.25; } -/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on. */ -export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set): Drawable[] { - const items: Drawable[] = []; +function centerDepth(rect: Rect): number { + return depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }); +} + +/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on, and `timeMs` animates screens and steam. */ +export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set, timeMs: number): SceneDrawable[] { + const items: SceneDrawable[] = []; // Long blocks go in pieces so each sorts against nearby people on its own. const block = (foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect) => { for (const piece of support ? [foot] : split(foot)) items.push(blockDrawable(frame, piece, height, colors, z, support)); @@ -166,7 +197,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, // CEO office: rug, executive desk, sofa, plant. block({ x: 32, y: 96, w: 24, h: 72 }, 10, faces("#7a4a41")); plant(280, 40); - deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk)).forEach((item) => items.push(item(frame))); + deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk), timeMs).forEach((item) => items.push(item(frame))); // Meeting room: table and chairs. block({ x: 392, y: 120, w: 160, h: 64 }, 12, faces("#75593b")); for (let i = 0; i < 3; i++) { @@ -177,33 +208,51 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, const counter = { x: 656, y: 40, w: 224, h: 28 }; block(counter, 16, faces("#8f8578")); block({ x: 700, y: 20, w: 24, h: 20 }, 22, faces("#d5dade")); - block({ x: 744, y: 44, w: 14, h: 14 }, 12, faces("#2e3238"), 16, counter); - block({ x: 806, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter); + const machine = { x: 726, y: 44, w: 14, h: 14 }; + items.push({ foot: machine, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) }); + block({ x: 792, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter); block({ x: 826, y: 46, w: 12, h: 10 }, 10, faces("#b3543f"), 16, counter); block({ x: 748, y: 170, w: 40, h: 40 }, 12, faces("#94897b")); block({ x: 728, y: 182, w: 12, h: 12 }, 7, faces("#6d5334")); block({ x: 796, y: 182, w: 12, h: 12 }, 7, faces("#6d5334")); - // Restroom: three stalls and a sink counter. - for (let i = 0; i < 3; i++) block({ x: 1064 + i * 40, y: WALL, w: 38, h: 72 }, 30, faces("#7d8a99")); - block({ x: 928, y: 32, w: 96, h: 32 }, 14, faces("#c6d2de")); + // Restroom: three open stalls with toilets between partitions, and a sink counter. + for (let i = 0; i < 3; i++) { + const cx = 1084 + i * 40; + const toilet = { x: cx - 6, y: WALL + 2, w: 12, h: 16 }; + items.push({ foot: toilet, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) }); + } + for (let i = 0; i <= 3; i++) block({ x: 1063 + i * 40, y: WALL, w: 2, h: 48 }, INNER_WALL_H, faces("#7d8a99")); + const sinks = { x: 928, y: 32, w: 96, h: 32 }; + block(sinks, 14, { top: "#c6d2de", south: "#a7b4c2", east: "#909eae" }); + for (let i = 0; i < 2; i++) { + const basin = { x: sinks.x + 2 + i * 64, y: sinks.y + 8, w: 28, h: 16 }; + items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) }); + } for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy); - for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds)).forEach((item) => items.push(item(frame))); + for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds), timeMs).forEach((item) => items.push(item(frame))); return items; } /** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */ -function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean): Array<(frame: IsoFrame) => Drawable> { +function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> { const monitor = deskMonitor(desk); return [ ...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))), - (frame) => blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: screenOn ? PALETTE.screenOn : PALETTE.screenOff, east: shade(PALETTE.monitor, 0.7) }, 14, desk), + (frame) => { + const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk); + return { foot: monitor, depth: shell.depth, draw: (ctx) => { + shell.draw(ctx); + drawMonitorScreen(ctx, frame, monitor, 14, 12, screenOn, timeMs, Math.round(desk.x + desk.y)); + } }; + }, (frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)), ]; } -/** Footprint of a desk's monitor, at the back of the desktop with its screen facing south. */ -export function deskMonitor(desk: Rect): Rect { - return { x: desk.x + desk.w / 2 - 14, y: desk.y + 4, w: 28, h: 6 }; +/** Footprint of a desk's monitor, at the back of the desktop with its screen facing south, inside the desk piece that holds the desk's centre. */ +function deskMonitor(desk: Rect): Rect { + const piece = Math.floor(desk.w / 2 / PIECE) * PIECE; + return { x: desk.x + piece + 3, y: desk.y + 4, w: 26, h: 6 }; } /** Partitions, room fronts (with door gaps) and the low outer east and south walls. */ @@ -225,13 +274,12 @@ function innerWalls(plan: FloorPlan): Rect[] { return walls; } -function split(wall: Rect): Rect[] { - const horizontal = wall.w >= wall.h; - const length = horizontal ? wall.w : wall.h; +function split(block: Rect): Rect[] { const pieces: Rect[] = []; - for (let start = 0; start < length; start += PIECE) { - const size = Math.min(PIECE, length - start); - pieces.push(horizontal ? { x: wall.x + start, y: wall.y, w: size, h: wall.h } : { x: wall.x, y: wall.y + start, w: wall.w, h: size }); + for (let dy = 0; dy < block.h; dy += PIECE) { + for (let dx = 0; dx < block.w; dx += PIECE) { + pieces.push({ x: block.x + dx, y: block.y + dy, w: Math.min(PIECE, block.w - dx), h: Math.min(PIECE, block.h - dy) }); + } } return pieces; } diff --git a/src/renderer/src/pages/office/sky.ts b/src/renderer/src/pages/office/sky.ts index 9d7e3e9e..7134c7a9 100644 --- a/src/renderer/src/pages/office/sky.ts +++ b/src/renderer/src/pages/office/sky.ts @@ -1,4 +1,5 @@ import type { Rect } from "./office-types"; +import { season } from "../../shared/palette-clock"; /** * The world outside the office windows, driven by real local time: night @@ -155,6 +156,34 @@ function drawSkyline(ctx: CanvasRenderingContext2D, glass: Rect, scene: SkyScene } } +export type Weather = "clear" | "rain" | "snow" | "leaves"; + +/** Decoration for the view outside, from the season and a per-day roll; never a forecast. */ +export function weatherFor(date: Date): Weather { + const dayKey = date.getFullYear() * 400 + date.getMonth() * 32 + date.getDate(); + const roll = Math.abs(Math.imul(dayKey ^ 0x5bd1e995, 2654435761) >> 7) % 100; + switch (season(date)) { + case "winter": return roll < 60 ? "snow" : "clear"; + case "spring": return roll < 40 ? "rain" : "clear"; + case "summer": return roll < 15 ? "rain" : "clear"; + case "autumn": return roll < 45 ? "leaves" : roll < 65 ? "rain" : "clear"; + } +} + +/** Falling rain, snow or leaves inside a window, moving with time; each pane gets its own scatter. */ +export function drawWeather(ctx: CanvasRenderingContext2D, glass: Rect, weather: Weather, timeMs: number, seed: number): void { + if (weather === "clear") return; + const style = { rain: { color: "rgba(200,220,255,0.75)", w: 1, h: 3, speed: 0.06, drift: 0.01 }, snow: { color: "#f4f6fb", w: 1, h: 1, speed: 0.008, drift: 0.004 }, leaves: { color: "#d9822b", w: 2, h: 1, speed: 0.012, drift: 0.01 } }[weather]; + ctx.fillStyle = style.color; + const count = Math.max(3, Math.floor(glass.w / 6)); + for (let i = 0; i < count; i++) { + const offset = (seed * 37 + i * 53) % 1000; + const fall = (offset + timeMs * style.speed) % glass.h; + const sway = (offset * 7 + timeMs * style.drift) % glass.w; + ctx.fillRect(Math.floor(glass.x + sway), Math.floor(glass.y + fall), style.w, Math.min(style.h, glass.y + glass.h - Math.floor(glass.y + fall))); + } +} + function rgb(r: number, g: number, b: number): Rgb { return { r, g, b }; } diff --git a/src/renderer/src/shared/HintBubble.tsx b/src/renderer/src/shared/HintBubble.tsx deleted file mode 100644 index 474b5fdc..00000000 --- a/src/renderer/src/shared/HintBubble.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ReactNode } from "react"; -import { Icon } from "./Icon"; - -/** A hint spoken by the office guide: a pixel portrait and a speech bubble, so help reads like a character talking. */ -export function HintBubble({ children }: { children: ReactNode }) { - return ( -
- -

{children}

-
- ); -} diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index f8a0cabc..e8002092 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -254,13 +254,13 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } transform: translateX(-50%); display: flex; gap: 6px; + width: max-content; + max-width: calc(100% - 32px); padding: 6px; background: var(--panel); - border: 1px solid var(--border-strong); - border-radius: 0; - box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18); + border: 2px solid var(--border-strong); + box-shadow: var(--shadow-md); } -.office-legend { align-self: center; padding: 0 6px; font-size: var(--fs-body); color: var(--text-2); } /* Floating "jump to latest" pill above the chat composer */ .jump-latest { diff --git a/src/renderer/src/styles/pixel.css b/src/renderer/src/styles/pixel.css index bc6d35b1..fa77165f 100644 --- a/src/renderer/src/styles/pixel.css +++ b/src/renderer/src/styles/pixel.css @@ -69,11 +69,6 @@ transition: width var(--motion-slow) var(--ease-out); } -/* Hint bubble: the guide's portrait and a speech box with a stepped tail. */ -.hint-bubble { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 var(--sp-4); } -.hint-speaker { display: grid; place-items: center; width: calc(24px * var(--px)); height: calc(24px * var(--px)); background: var(--accent); color: var(--btn-primary-fg); box-shadow: 2px 2px 0 0 var(--border-strong); flex-shrink: 0; } -.hint-text { position: relative; margin: 0; padding: 6px 10px; background: var(--panel); border: 2px solid var(--border-strong); color: var(--text-2); } -.hint-text::before { content: ""; position: absolute; left: -8px; top: 6px; width: 6px; height: 8px; background: var(--border-strong); clip-path: polygon(100% 0, 100% 100%, 67% 100%, 67% 75%, 34% 75%, 34% 50%, 0 50%, 0 25%, 34% 25%, 34% 0); } /* Game over: a framed panel with a blinking banner and Continue. */ .game-over { display: grid; gap: 12px; max-width: 520px; text-align: center; justify-items: center; } diff --git a/tests/e2e/library.spec.ts b/tests/e2e/library.spec.ts index 78cabfbc..5a5b37ab 100644 --- a/tests/e2e/library.spec.ts +++ b/tests/e2e/library.spec.ts @@ -48,8 +48,6 @@ test("the library manages documents with versions and knowledge with history", a expect(orgId).toBeTruthy(); await page.getByRole("button", { name: "Library", exact: true }).click(); - // Each page says which object drives work: only a task runs. - await expect(page.getByText("Nothing here starts work; a task does.", { exact: false })).toBeVisible(); await page.getByRole("listitem").filter({ hasText: "Pricing memo" }).click(); await page.getByRole("button", { name: "Edit", exact: true }).click(); await page.getByLabel("Content").fill("Price is 12."); @@ -60,9 +58,7 @@ test("the library manages documents with versions and knowledge with history", a await expect(page.getByText("Price is 10.")).toBeVisible(); await page.getByRole("button", { name: "Knowledge", exact: true }).click(); - await expect(page.getByText("Nothing here starts work; a task does.", { exact: false })).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); - await expect(page.getByText("Only a task runs: an agent picks it up and does the work.", { exact: false })).toBeVisible(); await page.getByRole("button", { name: "Library", exact: true }).click(); await page.getByRole("button", { name: "Knowledge", exact: true }).click(); await page.getByRole("button", { name: "New", exact: true }).click(); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 6d29eb9e..eb4bd765 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -91,9 +91,8 @@ test("the office floor opens agent details on click", async () => { } await expect(canvas).toBeVisible(); - // The office offers no pretend controls over work, and says what a position means. + // The office offers no pretend controls over work. await expect(page.getByRole("button", { name: "Whip" })).toHaveCount(0); - await expect(page.getByText("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")).toBeVisible(); // Hired agents take open-floor desks in hire order and are clickable too. await hireAgent(page, "Ava Patel", "Designer", "Design"); diff --git a/tests/e2e/pixel-style.spec.ts b/tests/e2e/pixel-style.spec.ts index 7a91cc7a..fd921a63 100644 --- a/tests/e2e/pixel-style.spec.ts +++ b/tests/e2e/pixel-style.spec.ts @@ -60,15 +60,13 @@ test("text uses the bundled pixel font, and the palette follows the day part, se await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); - // Progress is an XP bar announced as a value; hints are spoken by the guide in a bubble. + // Progress is an XP bar announced as a value. await page.getByRole("button", { name: "Home" }).click(); await expect(page.getByRole("progressbar", { name: "Goal progress" })).toHaveAttribute("aria-valuenow", "0"); // On the roster the only agent holds the largest budget, and the bar says it compares budgets, not spend. await page.getByRole("button", { name: "Team", exact: true }).click(); await expect(page.getByRole("progressbar", { name: "Budget compared with the largest agent budget" })).toHaveAttribute("aria-valuenow", "100"); await page.getByRole("button", { name: "Work", exact: true }).click(); - const hint = page.getByRole("note").filter({ hasText: "Only a task runs" }); - await expect(hint.locator(".hint-speaker svg.pixel-icon")).toHaveCount(1); // Tabs are raised pixel tabs; fields are sunken pixel boxes. const views = page.getByRole("radiogroup", { name: "View" }); expect(await views.locator("[aria-checked=true]").evaluate((tab) => getComputedStyle(tab).borderBottomColor === getComputedStyle(tab).backgroundColor)).toBe(true); diff --git a/tests/unit/office-iso.test.ts b/tests/unit/office-iso.test.ts index 86db5112..30f6f676 100644 --- a/tests/unit/office-iso.test.ts +++ b/tests/unit/office-iso.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { depthOf, fromIso, isoFrame, isoPixelSize, pickAt, toIso } from "../../src/renderer/src/pages/office/iso"; -import { blockDrawable } from "../../src/renderer/src/pages/office/scene-renderer"; +import { blockDrawable, sceneDrawables } from "../../src/renderer/src/pages/office/scene-renderer"; +import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan"; const frame = isoFrame({ w: 1216, h: 600 }); @@ -70,11 +71,26 @@ describe("office depth order", () => { const resting = blockDrawable(frame, cup, 12, colors, 16, counter); expect(loose.depth).toBeLessThan(underneath.depth); expect(resting.depth).toBeGreaterThan(underneath.depth); - expect(() => blockDrawable(frame, { x: 10, y: 10, w: 4, h: 4 }, 4, colors, 16, counter)).toThrow("A resting block must sit over its support."); - // The coffee machine straddles the pieces at 720..752 and 752..784: it sorts after both. - const machine = blockDrawable(frame, { x: 744, y: 44, w: 14, h: 14 }, 12, colors, 16, counter); - for (const x of [720, 752]) expect(machine.depth).toBeGreaterThan(blockDrawable(frame, { x, y: 40, w: 32, h: 28 }, 16, colors).depth); + // Off the support, or straddling two of its pieces, a resting block has no single surface to sort after: refused. + expect(() => blockDrawable(frame, { x: 10, y: 10, w: 4, h: 4 }, 4, colors, 16, counter)).toThrow("A resting block must touch exactly one piece of its support."); + expect(() => blockDrawable(frame, { x: 744, y: 44, w: 14, h: 14 }, 12, colors, 16, counter)).toThrow("A resting block must touch exactly one piece of its support."); + // Overhanging the support's outer edge, like a plant's leaves past its pot, still sorts after that one piece. + const overhang = blockDrawable(frame, { x: 726, y: 64, w: 14, h: 14 }, 12, colors, 16, counter); + expect(overhang.depth).toBeGreaterThan(underneath.depth); // A person standing in front of the counter still draws over what rests on it. expect(depthOf({ x: 704, y: 92 })).toBeGreaterThan(resting.depth); }); }); + +test("every piece of the scene sorts by a footprint no longer than 32 on either side, so a person beside a long block is never painted over", () => { + const plan = buildFloorPlan(12); + const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Set(), 0); + const oversized = items.filter((item) => item.foot.w > 32 || item.foot.h > 32).map((item) => item.foot); + expect(oversized).toEqual([]); + // A person at the left basin sorts after both the counter piece and the basin in front of them. + const atBasin = depthOf({ x: 944, y: 72 }); + for (const foot of [{ x: 928, y: 32 }, { x: 930, y: 40 }]) { + const item = items.find((candidate) => candidate.foot.x === foot.x && candidate.foot.y === foot.y)!; + expect(atBasin).toBeGreaterThan(item.depth); + } +}); diff --git a/tests/unit/office-props.test.ts b/tests/unit/office-props.test.ts new file mode 100644 index 00000000..1170ef3d --- /dev/null +++ b/tests/unit/office-props.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { weatherFor } from "../../src/renderer/src/pages/office/sky"; +import { clockHands } from "../../src/renderer/src/pages/office/props"; + +const day = (month: number, date: number, hour = 12) => new Date(2026, month - 1, date, hour, 0, 0); + +describe("office decor", () => { + test("the view outside keeps one weather all day and fits the season", () => { + expect(weatherFor(day(1, 10, 8))).toBe(weatherFor(day(1, 10, 22))); + const seen = (month: number) => new Set(Array.from({ length: 28 }, (_, index) => weatherFor(day(month, index + 1)))); + expect([...seen(1)].sort()).toEqual(["clear", "snow"]); + expect([...seen(4)].sort()).toEqual(["clear", "rain"]); + expect(seen(7).has("snow")).toBe(false); + expect(seen(7).has("leaves")).toBe(false); + expect(seen(10).has("leaves")).toBe(true); + expect(seen(10).has("snow")).toBe(false); + }); + + test("the wall clock shows the local time: hands measured clockwise from twelve", () => { + const at = (hour: number, minute: number) => clockHands(new Date(2026, 0, 1, hour, minute)); + expect(at(0, 0)).toEqual({ hour: 0, minute: 0 }); + expect(at(3, 0).hour).toBeCloseTo(Math.PI / 2); + expect(at(15, 0).hour).toBeCloseTo(Math.PI / 2); + expect(at(6, 30).hour).toBeCloseTo((6.5 / 12) * 2 * Math.PI); + expect(at(6, 30).minute).toBeCloseTo(Math.PI); + expect(at(9, 45).minute).toBeCloseTo(1.5 * Math.PI); + }); +});