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:
+19
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user