const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: "*", // Allow connection from any IP or Domain methods: ["GET", "POST"] } }); //const io = socketIo(server); app.use(express.static('public')); const gameRooms = {}; const WIDTH = 1600; const HEIGHT = 1200; const gravity = 0.02; const maxSpeed = 3; // Reduced max speed // --- MAP GENERATOR FACTORY --- // Returns a unique set of terrain data for a specific room function generateCaveLevel() { const roofPoints = []; const floorPoints = []; const leftWallPoints = []; const rightWallPoints = []; const islands = []; const step = 40; const roofInitialHeight = HEIGHT / 8; const floorInitialHeight = HEIGHT * 7 / 8; const verticalDrift = 30; const horizontalDrift = 15; const islandCount = 15; const minGap = 500; let y_roof = roofInitialHeight; let y_floor = floorInitialHeight; // Horizontal terrain generation for (let x = 0; x <= WIDTH + step; x += step) { y_roof += (Math.random() - 0.5) * verticalDrift; y_floor += (Math.random() - 0.5) * verticalDrift; y_roof = Math.max(20, Math.min(y_roof, HEIGHT - minGap)); y_floor = Math.min(HEIGHT - 20, Math.max(y_floor, y_roof + minGap)); roofPoints.push({ x, y: y_roof }); floorPoints.push({ x, y: y_floor }); } // Vertical wall generation let x_left = 50; let x_right = WIDTH - 50; for (let y = 0; y <= HEIGHT + step; y += step) { x_left += (Math.random() - 0.5) * horizontalDrift; x_right += (Math.random() - 0.5) * horizontalDrift; x_left = Math.max(20, Math.min(x_left, 80)); x_right = Math.min(WIDTH - 20, Math.max(x_right, WIDTH - 80)); leftWallPoints.push({ x: x_left, y }); rightWallPoints.push({ x: x_right, y }); } // Island generation for (let i = 0; i < islandCount; i++) { const islandCenterX = Math.random() * (WIDTH - 600) + 300; // Helper functions are defined below, but hoisted, so this works const roofAtX = getTerrainHeight(islandCenterX, roofPoints); const floorAtX = getTerrainHeight(islandCenterX, floorPoints); const islandCenterY = Math.random() * (floorAtX - roofAtX - 400) + (roofAtX + 200); const islandPoints = []; const islandSegments = Math.floor(Math.random() * 12) + 10; const minRadius = 50, maxRadius = 120; for (let j = 0; j < islandSegments; j++) { const angle = (j / islandSegments) * Math.PI * 2; const radius = Math.random() * (maxRadius - minRadius) + minRadius; const x = islandCenterX + Math.cos(angle) * radius; const y = islandCenterY + Math.sin(angle) * radius; islandPoints.push({ x, y }); } islands.push(islandPoints); } // Create Landing Zone const landingZoneWidth = 100; const lzStartIndex = Math.floor(Math.random() * (floorPoints.length - (landingZoneWidth / step) - 10)) + 5; // avoid edges const lzEndIndex = lzStartIndex + Math.round(landingZoneWidth / step); let landingZone = null; if (lzEndIndex < floorPoints.length) { const avgY = floorPoints.slice(lzStartIndex, lzEndIndex).reduce((sum, p) => sum + p.y, 0) / (lzEndIndex - lzStartIndex); for(let i = lzStartIndex; i < lzEndIndex; i++) { floorPoints[i].y = avgY; // Flatten the floor segment } landingZone = { x: floorPoints[lzStartIndex].x, y: avgY, width: floorPoints[lzEndIndex].x - floorPoints[lzStartIndex].x, height: 40 // For visualization on client }; } return { roofPoints, floorPoints, leftWallPoints, rightWallPoints, islands, landingZone }; } // --- HELPER FUNCTIONS --- const PLAYER_EMOJIS = ['🚀', '👾', '🤖', '👽', '⭐', '💫', '💥', '✨', '☄️', '🪐']; function makeId(length) { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; var charactersLength = characters.length; for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } function getTerrainHeight(x, points) { if (!points || points.length === 0) return 0; if (x <= points[0].x) return points[0].y; if (x >= points[points.length - 1].x) return points[points.length - 1].y; let p1, p2; for (let i = 0; i < points.length - 1; i++) { if (x >= points[i].x && x < points[i+1].x) { p1 = points[i]; p2 = points[i+1]; break; } } if (!p1) return points[0].y; const t = (x - p1.x) / (p2.x - p1.x); return p1.y + t * (p2.y - p1.y); } function getTerrainWidth(y, points) { if (!points || points.length === 0) return 0; if (y <= points[0].y) return points[0].x; if (y >= points[points.length - 1].y) return points[points.length - 1].x; let p1, p2; for (let i = 0; i < points.length - 1; i++) { if (y >= points[i].y && y < points[i+1].y) { p1 = points[i]; p2 = points[i+1]; break; } } if (!p1) return points[0].x; const t = (y - p1.y) / (p2.y - p1.y); return p1.x + t * (p2.x - p1.x); } function isPointInPolygon(point, polygon) { let isInside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x, yi = polygon[i].y; const xj = polygon[j].x, yj = polygon[j].y; const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); if (intersect) isInside = !isInside; } return isInside; } function getSafeSpawnPoint(mapData) { const { roofPoints, floorPoints, leftWallPoints, rightWallPoints, islands } = mapData; const shipRadius = 20; // Slightly larger than actual radius for safety let newX, newY, isSafe; let attempts = 0; do { attempts++; newX = Math.random() * (WIDTH - 200) + 100; newY = Math.random() * (HEIGHT / 2) + (HEIGHT / 4); isSafe = true; if (newY - shipRadius < getTerrainHeight(newX, roofPoints) || newY + shipRadius > getTerrainHeight(newX, floorPoints) || newX - shipRadius < getTerrainWidth(newY, leftWallPoints) || newX + shipRadius > getTerrainWidth(newY, rightWallPoints)) { isSafe = false; } if(isSafe) { for (const island of islands) { if (isPointInPolygon({x: newX, y: newY}, island)) { isSafe = false; break; } } } // Fallback if stuck if (attempts > 50) { return { x: WIDTH/2, y: HEIGHT/2 }; } } while (!isSafe); return { x: newX, y: newY }; } // --- ROOM LIST HELPER --- function getRoomList() { const rooms = []; for (const code in gameRooms) { rooms.push({ code: code, count: Object.keys(gameRooms[code].players).length }); } return rooms; } function broadcastRoomList() { io.emit('roomListUpdate', getRoomList()); } // --- SOCKET IO LOGIC --- io.on('connection', (socket) => { console.log('A user connected:', socket.id); socket.emit('roomListUpdate', getRoomList()); // Send list immediately on connect socket.on('createGame', (data) => { let roomCode = makeId(4); while (gameRooms[roomCode]) { roomCode = makeId(4); } // Generate Unique Map for this room const levelData = generateCaveLevel(); gameRooms[roomCode] = { players: {}, pellets: [], mines: [], pickups: [], // Init pickups missiles: [], // Init missiles map: levelData, // Store map in the room object wallCollisionsEnabled: data.enableWallCollisions, // Store setting winScore: data.winScore || 10 // Store win score }; socket.join(roomCode); socket.roomCode = roomCode; const spawn = getSafeSpawnPoint(levelData); const player = { x: spawn.x, y: spawn.y, rotation: 0, velocityX: 0, velocityY: 0, id: socket.id, score: 0, isDead: false, respawnTimer: 0, name: data.playerName, isLanded: false, secondaryType: 'mine', // Default weapon secondaryAmmo: 3, // Default ammo emoji: PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)] // Assign random emoji }; gameRooms[roomCode].players[socket.id] = player; socket.emit('gameStarted', roomCode); // Send THIS room's map data socket.emit('currentPlayers', { players: gameRooms[roomCode].players, roof: levelData.roofPoints, floor: levelData.floorPoints, islands: levelData.islands, landingZone: levelData.landingZone, leftWall: levelData.leftWallPoints, rightWall: levelData.rightWallPoints, mapWidth: WIDTH, mapHeight: HEIGHT, winScore: gameRooms[roomCode].winScore }); broadcastRoomList(); // Update everyone }); socket.on('joinRoom', (data) => { const roomCode = data.roomCode; if (!gameRooms[roomCode]) { socket.emit('unknownGame'); return; } socket.join(roomCode); socket.roomCode = roomCode; const room = gameRooms[roomCode]; // Reference the specific room const spawn = getSafeSpawnPoint(room.map); const player = { x: spawn.x, y: spawn.y, rotation: 0, velocityX: 0, velocityY: 0, id: socket.id, score: 0, isDead: false, respawnTimer: 0, name: data.playerName, isLanded: false, secondaryType: 'mine', // Default weapon secondaryAmmo: 3, // Default ammo emoji: PLAYER_EMOJIS[Math.floor(Math.random() * PLAYER_EMOJIS.length)] // Assign random emoji }; room.players[socket.id] = player; socket.emit('gameStarted', roomCode); // Send THIS room's map data socket.emit('currentPlayers', { players: room.players, roof: room.map.roofPoints, floor: room.map.floorPoints, islands: room.map.islands, landingZone: room.map.landingZone, leftWall: room.map.leftWallPoints, rightWall: room.map.rightWallPoints, mapWidth: WIDTH, mapHeight: HEIGHT, winScore: room.winScore }); socket.to(roomCode).emit('newPlayer', player); broadcastRoomList(); // Update everyone }); socket.on('playerMovement', (movementData) => { const roomCode = socket.roomCode; if (roomCode && gameRooms[roomCode] && gameRooms[roomCode].players[socket.id]) { const player = gameRooms[roomCode].players[socket.id]; if(player.isDead) return; if (player.isLanded) { // LANDED STATE: // Only listen for UP to take off if (movementData.up) { player.isLanded = false; player.y -= 2; // CRITICAL: Nudge up so we don't immediately re-land player.velocityY = -2; // Give initial pop } // Ignore left/right while landed to prevent spinning on the ground return; } // AIRBORNE STATE: if (movementData.isTouch) { const touchRotationSpeed = 0.04; // Slower rotation for touch if (movementData.left) player.rotation -= touchRotationSpeed; if (movementData.right) player.rotation += touchRotationSpeed; } else { // Keyboard or other input if (movementData.left) player.rotation -= 0.1; if (movementData.right) player.rotation += 0.1; } if (movementData.up) { const thrust = 0.1; // Reduced thrust player.velocityX += Math.cos(player.rotation) * thrust; player.velocityY += Math.sin(player.rotation) * thrust; } } }); socket.on('shoot', () => { const roomCode = socket.roomCode; if (roomCode && gameRooms[roomCode] && gameRooms[roomCode].players[socket.id]) { const player = gameRooms[roomCode].players[socket.id]; if(player.isDead) return; const pelletSpeed = 7; gameRooms[roomCode].pellets.push({ x: player.x + Math.cos(player.rotation) * 18, y: player.y + Math.sin(player.rotation) * 18, velocityX: Math.cos(player.rotation) * pelletSpeed + player.velocityX, velocityY: Math.sin(player.rotation) * pelletSpeed + player.velocityY, ownerId: player.id, }); } }); socket.on('fireSecondary', () => { const roomCode = socket.roomCode; if (roomCode && gameRooms[roomCode] && gameRooms[roomCode].players[socket.id]) { const player = gameRooms[roomCode].players[socket.id]; if(player.isDead || player.secondaryAmmo <= 0) return; if (player.secondaryType === 'mine') { player.secondaryAmmo--; gameRooms[roomCode].mines.push({ x: player.x, y: player.y, ownerId: player.id, age: 0 }); } else if (player.secondaryType === 'missile') { player.secondaryAmmo--; const speed = 10; gameRooms[roomCode].missiles.push({ x: player.x + Math.cos(player.rotation) * 20, y: player.y + Math.sin(player.rotation) * 20, velocityX: Math.cos(player.rotation) * speed + player.velocityX, velocityY: Math.sin(player.rotation) * speed + player.velocityY, rotation: player.rotation, ownerId: player.id, age: 0 }); } } }); socket.on('disconnect', () => { console.log('User disconnected:', socket.id); const roomCode = socket.roomCode; if (roomCode && gameRooms[roomCode]) { delete gameRooms[roomCode].players[socket.id]; io.to(roomCode).emit('playerDisconnected', socket.id); if (Object.keys(gameRooms[roomCode].players).length === 0) { console.log(`Room ${roomCode} is empty, deleting.`); delete gameRooms[roomCode]; } broadcastRoomList(); // Update everyone } }); }); // --- GAME LOOP --- function updateGameState() { for (const roomCode in gameRooms) { const room = gameRooms[roomCode]; // CRITICAL: Destructure this room's specific map data // All physics checks below now use these local variables instead of globals const { roofPoints, floorPoints, leftWallPoints, rightWallPoints, islands } = room.map; // --- UPDATE PICKUPS --- if (!room.pickups) room.pickups = []; // Spawn chance: 0.5% per frame if less than 3 pickups if (room.pickups.length < 3 && Math.random() < 0.005) { let px, py, valid = false; let attempts = 0; while (!valid && attempts < 10) { attempts++; px = Math.random() * (WIDTH - 200) + 100; py = Math.random() * (HEIGHT - 200) + 100; const rY = getTerrainHeight(px, roofPoints); const fY = getTerrainHeight(px, floorPoints); const lX = getTerrainWidth(py, leftWallPoints); const rX = getTerrainWidth(py, rightWallPoints); // Check boundaries if (py > rY + 30 && py < fY - 30 && px > lX + 30 && px < rX - 30) { valid = true; // Check islands for (const island of islands) { if (isPointInPolygon({x: px, y: py}, island)) { valid = false; break; } } } } if (valid) { room.pickups.push({ x: px, y: py, type: 'missile', id: Math.random().toString(36).substring(7) }); } } for (let i = room.pickups.length - 1; i >= 0; i--) { const pickup = room.pickups[i]; // Check collision with players for (const pid in room.players) { const p = room.players[pid]; if (p.isDead) continue; const dist = Math.sqrt((p.x - pickup.x)**2 + (p.y - pickup.y)**2); if (dist < 30) { // Pickup radius // Award Weapon if (pickup.type === 'missile') { p.secondaryType = 'missile'; p.secondaryAmmo = 2; // Give 2 missiles } io.to(roomCode).emit('pickupCollected', { x: pickup.x, y: pickup.y, playerId: p.id, type: pickup.type }); room.pickups.splice(i, 1); break; // Pickup consumed } } } // --- UPDATE MISSILES --- if (!room.missiles) room.missiles = []; for (let i = room.missiles.length - 1; i >= 0; i--) { const missile = room.missiles[i]; missile.x += missile.velocityX; missile.y += missile.velocityY; missile.age++; if (missile.age > 120) { // 2 seconds life room.missiles.splice(i, 1); continue; } // Check Terrain Collision const roofY = getTerrainHeight(missile.x, roofPoints); const floorY = getTerrainHeight(missile.x, floorPoints); const leftX = getTerrainWidth(missile.y, leftWallPoints); const rightX = getTerrainWidth(missile.y, rightWallPoints); let hit = false; if (missile.y < roofY || missile.y > floorY || missile.x < leftX || missile.x > rightX) hit = true; else { for (const island of islands) { if (isPointInPolygon(missile, island)) { hit = true; break; } } } if (!hit) { // Check Player Collision for (const pid in room.players) { const p = room.players[pid]; if (p.isDead) continue; if (missile.ownerId === p.id && missile.age < 10) continue; // Safety const dist = Math.sqrt((p.x - missile.x)**2 + (p.y - missile.y)**2); if (dist < 20) { hit = true; io.to(roomCode).emit('playerDestroyed', { x: p.x, y: p.y, playerId: p.id }); p.isDead = true; p.isLanded = false; p.respawnTimer = 180; if (missile.ownerId !== p.id) { const killer = room.players[missile.ownerId]; if (killer) killer.score++; } break; } } } if (hit) { io.to(roomCode).emit('pelletImpact', { x: missile.x, y: missile.y }); room.missiles.splice(i, 1); } } // 0. Update Mines if (!room.mines) room.mines = []; // Safety init if missing for (let i = room.mines.length - 1; i >= 0; i--) { const mine = room.mines[i]; mine.age++; // Self-destroy after 10 seconds (600 frames) if (mine.age > 600) { io.to(roomCode).emit('pelletImpact', { x: mine.x, y: mine.y }); // Show explosion room.mines.splice(i, 1); continue; } // Check collision with all players let hit = false; for (const pid in room.players) { const p = room.players[pid]; if (p.isDead) continue; // Safety: Don't kill owner immediately if (mine.ownerId === p.id && mine.age < 60) continue; const dx = p.x - mine.x; const dy = p.y - mine.y; const dist = Math.sqrt(dx*dx + dy*dy); if (dist < 20) { // Mine radius hit = true; io.to(roomCode).emit('playerDestroyed', { x: p.x, y: p.y, playerId: p.id }); p.isDead = true; p.isLanded = false; p.respawnTimer = 180; // Award point to mine owner (even if they killed themselves?) // Let's say suicide doesn't give points, but kills you. if (mine.ownerId !== p.id) { const killer = room.players[mine.ownerId]; if (killer) killer.score++; } } } if (hit) { io.to(roomCode).emit('pelletImpact', { x: mine.x, y: mine.y }); // Use impact effect for mine too room.mines.splice(i, 1); } } // 1. Update pellets for (let i = room.pellets.length - 1; i >= 0; i--) { const pellet = room.pellets[i]; pellet.x += pellet.velocityX; pellet.y += pellet.velocityY; // Check collisions using local map data const roofY = getTerrainHeight(pellet.x, roofPoints); const floorY = getTerrainHeight(pellet.x, floorPoints); const leftX = getTerrainWidth(pellet.y, leftWallPoints); const rightX = getTerrainWidth(pellet.y, rightWallPoints); let collided = false; if (pellet.y < roofY || pellet.y > floorY || pellet.x < leftX || pellet.x > rightX) { collided = true; } else { for (const island of islands) { if (isPointInPolygon(pellet, island)) { collided = true; break; } } } if (collided) { io.to(roomCode).emit('pelletImpact', { x: pellet.x, y: pellet.y }); room.pellets.splice(i, 1); } } // 2. Update players for (const playerId in room.players) { const player = room.players[playerId]; const shipRadius = 15; // --- DEAD STATE --- if (player.isDead) { player.respawnTimer--; if (player.respawnTimer <= 0) { const spawn = getSafeSpawnPoint(room.map); player.isDead = false; player.isLanded = false; // Ensure they respawn airborne player.x = spawn.x; player.y = spawn.y; player.velocityX = 0; player.velocityY = 0; } continue; } // --- LANDED STATE --- if (player.isLanded) { // Force physics to stop. // This prevents "jitter" where gravity pulls it down and collision snaps it up. player.velocityX *= 0.9; // Friction to stop sliding player.velocityY = 0; player.rotation = -Math.PI / 2; // Force upright visual (pointing Up) // Check if they are still over the landing zone (in case they slid off) const lz = room.map.landingZone; if (lz) { // Hard snap to surface to prevent sinking player.y = (lz.y - lz.height) - shipRadius; // If they slid off the edge, wake them up if (player.x < lz.x || player.x > lz.x + lz.width) { player.isLanded = false; } } // Skip the rest of physics, but still check for bullet collisions later } // --- AIRBORNE STATE --- else { // Apply inputs and physics player.velocityY += gravity; player.x += player.velocityX; player.y += player.velocityY; const speed = Math.sqrt(player.velocityX ** 2 + player.velocityY ** 2); if (speed > maxSpeed) { player.velocityX = (player.velocityX / speed) * maxSpeed; player.velocityY = (player.velocityY / speed) * maxSpeed; } } // 3. Check for collisions and deaths (Using local map data) const roofY = getTerrainHeight(player.x, roofPoints); const floorY = getTerrainHeight(player.x, floorPoints); const leftX = getTerrainWidth(player.y, leftWallPoints); const rightX = getTerrainWidth(player.y, rightWallPoints); let hasDied = false; // Collision Flags let hitFloor = false, hitRoof = false, hitLeft = false, hitRight = false, hitIsland = false; // Only check terrain collision if NOT landed (or if we slid off) if (!player.isLanded) { if (player.y + shipRadius > floorY) hitFloor = true; else if (player.y - shipRadius < roofY) hitRoof = true; else if (player.x - shipRadius < leftX) hitLeft = true; else if (player.x + shipRadius > rightX) hitRight = true; else { for (const island of islands) { if (isPointInPolygon(player, island)) { hitIsland = true; break; } } } if (hitFloor || hitRoof || hitLeft || hitRight || hitIsland) { // LANDING LOGIC (Always check first) let landed = false; if (hitFloor) { const lz = room.map.landingZone; const speed = Math.sqrt(player.velocityX ** 2 + player.velocityY ** 2); const isOverLZ = lz && player.x > lz.x && player.x < lz.x + lz.width; const isUpright = Math.sin(player.rotation) < -0.9; if (isOverLZ && speed < 2.0 && isUpright) { landed = true; player.isLanded = true; player.secondaryType = 'mine'; // Reset to default player.secondaryAmmo = 3; // Restock player.velocityX = 0; player.velocityY = 0; player.rotation = 0; player.y = (lz.y - lz.height) - shipRadius; // Snap perfectly to top } } if (!landed) { if (room.wallCollisionsEnabled) { hasDied = true; } else { // SAFE MODE: Resolve Collision (Bounce) const bounceDamp = 0.5; if (hitFloor) { player.y = floorY - shipRadius; player.velocityY = -Math.abs(player.velocityY) * bounceDamp; } else if (hitRoof) { player.y = roofY + shipRadius; player.velocityY = Math.abs(player.velocityY) * bounceDamp; } else if (hitLeft) { player.x = leftX + shipRadius; player.velocityX = Math.abs(player.velocityX) * bounceDamp; } else if (hitRight) { player.x = rightX - shipRadius; player.velocityX = -Math.abs(player.velocityX) * bounceDamp; } else if (hitIsland) { // Simple reverse for islands player.x -= player.velocityX * 1.5; player.y -= player.velocityY * 1.5; player.velocityX *= -bounceDamp; player.velocityY *= -bounceDamp; } // Add impact sound/effect even if safe if (Math.abs(player.velocityX) > 1 || Math.abs(player.velocityY) > 1) { io.to(roomCode).emit('pelletImpact', { x: player.x, y: player.y }); } } } } } if (hasDied) { io.to(roomCode).emit('playerDestroyed', { x: player.x, y: player.y, playerId: player.id }); player.isDead = true; player.isLanded = false; player.respawnTimer = 180; } // Pellet-player collision (unchanged) for (let i = room.pellets.length - 1; i >= 0; i--) { const pellet = room.pellets[i]; if (pellet.ownerId !== playerId && !player.isDead) { const distance = Math.sqrt((player.x - pellet.x) ** 2 + (player.y - pellet.y) ** 2); if (distance < shipRadius) { io.to(roomCode).emit('playerDestroyed', { x: player.x, y: player.y, playerId: player.id }); player.isDead = true; player.isLanded = false; player.respawnTimer = 180; const killer = room.players[pellet.ownerId]; if (killer) killer.score++; room.pellets.splice(i, 1); break; } } } } // 4. Check for win condition let winnerId = null; for (const playerId in room.players) { if (room.players[playerId].score >= room.winScore) { winnerId = playerId; break; } } if (winnerId) { io.to(roomCode).emit('gameOver', winnerId); for (const playerId in room.players) room.players[playerId].score = 0; } // 5. Emit state (Pellets and Scores - Map data is static and sent on join) // We create a lightweight room object to send, we don't need to resend the map every frame io.to(roomCode).emit('gameState', { players: room.players, pellets: room.pellets, mines: room.mines, pickups: room.pickups, missiles: room.missiles }); } } setInterval(updateGameState, 1000 / 60); const PORT = process.env.PORT || 3001; // UPDATED: Added '0.0.0.0' as the second argument server.listen(PORT, '0.0.0.0', () => { console.log(`Server listening on port ${PORT}`); console.log(`Access locally via: http://localhost:${PORT}`); console.log(`Access via LAN via: https://192.168.1.64:${PORT}`); // (Or whatever your IP is) });