diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..ea6b635 --- /dev/null +++ b/fly.toml @@ -0,0 +1,17 @@ +app = "tank-warfare" +primary_region = "fra" + +[build] + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 256 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1dfacb3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tank-warfare", + "version": "1.0.0", + "description": "Multiplayer tank battle game with adaptive AI", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "keywords": ["game", "multiplayer", "tank", "pvp"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.6.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e702d8f --- /dev/null +++ b/public/index.html @@ -0,0 +1,1443 @@ + + + + + + Tank Warfare - Multiplayer Battle Arena + + + +
+ + +
+
+
Spieler
+
๐ŸŽฎ Online
+
+
+
100
+
+
Kills: 0 | Deaths: 0
+
+ + +
+
Score
+
0
+
+
+ +
+

๐Ÿ† Rangliste

+
+
+ +
+ + + +
+ Verbinde... +
+
+ +
+

โš”๏ธ TANK WARFARE โš”๏ธ

+
๐ŸŒ MULTIPLAYER ARENA
+ + +
+
๐ŸŽฎ Steuerung
+
Maus bewegen โ†’ Tank folgt
+
Linke Maustaste โ†’ SchieรŸen
+
ESC โ†’ Pause
+
+ ๐Ÿ—บ๏ธ 3000x3000 Riesen-Map | ๐Ÿ”Š Mit Sound | ๐Ÿค– 10 Adaptive Bots | ๐Ÿ’€ PvP +
+
+
+ + + + + + + + + diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..46bcfcf --- /dev/null +++ b/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "npm start", + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..a21991c --- /dev/null +++ b/render.yaml @@ -0,0 +1,11 @@ +services: + - type: web + name: tank-warfare + env: node + plan: free + buildCommand: npm install + startCommand: npm start + healthCheckPath: / + envVars: + - key: NODE_ENV + value: production diff --git a/server.js b/server.js new file mode 100644 index 0000000..6de4bcd --- /dev/null +++ b/server.js @@ -0,0 +1,570 @@ +const express = require('express'); +const http = require('http'); +const socketIO = require('socket.io'); +const path = require('path'); + +const app = express(); +const server = http.createServer(app); +const io = socketIO(server, { + cors: { origin: "*", methods: ["GET", "POST"] } +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +const WORLD_WIDTH = 3000; +const WORLD_HEIGHT = 3000; +const PLAYER_SIZE = 30; +const BULLET_SPEED = 12; +const MAX_HEALTH = 100; +const MAX_BOTS = 10; // More bots for larger map +const TICK_RATE = 60; + +const gameState = { + players: new Map(), + bots: new Map(), + bullets: [], + powerups: [], + nextBulletId: 0, + nextBotId: 0, + averagePlayerScore: 0 +}; + +function calculateBotDifficulty() { + const players = Array.from(gameState.players.values()); + if (players.length === 0) return 1.0; + const avgScore = players.reduce((sum, p) => sum + p.score, 0) / players.length; + gameState.averagePlayerScore = avgScore; + return Math.min(2.0, 0.5 + (avgScore / 500)); +} + +class Player { + constructor(id, name, x, y, isBot = false) { + this.id = id; + this.name = name; + this.x = x; + this.y = y; + this.targetX = x; + this.targetY = y; + this.vx = 0; + this.vy = 0; + this.angle = 0; + this.health = MAX_HEALTH; + this.maxHealth = MAX_HEALTH; + this.score = 0; + this.kills = 0; + this.deaths = 0; + this.isBot = isBot; + this.isDead = false; + this.respawnTime = 0; + this.color = isBot ? this.randomColor() : '#00ff00'; + this.baseSpeed = 3.5; + this.speed = 3.5; + this.lastShot = Date.now(); + this.fireRate = 250; + this.baseDamage = 25; + this.damage = 25; + this.shield = false; + this.shieldTime = 0; + this.rapidFire = false; + this.rapidFireTime = 0; + this.damageBoost = false; + this.damageBoostTime = 0; + this.invisible = false; + this.invisibleTime = 0; + this.moveTimer = 0; + this.difficulty = 1.0; + this.lastTarget = null; + this.strategyTimer = 0; + this.strategy = 'aggressive'; + this.lastKiller = null; + } + + randomColor() { + const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731', '#5f27cd', '#ff9ff3', '#ee5a6f', '#00d2ff']; + return colors[Math.floor(Math.random() * colors.length)]; + } + + takeDamage(amount, attackerId) { + if (this.shield || this.isDead) return false; + this.health -= amount; + if (this.health <= 0) { + this.deaths++; + this.isDead = true; + this.respawnTime = 300; + this.lastKiller = attackerId; + return true; + } + return false; + } + + update() { + if (this.isDead) { + if (this.respawnTime > 0) this.respawnTime--; + return; + } + + if (this.isBot) { + this.difficulty = calculateBotDifficulty(); + this.updateBot(); + } + + const dx = this.targetX - this.x; + const dy = this.targetY - this.y; + const dist = Math.hypot(dx, dy); + + if (dist > 5) { + const moveSpeed = Math.min(this.speed, dist * 0.15); + this.vx = (dx / dist) * moveSpeed; + this.vy = (dy / dist) * moveSpeed; + } else { + this.vx *= 0.9; + this.vy *= 0.9; + } + + this.x += this.vx; + this.y += this.vy; + this.x = Math.max(PLAYER_SIZE, Math.min(WORLD_WIDTH - PLAYER_SIZE, this.x)); + this.y = Math.max(PLAYER_SIZE, Math.min(WORLD_HEIGHT - PLAYER_SIZE, this.y)); + + if (this.shield) { + this.shieldTime--; + if (this.shieldTime <= 0) this.shield = false; + } + + if (this.rapidFire) { + this.rapidFireTime--; + if (this.rapidFireTime <= 0) { + this.rapidFire = false; + this.fireRate = 250; + } + } + + if (this.damageBoost) { + this.damageBoostTime--; + if (this.damageBoostTime <= 0) { + this.damageBoost = false; + this.damage = this.baseDamage; + } + } + + if (this.invisible) { + this.invisibleTime--; + if (this.invisibleTime <= 0) this.invisible = false; + } + } + + updateBot() { + this.moveTimer++; + this.strategyTimer++; + + if (this.strategyTimer > 600) { + const strategies = ['aggressive', 'defensive', 'balanced']; + this.strategy = strategies[Math.floor(Math.random() * strategies.length)]; + this.strategyTimer = 0; + } + + const allTargets = [ + ...Array.from(gameState.players.values()), + ...Array.from(gameState.bots.values()) + ].filter(t => t.id !== this.id && !t.isDead); + + if (this.moveTimer % 30 === 0) { + let bestTarget = null; + let bestScore = -Infinity; + + allTargets.forEach(target => { + const dist = Math.hypot(target.x - this.x, target.y - this.y); + const healthRatio = target.health / target.maxHealth; + + let score = 0; + switch(this.strategy) { + case 'aggressive': + score = (1 - healthRatio) * 100 - dist * 0.5; + break; + case 'defensive': + score = -dist * 2; + break; + case 'balanced': + score = (1 - healthRatio) * 50 - dist * 0.3 + (target.isBot ? -20 : 30); + break; + } + + if (score > bestScore) { + bestScore = score; + bestTarget = target; + } + }); + + let nearestPowerup = null; + let powerupDist = Infinity; + gameState.powerups.forEach(powerup => { + const dist = Math.hypot(powerup.x - this.x, powerup.y - this.y); + if (dist < powerupDist && dist < 400) { + powerupDist = dist; + nearestPowerup = powerup; + } + }); + + if (this.health < 40 && nearestPowerup && nearestPowerup.type === 'health') { + this.targetX = nearestPowerup.x; + this.targetY = nearestPowerup.y; + } else if (nearestPowerup && Math.random() < 0.4) { + this.targetX = nearestPowerup.x; + this.targetY = nearestPowerup.y; + } else if (bestTarget) { + const distToTarget = Math.hypot(bestTarget.x - this.x, bestTarget.y - this.y); + const optimalDist = this.strategy === 'defensive' ? 350 : this.strategy === 'aggressive' ? 200 : 250; + + if (distToTarget > optimalDist) { + this.targetX = this.x + (bestTarget.x - this.x) * 0.5 + (Math.random() - 0.5) * 100 * this.difficulty; + this.targetY = this.y + (bestTarget.y - this.y) * 0.5 + (Math.random() - 0.5) * 100 * this.difficulty; + } else if (distToTarget < optimalDist * 0.7) { + this.targetX = this.x - (bestTarget.x - this.x) * 0.3 + (Math.random() - 0.5) * 100; + this.targetY = this.y - (bestTarget.y - this.y) * 0.3 + (Math.random() - 0.5) * 100; + } else { + const perpAngle = Math.atan2(bestTarget.y - this.y, bestTarget.x - this.x) + Math.PI / 2; + this.targetX = this.x + Math.cos(perpAngle) * 100; + this.targetY = this.y + Math.sin(perpAngle) * 100; + } + this.lastTarget = bestTarget; + } else { + this.targetX = Math.random() * WORLD_WIDTH; + this.targetY = Math.random() * WORLD_HEIGHT; + } + + this.targetX = Math.max(PLAYER_SIZE, Math.min(WORLD_WIDTH - PLAYER_SIZE, this.targetX)); + this.targetY = Math.max(PLAYER_SIZE, Math.min(WORLD_HEIGHT - PLAYER_SIZE, this.targetY)); + } + + if (this.lastTarget && !this.lastTarget.isDead && this.lastTarget.health > 0) { + const leadAmount = this.difficulty * 0.3; + const targetVx = this.lastTarget.vx || 0; + const targetVy = this.lastTarget.vy || 0; + + const aimX = this.lastTarget.x + targetVx * leadAmount * 10; + const aimY = this.lastTarget.y + targetVy * leadAmount * 10; + + this.angle = Math.atan2(aimY - this.y, aimX - this.x) + (Math.random() - 0.5) * (0.4 / this.difficulty); + + const dist = Math.hypot(this.lastTarget.x - this.x, this.lastTarget.y - this.y); + const fireRate = 1200 / this.difficulty; + const accuracy = 0.15 + (this.difficulty * 0.25); + + if (dist < 400 && Math.random() < accuracy) { + if (Date.now() - this.lastShot > fireRate) { + shootBullet(this); + } + } + } + } + + respawn() { + const edge = Math.floor(Math.random() * 4); + switch(edge) { + case 0: this.x = Math.random() * WORLD_WIDTH; this.y = 50; break; + case 1: this.x = WORLD_WIDTH - 50; this.y = Math.random() * WORLD_HEIGHT; break; + case 2: this.x = Math.random() * WORLD_WIDTH; this.y = WORLD_HEIGHT - 50; break; + case 3: this.x = 50; this.y = Math.random() * WORLD_HEIGHT; break; + } + this.health = MAX_HEALTH; + this.shield = true; + this.shieldTime = 300; + this.isDead = false; + this.respawnTime = 0; + this.lastKiller = null; + } + + toJSON() { + return { + id: this.id, + name: this.name, + x: this.x, + y: this.y, + angle: this.angle, + health: this.health, + maxHealth: this.maxHealth, + score: this.score, + kills: this.kills, + deaths: this.deaths, + isBot: this.isBot, + color: this.color, + shield: this.shield, + rapidFire: this.rapidFire, + damageBoost: this.damageBoost, + invisible: this.invisible, + isDead: this.isDead, + respawnTime: this.respawnTime + }; + } +} + +class Bullet { + constructor(id, x, y, angle, owner) { + this.id = id; + this.x = x; + this.y = y; + this.vx = Math.cos(angle) * BULLET_SPEED; + this.vy = Math.sin(angle) * BULLET_SPEED; + this.ownerId = owner.id; + this.damage = owner.damage || 25; + this.life = 120; + } + + update() { + this.x += this.vx; + this.y += this.vy; + this.life--; + + const allEntities = [...gameState.players.values(), ...gameState.bots.values()]; + + for (let entity of allEntities) { + if (entity.id === this.ownerId || entity.isDead) continue; + + const dist = Math.hypot(entity.x - this.x, entity.y - this.y); + if (dist < PLAYER_SIZE) { + if (entity.takeDamage(this.damage, this.ownerId)) { + const owner = gameState.players.get(this.ownerId) || gameState.bots.get(this.ownerId); + if (owner) { + owner.kills++; + owner.score += 100; + } + + io.emit('killFeed', { + killer: owner ? owner.name : 'Unknown', + victim: entity.name, + killerColor: owner ? owner.color : '#ffffff', + victimColor: entity.color + }); + + if (entity.isBot) { + setTimeout(() => { + if (gameState.bots.has(entity.id)) { + entity.respawn(); + } + }, 3000); + } else { + io.to(entity.id).emit('playerDied', { + score: entity.score, + kills: entity.kills, + deaths: entity.deaths, + killerName: owner ? owner.name : 'Unknown' + }); + } + } + return false; + } + } + + if (this.x < 0 || this.x > WORLD_WIDTH || this.y < 0 || this.y > WORLD_HEIGHT || this.life <= 0) { + return false; + } + return true; + } + + toJSON() { + return { x: this.x, y: this.y, vx: this.vx, vy: this.vy }; + } +} + +class PowerUp { + constructor(x, y, type) { + this.x = x; + this.y = y; + this.type = type; + } + + toJSON() { + return { x: this.x, y: this.y, type: this.type }; + } +} + +function spawnBot() { + if (gameState.bots.size >= MAX_BOTS) return; + const edge = Math.floor(Math.random() * 4); + let x, y; + switch(edge) { + case 0: x = Math.random() * WORLD_WIDTH; y = 50; break; + case 1: x = WORLD_WIDTH - 50; y = Math.random() * WORLD_HEIGHT; break; + case 2: x = Math.random() * WORLD_WIDTH; y = WORLD_HEIGHT - 50; break; + case 3: x = 50; y = Math.random() * WORLD_HEIGHT; break; + } + + const ranks = ['Cpt.', 'Lt.', 'Sgt.', 'Pvt.', 'Col.', 'Maj.']; + const adjectives = ['Iron', 'Steel', 'Shadow', 'Ghost', 'Thunder', 'Viper', 'Hawk', 'Wolf', 'Tiger', 'Dragon']; + const nouns = ['Tank', 'Hunter', 'Warrior', 'Striker', 'Guardian', 'Phantom', 'Reaper', 'Titan']; + + const rank = ranks[Math.floor(Math.random() * ranks.length)]; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const name = `${rank} ${adj} ${noun}`; + + const botId = 'bot_' + gameState.nextBotId++; + const bot = new Player(botId, name, x, y, true); + gameState.bots.set(botId, bot); +} + +function removeBot() { + if (gameState.bots.size > 0) { + const botId = Array.from(gameState.bots.keys())[0]; + gameState.bots.delete(botId); + } +} + +function spawnPowerUp() { + const types = ['health', 'shield', 'speed', 'rapid_fire', 'damage_boost', 'regen', 'invisible']; + const type = types[Math.floor(Math.random() * types.length)]; + const x = Math.random() * (WORLD_WIDTH - 100) + 50; + const y = Math.random() * (WORLD_HEIGHT - 100) + 50; + gameState.powerups.push(new PowerUp(x, y, type)); +} + +function shootBullet(player) { + player.lastShot = Date.now(); + const bulletId = gameState.nextBulletId++; + const bullet = new Bullet( + bulletId, + player.x + Math.cos(player.angle) * PLAYER_SIZE, + player.y + Math.sin(player.angle) * PLAYER_SIZE, + player.angle, + player + ); + gameState.bullets.push(bullet); +} + +io.on('connection', (socket) => { + console.log('Player connected:', socket.id); + + socket.on('joinGame', (data) => { + const playerName = data.name || 'Spieler'; + removeBot(); + const player = new Player(socket.id, playerName, WORLD_WIDTH / 2, WORLD_HEIGHT / 2, false); + gameState.players.set(socket.id, player); + + socket.emit('initGame', { + playerId: socket.id, + worldSize: { width: WORLD_WIDTH, height: WORLD_HEIGHT }, + players: Array.from(gameState.players.values()).map(p => p.toJSON()), + bots: Array.from(gameState.bots.values()).map(b => b.toJSON()), + bullets: gameState.bullets.map(b => b.toJSON()), + powerups: gameState.powerups.map(p => p.toJSON()) + }); + }); + + socket.on('updatePlayer', (data) => { + const player = gameState.players.get(socket.id); + if (player && !player.isDead) { + player.targetX = data.targetX; + player.targetY = data.targetY; + player.angle = data.angle; + } + }); + + socket.on('shoot', () => { + const player = gameState.players.get(socket.id); + if (player && !player.isDead) { + const fireRate = player.rapidFire ? player.fireRate : 250; + if (Date.now() - player.lastShot > fireRate) { + shootBullet(player); + } + } + }); + + socket.on('collectPowerUp', (index) => { + const player = gameState.players.get(socket.id); + const powerup = gameState.powerups[index]; + if (player && powerup && !player.isDead) { + const dist = Math.hypot(player.x - powerup.x, player.y - powerup.y); + if (dist < PLAYER_SIZE + 20) { + switch(powerup.type) { + case 'health': + player.health = Math.min(player.maxHealth, player.health + 50); + break; + case 'shield': + player.shield = true; + player.shieldTime = 300; + break; + case 'speed': + player.speed = player.baseSpeed * 1.5; + setTimeout(() => player.speed = player.baseSpeed, 5000); + break; + case 'rapid_fire': + player.rapidFire = true; + player.rapidFireTime = 300; + player.fireRate = 150; // 1.66x faster instead of 2.5x + break; + case 'damage_boost': + player.damageBoost = true; + player.damageBoostTime = 300; + player.damage = player.baseDamage * 1.5; // 1.5x instead of 2x + break; + case 'regen': + let regenTicks = 300; + const regenInterval = setInterval(() => { + if (player.health < player.maxHealth && !player.isDead) { + player.health = Math.min(player.health + 0.25, player.maxHealth); // 75 HP total instead of 120 + } + regenTicks--; + if (regenTicks <= 0) clearInterval(regenInterval); + }, 1000 / 60); + break; + case 'invisible': + player.invisible = true; + player.invisibleTime = 150; // 2.5s instead of 3s + break; + } + gameState.powerups.splice(index, 1); + io.emit('powerUpCollected', { index, type: powerup.type }); + } + } + }); + + socket.on('respawn', () => { + const player = gameState.players.get(socket.id); + if (player) player.respawn(); + }); + + socket.on('disconnect', () => { + const player = gameState.players.get(socket.id); + if (player) { + console.log(`${player.name} disconnected`); + gameState.players.delete(socket.id); + setTimeout(() => spawnBot(), 1000); + } + }); +}); + +function gameLoop() { + gameState.players.forEach(player => player.update()); + gameState.bots.forEach(bot => bot.update()); + gameState.bullets = gameState.bullets.filter(bullet => bullet.update()); + + if (gameState.powerups.length < 16 && Math.random() < 0.015) { + spawnPowerUp(); + } + + const state = { + players: Array.from(gameState.players.values()).map(p => p.toJSON()), + bots: Array.from(gameState.bots.values()).map(b => b.toJSON()), + bullets: gameState.bullets.map(b => b.toJSON()), + powerups: gameState.powerups.map(p => p.toJSON()), + averageScore: gameState.averagePlayerScore + }; + + io.emit('gameState', state); +} + +function initGame() { + for (let i = 0; i < MAX_BOTS; i++) spawnBot(); + for (let i = 0; i < 16; i++) spawnPowerUp(); + setInterval(gameLoop, 1000 / TICK_RATE); + console.log('๐ŸŽฎ Tank Warfare Server Started!'); + console.log(`๐Ÿ“ World: ${WORLD_WIDTH}x${WORLD_HEIGHT}`); + console.log(`โšก Tick rate: ${TICK_RATE} updates/sec`); +} + +const PORT = process.env.PORT || 3000; + +server.listen(PORT, () => { + console.log(`๐Ÿš€ Server running on port ${PORT}`); + initGame(); +});