@lagless/create 0.0.44 → 0.0.48

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.
@@ -0,0 +1,707 @@
1
+ # 2D Map Generation
2
+
3
+ ## Overview
4
+
5
+ `@lagless/2d-map-generator` produces deterministic 2D maps from a seed using a feature pipeline. `@lagless/2d-map-renderer` renders those maps using Pixi.js. Together they provide procedural terrain, rivers, lakes, object placement, and rendering for top-down 2D games.
6
+
7
+ **Key properties:**
8
+ - **Deterministic** — same seed + same config = identical map on every client (uses `MathOps` trig, `ISeededRandom`)
9
+ - **Feature-based** — compose terrain, water, objects via independent features with automatic dependency resolution
10
+ - **Physics-integrated** — `createMapColliders()` converts placed objects into Rapier 2D rigid bodies
11
+ - **Render-ready** — `MapTerrainRenderer` and `MapObjectRenderer` output Pixi.js containers
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @lagless/2d-map-generator @lagless/2d-map-renderer
17
+ ```
18
+
19
+ Both packages are peer dependencies — they are NOT included by default. Add them when your game needs procedural map generation.
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ MapGenerator
25
+ ├── addFeature(feature, config) // register features
26
+ └── generate(random, collision) // run all features in dependency order
27
+
28
+ ├── BiomeFeature → BiomeOutput (color palette)
29
+ ├── ShoreFeature → ShoreOutput (island shore polygon)
30
+ ├── GrassFeature → GrassOutput (grass area polygon)
31
+ ├── RiverFeature → RiverOutput (river polygons)
32
+ ├── LakeFeature → LakeOutput (lake polygons)
33
+ ├── BridgeFeature → BridgeOutput (bridge placements)
34
+ ├── ObjectPlacementFeature → ObjectPlacementOutput (placed objects)
35
+ ├── GroundPatchFeature → GroundPatchOutput (ground patches)
36
+ └── PlacesFeature → PlacesOutput (named positions)
37
+ ```
38
+
39
+ Features declare dependencies via `requires`. The generator resolves them with topological sort — no manual ordering needed. You only add the features your game needs.
40
+
41
+ ## Integration Flow
42
+
43
+ The full integration spans three layers:
44
+
45
+ ```
46
+ 1. Simulation (runner constructor)
47
+ └── MapGenerator.generate() → IGeneratedMap
48
+ └── createMapColliders() → Rapier 2D physics bodies
49
+ └── capturePreStartState() → snapshot includes map colliders
50
+
51
+ 2. DI Bridge
52
+ └── MapData class registered via extraRegistrations
53
+ └── Systems access map data via DI constructor injection
54
+
55
+ 3. Client (React/Pixi.js)
56
+ └── MapTerrainRenderer.buildTerrain() → terrain container
57
+ └── MapObjectRenderer.build() → ground + canopy ParticleContainers
58
+ └── extractCanopyZones() + isInsideCanopyZone() → per-frame transparency
59
+ ```
60
+
61
+ ## Setting Up the Generator
62
+
63
+ ### Step 1: Define Object Types and Registry
64
+
65
+ Create a file in your simulation package (e.g., `map-config/objects.ts`):
66
+
67
+ ```typescript
68
+ import type { MapObjectDef, MapObjectRegistry } from '@lagless/2d-map-generator';
69
+ import { RenderLayer, ShapeType, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
70
+
71
+ export enum ObjectType { Tree = 0, Building = 1 }
72
+
73
+ const TREE: MapObjectDef = {
74
+ typeId: ObjectType.Tree,
75
+ colliders: [
76
+ { shape: { type: ShapeType.Circle, radius: 30 } },
77
+ // Sensor for canopy transparency zone (view-only, skipped by createMapColliders)
78
+ { shape: { type: ShapeType.Circle, radius: 128 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
79
+ ],
80
+ visuals: [
81
+ { texture: 'tree-trunk', layer: RenderLayer.Ground },
82
+ { texture: 'tree-foliage', layer: RenderLayer.Canopy },
83
+ ],
84
+ scaleRange: [0.1, 0.2],
85
+ // Include sensor radius in placement bounds (prevents canopy overlap)
86
+ includeSensorsInBounds: true,
87
+ // Optional: minimap display
88
+ mapDisplay: {
89
+ shapes: [
90
+ { collider: { type: ShapeType.Circle, radius: 30 }, color: 0x2d5a1e, scale: 1 },
91
+ ],
92
+ },
93
+ };
94
+
95
+ const BUILDING: MapObjectDef = {
96
+ typeId: ObjectType.Building,
97
+ colliders: [
98
+ { shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 } },
99
+ { shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
100
+ ],
101
+ visuals: [
102
+ { texture: 'building-floor', layer: RenderLayer.Ground },
103
+ { texture: 'building-roof', layer: RenderLayer.Canopy },
104
+ ],
105
+ scaleRange: [1, 1],
106
+ groundPatches: [
107
+ {
108
+ offset: { x: 0, y: 0 },
109
+ halfExtents: { x: 32, y: 22 },
110
+ color: 0x8b4513,
111
+ roughness: 0.5,
112
+ offsetDist: 2,
113
+ order: 0,
114
+ useAsMapShape: false,
115
+ },
116
+ ],
117
+ };
118
+
119
+ export const OBJECT_REGISTRY: MapObjectRegistry = new Map<number, MapObjectDef>([
120
+ [ObjectType.Tree, TREE],
121
+ [ObjectType.Building, BUILDING],
122
+ ]);
123
+ ```
124
+
125
+ ### Step 2: Create Map Generator Factory
126
+
127
+ Create `map-config/create-map-generator.ts`:
128
+
129
+ ```typescript
130
+ import {
131
+ MapGenerator, BiomeFeature, ShoreFeature, GrassFeature,
132
+ RiverFeature, LakeFeature, ObjectPlacementFeature,
133
+ PlacementKind, TerrainZone, STANDARD_BIOME,
134
+ } from '@lagless/2d-map-generator';
135
+ import { OBJECT_REGISTRY, ObjectType } from './objects.js';
136
+
137
+ export function createMapGenerator(): MapGenerator {
138
+ const generator = new MapGenerator({
139
+ baseWidth: 720,
140
+ baseHeight: 720,
141
+ scale: 1.0,
142
+ extension: 80,
143
+ gridSize: 16,
144
+ });
145
+
146
+ generator
147
+ .addFeature(new BiomeFeature(), STANDARD_BIOME)
148
+ .addFeature(new ShoreFeature(), { inset: 48, divisions: 12, variation: 4 })
149
+ .addFeature(new GrassFeature(), { inset: 18, variation: 3 })
150
+ .addFeature(new RiverFeature(), {
151
+ weights: [
152
+ { weight: 0.25, widths: [8, 4] },
153
+ { weight: 0.75, widths: [4] },
154
+ ],
155
+ subdivisionPasses: 5,
156
+ masks: [],
157
+ })
158
+ .addFeature(new LakeFeature(), {
159
+ lakes: [{ odds: 1.0, innerRad: 30, outerRad: 200, spawnBound: { pos: { x: 0.5, y: 0.5 }, rad: 300 } }],
160
+ })
161
+ .addFeature(new ObjectPlacementFeature(), {
162
+ registry: OBJECT_REGISTRY,
163
+ stages: [
164
+ { kind: PlacementKind.Density, typeId: ObjectType.Tree, density: 100, terrainZone: TerrainZone.Grass },
165
+ { kind: PlacementKind.Fixed, typeId: ObjectType.Building, count: 3, terrainZone: TerrainZone.Grass },
166
+ ],
167
+ });
168
+
169
+ return generator;
170
+ }
171
+ ```
172
+
173
+ ### Step 3: Create MapData DI Token
174
+
175
+ Create `map-data.ts` in your simulation:
176
+
177
+ ```typescript
178
+ import type { IGeneratedMap, MapObjectRegistry } from '@lagless/2d-map-generator';
179
+
180
+ export class MapData {
181
+ map!: IGeneratedMap;
182
+ registry!: MapObjectRegistry;
183
+ }
184
+ ```
185
+
186
+ ### Step 4: Extend Runner with Map Generation
187
+
188
+ Create a runner subclass that generates the map and creates physics colliders:
189
+
190
+ ```typescript
191
+ import { AbstractInputProvider, ECSConfig, PRNG } from '@lagless/core';
192
+ import { PhysicsConfig2d, PhysicsWorldManager2d, type RapierModule2d, RapierRigidBody2d } from '@lagless/physics2d';
193
+ import {
194
+ SpatialGridCollisionProvider, ObjectPlacementFeature,
195
+ createMapColliders, CANOPY_SENSOR_TAG,
196
+ } from '@lagless/2d-map-generator';
197
+ import type { ObjectPlacementOutput, MapPhysicsProvider } from '@lagless/2d-map-generator';
198
+ import { createMapGenerator } from './map-config/create-map-generator.js';
199
+ import { OBJECT_REGISTRY } from './map-config/objects.js';
200
+ import { MyGameRunner } from './schema/code-gen/MyGame.runner.js';
201
+ import { MyGameSystems } from './systems/index.js';
202
+ import { MyGameSignals } from './signals/index.js';
203
+ import { MapData } from './map-data.js';
204
+
205
+ function createPhysicsAdapter(wm: PhysicsWorldManager2d, rapier: RapierModule2d): MapPhysicsProvider {
206
+ return {
207
+ createFixedBody(x, y, rotation) {
208
+ const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
209
+ return wm.createBodyFromDesc(desc);
210
+ },
211
+ createCircleCollider(body, radius, ox, oy, isSensor, _tag, collisionGroup) {
212
+ let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
213
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
214
+ wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
215
+ },
216
+ createCuboidCollider(body, hw, hh, ox, oy, isSensor, _tag, collisionGroup) {
217
+ let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
218
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
219
+ wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
220
+ },
221
+ };
222
+ }
223
+
224
+ export class MyGameRunnerWithMap extends MyGameRunner {
225
+ constructor(
226
+ config: ECSConfig,
227
+ inputProvider: AbstractInputProvider,
228
+ rapier: RapierModule2d,
229
+ physicsConfig?: PhysicsConfig2d,
230
+ ) {
231
+ const mapData = new MapData();
232
+
233
+ super(
234
+ config, inputProvider,
235
+ MyGameSystems, MyGameSignals,
236
+ rapier, physicsConfig, undefined,
237
+ [[MapData, mapData]], // Register MapData for DI
238
+ );
239
+
240
+ // Generate map using ECS PRNG (available after super)
241
+ const prng = this.DIContainer.resolve(PRNG);
242
+ const generator = createMapGenerator();
243
+ const collision = new SpatialGridCollisionProvider(1024, 1024, 64);
244
+ const map = generator.generate(prng, collision);
245
+ mapData.map = map;
246
+ mapData.registry = OBJECT_REGISTRY;
247
+
248
+ // Create physics colliders for placed objects
249
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
250
+ if (placement) {
251
+ const physics = createPhysicsAdapter(this.PhysicsWorldManager, rapier);
252
+ createMapColliders(physics, placement.objects, mapData.registry, {
253
+ skipTags: [CANOPY_SENSOR_TAG],
254
+ });
255
+ }
256
+
257
+ // CRITICAL: re-capture initial state AFTER creating static bodies
258
+ // Without this, rollback to tick 0 restores a world without map colliders
259
+ this.Simulation.capturePreStartState();
260
+ }
261
+ }
262
+ ```
263
+
264
+ **Key points:**
265
+ - Map is generated in the runner constructor, BEFORE `start()` is called
266
+ - Uses ECS `PRNG` for determinism — same seed (from `serverHello.seed` in multiplayer) = same map
267
+ - `capturePreStartState()` MUST be called after creating static bodies — otherwise rollback loses them
268
+ - `MapData` is registered via `extraRegistrations` so systems can access map data through DI
269
+ - `skipTags: [CANOPY_SENSOR_TAG]` prevents creating physics bodies for view-only sensor colliders
270
+
271
+ ## MapGenerator Configuration
272
+
273
+ ```typescript
274
+ const generator = new MapGenerator({
275
+ baseWidth: 720, // map width before scaling (pixels)
276
+ baseHeight: 720, // map height before scaling (pixels)
277
+ scale: 1.0, // multiplier applied to base dimensions
278
+ extension: 80, // extra border around the map (water area)
279
+ gridSize: 16, // terrain grid cell size for rendering
280
+ });
281
+ ```
282
+
283
+ The actual map dimensions are `(baseWidth + 2 * extension) * scale` by `(baseHeight + 2 * extension) * scale`.
284
+
285
+ ## Object Definitions
286
+
287
+ ### MapObjectDef
288
+
289
+ | Field | Type | Description |
290
+ |-------|------|-------------|
291
+ | `typeId` | `number` | Unique identifier for this object type |
292
+ | `colliders` | `MapColliderDef[]` | Physics collision shapes |
293
+ | `visuals` | `MapVisualDef[]` | Texture references with render layer |
294
+ | `scaleRange` | `[min, max]` | Random scale range applied during placement |
295
+ | `orientations` | `number[]?` | Allowed rotation angles (default: `[0]`) |
296
+ | `groundPatches` | `GroundPatchDef[]?` | Ground patches drawn under the object |
297
+ | `mapDisplay` | `MapDisplayDef?` | Minimap display shapes |
298
+ | `children` | `ChildObjectDef[]?` | Child objects spawned relative to parent |
299
+ | `includeSensorsInBounds` | `boolean?` | Include sensor colliders in placement AABB (default: false) |
300
+
301
+ ### MapColliderDef
302
+
303
+ | Field | Type | Description |
304
+ |-------|------|-------------|
305
+ | `shape` | `MapCollisionShape` | `{ type: ShapeType.Circle, radius }` or `{ type: ShapeType.Cuboid, halfWidth, halfHeight }` |
306
+ | `offsetX` | `number?` | Offset from object center |
307
+ | `offsetY` | `number?` | Offset from object center |
308
+ | `isSensor` | `boolean?` | Sensor collider (no physics response) |
309
+ | `tag` | `number?` | Tag for filtering (e.g., `CANOPY_SENSOR_TAG`) |
310
+ | `collisionGroup` | `number?` | Rapier collision group bitmask |
311
+
312
+ ### MapVisualDef
313
+
314
+ | Field | Type | Description |
315
+ |-------|------|-------------|
316
+ | `texture` | `string` | Texture key (resolved by `getTexture` callback in renderer) |
317
+ | `layer` | `RenderLayer` | `RenderLayer.Ground` (under entities) or `RenderLayer.Canopy` (over entities) |
318
+ | `offsetX/Y` | `number?` | Visual offset from object center |
319
+ | `anchorX/Y` | `number?` | Sprite anchor (default: 0.5, 0.5) |
320
+
321
+ ## Placement Stages
322
+
323
+ Stages define how and where objects are placed. All stages run during `ObjectPlacementFeature.generate()`.
324
+
325
+ | Kind | Description | Key Fields |
326
+ |------|-------------|------------|
327
+ | `PlacementKind.Location` | Place at a specific position | `typeId`, `pos: {x, y}`, `rad`, `optional` |
328
+ | `PlacementKind.Fixed` | Place exact count randomly | `typeId`, `count`, `important?`, `terrainZone?` |
329
+ | `PlacementKind.Random` | Choose N types from a list | `spawns: number[]`, `choose`, `terrainZone?` |
330
+ | `PlacementKind.Density` | Count proportional to map area | `typeId`, `density`, `terrainZone?` |
331
+
332
+ ### Examples
333
+
334
+ ```typescript
335
+ stages: [
336
+ // Place 1 tree per 100 sq. units of grass area
337
+ { kind: PlacementKind.Density, typeId: 0, density: 100, terrainZone: TerrainZone.Grass },
338
+
339
+ // Place exactly 3 buildings on grass
340
+ { kind: PlacementKind.Fixed, typeId: 1, count: 3, terrainZone: TerrainZone.Grass },
341
+
342
+ // Place a spawn point at (100, 100) within 20px radius; skip if placement fails
343
+ { kind: PlacementKind.Location, typeId: 2, pos: { x: 100, y: 100 }, rad: 20, optional: true },
344
+
345
+ // Randomly pick 5 objects from types [0, 1, 2]
346
+ { kind: PlacementKind.Random, spawns: [0, 1, 2], choose: 5 },
347
+ ]
348
+ ```
349
+
350
+ ### Terrain Zones
351
+
352
+ Restrict placement to specific terrain types:
353
+
354
+ | Zone | Value | Description |
355
+ |------|-------|-------------|
356
+ | `TerrainZone.Grass` | 0 | Main land area |
357
+ | `TerrainZone.Beach` | 1 | Shore/beach area |
358
+ | `TerrainZone.RiverShore` | 2 | Riverbank |
359
+ | `TerrainZone.River` | 3 | Inside river |
360
+ | `TerrainZone.Lake` | 4 | Inside lake |
361
+ | `TerrainZone.Bridge` | 5 | On a bridge |
362
+ | `TerrainZone.WaterEdge` | 6 | Water edge |
363
+
364
+ ## Collision Providers
365
+
366
+ Collision providers prevent object overlap during placement. Two options:
367
+
368
+ ```typescript
369
+ import { SpatialGridCollisionProvider, RapierCollisionProvider } from '@lagless/2d-map-generator';
370
+
371
+ // Fast grid-based provider (recommended for most cases)
372
+ const collision = new SpatialGridCollisionProvider(mapWidth, mapHeight, cellSize);
373
+
374
+ // Rapier-based provider (more accurate, slower — use when shapes need exact overlap testing)
375
+ const collision = new RapierCollisionProvider(rapier);
376
+ ```
377
+
378
+ ## Terrain Query
379
+
380
+ Classify world positions into terrain zones at runtime:
381
+
382
+ ```typescript
383
+ import { TerrainQuery, TerrainZone } from '@lagless/2d-map-generator';
384
+ import type { ShoreOutput, GrassOutput, RiverOutput, LakeOutput } from '@lagless/2d-map-generator';
385
+ import { ShoreFeature, GrassFeature, RiverFeature, LakeFeature } from '@lagless/2d-map-generator';
386
+
387
+ const terrain = new TerrainQuery({
388
+ shore: map.get<ShoreOutput>(ShoreFeature),
389
+ grass: map.get<GrassOutput>(GrassFeature),
390
+ river: map.get<RiverOutput>(RiverFeature),
391
+ lake: map.get<LakeOutput>(LakeFeature),
392
+ });
393
+
394
+ const zone = terrain.classify(playerX, playerY); // TerrainZone.Grass, .Beach, etc.
395
+ ```
396
+
397
+ Useful for terrain-dependent game logic (speed modifiers, footstep sounds, spawn restrictions).
398
+
399
+ ## Physics Integration
400
+
401
+ ### MapPhysicsProvider Adapter
402
+
403
+ `createMapColliders()` uses a `MapPhysicsProvider` adapter to create physics bodies. This decouples the generator from Rapier's API:
404
+
405
+ ```typescript
406
+ import { createMapColliders, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
407
+ import type { MapPhysicsProvider } from '@lagless/2d-map-generator';
408
+
409
+ const physics: MapPhysicsProvider = {
410
+ createFixedBody(x, y, rotation) {
411
+ const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
412
+ return worldManager.createBodyFromDesc(desc);
413
+ },
414
+ createCircleCollider(body, radius, ox, oy, isSensor, tag, collisionGroup) {
415
+ let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
416
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
417
+ worldManager.createColliderFromDesc(desc, body);
418
+ },
419
+ createCuboidCollider(body, hw, hh, ox, oy, isSensor, tag, collisionGroup) {
420
+ let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
421
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
422
+ worldManager.createColliderFromDesc(desc, body);
423
+ },
424
+ };
425
+
426
+ // Create colliders, skipping canopy sensors (view-only)
427
+ createMapColliders(physics, placement.objects, registry, {
428
+ skipTags: [CANOPY_SENSOR_TAG],
429
+ });
430
+ ```
431
+
432
+ ### skipTags Option
433
+
434
+ | Option | Type | Description |
435
+ |--------|------|-------------|
436
+ | `skipTags` | `readonly number[]` | Skip colliders whose `tag` is in this list |
437
+
438
+ Use `skipTags: [CANOPY_SENSOR_TAG]` to prevent creating physics bodies for canopy transparency sensors — they are view-only and don't need physics responses.
439
+
440
+ ### capturePreStartState (CRITICAL)
441
+
442
+ Static map bodies must be created BEFORE calling `capturePreStartState()`:
443
+
444
+ ```typescript
445
+ // 1. Generate map + create colliders (in runner constructor)
446
+ // 2. Re-capture initial snapshot:
447
+ this.Simulation.capturePreStartState();
448
+ // 3. Start simulation:
449
+ runner.start();
450
+ ```
451
+
452
+ Without this, rollback to tick 0/1 restores a physics world without map colliders.
453
+
454
+ ## Rendering
455
+
456
+ ### MapTerrainRenderer
457
+
458
+ Renders terrain layers (background, beach, grass, rivers, lakes, grid, ground patches):
459
+
460
+ ```typescript
461
+ import { MapTerrainRenderer } from '@lagless/2d-map-renderer';
462
+
463
+ const terrain = new MapTerrainRenderer();
464
+ const terrainContainer = terrain.buildTerrain(map);
465
+ viewport.addChildAt(terrainContainer, 0); // add at bottom of display list
466
+
467
+ // Cleanup:
468
+ terrain.destroy();
469
+ ```
470
+
471
+ ### MapObjectRenderer
472
+
473
+ Renders placed objects as two `ParticleContainer` layers — ground (under entities) and canopy (over entities):
474
+
475
+ ```typescript
476
+ import { MapObjectRenderer } from '@lagless/2d-map-renderer';
477
+ import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
478
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
479
+ import { Assets, Texture } from 'pixi.js';
480
+
481
+ const objectRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
482
+
483
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
484
+ if (placement) {
485
+ objectRenderer.build(
486
+ placement.objects,
487
+ registry,
488
+ (textureKey) => Assets.get<Texture>(textureKey) ?? Texture.EMPTY,
489
+ );
490
+
491
+ viewport.addChild(objectRenderer.ground); // under entities
492
+ // ... add entity views here ...
493
+ viewport.addChild(objectRenderer.canopy); // over entities
494
+ }
495
+
496
+ // Cleanup:
497
+ objectRenderer.destroy();
498
+ ```
499
+
500
+ **Display order:** terrain → `objectRenderer.ground` → entity sprites → `objectRenderer.canopy`
501
+
502
+ ### Canopy Transparency
503
+
504
+ Canopy transparency is a **view-only** concern — it must NOT live in ECS or affect determinism. When a player is under a tree/building canopy, the canopy becomes transparent so the player remains visible.
505
+
506
+ ```typescript
507
+ import { extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
508
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
509
+
510
+ // Pre-compute once (e.g., in useMemo):
511
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
512
+ const canopyZones = placement ? extractCanopyZones(placement.objects, registry) : [];
513
+
514
+ // Per frame (e.g., in useTick):
515
+ const px = playerX, py = playerY;
516
+ for (const zone of canopyZones) {
517
+ const inside = isInsideCanopyZone(zone, px, py);
518
+ objectRenderer.setCanopyAlpha(zone.objectIndex, inside ? 0.3 : 1.0);
519
+ }
520
+ ```
521
+
522
+ **How it works:**
523
+ 1. `extractCanopyZones()` finds all sensor colliders tagged with `CANOPY_SENSOR_TAG` (default tag)
524
+ 2. Returns `CanopyZone[]` with pre-computed `radiusSq` (for circles) or `halfWidth/halfHeight` (for cuboids)
525
+ 3. `isInsideCanopyZone()` performs the appropriate distance check based on zone type
526
+ 4. `objectRenderer.setCanopyAlpha()` sets the alpha of the canopy particle at that index
527
+
528
+ **Performance:** O(N) per frame with N objects — just a distance comparison per object, negligible cost.
529
+
530
+ ### MinimapRenderer
531
+
532
+ Renders a simplified minimap:
533
+
534
+ ```typescript
535
+ import { MinimapRenderer } from '@lagless/2d-map-renderer';
536
+ import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
537
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
538
+
539
+ const minimap = new MinimapRenderer();
540
+ const minimapContainer = minimap.buildMinimap(map, 200); // 200px size
541
+
542
+ // Add object dots to minimap (uses mapDisplay shapes from object definitions)
543
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
544
+ if (placement) {
545
+ minimap.addObjectShapes(placement.objects, registry);
546
+ }
547
+
548
+ stage.addChild(minimapContainer);
549
+
550
+ // Cleanup:
551
+ minimap.destroy();
552
+ ```
553
+
554
+ Objects only show on the minimap if their `MapObjectDef` has a `mapDisplay` property with shapes.
555
+
556
+ ## Accessing Feature Outputs
557
+
558
+ ```typescript
559
+ import type { BiomeOutput, ShoreOutput, GrassOutput, RiverOutput, ObjectPlacementOutput } from '@lagless/2d-map-generator';
560
+ import { BiomeFeature, ShoreFeature, GrassFeature, RiverFeature, ObjectPlacementFeature } from '@lagless/2d-map-generator';
561
+
562
+ const map = generator.generate(random, collision);
563
+
564
+ // Type-safe access via feature class:
565
+ const biome = map.get<BiomeOutput>(BiomeFeature); // color palette
566
+ const shore = map.get<ShoreOutput>(ShoreFeature); // island polygon
567
+ const grass = map.get<GrassOutput>(GrassFeature); // grass polygon + area
568
+ const river = map.get<RiverOutput>(RiverFeature); // river polygons
569
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature); // placed objects
570
+ ```
571
+
572
+ ## Biome Colors
573
+
574
+ Use `STANDARD_BIOME` for default colors, or define custom:
575
+
576
+ ```typescript
577
+ import { STANDARD_BIOME } from '@lagless/2d-map-generator';
578
+
579
+ // Standard biome (green grass, blue water, sandy beach):
580
+ generator.addFeature(new BiomeFeature(), STANDARD_BIOME);
581
+
582
+ // Custom biome:
583
+ generator.addFeature(new BiomeFeature(), {
584
+ background: 0x80af49,
585
+ water: 0x3d85c6,
586
+ waterRipple: 0x3478b2,
587
+ beach: 0xcdb35b,
588
+ riverbank: 0x905e24,
589
+ grass: 0x80af49,
590
+ underground: 0x1b0d00,
591
+ });
592
+ ```
593
+
594
+ ## Ground Patches
595
+
596
+ Objects can define `groundPatches` — colored rectangles drawn under the object (e.g., building foundations, dirt patches):
597
+
598
+ ```typescript
599
+ groundPatches: [
600
+ {
601
+ offset: { x: 0, y: 0 }, // offset from object center
602
+ halfExtents: { x: 12, y: 10 }, // half-size of the rectangle
603
+ color: 0x8b4513, // fill color
604
+ roughness: 0.5, // edge roughness (0 = smooth)
605
+ offsetDist: 2, // random edge offset distance
606
+ order: 0, // 0 = under grid, 1 = over grid
607
+ useAsMapShape: false, // whether to use as map boundary shape
608
+ },
609
+ ]
610
+ ```
611
+
612
+ Add `GroundPatchFeature` to your generator to enable ground patches:
613
+
614
+ ```typescript
615
+ import { GroundPatchFeature } from '@lagless/2d-map-generator';
616
+
617
+ generator.addFeature(new GroundPatchFeature(), { registry: OBJECT_REGISTRY });
618
+ ```
619
+
620
+ ## Utilities
621
+
622
+ ### sortPlacedObjects
623
+
624
+ Sorts placed objects by position (Y then X). Used internally by both `MapObjectRenderer.build()` and `extractCanopyZones()` to guarantee consistent object indices.
625
+
626
+ ```typescript
627
+ import { sortPlacedObjects } from '@lagless/2d-map-generator';
628
+
629
+ const sorted = sortPlacedObjects(placement.objects);
630
+ // sorted[i] index matches MapObjectRenderer particle index and CanopyZone.objectIndex
631
+ ```
632
+
633
+ ### CANOPY_SENSOR_TAG
634
+
635
+ Constant (`= 1`) used as a tag on sensor colliders to mark canopy transparency zones. Used by:
636
+ - `extractCanopyZones()` — default tag parameter
637
+ - `createMapColliders()` with `skipTags` — prevents creating physics bodies for canopy sensors
638
+
639
+ ## Determinism Notes
640
+
641
+ All map generation is deterministic:
642
+ - Uses `ISeededRandom` interface — the ECS `PRNG` satisfies this structurally
643
+ - Trigonometry uses `MathOps` (WASM-backed, cross-platform identical)
644
+ - Same seed + same config = identical map on every client
645
+ - Map generation happens ONCE before simulation starts, not during ticks
646
+ - In multiplayer, the seed comes from `serverHello.seed` — guaranteed identical for all clients
647
+
648
+ ## Enums Reference
649
+
650
+ | Enum | Values |
651
+ |------|--------|
652
+ | `ShapeType` | `Circle = 0`, `Cuboid = 1` |
653
+ | `PlacementKind` | `Location = 0`, `Fixed = 1`, `Random = 2`, `Density = 3` |
654
+ | `RenderLayer` | `Ground = 0`, `Canopy = 1` |
655
+ | `TerrainZone` | `Grass = 0`, `Beach = 1`, `RiverShore = 2`, `River = 3`, `Lake = 4`, `Bridge = 5`, `WaterEdge = 6` |
656
+
657
+ ## Full Client Example
658
+
659
+ ```typescript
660
+ // In your game view component:
661
+ import { FC, useEffect, useMemo, useRef } from 'react';
662
+ import { useTick } from '@pixi/react';
663
+ import { Assets, Texture } from 'pixi.js';
664
+ import { MapTerrainRenderer, MapObjectRenderer } from '@lagless/2d-map-renderer';
665
+ import { ObjectPlacementFeature, extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
666
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
667
+
668
+ export const MapView: FC<{ runner: MyGameRunner; viewport: Viewport }> = ({ runner, viewport }) => {
669
+ const mapData = useMemo(() => runner.DIContainer.resolve(MapData), [runner]);
670
+ const objectRendererRef = useRef<MapObjectRenderer | null>(null);
671
+
672
+ // Build terrain + objects once
673
+ useEffect(() => {
674
+ const terrain = new MapTerrainRenderer();
675
+ viewport.addChildAt(terrain.buildTerrain(mapData.map), 0);
676
+
677
+ const objRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
678
+ objectRendererRef.current = objRenderer;
679
+ const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
680
+ if (placement) {
681
+ objRenderer.build(placement.objects, mapData.registry, (key) => Assets.get<Texture>(key) ?? Texture.EMPTY);
682
+ viewport.addChild(objRenderer.ground);
683
+ viewport.addChild(objRenderer.canopy);
684
+ }
685
+
686
+ return () => { terrain.destroy(); objRenderer.destroy(); };
687
+ }, [viewport, mapData]);
688
+
689
+ // Pre-compute canopy zones
690
+ const canopyZones = useMemo(() => {
691
+ const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
692
+ return placement ? extractCanopyZones(placement.objects, mapData.registry) : [];
693
+ }, [mapData]);
694
+
695
+ // Per-frame canopy transparency
696
+ useTick(() => {
697
+ const objRenderer = objectRendererRef.current;
698
+ if (!objRenderer) return;
699
+ const px = playerX, py = playerY; // get from transform
700
+ for (const zone of canopyZones) {
701
+ objRenderer.setCanopyAlpha(zone.objectIndex, isInsideCanopyZone(zone, px, py) ? 0.3 : 1.0);
702
+ }
703
+ });
704
+
705
+ return null;
706
+ };
707
+ ```