diff options
Diffstat (limited to 'html/fps/game.js')
-rw-r--r-- | html/fps/game.js | 992 |
1 files changed, 992 insertions, 0 deletions
diff --git a/html/fps/game.js b/html/fps/game.js new file mode 100644 index 0000000..e157ab1 --- /dev/null +++ b/html/fps/game.js @@ -0,0 +1,992 @@ +// Game state management +const GameState = { + player: { + x: 0, + y: 0, + angle: 0, + health: 100, + ammo: 10, + score: 0 + }, + level: { + width: 32, + height: 32, + map: [], + flag: { x: 0, y: 0 } + }, + enemies: [], + items: [], + isGameOver: false, + gun: { + recoil: 0, + lastShot: 0, + muzzleFlash: 0, + slidePosition: 0, // 0 = forward, 1 = back + tilt: 0 // 0 = normal, 1 = tilted + }, + shots: [], + isStarted: false, + gradients: {}, // Cache for wall gradients + lastGradientUpdate: 0, + particles: [], + damageFlash: 0 +}; + +// Level generation using a simple maze algorithm +const generateLevel = () => { + const map = Array(GameState.level.height).fill().map(() => + Array(GameState.level.width).fill(1) + ); + + // Create starting room + const startRoomSize = 5; + for (let y = 1; y < startRoomSize; y++) { + for (let x = 1; x < startRoomSize; x++) { + map[y][x] = 0; + } + } + + // Add fewer random larger rooms + const numLargeRooms = 2; // Reduced from 3 + for (let i = 0; i < numLargeRooms; i++) { + const roomWidth = Math.floor(Math.random() * 3) + 4; // 4-6 (reduced from 4-7) + const roomHeight = Math.floor(Math.random() * 3) + 4; // 4-6 (reduced from 4-7) + const roomX = Math.floor(Math.random() * (GameState.level.width - roomWidth - 2)) + 1; + const roomY = Math.floor(Math.random() * (GameState.level.height - roomHeight - 2)) + 1; + + // Create room + for (let y = roomY; y < roomY + roomHeight; y++) { + for (let x = roomX; x < roomX + roomWidth; x++) { + map[y][x] = 0; + } + } + + // Connect to maze + const connectX = roomX + Math.floor(roomWidth/2); + const connectY = roomY + Math.floor(roomHeight/2); + map[connectY][connectX] = 0; + } + + // Simple maze generation using depth-first search with single-cell hallways + const carveMaze = (x, y) => { + // Ensure we're within bounds + if (x <= 0 || x >= GameState.level.width - 1 || y <= 0 || y >= GameState.level.height - 1) { + return; + } + + map[y][x] = 0; + + const directions = [ + [0, -2], [2, 0], [0, 2], [-2, 0] + ].sort(() => Math.random() - 0.5); + + for (const [dx, dy] of directions) { + const nx = x + dx; + const ny = y + dy; + if (nx > 0 && nx < GameState.level.width - 1 && + ny > 0 && ny < GameState.level.height - 1 && + map[ny][nx] === 1) { + // Carve single-cell paths + const midX = x + Math.floor(dx/2); + const midY = y + Math.floor(dy/2); + if (midX >= 0 && midX < GameState.level.width && + midY >= 0 && midY < GameState.level.height) { + map[midY][midX] = 0; + } + carveMaze(nx, ny); + } + } + }; + + // Start maze generation from the edge of the starting room + carveMaze(startRoomSize, startRoomSize); + + // Place flag in a random open space (not in starting room) + const openSpaces = []; + for (let y = 0; y < GameState.level.height; y++) { + for (let x = 0; x < GameState.level.width; x++) { + if (map[y][x] === 0 && (x >= startRoomSize || y >= startRoomSize)) { + openSpaces.push({x, y}); + } + } + } + const flagPos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.level.flag = flagPos; + + // Place enemies in open spaces (not in starting room) + GameState.enemies = []; + for (let i = 0; i < 5; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.enemies.push({ + x: pos.x, + y: pos.y, + health: Math.floor(Math.random() * 4) + 2 + }); + } + + // Place items in open spaces (not in starting room) + GameState.items = []; + // Add health packs + for (let i = 0; i < 8; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.items.push({ + x: pos.x, + y: pos.y, + type: 'health', + value: Math.floor(Math.random() * 5) + 1 + }); + } + // Add ammo packs + for (let i = 0; i < 6; i++) { + const pos = openSpaces[Math.floor(Math.random() * openSpaces.length)]; + GameState.items.push({ + x: pos.x, + y: pos.y, + type: 'ammo', + value: 5 + }); + } + + GameState.level.map = map; + GameState.player.x = startRoomSize/2; + GameState.player.y = startRoomSize/2; + GameState.player.angle = 0; +}; + +// Player movement and controls +const handlePlayerMovement = (keys) => { + const moveSpeed = 0.05; + const rotateSpeed = 0.05; + + if (keys.w) { + // Move forward in the direction the player is facing + GameState.player.x += Math.sin(GameState.player.angle) * moveSpeed; + GameState.player.y -= Math.cos(GameState.player.angle) * moveSpeed; + } + if (keys.s) { + // Move backward + GameState.player.x -= Math.sin(GameState.player.angle) * moveSpeed; + GameState.player.y += Math.cos(GameState.player.angle) * moveSpeed; + } + if (keys.a) { + // Strafe left + GameState.player.x -= Math.cos(GameState.player.angle) * moveSpeed; + GameState.player.y -= Math.sin(GameState.player.angle) * moveSpeed; + } + if (keys.d) { + // Strafe right + GameState.player.x += Math.cos(GameState.player.angle) * moveSpeed; + GameState.player.y += Math.sin(GameState.player.angle) * moveSpeed; + } + + // Add arrow key rotation + if (keys.ArrowLeft) { + GameState.player.angle -= rotateSpeed; + } + if (keys.ArrowRight) { + GameState.player.angle += rotateSpeed; + } +}; + +// Enemy AI +const updateEnemies = () => { + GameState.enemies = GameState.enemies.map(enemy => { + if (enemy.health <= 0) return null; + + const dx = GameState.player.x - enemy.x; + const dy = GameState.player.y - enemy.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 0.5) { + GameState.player.health -= 10; + GameState.damageFlash = 1.0; // Trigger full flash + if (GameState.player.health <= 0) { + GameState.isGameOver = true; + } + } else if (dist < 5) { + // Check if path to player is clear + const steps = 10; + let canMove = true; + for (let i = 1; i <= steps; i++) { + const testX = enemy.x + (dx / dist) * (i / steps); + const testY = enemy.y + (dy / dist) * (i / steps); + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + canMove = false; + break; + } + } + + if (canMove) { + enemy.x += dx / dist * 0.05; + enemy.y += dy / dist * 0.05; + } + } + + return enemy; + }).filter(Boolean); +}; + +// Collision detection +const checkCollisions = () => { + // Wall collisions with improved precision and bounds checking + const playerX = GameState.player.x; + const playerY = GameState.player.y; + + // Check if player is within map bounds + if (playerX < 0 || playerX >= GameState.level.width || + playerY < 0 || playerY >= GameState.level.height) { + // Push player back to last valid position + GameState.player.x = Math.max(0, Math.min(GameState.player.x, GameState.level.width - 1)); + GameState.player.y = Math.max(0, Math.min(GameState.player.y, GameState.level.height - 1)); + return; + } + + const nextX = playerX + Math.sin(GameState.player.angle) * 0.1; + const nextY = playerY + Math.cos(GameState.player.angle) * 0.1; + + // Check all four corners of the player's collision box + const checkPoint = (x, y) => { + const gridX = Math.floor(x); + const gridY = Math.floor(y); + // Ensure we're within bounds + if (gridX < 0 || gridX >= GameState.level.width || + gridY < 0 || gridY >= GameState.level.height) { + return true; // Treat out of bounds as a wall + } + return GameState.level.map[gridY][gridX] === 1; + }; + + const collisionBox = 0.2; // Player collision box size + + if (checkPoint(playerX + collisionBox, playerY + collisionBox) || + checkPoint(playerX + collisionBox, playerY - collisionBox) || + checkPoint(playerX - collisionBox, playerY + collisionBox) || + checkPoint(playerX - collisionBox, playerY - collisionBox)) { + // Push player back to last valid position + GameState.player.x = Math.floor(GameState.player.x) + 0.5; + GameState.player.y = Math.floor(GameState.player.y) + 0.5; + } + + // Item collection + GameState.items = GameState.items.filter(item => { + const dx = GameState.player.x - item.x; + const dy = GameState.player.y - item.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.5) { + if (item.type === 'ammo') { + GameState.player.ammo += item.value; + } else { + GameState.player.health = Math.min(100, GameState.player.health + item.value); + } + return false; + } + return true; + }); + + // Flag collection + const dx = GameState.player.x - GameState.level.flag.x; + const dy = GameState.player.y - GameState.level.flag.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.5) { + GameState.player.score++; + generateLevel(); + } +}; + +// Rendering system +const render = (ctx) => { + const canvas = ctx.canvas; + const width = canvas.width; + const height = canvas.height; + + // Clear screen + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, width, height); + + if (!GameState.isStarted) { + // Draw start screen + ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + ctx.fillRect(0, 0, width, height); + + // Draw title + ctx.fillStyle = '#fff'; + ctx.font = '48px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('Hedge Maze', width/2, height/2 - 50); + + // Draw start button + const buttonWidth = 200; + const buttonHeight = 60; + const buttonX = width/2 - buttonWidth/2; + const buttonY = height/2; + + ctx.fillStyle = '#333'; + ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight); + + ctx.fillStyle = '#fff'; + ctx.font = '24px monospace'; + ctx.fillText('START GAME', width/2, buttonY + buttonHeight/2 + 8); + + return; + } + + // Draw ceiling + ctx.fillStyle = '#1a1a4a'; + ctx.fillRect(0, 0, width, height/2); + + // Draw floor + ctx.fillStyle = '#4a2a00'; + ctx.fillRect(0, height/2, width, height/2); + + // Draw walls and sprites using ray casting + const fov = Math.PI / 3; + const numRays = Math.floor(width / 2); // Reduce ray count by half + const rayResults = []; + + // Pre-calculate common values + const halfHeight = height / 2; + const brightnessSteps = 20; // Number of brightness levels to cache + const gradientCache = {}; + + // Create cached gradients for different brightness levels + for (let b = 0; b <= brightnessSteps; b++) { + const brightness = b / brightnessSteps; + const baseColor = Math.floor(brightness * 100); + const distanceColor = Math.floor(brightness * 50); + + gradientCache[b] = { + top: `rgb(${distanceColor}, ${baseColor + 20}, ${distanceColor})`, + middle: `rgb(${distanceColor}, ${baseColor}, ${distanceColor})`, + bottom: `rgb(${distanceColor}, ${baseColor - 20}, ${distanceColor})`, + line: `rgba(0, ${baseColor - 30}, 0, 0.3)` + }; + } + + for (let i = 0; i < width; i += 2) { + const rayIndex = Math.floor(i / 2); + const rayAngle = GameState.player.angle - fov/2 + fov * rayIndex / numRays; + let distance = 0; + let hit = false; + + while (!hit && distance < 20) { + distance += 0.2; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + // Check for wall hits + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hit = true; + } + } + + rayResults[rayIndex] = { + distance, + angle: rayAngle + }; + + const wallHeight = height / (distance * Math.cos(rayAngle - GameState.player.angle)); + const brightness = Math.max(0, 1 - distance / 20); + const brightnessIndex = Math.floor(brightness * brightnessSteps); + const colors = gradientCache[brightnessIndex]; + + const wallTop = halfHeight - wallHeight/2; + const wallBottom = halfHeight + wallHeight/2; + + // Draw wall with cached colors + ctx.fillStyle = colors.middle; + ctx.fillRect(i, wallTop, 2, wallHeight); + + // Draw top and bottom highlights + ctx.fillStyle = colors.top; + ctx.fillRect(i, wallTop, 2, 1); + ctx.fillStyle = colors.bottom; + ctx.fillRect(i, wallBottom - 1, 2, 1); + + // Draw vertical lines less frequently + if (i % 8 === 0) { + ctx.fillStyle = colors.line; + ctx.fillRect(i, wallTop, 2, wallHeight); + } + } + + // Draw shots + const now = Date.now(); + GameState.shots = GameState.shots.filter(shot => { + const age = now - shot.time; + if (age > 200) return false; // Remove shots older than 200ms + + const progress = age / 200; + const distance = progress * 20; + const x = GameState.player.x + Math.sin(shot.angle) * distance; + const y = GameState.player.y + Math.cos(shot.angle) * distance; + + // Convert world position to screen position + const dx = x - GameState.player.x; + const dy = y - GameState.player.y; + const angle = Math.atan2(dx, dy) - GameState.player.angle; + const screenX = (angle / fov + 0.5) * width; + + if (screenX >= 0 && screenX < width) { + ctx.fillStyle = '#ffff00'; + ctx.fillRect(screenX, halfHeight, 2, 2); + } + + return true; + }); + + // Draw gun + const gunY = height - 150; + const gunX = width/2; + const recoilOffset = Math.sin(GameState.gun.recoil * Math.PI) * 50; + const tiltAngle = GameState.gun.tilt * Math.PI / 6; + + // Only draw gun if it's not in full recoil + if (GameState.gun.recoil < 0.8) { + ctx.save(); + ctx.translate(gunX, gunY + recoilOffset); + ctx.rotate(tiltAngle); + + // Gun body (larger rectangle) extending below screen + ctx.fillStyle = '#333'; + ctx.fillRect(-30, 0, 60, height); + + // Gun slide (smaller rectangle) with sliding animation + const slideOffset = GameState.gun.slidePosition * 20; + ctx.fillStyle = '#222'; + // Adjusted slide dimensions: shorter above, longer below + ctx.fillRect(-8, -15 - slideOffset, 16, 90); // Changed from -30 to -15 for top, and 60 to 90 for height + + // Muzzle flash + if (GameState.gun.muzzleFlash > 0) { + const flashSize = GameState.gun.muzzleFlash * 30; + ctx.fillStyle = `rgba(255, 255, 0, ${GameState.gun.muzzleFlash})`; + ctx.beginPath(); + ctx.arc(0, -15 - slideOffset, flashSize, 0, Math.PI * 2); // Adjusted to match new slide position + ctx.fill(); + } + + ctx.restore(); + } + + // Draw crosshair + ctx.fillStyle = '#fff'; + ctx.fillRect(width/2 - 5, halfHeight, 10, 1); + ctx.fillRect(width/2, halfHeight - 5, 1, 10); + + // Draw HUD - only canvas-based + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(10, 10, 200, 120); + + ctx.fillStyle = '#fff'; + ctx.font = '24px monospace'; + ctx.fillText(`Health: ${GameState.player.health}`, 100, 45); + ctx.fillText(`Ammo: ${GameState.player.ammo}`, 80, 80); + ctx.fillText(`Score: ${GameState.player.score}`, 80, 115); + + // Draw compass + const compassSize = 100; + const compassX = width/2 - compassSize/2; + const compassY = 20; + + // Draw compass background + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(compassX, compassY, compassSize, compassSize); // Make it square + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(compassX, compassY, compassSize, compassSize); + + // Draw compass directions + ctx.fillStyle = '#fff'; + ctx.font = '16px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Draw N, E, S, W in traditional compass layout + ctx.fillText('N', compassX + compassSize/2, compassY + 15); + ctx.fillText('E', compassX + compassSize - 15, compassY + compassSize/2); + ctx.fillText('S', compassX + compassSize/2, compassY + compassSize - 15); + ctx.fillText('W', compassX + 15, compassY + compassSize/2); + + // Draw direction indicator - ensure it's always visible within the compass + const angle = GameState.player.angle; + const radius = compassSize/2 - 15; // Keep indicator within compass bounds + const indicatorX = compassX + compassSize/2 + Math.sin(angle) * radius; + const indicatorY = compassY + compassSize/2 - Math.cos(angle) * radius; + + ctx.fillStyle = '#0f0'; + ctx.beginPath(); + ctx.arc(indicatorX, indicatorY, 4, 0, Math.PI * 2); + ctx.fill(); + + // Draw sprites + const drawSprite = (x, y, distance, type) => { + // Calculate screen position + const dx = x - GameState.player.x; + const dy = y - GameState.player.y; + const angle = Math.atan2(dx, dy) - GameState.player.angle; + + // Convert to screen coordinates + const screenX = (angle / (Math.PI / 3) + 0.5) * width; + const screenY = height / 2; + + // Calculate size based on distance + const baseSize = 0.5; + const size = (baseSize / distance) * height; + + // Only draw if on screen and not behind a wall + if (screenX >= 0 && screenX < width) { + const wallDistance = rayResults[Math.floor(screenX / 2)].distance; + if (distance < wallDistance) { + // Set color based on type + if (type === 'enemy') { + ctx.fillStyle = '#f00'; + } else if (type === 'health') { + ctx.fillStyle = '#0f0'; + } else if (type === 'ammo') { + ctx.fillStyle = '#0ff'; + } else if (type === 'flag') { + ctx.fillStyle = '#ff0'; + } + + // Draw single square + ctx.fillRect(screenX - size/2, screenY - size/2, size, size); + } + } + }; + + // Draw all sprites + GameState.enemies.forEach(enemy => { + const dx = enemy.x - GameState.player.x; + const dy = enemy.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + drawSprite(enemy.x, enemy.y, distance, 'enemy'); + }); + + GameState.items.forEach(item => { + const dx = item.x - GameState.player.x; + const dy = item.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + drawSprite(item.x, item.y, distance, item.type); + }); + + // Draw flag + const dx = GameState.level.flag.x - GameState.player.x; + const dy = GameState.level.flag.y - GameState.player.y; + const flagDistance = Math.sqrt(dx * dx + dy * dy); + drawSprite(GameState.level.flag.x, GameState.level.flag.y, flagDistance, 'flag'); + + // Draw particles + GameState.particles.forEach(particle => { + const dx = particle.x - GameState.player.x; + const dy = particle.y - GameState.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dx, dy) - GameState.player.angle; + + // Convert world position to screen position + const screenX = (angle / (Math.PI / 3) + 0.5) * width; + const screenY = height / 2; + + // Ensure size is always positive and within reasonable bounds + const size = Math.max(1, Math.min(particle.size, (1 - distance / 20) * particle.size)); + + if (screenX >= 0 && screenX < width && size > 0) { + ctx.save(); + ctx.translate(screenX, screenY); + ctx.rotate(particle.rotation); + + ctx.fillStyle = particle.color; + ctx.globalAlpha = particle.life; + + // Draw a more interesting particle shape + ctx.beginPath(); + ctx.moveTo(0, -size); + ctx.lineTo(size, size); + ctx.lineTo(-size, size); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + ctx.globalAlpha = 1; + } + }); + + // Draw mini-map + const miniMapSize = 200; + const miniMapX = width - miniMapSize - 10; + const miniMapY = 10; + const cellSize = miniMapSize / GameState.level.width; + + // Draw mini-map background with semi-transparent border + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(miniMapX, miniMapY, miniMapSize, miniMapSize); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(miniMapX, miniMapY, miniMapSize, miniMapSize); + + // Draw walls + ctx.fillStyle = '#666'; + for (let y = 0; y < GameState.level.height; y++) { + for (let x = 0; x < GameState.level.width; x++) { + if (GameState.level.map[y][x] === 1) { + ctx.fillRect( + miniMapX + x * cellSize, + miniMapY + y * cellSize, + cellSize, + cellSize + ); + } + } + } + + // Draw flag + ctx.fillStyle = '#ff0'; + ctx.beginPath(); + ctx.arc( + miniMapX + GameState.level.flag.x * cellSize + cellSize/2, + miniMapY + GameState.level.flag.y * cellSize + cellSize/2, + cellSize/2, + 0, + Math.PI * 2 + ); + ctx.fill(); + + // Draw items + GameState.items.forEach(item => { + if (item.type === 'health') { + ctx.fillStyle = '#0f0'; + } else { + ctx.fillStyle = '#0ff'; + } + ctx.beginPath(); + ctx.arc( + miniMapX + item.x * cellSize + cellSize/2, + miniMapY + item.y * cellSize + cellSize/2, + cellSize/3, + 0, + Math.PI * 2 + ); + ctx.fill(); + }); + + // Draw enemies + ctx.fillStyle = '#f00'; + GameState.enemies.forEach(enemy => { + ctx.beginPath(); + ctx.arc( + miniMapX + enemy.x * cellSize + cellSize/2, + miniMapY + enemy.y * cellSize + cellSize/2, + cellSize/2, + 0, + Math.PI * 2 + ); + ctx.fill(); + }); + + // Draw player with enhanced direction indicator + const playerX = miniMapX + GameState.player.x * cellSize; + const playerY = miniMapY + GameState.player.y * cellSize; + const size = cellSize * 1.5; + + // Draw player outline + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(playerX + cellSize/2, playerY + cellSize/2, size/2 + 2, 0, Math.PI * 2); + ctx.stroke(); + + // Draw player triangle with direction indicator - use the angle directly + ctx.fillStyle = '#00f'; + ctx.save(); + ctx.translate(playerX + cellSize/2, playerY + cellSize/2); + ctx.rotate(GameState.player.angle); // Use the angle directly + + // Draw main triangle + ctx.beginPath(); + ctx.moveTo(0, -size/2); + ctx.lineTo(size/2, size/2); + ctx.lineTo(-size/2, size/2); + ctx.closePath(); + ctx.fill(); + + // Draw direction indicator line + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, -size/2); + ctx.lineTo(0, -size); + ctx.stroke(); + + // Draw small circle at the end of the direction line + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(0, -size, size/4, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + // Draw damage flash effect + if (GameState.damageFlash > 0) { + // Create a radial gradient that's transparent in the center and red at the edges + const gradient = ctx.createRadialGradient( + width/2, height/2, 0, // Inner circle (center) + width/2, height/2, Math.max(width, height)/2 // Outer circle (edges) + ); + gradient.addColorStop(0, `rgba(255, 0, 0, 0)`); // Transparent center + gradient.addColorStop(0.7, `rgba(255, 0, 0, 0)`); // Start red at 70% of radius + gradient.addColorStop(1, `rgba(255, 0, 0, ${GameState.damageFlash})`); // Full red at edges + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + GameState.damageFlash -= 0.05; // Fade out over time + } +}; + +// Game loop +const gameLoop = (ctx) => { + if (!GameState.isStarted) { + render(ctx); + requestAnimationFrame(() => gameLoop(ctx)); + return; + } + + if (GameState.isGameOver) { + ctx.fillStyle = '#fff'; + ctx.font = '48px monospace'; + ctx.fillText('GAME OVER', ctx.canvas.width/2 - 100, ctx.canvas.height/2); + return; + } + + // Check for firing input + if ((keys[' '] || keys.e) && GameState.player.ammo > 0 && + Date.now() - GameState.gun.lastShot > 200) { + GameState.player.ammo--; + GameState.gun.recoil = 1; + GameState.gun.muzzleFlash = 1; + GameState.gun.lastShot = Date.now(); + GameState.shots.push({ + time: Date.now(), + angle: GameState.player.angle + }); + + // Check for enemy hits + const fov = Math.PI / 3; + const rayAngle = GameState.player.angle; + let distance = 0; + let hitEnemy = null; + let hitWall = false; + + while (!hitEnemy && !hitWall && distance < 20) { + distance += 0.1; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hitWall = true; + break; + } + + hitEnemy = GameState.enemies.find(enemy => { + const dx = testX - enemy.x; + const dy = testY - enemy.y; + return Math.sqrt(dx * dx + dy * dy) < 0.5; + }); + } + + if (hitEnemy) { + hitEnemy.health--; + if (hitEnemy.health <= 0) { + // Create more particles with more colors + const colors = [ + '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#ff8800', '#88ff00', '#00ff88', '#0088ff', '#8800ff', '#ff0088', + '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff' + ]; + for (let i = 0; i < 80; i++) { // More particles + const color = colors[Math.floor(Math.random() * colors.length)]; + GameState.particles.push(new Particle(hitEnemy.x, hitEnemy.y, color)); + } + } + } + } + + // Update gun recoil, muzzle flash, and slide position + if (GameState.gun.recoil > 0) { + GameState.gun.recoil -= 0.1; + // Animate slide moving back + GameState.gun.slidePosition = Math.min(1, GameState.gun.slidePosition + 0.2); + } else if (GameState.gun.slidePosition > 0) { + // Animate slide moving forward + GameState.gun.slidePosition = Math.max(0, GameState.gun.slidePosition - 0.1); + } + if (GameState.gun.muzzleFlash > 0) { + GameState.gun.muzzleFlash -= 0.2; + } + + // Update gun tilt based on ammo + if (GameState.player.ammo === 0) { + GameState.gun.tilt = Math.min(1, GameState.gun.tilt + 0.1); + } else { + GameState.gun.tilt = Math.max(0, GameState.gun.tilt - 0.1); + } + + // Update particles + GameState.particles = GameState.particles.filter(particle => particle.update()); + + handlePlayerMovement(keys); + updateEnemies(); + checkCollisions(); + render(ctx); + + requestAnimationFrame(() => gameLoop(ctx)); +}; + +// Input handling +const keys = { + w: false, + a: false, + s: false, + d: false, + ArrowLeft: false, + ArrowRight: false, + ' ': false, // Space bar + e: false // E key +}; + +document.addEventListener('keydown', e => { + if (!GameState.isStarted && (e.key === ' ' || e.key === 'Enter')) { + GameState.isStarted = true; + generateLevel(); + return; + } + + if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = true; + if (e.key in keys) keys[e.key] = true; +}); + +document.addEventListener('keyup', e => { + if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = false; + if (e.key in keys) keys[e.key] = false; +}); + +document.addEventListener('mousemove', e => { + if (GameState.isStarted) { + GameState.player.angle += e.movementX * 0.01; + } +}); + +// Update click handler to handle both start screen and firing +document.addEventListener('click', (e) => { + if (!GameState.isStarted) { + const canvas = document.getElementById('gameCanvas'); + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const buttonWidth = 200; + const buttonHeight = 60; + const buttonX = canvas.width/2 - buttonWidth/2; + const buttonY = canvas.height/2; + + if (x >= buttonX && x <= buttonX + buttonWidth && + y >= buttonY && y <= buttonY + buttonHeight) { + GameState.isStarted = true; + generateLevel(); + } + return; + } + + // Handle firing on click during gameplay + if (GameState.player.ammo > 0 && Date.now() - GameState.gun.lastShot > 200) { + GameState.player.ammo--; + GameState.gun.recoil = 1; + GameState.gun.muzzleFlash = 1; + GameState.gun.lastShot = Date.now(); + GameState.shots.push({ + time: Date.now(), + angle: GameState.player.angle + }); + + // Check for enemy hits + const fov = Math.PI / 3; + const rayAngle = GameState.player.angle; + let distance = 0; + let hitEnemy = null; + let hitWall = false; + + while (!hitEnemy && !hitWall && distance < 20) { + distance += 0.1; + const testX = GameState.player.x + Math.sin(rayAngle) * distance; + const testY = GameState.player.y + Math.cos(rayAngle) * distance; + + if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { + hitWall = true; + break; + } + + hitEnemy = GameState.enemies.find(enemy => { + const dx = testX - enemy.x; + const dy = testY - enemy.y; + return Math.sqrt(dx * dx + dy * dy) < 0.5; + }); + } + + if (hitEnemy) { + hitEnemy.health--; + if (hitEnemy.health <= 0) { + // Create more particles with more colors + const colors = [ + '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#ff8800', '#88ff00', '#00ff88', '#0088ff', '#8800ff', '#ff0088', + '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff' + ]; + for (let i = 0; i < 80; i++) { // More particles + const color = colors[Math.floor(Math.random() * colors.length)]; + GameState.particles.push(new Particle(hitEnemy.x, hitEnemy.y, color)); + } + } + } + } +}); + +// Initialize game +const init = () => { + const canvas = document.getElementById('gameCanvas'); + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + const ctx = canvas.getContext('2d'); + + // Don't generate level or start game loop until start button is clicked + render(ctx); + gameLoop(ctx); +}; + +// Add particle class +class Particle { + constructor(x, y, color) { + this.x = x; + this.y = y; + this.color = color; + this.size = Math.random() * 8 + 4; // Larger size range (4-12) + + // Random direction in all directions (360 degrees) + const angle = Math.random() * Math.PI * 2; + const speed = Math.random() * 15 + 5; // Faster spread (5-20) + this.speedX = Math.sin(angle) * speed; + this.speedY = Math.cos(angle) * speed; + + this.life = 1.0; + this.decay = Math.random() * 0.005 + 0.002; // Slower decay + this.rotation = Math.random() * Math.PI * 2; + this.rotationSpeed = (Math.random() - 0.5) * 0.2; + this.gravity = Math.random() * 0.15 + 0.05; + } + + update() { + this.x += this.speedX; + this.y += this.speedY; + this.speedY += this.gravity; + this.life -= this.decay; + this.rotation += this.rotationSpeed; + return this.life > 0; + } +} + +init(); |