dark mode release

This commit is contained in:
Scott Pruett 2022-05-22 22:23:28 -04:00
parent 4a920b4b42
commit 34a2b61757
15 changed files with 427 additions and 342 deletions

14
car-classes.txt Normal file
View File

@ -0,0 +1,14 @@
Piccino
Superlight
Eurotruck
Muscle Car
Stock Car
Super Truck
Rally
50s GT
Touring Car
GT
Prototype
60s GP
80s GP
GP

View File

@ -1,55 +1,56 @@
{ {
"ocr_regions": [ "ocr_regions": [
{ {
"name": "lap", "name": "lap",
"x": 2300, "x": 2300,
"y": 46, "y": 46,
"width": 145, "width": 140,
"height": 90 "height": 90
}, },
{ {
"name": "health", "name": "health",
"x": 90, "x": 90,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "gas", "name": "gas",
"x": 208, "x": 208,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "tyres", "name": "tyres",
"x": 325, "x": 325,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "best", "name": "best",
"x": 2325, "x": 2325,
"y": 169, "y": 169,
"width": 183, "width": 183,
"height": 43 "height": 43
}, },
{ {
"name": "lap_time", "name": "lap_time",
"x": 2325, "x": 2325,
"y": 222, "y": 222,
"width": 183, "width": 183,
"height": 43 "height": 43,
} "use_ocr_cache": false
], }
"track_region": { ],
"name": "track", "track_region": {
"x": 2020, "name": "track",
"y": 1030, "x": 2020,
"width": 540, "y": 1030,
"height": 410, "width": 540,
"threshold": 0.85 "height": 410,
}, "threshold": 0.85
"ocr_server_endpoint": "https://tesserver.spruett.dev/" },
"ocr_server_endpoint": "https://tesserver.spruett.dev/"
} }

3
race_stats.csv Normal file
View File

@ -0,0 +1,3 @@
2022-05-22-20:39,whistle valley,gp,1,22.349,22.349,100,,
2022-05-22-20:39,whistle valley,gp,2,21.888,21.888,99,83,82
2022-05-22-20:39,whistle valley,gp,3,22.031,21.888,99,75,73
1 2022-05-22-20:39 whistle valley gp 1 22.349 22.349 100
2 2022-05-22-20:39 whistle valley gp 2 21.888 21.888 99 83 82
3 2022-05-22-20:39 whistle valley gp 3 22.031 21.888 99 75 73

View File

