vehicles: a kind's numbers are a VehicleSpec the game hands over (vehicles_spec), not constants

The seat height, the horse's walk, gallop, acceleration and turn (VE_HORSE_*), its stamina (a gallop
over 6 m/s spends 6 a second, a walk gives back 3, 5 needed to gallop) and hay (0.02 a metre,
VEHICLE_HAY_MIN), the boat's stroke, turn and wind (VE_ROW, VE_ROW_TURN, VE_WIND) and where a rider
gets off (the horse's 1.2 m flank, the boat's 1.25 m wade) move into VehicleSpec (spec.ludic), held
per kind in VehiclesState.ve_specs and kept across a reset. vehicles_spec(kind, spec) sets one,
vehicle_spec(kind) asks it; a kind with no spec cannot be called. VEHICLE_HORSE and VEHICLE_BOAT stay
the kinds. The tests hand Maroon Lake's numbers in, and a new one holds a spec's walk and seat_h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 20:17:31 +03:00
parent a87f20c66e
commit 668a9ecf69
10 changed files with 137 additions and 34 deletions

8
changes/vehicles-spec.md Normal file
View file

@ -0,0 +1,8 @@
bump: minor
type: feature
**`ludic.vehicles`: a kind's numbers are the game's.** The seat height, a horse's walk, gallop,
acceleration and turn, its stamina and hay, a boat's stroke, turn and the wind on it, and where a rider
gets off (the horse's flank, the boat's wade) were constants in the package; they are a `VehicleSpec` a
game hands each kind with `vehicles_spec(kind, spec)` before any vehicle of it is called, and
`vehicle_spec(kind)` asks them. A kind with no spec cannot be called, and a reset keeps the specs.
`VEHICLE_HAY_MIN` is gone: it is the horse spec's `hay_min`.

View file

@ -16,12 +16,17 @@ through the game).
## The rules it keeps
- **A kind's numbers are the game's data.** The package keeps the rules (a walk and a gallop on
stamina, a stroke and a turn, where a rider gets off) and no numbers: the game hands each kind a
`VehicleSpec` with `vehicles_spec(kind, spec)` - in Maroon Lake from its `VehicleKinds` table -
once, before any vehicle is made. A kind with no spec cannot be called; a reset keeps the specs.
- **Nothing is parked for anyone.** A vehicle has an `owner` and a `rider`; `vehicle_find(kind)` is
this player's own and nothing else may pick "the" boat. A place is taken while a boat lies in it
or a horse is tied at it (`home_x`, `home_z`), within half a place's spacing.
- **Where a body is put down is one question with one answer.** Getting off asks the rider's
`safe_spot` - a wade of up to 1.25 m within its search from a boat, beside the right flank from a
horse - and a boat too far out refuses and says so (`VEHICLE_NO_LANDING`) rather than dropping the
`safe_spot` - a wade of up to the spec's `off_depth` within its search from a boat, `off_side` out
by the right flank from a horse - and a boat too far out refuses and says so (`VEHICLE_NO_LANDING`) rather than dropping the
rider in the lake.
- **A boat is a body in the game's physics** (`VehicleHull`, phase 16; a buoyant Jolt hull in Maroon
Lake). The package rows it - the stick forward and back is a stroke along the bow, left and right
@ -66,7 +71,9 @@ export port VehicleRider {
| | |
| --- | --- |
| `VEHICLE_HORSE`, `VEHICLE_BOAT`, `VEHICLE_PLACES`, `VEHICLE_HAY_MIN` | the kinds, four places each, and the hay a horse needs to go |
| `VEHICLE_HORSE`, `VEHICLE_BOAT`, `VEHICLE_PLACES`, `VEHICLE_KINDS` | the kinds, four places each, and how many kinds may have a spec |
| `VehicleSpec { seat_h, walk, run, acc, turn, row, row_turn, wind, hay_per_m, hay_min, run_min, tire_at, tire, rest, off_side, off_depth }` | a kind's numbers: the seat, a horse's pace, stamina and hay, a boat's oars and wind, where one gets off |
| `vehicles_spec(kind, spec)`, `vehicle_spec(kind)` | set a kind's numbers (at boot), and ask them |
| `Vehicle { kind, nid, x, y, z, yaw, speed, fuel, food, seat_h, owned, owner, rider, home_x, home_z }` | a vehicle (change one through the verbs) |
| `vehicles_dock_at(x, z, yaw)`, `vehicles_rail_at(x, z, yaw)`, `vehicles_dock_yaw()` | where the places are measured from |
| `vehicle_berth_x/z(k)`, `vehicle_post_x/z(k)`, `vehicle_bay_x/z(k)`, `vehicle_place_x/z(kind, k)` | the places |
@ -85,7 +92,8 @@ export port VehicleRider {
ludic test packages/ludic.vehicles
```
A fake shore with a pier head and a trough: berths and bays, calling and fetching, a taken place, a
A fake shore with a pier head and a trough, and Maroon Lake's numbers as the specs: a kind with no
spec refused and a spec's walk and seat obeyed, berths and bays, calling and fetching, a taken place, a
player leaving, a hungry horse, riding a horse into a trunk and to the water's edge; a real Jolt hull
rowed out, losing its stroke to the swell, turned, stopped by the shore's bed and drifted by the
wind; getting off near a shore and far from one, and the save.

View file

@ -3,17 +3,17 @@
@alloc_ok("a player's own boat or horse, made the first time it is called and fetched after")
function ve_add(vehicles_st: VehiclesState, kind: int, owner: int, x: float, z: float, yaw: float) -> Vehicle {
if kind != VEHICLE_HORSE and kind != VEHICLE_BOAT { return null }
let spec = vehicle_spec(vehicles_st, kind)
if spec == null { return null }
let v = new Vehicle
v.nid = VehicleWorld.next_id()
v.kind = kind
v.owner = owner
v.yaw = yaw
v.seat_h = spec.seat_h
if kind == VEHICLE_HORSE {
v.seat_h = 1.35
v.fuel = 100.0
v.food = 40.0
} else {
v.seat_h = 0.30
}
ve_put(v, x, z)
ve_file(vehicles_st.ve, v)

View file

@ -1,19 +1,16 @@
# horse.ludic - a horse under the stick: a walk, a canter on stamina, hay for every metre; it will
# not step into water, and its legs (VehicleLegs) meet the ground and whatever stands in its way
const VE_HORSE_WALK: float = 4.5
const VE_HORSE_GALLOP: float = 11.0
const VE_HORSE_ACC: float = 4.0
const VE_HORSE_TURN: float = 1.6
# (its numbers are its kind's VehicleSpec: walk, run, acc, turn, the stamina and the hay)
function ve_ride_horse(vehicles_st: mut VehiclesState, v: Vehicle, ix: float, iz: float, run: bool, dt: float) -> void {
var top = VE_HORSE_WALK
if run and v.fuel > 5.0 { top = VE_HORSE_GALLOP }
v.speed = v.speed + Math.clamp(iz * top - v.speed, -VE_HORSE_ACC * dt, VE_HORSE_ACC * dt)
let sp = vehicle_spec(vehicles_st, v.kind)
var top = sp.walk
if run and v.fuel > sp.run_min { top = sp.run }
v.speed = v.speed + Math.clamp(iz * top - v.speed, -sp.acc * dt, sp.acc * dt)
# Steering follows SIGNED speed: backing up with the stick left swings the nose right
if Math.abs(v.speed) > 0.3 {
var rate = Math.clamp(Math.abs(v.speed) / 3.0, 0.4, 1.0)
if v.speed < 0.0 { rate = -rate }
v.yaw = v.yaw - ix * VE_HORSE_TURN * (dt * rate)
v.yaw = v.yaw - ix * sp.turn * (dt * rate)
}
let fx = -Math.sin(v.yaw)
let fz = -Math.cos(v.yaw)
@ -24,7 +21,7 @@ function ve_ride_horse(vehicles_st: mut VehiclesState, v: Vehicle, ix: float, iz
return
}
ve_legs_walk(vehicles_st, v, fx * v.speed, fz * v.speed, dt)
ve_stamina(v, d, dt)
ve_stamina(sp, v, d, dt)
}
# the legs carry it from where it is; what they covered along its heading is its speed now, never
@ -43,8 +40,8 @@ function ve_legs_walk(vehicles_st: mut VehiclesState, v: Vehicle, vx: float, vz:
}
# a gallop spends stamina and a walk gives it back; every metre costs hay
function ve_stamina(v: Vehicle, d: float, dt: float) -> void {
if Math.abs(v.speed) > 6.0 { v.fuel = Math.max(v.fuel - 6.0 * dt, 0.0) } else { v.fuel = Math.min(v.fuel + 3.0 * dt, 100.0) }
v.food = Math.max(v.food - Math.abs(d) * 0.02, 0.0)
function ve_stamina(sp: VehicleSpec, v: Vehicle, d: float, dt: float) -> void {
if Math.abs(v.speed) > sp.tire_at { v.fuel = Math.max(v.fuel - sp.tire * dt, 0.0) } else { v.fuel = Math.min(v.fuel + sp.rest * dt, 100.0) }
v.food = Math.max(v.food - Math.abs(d) * sp.hay_per_m, 0.0)
}

View file

@ -5,6 +5,7 @@ module ludic_vehicles uses ludic_base
numbers float
import "ludic.base"
import "kinds.ludic"
import "spec.ludic"
import "table.ludic"
import "ports.ludic"
import "fact_pool.ludic"

View file

@ -1,12 +1,10 @@
# mount.ludic - getting on, and getting off onto a spot the rider's own safe-spot rule found: the
# same one answer a seat and a stand-up use, so a landing is never inside a boulder or on a bank
# the body cannot climb off
export const VEHICLE_HAY_MIN: float = 10.0 # a horse with less hay than this will not go
# this machine's player onto v; false when it is somebody else's, taken, or a hungry horse
export function vehicle_mount(vehicles_st: mut VehiclesState, v: Vehicle) -> bool {
if v == null or v.owner != 0 or v.rider >= 0 or vehicles_st.ve_cur != null { return false }
if v.kind == VEHICLE_HORSE and v.food < VEHICLE_HAY_MIN {
if v.kind == VEHICLE_HORSE and v.food < vehicle_spec(vehicles_st, v.kind).hay_min {
ve_say(vehicles_st, VEHICLE_HUNGRY, v, v.food)
return false
}
@ -17,21 +15,22 @@ export function vehicle_mount(vehicles_st: mut VehiclesState, v: Vehicle) -> boo
return true
}
# Off, onto the nearest spot a body could stand and walk off: from a boat a wade of up to 1.25 m
# within ten metres, from a horse beside its right flank. False (and said) when a boat is too far out.
# Off, onto the nearest spot a body could stand and walk off: from a boat a wade of up to its spec's
# off_depth, from a horse off_side out by its right flank. False (and said) when a boat is too far out.
export function vehicle_dismount(vehicles_st: mut VehiclesState) -> bool {
let v = vehicles_st.ve_cur
if v == null { return false }
let sp = vehicle_spec(vehicles_st, v.kind)
if v.kind == VEHICLE_BOAT {
if not VehicleRider.safe_spot(v.x, v.z, VehicleWorld.water(v.x, v.z), 1.25) {
if not VehicleRider.safe_spot(v.x, v.z, VehicleWorld.water(v.x, v.z), sp.off_depth) {
ve_say(vehicles_st, VEHICLE_NO_LANDING, v, 0.0)
return false
}
VehicleRider.step_off(VehicleRider.safe_x(), VehicleRider.safe_z())
ve_moor(v)
} else {
let wx = v.x + Math.cos(v.yaw) * 1.2
let wz = v.z - Math.sin(v.yaw) * 1.2
let wx = v.x + Math.cos(v.yaw) * sp.off_side
let wz = v.z - Math.sin(v.yaw) * sp.off_side
if VehicleRider.safe_spot(wx, wz, VehicleWorld.ground(v.x, v.z), 0.0) {
VehicleRider.step_off(VehicleRider.safe_x(), VehicleRider.safe_z())
} else { VehicleRider.step_off(v.x, v.z) }

View file

@ -52,6 +52,7 @@ export state VehiclesState {
ve_sz: float = 0.0
ve: VehicleTable = ve__new()
ve_cur: Vehicle = null
@max(8) ve_specs: []VehicleSpec = ve_specs_new() # each kind's numbers (vehicles_spec), kept across a reset
ve_dock_x: float = 0.0
ve_dock_z: float = 0.0
ve_dock_yaw: float = 0.0

View file

@ -1,9 +1,6 @@
# ride.ludic - the rider's stick steers what they are on: a horse walks, canters on stamina and
# stops at water, a slope or a trunk; a boat is rowed - a stroke and a turn handed to its hull,
# which the water floats, the wind drifts and the lake bed stops
const VE_ROW: float = 630.0 # N: a full stroke forward, about 3 m/s on Maroon Lake's hull
const VE_ROW_TURN: float = 270.0 # N.m: the oars pulled against each other, about 1.1 rad/s
const VE_WIND: float = 70.0 # N on a boat in a full wind
export function vehicle_update(vehicles_st: mut VehiclesState, ix: float, iz: float, run: bool, dt: float) -> void {
let v = vehicles_st.ve_cur
@ -18,7 +15,8 @@ export function vehicle_update(vehicles_st: mut VehiclesState, ix: float, iz: fl
function ve_row(vehicles_st: mut VehiclesState, v: Vehicle, ix: float, iz: float, dt: float) -> void {
ve_hull_read(v)
let take = ve_swell(vehicles_st, v, iz, dt)
VehicleHull.row(v.nid, iz * VE_ROW * (1.0 - take), -ix * VE_ROW_TURN)
let sp = vehicle_spec(vehicles_st, v.kind)
VehicleHull.row(v.nid, iz * sp.row * (1.0 - take), -ix * sp.row_turn)
let d = Math.abs(v.speed) * dt
if d > 0.0 { ve_moved_add(vehicles_st, v, d) }
}
@ -55,13 +53,14 @@ function ve_swell(vehicles_st: mut VehiclesState, v: Vehicle, iz: float, dt: flo
# called or left, its hull read back and held there
export function vehicles_tick(vehicles_st: VehiclesState, dt: float) -> void {
let l = ve_all(vehicles_st)
let w = VehicleWorld.wind() * VE_WIND
let w = VehicleWorld.wind()
let wy = VehicleWorld.wind_yaw()
for i in 0 .. len(l) {
let v = l[i]
if v.kind != VEHICLE_BOAT { continue }
if v.rider >= 0 {
if w > 0.0 { VehicleHull.drift(v.nid, -Math.sin(wy) * w, -Math.cos(wy) * w) }
let f = w * vehicle_spec(vehicles_st, v.kind).wind
if f > 0.0 { VehicleHull.drift(v.nid, -Math.sin(wy) * f, -Math.cos(wy) * f) }
continue
}
if not VehicleWorld.active(v.x, v.z) { continue } # outside every zone: left where it lies

View file

@ -0,0 +1,40 @@
# spec.ludic - a kind's numbers, handed in by the game from its own table before any vehicle of the
# kind is made (vehicles_spec): where the rider sits, its pace, its oars, its hay, where one gets off
export property VehicleSpec {
seat_h: float = 0.0 # m: the rider sits this far above it
walk: float = 0.0 # m/s: a horse's walk
run: float = 0.0 # m/s: its gallop
acc: float = 0.0 # m/s2: how fast it gets there
turn: float = 0.0 # rad/s at a full stick
row: float = 0.0 # N: a full stroke forward
row_turn: float = 0.0 # N.m: the oars pulled against each other
wind: float = 0.0 # N on it in a full wind
hay_per_m: float = 0.0 # hay (0..100) every metre costs
hay_min: float = 0.0 # with less hay than this it will not go
run_min: float = 0.0 # stamina (0..100) a gallop needs
tire_at: float = 0.0 # m/s: faster than this is a gallop, and tires it
tire: float = 0.0 # stamina a second spent at a gallop
rest: float = 0.0 # and given back a second at a walk
off_side: float = 0.0 # m: a rider gets off this far out to its right
off_depth: float = 0.0 # m: the deepest wade a rider gets off into
}
export const VEHICLE_KINDS: int = 8 # kinds a game may give numbers to, 0 .. 7
function ve_specs_new() -> []VehicleSpec {
let l = new []VehicleSpec
for i in 0 .. VEHICLE_KINDS { push(l, null) }
return l
}
# kind's numbers from now on; a kind that has none cannot be called
export function vehicles_spec(vehicles_st: mut VehiclesState, kind: int, spec: VehicleSpec) -> void {
if kind < 0 or kind >= VEHICLE_KINDS { return }
vehicles_st.ve_specs[kind] = spec
}
# a kind's numbers, or null when the game gave it none
export function vehicle_spec(vehicles_st: VehiclesState, kind: int) -> VehicleSpec {
if kind < 0 or kind >= VEHICLE_KINDS { return null }
return vehicles_st.ve_specs[kind]
}

View file

@ -158,8 +158,37 @@ program VehiclesTest {
const EAST: float = -1.5707963
# Maroon Lake's numbers for its two kinds (its vehicle_kinds.lres), as a game hands them in
function horse_spec() -> VehicleSpec {
let s = new VehicleSpec
s.seat_h = 1.35
s.walk = 4.5
s.run = 11.0
s.acc = 4.0
s.turn = 1.6
s.hay_per_m = 0.02
s.hay_min = 10.0
s.run_min = 5.0
s.tire_at = 6.0
s.tire = 6.0
s.rest = 3.0
s.off_side = 1.2
return s
}
function boat_spec() -> VehicleSpec {
let s = new VehicleSpec
s.seat_h = 0.30
s.row = 630.0
s.row_turn = 270.0
s.wind = 70.0
s.off_depth = 1.25
return s
}
function fresh(vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) -> void {
vehicles_reset(vehicles_st)
vehicles_spec(vehicles_st, VEHICLE_HORSE, horse_spec())
vehicles_spec(vehicles_st, VEHICLE_BOAT, boat_spec())
vehicles_dock_at(vehicles_st, 10.0, 0.0, EAST)
vehicles_rail_at(vehicles_st, 0.0, 5.0, 0.0)
vehicles_test_st.made = 0
@ -189,6 +218,27 @@ program VehiclesTest {
expect_near(vehicle_bay_z(vehicles_st, 2), 3.6, 0.01)
}
test "a kind with no numbers cannot be called; its spec's numbers are the horse's pace and its seat" (physics_st: mut PhysicsState, vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) {
fresh(vehicles_st, vehicles_test_st)
vehicles_spec(vehicles_st, VEHICLE_BOAT, null)
expect(not vehicle_call_for(vehicles_st, 0, VEHICLE_BOAT, 0))
let slow = horse_spec()
slow.walk = 2.5
slow.seat_h = 1.1
vehicles_spec(vehicles_st, VEHICLE_HORSE, slow)
expect(vehicle_spec(vehicles_st, VEHICLE_HORSE) == slow)
vehicle_call_for(vehicles_st, 0, VEHICLE_HORSE, 0)
let h = vehicle_find(vehicles_st, VEHICLE_HORSE)
expect_near(h.seat_h, 1.1, 0.001)
vehicle_mount(vehicles_st, h)
ride(physics_st, vehicles_st, 0.0, 1.0, false, 3.0)
expect_near(h.speed, 2.5, 0.05)
expect_near(vehicles_test_st.ry - h.y, 1.1, 0.001)
vehicle_drop(vehicles_st)
vehicles_reset(vehicles_st)
expect(vehicle_spec(vehicles_st, VEHICLE_HORSE) == slow) # a reset keeps the numbers
}
test "the first call makes a vehicle, a later one fetches it, and a taken place refuses" (vehicles_st: mut VehiclesState, vehicles_test_st: mut VehiclesTestState) {
fresh(vehicles_st, vehicles_test_st)
expect(vehicle_call_for(vehicles_st, 0, VEHICLE_BOAT, 0))