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
-
+
+
-
-
-
+
+
+
+
🚁
+
Mission Briefing
+
+
Cargo crates have gone overboard and are drifting in hostile waters.
+
Extract them all and return to the platform before the clock runs out.
+
+
+
+ 1
+ Press G to ignite the engine — wait for full rotor speed
+
+
+ 2
+ Use W to gain altitude, ↑↓←→ to fly and tilt
+
+
+ 3
+ Fly close to a glowing crate — press E to hook it
+
+
+ 4
+ Airlift it back to the yellow extraction ring on the platform
+
+
+ 5
+ Extract all crates before time expires — 5 missions await
+
+
+
+
+
+
+
+
+
+
+
+
Mission 1
+
Cargo 0/0
+
+
-
- Health: 100%
-
-
-
Controls
-
-
W
-
A
-
S
-
D
-
Movement
+
+
▸ Press E to extract cargo
+
▸ Return to extraction point
+
+
+
+
Field Controls
+
+
+
+
+
-
-
↑
-
←
-
→
-
↓
-
Movement
+
+
+
+
+
-
+
+
+
+
+
DROPZONE
+
All Cargo Extracted.
+
Mission Accomplished.
+
Final Score: 0
+
Built with Three.js + Cannon.js
+
Helicopter v2 · Aditya Graphical | Cargo Crate · boysichterman | Oil Platform Troll A · Arkikon
+
— Good flying, pilot. —
-
+
+
+
+
+
DROPZONE
+
Establishing Uplink…
+
-
+
+
+