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:
parent
baa7bba95f
commit
7004f2bd66
@ -945,7 +945,7 @@ async function boot() {
|
|||||||
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
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.followSun();
|
||||||
view.setTone(control, frameDt);
|
view.setTone(control, frameDt);
|
||||||
markers.update(elapsed);
|
markers.update(elapsed);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { Round } from '../sim/combat';
|
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.
|
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
|
||||||
@ -28,6 +28,23 @@ const COLOURS = {
|
|||||||
insurgent: 0x76c98a,
|
insurgent: 0x76c98a,
|
||||||
} as const;
|
} 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 {
|
function colourOf(unit: Unit): number {
|
||||||
if (unit.faction === 'civilian') {
|
if (unit.faction === 'civilian') {
|
||||||
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
|
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
|
||||||
@ -43,6 +60,9 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
const position = new THREE.Vector3();
|
const position = new THREE.Vector3();
|
||||||
const scale = new THREE.Vector3();
|
const scale = new THREE.Vector3();
|
||||||
const colour = new THREE.Color();
|
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(
|
const cars = new THREE.InstancedMesh(
|
||||||
new THREE.BoxGeometry(1.8, 1.4, 4),
|
new THREE.BoxGeometry(1.8, 1.4, 4),
|
||||||
@ -68,6 +88,32 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
MAX_ROUNDS,
|
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.
|
// Towers, which only exist once someone has built them.
|
||||||
const towers = new THREE.InstancedMesh(
|
const towers = new THREE.InstancedMesh(
|
||||||
new THREE.BoxGeometry(3, 6, 3),
|
new THREE.BoxGeometry(3, 6, 3),
|
||||||
@ -76,13 +122,13 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
);
|
);
|
||||||
towers.castShadow = true;
|
towers.castShadow = true;
|
||||||
// The gun on top, so a finished checkpoint reads as armed at a glance.
|
// 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.BoxGeometry(0.35, 0.35, 2.6),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 0.5 }),
|
new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 0.5 }),
|
||||||
32,
|
32,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const mesh of [cars, people, tracers, towers, guns]) {
|
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
|
||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
}
|
}
|
||||||
@ -93,9 +139,10 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(units: UnitState, rounds: Round[], elapsed: number) {
|
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
|
||||||
let carCount = 0;
|
let carCount = 0;
|
||||||
let personCount = 0;
|
let personCount = 0;
|
||||||
|
let crewCount = 0;
|
||||||
|
|
||||||
for (const unit of units.units) {
|
for (const unit of units.units) {
|
||||||
const mesh = unit.kind === 'car' ? cars : people;
|
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));
|
scratch.compose(position, quaternion, scale.set(1, 1, 1));
|
||||||
mesh.setMatrixAt(index, scratch);
|
mesh.setMatrixAt(index, scratch);
|
||||||
mesh.setColorAt(index, colour.setHex(colourOf(unit)));
|
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(cars, carCount);
|
||||||
park(people, personCount);
|
park(people, personCount);
|
||||||
|
park(crew, crewCount);
|
||||||
|
park(guns, crewCount);
|
||||||
cars.instanceMatrix.needsUpdate = true;
|
cars.instanceMatrix.needsUpdate = true;
|
||||||
people.instanceMatrix.needsUpdate = true;
|
people.instanceMatrix.needsUpdate = true;
|
||||||
|
crew.instanceMatrix.needsUpdate = true;
|
||||||
|
guns.instanceMatrix.needsUpdate = true;
|
||||||
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
|
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
|
||||||
if (people.instanceColor) people.instanceColor.needsUpdate = true;
|
if (people.instanceColor) people.instanceColor.needsUpdate = true;
|
||||||
|
if (crew.instanceColor) crew.instanceColor.needsUpdate = true;
|
||||||
|
|
||||||
// --- Checkpoints under construction, and finished ones ---
|
// --- Checkpoints under construction, and finished ones ---
|
||||||
let towerCount = 0;
|
let towerCount = 0;
|
||||||
@ -139,13 +233,13 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
} else {
|
} else {
|
||||||
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
|
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
|
||||||
}
|
}
|
||||||
guns.setMatrixAt(towerCount, scratch);
|
towerGuns.setMatrixAt(towerCount, scratch);
|
||||||
towerCount++;
|
towerCount++;
|
||||||
}
|
}
|
||||||
park(towers, towerCount);
|
park(towers, towerCount);
|
||||||
park(guns, towerCount);
|
park(towerGuns, towerCount);
|
||||||
towers.instanceMatrix.needsUpdate = true;
|
towers.instanceMatrix.needsUpdate = true;
|
||||||
guns.instanceMatrix.needsUpdate = true;
|
towerGuns.instanceMatrix.needsUpdate = true;
|
||||||
|
|
||||||
// --- Tracers ---
|
// --- Tracers ---
|
||||||
let roundCount = 0;
|
let roundCount = 0;
|
||||||
|
|||||||
@ -319,6 +319,8 @@ const FOLLOW_RANGE = 26;
|
|||||||
const FOLLOW_WIDTH = 2.6;
|
const FOLLOW_WIDTH = 2.6;
|
||||||
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
|
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
|
||||||
const CAR_SEPARATION = 4.5;
|
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. */
|
/** Turns `from` toward `to`, by at most `limit`. Both radians. */
|
||||||
function turnToward(from: number, to: number, limit: number): number {
|
function turnToward(from: number, to: number, limit: number): number {
|
||||||
@ -558,7 +560,8 @@ export function dispatchTo(
|
|||||||
assigned: segment.id,
|
assigned: segment.id,
|
||||||
onStation: 0,
|
onStation: 0,
|
||||||
cooldown: 1,
|
cooldown: 1,
|
||||||
elevation: 1,
|
// Shots leave the man in the passenger seat, not the bonnet.
|
||||||
|
elevation: CREW_ELEVATION,
|
||||||
building: role === 'engineer' ? stage : undefined,
|
building: role === 'engineer' ? stage : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -605,7 +608,7 @@ function spawnAmbientPatrol(
|
|||||||
// Start part-way through a sweep, so they are not all in step.
|
// Start part-way through a sweep, so they are not all in step.
|
||||||
onStation: rng() * 20,
|
onStation: rng() * 20,
|
||||||
cooldown: 1,
|
cooldown: 1,
|
||||||
elevation: 1,
|
elevation: CREW_ELEVATION,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user