Skip to content

Commit 1ffbbb3

Browse files
valuecodesclaude
andcommitted
feat: generate module areas lazily as the player approaches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 37ba611 commit 1ffbbb3

24 files changed

Lines changed: 1166 additions & 193 deletions

‎apps/web/AGENTS.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,19 @@ external, x.render(…) ambiguous: A.render() | B.render()`). Choosing
121121
every file can be walked to. `repo` has one, `→ src/index.ts`; a
122122
single-file example has its file. R returns to the entrance; the source
123123
panel shows nothing there.
124-
- Milestone 17 (current): class lobbies. The methods of a top-level class
124+
- Milestone 17: class lobbies. The methods of a top-level class
125125
that hang off the hub open off a lobby of their own instead (`class
126126
UserService`), one door of the module hub; methods reached by a call door
127127
keep it. The HUD reads `service.ts · class UserService` and the source
128128
panel shows the whole class.
129+
- Milestone 18 (current): lazy areas. A code world generates its entrance
130+
only; a module's area is generated when the player faces a portal into
131+
it (or steps into one first), placed after the areas so far, so nothing
132+
already there moves, and the player, the breadcrumb and the map carry on.
133+
A portal into a file not generated yet is dark until it is; one whose
134+
file could not be laid out stays dark and reads `closed · <file> could
135+
not be laid out`. The HUD reads `2/7 modules · last 14 ms` on the way;
136+
`?eager=1` generates every module up front, as before.
129137

130138
---
131139

‎apps/web/src/app.tsx‎

Lines changed: 52 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { Canvas } from "@react-three/fiber";
2-
import type { CodeGraph } from "@repo/code-graph";
32
import { PointerLook } from "@repo/renderer/controls";
43
import { Player } from "@repo/renderer/player";
54
import { World } from "@repo/renderer/world";
6-
import type { GeneratedWorld } from "@repo/types";
7-
import { generateWorld } from "@repo/world-generator";
8-
import { mergeAreas } from "@repo/world-generator/areas";
95
import type { Target } from "@repo/world-generator/interaction";
10-
import { presets } from "@repo/world-generator/presets";
116
import { useCallback, useEffect, useMemo, useState } from "react";
127

138
import { Hud } from "~/hud";
@@ -17,31 +12,31 @@ import { initialSeed, nextSeed, parseWorldParams, withSeed } from "~/params";
1712
import { createPoseFeed } from "~/pose-feed";
1813
import { SourcePanel } from "~/source-panel";
1914
import { sourceView } from "~/source-view";
15+
import { useAreaWorld } from "~/use-area-world";
16+
import type { WorldOptions } from "~/use-area-world";
2017
import { useNavigation } from "~/use-navigation";
2118
import {
22-
areasFromCode,
2319
breadcrumbOf,
2420
codeGraphOf,
2521
describeRoom,
2622
promptOf,
2723
sourceSeedOf,
2824
} from "~/world-from-code";
2925

30-
type Generated = {
31-
readonly world: GeneratedWorld | null;
32-
/** Modules of a code world, each its own area; one for other worlds. */
33-
readonly modules: number;
34-
readonly codeGraph: CodeGraph | null;
35-
/** Graph connections and portals the layout could not realise, one line each. */
36-
readonly warnings: readonly string[];
37-
readonly error: string | null;
38-
};
39-
4026
const initial = parseWorldParams(globalThis.location.search);
4127

4228
// Parsed once: a syntax error in a bundled example does not depend on the
4329
// seed, and it is shown in the HUD rather than thrown through the renderer.
4430
const code = initial.code === null ? null : codeGraphOf(initial.code);
31+
const codeGraph = code?.codeGraph ?? null;
32+
33+
const worldOptions: WorldOptions = {
34+
codeGraph,
35+
codeError: code?.error ?? null,
36+
eager: initial.eager,
37+
rooms: initial.rooms,
38+
preset: initial.preset,
39+
};
4540

4641
// A code world without `?seed` takes its source's own seed, so the same
4742
// program reloads into the same world; `?seed` and N override it.
@@ -53,62 +48,6 @@ const startSeed = initialSeed(
5348
// Fed from the render loop and read only by the open map, never by the app.
5449
const feed = createPoseFeed();
5550

56-
const generate = (
57-
seed: number
58-
): { readonly world: GeneratedWorld; readonly modules: number } => {
59-
const codeGraph = code?.codeGraph ?? null;
60-
if (codeGraph !== null) {
61-
const areas = areasFromCode(codeGraph, seed);
62-
// The entrance is an area but not a module.
63-
return { world: mergeAreas(areas), modules: codeGraph.modules.length };
64-
}
65-
const world = generateWorld({
66-
seed,
67-
roomCount: initial.rooms,
68-
graph: initial.preset === null ? undefined : presets[initial.preset],
69-
});
70-
return { world, modules: 1 };
71-
};
72-
73-
/**
74-
* Builds the world once per seed: it never regenerates while the player moves.
75-
* Generation errors (an unplaceable graph) are shown, not thrown.
76-
*/
77-
const useGeneratedWorld = (seed: number): Generated =>
78-
useMemo(() => {
79-
const codeGraph = code?.codeGraph ?? null;
80-
if (code !== null && code.error !== null) {
81-
return {
82-
world: null,
83-
modules: 0,
84-
codeGraph,
85-
warnings: [],
86-
error: code.error,
87-
};
88-
}
89-
try {
90-
const { world, modules } = generate(seed);
91-
// Shown in the HUD, so the console stays quiet.
92-
const warnings = [
93-
...world.layout.unresolved.map(
94-
({ from, to }) => `${from} -> ${to} could not be laid out`
95-
),
96-
...world.layout.unplacedPortals.map(
97-
({ id }) => `${id} has no wall space`
98-
),
99-
];
100-
return { world, modules, codeGraph, warnings, error: null };
101-
} catch (error) {
102-
return {
103-
world: null,
104-
modules: 0,
105-
codeGraph,
106-
warnings: [],
107-
error: String(error),
108-
};
109-
}
110-
}, [seed]);
111-
11251
const App = () => {
11352
const [locked, setLocked] = useState(false);
11453
const [seed, setSeed] = useState(startSeed);
@@ -117,20 +56,42 @@ const App = () => {
11756
const [sourceOpen, setSourceOpen] = useState(false);
11857
const [mapOpen, setMapOpen] = useState(false);
11958
const [visits, setVisits] = useState(EMPTY_VISITS);
120-
const generated = useGeneratedWorld(seed);
59+
// Generated once per seed, then grown an area at a time: it never
60+
// regenerates while the player moves.
61+
const generated = useAreaWorld(seed, worldOptions);
62+
const { ensurePortal } = generated;
12163
const built = generated.world?.built ?? null;
122-
const navigation = useNavigation(generated.world);
123-
const { onRoomChange, back, home } = navigation;
64+
const navigation = useNavigation(seed, generated.current);
65+
const { onRoomChange, back, home, onPortal: stepPortal } = navigation;
12466

12567
const onRoom = useCallback(
12668
(id: string | null) => {
12769
setRoomId(id);
128-
setVisits((previous) => visit(previous, built, id));
70+
setVisits((previous) => visit(previous, seed, id));
12971
onRoomChange(id);
13072
},
131-
[built, onRoomChange]
73+
[seed, onRoomChange]
74+
);
75+
76+
// A portal entered before its far side was generated: generate it first,
77+
// so the step lands there.
78+
const onPortal = useCallback(
79+
(portalId: string) => {
80+
ensurePortal(portalId);
81+
stepPortal(portalId);
82+
},
83+
[ensurePortal, stepPortal]
13284
);
13385

86+
// The portal in front of the player leads into an area not generated yet:
87+
// generate it now, before the player steps in. Also run when the world
88+
// changes, as a target kept across a new seed reports no change.
89+
useEffect(() => {
90+
if (target?.kind === "portal") {
91+
ensurePortal(target.portalId);
92+
}
93+
}, [target, ensurePortal, generated.world]);
94+
13495
// N: next seed (the only thing that regenerates the world). Backspace and
13596
// R navigate, E shows the source and M or Tab the map, only while walking,
13697
// so a free pointer leaves them alone.
@@ -188,16 +149,14 @@ const App = () => {
188149
}
189150
}, [generated.error]);
190151

191-
const place =
192-
roomId === null ? null : describeRoom(generated.codeGraph, roomId);
152+
const place = roomId === null ? null : describeRoom(codeGraph, roomId);
193153
const sources = code?.error === null ? code.sources : null;
194154
const source = useMemo(
195-
() =>
196-
sources === null
197-
? null
198-
: sourceView(generated.codeGraph, sources, roomId),
199-
[generated.codeGraph, sources, roomId]
155+
() => (sources === null ? null : sourceView(codeGraph, sources, roomId)),
156+
[sources, roomId]
200157
);
158+
const failed =
159+
target?.kind === "portal" ? generated.failedArea(target.portalId) : null;
201160

202161
return (
203162
<div className="relative h-dvh w-screen overflow-hidden bg-black">
@@ -220,7 +179,7 @@ const App = () => {
220179
enabled={locked}
221180
onRoomChange={onRoom}
222181
placement={navigation.placement}
223-
onPortal={navigation.onPortal}
182+
onPortal={onPortal}
224183
onNearTarget={setTarget}
225184
onMove={feed.publish}
226185
/>
@@ -230,7 +189,7 @@ const App = () => {
230189
{locked && mapOpen && built !== null && (
231190
<OverviewMap
232191
world={built}
233-
visited={visitedIn(visits, built)}
192+
visited={visitedIn(visits, seed)}
234193
current={roomId}
235194
feed={feed}
236195
/>
@@ -243,15 +202,20 @@ const App = () => {
243202
seed={seed}
244203
rooms={generated.world?.graph.rooms.length ?? 0}
245204
modules={generated.modules}
205+
lastMs={generated.lastMs}
246206
preset={initial.preset}
247207
code={initial.code}
248208
place={place}
249209
breadcrumb={breadcrumbOf(
250-
generated.codeGraph,
210+
codeGraph,
251211
navigation.frames,
252212
navigation.roomId
253213
)}
254-
prompt={promptOf(generated.codeGraph, navigation.frames, target)}
214+
prompt={
215+
failed === null
216+
? promptOf(codeGraph, navigation.frames, target)
217+
: `closed · ${failed} could not be laid out`
218+
}
255219
warnings={generated.warnings}
256220
error={generated.error}
257221
/>

‎apps/web/src/hud.tsx‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ type HudProps = {
22
readonly locked: boolean;
33
readonly seed: number;
44
readonly rooms: number;
5-
/** Modules of a code world, each its own area. */
6-
readonly modules: number;
5+
/** Modules of a code world generated so far and in all, each its own area. */
6+
readonly modules: { readonly built: number; readonly total: number };
7+
/** Milliseconds the last module generated on the way took, or null. */
8+
readonly lastMs: number | null;
79
readonly preset: string | null;
810
readonly code: string | null;
911
/** Where the player is: `file · fn()` for code worlds, else the room id. */
@@ -33,20 +35,36 @@ const codeHints: readonly (readonly [string, string])[] = [
3335
["Wheel", "scroll the source"],
3436
];
3537

38+
/** `6 modules` once all are generated, `2/6 modules · last 14 ms` on the way. */
39+
const modulesOf = ({
40+
modules: { built, total },
41+
lastMs,
42+
}: Pick<HudProps, "modules" | "lastMs">): string | null => {
43+
if (total <= 1) {
44+
return null;
45+
}
46+
const count = built < total ? `${built}/${total}` : `${total}`;
47+
return lastMs === null
48+
? `${count} modules`
49+
: `${count} modules · last ${Math.round(lastMs)} ms`;
50+
};
51+
3652
const whereOf = ({
3753
seed,
3854
rooms,
3955
modules,
56+
lastMs,
4057
preset,
4158
code,
4259
}: Pick<
4360
HudProps,
44-
"seed" | "rooms" | "modules" | "preset" | "code"
61+
"seed" | "rooms" | "modules" | "lastMs" | "preset" | "code"
4562
>): string => {
4663
if (code !== null) {
47-
return modules > 1
48-
? `seed ${seed} · code ${code} · ${modules} modules`
49-
: `seed ${seed} · code ${code}`;
64+
const count = modulesOf({ modules, lastMs });
65+
return count === null
66+
? `seed ${seed} · code ${code}`
67+
: `seed ${seed} · code ${code} · ${count}`;
5068
}
5169
return `seed ${seed} · ${preset ?? `${rooms} rooms`}`;
5270
};

‎apps/web/src/map-model.test.ts‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,36 @@ const hubRoom = () => {
3131

3232
describe("visit", () => {
3333
it("records a room of the world", () => {
34-
const visits = visit(EMPTY_VISITS, demo, "a");
35-
expect([...visitedIn(visits, demo)]).toEqual(["a"]);
34+
const visits = visit(EMPTY_VISITS, 1, "a");
35+
expect([...visitedIn(visits, 1)]).toEqual(["a"]);
3636
});
3737

3838
it("leaves the visits alone between rooms and on a repeat", () => {
39-
const visits = visit(EMPTY_VISITS, demo, "a");
40-
expect(visit(visits, demo, null)).toBe(visits);
41-
expect(visit(visits, demo, "a")).toBe(visits);
39+
const visits = visit(EMPTY_VISITS, 1, "a");
40+
expect(visit(visits, 1, null)).toBe(visits);
41+
expect(visit(visits, 1, "a")).toBe(visits);
4242
});
4343

44-
it("starts over for another world", () => {
45-
const other = builtOf("demo", 2);
46-
const visits = visit(visit(EMPTY_VISITS, demo, "a"), demo, "b");
47-
const next = visit(visits, other, "c");
48-
expect([...visitedIn(next, other)]).toEqual(["c"]);
49-
expect(visitedIn(next, demo).size).toBe(0);
50-
expect(visitedIn(visit(visits, other, null), other).size).toBe(0);
44+
it("starts over for another seed", () => {
45+
const visits = visit(visit(EMPTY_VISITS, 1, "a"), 1, "b");
46+
const next = visit(visits, 2, "c");
47+
expect([...visitedIn(next, 2)]).toEqual(["c"]);
48+
expect(visitedIn(next, 1).size).toBe(0);
49+
expect(visitedIn(visit(visits, 2, null), 2).size).toBe(0);
5150
});
5251

5352
it("shows nothing of a world the visits do not belong to", () => {
54-
const visits = visit(EMPTY_VISITS, demo, "a");
55-
expect(visitedIn(visits, builtOf("demo", 2)).size).toBe(0);
53+
const visits = visit(EMPTY_VISITS, 1, "a");
54+
expect(visitedIn(visits, 2).size).toBe(0);
55+
});
56+
57+
it("keeps the rooms while the world of the same seed grows", () => {
58+
// A new world object for the same seed, as a world that grew is.
59+
const before = builtOf("demo");
60+
const visits = visit(EMPTY_VISITS, 1, "demo.ts");
61+
const after = builtOf("demo");
62+
expect(after).not.toBe(before);
63+
expect([...visitedIn(visit(visits, 1, null), 1)]).toEqual(["demo.ts"]);
5664
});
5765
});
5866

0 commit comments

Comments
 (0)