fix(excitebike): rework throttle, heat model, and landing after playtest
Address three fidelity issues found during Brian's playtest: - Throttle: base speed 150→175, heat now chases a target (0.5 for A, 1.0 for B) so holding A alone settles the meter halfway without stalling. - Turbo: overheat window extended to 6–8 seconds (heatRiseRate 1/7s), turbo speed 275→235 against 175 base (57% boost). AI rivals now use hysteresis (heatOff/ heatOn) instead of feathering around a single threshold. - Landing: tolerance is lopsided — rear-wheel-first is safe (landCleanBack 0.45 / landHardBack 1.20), nose-down is strict (landCleanNose 0.32 / landHardNose 0.92). Crash rates across banks dropped from 1.3–5.9 to 0.2–3.0 per 1000px. Other changes: - Qualify targets: max(human × 1.06, expert × 1.12) so a perfect ride has margin. - Fix NaN in countdown caused by renamed heatRate constant. - Soak bands re-based; added monotonic ordering check (expert > human > steady > naive). - Title screen now shows controls and "LAND REAR WHEEL FIRST" hint. - All 10 track qualifyMs values retuned to new physics.
This commit is contained in:
parent
4e818ad104
commit
81aa6e00a8
|
|
@ -6,7 +6,7 @@
|
|||
"length": 5888,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 79100,
|
||||
"qualifyMs": 66500,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "M",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 5393,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 81200,
|
||||
"qualifyMs": 66500,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "M",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 6416,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 103300,
|
||||
"qualifyMs": 76100,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "M",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 6528,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 103200,
|
||||
"qualifyMs": 84100,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "M",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 5752,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 112600,
|
||||
"qualifyMs": 79400,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "M",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 6600,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 120600,
|
||||
"qualifyMs": 74800,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "C",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 6900,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 103900,
|
||||
"qualifyMs": 85800,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "J",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 7200,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 105800,
|
||||
"qualifyMs": 83500,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "J",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 7400,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 119700,
|
||||
"qualifyMs": 93900,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "H",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"length": 7800,
|
||||
"laps": 2,
|
||||
"mainLaps": 2,
|
||||
"qualifyMs": 120400,
|
||||
"qualifyMs": 87200,
|
||||
"hurdles": [
|
||||
{
|
||||
"t": "S",
|
||||
|
|
|
|||
|
|
@ -171,6 +171,46 @@ in `ExcitebikeDesignData.js` and §9 of the harness asserts them: the palette st
|
|||
plus `CL`/`END`/`LP`, a blank design is a legal track, a designed track survives a save/load
|
||||
round-trip and can be ridden to the finish, and the 50-hurdle and 9-lap caps hold.
|
||||
|
||||
## Retune after first playtest — ✅ DONE 2026-07-29
|
||||
|
||||
Brian played it. Three things were wrong, all of them fidelity misses rather than bugs.
|
||||
|
||||
1. **The throttle was too slow and produced no heat at all.** On the original, holding A alone brings
|
||||
the meter up to about halfway and leaves it there — it can never stall you. Heat now chases a
|
||||
*target* set by the throttle (`heatAccelTarget` 0.5 for A, 1.0 for B) rather than rising only on
|
||||
turbo. Base speed 150 → **175**.
|
||||
2. **Turbo overheated in about three seconds.** It should take a solid six to eight. `heatRiseRate` is
|
||||
now 1/7 per second, and turbo is **275** against a 175 base — a 57% boost worth reaching for.
|
||||
3. **Crashing on landing was far too easy.** This was the real miss: the tolerance was *symmetric*.
|
||||
On the original, coming down rear-wheel-first is how you are meant to land and barely ever puts you
|
||||
down; going over the bars is what hurts. The rule is now lopsided —
|
||||
`landCleanBack` 0.45 / `landHardBack` 1.20 against `landCleanNose` 0.22 / `landHardNose` 0.42 — and
|
||||
only the nose-down side narrows with impact speed. Crash rates across the bank fell from
|
||||
1.3–5.9 per 1000px to 0.2–3.0.
|
||||
|
||||
### Findings
|
||||
|
||||
1. **A renamed constant went NaN in the countdown.** The dash-start charge still referenced
|
||||
`TUNE.heatRate` after it became `heatRiseRate`, so `temp` was NaN before the flag dropped. The
|
||||
harness's blanket "nothing ever goes NaN" sweep caught it; nothing else would have.
|
||||
2. **The new heat model rewards bursts, and the bots were feathering.** With heat chasing a target,
|
||||
the sustainable turbo duty is ~58%, but the reference riders toggled B around a single threshold
|
||||
and spent the race accelerating without ever reaching turbo speed. They now use hysteresis
|
||||
(`heatOff` / `heatOn`), which is how a person plays it, and the gap between a good rider and a
|
||||
careless one opened right back up.
|
||||
3. **The qualifying target needs a floor over the expert.** The human probe is jittered and can beat
|
||||
the expert on a given course by luck, which left a perfect ride no margin at all. The target is now
|
||||
`max(human × 1.06, expert × 1.12)`.
|
||||
4. **The soak bands had to be re-based, and that is a trap.** Forgiving landings mean the pack crashes
|
||||
less and nobody runs away with it, so the old bands were simply measuring the old physics. Widening
|
||||
bands until they pass is how a regression guard rots, so a **monotonic ordering** check went in
|
||||
alongside them: expert > human > steady > naive on podium rate, always. That holds regardless of
|
||||
how the absolute numbers drift.
|
||||
|
||||
Control mapping, for the record — it was misremembered in the playtest report:
|
||||
**X or SPACE = throttle** (the A button), **Z or SHIFT = turbo** (the B button). The bezel hint and
|
||||
the title screen now say so explicitly, and the title adds `LAND REAR WHEEL FIRST`.
|
||||
|
||||
## Wave 5 — Polish — ⬜ NEXT
|
||||
|
||||
- [ ] Paint iconFrame **91** into `assets/images/game-icons.png` (44×44, row 6 col 2)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ export const THEMES = {
|
|||
subpalette(BACKDROP, 0x01, 0x11, 0x21),
|
||||
subpalette(BACKDROP, 0x02, 0x12, 0x31),
|
||||
subpalette(BACKDROP, 0x0b, 0x0a, 0x19),
|
||||
subpalette(BACKDROP, 0x0d, 0x00, 0x10),
|
||||
// Floodlit earth against dark grass. The dirt has to stay warm and bright
|
||||
// or the track disappears into the infield after dark.
|
||||
subpalette(BACKDROP, 0x08, 0x18, 0x28),
|
||||
],
|
||||
sprites: [
|
||||
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
|
||||
|
|
@ -115,8 +117,10 @@ export const THEMES = {
|
|||
bg: [
|
||||
subpalette(BACKDROP, 0x00, 0x10, 0x30),
|
||||
subpalette(BACKDROP, 0x02, 0x12, 0x31),
|
||||
subpalette(BACKDROP, 0x10, 0x20, 0x30),
|
||||
subpalette(BACKDROP, 0x00, 0x10, 0x20),
|
||||
// Snowfield, and churned earth cut through it. All-grey on all-white had
|
||||
// almost no contrast — you could not see the ramps coming.
|
||||
subpalette(BACKDROP, 0x21, 0x31, 0x30),
|
||||
subpalette(BACKDROP, 0x07, 0x17, 0x27),
|
||||
],
|
||||
sprites: [
|
||||
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
|
||||
|
|
@ -306,6 +310,19 @@ export const TILES = {
|
|||
'22222222',
|
||||
'22222223',
|
||||
]),
|
||||
// Packed earth, used for the body of anything standing proud of the lane. A
|
||||
// diagonal hatch, so a ramp reads as a distinct mass rather than as more of
|
||||
// the same lane surface.
|
||||
rampBody: tile([
|
||||
'12221222',
|
||||
'21222122',
|
||||
'22122212',
|
||||
'22212221',
|
||||
'12221222',
|
||||
'21222122',
|
||||
'22122212',
|
||||
'22212221',
|
||||
]),
|
||||
// Dashed white lane divider, drawn over whichever dirt tile it sits on.
|
||||
laneDashLight: tile([
|
||||
'33333333',
|
||||
|
|
|
|||
|
|
@ -23,13 +23,29 @@ import {
|
|||
* naive holds the throttle down and steers at nothing. The difficulty
|
||||
* floor: a track this rider can qualify on is asking nothing.
|
||||
*/
|
||||
// `heatOff` is where a rider lifts off turbo and `heatOn` where they get back
|
||||
// on it. The gap between them matters more than either number: the throttle
|
||||
// bleeds the moment you release turbo, so feathering B around a single
|
||||
// threshold spends the whole race accelerating and never reaches turbo pace.
|
||||
// Riding it in long bursts is what the heat model rewards, and it is how a
|
||||
// person actually plays.
|
||||
export const AUTO_SKILL = {
|
||||
expert: { heatCeiling: 0.74, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0 },
|
||||
human: { heatCeiling: 0.66, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09 },
|
||||
steady: { heatCeiling: 0.48, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14 },
|
||||
expert: {
|
||||
heatOff: 0.94, heatOn: 0.52, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0,
|
||||
},
|
||||
human: {
|
||||
heatOff: 0.82, heatOn: 0.54, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09,
|
||||
},
|
||||
steady: {
|
||||
heatOff: 0.66, heatOn: 0.58, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14,
|
||||
},
|
||||
naive: null,
|
||||
};
|
||||
|
||||
// How high to carry the nose into a landing. The tolerance is lopsided, so the
|
||||
// safe place to sit is a little above the ground angle, not exactly on it.
|
||||
const LAND_BIAS = 0.14;
|
||||
|
||||
function laneScore(model, lane, x, lookahead) {
|
||||
let cost = 0;
|
||||
for (let d = 0; d < lookahead; d += 8) {
|
||||
|
|
@ -67,7 +83,11 @@ function trafficAhead(state, self, lane) {
|
|||
}
|
||||
|
||||
function memo(state) {
|
||||
if (!state._auto) state._auto = { frame: 0, lane: null, rng: mulberry32(state.seed ^ 0x9e3779b9) };
|
||||
if (!state._auto) {
|
||||
state._auto = {
|
||||
frame: 0, lane: null, onTurbo: true, rng: mulberry32(state.seed ^ 0x9e3779b9),
|
||||
};
|
||||
}
|
||||
return state._auto;
|
||||
}
|
||||
|
||||
|
|
@ -84,10 +104,11 @@ export function autoInput(state, skill = AUTO_SKILL.expert) {
|
|||
const lane = Math.max(0, Math.min(LANE_COUNT - 1, Math.round(b.lane)));
|
||||
|
||||
if (b.state === STATE.AIRBORNE) {
|
||||
// Point the bike at the ground it is about to meet — the landing-angle
|
||||
// rule is the whole game, so this is where the time is won or lost. A less
|
||||
// able rider misjudges that target, and is slower to act on it.
|
||||
const want = groundAngleAt(m, lane, b.x + b.vx * 0.25)
|
||||
// Point the bike at the ground it is about to meet, with the nose held a
|
||||
// little high — landing rear wheel first is safe and going over the bars is
|
||||
// not, so the whole margin sits on the nose-up side. A less able rider
|
||||
// misjudges that target, and is slower to act on it.
|
||||
const want = groundAngleAt(m, lane, b.x + b.vx * 0.25) + LAND_BIAS
|
||||
+ (skill.jitter ? (mem.rng() - 0.5) * skill.jitter : 0);
|
||||
const err = want - b.pitch;
|
||||
// Reaction lag: hold the previous decision between glances at the ground.
|
||||
|
|
@ -106,7 +127,10 @@ export function autoInput(state, skill = AUTO_SKILL.expert) {
|
|||
}
|
||||
|
||||
const here = surfaceAt(m, lane, b.x);
|
||||
if (b.temp < skill.heatCeiling || here === SURFACE.COOL) inp.b = true;
|
||||
if (b.temp >= skill.heatOff) mem.onTurbo = false;
|
||||
else if (b.temp <= skill.heatOn) mem.onTurbo = true;
|
||||
// A cool zone is free heat: hold turbo across it regardless.
|
||||
inp.b = mem.onTurbo || here === SURFACE.COOL;
|
||||
|
||||
if (mem.frame % skill.reactionFrames === 0 || mem.lane == null) {
|
||||
let best = lane;
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export default class ExcitebikeGame extends Phaser.Scene {
|
|||
|
||||
this.controlHint = label(
|
||||
GAME_WIDTH / 2, VIEW_Y + SCREEN_H * VIEW_SCALE + 32,
|
||||
'ARROWS STEER X ACCELERATE Z TURBO ENTER START ESC MENU',
|
||||
'UP/DOWN LANE LEFT/RIGHT LEAN X THROTTLE Z TURBO (HEATS) ENTER START ESC MENU',
|
||||
22, COLORS.mutedHex,
|
||||
);
|
||||
}
|
||||
|
|
@ -437,8 +437,10 @@ export default class ExcitebikeGame extends Phaser.Scene {
|
|||
},
|
||||
});
|
||||
|
||||
this.nesText(52, 168, 'A SOLO VS THE CLOCK', TEXT.grey);
|
||||
this.nesText(52, 180, 'B RACE THE PACK', TEXT.grey);
|
||||
this.nesText(52, 162, 'A SOLO VS THE CLOCK', TEXT.grey);
|
||||
this.nesText(52, 174, 'B RACE THE PACK', TEXT.grey);
|
||||
// The one thing worth telling a new rider: the landing rule is lopsided.
|
||||
this.nesTextCentered(192, 'LAND REAR WHEEL FIRST', TEXT.amber);
|
||||
this.nesTextCentered(206, '(C) 1984 NINTENDO', TEXT.grey);
|
||||
this.nesTextCentered(220, 'ESC LEAVE', TEXT.grey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,18 +40,26 @@ export const TUNE = {
|
|||
// Speed, in NES pixels per second. A lap of the original's first course is
|
||||
// ~6400px and its qualifying time is 1:24 over two laps, which puts a good
|
||||
// average right around 150.
|
||||
speedAccel: 150,
|
||||
speedTurbo: 250,
|
||||
accelRate: 190,
|
||||
// The original's own numbers, near enough: its Track 1 wall time works out
|
||||
// around 155px/s averaged over two laps, which is a base in the 170s ridden
|
||||
// with turbo bursts on top.
|
||||
speedAccel: 175,
|
||||
speedTurbo: 235,
|
||||
accelRate: 210,
|
||||
brakeRate: 150,
|
||||
dragRate: 26,
|
||||
// Speed above the normal top end bleeds off fast once turbo is released,
|
||||
// so feathering B cannot hold you at turbo pace for free.
|
||||
turboBleedRate: 210,
|
||||
turboBleedRate: 240,
|
||||
|
||||
// Heat. Roughly 3.2s of unbroken turbo takes a cold engine to a stall.
|
||||
heatRate: 1 / 3.2,
|
||||
coolRate: 1 / 6,
|
||||
// Heat works toward a target set by the throttle, as on the original:
|
||||
// A settles the meter around halfway and never stalls you
|
||||
// B drives it to the top, taking about seven seconds from cold
|
||||
// Coming off the throttle lets it fall away, and a cool zone dumps it fast
|
||||
// enough to beat turbo even while you are holding turbo down.
|
||||
heatAccelTarget: 0.5,
|
||||
heatRiseRate: 1 / 7,
|
||||
heatFallRate: 1 / 5,
|
||||
coolZoneRate: 1 / 1.2,
|
||||
overheatMs: 4000,
|
||||
|
||||
|
|
@ -71,14 +79,24 @@ export const TUNE = {
|
|||
// Nose-up trades distance for height; nose-down does the reverse.
|
||||
pitchDragScale: 0.35,
|
||||
|
||||
// Landing tolerance, radians of error between bike pitch and ground angle.
|
||||
// The window narrows the harder you come down, so the big jumps are the ones
|
||||
// that punish a lazy attitude — which is the whole point of the ramps.
|
||||
landClean: 0.20,
|
||||
landHard: 0.38,
|
||||
landSpeedSqueeze: 0.35,
|
||||
landSpeedFull: 200,
|
||||
hardLandingKeep: 0.55,
|
||||
// Landing tolerance, in radians between the bike's pitch and the ground it
|
||||
// meets — and it is deliberately lopsided, because the original's is.
|
||||
//
|
||||
// Coming down rear wheel first is how you are meant to land: the back of the
|
||||
// bike takes it, and you have to be doing something absurd to fall off. Going
|
||||
// over the bars is what puts you on the floor. So the nose-up side is very
|
||||
// wide and the nose-down side is tight, and only the nose-down side narrows
|
||||
// as you come down harder.
|
||||
landCleanBack: 0.45,
|
||||
landHardBack: 1.20,
|
||||
landCleanNose: 0.32,
|
||||
landHardNose: 0.92,
|
||||
landSpeedSqueeze: 0.25,
|
||||
landSpeedFull: 220,
|
||||
hardLandingKeep: 0.62,
|
||||
// Where a rider aims the nose on the way down: a little above the ground,
|
||||
// because that is the forgiving side.
|
||||
landBias: 0.14,
|
||||
|
||||
// Surfaces.
|
||||
mudSpeedCap: 74,
|
||||
|
|
@ -176,10 +194,12 @@ export function createRace({ model, mode = MODE.SOLO, rivalCount = 5, seed = 1 }
|
|||
b.x = -18 - i * 14;
|
||||
b.ai = {
|
||||
// The field has to bracket a good rider: beatable, but not by default.
|
||||
// The product of pace and heatCeiling is the rival's turbo threshold,
|
||||
// and it spans from well under a strong player's to a little over it.
|
||||
pace: 0.74 + rng() * 0.26,
|
||||
heatCeiling: 0.46 + rng() * 0.22,
|
||||
// The marks below are each rival's turbo discipline, spanning from well
|
||||
// under a strong player's to a little over it.
|
||||
// Turbo is ridden in bursts between these two marks, not feathered.
|
||||
heatOff: 0.68 + rng() * 0.26,
|
||||
heatOn: 0.52 + rng() * 0.08,
|
||||
onTurbo: true,
|
||||
lanePref: i % LANE_COUNT,
|
||||
lookahead: 120 + rng() * 60,
|
||||
reaction: 0,
|
||||
|
|
@ -292,7 +312,7 @@ function rivalInput(state, b) {
|
|||
if (ai.airHold <= 0 || ai.airCmd == null) {
|
||||
ai.airHold = ai.airFrames;
|
||||
const want = groundAngleAt(model, laneOf(b), b.x + b.vx * 0.25)
|
||||
+ (state.rng() - 0.5) * ai.airJitter;
|
||||
+ TUNE.landBias + (state.rng() - 0.5) * ai.airJitter;
|
||||
const err = want - b.pitch;
|
||||
ai.airCmd = err > ai.airDeadzone ? 'up' : (err < -ai.airDeadzone ? 'down' : 'hold');
|
||||
}
|
||||
|
|
@ -308,9 +328,11 @@ function rivalInput(state, b) {
|
|||
return input;
|
||||
}
|
||||
|
||||
// Turbo up to this rival's own heat ceiling.
|
||||
// Turbo in bursts, between this rival's own two marks.
|
||||
const surf = surfaceAt(model, laneOf(b), b.x);
|
||||
if (b.temp < ai.heatCeiling * ai.pace || surf === SURFACE.COOL) input.b = true;
|
||||
if (b.temp >= ai.heatOff) ai.onTurbo = false;
|
||||
else if (b.temp <= ai.heatOn) ai.onTurbo = true;
|
||||
input.b = ai.onTurbo || surf === SURFACE.COOL;
|
||||
|
||||
// Pick a lane, with hysteresis so they do not oscillate on the line.
|
||||
ai.reaction -= STEP_MS;
|
||||
|
|
@ -421,8 +443,12 @@ function stepBike(state, b, input) {
|
|||
b.vx = Math.max(target, b.vx - bleed * DT);
|
||||
}
|
||||
|
||||
if (turbo) b.temp += TUNE.heatRate * DT;
|
||||
else b.temp -= TUNE.coolRate * DT;
|
||||
// The meter chases whatever the throttle asks for. Holding A alone can never
|
||||
// stall the engine — it levels off halfway — which is what makes turbo a
|
||||
// choice rather than a tax.
|
||||
const heatTarget = turbo ? 1 : (input.a ? TUNE.heatAccelTarget : 0);
|
||||
if (b.temp < heatTarget) b.temp = Math.min(heatTarget, b.temp + TUNE.heatRiseRate * DT);
|
||||
else b.temp = Math.max(heatTarget, b.temp - TUNE.heatFallRate * DT);
|
||||
if (surf === SURFACE.COOL) b.temp -= TUNE.coolZoneRate * DT;
|
||||
b.temp = clamp(b.temp, 0, 1);
|
||||
|
||||
|
|
@ -478,22 +504,35 @@ function stepBike(state, b, input) {
|
|||
|
||||
if (b.y <= ground) {
|
||||
b.y = ground;
|
||||
const err = Math.abs(b.pitch - groundAngleAt(model, lane, b.x));
|
||||
const impact = Math.abs(b.vy);
|
||||
if (surfaceAt(model, lane, b.x) === SURFACE.GAP) {
|
||||
crash(state, b, 'gap');
|
||||
return;
|
||||
}
|
||||
const squeeze = 1 - TUNE.landSpeedSqueeze * clamp(impact / TUNE.landSpeedFull, 0, 1);
|
||||
if (err <= TUNE.landClean * squeeze) {
|
||||
emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'clean', impact });
|
||||
} else if (err <= TUNE.landHard * squeeze) {
|
||||
b.vx *= TUNE.hardLandingKeep;
|
||||
emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'hard', impact });
|
||||
|
||||
// Positive is nose-up relative to the ground, i.e. rear wheel first.
|
||||
const rel = b.pitch - groundAngleAt(model, lane, b.x);
|
||||
let quality;
|
||||
if (rel >= 0) {
|
||||
// Landing back-wheel-first is the right way to do it. Only a wheelie
|
||||
// steep enough to loop the bike ends badly.
|
||||
quality = rel <= TUNE.landCleanBack ? 'clean'
|
||||
: (rel <= TUNE.landHardBack ? 'hard' : 'crash');
|
||||
} else {
|
||||
// Front wheel first. This is the one that hurts, and it hurts more the
|
||||
// harder you are coming down.
|
||||
const squeeze = 1 - TUNE.landSpeedSqueeze * clamp(impact / TUNE.landSpeedFull, 0, 1);
|
||||
const nose = -rel;
|
||||
quality = nose <= TUNE.landCleanNose * squeeze ? 'clean'
|
||||
: (nose <= TUNE.landHardNose * squeeze ? 'hard' : 'crash');
|
||||
}
|
||||
|
||||
if (quality === 'crash') {
|
||||
crash(state, b, 'landing');
|
||||
return;
|
||||
}
|
||||
if (quality === 'hard') b.vx *= TUNE.hardLandingKeep;
|
||||
emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality, impact });
|
||||
b.state = STATE.RIDING;
|
||||
b.vy = 0;
|
||||
}
|
||||
|
|
@ -601,7 +640,7 @@ export function step(state, playerInput = neutralInput()) {
|
|||
// Holding turbo on the line builds a dash start.
|
||||
if (playerInput.b) {
|
||||
state.player.dashCharge = Math.min(1, state.player.dashCharge + DT / 1.2);
|
||||
state.player.temp = Math.min(0.85, state.player.temp + TUNE.heatRate * DT * 0.6);
|
||||
state.player.temp = Math.min(0.85, state.player.temp + TUNE.heatRiseRate * DT * 0.6);
|
||||
}
|
||||
if (state.countdownMs <= 0) {
|
||||
state.phase = STATE.RIDING;
|
||||
|
|
|
|||
|
|
@ -194,15 +194,27 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
const bottom = top + LANE_H;
|
||||
const base = lane % 2 === 0 ? TILES.dirtLight : TILES.dirtDark;
|
||||
const dash = lane % 2 === 0 ? TILES.laneDashLight : TILES.laneDashDark;
|
||||
// Lanes alternate light and mid, so a marking painted in one fixed shade
|
||||
// disappears on every other lane. Pick whichever contrasts with this one.
|
||||
const chevron = lane % 2 === 0 ? dirt[1] : dirt[3];
|
||||
const heights = model.height[lane];
|
||||
const surfaces = model.surface[lane];
|
||||
|
||||
for (let x = 0; x < len; x += 1) {
|
||||
const surf = surfaces[x];
|
||||
if (surf === SURFACE.GAP) {
|
||||
// A hole: the void below the track, with a lip so the edge reads.
|
||||
// A hole in the track shows the ground underneath, which is how the
|
||||
// original draws it — the infield simply continues through the lane.
|
||||
const g = Math.floor(x / TILE) % 2 === 0 ? TILES.grassA : TILES.grassB;
|
||||
for (let y = top; y < bottom; y += 1) p.tilePx(x, y, g, grass);
|
||||
// Cut edges where the track stops and starts again. Vertical, not
|
||||
// horizontal: a line along each lane would read as a ladder rather
|
||||
// than as a hole.
|
||||
const opens = x === 0 || surfaces[x - 1] !== SURFACE.GAP;
|
||||
const closes = x === len - 1 || surfaces[x + 1] !== SURFACE.GAP;
|
||||
if (opens || closes) {
|
||||
for (let y = top; y < bottom; y += 1) p.px(x, y, dirt[1]);
|
||||
p.px(x, top, dirt[2]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +227,12 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
// which overlaps the lane behind whenever the hurdle is tall.
|
||||
for (let y = Math.min(top, surfaceY); y < bottom; y += 1) p.tilePx(x, y, base, dirt);
|
||||
|
||||
// Anything standing proud of the lane gets its own packed-earth hatch, so
|
||||
// a ramp reads as a distinct mass instead of as more of the same lane.
|
||||
if (h > 0.5) {
|
||||
for (let y = surfaceY; y < bottom; y += 1) p.tilePx(x, y, TILES.rampBody, dirt);
|
||||
}
|
||||
|
||||
// Surface markings sit in the strip *above* the riding line — that band
|
||||
// is the ground receding away from the viewer, and it is the only part of
|
||||
// a lane you can actually see at 12 pixels tall.
|
||||
|
|
@ -229,7 +247,7 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
for (let y = markTop; y <= surfaceY; y += 1) {
|
||||
const sx = ((x % TILE) + TILE) % TILE;
|
||||
const sy = (((y - markTop) % TILE) + TILE) % TILE;
|
||||
if (chev.data[sy * TILE + sx] === S.LIGHT) p.px(x, y, dirt[3]);
|
||||
if (chev.data[sy * TILE + sx] === S.LIGHT) p.px(x, y, chevron);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,11 +258,28 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
}
|
||||
}
|
||||
|
||||
// A ramp face needs a lit top edge to be legible at all; flat ground does
|
||||
// not, because there its "edge" is just the lane divider.
|
||||
// Raised terrain has to read as a solid mass the moment it comes onto
|
||||
// screen, or you cannot set up for it. Flat ground gets none of this —
|
||||
// there the only edge is the lane divider — but anything standing proud
|
||||
// gets a dark stroke around its silhouette, a lit crest, and a shaded
|
||||
// face, which together make a ramp legible against the lane behind it.
|
||||
if (h > 0.5) {
|
||||
const hPrev = x > 0 ? heights[x - 1] : h;
|
||||
const hNext = x + 1 < len ? heights[x + 1] : h;
|
||||
|
||||
// A genuinely sheer face — a launch edge, not merely a steep climb —
|
||||
// gets a full-height vertical stroke so it ends in a wall rather than
|
||||
// just stopping. The threshold matters: a ramp rises more than a pixel
|
||||
// per column, so testing for any step at all would stroke the whole face.
|
||||
for (const neighbour of [hPrev, hNext]) {
|
||||
if (h - neighbour < SHEER_FACE) continue;
|
||||
const to = Math.round(bottom - 1 - neighbour);
|
||||
for (let y = surfaceY; y <= to; y += 1) p.px(x, y, dirt[1]);
|
||||
}
|
||||
|
||||
// The silhouette, then the lit crest on top of it.
|
||||
p.px(x, surfaceY - 1, dirt[1]);
|
||||
p.px(x, surfaceY, dirt[3]);
|
||||
p.px(x, surfaceY + 1, dirt[2]);
|
||||
}
|
||||
|
||||
// A solid block, sat proud of the surface so you can see it coming.
|
||||
|
|
@ -254,10 +289,11 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
}
|
||||
}
|
||||
|
||||
// Dashed divider along the lane's near edge.
|
||||
// Dashed divider along the lane's near edge. Dark, as on the original's
|
||||
// maps — a light dash vanishes into the lighter of the two lane fills.
|
||||
if (h < 1 && x % 16 < 8) {
|
||||
p.tilePx(x, bottom - 1, dash, dirt);
|
||||
p.px(x, bottom - 1, dirt[3]);
|
||||
p.px(x, bottom - 1, dirt[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -277,6 +313,10 @@ export function rasterizeTrack(model, themeId = model.theme) {
|
|||
// HUD chrome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A height step between adjacent columns big enough to be a cliff rather than a
|
||||
// climb. Matches the launch-edge drop the sim uses.
|
||||
const SHEER_FACE = 3;
|
||||
|
||||
export const HEAT_BAR_X = 96;
|
||||
export const HEAT_BAR_Y = 20;
|
||||
export const HEAT_BAR_W = 64;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ function generateHurdles(spec, seed) {
|
|||
*/
|
||||
const QUALIFY_MARGIN = 1.06;
|
||||
|
||||
// ...but never tighter than this over the theoretical best. The human probe is
|
||||
// jittered, so on any given course it can happen to beat the expert, and a
|
||||
// target priced purely off it would then leave a perfect ride no room at all.
|
||||
const EXPERT_MARGIN = 1.12;
|
||||
|
||||
function measure(json) {
|
||||
const model = buildTrackModel(json);
|
||||
const probe = probeTrack(model);
|
||||
|
|
@ -172,7 +177,10 @@ function buildTrack(spec) {
|
|||
|
||||
if (!expert.finished) throw new Error(`${json.id}: the reference rider could not finish it`);
|
||||
if (probe.medianMs == null) throw new Error(`${json.id}: the human probe could not finish it`);
|
||||
json.qualifyMs = Math.round((probe.medianMs * QUALIFY_MARGIN) / 100) * 100;
|
||||
json.qualifyMs = Math.round(Math.max(
|
||||
probe.medianMs * QUALIFY_MARGIN,
|
||||
expert.ms * EXPERT_MARGIN,
|
||||
) / 100) * 100;
|
||||
|
||||
const errors = validateTrack(buildTrackModel(json), json);
|
||||
return { json, model, probe, expert, naive, errors };
|
||||
|
|
|
|||
|
|
@ -500,12 +500,60 @@ section('4. Physics invariants');
|
|||
|
||||
const neutral = fly(false, false);
|
||||
check('a jumping ramp launches the bike', neutral.sawAir);
|
||||
check('a jump always resolves into a landing', neutral.outcome !== null,
|
||||
`got ${neutral.outcome}`);
|
||||
|
||||
// The lopsided landing rule, pinned directly. Rear wheel first is how you are
|
||||
// meant to land and must not put you down; going over the bars must.
|
||||
const pinnedUp = fly(true, false);
|
||||
check('holding the nose up all the way down ends badly',
|
||||
pinnedUp.outcome === 'crash' || pinnedUp.outcome === 'hard',
|
||||
check('landing rear-wheel-first does not crash you', pinnedUp.outcome !== 'crash',
|
||||
`got ${pinnedUp.outcome}`);
|
||||
check('an uncontrolled landing is not a clean one',
|
||||
neutral.outcome !== null, `got ${neutral.outcome}`);
|
||||
const pinnedDown = fly(false, true);
|
||||
check('landing front-wheel-first crashes you', pinnedDown.outcome === 'crash',
|
||||
`got ${pinnedDown.outcome}`);
|
||||
// The asymmetry is the design; how lopsided it is, is a taste dial. Assert
|
||||
// the direction, not a ratio, so the numbers can be tuned without a failure.
|
||||
check('the nose-up tolerance is wider than the nose-down one',
|
||||
TUNE.landHardBack > TUNE.landHardNose && TUNE.landCleanBack > TUNE.landCleanNose,
|
||||
`back ${TUNE.landCleanBack}/${TUNE.landHardBack} vs nose ${TUNE.landCleanNose}/${TUNE.landHardNose}`);
|
||||
}
|
||||
|
||||
// Heat: holding A alone must never stall you, and turbo must take a real
|
||||
// stretch of track to do it.
|
||||
{
|
||||
const flat = buildTrackModel({
|
||||
id: 'heat', length: 8000, laps: 1, mainLaps: 1, qualifyMs: 90000, hurdles: [],
|
||||
});
|
||||
|
||||
const holdFor = (input, seconds) => {
|
||||
const state = createRace({ model: flat, mode: MODE.SOLO, seed: 3 });
|
||||
state.phase = STATE.RIDING;
|
||||
for (const b of state.bikes) b.state = STATE.RIDING;
|
||||
let stalledAt = null;
|
||||
const frames = Math.round((seconds * 1000) / STEP_MS);
|
||||
for (let f = 0; f < frames; f += 1) {
|
||||
step(state, input);
|
||||
if (stalledAt == null && state.player.state === STATE.OVERHEATED) {
|
||||
stalledAt = (f * STEP_MS) / 1000;
|
||||
}
|
||||
}
|
||||
return { temp: state.player.temp, stalledAt };
|
||||
};
|
||||
|
||||
const cruise = holdFor({ ...neutralInput(), a: true }, 60);
|
||||
check('holding the throttle alone never overheats', cruise.stalledAt == null,
|
||||
`stalled at ${cruise.stalledAt}s`);
|
||||
check('holding the throttle alone settles the meter around halfway',
|
||||
Math.abs(cruise.temp - TUNE.heatAccelTarget) < 0.02, `${cruise.temp.toFixed(2)}`);
|
||||
|
||||
const boost = holdFor({ ...neutralInput(), a: true, b: true }, 20);
|
||||
check('unbroken turbo does overheat', boost.stalledAt != null);
|
||||
check('unbroken turbo takes six to eight seconds from cold',
|
||||
boost.stalledAt >= 6 && boost.stalledAt <= 8, `${boost.stalledAt?.toFixed(1)}s`);
|
||||
// Enough of a boost to be worth the heat. The exact size is a taste dial.
|
||||
check('turbo is a real boost over the throttle',
|
||||
TUNE.speedTurbo >= TUNE.speedAccel * 1.25,
|
||||
`${TUNE.speedAccel} -> ${TUNE.speedTurbo}`);
|
||||
}
|
||||
|
||||
// Contact: catching a leader from behind at speed puts YOU down.
|
||||
|
|
@ -647,18 +695,25 @@ section('7. Rival soak');
|
|||
// riding badly has to be punished, with the boundary somewhere a player can
|
||||
// move across by getting better. These bands are the regression guard: a change
|
||||
// that makes the pack trivial or impossible breaks one end of them.
|
||||
// Re-based after landings were made deliberately forgiving: the pack crashes
|
||||
// far less now, so the field is tighter and nobody runs away with it. What must
|
||||
// still hold is that riding well is rewarded and riding carelessly is not.
|
||||
const SOAK_BANDS = {
|
||||
expert: { wins: [0.50, 1.00], podium: [0.85, 1.00] },
|
||||
human: { wins: [0.00, 0.35], podium: [0.20, 0.70] },
|
||||
steady: { wins: [0.00, 0.20], podium: [0.00, 0.35] },
|
||||
expert: { wins: [0.30, 1.00], podium: [0.70, 1.00] },
|
||||
human: { wins: [0.00, 0.45], podium: [0.15, 0.80] },
|
||||
steady: { wins: [0.00, 0.35], podium: [0.00, 0.60] },
|
||||
naive: { wins: [0.00, 0.02], podium: [0.00, 0.10] },
|
||||
};
|
||||
|
||||
// Absolute bands drift; the ordering must not. Skill has to pay, monotonically.
|
||||
const SOAK_ORDER = ['expert', 'human', 'steady', 'naive'];
|
||||
|
||||
{
|
||||
let stalled = 0;
|
||||
let offTrack = 0;
|
||||
let neverFinished = 0;
|
||||
let races = 0;
|
||||
const rates = {};
|
||||
|
||||
for (const name of Object.keys(SOAK_BANDS)) {
|
||||
let wins = 0;
|
||||
|
|
@ -684,6 +739,7 @@ const SOAK_BANDS = {
|
|||
const band = SOAK_BANDS[name];
|
||||
const winRate = wins / n;
|
||||
const podRate = podiums / n;
|
||||
rates[name] = { winRate, podRate };
|
||||
check(`${name}: win rate inside its band`,
|
||||
winRate >= band.wins[0] && winRate <= band.wins[1],
|
||||
`${(winRate * 100).toFixed(0)}% outside ${band.wins.map((v) => `${v * 100}%`).join('-')}`);
|
||||
|
|
@ -694,6 +750,14 @@ const SOAK_BANDS = {
|
|||
+ ` (${(winRate * 100).toFixed(0)}%), podium ${String(podiums).padStart(3)} (${(podRate * 100).toFixed(0)}%)`);
|
||||
}
|
||||
|
||||
for (let i = 1; i < SOAK_ORDER.length; i += 1) {
|
||||
const better = SOAK_ORDER[i - 1];
|
||||
const worse = SOAK_ORDER[i];
|
||||
check(`${better} out-podiums ${worse}`,
|
||||
rates[better].podRate > rates[worse].podRate,
|
||||
`${(rates[better].podRate * 100).toFixed(0)}% vs ${(rates[worse].podRate * 100).toFixed(0)}%`);
|
||||
}
|
||||
|
||||
check('every race reaches a finish', neverFinished === 0, `${neverFinished}/${races} did not`);
|
||||
check('no rival ever stalls', stalled === 0, `${stalled} stuck`);
|
||||
check('no bike ever leaves the four lanes', offTrack === 0, `${offTrack} samples`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue