diff --git a/src/main.ts b/src/main.ts index 2b7dc20..23b4ec6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/render/units.ts b/src/render/units.ts index d1b2520..0650dfb 100644 --- a/src/render/units.ts +++ b/src/render/units.ts @@ -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; diff --git a/src/sim/units.ts b/src/sim/units.ts index b3a4868..c4bbe18 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -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; }