@threenative/terrain 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/AGENT_GUIDE.md +194 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -2
  4. package/THIRD_PARTY_NOTICES.md +38 -0
  5. package/dist/editor/index.d.ts +231 -0
  6. package/dist/editor/index.js +5624 -0
  7. package/dist/editor/server.d.ts +3 -0
  8. package/dist/editor/server.js +3688 -0
  9. package/dist/editor/worker.d.ts +2 -0
  10. package/dist/editor/worker.js +2212 -0
  11. package/dist/export.d.ts +54 -0
  12. package/dist/export.js +297 -0
  13. package/dist/index.d.ts +335 -0
  14. package/dist/index.js +2927 -0
  15. package/dist/server-ByM2hmYJ.d.ts +630 -0
  16. package/dist/terrain-gPnkCU6S.d.ts +141 -0
  17. package/dist/three.d.ts +14 -0
  18. package/dist/three.js +40 -0
  19. package/dist/types-Ant8-Axc.d.ts +486 -0
  20. package/package.json +86 -3
  21. package/starter/alpine/README.md +29 -0
  22. package/starter/alpine/assets.json +12 -0
  23. package/starter/alpine/bake.mjs +118 -0
  24. package/starter/alpine/recipe.json +66167 -0
  25. package/starter/alpine/sky.ts +23 -0
  26. package/starter/alpine/surface.json +53 -0
  27. package/starter/alpine/world.ts +335 -0
  28. package/starter/coastal/README.md +37 -0
  29. package/starter/coastal/assets.json +10 -0
  30. package/starter/coastal/bake.mjs +118 -0
  31. package/starter/coastal/recipe.json +66161 -0
  32. package/starter/coastal/sea.ts +327 -0
  33. package/starter/coastal/sky.ts +24 -0
  34. package/starter/coastal/surface.json +41 -0
  35. package/starter/coastal/world.ts +386 -0
  36. package/starter/desert/README.md +35 -0
  37. package/starter/desert/assets.json +12 -0
  38. package/starter/desert/bake.mjs +118 -0
  39. package/starter/desert/recipe.json +66156 -0
  40. package/starter/desert/sky.ts +23 -0
  41. package/starter/desert/surface.json +54 -0
  42. package/starter/desert/world.ts +335 -0
  43. package/starter/forest/README.md +46 -0
  44. package/starter/forest/assets.json +32 -0
  45. package/starter/forest/bake.mjs +118 -0
  46. package/starter/forest/recipe.json +4586 -0
  47. package/starter/forest/sky.ts +27 -0
  48. package/starter/forest/surface.json +79 -0
  49. package/starter/forest/water.ts +266 -0
  50. package/starter/forest/world.ts +413 -0
  51. package/starter/tundra/README.md +29 -0
  52. package/starter/tundra/assets.json +12 -0
  53. package/starter/tundra/bake.mjs +118 -0
  54. package/starter/tundra/recipe.json +66325 -0
  55. package/starter/tundra/sky.ts +27 -0
  56. package/starter/tundra/surface.json +54 -0
  57. package/starter/tundra/water.ts +313 -0
  58. package/starter/tundra/world.ts +366 -0
  59. package/starter-assets/bark_brown_02/bark_brown_02_diff_1k.jpg +0 -0
  60. package/starter-assets/bark_brown_02/bark_brown_02_nor_gl_1k.jpg +0 -0
  61. package/starter-assets/bark_brown_02/bark_brown_02_rough_1k.jpg +0 -0
  62. package/starter-assets/boulder_01/boulder-mid.glb +0 -0
  63. package/starter-assets/boulder_01/boulder-near.glb +0 -0
  64. package/starter-assets/cliff_side/cliff_side_ao_1k.jpg +0 -0
  65. package/starter-assets/cliff_side/cliff_side_diff_1k.jpg +0 -0
  66. package/starter-assets/cliff_side/cliff_side_nor_gl_1k.jpg +0 -0
  67. package/starter-assets/cliff_side/cliff_side_rough_1k.jpg +0 -0
  68. package/starter-assets/credits.json +1022 -0
  69. package/starter-assets/fern_02/fern_02_alpha_512.png +0 -0
  70. package/starter-assets/fern_02/fern_02_diff_512.jpg +0 -0
  71. package/starter-assets/fern_02/fern_02_nor_gl_512.jpg +0 -0
  72. package/starter-assets/fir_tree_01/fir-b-lod1.glb +0 -0
  73. package/starter-assets/fir_tree_01/fir-b-lod2.glb +0 -0
  74. package/starter-assets/fir_tree_01/fir-c-lod1.glb +0 -0
  75. package/starter-assets/fir_tree_01/fir-c-lod2.glb +0 -0
  76. package/starter-assets/fir_tree_01/fir_tree_01_twig_arm_1k.jpg +0 -0
  77. package/starter-assets/fir_tree_01/fir_tree_01_twig_diff_1k.jpg +0 -0
  78. package/starter-assets/fir_tree_01/fir_tree_01_twig_nor_gl_1k.jpg +0 -0
  79. package/starter-assets/forest_ground_04/forest_ground_04_ao_1k.jpg +0 -0
  80. package/starter-assets/forest_ground_04/forest_ground_04_diff_1k.jpg +0 -0
  81. package/starter-assets/forest_ground_04/forest_ground_04_nor_gl_1k.jpg +0 -0
  82. package/starter-assets/forest_ground_04/forest_ground_04_rough_1k.jpg +0 -0
  83. package/starter-assets/forest_leaves_02/forest_leaves_02_arm_512.jpg +0 -0
  84. package/starter-assets/forest_leaves_02/forest_leaves_02_diff_512.jpg +0 -0
  85. package/starter-assets/forest_leaves_02/forest_leaves_02_nor_gl_512.jpg +0 -0
  86. package/starter-assets/grass_medium_02/grass_medium_02_alpha_512.png +0 -0
  87. package/starter-assets/grass_medium_02/grass_medium_02_diff_512.jpg +0 -0
  88. package/starter-assets/grass_medium_02/grass_medium_02_nor_gl_512.jpg +0 -0
  89. package/starter-assets/hdri/kloofendal_48d_partly_cloudy_1k.hdr +0 -0
  90. package/starter-assets/leafy_grass/leafy_grass_ao_1k.jpg +0 -0
  91. package/starter-assets/leafy_grass/leafy_grass_diff_1k.jpg +0 -0
  92. package/starter-assets/leafy_grass/leafy_grass_nor_gl_1k.jpg +0 -0
  93. package/starter-assets/leafy_grass/leafy_grass_rough_1k.jpg +0 -0
  94. package/starter-assets/lichen_rock/lichen_rock_arm_512.jpg +0 -0
  95. package/starter-assets/lichen_rock/lichen_rock_diff_512.jpg +0 -0
  96. package/starter-assets/lichen_rock/lichen_rock_nor_gl_512.jpg +0 -0
  97. package/starter-assets/mossy_rock/mossy_rock_ao_1k.jpg +0 -0
  98. package/starter-assets/mossy_rock/mossy_rock_diff_1k.jpg +0 -0
  99. package/starter-assets/mossy_rock/mossy_rock_nor_gl_1k.jpg +0 -0
  100. package/starter-assets/mossy_rock/mossy_rock_rough_1k.jpg +0 -0
  101. package/starter-assets/needle-atlas.png +0 -0
  102. package/starter-assets/needle-surface.png +0 -0
  103. package/starter-assets/river_small_rocks/river_small_rocks_arm_512.jpg +0 -0
  104. package/starter-assets/river_small_rocks/river_small_rocks_diff_512.jpg +0 -0
  105. package/starter-assets/river_small_rocks/river_small_rocks_nor_gl_512.jpg +0 -0
  106. package/starter-assets/rock_boulder_dry/rock_boulder_dry_arm_512.jpg +0 -0
  107. package/starter-assets/rock_boulder_dry/rock_boulder_dry_diff_512.jpg +0 -0
  108. package/starter-assets/rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg +0 -0
  109. package/starter-assets/rocks/boulder-mid.glb +0 -0
  110. package/starter-assets/rocks/boulder-near.glb +0 -0
  111. package/starter-assets/rocks/boulder-prep.json +37 -0
  112. package/starter-assets/rocks/rock01-mid.glb +0 -0
  113. package/starter-assets/rocks/rock01-near.glb +0 -0
  114. package/starter-assets/rocks/rock01-prep.json +37 -0
  115. package/starter-assets/rocks/rock04-mid.glb +0 -0
  116. package/starter-assets/rocks/rock04-near.glb +0 -0
  117. package/starter-assets/rocks/rock04-prep.json +37 -0
  118. package/starter-assets/sand_01/sand_01_ao_1k.jpg +0 -0
  119. package/starter-assets/sand_01/sand_01_diff_1k.jpg +0 -0
  120. package/starter-assets/sand_01/sand_01_nor_gl_1k.jpg +0 -0
  121. package/starter-assets/sand_01/sand_01_rough_1k.jpg +0 -0
  122. package/starter-assets/snow_02/snow_02_ao_1k.jpg +0 -0
  123. package/starter-assets/snow_02/snow_02_diff_1k.jpg +0 -0
  124. package/starter-assets/snow_02/snow_02_nor_gl_1k.jpg +0 -0
  125. package/starter-assets/snow_02/snow_02_rough_1k.jpg +0 -0
