Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Counter-Strike: Web Ops</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script> | |
| <style> | |
| :root { | |
| --cs-orange: #d57b00; | |
| --cs-blue: #4f7cac; | |
| --cs-bg: #1a1a1a; | |
| --cs-dark: #111; | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #000; | |
| font-family: 'Rajdhani', sans-serif; | |
| user-select: none; | |
| } | |
| /* UI Styling */ | |
| .cs-hud { | |
| text-shadow: 0 0 2px rgba(0, 0, 0, 0.8); | |
| letter-spacing: 1px; | |
| } | |
| .weapon-slot { | |
| transition: all 0.2s; | |
| } | |
| .weapon-slot.active { | |
| background: rgba(255, 255, 255, 0.2); | |
| border-left: 3px solid white; | |
| } | |
| /* Crosshair */ | |
| #crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| /* Hit Marker */ | |
| #hitmarker { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 40px; | |
| height: 40px; | |
| opacity: 0; | |
| pointer-events: none; | |
| z-index: 11; | |
| } | |
| #hitmarker.active { | |
| animation: hitmarker-anim 0.1s ease-out; | |
| } | |
| @keyframes hitmarker-anim { | |
| 0% { | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1.2); | |
| } | |
| 100% { | |
| opacity: 0; | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| } | |
| /* Damage Overlay */ | |
| #damage-overlay { | |
| background: radial-gradient(circle, transparent 50%, rgba(255, 0, 0, 0.6) 100%); | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| pointer-events: none; | |
| } | |
| /* Muzzle Flash */ | |
| .muzzle-flash { | |
| position: absolute; | |
| width: 100px; | |
| height: 100px; | |
| background: radial-gradient(circle, #ffaa00 0%, transparent 70%); | |
| border-radius: 50%; | |
| opacity: 0; | |
| pointer-events: none; | |
| mix-blend-mode: screen; | |
| z-index: 5; | |
| } | |
| .muzzle-flash.active { | |
| animation: flash-anim 0.05s linear; | |
| } | |
| @keyframes flash-anim { | |
| 0% { | |
| opacity: 0.8; | |
| transform: scale(0.5); | |
| } | |
| 100% { | |
| opacity: 0; | |
| transform: scale(1.5); | |
| } | |
| } | |
| /* Menus */ | |
| .menu-overlay { | |
| background: rgba(0, 0, 0, 0.85); | |
| backdrop-filter: blur(5px); | |
| } | |
| .btn-cs { | |
| background: linear-gradient(to bottom, #555, #333); | |
| border: 1px solid #777; | |
| color: white; | |
| text-transform: uppercase; | |
| clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); | |
| transition: all 0.2s; | |
| } | |
| .btn-cs:hover { | |
| background: linear-gradient(to bottom, #666, #444); | |
| border-color: white; | |
| transform: translateY(-2px); | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| </style> | |
| </head> | |
| <body class="text-white"> | |
| <!-- Canvas Layer --> | |
| <canvas id="gameCanvas" class="absolute top-0 left-0 w-full h-full"></canvas> | |
| <!-- Visual Effects Layer --> | |
| <div id="muzzle-flash" class="muzzle-flash" style="top: 60%; left: 50%; transform: translate(-50%, -50%);"></div> | |
| <div id="hitmarker"> | |
| <svg width="40" height="40" viewBox="0 0 40 40"> | |
| <line x1="5" y1="5" x2="15" y2="15" stroke="white" stroke-width="2" /> | |
| <line x1="35" y1="5" x2="25" y2="15" stroke="white" stroke-width="2" /> | |
| <line x1="5" y1="35" x2="15" y2="25" stroke="white" stroke-width="2" /> | |
| <line x1="35" y1="35" x2="25" y2="25" stroke="white" stroke-width="2" /> | |
| </svg> | |
| </div> | |
| <div id="damage-overlay" class="absolute top-0 left-0 w-full h-full z-20"></div> | |
| <!-- Crosshair --> | |
| <div id="crosshair"> | |
| <svg width="32" height="32" viewBox="0 0 32 32" fill="none"> | |
| <rect x="15" y="0" width="2" height="10" fill="white" /> | |
| <rect x="15" y="22" width="2" height="10" fill="white" /> | |
| <rect x="22" y="15" width="10" height="2" fill="white" /> | |
| <rect x="0" y="15" width="10" height="2" fill="white" /> | |
| <circle cx="16" cy="16" r="1.5" fill="white" /> | |
| </svg> | |
| </div> | |
| <!-- HUD Layer --> | |
| <div id="hud" class="absolute top-0 left-0 w-full h-full z-30 pointer-events-none hidden"> | |
| <!-- Top Info --> | |
| <div class="absolute top-4 left-0 w-full flex justify-center cs-hud"> | |
| <div class="bg-black/50 px-6 py-2 border-b-2 border-white/20 flex items-center gap-8"> | |
| <div class="text-red-500 font-bold text-2xl">T <span id="score-t">0</span></div> | |
| <div class="text-yellow-500 font-bold text-xl tracking-widest" id="timer">01:30</div> | |
| <div class="text-blue-400 font-bold text-2xl">CT <span id="score-ct">0</span></div> | |
| </div> | |
| </div> | |
| <!-- Bottom HUD --> | |
| <div class="absolute bottom-0 left-0 w-full flex justify-between items-end p-6 cs-hud"> | |
| <!-- Health & Armor --> | |
| <div class="flex gap-4"> | |
| <div class="bg-black/60 p-3 border-l-4 border-green-500"> | |
| <div class="text-xs text-gray-400">HEALTH</div> | |
| <div class="text-3xl font-bold text-green-500" id="health-val">100</div> | |
| </div> | |
| <div class="bg-black/60 p-3 border-l-4 border-blue-400"> | |
| <div class="text-xs text-gray-400">ARMOR</div> | |
| <div class="text-3xl font-bold text-blue-400" id="armor-val">100</div> | |
| </div> | |
| </div> | |
| <!-- Kill Feed --> | |
| <div id="kill-feed" class="absolute top-20 right-4 flex flex-col gap-1 text-right pointer-events-none"> | |
| <!-- Dynamic content --> | |
| </div> | |
| <!-- Ammo & Weapons --> | |
| <div class="flex items-end gap-6"> | |
| <div class="text-right"> | |
| <div class="text-6xl font-bold leading-none" id="ammo-clip">30</div> | |
| <div class="text-xl text-gray-400" id="ammo-reserve">90</div> | |
| </div> | |
| <div class="flex flex-col gap-1 w-32"> | |
| <div class="weapon-slot active bg-black/60 p-2 text-right text-sm font-bold" id="wep-primary">AK-47</div> | |
| <div class="weapon-slot bg-black/60 p-2 text-right text-sm font-bold" id="wep-secondary">GLOCK-18</div> | |
| <div class="weapon-slot bg-black/60 p-2 text-right text-sm font-bold" id="wep-knife">KNIFE</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Start Menu --> | |
| <div id="start-menu" | |
| class="menu-overlay absolute top-0 left-0 w-full h-full z-50 flex flex-col items-center justify-center text-center"> | |
| <h1 class="text-6xl font-bold mb-2 text-white italic tracking-tighter" | |
| style="text-shadow: 0 0 20px rgba(255,255,255,0.5);">COUNTER-STRIKE</h1> | |
| <h2 class="text-2xl text-gray-400 mb-8 tracking-[0.5em]">WEB OPS</h2> | |
| <div class="bg-black/80 p-8 border border-gray-700 max-w-md w-full rounded-lg shadow-2xl"> | |
| <p class="text-gray-300 mb-6 text-sm">Mouse to Look | WASD to Move | Click to Shoot | 1/2/3 for Weapons | B to Buy | |
| </p> | |
| <button onclick="game.start()" class="btn-cs w-full py-4 text-xl font-bold mb-4">Start Mission</button> | |
| <div class="text-xs text-gray-500 mt-4">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" | |
| target="_blank" class="text-blue-400 hover:underline">anycoder</a></div> | |
| </div> | |
| </div> | |
| <!-- Game Over Menu --> | |
| <div id="game-over-menu" | |
| class="menu-overlay absolute top-0 left-0 w-full h-full z-50 flex flex-col items-center justify-center hidden"> | |
| <h1 class="text-5xl font-bold mb-4 text-red-500">MISSION FAILED</h1> | |
| <p class="text-xl text-white mb-8">Kills: <span id="final-score">0</span></p> | |
| <button onclick="location.reload()" class="btn-cs px-8 py-3 text-lg font-bold">RETRY</button> | |
| </div> | |
| <!-- Buy Menu (Hidden by default) --> | |
| <div id="buy-menu" | |
| class="menu-overlay absolute top-0 left-0 w-full h-full z-40 flex items-center justify-center hidden"> | |
| <div class="bg-black/90 border border-yellow-600 p-8 w-[600px] max-w-full"> | |
| <h2 class="text-3xl text-yellow-500 font-bold mb-6 border-b border-gray-700 pb-2">BUY MENU</h2> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <button onclick="game.buyWeapon('ak47')" class="btn-cs p-3 text-left flex justify-between"><span>AK-47</span> <span class="text-yellow-500">$2700</span></button> | |
| <button onclick="game.buyWeapon('m4a1')" class="btn-cs p-3 text-left flex justify-between"><span>M4A4</span> <span class="text-yellow-500">$3100</span></button> | |
| <button onclick="game.buyWeapon('awp')" class="btn-cs p-3 text-left flex justify-between"><span>AWP</span> <span class="text-yellow-500">$4750</span></button> | |
| <button onclick="game.buyWeapon('deagle')" class="btn-cs p-3 text-left flex justify-between"><span>DESERT EAGLE</span> <span class="text-yellow-500">$700</span></button> | |
| <button onclick="game.buyWeapon('armor')" class="btn-cs p-3 text-left flex justify-between"><span>KEVLAR + HELMET</span> <span class="text-yellow-500">$1000</span></button> | |
| </div> | |
| <p class="mt-6 text-center text-gray-400 text-sm">Press 'B' or 'ESC' to close</p> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Audio Engine (Tone.js) --- | |
| const AudioEngine = { | |
| synth: null, | |
| noise: null, | |
| init: async () => { | |
| await Tone.start(); | |
| AudioEngine.synth = new Tone.PolySynth(Tone.Synth).toDestination(); | |
| AudioEngine.synth.volume.value = -10; | |
| // Simple noise synth for gunshots | |
| AudioEngine.noise = new Tone.NoiseSynth({ | |
| noise: { type: 'white' }, | |
| envelope: { attack: 0.001, decay: 0.1, sustain: 0 } | |
| }).toDestination(); | |
| }, | |
| playShoot: (type) => { | |
| if(!AudioEngine.noise) return; | |
| const duration = type === 'sniper' ? 0.3 : (type === 'pistol' ? 0.05 : 0.08); | |
| AudioEngine.noise.envelope.decay = duration; | |
| AudioEngine.noise.triggerAttackRelease(duration); | |
| // Add a punchy oscillator for the "click" sound | |
| const click = new Tone.MembraneSynth().toDestination(); | |
| click.volume.value = -20; | |
| click.triggerAttackRelease("C2", "32n"); | |
| }, | |
| playEmpty: () => { | |
| const click = new Tone.MembraneSynth().toDestination(); | |
| click.triggerAttackRelease("C1", "32n"); | |
| }, | |
| playHit: () => { | |
| const osc = new Tone.Oscillator(200, "square").toDestination().start(); | |
| osc.volume.value = -10; | |
| osc.frequency.rampTo(50, 0.1); | |
| osc.stop("+0.1"); | |
| }, | |
| playKill: () => { | |
| const synth = new Tone.Synth().toDestination(); | |
| synth.triggerAttackRelease("C5", "8n"); | |
| setTimeout(() => synth.triggerAttackRelease("E5", "8n"), 100); | |
| } | |
| }; | |
| // --- Game Constants & Config --- | |
| const WEAPONS = { | |
| glock: { name: "GLOCK-18", damage: 15, clip: 20, reserve: 120, rate: 150, spread: 0.05, type: 'pistol', automatic: true }, | |
| ak47: { name: "AK-47", damage: 35, clip: 30, reserve: 90, rate: 100, spread: 0.03, type: 'rifle', automatic: true }, | |
| m4a1: { name: "M4A4", damage: 30, clip: 30, reserve: 90, rate: 100, spread: 0.025, type: 'rifle', automatic: true }, | |
| awp: { name: "AWP", damage: 100, clip: 10, reserve: 30, rate: 1500, spread: 0.001, type: 'sniper', automatic: false }, | |
| deagle: { name: "DESERT EAGLE", damage: 55, clip: 7, reserve: 35, rate: 300, spread: 0.02, type: 'pistol', automatic: false }, | |
| knife: { name: "KNIFE", damage: 50, clip: 0, reserve: 0, rate: 500, spread: 0, type: 'melee', automatic: false } | |
| }; | |
| // --- Vector Math Helper --- | |
| class Vector3 { | |
| constructor(x, y, z) { this.x = x; this.y = y; this.z = z; } | |
| add(v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); } | |
| sub(v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); } | |
| mul(s) { return new Vector3(this.x * s, this.y * s, this.z * s); } | |
| div(s) { return new Vector3(this.x / s, this.y / s, this.z / s); } | |
| dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; } | |
| cross(v) { return new Vector3(this.y*v.z - this.z*v.y, this.z*v.x - this.x*v.z, this.x*v.y - this.y*v.x); } | |
| length() { return Math.sqrt(this.x*this.x + this.y*this.y + this.z*this.z); } | |
| normalize() { let l = this.length(); return l === 0 ? new Vector3(0,0,0) : this.div(l); } | |
| dist(v) { return this.sub(v).length(); } | |
| } | |
| // --- Core Game Classes --- | |
| class Camera { | |
| constructor() { | |
| this.pos = new Vector3(0, 1.8, 0); // Eye height | |
| this.dir = new Vector3(0, 0, 1); // Looking along Z | |
| this.plane = new Vector3(1, 0, 0); // Camera plane (perpendicular to dir) | |
| this.yaw = 0; | |
| this.pitch = 0; | |
| } | |
| rotate(angle) { | |
| this.yaw += angle; | |
| // Update direction vector | |
| this.dir.x = Math.sin(this.yaw); | |
| this.dir.z = Math.cos(this.yaw); | |
| // Update camera plane (FOV 66 degrees approx) | |
| this.plane.x = Math.cos(this.yaw) * 0.66; | |
| this.plane.z = -Math.sin(this.yaw) * 0.66; | |
| } | |
| } | |
| class Entity { | |
| constructor(x, z, type, hp) { | |
| this.pos = new Vector3(x, 0, z); | |
| this.type = type; // 'enemy' or 'player' | |
| this.hp = hp; | |
| this.radius = 0.4; | |
| this.dead = false; | |
| this.state = 'idle'; // idle, hunt, attack, dead | |
| this.timer = 0; | |
| this.lastShot = 0; | |
| } | |
| update(dt, player, map) { | |
| if (this.dead) return; | |
| const dist = this.pos.dist(player.pos); | |
| // AI Logic | |
| if (this.type === 'enemy') { | |
| if (dist < 15) { | |
| this.state = 'hunt'; | |
| // Simple move towards player | |
| let moveDir = player.pos.sub(this.pos).normalize(); | |
| // Wall collision check (simple) | |
| let nextX = this.pos.x + moveDir.x * 2 * dt; | |
| let nextZ = this.pos.z + moveDir.z * 2 * dt; | |
| if (map[Math.floor(nextX)] && map[Math.floor(nextX)][Math.floor(this.pos.z)] === 0) this.pos.x = nextX; | |
| if (map[Math.floor(this.pos.x)] && map[Math.floor(this.pos.x)][Math.floor(nextZ)] === 0) this.pos.z = nextZ; | |
| // Attack | |
| if (dist < 10 && Date.now() - this.lastShot > 1000) { | |
| // Check line of sight | |
| if (this.raycastToPlayer(player, map)) { | |
| player.takeDamage(10); | |
| this.lastShot = Date.now(); | |
| // Visual feedback for being shot | |
| document.getElementById('damage-overlay').style.opacity = 1; | |
| setTimeout(() => document.getElementById('damage-overlay').style.opacity = 0, 200); | |
| } | |
| } | |
| } else { | |
| this.state = 'idle'; | |
| } | |
| } | |
| } | |
| raycastToPlayer(player, map) { | |
| // Simplified raycast to check if wall is between enemy and player | |
| let dx = player.pos.x - this.pos.x; | |
| let dz = player.pos.z - this.pos.z; | |
| let dist = Math.sqrt(dx*dx + dz*dz); | |
| let steps = dist * 2; // Resolution | |
| for(let i=0; i<steps; i++) { | |
| let t = i/steps; | |
| let cx = this.pos.x + dx * t; | |
| let cz = this.pos.z + dz * t; | |
| let mx = Math.floor(cx); | |
| let mz = Math.floor(cz); | |
| if (mx < 0 || mx >= map.length || mz < 0 || mz >= map[0].length) return false; | |
| if (map[mx][mz] > 0) return false; // Wall hit | |
| } | |
| return true; | |
| } | |
| } | |
| class Game { | |
| constructor() { | |
| this.canvas = document.getElementById('gameCanvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.width = window.innerWidth; | |
| this.height = window.innerHeight; | |
| this.canvas.width = this.width; | |
| this.canvas.height = this.height; | |
| this.camera = new Camera(); | |
| this.keys = {}; | |
| this.entities = []; | |
| this.particles = []; | |
| this.bullets = []; // Visual only | |
| // Map 1 = Wall, 0 = Empty | |
| this.mapSize = 24; | |
| this.map = []; | |
| this.generateMap(); | |
| this.player = { | |
| pos: new Vector3(2, 1.8, 2), | |
| hp: 100, | |
| armor: 0, | |
| speed: 4, | |
| money: 800, | |
| kills: 0, | |
| takeDamage: (amount) => { | |
| if (this.player.armor > 0) { | |
| let absorb = amount * 0.66; | |
| this.player.armor -= absorb; | |
| this.player.hp -= (amount - absorb); | |
| } else { | |
| this.player.hp -= amount; | |
| } | |
| if (this.player.hp <= 0) this.gameOver(); | |
| this.updateHealthUI(); | |
| } | |
| }; | |
| this.camera.pos = this.player.pos; | |
| this.currentWeapon = 'ak47'; | |
| this.weapons = { | |
| primary: 'ak47', | |
| secondary: 'glock', | |
| knife: 'knife' | |
| }; | |
| this.ammo = { | |
| ak47: { clip: 30, reserve: 90 }, | |
| m4a1: { clip: 0, reserve: 0 }, | |
| awp: { clip: 0, reserve: 0 }, | |
| deagle: { clip: 0, reserve: 0 }, | |
| glock: { clip: 20, reserve: 120 } | |
| }; | |
| this.lastShotTime = 0; | |
| this.recoil = 0; | |
| this.isPointerLocked = false; | |
| this.gameRunning = false; | |
| this.gameTime = 90; // seconds | |
| this.lastTime = 0; | |
| this.setupInputs(); | |
| this.resize(); | |
| window.addEventListener('resize', () => this.resize()); | |
| } | |
| generateMap() { | |
| // Procedural-ish map generation (simple box maze) | |
| for(let x=0; x<this.mapSize; x++) { | |
| this.map[x] = []; | |
| for(let z=0; z<this.mapSize; z++) { | |
| if (x===0 || x===this.mapSize-1 || z===0 || z===this.mapSize-1) { | |
| this.map[x][z] = 1; | |
| } else if (Math.random() < 0.2 && (x > 3 || z > 3)) { | |
| this.map[x][z] = 1; // Random walls | |
| } else { | |
| this.map[x][z] = 0; | |
| } | |
| } | |
| } | |
| // Ensure start is clear | |
| this.map[1][1] = 0; this.map[2][1] = 0; this.map[1][2] = 0; | |
| } | |
| spawnEnemies() { | |
| if (this.entities.length < 5) { | |
| let x = Math.floor(Math.random() * (this.mapSize-2)) + 1; | |
| let z = Math.floor(Math.random() * (this.mapSize-2)) + 1; | |
| if (this.map[x] && this.map[x][z] === 0 && (Math.abs(x - this.player.pos.x) > 5)) { | |
| this.entities.push(new Entity(x, z, 'enemy', 100)); | |
| } | |
| } | |
| } | |
| start() { | |
| document.getElementById('start-menu').style.display = 'none'; | |
| document.getElementById('hud').classList.remove('hidden'); | |
| this.canvas.requestPointerLock(); | |
| AudioEngine.init(); | |
| this.gameRunning = true; | |
| this.lastTime = performance.now(); | |
| requestAnimationFrame((t) => this.loop(t)); | |
| // Spawn Loop | |
| setInterval(() => { | |
| if(this.gameRunning) this.spawnEnemies(); | |
| }, 3000); | |
| // Timer | |
| setInterval(() => { | |
| if(this.gameRunning && this.gameTime > 0) { | |
| this.gameTime--; | |
| let m = Math.floor(this.gameTime/60).toString().padStart(2,'0'); | |
| let s = (this.gameTime%60).toString().padStart(2,'0'); | |
| document.getElementById('timer').innerText = `${m}:${s}`; | |
| if(this.gameTime === 0) this.gameOver(); | |
| } | |
| }, 1000); | |
| } | |
| resize() { | |
| this.width = window.innerWidth; | |
| this.height = window.innerHeight; | |
| this.canvas.width = this.width; | |
| this.canvas.height = this.height; | |
| } | |
| setupInputs() { | |
| document.addEventListener('keydown', (e) => { | |
| this.keys[e.code] = true; | |
| if (e.code === 'KeyB') this.toggleBuyMenu(); | |
| if (e.code === 'Digit1') this.switchWeapon(this.weapons.primary); | |
| if (e.code === 'Digit2') this.switchWeapon(this.weapons.secondary); | |
| if (e.code === 'Digit3') this.switchWeapon('knife'); | |
| if (e.code === 'KeyR') this.reload(); | |
| if (e.code === 'Escape') { | |
| const menu = document.getElementById('buy-menu'); | |
| if (!menu.classList.contains('hidden')) { | |
| this.toggleBuyMenu(); | |
| } | |
| } | |
| }); | |
| document.addEventListener('keyup', (e) => this.keys[e.code] = false); | |
| document.addEventListener('mousemove', (e) => { | |
| if (this.isPointerLocked) { | |
| this.camera.rotate(e.movementX * 0.002); | |
| } | |
| }); | |
| document.addEventListener('mousedown', (e) => { | |
| if (this.gameRunning && e.button === 0 && !document.getElementById('buy-menu').classList.contains('hidden')) return; | |
| if (this.gameRunning && e.button === 0) this.shoot(); | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| this.isPointerLocked = (document.pointerLockElement === this.canvas); | |
| }); | |
| } | |
| switchWeapon(key) { | |
| this.currentWeapon = key; | |
| // Update UI | |
| ['primary', 'secondary', 'knife'].forEach(w => { | |
| let el = document.getElementById(`wep-${w}`); | |
| if(el) { | |
| el.classList.remove('active'); | |
| el.style.borderLeft = 'none'; | |
| el.style.background = 'rgba(0,0,0,0.6)'; | |
| } | |
| }); | |
| let slot = 'primary'; | |
| if (key === this.weapons.secondary) slot = 'secondary'; | |
| if (key === 'knife') slot = 'knife'; | |
| let activeEl = document.getElementById(`wep-${slot}`); | |
| if(activeEl) { | |
| activeEl.classList.add('active'); | |
| activeEl.style.borderLeft = '3px solid white'; | |
| activeEl.style.background = 'rgba(255,255,255,0.2)'; | |
| } | |
| this.updateAmmoUI(); | |
| } | |
| toggleBuyMenu() { | |
| const menu = document.getElementById('buy-menu'); | |
| if (menu.classList.contains('hidden')) { | |
| menu.classList.remove('hidden'); | |
| document.exitPointerLock(); | |
| } else { | |
| menu.classList.add('hidden'); | |
| this.canvas.requestPointerLock(); | |
| } | |
| } | |
| buyWeapon(id) { | |
| if (id === 'armor') { | |
| if (this.player.money >= 1000) { | |
| this.player.armor = 100; | |
| this.player.money -= 1000; | |
| this.updateHealthUI(); | |
| } | |
| return; | |
| } | |
| const cost = { 'ak47': 2700, 'm4a1': 3100, 'awp': 4750, 'deagle': 700 }[id]; | |
| if (this.player.money >= cost) { | |
| this.player.money -= cost; | |
| this.weapons.primary = id; | |
| this.ammo[id] = { clip: WEAPONS[id].clip, reserve: WEAPONS[id].reserve * 3 }; // Give ammo | |
| this.switchWeapon(id); | |
| this.toggleBuyMenu(); | |
| } | |
| } | |
| reload() { | |
| const w = WEAPONS[this.currentWeapon]; | |
| const a = this.ammo[this.currentWeapon]; | |
| if (!w || !a) return; | |
| if (a.clip < w.clip && a.reserve > 0) { | |
| let need = w.clip - a.clip; | |
| let take = Math.min(need, a.reserve); | |
| a.clip += take; | |
| a.reserve -= take; | |
| this.updateAmmoUI(); | |
| } | |
| } | |
| shoot() { | |
| const now = Date.now(); | |
| const w = WEAPONS[this.currentWeapon]; | |
| const a = this.ammo[this.currentWeapon]; | |
| if (!w) return; | |
| if (now - this.lastShotTime < w.rate) return; | |
| if (this.currentWeapon === 'knife') { | |
| this.meleeAttack(); | |
| this.lastShotTime = now; | |
| return; | |
| } | |
| if (!a || a.clip <= 0) { | |
| AudioEngine.playEmpty(); | |
| return; | |
| } | |
| a.clip--; | |
| this.lastShotTime = now; | |
| this.updateAmmoUI(); | |
| AudioEngine.playShoot(w.type); | |
| // Visuals | |
| let flash = document.getElementById('muzzle-flash'); | |
| flash.classList.remove('active'); | |
| void flash.offsetWidth; // trigger reflow | |
| flash.classList.add('active'); | |
| // Recoil kick | |
| this.recoil = 5; | |
| // Add spread | |
| let spreadX = (Math.random() - 0.5) * w.spread; | |
| let spreadZ = (Math.random() - 0.5) * w.spread; | |
| let rayDir = new Vector3( | |
| this.camera.dir.x + spreadX, | |
| 0, | |
| this.camera.dir.z + spreadZ | |
| ).normalize(); | |
| // Simple DDA Raycaster for hit detection | |
| let mapX = Math.floor(this.player.pos.x); | |
| let mapZ = Math.floor(this.player.pos.z); | |
| let sideDistX, sideDistZ; | |
| let deltaDistX = Math.abs(1 / rayDir.x); | |
| let deltaDistZ = Math.abs(1 / rayDir.z); | |
| let perpWallDist; | |
| let stepX, stepZ; | |
| let hit = 0; | |
| let side; | |
| if (rayDir.x < 0) { stepX = -1; sideDistX = (this.player.pos.x - mapX) * deltaDistX; } | |
| else { stepX = 1; sideDistX = (mapX + 1.0 - this.player.pos.x) * deltaDistX; } | |
| if (rayDir.z < 0) { stepZ = -1; sideDistZ = (this.player.pos.z - mapZ) * deltaDistZ; } | |
| else { stepZ = 1; sideDistZ = (mapZ + 1.0 - this.player.pos.z) * deltaDistZ; } | |
| let maxDist = 50; // Max range | |
| while (hit === 0) { | |
| if (sideDistX < sideDistZ) { | |
| sideDistX += deltaDistX; | |
| mapX += stepX; | |
| side = 0; | |
| } else { | |
| sideDistZ += deltaDistZ; | |
| mapZ += stepZ; | |
| side = 1; | |
| } | |
| if (mapX < 0 || mapX >= this.mapSize || mapZ < 0 || mapZ >= this.mapSize) break; | |
| if (this.map[mapX][mapZ] > 0) hit = 1; | |
| // Check entity intersection roughly | |
| for (let e of this.entities) { | |
| if (e.dead) continue; | |
| let dist = e.pos.dist(this.player.pos); | |
| if (dist > maxDist) continue; | |
| let ex = Math.floor(e.pos.x); | |
| let ez = Math.floor(e.pos.z); | |
| if (ex === mapX && ez === mapZ) { | |
| this.hitEntity(e, w.damage); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| meleeAttack() { | |
| for (let e of this.entities) { | |
| if (e.dead) continue; | |
| if (e.pos.dist(this.player.pos) < 2.0) { | |
| this.hitEntity(e, 50); | |
| return; | |
| } | |
| } | |
| } | |
| hitEntity(entity, damage) { | |
| entity.hp -= damage; | |
| AudioEngine.playHit(); | |
| // Hitmarker | |
| let hm = document.getElementById('hitmarker'); | |
| hm.classList.remove('active'); | |
| void hm.offsetWidth; | |
| hm.classList.add('active'); | |
| if (entity.hp <= 0) { | |
| entity.dead = true; | |
| this.player.kills++; | |
| this.player.money += 300; | |
| AudioEngine.playKill(); | |
| this.addKillFeed("Player", "Terrorist", this.currentWeapon === 'knife' ? 'KNIFE' : WEAPONS[this.currentWeapon].name); | |
| setTimeout(() => { | |
| this.entities = this.entities.filter(ent => ent !== entity); | |
| }, 5000); | |
| } | |
| } | |
| addKillFeed(killer, victim, weapon) { | |
| const feed = document.getElementById('kill-feed'); | |
| const div = document.createElement('div'); | |
| div.className = "bg-black/50 p-1 text-xs text-white border-l-2 border-green-500 animate-pulse"; | |
| div.innerHTML = `<span class="text-blue-300">${killer}</span> [${weapon}] <span class="text-red-400">${victim}</span>`; | |
| feed.appendChild(div); | |
| if(feed.children.length > 5) feed.removeChild(feed.firstChild); | |
| setTimeout(() => div.remove(), 5000); | |
| } | |
| updateHealthUI() { | |
| document.getElementById('health-val').innerText = Math.ceil(Math.max(0, this.player.hp)); | |
| document.getElementById('armor-val').innerText = Math.ceil(Math.max(0, this.player.armor)); | |
| } | |
| updateAmmoUI() { | |
| const w = WEAPONS[this.currentWeapon]; | |
| const a = this.ammo[this.currentWeapon]; | |
| document.getElementById('ammo-clip').innerText = a ? a.clip : 0; | |
| document.getElementById('ammo-reserve').innerText = a ? a.reserve : 0; | |
| let pri = document.getElementById('wep-primary'); | |
| let sec = document.getElementById('wep-secondary'); | |
| if(pri) pri.innerText = WEAPONS[this.weapons.primary].name; | |
| if(sec) sec.innerText = WEAPONS[this.weapons.secondary].name; | |
| } | |
| gameOver() { | |
| this.gameRunning = false; | |
| document.exitPointerLock(); | |
| document.getElementById('game-over-menu').classList.remove('hidden'); | |
| document.getElementById('hud').classList.add('hidden'); | |
| document.getElementById('final-score').innerText = this.player.kills; | |
| } | |
| update(dt) { | |
| // Movement | |
| let moveSpeed = this.player.speed * dt; | |
| let moveVec = new Vector3(0, 0, 0); | |
| if (this.keys['KeyW']) moveVec = moveVec.add(this.camera.dir); | |
| if (this.keys['KeyS']) moveVec = moveVec.sub(this.camera.dir); | |
| if (this.keys['KeyA']) moveVec = moveVec.sub(this.camera.dir.cross(new Vector3(0, 1, 0)).normalize()); | |
| if (this.keys['KeyD']) moveVec = moveVec.add(this.camera.dir.cross(new Vector3(0, 1, 0)).normalize()); | |
| if (moveVec.length() > 0) { | |
| moveVec = moveVec.normalize().mul(moveSpeed); | |
| let nextX = this.player.pos.x + moveVec.x; | |
| let nextZ = this.player.pos.z + moveVec.z; | |
| let mx = Math.floor(nextX); | |
| let mz = Math.floor(nextZ); | |
| let cx = Math.floor(this.player.pos.x); | |
| let cz = Math.floor(this.player.pos.z); | |
| if (mx >= 0 && mx < this.mapSize && this.map[mx] && this.map[mx][cz] === 0) this.player.pos.x = nextX; | |
| if (mz >= 0 && mz < this.mapSize && this.map[cx] && this.map[cx][mz] === 0) this.player.pos.z = nextZ; | |
| } | |
| if (this.recoil > 0) this.recoil -= dt * 10; | |
| else this.recoil = 0; | |
| this.entities.forEach(e => e.update(dt, this.player, this.map)); | |
| } | |
| render() { | |
| // Floor & Ceiling | |
| const grad = this.ctx.createLinearGradient(0, 0, 0, this.height); | |
| grad.addColorStop(0, '#222'); | |
| grad.addColorStop(0.5, '#444'); | |
| grad.addColorStop(0.5, '#333'); | |
| grad.addColorStop(1, '#111'); | |
| this.ctx.fillStyle = grad; | |
| this.ctx.fillRect(0, 0, this.width, this.height); | |
| // Raycasting Walls | |
| for (let x = 0; x < this.width; x += 2) { | |
| let cameraX = 2 * x / this.width - 1; | |
| let rayDirX = this.camera.dir.x + this.camera.plane.x * cameraX; | |
| let rayDirZ = this.camera.dir.z + this.camera.plane.z * cameraX; | |
| let mapX = Math.floor(this.player.pos.x); | |
| let mapZ = Math.floor(this.player.pos.z); | |
| let deltaDistX = Math.abs(1 / rayDirX); | |
| let deltaDistZ = Math.abs(1 / rayDirZ); | |
| let stepX, stepZ, sideDistX, sideDistZ; | |
| if (rayDirX < 0) { stepX = -1; sideDistX = (this.player.pos.x - mapX) * deltaDistX; } | |
| else { stepX = 1; sideDistX = (mapX + 1.0 - this.player.pos.x) * deltaDistX; } | |
| if (rayDirZ < 0) { stepZ = -1; sideDistZ = (this.player.pos.z - mapZ) * deltaDistZ; } | |
| else { stepZ = 1; sideDistZ = (mapZ + 1.0 - this.player.pos.z) * deltaDistZ; } | |
| let hit = 0, side, wallDist; | |
| while (hit === 0) { | |
| if (sideDistX < sideDistZ) { | |
| sideDistX += deltaDistX; | |
| mapX += stepX; | |
| side = 0; | |
| } else { | |
| sideDistZ += deltaDistZ; | |
| mapZ += stepZ; | |
| side = 1; | |
| } | |
| if (mapX < 0 || mapX >= this.mapSize || mapZ < 0 || mapZ >= this.mapSize) { hit = 1; break; } | |
| if (this.map[mapX][mapZ] > 0) hit = 1; | |
| } | |
| if (hit === 0) continue; | |
| if (side === 0) wallDist = (mapX - this.player.pos.x + (1 - stepX) / 2) / rayDirX; | |
| else wallDist = (mapZ - this.player.pos.z + (1 - stepZ) / 2) / rayDirZ; | |
| let lineHeight = Math.floor(this.height / wallDist); | |
| let drawStart = Math.floor(-lineHeight / 2 + this.height / 2) + this.recoil; | |
| let r=180, g=180, b=180; | |
| if(side===1) { r=140; g=140; b=140; } | |
| let alpha = Math.min(1, wallDist / 15); | |
| r = r * (1-alpha); | |
| g = g * (1-alpha); | |
| b = b * (1-alpha); | |
| this.ctx.fillStyle = `rgb(${Math.floor(r)},${Math.floor(g)},${Math.floor(b)})`; | |
| this.ctx.fillRect(x, drawStart, 2, lineHeight); | |
| } | |
| // Render Entities | |
| this.entities.forEach(e => { | |
| if (e.dead) return; | |
| let spriteX = e.pos.x - this.player.pos.x; | |
| let spriteZ = e.pos.z - this.player.pos.z; | |
| let invDet = 1.0 / (this.camera.plane.x * this.camera.dir.z - this.camera.dir.x * this.camera.plane.z); | |
| let transformX = invDet * (this.camera.dir.z * spriteX - this.camera.dir.x * spriteZ); | |
| let transformY = invDet * (-this.camera.plane.z * spriteX + this.camera.plane.x * spriteZ); | |
| if (transformY <= 0 || transformY > 20) return; | |
| let spriteScreenX = Math.floor((this.width / 2) * (1 + transformX / transformY)); | |
| let spriteHeight = Math.abs(Math.floor(this.height / (transformY))); | |
| let drawStartY = -spriteHeight / 2 + this.height / 2; | |
| let drawStartX = -spriteHeight / 2 + spriteScreenX; | |
| if (e.type === 'enemy') { | |
| this.ctx.fillStyle = 'rgba(255, 0, 0, 0.8)'; | |
| this.ctx.fillRect(drawStartX, drawStartY, spriteHeight/2, spriteHeight); | |
| this.ctx.font = "12px Arial"; | |
| this.ctx.fillStyle = "white"; | |
| this.ctx.fillText("TERRORIST", drawStartX, drawStartY - 5); | |
| } | |
| }); | |
| // Weapon Viewmodel | |
| let bob = Math.sin(Date.now()/100) * 5; | |
| let recoilY = this.recoil * 2; | |
| this.ctx.fillStyle = '#333'; | |
| this.ctx.fillRect(this.width/2 + 50 + bob, this.height - 150 - recoilY, 150, 100); | |
| this.ctx.fillStyle = '#111'; | |
| this.ctx.fillRect(this.width/2 + 200 + bob, this.height - 120 - recoilY, 100, 40); | |
| } | |
| loop(time) { | |
| if (!this.gameRunning) return; | |
| let dt = (time - this.lastTime) / 1000; | |
| this.lastTime = time; | |
| this.update(dt); | |
| this.render(); | |
| requestAnimationFrame((t) => this.loop(t)); | |
| } | |
| } | |
| // Init | |
| const game = new Game(); | |
| </script> | |
| </body> | |
| </html> |