Phase 3: regional control, sketch minimap, and fix the base hut

Three things, all tangled together enough to land at once.

Regions. A single directional front cuts the map into liberated, contested,
occupied and frontier. Heat accrual behind your own lines is exactly zero, not
merely small — a trickle would eventually fortify your own back garden, and
would mean the safe area quietly punished you for using it. Decay is judged by
whoever holds the road, not whoever holds the ground the car is standing on.
Modelled as one front rather than blobs of territory because the brief wants the
line to move in both directions, and Phase 4 can then just slide the offsets.

Territory is signalled by tone, never by the world announcing itself: crossing a
border eases the fog, light and palette between four looks. Phase 3's gate is
telling occupied from liberated without being told, so the HUD line naming the
band is debug scaffolding like the heat numbers.

Minimap. There was no way to tell where a target was. The map is drawn from
Intel, not from the world: the road network's shape is always faintly visible,
but only roads actually seen carry remembered heat, only ground actually driven
through is filled in, and the front line is dashed in only where both sides of
it have been explored. Adds a coarse fog-of-war grid to Intel, plus an
eight-point compass arrow in the HUD.

Base huts. The building was centred on the junction, so the starting hut sat
directly over the car and hid it. Huts now stand off the junction, in whichever
direction is furthest from tarmac, and are solid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 10:23:23 +02:00
co-authored by Claude Opus 5
parent 73e8ac68f1
commit 477195a7d6
12 changed files with 737 additions and 40 deletions
+4 -1
View File
@@ -26,11 +26,14 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
new THREE.BoxGeometry(7, 3.4, 7),
new THREE.MeshStandardMaterial({ color: 0x3f5a46, roughness: 0.85 }),
);
hut.position.set(base.x, 1.7, base.z);
// The building sits beside the junction, not on it — on it, it blocks the
// road, and at the starting base it sits directly over the car.
hut.position.set(base.hutX, 1.7, base.hutZ);
hut.castShadow = true;
hut.receiveShadow = true;
scene.add(hut);
// The beacon stays over the junction, since that is where you park.
const light = beacon(0x5fd08a, 5);
light.position.set(base.x, BEACON_HEIGHT / 2, base.z);
scene.add(light);
+38 -1
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { WorldModel } from '../sim/world';
import type { Control } from '../sim/regions';
import { CAR, WHEELS } from '../carSpec';
export interface SceneView {
@@ -11,9 +12,26 @@ export interface SceneView {
obstacles: THREE.Mesh[];
/** Keeps the shadow frustum centred on the car. */
followSun(): void;
/** Eases the world's colour and visibility toward the current territory. */
setTone(control: Control, dt: number): void;
dispose(): void;
}
/**
* How each band of control looks.
*
* Phase 3's gate is that you can tell occupied ground from liberated within a
* couple of minutes *without being told*. Nothing here is a label: your own
* territory is open and green, contested ground goes flat and hazy, occupied
* turns dusty and closes in, and the frontier is cold and short-sighted.
*/
const TONES: Record<Control, { sky: number; ground: number; sun: number; fog: [number, number] }> = {
liberated: { sky: 0x9fc0b4, ground: 0x2f3830, sun: 0xfff0dc, fog: [110, 380] },
contested: { sky: 0x9aa3a8, ground: 0x30302a, sun: 0xf6ecd8, fog: [80, 300] },
occupied: { sky: 0xb09274, ground: 0x352c24, sun: 0xffdcb0, fog: [55, 210] },
frontier: { sky: 0x7f8ba8, ground: 0x272a33, sun: 0xd8ddf0, fog: [35, 150] },
};
const SKY = 0x11161c;
export function createScene(model: WorldModel): SceneView {
@@ -31,7 +49,8 @@ export function createScene(model: WorldModel): SceneView {
const camera = new THREE.PerspectiveCamera(62, innerWidth / innerHeight, 0.2, 900);
camera.position.set(0, 6, -12);
scene.add(new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1));
const hemi = new THREE.HemisphereLight(0x9fb4c7, 0x2a2823, 1.1);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff0dc, 2.1);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
@@ -157,6 +176,21 @@ export function createScene(model: WorldModel): SceneView {
sun.position.set(car.position.x + 45, 70, car.position.z + 25);
sun.target.position.copy(car.position);
},
setTone(control, dt) {
const tone = TONES[control];
// Ease rather than snap, so a border is something you notice having
// crossed rather than a switch being thrown in your face.
const t = 1 - Math.exp(-0.7 * dt);
hemi.color.lerp(toneColour.set(tone.sky), t);
hemi.groundColor.lerp(toneColour.set(tone.ground), t);
sun.color.lerp(toneColour.set(tone.sun), t);
(scene.background as THREE.Color).lerp(toneColour.set(tone.sky).multiplyScalar(0.22), t);
const fog = scene.fog as THREE.Fog;
fog.color.copy(scene.background as THREE.Color);
fog.near += (tone.fog[0] - fog.near) * t;
fog.far += (tone.fog[1] - fog.far) * t;
},
dispose() {
removeEventListener('resize', onResize);
renderer.dispose();
@@ -165,6 +199,9 @@ export function createScene(model: WorldModel): SceneView {
};
}
/** Scratch colour, so tone easing allocates nothing per frame. */
const toneColour = new THREE.Color();
const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);