@gamerstake/game-core 0.1.0
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/.eslintrc.json +22 -0
- package/.testing-guide-summary.md +261 -0
- package/DEVELOPER_GUIDE.md +996 -0
- package/MANUAL_TESTING.md +369 -0
- package/QUICK_START.md +368 -0
- package/README.md +379 -0
- package/TESTING_OVERVIEW.md +378 -0
- package/dist/index.d.ts +1266 -0
- package/dist/index.js +1632 -0
- package/dist/index.js.map +1 -0
- package/examples/simple-game/README.md +176 -0
- package/examples/simple-game/client.ts +201 -0
- package/examples/simple-game/package.json +14 -0
- package/examples/simple-game/server.ts +233 -0
- package/jest.config.ts +39 -0
- package/package.json +54 -0
- package/src/core/GameLoop.ts +214 -0
- package/src/core/GameRules.ts +103 -0
- package/src/core/GameServer.ts +200 -0
- package/src/core/Room.ts +368 -0
- package/src/entities/Entity.ts +118 -0
- package/src/entities/Registry.ts +161 -0
- package/src/index.ts +51 -0
- package/src/input/Command.ts +41 -0
- package/src/input/InputQueue.ts +130 -0
- package/src/network/Network.ts +112 -0
- package/src/network/Snapshot.ts +59 -0
- package/src/physics/AABB.ts +104 -0
- package/src/physics/Movement.ts +124 -0
- package/src/spatial/Grid.ts +202 -0
- package/src/types/index.ts +117 -0
- package/src/types/protocol.ts +161 -0
- package/src/utils/Logger.ts +112 -0
- package/src/utils/RingBuffer.ts +116 -0
- package/tests/AABB.test.ts +38 -0
- package/tests/Entity.test.ts +35 -0
- package/tests/GameLoop.test.ts +58 -0
- package/tests/GameServer.test.ts +64 -0
- package/tests/Grid.test.ts +28 -0
- package/tests/InputQueue.test.ts +42 -0
- package/tests/Movement.test.ts +37 -0
- package/tests/Network.test.ts +39 -0
- package/tests/Registry.test.ts +36 -0
- package/tests/RingBuffer.test.ts +38 -0
- package/tests/Room.test.ts +80 -0
- package/tests/Snapshot.test.ts +19 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +14 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
5
|
+
var defaultLogger = pino({
|
|
6
|
+
level: process.env.LOG_LEVEL || "info",
|
|
7
|
+
transport: process.env.NODE_ENV === "development" ? {
|
|
8
|
+
target: "pino-pretty",
|
|
9
|
+
options: {
|
|
10
|
+
colorize: true,
|
|
11
|
+
translateTime: "HH:MM:ss",
|
|
12
|
+
ignore: "pid,hostname"
|
|
13
|
+
}
|
|
14
|
+
} : void 0,
|
|
15
|
+
base: {
|
|
16
|
+
package: "@gamerstake/game-core"
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
var logger = defaultLogger;
|
|
20
|
+
function setLogger(customLogger) {
|
|
21
|
+
logger = customLogger;
|
|
22
|
+
}
|
|
23
|
+
__name(setLogger, "setLogger");
|
|
24
|
+
function createChildLogger(bindings) {
|
|
25
|
+
return logger.child(bindings);
|
|
26
|
+
}
|
|
27
|
+
__name(createChildLogger, "createChildLogger");
|
|
28
|
+
var Logger = class _Logger {
|
|
29
|
+
static {
|
|
30
|
+
__name(this, "Logger");
|
|
31
|
+
}
|
|
32
|
+
logger;
|
|
33
|
+
constructor(bindings) {
|
|
34
|
+
this.logger = bindings ? logger.child(bindings) : logger;
|
|
35
|
+
}
|
|
36
|
+
debug(objOrMsg, msg) {
|
|
37
|
+
if (typeof objOrMsg === "string") {
|
|
38
|
+
this.logger.debug(objOrMsg);
|
|
39
|
+
} else {
|
|
40
|
+
this.logger.debug(objOrMsg, msg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
info(objOrMsg, msg) {
|
|
44
|
+
if (typeof objOrMsg === "string") {
|
|
45
|
+
this.logger.info(objOrMsg);
|
|
46
|
+
} else {
|
|
47
|
+
this.logger.info(objOrMsg, msg);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
warn(objOrMsg, msg) {
|
|
51
|
+
if (typeof objOrMsg === "string") {
|
|
52
|
+
this.logger.warn(objOrMsg);
|
|
53
|
+
} else {
|
|
54
|
+
this.logger.warn(objOrMsg, msg);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
error(objOrMsg, msg) {
|
|
58
|
+
if (typeof objOrMsg === "string") {
|
|
59
|
+
this.logger.error(objOrMsg);
|
|
60
|
+
} else {
|
|
61
|
+
this.logger.error(objOrMsg, msg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
child(bindings) {
|
|
65
|
+
const childLogger = new _Logger();
|
|
66
|
+
childLogger.logger = this.logger.child(bindings);
|
|
67
|
+
return childLogger;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// src/core/GameLoop.ts
|
|
72
|
+
var GameLoop = class {
|
|
73
|
+
static {
|
|
74
|
+
__name(this, "GameLoop");
|
|
75
|
+
}
|
|
76
|
+
tickRate;
|
|
77
|
+
tickMs;
|
|
78
|
+
tickNumber = 0;
|
|
79
|
+
lastTickTime = 0;
|
|
80
|
+
running = false;
|
|
81
|
+
timeout = null;
|
|
82
|
+
handlers = [];
|
|
83
|
+
// Performance metrics
|
|
84
|
+
tickTimes = [];
|
|
85
|
+
metricsWindowSize = 100;
|
|
86
|
+
/**
|
|
87
|
+
* Create a new game loop.
|
|
88
|
+
*
|
|
89
|
+
* @param tickRate - Ticks per second (default: 20)
|
|
90
|
+
*/
|
|
91
|
+
constructor(tickRate = 20) {
|
|
92
|
+
this.tickRate = tickRate;
|
|
93
|
+
this.tickMs = 1e3 / tickRate;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Register a tick handler.
|
|
97
|
+
*/
|
|
98
|
+
addHandler(handler) {
|
|
99
|
+
this.handlers.push(handler);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Remove a tick handler.
|
|
103
|
+
*/
|
|
104
|
+
removeHandler(handler) {
|
|
105
|
+
const index = this.handlers.indexOf(handler);
|
|
106
|
+
if (index !== -1) {
|
|
107
|
+
this.handlers.splice(index, 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Start the game loop.
|
|
112
|
+
*/
|
|
113
|
+
start() {
|
|
114
|
+
if (this.running) {
|
|
115
|
+
logger.warn("Game loop already running");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.running = true;
|
|
119
|
+
this.lastTickTime = performance.now();
|
|
120
|
+
this.tickNumber = 0;
|
|
121
|
+
logger.info({
|
|
122
|
+
tickRate: this.tickRate,
|
|
123
|
+
tickMs: this.tickMs
|
|
124
|
+
}, "Game loop started");
|
|
125
|
+
this.scheduleNextTick();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Stop the game loop.
|
|
129
|
+
*/
|
|
130
|
+
stop() {
|
|
131
|
+
this.running = false;
|
|
132
|
+
if (this.timeout) {
|
|
133
|
+
clearTimeout(this.timeout);
|
|
134
|
+
this.timeout = null;
|
|
135
|
+
}
|
|
136
|
+
logger.info({
|
|
137
|
+
totalTicks: this.tickNumber
|
|
138
|
+
}, "Game loop stopped");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if loop is running.
|
|
142
|
+
*/
|
|
143
|
+
isRunning() {
|
|
144
|
+
return this.running;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Get current tick number.
|
|
148
|
+
*/
|
|
149
|
+
getCurrentTick() {
|
|
150
|
+
return this.tickNumber;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get tick rate (TPS).
|
|
154
|
+
*/
|
|
155
|
+
getTickRate() {
|
|
156
|
+
return this.tickRate;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Get tick duration (ms).
|
|
160
|
+
*/
|
|
161
|
+
getTickMs() {
|
|
162
|
+
return this.tickMs;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Get performance metrics.
|
|
166
|
+
*/
|
|
167
|
+
getMetrics() {
|
|
168
|
+
if (this.tickTimes.length === 0) {
|
|
169
|
+
return {
|
|
170
|
+
avgTickTime: 0,
|
|
171
|
+
maxTickTime: 0,
|
|
172
|
+
minTickTime: 0,
|
|
173
|
+
ticksPerSecond: 0
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const sum = this.tickTimes.reduce((a, b) => a + b, 0);
|
|
177
|
+
const avg = sum / this.tickTimes.length;
|
|
178
|
+
return {
|
|
179
|
+
avgTickTime: avg,
|
|
180
|
+
maxTickTime: Math.max(...this.tickTimes),
|
|
181
|
+
minTickTime: Math.min(...this.tickTimes),
|
|
182
|
+
ticksPerSecond: 1e3 / avg
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Schedule the next tick with drift compensation.
|
|
187
|
+
*/
|
|
188
|
+
scheduleNextTick() {
|
|
189
|
+
if (!this.running) return;
|
|
190
|
+
const now = performance.now();
|
|
191
|
+
const elapsed = now - this.lastTickTime;
|
|
192
|
+
const drift = elapsed - this.tickMs;
|
|
193
|
+
const nextDelay = Math.max(0, this.tickMs - drift);
|
|
194
|
+
this.timeout = setTimeout(() => this.tick(), nextDelay);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Execute one tick.
|
|
198
|
+
*/
|
|
199
|
+
tick() {
|
|
200
|
+
const tickStart = performance.now();
|
|
201
|
+
const deltaMs = tickStart - this.lastTickTime;
|
|
202
|
+
this.lastTickTime = tickStart;
|
|
203
|
+
this.tickNumber++;
|
|
204
|
+
try {
|
|
205
|
+
for (const handler of this.handlers) {
|
|
206
|
+
handler.onTick(this.tickNumber, deltaMs);
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
logger.error({
|
|
210
|
+
error,
|
|
211
|
+
tick: this.tickNumber
|
|
212
|
+
}, "Error in tick handler");
|
|
213
|
+
}
|
|
214
|
+
const tickDuration = performance.now() - tickStart;
|
|
215
|
+
this.recordTickTime(tickDuration);
|
|
216
|
+
if (tickDuration > this.tickMs * 0.8) {
|
|
217
|
+
logger.warn({
|
|
218
|
+
tickNumber: this.tickNumber,
|
|
219
|
+
duration: tickDuration,
|
|
220
|
+
budget: this.tickMs
|
|
221
|
+
}, "Tick took too long");
|
|
222
|
+
}
|
|
223
|
+
this.scheduleNextTick();
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Record tick time for metrics.
|
|
227
|
+
*/
|
|
228
|
+
recordTickTime(duration) {
|
|
229
|
+
this.tickTimes.push(duration);
|
|
230
|
+
if (this.tickTimes.length > this.metricsWindowSize) {
|
|
231
|
+
this.tickTimes.shift();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/entities/Registry.ts
|
|
237
|
+
var Registry = class {
|
|
238
|
+
static {
|
|
239
|
+
__name(this, "Registry");
|
|
240
|
+
}
|
|
241
|
+
entities = /* @__PURE__ */ new Map();
|
|
242
|
+
dirtyEntities = /* @__PURE__ */ new Set();
|
|
243
|
+
// Pre-allocated buffers to avoid allocations in hot paths
|
|
244
|
+
dirtyBuffer = [];
|
|
245
|
+
allBuffer = [];
|
|
246
|
+
/**
|
|
247
|
+
* Add an entity to the registry.
|
|
248
|
+
*/
|
|
249
|
+
add(entity) {
|
|
250
|
+
if (this.entities.has(entity.id)) {
|
|
251
|
+
logger.warn({
|
|
252
|
+
entityId: entity.id
|
|
253
|
+
}, "Entity already exists in registry");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.entities.set(entity.id, entity);
|
|
257
|
+
this.dirtyEntities.add(entity.id);
|
|
258
|
+
logger.debug({
|
|
259
|
+
entityId: entity.id
|
|
260
|
+
}, "Entity added to registry");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Remove an entity from the registry.
|
|
264
|
+
*/
|
|
265
|
+
remove(entityId) {
|
|
266
|
+
const entity = this.entities.get(entityId);
|
|
267
|
+
if (!entity) return false;
|
|
268
|
+
this.entities.delete(entityId);
|
|
269
|
+
this.dirtyEntities.delete(entityId);
|
|
270
|
+
logger.debug({
|
|
271
|
+
entityId
|
|
272
|
+
}, "Entity removed from registry");
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get an entity by ID.
|
|
277
|
+
*/
|
|
278
|
+
get(entityId) {
|
|
279
|
+
return this.entities.get(entityId);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Check if entity exists.
|
|
283
|
+
*/
|
|
284
|
+
has(entityId) {
|
|
285
|
+
return this.entities.has(entityId);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Mark an entity as dirty (needs broadcast).
|
|
289
|
+
*/
|
|
290
|
+
markDirty(entityId) {
|
|
291
|
+
if (this.entities.has(entityId)) {
|
|
292
|
+
this.dirtyEntities.add(entityId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Get all dirty entities and clear dirty flags.
|
|
297
|
+
* OPTIMIZED: Reuses buffer array to avoid allocations.
|
|
298
|
+
*
|
|
299
|
+
* @returns Array of dirty entities (reused buffer - don't store reference!)
|
|
300
|
+
*/
|
|
301
|
+
getDirtyEntities() {
|
|
302
|
+
this.dirtyBuffer.length = 0;
|
|
303
|
+
for (const entityId of this.dirtyEntities) {
|
|
304
|
+
const entity = this.entities.get(entityId);
|
|
305
|
+
if (entity) {
|
|
306
|
+
this.dirtyBuffer.push(entity);
|
|
307
|
+
entity.markClean();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
this.dirtyEntities.clear();
|
|
311
|
+
return this.dirtyBuffer;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Get all entities.
|
|
315
|
+
* OPTIMIZED: Reuses buffer array to avoid allocations.
|
|
316
|
+
*
|
|
317
|
+
* @returns Array of all entities (reused buffer - don't store reference!)
|
|
318
|
+
*/
|
|
319
|
+
getAll() {
|
|
320
|
+
this.allBuffer.length = 0;
|
|
321
|
+
for (const entity of this.entities.values()) {
|
|
322
|
+
this.allBuffer.push(entity);
|
|
323
|
+
}
|
|
324
|
+
return this.allBuffer;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get entity count.
|
|
328
|
+
*/
|
|
329
|
+
size() {
|
|
330
|
+
return this.entities.size;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Get dirty entity count.
|
|
334
|
+
*/
|
|
335
|
+
dirtyCount() {
|
|
336
|
+
return this.dirtyEntities.size;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Clear all entities.
|
|
340
|
+
*/
|
|
341
|
+
clear() {
|
|
342
|
+
this.entities.clear();
|
|
343
|
+
this.dirtyEntities.clear();
|
|
344
|
+
logger.debug("Registry cleared");
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Execute callback for each entity.
|
|
348
|
+
*/
|
|
349
|
+
forEach(callback) {
|
|
350
|
+
for (const entity of this.entities.values()) {
|
|
351
|
+
callback(entity);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Filter entities by predicate.
|
|
356
|
+
*
|
|
357
|
+
* @param predicate - Filter function
|
|
358
|
+
* @returns New array of matching entities
|
|
359
|
+
*/
|
|
360
|
+
filter(predicate) {
|
|
361
|
+
const result = [];
|
|
362
|
+
for (const entity of this.entities.values()) {
|
|
363
|
+
if (predicate(entity)) {
|
|
364
|
+
result.push(entity);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// src/spatial/Grid.ts
|
|
372
|
+
var Grid = class {
|
|
373
|
+
static {
|
|
374
|
+
__name(this, "Grid");
|
|
375
|
+
}
|
|
376
|
+
cellSize;
|
|
377
|
+
cells = /* @__PURE__ */ new Map();
|
|
378
|
+
entityCells = /* @__PURE__ */ new Map();
|
|
379
|
+
/**
|
|
380
|
+
* Create a new spatial grid.
|
|
381
|
+
*
|
|
382
|
+
* @param cellSize - Size of each cell in world units (default: 512)
|
|
383
|
+
*/
|
|
384
|
+
constructor(cellSize = 512) {
|
|
385
|
+
this.cellSize = cellSize;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Convert world coordinates to cell key.
|
|
389
|
+
*/
|
|
390
|
+
getCellKey(worldX, worldY) {
|
|
391
|
+
const cellX = Math.floor(worldX / this.cellSize);
|
|
392
|
+
const cellY = Math.floor(worldY / this.cellSize);
|
|
393
|
+
return `${cellX},${cellY}`;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Get or create a cell at world coordinates.
|
|
397
|
+
*/
|
|
398
|
+
getCell(worldX, worldY) {
|
|
399
|
+
const key = this.getCellKey(worldX, worldY);
|
|
400
|
+
let cell = this.cells.get(key);
|
|
401
|
+
if (!cell) {
|
|
402
|
+
const cellX = Math.floor(worldX / this.cellSize);
|
|
403
|
+
const cellY = Math.floor(worldY / this.cellSize);
|
|
404
|
+
cell = {
|
|
405
|
+
x: cellX,
|
|
406
|
+
y: cellY,
|
|
407
|
+
entities: /* @__PURE__ */ new Set()
|
|
408
|
+
};
|
|
409
|
+
this.cells.set(key, cell);
|
|
410
|
+
}
|
|
411
|
+
return cell;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Add an entity to the grid.
|
|
415
|
+
*/
|
|
416
|
+
addEntity(entityId, x, y) {
|
|
417
|
+
const cell = this.getCell(x, y);
|
|
418
|
+
cell.entities.add(entityId);
|
|
419
|
+
this.entityCells.set(entityId, this.getCellKey(x, y));
|
|
420
|
+
logger.debug({
|
|
421
|
+
entityId,
|
|
422
|
+
cellX: cell.x,
|
|
423
|
+
cellY: cell.y
|
|
424
|
+
}, "Entity added to grid");
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Remove an entity from the grid.
|
|
428
|
+
*/
|
|
429
|
+
removeEntity(entityId) {
|
|
430
|
+
const cellKey = this.entityCells.get(entityId);
|
|
431
|
+
if (!cellKey) return;
|
|
432
|
+
const cell = this.cells.get(cellKey);
|
|
433
|
+
if (cell) {
|
|
434
|
+
cell.entities.delete(entityId);
|
|
435
|
+
if (cell.entities.size === 0) {
|
|
436
|
+
this.cells.delete(cellKey);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.entityCells.delete(entityId);
|
|
440
|
+
logger.debug({
|
|
441
|
+
entityId
|
|
442
|
+
}, "Entity removed from grid");
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Move an entity to a new position.
|
|
446
|
+
* Returns true if the entity changed cells.
|
|
447
|
+
*/
|
|
448
|
+
moveEntity(entityId, oldX, oldY, newX, newY) {
|
|
449
|
+
const oldKey = this.getCellKey(oldX, oldY);
|
|
450
|
+
const newKey = this.getCellKey(newX, newY);
|
|
451
|
+
if (oldKey === newKey) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
const oldCell = this.cells.get(oldKey);
|
|
455
|
+
if (oldCell) {
|
|
456
|
+
oldCell.entities.delete(entityId);
|
|
457
|
+
if (oldCell.entities.size === 0) {
|
|
458
|
+
this.cells.delete(oldKey);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const newCell = this.getCell(newX, newY);
|
|
462
|
+
newCell.entities.add(entityId);
|
|
463
|
+
this.entityCells.set(entityId, newKey);
|
|
464
|
+
logger.debug({
|
|
465
|
+
entityId,
|
|
466
|
+
oldKey,
|
|
467
|
+
newKey
|
|
468
|
+
}, "Entity moved to new cell");
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Get all entities in nearby cells.
|
|
473
|
+
*
|
|
474
|
+
* @param worldX - World X coordinate
|
|
475
|
+
* @param worldY - World Y coordinate
|
|
476
|
+
* @param range - Range in grid cells (default: 1 = 3x3 area)
|
|
477
|
+
*/
|
|
478
|
+
getNearbyEntities(worldX, worldY, range = 1) {
|
|
479
|
+
const centerCellX = Math.floor(worldX / this.cellSize);
|
|
480
|
+
const centerCellY = Math.floor(worldY / this.cellSize);
|
|
481
|
+
const entities = [];
|
|
482
|
+
for (let dx = -range; dx <= range; dx++) {
|
|
483
|
+
for (let dy = -range; dy <= range; dy++) {
|
|
484
|
+
const key = `${centerCellX + dx},${centerCellY + dy}`;
|
|
485
|
+
const cell = this.cells.get(key);
|
|
486
|
+
if (cell) {
|
|
487
|
+
entities.push(...cell.entities);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return entities;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Get entities in a specific cell.
|
|
495
|
+
*/
|
|
496
|
+
getEntitiesInCell(worldX, worldY) {
|
|
497
|
+
const cell = this.cells.get(this.getCellKey(worldX, worldY));
|
|
498
|
+
return cell ? Array.from(cell.entities) : [];
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Get the cell an entity is in.
|
|
502
|
+
*/
|
|
503
|
+
getEntityCell(entityId) {
|
|
504
|
+
return this.entityCells.get(entityId);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Get total number of active cells.
|
|
508
|
+
*/
|
|
509
|
+
getCellCount() {
|
|
510
|
+
return this.cells.size;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Get total entities in grid.
|
|
514
|
+
*/
|
|
515
|
+
getEntityCount() {
|
|
516
|
+
return this.entityCells.size;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Clear all cells and entities.
|
|
520
|
+
*/
|
|
521
|
+
clear() {
|
|
522
|
+
this.cells.clear();
|
|
523
|
+
this.entityCells.clear();
|
|
524
|
+
logger.debug("Grid cleared");
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/utils/RingBuffer.ts
|
|
529
|
+
var RingBuffer = class {
|
|
530
|
+
static {
|
|
531
|
+
__name(this, "RingBuffer");
|
|
532
|
+
}
|
|
533
|
+
capacity;
|
|
534
|
+
buffer;
|
|
535
|
+
head;
|
|
536
|
+
tail;
|
|
537
|
+
count;
|
|
538
|
+
constructor(capacity) {
|
|
539
|
+
this.capacity = capacity;
|
|
540
|
+
this.head = 0;
|
|
541
|
+
this.tail = 0;
|
|
542
|
+
this.count = 0;
|
|
543
|
+
this.buffer = new Array(capacity);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Add item to buffer. O(1)
|
|
547
|
+
* Returns false if buffer is full.
|
|
548
|
+
*/
|
|
549
|
+
push(item) {
|
|
550
|
+
if (this.count >= this.capacity) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
this.buffer[this.head] = item;
|
|
554
|
+
this.head = (this.head + 1) % this.capacity;
|
|
555
|
+
this.count++;
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Add item, overwriting oldest if full. O(1)
|
|
560
|
+
*/
|
|
561
|
+
pushOverwrite(item) {
|
|
562
|
+
let dropped;
|
|
563
|
+
if (this.count >= this.capacity) {
|
|
564
|
+
dropped = this.buffer[this.tail];
|
|
565
|
+
this.tail = (this.tail + 1) % this.capacity;
|
|
566
|
+
this.count--;
|
|
567
|
+
}
|
|
568
|
+
this.buffer[this.head] = item;
|
|
569
|
+
this.head = (this.head + 1) % this.capacity;
|
|
570
|
+
this.count++;
|
|
571
|
+
return dropped;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Remove and return oldest item. O(1)
|
|
575
|
+
*/
|
|
576
|
+
shift() {
|
|
577
|
+
if (this.count === 0) {
|
|
578
|
+
return void 0;
|
|
579
|
+
}
|
|
580
|
+
const item = this.buffer[this.tail];
|
|
581
|
+
this.buffer[this.tail] = void 0;
|
|
582
|
+
this.tail = (this.tail + 1) % this.capacity;
|
|
583
|
+
this.count--;
|
|
584
|
+
return item;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Peek at oldest item without removing. O(1)
|
|
588
|
+
*/
|
|
589
|
+
peek() {
|
|
590
|
+
if (this.count === 0) {
|
|
591
|
+
return void 0;
|
|
592
|
+
}
|
|
593
|
+
return this.buffer[this.tail];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Get current size.
|
|
597
|
+
*/
|
|
598
|
+
size() {
|
|
599
|
+
return this.count;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Check if empty.
|
|
603
|
+
*/
|
|
604
|
+
isEmpty() {
|
|
605
|
+
return this.count === 0;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Check if full.
|
|
609
|
+
*/
|
|
610
|
+
isFull() {
|
|
611
|
+
return this.count >= this.capacity;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Clear all items. O(1)
|
|
615
|
+
*/
|
|
616
|
+
clear() {
|
|
617
|
+
this.head = 0;
|
|
618
|
+
this.tail = 0;
|
|
619
|
+
this.count = 0;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Get capacity.
|
|
623
|
+
*/
|
|
624
|
+
getCapacity() {
|
|
625
|
+
return this.capacity;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/input/InputQueue.ts
|
|
630
|
+
var InputQueue = class {
|
|
631
|
+
static {
|
|
632
|
+
__name(this, "InputQueue");
|
|
633
|
+
}
|
|
634
|
+
queues = /* @__PURE__ */ new Map();
|
|
635
|
+
maxQueueSize;
|
|
636
|
+
/**
|
|
637
|
+
* Create a new input queue manager.
|
|
638
|
+
*
|
|
639
|
+
* @param maxQueueSize - Maximum inputs per player (default: 100)
|
|
640
|
+
*/
|
|
641
|
+
constructor(maxQueueSize = 100) {
|
|
642
|
+
this.maxQueueSize = maxQueueSize;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Get or create a player's queue.
|
|
646
|
+
*/
|
|
647
|
+
getQueue(playerId) {
|
|
648
|
+
let queue = this.queues.get(playerId);
|
|
649
|
+
if (!queue) {
|
|
650
|
+
queue = new RingBuffer(this.maxQueueSize);
|
|
651
|
+
this.queues.set(playerId, queue);
|
|
652
|
+
}
|
|
653
|
+
return queue;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Add an input to a player's queue. O(1)
|
|
657
|
+
*/
|
|
658
|
+
push(playerId, input) {
|
|
659
|
+
const queue = this.getQueue(playerId);
|
|
660
|
+
const dropped = queue.pushOverwrite(input);
|
|
661
|
+
if (dropped) {
|
|
662
|
+
logger.debug({
|
|
663
|
+
playerId
|
|
664
|
+
}, "Input queue overflow, dropped oldest");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Get and remove the next input for a player. O(1)
|
|
669
|
+
*/
|
|
670
|
+
pop(playerId) {
|
|
671
|
+
const queue = this.queues.get(playerId);
|
|
672
|
+
if (!queue) return void 0;
|
|
673
|
+
return queue.shift();
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Peek at the next input without removing. O(1)
|
|
677
|
+
*/
|
|
678
|
+
peek(playerId) {
|
|
679
|
+
const queue = this.queues.get(playerId);
|
|
680
|
+
return queue?.peek();
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Get all inputs for a player and clear.
|
|
684
|
+
*/
|
|
685
|
+
drain(playerId) {
|
|
686
|
+
const queue = this.queues.get(playerId);
|
|
687
|
+
if (!queue) return [];
|
|
688
|
+
const inputs = [];
|
|
689
|
+
while (!queue.isEmpty()) {
|
|
690
|
+
const input = queue.shift();
|
|
691
|
+
if (input) inputs.push(input);
|
|
692
|
+
}
|
|
693
|
+
return inputs;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Get queue size for a player.
|
|
697
|
+
*/
|
|
698
|
+
size(playerId) {
|
|
699
|
+
return this.queues.get(playerId)?.size() ?? 0;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Clear a player's queue.
|
|
703
|
+
*/
|
|
704
|
+
clear(playerId) {
|
|
705
|
+
const queue = this.queues.get(playerId);
|
|
706
|
+
if (queue) {
|
|
707
|
+
queue.clear();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Remove a player's queue entirely.
|
|
712
|
+
*/
|
|
713
|
+
remove(playerId) {
|
|
714
|
+
this.queues.delete(playerId);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Clear all queues.
|
|
718
|
+
*/
|
|
719
|
+
clearAll() {
|
|
720
|
+
this.queues.clear();
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Get total queued inputs across all players.
|
|
724
|
+
*/
|
|
725
|
+
getTotalSize() {
|
|
726
|
+
let total = 0;
|
|
727
|
+
for (const queue of this.queues.values()) {
|
|
728
|
+
total += queue.size();
|
|
729
|
+
}
|
|
730
|
+
return total;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// src/network/Network.ts
|
|
735
|
+
var Network = class {
|
|
736
|
+
static {
|
|
737
|
+
__name(this, "Network");
|
|
738
|
+
}
|
|
739
|
+
io = null;
|
|
740
|
+
sockets = /* @__PURE__ */ new Map();
|
|
741
|
+
/**
|
|
742
|
+
* Set the Socket.io server instance.
|
|
743
|
+
*/
|
|
744
|
+
setServer(io) {
|
|
745
|
+
this.io = io;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Register a socket connection.
|
|
749
|
+
*/
|
|
750
|
+
registerSocket(playerId, socket) {
|
|
751
|
+
this.sockets.set(playerId, socket);
|
|
752
|
+
logger.debug({
|
|
753
|
+
playerId
|
|
754
|
+
}, "Socket registered");
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Unregister a socket connection.
|
|
758
|
+
*/
|
|
759
|
+
unregisterSocket(playerId) {
|
|
760
|
+
this.sockets.delete(playerId);
|
|
761
|
+
logger.debug({
|
|
762
|
+
playerId
|
|
763
|
+
}, "Socket unregistered");
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Get a socket by player ID.
|
|
767
|
+
*/
|
|
768
|
+
getSocket(playerId) {
|
|
769
|
+
return this.sockets.get(playerId);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Broadcast event to all connected players.
|
|
773
|
+
*/
|
|
774
|
+
broadcast(event) {
|
|
775
|
+
if (!this.io) {
|
|
776
|
+
logger.warn("Cannot broadcast - no Socket.io server set");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
this.io.emit(event.op, event);
|
|
780
|
+
logger.debug({
|
|
781
|
+
opcode: event.op
|
|
782
|
+
}, "Broadcast event");
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Broadcast event to all players in a room.
|
|
786
|
+
*/
|
|
787
|
+
broadcastToRoom(roomId, event) {
|
|
788
|
+
if (!this.io) {
|
|
789
|
+
logger.warn("Cannot broadcast - no Socket.io server set");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.io.to(roomId).emit(event.op, event);
|
|
793
|
+
logger.debug({
|
|
794
|
+
roomId,
|
|
795
|
+
opcode: event.op
|
|
796
|
+
}, "Broadcast to room");
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Send event to a specific player.
|
|
800
|
+
*/
|
|
801
|
+
sendTo(playerId, event) {
|
|
802
|
+
const socket = this.sockets.get(playerId);
|
|
803
|
+
if (!socket) {
|
|
804
|
+
logger.warn({
|
|
805
|
+
playerId
|
|
806
|
+
}, "Cannot send - socket not found");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
socket.emit(event.op, event);
|
|
810
|
+
logger.debug({
|
|
811
|
+
playerId,
|
|
812
|
+
opcode: event.op
|
|
813
|
+
}, "Sent to player");
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Send event to multiple players.
|
|
817
|
+
*/
|
|
818
|
+
sendToMany(playerIds, event) {
|
|
819
|
+
for (const playerId of playerIds) {
|
|
820
|
+
this.sendTo(playerId, event);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Get connected player count.
|
|
825
|
+
*/
|
|
826
|
+
getPlayerCount() {
|
|
827
|
+
return this.sockets.size;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Clear all sockets.
|
|
831
|
+
*/
|
|
832
|
+
clear() {
|
|
833
|
+
this.sockets.clear();
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// src/network/Snapshot.ts
|
|
838
|
+
var Snapshot = class {
|
|
839
|
+
static {
|
|
840
|
+
__name(this, "Snapshot");
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Create a full state snapshot from entities.
|
|
844
|
+
*
|
|
845
|
+
* @param tick - Current tick number
|
|
846
|
+
* @param entities - Array of entities to snapshot
|
|
847
|
+
* @returns Full state snapshot
|
|
848
|
+
*/
|
|
849
|
+
static create(tick, entities) {
|
|
850
|
+
return {
|
|
851
|
+
tick,
|
|
852
|
+
timestamp: Date.now(),
|
|
853
|
+
entities: entities.map((e) => this.entityToSnapshot(e))
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Convert an entity to a snapshot.
|
|
858
|
+
*/
|
|
859
|
+
static entityToSnapshot(entity) {
|
|
860
|
+
return {
|
|
861
|
+
id: entity.id,
|
|
862
|
+
x: entity.x,
|
|
863
|
+
y: entity.y,
|
|
864
|
+
vx: entity.vx,
|
|
865
|
+
vy: entity.vy
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Create a delta snapshot (only dirty entities).
|
|
870
|
+
*
|
|
871
|
+
* @param tick - Current tick number
|
|
872
|
+
* @param dirtyEntities - Array of dirty entities
|
|
873
|
+
* @returns Delta snapshot
|
|
874
|
+
*/
|
|
875
|
+
static createDelta(tick, dirtyEntities) {
|
|
876
|
+
return {
|
|
877
|
+
tick,
|
|
878
|
+
timestamp: Date.now(),
|
|
879
|
+
entities: dirtyEntities.map((e) => this.entityToSnapshot(e))
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// src/core/Room.ts
|
|
885
|
+
var Room = class {
|
|
886
|
+
static {
|
|
887
|
+
__name(this, "Room");
|
|
888
|
+
}
|
|
889
|
+
/** Room identifier */
|
|
890
|
+
id;
|
|
891
|
+
/** Game-specific logic */
|
|
892
|
+
rules;
|
|
893
|
+
// Core systems
|
|
894
|
+
registry;
|
|
895
|
+
grid;
|
|
896
|
+
loop;
|
|
897
|
+
inputQueue;
|
|
898
|
+
network;
|
|
899
|
+
// State
|
|
900
|
+
players = /* @__PURE__ */ new Map();
|
|
901
|
+
tickCount = 0;
|
|
902
|
+
startTime = 0;
|
|
903
|
+
config;
|
|
904
|
+
/**
|
|
905
|
+
* Create a new room.
|
|
906
|
+
*
|
|
907
|
+
* @param id - Unique room identifier
|
|
908
|
+
* @param rules - Game-specific logic
|
|
909
|
+
* @param config - Room configuration
|
|
910
|
+
*/
|
|
911
|
+
constructor(id, rules, config) {
|
|
912
|
+
this.id = id;
|
|
913
|
+
this.rules = rules;
|
|
914
|
+
this.config = {
|
|
915
|
+
tickRate: config?.tickRate ?? 20,
|
|
916
|
+
cellSize: config?.cellSize ?? 512,
|
|
917
|
+
maxInputQueueSize: config?.maxInputQueueSize ?? 100,
|
|
918
|
+
maxEntities: config?.maxEntities ?? 1e3,
|
|
919
|
+
visibilityRange: config?.visibilityRange ?? 1
|
|
920
|
+
};
|
|
921
|
+
this.registry = new Registry();
|
|
922
|
+
this.grid = new Grid(this.config.cellSize);
|
|
923
|
+
this.loop = new GameLoop(this.config.tickRate);
|
|
924
|
+
this.inputQueue = new InputQueue(this.config.maxInputQueueSize);
|
|
925
|
+
this.network = new Network();
|
|
926
|
+
this.loop.addHandler(this);
|
|
927
|
+
logger.info({
|
|
928
|
+
roomId: id,
|
|
929
|
+
config: this.config
|
|
930
|
+
}, "Room created");
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Start the room (begins tick loop).
|
|
934
|
+
*/
|
|
935
|
+
start() {
|
|
936
|
+
this.startTime = Date.now();
|
|
937
|
+
try {
|
|
938
|
+
this.rules.onRoomCreated(this);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
logger.error({
|
|
941
|
+
error,
|
|
942
|
+
roomId: this.id
|
|
943
|
+
}, "Error in onRoomCreated");
|
|
944
|
+
}
|
|
945
|
+
this.loop.start();
|
|
946
|
+
logger.info({
|
|
947
|
+
roomId: this.id
|
|
948
|
+
}, "Room started");
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Stop the room (ends tick loop).
|
|
952
|
+
*/
|
|
953
|
+
stop() {
|
|
954
|
+
this.loop.stop();
|
|
955
|
+
logger.info({
|
|
956
|
+
roomId: this.id,
|
|
957
|
+
totalTicks: this.tickCount
|
|
958
|
+
}, "Room stopped");
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Add a player to the room.
|
|
962
|
+
*/
|
|
963
|
+
addPlayer(player) {
|
|
964
|
+
if (this.players.has(player.id)) {
|
|
965
|
+
logger.warn({
|
|
966
|
+
playerId: player.id
|
|
967
|
+
}, "Player already in room");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
this.players.set(player.id, player);
|
|
971
|
+
this.registry.add(player);
|
|
972
|
+
this.grid.addEntity(player.id, player.x, player.y);
|
|
973
|
+
try {
|
|
974
|
+
this.rules.onPlayerJoin(this, player);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
logger.error({
|
|
977
|
+
error,
|
|
978
|
+
playerId: player.id
|
|
979
|
+
}, "Error in onPlayerJoin");
|
|
980
|
+
}
|
|
981
|
+
logger.info({
|
|
982
|
+
roomId: this.id,
|
|
983
|
+
playerId: player.id
|
|
984
|
+
}, "Player joined");
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Remove a player from the room.
|
|
988
|
+
*/
|
|
989
|
+
removePlayer(playerId) {
|
|
990
|
+
const player = this.players.get(playerId);
|
|
991
|
+
if (!player) return;
|
|
992
|
+
this.players.delete(playerId);
|
|
993
|
+
this.registry.remove(playerId);
|
|
994
|
+
this.grid.removeEntity(playerId);
|
|
995
|
+
this.inputQueue.remove(playerId);
|
|
996
|
+
try {
|
|
997
|
+
this.rules.onPlayerLeave(this, playerId);
|
|
998
|
+
} catch (error) {
|
|
999
|
+
logger.error({
|
|
1000
|
+
error,
|
|
1001
|
+
playerId
|
|
1002
|
+
}, "Error in onPlayerLeave");
|
|
1003
|
+
}
|
|
1004
|
+
logger.info({
|
|
1005
|
+
roomId: this.id,
|
|
1006
|
+
playerId
|
|
1007
|
+
}, "Player left");
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Spawn a non-player entity.
|
|
1011
|
+
*/
|
|
1012
|
+
spawnEntity(entity) {
|
|
1013
|
+
if (this.registry.size() >= this.config.maxEntities) {
|
|
1014
|
+
logger.warn({
|
|
1015
|
+
roomId: this.id
|
|
1016
|
+
}, "Max entities reached, cannot spawn");
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
this.registry.add(entity);
|
|
1020
|
+
this.grid.addEntity(entity.id, entity.x, entity.y);
|
|
1021
|
+
logger.debug({
|
|
1022
|
+
entityId: entity.id
|
|
1023
|
+
}, "Entity spawned");
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Destroy an entity.
|
|
1027
|
+
*/
|
|
1028
|
+
destroyEntity(entityId) {
|
|
1029
|
+
const entity = this.registry.get(entityId);
|
|
1030
|
+
if (!entity) return;
|
|
1031
|
+
this.registry.remove(entityId);
|
|
1032
|
+
this.grid.removeEntity(entityId);
|
|
1033
|
+
logger.debug({
|
|
1034
|
+
entityId
|
|
1035
|
+
}, "Entity destroyed");
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Queue a player input.
|
|
1039
|
+
*/
|
|
1040
|
+
queueInput(playerId, command) {
|
|
1041
|
+
if (!this.players.has(playerId)) {
|
|
1042
|
+
logger.warn({
|
|
1043
|
+
playerId
|
|
1044
|
+
}, "Input from non-player");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
this.inputQueue.push(playerId, command);
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Broadcast event to all players.
|
|
1051
|
+
*/
|
|
1052
|
+
broadcast(event) {
|
|
1053
|
+
this.network.broadcastToRoom(this.id, event);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Send event to specific player.
|
|
1057
|
+
*/
|
|
1058
|
+
sendTo(playerId, event) {
|
|
1059
|
+
this.network.sendTo(playerId, event);
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Get full state snapshot.
|
|
1063
|
+
*/
|
|
1064
|
+
getSnapshot() {
|
|
1065
|
+
return Snapshot.create(this.tickCount, this.registry.getAll());
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Get delta snapshot (only dirty entities).
|
|
1069
|
+
*/
|
|
1070
|
+
getDeltaSnapshot() {
|
|
1071
|
+
return Snapshot.createDelta(this.tickCount, this.registry.getDirtyEntities());
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Main tick function (called by GameLoop).
|
|
1075
|
+
*/
|
|
1076
|
+
onTick(tickNumber, deltaMs) {
|
|
1077
|
+
this.tickCount = tickNumber;
|
|
1078
|
+
for (const [playerId] of this.players) {
|
|
1079
|
+
const inputs = this.inputQueue.drain(playerId);
|
|
1080
|
+
for (const input of inputs) {
|
|
1081
|
+
try {
|
|
1082
|
+
this.rules.onCommand(this, playerId, input);
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
logger.error({
|
|
1085
|
+
error,
|
|
1086
|
+
playerId,
|
|
1087
|
+
command: input.type
|
|
1088
|
+
}, "Error in onCommand");
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
this.rules.onTick(this, deltaMs);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
logger.error({
|
|
1096
|
+
error,
|
|
1097
|
+
tick: tickNumber
|
|
1098
|
+
}, "Error in onTick");
|
|
1099
|
+
}
|
|
1100
|
+
const dirtyEntities = this.registry.getDirtyEntities();
|
|
1101
|
+
if (dirtyEntities.length > 0) {
|
|
1102
|
+
const delta = Snapshot.createDelta(this.tickCount, dirtyEntities);
|
|
1103
|
+
this.broadcast({
|
|
1104
|
+
op: "S_UPDATE",
|
|
1105
|
+
...delta
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
if (this.rules.shouldEndRoom(this)) {
|
|
1110
|
+
logger.info({
|
|
1111
|
+
roomId: this.id
|
|
1112
|
+
}, "Room ending per game rules");
|
|
1113
|
+
this.stop();
|
|
1114
|
+
}
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
logger.error({
|
|
1117
|
+
error
|
|
1118
|
+
}, "Error in shouldEndRoom");
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// Getters
|
|
1122
|
+
/**
|
|
1123
|
+
* Get all players.
|
|
1124
|
+
*/
|
|
1125
|
+
getPlayers() {
|
|
1126
|
+
return this.players;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Get entity registry.
|
|
1130
|
+
*/
|
|
1131
|
+
getRegistry() {
|
|
1132
|
+
return this.registry;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Get spatial grid.
|
|
1136
|
+
*/
|
|
1137
|
+
getGrid() {
|
|
1138
|
+
return this.grid;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Get network layer.
|
|
1142
|
+
*/
|
|
1143
|
+
getNetwork() {
|
|
1144
|
+
return this.network;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Get input queue.
|
|
1148
|
+
*/
|
|
1149
|
+
getInputQueue() {
|
|
1150
|
+
return this.inputQueue;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Get current tick number.
|
|
1154
|
+
*/
|
|
1155
|
+
getTickCount() {
|
|
1156
|
+
return this.tickCount;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Get uptime in milliseconds.
|
|
1160
|
+
*/
|
|
1161
|
+
getUptime() {
|
|
1162
|
+
return Date.now() - this.startTime;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Check if room is running.
|
|
1166
|
+
*/
|
|
1167
|
+
isRunning() {
|
|
1168
|
+
return this.loop.isRunning();
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Get room configuration.
|
|
1172
|
+
*/
|
|
1173
|
+
getConfig() {
|
|
1174
|
+
return {
|
|
1175
|
+
...this.config
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Get room metrics.
|
|
1180
|
+
*/
|
|
1181
|
+
getMetrics() {
|
|
1182
|
+
const loopMetrics = this.loop.getMetrics();
|
|
1183
|
+
return {
|
|
1184
|
+
roomId: this.id,
|
|
1185
|
+
tickCount: this.tickCount,
|
|
1186
|
+
uptime: this.getUptime(),
|
|
1187
|
+
playerCount: this.players.size,
|
|
1188
|
+
entityCount: this.registry.size(),
|
|
1189
|
+
cellCount: this.grid.getCellCount(),
|
|
1190
|
+
queuedInputs: this.inputQueue.getTotalSize(),
|
|
1191
|
+
...loopMetrics
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// src/core/GameServer.ts
|
|
1197
|
+
var GameServer = class {
|
|
1198
|
+
static {
|
|
1199
|
+
__name(this, "GameServer");
|
|
1200
|
+
}
|
|
1201
|
+
rooms = /* @__PURE__ */ new Map();
|
|
1202
|
+
io = null;
|
|
1203
|
+
/**
|
|
1204
|
+
* Set the Socket.io server instance.
|
|
1205
|
+
*
|
|
1206
|
+
* This should be called before creating rooms to enable
|
|
1207
|
+
* network functionality.
|
|
1208
|
+
*/
|
|
1209
|
+
setServer(io) {
|
|
1210
|
+
this.io = io;
|
|
1211
|
+
for (const room of this.rooms.values()) {
|
|
1212
|
+
room.getNetwork().setServer(io);
|
|
1213
|
+
}
|
|
1214
|
+
logger.info("Socket.io server set");
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Create a new room.
|
|
1218
|
+
*
|
|
1219
|
+
* @param id - Unique room identifier
|
|
1220
|
+
* @param rules - Game-specific logic
|
|
1221
|
+
* @param config - Room configuration
|
|
1222
|
+
* @returns The created room
|
|
1223
|
+
* @throws If room with same ID already exists
|
|
1224
|
+
*/
|
|
1225
|
+
createRoom(id, rules, config) {
|
|
1226
|
+
if (this.rooms.has(id)) {
|
|
1227
|
+
throw new Error(`Room ${id} already exists`);
|
|
1228
|
+
}
|
|
1229
|
+
const room = new Room(id, rules, config);
|
|
1230
|
+
if (this.io) {
|
|
1231
|
+
room.getNetwork().setServer(this.io);
|
|
1232
|
+
}
|
|
1233
|
+
this.rooms.set(id, room);
|
|
1234
|
+
room.start();
|
|
1235
|
+
logger.info({
|
|
1236
|
+
roomId: id
|
|
1237
|
+
}, "Room created and started");
|
|
1238
|
+
return room;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Destroy a room.
|
|
1242
|
+
*
|
|
1243
|
+
* Stops the room's tick loop and removes it from the server.
|
|
1244
|
+
*
|
|
1245
|
+
* @param id - Room identifier
|
|
1246
|
+
* @returns True if room was destroyed, false if not found
|
|
1247
|
+
*/
|
|
1248
|
+
destroyRoom(id) {
|
|
1249
|
+
const room = this.rooms.get(id);
|
|
1250
|
+
if (!room) {
|
|
1251
|
+
logger.warn({
|
|
1252
|
+
roomId: id
|
|
1253
|
+
}, "Cannot destroy - room not found");
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
room.stop();
|
|
1257
|
+
this.rooms.delete(id);
|
|
1258
|
+
logger.info({
|
|
1259
|
+
roomId: id
|
|
1260
|
+
}, "Room destroyed");
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Get a room by ID.
|
|
1265
|
+
*/
|
|
1266
|
+
getRoom(id) {
|
|
1267
|
+
return this.rooms.get(id);
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Get a room with specific entity type.
|
|
1271
|
+
*
|
|
1272
|
+
* @param id - Room identifier
|
|
1273
|
+
* @returns Room cast to specific entity type, or undefined
|
|
1274
|
+
*/
|
|
1275
|
+
getRoomAs(id) {
|
|
1276
|
+
return this.rooms.get(id);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Check if room exists.
|
|
1280
|
+
*/
|
|
1281
|
+
hasRoom(id) {
|
|
1282
|
+
return this.rooms.has(id);
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Get all rooms.
|
|
1286
|
+
*/
|
|
1287
|
+
getRooms() {
|
|
1288
|
+
return this.rooms;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Get room count.
|
|
1292
|
+
*/
|
|
1293
|
+
getRoomCount() {
|
|
1294
|
+
return this.rooms.size;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Get total player count across all rooms.
|
|
1298
|
+
*/
|
|
1299
|
+
getTotalPlayerCount() {
|
|
1300
|
+
let total = 0;
|
|
1301
|
+
for (const room of this.rooms.values()) {
|
|
1302
|
+
total += room.getPlayers().size;
|
|
1303
|
+
}
|
|
1304
|
+
return total;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Get server health metrics.
|
|
1308
|
+
*/
|
|
1309
|
+
getMetrics() {
|
|
1310
|
+
return {
|
|
1311
|
+
roomCount: this.rooms.size,
|
|
1312
|
+
totalPlayers: this.getTotalPlayerCount(),
|
|
1313
|
+
rooms: Array.from(this.rooms.values()).map((room) => ({
|
|
1314
|
+
id: room.id,
|
|
1315
|
+
playerCount: room.getPlayers().size,
|
|
1316
|
+
entityCount: room.getRegistry().size(),
|
|
1317
|
+
tickCount: room.getTickCount(),
|
|
1318
|
+
isRunning: room.isRunning(),
|
|
1319
|
+
avgTickTime: room.getMetrics().avgTickTime,
|
|
1320
|
+
ticksPerSecond: room.getMetrics().ticksPerSecond
|
|
1321
|
+
}))
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Stop all rooms.
|
|
1326
|
+
*/
|
|
1327
|
+
stopAll() {
|
|
1328
|
+
logger.info({
|
|
1329
|
+
roomCount: this.rooms.size
|
|
1330
|
+
}, "Stopping all rooms");
|
|
1331
|
+
for (const room of this.rooms.values()) {
|
|
1332
|
+
room.stop();
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Destroy all rooms.
|
|
1337
|
+
*/
|
|
1338
|
+
destroyAll() {
|
|
1339
|
+
logger.info({
|
|
1340
|
+
roomCount: this.rooms.size
|
|
1341
|
+
}, "Destroying all rooms");
|
|
1342
|
+
for (const room of this.rooms.values()) {
|
|
1343
|
+
room.stop();
|
|
1344
|
+
}
|
|
1345
|
+
this.rooms.clear();
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
// src/entities/Entity.ts
|
|
1350
|
+
var Entity = class {
|
|
1351
|
+
static {
|
|
1352
|
+
__name(this, "Entity");
|
|
1353
|
+
}
|
|
1354
|
+
/** Unique entity identifier */
|
|
1355
|
+
id;
|
|
1356
|
+
/** World X position */
|
|
1357
|
+
x;
|
|
1358
|
+
/** World Y position */
|
|
1359
|
+
y;
|
|
1360
|
+
/** Velocity X (units/second) */
|
|
1361
|
+
vx;
|
|
1362
|
+
/** Velocity Y (units/second) */
|
|
1363
|
+
vy;
|
|
1364
|
+
/** Dirty flag - entity needs to be broadcast */
|
|
1365
|
+
dirty;
|
|
1366
|
+
/** Last update timestamp */
|
|
1367
|
+
lastUpdate;
|
|
1368
|
+
/**
|
|
1369
|
+
* Create a new entity.
|
|
1370
|
+
*
|
|
1371
|
+
* @param id - Unique identifier
|
|
1372
|
+
* @param x - Initial X position
|
|
1373
|
+
* @param y - Initial Y position
|
|
1374
|
+
*/
|
|
1375
|
+
constructor(id, x, y) {
|
|
1376
|
+
this.id = id;
|
|
1377
|
+
this.x = x;
|
|
1378
|
+
this.y = y;
|
|
1379
|
+
this.vx = 0;
|
|
1380
|
+
this.vy = 0;
|
|
1381
|
+
this.dirty = true;
|
|
1382
|
+
this.lastUpdate = Date.now();
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Update position based on velocity and delta time.
|
|
1386
|
+
*
|
|
1387
|
+
* @param deltaMs - Time since last update (milliseconds)
|
|
1388
|
+
*/
|
|
1389
|
+
updatePosition(deltaMs) {
|
|
1390
|
+
if (this.vx !== 0 || this.vy !== 0) {
|
|
1391
|
+
const deltaSec = deltaMs / 1e3;
|
|
1392
|
+
this.x += this.vx * deltaSec;
|
|
1393
|
+
this.y += this.vy * deltaSec;
|
|
1394
|
+
this.dirty = true;
|
|
1395
|
+
this.lastUpdate = Date.now();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Set velocity.
|
|
1400
|
+
*/
|
|
1401
|
+
setVelocity(vx, vy) {
|
|
1402
|
+
if (this.vx !== vx || this.vy !== vy) {
|
|
1403
|
+
this.vx = vx;
|
|
1404
|
+
this.vy = vy;
|
|
1405
|
+
this.dirty = true;
|
|
1406
|
+
this.lastUpdate = Date.now();
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Teleport to position (no velocity integration).
|
|
1411
|
+
*/
|
|
1412
|
+
setPosition(x, y) {
|
|
1413
|
+
if (this.x !== x || this.y !== y) {
|
|
1414
|
+
this.x = x;
|
|
1415
|
+
this.y = y;
|
|
1416
|
+
this.dirty = true;
|
|
1417
|
+
this.lastUpdate = Date.now();
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Mark entity as clean (after broadcast).
|
|
1422
|
+
*/
|
|
1423
|
+
markClean() {
|
|
1424
|
+
this.dirty = false;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Mark entity as dirty (needs broadcast).
|
|
1428
|
+
*/
|
|
1429
|
+
markDirty() {
|
|
1430
|
+
this.dirty = true;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Get entity as plain object for serialization.
|
|
1434
|
+
*/
|
|
1435
|
+
toJSON() {
|
|
1436
|
+
return {
|
|
1437
|
+
id: this.id,
|
|
1438
|
+
x: this.x,
|
|
1439
|
+
y: this.y,
|
|
1440
|
+
vx: this.vx,
|
|
1441
|
+
vy: this.vy
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
// src/physics/AABB.ts
|
|
1447
|
+
var AABBCollision = class {
|
|
1448
|
+
static {
|
|
1449
|
+
__name(this, "AABBCollision");
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Check if two AABBs overlap.
|
|
1453
|
+
*/
|
|
1454
|
+
static overlaps(a, b) {
|
|
1455
|
+
const aHalfW = a.width / 2;
|
|
1456
|
+
const aHalfH = a.height / 2;
|
|
1457
|
+
const bHalfW = b.width / 2;
|
|
1458
|
+
const bHalfH = b.height / 2;
|
|
1459
|
+
return Math.abs(a.x - b.x) < aHalfW + bHalfW && Math.abs(a.y - b.y) < aHalfH + bHalfH;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Check if a point is inside an AABB.
|
|
1463
|
+
*/
|
|
1464
|
+
static containsPoint(aabb, x, y) {
|
|
1465
|
+
const halfW = aabb.width / 2;
|
|
1466
|
+
const halfH = aabb.height / 2;
|
|
1467
|
+
return x >= aabb.x - halfW && x <= aabb.x + halfW && y >= aabb.y - halfH && y <= aabb.y + halfH;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Create AABB from entity.
|
|
1471
|
+
*/
|
|
1472
|
+
static fromEntity(entity, width, height) {
|
|
1473
|
+
return {
|
|
1474
|
+
x: entity.x,
|
|
1475
|
+
y: entity.y,
|
|
1476
|
+
width,
|
|
1477
|
+
height
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Get the distance between two AABBs.
|
|
1482
|
+
* Returns 0 if overlapping.
|
|
1483
|
+
*/
|
|
1484
|
+
static distance(a, b) {
|
|
1485
|
+
const dx = Math.abs(a.x - b.x);
|
|
1486
|
+
const dy = Math.abs(a.y - b.y);
|
|
1487
|
+
const combinedHalfW = (a.width + b.width) / 2;
|
|
1488
|
+
const combinedHalfH = (a.height + b.height) / 2;
|
|
1489
|
+
const gapX = Math.max(0, dx - combinedHalfW);
|
|
1490
|
+
const gapY = Math.max(0, dy - combinedHalfH);
|
|
1491
|
+
return Math.sqrt(gapX * gapX + gapY * gapY);
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Compute the overlap amount between two AABBs.
|
|
1495
|
+
* Returns { x: 0, y: 0 } if not overlapping.
|
|
1496
|
+
*/
|
|
1497
|
+
static overlap(a, b) {
|
|
1498
|
+
const dx = a.x - b.x;
|
|
1499
|
+
const dy = a.y - b.y;
|
|
1500
|
+
const combinedHalfW = (a.width + b.width) / 2;
|
|
1501
|
+
const combinedHalfH = (a.height + b.height) / 2;
|
|
1502
|
+
const overlapX = combinedHalfW - Math.abs(dx);
|
|
1503
|
+
const overlapY = combinedHalfH - Math.abs(dy);
|
|
1504
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
1505
|
+
return {
|
|
1506
|
+
x: overlapX * Math.sign(dx),
|
|
1507
|
+
y: overlapY * Math.sign(dy)
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
x: 0,
|
|
1512
|
+
y: 0
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// src/physics/Movement.ts
|
|
1518
|
+
var Movement = class {
|
|
1519
|
+
static {
|
|
1520
|
+
__name(this, "Movement");
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Update entity position based on velocity.
|
|
1524
|
+
*
|
|
1525
|
+
* @param entity - Entity to update
|
|
1526
|
+
* @param deltaMs - Time delta in milliseconds
|
|
1527
|
+
*/
|
|
1528
|
+
static integrate(entity, deltaMs) {
|
|
1529
|
+
if (entity.vx === 0 && entity.vy === 0) return;
|
|
1530
|
+
const deltaSec = deltaMs / 1e3;
|
|
1531
|
+
entity.x += entity.vx * deltaSec;
|
|
1532
|
+
entity.y += entity.vy * deltaSec;
|
|
1533
|
+
entity.markDirty();
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Apply velocity to entity.
|
|
1537
|
+
*/
|
|
1538
|
+
static applyVelocity(entity, vx, vy) {
|
|
1539
|
+
entity.setVelocity(vx, vy);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Stop entity movement.
|
|
1543
|
+
*/
|
|
1544
|
+
static stop(entity) {
|
|
1545
|
+
entity.setVelocity(0, 0);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Constrain entity to boundary.
|
|
1549
|
+
*
|
|
1550
|
+
* @param entity - Entity to constrain
|
|
1551
|
+
* @param boundary - Boundary constraints
|
|
1552
|
+
* @returns True if entity was clamped
|
|
1553
|
+
*/
|
|
1554
|
+
static constrainToBoundary(entity, boundary) {
|
|
1555
|
+
let clamped = false;
|
|
1556
|
+
if (entity.x < boundary.minX) {
|
|
1557
|
+
entity.x = boundary.minX;
|
|
1558
|
+
entity.vx = 0;
|
|
1559
|
+
clamped = true;
|
|
1560
|
+
} else if (entity.x > boundary.maxX) {
|
|
1561
|
+
entity.x = boundary.maxX;
|
|
1562
|
+
entity.vx = 0;
|
|
1563
|
+
clamped = true;
|
|
1564
|
+
}
|
|
1565
|
+
if (entity.y < boundary.minY) {
|
|
1566
|
+
entity.y = boundary.minY;
|
|
1567
|
+
entity.vy = 0;
|
|
1568
|
+
clamped = true;
|
|
1569
|
+
} else if (entity.y > boundary.maxY) {
|
|
1570
|
+
entity.y = boundary.maxY;
|
|
1571
|
+
entity.vy = 0;
|
|
1572
|
+
clamped = true;
|
|
1573
|
+
}
|
|
1574
|
+
if (clamped) {
|
|
1575
|
+
entity.markDirty();
|
|
1576
|
+
}
|
|
1577
|
+
return clamped;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Calculate distance between two entities.
|
|
1581
|
+
*/
|
|
1582
|
+
static distance(a, b) {
|
|
1583
|
+
const dx = a.x - b.x;
|
|
1584
|
+
const dy = a.y - b.y;
|
|
1585
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Normalize a direction vector.
|
|
1589
|
+
*/
|
|
1590
|
+
static normalize(x, y) {
|
|
1591
|
+
const length = Math.sqrt(x * x + y * y);
|
|
1592
|
+
if (length === 0) return {
|
|
1593
|
+
x: 0,
|
|
1594
|
+
y: 0
|
|
1595
|
+
};
|
|
1596
|
+
return {
|
|
1597
|
+
x: x / length,
|
|
1598
|
+
y: y / length
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Calculate velocity from direction and speed.
|
|
1603
|
+
*/
|
|
1604
|
+
static velocityFromDirection(dirX, dirY, speed) {
|
|
1605
|
+
const normalized = this.normalize(dirX, dirY);
|
|
1606
|
+
return {
|
|
1607
|
+
vx: normalized.x * speed,
|
|
1608
|
+
vy: normalized.y * speed
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
// src/types/protocol.ts
|
|
1614
|
+
var ClientOpcode;
|
|
1615
|
+
(function(ClientOpcode2) {
|
|
1616
|
+
ClientOpcode2["C_MOVE"] = "C_MOVE";
|
|
1617
|
+
ClientOpcode2["C_STOP"] = "C_STOP";
|
|
1618
|
+
ClientOpcode2["C_ACTION"] = "C_ACTION";
|
|
1619
|
+
})(ClientOpcode || (ClientOpcode = {}));
|
|
1620
|
+
var ServerOpcode;
|
|
1621
|
+
(function(ServerOpcode2) {
|
|
1622
|
+
ServerOpcode2["S_INIT"] = "S_INIT";
|
|
1623
|
+
ServerOpcode2["S_UPDATE"] = "S_UPDATE";
|
|
1624
|
+
ServerOpcode2["S_SPAWN"] = "S_SPAWN";
|
|
1625
|
+
ServerOpcode2["S_DESPAWN"] = "S_DESPAWN";
|
|
1626
|
+
ServerOpcode2["S_EVENT"] = "S_EVENT";
|
|
1627
|
+
ServerOpcode2["S_ERROR"] = "S_ERROR";
|
|
1628
|
+
})(ServerOpcode || (ServerOpcode = {}));
|
|
1629
|
+
|
|
1630
|
+
export { AABBCollision, ClientOpcode, Entity, GameLoop, GameServer, Grid, InputQueue, Logger, Movement, Network, Registry, RingBuffer, Room, ServerOpcode, Snapshot, createChildLogger, logger, setLogger };
|
|
1631
|
+
//# sourceMappingURL=index.js.map
|
|
1632
|
+
//# sourceMappingURL=index.js.map
|