@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Registry
|
|
3
|
+
*
|
|
4
|
+
* Manages entity lifecycle and provides efficient queries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Entity } from './Entity.js';
|
|
8
|
+
import { logger } from '../utils/Logger.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Entity registry with lifecycle management.
|
|
12
|
+
*
|
|
13
|
+
* Provides zero-allocation queries using pre-allocated buffers
|
|
14
|
+
* to minimize GC pressure in hot paths.
|
|
15
|
+
*/
|
|
16
|
+
export class Registry<TEntity extends Entity = Entity> {
|
|
17
|
+
private readonly entities = new Map<string, TEntity>();
|
|
18
|
+
private readonly dirtyEntities = new Set<string>();
|
|
19
|
+
|
|
20
|
+
// Pre-allocated buffers to avoid allocations in hot paths
|
|
21
|
+
private readonly dirtyBuffer: TEntity[] = [];
|
|
22
|
+
private readonly allBuffer: TEntity[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add an entity to the registry.
|
|
26
|
+
*/
|
|
27
|
+
add(entity: TEntity): void {
|
|
28
|
+
if (this.entities.has(entity.id)) {
|
|
29
|
+
logger.warn({ entityId: entity.id }, 'Entity already exists in registry');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.entities.set(entity.id, entity);
|
|
34
|
+
this.dirtyEntities.add(entity.id);
|
|
35
|
+
|
|
36
|
+
logger.debug({ entityId: entity.id }, 'Entity added to registry');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Remove an entity from the registry.
|
|
41
|
+
*/
|
|
42
|
+
remove(entityId: string): boolean {
|
|
43
|
+
const entity = this.entities.get(entityId);
|
|
44
|
+
if (!entity) return false;
|
|
45
|
+
|
|
46
|
+
this.entities.delete(entityId);
|
|
47
|
+
this.dirtyEntities.delete(entityId);
|
|
48
|
+
|
|
49
|
+
logger.debug({ entityId }, 'Entity removed from registry');
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get an entity by ID.
|
|
56
|
+
*/
|
|
57
|
+
get(entityId: string): TEntity | undefined {
|
|
58
|
+
return this.entities.get(entityId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if entity exists.
|
|
63
|
+
*/
|
|
64
|
+
has(entityId: string): boolean {
|
|
65
|
+
return this.entities.has(entityId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mark an entity as dirty (needs broadcast).
|
|
70
|
+
*/
|
|
71
|
+
markDirty(entityId: string): void {
|
|
72
|
+
if (this.entities.has(entityId)) {
|
|
73
|
+
this.dirtyEntities.add(entityId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get all dirty entities and clear dirty flags.
|
|
79
|
+
* OPTIMIZED: Reuses buffer array to avoid allocations.
|
|
80
|
+
*
|
|
81
|
+
* @returns Array of dirty entities (reused buffer - don't store reference!)
|
|
82
|
+
*/
|
|
83
|
+
getDirtyEntities(): TEntity[] {
|
|
84
|
+
// Reset buffer without reallocation
|
|
85
|
+
this.dirtyBuffer.length = 0;
|
|
86
|
+
|
|
87
|
+
for (const entityId of this.dirtyEntities) {
|
|
88
|
+
const entity = this.entities.get(entityId);
|
|
89
|
+
if (entity) {
|
|
90
|
+
this.dirtyBuffer.push(entity);
|
|
91
|
+
entity.markClean();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.dirtyEntities.clear();
|
|
96
|
+
|
|
97
|
+
return this.dirtyBuffer;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get all entities.
|
|
102
|
+
* OPTIMIZED: Reuses buffer array to avoid allocations.
|
|
103
|
+
*
|
|
104
|
+
* @returns Array of all entities (reused buffer - don't store reference!)
|
|
105
|
+
*/
|
|
106
|
+
getAll(): TEntity[] {
|
|
107
|
+
this.allBuffer.length = 0;
|
|
108
|
+
for (const entity of this.entities.values()) {
|
|
109
|
+
this.allBuffer.push(entity);
|
|
110
|
+
}
|
|
111
|
+
return this.allBuffer;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get entity count.
|
|
116
|
+
*/
|
|
117
|
+
size(): number {
|
|
118
|
+
return this.entities.size;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get dirty entity count.
|
|
123
|
+
*/
|
|
124
|
+
dirtyCount(): number {
|
|
125
|
+
return this.dirtyEntities.size;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clear all entities.
|
|
130
|
+
*/
|
|
131
|
+
clear(): void {
|
|
132
|
+
this.entities.clear();
|
|
133
|
+
this.dirtyEntities.clear();
|
|
134
|
+
logger.debug('Registry cleared');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Execute callback for each entity.
|
|
139
|
+
*/
|
|
140
|
+
forEach(callback: (entity: TEntity) => void): void {
|
|
141
|
+
for (const entity of this.entities.values()) {
|
|
142
|
+
callback(entity);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Filter entities by predicate.
|
|
148
|
+
*
|
|
149
|
+
* @param predicate - Filter function
|
|
150
|
+
* @returns New array of matching entities
|
|
151
|
+
*/
|
|
152
|
+
filter(predicate: (entity: TEntity) => boolean): TEntity[] {
|
|
153
|
+
const result: TEntity[] = [];
|
|
154
|
+
for (const entity of this.entities.values()) {
|
|
155
|
+
if (predicate(entity)) {
|
|
156
|
+
result.push(entity);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gamerstake/game-core
|
|
3
|
+
*
|
|
4
|
+
* Reusable multiplayer game engine for GamerStake platform.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Core
|
|
10
|
+
export { GameServer } from './core/GameServer.js';
|
|
11
|
+
export { Room } from './core/Room.js';
|
|
12
|
+
export { GameLoop } from './core/GameLoop.js';
|
|
13
|
+
export type { GameRules } from './core/GameRules.js';
|
|
14
|
+
export type { TickHandler } from './core/GameLoop.js';
|
|
15
|
+
|
|
16
|
+
// Entities
|
|
17
|
+
export { Entity } from './entities/Entity.js';
|
|
18
|
+
export { Registry } from './entities/Registry.js';
|
|
19
|
+
|
|
20
|
+
// Spatial
|
|
21
|
+
export { Grid } from './spatial/Grid.js';
|
|
22
|
+
|
|
23
|
+
// Physics
|
|
24
|
+
export { AABBCollision, type AABB } from './physics/AABB.js';
|
|
25
|
+
export { Movement, type Boundary } from './physics/Movement.js';
|
|
26
|
+
|
|
27
|
+
// Input
|
|
28
|
+
export { InputQueue } from './input/InputQueue.js';
|
|
29
|
+
export type {
|
|
30
|
+
Command,
|
|
31
|
+
MoveCommand,
|
|
32
|
+
StopCommand,
|
|
33
|
+
ActionCommand,
|
|
34
|
+
} from './input/Command.js';
|
|
35
|
+
|
|
36
|
+
// Network
|
|
37
|
+
export { Network } from './network/Network.js';
|
|
38
|
+
export { Snapshot } from './network/Snapshot.js';
|
|
39
|
+
|
|
40
|
+
// Utils
|
|
41
|
+
export { RingBuffer } from './utils/RingBuffer.js';
|
|
42
|
+
export {
|
|
43
|
+
Logger,
|
|
44
|
+
logger,
|
|
45
|
+
setLogger,
|
|
46
|
+
createChildLogger,
|
|
47
|
+
} from './utils/Logger.js';
|
|
48
|
+
|
|
49
|
+
// Types
|
|
50
|
+
export * from './types/index.js';
|
|
51
|
+
export * from './types/protocol.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command types for player input.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base command interface.
|
|
7
|
+
*/
|
|
8
|
+
export interface Command {
|
|
9
|
+
/** Client sequence number for reconciliation */
|
|
10
|
+
readonly seq: number;
|
|
11
|
+
|
|
12
|
+
/** Command type */
|
|
13
|
+
readonly type: string;
|
|
14
|
+
|
|
15
|
+
/** Timestamp when command was created */
|
|
16
|
+
readonly timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Movement command.
|
|
21
|
+
*/
|
|
22
|
+
export interface MoveCommand extends Command {
|
|
23
|
+
type: 'move';
|
|
24
|
+
dir: { x: number; y: number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stop command.
|
|
29
|
+
*/
|
|
30
|
+
export interface StopCommand extends Command {
|
|
31
|
+
type: 'stop';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generic action command.
|
|
36
|
+
*/
|
|
37
|
+
export interface ActionCommand extends Command {
|
|
38
|
+
type: 'action';
|
|
39
|
+
action: string;
|
|
40
|
+
data?: Record<string, unknown>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Queue
|
|
3
|
+
*
|
|
4
|
+
* Buffers player inputs for processing in the game loop.
|
|
5
|
+
* Uses RingBuffer for O(1) push/pop operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/Logger.js';
|
|
9
|
+
import { RingBuffer } from '../utils/RingBuffer.js';
|
|
10
|
+
import type { Command } from './Command.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Input queue manager using RingBuffer for O(1) operations.
|
|
14
|
+
*
|
|
15
|
+
* Manages input queues for multiple players, with automatic
|
|
16
|
+
* overflow handling (drops oldest inputs when full).
|
|
17
|
+
*/
|
|
18
|
+
export class InputQueue {
|
|
19
|
+
private readonly queues = new Map<string, RingBuffer<Command>>();
|
|
20
|
+
private readonly maxQueueSize: number;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new input queue manager.
|
|
24
|
+
*
|
|
25
|
+
* @param maxQueueSize - Maximum inputs per player (default: 100)
|
|
26
|
+
*/
|
|
27
|
+
constructor(maxQueueSize = 100) {
|
|
28
|
+
this.maxQueueSize = maxQueueSize;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get or create a player's queue.
|
|
33
|
+
*/
|
|
34
|
+
private getQueue(playerId: string): RingBuffer<Command> {
|
|
35
|
+
let queue = this.queues.get(playerId);
|
|
36
|
+
if (!queue) {
|
|
37
|
+
queue = new RingBuffer<Command>(this.maxQueueSize);
|
|
38
|
+
this.queues.set(playerId, queue);
|
|
39
|
+
}
|
|
40
|
+
return queue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Add an input to a player's queue. O(1)
|
|
45
|
+
*/
|
|
46
|
+
push(playerId: string, input: Command): void {
|
|
47
|
+
const queue = this.getQueue(playerId);
|
|
48
|
+
|
|
49
|
+
// Push with overwrite (drops oldest if full)
|
|
50
|
+
const dropped = queue.pushOverwrite(input);
|
|
51
|
+
if (dropped) {
|
|
52
|
+
logger.debug({ playerId }, 'Input queue overflow, dropped oldest');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get and remove the next input for a player. O(1)
|
|
58
|
+
*/
|
|
59
|
+
pop(playerId: string): Command | undefined {
|
|
60
|
+
const queue = this.queues.get(playerId);
|
|
61
|
+
if (!queue) return undefined;
|
|
62
|
+
return queue.shift();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Peek at the next input without removing. O(1)
|
|
67
|
+
*/
|
|
68
|
+
peek(playerId: string): Command | undefined {
|
|
69
|
+
const queue = this.queues.get(playerId);
|
|
70
|
+
return queue?.peek();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get all inputs for a player and clear.
|
|
75
|
+
*/
|
|
76
|
+
drain(playerId: string): Command[] {
|
|
77
|
+
const queue = this.queues.get(playerId);
|
|
78
|
+
if (!queue) return [];
|
|
79
|
+
|
|
80
|
+
const inputs: Command[] = [];
|
|
81
|
+
while (!queue.isEmpty()) {
|
|
82
|
+
const input = queue.shift();
|
|
83
|
+
if (input) inputs.push(input);
|
|
84
|
+
}
|
|
85
|
+
return inputs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get queue size for a player.
|
|
90
|
+
*/
|
|
91
|
+
size(playerId: string): number {
|
|
92
|
+
return this.queues.get(playerId)?.size() ?? 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clear a player's queue.
|
|
97
|
+
*/
|
|
98
|
+
clear(playerId: string): void {
|
|
99
|
+
const queue = this.queues.get(playerId);
|
|
100
|
+
if (queue) {
|
|
101
|
+
queue.clear();
|
|
102
|
+
}
|
|
103
|
+
// Don't delete the queue itself - reuse it
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a player's queue entirely.
|
|
108
|
+
*/
|
|
109
|
+
remove(playerId: string): void {
|
|
110
|
+
this.queues.delete(playerId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear all queues.
|
|
115
|
+
*/
|
|
116
|
+
clearAll(): void {
|
|
117
|
+
this.queues.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get total queued inputs across all players.
|
|
122
|
+
*/
|
|
123
|
+
getTotalSize(): number {
|
|
124
|
+
let total = 0;
|
|
125
|
+
for (const queue of this.queues.values()) {
|
|
126
|
+
total += queue.size();
|
|
127
|
+
}
|
|
128
|
+
return total;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Layer
|
|
3
|
+
*
|
|
4
|
+
* Broadcast and send utilities for game events.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Server, Socket } from 'socket.io';
|
|
8
|
+
import { logger } from '../utils/Logger.js';
|
|
9
|
+
import type { NetworkEvent } from '../types/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Network layer for broadcasting game events.
|
|
13
|
+
*
|
|
14
|
+
* Wraps Socket.io with game-specific utilities.
|
|
15
|
+
*/
|
|
16
|
+
export class Network {
|
|
17
|
+
private io: Server | null = null;
|
|
18
|
+
private readonly sockets = new Map<string, Socket>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Set the Socket.io server instance.
|
|
22
|
+
*/
|
|
23
|
+
setServer(io: Server): void {
|
|
24
|
+
this.io = io;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a socket connection.
|
|
29
|
+
*/
|
|
30
|
+
registerSocket(playerId: string, socket: Socket): void {
|
|
31
|
+
this.sockets.set(playerId, socket);
|
|
32
|
+
logger.debug({ playerId }, 'Socket registered');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unregister a socket connection.
|
|
37
|
+
*/
|
|
38
|
+
unregisterSocket(playerId: string): void {
|
|
39
|
+
this.sockets.delete(playerId);
|
|
40
|
+
logger.debug({ playerId }, 'Socket unregistered');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get a socket by player ID.
|
|
45
|
+
*/
|
|
46
|
+
getSocket(playerId: string): Socket | undefined {
|
|
47
|
+
return this.sockets.get(playerId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Broadcast event to all connected players.
|
|
52
|
+
*/
|
|
53
|
+
broadcast(event: NetworkEvent): void {
|
|
54
|
+
if (!this.io) {
|
|
55
|
+
logger.warn('Cannot broadcast - no Socket.io server set');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.io.emit(event.op, event);
|
|
60
|
+
logger.debug({ opcode: event.op }, 'Broadcast event');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Broadcast event to all players in a room.
|
|
65
|
+
*/
|
|
66
|
+
broadcastToRoom(roomId: string, event: NetworkEvent): void {
|
|
67
|
+
if (!this.io) {
|
|
68
|
+
logger.warn('Cannot broadcast - no Socket.io server set');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.io.to(roomId).emit(event.op, event);
|
|
73
|
+
logger.debug({ roomId, opcode: event.op }, 'Broadcast to room');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send event to a specific player.
|
|
78
|
+
*/
|
|
79
|
+
sendTo(playerId: string, event: NetworkEvent): void {
|
|
80
|
+
const socket = this.sockets.get(playerId);
|
|
81
|
+
if (!socket) {
|
|
82
|
+
logger.warn({ playerId }, 'Cannot send - socket not found');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
socket.emit(event.op, event);
|
|
87
|
+
logger.debug({ playerId, opcode: event.op }, 'Sent to player');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Send event to multiple players.
|
|
92
|
+
*/
|
|
93
|
+
sendToMany(playerIds: string[], event: NetworkEvent): void {
|
|
94
|
+
for (const playerId of playerIds) {
|
|
95
|
+
this.sendTo(playerId, event);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get connected player count.
|
|
101
|
+
*/
|
|
102
|
+
getPlayerCount(): number {
|
|
103
|
+
return this.sockets.size;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clear all sockets.
|
|
108
|
+
*/
|
|
109
|
+
clear(): void {
|
|
110
|
+
this.sockets.clear();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot System
|
|
3
|
+
*
|
|
4
|
+
* Full state snapshots for network synchronization.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Entity } from '../entities/Entity.js';
|
|
8
|
+
import type { StateSnapshot, EntitySnapshot } from '../types/index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Snapshot generator.
|
|
12
|
+
*
|
|
13
|
+
* Creates full state snapshots for sending to clients.
|
|
14
|
+
* Used for initial sync and periodic full updates.
|
|
15
|
+
*/
|
|
16
|
+
export class Snapshot {
|
|
17
|
+
/**
|
|
18
|
+
* Create a full state snapshot from entities.
|
|
19
|
+
*
|
|
20
|
+
* @param tick - Current tick number
|
|
21
|
+
* @param entities - Array of entities to snapshot
|
|
22
|
+
* @returns Full state snapshot
|
|
23
|
+
*/
|
|
24
|
+
static create(tick: number, entities: Entity[]): StateSnapshot {
|
|
25
|
+
return {
|
|
26
|
+
tick,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
entities: entities.map((e) => this.entityToSnapshot(e)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert an entity to a snapshot.
|
|
34
|
+
*/
|
|
35
|
+
static entityToSnapshot(entity: Entity): EntitySnapshot {
|
|
36
|
+
return {
|
|
37
|
+
id: entity.id,
|
|
38
|
+
x: entity.x,
|
|
39
|
+
y: entity.y,
|
|
40
|
+
vx: entity.vx,
|
|
41
|
+
vy: entity.vy,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a delta snapshot (only dirty entities).
|
|
47
|
+
*
|
|
48
|
+
* @param tick - Current tick number
|
|
49
|
+
* @param dirtyEntities - Array of dirty entities
|
|
50
|
+
* @returns Delta snapshot
|
|
51
|
+
*/
|
|
52
|
+
static createDelta(tick: number, dirtyEntities: Entity[]): StateSnapshot {
|
|
53
|
+
return {
|
|
54
|
+
tick,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
entities: dirtyEntities.map((e) => this.entityToSnapshot(e)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AABB Collision Detection
|
|
3
|
+
*
|
|
4
|
+
* Axis-Aligned Bounding Box collision detection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Entity } from '../entities/Entity.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Axis-Aligned Bounding Box.
|
|
11
|
+
*/
|
|
12
|
+
export interface AABB {
|
|
13
|
+
x: number; // Center X
|
|
14
|
+
y: number; // Center Y
|
|
15
|
+
width: number; // Total width
|
|
16
|
+
height: number; // Total height
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* AABB collision detection utilities.
|
|
21
|
+
*/
|
|
22
|
+
export class AABBCollision {
|
|
23
|
+
/**
|
|
24
|
+
* Check if two AABBs overlap.
|
|
25
|
+
*/
|
|
26
|
+
static overlaps(a: AABB, b: AABB): boolean {
|
|
27
|
+
const aHalfW = a.width / 2;
|
|
28
|
+
const aHalfH = a.height / 2;
|
|
29
|
+
const bHalfW = b.width / 2;
|
|
30
|
+
const bHalfH = b.height / 2;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
Math.abs(a.x - b.x) < aHalfW + bHalfW &&
|
|
34
|
+
Math.abs(a.y - b.y) < aHalfH + bHalfH
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a point is inside an AABB.
|
|
40
|
+
*/
|
|
41
|
+
static containsPoint(aabb: AABB, x: number, y: number): boolean {
|
|
42
|
+
const halfW = aabb.width / 2;
|
|
43
|
+
const halfH = aabb.height / 2;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
x >= aabb.x - halfW &&
|
|
47
|
+
x <= aabb.x + halfW &&
|
|
48
|
+
y >= aabb.y - halfH &&
|
|
49
|
+
y <= aabb.y + halfH
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create AABB from entity.
|
|
55
|
+
*/
|
|
56
|
+
static fromEntity(entity: Entity, width: number, height: number): AABB {
|
|
57
|
+
return {
|
|
58
|
+
x: entity.x,
|
|
59
|
+
y: entity.y,
|
|
60
|
+
width,
|
|
61
|
+
height,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the distance between two AABBs.
|
|
67
|
+
* Returns 0 if overlapping.
|
|
68
|
+
*/
|
|
69
|
+
static distance(a: AABB, b: AABB): number {
|
|
70
|
+
const dx = Math.abs(a.x - b.x);
|
|
71
|
+
const dy = Math.abs(a.y - b.y);
|
|
72
|
+
const combinedHalfW = (a.width + b.width) / 2;
|
|
73
|
+
const combinedHalfH = (a.height + b.height) / 2;
|
|
74
|
+
|
|
75
|
+
const gapX = Math.max(0, dx - combinedHalfW);
|
|
76
|
+
const gapY = Math.max(0, dy - combinedHalfH);
|
|
77
|
+
|
|
78
|
+
return Math.sqrt(gapX * gapX + gapY * gapY);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compute the overlap amount between two AABBs.
|
|
83
|
+
* Returns { x: 0, y: 0 } if not overlapping.
|
|
84
|
+
*/
|
|
85
|
+
static overlap(a: AABB, b: AABB): { x: number; y: number } {
|
|
86
|
+
const dx = a.x - b.x;
|
|
87
|
+
const dy = a.y - b.y;
|
|
88
|
+
|
|
89
|
+
const combinedHalfW = (a.width + b.width) / 2;
|
|
90
|
+
const combinedHalfH = (a.height + b.height) / 2;
|
|
91
|
+
|
|
92
|
+
const overlapX = combinedHalfW - Math.abs(dx);
|
|
93
|
+
const overlapY = combinedHalfH - Math.abs(dy);
|
|
94
|
+
|
|
95
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
96
|
+
return {
|
|
97
|
+
x: overlapX * Math.sign(dx),
|
|
98
|
+
y: overlapY * Math.sign(dy),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { x: 0, y: 0 };
|
|
103
|
+
}
|
|
104
|
+
}
|