Phase 4: a front line that moves, and stops the map from telling you
The objective beacon now goes out once the cargo is dropped or the survey is done. The target is no longer somewhere the player needs to be, and the bases' own beacons take over for the trip home. The front moves from two independent sources. Mission completions push it forward, weighted by what the job was worth, and that contribution decays over about seven minutes so ground won is not held for free. On top of that the line drifts on its own in both directions, which the brief is explicit about: ambient drift is what makes this a war rather than a level, and it is the only mechanism for the enemy's attention being pulled elsewhere. Drift is a sum of slow waves, not a random walk. A first attempt at a damped walk moved 3m in four minutes, because white noise scaled by dt averages itself out. Waves at incommensurate periods are unpredictable in practice, bounded by construction, and reproducible from the seed, so a test can assert that a region changed hands while the player was away. Most of the movement is shared across boundaries, since a front advances as a whole; independent per-band waves were squeezing bands into the ordering clamp. Crucially the map no longer reads live state. Intel now records who held each cell when the player last stood in it, and the minimap draws that, with the border inferred from where remembered control changes rather than from the front's true position. Otherwise the map would quietly hand the player a live readout of a line that is supposed to move behind their back. Stale roads are drawn faded, so untrustworthy notes look untrustworthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
477195a7d6
commit
f5742de595
71
README.md
71
README.md
@ -6,6 +6,7 @@ Endless driving survival game.
|
|||||||
- **Phase 1** — a road network whose roads remember being driven, and escalate.
|
- **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 2** — bases, a quest board, and known-vs-new target selection.
|
||||||
- **Phase 3** — regional control: behind your own lines, nobody is watching.
|
- **Phase 3** — regional control: behind your own lines, nobody is watching.
|
||||||
|
- **Phase 4** — the front line moves, from your work and on its own.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
@ -25,7 +26,9 @@ seeds may be numbers or words.
|
|||||||
| `1`–`3` | accept a mission, when parked at a base |
|
| `1`–`3` | accept a mission, when parked at a base |
|
||||||
|
|
||||||
Drive to a green beacon (a base) and stop. Take a job, drive to the amber
|
Drive to a green beacon (a base) and stop. Take a job, drive to the amber
|
||||||
beacon, stop and hold position for three seconds, then return to **any** base.
|
beacon, stop and hold position for three seconds, then return to **any** base —
|
||||||
|
the amber beacon goes out once the job at the target is done, since the target
|
||||||
|
is no longer anywhere you need to be.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # sim + headless physics
|
npm test # sim + headless physics
|
||||||
@ -146,10 +149,37 @@ 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
|
car is standing on — your own side dismantles checkpoints on its own roads
|
||||||
whether or not you are there to watch.
|
whether or not you are there to watch.
|
||||||
|
|
||||||
It is modelled as one directional front rather than blobs of territory,
|
### The line moves
|
||||||
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
|
Two forces, and they are independent by design.
|
||||||
touching anything else.
|
|
||||||
|
**Your work.** Completing a mission pushes the front forward, weighted by what
|
||||||
|
the job was worth — a long haul into unknown country moves the war more than a
|
||||||
|
local errand, and supply runs move it more than surveys. Ground won is not held
|
||||||
|
forever: the player's contribution decays over about seven minutes, so the enemy
|
||||||
|
presses back on its own.
|
||||||
|
|
||||||
|
**The war elsewhere.** The line also drifts with nobody's help, in both
|
||||||
|
directions. This is not background flavour: the brief is explicit that ambient
|
||||||
|
drift is what makes the setting a war rather than a level, and that it is the
|
||||||
|
*only* mechanism for the enemy's attention being pulled elsewhere — there is no
|
||||||
|
separate "baseline threat" system alongside it.
|
||||||
|
|
||||||
|
Drift is a sum of slow waves rather than a random walk. A walk either wanders
|
||||||
|
off the map or, once reined in, moves so little the war reads as static. Waves
|
||||||
|
at incommensurate periods are unpredictable in practice, always bounded, and
|
||||||
|
reproducible from the seed — so "the region changed while you were away" is
|
||||||
|
something a test can assert. Most of the movement is shared by every boundary,
|
||||||
|
because a front that advances advances as a whole; each band then wobbles a
|
||||||
|
little on its own so the map is not one rigid object sliding about.
|
||||||
|
|
||||||
|
In practice a boundary swings about ±45 m and a spot near a border changes hands
|
||||||
|
several times over a quarter of an hour.
|
||||||
|
|
||||||
|
**Nothing announces any of this.** There is no notification, no readout, no
|
||||||
|
number. The minimap shows who held each patch of ground *when you last stood in
|
||||||
|
it* — so after a while away, your map is simply wrong, and the only way to find
|
||||||
|
out is to go back and look.
|
||||||
|
|
||||||
**You are never told which band you are in by the world itself.** Crossing a
|
**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
|
border eases the fog, the light and the palette: your own ground is open and
|
||||||
@ -166,10 +196,17 @@ A sketch map, top right, drawn from `Intel` rather than from the world:
|
|||||||
an operative with a map, not an amnesiac.
|
an operative with a map, not an amnesiac.
|
||||||
- **Roads you have actually seen** are drawn solid and coloured by the heat you
|
- **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.
|
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
|
- **Ground you have driven through** is filled in, tinted by who held it *when
|
||||||
survey mission fills in a whole area at once.
|
you were last there*. 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.
|
- **The border** is drawn between adjacent remembered cells that you recall as
|
||||||
You know where the border is because you crossed it.
|
belonging to different sides — never from the front's true position, which
|
||||||
|
would hand you a live readout of a line that is supposed to move behind your
|
||||||
|
back.
|
||||||
|
- **Old notes fade.** An observation drawn faint is one you should not trust.
|
||||||
|
|
||||||
|
Nothing on the map reads live world state. Heat is what you last saw on a road;
|
||||||
|
territory is who held it when you last stood there. Both keep changing after you
|
||||||
|
leave, and the map has no way of knowing.
|
||||||
|
|
||||||
The HUD also carries an eight-point compass arrow to the active target, since
|
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.
|
the map alone does not tell you which way to point the car.
|
||||||
@ -263,10 +300,18 @@ 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
|
that names it. If you cannot, the palettes in `render/scene.ts` are too close
|
||||||
together — push them apart before trusting the band boundaries.
|
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:
|
**Phase 4:** returning to a region after time away, you are genuinely unsure
|
||||||
mission completions nudging the boundaries, plus ambient drift that happens
|
what you will find until you are back on the ground — and when it has changed,
|
||||||
whether or not you are watching. `Front.boundaries` is already the only thing
|
it reads as the world having moved rather than as a notification you were given.
|
||||||
that would have to change.
|
If the world instead feels arbitrary, the drift is too fast: lengthen the wave
|
||||||
|
periods in `sim/regions.ts` before shrinking their amplitude, since it is the
|
||||||
|
*rate* of change that reads as noise, not the size of it.
|
||||||
|
|
||||||
|
Next is **Phase 5** — narrative and mission texture, all four mission types made
|
||||||
|
to feel distinct, and signals that the enemy has other priorities. Before that,
|
||||||
|
two things are worth doing regardless: persistence (nothing survives a reload)
|
||||||
|
and taking the debug lines out of the HUD, since several gates now depend on
|
||||||
|
reading the world rather than a readout.
|
||||||
|
|
||||||
Watch for one failure mode in particular: if you find yourself taking whichever
|
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
|
job is *nearest* and ignoring the intel line entirely, the board is decorative
|
||||||
|
|||||||
52
src/main.ts
52
src/main.ts
@ -5,7 +5,14 @@ import { routeAt, segmentAt } from './sim/roads';
|
|||||||
import { baseAt, placeBases } from './sim/bases';
|
import { baseAt, placeBases } from './sim/bases';
|
||||||
import { buildGraph } from './sim/routing';
|
import { buildGraph } from './sim/routing';
|
||||||
import { createIntel, observe, reveal, survey } from './sim/intel';
|
import { createIntel, observe, reveal, survey } from './sim/intel';
|
||||||
import { controlAt, createFront, DECAY_MULTIPLIER, HEAT_MULTIPLIER } from './sim/regions';
|
import {
|
||||||
|
applyMissionImpact,
|
||||||
|
controlAt,
|
||||||
|
createFront,
|
||||||
|
stepFront,
|
||||||
|
DECAY_MULTIPLIER,
|
||||||
|
HEAT_MULTIPLIER,
|
||||||
|
} from './sim/regions';
|
||||||
import { createMinimap } from './ui/minimap';
|
import { createMinimap } from './ui/minimap';
|
||||||
import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests';
|
import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
@ -40,7 +47,7 @@ async function boot() {
|
|||||||
const physics = await createPhysics(model);
|
const physics = await createPhysics(model);
|
||||||
const view = createScene(model);
|
const view = createScene(model);
|
||||||
const markers = createMarkers(view.scene, bases);
|
const markers = createMarkers(view.scene, bases);
|
||||||
const minimap = createMinimap(model.roads, bases, front, model.extent);
|
const minimap = createMinimap(model.roads, bases, model.extent);
|
||||||
|
|
||||||
// Base buildings are solid. They are placed off the junction, so this never
|
// Base buildings are solid. They are placed off the junction, so this never
|
||||||
// walls off the road the player has to park on.
|
// walls off the road the player has to park on.
|
||||||
@ -71,10 +78,14 @@ async function boot() {
|
|||||||
let currentSegment: number | null = null;
|
let currentSegment: number | null = null;
|
||||||
let onTarmac = false;
|
let onTarmac = false;
|
||||||
let control = controlAt(front, model.spawn.x, model.spawn.z);
|
let control = controlAt(front, model.spawn.x, model.spawn.z);
|
||||||
/** Which side holds each road, cached — the front is static in this phase. */
|
/** Who holds each road right now. Recomputed each step: the line moves. */
|
||||||
const segmentControl = model.roads.segments.map((s) =>
|
const segmentControl = model.roads.segments.map(() => control);
|
||||||
controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2),
|
const refreshSegmentControl = () => {
|
||||||
);
|
for (const s of model.roads.segments) {
|
||||||
|
segmentControl[s.id] = controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refreshSegmentControl();
|
||||||
/** Offers are generated once per arrival at a base, not every frame. */
|
/** Offers are generated once per arrival at a base, not every frame. */
|
||||||
let openBase: number | null = null;
|
let openBase: number | null = null;
|
||||||
let offers: Offer[] = [];
|
let offers: Offer[] = [];
|
||||||
@ -87,6 +98,7 @@ async function boot() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nodeOf = (id: number) => model.roads.nodes[id]!;
|
const nodeOf = (id: number) => model.roads.nodes[id]!;
|
||||||
|
const controlOf = (x: number, z: number) => controlAt(front, x, z);
|
||||||
|
|
||||||
document.getElementById('boot')?.remove();
|
document.getElementById('boot')?.remove();
|
||||||
|
|
||||||
@ -113,6 +125,10 @@ async function boot() {
|
|||||||
|
|
||||||
// Heat: the road remembers being used — including being driven alongside.
|
// Heat: the road remembers being used — including being driven alongside.
|
||||||
// Behind your own lines it remembers nothing: nobody there is watching.
|
// Behind your own lines it remembers nothing: nobody there is watching.
|
||||||
|
// The war moves whether or not the player is looking at it.
|
||||||
|
stepFront(front, dt);
|
||||||
|
refreshSegmentControl();
|
||||||
|
|
||||||
const at = physics.chassis.translation();
|
const at = physics.chassis.translation();
|
||||||
control = controlAt(front, at.x, at.z);
|
control = controlAt(front, at.x, at.z);
|
||||||
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
||||||
@ -133,7 +149,7 @@ async function boot() {
|
|||||||
// Driving a road is how you learn what is on it, and seeing ground is how
|
// Driving a road is how you learn what is on it, and seeing ground is how
|
||||||
// it stops being a blank on the map.
|
// it stops being a blank on the map.
|
||||||
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
|
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
|
||||||
reveal(intel, at.x, at.z, SIGHT_RADIUS);
|
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf);
|
||||||
|
|
||||||
const base = baseAt(bases, at.x, at.z);
|
const base = baseAt(bases, at.x, at.z);
|
||||||
const stopped = Math.abs(speed) < 2.5;
|
const stopped = Math.abs(speed) < 2.5;
|
||||||
@ -183,12 +199,24 @@ async function boot() {
|
|||||||
say('At the target. Stop the car and hold position.');
|
say('At the target. Stop the car and hold position.');
|
||||||
} else if (event?.kind === 'worked') {
|
} else if (event?.kind === 'worked') {
|
||||||
if (event.type === 'recon') {
|
if (event.type === 'recon') {
|
||||||
const count = survey(intel, heat, model.roads, target.x, target.z, elapsed);
|
const count = survey(
|
||||||
|
intel,
|
||||||
|
heat,
|
||||||
|
model.roads,
|
||||||
|
target.x,
|
||||||
|
target.z,
|
||||||
|
elapsed,
|
||||||
|
undefined,
|
||||||
|
controlOf,
|
||||||
|
);
|
||||||
say(`Survey complete — ${count} roads mapped. Report back to any base.`);
|
say(`Survey complete — ${count} roads mapped. Report back to any base.`);
|
||||||
} else {
|
} else {
|
||||||
say('Cargo delivered. Report back to any base.');
|
say('Cargo delivered. Report back to any base.');
|
||||||
}
|
}
|
||||||
} else if (event?.kind === 'completed') {
|
} else if (event?.kind === 'completed') {
|
||||||
|
// The line moves because of what the player did. They are not told by
|
||||||
|
// how much — they find out by going back and looking.
|
||||||
|
applyMissionImpact(front, event.impact);
|
||||||
const outcome = repair(condition, quests.parts);
|
const outcome = repair(condition, quests.parts);
|
||||||
const spent = quests.parts - outcome.unused;
|
const spent = quests.parts - outcome.unused;
|
||||||
condition = outcome.condition;
|
condition = outcome.condition;
|
||||||
@ -201,7 +229,10 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
markers.setObjective(quests.active ? nodeOf(quests.active.targetNode) : null);
|
// Once the cargo is dropped or the survey is done, the target is no longer
|
||||||
|
// anywhere the player needs to be — the bases' own beacons take over.
|
||||||
|
const heading = quests.active && quests.active.stage !== 'return' ? quests.active : null;
|
||||||
|
markers.setObjective(heading ? nodeOf(heading.targetNode) : null);
|
||||||
},
|
},
|
||||||
|
|
||||||
render(_alpha, frameDt) {
|
render(_alpha, frameDt) {
|
||||||
@ -242,6 +273,7 @@ async function boot() {
|
|||||||
const target = quest ? nodeOf(quest.targetNode) : null;
|
const target = quest ? nodeOf(quest.targetNode) : null;
|
||||||
|
|
||||||
minimap.draw(intel, {
|
minimap.draw(intel, {
|
||||||
|
now: elapsed,
|
||||||
x: p.x,
|
x: p.x,
|
||||||
z: p.z,
|
z: p.z,
|
||||||
// Yaw about +Y, measured from world +Z, which is the car's local forward.
|
// Yaw about +Y, measured from world +Z, which is the car's local forward.
|
||||||
@ -249,7 +281,7 @@ async function boot() {
|
|||||||
2 * (r.w * r.y + r.x * r.z),
|
2 * (r.w * r.y + r.x * r.z),
|
||||||
1 - 2 * (r.y * r.y + r.x * r.x),
|
1 - 2 * (r.y * r.y + r.x * r.x),
|
||||||
),
|
),
|
||||||
objective: target,
|
objective: quest && quest.stage !== 'return' ? target : null,
|
||||||
});
|
});
|
||||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||||
heat: {
|
heat: {
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
* but ageing; roads you have not are blank.
|
* but ageing; roads you have not are blank.
|
||||||
*/
|
*/
|
||||||
import { levelFor, type HeatLevel, type HeatState } from './heat';
|
import { levelFor, type HeatLevel, type HeatState } from './heat';
|
||||||
|
import { CONTROLS, type Control } from './regions';
|
||||||
import type { RoadNetwork } from './roads';
|
import type { RoadNetwork } from './roads';
|
||||||
|
|
||||||
/** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */
|
/** Side of a fog-of-war cell, metres. Coarse on purpose — this is a sketch map. */
|
||||||
@ -23,6 +24,12 @@ export interface Intel {
|
|||||||
visitedNodes: Set<number>;
|
visitedNodes: Set<number>;
|
||||||
/** Ground actually laid eyes on, as a coarse grid. 1 = explored. */
|
/** Ground actually laid eyes on, as a coarse grid. 1 = explored. */
|
||||||
explored: Uint8Array;
|
explored: Uint8Array;
|
||||||
|
/**
|
||||||
|
* Who held each cell *when the player last stood in it*, as an index into
|
||||||
|
* CONTROLS. The front moves on its own, so this is the only honest thing the
|
||||||
|
* map can show: the player is never told a region changed hands behind them.
|
||||||
|
*/
|
||||||
|
rememberedControl: Uint8Array;
|
||||||
cellsPerSide: number;
|
cellsPerSide: number;
|
||||||
/** World coordinate of the grid's lower corner. */
|
/** World coordinate of the grid's lower corner. */
|
||||||
gridOrigin: number;
|
gridOrigin: number;
|
||||||
@ -39,6 +46,7 @@ export function createIntel(roads: RoadNetwork, extent: number): Intel {
|
|||||||
seenAt: new Array(roads.segments.length).fill(-1),
|
seenAt: new Array(roads.segments.length).fill(-1),
|
||||||
visitedNodes: new Set(),
|
visitedNodes: new Set(),
|
||||||
explored: new Uint8Array(cellsPerSide * cellsPerSide),
|
explored: new Uint8Array(cellsPerSide * cellsPerSide),
|
||||||
|
rememberedControl: new Uint8Array(cellsPerSide * cellsPerSide),
|
||||||
cellsPerSide,
|
cellsPerSide,
|
||||||
gridOrigin,
|
gridOrigin,
|
||||||
};
|
};
|
||||||
@ -56,8 +64,20 @@ export const isExplored = (intel: Intel, x: number, z: number): boolean => {
|
|||||||
return index !== null && intel.explored[index] === 1;
|
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 {
|
* Marks everything within `radius` of a point as seen, recording who holds it
|
||||||
|
* right now. Re-revealing ground you are standing in refreshes that record —
|
||||||
|
* which is exactly how the player finds out the line has moved: by going back.
|
||||||
|
*
|
||||||
|
* Returns the count of cells that were blank before.
|
||||||
|
*/
|
||||||
|
export function reveal(
|
||||||
|
intel: Intel,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
radius: number,
|
||||||
|
controlOf?: (x: number, z: number) => Control,
|
||||||
|
): number {
|
||||||
let found = 0;
|
let found = 0;
|
||||||
const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE);
|
const min = Math.floor((x - radius - intel.gridOrigin) / CELL_SIZE);
|
||||||
const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE);
|
const max = Math.ceil((x + radius - intel.gridOrigin) / CELL_SIZE);
|
||||||
@ -71,6 +91,7 @@ export function reveal(intel: Intel, x: number, z: number, radius: number): numb
|
|||||||
const cz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
|
const cz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
|
||||||
if (Math.hypot(cx - x, cz - z) > radius) continue;
|
if (Math.hypot(cx - x, cz - z) > radius) continue;
|
||||||
const index = row * intel.cellsPerSide + col;
|
const index = row * intel.cellsPerSide + col;
|
||||||
|
if (controlOf) intel.rememberedControl[index] = CONTROLS.indexOf(controlOf(cx, cz));
|
||||||
if (intel.explored[index] === 1) continue;
|
if (intel.explored[index] === 1) continue;
|
||||||
intel.explored[index] = 1;
|
intel.explored[index] = 1;
|
||||||
found++;
|
found++;
|
||||||
@ -101,6 +122,7 @@ export function survey(
|
|||||||
z: number,
|
z: number,
|
||||||
now: number,
|
now: number,
|
||||||
radius = 130,
|
radius = 130,
|
||||||
|
controlOf?: (x: number, z: number) => Control,
|
||||||
): number {
|
): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const s of roads.segments) {
|
for (const s of roads.segments) {
|
||||||
@ -111,7 +133,7 @@ export function survey(
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
// A survey fills in the ground too, not just the roads through it.
|
// A survey fills in the ground too, not just the roads through it.
|
||||||
reveal(intel, x, z, radius);
|
reveal(intel, x, z, radius, controlOf);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,12 @@ export interface Offer {
|
|||||||
intel: RouteIntel;
|
intel: RouteIntel;
|
||||||
/** Parts awarded on hand-in. */
|
/** Parts awarded on hand-in. */
|
||||||
reward: number;
|
reward: number;
|
||||||
|
/**
|
||||||
|
* How far this job pushes the front line, in metres. The brief asks for
|
||||||
|
* impact to be weighted per mission rather than every job counting the same,
|
||||||
|
* so a long haul into unknown country moves the war more than a local errand.
|
||||||
|
*/
|
||||||
|
impact: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveQuest extends Offer {
|
export interface ActiveQuest extends Offer {
|
||||||
@ -68,6 +74,17 @@ function rewardFor(type: MissionType, route: Route, novel: boolean): number {
|
|||||||
return base * (novel ? NOVELTY_BONUS : 1) * (type === 'recon' ? RECON_BONUS : 1);
|
return base * (novel ? NOVELTY_BONUS : 1) * (type === 'recon' ? RECON_BONUS : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Metres of front-line movement a job is worth. */
|
||||||
|
const IMPACT_BASE = 5;
|
||||||
|
const IMPACT_PER_KM = 11;
|
||||||
|
|
||||||
|
function impactFor(type: MissionType, route: Route, novel: boolean): number {
|
||||||
|
// Supply runs move the line more than surveys: intelligence is worth having,
|
||||||
|
// but it is materiel that holds ground.
|
||||||
|
const base = IMPACT_BASE + (route.length / 1000) * IMPACT_PER_KM;
|
||||||
|
return base * (novel ? 1.4 : 1) * (type === 'delivery' ? 1.2 : 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the board for a base: always at least one previously visited target
|
* Builds the board for a base: always at least one previously visited target
|
||||||
* and one the player has never been to, so the comparison the phase is testing
|
* and one the player has never been to, so the comparison the phase is testing
|
||||||
@ -117,6 +134,7 @@ export function offersAt(
|
|||||||
route,
|
route,
|
||||||
intel: summariseRoute(intel, route.segments, now),
|
intel: summariseRoute(intel, route.segments, now),
|
||||||
reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1),
|
reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1),
|
||||||
|
impact: impactFor(type, route, novel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return offers;
|
return offers;
|
||||||
@ -139,7 +157,7 @@ export interface QuestStep {
|
|||||||
export type QuestEvent =
|
export type QuestEvent =
|
||||||
| { kind: 'arrived' }
|
| { kind: 'arrived' }
|
||||||
| { kind: 'worked'; type: MissionType }
|
| { kind: 'worked'; type: MissionType }
|
||||||
| { kind: 'completed'; parts: number };
|
| { kind: 'completed'; parts: number; impact: number };
|
||||||
|
|
||||||
/** Advances the active mission. Returns whatever just happened, if anything. */
|
/** Advances the active mission. Returns whatever just happened, if anything. */
|
||||||
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
||||||
@ -178,7 +196,7 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null
|
|||||||
state.active = null;
|
state.active = null;
|
||||||
state.completed++;
|
state.completed++;
|
||||||
state.parts += quest.reward;
|
state.parts += quest.reward;
|
||||||
return { kind: 'completed', parts: quest.reward };
|
return { kind: 'completed', parts: quest.reward, impact: quest.impact };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,17 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { generateWorld } from './world';
|
import { generateWorld } from './world';
|
||||||
import { placeBases } from './bases';
|
import { placeBases } from './bases';
|
||||||
import { distanceToRoad } from './roads';
|
import { distanceToRoad } from './roads';
|
||||||
import { controlAt, createFront, CONTROLS, DECAY_MULTIPLIER, HEAT_MULTIPLIER, depthAt } from './regions';
|
import {
|
||||||
|
applyMissionImpact,
|
||||||
|
BOUNDARIES,
|
||||||
|
controlAt,
|
||||||
|
createFront,
|
||||||
|
CONTROLS,
|
||||||
|
DECAY_MULTIPLIER,
|
||||||
|
HEAT_MULTIPLIER,
|
||||||
|
depthAt,
|
||||||
|
stepFront,
|
||||||
|
} from './regions';
|
||||||
import { createHeat, stepHeat } from './heat';
|
import { createHeat, stepHeat } from './heat';
|
||||||
import { cellIndex, createIntel, isExplored, reveal } from './intel';
|
import { cellIndex, createIntel, isExplored, reveal } from './intel';
|
||||||
|
|
||||||
@ -99,6 +109,118 @@ describe('heat under regional control', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the front moving on its own', () => {
|
||||||
|
const drift = (seconds: number, seed = 1337) => {
|
||||||
|
const f = createFront(seed, world.extent, world.spawn);
|
||||||
|
const samples: number[] = [];
|
||||||
|
for (let i = 0; i < seconds * 60; i++) {
|
||||||
|
stepFront(f, 1 / 60);
|
||||||
|
if (i % 60 === 0) samples.push(f.boundaries.liberated);
|
||||||
|
}
|
||||||
|
return { front: f, samples };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('drifts without the player doing anything', () => {
|
||||||
|
const { samples } = drift(240);
|
||||||
|
const spread = Math.max(...samples) - Math.min(...samples);
|
||||||
|
// The war continuing elsewhere has to be visible, not a rounding error.
|
||||||
|
expect(spread).toBeGreaterThan(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves in both directions, not just outward', () => {
|
||||||
|
const { front, samples } = drift(400);
|
||||||
|
const start = front.baseline.liberated;
|
||||||
|
expect(Math.min(...samples)).toBeLessThan(start);
|
||||||
|
expect(Math.max(...samples)).toBeGreaterThan(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays bounded rather than wandering off the map', () => {
|
||||||
|
for (const seed of [1, 2, 3, 4, 5]) {
|
||||||
|
const { front } = drift(900, seed);
|
||||||
|
for (const name of BOUNDARIES) {
|
||||||
|
expect(Math.abs(front.offset[name])).toBeLessThanOrEqual(70);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets the bands cross over each other', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
for (let i = 0; i < 60 * 600; i++) {
|
||||||
|
stepFront(f, 1 / 60);
|
||||||
|
// Also shove it hard, to make sure impact cannot invert the order either.
|
||||||
|
if (i % 600 === 0) applyMissionImpact(f, 40);
|
||||||
|
expect(f.boundaries.contested).toBeGreaterThan(f.boundaries.liberated);
|
||||||
|
expect(f.boundaries.occupied).toBeGreaterThan(f.boundaries.contested);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the front moving because of the player', () => {
|
||||||
|
it('pushes the line forward when a mission is completed', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
const before = { ...f.boundaries };
|
||||||
|
applyMissionImpact(f, 20);
|
||||||
|
for (const name of BOUNDARIES) {
|
||||||
|
expect(f.boundaries[name]).toBeGreaterThan(before[name]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the near boundary more than the far one', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
const before = { ...f.boundaries };
|
||||||
|
applyMissionImpact(f, 20);
|
||||||
|
const moved = BOUNDARIES.map((n) => f.boundaries[n] - before[n]);
|
||||||
|
expect(moved[0]!).toBeGreaterThan(moved[1]!);
|
||||||
|
expect(moved[1]!).toBeGreaterThan(moved[2]!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can liberate ground that used to be contested', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
// A point just past the liberated edge.
|
||||||
|
const depth = f.boundaries.liberated + 20;
|
||||||
|
const x = f.axis.x * depth;
|
||||||
|
const z = f.axis.z * depth;
|
||||||
|
expect(controlAt(f, x, z)).toBe('contested');
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) applyMissionImpact(f, 15);
|
||||||
|
expect(controlAt(f, x, z)).toBe('liberated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remembered territory', () => {
|
||||||
|
it('records who held the ground at the moment it was seen', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
const map = createIntel(world.roads, world.extent);
|
||||||
|
reveal(map, world.spawn.x, world.spawn.z, 40, (x, z) => controlAt(f, x, z));
|
||||||
|
|
||||||
|
const index = cellIndex(map, world.spawn.x, world.spawn.z)!;
|
||||||
|
expect(CONTROLS[map.rememberedControl[index]!]).toBe('liberated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update itself when the line moves behind the player', () => {
|
||||||
|
const f = createFront(1337, world.extent, world.spawn);
|
||||||
|
const map = createIntel(world.roads, world.extent);
|
||||||
|
|
||||||
|
// Somewhere past the line, seen once.
|
||||||
|
const depth = f.boundaries.liberated + 20;
|
||||||
|
const x = f.axis.x * depth;
|
||||||
|
const z = f.axis.z * depth;
|
||||||
|
reveal(map, x, z, 20, (px, pz) => controlAt(f, px, pz));
|
||||||
|
const index = cellIndex(map, x, z)!;
|
||||||
|
expect(CONTROLS[map.rememberedControl[index]!]).toBe('contested');
|
||||||
|
|
||||||
|
// The war moves on; the player is elsewhere.
|
||||||
|
for (let i = 0; i < 6; i++) applyMissionImpact(f, 15);
|
||||||
|
expect(controlAt(f, x, z)).toBe('liberated');
|
||||||
|
// The map still says what the player last saw. It is wrong, and it should be.
|
||||||
|
expect(CONTROLS[map.rememberedControl[index]!]).toBe('contested');
|
||||||
|
|
||||||
|
// Going back is the only way to find out.
|
||||||
|
reveal(map, x, z, 20, (px, pz) => controlAt(f, px, pz));
|
||||||
|
expect(CONTROLS[map.rememberedControl[index]!]).toBe('liberated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fog of war', () => {
|
describe('fog of war', () => {
|
||||||
const intel = () => createIntel(world.roads, world.extent);
|
const intel = () => createIntel(world.roads, world.extent);
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,26 @@ import { makeRng } from '../core/rng';
|
|||||||
export const CONTROLS = ['liberated', 'contested', 'occupied', 'frontier'] as const;
|
export const CONTROLS = ['liberated', 'contested', 'occupied', 'frontier'] as const;
|
||||||
export type Control = (typeof CONTROLS)[number];
|
export type Control = (typeof CONTROLS)[number];
|
||||||
|
|
||||||
|
export const BOUNDARIES = ['liberated', 'contested', 'occupied'] as const;
|
||||||
|
export type BoundaryName = (typeof BOUNDARIES)[number];
|
||||||
|
export type Boundaries = Record<BoundaryName, number>;
|
||||||
|
|
||||||
export interface Front {
|
export interface Front {
|
||||||
/** Unit vector pointing from friendly territory toward enemy territory. */
|
/** Unit vector pointing from friendly territory toward enemy territory. */
|
||||||
axis: { x: number; z: number };
|
axis: { x: number; z: number };
|
||||||
/**
|
/** Distance along the axis at which each band ends. Monotonic. */
|
||||||
* Distance along the axis at which each band ends. Monotonic. Phase 4 moves
|
boundaries: Boundaries;
|
||||||
* these; nothing else about the model has to change.
|
/** Where the line would sit with no war going on. Drift is pulled back here. */
|
||||||
*/
|
baseline: Boundaries;
|
||||||
boundaries: { liberated: number; contested: number; occupied: number };
|
/** Current displacement from baseline, metres. */
|
||||||
|
offset: Boundaries;
|
||||||
|
/** Ground won by the player, which the enemy slowly presses back. */
|
||||||
|
playerOffset: Boundaries;
|
||||||
|
/** Phases of the waves the whole front shares. */
|
||||||
|
sharedPhases: number[];
|
||||||
|
/** Phases of each boundary's own small wobble. */
|
||||||
|
phases: Record<BoundaryName, number[]>;
|
||||||
|
elapsed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,6 +59,37 @@ export const DECAY_MULTIPLIER: Record<Control, number> = {
|
|||||||
frontier: 0.8,
|
frontier: 0.8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient drift is a sum of slow waves rather than a random walk.
|
||||||
|
*
|
||||||
|
* A walk either wanders off the map or, once you rein it in, moves so little
|
||||||
|
* that the war reads as static. Incommensurate periods give movement that is
|
||||||
|
* unpredictable in practice, always bounded, and reproducible from the seed —
|
||||||
|
* so "the region changed while you were away" is testable rather than hoped for.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Most of the movement is shared by every boundary: a front that advances
|
||||||
|
* advances as a whole. Each band then wobbles a little on its own, so the map
|
||||||
|
* does not read as one rigid object sliding back and forth.
|
||||||
|
*/
|
||||||
|
const SHARED_WAVES = [
|
||||||
|
{ period: 97, amplitude: 20 },
|
||||||
|
{ period: 173, amplitude: 15 },
|
||||||
|
{ period: 271, amplitude: 10 },
|
||||||
|
];
|
||||||
|
const LOCAL_WAVES = [
|
||||||
|
{ period: 61, amplitude: 6 },
|
||||||
|
{ period: 113, amplitude: 5 },
|
||||||
|
];
|
||||||
|
/** Cap on total displacement from baseline, metres. */
|
||||||
|
const DRIFT_RANGE = 70;
|
||||||
|
/** Seconds over which ground the player won is pressed back. */
|
||||||
|
const PLAYER_TAU = 420;
|
||||||
|
/** Cap on how far the player alone can shift a boundary, metres. */
|
||||||
|
const PLAYER_RANGE = 60;
|
||||||
|
/** Minimum metres between adjacent boundaries, so bands cannot cross over. */
|
||||||
|
const MIN_BAND = 40;
|
||||||
|
|
||||||
export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front {
|
export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front {
|
||||||
const rng = makeRng(seed ^ 0x51ed270b);
|
const rng = makeRng(seed ^ 0x51ed270b);
|
||||||
const angle = rng() * Math.PI * 2;
|
const angle = rng() * Math.PI * 2;
|
||||||
@ -55,14 +98,96 @@ export function createFront(seed: number, extent: number, home: { x: number; z:
|
|||||||
// Anchor the bands relative to home, so the player always starts safely
|
// 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.
|
// behind their own line rather than wherever the seed happened to put it.
|
||||||
const at = home.x * axis.x + home.z * axis.z;
|
const at = home.x * axis.x + home.z * axis.z;
|
||||||
return {
|
const baseline: Boundaries = {
|
||||||
axis,
|
|
||||||
boundaries: {
|
|
||||||
liberated: at + extent * 0.3,
|
liberated: at + extent * 0.3,
|
||||||
contested: at + extent * 0.75,
|
contested: at + extent * 0.75,
|
||||||
occupied: at + extent * 1.3,
|
occupied: at + extent * 1.3,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
const zero = (): Boundaries => ({ liberated: 0, contested: 0, occupied: 0 });
|
||||||
|
const front: Front = {
|
||||||
|
axis,
|
||||||
|
boundaries: { ...baseline },
|
||||||
|
baseline,
|
||||||
|
offset: zero(),
|
||||||
|
playerOffset: zero(),
|
||||||
|
sharedPhases: SHARED_WAVES.map(() => rng() * Math.PI * 2),
|
||||||
|
phases: {
|
||||||
|
liberated: LOCAL_WAVES.map(() => rng() * Math.PI * 2),
|
||||||
|
contested: LOCAL_WAVES.map(() => rng() * Math.PI * 2),
|
||||||
|
occupied: LOCAL_WAVES.map(() => rng() * Math.PI * 2),
|
||||||
|
},
|
||||||
|
elapsed: 0,
|
||||||
|
};
|
||||||
|
// Bring `boundaries` in line with the waves at t=0, or the very first step
|
||||||
|
// would look like a jump.
|
||||||
|
stepFront(front, 0);
|
||||||
|
return front;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveSum = (
|
||||||
|
waves: ReadonlyArray<{ period: number; amplitude: number }>,
|
||||||
|
phases: number[],
|
||||||
|
t: number,
|
||||||
|
): number =>
|
||||||
|
waves.reduce(
|
||||||
|
(total, wave, i) => total + wave.amplitude * Math.sin((t / wave.period) * Math.PI * 2 + phases[i]!),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ambientAt = (front: Front, name: BoundaryName): number =>
|
||||||
|
waveSum(SHARED_WAVES, front.sharedPhases, front.elapsed) +
|
||||||
|
waveSum(LOCAL_WAVES, front.phases[name], front.elapsed);
|
||||||
|
|
||||||
|
function settle(front: Front): void {
|
||||||
|
for (const name of BOUNDARIES) {
|
||||||
|
front.offset[name] = Math.max(-DRIFT_RANGE, Math.min(DRIFT_RANGE, front.offset[name]));
|
||||||
|
front.boundaries[name] = front.baseline[name] + front.offset[name];
|
||||||
|
}
|
||||||
|
// Keep the bands in order even when drift and player impact disagree.
|
||||||
|
front.boundaries.contested = Math.max(
|
||||||
|
front.boundaries.contested,
|
||||||
|
front.boundaries.liberated + MIN_BAND,
|
||||||
|
);
|
||||||
|
front.boundaries.occupied = Math.max(
|
||||||
|
front.boundaries.occupied,
|
||||||
|
front.boundaries.contested + MIN_BAND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The war continuing without you.
|
||||||
|
*
|
||||||
|
* This is not background flavour — the brief is explicit that ambient drift is
|
||||||
|
* what makes the setting a war rather than a level, and that it is the *only*
|
||||||
|
* mechanism for the enemy's attention being pulled elsewhere. So the line moves
|
||||||
|
* whether or not the player is doing anything, and in both directions.
|
||||||
|
*/
|
||||||
|
export function stepFront(front: Front, dt: number): void {
|
||||||
|
front.elapsed += dt;
|
||||||
|
for (const name of BOUNDARIES) {
|
||||||
|
// Ground taken is not held forever; the enemy presses back on its own.
|
||||||
|
front.playerOffset[name] *= Math.exp(-dt / PLAYER_TAU);
|
||||||
|
front.offset[name] = ambientAt(front, name) + front.playerOffset[name];
|
||||||
|
}
|
||||||
|
settle(front);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A completed mission pushes the line forward, weighted by how much the job
|
||||||
|
* was worth. The near boundaries move most: liberating ground consolidates what
|
||||||
|
* is already yours faster than it pushes the far edge of the war.
|
||||||
|
*/
|
||||||
|
const IMPACT_WEIGHT: Boundaries = { liberated: 1, contested: 0.7, occupied: 0.4 };
|
||||||
|
|
||||||
|
export function applyMissionImpact(front: Front, impact: number): void {
|
||||||
|
for (const name of BOUNDARIES) {
|
||||||
|
front.playerOffset[name] = Math.max(
|
||||||
|
-PLAYER_RANGE,
|
||||||
|
Math.min(PLAYER_RANGE, front.playerOffset[name] + impact * IMPACT_WEIGHT[name]),
|
||||||
|
);
|
||||||
|
front.offset[name] = ambientAt(front, name) + front.playerOffset[name];
|
||||||
|
}
|
||||||
|
settle(front);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signed distance along the front axis. Higher is deeper into enemy ground. */
|
/** Signed distance along the front axis. Higher is deeper into enemy ground. */
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { Base } from '../sim/bases';
|
|||||||
import type { HeatLevel } from '../sim/heat';
|
import type { HeatLevel } from '../sim/heat';
|
||||||
import { CELL_SIZE, hasSeen, type Intel } from '../sim/intel';
|
import { CELL_SIZE, hasSeen, type Intel } from '../sim/intel';
|
||||||
import type { RoadNetwork } from '../sim/roads';
|
import type { RoadNetwork } from '../sim/roads';
|
||||||
import { controlAt, type Control, type Front } from '../sim/regions';
|
import { CONTROLS, type Control } from '../sim/regions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sketch map, drawn from `Intel` rather than from the world.
|
* A sketch map, drawn from `Intel` rather than from the world.
|
||||||
@ -12,7 +12,16 @@ import { controlAt, type Control, type Front } from '../sim/regions';
|
|||||||
* filled in, and only roads you have actually seen carry any information about
|
* 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
|
* what is on them. Unexplored country is a faint outline: enough to plan a route
|
||||||
* toward, not enough to know what it costs.
|
* toward, not enough to know what it costs.
|
||||||
|
*
|
||||||
|
* Nothing here reads live world state. Heat is what you last saw on a road;
|
||||||
|
* territory is who held it when you last stood there. Both go on drifting once
|
||||||
|
* you leave, and the map has no way of knowing — which is the point. Old notes
|
||||||
|
* are drawn faded, so you can see at a glance which of them you should not trust.
|
||||||
*/
|
*/
|
||||||
|
/** Seconds after which an observation is drawn as faded as it will ever get. */
|
||||||
|
const STALE_AFTER = 240;
|
||||||
|
const FRESH_ALPHA = 1;
|
||||||
|
const STALE_ALPHA = 0.35;
|
||||||
const SIZE = 190;
|
const SIZE = 190;
|
||||||
const PADDING = 12;
|
const PADDING = 12;
|
||||||
|
|
||||||
@ -36,14 +45,11 @@ export interface MinimapView {
|
|||||||
/** Car heading in radians, matching the world's yaw convention. */
|
/** Car heading in radians, matching the world's yaw convention. */
|
||||||
heading: number;
|
heading: number;
|
||||||
objective: { x: number; z: number } | null;
|
objective: { x: number; z: number } | null;
|
||||||
|
/** Elapsed time, for ageing observations. */
|
||||||
|
now: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMinimap(
|
export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: number) {
|
||||||
roads: RoadNetwork,
|
|
||||||
bases: Base[],
|
|
||||||
front: Front,
|
|
||||||
worldExtent: number,
|
|
||||||
) {
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.id = 'minimap';
|
canvas.id = 'minimap';
|
||||||
const scale = devicePixelRatio > 1 ? 2 : 1;
|
const scale = devicePixelRatio > 1 ? 2 : 1;
|
||||||
@ -69,41 +75,45 @@ export function createMinimap(
|
|||||||
draw(intel: Intel, view: MinimapView) {
|
draw(intel: Intel, view: MinimapView) {
|
||||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||||
|
|
||||||
// --- Explored ground, tinted by who holds it ---
|
// --- Explored ground, tinted by who held it when you were last there ---
|
||||||
const cell = (CELL_SIZE / span) * (SIZE - PADDING * 2);
|
const cell = (CELL_SIZE / span) * (SIZE - PADDING * 2);
|
||||||
for (let row = 0; row < intel.cellsPerSide; row++) {
|
for (let row = 0; row < intel.cellsPerSide; row++) {
|
||||||
for (let col = 0; col < intel.cellsPerSide; col++) {
|
for (let col = 0; col < intel.cellsPerSide; col++) {
|
||||||
if (intel.explored[row * intel.cellsPerSide + col] !== 1) continue;
|
const index = row * intel.cellsPerSide + col;
|
||||||
|
if (intel.explored[index] !== 1) continue;
|
||||||
const wx = intel.gridOrigin + (col + 0.5) * CELL_SIZE;
|
const wx = intel.gridOrigin + (col + 0.5) * CELL_SIZE;
|
||||||
const wz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
|
const wz = intel.gridOrigin + (row + 0.5) * CELL_SIZE;
|
||||||
ctx.fillStyle = CONTROL_COLOURS[controlAt(front, wx, wz)];
|
ctx.fillStyle = CONTROL_COLOURS[CONTROLS[intel.rememberedControl[index]!]!];
|
||||||
// +1 closes the hairline seams between neighbouring cells.
|
// +1 closes the hairline seams between neighbouring cells.
|
||||||
ctx.fillRect(px(wx) - cell / 2, px(wz) - cell / 2, cell + 1, cell + 1);
|
ctx.fillRect(px(wx) - cell / 2, px(wz) - cell / 2, cell + 1, cell + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Roads: rough everywhere, detailed where seen ---
|
// --- The border, drawn where remembered territory changes hands ---
|
||||||
|
drawRememberedBorder(ctx, intel, px);
|
||||||
|
|
||||||
|
// --- Roads: rough everywhere, detailed where seen, faded where stale ---
|
||||||
for (const s of roads.segments) {
|
for (const s of roads.segments) {
|
||||||
const seen = hasSeen(intel, s.id);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px(s.ax), px(s.az));
|
ctx.moveTo(px(s.ax), px(s.az));
|
||||||
ctx.lineTo(px(s.bx), px(s.bz));
|
ctx.lineTo(px(s.bx), px(s.bz));
|
||||||
if (seen) {
|
if (hasSeen(intel, s.id)) {
|
||||||
|
const age = Math.min(1, (view.now - intel.seenAt[s.id]!) / STALE_AFTER);
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
|
ctx.globalAlpha = FRESH_ALPHA + (STALE_ALPHA - FRESH_ALPHA) * age;
|
||||||
ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!];
|
ctx.strokeStyle = HEAT_COLOURS[intel.rememberedLevel[s.id]!];
|
||||||
} else {
|
} else {
|
||||||
// Known to exist, nothing known about it.
|
// Known to exist, nothing known about it.
|
||||||
ctx.setLineDash([2, 3]);
|
ctx.setLineDash([2, 3]);
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
ctx.strokeStyle = '#3c464f';
|
ctx.strokeStyle = '#3c464f';
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
// --- The front line, once you have seen ground on both sides of it ---
|
|
||||||
drawFront(ctx, front, intel, px, half);
|
|
||||||
|
|
||||||
// --- Bases ---
|
// --- Bases ---
|
||||||
for (const base of bases) {
|
for (const base of bases) {
|
||||||
@ -146,44 +156,41 @@ export function createMinimap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The front is drawn as a dashed line, but only across country the player has
|
* The border is not drawn from the front's true position — that would hand the
|
||||||
* explored. You know where the border is because you crossed it, not because
|
* player a live readout of a line that is supposed to move behind their back.
|
||||||
* you were handed a map of the war.
|
* Instead it is drawn between adjacent explored cells that the player remembers
|
||||||
|
* as belonging to different sides. You know where the border was because you
|
||||||
|
* crossed it; if it has since moved, your map is simply wrong.
|
||||||
*/
|
*/
|
||||||
function drawFront(
|
function drawRememberedBorder(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
front: Front,
|
|
||||||
intel: Intel,
|
intel: Intel,
|
||||||
px: (n: number) => number,
|
px: (n: number) => number,
|
||||||
half: number,
|
|
||||||
) {
|
) {
|
||||||
const along = { x: -front.axis.z, z: front.axis.x };
|
const size = intel.cellsPerSide;
|
||||||
ctx.setLineDash([3, 4]);
|
ctx.strokeStyle = '#98a3b1';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
for (const edge of Object.values(front.boundaries)) {
|
|
||||||
ctx.strokeStyle = '#8892a0';
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
let drawing = false;
|
|
||||||
for (let t = -half * 1.6; t <= half * 1.6; t += CELL_SIZE / 2) {
|
for (let row = 0; row < size; row++) {
|
||||||
const x = front.axis.x * edge + along.x * t;
|
for (let col = 0; col < size; col++) {
|
||||||
const z = front.axis.z * edge + along.z * t;
|
const index = row * size + col;
|
||||||
const index =
|
if (intel.explored[index] !== 1) continue;
|
||||||
Math.floor((z - intel.gridOrigin) / CELL_SIZE) * intel.cellsPerSide +
|
const control = intel.rememberedControl[index];
|
||||||
Math.floor((x - intel.gridOrigin) / CELL_SIZE);
|
const wx = intel.gridOrigin + col * CELL_SIZE;
|
||||||
const known = intel.explored[index] === 1;
|
const wz = intel.gridOrigin + row * CELL_SIZE;
|
||||||
if (!known) {
|
|
||||||
drawing = false;
|
const right = index + 1;
|
||||||
continue;
|
if (col + 1 < size && intel.explored[right] === 1 && intel.rememberedControl[right] !== control) {
|
||||||
|
ctx.moveTo(px(wx + CELL_SIZE), px(wz));
|
||||||
|
ctx.lineTo(px(wx + CELL_SIZE), px(wz + CELL_SIZE));
|
||||||
|
}
|
||||||
|
const below = index + size;
|
||||||
|
if (row + 1 < size && intel.explored[below] === 1 && intel.rememberedControl[below] !== control) {
|
||||||
|
ctx.moveTo(px(wx), px(wz + CELL_SIZE));
|
||||||
|
ctx.lineTo(px(wx + CELL_SIZE), px(wz + CELL_SIZE));
|
||||||
}
|
}
|
||||||
if (!drawing) {
|
|
||||||
ctx.moveTo(px(x), px(z));
|
|
||||||
drawing = true;
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(px(x), px(z));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user