Steering. The falloff with speed was linear, so the car had already lost a third of its lock by 20km/h — exactly the speed you take a right-angle junction at, on a map made of right-angle junctions. It is quadratic now, which leaves low speeds nearly untouched and still calms things down at pace, plus a faster rack and a little more lock. Pinned with a test that drives an actual quarter turn and measures its radius. A first attempt asserted that fast corners take longer, which is simply false: a fast car swings through ninety degrees quicker, just across far more tarmac. Radius is what a corner costs you. Contact. Units were points that ignored everything, so there was nothing to hit. Vehicles now carry kinematic bodies and ramming one is a real collision that damages them and shoves you. People get no collider on purpose — a capsule means snagging on pedestrians or launching them — so you drive through them and they go down, and running over a civilian is counted for later. Territory. Liberated ground covered more than half the map because the player started in the middle of it, so most of the world was safe by geometry. The starting base is now at the friendly edge and the bands are anchored to give roughly 15/25/27/33 across liberated, contested, occupied and frontier. Measured in the running game rather than guessed, since the axis runs diagonally across a square and area is not linear in depth. Also fixes a fragile test that compared reward rates across whichever targets a board happened to offer, and so mostly measured route length rather than the novelty premium it claimed to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
import * as THREE from 'three';
|
|
import { generateWorld } from './sim/world';
|
|
import { applyWear, deriveHandling, freshCondition, repair } from './sim/car';
|
|
import { createHeat, stepHeat } from './sim/heat';
|
|
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
|
|
import { baseAt, placeBases } from './sim/bases';
|
|
import { buildGraph } from './sim/routing';
|
|
import { createIntel, observe, reveal, survey } from './sim/intel';
|
|
import {
|
|
applyMissionImpact,
|
|
controlAt,
|
|
createFront,
|
|
stepFront,
|
|
DECAY_MULTIPLIER,
|
|
HEAT_MULTIPLIER,
|
|
} from './sim/regions';
|
|
import { createMinimap } from './ui/minimap';
|
|
import {
|
|
accept,
|
|
createQuests,
|
|
MISSION_SHAPE,
|
|
offersAt,
|
|
stepQuest,
|
|
type Offer,
|
|
} from './sim/quests';
|
|
import { createRadio, pollRadio } from './sim/radio';
|
|
import { createOpportunities, stepOpportunities } from './sim/opportunities';
|
|
import { createHeatProps } from './heatProps';
|
|
import { createUnits, dispatchTo, stepUnits } from './sim/units';
|
|
import { createCombat, dangerNear, stepCombat } from './sim/combat';
|
|
import { createUnitView } from './render/units';
|
|
import { createUnitBodies } from './unitBodies';
|
|
import { createPersistence } from './persistence';
|
|
import { apply, type Snapshot } from './sim/save';
|
|
import { makeRng, seedFromString } from './core/rng';
|
|
import { startLoop, type LoopHandlers } from './core/loop';
|
|
import { exposeDebug } from './debug';
|
|
import { createInput } from './core/input';
|
|
import { createPhysics } from './physics/physics';
|
|
import { createDriveState, drive } from './physics/drive';
|
|
import { createScene, updateCamera } from './render/scene';
|
|
import { createMarkers } from './render/markers';
|
|
import { createHud } from './ui/hud';
|
|
import { createBoard } from './ui/board';
|
|
import { createAudio } from './audio/audio';
|
|
import { CAR, WHEELS } from './carSpec';
|
|
|
|
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
|
export const DEBUG = new URLSearchParams(location.search).has('debug');
|
|
|
|
/** How far the driver can see well enough to fill in the map, metres. */
|
|
const SIGHT_RADIUS = 85;
|
|
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
|
|
const RESET_HOLD_SECONDS = 5;
|
|
|
|
function resolveSeed(): number {
|
|
const raw = new URLSearchParams(location.search).get('seed');
|
|
if (!raw) return 1337;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) ? n >>> 0 : seedFromString(raw);
|
|
}
|
|
|
|
async function boot() {
|
|
const seed = resolveSeed();
|
|
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, 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();
|
|
// Audio is built now but stays silent until the browser lets it speak, which
|
|
// is not until the player has actually touched something.
|
|
const audio = createAudio();
|
|
input.onGesture(() => audio.resume());
|
|
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, model.extent);
|
|
const quests = createQuests();
|
|
const radio = createRadio();
|
|
const units = createUnits();
|
|
const combat = createCombat();
|
|
const unitView = createUnitView(view.scene);
|
|
const unitBodies = createUnitBodies(physics);
|
|
|
|
// Bullets stop at buildings, so combat needs a fast "is this inside a wall"
|
|
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
|
const BUILDING_CELL = 24;
|
|
const buildingGrid = new Map<string, typeof model.obstacles>();
|
|
const cellKey = (x: number, z: number) =>
|
|
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`;
|
|
for (const o of model.obstacles) {
|
|
if (o.kind !== 'block') continue;
|
|
const reach = Math.hypot(o.width, o.depth) / 2;
|
|
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) {
|
|
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) {
|
|
const key = cellKey(x, z);
|
|
const list = buildingGrid.get(key) ?? [];
|
|
list.push(o);
|
|
buildingGrid.set(key, list);
|
|
}
|
|
}
|
|
}
|
|
const insideBuilding = (x: number, z: number): boolean => {
|
|
for (const o of buildingGrid.get(cellKey(x, z)) ?? []) {
|
|
const dx = x - o.x;
|
|
const dz = z - o.z;
|
|
const cos = Math.cos(-o.yaw);
|
|
const sin = Math.sin(-o.yaw);
|
|
if (Math.abs(dx * cos - dz * sin) < o.width / 2 && Math.abs(dx * sin + dz * cos) < o.depth / 2) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/** Physics bodies for finished checkpoint towers, so they are solid cover. */
|
|
const towerBodies = new Map<number, ReturnType<typeof physics.addStaticBox>>();
|
|
const opportunities = createOpportunities();
|
|
const chatterRng = makeRng(seed ^ 0x2c1b3c6d);
|
|
|
|
let condition = freshCondition();
|
|
let elapsed = 0;
|
|
let respawnLatch = false;
|
|
/** 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);
|
|
/** Who holds each road right now. Recomputed each step: the line moves. */
|
|
const segmentControl = model.roads.segments.map(() => control);
|
|
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. */
|
|
let openBase: number | null = null;
|
|
let offers: Offer[] = [];
|
|
let notice = '';
|
|
let noticeUntil = 0;
|
|
let lastMinimapDraw = -1;
|
|
let lastFrontPosition = front.boundaries.liberated;
|
|
/** Smoothed, because a single step's movement is far too noisy to react to. */
|
|
let driftAverage = 0;
|
|
/** Seconds R has been held, toward a full reset. */
|
|
let resetHeld = 0;
|
|
/** People you have killed who were not part of anyone's war. */
|
|
let civilianDeaths = 0;
|
|
|
|
const say = (text: string, seconds = 4) => {
|
|
notice = text;
|
|
noticeUntil = elapsed + seconds;
|
|
audio.radio();
|
|
};
|
|
|
|
const nodeOf = (id: number) => model.roads.nodes[id]!;
|
|
const controlOf = (x: number, z: number) => controlAt(front, x, z);
|
|
|
|
/** Car yaw about +Y, measured from world +Z, which is the car's forward. */
|
|
const headingOf = (): number => {
|
|
const r = physics.chassis.rotation();
|
|
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
|
|
};
|
|
|
|
// --- Persistence ---
|
|
const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length);
|
|
|
|
const snapshot = (): Snapshot => {
|
|
const t = physics.chassis.translation();
|
|
const r = physics.chassis.rotation();
|
|
const v = physics.chassis.linvel();
|
|
const w = physics.chassis.angvel();
|
|
return {
|
|
seed,
|
|
elapsed,
|
|
condition,
|
|
car: {
|
|
position: [t.x, t.y, t.z],
|
|
rotation: [r.x, r.y, r.z, r.w],
|
|
linvel: [v.x, v.y, v.z],
|
|
angvel: [w.x, w.y, w.z],
|
|
},
|
|
heat,
|
|
intel,
|
|
front,
|
|
quests,
|
|
};
|
|
};
|
|
|
|
const saved = persistence.load();
|
|
if (saved) {
|
|
const live = snapshot();
|
|
apply(saved, live);
|
|
// `apply` writes into the live objects for everything held by reference;
|
|
// the values that are not have to be copied back out by hand.
|
|
elapsed = live.elapsed;
|
|
condition = live.condition;
|
|
physics.chassis.setTranslation(
|
|
{ x: saved.car.position[0], y: saved.car.position[1], z: saved.car.position[2] },
|
|
true,
|
|
);
|
|
physics.chassis.setRotation(
|
|
{
|
|
x: saved.car.rotation[0],
|
|
y: saved.car.rotation[1],
|
|
z: saved.car.rotation[2],
|
|
w: saved.car.rotation[3],
|
|
},
|
|
true,
|
|
);
|
|
physics.chassis.setLinvel(
|
|
{ x: saved.car.linvel[0], y: saved.car.linvel[1], z: saved.car.linvel[2] },
|
|
true,
|
|
);
|
|
physics.chassis.setAngvel(
|
|
{ x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
|
|
true,
|
|
);
|
|
stepFront(front, 0);
|
|
refreshSegmentControl();
|
|
// A save does not record who was standing where, so barricades on roads
|
|
// that were already hot are treated as already built rather than making the
|
|
// player wait for a fresh engineer to be sent out.
|
|
for (const segment of model.roads.segments) {
|
|
if (heat.level[segment.id] === 'barricade' || heat.level[segment.id] === 'turret') {
|
|
heatProps.finished(segment.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Chassis transform at the end of the last two fixed steps, for interpolation.
|
|
const previous = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() };
|
|
const current = { position: new THREE.Vector3(), rotation: new THREE.Quaternion() };
|
|
const captureChassis = () => {
|
|
previous.position.copy(current.position);
|
|
previous.rotation.copy(current.rotation);
|
|
const t = physics.chassis.translation();
|
|
const q = physics.chassis.rotation();
|
|
current.position.set(t.x, t.y, t.z);
|
|
current.rotation.set(q.x, q.y, q.z, q.w);
|
|
};
|
|
// Prime both, so the first frame does not interpolate out of the origin.
|
|
captureChassis();
|
|
captureChassis();
|
|
|
|
document.getElementById('boot')?.remove();
|
|
|
|
const handlers: LoopHandlers = {
|
|
fixedUpdate(dt) {
|
|
elapsed += dt;
|
|
const cmd = input.read();
|
|
|
|
// Tap R to get unstuck; hold it to wipe the campaign and start over.
|
|
// A reset has to be hard to do by accident — it throws away every mile
|
|
// of wear, every road the enemy has learned, and the whole map you built.
|
|
if (cmd.respawn) {
|
|
resetHeld += dt;
|
|
if (resetHeld >= RESET_HOLD_SECONDS) {
|
|
persistence.clear();
|
|
location.reload();
|
|
return;
|
|
}
|
|
} else {
|
|
resetHeld = 0;
|
|
}
|
|
if (cmd.respawn && !respawnLatch) physics.respawn();
|
|
respawnLatch = cmd.respawn;
|
|
|
|
drive(physics, driveState, cmd, deriveHandling(condition), dt);
|
|
physics.step(dt);
|
|
captureChassis();
|
|
|
|
// Wear is applied from what actually happened this step, not from intent.
|
|
const speed = physics.vehicle.currentVehicleSpeed();
|
|
const distance = Math.abs(speed) * dt;
|
|
// Read once: both the car's wear and any cargo aboard are damaged by the
|
|
// same knock, and draining the queue twice would lose one of them.
|
|
if (cmd.toggleMute) say(audio.toggleMute() ? 'Sound off.' : 'Sound on.', 2);
|
|
|
|
const lastImpact = physics.drainImpactForce();
|
|
audio.impact(lastImpact);
|
|
condition = applyWear(condition, {
|
|
dt,
|
|
distance,
|
|
throttle: Math.abs(cmd.throttle),
|
|
impactForce: lastImpact,
|
|
});
|
|
|
|
// Heat: the road remembers being used — including being driven alongside.
|
|
// 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();
|
|
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;
|
|
const heatChanged = stepHeat(heat, {
|
|
dt,
|
|
segmentId: currentSegment,
|
|
distance,
|
|
// Who holds the ground, times how conspicuous this class of road is.
|
|
// A farm track behind the lines costs nothing; a trunk past the front
|
|
// is the most watched thing you can drive on.
|
|
accrual:
|
|
HEAT_MULTIPLIER[control] *
|
|
(currentSegment === null
|
|
? 1
|
|
: ROAD_ATTENTION[model.roads.segments[currentSegment]!.cls]),
|
|
// 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]!],
|
|
});
|
|
heatProps.sync({ x: at.x, z: at.z });
|
|
|
|
// Escalation is entirely something the enemy has to *do*. Every level
|
|
// sends somebody: a patrol to work the road, an engineer to pour the
|
|
// concrete, another to put the tower up. Nothing appears on its own.
|
|
for (const id of heatChanged) {
|
|
const segment = model.roads.segments[id]!;
|
|
const level = heat.level[id]!;
|
|
if (level === 'patrol') {
|
|
dispatchTo(units, model.roads, graph, segment, 'patrol', chatterRng);
|
|
}
|
|
if (level === 'barricade') {
|
|
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'barricade');
|
|
}
|
|
if (level === 'turret') {
|
|
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'tower');
|
|
}
|
|
// Cooled back down: whatever is standing there stops being maintained.
|
|
if (level === 'clear' || level === 'patrol') heatProps.abandon(id);
|
|
}
|
|
// 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, controlOf);
|
|
|
|
const base = baseAt(bases, at.x, at.z);
|
|
const stopped = Math.abs(speed) < 2.5;
|
|
|
|
// --- Quest board: only when parked at a base with nothing in hand ---
|
|
if (base && stopped && !quests.active) {
|
|
if (openBase !== base.nodeId) {
|
|
openBase = base.nodeId;
|
|
// Seeded by base and runs completed, not by time: leaving and coming
|
|
// straight back must not reroll a board you did not like.
|
|
offers = offersAt(
|
|
quests,
|
|
model.roads,
|
|
graph,
|
|
intel,
|
|
base,
|
|
elapsed,
|
|
seed ^ (base.nodeId * 31) ^ (quests.completed * 7919),
|
|
);
|
|
board.show(base, offers);
|
|
audio.ui('open');
|
|
}
|
|
const chosen = cmd.select === null ? undefined : offers[cmd.select - 1];
|
|
if (chosen) {
|
|
accept(quests, chosen, base);
|
|
board.hide();
|
|
openBase = null;
|
|
audio.ui('accept');
|
|
say(`${MISSION_SHAPE[chosen.type].label} accepted.`);
|
|
// Checkpoint: taking a job is a decision worth not having to make twice.
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
} else if (openBase !== null) {
|
|
openBase = null;
|
|
board.hide();
|
|
}
|
|
|
|
// --- Active mission ---
|
|
const quest = quests.active;
|
|
if (quest) {
|
|
const target = nodeOf(quest.targetNode);
|
|
const event = stepQuest(quests, {
|
|
dt,
|
|
distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z),
|
|
atBase: base,
|
|
speed: Math.abs(speed),
|
|
impactForce: lastImpact,
|
|
});
|
|
|
|
if (event?.kind === 'arrived') {
|
|
intel.visitedNodes.add(quest.targetNode);
|
|
say(`At the target. Stop the car — ${MISSION_SHAPE[quest.type].working}.`);
|
|
} else if (event?.kind === 'cargoDamaged') {
|
|
if (event.cargo < 0.5) say('That was the cargo taking the hit, not you.', 3);
|
|
} else if (event?.kind === 'worked') {
|
|
if (event.type === 'recon') {
|
|
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.`);
|
|
} else if (event.type === 'retrieval') {
|
|
say('Loaded. Get it back in one piece — damaged goods pay less.');
|
|
} else if (event.type === 'intel') {
|
|
say('Transmission sent. Move before anyone triangulates it.');
|
|
} else {
|
|
say('Cargo delivered. Report back to any base.');
|
|
}
|
|
} 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 spent = quests.parts - outcome.unused;
|
|
condition = outcome.condition;
|
|
quests.parts = outcome.unused;
|
|
say(
|
|
spent > 0.001
|
|
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
|
|
: 'Handed in. Nothing left worth repairing.',
|
|
);
|
|
// Checkpoint: a finished run is the most expensive thing to lose.
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
}
|
|
|
|
// --- Work found rather than given ---
|
|
const found = stepOpportunities(
|
|
opportunities,
|
|
{
|
|
dt,
|
|
now: elapsed,
|
|
control,
|
|
carX: at.x,
|
|
carZ: at.z,
|
|
onMission: quests.active !== null,
|
|
},
|
|
model.roads,
|
|
chatterRng,
|
|
stopped,
|
|
);
|
|
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
|
|
else if (found?.kind === 'helped') {
|
|
quests.parts += found.opportunity.reward;
|
|
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
|
|
say(found.opportunity.done, 7);
|
|
persistence.checkpoint(elapsed, snapshot());
|
|
}
|
|
|
|
// --- The inhabited world ---
|
|
const unitEvents = stepUnits(
|
|
units,
|
|
{
|
|
dt,
|
|
now: elapsed,
|
|
player: { x: at.x, z: at.z },
|
|
front,
|
|
heatLevel: (id) => heat.level[id]!,
|
|
// A patrol working its road is what actually brings the heat down.
|
|
decayHeat: (id, amount) => {
|
|
heat.value[id] = Math.max(0, heat.value[id]! - amount);
|
|
},
|
|
},
|
|
model.roads,
|
|
graph,
|
|
chatterRng,
|
|
);
|
|
|
|
// Whatever an engineer just finished now exists in the world.
|
|
for (const { segment, stage } of unitEvents.built) {
|
|
if (stage === 'barricade') {
|
|
heatProps.finished(segment);
|
|
say('They have put concrete across that road.', 6);
|
|
continue;
|
|
}
|
|
const site = units.checkpoints.get(segment);
|
|
if (!site || towerBodies.has(segment)) continue;
|
|
towerBodies.set(
|
|
segment,
|
|
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }),
|
|
);
|
|
say('They have finished the tower on that road.', 6);
|
|
}
|
|
for (const id of unitEvents.removed) {
|
|
heatProps.abandon(id);
|
|
const body = towerBodies.get(id);
|
|
if (!body) continue;
|
|
physics.removeBody(body);
|
|
towerBodies.delete(id);
|
|
}
|
|
|
|
// --- Hitting things ---
|
|
// Vehicles are solid; people are not, so you drive through them rather
|
|
// than getting hung up on them.
|
|
const contact = unitBodies.sync(units, { x: at.x, z: at.z }, speed, dt);
|
|
for (const victim of contact.ranOver) {
|
|
audio.impact(26000);
|
|
civilianDeaths += victim.faction === 'civilian' ? 1 : 0;
|
|
say(
|
|
victim.faction === 'civilian'
|
|
? 'You just put someone under the wheels.'
|
|
: 'One of theirs, under the wheels.',
|
|
4,
|
|
);
|
|
}
|
|
if (contact.rammed.length > 0 && elapsed > noticeUntil) {
|
|
say('Metal on metal. Somebody noticed that.', 3);
|
|
}
|
|
|
|
// --- Shooting ---
|
|
// The enemy shoots at the player only once a road has been noticed enough
|
|
// to be checkpointed, and only past the line. Undercover means undercover.
|
|
const exposed =
|
|
(control === 'occupied' || control === 'frontier') &&
|
|
currentSegment !== null &&
|
|
(heat.level[currentSegment] === 'barricade' || heat.level[currentSegment] === 'turret');
|
|
|
|
const shooting = stepCombat(
|
|
combat,
|
|
units,
|
|
{
|
|
dt,
|
|
player: { x: at.x, z: at.z },
|
|
playerExposed: exposed,
|
|
blocked: insideBuilding,
|
|
},
|
|
condition,
|
|
chatterRng,
|
|
);
|
|
condition = shooting.condition;
|
|
const listener = { x: at.x, z: at.z, heading: headingOf() };
|
|
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
|
|
if (shooting.playerHit) {
|
|
audio.hit();
|
|
if (elapsed > noticeUntil) say('Taking fire.', 2.5);
|
|
}
|
|
|
|
// --- Radio ---
|
|
// Front drift is measured, not narrated: the chatter about the enemy
|
|
// being busy elsewhere fires because the line really is moving.
|
|
const drift = (front.boundaries.liberated - lastFrontPosition) / dt;
|
|
lastFrontPosition = front.boundaries.liberated;
|
|
driftAverage += (drift - driftAverage) * Math.min(1, dt / 3);
|
|
if (!notice || elapsed > noticeUntil) {
|
|
const line = pollRadio(
|
|
radio,
|
|
{
|
|
now: elapsed,
|
|
control,
|
|
roadClass: currentSegment === null ? null : model.roads.segments[currentSegment]!.cls,
|
|
heatLevel: currentSegment === null ? null : heat.level[currentSegment]!,
|
|
condition,
|
|
frontDrift: driftAverage,
|
|
cargo: quests.active?.cargo ?? null,
|
|
carrying: quests.active?.stage === 'return',
|
|
},
|
|
chatterRng,
|
|
);
|
|
if (line) say(line, 8);
|
|
}
|
|
|
|
// Continuous audio follows the sim, not the frame rate, so it is driven
|
|
// from the fixed step like everything else that has to stay consistent.
|
|
const wheels = physics.telemetry();
|
|
audio.update({
|
|
speed,
|
|
throttle: cmd.throttle,
|
|
condition,
|
|
sideSlip: wheels.sideSlip,
|
|
wheelsOnGround: wheels.wheelsOnGround,
|
|
control,
|
|
listener: { x: at.x, z: at.z, heading: headingOf() },
|
|
});
|
|
|
|
persistence.tick(elapsed, snapshot);
|
|
|
|
// 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) {
|
|
// Interpolate between the last two physics steps.
|
|
//
|
|
// The display almost never lines up with a 60Hz fixed step, so some frames
|
|
// consume two steps and some none. Drawing the raw latest transform makes
|
|
// that land as visible stutter at speed even when the framerate is fine.
|
|
// Blending by the leftover accumulator hides it.
|
|
view.car.position.lerpVectors(previous.position, current.position, alpha);
|
|
view.car.quaternion.slerpQuaternions(previous.rotation, current.rotation, alpha);
|
|
const p = view.car.position;
|
|
const r = view.car.quaternion;
|
|
|
|
for (let i = 0; i < WHEELS.length; i++) {
|
|
const pivot = view.wheels[i]!;
|
|
const suspension =
|
|
physics.vehicle.wheelSuspensionLength(i) ?? CAR.wheel.suspensionRestLength;
|
|
pivot.position.y = CAR.wheel.offsetY - suspension;
|
|
pivot.rotation.y = physics.vehicle.wheelSteering(i) ?? 0;
|
|
pivot.children[0]!.rotation.x = physics.vehicle.wheelRotation(i) ?? 0;
|
|
}
|
|
|
|
// Static blocks are instanced and never move; only crates need syncing.
|
|
for (const { index, mesh } of view.crates) {
|
|
const body = physics.obstacleBodies[index]!;
|
|
const t = body.translation();
|
|
const q = body.rotation();
|
|
mesh.position.set(t.x, t.y, t.z);
|
|
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
|
}
|
|
|
|
unitView.update(units, combat.rounds, elapsed);
|
|
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;
|
|
|
|
// The map is a sketch that changes slowly; redrawing every cell of it at
|
|
// display rate is wasted work in the same frame budget as the shadow pass.
|
|
if (elapsed - lastMinimapDraw > 0.1) {
|
|
lastMinimapDraw = elapsed;
|
|
minimap.draw(intel, {
|
|
now: elapsed,
|
|
x: p.x,
|
|
z: p.z,
|
|
// Yaw about +Y, 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: quest && quest.stage !== 'return' ? target : null,
|
|
opportunity: opportunities.current,
|
|
});
|
|
}
|
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
|
heat: {
|
|
segmentId: currentSegment,
|
|
onTarmac,
|
|
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
|
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
|
},
|
|
quest:
|
|
quest && target
|
|
? {
|
|
type: quest.type,
|
|
stage: quest.stage,
|
|
novel: quest.novel,
|
|
worked: quest.worked,
|
|
cargo: quest.cargo,
|
|
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,
|
|
danger: dangerNear(combat, p.x, p.z, 90),
|
|
completed: quests.completed,
|
|
parts: quests.parts,
|
|
notice: elapsed < noticeUntil ? notice : '',
|
|
resetProgress: resetHeld / RESET_HOLD_SECONDS,
|
|
muted: audio.muted,
|
|
});
|
|
},
|
|
};
|
|
|
|
startLoop(handlers);
|
|
|
|
if (DEBUG) {
|
|
exposeDebug({
|
|
handlers,
|
|
view,
|
|
physics,
|
|
// Live state, so a script can find something interesting and go look at it.
|
|
state: { units, combat, heat, intel, quests, front, model, bases },
|
|
teleport(x, z) {
|
|
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
|
|
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
|
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
|
},
|
|
info: () => ({
|
|
elapsed,
|
|
control,
|
|
segment: currentSegment,
|
|
onTarmac,
|
|
parts: quests.parts,
|
|
completed: quests.completed,
|
|
condition,
|
|
front: front.boundaries,
|
|
opportunity: opportunities.current,
|
|
civilianDeaths,
|
|
unitBodies: unitBodies.count,
|
|
units: units.units.length,
|
|
roles: units.units.reduce<Record<string, number>>((counts, u) => {
|
|
counts[u.role] = (counts[u.role] ?? 0) + 1;
|
|
return counts;
|
|
}, {}),
|
|
rounds: combat.rounds.length,
|
|
checkpoints: units.checkpoints.size,
|
|
notice,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
boot().catch((err) => {
|
|
console.error(err);
|
|
const boot = document.getElementById('boot');
|
|
if (boot) boot.textContent = `failed to start: ${err}`;
|
|
});
|