From c3460929adc1e3ef09275110cd0ffb38ef280fe8 Mon Sep 17 00:00:00 2001 From: PSheon Date: Wed, 23 Sep 2026 18:11:26 +0800 Subject: [PATCH 1/7] docs(handoff): where No. 015 went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft is paused and `dev` was reset, so nothing about it is on a branch any more — this says which tag holds it, how to read it without checking it out, and what the read-through found, so that whoever writes the article does not start from an empty page or rebuild the simulator. Co-Authored-By: Claude Opus 5 (1M context) --- docs/HANDOFF.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1042183..bbd6beb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -67,6 +67,28 @@ stays in `lib/rt` with its tests. Read before touching it: - CI has no GPU. The light E2E tests take the "no adapter" branch; what needs a GPU was checked by hand in the Playwright MCP browser, whose own tab must be in front for pointer lock (a `newContext()` window is refused). +### № 015, the Starship landing — PAUSED, and not on any branch (2026-09-23) + +Paul read the draft, found the prose 不有趣又有點雜亂, paused it for Fable to write, and had `dev` reset back to +3cee702 — so none of it is on a branch. **It is all in the tag `rocket-draft-2026-09-23`** (pushed), thirteen +commits: the simulator, the packed Ship 24 geometry, the four-pilot figure, the network, both languages, the cover, +an E2E spec, `docs/research/rocket/RESULTS.md`, and paul-e9's `docs/research/rsa/` notes, which went with it. + +``` +git show rocket-draft-2026-09-23:docs/research/rocket/RESULTS.md # read one file +git checkout -b rocket rocket-draft-2026-09-23 # pick it up again +``` + +The one idea worth not losing: a Raptor spans 0.90–2.26 MN and the ship weighs 1.26 MN, so **one engine straddles +its own weight and two do not**. The pilot asks for a thrust and lights the fewest engines that can deliver it, and +a descent goes 0 → 1 → 2 → 1 on its own. Ten learned constants land 98 %; seven hundred weights copying them land +between 5 % and 98 % depending on where they started. + +What the read-through found, for whoever writes it: the three best moments (it cannot hover, the flip throws it +64 m sideways so it must aim off-target, the network hovering 3 m above the deck for ninety seconds) were all +buried in engineering notes; the engine-count thread is told three separate times; and the last three sections +share one shape — setup, table, moral. + ### The UI/UX batch (2026-09-23) paul-8b measured the whole site and Paul picked thirteen fixes; they are on dev as one commit each. Two he turned From 7c2176373fbe28d1b99f33569943b7b89318090e Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 15:25:38 +0800 Subject: [PATCH 2/7] feat(city): the follow badge says what the person is doing and where MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "正在跟拍 · 佳穎 5 ● 前往 工作 · 辦公(商業區 C4)": the activity dot, then the Overseer table's own words, now one function (personTask) that both use. Only the name is in the live region; the task changes every few seconds at 20×. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../city-of-agents/components/city-lab.tsx | 20 ++++++++++++++++++- .../city-of-agents/components/describe.ts | 5 +++++ .../components/overseer-lab.tsx | 6 +++--- .../components/overseer-panel.tsx | 6 +++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/content/posts/city-of-agents/components/city-lab.tsx b/content/posts/city-of-agents/components/city-lab.tsx index 8080c5e..b1b8bfb 100644 --- a/content/posts/city-of-agents/components/city-lab.tsx +++ b/content/posts/city-of-agents/components/city-lab.tsx @@ -4,7 +4,9 @@ import { Pause, Play } from "lucide-react"; import { useRef, useState } from "react"; import { Readout } from "@/components/lab/readout"; import { Button } from "@/components/ui/button"; +import { personTask } from "./describe"; import { personName, useLabels } from "./labels"; +import type { PanelState } from "./session"; import { formatTime, type Mode, PARAMS } from "./sim"; import { SWATCH, useCity } from "./use-city"; @@ -24,6 +26,22 @@ export function Legend() { ); } +/** + * Who the camera follows and what they are up to right now, in the Overseer's words. Only the name is announced: the + * task changes every few seconds at speed, and a screen reader would never stop talking. + */ +export function FollowBadge({ panel }: { panel: PanelState | null }) { + const t = useLabels(), id = panel?.follow ?? -1, p = panel && id >= 0 ? panel.people[id] : undefined; + if (!panel || !p) return null; + return ( +
+ {t.following} · {personName(t, id)} + + {personTask(t, panel.city, p)} +
+ ); +} + /** * The city and the people in it, with as few controls as the point needs: play, speed, follow someone. With `modes`, * also who decides and whether duty exists — the same city and the same legs under a different head. @@ -41,7 +59,7 @@ export function CityLab({ seed = 1, n = 8, agents = 300, modes = false }: { seed act(() => s?.setFollow(s.pick(e.clientX, e.clientY)))} /> {!ready &&

{t.loading}

} - {follow >= 0 &&

{t.following} · {personName(t, follow)}

} + {modes && ( diff --git a/content/posts/city-of-agents/components/describe.ts b/content/posts/city-of-agents/components/describe.ts index 1445da7..28687cc 100644 --- a/content/posts/city-of-agents/components/describe.ts +++ b/content/posts/city-of-agents/components/describe.ts @@ -1,4 +1,5 @@ import { type Labels, personName } from "./labels"; +import type { PersonRow } from "./session"; import { type City, formatTime, type SimEvent } from "./sim"; /** Place ids only mean something in the city they came from: always pass the city of the same snapshot as the rows. */ @@ -8,6 +9,10 @@ export const placeName = (t: Labels, city: City, id: number): string => { return `${t.places[place.kind]}(${t.zones[block.zone]} ${block.label})`; }; +/** What a person is up to, as the Overseer's table and the follow badge say it: "前往 工作 · 辦公(商業區 D5)", or "沒事". */ +export const personTask = (t: Labels, city: City, p: PersonRow): string => + p.state === "idle" ? t.states.idle : `${p.state === "traveling" ? `${t.states.traveling} ` : ""}${p.action ? t.actions[p.action] : ""} · ${placeName(t, city, p.place)}`; + /** "Day 2 07:40 阿凱 離開 家(住宅區 B3) → 前往 辦公(商業區 D5)" — put together here; the record itself holds no text. */ export function describeEvent(t: Labels, city: City, e: SimEvent): string { const when = formatTime(e.t); diff --git a/content/posts/city-of-agents/components/overseer-lab.tsx b/content/posts/city-of-agents/components/overseer-lab.tsx index d8332a6..d1fbd21 100644 --- a/content/posts/city-of-agents/components/overseer-lab.tsx +++ b/content/posts/city-of-agents/components/overseer-lab.tsx @@ -4,8 +4,8 @@ import { Pause, Play } from "lucide-react"; import { useRef, useState } from "react"; import { Readout } from "@/components/lab/readout"; import { Button } from "@/components/ui/button"; -import { Legend } from "./city-lab"; -import { personName, useLabels } from "./labels"; +import { FollowBadge, Legend } from "./city-lab"; +import { useLabels } from "./labels"; import { DepartureChart, EventStream, StatusTable, Timeline } from "./overseer-panel"; import { Param } from "@/components/lab/param"; import { MAX_N, MIN_N, type Mode, PARAMS } from "./sim"; @@ -31,7 +31,7 @@ export function OverseerLab({ seed = 1, n = 8, agents = 300 }: { seed?: number; act(() => s?.setFollow(s.pick(e.clientX, e.clientY)))} /> {!ready &&

{t.loading}

} - {follow >= 0 &&

{t.following} · {personName(t, follow)}

} + {panel?.replaying &&

● {t.replay}

} diff --git a/content/posts/city-of-agents/components/overseer-panel.tsx b/content/posts/city-of-agents/components/overseer-panel.tsx index 499f41b..4733734 100644 --- a/content/posts/city-of-agents/components/overseer-panel.tsx +++ b/content/posts/city-of-agents/components/overseer-panel.tsx @@ -5,13 +5,13 @@ import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { type Labels, personName } from "./labels"; import type { PanelState, PersonRow } from "./session"; -import { describeEvent, placeName } from "./describe"; +import { describeEvent, personTask } from "./describe"; import { type City, formatTime, type SimEvent } from "./sim"; import { SWATCH } from "./use-city"; const ROW = 30, VIEWPORT = 8; -function Dot({ action }: { action: PersonRow["action"] }) { +export function Dot({ action }: { action: PersonRow["action"] }) { return ; } @@ -30,7 +30,7 @@ export function StatusTable({ t, city, people, follow, onFollow }: { t: Labels; {personName(t, p.id)} - {p.state === "idle" ? t.states.idle : `${p.state === "traveling" ? `${t.states.traveling} ` : ""}${p.action ? t.actions[p.action] : ""} · ${placeName(t, city, p.place)}`} + {personTask(t, city, p)} `${t.needNames[i]} ${Math.round(v * 100)}%`).join(", ")} role="img"> {p.needs.map((v, i) => 0.75 ? "bg-signal-2" : "bg-signal"}`} style={{ height: `${Math.round(v * 100)}%` }} />)} From e7f78f7ff62a1967614a54a554dcfb2c628eb90a Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 15:25:38 +0800 Subject: [PATCH 3/7] feat(city): a model city with roofs, glass towers and people who go indoors and talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scene: the city sits on a plinth under a gradient sky dome with a sunset band on the sun's side; houses get gable roofs, offices a podium with a lobby, a set-back crown and roof plant, all flat roofs a parapet; lane markings, park paths, doors, soft shadows under buildings and trees, walls darkened by height in the shader, office glass that reflects the hour's sky, a river that catches the sun, softer lamp pools, neutral tone mapping. The people: one merged figure (legs, shoes, arms, hands, head, hair, nose) whose limbs swing in the vertex shader; the shirt carries the activity colour and a glow of it sits at their feet. On screen only (the simulation and every number in the article are unchanged): people at work, a meal or asleep walk in through their door and come back out; people in a park pair up, face each other and take turns talking, with a speech bubble. Figures grow as the camera pulls back so the overview stays readable. Draw calls 10 → 16 (measured in the Overseer), sidenote and the two sentences about drawing updated in zh and en. window.__cities in development only. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../city-of-agents/components/city-view3d.ts | 658 +++++++++++++++--- .../city-of-agents/components/session.ts | 6 +- .../city-of-agents/components/use-city.ts | 2 + content/posts/city-of-agents/en.mdx | 6 +- content/posts/city-of-agents/zh.mdx | 6 +- lib/three.ts | 6 + 6 files changed, 580 insertions(+), 104 deletions(-) diff --git a/content/posts/city-of-agents/components/city-view3d.ts b/content/posts/city-of-agents/components/city-view3d.ts index f4a3212..bba021e 100644 --- a/content/posts/city-of-agents/components/city-view3d.ts +++ b/content/posts/city-of-agents/components/city-view3d.ts @@ -5,29 +5,98 @@ type Three = typeof import("@/lib/three"); /** Scene colours. These are materials of a little world with its own sky, not UI: the page's tokens colour the people. */ const C = { - outskirts: 0x3d5a45, asphalt: 0x3a3d4a, pavement: 0xb9bcc6, stripe: 0xe8e8ee, river: 0x3f7fb5, - lot: { residential: 0x7fa06a, commercial: 0x9a9eab, food: 0xb3a590, park: 0x5f9e57, riverside: 0xd6c79a }, - house: [0xd9b38c, 0xe6c9a8, 0xc98f6b, 0xd4a27a], office: [0x8fa3b8, 0x7b8da6, 0xa3b3c4, 0x6f8199], shop: [0xe9e2d0, 0xdfd3bd], - awning: [0xff6e96, 0x79dafa, 0xb9a5ff, 0xffc46b], trunk: 0x6b4a32, crown: [0x4f8a4a, 0x5c9a52, 0x3f7a44], metal: 0x4a4e5c, bench: 0x8a6a45, - glass: 0x27324a, lit: [0xffd98a, 0xffe9b8, 0xffc46b, 0xcfe6ff], lampOff: 0x5a5e6c, lampOn: 0xffd9a0, - night: 0x070918, dusk: 0xf08a5d, day: 0x9ccff2, sunLow: 0xffb070, sunHigh: 0xfff4e0, moon: 0x8fa8ff, + plinth: 0x232836, verge: 0x4f6e52, asphalt: 0x3b3f4c, pavement: 0xc4c7cf, stripe: 0xeeeef2, laneLine: 0xd9d6cc, centreLine: 0xe8b85a, river: 0x2f6f9f, + lot: { residential: 0x86a570, commercial: 0xa3a7b2, food: 0xb8ab96, park: 0x62a05a, riverside: 0xd8c99e }, + path: 0xd9ceb0, + house: [0xecd8bd, 0xdcbd9d, 0xf2e5d3, 0xcfa98a, 0xe3cbb3], roof: [0xa65d48, 0x8c5344, 0x5d6470, 0x6e5247, 0xb8715a, 0x4f5866], + office: [0xa4b4c6, 0x8797ac, 0xbcc7d3, 0x71819a, 0xcad0d8], podium: 0x7f8796, shop: [0xeee7d6, 0xe2d6c0], + awning: [0xff6e96, 0x79dafa, 0xb9a5ff, 0xffc46b], trunk: 0x6b4a32, crown: [0x4f8a4a, 0x5f9d52, 0x3f7a44, 0x6aa65a, 0x467f3f], metal: 0x4a4e5c, bench: 0x8a6a45, + lit: [0xffd68a, 0xffe6b0, 0xffc466, 0xd6e8ff], lampOff: 0x5a5e6c, lampOn: 0xffd9a0, + sky: { dayZenith: 0x4f8fd6, dayHorizon: 0xd6e8f3, duskZenith: 0x3f4f8c, duskHorizon: 0xf29a6a, nightZenith: 0x03050d, nightHorizon: 0x18203d }, + glass: { nightLow: 0x0a0f1d, nightHigh: 0x172036, dayLow: 0x3a4d68 }, + sunLow: 0xffb070, sunHigh: 0xfff4e0, moon: 0x8fa8ff, hemiDay: 0xdce9ff, hemiNight: 0x5b6a9c, }; /** People are drawn about twice life size: at the distance the whole city is seen from, life size is two pixels. */ const PERSON = 2.2; +/** Top of a block's lot, which buildings stand on. */ +const LOT = 0.26; +/** Green belt between the city and the edge of the model. */ +const MARGIN = 14; +/** Houses up to this tall get a pitched roof; taller ones are flat-roofed flats. */ +const PITCHED = 10; +/** Offices from this tall stand on a podium; from `CROWN` up they end in a set-back crown. */ +const PODIUM = 28, CROWN = 45; export type PeopleColors = Record; -/** What the view needs to draw everyone: positions now and one tick ago, which way they face, what they are up to. */ -export type PeopleFrame = { count: number; x: Float64Array; y: Float64Array; px: Float64Array; py: Float64Array; heading: Float64Array; action: (Action | null)[]; walking: Uint8Array }; +/** + * What the view needs to draw everyone: positions now and one tick ago, which way they face, what they are up to, and + * `at`, the place where they are doing it (−1 unless they are doing something). + */ +export type PeopleFrame = { count: number; x: Float64Array; y: Float64Array; px: Float64Array; py: Float64Array; heading: Float64Array; action: (Action | null)[]; walking: Uint8Array; at: Int32Array }; const hash01 = (n: number) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; const smooth = (x: number) => { const t = Math.min(1, Math.max(0, x)); return t * t * (3 - 2 * t); }; type Box = { x: number; y: number; z: number; sx: number; sy: number; sz: number; color: number }; +/** The sky: a gradient from the zenith to the horizon and on down to a darker ground, a sunset band, and a glow where the sun is. */ +const SKY_VERTEX = /* glsl */ ` +varying vec3 vDir; +void main() { + vDir = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; +const SKY_FRAGMENT = /* glsl */ ` +uniform vec3 uZenith; uniform vec3 uHorizon; uniform vec3 uGround; uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uDusk; uniform float uDuskAmount; +varying vec3 vDir; +void main() { + vec3 d = normalize(vDir); + vec3 c = d.z > 0.0 ? mix(uHorizon, uZenith, pow(smoothstep(0.0, 1.0, d.z), 0.55)) : mix(uHorizon, uGround, smoothstep(0.0, 0.9, -d.z)); + // Dawn and dusk colour a band along the horizon, strongest towards the sun. + float toward = max(dot(normalize(d.xy + 1e-5), normalize(uSunDir.xy + 1e-5)), 0.0); + c = mix(c, uDusk, uDuskAmount * exp(-abs(d.z) * 5.0) * (0.2 + 0.8 * toward * toward)); + float s = max(dot(d, uSunDir), 0.0); + c += uSunColor * (pow(s, 900.0) * 2.0 + pow(s, 12.0) * 0.18) * smoothstep(-0.05, 0.02, d.z); + gl_FragColor = vec4(c, 1.0); + #include + #include +}`; + +/** + * The figure's pose, per vertex: legs swing opposite each other and the arms opposite the legs, by how much the person + * is walking; while talking the right arm gestures and the head nods. `aAnim` is per person: stride phase, walking + * (0–1), talking (0 silent, 0.4 listening, 1 speaking), and a number that picks their skin, hair and trousers. + */ +const FIGURE_VERTEX = /* glsl */ ` +attribute float aPart; attribute vec3 aJoint; attribute vec4 aAnim; +uniform float uTime; +varying float vPart; varying float vLook; +vec3 rotY(vec3 v, float a) { float c = cos(a), s = sin(a); return vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z); } +float figureAngle() { + float swing = sin(aAnim.x * 3.14159), walk = aAnim.y, talk = aAnim.z, look = aAnim.w, kind = aJoint.x, side = aJoint.y; + float speak = smoothstep(0.6, 1.0, talk), listen = step(0.2, talk); + if (kind == 1.0) return side * swing * 0.6 * walk; + if (kind == 2.0) return -side * swing * 0.5 * walk - (side > 0.0 ? speak * (1.0 + 0.35 * sin(uTime * 7.0 + look * 20.0)) : speak * 0.15 * sin(uTime * 4.0 + look * 9.0)); + if (kind == 3.0) return listen * (speak > 0.5 ? 0.06 : 0.14) * sin(uTime * (speak > 0.5 ? 3.0 : 5.0) + look * 13.0); + return 0.0; +}`; +/** Parts: 0 shirt (the activity's colour), 1 skin, 2 trousers, 3 hair, 4 shoes. Colours are linear. */ +const FIGURE_FRAGMENT = /* glsl */ ` +uniform float uGlow; +varying float vPart; varying float vLook; +vec3 figureColor(vec3 shirt) { + if (vPart < 0.5) return shirt; + if (vPart < 1.5) return mix(vec3(0.9, 0.64, 0.5), vec3(0.34, 0.18, 0.1), pow(fract(vLook * 7.31), 1.8)); + if (vPart < 2.5) return mix(vec3(0.02, 0.025, 0.05), vec3(0.2, 0.18, 0.15), fract(vLook * 5.13)); + if (vPart < 3.5) { float h = fract(vLook * 3.77); return h < 0.45 ? vec3(0.01, 0.008, 0.007) : h < 0.75 ? vec3(0.09, 0.04, 0.02) : h < 0.9 ? vec3(0.3, 0.16, 0.06) : vec3(0.45, 0.43, 0.4); } + return vec3(0.02); +}`; + /** - * The city as a handful of instanced meshes: every box (ground, blocks, zebra stripes, buildings, awnings, posts, benches) - * is one draw call, tree crowns one, windows one, lamp heads and their pools of light one each, people two. Light, sky, fog, lamps - * and windows are functions of the simulated hour; the view keeps no state of its own about the time of day. + * The city as a handful of instanced meshes: every box (plinth, ground, blocks, road markings, buildings, awnings, posts, + * benches) is one draw call; roofs, tree crowns, windows, lamp heads, pools of lamplight and the soft shadows on the + * ground one each; people three. Light, sky, fog, lamps and windows are functions of the simulated hour; the view keeps no + * state of its own about the time of day. */ export class CityView { readonly renderer: THREE.WebGLRenderer; @@ -44,22 +113,42 @@ export class CityView { private readonly hemi: THREE.HemisphereLight; private readonly fog: THREE.FogExp2; private readonly sky: THREE.Color; + private readonly dome: THREE.Mesh; + private readonly skyUniforms: { uZenith: { value: THREE.Color }; uHorizon: { value: THREE.Color }; uGround: { value: THREE.Color }; uSunDir: { value: THREE.Vector3 }; uSunColor: { value: THREE.Color }; uDusk: { value: THREE.Color }; uDuskAmount: { value: number } }; + private readonly glassUniforms: { uGlassLow: { value: THREE.Color }; uGlassHigh: { value: THREE.Color } }; private readonly lampHeads: THREE.MeshBasicMaterial; private readonly lampPools: THREE.MeshBasicMaterial; private readonly windows: THREE.InstancedMesh | null = null; private boxMesh: THREE.InstancedMesh | null = null; - /** For the cutaway: each building's box (and awning) among the boxes, its windows among the panes, and how far it is lowered (1 = standing). */ - private readonly boxOf: number[] = []; - private readonly awningOf: number[] = []; + private roofMesh: THREE.InstancedMesh | null = null; + /** + * For the cutaway: each building's body among the boxes, the other boxes that belong to it (podium, crown, parapet, + * awning, plant on the roof), its roof, its windows among the panes, and how far it is lowered (1 = standing). + */ + private readonly bodyOf: number[] = []; + private readonly partsOf: number[][] = []; + private readonly roofOf: number[] = []; private readonly boxes: Box[] = []; + private readonly roofMatrices: THREE.Matrix4[] = []; private readonly panesOf: [number, number][] = []; private paneMatrices: THREE.Matrix4[] = []; private stand: Float32Array = new Float32Array(0); private readonly windowHash: Float32Array = new Float32Array(0); private readonly windowLit: Uint8Array = new Uint8Array(0); private windowStamp = -1; - private people: { bodies: THREE.InstancedMesh; heads: THREE.InstancedMesh; colors: Record; stride: Float32Array; shown: (string | null)[] } | null = null; - private readonly tmp: { a: THREE.Color; b: THREE.Color }; + private people: { + bodies: THREE.InstancedMesh; blobs: THREE.InstancedMesh; bubbles: THREE.InstancedMesh; anim: THREE.InstancedBufferAttribute; colors: Record; shown: (string | null)[]; + stride: Float32Array; walk: Float32Array; inside: Float32Array; door: Float32Array; heading: Float32Array; turned: Uint8Array; + talk: Float32Array; speak: Float32Array; partner: Int32Array; ox: Float32Array; oy: Float32Array; lastX: Float32Array; lastY: Float32Array; paired: number; + } | null = null; + private lastPeople = 0; + /** How far the camera stood from what it looked at, last frame. */ + private viewDistance = 0; + /** Where each place's door is, for the places that are buildings; parks and the riverside have none. */ + private readonly doorAt: ([number, number] | undefined)[] = []; + private readonly figureUniforms: { uTime: { value: number }; uGlow: { value: number } }; + private readonly softShadow: THREE.CanvasTexture; + private readonly tmp: { a: THREE.Color; b: THREE.Color; c: THREE.Color }; private readonly lightDir: THREE.Vector3; private readonly up: THREE.Vector3; private readonly origin: THREE.Vector3; @@ -69,33 +158,41 @@ export class CityView { private eased = false; private readonly personMatrix: THREE.Matrix4; private readonly personTurn: THREE.Quaternion; - private readonly personLean: THREE.Quaternion; private readonly personScale: THREE.Vector3; private readonly personAt: THREE.Vector3; + private readonly blobTurn: THREE.Quaternion; + private readonly blobScale: THREE.Vector3; + private readonly bubbleScale: THREE.Vector3; + private readonly leanAxis: THREE.Vector3; + private readonly leanNow: THREE.Quaternion; private readonly disposables: { dispose(): void }[] = []; constructor(private readonly T: Three, readonly canvas: HTMLCanvasElement, readonly city: City) { this.renderer = new T.WebGLRenderer({ canvas, antialias: true }); this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); + // Khronos' neutral tone mapping rolls off the highlights and leaves the palette's own colours alone. + this.renderer.toneMapping = T.NeutralToneMapping; this.renderer.shadowMap.enabled = true; // three r186 folded PCFSoft into PCFShadowMap (asking for PCFSoft only logs a warning): this is the soft one. this.renderer.shadowMap.type = T.PCFShadowMap; this.scene = new T.Scene(); - this.sky = new T.Color(C.night); + this.sky = new T.Color(C.sky.nightHorizon); this.scene.background = this.sky; - this.fog = new T.FogExp2(C.night, 0.28 / city.size); + this.fog = new T.FogExp2(C.sky.nightHorizon, 0.24 / city.size); this.scene.fog = this.fog; this.camera = new T.PerspectiveCamera(32, 2, 1, city.size * 6); this.camera.up.set(0, 0, 1); - this.tmp = { a: new T.Color(), b: new T.Color() }; + this.tmp = { a: new T.Color(), b: new T.Color(), c: new T.Color() }; this.lightDir = new T.Vector3(0, 0, 1); this.up = new T.Vector3(0, 0, 1); this.origin = new T.Vector3(); this.followAt = new T.Vector3(); this.lookAt = new T.Vector3(); this.cameraAt = new T.Vector3(); this.personMatrix = new T.Matrix4(); this.personTurn = new T.Quaternion(); this.personAt = new T.Vector3(); - this.personLean = new T.Quaternion().setFromAxisAngle(new T.Vector3(0, 1, 0), 0.16); this.personScale = new T.Vector3(PERSON, PERSON, PERSON); + this.blobTurn = new T.Quaternion(); this.blobScale = new T.Vector3(1.5 * PERSON, 1.5 * PERSON, 1); this.bubbleScale = new T.Vector3(); + this.leanAxis = new T.Vector3(0, 1, 0); this.leanNow = new T.Quaternion(); + this.figureUniforms = { uTime: { value: 0 }, uGlow: { value: 0.2 } }; - this.hemi = new T.HemisphereLight(0xcfe3ff, 0x4a4a3a, 1); + this.hemi = new T.HemisphereLight(C.hemiDay, 0x3f3f34, 1); this.hemi.position.set(0, 0, 1); this.sun = new T.DirectionalLight(0xffffff, 2); this.sun.castShadow = true; @@ -104,17 +201,32 @@ export class CityView { this.sun.shadow.normalBias = 0.6; this.scene.add(this.hemi, this.sun, this.sun.target); + // The sky is a dome that travels with the camera, drawn first and behind everything. + this.skyUniforms = { uZenith: { value: new T.Color() }, uHorizon: { value: new T.Color() }, uGround: { value: new T.Color() }, uSunDir: { value: new T.Vector3(0, 0, 1) }, uSunColor: { value: new T.Color() }, uDusk: { value: new T.Color(C.sky.duskHorizon) }, uDuskAmount: { value: 0 } }; + const domeGeometry = new T.SphereGeometry(city.size * 2.5, 32, 16), domeMaterial = new T.ShaderMaterial({ uniforms: this.skyUniforms, vertexShader: SKY_VERTEX, fragmentShader: SKY_FRAGMENT, side: T.BackSide, depthWrite: false, fog: false }); + this.dome = new T.Mesh(domeGeometry, domeMaterial); + this.dome.renderOrder = -1; this.dome.frustumCulled = false; + this.scene.add(this.dome); + this.disposables.push(domeGeometry, domeMaterial); + // City coordinates run 0…size; the scene is centred on the origin. this.root = new T.Group(); this.root.position.set(-city.size / 2, -city.size / 2, 0); this.scene.add(this.root); - const boxes: Box[] = [], crowns: [number, number, number][] = [], lamps: [number, number][] = []; - this.collect(boxes, crowns, lamps); + this.softShadow = this.makeSoftShadow(); + this.disposables.push(this.softShadow); + this.glassUniforms = { uGlassLow: { value: new T.Color(C.glass.nightLow) }, uGlassHigh: { value: new T.Color(C.glass.nightHigh) } }; + + const boxes: Box[] = [], roofs: THREE.Matrix4[] = [], roofColors: number[] = [], crowns: [number, number, number, number][] = [], lamps: [number, number][] = [], shadows: THREE.Matrix4[] = []; + this.collect(boxes, roofs, roofColors, crowns, lamps, shadows); this.addBoxes(boxes); + this.addRoofs(roofs, roofColors); this.addCrowns(crowns); - this.lampHeads = new T.MeshBasicMaterial({ color: C.lampOff }); - this.lampPools = new T.MeshBasicMaterial({ color: C.lampOn, transparent: true, opacity: 0, depthWrite: false, blending: T.AdditiveBlending, fog: false }); + this.addShadows(shadows); + this.addRiver(); + this.lampHeads = new T.MeshBasicMaterial({ color: C.lampOff, toneMapped: false }); + this.lampPools = new T.MeshBasicMaterial({ color: C.lampOn, map: this.makeGlow(), transparent: true, opacity: 0, depthWrite: false, blending: T.AdditiveBlending, fog: false, toneMapped: false }); this.addLamps(lamps); const built = this.addWindows(); if (built) { this.windows = built.mesh; this.windowHash = built.hash; this.windowLit = new Uint8Array(built.hash.length); } @@ -123,14 +235,27 @@ export class CityView { // ── building the scene ──────────────────────────────────────────────────── - private collect(boxes: Box[], crowns: [number, number, number][], lamps: [number, number][]): void { - const { city } = this, P = PARAMS.pavementWidth, slab = (r: Rect, top: number, thick: number, color: number) => boxes.push({ x: r.x, y: r.y, z: top - thick / 2, sx: r.w, sy: r.d, sz: thick, color }); - slab({ x: city.size / 2, y: city.size / 2, w: city.size * 5, d: city.size * 5 }, -0.4, 1, C.outskirts); - slab({ x: city.size / 2, y: city.size / 2, w: city.size, d: city.size }, 0, 1, C.asphalt); - slab({ ...city.river, d: city.size * 5 }, -0.2, 1, C.river); + private collect(boxes: Box[], roofs: THREE.Matrix4[], roofColors: number[], crowns: [number, number, number, number][], lamps: [number, number][], shadows: THREE.Matrix4[]): void { + const T = this.T, { city } = this, P = PARAMS.pavementWidth, S = city.size, river = city.river; + const slab = (r: Rect, top: number, thick: number, color: number) => boxes.push({ x: r.x, y: r.y, z: top - thick / 2, sx: r.w, sy: r.d, sz: thick, color }) - 1; + const shadow = (x: number, y: number, w: number, d: number, z = LOT + 0.02) => shadows.push(new T.Matrix4().makeScale(w, d, 1).setPosition(x, y, z)); + + // The model: a dark plinth, a green belt round the city, the river cut into it along the east side. + const west = -MARGIN, east = river.x + river.w / 2 + MARGIN, south = -MARGIN, north = S + MARGIN, mid = (south + north) / 2, span = north - south; + slab({ x: (west + east) / 2, y: mid, w: east - west, d: span }, -1, 7, C.plinth); + slab({ x: west / 2, y: mid, w: -west, d: span }, -0.1, 0.9, C.verge); + slab({ x: (river.x + river.w / 2 + east) / 2, y: mid, w: MARGIN, d: span }, -0.1, 0.9, C.verge); + slab({ x: S / 2, y: -MARGIN / 2, w: S, d: MARGIN }, -0.1, 0.9, C.verge); + slab({ x: S / 2, y: S + MARGIN / 2, w: S, d: MARGIN }, -0.1, 0.9, C.verge); + slab({ x: river.x, y: mid, w: river.w, d: span }, -0.62, 0.4, this.shade(C.river, 0.55)); + slab({ x: S / 2, y: S / 2, w: S, d: S }, 0, 1, C.asphalt); + for (const b of city.blocks) { slab(b.rect, 0.2, 0.6, C.pavement); - slab({ ...b.rect, w: b.rect.w - 2 * P, d: b.rect.d - 2 * P }, 0.26, 0.6, C.lot[b.zone]); + const inner = { ...b.rect, w: b.rect.w - 2 * P, d: b.rect.d - 2 * P }; + slab(inner, LOT, 0.6, C.lot[b.zone]); + // Parks get two paths crossing in the middle. + if (b.zone === "park") { slab({ ...inner, w: 2.2 }, LOT + 0.02, 0.6, C.path); slab({ ...inner, d: 2.2 }, LOT + 0.02, 0.6, C.path); } } for (const r of city.crosswalks) { const alongX = r.w > r.d, span = alongX ? r.w : r.d, count = Math.floor((span - P) / 1.3); @@ -139,32 +264,119 @@ export class CityView { boxes.push({ x: r.x + (alongX ? at : 0), y: r.y + (alongX ? 0 : at), z: 0.02, sx: alongX ? 0.65 : r.w * 0.85, sy: alongX ? r.d * 0.85 : 0.65, sz: 0.04, color: C.stripe }); } } + this.markings(boxes); + for (const b of city.buildings) { - const palette = C[b.kind], { rect } = b; - this.boxOf[b.id] = boxes.length; this.awningOf[b.id] = -1; - boxes.push({ x: rect.x, y: rect.y, z: 0.26 + b.height / 2, sx: rect.w, sy: rect.d, sz: b.height, color: palette[b.id % palette.length] }); - if (b.kind === "shop") { - this.awningOf[b.id] = boxes.length; - const [dx, dy] = this.facing(b); - boxes.push({ x: rect.x + dx * (rect.w / 2 + 0.6), y: rect.y + dy * (rect.d / 2 + 0.6), z: 2.7, sx: dx ? 1.4 : rect.w * 0.9, sy: dy ? 1.4 : rect.d * 0.9, sz: 0.18, color: C.awning[b.id % C.awning.length] }); + const { rect } = b, id = b.id, parts: number[] = [], color = C[b.kind][id % C[b.kind].length]; + this.roofOf[id] = -1; this.partsOf[id] = parts; + const part = (box: Box) => parts.push(boxes.push(box) - 1); + const cap = (r: Rect, top: number, c: number) => part({ x: r.x, y: r.y, z: top + 0.3, sx: r.w + 0.4, sy: r.d + 0.4, sz: 0.6, color: this.shade(c, 0.78) }); + if (b.kind === "office" && b.height >= PODIUM) { + // A podium two floors high over the whole lot, the tower set back on it, and on the tallest a crown set back again. + const tower = { ...rect, w: rect.w * 0.82, d: rect.d * 0.82 }, podium = 7; + part({ x: rect.x, y: rect.y, z: LOT + podium / 2, sx: rect.w + 0.6, sy: rect.d + 0.6, sz: podium, color: C.podium }); + this.bodyOf[id] = boxes.push({ x: rect.x, y: rect.y, z: LOT + b.height / 2, sx: tower.w, sy: tower.d, sz: b.height, color }) - 1; + const top = LOT + b.height; + if (b.height >= CROWN) { + const crown = 3 + hash01(id) * 3; + part({ x: rect.x, y: rect.y, z: top + crown / 2, sx: tower.w * 0.62, sy: tower.d * 0.62, sz: crown, color: this.shade(color, 0.9) }); + cap({ ...tower, w: tower.w * 0.62, d: tower.d * 0.62 }, top + crown, color); + } else cap(tower, top, color); + this.plant(part, tower, top, id); + shadow(rect.x, rect.y, rect.w + 7, rect.d + 7); + } else { + this.bodyOf[id] = boxes.push({ x: rect.x, y: rect.y, z: LOT + b.height / 2, sx: rect.w, sy: rect.d, sz: b.height, color }) - 1; + const top = LOT + b.height; + if (b.kind === "house" && b.height < PITCHED) { + // A gable roof, ridge one way or the other, eaves over the walls. + const turn = hash01(id * 3.1) < 0.5, pitch = Math.min(rect.w, rect.d) * 0.42, m = new T.Matrix4().makeScale(rect.w + 0.8, rect.d + 0.8, pitch); + if (turn) m.premultiply(new T.Matrix4().makeRotationZ(Math.PI / 2)); + m.setPosition(rect.x, rect.y, top); + this.roofOf[id] = roofs.push(m) - 1; roofColors.push(C.roof[Math.floor(hash01(id * 7.7) * C.roof.length)]); + } else { + cap(rect, top, color); + if (b.kind !== "shop") this.plant(part, rect, top, id); + } + if (b.kind === "shop") { + const [dx, dy] = this.facing(b); + part({ x: rect.x + dx * (rect.w / 2 + 0.6), y: rect.y + dy * (rect.d / 2 + 0.6), z: 2.7, sx: dx ? 1.4 : rect.w * 0.9, sy: dy ? 1.4 : rect.d * 0.9, sz: 0.18, color: C.awning[id % C.awning.length] }); + } + shadow(rect.x, rect.y, rect.w + (b.height > 20 ? 6 : 3.5), rect.d + (b.height > 20 ? 6 : 3.5)); } } + // A door on every building, on the side that faces its spot on the pavement: where people go in and come out. + for (const b of city.buildings) { + const { rect } = b, [dx, dy] = this.facing(b), podium = b.kind === "office" && b.height >= PODIUM ? 0.3 : 0, out = (dx ? rect.w : rect.d) / 2 + podium; + const wide = b.kind === "shop" ? 2.4 : b.kind === "office" ? 2.2 : 1.3, high = b.kind === "house" ? 2.2 : 2.8; + this.partsOf[b.id].push(boxes.push({ x: rect.x + dx * (out + 0.06), y: rect.y + dy * (out + 0.06), z: LOT + high / 2, sx: dx ? 0.14 : wide, sy: dy ? 0.14 : wide, sz: high, color: b.kind === "house" ? 0x5a3d2b : 0x1d2433 }) - 1); + this.doorAt[b.place] = [rect.x + dx * (out + 0.3), rect.y + dy * (out + 0.3)]; + } city.props.forEach((p, i) => { if (p.kind === "lamp") { boxes.push({ x: p.x, y: p.y, z: 2.4, sx: 0.16, sy: 0.16, sz: 4.4, color: C.metal }); lamps.push([p.x, p.y]); } else if (p.kind === "bench") boxes.push({ x: p.x, y: p.y, z: 0.5, sx: 1.7, sy: 0.55, sz: 0.45, color: C.bench }); else if (p.kind === "bin") boxes.push({ x: p.x, y: p.y, z: 0.65, sx: 0.5, sy: 0.5, sz: 0.85, color: C.metal }); - else { boxes.push({ x: p.x, y: p.y, z: 1.1, sx: 0.3, sy: 0.3, sz: 1.8, color: C.trunk }); crowns.push([p.x, p.y, 1.2 + hash01(i) * 0.7]); } + else { + const r = 1.2 + hash01(i) * 0.7; + boxes.push({ x: p.x, y: p.y, z: 1.1, sx: 0.3, sy: 0.3, sz: 1.8, color: C.trunk }); + crowns.push([p.x, p.y, 2 + r, r]); + // Every other tree carries a smaller second crown, off to one side and higher up. + if (hash01(i * 5.3) < 0.5) { const a = hash01(i * 9.1) * Math.PI * 2; crowns.push([p.x + Math.cos(a) * r * 0.5, p.y + Math.sin(a) * r * 0.5, 2 + r * 1.9, r * 0.6]); } + shadow(p.x, p.y, r * 3.2, r * 3.2, 0.3); + } }); } + /** Lane markings between the junctions: a double centre line on the avenues, a dashed one on the streets. */ + private markings(boxes: Box[]): void { + const { city } = this, P = PARAMS.pavementWidth, B = PARAMS.blockSize, stretches = city.blocks.filter((b) => b.i === 0).map((b) => [b.rect.y - B / 2 + P + 0.5, b.rect.y + B / 2 - P - 0.5]); + const line = (vertical: boolean, c: number, from: number, to: number, offset: number, color: number) => { + const along = (from + to) / 2, length = to - from; + boxes.push({ x: vertical ? c + offset : along, y: vertical ? along : c + offset, z: 0.015, sx: vertical ? 0.22 : length, sy: vertical ? length : 0.22, sz: 0.03, color }); + }; + for (const road of city.roads) { + const c = road.vertical ? road.rect.x : road.rect.y; + for (const [from, to] of stretches) { + if (road.arterial) { line(road.vertical, c, from, to, -0.22, C.centreLine); line(road.vertical, c, from, to, 0.22, C.centreLine); } + else for (let s = from; s + 2.4 <= to; s += 5) line(road.vertical, c, s, s + 2.4, 0, C.laneLine); + } + } + } + + /** Plant on a flat roof: a box or two of machinery, never over the edge. */ + private plant(part: (box: Box) => number, r: Rect, top: number, id: number): void { + const n = 1 + Math.floor(hash01(id * 2.3) * 2); + for (let k = 0; k < n; k++) { + const w = r.w * (0.18 + hash01(id + k * 4.1) * 0.14), d = r.d * (0.18 + hash01(id + k * 6.7) * 0.14), h = 1.2 + hash01(id + k * 8.9) * 1.8; + const x = r.x + (hash01(id + k * 3.3) - 0.5) * (r.w - w - 1.5), y = r.y + (hash01(id + k * 5.9) - 0.5) * (r.d - d - 1.5); + part({ x, y, z: top + 0.6 + h / 2, sx: w, sy: d, sz: h, color: 0x8b909c }); + } + } + + private shade(hex: number, k: number): number { return this.tmp.c.setHex(hex).multiplyScalar(k).getHex(); } + /** Which way a building's door faces: towards its place on the pavement. */ private facing(b: Building): [number, number] { const [px, py] = this.city.places[b.place].centre, dx = px - b.rect.x, dy = py - b.rect.y; return Math.abs(dx) > Math.abs(dy) ? [Math.sign(dx), 0] : [0, Math.sign(dy)]; } + /** + * Every box in one draw call. Walls darken towards the ground over the first few metres, the way light is blocked + * where a wall meets the street: ambient occlusion by height, worked out in the shader instead of by a pass. + */ private addBoxes(boxes: Box[]): void { - const T = this.T, geometry = new T.BoxGeometry(1, 1, 1), material = new T.MeshStandardMaterial({ roughness: 0.92, metalness: 0 }); + const T = this.T, geometry = new T.BoxGeometry(1, 1, 1), material = new T.MeshStandardMaterial({ roughness: 0.86, metalness: 0 }); + material.onBeforeCompile = (shader) => { + shader.vertexShader = shader.vertexShader.replace("#include ", "#include \nvarying float vAoZ;\nvarying float vAoSide;").replace("#include ", [ + "#include ", + "vec4 aoAt = vec4(transformed, 1.0);", + "#ifdef USE_INSTANCING\naoAt = instanceMatrix * aoAt;\n#endif", + "vAoZ = (modelMatrix * aoAt).z;", + "vAoSide = 1.0 - abs(objectNormal.z);", + ].join("\n")); + shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \nvarying float vAoZ;\nvarying float vAoSide;").replace("#include ", + "#include \ndiffuseColor.rgb *= mix(1.0, mix(0.5, 1.0, smoothstep(0.2, 4.0, vAoZ)), vAoSide);"); + }; const mesh = new T.InstancedMesh(geometry, material, boxes.length), m = new T.Matrix4(), color = new T.Color(); boxes.forEach((b, i) => { m.makeScale(b.sx, b.sy, b.sz).setPosition(b.x, b.y, b.z); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(b.color)); }); mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; @@ -173,36 +385,78 @@ export class CityView { this.boxMesh = mesh; this.boxes.push(...boxes); this.stand = new Float32Array(this.city.buildings.length).fill(1); } - private addCrowns(crowns: [number, number, number][]): void { + /** A unit gable roof: base 1 × 1 at z = 0, ridge along x at z = 1. */ + private addRoofs(roofs: THREE.Matrix4[], colors: number[]): void { + if (!roofs.length) return; + const T = this.T, a = [-0.5, -0.5, 0], b = [0.5, -0.5, 0], c = [0.5, 0.5, 0], d = [-0.5, 0.5, 0], e = [-0.5, 0, 1], f = [0.5, 0, 1]; + const faces = [a, b, f, a, f, e, c, d, e, c, e, f, d, a, e, b, c, f]; + const geometry = new T.BufferGeometry(); + geometry.setAttribute("position", new T.BufferAttribute(new Float32Array(faces.flat()), 3)); + geometry.computeVertexNormals(); + const material = new T.MeshStandardMaterial({ roughness: 0.8 }), mesh = new T.InstancedMesh(geometry, material, roofs.length), color = new T.Color(); + roofs.forEach((m, i) => { mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(colors[i])); }); + mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; + this.root.add(mesh); + this.disposables.push(geometry, material, mesh); + this.roofMesh = mesh; this.roofMatrices.push(...roofs); + } + + private addCrowns(crowns: [number, number, number, number][]): void { if (!crowns.length) return; - const T = this.T, geometry = new T.IcosahedronGeometry(1, 0), material = new T.MeshStandardMaterial({ roughness: 1, flatShading: true }); + const T = this.T, geometry = new T.IcosahedronGeometry(1, 1), material = new T.MeshStandardMaterial({ roughness: 0.95, flatShading: true }); const mesh = new T.InstancedMesh(geometry, material, crowns.length), m = new T.Matrix4(), color = new T.Color(); - crowns.forEach(([x, y, r], i) => { m.makeScale(r, r, r * 1.25).setPosition(x, y, 2 + r); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(C.crown[i % C.crown.length])); }); - mesh.castShadow = true; mesh.frustumCulled = false; + crowns.forEach(([x, y, z, r], i) => { m.makeScale(r, r, r * 1.15).setPosition(x, y, z); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(C.crown[Math.floor(hash01(i * 1.7) * C.crown.length)])); }); + mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; + this.root.add(mesh); + this.disposables.push(geometry, material, mesh); + } + + /** A soft dark patch under every building and tree, which grounds them at night when the sun casts nothing. */ + private addShadows(shadows: THREE.Matrix4[]): void { + const T = this.T, geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial({ color: 0x000000, map: this.softShadow, transparent: true, opacity: 0.42, depthWrite: false }); + const mesh = new T.InstancedMesh(geometry, material, shadows.length); + shadows.forEach((m, i) => mesh.setMatrixAt(i, m)); + mesh.frustumCulled = false; this.root.add(mesh); this.disposables.push(geometry, material, mesh); } + /** Water on its own, smooth enough to catch the sun. */ + private addRiver(): void { + const T = this.T, r = this.city.river, geometry = new T.PlaneGeometry(r.w, this.city.size + 2 * MARGIN), material = new T.MeshStandardMaterial({ color: C.river, roughness: 0.18, metalness: 0.1 }); + const mesh = new T.Mesh(geometry, material); + mesh.position.set(r.x, this.city.size / 2, -0.6); + mesh.receiveShadow = true; + this.root.add(mesh); + this.disposables.push(geometry, material); + } + private addLamps(lamps: [number, number][]): void { - const T = this.T, m = new T.Matrix4(), head = new T.BoxGeometry(0.5, 0.5, 0.3), pool = new T.CircleGeometry(3, 20); + const T = this.T, m = new T.Matrix4(), head = new T.BoxGeometry(0.55, 0.55, 0.25), pool = new T.PlaneGeometry(11, 11); const heads = new T.InstancedMesh(head, this.lampHeads, lamps.length), pools = new T.InstancedMesh(pool, this.lampPools, lamps.length); - lamps.forEach(([x, y], i) => { heads.setMatrixAt(i, m.makeTranslation(x, y, 4.7)); pools.setMatrixAt(i, m.makeTranslation(x, y, 0.3)); }); + lamps.forEach(([x, y], i) => { heads.setMatrixAt(i, m.makeTranslation(x, y, 4.7)); pools.setMatrixAt(i, m.makeTranslation(x, y, 0.32)); }); heads.frustumCulled = false; pools.frustumCulled = false; this.root.add(heads, pools); - this.disposables.push(head, pool, heads, pools, this.lampHeads, this.lampPools); + this.disposables.push(head, pool, heads, pools, this.lampHeads, this.lampPools, this.lampPools.map as THREE.Texture); } - /** One quad per window, up to six across and twenty-four floors up on each face. */ + /** + * One quad per window. Homes and shops have punched windows; offices have ribbons of glass, floor after floor. + * An unlit pane shows the sky it faces (a gradient that follows the hour); a lit one shows its own light, untouched + * by tone mapping so it glows. + */ private addWindows(): { mesh: THREE.InstancedMesh; hash: Float32Array } | null { const T = this.T, panes: { m: THREE.Matrix4; hash: number }[] = [], right = new T.Vector3(), up = new T.Vector3(0, 0, 1), normal = new T.Vector3(); for (const b of this.city.buildings) { - const first = panes.length; - const { rect } = b, shop = b.kind === "shop", floors = shop ? 1 : Math.min(24, Math.floor((b.height - 1) / 3.2)), bHash = hash01(b.id + 0.5); + const first = panes.length, shop = b.kind === "shop", office = b.kind === "office", tower = office && b.height >= PODIUM; + const rect = tower ? { ...b.rect, w: b.rect.w * 0.82, d: b.rect.d * 0.82 } : b.rect; + const floors = shop ? 1 : Math.min(24, Math.floor((b.height - 1) / 3.2)), bHash = hash01(b.id + 0.5); for (const [nx, ny] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const width = nx ? rect.d : rect.w, cols = shop ? 1 : Math.min(6, Math.max(1, Math.floor(width / 2.6))), cell = width / cols; normal.set(nx, ny, 0); right.set(-ny, nx, 0); - for (let f = 0; f < floors; f++) for (let c = 0; c < cols; c++) { - const along = (c + 0.5) * cell - width / 2, w = shop ? width * 0.8 : cell * 0.55, h = shop ? 1.6 : 1.5, z = 0.26 + (shop ? 1.3 : 2 + f * 3.2); + for (let f = tower ? 2 : 0; f < floors; f++) for (let c = 0; c < cols; c++) { + const along = (c + 0.5) * cell - width / 2, w = shop ? width * 0.8 : office ? cell * 0.9 : cell * 0.5, h = shop ? 1.6 : office ? 2.2 : 1.4; + const z = LOT + (shop ? 1.3 : (office ? 1.8 : 2) + f * 3.2); const m = new T.Matrix4().makeBasis(right.clone().multiplyScalar(w), up.clone().multiplyScalar(h), normal); m.setPosition(rect.x + nx * (rect.w / 2 + 0.03) - ny * along, rect.y + ny * (rect.d / 2 + 0.03) + nx * along, z); // Most of a building's windows follow the building; each is a little early or late, and a quarter never come on. @@ -210,11 +464,31 @@ export class CityView { panes.push({ m, hash: own < 0.25 ? -1 : Math.min(0.999, bHash * 0.75 + own * 0.25) }); } } + if (tower) { + // The podium's lobby: one tall pane per bay, all the way round. + const r = b.rect; + for (const [nx, ny] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const width = (nx ? r.d : r.w) + 0.6, cols = Math.max(2, Math.floor(width / 3.4)), cell = width / cols; + normal.set(nx, ny, 0); right.set(-ny, nx, 0); + for (let c = 0; c < cols; c++) { + const along = (c + 0.5) * cell - width / 2, m = new T.Matrix4().makeBasis(right.clone().multiplyScalar(cell * 0.86), up.clone().multiplyScalar(4.4), normal); + m.setPosition(r.x + nx * (r.w / 2 + 0.33) - ny * along, r.y + ny * (r.d / 2 + 0.33) + nx * along, LOT + 2.7); + panes.push({ m, hash: Math.min(0.999, bHash * 0.5 + hash01(panes.length * 2.1) * 0.2) }); + } + } + } this.panesOf[b.id] = [first, panes.length]; } if (!panes.length) return null; - const geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial(), mesh = new T.InstancedMesh(geometry, material, panes.length), glass = new T.Color(C.glass); - panes.forEach((p, i) => { mesh.setMatrixAt(i, p.m); mesh.setColorAt(i, glass); }); + const geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial({ toneMapped: false }); + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, this.glassUniforms); + shader.vertexShader = shader.vertexShader.replace("#include ", "#include \nvarying float vPaneY;").replace("#include ", "#include \nvPaneY = position.y + 0.5;"); + shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \nvarying float vPaneY;\nuniform vec3 uGlassLow;\nuniform vec3 uGlassHigh;").replace("#include ", + "#include \nfloat paneLit = step(0.004, max(diffuseColor.r, max(diffuseColor.g, diffuseColor.b)));\ndiffuseColor.rgb = mix(mix(uGlassLow, uGlassHigh, vPaneY), diffuseColor.rgb * mix(0.85, 1.1, vPaneY), paneLit);"); + }; + const mesh = new T.InstancedMesh(geometry, material, panes.length), dark = new T.Color(0, 0, 0); + panes.forEach((p, i) => { mesh.setMatrixAt(i, p.m); mesh.setColorAt(i, dark); }); mesh.frustumCulled = false; this.root.add(mesh); this.disposables.push(geometry, material, mesh); @@ -222,46 +496,212 @@ export class CityView { return { mesh, hash: Float32Array.from(panes, (p) => p.hash) }; } + /** A square that fades out towards its edges, rounded at the corners: the soft shadow under things. */ + private makeSoftShadow(): THREE.CanvasTexture { + const size = 64, canvas = document.createElement("canvas"), ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + canvas.width = canvas.height = size; + const img = ctx.createImageData(size, size); + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { + const u = (x + 0.5) / size - 0.5, v = (y + 0.5) / size - 0.5, dx = Math.max(Math.abs(u) - 0.2, 0), dy = Math.max(Math.abs(v) - 0.2, 0); + const k = (y * size + x) * 4, a = (1 - smooth(Math.hypot(dx, dy) / 0.3)) ** 1.6; + img.data[k] = img.data[k + 1] = img.data[k + 2] = 255; img.data[k + 3] = Math.round(a * 255); + } + ctx.putImageData(img, 0, 0); + return new this.T.CanvasTexture(canvas); + } + + /** A round glow, bright in the middle: a street lamp's light on the pavement. */ + private makeGlow(): THREE.CanvasTexture { + const size = 64, canvas = document.createElement("canvas"), ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + canvas.width = canvas.height = size; + const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + g.addColorStop(0, "rgba(255,255,255,0.8)"); g.addColorStop(0.45, "rgba(255,255,255,0.32)"); g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); + const texture = new this.T.CanvasTexture(canvas); + texture.colorSpace = this.T.SRGBColorSpace; + return texture; + } + // ── people ──────────────────────────────────────────────────────────────── - /** Two instanced meshes for everyone: a body that takes the light and a head that glows a little, so people read at night. */ + /** + * Everyone is one instanced mesh of a whole figure (legs, shoes, hips, torso, arms, hands, neck, head, hair) whose limbs + * the vertex shader swings, plus a soft shadow at their feet and a speech bubble. The shirt wears the activity's colour + * and glows a little so it reads at night; skin, hair and trousers differ from person to person. + */ setPeople(count: number, colors: PeopleColors): void { - if (this.people) { this.root.remove(this.people.bodies, this.people.heads); this.people.bodies.dispose(); this.people.heads.dispose(); } - const T = this.T, body = new T.BoxGeometry(0.62, 0.42, 1), head = new T.SphereGeometry(0.3, 10, 8); - const bodies = new T.InstancedMesh(body, new T.MeshStandardMaterial({ roughness: 0.8 }), count), heads = new T.InstancedMesh(head, new T.MeshBasicMaterial(), count); - bodies.castShadow = true; bodies.frustumCulled = false; heads.frustumCulled = false; - const white = new T.Color(colors.idle); - for (let i = 0; i < count; i++) { bodies.setColorAt(i, white); heads.setColorAt(i, white); } - this.root.add(bodies, heads); - this.disposables.push(body, head, bodies.material as THREE.Material, heads.material as THREE.Material); - this.people = { bodies, heads, colors: Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, new T.Color(v)])), stride: new Float32Array(count), shown: new Array(count).fill(null) }; + if (this.people) { + const { bodies, blobs, bubbles } = this.people; + this.root.remove(bodies, blobs, bubbles); + for (const mesh of [bodies, blobs, bubbles]) { mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); mesh.dispose(); } + (blobs.material as THREE.MeshBasicMaterial).map?.dispose(); + } + const T = this.T, figure = this.makeFigure(), material = new T.MeshStandardMaterial({ roughness: 0.7 }); + const anim = new T.InstancedBufferAttribute(new Float32Array(count * 4), 4); + figure.setAttribute("aAnim", anim); + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, this.figureUniforms); + shader.vertexShader = shader.vertexShader.replace("#include ", `#include \n${FIGURE_VERTEX}`) + .replace("#include ", "#include \nfloat poseAngle = figureAngle();\nobjectNormal = rotY(objectNormal, poseAngle);\nvPart = aPart; vLook = aAnim.w;") + .replace("#include ", "#include \ntransformed = vec3(0.0, 0.0, aJoint.z) + rotY(transformed - vec3(0.0, 0.0, aJoint.z), poseAngle);"); + shader.fragmentShader = shader.fragmentShader.replace("#include ", `#include \n${FIGURE_FRAGMENT}`) + .replace("#include ", "#include \nvec3 shirt = diffuseColor.rgb;\ndiffuseColor.rgb = figureColor(shirt);") + .replace("#include ", "#include \ntotalEmissiveRadiance += vPart < 0.5 ? shirt * uGlow : vec3(0.0);"); + }; + const bodies = new T.InstancedMesh(figure, material, count); + const blobs = new T.InstancedMesh(new T.PlaneGeometry(1, 1), new T.MeshBasicMaterial({ map: this.makeGlow(), transparent: true, opacity: 0.75, depthWrite: false, toneMapped: false }), count); + const bubbles = new T.InstancedMesh(new T.SphereGeometry(0.3, 12, 8), new T.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, opacity: 0.92 }), count); + bodies.castShadow = true; + for (const mesh of [bodies, blobs, bubbles]) mesh.frustumCulled = false; + const zero = new T.Matrix4().makeScale(0, 0, 0), white = new T.Color(colors.idle); + for (let i = 0; i < count; i++) { bodies.setColorAt(i, white); blobs.setColorAt(i, white); bubbles.setMatrixAt(i, zero); anim.setW(i, hash01(i * 3.7 + 0.1)); } + this.root.add(blobs, bodies, bubbles); + const heading = new Float32Array(count); + this.people = { + bodies, blobs, bubbles, anim, colors: Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, new T.Color(v)])), shown: new Array(count).fill(null), + stride: new Float32Array(count), walk: new Float32Array(count), inside: new Float32Array(count), door: new Float32Array(count * 2), heading, turned: new Uint8Array(count), + talk: new Float32Array(count), speak: new Float32Array(count), partner: new Int32Array(count).fill(-1), ox: new Float32Array(count), oy: new Float32Array(count), lastX: new Float32Array(count), lastY: new Float32Array(count), paired: -Infinity, + }; } /** - * Places everyone between their position one tick ago (`px`, `py`) and now, `alpha` of the way. Walking is drawn, not - * simulated: the body bobs once per stride and leans into the walk. + * Places everyone between their position one tick ago (`px`, `py`) and now, `alpha` of the way. Everything here is + * drawn, not simulated: in the simulation a person at work, at a meal or asleep stands on their spot outside the door, + * and a person in the park stands on theirs. On screen the first walks in through the door (and back out when they + * leave), and the second turns to the nearest other person there, closes in and talks, the two taking turns. */ updatePeople(f: PeopleFrame, alpha: number): void { const people = this.people; if (!people) return; - const m = this.personMatrix, q = this.personTurn, lean = this.personLean, s = this.personScale, at = this.personAt; + const now = performance.now() / 1000, dt = this.lastPeople ? Math.min(0.1, now - this.lastPeople) : 0; + this.lastPeople = now; this.figureUniforms.uTime.value = now % 1000; + if (now - people.paired > 0.4) { this.pairUp(f); people.paired = now; } + // From afar a figure is a few pixels: people and their glow grow as the camera pulls back, up to half as big again. + const far = Math.min(1.5, Math.max(1, this.viewDistance / 120)), glow = 1.6 * Math.min(2.5, Math.max(1, this.viewDistance / 90)); + const m = this.personMatrix, q = this.personTurn, s = this.personScale, at = this.personAt, anim = people.anim, k = 1 - Math.exp(-dt * 3), turn = 1 - Math.exp(-dt * 8); let recolored = false; for (let i = 0; i < f.count; i++) { - const x = f.px[i] + (f.x[i] - f.px[i]) * alpha, y = f.py[i] + (f.y[i] - f.py[i]) * alpha, walking = f.walking[i] === 1; - if (walking) people.stride[i] += 0.16; + let x = f.px[i] + (f.x[i] - f.px[i]) * alpha, y = f.py[i] + (f.y[i] - f.py[i]) * alpha; + const place = f.at[i], door = place >= 0 ? this.doorAt[place] : undefined, social = place >= 0 && !door, partner = social ? people.partner[i] : -1; + if (door) { people.door[2 * i] = door[0]; people.door[2 * i + 1] = door[1]; } + // In through the door or out of it, about a second either way. + const inside = people.inside[i] = Math.min(1, Math.max(0, people.inside[i] + (door ? dt : -dt) * 1.1)), e = smooth(inside); + // A conversation: both step towards each other (or apart) until they stand a pace apart. + let tx = 0, ty = 0; + if (partner >= 0) { + const d = Math.hypot(f.x[partner] - f.x[i], f.y[partner] - f.y[i]), ux = d > 0.01 ? (f.x[partner] - f.x[i]) / d : i < partner ? 1 : -1, uy = d > 0.01 ? (f.y[partner] - f.y[i]) / d : 0, closer = (d - 2.8) / 2; + tx = ux * closer; ty = uy * closer; + } + people.ox[i] += (tx - people.ox[i]) * k; people.oy[i] += (ty - people.oy[i]) * k; + x += people.ox[i]; y += people.oy[i]; + if (e > 0) { x += (people.door[2 * i] - x) * e; y += (people.door[2 * i + 1] - y) * e; } + const moved = Math.hypot(x - people.lastX[i], y - people.lastY[i]); + people.lastX[i] = x; people.lastY[i] = y; if (i === this.follow) this.followAt.set(x - this.city.size / 2, y - this.city.size / 2, 0); - const bob = walking ? Math.abs(Math.sin(people.stride[i])) * 0.22 * PERSON : 0; - q.setFromAxisAngle(this.up, f.heading[i]); - if (walking) q.multiply(lean); - m.compose(at.set(x, y, 0.26 + 0.55 * PERSON + bob), q, s); + + // Which way they face: along the walk, at the door while going in, at the partner while talking. + let face = f.heading[i]; + if (door && inside < 1) face = Math.atan2(people.door[2 * i + 1] - y, people.door[2 * i] - x); + else if (partner >= 0) face = Math.atan2(f.y[partner] + people.oy[partner] - y, f.x[partner] + people.ox[partner] - x); + if (!people.turned[i]) { people.heading[i] = face; people.turned[i] = 1; } + const dh = Math.atan2(Math.sin(face - people.heading[i]), Math.cos(face - people.heading[i])); + people.heading[i] += dh * turn; + + // Legs follow the ground covered: one stride is about a body length. + const walking = f.walking[i] === 1 || (inside > 0 && inside < 1) || Math.hypot(people.ox[i] - tx, people.oy[i] - ty) > 0.15; + people.walk[i] += ((walking ? 1 : 0) - people.walk[i]) * turn; + people.stride[i] += Math.min(moved, 3) / (0.55 * PERSON); + // Partners take turns: one speaks (gestures, and a bubble pops up), the other listens (nods). + let speaking = 0; + if (partner >= 0) { const seed = Math.min(i, partner), lead = Math.sin(now * 0.7 + seed * 1.7) > 0; speaking = lead === i < partner ? 1 : 0; } + people.talk[i] += ((partner >= 0 ? (speaking ? 1 : 0.4) : 0) - people.talk[i]) * turn; + people.speak[i] += (speaking - people.speak[i]) * turn; + anim.setXYZ(i, people.stride[i], people.walk[i], people.talk[i]); + + const bob = Math.abs(Math.sin(people.stride[i] * Math.PI)) * 0.06 * PERSON * people.walk[i], size = PERSON * far * (1 - smooth((inside - 0.55) / 0.45)); + q.setFromAxisAngle(this.up, people.heading[i]); + if (people.walk[i] > 0.01) q.multiply(this.leanBy(people.walk[i])); + m.compose(at.set(x, y, LOT + bob), q, s.setScalar(size)); people.bodies.setMatrixAt(i, m); - m.compose(at.set(x + (walking ? Math.cos(f.heading[i]) * 0.12 * PERSON : 0), y + (walking ? Math.sin(f.heading[i]) * 0.12 * PERSON : 0), 0.26 + 1.38 * PERSON + bob), q, s); - people.heads.setMatrixAt(i, m); + people.blobs.setMatrixAt(i, m.compose(at.set(x, y, LOT + 0.06), this.blobTurn, this.blobScale.set(glow * size, glow * size, 1))); + const pop = people.speak[i] * (1 + 0.08 * Math.sin(now * 9 + i)); + people.bubbles.setMatrixAt(i, m.compose(at.set(x + Math.cos(people.heading[i] + 1.2) * 0.9, y + Math.sin(people.heading[i] + 1.2) * 0.9, LOT + 1.63 * size + 1.1), this.blobTurn, this.bubbleScale.set(1.25 * pop, 1.25 * pop, 0.9 * pop))); const key = f.action[i] ?? "idle"; - if (people.shown[i] !== key) { people.shown[i] = key; people.bodies.setColorAt(i, people.colors[key]); people.heads.setColorAt(i, people.colors[key]); recolored = true; } + if (people.shown[i] !== key) { people.shown[i] = key; people.bodies.setColorAt(i, people.colors[key]); people.blobs.setColorAt(i, people.colors[key]); recolored = true; } + } + anim.needsUpdate = true; + people.bodies.instanceMatrix.needsUpdate = true; people.blobs.instanceMatrix.needsUpdate = true; people.bubbles.instanceMatrix.needsUpdate = true; + if (recolored) for (const mesh of [people.bodies, people.blobs]) (mesh.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; + } + + /** A forward lean that grows with the walk. */ + private leanBy(walk: number): THREE.Quaternion { return this.leanNow.setFromAxisAngle(this.leanAxis, 0.12 * walk); } + + /** + * People in the same park paired off two by two, nearest first; anyone left over stands alone. A pair holds until + * one of the two leaves, so nobody hops from conversation to conversation. + */ + private pairUp(f: PeopleFrame): void { + const people = this.people; + if (!people) return; + const byPlace = new Map(), outdoors = (i: number) => f.at[i] >= 0 && !this.doorAt[f.at[i]] && !f.walking[i]; + for (let i = 0; i < f.count; i++) { + const j = people.partner[i]; + if (j >= 0 && (j >= f.count || !outdoors(i) || !outdoors(j) || f.at[j] !== f.at[i] || people.partner[j] !== i)) people.partner[i] = -1; + } + for (let i = 0; i < f.count; i++) { + if (!outdoors(i) || people.partner[i] >= 0) continue; + const list = byPlace.get(f.at[i]); + if (list) list.push(i); else byPlace.set(f.at[i], [i]); + } + for (const list of byPlace.values()) { + for (const i of list) { + if (people.partner[i] >= 0) continue; + let best = -1, bestD = 14 * 14; + for (const j of list) { + if (j === i || people.partner[j] >= 0) continue; + const d = (f.x[j] - f.x[i]) ** 2 + (f.y[j] - f.y[i]) ** 2; + if (d < bestD) { bestD = d; best = j; } + } + if (best >= 0) { people.partner[i] = best; people.partner[best] = i; } + } } - people.bodies.instanceMatrix.needsUpdate = true; people.heads.instanceMatrix.needsUpdate = true; - if (recolored) { (people.bodies.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; (people.heads.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; } + } + + /** + * One figure, feet at z = 0, facing +x, 1.63 tall before scaling. Each vertex knows its part (for its colour) and its + * joint: what swings it (0 nothing, 1 leg, 2 arm, 3 head), which side it is on, and the height of the pivot. + */ + private makeFigure(): THREE.BufferGeometry { + const T = this.T, positions: number[] = [], normals: number[] = [], parts: number[] = [], joints: number[] = []; + const add = (g: THREE.BufferGeometry, x: number, y: number, z: number, part: number, joint: number, side: number, pivot: number, scale?: [number, number, number]) => { + const flat = g.index ? g.toNonIndexed() : g; + if (scale) flat.scale(...scale); + flat.translate(x, y, z); + const p = flat.getAttribute("position"), n = flat.getAttribute("normal"); + for (let v = 0; v < p.count; v++) { positions.push(p.getX(v), p.getY(v), p.getZ(v)); normals.push(n.getX(v), n.getY(v), n.getZ(v)); parts.push(part); joints.push(joint, side, pivot); } + flat.dispose(); if (flat !== g) g.dispose(); + }; + const upright = (g: G) => g.rotateX(Math.PI / 2); + for (const side of [-1, 1]) { + add(upright(new T.CapsuleGeometry(0.068, 0.62, 3, 8)), 0, side * 0.085, 0.44, 2, 1, side, 0.8); + add(new T.BoxGeometry(0.2, 0.1, 0.08), 0.035, side * 0.085, 0.04, 4, 1, side, 0.8); + add(upright(new T.CapsuleGeometry(0.05, 0.42, 3, 6)), 0, side * 0.215, 0.94, 0, 2, side, 1.18); + add(new T.SphereGeometry(0.056, 8, 6), 0, side * 0.22, 0.66, 1, 2, side, 1.18); + } + add(upright(new T.CylinderGeometry(0.15, 0.14, 0.16, 12)), 0, 0, 0.8, 2, 0, 0, 0); + add(upright(new T.CapsuleGeometry(0.15, 0.3, 4, 12)), 0, 0, 1.0, 0, 0, 0, 0, [0.78, 1.15, 1]); + add(upright(new T.CylinderGeometry(0.05, 0.055, 0.1, 8)), 0, 0, 1.32, 1, 0, 0, 0); + add(new T.SphereGeometry(0.125, 16, 12), 0, 0, 1.47, 1, 3, 0, 1.33); + add(upright(new T.SphereGeometry(0.136, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.55)), -0.015, 0, 1.49, 3, 3, 0, 1.33); + // The nose: which way the head faces, from close up. + add(new T.SphereGeometry(0.025, 6, 4), 0.125, 0, 1.46, 1, 3, 0, 1.33); + const g = new T.BufferGeometry(); + g.setAttribute("position", new T.Float32BufferAttribute(positions, 3)); + g.setAttribute("normal", new T.Float32BufferAttribute(normals, 3)); + g.setAttribute("aPart", new T.Float32BufferAttribute(parts, 1)); + g.setAttribute("aJoint", new T.Float32BufferAttribute(joints, 3)); + return g; } // ── time of day ─────────────────────────────────────────────────────────── @@ -273,13 +713,29 @@ export class CityView { this.lightDir.set(Math.cos(theta) * side, -0.45 * side, Math.sin(theta) * side).normalize(); this.sun.color.copy(alt >= 0 ? a.setHex(C.sunLow).lerp(b.setHex(C.sunHigh), smooth(alt * 2)) : a.setHex(C.moon)); this.sun.intensity = alt >= 0 ? 2.6 * smooth(alt / 0.15) : 0.7 * smooth(-alt / 0.15); - this.hemi.intensity = 0.5 + 0.7 * day; - const dusk = Math.max(0, 1 - Math.abs(alt) / 0.3); - this.sky.setHex(C.night).lerp(a.setHex(C.day), day).lerp(b.setHex(C.dusk), dusk * 0.55); - this.fog.color.copy(this.sky); + this.hemi.intensity = 0.55 + 0.65 * day; + this.hemi.color.setHex(C.hemiNight).lerp(a.setHex(C.hemiDay), day); + + // The sky: night to day, the zenith deepening and a band on the sun's side of the horizon warming through dawn and dusk. + const dusk = Math.max(0, 1 - Math.abs(alt) / 0.3), { uZenith, uHorizon, uGround, uSunDir, uSunColor, uDuskAmount } = this.skyUniforms; + uHorizon.value.setHex(C.sky.nightHorizon).lerp(a.setHex(C.sky.dayHorizon), day); + uZenith.value.setHex(C.sky.nightZenith).lerp(a.setHex(C.sky.dayZenith), day).lerp(b.setHex(C.sky.duskZenith), dusk * 0.3); + uDuskAmount.value = dusk * 0.75; + uGround.value.copy(uHorizon.value).lerp(uZenith.value, 0.35).multiplyScalar(0.62); + this.fog.color.copy(uHorizon.value).lerp(uGround.value, 0.4); + uSunDir.value.set(Math.cos(theta), -0.45, Math.sin(theta)).normalize(); + uSunColor.value.setHex(C.sunLow).lerp(a.setHex(C.sunHigh), smooth(alt * 3)).multiplyScalar(smooth(alt / 0.1 + 1)); + this.sky.copy(uGround.value); + + // Unlit glass shows the sky: dark by night, by day the zenith low on the pane and the horizon high. + const { uGlassLow, uGlassHigh } = this.glassUniforms; + uGlassLow.value.setHex(C.glass.nightLow).lerp(a.setHex(C.glass.dayLow).lerp(uZenith.value, 0.3), day); + uGlassHigh.value.setHex(C.glass.nightHigh).lerp(a.copy(uHorizon.value).lerp(b.setHex(C.glass.dayLow), 0.5).multiplyScalar(0.85), day); + const lampsOn = smooth(-alt / 0.08 + 0.5); + this.figureUniforms.uGlow.value = 0.15 + 0.4 * (1 - day); this.lampHeads.color.setHex(C.lampOff).lerp(a.setHex(C.lampOn), lampsOn); - this.lampPools.opacity = 0.16 * lampsOn; + this.lampPools.opacity = 0.42 * lampsOn; this.updateWindows(t, hour); } @@ -294,7 +750,8 @@ export class CityView { const lit = this.windowHash[i] >= 0 && windowsLit(hour, this.windowHash[i]) ? 1 : 0; if (lit === this.windowLit[i]) continue; this.windowLit[i] = lit; changed = true; - this.windows.setColorAt(i, a.setHex(lit ? C.lit[i % C.lit.length] : C.glass)); + // Black is the shader's sign for an unlit pane. + this.windows.setColorAt(i, lit ? a.setHex(C.lit[i % C.lit.length]) : a.setRGB(0, 0, 0)); } if (changed && this.windows.instanceColor) this.windows.instanceColor.needsUpdate = true; } @@ -312,6 +769,7 @@ export class CityView { const rect = this.canvas.getBoundingClientRect(), v = this.personAt, half = this.city.size / 2; let best = -1, bestD = tolerance; for (let i = 0; i < f.count; i++) { + if (this.people && this.people.inside[i] > 0.9) continue; // indoors: nobody to click v.set(f.x[i] - half, f.y[i] - half, 2).project(this.camera); if (v.z > 1) continue; const d = Math.hypot(rect.left + ((v.x + 1) / 2) * rect.width - clientX, rect.top + ((1 - v.y) / 2) * rect.height - clientY); @@ -338,6 +796,8 @@ export class CityView { this.cameraAt.set(this.lookAt.x + Math.cos(this.orbit) * flat, this.lookAt.y + Math.sin(this.orbit) * flat, height); this.camera.position.lerp(this.cameraAt, ease); this.camera.lookAt(this.lookAt.x, this.lookAt.y, following ? 2 : 0); + this.dome.position.copy(this.camera.position); + this.viewDistance = this.camera.position.distanceTo(this.lookAt); this.eased = true; this.cutaway(following, dt); this.aimLight(this.lookAt.x, this.lookAt.y, Math.min(this.city.size * 0.75, Math.max(70, this.camera.position.distanceTo(this.lookAt) * 0.8))); @@ -346,16 +806,16 @@ export class CityView { } /** - * While following someone, any building standing between the camera and that person is lowered to a stub (and its - * windows put away), then raised again once it is out of the way: the person is never hidden behind a tower. - * A building is in the way if the sight line crosses its footprint below its roof. + * While following someone, any building standing between the camera and that person is lowered to a stub (its + * windows, roof and everything on it put away), then raised again once it is out of the way: the person is never + * hidden behind a tower. A building is in the way if the sight line crosses its footprint below its roof. */ private cutaway(following: boolean, dt: number): void { const mesh = this.boxMesh; if (!mesh) return; const half = this.city.size / 2, cx = this.camera.position.x + half, cy = this.camera.position.y + half, cz = this.camera.position.z; const tx = this.lookAt.x + half, ty = this.lookAt.y + half, tz = 3, ease = 1 - Math.exp(-dt * 8), m = this.personMatrix; - let moved = false, panesMoved = false; + let moved = false, panesMoved = false, roofsMoved = false; for (const b of this.city.buildings) { let target = 1; if (following && b.height > 3) { @@ -366,23 +826,27 @@ export class CityView { if (pp === 0) { if (q < 0) { hit = false; break; } } else { const t = q / pp; if (pp < 0) t0 = Math.max(t0, t); else t1 = Math.min(t1, t); } } // The sight line descends towards the person, so it is lowest where it leaves the footprint. - if (hit && t0 < t1 && cz + (tz - cz) * t1 < 0.26 + b.height) target = Math.min(1, 1.6 / b.height); + if (hit && t0 < t1 && cz + (tz - cz) * t1 < LOT + b.height) target = Math.min(1, 1.6 / b.height); } const now = this.stand[b.id]; if (Math.abs(now - target) < 0.002) continue; - const next = Math.abs(now - target) < 0.01 ? target : now + (target - now) * ease, box = this.boxes[this.boxOf[b.id]]; + const next = Math.abs(now - target) < 0.01 ? target : now + (target - now) * ease, box = this.boxes[this.bodyOf[b.id]]; this.stand[b.id] = next; moved = true; - mesh.setMatrixAt(this.boxOf[b.id], m.makeScale(box.sx, box.sy, box.sz * next).setPosition(box.x, box.y, 0.26 + (box.sz * next) / 2)); - if (this.awningOf[b.id] >= 0) { const a = this.boxes[this.awningOf[b.id]], s = next < 0.999 ? 0 : 1; mesh.setMatrixAt(this.awningOf[b.id], m.makeScale(a.sx * s, a.sy * s, a.sz * s).setPosition(a.x, a.y, a.z)); } - // Windows go the moment a building starts to sink and come back when it stands again. + mesh.setMatrixAt(this.bodyOf[b.id], m.makeScale(box.sx, box.sy, box.sz * next).setPosition(box.x, box.y, LOT + (box.sz * next) / 2)); + // Everything else that belongs to the building goes the moment it starts to sink and comes back when it stands again. const wasWhole = now >= 0.999, isWhole = next >= 0.999; - if (this.windows && wasWhole !== isWhole) { + if (wasWhole === isWhole) continue; + for (const k of this.partsOf[b.id]) { const p = this.boxes[k]; mesh.setMatrixAt(k, isWhole ? m.makeScale(p.sx, p.sy, p.sz).setPosition(p.x, p.y, p.z) : m.makeScale(0, 0, 0)); } + const roof = this.roofOf[b.id]; + if (roof >= 0 && this.roofMesh) { this.roofMesh.setMatrixAt(roof, isWhole ? this.roofMatrices[roof] : m.makeScale(0, 0, 0)); roofsMoved = true; } + if (this.windows) { const [from, to] = this.panesOf[b.id]; for (let k = from; k < to; k++) this.windows.setMatrixAt(k, isWhole ? this.paneMatrices[k] : m.makeScale(0, 0, 0)); panesMoved = true; } } if (moved) mesh.instanceMatrix.needsUpdate = true; + if (roofsMoved && this.roofMesh) this.roofMesh.instanceMatrix.needsUpdate = true; if (panesMoved && this.windows) this.windows.instanceMatrix.needsUpdate = true; } @@ -400,6 +864,10 @@ export class CityView { dispose(): void { for (const d of this.disposables) d.dispose(); + if (this.people) { + for (const mesh of [this.people.bodies, this.people.blobs, this.people.bubbles]) { mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); mesh.dispose(); } + (this.people.blobs.material as THREE.MeshBasicMaterial).map?.dispose(); + } this.renderer.dispose(); } } diff --git a/content/posts/city-of-agents/components/session.ts b/content/posts/city-of-agents/components/session.ts index 335df26..3a63902 100644 --- a/content/posts/city-of-agents/components/session.ts +++ b/content/posts/city-of-agents/components/session.ts @@ -57,7 +57,7 @@ export class CitySession { this.ctx = { infos, params: PARAMS }; this.log = { base: initialSnapshot(infos, this.world.agents.map((a) => a.needs), this.world.t, mode, duty), events: [], dropped: 0 }; this.world.onEvent = (e) => record(this.log, e, this.ctx); - this.frame = { count, x: new Float64Array(count), y: new Float64Array(count), px: Float64Array.from(this.world.x), py: Float64Array.from(this.world.y), heading: new Float64Array(count), action: new Array(count).fill(null), walking: new Uint8Array(count) }; + this.frame = { count, x: new Float64Array(count), y: new Float64Array(count), px: Float64Array.from(this.world.x), py: Float64Array.from(this.world.y), heading: new Float64Array(count), action: new Array(count).fill(null), walking: new Uint8Array(count), at: new Int32Array(count).fill(-1) }; this.replayT = null; this.pending = 0; this.dirty = true; this.onRebuild?.(); } @@ -93,7 +93,7 @@ export class CitySession { private fillLive(): void { const { world, frame } = this; frame.x.set(world.x); frame.y.set(world.y); frame.heading.set(world.heading); - for (const a of world.agents) { frame.action[a.id] = a.action; frame.walking[a.id] = a.state === "traveling" ? 1 : 0; } + for (const a of world.agents) { frame.action[a.id] = a.action; frame.walking[a.id] = a.state === "traveling" ? 1 : 0; frame.at[a.id] = a.state === "acting" ? a.place : -1; } this.shownReplay = NaN; } @@ -101,7 +101,7 @@ export class CitySession { const { frame, log, ctx } = this, cursor = cursorAt(log, t), snapshot = snapshotAt(log, cursor, ctx); replayPositions(this.city, this.world.paths, snapshot, ctx, arrivalsAfter(log, cursor, snapshot), t, frame.x, frame.y, frame.heading); frame.px.set(frame.x); frame.py.set(frame.y); - snapshot.agents.forEach((a, i) => { frame.action[i] = a.action; frame.walking[i] = a.state === "traveling" ? 1 : 0; }); + snapshot.agents.forEach((a, i) => { frame.action[i] = a.action; frame.walking[i] = a.state === "traveling" ? 1 : 0; frame.at[i] = a.state === "acting" ? a.place : -1; }); this.shownReplay = t; } diff --git a/content/posts/city-of-agents/components/use-city.ts b/content/posts/city-of-agents/components/use-city.ts index c477a7a..ea9af50 100644 --- a/content/posts/city-of-agents/components/use-city.ts +++ b/content/posts/city-of-agents/components/use-city.ts @@ -29,6 +29,8 @@ export function useCity(root: RefObject, canvas: RefObject { raf = requestAnimationFrame(loop); diff --git a/content/posts/city-of-agents/en.mdx b/content/posts/city-of-agents/en.mdx index f80c2bf..d154663 100644 --- a/content/posts/city-of-agents/en.mdx +++ b/content/posts/city-of-agents/en.mdx @@ -20,7 +20,7 @@ Look first. One second in the city is ten minutes outside, so a day takes two an -Around eight or nine in the morning the cyan dots gather from the residential blocks towards the towers in the centre; in the evening they spread out again; at night only a few people are left on the streets and the windows go dark one building at a time. +Around eight or nine in the morning the cyan dots gather from the residential blocks towards the towers in the centre and go in at the doors; in the evening they come back out and spread again; at night only a few people are left on the streets and the windows go dark one building at a time. There is no timetable in this city. You will not find a line of code that says "work starts at eight". Each person knows four numbers of their own and one formula for scoring things. @@ -40,7 +40,7 @@ Seed 1 grows 373 buildings: 248 houses, 29 office blocks and 96 shopfronts, the People walk on pavements and zebra crossings only. Each block is ringed by a pavement, joined to its neighbours at the corners, and every building has a door on it. Those points and lines are a graph of 639 nodes and 863 edges; going somewhere is a shortest path on it, found with A\* and remembered for the next person. -Everything on screen takes 10 draw calls, shadow pass included: every box — ground, buildings, crossing stripes, benches, lamp posts — is one InstancedMesh; tree crowns, windows, lamps, people's bodies and people's heads are one each. The sun's height is sin((hour − 6) / 24 · 2π), and light, sky, fog, lamps and windows are all functions of that value: the scene itself does not remember whether it is day or night. +Everything on screen takes 16 draw calls, shadow pass included: every box — plinth, ground, buildings, lane markings, crossing stripes, benches, lamp posts — is one InstancedMesh; roofs, tree crowns, windows, lamps, the lamplight on the pavement, the soft shadows under buildings and trees, the river, the sky, and the people (each a single model whose arms and legs swing in the shader), the glow at their feet and their speech bubbles are one each. Walls darken towards the ground in the shader, by height, with no extra pass. The sun's height is sin((hour − 6) / 24 · 2π), and light, sky, fog, lamps, lit windows and the sky reflected in the glass are all functions of that value: the scene itself does not remember whether it is day or night. ## Four needs @@ -131,7 +131,7 @@ The "frame time" under the scene is measured on your machine as you watch: the m **This does not simulate people.** I chose the four needs and tuned their rates until it looked like a day. The backlog starts to build at {PARAMS.dutyHours[0]} in the morning so that it passes its threshold around half past eight; that is not how an office worker works. The model can claim one thing only: without a timetable, a few numbers that rise and fall and one scoring formula are enough for a crowd to find a rhythm. -**The walking is drawn.** A position moves along its route, steps aside a little and is kept within the pavement; the bobbing and the lean are only drawing. No cars, no traffic lights, no interiors: people doing something stand at the door, twice life size, or from the air they would be two pixels. +**The walking and the talking are drawn.** A position moves along its route, steps aside a little and is kept within the pavement; the swinging arms and legs and the bobbing are only drawing. No cars, no traffic lights, no interiors: in the simulation people at work, at a meal or asleep stand at the door, and only on screen do they walk in and come back out. People in a park each stand on their own spot in the simulation; on screen each pairs up with the nearest other and the two take turns talking. They are drawn twice life size, and bigger again as the camera pulls back, or from the air they would be two pixels. **What a game's version adds.** Real utility AI gives each option several considerations, passes each through its own curve (not necessarily a square) and multiplies them; objects advertise what they can satisfy; and there are personalities, memory, queues and reservations. What is left here is the smallest skeleton. diff --git a/content/posts/city-of-agents/zh.mdx b/content/posts/city-of-agents/zh.mdx index 7adb073..ddb2c94 100644 --- a/content/posts/city-of-agents/zh.mdx +++ b/content/posts/city-of-agents/zh.mdx @@ -18,7 +18,7 @@ import { PARAMS } from "./components/sim/params"; -早上八九點,青色的點從住宅區往市中心的高樓聚過去;傍晚散開;半夜街上只剩零星幾個人,窗戶一棟一棟暗下來。 +早上八九點,青色的點從住宅區往市中心的高樓聚過去,一個個走進門;傍晚再從門口出來、散開;半夜街上只剩零星幾個人,窗戶一棟一棟暗下來。 這座城市裡沒有時刻表。程式裡找不到「八點上班」這一行。每個小人只知道自己的四個數字,和一條算分數的式子。 @@ -38,7 +38,7 @@ import { PARAMS } from "./components/sim/params"; 小人只走人行道和斑馬線。每個街區外圍一圈人行道,轉角和隔壁街區用斑馬線接起來,每棟建築在人行道上有一個門口。這些點和線就是一張圖:639 個節點、863 條邊。要去哪裡,就在這張圖上用 A\* 找最短路;同一對門口的路線會被記下來重複使用。 -畫面上的一切只用了 10 次繪圖呼叫(含陰影):所有的方塊,包括地面、建築、斑馬線、長椅、路燈桿,是同一個 InstancedMesh;樹冠、窗戶、路燈、小人的身體和頭各一個。太陽的高度是 sin((時 − 6) / 24 · 2π),光線、天色、霧、路燈和窗戶的燈全部是這個值的函數,場景本身不記得現在是白天還是晚上。 +畫面上的一切只用了 16 次繪圖呼叫(含陰影):所有的方塊,包括底座、地面、建築、車道線、斑馬線、長椅、路燈桿,是同一個 InstancedMesh;屋頂、樹冠、窗戶、路燈、路燈照在地上的光、建築和樹底下的軟影子、河、天空、小人(整個身體是一個模型,手腳在著色器裡擺動)、腳下的光圈和說話的泡泡各一個。牆腳越低越暗,是著色器照高度算的,不用多畫一趟。太陽的高度是 sin((時 − 6) / 24 · 2π),光線、天色、霧、路燈、窗戶的燈和玻璃映出的天空全部是這個值的函數,場景本身不記得現在是白天還是晚上。 ## 四條需求 @@ -129,7 +129,7 @@ import { PARAMS } from "./components/sim/params"; **這不是在模擬人。** 四條需求是我選的,速度是我調到「看起來像一天」為止的。待辦事項從早上 {PARAMS.dutyHours[0]} 點就開始累積,是為了讓它在八點半左右過門檻;真的上班族不是這樣運作的。這個模型能說的只有一件事:不用時刻表,只靠幾條會漲會退的數字和一條打分數的式子,群體的節奏就會自己出現。 -**走路是畫出來的。** 小人的位置沿著路線前進,加上一點互相閃避,會被夾在人行道的範圍裡;彈跳和前傾只是繪圖。沒有車、沒有紅綠燈、沒有室內,做事的人就站在門口。小人畫成真人的兩倍大,不然從空中看只有兩個像素。 +**走路和聊天是畫出來的。** 小人的位置沿著路線前進,加上一點互相閃避,會被夾在人行道的範圍裡;擺手擺腳和彈跳只是繪圖。沒有車、沒有紅綠燈、沒有室內:模擬裡,上班、吃飯、睡覺的人就站在門口,畫面上才讓他走進門、做完再走出來。公園裡的人在模擬裡各站各的,畫面上會和最近的一個人湊成一對,面對面輪流說話。小人畫成真人的兩倍大,鏡頭拉遠時再放大一些,不然從空中看只有兩個像素。 **遊戲裡的版本多了什麼。** 真的 Utility AI 每個選項會有好幾個考量,各自過一條曲線(不一定是平方)再相乘;物件會自己「廣告」它能滿足什麼;還有個性、記憶、排隊、預約。這裡留下的是最小的骨架。 diff --git a/lib/three.ts b/lib/three.ts index 9197cfe..136b536 100644 --- a/lib/three.ts +++ b/lib/three.ts @@ -10,6 +10,7 @@ export { BoxGeometry, BufferAttribute, BufferGeometry, + CapsuleGeometry, CanvasTexture, CircleGeometry, Color, @@ -17,12 +18,14 @@ export { CylinderGeometry, DirectionalLight, DoubleSide, + Float32BufferAttribute, Fog, FogExp2, GridHelper, Group, HemisphereLight, IcosahedronGeometry, + InstancedBufferAttribute, InstancedMesh, Line, LineBasicMaterial, @@ -33,6 +36,7 @@ export { Mesh, MeshBasicMaterial, MeshStandardMaterial, + NeutralToneMapping, Object3D, PCFShadowMap, PerspectiveCamera, @@ -45,7 +49,9 @@ export { RingGeometry, RepeatWrapping, Scene, + ShaderMaterial, SphereGeometry, + SRGBColorSpace, Vector2, Vector3, WebGLRenderer, From cf440cd1b40772a650b99502c74ca18cd1ff586f Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 15:37:33 +0800 Subject: [PATCH 4/7] feat(city): switch the body clock off in fig. 04, and count the day under fig. 01 Fig. 04 gets a body-clock switch. Off, the needs head is run once more with fatigue growing as fast by day as by night (the timetable and the dice never read the needs, so they are not rerun), and each head now shows who is at home at 01:00 and at 12:00: 73 % / 3 % with the clock, 38 % / 16 % without, seed 1, 300 people, day two, as the reader's browser works it out. Fig. 01 gets a strip under the picture: what share of people is doing what in each ten minutes of today, yesterday faint where today has not got to yet, a line at now. People go indoors now, so the scene alone no longer shows it. Both charts are one component (activity-chart.tsx) drawing one path per activity, and count people with the same activityOf in sim/metrics.ts. The prose says where the switch is and quotes the two pairs of numbers; the smoke test unticks the clock and waits for the strip. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/activity-chart.tsx | 40 +++++++++ .../city-of-agents/components/city-lab.tsx | 17 ++++ .../posts/city-of-agents/components/labels.ts | 4 + .../city-of-agents/components/modes-lab.tsx | 82 +++++++++++-------- .../city-of-agents/components/session.ts | 20 ++++- .../city-of-agents/components/sim/metrics.ts | 13 +++ content/posts/city-of-agents/en.mdx | 8 +- content/posts/city-of-agents/zh.mdx | 8 +- e2e/smoke.spec.ts | 9 ++ 9 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 content/posts/city-of-agents/components/activity-chart.tsx diff --git a/content/posts/city-of-agents/components/activity-chart.tsx b/content/posts/city-of-agents/components/activity-chart.tsx new file mode 100644 index 0000000..6f1e247 --- /dev/null +++ b/content/posts/city-of-agents/components/activity-chart.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ACTIVITIES } from "./sim"; +import { FILL } from "./use-city"; + +/** Columns per day: one every ten minutes. */ +export const SLOTS = 144; +const H = 60; +/** Walking and doing nothing are drawn lighter, so the four activities carry the picture. */ +const WEIGHT: Record<(typeof ACTIVITIES)[number], number> = { sleep: 1, work: 1, eat: 1, social: 1, walking: 0.55, idle: 0.25 }; + +export type ActivityLayer = { columns: readonly (ArrayLike | null)[]; opacity?: number }; + +/** + * A day as stacked columns, one every ten minutes: what share of people were doing what, sleep at the bottom. One path + * per activity and layer, not a rectangle per cell, so a live chart redrawn a few times a second stays cheap. Later + * layers are drawn over earlier ones; `now` marks a slot with a line. + */ +export function ActivityColumns({ layers, now, label, className = "h-28" }: { layers: ActivityLayer[]; now?: number; label: string; className?: string }) { + return ( + + {layers.map((layer, l) => ACTIVITIES.map((kind, k) => { + let d = ""; + layer.columns.forEach((column, x) => { + if (!column) return; + let y = H; + for (let j = 0; j < k; j++) y -= column[j] * H; + const h = column[k] * H; + if (h > 0.05) d += `M${x} ${(y - h).toFixed(2)}h1.02v${h.toFixed(2)}h-1.02z`; + }); + return d ? : null; + }))} + {now !== undefined && } + + ); +} + +export function HourTicks() { + return
0006121824
; +} diff --git a/content/posts/city-of-agents/components/city-lab.tsx b/content/posts/city-of-agents/components/city-lab.tsx index b1b8bfb..600dd09 100644 --- a/content/posts/city-of-agents/components/city-lab.tsx +++ b/content/posts/city-of-agents/components/city-lab.tsx @@ -4,6 +4,7 @@ import { Pause, Play } from "lucide-react"; import { useRef, useState } from "react"; import { Readout } from "@/components/lab/readout"; import { Button } from "@/components/ui/button"; +import { ActivityColumns, HourTicks } from "./activity-chart"; import { personTask } from "./describe"; import { personName, useLabels } from "./labels"; import type { PanelState } from "./session"; @@ -42,6 +43,21 @@ export function FollowBadge({ panel }: { panel: PanelState | null }) { ); } +/** + * The day so far under the picture: people go indoors, so the scene alone no longer shows how many are at work or + * asleep. Yesterday shows, faint, only where today has not got to yet. + */ +export function DayStrip({ panel }: { panel: PanelState | null }) { + const t = useLabels(); + if (!panel) return null; + return ( +
+ (panel.today[k] ? null : c)), opacity: 0.3 }, { columns: panel.today }]} now={(panel.t % 1440) / 10} label={t.today} className="h-12" /> + +
+ ); +} + /** * The city and the people in it, with as few controls as the point needs: play, speed, follow someone. With `modes`, * also who decides and whether duty exists — the same city and the same legs under a different head. @@ -61,6 +77,7 @@ export function CityLab({ seed = 1, n = 8, agents = 300, modes = false }: { seed {!ready &&

{t.loading}

} + {modes && (
diff --git a/content/posts/city-of-agents/components/labels.ts b/content/posts/city-of-agents/components/labels.ts index ba43b91..ece4899 100644 --- a/content/posts/city-of-agents/components/labels.ts +++ b/content/posts/city-of-agents/components/labels.ts @@ -36,6 +36,8 @@ const zh = { anotherPerson: "換一個人", threshold: "門檻", day: "第", dayUnit: "天", walking: "走路", optionA: "選項 A", optionB: "選項 B", need: "需求", walk: "要走幾分鐘", utilityOf: "效用", wins: "勝出", formula: "效用 = 需求² − 0.1 × 路程(小時)", computing: "計算中…", modesChart: "同樣 300 個人、同一座城市,第二天每 10 分鐘各有多少比例的人在做什麼", peakShort: "最擠的 10 分鐘", tripsPerDay: "每人每天出門", tripsUnit: "趟", + asleepAt: "01:00 / 12:00 在家", bodyClock: "生理時鐘(晚上比較容易累)", + today: "今天每 10 分鐘各有多少比例的人在做什麼;淡色是昨天,直線是現在", todayTitle: "這一天", names: ["阿凱", "小美", "志明", "春嬌", "阿土", "佩君", "冠宇", "怡君", "家豪", "雅婷", "承恩", "宜蓁", "柏翰", "欣怡", "俊傑", "淑芬", "建宏", "美玲", "宗翰", "詩涵", "信宏", "惠雯", "文傑", "佳穎", "明哲", "筱涵", "國華", "雅雯", "子軒", "思妤", "彥廷", "郁婷", "哲瑋", "靜宜", "育成", "婉婷", "志豪", "曉玲", "政憲", "珮瑜"], }; @@ -73,6 +75,8 @@ const en: typeof zh = { anotherPerson: "Another person", threshold: "threshold", day: "day", dayUnit: "", walking: "walking", optionA: "Option A", optionB: "Option B", need: "need", walk: "minutes on foot", utilityOf: "utility", wins: "wins", formula: "utility = need² − 0.1 × walk (hours)", computing: "computing…", modesChart: "The same 300 people in the same city: what share of them is doing what, in each ten minutes of day two", peakShort: "busiest ten minutes", tripsPerDay: "trips per person per day", tripsUnit: "", + asleepAt: "at home at 01:00 / 12:00", bodyClock: "body clock (tiring faster at night)", + today: "What share of people is doing what, in each ten minutes of today; yesterday is faint, the line is now", todayTitle: "the day so far", names: ["Kai", "Mei", "Ming", "Jiao", "Tu", "Pei", "Yu", "Yi", "Hao", "Ting", "En", "Zhen", "Han", "Xin", "Jie", "Fen", "Hong", "Ling", "Zong", "Shi", "Xinh", "Wen", "Wei", "Ying", "Zhe", "Xiao", "Hua", "Ya", "Xuan", "Si", "Yan", "Yuting", "Zhewei", "Jing", "Cheng", "Wan", "Zhihao", "Lin", "Xian", "Peiyu"], }; diff --git a/content/posts/city-of-agents/components/modes-lab.tsx b/content/posts/city-of-agents/components/modes-lab.tsx index d2a7df1..61b763b 100644 --- a/content/posts/city-of-agents/components/modes-lab.tsx +++ b/content/posts/city-of-agents/components/modes-lab.tsx @@ -1,72 +1,90 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { ActivityColumns, HourTicks } from "./activity-chart"; import { useLabels } from "./labels"; -import { departureHistogram, generateCity, type Mode, peakDepartureShare, type SimEvent, World } from "./sim"; -import { FILL, SWATCH } from "./use-city"; +import { ACTIVITIES, type Activity, activityShares, departureHistogram, generateCity, type Mode, PARAMS, peakDepartureShare, type SimEvent, World } from "./sim"; +import { SWATCH } from "./use-city"; import { useVisible } from "./use-visible"; const MODES: Mode[] = ["fsm", "utility", "random"], PEOPLE = 300, FROM = 1440, UNTIL = 2880, CHUNK = 180; -/** Bottom to top of each column. */ -const STACK = ["sleep", "work", "eat", "social", "walking", "idle"] as const; -type Kind = (typeof STACK)[number]; -type Result = { shares: number[][]; peak: number; trips: number }; +/** The columns of day two start at 00:10, one every ten minutes: these are 01:00 and 12:00. */ +const ONE_AM = 5, NOON = 71; +type Result = { shares: Float32Array[]; peak: number; trips: number }; +/** + * A run of one head for two days. The body clock only reaches the head that reads the needs: the timetable and the dice + * never look at fatigue, so their days are the same with it or without it and are not run twice. + */ +type Job = "fsm" | "utility" | "random" | "utility-noclock"; +const jobOf = (mode: Mode, clock: boolean): Job => (mode === "utility" && !clock ? "utility-noclock" : mode); /** * Fig. 04: the three heads side by side. Each is the same 300 people run for two days by the reader's browser, without * a scene, a few simulated hours per frame so the page never stalls. The chart is day two: what everyone was doing. + * Switching the body clock off runs the needs head once more, with fatigue growing at the same rate day and night. */ export function ModesLab() { - const t = useLabels(), root = useRef(null), visible = useVisible(root), [results, setResults] = useState<(Result | null)[]>([null, null, null]); + const t = useLabels(), root = useRef(null), visible = useVisible(root); + const [results, setResults] = useState>>({}), [clock, setClock] = useState(true); + const queue = useRef(["fsm", "utility", "random"]), kick = useRef<() => void>(() => {}); useEffect(() => { - let raf = 0, index = 0, world: World | null = null, events: SimEvent[] = [], shares: number[][] = []; + let raf = 0, job: Job | null = null, world: World | null = null, events: SimEvent[] = [], shares: Float32Array[] = []; const work = () => { - if (index >= MODES.length) return; + raf = 0; + if (!job && !queue.current.length) return; // nothing to do: the loop stops until a switch asks for more raf = requestAnimationFrame(work); if (!visible.current || !root.current || root.current.getBoundingClientRect().top > window.innerHeight) return; // nobody pays for this before they get here - if (!world) { world = new World(generateCity(1, 8), { agents: PEOPLE, mode: MODES[index] }); events = []; shares = []; world.onEvent = (e) => { if (e.type === "departed") events.push(e); }; } + if (!world) { + job = queue.current.shift() as Job; + const mode: Mode = job === "utility-noclock" ? "utility" : job; + world = new World(generateCity(1, 8), { agents: PEOPLE, mode, params: job === "utility-noclock" ? { ...PARAMS, circadian: 0 } : undefined }); + events = []; shares = []; + world.onEvent = (e) => { if (e.type === "departed") events.push(e); }; + } for (let k = 0; k < CHUNK && world.t < UNTIL; k++) { world.tick(); - if (world.t > FROM && world.t % 10 === 0) { - const counts = STACK.map(() => 0); - for (const a of world.agents) counts[STACK.indexOf(a.state === "traveling" ? "walking" : a.state === "acting" && a.action ? a.action : "idle")]++; - shares.push(counts.map((c) => c / PEOPLE)); - } + if (world.t > FROM && world.t % 10 === 0) shares.push(activityShares(world.agents)); } if (world.t < UNTIL) return; - const histogram = departureHistogram(events, FROM, UNTIL), at = index, done = shares; - setResults((all) => all.map((r, k) => (k === at ? { shares: done, peak: peakDepartureShare(histogram, PEOPLE), trips: histogram.reduce((s, v) => s + v, 0) / PEOPLE } : r))); - world = null; index++; + const histogram = departureHistogram(events, FROM, UNTIL), done: Result = { shares, peak: peakDepartureShare(histogram, PEOPLE), trips: histogram.reduce((s, v) => s + v, 0) / PEOPLE }, key = job as Job; + setResults((all) => ({ ...all, [key]: done })); + world = null; job = null; }; - raf = requestAnimationFrame(work); - return () => cancelAnimationFrame(raf); + kick.current = () => { if (!raf) raf = requestAnimationFrame(work); }; + kick.current(); + return () => { cancelAnimationFrame(raf); kick.current = () => {}; }; }, [visible]); - const name = (k: Kind) => (k === "walking" ? t.walking : t.actions[k]); + const toggleClock = (on: boolean) => { + setClock(on); + if (!on && !results["utility-noclock"] && !queue.current.includes("utility-noclock")) { queue.current.push("utility-noclock"); kick.current(); } + }; + const name = (k: Activity) => (k === "walking" ? t.walking : t.actions[k]), pct = (v: number) => `${Math.round(v * 100)}%`; return (
+
- {MODES.map((mode, i) => { - const r = results[i]; + {MODES.map((mode) => { + const r = results[jobOf(mode, clock)], sleep = ACTIVITIES.indexOf("sleep"); return (

{t.modes[mode]}

- - {r?.shares.map((column, x) => { - let y = 60; - return column.map((share, k) => { const h = share * 60; y -= h; return h > 0.05 ? : null; }); - })} - -
0006121824
-

{t.peak} · {t.peakShort}{r ? `${Math.round(r.peak * 100)}%` : t.computing}

+ {r ? + :
{t.computing}
} + +

{t.peak} · {t.peakShort}{r ? pct(r.peak) : t.computing}

{t.tripsPerDay}{r ? `${r.trips.toFixed(1)} ${t.tripsUnit}` : "—"}

+

{t.asleepAt}{r ? `${pct(r.shares[ONE_AM][sleep])} / ${pct(r.shares[NOON][sleep])}` : "—"}

); })}
    - {STACK.map((k) =>
  • {name(k)}
  • )} + {ACTIVITIES.map((k) =>
  • {name(k)}
  • )}
); diff --git a/content/posts/city-of-agents/components/session.ts b/content/posts/city-of-agents/components/session.ts index 3a63902..da2023a 100644 --- a/content/posts/city-of-agents/components/session.ts +++ b/content/posts/city-of-agents/components/session.ts @@ -1,7 +1,7 @@ import type { CityView, PeopleFrame } from "./city-view3d"; import { type Action, type AgentInfo, type AgentState, arrivalsAfter, type City, type Context, cursorAt, departureHistogram, generateCity, initialSnapshot, type Log, - modalShare, type Mode, type Needs, needsAt, PARAMS, peakDepartureShare, record, replayPositions, type SimEvent, snapshotAt, World, + activityShares, modalShare, type Mode, type Needs, needsAt, PARAMS, peakDepartureShare, record, replayPositions, type SimEvent, snapshotAt, World, } from "./sim"; export type Setup = { seed: number; n: number; agents: number }; @@ -13,6 +13,8 @@ export type PanelState = { histogram: number[]; peak: number; sync: number; frame: number; calls: number; /** Who the camera follows, or −1; never past the end of `people`. */ follow: number; + /** What share of people did what in each ten minutes of the live day and of the day before (null: not reached, or before the start). */ + today: (Float32Array | null)[]; yesterday: (Float32Array | null)[]; }; /** At most this many simulated minutes per frame: a slow frame drops simulated time instead of snowballing. */ @@ -41,6 +43,9 @@ export class CitySession { private frames = 0; private since = 0; private frameMs = 0; + /** One sample of what everyone is doing per ten simulated minutes, for today and yesterday; slot k is k × 10 minutes after midnight. */ + private today: (Float32Array | null)[] = []; + private yesterday: (Float32Array | null)[] = []; /** Set by whoever owns the canvas: a rebuilt city needs a rebuilt scene. */ onRebuild: (() => void) | null = null; @@ -59,6 +64,8 @@ export class CitySession { this.world.onEvent = (e) => record(this.log, e, this.ctx); this.frame = { count, x: new Float64Array(count), y: new Float64Array(count), px: Float64Array.from(this.world.x), py: Float64Array.from(this.world.y), heading: new Float64Array(count), action: new Array(count).fill(null), walking: new Uint8Array(count), at: new Int32Array(count).fill(-1) }; this.replayT = null; this.pending = 0; this.dirty = true; + this.today = new Array(144).fill(null); this.yesterday = new Array(144).fill(null); + this.sample(); this.onRebuild?.(); } @@ -74,7 +81,7 @@ export class CitySession { const started = performance.now(), { world, frame } = this, live = this.replayT === null; if (live && this.running) { this.pending += dt * PARAMS.minutesPerSecond * this.rate; - for (let k = 0; this.pending >= 1 && k < MAX_TICKS; k++, this.pending--) { frame.px.set(world.x); frame.py.set(world.y); world.tick(); } + for (let k = 0; this.pending >= 1 && k < MAX_TICKS; k++, this.pending--) { frame.px.set(world.x); frame.py.set(world.y); world.tick(); if (world.t % 10 === 0) this.sample(); } if (this.pending >= 1) this.pending = 0; this.dirty = true; } @@ -105,6 +112,13 @@ export class CitySession { this.shownReplay = t; } + /** Records what everyone is doing in the current ten-minute slot; midnight turns today into yesterday. */ + private sample(): void { + const slot = Math.floor((this.world.t % 1440) / 10); + if (slot === 0 && this.today.some((v) => v)) { this.yesterday = this.today; this.today = new Array(144).fill(null); } + this.today[slot] = activityShares(this.world.agents); + } + pick(clientX: number, clientY: number): number { return this.view ? this.view.pick(clientX, clientY, this.frame) : -1; } /** What the panel shows, live or at the replay time — the latter from the record alone. */ @@ -123,7 +137,7 @@ export class CitySession { for (let k = cursor - 1; k >= 0 && events.length < eventCount; k--) if (log.events[k].type !== "arrived") events.push(log.events[k]); const histogram = departureHistogram(log.events, t - 1440, t + 1e-9); return { city: this.city, follow: Math.min(this.follow, people.length - 1), t, from: log.base.t, now: world.t, replaying: !live, mode, duty, people, events, marks: log.events.filter((e) => e.type === "config").map((e) => e.t), histogram, - peak: peakDepartureShare(histogram, people.length), sync, frame: this.frameMs, calls: this.view?.calls ?? 0 }; + peak: peakDepartureShare(histogram, people.length), sync, frame: this.frameMs, calls: this.view?.calls ?? 0, today: this.today.slice(), yesterday: this.yesterday.slice() }; } dispose(): void { this.view?.dispose(); this.view = null; } diff --git a/content/posts/city-of-agents/components/sim/metrics.ts b/content/posts/city-of-agents/components/sim/metrics.ts index 71dcf7c..b696d5f 100644 --- a/content/posts/city-of-agents/components/sim/metrics.ts +++ b/content/posts/city-of-agents/components/sim/metrics.ts @@ -1,6 +1,19 @@ import type { Agent } from "./agent"; import type { SimEvent } from "./types"; +/** What a person counts as in the activity charts, bottom to top of each column. */ +export const ACTIVITIES = ["sleep", "work", "eat", "social", "walking", "idle"] as const; +export type Activity = (typeof ACTIVITIES)[number]; +export const activityOf = (a: Pick): Activity => (a.state === "traveling" ? "walking" : a.state === "acting" && a.action ? a.action : "idle"); + +/** Share of each activity among `agents`, in the order of `ACTIVITIES`. */ +export function activityShares(agents: Pick[]): Float32Array { + const shares = new Float32Array(ACTIVITIES.length); + for (const a of agents) shares[ACTIVITIES.indexOf(activityOf(a))]++; + if (agents.length) for (let k = 0; k < shares.length; k++) shares[k] /= agents.length; + return shares; +} + /** Share of people doing the most common thing right now (an action, walking, or nothing). Near 1 at night in every mode. */ export function modalShare(agents: Agent[]): number { const counts = new Map(); diff --git a/content/posts/city-of-agents/en.mdx b/content/posts/city-of-agents/en.mdx index d154663..57e3122 100644 --- a/content/posts/city-of-agents/en.mdx +++ b/content/posts/city-of-agents/en.mdx @@ -16,7 +16,7 @@ import { PARAMS } from "./components/sim/params"; Look first. One second in the city is ten minutes outside, so a day takes two and a half minutes; 20× is faster still. Click any person in the scene, or press "Follow someone", and the camera comes down to the street and walks with them. - + @@ -63,7 +63,7 @@ Here are three days of one of them, worked out by your browser just now. Hunger is the most regular: eight hours to reach 0.6, forty minutes to eat, three meals a day. **The backlog** is the one need that is not bodily: it is the work waiting on the desk. By day work keeps coming in, so it keeps rising, and only being at the office brings it down; after {PARAMS.dutyHours[1]}:00 nothing new arrives, so whoever has cleared it can leave. Switching it off means nobody in this city has a job to go to. -Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. I measured what happens without it: the sleep of 300 people spreads evenly over the day, with a fifth to almost half of them asleep at any hour, and the city has no night. With it, two thirds are asleep at one in the morning and 2% at noon. +Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. I measured what happens without it: the sleep of 300 people spreads evenly over the day, with a fifth to almost half of them asleep at any hour, and the city has no night. With it, two thirds are asleep at one in the morning and 2% at noon. Figure 04 below has the switch, so you can turn it off yourself. My first version of this table did not fit into a day. Add up the time the four needs each demand and you get 28.8 hours out of every 24, before any walking. Run, it gave a city where nobody had a free minute, every need sat at 1.0 for more than a quarter of the time, and the median start of work was two in the afternoon. The numbers above were changed against measurements; the comparison is in this project's `docs/research/city-of-agents/RESULTS.md`. @@ -95,7 +95,7 @@ To know whether the daily rhythm really grows out of the needs, swap the head an - **Each by their needs**: the formula from the last section. - **At random**: pick any place, stay half an hour to two hours. - + @@ -105,6 +105,8 @@ The random day is flat: three in the morning looks like three in the afternoon. The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 11% of people set off together, much like random's 9% — but random has no shape. +Half of that shape is the body clock's. Untick it and the middle chart's night smears out: people at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. + Switch the backlog off and measure again (offline: 300 people, seed 1, the last of five days): sleeping, eating and company barely change, and free time goes from 11% to 40%. People do not find themselves something to do, and that is the most honest thing about this model: **it only answers the needs you give it.** If you want them to shop or exercise, that is another number. ## Who is watching everyone diff --git a/content/posts/city-of-agents/zh.mdx b/content/posts/city-of-agents/zh.mdx index ddb2c94..2032e81 100644 --- a/content/posts/city-of-agents/zh.mdx +++ b/content/posts/city-of-agents/zh.mdx @@ -14,7 +14,7 @@ import { PARAMS } from "./components/sim/params"; 先看。城市裡的一秒是外面的十分鐘,所以兩分半鐘就是一天。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。 - + @@ -61,7 +61,7 @@ import { PARAMS } from "./components/sim/params"; 飢餓是最規律的一條:八小時長到 0.6,吃四十分鐘,一天三餐。**待辦事項**是四條裡唯一不屬於身體的,它是桌上還沒做完的工作:白天工作一直進來,它就一直漲,只有人在辦公室裡才消得掉;{PARAMS.dutyHours[1]} 點以後不再有新的進來,做完就能走。把它關掉,等於這座城市沒有人需要工作。 -疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項會怎樣,我量過:300 個人的睡覺時間會平均攤在一整天,任何時刻都有兩成到四成五的人在睡,這座城市就沒有夜晚。加上去之後,凌晨一點有三分之二的人在睡,中午只剩 2%。 +疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項會怎樣,我量過:300 個人的睡覺時間會平均攤在一整天,任何時刻都有兩成到四成五的人在睡,這座城市就沒有夜晚。加上去之後,凌晨一點有三分之二的人在睡,中午只剩 2%。後面的圖 04 有這個開關,可以自己關掉看。 我第一版的表格根本排不進一天。把四條需求各自要花的時間加起來,一天需要 28.8 小時,還沒算走路。跑出來的結果是:沒有人有任何一分鐘是閒著的,每條需求有四分之一以上的時間頂在 1.0,開始上班的時間中位數是下午兩點。現在的數字是照著量測結果改的,比較的過程留在這個專案的 `docs/research/city-of-agents/RESULTS.md`。 @@ -93,7 +93,7 @@ import { PARAMS } from "./components/sim/params"; - **各看各的需求**:上一節那條式子。 - **隨機**:隨便挑一個地方去,待半小時到兩小時。 - + @@ -103,6 +103,8 @@ import { PARAMS } from "./components/sim/params"; 中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 11% 的人同時出門,和隨機的 9% 差不多;但隨機沒有那個形狀。 +這個形狀有一半是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 + 把「待辦事項」整條關掉再量一次(離線量的:300 人、種子 1、跑五天取最後一天):睡覺、吃飯、社交的時間幾乎不變,空出來的時間從 11% 變成 40%。小人不會自己找事做,這是這個模型最誠實的地方:**它只會回應你給它的需求。** 想要他們買東西、運動,就得再加一條數字。 ## 誰在看著所有人 diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index b262855..443b12c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -577,6 +577,15 @@ test("a city of people runs in the page: the clock moves, the three heads differ await expect(page.getByTestId("modes-fsm")).toContainText("100%", { timeout: 60_000 }); await expect(page.getByTestId("modes-utility")).toContainText("%", { timeout: 60_000 }); await expect(page.getByTestId("modes-utility")).not.toContainText("100%"); + // Without the body clock the needs head is run again, and its night smears out. + const asleep = page.getByTestId("modes-utility-asleep"), withClock = await asleep.textContent(); + await page.getByTestId("modes-clock").uncheck(); + await expect(asleep).not.toHaveText(withClock ?? "", { timeout: 60_000 }); + await expect(asleep).not.toContainText("—"); + + // Fig. 01 counts the day under the picture, indoors or not. + await page.getByTestId("city-canvas").first().scrollIntoViewIfNeeded(); + await expect(page.getByTestId("city-day").locator("path").first()).toBeAttached({ timeout: 60_000 }); // The Overseer: the table fills, a row follows a person, the record replays and comes back. await page.getByTestId("city-play").scrollIntoViewIfNeeded(); From 919033205624bf5599196012cbb4362aa95c933c Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 18:03:54 +0800 Subject: [PATCH 5/7] fix(city): the numbers beside fig. 04 are the ones fig. 04 shows The busiest ten minutes read 11 % (needs) and 9 % (random) in the text, from the offline five-day runs, while the figure the reader is looking at works out day two and shows 12 % and 10 %. The body-clock paragraph quoted offline shares too (two thirds at 01:00, 2 % at noon) against the figure's 73 % / 3 %. The text now quotes the figure, once, after it; the needs section only says what happens and points to the switch. Two loose phrases went with it ("about as many asleep at noon as at midnight", "half of that shape"). The backlog-off measurement stays: it is labelled offline and no figure shows it. Co-Authored-By: Claude Opus 5.5 (1M context) --- content/posts/city-of-agents/en.mdx | 6 +++--- content/posts/city-of-agents/zh.mdx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/posts/city-of-agents/en.mdx b/content/posts/city-of-agents/en.mdx index 57e3122..5c55080 100644 --- a/content/posts/city-of-agents/en.mdx +++ b/content/posts/city-of-agents/en.mdx @@ -63,7 +63,7 @@ Here are three days of one of them, worked out by your browser just now. Hunger is the most regular: eight hours to reach 0.6, forty minutes to eat, three meals a day. **The backlog** is the one need that is not bodily: it is the work waiting on the desk. By day work keeps coming in, so it keeps rising, and only being at the office brings it down; after {PARAMS.dutyHours[1]}:00 nothing new arrives, so whoever has cleared it can leave. Switching it off means nobody in this city has a job to go to. -Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. I measured what happens without it: the sleep of 300 people spreads evenly over the day, with a fifth to almost half of them asleep at any hour, and the city has no night. With it, two thirds are asleep at one in the morning and 2% at noon. Figure 04 below has the switch, so you can turn it off yourself. +Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. Without it the sleep of 300 people spreads out over the whole day, midnight is no longer when most of them sleep, and the city's night smears out. Figure 04 below has the switch, so you can turn it off yourself. My first version of this table did not fit into a day. Add up the time the four needs each demand and you get 28.8 hours out of every 24, before any walking. Run, it gave a city where nobody had a free minute, every need sat at 1.0 for more than a quarter of the time, and the median start of work was two in the afternoon. The numbers above were changed against measurements; the comparison is in this project's `docs/research/city-of-agents/RESULTS.md`. @@ -103,9 +103,9 @@ The timetable's day is cut with a knife. At eight sharp, 100% of people leave wi The random day is flat: three in the morning looks like three in the afternoon. -The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 11% of people set off together, much like random's 9% — but random has no shape. +The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 12% of people set off together, much like random's 10% — but random has no shape. -Half of that shape is the body clock's. Untick it and the middle chart's night smears out: people at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. +Part of that shape is the body clock's. Untick it and the middle chart's night smears out: people at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. Switch the backlog off and measure again (offline: 300 people, seed 1, the last of five days): sleeping, eating and company barely change, and free time goes from 11% to 40%. People do not find themselves something to do, and that is the most honest thing about this model: **it only answers the needs you give it.** If you want them to shop or exercise, that is another number. diff --git a/content/posts/city-of-agents/zh.mdx b/content/posts/city-of-agents/zh.mdx index 2032e81..7fc2794 100644 --- a/content/posts/city-of-agents/zh.mdx +++ b/content/posts/city-of-agents/zh.mdx @@ -61,7 +61,7 @@ import { PARAMS } from "./components/sim/params"; 飢餓是最規律的一條:八小時長到 0.6,吃四十分鐘,一天三餐。**待辦事項**是四條裡唯一不屬於身體的,它是桌上還沒做完的工作:白天工作一直進來,它就一直漲,只有人在辦公室裡才消得掉;{PARAMS.dutyHours[1]} 點以後不再有新的進來,做完就能走。把它關掉,等於這座城市沒有人需要工作。 -疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項會怎樣,我量過:300 個人的睡覺時間會平均攤在一整天,任何時刻都有兩成到四成五的人在睡,這座城市就沒有夜晚。加上去之後,凌晨一點有三分之二的人在睡,中午只剩 2%。後面的圖 04 有這個開關,可以自己關掉看。 +疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項,300 個人的睡覺時間會攤開到一整天,半夜不再是大家都在睡的時候,這座城市的夜晚就糊掉了。後面的圖 04 有這個開關,可以自己關掉看。 我第一版的表格根本排不進一天。把四條需求各自要花的時間加起來,一天需要 28.8 小時,還沒算走路。跑出來的結果是:沒有人有任何一分鐘是閒著的,每條需求有四分之一以上的時間頂在 1.0,開始上班的時間中位數是下午兩點。現在的數字是照著量測結果改的,比較的過程留在這個專案的 `docs/research/city-of-agents/RESULTS.md`。 @@ -101,9 +101,9 @@ import { PARAMS } from "./components/sim/params"; 隨機的一天是平的,半夜三點和下午三點長得一樣。 -中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 11% 的人同時出門,和隨機的 9% 差不多;但隨機沒有那個形狀。 +中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 12% 的人同時出門,和隨機的 10% 差不多;但隨機沒有那個形狀。 -這個形狀有一半是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 +這個形狀有一部分是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 把「待辦事項」整條關掉再量一次(離線量的:300 人、種子 1、跑五天取最後一天):睡覺、吃飯、社交的時間幾乎不變,空出來的時間從 11% 變成 40%。小人不會自己找事做,這是這個模型最誠實的地方:**它只會回應你給它的需求。** 想要他們買東西、運動,就得再加一條數字。 From f83f014ea679746f6731a235098291f454f04b87 Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 18:09:16 +0800 Subject: [PATCH 6/7] fix(city): six things the article said that the code does not do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read against the code and the figures, in both languages: - The clock ran backwards: "one second in the city is ten minutes outside". It is one second outside, ten minutes in the city (minutesPerSecond: 10). - People do not think again on arriving: arrive() starts what was decided and restarts the 30-minute count. They think on finishing and every 30 minutes. - The random head does nothing one time in five (pickAtRandom); the list left that out, though fig. 04 shows it as the idle band. - Parks are drawn only in the outer ring; zh said "中間". - Fig. 04's new read-out counts sleep, not everyone at home: "在家睡覺" / "asleep at home", in the label and the paragraph that quotes it. - The sources paragraph still pointed at "我量過", a phrase the text no longer uses, and left out the strip under fig. 01, which is computed live too. Checked and left alone: 373 buildings (248 / 96 / 29, tallest 79.5 m), 639 nodes, 863 edges, two parks for seed 1; fig. 03's 0.573 / 0.482 and the 0.77 crossover; 8 h to 0.6 hunger and 40 min to eat; the 28.8-hour first draft and its 14:06 median start; 5000 events ≈ 10.5 h; 0.095 ms per tick. Co-Authored-By: Claude Fable 5.1 --- content/posts/city-of-agents/components/labels.ts | 4 ++-- content/posts/city-of-agents/en.mdx | 10 +++++----- content/posts/city-of-agents/zh.mdx | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/content/posts/city-of-agents/components/labels.ts b/content/posts/city-of-agents/components/labels.ts index ece4899..412cad5 100644 --- a/content/posts/city-of-agents/components/labels.ts +++ b/content/posts/city-of-agents/components/labels.ts @@ -36,7 +36,7 @@ const zh = { anotherPerson: "換一個人", threshold: "門檻", day: "第", dayUnit: "天", walking: "走路", optionA: "選項 A", optionB: "選項 B", need: "需求", walk: "要走幾分鐘", utilityOf: "效用", wins: "勝出", formula: "效用 = 需求² − 0.1 × 路程(小時)", computing: "計算中…", modesChart: "同樣 300 個人、同一座城市,第二天每 10 分鐘各有多少比例的人在做什麼", peakShort: "最擠的 10 分鐘", tripsPerDay: "每人每天出門", tripsUnit: "趟", - asleepAt: "01:00 / 12:00 在家", bodyClock: "生理時鐘(晚上比較容易累)", + asleepAt: "01:00 / 12:00 在家睡覺", bodyClock: "生理時鐘(晚上比較容易累)", today: "今天每 10 分鐘各有多少比例的人在做什麼;淡色是昨天,直線是現在", todayTitle: "這一天", names: ["阿凱", "小美", "志明", "春嬌", "阿土", "佩君", "冠宇", "怡君", "家豪", "雅婷", "承恩", "宜蓁", "柏翰", "欣怡", "俊傑", "淑芬", "建宏", "美玲", "宗翰", "詩涵", "信宏", "惠雯", "文傑", "佳穎", "明哲", "筱涵", "國華", "雅雯", "子軒", "思妤", "彥廷", "郁婷", "哲瑋", "靜宜", "育成", "婉婷", "志豪", "曉玲", "政憲", "珮瑜"], }; @@ -75,7 +75,7 @@ const en: typeof zh = { anotherPerson: "Another person", threshold: "threshold", day: "day", dayUnit: "", walking: "walking", optionA: "Option A", optionB: "Option B", need: "need", walk: "minutes on foot", utilityOf: "utility", wins: "wins", formula: "utility = need² − 0.1 × walk (hours)", computing: "computing…", modesChart: "The same 300 people in the same city: what share of them is doing what, in each ten minutes of day two", peakShort: "busiest ten minutes", tripsPerDay: "trips per person per day", tripsUnit: "", - asleepAt: "at home at 01:00 / 12:00", bodyClock: "body clock (tiring faster at night)", + asleepAt: "asleep at home at 01:00 / 12:00", bodyClock: "body clock (tiring faster at night)", today: "What share of people is doing what, in each ten minutes of today; yesterday is faint, the line is now", todayTitle: "the day so far", names: ["Kai", "Mei", "Ming", "Jiao", "Tu", "Pei", "Yu", "Yi", "Hao", "Ting", "En", "Zhen", "Han", "Xin", "Jie", "Fen", "Hong", "Ling", "Zong", "Shi", "Xinh", "Wen", "Wei", "Ying", "Zhe", "Xiao", "Hua", "Ya", "Xuan", "Si", "Yan", "Yuting", "Zhewei", "Jing", "Cheng", "Wan", "Zhihao", "Lin", "Xian", "Peiyu"], }; diff --git a/content/posts/city-of-agents/en.mdx b/content/posts/city-of-agents/en.mdx index 5c55080..3f42638 100644 --- a/content/posts/city-of-agents/en.mdx +++ b/content/posts/city-of-agents/en.mdx @@ -14,7 +14,7 @@ import { PARAMS } from "./components/sim/params"; [The previous entry](/en/posts/slam-2d) had one car. This one has three hundred people living in the same city, each going to work, eating, finding company and going home to sleep. -Look first. One second in the city is ten minutes outside, so a day takes two and a half minutes; 20× is faster still. Click any person in the scene, or press "Follow someone", and the camera comes down to the street and walks with them. +Look first. One second outside is ten minutes in the city, so a day takes two and a half minutes; 20× is faster still. Click any person in the scene, or press "Follow someone", and the camera comes down to the street and walks with them. @@ -85,7 +85,7 @@ The formula alone is not enough. Two things go wrong: **One bite and gone.** Someone goes to eat when hunger passes 0.6; half an hour later it is below 0.6, eating is no longer an option, so they leave — and are hungry again within minutes. The cure is two thresholds (start past {PARAMS.threshold[1]}, finish at {PARAMS.doneAt}) and {PARAMS.stayBonus} extra for whatever a person is already doing. -**Everyone deciding on the same minute.** People do not think again every frame. They think on arriving, on finishing, and every {PARAMS.reconsiderMinutes} minutes. Count those {PARAMS.reconsiderMinutes} minutes from midnight for everybody and three hundred people set off together, as if someone were conducting. So each person counts from a moment of their own. +**Everyone deciding on the same minute.** People do not think again every frame. They think on finishing, and every {PARAMS.reconsiderMinutes} minutes; arriving starts what was decided and restarts the count. Count those {PARAMS.reconsiderMinutes} minutes from midnight for everybody and three hundred people set off together, as if someone were conducting. So each person counts from a moment of their own. ## Three heads, the same legs @@ -93,7 +93,7 @@ To know whether the daily rhythm really grows out of the needs, swap the head an - **Fixed timetable**: everyone works at {PARAMS.fsm.work}, eats at {PARAMS.fsm.lunch}, goes home at {PARAMS.fsm.home}. - **Each by their needs**: the formula from the last section. -- **At random**: pick any place, stay half an hour to two hours. +- **At random**: pick any place, stay half an hour to two hours; one time in five, do nothing. @@ -105,7 +105,7 @@ The random day is flat: three in the morning looks like three in the afternoon. The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 12% of people set off together, much like random's 10% — but random has no shape. -Part of that shape is the body clock's. Untick it and the middle chart's night smears out: people at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. +Part of that shape is the body clock's. Untick it and the middle chart's night smears out: people asleep at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. Switch the backlog off and measure again (offline: 300 people, seed 1, the last of five days): sleeping, eating and company barely change, and free time goes from 11% to 40%. People do not find themselves something to do, and that is the most honest thing about this model: **it only answers the needs you give it.** If you want them to shop or exercise, that is another number. @@ -137,7 +137,7 @@ The "frame time" under the scene is measured on your machine as you watch: the m **What a game's version adds.** Real utility AI gives each option several considerations, passes each through its own curve (not necessarily a square) and multiplies them; objects advertise what they can satisfy; and there are personalities, memory, queues and reservations. What is left here is the smallest skeleton. -The numbers come from three places. Figures 02, 03 and 04 and the read-outs on the bench are computed by your browser as you read; the ones I say I measured were run offline, with seed, population and scripts in `docs/research/city-of-agents/`; the counts of buildings and nodes are what seed 1 grows at 8 × 8, and a test guarantees the same seed always grows the same city. +The numbers come from three places. The strip under figure 01, figures 02, 03 and 04 and the read-outs on the bench are computed by your browser as you read; the ones marked offline, and the first draft in the sidenote, come with their seed, population and scripts in `docs/research/city-of-agents/`; the counts of buildings and nodes are what seed 1 grows at 8 × 8, and a test guarantees the same seed always grows the same city. ## Sources diff --git a/content/posts/city-of-agents/zh.mdx b/content/posts/city-of-agents/zh.mdx index 7fc2794..a5fe53e 100644 --- a/content/posts/city-of-agents/zh.mdx +++ b/content/posts/city-of-agents/zh.mdx @@ -12,7 +12,7 @@ import { PARAMS } from "./components/sim/params"; [上一篇](/zh/posts/slam-2d)只有一台車。這一篇有三百個人,住在同一座城市裡,各自上班、吃飯、找人聊天、回家睡覺。 -先看。城市裡的一秒是外面的十分鐘,所以兩分半鐘就是一天。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。 +先看。外面的一秒是城市裡的十分鐘,所以兩分半鐘就是一天,按 20× 更快。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。 @@ -31,7 +31,7 @@ import { PARAMS } from "./components/sim/params"; 先畫 8 × 8 的街區,再依序決定: - **路**:每四條路有一條是比較寬的主幹道,最東邊一排是河岸。 -- **分區**:看街區離市中心多遠。近的是商業區,再外一圈是餐飲街和一些辦公樓,最外面是住宅區,中間隨機留幾塊公園(種子 1 是兩塊)。 +- **分區**:看街區離市中心多遠。近的是商業區,再外一圈是餐飲街和一些辦公樓,最外面是住宅區,其中隨機留幾塊公園(種子 1 是兩塊)。 - **高度**:「一個隨機數 × 離市中心有多近」,所以中間高、邊緣矮。 種子 1 長出來的是 373 棟建築:248 棟住家、29 棟辦公樓、96 間店面,最高的 80 公尺。 @@ -83,7 +83,7 @@ import { PARAMS } from "./components/sim/params"; **吃一口就走。** 飢餓過了 0.6 才去吃,吃了半小時降到 0.6 以下,「吃飯」就不再是選項,於是他離開,幾分鐘後又餓了。解法是兩個門檻:過了 {PARAMS.threshold[1]} 才開始,降到 {PARAMS.doneAt} 才算做完;正在做的事另外加 {PARAMS.stayBonus} 分,別的事要明顯更急才能打斷它。 -**所有人同一分鐘做決定。** 小人不是每一幀都重新想。他們只在三個時候想:到了目的地、事情做完、或者每 {PARAMS.reconsiderMinutes} 分鐘一次。如果這 {PARAMS.reconsiderMinutes} 分鐘大家都從零點起算,三百個人會在同一分鐘一起出門,像有人在指揮。所以每個人的起算點是錯開的。 +**所有人同一分鐘做決定。** 小人不是每一幀都重新想。他們只在兩個時候想:事情做完,或者每 {PARAMS.reconsiderMinutes} 分鐘一次;到了目的地就開始做決定好的事,{PARAMS.reconsiderMinutes} 分鐘重新起算。如果這 {PARAMS.reconsiderMinutes} 分鐘大家都從零點起算,三百個人會在同一分鐘一起出門,像有人在指揮。所以每個人的起算點是錯開的。 ## 三種腦袋,同一雙腳 @@ -91,7 +91,7 @@ import { PARAMS } from "./components/sim/params"; - **固定時程**:所有人 {PARAMS.fsm.work} 點上班、{PARAMS.fsm.lunch} 點吃飯、{PARAMS.fsm.home} 點回家。 - **各看各的需求**:上一節那條式子。 -- **隨機**:隨便挑一個地方去,待半小時到兩小時。 +- **隨機**:隨便挑一個地方去,待半小時到兩小時;五次裡有一次什麼都不做。 @@ -103,7 +103,7 @@ import { PARAMS } from "./components/sim/params"; 中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 12% 的人同時出門,和隨機的 10% 差不多;但隨機沒有那個形狀。 -這個形狀有一部分是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 +這個形狀有一部分是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家睡覺的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 把「待辦事項」整條關掉再量一次(離線量的:300 人、種子 1、跑五天取最後一天):睡覺、吃飯、社交的時間幾乎不變,空出來的時間從 11% 變成 40%。小人不會自己找事做,這是這個模型最誠實的地方:**它只會回應你給它的需求。** 想要他們買東西、運動,就得再加一條數字。 @@ -135,7 +135,7 @@ import { PARAMS } from "./components/sim/params"; **遊戲裡的版本多了什麼。** 真的 Utility AI 每個選項會有好幾個考量,各自過一條曲線(不一定是平方)再相乘;物件會自己「廣告」它能滿足什麼;還有個性、記憶、排隊、預約。這裡留下的是最小的骨架。 -數字的來源分三種。圖 02、03、04 和工作台上的讀數是你的瀏覽器現場算的;文中標明「我量過」的是離線跑的,種子、人數和腳本都在 `docs/research/city-of-agents/`;城市的棟數和節點數是種子 1、8 × 8 的生成結果,有測試保證同一個種子永遠一樣。 +數字的來源分三種。圖 01 底下的色帶、圖 02、03、04 和工作台上的讀數是你的瀏覽器現場算的;文中標明「離線量的」和旁註裡的第一版,種子、人數和腳本都在 `docs/research/city-of-agents/`;城市的棟數和節點數是種子 1、8 × 8 的生成結果,有測試保證同一個種子永遠一樣。 ## 來源 From 90caaccf9f1dcb940093d9aa623a8b9648126bd2 Mon Sep 17 00:00:00 2001 From: PSheon Date: Sun, 27 Sep 2026 18:33:32 +0800 Subject: [PATCH 7/7] test(e2e): keep a screenshot of the page when a test fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19's run of the E2E job failed once on "URLs outside both locales get the site's own 404": no h1 for five seconds, and the error context had no page snapshot at all. The same commit passed in the push run. Locally it passed 40 of 40 against a production build, and 12 of 12 with the CPU throttled 6×: the h1 is there on the first sample, the page never reloads (a marker set after load survives), and the second framenavigated is the router's replaceState. Nothing to fix without seeing the page, and a retry would hide the next real failure as well. The CI step already uploads test-results on failure; now it will hold a screenshot. Co-Authored-By: Claude Fable 5.1 --- playwright.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 45f1aeb..6f7fdab 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,8 @@ export default defineConfig({ testDir: "e2e", fullyParallel: true, reporter: "list", - use: { baseURL: `http://localhost:${PORT}`, locale: "zh-TW" }, + // A failure keeps a picture of the page: a CI-only failure (one run of two, never locally) can then be read, not guessed. + use: { baseURL: `http://localhost:${PORT}`, locale: "zh-TW", screenshot: "only-on-failure" }, projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"] } }, { name: "mobile", use: { ...devices["Pixel 7"] } },