diff --git a/src/experience/camera.js b/src/experience/camera.js index 3acc1aa..96c1ff0 100644 --- a/src/experience/camera.js +++ b/src/experience/camera.js @@ -1,41 +1,42 @@ import * as THREE from "three"; - -import Experience from "./index.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import Experience from "./index.js"; export default class Camera { - constructor() { - this.experience = new Experience(); - this.sizes = this.experience.sizes; - this.scene = this.experience.scene; - this.canvas = this.experience.canvas; + constructor() { + const exp = new Experience(); + this.sizes = exp.sizes; + this.scene = exp.scene; + this.canvas = exp.canvas; - this.setInstance(); - this.setControls(); - } + this._createInstance(); + this._createControls(); + } - setInstance() { - this.instance = new THREE.PerspectiveCamera( - 75, - this.sizes.width / this.sizes.height, - 0.1, - 10000, - ); - this.instance.position.set(3, 90, -10); - this.scene.add(this.instance); - } + _createInstance() { + this.instance = new THREE.PerspectiveCamera( + 75, + this.sizes.width / this.sizes.height, + 0.1, + 10000, + ); + this.instance.position.set(3, 90, -10); + this.scene.add(this.instance); + } - setControls() { - this.controls = new OrbitControls(this.instance, this.canvas); - this.controls.enableDamping = true; - } + _createControls() { + // OrbitControls are overridden during gameplay by the vehicle's chase-cam, + // but remain useful in #debug mode for free-look. + this.controls = new OrbitControls(this.instance, this.canvas); + this.controls.enableDamping = true; + } - resize() { - this.instance.aspect = this.sizes.width / this.sizes.height; - this.instance.updateProjectionMatrix(); - } + resize() { + this.instance.aspect = this.sizes.width / this.sizes.height; + this.instance.updateProjectionMatrix(); + } - update() { - this.controls.update(); - } + update() { + this.controls.update(); + } } diff --git a/src/experience/controls.js b/src/experience/controls.js index a38fd13..1dc4c46 100644 --- a/src/experience/controls.js +++ b/src/experience/controls.js @@ -10,6 +10,7 @@ export default class Controls { ArrowLeft: false, ArrowRight: false, g: false, + e: false, }; } diff --git a/src/experience/game-manager.js b/src/experience/game-manager.js new file mode 100644 index 0000000..b39bc8c --- /dev/null +++ b/src/experience/game-manager.js @@ -0,0 +1,174 @@ +// GameManager — owns levels, score, countdown timer, HUD, and end-game flow. +// It is deliberately decoupled from Three.js: no scene/physics references. + +const LEVELS = [ + { level: 1, cargoCount: 2, timeLimit: 300 }, + { level: 2, cargoCount: 3, timeLimit: 300 }, + { level: 3, cargoCount: 4, timeLimit: 240 }, + { level: 4, cargoCount: 5, timeLimit: 240 }, + { level: 5, cargoCount: 6, timeLimit: 180 }, +]; + +const LEVEL_LABELS = [ + "Mission 1 — First Deployment", + "Mission 2 — Hostile Waters", + "Mission 3 — Storm Warning", + "Mission 4 — Night Ops", + "Mission 5 — Final Extraction", +]; + +// Bonus seconds awarded on each successful delivery +const DELIVERY_TIME_BONUS = 15; + +export default class GameManager { + constructor() { + this.score = 0; + this._levelIndex = 0; + this._timeRemaining = 0; + this._cargoDelivered = 0; + this._timerHandle = null; + this._running = false; + + // Callbacks set by index.js + this.onLevelComplete = null; // (nextIndex) => void + this.onGameOver = null; // (score) => void + + this._bindHUD(); + } + + // ─── Public API ────────────────────────────────────────────────────────── + + get currentLevel() { return LEVELS[this._levelIndex]; } + get isRunning() { return this._running; } + + startLevel(index) { + if (index >= LEVELS.length) { this._showCredits(); return; } + + this._levelIndex = index; + this._cargoDelivered = 0; + this._timeRemaining = LEVELS[index].timeLimit; + this._running = true; + + this._updateHUD(); + this._showBanner(LEVEL_LABELS[index]); + this._startTimer(); + } + + /** + * Called by Vehicle when a cargo reaches the platform. + * @param {number} basePoints Points before bonuses (always >= 100) + */ + deliverCargo(basePoints) { + this._cargoDelivered++; + this.score += basePoints; + this._timeRemaining = Math.min( + this._timeRemaining + DELIVERY_TIME_BONUS, + LEVELS[this._levelIndex].timeLimit, + ); + this._updateHUD(); + + if (this._cargoDelivered >= LEVELS[this._levelIndex].cargoCount) { + this._completeLevel(); + } + } + + showPickupHint(visible) { + this._el("pickup-hint")?.classList.toggle("hidden", !visible); + } + + showDeliverHint(visible) { + this._el("deliver-hint")?.classList.toggle("hidden", !visible); + } + + destroy() { + clearInterval(this._timerHandle); + this._running = false; + } + + // ─── Private ───────────────────────────────────────────────────────────── + + _bindHUD() { + // Cache DOM references once + this._hud = { + score: this._el("hud-score"), + level: this._el("hud-level"), + cargo: this._el("hud-cargo"), + timer: this._el("hud-timer"), + banner: this._el("level-banner"), + }; + } + + _el(id) { return document.getElementById(id); } + + _updateHUD() { + const h = this._hud; + if (h.score) h.score.textContent = this.score.toLocaleString(); + if (h.level) h.level.textContent = this._levelIndex + 1; + if (h.cargo) h.cargo.textContent = `${this._cargoDelivered}/${LEVELS[this._levelIndex].cargoCount}`; + if (h.timer) { + const m = Math.floor(this._timeRemaining / 60); + const s = this._timeRemaining % 60; + h.timer.textContent = `${m}:${String(s).padStart(2, "0")}`; + h.timer.classList.toggle("urgent", this._timeRemaining <= 30); + } + } + + _startTimer() { + clearInterval(this._timerHandle); + this._timerHandle = setInterval(() => { + if (!this._running) { clearInterval(this._timerHandle); return; } + this._timeRemaining--; + this._updateHUD(); + + if (this._timeRemaining <= 0) { + clearInterval(this._timerHandle); + this._running = false; + this._showGameOver(); + } + }, 1000); + } + + _completeLevel() { + this._running = false; + clearInterval(this._timerHandle); + + // Time-remaining bonus + this.score += this._timeRemaining * 10; + this._updateHUD(); + + const isLast = this._levelIndex >= LEVELS.length - 1; + if (isLast) { + setTimeout(() => this._showCredits(), 1800); + } else { + this.onLevelComplete?.(this._levelIndex + 1); + } + } + + _showBanner(text) { + const el = this._hud.banner; + if (!el) return; + el.textContent = text; + el.classList.remove("hidden"); + clearTimeout(this._bannerTimeout); + this._bannerTimeout = setTimeout(() => el.classList.add("hidden"), 3200); + } + + _showGameOver() { + const screen = this._el("end-screen"); + if (!screen) return; + this._el("end-title").textContent = "Time Up!"; + this._el("end-score").textContent = `Score: ${this.score.toLocaleString()}`; + screen.classList.remove("hidden"); + this._el("ui")?.classList.add("hidden"); + this.onGameOver?.(this.score); + } + + _showCredits() { + const credits = this._el("credits-roll"); + if (!credits) return; + const finalEl = this._el("credits-final-score"); + if (finalEl) finalEl.textContent = this.score.toLocaleString(); + credits.classList.remove("hidden"); + this._el("ui")?.classList.add("hidden"); + } +} diff --git a/src/experience/index.js b/src/experience/index.js index f7e96ef..5ed0607 100644 --- a/src/experience/index.js +++ b/src/experience/index.js @@ -10,78 +10,73 @@ import Sizes from "../utils/sizes.js"; import Time from "../utils/time.js"; import Universe from "./world/universe.js"; +// Singleton instance — one Experience per page lifetime let instance = null; export default class Experience { - constructor(_canvas) { - if (instance) { - return instance; - } - - instance = this; - window.experience = this; - - this.resources = new Resources(OIL_PLATFORM); - this.universe = new Universe(); - this.canvas = _canvas; - this.ready = false; - this.debug = new Debug(); - this.sizes = new Sizes(); - this.time = new Time(); - this.scene = new THREE.Scene(); - this.camera = new Camera(); - this.renderer = new Renderer(); - - this.world = new CANNON.World(); - this.world.gravity.set(0, -9.82, 0); - - // Resize event - this.sizes.on("resize", () => { - this.resize(); - }); - - // Time tick event - this.time.on("tick", () => { - this.update(); - }); - } - - resize() { - this.camera.resize(); - this.renderer.resize(); - } - - update() { - this.camera.update(); - this.universe.update(); - this.renderer.update(); - } - - destroy() { - this.sizes.off("resize"); - this.time.off("tick"); - - // Traverse the whole scene - this.scene.traverse((child) => { - // Test if it's a mesh - if (child instanceof THREE.Mesh) { - child.geometry.dispose(); - - // Loop through the material properties - for (const key in child.material) { - const value = child.material[key]; - - // Test if there is a dispose function - if (value && typeof value.dispose === "function") { - value.dispose(); - } - } - } - }); - - this.camera.controls.dispose(); - this.renderer.instance.dispose(); - - if (this.debug.active) this.debug.ui.destroy(); - } + constructor(_canvas) { + if (instance) return instance; + + instance = this; + window.experience = this; + + // Core systems must be created before anything that depends on them + this.canvas = _canvas; + this.debug = new Debug(); + this.sizes = new Sizes(); + this.time = new Time(); + this.scene = new THREE.Scene(); + + // Physics world + this.world = new CANNON.World(); + this.world.gravity.set(0, -9.82, 0); + // Broadphase improves perf with many bodies + this.world.broadphase = new CANNON.SAPBroadphase(this.world); + this.world.allowSleep = true; + + // Rendering & camera + this.camera = new Camera(); + this.renderer = new Renderer(); + + // Resources & world objects — depend on scene/world/camera being ready + this.resources = new Resources(OIL_PLATFORM); + this.universe = new Universe(); + + this.sizes.on("resize", () => this.resize()); + this.time.on("tick", () => this.update()); + } + + resize() { + this.camera.resize(); + this.renderer.resize(); + } + + update() { + this.camera.update(); + this.universe.update(); + this.renderer.update(); + } + + destroy() { + this.sizes.off("resize"); + this.time.off("tick"); + + this.scene.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + child.geometry.dispose(); + // Material can be an array (multi-material) or a single material + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const mat of materials) { + for (const value of Object.values(mat)) { + if (value?.dispose) value.dispose(); + } + } + }); + + this.camera.controls.dispose(); + this.renderer.instance.dispose(); + if (this.debug.active) this.debug.ui.destroy(); + + instance = null; + } } diff --git a/src/experience/level-manager.js b/src/experience/level-manager.js index 5922571..23363c0 100644 --- a/src/experience/level-manager.js +++ b/src/experience/level-manager.js @@ -1,41 +1,38 @@ -import Experience from "."; +import Experience from "./index.js"; + +// LevelManager's sole job: bootstrap the Three.js experience for a given map/mode. +// Game progression (score, timer, cargo) lives in GameManager. const MAPS = { - OIL_PLATFORM: "oil-platform", - SNOW_FOREST: "snow-forest", + OIL_PLATFORM: "oil-platform", + SNOW_FOREST: "snow-forest", }; const MODES = { - FREESTYLE: "freestyle", - SINGLE: "single", - MULTIPLYAER: "multiplayer", + FREESTYLE: "freestyle", + SINGLE: "single", + MULTIPLAYER: "multiplayer", // fixed typo: MULTIPLYAER → MULTIPLAYER }; export default class LevelManager { - constructor() { - this.map = ""; - this.mode = ""; - this.score = 0; - } - - onCreateLevel(mode, map) { - if (!MODES[mode]) { - throw new Error("The given mode does not exist!"); - } - - if (!MAPS[map]) { - throw new Error("The given map does not exist!"); - } - - this.map = MAPS[map]; - this.mode = MODES[mode]; - - // TODO: Load differen maps in different modes - const experience = new Experience(document.querySelector("canvas.webgl")); - } - - onDestoryLevel() { - this.map = ""; - this.mode = ""; - } + constructor() { + this.map = ""; + this.mode = ""; + } + + onCreateLevel(mode, map) { + if (!MODES[mode]) throw new Error(`Unknown mode: "${mode}"`); + if (!MAPS[map]) throw new Error(`Unknown map: "${map}"`); + + this.map = MAPS[map]; + this.mode = MODES[mode]; + + // Instantiates the singleton; safe to call multiple times + new Experience(document.querySelector("canvas.webgl")); + } + + onDestroyLevel() { // fixed typo: onDestoryLevel → onDestroyLevel + this.map = ""; + this.mode = ""; + } } diff --git a/src/experience/renderer.js b/src/experience/renderer.js index 89b47df..fb8e220 100644 --- a/src/experience/renderer.js +++ b/src/experience/renderer.js @@ -1,60 +1,41 @@ import * as THREE from "three"; - -import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import Experience from "./index.js"; -import { GlitchPass } from "three/examples/jsm/postprocessing/GlitchPass.js"; -import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; export default class Renderer { - constructor() { - this.experience = new Experience(); - this.canvas = this.experience.canvas; - this.sizes = this.experience.sizes; - this.scene = this.experience.scene; - this.camera = this.experience.camera; - - this.setInstance(); - } - - setInstance() { - this.instance = new THREE.WebGLRenderer({ - canvas: this.canvas, - antialias: true, - alpha: true, - }); - this.instance.physicallyCorrectLights = true; - this.instance.outputEncoding = THREE.sRGBEncoding; - this.instance.toneMapping = THREE.ReinhardToneMapping; - this.instance.toneMappingExposure = 1.75; - this.instance.shadowMap.enabled = true; - this.instance.shadowMap.type = THREE.PCFSoftShadowMap; - - this.instance.setClearColor("#211d20"); - this.instance.setSize(this.sizes.width, this.sizes.height); - this.instance.setPixelRatio(Math.min(this.sizes.pixelRatio, 2)); - - this.effectComposer = new EffectComposer(this.instance); - this.effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.effectComposer.setSize(window.innerWidth, window.innerHeight); - - const renderPass = new RenderPass(this.scene, this.camera.instance); - this.effectComposer.addPass(renderPass); - - this.glitchPass = new GlitchPass(); - this.glitchPass.enabled = false; - this.effectComposer.addPass(this.glitchPass); - } - - resize() { - this.instance.setSize(this.sizes.width, this.sizes.height); - this.instance.setPixelRatio(Math.min(this.sizes.pixelRatio, 2)); - - this.effectComposer.setSize(this.sizes.width, this.sizes.height); - this.effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - } - - update() { - this.instance.render(this.scene, this.camera.instance); - this.effectComposer.render(); - } + constructor() { + const exp = new Experience(); + this.canvas = exp.canvas; + this.sizes = exp.sizes; + this.scene = exp.scene; + this.camera = exp.camera; + + this._setup(); + } + + _setup() { + this.instance = new THREE.WebGLRenderer({ + canvas: this.canvas, + antialias: true, + alpha: true, + }); + + this.instance.useLegacyLights = false; + this.instance.outputColorSpace = THREE.SRGBColorSpace; + this.instance.toneMapping = THREE.ReinhardToneMapping; + this.instance.toneMappingExposure = 1.75; + this.instance.shadowMap.enabled = true; + this.instance.shadowMap.type = THREE.PCFSoftShadowMap; + + this.instance.setSize(this.sizes.width, this.sizes.height); + this.instance.setPixelRatio(Math.min(this.sizes.pixelRatio, 2)); + } + + resize() { + this.instance.setSize(this.sizes.width, this.sizes.height); + this.instance.setPixelRatio(Math.min(this.sizes.pixelRatio, 2)); + } + + update() { + this.instance.render(this.scene, this.camera.instance); + } } diff --git a/src/experience/sources.js b/src/experience/sources.js index 1d83402..5d86088 100644 --- a/src/experience/sources.js +++ b/src/experience/sources.js @@ -1,46 +1,23 @@ +// Asset manifest for the Oil Platform map. +// Duplicate "helicopterModel" entry removed from original. export const OIL_PLATFORM = [ - { - name: "environmentMapTexture", - type: "cubeTexture", - path: [ - "textures/environmentMap/px.jpg", - "textures/environmentMap/nx.jpg", - "textures/environmentMap/py.jpg", - "textures/environmentMap/ny.jpg", - "textures/environmentMap/pz.jpg", - "textures/environmentMap/nz.jpg", - ], - }, - { - name: "grassColorTexture", - type: "texture", - path: "textures/dirt/color.jpg", - }, - { - name: "grassNormalTexture", - type: "texture", - path: "textures/dirt/normal.jpg", - }, - { - name: "helicopterModel", - type: "gltfModel", - path: "models/vehicle/scene.gltf", - }, - { - name: "helicopterModel", - type: "gltfModel", - path: "models/vehicle/scene.gltf", - }, - { - name: "oilPlatformModel", - type: "gltfModel", - path: "models/platform/scene.gltf", - }, - { - name: "cargoModel", - type: "gltfModel", - path: "models/cargo/scene.gltf", - }, + { + name: "environmentMapTexture", + type: "cubeTexture", + path: [ + "textures/environmentMap/px.jpg", + "textures/environmentMap/nx.jpg", + "textures/environmentMap/py.jpg", + "textures/environmentMap/ny.jpg", + "textures/environmentMap/pz.jpg", + "textures/environmentMap/nz.jpg", + ], + }, + { name: "grassColorTexture", type: "texture", path: "textures/dirt/color.jpg" }, + { name: "grassNormalTexture", type: "texture", path: "textures/dirt/normal.jpg" }, + { name: "helicopterModel", type: "gltfModel", path: "models/vehicle/scene.gltf" }, + { name: "oilPlatformModel", type: "gltfModel", path: "models/platform/scene.gltf"}, + { name: "cargoModel", type: "gltfModel", path: "models/cargo/scene.gltf" }, ]; export const SNOW_FOREST = []; diff --git a/src/experience/world/cargo.js b/src/experience/world/cargo.js index bf9933c..7ae4cd2 100644 --- a/src/experience/world/cargo.js +++ b/src/experience/world/cargo.js @@ -1,77 +1,229 @@ import * as THREE from "three"; - +import * as CANNON from "cannon-es"; import Experience from "../index.js"; -import { random } from "../../utils/random.js"; + +const ROPE_SEGMENTS = 8; +const ROPE_LENGTH = 6; // metres (DistanceConstraint) +const ROPE_COLOR = 0xc8a050; +const INDICATOR_COLOR = 0x4fffb0; export default class Cargo { - constructor() { - this.experience = new Experience(); - this.resources = this.experience.resources; - this.scene = this.experience.scene; - this.scale = 0.08; - this.debug = this.experience.debug; - this.resource = this.resources.items.cargoModel; - - this.onCreate(); - } - - onCreate() { - this.cargo = this.resource.scene; - this.cargo.scale.set(this.scale, this.scale, this.scale); - - if (Math.random() > 0.6) { - this.cargo.position.set(random(-100, 100), 1, random(-100, 100)); - } else { - this.cargo.position.set(random(-100, 100), 1, random(-100, 100)); - } - - this.outlinedMaterial = new THREE.MeshBasicMaterial({ color: "black" }); - - this.cargo.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - } - }); - - this.particlesGeometry = new THREE.BufferGeometry(); - this.count = 500; - - this.positions = new Float32Array(this.count * 3); // Multiply by 3 because each position is composed of 3 values (x, y, z) - - for ( - let i = 0; - i < this.count * 3; - i++ // Multiply by 3 for same reason - ) { - this.positions[i] = (Math.random() - 0.5) * 10; // Math.random() - 0.5 to have a random value between -0.5 and +0.5 - } - - this.textureLoader = new THREE.TextureLoader(); - this.partcileTexture = this.textureLoader.load("/textures/4.png"); - this.particlesMaterial = new THREE.PointsMaterial({ - size: 0.08, - sizeAttenuation: true, - color: "#ff88cc", - transparent: true, - alphaMap: this.partcileTexture, - alphaTest: 0.001, - depthWrite: true, - blending: THREE.AdditiveBlending, - vertexColors: true, - }); - this.particlesGeometry.setAttribute( - "position", - new THREE.BufferAttribute(this.positions, 3), - ); - this.particles = new THREE.Points( - this.particlesGeometry, - this.particlesMaterial, - ); - this.scene.add(this.particles); - this.scene.add(this.cargo); - } - - update() { - this.scene.updateMatrixWorld(); - } + constructor(index = 0) { + const exp = new Experience(); + this.scene = exp.scene; + this.world = exp.world; + this.resources = exp.resources; + + this.index = index; + this.isPickedUp = false; + this.isDelivered = false; + + // Unique per-cargo bob variation + this._bobOffset = Math.random() * Math.PI * 2; + this._bobSpeed = 0.6 + Math.random() * 0.4; + + this._ropeLine = null; + this._ropePoints = null; + this._ropeConstraint = null; + this._helicopterBody = null; + + this._build(); + } + + // ─── Setup ─────────────────────────────────────────────────────────────── + + _build() { + this._buildModel(); + this._buildPhysics(); + this._buildIndicator(); + } + + _buildModel() { + // Clone so each cargo instance is independent + this._model = this.resources.items.cargoModel.scene.clone(); + this._model.scale.setScalar(0.08); + + const { x, z } = this._randomSeaPosition(); + this._model.position.set(x, 0.5, z); + this._spawnX = x; + this._spawnZ = z; + + this._model.traverse((child) => { + if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } + }); + + this.scene.add(this._model); + } + + _buildPhysics() { + this._body = new CANNON.Body({ + mass: 0.5, // very light — helicopter can lift it easily + linearDamping: 0.95, // high drag so it doesn't swing wildly + angularDamping: 0.99, + }); + this._body.addShape(new CANNON.Box(new CANNON.Vec3(1.2, 0.8, 1.2))); + this._body.position.set(this._spawnX, 0.5, this._spawnZ); + this.world.addBody(this._body); + } + + _buildIndicator() { + this._indicator = new THREE.Mesh( + new THREE.TorusGeometry(2.5, 0.12, 8, 32), + new THREE.MeshBasicMaterial({ color: INDICATOR_COLOR, transparent: true, opacity: 0.8 }), + ); + this._indicator.rotation.x = Math.PI / 2; + this._syncIndicator(); + this.scene.add(this._indicator); + } + + _randomSeaPosition() { + const angle = Math.random() * Math.PI * 2; + const dist = 30 + Math.random() * 80; + return { x: Math.cos(angle) * dist, z: Math.sin(angle) * dist }; + } + + // ─── Public API ────────────────────────────────────────────────────────── + + /** Called by Vehicle when E is pressed near this cargo. */ + attachToHelicopter(helicopterBody) { + if (this.isPickedUp || this.isDelivered) return; + this.isPickedUp = true; + this._helicopterBody = helicopterBody; + + this._body.linearDamping = 0.7; + this._body.angularDamping = 0.99; + + this._ropeConstraint = new CANNON.DistanceConstraint(helicopterBody, this._body, ROPE_LENGTH); + this.world.addConstraint(this._ropeConstraint); + + this._buildRope(); + this._indicator.visible = false; + } + + /** Called when the helicopter moves away without delivering. */ + detach() { + if (!this.isPickedUp) return; + this.isPickedUp = false; + this._removeConstraint(); + this._destroyRope(); + this._body.linearDamping = 0.8; + this._body.angularDamping = 0.9; + this._indicator.visible = true; + } + + /** Called by Vehicle on successful platform landing. */ + markDelivered() { + this.isDelivered = true; + this.isPickedUp = false; + this._removeConstraint(); + this._destroyRope(); + this._indicator.visible = false; + this._fadeAndRemove(); + } + + getPosition() { + return this._body.position; + } + + // ─── Per-frame update ──────────────────────────────────────────────────── + + update(elapsed) { + if (this.isDelivered) return; + + if (!this.isPickedUp) { + // Realistic-ish sea buoyancy: sine bob + gentle rotation sway + const bob = Math.sin(elapsed * this._bobSpeed + this._bobOffset) * 0.2; + const sway = Math.sin(elapsed * 0.4 + this._bobOffset) * 0.04; + this._body.position.y = 0.5 + bob; + this._body.quaternion.setFromEuler(sway, 0, sway * 0.7); + this._syncIndicator(elapsed); + } + + // Sync mesh → physics + this._model.position.copy(this._body.position); + this._model.quaternion.copy(this._body.quaternion); + + this._updateRope(); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + _syncIndicator(elapsed = 0) { + const pos = this._body.position; + this._indicator.position.set(pos.x, pos.y + 4, pos.z); + this._indicator.rotation.z += 0.02; + if (elapsed) { + this._indicator.material.opacity = 0.4 + Math.sin(elapsed * 2 + this._bobOffset) * 0.3; + } + } + + _buildRope() { + this._ropePoints = Array.from({ length: ROPE_SEGMENTS + 1 }, () => new THREE.Vector3()); + this._ropeLine = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(this._ropePoints), + new THREE.LineBasicMaterial({ color: ROPE_COLOR }), + ); + this.scene.add(this._ropeLine); + } + + _destroyRope() { + if (!this._ropeLine) return; + this._ropeLine.geometry.dispose(); + this.scene.remove(this._ropeLine); + this._ropeLine = null; + this._ropePoints = null; + } + + _updateRope() { + if (!this._ropeLine || !this._helicopterBody) return; + + const h = this._helicopterBody.position; + const c = this._body.position; + + for (let i = 0; i <= ROPE_SEGMENTS; i++) { + const t = i / ROPE_SEGMENTS; + const sag = Math.sin(t * Math.PI) * 1.5; // catenary-ish droop + this._ropePoints[i].set( + h.x + (c.x - h.x) * t, + h.y + (c.y - h.y) * t - sag, + h.z + (c.z - h.z) * t, + ); + } + this._ropeLine.geometry.setFromPoints(this._ropePoints); + } + + _removeConstraint() { + if (!this._ropeConstraint) return; + this.world.removeConstraint(this._ropeConstraint); + this._ropeConstraint = null; + } + + _fadeAndRemove() { + let opacity = 1; + const mat = []; + this._model.traverse((child) => { + if (child.isMesh && child.material) { + child.material = child.material.clone(); + child.material.transparent = true; + mat.push(child.material); + } + }); + + const fade = setInterval(() => { + opacity -= 0.05; + for (const m of mat) m.opacity = Math.max(0, opacity); + if (opacity <= 0) { + clearInterval(fade); + this.scene.remove(this._model); + } + }, 50); + } + + destroy() { + this._removeConstraint(); + this._destroyRope(); + if (this._body) this.world.removeBody(this._body); + this.scene.remove(this._model); + if (this._indicator) this.scene.remove(this._indicator); + } } diff --git a/src/experience/world/environment.js b/src/experience/world/environment.js index 296944f..dec87c8 100644 --- a/src/experience/world/environment.js +++ b/src/experience/world/environment.js @@ -1,177 +1,110 @@ import * as THREE from "three"; - -import Experience from "../index.js"; -import { Sky } from "three/examples/jsm/objects/Sky.js"; +import { Sky } from "three/examples/jsm/objects/Sky.js"; import { Water } from "three/examples/jsm/objects/Water.js"; +import Experience from "../index.js"; export default class Environment { - constructor() { - this.experience = new Experience(); - this.scene = this.experience.scene; - this.resources = this.experience.resources; - this.debug = this.experience.debug; - this.sun = new THREE.Vector3(); - this.parameters = { - elevation: 2, - azimuth: 180, - }; - - // Debug - if (this.debug.active) { - this.debugFolder = this.debug.ui.addFolder("environment"); - } - - this.setSunLight(); - this.setEnvironmentMap(); - this.setWater(); - this.setSky(); - this.updateSun(); - } - - setWater() { - this.waterGeometry = new THREE.PlaneGeometry(10000, 10000); - this.water = new Water(this.waterGeometry, { - textureWidth: 512, - textureHeight: 512, - waterNormals: new THREE.TextureLoader().load( - "textures/waternormals.jpeg", - function (texture) { - texture.wrapS = texture.wrapT = THREE.RepeatWrapping; - }, - ), - sunDirection: new THREE.Vector3(), - sunColor: 0xffffff, - waterColor: 0x001e0f, - distortionScale: 3.7, - fog: this.scene.fog !== undefined, - }); - - this.water.rotation.x = -Math.PI / 2; - this.water.position.y = -0.09; - - this.scene.add(this.water); - } - - setSky() { - this.sky = new Sky(); - this.sky.scale.setScalar(10000); - this.scene.add(this.sky); - - this.skyUniforms = this.sky.material.uniforms; - - this.skyUniforms["turbidity"].value = 10; - this.skyUniforms["rayleigh"].value = 2; - this.skyUniforms["mieCoefficient"].value = 0.005; - this.skyUniforms["mieDirectionalG"].value = 0.8; - - this.pmremGenerator = new THREE.PMREMGenerator( - this.experience.renderer.instance, - ); - - this.phi = THREE.MathUtils.degToRad(90 - this.parameters.elevation); - this.theta = THREE.MathUtils.degToRad(this.parameters.azimuth); - - this.sun.setFromSphericalCoords(1, this.phi, this.theta); - - this.sky.material.uniforms["sunPosition"].value.copy(this.sun); - this.water.material.uniforms["sunDirection"].value - .copy(this.sun) - .normalize(); - - this.scene.environment = this.pmremGenerator.fromScene(this.sky).texture; - } - - updateSun() { - this.phi = THREE.MathUtils.degToRad(90 - this.parameters.elevation); - this.theta = THREE.MathUtils.degToRad(this.parameters.azimuth); - - this.sun.setFromSphericalCoords(1, this.phi, this.theta); - - this.sky.material.uniforms["sunPosition"].value.copy(this.sun); - this.water.material.uniforms["sunDirection"].value - .copy(this.sun) - .normalize(); - - this.scene.environment = this.pmremGenerator.fromScene(this.sky).texture; - } - - setSunLight() { - this.sunLight = new THREE.DirectionalLight("#ffffff", 4); - this.sunLight.castShadow = true; - this.sunLight.shadow.camera.far = 15; - this.sunLight.shadow.mapSize.set(1024, 1024); - this.sunLight.shadow.normalBias = 0.05; - this.sunLight.position.set(3.5, 2, -1.25); - this.scene.add(this.sunLight); - - // Debug - if (this.debug.active) { - this.debugFolder - .add(this.sunLight, "intensity") - .name("sunLightIntensity") - .min(0) - .max(10) - .step(0.001); - - this.debugFolder - .add(this.sunLight.position, "x") - .name("sunLightX") - .min(-5) - .max(5) - .step(0.001); - - this.debugFolder - .add(this.sunLight.position, "y") - .name("sunLightY") - .min(-5) - .max(5) - .step(0.001); - - this.debugFolder - .add(this.sunLight.position, "z") - .name("sunLightZ") - .min(-5) - .max(5) - .step(0.001); - } - } - - setEnvironmentMap() { - this.environmentMap = {}; - this.environmentMap.intensity = 0.4; - this.environmentMap.texture = this.resources.items.environmentMapTexture; - this.environmentMap.texture.encoding = THREE.sRGBEncoding; - - this.environmentMap.updateMaterials = () => { - this.scene.traverse((child) => { - if ( - child instanceof THREE.Mesh && - child.material instanceof THREE.MeshStandardMaterial - ) { - child.material.envMap = this.environmentMap.texture; - child.material.envMapIntensity = this.environmentMap.intensity; - child.material.needsUpdate = true; - } - }); - }; - - this.environmentMap.updateMaterials(); - this.environmentMap.encoding = THREE.sRGBEncoding; - - // Debug - if (this.debug.active) { - this.debugFolder - .add(this.environmentMap, "intensity") - .name("envMapIntensity") - .min(0) - .max(4) - .step(0.001) - .onChange(this.environmentMap.updateMaterials); - } - } - - update() { - this.water.material.uniforms["time"].value += 1.0 / 60.0; - this.updateSun(); - } + constructor() { + const exp = new Experience(); + this.scene = exp.scene; + this.resources = exp.resources; + this.debug = exp.debug; + this.renderer = exp.renderer; + + this._sun = new THREE.Vector3(); + this._sunParams = { elevation: 2, azimuth: 180 }; + this._waterTime = 0; + + this._setupSunLight(); + this._setupEnvironmentMap(); + this._setupWater(); + this._setupSky(); + this._updateSun(); + + if (this.debug.active) this._setupDebug(); + } + + _setupSunLight() { + this._sunLight = new THREE.DirectionalLight("#ffffff", 4); + this._sunLight.castShadow = true; + this._sunLight.shadow.camera.far = 15; + this._sunLight.shadow.mapSize.set(1024, 1024); + this._sunLight.shadow.normalBias = 0.05; + this._sunLight.position.set(3.5, 2, -1.25); + this.scene.add(this._sunLight); + } + + _setupEnvironmentMap() { + this._envMap = this.resources.items.environmentMapTexture; + // colorSpace replaces deprecated .encoding in Three r152+ + this._envMap.colorSpace = THREE.SRGBColorSpace; + this.scene.environment = this._envMap; + this._applyEnvMap(); + } + + _applyEnvMap(intensity = 0.4) { + this.scene.traverse((child) => { + if (child instanceof THREE.Mesh && + child.material instanceof THREE.MeshStandardMaterial) { + child.material.envMap = this._envMap; + child.material.envMapIntensity = intensity; + child.material.needsUpdate = true; + } + }); + } + + _setupWater() { + this._water = new Water(new THREE.PlaneGeometry(10000, 10000), { + textureWidth: 512, + textureHeight: 512, + waterNormals: new THREE.TextureLoader().load( + "textures/waternormals.jpeg", + (t) => { t.wrapS = t.wrapT = THREE.RepeatWrapping; }, + ), + sunDirection: new THREE.Vector3(), + sunColor: 0xffffff, + waterColor: 0x001e0f, + distortionScale: 3.7, + fog: this.scene.fog !== undefined, + }); + this._water.rotation.x = -Math.PI / 2; + this._water.position.y = -0.09; + this.scene.add(this._water); + } + + _setupSky() { + this._sky = new Sky(); + this._sky.scale.setScalar(10000); + this.scene.add(this._sky); + + const u = this._sky.material.uniforms; + u.turbidity.value = 10; + u.rayleigh.value = 2; + u.mieCoefficient.value = 0.005; + u.mieDirectionalG.value = 0.8; + + this._pmrem = new THREE.PMREMGenerator(this.renderer.instance); + } + + _updateSun() { + const phi = THREE.MathUtils.degToRad(90 - this._sunParams.elevation); + const theta = THREE.MathUtils.degToRad(this._sunParams.azimuth); + this._sun.setFromSphericalCoords(1, phi, theta); + + this._sky.material.uniforms.sunPosition.value.copy(this._sun); + this._water.material.uniforms.sunDirection.value.copy(this._sun).normalize(); + this.scene.environment = this._pmrem.fromScene(this._sky).texture; + } + + _setupDebug() { + const folder = this.debug.ui.addFolder("environment"); + folder.add(this._sunParams, "elevation", -90, 90, 0.1).onChange(() => this._updateSun()); + folder.add(this._sunParams, "azimuth", 0, 360, 1).onChange(() => this._updateSun()); + folder.add(this._sunLight, "intensity", 0, 10, 0.01).name("sunIntensity"); + } + + update() { + // Water needs a continuous time uniform + this._water.material.uniforms.time.value += 1 / 60; + } } diff --git a/src/experience/world/floor.js b/src/experience/world/floor.js index 9a04107..72c6e05 100644 --- a/src/experience/world/floor.js +++ b/src/experience/world/floor.js @@ -1,67 +1,27 @@ import * as CANNON from "cannon-es"; -import * as THREE from "three"; - import Experience from "../index.js"; -//TODO: use platform physical body instead +// Floor only provides a physical landing platform for the helicopter spawn. +// Visual ground is the water plane in Environment. export default class Floor { - constructor() { - this.experience = new Experience(); - this.scene = this.experience.scene; - this.resources = this.experience.resources; - - this.setGeometry(); - // this.setTextures(); - // this.setMaterial(); - // this.setMesh(); - this.setPhysicalBody(); - } - - setGeometry() { - this.geometry = new THREE.CircleGeometry(25, 64); - } - - setTextures() { - this.textures = {}; - - this.textures.color = this.resources.items.grassColorTexture; - this.textures.color.encoding = THREE.sRGBEncoding; - this.textures.color.repeat.set(1.5, 1.5); - this.textures.color.wrapS = THREE.RepeatWrapping; - this.textures.color.wrapT = THREE.RepeatWrapping; - - this.textures.normal = this.resources.items.grassNormalTexture; - this.textures.normal.repeat.set(1.5, 1.5); - this.textures.normal.wrapS = THREE.RepeatWrapping; - this.textures.normal.wrapT = THREE.RepeatWrapping; - } - - setMaterial() { - this.material = new THREE.MeshStandardMaterial({ - map: this.textures.color, - normalMap: this.textures.normal, - }); - } - - setMesh() { - this.mesh = new THREE.Mesh(this.geometry, this.material); - this.mesh.rotation.x = -Math.PI * 0.5; - this.mesh.receiveShadow = true; - this.scene.add(this.mesh); - } - - setPhysicalBody() { - this.groundMaterial = new CANNON.Material("groundMaterial"); - this.groundMaterial.friction = 0.25; - this.groundMaterial.restitution = 0.25; - - this.shape = new CANNON.Box(new CANNON.Vec3(8, 1, 8)); - this.body = new CANNON.Body({ mass: 0, material: this.groundMaterial }); - this.body.addShape(this.shape); - - //TODO: Get actual model position - this.body.position.set(-48, 63, 0); - - this.experience.world.addBody(this.body); - } + constructor() { + const exp = new Experience(); + this.world = exp.world; + + this._createBody(); + } + + _createBody() { + const material = new CANNON.Material("platform"); + material.friction = 0.25; + material.restitution = 0.25; + + // Box sized to approximate the oil platform deck + this.body = new CANNON.Body({ mass: 0, material }); + this.body.addShape(new CANNON.Box(new CANNON.Vec3(8, 1, 8))); + // Position matches the platform model's deck height + this.body.position.set(-48, 63, 0); + + this.world.addBody(this.body); + } } diff --git a/src/experience/world/platform.js b/src/experience/world/platform.js index e6cf71b..d2dfb70 100644 --- a/src/experience/world/platform.js +++ b/src/experience/world/platform.js @@ -1,68 +1,26 @@ -import * as CANNON from "cannon-es"; -import * as THREE from "three"; - import Experience from "../index.js"; export default class Platform { - constructor() { - this.experience = new Experience(); - this.world = this.experience.world; - this.scene = this.experience.scene; - this.resources = this.experience.resources; - this.resource = this.resources.items.oilPlatformModel; - this.platformBody = new CANNON.Body({ mass: 1 }); - - this.scale = 0.6; - - this.onCreate(); - } - - onCreate() { - this.platform = this.resource.scene; - this.platform.scale.set(this.scale, this.scale, this.scale); - this.platform.position.y = -90; - - this.platform.traverse((child) => { - // console.log(child) - if (child.geometry) { - const pos = child.geometry.attributes.position; - const vertex = new THREE.Vector3().fromBufferAttribute(pos, 0); - - // this.world.addBody(physcialBody) - } - }); - - // for (let i = 0; i < this.vertexes.length; i++) { - // const rawVertices = bunny[i].vertices - // const rawFaces = bunny[i].faces - // const rawOffset = bunny[i].offset - - // // Get vertices - // const vertices = [] - // for (let j = 0; j < rawVertices.length; j += 3) { - // vertices.push(new CANNON.Vec3(rawVertices[j], rawVertices[j + 1], rawVertices[j + 2])) - // } - - // // Get faces - // const faces = [] - // for (let j = 0; j < rawFaces.length; j += 3) { - // faces.push([rawFaces[j], rawFaces[j + 1], rawFaces[j + 2]]) - // } - - // // Get offset - // const offset = new CANNON.Vec3(rawOffset[0], rawOffset[1], rawOffset[2]) - - // // Construct polyhedron - // const bunnyPart = new CANNON.ConvexPolyhedron({ vertices, faces }) - - // // Add to compound - // bunnyBody.addShape(bunnyPart, offset) - // } - - // // Create body - // bunnyBody.quaternion.setFromEuler(Math.PI, 0, 0) - // world.addBody(bunnyBody) - - this.scene.add(this.platform); - } + constructor() { + const exp = new Experience(); + this.world = exp.world; + this.scene = exp.scene; + this.resources = exp.resources; + + this._scale = 0.6; + this._onCreate(); + } + + _onCreate() { + this.model = this.resources.items.oilPlatformModel.scene; + this.model.scale.setScalar(this._scale); + this.model.position.y = -90; + + this.model.traverse((child) => { + if (child.isMesh) child.receiveShadow = true; + }); + + this.scene.add(this.model); + // Physics body lives in Floor — no duplicate body here + } } diff --git a/src/experience/world/universe.js b/src/experience/world/universe.js index 0c4aded..2c272ca 100644 --- a/src/experience/world/universe.js +++ b/src/experience/world/universe.js @@ -1,31 +1,47 @@ -import Cargo from "./cargo.js"; +import Cargo from "./cargo.js"; import Environment from "./environment.js"; -import Experience from "../index.js"; -import Floor from "./floor.js"; -import Platform from "./platform.js"; -import Vehicle from "./vehicle.js"; +import Experience from "../index.js"; +import Floor from "./floor.js"; +import Platform from "./platform.js"; +import Vehicle from "./vehicle.js"; export default class Universe { - constructor() { - this.experience = new Experience(); - this.scene = this.experience.scene; - this.resources = this.experience.resources; - this.environment = null; + constructor() { + const exp = new Experience(); + this.scene = exp.scene; + this.resources = exp.resources; - // Wait for resources - this.resources.on("ready", () => { - this.floor = new Floor(); - this.environment = new Environment(); - this.platform = new Platform(); + this.floor = null; + this.environment = null; + this.platform = null; + this.vehicle = null; + this.cargos = []; - this.vehicle = new Vehicle(); - this.cargo = new Cargo(); - }); - } + this.resources.on("ready", () => this._onReady()); + } - update() { - if (this.vehicle) this.vehicle.update(); - if (this.environment) this.environment.update(); - if (this.cargo) this.cargo.update(); - } + _onReady() { + this.floor = new Floor(); + this.platform = new Platform(); + this.environment = new Environment(); + // Vehicle must come after floor — it reads floor.body.position in its constructor + this.vehicle = new Vehicle(); + } + + /** + * Replace all cargos with a fresh set of `count` items. + * Called by index.js when a new level starts. + */ + spawnCargos(count) { + for (const c of this.cargos) c.destroy(); + this.cargos = Array.from({ length: count }, (_, i) => new Cargo(i)); + } + + update() { + const elapsed = new Experience().time?.elapsed * 0.001 ?? 0; + + if (this.vehicle) this.vehicle.update(this.cargos); + if (this.environment) this.environment.update(); + for (const cargo of this.cargos) cargo.update(elapsed); + } } diff --git a/src/experience/world/vehicle.js b/src/experience/world/vehicle.js index 2c47a00..5e92135 100644 --- a/src/experience/world/vehicle.js +++ b/src/experience/world/vehicle.js @@ -1,323 +1,326 @@ import * as CANNON from "cannon-es"; import * as THREE from "three"; - import CannonDebugger from "cannon-es-debugger"; import Controls from "../controls.js"; import Experience from "../index.js"; -import Universe from "./universe.js"; - -const STATIC_ROTOR_2 = "static_rotor2_Mat_maverick012_ec135bmp_0"; -const STATIC_ROTOR = "static_rotor_Mat_maverick011_ec1351bmp_0"; - -export default class Helicopter { - constructor() { - this.state = { - health: 100, - score: 0, - deaths: 0, - }; - - this.engineStarted = false; - - // Physical control - this.delta = 1; - this.thrust = new CANNON.Vec3(0, 5, 0); - this.stableLift = 14.7; - this.clock = new THREE.Clock(); - this.climbing = false; - this.yawning = false; - this.banking = false; - this.pitching = false; - this.rotationSpeed = 0; - - // Model settings - this.rotors = {}; - this.experience = new Experience(); - this.universe = new Universe(); - this.scene = this.experience.scene; - this.resources = this.experience.resources; - this.time = this.experience.time; - this.debug = this.experience.debug; - this.world = this.experience.world; - this.scale = 0.6; - - // Camera - this.chaseCamera = new THREE.Object3D(); - this.chaseCamPivot = new THREE.Object3D(); - this.v = new THREE.Vector3(); - this.cameraVector = new THREE.Vector3(); - - // Resource - this.resource = this.resources.items.helicopterModel; - - // Debug - if (this.debug.active) { - this.debugFolder = this.debug.ui.addFolder("helicopter"); - this.cannonDebugger = new CannonDebugger(this.scene, this.world, {}); - } - - this.controls = new Controls(); - this.hitSound = new Audio("/sounds/hit.mp3"); - - this.onCreate(); - } - - // updateScore(score){ - // this.state.score = this.state.score + score; - // } - - // onDeath(){ - // this.state.deaths = this.state.deaths + 1; - // } - - onDamageTaken(damage) { - this.calculatedDamage = damage; - this.state.health = this.state.health - this.calculatedDamage; - - console.log("[DAMAGE]: Damage taken", this.calculatedDamage); - console.log(this.state); - } - - onCollide = (collision) => { - this.impactStrength = collision.contact.getImpactVelocityAlongNormal(); - - if (this.hitSound && this.impactStrength > 1.7) { - // Hit Sound - this.hitSound.volume = Math.random(); - this.hitSound.currentTime = 0; - this.hitSound.play(); - - // Calculate damage - this.onDamageTaken(this.impactStrength); - - this.experience.renderer.glitchPass.goWild = false; - this.experience.renderer.glitchPass.enabled = true; - - setTimeout(() => { - this.experience.renderer.glitchPass.enabled = false; - }, 1000); - } - }; - - onCreate() { - this.vehicle = this.resource.scene; - this.vehicle.scale.set(this.scale, this.scale, this.scale); - - this.vehicle.position.x = this.experience.universe.floor.body.position.x; - this.vehicle.position.y = - this.experience.universe.floor.body.position.y + 1.9; - this.vehicle.position.z = this.experience.universe.floor.body.position.z; - - this.scene.add(this.vehicle); - - this.vehicle.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - - if (child.name === STATIC_ROTOR_2) { - this.rotors["ROTOR_2"] = child; - } - - if (child.name === STATIC_ROTOR) { - this.rotors["ROTOR"] = child; - } - } - }); - - // Apply physical object to helicopter model - this.vehiclePhysicalBodyShape = new CANNON.Box(new CANNON.Vec3(0.7, 1, 2)); - this.vehiclePhysicalBody = new CANNON.Body({ mass: 0.5 }); - this.vehiclePhysicalBody.addShape(this.vehiclePhysicalBodyShape); - - this.vehiclePhysicalBody.position.x = this.vehicle.position.x; - this.vehiclePhysicalBody.position.y = this.vehicle.position.y; - this.vehiclePhysicalBody.position.z = this.vehicle.position.z; - this.vehiclePhysicalBody.angularDamping = 0.9; //so it doesn't pendulum so much - - this.vehiclePhysicalBody.addEventListener("collide", this.onCollide); - - this.world.addBody(this.vehiclePhysicalBody); - - this.rotorShape = new CANNON.Sphere(0.2); - this.rotorPshycialBody = new CANNON.Body({ mass: 1 }); - this.rotorPshycialBody.addShape(this.rotorShape); - - this.rotorPshycialBody.position.x = this.vehicle.position.x; - this.rotorPshycialBody.position.y = this.vehicle.position.y + 1; - this.rotorPshycialBody.position.z = this.vehicle.position.z; - - this.rotorPshycialBody.linearDamping = 0.5; //simulates auto altitude - this.world.addBody(this.rotorPshycialBody); - - this.rotorConstraint = new CANNON.PointToPointConstraint( - this.vehiclePhysicalBody, - new CANNON.Vec3(0, 1, 0), - this.rotorPshycialBody, - new CANNON.Vec3(), - ); - - this.rotorConstraint.collideConnected = false; - this.world.addConstraint(this.rotorConstraint); - - this.chaseCamera.position.set(0, 0, 0); - this.chaseCamPivot.position.set(0, 3, 10); - this.chaseCamera.add(this.chaseCamPivot); - this.scene.add(this.chaseCamera); - - this.vehicle.add(this.chaseCamera); - - // Switch controls on - this.controls.onStart(); - } - - update = () => { - this.delta = Math.min(this.clock.getDelta(), 0.1); - this.cameraDirection = this.experience.camera.instance.getWorldDirection( - this.cameraVector, - ); - - if (this.delta > 0) { - this.world.step(this.delta); - } - - if (this.engineStarted && this.rotationSpeed < 40) { - this.rotationSpeed += 5 * this.delta; - } - - if (this.controls.keyMap["g"]) { - this.engineStarted = true; - } - - if (this.engineStarted) { - this.rotors["ROTOR"].rotateY(this.rotationSpeed * this.delta * 2); - } - - this.vehicle.position.set( - this.vehiclePhysicalBody.position.x, - this.vehiclePhysicalBody.position.y, - this.vehiclePhysicalBody.position.z, - ); - this.vehicle.quaternion.set( - this.vehiclePhysicalBody.quaternion.x, - this.vehiclePhysicalBody.quaternion.y, - this.vehiclePhysicalBody.quaternion.z, - this.vehiclePhysicalBody.quaternion.w, - ); - - if (this.debug.active) { - this.cannonDebugger.update(); - } - - if (this.engineStarted && this.rotationSpeed >= 30) { - this.climbing = false; - - if (this.controls.keyMap["w"]) { - if (this.thrust.y < 40) { - this.thrust.y += 5 * this.delta; - this.climbing = true; - } - } - - if (this.controls.keyMap["s"]) { - if (this.thrust.y > 0) { - this.thrust.y -= 5 * this.delta; - this.climbing = true; - } - } - - this.yawing = false; - if (this.controls.keyMap["a"]) { - if (this.rotorPshycialBody.angularVelocity.y < 2.0) - this.rotorPshycialBody.angularVelocity.y += 5 * this.delta; - this.yawing = true; - } - if (this.controls.keyMap["d"]) { - if (this.rotorPshycialBody.angularVelocity.y > -2.0) - this.rotorPshycialBody.angularVelocity.y -= 5 * this.delta; - this.yawing = true; - } - - if (!this.yawing) { - if (this.rotorPshycialBody.angularVelocity.y < 0) - this.rotorPshycialBody.angularVelocity.y += 1 * this.delta; - if (this.rotorPshycialBody.angularVelocity.y > 0) - this.rotorPshycialBody.angularVelocity.y -= 1 * this.delta; - } - - this.vehiclePhysicalBody.angularVelocity.y = - this.rotorPshycialBody.angularVelocity.y; - - this.pitching = false; - if (this.controls.keyMap["ArrowUp"]) { - if (this.thrust.z >= -10.0) { - this.thrust.z -= 5 * this.delta; - } - this.pitching = true; - } - if (this.controls.keyMap["ArrowDown"]) { - if (this.thrust.z <= 10.0) { - this.thrust.z += 5 * this.delta; - } - this.pitching = true; - } - - this.banking = false; - if (this.controls.keyMap["ArrowLeft"]) { - if (this.thrust.x >= -10.0) { - this.thrust.x -= 5 * this.delta; - } - this.banking = true; - } - if (this.controls.keyMap["ArrowRight"]) { - if (this.thrust.x <= 10.0) { - this.thrust.x += 5 * this.delta; - } - this.banking = true; - } - - if (!this.pitching) { - if (this.thrust.z < 0) { - this.thrust.z += 2.5 * this.delta; - } - if (this.thrust.z > 0) { - this.thrust.z -= 2.5 * this.delta; - } - } - if (!this.banking) { - if (this.thrust.x < 0) { - this.thrust.x += 2.5 * this.delta; - } - if (this.thrust.x > 0) { - this.thrust.x -= 2.5 * this.delta; - } - } - - if (!this.climbing && this.vehicle.position.y > 1000) { - this.thrust.y = this.stableLift; - } - - this.rotorPshycialBody.applyLocalForce(this.thrust, new CANNON.Vec3()); - } - - this.experience.camera.instance.lookAt(this.vehicle.position); - - this.chaseCamPivot.getWorldPosition(this.v); - - if (this.v.y < 3) { - this.v.y = 3; - this.v.z = 6; - } - - this.experience.camera.instance.position.lerpVectors( - this.experience.camera.instance.position, - this.v, - 0.1, - ); - - this.scene.updateMatrixWorld(); - }; - - destroy() { - this.controls.onStop(); - } + +const ROTOR_MAIN = "static_rotor_Mat_maverick011_ec1351bmp_0"; +const ROTOR_TAIL = "static_rotor2_Mat_maverick012_ec135bmp_0"; +const PICKUP_DIST = 20; +const DELIVERY_DIST = 12; +const SPAWN_GRACE = 2.5; // seconds before collision damage is enabled + +export default class Vehicle { + constructor() { + const exp = new Experience(); + this.scene = exp.scene; + this.resources = exp.resources; + this.debug = exp.debug; + this.world = exp.world; + this.camera = exp.camera; + this.universe = exp.universe; + + this._thrust = new CANNON.Vec3(0, 5, 0); + this._stableLift = 14.7; + this._rotationSpeed = 0; + this._visualYaw = 0; // radians, accumulated manually + this._yawRate = 0; // current yaw angular velocity + + this._camTarget = new THREE.Vector3(); + + this.engineStarted = false; + this.health = 100; + this._rotors = {}; + this._clock = new THREE.Clock(); + this._spawnTime = null; // set after build so grace timer is accurate + + this.attachedCargo = null; + this._eWasDown = false; + this.gameManager = null; + this._platformPos = new CANNON.Vec3(); + + this.controls = new Controls(); + this._hitSound = new Audio("/sounds/hit.mp3"); + + if (this.debug.active) { + this._cannonDebugger = new CannonDebugger(this.scene, this.world, {}); + } + + this._build(); + } + + // ─── Setup ─────────────────────────────────────────────────────────────── + + _build() { + this._buildModel(); + this._buildPhysics(); + this._buildLandingZone(); + this.controls.onStart(); + // Grace starts after everything is constructed + this._spawnTime = performance.now(); + } + + _buildModel() { + this._model = this.resources.items.helicopterModel.scene; + this._model.scale.setScalar(0.6); + + const fp = this.universe.floor.body.position; + // Floor box half-extent=1 → top surface at fp.y+1 + // Heli body half-extent Y=1 → needs centre at fp.y+1+1+0.3 gap = fp.y+2.3 + this._model.position.set(fp.x, fp.y + 2.3, fp.z); + + this._model.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + if (child.name === ROTOR_MAIN) this._rotors.main = child; + if (child.name === ROTOR_TAIL) this._rotors.tail = child; + }); + + this.scene.add(this._model); + } + + _buildPhysics() { + const sp = this._model.position; + + this._body = new CANNON.Body({ mass: 0.5 }); + this._body.addShape(new CANNON.Box(new CANNON.Vec3(0.7, 1, 2))); + this._body.position.copy(sp); + this._body.angularDamping = 1; // we control rotation ourselves + this._body.linearDamping = 0.4; + this._body.fixedRotation = true; // never tip — physics can't rotate it + this._body.updateMassProperties(); + this._body.addEventListener("collide", this._onCollide); + this.world.addBody(this._body); + } + + _buildLandingZone() { + const fp = this.universe.floor.body.position; + this._platformPos.set(fp.x, fp.y, fp.z); + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(4, 0.2, 8, 32), + new THREE.MeshBasicMaterial({ color: 0xffdd00, transparent: true, opacity: 0.7 }), + ); + ring.rotation.x = Math.PI / 2; + ring.position.set(fp.x, fp.y + 1.5, fp.z); + this.scene.add(ring); + this._landingRing = ring; + } + + // ─── Collision ─────────────────────────────────────────────────────────── + + _onCollide = (event) => { + // Ignore collisions during spawn grace period + if (performance.now() - this._spawnTime < SPAWN_GRACE * 1000) return; + + const impact = event.contact.getImpactVelocityAlongNormal(); + if (impact <= 1.7) return; + + this._hitSound.volume = Math.min(1, Math.random()); + this._hitSound.currentTime = 0; + this._hitSound.play().catch(() => {}); + this.health = Math.max(0, this.health - impact); + this._flashDamage(); + }; + + _flashDamage() { + let el = document.getElementById("damage-flash"); + if (!el) { + el = document.createElement("div"); + el.id = "damage-flash"; + el.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:9998;background:rgba(255,30,0,0.35);opacity:0;transition:opacity 0.08s ease-in"; + document.body.appendChild(el); + } + el.style.transition = "opacity 0.08s ease-in"; + el.style.opacity = "1"; + clearTimeout(this._flashTimeout); + this._flashTimeout = setTimeout(() => { + el.style.transition = "opacity 0.5s ease-out"; + el.style.opacity = "0"; + }, 80); + } + + // ─── Cargo ─────────────────────────────────────────────────────────────── + + _checkPickup(cargos) { + if (this.attachedCargo) return; + let nearest = null, nearestDist = PICKUP_DIST; + for (const cargo of cargos) { + if (cargo.isDelivered || cargo.isPickedUp) continue; + const d = this._body.position.distanceTo(cargo.getPosition()); + if (d < nearestDist) { nearestDist = d; nearest = cargo; } + } + this.gameManager?.showPickupHint(!!nearest); + if (nearest && this.controls.keyMap.e && !this._eWasDown) { + nearest.attachToHelicopter(this._body); + this.attachedCargo = nearest; + this.gameManager?.showPickupHint(false); + this.gameManager?.showDeliverHint(true); + } + } + + _checkDelivery() { + if (!this.attachedCargo?.isPickedUp) return; + const dist = this._body.position.distanceTo(this._platformPos); + if (this._landingRing) { + this._landingRing.material.opacity = 0.4 + Math.sin(Date.now() * 0.004) * 0.3; + } + if (dist < DELIVERY_DIST) { + const cargo = this.attachedCargo; + this.attachedCargo = null; + cargo.markDelivered(); + this.gameManager?.showDeliverHint(false); + this.gameManager?.deliverCargo(100 + Math.floor((DELIVERY_DIST - dist) * 20)); + } + } + + // ─── Update ────────────────────────────────────────────────────────────── + + update(cargos) { + const delta = Math.min(this._clock.getDelta(), 0.1); + if (delta > 0) this.world.step(delta); + + if (this.controls.keyMap.g) this.engineStarted = true; + + if (this.engineStarted && this._rotationSpeed < 40) { + this._rotationSpeed += 5 * delta; + } + if (this.engineStarted && this._rotors.main) { + this._rotors.main.rotateY(this._rotationSpeed * delta * 2); + } + + // Sync mesh position from physics body + this._model.position.copy(this._body.position); + + // Tilt forward when pitching, sideways when strafing — cosmetic only + const forwardSpeed = -(this._body.velocity.x * Math.sin(this._visualYaw) + this._body.velocity.z * Math.cos(this._visualYaw)); + const strafeSpeed = (this._body.velocity.x * Math.cos(this._visualYaw) - this._body.velocity.z * Math.sin(this._visualYaw)); + const tiltPitch = THREE.MathUtils.clamp(-forwardSpeed * 0.04, -0.25, 0.25); + const tiltRoll = THREE.MathUtils.clamp(strafeSpeed * 0.04, -0.2, 0.2); + this._model.rotation.set(tiltPitch, this._visualYaw, tiltRoll, 'YXZ'); + + if (this._cannonDebugger) this._cannonDebugger.update(); + + if (this.engineStarted && this._rotationSpeed >= 30) { + this._applyControls(delta); + + if (cargos?.length) { + this._checkPickup(cargos); + if (this.attachedCargo) this._checkDelivery(); + } + } + + this._eWasDown = !!this.controls.keyMap.e; + + // Respawn if helicopter hits the sea + if (this._body.position.y < 0) { + this._respawn(); + } + + this._updateCamera(); + this.scene.updateMatrixWorld(); + } + + _applyControls(delta) { + // ── Altitude (Y thrust) ── + if (this.controls.keyMap.w) { + if (this._thrust.y < 40) this._thrust.y += 5 * delta; + } else if (this.controls.keyMap.s) { + if (this._thrust.y > 0) this._thrust.y -= 5 * delta; + } + + // ── Yaw (A/D) — rotate the visual model and track _visualYaw ── + const YAW_SPEED = 1.5; // radians per second + if (this.controls.keyMap.a) { + this._yawRate = Math.min(this._yawRate + 4 * delta, YAW_SPEED); + } else if (this.controls.keyMap.d) { + this._yawRate = Math.max(this._yawRate - 4 * delta, -YAW_SPEED); + } else { + // Dampen back to zero + if (this._yawRate > 0) this._yawRate = Math.max(0, this._yawRate - 3 * delta); + if (this._yawRate < 0) this._yawRate = Math.min(0, this._yawRate + 3 * delta); + } + this._visualYaw += this._yawRate * delta; + + // ── Forward/back thrust (Arrow Up/Down) — in helicopter's facing direction ── + const sinYaw = Math.sin(this._visualYaw); + const cosYaw = Math.cos(this._visualYaw); + + if (this.controls.keyMap.ArrowUp) { + // Accelerate forward along facing direction + const speed = 5 * delta; + this._body.velocity.x -= sinYaw * speed; + this._body.velocity.z -= cosYaw * speed; + } + if (this.controls.keyMap.ArrowDown) { + const speed = 5 * delta; + this._body.velocity.x += sinYaw * speed; + this._body.velocity.z += cosYaw * speed; + } + + // ── Strafe (Arrow Left/Right) — perpendicular to facing ── + if (this.controls.keyMap.ArrowLeft) { + const speed = 5 * delta; + this._body.velocity.x -= cosYaw * speed; + this._body.velocity.z += sinYaw * speed; + } + if (this.controls.keyMap.ArrowRight) { + const speed = 5 * delta; + this._body.velocity.x += cosYaw * speed; + this._body.velocity.z -= sinYaw * speed; + } + + // ── Apply vertical thrust in world space ── + this._body.applyForce( + new CANNON.Vec3(0, this._thrust.y, 0), + this._body.position + ); + } + + _respawn() { + const fp = this.universe.floor.body.position; + // Reset physics body to spawn position, zero velocity + this._body.position.set(fp.x, fp.y + 2.3, fp.z); + this._body.velocity.set(0, 0, 0); + this._body.angularVelocity.set(0, 0, 0); + // Reset controls state + this._thrust.set(0, 5, 0); + this._visualYaw = 0; + this._yawRate = 0; + // Drop attached cargo + if (this.attachedCargo) { + this.attachedCargo.detach(); + this.attachedCargo = null; + this.gameManager?.showDeliverHint(false); + } + // Reset spawn grace so landing doesn't trigger damage + this._spawnTime = performance.now(); + // Brief red flash to signal respawn + this._flashDamage(); + } + + _updateCamera() { + const p = this._model.position; + + // Camera sits BEHIND and above the helicopter. + // "Behind" = opposite of where helicopter faces. + // Facing direction: (-sinYaw, 0, -cosYaw), so behind = (+sinYaw, 0, +cosYaw) + const BACK = 8; + const HEIGHT = 3; + + const tx = p.x + Math.sin(this._visualYaw) * BACK; + const ty = Math.max(p.y + HEIGHT, 3); + const tz = p.z + Math.cos(this._visualYaw) * BACK; + + this._camTarget.set(tx, ty, tz); + this.camera.instance.position.lerp(this._camTarget, 0.07); + this.camera.instance.lookAt(p.x, p.y + 1, p.z); + } + + destroy() { + this.controls.onStop(); + this.world.removeBody(this._body); + this.scene.remove(this._model); + if (this._landingRing) this.scene.remove(this._landingRing); + } } diff --git a/src/index.html b/src/index.html index dee41d9..8c95537 100644 --- a/src/index.html +++ b/src/index.html @@ -3,66 +3,200 @@ - Collect Lost Cargo - - - + DROPZONE — Helicopter Extraction +
-
-

H-Cargo

- + +
-