Spaces:
Running
Running
| import * as THREE from 'three'; | |
| import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; | |
| import { CFG } from './config.js'; | |
| import { G } from './globals.js'; | |
| import { makeRandom } from './utils.js'; | |
| import { setupLights } from './lighting.js'; | |
| import { setupGround, generateForest, generateGroundCover, getTerrainHeight, tickForest } from './world.js'; | |
| import { setupWeapon, updateWeaponAnchor, beginReload, updateWeapon } from './weapon.js'; | |
| import { setupEvents } from './events.js'; | |
| import { updatePlayer } from './player.js'; | |
| import { updateEnemies } from './enemies.js'; | |
| import { startNextWave, updateWaves } from './waves.js'; | |
| import { updateHUD, showOverlay, updateDamageEffect, updateHealEffect, updateCrosshair, updateHitMarker } from './hud.js'; | |
| import { updateFX } from './fx.js'; | |
| import { updateCasings } from './casings.js'; | |
| import { updateEnemyProjectiles } from './projectiles.js'; | |
| import { updateGrenades } from './grenades.js'; | |
| import { updateDayNight } from './daynight.js'; | |
| import { performShooting } from './combat.js'; | |
| import { updatePickups } from './pickups.js'; | |
| import { updateHelmets } from './helmets.js'; | |
| import { setupClouds, updateClouds } from './clouds.js'; | |
| import { startMusic, stopMusic } from './audio.js'; | |
| import { setupMountains, updateMountains } from './mountains.js'; | |
| init(); | |
| animate(); | |
| function init() { | |
| // Renderer | |
| G.renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| G.renderer.setSize(window.innerWidth, window.innerHeight); | |
| // Lower pixel ratio cap for significant fill-rate savings | |
| G.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.0)); | |
| G.renderer.shadowMap.enabled = true; | |
| // Use the cheapest shadow filter for CPU savings | |
| G.renderer.shadowMap.type = THREE.BasicShadowMap; | |
| document.body.appendChild(G.renderer.domElement); | |
| // Scene | |
| G.scene = new THREE.Scene(); | |
| G.scene.background = new THREE.Color(0x0a1015); | |
| G.scene.fog = new THREE.FogExp2(0x05070a, CFG.fogDensity); | |
| // Camera | |
| G.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| // Controls | |
| G.controls = new PointerLockControls(G.camera, document.body); | |
| // Clock | |
| G.clock = new THREE.Clock(); | |
| // Seeded random | |
| G.random = makeRandom(CFG.seed); | |
| // Player | |
| const startY = getTerrainHeight(0, 0) + (CFG.player.eyeHeight || 1.8); | |
| G.player = { | |
| pos: new THREE.Vector3(0, startY, 0), | |
| vel: new THREE.Vector3(), | |
| speed: CFG.player.speed, | |
| radius: CFG.player.radius, | |
| health: CFG.player.health, | |
| alive: true, | |
| score: 0, | |
| yVel: 0, | |
| grounded: true, | |
| // Apex-like movement state | |
| sliding: false, | |
| jumpBuffer: 0, | |
| coyoteTimer: 0, | |
| lastWallNormal: new THREE.Vector3(), | |
| wallContactTimer: 0 | |
| , jumpHeld: false | |
| }; | |
| G.weapon.ammo = CFG.gun.magSize; | |
| G.weapon.reserve = Infinity; | |
| G.grenadeCount = 0; | |
| // Add camera to scene | |
| G.camera.position.copy(G.player.pos); | |
| // Lights | |
| setupLights(); | |
| // Ground and world | |
| setupGround(); | |
| // Ground cover before or after trees — independent | |
| generateGroundCover(); | |
| generateForest(); | |
| setupClouds(); | |
| setupMountains(); | |
| // Weapon | |
| setupWeapon(); | |
| updateWeaponAnchor(); | |
| // Events | |
| setupEvents({ | |
| startGame, | |
| restartGame, | |
| beginReload, | |
| updateWeaponAnchor | |
| }); | |
| // Pre-warm shaders to avoid first-frame hitches | |
| if (G.renderer && G.scene && G.camera) { | |
| try { G.renderer.compile(G.scene, G.camera); } catch (_) {} | |
| } | |
| } | |
| function startGame() { | |
| G.state = 'playing'; | |
| G.player.health = CFG.player.health; | |
| G.player.score = 0; | |
| G.player.alive = true; | |
| G.player.pos.set(0, getTerrainHeight(0, 0) + (CFG.player.eyeHeight || 1.8), 0); | |
| G.player.vel.set(0, 0, 0); | |
| G.player.yVel = 0; | |
| G.player.grounded = true; | |
| G.player.sliding = false; | |
| G.player.jumpBuffer = 0; | |
| G.player.coyoteTimer = 0; | |
| G.player.lastWallNormal.set(0, 0, 0); | |
| G.player.wallContactTimer = 0; | |
| G.camera.position.copy(G.player.pos); | |
| G.damageFlash = 0; | |
| G.healFlash = 0; | |
| G.hitFlash = 0; | |
| // Grenades reset | |
| G.grenades.length = 0; | |
| G.heldGrenade = null; | |
| G.grenadeCount = 0; | |
| // Clear enemies | |
| for (const enemy of G.enemies) { | |
| G.scene.remove(enemy.mesh); | |
| } | |
| G.enemies.length = 0; | |
| // Clear enemy projectiles | |
| for (const p of G.enemyProjectiles) { | |
| G.scene.remove(p.mesh); | |
| } | |
| G.enemyProjectiles.length = 0; | |
| // Clear pickups/orbs | |
| for (const o of G.orbs) { | |
| G.scene.remove(o.mesh); | |
| } | |
| G.orbs.length = 0; | |
| // Clear wave powerups | |
| for (const p of G.powerups || []) { | |
| if (p && p.mesh) G.scene.remove(p.mesh); | |
| } | |
| G.powerups.length = 0; | |
| // Clear any detached helmets | |
| for (const h of G.helmets) { | |
| G.scene.remove(h.mesh); | |
| } | |
| G.helmets.length = 0; | |
| // Clear ejected casings | |
| for (const c of G.casings) { | |
| G.scene.remove(c.mesh); | |
| } | |
| G.casings.length = 0; | |
| // Reset waves | |
| G.waves.current = 1; | |
| G.waves.aliveCount = 0; | |
| G.waves.spawnQueue = 0; | |
| G.waves.nextSpawnTimer = 0; | |
| G.waves.breakTimer = 0; | |
| G.waves.inBreak = false; | |
| G.waves.wolvesToSpawn = 0; | |
| G.waves.shamansToSpawn = 0; | |
| G.waves.golemsToSpawn = 0; | |
| // Reset weapon | |
| G.weapon.ammo = CFG.gun.magSize; | |
| G.weapon.reloading = false; | |
| G.weapon.reloadTimer = 0; | |
| G.weapon.recoil = 0; | |
| G.weapon.spread = CFG.gun.spreadMin ?? CFG.gun.bloom ?? 0; | |
| G.weapon.targetSpread = G.weapon.spread; | |
| G.weapon.viewPitch = 0; | |
| G.weapon.viewYaw = 0; | |
| G.weapon.appliedPitch = 0; | |
| G.weapon.appliedYaw = 0; | |
| G.weapon.rofMult = 1; | |
| G.weapon.rofBuffTimer = 0; | |
| G.weapon.rofBuffTotal = 0; | |
| G.movementMult = 1; | |
| G.movementBuffTimer = 0; | |
| G.weapon.infiniteAmmoTimer = 0; | |
| G.weapon.infiniteAmmoTotal = 0; | |
| G.weapon.ammoBeforeInf = null; | |
| G.weapon.reserveBeforeInf = null; | |
| const overlay = document.getElementById('overlay'); | |
| if (overlay) overlay.classList.add('hidden'); | |
| updateHUD(); | |
| // Initialize day/night visuals immediately | |
| updateDayNight(0); | |
| startNextWave(); | |
| // Start background music (once audio is unlocked) | |
| startMusic(); | |
| } | |
| function restartGame() { | |
| G.controls.lock(); | |
| } | |
| function gameOver() { | |
| G.state = 'gameover'; | |
| G.controls.unlock(); | |
| showOverlay('gameover'); | |
| // Gracefully fade out music | |
| stopMusic(0.6); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = Math.min(G.clock.getDelta(), 0.1); | |
| if (G.state === 'playing') { | |
| updatePlayer(delta); | |
| updateEnemies(delta, gameOver); | |
| updateEnemyProjectiles(delta, gameOver); | |
| updateWaves(delta); | |
| performShooting(delta); | |
| updateWeapon(delta); | |
| updatePickups(delta); | |
| updateHUD(); | |
| updateCrosshair(delta); | |
| updateHitMarker(delta); | |
| updateDamageEffect(delta); | |
| updateHealEffect(delta); | |
| } | |
| updateDayNight(delta); | |
| updateFX(delta); | |
| updateHelmets(delta); | |
| updateCasings(delta); | |
| // Update grenades and previews last among gameplay | |
| if (G.state === 'playing') { | |
| updateGrenades(delta); | |
| if (!G.player.alive) { | |
| gameOver(); | |
| } | |
| } | |
| updateClouds(delta); | |
| updateMountains(delta); | |
| // Update subtle foliage wind sway using elapsedTime (avoid double-advancing clock) | |
| tickForest(G.clock.elapsedTime); | |
| G.renderer.render(G.scene, G.camera); | |
| // Lightweight FPS meter (updates ~2x/sec) | |
| if (!G._fpsAccum) { G._fpsAccum = 0; G._fpsFrames = 0; G._fpsNext = 0.5; } | |
| G._fpsAccum += delta; G._fpsFrames++; | |
| if (G._fpsAccum >= G._fpsNext) { | |
| const fps = Math.round(G._fpsFrames / G._fpsAccum); | |
| const el = document.getElementById('fps'); | |
| if (el) { | |
| el.textContent = String(fps); | |
| } | |
| G._fpsAccum = 0; G._fpsFrames = 0; | |
| } | |
| // Process a small budget of deferred disposals to avoid spikes | |
| if (G.disposeQueue && G.disposeQueue.length) { | |
| const budget = 24; // dispose up to N geometries per frame | |
| for (let i = 0; i < budget && G.disposeQueue.length; i++) { | |
| const geom = G.disposeQueue.pop(); | |
| if (geom && geom.dispose) { | |
| try { geom.dispose(); } catch (e) { /* noop */ } | |
| } | |
| } | |
| } | |
| } | |