Compare commits
No commits in common. "b4f8fd6c9926c9cc319436fdf61adfad29e8c232" and "6c7e67cf790ce6fa54f9dbf1ca8398b0ce553abc" have entirely different histories.
b4f8fd6c99
...
6c7e67cf79
296
README.md
296
README.md
@ -11,10 +11,6 @@ Endless driving survival game.
|
||||
- **Phase 6** — an inhabited world: traffic, people, patrols, and a live war.
|
||||
- **Phase 7** — sound, synthesised from the simulation.
|
||||
- **Phase 8** — recognition: being noticed, being hunted, and getting away.
|
||||
- **Phase 9** — stakes: a crash that costs something, a death that costs
|
||||
something, a chase you can lose, and a town you can navigate.
|
||||
- **Phase 10** — a world that behaves: traffic with lanes, crews you can read,
|
||||
walls that are actually there, and money you can spend.
|
||||
|
||||
## Running
|
||||
|
||||
@ -32,17 +28,9 @@ seeds may be numbers or words.
|
||||
| `Space` | handbrake (rear wheels only) |
|
||||
| `R` | respawn the car — **does not** repair it |
|
||||
| `1`–`3` | accept a mission, when parked at a base |
|
||||
| `X` | give up the mission in hand, when parked at a base |
|
||||
| `C` | overhaul the car, when parked at a base |
|
||||
| `M` | sound on/off |
|
||||
| hold `R` | wipe the campaign and start over (5 seconds) |
|
||||
|
||||
Add `?debug=1` for the tuning readouts — heat, district standing, which band
|
||||
you are in — and the debug panel. Without it the HUD tells you the car, the job
|
||||
and your cover, and nothing else: several of the design gates below are about
|
||||
reading the world rather than a number, and they cannot be judged with the
|
||||
number on screen.
|
||||
|
||||
Progress saves itself every 20 seconds, and immediately whenever you take or
|
||||
finish a job. Saves are per seed; `?seed=x` is a separate campaign.
|
||||
|
||||
@ -162,30 +150,6 @@ bypass lanes around a checkpoint from none obstructed to about two thirds. You
|
||||
can still get around one; it means threading a maze at walking pace instead of a
|
||||
free detour at speed.
|
||||
|
||||
### Districts, and the buildings that ignore them
|
||||
|
||||
Every building used to be the same grey box between four and twelve metres tall.
|
||||
From the air the world was one texture; from the driver's seat every junction
|
||||
looked like the junction before it, and "learn the map" was not something the
|
||||
map supported.
|
||||
|
||||
The world is cut into districts, and a district agrees with itself: its
|
||||
buildings share a palette, a rough height and a silhouette, and vary only
|
||||
slightly around them. That agreement is the point twice over — it makes crossing
|
||||
into the next district a visible event, and it is the only reason the exceptions
|
||||
read at all.
|
||||
|
||||
Because about one plot in fifty ignores its district completely: much taller,
|
||||
lighter, a different shape. Those are the things you actually navigate by, and
|
||||
they are floored at a height that clears every ordinary roofline on the map,
|
||||
since a landmark you cannot see from outside its own district is no use in the
|
||||
only place you need it.
|
||||
|
||||
Colour rides on the instance rather than the material, so a district costs no
|
||||
extra draw calls. Colliders stay boxes whatever the silhouette says — both the
|
||||
physics and the line-of-sight grid already assume a building is solid to its own
|
||||
footprint.
|
||||
|
||||
### Other deliberate choices
|
||||
|
||||
- **Concrete never closes around the car standing in the gap.** A block spawning
|
||||
@ -232,15 +196,6 @@ the most, but it pays for what *arrives*, not what you set off with. Hand-in
|
||||
works at *any* base, not the issuing one, because the brief is explicit that a
|
||||
blocked road home must never strand the player.
|
||||
|
||||
### Giving one up
|
||||
|
||||
Park at any base with a job you have not started bringing home and `X` takes it
|
||||
off you, for 12 m of front line. A target can end up behind concrete there is no
|
||||
way round, and the only exit used to be holding `R` — which does not drop the
|
||||
job, it destroys the campaign. It cannot be free either, or the board is a
|
||||
reroll button: park, dislike the route, drop it, take another. Dropped jobs are
|
||||
counted apart from finished ones. They are not runs.
|
||||
|
||||
### Work you find rather than work you are given
|
||||
|
||||
Past the line, and only when you have nothing in hand, someone occasionally
|
||||
@ -257,106 +212,23 @@ sim: crossing a border, a road you have made notorious, a car past saving, or
|
||||
the front genuinely drifting your way, which is the brief's "the enemy has other
|
||||
priorities" said out loud without ever showing a number.
|
||||
|
||||
### Rewards, repairs, and the overhaul
|
||||
### Rewards, and why repairs exist
|
||||
|
||||
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. The currency sign
|
||||
is `¤` — Unicode's generic one, which looks like money and is nobody's actual
|
||||
money, for a place whose banks are somebody else's problem. That multiplier is
|
||||
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is
|
||||
the phase's tuning dial: if you never take the new target it is too low, and if
|
||||
you never take the known one it is too high.
|
||||
|
||||
Payment is money, and money repairs the car. Repairs go to the worst subsystem first and stop at
|
||||
Payment is in **parts**, which repair the car — the only thing in the game that
|
||||
pushes condition back up. Repairs go to the worst subsystem first and stop at
|
||||
its **ceiling**, which drops permanently with every bit of damage taken
|
||||
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
|
||||
still capable of, but never to what it was. The HUD bars show this directly: `█`
|
||||
is current condition, `·` is what repairs could still reach, `×` is gone.
|
||||
|
||||
Repairs happen automatically on hand-in, and they stop at the ceiling. Pressing
|
||||
`C` at a base does the other thing: an **overhaul**, which buys the *ceiling*
|
||||
itself back at 0.12 a point per ¤1, worst subsystem first. It deliberately does
|
||||
not touch the current level — it raises what the car could be, and ordinary
|
||||
repair still has to fill it in.
|
||||
|
||||
The ceiling used to fall and nothing else: the only number in the game that
|
||||
moved one way, with no counterplay at any price. That reads less as a design
|
||||
pillar than as a punishment you cannot answer. Now decline is something you can
|
||||
fight, and what keeps it meaningful is the exchange rate rather than the
|
||||
impossibility — putting a car back to factory after one bad crash is about a
|
||||
dozen missions. Early on there is never a surplus to spend, so the option is
|
||||
invisible; it only becomes the thing to save for once the ceiling is what is
|
||||
actually holding you back.
|
||||
|
||||
This is what gives the board's reward a reason to matter. Without an economy the
|
||||
choice between a safe route and a risky one has no stakes; with one, the risky
|
||||
job is what keeps the car alive a while longer.
|
||||
|
||||
### What a crash actually costs
|
||||
|
||||
The impact coefficients were originally written against an imagined scale.
|
||||
Measured on the running game, a head-on into a building at 85 km/h comes back
|
||||
from Rapier as a single contact event of about **2.5e6 N** — twenty-five units
|
||||
of "impact" against the old 1e5 reference. Through the old coefficients that was
|
||||
123% of the chassis and 37% of its ceiling, gone, in one step, in a game whose
|
||||
missions pay about ¤30. One crash ended the campaign, and the campaign had
|
||||
no way to end.
|
||||
|
||||
Everything is now anchored to that measured figure, and damage is capped per
|
||||
step — a crash is a pile of contacts across several steps plus whatever the car
|
||||
scrapes on the way to a standstill, and that sum has no natural bound.
|
||||
|
||||
What it costs is deliberately brutal. That reference head-on leaves the chassis
|
||||
at **23%**, the tyres at 61% and the engine at 75%, ordered by what a collision
|
||||
actually ruins. One is a disaster you drive home from; two in a row is the end
|
||||
of the car. An earlier calibration left it at 65% and perfectly driveable, which
|
||||
made hitting a building at ninety an inconvenience rather than a decision.
|
||||
|
||||
Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean
|
||||
kilometres took them from new to ruined while a two-kilometre round trip paid
|
||||
¤30 against 0.24 of condition spent. Driving perfectly barely broke even.
|
||||
At a quarter of that, a typical mission spends about 0.06, so roughly two clean
|
||||
runs pay for one bad crash — enough slack to gamble with, not enough to ignore.
|
||||
|
||||
## Killed in action
|
||||
|
||||
A car could be driven to zero in every subsystem and would keep going forever at
|
||||
a wheezing thirty kilometres an hour. Nothing was at stake, so nothing was worth
|
||||
protecting, so there was never a reason to take the quiet road. "Decline, not
|
||||
reset" only reads as a stake if the decline can run out.
|
||||
|
||||
When the **chassis** is gone, so are you. Somebody pulls you out; you come round
|
||||
at the nearest base; the job in hand is written off with the car; and 25 m of
|
||||
front line goes back to the enemy, through the same path a finished mission uses
|
||||
so the war moves for one reason only.
|
||||
|
||||
The replacement is a real car — patched to its ceilings, because one that still
|
||||
needed money you do not have would just kill you again on the way out of the
|
||||
yard — and every ceiling is 8% down on the one you wrote off. That is the
|
||||
ratchet applied at the one moment repairs cannot reach.
|
||||
|
||||
It stops at a floor, and that is worth being honest about: strictly it breaks
|
||||
"the ceiling only ever falls". Without one, enough deaths issue a car that is
|
||||
written off the instant you are handed it, and the only way out of the campaign
|
||||
is wiping it. A genuinely miserable car you can still drive is the interesting
|
||||
version of that ending rather than the broken one.
|
||||
|
||||
### The five seconds before you wake up
|
||||
|
||||
The run does not end the instant the car does. Control goes, the camera leaves
|
||||
the bumper and climbs away to sixty metres — turning slowly — until the street,
|
||||
whatever you hit and whoever is standing around watching are all in frame. The
|
||||
point of the shot is not the car; by then the car is a fact. It is *where* this
|
||||
happened, which is the thing you have to drive back out to.
|
||||
|
||||
The world keeps running underneath it, because a death cam over a frozen world
|
||||
is a screenshot rather than a moment. What stops is the driver: no throttle, no
|
||||
board, no overhaul, and no quietly finishing the job by virtue of having stopped
|
||||
moving on top of the target. Anyone shooting stops a quarter of the way in —
|
||||
the burst that killed you should finish, but standing over a wreck emptying
|
||||
magazines into it reads as the enemy beating a dead horse.
|
||||
|
||||
Nothing about any of it is announced. The front moving against you is something
|
||||
you find out by driving back out there.
|
||||
|
||||
## A world with people in it
|
||||
|
||||
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
|
||||
@ -384,53 +256,6 @@ Let the road cool and the whole thing is abandoned.
|
||||
**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.
|
||||
|
||||
**And they drive like traffic.** Cars hold a line parallel to the centreline,
|
||||
offset right by a share of the road's own half-width — a track gets a nudge, a
|
||||
trunk gets a lane — so two meeting head-on are on opposite sides of the road
|
||||
instead of in the same metre of tarmac. Steering is rate-limited, so a
|
||||
right-angle junction is a turn rather than one frame of instant pivot. They lift
|
||||
off for the car in front, and overlapping vehicles are pushed apart, because
|
||||
following distance does not cover a merge: two cars converging on a junction are
|
||||
more than ninety degrees apart right up until they both turn onto the same
|
||||
heading and are already touching.
|
||||
|
||||
Each of those rules is narrower than it first looks, and the narrowness is the
|
||||
point. Only civilians defer, or a patrol queues politely behind a bus and never
|
||||
reaches the road it was dispatched to. Only for cars going roughly the same way,
|
||||
or two meeting on a single track each wait forever for the other. And separation
|
||||
is same-direction only, since oncoming cars are already a lane apart — which on
|
||||
a narrow road is less than the separation radius, so pushing them apart undid
|
||||
the lane discipline that put them there.
|
||||
|
||||
**Armed vehicles carry a visible crew.** A patrol car used to be an empty box
|
||||
that occasionally emitted tracers. There is now a man standing up out of it, and
|
||||
his weapon comes up only once they have decided about you: across his lap
|
||||
otherwise, levelled and tracking when the car is hunting or when something of
|
||||
the other army is in range. That difference is the point — being recognised was
|
||||
a meter filling up in the corner of the screen, and it is now something you can
|
||||
watch happen across a street.
|
||||
|
||||
**Your own side is standing in your own streets.** Liberated territory read as
|
||||
empty — no patrols by design, and nobody else either — so the safest part of the
|
||||
map was also the deadest, and coming home felt like arriving nowhere rather than
|
||||
arriving somewhere held. Militia mirror the ambient patrols the other way up:
|
||||
fourteen at home, five in contested ground, none past that, because a friendly
|
||||
face on the frontier would be a safety net the setting is not supposed to have.
|
||||
Crossing the map reads 14, 14, 11, 5, 0 against enemies 0, 0, 3, 4, 4 — who is
|
||||
standing in the street is a more legible signal of whose ground you are on than
|
||||
the palette is.
|
||||
|
||||
They are placed by checking who holds the ground they would *stand* on rather
|
||||
than the band the player is in, since near a border those are different answers,
|
||||
and they leave when the line moves over them.
|
||||
|
||||
**Your own side has vehicles too.** Convoys route the network like traffic, so
|
||||
they inherit lane discipline and following distance for nothing, but only
|
||||
between junctions your side holds: 4 at home, 2 in contested ground, none past
|
||||
it. Fewer than the men on foot, because a truck is scarcer than a rifle and
|
||||
because friendly traffic outnumbering the civilian kind would turn every road
|
||||
into a convoy. They carry the same crew the enemy cars do.
|
||||
|
||||
### The war you are driving through
|
||||
|
||||
Skirmishes break out between the two armies on contested and occupied ground,
|
||||
@ -447,23 +272,6 @@ Buildings stop bullets, which is what makes cover worth anything. Hits are route
|
||||
through the same wear model as a crash, so being shot costs you ceiling too — it
|
||||
is permanent in the same way everything else is.
|
||||
|
||||
**Nothing shoots or walks through a wall.** Rounds always died against
|
||||
buildings, but nothing checked before pulling a trigger, so units emptied
|
||||
magazines into the concrete between them and a target they could not see — what
|
||||
you saw from the car was tracers coming out of a solid wall. Firing now needs
|
||||
the same line of sight the round does. Free movement wrote straight into x and z
|
||||
too, so pedestrians, militia, fighters and fleeing civilians all walked through
|
||||
buildings; they now take the same detour the hunters do, and spawns retry rather
|
||||
than dropping somebody inside a wall on the first frame.
|
||||
|
||||
Underneath both was one bug worth recording. The building lookup grid stepped
|
||||
the world coordinate by the cell size from `x-reach` to `x+reach`, but a
|
||||
building's reach is about 7 m and a cell is 24 m — so the loop always took
|
||||
exactly one step, every building registered a single cell, and it silently
|
||||
vanished from the other three it straddled. Points in those cells read as open
|
||||
ground for bullets, for line of sight and for people. Six units in ninety were
|
||||
standing inside buildings; now none are.
|
||||
|
||||
**The enemy does not shoot at you by default.** You are an undercover driver, not
|
||||
a target. They open up only past the line *and* on a road they have already
|
||||
checkpointed — that is, a road you personally made notorious.
|
||||
@ -486,20 +294,6 @@ patrols — a chase you could outrun by passing more of them would be no chase.
|
||||
This is also the *only* thing that makes the enemy shoot at you: being a target
|
||||
is a state, not a property of the road you are on.
|
||||
|
||||
**They drive differently when they are chasing.** A patrol on its rounds does
|
||||
14 m/s and a healthy car does 29, so being hunted used to mean holding `W` until
|
||||
the 95 m sight radius did the rest. Chasing cars wind up to 21 m/s, which sits
|
||||
deliberately between the two: faster than anyone averages through a grid of
|
||||
junctions and buildings, slower than the car's top end on a clear run. So a long
|
||||
trunk straight is an escape and the lanes are not — which puts the road
|
||||
hierarchy under real pressure for the first time. The fast conspicuous road is
|
||||
the one you want when they are behind you, and it is the one they will be
|
||||
looking on next time.
|
||||
|
||||
They also go **round** buildings rather than through them. Line of sight was
|
||||
already blocked by them and movement was not, which made "break line of sight
|
||||
and change direction" advice the world did not honour.
|
||||
|
||||
**Getting away** takes one of two things:
|
||||
|
||||
- **Break line of sight for fourteen seconds.** They drive to where they last
|
||||
@ -507,10 +301,7 @@ and change direction" advice the world did not honour.
|
||||
the move. Any glimpse resets the clock.
|
||||
- **Let your own side occupy them.** Hunters are patrols, not fanatics: an
|
||||
insurgent squad on their doorstep is a better use of their time. Enough of
|
||||
them peeling off ends the chase outright. This is why the militia matter
|
||||
mechanically as well as atmospherically — running for your own lines works
|
||||
because the closer you get to home the more likely they peel off. The rule
|
||||
always existed; there was simply nobody around to trigger it.
|
||||
them peeling off ends the chase outright.
|
||||
|
||||
Escaping does not hand your cover straight back. Suspicion drops to 0.6 and
|
||||
decays slowly from there — they know there is a car worth looking for, and you
|
||||
@ -523,9 +314,6 @@ corner. The panel has `−` / `+` buttons for the car's condition, because
|
||||
"drive a wrecked car for a minute" is something you want to do *while driving*
|
||||
rather than while typing into a console.
|
||||
|
||||
The panel also has `−¤` / `+¤`, so the workshop and the board can be exercised
|
||||
without first driving eight missions to afford looking at them.
|
||||
|
||||
The handle also steps the simulation by hand, renders on demand, and writes
|
||||
frames to `shots/` via a dev-only endpoint — which is how this build gets looked
|
||||
at at all when the tab is not compositing. `__dbtl.state` exposes the live sim.
|
||||
@ -619,11 +407,9 @@ running diagonally, so area is nowhere near linear in depth.
|
||||
**You are never told which band you are in by the world itself.** Crossing a
|
||||
border eases the fog, the light and the palette: your own ground is open and
|
||||
green, contested goes flat and hazy, occupied turns dusty and closes in, the
|
||||
frontier is cold and short-sighted. On top of the light, the population: home
|
||||
ground is thick with your own people and thins to nobody past the line. The HUD
|
||||
names the band only under `?debug`, for the same reason the heat numbers are
|
||||
behind it — Phase 3's gate is telling them apart *without* being told, and you
|
||||
cannot judge that with the answer on screen.
|
||||
frontier is cold and short-sighted. The HUD names the band for now, but that
|
||||
line is debug scaffolding for the same reason the heat numbers are — Phase 3's
|
||||
gate is telling them apart *without* being told.
|
||||
|
||||
## The map
|
||||
|
||||
@ -631,15 +417,6 @@ A sketch map, top right, drawn from `Intel` rather than from the world:
|
||||
|
||||
- **The road network's shape is always visible**, as faint dashed lines. You are
|
||||
an operative with a map, not an amnesiac.
|
||||
- **Your own ground starts filled in.** A fresh campaign knows the pocket its
|
||||
side holds — the cells, the roads through them, and the junctions as places
|
||||
you have been. You are a local with a car; the war is the part you would not
|
||||
know. It also fixes the board, which splits its offers into targets you have
|
||||
visited and targets you have not: with nothing visited every offer came up
|
||||
"new", so the known-against-new comparison the mission design turns on was not
|
||||
on screen until you had wandered around for a while. None of it survives the
|
||||
line moving — the map still only remembers who held a place when you last
|
||||
stood in it.
|
||||
- **Roads you have actually seen** are drawn solid and coloured by the heat you
|
||||
remember on them — which is what you last saw, not what is there now.
|
||||
- **Ground you have driven through** is filled in, tinted by who held it *when
|
||||
@ -668,14 +445,13 @@ ever opening a browser.
|
||||
```
|
||||
src/
|
||||
sim/ pure model, no engine imports:
|
||||
world, roads, heat — the map, its districts and its memory
|
||||
world, roads, heat — the map and its memory
|
||||
radio, opportunities — texture: chatter and field work
|
||||
units, combat — traffic, patrols, soldiers, rounds
|
||||
save — the campaign as JSON
|
||||
regions — the front line and who holds what
|
||||
intel, routing, quests — what the player knows and is asked to do
|
||||
bases, car — where missions come from, and decline
|
||||
kia — what happens when the decline runs out
|
||||
physics/ Rapier world, raycast vehicle, input → wheel forces
|
||||
render/ three.js scene, roads, markers, chase camera
|
||||
core/ fixed-timestep loop, seeded RNG, keyboard
|
||||
@ -701,15 +477,16 @@ one place where "how broken the car is" turns into "how it drives".
|
||||
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in
|
||||
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
|
||||
not something tuned by feel. That tuning *is* Phase 0's verification gate.
|
||||
- **Wear rates are tuned against the economy** rather than by feel: a
|
||||
clean mission spends about a fifth of what it pays, and a hard crash costs
|
||||
roughly two clean missions. Those ratios are the dial, not the raw numbers.
|
||||
- **Wear rates are deliberately aggressive** so decline is visible in minutes
|
||||
rather than hours. Turn them down in `applyWear` once the curve reads right.
|
||||
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
|
||||
rate changes.
|
||||
- **Repairs are automatic on hand-in.** Money gets spent the moment you report
|
||||
back; the overhaul under `C` is the only deliberate spend. The brief wants
|
||||
repair to be a physical, on-foot act with scavenged parts; that is still
|
||||
deferred.
|
||||
- **Nothing is persisted** across reloads — condition, heat, intel and completed
|
||||
runs all reset. IndexedDB comes with the campaign layer, and `sim/intel.ts`
|
||||
plus `sim/heat.ts` are the two things that most need it.
|
||||
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
|
||||
back. The brief wants repair to be a physical, on-foot act with scavenged
|
||||
parts; that is Phase 6.
|
||||
- **The board's route preview assumes the shortest path.** If you drive a
|
||||
different way, the intel you were shown described a route you did not take.
|
||||
`findRoute` already accepts a cost function, so a "safest route" preview is a
|
||||
@ -722,12 +499,11 @@ one place where "how broken the car is" turns into "how it drives".
|
||||
gameplay shape is right; the presentation wants berms, wire, or wreckage on the
|
||||
outer sections. They can also clip scenery placed on the verge — both are
|
||||
static bodies, so physics is unaffected, but it looks wrong up close.
|
||||
- **Hunters steer round buildings by trial rather than by pathfinding.** They
|
||||
try seven headings, widening either side of straight-on, and take the first
|
||||
that is clear. Good enough that corners work; it will not solve a cul-de-sac.
|
||||
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
|
||||
arriving at the checkpoint. The brief wants it readable from patrol density and
|
||||
wreckage before you commit — that needs the enemy presence Phase 5 brings.
|
||||
- **The heat HUD lines are debug scaffolding.** The brief is explicit that no
|
||||
numeric heat meter ships; they exist to tune the curve and should come out.
|
||||
- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which
|
||||
`rapier3d-compat` inlines as base64. Switching to the non-compat `@dimforge/rapier3d`
|
||||
package serves the WASM as a separate file (~570 KB gzipped, compiled in
|
||||
@ -771,39 +547,15 @@ and people you have no reason to interact with, and a war that is obviously not
|
||||
about you. If firefights read as encounters staged for the player, the spacing
|
||||
and spawn rules in `sim/units.ts` are the dial.
|
||||
|
||||
**Phase 10:** the world should hold up to being *looked at* rather than only
|
||||
driven through. Park somewhere busy and watch for a minute: traffic should
|
||||
queue and pass rather than interpenetrate, crews should be relaxed until they
|
||||
are not, and nothing should be standing in a wall. If any of that fails it is
|
||||
visible immediately, which is the point of putting it in the list.
|
||||
|
||||
**Phase 9:** three gates, and all of them are about whether anything is at
|
||||
stake.
|
||||
|
||||
- You slow down for a corner you would previously have taken flat, because the
|
||||
car is worth protecting. One serious crash should be a disaster you drive home
|
||||
from and two should end the car; if that reads as too punishing, the dial is
|
||||
the coefficients in `applyWear` rather than `IMPACT_REFERENCE`, which is
|
||||
measured rather than chosen.
|
||||
- Money accumulates faster than you can spend it on repairs, and the overhaul
|
||||
becomes the thing you save for. If you never press `C`, `CEILING_PER_FUND` is
|
||||
too mean; if the ceiling stops mattering, it is too generous.
|
||||
- Being hunted makes you think about *where* rather than only about the
|
||||
throttle. If you still escape by holding `W` on any road at all, raise
|
||||
`HUNT_SPEED`; if the chase is unloseable, the answer is more militia near home
|
||||
rather than slower hunters, since being saved by your own side is the more
|
||||
interesting exit.
|
||||
- You can say where you are without the HUD, from the roofline and from who is
|
||||
standing in the street. Districts and militia are both aimed squarely at this;
|
||||
if it still takes reading a line of text, push `PALETTES` apart before
|
||||
touching anything else.
|
||||
|
||||
Still deferred: a deeper repair economy
|
||||
with scavenged parts and on-foot work, car identity and disguise feeding into
|
||||
heat, expanded combat, and something happening when the front line reaches a
|
||||
landmark — missions move it and nothing ever acknowledges that it moved. Nothing there should be pulled forward until the loop
|
||||
heat, and expanded combat. Nothing there should be pulled forward until the loop
|
||||
below it is proven.
|
||||
|
||||
The most useful thing before any of that is taking the debug lines out of the
|
||||
HUD. Several gates now depend on reading the world rather than a readout, and
|
||||
the scaffolding actively works against you when you sit down to judge them.
|
||||
|
||||
Watch for one failure mode in particular: if you find yourself taking whichever
|
||||
job is *nearest* and ignoring the intel line entirely, the board is decorative
|
||||
and the phase has not landed, whatever the reward numbers say.
|
||||
|
||||
@ -11,8 +11,6 @@ export interface DriverInput {
|
||||
toggleMute: boolean;
|
||||
/** True on the frame X was pressed: give up the job in hand. Consumed on read. */
|
||||
abandon: boolean;
|
||||
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
|
||||
overhaul: boolean;
|
||||
}
|
||||
|
||||
const KEYS = {
|
||||
@ -24,7 +22,6 @@ const KEYS = {
|
||||
respawn: ['KeyR'],
|
||||
mute: ['KeyM'],
|
||||
abandon: ['KeyX'],
|
||||
overhaul: ['KeyC'],
|
||||
} as const;
|
||||
|
||||
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
|
||||
@ -41,7 +38,6 @@ export function createInput(): {
|
||||
let pendingSelect: number | null = null;
|
||||
let pendingMute = false;
|
||||
let pendingAbandon = false;
|
||||
let pendingOverhaul = false;
|
||||
/** Called on the first real interaction, to satisfy autoplay policy. */
|
||||
let onFirstGesture: (() => void) | null = null;
|
||||
|
||||
@ -53,7 +49,6 @@ export function createInput(): {
|
||||
if (!down.has(e.code)) {
|
||||
if (e.code === 'KeyM') pendingMute = true;
|
||||
if (e.code === 'KeyX') pendingAbandon = true;
|
||||
if (e.code === 'KeyC') pendingOverhaul = true;
|
||||
const index = SELECT_KEYS.indexOf(e.code);
|
||||
if (index >= 0) pendingSelect = index + 1;
|
||||
}
|
||||
@ -68,7 +63,6 @@ export function createInput(): {
|
||||
pendingSelect = null;
|
||||
pendingMute = false;
|
||||
pendingAbandon = false;
|
||||
pendingOverhaul = false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onDown);
|
||||
@ -80,11 +74,9 @@ export function createInput(): {
|
||||
const select = pendingSelect;
|
||||
const toggleMute = pendingMute;
|
||||
const abandon = pendingAbandon;
|
||||
const overhaul = pendingOverhaul;
|
||||
pendingSelect = null;
|
||||
pendingMute = false;
|
||||
pendingAbandon = false;
|
||||
pendingOverhaul = false;
|
||||
return {
|
||||
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
|
||||
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
|
||||
@ -93,7 +85,6 @@ export function createInput(): {
|
||||
select,
|
||||
toggleMute,
|
||||
abandon,
|
||||
overhaul,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
42
src/debug.ts
42
src/debug.ts
@ -16,7 +16,6 @@ import type { SceneView } from './render/scene';
|
||||
import type { PhysicsWorld } from './physics/physics';
|
||||
import type { LoopHandlers } from './core/loop';
|
||||
import { STEP } from './core/loop';
|
||||
import { CURRENCY } from './ui/currency';
|
||||
|
||||
export interface CaptureOptions {
|
||||
/** Output width in pixels. Height follows 16:9. */
|
||||
@ -39,9 +38,6 @@ export interface DebugApi {
|
||||
teleport: (x: number, z: number) => void;
|
||||
condition: () => { level: { engine: number; tires: number; chassis: number } };
|
||||
setCondition: (level: number) => unknown;
|
||||
/** Reads and writes the money in hand, for trying the workshop out. */
|
||||
funds: () => number;
|
||||
setFunds: (amount: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,10 +47,7 @@ export interface DebugApi {
|
||||
* is a thing you want to do *while driving*, not while typing — so condition
|
||||
* gets buttons. Only ever mounted under `?debug=1`.
|
||||
*/
|
||||
function mountPanel(
|
||||
debug: { nudgeCondition: (by: number) => unknown; nudgeFunds: (by: number) => number },
|
||||
api: DebugApi,
|
||||
): void {
|
||||
function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: DebugApi): void {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'debug-panel';
|
||||
panel.style.cssText = `
|
||||
@ -65,20 +58,14 @@ function mountPanel(
|
||||
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
|
||||
`;
|
||||
|
||||
const reading = (width: number) => {
|
||||
const span = document.createElement('span');
|
||||
span.style.cssText = `min-width: ${width}px; color: #8fa3b5;`;
|
||||
return span;
|
||||
};
|
||||
const carReadout = reading(74);
|
||||
const fundsReadout = reading(66);
|
||||
const readout = document.createElement('span');
|
||||
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;';
|
||||
const refresh = () => {
|
||||
const { engine, tires, chassis } = api.condition().level;
|
||||
carReadout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
|
||||
fundsReadout.textContent = `${CURRENCY}${Math.round(api.funds())}`;
|
||||
readout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const button = (label: string, apply: () => void) => {
|
||||
const button = (label: string, by: number) => {
|
||||
const element = document.createElement('button');
|
||||
element.textContent = label;
|
||||
element.style.cssText = `
|
||||
@ -87,7 +74,7 @@ function mountPanel(
|
||||
padding: 3px 9px;
|
||||
`;
|
||||
element.addEventListener('click', () => {
|
||||
apply();
|
||||
debug.nudgeCondition(by);
|
||||
refresh();
|
||||
// Keep the keyboard on the game, or space starts pressing this button.
|
||||
element.blur();
|
||||
@ -96,14 +83,9 @@ function mountPanel(
|
||||
};
|
||||
|
||||
panel.append(document.createTextNode('debug'));
|
||||
button('−', () => debug.nudgeCondition(-0.15));
|
||||
button('+', () => debug.nudgeCondition(+0.15));
|
||||
panel.append(carReadout);
|
||||
// Money, so the workshop and the board can be exercised without first
|
||||
// driving eight missions to afford looking at them.
|
||||
button('−¤', () => debug.nudgeFunds(-50));
|
||||
button('+¤', () => debug.nudgeFunds(+50));
|
||||
panel.append(fundsReadout);
|
||||
button('−', -0.15);
|
||||
button('+', +0.15);
|
||||
panel.append(readout);
|
||||
document.body.append(panel);
|
||||
|
||||
refresh();
|
||||
@ -186,12 +168,6 @@ export function exposeDebug(api: DebugApi): void {
|
||||
const now = api.condition().level;
|
||||
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
|
||||
},
|
||||
/** Money in hand. */
|
||||
funds: api.funds,
|
||||
setFunds: api.setFunds,
|
||||
nudgeFunds(by: number) {
|
||||
return api.setFunds(api.funds() + by);
|
||||
},
|
||||
info: api.info,
|
||||
state: api.state,
|
||||
three: THREE,
|
||||
|
||||
223
src/main.ts
223
src/main.ts
@ -1,14 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { generateWorld } from './sim/world';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
overhaul,
|
||||
repair,
|
||||
surfaceFor,
|
||||
} from './sim/car';
|
||||
import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car';
|
||||
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
|
||||
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
|
||||
import { baseAt, placeBases } from './sim/bases';
|
||||
@ -61,19 +53,6 @@ import { createHud } from './ui/hud';
|
||||
import { createBoard } from './ui/board';
|
||||
import { createAudio } from './audio/audio';
|
||||
import { CAR, WHEELS } from './carSpec';
|
||||
import { CURRENCY } from './ui/currency';
|
||||
|
||||
/** What the wheel reads while nobody is holding it. */
|
||||
const DEAD_HANDS = {
|
||||
throttle: 0,
|
||||
steer: 0,
|
||||
handbrake: true,
|
||||
respawn: false,
|
||||
select: null,
|
||||
toggleMute: false,
|
||||
abandon: false,
|
||||
overhaul: false,
|
||||
} as const;
|
||||
|
||||
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
|
||||
export const DEBUG = new URLSearchParams(location.search).has('debug');
|
||||
@ -90,27 +69,6 @@ const RESET_HOLD_SECONDS = 5;
|
||||
* becomes a reroll button: park, dislike the route, drop it, take another.
|
||||
*/
|
||||
const ABANDON_LAND_LOSS = 12;
|
||||
/**
|
||||
* Seconds spent watching the wreck before you come round somewhere else.
|
||||
*
|
||||
* Long enough to register what happened and where it happened, short enough
|
||||
* that it does not become the thing you dread about dying.
|
||||
*/
|
||||
const KIA_HOLD = 5;
|
||||
/**
|
||||
* Radians per second the view turns while the camera climbs away from a wreck.
|
||||
* Slow: this is a drift across the scene, not an orbit around the car.
|
||||
*/
|
||||
const WAKE_ORBIT_RATE = 0.22;
|
||||
/**
|
||||
* Share of the death cam anyone keeps shooting for.
|
||||
*
|
||||
* The burst that killed you should finish — cutting the gunfire on the exact
|
||||
* frame the car dies looks like the sound broke. After that they stop, because
|
||||
* standing over a wreck emptying magazines into it reads as the enemy beating a
|
||||
* dead horse rather than as a firefight ending.
|
||||
*/
|
||||
const KIA_CEASE_FIRE = 0.25;
|
||||
|
||||
function resolveSeed(): number {
|
||||
const raw = new URLSearchParams(location.search).get('seed');
|
||||
@ -168,20 +126,14 @@ async function boot() {
|
||||
// lookup. A grid built once at boot beats scanning a thousand obstacles.
|
||||
const BUILDING_CELL = 24;
|
||||
const buildingGrid = new Map<string, typeof model.obstacles>();
|
||||
const cellOf = (v: number) => Math.floor(v / BUILDING_CELL);
|
||||
const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`;
|
||||
const cellKey = (x: number, z: number) =>
|
||||
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`;
|
||||
for (const o of model.obstacles) {
|
||||
if (o.kind !== 'block') continue;
|
||||
const reach = Math.hypot(o.width, o.depth) / 2;
|
||||
// Walk cell indices, not metres. Stepping the world coordinate by
|
||||
// BUILDING_CELL only ever took one step, because a building's reach (~7m)
|
||||
// is smaller than a cell (24m) — so every building registered exactly one
|
||||
// cell and silently vanished from the other three it straddled. Points in
|
||||
// those cells read as open ground: bullets flew through the wall, line of
|
||||
// sight saw through it, and people stood inside it.
|
||||
for (let cx = cellOf(o.x - reach); cx <= cellOf(o.x + reach); cx++) {
|
||||
for (let cz = cellOf(o.z - reach); cz <= cellOf(o.z + reach); cz++) {
|
||||
const key = `${cx},${cz}`;
|
||||
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) {
|
||||
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) {
|
||||
const key = cellKey(x, z);
|
||||
const list = buildingGrid.get(key) ?? [];
|
||||
list.push(o);
|
||||
buildingGrid.set(key, list);
|
||||
@ -254,10 +206,6 @@ async function boot() {
|
||||
let provocation = 0;
|
||||
/** Times the car has been written off underneath the player. */
|
||||
let kia = 0;
|
||||
/** Seconds left of watching the wreck, or 0 when alive. */
|
||||
let dying = 0;
|
||||
/** Where the camera has got to in its drift around it. */
|
||||
let deathAngle = 0;
|
||||
|
||||
const say = (text: string, seconds = 4) => {
|
||||
notice = text;
|
||||
@ -368,32 +316,7 @@ async function boot() {
|
||||
captureChassis();
|
||||
|
||||
/**
|
||||
* The moment the car came apart around you.
|
||||
*
|
||||
* The run does not end here — it ends `KIA_HOLD` seconds later. What happens
|
||||
* in between is nothing: you have no control, the camera pulls off the bumper
|
||||
* and drifts round the wreck, and the world carries on around it. Cutting
|
||||
* straight from the crash to a base three hundred metres away read as a bug
|
||||
* rather than as a death, and the one moment in the campaign worth actually
|
||||
* looking at was the one moment you were not allowed to see.
|
||||
*/
|
||||
const beginDeath = () => {
|
||||
dying = KIA_HOLD;
|
||||
deathAngle = 0;
|
||||
// Nobody is chasing a car that is no longer going anywhere, and being shot
|
||||
// at while you watch your own wreck is not a thing anyone needs to see.
|
||||
for (const unit of units.units) unit.hunting = false;
|
||||
pursuit.hunters.clear();
|
||||
pursuit.suspicion = 0;
|
||||
pursuit.alert = 'clear';
|
||||
pursuit.lastSeen = null;
|
||||
pursuit.unseenFor = 0;
|
||||
audio.impact(26000);
|
||||
say('That is the car finished.', KIA_HOLD);
|
||||
};
|
||||
|
||||
/**
|
||||
* Coming round somewhere else.
|
||||
* The car came apart around you.
|
||||
*
|
||||
* You wake up at whichever base was nearest, in a worse car than the one you
|
||||
* wrote off, with the job you were carrying written off along with it and the
|
||||
@ -401,7 +324,7 @@ async function boot() {
|
||||
* number — the front moving against you is something you find out about by
|
||||
* driving back out there.
|
||||
*/
|
||||
const finishDeath = () => {
|
||||
const killedInAction = () => {
|
||||
kia++;
|
||||
const at = physics.chassis.translation();
|
||||
const home = bases.reduce((best, b) =>
|
||||
@ -414,6 +337,14 @@ async function boot() {
|
||||
applyMissionImpact(front, -KIA_LAND_LOSS);
|
||||
condition = replacementCar(condition);
|
||||
|
||||
// Whoever was chasing is chasing a car that no longer exists.
|
||||
for (const unit of units.units) unit.hunting = false;
|
||||
pursuit.hunters.clear();
|
||||
pursuit.suspicion = 0;
|
||||
pursuit.alert = 'clear';
|
||||
pursuit.lastSeen = null;
|
||||
pursuit.unseenFor = 0;
|
||||
|
||||
physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true);
|
||||
physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
|
||||
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
@ -429,6 +360,7 @@ async function boot() {
|
||||
: `You woke up at ${home.name}. Somebody else is driving what is left of the car.`,
|
||||
9,
|
||||
);
|
||||
audio.impact(26000);
|
||||
persistence.checkpoint(elapsed, snapshot());
|
||||
};
|
||||
|
||||
@ -437,23 +369,7 @@ async function boot() {
|
||||
const handlers: LoopHandlers = {
|
||||
fixedUpdate(dt) {
|
||||
elapsed += dt;
|
||||
const raw = input.read();
|
||||
|
||||
// While the wreck is being looked at, the world carries on and the player
|
||||
// does not. Everything below still runs — traffic, the war, the front —
|
||||
// because a death cam over a frozen world is a screenshot, not a moment.
|
||||
// What stops is the driver.
|
||||
const dead = dying > 0;
|
||||
const deathProgress = dead ? 1 - dying / KIA_HOLD : 0;
|
||||
if (dead) {
|
||||
dying -= dt;
|
||||
deathAngle += WAKE_ORBIT_RATE * dt;
|
||||
if (dying <= 0) {
|
||||
dying = 0;
|
||||
finishDeath();
|
||||
}
|
||||
}
|
||||
const cmd = dead ? DEAD_HANDS : raw;
|
||||
const cmd = input.read();
|
||||
|
||||
// Tap R to get unstuck; hold it to wipe the campaign and start over.
|
||||
// A reset has to be hard to do by accident — it throws away every mile
|
||||
@ -546,48 +462,9 @@ async function boot() {
|
||||
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
|
||||
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf);
|
||||
|
||||
// A wreck is not parked at a base, whatever the coordinates say.
|
||||
const base = dead ? null : baseAt(bases, at.x, at.z);
|
||||
const base = baseAt(bases, at.x, at.z);
|
||||
const stopped = Math.abs(speed) < 2.5;
|
||||
|
||||
// --- Overhaul: buying back what the car is capable of ---
|
||||
// Offered whether or not there is a job in hand, since a workshop does
|
||||
// not care what you are carrying. Deliberately a bad rate next to an
|
||||
// ordinary repair — this rebuilds what the car *could* be, which is the
|
||||
// expensive thing you do once patching it up has stopped helping.
|
||||
if (base && stopped && cmd.overhaul) {
|
||||
if (!canOverhaul(condition)) {
|
||||
say('Nothing to rebuild. She is as good as she ever was.', 4);
|
||||
} else if (quests.funds < 1) {
|
||||
say(`Nothing to pay with. Bring something back first.`, 4);
|
||||
} else {
|
||||
const before = condition.ceiling;
|
||||
const done = overhaul(condition, quests.funds);
|
||||
const spent = quests.funds - done.unused;
|
||||
condition = done.condition;
|
||||
quests.funds = done.unused;
|
||||
const gained =
|
||||
condition.ceiling.engine +
|
||||
condition.ceiling.tires +
|
||||
condition.ceiling.chassis -
|
||||
(before.engine + before.tires + before.chassis);
|
||||
// A car that has turned a wheel is never *exactly* at factory, so the
|
||||
// sim will happily sell you a hundredth of a percent. Judge by what
|
||||
// the player can actually see rather than by what is left to buy.
|
||||
if (gained < 0.005) {
|
||||
say('Nothing worth stripping her down for. She is near enough new.', 4);
|
||||
} else {
|
||||
say(
|
||||
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
|
||||
`${(gained * 100).toFixed(0)}% more than she would have.`,
|
||||
7,
|
||||
);
|
||||
audio.ui('accept');
|
||||
persistence.checkpoint(elapsed, snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Quest board: only when parked at a base with nothing in hand ---
|
||||
if (base && stopped && !quests.active) {
|
||||
if (openBase !== base.nodeId) {
|
||||
@ -649,9 +526,7 @@ async function boot() {
|
||||
}
|
||||
|
||||
// --- Active mission ---
|
||||
// Not while you are unconscious: a wreck sitting on the target must not
|
||||
// quietly finish the job, and stepQuest only asks whether you are stopped.
|
||||
const quest = dead ? null : quests.active;
|
||||
const quest = quests.active;
|
||||
if (quest) {
|
||||
const target = nodeOf(quest.targetNode);
|
||||
const event = stepQuest(quests, {
|
||||
@ -691,13 +566,13 @@ async function boot() {
|
||||
// The line moves because of what the player did. They are not told by
|
||||
// how much — they find out by going back and looking.
|
||||
applyMissionImpact(front, event.impact);
|
||||
const outcome = repair(condition, quests.funds);
|
||||
const spent = quests.funds - outcome.unused;
|
||||
const outcome = repair(condition, quests.parts);
|
||||
const spent = quests.parts - outcome.unused;
|
||||
condition = outcome.condition;
|
||||
quests.funds = outcome.unused;
|
||||
quests.parts = outcome.unused;
|
||||
say(
|
||||
spent > 0.5
|
||||
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
|
||||
spent > 0.001
|
||||
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
|
||||
: 'Handed in. Nothing left worth repairing.',
|
||||
);
|
||||
// Checkpoint: a finished run is the most expensive thing to lose.
|
||||
@ -714,17 +589,15 @@ async function boot() {
|
||||
control,
|
||||
carX: at.x,
|
||||
carZ: at.z,
|
||||
// Nothing turns up for a man who is not going anywhere, and a wreck
|
||||
// must not collect one by virtue of having stopped moving.
|
||||
onMission: dead || quests.active !== null,
|
||||
onMission: quests.active !== null,
|
||||
},
|
||||
model.roads,
|
||||
chatterRng,
|
||||
stopped && !dead,
|
||||
stopped,
|
||||
);
|
||||
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
|
||||
else if (found?.kind === 'helped') {
|
||||
quests.funds += found.opportunity.reward;
|
||||
quests.parts += found.opportunity.reward;
|
||||
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
|
||||
say(found.opportunity.done, 7);
|
||||
persistence.checkpoint(elapsed, snapshot());
|
||||
@ -811,21 +684,14 @@ async function boot() {
|
||||
}
|
||||
|
||||
// --- Being noticed ---
|
||||
// Not while you are dead. Enemies standing over the wreck would otherwise
|
||||
// build suspicion on it all over again and re-recognise a car that is not
|
||||
// going anywhere, which is how they ended up shooting at it for five
|
||||
// solid seconds.
|
||||
const noticed = dead
|
||||
? []
|
||||
: stepPursuit(pursuit, {
|
||||
for (const event of stepPursuit(pursuit, {
|
||||
dt,
|
||||
player: { x: at.x, z: at.z },
|
||||
control,
|
||||
units,
|
||||
canSee,
|
||||
provocation,
|
||||
});
|
||||
for (const event of noticed) {
|
||||
})) {
|
||||
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
|
||||
if (event.kind === 'lost') say('You lost them.', 5);
|
||||
if (event.kind === 'distracted' && event.remaining > 0) {
|
||||
@ -837,9 +703,7 @@ async function boot() {
|
||||
// --- Shooting ---
|
||||
// They shoot at you because they have recognised you, not because of the
|
||||
// road you happen to be on. Being hunted *is* the state of being a target.
|
||||
// While dying, exposure runs off the clock rather than off the meter: the
|
||||
// shooting tails off shortly after the car does.
|
||||
const exposed = dead ? deathProgress < KIA_CEASE_FIRE : pursuit.alert === 'hunted';
|
||||
const exposed = pursuit.alert === 'hunted';
|
||||
|
||||
const shooting = stepCombat(
|
||||
combat,
|
||||
@ -849,9 +713,6 @@ async function boot() {
|
||||
player: { x: at.x, z: at.z },
|
||||
playerExposed: exposed,
|
||||
blocked: insideBuilding,
|
||||
// Nobody fires at what they cannot see. Rounds already stopped at
|
||||
// walls; without this the muzzle flashes still came from inside them.
|
||||
canSee,
|
||||
},
|
||||
condition,
|
||||
chatterRng,
|
||||
@ -868,7 +729,7 @@ async function boot() {
|
||||
// --- The end of a car ---
|
||||
// Checked here rather than beside `applyWear`, because being shot at
|
||||
// damages the chassis too and either route has to be able to finish you.
|
||||
if (!dead && isWrittenOff(condition)) beginDeath();
|
||||
if (isWrittenOff(condition)) killedInAction();
|
||||
|
||||
// --- Radio ---
|
||||
// Front drift is measured, not narrated: the chatter about the enemy
|
||||
@ -945,16 +806,11 @@ async function boot() {
|
||||
mesh.quaternion.set(q.x, q.y, q.z, q.w);
|
||||
}
|
||||
|
||||
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
|
||||
unitView.update(units, combat.rounds, elapsed);
|
||||
view.followSun();
|
||||
view.setTone(control, frameDt);
|
||||
markers.update(elapsed);
|
||||
updateCamera(
|
||||
view,
|
||||
frameDt,
|
||||
physics.vehicle.currentVehicleSpeed(),
|
||||
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
|
||||
);
|
||||
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
|
||||
view.renderer.render(view.scene, view.camera);
|
||||
|
||||
const quest = quests.active;
|
||||
@ -1008,7 +864,7 @@ async function boot() {
|
||||
completed: quests.completed,
|
||||
failed: quests.failed,
|
||||
kia,
|
||||
funds: quests.funds,
|
||||
parts: quests.parts,
|
||||
notice: elapsed < noticeUntil ? notice : '',
|
||||
resetProgress: resetHeld / RESET_HOLD_SECONDS,
|
||||
muted: audio.muted,
|
||||
@ -1044,12 +900,6 @@ async function boot() {
|
||||
};
|
||||
return condition;
|
||||
},
|
||||
funds: () => quests.funds,
|
||||
/** Set the money in hand. Nothing in the game hands out money like this. */
|
||||
setFunds(amount: number) {
|
||||
quests.funds = Math.max(0, amount);
|
||||
return quests.funds;
|
||||
},
|
||||
teleport(x, z) {
|
||||
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
|
||||
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
@ -1060,14 +910,13 @@ async function boot() {
|
||||
control,
|
||||
segment: currentSegment,
|
||||
onTarmac,
|
||||
funds: quests.funds,
|
||||
parts: quests.parts,
|
||||
completed: quests.completed,
|
||||
failed: quests.failed,
|
||||
kia,
|
||||
condition,
|
||||
front: front.boundaries,
|
||||
opportunity: opportunities.current,
|
||||
dying: +dying.toFixed(2),
|
||||
alert: pursuit.alert,
|
||||
suspicion: +pursuit.suspicion.toFixed(2),
|
||||
hunters: pursuit.hunters.size,
|
||||
|
||||
@ -21,7 +21,6 @@ const IDLE: DriverInput = {
|
||||
select: null,
|
||||
toggleMute: false,
|
||||
abandon: false,
|
||||
overhaul: false,
|
||||
};
|
||||
|
||||
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
|
||||
|
||||
@ -286,52 +286,10 @@ const camTarget = new THREE.Vector3();
|
||||
const camDesired = new THREE.Vector3();
|
||||
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
|
||||
|
||||
/**
|
||||
* Where the camera starts and ends up while pulling off a wreck.
|
||||
*
|
||||
* It leaves from just behind the car and climbs away until the whole scene is
|
||||
* below it: the street, whatever was hit, and whoever happens to be standing
|
||||
* around watching. The point is not the car — by then the car is a fact — it is
|
||||
* *where* this happened, which is the thing you have to drive back out to.
|
||||
*/
|
||||
const WAKE_FROM = { radius: 9, height: 3.5 };
|
||||
const WAKE_TO = { radius: 26, height: 62 };
|
||||
|
||||
/** Smoothstep. Eases both ends, so the pull-away neither jerks nor drifts. */
|
||||
const ease = (t: number) => t * t * (3 - 2 * t);
|
||||
|
||||
/**
|
||||
* Smoothed chase camera. Frame-rate independent damping.
|
||||
*
|
||||
* @param wake When set, the camera stops chasing and climbs slowly away from
|
||||
* the car, looking down at where the run ended. Used after the car is written
|
||||
* off: a hard cut from a crash to a base three hundred metres away reads as a
|
||||
* bug rather than as a death. `angle` turns the view a little as it goes so
|
||||
* the shot is not a straight retreat; `progress` runs 0 to 1 over the hold.
|
||||
*/
|
||||
export function updateCamera(
|
||||
view: SceneView,
|
||||
dt: number,
|
||||
speed: number,
|
||||
wake: { angle: number; progress: number } | null = null,
|
||||
): void {
|
||||
/** Smoothed chase camera. Frame-rate independent damping. */
|
||||
export function updateCamera(view: SceneView, dt: number, speed: number): void {
|
||||
const { camera, car } = view;
|
||||
|
||||
if (wake) {
|
||||
const t = ease(Math.min(1, Math.max(0, wake.progress)));
|
||||
const radius = WAKE_FROM.radius + (WAKE_TO.radius - WAKE_FROM.radius) * t;
|
||||
const height = WAKE_FROM.height + (WAKE_TO.height - WAKE_FROM.height) * t;
|
||||
camDesired
|
||||
.set(Math.sin(wake.angle) * radius, height, Math.cos(wake.angle) * radius)
|
||||
.add(car.position);
|
||||
// Barely damped: the easing above already shapes the move, and heavy
|
||||
// smoothing on top of it turns the climb into a lag.
|
||||
camera.position.lerp(camDesired, 1 - Math.exp(-6 * dt));
|
||||
camTarget.copy(car.position);
|
||||
camera.lookAt(camTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
camDesired.copy(CHASE_OFFSET);
|
||||
// Pull back a little at speed for a sense of pace.
|
||||
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Round } from '../sim/combat';
|
||||
import { nearestHostile, type Unit, type UnitState } from '../sim/units';
|
||||
import type { Unit, UnitState } from '../sim/units';
|
||||
|
||||
/**
|
||||
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
|
||||
@ -28,23 +28,6 @@ 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;
|
||||
@ -60,9 +43,6 @@ 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),
|
||||
@ -88,32 +68,6 @@ 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),
|
||||
@ -122,13 +76,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 towerGuns = new THREE.InstancedMesh(
|
||||
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, crew, guns, tracers, towers, towerGuns]) {
|
||||
for (const mesh of [cars, people, tracers, towers, guns]) {
|
||||
mesh.frustumCulled = false;
|
||||
scene.add(mesh);
|
||||
}
|
||||
@ -139,10 +93,9 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
};
|
||||
|
||||
return {
|
||||
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
|
||||
update(units: UnitState, rounds: Round[], elapsed: number) {
|
||||
let carCount = 0;
|
||||
let personCount = 0;
|
||||
let crewCount = 0;
|
||||
|
||||
for (const unit of units.units) {
|
||||
const mesh = unit.kind === 'car' ? cars : people;
|
||||
@ -156,61 +109,14 @@ 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;
|
||||
@ -233,13 +139,13 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
} else {
|
||||
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
|
||||
}
|
||||
towerGuns.setMatrixAt(towerCount, scratch);
|
||||
guns.setMatrixAt(towerCount, scratch);
|
||||
towerCount++;
|
||||
}
|
||||
park(towers, towerCount);
|
||||
park(towerGuns, towerCount);
|
||||
park(guns, towerCount);
|
||||
towers.instanceMatrix.needsUpdate = true;
|
||||
towerGuns.instanceMatrix.needsUpdate = true;
|
||||
guns.instanceMatrix.needsUpdate = true;
|
||||
|
||||
// --- Tracers ---
|
||||
let roundCount = 0;
|
||||
|
||||
125
src/sim/car.ts
125
src/sim/car.ts
@ -1,15 +1,9 @@
|
||||
/**
|
||||
* Car condition and what it does to handling. Pure — no engine imports.
|
||||
*
|
||||
* Design pillar this serves: "Decline, not reset." Every subsystem carries a
|
||||
* *ceiling*, and ordinary repair brings a car back up to what it is still
|
||||
* capable of, which is never quite what it was.
|
||||
*
|
||||
* The ceiling used to fall and nothing else — the only number in the game that
|
||||
* moved one way, with no counterplay at any price. In practice that read less
|
||||
* as a pillar than as a punishment you could not answer. `overhaul` is the
|
||||
* answer: decline is now something you can fight rather than only absorb, and
|
||||
* what keeps it meaningful is the exchange rate rather than the impossibility.
|
||||
* Design pillar this serves: "Decline, not reset." Repairs exist, but every
|
||||
* subsystem carries a *ceiling* that only ever falls. Patching a car brings it
|
||||
* back up to what it is still capable of, which is never quite what it was.
|
||||
*/
|
||||
|
||||
export interface Subsystems {
|
||||
@ -42,17 +36,6 @@ export interface Handling {
|
||||
steeringPull: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Money that buys one full point of a subsystem's condition.
|
||||
*
|
||||
* The sim thinks in condition, which runs 0..1; the player thinks in what is in
|
||||
* their pocket. Fixing the exchange rate here lets every balance number stay in
|
||||
* the units it is actually about, and keeps the scale of the currency a purely
|
||||
* presentational decision — ¤34 for a supply run reads as a payment, where 0.34
|
||||
* read as a fraction of something unnamed.
|
||||
*/
|
||||
export const FUNDS_PER_CONDITION = 100;
|
||||
|
||||
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
|
||||
|
||||
export const freshCondition = (): CarCondition => ({
|
||||
@ -140,13 +123,11 @@ const PERMANENT_SHARE = 0.3;
|
||||
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25
|
||||
* units of impact, which through the coefficients below wrote off the chassis —
|
||||
* and 37% of its ceiling — in one hit, on a car whose whole economy pays about
|
||||
* ¤30 a mission. One crash ended the campaign.
|
||||
* 0.3 parts a mission. One crash ended the campaign.
|
||||
*
|
||||
* At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by
|
||||
* the coefficients below, and they are deliberately brutal: a proper head-on
|
||||
* takes about four fifths of the chassis. Hitting a building at ninety should
|
||||
* very nearly be the end of the car, and the first calibration here — which
|
||||
* left it at 65% and driveable — made a serious crash into an inconvenience.
|
||||
* At 1.6e7 the same crash reads as 0.156 impact: about 15% of the chassis and a
|
||||
* permanent 4.7% off its ceiling. Expensive, survivable, and still the most
|
||||
* costly thing you can do to the car.
|
||||
*/
|
||||
const IMPACT_REFERENCE = 1.6e7;
|
||||
|
||||
@ -158,38 +139,34 @@ const IMPACT_REFERENCE = 1.6e7;
|
||||
* has no natural bound. Without a cap the tail of a bad landing is worth more
|
||||
* than the landing. This is what stops a single frame from writing off a part.
|
||||
*
|
||||
* Set above even a bad head-on so it stays a backstop rather than the number
|
||||
* that actually decides what a collision costs. It exists for the pathological
|
||||
* case — a car wedged between two colliders reporting forces in the billions —
|
||||
* not for the crash the player just had.
|
||||
* Set well above the ordinary crash so it stays a backstop rather than the
|
||||
* number that actually decides what a collision costs.
|
||||
*/
|
||||
const MAX_DAMAGE_PER_STEP = 0.85;
|
||||
const MAX_DAMAGE_PER_STEP = 0.25;
|
||||
|
||||
/**
|
||||
* Returns a new condition.
|
||||
*
|
||||
* Baseline wear is deliberately quicker than real life so decline is legible
|
||||
* within a session, but it has to stay inside what the economy can pay
|
||||
* within a session, but it has to stay inside what the parts economy can pay
|
||||
* for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean
|
||||
* kilometres — no crashes at all — took them from new to ruined, while a
|
||||
* two-kilometre round trip paid about ¤30 against 0.24 of condition
|
||||
* two-kilometre round trip paid about 0.3 parts against 0.24 of condition
|
||||
* spent. Driving perfectly barely broke even and one knock put you permanently
|
||||
* behind, which is a decline with no way to arrest it rather than a decision
|
||||
* about how hard to push the car.
|
||||
*
|
||||
* At a quarter of that a typical mission spends about 0.06 condition against
|
||||
* ~¤30 earned, so roughly two clean runs pay for one bad crash. That is
|
||||
* ~0.3 parts earned, so roughly two clean runs pay for one bad crash. That is
|
||||
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
|
||||
* not enough to ignore.
|
||||
*/
|
||||
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
||||
const impact = w.impactForce / IMPACT_REFERENCE;
|
||||
const damage: Subsystems = {
|
||||
// Ordered by what a collision actually ruins: the shell takes the worst of
|
||||
// it, the tyres and suspension a good share, the engine least of all.
|
||||
engine: w.throttle * w.dt * 1.5e-4 + impact * 1.6,
|
||||
tires: w.distance * 2e-5 + impact * 2.5,
|
||||
chassis: impact * 5.0,
|
||||
engine: w.throttle * w.dt * 1.5e-4 + impact * 0.32,
|
||||
tires: w.distance * 2e-5 + impact * 0.5,
|
||||
chassis: impact * 1.0,
|
||||
};
|
||||
|
||||
const level = {} as Subsystems;
|
||||
@ -203,12 +180,12 @@ export function applyWear(c: CarCondition, w: WearInput): CarCondition {
|
||||
}
|
||||
|
||||
/**
|
||||
* Spends money on the worst subsystem first, never above its ceiling.
|
||||
* Spends parts on the worst subsystem first, never above its ceiling.
|
||||
* Returns the new condition and whatever could not be used.
|
||||
*/
|
||||
export function repair(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
|
||||
export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
|
||||
const level = { ...c.level };
|
||||
let remaining = funds / FUNDS_PER_CONDITION;
|
||||
let remaining = parts;
|
||||
|
||||
// Worst-first: a car with one ruined subsystem drives worse than one that is
|
||||
// evenly tired, so that is where a scarce part belongs.
|
||||
@ -224,71 +201,9 @@ export function repair(c: CarCondition, funds: number): { condition: CarConditio
|
||||
remaining -= spend;
|
||||
}
|
||||
|
||||
return { condition: { level, ceiling: c.ceiling }, unused: remaining * FUNDS_PER_CONDITION };
|
||||
return { condition: { level, ceiling: c.ceiling }, unused: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling bought back per unit of currency spent on an overhaul.
|
||||
*
|
||||
* Deliberately a bad rate next to an ordinary repair, which buys a full point
|
||||
* of condition per part. Rebuilding what a subsystem is *capable* of is not
|
||||
* patching it up; it is the expensive thing you do when patching has stopped
|
||||
* helping.
|
||||
*
|
||||
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
|
||||
* undo, or seven missions' pay.
|
||||
*/
|
||||
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
|
||||
|
||||
/**
|
||||
* Spends parts on raising the ceiling itself, worst subsystem first.
|
||||
*
|
||||
* The ceiling used to be a pure one-way ratchet: the only number in the game
|
||||
* that could move in one direction, with no counterplay at any price. That
|
||||
* reads less as a design pillar than as a punishment you cannot answer — you
|
||||
* watch the car get permanently worse and there is nothing to *do* about it.
|
||||
*
|
||||
* So decline is now something you can fight rather than only absorb, and what
|
||||
* makes it still mean something is the exchange rate. This does not touch the
|
||||
* current level at all: an overhaul raises what the car could be, and it is
|
||||
* ordinary repair that then fills it in. Early on every part goes to repairs
|
||||
* and there is never a surplus; it is only once the ceiling is what is actually
|
||||
* holding you back that parts start piling up with nowhere else to go.
|
||||
*/
|
||||
export function overhaul(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
|
||||
const ceiling = { ...c.ceiling };
|
||||
let remaining = funds;
|
||||
|
||||
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
|
||||
const worst = [...SUBSYSTEMS]
|
||||
.filter((p) => ceiling[p] < 1 - OVERHAUL_EPSILON)
|
||||
.sort((a, b) => ceiling[a] - ceiling[b])[0];
|
||||
if (!worst) break;
|
||||
|
||||
const room = 1 - ceiling[worst];
|
||||
const needed = room / CEILING_PER_FUND;
|
||||
// Snap when the spend covers the whole gap, rather than landing a
|
||||
// rounding error short of factory and reporting the car as still rebuildable.
|
||||
ceiling[worst] =
|
||||
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_FUND);
|
||||
remaining -= Math.min(needed, remaining);
|
||||
}
|
||||
|
||||
// Levels are untouched, and still cannot sit above their ceiling — which they
|
||||
// now never do, since every ceiling here only went up.
|
||||
return { condition: { level: { ...c.level }, ceiling }, unused: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerance for "as good as new". Wide enough that accumulated float error
|
||||
* never leaves a car reporting itself rebuildable when nothing is left to buy.
|
||||
*/
|
||||
const OVERHAUL_EPSILON = 1e-6;
|
||||
|
||||
/** True when there is nothing an overhaul could buy: the car is as good as new. */
|
||||
export const canOverhaul = (c: CarCondition): boolean =>
|
||||
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);
|
||||
|
||||
/** Rough single number for the HUD and for deciding when a car is finished. */
|
||||
export const overallCondition = (c: CarCondition): number =>
|
||||
(c.level.engine + c.level.tires + c.level.chassis) / 3;
|
||||
|
||||
@ -73,16 +73,6 @@ export interface CombatStep {
|
||||
playerExposed: boolean;
|
||||
/** Blocks a round: buildings stop bullets. */
|
||||
blocked: (x: number, z: number) => boolean;
|
||||
/**
|
||||
* Can one point see another? Buildings block it.
|
||||
*
|
||||
* Rounds already died against walls, but nothing checked before pulling the
|
||||
* trigger, so units happily emptied magazines into the building between them
|
||||
* and a target they could not possibly see. What you saw from the car was
|
||||
* tracers appearing out of solid concrete. Firing is a decision, and it needs
|
||||
* the same information the round does.
|
||||
*/
|
||||
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
|
||||
}
|
||||
|
||||
function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void {
|
||||
@ -131,7 +121,7 @@ export function stepCombat(
|
||||
if (unit.role === 'pedestrian' || unit.role === 'traffic') continue;
|
||||
|
||||
const target = nearestHostile(units, unit, RANGE[unit.kind]);
|
||||
if (target && step.canSee(unit, target)) {
|
||||
if (target) {
|
||||
fire(state, unit, target, rng);
|
||||
continue;
|
||||
}
|
||||
@ -142,8 +132,7 @@ export function stepCombat(
|
||||
unit.faction === 'enemy' &&
|
||||
step.playerExposed &&
|
||||
(unit.role === 'garrison' || unit.role === 'patrol') &&
|
||||
Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind] &&
|
||||
step.canSee(unit, step.player)
|
||||
Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind]
|
||||
) {
|
||||
fire(state, unit, step.player, rng);
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ describe('what a death costs', () => {
|
||||
expect(quests.active).toBeNull();
|
||||
expect(quests.completed).toBe(0);
|
||||
expect(quests.failed).toBe(1);
|
||||
expect(quests.funds).toBe(0);
|
||||
expect(quests.parts).toBe(0);
|
||||
});
|
||||
|
||||
it('is a setback, not a campaign reversal', () => {
|
||||
|
||||
@ -19,7 +19,7 @@ export interface Opportunity {
|
||||
kind: OpportunityKind;
|
||||
x: number;
|
||||
z: number;
|
||||
/** Money paid for stopping. */
|
||||
/** Parts paid for stopping. */
|
||||
reward: number;
|
||||
/** Whether helping shifts the front at all. */
|
||||
impact: number;
|
||||
@ -59,7 +59,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
||||
'They can walk from here. They did not ask your name, which was kind of them.',
|
||||
'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.',
|
||||
],
|
||||
reward: 6,
|
||||
reward: 0.06,
|
||||
impact: 3,
|
||||
},
|
||||
contact: {
|
||||
@ -71,7 +71,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
||||
'A package changes hands. Neither of you says anything worth repeating.',
|
||||
'They pass you a name and a road number, then walk off before you can ask.',
|
||||
],
|
||||
reward: 14,
|
||||
reward: 0.14,
|
||||
impact: 9,
|
||||
},
|
||||
wreck: {
|
||||
@ -83,7 +83,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
|
||||
'You strip what will fit in the boot and leave the rest to rust.',
|
||||
'Two good parts and a lot of scrap. It will do.',
|
||||
],
|
||||
reward: 11,
|
||||
reward: 0.11,
|
||||
impact: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@ -208,7 +208,7 @@ describe('mission lifecycle', () => {
|
||||
expect(done?.kind).toBe('completed');
|
||||
expect(state.active).toBeNull();
|
||||
expect(state.completed).toBe(1);
|
||||
expect(state.funds).toBeGreaterThan(0);
|
||||
expect(state.parts).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {
|
||||
|
||||
@ -73,7 +73,7 @@ export interface Offer {
|
||||
novel: boolean;
|
||||
route: Route;
|
||||
intel: RouteIntel;
|
||||
/** Money paid on hand-in. */
|
||||
/** Parts awarded on hand-in. */
|
||||
reward: number;
|
||||
/**
|
||||
* How far this job pushes the front line, in metres. The brief asks for
|
||||
@ -97,8 +97,8 @@ export interface QuestState {
|
||||
completed: number;
|
||||
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */
|
||||
failed: number;
|
||||
/** Money in hand, spent on repairs and overhauls. */
|
||||
funds: number;
|
||||
/** Unspent repair parts. */
|
||||
parts: number;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ export const createQuests = (): QuestState => ({
|
||||
active: null,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
funds: 0,
|
||||
parts: 0,
|
||||
nextId: 1,
|
||||
});
|
||||
|
||||
@ -138,11 +138,11 @@ const CARGO_FRAGILITY = 1.4e-6;
|
||||
* known targets are never taken, too high.
|
||||
*/
|
||||
const NOVELTY_BONUS = 1.8;
|
||||
const PAY_PER_KM = 10;
|
||||
const PARTS_PER_KM = 0.1;
|
||||
|
||||
/** Exported so the novelty premium can be tested on like for like. */
|
||||
export function rewardFor(type: MissionType, route: Route, novel: boolean): number {
|
||||
const base = 8 + (route.length / 1000) * PAY_PER_KM;
|
||||
const base = 0.08 + (route.length / 1000) * PARTS_PER_KM;
|
||||
return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward;
|
||||
}
|
||||
|
||||
@ -232,7 +232,7 @@ export type QuestEvent =
|
||||
| { kind: 'arrived' }
|
||||
| { kind: 'worked'; type: MissionType }
|
||||
| { kind: 'cargoDamaged'; cargo: number }
|
||||
| { kind: 'completed'; paid: number; impact: number; cargo: number };
|
||||
| { kind: 'completed'; parts: number; impact: number; cargo: number };
|
||||
|
||||
/** Advances the active mission. Returns whatever just happened, if anything. */
|
||||
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
|
||||
@ -281,8 +281,8 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null
|
||||
const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1);
|
||||
state.active = null;
|
||||
state.completed++;
|
||||
state.funds += paid;
|
||||
return { kind: 'completed', paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
|
||||
state.parts += paid;
|
||||
return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
|
||||
}
|
||||
|
||||
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;
|
||||
|
||||
@ -29,7 +29,7 @@ function played(): Snapshot {
|
||||
applyMissionImpact(front, 18);
|
||||
quests.completed = 3;
|
||||
quests.failed = 1;
|
||||
quests.funds = 42;
|
||||
quests.parts = 0.42;
|
||||
quests.nextId = 7;
|
||||
|
||||
return {
|
||||
@ -78,7 +78,7 @@ describe('save round trip', () => {
|
||||
expect(restored.heat.level).toEqual(source.heat.level);
|
||||
expect(restored.quests.completed).toBe(3);
|
||||
expect(restored.quests.failed).toBe(1);
|
||||
expect(restored.quests.funds).toBeCloseTo(42, 9);
|
||||
expect(restored.quests.parts).toBeCloseTo(0.42, 9);
|
||||
// A campaign's history of written-off cars has to outlive the tab, or the
|
||||
// ratchet the KIA rule exists to drive resets every time you close it.
|
||||
expect(restored.kia).toBe(2);
|
||||
|
||||
@ -16,7 +16,7 @@ import type { Intel } from './intel';
|
||||
import type { Boundaries, Front } from './regions';
|
||||
import type { ActiveQuest, QuestState } from './quests';
|
||||
|
||||
export const SAVE_VERSION = 4;
|
||||
export const SAVE_VERSION = 3;
|
||||
|
||||
export interface CarSnapshot {
|
||||
position: [number, number, number];
|
||||
@ -47,7 +47,7 @@ export interface SaveData {
|
||||
active: ActiveQuest | null;
|
||||
completed: number;
|
||||
failed: number;
|
||||
funds: number;
|
||||
parts: number;
|
||||
nextId: number;
|
||||
};
|
||||
/** Times the car has been written off underneath the player. */
|
||||
@ -89,7 +89,7 @@ export function serialise(s: Snapshot): SaveData {
|
||||
active: s.quests.active,
|
||||
completed: s.quests.completed,
|
||||
failed: s.quests.failed,
|
||||
funds: s.quests.funds,
|
||||
parts: s.quests.parts,
|
||||
nextId: s.quests.nextId,
|
||||
},
|
||||
kia: s.kia,
|
||||
@ -146,7 +146,7 @@ export function apply(data: SaveData, into: Snapshot): void {
|
||||
into.quests.active = data.quests.active;
|
||||
into.quests.completed = data.quests.completed;
|
||||
into.quests.failed = data.quests.failed;
|
||||
into.quests.funds = data.quests.funds;
|
||||
into.quests.parts = data.quests.parts;
|
||||
into.quests.nextId = data.quests.nextId;
|
||||
into.kia = data.kia;
|
||||
}
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateWorld } from './world';
|
||||
import {
|
||||
applyWear,
|
||||
canOverhaul,
|
||||
FUNDS_PER_CONDITION,
|
||||
deriveHandling,
|
||||
freshCondition,
|
||||
overhaul,
|
||||
repair,
|
||||
SUBSYSTEMS,
|
||||
type CarCondition,
|
||||
} from './car';
|
||||
import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car';
|
||||
|
||||
describe('world generation', () => {
|
||||
it('is reproducible from a seed', () => {
|
||||
@ -56,61 +46,43 @@ describe('car condition', () => {
|
||||
*/
|
||||
const HARD_CRASH = 2.47e6;
|
||||
|
||||
it('very nearly ends the car in one head-on', () => {
|
||||
it('leaves a car driveable after one hard crash', () => {
|
||||
const c = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: HARD_CRASH,
|
||||
});
|
||||
// Hitting a building at ninety is meant to be close to the end of it. The
|
||||
// shell takes the worst, the tyres a good share, the engine least.
|
||||
expect(c.level.chassis).toBeLessThan(0.3);
|
||||
expect(c.level.chassis).toBeGreaterThan(0.1);
|
||||
expect(c.level.tires).toBeLessThan(c.level.engine);
|
||||
expect(c.level.engine).toBeLessThan(0.8);
|
||||
});
|
||||
|
||||
it('is survivable exactly once, which is what makes it worth avoiding', () => {
|
||||
// One is a disaster you drive home from. Two in a row is the end of the
|
||||
// car, and that is the whole shape of the risk.
|
||||
const one = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: HARD_CRASH,
|
||||
});
|
||||
expect(one.level.chassis).toBeGreaterThan(0);
|
||||
const two = applyWear(one, {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: HARD_CRASH,
|
||||
});
|
||||
expect(two.level.chassis).toBe(0);
|
||||
// Expensive — the worst single thing that can happen to the car — but the
|
||||
// campaign continues. The first pass wrote off the chassis in this one step.
|
||||
expect(c.level.chassis).toBeGreaterThan(0.7);
|
||||
expect(c.level.chassis).toBeLessThan(0.95);
|
||||
expect(c.ceiling.chassis).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('caps what a single step can take, however violent the contact', () => {
|
||||
// A car wedged between two colliders reports forces in the billions. The
|
||||
// cap is for that, not for the crash the player just had — so it has to sit
|
||||
// above even a bad head-on or it becomes the number deciding every impact.
|
||||
const pathological = applyWear(freshCondition(), {
|
||||
// A pileup reports contact forces without any natural bound; without a cap
|
||||
// one frame of a bad landing is worth more than the crash that caused it.
|
||||
const c = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 1,
|
||||
impactForce: 1e9,
|
||||
});
|
||||
for (const part of SUBSYSTEMS) {
|
||||
expect(pathological.level[part]).toBeGreaterThanOrEqual(0.14);
|
||||
expect(c.level[part]).toBeGreaterThanOrEqual(0.74);
|
||||
}
|
||||
// And it must not be what decides an ordinary head-on.
|
||||
const crash = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: HARD_CRASH,
|
||||
});
|
||||
expect(crash.level.chassis).toBeGreaterThan(pathological.level.chassis);
|
||||
|
||||
it('takes a sustained battering to write the car off', () => {
|
||||
let c = freshCondition();
|
||||
let crashes = 0;
|
||||
while (c.level.chassis > 0 && crashes < 100) {
|
||||
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: HARD_CRASH });
|
||||
crashes++;
|
||||
}
|
||||
// Enough hard crashes to be a story about the driver, not one bad corner.
|
||||
expect(crashes).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('lets a careful driver come out ahead of the parts they are paid', () => {
|
||||
@ -152,31 +124,30 @@ describe('repair', () => {
|
||||
const c = damaged();
|
||||
expect(c.ceiling.chassis).toBeLessThan(1);
|
||||
// No amount of parts can undo it.
|
||||
const restored = repair(c, 9999).condition;
|
||||
const restored = repair(c, 99).condition;
|
||||
expect(restored.level.chassis).toBeLessThan(1);
|
||||
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||
});
|
||||
|
||||
it('never lifts a subsystem above its ceiling', () => {
|
||||
const c = damaged();
|
||||
const restored = repair(c, 9999).condition;
|
||||
const restored = repair(c, 99).condition;
|
||||
for (const part of SUBSYSTEMS) {
|
||||
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it('hands back parts it could not use', () => {
|
||||
const { unused } = repair(freshCondition(), 500);
|
||||
expect(unused).toBeCloseTo(500, 6);
|
||||
const { unused } = repair(freshCondition(), 5);
|
||||
expect(unused).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
it('spends scarce money on the worst subsystem', () => {
|
||||
it('spends a scarce part on the worst subsystem', () => {
|
||||
const c: CarCondition = {
|
||||
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
|
||||
ceiling: { engine: 1, tires: 1, chassis: 1 },
|
||||
};
|
||||
// A tenth of a point of condition, in what the player actually carries.
|
||||
const restored = repair(c, 0.1 * FUNDS_PER_CONDITION).condition;
|
||||
const restored = repair(c, 0.1).condition;
|
||||
expect(restored.level.tires).toBeCloseTo(0.3, 6);
|
||||
expect(restored.level.engine).toBe(0.9);
|
||||
});
|
||||
@ -188,7 +159,7 @@ describe('repair', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
|
||||
}
|
||||
c = repair(c, 9999).condition;
|
||||
c = repair(c, 99).condition;
|
||||
// Fully repaired every cycle, and still worse off every cycle.
|
||||
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
|
||||
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
|
||||
@ -250,83 +221,3 @@ describe('districts and landmarks', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overhaul', () => {
|
||||
/** A car that has been through it: low ceilings, levels patched up to them. */
|
||||
const battered = () => {
|
||||
let c = freshCondition();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
|
||||
c = repair(c, 9999).condition;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
it('is the one thing that can push a ceiling back up', () => {
|
||||
const c = battered();
|
||||
expect(c.ceiling.chassis).toBeLessThan(0.9);
|
||||
const after = overhaul(c, 300).condition;
|
||||
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
|
||||
});
|
||||
|
||||
it('leaves the level alone — it buys capability, not condition', () => {
|
||||
const c = battered();
|
||||
const after = overhaul(c, 300).condition;
|
||||
expect(after.level).toEqual(c.level);
|
||||
// Which is what makes it a *second* spend: the room it opens up still has
|
||||
// to be filled by ordinary repair.
|
||||
expect(after.level.chassis).toBeLessThan(after.ceiling.chassis);
|
||||
});
|
||||
|
||||
it('works on the worst subsystem first', () => {
|
||||
const c: CarCondition = {
|
||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
|
||||
};
|
||||
const after = overhaul(c, 100).condition;
|
||||
expect(after.ceiling.tires).toBeGreaterThan(0.5);
|
||||
expect(after.ceiling.engine).toBe(0.9);
|
||||
});
|
||||
|
||||
it('is a worse rate than patching the car up, and deliberately so', () => {
|
||||
const c: CarCondition = {
|
||||
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 },
|
||||
};
|
||||
const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 50).condition;
|
||||
const rebuilt = overhaul(c, 50).condition;
|
||||
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5;
|
||||
const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
|
||||
expect(rebuiltGain).toBeLessThan(repairedGain);
|
||||
});
|
||||
|
||||
it('never goes past factory, and hands back what it could not use', () => {
|
||||
const { condition, unused } = overhaul(battered(), 9999);
|
||||
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
|
||||
expect(unused).toBeGreaterThan(0);
|
||||
expect(canOverhaul(condition)).toBe(false);
|
||||
});
|
||||
|
||||
it('costs a run of missions to undo one crash, not one and not fifty', () => {
|
||||
// The exchange rate is the whole design. Free and the ceiling means
|
||||
// nothing; unaffordable and it is the ratchet it was before.
|
||||
const crashed = applyWear(freshCondition(), {
|
||||
dt: 1 / 60,
|
||||
distance: 0.4,
|
||||
throttle: 0,
|
||||
impactForce: 2.47e6,
|
||||
});
|
||||
const MISSION_PAY = 30;
|
||||
let missions = 0;
|
||||
let c = crashed;
|
||||
while (canOverhaul(c) && missions < 200) {
|
||||
c = overhaul(c, MISSION_PAY).condition;
|
||||
missions++;
|
||||
}
|
||||
expect(1 - crashed.ceiling.chassis).toBeGreaterThan(0.15);
|
||||
// Putting the whole car back to factory after one bad crash: an evening's
|
||||
// work, not a weekend's and not a single job.
|
||||
expect(missions).toBeGreaterThan(6);
|
||||
expect(missions).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
@ -82,7 +82,7 @@ function runMission(type: MissionType, impactPerStep = 0) {
|
||||
speed: 0,
|
||||
impactForce: 0,
|
||||
});
|
||||
return { done, cargo, paid: state.funds };
|
||||
return { done, cargo, parts: state.parts };
|
||||
}
|
||||
|
||||
describe('carrying cargo home', () => {
|
||||
@ -96,13 +96,13 @@ describe('carrying cargo home', () => {
|
||||
const clean = runMission('retrieval');
|
||||
const rough = runMission('retrieval', 4e4);
|
||||
expect(rough.cargo).toBeLessThan(clean.cargo);
|
||||
expect(rough.paid).toBeLessThan(clean.paid);
|
||||
expect(rough.parts).toBeLessThan(clean.parts);
|
||||
});
|
||||
|
||||
it('leaves other mission types alone, however hard you crash', () => {
|
||||
const rough = runMission('supply', 8e4);
|
||||
expect(rough.cargo).toBe(1);
|
||||
expect(rough.paid).toBeCloseTo(runMission('supply').paid, 9);
|
||||
expect(rough.parts).toBeCloseTo(runMission('supply').parts, 9);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import { createHeat, createAreas } from './heat';
|
||||
import { freshCondition } from './car';
|
||||
import {
|
||||
AMBIENT_ALLIES,
|
||||
AMBIENT_ALLY_CARS,
|
||||
AMBIENT_PATROLS,
|
||||
createUnits,
|
||||
dispatchTo,
|
||||
@ -290,9 +289,8 @@ describe('a war going on regardless', () => {
|
||||
run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
|
||||
for (const fighter of state.units.filter((u) => u.role === 'fighter')) {
|
||||
const control = controlAt(front, fighter.x, fighter.z);
|
||||
// Spawned where the war is, and far enough past your line that closing
|
||||
// on each other cannot walk them back over it.
|
||||
expect(control).not.toBe('liberated');
|
||||
// Spawned where the war is — they may drift, but not from behind a line.
|
||||
expect(['contested', 'occupied']).toContain(control);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -337,7 +335,7 @@ describe('rounds in flight', () => {
|
||||
stepCombat(
|
||||
combat,
|
||||
units,
|
||||
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false, canSee: () => true },
|
||||
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false },
|
||||
freshCondition(),
|
||||
makeRng(1),
|
||||
);
|
||||
@ -355,7 +353,7 @@ describe('rounds in flight', () => {
|
||||
const result = stepCombat(
|
||||
combat,
|
||||
units,
|
||||
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true },
|
||||
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false },
|
||||
condition,
|
||||
rng,
|
||||
);
|
||||
@ -382,7 +380,6 @@ describe('rounds in flight', () => {
|
||||
playerExposed: false,
|
||||
// Walls between the player and each squad, so nothing has a line.
|
||||
blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22),
|
||||
canSee: () => true,
|
||||
},
|
||||
condition,
|
||||
makeRng(5),
|
||||
@ -417,7 +414,7 @@ describe('rounds in flight', () => {
|
||||
stepCombat(
|
||||
combat,
|
||||
units,
|
||||
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true },
|
||||
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false },
|
||||
freshCondition(),
|
||||
makeRng(9),
|
||||
);
|
||||
@ -450,7 +447,7 @@ describe('rounds in flight', () => {
|
||||
stepCombat(
|
||||
combat,
|
||||
units,
|
||||
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false, canSee: () => true },
|
||||
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false },
|
||||
freshCondition(),
|
||||
makeRng(9),
|
||||
);
|
||||
@ -614,7 +611,7 @@ describe('your own side, standing in your own streets', () => {
|
||||
// Just past the line, some of the ones you set off with are still behind
|
||||
// you in contested ground and legitimately still there.
|
||||
run(state, 60, { player: deepIn('occupied') }, makeRng(21));
|
||||
expect(militia()).toBeLessThanOrEqual(home / 2);
|
||||
expect(militia()).toBeLessThan(home / 2);
|
||||
|
||||
// Out on the frontier there is nobody at all. That is what past the line
|
||||
// means, and it is the one place a friendly face would be a safety net the
|
||||
@ -630,266 +627,3 @@ describe('your own side, standing in your own streets', () => {
|
||||
expect(populate('liberated').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('walls are real', () => {
|
||||
/** A slab across the middle of the world, with open ground either side. */
|
||||
const wall = (x: number, z: number) => Math.abs(x) < 40 && z > 30 && z < 60;
|
||||
|
||||
it('keeps people on foot out of the buildings', () => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: { x: 0, z: 0 }, blocked: wall }, makeRng(12));
|
||||
const onFoot = state.units.filter((u) => u.kind === 'soldier');
|
||||
expect(onFoot.length).toBeGreaterThan(5);
|
||||
for (const u of onFoot) expect(wall(u.x, u.z)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a chase cut through the middle of one', () => {
|
||||
const state = createUnits();
|
||||
const unit = dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7))!;
|
||||
unit.x = 0;
|
||||
unit.z = 0;
|
||||
unit.path = [];
|
||||
unit.hunting = true;
|
||||
for (let i = 0; i < 60 * 10; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 1 / 60,
|
||||
now: i / 60,
|
||||
player: { x: 0, z: 300 },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: { x: 0, z: 300 },
|
||||
blocked: wall,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(5),
|
||||
);
|
||||
expect(wall(unit.x, unit.z)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('nobody fires at what they cannot see', () => {
|
||||
/** Two hostiles in range of each other with something solid between them. */
|
||||
const facingOff = () => {
|
||||
const units = createUnits();
|
||||
const place = (faction: 'enemy' | 'insurgent', x: number) => {
|
||||
units.units.push({
|
||||
id: units.nextId++,
|
||||
kind: 'soldier',
|
||||
faction,
|
||||
role: 'fighter',
|
||||
x,
|
||||
z: 0,
|
||||
heading: 0,
|
||||
speed: 0,
|
||||
hp: 30,
|
||||
path: [],
|
||||
expires: 999,
|
||||
assigned: null,
|
||||
onStation: 0,
|
||||
cooldown: 0,
|
||||
elevation: 1.2,
|
||||
});
|
||||
};
|
||||
place('enemy', -20);
|
||||
place('insurgent', 20);
|
||||
return units;
|
||||
};
|
||||
|
||||
const shotsOver = (canSee: () => boolean) => {
|
||||
const combat = createCombat();
|
||||
const units = facingOff();
|
||||
let fired = 0;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const r = stepCombat(
|
||||
combat,
|
||||
units,
|
||||
{
|
||||
dt: 1 / 60,
|
||||
player: { x: 999, z: 999 },
|
||||
playerExposed: false,
|
||||
blocked: () => false,
|
||||
canSee,
|
||||
},
|
||||
freshCondition(),
|
||||
makeRng(9),
|
||||
);
|
||||
fired += r.fired.length;
|
||||
}
|
||||
return fired;
|
||||
};
|
||||
|
||||
it('holds fire through a wall, and opens up without one', () => {
|
||||
// The rounds always died against the building. What nothing checked was
|
||||
// whether to pull the trigger, so tracers appeared out of solid concrete.
|
||||
expect(shotsOver(() => false)).toBe(0);
|
||||
expect(shotsOver(() => true)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic that drives like traffic', () => {
|
||||
/** Where each car sits relative to the centre of the road it is on. */
|
||||
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
|
||||
// Signed offset from the segment's centreline, positive to the car's right.
|
||||
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
||||
const px = unit.x - seg.ax;
|
||||
const pz = unit.z - seg.az;
|
||||
const lateral = px * Math.cos(along) - pz * Math.sin(along);
|
||||
// Flip for cars travelling the other way down the same segment.
|
||||
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
|
||||
};
|
||||
|
||||
const traffic = () => {
|
||||
const state = createUnits();
|
||||
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(31));
|
||||
return state.units.filter((u) => u.role === 'traffic');
|
||||
};
|
||||
|
||||
it('keeps right rather than straddling the centreline', () => {
|
||||
// Sampled over four minutes rather than from one frozen frame: at any one
|
||||
// instant only a handful of cars are squarely mid-segment, and four cars is
|
||||
// not a measurement.
|
||||
const state = createUnits();
|
||||
let onTheRight = 0;
|
||||
let judged = 0;
|
||||
for (let i = 0; i < 2400; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 0.1,
|
||||
now: i * 0.1,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(31),
|
||||
);
|
||||
if (i % 10 !== 0) continue;
|
||||
for (const car of state.units) {
|
||||
if (car.role !== 'traffic' && car.role !== 'convoy') continue;
|
||||
const seg = world.roads.segments.find((s) => {
|
||||
const t =
|
||||
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
|
||||
// Well inside the segment: at a junction there is no side to be on.
|
||||
if (t < 0.25 || t > 0.75) return false;
|
||||
const cx = s.ax + (s.bx - s.ax) * t;
|
||||
const cz = s.az + (s.bz - s.az) * t;
|
||||
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
|
||||
});
|
||||
if (!seg) continue;
|
||||
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
|
||||
// Only cars travelling along this road, not crossing it.
|
||||
if (Math.abs(Math.cos(car.heading - along)) < 0.9) continue;
|
||||
judged++;
|
||||
if (sideOfRoad(car, seg) > 0) onTheRight++;
|
||||
}
|
||||
}
|
||||
expect(judged).toBeGreaterThan(200);
|
||||
expect(onTheRight / judged).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it('does not drive through the car in front', () => {
|
||||
const cars = traffic();
|
||||
for (const car of cars) {
|
||||
for (const other of cars) {
|
||||
if (other === car) continue;
|
||||
// Nose to nose. Cars passing in opposite directions are fine — they are
|
||||
// on opposite sides of the road — so only same-direction pairs count.
|
||||
if (Math.cos(other.heading - car.heading) < 0.5) continue;
|
||||
expect(Math.hypot(other.x - car.x, other.z - car.z)).toBeGreaterThan(1.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('turns the nose rather than snapping it', () => {
|
||||
const state = createUnits();
|
||||
let worst = 0;
|
||||
const before = new Map<number, number>();
|
||||
for (let i = 0; i < 60 * 90; i++) {
|
||||
stepUnits(
|
||||
state,
|
||||
{
|
||||
dt: 1 / 60,
|
||||
now: i / 60,
|
||||
player: { x: world.spawn.x, z: world.spawn.z },
|
||||
front,
|
||||
heatLevel: () => 'clear',
|
||||
decayHeat: () => {},
|
||||
decayArea: () => {},
|
||||
hunt: null,
|
||||
},
|
||||
world.roads,
|
||||
graph,
|
||||
makeRng(17),
|
||||
);
|
||||
for (const u of state.units) {
|
||||
if (u.role !== 'traffic') continue;
|
||||
const was = before.get(u.id);
|
||||
if (was !== undefined) {
|
||||
let delta = Math.abs(u.heading - was);
|
||||
if (delta > Math.PI) delta = Math.PI * 2 - delta;
|
||||
worst = Math.max(worst, delta);
|
||||
}
|
||||
before.set(u.id, u.heading);
|
||||
}
|
||||
}
|
||||
// A right-angle junction used to be one frame of instant pivot. Nothing
|
||||
// should now turn faster than a car can be steered.
|
||||
expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('your own side, with vehicles', () => {
|
||||
const deepIn = (control: 'liberated' | 'contested' | 'occupied') => {
|
||||
const wanted = { liberated: -140, contested: 40, occupied: 240 }[control];
|
||||
const depth = front.boundaries.liberated + wanted;
|
||||
return { x: front.axis.x * depth, z: front.axis.z * depth };
|
||||
};
|
||||
|
||||
const convoys = (control: 'liberated' | 'contested' | 'occupied') => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn(control) }, makeRng(8));
|
||||
return state.units.filter((u) => u.role === 'convoy');
|
||||
};
|
||||
|
||||
it('puts allied vehicles on ground your side holds', () => {
|
||||
expect(convoys('liberated').length).toBeGreaterThan(0);
|
||||
expect(convoys('liberated').length).toBeLessThanOrEqual(AMBIENT_ALLY_CARS.liberated);
|
||||
});
|
||||
|
||||
it('stops at the line, like everything else of yours', () => {
|
||||
expect(convoys('occupied').length).toBe(0);
|
||||
});
|
||||
|
||||
it('never routes one onto ground your side does not hold', () => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn('contested') }, makeRng(8));
|
||||
for (const u of state.units.filter((c) => c.role === 'convoy')) {
|
||||
const control = controlAt(front, u.x, u.z);
|
||||
expect(control === 'liberated' || control === 'contested').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries a crew that the enemy will shoot at', () => {
|
||||
// Insurgent faction, so nearestHostile treats them as a target and they
|
||||
// treat the enemy as one — a convoy is not scenery.
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deepIn('liberated') }, makeRng(8));
|
||||
for (const u of state.units.filter((c) => c.role === 'convoy')) {
|
||||
expect(u.faction).toBe('insurgent');
|
||||
expect(u.kind).toBe('car');
|
||||
// Rounds leave the man riding in it, not the bonnet.
|
||||
expect(u.elevation).toBeGreaterThan(1.2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
442
src/sim/units.ts
442
src/sim/units.ts
@ -14,7 +14,7 @@ import type { RoadNetwork, RoadSegment } from './roads';
|
||||
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads';
|
||||
import { findRoute, travelTime, type Graph } from './routing';
|
||||
import type { Control, Front } from './regions';
|
||||
import { controlAt, depthAt } from './regions';
|
||||
import { controlAt } from './regions';
|
||||
|
||||
export type Faction = 'enemy' | 'insurgent' | 'civilian';
|
||||
export type UnitKind = 'car' | 'soldier';
|
||||
@ -33,9 +33,7 @@ export type Role =
|
||||
/** Fighting the other army. */
|
||||
| 'fighter'
|
||||
/** Your own side, holding the ground it holds. Thick at home, thin at the line. */
|
||||
| 'militia'
|
||||
/** Your own side, with a vehicle and somewhere to be. */
|
||||
| 'convoy';
|
||||
| 'militia';
|
||||
|
||||
export interface Unit {
|
||||
id: number;
|
||||
@ -65,8 +63,6 @@ export interface Unit {
|
||||
hunting?: boolean;
|
||||
/** Current speed while chasing, wound up from a standing start. */
|
||||
chaseSpeed?: number;
|
||||
/** Junction most recently left, so the leg being driven is known. */
|
||||
lastNode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,12 +135,12 @@ const HUNT_SPEED = 21;
|
||||
/** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */
|
||||
const HUNT_ACCELERATION = 7;
|
||||
/**
|
||||
* Headings anything on foot or on wheels will try, in order, when the straight
|
||||
* line is blocked: dead ahead, then further and further round either side of
|
||||
* the obstruction. Alternating sides means they take whichever way round is
|
||||
* actually open rather than committing to a direction and grinding along a wall.
|
||||
* Headings a hunter will try, in order, when the straight line is blocked:
|
||||
* dead ahead, then further and further round either side of the obstruction.
|
||||
* Alternating sides means they take whichever way round is actually open
|
||||
* rather than committing to a direction and grinding along a wall.
|
||||
*/
|
||||
const DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
|
||||
const HUNT_DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
|
||||
/** Seconds a dispatched patrol works its road before leaving. */
|
||||
const PATROL_DURATION = 90;
|
||||
/** Heat removed per second by a patrol actually driving its assigned road. */
|
||||
@ -167,8 +163,6 @@ export const PATROL_AREA_DECAY = 0.0005;
|
||||
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
|
||||
/** Minimum gap between skirmishes breaking out. */
|
||||
const SKIRMISH_SPACING = 55;
|
||||
/** How far past your own line a fight has to start, so it stays past it. */
|
||||
const SKIRMISH_CLEARANCE = 70;
|
||||
|
||||
/**
|
||||
* Standing patrols the enemy keeps on the roads near the player, by whose
|
||||
@ -213,22 +207,6 @@ export const AMBIENT_ALLIES: Record<Control, number> = {
|
||||
frontier: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Your own side's vehicles, on the same gradient as the men on foot.
|
||||
*
|
||||
* Fewer than the militia, because a truck is a scarcer thing than a man with a
|
||||
* rifle, and because the roads have to stay legible: friendly traffic that
|
||||
* outnumbered the civilian kind would turn every road into a convoy. They exist
|
||||
* mostly so home ground has something moving on it that is *yours* — a road
|
||||
* with only civilians on it reads the same behind the lines as past them.
|
||||
*/
|
||||
export const AMBIENT_ALLY_CARS: Record<Control, number> = {
|
||||
liberated: 4,
|
||||
contested: 2,
|
||||
occupied: 0,
|
||||
frontier: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* How far from the player a militiaman is still worth simulating.
|
||||
*
|
||||
@ -268,205 +246,25 @@ function nearestNode(roads: RoadNetwork, x: number, z: number): number {
|
||||
return best.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a unit, going round whatever is in the way rather than through it.
|
||||
*
|
||||
* Every kind of free movement in this file used to write straight into x and z,
|
||||
* which meant pedestrians, militia and fighters all walked through walls — and
|
||||
* a world where people stand inside buildings is one where cover means nothing,
|
||||
* because nothing is really there. Returns the heading actually taken.
|
||||
*/
|
||||
function moveThrough(
|
||||
unit: Unit,
|
||||
heading: number,
|
||||
distance: number,
|
||||
blocked?: (x: number, z: number) => boolean,
|
||||
): number {
|
||||
for (const offset of DETOURS) {
|
||||
const tried = heading + offset;
|
||||
const x = unit.x + Math.sin(tried) * distance;
|
||||
const z = unit.z + Math.cos(tried) * distance;
|
||||
if (blocked?.(x, z)) continue;
|
||||
unit.x = x;
|
||||
unit.z = z;
|
||||
return tried;
|
||||
}
|
||||
// Boxed in on every heading. Stay put rather than clipping out of it.
|
||||
return heading;
|
||||
}
|
||||
|
||||
/** Range a fighter closes to before standing its ground. */
|
||||
const gapTooFar = (gap: number) => gap > 45;
|
||||
|
||||
/**
|
||||
* Which segment joins two junctions, built once per network and cached.
|
||||
*
|
||||
* Needed so a car can know how wide the road it is on actually is, and put
|
||||
* itself the right distance off the centreline. Scanning the segment list per
|
||||
* car per step would not be worth the lane it buys.
|
||||
*/
|
||||
const segmentIndexes = new WeakMap<RoadNetwork, Map<number, RoadSegment>>();
|
||||
function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment | undefined {
|
||||
let index = segmentIndexes.get(roads);
|
||||
if (!index) {
|
||||
index = new Map();
|
||||
for (const s of roads.segments) {
|
||||
index.set(s.a * 100000 + s.b, s);
|
||||
index.set(s.b * 100000 + s.a, s);
|
||||
}
|
||||
segmentIndexes.set(roads, index);
|
||||
}
|
||||
return index.get(a * 100000 + b);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far right of the centreline a vehicle sits, as a share of half-width.
|
||||
*
|
||||
* Traffic used to drive straight down the middle of every road in both
|
||||
* directions, which meant two cars meeting head-on occupied the same metre of
|
||||
* tarmac and passed through each other. Keeping right also gives the player a
|
||||
* side of the road to be on, which is most of what makes a road feel like a
|
||||
* road rather than a corridor.
|
||||
*/
|
||||
const LANE_SHARE = 0.45;
|
||||
/** How far ahead a driver looks along their lane when deciding where to point. */
|
||||
const LANE_LOOKAHEAD = 11;
|
||||
/** How quickly a driver can swing the nose round, radians per second. */
|
||||
const TURN_RATE = 2.2;
|
||||
/** Gap a driver keeps to whatever is in front, metres. */
|
||||
const FOLLOW_GAP = 9;
|
||||
/** How far ahead they look for it. */
|
||||
const FOLLOW_RANGE = 26;
|
||||
/** How wide a lane counts as "in front of me" rather than "beside me". */
|
||||
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 {
|
||||
let delta = to - from;
|
||||
while (delta > Math.PI) delta -= Math.PI * 2;
|
||||
while (delta < -Math.PI) delta += Math.PI * 2;
|
||||
return from + Math.max(-limit, Math.min(limit, delta));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance to the vehicle in front, or null if the road ahead is clear.
|
||||
*
|
||||
* Three restrictions, each of which exists to stop the traffic locking up:
|
||||
*
|
||||
* - A **cone**, not a radius. A car alongside is not in the way, and treating
|
||||
* it as though it were gridlocks every junction.
|
||||
* - Only cars going **roughly the same way**. You brake for the car in front,
|
||||
* not for the one coming the other way — lane discipline already handles
|
||||
* that, and on a single track two oncoming cars would otherwise each wait
|
||||
* for the other forever.
|
||||
* - Only **civilian traffic** yields. A patrol queueing politely behind a bus
|
||||
* never reaches the road it was sent to, and the whole escalation chain is
|
||||
* built on dispatched units actually arriving.
|
||||
*/
|
||||
function carAhead(state: UnitState, unit: Unit): number | null {
|
||||
const forwardX = Math.sin(unit.heading);
|
||||
const forwardZ = Math.cos(unit.heading);
|
||||
let nearest: number | null = null;
|
||||
for (const other of state.units) {
|
||||
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
|
||||
if (Math.cos(other.heading - unit.heading) < 0) continue;
|
||||
const dx = other.x - unit.x;
|
||||
const dz = other.z - unit.z;
|
||||
const ahead = dx * forwardX + dz * forwardZ;
|
||||
if (ahead <= 0 || ahead > FOLLOW_RANGE) continue;
|
||||
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) continue;
|
||||
if (nearest === null || ahead < nearest) nearest = ahead;
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps a unit along its path. Returns true when the path is exhausted.
|
||||
*
|
||||
* Three things beyond "move toward the next node": it aims at a point off to
|
||||
* the right of the centreline rather than at the junction itself, it eases the
|
||||
* nose round rather than snapping it, and it lifts off for whatever is in
|
||||
* front instead of driving through it.
|
||||
*/
|
||||
function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean {
|
||||
/** Steps a unit along its path. Returns true when the path is exhausted. */
|
||||
function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
|
||||
const next = unit.path[0];
|
||||
if (next === undefined) return true;
|
||||
|
||||
const node = nodeById(roads, next);
|
||||
const remaining = Math.hypot(node.x - unit.x, node.z - unit.z);
|
||||
const dx = node.x - unit.x;
|
||||
const dz = node.z - unit.z;
|
||||
const remaining = Math.hypot(dx, dz);
|
||||
|
||||
/*
|
||||
* Drive a line parallel to the centreline, a share of the road's own
|
||||
* half-width to the right of it — so a track gets a nudge and a trunk gets a
|
||||
* lane, and two cars meeting head-on are on opposite sides of the road.
|
||||
*
|
||||
* Built from the leg actually being driven, which is why units remember the
|
||||
* junction they last left. Deriving the axis from the car-to-node direction
|
||||
* instead is nearly right in the middle of a leg and degenerates completely
|
||||
* near either end of it, where the direction swings through ninety degrees
|
||||
* over a few metres — and a grid map with hundred-metre blocks means cars
|
||||
* spend most of their time near a junction.
|
||||
*/
|
||||
const previous = nodeById(roads, unit.lastNode ?? next);
|
||||
let dirX = node.x - previous.x;
|
||||
let dirZ = node.z - previous.z;
|
||||
const legLength = Math.hypot(dirX, dirZ);
|
||||
if (legLength < 1) {
|
||||
// No leg to speak of — first step out of a spawn, or a doubled-back path.
|
||||
dirX = (node.x - unit.x) / remaining;
|
||||
dirZ = (node.z - unit.z) / remaining;
|
||||
} else {
|
||||
dirX /= legLength;
|
||||
dirZ /= legLength;
|
||||
}
|
||||
// Right of the direction of travel.
|
||||
const rightX = dirZ;
|
||||
const rightZ = -dirX;
|
||||
|
||||
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
|
||||
const offset = halfWidth * LANE_SHARE;
|
||||
const laneX = previous.x + rightX * offset;
|
||||
const laneZ = previous.z + rightZ * offset;
|
||||
|
||||
/*
|
||||
* Arrived, if either the junction is close enough or the car has simply
|
||||
* driven past it.
|
||||
*
|
||||
* "Close enough" has to know about the lane, because the car is deliberately
|
||||
* not aiming at the junction: on a fifteen-metre trunk it passes three and a
|
||||
* half metres to one side of it. A fixed three-metre radius meant dispatched
|
||||
* units drove straight past their destination and never registered arriving,
|
||||
* so no patrol ever worked a road and no engineer ever poured any concrete.
|
||||
*/
|
||||
const beyond = (unit.x - node.x) * dirX + (unit.z - node.z) * dirZ;
|
||||
if (remaining < 3 + offset || beyond > -0.5) {
|
||||
unit.lastNode = next;
|
||||
if (remaining < 3) {
|
||||
unit.path.shift();
|
||||
return unit.path.length === 0;
|
||||
}
|
||||
|
||||
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
|
||||
// wherever the car currently projects onto it. Short lookahead weaves, long
|
||||
// lookahead never quite arrives.
|
||||
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
|
||||
const aimX = laneX + dirX * along;
|
||||
const aimZ = laneZ + dirZ * along;
|
||||
|
||||
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
|
||||
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
|
||||
|
||||
// Close on the car in front and lift off. Only civilians defer; anyone with
|
||||
// somewhere to be leans on the horn and keeps going.
|
||||
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
|
||||
const allowed =
|
||||
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
|
||||
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
|
||||
unit.x += Math.sin(unit.heading) * move;
|
||||
unit.z += Math.cos(unit.heading) * move;
|
||||
unit.heading = Math.atan2(dx, dz);
|
||||
const move = Math.min(remaining, unit.speed * dt);
|
||||
unit.x += (dx / remaining) * move;
|
||||
unit.z += (dz / remaining) * move;
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -509,9 +307,6 @@ function spawnTraffic(
|
||||
const to = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
const path = routeTo(graph, roads, from.id, to.id);
|
||||
if (path.length === 0) return;
|
||||
// Not on top of something already sitting there. Junctions are popular, and
|
||||
// two cars dropped onto the same one start the day inside each other.
|
||||
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return;
|
||||
|
||||
makeUnit(state, {
|
||||
kind: 'car',
|
||||
@ -531,21 +326,9 @@ function spawnTraffic(
|
||||
});
|
||||
}
|
||||
|
||||
function spawnPedestrian(
|
||||
state: UnitState,
|
||||
near: { x: number; z: number },
|
||||
rng: Rng,
|
||||
blocked?: (x: number, z: number) => boolean,
|
||||
): void {
|
||||
// Somewhere that is actually a street. Placing people by polar coordinates
|
||||
// alone stood a tenth of them inside a building on the first frame.
|
||||
let angle = rng() * Math.PI * 2;
|
||||
let radius = 60 + rng() * 200;
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
if (!blocked?.(near.x + Math.cos(angle) * radius, near.z + Math.sin(angle) * radius)) break;
|
||||
angle = rng() * Math.PI * 2;
|
||||
radius = 60 + rng() * 200;
|
||||
}
|
||||
function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void {
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const radius = 60 + rng() * 200;
|
||||
makeUnit(state, {
|
||||
kind: 'soldier',
|
||||
faction: 'civilian',
|
||||
@ -607,8 +390,7 @@ export function dispatchTo(
|
||||
assigned: segment.id,
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
// Shots leave the man in the passenger seat, not the bonnet.
|
||||
elevation: CREW_ELEVATION,
|
||||
elevation: 1,
|
||||
building: role === 'engineer' ? stage : undefined,
|
||||
});
|
||||
}
|
||||
@ -655,7 +437,7 @@ function spawnAmbientPatrol(
|
||||
// Start part-way through a sweep, so they are not all in step.
|
||||
onStation: rng() * 20,
|
||||
cooldown: 1,
|
||||
elevation: CREW_ELEVATION,
|
||||
elevation: 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@ -673,7 +455,6 @@ function spawnAlly(
|
||||
near: { x: number; z: number },
|
||||
front: Front,
|
||||
rng: Rng,
|
||||
blocked?: (x: number, z: number) => boolean,
|
||||
): boolean {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const angle = rng() * Math.PI * 2;
|
||||
@ -682,7 +463,6 @@ function spawnAlly(
|
||||
const z = near.z + Math.sin(angle) * radius;
|
||||
const control = controlAt(front, x, z);
|
||||
if (control !== 'liberated' && control !== 'contested') continue;
|
||||
if (blocked?.(x, z)) continue;
|
||||
|
||||
makeUnit(state, {
|
||||
kind: 'soldier',
|
||||
@ -705,56 +485,6 @@ function spawnAlly(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One of your own side's vehicles, routing across ground your side holds.
|
||||
*
|
||||
* Built on the traffic spawner rather than the militia one: it has somewhere to
|
||||
* be and drives the network to get there, which means it inherits lane
|
||||
* discipline and following distance for nothing.
|
||||
*/
|
||||
function spawnAllyCar(
|
||||
state: UnitState,
|
||||
roads: RoadNetwork,
|
||||
graph: Graph,
|
||||
near: { x: number; z: number },
|
||||
front: Front,
|
||||
rng: Rng,
|
||||
): boolean {
|
||||
const friendly = (n: { x: number; z: number }) => {
|
||||
const control = controlAt(front, n.x, n.z);
|
||||
return control === 'liberated' || control === 'contested';
|
||||
};
|
||||
const candidates = roads.nodes.filter((n) => {
|
||||
const gap = distance(n, near);
|
||||
return gap > 90 && gap < SIM_RADIUS && friendly(n);
|
||||
});
|
||||
if (candidates.length < 2) return false;
|
||||
|
||||
const from = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
const to = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return false;
|
||||
const path = routeTo(graph, roads, from.id, to.id);
|
||||
if (path.length === 0) return false;
|
||||
|
||||
makeUnit(state, {
|
||||
kind: 'car',
|
||||
faction: 'insurgent',
|
||||
role: 'convoy',
|
||||
x: from.x,
|
||||
z: from.z,
|
||||
heading: 0,
|
||||
speed: SPEED.car * (0.75 + rng() * 0.3),
|
||||
hp: UNIT_HP.car,
|
||||
path,
|
||||
expires: 500,
|
||||
assigned: null,
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
elevation: CREW_ELEVATION,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two squads run into each other. The player is not invited. */
|
||||
function spawnSkirmish(
|
||||
state: UnitState,
|
||||
@ -762,18 +492,13 @@ function spawnSkirmish(
|
||||
near: { x: number; z: number },
|
||||
front: Front,
|
||||
rng: Rng,
|
||||
blocked?: (x: number, z: number) => boolean,
|
||||
): boolean {
|
||||
const candidates = roads.nodes.filter((n) => {
|
||||
const d = distance(n, near);
|
||||
if (d < 150 || d > SIM_RADIUS) return false;
|
||||
const control = controlAt(front, n.x, n.z);
|
||||
// Fights happen where the war is, not behind either side's lines.
|
||||
if (control !== 'contested' && control !== 'occupied') return false;
|
||||
// And far enough past the line that closing on each other cannot walk them
|
||||
// over it. Fighters advance up to their standoff range, so a skirmish
|
||||
// spawned right on the boundary ends up being fought behind your own.
|
||||
return depthAt(front, n.x, n.z) > front.boundaries.liberated + SKIRMISH_CLEARANCE;
|
||||
return control === 'contested' || control === 'occupied';
|
||||
});
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
@ -787,20 +512,12 @@ function spawnSkirmish(
|
||||
] as const) {
|
||||
const count = 2 + Math.floor(rng() * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Scattered around the meeting point, but not inside the scenery.
|
||||
let fx = 0;
|
||||
let fz = 0;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
fx = at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16;
|
||||
fz = at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16;
|
||||
if (!blocked?.(fx, fz)) break;
|
||||
}
|
||||
makeUnit(state, {
|
||||
kind: 'soldier',
|
||||
faction,
|
||||
role: 'fighter',
|
||||
x: fx,
|
||||
z: fz,
|
||||
x: at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16,
|
||||
z: at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16,
|
||||
heading: angle + (side > 0 ? Math.PI : 0),
|
||||
speed: SPEED.soldier,
|
||||
hp: UNIT_HP.soldier,
|
||||
@ -869,7 +586,7 @@ export function stepUnits(
|
||||
spawnTraffic(state, roads, graph, player, rng);
|
||||
}
|
||||
if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) {
|
||||
spawnPedestrian(state, player, rng, step.blocked);
|
||||
spawnPedestrian(state, player, rng);
|
||||
}
|
||||
|
||||
// --- Enemy ground is patrolled because it is enemy ground ---
|
||||
@ -882,16 +599,12 @@ export function stepUnits(
|
||||
// --- And your own ground has your own people standing in it ---
|
||||
const allies = state.units.filter((u) => u.role === 'militia').length;
|
||||
if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) {
|
||||
spawnAlly(state, player, step.front, rng, step.blocked);
|
||||
}
|
||||
const allyCars = state.units.filter((u) => u.role === 'convoy').length;
|
||||
if (allyCars < AMBIENT_ALLY_CARS[here] && rng() < dt * 2) {
|
||||
spawnAllyCar(state, roads, graph, player, step.front, rng);
|
||||
spawnAlly(state, player, step.front, rng);
|
||||
}
|
||||
|
||||
// --- A war going on regardless of the player ---
|
||||
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
|
||||
if (spawnSkirmish(state, roads, player, step.front, rng, step.blocked)) {
|
||||
if (spawnSkirmish(state, roads, player, step.front, rng)) {
|
||||
state.lastSkirmishAt = step.now;
|
||||
events.skirmish = true;
|
||||
}
|
||||
@ -918,9 +631,21 @@ export function stepUnits(
|
||||
// Hold off a little rather than piling into the car; they want to shoot
|
||||
// at it, and a ram is the player's move, not theirs.
|
||||
if (gap > 12) {
|
||||
// Round the side of whatever is in the way, never through it: a chase
|
||||
// you cannot lose by turning a corner is not a chase, it is a timer.
|
||||
unit.heading = moveThrough(unit, wanted, Math.min(gap, unit.chaseSpeed * dt), step.blocked);
|
||||
const move = Math.min(gap, unit.chaseSpeed * dt);
|
||||
// Straight at the target if there is a way through, otherwise round the
|
||||
// side of whatever is in the way. Without this they drive through the
|
||||
// buildings, and a chase you cannot lose by turning a corner is not a
|
||||
// chase — it is a timer.
|
||||
for (const offset of HUNT_DETOURS) {
|
||||
const heading = wanted + offset;
|
||||
const x = unit.x + Math.sin(heading) * move;
|
||||
const z = unit.z + Math.cos(heading) * move;
|
||||
if (step.blocked?.(x, z)) continue;
|
||||
unit.x = x;
|
||||
unit.z = z;
|
||||
unit.heading = heading;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Being chased keeps a unit alive past its ordinary shift.
|
||||
unit.expires = Math.max(unit.expires, 30);
|
||||
@ -930,33 +655,20 @@ export function stepUnits(
|
||||
unit.chaseSpeed = undefined;
|
||||
|
||||
switch (unit.role) {
|
||||
case 'traffic':
|
||||
case 'convoy': {
|
||||
if (advance(unit, roads, state, dt)) {
|
||||
// Somewhere else to be. A convoy stays on ground its own side holds;
|
||||
// a taxi does not care.
|
||||
const pool =
|
||||
unit.role === 'convoy'
|
||||
? roads.nodes.filter((n) => {
|
||||
const control = controlAt(step.front, n.x, n.z);
|
||||
return control === 'liberated' || control === 'contested';
|
||||
})
|
||||
: roads.nodes;
|
||||
const to = pool[Math.floor(rng() * pool.length)];
|
||||
unit.path = to
|
||||
? routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id)
|
||||
: [];
|
||||
case 'traffic': {
|
||||
if (advance(unit, roads, dt)) {
|
||||
const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!;
|
||||
unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id);
|
||||
if (unit.path.length === 0) unit.expires = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pedestrian': {
|
||||
// A slow wander. People are not going anywhere in particular — but they
|
||||
// do go *round* the buildings, and take the turn as their new heading
|
||||
// so they walk along a wall rather than repeatedly into it.
|
||||
// A slow wander. People are not going anywhere in particular.
|
||||
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
|
||||
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
|
||||
unit.x += Math.sin(unit.heading) * unit.speed * dt;
|
||||
unit.z += Math.cos(unit.heading) * unit.speed * dt;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -968,7 +680,7 @@ export function stepUnits(
|
||||
break;
|
||||
}
|
||||
if (unit.path.length > 0) {
|
||||
advance(unit, roads, state, dt);
|
||||
advance(unit, roads, dt);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1050,10 +762,11 @@ export function stepUnits(
|
||||
// Close on the nearest enemy of the other army and stand your ground.
|
||||
const enemy = nearestHostile(state, unit, 140);
|
||||
if (enemy) {
|
||||
const wanted = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
|
||||
unit.heading = wanted;
|
||||
if (gapTooFar(distance(unit, enemy))) {
|
||||
unit.heading = moveThrough(unit, wanted, unit.speed * dt, step.blocked);
|
||||
const gap = distance(unit, enemy);
|
||||
unit.heading = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
|
||||
if (gap > 45) {
|
||||
unit.x += Math.sin(unit.heading) * unit.speed * dt;
|
||||
unit.z += Math.cos(unit.heading) * unit.speed * dt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -1063,7 +776,8 @@ export function stepUnits(
|
||||
// point of putting them there.
|
||||
if (unit.role !== 'militia') break;
|
||||
if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6;
|
||||
unit.heading = moveThrough(unit, unit.heading, unit.speed * 0.4 * dt, step.blocked);
|
||||
unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt;
|
||||
unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * dt;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1072,36 +786,6 @@ export function stepUnits(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nothing occupies the same metre of road as something else ---
|
||||
// Following distance handles a queue, but not a merge: two cars converging
|
||||
// on a junction from different roads are more than ninety degrees apart right
|
||||
// up until they both turn onto the same heading, by which point they are
|
||||
// already touching. A give-way rule would fix that and can deadlock; pushing
|
||||
// the overlap out always resolves, and at these speeds reads as two drivers
|
||||
// both easing over.
|
||||
for (let i = 0; i < state.units.length; i++) {
|
||||
const a = state.units[i]!;
|
||||
if (a.kind !== 'car') continue;
|
||||
for (let j = i + 1; j < state.units.length; j++) {
|
||||
const b = state.units[j]!;
|
||||
if (b.kind !== 'car') continue;
|
||||
// Only cars going roughly the same way. Two passing in opposite
|
||||
// directions are already a lane apart, which on a narrow road is less
|
||||
// than this radius — separating them too shoved both off their own side
|
||||
// and undid the lane discipline entirely.
|
||||
if (Math.cos(b.heading - a.heading) < 0) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dz = b.z - a.z;
|
||||
const gap = Math.hypot(dx, dz);
|
||||
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
|
||||
const push = (CAR_SEPARATION - gap) / 2;
|
||||
const nx = dx / gap;
|
||||
const nz = dz / gap;
|
||||
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
|
||||
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Civilians get out of the way of a firefight ---
|
||||
for (const unit of state.units) {
|
||||
if (unit.faction !== 'civilian') continue;
|
||||
@ -1110,7 +794,8 @@ export function stepUnits(
|
||||
);
|
||||
if (!danger) continue;
|
||||
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
|
||||
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
|
||||
unit.x += Math.sin(away) * unit.speed * 1.4 * dt;
|
||||
unit.z += Math.cos(away) * unit.speed * 1.4 * dt;
|
||||
}
|
||||
|
||||
// --- Checkpoints whose road has cooled off are abandoned ---
|
||||
@ -1131,13 +816,10 @@ export function stepUnits(
|
||||
// with a job to do, and driving deep into occupied ground leaves them behind
|
||||
// rather than towing a friendly crowd along the front.
|
||||
for (const unit of state.units) {
|
||||
if (unit.role !== 'militia' && unit.role !== 'convoy') continue;
|
||||
if (unit.role !== 'militia') continue;
|
||||
const control = controlAt(step.front, unit.x, unit.z);
|
||||
if (control !== 'liberated' && control !== 'contested') unit.expires = 0;
|
||||
// Vehicles get more rope than men on foot: they cover ground, and culling
|
||||
// one at walking-pace range would delete it halfway down its own route.
|
||||
const reach = unit.role === 'convoy' ? SIM_RADIUS : MILITIA_RADIUS;
|
||||
if (distance(unit, player) > reach) unit.expires = 0;
|
||||
if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0;
|
||||
}
|
||||
|
||||
// --- Retire the dead, the finished and the far away ---
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Base } from '../sim/bases';
|
||||
import type { HeatLevel } from '../sim/heat';
|
||||
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
|
||||
import { money } from './currency';
|
||||
|
||||
/**
|
||||
* What each job actually asks of you, in the words the people asking would use.
|
||||
@ -43,17 +42,6 @@ function describe(offer: Offer): string {
|
||||
return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workshop line, on every panel a base can show.
|
||||
*
|
||||
* Repairs happen by themselves on hand-in; this is the other thing a base is
|
||||
* for, and it is the only counterplay to a ceiling that otherwise only ever
|
||||
* falls. It has to be visible wherever you are standing at one, or nobody
|
||||
* discovers it exists.
|
||||
*/
|
||||
const OVERHAUL_FOOTER =
|
||||
' · <span class="key">[C]</span> overhaul — spend on what she is capable of';
|
||||
|
||||
export function createBoard() {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'board';
|
||||
@ -93,13 +81,13 @@ export function createBoard() {
|
||||
${MISSION_SHAPE[offer.type].label} —
|
||||
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
|
||||
· ${(offer.route.length / 1000).toFixed(1)} km
|
||||
· ${money(offer.reward)}
|
||||
· ${offer.reward.toFixed(2)} parts
|
||||
<div class="meta">${BRIEFING[offer.type]}</div>
|
||||
<div class="meta">${describe(offer)}</div>
|
||||
</li>`,
|
||||
)
|
||||
.join('')}</ol>
|
||||
<footer>press 1–${offers.length} to accept${OVERHAUL_FOOTER}</footer>`;
|
||||
<footer>press 1–${offers.length} to accept</footer>`;
|
||||
},
|
||||
|
||||
/**
|
||||
@ -118,7 +106,7 @@ export function createBoard() {
|
||||
<div class="meta">Nobody here will take it off your hands, and nobody
|
||||
here will hold the board open while you decide.</div>
|
||||
</li></ol>
|
||||
<footer><span class="key">[X]</span> give it up — we lose ground for it${OVERHAUL_FOOTER}</footer>`;
|
||||
<footer><span class="key">[X]</span> give it up — we lose ground for it</footer>`;
|
||||
},
|
||||
|
||||
hide() {
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
/**
|
||||
* What the insurgency pays in.
|
||||
*
|
||||
* `¤` is Unicode's generic currency sign: it looks like money and is not any
|
||||
* particular country's money, which is exactly right for a place whose banks
|
||||
* are somebody else's problem. Kept here rather than inlined so the whole game
|
||||
* agrees on it, and so it is one edit if it ever wants a name.
|
||||
*/
|
||||
export const CURRENCY = '¤';
|
||||
|
||||
/** Money as the player reads it. Whole units — nobody counts small change here. */
|
||||
export const money = (amount: number): string => `${CURRENCY}${Math.round(amount)}`;
|
||||
@ -1,5 +1,4 @@
|
||||
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
|
||||
import { money } from './currency';
|
||||
import type { HeatLevel } from '../sim/heat';
|
||||
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
|
||||
import type { Control } from '../sim/regions';
|
||||
@ -55,7 +54,7 @@ export interface HudModel {
|
||||
failed: number;
|
||||
/** Cars written off underneath the player. */
|
||||
kia: number;
|
||||
funds: number;
|
||||
parts: number;
|
||||
notice: string;
|
||||
/** 0..1 toward wiping the campaign, while R is held. */
|
||||
resetProgress: number;
|
||||
@ -142,7 +141,7 @@ export function createHud(seed: number, debug = false) {
|
||||
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
|
||||
`${(condition.level[part] * 100).toFixed(0)}%`,
|
||||
),
|
||||
`funds ${money(model.funds).padEnd(8)} runs ${model.completed}` +
|
||||
`parts ${model.parts.toFixed(2)} runs ${model.completed}` +
|
||||
(model.failed > 0 ? ` lost ${model.failed}` : '') +
|
||||
// Cars are counted, not lives. There is no stock of them to run out
|
||||
// of; the number is there so a long campaign reads as a history.
|
||||
@ -171,8 +170,8 @@ export function createHud(seed: number, debug = false) {
|
||||
`[debug] seed ${seed}`,
|
||||
]
|
||||
: []),
|
||||
'WASD drive · space handbrake · R respawn',
|
||||
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
`WASD drive · space handbrake · R respawn · X drop job at a base` +
|
||||
` · M sound ${model.muted ? 'off' : 'on'}`,
|
||||
'hold R to reset the campaign',
|
||||
];
|
||||
if (model.resetProgress > 0.06) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user