@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
|
@@ -2,78 +2,254 @@
|
|
|
2
2
|
|
|
3
3
|
## What This Is
|
|
4
4
|
|
|
5
|
-
<%= projectName %> is a multiplayer browser game built with **Lagless
|
|
5
|
+
<%= projectName %> is a multiplayer browser game built with **Lagless** — a deterministic ECS framework with simulate/rollback netcode. All simulation state lives in a single ArrayBuffer. Server relays inputs, clients run deterministic simulation.
|
|
6
6
|
|
|
7
7
|
## Commands
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
# Install
|
|
11
|
-
pnpm
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
pnpm dev:
|
|
10
|
+
pnpm install # Install dependencies
|
|
11
|
+
pnpm codegen # Regenerate ECS code from schema (MUST run after schema changes)
|
|
12
|
+
pnpm dev # Start backend + frontend + dev-player (all at once)
|
|
13
|
+
pnpm dev:backend # Game server only (Bun, auto-reload)
|
|
14
|
+
pnpm dev:frontend # Frontend only (Vite HMR)
|
|
15
|
+
pnpm dev:player # Dev-player multiplayer testing tool (port 4210)
|
|
16
|
+
```
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
pnpm dev:frontend
|
|
18
|
+
## Project Structure
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
```
|
|
21
|
+
<%= packageName %>-simulation/ # Shared deterministic game logic
|
|
22
|
+
src/lib/schema/ecs.yaml # ECS schema definition → run pnpm codegen
|
|
23
|
+
src/lib/code-gen/ # GENERATED — never edit manually
|
|
24
|
+
src/lib/systems/ # Game systems (execution order = array order)
|
|
25
|
+
src/lib/signals/ # Rollback-aware events
|
|
26
|
+
src/lib/arena.ts # Arena config (systems, signals, ECS config)
|
|
27
|
+
<%= packageName %>-frontend/ # React + Pixi.js game client
|
|
28
|
+
src/app/game-view/ # Pixi rendering components
|
|
29
|
+
src/app/screens/ # Game screens (title, game)
|
|
30
|
+
src/app/hooks/ # React hooks (match start, inputs)
|
|
31
|
+
<%= packageName %>-backend/ # Bun game server (relay model, NO simulation)
|
|
32
|
+
src/main.ts # Server entry point
|
|
33
|
+
src/game-hooks.ts # Room lifecycle hooks
|
|
21
34
|
```
|
|
22
35
|
|
|
23
|
-
##
|
|
36
|
+
## Quick Recipe: Adding a Feature
|
|
37
|
+
|
|
38
|
+
1. **Schema** — Add components/inputs/filters to `<%= packageName %>-simulation/src/lib/schema/ecs.yaml`
|
|
39
|
+
2. **Codegen** — Run `pnpm codegen`
|
|
40
|
+
3. **System** — Create `my-feature.system.ts` with `@ECSSystem()` decorator
|
|
41
|
+
4. **Register** — Add system to `systems` array in `systems/index.ts` (order matters!)
|
|
42
|
+
5. **Render** — Add Pixi.js view component using `filterView()` or `<FilterViews>`
|
|
43
|
+
6. **Input** — Wire UI events via `drainInputs` in runner-provider
|
|
44
|
+
|
|
45
|
+
## ECS System Pattern
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { ECSSystem, IECSSystem } from '@lagless/core';
|
|
49
|
+
|
|
50
|
+
@ECSSystem()
|
|
51
|
+
export class MySystem implements IECSSystem {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly _transform: Transform2d, // component (unsafe typed arrays)
|
|
54
|
+
private readonly _filter: PlayerFilter, // filter (iterable entity list)
|
|
55
|
+
private readonly _entities: EntitiesManager, // entity CRUD
|
|
56
|
+
private readonly _prng: PRNG, // deterministic random
|
|
57
|
+
private readonly _config: ECSConfig, // simulation config
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
update(tick: number): void {
|
|
61
|
+
for (const entity of this._filter) {
|
|
62
|
+
this._transform.unsafe.positionX[entity] += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
24
67
|
|
|
25
|
-
|
|
68
|
+
## Input Handling Pattern
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// In runner-provider.tsx — drainInputs callback:
|
|
72
|
+
drainInputs={(tick, addRPC) => {
|
|
73
|
+
const dir = getDirection(); // from keyboard/joystick
|
|
74
|
+
addRPC(MoveInput, { directionX: dir.x, directionY: dir.y });
|
|
75
|
+
}}
|
|
76
|
+
|
|
77
|
+
// In system — reading inputs:
|
|
78
|
+
const rpcs = this._input.collectTickRPCs(tick, MoveInput);
|
|
79
|
+
for (const rpc of rpcs) {
|
|
80
|
+
const slot = rpc.meta.playerSlot;
|
|
81
|
+
const dirX = finite(rpc.data.directionX); // ALWAYS sanitize!
|
|
82
|
+
const dirY = finite(rpc.data.directionY);
|
|
83
|
+
// use dirX, dirY...
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sanitization helper:
|
|
87
|
+
const finite = (v: number): number => Number.isFinite(v) ? v : 0;
|
|
88
|
+
```
|
|
26
89
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
90
|
+
## Rendering Pattern
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// FilterViews — auto-manages lifecycle for entities matching a filter
|
|
94
|
+
<FilterViews filter={runner.Core.PlayerFilter} View={PlayerView} />
|
|
95
|
+
|
|
96
|
+
// filterView — define a view component for an entity
|
|
97
|
+
const PlayerView = filterView(({ entity }, ref) => {
|
|
98
|
+
const transform = runner.Core.Transform2d;
|
|
99
|
+
// onCreate: set up sprites
|
|
100
|
+
// Return Pixi container
|
|
101
|
+
return <pixiContainer ref={ref} />;
|
|
102
|
+
}, {
|
|
103
|
+
onUpdate: ({ entity }, container, runner) => {
|
|
104
|
+
container.position.set(
|
|
105
|
+
transform.unsafe.positionX[entity],
|
|
106
|
+
transform.unsafe.positionY[entity]
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
```
|
|
30
111
|
|
|
31
|
-
|
|
112
|
+
## Signal Pattern (Rollback-Aware Events)
|
|
32
113
|
|
|
33
|
-
|
|
114
|
+
```typescript
|
|
115
|
+
// Define signal in signals/index.ts:
|
|
116
|
+
@ECSSignal()
|
|
117
|
+
export class ScoreSignal extends Signal<{ slot: number; points: number }> {}
|
|
34
118
|
|
|
35
|
-
|
|
119
|
+
// Emit in system:
|
|
120
|
+
this._scoreSignal.emit(tick, { slot: 0, points: 100 });
|
|
36
121
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
4. simulationTicks: for each tick → run systems in order → process signals
|
|
42
|
-
5. inputProvider.update() — drain inputs, send to server
|
|
43
|
-
6. interpolationFactor = leftover / frameLength
|
|
122
|
+
// Subscribe in view (three streams):
|
|
123
|
+
signal.Predicted.subscribe(e => playSound()); // instant feedback
|
|
124
|
+
signal.Verified.subscribe(e => updateScore()); // survived all rollbacks
|
|
125
|
+
signal.Cancelled.subscribe(e => undoSound()); // was rolled back
|
|
44
126
|
```
|
|
45
127
|
|
|
46
|
-
|
|
128
|
+
## DETERMINISM RULES (CRITICAL)
|
|
47
129
|
|
|
48
|
-
|
|
130
|
+
**Breaking these causes permanent desync between clients — game becomes unplayable.**
|
|
49
131
|
|
|
50
|
-
|
|
132
|
+
**ALWAYS use:**
|
|
133
|
+
- `MathOps.sin/cos/atan2/sqrt/clamp` — WASM-backed, deterministic across platforms
|
|
134
|
+
- `PRNG.getFloat()/getRandomInt()` — deterministic random (state in ArrayBuffer)
|
|
135
|
+
- Set `prevPositionX/Y = positionX/Y` when spawning entities (avoids interpolation jump)
|
|
51
136
|
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
- `
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
137
|
+
**NEVER use in simulation code:**
|
|
138
|
+
- `Math.sin/cos/tan/atan2/sqrt/pow/log` — platform-dependent floating point
|
|
139
|
+
- `Math.random()` — non-deterministic
|
|
140
|
+
- `Date.now()` or `performance.now()` — non-deterministic
|
|
141
|
+
- `Array.sort()` without explicit comparator — unstable sort order
|
|
142
|
+
- `for...in` on objects — non-deterministic key order
|
|
143
|
+
- `Map/Set` iteration — insertion-order dependent on network timing
|
|
59
144
|
|
|
60
|
-
|
|
145
|
+
**SAFE Math functions (platform-identical):**
|
|
146
|
+
`Math.abs`, `Math.min`, `Math.max`, `Math.floor`, `Math.ceil`, `Math.round`, `Math.trunc`, `Math.sign`, `Math.fround`
|
|
61
147
|
|
|
62
|
-
|
|
148
|
+
## Input Validation Rules
|
|
63
149
|
|
|
64
|
-
|
|
150
|
+
All RPC data from players is potentially malicious. **In every system reading RPCs:**
|
|
65
151
|
|
|
66
|
-
|
|
152
|
+
1. `Number.isFinite(value)` FIRST — rejects NaN and Infinity
|
|
153
|
+
2. `MathOps.clamp(value, min, max)` SECOND — bounds to valid range
|
|
154
|
+
3. **Never** `MathOps.clamp(NaN, min, max)` — returns NaN, propagates everywhere
|
|
67
155
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
156
|
+
```typescript
|
|
157
|
+
const finite = (v: number): number => Number.isFinite(v) ? v : 0;
|
|
158
|
+
let dirX = MathOps.clamp(finite(rpc.data.directionX), -1, 1);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Schema Quick Reference
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
components:
|
|
165
|
+
MyComponent:
|
|
166
|
+
field: float32 # Types: uint8, uint16, uint32, int8, int16, int32, float32, float64
|
|
167
|
+
arrayField: uint8[16] # Fixed-size array
|
|
168
|
+
TagComponent: {} # Empty = tag (bitmask only, zero memory per entity)
|
|
169
|
+
|
|
170
|
+
singletons:
|
|
171
|
+
GameState:
|
|
172
|
+
phase: uint8
|
|
72
173
|
|
|
73
|
-
|
|
174
|
+
playerResources:
|
|
175
|
+
PlayerResource:
|
|
176
|
+
score: uint32
|
|
177
|
+
|
|
178
|
+
inputs:
|
|
179
|
+
MyInput:
|
|
180
|
+
value: float32
|
|
181
|
+
|
|
182
|
+
filters:
|
|
183
|
+
MyFilter:
|
|
184
|
+
include: [Transform2d, MyComponent]
|
|
185
|
+
exclude: [Frozen] # Optional
|
|
186
|
+
```
|
|
74
187
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
188
|
+
## Key APIs Cheat Sheet
|
|
189
|
+
|
|
190
|
+
| Class | Purpose | Access |
|
|
191
|
+
|-------|---------|--------|
|
|
192
|
+
| `EntitiesManager` | Create/remove entities, add/remove components | DI constructor |
|
|
193
|
+
| `Component.unsafe.field[entity]` | Read/write component data (hot path) | DI constructor |
|
|
194
|
+
| `Component.getCursor(entity)` | Convenient single-entity access | DI constructor |
|
|
195
|
+
| `Filter` | Iterate entities matching component mask | DI constructor, iterable |
|
|
196
|
+
| `PRNG` | Deterministic random: `getFloat()`, `getRandomInt(from, to)` | DI constructor |
|
|
197
|
+
| `ECSConfig` | `maxEntities`, `maxPlayers`, `frameLength`, `tickRate` | DI constructor |
|
|
198
|
+
| `AbstractInputProvider` | `collectTickRPCs(tick, InputClass)` | DI constructor |
|
|
199
|
+
| `Signal` | `emit(tick, data)` / `.Predicted/.Verified/.Cancelled.subscribe()` | DI constructor |
|
|
200
|
+
| `Singleton` | Global typed fields, `singleton.field` | DI constructor |
|
|
201
|
+
| `PlayerResource` | Per-player data, `playerResource.field[slot]` | DI constructor |
|
|
202
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
203
|
+
| `PhysicsWorldManager2d` | Create/remove bodies and colliders | DI constructor |
|
|
204
|
+
| `CollisionEvents2d` | Drain collision start/end events | DI constructor |
|
|
205
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
206
|
+
| `PhysicsWorldManager3d` | Create/remove bodies and colliders | DI constructor |
|
|
207
|
+
| `CollisionEvents3d` | Drain collision start/end events | DI constructor |
|
|
208
|
+
<% } -%>
|
|
209
|
+
|
|
210
|
+
## System Execution Order
|
|
211
|
+
|
|
212
|
+
Systems run in array order every tick. Canonical ordering:
|
|
213
|
+
|
|
214
|
+
1. `SavePrevTransformSystem` — store previous positions for interpolation
|
|
215
|
+
2. `PlayerConnectionSystem` — handle join/leave server events
|
|
216
|
+
3. `ApplyMoveInputSystem` — read RPCs, apply to entities
|
|
217
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
218
|
+
4. `PhysicsStepSystem` — step Rapier 2D, sync transforms
|
|
219
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
220
|
+
4. `PhysicsStepSystem` — step Rapier 3D, sync transforms
|
|
221
|
+
<% } else { -%>
|
|
222
|
+
4. Game logic systems (integrate, damping, boundary, etc.)
|
|
223
|
+
<% } -%>
|
|
224
|
+
5. `PlayerLeaveSystem` — cleanup disconnected player entities
|
|
225
|
+
6. `HashVerificationSystem` — detect simulation divergence (always last)
|
|
226
|
+
|
|
227
|
+
## Detailed Documentation
|
|
228
|
+
|
|
229
|
+
| File | Contents |
|
|
230
|
+
|------|----------|
|
|
231
|
+
| [docs/01-schema-and-codegen.md](docs/01-schema-and-codegen.md) | YAML schema format, field types, codegen workflow, generated files |
|
|
232
|
+
| [docs/02-ecs-systems.md](docs/02-ecs-systems.md) | Writing systems, DI tokens, entity lifecycle, prefabs, PRNG |
|
|
233
|
+
| [docs/03-determinism.md](docs/03-determinism.md) | **CRITICAL** — determinism rules, pitfalls, debugging divergence |
|
|
234
|
+
| [docs/04-input-system.md](docs/04-input-system.md) | RPCs, drainInputs, collectTickRPCs, sanitization, server events |
|
|
235
|
+
| [docs/05-signals.md](docs/05-signals.md) | Predicted/Verified/Cancelled events, rollback behavior |
|
|
236
|
+
| [docs/06-rendering.md](docs/06-rendering.md) | FilterViews, VisualSmoother, Pixi.js patterns |
|
|
237
|
+
| [docs/07-multiplayer.md](docs/07-multiplayer.md) | Relay architecture, RoomHooks, state transfer, reconnect |
|
|
238
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
239
|
+
| [docs/08-physics2d.md](docs/08-physics2d.md) | Rapier 2D integration, bodies, colliders, collision events |
|
|
240
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
241
|
+
| [docs/08-physics3d.md](docs/08-physics3d.md) | Rapier 3D integration, character controller, animation |
|
|
242
|
+
<% } -%>
|
|
243
|
+
| [docs/09-recipes.md](docs/09-recipes.md) | Step-by-step cookbook for common game features |
|
|
244
|
+
| [docs/10-common-mistakes.md](docs/10-common-mistakes.md) | "Never do X" reference + error solutions |
|
|
245
|
+
| [docs/api-quick-reference.md](docs/api-quick-reference.md) | One-page API cheat sheet |
|
|
246
|
+
|
|
247
|
+
## Source Reference
|
|
248
|
+
|
|
249
|
+
`docs/sources/lagless/` contains a full clone of the Lagless framework repository for deep reference.
|
|
250
|
+
- `libs/` — all framework library source code
|
|
251
|
+
- `circle-sumo/` — complete example game (2D, gameplay focused)
|
|
252
|
+
- `sync-test/` — determinism verification test bench
|
|
253
|
+
- `roblox-like/` — 3D example with character controller + BabylonJS
|
|
254
|
+
|
|
255
|
+
**Do NOT import from `docs/sources/`** — always use `@lagless/*` npm packages.
|
|
@@ -12,18 +12,28 @@ A multiplayer game built with [Lagless](https://github.com/GbGr/lagless) — a d
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
pnpm install
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
pnpm dev:backend
|
|
18
|
-
|
|
19
|
-
# Terminal 2 — Start the frontend dev server
|
|
20
|
-
pnpm dev:frontend
|
|
15
|
+
pnpm codegen # Generate ECS code from schema
|
|
16
|
+
pnpm dev # Start backend + frontend + dev-player
|
|
21
17
|
```
|
|
22
18
|
|
|
23
19
|
Open http://localhost:<%= frontendPort %> in your browser. Click "Play Local" for single-player or "Play Online" for multiplayer.
|
|
24
20
|
|
|
25
21
|
Press **F3** to toggle the debug panel (shows network stats, tick info, hash verification).
|
|
26
22
|
|
|
23
|
+
You can also run services individually:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm dev:backend # Game server (Bun, watches for changes)
|
|
27
|
+
pnpm dev:frontend # Frontend (Vite HMR)
|
|
28
|
+
pnpm dev:player # Dev-player (multiplayer testing tool, port 4210)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Dev Player
|
|
32
|
+
|
|
33
|
+
The **dev-player** (http://localhost:4210) is a multiplayer testing tool that opens multiple game instances in a grid. It auto-matchmakes them, displays per-instance network stats, and provides a hash timeline for detecting simulation divergence between clients.
|
|
34
|
+
|
|
35
|
+
Use it to test multiplayer without opening multiple browser tabs manually.
|
|
36
|
+
|
|
27
37
|
## Project Structure
|
|
28
38
|
|
|
29
39
|
```
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"<%= packageName %>-simulation": "workspace:*",
|
|
12
12
|
"@lagless/relay-game-server": "^<%= laglessVersion %>",
|
|
13
|
+
"@lagless/dev-tools": "^<%= laglessVersion %>",
|
|
13
14
|
"@lagless/misc": "^<%= laglessVersion %>",
|
|
14
15
|
"reflect-metadata": "^0.2.0"
|
|
15
16
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { RelayGameServer } from '@lagless/relay-game-server';
|
|
3
|
+
import { setupDevTools } from '@lagless/dev-tools';
|
|
3
4
|
import { <%= projectName %>InputRegistry } from '<%= packageName %>-simulation';
|
|
4
5
|
import { gameHooks } from './game-hooks.js';
|
|
5
6
|
|
|
@@ -25,4 +26,5 @@ const server = new RelayGameServer({
|
|
|
25
26
|
},
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
setupDevTools(server);
|
|
28
30
|
server.start();
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
"@lagless/net-wire": "^<%= laglessVersion %>",
|
|
17
17
|
"@lagless/react": "^<%= laglessVersion %>",
|
|
18
18
|
"@lagless/pixi-react": "^<%= laglessVersion %>",
|
|
19
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
20
|
+
"@lagless/physics2d": "^<%= laglessVersion %>",
|
|
21
|
+
"@dimforge/rapier2d-compat": "^0.14.0",
|
|
22
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
23
|
+
"@lagless/physics3d": "^<%= laglessVersion %>",
|
|
24
|
+
"@lagless/physics-shared": "^<%= laglessVersion %>",
|
|
25
|
+
"@dimforge/rapier3d-compat": "^0.14.0",
|
|
26
|
+
<% } -%>
|
|
19
27
|
"@abraham/reflection": "^0.12.0",
|
|
20
28
|
"pixi.js": "^8.12.0",
|
|
21
29
|
"@pixi/react": "^8.0.5",
|
|
@@ -27,7 +35,6 @@
|
|
|
27
35
|
"@vitejs/plugin-react-swc": "^4.0.0",
|
|
28
36
|
"vite": "^7.0.0",
|
|
29
37
|
"vite-plugin-wasm": "^3.5.0",
|
|
30
|
-
"vite-plugin-top-level-await": "^1.6.0",
|
|
31
38
|
"typescript": "^5.9.0"
|
|
32
39
|
}
|
|
33
40
|
}
|
|
@@ -10,7 +10,11 @@ export const GridBackground: FC = () => {
|
|
|
10
10
|
if (!g) return;
|
|
11
11
|
|
|
12
12
|
const w = <%= projectName %>Arena.width;
|
|
13
|
+
<% if (simulationType === 'physics3d') { -%>
|
|
14
|
+
const h = <%= projectName %>Arena.depth;
|
|
15
|
+
<% } else { -%>
|
|
13
16
|
const h = <%= projectName %>Arena.height;
|
|
17
|
+
<% } -%>
|
|
14
18
|
const step = 100;
|
|
15
19
|
|
|
16
20
|
g.clear();
|
|
@@ -1,3 +1,70 @@
|
|
|
1
|
+
<% if (simulationType === 'physics3d') { -%>
|
|
2
|
+
import { useImperativeHandle, useRef } from 'react';
|
|
3
|
+
import { Container, Graphics } from 'pixi.js';
|
|
4
|
+
import { filterView, FilterViewRef } from '@lagless/pixi-react';
|
|
5
|
+
import { useRunner } from './runner-provider';
|
|
6
|
+
import { PlayerBody, <%= projectName %>Arena, Transform3d } from '<%= packageName %>-simulation';
|
|
7
|
+
import { VisualSmoother2d } from '@lagless/misc';
|
|
8
|
+
|
|
9
|
+
const PLAYER_COLORS = [0xff4444, 0x4488ff, 0x44ff44, 0xffff44];
|
|
10
|
+
|
|
11
|
+
// Top-down rendering: 3D positionX → screen X, positionZ → screen Y
|
|
12
|
+
const SCALE = 20; // pixels per world unit
|
|
13
|
+
const OFFSET_X = 400;
|
|
14
|
+
const OFFSET_Y = 300;
|
|
15
|
+
|
|
16
|
+
export const PlayerView = filterView(({ entity }, ref) => {
|
|
17
|
+
const runner = useRunner();
|
|
18
|
+
const containerRef = useRef<Container>(null);
|
|
19
|
+
const graphicsRef = useRef<Graphics>(null);
|
|
20
|
+
const smootherRef = useRef<VisualSmoother2d>(new VisualSmoother2d());
|
|
21
|
+
|
|
22
|
+
const transform3d = runner.DIContainer.resolve(Transform3d);
|
|
23
|
+
const playerBody = runner.DIContainer.resolve(PlayerBody);
|
|
24
|
+
|
|
25
|
+
useImperativeHandle(ref, (): FilterViewRef => ({
|
|
26
|
+
onCreate() {
|
|
27
|
+
const g = graphicsRef.current;
|
|
28
|
+
if (!g) return;
|
|
29
|
+
const slot = playerBody.unsafe.playerSlot[entity];
|
|
30
|
+
const radius = playerBody.unsafe.radius[entity] * SCALE;
|
|
31
|
+
const color = PLAYER_COLORS[slot % PLAYER_COLORS.length];
|
|
32
|
+
g.clear();
|
|
33
|
+
g.circle(0, 0, radius);
|
|
34
|
+
g.fill(color);
|
|
35
|
+
g.circle(0, 0, radius);
|
|
36
|
+
g.stroke({ color: 0xffffff, width: 2, alpha: 0.3 });
|
|
37
|
+
},
|
|
38
|
+
onUpdate() {
|
|
39
|
+
const container = containerRef.current;
|
|
40
|
+
if (!container) return;
|
|
41
|
+
const smoother = smootherRef.current;
|
|
42
|
+
const factor = runner.Simulation.interpolationFactor;
|
|
43
|
+
|
|
44
|
+
// Map 3D (X, Z) to 2D screen space (top-down view)
|
|
45
|
+
smoother.update(
|
|
46
|
+
transform3d.unsafe.prevPositionX[entity] * SCALE + OFFSET_X,
|
|
47
|
+
transform3d.unsafe.prevPositionZ[entity] * SCALE + OFFSET_Y,
|
|
48
|
+
transform3d.unsafe.positionX[entity] * SCALE + OFFSET_X,
|
|
49
|
+
transform3d.unsafe.positionZ[entity] * SCALE + OFFSET_Y,
|
|
50
|
+
factor,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
container.x = smoother.x;
|
|
54
|
+
container.y = smoother.y;
|
|
55
|
+
},
|
|
56
|
+
onDestroy() {
|
|
57
|
+
// cleanup
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<pixiContainer ref={containerRef}>
|
|
63
|
+
<pixiGraphics ref={graphicsRef} />
|
|
64
|
+
</pixiContainer>
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
<% } else { -%>
|
|
1
68
|
import { useImperativeHandle, useRef } from 'react';
|
|
2
69
|
import { Container, Graphics } from 'pixi.js';
|
|
3
70
|
import { filterView, FilterViewRef } from '@lagless/pixi-react';
|
|
@@ -57,3 +124,4 @@ export const PlayerView = filterView(({ entity }, ref) => {
|
|
|
57
124
|
</pixiContainer>
|
|
58
125
|
);
|
|
59
126
|
});
|
|
127
|
+
<% } -%>
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
PlayerJoined,
|
|
8
8
|
ReportHash,
|
|
9
9
|
<%= projectName %>Arena,
|
|
10
|
+
<% if (simulationType !== 'raw') { -%>
|
|
11
|
+
PhysicsRefs,
|
|
12
|
+
PhysicsRefsFilter,
|
|
13
|
+
PlayerFilter,
|
|
14
|
+
<% } -%>
|
|
10
15
|
} from '<%= packageName %>-simulation';
|
|
11
16
|
import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react';
|
|
12
17
|
import { useTick } from '@pixi/react';
|
|
@@ -16,6 +21,12 @@ import { ECSConfig, LocalInputProvider, RPC, createHashReporter } from '@lagless
|
|
|
16
21
|
import { RelayInputProvider, RelayConnection } from '@lagless/relay-client';
|
|
17
22
|
import { getMatchInfo } from '../hooks/use-start-multiplayer-match';
|
|
18
23
|
import { UUID } from '@lagless/misc';
|
|
24
|
+
import { useDevBridge } from '@lagless/react';
|
|
25
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
26
|
+
import { PhysicsWorldManager2d, type RapierModule2d } from '@lagless/physics2d';
|
|
27
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
28
|
+
import { PhysicsWorldManager3d, type RapierModule3d } from '@lagless/physics3d';
|
|
29
|
+
<% } -%>
|
|
19
30
|
|
|
20
31
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
21
32
|
const RunnerContext = createContext<<%= projectName %>Runner>(null!);
|
|
@@ -66,6 +77,23 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
66
77
|
return;
|
|
67
78
|
}
|
|
68
79
|
|
|
80
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
81
|
+
// Load Rapier 2D WASM
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
const RAPIER = (await import('@dimforge/rapier2d-compat')).default as any;
|
|
84
|
+
await RAPIER.init();
|
|
85
|
+
const rapier = RAPIER as unknown as RapierModule2d;
|
|
86
|
+
if (disposed) { inputProvider.dispose(); return; }
|
|
87
|
+
|
|
88
|
+
<% } else if (simulationType === 'physics3d') { -%>
|
|
89
|
+
// Load Rapier 3D WASM
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
const RAPIER = (await import('@dimforge/rapier3d-compat')).default as any;
|
|
92
|
+
await RAPIER.init();
|
|
93
|
+
const rapier = RAPIER as unknown as RapierModule3d;
|
|
94
|
+
if (disposed) { inputProvider.dispose(); return; }
|
|
95
|
+
|
|
96
|
+
<% } -%>
|
|
69
97
|
if (inputProvider instanceof RelayInputProvider) {
|
|
70
98
|
const matchInfo = getMatchInfo(inputProvider);
|
|
71
99
|
if (matchInfo) {
|
|
@@ -94,14 +122,40 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
94
122
|
if (disposed) { inputProvider.dispose(); return; }
|
|
95
123
|
|
|
96
124
|
const seededConfig = new ECSConfig({ ...inputProvider.ecsConfig, seed: serverHello.seed });
|
|
125
|
+
<% if (simulationType === 'raw') { -%>
|
|
97
126
|
_runner = new <%= projectName %>Runner(seededConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
|
|
127
|
+
<% } else { -%>
|
|
128
|
+
_runner = new <%= projectName %>Runner(seededConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
|
|
129
|
+
<% } -%>
|
|
98
130
|
} else {
|
|
131
|
+
<% if (simulationType === 'raw') { -%>
|
|
99
132
|
_runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
|
|
133
|
+
<% } else { -%>
|
|
134
|
+
_runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
|
|
135
|
+
<% } -%>
|
|
100
136
|
}
|
|
101
137
|
} else {
|
|
138
|
+
<% if (simulationType === 'raw') { -%>
|
|
102
139
|
_runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals);
|
|
140
|
+
<% } else { -%>
|
|
141
|
+
_runner = new <%= projectName %>Runner(inputProvider.ecsConfig, inputProvider, <%= projectName %>Systems, <%= projectName %>Signals, rapier);
|
|
142
|
+
<% } -%>
|
|
103
143
|
}
|
|
104
144
|
|
|
145
|
+
<% if (simulationType !== 'raw') { -%>
|
|
146
|
+
// Hook state transfer to rebuild ColliderEntityMap after receiving external state
|
|
147
|
+
const worldManager = _runner.PhysicsWorldManager;
|
|
148
|
+
_runner.Simulation.addStateTransferHandler(() => {
|
|
149
|
+
worldManager.colliderEntityMap.clear();
|
|
150
|
+
const physicsFilter = _runner.DIContainer.resolve(PhysicsRefsFilter);
|
|
151
|
+
const refs = _runner.DIContainer.resolve(PhysicsRefs);
|
|
152
|
+
const refsUnsafe = refs.unsafe;
|
|
153
|
+
for (const e of physicsFilter) {
|
|
154
|
+
worldManager.registerCollider(refsUnsafe.colliderHandle[e], e);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
<% } -%>
|
|
105
159
|
// Set up keyboard input drainer with hash reporting
|
|
106
160
|
const reportHash = createHashReporter(_runner, {
|
|
107
161
|
reportInterval: <%= projectName %>Arena.hashReportInterval,
|
|
@@ -127,6 +181,7 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
127
181
|
reportHash(addRPC);
|
|
128
182
|
});
|
|
129
183
|
|
|
184
|
+
_runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
|
|
130
185
|
_runner.start();
|
|
131
186
|
|
|
132
187
|
if (inputProvider instanceof RelayInputProvider) {
|
|
@@ -167,6 +222,8 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
167
222
|
};
|
|
168
223
|
}, [v, navigate]);
|
|
169
224
|
|
|
225
|
+
useDevBridge(runner, { hashTrackingInterval: <%= projectName %>Arena.hashReportInterval });
|
|
226
|
+
|
|
170
227
|
return !runner ? null : <RunnerContext.Provider value={runner}>{children}</RunnerContext.Provider>;
|
|
171
228
|
};
|
|
172
229
|
|
package/templates/pixi-react/__packageName__-frontend/src/app/hooks/use-start-multiplayer-match.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { useCallback, useRef, useState } from 'react';
|
|
|
5
5
|
import { useNavigate } from 'react-router-dom';
|
|
6
6
|
import { ProviderStore } from './use-start-match';
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const _params = new URLSearchParams(window.location.search);
|
|
9
|
+
const SERVER_URL = _params.get('serverUrl') || import.meta.env.VITE_RELAY_URL || 'ws://localhost:<%= serverPort %>';
|
|
10
|
+
const SCOPE = _params.get('scope') || '<%= packageName %>';
|
|
9
11
|
|
|
10
12
|
export type MatchmakingState = 'idle' | 'queuing' | 'connecting' | 'error';
|
|
11
13
|
|
|
@@ -48,7 +50,7 @@ export const useStartMultiplayerMatch = () => {
|
|
|
48
50
|
ws.send(
|
|
49
51
|
JSON.stringify({
|
|
50
52
|
type: 'join',
|
|
51
|
-
scope:
|
|
53
|
+
scope: SCOPE,
|
|
52
54
|
}),
|
|
53
55
|
);
|
|
54
56
|
};
|
|
@@ -85,9 +87,7 @@ export const useStartMultiplayerMatch = () => {
|
|
|
85
87
|
};
|
|
86
88
|
|
|
87
89
|
ws.onclose = () => {
|
|
88
|
-
|
|
89
|
-
setState('idle');
|
|
90
|
-
}
|
|
90
|
+
setState((prev) => (prev === 'queuing' ? 'idle' : prev));
|
|
91
91
|
};
|
|
92
92
|
}, [state]);
|
|
93
93
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { FC } from 'react';
|
|
1
|
+
import { FC, useEffect } from 'react';
|
|
2
2
|
import { useStartMatch } from '../hooks/use-start-match';
|
|
3
3
|
import { useStartMultiplayerMatch } from '../hooks/use-start-multiplayer-match';
|
|
4
|
+
import { DevBridge } from '@lagless/react';
|
|
4
5
|
|
|
5
6
|
export const TitleScreen: FC = () => {
|
|
6
7
|
const { isBusy, startMatch } = useStartMatch();
|
|
@@ -8,6 +9,22 @@ export const TitleScreen: FC = () => {
|
|
|
8
9
|
|
|
9
10
|
const isMultiplayerBusy = multiplayer.state !== 'idle';
|
|
10
11
|
|
|
12
|
+
// Dev-bridge: auto-match on URL param or parent command
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const params = new URLSearchParams(window.location.search);
|
|
15
|
+
if (params.get('autoMatch') === 'true' && multiplayer.state === 'idle') {
|
|
16
|
+
multiplayer.startMatch();
|
|
17
|
+
}
|
|
18
|
+
const bridge = DevBridge.fromUrlParams();
|
|
19
|
+
if (!bridge) return;
|
|
20
|
+
bridge.sendMatchState(multiplayer.state === 'idle' ? 'idle' : multiplayer.state);
|
|
21
|
+
return bridge.onParentMessage((msg) => {
|
|
22
|
+
if (msg.type === 'dev-bridge:start-match' && multiplayer.state === 'idle') {
|
|
23
|
+
multiplayer.startMatch();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}, [multiplayer.state]);
|
|
27
|
+
|
|
11
28
|
return (
|
|
12
29
|
<div style={styles.screen}>
|
|
13
30
|
<div style={styles.title}><%= projectName %></div>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { defineConfig } from 'vite';
|
|
2
2
|
import react from '@vitejs/plugin-react-swc';
|
|
3
3
|
import wasm from 'vite-plugin-wasm';
|
|
4
|
-
import topLevelAwait from 'vite-plugin-top-level-await';
|
|
5
4
|
|
|
6
5
|
export default defineConfig(() => ({
|
|
7
6
|
root: __dirname,
|
|
@@ -15,7 +14,6 @@ export default defineConfig(() => ({
|
|
|
15
14
|
},
|
|
16
15
|
plugins: [
|
|
17
16
|
wasm(),
|
|
18
|
-
topLevelAwait(),
|
|
19
17
|
react({
|
|
20
18
|
tsDecorators: true,
|
|
21
19
|
useAtYourOwnRisk_mutateSwcOptions: (options) => {
|