@ -1,42 +1,42 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import json import json
from typing import Tuple from typing import Tuple
def parse_resolution(resolution: str) -> Tuple[int, int]: def parse_resolution(resolution: str) -> Tuple[int, int]:
a, b = resolution.split('x') a, b = resolution.split('x')
return int(a), int(b) return int(a), int(b)
def scale_x_y(x, y, from_resolution, to_resolution): def scale_x_y(x, y, from_resolution, to_resolution):
return (x * to_resolution[0] / from_resolution[0], y * to_resolution[1] / from_resolution[1]) return (x * to_resolution[0] / from_resolution[0], y * to_resolution[1] / from_resolution[1])
def scale_region(region, from_resolution, to_resolution): def scale_region(region, from_resolution, to_resolution):
x, y = scale_x_y(region['x'], region['y'], from_resolution, to_resolution) x, y = scale_x_y(region['x'], region['y'], from_resolution, to_resolution)
width, height = scale_x_y(region['width'], region['height'], from_resolution, to_resolution) width, height = scale_x_y(region['width'], region['height'], from_resolution, to_resolution)
region['x'] = round(x) region['x'] = round(x)
region['y'] = round(y) region['y'] = round(y)
region['width'] = round(width) region['width'] = round(width)
region['height'] = round(height) region['height'] = round(height)
def main(): def main():
argparser = argparse.ArgumentParser() argparser = argparse.ArgumentParser()
argparser.add_argument("--from_res", help="From resolution", default="2560x1440") argparser.add_argument("--from_res", help="From resolution", default="2560x1440")
argparser.add_argument("--to_res", help="To resolution (e.g. 1920x1080)") argparser.add_argument("--to_res", help="To resolution (e.g. 1920x1080)")
argparser.add_argument("--config", help="Config file", default="config.json") argparser.add_argument("--config", help="Config file", default="config.json")
args = argparser.parse_args() args = argparser.parse_args()
from_resolution = parse_resolution(args.from_res) from_resolution = parse_resolution(args.from_res)
to_resolution = parse_resolution(args.to_res) to_resolution = parse_resolution(args.to_res)
config = json.load(open(args.config, 'r')) config = json.load(open(args.config, 'r'))
for region in config['ocr_regions']: for region in config['ocr_regions']:
scale_region(region, from_resolution, to_resolution) scale_region(region, from_resolution, to_resolution)
scale_region(config['track_region'], from_resolution, to_resolution) scale_region(config['track_region'], from_resolution, to_resolution)
print(json.dumps(config, indent=4)) print(json.dumps(config, indent=4))
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -1,56 +1,56 @@
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use anyhow::Result; use anyhow::Result;
use serde::{Serialize, Deserialize, de::DeserializeOwned}; use serde::{Serialize, Deserialize, de::DeserializeOwned};
use crate::image_processing::Region; use crate::image_processing::Region;
#[derive(Default, Serialize, Deserialize, Clone)] #[derive(Default, Serialize, Deserialize, Clone)]
pub struct Config { pub struct Config {
pub ocr_regions: Vec<Region>, pub ocr_regions: Vec<Region>,
pub track_region: Option<Region>, pub track_region: Option<Region>,
pub ocr_server_endpoint: String, pub ocr_server_endpoint: String,
pub filter_threshold: Option<f64>, pub filter_threshold: Option<f64>,
pub use_ocr_cache: Option<bool>, pub use_ocr_cache: Option<bool>,
pub ocr_interval_ms: Option<u64>, pub ocr_interval_ms: Option<u64>,
} }
impl Config { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
load_or_make_default("config.json", include_str!("configs/config.default.json")) load_or_make_default("config.json", include_str!("configs/config.default.json"))
} }
} }
#[derive(Default, Serialize, Deserialize, Clone)] #[derive(Default, Serialize, Deserialize, Clone)]
pub struct LearnedConfig { pub struct LearnedConfig {
pub learned_images: HashMap<String, String>, pub learned_images: HashMap<String, String>,
pub learned_tracks: HashMap<String, String>, pub learned_tracks: HashMap<String, String>,
} }
impl LearnedConfig { impl LearnedConfig {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
load_or_make_default("learned.json", include_str!("configs/learned.default.json")) load_or_make_default("learned.json", include_str!("configs/learned.default.json"))
} }
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
save_json_config("learned.json", self) save_json_config("learned.json", self)
} }
} }
fn load_or_make_default<T: DeserializeOwned>(path: &str, default: &str) -> Result<T> { fn load_or_make_default<T: DeserializeOwned>(path: &str, default: &str) -> Result<T> {
let file_path = PathBuf::from(path); let file_path = PathBuf::from(path);
if !file_path.exists() { if !file_path.exists() {
std::fs::write(&path, default)?; std::fs::write(&path, default)?;
} }
load_json_config(path) load_json_config(path)
} }
fn load_json_config<T: DeserializeOwned>(path: &str) -> Result<T> { fn load_json_config<T: DeserializeOwned>(path: &str) -> Result<T> {
let data = std::fs::read(path)?; let data = std::fs::read(path)?;
let value = serde_json::from_slice(&data)?; let value = serde_json::from_slice(&data)?;
Ok(value) Ok(value)
} }
fn save_json_config<T: Serialize>(path: &str, val: &T) -> Result<()> { fn save_json_config<T: Serialize>(path: &str, val: &T) -> Result<()> {
let serialized = serde_json::to_vec_pretty(val)?; let serialized = serde_json::to_vec_pretty(val)?;
Ok(std::fs::write(path, &serialized)?) Ok(std::fs::write(path, &serialized)?)
} }

View File

