From 5687adbf8a57013b6c082b7d4f05b15e50ce834b Mon Sep 17 00:00:00 2001 From: Sofia Date: Sun, 26 Jul 2026 21:30:57 +0300 Subject: [PATCH] Store replay to file --- Cargo.lock | 1 + godot/scenes/game_finished.tscn | 25 ++++++++- rust/Cargo.toml | 1 + rust/src/game_manager.rs | 4 +- rust/src/map.rs | 2 +- rust/src/replay.rs | 78 +++++++++++++++++++++++------ rust/src/ui/game_finished_screen.rs | 27 +++++++++- 7 files changed, 118 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80841ff..d9d71b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,7 @@ dependencies = [ name = "quakeball" version = "0.1.0" dependencies = [ + "ciborium", "godot", "serde", "teanet", diff --git a/godot/scenes/game_finished.tscn b/godot/scenes/game_finished.tscn index a982b5b..b948dff 100644 --- a/godot/scenes/game_finished.tscn +++ b/godot/scenes/game_finished.tscn @@ -2,9 +2,11 @@ [ext_resource type="PackedScene" uid="uid://2sfy0vq0fqt3" path="res://scenes/ui/player_listing.tscn" id="1_ou354"] -[node name="game_finished" type="GameFinishedScreen" unique_id=700430082 node_paths=PackedStringArray("title_container", "back_button")] +[node name="game_finished" type="GameFinishedScreen" unique_id=700430082 node_paths=PackedStringArray("title_container", "back_button", "player_listings", "save_file_dialog")] title_container = NodePath("title_container") back_button = NodePath("button") +player_listings = NodePath("player_listings") +save_file_dialog = NodePath("save_dialog") anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 @@ -39,6 +41,22 @@ grow_vertical = 0 theme_override_font_sizes/font_size = 32 text = "Back" +[node name="save_replay" type="Button" parent="." unique_id=1199584793] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -534.0 +offset_top = -87.0 +offset_right = -342.0 +offset_bottom = -34.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_override_font_sizes/font_size = 32 +text = "Save Replay" + [node name="player_listings" type="PlayerListings" parent="." unique_id=602626392] player_listing_scene = ExtResource("1_ou354") layout_mode = 1 @@ -62,3 +80,8 @@ offset_top = 93.0 offset_right = 20.0 offset_bottom = 133.0 grow_horizontal = 2 + +[node name="save_dialog" type="FileDialog" parent="." unique_id=1637714468] +filters = PackedStringArray("*.replay") + +[connection signal="pressed" from="save_replay" to="." method="open_file_dialog"] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 310363c..fca4d8b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" crate-type = ["cdylib"] # Compile this crate to a dynamic C library. [dependencies] +ciborium = "0.2.2" godot = "0.5.4" serde = "1.0.228" teanet = { path = "../teanet-rust" } diff --git a/rust/src/game_manager.rs b/rust/src/game_manager.rs index 7ff80a3..98ed2ac 100644 --- a/rust/src/game_manager.rs +++ b/rust/src/game_manager.rs @@ -1008,7 +1008,7 @@ impl Game { if let Some(recorder) = &mut self.replay_recorder { recorder .bind_mut() - .record_event(ReplayEvent::Shoot(player_id, to)); + .record_event(ReplayEvent::Shoot(player_id, to.into())); } let self_id = self.self_id.clone().unwrap_or(0); @@ -1035,7 +1035,7 @@ impl Game { if let Some(recorder) = &mut self.replay_recorder { recorder .bind_mut() - .record_event(ReplayEvent::Punch(player_id, to)); + .record_event(ReplayEvent::Punch(player_id, to.into())); } let self_id = self.self_id.clone().unwrap_or(0); diff --git a/rust/src/map.rs b/rust/src/map.rs index e175128..34792a6 100644 --- a/rust/src/map.rs +++ b/rust/src/map.rs @@ -112,7 +112,7 @@ impl Map { let mut node: Gd = replay_player.instantiate_as(); node.bind_mut().player_id = Some(player_id); node.bind_mut().player_name = player.name.clone(); - node.bind_mut().player_color = player.color; + node.bind_mut().player_color = player.color.clone().into(); self.base_mut().add_child(&node); node.bind_mut() diff --git a/rust/src/replay.rs b/rust/src/replay.rs index 21c37a1..4dc794b 100644 --- a/rust/src/replay.rs +++ b/rust/src/replay.rs @@ -1,6 +1,10 @@ -use std::collections::HashMap; +use std::{collections::HashMap, io::Cursor}; -use godot::prelude::*; +use godot::{ + classes::{FileAccess, file_access::ModeFlags}, + prelude::*, +}; +use serde::{Deserialize, Serialize}; use crate::{ game_manager::{Game, Player}, @@ -97,7 +101,7 @@ impl INode for ReplayPlayback { } ReplayEvent::Shoot(player_id, to) => { if let Some(character) = self.player_characters.get_mut(player_id) { - character.bind_mut().try_shoot(*to, false); + character.bind_mut().try_shoot((*to).into(), false); } } ReplayEvent::Respawn(player_id, data) => { @@ -124,7 +128,7 @@ impl INode for ReplayPlayback { } ReplayEvent::Punch(player_id, to) => { if let Some(character) = self.player_characters.get_mut(player_id) { - character.bind_mut().try_punch(*to, false); + character.bind_mut().try_punch((*to).into(), false); } } ReplayEvent::TakeDamage(player_id, damage_source, damage) => { @@ -213,11 +217,12 @@ impl INode for ReplayRecorder { ReplayPlayer { id: player.id(), name: player.data.name.clone(), - color: team.bind().color, + color: team.bind().color.into(), }, ); } } + s.replay.map_idx = game.bind().selected_map_idx; } }); } @@ -236,6 +241,17 @@ impl INode for ReplayRecorder { } impl ReplayRecorder { + pub fn save_to(&self, path: &GString) { + if let Some(mut file) = FileAccess::open(path, ModeFlags::WRITE) { + let mut buf = Vec::new(); + let cursor = Cursor::new(&mut buf); + ciborium::into_writer(&self.replay, cursor).ok(); + for byte in buf { + file.store_8(byte); + } + } + } + pub fn record_event(&mut self, event: ReplayEvent) { self.current_frame.events.push(event); } @@ -274,8 +290,9 @@ impl ReplayRecorder { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Replay { + map_idx: u8, players: HashMap, frames: Vec, } @@ -283,20 +300,51 @@ pub struct Replay { impl Default for Replay { fn default() -> Self { Self { + map_idx: Default::default(), players: Default::default(), frames: Default::default(), } } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReplayPlayer { pub id: u16, pub name: String, - pub color: Color, + pub color: ReplayColor, } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplayColor { + r: f32, + g: f32, + b: f32, + a: f32, +} + +impl From for ReplayColor { + fn from(value: Color) -> Self { + ReplayColor { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + } + } +} + +impl From for Color { + fn from(value: ReplayColor) -> Self { + Color { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct ReplayFrame { time: f64, player_data: HashMap, @@ -304,24 +352,24 @@ pub struct ReplayFrame { events: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum ReplayEvent { Death(DamageSource, u16), - Shoot(u16, Vector3), + Shoot(u16, NetVector3), Respawn(u16, ReplayPlayerState), Jump(u16), - Punch(u16, Vector3), + Punch(u16, NetVector3), TakeDamage(u16, DamageSource, i32), } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub enum ReplayBall { World(ReplayBallData), #[default] Held, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReplayPlayerState { pub transform: NetTransform, pub movement_dir: NetVector3, @@ -350,7 +398,7 @@ impl Player { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReplayBallData { transform: NetTransform, velocity: NetVector3, diff --git a/rust/src/ui/game_finished_screen.rs b/rust/src/ui/game_finished_screen.rs index cd3c772..7f7c807 100644 --- a/rust/src/ui/game_finished_screen.rs +++ b/rust/src/ui/game_finished_screen.rs @@ -1,5 +1,5 @@ use godot::{ - classes::{Button, HBoxContainer, IPanel, Label, Panel}, + classes::{Button, FileDialog, HBoxContainer, IPanel, Label, Panel}, prelude::*, }; @@ -18,6 +18,9 @@ pub struct GameFinishedScreen { #[export] player_listings: Option>, + #[export] + save_file_dialog: Option>, + base: Base, } @@ -36,10 +39,24 @@ impl IPanel for GameFinishedScreen { GameManager::singleton().bind().go_to_lobby(); }); } + if let Some(file_dialog) = self.save_file_dialog.clone() { + file_dialog + .signals() + .file_selected() + .connect_other(self, Self::save_replay); + } } } +#[godot_api] impl GameFinishedScreen { + #[func] + pub fn open_file_dialog(&mut self) { + if let Some(file_dialog) = &mut self.save_file_dialog { + file_dialog.popup_file_dialog(); + } + } + fn produce_title(&mut self) { if let Some(game) = Game::singleton() { let max_goals = game @@ -84,4 +101,12 @@ impl GameFinishedScreen { } } } + + pub fn save_replay(&mut self, path: GString) { + if let Some(game) = &Game::singleton() + && let Some(recorder) = &game.bind().replay_recorder + { + recorder.bind().save_to(&path); + } + } }