diff --git a/phira-monitor/src/launch.rs b/phira-monitor/src/launch.rs index d2839690..4614f11d 100644 --- a/phira-monitor/src/launch.rs +++ b/phira-monitor/src/launch.rs @@ -27,7 +27,7 @@ pub fn launch_task(id: i32, players: Vec) -> Result { for _ in 0..players.len() { charts.push(GameScene::load_chart(fs.as_mut(), &info).await?.0); } - let loading_scene = LoadingScene::new(GameMode::View, info, config, fs, None, None, None, None, None).await?; + let loading_scene = LoadingScene::new(GameMode::View, info, config, fs, None, None, None, None, None, None, None).await?; let game_scene = loading_scene.load_task.unwrap().await?; let views = players diff --git a/phira/locales/de-DE/replay_list.ftl b/phira/locales/de-DE/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/de-DE/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/en-US/replay_list.ftl b/phira/locales/en-US/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/en-US/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/en-US/settings.ftl b/phira/locales/en-US/settings.ftl index fd46d48b..99fe0a17 100644 --- a/phira/locales/en-US/settings.ftl +++ b/phira/locales/en-US/settings.ftl @@ -56,6 +56,9 @@ item-opt = Chart Optimization item-opt-sub = Significantly increase peformance while playing. (If unintended behavior arises, disable this.) item-use-keyboard = Use Keyboard item-use-keyboard-sub = Enable keyboard input for gameplay. Scores cannot be uploaded when enabled. + +item-auto-record = Auto-record replays +item-auto-record-sub = Replays are saved automatically. View them from Home → Replays. item-prefer-reduced-motion = Prefer Reduced Motion item-prefer-reduced-motion-sub = Reduce animations and visual effects item-speed = Speed diff --git a/phira/locales/fr-FR/replay_list.ftl b/phira/locales/fr-FR/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/fr-FR/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/id-ID/replay_list.ftl b/phira/locales/id-ID/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/id-ID/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/ja-JP/replay_list.ftl b/phira/locales/ja-JP/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/ja-JP/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/ko-KR/replay_list.ftl b/phira/locales/ko-KR/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/ko-KR/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/mn-MN/replay_list.ftl b/phira/locales/mn-MN/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/mn-MN/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/pl-PL/replay_list.ftl b/phira/locales/pl-PL/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/pl-PL/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/pt-BR/replay_list.ftl b/phira/locales/pt-BR/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/pt-BR/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/ru-RU/replay_list.ftl b/phira/locales/ru-RU/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/ru-RU/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/th-TH/replay_list.ftl b/phira/locales/th-TH/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/th-TH/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/tr-TR/replay_list.ftl b/phira/locales/tr-TR/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/tr-TR/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/vi-VN/replay_list.ftl b/phira/locales/vi-VN/replay_list.ftl new file mode 100644 index 00000000..a5d6e9b9 --- /dev/null +++ b/phira/locales/vi-VN/replay_list.ftl @@ -0,0 +1,8 @@ +label = Replay List +chart-not-found = Could not find matching chart: { $chart } +load-failed = Failed to load replay +favorites-only = Favorites only +chart-empty = No replays for this chart +empty = No replays yet +empty-hint = Start playing after turning on "Auto-record replay"; replays will appear here +replay-count = { $count } replays diff --git a/phira/locales/zh-CN/replay_list.ftl b/phira/locales/zh-CN/replay_list.ftl new file mode 100644 index 00000000..38373077 --- /dev/null +++ b/phira/locales/zh-CN/replay_list.ftl @@ -0,0 +1,8 @@ +label = 回放列表 +chart-not-found = 找不到对应的谱面:{ $chart } +load-failed = 加载回放失败 +favorites-only = 仅显示收藏 +chart-empty = 此谱面暂无回放 +empty = 还没有任何回放 +empty-hint = 打开 "自动录制回放" 后开始游玩,回放会出现在这里 +replay-count = { $count } 个回放 diff --git a/phira/locales/zh-CN/settings.ftl b/phira/locales/zh-CN/settings.ftl index 6504f3f3..4d686364 100644 --- a/phira/locales/zh-CN/settings.ftl +++ b/phira/locales/zh-CN/settings.ftl @@ -56,6 +56,9 @@ item-opt = 激进优化 item-opt-sub = 采用激进的优化策略,提升性能但可能导致部分谱面显示出错 item-use-keyboard = 使用键盘游玩 item-use-keyboard-sub = 开启后可以使用键盘进行游戏,但成绩无法上传 + +item-auto-record = 自动录制回放 +item-auto-record-sub = 自动保存回放,可在首页 → 回放中查看 item-prefer-reduced-motion = 减少动画效果 item-prefer-reduced-motion-sub = 减少动画和视觉特效 item-speed = 速度 diff --git a/phira/locales/zh-TW/replay_list.ftl b/phira/locales/zh-TW/replay_list.ftl new file mode 100644 index 00000000..8e91c63c --- /dev/null +++ b/phira/locales/zh-TW/replay_list.ftl @@ -0,0 +1,8 @@ +label = 回放列表 +chart-not-found = 找不到對應的譜面:{ $chart } +load-failed = 載入回放失敗 +favorites-only = 僅顯示收藏 +chart-empty = 此譜面暫無回放 +empty = 還沒有任何回放 +empty-hint = 開啟 "自動錄製回放" 後開始遊玩,回放會顯示在這裡 +replay-count = { $count } 個回放 diff --git a/phira/src/lib.rs b/phira/src/lib.rs index 81c5b808..03e63f66 100644 --- a/phira/src/lib.rs +++ b/phira/src/lib.rs @@ -160,6 +160,10 @@ mod dir { pub fn respacks() -> Result { ensure("data/respack") } + + pub fn replays() -> Result { + ensure("data/replays") + } } async fn the_main() -> Result<()> { diff --git a/phira/src/page.rs b/phira/src/page.rs index 973676b0..4cd43b91 100644 --- a/phira/src/page.rs +++ b/phira/src/page.rs @@ -24,6 +24,9 @@ pub use respack::{ResPackItem, ResPackPage}; mod settings; pub use settings::SettingsPage; + +mod replay_list; +pub use replay_list::ReplayListPage; use tokio::sync::Notify; use crate::{ diff --git a/phira/src/page/home.rs b/phira/src/page/home.rs index 023f5498..f587acec 100644 --- a/phira/src/page/home.rs +++ b/phira/src/page/home.rs @@ -57,6 +57,7 @@ pub struct HomePage { btn_play: DRectButton, btn_event: DRectButton, btn_respack: DRectButton, + btn_replay: DRectButton, btn_msg: DRectButton, btn_settings: DRectButton, btn_user: DRectButton, @@ -132,6 +133,7 @@ impl HomePage { btn_play: DRectButton::new().with_delta(-0.01).no_sound(), btn_event: DRectButton::new().with_elevation(0.002).no_sound(), btn_respack: DRectButton::new().with_elevation(0.002).no_sound(), + btn_replay: DRectButton::new().with_radius(0.008).with_delta(-0.003).with_elevation(0.002), btn_msg: DRectButton::new().with_radius(0.008).with_delta(-0.003).with_elevation(0.002), btn_settings: DRectButton::new().with_radius(0.008).with_delta(-0.003).with_elevation(0.002), btn_user: DRectButton::new().with_delta(-0.003), @@ -343,21 +345,28 @@ impl HomePage { let lf = r.right() + 0.02; s.render_fader(ui, |ui| { - let r = Rect::new(lf, top, 0.11, 0.11); + let r = Rect::new(lf, top, 0.11, 0.07); self.btn_msg.render_shadow(ui, r, t, |ui, path| { ui.fill_path(&path, semi_black(0.4)); - let r = r.feather(-0.01); + let r = r.feather(-0.005); ui.fill_rect(r, (*self.icons.msg, r, ScaleType::Fit)); if self.has_new { - let pad = 0.007; - ui.fill_circle(r.right() - pad, r.y + pad, 0.01, RED); + let pad = 0.006; + ui.fill_circle(r.right() - pad, r.y + pad, 0.008, RED); } }); - let r = Rect::new(lf, top + 0.12, 0.11, 0.11); + let r = Rect::new(lf, top + 0.08, 0.11, 0.07); + self.btn_replay.render_shadow(ui, r, t, |ui, path| { + ui.fill_path(&path, semi_black(0.4)); + let r = r.feather(-0.005); + ui.fill_rect(r, (*self.icons.play, r, ScaleType::Fit)); + }); + + let r = Rect::new(lf, top + 0.16, 0.11, 0.07); self.btn_settings.render_shadow(ui, r, t, |ui, path| { ui.fill_path(&path, semi_black(0.4)); - let r = r.feather(0.004); + let r = r.feather(-0.005); ui.fill_rect(r, (*self.icons.settings, r, ScaleType::Fit)); }); }); @@ -413,6 +422,11 @@ impl Page for HomePage { self.next_page = Some(NextPage::Overlay(Box::new(MessagePage::new(Arc::clone(&self.icons), s.icons.clone())))); return Ok(true); } + if self.btn_replay.touch(touch, t) { + button_hit_large(); + self.next_page = Some(NextPage::Overlay(Box::new(super::ReplayListPage::new(Arc::clone(&self.icons), s.icons.clone())?))); + return Ok(true); + } if self.btn_settings.touch(touch, t) { self.next_page = Some(NextPage::Overlay(Box::new(SettingsPage::new(self.icons.icon.clone(), self.icons.lang.clone())))); return Ok(true); diff --git a/phira/src/page/replay_list.rs b/phira/src/page/replay_list.rs new file mode 100644 index 00000000..fe29f4d6 --- /dev/null +++ b/phira/src/page/replay_list.rs @@ -0,0 +1,623 @@ +//! Replay browser. Lists all `data/replays/*.json` files grouped by chart +//! and lets the user replay them. + +prpr_l10n::tl_file!("replay_list"); + +use super::{NextPage, Page, SharedState}; +use crate::{dir, get_data, icons::Icons, scene::fs_from_path}; +use anyhow::Result; +use chrono::{Local, TimeZone}; +use inputbox::InputBox; +use macroquad::prelude::*; +use prpr::{ + ext::{poll_future, semi_black, semi_white, LocalTask, SafeTexture, ScaleType}, + fs, + judge::icon_index, + replay::ReplayData, + scene::{request_input, return_input, show_error, take_input, BasicPlayer, GameMode, LoadingScene, NextScene}, + ui::{button_hit, DRectButton, Scroll, Ui}, +}; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +const ITEM_HEIGHT: f32 = 0.18; + +pub struct ReplayListPage { + icons: Arc, + rank_icons: [SafeTexture; 8], + + /// Replays grouped by chart name (root view) or list of single-replay + /// entries (folder view). + folders: Vec, + entries: Vec, + /// Folder we're currently inside (None = root). Stores `(group_key, + /// display_name)` — the key is used to match replay files, the display + /// name is what the page label / title bar shows. + current_folder: Option<(String, String)>, + + folder_btns: Vec, + play_btns: Vec, + favorite_btns: Vec, + rename_btns: Vec, + delete_btns: Vec, + favorite_filter_btn: DRectButton, + favorites_only: bool, + renaming_file: Option, + + scroll: Scroll, + + /// Async task building a `LoadingScene` for a selected replay. + load_task: LocalTask>, + /// Scene to push to the main scene loop. + pending_scene: Option, +} + +struct FolderEntry { + /// Group key: phira chart `local_path` when known, else `name:`. + /// This is what's persisted as `current_folder` while we're inside a + /// chart's replay list. + key: String, + chart_name: String, + chart_id: Option, + count: usize, +} + +struct ReplayEntry { + file_name: String, + replay_name: String, + timestamp: i64, + score: i32, + accuracy: f32, + full_combo: bool, + speed: f32, + favorite: bool, +} + +impl ReplayListPage { + pub fn new(icons: Arc, rank_icons: [SafeTexture; 8]) -> Result { + let mut this = Self { + icons, + rank_icons, + folders: Vec::new(), + entries: Vec::new(), + current_folder: None, + folder_btns: Vec::new(), + play_btns: Vec::new(), + favorite_btns: Vec::new(), + rename_btns: Vec::new(), + delete_btns: Vec::new(), + favorite_filter_btn: DRectButton::new(), + favorites_only: false, + renaming_file: None, + scroll: Scroll::new(), + load_task: None, + pending_scene: None, + }; + this.reload(); + Ok(this) + } + + fn reload(&mut self) { + if let Some((key, _)) = self.current_folder.clone() { + self.entries = read_chart_replays(&key, self.favorites_only); + self.entries.sort_by_key(|b| std::cmp::Reverse(b.timestamp)); + self.play_btns = (0..self.entries.len()).map(|_| DRectButton::new()).collect(); + self.favorite_btns = (0..self.entries.len()).map(|_| DRectButton::new()).collect(); + self.rename_btns = (0..self.entries.len()).map(|_| DRectButton::new()).collect(); + self.delete_btns = (0..self.entries.len()).map(|_| DRectButton::new()).collect(); + } else { + self.folders = read_all_folders(self.favorites_only); + self.folders.sort_by(|a, b| a.chart_name.cmp(&b.chart_name)); + self.folder_btns = (0..self.folders.len()).map(|_| DRectButton::new()).collect(); + } + } + + fn replay_path(file_name: &str) -> Result { + anyhow::ensure!( + !file_name.is_empty() && std::path::Path::new(file_name).file_name().and_then(|it| it.to_str()) == Some(file_name), + "invalid replay file name" + ); + Ok(std::path::PathBuf::from(dir::replays()?).join(file_name)) + } + + fn update_replay(file_name: &str, update: impl FnOnce(&mut ReplayData)) -> Result<()> { + let path = Self::replay_path(file_name)?; + let content = std::fs::read_to_string(&path)?; + let mut replay: ReplayData = serde_json::from_str(&content)?; + update(&mut replay); + std::fs::write(path, serde_json::to_string_pretty(&replay)?)?; + Ok(()) + } + + fn toggle_favorite(&mut self, file_name: &str) { + if let Err(e) = Self::update_replay(file_name, |replay| replay.favorite = !replay.favorite) { + show_error(e); + } else { + self.reload(); + } + } + + fn rename_replay(&mut self, file_name: &str, name: String) { + if let Err(e) = Self::update_replay(file_name, |replay| replay.replay_name = name.trim().to_string()) { + show_error(e); + } else { + self.reload(); + } + } + + fn request_rename(&mut self, index: usize) { + let entry = &self.entries[index]; + let text = if entry.replay_name.is_empty() { + fmt_timestamp(entry.timestamp) + } else { + entry.replay_name.clone() + }; + self.renaming_file = Some(entry.file_name.clone()); + request_input("replay_rename", InputBox::new().default_text(&text)); + } + + fn launch_replay_async(&mut self, file_name: String) -> Result<()> { + let path = Self::replay_path(&file_name)?; + let content = std::fs::read_to_string(&path)?; + let replay: ReplayData = serde_json::from_str(&content)?; + + // Match the recorded chart back to a local entry, preferring the + // host's exact local_path when present. Two locally imported charts + // with the same display name no longer collide because `local_path` + // is unique per chart directory. + let local_path = get_data() + .charts + .iter() + .find(|c| !replay.chart_local_path.is_empty() && c.local_path == replay.chart_local_path) + .or_else(|| get_data().charts.iter().find(|c| replay.chart_id.is_some_and(|id| c.info.id == Some(id)))) + .or_else(|| get_data().charts.iter().find(|c| c.info.name == replay.chart_name)) + .map(|c| c.local_path.clone()) + .ok_or_else(|| anyhow::anyhow!(tl!("chart-not-found", "chart" => replay.chart_name.as_str())))?; + + let replay_clone = replay; + + self.load_task = Some(Box::pin(async move { + let mut fs_obj = fs_from_path(&local_path)?; + let mut info = fs::load_info(fs_obj.as_mut()).await?; + if info.id.is_none() { + info.id = replay_clone.chart_id; + } + if let Some(chart_offset) = replay_clone.chart_offset { + info.offset = chart_offset; + } + + let mut config = get_data().config.clone(); + if let Some(me) = get_data().me.as_ref() { + config.player_name = me.name.clone(); + } + config.res_pack_path = { + let id = get_data().respack_id; + if id == 0 { + None + } else { + Some(format!("{}/{}", dir::respacks()?, get_data().respacks[id - 1])) + } + }; + config.offline_mode = true; + config.speed = replay_clone.speed.max(0.5); + config.mods = Default::default(); + // Replay disables auto_record so we don't record-of-replay. + config.auto_record = false; + + let preload = LoadingScene::load(fs_obj.as_mut(), &info.illustration).await?; + let player = get_data().me.as_ref().map(|it| BasicPlayer { + avatar: crate::client::UserManager::get_avatar(it.id).flatten(), + id: it.id, + rks: it.rks, + historic_best: 0, + }); + + let scene = LoadingScene::new( + GameMode::Normal, + info, + config, + fs_obj, + player, + None, + None, + None, + None, + Some(prpr::replay::ReplayHandoff::Playback(replay_clone)), + Some(preload), + ) + .await?; + Ok(NextScene::Overlay(Box::new(scene))) + })); + Ok(()) + } +} + +/// Build a stable group key for a `ReplayData`. We prefer the host's +/// `local_path` (unique per chart directory) so two locally imported +/// charts with the same display name don't end up in the same folder. +/// Old replays without `chart_local_path` fall back to a name-based key. +fn replay_group_key(r: &ReplayData) -> String { + if !r.chart_local_path.is_empty() { + format!("path:{}", r.chart_local_path) + } else if let Some(id) = r.chart_id { + format!("id:{id}") + } else { + format!("name:{}", r.chart_name) + } +} + +fn read_all_folders(favorites_only: bool) -> Vec { + let dir = match dir::replays() { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let path = std::path::Path::new(&dir); + if !path.exists() { + return Vec::new(); + } + let mut groups: HashMap = HashMap::new(); + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + if let Ok(replay) = serde_json::from_str::(&content) { + if favorites_only && !replay.favorite { + continue; + } + let key = replay_group_key(&replay); + let g = groups.entry(key.clone()).or_insert_with(|| FolderEntry { + key, + chart_name: replay.chart_name.clone(), + chart_id: replay.chart_id, + count: 0, + }); + g.count += 1; + if g.chart_id.is_none() { + g.chart_id = replay.chart_id; + } + } + } + } + } + groups.into_values().collect() +} + +fn read_chart_replays(folder_key: &str, favorites_only: bool) -> Vec { + let dir = match dir::replays() { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let path = std::path::Path::new(&dir); + if !path.exists() { + return Vec::new(); + } + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + if let Ok(replay) = serde_json::from_str::(&content) { + if replay_group_key(&replay) == folder_key { + if favorites_only && !replay.favorite { + continue; + } + out.push(ReplayEntry { + file_name: entry.file_name().to_string_lossy().to_string(), + replay_name: replay.replay_name, + timestamp: replay.timestamp, + score: replay.score, + accuracy: replay.accuracy, + full_combo: replay.full_combo, + speed: replay.speed, + favorite: replay.favorite, + }); + } + } + } + } + } + out +} + +fn fmt_timestamp(ts: i64) -> String { + if let Some(dt) = Local.timestamp_opt(ts, 0).single() { + dt.format("%Y-%m-%d %H:%M:%S").to_string() + } else { + format!("ts={}", ts) + } +} + +impl Page for ReplayListPage { + fn label(&self) -> Cow<'static, str> { + if let Some((_, name)) = &self.current_folder { + name.clone().into() + } else { + tl!("label") + } + } + + fn enter(&mut self, _s: &mut SharedState) -> Result<()> { + self.reload(); + Ok(()) + } + + fn touch(&mut self, touch: &Touch, s: &mut SharedState) -> Result { + let t = s.t; + + if self.favorite_filter_btn.touch(touch, t) { + button_hit(); + self.favorites_only = !self.favorites_only; + self.reload(); + return Ok(true); + } + if self.scroll.touch(touch, t) { + return Ok(true); + } + + if self.current_folder.is_some() { + let entries_len = self.entries.len(); + for i in 0..entries_len { + if self.favorite_btns[i].touch(touch, t) { + button_hit(); + let file_name = self.entries[i].file_name.clone(); + self.toggle_favorite(&file_name); + return Ok(true); + } + if self.rename_btns[i].touch(touch, t) { + button_hit(); + self.request_rename(i); + return Ok(true); + } + if self.delete_btns[i].touch(touch, t) { + button_hit(); + let file_name = self.entries[i].file_name.clone(); + if let Ok(path) = ReplayListPage::replay_path(&file_name) { + let _ = std::fs::remove_file(path); + } + self.reload(); + return Ok(true); + } + if self.play_btns[i].touch(touch, t) { + button_hit(); + let file_name = self.entries[i].file_name.clone(); + if let Err(e) = self.launch_replay_async(file_name) { + show_error(e); + } + return Ok(true); + } + } + } else { + for i in 0..self.folders.len() { + if self.folder_btns[i].touch(touch, t) { + button_hit(); + let folder = &self.folders[i]; + self.current_folder = Some((folder.key.clone(), folder.chart_name.clone())); + self.reload(); + return Ok(true); + } + } + } + + Ok(false) + } + + fn update(&mut self, s: &mut SharedState) -> Result<()> { + if let Some((id, text)) = take_input() { + if id == "replay_rename" { + if let Some(file_name) = self.renaming_file.take() { + self.rename_replay(&file_name, text); + } else { + return_input(id, text); + } + } else { + return_input(id, text); + } + } + + self.scroll.update(s.t); + + if let Some(task) = &mut self.load_task { + if let Some(res) = poll_future(task.as_mut()) { + match res { + Ok(scene) => { + self.pending_scene = Some(scene); + } + Err(e) => { + show_error(e.context(tl!("load-failed"))); + } + } + self.load_task = None; + } + } + Ok(()) + } + + fn render(&mut self, ui: &mut Ui, s: &mut SharedState) -> Result<()> { + let t = s.t; + + s.render_fader(ui, |ui| { + let top = ui.top; + let chosen = self.favorites_only; + let rr = Rect::new(0.76, -top + 0.04, 0.20, 0.07); + self.favorite_filter_btn.render_shadow(ui, rr, t, |ui, path| { + ui.fill_path(&path, if chosen { WHITE } else { semi_black(0.5) }); + ui.text(tl!("favorites-only")) + .pos(rr.center().x, rr.center().y) + .anchor(0.5, 0.5) + .no_baseline() + .size(0.35) + .max_width(rr.w - 0.02) + .color(if chosen { Color::new(0.3, 0.3, 0.3, 1.) } else { WHITE }) + .draw(); + }); + }); + + let r = ui.content_rect(); + s.render_fader(ui, |ui| { + ui.scope(|ui| { + ui.dx(r.x); + ui.dy(r.y); + self.scroll.size((r.w, r.h)); + self.scroll.render(ui, |ui| { + let pad = 0.02; + // 2-column grid layout. + let cols = 2usize; + let cell_w = (r.w - pad * (cols as f32 + 1.)) / cols as f32; + + if self.current_folder.is_some() { + let n = self.entries.len(); + let rows = n.div_ceil(cols); + let total_h = pad + rows as f32 * (ITEM_HEIGHT + pad); + if n == 0 { + ui.text(tl!("chart-empty")) + .pos(r.w / 2., 0.2) + .anchor(0.5, 0.) + .size(0.5) + .color(semi_white(0.7)) + .draw(); + } + for i in 0..n { + let col = i % cols; + let row = i / cols; + let x = pad + col as f32 * (cell_w + pad); + let y = pad + row as f32 * (ITEM_HEIGHT + pad); + let item_r = Rect::new(x, y, cell_w, ITEM_HEIGHT); + let entry = &self.entries[i]; + self.play_btns[i].render_shadow(ui, item_r, t, |ui, path| { + ui.fill_path(&path, semi_black(0.55)); + }); + + let pad_in = 0.018; + let mut tx = item_r.x + pad_in; + let icon = self.rank_icons[icon_index(entry.score as u32, entry.full_combo)].clone(); + let icon_size = ITEM_HEIGHT - 0.04; + let ic_r = Rect::new(tx, item_r.y + 0.02, icon_size, icon_size); + ui.fill_rect(ic_r, (*icon, ic_r, ScaleType::Fit)); + tx += icon_size + pad_in; + + let title = if entry.replay_name.is_empty() { + format!("{:07}", entry.score) + } else { + entry.replay_name.clone() + }; + ui.text(title) + .pos(tx, item_r.y + 0.018) + .size(0.55) + .max_width(cell_w - icon_size - 0.18) + .draw(); + let acc_text = if entry.replay_name.is_empty() { + format!( + "{:.2}% {}{}", + entry.accuracy * 100., + if entry.full_combo { "FC " } else { "" }, + fmt_timestamp(entry.timestamp) + ) + } else { + format!( + "{:07} {:.2}% {}{}", + entry.score, + entry.accuracy * 100., + if entry.full_combo { "FC " } else { "" }, + fmt_timestamp(entry.timestamp) + ) + }; + ui.text(acc_text) + .pos(tx, item_r.y + 0.07) + .size(0.3) + .max_width(cell_w - icon_size - 0.18) + .color(semi_white(0.8)) + .draw(); + if (entry.speed - 1.0).abs() > 1e-3 { + ui.text(format!("speed {:.2}x", entry.speed)) + .pos(tx, item_r.y + 0.105) + .size(0.3) + .color(semi_white(0.6)) + .draw(); + } + + let icon_size = 0.045; + let icon_x = item_r.right() - pad_in - icon_size; + let fav_r = Rect::new(icon_x, item_r.y + 0.018, icon_size, icon_size); + let fav_icon = if entry.favorite { &self.icons.star } else { &self.icons.star_outline }; + ui.fill_rect(fav_r, (**fav_icon, fav_r, ScaleType::Fit, if entry.favorite { YELLOW } else { WHITE })); + self.favorite_btns[i].inner.set(ui, fav_r); + + let edit_r = Rect::new(icon_x, item_r.y + 0.068, icon_size, icon_size); + ui.fill_rect(edit_r, (*self.icons.edit, edit_r, ScaleType::Fit)); + self.rename_btns[i].inner.set(ui, edit_r); + + let del_r = Rect::new(icon_x, item_r.y + 0.118, icon_size, icon_size); + ui.fill_rect(del_r, (*self.icons.delete, del_r, ScaleType::Fit)); + self.delete_btns[i].inner.set(ui, del_r); + } + (r.w, total_h) + } else { + let n = self.folders.len(); + let rows = n.div_ceil(cols); + let total_h = pad + rows as f32 * (ITEM_HEIGHT + pad); + if n == 0 { + ui.text(tl!("empty")) + .pos(r.w / 2., 0.15) + .anchor(0.5, 0.) + .size(0.6) + .color(semi_white(0.7)) + .draw(); + ui.text(tl!("empty-hint")) + .pos(r.w / 2., 0.27) + .anchor(0.5, 0.) + .size(0.42) + .color(semi_white(0.5)) + .draw(); + } + for i in 0..n { + let col = i % cols; + let row = i / cols; + let x = pad + col as f32 * (cell_w + pad); + let y = pad + row as f32 * (ITEM_HEIGHT + pad); + let item_r = Rect::new(x, y, cell_w, ITEM_HEIGHT); + let folder = &self.folders[i]; + self.folder_btns[i].render_shadow(ui, item_r, t, |ui, path| { + ui.fill_path(&path, semi_black(0.55)); + }); + ui.text(&folder.chart_name) + .pos(item_r.x + 0.025, item_r.y + 0.03) + .size(0.6) + .max_width(item_r.w - 0.08) + .draw(); + ui.text(tl!("replay-count", "count" => folder.count)) + .pos(item_r.x + 0.025, item_r.y + 0.105) + .size(0.4) + .color(semi_white(0.7)) + .draw(); + ui.text(">") + .pos(item_r.right() - 0.025, item_r.center().y) + .anchor(1., 0.5) + .no_baseline() + .size(0.7) + .color(semi_white(0.5)) + .draw(); + } + (r.w, total_h) + } + }); + }); + }); + Ok(()) + } + + fn next_page(&mut self) -> NextPage { + NextPage::None + } + + fn next_scene(&mut self, _s: &mut SharedState) -> NextScene { + self.pending_scene.take().unwrap_or_default() + } + + fn on_back_pressed(&mut self, _s: &mut SharedState) -> bool { + // Inside a chart folder: back navigates up to the folder list + // instead of popping the page. + if self.current_folder.is_some() { + self.current_folder = None; + self.reload(); + return true; + } + false + } +} diff --git a/phira/src/page/settings.rs b/phira/src/page/settings.rs index 3a01bf47..feb8c40c 100644 --- a/phira/src/page/settings.rs +++ b/phira/src/page/settings.rs @@ -814,6 +814,7 @@ struct ChartList { dhint_btn: DRectButton, opt_btn: DRectButton, use_keyboard_btn: DRectButton, + auto_record_btn: DRectButton, speed_slider: Slider, size_slider: Slider, } @@ -828,6 +829,7 @@ impl ChartList { dhint_btn: DRectButton::new(), opt_btn: DRectButton::new(), use_keyboard_btn: DRectButton::new(), + auto_record_btn: DRectButton::new(), speed_slider: Slider::new(0.5..2., 0.05), size_slider: Slider::new(0.8..1.2, 0.005), } @@ -868,6 +870,10 @@ impl ChartList { config.use_keyboard ^= true; return Ok(Some(true)); } + if self.auto_record_btn.touch(touch, t) { + config.auto_record ^= true; + return Ok(Some(true)); + } if let wt @ Some(_) = self.speed_slider.touch(touch, t, &mut config.speed) { return Ok(wt); } @@ -923,6 +929,10 @@ impl ChartList { render_title(ui, tl!("item-use-keyboard"), Some(tl!("item-use-keyboard-sub"))); render_switch(ui, rr, t, &mut self.use_keyboard_btn, config.use_keyboard); } + item! { + render_title(ui, tl!("item-auto-record"), Some(tl!("item-auto-record-sub"))); + render_switch(ui, rr, t, &mut self.auto_record_btn, config.auto_record); + } item! { render_title(ui, tl!("item-speed"), None); self.speed_slider.render(ui, rr, t, config.speed, format!("{:.2}", config.speed)); diff --git a/phira/src/scene/main.rs b/phira/src/scene/main.rs index beccb6ee..01bdc65a 100644 --- a/phira/src/scene/main.rs +++ b/phira/src/scene/main.rs @@ -417,7 +417,7 @@ impl Scene for MainScene { "_import_respack" => { let root = dir::respacks()?; let dir = prpr::dir::Dir::new(&root)?; - let mut dir_id = String::new(); + let mut dir_id: Option = None; let item: Result = (|| { let config = { let mut zip = zip::ZipArchive::new(BufReader::new(File::open(&file)?))?; @@ -462,18 +462,21 @@ impl Scene for MainScene { while dir.exists(uuid.to_string())? { uuid = Uuid::new_v4(); } - dir_id = uuid.to_string(); - dir.create_dir_all(&dir_id)?; - let dir = dir.open_dir(&dir_id)?; + let id = uuid.to_string(); + dir.create_dir_all(&id)?; + let dir = dir.open_dir(&id)?; + dir_id = Some(id.clone()); unzip_into(BufReader::new(File::open(file)?), &dir, false).context("failed to unzip")?; - get_data_mut().respacks.push(dir_id.clone()); + get_data_mut().respacks.push(id.clone()); save_data()?; - Ok(ResPackItem::new(Some(format!("{root}/{dir_id}").into()), config.name)) + Ok(ResPackItem::new(Some(format!("{root}/{id}").into()), config.name)) })(); match item { Err(err) => { - dir.remove_dir_all(&dir_id)?; show_error(err.context(itl!("import-respack-failed"))); + if let Some(id) = &dir_id { + dir.remove_dir_all(id)?; + } } Ok(item) => { RESPACK_ITEM.with(|it| *it.borrow_mut() = Some(item)); diff --git a/phira/src/scene/song.rs b/phira/src/scene/song.rs index c28b48be..f148456d 100644 --- a/phira/src/scene/song.rs +++ b/phira/src/scene/song.rs @@ -857,6 +857,7 @@ impl SongScene { ) -> Result { let mut fs = fs_from_path(local_path)?; let can_rated = id.is_some() || local_path.starts_with(':'); + let replay_local_path = local_path.to_owned(); #[cfg(feature = "video")] let local_path = local_path.to_owned(); #[cfg(closed)] @@ -993,6 +994,13 @@ impl SongScene { } })); + // Persist auto-recorded replays to data/replays/_.json. + let record_save_fn: Option = Some(Arc::new(|data| { + let dir = std::path::PathBuf::from(crate::dir::replays()?); + prpr::replay::save_replay_to_dir(&dir, &data)?; + Ok(()) + })); + Ok(Some(Box::pin(async move { let mut info = fs::load_info(fs.as_mut()).await?; info.id = id; @@ -1060,11 +1068,18 @@ impl SongScene { }) }) })); + let replay_handoff = if get_data().config.auto_record && mode == GameMode::Normal { + Some(prpr::replay::ReplayHandoff::Record { + chart_local_path: replay_local_path, + }) + } else { + None + }; if is_unlock { #[cfg(not(feature = "video"))] { warn!("this build does not support unlock video."); - LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, Some(preload)) + LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, record_save_fn, replay_handoff, Some(preload)) .await .map(|it| NextScene::Overlay(Box::new(it))) } @@ -1076,12 +1091,12 @@ impl SongScene { save_data()?; } - UnlockScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, Some(preload)) + UnlockScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, record_save_fn, replay_handoff, Some(preload)) .await .map(|it| NextScene::Overlay(Box::new(it))) } } else { - LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, Some(preload)) + LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, record_save_fn, replay_handoff, Some(preload)) .await .map(|it| NextScene::Overlay(Box::new(it))) } diff --git a/phira/src/scene/unlock.rs b/phira/src/scene/unlock.rs index d3040a71..9af52bca 100644 --- a/phira/src/scene/unlock.rs +++ b/phira/src/scene/unlock.rs @@ -51,6 +51,8 @@ impl UnlockScene { upload_fn: Option, update_fn: Option, save_fn: Option, + record_save_fn: Option, + replay_handoff: Option, preloaded: Option<(prpr::ext::SafeTexture, prpr::ext::SafeTexture, Color)>, ) -> Result { let bytes = fs @@ -76,7 +78,9 @@ impl UnlockScene { }; let (_, background, _) = preloaded.clone().unwrap_or(LoadingScene::load(&mut *fs, &info.illustration).await?); - let loading_scene = Box::new(LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, preloaded).await?); + let loading_scene = Box::new( + LoadingScene::new(mode, info, config, fs, player, upload_fn, update_fn, save_fn, record_save_fn, replay_handoff, preloaded).await?, + ); Ok(UnlockScene { loading_scene, diff --git a/prpr/src/config.rs b/prpr/src/config.rs index 9852dea5..8bbad9cc 100644 --- a/prpr/src/config.rs +++ b/prpr/src/config.rs @@ -85,6 +85,9 @@ pub struct Config { pub volume_music: f32, pub volume_sfx: f32, + /// Whether to automatically record a replay file for each play. + pub auto_record: bool, + // for compatibility autoplay: Option, } @@ -125,6 +128,8 @@ impl Default for Config { volume_sfx: 1., volume_bgm: 1., + auto_record: true, + autoplay: None, } } diff --git a/prpr/src/judge.rs b/prpr/src/judge.rs index 6675ad49..e19999df 100644 --- a/prpr/src/judge.rs +++ b/prpr/src/judge.rs @@ -181,9 +181,6 @@ impl JudgeInner { pub fn commit(&mut self, what: Judgement, diff: f64) { use Judgement::*; - if matches!(what, Judgement::Good) { - self.diffs.push(diff); - } if diff < 0. { self.early_kind[what as usize] += 1; } else if diff > 0. { @@ -203,6 +200,12 @@ impl JudgeInner { } } + pub fn record_timing_diff(&mut self, diff: f64) { + if diff.is_finite() { + self.diffs.push(diff); + } + } + pub fn reset(&mut self) { self.combo = 0; self.max_combo = 0; @@ -236,6 +239,11 @@ impl JudgeInner { pub fn result(&self) -> PlayResult { let early = self.diffs.iter().filter(|it| **it < 0.).count() as u32; + let std = if self.diffs.is_empty() { + 0. + } else { + (self.diffs.iter().map(|it| it * it).sum::() / self.diffs.len() as f64).sqrt() as f32 + }; PlayResult { score: self.score(), accuracy: self.accuracy(), @@ -244,7 +252,7 @@ impl JudgeInner { counts: self.counts, early, late: self.diffs.len() as u32 - early, - std: 0., + std, early_kind: self.early_kind, late_kind: self.late_kind, } @@ -279,6 +287,13 @@ pub struct Judge { pub(crate) inner: JudgeInner, pub judgements: RefCell, + + // --- Replay support --- + /// Accumulated replay data when recording. + replay_record: Option, + /// Sorted list of replay events to play back, plus our read index. + replay_playback: Option>, + replay_playback_idx: usize, } #[derive(Default)] @@ -318,14 +333,69 @@ impl Judge { inner: JudgeInner::new(chart.lines.iter().map(|it| it.notes.iter().filter(|it| !it.fake).count() as u32).sum()), judgements: RefCell::new(Vec::new()), + + replay_record: None, + replay_playback: None, + replay_playback_idx: 0, } } + /// Begin recording judgements into a fresh `ReplayData`. Clears any + /// previously-recorded events for this Judge. + pub fn start_recording( + &mut self, + chart_id: Option, + chart_name: String, + chart_local_path: String, + chart_level: String, + chart_offset: f32, + speed: f32, + ) { + let mut data = crate::replay::ReplayData::new(chart_id, chart_name); + data.chart_local_path = chart_local_path; + data.chart_level = chart_level; + data.chart_offset = Some(chart_offset); + data.speed = speed; + self.replay_record = Some(data); + } + + /// Consume the recorded `ReplayData`, leaving recording disabled. + pub fn take_replay_record(&mut self) -> Option { + self.replay_record.take() + } + + pub fn discard_replay_record(&mut self) { + self.replay_record = None; + } + + #[inline] + pub fn is_recording_replay(&self) -> bool { + self.replay_record.is_some() + } + + /// Enter playback mode. Consumes the provided replay events. + pub fn set_replay_data(&mut self, data: crate::replay::ReplayData) { + let mut records = data.records; + records.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal)); + self.replay_playback = Some(records); + self.replay_playback_idx = 0; + } + + /// Whether this Judge is currently driving gameplay from a replay. + #[inline] + pub fn is_replaying(&self) -> bool { + self.replay_playback.is_some() + } + pub fn reset(&mut self) { self.notes.iter_mut().for_each(|it| it.1 = 0); self.trackers.clear(); self.inner.reset(); self.judgements.borrow_mut().clear(); + if let Some(rec) = self.replay_record.as_mut() { + rec.records.clear(); + } + self.replay_playback_idx = 0; } /// Advance note pointers past notes before time `t`, marking them as judged. @@ -344,9 +414,113 @@ impl Judge { self.last_time = t; } - pub fn commit(&mut self, t: f64, what: Judgement, line_id: u32, note_id: u32, diff: f64) { + /// Rebuild replay playback state at time `t` without playing sounds or FX. + /// Used by replay seeking while paused. + pub fn seek_replay_to(&mut self, chart: &mut Chart, t: f64) { + if self.replay_playback.is_none() { + self.advance_to(chart, t); + return; + } + + self.notes.iter_mut().for_each(|it| it.1 = 0); + self.trackers.clear(); + self.inner.reset(); + self.judgements.borrow_mut().clear(); + self.replay_playback_idx = 0; + + let target_idx = self + .replay_playback + .as_ref() + .map(|records| records.iter().take_while(|record| record.time <= t).count()) + .unwrap_or_default(); + + for idx in 0..target_idx { + let rec = { + let records = self.replay_playback.as_ref().unwrap(); + records[idx].clone() + }; + self.apply_replay_record_for_seek(chart, rec); + } + self.replay_playback_idx = target_idx; + + for (line, (idx, st)) in chart.lines.iter_mut().zip(self.notes.iter_mut()) { + for id in &idx[*st..] { + let note = &mut line.notes[*id as usize]; + if let JudgeStatus::Hold(..) = note.judge { + if let NoteKind::Hold { end_time, .. } = note.kind { + if t >= end_time { + note.judge = JudgeStatus::Judged; + } + } + } + } + while idx + .get(*st) + .is_some_and(|id| matches!(line.notes[*id as usize].judge, JudgeStatus::Judged)) + { + *st += 1; + } + } + + self.judgements.borrow_mut().clear(); + self.last_time = t; + } + + fn apply_replay_record_for_seek(&mut self, chart: &mut Chart, rec: crate::replay::NoteRecord) { + let line_idx = rec.line_id as usize; + let note_idx = rec.note_id as usize; + if line_idx >= chart.lines.len() || note_idx >= chart.lines[line_idx].notes.len() { + return; + } + + if rec.judgment.is_hold_prejudge() { + let perfect = matches!(rec.judgment, crate::replay::ReplayJudgement::HoldPerfect); + let note = &mut chart.lines[line_idx].notes[note_idx]; + if matches!(note.judge, JudgeStatus::NotJudged) { + note.judge = JudgeStatus::Hold(perfect, rec.time, rec.offset, false, f64::INFINITY); + } + return; + } + + let Some(judgement) = rec.judgment.to_judgement() else { + return; + }; + let note_kind = chart.lines[line_idx].notes[note_idx].kind.clone(); + chart.lines[line_idx].notes[note_idx].judge = JudgeStatus::Judged; + + let timing_diff = if matches!(judgement, Judgement::Miss) || matches!(note_kind, NoteKind::Drag | NoteKind::Flick) { + None + } else { + Some(rec.offset) + }; + self.commit_with_timing_diff(rec.time, judgement, rec.line_id, rec.note_id, rec.offset, timing_diff); + } + + fn commit_with_timing_diff(&mut self, t: f64, what: Judgement, line_id: u32, note_id: u32, diff: f64, timing_diff: Option) { self.judgements.borrow_mut().push((t, line_id, note_id, Ok(what))); + #[cfg(closed)] + self.inner.commit(what, diff as f32); + #[cfg(not(closed))] self.inner.commit(what, diff); + #[cfg(not(closed))] + if let Some(timing_diff) = timing_diff { + self.inner.record_timing_diff(timing_diff); + } + + // Append to replay record buffer if we are recording. + if let Some(rec) = self.replay_record.as_mut() { + rec.records.push(crate::replay::NoteRecord { + time: t, + line_id, + note_id, + judgment: crate::replay::ReplayJudgement::from_commit(Ok(what)), + offset: diff, + }); + } + } + + pub fn commit(&mut self, t: f64, what: Judgement, line_id: u32, note_id: u32, diff: f64) { + self.commit_with_timing_diff(t, what, line_id, note_id, diff, Some(diff)); } #[inline] @@ -410,6 +584,10 @@ impl Judge { } pub fn update(&mut self, res: &mut Resource, chart: &mut Chart, bad_notes: &mut Vec) { + if self.is_replaying() { + self.replay_update(res, chart, bad_notes); + return; + } if res.config.autoplay() { self.auto_play_update(res, chart); return; @@ -626,6 +804,19 @@ impl Judge { note.hitsound.play(res); self.judgements.borrow_mut().push((t, line_id as _, id, Err(dt <= LIMIT_PERFECT))); note.judge = JudgeStatus::Hold(dt <= LIMIT_PERFECT, t, t, false, f64::INFINITY); + if let Some(rec) = self.replay_record.as_mut() { + rec.records.push(crate::replay::NoteRecord { + time: t, + line_id: line_id as _, + note_id: id, + judgment: if dt <= LIMIT_PERFECT { + crate::replay::ReplayJudgement::HoldPerfect + } else { + crate::replay::ReplayJudgement::HoldGood + }, + offset: (t - note.time) / spd, + }); + } } _ => unreachable!(), }; @@ -688,6 +879,19 @@ impl Judge { note.hitsound.play(res); self.judgements.borrow_mut().push((t, line_id as _, id, Err(dt <= LIMIT_PERFECT))); note.judge = JudgeStatus::Hold(dt <= LIMIT_PERFECT, t, (t - note.time) / spd, false, f64::INFINITY); + if let Some(rec) = self.replay_record.as_mut() { + rec.records.push(crate::replay::NoteRecord { + time: t, + line_id: line_id as _, + note_id: id, + judgment: if dt <= LIMIT_PERFECT { + crate::replay::ReplayJudgement::HoldPerfect + } else { + crate::replay::ReplayJudgement::HoldGood + }, + offset: (t - note.time) / spd, + }); + } } _ => unreachable!(), }; @@ -802,19 +1006,19 @@ impl Judge { let line = &chart.lines[line_id]; let note = &line.notes[id as usize]; let line_tr = line.now_transform(res, &chart.lines); - self.commit( - t, - judgement, - line_id as _, - id, - if matches!(judgement, Judgement::Miss) { - 0.25 - } else if matches!(note.kind, NoteKind::Drag | NoteKind::Flick) { - 0. - } else { - (diff.unwrap_or(t) - note.time) / spd - }, - ); + let diff = if matches!(judgement, Judgement::Miss) { + 0.25 + } else if matches!(note.kind, NoteKind::Drag | NoteKind::Flick) { + 0. + } else { + (diff.unwrap_or(t) - note.time) / spd + }; + let timing_diff = if matches!(judgement, Judgement::Miss) || matches!(note.kind, NoteKind::Drag | NoteKind::Flick) { + None + } else { + Some(diff) + }; + self.commit_with_timing_diff(t, judgement, line_id as _, id, diff, timing_diff); if matches!(note.kind, NoteKind::Hold { .. }) { continue; } @@ -870,6 +1074,145 @@ impl Judge { self.last_time = t / spd; } + /// Drive note state from a recorded replay. Replaces both `update` and + /// `auto_play_update` when `is_replaying()` is true. Commits judgements + /// matching the recording at the times they were originally committed. + fn replay_update(&mut self, res: &mut Resource, chart: &mut Chart, bad_notes: &mut Vec) { + let t = res.time; + let spd = res.config.speed as f64; + let Some(records) = self.replay_playback.as_ref() else { + return; + }; + + // Consume events whose time <= current game time. + let mut pending: Vec = Vec::new(); + while self.replay_playback_idx < records.len() && records[self.replay_playback_idx].time <= t { + pending.push(records[self.replay_playback_idx].clone()); + self.replay_playback_idx += 1; + } + + for rec in pending { + let line_idx = rec.line_id as usize; + let note_idx = rec.note_id as usize; + if line_idx >= chart.lines.len() { + continue; + } + if note_idx >= chart.lines[line_idx].notes.len() { + continue; + } + + let is_hold_prejudge = rec.judgment.is_hold_prejudge(); + let judgement = rec.judgment.to_judgement(); + + // Prepare note for rendering FX. + { + let line = &mut chart.lines[line_idx]; + let note = &mut line.notes[note_idx]; + line.object.set_time(rec.time); + note.object.set_time(rec.time); + } + + if is_hold_prejudge { + let perfect = matches!(rec.judgment, crate::replay::ReplayJudgement::HoldPerfect); + { + let note = &mut chart.lines[line_idx].notes[note_idx]; + if matches!(note.judge, JudgeStatus::NotJudged) { + note.judge = JudgeStatus::Hold(perfect, rec.time, rec.offset, false, f64::INFINITY); + note.hitsound.clone().play(res); + } + } + self.judgements.borrow_mut().push((rec.time, rec.line_id, rec.note_id, Err(perfect))); + continue; + } + + let Some(judgement) = judgement else { continue }; + let note_kind = chart.lines[line_idx].notes[note_idx].kind.clone(); + let hitsound = chart.lines[line_idx].notes[note_idx].hitsound.clone(); + + { + let note = &mut chart.lines[line_idx].notes[note_idx]; + note.judge = JudgeStatus::Judged; + } + + let timing_diff = if matches!(judgement, Judgement::Miss) || matches!(note_kind, NoteKind::Drag | NoteKind::Flick) { + None + } else { + Some(rec.offset) + }; + self.commit_with_timing_diff(rec.time, judgement, rec.line_id, rec.note_id, rec.offset, timing_diff); + + if matches!(note_kind, NoteKind::Hold { .. }) { + continue; + } + let line_tr = chart.lines[line_idx].now_transform(res, &chart.lines); + let note_tr = chart.lines[line_idx].notes[note_idx].object.now(res); + match judgement { + Judgement::Perfect => { + res.with_model(line_tr * note_tr, |res| { + let rot = chart.lines[line_idx].notes[note_idx].rotation(&chart.lines[line_idx]); + res.emit_at_origin(rot, res.res_pack.info.fx_perfect()); + }); + hitsound.play(res); + } + Judgement::Good => { + res.with_model(line_tr * note_tr, |res| { + let rot = chart.lines[line_idx].notes[note_idx].rotation(&chart.lines[line_idx]); + res.emit_at_origin(rot, res.res_pack.info.fx_good()); + }); + hitsound.play(res); + } + Judgement::Bad => { + // Mirror the normal-gameplay Bad path: spawn a fading + // dark note copy at the press location instead of + // letting the note vanish. + let line = &chart.lines[line_idx]; + let note = &line.notes[note_idx]; + if !matches!(note.kind, NoteKind::Hold { .. }) { + let mut mat = line_tr; + if !note.above { + mat.append_nonuniform_scaling_mut(&Vector::new(1., -1.)); + } + let incline_sin = line.incline.now_opt().map(|it| it.to_radians().sin()).unwrap_or_default(); + mat *= note.now_transform( + res, + &line.ctrl_obj.borrow_mut(), + ((note.height - line.height.now() as f64) / res.aspect_ratio as f64 * note.speed) as f32, + incline_sin, + ); + bad_notes.push(BadNote { + time: rec.time, + kind: note.kind.clone(), + matrix: mat, + }); + } + } + _ => {} + } + } + + // Hold notes: flush Judged state when we pass their end_time. + for (line, (idx, st)) in chart.lines.iter_mut().zip(self.notes.iter_mut()) { + for id in &idx[*st..] { + let note = &mut line.notes[*id as usize]; + if let JudgeStatus::Hold(..) = note.judge { + if let NoteKind::Hold { end_time, .. } = note.kind { + if t >= end_time { + note.judge = JudgeStatus::Judged; + } + } + } + } + while idx + .get(*st) + .is_some_and(|id| matches!(line.notes[*id as usize].judge, JudgeStatus::Judged)) + { + *st += 1; + } + } + + self.last_time = t / spd; + } + fn auto_play_update(&mut self, res: &mut Resource, chart: &mut Chart) { let t = res.time; let spd = res.config.speed as f64; @@ -909,7 +1252,7 @@ impl Judge { } } for (line_id, id) in judgements.into_iter() { - self.commit(t, Judgement::Perfect, line_id as _, id, 0.); + self.commit_with_timing_diff(t, Judgement::Perfect, line_id as _, id, 0., None); let (note_transform, note_hitsound) = { let line = &mut chart.lines[line_id]; let note = &mut line.notes[id as usize]; diff --git a/prpr/src/lib.rs b/prpr/src/lib.rs index 59a02697..12f7b022 100644 --- a/prpr/src/lib.rs +++ b/prpr/src/lib.rs @@ -8,6 +8,7 @@ pub mod info; pub mod judge; pub mod parse; pub mod particle; +pub mod replay; pub mod scene; pub mod task; pub mod time; diff --git a/prpr/src/replay.rs b/prpr/src/replay.rs new file mode 100644 index 00000000..26dd61d2 --- /dev/null +++ b/prpr/src/replay.rs @@ -0,0 +1,282 @@ +//! Replay recording and playback data. +//! +//! A replay captures, in time order, every per-note judgement made during a +//! play. During playback the engine consumes these records and applies the +//! same judgements to the matching notes without user input. + +use crate::judge::Judgement; +use serde::{Deserialize, Serialize}; + +/// A single judgement event in a replay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteRecord { + /// Game time (in seconds, relative to chart start) at which this judgement + /// was committed. + pub time: f64, + /// Index of the note's line (0-based, matches `Chart::lines`). + pub line_id: u32, + /// Index of the note within the line (matches `Line::notes`). + pub note_id: u32, + /// The committed judgement. + pub judgment: ReplayJudgement, + /// Signed offset in seconds. Negative = early, positive = late. For + /// `Perfect`/`Good`/`Bad` this is the raw press - note.time; for `Miss` + /// and hold pre-judges it is 0. + pub offset: f64, +} + +/// Persistable form of `Judgement` including hold pre/end variants we care +/// about for accurate playback. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ReplayJudgement { + Perfect, + Good, + Bad, + Miss, + /// Hold note accepted on press (no scoring effect yet). + HoldPerfect, + /// Hold note accepted on press, rated Good. + HoldGood, +} + +impl ReplayJudgement { + pub fn from_commit(r: Result) -> Self { + match r { + Ok(Judgement::Perfect) => Self::Perfect, + Ok(Judgement::Good) => Self::Good, + Ok(Judgement::Bad) => Self::Bad, + Ok(Judgement::Miss) => Self::Miss, + Err(true) => Self::HoldPerfect, + Err(false) => Self::HoldGood, + } + } + + /// Returns the visual `Judgement` to commit, or `None` if this record + /// represents only a hold pre-judge (which scores nothing on its own). + pub fn to_judgement(self) -> Option { + match self { + Self::Perfect => Some(Judgement::Perfect), + Self::Good => Some(Judgement::Good), + Self::Bad => Some(Judgement::Bad), + Self::Miss => Some(Judgement::Miss), + Self::HoldPerfect | Self::HoldGood => None, + } + } + + pub fn is_hold_prejudge(self) -> bool { + matches!(self, Self::HoldPerfect | Self::HoldGood) + } +} + +/// A full replay tied to a specific chart. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplayData { + /// Phira chart id when the replay was recorded from an online chart. + pub chart_id: Option, + /// Display name of the chart (used for matching when id is missing). + pub chart_name: String, + /// Host-supplied unique identifier for the chart, e.g. phira's + /// `local_path` (`download/` for online charts, the unzipped + /// directory name for local imports). Empty if unknown. Replay-list + /// matching prefers this over the display name to avoid two locally + /// imported charts with the same `chart_name` mapping to each other. + #[serde(default)] + pub chart_local_path: String, + /// Difficulty level of the chart at recording time (e.g. "IN Lv.13"). + #[serde(default)] + pub chart_level: String, + /// Chart offset (seconds) when the replay was recorded. `None` means the + /// replay was saved by an older version before this field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chart_offset: Option, + /// Playback speed at recording time. + #[serde(default = "default_speed")] + pub speed: f32, + /// Chronological list of per-note judgement events. + pub records: Vec, + /// Final score (0..=1_000_000). + #[serde(default)] + pub score: i32, + /// Final accuracy (0.0..=1.0). + #[serde(default)] + pub accuracy: f32, + /// Max combo reached. + #[serde(default)] + pub max_combo: u32, + /// Full-combo flag. + #[serde(default)] + pub full_combo: bool, + /// User-marked favorite flag. + #[serde(default)] + pub favorite: bool, + /// User-provided display name for this replay. + #[serde(default)] + pub replay_name: String, + /// Unix timestamp of the game session. + pub timestamp: i64, +} + +fn default_speed() -> f32 { + 1.0 +} + +impl ReplayData { + pub fn new(chart_id: Option, chart_name: String) -> Self { + Self { + chart_id, + chart_name, + chart_local_path: String::new(), + chart_level: String::new(), + chart_offset: None, + speed: 1., + records: Vec::new(), + score: 0, + accuracy: 0., + max_combo: 0, + full_combo: false, + favorite: false, + replay_name: String::new(), + timestamp: chrono::Utc::now().timestamp(), + } + } + + pub fn finalize(&mut self, score: i32, accuracy: f32, max_combo: u32, full_combo: bool) { + self.score = score; + self.accuracy = accuracy; + self.max_combo = max_combo; + self.full_combo = full_combo; + } +} + +// ------- replay file storage ------- + +use std::{ + io::{ErrorKind, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// A host-supplied callback that persists a finished `ReplayData` somewhere +/// the host knows about (typically `/replays/_.json`). +/// Invoked from the game scene right before tear-down so the host's directory +/// layout doesn't have to leak into `prpr`. +pub type RecordSaveFn = Arc anyhow::Result<()>>; + +/// Replay/record setup to apply to a newly-created game scene. +pub enum ReplayHandoff { + Playback(ReplayData), + Record { chart_local_path: String }, +} + +/// Convenience: serialize `data` as pretty JSON into `dir`. The filename +/// encodes timestamp and chart name, with a numeric suffix when needed to +/// avoid overwriting another replay. Hosts can use this from inside their +/// own `RecordSaveFn`, or roll their own. +pub fn save_replay_to_dir(dir: &Path, data: &ReplayData) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let safe_name: String = data + .chart_name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + let json = serde_json::to_string_pretty(data)?; + for index in 0u32.. { + let filename = if index == 0 { + format!("{}_{}.json", data.timestamp, safe_name) + } else { + format!("{}_{}_{}.json", data.timestamp, safe_name, index) + }; + let path = dir.join(filename); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + file.write_all(json.as_bytes())?; + return Ok(path); + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err.into()), + } + } + unreachable!() +} + +// --- thread-local handoff so callers can queue a replay/recording config +// for the next-built GameScene without modifying its public API. --- + +use std::cell::RefCell; + +thread_local! { + /// A pending `ReplayData` that the next `GameScene` constructed on this + /// thread should play back (rather than recording). + static PENDING_PLAYBACK: RefCell> = const { RefCell::new(None) }; + + /// A pending record-request: when set, the next `GameScene::new` will + /// start recording into a fresh `ReplayData`. The string carries the + /// host's unique chart identifier (phira's `local_path`) so the saved + /// replay can be matched back to the same chart unambiguously, even + /// when two locally imported charts share a display name. + static PENDING_RECORD: RefCell> = const { RefCell::new(None) }; +} + +/// Queue replay data to be played back by the next `GameScene` on this thread. +pub fn set_pending_playback(data: ReplayData) { + PENDING_PLAYBACK.with(|cell| *cell.borrow_mut() = Some(data)); +} + +pub fn take_pending_playback() -> Option { + PENDING_PLAYBACK.with(|cell| cell.borrow_mut().take()) +} + +/// Queue the next built `GameScene` to start recording a replay, tagged +/// with `local_path` (an empty string means "host has no stable id"). +pub fn set_pending_record(local_path: String) { + PENDING_RECORD.with(|cell| *cell.borrow_mut() = Some(local_path)); +} + +/// Cancel any pending record request. +pub fn clear_pending_record() { + PENDING_RECORD.with(|cell| *cell.borrow_mut() = None); +} + +pub fn take_pending_record() -> Option { + PENDING_RECORD.with(|cell| cell.borrow_mut().take()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_chart_offset_deserializes_as_none() { + let replay: ReplayData = serde_json::from_str( + r#"{ + "chart_id": null, + "chart_name": "legacy", + "records": [], + "timestamp": 1 + }"#, + ) + .unwrap(); + + assert_eq!(replay.chart_offset, None); + + let json = serde_json::to_string(&replay).unwrap(); + assert!(!json.contains("chart_offset")); + } + + #[test] + fn present_chart_offset_round_trips() { + let mut replay = ReplayData::new(Some(42), "offset".to_owned()); + replay.chart_offset = Some(0.08); + replay.timestamp = 1; + + let json = serde_json::to_string(&replay).unwrap(); + assert!(json.contains(r#""chart_offset":0.08"#)); + + let replay: ReplayData = serde_json::from_str(&json).unwrap(); + assert_eq!(replay.chart_offset, Some(0.08)); + } +} diff --git a/prpr/src/scene/game.rs b/prpr/src/scene/game.rs index 3a679747..b54f70c2 100644 --- a/prpr/src/scene/game.rs +++ b/prpr/src/scene/game.rs @@ -127,6 +127,7 @@ pub struct GameScene { chart_bytes: Vec, chart_format: ChartFormat, info_offset: f32, + replay_record_local_path: Option, effects: Vec, first_in: bool, @@ -146,6 +147,7 @@ pub struct GameScene { upload_fn: Option, update_fn: Option, save_fn: Option, + record_save_fn: Option, best_record: Option, @@ -245,7 +247,15 @@ impl GameScene { upload_fn: Option, update_fn: Option, save_fn: Option, + record_save_fn: Option, + replay_handoff: Option, ) -> Result { + let pending_playback = crate::replay::take_pending_playback(); + let pending_record = crate::replay::take_pending_record(); + let replay_handoff = replay_handoff + .or_else(|| pending_playback.map(crate::replay::ReplayHandoff::Playback)) + .or_else(|| pending_record.map(|chart_local_path| crate::replay::ReplayHandoff::Record { chart_local_path })); + match mode { GameMode::TweakOffset => { config.mods.insert(Mods::AUTOPLAY); @@ -305,7 +315,8 @@ impl GameScene { let judge = Judge::new(&chart); let music = Self::new_music(&mut res)?; - Ok(Self { + let normal_mode = matches!(mode, GameMode::Normal); + let mut this = Self { should_exit: false, next_scene: None, @@ -319,6 +330,7 @@ impl GameScene { chart_format, effects, info_offset, + replay_record_local_path: None, first_in: false, exercise_range, @@ -337,6 +349,7 @@ impl GameScene { upload_fn, update_fn, save_fn, + record_save_fn, best_record: None, @@ -347,7 +360,26 @@ impl GameScene { fps_last_frame_time: 0.0, dead: false, - }) + }; + + // Pick up any pending replay/record configuration queued by + // the caller before returning. + if let Some(replay_handoff) = replay_handoff { + match replay_handoff { + crate::replay::ReplayHandoff::Playback(replay) => { + this.judge.set_replay_data(replay); + } + crate::replay::ReplayHandoff::Record { chart_local_path } => { + // Record only when it actually makes sense to: a normal + // play with no autoplay and 1x speed. + if normal_mode && !this.res.config.autoplay() && (this.res.config.speed - 1.0).abs() < 1e-3 { + this.replay_record_local_path = Some(chart_local_path); + this.start_replay_recording(); + } + } + } + } + Ok(this) } fn new_music(res: &mut Resource) -> Result { @@ -550,12 +582,13 @@ impl GameScene { } fn overlay_ui(&mut self, ui: &mut Ui, tm: &mut TimeManager) -> Result<()> { + let is_replay = self.judge.is_replaying(); let c = semi_white(self.res.alpha); let res = &mut self.res; if tm.paused() { let h = 1. / res.aspect_ratio; draw_rectangle(-1., -h, 2., h * 2., Color::new(0., 0., 0., 0.6)); - let o = if self.mode == GameMode::Exercise { -0.3 } else { 0. }; + let o = if self.mode == GameMode::Exercise || is_replay { -0.3 } else { 0. }; let s = 0.06; let w = 0.05; let no_retry = self.mode == GameMode::NoRetry; @@ -603,7 +636,7 @@ impl GameScene { clicked = None; } let mut pos = self.music.position(); - if self.mode == GameMode::Exercise { + if self.mode == GameMode::Exercise || is_replay { pos = tm.now(); } if clicked.is_some_and(|it| it != -1) && (tm.speed - res.config.speed as f64).abs() > 0.01 { @@ -624,7 +657,9 @@ impl GameScene { miniquad::native::set_interceptor_state(false); } Some(0) => { + self.judge.discard_replay_record(); reset!(self, res, tm); + Self::start_replay_recording_with(&mut self.judge, res, self.replay_record_local_path.clone()); if self.mode == GameMode::Exercise { self.judge.advance_to(&mut self.chart, self.exercise_range.start); } @@ -769,6 +804,60 @@ impl GameScene { for touch in ui.ensure_touches() { touch.position /= asp; } + } else if is_replay { + let asp = self.touch_scale(); + for touch in ui.ensure_touches() { + touch.position *= asp; + } + ui.dy(0.06); + let hw = 0.7; + let h = 0.06; + let rad = 0.03; + let track_length = self.res.track_length.max(1e-3); + let t = self.res.time.clamp(0., track_length); + let cur = -hw + (t / track_length) as f32 * hw * 2.; + ui.fill_rect(Rect::new(-hw, -h, hw * 2., h * 2.), GRAY); + ui.fill_rect(Rect::new(-hw, -h, cur + hw, h * 2.), WHITE); + ui.fill_rect(Rect::new(cur, -h, 0., h * 2.).feather(0.005), GREEN); + ui.fill_circle(cur, 0., rad, GREEN); + if self.exercise_press.is_none() { + let r = ui.rect_to_global(Rect::new(-hw, -h, hw * 2., h * 2.).feather(rad)); + self.exercise_press = Judge::get_touches() + .iter() + .find(|it| it.phase == TouchPhase::Started && r.contains(it.position)) + .map(|it| (0, it.id)); + } + ui.text(format!("{} / {}", fmt_time(t as f32), fmt_time(track_length as f32))) + .pos(0., -0.23) + .anchor(0.5, 0.) + .size(0.8) + .draw(); + if let Some((_, id)) = self.exercise_press { + let mut release_press = false; + if let Some(touch) = Judge::get_touches().into_iter().rfind(|it| it.id == id) { + let x = touch.position.x; + let p = ((x + hw) as f64 / (hw * 2.) as f64 * track_length).clamp(0., track_length); + let timeline_pos = p + self.offset() as f64; + tm.seek_to(timeline_pos); + self.music.seek_to(timeline_pos.max(0.))?; + self.res.time = p; + self.bad_notes.clear(); + self.chart.reset(); + self.judge.seek_replay_to(&mut self.chart, p); + self.res.judge_line_color = self.res.res_pack.info.color_perfect(); + if matches!(touch.phase, TouchPhase::Cancelled | TouchPhase::Ended) { + release_press = true; + } + } else { + release_press = true; + } + if release_press { + self.exercise_press = None; + } + } + for touch in ui.ensure_touches() { + touch.position /= asp; + } } } if let Some(time) = self.pause_rewind { @@ -802,6 +891,44 @@ impl GameScene { self.chart.offset + self.res.config.offset + self.info_offset } + fn start_replay_recording_with(judge: &mut Judge, res: &Resource, chart_local_path: Option) { + let Some(chart_local_path) = chart_local_path else { + return; + }; + if judge.is_replaying() { + return; + } + judge.start_recording(res.info.id, res.info.name.clone(), chart_local_path, res.info.level.clone(), res.info.offset, res.config.speed); + } + + fn start_replay_recording(&mut self) { + Self::start_replay_recording_with(&mut self.judge, &self.res, self.replay_record_local_path.clone()); + } + + fn save_replay_recording_with(judge: &mut Judge, record_save_fn: &Option) { + if judge.is_replaying() { + return; + } + let Some(mut rec) = judge.take_replay_record() else { + return; + }; + if rec.records.is_empty() { + return; + } + + let result = judge.result(); + rec.finalize(result.score as _, result.accuracy as _, result.max_combo as _, result.max_combo == result.num_of_notes); + if let Some(f) = record_save_fn { + if let Err(e) = f(rec) { + tracing::warn!("failed to save replay: {e:?}"); + } + } + } + + fn save_replay_recording(&mut self) { + Self::save_replay_recording_with(&mut self.judge, &self.record_save_fn); + } + fn tweak_offset(&mut self, ui: &mut Ui, ita: bool) { ui.scope(|ui| { let width = 0.55; @@ -879,6 +1006,9 @@ impl Scene for GameScene { reset!(self, self.res, tm); set_camera(&self.res.camera); self.first_in = true; + if self.replay_record_local_path.is_some() && !self.judge.is_recording_replay() { + self.start_replay_recording(); + } Ok(()) } @@ -976,6 +1106,11 @@ impl Scene for GameScene { State::Ending => { let t = time - self.res.track_length - WAIT_TIME; if t >= AFTER_TIME + 0.3 { + let is_replay = self.judge.is_replaying(); + + // Persist any recorded replay before the scene tears down. + self.save_replay_recording(); + let mut record_data = None; // TODO strengthen the protection #[cfg(closed)] @@ -1005,18 +1140,21 @@ impl Scene for GameScene { self.next_scene = match self.mode { GameMode::Normal | GameMode::NoRetry | GameMode::View => { let historic_best = self.player.as_ref().map_or(0, |it| it.historic_best); - if let Some(new_rec) = &record { - if let Some(f) = &self.save_fn { - f(new_rec.clone())?; - } - if let Some(best) = &mut self.best_record { - best.update(new_rec); - } else { - self.best_record = record.clone(); - } - if let Some(best) = &self.best_record { - if let Some(player) = &mut self.player { - player.historic_best = player.historic_best.max(best.score as _); + // Don't update local best / upload during replay playback. + if !is_replay { + if let Some(new_rec) = &record { + if let Some(f) = &self.save_fn { + f(new_rec.clone())?; + } + if let Some(best) = &mut self.best_record { + best.update(new_rec); + } else { + self.best_record = record.clone(); + } + if let Some(best) = &self.best_record { + if let Some(player) = &mut self.player { + player.historic_best = player.historic_best.max(best.score as _); + } } } } @@ -1032,11 +1170,11 @@ impl Scene for GameScene { self.judge.result(), &self.res.config, self.res.res_pack.ending.clone(), - self.upload_fn.as_ref().map(Arc::clone), + if is_replay { None } else { self.upload_fn.as_ref().map(Arc::clone) }, self.player.as_ref().map(|it| it.rks), historic_best, - record_data, - self.best_record.clone(), + if is_replay { None } else { record_data }, + if is_replay { None } else { self.best_record.clone() }, if self.res.config.show_avg_fps { self.get_avg_fps() } else { None }, )?))) } diff --git a/prpr/src/scene/loading.rs b/prpr/src/scene/loading.rs index c08332b4..87dd6b9e 100644 --- a/prpr/src/scene/loading.rs +++ b/prpr/src/scene/loading.rs @@ -100,6 +100,8 @@ impl LoadingScene { upload_fn: Option, update_fn: Option, save_fn: Option, + record_save_fn: Option, + replay_handoff: Option, preloaded: Option<(SafeTexture, SafeTexture, Color)>, ) -> Result { @@ -118,8 +120,20 @@ impl LoadingScene { if info.tip.is_none() { info.tip = Some(crate::config::TIPS.choose(&mut thread_rng()).unwrap().to_owned()); } - let future = - Box::pin(GameScene::new(mode, info.clone(), config, fs, player, background.clone(), illustration.clone(), upload_fn, update_fn, save_fn)); + let future = Box::pin(GameScene::new( + mode, + info.clone(), + config, + fs, + player, + background.clone(), + illustration.clone(), + upload_fn, + update_fn, + save_fn, + record_save_fn, + replay_handoff, + )); let charter = Regex::new(r"\[!:[0-9]+:([^:]*)\]").unwrap().replace_all(&info.charter, "$1").to_string(); Ok(Self {