@ -1,55 +1,56 @@
{ {
"ocr_regions": [ "ocr_regions": [
{ {
"name": "lap", "name": "lap",
"x": 2300, "x": 2300,
"y": 46, "y": 46,
"width": 145, "width": 142,
"height": 90 "height": 90
}, },
{ {
"name": "health", "name": "health",
"x": 90, "x": 90,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "gas", "name": "gas",
"x": 208, "x": 208,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "tyres", "name": "tyres",
"x": 325, "x": 325,
"y": 1364, "y": 1364,
"width": 52, "width": 52,
"height": 24 "height": 24
}, },
{ {
"name": "best", "name": "best",
"x": 2325, "x": 2325,
"y": 169, "y": 169,
"width": 183, "width": 183,
"height": 43 "height": 43
}, },
{ {
"name": "lap_time", "name": "lap_time",
"x": 2325, "x": 2325,
"y": 222, "y": 222,
"width": 183, "width": 183,
"height": 43 "height": 43,
} "use_ocr_cache": false
], }
"track_region": { ],
"name": "track", "track_region": {
"x": 2020, "name": "track",
"y": 1030, "x": 2020,
"width": 540, "y": 1030,
"height": 410, "width": 540,
"threshold": 0.85 "height": 410,
}, "threshold": 0.85
"ocr_server_endpoint": "https://tesserver.spruett.dev/" },
"ocr_server_endpoint": "https://tesserver.spruett.dev/"
} }

View File

@ -1,4 +1,4 @@
{ {
"learned_images": {}, "learned_images": {},
"learned_tracks": {} "learned_tracks": {}
} }

View File

@ -57,6 +57,7 @@ fn merge_frames(prev: &ParsedFrame, next: &ParsedFrame) -> ParsedFrame {
tyres: merge_with_max(&prev.tyres, &next.tyres), tyres: merge_with_max(&prev.tyres, &next.tyres),
lap_time: merge_with_max(&prev.lap_time, &next.lap_time), lap_time: merge_with_max(&prev.lap_time, &next.lap_time),
best_time: merge_with_max(&prev.best_time, &next.best_time), best_time: merge_with_max(&prev.best_time, &next.best_time),
striked: false,
} }
} }
@ -158,7 +159,7 @@ fn run_loop_once(capturer: &mut Capturer, state: &SharedAppState) -> Result<()>
} }
{ {
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
let parsed = ParsedFrame::parse(&ocr_results); let mut parsed = ParsedFrame::parse(&ocr_results);
handle_new_frame(&mut state, parsed, &frame); handle_new_frame(&mut state, parsed, &frame);
state.raw_data = ocr_results; state.raw_data = ocr_results;
state.saved_frames = saved_frames; state.saved_frames = saved_frames;
@ -172,6 +173,7 @@ pub fn run_control_loop(state: SharedAppState) {
if let Err(e) = run_loop_once(&mut capturer, &state) { if let Err(e) = run_loop_once(&mut capturer, &state) {
eprintln!("Error in control loop: {:?}", e) eprintln!("Error in control loop: {:?}", e)
} }
thread::sleep(Duration::from_millis(state.lock().unwrap().config.ocr_interval_ms.unwrap_or(500))); let interval = state.lock().unwrap().config.ocr_interval_ms.unwrap_or(500);
thread::sleep(Duration::from_millis(interval));
} }
} }

View File

