76bad46d09
Loading screen, main menu, 3D flight sim with CRT post-processing, procedural terrain, airport with buildings, low-poly aircraft, flight physics, HUD instruments, and sound. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
307 lines
8.2 KiB
JavaScript
307 lines
8.2 KiB
JavaScript
import * as THREE from 'three';
|
|
import { FlightModel } from './flight-model.js';
|
|
import { Terrain } from './terrain.js';
|
|
import { Airport } from './airport.js';
|
|
import { Aircraft } from './aircraft.js';
|
|
import { HUD } from './hud.js';
|
|
import { UI } from './ui.js';
|
|
import { RetroEffect } from './retro.js';
|
|
import { Sound } from './sound.js';
|
|
|
|
const GW = 320;
|
|
const GH = 200;
|
|
|
|
class Game {
|
|
constructor() {
|
|
this.state = 'LOADING';
|
|
this.clock = new THREE.Clock();
|
|
this.cockpitView = false;
|
|
this.fixedDelta = 1 / 60;
|
|
this.accumulator = 0;
|
|
|
|
this.initRenderer();
|
|
this.initScene();
|
|
this.initCamera();
|
|
this.initInput();
|
|
this.initComponents();
|
|
this.initUI();
|
|
|
|
// Start loading sequence
|
|
this.startLoading();
|
|
|
|
// Begin render loop
|
|
this.animate();
|
|
}
|
|
|
|
initRenderer() {
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
canvas: document.getElementById('game-canvas'),
|
|
antialias: false,
|
|
powerPreference: 'low-power',
|
|
});
|
|
this.renderer.setSize(GW, GH, false);
|
|
this.renderer.setPixelRatio(1);
|
|
}
|
|
|
|
initScene() {
|
|
this.scene = new THREE.Scene();
|
|
this.scene.background = new THREE.Color(0x4466AA);
|
|
this.scene.fog = new THREE.Fog(0x4466AA, 600, 2000);
|
|
|
|
// Hemisphere light (sky/ground)
|
|
const hemi = new THREE.HemisphereLight(0x8888CC, 0x444422, 0.6);
|
|
this.scene.add(hemi);
|
|
|
|
// Directional light (sun)
|
|
const sun = new THREE.DirectionalLight(0xFFFFDD, 1.2);
|
|
sun.position.set(200, 120, 100);
|
|
this.scene.add(sun);
|
|
|
|
// Ambient
|
|
this.scene.add(new THREE.AmbientLight(0x404050, 0.3));
|
|
|
|
// Low-poly clouds
|
|
this.buildClouds();
|
|
}
|
|
|
|
buildClouds() {
|
|
const cloudMat = new THREE.MeshLambertMaterial({
|
|
color: 0xDDDDDD,
|
|
flatShading: true,
|
|
transparent: true,
|
|
opacity: 0.85,
|
|
});
|
|
const cloudPositions = [
|
|
{ x: 300, z: 200, y: 180 },
|
|
{ x: -400, z: -100, y: 220 },
|
|
{ x: 500, z: -300, y: 160 },
|
|
{ x: -200, z: 400, y: 200 },
|
|
{ x: 100, z: -500, y: 190 },
|
|
{ x: -600, z: 300, y: 170 },
|
|
{ x: 400, z: 500, y: 210 },
|
|
{ x: -300, z: -400, y: 185 },
|
|
{ x: 700, z: 100, y: 195 },
|
|
{ x: -500, z: -200, y: 175 },
|
|
];
|
|
|
|
cloudPositions.forEach(({ x, y, z }) => {
|
|
const cloud = new THREE.Group();
|
|
const numPuffs = 3 + Math.floor(Math.random() * 4);
|
|
for (let i = 0; i < numPuffs; i++) {
|
|
const size = 10 + Math.random() * 20;
|
|
const puff = new THREE.Mesh(
|
|
new THREE.SphereGeometry(size, 5, 4),
|
|
cloudMat
|
|
);
|
|
puff.position.set(
|
|
(Math.random() - 0.5) * 30,
|
|
(Math.random() - 0.5) * 5,
|
|
(Math.random() - 0.5) * 20
|
|
);
|
|
puff.scale.y = 0.4;
|
|
cloud.add(puff);
|
|
}
|
|
cloud.position.set(x, y, z);
|
|
this.scene.add(cloud);
|
|
});
|
|
}
|
|
|
|
initCamera() {
|
|
this.camera = new THREE.PerspectiveCamera(75, GW / GH, 0.5, 3000);
|
|
this.camera.position.set(0, 15, 20);
|
|
}
|
|
|
|
initInput() {
|
|
this.keys = {};
|
|
window.addEventListener('keydown', (e) => {
|
|
this.keys[e.code] = true;
|
|
this.handleKeydown(e.code);
|
|
});
|
|
window.addEventListener('keyup', (e) => {
|
|
this.keys[e.code] = false;
|
|
});
|
|
}
|
|
|
|
initComponents() {
|
|
this.terrain = new Terrain(2000, 100);
|
|
this.airport = new Airport();
|
|
this.aircraft = new Aircraft();
|
|
this.flightModel = new FlightModel();
|
|
this.hud = new HUD();
|
|
this.retro = new RetroEffect(this.renderer, GW, GH);
|
|
this.sound = new Sound();
|
|
}
|
|
|
|
initUI() {
|
|
this.ui = new UI();
|
|
this.ui.onNewFlight = () => this.startFlight();
|
|
this.ui.onResume = () => this.resumeFlight();
|
|
this.ui.onMainMenu = () => this.returnToMenu();
|
|
this.ui.onQuit = () => this.quitGame();
|
|
}
|
|
|
|
handleKeydown(code) {
|
|
// Init audio on first keypress
|
|
this.sound.init();
|
|
|
|
if (this.ui.menuActive) {
|
|
if (this.ui.handleMenuInput(code)) return;
|
|
}
|
|
|
|
if (this.ui.controlsActive) {
|
|
if (this.ui.handleControlsInput()) return;
|
|
}
|
|
|
|
if (this.ui.pauseMenu.style.display !== 'none') {
|
|
if (this.ui.handlePauseInput(code)) return;
|
|
}
|
|
|
|
if (this.state === 'FLYING' && code === 'Escape') {
|
|
this.pauseFlight();
|
|
this.sound.beep(400, 60);
|
|
}
|
|
|
|
if (this.state === 'FLYING' && code === 'KeyC') {
|
|
this.cockpitView = !this.cockpitView;
|
|
}
|
|
}
|
|
|
|
getFlightInput() {
|
|
return {
|
|
throttleUp: !!this.keys['KeyW'],
|
|
throttleDown: !!this.keys['KeyS'],
|
|
pitch: this.keys['ArrowUp'] ? 1 : this.keys['ArrowDown'] ? -1 : 0,
|
|
roll: this.keys['ArrowLeft'] ? 1 : this.keys['ArrowRight'] ? -1 : 0,
|
|
yaw: this.keys['KeyQ'] ? -1 : this.keys['KeyE'] ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
async startLoading() {
|
|
await this.ui.showLoading(async (step) => {
|
|
if (step === 0) {
|
|
// Generate terrain
|
|
const terrainMesh = this.terrain.createMesh();
|
|
this.scene.add(terrainMesh);
|
|
const water = this.terrain.createWater();
|
|
this.scene.add(water);
|
|
} else if (step === 1) {
|
|
// Build airport
|
|
this.airport.build();
|
|
this.scene.add(this.airport.getGroup());
|
|
} else if (step === 2) {
|
|
// Place aircraft
|
|
const aircraftGroup = this.aircraft.getGroup();
|
|
// Position on runway
|
|
const terrainH = this.terrain.getHeightAt(0, 50);
|
|
this.flightModel.position.set(0, terrainH + 2, 50);
|
|
this.flightModel.orientation.y = Math.PI; // Face down runway (south)
|
|
aircraftGroup.position.copy(this.flightModel.position);
|
|
aircraftGroup.rotation.copy(this.flightModel.orientation);
|
|
this.scene.add(aircraftGroup);
|
|
}
|
|
});
|
|
this.state = 'MENU';
|
|
}
|
|
|
|
startFlight() {
|
|
this.ui.hideMainMenu();
|
|
this.state = 'FLYING';
|
|
this.hud.show();
|
|
this.sound.takeoffSound();
|
|
}
|
|
|
|
pauseFlight() {
|
|
this.state = 'PAUSED';
|
|
this.ui.showPauseMenu();
|
|
}
|
|
|
|
resumeFlight() {
|
|
this.state = 'FLYING';
|
|
this.ui.hidePauseMenu();
|
|
}
|
|
|
|
returnToMenu() {
|
|
this.state = 'MENU';
|
|
this.ui.hidePauseMenu();
|
|
this.ui.showMainMenu();
|
|
this.hud.hide();
|
|
}
|
|
|
|
quitGame() {
|
|
this.state = 'QUIT';
|
|
this.ui.showQuitScreen();
|
|
this.hud.hide();
|
|
}
|
|
|
|
updateCamera() {
|
|
const acPos = this.flightModel.position;
|
|
const acRot = this.flightModel.orientation;
|
|
|
|
if (this.cockpitView) {
|
|
const forward = new THREE.Vector3(0, 0, -1).applyEuler(acRot);
|
|
const up = new THREE.Vector3(0, 1, 0).applyEuler(acRot);
|
|
this.camera.position.copy(acPos)
|
|
.add(forward.clone().multiplyScalar(3))
|
|
.add(up.clone().multiplyScalar(1.5));
|
|
this.camera.lookAt(acPos.clone().add(forward.clone().multiplyScalar(20)));
|
|
} else {
|
|
// Chase camera
|
|
const forward = new THREE.Vector3(0, 0, -1).applyEuler(acRot);
|
|
const up = new THREE.Vector3(0, 1, 0).applyEuler(acRot);
|
|
const desiredPos = acPos.clone()
|
|
.add(forward.multiplyScalar(-20))
|
|
.add(up.multiplyScalar(10));
|
|
|
|
this.camera.position.lerp(desiredPos, 0.06);
|
|
|
|
const lookTarget = acPos.clone().add(up.multiplyScalar(2));
|
|
this.camera.lookAt(lookTarget);
|
|
}
|
|
}
|
|
|
|
animate() {
|
|
requestAnimationFrame(() => this.animate());
|
|
|
|
const deltaRaw = this.clock.getDelta();
|
|
const delta = Math.min(deltaRaw, 0.1);
|
|
const elapsed = this.clock.getElapsedTime();
|
|
|
|
if (this.state === 'FLYING') {
|
|
this.accumulator += delta;
|
|
while (this.accumulator >= this.fixedDelta) {
|
|
const input = this.getFlightInput();
|
|
this.flightModel.update(
|
|
this.fixedDelta,
|
|
input,
|
|
(x, z) => this.terrain.getHeightAt(x, z)
|
|
);
|
|
|
|
// Sync aircraft mesh
|
|
const acGroup = this.aircraft.getGroup();
|
|
acGroup.position.copy(this.flightModel.position);
|
|
acGroup.rotation.copy(this.flightModel.orientation);
|
|
|
|
// Propeller
|
|
this.aircraft.updatePropeller(this.flightModel.thrust, this.fixedDelta);
|
|
|
|
// Landing gear
|
|
this.aircraft.retractGear(this.flightModel.onGround);
|
|
|
|
this.accumulator -= this.fixedDelta;
|
|
}
|
|
|
|
this.updateCamera();
|
|
this.hud.draw(this.flightModel);
|
|
this.sound.update(this.flightModel.thrust);
|
|
}
|
|
|
|
// Update retro effect
|
|
this.retro.update(elapsed);
|
|
|
|
// Render with retro post-processing
|
|
this.retro.render(this.scene, this.camera);
|
|
}
|
|
}
|
|
|
|
new Game();
|