Put a man in the enemy cars, and let him raise his weapon

A patrol car was an empty box that occasionally emitted tracers.

It now carries a visible crewman, standing up out of the roof so he
clears the roofline at any useful distance. He turns toward whatever he
is watching, and the weapon comes up only once they have decided about
you: across his lap and pointing at the floor otherwise, levelled and
out in front when the car is hunting or when there is something of the
other army in range.

That difference is the point of the whole thing. Being recognised was a
meter filling up in the corner of the screen; it is now something you
can see happen across a street. The gun is bigger and lighter than it
ought to be for the same reason the people are - at a driver's eye
height a rifle is three pixels against a dark road - and the pose
change is exaggerated, because a subtle change of angle is a detail
rather than a signal.

Rounds now leave the man rather than the bonnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 08:18:45 +02:00
parent baa7bba95f
commit 7004f2bd66
3 changed files with 107 additions and 10 deletions

View File

@ -945,7 +945,7 @@ async function boot() {
mesh.quaternion.set(q.x, q.y, q.z, q.w);
}
unitView.update(units, combat.rounds, elapsed);
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
view.followSun();
view.setTone(control, frameDt);
markers.update(elapsed);

View File

@ -1,6 +1,6 @@
import * as THREE from 'three';
import type { Round } from '../sim/combat';
import type { Unit, UnitState } from '../sim/units';
import { nearestHostile, type Unit, type UnitState } from '../sim/units';
/**
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
@ -28,6 +28,23 @@ const COLOURS = {
insurgent: 0x76c98a,
} as const;
/** How far a crewman can pick you out, for deciding whether to raise the gun. */
const CREW_SIGHT = 110;
/**
* Where the man sits: standing up out of the car, not sitting in it. He has to
* clear the roofline or he is a lump on a box at any useful distance.
*/
const CREW_HEIGHT = 1.75;
/**
* Where the weapon sits when it is up, and when it is across his lap.
*
* The difference between the two has to be readable from across the street,
* because that difference *is* the tell that they have decided about you. A
* subtle change of angle is not a signal, it is a detail.
*/
const GUN_READY = { height: 1.8, reach: 1.5, pitch: 0 };
const GUN_STOWED = { height: 1.15, reach: 0.4, pitch: 1.15 };
function colourOf(unit: Unit): number {
if (unit.faction === 'civilian') {
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
@ -43,6 +60,9 @@ export function createUnitView(scene: THREE.Scene) {
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const colour = new THREE.Color();
const euler = new THREE.Euler();
const distanceTo = (a: { x: number; z: number }, b: { x: number; z: number }) =>
Math.hypot(a.x - b.x, a.z - b.z);
const cars = new THREE.InstancedMesh(
new THREE.BoxGeometry(1.8, 1.4, 4),
@ -68,6 +88,32 @@ export function createUnitView(scene: THREE.Scene) {
MAX_ROUNDS,
);
/**
* The man riding in an armed vehicle, and what he is holding.
*
* A patrol car used to be an empty box that occasionally emitted tracers.
* Putting a visible crewman in it does two jobs: it says at a glance that the
* car is a threat rather than traffic, and because the weapon comes up only
* when they have decided about you it makes the moment you are recognised
* something you can *see* happening across the street, rather than a meter
* filling up in the corner of the screen.
*/
const crew = new THREE.InstancedMesh(
new THREE.CapsuleGeometry(0.32, 0.7, 4, 8),
new THREE.MeshStandardMaterial({ roughness: 0.8 }),
MAX_UNITS,
);
crew.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
crew.castShadow = true;
const guns = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.16, 0.16, 1.6),
// Lighter than it ought to be, for the same reason the people are: at a
// driver's eye height a rifle is three pixels against a dark street.
new THREE.MeshStandardMaterial({ color: 0x4a5158, roughness: 0.5 }),
MAX_UNITS,
);
// Towers, which only exist once someone has built them.
const towers = new THREE.InstancedMesh(
new THREE.BoxGeometry(3, 6, 3),
@ -76,13 +122,13 @@ export function createUnitView(scene: THREE.Scene) {
);
towers.castShadow = true;
// The gun on top, so a finished checkpoint reads as armed at a glance.
const guns = new THREE.InstancedMesh(
const towerGuns = 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]) {
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
mesh.frustumCulled = false;
scene.add(mesh);
}
@ -93,9 +139,10 @@ export function createUnitView(scene: THREE.Scene) {
};
return {
update(units: UnitState, rounds: Round[], elapsed: number) {
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
let carCount = 0;
let personCount = 0;
let crewCount = 0;
for (const unit of units.units) {
const mesh = unit.kind === 'car' ? cars : people;
@ -109,14 +156,61 @@ export function createUnitView(scene: THREE.Scene) {
scratch.compose(position, quaternion, scale.set(1, 1, 1));
mesh.setMatrixAt(index, scratch);
mesh.setColorAt(index, colour.setHex(colourOf(unit)));
// --- Whoever is riding in it ---
if (unit.kind !== 'car' || unit.faction === 'civilian') continue;
if (crewCount >= MAX_UNITS) continue;
// What he is watching. Hunting means the car itself; otherwise whatever
// of the other army is close enough to be worth pointing a gun at.
const hostile = nearestHostile(units, unit, CREW_SIGHT);
const watching = unit.hunting
? player
: hostile && distanceTo(unit, hostile) < CREW_SIGHT
? hostile
: null;
const facing = watching
? Math.atan2(watching.x - unit.x, watching.z - unit.z)
: unit.heading;
const gun = watching ? GUN_READY : GUN_STOWED;
// The man, turned toward whatever he is watching.
position.set(unit.x, CREW_HEIGHT, unit.z);
quaternion.setFromAxisAngle(up, facing);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
crew.setMatrixAt(crewCount, scratch);
crew.setColorAt(
crewCount,
colour.setHex(
unit.faction === 'insurgent' ? COLOURS.insurgent : COLOURS.enemySoldier,
),
);
// The weapon: levelled and out in front when he has decided about you,
// across his lap and pointing at the floor when he has not.
position.set(
unit.x + Math.sin(facing) * gun.reach * 0.5,
gun.height,
unit.z + Math.cos(facing) * gun.reach * 0.5,
);
euler.set(gun.pitch, facing, 0, 'YXZ');
quaternion.setFromEuler(euler);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
guns.setMatrixAt(crewCount, scratch);
crewCount++;
}
park(cars, carCount);
park(people, personCount);
park(crew, crewCount);
park(guns, crewCount);
cars.instanceMatrix.needsUpdate = true;
people.instanceMatrix.needsUpdate = true;
crew.instanceMatrix.needsUpdate = true;
guns.instanceMatrix.needsUpdate = true;
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
if (people.instanceColor) people.instanceColor.needsUpdate = true;
if (crew.instanceColor) crew.instanceColor.needsUpdate = true;
// --- Checkpoints under construction, and finished ones ---
let towerCount = 0;
@ -139,13 +233,13 @@ export function createUnitView(scene: THREE.Scene) {
} else {
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
}
guns.setMatrixAt(towerCount, scratch);
towerGuns.setMatrixAt(towerCount, scratch);
towerCount++;
}
park(towers, towerCount);
park(guns, towerCount);
park(towerGuns, towerCount);
towers.instanceMatrix.needsUpdate = true;
guns.instanceMatrix.needsUpdate = true;
towerGuns.instanceMatrix.needsUpdate = true;
// --- Tracers ---
let roundCount = 0;

View File

@ -319,6 +319,8 @@ const FOLLOW_RANGE = 26;
const FOLLOW_WIDTH = 2.6;
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
const CAR_SEPARATION = 4.5;
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
export const CREW_ELEVATION = 1.5;
/** Turns `from` toward `to`, by at most `limit`. Both radians. */
function turnToward(from: number, to: number, limit: number): number {
@ -558,7 +560,8 @@ export function dispatchTo(
assigned: segment.id,
onStation: 0,
cooldown: 1,
elevation: 1,
// Shots leave the man in the passenger seat, not the bonnet.
elevation: CREW_ELEVATION,
building: role === 'engineer' ? stage : undefined,
});
}
@ -605,7 +608,7 @@ function spawnAmbientPatrol(
// Start part-way through a sweep, so they are not all in step.
onStation: rng() * 20,
cooldown: 1,
elevation: 1,
elevation: CREW_ELEVATION,
});
return true;
}