@ -11,6 +11,7 @@ pub struct Region {
width: usize, width: usize,
height: usize, height: usize,
pub threshold: Option<f64>, pub threshold: Option<f64>,
pub use_ocr_cache: Option<bool>,
} }
pub fn extract_and_filter(image: &RgbImage, region: &Region) -> RgbImage { pub fn extract_and_filter(image: &RgbImage, region: &Region) -> RgbImage {

View File

@ -1,24 +1,26 @@
// #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release // #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
mod capture; mod capture;
mod config;
mod control_loop; mod control_loop;
mod image_processing; mod image_processing;
mod ocr; mod ocr;
mod state; mod state;
mod config;
mod stats_writer; mod stats_writer;
use std::{ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Duration, thread, thread,
time::{Duration, Instant},
}; };
use config::{Config, LearnedConfig}; use config::{Config, LearnedConfig};
use eframe::{ use eframe::{
egui::{self, Ui}, egui::{self, Ui, Visuals},
epaint::Color32, emath::Vec2, emath::Vec2,
epaint::Color32,
}; };
use state::{AppState, RaceState, SharedAppState}; use state::{AppState, RaceState, SharedAppState, ParsedFrame};
use stats_writer::export_race_stats; use stats_writer::export_race_stats;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@ -92,18 +94,23 @@ struct MyApp {
state: SharedAppState, state: SharedAppState,
config_load_err: Option<String>, config_load_err: Option<String>,
hash_to_learn: String, hash_to_learn: String,
value_to_learn: String, value_to_learn: String,
} }
impl MyApp { impl MyApp {
pub fn new(state: SharedAppState) -> Self { pub fn new(state: SharedAppState) -> Self {
Self { state, config_load_err: None, hash_to_learn: "".to_owned(), value_to_learn: "".to_owned() } Self {
state,
config_load_err: None,
hash_to_learn: "".to_owned(),
value_to_learn: "".to_owned(),
}
} }
} }
fn show_race_state(ui: &mut Ui, race_name: &str, race: &RaceState) { fn show_race_state(ui: &mut Ui, race_name: &str, race: &mut RaceState) {
egui::Grid::new(format!("race:{}", race_name)).show(ui, |ui| { egui::Grid::new(format!("race:{}", race_name)).show(ui, |ui| {
ui.label("Lap"); ui.label("Lap");
ui.label("Time"); ui.label("Time");
@ -113,9 +120,9 @@ fn show_race_state(ui: &mut Ui, race_name: &str, race: &RaceState) {
ui.label("Gas"); ui.label("Gas");
ui.label("Tyres"); ui.label("Tyres");
ui.end_row(); ui.end_row();
for (i, lap) in race.laps.iter().enumerate() { let mut prev_lap: Option<&ParsedFrame> = None;
for (i, lap) in race.laps.iter_mut().enumerate() {
if let Some(lap_time) = lap.lap_time { if let Some(lap_time) = lap.lap_time {
let prev_lap = race.laps.get(i - 1);
ui.label(format!("#{}", lap.lap.unwrap_or(i + 1))); ui.label(format!("#{}", lap.lap.unwrap_or(i + 1)));
ui.label(format_time(lap_time)); ui.label(format_time(lap_time));
@ -141,14 +148,24 @@ fn show_race_state(ui: &mut Ui, race_name: &str, race: &RaceState) {
&lap.tyres, &lap.tyres,
); );
if lap.striked {
ui.colored_label(Color32::RED, "Striked from the record");
} else {
if ui.button("Strike").clicked() {
lap.striked = true;
}
}
ui.end_row(); ui.end_row();
} }
prev_lap = Some(lap);
} }
}); });
} }
impl eframe::App for MyApp { impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.set_visuals(Visuals::dark());
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
egui::SidePanel::left("frame").show(ctx, |ui| { egui::SidePanel::left("frame").show(ctx, |ui| {
if let Some(frame) = &state.last_frame { if let Some(frame) = &state.last_frame {
@ -175,95 +192,107 @@ impl eframe::App for MyApp {
)); ));
} }
ui.separator(); if state.debug_frames {
ui.heading("Raw OCR results"); ui.separator();
let mut raw_data_sorted: Vec<_> = state.raw_data.iter().collect(); ui.heading("Raw OCR results");
raw_data_sorted.sort(); let mut raw_data_sorted: Vec<_> = state.raw_data.iter().collect();
for (key, val) in raw_data_sorted { raw_data_sorted.sort();
ui.label(format!("{}: {:?}", key, val)); for (key, val) in raw_data_sorted {
ui.label(format!("{}: {:?}", key, val));
}
} }
ui.separator(); ui.separator();
ui.checkbox(&mut state.debug_frames, "Debug OCR regions"); ui.checkbox(&mut state.debug_frames, "Debug OCR regions");
}); });
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
if let Some(race) = &state.current_race { egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading("Current Race"); if let Some(race) = &mut state.current_race {
show_race_state(ui, "current", race); ui.heading("Current Race");
} show_race_state(ui, "current", race);
let len = state.past_races.len();
for (i, race) in state.past_races.iter_mut().enumerate() {
ui.separator();
ui.heading(format!("Race #{}", len - i));
show_race_state(ui, &format!("{}", i), race);
if let Some(img) = &race.screencap {
img.show_max_size(ui, Vec2::new(600.0, 500.0));
} }
if !race.exported { let len = state.past_races.len();
ui.label("Car:"); for (i, race) in state.past_races.iter_mut().enumerate() {
ui.text_edit_singleline(&mut race.car); ui.separator();
ui.label("Track:"); ui.heading(format!("Race #{}", len - i));
ui.text_edit_singleline(&mut race.track); show_race_state(ui, &format!("{}", i), race);
if ui.button("Export").clicked() { if let Some(img) = &race.screencap {
match export_race_stats(race) { img.show_max_size(ui, Vec2::new(600.0, 500.0));
Ok(_) => { }
race.exported = true; if !race.exported {
} ui.label("Car:");
Err(e) => { ui.text_edit_singleline(&mut race.car);
race.export_error = Some(format!("failed to export race: {:?}", e)); ui.label("Track:");
ui.text_edit_singleline(&mut race.track);
ui.label("Comments:");
ui.text_edit_singleline(&mut race.comments);
if ui.button("Export").clicked() {
match export_race_stats(race) {
Ok(_) => {
race.exported = true;
}
Err(e) => {
race.export_error =
Some(format!("failed to export race: {:?}", e));
}
} }
} }
if let Some(e) = &race.export_error {
ui.colored_label(Color32::RED, e);
}
} else {
ui.label("Exported ✅");
} }
if let Some(e) = &race.export_error {
ui.colored_label(Color32::RED, e);
}
} else {
ui.label("Exported ✅");
} }
} });
}); });
if state.debug_frames { if state.debug_frames {
egui::SidePanel::right("screenshots").show(ctx, |ui| { egui::SidePanel::right("screenshots").show(ctx, |ui| {
let mut screenshots_sorted: Vec<_> = state.saved_frames.iter().collect(); egui::ScrollArea::vertical().show(ui, |ui| {
screenshots_sorted.sort_by_key(|(name, _)| name.clone()); let mut screenshots_sorted: Vec<_> = state.saved_frames.iter().collect();
for (name, image) in screenshots_sorted { screenshots_sorted.sort_by_key(|(name, _)| name.clone());
ui.label(name); for (name, image) in screenshots_sorted {
if ui.button(&image.img_hash).on_hover_text("Copy").clicked() { ui.label(name);
ui.output().copied_text = image.img_hash.clone(); if ui.button(&image.img_hash).on_hover_text("Copy").clicked() {
} ui.output().copied_text = image.img_hash.clone();
image.image.show_max_size(ui, ui.available_size());
}
if ui.button("Reload config").clicked() {
match Config::load() {
Ok(c) => {
state.config = Arc::new(c);
self.config_load_err = None;
} }
Err(e) => { image.image.show_max_size(ui, ui.available_size());
self.config_load_err = Some(format!("failed to load config: {:?}", e)); }
if ui.button("Reload config").clicked() {
match Config::load() {
Ok(c) => {
state.config = Arc::new(c);
self.config_load_err = None;
}
Err(e) => {
self.config_load_err =
Some(format!("failed to load config: {:?}", e));
}
} }
} }
} if let Some(e) = &self.config_load_err {
if let Some(e) = &self.config_load_err { ui.colored_label(Color32::RED, e);
ui.colored_label(Color32::RED, e); }
}
ui.separator(); ui.separator();
ui.label("Hash"); ui.label("Hash");
ui.text_edit_singleline(&mut self.hash_to_learn); ui.text_edit_singleline(&mut self.hash_to_learn);
ui.label("Value"); ui.label("Value");
ui.text_edit_singleline(&mut self.value_to_learn); ui.text_edit_singleline(&mut self.value_to_learn);
if ui.button("Learn").clicked() { if ui.button("Learn").clicked() {
let mut learned_config = (*state.learned).clone(); let mut learned_config = (*state.learned).clone();
learned_config.learned_images.insert(self.hash_to_learn.clone(), self.value_to_learn.clone()); learned_config
learned_config.save().unwrap(); .learned_images
state.learned = Arc::new(learned_config); .insert(self.hash_to_learn.clone(), self.value_to_learn.clone());
learned_config.save().unwrap();
state.learned = Arc::new(learned_config);
self.hash_to_learn = "".to_owned(); self.hash_to_learn = "".to_owned();
self.value_to_learn = "".to_owned(); self.value_to_learn = "".to_owned();
} }
});
}); });
} }

