Use the original handling, walls and projection from the executable

Decoded from SKIDMARKS_CD32's machine code:

- Car physics: the per-frame engine/grip/steering update, gravity and
  jumps, the hill effect on the throttle, and body tilt. Replaces the
  reconstructed handling model.
- The meaning of every .set value (separate drag, grip and power for
  human and computer drivers; steering, gravity, tow hitch), and the
  six handling presets, offered by a new Handling menu option.
- Walls: the road edges are quadratic B-splines through the gates'
  ends. They match the painted road edges.
- The real projection, which lifts points by their height; the height
  map is indexed by world cells, not screen pixels.
- Tracks have one more gate than the stored count: the last gate was
  being dropped on every track.

Computer drivers now follow the road between the walls and steer like
the original's. Vehicle assets store the values as `handling`; older
files still load. Docs describe the decoded logic.
This commit is contained in:
2026-10-20 07:32:46 +00:00
parent ee895e0455
commit 6226c88d4e
44 changed files with 1283 additions and 791 deletions
+19 -3
View File
@@ -60,6 +60,8 @@ pub struct Settings {
pub opponents: usize,
/// Every car tows a caravan.
pub caravans: bool,
/// 0 = each car's own handling, else a preset (`vehicle::PRESETS`).
pub handling: usize,
pub music: Music,
}
@@ -73,6 +75,7 @@ enum Item {
Car2,
Opponents,
Caravans,
Handling,
Music,
Quit,
}
@@ -150,6 +153,7 @@ impl App {
cars,
opponents: 3,
caravans: false,
handling: 0,
music,
},
screen: Screen::Menu { cursor: 0 },
@@ -179,6 +183,7 @@ impl App {
if self.data.vehicles.iter().any(|v| v.trailer) {
v.push(Item::Caravans);
}
v.push(Item::Handling);
if self.audio.has_music() {
v.push(Item::Music);
}
@@ -209,6 +214,7 @@ impl App {
team: p,
name: format!("PLAYER {}", p + 1),
trailer,
preset: self.settings.handling,
});
}
let mut rng = seed.wrapping_mul(2654435761) | 1;
@@ -222,6 +228,7 @@ impl App {
team: humans + i,
name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(),
trailer,
preset: self.settings.handling,
});
}
// Humans start at the back, as is tradition.
@@ -347,6 +354,7 @@ impl App {
}
Item::Opponents => s.opponents = wrap(s.opponents, 8 - s.players + 1, delta),
Item::Caravans => s.caravans = !s.caravans,
Item::Handling => s.handling = wrap(s.handling, vehicle::PRESETS.len() + 1, delta),
Item::Music => {
const ORDER: [Music; 3] = [Music::Menus, Music::Always, Music::Off];
let at = ORDER.iter().position(|&m| m == s.music).unwrap_or(0);
@@ -370,6 +378,7 @@ impl App {
| Item::Laps
| Item::Players
| Item::Caravans
| Item::Handling
| Item::Music
| Item::Opponents => {
let s = &self.settings;
@@ -505,6 +514,13 @@ impl App {
Item::Caravans => {
format!("CARAVANS < {} >", if s.caravans { "ON" } else { "OFF" })
}
Item::Handling => format!(
"HANDLING < {} >",
match s.handling {
0 => "CUSTOM",
n => vehicle::PRESETS[n - 1].0,
}
),
Item::Music => format!(
"MUSIC < {} >",
match s.music {
@@ -729,7 +745,7 @@ pub fn draw_race(
};
let e = &race.entrants[ei];
// Look ahead in the direction of travel.
let target = track::to_screen(e.car.x + e.car.vx * 0.3, e.car.y + e.car.vy * 0.3);
let target = track::project(e.car.x + e.car.vx * 0.3, e.car.y + e.car.vy * 0.3, e.car.z);
cams[p].0 += (target.0 - cams[p].0) * 0.12;
cams[p].1 += (target.1 - cams[p].1) * 0.12;
draw_world(f, data, race, *view, &mut cams[p], false, 1.0);
@@ -774,7 +790,7 @@ pub fn draw_world(
for (i, e) in race.entrants.iter().enumerate() {
order.push((e.car.screen_pos().1, i, false));
if let Some(t) = e.trailer {
order.push((track::to_screen(t.x, t.y).1, i, true));
order.push((race.track.to_screen(t.x, t.y).1, i, true));
}
}
order.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -784,7 +800,7 @@ pub fn draw_world(
let pal = palette::car_palette(c.team);
if is_trailer {
let t = e.trailer.unwrap();
let (sx, sy) = track::to_screen(t.x, t.y);
let (sx, sy) = race.track.to_screen(t.x, t.y);
let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2);
let y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2);
let pix =
+124 -35
View File
@@ -13,18 +13,27 @@
//! name of the set of tracks, NUL-terminated.
//!
//! Each track section (281240 bytes once decrunched):
//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels
//! 0x0000 height map, 64x64 BE16, row-major by world y then x. Each cell
//! is one unit of the original's world (16 of ours); the ground
//! stands value / 64 pixels above the plane
//! 0x2000 8192 bytes, unused (always zero on the retail disks)
//! 0x4000 BE16 gate count, start x, start y (screen pixels), flags
//! 0x4008 gates: x1 y1 x2 y2 BE16, world coordinates in 1/32 units
//! 0x4000 BE16 last gate index (one less than the gate count), start x,
//! start y (screen pixels), flags
//! 0x4008 70 gate slots: x1 y1 x2 y2 BE16, world coordinates in 1/32
//! units. Slots past the last gate repeat the list.
//! 0x4A98 four 1024x512 bitplanes (65536 bytes each), giving 16 colours
//!
//! The world is an isometric ground plane rotated 45 degrees to the picture:
//! screen x = wx - wy + 512, screen y = (wx + wy) * 31/64 - 252. The constants
//! were fitted so that 96% of gate samples over all 24 tracks land on road
//! pixels (against 40-70% for a plain 2:1 squash). The stored start point
//! only lands on the road on 14 of the 24 tracks, so the grid is placed from
//! the gates instead.
//! The world is an isometric ground plane rotated 45 degrees to the picture.
//! The executable projects a point at height z (pixels) with
//! screen x = wx - wy + 512, screen y = (wx + wy) / 2 - 156 - z, which puts
//! 98% of gate samples over all 24 tracks on road pixels once z is the
//! ground height. The stored start point only lands on the road on 14 of
//! the 24 tracks, so the grid is placed from the gates instead.
//!
//! The gates are also the control points of the road edges: the executable
//! runs a uniform quadratic B-spline through the gates' first ends for one
//! edge and their second ends for the other, and those curves are the
//! walls. They follow the painted road edge closely (see `Track::edges`).
use super::image;
use super::palette::{self, Rgb};
@@ -35,19 +44,24 @@ pub const W: usize = 1024;
pub const H: usize = 512;
pub const HM_W: usize = 64;
pub const HM_H: usize = 64;
pub const CELL_W: f32 = (W / HM_W) as f32;
pub const CELL_H: f32 = (H / HM_H) as f32;
/// World units per height-map cell: one unit of the original's world.
pub const CELL: f32 = 16.0;
/// Samples per gate along the road edges.
pub const EDGE_STEPS: usize = 8;
const SECTION_SIZE: usize = 0x44a98;
const GATES_AT: usize = 0x4000;
/// Room for gates in a track section; slots past the last gate repeat the
/// list from the start.
const GATE_SLOTS: usize = 70;
const PLANES_AT: usize = 0x4a98;
const PLANE_SIZE: usize = W * H / 8;
const SECTIONS: usize = 7;
const ISO_X0: f32 = 512.0;
const ISO_Y_SCALE: f32 = 31.0 / 64.0;
const ISO_Y0: f32 = -252.0;
const ISO_Y0: f32 = -156.0;
pub fn to_screen(wx: f32, wy: f32) -> (f32, f32) {
(wx - wy + ISO_X0, (wx + wy) * ISO_Y_SCALE + ISO_Y0)
/// Screen position of a world point `z` pixels above the ground plane.
pub fn project(wx: f32, wy: f32, z: f32) -> (f32, f32) {
(wx - wy + ISO_X0, (wx + wy) * 0.5 + ISO_Y0 - z)
}
pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool {
@@ -89,10 +103,13 @@ pub struct Track {
/// Palette indices, W x H.
pub pixels: Vec<u8>,
pub palette: [Rgb; 16],
/// Height samples in 8.8 fixed point.
/// Height samples; the ground is value / 64 pixels high.
pub height: Vec<u16>,
/// Gates in world coordinates.
pub gates: Vec<Gate>,
/// The road edges sampled EDGE_STEPS times per gate, as a closed loop
/// of cross-sections (one point on each edge). Built from the gates.
pub edges: Vec<Gate>,
/// Start position and flags as stored. Kept for reference: the start
/// point does not line up with the road reliably, and the flags' meaning
/// is unknown.
@@ -201,10 +218,11 @@ impl Track {
}
let height = (0..HM_W * HM_H).map(|i| be16(d, i * 2)).collect();
let count = be16(d, GATES_AT) as usize;
// The stored value is the index of the last gate.
let count = be16(d, GATES_AT) as usize + 1;
let start = (be16(d, GATES_AT + 2) as f32, be16(d, GATES_AT + 4) as f32);
let flags = be16(d, GATES_AT + 6);
if count == 0 || GATES_AT + 8 + count * 8 > PLANES_AT {
if count > GATE_SLOTS {
return Err(format!("implausible gate count {count}"));
}
let gates = (0..count)
@@ -242,28 +260,35 @@ impl Track {
thumbnail: None,
height,
gates,
edges: Vec::new(),
start,
flags,
})
}
.with_edges())
}
/// Ground height (in height units) at a screen position, bilinear.
pub fn height_at(&self, sx: f32, sy: f32) -> f32 {
let fx = (sx / CELL_W - 0.5).clamp(0.0, (HM_W - 1) as f32);
let fy = (sy / CELL_H - 0.5).clamp(0.0, (HM_H - 1) as f32);
fn with_edges(mut self) -> Track {
self.edges = edges(&self.gates);
self
}
/// Ground height in pixels at a world position, bilinear between cells
/// as the executable does.
pub fn ground(&self, wx: f32, wy: f32) -> f32 {
let fx = (wx / CELL).clamp(0.0, (HM_W - 1) as f32);
let fy = (wy / CELL).clamp(0.0, (HM_H - 1) as f32);
let (x0, y0) = (fx as usize, fy as usize);
let (x1, y1) = ((x0 + 1).min(HM_W - 1), (y0 + 1).min(HM_H - 1));
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
let h = |x: usize, y: usize| self.height[y * HM_W + x] as f32 / 256.0;
let h = |x: usize, y: usize| self.height[y * HM_W + x] as f32 / 64.0;
let top = h(x0, y0) * (1.0 - tx) + h(x1, y0) * tx;
let bot = h(x0, y1) * (1.0 - tx) + h(x1, y1) * tx;
top * (1.0 - ty) + bot * ty
}
/// Height at a world position.
pub fn height_world(&self, wx: f32, wy: f32) -> f32 {
let (sx, sy) = to_screen(wx, wy);
self.height_at(sx, sy)
/// Screen position of a point on the ground.
pub fn to_screen(&self, wx: f32, wy: f32) -> (f32, f32) {
project(wx, wy, self.ground(wx, wy))
}
pub fn pixel(&self, sx: f32, sy: f32) -> u8 {
@@ -279,7 +304,7 @@ impl Track {
}
pub fn on_road_world(&self, wx: f32, wy: f32) -> bool {
let (sx, sy) = to_screen(wx, wy);
let (sx, sy) = self.to_screen(wx, wy);
on_screen(sx, sy, 0.0) && self.on_road(sx, sy)
}
@@ -382,9 +407,11 @@ impl Track {
thumbnail: None,
height,
gates,
edges: Vec::new(),
start,
flags,
})
}
.with_edges())
}
/// The thumbnail as an indexed PNG in the track's palette.
@@ -414,11 +441,14 @@ impl Track {
"# Skids track {}. Lines starting with # are comments.\n\
#\n\
# The picture is track.png: 1024x512, 16 colours. Its palette is the\n\
# track's palette. Colours 8-15 are road (full grip), 0-7 are scenery.\n\
# track's palette. Colours 8-15 are road, 0-7 are scenery (dusty).\n\
#\n\
# gate x1 y1 x2 y2: lap gates in race order, in world units. Gate 0 is\n\
# the start/finish line; the grid forms up behind it. World to picture:\n\
# picture x = wx - wy + 512, picture y = (wx + wy) * 31/64 - 252\n",
# the start/finish line; the grid forms up behind it. The gates' ends\n\
# also shape the walls: each edge of the road is a smooth curve\n\
# through the matching ends of the gates. World to picture, for a\n\
# point at height z (pixels):\n\
# picture x = wx - wy + 512, picture y = (wx + wy) / 2 - 156 - z\n",
self.name
);
for g in &self.gates {
@@ -427,8 +457,8 @@ impl Track {
t.push_str(&format!(
"\n# Name of the set of tracks this one belongs to, shown in the menu.\nset {}\n\n\
# As stored in the original data; not used by the game.\nstart {} {}\nflags {}\n\n\
# height: 64 rows of 64 values, ground height x 256. Each value covers\n\
# 16x8 pixels of the picture.\nheight\n",
# height: 64 rows (world y) of 64 values (world x), ground height in\n\
# pixels x 64. Each value covers 16 x 16 world units.\nheight\n",
self.set_name, self.start.0, self.start.1, self.flags
));
for row in self.height.chunks(HM_W) {
@@ -444,3 +474,62 @@ impl Track {
self.pixels.iter().map(|&i| pal[i as usize]).collect()
}
}
/// The road edges: a uniform quadratic B-spline through the gates' ends,
/// sampled EDGE_STEPS times per gate. Sample k * EDGE_STEPS lies halfway
/// between gates k and k + 1, as in the executable.
pub fn edges(gates: &[Gate]) -> Vec<Gate> {
let n = gates.len();
if n < 3 {
return gates.to_vec();
}
let mut out = Vec::with_capacity(n * EDGE_STEPS);
for i in 0..n {
let (p0, p1, p2) = (gates[i], gates[(i + 1) % n], gates[(i + 2) % n]);
for s in 0..EDGE_STEPS {
let f = s as f32 / EDGE_STEPS as f32;
let (w0, w2) = ((1.0 - f) * (1.0 - f) * 0.5, f * f * 0.5);
let w1 = 1.0 - w0 - w2;
let mix = |a: (f32, f32), b: (f32, f32), c: (f32, f32)| {
(
a.0 * w0 + b.0 * w1 + c.0 * w2,
a.1 * w0 + b.1 * w1 + c.1 * w2,
)
};
out.push(Gate {
a: mix(p0.a, p1.a, p2.a),
b: mix(p0.b, p1.b, p2.b),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edges_run_between_gate_midpoints() {
let g = |x: f32| Gate {
a: (x, 0.0),
b: (x, 10.0),
};
let gates = [g(0.0), g(10.0), g(30.0), g(60.0)];
let e = edges(&gates);
assert_eq!(e.len(), gates.len() * EDGE_STEPS);
// Each gate's run of samples starts halfway between it and the next.
assert_eq!(e[0].a, (5.0, 0.0));
assert_eq!(e[EDGE_STEPS].b, (20.0, 10.0));
// In between, the curve stays inside the control polygon.
assert!(e[EDGE_STEPS / 2].a.0 > 5.0 && e[EDGE_STEPS / 2].a.0 < 20.0);
}
#[test]
fn projection_lifts_by_height() {
let (x0, y0) = project(100.0, 50.0, 0.0);
let (x1, y1) = project(100.0, 50.0, 10.0);
assert_eq!((x0, y0), (562.0, -81.0));
assert_eq!((x1, y1), (x0, y0 - 10.0));
}
}
+197 -168
View File
@@ -7,9 +7,9 @@
//! file holds the display name and handling.
//!
//! Settings (`vehicles/*.set`, disk 1): car name and driver name, each
//! ending in a newline, then 544 bytes: 16 BE16 values (11 used; their
//! meaning lives in the executable and is not decoded yet) and a 64x32
//! two-colour-plane picture for the menu, plane 0 then plane 1.
//! ending in a newline, then 544 bytes: 16 BE16 handling values (see
//! `SetValues`) and a 64x32 two-colour-plane picture for the menu, plane 0
//! then plane 1.
//!
//! Original data (`vehicles/*.aga`):
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes.
@@ -35,105 +35,135 @@ const BANK_BYTES: usize = TILTS * HEADINGS * FRAME_BYTES;
pub const SHEET_W: usize = SPR_W * HEADINGS;
pub const SHEET_H: usize = SPR_H * TILTS;
/// Handling. The retail executable is not available, so these values are a
/// reconstruction; edit them in the vehicle's `.txt` asset.
/// The 16 handling values of a `.set` file. Their meaning, from the
/// executable's set-up code (word k is `set[k]`):
///
/// | Word | Use |
/// |------|-----|
/// | 0, 1, 5 | computer drivers' drag, grip and power |
/// | 2 | steering rate |
/// | 3 | read into the car but never used |
/// | 4 | gravity |
/// | 6 | tow hitch distance behind the car |
/// | 8, 9, 10 | human drivers' drag, grip and power |
///
/// Words 7 and 11-15 aren't read.
pub type SetValues = [u16; 16];
/// The Camaro's values, for vehicles that come without any.
pub const DEFAULT_SET: SetValues = [54, 35, 32, 8, 7, 22, 22, 16, 49, 14, 53, 0, 0, 0, 0, 0];
/// One driver profile. The original's per-frame update (in its own units,
/// where one world unit is a height-map cell) is:
///
/// ```text
/// target = (speed + throttle * power) * drag
/// velocity += grip * (heading * target - velocity)
/// speed += (target - speed) / 4
/// ```
///
/// so top speed is `power * drag / (1 - drag)` per frame, and `grip` is
/// the fraction of the gap between where the car points and where it is
/// going that closes each frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spec {
/// Top speed on tarmac, world pixels per second.
pub top: f32,
pub accel: f32,
pub brake: f32,
/// Lateral grip: how quickly sideways motion is killed (1/s).
pub struct Handling {
pub drag: f32,
pub grip: f32,
/// Steering rate at speed (rad/s).
pub turn: f32,
pub mass: f32,
/// Speed and grip multiplier off the road.
pub offroad: f32,
pub power: f32,
}
pub const BASE_SPEC: Spec = Spec {
top: 200.0,
accel: 170.0,
brake: 320.0,
grip: 5.0,
turn: 3.6,
mass: 1.0,
offroad: 0.55,
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spec {
pub player: Handling,
pub computer: Handling,
/// Steering at full lock, in 64ths of a turn per frame.
pub turn: f32,
/// Pixels per frame per frame.
pub gravity: f32,
/// Tow hitch distance behind the car's centre, world units.
pub hitch: f32,
/// Weight in collisions between cars. Not from the original data.
pub mass: f32,
}
/// Starting values used when importing the original data.
pub fn default_spec(id: &str) -> Spec {
let b = BASE_SPEC;
impl Spec {
/// Decodes the values as the executable does (fixed-point shifts).
pub fn from_set(v: &SetValues, mass: f32) -> Spec {
let f = |k: usize, shift: u32| v[k] as i16 as f32 / (1u32 << shift) as f32;
Spec {
player: Handling {
drag: f(8, 6),
grip: f(9, 9),
power: f(10, 9),
},
computer: Handling {
drag: f(0, 6),
grip: f(1, 9),
power: f(5, 9),
},
turn: f(2, 4),
gravity: f(4, 3),
hitch: f(6, 5) * super::track::CELL,
mass,
}
}
}
/// The original's handling presets, which replace every car's own values
/// (its "CUSTOM" setting). Bytes 0-2 are drag, top speed and grip for
/// human drivers, 3-5 the same for computer drivers, and 6 is steering.
pub const PRESETS: [(&str, [u8; 7]); 6] = [
("CLASSIC", [98, 26, 24, 98, 24, 24, 44]),
("GRUNTY", [92, 40, 30, 108, 33, 25, 60]),
("ZIPPY", [102, 61, 47, 115, 59, 45, 64]),
("RIPPY", [100, 80, 60, 110, 50, 60, 72]),
("SLIPPERY", [25, 127, 101, 29, 45, 122, 68]),
("PEDAL CAR", [117, 23, 17, 104, 17, 10, 30]),
];
impl Handling {
/// Decodes a preset's three bytes as the executable does: top speed
/// comes out at `top / 128` world units per frame.
fn from_preset(drag: u8, top: u8, grip: u8) -> Handling {
let drag = drag as f32 / 128.0 * 0.98 + 0.01;
let top = top as f32 / 16.0;
let power = top * (1.0 / drag - 1.0);
Handling {
drag,
grip: grip as f32 / 256.0 / power / drag / 8.0,
power: power / 8.0,
}
}
}
impl Spec {
/// The spec with preset `n` (1-based, as in the original's menu; 0
/// keeps the car's own values).
pub fn with_preset(self, n: usize) -> Spec {
let Some((_, b)) = n.checked_sub(1).and_then(|i| PRESETS.get(i)) else {
return self;
};
Spec {
player: Handling::from_preset(b[0], b[1], b[2]),
computer: Handling::from_preset(b[3], b[4], b[5]),
turn: b[6] as f32 / 32.0,
gravity: 0.5,
..self
}
}
}
/// Collision weight. The original's car-to-car physics isn't decoded, so
/// these are guesses.
pub fn default_mass(id: &str) -> f32 {
match id {
"f1" => Spec {
top: 240.0,
accel: 210.0,
grip: 6.5,
turn: 3.7,
mass: 0.9,
offroad: 0.45,
..b
},
"porsche" => Spec {
top: 225.0,
accel: 190.0,
grip: 5.2,
turn: 3.6,
..b
},
"mini" => Spec {
top: 190.0,
accel: 200.0,
grip: 5.8,
turn: 4.4,
mass: 0.8,
..b
},
"vw" => Spec {
top: 185.0,
accel: 160.0,
grip: 4.6,
turn: 3.6,
mass: 1.0,
..b
},
"midget" => Spec {
top: 205.0,
accel: 185.0,
grip: 4.0,
turn: 4.0,
mass: 0.85,
offroad: 0.6,
..b
},
"truck" => Spec {
top: 195.0,
accel: 140.0,
grip: 4.2,
turn: 3.0,
mass: 1.8,
offroad: 0.75,
..b
},
"police" => Spec {
top: 215.0,
accel: 180.0,
grip: 5.0,
turn: 3.5,
mass: 1.2,
..b
},
"cow" => Spec {
top: 170.0,
accel: 150.0,
grip: 7.0,
turn: 4.6,
mass: 1.4,
offroad: 0.9,
..b
},
_ => b,
"f1" => 0.9,
"mini" => 0.8,
"midget" => 0.85,
"truck" => 1.8,
"police" => 1.2,
"cow" => 1.4,
_ => 1.0,
}
}
@@ -143,8 +173,8 @@ pub struct Vehicle {
pub driver: String,
/// Towed behind cars rather than driven (the caravan).
pub trailer: bool,
/// The 16 values from the original `.set` file, kept for reference.
pub original: Vec<u16>,
/// The 16 handling values from the original `.set` file.
pub set: SetValues,
/// 64x32 menu picture (indices 0-3), from the `.set` file.
pub menu_picture: Option<Vec<u8>>,
pub spec: Spec,
@@ -180,9 +210,9 @@ impl Vehicle {
name: display_name(&id),
driver: String::new(),
trailer: id == "caravan",
original: Vec::new(),
set: DEFAULT_SET,
menu_picture: None,
spec: default_spec(&id),
spec: Spec::from_set(&DEFAULT_SET, default_mass(&id)),
id,
frames,
}))
@@ -202,7 +232,10 @@ impl Vehicle {
self.name = name;
}
self.driver = crate::formats::latin1(&d[a + 1..b]).trim().to_uppercase();
self.original = (0..16).map(|i| crate::formats::be16(body, i * 2)).collect();
for (i, v) in self.set.iter_mut().enumerate() {
*v = crate::formats::be16(body, i * 2);
}
self.spec = Spec::from_set(&self.set, self.spec.mass);
let pic = &body[32..32 + 512];
let mut px = vec![0u8; SPR_W * SPR_H];
for (i, p) in px.iter_mut().enumerate() {
@@ -258,9 +291,9 @@ impl Vehicle {
name: display_name(id),
driver: String::new(),
trailer: id == "caravan",
original: Vec::new(),
set: DEFAULT_SET,
menu_picture: None,
spec: default_spec(id),
spec: Spec::from_set(&DEFAULT_SET, default_mass(id)),
frames,
};
if let Some(txt) = txt {
@@ -284,7 +317,6 @@ impl Vehicle {
}
pub fn settings_text(&self) -> String {
let s = &self.spec;
format!(
"# Skids vehicle settings. Lines starting with # are comments.\n\
# The sprite sheet is {id}.png: 32 headings across, 25 tilts down.\n\
@@ -292,40 +324,28 @@ impl Vehicle {
driver {driver}\n\
# yes = towed behind cars (Caravans option) instead of driven\n\
trailer {trailer}\n\
# The 16 values from the original .set file. Their meaning is not\n\
# decoded yet, so the game does not use them; the handling below is\n\
# a reconstruction.\n\
original {original}\n\
# top speed on tarmac, world pixels per second\n\
top {top}\n\
# acceleration and braking, world pixels per second squared\n\
accel {accel}\n\
brake {brake}\n\
# sideways grip (higher = less sliding)\n\
grip {grip}\n\
# steering rate, radians per second\n\
turn {turn}\n\
# weight in collisions\n\
mass {mass}\n\
# speed and grip multiplier off the road\n\
offroad {offroad}\n",
#\n\
# handling: the 16 values from the original .set file, used as the\n\
# original game does. Counting from 1:\n\
# 1 2 6 computer drivers: drag, grip, power\n\
# 9 10 11 human drivers: drag, grip, power\n\
# 3 steering, 5 gravity, 7 tow hitch distance; the rest are unused.\n\
# Top speed = power * drag / (64 - drag) / 32 world units per frame,\n\
# 50 frames a second (drag below 64). Higher grip, less sliding.\n\
handling {set}\n\
# weight when cars collide (not from the original data)\n\
mass {mass}\n",
id = self.id,
name = self.name,
driver = self.driver,
trailer = if self.trailer { "yes" } else { "no" },
original = self
.original
set = self
.set
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(" "),
top = s.top,
accel = s.accel,
brake = s.brake,
grip = s.grip,
turn = s.turn,
mass = s.mass,
offroad = s.offroad
mass = self.spec.mass,
)
}
@@ -337,43 +357,36 @@ impl Vehicle {
}
let (key, val) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let val = val.trim();
let bad = |what: &str| format!("line {}: {what}", n + 1);
match key {
"name" => {
self.name = val.to_uppercase();
continue;
}
"driver" => {
self.driver = val.to_uppercase();
continue;
}
"trailer" => {
self.trailer = matches!(val, "yes" | "true" | "1");
continue;
}
"original" => {
self.original = val
"name" => self.name = val.to_uppercase(),
"driver" => self.driver = val.to_uppercase(),
"trailer" => self.trailer = matches!(val, "yes" | "true" | "1"),
// `original` is what older asset files called it.
"handling" | "original" => {
let vals: Vec<u16> = val
.split_whitespace()
.filter_map(|x| x.parse().ok())
.collect();
continue;
.map(|x| x.parse::<u16>())
.collect::<Result<_, _>>()
.map_err(|_| bad("handling values must be whole numbers 0-65535"))?;
if vals.len() > 16 {
return Err(bad("handling has more than 16 values"));
}
self.set = [0; 16];
self.set[..vals.len()].copy_from_slice(&vals);
}
_ => {}
}
let num: f32 = val
.parse()
.map_err(|_| format!("line {}: `{val}` is not a number", n + 1))?;
let s = &mut self.spec;
match key {
"top" => s.top = num,
"accel" => s.accel = num,
"brake" => s.brake = num,
"grip" => s.grip = num,
"turn" => s.turn = num,
"mass" => s.mass = num.max(0.05),
"offroad" => s.offroad = num,
_ => return Err(format!("line {}: unknown setting `{key}`", n + 1)),
"mass" => {
self.spec.mass = val
.parse::<f32>()
.map_err(|_| bad(&format!("`{val}` is not a number")))?
.max(0.05)
}
// Settings from before the original handling was decoded.
"top" | "accel" | "brake" | "grip" | "turn" | "offroad" => {}
_ => return Err(bad(&format!("unknown setting `{key}`"))),
}
}
self.spec = Spec::from_set(&self.set, self.spec.mass);
Ok(())
}
@@ -436,15 +449,15 @@ mod tests {
name: String::new(),
driver: String::new(),
trailer: false,
original: Vec::new(),
set: DEFAULT_SET,
menu_picture: None,
spec: BASE_SPEC,
spec: Spec::from_set(&DEFAULT_SET, 1.0),
frames,
};
let mut v = blank(vec![0; TILTS * HEADINGS * SPR_W * SPR_H]);
v.driver = "SIMON".into();
v.original = vec![60, 71, 30];
v.spec.grip = 6.25;
v.set = [60, 71, 30, 0, 6, 16, 24, 12, 58, 42, 29, 0, 0, 0, 0, 0];
v.spec = Spec::from_set(&v.set, 0.9);
v.name = "FORMULA 1".into();
let txt = v.settings_text();
let mut w = blank(Vec::new());
@@ -452,8 +465,24 @@ mod tests {
assert_eq!(w.spec, v.spec);
assert_eq!(w.name, "FORMULA 1");
assert_eq!(w.driver, "SIMON");
assert_eq!(w.original, v.original);
assert!(w.apply_settings("grip fast").is_err());
assert_eq!(w.set, v.set);
assert!(w.apply_settings("mass heavy").is_err());
// Older files with reconstructed handling still load.
assert!(w.apply_settings("original 1 2 3\ntop 200").is_ok());
assert_eq!(&w.set[..4], &[1, 2, 3, 0]);
}
#[test]
fn decodes_set_values() {
let s = Spec::from_set(&DEFAULT_SET, 1.0);
assert_eq!(s.player.drag, 49.0 / 64.0);
assert_eq!(s.computer.power, 22.0 / 512.0);
assert_eq!(s.turn, 2.0);
assert_eq!(s.hitch, 11.0);
let p = s.with_preset(1);
let top = p.player.power * p.player.drag / (1.0 - p.player.drag);
assert!((top - 26.0 / 128.0).abs() < 1e-5);
assert_eq!(s.with_preset(0), s);
}
#[test]
+51 -117
View File
@@ -1,24 +1,25 @@
//! Computer drivers.
//!
//! Each driver follows a line through the gates (at its own offset across
//! the track) by pure pursuit: aim at a point a speed-dependent distance
//! further along the line. Short look-ahead rays nudge the chosen heading
//! away from the grass, and a driver that stops moving backs out.
//! As in the executable, a computer car steers like a joystick: full lock
//! towards a target point ahead, or straight on when it is dead ahead, and
//! drives flat out. The cars' computer handling (`vehicle::Handling`) is
//! what makes them take bends at that. The target is a point a little way
//! along the road (`Track::edges`) at the driver's own line across it. A
//! driver that stops moving backs out.
use super::car::{angle_diff, Car, Controls};
use crate::data::track::{Gate, Track};
use crate::data::track::Track;
const RAYS: i32 = 7;
const RAY_STEP: f32 = 0.13;
/// Heading error, in radians, below which the driver doesn't steer.
const DEAD_AHEAD: f32 = 0.06;
#[derive(Clone, Debug)]
pub struct Ai {
/// Where across each gate this driver aims (0 = end a, 1 = end b).
/// Where across the road this driver aims (0 = one edge, 1 = the other).
pub line: f32,
/// Fraction of the car's top speed this driver uses.
/// Fraction of full throttle this driver uses.
pub pace: f32,
stuck_for: f32,
blocked_for: f32,
reversing: f32,
}
@@ -28,83 +29,59 @@ impl Ai {
line,
pace,
stuck_for: 0.0,
blocked_for: 0.0,
reversing: 0.0,
}
}
fn point(&self, g: &Gate) -> (f32, f32) {
(
g.a.0 + (g.b.0 - g.a.0) * self.line,
g.a.1 + (g.b.1 - g.a.1) * self.line,
)
/// The point `dist` along the road from the car's cross-section, at
/// this driver's line.
pub fn target(&self, car: &Car, track: &Track, dist: f32) -> (f32, f32) {
let e = &track.edges;
let m = e.len();
let at = |k: usize| {
let g = e[k % m];
(
g.a.0 + (g.b.0 - g.a.0) * self.line,
g.a.1 + (g.b.1 - g.a.1) * self.line,
)
};
let mut k = car.section + 1;
let mut p = at(k);
let mut left = dist - (p.0 - car.x).hypot(p.1 - car.y);
while left > 0.0 && k < car.section + m {
let q = at(k + 1);
let len = (q.0 - p.0).hypot(q.1 - p.1);
if len >= left {
let f = left / len;
return (p.0 + (q.0 - p.0) * f, p.1 + (q.1 - p.1) * f);
}
left -= len;
p = q;
k += 1;
}
p
}
/// Point `dist` along the driving line from the car's projection onto it.
fn pursue(&mut self, car: &Car, gates: &[Gate], next_gate: usize, dist: f32) -> (f32, f32) {
let n = gates.len();
// Only the stretch into the gate the lap counter needs next, or the
// one after it (crossing counts with one gate skipped), so a car
// knocked backwards turns round for the gate it missed.
let seg = next_gate;
let mut best = (f32::MAX, seg, 0.0f32);
for k in 0..2 {
let i = (seg + k) % n;
let a = self.point(&gates[(i + n - 1) % n]);
let b = self.point(&gates[i]);
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = (dx * dx + dy * dy).max(1e-3);
let t = (((car.x - a.0) * dx + (car.y - a.1) * dy) / len2).clamp(0.0, 1.0);
let d = (a.0 + dx * t - car.x).hypot(a.1 + dy * t - car.y);
if d < best.0 - 1.0 {
best = (d, i, t);
}
}
let (_, mut i, t) = best;
let mut a = self.point(&gates[(i + n - 1) % n]);
let mut b = self.point(&gates[i]);
let mut remaining = dist + (b.0 - a.0).hypot(b.1 - a.1) * t;
for _ in 0..n {
let len = (b.0 - a.0).hypot(b.1 - a.1);
if remaining <= len {
let k = remaining / len.max(1e-3);
return (a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k);
}
remaining -= len;
i = (i + 1) % n;
a = b;
b = self.point(&gates[i]);
}
b
}
pub fn drive(
&mut self,
car: &Car,
track: &Track,
next_gate: usize,
dt: f32,
racing: bool,
) -> Controls {
if !racing {
pub fn drive(&mut self, car: &Car, track: &Track, dt: f32, racing: bool) -> Controls {
if !racing || track.edges.len() < 3 {
return Controls::default();
}
let gates = &track.gates;
let speed = car.speed();
let look = 34.0 + speed * 0.32;
let target = self.pursue(car, gates, next_gate, look);
let far = self.pursue(car, gates, next_gate, look * 2.2);
let want = (target.1 - car.y).atan2(target.0 - car.x);
let look = 30.0 + speed * 0.2;
let (tx, ty) = self.target(car, track, look);
let diff = angle_diff((ty - car.y).atan2(tx - car.x), car.heading);
let steer = if diff.abs() < DEAD_AHEAD {
0.0
} else {
diff.signum()
};
if self.reversing > 0.0 {
self.reversing -= dt;
let diff = angle_diff(want, car.heading);
return Controls {
throttle: 0.0,
brake: 1.0,
steer: -diff.signum(),
steer: -steer,
};
}
if speed < 10.0 {
@@ -116,49 +93,6 @@ impl Ai {
} else {
self.stuck_for = 0.0;
}
// Pick the heading closest to the target that keeps us on the road.
let ray_len = 18.0 + speed * 0.25;
let mut best = (f32::MAX, want);
for k in -RAYS..=RAYS {
let cand = want + k as f32 * RAY_STEP;
let mut grass = 0.0;
for step in 1..=4 {
let d = ray_len * step as f32 / 4.0;
if !track.on_road_world(car.x + cand.cos() * d, car.y + cand.sin() * d) {
grass += 1.0 / step as f32;
}
}
let cost = (k as f32 * RAY_STEP).abs() + grass * 0.9;
if cost < best.0 {
best = (cost, cand);
}
}
let diff = angle_diff(best.1, car.heading);
let steer = (diff * 3.0).clamp(-1.0, 1.0);
// Boxed in (nose in the scenery or against the edge): back out.
if best.0 > 1.6 && speed < 30.0 {
self.blocked_for += dt;
if self.blocked_for > 0.5 {
self.blocked_for = 0.0;
self.reversing = 0.7;
}
} else {
self.blocked_for = 0.0;
}
// Slow for what is coming: compare heading with the far probe.
let bend = angle_diff((far.1 - car.y).atan2(far.0 - car.x), car.heading).abs();
let corner_speed = car.spec.top * self.pace * (1.0 - (bend / 1.3).min(0.75));
let (mut throttle, mut brake) = (1.0, 0.0);
if speed > corner_speed {
throttle = 0.0;
if speed > corner_speed + 25.0 {
brake = 0.7;
}
} else if diff.abs() > 0.8 {
throttle = 0.6;
}
if diff.abs() > 2.3 && speed < 35.0 {
// Facing the wrong way: reverse-turn round.
return Controls {
@@ -168,8 +102,8 @@ impl Ai {
};
}
Controls {
throttle,
brake,
throttle: self.pace,
brake: 0.0,
steer,
}
}
+233 -94
View File
@@ -1,19 +1,36 @@
//! Vehicle handling.
//! Vehicle handling, as decoded from the executable.
//!
//! The retail executable is not available, so handling is a reconstruction
//! (per-car values live in the vehicle assets): an arcade model with separate forward and lateral friction, which gives
//! the long power-slides the series is known for. Physics runs on the
//! isometric ground plane (see `data::track::to_screen`).
//! The original runs one update per 50 Hz frame in its own units, where one
//! world unit is a height-map cell (`track::CELL` of ours). Each frame:
//!
//! - **Steering:** spin = (spin + steer * turn) / 2, then the heading
//! turns by spin (64ths of a turn). Steering is digital, as the
//! joystick is.
//! - **Height:** vertical speed loses `gravity`. If the car would still be
//! above the ground two frames ahead it's airborne; otherwise its
//! vertical speed is half the climb to that point. Body tilt steps one
//! notch a frame towards the slope under the car.
//! - **Engine and grip:** see `vehicle::Handling`. Climbing takes half the
//! vertical speed off the throttle; descending adds it.
//! - **Move:** velocity is added to the position. Airborne cars keep
//! their velocity and engine speed.
//! - **Walls:** the road edges (`Track::edges`) are walls. A car that
//! reaches one loses the part of its velocity into the wall and bounces
//! back with half of it.
//!
//! Human and computer drivers use separate handling from the vehicle's
//! `.set` values.
use crate::data::track::{self, Track};
use crate::data::vehicle::{self, Spec};
use super::DT;
use crate::data::track::{self, Gate, Track, CELL};
use crate::data::vehicle::{self, Handling, Spec};
use std::f32::consts::{PI, TAU};
pub const RADIUS: f32 = 11.0;
const SLOPE_GRAVITY: f32 = 110.0;
/// Cap on slope acceleration so the steep banking on some tracks cannot pin
/// a car against the outside of a bend.
const SLOPE_MAX: f32 = 45.0;
/// How close the car's centre may come to a wall, in world units.
const WALL_MARGIN: f32 = 4.0;
/// The executable samples the slope 0.2 cells from the car.
const TILT_PROBE: f32 = 0.2 * CELL;
#[derive(Clone, Copy, Default, Debug)]
pub struct Controls {
@@ -28,21 +45,39 @@ pub struct Car {
pub vehicle: usize,
pub spec: Spec,
pub team: usize,
/// Uses the vehicle's computer-driver handling.
pub computer: bool,
pub x: f32,
pub y: f32,
/// Height above the ground plane, pixels.
pub z: f32,
/// World units per second.
pub vx: f32,
pub vy: f32,
/// Pixels per frame.
pub vz: f32,
/// World angle, 0 = +x, increasing clockwise on screen.
pub heading: f32,
/// Turn per frame, 64ths of a turn.
pub spin: f32,
/// The engine's speed, in the original's units per frame.
pub drive: f32,
pub airborne: bool,
pub controls: Controls,
/// Sideways speed this frame, used for skid marks and sound.
pub slip: f32,
pub on_road: bool,
/// Tilt notches 0-4 (2 = level): pitch follows the slope along world y
/// and roll the slope along world x, as in the executable.
pub pitch: i32,
pub roll: i32,
pub tilt: usize,
/// Throttle/rpm proxy for the engine sound, 0..1.
pub rpm: f32,
/// Strength of the last collision, decays each frame.
pub bump: f32,
/// The road cross-section (`Track::edges`) the car is past.
pub section: usize,
}
impl Car {
@@ -51,22 +86,44 @@ impl Car {
vehicle,
spec,
team,
computer: false,
x,
y,
z: 0.0,
vx: 0.0,
vy: 0.0,
vz: 0.0,
heading,
spin: 0.0,
drive: 0.0,
airborne: false,
controls: Controls::default(),
slip: 0.0,
on_road: true,
pitch: 2,
roll: 2,
tilt: vehicle::LEVEL,
rpm: 0.0,
bump: 0.0,
section: 0,
}
}
/// Puts the car on the ground at its position, stopped, and finds its
/// place along the road.
pub fn place(&mut self, track: &Track) {
self.z = track.ground(self.x, self.y);
self.vx = 0.0;
self.vy = 0.0;
self.vz = 0.0;
self.spin = 0.0;
self.drive = 0.0;
self.airborne = false;
self.section = nearest_section(&track.edges, (self.x, self.y));
}
pub fn screen_pos(&self) -> (f32, f32) {
track::to_screen(self.x, self.y)
track::project(self.x, self.y, self.z)
}
pub fn speed(&self) -> f32 {
@@ -77,84 +134,147 @@ impl Car {
(self.heading.cos(), self.heading.sin())
}
pub fn step(&mut self, track: &Track, dt: f32, top_scale: f32) {
self.on_road = track.on_road_world(self.x, self.y);
let surf = if self.on_road { 1.0 } else { self.spec.offroad };
let c = self.controls;
let (fx, fy) = self.forward();
let (rx, ry) = (-fy, fx);
let mut vf = self.vx * fx + self.vy * fy;
let mut vr = self.vx * rx + self.vy * ry;
let top = self.spec.top * surf * top_scale;
if c.throttle > 0.0 && vf < top {
let headroom = (1.0 - (vf / top).max(0.0).powi(2)).max(0.05);
vf += self.spec.accel * c.throttle * headroom * dt;
}
if c.brake > 0.0 {
if vf > 4.0 {
vf = (vf - self.spec.brake * c.brake * dt).max(0.0);
} else if vf > -top * 0.35 {
vf -= self.spec.accel * 0.6 * c.brake * dt;
}
}
let drag = if self.on_road { 0.35 } else { 1.6 };
vf -= vf * drag * dt;
if c.throttle == 0.0 && c.brake == 0.0 {
vf -= vf.signum() * (30.0 * dt).min(vf.abs());
}
// Sideways friction: grip drops as the slide gets faster, so once a
// car breaks away it keeps sliding.
let grip = self.spec.grip * surf.max(0.6) / (1.0 + vr.abs() / 120.0);
vr *= (-grip * dt).exp();
self.slip = vr.abs();
let steer_auth = (vf.abs() / 45.0).min(1.0) * vf.signum();
let turn = self.spec.turn * (1.0 - (vf.abs() / (top * 2.2)).min(0.35));
self.heading = (self.heading + c.steer * turn * steer_auth * dt).rem_euclid(TAU);
self.vx = fx * vf + rx * vr;
self.vy = fy * vf + ry * vr;
// Hills: the height map is sampled in screen space.
let (gx, gy) = gradient(track, self.x, self.y);
let (mut ax, mut ay) = (-gx * SLOPE_GRAVITY, -gy * SLOPE_GRAVITY);
let a = ax.hypot(ay);
if a > SLOPE_MAX {
ax *= SLOPE_MAX / a;
ay *= SLOPE_MAX / a;
}
self.vx += ax * dt;
self.vy += ay * dt;
let (ox, oy) = (self.x, self.y);
self.x += self.vx * dt;
self.y += self.vy * dt;
self.keep_in_bounds(ox, oy);
// Body tilt from the slope along and across the car.
let (fx, fy) = self.forward();
let along = gx * fx + gy * fy;
let across = gx * -fy + gy * fx;
let q = |s: f32| (s / 0.035).round() as i32;
self.tilt = vehicle::tilt_block(-q(along), q(across));
let target_rpm = if c.throttle > 0.0 {
0.35 + 0.65 * (vf.abs() / top).min(1.0)
pub fn handling(&self) -> Handling {
if self.computer {
self.spec.computer
} else {
(vf.abs() / top).min(1.0) * 0.7
};
self.rpm += (target_rpm - self.rpm) * (1.0 - (-6.0 * dt).exp());
self.bump *= (-8.0 * dt).exp();
self.spec.player
}
}
/// The picture edge is a wall. Slide along it by keeping whichever axis
/// of the move stays inside, so cars never get pinned in a corner.
fn keep_in_bounds(&mut self, ox: f32, oy: f32) {
/// Top speed on the flat, world units per second.
pub fn top_speed(&self) -> f32 {
let h = self.handling();
if h.drag >= 1.0 {
return f32::INFINITY;
}
h.power * h.drag / (1.0 - h.drag) * CELL / DT
}
/// One frame. `pace` scales the throttle (finished cars cruise).
pub fn step(&mut self, track: &Track, pace: f32) {
let c = self.controls;
let h = self.handling();
// Velocity in world units per frame.
let (mut vx, mut vy) = (self.vx * DT, self.vy * DT);
if self.airborne {
self.spin = self.spin.clamp(-1.0, 1.0);
} else {
let steer = c.steer.clamp(-1.0, 1.0);
self.spin = (self.spin + steer * self.spec.turn) * 0.5;
}
self.vz -= self.spec.gravity;
let (ax, ay) = (self.x + 2.0 * vx, self.y + 2.0 * vy);
let ground = track.ground(ax, ay);
if self.z + 2.0 * self.vz > ground {
self.airborne = true;
} else {
self.airborne = false;
self.vz = (ground - self.z) * 0.5;
let notch = |probe: f32| ((probe - ground + 2.5).floor() as i32).clamp(0, 4);
let pitch = notch(track.ground(ax, ay + TILT_PROBE));
let roll = notch(track.ground(ax - TILT_PROBE, ay));
self.pitch += (pitch - self.pitch).signum();
self.roll += (roll - self.roll).signum();
}
self.tilt = vehicle::tilt_block(self.pitch - 2, self.roll - 2);
self.heading = (self.heading + self.spin * TAU / 64.0).rem_euclid(TAU);
let (fx, fy) = self.forward();
if !self.airborne {
// The original's units: world cells per frame.
let (cx, cy) = (vx / CELL, vy / CELL);
let along = cx * fx + cy * fy;
let mut throttle = (c.throttle - c.brake).clamp(-1.0, 1.0) * pace;
throttle += if along < 0.0 { self.vz } else { -self.vz } * 0.5;
let target = (self.drive + throttle * h.power) * h.drag;
let (sx, sy) = (h.grip * (fx * target - cx), h.grip * (fy * target - cy));
self.drive += (target - self.drive) * 0.25;
vx += sx * CELL;
vy += sy * CELL;
}
let (ox, oy) = (self.x, self.y);
self.x += vx;
self.y += vy;
self.z += self.vz;
self.vx = vx / DT;
self.vy = vy / DT;
self.walls(track);
self.keep_in_bounds(track, ox, oy);
self.on_road = track.on_road_world(self.x, self.y);
self.slip = (self.vy * fx - self.vx * fy).abs();
let top = self.top_speed().max(1.0);
let fwd = (self.vx * fx + self.vy * fy).abs();
let target_rpm = if c.throttle > 0.0 {
0.35 + 0.65 * (fwd / top).min(1.0)
} else {
(fwd / top).min(1.0) * 0.7
};
self.rpm += (target_rpm - self.rpm) * (1.0 - (-6.0 * DT).exp());
self.bump *= (-8.0 * DT).exp();
}
/// Keeps the car between the road edges of the section it is in, and
/// moves `section` along as the car crosses from one to the next.
fn walls(&mut self, track: &Track) {
let e = &track.edges;
let m = e.len();
if m < 3 {
return;
}
let p = (self.x, self.y);
for _ in 0..m {
let next = (self.section + 1) % m;
if !past(e, next, p) {
break;
}
self.section = next;
}
for _ in 0..m {
if past(e, self.section, p) {
break;
}
self.section = (self.section + m - 1) % m;
}
let (s0, s1) = (e[self.section], e[(self.section + 1) % m]);
for (p0, p1, inside) in [(s0.a, s1.a, s0.b), (s0.b, s1.b, s0.a)] {
let (dx, dy) = (p1.0 - p0.0, p1.1 - p0.1);
let len = dx.hypot(dy);
if len < 1e-3 {
continue;
}
// Unit normal pointing into the road.
let (mut nx, mut ny) = (-dy / len, dx / len);
if nx * (inside.0 - p0.0) + ny * (inside.1 - p0.1) < 0.0 {
nx = -nx;
ny = -ny;
}
let dist = nx * (self.x - p0.0) + ny * (self.y - p0.1);
if dist >= WALL_MARGIN {
continue;
}
self.x += nx * (WALL_MARGIN - dist);
self.y += ny * (WALL_MARGIN - dist);
let vn = self.vx * nx + self.vy * ny;
if vn < 0.0 {
self.vx -= 1.5 * vn * nx;
self.vy -= 1.5 * vn * ny;
self.bump = self.bump.max((-vn / 200.0).min(0.6));
}
}
}
/// The picture edge is a wall too, for tracks whose road edges run off
/// it. Slide along it by keeping whichever axis of the move stays
/// inside, so cars never get pinned in a corner.
fn keep_in_bounds(&mut self, track: &Track, ox: f32, oy: f32) {
const MARGIN: f32 = 3.0;
let inside = |x: f32, y: f32| {
let (sx, sy) = track::to_screen(x, y);
let (sx, sy) = track.to_screen(x, y);
track::on_screen(sx, sy, MARGIN)
};
if inside(self.x, self.y) {
@@ -177,23 +297,42 @@ impl Car {
}
/// Rear wheel contact points in screen space, for skid marks.
pub fn rear_wheels(&self) -> [(f32, f32); 2] {
pub fn rear_wheels(&self, track: &Track) -> [(f32, f32); 2] {
let (fx, fy) = self.forward();
let (rx, ry) = (-fy, fx);
let (bx, by) = (self.x - fx * 10.0, self.y - fy * 10.0);
[
track::to_screen(bx + rx * 6.0, by + ry * 6.0),
track::to_screen(bx - rx * 6.0, by - ry * 6.0),
track.to_screen(bx + rx * 6.0, by + ry * 6.0),
track.to_screen(bx - rx * 6.0, by - ry * 6.0),
]
}
}
/// Height change per world unit along world x and y.
fn gradient(track: &Track, wx: f32, wy: f32) -> (f32, f32) {
let d = 6.0;
let gx = (track.height_world(wx + d, wy) - track.height_world(wx - d, wy)) / (2.0 * d);
let gy = (track.height_world(wx, wy + d) - track.height_world(wx, wy - d)) / (2.0 * d);
(gx, gy)
/// Whether `p` is past cross-section `k`, going round the road.
fn past(e: &[Gate], k: usize, p: (f32, f32)) -> bool {
let m = e.len();
let (a, b) = (e[k].a, e[k].b);
let side = |q: (f32, f32)| (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0);
side(p) * side(e[(k + 1) % m].mid()) > 0.0
}
/// The cross-section just behind the point closest to `p`.
pub fn nearest_section(e: &[Gate], p: (f32, f32)) -> usize {
let mut best = (f32::MAX, 0);
for (k, g) in e.iter().enumerate() {
let (mx, my) = g.mid();
let d = (mx - p.0).hypot(my - p.1);
if d < best.0 {
best = (d, k);
}
}
let k = best.1;
let m = e.len().max(1);
if past(e, k, p) || e.len() < 3 {
k
} else {
(k + m - 1) % m
}
}
/// Pushes overlapping cars apart and exchanges momentum.
+58 -23
View File
@@ -46,15 +46,15 @@ pub struct Trailer {
pub heading: f32,
}
/// Hitch distance behind the car's centre, and tow bar length to the
/// trailer's centre, in world units.
const HITCH: f32 = 12.0;
/// Tow bar length from the car's hitch (`Spec::hitch`) to the trailer's
/// centre, in world units.
const TOW_BAR: f32 = 20.0;
const TRAILER_RADIUS: f32 = 10.0;
/// Seconds without crossing a gate before a computer car is rescued.
const RESCUE_AFTER: f32 = 8.0;
fn rescue(e: &mut Entrant, gates: &[Gate]) {
fn rescue(e: &mut Entrant, track: &Track) {
let gates = &track.gates;
let n = gates.len();
let from = gates[(e.next_gate + n - 1) % n].mid();
let to = gates[e.next_gate].mid();
@@ -62,11 +62,10 @@ fn rescue(e: &mut Entrant, gates: &[Gate]) {
let c = &mut e.car;
c.x = from.0;
c.y = from.1;
c.vx = 0.0;
c.vy = 0.0;
c.heading = heading.rem_euclid(std::f32::consts::TAU);
c.place(track);
if let Some(t) = &mut e.trailer {
let back = HITCH + TOW_BAR;
let back = c.spec.hitch + TOW_BAR;
t.x = from.0 - heading.cos() * back;
t.y = from.1 - heading.sin() * back;
t.heading = heading;
@@ -78,7 +77,7 @@ impl Trailer {
/// hitch and swings to point at it.
fn follow(&mut self, car: &Car) {
let (fx, fy) = car.forward();
let (hx, hy) = (car.x - fx * HITCH, car.y - fy * HITCH);
let (hx, hy) = (car.x - fx * car.spec.hitch, car.y - fy * car.spec.hitch);
let (dx, dy) = (self.x - hx, self.y - hy);
let d = dx.hypot(dy).max(1e-3);
self.x = hx + dx / d * TOW_BAR;
@@ -130,6 +129,8 @@ pub struct EntrantSetup {
pub name: String,
/// Vehicle index of a caravan to tow, if any.
pub trailer: Option<usize>,
/// Handling preset (`Spec::with_preset`); 0 = the car's own.
pub preset: usize,
}
impl Race {
@@ -153,15 +154,18 @@ impl Race {
let row = (i / 2) as f32;
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
// Leave room for a caravan between rows when towing.
let spec = vehicles[s.vehicle].spec.with_preset(s.preset);
let spacing = if s.trailer.is_some() {
30.0 + HITCH + TOW_BAR + TRAILER_RADIUS
30.0 + spec.hitch + TOW_BAR + TRAILER_RADIUS
} else {
30.0
};
let ((px, py), heading) = behind(&path, 18.0 + row * spacing);
let (rx, ry) = (-heading.sin(), heading.cos());
let (x, y) = (px + rx * side * 12.0, py + ry * side * 12.0);
let car = Car::new(s.vehicle, vehicles[s.vehicle].spec, s.team, x, y, heading);
let mut car = Car::new(s.vehicle, spec, s.team, x, y, heading);
car.computer = s.driver == Driver::Cpu;
car.place(&track);
let line = 0.3 + 0.4 * rand01(&mut rng);
let pace = 0.86 + 0.12 * rand01(&mut rng);
entrants.push(Entrant {
@@ -178,7 +182,7 @@ impl Race {
progress_at: 0.0,
trailer: s.trailer.map(|t| {
let (fx, fy) = (heading.cos(), heading.sin());
let back = HITCH + TOW_BAR;
let back = spec.hitch + TOW_BAR;
Trailer {
vehicle: t,
x: x - fx * back,
@@ -187,11 +191,6 @@ impl Race {
}
}),
});
if let Some(e) = entrants.last_mut().filter(|e| e.trailer.is_some()) {
// Towing costs some speed and pick-up.
e.car.spec.top *= 0.9;
e.car.spec.accel *= 0.8;
}
}
let ground = track.rgb();
Race {
@@ -236,15 +235,15 @@ impl Race {
for e in self.entrants.iter_mut() {
let controls = match (e.driver, e.finished) {
(_, Some(_)) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing),
(_, Some(_)) => e.ai.drive(&e.car, &self.track, DT, racing),
(Driver::Human(p), None) if racing => human.get(p).copied().unwrap_or_default(),
(Driver::Human(_), None) => Controls::default(),
(Driver::Cpu, None) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing),
(Driver::Cpu, None) => e.ai.drive(&e.car, &self.track, DT, racing),
};
e.car.controls = controls;
// Finished cars cruise round on autopilot at reduced pace.
let pace = if e.finished.is_some() { 0.6 } else { 1.0 };
e.car.step(&self.track, DT, pace);
e.car.step(&self.track, pace);
}
for i in 0..self.entrants.len() {
@@ -298,7 +297,7 @@ impl Race {
&& e.finished.is_none()
&& rt - e.progress_at > RESCUE_AFTER
{
rescue(e, &self.track.gates);
rescue(e, &self.track);
e.progress_at = rt;
}
}
@@ -326,14 +325,14 @@ impl Race {
for e in &self.entrants {
let c = &e.car;
let speed = c.speed();
if c.slip > 55.0 && speed > 40.0 && c.on_road {
if c.slip > 55.0 && speed > 40.0 && c.on_road && !c.airborne {
let strength = ((c.slip - 55.0) / 120.0).clamp(0.08, 0.35);
for (wx, wy) in c.rear_wheels() {
for (wx, wy) in c.rear_wheels(&self.track) {
burn(&mut self.ground, wx, wy, strength);
}
}
if !c.on_road && speed > 50.0 {
spawn.push((c.rear_wheels()[0], c.track_colour(&self.track)));
spawn.push((c.rear_wheels(&self.track)[0], c.track_colour(&self.track)));
}
}
for ((x, y), colour) in spawn {
@@ -487,4 +486,40 @@ mod tests {
let ((x, y), _) = behind(&path, 130.0);
assert!((x - 30.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3);
}
#[test]
fn walls_keep_cars_on_the_road() {
let gates: Vec<Gate> = (0..12)
.map(|i| {
let a = i as f32 / 12.0 * std::f32::consts::TAU;
let (c, s) = (a.cos(), a.sin());
Gate {
a: (512.0 + c * 150.0, 512.0 + s * 150.0),
b: (512.0 + c * 210.0, 512.0 + s * 210.0),
}
})
.collect();
let track = Track {
name: "ring".into(),
set_name: String::new(),
thumbnail: None,
pixels: vec![8; crate::data::track::W * crate::data::track::H],
palette: crate::data::palette::TRACK_DEFAULT,
height: vec![0; 64 * 64],
edges: crate::data::track::edges(&gates),
gates,
start: (0.0, 0.0),
flags: 0,
};
let spec = crate::data::vehicle::Spec::from_set(&crate::data::vehicle::DEFAULT_SET, 1.0);
let mut c = Car::new(0, spec, 0, 512.0 + 180.0, 512.0, 0.0);
c.place(&track);
// Flat out in a straight line: the outer wall turns the car back.
c.controls.throttle = 1.0;
for _ in 0..500 {
c.step(&track, 1.0);
let r = (c.x - 512.0).hypot(c.y - 512.0);
assert!(r > 140.0 && r < 215.0, "left the road at radius {r}");
}
}
}
+35 -8
View File
@@ -39,12 +39,12 @@ USAGE:
skids unpack FILE.adf|FILE.lha --out DIR extract every file from a disk image or archive
skids music [--data DIR] --out FILE.wav [--seconds S]
render the tune to a WAV file
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] [--caravans]
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] [--caravans] [--handling N]
simulate a race (or the menu) and save a frame
skids bench [--data DIR] [--seconds S] [--caravans]
skids bench [--data DIR] [--seconds S] [--caravans] [--handling N]
race computer cars on every track
skids trace [--data DIR] --out FILE.png [--track N] [--seconds S]
draw the computer cars' paths on a track
skids trace [--data DIR] --out FILE.png [--track N] [--seconds S] [--handling N]
draw the walls, gates and computer cars' paths
The game data is built into the executable, so no disks are needed. --data
adds to or overrides it with ADF images, LHA archives, or extracted track/,
@@ -52,6 +52,9 @@ vehicles/ and palettes/ folders, or an assets folder of edits. Without
--data, a folder like that in the current directory or next to the
executable is picked up automatically.
--handling picks the original's handling presets: 0 = each car's own
(CUSTOM), 1-6 = CLASSIC, GRUNTY, ZIPPY, RIPPY, SLIPPERY, PEDAL CAR.
CONTROLS:
Player 1: arrow keys (or W A S D in one-player games)
Player 2: W A S D
@@ -68,6 +71,7 @@ struct Args {
players: usize,
menu: bool,
caravans: bool,
handling: usize,
}
fn parse_args() -> Result<Args, String> {
@@ -82,6 +86,7 @@ fn parse_args() -> Result<Args, String> {
players: 1,
menu: false,
caravans: false,
handling: 0,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
@@ -97,6 +102,12 @@ fn parse_args() -> Result<Args, String> {
"--mute" => a.mute = true,
"--menu" => a.menu = true,
"--caravans" => a.caravans = true,
"--handling" => {
a.handling = val("--handling")?.parse().map_err(|_| "bad --handling")?;
if a.handling > data::vehicle::PRESETS.len() {
return Err("--handling must be 0-6".into());
}
}
"info" | "extract" | "render" | "bench" | "trace" | "music" | "play" => a.cmd = arg,
"unpack" => {
a.cmd = arg;
@@ -370,6 +381,7 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
App::new(data, audio)
};
app.settings.caravans = args.caravans;
app.settings.handling = args.handling;
if args.menu {
for _ in 0..(args.seconds / game::DT) as usize {
app.update(&Input::default());
@@ -399,7 +411,7 @@ fn render_shot(data: GameData, args: &Args) -> Result<(), String> {
.entrants
.iter_mut()
.filter(|e| matches!(e.driver, game::Driver::Human(_)))
.map(|e| e.ai.drive(&e.car, &race.track, e.next_gate, game::DT, true))
.map(|e| e.ai.drive(&e.car, &race.track, game::DT, true))
.collect();
race.step(&controls);
app::draw_race(&mut frame, &app.data, &race, &mut cams, players);
@@ -428,6 +440,7 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
App::new(data, audio)
};
app.settings.caravans = args.caravans;
app.settings.handling = args.handling;
let frames = (args.seconds / game::DT) as usize;
out!(
"{:<10} {:>5} {:>8} {:>9} {:>8}",
@@ -537,6 +550,7 @@ fn trace(data: GameData, args: &Args) -> Result<(), String> {
App::new(data, audio)
};
app.settings.caravans = args.caravans;
app.settings.handling = args.handling;
// Same seed as `bench` uses for this track, so problems seen there can be traced.
let mut race = app.build_race(
args.track.saturating_sub(1),
@@ -547,18 +561,31 @@ fn trace(data: GameData, args: &Args) -> Result<(), String> {
args.track.max(1) as u32,
)?;
let mut img = race.track.rgb();
let plot = |img: &mut Vec<u32>, wx: f32, wy: f32, c: u32| {
let (x, y) = track::to_screen(wx, wy);
let plot = |img: &mut Vec<u32>, t: &track::Track, wx: f32, wy: f32, c: u32| {
let (x, y) = t.to_screen(wx, wy);
if track::on_screen(x, y, 0.0) {
img[y as usize * track::W + x as usize] = c;
}
};
// The walls: both road edges.
let edges = &race.track.edges;
for (k, g) in edges.iter().enumerate() {
let h = edges[(k + 1) % edges.len()];
for i in 0..8 {
let f = i as f32 / 8.0;
for (p, q) in [(g.a, h.a), (g.b, h.b)] {
let (x, y) = (p.0 + (q.0 - p.0) * f, p.1 + (q.1 - p.1) * f);
plot(&mut img, &race.track, x, y, 0xc0c0c0);
}
}
}
for (gi, g) in race.track.gates.iter().enumerate() {
let c = if gi == 0 { 0xffff00 } else { 0xff2020 };
for i in 0..=64 {
let k = i as f32 / 64.0;
plot(
&mut img,
&race.track,
g.a.0 + (g.b.0 - g.a.0) * k,
g.a.1 + (g.b.1 - g.a.1) * k,
c,
@@ -569,7 +596,7 @@ fn trace(data: GameData, args: &Args) -> Result<(), String> {
for _ in 0..(args.seconds / game::DT) as usize {
race.step(&[]);
for (i, e) in race.entrants.iter().enumerate() {
plot(&mut img, e.car.x, e.car.y, TRAIL[i % 4]);
plot(&mut img, &race.track, e.car.x, e.car.y, TRAIL[i % 4]);
}
}
let n = race.track.gates.len();