Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to MoonLander Enhanced will be documented in this file.

## [0.6.15.0] - 2026-06-15 (Cockpit window vignette + attitude indicator)

Polish on the v0.6.14.0 cockpit camera. The first-person view was hard to read for two reasons: it didn't feel "inside" anything, and roll was only conveyed by the world tilting (so small banks were easy to miss). Both are now addressed.

### Added
- **Window-frame vignette** — a camera-fixed porthole that darkens the periphery and faintly frames the edge, so the cockpit view reads as looking *out* of the lander rather than a free camera. Built once into a `CanvasTexture` mapped onto a camera-child plane (`makeVignetteTexture`), drawn over the scene with depth test off.
- **Synthetic attitude indicator (artificial horizon)** — a small gauge at bottom-center showing bank directly: a horizon bar counter-rotates against a fixed craft reference (wings + hub) as the lander rolls. The reference goes red once `|angle|` exceeds `MAX_LANDING_ANGLE` (10°), so "too steep to land" reads at a glance instead of having to infer it from the tilting world.

### Note
- Both elements are cockpit-only (camera mode 3) and need a live WebGL context, so they aren't jsdom-testable. Verified in-browser on `?renderer=3d`: vignette + gauge render, the horizon bar tilts with roll, and the reference reddens past the 10° gate. Static checks (types, build, 602 tests) pass.

## [0.6.14.0] - 2026-06-15 (3D cockpit challenge camera)

3D mode gets a first-person cockpit view — the 4th camera angle in the `C` cycle (chase → orbital → low → cockpit). It's deliberately a *challenge* camera, not the default: from inside the lander you can't see your own attitude, and 3D foreshortening already makes precision landing harder. So it ships with a **landing-point designator (LPD)** — a ground reticle showing where you'll touch down if you cut thrust now. Steer it onto a pad (the ring turns green over a pad, amber off it) and you land. Live flight defaults to chase; the land/crash beat still pulls to the orbital shot.

### Added
- **Cockpit camera in `ThreejsGameplayRenderer`** — first-person, positioned at the lander, rolling with the craft (so banking is felt) and looking down-forward into the terrain's depth; the LM mesh hides. A camera-fixed boresight reticle gives a stable aiming reference while the world tilts around it.
- **Landing-point designator** — a world-space ground ring at the ballistic-predicted touchdown point (current position + velocity under gravity, no thrust), refined against the terrain height there, clamped to the world, and colored by whether it lands on a pad. The skill aid that makes flying blind to your own attitude landable.
- **`IGameplayRenderer.setEffectiveGravity?(g)`** — optional signal so the LPD's prediction is correct on alt-gravity worlds (Europa/Titan/etc.); `Game` feeds it `gravityPreset.gameGravity`. Canvas/WebGL omit it.

### Changed
- The `C` camera cycle is now 4-wide (was 3); cockpit is the new mode 3.

### Note
- Camera/reticle behavior needs a live WebGL context (not jsdom-testable). Static checks (types, build, 602 tests) pass; confirm the cockpit + LPD on the deploy preview with `?renderer=3d` (press `C` to reach it). Window-frame vignette and a synthetic attitude indicator are possible polish follow-ups.

## [0.6.13.0] - 2026-06-15 (Founder's story + reciprocal link)

