csterm-server 1.0.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/dist/GameRunner.d.ts +54 -0
- package/dist/GameRunner.js +998 -0
- package/dist/LockstepRunner.d.ts +39 -0
- package/dist/LockstepRunner.js +276 -0
- package/dist/Room.d.ts +43 -0
- package/dist/Room.js +498 -0
- package/dist/RoomManager.d.ts +29 -0
- package/dist/RoomManager.js +277 -0
- package/dist/VoiceRelay.d.ts +62 -0
- package/dist/VoiceRelay.js +167 -0
- package/dist/hub/HubServer.d.ts +37 -0
- package/dist/hub/HubServer.js +266 -0
- package/dist/hub/PoolRegistry.d.ts +35 -0
- package/dist/hub/PoolRegistry.js +185 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +175 -0
- package/dist/pool/GameServer.d.ts +29 -0
- package/dist/pool/GameServer.js +185 -0
- package/dist/pool/PoolClient.d.ts +48 -0
- package/dist/pool/PoolClient.js +204 -0
- package/dist/protocol.d.ts +433 -0
- package/dist/protocol.js +59 -0
- package/dist/test/HeadlessClient.d.ts +44 -0
- package/dist/test/HeadlessClient.js +196 -0
- package/dist/test/testTeamSync.d.ts +2 -0
- package/dist/test/testTeamSync.js +82 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +139 -0
- package/package.json +50 -0
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
// Server-side game loop for CS-CLI multiplayer
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { createVec3, vec3Add, vec3Sub, vec3Scale, vec3Length, vec3Normalize, vec3Distance, WEAPON_DEFS, DEFAULT_ECONOMY_CONFIG, } from './types.js';
|
|
4
|
+
// Game timing constants
|
|
5
|
+
const WARMUP_TIME = 5;
|
|
6
|
+
const FREEZE_TIME_DM = 5;
|
|
7
|
+
const FREEZE_TIME_COMP = 15;
|
|
8
|
+
const ROUND_TIME = 120;
|
|
9
|
+
const ROUND_END_DELAY = 3;
|
|
10
|
+
// Player constants
|
|
11
|
+
const PLAYER_MOVE_SPEED = 8;
|
|
12
|
+
const PLAYER_EYE_HEIGHT = 1.7;
|
|
13
|
+
const PLAYER_RADIUS = 0.4;
|
|
14
|
+
const GRAVITY = 20;
|
|
15
|
+
const JUMP_VELOCITY = 8;
|
|
16
|
+
// Bot AI constants
|
|
17
|
+
const BOT_REACTION_TIME_EASY = 800;
|
|
18
|
+
const BOT_REACTION_TIME_MEDIUM = 400;
|
|
19
|
+
const BOT_REACTION_TIME_HARD = 150;
|
|
20
|
+
const BOT_ACCURACY_EASY = 0.3;
|
|
21
|
+
const BOT_ACCURACY_MEDIUM = 0.5;
|
|
22
|
+
const BOT_ACCURACY_HARD = 0.8;
|
|
23
|
+
export class GameRunner {
|
|
24
|
+
state;
|
|
25
|
+
mapData;
|
|
26
|
+
roomConfig;
|
|
27
|
+
serverConfig;
|
|
28
|
+
// Callbacks
|
|
29
|
+
broadcast;
|
|
30
|
+
sendToClient;
|
|
31
|
+
// Intervals
|
|
32
|
+
gameLoopInterval = null;
|
|
33
|
+
broadcastInterval = null;
|
|
34
|
+
// Timing
|
|
35
|
+
lastTickTime = 0;
|
|
36
|
+
tickDeltaMs;
|
|
37
|
+
broadcastDeltaMs;
|
|
38
|
+
// Spawn tracking
|
|
39
|
+
usedSpawns = new Set();
|
|
40
|
+
constructor(state, mapData, roomConfig, serverConfig, broadcast, sendToClient) {
|
|
41
|
+
this.state = state;
|
|
42
|
+
this.mapData = mapData;
|
|
43
|
+
this.roomConfig = roomConfig;
|
|
44
|
+
this.serverConfig = serverConfig;
|
|
45
|
+
this.broadcast = broadcast;
|
|
46
|
+
this.sendToClient = sendToClient;
|
|
47
|
+
this.tickDeltaMs = 1000 / serverConfig.tickRate;
|
|
48
|
+
this.broadcastDeltaMs = 1000 / serverConfig.broadcastRate;
|
|
49
|
+
}
|
|
50
|
+
// ============ Lifecycle ============
|
|
51
|
+
start() {
|
|
52
|
+
this.lastTickTime = Date.now();
|
|
53
|
+
this.state.phaseStartTime = Date.now();
|
|
54
|
+
this.state.phase = 'warmup';
|
|
55
|
+
// Start game loop
|
|
56
|
+
this.gameLoopInterval = setInterval(() => this.tick(), this.tickDeltaMs);
|
|
57
|
+
// Start broadcast loop
|
|
58
|
+
this.broadcastInterval = setInterval(() => this.broadcastState(), this.broadcastDeltaMs);
|
|
59
|
+
console.log('GameRunner started');
|
|
60
|
+
}
|
|
61
|
+
stop() {
|
|
62
|
+
if (this.gameLoopInterval) {
|
|
63
|
+
clearInterval(this.gameLoopInterval);
|
|
64
|
+
this.gameLoopInterval = null;
|
|
65
|
+
}
|
|
66
|
+
if (this.broadcastInterval) {
|
|
67
|
+
clearInterval(this.broadcastInterval);
|
|
68
|
+
this.broadcastInterval = null;
|
|
69
|
+
}
|
|
70
|
+
console.log('GameRunner stopped');
|
|
71
|
+
}
|
|
72
|
+
getPhase() {
|
|
73
|
+
return this.state.phase;
|
|
74
|
+
}
|
|
75
|
+
// ============ Player Management ============
|
|
76
|
+
addPlayer(clientId, name, team) {
|
|
77
|
+
const spawn = this.getSpawnPoint(team);
|
|
78
|
+
const player = {
|
|
79
|
+
id: clientId,
|
|
80
|
+
name,
|
|
81
|
+
team,
|
|
82
|
+
position: { ...spawn.position, y: spawn.position.y + PLAYER_EYE_HEIGHT },
|
|
83
|
+
velocity: createVec3(),
|
|
84
|
+
yaw: spawn.angle,
|
|
85
|
+
pitch: 0,
|
|
86
|
+
health: 100,
|
|
87
|
+
armor: 0,
|
|
88
|
+
isAlive: true,
|
|
89
|
+
currentWeapon: 'pistol',
|
|
90
|
+
weapons: new Map([
|
|
91
|
+
[2, { type: 'pistol', currentAmmo: 12, reserveAmmo: 36, isReloading: false, reloadStartTime: 0, lastFireTime: 0 }],
|
|
92
|
+
[3, { type: 'knife', currentAmmo: Infinity, reserveAmmo: Infinity, isReloading: false, reloadStartTime: 0, lastFireTime: 0 }],
|
|
93
|
+
]),
|
|
94
|
+
money: DEFAULT_ECONOMY_CONFIG.startMoney,
|
|
95
|
+
kills: 0,
|
|
96
|
+
deaths: 0,
|
|
97
|
+
lastInputSequence: 0,
|
|
98
|
+
};
|
|
99
|
+
this.state.players.set(clientId, player);
|
|
100
|
+
this.broadcast({
|
|
101
|
+
type: 'spawn_event',
|
|
102
|
+
entityId: clientId,
|
|
103
|
+
entityType: 'player',
|
|
104
|
+
position: player.position,
|
|
105
|
+
team,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
removePlayer(clientId) {
|
|
109
|
+
this.state.players.delete(clientId);
|
|
110
|
+
}
|
|
111
|
+
addBot(name, team, difficulty) {
|
|
112
|
+
const botId = `bot_${uuidv4().substring(0, 8)}`;
|
|
113
|
+
const spawn = this.getSpawnPoint(team);
|
|
114
|
+
const bot = {
|
|
115
|
+
id: botId,
|
|
116
|
+
name,
|
|
117
|
+
team,
|
|
118
|
+
difficulty,
|
|
119
|
+
position: { ...spawn.position, y: spawn.position.y + PLAYER_EYE_HEIGHT },
|
|
120
|
+
velocity: createVec3(),
|
|
121
|
+
yaw: spawn.angle,
|
|
122
|
+
pitch: 0,
|
|
123
|
+
health: 100,
|
|
124
|
+
armor: 0,
|
|
125
|
+
isAlive: true,
|
|
126
|
+
currentWeapon: 'pistol',
|
|
127
|
+
kills: 0,
|
|
128
|
+
deaths: 0,
|
|
129
|
+
targetId: null,
|
|
130
|
+
lastTargetSeen: 0,
|
|
131
|
+
wanderAngle: spawn.angle,
|
|
132
|
+
nextFireTime: 0,
|
|
133
|
+
};
|
|
134
|
+
this.state.bots.set(botId, bot);
|
|
135
|
+
this.broadcast({
|
|
136
|
+
type: 'spawn_event',
|
|
137
|
+
entityId: botId,
|
|
138
|
+
entityType: 'bot',
|
|
139
|
+
position: bot.position,
|
|
140
|
+
team,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// ============ Input Handling ============
|
|
144
|
+
handleInput(clientId, message) {
|
|
145
|
+
const player = this.state.players.get(clientId);
|
|
146
|
+
if (!player || !player.isAlive)
|
|
147
|
+
return;
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
switch (message.type) {
|
|
150
|
+
case 'input':
|
|
151
|
+
this.processPlayerInput(player, message.input, message.sequence);
|
|
152
|
+
break;
|
|
153
|
+
case 'fire':
|
|
154
|
+
this.processPlayerFire(player, now);
|
|
155
|
+
break;
|
|
156
|
+
case 'reload':
|
|
157
|
+
this.processPlayerReload(player, now);
|
|
158
|
+
break;
|
|
159
|
+
case 'buy_weapon':
|
|
160
|
+
if (this.state.phase === 'freeze' || this.state.phase === 'warmup') {
|
|
161
|
+
this.processPlayerBuy(player, message.weaponName);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
case 'select_weapon':
|
|
165
|
+
const weapon = player.weapons.get(message.slot);
|
|
166
|
+
if (weapon) {
|
|
167
|
+
player.currentWeapon = weapon.type;
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
case 'drop_weapon':
|
|
171
|
+
this.processPlayerDrop(player, now);
|
|
172
|
+
break;
|
|
173
|
+
case 'pickup_weapon':
|
|
174
|
+
this.processPlayerPickup(player, message.weaponId);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
processPlayerInput(player, input, sequence) {
|
|
179
|
+
// Update look direction
|
|
180
|
+
player.yaw = input.yaw;
|
|
181
|
+
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, input.pitch));
|
|
182
|
+
// Can't move during freeze phase
|
|
183
|
+
if (this.state.phase === 'freeze' || this.state.phase === 'round_end') {
|
|
184
|
+
player.lastInputSequence = sequence;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Calculate movement direction
|
|
188
|
+
const forward = vec3Normalize({
|
|
189
|
+
x: -Math.sin(player.yaw),
|
|
190
|
+
y: 0,
|
|
191
|
+
z: -Math.cos(player.yaw),
|
|
192
|
+
});
|
|
193
|
+
const right = vec3Normalize({
|
|
194
|
+
x: Math.cos(player.yaw),
|
|
195
|
+
y: 0,
|
|
196
|
+
z: -Math.sin(player.yaw),
|
|
197
|
+
});
|
|
198
|
+
// Apply movement
|
|
199
|
+
const moveDir = vec3Add(vec3Scale(forward, input.forward), vec3Scale(right, input.strafe));
|
|
200
|
+
if (vec3Length(moveDir) > 0) {
|
|
201
|
+
const normalizedDir = vec3Normalize(moveDir);
|
|
202
|
+
const speed = PLAYER_MOVE_SPEED * (this.tickDeltaMs / 1000);
|
|
203
|
+
// Use sub-stepping for collision detection to prevent tunneling
|
|
204
|
+
const moveLength = speed;
|
|
205
|
+
const stepSize = PLAYER_RADIUS * 0.5; // Max step size is half player radius
|
|
206
|
+
const numSteps = Math.max(1, Math.ceil(moveLength / stepSize));
|
|
207
|
+
const stepMove = speed / numSteps;
|
|
208
|
+
for (let i = 0; i < numSteps; i++) {
|
|
209
|
+
const stepDir = vec3Scale(normalizedDir, stepMove);
|
|
210
|
+
const newPos = vec3Add(player.position, stepDir);
|
|
211
|
+
// Check collision before applying
|
|
212
|
+
if (!this.checkCollision(newPos)) {
|
|
213
|
+
player.position = newPos;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// Try sliding along walls - try X only
|
|
217
|
+
const newPosX = { ...player.position, x: newPos.x };
|
|
218
|
+
if (!this.checkCollision(newPosX)) {
|
|
219
|
+
player.position = newPosX;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Try Z only
|
|
223
|
+
const newPosZ = { ...player.position, z: newPos.z };
|
|
224
|
+
if (!this.checkCollision(newPosZ)) {
|
|
225
|
+
player.position = newPosZ;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// If we hit a wall, stop sub-stepping
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Handle jumping
|
|
234
|
+
if (input.jump && player.position.y <= PLAYER_EYE_HEIGHT + 0.1) {
|
|
235
|
+
player.velocity.y = JUMP_VELOCITY;
|
|
236
|
+
}
|
|
237
|
+
player.lastInputSequence = sequence;
|
|
238
|
+
// Send acknowledgment
|
|
239
|
+
this.sendToClient(player.id, {
|
|
240
|
+
type: 'input_ack',
|
|
241
|
+
sequence,
|
|
242
|
+
position: player.position,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
processPlayerFire(player, now) {
|
|
246
|
+
const weaponSlot = this.getWeaponSlot(player.currentWeapon);
|
|
247
|
+
const weapon = player.weapons.get(weaponSlot);
|
|
248
|
+
if (!weapon)
|
|
249
|
+
return;
|
|
250
|
+
const def = WEAPON_DEFS[player.currentWeapon];
|
|
251
|
+
const fireInterval = 60000 / def.fireRate;
|
|
252
|
+
// Check if can fire
|
|
253
|
+
if (weapon.isReloading)
|
|
254
|
+
return;
|
|
255
|
+
if (weapon.currentAmmo <= 0)
|
|
256
|
+
return;
|
|
257
|
+
if (now - weapon.lastFireTime < fireInterval)
|
|
258
|
+
return;
|
|
259
|
+
// Fire the weapon
|
|
260
|
+
weapon.currentAmmo--;
|
|
261
|
+
weapon.lastFireTime = now;
|
|
262
|
+
// Calculate fire direction
|
|
263
|
+
const direction = this.getAimDirection(player, def.spread);
|
|
264
|
+
// Broadcast fire event
|
|
265
|
+
this.broadcast({
|
|
266
|
+
type: 'fire_event',
|
|
267
|
+
event: {
|
|
268
|
+
playerId: player.id,
|
|
269
|
+
origin: player.position,
|
|
270
|
+
direction,
|
|
271
|
+
weapon: player.currentWeapon,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
// Perform hit detection
|
|
275
|
+
this.performHitDetection(player, direction, def);
|
|
276
|
+
}
|
|
277
|
+
processPlayerReload(player, now) {
|
|
278
|
+
const weaponSlot = this.getWeaponSlot(player.currentWeapon);
|
|
279
|
+
const weapon = player.weapons.get(weaponSlot);
|
|
280
|
+
if (!weapon)
|
|
281
|
+
return;
|
|
282
|
+
const def = WEAPON_DEFS[player.currentWeapon];
|
|
283
|
+
if (weapon.isReloading)
|
|
284
|
+
return;
|
|
285
|
+
if (weapon.currentAmmo >= def.magazineSize)
|
|
286
|
+
return;
|
|
287
|
+
if (weapon.reserveAmmo <= 0)
|
|
288
|
+
return;
|
|
289
|
+
weapon.isReloading = true;
|
|
290
|
+
weapon.reloadStartTime = now;
|
|
291
|
+
}
|
|
292
|
+
processPlayerBuy(player, weaponName) {
|
|
293
|
+
const def = WEAPON_DEFS[weaponName];
|
|
294
|
+
if (!def)
|
|
295
|
+
return;
|
|
296
|
+
if (player.money < def.cost)
|
|
297
|
+
return;
|
|
298
|
+
player.money -= def.cost;
|
|
299
|
+
player.weapons.set(def.slot, {
|
|
300
|
+
type: def.type,
|
|
301
|
+
currentAmmo: def.magazineSize,
|
|
302
|
+
reserveAmmo: def.reserveAmmo,
|
|
303
|
+
isReloading: false,
|
|
304
|
+
reloadStartTime: 0,
|
|
305
|
+
lastFireTime: 0,
|
|
306
|
+
});
|
|
307
|
+
player.currentWeapon = def.type;
|
|
308
|
+
}
|
|
309
|
+
processPlayerDrop(player, now) {
|
|
310
|
+
const weaponSlot = this.getWeaponSlot(player.currentWeapon);
|
|
311
|
+
const weapon = player.weapons.get(weaponSlot);
|
|
312
|
+
if (!weapon || weapon.type === 'knife')
|
|
313
|
+
return;
|
|
314
|
+
// Create dropped weapon
|
|
315
|
+
const dropId = uuidv4().substring(0, 8);
|
|
316
|
+
const dropped = {
|
|
317
|
+
id: dropId,
|
|
318
|
+
weaponType: weapon.type,
|
|
319
|
+
position: { ...player.position, y: player.position.y - PLAYER_EYE_HEIGHT },
|
|
320
|
+
ammo: weapon.currentAmmo,
|
|
321
|
+
reserveAmmo: weapon.reserveAmmo,
|
|
322
|
+
dropTime: now,
|
|
323
|
+
};
|
|
324
|
+
this.state.droppedWeapons.set(dropId, dropped);
|
|
325
|
+
// Remove from player
|
|
326
|
+
player.weapons.delete(weaponSlot);
|
|
327
|
+
player.currentWeapon = player.weapons.has(2) ? 'pistol' : 'knife';
|
|
328
|
+
this.broadcast({
|
|
329
|
+
type: 'weapon_dropped',
|
|
330
|
+
weaponId: dropId,
|
|
331
|
+
weaponType: weapon.type,
|
|
332
|
+
position: dropped.position,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
processPlayerPickup(player, weaponId) {
|
|
336
|
+
const dropped = this.state.droppedWeapons.get(weaponId);
|
|
337
|
+
if (!dropped)
|
|
338
|
+
return;
|
|
339
|
+
// Check distance
|
|
340
|
+
const dist = vec3Distance(player.position, {
|
|
341
|
+
...dropped.position,
|
|
342
|
+
y: player.position.y,
|
|
343
|
+
});
|
|
344
|
+
if (dist > 3)
|
|
345
|
+
return;
|
|
346
|
+
// Pick up the weapon
|
|
347
|
+
const def = WEAPON_DEFS[dropped.weaponType];
|
|
348
|
+
player.weapons.set(def.slot, {
|
|
349
|
+
type: dropped.weaponType,
|
|
350
|
+
currentAmmo: dropped.ammo,
|
|
351
|
+
reserveAmmo: dropped.reserveAmmo,
|
|
352
|
+
isReloading: false,
|
|
353
|
+
reloadStartTime: 0,
|
|
354
|
+
lastFireTime: 0,
|
|
355
|
+
});
|
|
356
|
+
player.currentWeapon = dropped.weaponType;
|
|
357
|
+
this.state.droppedWeapons.delete(weaponId);
|
|
358
|
+
this.broadcast({
|
|
359
|
+
type: 'weapon_picked_up',
|
|
360
|
+
weaponId,
|
|
361
|
+
playerId: player.id,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// ============ Game Loop ============
|
|
365
|
+
tick() {
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
const deltaTime = (now - this.lastTickTime) / 1000;
|
|
368
|
+
this.lastTickTime = now;
|
|
369
|
+
this.state.tick++;
|
|
370
|
+
// Update phase
|
|
371
|
+
this.updatePhase(now);
|
|
372
|
+
// Only update physics/AI during live gameplay
|
|
373
|
+
if (this.state.phase === 'live' || this.state.phase === 'warmup') {
|
|
374
|
+
// Update players
|
|
375
|
+
for (const player of this.state.players.values()) {
|
|
376
|
+
this.updatePlayer(player, deltaTime, now);
|
|
377
|
+
}
|
|
378
|
+
// Update bots
|
|
379
|
+
for (const bot of this.state.bots.values()) {
|
|
380
|
+
this.updateBot(bot, deltaTime, now);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Update reloads always
|
|
384
|
+
this.updateReloads(now);
|
|
385
|
+
}
|
|
386
|
+
updatePhase(now) {
|
|
387
|
+
const elapsed = (now - this.state.phaseStartTime) / 1000;
|
|
388
|
+
switch (this.state.phase) {
|
|
389
|
+
case 'warmup':
|
|
390
|
+
if (elapsed >= WARMUP_TIME) {
|
|
391
|
+
this.startRound(now);
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
case 'freeze':
|
|
395
|
+
const freezeTime = this.roomConfig.mode === 'competitive'
|
|
396
|
+
? FREEZE_TIME_COMP
|
|
397
|
+
: FREEZE_TIME_DM;
|
|
398
|
+
if (elapsed >= freezeTime) {
|
|
399
|
+
this.state.phase = 'live';
|
|
400
|
+
this.state.phaseStartTime = now;
|
|
401
|
+
this.broadcastPhaseChange();
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
case 'live':
|
|
405
|
+
// Check round end conditions
|
|
406
|
+
const winner = this.checkRoundEnd();
|
|
407
|
+
if (winner) {
|
|
408
|
+
this.endRound(winner, now);
|
|
409
|
+
}
|
|
410
|
+
else if (elapsed >= ROUND_TIME) {
|
|
411
|
+
// Time ran out
|
|
412
|
+
this.endRound(this.getTimeoutWinner(), now);
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
case 'round_end':
|
|
416
|
+
if (elapsed >= ROUND_END_DELAY) {
|
|
417
|
+
// Check for match end
|
|
418
|
+
const roundsToWin = this.roomConfig.mode === 'competitive' ? 7 : 10;
|
|
419
|
+
if (this.state.tScore >= roundsToWin || this.state.ctScore >= roundsToWin) {
|
|
420
|
+
this.state.phase = 'match_end';
|
|
421
|
+
this.state.phaseStartTime = now;
|
|
422
|
+
this.broadcastPhaseChange();
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
this.startRound(now);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
startRound(now) {
|
|
432
|
+
this.state.phase = 'freeze';
|
|
433
|
+
this.state.phaseStartTime = now;
|
|
434
|
+
this.state.roundNumber++;
|
|
435
|
+
this.state.roundWinner = null;
|
|
436
|
+
this.usedSpawns.clear();
|
|
437
|
+
// Respawn all players and bots
|
|
438
|
+
for (const player of this.state.players.values()) {
|
|
439
|
+
this.respawnPlayer(player);
|
|
440
|
+
}
|
|
441
|
+
for (const bot of this.state.bots.values()) {
|
|
442
|
+
this.respawnBot(bot);
|
|
443
|
+
}
|
|
444
|
+
// Clear dropped weapons
|
|
445
|
+
this.state.droppedWeapons.clear();
|
|
446
|
+
this.broadcastPhaseChange();
|
|
447
|
+
}
|
|
448
|
+
endRound(winner, now) {
|
|
449
|
+
this.state.phase = 'round_end';
|
|
450
|
+
this.state.phaseStartTime = now;
|
|
451
|
+
this.state.roundWinner = winner;
|
|
452
|
+
if (winner === 'T') {
|
|
453
|
+
this.state.tScore++;
|
|
454
|
+
}
|
|
455
|
+
else if (winner === 'CT') {
|
|
456
|
+
this.state.ctScore++;
|
|
457
|
+
}
|
|
458
|
+
// Award economy
|
|
459
|
+
for (const player of this.state.players.values()) {
|
|
460
|
+
if (player.team === winner) {
|
|
461
|
+
player.money = Math.min(player.money + DEFAULT_ECONOMY_CONFIG.roundWinBonus, DEFAULT_ECONOMY_CONFIG.maxMoney);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
player.money = Math.min(player.money + DEFAULT_ECONOMY_CONFIG.roundLoseBonus, DEFAULT_ECONOMY_CONFIG.maxMoney);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
this.broadcastPhaseChange();
|
|
468
|
+
}
|
|
469
|
+
checkRoundEnd() {
|
|
470
|
+
// Count alive on each team
|
|
471
|
+
let tAlive = 0;
|
|
472
|
+
let ctAlive = 0;
|
|
473
|
+
for (const player of this.state.players.values()) {
|
|
474
|
+
if (player.isAlive) {
|
|
475
|
+
if (player.team === 'T')
|
|
476
|
+
tAlive++;
|
|
477
|
+
else if (player.team === 'CT')
|
|
478
|
+
ctAlive++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const bot of this.state.bots.values()) {
|
|
482
|
+
if (bot.isAlive) {
|
|
483
|
+
if (bot.team === 'T')
|
|
484
|
+
tAlive++;
|
|
485
|
+
else if (bot.team === 'CT')
|
|
486
|
+
ctAlive++;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (tAlive === 0 && ctAlive > 0)
|
|
490
|
+
return 'CT';
|
|
491
|
+
if (ctAlive === 0 && tAlive > 0)
|
|
492
|
+
return 'T';
|
|
493
|
+
if (tAlive === 0 && ctAlive === 0)
|
|
494
|
+
return 'CT'; // Draw goes to CT
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
getTimeoutWinner() {
|
|
498
|
+
// Count alive
|
|
499
|
+
let tAlive = 0;
|
|
500
|
+
let ctAlive = 0;
|
|
501
|
+
for (const player of this.state.players.values()) {
|
|
502
|
+
if (player.isAlive) {
|
|
503
|
+
if (player.team === 'T')
|
|
504
|
+
tAlive++;
|
|
505
|
+
else if (player.team === 'CT')
|
|
506
|
+
ctAlive++;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const bot of this.state.bots.values()) {
|
|
510
|
+
if (bot.isAlive) {
|
|
511
|
+
if (bot.team === 'T')
|
|
512
|
+
tAlive++;
|
|
513
|
+
else if (bot.team === 'CT')
|
|
514
|
+
ctAlive++;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (tAlive > ctAlive)
|
|
518
|
+
return 'T';
|
|
519
|
+
return 'CT'; // CT wins ties
|
|
520
|
+
}
|
|
521
|
+
respawnPlayer(player) {
|
|
522
|
+
const spawn = this.getSpawnPoint(player.team);
|
|
523
|
+
player.position = { ...spawn.position, y: spawn.position.y + PLAYER_EYE_HEIGHT };
|
|
524
|
+
player.velocity = createVec3();
|
|
525
|
+
player.yaw = spawn.angle;
|
|
526
|
+
player.pitch = 0;
|
|
527
|
+
player.health = 100;
|
|
528
|
+
player.armor = 0;
|
|
529
|
+
player.isAlive = true;
|
|
530
|
+
// Reset weapons
|
|
531
|
+
player.weapons = new Map([
|
|
532
|
+
[2, { type: 'pistol', currentAmmo: 12, reserveAmmo: 36, isReloading: false, reloadStartTime: 0, lastFireTime: 0 }],
|
|
533
|
+
[3, { type: 'knife', currentAmmo: Infinity, reserveAmmo: Infinity, isReloading: false, reloadStartTime: 0, lastFireTime: 0 }],
|
|
534
|
+
]);
|
|
535
|
+
player.currentWeapon = 'pistol';
|
|
536
|
+
}
|
|
537
|
+
respawnBot(bot) {
|
|
538
|
+
const spawn = this.getSpawnPoint(bot.team);
|
|
539
|
+
bot.position = { ...spawn.position, y: spawn.position.y + PLAYER_EYE_HEIGHT };
|
|
540
|
+
bot.velocity = createVec3();
|
|
541
|
+
bot.yaw = spawn.angle;
|
|
542
|
+
bot.pitch = 0;
|
|
543
|
+
bot.health = 100;
|
|
544
|
+
bot.armor = 0;
|
|
545
|
+
bot.isAlive = true;
|
|
546
|
+
bot.currentWeapon = 'pistol';
|
|
547
|
+
bot.targetId = null;
|
|
548
|
+
bot.lastTargetSeen = 0;
|
|
549
|
+
bot.wanderAngle = spawn.angle;
|
|
550
|
+
bot.nextFireTime = 0;
|
|
551
|
+
}
|
|
552
|
+
updatePlayer(player, deltaTime, now) {
|
|
553
|
+
if (!player.isAlive)
|
|
554
|
+
return;
|
|
555
|
+
// Apply gravity
|
|
556
|
+
player.velocity.y -= GRAVITY * deltaTime;
|
|
557
|
+
player.position.y += player.velocity.y * deltaTime;
|
|
558
|
+
// Ground collision
|
|
559
|
+
if (player.position.y < PLAYER_EYE_HEIGHT) {
|
|
560
|
+
player.position.y = PLAYER_EYE_HEIGHT;
|
|
561
|
+
player.velocity.y = 0;
|
|
562
|
+
}
|
|
563
|
+
// Clamp to map bounds
|
|
564
|
+
player.position.x = Math.max(this.mapData.bounds.min.x + PLAYER_RADIUS, Math.min(this.mapData.bounds.max.x - PLAYER_RADIUS, player.position.x));
|
|
565
|
+
player.position.z = Math.max(this.mapData.bounds.min.z + PLAYER_RADIUS, Math.min(this.mapData.bounds.max.z - PLAYER_RADIUS, player.position.z));
|
|
566
|
+
}
|
|
567
|
+
updateBot(bot, deltaTime, now) {
|
|
568
|
+
if (!bot.isAlive)
|
|
569
|
+
return;
|
|
570
|
+
// Apply gravity
|
|
571
|
+
bot.velocity.y -= GRAVITY * deltaTime;
|
|
572
|
+
bot.position.y += bot.velocity.y * deltaTime;
|
|
573
|
+
if (bot.position.y < PLAYER_EYE_HEIGHT) {
|
|
574
|
+
bot.position.y = PLAYER_EYE_HEIGHT;
|
|
575
|
+
bot.velocity.y = 0;
|
|
576
|
+
}
|
|
577
|
+
// Simple AI: find and attack enemies
|
|
578
|
+
const target = this.findBotTarget(bot);
|
|
579
|
+
if (target) {
|
|
580
|
+
bot.targetId = target.id;
|
|
581
|
+
bot.lastTargetSeen = now;
|
|
582
|
+
// Turn toward target
|
|
583
|
+
const toTarget = vec3Sub(target.position, bot.position);
|
|
584
|
+
const targetYaw = Math.atan2(-toTarget.x, -toTarget.z);
|
|
585
|
+
const targetPitch = Math.atan2(toTarget.y, Math.sqrt(toTarget.x * toTarget.x + toTarget.z * toTarget.z));
|
|
586
|
+
// Smooth turn
|
|
587
|
+
const turnSpeed = 3 * deltaTime;
|
|
588
|
+
let yawDiff = targetYaw - bot.yaw;
|
|
589
|
+
while (yawDiff > Math.PI)
|
|
590
|
+
yawDiff -= Math.PI * 2;
|
|
591
|
+
while (yawDiff < -Math.PI)
|
|
592
|
+
yawDiff += Math.PI * 2;
|
|
593
|
+
bot.yaw += yawDiff * turnSpeed;
|
|
594
|
+
bot.pitch += (targetPitch - bot.pitch) * turnSpeed;
|
|
595
|
+
// Move toward target if far away
|
|
596
|
+
const dist = vec3Length(toTarget);
|
|
597
|
+
if (dist > 10) {
|
|
598
|
+
const moveDir = vec3Normalize({ x: toTarget.x, y: 0, z: toTarget.z });
|
|
599
|
+
const speed = PLAYER_MOVE_SPEED * 0.7 * deltaTime;
|
|
600
|
+
const newPos = vec3Add(bot.position, vec3Scale(moveDir, speed));
|
|
601
|
+
if (!this.checkCollision(newPos)) {
|
|
602
|
+
bot.position = newPos;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// Fire at target
|
|
606
|
+
if (now >= bot.nextFireTime && dist < 50) {
|
|
607
|
+
this.botFire(bot, target, now);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
// Wander
|
|
612
|
+
bot.wanderAngle += (Math.random() - 0.5) * deltaTime;
|
|
613
|
+
const wanderDir = {
|
|
614
|
+
x: -Math.sin(bot.wanderAngle),
|
|
615
|
+
y: 0,
|
|
616
|
+
z: -Math.cos(bot.wanderAngle),
|
|
617
|
+
};
|
|
618
|
+
const speed = PLAYER_MOVE_SPEED * 0.3 * deltaTime;
|
|
619
|
+
const newPos = vec3Add(bot.position, vec3Scale(wanderDir, speed));
|
|
620
|
+
if (!this.checkCollision(newPos)) {
|
|
621
|
+
bot.position = newPos;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
// Turn around if hitting a wall
|
|
625
|
+
bot.wanderAngle += Math.PI;
|
|
626
|
+
}
|
|
627
|
+
bot.yaw = bot.wanderAngle;
|
|
628
|
+
}
|
|
629
|
+
// Clamp to map bounds
|
|
630
|
+
bot.position.x = Math.max(this.mapData.bounds.min.x + PLAYER_RADIUS, Math.min(this.mapData.bounds.max.x - PLAYER_RADIUS, bot.position.x));
|
|
631
|
+
bot.position.z = Math.max(this.mapData.bounds.min.z + PLAYER_RADIUS, Math.min(this.mapData.bounds.max.z - PLAYER_RADIUS, bot.position.z));
|
|
632
|
+
}
|
|
633
|
+
findBotTarget(bot) {
|
|
634
|
+
let closestDist = Infinity;
|
|
635
|
+
let closest = null;
|
|
636
|
+
// Check players
|
|
637
|
+
for (const player of this.state.players.values()) {
|
|
638
|
+
if (!player.isAlive || player.team === bot.team)
|
|
639
|
+
continue;
|
|
640
|
+
const dist = vec3Distance(bot.position, player.position);
|
|
641
|
+
if (dist < closestDist) {
|
|
642
|
+
closestDist = dist;
|
|
643
|
+
closest = { id: player.id, position: player.position };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Check other bots
|
|
647
|
+
for (const otherBot of this.state.bots.values()) {
|
|
648
|
+
if (!otherBot.isAlive || otherBot.team === bot.team || otherBot.id === bot.id)
|
|
649
|
+
continue;
|
|
650
|
+
const dist = vec3Distance(bot.position, otherBot.position);
|
|
651
|
+
if (dist < closestDist) {
|
|
652
|
+
closestDist = dist;
|
|
653
|
+
closest = { id: otherBot.id, position: otherBot.position };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return closest;
|
|
657
|
+
}
|
|
658
|
+
botFire(bot, target, now) {
|
|
659
|
+
// Get bot accuracy based on difficulty
|
|
660
|
+
let accuracy;
|
|
661
|
+
let reactionTime;
|
|
662
|
+
switch (bot.difficulty) {
|
|
663
|
+
case 'easy':
|
|
664
|
+
accuracy = BOT_ACCURACY_EASY;
|
|
665
|
+
reactionTime = BOT_REACTION_TIME_EASY;
|
|
666
|
+
break;
|
|
667
|
+
case 'medium':
|
|
668
|
+
accuracy = BOT_ACCURACY_MEDIUM;
|
|
669
|
+
reactionTime = BOT_REACTION_TIME_MEDIUM;
|
|
670
|
+
break;
|
|
671
|
+
case 'hard':
|
|
672
|
+
accuracy = BOT_ACCURACY_HARD;
|
|
673
|
+
reactionTime = BOT_REACTION_TIME_HARD;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
const def = WEAPON_DEFS[bot.currentWeapon];
|
|
677
|
+
bot.nextFireTime = now + reactionTime + (60000 / def.fireRate);
|
|
678
|
+
// Broadcast fire event
|
|
679
|
+
const direction = this.getAimDirection({ position: bot.position, yaw: bot.yaw, pitch: bot.pitch }, def.spread);
|
|
680
|
+
this.broadcast({
|
|
681
|
+
type: 'fire_event',
|
|
682
|
+
event: {
|
|
683
|
+
playerId: bot.id,
|
|
684
|
+
origin: bot.position,
|
|
685
|
+
direction,
|
|
686
|
+
weapon: bot.currentWeapon,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
// Check if hit (simplified - uses accuracy check)
|
|
690
|
+
if (Math.random() < accuracy) {
|
|
691
|
+
const dist = vec3Distance(bot.position, target.position);
|
|
692
|
+
if (dist <= def.range) {
|
|
693
|
+
const isHeadshot = Math.random() < 0.1;
|
|
694
|
+
const damage = isHeadshot ? def.damage * def.headshotMultiplier : def.damage;
|
|
695
|
+
// Find and damage target
|
|
696
|
+
const targetPlayer = this.state.players.get(target.id);
|
|
697
|
+
if (targetPlayer) {
|
|
698
|
+
this.damagePlayer(targetPlayer, damage, isHeadshot, bot.id, bot.name, bot.currentWeapon);
|
|
699
|
+
}
|
|
700
|
+
const targetBot = this.state.bots.get(target.id);
|
|
701
|
+
if (targetBot) {
|
|
702
|
+
this.damageBot(targetBot, damage, isHeadshot, bot.id, bot.name, bot.currentWeapon);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
updateReloads(now) {
|
|
708
|
+
for (const player of this.state.players.values()) {
|
|
709
|
+
for (const weapon of player.weapons.values()) {
|
|
710
|
+
if (weapon.isReloading) {
|
|
711
|
+
const def = WEAPON_DEFS[weapon.type];
|
|
712
|
+
if (now - weapon.reloadStartTime >= def.reloadTime * 1000) {
|
|
713
|
+
const ammoNeeded = def.magazineSize - weapon.currentAmmo;
|
|
714
|
+
const ammoToAdd = Math.min(ammoNeeded, weapon.reserveAmmo);
|
|
715
|
+
weapon.currentAmmo += ammoToAdd;
|
|
716
|
+
weapon.reserveAmmo -= ammoToAdd;
|
|
717
|
+
weapon.isReloading = false;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// ============ Combat ============
|
|
724
|
+
performHitDetection(attacker, direction, weaponDef) {
|
|
725
|
+
// Check against all enemies
|
|
726
|
+
for (const player of this.state.players.values()) {
|
|
727
|
+
if (!player.isAlive || player.team === attacker.team)
|
|
728
|
+
continue;
|
|
729
|
+
const hit = this.checkRayHit(attacker.position, direction, player.position, weaponDef.range);
|
|
730
|
+
if (hit) {
|
|
731
|
+
const damage = hit.isHeadshot
|
|
732
|
+
? weaponDef.damage * weaponDef.headshotMultiplier
|
|
733
|
+
: weaponDef.damage;
|
|
734
|
+
this.damagePlayer(player, damage, hit.isHeadshot, attacker.id, attacker.name, attacker.currentWeapon);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const bot of this.state.bots.values()) {
|
|
738
|
+
if (!bot.isAlive || bot.team === attacker.team)
|
|
739
|
+
continue;
|
|
740
|
+
const hit = this.checkRayHit(attacker.position, direction, bot.position, weaponDef.range);
|
|
741
|
+
if (hit) {
|
|
742
|
+
const damage = hit.isHeadshot
|
|
743
|
+
? weaponDef.damage * weaponDef.headshotMultiplier
|
|
744
|
+
: weaponDef.damage;
|
|
745
|
+
this.damageBot(bot, damage, hit.isHeadshot, attacker.id, attacker.name, attacker.currentWeapon);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
checkRayHit(origin, direction, targetPos, maxDist) {
|
|
750
|
+
// Simplified ray-sphere intersection
|
|
751
|
+
const toTarget = vec3Sub(targetPos, origin);
|
|
752
|
+
const dist = vec3Length(toTarget);
|
|
753
|
+
if (dist > maxDist)
|
|
754
|
+
return null;
|
|
755
|
+
// Project target onto ray
|
|
756
|
+
const dot = toTarget.x * direction.x + toTarget.y * direction.y + toTarget.z * direction.z;
|
|
757
|
+
if (dot < 0)
|
|
758
|
+
return null;
|
|
759
|
+
// Check distance from ray to target
|
|
760
|
+
const closestPoint = vec3Add(origin, vec3Scale(direction, dot));
|
|
761
|
+
const distToRay = vec3Distance(closestPoint, targetPos);
|
|
762
|
+
if (distToRay < PLAYER_RADIUS * 2) {
|
|
763
|
+
// Hit! Check if headshot (target y is close to head height)
|
|
764
|
+
const hitY = closestPoint.y - (targetPos.y - PLAYER_EYE_HEIGHT);
|
|
765
|
+
const isHeadshot = hitY > 1.5;
|
|
766
|
+
return { isHeadshot };
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
damagePlayer(player, damage, isHeadshot, attackerId, attackerName, weapon) {
|
|
771
|
+
// Apply armor absorption
|
|
772
|
+
let actualDamage = damage;
|
|
773
|
+
if (player.armor > 0) {
|
|
774
|
+
const absorbed = Math.min(player.armor, damage * 0.5);
|
|
775
|
+
player.armor -= absorbed;
|
|
776
|
+
actualDamage = damage - absorbed * 0.5;
|
|
777
|
+
}
|
|
778
|
+
player.health -= actualDamage;
|
|
779
|
+
this.broadcast({
|
|
780
|
+
type: 'hit_event',
|
|
781
|
+
event: {
|
|
782
|
+
attackerId,
|
|
783
|
+
victimId: player.id,
|
|
784
|
+
damage: actualDamage,
|
|
785
|
+
headshot: isHeadshot,
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
if (player.health <= 0) {
|
|
789
|
+
player.health = 0;
|
|
790
|
+
player.isAlive = false;
|
|
791
|
+
player.deaths++;
|
|
792
|
+
// Award kill to attacker
|
|
793
|
+
const attackerPlayer = this.state.players.get(attackerId);
|
|
794
|
+
if (attackerPlayer) {
|
|
795
|
+
attackerPlayer.kills++;
|
|
796
|
+
attackerPlayer.money = Math.min(attackerPlayer.money + DEFAULT_ECONOMY_CONFIG.killReward[weapon], DEFAULT_ECONOMY_CONFIG.maxMoney);
|
|
797
|
+
}
|
|
798
|
+
const attackerBot = this.state.bots.get(attackerId);
|
|
799
|
+
if (attackerBot) {
|
|
800
|
+
attackerBot.kills++;
|
|
801
|
+
}
|
|
802
|
+
this.broadcast({
|
|
803
|
+
type: 'kill_event',
|
|
804
|
+
event: {
|
|
805
|
+
killerId: attackerId,
|
|
806
|
+
killerName: attackerName,
|
|
807
|
+
victimId: player.id,
|
|
808
|
+
victimName: player.name,
|
|
809
|
+
weapon,
|
|
810
|
+
headshot: isHeadshot,
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
damageBot(bot, damage, isHeadshot, attackerId, attackerName, weapon) {
|
|
816
|
+
let actualDamage = damage;
|
|
817
|
+
if (bot.armor > 0) {
|
|
818
|
+
const absorbed = Math.min(bot.armor, damage * 0.5);
|
|
819
|
+
bot.armor -= absorbed;
|
|
820
|
+
actualDamage = damage - absorbed * 0.5;
|
|
821
|
+
}
|
|
822
|
+
bot.health -= actualDamage;
|
|
823
|
+
this.broadcast({
|
|
824
|
+
type: 'hit_event',
|
|
825
|
+
event: {
|
|
826
|
+
attackerId,
|
|
827
|
+
victimId: bot.id,
|
|
828
|
+
damage: actualDamage,
|
|
829
|
+
headshot: isHeadshot,
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
if (bot.health <= 0) {
|
|
833
|
+
bot.health = 0;
|
|
834
|
+
bot.isAlive = false;
|
|
835
|
+
bot.deaths++;
|
|
836
|
+
const attackerPlayer = this.state.players.get(attackerId);
|
|
837
|
+
if (attackerPlayer) {
|
|
838
|
+
attackerPlayer.kills++;
|
|
839
|
+
attackerPlayer.money = Math.min(attackerPlayer.money + DEFAULT_ECONOMY_CONFIG.killReward[weapon], DEFAULT_ECONOMY_CONFIG.maxMoney);
|
|
840
|
+
}
|
|
841
|
+
const attackerBot = this.state.bots.get(attackerId);
|
|
842
|
+
if (attackerBot) {
|
|
843
|
+
attackerBot.kills++;
|
|
844
|
+
}
|
|
845
|
+
this.broadcast({
|
|
846
|
+
type: 'kill_event',
|
|
847
|
+
event: {
|
|
848
|
+
killerId: attackerId,
|
|
849
|
+
killerName: attackerName,
|
|
850
|
+
victimId: bot.id,
|
|
851
|
+
victimName: bot.name,
|
|
852
|
+
weapon,
|
|
853
|
+
headshot: isHeadshot,
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// ============ Broadcasting ============
|
|
859
|
+
broadcastState() {
|
|
860
|
+
const snapshot = this.createSnapshot();
|
|
861
|
+
this.broadcast({
|
|
862
|
+
type: 'game_state',
|
|
863
|
+
state: snapshot,
|
|
864
|
+
});
|
|
865
|
+
this.state.lastBroadcastTick = this.state.tick;
|
|
866
|
+
}
|
|
867
|
+
createSnapshot() {
|
|
868
|
+
const now = Date.now();
|
|
869
|
+
const elapsed = (now - this.state.phaseStartTime) / 1000;
|
|
870
|
+
let roundTime = ROUND_TIME;
|
|
871
|
+
let freezeTime = 0;
|
|
872
|
+
if (this.state.phase === 'freeze') {
|
|
873
|
+
freezeTime = (this.roomConfig.mode === 'competitive' ? FREEZE_TIME_COMP : FREEZE_TIME_DM) - elapsed;
|
|
874
|
+
}
|
|
875
|
+
else if (this.state.phase === 'live') {
|
|
876
|
+
roundTime = ROUND_TIME - elapsed;
|
|
877
|
+
}
|
|
878
|
+
const players = [];
|
|
879
|
+
for (const player of this.state.players.values()) {
|
|
880
|
+
players.push({
|
|
881
|
+
id: player.id,
|
|
882
|
+
name: player.name,
|
|
883
|
+
position: player.position,
|
|
884
|
+
yaw: player.yaw,
|
|
885
|
+
pitch: player.pitch,
|
|
886
|
+
health: player.health,
|
|
887
|
+
armor: player.armor,
|
|
888
|
+
team: player.team,
|
|
889
|
+
isAlive: player.isAlive,
|
|
890
|
+
currentWeapon: player.currentWeapon,
|
|
891
|
+
money: player.money,
|
|
892
|
+
kills: player.kills,
|
|
893
|
+
deaths: player.deaths,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
const bots = [];
|
|
897
|
+
for (const bot of this.state.bots.values()) {
|
|
898
|
+
bots.push({
|
|
899
|
+
id: bot.id,
|
|
900
|
+
name: bot.name,
|
|
901
|
+
position: bot.position,
|
|
902
|
+
yaw: bot.yaw,
|
|
903
|
+
pitch: bot.pitch,
|
|
904
|
+
health: bot.health,
|
|
905
|
+
armor: bot.armor,
|
|
906
|
+
team: bot.team,
|
|
907
|
+
isAlive: bot.isAlive,
|
|
908
|
+
currentWeapon: bot.currentWeapon,
|
|
909
|
+
kills: bot.kills,
|
|
910
|
+
deaths: bot.deaths,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
const droppedWeapons = [];
|
|
914
|
+
for (const weapon of this.state.droppedWeapons.values()) {
|
|
915
|
+
droppedWeapons.push({
|
|
916
|
+
id: weapon.id,
|
|
917
|
+
weaponType: weapon.weaponType,
|
|
918
|
+
position: weapon.position,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
tick: this.state.tick,
|
|
923
|
+
timestamp: now,
|
|
924
|
+
phase: this.state.phase,
|
|
925
|
+
roundTime: Math.max(0, roundTime),
|
|
926
|
+
freezeTime: Math.max(0, freezeTime),
|
|
927
|
+
players,
|
|
928
|
+
bots,
|
|
929
|
+
droppedWeapons,
|
|
930
|
+
tScore: this.state.tScore,
|
|
931
|
+
ctScore: this.state.ctScore,
|
|
932
|
+
roundNumber: this.state.roundNumber,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
broadcastPhaseChange() {
|
|
936
|
+
this.broadcast({
|
|
937
|
+
type: 'phase_change',
|
|
938
|
+
phase: this.state.phase,
|
|
939
|
+
roundNumber: this.state.roundNumber,
|
|
940
|
+
tScore: this.state.tScore,
|
|
941
|
+
ctScore: this.state.ctScore,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
// ============ Utility ============
|
|
945
|
+
getSpawnPoint(team) {
|
|
946
|
+
// Filter spawns by team
|
|
947
|
+
const validSpawns = this.mapData.spawnPoints.filter((s, idx) => !this.usedSpawns.has(idx) && (s.team === team || s.team === 'DM'));
|
|
948
|
+
if (validSpawns.length === 0) {
|
|
949
|
+
// All spawns used, reset
|
|
950
|
+
this.usedSpawns.clear();
|
|
951
|
+
return this.mapData.spawnPoints[0];
|
|
952
|
+
}
|
|
953
|
+
// Pick random spawn
|
|
954
|
+
const idx = Math.floor(Math.random() * validSpawns.length);
|
|
955
|
+
const spawn = validSpawns[idx];
|
|
956
|
+
const originalIdx = this.mapData.spawnPoints.indexOf(spawn);
|
|
957
|
+
this.usedSpawns.add(originalIdx);
|
|
958
|
+
return spawn;
|
|
959
|
+
}
|
|
960
|
+
getAimDirection(entity, spread) {
|
|
961
|
+
const spreadRad = (spread * Math.PI) / 180;
|
|
962
|
+
const randomYaw = (Math.random() - 0.5) * spreadRad;
|
|
963
|
+
const randomPitch = (Math.random() - 0.5) * spreadRad;
|
|
964
|
+
return vec3Normalize({
|
|
965
|
+
x: -Math.sin(entity.yaw + randomYaw) * Math.cos(entity.pitch + randomPitch),
|
|
966
|
+
y: Math.sin(entity.pitch + randomPitch),
|
|
967
|
+
z: -Math.cos(entity.yaw + randomYaw) * Math.cos(entity.pitch + randomPitch),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
getWeaponSlot(type) {
|
|
971
|
+
return WEAPON_DEFS[type].slot;
|
|
972
|
+
}
|
|
973
|
+
checkCollision(pos) {
|
|
974
|
+
// Check map bounds first
|
|
975
|
+
if (pos.x < this.mapData.bounds.min.x + PLAYER_RADIUS ||
|
|
976
|
+
pos.x > this.mapData.bounds.max.x - PLAYER_RADIUS ||
|
|
977
|
+
pos.z < this.mapData.bounds.min.z + PLAYER_RADIUS ||
|
|
978
|
+
pos.z > this.mapData.bounds.max.z - PLAYER_RADIUS) {
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
// Check against map colliders (AABB vs sphere)
|
|
982
|
+
for (const collider of this.mapData.colliders) {
|
|
983
|
+
// Expand AABB by player radius for sphere check
|
|
984
|
+
const minX = collider.min.x - PLAYER_RADIUS;
|
|
985
|
+
const maxX = collider.max.x + PLAYER_RADIUS;
|
|
986
|
+
const minZ = collider.min.z - PLAYER_RADIUS;
|
|
987
|
+
const maxZ = collider.max.z + PLAYER_RADIUS;
|
|
988
|
+
const maxY = collider.max.y;
|
|
989
|
+
// Check if position is inside expanded AABB (only if below top of collider)
|
|
990
|
+
if (pos.x >= minX && pos.x <= maxX &&
|
|
991
|
+
pos.z >= minZ && pos.z <= maxZ &&
|
|
992
|
+
(pos.y - PLAYER_EYE_HEIGHT) < maxY) {
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|