Convert the game data into editable assets
assets/ now holds every track as a 16-colour indexed PNG (its palette is the track palette) plus a text file of lap gates and height map, and every vehicle as a PNG sprite sheet plus a text file with its name and handling (moved out of the code). The build embeds assets/ instead of unpacking the ADFs, so the disks are no longer needed to build either. skids extract converts original disks to assets and checks each file reads back identically; benchmark results from the assets match the disks exactly. Assets win over disk data with the same name, and --data assets picks up edits without a rebuild. assets/README.md describes how to edit everything.
This commit is contained in:
+5
-13
@@ -81,7 +81,6 @@ enum Screen {
|
||||
|
||||
pub struct App {
|
||||
pub data: GameData,
|
||||
tracks: Vec<(usize, usize)>,
|
||||
pub settings: Settings,
|
||||
screen: Screen,
|
||||
pub frame: Frame,
|
||||
@@ -96,7 +95,6 @@ pub struct App {
|
||||
|
||||
impl App {
|
||||
pub fn new(data: GameData, audio: Audio) -> App {
|
||||
let tracks = data.track_list();
|
||||
let cars = [
|
||||
data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0),
|
||||
data.vehicles
|
||||
@@ -106,7 +104,6 @@ impl App {
|
||||
];
|
||||
App {
|
||||
data,
|
||||
tracks,
|
||||
settings: Settings {
|
||||
players: 1,
|
||||
track: 0,
|
||||
@@ -150,8 +147,7 @@ impl App {
|
||||
laps: usize,
|
||||
seed: u32,
|
||||
) -> Result<Race, String> {
|
||||
let (f, s) = self.tracks[track_index];
|
||||
let track = self.data.load_track(f, s)?;
|
||||
let track = self.data.load_track(track_index)?;
|
||||
let nv = self.data.vehicles.len();
|
||||
let mut setup = Vec::new();
|
||||
for (p, &car) in cars.iter().enumerate().take(humans) {
|
||||
@@ -182,8 +178,7 @@ impl App {
|
||||
|
||||
fn thumb(&mut self, i: usize) -> Option<&Vec<u32>> {
|
||||
if !self.thumbs.contains_key(&i) {
|
||||
let (f, s) = self.tracks[i];
|
||||
let t = self.data.load_track(f, s).ok()?;
|
||||
let t = self.data.load_track(i).ok()?;
|
||||
self.thumbs
|
||||
.insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H));
|
||||
}
|
||||
@@ -275,7 +270,7 @@ impl App {
|
||||
if delta != 0 {
|
||||
match item {
|
||||
Item::Players => s.players = if s.players == 1 { 2 } else { 1 },
|
||||
Item::Track => s.track = wrap(s.track, self.tracks.len(), delta),
|
||||
Item::Track => s.track = wrap(s.track, self.data.tracks.len(), delta),
|
||||
Item::Laps => {
|
||||
let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1);
|
||||
s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)];
|
||||
@@ -340,7 +335,7 @@ impl App {
|
||||
.as_ref()
|
||||
.is_none_or(|d| d.phase == Phase::Done || d.race_time() > 75.0);
|
||||
if finished {
|
||||
self.demo_track = (self.demo_track + 7) % self.tracks.len();
|
||||
self.demo_track = (self.demo_track + 7) % self.data.tracks.len();
|
||||
self.demo = self
|
||||
.build_race(self.demo_track, 0, [0, 0], 4, 2, self.tick as u32 + 99)
|
||||
.ok()
|
||||
@@ -414,10 +409,7 @@ impl App {
|
||||
let label = match item {
|
||||
Item::Start => "START RACE".to_string(),
|
||||
Item::Players => format!("PLAYERS < {} >", s.players),
|
||||
Item::Track => format!("TRACK < {} >", {
|
||||
let (fi, si) = self.tracks[s.track];
|
||||
self.data.track_files[fi].track_name(si)
|
||||
}),
|
||||
Item::Track => format!("TRACK < {} >", self.data.tracks[s.track].name),
|
||||
Item::Laps => format!("LAPS < {} >", s.laps),
|
||||
Item::Car1 => format!("P1 CAR < {} >", self.data.vehicles[s.cars[0]].name),
|
||||
Item::Car2 => format!("P2 CAR < {} >", self.data.vehicles[s.cars[1]].name),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Indexed-colour PNG reading and writing for the editable assets.
|
||||
|
||||
use super::palette::Rgb;
|
||||
|
||||
pub struct Indexed {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub pixels: Vec<u8>,
|
||||
/// The PNG's own palette, if it had one.
|
||||
pub palette: Option<Vec<Rgb>>,
|
||||
}
|
||||
|
||||
/// Writes an 8-bit indexed PNG. With `transparent_zero`, index 0 is marked
|
||||
/// fully transparent so sprite sheets show their backgrounds as clear.
|
||||
pub fn write_indexed(
|
||||
w: usize,
|
||||
h: usize,
|
||||
palette: &[Rgb],
|
||||
transparent_zero: bool,
|
||||
pixels: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, w as u32, h as u32);
|
||||
enc.set_color(png::ColorType::Indexed);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
enc.set_palette(
|
||||
palette
|
||||
.iter()
|
||||
.flat_map(|c| [c.0, c.1, c.2])
|
||||
.collect::<Vec<u8>>(),
|
||||
);
|
||||
if transparent_zero {
|
||||
enc.set_trns(vec![0u8]);
|
||||
}
|
||||
let mut wr = enc.write_header().map_err(|e| e.to_string())?;
|
||||
wr.write_image_data(pixels).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a PNG as palette indices. Indexed images keep their indices; images
|
||||
/// an editor has converted to RGB(A) are mapped to the nearest colour in
|
||||
/// `fallback`, with fully transparent pixels becoming index 0.
|
||||
pub fn read_indexed(bytes: &[u8], fallback: &[Rgb]) -> Result<Indexed, String> {
|
||||
let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
dec.set_transformations(png::Transformations::STRIP_16);
|
||||
let mut reader = dec.read_info().map_err(|e| e.to_string())?;
|
||||
let info = reader.info();
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let own_palette = info.palette.as_ref().map(|p| {
|
||||
p.chunks_exact(3)
|
||||
.map(|c| Rgb(c[0], c[1], c[2]))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let mut buf = vec![0; reader.output_buffer_size().ok_or("image too large")?];
|
||||
let frame = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
|
||||
let line = frame.line_size;
|
||||
let mut pixels = Vec::with_capacity(w * h);
|
||||
|
||||
match frame.color_type {
|
||||
png::ColorType::Indexed => {
|
||||
let bits = frame.bit_depth as usize;
|
||||
for y in 0..h {
|
||||
let row = &buf[y * line..(y + 1) * line];
|
||||
for x in 0..w {
|
||||
let bit = x * bits;
|
||||
let byte = row[bit / 8];
|
||||
let shift = 8 - bits - (bit % 8);
|
||||
pixels.push((byte >> shift) & ((1u16 << bits) - 1) as u8);
|
||||
}
|
||||
}
|
||||
return Ok(Indexed {
|
||||
w,
|
||||
h,
|
||||
pixels,
|
||||
palette: own_palette,
|
||||
});
|
||||
}
|
||||
png::ColorType::Rgb
|
||||
| png::ColorType::Rgba
|
||||
| png::ColorType::Grayscale
|
||||
| png::ColorType::GrayscaleAlpha => {
|
||||
if frame.bit_depth != png::BitDepth::Eight {
|
||||
return Err("unsupported bit depth; save as 8-bit".into());
|
||||
}
|
||||
let ch = frame.color_type.samples();
|
||||
for y in 0..h {
|
||||
let row = &buf[y * line..(y + 1) * line];
|
||||
for x in 0..w {
|
||||
let p = &row[x * ch..(x + 1) * ch];
|
||||
let (c, alpha) = match ch {
|
||||
1 => (Rgb(p[0], p[0], p[0]), 255),
|
||||
2 => (Rgb(p[0], p[0], p[0]), p[1]),
|
||||
3 => (Rgb(p[0], p[1], p[2]), 255),
|
||||
_ => (Rgb(p[0], p[1], p[2]), p[3]),
|
||||
};
|
||||
pixels.push(if alpha < 128 { 0 } else { nearest(c, fallback) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Indexed {
|
||||
w,
|
||||
h,
|
||||
pixels,
|
||||
palette: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn nearest(c: Rgb, pal: &[Rgb]) -> u8 {
|
||||
let d = |p: &Rgb| {
|
||||
let (dr, dg, db) = (
|
||||
c.0 as i32 - p.0 as i32,
|
||||
c.1 as i32 - p.1 as i32,
|
||||
c.2 as i32 - p.2 as i32,
|
||||
);
|
||||
dr * dr * 3 + dg * dg * 4 + db * db * 2
|
||||
};
|
||||
(0..pal.len()).min_by_key(|&i| d(&pal[i])).unwrap_or(0) as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn indexed_round_trip() {
|
||||
let pal: Vec<Rgb> = (0..16).map(|i| Rgb(i * 16, i * 8, 255 - i * 16)).collect();
|
||||
let px: Vec<u8> = (0..64u32).map(|i| (i * 7 % 16) as u8).collect();
|
||||
let png = write_indexed(8, 8, &pal, false, &px).unwrap();
|
||||
let back = read_indexed(&png, &pal).unwrap();
|
||||
assert_eq!((back.w, back.h), (8, 8));
|
||||
assert_eq!(back.pixels, px);
|
||||
assert_eq!(back.palette.unwrap(), pal);
|
||||
}
|
||||
}
|
||||
+133
-38
@@ -1,12 +1,15 @@
|
||||
//! Locates and decodes the original game data.
|
||||
//!
|
||||
//! The default build embeds the data from the ADF images it was built
|
||||
//! alongside (see `build.rs`), so no disks are needed at run time. Extra or
|
||||
//! replacement data can still be supplied as ADF images, the LHA archives
|
||||
//! from them, or an extracted tree with `track/`, `vehicles/` and `palettes/`.
|
||||
//! Files on disk override the built-in ones.
|
||||
//! The game's data lives in `assets/` as editable files: per-track PNG and
|
||||
//! text, per-vehicle sprite sheet and settings (see `track.rs` and
|
||||
//! `vehicle.rs`). The default build embeds that folder (see `build.rs`), so
|
||||
//! nothing is needed at run time. The original Amiga data is still read as
|
||||
//! ADF images, LHA archives, or `track/` and `vehicles/` files; `skids
|
||||
//! extract` converts it to assets. When both exist, assets win, so edits are
|
||||
//! never shadowed by the disks they came from.
|
||||
|
||||
pub mod embedded;
|
||||
pub mod image;
|
||||
pub mod palette;
|
||||
pub mod track;
|
||||
pub mod vehicle;
|
||||
@@ -97,14 +100,22 @@ impl FileSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduces a path to `dir/name` if `dir` is one the game uses.
|
||||
/// Reduces a path to the key the game looks files up by: `dir/name` for
|
||||
/// the original data and palettes, `tracks/<name>/<file>` for track assets.
|
||||
fn logical_key(path: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 2 {
|
||||
let parts: Vec<String> = path
|
||||
.split(['/', '\\'])
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_lowercase)
|
||||
.collect();
|
||||
let n = parts.len();
|
||||
if n >= 3 && parts[n - 3] == "tracks" {
|
||||
return Some(format!("tracks/{}/{}", parts[n - 2], parts[n - 1]));
|
||||
}
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let dir = parts[parts.len() - 2].to_lowercase();
|
||||
let name = parts[parts.len() - 1].to_lowercase();
|
||||
let (dir, name) = (&parts[n - 2], &parts[n - 1]);
|
||||
matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}"))
|
||||
}
|
||||
|
||||
@@ -117,7 +128,20 @@ pub fn unpack(d: &[u8]) -> Result<Vec<u8>, crm::CrmError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TrackSource {
|
||||
/// `tracks/<name>/` asset files.
|
||||
Asset { png: String, txt: String },
|
||||
/// A section of an original `TRACKn` file.
|
||||
Packed { file: usize, section: usize },
|
||||
}
|
||||
|
||||
pub struct TrackEntry {
|
||||
pub name: String,
|
||||
pub source: TrackSource,
|
||||
}
|
||||
|
||||
pub struct GameData {
|
||||
pub tracks: Vec<TrackEntry>,
|
||||
pub track_files: Vec<TrackFile>,
|
||||
pub vehicles: Vec<Vehicle>,
|
||||
pub files: FileSet,
|
||||
@@ -133,6 +157,26 @@ impl GameData {
|
||||
}
|
||||
let mut warnings = files.warnings.clone();
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for key in files.files.keys() {
|
||||
if let Some(rest) = key.strip_prefix("tracks/") {
|
||||
if let Some(name) = rest.strip_suffix("/track.png") {
|
||||
let txt = format!("tracks/{name}/track.txt");
|
||||
if files.files.contains_key(&txt) {
|
||||
tracks.push(TrackEntry {
|
||||
name: name.to_uppercase(),
|
||||
source: TrackSource::Asset {
|
||||
png: key.clone(),
|
||||
txt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
warnings.push(format!("{key}: no track.txt next to it, skipped"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut track_files = Vec::new();
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = key.strip_prefix("track/") {
|
||||
@@ -143,25 +187,59 @@ impl GameData {
|
||||
}
|
||||
}
|
||||
track_files.sort_by_key(|t| (t.number, t.name.clone()));
|
||||
|
||||
let mut vehicles = Vec::new();
|
||||
for (key, data) in &files.files {
|
||||
if let Some(name) = key.strip_prefix("vehicles/") {
|
||||
match Vehicle::parse(name, data) {
|
||||
Ok(Some(v)) => vehicles.push(v),
|
||||
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
for (file, tf) in track_files.iter().enumerate() {
|
||||
for section in 0..tf.sections.len() {
|
||||
let name = tf.track_name(section);
|
||||
if !tracks.iter().any(|t| t.name == name) {
|
||||
tracks.push(TrackEntry {
|
||||
name,
|
||||
source: TrackSource::Packed { file, section },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
vehicles.sort_by_key(|v| vehicle::display_order(&v.id));
|
||||
tracks.sort_by_key(|t| natural_key(&t.name));
|
||||
|
||||
if track_files.is_empty() || vehicles.is_empty() {
|
||||
let mut vehicles: Vec<Vehicle> = Vec::new();
|
||||
for (key, data) in &files.files {
|
||||
let Some(id) = key
|
||||
.strip_prefix("vehicles/")
|
||||
.and_then(|n| n.strip_suffix(".png"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let txt = files
|
||||
.get(&format!("vehicles/{id}.txt"))
|
||||
.map(String::from_utf8_lossy);
|
||||
match Vehicle::from_assets(id, data, txt.as_deref()) {
|
||||
Ok(v) => vehicles.push(v),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
for (key, data) in &files.files {
|
||||
let Some(name) = key
|
||||
.strip_prefix("vehicles/")
|
||||
.filter(|n| n.ends_with(".aga"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if vehicles.iter().any(|v| format!("{}.aga", v.id) == name) {
|
||||
continue;
|
||||
}
|
||||
match Vehicle::parse(name, data) {
|
||||
Ok(Some(v)) => vehicles.push(v),
|
||||
Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")),
|
||||
Err(e) => warnings.push(format!("{key}: {e}")),
|
||||
}
|
||||
}
|
||||
vehicles.sort_by_key(|v| (vehicle::display_order(&v.id), v.id.clone()));
|
||||
|
||||
if tracks.is_empty() || vehicles.is_empty() {
|
||||
return Err(format!(
|
||||
"no usable game data (found {} track files, {} vehicles).\n\
|
||||
This build has no data built in: point --data at a folder containing the \
|
||||
Super Skidmarks ADF images, or rebuild with the images next to Cargo.toml.{}",
|
||||
track_files.len(),
|
||||
"no usable game data (found {} tracks, {} vehicles).\n\
|
||||
This build has no data built in: point --data at the assets folder or \
|
||||
the Super Skidmarks ADF images, or rebuild with assets/ in place.{}",
|
||||
tracks.len(),
|
||||
vehicles.len(),
|
||||
if warnings.is_empty() {
|
||||
String::new()
|
||||
@@ -171,6 +249,7 @@ impl GameData {
|
||||
));
|
||||
}
|
||||
Ok(GameData {
|
||||
tracks,
|
||||
track_files,
|
||||
vehicles,
|
||||
files,
|
||||
@@ -178,23 +257,39 @@ impl GameData {
|
||||
})
|
||||
}
|
||||
|
||||
/// Flattened (file, section) list of every playable track.
|
||||
pub fn track_list(&self) -> Vec<(usize, usize)> {
|
||||
let mut v = Vec::new();
|
||||
for (fi, tf) in self.track_files.iter().enumerate() {
|
||||
for si in 0..tf.sections.len() {
|
||||
v.push((fi, si));
|
||||
pub fn load_track(&self, i: usize) -> Result<Track, String> {
|
||||
let entry = &self.tracks[i];
|
||||
let mut t = match &entry.source {
|
||||
TrackSource::Asset { png, txt } => {
|
||||
let txt = String::from_utf8_lossy(self.files.get(txt).unwrap_or_default());
|
||||
Track::from_assets(&entry.name, self.files.get(png).unwrap_or_default(), &txt)
|
||||
.map_err(|e| format!("{}: {e}", entry.name))?
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn load_track(&self, file: usize, section: usize) -> Result<Track, String> {
|
||||
let tf = &self.track_files[file];
|
||||
let mut t = tf.decode(section)?;
|
||||
if let Some(p) = palette::override_for(&self.files, &tf.name, section) {
|
||||
TrackSource::Packed { file, section } => self.track_files[*file].decode(*section)?,
|
||||
};
|
||||
if let Some(p) = palette::override_for(&self.files, &entry.name) {
|
||||
t.palette = p;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort key that orders TRACK2-10 after TRACK2-9.
|
||||
fn natural_key(name: &str) -> Vec<(String, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut num = String::new();
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
num.push(c);
|
||||
} else {
|
||||
if !num.is_empty() {
|
||||
out.push((std::mem::take(&mut text), num.parse().unwrap_or(0)));
|
||||
num.clear();
|
||||
}
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
out.push((text, num.parse().unwrap_or(0)));
|
||||
out
|
||||
}
|
||||
|
||||
+9
-6
@@ -6,8 +6,9 @@
|
||||
//! ordered dark-to-light, with 0-7 used for scenery and 8-15 for the driving
|
||||
//! surface, so two ramps give a faithful-looking result.
|
||||
//!
|
||||
//! Real palettes can be dropped in as text files (16 lines of `RRGGBB`,
|
||||
//! `#` comments allowed) next to the game data:
|
||||
//! Each track asset's PNG carries its own palette, so the usual way to
|
||||
//! recolour a track is to edit that. Palettes can also be dropped in as text
|
||||
//! files (16 lines of `RRGGBB`, `#` comments allowed), which win over both:
|
||||
//! palettes/track1-3.pal one track
|
||||
//! palettes/track1.pal all six tracks in TRACK1
|
||||
//! palettes/default.pal every track
|
||||
@@ -100,11 +101,13 @@ pub fn parse(text: &str) -> Option<[Rgb; 16]> {
|
||||
(n == 16).then_some(out)
|
||||
}
|
||||
|
||||
pub fn override_for(files: &FileSet, track_file: &str, section: usize) -> Option<[Rgb; 16]> {
|
||||
let base = track_file.to_lowercase();
|
||||
/// A palette file for a track named like `TRACK1-3`, most specific first.
|
||||
pub fn override_for(files: &FileSet, track: &str) -> Option<[Rgb; 16]> {
|
||||
let name = track.to_lowercase();
|
||||
let group = name.split('-').next().unwrap_or(&name).to_string();
|
||||
[
|
||||
format!("palettes/{base}-{}.pal", section + 1),
|
||||
format!("palettes/{base}.pal"),
|
||||
format!("palettes/{name}.pal"),
|
||||
format!("palettes/{group}.pal"),
|
||||
"palettes/default.pal".into(),
|
||||
]
|
||||
.iter()
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! only lands on the road on 14 of the 24 tracks, so the grid is placed from
|
||||
//! the gates instead.
|
||||
|
||||
use super::image;
|
||||
use super::palette::{self, Rgb};
|
||||
use super::unpack;
|
||||
use crate::formats::{be16, be32};
|
||||
@@ -210,6 +211,139 @@ impl Track {
|
||||
on_screen(sx, sy, 0.0) && self.on_road(sx, sy)
|
||||
}
|
||||
|
||||
/// Loads an editable asset: `track.png` (16-colour indexed; its palette
|
||||
/// is the track palette) and `track.txt` (gates and height map).
|
||||
pub fn from_assets(name: &str, png: &[u8], txt: &str) -> Result<Track, String> {
|
||||
let img = image::read_indexed(png, &palette::TRACK_DEFAULT)?;
|
||||
if (img.w, img.h) != (W, H) {
|
||||
return Err(format!(
|
||||
"track.png is {}x{}, expected {W}x{H}",
|
||||
img.w, img.h
|
||||
));
|
||||
}
|
||||
if let Some(bad) = img.pixels.iter().find(|&&p| p > 15) {
|
||||
return Err(format!(
|
||||
"track.png uses palette index {bad}; tracks may only use 0-15"
|
||||
));
|
||||
}
|
||||
let mut pal = palette::TRACK_DEFAULT;
|
||||
if let Some(own) = &img.palette {
|
||||
for (dst, src) in pal.iter_mut().zip(own) {
|
||||
*dst = *src;
|
||||
}
|
||||
}
|
||||
|
||||
let mut gates = Vec::new();
|
||||
let mut start = (0.0, 0.0);
|
||||
let mut flags = 0;
|
||||
let mut height = Vec::with_capacity(HM_W * HM_H);
|
||||
let mut in_height = false;
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
let err = |m: &str| format!("track.txt line {}: {m}", n + 1);
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut words = line.split_whitespace();
|
||||
let first = words.next().unwrap_or("");
|
||||
let nums = |w: std::str::SplitWhitespace| -> Result<Vec<f32>, String> {
|
||||
w.map(|x| {
|
||||
x.parse::<f32>()
|
||||
.map_err(|_| err(&format!("`{x}` is not a number")))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if in_height {
|
||||
for x in line.split_whitespace() {
|
||||
height.push(
|
||||
x.parse::<u16>()
|
||||
.map_err(|_| err(&format!("bad height `{x}`")))?,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match first {
|
||||
"gate" => {
|
||||
let v = nums(words)?;
|
||||
if v.len() != 4 {
|
||||
return Err(err("gate needs x1 y1 x2 y2"));
|
||||
}
|
||||
gates.push(Gate {
|
||||
a: (v[0], v[1]),
|
||||
b: (v[2], v[3]),
|
||||
});
|
||||
}
|
||||
"start" => {
|
||||
let v = nums(words)?;
|
||||
if v.len() != 2 {
|
||||
return Err(err("start needs x y"));
|
||||
}
|
||||
start = (v[0], v[1]);
|
||||
}
|
||||
"flags" => {
|
||||
flags = words
|
||||
.next()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.ok_or_else(|| err("bad flags"))?
|
||||
}
|
||||
"height" => in_height = true,
|
||||
other => return Err(err(&format!("unknown entry `{other}`"))),
|
||||
}
|
||||
}
|
||||
if gates.len() < 2 {
|
||||
return Err("track.txt needs at least two gates".into());
|
||||
}
|
||||
if height.len() != HM_W * HM_H {
|
||||
return Err(format!(
|
||||
"track.txt height map has {} values, expected {}",
|
||||
height.len(),
|
||||
HM_W * HM_H
|
||||
));
|
||||
}
|
||||
Ok(Track {
|
||||
name: name.to_string(),
|
||||
pixels: img.pixels,
|
||||
palette: pal,
|
||||
height,
|
||||
gates,
|
||||
start,
|
||||
flags,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
image::write_indexed(W, H, &self.palette, false, &self.pixels)
|
||||
}
|
||||
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut t = format!(
|
||||
"# 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\
|
||||
#\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",
|
||||
self.name
|
||||
);
|
||||
for g in &self.gates {
|
||||
t.push_str(&format!("gate {} {} {} {}\n", g.a.0, g.a.1, g.b.0, g.b.1));
|
||||
}
|
||||
t.push_str(&format!(
|
||||
"\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",
|
||||
self.start.0, self.start.1, self.flags
|
||||
));
|
||||
for row in self.height.chunks(HM_W) {
|
||||
let line: Vec<String> = row.iter().map(|v| v.to_string()).collect();
|
||||
t.push_str(&line.join(" "));
|
||||
t.push('\n');
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn rgb(&self) -> Vec<u32> {
|
||||
let pal: Vec<u32> = self.palette.iter().map(|c| c.to_u32()).collect();
|
||||
self.pixels.iter().map(|&i| pal[i as usize]).collect()
|
||||
|
||||
+259
-1
@@ -1,5 +1,12 @@
|
||||
//! Vehicle sprite banks (`vehicles/*.aga`).
|
||||
//! Vehicles: sprite banks and handling.
|
||||
//!
|
||||
//! Editable assets (`assets/vehicles/<id>.png` + `<id>.txt`): the PNG is a
|
||||
//! sheet of 32 headings across by 25 tilt blocks down, each cell 64x32, with
|
||||
//! palette indices 0 (transparent), 1 (tyres/shadow), 2 (trim), 3 (body).
|
||||
//! Its colours are only a preview; cars are recoloured per player. The text
|
||||
//! file holds the display name and handling.
|
||||
//!
|
||||
//! Original data (`vehicles/*.aga`):
|
||||
//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes.
|
||||
//! Each frame is a 64x32 AGA sprite with two bitplanes stored per line
|
||||
//! (8 bytes plane 0, 8 bytes plane 1), giving three colours plus transparent.
|
||||
@@ -8,6 +15,8 @@
|
||||
//! Heading frame 12 faces screen-right and frames advance clockwise in
|
||||
//! 11.25 degree steps.
|
||||
|
||||
use super::image;
|
||||
use super::palette::{self, Rgb};
|
||||
use super::unpack;
|
||||
|
||||
pub const SPR_W: usize = 64;
|
||||
@@ -18,9 +27,115 @@ pub const LEVEL: usize = 12;
|
||||
const FRAME_BYTES: usize = 512;
|
||||
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.
|
||||
#[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 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 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,
|
||||
};
|
||||
|
||||
/// Starting values used when importing the original data.
|
||||
pub fn default_spec(id: &str) -> Spec {
|
||||
let b = BASE_SPEC;
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vehicle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub spec: Spec,
|
||||
/// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H.
|
||||
pub frames: Vec<u8>,
|
||||
}
|
||||
@@ -51,11 +166,124 @@ impl Vehicle {
|
||||
let id = file.trim_end_matches(".aga").to_lowercase();
|
||||
Ok(Some(Vehicle {
|
||||
name: display_name(&id),
|
||||
spec: default_spec(&id),
|
||||
id,
|
||||
frames,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Loads an editable asset: sprite sheet PNG plus optional settings text.
|
||||
pub fn from_assets(id: &str, png: &[u8], txt: Option<&str>) -> Result<Vehicle, String> {
|
||||
let img = image::read_indexed(png, &preview_palette())?;
|
||||
if (img.w, img.h) != (SHEET_W, SHEET_H) {
|
||||
return Err(format!(
|
||||
"sheet is {}x{}, expected {SHEET_W}x{SHEET_H}",
|
||||
img.w, img.h
|
||||
));
|
||||
}
|
||||
if let Some(bad) = img.pixels.iter().find(|&&p| p > 3) {
|
||||
return Err(format!("uses palette index {bad}; cars may only use 0-3"));
|
||||
}
|
||||
let mut frames = vec![0u8; TILTS * HEADINGS * SPR_W * SPR_H];
|
||||
for tilt in 0..TILTS {
|
||||
for hd in 0..HEADINGS {
|
||||
let f = (tilt * HEADINGS + hd) * SPR_W * SPR_H;
|
||||
for y in 0..SPR_H {
|
||||
let src = (tilt * SPR_H + y) * SHEET_W + hd * SPR_W;
|
||||
frames[f + y * SPR_W..f + (y + 1) * SPR_W]
|
||||
.copy_from_slice(&img.pixels[src..src + SPR_W]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = Vehicle {
|
||||
id: id.to_string(),
|
||||
name: display_name(id),
|
||||
spec: default_spec(id),
|
||||
frames,
|
||||
};
|
||||
if let Some(txt) = txt {
|
||||
v.apply_settings(txt)?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn sheet_png(&self) -> Result<Vec<u8>, String> {
|
||||
let mut px = vec![0u8; SHEET_W * SHEET_H];
|
||||
for tilt in 0..TILTS {
|
||||
for hd in 0..HEADINGS {
|
||||
let fr = self.frame(tilt, hd);
|
||||
for y in 0..SPR_H {
|
||||
let dst = (tilt * SPR_H + y) * SHEET_W + hd * SPR_W;
|
||||
px[dst..dst + SPR_W].copy_from_slice(&fr[y * SPR_W..(y + 1) * SPR_W]);
|
||||
}
|
||||
}
|
||||
}
|
||||
image::write_indexed(SHEET_W, SHEET_H, &preview_palette(), true, &px)
|
||||
}
|
||||
|
||||
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\
|
||||
name {name}\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",
|
||||
id = self.id,
|
||||
name = self.name,
|
||||
top = s.top,
|
||||
accel = s.accel,
|
||||
brake = s.brake,
|
||||
grip = s.grip,
|
||||
turn = s.turn,
|
||||
mass = s.mass,
|
||||
offroad = s.offroad
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_settings(&mut self, txt: &str) -> Result<(), String> {
|
||||
for (n, line) in txt.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
let Some((key, val)) = line.split_once(char::is_whitespace) else {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("line {}: expected `key value`", n + 1));
|
||||
};
|
||||
let val = val.trim();
|
||||
if key == "name" {
|
||||
self.name = val.to_uppercase();
|
||||
continue;
|
||||
}
|
||||
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)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn frame(&self, tilt: usize, heading: usize) -> &[u8] {
|
||||
let f = tilt.min(TILTS - 1) * HEADINGS + heading % HEADINGS;
|
||||
&self.frames[f * SPR_W * SPR_H..(f + 1) * SPR_W * SPR_H]
|
||||
@@ -73,6 +301,13 @@ pub fn tilt_block(pitch: i32, roll: i32) -> usize {
|
||||
((roll.clamp(-2, 2) + 2) * 5 + (pitch.clamp(-2, 2) + 2)) as usize
|
||||
}
|
||||
|
||||
/// Colours stored in sprite sheets so they preview sensibly in an editor.
|
||||
fn preview_palette() -> Vec<Rgb> {
|
||||
let p = palette::car_palette(0);
|
||||
let rgb = |c: u32| Rgb((c >> 16) as u8, (c >> 8) as u8, c as u8);
|
||||
vec![Rgb(0x30, 0x48, 0x30), rgb(p[1]), rgb(p[2]), rgb(p[3])]
|
||||
}
|
||||
|
||||
fn display_name(id: &str) -> String {
|
||||
match id {
|
||||
"vw" => "BEETLE".into(),
|
||||
@@ -101,6 +336,29 @@ mod tests {
|
||||
assert_eq!(heading_frame(-PI + FRAC_PI_4), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let mut v = Vehicle {
|
||||
id: "f1".into(),
|
||||
name: "X".into(),
|
||||
spec: BASE_SPEC,
|
||||
frames: vec![0; TILTS * HEADINGS * SPR_W * SPR_H],
|
||||
};
|
||||
v.spec.grip = 6.25;
|
||||
v.name = "FORMULA 1".into();
|
||||
let txt = v.settings_text();
|
||||
let mut w = Vehicle {
|
||||
id: "f1".into(),
|
||||
name: String::new(),
|
||||
spec: BASE_SPEC,
|
||||
frames: Vec::new(),
|
||||
};
|
||||
w.apply_settings(&txt).unwrap();
|
||||
assert_eq!(w.spec, v.spec);
|
||||
assert_eq!(w.name, "FORMULA 1");
|
||||
assert!(w.apply_settings("grip fast").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_block() {
|
||||
assert_eq!(tilt_block(0, 0), LEVEL);
|
||||
|
||||
+5
-102
@@ -1,111 +1,14 @@
|
||||
//! Vehicle handling.
|
||||
//!
|
||||
//! The retail executable is not available, so handling is a reconstruction:
|
||||
//! an arcade model with separate forward and lateral friction, which gives
|
||||
//! 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`).
|
||||
|
||||
use crate::data::track::{self, Track};
|
||||
use crate::data::vehicle;
|
||||
use crate::data::vehicle::{self, Spec};
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
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 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 fn spec_for(id: &str) -> Spec {
|
||||
let base = Spec {
|
||||
top: 200.0,
|
||||
accel: 170.0,
|
||||
brake: 320.0,
|
||||
grip: 5.0,
|
||||
turn: 3.6,
|
||||
mass: 1.0,
|
||||
offroad: 0.55,
|
||||
};
|
||||
match id {
|
||||
"f1" => Spec {
|
||||
top: 240.0,
|
||||
accel: 210.0,
|
||||
grip: 6.5,
|
||||
turn: 3.7,
|
||||
mass: 0.9,
|
||||
offroad: 0.45,
|
||||
..base
|
||||
},
|
||||
"porsche" => Spec {
|
||||
top: 225.0,
|
||||
accel: 190.0,
|
||||
grip: 5.2,
|
||||
turn: 3.6,
|
||||
..base
|
||||
},
|
||||
"mini" => Spec {
|
||||
top: 190.0,
|
||||
accel: 200.0,
|
||||
grip: 5.8,
|
||||
turn: 4.4,
|
||||
mass: 0.8,
|
||||
..base
|
||||
},
|
||||
"vw" => Spec {
|
||||
top: 185.0,
|
||||
accel: 160.0,
|
||||
grip: 4.6,
|
||||
turn: 3.6,
|
||||
mass: 1.0,
|
||||
..base
|
||||
},
|
||||
"midget" => Spec {
|
||||
top: 205.0,
|
||||
accel: 185.0,
|
||||
grip: 4.0,
|
||||
turn: 4.0,
|
||||
mass: 0.85,
|
||||
offroad: 0.6,
|
||||
..base
|
||||
},
|
||||
"truck" => Spec {
|
||||
top: 195.0,
|
||||
accel: 140.0,
|
||||
grip: 4.2,
|
||||
turn: 3.0,
|
||||
mass: 1.8,
|
||||
offroad: 0.75,
|
||||
..base
|
||||
},
|
||||
"police" => Spec {
|
||||
top: 215.0,
|
||||
accel: 180.0,
|
||||
grip: 5.0,
|
||||
turn: 3.5,
|
||||
mass: 1.2,
|
||||
..base
|
||||
},
|
||||
"cow" => Spec {
|
||||
top: 170.0,
|
||||
accel: 150.0,
|
||||
grip: 7.0,
|
||||
turn: 4.6,
|
||||
mass: 1.4,
|
||||
offroad: 0.9,
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -143,10 +46,10 @@ pub struct Car {
|
||||
}
|
||||
|
||||
impl Car {
|
||||
pub fn new(vehicle: usize, id: &str, team: usize, x: f32, y: f32, heading: f32) -> Car {
|
||||
pub fn new(vehicle: usize, spec: Spec, team: usize, x: f32, y: f32, heading: f32) -> Car {
|
||||
Car {
|
||||
vehicle,
|
||||
spec: spec_for(id),
|
||||
spec,
|
||||
team,
|
||||
x,
|
||||
y,
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ impl Race {
|
||||
let ((px, py), heading) = behind(&path, 18.0 + row * 30.0);
|
||||
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].id, s.team, x, y, heading);
|
||||
let car = Car::new(s.vehicle, vehicles[s.vehicle].spec, s.team, x, y, heading);
|
||||
let line = 0.3 + 0.4 * rand01(&mut rng);
|
||||
let pace = 0.86 + 0.12 * rand01(&mut rng);
|
||||
entrants.push(Entrant {
|
||||
|
||||
+46
-51
@@ -23,7 +23,7 @@ skids - Super Skidmarks revival
|
||||
USAGE:
|
||||
skids [--data DIR] [--scale 1|2|4] [--mute] play
|
||||
skids info [--data DIR] list what was found in the data
|
||||
skids extract [--data DIR] --out DIR write PNGs of every track and car
|
||||
skids extract [--data DIR] [--out DIR] convert the data to editable assets (default ./assets)
|
||||
skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu]
|
||||
simulate a race (or the menu) and save a frame
|
||||
skids bench [--data DIR] [--seconds S] race computer cars on every track
|
||||
@@ -165,11 +165,14 @@ fn info(data: &GameData, dir: Option<&Path>) {
|
||||
(n, None) => println!("Data: built in ({n} files)"),
|
||||
}
|
||||
println!("Tracks:");
|
||||
for (f, s) in data.track_list() {
|
||||
let tf = &data.track_files[f];
|
||||
match tf.decode(s) {
|
||||
Ok(t) => println!(" {:<10} {:>2} gates", t.name, t.gates.len()),
|
||||
Err(e) => println!(" {:<10} ERROR {e}", tf.track_name(s)),
|
||||
for (i, entry) in data.tracks.iter().enumerate() {
|
||||
let from = match entry.source {
|
||||
data::TrackSource::Asset { .. } => "asset",
|
||||
data::TrackSource::Packed { .. } => "original data",
|
||||
};
|
||||
match data.load_track(i) {
|
||||
Ok(t) => println!(" {:<10} {:>2} gates ({from})", t.name, t.gates.len()),
|
||||
Err(e) => println!(" {:<10} ERROR {e}", entry.name),
|
||||
}
|
||||
}
|
||||
println!("Vehicles:");
|
||||
@@ -178,54 +181,46 @@ fn info(data: &GameData, dir: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes every track and vehicle as editable assets, then reads each one
|
||||
/// back to check the conversion is lossless.
|
||||
fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> {
|
||||
use data::{track, vehicle};
|
||||
let out = out.ok_or("extract needs --out DIR")?;
|
||||
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
|
||||
for (f, s) in data.track_list() {
|
||||
let t = data.load_track(f, s)?;
|
||||
let mut img = t.rgb();
|
||||
// Overlay the lap gates so the track data can be checked by eye.
|
||||
for g in &t.gates {
|
||||
let steps = 64;
|
||||
for i in 0..=steps {
|
||||
let k = i as f32 / steps as f32;
|
||||
let (x, y) =
|
||||
track::to_screen(g.a.0 + (g.b.0 - g.a.0) * k, g.a.1 + (g.b.1 - g.a.1) * k);
|
||||
if (0.0..track::W as f32).contains(&x) && (0.0..track::H as f32).contains(&y) {
|
||||
img[y as usize * track::W + x as usize] = 0xff2020;
|
||||
}
|
||||
}
|
||||
use data::{Track, Vehicle};
|
||||
let out = out.unwrap_or(Path::new("assets"));
|
||||
let write = |path: PathBuf, bytes: &[u8]| -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
};
|
||||
for i in 0..data.tracks.len() {
|
||||
let t = data.load_track(i)?;
|
||||
let dir = out.join("tracks").join(t.name.to_lowercase());
|
||||
let (png, txt) = (t.to_png()?, t.to_text());
|
||||
let back = Track::from_assets(&t.name, &png, &txt)?;
|
||||
let same = back.pixels == t.pixels
|
||||
&& back.palette == t.palette
|
||||
&& back.height == t.height
|
||||
&& back
|
||||
.gates
|
||||
.iter()
|
||||
.zip(&t.gates)
|
||||
.all(|(a, b)| a.a == b.a && a.b == b.b)
|
||||
&& back.gates.len() == t.gates.len();
|
||||
if !same {
|
||||
return Err(format!("{}: asset does not round-trip", t.name));
|
||||
}
|
||||
let path = out.join(format!("{}.png", t.name.to_lowercase()));
|
||||
render::save_png(&path, track::W, track::H, &img).map_err(|e| e.to_string())?;
|
||||
println!("wrote {}", path.display());
|
||||
write(dir.join("track.png"), &png)?;
|
||||
write(dir.join("track.txt"), txt.as_bytes())?;
|
||||
println!("wrote {}", dir.display());
|
||||
}
|
||||
for (vi, v) in data.vehicles.iter().enumerate() {
|
||||
// Sheet: 32 headings across, 25 tilt blocks down.
|
||||
let (w, h) = (
|
||||
vehicle::SPR_W * vehicle::HEADINGS,
|
||||
vehicle::SPR_H * vehicle::TILTS,
|
||||
);
|
||||
let pal = data::palette::car_palette(vi);
|
||||
let mut img = vec![0x203018u32; w * h];
|
||||
for tilt in 0..vehicle::TILTS {
|
||||
for hd in 0..vehicle::HEADINGS {
|
||||
let fr = v.frame(tilt, hd);
|
||||
for y in 0..vehicle::SPR_H {
|
||||
for x in 0..vehicle::SPR_W {
|
||||
let i = fr[y * vehicle::SPR_W + x];
|
||||
if i != 0 {
|
||||
img[(tilt * vehicle::SPR_H + y) * w + hd * vehicle::SPR_W + x] =
|
||||
pal[i as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in &data.vehicles {
|
||||
let (png, txt) = (v.sheet_png()?, v.settings_text());
|
||||
let back = Vehicle::from_assets(&v.id, &png, Some(&txt))?;
|
||||
if back.frames != v.frames || back.spec != v.spec || back.name != v.name {
|
||||
return Err(format!("{}: asset does not round-trip", v.id));
|
||||
}
|
||||
let path = out.join(format!("car-{}.png", v.id));
|
||||
render::save_png(&path, w, h, &img).map_err(|e| e.to_string())?;
|
||||
println!("wrote {}", path.display());
|
||||
let dir = out.join("vehicles");
|
||||
write(dir.join(format!("{}.png", v.id)), &png)?;
|
||||
write(dir.join(format!("{}.txt", v.id)), txt.as_bytes())?;
|
||||
println!("wrote {}", dir.join(&v.id).display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -293,7 +288,7 @@ fn bench(data: GameData, args: &Args) -> Result<(), String> {
|
||||
"{:<10} {:>5} {:>8} {:>9} {:>8}",
|
||||
"TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD"
|
||||
);
|
||||
for ti in 0..app.data.track_list().len() {
|
||||
for ti in 0..app.data.tracks.len() {
|
||||
let mut race = app.build_race(ti, 0, [0, 0], 4, 99, ti as u32 + 1)?;
|
||||
let (mut road, mut samples) = (0usize, 0usize);
|
||||
for _ in 0..frames {
|
||||
|
||||
Reference in New Issue
Block a user