Heat: credit the verge to the road, and block the whole corridor
Driving alongside a road accrued no heat at all, so the dominant strategy was to never actually use one. Heat is now credited to any road within 12m of the centreline, tarmac or verge. The cutoff is hard rather than tapered: a taper leaves a gradient to optimise along, where sitting at 80% of the catchment costs 20% of the heat. A cliff edge means you either use the route or genuinely leave it. 12m is a balance, not a guess. It puts ~7.5m of verge past the kerb — two car widths, so hugging the shoulder gains nothing — while leaving 57% of the map off-route. At 20m two thirds of the map counts as on-route, heat becomes unavoidable everywhere, and choosing a different route stops meaning anything. Barricades now span the full corridor for the same reason. Stopping at the kerb left them free to round on the verge at speed. Junction heat is still deferred; a point near a junction is credited to the nearest segment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
506d07be5d
commit
475db8773c
34
README.md
34
README.md
@ -28,8 +28,8 @@ npm run build # typecheck + production bundle
|
|||||||
|
|
||||||
## Road heat
|
## Road heat
|
||||||
|
|
||||||
Every metre driven on a road adds heat to that segment; every road everywhere
|
Every metre driven **on or alongside** a road adds heat to that segment; every
|
||||||
sheds it slowly. Cross a threshold and the road escalates:
|
road everywhere sheds it slowly. Cross a threshold and the road escalates:
|
||||||
|
|
||||||
`Clear → Patrol → Barricade → Turret`
|
`Clear → Patrol → Barricade → Turret`
|
||||||
|
|
||||||
@ -41,7 +41,27 @@ Roughly four traversals take a road from clear to turret; an untouched road
|
|||||||
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
|
cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are
|
||||||
guesses meant to be tuned.
|
guesses meant to be tuned.
|
||||||
|
|
||||||
Three things are deliberate:
|
### The route corridor
|
||||||
|
|
||||||
|
Heat is credited to a road for anything within `ROUTE_CATCHMENT` (12 m) of its
|
||||||
|
centreline, not just the 9 m of tarmac. Otherwise the dominant strategy is to
|
||||||
|
drive *next to* every road and never accrue heat at all.
|
||||||
|
|
||||||
|
Two consequences follow from that, and both are deliberate:
|
||||||
|
|
||||||
|
- **The cutoff is hard, not a taper.** A taper leaves a gradient to optimise
|
||||||
|
along — sit at 80% of the catchment, take 20% of the heat. A cliff edge means
|
||||||
|
you either use the route or you actually leave it.
|
||||||
|
- **Barricades span the full corridor**, well past the kerb. A checkpoint that
|
||||||
|
stopped at the tarmac would be free to round on the verge at speed.
|
||||||
|
|
||||||
|
12 m is chosen, not arbitrary: it puts ~7.5 m of verge beyond the kerb (about
|
||||||
|
two car widths, so hugging the shoulder gains nothing) while leaving 57% of the
|
||||||
|
map genuinely off-route. Widening it backfires — at 20 m two thirds of the map
|
||||||
|
counts as on-route, heat becomes unavoidable, and "take a different route" stops
|
||||||
|
being a choice at all.
|
||||||
|
|
||||||
|
### Other deliberate choices
|
||||||
|
|
||||||
- **No AI yet.** The props are stationary hazards. Escalation currently means the
|
- **No AI yet.** The props are stationary hazards. Escalation currently means the
|
||||||
road gets slower and more expensive to get wrong, which is enough to test the
|
road gets slower and more expensive to get wrong, which is enough to test the
|
||||||
@ -97,6 +117,14 @@ one place where "how broken the car is" turns into "how it drives".
|
|||||||
rate changes.
|
rate changes.
|
||||||
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
|
- **Condition and heat are not yet persisted** across reloads. IndexedDB comes
|
||||||
with the campaign layer.
|
with the campaign layer.
|
||||||
|
- **Junctions have no heat of their own.** A point near a junction is credited to
|
||||||
|
whichever segment is nearest, so a heavily used crossroads never fortifies as a
|
||||||
|
crossroads. Known gap, deferred on purpose.
|
||||||
|
- **Barricades are visually crude.** Blocking the whole 24 m corridor means two
|
||||||
|
long concrete slabs, which reads more like a wall than a checkpoint. The
|
||||||
|
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.
|
||||||
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
|
- **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
|
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.
|
wreckage before you commit — that needs the enemy presence Phase 5 brings.
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@ -1,7 +1,7 @@
|
|||||||
import { generateWorld } from './sim/world';
|
import { generateWorld } from './sim/world';
|
||||||
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
import { applyWear, deriveHandling, freshCondition } from './sim/car';
|
||||||
import { createHeat, stepHeat } from './sim/heat';
|
import { createHeat, stepHeat } from './sim/heat';
|
||||||
import { segmentAt } from './sim/roads';
|
import { routeAt, segmentAt } from './sim/roads';
|
||||||
import { createHeatProps } from './heatProps';
|
import { createHeatProps } from './heatProps';
|
||||||
import { seedFromString } from './core/rng';
|
import { seedFromString } from './core/rng';
|
||||||
import { startLoop } from './core/loop';
|
import { startLoop } from './core/loop';
|
||||||
@ -34,7 +34,9 @@ async function boot() {
|
|||||||
let condition = freshCondition();
|
let condition = freshCondition();
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
let respawnLatch = false;
|
let respawnLatch = false;
|
||||||
|
/** The road being travelled along — includes the verge, not just the tarmac. */
|
||||||
let currentSegment: number | null = null;
|
let currentSegment: number | null = null;
|
||||||
|
let onTarmac = false;
|
||||||
|
|
||||||
document.getElementById('boot')?.remove();
|
document.getElementById('boot')?.remove();
|
||||||
|
|
||||||
@ -59,9 +61,10 @@ async function boot() {
|
|||||||
impactForce: physics.drainImpactForce(),
|
impactForce: physics.drainImpactForce(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Heat: the road under the wheels remembers being used.
|
// Heat: the road remembers being used — including being driven alongside.
|
||||||
const at = physics.chassis.translation();
|
const at = physics.chassis.translation();
|
||||||
currentSegment = segmentAt(model.roads, at.x, at.z)?.id ?? null;
|
currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null;
|
||||||
|
onTarmac = segmentAt(model.roads, at.x, at.z) !== null;
|
||||||
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
|
heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment);
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -98,6 +101,7 @@ async function boot() {
|
|||||||
view.renderer.render(view.scene, view.camera);
|
view.renderer.render(view.scene, view.camera);
|
||||||
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, {
|
||||||
segmentId: currentSegment,
|
segmentId: currentSegment,
|
||||||
|
onTarmac,
|
||||||
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
value: currentSegment === null ? 0 : heat.value[currentSegment]!,
|
||||||
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
level: currentSegment === null ? null : heat.level[currentSegment]!,
|
||||||
hottest: Math.max(...heat.value),
|
hottest: Math.max(...heat.value),
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { generateRoads, segmentAt, distanceToRoad, projectOntoSegment } from './roads';
|
import {
|
||||||
|
generateRoads,
|
||||||
|
segmentAt,
|
||||||
|
routeAt,
|
||||||
|
distanceToRoad,
|
||||||
|
pointOnSegment,
|
||||||
|
projectOntoSegment,
|
||||||
|
ROUTE_CATCHMENT,
|
||||||
|
} from './roads';
|
||||||
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
|
import { createHeat, levelFor, propsFor, stepHeat, type HeatState } from './heat';
|
||||||
import { generateWorld } from './world';
|
import { generateWorld } from './world';
|
||||||
|
|
||||||
@ -41,6 +49,51 @@ describe('road network', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('route catchment', () => {
|
||||||
|
const s = roads.segments[3]!;
|
||||||
|
const beside = (metres: number) => pointOnSegment(s, 0.5, metres);
|
||||||
|
|
||||||
|
it('counts the verge as using the road', () => {
|
||||||
|
const verge = beside(s.width / 2 + 2);
|
||||||
|
// Off the tarmac, but still unmistakably travelling that route.
|
||||||
|
expect(segmentAt(roads, verge.x, verge.z)).toBeNull();
|
||||||
|
expect(routeAt(roads, verge.x, verge.z)?.id).toBe(s.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the drive-alongside exploit right up to the cutoff', () => {
|
||||||
|
for (const offset of [s.width / 2 + 0.5, ROUTE_CATCHMENT * 0.7, ROUTE_CATCHMENT - 0.5]) {
|
||||||
|
const p = beside(offset);
|
||||||
|
expect(routeAt(roads, p.x, p.z)?.id).toBe(s.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops counting once you have genuinely left the route', () => {
|
||||||
|
const away = beside(ROUTE_CATCHMENT + 5);
|
||||||
|
expect(routeAt(roads, away.x, away.z)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accrues heat at the same rate on the verge as on the tarmac', () => {
|
||||||
|
const onRoad = createHeat(roads);
|
||||||
|
const onVerge = createHeat(roads);
|
||||||
|
const centre = pointOnSegment(s, 0.5, 0);
|
||||||
|
const verge = beside(s.width / 2 + 2);
|
||||||
|
for (let m = 0; m < 200; m++) {
|
||||||
|
stepHeat(onRoad, {
|
||||||
|
dt: 1 / 60,
|
||||||
|
segmentId: routeAt(roads, centre.x, centre.z)!.id,
|
||||||
|
distance: 1,
|
||||||
|
});
|
||||||
|
stepHeat(onVerge, {
|
||||||
|
dt: 1 / 60,
|
||||||
|
segmentId: routeAt(roads, verge.x, verge.z)!.id,
|
||||||
|
distance: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// No discount for hugging the shoulder — that is the whole point.
|
||||||
|
expect(onVerge.value[s.id]).toBeCloseTo(onRoad.value[s.id]!, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('world', () => {
|
describe('world', () => {
|
||||||
it('spawns the car on the network and keeps scenery off the tarmac', () => {
|
it('spawns the car on the network and keeps scenery off the tarmac', () => {
|
||||||
const world = generateWorld(5);
|
const world = generateWorld(5);
|
||||||
@ -131,17 +184,36 @@ describe('heat props', () => {
|
|||||||
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'turret'));
|
expect(propsFor(segment, 'turret')).toEqual(propsFor(segment, 'turret'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves a gap in the barricade rather than sealing the road', () => {
|
it('leaves a gap in the barricade rather than sealing the route', () => {
|
||||||
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
|
for (const seed of [0, 1, 2, 3, 4, 5, 6, 7]) {
|
||||||
const spanned = blocks.reduce((sum, b) => sum + b.width, 0);
|
const s = roads.segments[seed]!;
|
||||||
expect(spanned).toBeLessThan(segment.width - 3);
|
const blocks = propsFor(s, 'barricade')
|
||||||
|
.filter((p) => p.kind === 'barricade')
|
||||||
|
.sort((a, b) => a.lateral - b.lateral);
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
const gap = blocks[1]!.lateral - blocks[1]!.width / 2 - (blocks[0]!.lateral + blocks[0]!.width / 2);
|
||||||
|
// Wide enough for a 1.8m car, tight enough to force a lift off the throttle.
|
||||||
|
expect(gap).toBeGreaterThan(3.5);
|
||||||
|
expect(gap).toBeLessThan(5);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps patrols and barricades within the road, and towers beside it', () => {
|
it('blocks the whole route corridor, not just the tarmac', () => {
|
||||||
|
const blocks = propsFor(segment, 'barricade').filter((p) => p.kind === 'barricade');
|
||||||
|
// Driving round the end of a barricade must mean leaving the route entirely.
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const reach = Math.max(
|
||||||
|
...blocks.map((b) => side * b.lateral + b.width / 2),
|
||||||
|
);
|
||||||
|
expect(reach).toBeGreaterThanOrEqual(ROUTE_CATCHMENT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps patrols on the tarmac and towers off it', () => {
|
||||||
for (const prop of propsFor(segment, 'turret')) {
|
for (const prop of propsFor(segment, 'turret')) {
|
||||||
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
|
const { distance } = projectOntoSegment(segment, prop.x, prop.z);
|
||||||
|
if (prop.kind === 'patrol') expect(distance).toBeLessThan(segment.width / 2);
|
||||||
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
|
if (prop.kind === 'tower') expect(distance).toBeGreaterThan(segment.width / 2);
|
||||||
else expect(distance).toBeLessThan(segment.width / 2);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* escalation is meant to be read off what is physically sitting in the road.
|
* escalation is meant to be read off what is physically sitting in the road.
|
||||||
*/
|
*/
|
||||||
import { makeRng, randRange } from '../core/rng';
|
import { makeRng, randRange } from '../core/rng';
|
||||||
import { pointOnSegment, type RoadNetwork, type RoadSegment } from './roads';
|
import { pointOnSegment, ROUTE_CATCHMENT, type RoadNetwork, type RoadSegment } from './roads';
|
||||||
|
|
||||||
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
|
export const HEAT_LEVELS = ['clear', 'patrol', 'barricade', 'turret'] as const;
|
||||||
export type HeatLevel = (typeof HEAT_LEVELS)[number];
|
export type HeatLevel = (typeof HEAT_LEVELS)[number];
|
||||||
@ -30,6 +30,9 @@ const METRES_PER_HEAT = 330;
|
|||||||
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
|
/** Heat shed per second everywhere. A hot road cools in roughly four minutes. */
|
||||||
const DECAY_PER_SECOND = 0.004;
|
const DECAY_PER_SECOND = 0.004;
|
||||||
|
|
||||||
|
/** Half the width of the opening left in a barricade. The car is 1.8m wide. */
|
||||||
|
const GAP_HALF_WIDTH = 2.1;
|
||||||
|
|
||||||
export interface HeatState {
|
export interface HeatState {
|
||||||
/** Indexed by segment id. */
|
/** Indexed by segment id. */
|
||||||
value: number[];
|
value: number[];
|
||||||
@ -87,6 +90,8 @@ export interface HeatProp {
|
|||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
yaw: number;
|
yaw: number;
|
||||||
|
/** Offset from the road centreline, positive to the segment's left. */
|
||||||
|
lateral: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
@ -113,30 +118,56 @@ export function propsFor(segment: RoadSegment, level: HeatLevel): HeatProp[] {
|
|||||||
// Patrol: parked on the verge. Narrows the road, does not block it.
|
// Patrol: parked on the verge. Narrows the road, does not block it.
|
||||||
const patrolT = randRange(rng, 0.3, 0.7);
|
const patrolT = randRange(rng, 0.3, 0.7);
|
||||||
const patrolSide = rng() < 0.5 ? -1 : 1;
|
const patrolSide = rng() < 0.5 ? -1 : 1;
|
||||||
const patrol = pointOnSegment(segment, patrolT, patrolSide * (half - 1.4));
|
const patrolLateral = patrolSide * (half - 1.4);
|
||||||
props.push({ ...patrol, width: 2, height: 1.6, depth: 4.4, kind: 'patrol' });
|
props.push({
|
||||||
|
...pointOnSegment(segment, patrolT, patrolLateral),
|
||||||
|
lateral: patrolLateral,
|
||||||
|
width: 2,
|
||||||
|
height: 1.6,
|
||||||
|
depth: 4.4,
|
||||||
|
kind: 'patrol',
|
||||||
|
});
|
||||||
|
|
||||||
if (level === 'patrol') return props;
|
if (level === 'patrol') return props;
|
||||||
|
|
||||||
// Barricade: blocks across the road with a gap you have to slow down for.
|
// Barricade: blocks with a gap you have to slow down for.
|
||||||
|
//
|
||||||
|
// It spans the full route catchment, not just the tarmac. A checkpoint that
|
||||||
|
// stopped at the kerb would be pointless — you would swing onto the verge,
|
||||||
|
// round it at speed, and still be on the route. Blocking the whole corridor
|
||||||
|
// means the only ways past are the gap or genuinely leaving the road.
|
||||||
const barricadeT = randRange(rng, 0.35, 0.65);
|
const barricadeT = randRange(rng, 0.35, 0.65);
|
||||||
const gapSide = rng() < 0.5 ? -1 : 1;
|
const gapSide = rng() < 0.5 ? -1 : 1;
|
||||||
const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6);
|
const gapCentre = gapSide * randRange(rng, half * 0.35, half * 0.6);
|
||||||
for (const side of [-1, 1]) {
|
for (const side of [-1, 1]) {
|
||||||
const inner = gapCentre + side * 2.1;
|
const inner = gapCentre + side * GAP_HALF_WIDTH;
|
||||||
const outer = side * half;
|
const outer = side * ROUTE_CATCHMENT;
|
||||||
const blockWidth = Math.abs(outer - inner);
|
const blockWidth = Math.abs(outer - inner);
|
||||||
if (blockWidth < 0.8) continue;
|
if (blockWidth < 0.8) continue;
|
||||||
const at = pointOnSegment(segment, barricadeT, (inner + outer) / 2);
|
const lateral = (inner + outer) / 2;
|
||||||
props.push({ ...at, width: blockWidth, height: 1.5, depth: 1.6, kind: 'barricade' });
|
props.push({
|
||||||
|
...pointOnSegment(segment, barricadeT, lateral),
|
||||||
|
lateral,
|
||||||
|
width: blockWidth,
|
||||||
|
height: 1.5,
|
||||||
|
depth: 1.6,
|
||||||
|
kind: 'barricade',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level === 'barricade') return props;
|
if (level === 'barricade') return props;
|
||||||
|
|
||||||
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
|
// Turret: a tower overlooking the checkpoint. Inert for now — it is a landmark
|
||||||
// that says "this road has been noticed", and something to collide with.
|
// that says "this road has been noticed", and something to collide with.
|
||||||
const tower = pointOnSegment(segment, barricadeT + 0.06, (rng() < 0.5 ? -1 : 1) * (half + 2.5));
|
const towerLateral = (rng() < 0.5 ? -1 : 1) * (half + 2.5);
|
||||||
props.push({ ...tower, width: 3, height: 6, depth: 3, kind: 'tower' });
|
props.push({
|
||||||
|
...pointOnSegment(segment, barricadeT + 0.06, towerLateral),
|
||||||
|
lateral: towerLateral,
|
||||||
|
width: 3,
|
||||||
|
height: 6,
|
||||||
|
depth: 3,
|
||||||
|
kind: 'tower',
|
||||||
|
});
|
||||||
|
|
||||||
return props;
|
return props;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -143,13 +143,32 @@ export function projectOntoSegment(
|
|||||||
return { distance: Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)), t };
|
return { distance: Math.hypot(x - (s.ax + dx * t), z - (s.az + dz * t)), t };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nearest segment the point is actually *on*, or null if off-road. */
|
/**
|
||||||
export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment | null {
|
* How far off the tarmac still counts as using a road.
|
||||||
|
*
|
||||||
|
* Without this, the obvious play is to drive alongside a road rather than on it
|
||||||
|
* and never accrue heat at all — which defeats the entire system. The verge is
|
||||||
|
* part of the route as far as anyone watching it is concerned.
|
||||||
|
*
|
||||||
|
* 12m leaves roughly 7.5m of verge beyond the tarmac edge — about two car widths,
|
||||||
|
* so hugging the shoulder buys nothing. Widening it is tempting but backfires:
|
||||||
|
* at 20m two thirds of the map counts as on-route, heat becomes unavoidable
|
||||||
|
* everywhere, and "take a different route" stops being a choice. At 12m about
|
||||||
|
* 57% of the map is genuinely off-route.
|
||||||
|
*/
|
||||||
|
export const ROUTE_CATCHMENT = 12;
|
||||||
|
|
||||||
|
function nearest(
|
||||||
|
roads: RoadNetwork,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
limit: (s: RoadSegment) => number,
|
||||||
|
): RoadSegment | null {
|
||||||
let best: RoadSegment | null = null;
|
let best: RoadSegment | null = null;
|
||||||
let bestDistance = Infinity;
|
let bestDistance = Infinity;
|
||||||
for (const s of roads.segments) {
|
for (const s of roads.segments) {
|
||||||
const { distance } = projectOntoSegment(s, x, z);
|
const { distance } = projectOntoSegment(s, x, z);
|
||||||
if (distance < s.width / 2 && distance < bestDistance) {
|
if (distance < limit(s) && distance < bestDistance) {
|
||||||
bestDistance = distance;
|
bestDistance = distance;
|
||||||
best = s;
|
best = s;
|
||||||
}
|
}
|
||||||
@ -157,6 +176,21 @@ export function segmentAt(roads: RoadNetwork, x: number, z: number): RoadSegment
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nearest segment the point is actually *on the tarmac of*, or null. */
|
||||||
|
export const segmentAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null =>
|
||||||
|
nearest(roads, x, z, (s) => s.width / 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearest segment the point counts as *travelling along*, tarmac or verge.
|
||||||
|
*
|
||||||
|
* Deliberately a hard cutoff rather than a taper. A taper would leave a gradient
|
||||||
|
* to optimise along — sit at 80% of the catchment and take 20% of the heat.
|
||||||
|
* A cliff edge means you either use the route or you genuinely leave it, and
|
||||||
|
* out there the scenery is its own punishment.
|
||||||
|
*/
|
||||||
|
export const routeAt = (roads: RoadNetwork, x: number, z: number): RoadSegment | null =>
|
||||||
|
nearest(roads, x, z, () => ROUTE_CATCHMENT);
|
||||||
|
|
||||||
/** Distance to the nearest road surface, used to keep scenery off the tarmac. */
|
/** Distance to the nearest road surface, used to keep scenery off the tarmac. */
|
||||||
export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number {
|
export function distanceToRoad(roads: RoadNetwork, x: number, z: number): number {
|
||||||
let best = Infinity;
|
let best = Infinity;
|
||||||
|
|||||||
@ -10,6 +10,8 @@ function bar(value: number): string {
|
|||||||
|
|
||||||
export interface HeatReadout {
|
export interface HeatReadout {
|
||||||
segmentId: number | null;
|
segmentId: number | null;
|
||||||
|
/** False while on the verge — still counts as using the road. */
|
||||||
|
onTarmac: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
level: HeatLevel | null;
|
level: HeatLevel | null;
|
||||||
hottest: number;
|
hottest: number;
|
||||||
@ -33,7 +35,11 @@ export function createHud(seed: number) {
|
|||||||
'',
|
'',
|
||||||
// Debug only. The real game signals heat through the road itself —
|
// Debug only. The real game signals heat through the road itself —
|
||||||
// see the design brief: no numeric meter ships.
|
// see the design brief: no numeric meter ships.
|
||||||
`[debug] road ${heat.segmentId === null ? 'off-road' : `#${heat.segmentId}`}`,
|
`[debug] road ${
|
||||||
|
heat.segmentId === null
|
||||||
|
? 'off-route'
|
||||||
|
: `#${heat.segmentId} ${heat.onTarmac ? '(on road)' : '(alongside)'}`
|
||||||
|
}`,
|
||||||
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
|
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
|
||||||
`[debug] hottest ${bar(heat.hottest)}`,
|
`[debug] hottest ${bar(heat.hottest)}`,
|
||||||
'',
|
'',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user