Commit graph

4 commits

Author SHA1 Message Date
Admin
fcfd335f1e Terrain: a real heightfield under the world, and cars that touch the road
**The flat plane is gone.** Terrain existed all along — heightfield collision
via box3d, AO and shadow raymarching against it, a mesh path, a
`game.terrain` verb — and arcade simply spawned a flat slab instead. That is
the fourth capability this week that was built and never called.

Turning it on was not enough, because the generator had faults that a
screenshot explains faster than prose:

- **Single-octave value noise.** Detail at exactly one scale reads as melted
  blobs. Now fBm with domain warp, which is the single highest-value knob
  here: warping the sample point bends contours into ridges and valleys
  instead of round lumps on a visible grid.
- **`step` defaulted to 1.0**, quantising every smooth slope into 1-unit
  stairs. The old default renders as a literal contour map. Off by default.
- **Noise was indexed by CELL INDEX, not world position**, so asking for a
  finer mesh silently generated a different landscape. Resolution should buy
  detail, never a new world.
- **Colour came from height alone**, which paints terrain in horizontal
  stripes like a contour map. Now height AND slope, so rock lands on cliff
  faces and grass on the shelf above them.

Generation moved to `libs/game/gen/terrain.rs` as a pure function. Beyond
testability that was forced: arcade's demo world has no script VM, so the
only generator in the tree was one it could not reach.

`rim_relief` is the load-bearing idea. Terrain interesting everywhere is
terrain you cannot put a town on; terrain flat enough to build on is a green
table. Growing the relief outward gives a playable basin ringed by something
worth looking at, and doubles as a soft boundary. It SCALES the noise rather
than adding a radial ramp — the ramp version has no noise in it and renders
as a smooth machined ring between two flat plains, which I built first and
threw away after looking at it.

Two things the tests taught me rather than confirmed:

- Normalising fBm by the sum of octave amplitudes — the textbook form — makes
  five octaves come out FLATTER than one, because summing decorrelated fields
  concentrates them about the mean. Normalising against the field's own
  extents makes `amp` mean literal relief at any octave count.
- The octave test measures CURVATURE, not slope. At gain 0.5 / lacunarity 2
  every octave contributes equally to slope — that is what self-similar
  means — so a slope-based test reports no difference while the terrain
  visibly gains detail. My first version of that test was wrong, not the code.

Statics get one conform pass after composition; movers already clamp to the
terrain every tick in `step.rs`, so the player, villagers and car find the
ground themselves.

**Cars now touch the road.** Kenney authors vehicles origin-at-the-contact-
patch — tyres exactly on y=0, with each wheel node lifted by its own radius —
and every vehicle in every kit measures min.y == 0, verified across all 4442
GLBs in the library. We were dropping the model by `half.y` instead, a rule
that is right for a walker (a Mover's box bottom really is its feet) and
wrong for a raycast vehicle, whose suspension probes from the chassis origin.
The float was suspension travel plus wheel radius, 0.341 units, predicted in
closed form and matched by simulation to 1e-5. The same line also shifted in
world Y after rotation, so the mesh slid out from under a leaning chassis.
Both now derive from measured bounds along the body's own down axis — no
constant anywhere. Note the convention is real but NOT universal: track and
road pieces go to min.y = -1.0, so it must be read, never assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:59:45 +02:00
Admin
25ecf635f6 Controller prefab: character feel, follow camera, mount/dismount
Character feel went into character.rs rather than a fork — its own doc comment
argues a second controller would drift, and it was right. Acceleration and
deceleration ramps, deliberately ASYMMETRIC (starts have weight, stops are
crisp), partial air control, coyote time, jump buffering, variable jump height,
asymmetric gravity via the sim's own gravity_scale, landing damp.

New controller.rs: FollowCamera with separate position and rotation smoothing,
clamped pitch, a boom that snaps IN but eases OUT, look-ahead, speed pullback,
and delayed recentring that yields to the player's hand. Mount/Seat with camera
blending. movement_intent with deadzone and diagonal normalisation, so
diagonal WASD can't outrun cardinal.

ELEVEN FEEL TESTS, each named for the complaint it prevents: speed ramps
monotonically rather than stepping; stopping is crisper than starting; a late
jump off a ledge still registers; a jump pressed before touchdown fires on
landing; releasing early measurably lowers the apex; falling takes fewer ticks
than rising; air control is neither zero nor total; the camera cannot invert or
bury itself; dismounting puts you BESIDE the car, not inside it; the boom
recovers gradually rather than popping.

Two bugs it found in its own work, both invisible to endpoint-only tests:
- Variable-height jump broke an existing test: callers who set jump_pressed but
  never hold `jump` — the older single-flag convention, and what a generated
  game will most likely write — had their jump cut on the next tick. Now cutting
  requires evidence the button is genuinely held; full height for everyone else
- THE CAMERA BLEND NEVER ADVANCED. `blend / blend.max(eps)` is always 1.0, so t
  was pinned at 0: the rig held its old shape and then snapped — precisely the
  cut the blend exists to prevent. Tracked against blend_total with smoothstep

Four of its own tests were wrong before the code was: air control limits the
RATE, so enough air time still reaches full speed (the real claim is that the
same input builds speed slower airborne); the buffer window genuinely cannot
survive a long fall; two needed the character settled on the ground first.

NOT DONE, deliberately: the controller is not yet exposed as verbs
(game.player_character({})) and arcade still uses its own camera and WASD path.
The prefab and its defaults exist and are tested; binding is the remaining step,
and stopping beat half-wiring input routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:48:03 +02:00
Admin
4936c1b783 Arcade audio: sampled playback, mixer, and gameplay-driven emission
libs/game/audio (77 tests): WAV decoder (8/16/24/32-bit int, f32/f64,
total on malformed input), Ogg container with packet reassembly, sample
bank with on-demand decode + linear resample + LRU eviction and pinning so
a playing voice can't be evicted, and a 24-voice mixer with equal-power
pan, playback-rate pitch, click-free fades, priority+age voice stealing and
a limiter. Generation-tagged voice handles mean a stale handle cannot
retune a reused slot. The limiter test caught a real bug: peak was being
measured per-voice, but what clips is the SUM.

Emission is the point — sounds come from the engine observing gameplay,
not from script calls: Material/MaterialPair (order-independent, the softer
material names the sound), an impact curve mapping closing speed to
gain/pitch, and an AudioDirector with repeat-avoiding variant selection,
per-category volumes, per-pair cooldowns and a per-frame cap. A 200-contact
frame yields <=6 sounds and the cooldown map is proven not to leak.

Blocks now emit their own audio: car engine tracking revs, skid on lateral
slip, suspension thud on landing; character footsteps timed off the WALK
CYCLE rather than a timer, so feet and sound stay together when slowing;
jump/land scaled by fall speed; plane engine by throttle; lap and win
stings. RNG isolation proven: heavy audio work interleaved with world-rng
draws leaves the drawn sequence bit-identical to a silent run.

KNOWN GAP, reported rather than hidden: every Kenney audio pack is Ogg
Vorbis only (471 files, zero WAV), and the from-scratch Vorbis decoder is
NOT correct yet — setup header parses exactly, channels/rate/frame count
and envelope shape are right, but floor magnitudes come out ~75x low and
best correlation against an afconvert reference is 0.67. Two real bugs were
found and fixed en route (type-1 residue filled one codeword instead of the
partition; MDCT post-twiddle carried the pre-twiddle's 1/4 term). The
reference test is committed as #[ignore] with its measurements in the
message so it stays runnable. Ogg stays flagged unplayable in the asset
index; WAV and the --transcode path work today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 08:47:06 +02:00
Admin
c13c867547 Arcade M1b: libs/game/blocks — car/character/plane, brains, race kit; racing game in 72 lines
The thesis proof: examples/gamemaker/resources/fixtures/racing.splash is a
complete playable racing game in 72 lines (12-corner oval from a waypoint
loop, 4 cars, gates, standings, restart) with no physics, no AI and no lap
bookkeeping in script. Blocks run engine-side at 60Hz: Blocks::pre_step
(intent -> motion) before step_world, post_step after; Blocks is Clone and
snapshots beside GameWorld so a failed eval rolls both back together.

- game.car (4 suspension raycasts on a box3d rigid chassis), game.character
  (drives the existing mover sweep + owns idle/walk/run blending),
  game.plane; game.drive/autodrive/speed
- Brains: game.wander/chase/patrol/caught — the fixture's hand-rolled AI,
  absorbed engine-side
- Race kit: spawnpoint, checkpoint, place, race, standings, lap/rank/
  finished, score/score_of (Shared-tier data, ready for replication)

raycast_vehicle audit (defects documented in car.rs, still live in xr):
libm sin/cos in steering (unreplicable), a wrong side-impulse denominator
(iaj.dot(iaj) where it should be (I^-1 aJ).aJ), and an unguarded division.
Kept the structure, replaced Bullet's friction solver with an arcade force
model: suspension acts at the contact point, grip and drive through the
centre of mass, steering as yaw torque — no lateral force can generate
roll, so it is stable by construction rather than by roll_influence fudge.

Engine bug fixed at the source: GameWorld::new() left rng at 0 and
xorshift64* is a fixed point at zero, so rand() returned 0 forever for any
world built through the sim API. reset_content seeds on every eval, which
hid it from gamemaker entirely; found by a wander brain that never left home.

Tape probe byte-identical; racing fixture evals clean and drives (AI follow
the line, gates bank in order).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 00:54:22 +02:00