| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
| <title>Not Halo: Full Stack</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; touch-action: none; -webkit-touch-callout: none; } |
| body { overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui; user-select: none; } |
| canvas { display: block; } |
| |
| #ui { position: absolute; inset: 0; pointer-events: none; display: none; flex-direction: column; justify-content: space-between; padding: 15px; z-index: 10; } |
| #crosshair { position: absolute; top: 50%; left: 50%; width: 24px; height: 24px; transform: translate(-50%,-50%); pointer-events: none; z-index: 12; } |
| #crosshair::before, #crosshair::after { content:''; position:absolute; background:rgba(255,255,255,0.9); box-shadow:0 0 4px #000; } |
| #crosshair::before { top:11px; left:0; width:24px; height:2px; } |
| #crosshair::after { top:0; left:11px; width:2px; height:24px; } |
| |
| #scope { position:absolute; inset:0; display:none; z-index:11; pointer-events:none; } |
| #scope .ring { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:300px; height:300px; border:2px solid rgba(0,255,100,0.6); border-radius:50%; box-shadow:0 0 0 9999px rgba(0,10,0,0.85); } |
| #scope .cross { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:2px; height:60px; background:rgba(0,255,100,0.8); } |
| #scope .cross::after { content:''; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:60px; height:2px; background:rgba(0,255,100,0.8); } |
| |
| #hud-top { display:flex; justify-content:space-between; align-items:flex-start; } |
| #stats { background:rgba(0,0,0,0.5); padding:10px 14px; border-radius:8px; color:#fff; font-size:13px; line-height:1.6; border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(4px); } |
| #stats span { color:#00ffaa; font-weight:700; } |
| #weapon-info { margin-top:6px; color:#ffcc00; font-size:11px; letter-spacing:1px; text-transform:uppercase; } |
| #health-bar { width:180px; height:18px; background:rgba(0,0,0,0.5); border-radius:9px; overflow:hidden; border:1px solid rgba(255,255,255,0.15); margin-top:6px; } |
| #health-fill { width:100%; height:100%; background:linear-gradient(90deg,#ff2244,#ff8844); transition:width .2s; } |
| #health-text { color:#fff; font-size:11px; text-align:center; margin-top:3px; text-shadow:0 0 4px rgba(0,0,0,0.9); } |
| |
| #minimap { position:absolute; top:15px; right:15px; width:140px; height:140px; background:rgba(0,0,0,0.5); border-radius:50%; border:2px solid rgba(255,255,255,0.15); overflow:hidden; backdrop-filter:blur(2px); } |
| #minimap-canvas { width:100%; height:100%; border-radius:50%; } |
| |
| #ammo-counter { position:absolute; bottom:20px; right:20px; color:#fff; text-align:right; text-shadow:0 0 8px rgba(0,0,0,0.9); } |
| #ammo-current { font-size:42px; font-weight:800; line-height:1; } |
| #ammo-total { font-size:14px; opacity:.7; } |
| #reload-bar { width:80px; height:4px; background:rgba(255,255,255,0.2); border-radius:2px; margin-top:4px; margin-left:auto; overflow:hidden; display:none; } |
| #reload-fill { width:0%; height:100%; background:#ffcc00; transition:width .1s linear; } |
| |
| #weapon-model { position:absolute; bottom:-20px; left:50%; transform:translateX(-50%); width:120px; height:220px; z-index:8; pointer-events:none; perspective:400px; } |
| .gun-body { position:absolute; bottom:0; left:50%; transform:translateX(-50%) rotateX(-5deg); width:20px; height:160px; background:linear-gradient(90deg,#222,#444,#222); border-radius:6px 6px 0 0; transform-origin:bottom center; transition:transform .1s; } |
| .gun-barrel { position:absolute; top:-40px; left:50%; transform:translateX(-50%); width:10px; height:50px; background:#333; border-radius:4px; } |
| .gun-mag { position:absolute; bottom:20px; left:-8px; width:14px; height:50px; background:#2a2a2a; transform:rotate(10deg); border-radius:2px; } |
| .recoil { animation:recoil .12s ease-out; } |
| @keyframes recoil { 0%{transform:translateX(-50%) rotateX(-5deg) translateY(0);} 50%{transform:translateX(-50%) rotateX(15deg) translateY(20px);} 100%{transform:translateX(-50%) rotateX(-5deg) translateY(0);} } |
| |
| #damage-overlay { position:absolute; inset:0; background:rgba(255,0,0,0); pointer-events:none; z-index:5; transition:background .15s; } |
| .damage-text { position:absolute; color:#ff4444; font-weight:900; font-size:22px; pointer-events:none; z-index:15; animation:damageFloat .9s forwards; text-shadow:0 0 4px #000; } |
| @keyframes damageFloat { 0%{opacity:1;transform:translateY(0) scale(1);} 100%{opacity:0;transform:translateY(-60px) scale(1.4);} } |
| |
| #kill-feed { position:absolute; top:70px; right:15px; display:flex; flex-direction:column; gap:4px; align-items:flex-end; pointer-events:none; } |
| .kill-msg { background:rgba(0,0,0,0.5); color:#fff; padding:4px 10px; border-radius:4px; font-size:12px; animation:killSlide 4s forwards; border-left:2px solid #ffcc00; } |
| @keyframes killSlide { 0%{opacity:1;transform:translateX(0);} 80%{opacity:1;} 100%{opacity:0;transform:translateX(20px);} } |
| |
| #start-screen { position:absolute; inset:0; background:linear-gradient(135deg,#050510 0%,#101025 50%,#081018 100%); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:20; pointer-events:auto; cursor:default; } |
| #start-screen h1 { font-size:52px; color:#fff; text-transform:uppercase; letter-spacing:6px; text-shadow:0 0 30px rgba(0,200,255,0.4); background:linear-gradient(180deg,#fff 0%,#88ccff 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; text-align:center; } |
| #start-screen .subtitle { color:#88aacc; font-size:16px; margin:8px 0 30px; letter-spacing:2px; text-align:center; } |
| #start-btn { padding:16px 48px; font-size:18px; color:#fff; background:linear-gradient(135deg,#0066ff,#00ccff); border:none; border-radius:50px; cursor:pointer; text-transform:uppercase; letter-spacing:3px; font-weight:700; box-shadow:0 0 30px rgba(0,150,255,0.4); transition:transform .2s,box-shadow .2s; } |
| #start-btn:hover { transform:scale(1.05); box-shadow:0 0 40px rgba(0,200,255,0.6); } |
| .controls-info { margin-top:25px; color:#6688aa; font-size:13px; text-align:center; line-height:2.2; max-width:90vw; } |
| .controls-info kbd { background:rgba(255,255,255,0.1); padding:3px 10px; border-radius:4px; border:1px solid rgba(255,255,255,0.15); color:#aaccff; font-family:monospace; } |
| |
| #mobile-ui { display:none; position:absolute; inset:0; z-index:14; pointer-events:none; } |
| .touch-zone { position:absolute; pointer-events:auto; } |
| #stick-zone { left:0; bottom:0; width:50%; height:60%; } |
| #look-zone { right:0; bottom:0; width:50%; height:60%; } |
| #stick-knob { position:absolute; width:70px; height:70px; border-radius:50%; background:rgba(255,255,255,0.15); border:2px solid rgba(255,255,255,0.3); transform:translate(-50%,-50%); display:none; pointer-events:none; } |
| #stick-center { position:absolute; width:40px; height:40px; border-radius:50%; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); transform:translate(-50%,-50%); display:none; pointer-events:none; } |
| |
| #btn-fire { position:absolute; right:30px; bottom:140px; width:70px; height:70px; border-radius:50%; background:rgba(255,50,50,0.25); border:2px solid rgba(255,100,100,0.5); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:12px; letter-spacing:1px; } |
| #btn-jump { position:absolute; right:30px; bottom:230px; width:50px; height:50px; border-radius:50%; background:rgba(255,255,255,0.15); border:2px solid rgba(255,255,255,0.3); color:#fff; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; } |
| #btn-weapon { position:absolute; right:110px; bottom:140px; width:50px; height:50px; border-radius:50%; background:rgba(0,200,255,0.2); border:2px solid rgba(0,200,255,0.4); color:#fff; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; } |
| |
| #connection-status { position:absolute; top:15px; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.5); padding:4px 12px; border-radius:12px; font-size:11px; color:#888; z-index:16; pointer-events:none; } |
| .conn-online { color:#00ffaa !important; } |
| .conn-offline { color:#ff4444 !important; } |
| </style> |
| <script type="importmap">{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js","three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}</script> |
| </head> |
| <body> |
|
|
| <div id="ui"> |
| <div id="hud-top"> |
| <div> |
| <div id="stats"> |
| <div>Score: <span id="score">0</span></div> |
| <div>Kills: <span id="kills">0</span></div> |
| <div>Time: <span id="time">0:00</span></div> |
| <div id="weapon-info">AR-15 [1]</div> |
| </div> |
| <div style="margin-top:6px;"> |
| <div id="health-bar"><div id="health-fill"></div></div> |
| <div id="health-text">HEALTH 100%</div> |
| </div> |
| </div> |
| <div id="minimap"><canvas id="minimap-canvas" width="140" height="140"></canvas></div> |
| </div> |
|
|
| <div id="crosshair"></div> |
| <div id="scope"><div class="ring"></div><div class="cross"></div></div> |
| <div id="damage-overlay"></div> |
| <div id="kill-feed"></div> |
|
|
| <div id="ammo-counter"> |
| <div id="ammo-current">30</div> |
| <div id="ammo-total"><span id="ammo-reserve">120</span> reserve</div> |
| <div id="reload-bar"><div id="reload-fill"></div></div> |
| </div> |
|
|
| <div id="weapon-model"><div class="gun-body" id="gun"><div class="gun-barrel"></div><div class="gun-mag"></div></div></div> |
| <div id="connection-status">Single Player</div> |
| </div> |
|
|
| <div id="mobile-ui"> |
| <div class="touch-zone" id="stick-zone"></div> |
| <div class="touch-zone" id="look-zone"></div> |
| <div id="stick-center"></div> |
| <div id="stick-knob"></div> |
| <div class="touch-zone" id="btn-fire">FIRE</div> |
| <div class="touch-zone" id="btn-jump">JUMP</div> |
| <div class="touch-zone" id="btn-weapon">WEP</div> |
| </div> |
|
|
| <div id="start-screen"> |
| <h1>Neon Strike</h1> |
| <div class="subtitle">FPS • Multiplayer • Procedural • Mobile</div> |
| <button id="start-btn">Enter Combat</button> |
| <div class="controls-info" id="controls-desktop"> |
| <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> Weapons • |
| <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move • |
| <kbd>SPACE</kbd> Jump • |
| <kbd>CLICK</kbd> Shoot • |
| <kbd>R</kbd> Reload • |
| <kbd>RIGHT CLICK</kbd> Scope • |
| <kbd>SHIFT</kbd> Sprint |
| </div> |
| <div class="controls-info" id="controls-mobile" style="display:none;"> |
| Left side: Virtual Joystick • Right side: Drag to Look<br> |
| Red Button: Shoot • Blue Button: Switch Weapon • White Button: Jump |
| </div> |
| </div> |
|
|
| <script type="module"> |
| import * as THREE from 'three'; |
| import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; |
| |
| const CONFIG = { |
| wsUrl: (() => { |
| const isHF = location.hostname.includes('hf.space'); |
| const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| return isHF ? `${protocol}//${location.host}/ws` : 'ws://localhost:7860/ws'; |
| })(), |
| isMobile: /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.userAgent.toLowerCase()), |
| mapSize: 40, |
| cellSize: 5, |
| gravity: 28, |
| playerHeight: 1.7, |
| walkSpeed: 5.5, |
| sprintSpeed: 9, |
| jumpForce: 10, |
| botCount: 4 |
| }; |
| |
| class SoundSys { |
| constructor() { |
| this.ctx = null; |
| this.master = null; |
| this.initialized = false; |
| } |
| init() { |
| if (this.initialized) return; |
| this.ctx = new (window.AudioContext || window.webkitAudioContext)(); |
| this.master = this.ctx.createGain(); |
| this.master.gain.value = 0.4; |
| this.master.connect(this.ctx.destination); |
| this.initialized = true; |
| } |
| _noise(duration, freq1, type = 'bandpass', vol = 1) { |
| if (!this.ctx) return; |
| const t = this.ctx.currentTime; |
| const bufferSize = this.ctx.sampleRate * duration; |
| const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); |
| const data = buffer.getChannelData(0); |
| for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; |
| const src = this.ctx.createBufferSource(); |
| src.buffer = buffer; |
| const filter = this.ctx.createBiquadFilter(); |
| filter.type = type; |
| filter.frequency.value = freq1; |
| filter.Q.value = 1; |
| const gain = this.ctx.createGain(); |
| gain.gain.setValueAtTime(vol, t); |
| gain.gain.exponentialRampToValueAtTime(0.001, t + duration); |
| src.connect(filter).connect(gain).connect(this.master); |
| src.start(t); |
| } |
| shoot(w) { |
| if (w.name === 'Shotgun') this._noise(0.12, 300, 'lowpass', 1.2); |
| else if (w.name === 'Sniper') { |
| this._noise(0.15, 2000, 'highpass', 0.8); |
| this._noise(0.2, 400, 'lowpass', 0.6); |
| } else { |
| this._noise(0.08, 1200, 'bandpass', 0.7); |
| } |
| } |
| hit() { |
| if (!this.ctx) return; |
| const osc = this.ctx.createOscillator(); |
| const g = this.ctx.createGain(); |
| osc.type = 'sine'; |
| osc.frequency.setValueAtTime(1200, this.ctx.currentTime); |
| osc.frequency.exponentialRampToValueAtTime(300, this.ctx.currentTime + 0.1); |
| g.gain.setValueAtTime(0.2, this.ctx.currentTime); |
| g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.1); |
| osc.connect(g).connect(this.master); |
| osc.start(); |
| osc.stop(this.ctx.currentTime + 0.1); |
| } |
| reload() { |
| if (!this.ctx) return; |
| const t = this.ctx.currentTime; |
| [0, 0.15, 0.3].forEach((d) => { |
| const osc = this.ctx.createOscillator(); |
| const g = this.ctx.createGain(); |
| osc.type = 'square'; |
| osc.frequency.value = 200 + Math.random() * 100; |
| g.gain.setValueAtTime(0.05, t + d); |
| g.gain.exponentialRampToValueAtTime(0.001, t + d + 0.08); |
| osc.connect(g).connect(this.master); |
| osc.start(t + d); |
| osc.stop(t + d + 0.08); |
| }); |
| } |
| explosion() { |
| this._noise(0.4, 200, 'lowpass', 1.5); |
| } |
| step() { |
| if (!this.ctx || Math.random() > 0.3) return; |
| this._noise(0.04, 600, 'bandpass', 0.08); |
| } |
| ambient() { |
| if (!this.ctx) return; |
| const osc = this.ctx.createOscillator(); |
| const g = this.ctx.createGain(); |
| osc.type = 'sawtooth'; |
| osc.frequency.value = 50; |
| const filter = this.ctx.createBiquadFilter(); |
| filter.type = 'lowpass'; |
| filter.frequency.value = 120; |
| filter.Q.value = 5; |
| g.gain.value = 0.015; |
| osc.connect(filter).connect(g).connect(this.master); |
| osc.start(); |
| } |
| } |
| |
| const audio = new SoundSys(); |
| |
| const WEAPONS = [ |
| { id: 0, name: 'AR-15', damage: 28, fireRate: 110, ammo: 30, reserve: 120, spread: 0.025, auto: true, reload: 1900, pellets: 1, zoomFOV: 75 }, |
| { id: 1, name: 'Shotgun', damage: 16, fireRate: 900, ammo: 8, reserve: 32, spread: 0.18, auto: false, reload: 2800, pellets: 7, zoomFOV: 75 }, |
| { id: 2, name: 'Sniper', damage: 100, fireRate: 1300, ammo: 5, reserve: 20, spread: 0.002, auto: false, reload: 3500, pellets: 1, zoomFOV: 25 } |
| ]; |
| let currentWeapon = 0; |
| let isScoped = false; |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x050510); |
| scene.fog = new THREE.FogExp2(0x050510, 0.018); |
| |
| const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 300); |
| camera.position.y = CONFIG.playerHeight; |
| |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setSize(innerWidth, innerHeight); |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| document.body.appendChild(renderer.domElement); |
| |
| scene.add(new THREE.AmbientLight(0x202040, 0.5)); |
| const dirLight = new THREE.DirectionalLight(0x8899ff, 0.5); |
| dirLight.position.set(30, 60, 20); |
| dirLight.castShadow = true; |
| dirLight.shadow.camera.left = -60; |
| dirLight.shadow.camera.right = 60; |
| dirLight.shadow.camera.top = 60; |
| dirLight.shadow.camera.bottom = -60; |
| dirLight.shadow.mapSize.width = 2048; |
| dirLight.shadow.mapSize.height = 2048; |
| scene.add(dirLight); |
| |
| class LevelGen { |
| constructor(size, cell) { |
| this.size = size; |
| this.cell = cell; |
| this.grid = Array(size).fill().map(() => Array(size).fill(1)); |
| this.walls = []; |
| this.neons = []; |
| this.generate(); |
| } |
| generate() { |
| const cx = Math.floor(this.size / 2); |
| const cy = Math.floor(this.size / 2); |
| let x = cx; |
| let y = cy; |
| this.grid[y][x] = 0; |
| for (let i = 0; i < 500; i++) { |
| const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; |
| const d = dirs[Math.floor(Math.random() * 4)]; |
| x += d[0]; |
| y += d[1]; |
| x = Math.max(1, Math.min(this.size - 2, x)); |
| y = Math.max(1, Math.min(this.size - 2, y)); |
| this.grid[y][x] = 0; |
| if (Math.random() < 0.08) { |
| for (let ry = -1; ry <= 1; ry++) { |
| for (let rx = -1; rx <= 1; rx++) { |
| if (y + ry > 0 && y + ry < this.size - 1 && x + rx > 0 && x + rx < this.size - 1) { |
| this.grid[y + ry][x + rx] = 0; |
| } |
| } |
| } |
| } |
| } |
| for (let dy = -2; dy <= 2; dy++) { |
| for (let dx = -2; dx <= 2; dx++) { |
| const sy = cy + dy; |
| const sx = x + dx; |
| if (sy >= 0 && sy < this.size && sx >= 0 && sx < this.size) this.grid[sy][sx] = 0; |
| } |
| } |
| } |
| build() { |
| const wallMat = new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.9 }); |
| const floorGeo = new THREE.PlaneGeometry(this.size * this.cell, this.size * this.cell); |
| const floorMat = new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.9, metalness: 0.2 }); |
| const floor = new THREE.Mesh(floorGeo, floorMat); |
| floor.rotation.x = -Math.PI / 2; |
| floor.receiveShadow = true; |
| scene.add(floor); |
| const grid = new THREE.GridHelper(this.size * this.cell, this.size, 0x334466, 0x223344); |
| grid.position.y = 0.01; |
| scene.add(grid); |
| |
| let wallCount = 0; |
| for (let y = 0; y < this.size; y++) for (let x = 0; x < this.size; x++) if (this.grid[y][x] === 1) wallCount++; |
| const wGeo = new THREE.BoxGeometry(this.cell, 6, this.cell); |
| const iMesh = new THREE.InstancedMesh(wGeo, wallMat, wallCount); |
| iMesh.castShadow = true; |
| iMesh.receiveShadow = true; |
| let idx = 0; |
| const dummy = new THREE.Object3D(); |
| for (let y = 0; y < this.size; y++) { |
| for (let x = 0; x < this.size; x++) { |
| if (this.grid[y][x] === 1) { |
| dummy.position.set((x - this.size / 2) * this.cell, 3, (y - this.size / 2) * this.cell); |
| dummy.updateMatrix(); |
| iMesh.setMatrixAt(idx++, dummy.matrix); |
| } |
| } |
| } |
| scene.add(iMesh); |
| |
| const neonColors = [0x00ffff, 0xff00ff, 0xff6600, 0x00ff66, 0x6644ff]; |
| let placed = 0; |
| while (placed < 8) { |
| const rx = Math.floor(Math.random() * this.size); |
| const ry = Math.floor(Math.random() * this.size); |
| if (this.grid[ry][rx] === 0 && Math.abs(rx - this.size / 2) > 4 && Math.abs(ry - this.size / 2) > 4) { |
| const cx = (rx - this.size / 2) * this.cell; |
| const cz = (ry - this.size / 2) * this.cell; |
| const colGeo = new THREE.CylinderGeometry(0.4, 0.4, 7, 8); |
| const ccol = neonColors[placed % neonColors.length]; |
| const colMat = new THREE.MeshStandardMaterial({ color: ccol, emissive: ccol, emissiveIntensity: 2.5, roughness: 0.2, metalness: 0.8 }); |
| const col = new THREE.Mesh(colGeo, colMat); |
| col.position.set(cx, 3.5, cz); |
| col.castShadow = true; |
| scene.add(col); |
| const pl = new THREE.PointLight(ccol, 2.5, 18); |
| pl.position.set(cx, 3.5, cz); |
| scene.add(pl); |
| this.neons.push({ mesh: col, box: new THREE.Box3().setFromObject(col) }); |
| placed++; |
| } |
| } |
| |
| for (let i = 0; i < 20; i++) { |
| const rx = Math.floor(Math.random() * this.size); |
| const ry = Math.floor(Math.random() * this.size); |
| if (this.grid[ry][rx] === 0 && (Math.abs(rx - this.size / 2) > 2 || Math.abs(ry - this.size / 2) > 2)) { |
| const s = 1.5 + Math.random() * 2; |
| const cx = (rx - this.size / 2) * this.cell; |
| const cz = (ry - this.size / 2) * this.cell; |
| const cm = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshStandardMaterial({ color: 0x4a3728 })); |
| cm.position.set(cx, s / 2, cz); |
| cm.castShadow = true; |
| cm.receiveShadow = true; |
| scene.add(cm); |
| this.neons.push({ mesh: cm, box: new THREE.Box3().setFromObject(cm) }); |
| } |
| } |
| |
| for (let y = 0; y < this.size; y++) { |
| for (let x = 0; x < this.size; x++) { |
| if (this.grid[y][x] === 1) { |
| const b = new THREE.Box3(); |
| const px = (x - this.size / 2) * this.cell; |
| const pz = (y - this.size / 2) * this.cell; |
| b.min.set(px - this.cell / 2, 0, pz - this.cell / 2); |
| b.max.set(px + this.cell / 2, 6, pz + this.cell / 2); |
| this.walls.push(b); |
| } |
| } |
| } |
| this.neons.forEach((n) => this.walls.push(n.box)); |
| } |
| } |
| |
| const level = new LevelGen(CONFIG.mapSize, CONFIG.cellSize); |
| level.build(); |
| |
| const keys = { w: false, a: false, s: false, d: false, shift: false }; |
| let velocity = new THREE.Vector3(); |
| let isGrounded = true; |
| const pBox = new THREE.Box3(); |
| const pSize = new THREE.Vector3(0.5, CONFIG.playerHeight, 0.5); |
| |
| function movePlayer(dt) { |
| const w = WEAPONS[currentWeapon]; |
| const speed = (keys.shift && !isScoped) ? CONFIG.sprintSpeed : CONFIG.walkSpeed; |
| camera.fov = THREE.MathUtils.lerp(camera.fov, isScoped ? w.zoomFOV : 75, dt * 8); |
| camera.updateProjectionMatrix(); |
| |
| velocity.x -= velocity.x * 10 * dt; |
| velocity.z -= velocity.z * 10 * dt; |
| velocity.y -= CONFIG.gravity * dt; |
| |
| let dz = (keys.w ? 1 : 0) - (keys.s ? 1 : 0); |
| let dx = (keys.d ? 1 : 0) - (keys.a ? 1 : 0); |
| if (mobileInput.move.lengthSq() > 0) { |
| dx = mobileInput.move.x; |
| dz = -mobileInput.move.y; |
| } |
| |
| const len = Math.hypot(dx, dz); |
| if (len > 0) { |
| dx /= len; |
| dz /= len; |
| } |
| |
| const forward = new THREE.Vector3(); |
| camera.getWorldDirection(forward); |
| forward.y = 0; |
| forward.normalize(); |
| const right = new THREE.Vector3(); |
| right.crossVectors(forward, new THREE.Vector3(0, 1, 0)); |
| |
| if (dz !== 0) velocity.addScaledVector(forward, dz * speed * 10 * dt); |
| if (dx !== 0) velocity.addScaledVector(right, dx * speed * 10 * dt); |
| |
| controls.moveRight(-velocity.x * dt); |
| controls.moveForward(-velocity.z * dt); |
| camera.position.y += velocity.y * dt; |
| |
| if (camera.position.y < CONFIG.playerHeight) { |
| velocity.y = 0; |
| camera.position.y = CONFIG.playerHeight; |
| isGrounded = true; |
| } else { |
| isGrounded = false; |
| } |
| |
| pBox.setFromCenterAndSize(camera.position, pSize); |
| for (const b of level.walls) { |
| if (pBox.intersectsBox(b)) { |
| const center = new THREE.Vector3(); |
| b.getCenter(center); |
| const push = new THREE.Vector3().subVectors(camera.position, center); |
| push.y = 0; |
| push.normalize(); |
| camera.position.x += push.x * 0.15; |
| camera.position.z += push.z * 0.15; |
| } |
| } |
| |
| const worldBound = (CONFIG.mapSize * CONFIG.cellSize) / 2 - 1; |
| camera.position.x = Math.max(-worldBound, Math.min(worldBound, camera.position.x)); |
| camera.position.z = Math.max(-worldBound, Math.min(worldBound, camera.position.z)); |
| } |
| |
| class MPManager { |
| constructor(url) { |
| this.url = url; |
| this.ws = null; |
| this.id = null; |
| this.remotePlayers = {}; |
| this.connected = false; |
| } |
| connect() { |
| try { |
| this.ws = new WebSocket(this.url); |
| this.ws.onopen = () => { |
| this.connected = true; |
| updateConnStatus('Online', true); |
| audio.ambient(); |
| }; |
| this.ws.onmessage = (e) => this.handle(JSON.parse(e.data)); |
| this.ws.onclose = () => { |
| this.connected = false; |
| updateConnStatus('Disconnected', false); |
| setTimeout(() => this.connect(), 3000); |
| }; |
| this.ws.onerror = () => updateConnStatus('Offline', false); |
| } catch { |
| updateConnStatus('Offline', false); |
| } |
| } |
| handle(d) { |
| if (d.t === 'id') this.id = d.id; |
| if (d.t === 'state') { |
| d.players.forEach((p) => { |
| if (p.id === this.id) return; |
| this.updateRemote(p); |
| }); |
| } |
| if (d.t === 'shoot') this.spawnRemoteBullet(d.origin || d.pos, d.dir, d.weapon); |
| if (d.t === 'kill') addKillFeed(d.killer, d.victim); |
| } |
| updateRemote(p) { |
| if (!this.remotePlayers[p.id]) { |
| const g = new THREE.Group(); |
| const body = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.6, 0.4), new THREE.MeshStandardMaterial({ color: 0xff2244 })); |
| body.position.y = 0.8; |
| body.castShadow = true; |
| g.add(body); |
| const head = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.45, 0.45), new THREE.MeshStandardMaterial({ color: 0xffaa00 })); |
| head.position.y = 1.8; |
| g.add(head); |
| const nameTag = document.createElement('div'); |
| nameTag.style.cssText = 'position:absolute;color:#fff;font-size:12px;font-weight:700;text-shadow:0 0 4px #000;pointer-events:none;transform:translate(-50%,-100%);'; |
| nameTag.textContent = p.name || 'Enemy'; |
| document.body.appendChild(nameTag); |
| scene.add(g); |
| this.remotePlayers[p.id] = { |
| mesh: g, |
| nametag: nameTag, |
| targetPos: new THREE.Vector3(...p.pos), |
| targetRot: new THREE.Quaternion(...p.rot) |
| }; |
| } |
| const rp = this.remotePlayers[p.id]; |
| rp.targetPos.set(...p.pos); |
| rp.targetRot.set(...p.rot); |
| } |
| spawnRemoteBullet(pos, dir, weaponId) { |
| if (!pos || !dir) return; |
| const w = WEAPONS[weaponId] || WEAPONS[0]; |
| const b = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.3), new THREE.MeshBasicMaterial({ color: w.name === 'Sniper' ? 0x66ccff : 0xffff00 })); |
| b.position.set(...pos); |
| b.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(...dir)); |
| scene.add(b); |
| const vel = new THREE.Vector3(...dir).multiplyScalar(60); |
| const tick = () => { |
| b.position.addScaledVector(vel, 0.016); |
| if (b.position.length() > 200) { |
| scene.remove(b); |
| return; |
| } |
| requestAnimationFrame(tick); |
| }; |
| tick(); |
| } |
| sync(pos, rot) { |
| if (this.connected && this.ws.readyState === 1) { |
| this.ws.send(JSON.stringify({ t: 'pos', pos: [pos.x, pos.y, pos.z], rot: [rot.x, rot.y, rot.z, rot.w] })); |
| } |
| } |
| sendShoot(origin, dir, weaponId) { |
| if (this.connected && this.ws.readyState === 1) { |
| this.ws.send(JSON.stringify({ t: 'shoot', origin: [origin.x, origin.y, origin.z], dir: [dir.x, dir.y, dir.z], weapon: weaponId })); |
| } |
| } |
| update(dt) { |
| for (const id in this.remotePlayers) { |
| const rp = this.remotePlayers[id]; |
| rp.mesh.position.lerp(rp.targetPos, dt * 10); |
| rp.mesh.quaternion.slerp(rp.targetRot, dt * 10); |
| const p = rp.mesh.position.clone().add(new THREE.Vector3(0, 2.2, 0)).project(camera); |
| const x = (p.x * 0.5 + 0.5) * innerWidth; |
| const y = (-p.y * 0.5 + 0.5) * innerHeight; |
| rp.nametag.style.left = `${x}px`; |
| rp.nametag.style.top = `${y}px`; |
| rp.nametag.style.display = p.z > 1 ? 'none' : 'block'; |
| } |
| } |
| } |
| const mp = new MPManager(CONFIG.wsUrl); |
| |
| class Bot { |
| constructor() { |
| this.group = new THREE.Group(); |
| const bodyMat = new THREE.MeshStandardMaterial({ color: 0xff2244, roughness: 0.3 }); |
| this.body = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.6, 0.4), bodyMat); |
| this.body.position.y = 0.8; |
| this.body.castShadow = true; |
| this.group.add(this.body); |
| this.head = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.45, 0.45), new THREE.MeshStandardMaterial({ color: 0xffaa00 })); |
| this.head.position.y = 1.8; |
| this.group.add(this.head); |
| this.group.position.set(999, 999, 999); |
| scene.add(this.group); |
| this.health = 100; |
| this.speed = 2.5 + Math.random() * 2; |
| this.dead = true; |
| this.lastAttack = 0; |
| this.flash = 0; |
| } |
| spawn() { |
| let attempts = 0; |
| let cx = 0; |
| let cz = 0; |
| do { |
| const rx = Math.floor(Math.random() * CONFIG.mapSize); |
| const ry = Math.floor(Math.random() * CONFIG.mapSize); |
| if (level.grid[ry][rx] === 0) { |
| cx = (rx - CONFIG.mapSize / 2) * CONFIG.cellSize; |
| cz = (ry - CONFIG.mapSize / 2) * CONFIG.cellSize; |
| if (new THREE.Vector3(cx, 0, cz).distanceTo(camera.position) > 20) break; |
| } |
| attempts++; |
| } while (attempts < 100); |
| this.group.position.set(cx, 0, cz); |
| this.health = 100 + Math.floor(Math.random() * 50); |
| this.dead = false; |
| } |
| update(dt, target) { |
| if (this.dead) return; |
| this.group.lookAt(target.x, this.group.position.y, target.z); |
| const dir = new THREE.Vector3().subVectors(target, this.group.position); |
| dir.y = 0; |
| const dist = dir.length(); |
| dir.normalize(); |
| if (dist > 1.5) { |
| this.group.position.x += dir.x * this.speed * dt; |
| this.group.position.z += dir.z * this.speed * dt; |
| } |
| if (dist < 2.5 && Date.now() - this.lastAttack > 1200) { |
| this.lastAttack = Date.now(); |
| damagePlayer(12); |
| } |
| if (this.flash > 0) { |
| this.flash -= dt * 4; |
| this.body.material.emissive = new THREE.Color(0xffffff); |
| this.body.material.emissiveIntensity = this.flash; |
| } else { |
| this.body.material.emissive = new THREE.Color(0x000000); |
| this.body.material.emissiveIntensity = 0; |
| } |
| } |
| hit(dmg, pt) { |
| this.health -= dmg; |
| this.flash = 1; |
| showDamage(dmg, pt); |
| if (this.health <= 0) { |
| this.dead = true; |
| this.group.position.set(999, 999, 999); |
| state.kills += 1; |
| state.score += 100; |
| updateUI(); |
| spawnParticles(pt, 0xff2244, 12); |
| audio.explosion(); |
| setTimeout(() => this.spawn(), 2500); |
| } |
| } |
| } |
| const bots = Array.from({ length: CONFIG.botCount }, () => new Bot()); |
| |
| let particles = []; |
| function spawnParticles(pos, color, count) { |
| for (let i = 0; i < count; i++) { |
| const m = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.06), new THREE.MeshBasicMaterial({ color })); |
| m.position.copy(pos).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.4)); |
| scene.add(m); |
| particles.push({ m, v: new THREE.Vector3((Math.random() - 0.5) * 8, Math.random() * 5, (Math.random() - 0.5) * 8), life: 1 }); |
| } |
| } |
| |
| const state = { score: 0, kills: 0, health: 100, startTime: 0, playing: false, canShoot: true, lastShot: 0, reloading: false }; |
| function maxAmmoFor(weapon) { |
| if (weapon.name === 'AR-15') return 30; |
| if (weapon.name === 'Shotgun') return 8; |
| return 5; |
| } |
| function resetGame() { |
| state.score = 0; |
| state.kills = 0; |
| state.health = 100; |
| state.playing = false; |
| state.canShoot = true; |
| state.lastShot = 0; |
| state.reloading = false; |
| camera.position.set(0, CONFIG.playerHeight, 0); |
| camera.rotation.set(0, 0, 0); |
| velocity.set(0, 0, 0); |
| bots.forEach((b) => { |
| b.dead = true; |
| b.group.position.set(999, 999, 999); |
| setTimeout(() => b.spawn(), Math.random() * 2000); |
| }); |
| WEAPONS.forEach((w) => { |
| w.ammo = maxAmmoFor(w); |
| w.reserve = w.name === 'AR-15' ? 120 : (w.name === 'Shotgun' ? 32 : 20); |
| }); |
| currentWeapon = 0; |
| isScoped = false; |
| document.getElementById('scope').style.display = 'none'; |
| document.getElementById('crosshair').style.display = 'block'; |
| } |
| |
| const controls = new PointerLockControls(camera, document.body); |
| const mobileInput = { move: new THREE.Vector2(), look: new THREE.Vector2() }; |
| |
| document.addEventListener('keydown', (e) => { |
| const k = e.key.toLowerCase(); |
| if (k === 'w') keys.w = true; |
| if (k === 'a') keys.a = true; |
| if (k === 's') keys.s = true; |
| if (k === 'd') keys.d = true; |
| if (k === ' ') { if (isGrounded) velocity.y = CONFIG.jumpForce; } |
| if (k === 'shift') keys.shift = true; |
| if (k === 'r') reloadWeapon(); |
| if (k >= '1' && k <= '3') switchWeapon(parseInt(k, 10) - 1); |
| if (k === 'z') toggleScope(); |
| }); |
| document.addEventListener('keyup', (e) => { |
| const k = e.key.toLowerCase(); |
| if (k === 'w') keys.w = false; |
| if (k === 'a') keys.a = false; |
| if (k === 's') keys.s = false; |
| if (k === 'd') keys.d = false; |
| if (k === 'shift') keys.shift = false; |
| }); |
| document.addEventListener('mousedown', (e) => { |
| if (e.button === 2) { |
| e.preventDefault(); |
| toggleScope(); |
| } |
| if (state.playing && e.button === 0 && !CONFIG.isMobile) shoot(); |
| }); |
| document.addEventListener('wheel', (e) => { |
| if (!state.playing) return; |
| if (e.deltaY > 0) switchWeapon((currentWeapon + 1) % WEAPONS.length); |
| else switchWeapon((currentWeapon - 1 + WEAPONS.length) % WEAPONS.length); |
| }); |
| document.addEventListener('contextmenu', (e) => e.preventDefault()); |
| |
| if (CONFIG.isMobile) { |
| document.getElementById('controls-mobile').style.display = 'block'; |
| document.getElementById('controls-desktop').style.display = 'none'; |
| document.getElementById('mobile-ui').style.display = 'block'; |
| |
| const stickZone = document.getElementById('stick-zone'); |
| const stickKnob = document.getElementById('stick-knob'); |
| const stickCenter = document.getElementById('stick-center'); |
| let stickId = null; |
| let stickOrigin = { x: 0, y: 0 }; |
| |
| stickZone.addEventListener('touchstart', (e) => { |
| const t = e.changedTouches[0]; |
| stickId = t.identifier; |
| stickOrigin = { x: t.clientX, y: t.clientY }; |
| stickKnob.style.display = 'block'; |
| stickCenter.style.display = 'block'; |
| stickKnob.style.left = `${t.clientX}px`; |
| stickCenter.style.left = `${t.clientX}px`; |
| stickKnob.style.top = `${t.clientY}px`; |
| stickCenter.style.top = `${t.clientY}px`; |
| }, { passive: false }); |
| |
| stickZone.addEventListener('touchmove', (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) { |
| const t = e.changedTouches[i]; |
| if (t.identifier === stickId) { |
| const dx = t.clientX - stickOrigin.x; |
| const dy = t.clientY - stickOrigin.y; |
| const dist = Math.min(Math.hypot(dx, dy), 50); |
| const ang = Math.atan2(dy, dx); |
| mobileInput.move.set(Math.cos(ang) * dist / 50, Math.sin(ang) * dist / 50); |
| stickKnob.style.left = `${stickOrigin.x + Math.cos(ang) * dist}px`; |
| stickKnob.style.top = `${stickOrigin.y + Math.sin(ang) * dist}px`; |
| } |
| } |
| }, { passive: false }); |
| |
| const endStick = (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) { |
| if (e.changedTouches[i].identifier === stickId) { |
| stickId = null; |
| mobileInput.move.set(0, 0); |
| stickKnob.style.display = 'none'; |
| stickCenter.style.display = 'none'; |
| } |
| } |
| }; |
| stickZone.addEventListener('touchend', endStick); |
| stickZone.addEventListener('touchcancel', endStick); |
| |
| const lookZone = document.getElementById('look-zone'); |
| const lookTouches = {}; |
| lookZone.addEventListener('touchstart', (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) { |
| const t = e.changedTouches[i]; |
| lookTouches[t.identifier] = { x: t.clientX, y: t.clientY }; |
| } |
| }, { passive: false }); |
| |
| lookZone.addEventListener('touchmove', (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) { |
| const t = e.changedTouches[i]; |
| const prev = lookTouches[t.identifier]; |
| if (prev) { |
| const sens = 0.004; |
| const dx = (t.clientX - prev.x) * sens; |
| const dy = (t.clientY - prev.y) * sens; |
| camera.rotation.y -= dx; |
| camera.rotation.x -= dy; |
| camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x)); |
| lookTouches[t.identifier] = { x: t.clientX, y: t.clientY }; |
| } |
| } |
| }, { passive: false }); |
| |
| lookZone.addEventListener('touchend', (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) delete lookTouches[e.changedTouches[i].identifier]; |
| }); |
| lookZone.addEventListener('touchcancel', (e) => { |
| for (let i = 0; i < e.changedTouches.length; i++) delete lookTouches[e.changedTouches[i].identifier]; |
| }); |
| |
| document.getElementById('btn-fire').addEventListener('touchstart', (e) => { e.preventDefault(); shoot(); }, { passive: false }); |
| document.getElementById('btn-jump').addEventListener('touchstart', (e) => { e.preventDefault(); if (isGrounded) velocity.y = CONFIG.jumpForce; }, { passive: false }); |
| document.getElementById('btn-weapon').addEventListener('touchstart', (e) => { e.preventDefault(); switchWeapon((currentWeapon + 1) % WEAPONS.length); }, { passive: false }); |
| } |
| |
| function reloadWeapon() { |
| const w = WEAPONS[currentWeapon]; |
| const magMax = maxAmmoFor(w); |
| if (state.reloading || w.ammo >= magMax || w.reserve <= 0) return; |
| |
| state.reloading = true; |
| state.canShoot = false; |
| audio.reload(); |
| document.getElementById('reload-bar').style.display = 'block'; |
| document.getElementById('reload-fill').style.transition = 'none'; |
| document.getElementById('reload-fill').style.width = '0%'; |
| |
| setTimeout(() => { |
| document.getElementById('reload-fill').style.transition = `width ${Math.floor(w.reload * 0.8)}ms linear`; |
| document.getElementById('reload-fill').style.width = '100%'; |
| }, 50); |
| |
| setTimeout(() => { |
| const needed = magMax - w.ammo; |
| const taken = Math.min(needed, w.reserve); |
| w.ammo += taken; |
| w.reserve -= taken; |
| state.reloading = false; |
| state.canShoot = true; |
| document.getElementById('reload-bar').style.display = 'none'; |
| updateUI(); |
| }, w.reload); |
| } |
| |
| function switchWeapon(idx) { |
| if (idx === currentWeapon) return; |
| currentWeapon = idx; |
| const w = WEAPONS[idx]; |
| document.getElementById('weapon-info').textContent = `${w.name} [${idx + 1}]`; |
| const gun = document.getElementById('gun'); |
| gun.style.filter = 'brightness(1.5)'; |
| setTimeout(() => { gun.style.filter = 'none'; }, 150); |
| isScoped = false; |
| document.getElementById('scope').style.display = 'none'; |
| document.getElementById('crosshair').style.display = 'block'; |
| updateUI(); |
| } |
| |
| function toggleScope() { |
| if (currentWeapon !== 2) return; |
| isScoped = !isScoped; |
| document.getElementById('scope').style.display = isScoped ? 'block' : 'none'; |
| document.getElementById('crosshair').style.display = isScoped ? 'none' : 'block'; |
| } |
| |
| function shoot() { |
| if (!state.playing || !state.canShoot || state.reloading) return; |
| const w = WEAPONS[currentWeapon]; |
| if (w.ammo <= 0) { |
| reloadWeapon(); |
| return; |
| } |
| if (Date.now() - state.lastShot < w.fireRate) return; |
| |
| state.lastShot = Date.now(); |
| w.ammo--; |
| updateUI(); |
| audio.shoot(w); |
| |
| const gun = document.getElementById('gun'); |
| gun.classList.remove('recoil'); |
| void gun.offsetWidth; |
| gun.classList.add('recoil'); |
| |
| const muzzlePos = camera.position.clone().add(new THREE.Vector3(0.25, -0.15, -0.4).applyQuaternion(camera.quaternion)); |
| spawnParticles(muzzlePos, 0xffffaa, 4); |
| |
| const rc = new THREE.Raycaster(); |
| const pellets = w.pellets; |
| |
| for (let i = 0; i < pellets; i++) { |
| const spread = isScoped ? 0 : w.spread; |
| const offset = new THREE.Vector3((Math.random() - 0.5) * spread, (Math.random() - 0.5) * spread, (Math.random() - 0.5) * spread); |
| const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).add(offset).normalize(); |
| rc.set(camera.position, dir); |
| |
| let hitSomething = false; |
| for (const b of bots) { |
| if (b.dead) continue; |
| const intersects = rc.intersectObjects([b.body, b.head]); |
| if (intersects.length > 0) { |
| const dmg = intersects[0].object === b.head ? w.damage * 2 : w.damage; |
| b.hit(dmg, intersects[0].point); |
| spawnParticles(intersects[0].point, 0xff0000, 6); |
| audio.hit(); |
| hitSomething = true; |
| break; |
| } |
| } |
| |
| if (!hitSomething) { |
| const wallMeshes = level.neons.map((n) => n.mesh); |
| const wi = rc.intersectObjects(wallMeshes); |
| if (wi.length > 0) spawnParticles(wi[0].point, 0xffaa00, 4); |
| } |
| } |
| |
| const shootDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); |
| mp.sendShoot(camera.position, shootDir, currentWeapon); |
| |
| if (w.ammo <= 0) reloadWeapon(); |
| } |
| |
| function damagePlayer(amt) { |
| state.health = Math.max(0, state.health - amt); |
| document.getElementById('damage-overlay').style.background = `rgba(255,0,0,${amt / 150})`; |
| setTimeout(() => { document.getElementById('damage-overlay').style.background = 'rgba(255,0,0,0)'; }, 120); |
| updateUI(); |
| if (state.health <= 0) gameOver(); |
| } |
| |
| function showDamage(amt, pt) { |
| const el = document.createElement('div'); |
| el.className = 'damage-text'; |
| el.textContent = `-${amt}`; |
| document.body.appendChild(el); |
| const p = pt.clone().project(camera); |
| const x = (p.x * 0.5 + 0.5) * innerWidth; |
| const y = (-p.y * 0.5 + 0.5) * innerHeight; |
| el.style.left = `${x}px`; |
| el.style.top = `${y}px`; |
| setTimeout(() => el.remove(), 900); |
| } |
| |
| function addKillFeed(killer, victim) { |
| const el = document.createElement('div'); |
| el.className = 'kill-msg'; |
| el.textContent = `${killer} ▶ ${victim}`; |
| document.getElementById('kill-feed').appendChild(el); |
| setTimeout(() => el.remove(), 4000); |
| } |
| |
| function updateConnStatus(text, ok) { |
| const el = document.getElementById('connection-status'); |
| el.textContent = text; |
| el.classList.remove('conn-online', 'conn-offline'); |
| el.classList.add(ok ? 'conn-online' : 'conn-offline'); |
| } |
| |
| function updateUI() { |
| document.getElementById('score').textContent = state.score; |
| document.getElementById('kills').textContent = state.kills; |
| const w = WEAPONS[currentWeapon]; |
| document.getElementById('ammo-current').textContent = w.ammo; |
| document.getElementById('ammo-reserve').textContent = w.reserve; |
| document.getElementById('weapon-info').textContent = `${w.name} [${currentWeapon + 1}]`; |
| document.getElementById('health-fill').style.width = `${Math.max(0, state.health)}%`; |
| document.getElementById('health-text').textContent = `HEALTH ${Math.floor(Math.max(0, state.health))}%`; |
| const elapsed = Math.floor((Date.now() - state.startTime) / 1000); |
| const m = Math.floor(elapsed / 60); |
| const s = elapsed % 60; |
| document.getElementById('time').textContent = `${m}:${s.toString().padStart(2, '0')}`; |
| } |
| |
| function gameOver() { |
| state.playing = false; |
| controls.unlock(); |
| document.getElementById('ui').style.display = 'none'; |
| const scr = document.getElementById('start-screen'); |
| scr.style.display = 'flex'; |
| scr.querySelector('h1').textContent = 'MISSION FAILED'; |
| scr.querySelector('.subtitle').textContent = `Score ${state.score} • Kills ${state.kills}`; |
| document.getElementById('start-btn').textContent = 'Deploy Again'; |
| resetGame(); |
| } |
| |
| const mmCanvas = document.getElementById('minimap-canvas'); |
| const mmCtx = mmCanvas.getContext('2d'); |
| function drawMinimap() { |
| mmCtx.fillStyle = '#050510'; |
| mmCtx.fillRect(0, 0, 140, 140); |
| const scl = 140 / (CONFIG.mapSize * CONFIG.cellSize); |
| const ox = CONFIG.mapSize * CONFIG.cellSize / 2; |
| const oy = CONFIG.mapSize * CONFIG.cellSize / 2; |
| |
| mmCtx.fillStyle = '#1a1a2e'; |
| for (let y = 0; y < CONFIG.mapSize; y++) { |
| for (let x = 0; x < CONFIG.mapSize; x++) { |
| if (level.grid[y][x] === 1) { |
| mmCtx.fillRect((x * CONFIG.cellSize - ox) * scl + 70, (y * CONFIG.cellSize - oy) * scl + 70, CONFIG.cellSize * scl, CONFIG.cellSize * scl); |
| } |
| } |
| } |
| |
| bots.forEach((b) => { |
| if (b.dead) return; |
| mmCtx.fillStyle = '#ff2244'; |
| mmCtx.beginPath(); |
| mmCtx.arc((b.group.position.x) * scl + 70, (b.group.position.z) * scl + 70, 2.5, 0, Math.PI * 2); |
| mmCtx.fill(); |
| }); |
| |
| for (const id in mp.remotePlayers) { |
| const rp = mp.remotePlayers[id].mesh; |
| mmCtx.fillStyle = '#00ccff'; |
| mmCtx.beginPath(); |
| mmCtx.arc(rp.position.x * scl + 70, rp.position.z * scl + 70, 2.5, 0, Math.PI * 2); |
| mmCtx.fill(); |
| } |
| |
| mmCtx.fillStyle = '#00ffaa'; |
| mmCtx.beginPath(); |
| mmCtx.arc(camera.position.x * scl + 70, camera.position.z * scl + 70, 3, 0, Math.PI * 2); |
| mmCtx.fill(); |
| const d = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); |
| mmCtx.strokeStyle = '#00ffaa'; |
| mmCtx.lineWidth = 1.5; |
| mmCtx.beginPath(); |
| mmCtx.moveTo(camera.position.x * scl + 70, camera.position.z * scl + 70); |
| mmCtx.lineTo((camera.position.x + d.x * 6) * scl + 70, (camera.position.z + d.z * 6) * scl + 70); |
| mmCtx.stroke(); |
| } |
| |
| const clock = new THREE.Clock(); |
| const startBtn = document.getElementById('start-btn'); |
| const ui = document.getElementById('ui'); |
| const startScreen = document.getElementById('start-screen'); |
| |
| function startGameSession() { |
| startScreen.style.display = 'none'; |
| ui.style.display = 'flex'; |
| state.playing = true; |
| state.startTime = Date.now(); |
| state.health = 100; |
| bots.forEach((b) => b.spawn()); |
| updateUI(); |
| } |
| |
| startBtn.addEventListener('click', () => { |
| audio.init(); |
| mp.connect(); |
| if (CONFIG.isMobile) { |
| startGameSession(); |
| } else { |
| controls.lock(); |
| } |
| }); |
| |
| controls.addEventListener('lock', () => { |
| startGameSession(); |
| }); |
| |
| controls.addEventListener('unlock', () => { |
| if (state.health > 0 && !CONFIG.isMobile) { |
| startScreen.style.display = 'flex'; |
| ui.style.display = 'none'; |
| startBtn.textContent = 'Resume'; |
| } |
| state.playing = false; |
| }); |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| const dt = Math.min(clock.getDelta(), 0.1); |
| if (state.playing) { |
| movePlayer(dt); |
| bots.forEach((b) => b.update(dt, camera.position)); |
| mp.update(dt); |
| particles = particles.filter((p) => { |
| p.life -= dt * 2; |
| p.m.position.addScaledVector(p.v, dt); |
| p.v.y -= CONFIG.gravity * dt; |
| p.m.rotation.x += dt * 4; |
| p.m.scale.setScalar(p.life); |
| if (p.life <= 0 || p.m.position.y < 0) { |
| scene.remove(p.m); |
| return false; |
| } |
| return true; |
| }); |
| if (audio.initialized && (keys.w || keys.s || keys.a || keys.d) && isGrounded && Math.random() < dt * 6) audio.step(); |
| mp.sync(camera.position, camera.quaternion); |
| updateUI(); |
| } |
| drawMinimap(); |
| renderer.render(scene, camera); |
| } |
| |
| window.addEventListener('resize', () => { |
| camera.aspect = innerWidth / innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(innerWidth, innerHeight); |
| }); |
| |
| updateConnStatus('Single Player', false); |
| updateUI(); |
| animate(); |
| </script> |
| </body> |
| </html> |
|
|