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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user