diff options
author | elioat <elioat@tilde.institute> | 2025-04-09 22:29:57 -0400 |
---|---|---|
committer | elioat <elioat@tilde.institute> | 2025-04-09 22:29:57 -0400 |
commit | 6bab5e8daf7c09cc31882b0018c59ac0b9356c67 (patch) | |
tree | 14ad7db2168310636286ded314e0e50a088ead62 /html/fps/game.js | |
parent | 0b4e014792b6969a0627fcf8e651f236102027af (diff) | |
download | tour-6bab5e8daf7c09cc31882b0018c59ac0b9356c67.tar.gz |
*
Diffstat (limited to 'html/fps/game.js')
-rw-r--r-- | html/fps/game.js | 446 |
1 files changed, 372 insertions, 74 deletions
diff --git a/html/fps/game.js b/html/fps/game.js index 0a18d79..bac8f9b 100644 --- a/html/fps/game.js +++ b/html/fps/game.js @@ -20,9 +20,12 @@ const GameState = { gun: { recoil: 0, lastShot: 0, - muzzleFlash: 0 + muzzleFlash: 0, + slidePosition: 0, // 0 = forward, 1 = back + tilt: 0 // 0 = normal, 1 = tilted }, - shots: [] + shots: [], + isStarted: false }; // Level generation using a simple maze algorithm @@ -31,7 +34,7 @@ const generateLevel = () => { Array(GameState.level.width).fill(1) ); - // Create a larger starting room + // Create starting room const startRoomSize = 5; for (let y = 1; y < startRoomSize; y++) { for (let x = 1; x < startRoomSize; x++) { @@ -39,9 +42,36 @@ const generateLevel = () => { } } - // Simple maze generation using depth-first search + // 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); @@ -52,7 +82,13 @@ const generateLevel = () => { if (nx > 0 && nx < GameState.level.width - 1 && ny > 0 && ny < GameState.level.height - 1 && map[ny][nx] === 1) { - map[y + dy/2][x + dx/2] = 0; + // 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); } } @@ -86,12 +122,24 @@ const generateLevel = () => { // Place items in open spaces (not in starting room) GameState.items = []; - for (let i = 0; i < 10; i++) { + // 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: Math.random() > 0.5 ? 'ammo' : 'health' + 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 }); } @@ -103,7 +151,7 @@ const generateLevel = () => { // Player movement and controls const handlePlayerMovement = (keys) => { - const moveSpeed = 0.1; + const moveSpeed = 0.05; const rotateSpeed = 0.05; if (keys.w) { @@ -115,12 +163,20 @@ const handlePlayerMovement = (keys) => { GameState.player.y -= Math.cos(GameState.player.angle) * moveSpeed; } if (keys.a) { - GameState.player.x -= Math.sin(GameState.player.angle - Math.PI/2) * moveSpeed; - GameState.player.y -= Math.cos(GameState.player.angle - Math.PI/2) * moveSpeed; + GameState.player.x -= Math.cos(GameState.player.angle) * moveSpeed; + GameState.player.y += Math.sin(GameState.player.angle) * moveSpeed; } if (keys.d) { - GameState.player.x += Math.sin(GameState.player.angle - Math.PI/2) * moveSpeed; - GameState.player.y += Math.cos(GameState.player.angle - Math.PI/2) * moveSpeed; + 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; } }; @@ -163,9 +219,19 @@ const updateEnemies = () => { // Collision detection const checkCollisions = () => { - // Wall collisions with improved precision + // 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; @@ -173,6 +239,11 @@ const checkCollisions = () => { 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; }; @@ -193,9 +264,9 @@ const checkCollisions = () => { const dy = GameState.player.y - item.y; if (Math.sqrt(dx * dx + dy * dy) < 0.5) { if (item.type === 'ammo') { - GameState.player.ammo += 5; + GameState.player.ammo += item.value; } else { - GameState.player.health = Math.min(100, GameState.player.health + 25); + GameState.player.health = Math.min(100, GameState.player.health + item.value); } return false; } @@ -221,6 +292,33 @@ const render = (ctx) => { 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); @@ -229,16 +327,18 @@ const render = (ctx) => { ctx.fillStyle = '#4a2a00'; ctx.fillRect(0, height/2, width, height/2); - // Draw walls and enemies using ray casting + // Draw walls and sprites using ray casting const fov = Math.PI / 3; const numRays = width; const rayResults = []; + const sprites = []; for (let i = 0; i < numRays; i++) { const rayAngle = GameState.player.angle - fov/2 + fov * i / numRays; let distance = 0; let hit = false; let hitEnemy = null; + let hitItem = null; while (!hit && distance < 20) { distance += 0.1; @@ -259,6 +359,32 @@ const render = (ctx) => { }); if (hitEnemy) { hit = true; + sprites.push({ + type: 'enemy', + x: i, + distance, + angle: rayAngle, + data: hitEnemy + }); + } + } + + // Check for item hits + if (!hit) { + hitItem = GameState.items.find(item => { + const dx = testX - item.x; + const dy = testY - item.y; + return Math.sqrt(dx * dx + dy * dy) < 0.5; + }); + if (hitItem) { + hit = true; + sprites.push({ + type: 'item', + x: i, + distance, + angle: rayAngle, + data: hitItem + }); } } } @@ -277,6 +403,13 @@ const render = (ctx) => { const enemyColor = Math.floor(brightness * 255); ctx.fillStyle = `rgb(${enemyColor}, 0, 0)`; ctx.fillRect(i, height/2 - wallHeight/2, 1, wallHeight); + } else if (hitItem) { + // Draw item + const itemColor = hitItem.type === 'health' ? + `rgb(0, ${Math.floor(brightness * 255)}, 0)` : + `rgb(0, ${Math.floor(brightness * 255)}, ${Math.floor(brightness * 255)})`; + ctx.fillStyle = itemColor; + ctx.fillRect(i, height/2 - wallHeight/2, 1, wallHeight); } else { // Draw wall const wallColor = Math.floor(brightness * 100); @@ -313,26 +446,34 @@ const render = (ctx) => { // Draw gun const gunY = height - 150; const gunX = width/2; - const recoilOffset = Math.sin(GameState.gun.recoil * Math.PI) * 50; // Increased recoil range + const recoilOffset = Math.sin(GameState.gun.recoil * Math.PI) * 50; + const tiltAngle = GameState.gun.tilt * Math.PI / 6; // 30 degrees max tilt // Only draw gun if it's not in full recoil if (GameState.gun.recoil < 0.8) { - // Gun body + ctx.save(); + ctx.translate(gunX, gunY + recoilOffset); + ctx.rotate(tiltAngle); + + // Gun body (larger rectangle) ctx.fillStyle = '#333'; - ctx.fillRect(gunX - 30, gunY + recoilOffset, 60, 90); + ctx.fillRect(-30, 0, 60, 90); - // Gun barrel + // Gun slide (smaller rectangle) with sliding animation + const slideOffset = GameState.gun.slidePosition * 20; ctx.fillStyle = '#222'; - ctx.fillRect(gunX - 8, gunY - 30 + recoilOffset, 16, 60); + ctx.fillRect(-8, -30 - slideOffset, 16, 60); // 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(gunX, gunY - 30 + recoilOffset, flashSize, 0, Math.PI * 2); + ctx.arc(0, -30 - slideOffset, flashSize, 0, Math.PI * 2); ctx.fill(); } + + ctx.restore(); } // Draw crosshair @@ -350,32 +491,144 @@ const render = (ctx) => { ctx.fillText(`Ammo: ${GameState.player.ammo}`, 20, 80); ctx.fillText(`Score: ${GameState.player.score}`, 20, 115); - // Draw enemy health bars - GameState.enemies.forEach(enemy => { - const dx = enemy.x - GameState.player.x; - const dy = enemy.y - GameState.player.y; - const angle = Math.atan2(dx, dy) - GameState.player.angle; - const screenX = (angle / fov + 0.5) * width; + // Draw sprites with proper wall occlusion + sprites.forEach(sprite => { + const spriteHeight = height / (sprite.distance * Math.cos(sprite.angle - GameState.player.angle)); + const spriteWidth = spriteHeight; + const spriteX = sprite.x - spriteWidth/2; + const spriteY = height/2 - spriteHeight/2; - if (screenX >= 0 && screenX < width) { - const distance = Math.sqrt(dx * dx + dy * dy); - const height = canvas.height / (distance * Math.cos(angle)); - const y = canvas.height/2 - height/2; + // Only draw if not occluded by a wall + if (sprite.distance < rayResults[sprite.x].distance) { + ctx.save(); + ctx.translate(spriteX + spriteWidth/2, spriteY + spriteHeight/2); + + // Enable anti-aliasing for clean edges + ctx.imageSmoothingEnabled = true; - // Health bar background - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; - ctx.fillRect(screenX - 10, y - 20, 20, 5); + if (sprite.type === 'enemy') { + // Draw enemy as red square + ctx.fillStyle = '#f00'; + ctx.fillRect(-spriteWidth/2, -spriteHeight/2, spriteWidth, spriteHeight); + } else if (sprite.data.type === 'health') { + // Draw health as golden square + ctx.fillStyle = '#ffd700'; + ctx.fillRect(-spriteWidth/2, -spriteHeight/2, spriteWidth, spriteHeight); + } else if (sprite.data.type === 'ammo') { + // Draw ammo as teal square + ctx.fillStyle = '#0ff'; + ctx.fillRect(-spriteWidth/2, -spriteHeight/2, spriteWidth, spriteHeight); + } else if (sprite.data.type === 'flag') { + // Draw flag as blue diamond + ctx.fillStyle = '#00f'; + ctx.beginPath(); + ctx.moveTo(0, -spriteHeight/2); + ctx.lineTo(spriteWidth/2, 0); + ctx.lineTo(0, spriteHeight/2); + ctx.lineTo(-spriteWidth/2, 0); + ctx.closePath(); + ctx.fill(); + } - // Health bar - const healthPercent = enemy.health / 5; // Assuming max health is 5 - ctx.fillStyle = `rgb(${255 * (1 - healthPercent)}, ${255 * healthPercent}, 0)`; - ctx.fillRect(screenX - 10, y - 20, 20 * healthPercent, 5); + ctx.restore(); } }); + + // Draw mini-map + const miniMapSize = 150; + const miniMapX = width - miniMapSize - 10; + const miniMapY = 10; + const cellSize = miniMapSize / GameState.level.width; + + // Draw mini-map background + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(miniMapX, miniMapY, miniMapSize, miniMapSize); + + // Draw walls + ctx.fillStyle = '#fff'; + 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 in mini-map with matching colors + GameState.items.forEach(item => { + if (item.type === 'health') { + ctx.fillStyle = '#ffd700'; // Golden + } else if (item.type === 'ammo') { + ctx.fillStyle = '#0ff'; // Teal + } + 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 in mini-map + ctx.fillStyle = '#f00'; // Red + 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 as a triangle + ctx.fillStyle = '#00f'; + const playerX = miniMapX + GameState.player.x * cellSize; + const playerY = miniMapY + GameState.player.y * cellSize; + const size = cellSize; + + ctx.save(); + ctx.translate(playerX, playerY); + ctx.rotate(GameState.player.angle); + ctx.beginPath(); + ctx.moveTo(0, -size/2); + ctx.lineTo(size/2, size/2); + ctx.lineTo(-size/2, size/2); + ctx.closePath(); + ctx.fill(); + ctx.restore(); }; // Game loop const gameLoop = (ctx) => { + if (!GameState.isStarted) { + render(ctx); + requestAnimationFrame(() => gameLoop(ctx)); + return; + } + if (GameState.isGameOver) { ctx.fillStyle = '#fff'; ctx.font = '48px monospace'; @@ -383,46 +636,19 @@ const gameLoop = (ctx) => { return; } - // Update gun recoil and muzzle flash - if (GameState.gun.recoil > 0) { - GameState.gun.recoil -= 0.1; - } - if (GameState.gun.muzzleFlash > 0) { - GameState.gun.muzzleFlash -= 0.2; - } - - handlePlayerMovement(keys); - updateEnemies(); - checkCollisions(); - render(ctx); - - requestAnimationFrame(() => gameLoop(ctx)); -}; - -// Input handling -const keys = { w: false, a: false, s: false, d: false }; -document.addEventListener('keydown', e => { - if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = true; -}); -document.addEventListener('keyup', e => { - if (e.key.toLowerCase() in keys) keys[e.key.toLowerCase()] = false; -}); - -document.addEventListener('mousemove', e => { - GameState.player.angle += e.movementX * 0.01; -}); - -document.addEventListener('click', () => { - if (GameState.player.ammo > 0) { + // Check for firing input + if ((keys[' '] || keys.e) && GameState.player.ammo > 0 && + Date.now() - GameState.gun.lastShot > 200) { // 200ms cooldown 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 with wall collision + // Check for enemy hits const fov = Math.PI / 3; const rayAngle = GameState.player.angle; let distance = 0; @@ -434,13 +660,11 @@ document.addEventListener('click', () => { const testX = GameState.player.x + Math.sin(rayAngle) * distance; const testY = GameState.player.y + Math.cos(rayAngle) * distance; - // Check for wall hit first if (GameState.level.map[Math.floor(testY)][Math.floor(testX)] === 1) { hitWall = true; break; } - // Then check for enemy hit hitEnemy = GameState.enemies.find(enemy => { const dx = testX - enemy.x; const dy = testY - enemy.y; @@ -452,6 +676,79 @@ document.addEventListener('click', () => { hitEnemy.health--; } } + + // 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); + } + + 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 (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 => { + GameState.player.angle += e.movementX * 0.01; +}); + +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(); + } + } }); // Initialize game @@ -461,7 +758,8 @@ const init = () => { canvas.height = window.innerHeight; const ctx = canvas.getContext('2d'); - generateLevel(); + // Don't generate level or start game loop until start button is clicked + render(ctx); gameLoop(ctx); }; |