1166 lines
52 KiB
HTML
Executable File
1166 lines
52 KiB
HTML
Executable File
<!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"><</div>
|
||
<div id="rotateRightBtn" class="control-btn">></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> |