From 6ee630a9d9da870b81524973d988667e8e251b44 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:18:38 +0000 Subject: [PATCH] feat: add machine gun pickup power-up that spawns once per match - Spawns at random valid position inside arena polygon once per match - Rendered on all clients as distinct orange icon with minigun barrels - Collected when player moves within PLAYER_RADIUS proximity - Reduces shoot cooldown by 50% (300ms to 150ms) via hasMachineGun flag - Game state broadcast includes pickup position and collected status - NPCs can also collect the pickup - Fire rate bonus resets at start of next round - 10 new tests covering all pickup mechanics Co-Authored-By: bot_apk --- client/client.js | 40 +++++++++ server/game.js | 45 +++++++++- server/npc.js | 1 + test/game.test.js | 219 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) diff --git a/client/client.js b/client/client.js index 96ee409..d5136c4 100644 --- a/client/client.js +++ b/client/client.js @@ -291,6 +291,46 @@ function render() { ctx.stroke(); } + // Draw machine gun pickup + if (gameState.machineGunPickup && !gameState.machineGunPickup.collected) { + const pkx = cx + gameState.machineGunPickup.x * scale; + const pky = cy + gameState.machineGunPickup.y * scale; + const pkRadius = 12 * scale; + + // Outer glow + ctx.beginPath(); + ctx.arc(pkx, pky, pkRadius * 1.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 165, 0, 0.15)'; + ctx.fill(); + + // Base circle + ctx.beginPath(); + ctx.arc(pkx, pky, pkRadius, 0, Math.PI * 2); + ctx.fillStyle = '#1a1a2e'; + ctx.fill(); + ctx.strokeStyle = '#f80'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Gun icon (three horizontal lines like a minigun barrel) + ctx.strokeStyle = '#f80'; + ctx.lineWidth = 2; + const barrelLen = pkRadius * 0.7; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.moveTo(pkx - barrelLen * 0.3, pky + i * pkRadius * 0.25); + ctx.lineTo(pkx + barrelLen, pky + i * pkRadius * 0.25); + ctx.stroke(); + } + + // Label + const pkFontSize = Math.max(6, pkRadius * 0.4); + ctx.font = `bold ${pkFontSize}px monospace`; + ctx.fillStyle = '#f80'; + ctx.textAlign = 'center'; + ctx.fillText('MG', pkx, pky + pkRadius + pkFontSize + 2); + } + // Draw bullets for (const bullet of gameState.bullets) { const bx = cx + bullet.x * scale; diff --git a/server/game.js b/server/game.js index 6a67570..2dc679f 100644 --- a/server/game.js +++ b/server/game.js @@ -13,6 +13,8 @@ const BULLET_DAMAGE = 34; // ~3 hits to kill const PLAYER_MAX_HP = 100; const RING_DAMAGE_PER_SEC = 20; const SHOOT_COOLDOWN_MS = 300; +const MACHINE_GUN_COOLDOWN_MS = 150; +const PICKUP_COLLECT_RADIUS = PLAYER_RADIUS; const RING_SHRINK_DURATION_MS = 75000; // 75 seconds const LOBBY_COUNTDOWN_MS = 5000; // 5 second countdown after 2+ players const ROUND_END_DELAY_MS = 5000; // 5 seconds before resetting @@ -232,6 +234,7 @@ class Game { this.onBroadcast = null; // callback for broadcasting state this.leaderboard = new Leaderboard(); this.registeredNicknames = new Map(); // nickname (lowercase) -> playerId + this.machineGunPickup = null; // { x, y, collected, collectedBy } } start() { @@ -258,6 +261,7 @@ class Game { lastShot: 0, input: { up: false, down: false, left: false, right: false }, name: `Player ${id}`, + hasMachineGun: false, }; if (this.state === STATE_ACTIVE || this.state === STATE_ROUND_END) { @@ -480,7 +484,8 @@ class Game { tryShoot(player) { const now = Date.now(); - if (now - player.lastShot < SHOOT_COOLDOWN_MS) return; + const cooldown = player.hasMachineGun ? MACHINE_GUN_COOLDOWN_MS : SHOOT_COOLDOWN_MS; + if (now - player.lastShot < cooldown) return; player.lastShot = now; const bullet = { @@ -540,6 +545,10 @@ class Game { this.bullets = []; this.winnerId = null; + // Spawn machine gun pickup at a random position inside the arena + const pickupPos = randomPointInPolygon(this.arenaVertices, this.arenaCentroid, 0.7); + this.machineGunPickup = { x: pickupPos.x, y: pickupPos.y, collected: false, collectedBy: null }; + // Respawn all non-spectator players inside the polygon const totalPlayers = this.getNonSpectatorPlayers().length; this.roundParticipants = totalPlayers; @@ -554,6 +563,7 @@ class Game { player.hp = PLAYER_MAX_HP; player.alive = true; player.lastShot = 0; + player.hasMachineGun = false; index++; } } @@ -581,6 +591,9 @@ class Game { // Move bullets this.updateBullets(dt, now); + // Check machine gun pickup collection + this.checkPickupCollection(); + // Ring damage this.applyRingDamage(dt); @@ -653,6 +666,25 @@ class Game { }); } + checkPickupCollection() { + if (!this.machineGunPickup || this.machineGunPickup.collected) return; + + for (const player of this.players.values()) { + if (!player.alive || this.spectators.has(player.id)) continue; + + const dx = player.x - this.machineGunPickup.x; + const dy = player.y - this.machineGunPickup.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < PICKUP_COLLECT_RADIUS) { + this.machineGunPickup.collected = true; + this.machineGunPickup.collectedBy = player.id; + player.hasMachineGun = true; + break; + } + } + } + applyRingDamage(dt) { for (const player of this.players.values()) { if (!player.alive || this.spectators.has(player.id)) continue; @@ -712,6 +744,7 @@ class Game { this.winnerId = null; this.lobbyCountdownStart = 0; this.roundParticipants = 0; + this.machineGunPickup = null; // Move spectators back to active players this.spectators.clear(); @@ -726,6 +759,7 @@ class Game { player.hp = PLAYER_MAX_HP; player.alive = true; player.lastShot = 0; + player.hasMachineGun = false; player.input = { up: false, down: false, left: false, right: false }; index++; } @@ -785,6 +819,13 @@ class Game { y: b.y, ownerId: b.ownerId, })), + machineGunPickup: this.machineGunPickup + ? { + x: this.machineGunPickup.x, + y: this.machineGunPickup.y, + collected: this.machineGunPickup.collected, + } + : null, winnerId: this.winnerId, yourId: forPlayerId, isSpectator: this.spectators.has(forPlayerId), @@ -810,6 +851,8 @@ module.exports = { PLAYER_MAX_HP, RING_DAMAGE_PER_SEC, SHOOT_COOLDOWN_MS, + MACHINE_GUN_COOLDOWN_MS, + PICKUP_COLLECT_RADIUS, RING_SHRINK_DURATION_MS, LOBBY_COUNTDOWN_MS, ROUND_END_DELAY_MS, diff --git a/server/npc.js b/server/npc.js index 49bac3a..4e46ed3 100644 --- a/server/npc.js +++ b/server/npc.js @@ -32,6 +32,7 @@ function createNPC(id, name) { input: { up: false, down: false, left: false, right: false }, name: name, isNPC: true, + hasMachineGun: false, _wanderAngle: Math.random() * Math.PI * 2, _wanderChangeTime: 0, }; diff --git a/test/game.test.js b/test/game.test.js index a1c0f28..8bab79f 100644 --- a/test/game.test.js +++ b/test/game.test.js @@ -10,6 +10,9 @@ const { STATE_ROUND_END, MIN_PLAYERS_TO_START, RING_SHRINK_DURATION_MS, + SHOOT_COOLDOWN_MS, + MACHINE_GUN_COOLDOWN_MS, + PICKUP_COLLECT_RADIUS, MAX_NPC_COUNT, MIN_REAL_PLAYERS_FOR_NO_BOTS, generateConvexPolygon, @@ -1356,6 +1359,222 @@ test('Leaderboard hasNickname is case-insensitive', () => { assert(!lb.hasNickname('Champ'), 'partial no match'); }); +// --- Machine Gun Pickup Tests --- + +test('Machine gun pickup spawns when round starts', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + assert(game.machineGunPickup === null, 'no pickup before round starts'); + game.startRound(); + assert(game.machineGunPickup !== null, 'pickup spawned after startRound'); + assert(typeof game.machineGunPickup.x === 'number', 'pickup has x coordinate'); + assert(typeof game.machineGunPickup.y === 'number', 'pickup has y coordinate'); + assert(game.machineGunPickup.collected === false, 'pickup not collected initially'); + assert(game.machineGunPickup.collectedBy === null, 'pickup collectedBy is null initially'); + assert( + pointInConvexPolygon(game.machineGunPickup.x, game.machineGunPickup.y, game.arenaVertices), + 'pickup spawns inside arena polygon' + ); +}); + +test('Pickup collection sets hasMachineGun and marks pickup collected', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const player = game.players.get(id1); + + // Move player directly on top of pickup + player.x = game.machineGunPickup.x; + player.y = game.machineGunPickup.y; + + assert(player.hasMachineGun === false, 'player does not have machine gun before collection'); + game.checkPickupCollection(); + assert(player.hasMachineGun === true, 'player has machine gun after collection'); + assert(game.machineGunPickup.collected === true, 'pickup marked as collected'); + assert(game.machineGunPickup.collectedBy === id1, 'pickup collectedBy is correct player'); +}); + +test('Collecting pickup changes effective fire rate from 300ms to 150ms', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const player = game.players.get(id1); + player.angle = 0; + + // Shoot once to set lastShot + game.tryShoot(player); + assert(game.bullets.length === 1, 'first shot fires'); + const firstShotTime = player.lastShot; + + // Try to shoot again immediately — should be blocked by normal cooldown + player.lastShot = firstShotTime; // ensure lastShot is set + game.tryShoot(player); + assert(game.bullets.length === 1, 'second shot blocked by normal cooldown'); + + // Advance time past machine gun cooldown (150ms) but before normal cooldown (300ms) + player.lastShot = Date.now() - (MACHINE_GUN_COOLDOWN_MS + 1); + game.tryShoot(player); + assert(game.bullets.length === 1, 'shot still blocked without machine gun (within normal cooldown)'); + + // Now give machine gun + player.hasMachineGun = true; + player.lastShot = Date.now() - (MACHINE_GUN_COOLDOWN_MS + 1); + game.tryShoot(player); + assert(game.bullets.length === 2, 'shot succeeds with machine gun at reduced cooldown'); + + // Verify the cooldown values are correct + assert(SHOOT_COOLDOWN_MS === 300, 'normal shoot cooldown is 300ms'); + assert(MACHINE_GUN_COOLDOWN_MS === 150, 'machine gun cooldown is 150ms (50% reduction)'); +}); + +test('Only one pickup spawns per match', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const pickupPos = { x: game.machineGunPickup.x, y: game.machineGunPickup.y }; + + // Simulate multiple ticks — pickup position should not change + game.tickActive(0.05, Date.now()); + game.tickActive(0.05, Date.now()); + assert( + game.machineGunPickup.x === pickupPos.x && game.machineGunPickup.y === pickupPos.y, + 'pickup position unchanged after ticks' + ); +}); + +test('Pickup disappears after collection (not re-rendered)', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const player = game.players.get(id1); + player.x = game.machineGunPickup.x; + player.y = game.machineGunPickup.y; + + game.checkPickupCollection(); + const state = game.getState(id1); + assert(state.machineGunPickup !== null, 'pickup data still in state'); + assert(state.machineGunPickup.collected === true, 'pickup marked collected in broadcast state'); +}); + +test('NPC can collect pickup and gain machine gun', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + + // Add NPC + const npcId = game.addNPC(); + const npc = game.players.get(npcId); + + game.startRound(); + + // Move NPC on top of pickup + npc.x = game.machineGunPickup.x; + npc.y = game.machineGunPickup.y; + + assert(npc.hasMachineGun === false, 'NPC does not have machine gun before collection'); + game.checkPickupCollection(); + assert(npc.hasMachineGun === true, 'NPC has machine gun after collection'); + assert(game.machineGunPickup.collected === true, 'pickup collected by NPC'); + assert(game.machineGunPickup.collectedBy === npcId, 'collectedBy is NPC id'); +}); + +test('Fire rate bonus resets at start of next round', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const player = game.players.get(id1); + + // Give player machine gun + player.hasMachineGun = true; + assert(player.hasMachineGun === true, 'player has machine gun during round'); + + // Reset for next round + game.resetForNextRound(); + const playerAfterReset = game.players.get(id1); + assert(playerAfterReset.hasMachineGun === false, 'machine gun bonus reset after round'); + assert(game.machineGunPickup === null, 'pickup cleared after round reset'); +}); + +test('Game state broadcast includes pickup data', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + // Before round: no pickup in state + const lobbyState = game.getState(id1); + assert(lobbyState.machineGunPickup === null, 'no pickup in lobby state'); + + game.startRound(); + const activeState = game.getState(id1); + assert(activeState.machineGunPickup !== null, 'pickup in active state'); + assert(typeof activeState.machineGunPickup.x === 'number', 'pickup state has x'); + assert(typeof activeState.machineGunPickup.y === 'number', 'pickup state has y'); + assert(activeState.machineGunPickup.collected === false, 'pickup state shows not collected'); +}); + +test('Collected pickup cannot be collected again', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + const id2 = game.addPlayer(mockWs2); + + game.startRound(); + const p1 = game.players.get(id1); + const p2 = game.players.get(id2); + + // Player 1 collects + p1.x = game.machineGunPickup.x; + p1.y = game.machineGunPickup.y; + game.checkPickupCollection(); + assert(p1.hasMachineGun === true, 'player 1 collected pickup'); + + // Player 2 walks over same spot + p2.x = game.machineGunPickup.x; + p2.y = game.machineGunPickup.y; + game.checkPickupCollection(); + assert(p2.hasMachineGun === false, 'player 2 cannot collect already-collected pickup'); + assert(game.machineGunPickup.collectedBy === id1, 'collectedBy still player 1'); +}); + +test('Player starts new round without hasMachineGun after startRound', () => { + const game = new Game(); + const mockWs1 = { readyState: 1, send: () => {} }; + const mockWs2 = { readyState: 1, send: () => {} }; + const id1 = game.addPlayer(mockWs1); + game.addPlayer(mockWs2); + + game.startRound(); + const player = game.players.get(id1); + assert(player.hasMachineGun === false, 'hasMachineGun is false at round start'); +}); + // --- Summary --- console.log(`\n${'='.repeat(40)}`); console.log(`Results: ${passed} passed, ${failed} failed`);