@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual Testing Server
|
|
3
|
+
*
|
|
4
|
+
* A simple multiplayer game server for manual testing of game-core.
|
|
5
|
+
* Players can move around a 2D world and see each other.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Server } from 'socket.io';
|
|
9
|
+
import {
|
|
10
|
+
GameServer,
|
|
11
|
+
Room,
|
|
12
|
+
Entity,
|
|
13
|
+
GameRules,
|
|
14
|
+
Command,
|
|
15
|
+
MoveCommand,
|
|
16
|
+
ActionCommand,
|
|
17
|
+
logger,
|
|
18
|
+
} from '../../dist/index.js';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Simple Game Rules
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
class SimpleGameRules implements GameRules {
|
|
25
|
+
onRoomCreated(room: Room): void {
|
|
26
|
+
logger.info('Room created! Waiting for players...');
|
|
27
|
+
|
|
28
|
+
// Spawn some static objects (obstacles)
|
|
29
|
+
for (let i = 0; i < 5; i++) {
|
|
30
|
+
const obstacle = new Entity(`obstacle-${i}`,
|
|
31
|
+
Math.random() * 800 + 100,
|
|
32
|
+
Math.random() * 800 + 100
|
|
33
|
+
);
|
|
34
|
+
room.spawnEntity(obstacle);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onPlayerJoin(room: Room, player: Entity): void {
|
|
39
|
+
// Spawn player at random position
|
|
40
|
+
const x = Math.random() * 800 + 100;
|
|
41
|
+
const y = Math.random() * 800 + 100;
|
|
42
|
+
player.setPosition(x, y);
|
|
43
|
+
|
|
44
|
+
logger.info(`Player ${player.id} joined at (${x.toFixed(0)}, ${y.toFixed(0)})`);
|
|
45
|
+
|
|
46
|
+
// Broadcast to other players
|
|
47
|
+
room.broadcast({
|
|
48
|
+
op: 'PLAYER_JOINED',
|
|
49
|
+
playerId: player.id,
|
|
50
|
+
x: player.x,
|
|
51
|
+
y: player.y,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onPlayerLeave(room: Room, playerId: string): void {
|
|
56
|
+
logger.info(`Player ${playerId} left`);
|
|
57
|
+
|
|
58
|
+
room.broadcast({
|
|
59
|
+
op: 'PLAYER_LEFT',
|
|
60
|
+
playerId,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onTick(room: Room, delta: number): void {
|
|
65
|
+
// Update all entities with velocity
|
|
66
|
+
room.getRegistry().forEach((entity) => {
|
|
67
|
+
if (entity.vx !== 0 || entity.vy !== 0) {
|
|
68
|
+
entity.updatePosition(delta);
|
|
69
|
+
|
|
70
|
+
// Keep entities in bounds (0-1000)
|
|
71
|
+
if (entity.x < 0) entity.setPosition(0, entity.y);
|
|
72
|
+
if (entity.x > 1000) entity.setPosition(1000, entity.y);
|
|
73
|
+
if (entity.y < 0) entity.setPosition(entity.x, 0);
|
|
74
|
+
if (entity.y > 1000) entity.setPosition(entity.x, 1000);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onCommand(room: Room, playerId: string, command: Command): void {
|
|
80
|
+
const player = room.getRegistry().get(playerId);
|
|
81
|
+
if (!player) return;
|
|
82
|
+
|
|
83
|
+
if (command.type === 'move') {
|
|
84
|
+
const moveCmd = command as MoveCommand;
|
|
85
|
+
const speed = 200; // units per second
|
|
86
|
+
|
|
87
|
+
// Normalize direction
|
|
88
|
+
const len = Math.sqrt(moveCmd.dir.x ** 2 + moveCmd.dir.y ** 2);
|
|
89
|
+
if (len > 0) {
|
|
90
|
+
const nx = moveCmd.dir.x / len;
|
|
91
|
+
const ny = moveCmd.dir.y / len;
|
|
92
|
+
player.setVelocity(nx * speed, ny * speed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger.debug(`Player ${playerId} moving: (${moveCmd.dir.x}, ${moveCmd.dir.y})`);
|
|
96
|
+
}
|
|
97
|
+
else if (command.type === 'stop') {
|
|
98
|
+
player.setVelocity(0, 0);
|
|
99
|
+
logger.debug(`Player ${playerId} stopped`);
|
|
100
|
+
}
|
|
101
|
+
else if (command.type === 'action') {
|
|
102
|
+
logger.info(`Player ${playerId} performed action: ${JSON.stringify(command)}`);
|
|
103
|
+
|
|
104
|
+
// Example: broadcast action to nearby players
|
|
105
|
+
room.broadcast({
|
|
106
|
+
op: 'PLAYER_ACTION',
|
|
107
|
+
playerId,
|
|
108
|
+
action: (command as any).action,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
shouldEndRoom(room: Room): boolean {
|
|
114
|
+
// Persistent world - never end
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// Server Setup
|
|
121
|
+
// ============================================================================
|
|
122
|
+
|
|
123
|
+
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
|
124
|
+
|
|
125
|
+
// Create Socket.io server
|
|
126
|
+
const io = new Server(PORT, {
|
|
127
|
+
cors: {
|
|
128
|
+
origin: '*', // For manual testing only!
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Create game server
|
|
133
|
+
const gameServer = new GameServer();
|
|
134
|
+
gameServer.setServer(io);
|
|
135
|
+
|
|
136
|
+
// Create a test room
|
|
137
|
+
const room = gameServer.createRoom('test-room', new SimpleGameRules(), {
|
|
138
|
+
tickRate: 20, // 20 TPS
|
|
139
|
+
cellSize: 512,
|
|
140
|
+
maxEntities: 100,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
logger.info(`Game server started on port ${PORT}`);
|
|
144
|
+
logger.info(`Room: ${room.id}`);
|
|
145
|
+
logger.info(`Tick rate: 20 TPS`);
|
|
146
|
+
logger.info('');
|
|
147
|
+
logger.info('Connect with: node examples/simple-game/client.js');
|
|
148
|
+
logger.info('');
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Socket.io Connection Handling
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
io.on('connection', (socket) => {
|
|
155
|
+
const playerId = socket.id;
|
|
156
|
+
logger.info(`Socket connected: ${playerId}`);
|
|
157
|
+
|
|
158
|
+
// Create player entity
|
|
159
|
+
const player = new Entity(playerId, 0, 0);
|
|
160
|
+
room.addPlayer(player);
|
|
161
|
+
|
|
162
|
+
// Register socket for networking
|
|
163
|
+
room.getNetwork().registerSocket(playerId, socket);
|
|
164
|
+
|
|
165
|
+
// Send initial state to the new player
|
|
166
|
+
const snapshot = room.getSnapshot();
|
|
167
|
+
socket.emit('S_INIT', {
|
|
168
|
+
op: 'S_INIT',
|
|
169
|
+
playerId,
|
|
170
|
+
entities: snapshot.entities,
|
|
171
|
+
timestamp: Date.now(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Handle player inputs
|
|
175
|
+
socket.on('C_MOVE', (data) => {
|
|
176
|
+
room.queueInput(playerId, {
|
|
177
|
+
seq: data.seq,
|
|
178
|
+
type: 'move',
|
|
179
|
+
dir: data.dir,
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
} as MoveCommand);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
socket.on('C_STOP', (data) => {
|
|
185
|
+
room.queueInput(playerId, {
|
|
186
|
+
seq: data.seq,
|
|
187
|
+
type: 'stop',
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
socket.on('C_ACTION', (data) => {
|
|
193
|
+
room.queueInput(playerId, {
|
|
194
|
+
seq: data.seq,
|
|
195
|
+
type: 'action',
|
|
196
|
+
action: data.action,
|
|
197
|
+
data: data.data,
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
} as ActionCommand);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
socket.on('disconnect', () => {
|
|
203
|
+
logger.info(`Socket disconnected: ${playerId}`);
|
|
204
|
+
room.removePlayer(playerId);
|
|
205
|
+
room.getNetwork().unregisterSocket(playerId);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Metrics Logging
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
setInterval(() => {
|
|
214
|
+
const metrics = gameServer.getMetrics();
|
|
215
|
+
const roomMetrics = metrics.rooms[0];
|
|
216
|
+
|
|
217
|
+
if (roomMetrics) {
|
|
218
|
+
logger.info(`Room: ${roomMetrics.playerCount} players, ${roomMetrics.entityCount} entities, ` +
|
|
219
|
+
`Avg tick: ${roomMetrics.avgTickTime?.toFixed(2) ?? 'N/A'}ms`);
|
|
220
|
+
}
|
|
221
|
+
}, 10000); // Log every 10 seconds
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Graceful Shutdown
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
process.on('SIGINT', () => {
|
|
228
|
+
logger.info('');
|
|
229
|
+
logger.info('Shutting down...');
|
|
230
|
+
gameServer.destroyRoom(room.id);
|
|
231
|
+
io.close();
|
|
232
|
+
process.exit(0);
|
|
233
|
+
});
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Config } from 'jest';
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
displayName: 'game-core',
|
|
5
|
+
preset: '../../jest.preset.js',
|
|
6
|
+
testEnvironment: 'node',
|
|
7
|
+
transform: {
|
|
8
|
+
'^.+\\.ts$': [
|
|
9
|
+
'ts-jest',
|
|
10
|
+
{
|
|
11
|
+
tsconfig: '<rootDir>/tsconfig.json',
|
|
12
|
+
useESM: true,
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
extensionsToTreatAsEsm: ['.ts'],
|
|
17
|
+
moduleNameMapper: {
|
|
18
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
19
|
+
},
|
|
20
|
+
testMatch: ['<rootDir>/tests/**/*.test.ts'],
|
|
21
|
+
collectCoverageFrom: [
|
|
22
|
+
'src/**/*.ts',
|
|
23
|
+
'!src/**/*.d.ts',
|
|
24
|
+
'!src/index.ts',
|
|
25
|
+
'!src/types/**',
|
|
26
|
+
],
|
|
27
|
+
coverageDirectory: 'coverage',
|
|
28
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
29
|
+
coverageThreshold: {
|
|
30
|
+
global: {
|
|
31
|
+
branches: 80,
|
|
32
|
+
functions: 80,
|
|
33
|
+
lines: 80,
|
|
34
|
+
statements: 80,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default config;
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gamerstake/game-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable multiplayer game engine for GamerStake platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "tsup --watch",
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "jest",
|
|
20
|
+
"test:watch": "jest --watch",
|
|
21
|
+
"test:coverage": "jest --coverage",
|
|
22
|
+
"lint": "eslint src/**/*.ts",
|
|
23
|
+
"clean": "rm -rf dist"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"multiplayer",
|
|
27
|
+
"game-engine",
|
|
28
|
+
"websocket",
|
|
29
|
+
"real-time",
|
|
30
|
+
"networking"
|
|
31
|
+
],
|
|
32
|
+
"author": "GamerStake",
|
|
33
|
+
"license": "UNLICENSED",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"socket.io": "^4.7.4",
|
|
36
|
+
"pino": "^9.0.0",
|
|
37
|
+
"zod": "^3.22.4"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.11.24",
|
|
41
|
+
"typescript": "^5.3.3",
|
|
42
|
+
"tsup": "^8.0.0",
|
|
43
|
+
"jest": "^29.7.0",
|
|
44
|
+
"@types/jest": "^29.5.12",
|
|
45
|
+
"ts-jest": "^29.1.2",
|
|
46
|
+
"socket.io-client": "^4.7.4",
|
|
47
|
+
"eslint": "^8.57.0",
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
49
|
+
"@typescript-eslint/parser": "^6.21.0"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Game Loop
|
|
3
|
+
*
|
|
4
|
+
* Fixed tick rate server loop with drift compensation.
|
|
5
|
+
* Processes inputs, updates state, broadcasts changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/Logger.js';
|
|
9
|
+
import type { LoopMetrics } from '../types/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tick handler interface.
|
|
13
|
+
* Implement this to receive tick callbacks.
|
|
14
|
+
*/
|
|
15
|
+
export interface TickHandler {
|
|
16
|
+
onTick(tickNumber: number, deltaMs: number): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* High-precision game loop using setTimeout with drift compensation.
|
|
21
|
+
*/
|
|
22
|
+
export class GameLoop {
|
|
23
|
+
private readonly tickRate: number;
|
|
24
|
+
private readonly tickMs: number;
|
|
25
|
+
private tickNumber = 0;
|
|
26
|
+
private lastTickTime = 0;
|
|
27
|
+
private running = false;
|
|
28
|
+
private timeout: NodeJS.Timeout | null = null;
|
|
29
|
+
private readonly handlers: TickHandler[] = [];
|
|
30
|
+
|
|
31
|
+
// Performance metrics
|
|
32
|
+
private tickTimes: number[] = [];
|
|
33
|
+
private readonly metricsWindowSize = 100;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new game loop.
|
|
37
|
+
*
|
|
38
|
+
* @param tickRate - Ticks per second (default: 20)
|
|
39
|
+
*/
|
|
40
|
+
constructor(tickRate = 20) {
|
|
41
|
+
this.tickRate = tickRate;
|
|
42
|
+
this.tickMs = 1000 / tickRate;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register a tick handler.
|
|
47
|
+
*/
|
|
48
|
+
addHandler(handler: TickHandler): void {
|
|
49
|
+
this.handlers.push(handler);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a tick handler.
|
|
54
|
+
*/
|
|
55
|
+
removeHandler(handler: TickHandler): void {
|
|
56
|
+
const index = this.handlers.indexOf(handler);
|
|
57
|
+
if (index !== -1) {
|
|
58
|
+
this.handlers.splice(index, 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start the game loop.
|
|
64
|
+
*/
|
|
65
|
+
start(): void {
|
|
66
|
+
if (this.running) {
|
|
67
|
+
logger.warn('Game loop already running');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.running = true;
|
|
72
|
+
this.lastTickTime = performance.now();
|
|
73
|
+
this.tickNumber = 0;
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
{ tickRate: this.tickRate, tickMs: this.tickMs },
|
|
77
|
+
'Game loop started',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
this.scheduleNextTick();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stop the game loop.
|
|
85
|
+
*/
|
|
86
|
+
stop(): void {
|
|
87
|
+
this.running = false;
|
|
88
|
+
|
|
89
|
+
if (this.timeout) {
|
|
90
|
+
clearTimeout(this.timeout);
|
|
91
|
+
this.timeout = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
logger.info({ totalTicks: this.tickNumber }, 'Game loop stopped');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if loop is running.
|
|
99
|
+
*/
|
|
100
|
+
isRunning(): boolean {
|
|
101
|
+
return this.running;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get current tick number.
|
|
106
|
+
*/
|
|
107
|
+
getCurrentTick(): number {
|
|
108
|
+
return this.tickNumber;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get tick rate (TPS).
|
|
113
|
+
*/
|
|
114
|
+
getTickRate(): number {
|
|
115
|
+
return this.tickRate;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get tick duration (ms).
|
|
120
|
+
*/
|
|
121
|
+
getTickMs(): number {
|
|
122
|
+
return this.tickMs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get performance metrics.
|
|
127
|
+
*/
|
|
128
|
+
getMetrics(): LoopMetrics {
|
|
129
|
+
if (this.tickTimes.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
avgTickTime: 0,
|
|
132
|
+
maxTickTime: 0,
|
|
133
|
+
minTickTime: 0,
|
|
134
|
+
ticksPerSecond: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sum = this.tickTimes.reduce((a, b) => a + b, 0);
|
|
139
|
+
const avg = sum / this.tickTimes.length;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
avgTickTime: avg,
|
|
143
|
+
maxTickTime: Math.max(...this.tickTimes),
|
|
144
|
+
minTickTime: Math.min(...this.tickTimes),
|
|
145
|
+
ticksPerSecond: 1000 / avg,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Schedule the next tick with drift compensation.
|
|
151
|
+
*/
|
|
152
|
+
private scheduleNextTick(): void {
|
|
153
|
+
if (!this.running) return;
|
|
154
|
+
|
|
155
|
+
const now = performance.now();
|
|
156
|
+
const elapsed = now - this.lastTickTime;
|
|
157
|
+
const drift = elapsed - this.tickMs;
|
|
158
|
+
|
|
159
|
+
// Compensate for drift (but never go negative)
|
|
160
|
+
const nextDelay = Math.max(0, this.tickMs - drift);
|
|
161
|
+
|
|
162
|
+
this.timeout = setTimeout(() => this.tick(), nextDelay);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Execute one tick.
|
|
167
|
+
*/
|
|
168
|
+
private tick(): void {
|
|
169
|
+
const tickStart = performance.now();
|
|
170
|
+
const deltaMs = tickStart - this.lastTickTime;
|
|
171
|
+
this.lastTickTime = tickStart;
|
|
172
|
+
this.tickNumber++;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// Call all handlers
|
|
176
|
+
for (const handler of this.handlers) {
|
|
177
|
+
handler.onTick(this.tickNumber, deltaMs);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.error({ error, tick: this.tickNumber }, 'Error in tick handler');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Record tick processing time
|
|
184
|
+
const tickDuration = performance.now() - tickStart;
|
|
185
|
+
this.recordTickTime(tickDuration);
|
|
186
|
+
|
|
187
|
+
// Warn if tick took too long (>80% of budget)
|
|
188
|
+
if (tickDuration > this.tickMs * 0.8) {
|
|
189
|
+
logger.warn(
|
|
190
|
+
{
|
|
191
|
+
tickNumber: this.tickNumber,
|
|
192
|
+
duration: tickDuration,
|
|
193
|
+
budget: this.tickMs,
|
|
194
|
+
},
|
|
195
|
+
'Tick took too long',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Schedule next tick
|
|
200
|
+
this.scheduleNextTick();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record tick time for metrics.
|
|
205
|
+
*/
|
|
206
|
+
private recordTickTime(duration: number): void {
|
|
207
|
+
this.tickTimes.push(duration);
|
|
208
|
+
|
|
209
|
+
// Keep only last N samples
|
|
210
|
+
if (this.tickTimes.length > this.metricsWindowSize) {
|
|
211
|
+
this.tickTimes.shift();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Game Rules Interface
|
|
3
|
+
*
|
|
4
|
+
* Pluggable game logic interface.
|
|
5
|
+
* Implement this to create custom multiplayer games.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Room } from './Room.js';
|
|
9
|
+
import type { Entity } from '../entities/Entity.js';
|
|
10
|
+
import type { Command } from '../input/Command.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Game-specific logic interface.
|
|
14
|
+
*
|
|
15
|
+
* This is the core abstraction that makes game-core reusable.
|
|
16
|
+
* All game-specific logic is implemented through this interface,
|
|
17
|
+
* keeping the engine generic.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* class MyGameRules implements GameRules {
|
|
22
|
+
* onRoomCreated(room: Room): void {
|
|
23
|
+
* // Spawn initial entities
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* onPlayerJoin(room: Room, player: Entity): void {
|
|
27
|
+
* // Setup player
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* onTick(room: Room, delta: number): void {
|
|
31
|
+
* // Update game state
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* onCommand(room: Room, playerId: string, command: Command): void {
|
|
35
|
+
* // Handle player input
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* onPlayerLeave(room: Room, playerId: string): void {
|
|
39
|
+
* // Cleanup player
|
|
40
|
+
* }
|
|
41
|
+
*
|
|
42
|
+
* shouldEndRoom(room: Room): boolean {
|
|
43
|
+
* // For match-based games, return true when match ends
|
|
44
|
+
* // For persistent worlds, return false
|
|
45
|
+
* return false;
|
|
46
|
+
* }
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export interface GameRules<TEntity extends Entity = Entity> {
|
|
51
|
+
/**
|
|
52
|
+
* Called once when room is created.
|
|
53
|
+
* Use for initialization (spawn items, set boundaries, etc).
|
|
54
|
+
*
|
|
55
|
+
* @param room - The newly created room
|
|
56
|
+
*/
|
|
57
|
+
onRoomCreated(room: Room<TEntity>): void;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Called when a player joins the room.
|
|
61
|
+
*
|
|
62
|
+
* @param room - The room
|
|
63
|
+
* @param player - The entity representing the player
|
|
64
|
+
*/
|
|
65
|
+
onPlayerJoin(room: Room<TEntity>, player: TEntity): void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Called when a player leaves the room.
|
|
69
|
+
*
|
|
70
|
+
* @param room - The room
|
|
71
|
+
* @param playerId - The ID of the leaving player
|
|
72
|
+
*/
|
|
73
|
+
onPlayerLeave(room: Room<TEntity>, playerId: string): void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Called every tick (20 TPS by default).
|
|
77
|
+
* Process inputs, update game state, check win conditions.
|
|
78
|
+
*
|
|
79
|
+
* @param room - The room
|
|
80
|
+
* @param delta - Time since last tick (milliseconds)
|
|
81
|
+
*/
|
|
82
|
+
onTick(room: Room<TEntity>, delta: number): void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Called when a player sends a command.
|
|
86
|
+
*
|
|
87
|
+
* @param room - The room
|
|
88
|
+
* @param playerId - The player who sent the command
|
|
89
|
+
* @param command - The input command from the player
|
|
90
|
+
*/
|
|
91
|
+
onCommand(room: Room<TEntity>, playerId: string, command: Command): void;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if the room should be destroyed.
|
|
95
|
+
*
|
|
96
|
+
* For match-based games, return true when match ends.
|
|
97
|
+
* For persistent worlds, return false.
|
|
98
|
+
*
|
|
99
|
+
* @param room - The room
|
|
100
|
+
* @returns True if room should end
|
|
101
|
+
*/
|
|
102
|
+
shouldEndRoom(room: Room<TEntity>): boolean;
|
|
103
|
+
}
|