@adatechnology/game-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.
package/dist/index.js ADDED
@@ -0,0 +1,1068 @@
1
+ // src/catalog.ts
2
+ var TERRAIN_MATERIALS = [
3
+ {
4
+ id: "turf",
5
+ debris: "clods",
6
+ rimShare: 0.25,
7
+ reposeDegrees: 55,
8
+ color: [0.09, 0.2, 0.04],
9
+ pattern: "grass",
10
+ hits: {
11
+ ballistic: { specificEnergy: 3e6, aspect: 0.6 },
12
+ blunt: { specificEnergy: 2e6, aspect: 0.2, thresholdJoules: 60 },
13
+ slash: { specificEnergy: 3e5, aspect: 0.5 },
14
+ explosive: { specificEnergy: 3e6, aspect: 0.45 }
15
+ },
16
+ fire: { maxScorch: 1, scorchPerSecond: 0.45, flammable: true }
17
+ },
18
+ {
19
+ id: "soil",
20
+ debris: "clods",
21
+ rimShare: 0.35,
22
+ reposeDegrees: 40,
23
+ color: [0.2, 0.12, 0.06],
24
+ pattern: "soil",
25
+ hits: {
26
+ ballistic: { specificEnergy: 4e6, aspect: 0.6 },
27
+ blunt: { specificEnergy: 2e6, aspect: 0.2, thresholdJoules: 60 },
28
+ slash: { specificEnergy: 3e5, aspect: 0.5 },
29
+ explosive: { specificEnergy: 4e6, aspect: 0.45 }
30
+ },
31
+ fire: { maxScorch: 0.6, scorchPerSecond: 0.15 }
32
+ },
33
+ {
34
+ id: "sand",
35
+ debris: "dust",
36
+ rimShare: 0.5,
37
+ reposeDegrees: 33,
38
+ color: [0.62, 0.5, 0.3],
39
+ pattern: "sand",
40
+ hits: {
41
+ ballistic: { specificEnergy: 25e5, aspect: 0.5 },
42
+ blunt: { specificEnergy: 1e6, aspect: 0.2, thresholdJoules: 30 },
43
+ slash: { specificEnergy: 15e4, aspect: 0.5 },
44
+ explosive: { specificEnergy: 3e6, aspect: 0.35 }
45
+ },
46
+ fire: { maxScorch: 0.2, scorchPerSecond: 0.05 }
47
+ },
48
+ {
49
+ id: "gravel",
50
+ debris: "chips",
51
+ rimShare: 0.3,
52
+ reposeDegrees: 38,
53
+ color: [0.27, 0.25, 0.22],
54
+ pattern: "stone",
55
+ hits: {
56
+ ballistic: { specificEnergy: 8e6, aspect: 0.4 },
57
+ blunt: { specificEnergy: 8e6, aspect: 0.2, thresholdJoules: 200 },
58
+ slash: { specificEnergy: 3e7, aspect: 0.3, thresholdJoules: 400 },
59
+ explosive: { specificEnergy: 1e7, aspect: 0.35 }
60
+ },
61
+ fire: { maxScorch: 0.3, scorchPerSecond: 0.06 }
62
+ },
63
+ {
64
+ id: "asphalt",
65
+ debris: "chips",
66
+ color: [0.05, 0.05, 0.055],
67
+ pattern: "asphalt",
68
+ hits: {
69
+ ballistic: { specificEnergy: 3e7, aspect: 0.2, minRadius: 0.03 },
70
+ blunt: { specificEnergy: 1e8, aspect: 0.1, thresholdJoules: 400 },
71
+ slash: { specificEnergy: 3e8, aspect: 0.2, thresholdJoules: 400 },
72
+ explosive: { specificEnergy: 2e8, aspect: 0.12 }
73
+ },
74
+ fire: { maxScorch: 0.85, scorchPerSecond: 0.1, burnsAway: 5e-5 }
75
+ },
76
+ {
77
+ id: "concrete",
78
+ debris: "chips",
79
+ color: [0.33, 0.33, 0.31],
80
+ pattern: "concrete",
81
+ hits: {
82
+ ballistic: { specificEnergy: 15e7, aspect: 0.14, minRadius: 0.018 },
83
+ blunt: { specificEnergy: 3e8, aspect: 0.08, thresholdJoules: 800 },
84
+ slash: { specificEnergy: 1e9, aspect: 0.2, thresholdJoules: 800 },
85
+ explosive: { specificEnergy: 27e8, aspect: 0.12 }
86
+ },
87
+ fire: { maxScorch: 0.45, scorchPerSecond: 0.08 }
88
+ },
89
+ {
90
+ id: "mortar",
91
+ debris: "chips",
92
+ color: [0.42, 0.4, 0.36],
93
+ pattern: "plain",
94
+ hits: {
95
+ ballistic: { specificEnergy: 4e7, aspect: 0.2 },
96
+ blunt: { specificEnergy: 8e7, aspect: 0.1, thresholdJoules: 300 },
97
+ slash: { specificEnergy: 3e8, aspect: 0.2, thresholdJoules: 300 },
98
+ explosive: { specificEnergy: 8e8, aspect: 0.14 }
99
+ },
100
+ fire: { maxScorch: 0.45, scorchPerSecond: 0.08 }
101
+ },
102
+ {
103
+ id: "ceramic",
104
+ debris: "shards",
105
+ color: [0.72, 0.7, 0.66],
106
+ pattern: "tile",
107
+ hits: {
108
+ ballistic: { specificEnergy: 8e6, aspect: 0.1, minRadius: 0.05 },
109
+ blunt: { specificEnergy: 1e7, aspect: 0.08, thresholdJoules: 80, minRadius: 0.06 },
110
+ slash: { specificEnergy: 2e8, aspect: 0.1, thresholdJoules: 200, minRadius: 0.03 },
111
+ explosive: { specificEnergy: 1e8, aspect: 0.03, minRadius: 0.12 }
112
+ },
113
+ fire: { maxScorch: 0.5, scorchPerSecond: 0.08 }
114
+ },
115
+ {
116
+ id: "wood_hard",
117
+ debris: "splinters",
118
+ color: [0.3, 0.16, 0.07],
119
+ pattern: "planks",
120
+ hits: {
121
+ ballistic: { specificEnergy: 25e6, aspect: 0.35, minRadius: 0.012 },
122
+ blunt: { specificEnergy: 8e7, aspect: 0.1, thresholdJoules: 120 },
123
+ slash: { specificEnergy: 15e6, aspect: 0.4, thresholdJoules: 15 },
124
+ explosive: { specificEnergy: 2e8, aspect: 0.1, minRadius: 0.1 }
125
+ },
126
+ fire: { maxScorch: 1, scorchPerSecond: 0.2, burnsAway: 12e-4, flammable: true }
127
+ },
128
+ {
129
+ id: "stone",
130
+ debris: "chips",
131
+ color: [0.23, 0.22, 0.2],
132
+ pattern: "stone",
133
+ hits: {
134
+ ballistic: { specificEnergy: 25e7, aspect: 0.12, minRadius: 0.015 },
135
+ blunt: { specificEnergy: 5e8, aspect: 0.08, thresholdJoules: 1500 },
136
+ slash: { specificEnergy: 2e9, aspect: 0.2, thresholdJoules: 1500 },
137
+ explosive: { specificEnergy: 4e9, aspect: 0.14 }
138
+ },
139
+ fire: { maxScorch: 0.4, scorchPerSecond: 0.06 }
140
+ }
141
+ ];
142
+ var TERRAIN_PROFILES = [
143
+ { id: "lawn", strata: [{ material: "turf", thickness: 0.05 }, { material: "soil", thickness: 1.5 }, { material: "stone", thickness: 0.5 }] },
144
+ { id: "dirt", strata: [{ material: "soil", thickness: 1.8 }, { material: "stone", thickness: 0.5 }] },
145
+ { id: "sand", strata: [{ material: "sand", thickness: 2.5 }] },
146
+ { id: "road", strata: [{ material: "asphalt", thickness: 0.05 }, { material: "gravel", thickness: 0.25 }, { material: "soil", thickness: 1.5 }] },
147
+ { id: "concrete_slab", strata: [{ material: "concrete", thickness: 0.12, reinforcement: { material: "steel", cover: 0.03, spacing: 0.15, resistance: 4 } }] },
148
+ { id: "suspended_slab", drop: 3, strata: [{ material: "concrete", thickness: 0.12, reinforcement: { material: "steel", cover: 0.03, spacing: 0.15, resistance: 4 } }] },
149
+ { id: "tile_floor", strata: [{ material: "ceramic", thickness: 8e-3 }, { material: "mortar", thickness: 0.03 }, { material: "concrete", thickness: 0.1, reinforcement: { material: "steel", cover: 0.03, spacing: 0.15, resistance: 4 } }] },
150
+ { id: "wood_floor", strata: [{ material: "wood_hard", thickness: 0.022 }, { material: "mortar", thickness: 0.03 }, { material: "concrete", thickness: 0.1, reinforcement: { material: "steel", cover: 0.03, spacing: 0.15, resistance: 4 } }] },
151
+ { id: "stone_paving", strata: [{ material: "stone", thickness: 0.08 }, { material: "gravel", thickness: 0.2 }, { material: "soil", thickness: 1.2 }] }
152
+ ];
153
+ var DEFAULT_BAR_YIELD_JOULES = 24e5;
154
+ var DEFAULT_PROFILE = "concrete_slab";
155
+
156
+ // src/debris.ts
157
+ var REST_SPEED = 0.6;
158
+ var RESTITUTION = 0.25;
159
+ var FRICTION = 0.55;
160
+ var MAX_BOUNCES = 2;
161
+ var REST_GAP = 0.01;
162
+ function createDebrisStore(max) {
163
+ const list = [];
164
+ let serial = 0;
165
+ function makeRoom() {
166
+ if (list.length < max()) return;
167
+ let at = 0;
168
+ let oldest = Infinity;
169
+ let oldestResting = -1;
170
+ let restingBorn = Infinity;
171
+ list.forEach((piece, index) => {
172
+ if (piece.born < oldest) {
173
+ oldest = piece.born;
174
+ at = index;
175
+ }
176
+ if (piece.atRest && piece.born < restingBorn) {
177
+ restingBorn = piece.born;
178
+ oldestResting = index;
179
+ }
180
+ });
181
+ list.splice(oldestResting >= 0 ? oldestResting : at, 1);
182
+ }
183
+ return {
184
+ list,
185
+ throwPiece(spec) {
186
+ makeRoom();
187
+ list.push({ ...spec, id: ++serial, born: serial, atRest: false, bounces: 0 });
188
+ },
189
+ update(dt, world) {
190
+ for (let i = list.length - 1; i >= 0; i--) {
191
+ const piece = list[i];
192
+ if (piece.atRest) {
193
+ const under = world.heightAt(piece.position[0], piece.position[2]) + piece.radius * 0.6;
194
+ if (piece.position[1] > under + REST_GAP) piece.atRest = false;
195
+ else if (piece.position[1] < under - REST_GAP) piece.position[1] = under;
196
+ if (piece.atRest) continue;
197
+ }
198
+ const { position, velocity } = piece;
199
+ velocity[1] -= world.gravity * dt;
200
+ position[0] += velocity[0] * dt;
201
+ position[1] += velocity[1] * dt;
202
+ position[2] += velocity[2] * dt;
203
+ const ground = world.heightAt(position[0], position[2]) + piece.radius * 0.6;
204
+ if (position[1] > ground) continue;
205
+ position[1] = ground;
206
+ const speed = Math.hypot(velocity[0], velocity[1], velocity[2]);
207
+ if (speed < REST_SPEED || piece.bounces >= MAX_BOUNCES) {
208
+ piece.atRest = true;
209
+ if (world.settle(piece)) list.splice(i, 1);
210
+ continue;
211
+ }
212
+ piece.bounces++;
213
+ velocity[1] = Math.abs(velocity[1]) * RESTITUTION;
214
+ velocity[0] *= FRICTION;
215
+ velocity[2] *= FRICTION;
216
+ }
217
+ },
218
+ clear() {
219
+ list.length = 0;
220
+ }
221
+ };
222
+ }
223
+
224
+ // src/marks.ts
225
+ var SAME_HOLE = 0.75;
226
+ function createMarkStore(budget) {
227
+ const list = [];
228
+ const areas = /* @__PURE__ */ new Map();
229
+ let serial = 0;
230
+ let version = 0;
231
+ const areaKey = (x, z) => Math.floor(x / budget.markAreaSize) * 100003 + Math.floor(z / budget.markAreaSize);
232
+ function detach(mark) {
233
+ const key = areaKey(mark.x, mark.z);
234
+ const area = areas.get(key);
235
+ if (area) {
236
+ const at2 = area.indexOf(mark);
237
+ if (at2 >= 0) area.splice(at2, 1);
238
+ if (area.length === 0) areas.delete(key);
239
+ }
240
+ const at = list.indexOf(mark);
241
+ if (at >= 0) list.splice(at, 1);
242
+ version++;
243
+ }
244
+ function attach(mark) {
245
+ const key = areaKey(mark.x, mark.z);
246
+ const area = areas.get(key);
247
+ if (area) area.push(mark);
248
+ else areas.set(key, [mark]);
249
+ list.push(mark);
250
+ version++;
251
+ }
252
+ function mergeClosestPair(area) {
253
+ let bestA = area[0];
254
+ let bestB = area[1];
255
+ let best = Infinity;
256
+ for (let i = 0; i < area.length; i++) {
257
+ for (let j = i + 1; j < area.length; j++) {
258
+ const distance = Math.hypot(area[i].x - area[j].x, area[i].z - area[j].z);
259
+ if (distance < best) {
260
+ best = distance;
261
+ bestA = area[i];
262
+ bestB = area[j];
263
+ }
264
+ }
265
+ }
266
+ const weight = bestA.radius ** 2 + bestB.radius ** 2;
267
+ const shareB = weight > 0 ? bestB.radius ** 2 / weight : 0.5;
268
+ detach(bestB);
269
+ bestA.x += (bestB.x - bestA.x) * shareB;
270
+ bestA.z += (bestB.z - bestA.z) * shareB;
271
+ bestA.radius = Math.sqrt(weight);
272
+ bestA.depth = Math.max(bestA.depth, bestB.depth);
273
+ bestA.count += bestB.count;
274
+ version++;
275
+ }
276
+ return {
277
+ list,
278
+ add(spec) {
279
+ const mark = { ...spec, id: ++serial, count: 1, born: serial };
280
+ attach(mark);
281
+ const area = areas.get(areaKey(mark.x, mark.z));
282
+ if (area.length > budget.maxMarksPerArea) mergeClosestPair(area);
283
+ if (list.length > budget.maxMarks) detach(list.reduce((oldest, candidate) => candidate.born < oldest.born ? candidate : oldest));
284
+ return mark;
285
+ },
286
+ near(x, z, radius) {
287
+ let found;
288
+ let best = Infinity;
289
+ const reach = Math.ceil((radius + 0.5) / budget.markAreaSize);
290
+ const baseX = Math.floor(x / budget.markAreaSize);
291
+ const baseZ = Math.floor(z / budget.markAreaSize);
292
+ for (let dx = -reach; dx <= reach; dx++) {
293
+ for (let dz = -reach; dz <= reach; dz++) {
294
+ for (const mark of areas.get((baseX + dx) * 100003 + baseZ + dz) ?? []) {
295
+ const distance = Math.hypot(mark.x - x, mark.z - z);
296
+ if (distance < best && distance < Math.max(mark.radius, radius) * SAME_HOLE) {
297
+ best = distance;
298
+ found = mark;
299
+ }
300
+ }
301
+ }
302
+ }
303
+ return found;
304
+ },
305
+ depthAt(x, z) {
306
+ let depth = 0;
307
+ for (const mark of areas.get(areaKey(x, z)) ?? []) {
308
+ const distance = Math.hypot(mark.x - x, mark.z - z);
309
+ if (distance < mark.radius) depth = Math.max(depth, mark.depth * (1 - (distance / mark.radius) ** 2));
310
+ }
311
+ return depth;
312
+ },
313
+ remove: detach,
314
+ removeInside(x, z, radius) {
315
+ for (const mark of [...list]) if (Math.hypot(mark.x - x, mark.z - z) + mark.radius * 0.5 < radius) detach(mark);
316
+ },
317
+ clear() {
318
+ list.length = 0;
319
+ areas.clear();
320
+ version++;
321
+ },
322
+ load(marks) {
323
+ this.clear();
324
+ for (const mark of marks) {
325
+ attach({ ...mark });
326
+ serial = Math.max(serial, mark.id);
327
+ }
328
+ },
329
+ get version() {
330
+ return version;
331
+ }
332
+ };
333
+ }
334
+
335
+ // src/random.ts
336
+ function createRandom(seed) {
337
+ let state = seed | 0;
338
+ return () => {
339
+ state = state + 1831565813 | 0;
340
+ let mixed = Math.imul(state ^ state >>> 15, 1 | state);
341
+ mixed = mixed + Math.imul(mixed ^ mixed >>> 7, 61 | mixed) ^ mixed;
342
+ return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
343
+ };
344
+ }
345
+
346
+ // src/terrain.ts
347
+ var DEFAULT_TERRAIN_BUDGET = {
348
+ markAreaSize: 2,
349
+ maxMarksPerArea: 24,
350
+ maxMarks: 800,
351
+ maxDirtyChunksPerFrame: 6,
352
+ maxDebris: 400,
353
+ maxDebrisPerBlow: 36,
354
+ maxSlideCellsPerFrame: 2500,
355
+ maxBurningCells: 300
356
+ };
357
+ var GRAVITY = 9.81;
358
+ var FINE_CELLS = 1.5;
359
+ var FINE_DEPTH_CELLS = 0.4;
360
+ var RIGID_WIDENING = 2.5;
361
+ var COLLAPSE_DEPTH_SHARE = 0.75;
362
+ var COLLAPSE_SPREAD_BEND = 0.5;
363
+ var RIM_REACH = 1.45;
364
+ var FLAME_MINIMUM = 200;
365
+ var FLAME_REACH = 0.6;
366
+ var BLAST_HEIGHT_REACH = 0.35;
367
+ var THROW_SHARE = 0.02;
368
+ var FIRE_CELL = 0.5;
369
+ var FIRE_SECONDS = 7;
370
+ var FIRE_SPREAD_PER_SECOND = 0.35;
371
+ var SLIDE_SHARE = 0.4;
372
+ var SLIDE_EPSILON = 2e-3;
373
+ var PIECE_RADIUS = { clods: 0.045, chips: 0.022, shards: 0.028, splinters: 0.02, dust: 0.012 };
374
+ var MAX_PIECE_SPEED = { explosive: 14, ballistic: 5, blunt: 2.5, slash: 1.5 };
375
+ var clampIndex = (value, max) => Math.max(0, Math.min(max, value));
376
+ var sparse = (array) => {
377
+ const entries = [];
378
+ array.forEach((value, index) => {
379
+ if (value !== 0) entries.push([index, value]);
380
+ });
381
+ return entries;
382
+ };
383
+ var fillSparse = (array, entries) => {
384
+ array.fill(0);
385
+ for (const [index, value] of entries) array[index] = value;
386
+ };
387
+ function stratumAtDepth(profile, depth) {
388
+ let top = 0;
389
+ for (let index2 = 0; index2 < profile.strata.length; index2++) {
390
+ const stratum2 = profile.strata[index2];
391
+ if (depth < top + stratum2.thickness - 1e-6) return { index: index2, stratum: stratum2, top };
392
+ top += stratum2.thickness;
393
+ }
394
+ const index = profile.strata.length - 1;
395
+ const stratum = profile.strata[index];
396
+ return { index, stratum, top: top - stratum.thickness };
397
+ }
398
+ var totalThickness = (profile) => profile.strata.reduce((sum, stratum) => sum + stratum.thickness, 0);
399
+ function createTerrain(options) {
400
+ const [x0, z0, x1, z1] = options.bounds;
401
+ const cellSize = options.cellSize ?? 0.1;
402
+ const chunkCells = options.chunkCells ?? 32;
403
+ const columns = Math.max(1, Math.ceil((x1 - x0) / cellSize));
404
+ const rows = Math.max(1, Math.ceil((z1 - z0) / cellSize));
405
+ const stride = columns + 1;
406
+ const vertexCount = stride * (rows + 1);
407
+ const chunksX = Math.ceil(columns / chunkCells);
408
+ const chunksZ = Math.ceil(rows / chunkCells);
409
+ const budget = { ...DEFAULT_TERRAIN_BUDGET, ...options.budget };
410
+ const gravity = options.gravity ?? GRAVITY;
411
+ const materials = new Map([...TERRAIN_MATERIALS, ...options.materials ?? []].map((material) => [material.id, material]));
412
+ const profileMap = new Map([...TERRAIN_PROFILES, ...options.profiles ?? []].map((profile) => [profile.id, profile]));
413
+ const profileList = [...profileMap.values()];
414
+ for (const profile of profileList) {
415
+ for (const stratum of profile.strata) if (!materials.has(stratum.material)) throw new Error(`game-terrain: profile "${profile.id}" uses material "${stratum.material}", which has no ground behaviour`);
416
+ }
417
+ const indexOfProfile = new Map(profileList.map((profile, index) => [profile.id, index]));
418
+ const maxDepthOf = profileList.map(totalThickness);
419
+ const base = new Float32Array(vertexCount);
420
+ if (options.baseHeight) {
421
+ for (let row = 0; row <= rows; row++) for (let column = 0; column <= columns; column++) base[row * stride + column] = options.baseHeight(x0 + column * cellSize, z0 + row * cellSize);
422
+ }
423
+ const dug = new Float32Array(vertexCount);
424
+ const loose = new Float32Array(vertexCount);
425
+ const scorch = new Float32Array(vertexCount);
426
+ const barBend = new Float32Array(vertexCount);
427
+ const profileIndex = new Uint8Array(vertexCount);
428
+ const touched = new Uint8Array(chunksX * chunksZ);
429
+ const mixed = new Uint8Array(chunksX * chunksZ);
430
+ const dirty = /* @__PURE__ */ new Set();
431
+ const marks = createMarkStore(budget);
432
+ const debrisStore = createDebrisStore(() => budget.maxDebris);
433
+ const sliding = /* @__PURE__ */ new Set();
434
+ const burning = /* @__PURE__ */ new Map();
435
+ const random = createRandom(options.seed ?? 1);
436
+ let currentLayout = options.layout ?? { surface: DEFAULT_PROFILE };
437
+ const deformListeners = /* @__PURE__ */ new Set();
438
+ const emitDeform = (x, z, radius) => {
439
+ for (const listener of deformListeners) listener({ x, z, radius });
440
+ };
441
+ const chunkOfVertex = (column, row) => Math.min(chunksZ - 1, Math.floor(row / chunkCells)) * chunksX + Math.min(chunksX - 1, Math.floor(column / chunkCells));
442
+ function touchVertex(column, row) {
443
+ const chunk2 = chunkOfVertex(column, row);
444
+ touched[chunk2] = 1;
445
+ dirty.add(chunk2);
446
+ if (column % chunkCells === 0 && column > 0) markChunk(chunk2 - 1);
447
+ if (row % chunkCells === 0 && row > 0) markChunk(chunk2 - chunksX);
448
+ if (column % chunkCells === 0 && row % chunkCells === 0 && column > 0 && row > 0) markChunk(chunk2 - chunksX - 1);
449
+ }
450
+ function markChunk(chunk2) {
451
+ touched[chunk2] = 1;
452
+ dirty.add(chunk2);
453
+ }
454
+ const profileId = (id) => indexOfProfile.get(id) ?? indexOfProfile.get(DEFAULT_PROFILE) ?? 0;
455
+ function applyLayout(layout) {
456
+ currentLayout = layout;
457
+ profileIndex.fill(profileId(layout.surface));
458
+ for (const zone of layout.zones ?? []) {
459
+ const index = profileId(zone.surface);
460
+ const [zx0, zz0, zx1, zz1] = zone.rect;
461
+ const column0 = clampIndex(Math.round((zx0 - x0) / cellSize), columns);
462
+ const column1 = clampIndex(Math.round((zx1 - x0) / cellSize), columns);
463
+ const row0 = clampIndex(Math.round((zz0 - z0) / cellSize), rows);
464
+ const row1 = clampIndex(Math.round((zz1 - z0) / cellSize), rows);
465
+ for (let row = row0; row <= row1; row++) profileIndex.fill(index, row * stride + column0, row * stride + column1 + 1);
466
+ }
467
+ for (let chunkZ = 0; chunkZ < chunksZ; chunkZ++) {
468
+ for (let chunkX = 0; chunkX < chunksX; chunkX++) {
469
+ const chunk2 = chunkZ * chunksX + chunkX;
470
+ const first = profileIndex[chunkZ * chunkCells * stride + chunkX * chunkCells];
471
+ let isMixed = 0;
472
+ const lastRow = Math.min(rows, (chunkZ + 1) * chunkCells);
473
+ const lastColumn = Math.min(columns, (chunkX + 1) * chunkCells);
474
+ for (let row = chunkZ * chunkCells; row <= lastRow && !isMixed; row++) {
475
+ for (let column = chunkX * chunkCells; column <= lastColumn; column++) {
476
+ if (profileIndex[row * stride + column] !== first) {
477
+ isMixed = 1;
478
+ break;
479
+ }
480
+ }
481
+ }
482
+ mixed[chunk2] = isMixed;
483
+ dirty.add(chunk2);
484
+ }
485
+ }
486
+ }
487
+ const vertexHeight = (column, row) => {
488
+ const index = row * stride + column;
489
+ return base[index] - dug[index] + loose[index];
490
+ };
491
+ const contains = (x, z) => x >= x0 && x <= x1 && z >= z0 && z <= z1;
492
+ function heightAt(x, z) {
493
+ if (!contains(x, z)) return options.baseHeight?.(x, z) ?? 0;
494
+ const fx = Math.max(0, Math.min(columns - 1e-6, (x - x0) / cellSize));
495
+ const fz = Math.max(0, Math.min(rows - 1e-6, (z - z0) / cellSize));
496
+ const column = fx | 0;
497
+ const row = fz | 0;
498
+ const tx = fx - column;
499
+ const tz = fz - row;
500
+ const index = row * stride + column;
501
+ const h = (i) => base[i] - dug[i] + loose[i];
502
+ return (h(index) * (1 - tx) + h(index + 1) * tx) * (1 - tz) + (h(index + stride) * (1 - tx) + h(index + stride + 1) * tx) * tz;
503
+ }
504
+ const vertexOf = (x, z) => {
505
+ const column = clampIndex(Math.round((x - x0) / cellSize), columns);
506
+ const row = clampIndex(Math.round((z - z0) / cellSize), rows);
507
+ return { column, row, index: row * stride + column };
508
+ };
509
+ function vertexMaterial(column, row) {
510
+ const index = row * stride + column;
511
+ return materials.get(stratumAtDepth(profileList[profileIndex[index]], dug[index]).stratum.material);
512
+ }
513
+ function columnAt(x, z) {
514
+ const { index } = vertexOf(x, z);
515
+ const which = profileIndex[index];
516
+ const profile = profileList[which];
517
+ const depth = Math.min(maxDepthOf[which], dug[index] + marks.depthAt(x, z));
518
+ const layer = stratumAtDepth(profile, depth);
519
+ return { profile: profile.id, material: layer.stratum.material, stratum: layer.index, depth, remaining: maxDepthOf[which] - depth, scorch: scorch[index], loose: loose[index] };
520
+ }
521
+ function removeFromVertex(index, amount) {
522
+ const fromLoose = Math.min(loose[index], amount);
523
+ loose[index] = loose[index] - fromLoose;
524
+ const fromColumn = Math.min(maxDepthOf[profileIndex[index]] - dug[index], amount - fromLoose);
525
+ if (fromColumn > 0) dug[index] = dug[index] + fromColumn;
526
+ return fromLoose + Math.max(0, fromColumn);
527
+ }
528
+ function addToVertex(index, amount, fillsColumn) {
529
+ const filled = fillsColumn ? Math.min(dug[index], amount) : 0;
530
+ dug[index] = dug[index] - filled;
531
+ loose[index] = loose[index] + amount - filled;
532
+ }
533
+ const isSoftExposed = (index) => materials.get(stratumAtDepth(profileList[profileIndex[index]], dug[index]).stratum.material).reposeDegrees !== void 0;
534
+ function stampBowl(x, z, radius, depth, rimVolume) {
535
+ const reach = radius * RIM_REACH;
536
+ const column0 = clampIndex(Math.floor((x - reach - x0) / cellSize), columns);
537
+ const column1 = clampIndex(Math.ceil((x + reach - x0) / cellSize), columns);
538
+ const row0 = clampIndex(Math.floor((z - reach - z0) / cellSize), rows);
539
+ const row1 = clampIndex(Math.ceil((z + reach - z0) / cellSize), rows);
540
+ const phaseA = random() * 6.283;
541
+ const phaseB = random() * 6.283;
542
+ const shapeOf = (px, pz) => {
543
+ const angle = Math.atan2(pz - z, px - x);
544
+ return Math.hypot(px - x, pz - z) / (radius * (1 + 0.12 * Math.sin(angle * 5 + phaseA) + 0.06 * Math.sin(angle * 11 + phaseB)));
545
+ };
546
+ let rimWeight = 0;
547
+ if (rimVolume > 0) {
548
+ for (let row = row0; row <= row1; row++) {
549
+ for (let column = column0; column <= column1; column++) {
550
+ const q = shapeOf(x0 + column * cellSize, z0 + row * cellSize);
551
+ if (q >= 1 && q < RIM_REACH) rimWeight += Math.sin((q - 1) / (RIM_REACH - 1) * Math.PI);
552
+ }
553
+ }
554
+ }
555
+ const cellArea = cellSize * cellSize;
556
+ for (let row = row0; row <= row1; row++) {
557
+ for (let column = column0; column <= column1; column++) {
558
+ const q = shapeOf(x0 + column * cellSize, z0 + row * cellSize);
559
+ const index = row * stride + column;
560
+ if (q < 1) {
561
+ removeFromVertex(index, depth * (1 - q * q) * (0.92 + random() * 0.16));
562
+ sliding.add(index);
563
+ touchVertex(column, row);
564
+ } else if (q < RIM_REACH && rimWeight > 0) {
565
+ loose[index] = loose[index] + rimVolume * Math.sin((q - 1) / (RIM_REACH - 1) * Math.PI) / (rimWeight * cellArea);
566
+ sliding.add(index);
567
+ touchVertex(column, row);
568
+ }
569
+ }
570
+ }
571
+ marks.removeInside(x, z, radius);
572
+ emitDeform(x, z, reach);
573
+ }
574
+ function throwPieces(spec) {
575
+ const radius = PIECE_RADIUS[spec.material.debris];
576
+ const pieceVolume = 4 / 3 * Math.PI * radius ** 3;
577
+ const count = Math.min(spec.quota, Math.max(1, Math.round(spec.volume / pieceVolume)));
578
+ const mass = Math.max(1e-3, spec.volume * 1500);
579
+ const launch = Math.min(MAX_PIECE_SPEED[spec.blow], Math.sqrt(2 * spec.energy * THROW_SHARE / mass));
580
+ for (let i = 0; i < count; i++) {
581
+ const angle = random() * 6.283;
582
+ const out = spec.bowlRadius * Math.sqrt(random()) * 0.8;
583
+ const speed = launch * (0.5 + random() * 0.7);
584
+ const spread = 0.35 + random() * 0.65;
585
+ const backX = spec.direction ? -(spec.direction[0] ?? 0) * 0.4 : 0;
586
+ const backZ = spec.direction ? -(spec.direction[2] ?? 0) * 0.4 : 0;
587
+ debrisStore.throwPiece({
588
+ material: spec.material.id,
589
+ kind: spec.material.debris,
590
+ radius: radius * (0.6 + random() * 0.9),
591
+ position: [spec.x + Math.cos(angle) * out, spec.y + 0.03, spec.z + Math.sin(angle) * out],
592
+ velocity: [(Math.cos(angle) * spread + backX) * speed, (0.6 + random() * 0.7) * speed, (Math.sin(angle) * spread + backZ) * speed],
593
+ spin: [(random() - 0.5) * 14, (random() - 0.5) * 14, (random() - 0.5) * 14]
594
+ });
595
+ }
596
+ }
597
+ function strike(blow, spec) {
598
+ const { x, z } = spec;
599
+ const start = columnAt(x, z);
600
+ const profile = profileMap.get(start.profile);
601
+ const removed = [];
602
+ const point = [x, heightAt(x, z), z];
603
+ let energy = spec.energy;
604
+ let depth = start.depth;
605
+ let radius = 0;
606
+ let carved = 0;
607
+ const firstMaterial = materials.get(start.material);
608
+ if (start.loose <= 0) {
609
+ for (; ; ) {
610
+ const layer = stratumAtDepth(profile, depth);
611
+ const bottom = layer.top + layer.stratum.thickness;
612
+ const room = bottom - depth;
613
+ if (room <= 1e-6) break;
614
+ const material = materials.get(layer.stratum.material);
615
+ const response = material.hits[blow];
616
+ if (!response || energy < (response.thresholdJoules ?? 0)) break;
617
+ const bars = layer.stratum.reinforcement;
618
+ const pastCover = bars !== void 0 && depth - layer.top >= bars.cover - 1e-6;
619
+ const specificEnergy = response.specificEnergy * (pastCover ? bars.resistance : 1);
620
+ const limit = bars && !pastCover ? Math.min(room, layer.top + bars.cover - depth) : room;
621
+ const volume = energy / specificEnergy;
622
+ const wanted = Math.cbrt(2 * volume / (Math.PI * response.aspect));
623
+ const wantedDepth = wanted * response.aspect;
624
+ const takenDepth = Math.min(wantedDepth, limit);
625
+ const takenRadius = takenDepth < wantedDepth ? takenDepth / response.aspect : wanted;
626
+ const takenVolume = Math.PI * takenRadius * takenRadius * takenDepth / 2;
627
+ energy -= takenVolume * specificEnergy;
628
+ depth += takenDepth;
629
+ carved += takenDepth;
630
+ radius = Math.max(radius, takenRadius, response.minRadius ?? 0, spec.contactRadius ?? 0);
631
+ const last = removed[removed.length - 1];
632
+ if (last?.material === material.id) last.volume += takenVolume;
633
+ else removed.push({ material: material.id, volume: takenVolume });
634
+ if (energy <= 1e-6 || takenDepth >= wantedDepth - 1e-9) break;
635
+ }
636
+ } else {
637
+ const response = firstMaterial.hits[blow];
638
+ if (response && energy >= (response.thresholdJoules ?? 0)) {
639
+ const volume = energy / response.specificEnergy;
640
+ radius = Math.cbrt(2 * volume / (Math.PI * response.aspect));
641
+ carved = radius * response.aspect;
642
+ removed.push({ material: firstMaterial.id, volume });
643
+ }
644
+ }
645
+ const collapsed = loadBars(blow, spec.energy, x, z, profile);
646
+ if (radius <= 0) return { point, radius: 0, depth: 0, totalDepth: columnAt(x, z).depth, removed: [], exposed: start.material, atLimit: start.remaining <= 1e-6, rebarExposed: rebarShowing(profile, start.depth), collapsed, fine: true };
647
+ if (firstMaterial.reposeDegrees === void 0 && start.loose <= 0) radius *= 1 + RIGID_WIDENING * (start.depth / maxDepthOfProfile(profile));
648
+ const volumeTotal = removed.reduce((sum, entry) => sum + entry.volume, 0);
649
+ const fine = radius < cellSize * FINE_CELLS;
650
+ if (fine) depositMark({ x, z, radius, depth: carved, blow, profile, cap: maxDepthOfProfile(profile) });
651
+ else {
652
+ const rimShare = materials.get(removed[0].material).rimShare ?? 0;
653
+ stampBowl(x, z, radius, carved, volumeTotal * rimShare);
654
+ }
655
+ let quota = budget.maxDebrisPerBlow;
656
+ for (const entry of removed) {
657
+ const material = materials.get(entry.material);
658
+ const thrown = entry.volume * (1 - (fine ? 0 : material.rimShare ?? 0));
659
+ if (thrown <= 0 || quota <= 0) continue;
660
+ const share = Math.max(1, Math.round(quota * entry.volume / volumeTotal));
661
+ throwPieces({ x, y: point[1], z, volume: thrown, material, energy: spec.energy * (entry.volume / volumeTotal), direction: spec.direction, quota: share, bowlRadius: radius, blow });
662
+ quota -= share;
663
+ }
664
+ const after = columnAt(x, z);
665
+ return { point, radius, depth: carved, totalDepth: after.depth, removed, exposed: after.material, atLimit: after.remaining <= 1e-4, rebarExposed: rebarShowing(profile, after.depth), collapsed, fine };
666
+ }
667
+ function loadBars(blow, energy, x, z, profile) {
668
+ const bars = profile.strata.find((stratum) => stratum.reinforcement);
669
+ if (!bars?.reinforcement) return false;
670
+ const response = materials.get(bars.material).hits[blow];
671
+ if (!response || energy < (response.thresholdJoules ?? 0)) return false;
672
+ const yieldJoules = bars.reinforcement.yieldJoules ?? DEFAULT_BAR_YIELD_JOULES;
673
+ let cover = bars.reinforcement.cover;
674
+ for (const stratum of profile.strata) {
675
+ if (stratum === bars) break;
676
+ cover += stratum.thickness;
677
+ }
678
+ const reach = Math.max(cellSize * FINE_CELLS, Math.cbrt(2 * energy / response.specificEnergy / (Math.PI * response.aspect)) * (1 + RIGID_WIDENING));
679
+ const column0 = clampIndex(Math.floor((x - reach - x0) / cellSize), columns);
680
+ const column1 = clampIndex(Math.ceil((x + reach - x0) / cellSize), columns);
681
+ const row0 = clampIndex(Math.floor((z - reach - z0) / cellSize), rows);
682
+ const row1 = clampIndex(Math.ceil((z + reach - z0) / cellSize), rows);
683
+ const limit = maxDepthOfProfile(profile);
684
+ let collapsed = false;
685
+ for (let row = row0; row <= row1; row++) {
686
+ for (let column = column0; column <= column1; column++) {
687
+ const index = row * stride + column;
688
+ if (dug[index] < cover) continue;
689
+ const q = Math.hypot(x0 + column * cellSize - x, z0 + row * cellSize - z) / reach;
690
+ if (q >= 1) continue;
691
+ barBend[index] = barBend[index] + energy / yieldJoules * (1 - q * q);
692
+ touchVertex(column, row);
693
+ if (barBend[index] >= 1 && profile.drop !== void 0 && dug[index] >= limit * COLLAPSE_DEPTH_SHARE && dug[index] < limit + profile.drop) {
694
+ dug[index] = limit + profile.drop;
695
+ collapsed = true;
696
+ }
697
+ }
698
+ }
699
+ if (collapsed) emitDeform(x, z, reach);
700
+ if (collapsed && profile.drop !== void 0) {
701
+ for (let row = row0; row <= row1; row++) {
702
+ for (let column = column0; column <= column1; column++) {
703
+ const index = row * stride + column;
704
+ if (barBend[index] >= COLLAPSE_SPREAD_BEND && dug[index] < limit + profile.drop) {
705
+ dug[index] = limit + profile.drop;
706
+ touchVertex(column, row);
707
+ }
708
+ }
709
+ }
710
+ }
711
+ return collapsed;
712
+ }
713
+ const maxDepthOfProfile = (profile) => maxDepthOf[indexOfProfile.get(profile.id)];
714
+ function rebarShowing(profile, depth) {
715
+ const layer = stratumAtDepth(profile, depth);
716
+ return layer.stratum.reinforcement !== void 0 && depth - layer.top >= layer.stratum.reinforcement.cover - 1e-6;
717
+ }
718
+ function depositMark(spec) {
719
+ const { x, z, radius, depth, blow, profile, cap } = spec;
720
+ const dugHere = dug[vertexOf(x, z).index];
721
+ const existing = marks.near(x, z, radius);
722
+ let mark;
723
+ if (existing) {
724
+ existing.radius = Math.hypot(existing.radius, radius);
725
+ existing.depth = Math.min(cap - dugHere, existing.depth + depth);
726
+ existing.count++;
727
+ existing.blow = blow;
728
+ mark = existing;
729
+ } else {
730
+ mark = marks.add({ x, z, radius, depth: Math.min(cap - dugHere, depth), blow, material: "" });
731
+ }
732
+ mark.material = stratumAtDepth(profile, dugHere + mark.depth - 1e-6).stratum.material;
733
+ if (mark.radius >= cellSize * FINE_CELLS || mark.depth >= cellSize * FINE_DEPTH_CELLS) {
734
+ const bowlRadius = Math.max(mark.radius, cellSize * FINE_CELLS);
735
+ const bowlDepth = mark.depth;
736
+ marks.remove(mark);
737
+ stampBowl(mark.x, mark.z, bowlRadius, bowlDepth, 0);
738
+ }
739
+ }
740
+ function burn(spec) {
741
+ if (spec.temperature < FLAME_MINIMUM) return;
742
+ const column0 = clampIndex(Math.floor((spec.x - spec.radius - x0) / cellSize), columns);
743
+ const column1 = clampIndex(Math.ceil((spec.x + spec.radius - x0) / cellSize), columns);
744
+ const row0 = clampIndex(Math.floor((spec.z - spec.radius - z0) / cellSize), rows);
745
+ const row1 = clampIndex(Math.ceil((spec.z + spec.radius - z0) / cellSize), rows);
746
+ for (let row = row0; row <= row1; row++) {
747
+ for (let column = column0; column <= column1; column++) {
748
+ if (Math.hypot(x0 + column * cellSize - spec.x, z0 + row * cellSize - spec.z) > spec.radius) continue;
749
+ burnVertex(column, row, spec.seconds);
750
+ }
751
+ }
752
+ }
753
+ function burnVertex(column, row, seconds) {
754
+ const index = row * stride + column;
755
+ const material = vertexMaterial(column, row);
756
+ const response = material.fire;
757
+ if (!response) return;
758
+ const before = scorch[index];
759
+ scorch[index] = Math.max(before, Math.min(response.maxScorch, before + response.scorchPerSecond * seconds));
760
+ if (response.burnsAway) removeFromVertex(index, response.burnsAway * seconds);
761
+ if (scorch[index] !== before || response.burnsAway) touchVertex(column, row);
762
+ if (response.flammable) ignite(x0 + column * cellSize, z0 + row * cellSize);
763
+ }
764
+ const fireKey = (x, z) => Math.floor((z - z0) / FIRE_CELL) * 65536 + Math.floor((x - x0) / FIRE_CELL);
765
+ function ignite(x, z) {
766
+ const key = fireKey(x, z);
767
+ if (burning.has(key) || burning.size >= budget.maxBurningCells) return;
768
+ const index = vertexOf(x, z).index;
769
+ if (scorch[index] >= 1) return;
770
+ burning.set(key, FIRE_SECONDS);
771
+ }
772
+ function updateFire(dt) {
773
+ for (const [key, remaining] of [...burning]) {
774
+ const cellRow = Math.floor(key / 65536);
775
+ const cellColumn = key - cellRow * 65536;
776
+ const x = x0 + (cellColumn + 0.5) * FIRE_CELL;
777
+ const z = z0 + (cellRow + 0.5) * FIRE_CELL;
778
+ burn({ x, z, radius: FIRE_CELL * 0.75, temperature: 600, seconds: dt });
779
+ if (random() < FIRE_SPREAD_PER_SECOND * dt) {
780
+ const angle = Math.floor(random() * 4) * (Math.PI / 2);
781
+ const nextX = x + Math.cos(angle) * FIRE_CELL;
782
+ const nextZ = z + Math.sin(angle) * FIRE_CELL;
783
+ if (contains(nextX, nextZ)) {
784
+ const { column, row, index } = vertexOf(nextX, nextZ);
785
+ if (vertexMaterial(column, row).fire?.flammable && scorch[index] < 1) ignite(nextX, nextZ);
786
+ }
787
+ }
788
+ if (remaining - dt <= 0) burning.delete(key);
789
+ else burning.set(key, remaining - dt);
790
+ }
791
+ }
792
+ function updateSlides() {
793
+ if (sliding.size === 0) return;
794
+ let budgetLeft = budget.maxSlideCellsPerFrame;
795
+ let minX = Infinity;
796
+ let minZ = Infinity;
797
+ let maxX = -Infinity;
798
+ let maxZ = -Infinity;
799
+ for (const index of sliding) {
800
+ if (budgetLeft-- <= 0) break;
801
+ sliding.delete(index);
802
+ const column = index % stride;
803
+ const row = (index - column) / stride;
804
+ if (!isSoftExposed(index)) continue;
805
+ const material = vertexMaterial(column, row);
806
+ const slope = Math.tan((material.reposeDegrees ?? 90) * Math.PI / 180) * cellSize;
807
+ const height = vertexHeight(column, row);
808
+ const neighbours = [[column - 1, row], [column + 1, row], [column, row - 1], [column, row + 1]];
809
+ for (const [nextColumn, nextRow] of neighbours) {
810
+ if (nextColumn < 0 || nextRow < 0 || nextColumn > columns || nextRow > rows) continue;
811
+ const excess = height - vertexHeight(nextColumn, nextRow) - slope;
812
+ if (excess <= SLIDE_EPSILON) continue;
813
+ const moved = removeFromVertex(index, excess * SLIDE_SHARE);
814
+ if (moved <= 0) continue;
815
+ const next = nextRow * stride + nextColumn;
816
+ addToVertex(next, moved, isSoftExposed(next));
817
+ sliding.add(index);
818
+ sliding.add(next);
819
+ touchVertex(column, row);
820
+ touchVertex(nextColumn, nextRow);
821
+ const px = x0 + column * cellSize;
822
+ const pz = z0 + row * cellSize;
823
+ minX = Math.min(minX, px);
824
+ maxX = Math.max(maxX, px);
825
+ minZ = Math.min(minZ, pz);
826
+ maxZ = Math.max(maxZ, pz);
827
+ }
828
+ }
829
+ if (minX <= maxX) emitDeform((minX + maxX) / 2, (minZ + maxZ) / 2, Math.hypot(maxX - minX, maxZ - minZ) / 2 + cellSize * 2);
830
+ }
831
+ function settle(piece) {
832
+ const material = materials.get(piece.material);
833
+ if (!material || material.reposeDegrees === void 0) return false;
834
+ if (!contains(piece.position[0], piece.position[2])) return true;
835
+ const { index, column, row } = vertexOf(piece.position[0], piece.position[2]);
836
+ addToVertex(index, 4 / 3 * Math.PI * piece.radius ** 3 / (cellSize * cellSize), false);
837
+ sliding.add(index);
838
+ touchVertex(column, row);
839
+ return true;
840
+ }
841
+ function applyHit(hit) {
842
+ const [x, y, z] = hit.point;
843
+ switch (hit.type) {
844
+ case "ballistic":
845
+ case "blunt":
846
+ case "slash": {
847
+ if (!hit.energy) return null;
848
+ const contactRadius = hit.type === "blunt" ? hit.radius : void 0;
849
+ return strike(hit.type, { x, z, energy: hit.energy, ...hit.direction ? { direction: hit.direction } : {}, ...contactRadius !== void 0 ? { contactRadius } : {} });
850
+ }
851
+ case "explosive": {
852
+ if (!hit.energy) return null;
853
+ const above = Math.max(0, y - heightAt(x, z));
854
+ const reach = (hit.radius ?? 1) * BLAST_HEIGHT_REACH;
855
+ if (above >= reach) return null;
856
+ return strike("explosive", { x, z, energy: hit.energy * (1 - above / reach) ** 2 });
857
+ }
858
+ case "fire":
859
+ case "heat":
860
+ if (y - heightAt(x, z) > (hit.radius ?? 0.2) + FLAME_REACH) return null;
861
+ burn({ x, z, radius: hit.radius ?? 0.2, temperature: hit.temperature ?? 0, seconds: hit.duration ?? 0.016 });
862
+ return null;
863
+ default:
864
+ return null;
865
+ }
866
+ }
867
+ function raycast(origin, direction, maxDistance) {
868
+ const [ox = 0, oy = 0, oz = 0] = origin;
869
+ const [dx = 0, dy = 0, dz = 0] = direction;
870
+ const step = cellSize * 0.75;
871
+ let previous = 0;
872
+ for (let t = 0; t <= maxDistance; t += step) {
873
+ const x = ox + dx * t;
874
+ const z = oz + dz * t;
875
+ if (contains(x, z) && oy + dy * t <= heightAt(x, z)) {
876
+ let low = previous;
877
+ let high = t;
878
+ for (let i = 0; i < 6; i++) {
879
+ const middle = (low + high) / 2;
880
+ if (oy + dy * middle <= heightAt(ox + dx * middle, oz + dz * middle)) high = middle;
881
+ else low = middle;
882
+ }
883
+ const px = ox + dx * high;
884
+ const pz = oz + dz * high;
885
+ const gx = heightAt(px + cellSize, pz) - heightAt(px - cellSize, pz);
886
+ const gz = heightAt(px, pz + cellSize) - heightAt(px, pz - cellSize);
887
+ const length = Math.hypot(gx, 2 * cellSize, gz);
888
+ return { point: [px, oy + dy * high, pz], distance: high, normal: [-gx / length, 2 * cellSize / length, -gz / length] };
889
+ }
890
+ previous = t;
891
+ }
892
+ return null;
893
+ }
894
+ function chunk(index) {
895
+ const chunkZ = Math.floor(index / chunksX);
896
+ const chunkX = index - chunkZ * chunksX;
897
+ return { index, column: chunkX * chunkCells, row: chunkZ * chunkCells, cells: chunkCells, pristine: !touched[index] && !mixed[index] && !options.baseHeight };
898
+ }
899
+ function reset() {
900
+ dug.fill(0);
901
+ loose.fill(0);
902
+ scorch.fill(0);
903
+ barBend.fill(0);
904
+ touched.fill(0);
905
+ marks.clear();
906
+ debrisStore.clear();
907
+ sliding.clear();
908
+ burning.clear();
909
+ applyLayout(currentLayout);
910
+ }
911
+ applyLayout(currentLayout);
912
+ return {
913
+ bounds: options.bounds,
914
+ cellSize,
915
+ columns,
916
+ rows,
917
+ stride,
918
+ chunkCells,
919
+ chunksX,
920
+ chunksZ,
921
+ budget,
922
+ dug,
923
+ loose,
924
+ scorch,
925
+ barBend,
926
+ profileIndex,
927
+ profileList,
928
+ material: (id) => {
929
+ const material = materials.get(id);
930
+ if (!material) throw new Error(`game-terrain: unknown material "${id}"`);
931
+ return material;
932
+ },
933
+ layout: () => currentLayout,
934
+ setLayout(layout) {
935
+ currentLayout = layout;
936
+ reset();
937
+ },
938
+ heightAt,
939
+ vertexHeight,
940
+ vertexMaterial,
941
+ columnAt,
942
+ contains,
943
+ raycast,
944
+ strike,
945
+ burn,
946
+ applyHit,
947
+ onDeform(listener) {
948
+ deformListeners.add(listener);
949
+ return () => deformListeners.delete(listener);
950
+ },
951
+ update(dt) {
952
+ debrisStore.update(dt, { heightAt, gravity, settle });
953
+ updateSlides();
954
+ updateFire(dt);
955
+ },
956
+ drainDirtyChunks(max = budget.maxDirtyChunksPerFrame) {
957
+ const taken = [];
958
+ for (const index of dirty) {
959
+ if (taken.length >= max) break;
960
+ dirty.delete(index);
961
+ taken.push(chunk(index));
962
+ }
963
+ return taken;
964
+ },
965
+ chunk,
966
+ marks: () => marks.list,
967
+ debris: () => debrisStore.list,
968
+ burningPoints: () => [...burning.keys()].map((key) => {
969
+ const cellRow = Math.floor(key / 65536);
970
+ const cellColumn = key - cellRow * 65536;
971
+ return [x0 + (cellColumn + 0.5) * FIRE_CELL, z0 + (cellRow + 0.5) * FIRE_CELL];
972
+ }),
973
+ get marksVersion() {
974
+ return marks.version;
975
+ },
976
+ stats: () => ({ marks: marks.list.length, debris: debrisStore.list.length, debrisMoving: debrisStore.list.filter((piece) => !piece.atRest).length, dirtyChunks: dirty.size, burning: burning.size }),
977
+ reset,
978
+ serialize() {
979
+ return { layout: currentLayout, dug: sparse(dug), loose: sparse(loose), scorch: sparse(scorch), barBend: sparse(barBend), marks: marks.list.map((mark) => ({ ...mark })) };
980
+ },
981
+ load(snapshot) {
982
+ currentLayout = snapshot.layout;
983
+ reset();
984
+ fillSparse(dug, snapshot.dug);
985
+ fillSparse(loose, snapshot.loose);
986
+ fillSparse(scorch, snapshot.scorch);
987
+ fillSparse(barBend, snapshot.barBend ?? []);
988
+ marks.load(snapshot.marks);
989
+ for (let chunkIndex = 0; chunkIndex < touched.length; chunkIndex++) markChunk(chunkIndex);
990
+ }
991
+ };
992
+ }
993
+
994
+ // src/impl.ts
995
+ var PART_ID = "ground";
996
+ var PART_DEPTH = 3;
997
+ var RAY_BACKOFF = 1;
998
+ function createTerrainImpl(terrain, options) {
999
+ const { id } = options;
1000
+ const [x0, z0, x1, z1] = terrain.bounds;
1001
+ const spec = () => ({
1002
+ name: options.name ?? "Ground",
1003
+ parts: [
1004
+ {
1005
+ id: PART_ID,
1006
+ material: terrain.columnAt((x0 + x1) / 2, (z0 + z1) / 2).material,
1007
+ shape: { kind: "box", size: [x1 - x0, PART_DEPTH, z1 - z0] },
1008
+ position: [(x0 + x1) / 2, -PART_DEPTH / 2, (z0 + z1) / 2],
1009
+ hp: 1e12,
1010
+ anchored: true,
1011
+ tags: ["terrain"]
1012
+ }
1013
+ ],
1014
+ joints: []
1015
+ });
1016
+ const resolve = (hit) => {
1017
+ if (hit.type !== "ballistic" && hit.type !== "blunt" && hit.type !== "slash") return hit;
1018
+ if (!hit.direction) return { ...hit, point: [hit.point[0], terrain.heightAt(hit.point[0], hit.point[2]), hit.point[2]] };
1019
+ const [dx, dy, dz] = hit.direction;
1020
+ const from = [hit.point[0] - dx * RAY_BACKOFF, hit.point[1] - dy * RAY_BACKOFF, hit.point[2] - dz * RAY_BACKOFF];
1021
+ const onGround = terrain.raycast(from, hit.direction, RAY_BACKOFF + PART_DEPTH);
1022
+ return onGround ? { ...hit, point: onGround.point } : hit;
1023
+ };
1024
+ return {
1025
+ id,
1026
+ ...options.name ? { name: options.name } : {},
1027
+ describe: spec,
1028
+ hit(hit) {
1029
+ const landed = resolve(hit);
1030
+ const result = terrain.applyHit(landed);
1031
+ if (!result || result.radius <= 0) return [];
1032
+ options.onBlow?.(result, landed);
1033
+ return [{ type: "damage", object: id, time: 0, part: PART_ID, amount: 0, hp: 1e12, damageType: hit.type, ...hit.source ? { source: hit.source } : {} }];
1034
+ },
1035
+ update(dt) {
1036
+ terrain.update(dt);
1037
+ return [];
1038
+ },
1039
+ state() {
1040
+ return {
1041
+ id,
1042
+ position: [0, 0, 0],
1043
+ rotation: [0, 0, 0, 1],
1044
+ parts: [],
1045
+ joints: [],
1046
+ groups: [[PART_ID]],
1047
+ destroyed: false
1048
+ };
1049
+ },
1050
+ reset: () => terrain.reset(),
1051
+ serialize: () => terrain.serialize(),
1052
+ load: (data) => terrain.load(data)
1053
+ };
1054
+ }
1055
+
1056
+ // src/types.ts
1057
+ var TERRAIN_PATTERNS = ["plain", "grass", "soil", "sand", "asphalt", "concrete", "tile", "planks", "stone"];
1058
+ export {
1059
+ DEFAULT_BAR_YIELD_JOULES,
1060
+ DEFAULT_PROFILE,
1061
+ DEFAULT_TERRAIN_BUDGET,
1062
+ TERRAIN_MATERIALS,
1063
+ TERRAIN_PATTERNS,
1064
+ TERRAIN_PROFILES,
1065
+ createTerrain,
1066
+ createTerrainImpl
1067
+ };
1068
+ //# sourceMappingURL=index.js.map