Patrols everywhere they should be, and concrete that has to be poured
Three fixes, all from watching it run. Cars stuck against buildings. routeTo dropped the node a route starts from, which is fine for a unit standing on it and wrong for anything re-routing from mid-segment: its first leg became a straight line to a node two hops away, across buildings and open country. Routes now include their start node so a unit rejoins the road before setting off. No patrols anywhere. They only existed as a reaction to the player's own tracks, so hostile territory was empty until you had personally made a road notorious. Enemy ground now carries standing patrols by how dangerous it is — none behind your lines, five on the frontier — spawned onto their roads so the territory has them the moment you arrive. This surfaced a second bug: dispatched units were culled at 546m from the player while dispatch origins reached 600m, so a patrol or engineer could be deleted on its first step and simply never turn up. Anything with an assignment is now kept regardless of range; its expiry still bounds it. Barricades appearing instantly. Crossing a heat threshold placed concrete on the road with nobody involved. Construction is now staged: an engineer is dispatched for the barricade, another for the tower, and heatProps is driven by finished builds rather than by heat levels. It still refuses to close a slab around a car parked in the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4feae2c731
commit
f4ed3cdc48
25
README.md
25
README.md
@ -190,17 +190,24 @@ job is what keeps the car alive a while longer.
|
|||||||
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
|
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
|
||||||
the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited.
|
the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited.
|
||||||
|
|
||||||
**Nothing appears any more — things are sent.** When a road turns notorious a
|
**Enemy ground is patrolled because it is enemy ground.** There are standing
|
||||||
patrol is *dispatched*: it starts at a junction a few hundred metres away, drives
|
patrols on the roads wherever you are past the line, and more of them the deeper
|
||||||
to the road, sweeps up and down it for about a minute, and while it is working
|
in you go — none in liberated territory, one in contested, three in occupied,
|
||||||
the road it brings the heat back down. Then it leaves. The enemy is not punishing
|
five on the frontier. Nobody summoned these; they are what hostile territory
|
||||||
|
looks like.
|
||||||
|
|
||||||
|
**On top of that, nothing appears — things are sent.** When a road turns
|
||||||
|
notorious a patrol is *dispatched*: it starts at a junction a few hundred metres
|
||||||
|
away, drives there, sweeps up and down for about a minute, and while it works the
|
||||||
|
road it brings the heat back down. Then it leaves. The enemy is not punishing
|
||||||
you; they are re-securing a route and going home.
|
you; they are re-securing a route and going home.
|
||||||
|
|
||||||
**Checkpoints are built.** At `turret` an engineer is dispatched, and the tower
|
**Everything on a road is built, including the concrete.** At `barricade` an
|
||||||
rises over about forty seconds of someone standing on site. Arrive early and you
|
engineer is dispatched and the blocks go up over about twenty seconds of someone
|
||||||
find a half-built stump and no one on it. Once it stands, a gunner mans it, up on
|
standing there; at `turret` another is sent and the tower takes forty. Arrive
|
||||||
top so he shoots over his own barricade. Let the road cool and the whole thing is
|
mid-build and you find a half-finished stump with nobody on it. Only once the
|
||||||
abandoned.
|
tower stands does a gunner man it, up on top so he shoots over his own barricade.
|
||||||
|
Let the road cool and the whole thing is abandoned.
|
||||||
|
|
||||||
**Traffic and people.** Civilian cars route across the network with somewhere to
|
**Traffic and people.** Civilian cars route across the network with somewhere to
|
||||||
be; people wander on foot near the buildings. Both scatter when shooting starts.
|
be; people wander on foot near the buildings. Both scatter when shooting starts.
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
import { createPhysics } from './physics/physics';
|
import { createPhysics } from './physics/physics';
|
||||||
import { createHeat, stepHeat } from './sim/heat';
|
import { propsFor } from './sim/heat';
|
||||||
import { generateWorld } from './sim/world';
|
import { generateWorld } from './sim/world';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,62 +10,86 @@ import { generateWorld } from './sim/world';
|
|||||||
* so the prop lifecycle can be checked end to end — including that removing a
|
* so the prop lifecycle can be checked end to end — including that removing a
|
||||||
* barricade removes its collider, not just its mesh.
|
* barricade removes its collider, not just its mesh.
|
||||||
*/
|
*/
|
||||||
describe('heat props lifecycle', () => {
|
const world = generateWorld(3, 0);
|
||||||
it('builds and tears down colliders and meshes together', async () => {
|
const segment = world.roads.segments[0]!;
|
||||||
const world = generateWorld(3, 0);
|
/** Somewhere the player is nowhere near the road in question. */
|
||||||
|
const AWAY = { x: 99999, z: 99999 };
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
const physics = await createPhysics(world);
|
const physics = await createPhysics(world);
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
const props = createHeatProps(world.roads, physics, scene);
|
const props = createHeatProps(world.roads, physics, scene);
|
||||||
const heat = createHeat(world.roads);
|
return {
|
||||||
|
physics,
|
||||||
const baseBodies = physics.rapier.bodies.len();
|
scene,
|
||||||
const baseMeshes = scene.children.length;
|
props,
|
||||||
|
baseBodies: physics.rapier.bodies.len(),
|
||||||
const drive = (metres: number) => {
|
baseMeshes: scene.children.length,
|
||||||
for (let m = 0; m < metres; m++) {
|
|
||||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const idle = (seconds: number) => {
|
|
||||||
for (let i = 0; i < seconds * 60; i++) {
|
|
||||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
drive(400);
|
describe('barricades appear only once someone has built them', () => {
|
||||||
expect(heat.level[0]).toBe('turret');
|
it('places nothing until a build is reported finished', async () => {
|
||||||
// Still on the road, so nothing has been built around the car yet.
|
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||||
|
// Heat alone does nothing now. An engineer has to have done the work.
|
||||||
|
for (let i = 0; i < 100; i++) props.sync(AWAY);
|
||||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
expect(scene.children.length).toBe(baseMeshes);
|
||||||
|
|
||||||
// Leaving it lets the deferred work land.
|
props.finished(segment.id);
|
||||||
idle(1);
|
props.sync(AWAY);
|
||||||
const hotBodies = physics.rapier.bodies.len();
|
const expected = propsFor(segment, 'barricade').length;
|
||||||
expect(hotBodies).toBeGreaterThan(baseBodies);
|
expect(expected).toBeGreaterThan(0);
|
||||||
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies);
|
expect(physics.rapier.bodies.len() - baseBodies).toBe(expected);
|
||||||
|
expect(scene.children.length - baseMeshes).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
// Left alone, the road should give everything back.
|
it('takes colliders and meshes away together when abandoned', async () => {
|
||||||
idle(300);
|
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||||
expect(heat.level[0]).toBe('clear');
|
props.finished(segment.id);
|
||||||
|
props.sync(AWAY);
|
||||||
|
expect(props.has(segment.id)).toBe(true);
|
||||||
|
|
||||||
|
props.abandon(segment.id);
|
||||||
|
expect(props.has(segment.id)).toBe(false);
|
||||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
expect(scene.children.length).toBe(baseMeshes);
|
expect(scene.children.length).toBe(baseMeshes);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not leak bodies when a road escalates through every level', async () => {
|
it('waits rather than closing a slab around the car standing in the gap', async () => {
|
||||||
const world = generateWorld(3, 0);
|
const { physics, props, baseBodies } = await setup();
|
||||||
const physics = await createPhysics(world);
|
const block = propsFor(segment, 'barricade')[0]!;
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const props = createHeatProps(world.roads, physics, scene);
|
|
||||||
const heat = createHeat(world.roads);
|
|
||||||
const baseBodies = physics.rapier.bodies.len();
|
|
||||||
|
|
||||||
for (let cycle = 0; cycle < 3; cycle++) {
|
props.finished(segment.id);
|
||||||
for (let m = 0; m < 400; m++) {
|
// Parked right where the concrete is going.
|
||||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
props.sync({ x: block.x, z: block.z });
|
||||||
}
|
|
||||||
for (let i = 0; i < 300 * 60; i++) {
|
|
||||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
|
||||||
}
|
|
||||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
|
||||||
|
// Moved off; the build lands.
|
||||||
|
props.sync(AWAY);
|
||||||
|
expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak bodies over repeated build and abandon cycles', async () => {
|
||||||
|
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||||
|
for (let cycle = 0; cycle < 4; cycle++) {
|
||||||
|
props.finished(segment.id);
|
||||||
|
props.sync(AWAY);
|
||||||
|
expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies);
|
||||||
|
props.abandon(segment.id);
|
||||||
|
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||||
|
expect(scene.children.length).toBe(baseMeshes);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('is idempotent, so a repeated report does not double up', async () => {
|
||||||
|
const { physics, props, baseBodies } = await setup();
|
||||||
|
props.finished(segment.id);
|
||||||
|
props.sync(AWAY);
|
||||||
|
const after = physics.rapier.bodies.len();
|
||||||
|
props.finished(segment.id);
|
||||||
|
props.sync(AWAY);
|
||||||
|
expect(physics.rapier.bodies.len()).toBe(after);
|
||||||
|
expect(after).toBeGreaterThan(baseBodies);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Turns heat levels into things that physically exist on the road.
|
* Puts the concrete on a road once someone has actually built it.
|
||||||
*
|
*
|
||||||
* This is an integration layer: it is allowed to touch both Rapier and three.js,
|
* This used to be driven straight off the heat level: cross a threshold and a
|
||||||
* which is why it sits outside src/sim/. The sim decides *what* should be there
|
* barricade existed. It does not any more. An engineer is dispatched, drives
|
||||||
* ({@link propsFor}); this only builds and tears it down.
|
* there, and stands on site while it goes up; only then does this get told to
|
||||||
|
* place anything. What arrives here is a finished build, not a state change.
|
||||||
|
*
|
||||||
|
* Integration layer: it is allowed to touch both Rapier and three.js, because it
|
||||||
|
* owns objects that must exist in both or in neither.
|
||||||
*/
|
*/
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type RAPIER from '@dimforge/rapier3d-compat';
|
import type RAPIER from '@dimforge/rapier3d-compat';
|
||||||
import { propsFor, type HeatProp, type HeatState } from './sim/heat';
|
import { propsFor, type HeatProp } from './sim/heat';
|
||||||
import type { RoadNetwork } from './sim/roads';
|
import type { RoadNetwork } from './sim/roads';
|
||||||
import type { PhysicsWorld } from './physics/physics';
|
import type { PhysicsWorld } from './physics/physics';
|
||||||
|
|
||||||
@ -17,19 +21,18 @@ const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
|
|||||||
|
|
||||||
const BOX = new THREE.BoxGeometry(1, 1, 1);
|
const BOX = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
|
||||||
|
/** Never close a barricade around the car that is standing in the gap. */
|
||||||
|
const PLAYER_CLEARANCE = 8;
|
||||||
|
|
||||||
interface Placed {
|
interface Placed {
|
||||||
bodies: RAPIER.RigidBody[];
|
bodies: RAPIER.RigidBody[];
|
||||||
meshes: THREE.Mesh[];
|
meshes: THREE.Mesh[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHeatProps(
|
export function createHeatProps(roads: RoadNetwork, physics: PhysicsWorld, scene: THREE.Scene) {
|
||||||
roads: RoadNetwork,
|
|
||||||
physics: PhysicsWorld,
|
|
||||||
scene: THREE.Scene,
|
|
||||||
) {
|
|
||||||
const placed = new Map<number, Placed>();
|
const placed = new Map<number, Placed>();
|
||||||
/** Level changes waiting for the player to get off the road in question. */
|
/** Builds finished while the player was stood in the way, waiting for room. */
|
||||||
const pending = new Set<number>();
|
const waiting = new Set<number>();
|
||||||
|
|
||||||
const clear = (segmentId: number) => {
|
const clear = (segmentId: number) => {
|
||||||
const existing = placed.get(segmentId);
|
const existing = placed.get(segmentId);
|
||||||
@ -39,9 +42,12 @@ export function createHeatProps(
|
|||||||
placed.delete(segmentId);
|
placed.delete(segmentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const build = (segmentId: number, heat: HeatState) => {
|
const raise = (segmentId: number): void => {
|
||||||
|
if (placed.has(segmentId)) return;
|
||||||
const segment = roads.segments[segmentId]!;
|
const segment = roads.segments[segmentId]!;
|
||||||
const props = propsFor(segment, heat.level[segmentId]!);
|
// 'barricade' is the level whose props are the concrete itself; the tower
|
||||||
|
// is a unit-owned structure and does not come from here.
|
||||||
|
const props = propsFor(segment, 'barricade');
|
||||||
if (props.length === 0) return;
|
if (props.length === 0) return;
|
||||||
|
|
||||||
const entry: Placed = { bodies: [], meshes: [] };
|
const entry: Placed = { bodies: [], meshes: [] };
|
||||||
@ -60,26 +66,39 @@ export function createHeatProps(
|
|||||||
placed.set(segmentId, entry);
|
placed.set(segmentId, entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const tooClose = (segmentId: number, player: { x: number; z: number }): boolean => {
|
||||||
/**
|
const segment = roads.segments[segmentId]!;
|
||||||
* Rebuild the segments whose level changed — but never the one the car is
|
return propsFor(segment, 'barricade').some(
|
||||||
* currently on. A barricade appearing around the player would spawn a static
|
(prop) => Math.hypot(prop.x - player.x, prop.z - player.z) < PLAYER_CLEARANCE,
|
||||||
* collider inside the chassis, and watching a checkpoint assemble itself in
|
);
|
||||||
* the mirror would break the fiction anyway. Changes wait until they are
|
};
|
||||||
* out of sight, which is how the player is meant to find them: by returning.
|
|
||||||
*
|
|
||||||
* Call every step, not only when something changed, so deferred work drains.
|
|
||||||
*/
|
|
||||||
sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) {
|
|
||||||
for (const id of changedSegmentIds) pending.add(id);
|
|
||||||
if (pending.size === 0) return;
|
|
||||||
|
|
||||||
for (const id of [...pending]) {
|
return {
|
||||||
if (id === occupiedSegmentId) continue;
|
/** A barricade has been finished on this road. */
|
||||||
pending.delete(id);
|
finished(segmentId: number) {
|
||||||
clear(id);
|
waiting.add(segmentId);
|
||||||
build(id, heat);
|
},
|
||||||
|
|
||||||
|
/** The road cooled off, or the checkpoint was abandoned. */
|
||||||
|
abandon(segmentId: number) {
|
||||||
|
waiting.delete(segmentId);
|
||||||
|
clear(segmentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call every step. Places anything finished as soon as there is room — a
|
||||||
|
* block spawning on top of the car would put a static collider inside the
|
||||||
|
* chassis, so a player parked in the gap makes the last slab wait.
|
||||||
|
*/
|
||||||
|
sync(player: { x: number; z: number }) {
|
||||||
|
if (waiting.size === 0) return;
|
||||||
|
for (const id of [...waiting]) {
|
||||||
|
if (tooClose(id, player)) continue;
|
||||||
|
waiting.delete(id);
|
||||||
|
raise(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
has: (segmentId: number) => placed.has(segmentId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/main.ts
53
src/main.ts
@ -217,15 +217,16 @@ async function boot() {
|
|||||||
{ x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
|
{ x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
// Rebuild whatever the restored heat implies, so checkpoints are standing
|
|
||||||
// where the save says they are rather than appearing as you drive past.
|
|
||||||
stepFront(front, 0);
|
stepFront(front, 0);
|
||||||
refreshSegmentControl();
|
refreshSegmentControl();
|
||||||
heatProps.sync(
|
// A save does not record who was standing where, so barricades on roads
|
||||||
model.roads.segments.map((s) => s.id),
|
// that were already hot are treated as already built rather than making the
|
||||||
heat,
|
// player wait for a fresh engineer to be sent out.
|
||||||
null,
|
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.
|
// Chassis transform at the end of the last two fixed steps, for interpolation.
|
||||||
@ -296,15 +297,25 @@ async function boot() {
|
|||||||
// the ground the car happens to be standing on.
|
// the ground the car happens to be standing on.
|
||||||
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
|
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
|
||||||
});
|
});
|
||||||
heatProps.sync(heatChanged, heat, currentSegment);
|
heatProps.sync({ x: at.x, z: at.z });
|
||||||
|
|
||||||
// Escalation is now something the enemy has to *do*. A road that turns
|
// Escalation is entirely something the enemy has to *do*. Every level
|
||||||
// notorious gets a patrol sent to it; one that turns worse gets an
|
// sends somebody: a patrol to work the road, an engineer to pour the
|
||||||
// engineer sent to build a checkpoint. Both have to drive here first.
|
// concrete, another to put the tower up. Nothing appears on its own.
|
||||||
for (const id of heatChanged) {
|
for (const id of heatChanged) {
|
||||||
|
const segment = model.roads.segments[id]!;
|
||||||
const level = heat.level[id]!;
|
const level = heat.level[id]!;
|
||||||
if (level === 'patrol') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'patrol', chatterRng);
|
if (level === 'patrol') {
|
||||||
if (level === 'turret') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'engineer', chatterRng);
|
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
|
// Driving a road is how you learn what is on it, and seeing ground is how
|
||||||
// it stops being a blank on the map.
|
// it stops being a blank on the map.
|
||||||
@ -442,17 +453,23 @@ async function boot() {
|
|||||||
chatterRng,
|
chatterRng,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Finished towers become solid; abandoned ones stop being solid.
|
// Whatever an engineer just finished now exists in the world.
|
||||||
for (const id of unitEvents.built) {
|
for (const { segment, stage } of unitEvents.built) {
|
||||||
const site = units.checkpoints.get(id);
|
if (stage === 'barricade') {
|
||||||
if (!site || towerBodies.has(id)) continue;
|
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(
|
towerBodies.set(
|
||||||
id,
|
segment,
|
||||||
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }),
|
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);
|
say('They have finished the tower on that road.', 6);
|
||||||
}
|
}
|
||||||
for (const id of unitEvents.removed) {
|
for (const id of unitEvents.removed) {
|
||||||
|
heatProps.abandon(id);
|
||||||
const body = towerBodies.get(id);
|
const body = towerBodies.get(id);
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
physics.removeBody(body);
|
physics.removeBody(body);
|
||||||
|
|||||||
@ -122,7 +122,10 @@ export function createUnitView(scene: THREE.Scene) {
|
|||||||
let towerCount = 0;
|
let towerCount = 0;
|
||||||
for (const site of units.checkpoints.values()) {
|
for (const site of units.checkpoints.values()) {
|
||||||
if (towerCount >= towers.count) break;
|
if (towerCount >= towers.count) break;
|
||||||
const built = site.built;
|
// A tower only stands once its own stage is done. While the barricade
|
||||||
|
// is going up there is nothing here but activity.
|
||||||
|
const built = site.done.includes('tower');
|
||||||
|
if (!built && site.stage !== 'tower') continue;
|
||||||
const height = built ? 1 : Math.max(0.15, site.progress);
|
const height = built ? 1 : Math.max(0.15, site.progress);
|
||||||
position.set(site.x, (6 * height) / 2, site.z);
|
position.set(site.x, (6 * height) / 2, site.z);
|
||||||
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1));
|
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1));
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { createFront, controlAt } from './regions';
|
|||||||
import { createHeat } from './heat';
|
import { createHeat } from './heat';
|
||||||
import { freshCondition } from './car';
|
import { freshCondition } from './car';
|
||||||
import {
|
import {
|
||||||
|
AMBIENT_PATROLS,
|
||||||
createUnits,
|
createUnits,
|
||||||
dispatchTo,
|
dispatchTo,
|
||||||
|
hasBuilt,
|
||||||
stepUnits,
|
stepUnits,
|
||||||
PATROL_HEAT_DECAY,
|
PATROL_HEAT_DECAY,
|
||||||
patrolProgress,
|
patrolProgress,
|
||||||
@ -25,7 +27,11 @@ function run(
|
|||||||
overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
|
overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
|
||||||
rng = makeRng(3),
|
rng = makeRng(3),
|
||||||
) {
|
) {
|
||||||
const events = { built: [] as number[], removed: [] as number[], skirmish: false };
|
const events = {
|
||||||
|
built: [] as Array<{ segment: number; stage: string }>,
|
||||||
|
removed: [] as number[],
|
||||||
|
skirmish: false,
|
||||||
|
};
|
||||||
for (let i = 0; i < seconds * 10; i++) {
|
for (let i = 0; i < seconds * 10; i++) {
|
||||||
const step = stepUnits(
|
const step = stepUnits(
|
||||||
state,
|
state,
|
||||||
@ -137,25 +143,47 @@ describe('patrols are dispatched, not conjured', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkpoints have to be built', () => {
|
describe('everything on a road has to be built', () => {
|
||||||
const segment = world.roads.segments[6]!;
|
const segment = world.roads.segments[6]!;
|
||||||
|
const atSite = { player: { x: segment.ax, z: segment.az } };
|
||||||
|
|
||||||
it('does not exist until an engineer has stood there long enough', () => {
|
it('pours no concrete until an engineer has stood there long enough', () => {
|
||||||
const state = createUnits();
|
const state = createUnits();
|
||||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||||
// Nothing is standing yet.
|
// Dispatched, but nothing is standing on the road yet.
|
||||||
expect(state.checkpoints.size).toBe(0);
|
expect(state.checkpoints.size).toBe(0);
|
||||||
|
|
||||||
const events = run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
const events = run(state, 200, atSite);
|
||||||
expect(events.built).toContain(segment.id);
|
expect(events.built).toContainEqual({ segment: segment.id, stage: 'barricade' });
|
||||||
const site = state.checkpoints.get(segment.id)!;
|
expect(hasBuilt(state.checkpoints.get(segment.id), 'barricade')).toBe(true);
|
||||||
expect(site.built).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is manned once it stands', () => {
|
it('does not raise a tower for a barricade job', () => {
|
||||||
const state = createUnits();
|
const state = createUnits();
|
||||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||||
run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
run(state, 200, atSite);
|
||||||
|
expect(hasBuilt(state.checkpoints.get(segment.id), 'tower')).toBe(false);
|
||||||
|
// And nobody is manning a checkpoint that is only concrete.
|
||||||
|
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a second engineer, and longer, to put the tower up', () => {
|
||||||
|
const state = createUnits();
|
||||||
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||||
|
run(state, 200, atSite);
|
||||||
|
state.dispatched.delete(segment.id);
|
||||||
|
|
||||||
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(4), 'tower');
|
||||||
|
const events = run(state, 260, atSite);
|
||||||
|
expect(events.built).toContainEqual({ segment: segment.id, stage: 'tower' });
|
||||||
|
const site = state.checkpoints.get(segment.id)!;
|
||||||
|
expect(site.done).toEqual(['barricade', 'tower']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mans the tower once it stands', () => {
|
||||||
|
const state = createUnits();
|
||||||
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower');
|
||||||
|
run(state, 260, atSite);
|
||||||
const gunner = state.units.find((u) => u.role === 'garrison');
|
const gunner = state.units.find((u) => u.role === 'garrison');
|
||||||
expect(gunner).toBeDefined();
|
expect(gunner).toBeDefined();
|
||||||
// Up on the tower, so it shoots over its own barricade.
|
// Up on the tower, so it shoots over its own barricade.
|
||||||
@ -164,20 +192,53 @@ describe('checkpoints have to be built', () => {
|
|||||||
|
|
||||||
it('is abandoned when the road stops being worth guarding', () => {
|
it('is abandoned when the road stops being worth guarding', () => {
|
||||||
const state = createUnits();
|
const state = createUnits();
|
||||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower');
|
||||||
run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
run(state, 260, atSite);
|
||||||
expect(state.checkpoints.size).toBe(1);
|
expect(state.checkpoints.size).toBe(1);
|
||||||
|
|
||||||
const events = run(state, 5, {
|
const events = run(state, 5, { ...atSite, heatLevel: () => 'clear' });
|
||||||
player: { x: segment.ax, z: segment.az },
|
|
||||||
heatLevel: () => 'clear',
|
|
||||||
});
|
|
||||||
expect(events.removed).toContain(segment.id);
|
expect(events.removed).toContain(segment.id);
|
||||||
expect(state.checkpoints.size).toBe(0);
|
expect(state.checkpoints.size).toBe(0);
|
||||||
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
|
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('enemy ground is patrolled because it is enemy ground', () => {
|
||||||
|
/** Somewhere well past the line, so the player is in hostile territory. */
|
||||||
|
const deep = (() => {
|
||||||
|
const depth = front.boundaries.occupied + 60;
|
||||||
|
return { x: front.axis.x * depth, z: front.axis.z * depth };
|
||||||
|
})();
|
||||||
|
|
||||||
|
it('keeps no patrols behind your own lines', () => {
|
||||||
|
const state = createUnits();
|
||||||
|
run(state, 300, { player: { x: world.spawn.x, z: world.spawn.z } });
|
||||||
|
expect(state.units.some((u) => u.role === 'patrol')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has patrols already working the roads out past the front', () => {
|
||||||
|
const state = createUnits();
|
||||||
|
run(state, 200, { player: deep });
|
||||||
|
const patrols = state.units.filter((u) => u.role === 'patrol');
|
||||||
|
// Nobody triggered these: the player never drove anything here.
|
||||||
|
expect(patrols.length).toBeGreaterThan(0);
|
||||||
|
for (const patrol of patrols) expect(patrol.assigned).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts more of them on more dangerous ground', () => {
|
||||||
|
expect(AMBIENT_PATROLS.liberated).toBe(0);
|
||||||
|
expect(AMBIENT_PATROLS.contested).toBeLessThan(AMBIENT_PATROLS.occupied);
|
||||||
|
expect(AMBIENT_PATROLS.occupied).toBeLessThan(AMBIENT_PATROLS.frontier);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never stacks two patrols onto the same road', () => {
|
||||||
|
const state = createUnits();
|
||||||
|
run(state, 400, { player: deep });
|
||||||
|
const roads = state.units.filter((u) => u.role === 'patrol').map((u) => u.assigned);
|
||||||
|
expect(new Set(roads).size).toBe(roads.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('a war going on regardless', () => {
|
describe('a war going on regardless', () => {
|
||||||
it('breaks out fights between the two armies', () => {
|
it('breaks out fights between the two armies', () => {
|
||||||
const state = createUnits();
|
const state = createUnits();
|
||||||
|
|||||||
152
src/sim/units.ts
152
src/sim/units.ts
@ -55,17 +55,34 @@ export interface Unit {
|
|||||||
cooldown: number;
|
cooldown: number;
|
||||||
/** Height of the muzzle, so a gunner on a tower shoots over the barricade. */
|
/** Height of the muzzle, so a gunner on a tower shoots over the barricade. */
|
||||||
elevation: number;
|
elevation: number;
|
||||||
|
/** What an engineer was sent to put up. */
|
||||||
|
building?: BuildStage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can be put up on a road, in the order it goes up.
|
||||||
|
*
|
||||||
|
* Both stages are *work*. Concrete does not pour itself the instant a road
|
||||||
|
* becomes notorious any more than a tower does — someone has to be sent, arrive,
|
||||||
|
* and stand there while it goes up.
|
||||||
|
*/
|
||||||
|
export type BuildStage = 'barricade' | 'tower';
|
||||||
|
|
||||||
export interface Checkpoint {
|
export interface Checkpoint {
|
||||||
segment: number;
|
segment: number;
|
||||||
/** 0..1. A tower does not exist until someone builds it. */
|
/** What is going up right now. */
|
||||||
|
stage: BuildStage;
|
||||||
|
/** 0..1 toward the current stage. */
|
||||||
progress: number;
|
progress: number;
|
||||||
built: boolean;
|
/** Stages finished and standing. */
|
||||||
|
done: BuildStage[];
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const hasBuilt = (site: Checkpoint | undefined, stage: BuildStage): boolean =>
|
||||||
|
site?.done.includes(stage) ?? false;
|
||||||
|
|
||||||
export interface UnitState {
|
export interface UnitState {
|
||||||
units: Unit[];
|
units: Unit[];
|
||||||
checkpoints: Map<number, Checkpoint>;
|
checkpoints: Map<number, Checkpoint>;
|
||||||
@ -98,11 +115,30 @@ const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
|
|||||||
const PATROL_DURATION = 90;
|
const PATROL_DURATION = 90;
|
||||||
/** Heat removed per second by a patrol actually driving its assigned road. */
|
/** Heat removed per second by a patrol actually driving its assigned road. */
|
||||||
export const PATROL_HEAT_DECAY = 0.012;
|
export const PATROL_HEAT_DECAY = 0.012;
|
||||||
/** Seconds of an engineer standing on site to finish a checkpoint. */
|
/**
|
||||||
const BUILD_SECONDS = 40;
|
* Seconds of an engineer standing on site per stage. Concrete is quicker than a
|
||||||
|
* tower, but neither is instant — arriving mid-build is meant to be a thing that
|
||||||
|
* happens to you.
|
||||||
|
*/
|
||||||
|
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
|
||||||
/** Minimum gap between skirmishes breaking out. */
|
/** Minimum gap between skirmishes breaking out. */
|
||||||
const SKIRMISH_SPACING = 55;
|
const SKIRMISH_SPACING = 55;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standing patrols the enemy keeps on the roads near the player, by whose
|
||||||
|
* ground it is.
|
||||||
|
*
|
||||||
|
* These are not a reaction to anything the player did. Occupied territory is
|
||||||
|
* patrolled because it is occupied, and the deeper in you go the more of it
|
||||||
|
* there is. Heat-triggered patrols arrive on top of this.
|
||||||
|
*/
|
||||||
|
export const AMBIENT_PATROLS: Record<Control, number> = {
|
||||||
|
liberated: 0,
|
||||||
|
contested: 1,
|
||||||
|
occupied: 3,
|
||||||
|
frontier: 5,
|
||||||
|
};
|
||||||
|
|
||||||
export const UNIT_HP: Record<UnitKind, number> = { car: 60, soldier: 30 };
|
export const UNIT_HP: Record<UnitKind, number> = { car: 60, soldier: 30 };
|
||||||
|
|
||||||
// --- Helpers --------------------------------------------------------------
|
// --- Helpers --------------------------------------------------------------
|
||||||
@ -154,8 +190,18 @@ function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node list for a journey, *including* the node it starts from.
|
||||||
|
*
|
||||||
|
* Dropping the start node looked harmless and was not. A unit re-routing from
|
||||||
|
* mid-segment is not standing on the node its route begins at, so its first leg
|
||||||
|
* became a straight line to a node two hops away — across buildings, across
|
||||||
|
* open country, wherever that line happened to go. Keeping the start node makes
|
||||||
|
* a unit rejoin the road before setting off. Anything already standing on it
|
||||||
|
* shifts past within a step.
|
||||||
|
*/
|
||||||
function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] {
|
function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] {
|
||||||
return findRoute(graph, from, to, travelTime(roads))?.nodes.slice(1) ?? [];
|
return findRoute(graph, from, to, travelTime(roads))?.nodes ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Spawning -------------------------------------------------------------
|
// --- Spawning -------------------------------------------------------------
|
||||||
@ -234,6 +280,7 @@ export function dispatchTo(
|
|||||||
segment: RoadSegment,
|
segment: RoadSegment,
|
||||||
role: 'patrol' | 'engineer',
|
role: 'patrol' | 'engineer',
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
|
stage: BuildStage = 'barricade',
|
||||||
): Unit | null {
|
): Unit | null {
|
||||||
if (state.dispatched.has(segment.id)) return null;
|
if (state.dispatched.has(segment.id)) return null;
|
||||||
|
|
||||||
@ -241,7 +288,8 @@ export function dispatchTo(
|
|||||||
// the patrol arrives from somewhere plausible rather than materialising.
|
// the patrol arrives from somewhere plausible rather than materialising.
|
||||||
const origins = roads.nodes.filter((n) => {
|
const origins = roads.nodes.filter((n) => {
|
||||||
const d = Math.hypot(n.x - segment.ax, n.z - segment.az);
|
const d = Math.hypot(n.x - segment.ax, n.z - segment.az);
|
||||||
return d > 150 && d < 600;
|
// Far enough to be somewhere else, close enough to arrive while it matters.
|
||||||
|
return d > 150 && d < 430;
|
||||||
});
|
});
|
||||||
if (origins.length === 0) return null;
|
if (origins.length === 0) return null;
|
||||||
const origin = origins[Math.floor(rng() * origins.length)]!;
|
const origin = origins[Math.floor(rng() * origins.length)]!;
|
||||||
@ -265,9 +313,57 @@ export function dispatchTo(
|
|||||||
onStation: 0,
|
onStation: 0,
|
||||||
cooldown: 1,
|
cooldown: 1,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
|
building: role === 'engineer' ? stage : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A patrol that is simply *there*, already working a road in enemy territory,
|
||||||
|
* rather than one summoned by the player's own tracks.
|
||||||
|
*
|
||||||
|
* Spawned onto its road rather than driven in from a depot: hostile ground
|
||||||
|
* should have patrols on it the moment you arrive, not thirty seconds later.
|
||||||
|
*/
|
||||||
|
function spawnAmbientPatrol(
|
||||||
|
state: UnitState,
|
||||||
|
roads: RoadNetwork,
|
||||||
|
near: { x: number; z: number },
|
||||||
|
front: Front,
|
||||||
|
rng: Rng,
|
||||||
|
): boolean {
|
||||||
|
const candidates = roads.segments.filter((segment) => {
|
||||||
|
if (state.dispatched.has(segment.id)) return false;
|
||||||
|
const mid = { x: (segment.ax + segment.bx) / 2, z: (segment.az + segment.bz) / 2 };
|
||||||
|
const gap = distance(mid, near);
|
||||||
|
// Not on top of the player, not beyond what is simulated.
|
||||||
|
if (gap < 90 || gap > SIM_RADIUS) return false;
|
||||||
|
return controlAt(front, mid.x, mid.z) !== 'liberated';
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) return false;
|
||||||
|
|
||||||
|
const segment = candidates[Math.floor(rng() * candidates.length)]!;
|
||||||
|
const start = pointOnSegment(segment, rng(), 0);
|
||||||
|
state.dispatched.add(segment.id);
|
||||||
|
makeUnit(state, {
|
||||||
|
kind: 'car',
|
||||||
|
faction: 'enemy',
|
||||||
|
role: 'patrol',
|
||||||
|
x: start.x,
|
||||||
|
z: start.z,
|
||||||
|
heading: start.yaw,
|
||||||
|
speed: SPEED.car,
|
||||||
|
hp: UNIT_HP.car,
|
||||||
|
path: [],
|
||||||
|
expires: 260 + rng() * 200,
|
||||||
|
assigned: segment.id,
|
||||||
|
// Start part-way through a sweep, so they are not all in step.
|
||||||
|
onStation: rng() * 20,
|
||||||
|
cooldown: 1,
|
||||||
|
elevation: 1,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Two squads run into each other. The player is not invited. */
|
/** Two squads run into each other. The player is not invited. */
|
||||||
function spawnSkirmish(
|
function spawnSkirmish(
|
||||||
state: UnitState,
|
state: UnitState,
|
||||||
@ -330,9 +426,9 @@ export interface UnitStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UnitEvents {
|
export interface UnitEvents {
|
||||||
/** Checkpoints finished this step, so the renderer can build them. */
|
/** Stages finished this step, so the world can put them up. */
|
||||||
built: number[];
|
built: Array<{ segment: number; stage: BuildStage }>;
|
||||||
/** Checkpoints abandoned, so the renderer can take them away. */
|
/** Checkpoints abandoned, so the world can take them away. */
|
||||||
removed: number[];
|
removed: number[];
|
||||||
skirmish: boolean;
|
skirmish: boolean;
|
||||||
}
|
}
|
||||||
@ -357,6 +453,12 @@ export function stepUnits(
|
|||||||
spawnPedestrian(state, player, rng);
|
spawnPedestrian(state, player, rng);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Enemy ground is patrolled because it is enemy ground ---
|
||||||
|
const patrols = state.units.filter((u) => u.role === 'patrol').length;
|
||||||
|
if (patrols < AMBIENT_PATROLS[controlAt(step.front, player.x, player.z)] && rng() < dt * 1.5) {
|
||||||
|
spawnAmbientPatrol(state, roads, player, step.front, rng);
|
||||||
|
}
|
||||||
|
|
||||||
// --- A war going on regardless of the player ---
|
// --- A war going on regardless of the player ---
|
||||||
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
|
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
|
||||||
if (spawnSkirmish(state, roads, player, step.front, rng)) {
|
if (spawnSkirmish(state, roads, player, step.front, rng)) {
|
||||||
@ -419,20 +521,32 @@ export function stepUnits(
|
|||||||
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
|
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
|
||||||
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
|
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
|
||||||
} else {
|
} else {
|
||||||
|
const stage: BuildStage = unit.building ?? 'barricade';
|
||||||
const site = state.checkpoints.get(segment.id) ?? {
|
const site = state.checkpoints.get(segment.id) ?? {
|
||||||
segment: segment.id,
|
segment: segment.id,
|
||||||
|
stage,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
built: false,
|
done: [],
|
||||||
x: point.x,
|
x: point.x,
|
||||||
z: point.z,
|
z: point.z,
|
||||||
};
|
};
|
||||||
state.checkpoints.set(segment.id, site);
|
state.checkpoints.set(segment.id, site);
|
||||||
if (!site.built) {
|
|
||||||
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS);
|
if (site.done.includes(stage)) {
|
||||||
|
// Already standing; nothing left for this one to do.
|
||||||
|
unit.expires = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
site.stage = stage;
|
||||||
|
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS[stage]);
|
||||||
if (site.progress >= 1) {
|
if (site.progress >= 1) {
|
||||||
site.built = true;
|
site.done.push(stage);
|
||||||
events.built.push(segment.id);
|
site.progress = 0;
|
||||||
// Someone has to man it once it stands.
|
events.built.push({ segment: segment.id, stage });
|
||||||
|
|
||||||
|
// A tower is no use unless someone is standing on it.
|
||||||
|
if (stage === 'tower') {
|
||||||
const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5);
|
const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5);
|
||||||
makeUnit(state, {
|
makeUnit(state, {
|
||||||
kind: 'soldier',
|
kind: 'soldier',
|
||||||
@ -451,8 +565,8 @@ export function stepUnits(
|
|||||||
// Up on the tower, shooting over its own barricade.
|
// Up on the tower, shooting over its own barricade.
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
});
|
});
|
||||||
unit.expires = 0;
|
|
||||||
}
|
}
|
||||||
|
unit.expires = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -503,7 +617,11 @@ export function stepUnits(
|
|||||||
// --- Retire the dead, the finished and the far away ---
|
// --- Retire the dead, the finished and the far away ---
|
||||||
const survivors: Unit[] = [];
|
const survivors: Unit[] = [];
|
||||||
for (const unit of state.units) {
|
for (const unit of state.units) {
|
||||||
const tooFar = distance(unit, player) > SIM_RADIUS * 1.3;
|
// Anything with a job keeps it regardless of distance. Culling by range
|
||||||
|
// caught units that were dispatched from further out than the cull radius
|
||||||
|
// and deleted them on their first step — so the patrol or the engineer the
|
||||||
|
// player had earned simply never turned up. Their `expires` bounds them.
|
||||||
|
const tooFar = unit.assigned === null && distance(unit, player) > SIM_RADIUS * 1.3;
|
||||||
if (unit.hp <= 0 || unit.expires <= 0 || tooFar) {
|
if (unit.hp <= 0 || unit.expires <= 0 || tooFar) {
|
||||||
if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) {
|
if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) {
|
||||||
state.dispatched.delete(unit.assigned);
|
state.dispatched.delete(unit.assigned);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user