diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index 6fca585c..2013c35c 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -144,7 +144,7 @@ export function OfficePage({ snapshot }: PageProps) { carrying: null, }); } - renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now); + renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date()); }; raf = requestAnimationFrame(tick); diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts index 205477dd..aeff8346 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -1,5 +1,6 @@ import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types"; import { deskScreenRect, drawWorld } from "./scene-renderer"; +import { skyScene } from "./sky"; import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; export interface RenderPerson { @@ -42,13 +43,14 @@ export function renderOffice( camera: Camera, people: RenderPerson[], timeMs: number, + clock: Date, ): void { ctx.fillStyle = "#14161c"; ctx.fillRect(0, 0, viewW, viewH); ctx.save(); ctx.translate(-camera.x, -camera.y); - drawWorld(ctx, plan, { x: camera.x, y: camera.y, w: viewW, h: viewH }); + drawWorld(ctx, plan, { x: camera.x, y: camera.y, w: viewW, h: viewH }, skyScene(clock)); for (const person of people) { if (!person.desk) continue; diff --git a/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts index 06d1b9d2..c9f1f7a1 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/scene-renderer.ts @@ -1,5 +1,6 @@ import type { FloorPlan, Rect, RoomSpec } from "./office-types"; import { WALL } from "./floor-plan"; +import { drawWindowSky, type SkyScene } from "./sky"; /** * Draws the static office world in a 3/4 top-down pixel style: wood floor, @@ -34,17 +35,15 @@ const PALETTE = { monitorShell: "#4b5058", chair: "#3d4148", chairDark: "#33373d", - glassSky: "#8fc3e8", - glassSkyLow: "#c3e0f2", frame: "#3f3831", signPlate: "#2f2a25", signText: "#d8cfc2", } as const; -export function drawWorld(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect): void { +export function drawWorld(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect, sky: SkyScene): void { drawFloor(ctx, plan, view); for (const room of plan.rooms) drawRoomFloor(ctx, room); - drawStructure(ctx, plan); + drawStructure(ctx, plan, sky); for (const room of plan.rooms) drawRoomFurniture(ctx, plan, room); drawCorridorPlants(ctx, plan); for (const desk of plan.desks) { @@ -96,7 +95,7 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void { } } -function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan): void { +function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan, sky: SkyScene): void { const { w, h } = plan.world; ctx.fillStyle = PALETTE.wallFill; ctx.fillRect(0, 0, w, WALL); @@ -113,7 +112,7 @@ function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan): void { ctx.fillRect(b.x, b.y + WALL_FACE_H - 6, b.w, 6); ctx.fillStyle = PALETTE.baseboard; ctx.fillRect(b.x, b.y + WALL_FACE_H, b.w, 2); - drawRoomWindows(ctx, room); + drawRoomWindows(ctx, room, sky); } ctx.fillStyle = PALETTE.wallFill; @@ -141,8 +140,11 @@ function drawSouthWall(ctx: CanvasRenderingContext2D, room: RoomSpec): void { ctx.fillRect(gapR, y, 3, WALL); } -function drawRoomWindows(ctx: CanvasRenderingContext2D, room: RoomSpec): void { - for (const rect of windowRects(room)) drawWindowFrame(ctx, rect); +function drawRoomWindows(ctx: CanvasRenderingContext2D, room: RoomSpec, sky: SkyScene): void { + const rects = windowRects(room); + for (let i = 0; i < rects.length; i++) { + drawWindowFrame(ctx, rects[i], sky, Math.round(room.bounds.x) + i * 53); + } } /** Window glass rects on each room's north wall face (world coordinates). */ @@ -163,14 +165,10 @@ export function windowRects(room: RoomSpec): Rect[] { ]; } -function drawWindowFrame(ctx: CanvasRenderingContext2D, rect: Rect): void { +function drawWindowFrame(ctx: CanvasRenderingContext2D, rect: Rect, sky: SkyScene, seed: number): void { ctx.fillStyle = PALETTE.frame; ctx.fillRect(rect.x - 3, rect.y - 3, rect.w + 6, rect.h + 6); - const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h); - grad.addColorStop(0, PALETTE.glassSky); - grad.addColorStop(1, PALETTE.glassSkyLow); - ctx.fillStyle = grad; - ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + drawWindowSky(ctx, rect, sky, seed); ctx.fillStyle = PALETTE.frame; ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h); } diff --git a/AgentCompany-New/src/renderer/src/pages/office/sky.ts b/AgentCompany-New/src/renderer/src/pages/office/sky.ts new file mode 100644 index 00000000..9d7e3e9e --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/sky.ts @@ -0,0 +1,176 @@ +import type { Rect } from "./office-types"; + +/** + * The world outside the office windows, driven by real local time: night + * with stars and a moon, a warm sunrise, blue day, and an orange sunset, + * over a city skyline whose windows light up after dark. All color math is + * pure so the phase timeline is unit-testable. + */ + +export type SkyPhaseKey = "night" | "dawn" | "day" | "dusk"; + +export interface SkyScene { + phase: SkyPhaseKey; + top: Rgb; + bottom: Rgb; + /** Sun position in unit space, null when below the horizon. */ + sun: { x: number; elevation: number } | null; + /** Moon position in unit space, null during daylight. */ + moon: { x: number; elevation: number } | null; + /** 0 (invisible) to 1 (full night sky). */ + starIntensity: number; + /** City windows glow from dusk through dawn. */ + cityLights: boolean; +} + +export interface Rgb { + r: number; + g: number; + b: number; +} + +interface SkyPalette { + top: Rgb; + bottom: Rgb; +} + +const NIGHT: SkyPalette = { top: rgb(11, 16, 38), bottom: rgb(30, 41, 73) }; +const DAWN: SkyPalette = { top: rgb(84, 100, 150), bottom: rgb(242, 160, 107) }; +const DAY: SkyPalette = { top: rgb(96, 166, 222), bottom: rgb(199, 228, 245) }; +const DUSK: SkyPalette = { top: rgb(74, 66, 110), bottom: rgb(232, 118, 79) }; + +/** (hour, palette) anchors across a day; linearly interpolated between. */ +const TIMELINE: Array<[number, SkyPalette]> = [ + [0, NIGHT], + [5.5, NIGHT], + [6.5, DAWN], + [7.5, DAY], + [17.5, DAY], + [18.5, DUSK], + [19.5, NIGHT], + [24, NIGHT], +]; + +const SUNRISE_H = 6; +const SUNSET_H = 19; + +export function phaseAt(date: Date): SkyPhaseKey { + const hour = date.getHours() + date.getMinutes() / 60; + if (hour < 5.5 || hour >= 19.5) return "night"; + if (hour < 7.5) return "dawn"; + if (hour < 17.5) return "day"; + return "dusk"; +} + +export function skyScene(date: Date): SkyScene { + const hour = date.getHours() + date.getMinutes() / 60 + date.getSeconds() / 3600; + const palette = paletteAt(hour); + const dayProgress = (hour - SUNRISE_H) / (SUNSET_H - SUNRISE_H); + const sunUp = dayProgress > 0 && dayProgress < 1; + const nightProgress = hour >= SUNSET_H ? (hour - SUNSET_H) / (24 - SUNSET_H + SUNRISE_H) : (hour + 24 - SUNSET_H) / (24 - SUNSET_H + SUNRISE_H); + const starIntensity = clamp01(hour < 12 ? (5.75 - hour) / 0.75 : (hour - 19.25) / 0.75); + return { + phase: phaseAt(date), + top: palette.top, + bottom: palette.bottom, + sun: sunUp ? { x: dayProgress, elevation: Math.sin(dayProgress * Math.PI) } : null, + moon: sunUp ? null : { x: nightProgress, elevation: Math.sin(nightProgress * Math.PI) * 0.9 + 0.05 }, + starIntensity, + cityLights: !sunUp || Math.sin(dayProgress * Math.PI) < 0.12, + }; +} + +function paletteAt(hour: number): SkyPalette { + for (let i = 0; i + 1 < TIMELINE.length; i++) { + const [fromHour, fromPalette] = TIMELINE[i]; + const [toHour, toPalette] = TIMELINE[i + 1]; + if (hour >= fromHour && hour <= toHour) { + const t = toHour === fromHour ? 0 : (hour - fromHour) / (toHour - fromHour); + return { + top: lerpRgb(fromPalette.top, toPalette.top, t), + bottom: lerpRgb(fromPalette.bottom, toPalette.bottom, t), + }; + } + } + return NIGHT; +} + +export function drawWindowSky(ctx: CanvasRenderingContext2D, glass: Rect, scene: SkyScene, seed: number): void { + const gradient = ctx.createLinearGradient(0, glass.y, 0, glass.y + glass.h); + gradient.addColorStop(0, css(scene.top)); + gradient.addColorStop(1, css(scene.bottom)); + ctx.fillStyle = gradient; + ctx.fillRect(glass.x, glass.y, glass.w, glass.h); + + if (scene.starIntensity > 0) { + ctx.fillStyle = `rgba(238,240,255,${0.85 * scene.starIntensity})`; + for (let i = 0; i < Math.floor(glass.w / 5); i++) { + const sx = glass.x + ((seed * 61 + i * 137) % glass.w); + const sy = glass.y + ((seed * 43 + i * 89) % Math.max(1, glass.h - 14)); + ctx.fillRect(sx, sy, 1, 1); + } + } + + if (scene.sun) { + const sx = glass.x + 4 + scene.sun.x * (glass.w - 8); + const sy = glass.y + 4 + (1 - scene.sun.elevation) * (glass.h - 16); + ctx.fillStyle = "rgba(255,214,120,0.35)"; + ctx.fillRect(sx - 4, sy - 4, 9, 9); + ctx.fillStyle = "#ffd66e"; + ctx.fillRect(sx - 2, sy - 2, 5, 5); + } else if (scene.moon) { + const mx = glass.x + 4 + scene.moon.x * (glass.w - 8); + const my = glass.y + 4 + (1 - scene.moon.elevation) * (glass.h - 16); + ctx.fillStyle = "#e8ecf5"; + ctx.fillRect(mx - 2, my - 2, 5, 5); + ctx.fillStyle = "#c3cad9"; + ctx.fillRect(mx, my - 1, 2, 2); + } + + drawSkyline(ctx, glass, scene, seed); +} + +function drawSkyline(ctx: CanvasRenderingContext2D, glass: Rect, scene: SkyScene, seed: number): void { + const silhouette = scene.phase === "day" ? rgb(122, 148, 178) : lerpRgb(rgb(26, 30, 48), scene.bottom, 0.25); + const baseline = glass.y + glass.h; + let x = glass.x; + let column = 0; + while (x < glass.x + glass.w) { + const key = seed * 31 + column * 97; + const width = 8 + (key % 8); + const height = 8 + ((key >> 2) % Math.max(4, glass.h - 18)); + const w = Math.min(width, glass.x + glass.w - x); + ctx.fillStyle = css(silhouette); + ctx.fillRect(x, baseline - height, w, height); + if (scene.cityLights) { + ctx.fillStyle = "rgba(255,224,130,0.9)"; + for (let wx = 1; wx + 2 < w; wx += 3) { + for (let wy = 2; wy + 2 < height; wy += 4) { + if (((key >> 3) + wx * 7 + wy * 13) % 4 === 0) ctx.fillRect(x + wx, baseline - height + wy, 1, 2); + } + } + } + x += w; + column += 1; + } +} + +function rgb(r: number, g: number, b: number): Rgb { + return { r, g, b }; +} + +function lerpRgb(from: Rgb, to: Rgb, t: number): Rgb { + return { + r: Math.round(from.r + (to.r - from.r) * t), + g: Math.round(from.g + (to.g - from.g) * t), + b: Math.round(from.b + (to.b - from.b) * t), + }; +} + +function css(color: Rgb): string { + return `rgb(${color.r},${color.g},${color.b})`; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} diff --git a/AgentCompany-New/tests/unit/office-sky.test.ts b/AgentCompany-New/tests/unit/office-sky.test.ts new file mode 100644 index 00000000..830dcaa4 --- /dev/null +++ b/AgentCompany-New/tests/unit/office-sky.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import { phaseAt, skyScene } from "../../src/renderer/src/pages/office/sky"; + +function at(hours: number, minutes: number): Date { + return new Date(2026, 6, 6, hours, minutes, 0); +} + +describe("office window sky", () => { + test("maps local time to the four phases", () => { + expect(phaseAt(at(3, 0))).toBe("night"); + expect(phaseAt(at(6, 30))).toBe("dawn"); + expect(phaseAt(at(12, 0))).toBe("day"); + expect(phaseAt(at(18, 30))).toBe("dusk"); + expect(phaseAt(at(22, 0))).toBe("night"); + }); + + test("the sun crosses the sky through the day and hands over to the moon", () => { + const morning = skyScene(at(8, 0)); + const noon = skyScene(at(13, 0)); + const evening = skyScene(at(18, 0)); + const night = skyScene(at(23, 0)); + expect(morning.sun).not.toBeNull(); + expect(noon.sun!.elevation).toBeGreaterThan(morning.sun!.elevation); + expect(evening.sun!.x).toBeGreaterThan(morning.sun!.x); + expect(morning.moon).toBeNull(); + expect(night.sun).toBeNull(); + expect(night.moon).not.toBeNull(); + }); + + test("stars and city lights belong to the night, not midday", () => { + const night = skyScene(at(1, 30)); + const noon = skyScene(at(13, 0)); + expect(night.starIntensity).toBe(1); + expect(night.cityLights).toBe(true); + expect(noon.starIntensity).toBe(0); + expect(noon.cityLights).toBe(false); + }); + + test("dawn blends smoothly instead of snapping palettes", () => { + const early = skyScene(at(5, 45)); + const later = skyScene(at(6, 15)); + expect(early.top).not.toEqual(later.top); + const delta = Math.abs(early.bottom.r - later.bottom.r); + expect(delta).toBeGreaterThan(0); + expect(delta).toBeLessThan(120); + }); + + test("sunrise and sunset windows glow with city lights while the sun is low", () => { + const sunrise = skyScene(at(6, 10)); + expect(sunrise.sun).not.toBeNull(); + expect(sunrise.cityLights).toBe(true); + }); +});