View File

@ -76,8 +76,9 @@ pub async fn ocr_all_regions(
let locked = ocr_cache.read().unwrap(); let locked = ocr_cache.read().unwrap();
locked.get(&hash).cloned() locked.get(&hash).cloned()
}; };
if let Some(cached) = cached { let use_cache = region.use_ocr_cache.unwrap_or(true) && config.use_ocr_cache.unwrap_or(true);
cached if cached.is_some() && use_cache {
cached.unwrap()
} else { } else {
match run_ocr(&filtered_image, &config.ocr_server_endpoint).await { match run_ocr(&filtered_image, &config.ocr_server_endpoint).await {
Ok(v) => { Ok(v) => {

View File

@ -17,6 +17,8 @@ pub struct ParsedFrame {
pub best_time: Option<Duration>, pub best_time: Option<Duration>,
pub lap_time: Option<Duration>, pub lap_time: Option<Duration>,
pub striked: bool,
} }
fn parse_duration(time: &str) -> Option<Duration> { fn parse_duration(time: &str) -> Option<Duration> {
@ -54,6 +56,7 @@ impl ParsedFrame {
tyres: parse_to_0_100(raw.get("tyres")), tyres: parse_to_0_100(raw.get("tyres")),
best_time: parse_to_duration(raw.get("best")), best_time: parse_to_duration(raw.get("best")),
lap_time: parse_to_duration(raw.get("lap_time")), lap_time: parse_to_duration(raw.get("lap_time")),
striked: false,
} }
} }
} }
@ -71,6 +74,7 @@ pub struct RaceState {
pub car: String, pub car: String,
pub track: String, pub track: String,
pub comments: String,
} }
impl RaceState { impl RaceState {

View File

@ -1,52 +1,57 @@
use std::{ use std::{
io::BufWriter, io::BufWriter,
time::{Duration}, time::{Duration},
}; };
use crate::state::RaceState; use crate::state::RaceState;
use anyhow::Result; use anyhow::Result;
pub fn export_race_stats(race_stats: &mut RaceState) -> Result<()> { pub fn export_race_stats(race_stats: &mut RaceState) -> Result<()> {
let race_name = race_stats.name(); let race_name = race_stats.name();
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open("race_stats.csv")?; .open("race_stats.csv")?;
let writer = BufWriter::new(file); let writer = BufWriter::new(file);
let mut csv_writer = csv::Writer::from_writer(writer); let mut csv_writer = csv::Writer::from_writer(writer);
for lap in &race_stats.laps { for lap in &race_stats.laps {
csv_writer.write_record(vec![ if lap.striked {
race_name.clone(), continue;
race_stats.track.clone(), }
race_stats.car.clone(),
lap.lap csv_writer.write_record(vec![
.map(|x| x.to_string()) race_name.clone(),
.unwrap_or_else(|| "?".to_owned()), race_stats.track.clone(),
format!( race_stats.car.clone(),
"{:.3}", lap.lap
lap.lap_time.unwrap_or(Duration::from_secs(0)).as_secs_f64() .map(|x| x.to_string())
), .unwrap_or_else(|| "?".to_owned()),
format!( format!(
"{:.3}", "{:.3}",
lap.best_time lap.lap_time.unwrap_or(Duration::from_secs(0)).as_secs_f64()
.unwrap_or(Duration::from_secs(0)) ),
.as_secs_f64() format!(
), "{:.3}",
lap.health lap.best_time
.map(|x| x.to_string()) .unwrap_or(Duration::from_secs(0))
.unwrap_or_else(|| "".to_owned()), .as_secs_f64()
lap.gas ),
.map(|x| x.to_string()) lap.health
.unwrap_or_else(|| "".to_owned()), .map(|x| x.to_string())
lap.tyres .unwrap_or_else(|| "".to_owned()),
.map(|x| x.to_string()) lap.gas
.unwrap_or_else(|| "".to_owned()), .map(|x| x.to_string())
])?; .unwrap_or_else(|| "".to_owned()),
} lap.tyres
.map(|x| x.to_string())
Ok(()) .unwrap_or_else(|| "".to_owned()),
} race_stats.comments.clone(),
])?;
}
Ok(())
}

5
suggestions.md Normal file
View File

@ -0,0 +1,5 @@
- Recognize
- Penalties
- Pit stops
- ComboBox for car/track
- GLobal best time not current best

19
tracklist.txt Normal file
View File

@ -0,0 +1,19 @@
Whistle Valley
Sugar Hill
Maple Ridge
Rennvoort
Magdalena GP
Magdalena Club
Copperwood GP
Copperwood Club
Interstate
Buffalo Hill
Lost Lagoons
Bullseye Speedway
Speedopolis
Faenza
Siena
Thunder Point
Tilksport GP
Tilksport Club
Tilksport Rallycross