@@ -0,0 +1,366 @@
1
+ // The tundra starter world, as the game that copied it draws and collides with it.
2
+ //
3
+ // `bake.mjs` wrote a world package into `world/`: heightmap, splat masks, placements and the CC0 boulder.
4
+ // The engine's `WorldCells` streams it. `loadTerrainSplat` textures its ground.
5
+ // Everything that decides how the world looks, or what stops the player, is in this folder for the game to edit.
6
+ import type { IComputeDriven, ICtx } from "@threenative/core";
7
+ import {
8
+ Heightfield,
9
+ type IWorldCellsLoadOptions,
10
+ type IWorldPackage,
11
+ WorldCells,
12
+ heightSamplerFromHeightmap,
13
+ loadTerrainSplat,
14
+ loadWorldHeightmap,
15
+ } from "@threenative/core/world";
16
+ import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics";
17
+ import {
18
+ EquirectangularReflectionMapping,
19
+ type Material,
20
+ type Mesh,
21
+ type MeshStandardMaterial,
22
+ Object3D,
23
+ type Texture,
24
+ Vector3,
25
+ } from "three";
26
+ import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
27
+ import { type ITundraWater, type ITundraWaterFile, addTundraWater } from "./water.js";
28
+
29
+ /** Where the game's asset source holds the baked `world/` folder. */
30
+ export const TUNDRA_URL = "terrain/tundra/world.json";
31
+
32
+ /**
33
+ * What stops the player, per placed asset, in metres at the placement's scale 1. `null` walks
34
+ * through. A sphere sits on the placement's origin. Change a row to change every copy.
35
+ */
36
+ export const COLLIDERS: Readonly<Record<string, { sphere: number } | null>> = {
37
+ boulder: { sphere: 0.9 },
38
+ };
39
+
40
+ /** How strongly the sky lights the props. The sun stays the dominant light on a boulder. */
41
+ export const PROP_SKY_LIGHT = 0.5;
42
+
43
+ /** Metres around `follow` within which props collide. Beyond that, props only draw. */
44
+ export const PROP_COLLIDER_REACH = 60;
45
+
46
+ export interface ITundraWorld {
47
+ readonly world: WorldCells;
48
+ /** One heightfield for the whole world, in place before `addTundra` resolves. */
49
+ readonly ground: RigidBody3D;
50
+ /** The prop colliders near `follow`. They stream with it (see `PROP_COLLIDER_REACH`). */
51
+ readonly colliders: PropColliders;
52
+ /** The heightfield the ground stands on: `heightAt(x, z)` reads the terrain under any point. */
53
+ readonly field: Heightfield;
54
+ /** Every placed prop's record, eight floats each, as the bake wrote it. */
55
+ readonly placements: Float32Array;
56
+ /** The world's extent in metres, as the bake wrote it. */
57
+ readonly extent: IWorldPackage["extent"];
58
+ /** The kettle lakes the bake wrote, as circles on the ground: the scene keeps its spawn off their shores. */
59
+ readonly lakes: readonly ITundraLake[];
60
+ /** How many meltwater rivers the bake wrote. They are drawn, and counted into the playtest. */
61
+ readonly rivers: number;
62
+ /** The lakes and rivers as they were drawn. `dispose` removes their meshes. */
63
+ readonly water: ITundraWater;
64
+ }
65
+
66
+ /** One kettle lake as the scene reads it: where its centre is and how far its shore reaches. */
67
+ export interface ITundraLake {
68
+ readonly at: readonly [number, number];
69
+ readonly radius: number;
70
+ }
71
+
72
+ /**
73
+ * Stream the tundra world around `follow` (usually the player or the camera) and give it collision.
74
+ * `whileCurrent` stops a departed scene's async load from attaching anything. Its resources are
75
+ * released on cancellation, failure and when the game's entity registry ends this scene.
76
+ */
77
+ export async function addTundra(
78
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
79
+ follow: Object3D,
80
+ url = TUNDRA_URL,
81
+ whileCurrent: () => boolean = () => true,
82
+ ): Promise<ITundraWorld> {
83
+ const assertCurrent = () => {
84
+ if (whileCurrent()) return;
85
+ const error = new Error("Tundra world: its scene is no longer current.");
86
+ error.name = "AbortError";
87
+ throw error;
88
+ };
89
+ let surface: Material | undefined;
90
+ let releaseLighting: (() => void) | undefined;
91
+ let world: WorldCells | undefined;
92
+ let ground: RigidBody3D | undefined;
93
+ let colliders: PropColliders | undefined;
94
+ let water: ITundraWater | undefined;
95
+ let released = false;
96
+ const dispose = () => {
97
+ if (released) return;
98
+ released = true;
99
+ colliders?.detach();
100
+ colliders?.removeFromParent();
101
+ ground?.dispose();
102
+ ground?.object?.removeFromParent();
103
+ water?.dispose();
104
+ world?.dispose();
105
+ surface?.dispose();
106
+ releaseLighting?.();
107
+ };
108
+ try {
109
+ assertCurrent();
110
+ const manifest = (await (await fetchAsset(ctx, url, assertCurrent)).json()) as IWorldPackage;
111
+ assertCurrent();
112
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
113
+ const records = await (
114
+ await fetchAsset(ctx, base + manifest.placements, assertCurrent)
115
+ ).arrayBuffer();
116
+ assertCurrent();
117
+ const [heightmapUrl] = await ctx.assets.resolve(base + manifest.terrain.heightmap);
118
+ assertCurrent();
119
+ if (heightmapUrl === undefined) throw new Error("Tundra world: its heightmap is not served.");
120
+ const heightmap = await loadWorldHeightmap(heightmapUrl);
121
+ assertCurrent();
122
+ surface = await loadTerrainSplat({ assets: ctx.assets, url });
123
+ assertCurrent();
124
+ releaseLighting = await lightProps(ctx, url, manifest, assertCurrent);
125
+ assertCurrent();
126
+ const options: IWorldCellsLoadOptions = {
127
+ url,
128
+ assets: ctx.assets,
129
+ data: { manifest, placements: records, heightmap },
130
+ surface,
131
+ follow,
132
+ ring: 5,
133
+ impostors: false,
134
+ gpuScene: false,
135
+ shadows: { cast: true, receive: true },
136
+ budgets: { residentCells: 64, instances: 40_000, bytes: 64_000_000 },
137
+ terrain: { streamRadius: 6 },
138
+ };
139
+ world = await WorldCells.load(options);
140
+ assertCurrent();
141
+ const field = tundraField(manifest, heightmap);
142
+ ground = groundCollider(ctx, manifest, field);
143
+ const waterFile = (await (
144
+ await fetchAsset(ctx, `${base}water.json`, assertCurrent)
145
+ ).json()) as ITundraWaterFile;
146
+ assertCurrent();
147
+ const lakeWater = addTundraWater(ctx, waterFile, field);
148
+ water = lakeWater;
149
+ const props = new Float32Array(records);
150
+ colliders = new PropColliders(ctx, follow, manifest, props);
151
+ ctx.add(world);
152
+ if (ground.object !== undefined) ctx.add(ground.object);
153
+ ctx.add(colliders);
154
+ ctx.entities.add(`tundra-world.${world.uuid}`, { dispose });
155
+ return {
156
+ world,
157
+ ground,
158
+ colliders,
159
+ field,
160
+ placements: props,
161
+ extent: manifest.extent,
162
+ lakes: waterFile.lakes.map((lake) => ({ at: lake.at, radius: lake.radius })),
163
+ rivers: waterFile.rivers.length,
164
+ water: lakeWater,
165
+ };
166
+ } catch (error) {
167
+ dispose();
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ // Every prop gets the sky as its own envMap before WorldCells adopts the shared cached models.
173
+ // Without it, a boulder lit by the sun alone draws flat.
174
+ async function lightProps(
175
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
176
+ url: string,
177
+ manifest: IWorldPackage,
178
+ assertCurrent: () => void,
179
+ ): Promise<() => void> {
180
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
181
+ const [skyUrl] = await ctx.assets.resolve(`${base}sky.hdr`);
182
+ assertCurrent();
183
+ if (skyUrl === undefined) throw new Error("Tundra world: 'sky.hdr' is not served.");
184
+ const sky = await new HDRLoader().loadAsync(skyUrl);
185
+ const originals = new Map<MeshStandardMaterial, { envMap: Texture | null; intensity: number }>();
186
+ const dispose = () => {
187
+ for (const [material, original] of originals) {
188
+ if (material.envMap !== sky) continue;
189
+ material.envMap = original.envMap;
190
+ material.envMapIntensity = original.intensity;
191
+ }
192
+ sky.dispose();
193
+ };
194
+ try {
195
+ assertCurrent();
196
+ sky.mapping = EquirectangularReflectionMapping;
197
+ for (const asset of Object.values(manifest.assets)) {
198
+ const model = await ctx.assets.model<{ scene: Object3D }>(base + asset.glb);
199
+ assertCurrent();
200
+ model.scene.traverse((object) => {
201
+ const mesh = object as Mesh;
202
+ for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[])
203
+ if (!originals.has(material))
204
+ originals.set(material, {
205
+ envMap: material.envMap,
206
+ intensity: material.envMapIntensity,
207
+ });
208
+ dressProp(mesh, sky);
209
+ });
210
+ }
211
+ return dispose;
212
+ } catch (error) {
213
+ dispose();
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ /** One prop mesh: the sky as its envMap. */
219
+ function dressProp(mesh: Mesh, sky: Texture): void {
220
+ for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[]) {
221
+ material.envMap = sky;
222
+ material.envMapIntensity = PROP_SKY_LIGHT;
223
+ }
224
+ }
225
+
226
+ // The streamed terrain tiles arrive over several frames. A player spawned before its tile would
227
+ // fall through. One heightfield for the whole package avoids that, at a few hundred kilobytes.
228
+ /** The kit's one heightfield: the collider stands on it. */
229
+ function tundraField(manifest: IWorldPackage, heightmap: Uint16Array): Heightfield {
230
+ const { extent, terrain } = manifest;
231
+ return Heightfield.fromSampler({
232
+ columns: terrain.columns,
233
+ rows: terrain.rows,
234
+ width: extent.sizeX,
235
+ depth: extent.sizeZ,
236
+ origin: { x: extent.minX + extent.sizeX / 2, z: extent.minZ + extent.sizeZ / 2 },
237
+ sampleHeight: heightSamplerFromHeightmap(terrain, extent, heightmap),
238
+ });
239
+ }
240
+
241
+ function groundCollider(
242
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
243
+ manifest: IWorldPackage,
244
+ field: Heightfield,
245
+ ): RigidBody3D {
246
+ const { extent } = manifest;
247
+ const anchor = new Object3D();
248
+ anchor.position.set(extent.minX + extent.sizeX / 2, 0, extent.minZ + extent.sizeZ / 2);
249
+ return new RigidBody3D({
250
+ object: anchor,
251
+ physics: ctx.physics,
252
+ type: "fixed",
253
+ entity: "terrain",
254
+ shape: CollisionShape3D.heightfield(field.rows, field.columns, field.toColliderHeights(), {
255
+ x: extent.sizeX,
256
+ y: 1,
257
+ z: extent.sizeZ,
258
+ }),
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Fixed bodies for the props within `PROP_COLLIDER_REACH` of `follow`, rebuilt when it has moved a few
264
+ * metres. Thousands of resident bodies cost Rapier about 0.5 s per 8 s (measured). A hundred do not.
265
+ */
266
+ export class PropColliders extends Object3D implements IComputeDriven {
267
+ readonly warmupNodes: readonly unknown[] = [];
268
+ readonly processCadence = "render" as const;
269
+ readonly #live = new Map<number, RigidBody3D>();
270
+ readonly #runs: { asset: string; offset: number; count: number }[] = [];
271
+ readonly #at = { x: Number.NaN, z: Number.NaN };
272
+ readonly #followPosition = new Vector3();
273
+ #released = false;
274
+
275
+ constructor(
276
+ private readonly ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
277
+ private readonly follow: Object3D,
278
+ manifest: IWorldPackage,
279
+ private readonly records: Float32Array,
280
+ ) {
281
+ super();
282
+ this.name = "tundra-prop-colliders";
283
+ for (const cell of manifest.cells)
284
+ for (const run of cell.runs) if (COLLIDERS[run.asset]) this.#runs.push(run);
285
+ try {
286
+ this.#rebuild();
287
+ } catch (error) {
288
+ this.detach();
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ /** How many prop bodies exist right now. */
294
+ get active(): number {
295
+ return this.#live.size;
296
+ }
297
+
298
+ get released(): boolean {
299
+ return this.#released;
300
+ }
301
+
302
+ attachRenderer(): void {}
303
+
304
+ process(): void {
305
+ if (this.#released) return;
306
+ const p = this.follow.getWorldPosition(this.#followPosition);
307
+ if (Math.hypot(p.x - this.#at.x, p.z - this.#at.z) > 4 || Number.isNaN(this.#at.x))
308
+ this.#rebuild();
309
+ }
310
+
311
+ detach(): void {
312
+ for (const body of this.#live.values()) body.dispose();
313
+ this.#live.clear();
314
+ this.#released = true;
315
+ }
316
+
317
+ #rebuild(): void {
318
+ const { x: fx, z: fz } = this.follow.getWorldPosition(this.#followPosition);
319
+ this.#at.x = fx;
320
+ this.#at.z = fz;
321
+ const keep = new Set<number>();
322
+ for (const run of this.#runs) {
323
+ const shape = COLLIDERS[run.asset];
324
+ if (!shape) continue;
325
+ for (let i = run.offset; i < run.offset + run.count; i++) {
326
+ const x = this.records[i * 8] as number;
327
+ const z = this.records[i * 8 + 2] as number;
328
+ if (Math.hypot(x - fx, z - fz) > PROP_COLLIDER_REACH) continue;
329
+ keep.add(i);
330
+ if (this.#live.has(i)) continue;
331
+ const collider = CollisionShape3D.sphere(
332
+ shape.sphere * (this.records[i * 8 + 7] as number),
333
+ );
334
+ this.#live.set(
335
+ i,
336
+ new RigidBody3D({
337
+ physics: this.ctx.physics,
338
+ type: "fixed",
339
+ entity: `${run.asset}.${String(i)}`,
340
+ position: { x, y: this.records[i * 8 + 1] as number, z },
341
+ shape: collider,
342
+ }),
343
+ );
344
+ }
345
+ }
346
+ for (const [i, body] of this.#live)
347
+ if (!keep.has(i)) {
348
+ body.dispose();
349
+ this.#live.delete(i);
350
+ }
351
+ }
352
+ }
353
+
354
+ async function fetchAsset(
355
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
356
+ url: string,
357
+ assertCurrent: () => void,
358
+ ): Promise<Response> {
359
+ const [resolved] = await ctx.assets.resolve(url);
360
+ assertCurrent();
361
+ if (resolved === undefined) throw new Error(`Tundra world: '${url}' is not served.`);
362
+ const response = await fetch(resolved);
363
+ assertCurrent();
364
+ if (!response.ok) throw new Error(`Tundra world: '${url}' answered ${String(response.status)}.`);
365
+ return response;
366
+ }