@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.
- package/dist/index.js +15 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/pixi-react/AGENTS.md +1 -2
- package/templates/pixi-react/CLAUDE.md +3 -0
- package/templates/pixi-react/__packageName__-frontend/package.json +3 -2
- package/templates/pixi-react/__packageName__-frontend/src/app/components/debug-panel.tsx +0 -5
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +28 -3
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +34 -40
- package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +0 -18
- package/templates/pixi-react/__packageName__-simulation/src/lib/signals/index.ts +2 -4
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +9 -2
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +0 -2
- package/templates/pixi-react/docs/08-physics2d.md +53 -39
- package/templates/pixi-react/docs/08-physics3d.md +60 -33
- package/templates/pixi-react/docs/09-recipes.md +60 -0
- package/templates/pixi-react/docs/10-common-mistakes.md +4 -9
- package/templates/pixi-react/docs/11-2d-map-generation.md +707 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/hash-verification.system.ts +0 -17
|
@@ -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
|
+
```
|