diff --git a/README.md b/README.md index 3f5cb3c..eda6417 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Endless driving survival game. - **Phase 0** — a drivable car with persistent wear that is felt through the wheel. - **Phase 1** — a road network whose roads remember being driven, and escalate. - **Phase 2** — bases, a quest board, and known-vs-new target selection. +- **Phase 3** — regional control: behind your own lines, nobody is watching. ## Running @@ -122,6 +123,57 @@ This is what gives the board's reward a reason to matter. Without an economy the choice between a safe route and a risky one has no stakes; with one, the risky job is what keeps the car alive a while longer. +## Regional control + +The map is cut by a single **front line** into four bands, running from your own +territory out past the edge of the known world: + +`Liberated → Contested → Occupied → Frontier` + +| Band | Heat accrual | Heat decay | +|---|---|---| +| Liberated | **×0** | ×4 | +| Contested | ×0.55 | ×1.4 | +| Occupied | ×1 | ×1 | +| Frontier | ×1.3 | ×0.8 | + +Liberated accrual is exactly zero, not merely small. A trickle would eventually +fortify your own back garden, which is nonsense, and it would mean the safe area +quietly punished you for using it. Behind your own lines, nobody is writing down +which roads you take. + +Decay is judged by whoever holds *the road*, not whoever holds the ground the +car is standing on — your own side dismantles checkpoints on its own roads +whether or not you are there to watch. + +It is modelled as one directional front rather than blobs of territory, +deliberately: the brief wants the line to *move*, in both directions, and a +scalar offset per boundary is something Phase 4 can slide either way without +touching anything else. + +**You are never told which band you are in by the world itself.** Crossing a +border eases the fog, the light and the palette: your own ground is open and +green, contested goes flat and hazy, occupied turns dusty and closes in, the +frontier is cold and short-sighted. The HUD names the band for now, but that +line is debug scaffolding for the same reason the heat numbers are — Phase 3's +gate is telling them apart *without* being told. + +## The map + +A sketch map, top right, drawn from `Intel` rather than from the world: + +- **The road network's shape is always visible**, as faint dashed lines. You are + an operative with a map, not an amnesiac. +- **Roads you have actually seen** are drawn solid and coloured by the heat you + remember on them — which is what you last saw, not what is there now. +- **Ground you have driven through** is filled in, tinted by who holds it. A + survey mission fills in a whole area at once. +- **The front line** is dashed in only where you have explored both sides of it. + You know where the border is because you crossed it. + +The HUD also carries an eight-point compass arrow to the active target, since +the map alone does not tell you which way to point the car. + ## Layout The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.** @@ -134,12 +186,13 @@ ever opening a browser. src/ sim/ pure model, no engine imports: world, roads, heat — the map and its memory + regions — the front line and who holds what intel, routing, quests — what the player knows and is asked to do bases, car — where missions come from, and decline physics/ Rapier world, raycast vehicle, input → wheel forces render/ three.js scene, roads, markers, chase camera core/ fixed-timestep loop, seeded RNG, keyboard - ui/ debug HUD, quest board + ui/ debug HUD, quest board, sketch minimap carSpec.ts shared car dimensions, so body and mesh cannot drift apart heatProps.ts integration layer: heat levels → colliders + meshes ``` @@ -205,6 +258,16 @@ reasons other than curiosity — because the known one has genuinely got expensive. If the known option is always strictly better, raise `NOVELTY_BONUS`. If you never take a known target, lower it. +**Phase 3:** driving into an occupied region, you can tell it from a liberated +one within a couple of minutes, from tone alone, without reading the HUD line +that names it. If you cannot, the palettes in `render/scene.ts` are too close +together — push them apart before trusting the band boundaries. + +The static front is the whole of Phase 3. **Phase 4** is what makes it move: +mission completions nudging the boundaries, plus ambient drift that happens +whether or not you are watching. `Front.boundaries` is already the only thing +that would have to change. + Watch for one failure mode in particular: if you find yourself taking whichever job is *nearest* and ignoring the intel line entirely, the board is decorative and the phase has not landed, whatever the reward numbers say. diff --git a/src/main.ts b/src/main.ts index 5cefbf4..03120ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,9 @@ import { createHeat, stepHeat } from './sim/heat'; import { routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; import { buildGraph } from './sim/routing'; -import { createIntel, observe, survey } from './sim/intel'; +import { createIntel, observe, reveal, survey } from './sim/intel'; +import { controlAt, createFront, DECAY_MULTIPLIER, HEAT_MULTIPLIER } from './sim/regions'; +import { createMinimap } from './ui/minimap'; import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests'; import { createHeatProps } from './heatProps'; import { seedFromString } from './core/rng'; @@ -18,6 +20,9 @@ import { createHud } from './ui/hud'; import { createBoard } from './ui/board'; import { CAR, WHEELS } from './carSpec'; +/** How far the driver can see well enough to fill in the map, metres. */ +const SIGHT_RADIUS = 85; + function resolveSeed(): number { const raw = new URLSearchParams(location.search).get('seed'); if (!raw) return 1337; @@ -30,17 +35,33 @@ async function boot() { const model = generateWorld(seed); const bases = placeBases(model.roads, model.spawn); const graph = buildGraph(model.roads); + const front = createFront(seed, model.extent, model.spawn); const physics = await createPhysics(model); const view = createScene(model); const markers = createMarkers(view.scene, bases); + const minimap = createMinimap(model.roads, bases, front, model.extent); + + // Base buildings are solid. They are placed off the junction, so this never + // walls off the road the player has to park on. + for (const base of bases) { + physics.addStaticBox({ + x: base.hutX, + z: base.hutZ, + yaw: 0, + width: 7, + height: 3.4, + depth: 7, + }); + } + const input = createInput(); const hud = createHud(seed); const board = createBoard(); const driveState = createDriveState(); const heat = createHeat(model.roads); const heatProps = createHeatProps(model.roads, physics, view.scene); - const intel = createIntel(model.roads); + const intel = createIntel(model.roads, model.extent); const quests = createQuests(); let condition = freshCondition(); @@ -49,6 +70,11 @@ async function boot() { /** The road being travelled along — includes the verge, not just the tarmac. */ let currentSegment: number | null = null; let onTarmac = false; + let control = controlAt(front, model.spawn.x, model.spawn.z); + /** Which side holds each road, cached — the front is static in this phase. */ + const segmentControl = model.roads.segments.map((s) => + controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2), + ); /** Offers are generated once per arrival at a base, not every frame. */ let openBase: number | null = null; let offers: Offer[] = []; @@ -86,16 +112,28 @@ async function boot() { }); // Heat: the road remembers being used — including being driven alongside. + // Behind your own lines it remembers nothing: nobody there is watching. const at = physics.chassis.translation(); + control = controlAt(front, at.x, at.z); currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null; onTarmac = segmentAt(model.roads, at.x, at.z) !== null; heatProps.sync( - stepHeat(heat, { dt, segmentId: currentSegment, distance }), + stepHeat(heat, { + dt, + segmentId: currentSegment, + distance, + accrual: HEAT_MULTIPLIER[control], + // Each road decays according to whoever holds *it*, not whoever holds + // the ground the car happens to be standing on. + decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!], + }), heat, currentSegment, ); - // Driving a road is how you learn what is on it. + // Driving a road is how you learn what is on it, and seeing ground is how + // it stops being a blank on the map. if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed); + reveal(intel, at.x, at.z, SIGHT_RADIUS); const base = baseAt(bases, at.x, at.z); const stopped = Math.abs(speed) < 2.5; @@ -195,12 +233,24 @@ async function boot() { } view.followSun(); + view.setTone(control, frameDt); markers.update(elapsed); updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed()); view.renderer.render(view.scene, view.camera); const quest = quests.active; const target = quest ? nodeOf(quest.targetNode) : null; + + minimap.draw(intel, { + x: p.x, + z: p.z, + // Yaw about +Y, measured from world +Z, which is the car's local forward. + heading: Math.atan2( + 2 * (r.w * r.y + r.x * r.z), + 1 - 2 * (r.y * r.y + r.x * r.x), + ), + objective: target, + }); hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { heat: { segmentId: currentSegment, @@ -208,17 +258,20 @@ async function boot() { value: currentSegment === null ? 0 : heat.value[currentSegment]!, level: currentSegment === null ? null : heat.level[currentSegment]!, }, - quest: quest - ? { - type: quest.type, - stage: quest.stage, - novel: quest.novel, - worked: quest.worked, - distance: target - ? Math.hypot(target.x - p.x, target.z - p.z) - : 0, - } - : null, + quest: + quest && target + ? { + type: quest.type, + stage: quest.stage, + novel: quest.novel, + worked: quest.worked, + distance: Math.hypot(target.x - p.x, target.z - p.z), + bearing: + Math.atan2(target.x - p.x, target.z - p.z) - + Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)), + } + : null, + control, completed: quests.completed, parts: quests.parts, notice: elapsed < noticeUntil ? notice : '', diff --git a/src/render/markers.ts b/src/render/markers.ts index 5e78d4d..664abe0 100644 --- a/src/render/markers.ts +++ b/src/render/markers.ts @@ -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); diff --git a/src/render/scene.ts b/src/render/scene.ts index e6960a0..434ca26 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -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 = { + 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); diff --git a/src/sim/bases.ts b/src/sim/bases.ts index 2bc065c..970f24c 100644 --- a/src/sim/bases.ts +++ b/src/sim/bases.ts @@ -7,15 +7,22 @@ * That only works if they are spread out, hence farthest-point selection rather * than random placement. */ -import type { RoadNetwork } from './roads'; +import { distanceToRoad, type RoadNetwork } from './roads'; export interface Base { nodeId: number; + /** The junction itself — where the car parks and the board opens. */ x: number; z: number; + /** The building, set off the junction so it never blocks the road or the view. */ + hutX: number; + hutZ: number; name: string; } +/** How far the building sits from the junction it serves. */ +const HUT_OFFSET = 15; + const NAMES = ['Anvil', 'Birch', 'Cinder', 'Dovetail', 'Ember', 'Foxglove']; export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number }, count = 4): Base[] { @@ -46,12 +53,34 @@ export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number }, chosen.push(best); } - return chosen.map((n, i) => ({ - nodeId: n.id, - x: n.x, - z: n.z, - name: NAMES[i % NAMES.length]!, - })); + return chosen.map((n, i) => { + // Put the building beside the junction, never on it. On it, the hut sits + // on the road, and at the starting base it swallows the car entirely. + // Pick whichever direction gets furthest from tarmac. + let bestX = n.x + HUT_OFFSET; + let bestZ = n.z; + let bestClearance = -Infinity; + for (let step = 0; step < 12; step++) { + const angle = (step / 12) * Math.PI * 2; + const x = n.x + Math.cos(angle) * HUT_OFFSET; + const z = n.z + Math.sin(angle) * HUT_OFFSET; + const clearance = distanceToRoad(roads, x, z); + if (clearance > bestClearance) { + bestClearance = clearance; + bestX = x; + bestZ = z; + } + } + + return { + nodeId: n.id, + x: n.x, + z: n.z, + hutX: bestX, + hutZ: bestZ, + name: NAMES[i % NAMES.length]!, + }; + }); } /** Nearest base within `radius` metres, or null. */ diff --git a/src/sim/heat.ts b/src/sim/heat.ts index 50f6bb7..db5f49b 100644 --- a/src/sim/heat.ts +++ b/src/sim/heat.ts @@ -64,16 +64,23 @@ export interface HeatStep { segmentId: number | null; /** Metres travelled this step. */ distance: number; + /** + * Accrual multiplier for the territory the car is in. Zero behind your own + * lines: nobody there is writing down which roads you use. + */ + accrual?: number; + /** Per-segment decay multiplier — your own side clears roads faster. */ + decayFor?: (segmentId: number) => number; } /** Advances heat and returns the segments whose *level* changed. */ export function stepHeat(state: HeatState, step: HeatStep): number[] { const changed: number[] = []; - const decay = DECAY_PER_SECOND * step.dt; + const accrual = step.accrual ?? 1; for (let id = 0; id < state.value.length; id++) { - let heat = state.value[id]! - decay; - if (id === step.segmentId) heat += step.distance / METRES_PER_HEAT; + let heat = state.value[id]! - DECAY_PER_SECOND * step.dt * (step.decayFor?.(id) ?? 1); + if (id === step.segmentId) heat += (step.distance / METRES_PER_HEAT) * accrual; heat = Math.min(1, Math.max(0, heat)); state.value[id] = heat; diff --git a/src/sim/intel.ts b/src/sim/intel.ts index e96e397..fb0a8d2 100644 --- a/src/sim/intel.ts +++ b/src/sim/intel.ts @@ -9,6 +9,9 @@ import { levelFor, type HeatLevel, type HeatState } from './heat'; import type { RoadNetwork } from './roads'; +/** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */ +export const CELL_SIZE = 24; + export interface Intel { /** Heat value at the moment each segment was last observed. */ rememberedHeat: number[]; @@ -18,17 +21,64 @@ export interface Intel { seenAt: number[]; /** Target nodes the player has actually reached. */ visitedNodes: Set; + /** Ground actually laid eyes on, as a coarse grid. 1 = explored. */ + explored: Uint8Array; + cellsPerSide: number; + /** World coordinate of the grid's lower corner. */ + gridOrigin: number; } -export function createIntel(roads: RoadNetwork): Intel { +export function createIntel(roads: RoadNetwork, extent: number): Intel { + // Cover more than the road network reaches, since node jitter pushes the + // outermost junctions past `extent`. + const gridOrigin = -(extent + 60); + const cellsPerSide = Math.ceil((-gridOrigin * 2) / CELL_SIZE); return { rememberedHeat: new Array(roads.segments.length).fill(0), rememberedLevel: new Array(roads.segments.length).fill('clear'), seenAt: new Array(roads.segments.length).fill(-1), visitedNodes: new Set(), + explored: new Uint8Array(cellsPerSide * cellsPerSide), + cellsPerSide, + gridOrigin, }; } +export const cellIndex = (intel: Intel, x: number, z: number): number | null => { + const col = Math.floor((x - intel.gridOrigin) / CELL_SIZE); + const row = Math.floor((z - intel.gridOrigin) / CELL_SIZE); + if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) return null; + return row * intel.cellsPerSide + col; +}; + +export const isExplored = (intel: Intel, x: number, z: number): boolean => { + const index = cellIndex(intel, x, z); + return index !== null && intel.explored[index] === 1; +}; + +/** Marks everything within `radius` of a point as seen. Returns new cells found. */ +export function reveal(intel: Intel, x: number, z: number, radius: number): number { + let found = 0; + const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE); + const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE); + const minRow = Math.floor((z - radius - intel.gridOrigin) / CELL_SIZE); + const maxRow = Math.ceil((z + radius - intel.gridOrigin) / CELL_SIZE); + + for (let row = minRow; row <= maxRow; row++) { + for (let col = min; col <= max; col++) { + if (col < 0 || row < 0 || col >= intel.cellsPerSide || row >= intel.cellsPerSide) continue; + const cx = intel.gridOrigin + (col + 0.5) * CELL_SIZE; + const cz = intel.gridOrigin + (row + 0.5) * CELL_SIZE; + if (Math.hypot(cx - x, cz - z) > radius) continue; + const index = row * intel.cellsPerSide + col; + if (intel.explored[index] === 1) continue; + intel.explored[index] = 1; + found++; + } + } + return found; +} + export const hasSeen = (intel: Intel, segmentId: number): boolean => intel.seenAt[segmentId]! >= 0; /** Record what a segment looks like right now. */ @@ -60,6 +110,8 @@ export function survey( observe(intel, heat, s.id, now); count++; } + // A survey fills in the ground too, not just the roads through it. + reveal(intel, x, z, radius); return count; } diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts index 93e3a87..7a5a90c 100644 --- a/src/sim/quests.test.ts +++ b/src/sim/quests.test.ts @@ -71,7 +71,7 @@ describe('routing', () => { describe('intel', () => { it('starts blank and only records what was observed', () => { - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const heat = createHeat(world.roads); expect(hasSeen(intel, 4)).toBe(false); observe(intel, heat, 4, 10); @@ -80,7 +80,7 @@ describe('intel', () => { }); it('remembers what a road was, not what it has since become', () => { - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const heat = createHeat(world.roads); observe(intel, heat, 0, 0); expect(intel.rememberedLevel[0]).toBe('clear'); @@ -92,7 +92,7 @@ describe('intel', () => { }); it('surveys a whole area at once, which is what recon is for', () => { - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const heat = createHeat(world.roads); const node = world.roads.nodes[24]!; const mapped = survey(intel, heat, world.roads, node.x, node.z, 5); @@ -101,7 +101,7 @@ describe('intel', () => { }); it('summarises a route by its worst road and how much is unscouted', () => { - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const heat = createHeat(world.roads); const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!; @@ -122,7 +122,7 @@ describe('intel', () => { describe('quest board', () => { it('always offers something new to go and look at', () => { const state = createQuests(); - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1); expect(offers.length).toBeGreaterThan(0); expect(offers.some((o) => o.novel)).toBe(true); @@ -130,7 +130,7 @@ describe('quest board', () => { it('offers both a known and an unknown target once somewhere has been visited', () => { const state = createQuests(); - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); intel.visitedNodes.add(world.roads.nodes[10]!.id); intel.visitedNodes.add(world.roads.nodes[30]!.id); const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 7); @@ -141,8 +141,8 @@ describe('quest board', () => { it('pays more for an unknown target, or nobody would ever take one', () => { // Same target, same route — the only difference is whether it is known. const state = createQuests(); - const blank = createIntel(world.roads); - const known = createIntel(world.roads); + const blank = createIntel(world.roads, world.extent); + const known = createIntel(world.roads, world.extent); for (const n of world.roads.nodes) known.visitedNodes.add(n.id); const rewardPerKm = (intel: typeof blank) => { @@ -156,7 +156,7 @@ describe('quest board', () => { it('never offers the base you are standing in as a target', () => { const state = createQuests(); - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); for (const base of bases) { for (const offer of offersAt(state, world.roads, graph, intel, base, 0, 11)) { expect(offer.targetNode).not.toBe(base.nodeId); @@ -168,7 +168,7 @@ describe('quest board', () => { describe('mission lifecycle', () => { const start = () => { const state = createQuests(); - const intel = createIntel(world.roads); + const intel = createIntel(world.roads, world.extent); const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 5)[0]!; accept(state, offer, bases[0]!); return state; diff --git a/src/sim/regions.test.ts b/src/sim/regions.test.ts new file mode 100644 index 0000000..ae320ba --- /dev/null +++ b/src/sim/regions.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { generateWorld } from './world'; +import { placeBases } from './bases'; +import { distanceToRoad } from './roads'; +import { controlAt, createFront, CONTROLS, DECAY_MULTIPLIER, HEAT_MULTIPLIER, depthAt } from './regions'; +import { createHeat, stepHeat } from './heat'; +import { cellIndex, createIntel, isExplored, reveal } from './intel'; + +const world = generateWorld(1337); +const front = createFront(1337, world.extent, world.spawn); + +describe('front line', () => { + it('starts the player behind their own line', () => { + expect(controlAt(front, world.spawn.x, world.spawn.z)).toBe('liberated'); + }); + + it('runs through every band as you push along the axis', () => { + const seen = new Set(); + for (let d = -world.extent; d < world.extent * 2; d += 5) { + seen.add(controlAt(front, front.axis.x * d, front.axis.z * d)); + } + expect([...seen].sort()).toEqual([...CONTROLS].sort()); + }); + + it('is a line, not a blob: control depends only on depth along the axis', () => { + const along = { x: -front.axis.z, z: front.axis.x }; + const base = { x: front.axis.x * 40, z: front.axis.z * 40 }; + const control = controlAt(front, base.x, base.z); + // Sliding sideways along the front must not change whose ground it is. + for (const t of [-300, -80, 80, 300]) { + expect(controlAt(front, base.x + along.x * t, base.z + along.z * t)).toBe(control); + } + expect(depthAt(front, base.x, base.z)).toBeCloseTo(40, 6); + }); + + it('gets more hostile the deeper you go, never less', () => { + const accrual = CONTROLS.map((c) => HEAT_MULTIPLIER[c]); + const decay = CONTROLS.map((c) => DECAY_MULTIPLIER[c]); + for (let i = 1; i < CONTROLS.length; i++) { + expect(accrual[i]!).toBeGreaterThan(accrual[i - 1]!); + expect(decay[i]!).toBeLessThan(decay[i - 1]!); + } + }); +}); + +describe('heat under regional control', () => { + it('accrues nothing at all behind your own lines', () => { + const heat = createHeat(world.roads); + for (let m = 0; m < 2000; m++) { + stepHeat(heat, { + dt: 1 / 60, + segmentId: 0, + distance: 1, + accrual: HEAT_MULTIPLIER.liberated, + }); + } + // Not "a little" — exactly none. Your own back garden never fortifies. + expect(heat.value[0]).toBe(0); + expect(heat.level[0]).toBe('clear'); + }); + + it('accrues fastest past the front', () => { + const drive = (accrual: number) => { + const heat = createHeat(world.roads); + for (let m = 0; m < 200; m++) { + stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1, accrual }); + } + return heat.value[0]!; + }; + expect(drive(HEAT_MULTIPLIER.frontier)).toBeGreaterThan(drive(HEAT_MULTIPLIER.occupied)); + expect(drive(HEAT_MULTIPLIER.occupied)).toBeGreaterThan(drive(HEAT_MULTIPLIER.contested)); + }); + + it('lets your own side dismantle a checkpoint faster than the enemy rebuilds', () => { + const cool = (decay: number) => { + const heat = createHeat(world.roads); + heat.value[0] = 1; + for (let i = 0; i < 60 * 60; i++) { + stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0, decayFor: () => decay }); + } + return heat.value[0]!; + }; + expect(cool(DECAY_MULTIPLIER.liberated)).toBeLessThan(cool(DECAY_MULTIPLIER.frontier)); + }); + + it('decays a road by who holds that road, not who holds the car', () => { + const heat = createHeat(world.roads); + heat.value[0] = 1; + heat.value[1] = 1; + for (let i = 0; i < 60 * 30; i++) { + stepHeat(heat, { + dt: 1 / 60, + segmentId: null, + distance: 0, + decayFor: (id) => (id === 0 ? DECAY_MULTIPLIER.liberated : DECAY_MULTIPLIER.frontier), + }); + } + expect(heat.value[0]).toBeLessThan(heat.value[1]!); + }); +}); + +describe('fog of war', () => { + const intel = () => createIntel(world.roads, world.extent); + + it('starts entirely blank', () => { + expect(intel().explored.some((v) => v === 1)).toBe(false); + }); + + it('uncovers only what you drove past', () => { + const map = intel(); + reveal(map, 0, 0, 60); + expect(isExplored(map, 0, 0)).toBe(true); + expect(isExplored(map, 30, 0)).toBe(true); + expect(isExplored(map, 200, 200)).toBe(false); + }); + + it('does not re-count ground already uncovered', () => { + const map = intel(); + const first = reveal(map, 0, 0, 60); + expect(first).toBeGreaterThan(0); + expect(reveal(map, 0, 0, 60)).toBe(0); + }); + + it('handles the map edges without falling off the grid', () => { + const map = intel(); + const corner = world.extent + 55; + expect(() => reveal(map, corner, corner, 90)).not.toThrow(); + expect(cellIndex(map, corner * 4, 0)).toBeNull(); + expect(isExplored(map, corner * 4, 0)).toBe(false); + }); +}); + +describe('base buildings', () => { + const bases = placeBases(world.roads, world.spawn); + + it('sits the hut off the road, not on the junction the player parks at', () => { + for (const base of bases) { + // Clear of the tarmac... + expect(distanceToRoad(world.roads, base.hutX, base.hutZ)).toBeGreaterThan(3.5); + // ...and clear of the spawn point, which is what was swallowing the car. + expect(Math.hypot(base.hutX - base.x, base.hutZ - base.z)).toBeGreaterThan(10); + } + }); + + it('keeps the hut close enough to still read as that base', () => { + for (const base of bases) { + expect(Math.hypot(base.hutX - base.x, base.hutZ - base.z)).toBeLessThan(20); + } + }); +}); diff --git a/src/sim/regions.ts b/src/sim/regions.ts new file mode 100644 index 0000000..0efc372 --- /dev/null +++ b/src/sim/regions.ts @@ -0,0 +1,90 @@ +/** + * Regional control and the front line. Pure — no engine imports. + * + * The map is not a uniform hostile plain. Behind your own lines nobody is + * watching you, so driving costs you nothing but fuel and tyres; past the front + * every road you use is a road someone notices. That boundary is what makes + * "which way do I go" a question about territory and not just distance. + * + * Modelled as a single directional front rather than blobs of territory, + * because the brief wants the line to *move*, in both directions — and a scalar + * offset per boundary is something Phase 4 can slide either way. + */ +import { makeRng } from '../core/rng'; + +export const CONTROLS = ['liberated', 'contested', 'occupied', 'frontier'] as const; +export type Control = (typeof CONTROLS)[number]; + +export interface Front { + /** Unit vector pointing from friendly territory toward enemy territory. */ + axis: { x: number; z: number }; + /** + * Distance along the axis at which each band ends. Monotonic. Phase 4 moves + * these; nothing else about the model has to change. + */ + boundaries: { liberated: number; contested: number; occupied: number }; +} + +/** + * Heat accrual multiplier per band. + * + * Liberated is exactly zero, not merely small. A trickle would still eventually + * fortify your own back garden, which is nonsense, and it would mean the safe + * area quietly punishes you for using it. + */ +export const HEAT_MULTIPLIER: Record = { + liberated: 0, + contested: 0.55, + occupied: 1, + frontier: 1.3, +}; + +/** Heat decay multiplier: your own side dismantles checkpoints quickly. */ +export const DECAY_MULTIPLIER: Record = { + liberated: 4, + contested: 1.4, + occupied: 1, + frontier: 0.8, +}; + +export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front { + const rng = makeRng(seed ^ 0x51ed270b); + const angle = rng() * Math.PI * 2; + const axis = { x: Math.cos(angle), z: Math.sin(angle) }; + + // Anchor the bands relative to home, so the player always starts safely + // behind their own line rather than wherever the seed happened to put it. + const at = home.x * axis.x + home.z * axis.z; + return { + axis, + boundaries: { + liberated: at + extent * 0.3, + contested: at + extent * 0.75, + occupied: at + extent * 1.3, + }, + }; +} + +/** Signed distance along the front axis. Higher is deeper into enemy ground. */ +export const depthAt = (front: Front, x: number, z: number): number => + x * front.axis.x + z * front.axis.z; + +export function controlAt(front: Front, x: number, z: number): Control { + const depth = depthAt(front, x, z); + if (depth < front.boundaries.liberated) return 'liberated'; + if (depth < front.boundaries.contested) return 'contested'; + if (depth < front.boundaries.occupied) return 'occupied'; + return 'frontier'; +} + +/** + * How far the point is past the nearest band edge, in metres. Used to fade the + * world's tone across a border rather than snapping it, so crossing reads as a + * gradient the player feels before they can name it. + */ +export function distanceToBorder(front: Front, x: number, z: number): number { + const depth = depthAt(front, x, z); + return Math.min( + ...Object.values(front.boundaries).map((edge) => Math.abs(depth - edge)), + ); +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts index fb6154e..12bf57e 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -1,6 +1,7 @@ import { SUBSYSTEMS, type CarCondition } from '../sim/car'; import type { HeatLevel } from '../sim/heat'; import type { MissionType } from '../sim/quests'; +import type { Control } from '../sim/regions'; import { WORK_SECONDS } from '../sim/quests'; const BAR_WIDTH = 12; @@ -31,12 +32,30 @@ export interface HudModel { novel: boolean; worked: number; distance: number; + /** Radians from the car's nose to the target, positive to the left. */ + bearing: number; } | null; + control: Control; completed: number; parts: number; notice: string; } +/** Eight-point compass, starting at "straight ahead" and turning left. */ +const ARROWS = ['↑', '↖', '←', '↙', '↓', '↘', '→', '↗']; + +const arrowFor = (bearing: number): string => { + const sector = Math.round(bearing / (Math.PI / 4)); + return ARROWS[((sector % 8) + 8) % 8]!; +}; + +const CONTROL_WORDS: Record = { + liberated: 'liberated — nobody is watching the roads here', + contested: 'contested — the roads remember, slowly', + occupied: 'occupied — every road you use is noticed', + frontier: 'frontier — beyond the line, and it shows', +}; + function questLines(quest: NonNullable): string[] { const label = quest.type === 'recon' ? 'Survey' : 'Supply run'; const kind = quest.novel ? 'new target' : 'known target'; @@ -46,7 +65,10 @@ function questLines(quest: NonNullable): string[] { if (quest.stage === 'working') { return [`${label} — working ${quest.worked.toFixed(1)}/${WORK_SECONDS}s (hold still)`]; } - return [`${label} (${kind}) — ${(quest.distance / 1000).toFixed(2)} km to target`]; + return [ + `${label} (${kind})`, + `${arrowFor(quest.bearing)} ${(quest.distance / 1000).toFixed(2)} km to target`, + ]; } export function createHud(seed: number) { @@ -71,6 +93,8 @@ export function createHud(seed: number) { '', ...(model.quest ? questLines(model.quest) : ['no mission — find a base']), '', + CONTROL_WORDS[model.control], + '', // Debug only. The real game signals heat through the road itself — // see the design brief: no numeric meter ships. `[debug] road ${ diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts new file mode 100644 index 0000000..2eb98da --- /dev/null +++ b/src/ui/minimap.ts @@ -0,0 +1,189 @@ +import type { Base } from '../sim/bases'; +import type { HeatLevel } from '../sim/heat'; +import { CELL_SIZE, hasSeen, type Intel } from '../sim/intel'; +import type { RoadNetwork } from '../sim/roads'; +import { controlAt, type Control, type Front } from '../sim/regions'; + +/** + * A sketch map, drawn from `Intel` rather than from the world. + * + * It shows the shape of the road network everywhere — you are an operative with + * a map, not an amnesiac — but only ground you have actually been through gets + * filled in, and only roads you have actually seen carry any information about + * what is on them. Unexplored country is a faint outline: enough to plan a route + * toward, not enough to know what it costs. + */ +const SIZE = 190; +const PADDING = 12; + +const HEAT_COLOURS: Record = { + clear: '#7d8b98', + patrol: '#c9c05a', + barricade: '#d98a3d', + turret: '#d4544c', +}; + +const CONTROL_COLOURS: Record = { + liberated: '#2c4436', + contested: '#3d3f2e', + occupied: '#43302c', + frontier: '#3a2b3d', +}; + +export interface MinimapView { + x: number; + z: number; + /** Car heading in radians, matching the world's yaw convention. */ + heading: number; + objective: { x: number; z: number } | null; +} + +export function createMinimap( + roads: RoadNetwork, + bases: Base[], + front: Front, + worldExtent: number, +) { + const canvas = document.createElement('canvas'); + canvas.id = 'minimap'; + const scale = devicePixelRatio > 1 ? 2 : 1; + canvas.width = SIZE * scale; + canvas.height = SIZE * scale; + canvas.style.cssText = ` + position: fixed; top: 12px; right: 12px; + width: ${SIZE}px; height: ${SIZE}px; + background: rgba(10,13,17,.86); + border: 1px solid #2f3944; border-radius: 4px; + `; + document.body.appendChild(canvas); + + const ctx = canvas.getContext('2d')!; + ctx.scale(scale, scale); + + // World spans [-half, half]; map that onto the canvas once, here. + const half = worldExtent + 60; + const span = half * 2; + const px = (worldX: number) => PADDING + ((worldX + half) / span) * (SIZE - PADDING * 2); + + return { + draw(intel: Intel, view: MinimapView) { + ctx.clearRect(0, 0, SIZE, SIZE); + + // --- Explored ground, tinted by who holds it --- + const cell = (CELL_SIZE / span) * (SIZE - PADDING * 2); + for (let row = 0; row < intel.cellsPerSide; row++) { + for (let col = 0; col < intel.cellsPerSide; col++) { + if (intel.explored[row * intel.cellsPerSide + col] !== 1) continue; + const wx = intel.gridOrigin + (col + 0.5) * CELL_SIZE; + const wz = intel.gridOrigin + (row + 0.5) * CELL_SIZE; + ctx.fillStyle = CONTROL_COLOURS[controlAt(front, wx, wz)]; + // +1 closes the hairline seams between neighbouring cells. + ctx.fillRect(px(wx) - cell / 2, px(wz) - cell / 2, cell + 1, cell + 1); + } + } + + // --- Roads: rough everywhere, detailed where seen --- + for (const s of roads.segments) { + const seen = hasSeen(intel, s.id); + ctx.beginPath(); + ctx.moveTo(px(s.ax), px(s.az)); + ctx.lineTo(px(s.bx), px(s.bz)); + if (seen) { + ctx.setLineDash([]); + ctx.lineWidth = 2; + ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!]; + } else { + // Known to exist, nothing known about it. + ctx.setLineDash([2, 3]); + ctx.lineWidth = 1; + ctx.strokeStyle = '#3c464f'; + } + ctx.stroke(); + } + ctx.setLineDash([]); + + // --- The front line, once you have seen ground on both sides of it --- + drawFront(ctx, front, intel, px, half); + + // --- Bases --- + for (const base of bases) { + ctx.fillStyle = '#5fd08a'; + ctx.fillRect(px(base.x) - 3, px(base.z) - 3, 6, 6); + } + + // --- Objective --- + if (view.objective) { + ctx.beginPath(); + ctx.arc(px(view.objective.x), px(view.objective.z), 4.5, 0, Math.PI * 2); + ctx.strokeStyle = '#ffc247'; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // --- The car, as an arrow, so heading is readable at a glance --- + const cx = px(view.x); + const cz = px(view.z); + ctx.save(); + ctx.translate(cx, cz); + // World forward is +Z at heading 0, and canvas +y is world +z. + ctx.rotate(-view.heading); + ctx.beginPath(); + ctx.moveTo(0, 6); + ctx.lineTo(-4, -4); + ctx.lineTo(0, -1.5); + ctx.lineTo(4, -4); + ctx.closePath(); + ctx.fillStyle = '#f2f6fa'; + ctx.fill(); + ctx.restore(); + + // --- North, since the map never rotates --- + ctx.fillStyle = '#6d7883'; + ctx.font = '9px ui-monospace, monospace'; + ctx.fillText('N', SIZE / 2 - 3, 10); + }, + }; +} + +/** + * The front is drawn as a dashed line, but only across country the player has + * explored. You know where the border is because you crossed it, not because + * you were handed a map of the war. + */ +function drawFront( + ctx: CanvasRenderingContext2D, + front: Front, + intel: Intel, + px: (n: number) => number, + half: number, +) { + const along = { x: -front.axis.z, z: front.axis.x }; + ctx.setLineDash([3, 4]); + ctx.lineWidth = 1; + + for (const edge of Object.values(front.boundaries)) { + ctx.strokeStyle = '#8892a0'; + ctx.beginPath(); + let drawing = false; + for (let t = -half * 1.6; t <= half * 1.6; t += CELL_SIZE / 2) { + const x = front.axis.x * edge + along.x * t; + const z = front.axis.z * edge + along.z * t; + const index = + Math.floor((z - intel.gridOrigin) / CELL_SIZE) * intel.cellsPerSide + + Math.floor((x - intel.gridOrigin) / CELL_SIZE); + const known = intel.explored[index] === 1; + if (!known) { + drawing = false; + continue; + } + if (!drawing) { + ctx.moveTo(px(x), px(z)); + drawing = true; + } else { + ctx.lineTo(px(x), px(z)); + } + } + ctx.stroke(); + } + ctx.setLineDash([]); +}