@netforge/template-dungeon 0.0.0-stage → 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NetNet Capital Management
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,35 @@
1
+ @netforge/template-dungeon (CRYPTWICK)
2
+ Copyright (c) 2026 NetNet Capital Management
3
+
4
+ An original game. No names, characters, sprites, models, sounds or code
5
+ from any other game or franchise. Nothing from PokéSurvivor (its code and
6
+ sprites are non-commercial) or SpriteCollab was used or consulted for code.
7
+
8
+ Art and sound
9
+ - Models come from the per-style asset library
10
+ (@netforge/asset-library): CC0 1.0 models by Quaternius (wickbearers,
11
+ the dead) and Kenney (columns, crates and rocks), each pack listed
12
+ with its source URL and licence verification in that package's NOTICE and
13
+ manifest/packs.json. The files are fetched and built, never committed
14
+ here; the build copies only the models this game uses.
15
+ - Fallback shapes (client/library.ts) are generated from three.js
16
+ primitives; textures are drawn on a canvas at load.
17
+
18
+ Dependencies (unmodified, via npm)
19
+ - three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
20
+ - nipplejs 1.0.4 (https://github.com/yoannmoinet/nipplejs), MIT.
21
+ - @netforge/engine and @netforge/engine-sim (this repository);
22
+ see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
23
+ zod MIT).
24
+
25
+ Patterns (ideas only)
26
+ - The dungeon-crawl and survivor-like genres' general mechanics (rooms
27
+ joined by corridors, rooms that wake as you near them, loot in cleared
28
+ rooms, permadeath runs, auto-firing weapons, pick-one-of-three
29
+ level-ups, a boss in the deepest room). Mechanics, not expression; no
30
+ level, item, enemy or text was copied from any game. The floor plan is
31
+ generated by our own code (engine dungeon.ts) from a seed.
32
+ - Flow-field pathing (a breadth-first distance field steered down its
33
+ gradient), a textbook technique, implemented in engine-sim walls.ts.
34
+ - Hit-stop, screen shake and damage numbers as described in common
35
+ game-feel talks and articles (e.g. "Juice it or lose it", GDC 2012).
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # CRYPTWICK (Template 3: co-op dungeon crawl)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Four wickbearers go down into a candle-lit crypt, room by room. The crypt
4
+ is grown from a seed (`dungeon(...)`): ten rooms joined by corridors, the
5
+ start room at the top and the Wax Abbot in the deepest one. Rooms wake as
6
+ the party nears them and their dead come through the doors; a cleared room
7
+ leaves a reliquary (pick an upgrade) and brings back anyone who fell. Runs
8
+ are permadeath: if the whole party is down at once, the descent is over.
9
+
10
+ | Path | What |
11
+ |---|---|
12
+ | `src/game.ts` | The game, as the model writes it (declarations only): the crypt's spec, the dead, weapons, the crawl system, earn, store. |
13
+ | `src/manifest.ts` | Loads and compiles it; `dungeonAdapter()` for the room server and client. Room template `dungeon`. |
14
+ | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive (50 coins), a cosmetic (300 coins) and the wick charm (losable gear, 150 coins), shown under the token's own symbol and logo; mock until the token is live. |
15
+ | `client/` | The browser client: three.js scene, the crypt's floors and walls (`client/crypt.ts`, with an x-ray cut so a wall never hides your wickbearer and a fog of war over rooms nobody has entered), HUD, input (keys, gamepad, touch), game feel. |
16
+ | `test/` | Playtest bots through `engine.test.run`, and the store. |
17
+ | `llms.txt` | How to change the game (for agents). |
18
+
19
+ ## How it plays on the server
20
+
21
+ - Server-authoritative at 30 Hz on the survivor machinery (auto-firing
22
+ weapons, shards and level-up cards, co-op downs and revives, keepers,
23
+ bosses with telegraphed attacks). Your own wickbearer is predicted.
24
+ - The crypt's walls are boxes in the sim (`field.walls`): players meet them
25
+ in Rapier, and enemies, bolts and spawns are resolved against them by hand
26
+ like pillars. Floors (`field.floors`) are the only ground: spawns land on
27
+ them, and enemies behind a wall walk round through the doors on a flow
28
+ field (engine-sim `walls.ts`), with a wider grid for big bodies.
29
+ - The crawl is a system in game.ts (the model can read and change it): room
30
+ wakes, encounters by depth, off-screen spawns inside the woken room, the
31
+ quiet timer that wakes the nearest room when the party waits, reliquaries
32
+ and the respawn on clear.
33
+ - Earn: room.cleared (2 and 7 rooms), the Abbot, and 60 kills by one wickbearer, once per
34
+ run each, on the same pay structure as EMBERWATCH. The room server is the
35
+ referee; before graduation earn is credited as points, after it the
36
+ game's own coins under its own name.
37
+
38
+ ## Run it
39
+
40
+ This template runs on your own computer with play money through
41
+ `kit play` (`@netforge/kit-play`). From the repository root:
42
+
43
+ ```sh
44
+ pnpm install --frozen-lockfile && pnpm build
45
+ mkdir -p play-dungeon/src && cp packages/template-dungeon/src/game.ts play-dungeon/src/
46
+ echo '{"template":"dungeon"}' > play-dungeon/netnet.json
47
+ node packages/kit-play/dist/cli.js play-dungeon # opens http://127.0.0.1:4663
48
+ ```
49
+
50
+ Edit `play-dungeon/src/game.ts` and run it again to try a change. Real-token
51
+ earning, stakes and claims run only on the hosted Net Forge server.
52
+
53
+ Open the Invite link in another tab or on a phone on the same network to
54
+ play together (`?room=<id>`). Keys: WASD or arrows, Space dash, 1 2 3 pick.
55
+ Touch: drag on the left half, DASH button, tap a card. ` shows stats.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The crypt as drawn: dark rock under everything, a flagstone floor on
3
+ * every room and corridor (game.ts field.floors), stone walls on every
4
+ * wall box (field.walls), and candles in sconces at the room corners. One merged mesh for all the walls and one for all
5
+ * the floors, so the whole level is a handful of draws.
6
+ *
7
+ * Rooms nobody has walked into yet stay dim (a soft fog of war that lifts
8
+ * as a wickbearer enters); the walls stay readable so the plan is always
9
+ * there to see.
10
+ *
11
+ * An art style that paints surfaces (ps1's textures) paints these too
12
+ * (surface roles "floor", "wall", "stone"); the toon, pixel, voxel and
13
+ * hand-drawn styles reshade them in post like everything else.
14
+ */
15
+ import { BufferAttribute, BufferGeometry, CanvasTexture, Color, CylinderGeometry, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, MeshStandardNodeMaterial, PlaneGeometry, RepeatWrapping, SphereGeometry, SRGBColorSpace, Vector3 } from "three/webgpu";
16
+ import { clamp, dot, float, length, positionWorld, select, uniform } from "three/tsl";
17
+ import type { Texture } from "three/webgpu";
18
+ import type { WallBox } from "@netforge/engine-sim";
19
+
20
+ /** Walls are drawn this tall (the sim's are 3 m): low enough that a wall south of you hides less of the room. */
21
+ export const WALL_DRAW_HEIGHT = 2.2;
22
+ /** Candles burn in sconces this high in the room corners, under the wall tops. */
23
+ const SCONCE_HEIGHT = 1.75;
24
+ /** Brightness of a floor nobody has walked into yet, and of one a wickbearer has. */
25
+ const UNSEEN = 0.2;
26
+ const LIT = 0.8;
27
+
28
+ function hash01(n: number): number {
29
+ let t = (n + 0x6d2b79f5) >>> 0;
30
+ t = Math.imul(t ^ (t >>> 15), t | 1);
31
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
32
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
33
+ }
34
+
35
+ /** Worn flagstones, drawn on a canvas (one repeat covers 4 m). */
36
+ function flagstones(): CanvasTexture {
37
+ const c = document.createElement("canvas");
38
+ c.width = c.height = 256;
39
+ const g = c.getContext("2d");
40
+ if (g) {
41
+ g.fillStyle = "#3b342d";
42
+ g.fillRect(0, 0, 256, 256);
43
+ for (let j = 0; j < 4; j++) {
44
+ for (let i = 0; i < 4; i++) {
45
+ const k = j * 4 + i;
46
+ const off = j % 2 === 0 ? 0 : 32;
47
+ const v = 70 + Math.floor(hash01(k) * 26);
48
+ g.fillStyle = `rgb(${v + 8}, ${v}, ${v - 10})`;
49
+ g.fillRect(((i * 64 + off) % 256) + 2, j * 64 + 2, 60, 60);
50
+ // Cracks and wear.
51
+ g.strokeStyle = "rgba(20, 16, 12, 0.35)";
52
+ g.beginPath();
53
+ g.moveTo(((i * 64 + off) % 256) + 8 + hash01(k + 50) * 40, j * 64 + 6);
54
+ g.lineTo(((i * 64 + off) % 256) + 10 + hash01(k + 90) * 44, j * 64 + 58);
55
+ g.stroke();
56
+ }
57
+ }
58
+ g.fillStyle = "rgba(255, 220, 160, 0.04)";
59
+ for (let n = 0; n < 300; n++) g.fillRect(hash01(n * 3) * 256, hash01(n * 3 + 1) * 256, 2, 2);
60
+ }
61
+ const t = new CanvasTexture(c);
62
+ t.wrapS = t.wrapT = RepeatWrapping;
63
+ t.colorSpace = SRGBColorSpace;
64
+ t.userData = { tile: 4 };
65
+ return t;
66
+ }
67
+
68
+ /** Coursed stone blocks for the walls (one repeat covers 2 m). */
69
+ function masonry(): CanvasTexture {
70
+ const c = document.createElement("canvas");
71
+ c.width = c.height = 128;
72
+ const g = c.getContext("2d");
73
+ if (g) {
74
+ g.fillStyle = "#1d1a18";
75
+ g.fillRect(0, 0, 128, 128);
76
+ for (let j = 0; j < 4; j++) {
77
+ for (let i = -1; i < 3; i++) {
78
+ const v = 52 + Math.floor(hash01(j * 7 + i + 20) * 22);
79
+ g.fillStyle = `rgb(${v}, ${v - 4}, ${v - 9})`;
80
+ g.fillRect(i * 64 + (j % 2) * 32 + 2, j * 32 + 2, 60, 28);
81
+ }
82
+ }
83
+ }
84
+ const t = new CanvasTexture(c);
85
+ t.wrapS = t.wrapT = RepeatWrapping;
86
+ t.colorSpace = SRGBColorSpace;
87
+ t.userData = { tile: 2 };
88
+ return t;
89
+ }
90
+
91
+ /**
92
+ * Boxes as one geometry with world-space UVs (so a texture tiles at the
93
+ * same scale on a 1 m stub and a 40 m run). `h` is the height; the floor
94
+ * is y = 0.
95
+ */
96
+ function boxesGeometry(boxes: readonly WallBox[], h: number, tile: number): BufferGeometry {
97
+ const pos: number[] = [];
98
+ const nor: number[] = [];
99
+ const uv: number[] = [];
100
+ const idx: number[] = [];
101
+ const quad = (a: number[], b: number[], c: number[], d: number[], n: number[], uvOf: (p: number[]) => [number, number]) => {
102
+ const base = pos.length / 3;
103
+ for (const p of [a, b, c, d]) {
104
+ pos.push(p[0] ?? 0, p[1] ?? 0, p[2] ?? 0);
105
+ nor.push(n[0] ?? 0, n[1] ?? 0, n[2] ?? 0);
106
+ const [u, v] = uvOf(p);
107
+ uv.push(u / tile, v / tile);
108
+ }
109
+ idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
110
+ };
111
+ for (const b of boxes) {
112
+ const x0 = b.x - b.hx;
113
+ const x1 = b.x + b.hx;
114
+ const z0 = b.z - b.hz;
115
+ const z1 = b.z + b.hz;
116
+ const xz = (p: number[]): [number, number] => [p[0] ?? 0, p[2] ?? 0];
117
+ const xy = (p: number[]): [number, number] => [p[0] ?? 0, p[1] ?? 0];
118
+ const zy = (p: number[]): [number, number] => [p[2] ?? 0, p[1] ?? 0];
119
+ quad([x0, h, z1], [x1, h, z1], [x1, h, z0], [x0, h, z0], [0, 1, 0], xz);
120
+ quad([x0, 0, z1], [x1, 0, z1], [x1, h, z1], [x0, h, z1], [0, 0, 1], xy);
121
+ quad([x1, 0, z0], [x0, 0, z0], [x0, h, z0], [x1, h, z0], [0, 0, -1], xy);
122
+ quad([x1, 0, z1], [x1, 0, z0], [x1, h, z0], [x1, h, z1], [1, 0, 0], zy);
123
+ quad([x0, 0, z0], [x0, 0, z1], [x0, h, z1], [x0, h, z0], [-1, 0, 0], zy);
124
+ }
125
+ const g = new BufferGeometry();
126
+ g.setAttribute("position", new BufferAttribute(new Float32Array(pos), 3));
127
+ g.setAttribute("normal", new BufferAttribute(new Float32Array(nor), 3));
128
+ g.setAttribute("uv", new BufferAttribute(new Float32Array(uv), 2));
129
+ g.setIndex(idx);
130
+ g.computeBoundingSphere();
131
+ return g;
132
+ }
133
+
134
+ /**
135
+ * The x-ray cut: wall fragments near the line from the camera to your
136
+ * wickbearer (and in front of them) are not drawn, so a wall south of you
137
+ * never hides you. Works on the node material every backend draws, and the
138
+ * art styles carry it over (they copy opacity nodes and the alpha test).
139
+ */
140
+ export class WallCut {
141
+ readonly camera = uniform(new Vector3(0, 20, 20));
142
+ readonly hero = uniform(new Vector3(0, 1, 0));
143
+ /** Radius of the cut, m. */
144
+ readonly radius = uniform(1.6);
145
+
146
+ /** A wall material with the cut in it. */
147
+ material(color: number, map: Texture | null, roughness: number): MeshStandardNodeMaterial {
148
+ const m = new MeshStandardNodeMaterial({ color, roughness, flatShading: true });
149
+ if (map) m.map = map;
150
+ const c = this.camera;
151
+ const seg = this.hero.sub(c);
152
+ const p = positionWorld;
153
+ const t = clamp(dot(p.sub(c), seg).div(dot(seg, seg)), 0, 1);
154
+ const d = length(p.sub(c.add(seg.mul(t))));
155
+ // Cut: close to the sight line, before the hero, and above the wall's foot (the base still outlines the room).
156
+ const cut = d.lessThan(this.radius).and(t.lessThan(0.97)).and(p.y.greaterThan(0.12));
157
+ m.opacityNode = select(cut, float(0), float(1));
158
+ m.alphaTest = 0.5;
159
+ return m;
160
+ }
161
+
162
+ update(camera: Vector3, hero: Vector3): void {
163
+ this.camera.value.copy(camera);
164
+ this.hero.value.set(hero.x, hero.y + 0.9, hero.z);
165
+ }
166
+ }
167
+
168
+ interface FloorPiece {
169
+ readonly box: WallBox;
170
+ readonly material: MeshStandardMaterial;
171
+ lit: number;
172
+ seen: boolean;
173
+ }
174
+
175
+ export class CryptLevel {
176
+ readonly group = new Group();
177
+ /** Where candles burn ([x, y, z]): the scene drifts embers off the near ones. */
178
+ readonly flames: [number, number, number][] = [];
179
+ private readonly floors: FloorPiece[] = [];
180
+ readonly cut = new WallCut();
181
+
182
+ constructor(
183
+ walls: readonly WallBox[],
184
+ floors: readonly WallBox[],
185
+ half: number,
186
+ surface: ((kind: "floor" | "wall" | "stone" | "ground") => Texture | null) | null,
187
+ ) {
188
+ // Rock under everything: what the camera sees past the walls.
189
+ const rock = new Mesh(new PlaneGeometry(half * 2 + 60, half * 2 + 60), new MeshStandardMaterial({ color: 0x0b0a0c, roughness: 1 }));
190
+ rock.rotation.x = -Math.PI / 2;
191
+ rock.position.y = -0.02;
192
+ rock.userData.surface = "rock";
193
+ this.group.add(rock);
194
+
195
+ // Floors: one mesh per room or corridor, so each can lift out of the dark on its own.
196
+ const floorMap = surface?.("floor") ?? flagstones();
197
+ const ftile = (floorMap.userData as { tile?: number }).tile ?? 4;
198
+ for (const f of floors) {
199
+ const map = floorMap.clone();
200
+ map.needsUpdate = true;
201
+ map.repeat.set((f.hx * 2) / ftile, (f.hz * 2) / ftile);
202
+ map.offset.set(((f.x - f.hx) / ftile) % 1, ((f.z - f.hz) / ftile) % 1);
203
+ const material = new MeshStandardMaterial({ map, roughness: 0.95, color: new Color(UNSEEN, UNSEEN, UNSEEN) });
204
+ const m = new Mesh(new PlaneGeometry(f.hx * 2, f.hz * 2), material);
205
+ m.rotation.x = -Math.PI / 2;
206
+ m.position.set(f.x, 0.005, f.z);
207
+ m.receiveShadow = true;
208
+ m.userData.surface = "floor";
209
+ this.group.add(m);
210
+ this.floors.push({ box: f, material, lit: UNSEEN, seen: false });
211
+ }
212
+
213
+ // Walls: one merged mesh with a darker cap, so the plan reads from above.
214
+ const wallMap = surface?.("wall") ?? masonry();
215
+ wallMap.wrapS = wallMap.wrapT = RepeatWrapping;
216
+ const wtile = (wallMap.userData as { tile?: number }).tile ?? 2;
217
+ const wall = new Mesh(boxesGeometry(walls, WALL_DRAW_HEIGHT, wtile), this.cut.material(0x9a8f84, wallMap, 0.92));
218
+ wall.castShadow = true;
219
+ wall.receiveShadow = true;
220
+ wall.userData.surface = "wall";
221
+ this.group.add(wall);
222
+ const capMat = this.cut.material(0x2b2420, null, 1);
223
+ const cap = new Mesh(boxesGeometry(walls.map((b) => ({ x: b.x, z: b.z, hx: b.hx + 0.04, hz: b.hz + 0.04 })), 0.08, 1), capMat);
224
+ cap.position.y = WALL_DRAW_HEIGHT;
225
+ this.group.add(cap);
226
+
227
+ // Candles: a sconce flame at each room's inner corners (rooms are the floors big enough to be rooms).
228
+ const flameMat = new MeshBasicMaterial({ color: new Color(0xffb04a).multiplyScalar(5) });
229
+ const flameGeo = new SphereGeometry(0.16, 8, 6);
230
+ const sconceGeo = new CylinderGeometry(0.22, 0.12, 0.24, 6);
231
+ const sconceMat = new MeshStandardMaterial({ color: 0x2a2420, roughness: 0.6, metalness: 0.4 });
232
+ for (const f of floors) {
233
+ if (f.hx < 4 || f.hz < 4) continue;
234
+ for (const [sx, sz] of [
235
+ [-1, -1],
236
+ [1, -1],
237
+ [-1, 1],
238
+ [1, 1],
239
+ ] as const) {
240
+ // A candle in an iron sconce, set into the corner.
241
+ const x = f.x + sx * (f.hx - 0.3);
242
+ const z = f.z + sz * (f.hz - 0.3);
243
+ const sconce = new Mesh(sconceGeo, sconceMat);
244
+ sconce.position.set(x, SCONCE_HEIGHT - 0.12, z);
245
+ this.group.add(sconce);
246
+ const flame = new Mesh(flameGeo, flameMat);
247
+ flame.position.set(x, SCONCE_HEIGHT + 0.1, z);
248
+ this.group.add(flame);
249
+ this.flames.push([x, SCONCE_HEIGHT + 0.15, z]);
250
+ }
251
+ }
252
+ }
253
+
254
+ /** Lift the dark off every floor a wickbearer stands on (or within 2 m of). */
255
+ update(dt: number, players: readonly (readonly [number, number])[]): void {
256
+ for (const f of this.floors) {
257
+ if (!f.seen) {
258
+ for (const [x, z] of players) {
259
+ if (Math.abs(x - f.box.x) <= f.box.hx + 2 && Math.abs(z - f.box.z) <= f.box.hz + 2) f.seen = true;
260
+ }
261
+ }
262
+ const goal = f.seen ? LIT : UNSEEN;
263
+ if (Math.abs(goal - f.lit) < 0.002) continue;
264
+ f.lit += (goal - f.lit) * Math.min(1, dt * 2.5);
265
+ // Candle-warm: a touch less blue than red.
266
+ f.material.color.setRGB(f.lit, f.lit * 0.93, f.lit * 0.84);
267
+ }
268
+ }
269
+
270
+ /** A fresh descent: every room goes back into the dark. */
271
+ reset(): void {
272
+ for (const f of this.floors) f.seen = false;
273
+ }
274
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Client-side physics dressing on engine-sim's PhysicsWorld (Rapier, the
3
+ * same build the server runs): chunks that fly off killed enemies and
4
+ * bounce off the floor, walls and pillars, and a scatter of rocks and stumps
5
+ * the keepers shove around and novas blast away.
6
+ *
7
+ * Presentation only. None of it feeds back into the authoritative sim, so
8
+ * it never needs to match the server or other players exactly; every
9
+ * client drives the props from the same interpolated keeper positions, so
10
+ * what they see agrees closely anyway.
11
+ */
12
+ import { Color, InstancedMesh, Matrix4, MeshStandardMaterial, Quaternion, Vector3 } from "three/webgpu";
13
+ import type { BufferGeometry, Material, Scene } from "three/webgpu";
14
+ import { CylinderGeometry, DodecahedronGeometry } from "three/webgpu";
15
+ import { Layer, createPhysics } from "@netforge/engine-sim";
16
+ import type { BodyId, PhysicsWorld, ResolvedSimConfig } from "@netforge/engine-sim";
17
+
18
+ const HZ = 60;
19
+ const MAX_CHUNKS = 220;
20
+ const MAX_STEPS = 4;
21
+ const CHUNK_LIFE = 2.2;
22
+
23
+ interface Chunk {
24
+ id: BodyId;
25
+ life: number;
26
+ max: number;
27
+ size: number;
28
+ color: Color;
29
+ }
30
+
31
+ interface Prop {
32
+ id: BodyId;
33
+ barrel: boolean;
34
+ /** Last speed, for impact thuds. */
35
+ speed: number;
36
+ }
37
+
38
+ /** Seeded PRNG so every client scatters the props in the same places. */
39
+ function rng(seed: number): () => number {
40
+ let s = seed >>> 0;
41
+ return () => {
42
+ s = (s + 0x6d2b79f5) >>> 0;
43
+ let t = s;
44
+ t = Math.imul(t ^ (t >>> 15), t | 1);
45
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
46
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
47
+ };
48
+ }
49
+
50
+ /** An art style's own drawing of a debris shape (the voxel style's cube chunks, rocks and stumps); null keeps the primitive. */
51
+ export type DebrisShape = (name: "chunk" | "rock" | "stump") => { readonly geometry: BufferGeometry; readonly material: Material } | null;
52
+
53
+ function instanced(geometry: BufferGeometry, color: number, capacity: number, emissive = 0x000000, styled?: ReturnType<DebrisShape>): InstancedMesh {
54
+ const material = styled?.material ?? new MeshStandardMaterial({ color, roughness: 0.75, flatShading: true, emissive, emissiveIntensity: 0.6 });
55
+ const mesh = new InstancedMesh(styled?.geometry ?? geometry, material, capacity);
56
+ mesh.count = 0;
57
+ mesh.frustumCulled = false;
58
+ mesh.castShadow = true;
59
+ mesh.receiveShadow = true;
60
+ const white = new Color(1, 1, 1);
61
+ for (let i = 0; i < capacity; i++) mesh.setColorAt(i, white);
62
+ return mesh;
63
+ }
64
+
65
+ export class PhysicsDressing {
66
+ readonly physics: PhysicsWorld;
67
+ private readonly chunks: Chunk[] = [];
68
+ private readonly props: Prop[] = [];
69
+ private readonly keepers = new Map<number, BodyId>();
70
+ private readonly chunkMesh: InstancedMesh;
71
+ private readonly crateMesh: InstancedMesh;
72
+ private readonly barrelMesh: InstancedMesh;
73
+ private acc = 0;
74
+ private readonly m = new Matrix4();
75
+ private readonly q = new Quaternion();
76
+ private readonly p = new Vector3();
77
+ private readonly s = new Vector3();
78
+ private readonly random = rng(0xe3b1);
79
+ /** Impacts this frame (world position and speed), for thud sounds. */
80
+ readonly impacts: { x: number; y: number; z: number; speed: number }[] = [];
81
+ /** Effects fired so far (playtest checks). */
82
+ readonly counts = { chunks: 0, blasts: 0 };
83
+
84
+ constructor(scene: Scene, config: ResolvedSimConfig, opts: { props?: boolean; shape?: DebrisShape } = {}) {
85
+ this.physics = createPhysics({ gravity: [0, -24, 0], tickRate: HZ });
86
+ const P = this.physics;
87
+ const h = config.arena.halfExtent;
88
+ const wall = config.arena.wallHeight;
89
+ P.addStaticBox([h + 1, 0.5, h + 1], [0, -0.5, 0], { friction: 0.8, layer: Layer.Level });
90
+ P.addStaticBox([h + 1, wall, 0.5], [0, wall, h + 0.5], { layer: Layer.Level });
91
+ P.addStaticBox([h + 1, wall, 0.5], [0, wall, -h - 0.5], { layer: Layer.Level });
92
+ P.addStaticBox([0.5, wall, h + 1], [h + 0.5, wall, 0], { layer: Layer.Level });
93
+ P.addStaticBox([0.5, wall, h + 1], [-h - 0.5, wall, 0], { layer: Layer.Level });
94
+ for (const pl of config.arena.pillars) {
95
+ P.addBody({ type: "static", shape: { cylinder: [pl.height / 2, pl.radius] }, position: [pl.x, pl.height / 2, pl.z], layer: Layer.Level, friction: 0.6 });
96
+ }
97
+ // The crypt's walls stop rubble and chunks too.
98
+ for (const w of config.arena.walls ?? []) {
99
+ P.addStaticBox([w.hx, wall / 2, w.hz], [w.x, wall / 2, w.z], { layer: Layer.Level, friction: 0.6 });
100
+ }
101
+
102
+ // Props: crates and barrels around the pillars and in the open, the same scatter on every client.
103
+ const props = rng(0x51f7);
104
+ const spots: [number, number][] = [];
105
+ for (const pl of config.arena.pillars) {
106
+ for (let k = 0; k < 3; k++) {
107
+ const a = props() * Math.PI * 2;
108
+ const d = pl.radius + 1.2 + props() * 1.5;
109
+ spots.push([pl.x + Math.cos(a) * d, pl.z + Math.sin(a) * d]);
110
+ }
111
+ }
112
+ // Loose rubble in the rooms (never in the rock between them): two or three per room, off its middle.
113
+ for (const f of config.arena.floors ?? []) {
114
+ if (f.hx < 4 || f.hz < 4) continue;
115
+ const n = 2 + Math.floor(props() * 2);
116
+ for (let k = 0; k < n; k++) {
117
+ const x = f.x + (props() * 2 - 1) * (f.hx - 1.2);
118
+ const z = f.z + (props() * 2 - 1) * (f.hz - 1.2);
119
+ if (Math.abs(x - f.x) < 2.5 && Math.abs(z - f.z) < 2.5) continue;
120
+ spots.push([x, z]);
121
+ }
122
+ }
123
+ if (opts.props === false) spots.length = 0;
124
+ for (const [x, z] of spots) {
125
+ const barrel = props() < 0.4;
126
+ const yaw = props() * Math.PI;
127
+ const id = P.addBody({
128
+ type: "dynamic",
129
+ shape: barrel ? { cylinder: [0.45, 0.35] } : { box: [0.4, 0.4, 0.4] },
130
+ position: [x, barrel ? 0.45 : 0.4, z],
131
+ rotation: [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)],
132
+ density: 40,
133
+ friction: 0.7,
134
+ restitution: 0.15,
135
+ linearDamping: 0.4,
136
+ angularDamping: 0.6,
137
+ layer: Layer.Prop,
138
+ mask: Layer.Level | Layer.Prop | Layer.Player | Layer.Debris,
139
+ });
140
+ this.props.push({ id, barrel, speed: 0 });
141
+ }
142
+
143
+ this.chunkMesh = instanced(new DodecahedronGeometry(1, 0), 0xffffff, MAX_CHUNKS, 0, opts.shape?.("chunk"));
144
+ // One family with the field's stone and the Nature Kit stumps: loose rocks and cut stumps, not crates and barrels.
145
+ this.crateMesh = instanced(new DodecahedronGeometry(0.5), 0x5e5246, Math.max(1, this.props.length), 0, opts.shape?.("rock"));
146
+ this.barrelMesh = instanced(new CylinderGeometry(0.33, 0.38, 0.9, 7), 0x4e3a28, Math.max(1, this.props.length), 0, opts.shape?.("stump"));
147
+ scene.add(this.chunkMesh, this.crateMesh, this.barrelMesh);
148
+ }
149
+
150
+ /** Keepers push props: one kinematic capsule per player, moved to where it is drawn. */
151
+ keeper(owner: number, x: number, y: number, z: number, up: boolean): void {
152
+ let id = this.keepers.get(owner);
153
+ if (id === undefined) {
154
+ id = this.physics.addBody({ type: "kinematic", shape: { capsule: [0.5, 0.4] }, position: [x, y, z], layer: Layer.Player, mask: Layer.Prop | Layer.Debris });
155
+ this.keepers.set(owner, id);
156
+ }
157
+ this.physics.setKinematicTarget(id, [x, up ? y : -5, z]);
158
+ }
159
+
160
+ /** Forget keepers not seen this frame. */
161
+ keepOnly(owners: ReadonlySet<number>): void {
162
+ for (const [owner, id] of this.keepers) {
163
+ if (owners.has(owner)) continue;
164
+ this.physics.removeBody(id);
165
+ this.keepers.delete(owner);
166
+ }
167
+ }
168
+
169
+ /** Chunks off a killed enemy, thrown along the push direction. */
170
+ shatter(x: number, z: number, radius: number, color: Color, count: number, dirX = 0, dirZ = 0): void {
171
+ const P = this.physics;
172
+ this.counts.chunks += count;
173
+ for (let i = 0; i < count; i++) {
174
+ if (this.chunks.length >= MAX_CHUNKS) this.retire(0);
175
+ const size = radius * (0.22 + this.random() * 0.25);
176
+ const a = this.random() * Math.PI * 2;
177
+ const sp = 2 + this.random() * 3.5;
178
+ const id = P.addBody({
179
+ type: "dynamic",
180
+ shape: { box: [size, size, size] },
181
+ position: [x + Math.cos(a) * radius * 0.5, radius + 0.2 + this.random() * radius, z + Math.sin(a) * radius * 0.5],
182
+ density: 8,
183
+ friction: 0.6,
184
+ restitution: 0.35,
185
+ angularDamping: 0.3,
186
+ linearVelocity: [Math.cos(a) * sp + dirX * 5, 3.5 + this.random() * 4, Math.sin(a) * sp + dirZ * 5],
187
+ angularVelocity: [(this.random() - 0.5) * 18, (this.random() - 0.5) * 18, (this.random() - 0.5) * 18],
188
+ layer: Layer.Debris,
189
+ mask: Layer.Level | Layer.Prop,
190
+ });
191
+ const life = CHUNK_LIFE * (0.7 + this.random() * 0.6);
192
+ this.chunks.push({ id, life, max: life, size, color });
193
+ }
194
+ }
195
+
196
+ /** A blast (nova) shoves props and chunks out of `radius`. */
197
+ blast(x: number, z: number, radius: number, strength: number): void {
198
+ const P = this.physics;
199
+ this.counts.blasts++;
200
+ const hits = P.overlap({ cylinder: [1.5, radius] }, [x, 1.5, z], { mask: Layer.Prop | Layer.Debris });
201
+ for (const id of hits) P.applyKnockback(id, [x, 0, z], strength, { lift: strength * 0.35 });
202
+ }
203
+
204
+ private retire(i: number): void {
205
+ const c = this.chunks[i];
206
+ if (!c) return;
207
+ this.physics.removeBody(c.id);
208
+ this.chunks[i] = this.chunks[this.chunks.length - 1] as Chunk;
209
+ this.chunks.pop();
210
+ }
211
+
212
+ update(dt: number): void {
213
+ this.impacts.length = 0;
214
+ this.acc = Math.min(this.acc + dt, MAX_STEPS / HZ);
215
+ while (this.acc >= 1 / HZ) {
216
+ this.acc -= 1 / HZ;
217
+ this.physics.step();
218
+ }
219
+ for (let i = this.chunks.length - 1; i >= 0; i--) {
220
+ const c = this.chunks[i] as Chunk;
221
+ c.life -= dt;
222
+ if (c.life <= 0) this.retire(i);
223
+ }
224
+ // Draw.
225
+ const P = this.physics;
226
+ let n = 0;
227
+ for (const c of this.chunks) {
228
+ const [x, y, z] = P.position(c.id);
229
+ const [qx, qy, qz, qw] = P.rotation(c.id);
230
+ const k = Math.min(1, c.life / (c.max * 0.35));
231
+ this.p.set(x, y, z);
232
+ this.q.set(qx, qy, qz, qw);
233
+ this.s.setScalar(c.size * 1.25 * k);
234
+ this.m.compose(this.p, this.q, this.s);
235
+ this.chunkMesh.setMatrixAt(n, this.m);
236
+ this.chunkMesh.setColorAt(n, c.color);
237
+ n++;
238
+ }
239
+ this.chunkMesh.count = n;
240
+ this.chunkMesh.instanceMatrix.needsUpdate = true;
241
+ if (this.chunkMesh.instanceColor) this.chunkMesh.instanceColor.needsUpdate = true;
242
+ let crates = 0;
243
+ let barrels = 0;
244
+ this.s.setScalar(1);
245
+ for (const prop of this.props) {
246
+ const [x, y, z] = P.position(prop.id);
247
+ const [vx, vy, vz] = P.velocity(prop.id);
248
+ const speed = Math.hypot(vx, vy, vz);
249
+ if (prop.speed - speed > 3) this.impacts.push({ x, y, z, speed: prop.speed - speed });
250
+ prop.speed = speed;
251
+ const [qx, qy, qz, qw] = P.rotation(prop.id);
252
+ this.p.set(x, y, z);
253
+ this.q.set(qx, qy, qz, qw);
254
+ this.m.compose(this.p, this.q, this.s);
255
+ if (prop.barrel) this.barrelMesh.setMatrixAt(barrels++, this.m);
256
+ else this.crateMesh.setMatrixAt(crates++, this.m);
257
+ }
258
+ this.crateMesh.count = crates;
259
+ this.barrelMesh.count = barrels;
260
+ this.crateMesh.instanceMatrix.needsUpdate = true;
261
+ this.barrelMesh.instanceMatrix.needsUpdate = true;
262
+ }
263
+
264
+ get liveChunks(): number {
265
+ return this.chunks.length;
266
+ }
267
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Library models the CRYPTWICK scene draws on its own (template machinery,
3
+ * not game content). The crypt draws its walls, floors and candles itself.
4
+ * The build copies these with the models game.ts uses.
5
+ */
6
+
7
+ /** Enemy models are drawn about this many hit radii tall. */
8
+ export const ENEMY_HEIGHT_PER_RADIUS = 3.2;
9
+ /** Keepers are drawn this tall, m. */
10
+ export const KEEPER_HEIGHT = 1.8;
11
+
12
+ /** The crypt draws its own walls, floors and candles: no library dressing of its own. */
13
+ export const SCENE_MODELS: readonly string[] = [];