Files
vectorvoid/public/index.html
2026-02-05 22:50:20 +02:00

1166 lines
52 KiB
HTML
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Vector Void</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@400;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body {
margin: 0; background: #1a1a1a; overflow: hidden; font-family: 'Exo 2', sans-serif;
}
canvas { display: block; width: 100vw; height: 100vh; }
#lobby {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center;
background: linear-gradient(145deg, rgba(10, 25, 40, 0.95), rgba(0, 5, 10, 0.98));
padding: 45px; border-radius: 20px;
border: 1px solid rgba(0, 255, 255, 0.15);
box-shadow: 0 0 50px rgba(0, 191, 255, 0.15), inset 0 0 20px rgba(0, 0, 0, 0.8);
color: #fff; width: 380px;
backdrop-filter: blur(8px);
}
#lobby h1 {
margin-top: 0; margin-bottom: 25px;
font-family: 'Orbitron', sans-serif; letter-spacing: 4px; text-transform: uppercase;
color: #fff; text-shadow: 0 0 10px #00BFFF, 0 0 25px #00BFFF;
font-size: 32px;
}
.join-section { margin-top: 25px; }
/* Targeted inputs to avoid messing up checkboxes */
input[type="text"] {
padding: 15px; border-radius: 8px;
border: 1px solid rgba(0, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.6);
color: #fff; font-size: 16px; margin-bottom: 15px;
width: calc(100% - 32px);
text-transform: uppercase; font-family: 'Exo 2', sans-serif;
transition: all 0.3s ease;
}
input[type="text"]:focus {
outline: none; border-color: #00BFFF;
box-shadow: 0 0 15px rgba(0, 191, 255, 0.4);
background-color: rgba(0, 20, 40, 0.8);
}
button {
width: 100%; padding: 15px;
border-radius: 8px; border: none;
background: linear-gradient(90deg, #00BFFF, #0077FF);
color: #fff; font-size: 18px; font-weight: 900;
cursor: pointer; transition: all 0.3s; margin-bottom: 15px;
font-family: 'Orbitron', sans-serif; letter-spacing: 2px;
box-shadow: 0 4px 15px rgba(0, 120, 255, 0.4);
text-transform: uppercase;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 191, 255, 0.6);
background: linear-gradient(90deg, #33CCFF, #0099FF);
}
hr {
border: 0; height: 1px;
background-image: linear-gradient(to right, rgba(0, 255, 255, 0), rgba(0, 255, 255, 0.5), rgba(0, 255, 255, 0));
margin: 25px 0;
}
#game-info {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
color: white; font-size: 24px; text-shadow: 0 0 10px #00BFFF; font-weight: bold; letter-spacing: 2px; font-family: 'Orbitron', sans-serif;
}
#scoreboard, #respawn-message { position: absolute; }
#scoreboard { top: 20px; left: 20px; background: rgba(0, 0, 0, 0.5); color: white; padding: 15px 20px; border-radius: 10px; border: 1px solid rgba(0, 255, 255, 0.3); font-size: 18px; line-height: 1.6; text-shadow: 0 0 8px #00BFFF; font-family: 'Exo 2', sans-serif; }
#helpBtn {
position: absolute; top: 20px; right: 20px; width: auto; /* Override global button width */
background: rgba(0, 0, 0, 0.5); color: #00BFFF; border: 1px solid rgba(0, 255, 255, 0.3);
padding: 8px 12px; /* Reduced padding */ border-radius: 5px; cursor: pointer;
font-family: 'Orbitron', sans-serif; font-weight: bold; transition: all 0.2s;
}
#helpBtn:hover { background: rgba(0, 255, 255, 0.2); color: #fff; }
#controls {
position: absolute; top: 60px; right: 20px; width: 280px;
background: rgba(10, 20, 30, 0.95); color: #ccc;
padding: 20px; border-radius: 10px; border: 1px solid #00BFFF;
font-size: 14px; line-height: 1.5; font-family: 'Exo 2', sans-serif;
text-align: left; z-index: 50; box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
#controls p { margin: 8px 0; }
#controls b { color: #00BFFF; font-family: 'Orbitron', sans-serif; text-shadow: 0 0 5px #00BFFF; }
#respawn-message { top: 50%; left: 50%; transform: translate(-50%, -50%); color: #FF6347; font-size: 64px; font-weight: 900; font-family: 'Orbitron', sans-serif; text-shadow: 0 0 20px rgba(255, 99, 71, 0.6); }
#playerNameInput { text-transform: none; }
#gameOverScreen {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center; background: rgba(10, 20, 30, 0.95);
padding: 60px; border-radius: 20px; border: 2px solid #00BFFF;
display: none; z-index: 100;
}
#gameOverTitle {
font-family: 'Orbitron', sans-serif; font-size: 72px; font-weight: 900;
margin: 0 0 30px 0; letter-spacing: 5px;
}
.win-text { color: #FFD700; text-shadow: 0 0 20px #FFD700; }
.lose-text { color: #FF6347; text-shadow: 0 0 20px #FF6347; }
#roomListContainer { margin-top: 20px; text-align: left; }
#roomListContainer h3 { font-size: 16px; color: #00BFFF; margin-bottom: 10px; font-family: 'Orbitron', sans-serif; text-transform: uppercase; }
#roomList { max-height: 150px; overflow-y: auto; border: 1px solid rgba(0, 255, 255, 0.3); border-radius: 5px; background: rgba(0, 0, 0, 0.3); }
.room-item { padding: 10px; border-bottom: 1px solid rgba(0, 255, 255, 0.1); cursor: pointer; display: flex; justify-content: space-between; transition: background 0.2s; }
.room-item:last-child { border-bottom: none; }
.room-item:hover { background: rgba(0, 255, 255, 0.1); }
.room-code { font-weight: bold; color: #fff; }
.player-count { color: #aaa; font-size: 0.9em; }
#roomList::-webkit-scrollbar { width: 5px; }
#roomList::-webkit-scrollbar-track { background: rgba(0,0,0,0.1); }
#roomList::-webkit-scrollbar-thumb { background: rgba(0, 255, 255, 0.3); border-radius: 3px; }
/* Mobile Controls */
#mobileControls { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 200; pointer-events: none; }
@media (pointer: coarse) {
#mobileControls.active { display: block; }
}
.control-btn {
position: absolute;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(0, 255, 255, 0.3);
border-radius: 15px;
color: rgba(255, 255, 255, 0.7);
font-family: 'Orbitron', sans-serif;
font-size: 24px;
font-weight: bold;
display: flex; align-items: center; justify-content: center;
pointer-events: auto; touch-action: none; user-select: none;
transition: all 0.1s;
backdrop-filter: blur(4px);
}
.control-btn:active, .control-btn.active {
background: rgba(0, 255, 255, 0.3);
transform: scale(0.95);
color: #fff;
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
}
/* Left Side: Rotation */
#rotateLeftBtn { bottom: 40px; left: 20px; width: 80px; height: 80px; border-radius: 50% 0 0 50%; }
#rotateRightBtn { bottom: 40px; left: 110px; width: 80px; height: 80px; border-radius: 0 50% 50% 0; }
/* Right Side: Action Cluster */
#thrustBtn {
bottom: 40px; right: 130px; width: 90px; height: 90px;
border-color: rgba(0, 255, 100, 0.5); color: #00FF64;
border-radius: 50%;
}
#thrustBtn:active, #thrustBtn.active { background: rgba(0, 255, 100, 0.3); box-shadow: 0 0 15px #00FF64; }
#fireBtn {
bottom: 110px; right: 30px; width: 80px; height: 80px;
background: rgba(255, 50, 50, 0.2); border-color: rgba(255, 50, 50, 0.5); color: #FF3232;
border-radius: 50%;
}
#fireBtn:active, #fireBtn.active { background: rgba(255, 50, 50, 0.5); box-shadow: 0 0 15px #FF3232; }
#secondaryBtn {
bottom: 40px; right: 30px; width: 70px; height: 70px;
background: rgba(255, 165, 0, 0.2); border-color: rgba(255, 165, 0, 0.5); color: #FFA500;
border-radius: 50%;
font-size: 18px;
}
#secondaryBtn:active, #secondaryBtn.active { background: rgba(255, 165, 0, 0.5); box-shadow: 0 0 15px #FFA500; }
</style>
</head>
<body>
<div id="lobby">
<h1>Vector Void</h1>
<input type="text" id="playerNameInput" placeholder="Enter Your Name" maxlength="12">
<div style="margin-bottom: 15px; text-align: left; padding-left: 10px; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center;">
<label for="wallCollisionsInput" style="color: #00BFFF; font-family: 'Exo 2', sans-serif; font-size: 16px; cursor: pointer; margin-right: 8px; white-space: nowrap;">Wall Collisions 🧱</label>
<input type="checkbox" id="wallCollisionsInput" checked>
</div> <div style="display: flex; align-items: center;">
<label for="winScoreInput" style="color: #00BFFF; font-family: 'Exo 2', sans-serif; font-size: 16px; margin-right: 5px; white-space: nowrap;">Frag Limit 💀</label>
<input type="number" id="winScoreInput" value="3" min="1" max="99" style="width: 50px; padding: 5px; text-align: center;">
</div>
</div>
<button id="createGameBtn">Create New Game</button>
<hr>
<div class="join-section">
<input type="text" id="roomCodeInput" placeholder="Enter Room Code" maxlength="4">
<button id="joinGameBtn">Join Game</button>
</div>
<div id="roomListContainer">
<h3>Active Rooms</h3>
<div id="roomList"></div>
</div>
</div>
<div id="game-info" style="display: none;"></div>
<div id="scoreboard" style="display: none;"></div>
<button id="helpBtn" style="display: none;">INFO </button>
<div id="controls" style="display: none;">
<div style="text-align: right;"><button id="closeControlsBtn" style="background:none; border:none; color: #FF6347; cursor: pointer; font-size: 20px;"></button></div>
<p><b style="font-size: 18px; border-bottom: 1px solid #00BFFF; display:block; margin-bottom: 10px;">FLIGHT MANUAL</b></p>
<p><b>CONTROLS</b></p>
<p>Thrust: ▲ Arrow Up</p>
<p>Turn: ◄ Left / Right ►</p>
<p>Shoot: Spacebar</p>
<p>Secondary: Shift</p>
<br>
<p><b>MECHANICS</b></p>
<p><b>Landing:</b> Find the green pad. Approach slowly, upright, and without thrust to land.</p>
<p><b>Restock:</b> Landing repairs your ship and restocks your Mines (Secondary).</p>
<p><b>Pickups:</b> Collect items for special weapons like Missiles.</p>
<p><b>Winning:</b> Reach the Frag Limit to win the round.</p>
</div>
<div id="respawn-message" style="display: none;">RESPAWNING...</div>
<div id="gameOverScreen">
<h1 id="gameOverTitle">YOU WON</h1>
<button id="returnLobbyBtn">Return to Lobby</button>
</div>
<canvas id="gameCanvas" style="display: none;"></canvas>
<div id="mobileControls">
<div id="rotateLeftBtn" class="control-btn">&lt;</div>
<div id="rotateRightBtn" class="control-btn">&gt;</div>
<div id="thrustBtn" class="control-btn"></div>
<div id="fireBtn" class="control-btn">A</div>
<div id="secondaryBtn" class="control-btn">B</div>
</div>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// --- AUDIO SYSTEM ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const SoundFx = {
shoot: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(400, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
},
secondary: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
osc.frequency.linearRampToValueAtTime(50, audioCtx.currentTime + 0.3);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
},
missileLaunch: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(100, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.2);
filter.type = 'bandpass';
filter.frequency.value = 500;
filter.Q.value = 2; // Resonance
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4);
osc.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
},
explode: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const bufferSize = audioCtx.sampleRate * 0.5; // 0.5 sec
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5);
noise.connect(gain);
gain.connect(audioCtx.destination);
noise.start();
},
pickup: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
osc.frequency.linearRampToValueAtTime(1200, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.1);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
},
missilePickup: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc1 = audioCtx.createOscillator();
const osc2 = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(800, audioCtx.currentTime);
osc1.frequency.exponentialRampToValueAtTime(1600, audioCtx.currentTime + 0.15);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(400, audioCtx.currentTime);
osc2.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.15);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(audioCtx.destination);
osc1.start();
osc2.start();
osc1.stop(audioCtx.currentTime + 0.2);
osc2.stop(audioCtx.currentTime + 0.2);
},
impact: () => {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(150, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
},
startThrottle: () => {
if (!throttleOsc) {
if(audioCtx.state === 'suspended') audioCtx.resume();
// Noise Buffer for Rocket Sound
const bufferSize = audioCtx.sampleRate * 2;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
throttleOsc = audioCtx.createBufferSource();
throttleOsc.buffer = buffer;
throttleOsc.loop = true;
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 600;
throttleGain = audioCtx.createGain();
throttleGain.gain.value = 0;
throttleOsc.connect(filter);
filter.connect(throttleGain);
throttleGain.connect(audioCtx.destination);
throttleOsc.start();
}
throttleGain.gain.setTargetAtTime(0.2, audioCtx.currentTime, 0.1);
},
stopThrottle: () => {
if (throttleGain) {
throttleGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1);
}
}
};
let throttleOsc = null;
let throttleGain = null;
// Helper to detect touch devices (coarse pointers)
const isCoarsePointer = () => window.matchMedia('(pointer: coarse)').matches;
let currentViewZoom = 1.0; // 1.0 for desktop, smaller for mobile to zoom out
// --- THREE.JS BACKGROUND SETUP ---
let scene, threeCamera, renderer, terrain, sky, terrainPos, terrainInitialZ, skyPos, skyInitialZ, time = 0;
let lobbyAnimationId = null; // To control lobby animation frame
function initThreeJSBackground() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x020218); // Darker, richer background
scene.fog = new THREE.FogExp2(0x050525, 0.015); // Deeper blue fog, slightly denser
const fov = isCoarsePointer() ? 90 : 75; // Wider FOV for mobile
threeCamera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.top = '0';
renderer.domElement.style.left = '0';
renderer.domElement.style.zIndex = '-1';
document.body.appendChild(renderer.domElement);
// 1. Bottom Terrain Grid (Wavy)
const terrainGeo = new THREE.PlaneGeometry(300, 300, 50, 50);
const terrainMat = new THREE.MeshBasicMaterial({
color: 0x00DFFF, // Brighter cyan
wireframe: true,
transparent: true,
opacity: 0.3 // Increased opacity
});
terrain = new THREE.Mesh(terrainGeo, terrainMat);
terrain.rotation.x = -Math.PI / 2;
terrain.position.y = -30;
scene.add(terrain);
// 2. Top Sky Grid (Nebula-ish)
const skyGeo = new THREE.PlaneGeometry(300, 300, 30, 30);
const skyMat = new THREE.MeshBasicMaterial({
color: 0xEE00FF, // More vibrant magenta
wireframe: true,
transparent: true,
opacity: 0.2 // Increased opacity
});
sky = new THREE.Mesh(skyGeo, skyMat); sky.rotation.x = Math.PI / 2;
sky.position.y = 30;
scene.add(sky);
threeCamera.position.z = 50;
threeCamera.position.y = 0;
// Store original positions for animation
terrainPos = terrainGeo.attributes.position;
terrainInitialZ = [];
for (let i = 0; i < terrainPos.count; i++) terrainInitialZ.push(terrainPos.getZ(i));
skyPos = skyGeo.attributes.position;
skyInitialZ = [];
for (let i = 0; i < skyPos.count; i++) skyInitialZ.push(skyPos.getZ(i));
}
function updateThreeJSBackground() {
time += 0.01;
// Animate Terrain (Wavy)
for (let i = 0; i < terrainPos.count; i++) {
const x = terrainPos.getX(i);
const y = terrainPos.getY(i);
// Sine wave movement based on X, Y and Time
const z = terrainInitialZ[i] + Math.sin(x / 15 + time) * Math.cos(y / 15 + time) * 8;
terrainPos.setZ(i, z);
}
terrainPos.needsUpdate = true;
// Animate Sky (Slow drift)
for (let i = 0; i < skyPos.count; i++) {
const x = skyPos.getX(i);
const y = skyPos.getY(i);
const z = skyInitialZ[i] + Math.sin(x / 25 - time * 0.5) * 5;
skyPos.setZ(i, z);
}
skyPos.needsUpdate = true;
// Rotate grids slightly for dynamic feel
terrain.rotation.z = time * 0.02;
sky.rotation.z = -time * 0.01;
renderer.render(scene, threeCamera);
}
function animateLobbyBackground() {
if (!gameRunning) { // Only animate lobby background if game is not running
updateThreeJSBackground();
lobbyAnimationId = requestAnimationFrame(animateLobbyBackground);
}
}
// Resize handler for Three.js
function handleResize() {
threeCamera.aspect = window.innerWidth / window.innerHeight;
threeCamera.fov = isCoarsePointer() ? 90 : 75; // Adjust FOV on resize
threeCamera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
// Also resize the 2D canvas if game is running
if (gameRunning) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
}
window.addEventListener('resize', handleResize);
// --- GAME LOGIC ---
const isNginx = window.location.pathname.includes('/vectorvoid');
const socket = io({
// If on Nginx, add the folder prefix. If on IP, use default.
path: isNginx ? '/vectorvoid/socket.io' : '/socket.io'
});
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const lobby = document.getElementById('lobby');
const createGameBtn = document.getElementById('createGameBtn');
const roomCodeInput = document.getElementById('roomCodeInput');
const playerNameInput = document.getElementById('playerNameInput');
const joinGameBtn = document.getElementById('joinGameBtn');
const scoreboardDiv = document.getElementById('scoreboard');
const respawnMessageDiv = document.getElementById('respawn-message');
const gameInfoDiv = document.getElementById('game-info');
const roomListDiv = document.getElementById('roomList');
let worldWidth, worldHeight;
let players = {}, pellets = [], mines = [], pickups = [], missiles = [], particles = [], thrustParticles = [];
let roofPoints = [], floorPoints = [], leftWallPoints = [], rightWallPoints = [], islands = [];
let localPlayerId = null;
let landingZone = null;
let screenShake = 0;
let winScore = 10;
const camera = { x: 0, y: 0 };
const keys = { left: false, right: false, up: false };
let gameRunning = false;
let lastShotTime = 0;
let touchActiveCount = 0; // Tracks how many touch buttons are currently active
let touchInputActive = false; // True if any touch movement input is active
socket.on('roomListUpdate', (rooms) => {
roomListDiv.innerHTML = '';
if (rooms.length === 0) {
roomListDiv.innerHTML = '<div style="padding:10px; color:#777; text-align:center;">No active rooms</div>';
return;
}
rooms.forEach(room => {
const div = document.createElement('div');
div.className = 'room-item';
div.innerHTML = `<span class="room-code">${room.code}</span> <span class="player-count">${room.count} Players</span>`;
div.onclick = () => {
roomCodeInput.value = room.code;
};
roomListDiv.appendChild(div);
});
});
class Particle {
constructor(x, y, options) {
this.x = x; this.y = y;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * (options.maxSpeed - options.minSpeed) + options.minSpeed;
this.velocityX = Math.cos(angle) * speed; this.velocityY = Math.sin(angle) * speed;
this.lifespan = options.lifespan; this.initialLifespan = options.lifespan;
this.color = `hsl(${options.hue}, 100%, ${Math.random() * 30 + 60}%)`;
this.gravity = options.gravity || 0;
this.size = Math.random() * 3 + 1;
}
update() { this.velocityY += this.gravity; this.x += this.velocityX; this.y += this.velocityY; this.lifespan--; }
draw() {
ctx.globalAlpha = this.lifespan / this.initialLifespan;
ctx.fillStyle = this.color;
ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill();
}
}
function createExplosion(x, y) { const o = { count: 60, minSpeed: 2, maxSpeed: 10, lifespan: 90, hue: 180 }; for (let i = 0; i < o.count; i++) particles.push(new Particle(x, y, o)); }
function createSubtleExplosion(x, y) { const o = { count: 20, minSpeed: 1, maxSpeed: 4, lifespan: 60, hue: 40 }; for (let i = 0; i < o.count; i++) particles.push(new Particle(x, y, o)); }
function createThrustParticle(player) {
const angle = player.rotation + Math.PI + (Math.random() - 0.5) * 0.4;
const x = player.x - Math.cos(player.rotation) * 14; const y = player.y - Math.sin(player.rotation) * 14;
const o = { count: 1, minSpeed: 2, maxSpeed: 4, lifespan: 20, hue: 170, gravity: 0.01 };
const p = new Particle(x, y, o);
p.velocityX = Math.cos(angle) * 3; p.velocityY = Math.sin(angle) * 3;
thrustParticles.push(p);
}
function startGame(roomCode) {
lobby.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('mobileControls').classList.add('active');
gameInfoDiv.style.display = 'block';
document.getElementById('scoreboard').style.display = 'block';
document.getElementById('helpBtn').style.display = 'block';
currentViewZoom = isCoarsePointer() ? 0.7 : 1.0; // Zoom out for mobile
if (lobbyAnimationId) {
cancelAnimationFrame(lobbyAnimationId); // Stop lobby animation
lobbyAnimationId = null;
}
// document.getElementById('controls').style.display = 'block'; // Don't show by default
gameInfoDiv.innerText = `Room Code: ${roomCode}`;
if (!gameRunning) {
gameRunning = true;
handleResize(); // Ensure canvas is sized correctly for game
gameLoop();
}
}
document.getElementById('helpBtn').addEventListener('click', () => {
const controls = document.getElementById('controls');
controls.style.display = (controls.style.display === 'none') ? 'block' : 'none';
});
document.getElementById('closeControlsBtn').addEventListener('click', () => {
document.getElementById('controls').style.display = 'none';
});
socket.on('connect', () => { localPlayerId = socket.id; });
socket.on('gameStarted', startGame);
socket.on('unknownGame', () => { alert("That room does not exist."); });
socket.on('currentPlayers', (data) => {
players = data.players; roofPoints = data.roof; floorPoints = data.floor;
islands = data.islands; leftWallPoints = data.leftWall; rightWallPoints = data.rightWall;
worldWidth = data.mapWidth; worldHeight = data.mapHeight;
landingZone = data.landingZone;
winScore = data.winScore || 10;
updateScoreboard();
});
socket.on('newPlayer', (p) => { players[p.id] = p; });
socket.on('playerDisconnected', (id) => { delete players[id]; });
socket.on('gameState', (gs) => { players = gs.players; pellets = gs.pellets; mines = gs.mines; pickups = gs.pickups; missiles = gs.missiles; updateScoreboard(); });
socket.on('playerDestroyed', (data) => {
SoundFx.explode();
createExplosion(data.x, data.y);
const lp = players[localPlayerId];
if (lp && lp.id === data.playerId && !lp.isDead) {
screenShake = 15;
if (lp.id === localPlayerId) SoundFx.stopThrottle(); // Stop throttle on death
}
});
socket.on('pickupCollected', (data) => {
// Check if WE collected it
if (data.playerId === localPlayerId) {
if (data.type === 'missile') SoundFx.missilePickup();
else SoundFx.pickup();
}
// Optional: Add visual effect for pickup at data.x, data.y
});
socket.on('pelletImpact', (data) => {
SoundFx.impact();
createSubtleExplosion(data.x, data.y);
});
socket.on('gameOver', (winnerId) => {
const screen = document.getElementById('gameOverScreen');
const title = document.getElementById('gameOverTitle');
screen.style.display = 'block';
if (winnerId === localPlayerId) {
title.innerText = "YOU WON";
title.className = 'win-text';
} else {
const winner = players[winnerId];
const winnerName = winner ? (winner.name || 'Player ' + winnerId.substring(0, 4)) : 'Unknown';
title.innerText = `${winnerName} WINS`;
title.className = 'lose-text';
}
});
document.getElementById('returnLobbyBtn').addEventListener('click', () => {
location.reload(); // Simplest way to reset everything and return to lobby
});
function updateScoreboard() {
let scoreHtml = `<b>SCORE (Goal: ${winScore})</b><br>`;
const sortedPlayers = Object.values(players).sort((a, b) => b.score - a.score);
sortedPlayers.forEach(player => {
const isLocal = player.id === localPlayerId;
const displayName = player.name || 'P' + player.id.substring(0, 4);
const playerEmoji = player.emoji ? player.emoji + ' ' : '';
scoreHtml += `<span style="color: ${isLocal ? '#FFD700' : '#E5E4E2'}; font-weight: ${isLocal ? 'bold' : 'normal'};">[${player.score}] ${playerEmoji}${displayName}</span><br>`;
});
scoreboardDiv.innerHTML = scoreHtml;
}
createGameBtn.addEventListener('click', () => {
const name = playerNameInput.value.trim() || 'P' + socket.id.substring(0, 4);
const wallCollisions = document.getElementById('wallCollisionsInput').checked;
const winScore = parseInt(document.getElementById('winScoreInput').value) || 10;
socket.emit('createGame', { playerName: name, enableWallCollisions: wallCollisions, winScore: winScore });
});
joinGameBtn.addEventListener('click', () => {
const rc = roomCodeInput.value.trim().toUpperCase();
const name = playerNameInput.value.trim() || 'P' + socket.id.substring(0, 4);
if (rc) socket.emit('joinRoom', { roomCode: rc, playerName: name });
});
window.addEventListener('keydown', (e) => {
const localPlayer = players[localPlayerId]; // Get local player for checks
if (e.key === 'ArrowLeft') keys.left = true; if (e.key === 'ArrowRight') keys.right = true;
if (e.key === 'ArrowUp') {
if (localPlayer && !localPlayer.isDead && !keys.up) SoundFx.startThrottle(); // Only start if not dead and not already pressed
keys.up = true;
}
if (e.key === ' ') {
const now = Date.now();
if (localPlayer && !localPlayer.isDead && (now - lastShotTime > 250)) {
socket.emit('shoot');
SoundFx.shoot();
lastShotTime = now;
}
}
if (e.key === 'Shift') {
// localPlayer already fetched above
if (localPlayer && !localPlayer.isDead && localPlayer.secondaryAmmo > 0) {
socket.emit('fireSecondary');
if (localPlayer.secondaryType === 'missile') {
SoundFx.missileLaunch();
} else { // mine
SoundFx.secondary();
}
}
}
});
window.addEventListener('keyup', (e) => {
const localPlayer = players[localPlayerId]; // Get local player for checks
if (e.key === 'ArrowLeft') keys.left = false; if (e.key === 'ArrowRight') keys.right = false;
if (e.key === 'ArrowUp') {
keys.up = false;
// Only stop if the throttle was actually running and player isn't dead
if (localPlayer && !localPlayer.isDead) SoundFx.stopThrottle();
}
});
function drawBackground() {
// Update Three.js camera based on game camera
// Game camera coords are ~0-1600. Three.js grid is small (-100 to 100).
// Scale factor:
const scale = 0.05;
threeCamera.position.x = camera.x * scale;
threeCamera.position.y = (-camera.y * scale); // Invert Y for 3D space
updateThreeJSBackground();
}
function drawVignette() {
// Draw Static World Gradient
// This is drawn INSIDE the camera transform (before ctx.restore), so it stays fixed to the map.
if (!worldWidth || !worldHeight) return;
const cx = worldWidth / 2;
const cy = worldHeight / 2;
const radius = Math.max(worldWidth, worldHeight) * 0.85;
const gradient = ctx.createRadialGradient(cx, cy, 100, cx, cy, radius);
gradient.addColorStop(0, 'rgba(0,0,0,0)'); // Clear center
gradient.addColorStop(0.4, 'rgba(0,0,0,0.1)');
gradient.addColorStop(1, 'rgba(0,0,0,0.95)'); // Dark edges
ctx.fillStyle = gradient;
ctx.fillRect(-1000, -1000, worldWidth + 2000, worldHeight + 2000); // Draw slightly oversize to catch corners
}
function drawTerrain() {
// 1. STROKES FIRST (Background Layer)
ctx.strokeStyle = '#5c6370';
ctx.lineWidth = 6;
// Helper for open lines (Walls)
function drawLineStrip(points) {
if (!points || points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
}
// Helper for closed loops (Islands)
function drawClosedLoop(points) {
if (!points || points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.stroke();
}
drawLineStrip(roofPoints);
drawLineStrip(floorPoints);
drawLineStrip(leftWallPoints);
drawLineStrip(rightWallPoints);
islands.forEach(island => drawClosedLoop(island));
// 2. FILLS SECOND (Foreground Layer)
ctx.fillStyle = '#282c34';
function fillWall(points, type) {
if (!points || points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
// Close to World Edges
if (type === 'roof') { ctx.lineTo(worldWidth, 0); ctx.lineTo(0, 0); }
if (type === 'floor') { ctx.lineTo(worldWidth, worldHeight); ctx.lineTo(0, worldHeight); }
if (type === 'left') { ctx.lineTo(0, worldHeight); ctx.lineTo(0, 0); }
if (type === 'right') { ctx.lineTo(worldWidth, worldHeight); ctx.lineTo(worldWidth, 0); }
ctx.closePath();
ctx.fill();
}
fillWall(roofPoints, 'roof');
fillWall(floorPoints, 'floor');
fillWall(leftWallPoints, 'left');
fillWall(rightWallPoints, 'right');
// Fill the outer void (areas outside the map boundaries)
// This ensures the camera doesn't see the "transparency" (3D background) when looking outside the map
const voidSize = 20000;
ctx.fillRect(-voidSize, -voidSize, worldWidth + voidSize * 2, voidSize); // Top
ctx.fillRect(-voidSize, worldHeight, worldWidth + voidSize * 2, voidSize); // Bottom
ctx.fillRect(-voidSize, 0, voidSize, worldHeight); // Left
ctx.fillRect(worldWidth, 0, voidSize, worldHeight); // Right
islands.forEach(island => {
if (!island || island.length < 2) return;
ctx.beginPath();
ctx.moveTo(island[0].x, island[0].y);
for (let i = 1; i < island.length; i++) ctx.lineTo(island[i].x, island[i].y);
ctx.closePath();
ctx.fill();
});
}
function drawLandingZone() {
if (!landingZone) return;
ctx.save();
ctx.fillStyle = 'rgba(0, 255, 100, 0.2)';
ctx.fillRect(landingZone.x, landingZone.y - landingZone.height, landingZone.width, landingZone.height);
ctx.strokeStyle = 'rgba(0, 255, 100, 0.7)';
ctx.lineWidth = 2;
ctx.strokeRect(landingZone.x, landingZone.y - landingZone.height, landingZone.width, landingZone.height);
ctx.restore();
}
function drawPlayer(player) {
if (player.isDead) return;
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.rotation);
// Draw Triangle Ship
ctx.beginPath();
ctx.moveTo(15, 0); // Tip
ctx.lineTo(-10, -10); // Back Left
ctx.lineTo(-10, 10); // Back Right
ctx.closePath();
// Color logic: Gold for me, Red for enemies
if (player.id === localPlayerId) {
ctx.fillStyle = '#FFD700';
ctx.shadowColor = '#FFA500';
} else {
ctx.fillStyle = '#FF4444';
ctx.shadowColor = '#8B0000';
}
ctx.shadowBlur = 10;
ctx.fill();
// Cockpit window (visual flair)
ctx.fillStyle = '#00FFFF';
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI*2);
ctx.fill();
// Thrust flame (if moving)
// We can check velocity magnitude or add an 'isThrusting' flag later
// For now, we just rely on particles for the thrust effect
ctx.restore();
// Draw Player Name/Score above ship
ctx.fillStyle = 'white';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
// Don't draw text if dead or rotated weirdly, keep it simple
const displayName = player.name || "P-" + player.id.substring(0, 2);
let weaponInfo = '';
if (player.secondaryType === 'mine') weaponInfo = `[MINE:${player.secondaryAmmo}]`;
else if (player.secondaryType === 'missile') weaponInfo = `[MSL:${player.secondaryAmmo}]`;
else if (player.secondaryAmmo !== undefined) weaponInfo = `[${player.secondaryAmmo}]`; // Fallback
const playerText = `${player.emoji ? player.emoji + ' ' : ''}${displayName} ${weaponInfo}`.trim();
ctx.fillText(playerText, player.x, player.y - 25);
}
function drawMine(mine) {
ctx.save();
ctx.translate(mine.x, mine.y);
// Pulsing effect
const pulse = Math.sin(Date.now() / 200) * 2;
ctx.fillStyle = '#FF4500'; // Orange-Red
ctx.shadowColor = '#FF0000';
ctx.shadowBlur = 15;
ctx.beginPath();
// Draw spiky shape
const spikes = 8;
const outerRadius = 10 + pulse;
const innerRadius = 5;
for (let i = 0; i < spikes * 2; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const angle = (i / (spikes * 2)) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawPickup(pickup) {
ctx.save();
ctx.translate(pickup.x, pickup.y);
// Floating effect
const floatY = Math.sin(Date.now() / 300) * 5;
ctx.translate(0, floatY);
// Glow
ctx.shadowColor = '#00FF00';
ctx.shadowBlur = 20;
ctx.fillStyle = '#00FF00'; // Bright Green for generic pickup, or change based on type
if (pickup.type === 'missile') {
ctx.fillStyle = '#FF00FF'; // Magenta for Missile
ctx.shadowColor = '#FF00FF';
// Draw Missile Icon (Simple Rocket shape)
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.lineTo(5, 5);
ctx.lineTo(0, 2);
ctx.lineTo(-5, 5);
ctx.closePath();
ctx.fill();
// Box around it
ctx.strokeStyle = '#FF00FF';
ctx.lineWidth = 2;
ctx.strokeRect(-12, -12, 24, 24);
}
ctx.restore();
}
function drawMissile(missile) {
ctx.save();
ctx.translate(missile.x, missile.y);
ctx.rotate(missile.rotation);
// Missile Body
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.moveTo(10, 0);
ctx.lineTo(-5, -3);
ctx.lineTo(-5, 3);
ctx.closePath();
ctx.fill();
// Fins
ctx.fillStyle = '#FF00FF';
ctx.fillRect(-5, -5, 3, 10);
// Thruster trail (simple)
ctx.fillStyle = '#FFA500';
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(-8, 0, Math.random() * 3 + 2, 0, Math.PI*2);
ctx.fill();
ctx.globalAlpha = 1.0;
ctx.restore();
}
function drawPellet(pellet) {
ctx.save();
ctx.translate(pellet.x, pellet.y);
ctx.fillStyle = '#00FFFF';
ctx.shadowColor = '#00FFFF';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(0, 0, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function gameLoop() {
// CLEAR Screen before any transforms
ctx.clearRect(0, 0, canvas.width, canvas.height);
const localPlayer = players[localPlayerId];
if(localPlayer && localPlayer.isDead) respawnMessageDiv.style.display = 'block';
else respawnMessageDiv.style.display = 'none';
ctx.save();
if (screenShake > 0) { ctx.translate(Math.random() * screenShake - screenShake / 2, Math.random() * screenShake - screenShake / 2); screenShake *= 0.9; }
if(localPlayer) {
// Adjust camera centering based on current zoom level
camera.x += (localPlayer.x - camera.x - (canvas.width / currentViewZoom) / 2) * 0.1;
camera.y += (localPlayer.y - camera.y - (canvas.height / currentViewZoom) / 2) * 0.1;
}
ctx.scale(currentViewZoom, currentViewZoom); // Apply zoom first
ctx.translate(-camera.x, -camera.y); // Then translate relative to world
drawBackground();
drawTerrain();
drawLandingZone();
if (mines) mines.forEach(drawMine);
if (pickups) pickups.forEach(drawPickup);
if (missiles) missiles.forEach(drawMissile);
for (const id in players) drawPlayer(players[id]);
ctx.globalCompositeOperation = 'lighter';
[...particles, ...thrustParticles].forEach((p, index, arr) => { p.update(); if (p.lifespan <= 0) arr.splice(index, 1); else p.draw(); });
ctx.globalAlpha = 1; // CRITICAL: Reset alpha, otherwise next draws inherit last particle's fade
for (const pellet of pellets) drawPellet(pellet);
ctx.globalCompositeOperation = 'source-over';
drawVignette(); // Draw world-fixed lighting layer
ctx.restore(); // Restore context after scaling and translations
if(localPlayer && !localPlayer.isDead && (keys.left || keys.right || keys.up)) {
socket.emit('playerMovement', { ...keys, isTouch: touchInputActive });
if (keys.up) createThrustParticle(localPlayer);
}
requestAnimationFrame(gameLoop);
}
// --- MOBILE CONTROLS ---
function initMobileControls() {
// Check for touch capability
if (navigator.maxTouchPoints <= 0 && !/Android|iPhone|iPad/i.test(navigator.userAgent)) return;
const rotateLeftBtn = document.getElementById('rotateLeftBtn');
const rotateRightBtn = document.getElementById('rotateRightBtn');
const thrustBtn = document.getElementById('thrustBtn');
const fireBtn = document.getElementById('fireBtn');
const secondaryBtn = document.getElementById('secondaryBtn');
if (!rotateLeftBtn || !fireBtn) return;
// Helper to bind press/release
const bindBtn = (btn, onPress, onRelease, isDirectional = false) => {
const handleStart = (e) => {
e.preventDefault(); // Prevent scroll/zoom
btn.classList.add('active');
if (isDirectional) {
touchActiveCount++;
touchInputActive = true;
}
if (onPress) onPress(e);
};
const handleEnd = (e) => {
e.preventDefault();
btn.classList.remove('active');
if (isDirectional) {
touchActiveCount--;
if (touchActiveCount <= 0) {
touchActiveCount = 0;
touchInputActive = false;
}
}
if (onRelease) onRelease(e);
};
btn.addEventListener('touchstart', handleStart, { passive: false });
btn.addEventListener('touchend', handleEnd);
btn.addEventListener('touchcancel', handleEnd);
};
// Rotate Left
bindBtn(rotateLeftBtn,
() => { keys.left = true; },
() => { keys.left = false; },
true
);
// Rotate Right
bindBtn(rotateRightBtn,
() => { keys.right = true; },
() => { keys.right = false; },
true
);
// Thrust
bindBtn(thrustBtn,
() => {
keys.up = true;
const localPlayer = players[localPlayerId];
if (localPlayer && !localPlayer.isDead) SoundFx.startThrottle();
},
() => {
keys.up = false;
const localPlayer = players[localPlayerId];
if (localPlayer && !localPlayer.isDead) SoundFx.stopThrottle();
},
true
);
// Fire (Shoot)
bindBtn(fireBtn, () => {
const now = Date.now();
const localPlayer = players[localPlayerId];
if (localPlayer && !localPlayer.isDead && (now - lastShotTime > 250)) {
socket.emit('shoot');
SoundFx.shoot();
lastShotTime = now;
}
});
// Secondary
bindBtn(secondaryBtn, () => {
const localPlayer = players[localPlayerId];
if (localPlayer && !localPlayer.isDead && localPlayer.secondaryAmmo > 0) {
socket.emit('fireSecondary');
if (localPlayer.secondaryType === 'missile') {
SoundFx.missileLaunch();
} else {
SoundFx.secondary();
}
}
});
}
initMobileControls();
initThreeJSBackground();
animateLobbyBackground();
</script>
</body>
</html>