Let real local time paint the world outside the windows

Office windows now render a living sky: night with stars, a moon, and lit
city windows; a warm sunrise; blue daylight with the sun arcing across; and
an orange sunset - all interpolated smoothly along a palette timeline driven
by the actual clock, over a per-window deterministic skyline. Phase mapping,
sun/moon handover, star intensity, and palette blending are unit-tested.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 21:51:43 -04:00
1 parent d92e1e8b17
commit 8e0716021f
5 files changed
+245 -16

No files matched your search

@@ -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);
@@ -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;
@@ -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);
}
@@ -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));
}
@@ -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);
});
});