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.
@@ -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
+ }