@lagless/create 0.0.36 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/dist/index.js +96 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/templates/pixi-react/AGENTS.md +57 -27
- package/templates/pixi-react/CLAUDE.md +225 -49
- package/templates/pixi-react/README.md +16 -6
- package/templates/pixi-react/__packageName__-backend/package.json +1 -0
- package/templates/pixi-react/__packageName__-backend/src/main.ts +2 -0
- package/templates/pixi-react/__packageName__-frontend/package.json +8 -1
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/grid-background.tsx +4 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/player-view.tsx +68 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +57 -0
- package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts +5 -5
- package/templates/pixi-react/__packageName__-frontend/src/app/screens/title.screen.tsx +18 -1
- package/templates/pixi-react/__packageName__-frontend/{vite.config.ts → vite.config.ts.ejs} +0 -2
- package/templates/pixi-react/__packageName__-simulation/package.json +7 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/arena.ts +12 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +90 -6
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/apply-move-input.system.ts +73 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/boundary.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/damping.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +8 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/integrate.system.ts +2 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/physics-step.system.ts +65 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-connection.system.ts +158 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/player-leave.system.ts +70 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/save-prev-transform.system.ts +46 -0
- package/templates/pixi-react/docs/01-schema-and-codegen.md +244 -0
- package/templates/pixi-react/docs/02-ecs-systems.md +293 -0
- package/templates/pixi-react/docs/03-determinism.md +204 -0
- package/templates/pixi-react/docs/04-input-system.md +255 -0
- package/templates/pixi-react/docs/05-signals.md +175 -0
- package/templates/pixi-react/docs/06-rendering.md +256 -0
- package/templates/pixi-react/docs/07-multiplayer.md +277 -0
- package/templates/pixi-react/docs/08-physics2d.md +266 -0
- package/templates/pixi-react/docs/08-physics3d.md +312 -0
- package/templates/pixi-react/docs/09-recipes.md +362 -0
- package/templates/pixi-react/docs/10-common-mistakes.md +224 -0
- package/templates/pixi-react/docs/api-quick-reference.md +254 -0
- package/templates/pixi-react/package.json +6 -0
- /package/templates/pixi-react/__packageName__-backend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-frontend/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{.swcrc → .swcrc.ejs} +0 -0
- /package/templates/pixi-react/__packageName__-simulation/{tsconfig.json → tsconfig.json.ejs} +0 -0
- /package/templates/pixi-react/{tsconfig.base.json → tsconfig.base.json.ejs} +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Physics 2D (Rapier)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`@lagless/physics2d` integrates [Rapier 2D](https://rapier.rs/) for deterministic rigid body physics. The framework manages Rapier world snapshots for rollback, body↔entity mapping, and transform synchronization.
|
|
6
|
+
|
|
7
|
+
## Auto-Prepended Components
|
|
8
|
+
|
|
9
|
+
When `simulationType: physics2d` is set in `ecs.yaml`, codegen auto-prepends:
|
|
10
|
+
|
|
11
|
+
**Transform2d** (6 float32 fields):
|
|
12
|
+
- `positionX`, `positionY` — current position
|
|
13
|
+
- `rotation` — current rotation (radians)
|
|
14
|
+
- `prevPositionX`, `prevPositionY` — previous position (for interpolation)
|
|
15
|
+
- `prevRotation` — previous rotation
|
|
16
|
+
|
|
17
|
+
**PhysicsRefs** (4 fields):
|
|
18
|
+
- `bodyHandle: float64` — Rapier rigid body handle
|
|
19
|
+
- `colliderHandle: float64` — Rapier collider handle
|
|
20
|
+
- `bodyType: uint8` — body type enum (Dynamic, Fixed, etc.)
|
|
21
|
+
- `collisionLayer: uint16` — collision layer bitmask
|
|
22
|
+
|
|
23
|
+
**Do NOT declare these manually** — they are auto-prepended by codegen.
|
|
24
|
+
|
|
25
|
+
## Body Types
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { BodyType } from '@lagless/physics-shared';
|
|
29
|
+
|
|
30
|
+
BodyType.DYNAMIC // 0 — affected by forces and collisions
|
|
31
|
+
BodyType.FIXED // 1 — immovable (walls, ground)
|
|
32
|
+
BodyType.KINEMATIC_POSITION // 2 — moved by setting position directly
|
|
33
|
+
BodyType.KINEMATIC_VELOCITY // 3 — moved by setting velocity directly
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Creating Bodies and Colliders
|
|
37
|
+
|
|
38
|
+
Use `PhysicsWorldManager2d` to create physics bodies:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { ECSSystem, IECSSystem } from '@lagless/core';
|
|
42
|
+
import { PhysicsWorldManager2d } from '@lagless/physics2d';
|
|
43
|
+
import { BodyType, CollisionLayers } from '@lagless/physics-shared';
|
|
44
|
+
|
|
45
|
+
@ECSSystem()
|
|
46
|
+
export class SpawnSystem implements IECSSystem {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly _physics: PhysicsWorldManager2d,
|
|
49
|
+
private readonly _entities: EntitiesManager,
|
|
50
|
+
private readonly _transform: Transform2d,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
update(tick: number): void {
|
|
54
|
+
// Create entity
|
|
55
|
+
const entity = this._entities.createEntity();
|
|
56
|
+
this._entities.addComponent(entity, Transform2d);
|
|
57
|
+
this._entities.addComponent(entity, PhysicsRefs);
|
|
58
|
+
|
|
59
|
+
// Set initial position
|
|
60
|
+
this._transform.set(entity, {
|
|
61
|
+
positionX: 100, positionY: 200,
|
|
62
|
+
prevPositionX: 100, prevPositionY: 200,
|
|
63
|
+
rotation: 0, prevRotation: 0,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Create physics body + collider
|
|
67
|
+
this._physics.createBody(entity, {
|
|
68
|
+
bodyType: BodyType.DYNAMIC,
|
|
69
|
+
position: { x: 100, y: 200 },
|
|
70
|
+
rotation: 0,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this._physics.createCollider(entity, {
|
|
74
|
+
shape: { type: 'ball', radius: 20 },
|
|
75
|
+
density: 1.0,
|
|
76
|
+
friction: 0.5,
|
|
77
|
+
restitution: 0.3,
|
|
78
|
+
collisionLayer: CollisionLayers.get('player'),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Collider Shapes
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Circle
|
|
88
|
+
{ type: 'ball', radius: 20 }
|
|
89
|
+
|
|
90
|
+
// Rectangle
|
|
91
|
+
{ type: 'cuboid', hx: 50, hy: 25 } // half-extents
|
|
92
|
+
|
|
93
|
+
// Capsule
|
|
94
|
+
{ type: 'capsule', halfHeight: 30, radius: 10 }
|
|
95
|
+
|
|
96
|
+
// Convex polygon
|
|
97
|
+
{ type: 'convexHull', points: [x1,y1, x2,y2, ...] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Collision Layers
|
|
101
|
+
|
|
102
|
+
Named collision groups (max 16 layers). Control which objects collide with which.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { CollisionLayers } from '@lagless/physics-shared';
|
|
106
|
+
|
|
107
|
+
// Register layers (call once at startup):
|
|
108
|
+
CollisionLayers.register('player');
|
|
109
|
+
CollisionLayers.register('wall');
|
|
110
|
+
CollisionLayers.register('projectile');
|
|
111
|
+
CollisionLayers.register('pickup');
|
|
112
|
+
|
|
113
|
+
// Get layer value:
|
|
114
|
+
const playerLayer = CollisionLayers.get('player');
|
|
115
|
+
|
|
116
|
+
// Interaction groups — which layers collide:
|
|
117
|
+
CollisionLayers.setInteraction('player', 'wall', true);
|
|
118
|
+
CollisionLayers.setInteraction('player', 'projectile', true);
|
|
119
|
+
CollisionLayers.setInteraction('projectile', 'wall', true);
|
|
120
|
+
CollisionLayers.setInteraction('player', 'pickup', true);
|
|
121
|
+
// player↔player collision:
|
|
122
|
+
CollisionLayers.setInteraction('player', 'player', true);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Collision Events
|
|
126
|
+
|
|
127
|
+
Drain collision events in a system each tick:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { CollisionEvents2d } from '@lagless/physics2d';
|
|
131
|
+
|
|
132
|
+
@ECSSystem()
|
|
133
|
+
export class CollisionSystem implements IECSSystem {
|
|
134
|
+
constructor(
|
|
135
|
+
private readonly _collisionEvents: CollisionEvents2d,
|
|
136
|
+
) {}
|
|
137
|
+
|
|
138
|
+
update(tick: number): void {
|
|
139
|
+
// Drain events (must be called every tick)
|
|
140
|
+
this._collisionEvents.drain();
|
|
141
|
+
|
|
142
|
+
// Process collision start events
|
|
143
|
+
for (let i = 0; i < this._collisionEvents.startCount; i++) {
|
|
144
|
+
const entityA = this._collisionEvents.startEntityA[i];
|
|
145
|
+
const entityB = this._collisionEvents.startEntityB[i];
|
|
146
|
+
// Handle collision between entityA and entityB
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Process collision end events
|
|
150
|
+
for (let i = 0; i < this._collisionEvents.endCount; i++) {
|
|
151
|
+
const entityA = this._collisionEvents.endEntityA[i];
|
|
152
|
+
const entityB = this._collisionEvents.endEntityB[i];
|
|
153
|
+
// Handle separation
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Collision Events and Determinism
|
|
160
|
+
|
|
161
|
+
Collision events are **ephemeral** — they are cleared each tick in `drain()` and regenerated on re-simulation after rollback. No snapshot storage needed. The Rapier EventQueue is stateless between ticks.
|
|
162
|
+
|
|
163
|
+
## Physics Step System
|
|
164
|
+
|
|
165
|
+
The physics step system advances the Rapier world and syncs transforms:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { ECSSystem, IECSSystem } from '@lagless/core';
|
|
169
|
+
import { PhysicsWorldManager2d } from '@lagless/physics2d';
|
|
170
|
+
|
|
171
|
+
@ECSSystem()
|
|
172
|
+
export class PhysicsStepSystem implements IECSSystem {
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly _physics: PhysicsWorldManager2d,
|
|
175
|
+
) {}
|
|
176
|
+
|
|
177
|
+
update(tick: number): void {
|
|
178
|
+
this._physics.step();
|
|
179
|
+
// Rapier positions → ECS Transform2d (automatic)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The `step()` method:
|
|
185
|
+
1. Steps the Rapier world with the configured timestep
|
|
186
|
+
2. Syncs Rapier body positions/rotations → ECS Transform2d component
|
|
187
|
+
3. Drains collision events
|
|
188
|
+
|
|
189
|
+
## ColliderEntityMap — Handle↔Entity Mapping
|
|
190
|
+
|
|
191
|
+
Rapier uses Float64 handles to identify bodies and colliders. The `ColliderEntityMap` maps these to entity IDs.
|
|
192
|
+
|
|
193
|
+
**Critical:** Rapier handles are Float64 values where the bit pattern encodes an arena index. `handle | 0` gives 0 for denormalized floats — **never** use bitwise OR for conversion. The framework uses `handleToIndex()` with Float64Array→Uint32Array reinterpretation.
|
|
194
|
+
|
|
195
|
+
You generally don't interact with this directly — it's managed by `PhysicsWorldManager2d`.
|
|
196
|
+
|
|
197
|
+
## Rollback
|
|
198
|
+
|
|
199
|
+
On rollback:
|
|
200
|
+
1. ArrayBuffer is restored → ECS state reverts
|
|
201
|
+
2. Rapier world snapshot is restored → physics state reverts
|
|
202
|
+
3. `updateSceneQueries()` is called → QueryPipeline is rebuilt
|
|
203
|
+
|
|
204
|
+
**Critical fix applied:** `World.restoreSnapshot()` creates a world with an **empty** QueryPipeline (not serialized). The framework calls `updateSceneQueries()` after restore to fix this. Without it, ray casts and shape casts fail on the first tick after rollback.
|
|
205
|
+
|
|
206
|
+
## State Transfer
|
|
207
|
+
|
|
208
|
+
After `applyExternalState()` (late join / reconnect):
|
|
209
|
+
1. Rapier world snapshot is applied alongside ArrayBuffer
|
|
210
|
+
2. `ColliderEntityMap` is rebuilt by iterating all entities with PhysicsRefs
|
|
211
|
+
3. Collision layers are re-applied
|
|
212
|
+
|
|
213
|
+
This is handled automatically by the physics runner. You don't need to do anything special.
|
|
214
|
+
|
|
215
|
+
## Complete Physics System Example
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import { ECSSystem, IECSSystem, AbstractInputProvider, ECSConfig, EntitiesManager } from '@lagless/core';
|
|
219
|
+
import { MathOps } from '@lagless/math';
|
|
220
|
+
import { PhysicsWorldManager2d, CollisionEvents2d } from '@lagless/physics2d';
|
|
221
|
+
import { BodyType } from '@lagless/physics-shared';
|
|
222
|
+
import { Transform2d, PhysicsRefs, PlayerBody, PlayerFilter, MoveInput } from '../code-gen/core.js';
|
|
223
|
+
|
|
224
|
+
const finite = (v: number): number => Number.isFinite(v) ? v : 0;
|
|
225
|
+
|
|
226
|
+
@ECSSystem()
|
|
227
|
+
export class ApplyMoveInputSystem implements IECSSystem {
|
|
228
|
+
constructor(
|
|
229
|
+
private readonly _input: AbstractInputProvider,
|
|
230
|
+
private readonly _physics: PhysicsWorldManager2d,
|
|
231
|
+
private readonly _playerBody: PlayerBody,
|
|
232
|
+
private readonly _filter: PlayerFilter,
|
|
233
|
+
private readonly _config: ECSConfig,
|
|
234
|
+
) {}
|
|
235
|
+
|
|
236
|
+
update(tick: number): void {
|
|
237
|
+
const rpcs = this._input.collectTickRPCs(tick, MoveInput);
|
|
238
|
+
for (const rpc of rpcs) {
|
|
239
|
+
const slot = rpc.meta.playerSlot;
|
|
240
|
+
let dirX = MathOps.clamp(finite(rpc.data.directionX), -1, 1);
|
|
241
|
+
let dirY = MathOps.clamp(finite(rpc.data.directionY), -1, 1);
|
|
242
|
+
|
|
243
|
+
for (const entity of this._filter) {
|
|
244
|
+
if (this._playerBody.unsafe.playerSlot[entity] !== slot) continue;
|
|
245
|
+
|
|
246
|
+
const speed = 300;
|
|
247
|
+
// Apply velocity to Rapier body
|
|
248
|
+
this._physics.setLinearVelocity(entity, {
|
|
249
|
+
x: dirX * speed,
|
|
250
|
+
y: dirY * speed,
|
|
251
|
+
});
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Tips
|
|
260
|
+
|
|
261
|
+
- **Dynamic bodies** are moved by forces/impulses/velocity — never set position directly
|
|
262
|
+
- **Kinematic bodies** are moved by setting position or velocity — not affected by forces
|
|
263
|
+
- **Fixed bodies** never move — use for walls, ground, boundaries
|
|
264
|
+
- **Substeps** — Rapier can run multiple sub-steps per tick for stability. Configure in physics config.
|
|
265
|
+
- **Gravity** — set in physics world config. Default is (0, 0) for top-down games.
|
|
266
|
+
- **CCD** — continuous collision detection prevents tunneling through thin walls at high speeds.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Physics 3D (Rapier)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`@lagless/physics3d` integrates [Rapier 3D](https://rapier.rs/) for deterministic rigid body physics. The framework manages Rapier world snapshots for rollback, body↔entity mapping, and transform synchronization.
|
|
6
|
+
|
|
7
|
+
For character controllers, see also `@lagless/character-controller-3d`.
|
|
8
|
+
|
|
9
|
+
## Auto-Prepended Components
|
|
10
|
+
|
|
11
|
+
When `simulationType: physics3d` is set in `ecs.yaml`, codegen auto-prepends:
|
|
12
|
+
|
|
13
|
+
**Transform3d** (14 float32 fields):
|
|
14
|
+
- `positionX`, `positionY`, `positionZ` — current position
|
|
15
|
+
- `rotationX`, `rotationY`, `rotationZ`, `rotationW` — current rotation (quaternion)
|
|
16
|
+
- `prevPositionX`, `prevPositionY`, `prevPositionZ` — previous position
|
|
17
|
+
- `prevRotationX`, `prevRotationY`, `prevRotationZ`, `prevRotationW` — previous rotation
|
|
18
|
+
|
|
19
|
+
**PhysicsRefs** (4 fields):
|
|
20
|
+
- `bodyHandle: float64` — Rapier rigid body handle
|
|
21
|
+
- `colliderHandle: float64` — Rapier collider handle
|
|
22
|
+
- `bodyType: uint8` — body type enum
|
|
23
|
+
- `collisionLayer: uint16` — collision layer bitmask
|
|
24
|
+
|
|
25
|
+
**Do NOT declare these manually** — they are auto-prepended by codegen.
|
|
26
|
+
|
|
27
|
+
## Body Types
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { BodyType } from '@lagless/physics-shared';
|
|
31
|
+
|
|
32
|
+
BodyType.DYNAMIC // 0 — affected by forces and collisions
|
|
33
|
+
BodyType.FIXED // 1 — immovable (walls, ground)
|
|
34
|
+
BodyType.KINEMATIC_POSITION // 2 — moved by setting position
|
|
35
|
+
BodyType.KINEMATIC_VELOCITY // 3 — moved by setting velocity
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Creating Bodies and Colliders
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { PhysicsWorldManager3d } from '@lagless/physics3d';
|
|
42
|
+
import { BodyType, CollisionLayers } from '@lagless/physics-shared';
|
|
43
|
+
|
|
44
|
+
@ECSSystem()
|
|
45
|
+
export class SpawnSystem implements IECSSystem {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly _physics: PhysicsWorldManager3d,
|
|
48
|
+
private readonly _entities: EntitiesManager,
|
|
49
|
+
private readonly _transform: Transform3d,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
update(tick: number): void {
|
|
53
|
+
const entity = this._entities.createEntity();
|
|
54
|
+
this._entities.addComponent(entity, Transform3d);
|
|
55
|
+
this._entities.addComponent(entity, PhysicsRefs);
|
|
56
|
+
|
|
57
|
+
this._transform.set(entity, {
|
|
58
|
+
positionX: 0, positionY: 5, positionZ: 0,
|
|
59
|
+
rotationX: 0, rotationY: 0, rotationZ: 0, rotationW: 1,
|
|
60
|
+
prevPositionX: 0, prevPositionY: 5, prevPositionZ: 0,
|
|
61
|
+
prevRotationX: 0, prevRotationY: 0, prevRotationZ: 0, prevRotationW: 1,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this._physics.createBody(entity, {
|
|
65
|
+
bodyType: BodyType.DYNAMIC,
|
|
66
|
+
position: { x: 0, y: 5, z: 0 },
|
|
67
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this._physics.createCollider(entity, {
|
|
71
|
+
shape: { type: 'ball', radius: 0.5 },
|
|
72
|
+
density: 1.0,
|
|
73
|
+
friction: 0.5,
|
|
74
|
+
restitution: 0.3,
|
|
75
|
+
collisionLayer: CollisionLayers.get('player'),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Collider Shapes (3D)
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// Sphere
|
|
85
|
+
{ type: 'ball', radius: 0.5 }
|
|
86
|
+
|
|
87
|
+
// Box
|
|
88
|
+
{ type: 'cuboid', hx: 1, hy: 0.5, hz: 1 } // half-extents
|
|
89
|
+
|
|
90
|
+
// Capsule
|
|
91
|
+
{ type: 'capsule', halfHeight: 0.5, radius: 0.3 }
|
|
92
|
+
|
|
93
|
+
// Cylinder
|
|
94
|
+
{ type: 'cylinder', halfHeight: 1.0, radius: 0.5 }
|
|
95
|
+
|
|
96
|
+
// Convex hull
|
|
97
|
+
{ type: 'convexHull', points: Float32Array }
|
|
98
|
+
|
|
99
|
+
// Triangle mesh (static only)
|
|
100
|
+
{ type: 'trimesh', vertices: Float32Array, indices: Uint32Array }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Collision Layers and Events
|
|
104
|
+
|
|
105
|
+
Same API as 2D — see [08-physics2d.md](08-physics2d.md) for `CollisionLayers` and `CollisionEvents` documentation. Use `CollisionEvents3d` instead of `CollisionEvents2d`.
|
|
106
|
+
|
|
107
|
+
## Physics Step System
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
@ECSSystem()
|
|
111
|
+
export class PhysicsStepSystem implements IECSSystem {
|
|
112
|
+
constructor(private readonly _physics: PhysicsWorldManager3d) {}
|
|
113
|
+
|
|
114
|
+
update(tick: number): void {
|
|
115
|
+
this._physics.step();
|
|
116
|
+
// Rapier 3D positions/rotations → ECS Transform3d (automatic)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Character Controller (KCC)
|
|
122
|
+
|
|
123
|
+
The `@lagless/character-controller-3d` library provides deterministic character movement using Rapier's KinematicCharacterController.
|
|
124
|
+
|
|
125
|
+
### Setup
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { CharacterControllerManager } from '@lagless/character-controller-3d';
|
|
129
|
+
|
|
130
|
+
// Create manager (in runner setup or first system tick):
|
|
131
|
+
const kccManager = new CharacterControllerManager(physicsWorld, {
|
|
132
|
+
offset: 0.01, // skin width
|
|
133
|
+
maxSlopeClimbAngle: 0.8, // ~45 degrees
|
|
134
|
+
maxSlopeSlideAngle: 0.6, // ~34 degrees
|
|
135
|
+
stepHeight: 0.3,
|
|
136
|
+
snapToGround: 0.3,
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Movement System
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
@ECSSystem()
|
|
144
|
+
export class CharacterMovementSystem implements IECSSystem {
|
|
145
|
+
constructor(
|
|
146
|
+
private readonly _kcc: CharacterControllerManager,
|
|
147
|
+
private readonly _input: AbstractInputProvider,
|
|
148
|
+
private readonly _transform: Transform3d,
|
|
149
|
+
private readonly _filter: PlayerFilter,
|
|
150
|
+
private readonly _config: ECSConfig,
|
|
151
|
+
) {}
|
|
152
|
+
|
|
153
|
+
update(tick: number): void {
|
|
154
|
+
const rpcs = this._input.collectTickRPCs(tick, MoveInput);
|
|
155
|
+
for (const rpc of rpcs) {
|
|
156
|
+
let dirX = MathOps.clamp(finite(rpc.data.directionX), -1, 1);
|
|
157
|
+
let dirZ = MathOps.clamp(finite(rpc.data.directionZ), -1, 1);
|
|
158
|
+
const cameraYaw = finite(rpc.data.cameraYaw);
|
|
159
|
+
|
|
160
|
+
for (const entity of this._filter) {
|
|
161
|
+
if (this._playerBody.unsafe.playerSlot[entity] !== rpc.meta.playerSlot) continue;
|
|
162
|
+
|
|
163
|
+
// Rotate input by camera yaw
|
|
164
|
+
const sinYaw = MathOps.sin(cameraYaw);
|
|
165
|
+
const cosYaw = MathOps.cos(cameraYaw);
|
|
166
|
+
const worldX = dirX * cosYaw - dirZ * sinYaw;
|
|
167
|
+
const worldZ = dirX * sinYaw + dirZ * cosYaw;
|
|
168
|
+
|
|
169
|
+
const speed = 5.0;
|
|
170
|
+
const dt = this._config.frameLength;
|
|
171
|
+
|
|
172
|
+
// Move character via KCC
|
|
173
|
+
this._kcc.computeMovement(entity, {
|
|
174
|
+
x: worldX * speed * dt,
|
|
175
|
+
y: -9.81 * dt, // gravity
|
|
176
|
+
z: worldZ * speed * dt,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Apply movement result
|
|
180
|
+
const result = this._kcc.getMovementResult(entity);
|
|
181
|
+
this._kcc.applyMovement(entity, result);
|
|
182
|
+
|
|
183
|
+
// Check grounding
|
|
184
|
+
const grounded = this._kcc.isGrounded(entity);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Rollback with KCC
|
|
193
|
+
|
|
194
|
+
After rollback, character controllers must be recreated:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// The physics runner handles this automatically via recreateAll()
|
|
198
|
+
// after Rapier world snapshot restore
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Animation Controller
|
|
202
|
+
|
|
203
|
+
The `@lagless/animation-controller` library provides deterministic animation state machines.
|
|
204
|
+
|
|
205
|
+
### AnimationStateMachine
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { AnimationStateMachine } from '@lagless/animation-controller';
|
|
209
|
+
|
|
210
|
+
const fsm = new AnimationStateMachine({
|
|
211
|
+
states: {
|
|
212
|
+
idle: { animation: 'idle', loop: true },
|
|
213
|
+
walk: { animation: 'walk', loop: true },
|
|
214
|
+
run: { animation: 'run', loop: true },
|
|
215
|
+
jump: { animation: 'jump', loop: false },
|
|
216
|
+
},
|
|
217
|
+
transitions: [
|
|
218
|
+
{ from: 'idle', to: 'walk', condition: (ctx) => ctx.speed > 0.1 },
|
|
219
|
+
{ from: 'walk', to: 'run', condition: (ctx) => ctx.speed > 3.0 },
|
|
220
|
+
{ from: 'walk', to: 'idle', condition: (ctx) => ctx.speed < 0.1 },
|
|
221
|
+
{ from: 'run', to: 'walk', condition: (ctx) => ctx.speed < 3.0 },
|
|
222
|
+
{ from: '*', to: 'jump', condition: (ctx) => !ctx.grounded },
|
|
223
|
+
{ from: 'jump', to: 'idle', condition: (ctx) => ctx.grounded },
|
|
224
|
+
],
|
|
225
|
+
initialState: 'idle',
|
|
226
|
+
crossfadeDuration: 0.2,
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### LocomotionBlendCalculator
|
|
231
|
+
|
|
232
|
+
Blends between idle/walk/run based on speed:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import { LocomotionBlendCalculator } from '@lagless/animation-controller';
|
|
236
|
+
|
|
237
|
+
const locomotion = new LocomotionBlendCalculator({
|
|
238
|
+
walkSpeed: 2.0,
|
|
239
|
+
runSpeed: 5.0,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Each tick:
|
|
243
|
+
const blend = locomotion.calculate(currentSpeed);
|
|
244
|
+
// blend.idle, blend.walk, blend.run — weights summing to 1.0
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### AnimationViewAdapter
|
|
248
|
+
|
|
249
|
+
Connects deterministic animation state to 3D engine (BabylonJS, Three.js):
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { AnimationViewAdapter } from '@lagless/animation-controller';
|
|
253
|
+
|
|
254
|
+
const adapter = new AnimationViewAdapter(fsm, {
|
|
255
|
+
playAnimation: (name, options) => { /* play in 3D engine */ },
|
|
256
|
+
stopAnimation: (name) => { /* stop in 3D engine */ },
|
|
257
|
+
setWeight: (name, weight) => { /* blend weight */ },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Each frame:
|
|
261
|
+
adapter.update(deltaTime);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## System Execution Order for 3D
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
export const systems = [
|
|
268
|
+
SavePrevTransformSystem, // 1. Store prev positions/rotations
|
|
269
|
+
PlayerConnectionSystem, // 2. Handle join/leave
|
|
270
|
+
ApplyMoveInputSystem, // 3. Read inputs
|
|
271
|
+
CharacterMovementSystem, // 4. KCC movement (before physics step)
|
|
272
|
+
PhysicsStepSystem, // 5. Step Rapier, sync transforms
|
|
273
|
+
AnimationSystem, // 6. Update animation FSM (after physics)
|
|
274
|
+
PlayerLeaveSystem, // 7. Cleanup
|
|
275
|
+
HashVerificationSystem, // 8. Always last
|
|
276
|
+
];
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Rollback
|
|
280
|
+
|
|
281
|
+
On rollback:
|
|
282
|
+
1. ArrayBuffer is restored → ECS state reverts
|
|
283
|
+
2. Rapier 3D world snapshot is restored → physics state reverts
|
|
284
|
+
3. `updateSceneQueries()` is called → QueryPipeline is rebuilt
|
|
285
|
+
4. KCC controllers are recreated via `recreateAll()`
|
|
286
|
+
|
|
287
|
+
**Critical fix:** `World.restoreSnapshot()` creates a world with an empty QueryPipeline. The framework calls `updateSceneQueries()` after restore. Without this, `computeColliderMovement()` queries fail on the first tick after rollback.
|
|
288
|
+
|
|
289
|
+
## State Transfer
|
|
290
|
+
|
|
291
|
+
After `applyExternalState()`:
|
|
292
|
+
1. Rapier 3D world snapshot is applied alongside ArrayBuffer
|
|
293
|
+
2. `ColliderEntityMap` is rebuilt by iterating all entities with PhysicsRefs
|
|
294
|
+
3. KCC controllers are recreated
|
|
295
|
+
4. Collision layers are re-applied
|
|
296
|
+
|
|
297
|
+
Handled automatically by the physics runner.
|
|
298
|
+
|
|
299
|
+
## Rapier Handle Encoding
|
|
300
|
+
|
|
301
|
+
Rapier WASM handles are **Float64** values where the bit pattern encodes an arena index in the low 32 bits.
|
|
302
|
+
- Handle `0` = float64 `0.0`, handle `1` = float64 `5e-324` (Number.MIN_VALUE)
|
|
303
|
+
- **NEVER** use `handle | 0` — gives 0 for all denormalized floats
|
|
304
|
+
- The framework uses `handleToIndex()` with Float64Array→Uint32Array reinterpretation
|
|
305
|
+
|
|
306
|
+
## Tips
|
|
307
|
+
|
|
308
|
+
- **Gravity:** default is (0, -9.81, 0) for 3D. Set in physics config.
|
|
309
|
+
- **Quaternion rotation:** rotationW=1 for identity rotation. Set all 4 components.
|
|
310
|
+
- **KCC offset:** small positive value (0.01) prevents character from getting stuck in geometry
|
|
311
|
+
- **System order matters:** KCC movement must run BEFORE PhysicsStep
|
|
312
|
+
- **Animation updates** should run AFTER physics to use final position/velocity
|