S3.7: isometric office assets: animated monitors, coffee machine with steam, toilets, sinks, real-time wall clock, decorative window weather; explanatory on-screen notes and the hint bubble removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23c7d55b1d
commit
202418f81c
17 files changed
+265
-94
No files matched your search
@@ -343,7 +343,6 @@ export const zh: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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": "还没有评判过的工作",
|
||||
|
||||
@@ -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) {
|
||||
<div className="content">
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
|
||||
<HintBubble>{t("Agents read and write these while working on tasks. Nothing here starts work; a task does.")}</HintBubble>
|
||||
{tab === "documents"
|
||||
? <DocumentsPanel focusId={focus?.kind === "document" ? focus.id : null} orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
|
||||
: <KnowledgePanel focusId={focus?.kind === "knowledge" ? focus.id : null} orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
<option value="">{t("Show a person…")}</option>
|
||||
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
|
||||
</select>
|
||||
<span className="office-legend">{t("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" /> {t("New task")}</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">{t("Work")}</h1>
|
||||
<HintBubble>{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.")}</HintBubble>
|
||||
{scope && (
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8 }}>
|
||||
<Pill>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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<Season, { leaf: string; light: string }> = {
|
||||
@@ -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<Rect>): 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<Rect>, 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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="hint-bubble" role="note">
|
||||
<span className="hint-speaker" aria-hidden="true"><Icon name="bot" /></span>
|
||||
<p className="hint-text">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user