Phase 6: an inhabited world, and a war to drive through
Nothing on a road simply appears any more. A road that turns notorious gets a patrol dispatched to it: it starts at a junction a few hundred metres away, drives there, sweeps the road for about a minute, and while it works the road it brings the heat down — then leaves. Checkpoints are built rather than placed. At turret level an engineer is sent, the tower rises over forty seconds of someone standing on site, and a gunner mans it once it stands, up on top so he clears his own barricade. Let the road cool and the whole thing is abandoned. heat.ts now only pours concrete; everything living has to arrive. Civilian traffic routes across the network with somewhere to be, people wander on foot, and both scatter when shooting starts. Skirmishes break out between the two armies on contested and occupied ground regardless of where the player is. Rounds are real objects in flight rather than instant hits between combatants — that is the point of the feature. If shots resolved instantly, driving through a battle would cost nothing and "the only road to the target runs through a firefight" would not be a decision. Strays hit bystanders, civilians and the player alike; buildings stop them, so cover means something; hits go through the same wear model as a crash, so being shot costs ceiling too. The enemy does not shoot at an undercover driver by default. They open up only past the line and only on a road they have already checkpointed. Reload was being counted in stepUnits while firing lived in stepCombat, so combat stepped on its own never reloaded. A test caught it; ownership now sits with the module that fires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Round } from '../sim/combat';
|
||||
import type { Unit, UnitState } from '../sim/units';
|
||||
|
||||
/**
|
||||
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
|
||||
*
|
||||
* Everything here is instanced. Unit counts swing about constantly as things
|
||||
* spawn, fight and are cleaned up, and a mesh per unit would mean churning the
|
||||
* scene graph every frame — so each category gets one draw call with a cap, and
|
||||
* unused instances are parked out of sight rather than removed.
|
||||
*/
|
||||
const MAX_UNITS = 160;
|
||||
const MAX_ROUNDS = 400;
|
||||
/** Somewhere off the map to park instances that are not in use this frame. */
|
||||
const HIDDEN = new THREE.Vector3(0, -5000, 0);
|
||||
|
||||
/**
|
||||
* Deliberately high-contrast. At a driver's eye height a person is a few pixels
|
||||
* of a dark capsule against dark ground — the first pass was effectively
|
||||
* invisible, which defeats the point of putting people in the world at all.
|
||||
*/
|
||||
const COLOURS = {
|
||||
civilianCar: 0x9aa7b4,
|
||||
civilianPerson: 0xd8c9a4,
|
||||
enemy: 0xb0704e,
|
||||
enemySoldier: 0xd08a4a,
|
||||
insurgent: 0x76c98a,
|
||||
} as const;
|
||||
|
||||
function colourOf(unit: Unit): number {
|
||||
if (unit.faction === 'civilian') {
|
||||
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
|
||||
}
|
||||
if (unit.faction === 'insurgent') return COLOURS.insurgent;
|
||||
return unit.kind === 'car' ? COLOURS.enemy : COLOURS.enemySoldier;
|
||||
}
|
||||
|
||||
export function createUnitView(scene: THREE.Scene) {
|
||||
const scratch = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3();
|
||||
const colour = new THREE.Color();
|
||||
|
||||
const cars = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(1.8, 1.4, 4),
|
||||
new THREE.MeshStandardMaterial({ roughness: 0.6 }),
|
||||
MAX_UNITS,
|
||||
);
|
||||
cars.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
|
||||
cars.castShadow = true;
|
||||
|
||||
const people = new THREE.InstancedMesh(
|
||||
// Slightly larger than life, for the same reason the colours are loud.
|
||||
new THREE.CapsuleGeometry(0.45, 1.4, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ roughness: 0.8 }),
|
||||
MAX_UNITS,
|
||||
);
|
||||
people.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
|
||||
people.castShadow = true;
|
||||
|
||||
// Tracers: thin stretched boxes along the round's direction of travel.
|
||||
const tracers = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(0.12, 0.12, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffd28a }),
|
||||
MAX_ROUNDS,
|
||||
);
|
||||
|
||||
// Towers, which only exist once someone has built them.
|
||||
const towers = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(3, 6, 3),
|
||||
new THREE.MeshStandardMaterial({ color: 0x54493d, roughness: 0.9 }),
|
||||
32,
|
||||
);
|
||||
towers.castShadow = true;
|
||||
// The gun on top, so a finished checkpoint reads as armed at a glance.
|
||||
const guns = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(0.35, 0.35, 2.6),
|
||||
new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 0.5 }),
|
||||
32,
|
||||
);
|
||||
|
||||
for (const mesh of [cars, people, tracers, towers, guns]) {
|
||||
mesh.frustumCulled = false;
|
||||
scene.add(mesh);
|
||||
}
|
||||
|
||||
const park = (mesh: THREE.InstancedMesh, from: number) => {
|
||||
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
|
||||
for (let i = from; i < mesh.count; i++) mesh.setMatrixAt(i, scratch);
|
||||
};
|
||||
|
||||
return {
|
||||
update(units: UnitState, rounds: Round[], elapsed: number) {
|
||||
let carCount = 0;
|
||||
let personCount = 0;
|
||||
|
||||
for (const unit of units.units) {
|
||||
const mesh = unit.kind === 'car' ? cars : people;
|
||||
const index = unit.kind === 'car' ? carCount++ : personCount++;
|
||||
if (index >= MAX_UNITS) continue;
|
||||
|
||||
position.set(unit.x, unit.kind === 'car' ? 0.7 : 0.9, unit.z);
|
||||
// Garrison gunners stand on their tower rather than beside it.
|
||||
if (unit.role === 'garrison') position.y = unit.elevation;
|
||||
quaternion.setFromAxisAngle(up, unit.heading);
|
||||
scratch.compose(position, quaternion, scale.set(1, 1, 1));
|
||||
mesh.setMatrixAt(index, scratch);
|
||||
mesh.setColorAt(index, colour.setHex(colourOf(unit)));
|
||||
}
|
||||
|
||||
park(cars, carCount);
|
||||
park(people, personCount);
|
||||
cars.instanceMatrix.needsUpdate = true;
|
||||
people.instanceMatrix.needsUpdate = true;
|
||||
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
|
||||
if (people.instanceColor) people.instanceColor.needsUpdate = true;
|
||||
|
||||
// --- Checkpoints under construction, and finished ones ---
|
||||
let towerCount = 0;
|
||||
for (const site of units.checkpoints.values()) {
|
||||
if (towerCount >= towers.count) break;
|
||||
const built = site.built;
|
||||
const height = built ? 1 : Math.max(0.15, site.progress);
|
||||
position.set(site.x, (6 * height) / 2, site.z);
|
||||
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1));
|
||||
towers.setMatrixAt(towerCount, scratch);
|
||||
|
||||
// The gun only exists on a finished tower.
|
||||
if (built) {
|
||||
position.set(site.x, 6.2, site.z);
|
||||
quaternion.setFromAxisAngle(up, elapsed * 0.25);
|
||||
scratch.compose(position, quaternion, scale.set(1, 1, 1));
|
||||
} else {
|
||||
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
|
||||
}
|
||||
guns.setMatrixAt(towerCount, scratch);
|
||||
towerCount++;
|
||||
}
|
||||
park(towers, towerCount);
|
||||
park(guns, towerCount);
|
||||
towers.instanceMatrix.needsUpdate = true;
|
||||
guns.instanceMatrix.needsUpdate = true;
|
||||
|
||||
// --- Tracers ---
|
||||
let roundCount = 0;
|
||||
for (const round of rounds) {
|
||||
if (roundCount >= MAX_ROUNDS) break;
|
||||
const speed = Math.hypot(round.vx, round.vz);
|
||||
position.set(round.x, round.y, round.z);
|
||||
quaternion.setFromAxisAngle(up, Math.atan2(round.vx, round.vz));
|
||||
// Length scales with speed so a tracer reads as motion, not a stick.
|
||||
scratch.compose(position, quaternion, scale.set(1, 1, Math.max(2, speed * 0.03)));
|
||||
tracers.setMatrixAt(roundCount++, scratch);
|
||||
}
|
||||
park(tracers, roundCount);
|
||||
tracers.instanceMatrix.needsUpdate = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user