A small "The Story" panel and a corner credit link tell the game's origin and link out to the full write-up on the author's site — a reciprocal link between canyou.land and kennethlacroix.me. Also fills in the page's missing SEO metadata.
Expand Down
2 changes: 1 addition & 1 deletion TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ The cinematic third-person 3D renderer (`?renderer=3d`, `ThreejsGameplayRenderer

- [x] **Desktop verification of `?renderer=3d`** — Ken eyeballed the preview (2026-06-15): "feels and looks better" than 2D. Still open: mid-tier **mobile perf** check (the renderer needs a live WebGL context so it isn't unit-testable).
- [x] **3D cinematic replays** (v0.6.12.0) — replay surfaces (AI Theater episodes, ghost-fork before takeover) orbital-sweep + beauty-shot pull on land/crash when in `?renderer=3d`, via `IGameplayRenderer.setReplayMode`. Where 3D is genuinely best (watch, don't control).
- [ ] **First-person cockpit view** — the scoped-out 4th camera mode. Per the v0.6.12.0 analysis, ship as an opt-in challenge/replay camera with an attitude + landing-point reticle, NOT the default live-landing view (3D foreshortening makes precision landing harder).
- [x] **First-person cockpit view** (v0.6.14.0) — shipped as the 4th `C`-cycle camera, a challenge mode with a ballistic landing-point reticle (green over a pad). LM hidden, rolls with the craft. `IGameplayRenderer.setEffectiveGravity` feeds per-world gravity for the prediction. Follow-up polish shipped in v0.6.15.0: window-frame vignette (porthole) + a synthetic attitude indicator (artificial horizon, reddens past the 10° landing gate).
- [ ] **3D models for artifacts + aliens** — currently `drawArtifacts`/`drawAlien` are no-ops in 3D; lunar archaeology + UFO hazards have no 3D presence.
- [ ] **GLB asset pipeline** — v1 uses code-built low-poly meshes (no hosted assets). A proper LM/Earth/artifact model set would lift fidelity (loader + caching + bundle budget).
- [ ] **Volumetric plume polish** — current plume is the shared particle cloud + an additive engine-glow cone; a dedicated volumetric/instanced plume would sell thrust better.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.13.0
0.6.15.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "moonlander-enhanced",
"version": "0.6.13.0",
"version": "0.6.15.0",
"description": "A browser-based, AI-enhanced reimagining of the 1979 Moon Lander arcade game",
"type": "module",
"scripts": {
Expand Down
12 changes: 12 additions & 0 deletions src/data/whatsNew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export interface WhatsNewEntry {
}

export const WHATS_NEW: readonly WhatsNewEntry[] = [
{
version: "0.6.15.0",
date: "2026-06-15",
summary:
"The 3D cockpit view is easier to fly now — a porthole vignette frames the window, and a new artificial-horizon gauge at the bottom shows your roll directly (it tilts with the craft and turns red when you bank past the safe landing angle).",
},
{
version: "0.6.14.0",
date: "2026-06-15",
summary:
"3D mode has a cockpit view — press C to cycle camera angles to first-person. Flying blind to your own tilt is hard, so a landing-point reticle shows where you'll touch down: steer it onto the pad (green = on a pad). A real challenge mode.",
},
{
version: "0.6.13.0",
date: "2026-06-15",
Expand Down
3 changes: 3 additions & 0 deletions src/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,9 @@ export class Game {
this.status === "agent-replay" ||
(this.forkReplay !== null && !this.forkReplay.forked);
this.gameplayRenderer.setReplayMode?.(isReplay);
// Sprint 8 follow-up — feed effective gravity to the 3D cockpit camera
// so its landing-point reticle predicts correctly on alt-gravity worlds.
this.gameplayRenderer.setEffectiveGravity?.(this.gravityPreset.gameGravity);
switch (this.status) {
case "title":
updateTitle(this, input);
Expand Down
5 changes: 5 additions & 0 deletions src/render/IGameplayRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,9 @@ export interface IGameplayRenderer {
* orbital sweep instead of the live chase framing. Optional: only the
* 3D renderer acts on it; Canvas / WebGL omit it (no camera concept). */
setReplayMode?(active: boolean): void;

/** Sprint 8 follow-up — current effective gravity (game units/s²) for the
* 3D cockpit camera's landing-point reticle, so its ballistic prediction
* is correct on alt-gravity worlds. Optional: only the 3D renderer uses it. */
setEffectiveGravity?(g: number): void;
}
257 changes: 255 additions & 2 deletions src/render/ThreejsGameplayRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
COLOR_TERRAIN,
GRAVITY,
MAX_LANDING_ANGLE,
WORLD_WIDTH,
} from "../utils/constants";
import { degToRad } from "../utils/math";
Expand Down Expand Up @@ -54,6 +56,36 @@ function depthRidge(x: number, z: number, seed: number): number {
);
}

/** Radial cockpit-window vignette: clear center fading to a dark, faintly
* framed border. Built once into a CanvasTexture and mapped onto a
* camera-fixed plane so the first-person view reads as a porthole. */
function makeVignetteTexture(): THREE.CanvasTexture {
const size = 256;
const cv = document.createElement("canvas");
cv.width = size;
cv.height = size;
const ctx = cv.getContext("2d");
if (!ctx) return new THREE.CanvasTexture(cv);
const g = ctx.createRadialGradient(
size / 2,
size / 2,
size * 0.32,
size / 2,
size / 2,
size * 0.62,
);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,0,0,0.92)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
// Faint window frame near the edge.
ctx.strokeStyle = "rgba(120,160,180,0.18)";
ctx.lineWidth = size * 0.02;
const m = size * 0.085;
ctx.strokeRect(m, m, size - 2 * m, size - 2 * m);
return new THREE.CanvasTexture(cv);
}

export class ThreejsGameplayRenderer implements IGameplayRenderer {
readonly canvas: HTMLCanvasElement;
private renderer: THREE.WebGLRenderer;
Expand All @@ -80,8 +112,17 @@ export class ThreejsGameplayRenderer implements IGameplayRenderer {
private ghostFrame = -1;
private lastLander: LanderState | null = null;
private shakeAmount = 0;
private camMode = 0; // 0 chase, 1 orbital, 2 low cinematic
private camMode = 0; // 0 chase, 1 orbital, 2 low cinematic, 3 cockpit
private replayMode = false;
// Cockpit challenge camera: first-person view + a landing-point reticle.
private cockpitGroup: THREE.Group;
private lpdRing: THREE.Mesh;
private lpdMat: THREE.MeshBasicMaterial;
private vignette: THREE.Mesh;
private attitudeGroup: THREE.Group;
private horizonBar: THREE.Mesh;
private attitudeMat: THREE.MeshBasicMaterial;
private effectiveGravity = GRAVITY;
private readonly onKey: (e: KeyboardEvent) => void;

private constructor(canvas: HTMLCanvasElement) {
Expand Down Expand Up @@ -189,8 +230,132 @@ export class ThreejsGameplayRenderer implements IGameplayRenderer {
particles.frustumCulled = false;
this.scene.add(particles);

// Cockpit reticle — a center boresight fixed to the camera (the
// world tilts around it, giving attitude feedback). The camera is
// added to the scene so its child HUD objects render.
this.scene.add(this.camera);
this.cockpitGroup = new THREE.Group();
const reticleMat = new THREE.MeshBasicMaterial({
color: 0x33ff99,
fog: false,
transparent: true,
opacity: 0.9,
});
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.03, 0.045, 24),
reticleMat,
);
this.cockpitGroup.add(ring);
for (let k = 0; k < 4; k++) {
const tick = new THREE.Mesh(
new THREE.BoxGeometry(0.012, 0.05, 0.001),
reticleMat,
);
const a = (k / 4) * Math.PI * 2;
tick.position.set(Math.cos(a) * 0.08, Math.sin(a) * 0.08, 0);
tick.rotation.z = a;
this.cockpitGroup.add(tick);
}
this.cockpitGroup.position.set(0, 0, -3); // fixed in front of the eye
this.cockpitGroup.visible = false;
this.camera.add(this.cockpitGroup);

// Landing-point designator — a world-space ground ring at the
// ballistic-predicted touchdown spot. Green over a pad, amber off it.
this.lpdMat = new THREE.MeshBasicMaterial({
color: 0xffaa33,
fog: false,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
});
this.lpdRing = new THREE.Mesh(
new THREE.RingGeometry(14, 20, 28),
this.lpdMat,
);
this.lpdRing.rotation.x = -Math.PI / 2;
this.lpdRing.visible = false;
this.scene.add(this.lpdRing);

// Window-frame vignette — darkens the periphery into a porthole so the
// first-person view reads as "inside the lander", not a free camera.
// Camera-fixed, drawn over the scene (depthTest off).
this.vignette = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 1.25),
new THREE.MeshBasicMaterial({
map: makeVignetteTexture(),
transparent: true,
depthTest: false,
depthWrite: false,
fog: false,
}),
);
this.vignette.position.set(0, 0, -1);
this.vignette.renderOrder = 10;
this.vignette.visible = false;
this.camera.add(this.vignette);

// Synthetic attitude indicator (artificial horizon). From inside you
// can't see your own bank except by the world tilting; this gauge
// shows roll directly. A fixed craft reference (wings + hub) sits
// against a horizon bar that counter-rotates with the craft. The wings
// go red past the landing-angle gate so "too steep" reads at a glance.
this.attitudeGroup = new THREE.Group();
this.attitudeGroup.position.set(0, -0.3, -1);
const adiHud = <T extends THREE.Object3D>(m: T): T => {
m.renderOrder = 11;
return m;
};
const ringOutline = new THREE.Mesh(
new THREE.RingGeometry(0.075, 0.083, 40),
new THREE.MeshBasicMaterial({
color: 0x2a4a55,
transparent: true,
opacity: 0.8,
depthTest: false,
fog: false,
}),
);
this.attitudeGroup.add(adiHud(ringOutline));

this.horizonBar = new THREE.Mesh(
new THREE.BoxGeometry(0.14, 0.008, 0.001),
new THREE.MeshBasicMaterial({
color: 0x55ccff,
transparent: true,
opacity: 0.9,
depthTest: false,
fog: false,
}),
);
this.attitudeGroup.add(adiHud(this.horizonBar));

this.attitudeMat = new THREE.MeshBasicMaterial({
color: 0x33ff88,
transparent: true,
opacity: 0.95,
depthTest: false,
fog: false,
});
for (const wx of [-0.05, 0.05]) {
const wing = new THREE.Mesh(
new THREE.BoxGeometry(0.045, 0.009, 0.001),
this.attitudeMat,
);
wing.position.set(wx, 0, 0.001);
this.attitudeGroup.add(adiHud(wing));
}
const hub = new THREE.Mesh(
new THREE.CircleGeometry(0.01, 16),
this.attitudeMat,
);
hub.position.z = 0.001;
this.attitudeGroup.add(adiHud(hub));
this.attitudeGroup.visible = false;
this.camera.add(this.attitudeGroup);

this.onKey = (e: KeyboardEvent) => {
if (e.code === "KeyC") this.camMode = (this.camMode + 1) % 3;
if (e.code === "KeyC") this.camMode = (this.camMode + 1) % 4;
};
window.addEventListener("keydown", this.onKey);

Expand Down Expand Up @@ -513,6 +678,43 @@ export class ThreejsGameplayRenderer implements IGameplayRenderer {
const tx = l ? l.x : WORLD_WIDTH * 0.5;
const ty = l ? sy(l.y) : 400;
const landed = l?.status === "landed" || l?.status === "crashed";
this.camera.up.set(0, 1, 0); // reset roll; cockpit overrides below

// Cockpit challenge camera — first-person, rolls with the craft, LM
// hidden. A landing-point reticle (where you'll touch down if you cut
// thrust now) is the skill aid that makes flying blind to your own
// attitude landable: steer the marker onto the pad. Not active on the
// land/crash beat (that pulls to the orbital shot like the other modes).
const cockpit = !this.replayMode && !landed && this.camMode === 3;
this.cockpitGroup.visible = cockpit;
this.lpdRing.visible = cockpit;
this.vignette.visible = cockpit;
this.attitudeGroup.visible = cockpit;
if (cockpit && l) {
this.lm.visible = false;
const roll = degToRad(l.angle);
const syY = sy(l.y);
this.camera.position.set(l.x, syY + 4, 0);
const groundSceneY = sy(this.terrainHeightWorldY(l.x));
this.camera.up.set(Math.sin(roll), Math.cos(roll), 0);
// Look down and forward into the terrain's depth, biased toward
// horizontal travel so drift reads.
this.camera.lookAt(l.x + l.vx * 0.25, groundSceneY + 20, 80);
const lpd = this.computeLPD();
if (lpd) {
this.lpdRing.position.set(lpd.x, sy(lpd.groundY) + 1, 0);
const pulse = 1 + Math.sin(this.frameId * 0.12) * 0.08;
this.lpdRing.scale.setScalar(pulse);
this.lpdMat.color.setHex(lpd.onPad ? 0x33ff88 : 0xffaa33);
}
// Artificial horizon: counter-rotate the bar against the fixed
// craft reference so bank reads directly; redden past the gate.
this.horizonBar.rotation.z = -roll;
this.attitudeMat.color.setHex(
Math.abs(l.angle) > MAX_LANDING_ANGLE ? 0xff5544 : 0x33ff88,
);
return;
}

// Cinematic replay sweep — slowly orbit the playback lander so the
// terrain's depth reads, widening into a beauty shot on touchdown /
Expand Down Expand Up @@ -563,6 +765,57 @@ export class ThreejsGameplayRenderer implements IGameplayRenderer {
this.replayMode = active;
}

setEffectiveGravity(g: number): void {
this.effectiveGravity = g > 0 ? g : GRAVITY;
}

/** World-space terrain height (y-down coords) at a world x, interpolated
* from the cached heightline. */
private terrainHeightWorldY(x: number): number {
const t = this.lastTerrain;
if (!t || t.points.length < 2) return CANVAS_HEIGHT - 200;
const pts = t.points;
const last = pts[pts.length - 1];
if (x <= pts[0].x) return pts[0].y;
if (x >= last.x) return last.y;
const step = WORLD_WIDTH / (pts.length - 1);
const i = Math.min(pts.length - 2, Math.max(0, Math.floor(x / step)));
const span = pts[i + 1].x - pts[i].x || 1;
const f = (x - pts[i].x) / span;
return pts[i].y + (pts[i + 1].y - pts[i].y) * f;
}

/** Ballistic landing-point prediction: where the lander touches down if
* it stops thrusting now (current position + velocity under gravity).
* Returns world-x, world-ground-y, and whether it lands on a pad. */
private computeLPD(): { x: number; groundY: number; onPad: boolean } | null {
const l = this.lastLander;
const t = this.lastTerrain;
if (!l || !t) return null;
const g = this.effectiveGravity;
// Solve 0.5*g*tt^2 + vy*tt + (y - groundY) = 0 for the fall time.
const solveT = (groundY: number): number | null => {
const a = 0.5 * g;
const b = l.vy;
const c = l.y - groundY;
if (a <= 0) return b > 0 ? -c / b : null;
const disc = b * b - 4 * a * c;
if (disc < 0) return null;
return (-b + Math.sqrt(disc)) / (2 * a);
};
let xi = l.x;
let tt = solveT(this.terrainHeightWorldY(xi));
if (tt != null && tt >= 0) {
xi = l.x + l.vx * tt; // first guess
tt = solveT(this.terrainHeightWorldY(xi)); // refine against terrain there
if (tt != null && tt >= 0) xi = l.x + l.vx * tt;
}
xi = Math.max(0, Math.min(WORLD_WIDTH, xi));
const groundY = this.terrainHeightWorldY(xi);
const onPad = t.pads.some((p) => xi >= p.x && xi <= p.x + p.width);
return { x: xi, groundY, onPad };
}

resize(_width: number, _height: number): void {
// Fixed internal resolution; CSS scales the canvas.
}
Expand Down