@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Movement Physics
|
|
3
|
+
*
|
|
4
|
+
* Velocity integration and boundary constraints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Entity } from '../entities/Entity.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Boundary constraints for movement.
|
|
11
|
+
*/
|
|
12
|
+
export interface Boundary {
|
|
13
|
+
minX: number;
|
|
14
|
+
maxX: number;
|
|
15
|
+
minY: number;
|
|
16
|
+
maxY: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Movement physics utilities.
|
|
21
|
+
*/
|
|
22
|
+
export class Movement {
|
|
23
|
+
/**
|
|
24
|
+
* Update entity position based on velocity.
|
|
25
|
+
*
|
|
26
|
+
* @param entity - Entity to update
|
|
27
|
+
* @param deltaMs - Time delta in milliseconds
|
|
28
|
+
*/
|
|
29
|
+
static integrate(entity: Entity, deltaMs: number): void {
|
|
30
|
+
if (entity.vx === 0 && entity.vy === 0) return;
|
|
31
|
+
|
|
32
|
+
const deltaSec = deltaMs / 1000;
|
|
33
|
+
entity.x += entity.vx * deltaSec;
|
|
34
|
+
entity.y += entity.vy * deltaSec;
|
|
35
|
+
entity.markDirty();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply velocity to entity.
|
|
40
|
+
*/
|
|
41
|
+
static applyVelocity(entity: Entity, vx: number, vy: number): void {
|
|
42
|
+
entity.setVelocity(vx, vy);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stop entity movement.
|
|
47
|
+
*/
|
|
48
|
+
static stop(entity: Entity): void {
|
|
49
|
+
entity.setVelocity(0, 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Constrain entity to boundary.
|
|
54
|
+
*
|
|
55
|
+
* @param entity - Entity to constrain
|
|
56
|
+
* @param boundary - Boundary constraints
|
|
57
|
+
* @returns True if entity was clamped
|
|
58
|
+
*/
|
|
59
|
+
static constrainToBoundary(entity: Entity, boundary: Boundary): boolean {
|
|
60
|
+
let clamped = false;
|
|
61
|
+
|
|
62
|
+
if (entity.x < boundary.minX) {
|
|
63
|
+
entity.x = boundary.minX;
|
|
64
|
+
entity.vx = 0;
|
|
65
|
+
clamped = true;
|
|
66
|
+
} else if (entity.x > boundary.maxX) {
|
|
67
|
+
entity.x = boundary.maxX;
|
|
68
|
+
entity.vx = 0;
|
|
69
|
+
clamped = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (entity.y < boundary.minY) {
|
|
73
|
+
entity.y = boundary.minY;
|
|
74
|
+
entity.vy = 0;
|
|
75
|
+
clamped = true;
|
|
76
|
+
} else if (entity.y > boundary.maxY) {
|
|
77
|
+
entity.y = boundary.maxY;
|
|
78
|
+
entity.vy = 0;
|
|
79
|
+
clamped = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (clamped) {
|
|
83
|
+
entity.markDirty();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return clamped;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Calculate distance between two entities.
|
|
91
|
+
*/
|
|
92
|
+
static distance(a: Entity, b: Entity): number {
|
|
93
|
+
const dx = a.x - b.x;
|
|
94
|
+
const dy = a.y - b.y;
|
|
95
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a direction vector.
|
|
100
|
+
*/
|
|
101
|
+
static normalize(x: number, y: number): { x: number; y: number } {
|
|
102
|
+
const length = Math.sqrt(x * x + y * y);
|
|
103
|
+
if (length === 0) return { x: 0, y: 0 };
|
|
104
|
+
return {
|
|
105
|
+
x: x / length,
|
|
106
|
+
y: y / length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Calculate velocity from direction and speed.
|
|
112
|
+
*/
|
|
113
|
+
static velocityFromDirection(
|
|
114
|
+
dirX: number,
|
|
115
|
+
dirY: number,
|
|
116
|
+
speed: number,
|
|
117
|
+
): { vx: number; vy: number } {
|
|
118
|
+
const normalized = this.normalize(dirX, dirY);
|
|
119
|
+
return {
|
|
120
|
+
vx: normalized.x * speed,
|
|
121
|
+
vy: normalized.y * speed,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid System
|
|
3
|
+
*
|
|
4
|
+
* Spatial partitioning using a fixed-size grid.
|
|
5
|
+
* Enables O(1) lookup for nearby entities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/Logger.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single cell in the grid.
|
|
12
|
+
*/
|
|
13
|
+
interface GridCell {
|
|
14
|
+
readonly x: number;
|
|
15
|
+
readonly y: number;
|
|
16
|
+
readonly entities: Set<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grid-based spatial partitioning system.
|
|
21
|
+
*
|
|
22
|
+
* Uses a hash map of cells for O(1) entity tracking.
|
|
23
|
+
* Automatically cleans up empty cells to prevent memory leaks.
|
|
24
|
+
*/
|
|
25
|
+
export class Grid {
|
|
26
|
+
private readonly cellSize: number;
|
|
27
|
+
private readonly cells = new Map<string, GridCell>();
|
|
28
|
+
private readonly entityCells = new Map<string, string>(); // entityId -> cellKey
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new spatial grid.
|
|
32
|
+
*
|
|
33
|
+
* @param cellSize - Size of each cell in world units (default: 512)
|
|
34
|
+
*/
|
|
35
|
+
constructor(cellSize = 512) {
|
|
36
|
+
this.cellSize = cellSize;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert world coordinates to cell key.
|
|
41
|
+
*/
|
|
42
|
+
getCellKey(worldX: number, worldY: number): string {
|
|
43
|
+
const cellX = Math.floor(worldX / this.cellSize);
|
|
44
|
+
const cellY = Math.floor(worldY / this.cellSize);
|
|
45
|
+
return `${cellX},${cellY}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get or create a cell at world coordinates.
|
|
50
|
+
*/
|
|
51
|
+
private getCell(worldX: number, worldY: number): GridCell {
|
|
52
|
+
const key = this.getCellKey(worldX, worldY);
|
|
53
|
+
let cell = this.cells.get(key);
|
|
54
|
+
|
|
55
|
+
if (!cell) {
|
|
56
|
+
const cellX = Math.floor(worldX / this.cellSize);
|
|
57
|
+
const cellY = Math.floor(worldY / this.cellSize);
|
|
58
|
+
cell = { x: cellX, y: cellY, entities: new Set() };
|
|
59
|
+
this.cells.set(key, cell);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return cell;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Add an entity to the grid.
|
|
67
|
+
*/
|
|
68
|
+
addEntity(entityId: string, x: number, y: number): void {
|
|
69
|
+
const cell = this.getCell(x, y);
|
|
70
|
+
cell.entities.add(entityId);
|
|
71
|
+
this.entityCells.set(entityId, this.getCellKey(x, y));
|
|
72
|
+
|
|
73
|
+
logger.debug(
|
|
74
|
+
{ entityId, cellX: cell.x, cellY: cell.y },
|
|
75
|
+
'Entity added to grid',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remove an entity from the grid.
|
|
81
|
+
*/
|
|
82
|
+
removeEntity(entityId: string): void {
|
|
83
|
+
const cellKey = this.entityCells.get(entityId);
|
|
84
|
+
if (!cellKey) return;
|
|
85
|
+
|
|
86
|
+
const cell = this.cells.get(cellKey);
|
|
87
|
+
if (cell) {
|
|
88
|
+
cell.entities.delete(entityId);
|
|
89
|
+
|
|
90
|
+
// Clean up empty cells
|
|
91
|
+
if (cell.entities.size === 0) {
|
|
92
|
+
this.cells.delete(cellKey);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.entityCells.delete(entityId);
|
|
97
|
+
|
|
98
|
+
logger.debug({ entityId }, 'Entity removed from grid');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Move an entity to a new position.
|
|
103
|
+
* Returns true if the entity changed cells.
|
|
104
|
+
*/
|
|
105
|
+
moveEntity(
|
|
106
|
+
entityId: string,
|
|
107
|
+
oldX: number,
|
|
108
|
+
oldY: number,
|
|
109
|
+
newX: number,
|
|
110
|
+
newY: number,
|
|
111
|
+
): boolean {
|
|
112
|
+
const oldKey = this.getCellKey(oldX, oldY);
|
|
113
|
+
const newKey = this.getCellKey(newX, newY);
|
|
114
|
+
|
|
115
|
+
// Same cell, no update needed
|
|
116
|
+
if (oldKey === newKey) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Remove from old cell
|
|
121
|
+
const oldCell = this.cells.get(oldKey);
|
|
122
|
+
if (oldCell) {
|
|
123
|
+
oldCell.entities.delete(entityId);
|
|
124
|
+
if (oldCell.entities.size === 0) {
|
|
125
|
+
this.cells.delete(oldKey);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Add to new cell
|
|
130
|
+
const newCell = this.getCell(newX, newY);
|
|
131
|
+
newCell.entities.add(entityId);
|
|
132
|
+
this.entityCells.set(entityId, newKey);
|
|
133
|
+
|
|
134
|
+
logger.debug({ entityId, oldKey, newKey }, 'Entity moved to new cell');
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get all entities in nearby cells.
|
|
141
|
+
*
|
|
142
|
+
* @param worldX - World X coordinate
|
|
143
|
+
* @param worldY - World Y coordinate
|
|
144
|
+
* @param range - Range in grid cells (default: 1 = 3x3 area)
|
|
145
|
+
*/
|
|
146
|
+
getNearbyEntities(worldX: number, worldY: number, range = 1): string[] {
|
|
147
|
+
const centerCellX = Math.floor(worldX / this.cellSize);
|
|
148
|
+
const centerCellY = Math.floor(worldY / this.cellSize);
|
|
149
|
+
|
|
150
|
+
const entities: string[] = [];
|
|
151
|
+
|
|
152
|
+
for (let dx = -range; dx <= range; dx++) {
|
|
153
|
+
for (let dy = -range; dy <= range; dy++) {
|
|
154
|
+
const key = `${centerCellX + dx},${centerCellY + dy}`;
|
|
155
|
+
const cell = this.cells.get(key);
|
|
156
|
+
if (cell) {
|
|
157
|
+
entities.push(...cell.entities);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return entities;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get entities in a specific cell.
|
|
167
|
+
*/
|
|
168
|
+
getEntitiesInCell(worldX: number, worldY: number): string[] {
|
|
169
|
+
const cell = this.cells.get(this.getCellKey(worldX, worldY));
|
|
170
|
+
return cell ? Array.from(cell.entities) : [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get the cell an entity is in.
|
|
175
|
+
*/
|
|
176
|
+
getEntityCell(entityId: string): string | undefined {
|
|
177
|
+
return this.entityCells.get(entityId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get total number of active cells.
|
|
182
|
+
*/
|
|
183
|
+
getCellCount(): number {
|
|
184
|
+
return this.cells.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get total entities in grid.
|
|
189
|
+
*/
|
|
190
|
+
getEntityCount(): number {
|
|
191
|
+
return this.entityCells.size;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Clear all cells and entities.
|
|
196
|
+
*/
|
|
197
|
+
clear(): void {
|
|
198
|
+
this.cells.clear();
|
|
199
|
+
this.entityCells.clear();
|
|
200
|
+
logger.debug('Grid cleared');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for game-core package.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Room configuration options.
|
|
7
|
+
*/
|
|
8
|
+
export interface RoomConfig {
|
|
9
|
+
/** Ticks per second (default: 20) */
|
|
10
|
+
tickRate?: number;
|
|
11
|
+
|
|
12
|
+
/** Grid cell size in world units (default: 512) */
|
|
13
|
+
cellSize?: number;
|
|
14
|
+
|
|
15
|
+
/** Maximum buffered inputs per player (default: 100) */
|
|
16
|
+
maxInputQueueSize?: number;
|
|
17
|
+
|
|
18
|
+
/** Maximum entities per room (default: 1000) */
|
|
19
|
+
maxEntities?: number;
|
|
20
|
+
|
|
21
|
+
/** Visibility range in grid cells (default: 1 = 3x3 area) */
|
|
22
|
+
visibilityRange?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Server metrics for monitoring.
|
|
27
|
+
*/
|
|
28
|
+
export interface ServerMetrics {
|
|
29
|
+
/** Total number of active rooms */
|
|
30
|
+
roomCount: number;
|
|
31
|
+
|
|
32
|
+
/** Total players across all rooms */
|
|
33
|
+
totalPlayers: number;
|
|
34
|
+
|
|
35
|
+
/** Per-room metrics */
|
|
36
|
+
rooms: RoomMetrics[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-room metrics.
|
|
41
|
+
*/
|
|
42
|
+
export interface RoomMetrics {
|
|
43
|
+
/** Room ID */
|
|
44
|
+
id: string;
|
|
45
|
+
|
|
46
|
+
/** Number of players in room */
|
|
47
|
+
playerCount: number;
|
|
48
|
+
|
|
49
|
+
/** Total entities in room */
|
|
50
|
+
entityCount: number;
|
|
51
|
+
|
|
52
|
+
/** Current tick number */
|
|
53
|
+
tickCount: number;
|
|
54
|
+
|
|
55
|
+
/** Is the room running */
|
|
56
|
+
isRunning: boolean;
|
|
57
|
+
|
|
58
|
+
/** Average tick time (ms) */
|
|
59
|
+
avgTickTime?: number;
|
|
60
|
+
|
|
61
|
+
/** Actual ticks per second */
|
|
62
|
+
ticksPerSecond?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Game loop performance metrics.
|
|
67
|
+
*/
|
|
68
|
+
export interface LoopMetrics {
|
|
69
|
+
/** Average tick processing time (ms) */
|
|
70
|
+
avgTickTime: number;
|
|
71
|
+
|
|
72
|
+
/** Maximum tick time (ms) */
|
|
73
|
+
maxTickTime: number;
|
|
74
|
+
|
|
75
|
+
/** Minimum tick time (ms) */
|
|
76
|
+
minTickTime: number;
|
|
77
|
+
|
|
78
|
+
/** Actual ticks per second */
|
|
79
|
+
ticksPerSecond: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Network event base type.
|
|
84
|
+
*/
|
|
85
|
+
export interface NetworkEvent {
|
|
86
|
+
/** Opcode identifying event type */
|
|
87
|
+
op: string;
|
|
88
|
+
|
|
89
|
+
/** Additional event data */
|
|
90
|
+
[key: string]: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Snapshot of entity state.
|
|
95
|
+
*/
|
|
96
|
+
export interface EntitySnapshot {
|
|
97
|
+
id: string;
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
vx: number;
|
|
101
|
+
vy: number;
|
|
102
|
+
[key: string]: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Full room state snapshot.
|
|
107
|
+
*/
|
|
108
|
+
export interface StateSnapshot {
|
|
109
|
+
/** Current tick number */
|
|
110
|
+
tick: number;
|
|
111
|
+
|
|
112
|
+
/** All entities in room */
|
|
113
|
+
entities: EntitySnapshot[];
|
|
114
|
+
|
|
115
|
+
/** Timestamp */
|
|
116
|
+
timestamp: number;
|
|
117
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network protocol definitions.
|
|
3
|
+
*
|
|
4
|
+
* Opcodes and message formats for client-server communication.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Client → Server opcodes.
|
|
9
|
+
*/
|
|
10
|
+
export enum ClientOpcode {
|
|
11
|
+
/** Player movement input */
|
|
12
|
+
C_MOVE = 'C_MOVE',
|
|
13
|
+
|
|
14
|
+
/** Player stop input */
|
|
15
|
+
C_STOP = 'C_STOP',
|
|
16
|
+
|
|
17
|
+
/** Generic action */
|
|
18
|
+
C_ACTION = 'C_ACTION',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Server → Client opcodes.
|
|
23
|
+
*/
|
|
24
|
+
export enum ServerOpcode {
|
|
25
|
+
/** Initial state on join */
|
|
26
|
+
S_INIT = 'S_INIT',
|
|
27
|
+
|
|
28
|
+
/** Delta state update */
|
|
29
|
+
S_UPDATE = 'S_UPDATE',
|
|
30
|
+
|
|
31
|
+
/** Entity spawned */
|
|
32
|
+
S_SPAWN = 'S_SPAWN',
|
|
33
|
+
|
|
34
|
+
/** Entity destroyed */
|
|
35
|
+
S_DESPAWN = 'S_DESPAWN',
|
|
36
|
+
|
|
37
|
+
/** Custom game event */
|
|
38
|
+
S_EVENT = 'S_EVENT',
|
|
39
|
+
|
|
40
|
+
/** Error message */
|
|
41
|
+
S_ERROR = 'S_ERROR',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Client movement input.
|
|
46
|
+
*/
|
|
47
|
+
export interface C_Move {
|
|
48
|
+
op: ClientOpcode.C_MOVE;
|
|
49
|
+
seq: number;
|
|
50
|
+
dir: { x: number; y: number };
|
|
51
|
+
timestamp: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Client stop input.
|
|
56
|
+
*/
|
|
57
|
+
export interface C_Stop {
|
|
58
|
+
op: ClientOpcode.C_STOP;
|
|
59
|
+
seq: number;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Client action input.
|
|
65
|
+
*/
|
|
66
|
+
export interface C_Action {
|
|
67
|
+
op: ClientOpcode.C_ACTION;
|
|
68
|
+
seq: number;
|
|
69
|
+
action: string;
|
|
70
|
+
data?: Record<string, unknown>;
|
|
71
|
+
timestamp: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Server initial state.
|
|
76
|
+
*/
|
|
77
|
+
export interface S_Init {
|
|
78
|
+
op: ServerOpcode.S_INIT;
|
|
79
|
+
playerId: string;
|
|
80
|
+
tick: number;
|
|
81
|
+
entities: Array<{
|
|
82
|
+
id: string;
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
vx: number;
|
|
86
|
+
vy: number;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Server delta update.
|
|
92
|
+
*/
|
|
93
|
+
export interface S_Update {
|
|
94
|
+
op: ServerOpcode.S_UPDATE;
|
|
95
|
+
tick: number;
|
|
96
|
+
entities: Array<{
|
|
97
|
+
id: string;
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
vx: number;
|
|
101
|
+
vy: number;
|
|
102
|
+
seq?: number; // Echo client seq for reconciliation
|
|
103
|
+
}>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Server entity spawn.
|
|
108
|
+
*/
|
|
109
|
+
export interface S_Spawn {
|
|
110
|
+
op: ServerOpcode.S_SPAWN;
|
|
111
|
+
entity: {
|
|
112
|
+
id: string;
|
|
113
|
+
x: number;
|
|
114
|
+
y: number;
|
|
115
|
+
vx: number;
|
|
116
|
+
vy: number;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Server entity despawn.
|
|
122
|
+
*/
|
|
123
|
+
export interface S_Despawn {
|
|
124
|
+
op: ServerOpcode.S_DESPAWN;
|
|
125
|
+
entityId: string;
|
|
126
|
+
reason?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Server custom event.
|
|
131
|
+
*/
|
|
132
|
+
export interface S_Event {
|
|
133
|
+
op: ServerOpcode.S_EVENT;
|
|
134
|
+
event: string;
|
|
135
|
+
data?: Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Server error message.
|
|
140
|
+
*/
|
|
141
|
+
export interface S_Error {
|
|
142
|
+
op: ServerOpcode.S_ERROR;
|
|
143
|
+
code: string;
|
|
144
|
+
message: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Union of all client messages.
|
|
149
|
+
*/
|
|
150
|
+
export type ClientMessage = C_Move | C_Stop | C_Action;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Union of all server messages.
|
|
154
|
+
*/
|
|
155
|
+
export type ServerMessage =
|
|
156
|
+
| S_Init
|
|
157
|
+
| S_Update
|
|
158
|
+
| S_Spawn
|
|
159
|
+
| S_Despawn
|
|
160
|
+
| S_Event
|
|
161
|
+
| S_Error;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility
|
|
3
|
+
*
|
|
4
|
+
* Wrapper around pino for structured logging.
|
|
5
|
+
* Consumers can override this with their own logger.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pino from 'pino';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default logger configuration.
|
|
12
|
+
*/
|
|
13
|
+
const defaultLogger = pino({
|
|
14
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
15
|
+
transport:
|
|
16
|
+
process.env.NODE_ENV === 'development'
|
|
17
|
+
? {
|
|
18
|
+
target: 'pino-pretty',
|
|
19
|
+
options: {
|
|
20
|
+
colorize: true,
|
|
21
|
+
translateTime: 'HH:MM:ss',
|
|
22
|
+
ignore: 'pid,hostname',
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
: undefined,
|
|
26
|
+
base: {
|
|
27
|
+
package: '@gamerstake/game-core',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Logger instance.
|
|
33
|
+
* Can be replaced by consumers.
|
|
34
|
+
*/
|
|
35
|
+
export let logger = defaultLogger;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Set a custom logger instance.
|
|
39
|
+
*
|
|
40
|
+
* @param customLogger - Custom pino logger
|
|
41
|
+
*/
|
|
42
|
+
export function setLogger(customLogger: pino.Logger): void {
|
|
43
|
+
logger = customLogger;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a child logger with additional context.
|
|
48
|
+
*
|
|
49
|
+
* @param bindings - Additional context fields
|
|
50
|
+
*/
|
|
51
|
+
export function createChildLogger(
|
|
52
|
+
bindings: Record<string, unknown>,
|
|
53
|
+
): pino.Logger {
|
|
54
|
+
return logger.child(bindings);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Logger class for dependency injection.
|
|
59
|
+
*/
|
|
60
|
+
export class Logger {
|
|
61
|
+
private logger: pino.Logger;
|
|
62
|
+
|
|
63
|
+
constructor(bindings?: Record<string, unknown>) {
|
|
64
|
+
this.logger = bindings ? logger.child(bindings) : logger;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
debug(obj: object, msg?: string): void;
|
|
68
|
+
debug(msg: string): void;
|
|
69
|
+
debug(objOrMsg: object | string, msg?: string): void {
|
|
70
|
+
if (typeof objOrMsg === 'string') {
|
|
71
|
+
this.logger.debug(objOrMsg);
|
|
72
|
+
} else {
|
|
73
|
+
this.logger.debug(objOrMsg, msg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
info(obj: object, msg?: string): void;
|
|
78
|
+
info(msg: string): void;
|
|
79
|
+
info(objOrMsg: object | string, msg?: string): void {
|
|
80
|
+
if (typeof objOrMsg === 'string') {
|
|
81
|
+
this.logger.info(objOrMsg);
|
|
82
|
+
} else {
|
|
83
|
+
this.logger.info(objOrMsg, msg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
warn(obj: object, msg?: string): void;
|
|
88
|
+
warn(msg: string): void;
|
|
89
|
+
warn(objOrMsg: object | string, msg?: string): void {
|
|
90
|
+
if (typeof objOrMsg === 'string') {
|
|
91
|
+
this.logger.warn(objOrMsg);
|
|
92
|
+
} else {
|
|
93
|
+
this.logger.warn(objOrMsg, msg);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
error(obj: object, msg?: string): void;
|
|
98
|
+
error(msg: string): void;
|
|
99
|
+
error(objOrMsg: object | string, msg?: string): void {
|
|
100
|
+
if (typeof objOrMsg === 'string') {
|
|
101
|
+
this.logger.error(objOrMsg);
|
|
102
|
+
} else {
|
|
103
|
+
this.logger.error(objOrMsg, msg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
child(bindings: Record<string, unknown>): Logger {
|
|
108
|
+
const childLogger = new Logger();
|
|
109
|
+
childLogger.logger = this.logger.child(bindings);
|
|
110
|
+
return childLogger;
|
|
111
|
+
}
|
|
112
|
+
}
|