From 494500003978d1ca8b18709abeb941df68de1669 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 23:34:03 +0000 Subject: [PATCH 01/29] =?UTF-8?q?feat(spectator):=20=E8=A6=B3=E6=88=A6?= =?UTF-8?q?=E8=80=85=E3=81=8C=E4=BB=BB=E6=84=8F=E3=81=AE=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=9F=E3=83=B3=E3=82=B0=E3=81=A7=E9=80=94=E4=B8=AD=E5=8F=82?= =?UTF-8?q?=E5=8A=A0=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまで観戦者は難易度選択画面 (カード未選択時) でしか参加できなかった。 ホストが直近の同期ポイント (難易度選択画面・キャラクター選択画面に入った時点) の 状態と乱数シード、およびそれ以降の入力を記録しておき、途中参加した観戦者には それらを送信して早送りで再生させることで、キャラクター選択中や対戦中でも 参加できるようにする。 - 観戦者の受け付けを画面に関わらず行う (F1 も対戦中に受け付ける) - 観戦者側はホストからの初期化情報を非ブロッキングで待つ - キャラクター選択画面から同期する場合は難易度を決定して画面遷移し、 到達時にカード選択状態と乱数シードを再現する - 対戦中に未処理の入力が溜まっている場合は早送りで追いつく Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/session/session_message.rs | 2 +- junowen/src/session/spectator.rs | 59 ++++++- junowen/src/session/spectator_host.rs | 10 +- .../waiting_for_spectator.rs | 12 +- .../src/state/battle_session/battle_game.rs | 8 +- .../src/state/battle_session/battle_select.rs | 3 +- .../src/state/battle_session/in_session.rs | 31 ++-- .../state/battle_session/spectator_host.rs | 164 ++++++++++++------ junowen/src/state/spectator_session.rs | 4 + .../src/state/spectator_session/in_session.rs | 4 + .../state/spectator_session/spectator_game.rs | 8 + .../spectator_session/spectator_select.rs | 103 ++++++++--- 12 files changed, 289 insertions(+), 119 deletions(-) diff --git a/junowen/src/session/session_message.rs b/junowen/src/session/session_message.rs index baae8d4..616358e 100644 --- a/junowen/src/session/session_message.rs +++ b/junowen/src/session/session_message.rs @@ -6,7 +6,7 @@ pub struct MatchInitial { pub game_settings: GameSettings, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct RoundInitial { pub seed1: u32, pub seed2: u32, diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index 67cd8b5..bc82765 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -1,4 +1,7 @@ -use std::sync::mpsc::RecvError; +use std::{ + collections::VecDeque, + sync::mpsc::{RecvError, TryRecvError}, +}; use anyhow::Result; use derive_new::new; @@ -47,7 +50,7 @@ pub struct SpectatorInitial { initial_state: InitialState, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub enum SpectatorSessionMessage { InitSpectator(SpectatorInitial), InitRound(RoundInitial), @@ -58,6 +61,9 @@ pub enum SpectatorSessionMessage { pub struct SpectatorSession { _conn: PeerConnection, hook_incoming_rx: std::sync::mpsc::Receiver, + /// 受信済みで未処理のメッセージ。途中参加時の遅れを把握するために使う + buffer: VecDeque, + disconnected: bool, spectator_initial: Option, round_initial: Option, } @@ -69,6 +75,8 @@ impl SpectatorSession { Self { _conn: conn, hook_incoming_rx, + buffer: VecDeque::new(), + disconnected: false, spectator_initial: None, round_initial: None, } @@ -78,8 +86,45 @@ impl SpectatorSession { self.spectator_initial.as_ref() } - pub fn recv_init_spectator(&mut self) -> Result<(), RecvError> { - let init = match self.hook_incoming_rx.recv()? { + fn fill_buffer(&mut self) { + loop { + match self.hook_incoming_rx.try_recv() { + Ok(msg) => self.buffer.push_back(msg), + Err(TryRecvError::Empty) => return, + Err(TryRecvError::Disconnected) => { + self.disconnected = true; + return; + } + } + } + } + + fn next_message(&mut self) -> Result { + if let Some(msg) = self.buffer.pop_front() { + return Ok(msg); + } + self.hook_incoming_rx.recv() + } + + /// 受信済みで未処理のメッセージ数 + pub fn buffered_len(&mut self) -> usize { + self.fill_buffer(); + self.buffer.len() + } + + /// ホスト側の準備が整うまで待つ必要があるため、ブロックせずに受信を試みる + /// + /// 受信できた場合は `true` を返す + pub fn try_recv_init_spectator(&mut self) -> Result { + self.fill_buffer(); + let Some(msg) = self.buffer.pop_front() else { + return if self.disconnected { + Err(RecvError) + } else { + Ok(false) + }; + }; + let init = match msg { SpectatorSessionMessage::InitSpectator(init) => init, msg => { error!("unexpected message: {:?}", msg); @@ -87,7 +132,7 @@ impl SpectatorSession { } }; self.spectator_initial = Some(init); - Ok(()) + Ok(true) } pub fn dequeue_init_round(&mut self) -> Result { @@ -95,7 +140,7 @@ impl SpectatorSession { return Ok(round_initial); } loop { - match self.hook_incoming_rx.recv()? { + match self.next_message()? { SpectatorSessionMessage::InitSpectator(init) => { error!("unexpected init spectator message: {:?}", init); return Err(RecvError); @@ -110,7 +155,7 @@ impl SpectatorSession { if self.round_initial.is_some() { return Ok((0, 0)); } - match self.hook_incoming_rx.recv()? { + match self.next_message()? { SpectatorSessionMessage::InitSpectator(init) => { error!("unexpected init spectator message: {:?}", init); Err(RecvError) diff --git a/junowen/src/session/spectator_host.rs b/junowen/src/session/spectator_host.rs index 157a9af..5168f53 100644 --- a/junowen/src/session/spectator_host.rs +++ b/junowen/src/session/spectator_host.rs @@ -25,6 +25,10 @@ impl SpectatorHostSession { } } + pub fn send(&self, msg: SpectatorSessionMessage) -> Result<()> { + Ok(self.hook_outgoing_tx.send(msg)?) + } + pub fn send_init_spectator(&self, init: SpectatorInitial) -> Result<()> { Ok(self .hook_outgoing_tx @@ -36,12 +40,6 @@ impl SpectatorHostSession { .hook_outgoing_tx .send(SpectatorSessionMessage::InitRound(init))?) } - - pub fn send_inputs(&self, p1_input: u16, p2_input: u16) -> Result<()> { - Ok(self - .hook_outgoing_tx - .send(SpectatorSessionMessage::Inputs(p1_input, p2_input))?) - } } impl Drop for SpectatorHostSession { diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index 345b096..e5cddfd 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -8,7 +8,7 @@ use junowen_lib::{ SignalingCodeType, parse_signaling_code, socket::async_read_write_socket::SignalingServerMessage, }, - structs::app::{MainMenu, ScreenId}, + structs::app::MainMenu, }; use tokio::sync::mpsc::{self, error::TryRecvError}; use tracing::info; @@ -91,13 +91,9 @@ impl WaitingForPureP2pSpectator { main_menu: Option<&MainMenu>, th19: &Th19, ) -> Result<()> { - let selection = th19.selection(); - self.set_ready( - main_menu.is_some() - && main_menu.unwrap().screen_id() == ScreenId::DifficultySelect - && selection.p1().card == 0 - && selection.p2().card == 0, - ); + // 観戦者はいつでも受け付け、同期可能なタイミングまで待機させる。 + // 案内表示は対戦画面の邪魔にならないようメニュー画面でのみ行う + self.set_ready(main_menu.is_some()); match self { Self::Standby { pushed, .. } => { diff --git a/junowen/src/state/battle_session/battle_game.rs b/junowen/src/state/battle_session/battle_game.rs index 4502aa0..7eb35b9 100644 --- a/junowen/src/state/battle_session/battle_game.rs +++ b/junowen/src/state/battle_session/battle_game.rs @@ -3,7 +3,10 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use crate::{helper::inputed_number, session::battle::BattleSession}; +use crate::{ + helper::{inputed_number, pushed_f1}, + session::battle::BattleSession, +}; use super::{spectator_host::SpectatorHostState, utils::init_round}; @@ -27,6 +30,7 @@ impl BattleGame { .set_current(InputValue::empty()); return Ok(()); } + let current_pushed = pushed_f1(th19.input_devices()); let input_devices = th19.input_devices_mut(); let delay = if session.host() { inputed_number(input_devices) @@ -42,7 +46,7 @@ impl BattleGame { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(false, None, th19, session, p1, p2); + spectator_host_state.update(current_pushed, None, th19, session, p1, p2); Ok(()) } diff --git a/junowen/src/state/battle_session/battle_select.rs b/junowen/src/state/battle_session/battle_select.rs index fb4afc9..8a15541 100644 --- a/junowen/src/state/battle_session/battle_select.rs +++ b/junowen/src/state/battle_session/battle_select.rs @@ -66,6 +66,7 @@ impl BattleSelect { return Ok(()); } + let current_pushed = pushed_f1(th19.input_devices()); let input_devices = th19.input_devices_mut(); let delay = if session.host() { inputed_number(input_devices) @@ -81,7 +82,7 @@ impl BattleSelect { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(false, Some(main_menu), th19, session, p1, p2); + spectator_host_state.update(current_pushed, Some(main_menu), th19, session, p1, p2); Ok(()) } diff --git a/junowen/src/state/battle_session/in_session.rs b/junowen/src/state/battle_session/in_session.rs index ca2bde6..c178594 100644 --- a/junowen/src/state/battle_session/in_session.rs +++ b/junowen/src/state/battle_session/in_session.rs @@ -24,36 +24,41 @@ pub fn on_render_texts(th19: &Th19, text_renderer: &c_void, status: RenderingSta render_game_settings(th19, text_renderer, game_settings); } - let (msg2_rear, msg2_front) = if status.spectator_host_state.count_spectators() > 0 { - ( - " ", - Cow::Owned(format!( - "Spectator(s): {}", - status.spectator_host_state.count_spectators() - )), - ) + let spectators = status.spectator_host_state.count_spectators(); + let pending_spectators = status.spectator_host_state.count_pending_spectators(); + let (msg2_rear, msg2_front): (Cow, Cow) = if spectators > 0 || pending_spectators > 0 + { + let msg = if pending_spectators > 0 { + format!( + "Spectator(s): {} (+{} joining)", + spectators, pending_spectators + ) + } else { + format!("Spectator(s): {}", spectators) + }; + (" ".repeat(msg.len()).into(), msg.into()) } else { match status.spectator_host_state.waiting() { WaitingForSpectator::PureP2p(waiting) => match waiting { WaitingForPureP2pSpectator::Standby { ready: false, .. } | WaitingForPureP2pSpectator::SignalingCodeRecved { ready: false, .. } | WaitingForPureP2pSpectator::SignalingCodeSent { ready: false, .. } => { - ("", "".into()) + ("".into(), "".into()) } WaitingForPureP2pSpectator::Standby { .. } => ( - " __ ", + " __ ".into(), "(Press F1 to accept spectator from clipboard)".into(), ), WaitingForPureP2pSpectator::SignalingCodeRecved { .. } => ( - " ", + " ".into(), "(Generating signaling code...)".into(), ), WaitingForPureP2pSpectator::SignalingCodeSent { .. } => ( - " ", + " ".into(), "(Your signaling code has been copied to the clipboard)".into(), ), }, - WaitingForSpectator::ReservedRoom(_) => ("", "".into()), + WaitingForSpectator::ReservedRoom(_) => ("".into(), "".into()), } }; diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 7835082..16b61c5 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, bail}; +use anyhow::Result; use getset::Getters; use junowen_lib::{ Th19, @@ -11,7 +11,7 @@ use crate::{ session::{ RoundInitial, battle::BattleSession, - spectator::{self, InitialState, SpectatorInitial}, + spectator::{self, InitialState, SpectatorInitial, SpectatorSessionMessage}, spectator_host::SpectatorHostSession, }, signaling::waiting_for_match::WaitingForSpectator, @@ -57,11 +57,61 @@ fn create_spectator_initial( ) } +fn current_round_initial(th19: &Th19) -> RoundInitial { + RoundInitial { + seed1: th19.rand_seed1().unwrap(), + seed2: th19.rand_seed2().unwrap(), + seed3: th19.rand_seed3().unwrap(), + seed4: th19.rand_seed4().unwrap(), + } +} + +/// 同期ポイントにできる画面かどうかを判定する +/// +/// 画面に入った最初のフレームのみ同期ポイントにできる +/// - 難易度選択画面 (カード選択状態が初期状態の場合のみ) +/// - キャラクター選択画面 +fn sync_screen(screen_id: ScreenId, th19: &Th19) -> Option { + match screen_id { + ScreenId::DifficultySelect => { + let vs_mode = th19.vs_mode(); + (vs_mode.p1_card() == 0 && vs_mode.p2_card() == 0).then_some(ScreenId::DifficultySelect) + } + ScreenId::CharacterSelect => Some(ScreenId::CharacterSelect), + _ => None, + } +} + +/// 観戦者の途中参加用に、直近の同期ポイントの状態とそれ以降のメッセージを保持する +/// +/// 途中参加した観戦者は同期ポイントの状態から記録済みのメッセージを早送りで再生し、 +/// ホストに追いつく +struct SyncPoint { + spectator_initial: SpectatorInitial, + round_initial: RoundInitial, + messages: Vec, +} + +impl SyncPoint { + fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { + session.send_init_spectator(self.spectator_initial.clone())?; + session.send_init_round(self.round_initial.clone())?; + for msg in &self.messages { + session.send(msg.clone())?; + } + Ok(()) + } +} + #[derive(Getters)] pub struct SpectatorHostState { #[get = "pub"] waiting: WaitingForSpectator, sessions: Vec, + sync_point: Option, + /// 同期ポイントがまだ無い間に接続した観戦者 + pending_sessions: Vec, + prev_screen_id: Option, } impl SpectatorHostState { @@ -69,6 +119,9 @@ impl SpectatorHostState { Self { waiting, sessions: Vec::new(), + sync_point: None, + pending_sessions: Vec::new(), + prev_screen_id: None, } } @@ -76,57 +129,44 @@ impl SpectatorHostState { self.sessions.len() } - pub fn send_init_round_if_connected(&mut self, th19: &Th19) { + pub fn count_pending_spectators(&self) -> usize { + self.pending_sessions.len() + } + + fn broadcast(&mut self, msg: SpectatorSessionMessage) { self.sessions.retain(|session| { - if let Err(err) = session.send_init_round(RoundInitial { - seed1: th19.rand_seed1().unwrap(), - seed2: th19.rand_seed2().unwrap(), - seed3: th19.rand_seed3().unwrap(), - seed4: th19.rand_seed4().unwrap(), - }) { + if let Err(err) = session.send(msg.clone()) { info!("spectator host error: {:?}", err); - false - } else { - true + return false; } + true }); + if let Some(sync_point) = &mut self.sync_point { + sync_point.messages.push(msg); + } } - fn init_session( - &self, - session: &SpectatorHostSession, - battle_session: &BattleSession, - main_menu: Option<&MainMenu>, - th19: &Th19, - ) -> Result<()> { - let Some(main_menu) = main_menu else { - bail!("spectator not supported yet."); + pub fn send_init_round_if_connected(&mut self, th19: &Th19) { + self.broadcast(SpectatorSessionMessage::InitRound(current_round_initial( + th19, + ))); + } + + fn join(&mut self, session: SpectatorHostSession) { + let Some(sync_point) = &self.sync_point else { + info!("spectator connected. waiting for sync point"); + self.pending_sessions.push(session); + return; }; - let vs_mode = th19.vs_mode(); - if main_menu.screen_id() != ScreenId::DifficultySelect - || vs_mode.p1_card() != 0 - || vs_mode.p2_card() != 0 - { - bail!( - "spectator not supported yet. screen={:?}, p1_card={}, p2_card={}", - main_menu.screen_id(), - vs_mode.p1_card(), - vs_mode.p2_card() - ); + if let Err(err) = sync_point.send_to(&session) { + info!("initialize spectator failed: {:?}", err); + return; } - session.send_init_spectator(create_spectator_initial( - main_menu.screen_id(), - th19.selection(), - battle_session, - th19.vs_mode().player_name().to_string(), - ))?; - session.send_init_round(RoundInitial { - seed1: th19.rand_seed1().unwrap(), - seed2: th19.rand_seed2().unwrap(), - seed3: th19.rand_seed3().unwrap(), - seed4: th19.rand_seed4().unwrap(), - })?; - Ok(()) + info!( + "spectator joined. replaying {} messages", + sync_point.messages.len() + ); + self.sessions.push(session); } pub fn update( @@ -138,19 +178,29 @@ impl SpectatorHostState { p1_input: u16, p2_input: u16, ) { - if let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) { - if let Err(err) = self.init_session(&session, battle_session, main_menu, th19) { - info!("initialize spectator failed: {:?}", err); - } else { - self.sessions.push(session); + let screen_id = main_menu.map(|x| x.screen_id()); + let entered = screen_id.is_some() && screen_id != self.prev_screen_id; + self.prev_screen_id = screen_id; + if entered && let Some(screen) = sync_screen(screen_id.unwrap(), th19) { + self.sync_point = Some(SyncPoint { + spectator_initial: create_spectator_initial( + screen, + th19.selection(), + battle_session, + th19.vs_mode().player_name().to_string(), + ), + round_initial: current_round_initial(th19), + messages: Vec::new(), + }); + for session in std::mem::take(&mut self.pending_sessions) { + self.join(session); } } - self.sessions.retain(|session| { - if let Err(err) = session.send_inputs(p1_input, p2_input) { - info!("spectator host error: {:?}", err); - return false; - } - true - }); + + if let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) { + self.join(session); + } + + self.broadcast(SpectatorSessionMessage::Inputs(p1_input, p2_input)); } } diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 42b0c75..e2519df 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -165,6 +165,10 @@ impl SpectatorSession { pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { let Some(initial) = self.props.spectator_initial() else { + if matches!(&self.state, SpectatorSessionState::Select(select) if select.is_waiting_for_host()) + { + in_session::on_render_texts_waiting_for_host(th19, text_renderer); + } return; }; in_session::on_render_texts_spectator( diff --git a/junowen/src/state/spectator_session/in_session.rs b/junowen/src/state/spectator_session/in_session.rs index 252aca9..74e0f37 100644 --- a/junowen/src/state/spectator_session/in_session.rs +++ b/junowen/src/state/spectator_session/in_session.rs @@ -13,3 +13,7 @@ pub fn on_render_texts_spectator( render_names(th19, text_renderer, p1_name, p2_name); render_footer(th19, text_renderer, "(Spectating)", ""); } + +pub fn on_render_texts_waiting_for_host(th19: &Th19, text_renderer: &c_void) { + render_footer(th19, text_renderer, "(Waiting for host...)", ""); +} diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index d46e96e..56075e5 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -5,6 +5,9 @@ use junowen_lib::{Th19, structs::input_devices::InputValue}; use crate::session::spectator::SpectatorSession; +/// 未処理の入力がこのフレーム数を超えている場合、早送りして追いつく +const CATCH_UP_THRESHOLD: usize = 30; + pub struct SpectatorGame; impl SpectatorGame { @@ -24,6 +27,11 @@ impl SpectatorGame { .set_current(InputValue::empty()); return Ok(()); } + // 途中参加した場合は記録済みの入力が溜まっているので、早送りしてホストに追いつく + let no_wait = session.buffered_len() > CATCH_UP_THRESHOLD; + if th19.no_wait() != no_wait { + th19.set_no_wait(no_wait); + } let input_devices = th19.input_devices_mut(); let (p1, p2) = session.dequeue_inputs()?; input_devices diff --git a/junowen/src/state/spectator_session/spectator_select.rs b/junowen/src/state/spectator_session/spectator_select.rs index c2dd359..820e451 100644 --- a/junowen/src/state/spectator_session/spectator_select.rs +++ b/junowen/src/state/spectator_session/spectator_select.rs @@ -9,19 +9,37 @@ use junowen_lib::{ app::{MainMenu, ScreenId}, input_devices::InputValue, }, - th19_helpers::reset_cursors, + th19_helpers::{reset_cursors, shot_repeatedly}, }; use tracing::trace; -use crate::session::spectator::{self, SpectatorSession}; +use crate::session::{ + RoundInitial, + spectator::{self, SpectatorSession}, +}; + +fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { + th19.set_rand_seed1(round_initial.seed1).unwrap(); + th19.set_rand_seed2(round_initial.seed2).unwrap(); + th19.set_rand_seed3(round_initial.seed3).unwrap(); + th19.set_rand_seed4(round_initial.seed4).unwrap(); +} #[derive(new, Getters, MutGetters)] pub struct SpectatorSelect { + /// 0: 未初期化, 1: ホストからの初期化情報待ち, 2: 同期ポイントへ移動中, 3: 同期済み #[new(value = "0")] initializing_state: u8, + /// キャラクター選択画面から同期する場合に、画面到達時に設定する乱数シード + #[new(default)] + round_initial: Option, } impl SpectatorSelect { + pub fn is_waiting_for_host(&self) -> bool { + self.initializing_state <= 1 + } + pub fn update_th19_on_input_players( &mut self, session: &mut SpectatorSession, @@ -32,26 +50,53 @@ impl SpectatorSelect { if session.spectator_initial().is_none() { self.initializing_state = 1; reset_cursors(th19); - session.recv_init_spectator()?; } else { - self.initializing_state = 2; + self.initializing_state = 3; + let round_initial = session.dequeue_init_round()?; + set_rand_seeds(th19, &round_initial); + } + } + if self.initializing_state == 1 { + if !session.try_recv_init_spectator()? { + // ホストの準備が整うまで待機する + if th19.no_wait() { + th19.set_no_wait(false); + } + let input_devices = th19.input_devices_mut(); + input_devices + .p1_input_mut() + .set_current(InputValue::empty()); + input_devices + .p2_input_mut() + .set_current(InputValue::empty()); + return Ok(()); } let round_initial = session.dequeue_init_round()?; - th19.set_rand_seed1(round_initial.seed1).unwrap(); - th19.set_rand_seed2(round_initial.seed2).unwrap(); - th19.set_rand_seed3(round_initial.seed3).unwrap(); - th19.set_rand_seed4(round_initial.seed4).unwrap(); + set_rand_seeds(th19, &round_initial); + self.round_initial = Some(round_initial); + self.initializing_state = 2; } if main_menu.screen_id() == ScreenId::DifficultySelect { return Ok(()); } - if self.initializing_state == 1 { - let init = session.spectator_initial().unwrap(); - match init.initial_state().screen() { + if self.initializing_state == 2 { + let initial_state = session.spectator_initial().unwrap().initial_state().clone(); + match initial_state.screen() { spectator::Screen::DifficultySelect => { return Ok(()); } - spectator::Screen::CharacterSelect => unimplemented!(), + spectator::Screen::CharacterSelect => { + if main_menu.screen_id() != ScreenId::CharacterSelect { + return Ok(()); + } + // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する + let selection = th19.selection_mut(); + selection.p1_mut().card = initial_state.p1_card() as u32; + selection.p2_mut().card = initial_state.p2_card() as u32; + let round_initial = self.round_initial.take().unwrap(); + set_rand_seeds(th19, &round_initial); + self.initializing_state = 3; + } spectator::Screen::Game => unimplemented!(), } } @@ -80,25 +125,35 @@ impl SpectatorSelect { if main_menu.screen_id() != ScreenId::DifficultySelect { return Ok(()); } + if self.is_waiting_for_host() { + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(()); + } let menu = main_menu.menu_mut(); - if self.initializing_state == 1 { + if self.initializing_state == 2 { let init = session.spectator_initial().unwrap(); trace!("spectator_initial: {:?}", init); - let initial_state = init.initial_state(); + let initial_state = init.initial_state().clone(); + if menu.cursor() != initial_state.difficulty() as u32 { + menu.set_cursor(initial_state.difficulty() as u32); + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(()); + } + let selection = th19.selection_mut(); + selection.p1_mut().character = initial_state.p1_character() as u32; + selection.p2_mut().character = initial_state.p2_character() as u32; match initial_state.screen() { spectator::Screen::DifficultySelect => { - if menu.cursor() != initial_state.difficulty() as u32 { - menu.set_cursor(initial_state.difficulty() as u32); - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(()); - } - let selection = th19.selection_mut(); - selection.p1_mut().character = initial_state.p1_character() as u32; - selection.p2_mut().character = initial_state.p2_character() as u32; th19.set_no_wait(false); - self.initializing_state = 2; + self.round_initial = None; + self.initializing_state = 3; + } + spectator::Screen::CharacterSelect => { + // 難易度を決定してキャラクター選択画面へ進む + let prev = th19.menu_input().prev(); + th19.menu_input_mut().set_current(shot_repeatedly(prev)); + return Ok(()); } - spectator::Screen::CharacterSelect => unimplemented!(), spectator::Screen::Game => unimplemented!(), } } From d32969627e887313b3694acff9a46e6def0bd2ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:02:31 +0000 Subject: [PATCH 02/29] =?UTF-8?q?refactor(spectator):=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E3=81=97=E3=81=A6=E5=8F=AF=E8=AA=AD=E6=80=A7=E3=82=92=E6=94=B9?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync_screen を bool を返す is_sync_point に変更 - SpectatorSelect の initializing_state を enum 化し、 キャラクター選択画面経由の同期でのみ到達時に乱数シードを設定するよう整理 - WaitingForPureP2pSpectator の ready を実態に合わせて show_hint に改名 - pushed_f1 の戻り値の変数名を f1_pushed に改名 - SyncPoint のメッセージ蓄積量についてコメントを追記 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- .../waiting_for_spectator.rs | 32 ++--- .../src/state/battle_session/battle_game.rs | 4 +- .../src/state/battle_session/battle_select.rs | 8 +- .../src/state/battle_session/in_session.rs | 12 +- .../state/battle_session/spectator_host.rs | 21 +-- .../spectator_session/spectator_select.rs | 132 +++++++++++------- 6 files changed, 121 insertions(+), 88 deletions(-) diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index e5cddfd..6d422cd 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -38,26 +38,26 @@ fn try_start_signaling(th19: &Th19) -> Option { Some(WaitingForPureP2pSpectator::SignalingCodeRecved { signaling, session_rx, - ready: false, + show_hint: false, pushed: true, }) } pub enum WaitingForPureP2pSpectator { Standby { - ready: bool, + show_hint: bool, pushed: bool, }, SignalingCodeRecved { signaling: Signaling, session_rx: mpsc::Receiver, - ready: bool, + show_hint: bool, pushed: bool, }, SignalingCodeSent { _signaling: Signaling, session_rx: mpsc::Receiver, - ready: bool, + show_hint: bool, pushed: bool, }, } @@ -65,23 +65,23 @@ pub enum WaitingForPureP2pSpectator { impl WaitingForPureP2pSpectator { pub fn standby() -> Self { Self::Standby { - ready: false, + show_hint: false, pushed: false, } } fn dummy() -> Self { Self::Standby { - ready: false, + show_hint: false, pushed: false, } } - fn set_ready(&mut self, value: bool) { + fn set_show_hint(&mut self, value: bool) { match self { - Self::Standby { ready, .. } - | Self::SignalingCodeRecved { ready, .. } - | Self::SignalingCodeSent { ready, .. } => *ready = value, + Self::Standby { show_hint, .. } + | Self::SignalingCodeRecved { show_hint, .. } + | Self::SignalingCodeSent { show_hint, .. } => *show_hint = value, } } @@ -91,9 +91,9 @@ impl WaitingForPureP2pSpectator { main_menu: Option<&MainMenu>, th19: &Th19, ) -> Result<()> { - // 観戦者はいつでも受け付け、同期可能なタイミングまで待機させる。 - // 案内表示は対戦画面の邪魔にならないようメニュー画面でのみ行う - self.set_ready(main_menu.is_some()); + // 観戦者はいつでも受け付ける (合流のタイミングは `SpectatorHostState` が制御する)。 + // `show_hint` は案内表示の有無のみを表し、対戦画面の邪魔にならないようメニュー画面でのみ表示する + self.set_show_hint(main_menu.is_some()); match self { Self::Standby { pushed, .. } => { @@ -128,7 +128,7 @@ impl WaitingForPureP2pSpectator { let Self::SignalingCodeRecved { signaling, session_rx, - ready, + show_hint, pushed, } = mem::replace(self, Self::dummy()) else { @@ -137,7 +137,7 @@ impl WaitingForPureP2pSpectator { *self = Self::SignalingCodeSent { _signaling: signaling, session_rx, - ready, + show_hint, pushed, }; Ok(()) @@ -159,7 +159,7 @@ impl WaitingForPureP2pSpectator { if let Err(err) = self.update_inner(pushed, menu, th19) { info!("spectator host error: {:?}", err); *self = Self::Standby { - ready: false, + show_hint: false, pushed, }; } diff --git a/junowen/src/state/battle_session/battle_game.rs b/junowen/src/state/battle_session/battle_game.rs index 7eb35b9..5910319 100644 --- a/junowen/src/state/battle_session/battle_game.rs +++ b/junowen/src/state/battle_session/battle_game.rs @@ -30,7 +30,7 @@ impl BattleGame { .set_current(InputValue::empty()); return Ok(()); } - let current_pushed = pushed_f1(th19.input_devices()); + let f1_pushed = pushed_f1(th19.input_devices()); let input_devices = th19.input_devices_mut(); let delay = if session.host() { inputed_number(input_devices) @@ -46,7 +46,7 @@ impl BattleGame { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(current_pushed, None, th19, session, p1, p2); + spectator_host_state.update(f1_pushed, None, th19, session, p1, p2); Ok(()) } diff --git a/junowen/src/state/battle_session/battle_select.rs b/junowen/src/state/battle_session/battle_select.rs index 8a15541..a531f90 100644 --- a/junowen/src/state/battle_session/battle_select.rs +++ b/junowen/src/state/battle_session/battle_select.rs @@ -66,7 +66,7 @@ impl BattleSelect { return Ok(()); } - let current_pushed = pushed_f1(th19.input_devices()); + let f1_pushed = pushed_f1(th19.input_devices()); let input_devices = th19.input_devices_mut(); let delay = if session.host() { inputed_number(input_devices) @@ -82,7 +82,7 @@ impl BattleSelect { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(current_pushed, Some(main_menu), th19, session, p1, p2); + spectator_host_state.update(f1_pushed, Some(main_menu), th19, session, p1, p2); Ok(()) } @@ -110,8 +110,8 @@ impl BattleSelect { let input = if p1 != 0 { p1 } else { p2 }; menu_input.set_current((input as u32).try_into().unwrap()); - let current_pushed = pushed_f1(input_devices); - spectator_host_state.update(current_pushed, Some(main_menu), th19, session, p1, p2); + let f1_pushed = pushed_f1(input_devices); + spectator_host_state.update(f1_pushed, Some(main_menu), th19, session, p1, p2); Ok(()) } diff --git a/junowen/src/state/battle_session/in_session.rs b/junowen/src/state/battle_session/in_session.rs index c178594..f880831 100644 --- a/junowen/src/state/battle_session/in_session.rs +++ b/junowen/src/state/battle_session/in_session.rs @@ -40,11 +40,15 @@ pub fn on_render_texts(th19: &Th19, text_renderer: &c_void, status: RenderingSta } else { match status.spectator_host_state.waiting() { WaitingForSpectator::PureP2p(waiting) => match waiting { - WaitingForPureP2pSpectator::Standby { ready: false, .. } - | WaitingForPureP2pSpectator::SignalingCodeRecved { ready: false, .. } - | WaitingForPureP2pSpectator::SignalingCodeSent { ready: false, .. } => { - ("".into(), "".into()) + WaitingForPureP2pSpectator::Standby { + show_hint: false, .. } + | WaitingForPureP2pSpectator::SignalingCodeRecved { + show_hint: false, .. + } + | WaitingForPureP2pSpectator::SignalingCodeSent { + show_hint: false, .. + } => ("".into(), "".into()), WaitingForPureP2pSpectator::Standby { .. } => ( " __ ".into(), "(Press F1 to accept spectator from clipboard)".into(), diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 16b61c5..0e9b379 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -71,14 +71,14 @@ fn current_round_initial(th19: &Th19) -> RoundInitial { /// 画面に入った最初のフレームのみ同期ポイントにできる /// - 難易度選択画面 (カード選択状態が初期状態の場合のみ) /// - キャラクター選択画面 -fn sync_screen(screen_id: ScreenId, th19: &Th19) -> Option { +fn is_sync_point(screen_id: ScreenId, th19: &Th19) -> bool { match screen_id { ScreenId::DifficultySelect => { let vs_mode = th19.vs_mode(); - (vs_mode.p1_card() == 0 && vs_mode.p2_card() == 0).then_some(ScreenId::DifficultySelect) + vs_mode.p1_card() == 0 && vs_mode.p2_card() == 0 } - ScreenId::CharacterSelect => Some(ScreenId::CharacterSelect), - _ => None, + ScreenId::CharacterSelect => true, + _ => false, } } @@ -86,6 +86,9 @@ fn sync_screen(screen_id: ScreenId, th19: &Th19) -> Option { /// /// 途中参加した観戦者は同期ポイントの状態から記録済みのメッセージを早送りで再生し、 /// ホストに追いつく +/// +/// `messages` には毎フレームの入力が蓄積され続けるが、同期ポイントはキャラクター選択画面に +/// 入るたびに作り直されるため、蓄積量はおおむね 1 対戦分 (1 分あたり約 3,600 件) に収まる struct SyncPoint { spectator_initial: SpectatorInitial, round_initial: RoundInitial, @@ -179,12 +182,14 @@ impl SpectatorHostState { p2_input: u16, ) { let screen_id = main_menu.map(|x| x.screen_id()); - let entered = screen_id.is_some() && screen_id != self.prev_screen_id; - self.prev_screen_id = screen_id; - if entered && let Some(screen) = sync_screen(screen_id.unwrap(), th19) { + let prev_screen_id = std::mem::replace(&mut self.prev_screen_id, screen_id); + if let Some(screen_id) = screen_id + && Some(screen_id) != prev_screen_id + && is_sync_point(screen_id, th19) + { self.sync_point = Some(SyncPoint { spectator_initial: create_spectator_initial( - screen, + screen_id, th19.selection(), battle_session, th19.vs_mode().player_name().to_string(), diff --git a/junowen/src/state/spectator_session/spectator_select.rs b/junowen/src/state/spectator_session/spectator_select.rs index 820e451..43b5dc7 100644 --- a/junowen/src/state/spectator_session/spectator_select.rs +++ b/junowen/src/state/spectator_session/spectator_select.rs @@ -2,7 +2,6 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use derive_new::new; -use getset::{Getters, MutGetters}; use junowen_lib::{ Th19, structs::{ @@ -25,19 +24,34 @@ fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { th19.set_rand_seed4(round_initial.seed4).unwrap(); } -#[derive(new, Getters, MutGetters)] +enum InitializingState { + Uninitialized, + /// ホストからの初期化情報 (同期ポイントの状態) を待っている + WaitingForHost, + /// 難易度選択画面の同期ポイントの状態を再現している + SyncingToDifficultySelect, + /// キャラクター選択画面の同期ポイントへ移動している + /// + /// ホストの同期ポイントはキャラクター選択画面に入った時点なので、 + /// 難易度の決定や画面遷移を経た後、画面に到達した時点で乱数シードを設定する + SyncingToCharacterSelect { + round_initial: RoundInitial, + }, + Synced, +} + +#[derive(new)] pub struct SpectatorSelect { - /// 0: 未初期化, 1: ホストからの初期化情報待ち, 2: 同期ポイントへ移動中, 3: 同期済み - #[new(value = "0")] - initializing_state: u8, - /// キャラクター選択画面から同期する場合に、画面到達時に設定する乱数シード - #[new(default)] - round_initial: Option, + #[new(value = "InitializingState::Uninitialized")] + initializing_state: InitializingState, } impl SpectatorSelect { pub fn is_waiting_for_host(&self) -> bool { - self.initializing_state <= 1 + matches!( + self.initializing_state, + InitializingState::Uninitialized | InitializingState::WaitingForHost + ) } pub fn update_th19_on_input_players( @@ -46,17 +60,18 @@ impl SpectatorSelect { main_menu: &MainMenu, th19: &mut Th19, ) -> Result<(), RecvError> { - if self.initializing_state == 0 { + if let InitializingState::Uninitialized = self.initializing_state { if session.spectator_initial().is_none() { - self.initializing_state = 1; + self.initializing_state = InitializingState::WaitingForHost; reset_cursors(th19); } else { - self.initializing_state = 3; + // 対戦後にキャラクター選択画面へ戻ってきた let round_initial = session.dequeue_init_round()?; set_rand_seeds(th19, &round_initial); + self.initializing_state = InitializingState::Synced; } } - if self.initializing_state == 1 { + if let InitializingState::WaitingForHost = self.initializing_state { if !session.try_recv_init_spectator()? { // ホストの準備が整うまで待機する if th19.no_wait() { @@ -72,33 +87,42 @@ impl SpectatorSelect { return Ok(()); } let round_initial = session.dequeue_init_round()?; - set_rand_seeds(th19, &round_initial); - self.round_initial = Some(round_initial); - self.initializing_state = 2; + let screen = session + .spectator_initial() + .unwrap() + .initial_state() + .screen(); + self.initializing_state = match screen { + spectator::Screen::DifficultySelect => { + // 既に難易度選択画面にいるので、そのまま設定する + set_rand_seeds(th19, &round_initial); + InitializingState::SyncingToDifficultySelect + } + spectator::Screen::CharacterSelect => { + InitializingState::SyncingToCharacterSelect { round_initial } + } + spectator::Screen::Game => unimplemented!(), + }; } if main_menu.screen_id() == ScreenId::DifficultySelect { return Ok(()); } - if self.initializing_state == 2 { - let initial_state = session.spectator_initial().unwrap().initial_state().clone(); - match initial_state.screen() { - spectator::Screen::DifficultySelect => { + match &self.initializing_state { + InitializingState::Uninitialized | InitializingState::WaitingForHost => unreachable!(), + InitializingState::SyncingToDifficultySelect => return Ok(()), + InitializingState::SyncingToCharacterSelect { round_initial } => { + if main_menu.screen_id() != ScreenId::CharacterSelect { return Ok(()); } - spectator::Screen::CharacterSelect => { - if main_menu.screen_id() != ScreenId::CharacterSelect { - return Ok(()); - } - // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する - let selection = th19.selection_mut(); - selection.p1_mut().card = initial_state.p1_card() as u32; - selection.p2_mut().card = initial_state.p2_card() as u32; - let round_initial = self.round_initial.take().unwrap(); - set_rand_seeds(th19, &round_initial); - self.initializing_state = 3; - } - spectator::Screen::Game => unimplemented!(), + // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する + let initial_state = session.spectator_initial().unwrap().initial_state(); + let selection = th19.selection_mut(); + selection.p1_mut().card = initial_state.p1_card() as u32; + selection.p2_mut().card = initial_state.p2_card() as u32; + set_rand_seeds(th19, round_initial); + self.initializing_state = InitializingState::Synced; } + InitializingState::Synced => {} } if !th19.no_wait() { th19.set_no_wait(true); @@ -125,37 +149,37 @@ impl SpectatorSelect { if main_menu.screen_id() != ScreenId::DifficultySelect { return Ok(()); } - if self.is_waiting_for_host() { - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(()); - } - let menu = main_menu.menu_mut(); - if self.initializing_state == 2 { - let init = session.spectator_initial().unwrap(); - trace!("spectator_initial: {:?}", init); - let initial_state = init.initial_state().clone(); - if menu.cursor() != initial_state.difficulty() as u32 { - menu.set_cursor(initial_state.difficulty() as u32); + match &self.initializing_state { + InitializingState::Uninitialized | InitializingState::WaitingForHost => { + // 同期前はホストの入力を消費せず、観戦者の操作も受け付けない th19.menu_input_mut().set_current(InputValue::empty()); return Ok(()); } - let selection = th19.selection_mut(); - selection.p1_mut().character = initial_state.p1_character() as u32; - selection.p2_mut().character = initial_state.p2_character() as u32; - match initial_state.screen() { - spectator::Screen::DifficultySelect => { - th19.set_no_wait(false); - self.round_initial = None; - self.initializing_state = 3; + InitializingState::SyncingToDifficultySelect + | InitializingState::SyncingToCharacterSelect { .. } => { + let init = session.spectator_initial().unwrap(); + trace!("spectator_initial: {:?}", init); + let initial_state = init.initial_state(); + let menu = main_menu.menu_mut(); + if menu.cursor() != initial_state.difficulty() as u32 { + menu.set_cursor(initial_state.difficulty() as u32); + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(()); } - spectator::Screen::CharacterSelect => { + let selection = th19.selection_mut(); + selection.p1_mut().character = initial_state.p1_character() as u32; + selection.p2_mut().character = initial_state.p2_character() as u32; + if let InitializingState::SyncingToCharacterSelect { .. } = self.initializing_state + { // 難易度を決定してキャラクター選択画面へ進む let prev = th19.menu_input().prev(); th19.menu_input_mut().set_current(shot_repeatedly(prev)); return Ok(()); } - spectator::Screen::Game => unimplemented!(), + th19.set_no_wait(false); + self.initializing_state = InitializingState::Synced; } + InitializingState::Synced => {} } let (p1, p2) = session.dequeue_inputs()?; From f8b7908534224f582897e02324df7ed6e204b0e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:24:20 +0000 Subject: [PATCH 03/29] =?UTF-8?q?refactor(spectator):=20=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=82=92=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E3=81=97=E3=81=A6=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同期ポイントへの移動処理を SpectatorSelect から SpectatorSync 状態に切り出し、 SpectatorSelect を従来に近いシンプルな形に戻す - 実質到達しない pending_sessions と関連表示を削除し、 同期ポイントが無い場合は途中参加を拒否する - 同期ポイント以降の記録量に上限 (約 10 分) を設け、超えたら次の同期ポイントまで 途中参加を受け付けない - 対戦中の F1 受け付けをやめ、ready の改名も元に戻す - Screen::Game を削除して unimplemented!() によるパニック経路をなくす - buffered_len を副作用がわかる poll_buffered_len に改名 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/session/spectator.rs | 7 +- .../waiting_for_spectator.rs | 32 ++-- .../src/state/battle_session/battle_game.rs | 8 +- .../src/state/battle_session/in_session.rs | 41 ++--- .../state/battle_session/spectator_host.rs | 35 ++-- junowen/src/state/spectator_session.rs | 77 ++++++--- .../state/spectator_session/spectator_game.rs | 9 +- .../spectator_session/spectator_select.rs | 141 +--------------- .../state/spectator_session/spectator_sync.rs | 150 ++++++++++++++++++ 9 files changed, 276 insertions(+), 224 deletions(-) create mode 100644 junowen/src/state/spectator_session/spectator_sync.rs diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index bc82765..52be253 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -19,7 +19,6 @@ use super::{session_message::RoundInitial, to_channel}; pub enum Screen { DifficultySelect, CharacterSelect, - Game, } #[derive(new, Clone, Debug, Deserialize, CopyGetters, Serialize)] @@ -106,8 +105,10 @@ impl SpectatorSession { self.hook_incoming_rx.recv() } - /// 受信済みで未処理のメッセージ数 - pub fn buffered_len(&mut self) -> usize { + /// 受信済みで未処理のメッセージ数を返す + /// + /// 呼び出し時点で受信できるメッセージをすべてバッファへ取り込む + pub fn poll_buffered_len(&mut self) -> usize { self.fill_buffer(); self.buffer.len() } diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index 6d422cd..3ccd3e2 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -38,26 +38,26 @@ fn try_start_signaling(th19: &Th19) -> Option { Some(WaitingForPureP2pSpectator::SignalingCodeRecved { signaling, session_rx, - show_hint: false, + ready: false, pushed: true, }) } pub enum WaitingForPureP2pSpectator { Standby { - show_hint: bool, + ready: bool, pushed: bool, }, SignalingCodeRecved { signaling: Signaling, session_rx: mpsc::Receiver, - show_hint: bool, + ready: bool, pushed: bool, }, SignalingCodeSent { _signaling: Signaling, session_rx: mpsc::Receiver, - show_hint: bool, + ready: bool, pushed: bool, }, } @@ -65,23 +65,23 @@ pub enum WaitingForPureP2pSpectator { impl WaitingForPureP2pSpectator { pub fn standby() -> Self { Self::Standby { - show_hint: false, + ready: false, pushed: false, } } fn dummy() -> Self { Self::Standby { - show_hint: false, + ready: false, pushed: false, } } - fn set_show_hint(&mut self, value: bool) { + fn set_ready(&mut self, value: bool) { match self { - Self::Standby { show_hint, .. } - | Self::SignalingCodeRecved { show_hint, .. } - | Self::SignalingCodeSent { show_hint, .. } => *show_hint = value, + Self::Standby { ready, .. } + | Self::SignalingCodeRecved { ready, .. } + | Self::SignalingCodeSent { ready, .. } => *ready = value, } } @@ -91,9 +91,9 @@ impl WaitingForPureP2pSpectator { main_menu: Option<&MainMenu>, th19: &Th19, ) -> Result<()> { - // 観戦者はいつでも受け付ける (合流のタイミングは `SpectatorHostState` が制御する)。 - // `show_hint` は案内表示の有無のみを表し、対戦画面の邪魔にならないようメニュー画面でのみ表示する - self.set_show_hint(main_menu.is_some()); + // 観戦者の受け付けはメニュー画面 (難易度選択・キャラクター選択) で行う。 + // 合流のタイミングは `SpectatorHostState` が同期ポイントで制御する + self.set_ready(main_menu.is_some()); match self { Self::Standby { pushed, .. } => { @@ -128,7 +128,7 @@ impl WaitingForPureP2pSpectator { let Self::SignalingCodeRecved { signaling, session_rx, - show_hint, + ready, pushed, } = mem::replace(self, Self::dummy()) else { @@ -137,7 +137,7 @@ impl WaitingForPureP2pSpectator { *self = Self::SignalingCodeSent { _signaling: signaling, session_rx, - show_hint, + ready, pushed, }; Ok(()) @@ -159,7 +159,7 @@ impl WaitingForPureP2pSpectator { if let Err(err) = self.update_inner(pushed, menu, th19) { info!("spectator host error: {:?}", err); *self = Self::Standby { - show_hint: false, + ready: false, pushed, }; } diff --git a/junowen/src/state/battle_session/battle_game.rs b/junowen/src/state/battle_session/battle_game.rs index 5910319..4502aa0 100644 --- a/junowen/src/state/battle_session/battle_game.rs +++ b/junowen/src/state/battle_session/battle_game.rs @@ -3,10 +3,7 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use crate::{ - helper::{inputed_number, pushed_f1}, - session::battle::BattleSession, -}; +use crate::{helper::inputed_number, session::battle::BattleSession}; use super::{spectator_host::SpectatorHostState, utils::init_round}; @@ -30,7 +27,6 @@ impl BattleGame { .set_current(InputValue::empty()); return Ok(()); } - let f1_pushed = pushed_f1(th19.input_devices()); let input_devices = th19.input_devices_mut(); let delay = if session.host() { inputed_number(input_devices) @@ -46,7 +42,7 @@ impl BattleGame { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(f1_pushed, None, th19, session, p1, p2); + spectator_host_state.update(false, None, th19, session, p1, p2); Ok(()) } diff --git a/junowen/src/state/battle_session/in_session.rs b/junowen/src/state/battle_session/in_session.rs index f880831..ca2bde6 100644 --- a/junowen/src/state/battle_session/in_session.rs +++ b/junowen/src/state/battle_session/in_session.rs @@ -24,45 +24,36 @@ pub fn on_render_texts(th19: &Th19, text_renderer: &c_void, status: RenderingSta render_game_settings(th19, text_renderer, game_settings); } - let spectators = status.spectator_host_state.count_spectators(); - let pending_spectators = status.spectator_host_state.count_pending_spectators(); - let (msg2_rear, msg2_front): (Cow, Cow) = if spectators > 0 || pending_spectators > 0 - { - let msg = if pending_spectators > 0 { - format!( - "Spectator(s): {} (+{} joining)", - spectators, pending_spectators - ) - } else { - format!("Spectator(s): {}", spectators) - }; - (" ".repeat(msg.len()).into(), msg.into()) + let (msg2_rear, msg2_front) = if status.spectator_host_state.count_spectators() > 0 { + ( + " ", + Cow::Owned(format!( + "Spectator(s): {}", + status.spectator_host_state.count_spectators() + )), + ) } else { match status.spectator_host_state.waiting() { WaitingForSpectator::PureP2p(waiting) => match waiting { - WaitingForPureP2pSpectator::Standby { - show_hint: false, .. + WaitingForPureP2pSpectator::Standby { ready: false, .. } + | WaitingForPureP2pSpectator::SignalingCodeRecved { ready: false, .. } + | WaitingForPureP2pSpectator::SignalingCodeSent { ready: false, .. } => { + ("", "".into()) } - | WaitingForPureP2pSpectator::SignalingCodeRecved { - show_hint: false, .. - } - | WaitingForPureP2pSpectator::SignalingCodeSent { - show_hint: false, .. - } => ("".into(), "".into()), WaitingForPureP2pSpectator::Standby { .. } => ( - " __ ".into(), + " __ ", "(Press F1 to accept spectator from clipboard)".into(), ), WaitingForPureP2pSpectator::SignalingCodeRecved { .. } => ( - " ".into(), + " ", "(Generating signaling code...)".into(), ), WaitingForPureP2pSpectator::SignalingCodeSent { .. } => ( - " ".into(), + " ", "(Your signaling code has been copied to the clipboard)".into(), ), }, - WaitingForSpectator::ReservedRoom(_) => ("".into(), "".into()), + WaitingForSpectator::ReservedRoom(_) => ("", "".into()), } }; diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 0e9b379..f2cafa5 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -82,13 +82,17 @@ fn is_sync_point(screen_id: ScreenId, th19: &Th19) -> bool { } } +/// 同期ポイント以降に記録するメッセージ数の上限 (60fps で約 10 分) +/// +/// `messages` には毎フレームの入力が記録される。キャラクター選択画面で待機している間も +/// 増え続けるため、上限を超えたら同期ポイントを破棄し、次の同期ポイントまで途中参加を受け付けない。 +/// 記録量が多いほど、途中参加時に送る量と観戦者が追いつくまでの時間も増える +const MAX_SYNC_POINT_MESSAGES: usize = 60 * 60 * 10; + /// 観戦者の途中参加用に、直近の同期ポイントの状態とそれ以降のメッセージを保持する /// /// 途中参加した観戦者は同期ポイントの状態から記録済みのメッセージを早送りで再生し、 /// ホストに追いつく -/// -/// `messages` には毎フレームの入力が蓄積され続けるが、同期ポイントはキャラクター選択画面に -/// 入るたびに作り直されるため、蓄積量はおおむね 1 対戦分 (1 分あたり約 3,600 件) に収まる struct SyncPoint { spectator_initial: SpectatorInitial, round_initial: RoundInitial, @@ -111,9 +115,8 @@ pub struct SpectatorHostState { #[get = "pub"] waiting: WaitingForSpectator, sessions: Vec, + /// 途中参加を受け付けられない間は `None` sync_point: Option, - /// 同期ポイントがまだ無い間に接続した観戦者 - pending_sessions: Vec, prev_screen_id: Option, } @@ -123,7 +126,6 @@ impl SpectatorHostState { waiting, sessions: Vec::new(), sync_point: None, - pending_sessions: Vec::new(), prev_screen_id: None, } } @@ -132,10 +134,6 @@ impl SpectatorHostState { self.sessions.len() } - pub fn count_pending_spectators(&self) -> usize { - self.pending_sessions.len() - } - fn broadcast(&mut self, msg: SpectatorSessionMessage) { self.sessions.retain(|session| { if let Err(err) = session.send(msg.clone()) { @@ -145,7 +143,14 @@ impl SpectatorHostState { true }); if let Some(sync_point) = &mut self.sync_point { - sync_point.messages.push(msg); + if sync_point.messages.len() >= MAX_SYNC_POINT_MESSAGES { + info!( + "too many messages since sync point. spectators cannot join until next sync point" + ); + self.sync_point = None; + } else { + sync_point.messages.push(msg); + } } } @@ -157,8 +162,9 @@ impl SpectatorHostState { fn join(&mut self, session: SpectatorHostSession) { let Some(sync_point) = &self.sync_point else { - info!("spectator connected. waiting for sync point"); - self.pending_sessions.push(session); + // 最初の同期ポイントは難易度選択画面の最初のフレームで作られるため、 + // ここに来るのは記録量が上限を超えた場合のみ + info!("spectator rejected. no sync point"); return; }; if let Err(err) = sync_point.send_to(&session) { @@ -197,9 +203,6 @@ impl SpectatorHostState { round_initial: current_round_initial(th19), messages: Vec::new(), }); - for session in std::mem::take(&mut self.pending_sessions) { - self.join(session); - } } if let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) { diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index e2519df..bf69947 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -1,6 +1,7 @@ mod in_session; mod spectator_game; mod spectator_select; +mod spectator_sync; use std::{ffi::c_void, sync::mpsc::RecvError}; @@ -14,11 +15,20 @@ use junowen_lib::{ }, }; -use crate::session::spectator::SpectatorSession as SpectatorSessionProps; +use crate::session::{RoundInitial, spectator::SpectatorSession as SpectatorSessionProps}; use super::prepare::Prepare; -use {spectator_game::SpectatorGame, spectator_select::SpectatorSelect}; +use { + spectator_game::SpectatorGame, spectator_select::SpectatorSelect, spectator_sync::SpectatorSync, +}; + +fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { + th19.set_rand_seed1(round_initial.seed1).unwrap(); + th19.set_rand_seed2(round_initial.seed2).unwrap(); + th19.set_rand_seed3(round_initial.seed3).unwrap(); + th19.set_rand_seed4(round_initial.seed4).unwrap(); +} pub struct SpectatorSession { props: SpectatorSessionProps, @@ -27,6 +37,7 @@ pub struct SpectatorSession { enum SpectatorSessionState { Prepare(Prepare), + Sync(SpectatorSync), Select(SpectatorSelect), GameLoading, Game(SpectatorGame), @@ -45,8 +56,11 @@ impl SpectatorSession { self.props.spectator_initial().map(|x| x.game_settings()) } - pub fn change_to_select(&mut self) { - self.state = SpectatorSessionState::Select(SpectatorSelect::new()); + pub fn change_to_sync(&mut self) { + self.state = SpectatorSessionState::Sync(SpectatorSync::new()); + } + pub fn change_to_select(&mut self, recv_round_initial: bool) { + self.state = SpectatorSessionState::Select(SpectatorSelect::new(recv_round_initial)); } pub fn change_to_game_loading(&mut self) { self.state = SpectatorSessionState::GameLoading; @@ -65,11 +79,11 @@ impl SpectatorSession { return Some(None); }; if prepare.update_state(main_menu, th19) { - self.change_to_select(); + self.change_to_sync(); } Some(Some(main_menu)) } - SpectatorSessionState::Select { .. } => { + SpectatorSessionState::Sync(_) | SpectatorSessionState::Select(_) => { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, @@ -113,7 +127,7 @@ impl SpectatorSession { if main_menu.screen_id() != ScreenId::CharacterSelect { return Some(Some(main_menu)); } - self.change_to_select(); + self.change_to_select(true); Some(Some(main_menu)) } } @@ -124,8 +138,16 @@ impl SpectatorSession { menu: Option<&MainMenu>, th19: &mut Th19, ) -> Result<(), RecvError> { + if let SpectatorSessionState::Sync(sync) = &mut self.state { + if !sync.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? { + return Ok(()); + } + // 同期が完了したフレームからホストの入力を再生する + self.change_to_select(false); + } match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_players(th19), + SpectatorSessionState::Sync(_) => unreachable!(), SpectatorSessionState::Select(select) => { select.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? } @@ -143,18 +165,8 @@ impl SpectatorSession { pub fn on_input_menu(&mut self, th19: &mut Th19) -> Result { match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_menu(th19), - SpectatorSessionState::Select(select) => { - let main_menu = th19 - .app_mut() - .main_loop_tasks_mut() - .find_main_menu_mut() - .unwrap(); - if main_menu.screen_id() == ScreenId::DifficultySelect - && th19.menu_input().current().0 & InputFlags::PAUSE != None - { - return Ok(false); - } - select.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; + SpectatorSessionState::Sync(_) | SpectatorSessionState::Select(_) => { + return self.on_input_menu_in_select(th19); } SpectatorSessionState::GameLoading { .. } => {} SpectatorSessionState::Game { .. } => {} @@ -163,9 +175,34 @@ impl SpectatorSession { Ok(true) } + fn on_input_menu_in_select(&mut self, th19: &mut Th19) -> Result { + let main_menu = th19 + .app_mut() + .main_loop_tasks_mut() + .find_main_menu_mut() + .unwrap(); + if main_menu.screen_id() == ScreenId::DifficultySelect + && th19.menu_input().current().0 & InputFlags::PAUSE != None + { + return Ok(false); + } + if let SpectatorSessionState::Sync(sync) = &mut self.state { + if !sync.update_th19_on_input_menu(&mut self.props, main_menu, th19)? { + return Ok(true); + } + // 同期が完了したフレームからホストの入力を再生する + self.change_to_select(false); + } + let SpectatorSessionState::Select(select) = &mut self.state else { + unreachable!() + }; + select.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; + Ok(true) + } + pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { let Some(initial) = self.props.spectator_initial() else { - if matches!(&self.state, SpectatorSessionState::Select(select) if select.is_waiting_for_host()) + if matches!(&self.state, SpectatorSessionState::Sync(sync) if sync.is_waiting_for_host()) { in_session::on_render_texts_waiting_for_host(th19, text_renderer); } diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index 56075e5..55c19df 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -5,6 +5,8 @@ use junowen_lib::{Th19, structs::input_devices::InputValue}; use crate::session::spectator::SpectatorSession; +use super::set_rand_seeds; + /// 未処理の入力がこのフレーム数を超えている場合、早送りして追いつく const CATCH_UP_THRESHOLD: usize = 30; @@ -28,7 +30,7 @@ impl SpectatorGame { return Ok(()); } // 途中参加した場合は記録済みの入力が溜まっているので、早送りしてホストに追いつく - let no_wait = session.buffered_len() > CATCH_UP_THRESHOLD; + let no_wait = session.poll_buffered_len() > CATCH_UP_THRESHOLD; if th19.no_wait() != no_wait { th19.set_no_wait(no_wait); } @@ -49,10 +51,7 @@ impl SpectatorGame { th19: &mut Th19, ) -> Result<(), RecvError> { let init = session.dequeue_init_round()?; - th19.set_rand_seed1(init.seed1).unwrap(); - th19.set_rand_seed2(init.seed2).unwrap(); - th19.set_rand_seed3(init.seed3).unwrap(); - th19.set_rand_seed4(init.seed4).unwrap(); + set_rand_seeds(th19, &init); Ok(()) } } diff --git a/junowen/src/state/spectator_session/spectator_select.rs b/junowen/src/state/spectator_session/spectator_select.rs index 43b5dc7..356f3d2 100644 --- a/junowen/src/state/spectator_session/spectator_select.rs +++ b/junowen/src/state/spectator_session/spectator_select.rs @@ -4,126 +4,34 @@ use anyhow::Result; use derive_new::new; use junowen_lib::{ Th19, - structs::{ - app::{MainMenu, ScreenId}, - input_devices::InputValue, - }, - th19_helpers::{reset_cursors, shot_repeatedly}, + structs::app::{MainMenu, ScreenId}, }; -use tracing::trace; -use crate::session::{ - RoundInitial, - spectator::{self, SpectatorSession}, -}; +use crate::session::spectator::SpectatorSession; -fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { - th19.set_rand_seed1(round_initial.seed1).unwrap(); - th19.set_rand_seed2(round_initial.seed2).unwrap(); - th19.set_rand_seed3(round_initial.seed3).unwrap(); - th19.set_rand_seed4(round_initial.seed4).unwrap(); -} - -enum InitializingState { - Uninitialized, - /// ホストからの初期化情報 (同期ポイントの状態) を待っている - WaitingForHost, - /// 難易度選択画面の同期ポイントの状態を再現している - SyncingToDifficultySelect, - /// キャラクター選択画面の同期ポイントへ移動している - /// - /// ホストの同期ポイントはキャラクター選択画面に入った時点なので、 - /// 難易度の決定や画面遷移を経た後、画面に到達した時点で乱数シードを設定する - SyncingToCharacterSelect { - round_initial: RoundInitial, - }, - Synced, -} +use super::set_rand_seeds; #[derive(new)] pub struct SpectatorSelect { - #[new(value = "InitializingState::Uninitialized")] - initializing_state: InitializingState, + /// 対戦後に戻ってきた場合は、最初のフレームでラウンドの初期化情報を受け取る + recv_round_initial: bool, } impl SpectatorSelect { - pub fn is_waiting_for_host(&self) -> bool { - matches!( - self.initializing_state, - InitializingState::Uninitialized | InitializingState::WaitingForHost - ) - } - pub fn update_th19_on_input_players( &mut self, session: &mut SpectatorSession, main_menu: &MainMenu, th19: &mut Th19, ) -> Result<(), RecvError> { - if let InitializingState::Uninitialized = self.initializing_state { - if session.spectator_initial().is_none() { - self.initializing_state = InitializingState::WaitingForHost; - reset_cursors(th19); - } else { - // 対戦後にキャラクター選択画面へ戻ってきた - let round_initial = session.dequeue_init_round()?; - set_rand_seeds(th19, &round_initial); - self.initializing_state = InitializingState::Synced; - } - } - if let InitializingState::WaitingForHost = self.initializing_state { - if !session.try_recv_init_spectator()? { - // ホストの準備が整うまで待機する - if th19.no_wait() { - th19.set_no_wait(false); - } - let input_devices = th19.input_devices_mut(); - input_devices - .p1_input_mut() - .set_current(InputValue::empty()); - input_devices - .p2_input_mut() - .set_current(InputValue::empty()); - return Ok(()); - } + if self.recv_round_initial { + self.recv_round_initial = false; let round_initial = session.dequeue_init_round()?; - let screen = session - .spectator_initial() - .unwrap() - .initial_state() - .screen(); - self.initializing_state = match screen { - spectator::Screen::DifficultySelect => { - // 既に難易度選択画面にいるので、そのまま設定する - set_rand_seeds(th19, &round_initial); - InitializingState::SyncingToDifficultySelect - } - spectator::Screen::CharacterSelect => { - InitializingState::SyncingToCharacterSelect { round_initial } - } - spectator::Screen::Game => unimplemented!(), - }; + set_rand_seeds(th19, &round_initial); } if main_menu.screen_id() == ScreenId::DifficultySelect { return Ok(()); } - match &self.initializing_state { - InitializingState::Uninitialized | InitializingState::WaitingForHost => unreachable!(), - InitializingState::SyncingToDifficultySelect => return Ok(()), - InitializingState::SyncingToCharacterSelect { round_initial } => { - if main_menu.screen_id() != ScreenId::CharacterSelect { - return Ok(()); - } - // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する - let initial_state = session.spectator_initial().unwrap().initial_state(); - let selection = th19.selection_mut(); - selection.p1_mut().card = initial_state.p1_card() as u32; - selection.p2_mut().card = initial_state.p2_card() as u32; - set_rand_seeds(th19, round_initial); - self.initializing_state = InitializingState::Synced; - } - InitializingState::Synced => {} - } if !th19.no_wait() { th19.set_no_wait(true); } @@ -149,39 +57,6 @@ impl SpectatorSelect { if main_menu.screen_id() != ScreenId::DifficultySelect { return Ok(()); } - match &self.initializing_state { - InitializingState::Uninitialized | InitializingState::WaitingForHost => { - // 同期前はホストの入力を消費せず、観戦者の操作も受け付けない - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(()); - } - InitializingState::SyncingToDifficultySelect - | InitializingState::SyncingToCharacterSelect { .. } => { - let init = session.spectator_initial().unwrap(); - trace!("spectator_initial: {:?}", init); - let initial_state = init.initial_state(); - let menu = main_menu.menu_mut(); - if menu.cursor() != initial_state.difficulty() as u32 { - menu.set_cursor(initial_state.difficulty() as u32); - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(()); - } - let selection = th19.selection_mut(); - selection.p1_mut().character = initial_state.p1_character() as u32; - selection.p2_mut().character = initial_state.p2_character() as u32; - if let InitializingState::SyncingToCharacterSelect { .. } = self.initializing_state - { - // 難易度を決定してキャラクター選択画面へ進む - let prev = th19.menu_input().prev(); - th19.menu_input_mut().set_current(shot_repeatedly(prev)); - return Ok(()); - } - th19.set_no_wait(false); - self.initializing_state = InitializingState::Synced; - } - InitializingState::Synced => {} - } - let (p1, p2) = session.dequeue_inputs()?; let input = if p1 != 0 { p1 } else { p2 }; th19.menu_input_mut() diff --git a/junowen/src/state/spectator_session/spectator_sync.rs b/junowen/src/state/spectator_session/spectator_sync.rs new file mode 100644 index 0000000..2e161f8 --- /dev/null +++ b/junowen/src/state/spectator_session/spectator_sync.rs @@ -0,0 +1,150 @@ +use std::sync::mpsc::RecvError; + +use anyhow::Result; +use junowen_lib::{ + Th19, + structs::{ + app::{MainMenu, ScreenId}, + input_devices::InputValue, + }, + th19_helpers::{reset_cursors, shot_repeatedly}, +}; +use tracing::trace; + +use crate::session::{ + RoundInitial, + spectator::{self, SpectatorSession}, +}; + +use super::set_rand_seeds; + +enum SpectatorSyncState { + /// ホストからの初期化情報 (同期ポイントの状態) を待っている + WaitingForHost, + /// 難易度選択画面の同期ポイントの状態を再現している + ToDifficultySelect, + /// キャラクター選択画面の同期ポイントへ移動している + /// + /// ホストの同期ポイントはキャラクター選択画面に入った時点なので、 + /// 難易度の決定や画面遷移を経た後、画面に到達した時点で乱数シードを設定する + ToCharacterSelect { round_initial: RoundInitial }, +} + +/// 観戦開始時に、ホストの同期ポイントと同じ状態まで移動する +/// +/// 同期が完了したら、以降は `SpectatorSelect` がホストの入力を再生する +pub struct SpectatorSync { + cursors_reset: bool, + state: SpectatorSyncState, +} + +impl SpectatorSync { + pub fn new() -> Self { + Self { + cursors_reset: false, + state: SpectatorSyncState::WaitingForHost, + } + } + + pub fn is_waiting_for_host(&self) -> bool { + matches!(self.state, SpectatorSyncState::WaitingForHost) + } + + /// 同期が完了したら `true` を返す。完了したフレームからホストの入力を再生する必要がある + pub fn update_th19_on_input_players( + &mut self, + session: &mut SpectatorSession, + main_menu: &MainMenu, + th19: &mut Th19, + ) -> Result { + if !self.cursors_reset { + self.cursors_reset = true; + reset_cursors(th19); + } + if let SpectatorSyncState::WaitingForHost = self.state { + if !session.try_recv_init_spectator()? { + if th19.no_wait() { + th19.set_no_wait(false); + } + let input_devices = th19.input_devices_mut(); + input_devices + .p1_input_mut() + .set_current(InputValue::empty()); + input_devices + .p2_input_mut() + .set_current(InputValue::empty()); + return Ok(false); + } + let round_initial = session.dequeue_init_round()?; + let screen = session + .spectator_initial() + .unwrap() + .initial_state() + .screen(); + self.state = match screen { + spectator::Screen::DifficultySelect => { + // 既に難易度選択画面にいるので、そのまま設定する + set_rand_seeds(th19, &round_initial); + SpectatorSyncState::ToDifficultySelect + } + spectator::Screen::CharacterSelect => { + SpectatorSyncState::ToCharacterSelect { round_initial } + } + }; + } + let SpectatorSyncState::ToCharacterSelect { round_initial } = &self.state else { + return Ok(false); + }; + if main_menu.screen_id() != ScreenId::CharacterSelect { + return Ok(false); + } + // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する + let initial_state = session.spectator_initial().unwrap().initial_state(); + let selection = th19.selection_mut(); + selection.p1_mut().card = initial_state.p1_card() as u32; + selection.p2_mut().card = initial_state.p2_card() as u32; + set_rand_seeds(th19, round_initial); + Ok(true) + } + + /// 同期が完了したら `true` を返す。完了したフレームからホストの入力を再生する必要がある + pub fn update_th19_on_input_menu( + &mut self, + session: &mut SpectatorSession, + main_menu: &mut MainMenu, + th19: &mut Th19, + ) -> Result { + if main_menu.screen_id() != ScreenId::DifficultySelect { + return Ok(false); + } + if let SpectatorSyncState::WaitingForHost = self.state { + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(false); + } + let init = session.spectator_initial().unwrap(); + trace!("spectator_initial: {:?}", init); + let initial_state = init.initial_state(); + let menu = main_menu.menu_mut(); + if menu.cursor() != initial_state.difficulty() as u32 { + menu.set_cursor(initial_state.difficulty() as u32); + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(false); + } + let selection = th19.selection_mut(); + selection.p1_mut().character = initial_state.p1_character() as u32; + selection.p2_mut().character = initial_state.p2_character() as u32; + match self.state { + SpectatorSyncState::WaitingForHost => unreachable!(), + SpectatorSyncState::ToDifficultySelect => { + th19.set_no_wait(false); + Ok(true) + } + SpectatorSyncState::ToCharacterSelect { .. } => { + // 難易度を決定してキャラクター選択画面へ進む + let prev = th19.menu_input().prev(); + th19.menu_input_mut().set_current(shot_repeatedly(prev)); + Ok(false) + } + } + } +} From 74910441e41baf7d073f56b454fd010ce10dd304 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:35:12 +0000 Subject: [PATCH 04/29] =?UTF-8?q?feat(spectator):=20=E6=8E=A5=E7=B6=9A?= =?UTF-8?q?=E6=99=82=E7=82=B9=E3=81=AE=E7=8A=B6=E6=85=8B=E3=82=92=E9=80=81?= =?UTF-8?q?=E3=82=8B=E6=96=B9=E5=BC=8F=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97?= =?UTF-8?q?=E3=80=81=E8=A6=B3=E6=88=A6=E7=94=A8=E3=83=97=E3=83=AD=E3=83=88?= =?UTF-8?q?=E3=82=B3=E3=83=AB=E3=82=92=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入力ログを記録・再生する方式をやめ、観戦者が接続した時点の状態を送る方式にする。 - キャラクター選択画面では、カーソル位置・カード・各プレイヤーの進行段階 (キャラクター選択/カード選択/準備完了) を送る。進行段階はメモリ上の値が 未解析のため、ホストが決定キーとキャンセルキーの押下から推定する - 観戦者は決定キーを自動で押して進行段階を再現してから、ホストの入力を再生する - 対戦中など状態を送れない間に接続した観戦者は、次に状態を送れるまで待機させる - 入力ログとその上限を削除 - 観戦用の接続のデータチャネルプロトコルを JUNOWEN/1.2 にし、 旧バージョンの観戦者を接続時に拒否する (対戦は JUNOWEN/1.1 のまま) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/connection.rs | 5 +- junowen-lib/src/connection/peer_connection.rs | 21 +- .../src/connection/signaling/socket.rs | 14 +- .../signaling/socket/channel_socket.rs | 7 + junowen-lib/src/th19/structs/app.rs | 2 +- junowen/src/lobby/pure_p2p_guest.rs | 11 +- junowen/src/lobby/pure_p2p_offerer.rs | 6 +- junowen/src/lobby/pure_p2p_offerer/role.rs | 4 +- junowen/src/session/spectator.rs | 59 +++- junowen/src/session/spectator_host.rs | 10 +- junowen/src/signaling.rs | 3 +- .../reserved_room_spectator_host_socket.rs | 13 +- .../reserved_room_spectator_socket.rs | 13 +- .../waiting_for_spectator.rs | 11 +- .../state/battle_session/spectator_host.rs | 272 ++++++++++-------- .../state/spectator_session/spectator_sync.rs | 112 ++++++-- 16 files changed, 382 insertions(+), 181 deletions(-) diff --git a/junowen-lib/src/connection.rs b/junowen-lib/src/connection.rs index 374567d..b4dbfc7 100644 --- a/junowen-lib/src/connection.rs +++ b/junowen-lib/src/connection.rs @@ -2,4 +2,7 @@ mod data_channel; mod peer_connection; pub mod signaling; -pub use self::{data_channel::DataChannel, peer_connection::PeerConnection}; +pub use self::{ + data_channel::DataChannel, + peer_connection::{BATTLE_PROTOCOL, PeerConnection, SPECTATOR_PROTOCOL}, +}; diff --git a/junowen-lib/src/connection/peer_connection.rs b/junowen-lib/src/connection/peer_connection.rs index ca188a7..c1f221d 100644 --- a/junowen-lib/src/connection/peer_connection.rs +++ b/junowen-lib/src/connection/peer_connection.rs @@ -54,6 +54,7 @@ pub struct PeerConnection { peer_connection_state_disconnected_rx: Option>, peer_connection_state_failed_rx: Option>, data_channel_rx: Option>, + protocol: &'static str, } impl Drop for PeerConnection { @@ -77,10 +78,20 @@ impl Drop for PeerConnection { } } -const PROTOCOL: &str = "JUNOWEN/1.1"; +/// 対戦用の接続のプロトコル。互換性のない変更をしたら上げる +pub const BATTLE_PROTOCOL: &str = "JUNOWEN/1.1"; +/// 観戦用の接続のプロトコル。互換性のない変更をしたら上げる +/// +/// 1.2: キャラクター選択画面からの途中参加に対応 +pub const SPECTATOR_PROTOCOL: &str = "JUNOWEN/1.2"; impl PeerConnection { - pub async fn new(timeout: Duration, ice_server_urls: Vec) -> Result { + /// `protocol` はデータチャネルのプロトコルで、相手と一致しない場合は接続を拒否する + pub async fn new( + timeout: Duration, + ice_server_urls: Vec, + protocol: &'static str, + ) -> Result { let rtc = create_default_peer_connection(timeout, ice_server_urls).await?; let (peer_connection_state_failed_tx, peer_connection_state_failed_rx) = oneshot::channel(); @@ -123,6 +134,7 @@ impl PeerConnection { peer_connection_state_failed_rx: Some(peer_connection_state_failed_rx), peer_connection_state_disconnected_rx: Some(peer_connection_state_disconnected_rx), data_channel_rx: None, + protocol, }) } @@ -136,7 +148,7 @@ impl PeerConnection { .create_data_channel( "data", Some(RTCDataChannelInit { - protocol: Some(PROTOCOL.to_owned()), + protocol: Some(self.protocol.to_owned()), ..Default::default() }), ) @@ -198,10 +210,11 @@ impl PeerConnection { } pub async fn wait_for_open_data_channel(&mut self) -> Result { + let protocol = self.protocol; let data_channel_task = async { let mut data_channel = self.data_channel_rx.take().unwrap().await.unwrap(); data_channel.wait_for_open_data_channel().await; - if data_channel.protocol() != PROTOCOL { + if data_channel.protocol() != protocol { bail!("unexpected protocol: {}", data_channel.protocol()); } Ok(data_channel) diff --git a/junowen-lib/src/connection/signaling/socket.rs b/junowen-lib/src/connection/signaling/socket.rs index fc9c36b..733e5c1 100644 --- a/junowen-lib/src/connection/signaling/socket.rs +++ b/junowen-lib/src/connection/signaling/socket.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use crate::connection::data_channel::DataChannel; -use super::super::peer_connection::PeerConnection; +use super::super::peer_connection::{BATTLE_PROTOCOL, PeerConnection}; use super::CompressedSdp; @@ -36,8 +36,14 @@ pub trait SignalingSocket { vec![DEFAULT_STUN_SERVER_URL.to_owned()] } + /// データチャネルのプロトコル。観戦用の接続では `SPECTATOR_PROTOCOL` を返す + fn protocol(&self) -> &'static str { + BATTLE_PROTOCOL + } + async fn receive_signaling(&mut self) -> Result<(PeerConnection, DataChannel, bool)> { - let mut conn = PeerConnection::new(Self::timeout(), self.ice_server_urls()).await?; + let mut conn = + PeerConnection::new(Self::timeout(), self.ice_server_urls(), self.protocol()).await?; let offer_desc = conn .start_as_offerer() .await @@ -51,7 +57,9 @@ pub trait SignalingSocket { (conn, true) } OfferResponse::Offer(offer_desc) => { - let mut conn = PeerConnection::new(Self::timeout(), self.ice_server_urls()).await?; + let mut conn = + PeerConnection::new(Self::timeout(), self.ice_server_urls(), self.protocol()) + .await?; let answer_desc = conn .start_as_answerer(offer_desc) .await diff --git a/junowen-lib/src/connection/signaling/socket/channel_socket.rs b/junowen-lib/src/connection/signaling/socket/channel_socket.rs index 3191eb6..f85164d 100644 --- a/junowen-lib/src/connection/signaling/socket/channel_socket.rs +++ b/junowen-lib/src/connection/signaling/socket/channel_socket.rs @@ -13,6 +13,7 @@ pub struct ChannelSocket { offer_sender: Option>, answer_sender: Option>, message_receiver: Option>, + protocol: &'static str, } impl ChannelSocket { @@ -20,11 +21,13 @@ impl ChannelSocket { offer_sender: oneshot::Sender, answer_sender: oneshot::Sender, message_receiver: oneshot::Receiver, + protocol: &'static str, ) -> Self { Self { offer_sender: Some(offer_sender), answer_sender: Some(answer_sender), message_receiver: Some(message_receiver), + protocol, } } } @@ -35,6 +38,10 @@ impl SignalingSocket for ChannelSocket { Duration::from_secs(20 * 60) } + fn protocol(&self) -> &'static str { + self.protocol + } + async fn offer(&mut self, desc: CompressedSdp) -> Result { self.offer_sender.take().unwrap().send(desc).unwrap(); Ok(match self.message_receiver.take().unwrap().await? { diff --git a/junowen-lib/src/th19/structs/app.rs b/junowen-lib/src/th19/structs/app.rs index 25dd369..bc1f6c0 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/junowen-lib/src/th19/structs/app.rs @@ -53,7 +53,7 @@ pub struct CharacterCursor { _unknown1: [u8; 0xd0], } -#[derive(CopyGetters, Debug, MutGetters, Setters)] +#[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] #[repr(C)] pub struct Menu { #[getset(get_copy = "pub", get_mut = "pub", set = "pub")] diff --git a/junowen/src/lobby/pure_p2p_guest.rs b/junowen/src/lobby/pure_p2p_guest.rs index 4c3d155..54aecbd 100644 --- a/junowen/src/lobby/pure_p2p_guest.rs +++ b/junowen/src/lobby/pure_p2p_guest.rs @@ -3,8 +3,9 @@ use std::ffi::c_void; use clipboard_win::set_clipboard_string; use junowen_lib::{ Th19, - connection::signaling::{ - SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage, + connection::{ + BATTLE_PROTOCOL, + signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, structs::input_devices::InputValue, }; @@ -45,7 +46,11 @@ impl PureP2pGuest { 0, ), ), - signaling: Signaling::new(session_tx, |conn, dc| BattleSession::new(conn, dc, false)), + signaling: Signaling::new( + session_tx, + |conn, dc| BattleSession::new(conn, dc, false), + BATTLE_PROTOCOL, + ), session_rx: Some(session_rx), offer: None, answer_generated: false, diff --git a/junowen/src/lobby/pure_p2p_offerer.rs b/junowen/src/lobby/pure_p2p_offerer.rs index adb2e27..530357a 100644 --- a/junowen/src/lobby/pure_p2p_offerer.rs +++ b/junowen/src/lobby/pure_p2p_offerer.rs @@ -35,6 +35,7 @@ pub struct PureP2pOfferer { offer_type: SignalingCodeType, answer_type: SignalingCodeType, create_session: fn(PeerConnection, DataChannel) -> T, + protocol: &'static str, messages: Messages, common_menu: CommonMenu, signaling: Signaling, @@ -52,6 +53,7 @@ where offer_type: SignalingCodeType, answer_type: SignalingCodeType, create_session: fn(PeerConnection, DataChannel) -> T, + protocol: &'static str, label: &'static str, messages: Messages, ) -> Self { @@ -60,9 +62,10 @@ where offer_type, answer_type, create_session, + protocol, messages, common_menu: make_menu(label), - signaling: Signaling::new(session_tx, create_session), + signaling: Signaling::new(session_tx, create_session, protocol), session_rx: Some(session_rx), answer: None, copy_state: 0, @@ -185,6 +188,7 @@ where self.offer_type, self.answer_type, self.create_session, + self.protocol, self.common_menu.root_title(), self.messages, ); diff --git a/junowen/src/lobby/pure_p2p_offerer/role.rs b/junowen/src/lobby/pure_p2p_offerer/role.rs index 10d902b..3b78649 100644 --- a/junowen/src/lobby/pure_p2p_offerer/role.rs +++ b/junowen/src/lobby/pure_p2p_offerer/role.rs @@ -1,4 +1,4 @@ -use junowen_lib::connection::signaling::SignalingCodeType; +use junowen_lib::connection::{BATTLE_PROTOCOL, SPECTATOR_PROTOCOL, signaling::SignalingCodeType}; use crate::session::{battle::BattleSession, spectator::SpectatorSession}; @@ -17,6 +17,7 @@ pub fn pure_p2p_host() -> PureP2pOfferer { SignalingCodeType::BattleOffer, SignalingCodeType::BattleAnswer, |pc, dc| BattleSession::new(pc, dc, true), + BATTLE_PROTOCOL, "Connect as a Host", Messages { share: "pure_p2p.host_share", @@ -31,6 +32,7 @@ pub fn pure_p2p_spectator() -> PureP2pOfferer { SignalingCodeType::SpectatorOffer, SignalingCodeType::SpectatorAnswer, SpectatorSession::new, + SPECTATOR_PROTOCOL, "Connect as a Spectator", Messages { share: "pure_p2p.spectator_share", diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index 52be253..fc0fd3e 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -8,7 +8,7 @@ use derive_new::new; use getset::{CopyGetters, Getters, Setters}; use junowen_lib::{ connection::{DataChannel, PeerConnection}, - structs::settings::GameSettings, + structs::settings::{AbilityCard, GameSettings}, }; use serde::{Deserialize, Serialize}; use tracing::{error, info}; @@ -21,6 +21,53 @@ pub enum Screen { CharacterSelect, } +/// キャラクター選択画面での各プレイヤーの進行段階 +/// +/// メモリ上の値は未解析のため、ホストが決定キーとキャンセルキーの押下から推定する +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +pub enum CharacterSelectPhase { + #[default] + Character, + Card, + Ready, +} + +impl CharacterSelectPhase { + pub fn decided(self, has_card_phase: bool) -> Self { + match self { + Self::Character if has_card_phase => Self::Card, + Self::Character | Self::Card | Self::Ready => Self::Ready, + } + } + + pub fn canceled(self, has_card_phase: bool) -> Self { + match self { + Self::Ready if has_card_phase => Self::Card, + Self::Character | Self::Card | Self::Ready => Self::Character, + } + } +} + +/// カード選択の段階があるかどうか +/// +/// TODO: 実機で確認する。`Random` はカードが自動で決まるため選択の段階がないと仮定している +pub fn has_card_select_phase(game_settings: &GameSettings) -> bool { + matches!( + game_settings.ability_card(), + AbilityCard::SelfCard | AbilityCard::AllCard + ) +} + +#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, Serialize)] +pub struct PlayerInitialState { + #[get_copy = "pub"] + character: u8, + #[get_copy = "pub"] + card: u8, + #[get_copy = "pub"] + phase: CharacterSelectPhase, +} + #[derive(new, Clone, Debug, Deserialize, CopyGetters, Serialize)] pub struct InitialState { #[get_copy = "pub"] @@ -28,13 +75,9 @@ pub struct InitialState { #[get_copy = "pub"] difficulty: u8, #[get_copy = "pub"] - p1_character: u8, - #[get_copy = "pub"] - p1_card: u8, - #[get_copy = "pub"] - p2_character: u8, + p1: PlayerInitialState, #[get_copy = "pub"] - p2_card: u8, + p2: PlayerInitialState, } #[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] @@ -49,7 +92,7 @@ pub struct SpectatorInitial { initial_state: InitialState, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub enum SpectatorSessionMessage { InitSpectator(SpectatorInitial), InitRound(RoundInitial), diff --git a/junowen/src/session/spectator_host.rs b/junowen/src/session/spectator_host.rs index 5168f53..157a9af 100644 --- a/junowen/src/session/spectator_host.rs +++ b/junowen/src/session/spectator_host.rs @@ -25,10 +25,6 @@ impl SpectatorHostSession { } } - pub fn send(&self, msg: SpectatorSessionMessage) -> Result<()> { - Ok(self.hook_outgoing_tx.send(msg)?) - } - pub fn send_init_spectator(&self, init: SpectatorInitial) -> Result<()> { Ok(self .hook_outgoing_tx @@ -40,6 +36,12 @@ impl SpectatorHostSession { .hook_outgoing_tx .send(SpectatorSessionMessage::InitRound(init))?) } + + pub fn send_inputs(&self, p1_input: u16, p2_input: u16) -> Result<()> { + Ok(self + .hook_outgoing_tx + .send(SpectatorSessionMessage::Inputs(p1_input, p2_input))?) + } } impl Drop for SpectatorHostSession { diff --git a/junowen/src/signaling.rs b/junowen/src/signaling.rs index f34a120..971a8c1 100644 --- a/junowen/src/signaling.rs +++ b/junowen/src/signaling.rs @@ -39,6 +39,7 @@ impl Signaling { pub fn new( session_tx: mpsc::Sender, create_session: fn(PeerConnection, DataChannel) -> T, + protocol: &'static str, ) -> Self where T: Send + 'static, @@ -49,7 +50,7 @@ impl Signaling { let (error_tx, error_rx) = oneshot::channel(); let (connected_tx, connected_rx) = oneshot::channel(); TOKIO_RUNTIME.spawn(async move { - let mut socket = ChannelSocket::new(offer_tx, answer_tx, msg_rx); + let mut socket = ChannelSocket::new(offer_tx, answer_tx, msg_rx, protocol); let (conn, dc, _host) = match socket.receive_signaling().await { Ok(ok) => ok, Err(err) => { diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs b/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs index c52becb..1beb47e 100644 --- a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs +++ b/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs @@ -4,9 +4,12 @@ use anyhow::{Result, bail}; use async_trait::async_trait; use junowen_lib::{ - connection::signaling::{ - CompressedSdp, - socket::{OfferResponse, SignalingSocket}, + connection::{ + SPECTATOR_PROTOCOL, + signaling::{ + CompressedSdp, + socket::{OfferResponse, SignalingSocket}, + }, }, signaling_server::reserved_room::{ PostReservedRoomKeepRequestBody, PostReservedRoomKeepResponse, @@ -60,6 +63,10 @@ impl SignalingSocket for SignalingServerReservedRoomSpectatorHostSocket { Duration::from_secs(10) } + fn protocol(&self) -> &'static str { + SPECTATOR_PROTOCOL + } + async fn offer(&mut self, desc: CompressedSdp) -> Result { let key = self.key.clone(); let url = format!("{}/keep", self.resource_url); diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs b/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs index 9914bca..eabad77 100644 --- a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs +++ b/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs @@ -3,9 +3,12 @@ use std::time::Duration; use anyhow::{Error, Result, bail}; use async_trait::async_trait; use junowen_lib::{ - connection::signaling::{ - CompressedSdp, - socket::{OfferResponse, SignalingSocket}, + connection::{ + SPECTATOR_PROTOCOL, + signaling::{ + CompressedSdp, + socket::{OfferResponse, SignalingSocket}, + }, }, signaling_server::reserved_room::{ GetReservedRoomResponse, PostReservedRoomSpectateRequestBody, @@ -52,6 +55,10 @@ impl SignalingSocket for SignalingServerReservedRoomSpectatorSocket { Duration::from_secs(10) } + fn protocol(&self) -> &'static str { + SPECTATOR_PROTOCOL + } + async fn offer(&mut self, _desc: CompressedSdp) -> Result { loop { info!("GET {}", self.resource_url); diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index 3ccd3e2..dbecc78 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -4,9 +4,12 @@ use anyhow::Result; use clipboard_win::{get_clipboard_string, set_clipboard_string}; use junowen_lib::{ Th19, - connection::signaling::{ - SignalingCodeType, parse_signaling_code, - socket::async_read_write_socket::SignalingServerMessage, + connection::{ + SPECTATOR_PROTOCOL, + signaling::{ + SignalingCodeType, parse_signaling_code, + socket::async_read_write_socket::SignalingServerMessage, + }, }, structs::app::MainMenu, }; @@ -27,7 +30,7 @@ fn try_start_signaling(th19: &Th19) -> Option { return None; }; let (session_tx, session_rx) = mpsc::channel(1); - let mut signaling = Signaling::new(session_tx, SpectatorHostSession::new); + let mut signaling = Signaling::new(session_tx, SpectatorHostSession::new, SPECTATOR_PROTOCOL); signaling .msg_tx_mut() .take() diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index f2cafa5..1e44ba3 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,9 +1,11 @@ -use anyhow::Result; use getset::Getters; use junowen_lib::{ Th19, - structs::app::{MainMenu, ScreenId}, - structs::selection::Selection, + structs::{ + app::{MainMenu, ScreenId}, + input_devices::{InputFlags, InputValue}, + selection::Player, + }, }; use tracing::info; @@ -11,52 +13,15 @@ use crate::{ session::{ RoundInitial, battle::BattleSession, - spectator::{self, InitialState, SpectatorInitial, SpectatorSessionMessage}, + spectator::{ + self, CharacterSelectPhase, InitialState, PlayerInitialState, SpectatorInitial, + has_card_select_phase, + }, spectator_host::SpectatorHostSession, }, signaling::waiting_for_match::WaitingForSpectator, }; -fn create_spectator_initial( - current_screen: ScreenId, - selection: &Selection, - battle_session: &BattleSession, - local_player_name: String, -) -> SpectatorInitial { - let p1_name = if battle_session.host() { - local_player_name.to_owned() - } else { - battle_session.remote_player_name().clone() - }; - let p2_name = if battle_session.host() { - battle_session.remote_player_name().clone() - } else { - local_player_name.to_owned() - }; - SpectatorInitial::new( - p1_name, - p2_name, - battle_session - .match_initial() - .as_ref() - .unwrap() - .game_settings - .clone(), - InitialState::new( - match current_screen { - ScreenId::DifficultySelect => spectator::Screen::DifficultySelect, - ScreenId::CharacterSelect => spectator::Screen::CharacterSelect, - _ => unreachable!(), - }, - selection.difficulty as u8, - selection.p1().character as u8, - selection.p1().card as u8, - selection.p2().character as u8, - selection.p2().card as u8, - ), - ) -} - fn current_round_initial(th19: &Th19) -> RoundInitial { RoundInitial { seed1: th19.rand_seed1().unwrap(), @@ -66,47 +31,28 @@ fn current_round_initial(th19: &Th19) -> RoundInitial { } } -/// 同期ポイントにできる画面かどうかを判定する -/// -/// 画面に入った最初のフレームのみ同期ポイントにできる -/// - 難易度選択画面 (カード選択状態が初期状態の場合のみ) -/// - キャラクター選択画面 -fn is_sync_point(screen_id: ScreenId, th19: &Th19) -> bool { - match screen_id { - ScreenId::DifficultySelect => { - let vs_mode = th19.vs_mode(); - vs_mode.p1_card() == 0 && vs_mode.p2_card() == 0 - } - ScreenId::CharacterSelect => true, - _ => false, - } +fn pushed(prev: u16, current: u16, flag: InputFlags) -> bool { + let bits = InputValue::from(flag).bits() as u16; + prev & bits == 0 && current & bits != 0 } -/// 同期ポイント以降に記録するメッセージ数の上限 (60fps で約 10 分) -/// -/// `messages` には毎フレームの入力が記録される。キャラクター選択画面で待機している間も -/// 増え続けるため、上限を超えたら同期ポイントを破棄し、次の同期ポイントまで途中参加を受け付けない。 -/// 記録量が多いほど、途中参加時に送る量と観戦者が追いつくまでの時間も増える -const MAX_SYNC_POINT_MESSAGES: usize = 60 * 60 * 10; - -/// 観戦者の途中参加用に、直近の同期ポイントの状態とそれ以降のメッセージを保持する -/// -/// 途中参加した観戦者は同期ポイントの状態から記録済みのメッセージを早送りで再生し、 -/// ホストに追いつく -struct SyncPoint { - spectator_initial: SpectatorInitial, - round_initial: RoundInitial, - messages: Vec, +/// キャラクター選択画面での各プレイヤーの進行段階を、決定キーとキャンセルキーの押下から推定する +#[derive(Default)] +struct CharacterSelectTracker { + phases: [CharacterSelectPhase; 2], + prev_inputs: [u16; 2], } -impl SyncPoint { - fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { - session.send_init_spectator(self.spectator_initial.clone())?; - session.send_init_round(self.round_initial.clone())?; - for msg in &self.messages { - session.send(msg.clone())?; +impl CharacterSelectTracker { + fn update(&mut self, inputs: [u16; 2], has_card_phase: bool) { + for ((phase, prev), current) in self.phases.iter_mut().zip(self.prev_inputs).zip(inputs) { + if pushed(prev, current, InputFlags::SHOT) { + *phase = phase.decided(has_card_phase); + } else if pushed(prev, current, InputFlags::BOMB) { + *phase = phase.canceled(has_card_phase); + } } - Ok(()) + self.prev_inputs = inputs; } } @@ -115,8 +61,9 @@ pub struct SpectatorHostState { #[get = "pub"] waiting: WaitingForSpectator, sessions: Vec, - /// 途中参加を受け付けられない間は `None` - sync_point: Option, + /// 対戦中など、状態を送れない間に接続した観戦者 + pending_sessions: Vec, + character_select: CharacterSelectTracker, prev_screen_id: Option, } @@ -125,7 +72,8 @@ impl SpectatorHostState { Self { waiting, sessions: Vec::new(), - sync_point: None, + pending_sessions: Vec::new(), + character_select: CharacterSelectTracker::default(), prev_screen_id: None, } } @@ -134,50 +82,120 @@ impl SpectatorHostState { self.sessions.len() } - fn broadcast(&mut self, msg: SpectatorSessionMessage) { + pub fn send_init_round_if_connected(&mut self, th19: &Th19) { + let round_initial = current_round_initial(th19); self.sessions.retain(|session| { - if let Err(err) = session.send(msg.clone()) { + if let Err(err) = session.send_init_round(round_initial.clone()) { info!("spectator host error: {:?}", err); return false; } true }); - if let Some(sync_point) = &mut self.sync_point { - if sync_point.messages.len() >= MAX_SYNC_POINT_MESSAGES { - info!( - "too many messages since sync point. spectators cannot join until next sync point" - ); - self.sync_point = None; - } else { - sync_point.messages.push(msg); + } + + /// 観戦者に送る現在の状態を作る。状態を送れない画面では `None` を返す + /// + /// - 難易度選択画面 (カード選択状態が初期状態の場合のみ) + /// - キャラクター選択画面 + fn create_initial_state(&self, main_menu: &MainMenu, th19: &Th19) -> Option { + let selection = th19.selection(); + match main_menu.screen_id() { + ScreenId::DifficultySelect => { + let vs_mode = th19.vs_mode(); + if vs_mode.p1_card() != 0 || vs_mode.p2_card() != 0 { + return None; + } + let player = |player: &Player| { + PlayerInitialState::new( + player.character as u8, + player.card as u8, + CharacterSelectPhase::Character, + ) + }; + Some(InitialState::new( + spectator::Screen::DifficultySelect, + main_menu.menu().cursor() as u8, + player(selection.p1()), + player(selection.p2()), + )) + } + ScreenId::CharacterSelect => { + // キャラクター選択画面では `selection` のキャラクターが有効でないため、カーソル位置を使う + let menu = main_menu.menu(); + let [p1_phase, p2_phase] = self.character_select.phases; + Some(InitialState::new( + spectator::Screen::CharacterSelect, + selection.difficulty as u8, + PlayerInitialState::new( + menu.p1_cursor().cursor as u8, + selection.p1().card as u8, + p1_phase, + ), + PlayerInitialState::new( + menu.p2_cursor().cursor as u8, + selection.p2().card as u8, + p2_phase, + ), + )) } + _ => None, } } - pub fn send_init_round_if_connected(&mut self, th19: &Th19) { - self.broadcast(SpectatorSessionMessage::InitRound(current_round_initial( - th19, - ))); + fn create_spectator_initial( + initial_state: InitialState, + battle_session: &BattleSession, + th19: &Th19, + ) -> SpectatorInitial { + let local_player_name = th19.vs_mode().player_name().to_string(); + let remote_player_name = battle_session.remote_player_name().clone(); + let (p1_name, p2_name) = if battle_session.host() { + (local_player_name, remote_player_name) + } else { + (remote_player_name, local_player_name) + }; + SpectatorInitial::new( + p1_name, + p2_name, + battle_session + .match_initial() + .as_ref() + .unwrap() + .game_settings + .clone(), + initial_state, + ) } - fn join(&mut self, session: SpectatorHostSession) { - let Some(sync_point) = &self.sync_point else { - // 最初の同期ポイントは難易度選択画面の最初のフレームで作られるため、 - // ここに来るのは記録量が上限を超えた場合のみ - info!("spectator rejected. no sync point"); + /// 保留中の観戦者に現在の状態を送り、観戦を開始させる + fn join_pending_sessions( + &mut self, + main_menu: Option<&MainMenu>, + th19: &Th19, + battle_session: &BattleSession, + ) { + if self.pending_sessions.is_empty() { return; - }; - if let Err(err) = sync_point.send_to(&session) { - info!("initialize spectator failed: {:?}", err); + } + let Some(initial_state) = main_menu.and_then(|x| self.create_initial_state(x, th19)) else { return; + }; + let spectator_initial = Self::create_spectator_initial(initial_state, battle_session, th19); + let round_initial = current_round_initial(th19); + for session in std::mem::take(&mut self.pending_sessions) { + let result = session + .send_init_spectator(spectator_initial.clone()) + .and_then(|_| session.send_init_round(round_initial.clone())); + if let Err(err) = result { + info!("initialize spectator failed: {:?}", err); + continue; + } + info!("spectator joined. {:?}", spectator_initial.initial_state()); + self.sessions.push(session); } - info!( - "spectator joined. replaying {} messages", - sync_point.messages.len() - ); - self.sessions.push(session); } + /// `p1_input` と `p2_input` はこのフレームで適用される入力 pub fn update( &mut self, pushed: bool, @@ -189,26 +207,30 @@ impl SpectatorHostState { ) { let screen_id = main_menu.map(|x| x.screen_id()); let prev_screen_id = std::mem::replace(&mut self.prev_screen_id, screen_id); - if let Some(screen_id) = screen_id - && Some(screen_id) != prev_screen_id - && is_sync_point(screen_id, th19) + if screen_id == Some(ScreenId::CharacterSelect) + && prev_screen_id != Some(ScreenId::CharacterSelect) { - self.sync_point = Some(SyncPoint { - spectator_initial: create_spectator_initial( - screen_id, - th19.selection(), - battle_session, - th19.vs_mode().player_name().to_string(), - ), - round_initial: current_round_initial(th19), - messages: Vec::new(), - }); + self.character_select = CharacterSelectTracker::default(); } if let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) { - self.join(session); + self.pending_sessions.push(session); } + // 状態はこのフレームの入力を適用する前のものなので、入力の送信より先に送る + self.join_pending_sessions(main_menu, th19, battle_session); - self.broadcast(SpectatorSessionMessage::Inputs(p1_input, p2_input)); + self.sessions.retain(|session| { + if let Err(err) = session.send_inputs(p1_input, p2_input) { + info!("spectator host error: {:?}", err); + return false; + } + true + }); + + if screen_id == Some(ScreenId::CharacterSelect) { + let game_settings = &battle_session.match_initial().unwrap().game_settings; + self.character_select + .update([p1_input, p2_input], has_card_select_phase(game_settings)); + } } } diff --git a/junowen/src/state/spectator_session/spectator_sync.rs b/junowen/src/state/spectator_session/spectator_sync.rs index 2e161f8..c3e99be 100644 --- a/junowen/src/state/spectator_session/spectator_sync.rs +++ b/junowen/src/state/spectator_session/spectator_sync.rs @@ -5,7 +5,7 @@ use junowen_lib::{ Th19, structs::{ app::{MainMenu, ScreenId}, - input_devices::InputValue, + input_devices::{InputFlags, InputValue}, }, th19_helpers::{reset_cursors, shot_repeatedly}, }; @@ -13,21 +13,35 @@ use tracing::trace; use crate::session::{ RoundInitial, - spectator::{self, SpectatorSession}, + spectator::{self, CharacterSelectPhase, SpectatorSession, has_card_select_phase}, }; use super::set_rand_seeds; +/// 進行段階を再現するときに、決定キーを押す間隔 (フレーム) +/// +/// 画面の演出中に押すと無視される可能性があるため、間隔を空ける +const PRESS_INTERVAL_FRAMES: u32 = 20; + +/// キャラクター選択画面で、ホストの各プレイヤーの進行段階を決定キーの押下で再現する +struct PhaseReproduction { + phases: [CharacterSelectPhase; 2], + wait: u32, +} + enum SpectatorSyncState { /// ホストからの初期化情報 (同期ポイントの状態) を待っている WaitingForHost, /// 難易度選択画面の同期ポイントの状態を再現している ToDifficultySelect, - /// キャラクター選択画面の同期ポイントへ移動している + /// キャラクター選択画面へ移動し、ホストの状態を再現している /// - /// ホストの同期ポイントはキャラクター選択画面に入った時点なので、 - /// 難易度の決定や画面遷移を経た後、画面に到達した時点で乱数シードを設定する - ToCharacterSelect { round_initial: RoundInitial }, + /// ホストの状態はキャラクター選択画面の途中のものなので、難易度の決定や画面遷移、 + /// 進行段階の再現を経た後に乱数シードを設定する + ToCharacterSelect { + round_initial: RoundInitial, + reproduction: Option, + }, } /// 観戦開始時に、ホストの同期ポイントと同じ状態まで移動する @@ -87,24 +101,84 @@ impl SpectatorSync { set_rand_seeds(th19, &round_initial); SpectatorSyncState::ToDifficultySelect } - spectator::Screen::CharacterSelect => { - SpectatorSyncState::ToCharacterSelect { round_initial } - } + spectator::Screen::CharacterSelect => SpectatorSyncState::ToCharacterSelect { + round_initial, + reproduction: None, + }, }; } - let SpectatorSyncState::ToCharacterSelect { round_initial } = &self.state else { + let SpectatorSyncState::ToCharacterSelect { + round_initial, + reproduction, + } = &mut self.state + else { return Ok(false); }; if main_menu.screen_id() != ScreenId::CharacterSelect { return Ok(false); } - // キャラクター選択画面に到達したので、ホストが同期ポイントに入った時点の状態を再現する - let initial_state = session.spectator_initial().unwrap().initial_state(); - let selection = th19.selection_mut(); - selection.p1_mut().card = initial_state.p1_card() as u32; - selection.p2_mut().card = initial_state.p2_card() as u32; - set_rand_seeds(th19, round_initial); - Ok(true) + let spectator_initial = session.spectator_initial().unwrap(); + let has_card_phase = has_card_select_phase(spectator_initial.game_settings()); + let initial_state = spectator_initial.initial_state().clone(); + let set_cards = |th19: &mut Th19| { + let selection = th19.selection_mut(); + selection.p1_mut().card = initial_state.p1().card() as u32; + selection.p2_mut().card = initial_state.p2().card() as u32; + }; + let reproduction = reproduction.get_or_insert_with(|| { + // キャラクター選択画面に到達したので、カーソル位置を合わせる + let menu = th19 + .app_mut() + .main_loop_tasks_mut() + .find_main_menu_mut() + .unwrap() + .menu_mut(); + let p1_cursor = menu.p1_cursor_mut(); + p1_cursor.cursor = initial_state.p1().character() as u32; + p1_cursor.prev_cursor = p1_cursor.cursor; + let p2_cursor = menu.p2_cursor_mut(); + p2_cursor.cursor = initial_state.p2().character() as u32; + p2_cursor.prev_cursor = p2_cursor.cursor; + set_cards(th19); + PhaseReproduction { + phases: [CharacterSelectPhase::Character; 2], + wait: PRESS_INTERVAL_FRAMES, + } + }); + let targets = [initial_state.p1().phase(), initial_state.p2().phase()]; + if reproduction.phases == targets { + // 段階の遷移でカードのカーソルが変わる可能性があるため、最後に改めて合わせる + set_cards(th19); + set_rand_seeds(th19, round_initial); + return Ok(true); + } + + if !th19.no_wait() { + th19.set_no_wait(true); + } + let mut inputs = [InputValue::empty(); 2]; + if reproduction.wait > 0 { + reproduction.wait -= 1; + } else { + for ((phase, target), input) in + reproduction.phases.iter_mut().zip(targets).zip(&mut inputs) + { + if *phase == target { + continue; + } + if *phase == CharacterSelectPhase::Card { + // カードを選んでから決定する + set_cards(th19); + } + *input = InputFlags::SHOT.into(); + *phase = phase.decided(has_card_phase); + } + reproduction.wait = PRESS_INTERVAL_FRAMES; + } + let input_devices = th19.input_devices_mut(); + input_devices.p1_input_mut().set_current(inputs[0]); + input_devices.p2_input_mut().set_current(inputs[1]); + Ok(false) } /// 同期が完了したら `true` を返す。完了したフレームからホストの入力を再生する必要がある @@ -131,8 +205,8 @@ impl SpectatorSync { return Ok(false); } let selection = th19.selection_mut(); - selection.p1_mut().character = initial_state.p1_character() as u32; - selection.p2_mut().character = initial_state.p2_character() as u32; + selection.p1_mut().character = initial_state.p1().character() as u32; + selection.p2_mut().character = initial_state.p2().character() as u32; match self.state { SpectatorSyncState::WaitingForHost => unreachable!(), SpectatorSyncState::ToDifficultySelect => { From 95f9cd2ecbf9ecd787d05c72a998287e8485f42e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:48:05 +0000 Subject: [PATCH 05/29] =?UTF-8?q?feat(spectator):=20=E8=A9=A6=E5=90=88?= =?UTF-8?q?=E9=96=8B=E5=A7=8B=E6=99=82=E3=81=AE=E7=8A=B6=E6=85=8B=E3=81=AB?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E8=A6=B3=E6=88=A6=E3=82=92?= =?UTF-8?q?=E5=A7=8B=E3=82=81=E3=82=8B=E6=96=B9=E5=BC=8F=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit キャラクター選択とカード選択は同時に進み、準備完了後もカードを変更できるため、 キャラクター選択画面の途中状態を送る方式をやめる。 - ホストは観戦者の接続時にプレイヤー名と設定を送り、試合開始時 (読み込み画面に 移ったとき) に難易度・キャラクター・カード・乱数シードを送る - 観戦者は難易度選択画面またはキャラクター選択画面で待機し、試合開始時の状態を 受け取ったら難易度・カーソル・カードを合わせ、一致を確認してから決定する - 選択画面の入力は観戦者に送らず、試合中の入力のみを送る - 対戦中に接続した観戦者は次の試合から観戦する - SpectatorSync / SpectatorSelect を SpectatorStandby にまとめる Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/helper.rs | 5 + junowen/src/session/spectator.rs | 113 ++++---- junowen/src/session/spectator_host.rs | 8 +- junowen/src/state/battle_session.rs | 1 + .../src/state/battle_session/battle_game.rs | 7 +- .../src/state/battle_session/battle_select.rs | 6 +- .../state/battle_session/spectator_host.rs | 242 +++++------------- junowen/src/state/battle_session/utils.rs | 9 +- junowen/src/state/spectator_session.rs | 156 +++++------ .../src/state/spectator_session/in_session.rs | 8 +- .../spectator_session/spectator_select.rs | 66 ----- .../spectator_session/spectator_standby.rs | 176 +++++++++++++ .../state/spectator_session/spectator_sync.rs | 224 ---------------- 13 files changed, 409 insertions(+), 612 deletions(-) delete mode 100644 junowen/src/state/spectator_session/spectator_select.rs create mode 100644 junowen/src/state/spectator_session/spectator_standby.rs delete mode 100644 junowen/src/state/spectator_session/spectator_sync.rs diff --git a/junowen/src/helper.rs b/junowen/src/helper.rs index 70e9a99..1fe5e14 100644 --- a/junowen/src/helper.rs +++ b/junowen/src/helper.rs @@ -9,3 +9,8 @@ pub fn pushed_f1(input_devices: &InputDevices) -> bool { let raw_keys = input_devices.keyboard_input().raw_keys(); raw_keys[0x70] & 0x80 != 0 } + +pub fn pushed_escape(input_devices: &InputDevices) -> bool { + let raw_keys = input_devices.keyboard_input().raw_keys(); + raw_keys[0x1b] & 0x80 != 0 +} diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index fc0fd3e..95cd514 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -8,93 +8,48 @@ use derive_new::new; use getset::{CopyGetters, Getters, Setters}; use junowen_lib::{ connection::{DataChannel, PeerConnection}, - structs::settings::{AbilityCard, GameSettings}, + structs::settings::GameSettings, }; use serde::{Deserialize, Serialize}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use super::{session_message::RoundInitial, to_channel}; -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -pub enum Screen { - DifficultySelect, - CharacterSelect, -} - -/// キャラクター選択画面での各プレイヤーの進行段階 -/// -/// メモリ上の値は未解析のため、ホストが決定キーとキャンセルキーの押下から推定する -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] -pub enum CharacterSelectPhase { - #[default] - Character, - Card, - Ready, -} - -impl CharacterSelectPhase { - pub fn decided(self, has_card_phase: bool) -> Self { - match self { - Self::Character if has_card_phase => Self::Card, - Self::Character | Self::Card | Self::Ready => Self::Ready, - } - } - - pub fn canceled(self, has_card_phase: bool) -> Self { - match self { - Self::Ready if has_card_phase => Self::Card, - Self::Character | Self::Card | Self::Ready => Self::Character, - } - } -} - -/// カード選択の段階があるかどうか -/// -/// TODO: 実機で確認する。`Random` はカードが自動で決まるため選択の段階がないと仮定している -pub fn has_card_select_phase(game_settings: &GameSettings) -> bool { - matches!( - game_settings.ability_card(), - AbilityCard::SelfCard | AbilityCard::AllCard - ) +#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] +pub struct SpectatorInitial { + #[get = "pub"] + p1_name: String, + #[get = "pub"] + p2_name: String, + #[get = "pub"] + game_settings: GameSettings, } -#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, Serialize)] -pub struct PlayerInitialState { +#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, PartialEq, Serialize)] +pub struct PlayerGameInitial { #[get_copy = "pub"] character: u8, #[get_copy = "pub"] card: u8, - #[get_copy = "pub"] - phase: CharacterSelectPhase, } -#[derive(new, Clone, Debug, Deserialize, CopyGetters, Serialize)] -pub struct InitialState { - #[get_copy = "pub"] - screen: Screen, +/// 試合開始時の状態。観戦者はこれに合わせてキャラクターとカードを決定する +#[derive(new, Clone, Debug, Deserialize, CopyGetters, Getters, Serialize)] +pub struct GameInitial { #[get_copy = "pub"] difficulty: u8, #[get_copy = "pub"] - p1: PlayerInitialState, + p1: PlayerGameInitial, #[get_copy = "pub"] - p2: PlayerInitialState, -} - -#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] -pub struct SpectatorInitial { - #[get = "pub"] - p1_name: String, - #[get = "pub"] - p2_name: String, + p2: PlayerGameInitial, #[get = "pub"] - game_settings: GameSettings, - #[get = "pub"] - initial_state: InitialState, + round_initial: RoundInitial, } #[derive(Debug, Deserialize, Serialize)] pub enum SpectatorSessionMessage { InitSpectator(SpectatorInitial), + InitGame(GameInitial), InitRound(RoundInitial), Inputs(u16, u16), } @@ -179,6 +134,28 @@ impl SpectatorSession { Ok(true) } + /// 試合開始時の状態をブロックせずに受信する + /// + /// 観戦者の試合がホストより早く終わった場合などに残った入力は読み捨てる + pub fn try_recv_init_game(&mut self) -> Result, RecvError> { + self.round_initial = None; + self.fill_buffer(); + while let Some(msg) = self.buffer.pop_front() { + match msg { + SpectatorSessionMessage::InitGame(init) => return Ok(Some(init)), + SpectatorSessionMessage::InitSpectator(init) => { + error!("unexpected init spectator message: {:?}", init); + return Err(RecvError); + } + msg => warn!("discard message: {:?}", msg), + } + } + if self.disconnected { + return Err(RecvError); + } + Ok(None) + } + pub fn dequeue_init_round(&mut self) -> Result { if let Some(round_initial) = self.round_initial.take() { return Ok(round_initial); @@ -189,6 +166,10 @@ impl SpectatorSession { error!("unexpected init spectator message: {:?}", init); return Err(RecvError); } + SpectatorSessionMessage::InitGame(init) => { + error!("unexpected init game message: {:?}", init); + return Err(RecvError); + } SpectatorSessionMessage::InitRound(round_initial) => return Ok(round_initial), SpectatorSessionMessage::Inputs(..) => continue, } @@ -204,6 +185,10 @@ impl SpectatorSession { error!("unexpected init spectator message: {:?}", init); Err(RecvError) } + SpectatorSessionMessage::InitGame(init) => { + error!("unexpected init game message: {:?}", init); + Err(RecvError) + } SpectatorSessionMessage::InitRound(round_initial) => { self.round_initial = Some(round_initial); Ok((0, 0)) diff --git a/junowen/src/session/spectator_host.rs b/junowen/src/session/spectator_host.rs index 157a9af..5ae2f57 100644 --- a/junowen/src/session/spectator_host.rs +++ b/junowen/src/session/spectator_host.rs @@ -5,7 +5,7 @@ use tracing::info; use super::{ session_message::RoundInitial, - spectator::{SpectatorInitial, SpectatorSessionMessage}, + spectator::{GameInitial, SpectatorInitial, SpectatorSessionMessage}, to_channel, }; @@ -31,6 +31,12 @@ impl SpectatorHostSession { .send(SpectatorSessionMessage::InitSpectator(init))?) } + pub fn send_init_game(&self, init: GameInitial) -> Result<()> { + Ok(self + .hook_outgoing_tx + .send(SpectatorSessionMessage::InitGame(init))?) + } + pub fn send_init_round(&self, init: RoundInitial) -> Result<()> { Ok(self .hook_outgoing_tx diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index e72ae37..9c89e60 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -79,6 +79,7 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { + self.spectator_host_state.start_game(th19); self.change_to_game_loading(); Some(Some(main_menu)) } diff --git a/junowen/src/state/battle_session/battle_game.rs b/junowen/src/state/battle_session/battle_game.rs index 4502aa0..1f791dc 100644 --- a/junowen/src/state/battle_session/battle_game.rs +++ b/junowen/src/state/battle_session/battle_game.rs @@ -42,7 +42,8 @@ impl BattleGame { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(false, None, th19, session, p1, p2); + spectator_host_state.update(false, None, th19, session); + spectator_host_state.send_inputs(p1, p2); Ok(()) } @@ -53,6 +54,8 @@ impl BattleGame { spectator_host_state: &mut SpectatorHostState, th19: &mut Th19, ) -> Result<(), RecvError> { - init_round(th19, session, spectator_host_state) + init_round(th19, session)?; + spectator_host_state.send_init_round(th19); + Ok(()) } } diff --git a/junowen/src/state/battle_session/battle_select.rs b/junowen/src/state/battle_session/battle_select.rs index a531f90..fd63ffe 100644 --- a/junowen/src/state/battle_session/battle_select.rs +++ b/junowen/src/state/battle_session/battle_select.rs @@ -59,7 +59,7 @@ impl BattleSelect { if session.match_initial().is_none() { init_match(th19, session)?; } - init_round(th19, session, spectator_host_state)?; + init_round(th19, session)?; } if main_menu.screen_id() == ScreenId::DifficultySelect { @@ -82,7 +82,7 @@ impl BattleSelect { .p2_input_mut() .set_current((p2 as u32).try_into().unwrap()); - spectator_host_state.update(f1_pushed, Some(main_menu), th19, session, p1, p2); + spectator_host_state.update(f1_pushed, Some(main_menu), th19, session); Ok(()) } @@ -111,7 +111,7 @@ impl BattleSelect { menu_input.set_current((input as u32).try_into().unwrap()); let f1_pushed = pushed_f1(input_devices); - spectator_host_state.update(f1_pushed, Some(main_menu), th19, session, p1, p2); + spectator_host_state.update(f1_pushed, Some(main_menu), th19, session); Ok(()) } diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 1e44ba3..6af1a56 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,11 +1,7 @@ use getset::Getters; use junowen_lib::{ Th19, - structs::{ - app::{MainMenu, ScreenId}, - input_devices::{InputFlags, InputValue}, - selection::Player, - }, + structs::{app::MainMenu, selection::Player}, }; use tracing::info; @@ -13,10 +9,7 @@ use crate::{ session::{ RoundInitial, battle::BattleSession, - spectator::{ - self, CharacterSelectPhase, InitialState, PlayerInitialState, SpectatorInitial, - has_card_select_phase, - }, + spectator::{GameInitial, PlayerGameInitial, SpectatorInitial}, spectator_host::SpectatorHostSession, }, signaling::waiting_for_match::WaitingForSpectator, @@ -31,194 +24,107 @@ fn current_round_initial(th19: &Th19) -> RoundInitial { } } -fn pushed(prev: u16, current: u16, flag: InputFlags) -> bool { - let bits = InputValue::from(flag).bits() as u16; - prev & bits == 0 && current & bits != 0 -} - -/// キャラクター選択画面での各プレイヤーの進行段階を、決定キーとキャンセルキーの押下から推定する -#[derive(Default)] -struct CharacterSelectTracker { - phases: [CharacterSelectPhase; 2], - prev_inputs: [u16; 2], -} - -impl CharacterSelectTracker { - fn update(&mut self, inputs: [u16; 2], has_card_phase: bool) { - for ((phase, prev), current) in self.phases.iter_mut().zip(self.prev_inputs).zip(inputs) { - if pushed(prev, current, InputFlags::SHOT) { - *phase = phase.decided(has_card_phase); - } else if pushed(prev, current, InputFlags::BOMB) { - *phase = phase.canceled(has_card_phase); - } - } - self.prev_inputs = inputs; - } +fn create_spectator_initial(battle_session: &BattleSession, th19: &Th19) -> SpectatorInitial { + let local_player_name = th19.vs_mode().player_name().to_string(); + let remote_player_name = battle_session.remote_player_name().clone(); + let (p1_name, p2_name) = if battle_session.host() { + (local_player_name, remote_player_name) + } else { + (remote_player_name, local_player_name) + }; + SpectatorInitial::new( + p1_name, + p2_name, + battle_session + .match_initial() + .as_ref() + .unwrap() + .game_settings + .clone(), + ) } +/// 観戦者の管理 +/// +/// 観戦者は接続するとプレイヤー名と設定を受け取り、次の試合の開始を待つ。 +/// 試合開始時にキャラクター・カード・乱数シードを受け取り、それに合わせて試合を始める #[derive(Getters)] pub struct SpectatorHostState { #[get = "pub"] waiting: WaitingForSpectator, + /// 次の試合の開始を待っている観戦者 + standby_sessions: Vec, + /// 試合を観戦している観戦者 sessions: Vec, - /// 対戦中など、状態を送れない間に接続した観戦者 - pending_sessions: Vec, - character_select: CharacterSelectTracker, - prev_screen_id: Option, } impl SpectatorHostState { pub fn new(waiting: WaitingForSpectator) -> Self { Self { waiting, + standby_sessions: Vec::new(), sessions: Vec::new(), - pending_sessions: Vec::new(), - character_select: CharacterSelectTracker::default(), - prev_screen_id: None, } } pub fn count_spectators(&self) -> usize { - self.sessions.len() + self.standby_sessions.len() + self.sessions.len() } - pub fn send_init_round_if_connected(&mut self, th19: &Th19) { - let round_initial = current_round_initial(th19); - self.sessions.retain(|session| { - if let Err(err) = session.send_init_round(round_initial.clone()) { - info!("spectator host error: {:?}", err); - return false; - } - true - }); - } - - /// 観戦者に送る現在の状態を作る。状態を送れない画面では `None` を返す - /// - /// - 難易度選択画面 (カード選択状態が初期状態の場合のみ) - /// - キャラクター選択画面 - fn create_initial_state(&self, main_menu: &MainMenu, th19: &Th19) -> Option { - let selection = th19.selection(); - match main_menu.screen_id() { - ScreenId::DifficultySelect => { - let vs_mode = th19.vs_mode(); - if vs_mode.p1_card() != 0 || vs_mode.p2_card() != 0 { - return None; - } - let player = |player: &Player| { - PlayerInitialState::new( - player.character as u8, - player.card as u8, - CharacterSelectPhase::Character, - ) - }; - Some(InitialState::new( - spectator::Screen::DifficultySelect, - main_menu.menu().cursor() as u8, - player(selection.p1()), - player(selection.p2()), - )) - } - ScreenId::CharacterSelect => { - // キャラクター選択画面では `selection` のキャラクターが有効でないため、カーソル位置を使う - let menu = main_menu.menu(); - let [p1_phase, p2_phase] = self.character_select.phases; - Some(InitialState::new( - spectator::Screen::CharacterSelect, - selection.difficulty as u8, - PlayerInitialState::new( - menu.p1_cursor().cursor as u8, - selection.p1().card as u8, - p1_phase, - ), - PlayerInitialState::new( - menu.p2_cursor().cursor as u8, - selection.p2().card as u8, - p2_phase, - ), - )) - } - _ => None, - } - } - - fn create_spectator_initial( - initial_state: InitialState, - battle_session: &BattleSession, - th19: &Th19, - ) -> SpectatorInitial { - let local_player_name = th19.vs_mode().player_name().to_string(); - let remote_player_name = battle_session.remote_player_name().clone(); - let (p1_name, p2_name) = if battle_session.host() { - (local_player_name, remote_player_name) - } else { - (remote_player_name, local_player_name) - }; - SpectatorInitial::new( - p1_name, - p2_name, - battle_session - .match_initial() - .as_ref() - .unwrap() - .game_settings - .clone(), - initial_state, - ) - } - - /// 保留中の観戦者に現在の状態を送り、観戦を開始させる - fn join_pending_sessions( + /// 観戦者の接続を受け付ける + pub fn update( &mut self, + pushed: bool, main_menu: Option<&MainMenu>, th19: &Th19, battle_session: &BattleSession, ) { - if self.pending_sessions.is_empty() { - return; - } - let Some(initial_state) = main_menu.and_then(|x| self.create_initial_state(x, th19)) else { + let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) else { return; }; - let spectator_initial = Self::create_spectator_initial(initial_state, battle_session, th19); - let round_initial = current_round_initial(th19); - for session in std::mem::take(&mut self.pending_sessions) { - let result = session - .send_init_spectator(spectator_initial.clone()) - .and_then(|_| session.send_init_round(round_initial.clone())); - if let Err(err) = result { - info!("initialize spectator failed: {:?}", err); - continue; - } - info!("spectator joined. {:?}", spectator_initial.initial_state()); - self.sessions.push(session); + if let Err(err) = + session.send_init_spectator(create_spectator_initial(battle_session, th19)) + { + info!("initialize spectator failed: {:?}", err); + return; } + info!("spectator joined. waiting for next game"); + self.standby_sessions.push(session); } - /// `p1_input` と `p2_input` はこのフレームで適用される入力 - pub fn update( - &mut self, - pushed: bool, - main_menu: Option<&MainMenu>, - th19: &Th19, - battle_session: &BattleSession, - p1_input: u16, - p2_input: u16, - ) { - let screen_id = main_menu.map(|x| x.screen_id()); - let prev_screen_id = std::mem::replace(&mut self.prev_screen_id, screen_id); - if screen_id == Some(ScreenId::CharacterSelect) - && prev_screen_id != Some(ScreenId::CharacterSelect) - { - self.character_select = CharacterSelectTracker::default(); - } + /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ + pub fn start_game(&mut self, th19: &Th19) { + let selection = th19.selection(); + let player = + |player: &Player| PlayerGameInitial::new(player.character as u8, player.card as u8); + let init = GameInitial::new( + selection.difficulty as u8, + player(selection.p1()), + player(selection.p2()), + current_round_initial(th19), + ); + self.sessions.append(&mut self.standby_sessions); + self.sessions.retain(|session| { + if let Err(err) = session.send_init_game(init.clone()) { + info!("spectator host error: {:?}", err); + return false; + } + true + }); + } - if let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) { - self.pending_sessions.push(session); - } - // 状態はこのフレームの入力を適用する前のものなので、入力の送信より先に送る - self.join_pending_sessions(main_menu, th19, battle_session); + pub fn send_init_round(&mut self, th19: &Th19) { + let round_initial = current_round_initial(th19); + self.sessions.retain(|session| { + if let Err(err) = session.send_init_round(round_initial.clone()) { + info!("spectator host error: {:?}", err); + return false; + } + true + }); + } + pub fn send_inputs(&mut self, p1_input: u16, p2_input: u16) { self.sessions.retain(|session| { if let Err(err) = session.send_inputs(p1_input, p2_input) { info!("spectator host error: {:?}", err); @@ -226,11 +132,5 @@ impl SpectatorHostState { } true }); - - if screen_id == Some(ScreenId::CharacterSelect) { - let game_settings = &battle_session.match_initial().unwrap().game_settings; - self.character_select - .update([p1_input, p2_input], has_card_select_phase(game_settings)); - } } } diff --git a/junowen/src/state/battle_session/utils.rs b/junowen/src/state/battle_session/utils.rs index 12bbfc1..0144152 100644 --- a/junowen/src/state/battle_session/utils.rs +++ b/junowen/src/state/battle_session/utils.rs @@ -5,13 +5,7 @@ use junowen_lib::Th19; use crate::session::{RoundInitial, battle::BattleSession}; -use super::spectator_host::SpectatorHostState; - -pub fn init_round( - th19: &mut Th19, - battle_session: &mut BattleSession, - spectator_host_state: &mut SpectatorHostState, -) -> Result<(), RecvError> { +pub fn init_round(th19: &mut Th19, battle_session: &mut BattleSession) -> Result<(), RecvError> { if battle_session.host() { let opt = battle_session.init_round(Some(RoundInitial { seed1: th19.rand_seed1().unwrap(), @@ -27,6 +21,5 @@ pub fn init_round( th19.set_rand_seed3(init.seed3).unwrap(); th19.set_rand_seed4(init.seed4).unwrap(); } - spectator_host_state.send_init_round_if_connected(th19); Ok(()) } diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index bf69947..742d8fb 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -1,7 +1,6 @@ mod in_session; mod spectator_game; -mod spectator_select; -mod spectator_sync; +mod spectator_standby; use std::{ffi::c_void, sync::mpsc::RecvError}; @@ -14,14 +13,19 @@ use junowen_lib::{ input_devices::InputFlags, }, }; +use tracing::warn; -use crate::session::{RoundInitial, spectator::SpectatorSession as SpectatorSessionProps}; +use crate::{ + helper::pushed_escape, + session::{ + RoundInitial, + spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, + }, +}; use super::prepare::Prepare; -use { - spectator_game::SpectatorGame, spectator_select::SpectatorSelect, spectator_sync::SpectatorSync, -}; +use {spectator_game::SpectatorGame, spectator_standby::SpectatorStandby}; fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { th19.set_rand_seed1(round_initial.seed1).unwrap(); @@ -30,6 +34,31 @@ fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { th19.set_rand_seed4(round_initial.seed4).unwrap(); } +/// 観戦者の試合開始時の状態がホストと一致しているか確認する +fn verify_game_initial(th19: &Th19, init: &GameInitial) { + let selection = th19.selection(); + let actual = ( + selection.difficulty as u8, + selection.p1().character as u8, + selection.p1().card as u8, + selection.p2().character as u8, + selection.p2().card as u8, + ); + let expected = ( + init.difficulty(), + init.p1().character(), + init.p1().card(), + init.p2().character(), + init.p2().card(), + ); + if actual != expected { + warn!( + "game initial mismatch. expected={:?}, actual={:?}", + expected, actual + ); + } +} + pub struct SpectatorSession { props: SpectatorSessionProps, state: SpectatorSessionState, @@ -37,9 +66,9 @@ pub struct SpectatorSession { enum SpectatorSessionState { Prepare(Prepare), - Sync(SpectatorSync), - Select(SpectatorSelect), - GameLoading, + Standby(SpectatorStandby), + /// 最初のフレームで乱数シードを設定する + GameLoading(Option), Game(SpectatorGame), BackToSelect, } @@ -56,14 +85,11 @@ impl SpectatorSession { self.props.spectator_initial().map(|x| x.game_settings()) } - pub fn change_to_sync(&mut self) { - self.state = SpectatorSessionState::Sync(SpectatorSync::new()); + pub fn change_to_standby(&mut self, first_time: bool) { + self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time)); } - pub fn change_to_select(&mut self, recv_round_initial: bool) { - self.state = SpectatorSessionState::Select(SpectatorSelect::new(recv_round_initial)); - } - pub fn change_to_game_loading(&mut self) { - self.state = SpectatorSessionState::GameLoading; + pub fn change_to_game_loading(&mut self, round_initial: Option) { + self.state = SpectatorSessionState::GameLoading(round_initial); } pub fn change_to_game(&mut self) { self.state = SpectatorSessionState::Game(SpectatorGame); @@ -79,28 +105,30 @@ impl SpectatorSession { return Some(None); }; if prepare.update_state(main_menu, th19) { - self.change_to_sync(); + self.change_to_standby(true); } Some(Some(main_menu)) } - SpectatorSessionState::Sync(_) | SpectatorSessionState::Select(_) => { - let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); + SpectatorSessionState::Standby(standby) => { + // 待機中は観戦者側でキャンセルキーを入力することがあるため、キーボードの ESC で中断する + if pushed_escape(th19.input_devices()) { + return None; + } + let main_menu = th19.app().main_loop_tasks().find_main_menu()?; match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, - ScreenId::CharacterSelect => { - if th19.input_devices().p1_input().current().0 & InputFlags::PAUSE != None { - return None; - } - Some(Some(main_menu)) - } ScreenId::GameLoading => { - self.change_to_game_loading(); + let init = standby.take_game_initial(); + if let Some(init) = &init { + verify_game_initial(th19, init); + } + self.change_to_game_loading(init.map(|x| x.round_initial().clone())); Some(Some(main_menu)) } _ => Some(Some(main_menu)), } } - SpectatorSessionState::GameLoading { .. } => { + SpectatorSessionState::GameLoading(_) => { let Some(round_frame) = th19.round_frame() else { return Some(None); }; @@ -120,14 +148,14 @@ impl SpectatorSession { self.change_to_back_to_select(); Some(None) } - SpectatorSessionState::BackToSelect { .. } => { + SpectatorSessionState::BackToSelect => { let Some(main_menu) = th19.app().main_loop_tasks().find_main_menu() else { return Some(None); }; if main_menu.screen_id() != ScreenId::CharacterSelect { return Some(Some(main_menu)); } - self.change_to_select(true); + self.change_to_standby(false); Some(Some(main_menu)) } } @@ -138,26 +166,21 @@ impl SpectatorSession { menu: Option<&MainMenu>, th19: &mut Th19, ) -> Result<(), RecvError> { - if let SpectatorSessionState::Sync(sync) = &mut self.state { - if !sync.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? { - return Ok(()); - } - // 同期が完了したフレームからホストの入力を再生する - self.change_to_select(false); - } match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_players(th19), - SpectatorSessionState::Sync(_) => unreachable!(), - SpectatorSessionState::Select(select) => { - select.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? + SpectatorSessionState::Standby(standby) => { + standby.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? } - SpectatorSessionState::GameLoading { .. } => { + SpectatorSessionState::GameLoading(round_initial) => { + if let Some(round_initial) = round_initial.take() { + set_rand_seeds(th19, &round_initial); + } if th19.no_wait() { th19.set_no_wait(false); } } SpectatorSessionState::Game(game) => game.update_th19(&mut self.props, th19)?, - SpectatorSessionState::BackToSelect { .. } => {} + SpectatorSessionState::BackToSelect => {} } Ok(()) } @@ -165,45 +188,33 @@ impl SpectatorSession { pub fn on_input_menu(&mut self, th19: &mut Th19) -> Result { match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_menu(th19), - SpectatorSessionState::Sync(_) | SpectatorSessionState::Select(_) => { - return self.on_input_menu_in_select(th19); + SpectatorSessionState::Standby(standby) => { + let main_menu = th19 + .app_mut() + .main_loop_tasks_mut() + .find_main_menu_mut() + .unwrap(); + if main_menu.screen_id() == ScreenId::DifficultySelect + && th19.menu_input().current().0 & InputFlags::PAUSE != None + { + return Ok(false); + } + standby.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; } - SpectatorSessionState::GameLoading { .. } => {} + SpectatorSessionState::GameLoading(_) => {} SpectatorSessionState::Game { .. } => {} - SpectatorSessionState::BackToSelect { .. } => {} + SpectatorSessionState::BackToSelect => {} } Ok(true) } - fn on_input_menu_in_select(&mut self, th19: &mut Th19) -> Result { - let main_menu = th19 - .app_mut() - .main_loop_tasks_mut() - .find_main_menu_mut() - .unwrap(); - if main_menu.screen_id() == ScreenId::DifficultySelect - && th19.menu_input().current().0 & InputFlags::PAUSE != None - { - return Ok(false); - } - if let SpectatorSessionState::Sync(sync) = &mut self.state { - if !sync.update_th19_on_input_menu(&mut self.props, main_menu, th19)? { - return Ok(true); - } - // 同期が完了したフレームからホストの入力を再生する - self.change_to_select(false); - } - let SpectatorSessionState::Select(select) = &mut self.state else { - unreachable!() - }; - select.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; - Ok(true) - } - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { + let waiting_for_host = matches!( + &self.state, + SpectatorSessionState::Standby(standby) if standby.is_waiting_for_host() + ); let Some(initial) = self.props.spectator_initial() else { - if matches!(&self.state, SpectatorSessionState::Sync(sync) if sync.is_waiting_for_host()) - { + if waiting_for_host { in_session::on_render_texts_waiting_for_host(th19, text_renderer); } return; @@ -213,6 +224,7 @@ impl SpectatorSession { text_renderer, initial.p1_name(), initial.p2_name(), + waiting_for_host, ); } diff --git a/junowen/src/state/spectator_session/in_session.rs b/junowen/src/state/spectator_session/in_session.rs index 74e0f37..c1963fd 100644 --- a/junowen/src/state/spectator_session/in_session.rs +++ b/junowen/src/state/spectator_session/in_session.rs @@ -9,9 +9,15 @@ pub fn on_render_texts_spectator( text_renderer: &c_void, p1_name: &str, p2_name: &str, + waiting_for_next_game: bool, ) { render_names(th19, text_renderer, p1_name, p2_name); - render_footer(th19, text_renderer, "(Spectating)", ""); + let msg = if waiting_for_next_game { + "(Spectating: waiting for next game...)" + } else { + "(Spectating)" + }; + render_footer(th19, text_renderer, msg, ""); } pub fn on_render_texts_waiting_for_host(th19: &Th19, text_renderer: &c_void) { diff --git a/junowen/src/state/spectator_session/spectator_select.rs b/junowen/src/state/spectator_session/spectator_select.rs deleted file mode 100644 index 356f3d2..0000000 --- a/junowen/src/state/spectator_session/spectator_select.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::sync::mpsc::RecvError; - -use anyhow::Result; -use derive_new::new; -use junowen_lib::{ - Th19, - structs::app::{MainMenu, ScreenId}, -}; - -use crate::session::spectator::SpectatorSession; - -use super::set_rand_seeds; - -#[derive(new)] -pub struct SpectatorSelect { - /// 対戦後に戻ってきた場合は、最初のフレームでラウンドの初期化情報を受け取る - recv_round_initial: bool, -} - -impl SpectatorSelect { - pub fn update_th19_on_input_players( - &mut self, - session: &mut SpectatorSession, - main_menu: &MainMenu, - th19: &mut Th19, - ) -> Result<(), RecvError> { - if self.recv_round_initial { - self.recv_round_initial = false; - let round_initial = session.dequeue_init_round()?; - set_rand_seeds(th19, &round_initial); - } - if main_menu.screen_id() == ScreenId::DifficultySelect { - return Ok(()); - } - if !th19.no_wait() { - th19.set_no_wait(true); - } - - let (p1, p2) = session.dequeue_inputs()?; - let input_devices = th19.input_devices_mut(); - input_devices - .p1_input_mut() - .set_current((p1 as u32).try_into().unwrap()); - input_devices - .p2_input_mut() - .set_current((p2 as u32).try_into().unwrap()); - - Ok(()) - } - - pub fn update_th19_on_input_menu( - &mut self, - session: &mut SpectatorSession, - main_menu: &mut MainMenu, - th19: &mut Th19, - ) -> Result<(), RecvError> { - if main_menu.screen_id() != ScreenId::DifficultySelect { - return Ok(()); - } - let (p1, p2) = session.dequeue_inputs()?; - let input = if p1 != 0 { p1 } else { p2 }; - th19.menu_input_mut() - .set_current((input as u32).try_into().unwrap()); - Ok(()) - } -} diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs new file mode 100644 index 0000000..f4ef15d --- /dev/null +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -0,0 +1,176 @@ +use std::sync::mpsc::RecvError; + +use anyhow::Result; +use junowen_lib::{ + Th19, + structs::{ + app::{MainMenu, ScreenId}, + input_devices::{InputFlags, InputValue}, + }, + th19_helpers::{reset_cursors, shot_repeatedly}, +}; +use tracing::trace; + +use crate::session::spectator::{GameInitial, SpectatorSession}; + +fn clear_player_inputs(th19: &mut Th19) { + let input_devices = th19.input_devices_mut(); + input_devices + .p1_input_mut() + .set_current(InputValue::empty()); + input_devices + .p2_input_mut() + .set_current(InputValue::empty()); +} + +/// キャラクター選択画面のカーソルとカードが試合開始時の状態と一致しているか +fn matches_character_select(main_menu: &MainMenu, th19: &Th19, init: &GameInitial) -> bool { + let menu = main_menu.menu(); + let selection = th19.selection(); + menu.p1_cursor().cursor == init.p1().character() as u32 + && menu.p2_cursor().cursor == init.p2().character() as u32 + && selection.p1().card == init.p1().card() as u32 + && selection.p2().card == init.p2().card() as u32 +} + +fn set_character_select(th19: &mut Th19, init: &GameInitial) { + let selection = th19.selection_mut(); + selection.p1_mut().card = init.p1().card() as u32; + selection.p2_mut().card = init.p2().card() as u32; + let menu = th19 + .app_mut() + .main_loop_tasks_mut() + .find_main_menu_mut() + .unwrap() + .menu_mut(); + let p1_cursor = menu.p1_cursor_mut(); + p1_cursor.cursor = init.p1().character() as u32; + p1_cursor.prev_cursor = p1_cursor.cursor; + let p2_cursor = menu.p2_cursor_mut(); + p2_cursor.cursor = init.p2().character() as u32; + p2_cursor.prev_cursor = p2_cursor.cursor; +} + +/// 難易度選択画面またはキャラクター選択画面で、ホストの試合開始を待つ +/// +/// 試合開始時の状態を受け取ったら、難易度・キャラクター・カードを合わせ、 +/// 一致していることを確認してから両プレイヤーを決定させる +pub struct SpectatorStandby { + cursors_reset: bool, + game_initial: Option, +} + +impl SpectatorStandby { + /// `first_time` は観戦を開始した直後かどうか + pub fn new(first_time: bool) -> Self { + Self { + cursors_reset: !first_time, + game_initial: None, + } + } + + pub fn is_waiting_for_host(&self) -> bool { + self.game_initial.is_none() + } + + /// 試合開始時の状態を返す。読み込み画面に移ったときに取り出す + pub fn take_game_initial(&mut self) -> Option { + self.game_initial.take() + } + + /// 試合開始時の状態を受信済みなら `true` を返す + fn recv(&mut self, session: &mut SpectatorSession, th19: &mut Th19) -> Result { + if self.game_initial.is_some() { + return Ok(true); + } + if session.spectator_initial().is_none() && !session.try_recv_init_spectator()? { + return Ok(false); + } + self.game_initial = session.try_recv_init_game()?; + if let Some(init) = &self.game_initial { + trace!("game_initial: {:?}", init); + // 待機中は負荷を抑えるため通常速度にしていたので、追いつくために早送りする + th19.set_no_wait(true); + return Ok(true); + } + if th19.no_wait() { + th19.set_no_wait(false); + } + Ok(false) + } + + pub fn update_th19_on_input_players( + &mut self, + session: &mut SpectatorSession, + main_menu: &MainMenu, + th19: &mut Th19, + ) -> Result<(), RecvError> { + if !self.cursors_reset { + self.cursors_reset = true; + reset_cursors(th19); + } + if !self.recv(session, th19)? { + clear_player_inputs(th19); + return Ok(()); + } + let init = self.game_initial.clone().unwrap(); + if main_menu.screen_id() != ScreenId::CharacterSelect { + clear_player_inputs(th19); + return Ok(()); + } + let input_devices = th19.input_devices(); + let (prev_p1, prev_p2) = ( + input_devices.p1_input().prev(), + input_devices.p2_input().prev(), + ); + let (p1, p2) = if th19.selection().difficulty as u8 != init.difficulty() { + // 難易度が異なるので難易度選択画面に戻る + let pause: InputValue = InputFlags::PAUSE.into(); + let p1 = if prev_p1 == pause { + InputValue::empty() + } else { + pause + }; + (p1, InputValue::empty()) + } else if matches_character_select(main_menu, th19, &init) { + // 一致していることを確認できたので決定する。準備完了後もカードは変更できるため合わせ続ける + (shot_repeatedly(prev_p1), shot_repeatedly(prev_p2)) + } else { + set_character_select(th19, &init); + (InputValue::empty(), InputValue::empty()) + }; + let input_devices = th19.input_devices_mut(); + input_devices.p1_input_mut().set_current(p1); + input_devices.p2_input_mut().set_current(p2); + Ok(()) + } + + pub fn update_th19_on_input_menu( + &mut self, + session: &mut SpectatorSession, + main_menu: &mut MainMenu, + th19: &mut Th19, + ) -> Result<(), RecvError> { + if main_menu.screen_id() != ScreenId::DifficultySelect { + return Ok(()); + } + if !self.recv(session, th19)? { + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(()); + } + let init = self.game_initial.clone().unwrap(); + let menu = main_menu.menu_mut(); + if menu.cursor() != init.difficulty() as u32 { + menu.set_cursor(init.difficulty() as u32); + th19.menu_input_mut().set_current(InputValue::empty()); + return Ok(()); + } + // キャラクター選択画面の初期カーソル位置を合わせてから決定する + let selection = th19.selection_mut(); + selection.p1_mut().character = init.p1().character() as u32; + selection.p2_mut().character = init.p2().character() as u32; + let prev = th19.menu_input().prev(); + th19.menu_input_mut().set_current(shot_repeatedly(prev)); + Ok(()) + } +} diff --git a/junowen/src/state/spectator_session/spectator_sync.rs b/junowen/src/state/spectator_session/spectator_sync.rs deleted file mode 100644 index c3e99be..0000000 --- a/junowen/src/state/spectator_session/spectator_sync.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::sync::mpsc::RecvError; - -use anyhow::Result; -use junowen_lib::{ - Th19, - structs::{ - app::{MainMenu, ScreenId}, - input_devices::{InputFlags, InputValue}, - }, - th19_helpers::{reset_cursors, shot_repeatedly}, -}; -use tracing::trace; - -use crate::session::{ - RoundInitial, - spectator::{self, CharacterSelectPhase, SpectatorSession, has_card_select_phase}, -}; - -use super::set_rand_seeds; - -/// 進行段階を再現するときに、決定キーを押す間隔 (フレーム) -/// -/// 画面の演出中に押すと無視される可能性があるため、間隔を空ける -const PRESS_INTERVAL_FRAMES: u32 = 20; - -/// キャラクター選択画面で、ホストの各プレイヤーの進行段階を決定キーの押下で再現する -struct PhaseReproduction { - phases: [CharacterSelectPhase; 2], - wait: u32, -} - -enum SpectatorSyncState { - /// ホストからの初期化情報 (同期ポイントの状態) を待っている - WaitingForHost, - /// 難易度選択画面の同期ポイントの状態を再現している - ToDifficultySelect, - /// キャラクター選択画面へ移動し、ホストの状態を再現している - /// - /// ホストの状態はキャラクター選択画面の途中のものなので、難易度の決定や画面遷移、 - /// 進行段階の再現を経た後に乱数シードを設定する - ToCharacterSelect { - round_initial: RoundInitial, - reproduction: Option, - }, -} - -/// 観戦開始時に、ホストの同期ポイントと同じ状態まで移動する -/// -/// 同期が完了したら、以降は `SpectatorSelect` がホストの入力を再生する -pub struct SpectatorSync { - cursors_reset: bool, - state: SpectatorSyncState, -} - -impl SpectatorSync { - pub fn new() -> Self { - Self { - cursors_reset: false, - state: SpectatorSyncState::WaitingForHost, - } - } - - pub fn is_waiting_for_host(&self) -> bool { - matches!(self.state, SpectatorSyncState::WaitingForHost) - } - - /// 同期が完了したら `true` を返す。完了したフレームからホストの入力を再生する必要がある - pub fn update_th19_on_input_players( - &mut self, - session: &mut SpectatorSession, - main_menu: &MainMenu, - th19: &mut Th19, - ) -> Result { - if !self.cursors_reset { - self.cursors_reset = true; - reset_cursors(th19); - } - if let SpectatorSyncState::WaitingForHost = self.state { - if !session.try_recv_init_spectator()? { - if th19.no_wait() { - th19.set_no_wait(false); - } - let input_devices = th19.input_devices_mut(); - input_devices - .p1_input_mut() - .set_current(InputValue::empty()); - input_devices - .p2_input_mut() - .set_current(InputValue::empty()); - return Ok(false); - } - let round_initial = session.dequeue_init_round()?; - let screen = session - .spectator_initial() - .unwrap() - .initial_state() - .screen(); - self.state = match screen { - spectator::Screen::DifficultySelect => { - // 既に難易度選択画面にいるので、そのまま設定する - set_rand_seeds(th19, &round_initial); - SpectatorSyncState::ToDifficultySelect - } - spectator::Screen::CharacterSelect => SpectatorSyncState::ToCharacterSelect { - round_initial, - reproduction: None, - }, - }; - } - let SpectatorSyncState::ToCharacterSelect { - round_initial, - reproduction, - } = &mut self.state - else { - return Ok(false); - }; - if main_menu.screen_id() != ScreenId::CharacterSelect { - return Ok(false); - } - let spectator_initial = session.spectator_initial().unwrap(); - let has_card_phase = has_card_select_phase(spectator_initial.game_settings()); - let initial_state = spectator_initial.initial_state().clone(); - let set_cards = |th19: &mut Th19| { - let selection = th19.selection_mut(); - selection.p1_mut().card = initial_state.p1().card() as u32; - selection.p2_mut().card = initial_state.p2().card() as u32; - }; - let reproduction = reproduction.get_or_insert_with(|| { - // キャラクター選択画面に到達したので、カーソル位置を合わせる - let menu = th19 - .app_mut() - .main_loop_tasks_mut() - .find_main_menu_mut() - .unwrap() - .menu_mut(); - let p1_cursor = menu.p1_cursor_mut(); - p1_cursor.cursor = initial_state.p1().character() as u32; - p1_cursor.prev_cursor = p1_cursor.cursor; - let p2_cursor = menu.p2_cursor_mut(); - p2_cursor.cursor = initial_state.p2().character() as u32; - p2_cursor.prev_cursor = p2_cursor.cursor; - set_cards(th19); - PhaseReproduction { - phases: [CharacterSelectPhase::Character; 2], - wait: PRESS_INTERVAL_FRAMES, - } - }); - let targets = [initial_state.p1().phase(), initial_state.p2().phase()]; - if reproduction.phases == targets { - // 段階の遷移でカードのカーソルが変わる可能性があるため、最後に改めて合わせる - set_cards(th19); - set_rand_seeds(th19, round_initial); - return Ok(true); - } - - if !th19.no_wait() { - th19.set_no_wait(true); - } - let mut inputs = [InputValue::empty(); 2]; - if reproduction.wait > 0 { - reproduction.wait -= 1; - } else { - for ((phase, target), input) in - reproduction.phases.iter_mut().zip(targets).zip(&mut inputs) - { - if *phase == target { - continue; - } - if *phase == CharacterSelectPhase::Card { - // カードを選んでから決定する - set_cards(th19); - } - *input = InputFlags::SHOT.into(); - *phase = phase.decided(has_card_phase); - } - reproduction.wait = PRESS_INTERVAL_FRAMES; - } - let input_devices = th19.input_devices_mut(); - input_devices.p1_input_mut().set_current(inputs[0]); - input_devices.p2_input_mut().set_current(inputs[1]); - Ok(false) - } - - /// 同期が完了したら `true` を返す。完了したフレームからホストの入力を再生する必要がある - pub fn update_th19_on_input_menu( - &mut self, - session: &mut SpectatorSession, - main_menu: &mut MainMenu, - th19: &mut Th19, - ) -> Result { - if main_menu.screen_id() != ScreenId::DifficultySelect { - return Ok(false); - } - if let SpectatorSyncState::WaitingForHost = self.state { - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(false); - } - let init = session.spectator_initial().unwrap(); - trace!("spectator_initial: {:?}", init); - let initial_state = init.initial_state(); - let menu = main_menu.menu_mut(); - if menu.cursor() != initial_state.difficulty() as u32 { - menu.set_cursor(initial_state.difficulty() as u32); - th19.menu_input_mut().set_current(InputValue::empty()); - return Ok(false); - } - let selection = th19.selection_mut(); - selection.p1_mut().character = initial_state.p1().character() as u32; - selection.p2_mut().character = initial_state.p2().character() as u32; - match self.state { - SpectatorSyncState::WaitingForHost => unreachable!(), - SpectatorSyncState::ToDifficultySelect => { - th19.set_no_wait(false); - Ok(true) - } - SpectatorSyncState::ToCharacterSelect { .. } => { - // 難易度を決定してキャラクター選択画面へ進む - let prev = th19.menu_input().prev(); - th19.menu_input_mut().set_current(shot_repeatedly(prev)); - Ok(false) - } - } - } -} From 0fe6dbdb49943a27caf8a1aed0caf5a4500888bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:46:03 +0000 Subject: [PATCH 06/29] =?UTF-8?q?fix(spectator):=20=E5=86=8D=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E6=8C=87=E6=91=98=E3=82=92?= =?UTF-8?q?=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 古い方式を前提にしたコメントを直す - 試合開始時の状態が一致しない場合は観戦を終了し、状態を合わせられないまま 一定フレームを超えた場合も諦める - 観戦者の試合中に次の試合の開始を受け取ったら、今の試合を打ち切って 次の試合から同期し直す - 待機中もゲームパッドの PAUSE で中断できるようにする (自動で入力した PAUSE は除く) - try_recv_init_game の副作用をなくし、待機状態に入るときに明示的に破棄する Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/connection/peer_connection.rs | 2 +- junowen/src/session/spectator.rs | 45 ++++++++++++++----- .../waiting_for_spectator.rs | 2 +- junowen/src/state/spectator_session.rs | 26 ++++++++--- .../state/spectator_session/spectator_game.rs | 7 +-- .../spectator_session/spectator_standby.rs | 23 +++++++++- 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/junowen-lib/src/connection/peer_connection.rs b/junowen-lib/src/connection/peer_connection.rs index c1f221d..d735cd6 100644 --- a/junowen-lib/src/connection/peer_connection.rs +++ b/junowen-lib/src/connection/peer_connection.rs @@ -82,7 +82,7 @@ impl Drop for PeerConnection { pub const BATTLE_PROTOCOL: &str = "JUNOWEN/1.1"; /// 観戦用の接続のプロトコル。互換性のない変更をしたら上げる /// -/// 1.2: キャラクター選択画面からの途中参加に対応 +/// 1.2: 試合開始時の状態を送り、いつ接続しても次の試合から観戦できるようにした pub const SPECTATOR_PROTOCOL: &str = "JUNOWEN/1.2"; impl PeerConnection { diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index 95cd514..f9db78a 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -58,11 +58,13 @@ pub enum SpectatorSessionMessage { pub struct SpectatorSession { _conn: PeerConnection, hook_incoming_rx: std::sync::mpsc::Receiver, - /// 受信済みで未処理のメッセージ。途中参加時の遅れを把握するために使う + /// 受信済みで未処理のメッセージ。試合開始がホストより遅れた分を把握するために使う buffer: VecDeque, disconnected: bool, spectator_initial: Option, round_initial: Option, + /// 観戦者の試合中に受け取った次の試合の開始時の状態 + next_game_initial: Option, } impl SpectatorSession { @@ -76,6 +78,7 @@ impl SpectatorSession { disconnected: false, spectator_initial: None, round_initial: None, + next_game_initial: None, } } @@ -134,11 +137,25 @@ impl SpectatorSession { Ok(true) } + /// 観戦者の試合中に次の試合が始まったかどうか + /// + /// 観戦者の試合がホストとずれた場合に起こる。この場合は今の試合を打ち切り、次の試合から同期し直す + pub fn has_next_game_initial(&self) -> bool { + self.next_game_initial.is_some() + } + + /// 前の試合で残ったラウンドの初期化情報を破棄する。試合開始を待つ状態に入るときに呼ぶ + pub fn discard_round_initial(&mut self) { + self.round_initial = None; + } + /// 試合開始時の状態をブロックせずに受信する /// /// 観戦者の試合がホストより早く終わった場合などに残った入力は読み捨てる pub fn try_recv_init_game(&mut self) -> Result, RecvError> { - self.round_initial = None; + if let Some(init) = self.next_game_initial.take() { + return Ok(Some(init)); + } self.fill_buffer(); while let Some(msg) = self.buffer.pop_front() { match msg { @@ -156,9 +173,13 @@ impl SpectatorSession { Ok(None) } - pub fn dequeue_init_round(&mut self) -> Result { + /// 次の試合が始まっている場合は `None` を返す + pub fn dequeue_init_round(&mut self) -> Result, RecvError> { if let Some(round_initial) = self.round_initial.take() { - return Ok(round_initial); + return Ok(Some(round_initial)); + } + if self.next_game_initial.is_some() { + return Ok(None); } loop { match self.next_message()? { @@ -167,17 +188,20 @@ impl SpectatorSession { return Err(RecvError); } SpectatorSessionMessage::InitGame(init) => { - error!("unexpected init game message: {:?}", init); - return Err(RecvError); + warn!("next game started during round over"); + self.next_game_initial = Some(init); + return Ok(None); + } + SpectatorSessionMessage::InitRound(round_initial) => { + return Ok(Some(round_initial)); } - SpectatorSessionMessage::InitRound(round_initial) => return Ok(round_initial), SpectatorSessionMessage::Inputs(..) => continue, } } } pub fn dequeue_inputs(&mut self) -> Result<(u16, u16), RecvError> { - if self.round_initial.is_some() { + if self.round_initial.is_some() || self.next_game_initial.is_some() { return Ok((0, 0)); } match self.next_message()? { @@ -186,8 +210,9 @@ impl SpectatorSession { Err(RecvError) } SpectatorSessionMessage::InitGame(init) => { - error!("unexpected init game message: {:?}", init); - Err(RecvError) + warn!("next game started during game"); + self.next_game_initial = Some(init); + Ok((0, 0)) } SpectatorSessionMessage::InitRound(round_initial) => { self.round_initial = Some(round_initial); diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index dbecc78..3830336 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -95,7 +95,7 @@ impl WaitingForPureP2pSpectator { th19: &Th19, ) -> Result<()> { // 観戦者の受け付けはメニュー画面 (難易度選択・キャラクター選択) で行う。 - // 合流のタイミングは `SpectatorHostState` が同期ポイントで制御する + // 観戦者は次の試合の開始 (`SpectatorHostState::start_game`) から合流する self.set_ready(main_menu.is_some()); match self { diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 742d8fb..6e0a7cf 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -35,7 +35,7 @@ fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { } /// 観戦者の試合開始時の状態がホストと一致しているか確認する -fn verify_game_initial(th19: &Th19, init: &GameInitial) { +fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { let selection = th19.selection(); let actual = ( selection.difficulty as u8, @@ -56,7 +56,9 @@ fn verify_game_initial(th19: &Th19, init: &GameInitial) { "game initial mismatch. expected={:?}, actual={:?}", expected, actual ); + return false; } + true } pub struct SpectatorSession { @@ -85,7 +87,11 @@ impl SpectatorSession { self.props.spectator_initial().map(|x| x.game_settings()) } + pub fn change_to_prepare(&mut self) { + self.state = SpectatorSessionState::Prepare(Prepare::new()); + } pub fn change_to_standby(&mut self, first_time: bool) { + self.props.discard_round_initial(); self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time)); } pub fn change_to_game_loading(&mut self, round_initial: Option) { @@ -110,8 +116,9 @@ impl SpectatorSession { Some(Some(main_menu)) } SpectatorSessionState::Standby(standby) => { - // 待機中は観戦者側でキャンセルキーを入力することがあるため、キーボードの ESC で中断する - if pushed_escape(th19.input_devices()) { + // 自動で入力した PAUSE は観戦者の操作として扱わない + let pause = th19.input_devices().p1_input().current().0 & InputFlags::PAUSE != None; + if pushed_escape(th19.input_devices()) || (pause && !standby.pause_injected()) { return None; } let main_menu = th19.app().main_loop_tasks().find_main_menu()?; @@ -119,8 +126,11 @@ impl SpectatorSession { ScreenId::PlayerMatchupSelect => None, ScreenId::GameLoading => { let init = standby.take_game_initial(); - if let Some(init) = &init { - verify_game_initial(th19, init); + if let Some(init) = &init + && !verify_game_initial(th19, init) + { + // ずれた試合を見せ続けないよう、観戦を終了する + return None; } self.change_to_game_loading(init.map(|x| x.round_initial().clone())); Some(Some(main_menu)) @@ -142,6 +152,12 @@ impl SpectatorSession { if th19.input_devices().p1_input().current().0 & InputFlags::PAUSE != None { return None; } + if self.props.has_next_game_initial() { + // ホストの次の試合が始まったので、今の試合を打ち切って次の試合から同期し直す + warn!("spectator game is out of sync. resync from next game"); + self.change_to_prepare(); + return Some(None); + } if th19.round_frame().is_some() { return Some(None); } diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index 55c19df..417e227 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -29,7 +29,7 @@ impl SpectatorGame { .set_current(InputValue::empty()); return Ok(()); } - // 途中参加した場合は記録済みの入力が溜まっているので、早送りしてホストに追いつく + // 観戦者の試合開始はホストより遅れるため、溜まった入力を早送りしてホストに追いつく let no_wait = session.poll_buffered_len() > CATCH_UP_THRESHOLD; if th19.no_wait() != no_wait { th19.set_no_wait(no_wait); @@ -50,8 +50,9 @@ impl SpectatorGame { session: &mut SpectatorSession, th19: &mut Th19, ) -> Result<(), RecvError> { - let init = session.dequeue_init_round()?; - set_rand_seeds(th19, &init); + if let Some(init) = session.dequeue_init_round()? { + set_rand_seeds(th19, &init); + } Ok(()) } } diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index f4ef15d..06239ec 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -9,7 +9,7 @@ use junowen_lib::{ }, th19_helpers::{reset_cursors, shot_repeatedly}, }; -use tracing::trace; +use tracing::{trace, warn}; use crate::session::spectator::{GameInitial, SpectatorSession}; @@ -51,6 +51,11 @@ fn set_character_select(th19: &mut Th19, init: &GameInitial) { p2_cursor.prev_cursor = p2_cursor.cursor; } +/// 試合開始時の状態を受け取ってから、試合開始までに許容するフレーム数 +/// +/// 状態を合わせられない場合 (カーソルの値とキャラクター番号の対応が想定と違う場合など) に諦める +const MAX_SYNC_FRAMES: u32 = 60 * 60; + /// 難易度選択画面またはキャラクター選択画面で、ホストの試合開始を待つ /// /// 試合開始時の状態を受け取ったら、難易度・キャラクター・カードを合わせ、 @@ -58,6 +63,9 @@ fn set_character_select(th19: &mut Th19, init: &GameInitial) { pub struct SpectatorStandby { cursors_reset: bool, game_initial: Option, + sync_frames: u32, + /// 直前のフレームで PAUSE を自動で入力したか。観戦者自身の PAUSE と区別するために使う + pause_injected: bool, } impl SpectatorStandby { @@ -66,9 +74,15 @@ impl SpectatorStandby { Self { cursors_reset: !first_time, game_initial: None, + sync_frames: 0, + pause_injected: false, } } + pub fn pause_injected(&self) -> bool { + self.pause_injected + } + pub fn is_waiting_for_host(&self) -> bool { self.game_initial.is_none() } @@ -109,10 +123,16 @@ impl SpectatorStandby { self.cursors_reset = true; reset_cursors(th19); } + self.pause_injected = false; if !self.recv(session, th19)? { clear_player_inputs(th19); return Ok(()); } + self.sync_frames += 1; + if self.sync_frames > MAX_SYNC_FRAMES { + warn!("failed to reproduce game initial"); + return Err(RecvError); + } let init = self.game_initial.clone().unwrap(); if main_menu.screen_id() != ScreenId::CharacterSelect { clear_player_inputs(th19); @@ -129,6 +149,7 @@ impl SpectatorStandby { let p1 = if prev_p1 == pause { InputValue::empty() } else { + self.pause_injected = true; pause }; (p1, InputValue::empty()) From 197c1204eede515187797ed9b299554d09f1dbd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:12:40 +0000 Subject: [PATCH 07/29] =?UTF-8?q?feat(spectator):=20=E8=A9=A6=E5=90=88?= =?UTF-8?q?=E4=B8=AD=E3=81=AB=E6=8E=A5=E7=B6=9A=E3=81=97=E3=81=9F=E8=A6=B3?= =?UTF-8?q?=E6=88=A6=E8=80=85=E3=81=AF=E8=A9=A6=E5=90=88=E3=81=AE=E6=9C=80?= =?UTF-8?q?=E5=88=9D=E3=81=8B=E3=82=89=E6=97=A9=E9=80=81=E3=82=8A=E3=81=A7?= =?UTF-8?q?=E8=BF=BD=E3=81=84=E3=81=8B=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ホストは進行中の試合の開始時の状態と、それ以降のラウンド初期化情報・入力を 記録する (1試合分で、試合ごとに作り直す)。試合中に接続した観戦者には これらをまとめて送り、観戦者は試合の最初から早送りでホストに追いつく。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/state/battle_session.rs | 1 + .../state/battle_session/spectator_host.rs | 97 +++++++++++++++---- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index 9c89e60..d69ecf1 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -101,6 +101,7 @@ impl BattleSession { if th19.round_frame().is_some() { return Some(None); } + self.spectator_host_state.end_game(); self.change_to_back_to_select(); Some(None) } diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 6af1a56..ecb87b8 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use getset::Getters; use junowen_lib::{ Th19, @@ -44,10 +45,38 @@ fn create_spectator_initial(battle_session: &BattleSession, th19: &Th19) -> Spec ) } +/// 進行中の試合の記録。試合の途中で接続した観戦者は、これを受け取って試合の最初から早送りで追いかける +struct GameLog { + init: GameInitial, + messages: Vec, +} + +enum GameLogMessage { + InitRound(RoundInitial), + Inputs(u16, u16), +} + +impl GameLog { + fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { + session.send_init_game(self.init.clone())?; + for msg in &self.messages { + match msg { + GameLogMessage::InitRound(round_initial) => { + session.send_init_round(round_initial.clone())? + } + GameLogMessage::Inputs(p1, p2) => session.send_inputs(*p1, *p2)?, + } + } + Ok(()) + } +} + /// 観戦者の管理 /// -/// 観戦者は接続するとプレイヤー名と設定を受け取り、次の試合の開始を待つ。 -/// 試合開始時にキャラクター・カード・乱数シードを受け取り、それに合わせて試合を始める +/// 観戦者は接続するとプレイヤー名と設定を受け取る。 +/// 選択画面で接続した場合は、次の試合の開始時にキャラクター・カード・乱数シードを受け取り、 +/// それに合わせて試合を始める。 +/// 試合中に接続した場合は、その試合の開始時の状態とそれまでの入力を受け取り、試合の最初から追いかける #[derive(Getters)] pub struct SpectatorHostState { #[get = "pub"] @@ -56,6 +85,8 @@ pub struct SpectatorHostState { standby_sessions: Vec, /// 試合を観戦している観戦者 sessions: Vec, + /// 試合中のみ `Some`。記録は1試合分で、試合ごとに作り直す + game_log: Option, } impl SpectatorHostState { @@ -64,6 +95,7 @@ impl SpectatorHostState { waiting, standby_sessions: Vec::new(), sessions: Vec::new(), + game_log: None, } } @@ -88,8 +120,37 @@ impl SpectatorHostState { info!("initialize spectator failed: {:?}", err); return; } - info!("spectator joined. waiting for next game"); - self.standby_sessions.push(session); + let Some(game_log) = &self.game_log else { + info!("spectator joined. waiting for next game"); + self.standby_sessions.push(session); + return; + }; + if let Err(err) = game_log.send_to(&session) { + info!("initialize spectator failed: {:?}", err); + return; + } + info!( + "spectator joined during game. replaying {} messages", + game_log.messages.len() + ); + self.sessions.push(session); + } + + fn broadcast( + &mut self, + msg: GameLogMessage, + send: impl Fn(&SpectatorHostSession) -> Result<()>, + ) { + self.sessions.retain(|session| { + if let Err(err) = send(session) { + info!("spectator host error: {:?}", err); + return false; + } + true + }); + if let Some(game_log) = &mut self.game_log { + game_log.messages.push(msg); + } } /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ @@ -111,26 +172,28 @@ impl SpectatorHostState { } true }); + self.game_log = Some(GameLog { + init, + messages: Vec::new(), + }); + } + + /// 試合終了時 (試合から選択画面に戻るとき) に呼ぶ + pub fn end_game(&mut self) { + self.game_log = None; } pub fn send_init_round(&mut self, th19: &Th19) { let round_initial = current_round_initial(th19); - self.sessions.retain(|session| { - if let Err(err) = session.send_init_round(round_initial.clone()) { - info!("spectator host error: {:?}", err); - return false; - } - true - }); + self.broadcast( + GameLogMessage::InitRound(round_initial.clone()), + |session| session.send_init_round(round_initial.clone()), + ); } pub fn send_inputs(&mut self, p1_input: u16, p2_input: u16) { - self.sessions.retain(|session| { - if let Err(err) = session.send_inputs(p1_input, p2_input) { - info!("spectator host error: {:?}", err); - return false; - } - true + self.broadcast(GameLogMessage::Inputs(p1_input, p2_input), |session| { + session.send_inputs(p1_input, p2_input) }); } } From fd0cf94ce2fd9f650f2c6e4c8b0aa3411c5840ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:22:36 +0000 Subject: [PATCH 08/29] =?UTF-8?q?refactor(spectator):=20=E6=AC=A1=E3=81=AE?= =?UTF-8?q?=E8=A9=A6=E5=90=88=E3=81=AE=20InitGame=20=E3=82=92=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=83=95=E3=82=A1=E3=81=AE=E5=85=88=E9=A0=AD=E3=81=AB?= =?UTF-8?q?=E6=88=BB=E3=81=97=E3=81=A6=E4=BF=9D=E6=8C=81=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - next_game_initial フィールドをやめ、試合中に受け取った InitGame は 受信バッファの先頭に戻す - 待機の上限を超えたときに RecvError を中断の合図として流用していることをコメントに明記 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/session/spectator.rs | 26 ++++++++----------- .../spectator_session/spectator_standby.rs | 1 + 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs index f9db78a..ec1245b 100644 --- a/junowen/src/session/spectator.rs +++ b/junowen/src/session/spectator.rs @@ -63,8 +63,6 @@ pub struct SpectatorSession { disconnected: bool, spectator_initial: Option, round_initial: Option, - /// 観戦者の試合中に受け取った次の試合の開始時の状態 - next_game_initial: Option, } impl SpectatorSession { @@ -78,7 +76,6 @@ impl SpectatorSession { disconnected: false, spectator_initial: None, round_initial: None, - next_game_initial: None, } } @@ -140,8 +137,13 @@ impl SpectatorSession { /// 観戦者の試合中に次の試合が始まったかどうか /// /// 観戦者の試合がホストとずれた場合に起こる。この場合は今の試合を打ち切り、次の試合から同期し直す + /// + /// 試合中に受け取った `InitGame` はバッファの先頭に戻しておき、次の試合の待機状態で受け取る pub fn has_next_game_initial(&self) -> bool { - self.next_game_initial.is_some() + matches!( + self.buffer.front(), + Some(SpectatorSessionMessage::InitGame(_)) + ) } /// 前の試合で残ったラウンドの初期化情報を破棄する。試合開始を待つ状態に入るときに呼ぶ @@ -153,9 +155,6 @@ impl SpectatorSession { /// /// 観戦者の試合がホストより早く終わった場合などに残った入力は読み捨てる pub fn try_recv_init_game(&mut self) -> Result, RecvError> { - if let Some(init) = self.next_game_initial.take() { - return Ok(Some(init)); - } self.fill_buffer(); while let Some(msg) = self.buffer.pop_front() { match msg { @@ -178,18 +177,15 @@ impl SpectatorSession { if let Some(round_initial) = self.round_initial.take() { return Ok(Some(round_initial)); } - if self.next_game_initial.is_some() { - return Ok(None); - } loop { match self.next_message()? { SpectatorSessionMessage::InitSpectator(init) => { error!("unexpected init spectator message: {:?}", init); return Err(RecvError); } - SpectatorSessionMessage::InitGame(init) => { + msg @ SpectatorSessionMessage::InitGame(_) => { warn!("next game started during round over"); - self.next_game_initial = Some(init); + self.buffer.push_front(msg); return Ok(None); } SpectatorSessionMessage::InitRound(round_initial) => { @@ -201,7 +197,7 @@ impl SpectatorSession { } pub fn dequeue_inputs(&mut self) -> Result<(u16, u16), RecvError> { - if self.round_initial.is_some() || self.next_game_initial.is_some() { + if self.round_initial.is_some() { return Ok((0, 0)); } match self.next_message()? { @@ -209,9 +205,9 @@ impl SpectatorSession { error!("unexpected init spectator message: {:?}", init); Err(RecvError) } - SpectatorSessionMessage::InitGame(init) => { + msg @ SpectatorSessionMessage::InitGame(_) => { warn!("next game started during game"); - self.next_game_initial = Some(init); + self.buffer.push_front(msg); Ok((0, 0)) } SpectatorSessionMessage::InitRound(round_initial) => { diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index 06239ec..abba666 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -131,6 +131,7 @@ impl SpectatorStandby { self.sync_frames += 1; if self.sync_frames > MAX_SYNC_FRAMES { warn!("failed to reproduce game initial"); + // NOTE: 呼び出し側はエラーでセッションを中断するため、`RecvError` を中断の合図として流用している return Err(RecvError); } let init = self.game_initial.clone().unwrap(); From e7ef63c8e5cffb88a3304b2a02b2a2f0694b3132 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:27:51 +0000 Subject: [PATCH 09/29] =?UTF-8?q?chore(spectator):=20=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E9=81=B8=E6=8A=9E=E7=8A=B6=E6=85=8B=E3=81=AE?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E5=A0=B4=E6=89=80=E3=82=92=E8=AA=BF=E3=81=B9?= =?UTF-8?q?=E3=82=8B=E4=B8=80=E6=99=82=E7=9A=84=E3=81=AA=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 観戦者側で selection.card に書き込んでも、試合では常に初期位置のカードが 使われることがわかった。ゲームが実際に使うカードの値の場所を特定するため、 試合開始時にホストと観戦者の両方で候補の値をログに出力する。 原因がわかったら削除する。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/th19/structs/app.rs | 12 ++++++ junowen-lib/src/th19/structs/selection.rs | 12 ++++++ junowen/src/helper.rs | 50 ++++++++++++++++++++++- junowen/src/state/battle_session.rs | 4 +- junowen/src/state/spectator_session.rs | 3 +- 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/junowen-lib/src/th19/structs/app.rs b/junowen-lib/src/th19/structs/app.rs index bc1f6c0..a44a331 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/junowen-lib/src/th19/structs/app.rs @@ -53,6 +53,18 @@ pub struct CharacterCursor { _unknown1: [u8; 0xd0], } +impl CharacterCursor { + /// 未解析の領域を調べるための生のバイト列 + pub fn raw_bytes(&self) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + (self as *const Self).cast::(), + std::mem::size_of::(), + ) + } + } +} + #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] #[repr(C)] pub struct Menu { diff --git a/junowen-lib/src/th19/structs/selection.rs b/junowen-lib/src/th19/structs/selection.rs index 638d11e..0f5b44f 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/junowen-lib/src/th19/structs/selection.rs @@ -15,6 +15,18 @@ pub struct Player { _unknown3: [u8; 0x34], } +impl Player { + /// 未解析の領域を調べるための生のバイト列 + pub fn raw_bytes(&self) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + (self as *const Self).cast::(), + std::mem::size_of::(), + ) + } + } +} + #[derive(Clone, Copy, PartialEq)] #[repr(u32)] pub enum Difficulty { diff --git a/junowen/src/helper.rs b/junowen/src/helper.rs index 1fe5e14..d3b6f6e 100644 --- a/junowen/src/helper.rs +++ b/junowen/src/helper.rs @@ -1,4 +1,8 @@ -use junowen_lib::structs::input_devices::InputDevices; +use junowen_lib::{ + Th19, + structs::{app::MainMenu, input_devices::InputDevices}, +}; +use tracing::warn; pub fn inputed_number(input_devices: &InputDevices) -> Option { let raw_keys = input_devices.keyboard_input().raw_keys(); @@ -14,3 +18,47 @@ pub fn pushed_escape(input_devices: &InputDevices) -> bool { let raw_keys = input_devices.keyboard_input().raw_keys(); raw_keys[0x1b] & 0x80 != 0 } + +fn to_hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|x| format!("{:02x}", x)) + .collect::>() + .join(" ") +} + +/// TODO: カードの選択状態がどこに保持されているかを調べるための一時的な診断ログ。原因がわかったら削除する +pub fn dump_card_candidates(label: &str, main_menu: &MainMenu, th19: &Th19) { + let selection = th19.selection(); + let vs_mode = th19.vs_mode(); + let menu = main_menu.menu(); + warn!( + "[card-diagnostics] {} screen={:?} selection.card=({}, {}) vs_mode.card=({}, {})", + label, + main_menu.screen_id(), + selection.p1().card, + selection.p2().card, + vs_mode.p1_card(), + vs_mode.p2_card(), + ); + warn!( + "[card-diagnostics] {} selection.p1={}", + label, + to_hex(selection.p1().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} selection.p2={}", + label, + to_hex(selection.p2().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} menu.p1_cursor={}", + label, + to_hex(menu.p1_cursor().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} menu.p2_cursor={}", + label, + to_hex(menu.p2_cursor().raw_bytes()) + ); +} diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index d69ecf1..4513ad1 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -14,7 +14,8 @@ use junowen_lib::{ }; use crate::{ - file::Features, session::battle::BattleSession as BattleSessionProps, + file::Features, helper::dump_card_candidates, + session::battle::BattleSession as BattleSessionProps, signaling::waiting_for_match::WaitingForSpectator, }; @@ -79,6 +80,7 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { + dump_card_candidates("host", main_menu, th19); self.spectator_host_state.start_game(th19); self.change_to_game_loading(); Some(Some(main_menu)) diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 6e0a7cf..94f0972 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -16,7 +16,7 @@ use junowen_lib::{ use tracing::warn; use crate::{ - helper::pushed_escape, + helper::{dump_card_candidates, pushed_escape}, session::{ RoundInitial, spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, @@ -125,6 +125,7 @@ impl SpectatorSession { match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, ScreenId::GameLoading => { + dump_card_candidates("spectator", main_menu, th19); let init = standby.take_game_initial(); if let Some(init) = &init && !verify_game_initial(th19, init) From 718716ee8e5648382b7c8dc2169c70aa75771380 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:34:09 +0000 Subject: [PATCH 10/29] =?UTF-8?q?Revert=20"chore(spectator):=20=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AE=E9=81=B8=E6=8A=9E=E7=8A=B6=E6=85=8B?= =?UTF-8?q?=E3=81=AE=E4=BF=9D=E6=8C=81=E5=A0=B4=E6=89=80=E3=82=92=E8=AA=BF?= =?UTF-8?q?=E3=81=B9=E3=82=8B=E4=B8=80=E6=99=82=E7=9A=84=E3=81=AA=E8=A8=BA?= =?UTF-8?q?=E6=96=AD=E3=83=AD=E3=82=B0=E3=82=92=E8=BF=BD=E5=8A=A0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit カードは入力で選ばせる方式にするため、診断ログは不要になった。 This reverts commit e7ef63c. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/th19/structs/app.rs | 12 ------ junowen-lib/src/th19/structs/selection.rs | 12 ------ junowen/src/helper.rs | 50 +---------------------- junowen/src/state/battle_session.rs | 4 +- junowen/src/state/spectator_session.rs | 3 +- 5 files changed, 3 insertions(+), 78 deletions(-) diff --git a/junowen-lib/src/th19/structs/app.rs b/junowen-lib/src/th19/structs/app.rs index a44a331..bc1f6c0 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/junowen-lib/src/th19/structs/app.rs @@ -53,18 +53,6 @@ pub struct CharacterCursor { _unknown1: [u8; 0xd0], } -impl CharacterCursor { - /// 未解析の領域を調べるための生のバイト列 - pub fn raw_bytes(&self) -> &[u8] { - unsafe { - std::slice::from_raw_parts( - (self as *const Self).cast::(), - std::mem::size_of::(), - ) - } - } -} - #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] #[repr(C)] pub struct Menu { diff --git a/junowen-lib/src/th19/structs/selection.rs b/junowen-lib/src/th19/structs/selection.rs index 0f5b44f..638d11e 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/junowen-lib/src/th19/structs/selection.rs @@ -15,18 +15,6 @@ pub struct Player { _unknown3: [u8; 0x34], } -impl Player { - /// 未解析の領域を調べるための生のバイト列 - pub fn raw_bytes(&self) -> &[u8] { - unsafe { - std::slice::from_raw_parts( - (self as *const Self).cast::(), - std::mem::size_of::(), - ) - } - } -} - #[derive(Clone, Copy, PartialEq)] #[repr(u32)] pub enum Difficulty { diff --git a/junowen/src/helper.rs b/junowen/src/helper.rs index d3b6f6e..1fe5e14 100644 --- a/junowen/src/helper.rs +++ b/junowen/src/helper.rs @@ -1,8 +1,4 @@ -use junowen_lib::{ - Th19, - structs::{app::MainMenu, input_devices::InputDevices}, -}; -use tracing::warn; +use junowen_lib::structs::input_devices::InputDevices; pub fn inputed_number(input_devices: &InputDevices) -> Option { let raw_keys = input_devices.keyboard_input().raw_keys(); @@ -18,47 +14,3 @@ pub fn pushed_escape(input_devices: &InputDevices) -> bool { let raw_keys = input_devices.keyboard_input().raw_keys(); raw_keys[0x1b] & 0x80 != 0 } - -fn to_hex(bytes: &[u8]) -> String { - bytes - .iter() - .map(|x| format!("{:02x}", x)) - .collect::>() - .join(" ") -} - -/// TODO: カードの選択状態がどこに保持されているかを調べるための一時的な診断ログ。原因がわかったら削除する -pub fn dump_card_candidates(label: &str, main_menu: &MainMenu, th19: &Th19) { - let selection = th19.selection(); - let vs_mode = th19.vs_mode(); - let menu = main_menu.menu(); - warn!( - "[card-diagnostics] {} screen={:?} selection.card=({}, {}) vs_mode.card=({}, {})", - label, - main_menu.screen_id(), - selection.p1().card, - selection.p2().card, - vs_mode.p1_card(), - vs_mode.p2_card(), - ); - warn!( - "[card-diagnostics] {} selection.p1={}", - label, - to_hex(selection.p1().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} selection.p2={}", - label, - to_hex(selection.p2().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} menu.p1_cursor={}", - label, - to_hex(menu.p1_cursor().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} menu.p2_cursor={}", - label, - to_hex(menu.p2_cursor().raw_bytes()) - ); -} diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index 4513ad1..d69ecf1 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -14,8 +14,7 @@ use junowen_lib::{ }; use crate::{ - file::Features, helper::dump_card_candidates, - session::battle::BattleSession as BattleSessionProps, + file::Features, session::battle::BattleSession as BattleSessionProps, signaling::waiting_for_match::WaitingForSpectator, }; @@ -80,7 +79,6 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { - dump_card_candidates("host", main_menu, th19); self.spectator_host_state.start_game(th19); self.change_to_game_loading(); Some(Some(main_menu)) diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 94f0972..6e0a7cf 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -16,7 +16,7 @@ use junowen_lib::{ use tracing::warn; use crate::{ - helper::{dump_card_candidates, pushed_escape}, + helper::pushed_escape, session::{ RoundInitial, spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, @@ -125,7 +125,6 @@ impl SpectatorSession { match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, ScreenId::GameLoading => { - dump_card_candidates("spectator", main_menu, th19); let init = standby.take_game_initial(); if let Some(init) = &init && !verify_game_initial(th19, init) From 85e0399770d68fa113c91f106784464f6ecf363b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:35:23 +0000 Subject: [PATCH 11/29] =?UTF-8?q?fix(spectator):=20=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92=E5=85=A5=E5=8A=9B=E3=81=A7=E9=81=B8=E3=81=B0?= =?UTF-8?q?=E3=81=9B=E3=81=A6=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selection.card への書き込みはキャラクター選択画面の表示にのみ影響し、 試合で使われるカードには反映されなかった。通常の操作と同じく 「低速移動を押しながら左右」の入力でカードを切り替える。 - 観戦者側で現在のカードを把握する。タイトル画面を経由すると初期位置に戻り、 以降は前の試合の選択が引き継がれる - キャラクター選択画面に入ってから少し待ち、目標との差の分だけ入力してから決定する Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/state/spectator_session.rs | 10 ++- .../spectator_session/spectator_standby.rs | 87 +++++++++++++++---- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 6e0a7cf..3887f05 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -64,6 +64,8 @@ fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { pub struct SpectatorSession { props: SpectatorSessionProps, state: SpectatorSessionState, + /// 観戦者側の現在のカード。タイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれる + cards: [u8; 2], } enum SpectatorSessionState { @@ -80,6 +82,7 @@ impl SpectatorSession { Self { props, state: SpectatorSessionState::Prepare(Prepare::new()), + cards: [0, 0], } } @@ -89,10 +92,12 @@ impl SpectatorSession { pub fn change_to_prepare(&mut self) { self.state = SpectatorSessionState::Prepare(Prepare::new()); + // タイトル画面を経由するので、カードは初期位置に戻る + self.cards = [0, 0]; } pub fn change_to_standby(&mut self, first_time: bool) { self.props.discard_round_initial(); - self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time)); + self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time, self.cards)); } pub fn change_to_game_loading(&mut self, round_initial: Option) { self.state = SpectatorSessionState::GameLoading(round_initial); @@ -132,6 +137,9 @@ impl SpectatorSession { // ずれた試合を見せ続けないよう、観戦を終了する return None; } + if let Some(init) = &init { + self.cards = [init.p1().card(), init.p2().card()]; + } self.change_to_game_loading(init.map(|x| x.round_initial().clone())); Some(Some(main_menu)) } diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index abba666..e50a051 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -23,20 +23,14 @@ fn clear_player_inputs(th19: &mut Th19) { .set_current(InputValue::empty()); } -/// キャラクター選択画面のカーソルとカードが試合開始時の状態と一致しているか -fn matches_character_select(main_menu: &MainMenu, th19: &Th19, init: &GameInitial) -> bool { +/// キャラクター選択画面のカーソルが試合開始時の状態と一致しているか +fn matches_characters(main_menu: &MainMenu, init: &GameInitial) -> bool { let menu = main_menu.menu(); - let selection = th19.selection(); menu.p1_cursor().cursor == init.p1().character() as u32 && menu.p2_cursor().cursor == init.p2().character() as u32 - && selection.p1().card == init.p1().card() as u32 - && selection.p2().card == init.p2().card() as u32 } -fn set_character_select(th19: &mut Th19, init: &GameInitial) { - let selection = th19.selection_mut(); - selection.p1_mut().card = init.p1().card() as u32; - selection.p2_mut().card = init.p2().card() as u32; +fn set_characters(th19: &mut Th19, init: &GameInitial) { let menu = th19 .app_mut() .main_loop_tasks_mut() @@ -51,6 +45,58 @@ fn set_character_select(th19: &mut Th19, init: &GameInitial) { p2_cursor.prev_cursor = p2_cursor.cursor; } +/// キャラクター選択画面に入ってからカードを切り替え始めるまでのフレーム数 +/// +/// 画面遷移中の入力は無視される可能性があるため、少し待つ +const CARD_MOVE_START_DELAY_FRAMES: u32 = 30; +/// カードを1つ切り替えるごとに空けるフレーム数 +const CARD_MOVE_INTERVAL_FRAMES: u32 = 4; + +/// カードの選択状態はメモリへの書き込みでは反映されないため、 +/// 通常の操作と同じく「低速移動を押しながら左右」の入力で切り替える +/// +/// カードはタイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれるので、 +/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する +struct CardMover { + current: [u8; 2], + wait: u32, +} + +impl CardMover { + fn is_done(&self, targets: [u8; 2]) -> bool { + self.current == targets + } + + /// 1フレーム分の入力を返す + fn next_inputs(&mut self, targets: [u8; 2]) -> [InputValue; 2] { + let mut inputs = [InputValue::empty(); 2]; + for ((current, target), input) in self.current.iter_mut().zip(targets).zip(&mut inputs) { + if *current == target { + continue; + } + if self.wait > 0 { + // 低速移動は押したままにする + *input = InputFlags::SLOW.into(); + continue; + } + let direction = if *current < target { + *current += 1; + InputFlags::RIGHT + } else { + *current -= 1; + InputFlags::LEFT + }; + *input = InputValue(InputFlags::SLOW | direction); + } + if self.wait > 0 { + self.wait -= 1; + } else { + self.wait = CARD_MOVE_INTERVAL_FRAMES; + } + inputs + } +} + /// 試合開始時の状態を受け取ってから、試合開始までに許容するフレーム数 /// /// 状態を合わせられない場合 (カーソルの値とキャラクター番号の対応が想定と違う場合など) に諦める @@ -66,16 +112,21 @@ pub struct SpectatorStandby { sync_frames: u32, /// 直前のフレームで PAUSE を自動で入力したか。観戦者自身の PAUSE と区別するために使う pause_injected: bool, + card_mover: CardMover, } impl SpectatorStandby { - /// `first_time` は観戦を開始した直後かどうか - pub fn new(first_time: bool) -> Self { + /// `first_time` は観戦を開始した直後かどうか。`current_cards` は観戦者側の現在のカード + pub fn new(first_time: bool, current_cards: [u8; 2]) -> Self { Self { cursors_reset: !first_time, game_initial: None, sync_frames: 0, pause_injected: false, + card_mover: CardMover { + current: current_cards, + wait: CARD_MOVE_START_DELAY_FRAMES, + }, } } @@ -139,6 +190,7 @@ impl SpectatorStandby { clear_player_inputs(th19); return Ok(()); } + let card_targets = [init.p1().card(), init.p2().card()]; let input_devices = th19.input_devices(); let (prev_p1, prev_p2) = ( input_devices.p1_input().prev(), @@ -154,12 +206,15 @@ impl SpectatorStandby { pause }; (p1, InputValue::empty()) - } else if matches_character_select(main_menu, th19, &init) { - // 一致していることを確認できたので決定する。準備完了後もカードは変更できるため合わせ続ける - (shot_repeatedly(prev_p1), shot_repeatedly(prev_p2)) - } else { - set_character_select(th19, &init); + } else if !matches_characters(main_menu, &init) { + set_characters(th19, &init); (InputValue::empty(), InputValue::empty()) + } else if !self.card_mover.is_done(card_targets) { + let [p1, p2] = self.card_mover.next_inputs(card_targets); + (p1, p2) + } else { + // 一致していることを確認できたので決定する + (shot_repeatedly(prev_p1), shot_repeatedly(prev_p2)) }; let input_devices = th19.input_devices_mut(); input_devices.p1_input_mut().set_current(p1); From 29947ec524e81c80ffe9152b1419d9510d979340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:12:14 +0000 Subject: [PATCH 12/29] =?UTF-8?q?fix(spectator):=20=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88=E3=81=8C?= =?UTF-8?q?=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91=E3=82=89=E3=82=8C=E3=81=9F?= =?UTF-8?q?=E3=81=93=E3=81=A8=E3=82=92=E7=A2=BA=E8=AA=8D=E3=81=97=E3=81=AA?= =?UTF-8?q?=E3=81=8C=E3=82=89=E5=85=A5=E5=8A=9B=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面遷移中などに左右を押すと入力が無視され、カードが切り替わらなかった。 押す前の selection.card を覚えておき、値が変わったら入力が受け付けられたとみなして 次へ進む。一定フレーム変わらなければ押し直す。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- .../spectator_session/spectator_standby.rs | 141 +++++++++++++----- 1 file changed, 102 insertions(+), 39 deletions(-) diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index e50a051..6237d94 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -46,54 +46,118 @@ fn set_characters(th19: &mut Th19, init: &GameInitial) { } /// キャラクター選択画面に入ってからカードを切り替え始めるまでのフレーム数 -/// -/// 画面遷移中の入力は無視される可能性があるため、少し待つ const CARD_MOVE_START_DELAY_FRAMES: u32 = 30; -/// カードを1つ切り替えるごとに空けるフレーム数 +/// 左右を押す前に低速移動を押したまま待つフレーム数 const CARD_MOVE_INTERVAL_FRAMES: u32 = 4; +/// 左右を押してから、カードが切り替わったかを判定するまでに待つ最大フレーム数。 +/// 切り替わらなければ入力が受け付けられなかったとみなして押し直す +const CARD_MOVE_ACCEPT_FRAMES: u32 = 10; + +#[derive(Clone, Copy)] +enum CardMoveState { + /// 低速移動を押したまま待っている + Waiting(u32), + /// 左右を押した。`before` は押す前の `selection.card` + Pressed { before: u32, frames: u32 }, +} /// カードの選択状態はメモリへの書き込みでは反映されないため、 /// 通常の操作と同じく「低速移動を押しながら左右」の入力で切り替える /// /// カードはタイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれるので、 -/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する -struct CardMover { - current: [u8; 2], - wait: u32, +/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する。 +/// 画面遷移中などは入力が無視されるため、`selection.card` の変化で入力が受け付けられたことを確認し、 +/// 変化しなければ押し直す +struct PlayerCardMover { + current: u8, + state: CardMoveState, + retries: u32, } -impl CardMover { - fn is_done(&self, targets: [u8; 2]) -> bool { - self.current == targets +impl PlayerCardMover { + fn new(current: u8) -> Self { + Self { + current, + state: CardMoveState::Waiting(CARD_MOVE_START_DELAY_FRAMES), + retries: 0, + } } - /// 1フレーム分の入力を返す - fn next_inputs(&mut self, targets: [u8; 2]) -> [InputValue; 2] { - let mut inputs = [InputValue::empty(); 2]; - for ((current, target), input) in self.current.iter_mut().zip(targets).zip(&mut inputs) { - if *current == target { - continue; + /// 1フレーム分の入力を返す。`card` は現在の `selection.card` + fn next_input(&mut self, target: u8, card: u32) -> InputValue { + if self.current == target { + return InputValue::empty(); + } + let slow: InputValue = InputFlags::SLOW.into(); + match self.state { + CardMoveState::Waiting(frames) if frames > 0 => { + self.state = CardMoveState::Waiting(frames - 1); + slow } - if self.wait > 0 { - // 低速移動は押したままにする - *input = InputFlags::SLOW.into(); - continue; + CardMoveState::Waiting(_) => { + let direction = if self.current < target { + InputFlags::RIGHT + } else { + InputFlags::LEFT + }; + self.state = CardMoveState::Pressed { + before: card, + frames: 0, + }; + InputValue(InputFlags::SLOW | direction) + } + CardMoveState::Pressed { before, .. } if card != before => { + // 入力が受け付けられた + if self.current < target { + self.current += 1; + } else { + self.current -= 1; + } + self.retries = 0; + self.state = CardMoveState::Waiting(CARD_MOVE_INTERVAL_FRAMES); + slow + } + CardMoveState::Pressed { frames, .. } if frames >= CARD_MOVE_ACCEPT_FRAMES => { + // 入力が受け付けられなかったので押し直す + self.retries += 1; + if self.retries.is_multiple_of(10) { + warn!( + "card move is not accepted. retries={}, card={}", + self.retries, card + ); + } + self.state = CardMoveState::Waiting(CARD_MOVE_INTERVAL_FRAMES); + slow + } + CardMoveState::Pressed { before, frames } => { + self.state = CardMoveState::Pressed { + before, + frames: frames + 1, + }; + slow } - let direction = if *current < target { - *current += 1; - InputFlags::RIGHT - } else { - *current -= 1; - InputFlags::LEFT - }; - *input = InputValue(InputFlags::SLOW | direction); - } - if self.wait > 0 { - self.wait -= 1; - } else { - self.wait = CARD_MOVE_INTERVAL_FRAMES; } - inputs + } +} + +struct CardMover([PlayerCardMover; 2]); + +impl CardMover { + fn new(current_cards: [u8; 2]) -> Self { + Self(current_cards.map(PlayerCardMover::new)) + } + + fn is_done(&self, targets: [u8; 2]) -> bool { + self.0[0].current == targets[0] && self.0[1].current == targets[1] + } + + /// 1フレーム分の入力を返す。`cards` は現在の `selection.card` + fn next_inputs(&mut self, targets: [u8; 2], cards: [u32; 2]) -> [InputValue; 2] { + let [p1, p2] = &mut self.0; + [ + p1.next_input(targets[0], cards[0]), + p2.next_input(targets[1], cards[1]), + ] } } @@ -123,10 +187,7 @@ impl SpectatorStandby { game_initial: None, sync_frames: 0, pause_injected: false, - card_mover: CardMover { - current: current_cards, - wait: CARD_MOVE_START_DELAY_FRAMES, - }, + card_mover: CardMover::new(current_cards), } } @@ -210,7 +271,9 @@ impl SpectatorStandby { set_characters(th19, &init); (InputValue::empty(), InputValue::empty()) } else if !self.card_mover.is_done(card_targets) { - let [p1, p2] = self.card_mover.next_inputs(card_targets); + let selection = th19.selection(); + let cards = [selection.p1().card, selection.p2().card]; + let [p1, p2] = self.card_mover.next_inputs(card_targets, cards); (p1, p2) } else { // 一致していることを確認できたので決定する From e6374f378ec2a0c69613a7c21e81785ece3aa4ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:41:34 +0000 Subject: [PATCH 13/29] =?UTF-8?q?fix(spectator):=20=E3=83=9B=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=8C=E9=80=81=E3=82=8B=E3=82=AB=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=20vs=5Fmode=20=E3=81=AE=E5=80=A4=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selection.card は v1.10c で構造体の配置が変わった後、正しい値を指していない 可能性が高く、ホストが常に 0 を送っていたため観戦者はカードを切り替えなかった。 - ホストは前回の選択を保持している vs_mode のカードを送り、観戦者も同じ値で照合する - selection.card の変化で入力の受け付けを確認する方式は、値があてにならないため 回数を数えて入力する方式に戻し、開始前の待ち時間を 60 フレームに延ばす - 原因を確かめるため、試合開始時に両方の候補の値をログに出す (一時的) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- .../state/battle_session/spectator_host.rs | 24 +-- junowen/src/state/spectator_session.rs | 5 +- .../spectator_session/spectator_standby.rs | 144 +++++------------- 3 files changed, 59 insertions(+), 114 deletions(-) diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index ecb87b8..1a75137 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,10 +1,7 @@ use anyhow::Result; use getset::Getters; -use junowen_lib::{ - Th19, - structs::{app::MainMenu, selection::Player}, -}; -use tracing::info; +use junowen_lib::{Th19, structs::app::MainMenu}; +use tracing::{info, warn}; use crate::{ session::{ @@ -156,12 +153,21 @@ impl SpectatorHostState { /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ pub fn start_game(&mut self, th19: &Th19) { let selection = th19.selection(); - let player = - |player: &Player| PlayerGameInitial::new(player.character as u8, player.card as u8); + // NOTE: `selection` のカードは v1.10c で構造体の配置が変わった後、正しい値を指していない可能性が高い。 + // 前回の選択を保持している `vs_mode` の値を使う + let vs_mode = th19.vs_mode(); + // TODO: カードの値の場所が確定したら削除する (ログファイルには WARN 以上しか出力されない) + warn!( + "start game. selection.card=({}, {}) vs_mode.card=({}, {})", + selection.p1().card, + selection.p2().card, + vs_mode.p1_card(), + vs_mode.p2_card(), + ); let init = GameInitial::new( selection.difficulty as u8, - player(selection.p1()), - player(selection.p2()), + PlayerGameInitial::new(selection.p1().character as u8, vs_mode.p1_card()), + PlayerGameInitial::new(selection.p2().character as u8, vs_mode.p2_card()), current_round_initial(th19), ); self.sessions.append(&mut self.standby_sessions); diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 3887f05..558f55e 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -37,12 +37,13 @@ fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { /// 観戦者の試合開始時の状態がホストと一致しているか確認する fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { let selection = th19.selection(); + let vs_mode = th19.vs_mode(); let actual = ( selection.difficulty as u8, selection.p1().character as u8, - selection.p1().card as u8, + vs_mode.p1_card(), selection.p2().character as u8, - selection.p2().card as u8, + vs_mode.p2_card(), ); let expected = ( init.difficulty(), diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index 6237d94..25837df 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -46,118 +46,55 @@ fn set_characters(th19: &mut Th19, init: &GameInitial) { } /// キャラクター選択画面に入ってからカードを切り替え始めるまでのフレーム数 -const CARD_MOVE_START_DELAY_FRAMES: u32 = 30; -/// 左右を押す前に低速移動を押したまま待つフレーム数 +/// +/// 画面遷移中の入力は無視される可能性があるため、少し待つ。 +/// カードの現在値を読み取れる場所がわかっていないため、入力が受け付けられたかは確認できない +const CARD_MOVE_START_DELAY_FRAMES: u32 = 60; +/// カードを1つ切り替えるごとに空けるフレーム数 const CARD_MOVE_INTERVAL_FRAMES: u32 = 4; -/// 左右を押してから、カードが切り替わったかを判定するまでに待つ最大フレーム数。 -/// 切り替わらなければ入力が受け付けられなかったとみなして押し直す -const CARD_MOVE_ACCEPT_FRAMES: u32 = 10; - -#[derive(Clone, Copy)] -enum CardMoveState { - /// 低速移動を押したまま待っている - Waiting(u32), - /// 左右を押した。`before` は押す前の `selection.card` - Pressed { before: u32, frames: u32 }, -} /// カードの選択状態はメモリへの書き込みでは反映されないため、 /// 通常の操作と同じく「低速移動を押しながら左右」の入力で切り替える /// /// カードはタイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれるので、 -/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する。 -/// 画面遷移中などは入力が無視されるため、`selection.card` の変化で入力が受け付けられたことを確認し、 -/// 変化しなければ押し直す -struct PlayerCardMover { - current: u8, - state: CardMoveState, - retries: u32, +/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する +struct CardMover { + current: [u8; 2], + wait: u32, } -impl PlayerCardMover { - fn new(current: u8) -> Self { - Self { - current, - state: CardMoveState::Waiting(CARD_MOVE_START_DELAY_FRAMES), - retries: 0, - } +impl CardMover { + fn is_done(&self, targets: [u8; 2]) -> bool { + self.current == targets } - /// 1フレーム分の入力を返す。`card` は現在の `selection.card` - fn next_input(&mut self, target: u8, card: u32) -> InputValue { - if self.current == target { - return InputValue::empty(); - } - let slow: InputValue = InputFlags::SLOW.into(); - match self.state { - CardMoveState::Waiting(frames) if frames > 0 => { - self.state = CardMoveState::Waiting(frames - 1); - slow - } - CardMoveState::Waiting(_) => { - let direction = if self.current < target { - InputFlags::RIGHT - } else { - InputFlags::LEFT - }; - self.state = CardMoveState::Pressed { - before: card, - frames: 0, - }; - InputValue(InputFlags::SLOW | direction) - } - CardMoveState::Pressed { before, .. } if card != before => { - // 入力が受け付けられた - if self.current < target { - self.current += 1; - } else { - self.current -= 1; - } - self.retries = 0; - self.state = CardMoveState::Waiting(CARD_MOVE_INTERVAL_FRAMES); - slow + /// 1フレーム分の入力を返す + fn next_inputs(&mut self, targets: [u8; 2]) -> [InputValue; 2] { + let mut inputs = [InputValue::empty(); 2]; + for ((current, target), input) in self.current.iter_mut().zip(targets).zip(&mut inputs) { + if *current == target { + continue; } - CardMoveState::Pressed { frames, .. } if frames >= CARD_MOVE_ACCEPT_FRAMES => { - // 入力が受け付けられなかったので押し直す - self.retries += 1; - if self.retries.is_multiple_of(10) { - warn!( - "card move is not accepted. retries={}, card={}", - self.retries, card - ); - } - self.state = CardMoveState::Waiting(CARD_MOVE_INTERVAL_FRAMES); - slow - } - CardMoveState::Pressed { before, frames } => { - self.state = CardMoveState::Pressed { - before, - frames: frames + 1, - }; - slow + if self.wait > 0 { + // 低速移動は押したままにする + *input = InputFlags::SLOW.into(); + continue; } + let direction = if *current < target { + *current += 1; + InputFlags::RIGHT + } else { + *current -= 1; + InputFlags::LEFT + }; + *input = InputValue(InputFlags::SLOW | direction); } - } -} - -struct CardMover([PlayerCardMover; 2]); - -impl CardMover { - fn new(current_cards: [u8; 2]) -> Self { - Self(current_cards.map(PlayerCardMover::new)) - } - - fn is_done(&self, targets: [u8; 2]) -> bool { - self.0[0].current == targets[0] && self.0[1].current == targets[1] - } - - /// 1フレーム分の入力を返す。`cards` は現在の `selection.card` - fn next_inputs(&mut self, targets: [u8; 2], cards: [u32; 2]) -> [InputValue; 2] { - let [p1, p2] = &mut self.0; - [ - p1.next_input(targets[0], cards[0]), - p2.next_input(targets[1], cards[1]), - ] + if self.wait > 0 { + self.wait -= 1; + } else { + self.wait = CARD_MOVE_INTERVAL_FRAMES; + } + inputs } } @@ -187,7 +124,10 @@ impl SpectatorStandby { game_initial: None, sync_frames: 0, pause_injected: false, - card_mover: CardMover::new(current_cards), + card_mover: CardMover { + current: current_cards, + wait: CARD_MOVE_START_DELAY_FRAMES, + }, } } @@ -271,9 +211,7 @@ impl SpectatorStandby { set_characters(th19, &init); (InputValue::empty(), InputValue::empty()) } else if !self.card_mover.is_done(card_targets) { - let selection = th19.selection(); - let cards = [selection.p1().card, selection.p2().card]; - let [p1, p2] = self.card_mover.next_inputs(card_targets, cards); + let [p1, p2] = self.card_mover.next_inputs(card_targets); (p1, p2) } else { // 一致していることを確認できたので決定する From ed85689b1a53bafca993b5dfe03a38691cf820d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:11:05 +0000 Subject: [PATCH 14/29] =?UTF-8?q?chore(spectator):=20=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E5=80=A4=E3=81=AE=E5=A0=B4=E6=89=80=E3=82=92?= =?UTF-8?q?=E8=AA=BF=E3=81=B9=E3=82=8B=E8=A8=BA=E6=96=AD=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=92=E5=86=8D=E5=BA=A6=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vs_mode のカードに 37 という、カードの番号としてありえない値が入っていたため、 正しい値の場所を改めて調べる。試合開始時にホストと観戦者の両方で、 selection・vs_mode のカード周辺・キャラクター選択カーソルの生のバイト列をログに出力する。 原因がわかったら削除する。 This reverts commit 718716e. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/th19/structs/app.rs | 12 +++++ junowen-lib/src/th19/structs/others.rs | 5 +++ junowen-lib/src/th19/structs/selection.rs | 12 +++++ junowen/src/helper.rs | 55 ++++++++++++++++++++++- junowen/src/state/battle_session.rs | 4 +- junowen/src/state/spectator_session.rs | 3 +- 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/junowen-lib/src/th19/structs/app.rs b/junowen-lib/src/th19/structs/app.rs index bc1f6c0..a44a331 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/junowen-lib/src/th19/structs/app.rs @@ -53,6 +53,18 @@ pub struct CharacterCursor { _unknown1: [u8; 0xd0], } +impl CharacterCursor { + /// 未解析の領域を調べるための生のバイト列 + pub fn raw_bytes(&self) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + (self as *const Self).cast::(), + std::mem::size_of::(), + ) + } + } +} + #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] #[repr(C)] pub struct Menu { diff --git a/junowen-lib/src/th19/structs/others.rs b/junowen-lib/src/th19/structs/others.rs index 2216f89..93f4b31 100644 --- a/junowen-lib/src/th19/structs/others.rs +++ b/junowen-lib/src/th19/structs/others.rs @@ -40,6 +40,11 @@ pub struct VSMode { } impl VSMode { + /// 未解析の領域を調べるための生のバイト列 (+2ea00h から 0x40 バイト。カードの値の周辺) + pub fn raw_bytes_around_cards(&self) -> &[u8] { + unsafe { std::slice::from_raw_parts((self as *const Self).cast::().add(0x2ea00), 0x40) } + } + pub fn player_name(&self) -> &str { CStr::from_bytes_until_nul(&self.player_name) .unwrap_or_default() diff --git a/junowen-lib/src/th19/structs/selection.rs b/junowen-lib/src/th19/structs/selection.rs index 638d11e..0f5b44f 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/junowen-lib/src/th19/structs/selection.rs @@ -15,6 +15,18 @@ pub struct Player { _unknown3: [u8; 0x34], } +impl Player { + /// 未解析の領域を調べるための生のバイト列 + pub fn raw_bytes(&self) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + (self as *const Self).cast::(), + std::mem::size_of::(), + ) + } + } +} + #[derive(Clone, Copy, PartialEq)] #[repr(u32)] pub enum Difficulty { diff --git a/junowen/src/helper.rs b/junowen/src/helper.rs index 1fe5e14..377d49e 100644 --- a/junowen/src/helper.rs +++ b/junowen/src/helper.rs @@ -1,4 +1,8 @@ -use junowen_lib::structs::input_devices::InputDevices; +use junowen_lib::{ + Th19, + structs::{app::MainMenu, input_devices::InputDevices}, +}; +use tracing::warn; pub fn inputed_number(input_devices: &InputDevices) -> Option { let raw_keys = input_devices.keyboard_input().raw_keys(); @@ -14,3 +18,52 @@ pub fn pushed_escape(input_devices: &InputDevices) -> bool { let raw_keys = input_devices.keyboard_input().raw_keys(); raw_keys[0x1b] & 0x80 != 0 } + +fn to_hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|x| format!("{:02x}", x)) + .collect::>() + .join(" ") +} + +/// TODO: カードの選択状態がどこに保持されているかを調べるための一時的な診断ログ。原因がわかったら削除する +pub fn dump_card_candidates(label: &str, main_menu: &MainMenu, th19: &Th19) { + let selection = th19.selection(); + let vs_mode = th19.vs_mode(); + let menu = main_menu.menu(); + warn!( + "[card-diagnostics] {} screen={:?} selection.card=({}, {}) vs_mode.card=({}, {})", + label, + main_menu.screen_id(), + selection.p1().card, + selection.p2().card, + vs_mode.p1_card(), + vs_mode.p2_card(), + ); + warn!( + "[card-diagnostics] {} vs_mode+2ea00h={}", + label, + to_hex(vs_mode.raw_bytes_around_cards()) + ); + warn!( + "[card-diagnostics] {} selection.p1={}", + label, + to_hex(selection.p1().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} selection.p2={}", + label, + to_hex(selection.p2().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} menu.p1_cursor={}", + label, + to_hex(menu.p1_cursor().raw_bytes()) + ); + warn!( + "[card-diagnostics] {} menu.p2_cursor={}", + label, + to_hex(menu.p2_cursor().raw_bytes()) + ); +} diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index d69ecf1..4513ad1 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -14,7 +14,8 @@ use junowen_lib::{ }; use crate::{ - file::Features, session::battle::BattleSession as BattleSessionProps, + file::Features, helper::dump_card_candidates, + session::battle::BattleSession as BattleSessionProps, signaling::waiting_for_match::WaitingForSpectator, }; @@ -79,6 +80,7 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { + dump_card_candidates("host", main_menu, th19); self.spectator_host_state.start_game(th19); self.change_to_game_loading(); Some(Some(main_menu)) diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 558f55e..f52f7e5 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -16,7 +16,7 @@ use junowen_lib::{ use tracing::warn; use crate::{ - helper::pushed_escape, + helper::{dump_card_candidates, pushed_escape}, session::{ RoundInitial, spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, @@ -131,6 +131,7 @@ impl SpectatorSession { match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, ScreenId::GameLoading => { + dump_card_candidates("spectator", main_menu, th19); let init = standby.take_game_initial(); if let Some(init) = &init && !verify_game_initial(th19, init) From d62918d03229f08a5b66737840e051e50d0dfb41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:41:57 +0000 Subject: [PATCH 15/29] =?UTF-8?q?fix(junowen-lib):=20Player=20=E3=81=AE?= =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=83=89=E3=81=AE=E4=BD=8D=E7=BD=AE=E3=82=92?= =?UTF-8?q?=20+94h=20=E3=81=AB=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.10c 対応で Player 構造体が大きくなった際、カードの位置が +90h のまま 残っており、常に別の値 (0 や 100) を読んでいた。診断ログで、カードの値が +94h にあり vs_mode のカードと一致することを確認した。 カードの値を正しく読み書きできるようになったため、観戦者のカードは 入力ではなくキャラクター選択画面で直接書き込む方式に戻す。 - selection.card の位置を +94h に修正 - ホストは selection.card を送り、観戦者はキャラクター選択画面で書き込んで照合してから決定する - 入力でカードを切り替える処理 (CardMover) と、観戦者側のカードの把握を削除 - 診断ログを削除 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen-lib/src/th19/structs/app.rs | 12 --- junowen-lib/src/th19/structs/others.rs | 5 -- junowen-lib/src/th19/structs/selection.rs | 20 +---- junowen/src/helper.rs | 55 +----------- junowen/src/state/battle_session.rs | 4 +- .../state/battle_session/spectator_host.rs | 24 ++--- junowen/src/state/spectator_session.rs | 18 +--- .../spectator_session/spectator_standby.rs | 88 ++++--------------- 8 files changed, 35 insertions(+), 191 deletions(-) diff --git a/junowen-lib/src/th19/structs/app.rs b/junowen-lib/src/th19/structs/app.rs index a44a331..bc1f6c0 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/junowen-lib/src/th19/structs/app.rs @@ -53,18 +53,6 @@ pub struct CharacterCursor { _unknown1: [u8; 0xd0], } -impl CharacterCursor { - /// 未解析の領域を調べるための生のバイト列 - pub fn raw_bytes(&self) -> &[u8] { - unsafe { - std::slice::from_raw_parts( - (self as *const Self).cast::(), - std::mem::size_of::(), - ) - } - } -} - #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] #[repr(C)] pub struct Menu { diff --git a/junowen-lib/src/th19/structs/others.rs b/junowen-lib/src/th19/structs/others.rs index 93f4b31..2216f89 100644 --- a/junowen-lib/src/th19/structs/others.rs +++ b/junowen-lib/src/th19/structs/others.rs @@ -40,11 +40,6 @@ pub struct VSMode { } impl VSMode { - /// 未解析の領域を調べるための生のバイト列 (+2ea00h から 0x40 バイト。カードの値の周辺) - pub fn raw_bytes_around_cards(&self) -> &[u8] { - unsafe { std::slice::from_raw_parts((self as *const Self).cast::().add(0x2ea00), 0x40) } - } - pub fn player_name(&self) -> &str { CStr::from_bytes_until_nul(&self.player_name) .unwrap_or_default() diff --git a/junowen-lib/src/th19/structs/selection.rs b/junowen-lib/src/th19/structs/selection.rs index 0f5b44f..14b5423 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/junowen-lib/src/th19/structs/selection.rs @@ -3,28 +3,16 @@ use std::mem::transmute; use anyhow::{Result, bail}; use getset::{Getters, MutGetters}; -/// length=c0 +/// length=c8 #[repr(C)] pub struct Player { _unknown1: [u8; 0x0c], /// NOT available on player select screen pub character: u32, - _unknown2: [u8; 0x80], - /// Available on player select screen + _unknown2: [u8; 0x84], + /// Available on player select screen (+94h) pub card: u32, - _unknown3: [u8; 0x34], -} - -impl Player { - /// 未解析の領域を調べるための生のバイト列 - pub fn raw_bytes(&self) -> &[u8] { - unsafe { - std::slice::from_raw_parts( - (self as *const Self).cast::(), - std::mem::size_of::(), - ) - } - } + _unknown3: [u8; 0x30], } #[derive(Clone, Copy, PartialEq)] diff --git a/junowen/src/helper.rs b/junowen/src/helper.rs index 377d49e..1fe5e14 100644 --- a/junowen/src/helper.rs +++ b/junowen/src/helper.rs @@ -1,8 +1,4 @@ -use junowen_lib::{ - Th19, - structs::{app::MainMenu, input_devices::InputDevices}, -}; -use tracing::warn; +use junowen_lib::structs::input_devices::InputDevices; pub fn inputed_number(input_devices: &InputDevices) -> Option { let raw_keys = input_devices.keyboard_input().raw_keys(); @@ -18,52 +14,3 @@ pub fn pushed_escape(input_devices: &InputDevices) -> bool { let raw_keys = input_devices.keyboard_input().raw_keys(); raw_keys[0x1b] & 0x80 != 0 } - -fn to_hex(bytes: &[u8]) -> String { - bytes - .iter() - .map(|x| format!("{:02x}", x)) - .collect::>() - .join(" ") -} - -/// TODO: カードの選択状態がどこに保持されているかを調べるための一時的な診断ログ。原因がわかったら削除する -pub fn dump_card_candidates(label: &str, main_menu: &MainMenu, th19: &Th19) { - let selection = th19.selection(); - let vs_mode = th19.vs_mode(); - let menu = main_menu.menu(); - warn!( - "[card-diagnostics] {} screen={:?} selection.card=({}, {}) vs_mode.card=({}, {})", - label, - main_menu.screen_id(), - selection.p1().card, - selection.p2().card, - vs_mode.p1_card(), - vs_mode.p2_card(), - ); - warn!( - "[card-diagnostics] {} vs_mode+2ea00h={}", - label, - to_hex(vs_mode.raw_bytes_around_cards()) - ); - warn!( - "[card-diagnostics] {} selection.p1={}", - label, - to_hex(selection.p1().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} selection.p2={}", - label, - to_hex(selection.p2().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} menu.p1_cursor={}", - label, - to_hex(menu.p1_cursor().raw_bytes()) - ); - warn!( - "[card-diagnostics] {} menu.p2_cursor={}", - label, - to_hex(menu.p2_cursor().raw_bytes()) - ); -} diff --git a/junowen/src/state/battle_session.rs b/junowen/src/state/battle_session.rs index 4513ad1..d69ecf1 100644 --- a/junowen/src/state/battle_session.rs +++ b/junowen/src/state/battle_session.rs @@ -14,8 +14,7 @@ use junowen_lib::{ }; use crate::{ - file::Features, helper::dump_card_candidates, - session::battle::BattleSession as BattleSessionProps, + file::Features, session::battle::BattleSession as BattleSessionProps, signaling::waiting_for_match::WaitingForSpectator, }; @@ -80,7 +79,6 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { - dump_card_candidates("host", main_menu, th19); self.spectator_host_state.start_game(th19); self.change_to_game_loading(); Some(Some(main_menu)) diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 1a75137..ecb87b8 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,7 +1,10 @@ use anyhow::Result; use getset::Getters; -use junowen_lib::{Th19, structs::app::MainMenu}; -use tracing::{info, warn}; +use junowen_lib::{ + Th19, + structs::{app::MainMenu, selection::Player}, +}; +use tracing::info; use crate::{ session::{ @@ -153,21 +156,12 @@ impl SpectatorHostState { /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ pub fn start_game(&mut self, th19: &Th19) { let selection = th19.selection(); - // NOTE: `selection` のカードは v1.10c で構造体の配置が変わった後、正しい値を指していない可能性が高い。 - // 前回の選択を保持している `vs_mode` の値を使う - let vs_mode = th19.vs_mode(); - // TODO: カードの値の場所が確定したら削除する (ログファイルには WARN 以上しか出力されない) - warn!( - "start game. selection.card=({}, {}) vs_mode.card=({}, {})", - selection.p1().card, - selection.p2().card, - vs_mode.p1_card(), - vs_mode.p2_card(), - ); + let player = + |player: &Player| PlayerGameInitial::new(player.character as u8, player.card as u8); let init = GameInitial::new( selection.difficulty as u8, - PlayerGameInitial::new(selection.p1().character as u8, vs_mode.p1_card()), - PlayerGameInitial::new(selection.p2().character as u8, vs_mode.p2_card()), + player(selection.p1()), + player(selection.p2()), current_round_initial(th19), ); self.sessions.append(&mut self.standby_sessions); diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index f52f7e5..6e0a7cf 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -16,7 +16,7 @@ use junowen_lib::{ use tracing::warn; use crate::{ - helper::{dump_card_candidates, pushed_escape}, + helper::pushed_escape, session::{ RoundInitial, spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, @@ -37,13 +37,12 @@ fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { /// 観戦者の試合開始時の状態がホストと一致しているか確認する fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { let selection = th19.selection(); - let vs_mode = th19.vs_mode(); let actual = ( selection.difficulty as u8, selection.p1().character as u8, - vs_mode.p1_card(), + selection.p1().card as u8, selection.p2().character as u8, - vs_mode.p2_card(), + selection.p2().card as u8, ); let expected = ( init.difficulty(), @@ -65,8 +64,6 @@ fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { pub struct SpectatorSession { props: SpectatorSessionProps, state: SpectatorSessionState, - /// 観戦者側の現在のカード。タイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれる - cards: [u8; 2], } enum SpectatorSessionState { @@ -83,7 +80,6 @@ impl SpectatorSession { Self { props, state: SpectatorSessionState::Prepare(Prepare::new()), - cards: [0, 0], } } @@ -93,12 +89,10 @@ impl SpectatorSession { pub fn change_to_prepare(&mut self) { self.state = SpectatorSessionState::Prepare(Prepare::new()); - // タイトル画面を経由するので、カードは初期位置に戻る - self.cards = [0, 0]; } pub fn change_to_standby(&mut self, first_time: bool) { self.props.discard_round_initial(); - self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time, self.cards)); + self.state = SpectatorSessionState::Standby(SpectatorStandby::new(first_time)); } pub fn change_to_game_loading(&mut self, round_initial: Option) { self.state = SpectatorSessionState::GameLoading(round_initial); @@ -131,7 +125,6 @@ impl SpectatorSession { match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, ScreenId::GameLoading => { - dump_card_candidates("spectator", main_menu, th19); let init = standby.take_game_initial(); if let Some(init) = &init && !verify_game_initial(th19, init) @@ -139,9 +132,6 @@ impl SpectatorSession { // ずれた試合を見せ続けないよう、観戦を終了する return None; } - if let Some(init) = &init { - self.cards = [init.p1().card(), init.p2().card()]; - } self.change_to_game_loading(init.map(|x| x.round_initial().clone())); Some(Some(main_menu)) } diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/junowen/src/state/spectator_session/spectator_standby.rs index 25837df..abba666 100644 --- a/junowen/src/state/spectator_session/spectator_standby.rs +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -23,14 +23,20 @@ fn clear_player_inputs(th19: &mut Th19) { .set_current(InputValue::empty()); } -/// キャラクター選択画面のカーソルが試合開始時の状態と一致しているか -fn matches_characters(main_menu: &MainMenu, init: &GameInitial) -> bool { +/// キャラクター選択画面のカーソルとカードが試合開始時の状態と一致しているか +fn matches_character_select(main_menu: &MainMenu, th19: &Th19, init: &GameInitial) -> bool { let menu = main_menu.menu(); + let selection = th19.selection(); menu.p1_cursor().cursor == init.p1().character() as u32 && menu.p2_cursor().cursor == init.p2().character() as u32 + && selection.p1().card == init.p1().card() as u32 + && selection.p2().card == init.p2().card() as u32 } -fn set_characters(th19: &mut Th19, init: &GameInitial) { +fn set_character_select(th19: &mut Th19, init: &GameInitial) { + let selection = th19.selection_mut(); + selection.p1_mut().card = init.p1().card() as u32; + selection.p2_mut().card = init.p2().card() as u32; let menu = th19 .app_mut() .main_loop_tasks_mut() @@ -45,59 +51,6 @@ fn set_characters(th19: &mut Th19, init: &GameInitial) { p2_cursor.prev_cursor = p2_cursor.cursor; } -/// キャラクター選択画面に入ってからカードを切り替え始めるまでのフレーム数 -/// -/// 画面遷移中の入力は無視される可能性があるため、少し待つ。 -/// カードの現在値を読み取れる場所がわかっていないため、入力が受け付けられたかは確認できない -const CARD_MOVE_START_DELAY_FRAMES: u32 = 60; -/// カードを1つ切り替えるごとに空けるフレーム数 -const CARD_MOVE_INTERVAL_FRAMES: u32 = 4; - -/// カードの選択状態はメモリへの書き込みでは反映されないため、 -/// 通常の操作と同じく「低速移動を押しながら左右」の入力で切り替える -/// -/// カードはタイトル画面で初期位置に戻り、以降は前の試合の選択が引き継がれるので、 -/// 観戦者側で現在のカードを把握しておき、目標との差の分だけ入力する -struct CardMover { - current: [u8; 2], - wait: u32, -} - -impl CardMover { - fn is_done(&self, targets: [u8; 2]) -> bool { - self.current == targets - } - - /// 1フレーム分の入力を返す - fn next_inputs(&mut self, targets: [u8; 2]) -> [InputValue; 2] { - let mut inputs = [InputValue::empty(); 2]; - for ((current, target), input) in self.current.iter_mut().zip(targets).zip(&mut inputs) { - if *current == target { - continue; - } - if self.wait > 0 { - // 低速移動は押したままにする - *input = InputFlags::SLOW.into(); - continue; - } - let direction = if *current < target { - *current += 1; - InputFlags::RIGHT - } else { - *current -= 1; - InputFlags::LEFT - }; - *input = InputValue(InputFlags::SLOW | direction); - } - if self.wait > 0 { - self.wait -= 1; - } else { - self.wait = CARD_MOVE_INTERVAL_FRAMES; - } - inputs - } -} - /// 試合開始時の状態を受け取ってから、試合開始までに許容するフレーム数 /// /// 状態を合わせられない場合 (カーソルの値とキャラクター番号の対応が想定と違う場合など) に諦める @@ -113,21 +66,16 @@ pub struct SpectatorStandby { sync_frames: u32, /// 直前のフレームで PAUSE を自動で入力したか。観戦者自身の PAUSE と区別するために使う pause_injected: bool, - card_mover: CardMover, } impl SpectatorStandby { - /// `first_time` は観戦を開始した直後かどうか。`current_cards` は観戦者側の現在のカード - pub fn new(first_time: bool, current_cards: [u8; 2]) -> Self { + /// `first_time` は観戦を開始した直後かどうか + pub fn new(first_time: bool) -> Self { Self { cursors_reset: !first_time, game_initial: None, sync_frames: 0, pause_injected: false, - card_mover: CardMover { - current: current_cards, - wait: CARD_MOVE_START_DELAY_FRAMES, - }, } } @@ -191,7 +139,6 @@ impl SpectatorStandby { clear_player_inputs(th19); return Ok(()); } - let card_targets = [init.p1().card(), init.p2().card()]; let input_devices = th19.input_devices(); let (prev_p1, prev_p2) = ( input_devices.p1_input().prev(), @@ -207,15 +154,12 @@ impl SpectatorStandby { pause }; (p1, InputValue::empty()) - } else if !matches_characters(main_menu, &init) { - set_characters(th19, &init); - (InputValue::empty(), InputValue::empty()) - } else if !self.card_mover.is_done(card_targets) { - let [p1, p2] = self.card_mover.next_inputs(card_targets); - (p1, p2) - } else { - // 一致していることを確認できたので決定する + } else if matches_character_select(main_menu, th19, &init) { + // 一致していることを確認できたので決定する。準備完了後もカードは変更できるため合わせ続ける (shot_repeatedly(prev_p1), shot_repeatedly(prev_p2)) + } else { + set_character_select(th19, &init); + (InputValue::empty(), InputValue::empty()) }; let input_devices = th19.input_devices_mut(); input_devices.p1_input_mut().set_current(p1); From b40fe1024ee1a68f584be2efbca81addb19f963b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:43:53 +0000 Subject: [PATCH 16/29] =?UTF-8?q?fix(spectator):=20=E8=A9=A6=E5=90=88?= =?UTF-8?q?=E4=B8=AD=E3=81=AB=E8=A6=B3=E6=88=A6=E3=82=92=E5=A7=8B=E3=82=81?= =?UTF-8?q?=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AB=E6=97=A9=E9=80=81=E3=82=8A?= =?UTF-8?q?=E3=81=A7=E8=BF=BD=E3=81=84=E3=81=8B=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試合中に no wait を切り替えても反映されない可能性があり、読み込み画面で no wait を強制的にオフにしていたため、試合全体が通常速度で進んでいた。 読み込み画面でも溜まった入力の量で判定し、溜まっていれば早送りで試合を始める。 早送り中も入力が届くまで待つので、追いついた後はホストの速度で進む。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- junowen/src/state/spectator_session.rs | 9 +++++---- .../state/spectator_session/spectator_game.rs | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index 6e0a7cf..edd19ef 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -25,7 +25,10 @@ use crate::{ use super::prepare::Prepare; -use {spectator_game::SpectatorGame, spectator_standby::SpectatorStandby}; +use { + spectator_game::{SpectatorGame, update_no_wait_for_catch_up}, + spectator_standby::SpectatorStandby, +}; fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { th19.set_rand_seed1(round_initial.seed1).unwrap(); @@ -191,9 +194,7 @@ impl SpectatorSession { if let Some(round_initial) = round_initial.take() { set_rand_seeds(th19, &round_initial); } - if th19.no_wait() { - th19.set_no_wait(false); - } + update_no_wait_for_catch_up(&mut self.props, th19); } SpectatorSessionState::Game(game) => game.update_th19(&mut self.props, th19)?, SpectatorSessionState::BackToSelect => {} diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index 417e227..4976da1 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -10,6 +10,18 @@ use super::set_rand_seeds; /// 未処理の入力がこのフレーム数を超えている場合、早送りして追いつく const CATCH_UP_THRESHOLD: usize = 30; +/// 溜まった入力がある場合は早送りしてホストに追いつく +/// +/// 観戦者の試合開始はホストより遅れる。試合中に観戦を始めた場合は、試合の最初から追いかける。 +/// NOTE: 試合中に no wait を切り替えても反映されない可能性があるため、読み込み画面でも判定する。 +/// 早送り中も入力が届くまで待つので、追いついた後はホストの速度で進む +pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th19) { + let no_wait = session.poll_buffered_len() > CATCH_UP_THRESHOLD; + if th19.no_wait() != no_wait { + th19.set_no_wait(no_wait); + } +} + pub struct SpectatorGame; impl SpectatorGame { @@ -29,11 +41,7 @@ impl SpectatorGame { .set_current(InputValue::empty()); return Ok(()); } - // 観戦者の試合開始はホストより遅れるため、溜まった入力を早送りしてホストに追いつく - let no_wait = session.poll_buffered_len() > CATCH_UP_THRESHOLD; - if th19.no_wait() != no_wait { - th19.set_no_wait(no_wait); - } + update_no_wait_for_catch_up(session, th19); let input_devices = th19.input_devices_mut(); let (p1, p2) = session.dequeue_inputs()?; input_devices From ed432d81caae62dab060dd932b77a262bfb1e668 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:10:52 +0000 Subject: [PATCH 17/29] =?UTF-8?q?chore(spectator):=20=E6=97=A9=E9=80=81?= =?UTF-8?q?=E3=82=8A=E3=81=8C=E5=8A=B9=E3=81=8B=E3=81=AA=E3=81=84=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E3=82=92=E8=AA=BF=E3=81=B9=E3=82=8B=E4=B8=80=E6=99=82?= =?UTF-8?q?=E7=9A=84=E3=81=AA=E8=A8=BA=E6=96=AD=E3=83=AD=E3=82=B0=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試合中に観戦を始めて10秒以上遅れても早送りが発生しなかった。 溜まった入力の量が条件を満たしていないのか、no wait を設定しても ゲームに反映されていないのかを切り分けるため、約60フレームごとに 溜まった入力の数と no wait の設定前後の値をログに出力する。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- .../state/spectator_session/spectator_game.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index 4976da1..082e03a 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -1,7 +1,11 @@ -use std::sync::mpsc::RecvError; +use std::sync::{ + atomic::{AtomicU32, Ordering}, + mpsc::RecvError, +}; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; +use tracing::warn; use crate::session::spectator::SpectatorSession; @@ -16,10 +20,23 @@ const CATCH_UP_THRESHOLD: usize = 30; /// NOTE: 試合中に no wait を切り替えても反映されない可能性があるため、読み込み画面でも判定する。 /// 早送り中も入力が届くまで待つので、追いついた後はホストの速度で進む pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th19) { - let no_wait = session.poll_buffered_len() > CATCH_UP_THRESHOLD; - if th19.no_wait() != no_wait { + let buffered_len = session.poll_buffered_len(); + let no_wait = buffered_len > CATCH_UP_THRESHOLD; + let before = th19.no_wait(); + if before != no_wait { th19.set_no_wait(no_wait); } + // TODO: 早送りが効かない原因を調べるための一時的なログ。原因がわかったら削除する + static COUNTER: AtomicU32 = AtomicU32::new(0); + if COUNTER.fetch_add(1, Ordering::Relaxed).is_multiple_of(60) { + warn!( + "[catch-up-diagnostics] buffered_len={} target_no_wait={} before={} after={}", + buffered_len, + no_wait, + before, + th19.no_wait() + ); + } } pub struct SpectatorGame; From 56fc4c24d67513f8ab6ba52c11b7803b990a803c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:32:50 +0000 Subject: [PATCH 18/29] =?UTF-8?q?feat(spectator):=20=E6=97=A9=E9=80=81?= =?UTF-8?q?=E3=82=8A=E4=B8=AD=E3=81=AF=E7=94=BB=E9=9D=A2=E3=81=AE=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=82=92=E9=96=93=E5=BC=95=E3=81=84=E3=81=A6=E8=BF=BD?= =?UTF-8?q?=E3=81=84=E3=81=A4=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 診断ログで、溜まった入力が 964 フレーム分あり no wait を有効にしても、 試合中は早送りされないことがわかった。試合中は表示の垂直同期待ちで 60fps に制限されていると考え、早送り中は Present を8回に1回だけ行う。 早送りの要求が途絶えたら2フレームで通常の表示に戻す。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011qhChXocxUoUmMqt9cLXQa --- .../custom_direct_3d_device_9.rs | 3 ++ junowen/src/lib.rs | 1 + junowen/src/present_skipper.rs | 32 +++++++++++++++++++ .../state/spectator_session/spectator_game.rs | 6 +++- 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 junowen/src/present_skipper.rs diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs b/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs index d678998..64230e5 100644 --- a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs +++ b/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs @@ -138,6 +138,9 @@ where hdestwindowoverride: HWND, pdirtyregion: *const RGNDATA, ) -> windows::core::Result<()> { + if crate::present_skipper::should_skip_present() { + return Ok(()); + } self.delegate.on_before_present(&self.instance); unsafe { self.instance diff --git a/junowen/src/lib.rs b/junowen/src/lib.rs index 996bc61..14aeaea 100644 --- a/junowen/src/lib.rs +++ b/junowen/src/lib.rs @@ -3,6 +3,7 @@ mod file; mod helper; mod junowen; mod lobby; +mod present_skipper; mod session; mod signaling; mod state; diff --git a/junowen/src/present_skipper.rs b/junowen/src/present_skipper.rs new file mode 100644 index 0000000..794e36c --- /dev/null +++ b/junowen/src/present_skipper.rs @@ -0,0 +1,32 @@ +//! 観戦者が溜まった入力を早送りで追いかけるときに、画面の表示 (`Present`) を間引く +//! +//! 試合中は no wait が効かず、表示の垂直同期待ちで 60fps に制限されていると考えられるため、 +//! 表示を間引いて待ちをなくす。 + +use std::sync::atomic::{AtomicU32, Ordering}; + +/// 早送りを要求されてから、表示を間引き続けるフレーム数。 +/// 要求が途絶えたら (セッションが終了した場合など) すぐに通常の表示に戻す +const SKIP_FRAMES_PER_REQUEST: u32 = 2; +/// 間引いている間も、このフレーム数ごとに1回は表示する +const PRESENT_INTERVAL: u32 = 8; + +static REMAINING_FRAMES: AtomicU32 = AtomicU32::new(0); +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// 早送り中は毎フレーム呼ぶ +pub fn request_skip() { + REMAINING_FRAMES.store(SKIP_FRAMES_PER_REQUEST, Ordering::Relaxed); +} + +/// 表示を間引く場合は `true` を返す +pub fn should_skip_present() -> bool { + let remaining = REMAINING_FRAMES.load(Ordering::Relaxed); + if remaining == 0 { + return false; + } + REMAINING_FRAMES.store(remaining - 1, Ordering::Relaxed); + !COUNTER + .fetch_add(1, Ordering::Relaxed) + .is_multiple_of(PRESENT_INTERVAL) +} diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index 082e03a..f6a95af 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -7,7 +7,7 @@ use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; use tracing::warn; -use crate::session::spectator::SpectatorSession; +use crate::{present_skipper, session::spectator::SpectatorSession}; use super::set_rand_seeds; @@ -26,6 +26,10 @@ pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th if before != no_wait { th19.set_no_wait(no_wait); } + if no_wait { + // 試合中は no wait が効かないため、表示を間引いて垂直同期の待ちをなくす + present_skipper::request_skip(); + } // TODO: 早送りが効かない原因を調べるための一時的なログ。原因がわかったら削除する static COUNTER: AtomicU32 = AtomicU32::new(0); if COUNTER.fetch_add(1, Ordering::Relaxed).is_multiple_of(60) { From a87f850f984610cf79babff10f862527ed27e878 Mon Sep 17 00:00:00 2001 From: progre Date: Thu, 8 Oct 2026 09:14:54 +0900 Subject: [PATCH 19/29] =?UTF-8?q?refactor(junowen-lib):=20PeerConnection?= =?UTF-8?q?=20=E3=81=AE=20protocol=20=E3=82=92=E5=BC=95=E6=95=B0=E3=81=A7?= =?UTF-8?q?=E5=8F=97=E3=81=91=E5=8F=96=E3=82=8B=E5=BD=A2=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol は SignalingSocket が決めて渡す値のため、PeerConnection は保持しない。 データチャネルの作成と相手との照合で使う箇所だけ引数で受け取る。 Co-Authored-By: Claude Haiku 5.5 --- junowen-lib/src/connection/peer_connection.rs | 21 ++++++++----------- .../src/connection/signaling/socket.rs | 11 ++++------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/junowen-lib/src/connection/peer_connection.rs b/junowen-lib/src/connection/peer_connection.rs index d735cd6..0adc66a 100644 --- a/junowen-lib/src/connection/peer_connection.rs +++ b/junowen-lib/src/connection/peer_connection.rs @@ -54,7 +54,6 @@ pub struct PeerConnection { peer_connection_state_disconnected_rx: Option>, peer_connection_state_failed_rx: Option>, data_channel_rx: Option>, - protocol: &'static str, } impl Drop for PeerConnection { @@ -86,12 +85,7 @@ pub const BATTLE_PROTOCOL: &str = "JUNOWEN/1.1"; pub const SPECTATOR_PROTOCOL: &str = "JUNOWEN/1.2"; impl PeerConnection { - /// `protocol` はデータチャネルのプロトコルで、相手と一致しない場合は接続を拒否する - pub async fn new( - timeout: Duration, - ice_server_urls: Vec, - protocol: &'static str, - ) -> Result { + pub async fn new(timeout: Duration, ice_server_urls: Vec) -> Result { let rtc = create_default_peer_connection(timeout, ice_server_urls).await?; let (peer_connection_state_failed_tx, peer_connection_state_failed_rx) = oneshot::channel(); @@ -134,7 +128,6 @@ impl PeerConnection { peer_connection_state_failed_rx: Some(peer_connection_state_failed_rx), peer_connection_state_disconnected_rx: Some(peer_connection_state_disconnected_rx), data_channel_rx: None, - protocol, }) } @@ -142,13 +135,14 @@ impl PeerConnection { self.rtc.as_ref().unwrap() } - pub async fn start_as_offerer(&mut self) -> Result { + /// `protocol` はデータチャネルのプロトコル + pub async fn start_as_offerer(&mut self, protocol: &'static str) -> Result { let rtc_data_channel = self .rtc() .create_data_channel( "data", Some(RTCDataChannelInit { - protocol: Some(self.protocol.to_owned()), + protocol: Some(protocol.to_owned()), ..Default::default() }), ) @@ -209,8 +203,11 @@ impl PeerConnection { Ok(()) } - pub async fn wait_for_open_data_channel(&mut self) -> Result { - let protocol = self.protocol; + /// 相手のプロトコルが `protocol` と一致しない場合は接続を拒否する + pub async fn wait_for_open_data_channel( + &mut self, + protocol: &'static str, + ) -> Result { let data_channel_task = async { let mut data_channel = self.data_channel_rx.take().unwrap().await.unwrap(); data_channel.wait_for_open_data_channel().await; diff --git a/junowen-lib/src/connection/signaling/socket.rs b/junowen-lib/src/connection/signaling/socket.rs index 733e5c1..6e854de 100644 --- a/junowen-lib/src/connection/signaling/socket.rs +++ b/junowen-lib/src/connection/signaling/socket.rs @@ -42,10 +42,9 @@ pub trait SignalingSocket { } async fn receive_signaling(&mut self) -> Result<(PeerConnection, DataChannel, bool)> { - let mut conn = - PeerConnection::new(Self::timeout(), self.ice_server_urls(), self.protocol()).await?; + let mut conn = PeerConnection::new(Self::timeout(), self.ice_server_urls()).await?; let offer_desc = conn - .start_as_offerer() + .start_as_offerer(self.protocol()) .await .context("Failed to start as host")?; let answer_desc = self.offer(offer_desc).await?; @@ -57,9 +56,7 @@ pub trait SignalingSocket { (conn, true) } OfferResponse::Offer(offer_desc) => { - let mut conn = - PeerConnection::new(Self::timeout(), self.ice_server_urls(), self.protocol()) - .await?; + let mut conn = PeerConnection::new(Self::timeout(), self.ice_server_urls()).await?; let answer_desc = conn .start_as_answerer(offer_desc) .await @@ -68,7 +65,7 @@ pub trait SignalingSocket { (conn, false) } }; - let data_channel = conn.wait_for_open_data_channel().await?; + let data_channel = conn.wait_for_open_data_channel(self.protocol()).await?; Ok((conn, data_channel, host)) } } From 2c34946404b44bfcf999abf6ff050ae79db42df9 Mon Sep 17 00:00:00 2001 From: progre Date: Thu, 8 Oct 2026 09:14:59 +0900 Subject: [PATCH 20/29] =?UTF-8?q?chore(spectator):=20=E6=97=A9=E9=80=81?= =?UTF-8?q?=E3=82=8A=E3=81=AE=E5=8E=9F=E5=9B=A0=E8=AA=BF=E6=9F=BB=E7=94=A8?= =?UTF-8?q?=E3=81=AE=E8=A8=BA=E6=96=AD=E3=83=AD=E3=82=B0=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 早送り中は表示を間引く対応を入れたため、原因調査用の一時的な診断ログを削除する。 Co-Authored-By: Claude Haiku 5.5 --- .../state/spectator_session/spectator_game.rs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index f6a95af..e4285db 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -1,11 +1,7 @@ -use std::sync::{ - atomic::{AtomicU32, Ordering}, - mpsc::RecvError, -}; +use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use tracing::warn; use crate::{present_skipper, session::spectator::SpectatorSession}; @@ -30,17 +26,6 @@ pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th // 試合中は no wait が効かないため、表示を間引いて垂直同期の待ちをなくす present_skipper::request_skip(); } - // TODO: 早送りが効かない原因を調べるための一時的なログ。原因がわかったら削除する - static COUNTER: AtomicU32 = AtomicU32::new(0); - if COUNTER.fetch_add(1, Ordering::Relaxed).is_multiple_of(60) { - warn!( - "[catch-up-diagnostics] buffered_len={} target_no_wait={} before={} after={}", - buffered_len, - no_wait, - before, - th19.no_wait() - ); - } } pub struct SpectatorGame; From 50a2987a2ce9194ed8b1648f2f93d7c36c5eaa74 Mon Sep 17 00:00:00 2001 From: progre Date: Sat, 10 Oct 2026 00:38:49 +0900 Subject: [PATCH 21/29] =?UTF-8?q?refactor(spectator):=20present=5Fskipper?= =?UTF-8?q?=20=E3=81=AE=E3=82=B0=E3=83=AD=E3=83=BC=E3=83=90=E3=83=AB?= =?UTF-8?q?=E5=A4=89=E6=95=B0=E3=82=92=E8=A6=B3=E6=88=A6=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E7=8A=B6=E6=85=8B=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../custom_direct_3d_device_9.rs | 2 +- .../direct_3d_device_event_listener.rs | 2 + junowen/src/junowen.rs | 4 ++ junowen/src/present_skipper.rs | 46 +++++++++++-------- junowen/src/state.rs | 7 +++ junowen/src/state/session.rs | 7 +++ junowen/src/state/spectator_session.rs | 13 +++++- .../state/spectator_session/spectator_game.rs | 13 ++++-- 8 files changed, 69 insertions(+), 25 deletions(-) diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs b/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs index 64230e5..fd75c3c 100644 --- a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs +++ b/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs @@ -138,7 +138,7 @@ where hdestwindowoverride: HWND, pdirtyregion: *const RGNDATA, ) -> windows::core::Result<()> { - if crate::present_skipper::should_skip_present() { + if self.delegate.should_skip_present() { return Ok(()); } self.delegate.on_before_present(&self.instance); diff --git a/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs b/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs index c3a36b0..db470b0 100644 --- a/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs +++ b/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs @@ -1,5 +1,7 @@ use windows::Win32::Graphics::Direct3D9::IDirect3DDevice9; pub trait Direct3DDeviceEventListener { + /// 表示を間引く場合は `true` を返す + fn should_skip_present(&self) -> bool; fn on_before_present(&self, device: &IDirect3DDevice9); } diff --git a/junowen/src/junowen.rs b/junowen/src/junowen.rs index 5de05ed..e1a8ac2 100644 --- a/junowen/src/junowen.rs +++ b/junowen/src/junowen.rs @@ -90,6 +90,10 @@ impl Th19EventListener for Junowen { } impl Direct3DDeviceEventListener for &Junowen { + fn should_skip_present(&self) -> bool { + self.state.should_skip_present() + } + fn on_before_present(&self, device: &IDirect3DDevice9) { if !self.title_menu_modifier.selected_junowen() { return; diff --git a/junowen/src/present_skipper.rs b/junowen/src/present_skipper.rs index 794e36c..2185342 100644 --- a/junowen/src/present_skipper.rs +++ b/junowen/src/present_skipper.rs @@ -1,7 +1,8 @@ -//! 観戦者が溜まった入力を早送りで追いかけるときに、画面の表示 (`Present`) を間引く +//! 観戦者がホストに追いつくまでの早送り中は、画面の表示 (`Present`) を間引く //! -//! 試合中は no wait が効かず、表示の垂直同期待ちで 60fps に制限されていると考えられるため、 -//! 表示を間引いて待ちをなくす。 +//! 試合中は、ゲームの no wait 設定を有効にしても処理が速くならない。 +//! 表示のたびに垂直同期を待つため、1 秒あたり 60 フレームに抑えられていると考えられる。 +//! そこで表示を間引き、この待ちを減らす。 use std::sync::atomic::{AtomicU32, Ordering}; @@ -11,22 +12,31 @@ const SKIP_FRAMES_PER_REQUEST: u32 = 2; /// 間引いている間も、このフレーム数ごとに1回は表示する const PRESENT_INTERVAL: u32 = 8; -static REMAINING_FRAMES: AtomicU32 = AtomicU32::new(0); -static COUNTER: AtomicU32 = AtomicU32::new(0); - -/// 早送り中は毎フレーム呼ぶ -pub fn request_skip() { - REMAINING_FRAMES.store(SKIP_FRAMES_PER_REQUEST, Ordering::Relaxed); +#[derive(Default)] +pub struct PresentSkipper { + remaining_frames: AtomicU32, + counter: AtomicU32, } -/// 表示を間引く場合は `true` を返す -pub fn should_skip_present() -> bool { - let remaining = REMAINING_FRAMES.load(Ordering::Relaxed); - if remaining == 0 { - return false; +impl PresentSkipper { + /// 早送り中は毎フレーム呼ぶ + pub fn request_skip(&self) { + self.remaining_frames + .store(SKIP_FRAMES_PER_REQUEST, Ordering::Relaxed); + } + + /// 表示を間引く場合は `true` を返す + pub fn should_skip_present(&self) -> bool { + let requested = self + .remaining_frames + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |x| x.checked_sub(1)) + .is_ok(); + if !requested { + return false; + } + !self + .counter + .fetch_add(1, Ordering::Relaxed) + .is_multiple_of(PRESENT_INTERVAL) } - REMAINING_FRAMES.store(remaining - 1, Ordering::Relaxed); - !COUNTER - .fetch_add(1, Ordering::Relaxed) - .is_multiple_of(PRESENT_INTERVAL) } diff --git a/junowen/src/state.rs b/junowen/src/state.rs index 36d3296..d63094d 100644 --- a/junowen/src/state.rs +++ b/junowen/src/state.rs @@ -104,6 +104,13 @@ impl State { } } + pub fn should_skip_present(&self) -> bool { + match self { + Self::Standby => false, + Self::Session(session) => session.should_skip_present(), + } + } + pub fn on_before_render_object( &self, title_menu_modifier: &TitleMenuModifier, diff --git a/junowen/src/state/session.rs b/junowen/src/state/session.rs index df0e3bd..7e2a0a8 100644 --- a/junowen/src/state/session.rs +++ b/junowen/src/state/session.rs @@ -34,6 +34,13 @@ impl Session { } } + pub fn should_skip_present(&self) -> bool { + match self { + Self::BattleSession(_) => false, + Self::SpectatorSession(session_state) => session_state.should_skip_present(), + } + } + pub fn update_state(&mut self, th19: &Th19) -> Option> { match self { Self::BattleSession(session_state) => session_state.update_state(th19), diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs index edd19ef..b9018cd 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -17,6 +17,7 @@ use tracing::warn; use crate::{ helper::pushed_escape, + present_skipper::PresentSkipper, session::{ RoundInitial, spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, @@ -66,6 +67,7 @@ fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { pub struct SpectatorSession { props: SpectatorSessionProps, + present_skipper: PresentSkipper, state: SpectatorSessionState, } @@ -82,10 +84,15 @@ impl SpectatorSession { pub fn prepare(props: SpectatorSessionProps) -> Self { Self { props, + present_skipper: PresentSkipper::default(), state: SpectatorSessionState::Prepare(Prepare::new()), } } + pub fn should_skip_present(&self) -> bool { + self.present_skipper.should_skip_present() + } + pub fn game_settings(&self) -> Option<&GameSettings> { self.props.spectator_initial().map(|x| x.game_settings()) } @@ -194,9 +201,11 @@ impl SpectatorSession { if let Some(round_initial) = round_initial.take() { set_rand_seeds(th19, &round_initial); } - update_no_wait_for_catch_up(&mut self.props, th19); + update_no_wait_for_catch_up(&mut self.props, &self.present_skipper, th19); + } + SpectatorSessionState::Game(game) => { + game.update_th19(&mut self.props, &self.present_skipper, th19)? } - SpectatorSessionState::Game(game) => game.update_th19(&mut self.props, th19)?, SpectatorSessionState::BackToSelect => {} } Ok(()) diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs index e4285db..2de52bb 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -3,7 +3,7 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use crate::{present_skipper, session::spectator::SpectatorSession}; +use crate::{present_skipper::PresentSkipper, session::spectator::SpectatorSession}; use super::set_rand_seeds; @@ -15,7 +15,11 @@ const CATCH_UP_THRESHOLD: usize = 30; /// 観戦者の試合開始はホストより遅れる。試合中に観戦を始めた場合は、試合の最初から追いかける。 /// NOTE: 試合中に no wait を切り替えても反映されない可能性があるため、読み込み画面でも判定する。 /// 早送り中も入力が届くまで待つので、追いついた後はホストの速度で進む -pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th19) { +pub fn update_no_wait_for_catch_up( + session: &mut SpectatorSession, + present_skipper: &PresentSkipper, + th19: &mut Th19, +) { let buffered_len = session.poll_buffered_len(); let no_wait = buffered_len > CATCH_UP_THRESHOLD; let before = th19.no_wait(); @@ -24,7 +28,7 @@ pub fn update_no_wait_for_catch_up(session: &mut SpectatorSession, th19: &mut Th } if no_wait { // 試合中は no wait が効かないため、表示を間引いて垂直同期の待ちをなくす - present_skipper::request_skip(); + present_skipper.request_skip(); } } @@ -34,6 +38,7 @@ impl SpectatorGame { pub fn update_th19( &mut self, session: &mut SpectatorSession, + present_skipper: &PresentSkipper, th19: &mut Th19, ) -> Result<(), RecvError> { // -1フレーム目、0フレーム目は複数回呼ばれ、回数が不定なのでスキップする @@ -47,7 +52,7 @@ impl SpectatorGame { .set_current(InputValue::empty()); return Ok(()); } - update_no_wait_for_catch_up(session, th19); + update_no_wait_for_catch_up(session, present_skipper, th19); let input_devices = th19.input_devices_mut(); let (p1, p2) = session.dequeue_inputs()?; input_devices From f93e69ab4846dc2864c5ed12682921b42b2f3b80 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 09:55:52 +0900 Subject: [PATCH 22/29] =?UTF-8?q?refactor(workspace):=20=E3=82=AF=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=20crates/app=20=E3=81=A8=20crates/f?= =?UTF-8?q?eatures=20=E3=81=B8=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- Cargo.toml | 12 ++++++------ .../app/junowen-server}/Cargo.toml | 0 .../app/junowen-server}/README.md | 0 .../app/junowen-server}/docs/activity-diagrams.md | 0 .../app/junowen-server}/src/bin/access-stats/args.rs | 0 .../src/bin/access-stats/cloudwatch.rs | 0 .../app/junowen-server}/src/bin/access-stats/main.rs | 0 .../junowen-server}/src/bin/access-stats/output.rs | 0 .../src/bin/access-stats/room_activity.rs | 0 .../app/junowen-server}/src/database.rs | 0 .../app/junowen-server}/src/database/dynamodb.rs | 0 .../src/database/dynamodb/reserved_room.rs | 0 .../src/database/dynamodb/shared_room.rs | 0 .../app/junowen-server}/src/database/file.rs | 0 .../app/junowen-server}/src/main.rs | 0 .../app/junowen-server}/src/routes.rs | 0 .../app/junowen-server}/src/routes/custom.rs | 0 .../app/junowen-server}/src/routes/reserved_room.rs | 0 .../src/routes/reserved_room/create.rs | 0 .../src/routes/reserved_room/delete.rs | 0 .../junowen-server}/src/routes/reserved_room/read.rs | 0 .../src/routes/reserved_room/update.rs | 0 .../app/junowen-server}/src/routes/room_utils.rs | 0 .../app/junowen-server}/src/tracing_helper.rs | 0 {junowen => crates/app/junowen}/Cargo.toml | 0 {junowen => crates/app/junowen}/build.rs | 0 {junowen => crates/app/junowen}/locales/de.toml | 0 {junowen => crates/app/junowen}/locales/en.toml | 0 {junowen => crates/app/junowen}/locales/eo.toml | 0 {junowen => crates/app/junowen}/locales/es-ES.toml | 0 {junowen => crates/app/junowen}/locales/es.toml | 0 {junowen => crates/app/junowen}/locales/fr.toml | 0 {junowen => crates/app/junowen}/locales/it.toml | 0 {junowen => crates/app/junowen}/locales/ja.toml | 0 {junowen => crates/app/junowen}/locales/ko.toml | 0 {junowen => crates/app/junowen}/locales/pt-BR.toml | 0 {junowen => crates/app/junowen}/locales/ru.toml | 0 {junowen => crates/app/junowen}/locales/zh-CN.toml | 0 {junowen => crates/app/junowen}/locales/zh-TW.toml | 0 .../app/junowen}/src/bin/junowen-standalone.rs | 0 {junowen => crates/app/junowen}/src/bin/lang/ja.toml | 0 {junowen => crates/app/junowen}/src/bin/lang/mod.rs | 0 .../app/junowen}/src/custom_direct_3d.rs | 0 .../src/custom_direct_3d/custom_direct_3d_9.rs | 0 .../custom_direct_3d/custom_direct_3d_device_9.rs | 0 .../direct_3d_device_event_listener.rs | 0 {junowen => crates/app/junowen}/src/file.rs | 0 {junowen => crates/app/junowen}/src/helper.rs | 0 {junowen => crates/app/junowen}/src/junowen.rs | 0 {junowen => crates/app/junowen}/src/lib.rs | 0 {junowen => crates/app/junowen}/src/lobby.rs | 0 .../app/junowen}/src/lobby/common_menu.rs | 0 .../app/junowen}/src/lobby/common_menu/menu.rs | 0 .../src/lobby/common_menu/menu_controller.rs | 0 .../app/junowen}/src/lobby/common_menu/menu_item.rs | 0 .../app/junowen}/src/lobby/common_menu/text_input.rs | 0 {junowen => crates/app/junowen}/src/lobby/helper.rs | 0 {junowen => crates/app/junowen}/src/lobby/overlay.rs | 0 .../app/junowen}/src/lobby/pure_p2p_guest.rs | 0 .../app/junowen}/src/lobby/pure_p2p_offerer.rs | 0 .../app/junowen}/src/lobby/pure_p2p_offerer/menu.rs | 0 .../app/junowen}/src/lobby/pure_p2p_offerer/role.rs | 0 .../app/junowen}/src/lobby/pure_p2p_scenes.rs | 0 {junowen => crates/app/junowen}/src/lobby/room.rs | 0 .../app/junowen}/src/lobby/room/reserved.rs | 0 .../app/junowen}/src/lobby/room/shared.rs | 0 .../app/junowen}/src/lobby/room/tcp_signaling.rs | 0 .../junowen}/src/lobby/room/tcp_signaling/menu.rs | 0 {junowen => crates/app/junowen}/src/lobby/root.rs | 0 .../app/junowen}/src/lobby/signaling_code.rs | 0 .../app/junowen}/src/lobby/text_layout.rs | 0 .../app/junowen}/src/lobby/title_menu_modifier.rs | 0 .../app/junowen}/src/present_skipper.rs | 0 {junowen => crates/app/junowen}/src/session.rs | 0 .../app/junowen}/src/session/battle.rs | 0 .../app/junowen}/src/session/delayed_inputs.rs | 0 .../app/junowen}/src/session/session_message.rs | 0 .../app/junowen}/src/session/spectator.rs | 0 .../app/junowen}/src/session/spectator_host.rs | 0 {junowen => crates/app/junowen}/src/signaling.rs | 0 .../app/junowen}/src/signaling/waiting_for_match.rs | 0 .../reserved_room_opponent_socket.rs | 0 .../reserved_room_spectator_host_socket.rs | 0 .../reserved_room_spectator_socket.rs | 0 .../waiting_for_match/shared_room_opponent_socket.rs | 0 .../src/signaling/waiting_for_match/socket.rs | 0 .../waiting_for_match/tcp_signaling_guest_socket.rs | 0 .../waiting_for_match/tcp_signaling_host_socket.rs | 0 .../waiting_for_match/waiting_for_spectator.rs | 0 .../signaling/waiting_for_match/waiting_in_room.rs | 0 {junowen => crates/app/junowen}/src/state.rs | 0 .../app/junowen}/src/state/battle_session.rs | 0 .../junowen}/src/state/battle_session/battle_game.rs | 0 .../src/state/battle_session/battle_select.rs | 0 .../junowen}/src/state/battle_session/in_session.rs | 0 .../src/state/battle_session/spectator_host.rs | 0 .../app/junowen}/src/state/battle_session/utils.rs | 0 {junowen => crates/app/junowen}/src/state/prepare.rs | 0 .../app/junowen}/src/state/render_parts.rs | 0 {junowen => crates/app/junowen}/src/state/session.rs | 0 .../app/junowen}/src/state/spectator_session.rs | 0 .../src/state/spectator_session/in_session.rs | 0 .../src/state/spectator_session/spectator_game.rs | 0 .../src/state/spectator_session/spectator_standby.rs | 0 {junowen => crates/app/junowen}/src/state/standby.rs | 0 .../app/junowen}/src/tracing_helper.rs | 0 {th19loader => crates/app/th19loader}/Cargo.toml | 2 +- {th19loader => crates/app/th19loader}/build.rs | 0 {th19loader => crates/app/th19loader}/src/lib.rs | 0 .../features/junowen-lib}/Cargo.toml | 0 .../features/junowen-lib}/examples/webrtc_guest.rs | 0 .../features/junowen-lib}/examples/webrtc_host.rs | 0 .../features/junowen-lib}/src/connection.rs | 0 .../junowen-lib}/src/connection/data_channel.rs | 0 .../junowen-lib}/src/connection/peer_connection.rs | 0 .../junowen-lib}/src/connection/signaling.rs | 0 .../junowen-lib}/src/connection/signaling/socket.rs | 0 .../socket/async_read_write_server_socket.rs | 0 .../signaling/socket/async_read_write_socket.rs | 0 .../connection/signaling/socket/channel_socket.rs | 0 .../src/connection/signaling/socket/frame.rs | 0 .../signaling/stdio_signaling_interface.rs | 0 .../features/junowen-lib}/src/find_process_id.rs | 0 .../features/junowen-lib}/src/hook_utils.rs | 0 .../junowen-lib}/src/hook_utils/dll_injection.rs | 0 .../src/hook_utils/load_library_w_addr.rs | 0 .../features/junowen-lib}/src/lang.rs | 0 .../features/junowen-lib}/src/lib.rs | 0 .../features/junowen-lib}/src/macros.rs | 0 .../features/junowen-lib}/src/memory_accessors.rs | 0 .../src/memory_accessors/external_process.rs | 0 .../src/memory_accessors/hooked_process.rs | 0 .../features/junowen-lib}/src/signaling_server.rs | 0 .../junowen-lib}/src/signaling_server/custom.rs | 0 .../src/signaling_server/reserved_room.rs | 0 .../junowen-lib}/src/signaling_server/room.rs | 0 .../src/signaling_server/room/delete_room.rs | 0 .../src/signaling_server/room/post_room_join.rs | 0 .../src/signaling_server/room/post_room_keep.rs | 0 .../src/signaling_server/room/put_room.rs | 0 .../features/junowen-lib}/src/th19.rs | 0 .../features/junowen-lib}/src/th19/structs.rs | 0 .../features/junowen-lib}/src/th19/structs/app.rs | 0 .../junowen-lib}/src/th19/structs/input_devices.rs | 0 .../features/junowen-lib}/src/th19/structs/others.rs | 0 .../junowen-lib}/src/th19/structs/selection.rs | 0 .../junowen-lib}/src/th19/structs/settings.rs | 0 .../junowen-lib}/src/th19/th19_event_dispatcher.rs | 0 .../features/junowen-lib}/src/th19/th19_helpers.rs | 0 .../features/junowen-lib}/src/win_api_wrappers.rs | 0 150 files changed, 7 insertions(+), 7 deletions(-) rename {junowen-server => crates/app/junowen-server}/Cargo.toml (100%) rename {junowen-server => crates/app/junowen-server}/README.md (100%) rename {junowen-server => crates/app/junowen-server}/docs/activity-diagrams.md (100%) rename {junowen-server => crates/app/junowen-server}/src/bin/access-stats/args.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/bin/access-stats/cloudwatch.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/bin/access-stats/main.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/bin/access-stats/output.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/bin/access-stats/room_activity.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/database.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/database/dynamodb.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/database/dynamodb/reserved_room.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/database/dynamodb/shared_room.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/database/file.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/main.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/custom.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/reserved_room.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/reserved_room/create.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/reserved_room/delete.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/reserved_room/read.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/reserved_room/update.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/routes/room_utils.rs (100%) rename {junowen-server => crates/app/junowen-server}/src/tracing_helper.rs (100%) rename {junowen => crates/app/junowen}/Cargo.toml (100%) rename {junowen => crates/app/junowen}/build.rs (100%) rename {junowen => crates/app/junowen}/locales/de.toml (100%) rename {junowen => crates/app/junowen}/locales/en.toml (100%) rename {junowen => crates/app/junowen}/locales/eo.toml (100%) rename {junowen => crates/app/junowen}/locales/es-ES.toml (100%) rename {junowen => crates/app/junowen}/locales/es.toml (100%) rename {junowen => crates/app/junowen}/locales/fr.toml (100%) rename {junowen => crates/app/junowen}/locales/it.toml (100%) rename {junowen => crates/app/junowen}/locales/ja.toml (100%) rename {junowen => crates/app/junowen}/locales/ko.toml (100%) rename {junowen => crates/app/junowen}/locales/pt-BR.toml (100%) rename {junowen => crates/app/junowen}/locales/ru.toml (100%) rename {junowen => crates/app/junowen}/locales/zh-CN.toml (100%) rename {junowen => crates/app/junowen}/locales/zh-TW.toml (100%) rename {junowen => crates/app/junowen}/src/bin/junowen-standalone.rs (100%) rename {junowen => crates/app/junowen}/src/bin/lang/ja.toml (100%) rename {junowen => crates/app/junowen}/src/bin/lang/mod.rs (100%) rename {junowen => crates/app/junowen}/src/custom_direct_3d.rs (100%) rename {junowen => crates/app/junowen}/src/custom_direct_3d/custom_direct_3d_9.rs (100%) rename {junowen => crates/app/junowen}/src/custom_direct_3d/custom_direct_3d_device_9.rs (100%) rename {junowen => crates/app/junowen}/src/custom_direct_3d/direct_3d_device_event_listener.rs (100%) rename {junowen => crates/app/junowen}/src/file.rs (100%) rename {junowen => crates/app/junowen}/src/helper.rs (100%) rename {junowen => crates/app/junowen}/src/junowen.rs (100%) rename {junowen => crates/app/junowen}/src/lib.rs (100%) rename {junowen => crates/app/junowen}/src/lobby.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/common_menu.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/common_menu/menu.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/common_menu/menu_controller.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/common_menu/menu_item.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/common_menu/text_input.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/helper.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/overlay.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/pure_p2p_guest.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/pure_p2p_offerer.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/pure_p2p_offerer/menu.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/pure_p2p_offerer/role.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/pure_p2p_scenes.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/room.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/room/reserved.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/room/shared.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/room/tcp_signaling.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/room/tcp_signaling/menu.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/root.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/signaling_code.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/text_layout.rs (100%) rename {junowen => crates/app/junowen}/src/lobby/title_menu_modifier.rs (100%) rename {junowen => crates/app/junowen}/src/present_skipper.rs (100%) rename {junowen => crates/app/junowen}/src/session.rs (100%) rename {junowen => crates/app/junowen}/src/session/battle.rs (100%) rename {junowen => crates/app/junowen}/src/session/delayed_inputs.rs (100%) rename {junowen => crates/app/junowen}/src/session/session_message.rs (100%) rename {junowen => crates/app/junowen}/src/session/spectator.rs (100%) rename {junowen => crates/app/junowen}/src/session/spectator_host.rs (100%) rename {junowen => crates/app/junowen}/src/signaling.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/shared_room_opponent_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/waiting_for_spectator.rs (100%) rename {junowen => crates/app/junowen}/src/signaling/waiting_for_match/waiting_in_room.rs (100%) rename {junowen => crates/app/junowen}/src/state.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session/battle_game.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session/battle_select.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session/in_session.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session/spectator_host.rs (100%) rename {junowen => crates/app/junowen}/src/state/battle_session/utils.rs (100%) rename {junowen => crates/app/junowen}/src/state/prepare.rs (100%) rename {junowen => crates/app/junowen}/src/state/render_parts.rs (100%) rename {junowen => crates/app/junowen}/src/state/session.rs (100%) rename {junowen => crates/app/junowen}/src/state/spectator_session.rs (100%) rename {junowen => crates/app/junowen}/src/state/spectator_session/in_session.rs (100%) rename {junowen => crates/app/junowen}/src/state/spectator_session/spectator_game.rs (100%) rename {junowen => crates/app/junowen}/src/state/spectator_session/spectator_standby.rs (100%) rename {junowen => crates/app/junowen}/src/state/standby.rs (100%) rename {junowen => crates/app/junowen}/src/tracing_helper.rs (100%) rename {th19loader => crates/app/th19loader}/Cargo.toml (89%) rename {th19loader => crates/app/th19loader}/build.rs (100%) rename {th19loader => crates/app/th19loader}/src/lib.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/Cargo.toml (100%) rename {junowen-lib => crates/features/junowen-lib}/examples/webrtc_guest.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/examples/webrtc_host.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/data_channel.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/peer_connection.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/socket.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/socket/async_read_write_server_socket.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/socket/async_read_write_socket.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/socket/channel_socket.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/socket/frame.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/connection/signaling/stdio_signaling_interface.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/find_process_id.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/hook_utils.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/hook_utils/dll_injection.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/hook_utils/load_library_w_addr.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/lang.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/lib.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/macros.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/memory_accessors.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/memory_accessors/external_process.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/memory_accessors/hooked_process.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/custom.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/reserved_room.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/room.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/room/delete_room.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/room/post_room_join.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/room/post_room_keep.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/signaling_server/room/put_room.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs/app.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs/input_devices.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs/others.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs/selection.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/structs/settings.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/th19_event_dispatcher.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/th19/th19_helpers.rs (100%) rename {junowen-lib => crates/features/junowen-lib}/src/win_api_wrappers.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 39ad324..070538f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,12 @@ members = [ "archives/th19replayrecorder", "archives/th19savesettingsseparately", "archives/th19seed", - "junowen", - "junowen-lib", - "junowen-server", - "th19loader", + "crates/app/junowen", + "crates/app/junowen-server", + "crates/app/th19loader", + "crates/features/junowen-lib", ] -default-members = ["junowen"] +default-members = ["crates/app/junowen"] resolver = "2" [workspace.package] @@ -26,7 +26,7 @@ anyhow = { version = "1.0.95", features = ["backtrace"] } async-trait = "0.1.85" bytes = "1.9.0" clipboard-win = "5.4.0" -junowen-lib = { path = "./junowen-lib" } +junowen-lib = { path = "./crates/features/junowen-lib" } rmp-serde = "1.3.0" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.135" diff --git a/junowen-server/Cargo.toml b/crates/app/junowen-server/Cargo.toml similarity index 100% rename from junowen-server/Cargo.toml rename to crates/app/junowen-server/Cargo.toml diff --git a/junowen-server/README.md b/crates/app/junowen-server/README.md similarity index 100% rename from junowen-server/README.md rename to crates/app/junowen-server/README.md diff --git a/junowen-server/docs/activity-diagrams.md b/crates/app/junowen-server/docs/activity-diagrams.md similarity index 100% rename from junowen-server/docs/activity-diagrams.md rename to crates/app/junowen-server/docs/activity-diagrams.md diff --git a/junowen-server/src/bin/access-stats/args.rs b/crates/app/junowen-server/src/bin/access-stats/args.rs similarity index 100% rename from junowen-server/src/bin/access-stats/args.rs rename to crates/app/junowen-server/src/bin/access-stats/args.rs diff --git a/junowen-server/src/bin/access-stats/cloudwatch.rs b/crates/app/junowen-server/src/bin/access-stats/cloudwatch.rs similarity index 100% rename from junowen-server/src/bin/access-stats/cloudwatch.rs rename to crates/app/junowen-server/src/bin/access-stats/cloudwatch.rs diff --git a/junowen-server/src/bin/access-stats/main.rs b/crates/app/junowen-server/src/bin/access-stats/main.rs similarity index 100% rename from junowen-server/src/bin/access-stats/main.rs rename to crates/app/junowen-server/src/bin/access-stats/main.rs diff --git a/junowen-server/src/bin/access-stats/output.rs b/crates/app/junowen-server/src/bin/access-stats/output.rs similarity index 100% rename from junowen-server/src/bin/access-stats/output.rs rename to crates/app/junowen-server/src/bin/access-stats/output.rs diff --git a/junowen-server/src/bin/access-stats/room_activity.rs b/crates/app/junowen-server/src/bin/access-stats/room_activity.rs similarity index 100% rename from junowen-server/src/bin/access-stats/room_activity.rs rename to crates/app/junowen-server/src/bin/access-stats/room_activity.rs diff --git a/junowen-server/src/database.rs b/crates/app/junowen-server/src/database.rs similarity index 100% rename from junowen-server/src/database.rs rename to crates/app/junowen-server/src/database.rs diff --git a/junowen-server/src/database/dynamodb.rs b/crates/app/junowen-server/src/database/dynamodb.rs similarity index 100% rename from junowen-server/src/database/dynamodb.rs rename to crates/app/junowen-server/src/database/dynamodb.rs diff --git a/junowen-server/src/database/dynamodb/reserved_room.rs b/crates/app/junowen-server/src/database/dynamodb/reserved_room.rs similarity index 100% rename from junowen-server/src/database/dynamodb/reserved_room.rs rename to crates/app/junowen-server/src/database/dynamodb/reserved_room.rs diff --git a/junowen-server/src/database/dynamodb/shared_room.rs b/crates/app/junowen-server/src/database/dynamodb/shared_room.rs similarity index 100% rename from junowen-server/src/database/dynamodb/shared_room.rs rename to crates/app/junowen-server/src/database/dynamodb/shared_room.rs diff --git a/junowen-server/src/database/file.rs b/crates/app/junowen-server/src/database/file.rs similarity index 100% rename from junowen-server/src/database/file.rs rename to crates/app/junowen-server/src/database/file.rs diff --git a/junowen-server/src/main.rs b/crates/app/junowen-server/src/main.rs similarity index 100% rename from junowen-server/src/main.rs rename to crates/app/junowen-server/src/main.rs diff --git a/junowen-server/src/routes.rs b/crates/app/junowen-server/src/routes.rs similarity index 100% rename from junowen-server/src/routes.rs rename to crates/app/junowen-server/src/routes.rs diff --git a/junowen-server/src/routes/custom.rs b/crates/app/junowen-server/src/routes/custom.rs similarity index 100% rename from junowen-server/src/routes/custom.rs rename to crates/app/junowen-server/src/routes/custom.rs diff --git a/junowen-server/src/routes/reserved_room.rs b/crates/app/junowen-server/src/routes/reserved_room.rs similarity index 100% rename from junowen-server/src/routes/reserved_room.rs rename to crates/app/junowen-server/src/routes/reserved_room.rs diff --git a/junowen-server/src/routes/reserved_room/create.rs b/crates/app/junowen-server/src/routes/reserved_room/create.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/create.rs rename to crates/app/junowen-server/src/routes/reserved_room/create.rs diff --git a/junowen-server/src/routes/reserved_room/delete.rs b/crates/app/junowen-server/src/routes/reserved_room/delete.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/delete.rs rename to crates/app/junowen-server/src/routes/reserved_room/delete.rs diff --git a/junowen-server/src/routes/reserved_room/read.rs b/crates/app/junowen-server/src/routes/reserved_room/read.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/read.rs rename to crates/app/junowen-server/src/routes/reserved_room/read.rs diff --git a/junowen-server/src/routes/reserved_room/update.rs b/crates/app/junowen-server/src/routes/reserved_room/update.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/update.rs rename to crates/app/junowen-server/src/routes/reserved_room/update.rs diff --git a/junowen-server/src/routes/room_utils.rs b/crates/app/junowen-server/src/routes/room_utils.rs similarity index 100% rename from junowen-server/src/routes/room_utils.rs rename to crates/app/junowen-server/src/routes/room_utils.rs diff --git a/junowen-server/src/tracing_helper.rs b/crates/app/junowen-server/src/tracing_helper.rs similarity index 100% rename from junowen-server/src/tracing_helper.rs rename to crates/app/junowen-server/src/tracing_helper.rs diff --git a/junowen/Cargo.toml b/crates/app/junowen/Cargo.toml similarity index 100% rename from junowen/Cargo.toml rename to crates/app/junowen/Cargo.toml diff --git a/junowen/build.rs b/crates/app/junowen/build.rs similarity index 100% rename from junowen/build.rs rename to crates/app/junowen/build.rs diff --git a/junowen/locales/de.toml b/crates/app/junowen/locales/de.toml similarity index 100% rename from junowen/locales/de.toml rename to crates/app/junowen/locales/de.toml diff --git a/junowen/locales/en.toml b/crates/app/junowen/locales/en.toml similarity index 100% rename from junowen/locales/en.toml rename to crates/app/junowen/locales/en.toml diff --git a/junowen/locales/eo.toml b/crates/app/junowen/locales/eo.toml similarity index 100% rename from junowen/locales/eo.toml rename to crates/app/junowen/locales/eo.toml diff --git a/junowen/locales/es-ES.toml b/crates/app/junowen/locales/es-ES.toml similarity index 100% rename from junowen/locales/es-ES.toml rename to crates/app/junowen/locales/es-ES.toml diff --git a/junowen/locales/es.toml b/crates/app/junowen/locales/es.toml similarity index 100% rename from junowen/locales/es.toml rename to crates/app/junowen/locales/es.toml diff --git a/junowen/locales/fr.toml b/crates/app/junowen/locales/fr.toml similarity index 100% rename from junowen/locales/fr.toml rename to crates/app/junowen/locales/fr.toml diff --git a/junowen/locales/it.toml b/crates/app/junowen/locales/it.toml similarity index 100% rename from junowen/locales/it.toml rename to crates/app/junowen/locales/it.toml diff --git a/junowen/locales/ja.toml b/crates/app/junowen/locales/ja.toml similarity index 100% rename from junowen/locales/ja.toml rename to crates/app/junowen/locales/ja.toml diff --git a/junowen/locales/ko.toml b/crates/app/junowen/locales/ko.toml similarity index 100% rename from junowen/locales/ko.toml rename to crates/app/junowen/locales/ko.toml diff --git a/junowen/locales/pt-BR.toml b/crates/app/junowen/locales/pt-BR.toml similarity index 100% rename from junowen/locales/pt-BR.toml rename to crates/app/junowen/locales/pt-BR.toml diff --git a/junowen/locales/ru.toml b/crates/app/junowen/locales/ru.toml similarity index 100% rename from junowen/locales/ru.toml rename to crates/app/junowen/locales/ru.toml diff --git a/junowen/locales/zh-CN.toml b/crates/app/junowen/locales/zh-CN.toml similarity index 100% rename from junowen/locales/zh-CN.toml rename to crates/app/junowen/locales/zh-CN.toml diff --git a/junowen/locales/zh-TW.toml b/crates/app/junowen/locales/zh-TW.toml similarity index 100% rename from junowen/locales/zh-TW.toml rename to crates/app/junowen/locales/zh-TW.toml diff --git a/junowen/src/bin/junowen-standalone.rs b/crates/app/junowen/src/bin/junowen-standalone.rs similarity index 100% rename from junowen/src/bin/junowen-standalone.rs rename to crates/app/junowen/src/bin/junowen-standalone.rs diff --git a/junowen/src/bin/lang/ja.toml b/crates/app/junowen/src/bin/lang/ja.toml similarity index 100% rename from junowen/src/bin/lang/ja.toml rename to crates/app/junowen/src/bin/lang/ja.toml diff --git a/junowen/src/bin/lang/mod.rs b/crates/app/junowen/src/bin/lang/mod.rs similarity index 100% rename from junowen/src/bin/lang/mod.rs rename to crates/app/junowen/src/bin/lang/mod.rs diff --git a/junowen/src/custom_direct_3d.rs b/crates/app/junowen/src/custom_direct_3d.rs similarity index 100% rename from junowen/src/custom_direct_3d.rs rename to crates/app/junowen/src/custom_direct_3d.rs diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_9.rs b/crates/app/junowen/src/custom_direct_3d/custom_direct_3d_9.rs similarity index 100% rename from junowen/src/custom_direct_3d/custom_direct_3d_9.rs rename to crates/app/junowen/src/custom_direct_3d/custom_direct_3d_9.rs diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs b/crates/app/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs similarity index 100% rename from junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs rename to crates/app/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs diff --git a/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs b/crates/app/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs similarity index 100% rename from junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs rename to crates/app/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs diff --git a/junowen/src/file.rs b/crates/app/junowen/src/file.rs similarity index 100% rename from junowen/src/file.rs rename to crates/app/junowen/src/file.rs diff --git a/junowen/src/helper.rs b/crates/app/junowen/src/helper.rs similarity index 100% rename from junowen/src/helper.rs rename to crates/app/junowen/src/helper.rs diff --git a/junowen/src/junowen.rs b/crates/app/junowen/src/junowen.rs similarity index 100% rename from junowen/src/junowen.rs rename to crates/app/junowen/src/junowen.rs diff --git a/junowen/src/lib.rs b/crates/app/junowen/src/lib.rs similarity index 100% rename from junowen/src/lib.rs rename to crates/app/junowen/src/lib.rs diff --git a/junowen/src/lobby.rs b/crates/app/junowen/src/lobby.rs similarity index 100% rename from junowen/src/lobby.rs rename to crates/app/junowen/src/lobby.rs diff --git a/junowen/src/lobby/common_menu.rs b/crates/app/junowen/src/lobby/common_menu.rs similarity index 100% rename from junowen/src/lobby/common_menu.rs rename to crates/app/junowen/src/lobby/common_menu.rs diff --git a/junowen/src/lobby/common_menu/menu.rs b/crates/app/junowen/src/lobby/common_menu/menu.rs similarity index 100% rename from junowen/src/lobby/common_menu/menu.rs rename to crates/app/junowen/src/lobby/common_menu/menu.rs diff --git a/junowen/src/lobby/common_menu/menu_controller.rs b/crates/app/junowen/src/lobby/common_menu/menu_controller.rs similarity index 100% rename from junowen/src/lobby/common_menu/menu_controller.rs rename to crates/app/junowen/src/lobby/common_menu/menu_controller.rs diff --git a/junowen/src/lobby/common_menu/menu_item.rs b/crates/app/junowen/src/lobby/common_menu/menu_item.rs similarity index 100% rename from junowen/src/lobby/common_menu/menu_item.rs rename to crates/app/junowen/src/lobby/common_menu/menu_item.rs diff --git a/junowen/src/lobby/common_menu/text_input.rs b/crates/app/junowen/src/lobby/common_menu/text_input.rs similarity index 100% rename from junowen/src/lobby/common_menu/text_input.rs rename to crates/app/junowen/src/lobby/common_menu/text_input.rs diff --git a/junowen/src/lobby/helper.rs b/crates/app/junowen/src/lobby/helper.rs similarity index 100% rename from junowen/src/lobby/helper.rs rename to crates/app/junowen/src/lobby/helper.rs diff --git a/junowen/src/lobby/overlay.rs b/crates/app/junowen/src/lobby/overlay.rs similarity index 100% rename from junowen/src/lobby/overlay.rs rename to crates/app/junowen/src/lobby/overlay.rs diff --git a/junowen/src/lobby/pure_p2p_guest.rs b/crates/app/junowen/src/lobby/pure_p2p_guest.rs similarity index 100% rename from junowen/src/lobby/pure_p2p_guest.rs rename to crates/app/junowen/src/lobby/pure_p2p_guest.rs diff --git a/junowen/src/lobby/pure_p2p_offerer.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs similarity index 100% rename from junowen/src/lobby/pure_p2p_offerer.rs rename to crates/app/junowen/src/lobby/pure_p2p_offerer.rs diff --git a/junowen/src/lobby/pure_p2p_offerer/menu.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer/menu.rs similarity index 100% rename from junowen/src/lobby/pure_p2p_offerer/menu.rs rename to crates/app/junowen/src/lobby/pure_p2p_offerer/menu.rs diff --git a/junowen/src/lobby/pure_p2p_offerer/role.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer/role.rs similarity index 100% rename from junowen/src/lobby/pure_p2p_offerer/role.rs rename to crates/app/junowen/src/lobby/pure_p2p_offerer/role.rs diff --git a/junowen/src/lobby/pure_p2p_scenes.rs b/crates/app/junowen/src/lobby/pure_p2p_scenes.rs similarity index 100% rename from junowen/src/lobby/pure_p2p_scenes.rs rename to crates/app/junowen/src/lobby/pure_p2p_scenes.rs diff --git a/junowen/src/lobby/room.rs b/crates/app/junowen/src/lobby/room.rs similarity index 100% rename from junowen/src/lobby/room.rs rename to crates/app/junowen/src/lobby/room.rs diff --git a/junowen/src/lobby/room/reserved.rs b/crates/app/junowen/src/lobby/room/reserved.rs similarity index 100% rename from junowen/src/lobby/room/reserved.rs rename to crates/app/junowen/src/lobby/room/reserved.rs diff --git a/junowen/src/lobby/room/shared.rs b/crates/app/junowen/src/lobby/room/shared.rs similarity index 100% rename from junowen/src/lobby/room/shared.rs rename to crates/app/junowen/src/lobby/room/shared.rs diff --git a/junowen/src/lobby/room/tcp_signaling.rs b/crates/app/junowen/src/lobby/room/tcp_signaling.rs similarity index 100% rename from junowen/src/lobby/room/tcp_signaling.rs rename to crates/app/junowen/src/lobby/room/tcp_signaling.rs diff --git a/junowen/src/lobby/room/tcp_signaling/menu.rs b/crates/app/junowen/src/lobby/room/tcp_signaling/menu.rs similarity index 100% rename from junowen/src/lobby/room/tcp_signaling/menu.rs rename to crates/app/junowen/src/lobby/room/tcp_signaling/menu.rs diff --git a/junowen/src/lobby/root.rs b/crates/app/junowen/src/lobby/root.rs similarity index 100% rename from junowen/src/lobby/root.rs rename to crates/app/junowen/src/lobby/root.rs diff --git a/junowen/src/lobby/signaling_code.rs b/crates/app/junowen/src/lobby/signaling_code.rs similarity index 100% rename from junowen/src/lobby/signaling_code.rs rename to crates/app/junowen/src/lobby/signaling_code.rs diff --git a/junowen/src/lobby/text_layout.rs b/crates/app/junowen/src/lobby/text_layout.rs similarity index 100% rename from junowen/src/lobby/text_layout.rs rename to crates/app/junowen/src/lobby/text_layout.rs diff --git a/junowen/src/lobby/title_menu_modifier.rs b/crates/app/junowen/src/lobby/title_menu_modifier.rs similarity index 100% rename from junowen/src/lobby/title_menu_modifier.rs rename to crates/app/junowen/src/lobby/title_menu_modifier.rs diff --git a/junowen/src/present_skipper.rs b/crates/app/junowen/src/present_skipper.rs similarity index 100% rename from junowen/src/present_skipper.rs rename to crates/app/junowen/src/present_skipper.rs diff --git a/junowen/src/session.rs b/crates/app/junowen/src/session.rs similarity index 100% rename from junowen/src/session.rs rename to crates/app/junowen/src/session.rs diff --git a/junowen/src/session/battle.rs b/crates/app/junowen/src/session/battle.rs similarity index 100% rename from junowen/src/session/battle.rs rename to crates/app/junowen/src/session/battle.rs diff --git a/junowen/src/session/delayed_inputs.rs b/crates/app/junowen/src/session/delayed_inputs.rs similarity index 100% rename from junowen/src/session/delayed_inputs.rs rename to crates/app/junowen/src/session/delayed_inputs.rs diff --git a/junowen/src/session/session_message.rs b/crates/app/junowen/src/session/session_message.rs similarity index 100% rename from junowen/src/session/session_message.rs rename to crates/app/junowen/src/session/session_message.rs diff --git a/junowen/src/session/spectator.rs b/crates/app/junowen/src/session/spectator.rs similarity index 100% rename from junowen/src/session/spectator.rs rename to crates/app/junowen/src/session/spectator.rs diff --git a/junowen/src/session/spectator_host.rs b/crates/app/junowen/src/session/spectator_host.rs similarity index 100% rename from junowen/src/session/spectator_host.rs rename to crates/app/junowen/src/session/spectator_host.rs diff --git a/junowen/src/signaling.rs b/crates/app/junowen/src/signaling.rs similarity index 100% rename from junowen/src/signaling.rs rename to crates/app/junowen/src/signaling.rs diff --git a/junowen/src/signaling/waiting_for_match.rs b/crates/app/junowen/src/signaling/waiting_for_match.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match.rs rename to crates/app/junowen/src/signaling/waiting_for_match.rs diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/socket.rs diff --git a/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs rename to crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs diff --git a/junowen/src/signaling/waiting_for_match/waiting_in_room.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/waiting_in_room.rs rename to crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs diff --git a/junowen/src/state.rs b/crates/app/junowen/src/state.rs similarity index 100% rename from junowen/src/state.rs rename to crates/app/junowen/src/state.rs diff --git a/junowen/src/state/battle_session.rs b/crates/app/junowen/src/state/battle_session.rs similarity index 100% rename from junowen/src/state/battle_session.rs rename to crates/app/junowen/src/state/battle_session.rs diff --git a/junowen/src/state/battle_session/battle_game.rs b/crates/app/junowen/src/state/battle_session/battle_game.rs similarity index 100% rename from junowen/src/state/battle_session/battle_game.rs rename to crates/app/junowen/src/state/battle_session/battle_game.rs diff --git a/junowen/src/state/battle_session/battle_select.rs b/crates/app/junowen/src/state/battle_session/battle_select.rs similarity index 100% rename from junowen/src/state/battle_session/battle_select.rs rename to crates/app/junowen/src/state/battle_session/battle_select.rs diff --git a/junowen/src/state/battle_session/in_session.rs b/crates/app/junowen/src/state/battle_session/in_session.rs similarity index 100% rename from junowen/src/state/battle_session/in_session.rs rename to crates/app/junowen/src/state/battle_session/in_session.rs diff --git a/junowen/src/state/battle_session/spectator_host.rs b/crates/app/junowen/src/state/battle_session/spectator_host.rs similarity index 100% rename from junowen/src/state/battle_session/spectator_host.rs rename to crates/app/junowen/src/state/battle_session/spectator_host.rs diff --git a/junowen/src/state/battle_session/utils.rs b/crates/app/junowen/src/state/battle_session/utils.rs similarity index 100% rename from junowen/src/state/battle_session/utils.rs rename to crates/app/junowen/src/state/battle_session/utils.rs diff --git a/junowen/src/state/prepare.rs b/crates/app/junowen/src/state/prepare.rs similarity index 100% rename from junowen/src/state/prepare.rs rename to crates/app/junowen/src/state/prepare.rs diff --git a/junowen/src/state/render_parts.rs b/crates/app/junowen/src/state/render_parts.rs similarity index 100% rename from junowen/src/state/render_parts.rs rename to crates/app/junowen/src/state/render_parts.rs diff --git a/junowen/src/state/session.rs b/crates/app/junowen/src/state/session.rs similarity index 100% rename from junowen/src/state/session.rs rename to crates/app/junowen/src/state/session.rs diff --git a/junowen/src/state/spectator_session.rs b/crates/app/junowen/src/state/spectator_session.rs similarity index 100% rename from junowen/src/state/spectator_session.rs rename to crates/app/junowen/src/state/spectator_session.rs diff --git a/junowen/src/state/spectator_session/in_session.rs b/crates/app/junowen/src/state/spectator_session/in_session.rs similarity index 100% rename from junowen/src/state/spectator_session/in_session.rs rename to crates/app/junowen/src/state/spectator_session/in_session.rs diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/crates/app/junowen/src/state/spectator_session/spectator_game.rs similarity index 100% rename from junowen/src/state/spectator_session/spectator_game.rs rename to crates/app/junowen/src/state/spectator_session/spectator_game.rs diff --git a/junowen/src/state/spectator_session/spectator_standby.rs b/crates/app/junowen/src/state/spectator_session/spectator_standby.rs similarity index 100% rename from junowen/src/state/spectator_session/spectator_standby.rs rename to crates/app/junowen/src/state/spectator_session/spectator_standby.rs diff --git a/junowen/src/state/standby.rs b/crates/app/junowen/src/state/standby.rs similarity index 100% rename from junowen/src/state/standby.rs rename to crates/app/junowen/src/state/standby.rs diff --git a/junowen/src/tracing_helper.rs b/crates/app/junowen/src/tracing_helper.rs similarity index 100% rename from junowen/src/tracing_helper.rs rename to crates/app/junowen/src/tracing_helper.rs diff --git a/th19loader/Cargo.toml b/crates/app/th19loader/Cargo.toml similarity index 89% rename from th19loader/Cargo.toml rename to crates/app/th19loader/Cargo.toml index 110ae2b..49cf1f0 100644 --- a/th19loader/Cargo.toml +++ b/crates/app/th19loader/Cargo.toml @@ -15,7 +15,7 @@ winres = "0.1" [dependencies] anyhow.workspace = true -junowen-lib = { path = "../junowen-lib" } +junowen-lib = { path = "../../features/junowen-lib" } windows.workspace = true [package.metadata.winres] diff --git a/th19loader/build.rs b/crates/app/th19loader/build.rs similarity index 100% rename from th19loader/build.rs rename to crates/app/th19loader/build.rs diff --git a/th19loader/src/lib.rs b/crates/app/th19loader/src/lib.rs similarity index 100% rename from th19loader/src/lib.rs rename to crates/app/th19loader/src/lib.rs diff --git a/junowen-lib/Cargo.toml b/crates/features/junowen-lib/Cargo.toml similarity index 100% rename from junowen-lib/Cargo.toml rename to crates/features/junowen-lib/Cargo.toml diff --git a/junowen-lib/examples/webrtc_guest.rs b/crates/features/junowen-lib/examples/webrtc_guest.rs similarity index 100% rename from junowen-lib/examples/webrtc_guest.rs rename to crates/features/junowen-lib/examples/webrtc_guest.rs diff --git a/junowen-lib/examples/webrtc_host.rs b/crates/features/junowen-lib/examples/webrtc_host.rs similarity index 100% rename from junowen-lib/examples/webrtc_host.rs rename to crates/features/junowen-lib/examples/webrtc_host.rs diff --git a/junowen-lib/src/connection.rs b/crates/features/junowen-lib/src/connection.rs similarity index 100% rename from junowen-lib/src/connection.rs rename to crates/features/junowen-lib/src/connection.rs diff --git a/junowen-lib/src/connection/data_channel.rs b/crates/features/junowen-lib/src/connection/data_channel.rs similarity index 100% rename from junowen-lib/src/connection/data_channel.rs rename to crates/features/junowen-lib/src/connection/data_channel.rs diff --git a/junowen-lib/src/connection/peer_connection.rs b/crates/features/junowen-lib/src/connection/peer_connection.rs similarity index 100% rename from junowen-lib/src/connection/peer_connection.rs rename to crates/features/junowen-lib/src/connection/peer_connection.rs diff --git a/junowen-lib/src/connection/signaling.rs b/crates/features/junowen-lib/src/connection/signaling.rs similarity index 100% rename from junowen-lib/src/connection/signaling.rs rename to crates/features/junowen-lib/src/connection/signaling.rs diff --git a/junowen-lib/src/connection/signaling/socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/channel_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/channel_socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/channel_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/channel_socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/frame.rs b/crates/features/junowen-lib/src/connection/signaling/socket/frame.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/frame.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/frame.rs diff --git a/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs b/crates/features/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs similarity index 100% rename from junowen-lib/src/connection/signaling/stdio_signaling_interface.rs rename to crates/features/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs diff --git a/junowen-lib/src/find_process_id.rs b/crates/features/junowen-lib/src/find_process_id.rs similarity index 100% rename from junowen-lib/src/find_process_id.rs rename to crates/features/junowen-lib/src/find_process_id.rs diff --git a/junowen-lib/src/hook_utils.rs b/crates/features/junowen-lib/src/hook_utils.rs similarity index 100% rename from junowen-lib/src/hook_utils.rs rename to crates/features/junowen-lib/src/hook_utils.rs diff --git a/junowen-lib/src/hook_utils/dll_injection.rs b/crates/features/junowen-lib/src/hook_utils/dll_injection.rs similarity index 100% rename from junowen-lib/src/hook_utils/dll_injection.rs rename to crates/features/junowen-lib/src/hook_utils/dll_injection.rs diff --git a/junowen-lib/src/hook_utils/load_library_w_addr.rs b/crates/features/junowen-lib/src/hook_utils/load_library_w_addr.rs similarity index 100% rename from junowen-lib/src/hook_utils/load_library_w_addr.rs rename to crates/features/junowen-lib/src/hook_utils/load_library_w_addr.rs diff --git a/junowen-lib/src/lang.rs b/crates/features/junowen-lib/src/lang.rs similarity index 100% rename from junowen-lib/src/lang.rs rename to crates/features/junowen-lib/src/lang.rs diff --git a/junowen-lib/src/lib.rs b/crates/features/junowen-lib/src/lib.rs similarity index 100% rename from junowen-lib/src/lib.rs rename to crates/features/junowen-lib/src/lib.rs diff --git a/junowen-lib/src/macros.rs b/crates/features/junowen-lib/src/macros.rs similarity index 100% rename from junowen-lib/src/macros.rs rename to crates/features/junowen-lib/src/macros.rs diff --git a/junowen-lib/src/memory_accessors.rs b/crates/features/junowen-lib/src/memory_accessors.rs similarity index 100% rename from junowen-lib/src/memory_accessors.rs rename to crates/features/junowen-lib/src/memory_accessors.rs diff --git a/junowen-lib/src/memory_accessors/external_process.rs b/crates/features/junowen-lib/src/memory_accessors/external_process.rs similarity index 100% rename from junowen-lib/src/memory_accessors/external_process.rs rename to crates/features/junowen-lib/src/memory_accessors/external_process.rs diff --git a/junowen-lib/src/memory_accessors/hooked_process.rs b/crates/features/junowen-lib/src/memory_accessors/hooked_process.rs similarity index 100% rename from junowen-lib/src/memory_accessors/hooked_process.rs rename to crates/features/junowen-lib/src/memory_accessors/hooked_process.rs diff --git a/junowen-lib/src/signaling_server.rs b/crates/features/junowen-lib/src/signaling_server.rs similarity index 100% rename from junowen-lib/src/signaling_server.rs rename to crates/features/junowen-lib/src/signaling_server.rs diff --git a/junowen-lib/src/signaling_server/custom.rs b/crates/features/junowen-lib/src/signaling_server/custom.rs similarity index 100% rename from junowen-lib/src/signaling_server/custom.rs rename to crates/features/junowen-lib/src/signaling_server/custom.rs diff --git a/junowen-lib/src/signaling_server/reserved_room.rs b/crates/features/junowen-lib/src/signaling_server/reserved_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/reserved_room.rs rename to crates/features/junowen-lib/src/signaling_server/reserved_room.rs diff --git a/junowen-lib/src/signaling_server/room.rs b/crates/features/junowen-lib/src/signaling_server/room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room.rs rename to crates/features/junowen-lib/src/signaling_server/room.rs diff --git a/junowen-lib/src/signaling_server/room/delete_room.rs b/crates/features/junowen-lib/src/signaling_server/room/delete_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/delete_room.rs rename to crates/features/junowen-lib/src/signaling_server/room/delete_room.rs diff --git a/junowen-lib/src/signaling_server/room/post_room_join.rs b/crates/features/junowen-lib/src/signaling_server/room/post_room_join.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/post_room_join.rs rename to crates/features/junowen-lib/src/signaling_server/room/post_room_join.rs diff --git a/junowen-lib/src/signaling_server/room/post_room_keep.rs b/crates/features/junowen-lib/src/signaling_server/room/post_room_keep.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/post_room_keep.rs rename to crates/features/junowen-lib/src/signaling_server/room/post_room_keep.rs diff --git a/junowen-lib/src/signaling_server/room/put_room.rs b/crates/features/junowen-lib/src/signaling_server/room/put_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/put_room.rs rename to crates/features/junowen-lib/src/signaling_server/room/put_room.rs diff --git a/junowen-lib/src/th19.rs b/crates/features/junowen-lib/src/th19.rs similarity index 100% rename from junowen-lib/src/th19.rs rename to crates/features/junowen-lib/src/th19.rs diff --git a/junowen-lib/src/th19/structs.rs b/crates/features/junowen-lib/src/th19/structs.rs similarity index 100% rename from junowen-lib/src/th19/structs.rs rename to crates/features/junowen-lib/src/th19/structs.rs diff --git a/junowen-lib/src/th19/structs/app.rs b/crates/features/junowen-lib/src/th19/structs/app.rs similarity index 100% rename from junowen-lib/src/th19/structs/app.rs rename to crates/features/junowen-lib/src/th19/structs/app.rs diff --git a/junowen-lib/src/th19/structs/input_devices.rs b/crates/features/junowen-lib/src/th19/structs/input_devices.rs similarity index 100% rename from junowen-lib/src/th19/structs/input_devices.rs rename to crates/features/junowen-lib/src/th19/structs/input_devices.rs diff --git a/junowen-lib/src/th19/structs/others.rs b/crates/features/junowen-lib/src/th19/structs/others.rs similarity index 100% rename from junowen-lib/src/th19/structs/others.rs rename to crates/features/junowen-lib/src/th19/structs/others.rs diff --git a/junowen-lib/src/th19/structs/selection.rs b/crates/features/junowen-lib/src/th19/structs/selection.rs similarity index 100% rename from junowen-lib/src/th19/structs/selection.rs rename to crates/features/junowen-lib/src/th19/structs/selection.rs diff --git a/junowen-lib/src/th19/structs/settings.rs b/crates/features/junowen-lib/src/th19/structs/settings.rs similarity index 100% rename from junowen-lib/src/th19/structs/settings.rs rename to crates/features/junowen-lib/src/th19/structs/settings.rs diff --git a/junowen-lib/src/th19/th19_event_dispatcher.rs b/crates/features/junowen-lib/src/th19/th19_event_dispatcher.rs similarity index 100% rename from junowen-lib/src/th19/th19_event_dispatcher.rs rename to crates/features/junowen-lib/src/th19/th19_event_dispatcher.rs diff --git a/junowen-lib/src/th19/th19_helpers.rs b/crates/features/junowen-lib/src/th19/th19_helpers.rs similarity index 100% rename from junowen-lib/src/th19/th19_helpers.rs rename to crates/features/junowen-lib/src/th19/th19_helpers.rs diff --git a/junowen-lib/src/win_api_wrappers.rs b/crates/features/junowen-lib/src/win_api_wrappers.rs similarity index 100% rename from junowen-lib/src/win_api_wrappers.rs rename to crates/features/junowen-lib/src/win_api_wrappers.rs From 5d6ac3ef016939eea1788437b7e71cb88c75721e Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 13:33:56 +0900 Subject: [PATCH 23/29] =?UTF-8?q?refactor(junowen):=20custom=5Fdirect=5F3d?= =?UTF-8?q?=20=E3=82=92=20junowen-direct3d=20=E3=82=AF=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=B8=E5=88=87=E3=82=8A=E5=87=BA=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct3D9 のラッパーは windows クレート以外に依存しないため、 features として app から分離する。 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 9 +++++++++ Cargo.toml | 2 ++ crates/app/junowen/Cargo.toml | 1 + crates/app/junowen/src/junowen.rs | 2 +- crates/app/junowen/src/lib.rs | 3 +-- crates/features/junowen-direct3d/Cargo.toml | 10 ++++++++++ .../junowen-direct3d/src}/custom_direct_3d_9.rs | 0 .../junowen-direct3d/src}/custom_direct_3d_device_9.rs | 0 .../src}/direct_3d_device_event_listener.rs | 0 .../junowen-direct3d/src/lib.rs} | 0 10 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 crates/features/junowen-direct3d/Cargo.toml rename crates/{app/junowen/src/custom_direct_3d => features/junowen-direct3d/src}/custom_direct_3d_9.rs (100%) rename crates/{app/junowen/src/custom_direct_3d => features/junowen-direct3d/src}/custom_direct_3d_device_9.rs (100%) rename crates/{app/junowen/src/custom_direct_3d => features/junowen-direct3d/src}/direct_3d_device_event_listener.rs (100%) rename crates/{app/junowen/src/custom_direct_3d.rs => features/junowen-direct3d/src/lib.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index ce85850..ce1383e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2021,6 +2021,7 @@ dependencies = [ "clipboard-win", "derive-new", "getset", + "junowen-direct3d", "junowen-lib", "reqwest", "rmp-serde", @@ -2042,6 +2043,14 @@ dependencies = [ "windows-core 0.59.0", ] +[[package]] +name = "junowen-direct3d" +version = "1.2.0" +dependencies = [ + "windows 0.59.0", + "windows-core 0.59.0", +] + [[package]] name = "junowen-lib" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 070538f..a574087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/app/junowen", "crates/app/junowen-server", "crates/app/th19loader", + "crates/features/junowen-direct3d", "crates/features/junowen-lib", ] default-members = ["crates/app/junowen"] @@ -26,6 +27,7 @@ anyhow = { version = "1.0.95", features = ["backtrace"] } async-trait = "0.1.85" bytes = "1.9.0" clipboard-win = "5.4.0" +junowen-direct3d = { path = "./crates/features/junowen-direct3d" } junowen-lib = { path = "./crates/features/junowen-lib" } rmp-serde = "1.3.0" serde = { version = "1.0.217", features = ["derive"] } diff --git a/crates/app/junowen/Cargo.toml b/crates/app/junowen/Cargo.toml index c2c61bd..28e0bba 100644 --- a/crates/app/junowen/Cargo.toml +++ b/crates/app/junowen/Cargo.toml @@ -22,6 +22,7 @@ bytes.workspace = true clipboard-win.workspace = true derive-new = "0.6.0" getset = "0.1.2" +junowen-direct3d.workspace = true junowen-lib.workspace = true reqwest = { version = "0.12.7", features = ["json"] } rmp-serde.workspace = true diff --git a/crates/app/junowen/src/junowen.rs b/crates/app/junowen/src/junowen.rs index e1a8ac2..a17b7f9 100644 --- a/crates/app/junowen/src/junowen.rs +++ b/crates/app/junowen/src/junowen.rs @@ -1,5 +1,6 @@ use std::ffi::c_void; +use junowen_direct3d::Direct3DDeviceEventListener; use junowen_lib::{ Th19, Th19EventListener, structs::{app::ScreenId, others::RenderingText}, @@ -7,7 +8,6 @@ use junowen_lib::{ use windows::Win32::Graphics::Direct3D9::IDirect3DDevice9; use crate::{ - custom_direct_3d::Direct3DDeviceEventListener, file::{Features, SettingsRepo}, lobby::{self, Lobby, TitleMenuModifier}, state::State, diff --git a/crates/app/junowen/src/lib.rs b/crates/app/junowen/src/lib.rs index 14aeaea..81908b8 100644 --- a/crates/app/junowen/src/lib.rs +++ b/crates/app/junowen/src/lib.rs @@ -1,4 +1,3 @@ -mod custom_direct_3d; mod file; mod helper; mod junowen; @@ -13,7 +12,7 @@ rust_i18n::i18n!("locales", fallback = "en"); use std::{cell::OnceCell, mem::take, path::Path, ptr::null_mut, slice, sync::LazyLock}; -use custom_direct_3d::CustomDirect3D9; +use junowen_direct3d::CustomDirect3D9; use junowen_lib::{ Th19, Th19EventDispatcher, hook_utils::{WELL_KNOWN_VERSION_HASHES, calc_th19_hash, show_warn_dialog}, diff --git a/crates/features/junowen-direct3d/Cargo.toml b/crates/features/junowen-direct3d/Cargo.toml new file mode 100644 index 0000000..f19afbf --- /dev/null +++ b/crates/features/junowen-direct3d/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "junowen-direct3d" +edition = "2024" +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +windows.workspace = true +windows-core.workspace = true diff --git a/crates/app/junowen/src/custom_direct_3d/custom_direct_3d_9.rs b/crates/features/junowen-direct3d/src/custom_direct_3d_9.rs similarity index 100% rename from crates/app/junowen/src/custom_direct_3d/custom_direct_3d_9.rs rename to crates/features/junowen-direct3d/src/custom_direct_3d_9.rs diff --git a/crates/app/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs b/crates/features/junowen-direct3d/src/custom_direct_3d_device_9.rs similarity index 100% rename from crates/app/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs rename to crates/features/junowen-direct3d/src/custom_direct_3d_device_9.rs diff --git a/crates/app/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs b/crates/features/junowen-direct3d/src/direct_3d_device_event_listener.rs similarity index 100% rename from crates/app/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs rename to crates/features/junowen-direct3d/src/direct_3d_device_event_listener.rs diff --git a/crates/app/junowen/src/custom_direct_3d.rs b/crates/features/junowen-direct3d/src/lib.rs similarity index 100% rename from crates/app/junowen/src/custom_direct_3d.rs rename to crates/features/junowen-direct3d/src/lib.rs From 4f5ee64d90c9e8f6c8b39d67ba80230302024db6 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 15:01:42 +0900 Subject: [PATCH 24/29] =?UTF-8?q?refactor(junowen):=20=E3=83=A1=E3=83=8B?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E9=83=A8=E5=93=81=E3=82=92=20junowen-menu=20?= =?UTF-8?q?=E3=82=AF=E3=83=AC=E3=83=BC=E3=83=88=E3=81=B8=E5=88=87=E3=82=8A?= =?UTF-8?q?=E5=87=BA=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面の種類は型引数で受け取る。 入力は MenuButtons で、効果音・キーボード・描画は MenuDevice と MenuRenderer で受け取る。 Th19 とのつなぎ込みは app 側の lobby/common_menu.rs に置く。 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 4 + crates/app/junowen/Cargo.toml | 5 +- crates/app/junowen/src/lobby.rs | 12 +- crates/app/junowen/src/lobby/common_menu.rs | 270 ++++-------------- crates/app/junowen/src/lobby/helper.rs | 84 ------ .../app/junowen/src/lobby/pure_p2p_guest.rs | 17 +- .../app/junowen/src/lobby/pure_p2p_offerer.rs | 16 +- .../app/junowen/src/lobby/pure_p2p_scenes.rs | 8 +- crates/app/junowen/src/lobby/room.rs | 12 +- crates/app/junowen/src/lobby/room/reserved.rs | 15 +- crates/app/junowen/src/lobby/room/shared.rs | 15 +- .../junowen/src/lobby/room/tcp_signaling.rs | 18 +- crates/app/junowen/src/lobby/root.rs | 21 +- .../junowen/src/lobby/title_menu_modifier.rs | 2 +- crates/features/junowen-menu/Cargo.toml | 14 + crates/features/junowen-menu/src/action.rs | 24 ++ .../features/junowen-menu/src/common_menu.rs | 140 +++++++++ crates/features/junowen-menu/src/device.rs | 13 + .../junowen-menu/src/input/menu_buttons.rs | 41 +++ .../src/input}/menu_controller.rs | 43 ++- crates/features/junowen-menu/src/input/mod.rs | 9 + .../junowen-menu/src/input}/text_input.rs | 41 ++- crates/features/junowen-menu/src/lib.rs | 19 ++ .../junowen-menu/src/menu/current_scene.rs | 67 +++++ .../junowen-menu/src/menu}/menu_item.rs | 31 +- .../junowen-menu/src/menu/mod.rs} | 53 ++-- crates/features/junowen-menu/src/render.rs | 123 ++++++++ 28 files changed, 688 insertions(+), 439 deletions(-) create mode 100644 crates/features/junowen-menu/Cargo.toml create mode 100644 crates/features/junowen-menu/src/action.rs create mode 100644 crates/features/junowen-menu/src/common_menu.rs create mode 100644 crates/features/junowen-menu/src/device.rs create mode 100644 crates/features/junowen-menu/src/input/menu_buttons.rs rename crates/{app/junowen/src/lobby/common_menu => features/junowen-menu/src/input}/menu_controller.rs (68%) create mode 100644 crates/features/junowen-menu/src/input/mod.rs rename crates/{app/junowen/src/lobby/common_menu => features/junowen-menu/src/input}/text_input.rs (72%) create mode 100644 crates/features/junowen-menu/src/lib.rs create mode 100644 crates/features/junowen-menu/src/menu/current_scene.rs rename crates/{app/junowen/src/lobby/common_menu => features/junowen-menu/src/menu}/menu_item.rs (88%) rename crates/{app/junowen/src/lobby/common_menu/menu.rs => features/junowen-menu/src/menu/mod.rs} (74%) create mode 100644 crates/features/junowen-menu/src/render.rs diff --git a/Cargo.lock b/Cargo.lock index ce1383e..15923de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,6 +2023,7 @@ dependencies = [ "getset", "junowen-direct3d", "junowen-lib", + "junowen-menu", "reqwest", "rmp-serde", "rust-i18n", @@ -2083,6 +2084,15 @@ dependencies = [ "windows 0.59.0", ] +[[package]] +name = "junowen-menu" +version = "1.2.0" +dependencies = [ + "derive-new", + "getset", + "windows 0.59.0", +] + [[package]] name = "junowen-server" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index a574087..1423eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/app/th19loader", "crates/features/junowen-direct3d", "crates/features/junowen-lib", + "crates/features/junowen-menu", ] default-members = ["crates/app/junowen"] resolver = "2" @@ -27,8 +28,11 @@ anyhow = { version = "1.0.95", features = ["backtrace"] } async-trait = "0.1.85" bytes = "1.9.0" clipboard-win = "5.4.0" +derive-new = "0.6.0" +getset = "0.1.2" junowen-direct3d = { path = "./crates/features/junowen-direct3d" } junowen-lib = { path = "./crates/features/junowen-lib" } +junowen-menu = { path = "./crates/features/junowen-menu" } rmp-serde = "1.3.0" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.135" diff --git a/crates/app/junowen/Cargo.toml b/crates/app/junowen/Cargo.toml index 28e0bba..717e311 100644 --- a/crates/app/junowen/Cargo.toml +++ b/crates/app/junowen/Cargo.toml @@ -20,10 +20,11 @@ anyhow.workspace = true async-trait.workspace = true bytes.workspace = true clipboard-win.workspace = true -derive-new = "0.6.0" -getset = "0.1.2" +derive-new.workspace = true +getset.workspace = true junowen-direct3d.workspace = true junowen-lib.workspace = true +junowen-menu.workspace = true reqwest = { version = "0.12.7", features = ["json"] } rmp-serde.workspace = true rust-i18n = "3.1.2" diff --git a/crates/app/junowen/src/lobby.rs b/crates/app/junowen/src/lobby.rs index e64aa20..282cd4c 100644 --- a/crates/app/junowen/src/lobby.rs +++ b/crates/app/junowen/src/lobby.rs @@ -24,7 +24,7 @@ use crate::{ }; use self::{ - common_menu::LobbyScene, + common_menu::{LobbyScene, MenuButtons, to_menu_buttons}, helper::overlay_description, pure_p2p_scenes::PureP2pScenes, room::{reserved::ReservedRoom, shared::SharedRoom, tcp_signaling::TcpSignaling}, @@ -44,7 +44,7 @@ pub struct Lobby { reserved_room: ReservedRoom, tcp_signaling: TcpSignaling, pure_p2p: PureP2pScenes, - prev_input: InputValue, + prev_input: MenuButtons, #[getset(get = "pub", get_mut = "pub")] waiting_for_match: Option, } @@ -61,17 +61,17 @@ impl Lobby { reserved_room: ReservedRoom::new(), tcp_signaling: TcpSignaling::new(), pure_p2p: PureP2pScenes::default(), - prev_input: InputValue::full(), + prev_input: to_menu_buttons(InputValue::full()), } } pub fn clear_input(&mut self) { - self.prev_input = InputValue::full(); + self.prev_input = to_menu_buttons(InputValue::full()); } pub fn on_input_menu(&mut self, th19: &mut Th19) { self.prev_scene = self.scene; - let current_input = th19.menu_input().current(); + let current_input = to_menu_buttons(th19.menu_input().current()); th19.menu_input_mut().set_current(InputValue::empty()); if let Some(scene) = match self.scene { @@ -134,7 +134,7 @@ impl Lobby { ), } { self.scene = scene; - self.prev_input = InputValue::full(); + self.prev_input = to_menu_buttons(InputValue::full()); } else { self.prev_input = current_input; } diff --git a/crates/app/junowen/src/lobby/common_menu.rs b/crates/app/junowen/src/lobby/common_menu.rs index fd83592..9c5e093 100644 --- a/crates/app/junowen/src/lobby/common_menu.rs +++ b/crates/app/junowen/src/lobby/common_menu.rs @@ -1,39 +1,15 @@ -mod menu; -mod menu_controller; -mod menu_item; -mod text_input; - use std::ffi::c_void; -use derive_new::new; -use getset::{CopyGetters, Getters, MutGetters}; -use junowen_lib::{Th19, structs::input_devices::InputValue}; - -use self::{ - menu_controller::{ - MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, +use junowen_lib::{ + Th19, + structs::{ + input_devices::{InputFlags, InputValue}, + others::RenderingText, }, - text_input::TextInput, }; +use junowen_menu::{MenuDevice, MenuRenderer, MenuSound, MenuText}; -use super::helper::render_title; - -pub use {menu::Menu, menu_item::MenuItem}; - -#[derive(Debug, CopyGetters, new)] -pub struct Action { - #[get_copy = "pub"] - id: u8, - #[get_copy = "pub"] - play_sound: bool, - value: Option, -} - -impl Action { - pub fn value(&self) -> Option<&str> { - self.value.as_deref() - } -} +pub use junowen_menu::{MenuButtons, menu_item_color, render_label_value}; #[derive(Clone, Copy, Debug)] pub enum LobbyScene { @@ -46,198 +22,60 @@ pub enum LobbyScene { PureP2pSpectator, } -pub enum OnMenuInputResult { - None, - Cancel, - Action(Action), - SubScene(LobbyScene), -} - -#[derive(Debug)] -enum CurrentMenuSceneResult<'a> { - Menu(&'a Menu), - SubScene(LobbyScene), - TextInput(&'static str, &'a TextInput), -} - -enum CurrentMenuSceneMutResult<'a> { - Menu(&'a mut Menu), - SubScene, - TextInput(&'a mut TextInput), -} - -#[derive(Getters, MutGetters)] -pub struct CommonMenu { - #[getset(get = "pub", get_mut = "pub")] - menu: Menu, - instant_exit: bool, - base_height: u32, - #[getset(get_mut = "pub")] - controller: MenuController, -} - -impl CommonMenu { - pub fn new(instant_exit: bool, base_height: u32, menu: Menu) -> Self { - Self { - menu, - instant_exit, - base_height, - controller: MenuController::default(), - } - } - - pub fn root_title(&self) -> &'static str { - self.menu.title() - } - - fn apply_decide_count(&mut self) -> Option { - match self.controller.update_decide() { - MenuControllerUpdateDecideResult::None => None, - MenuControllerUpdateDecideResult::Wait => Some(OnMenuInputResult::None), - MenuControllerUpdateDecideResult::Decide => { - if let Some(scene) = self.menu.dig() { - return Some(OnMenuInputResult::SubScene(scene)); - } - None - } - MenuControllerUpdateDecideResult::Cancel => { - if !self.menu.bury() { - return Some(OnMenuInputResult::Cancel); - } - None - } - } +pub type CommonMenu = junowen_menu::CommonMenu; +pub type Menu = junowen_menu::Menu; +pub type MenuItem = junowen_menu::MenuItem; +pub type OnMenuInputResult = junowen_menu::OnMenuInputResult; + +pub fn to_menu_buttons(input: InputValue) -> MenuButtons { + let bits = |flags: &[InputFlags]| { + flags + .iter() + .enumerate() + .filter(|&(_, &flag)| input.0.contains(flag)) + .fold(0u8, |acc, (i, _)| acc | (1 << i)) + }; + MenuButtons { + up: input.0.contains(InputFlags::UP), + down: input.0.contains(InputFlags::DOWN), + decide: bits(&[InputFlags::SHOT, InputFlags::ENTER]), + cancel: bits(&[InputFlags::CHARGE, InputFlags::BOMB, InputFlags::PAUSE]), } +} - pub fn on_input_menu( - &mut self, - current_input: InputValue, - prev_input: InputValue, - th19: &Th19, - ) -> OnMenuInputResult { - if let Some(result) = self.apply_decide_count() { - return result; - } - - match self.current_menu_scene() { - CurrentMenuSceneResult::SubScene(scene) => OnMenuInputResult::SubScene(scene), - CurrentMenuSceneResult::Menu(menu) => { - let ignore_decide = menu.items().is_empty(); - let instant_decide = - ignore_decide || matches!(menu.selected_item(), MenuItem::Plain(_)); - let play_decide_sound = !ignore_decide - && menu - .selected_item() - .decided_action() - .map(|x| x.play_sound()) - .unwrap_or(true); - let root_cancel = !self.menu.decided() && self.instant_exit; - - let input_result = - self.controller - .input(current_input, prev_input, instant_decide, root_cancel); +pub struct Th19MenuDevice<'a>(pub &'a Th19); - if root_cancel { - if let MenuControllerInputResult::Cancel = input_result { - return OnMenuInputResult::Cancel; - } - } - let CurrentMenuSceneMutResult::Menu(menu) = self.current_menu_scene_mut() else { - unreachable!() - }; - if let Some(action) = - menu.input(input_result, ignore_decide, play_decide_sound, th19) - { - OnMenuInputResult::Action(action) - } else { - OnMenuInputResult::None - } - } - CurrentMenuSceneResult::TextInput(_, _) => { - let CurrentMenuSceneMutResult::TextInput(text_input) = - self.current_menu_scene_mut() - else { - unreachable!() - }; - match text_input.on_input_menu(th19) { - text_input::OnMenuInputResult::None => OnMenuInputResult::None, - text_input::OnMenuInputResult::Cancel => { - th19.play_sound(th19.sound_manager(), 0x09, 0); - self.controller.force_cancel(); - OnMenuInputResult::None - } - text_input::OnMenuInputResult::Decide(changed_action, new_room_name) => { - th19.play_sound(th19.sound_manager(), 0x07, 0); - self.controller.force_cancel(); - let action = Action::new(changed_action, false, Some(new_room_name)); - OnMenuInputResult::Action(action) - } - } - } - } +impl MenuDevice for Th19MenuDevice<'_> { + fn play_sound(&self, sound: MenuSound) { + let id = match sound { + MenuSound::Cancel => 0x09, + MenuSound::Decide => 0x07, + MenuSound::Select => 0x0a, + }; + self.0.play_sound(self.0.sound_manager(), id, 0); } - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - match self.current_menu_scene() { - CurrentMenuSceneResult::SubScene { .. } => unreachable!(), - CurrentMenuSceneResult::Menu(menu) => { - render_title(th19, text_renderer, menu.title().as_bytes()); - menu.on_render_texts(self.base_height, th19, text_renderer); - } - CurrentMenuSceneResult::TextInput(label, text_input) => { - render_title(th19, text_renderer, label.as_bytes()); - text_input.on_render_texts(th19, text_renderer); - } - } + fn keyboard_raw_keys(&self) -> &[u8; 256] { + self.0.input_devices().keyboard_input().raw_keys() } +} - fn current_menu_scene(&self) -> CurrentMenuSceneResult { - if !self.menu.decided() { - return CurrentMenuSceneResult::Menu(&self.menu); - } - let mut menu = &self.menu; - loop { - match menu.selected_item() { - MenuItem::Plain(_) => unreachable!(), - MenuItem::SubMenu(item) => { - if item.sub_menu().decided() { - menu = item.sub_menu(); - continue; - } - return CurrentMenuSceneResult::Menu(item.sub_menu()); - } - MenuItem::SubScene(item) => { - return CurrentMenuSceneResult::SubScene(item.sub_scene()); - } - MenuItem::TextInput(item) => { - return CurrentMenuSceneResult::TextInput(item.label(), item.text_input()); - } - } - } - } +pub struct Th19MenuRenderer<'a> { + pub th19: &'a Th19, + pub text_renderer: &'a c_void, +} - fn current_menu_scene_mut(&mut self) -> CurrentMenuSceneMutResult { - if !self.menu.decided() { - return CurrentMenuSceneMutResult::Menu(&mut self.menu); - } - let mut menu = &mut self.menu; - loop { - match menu.selected_item_mut() { - MenuItem::Plain(_) => unreachable!(), - MenuItem::SubMenu(item) => { - if item.sub_menu().decided() { - menu = item.sub_menu_mut(); - continue; - } - return CurrentMenuSceneMutResult::Menu(item.sub_menu_mut()); - } - MenuItem::SubScene(_item) => { - return CurrentMenuSceneMutResult::SubScene; - } - MenuItem::TextInput(item) => { - return CurrentMenuSceneMutResult::TextInput(item.text_input_mut()); - } - } - } +impl MenuRenderer for Th19MenuRenderer<'_> { + fn render_text(&self, text: &MenuText) { + let mut rt = RenderingText::default(); + rt.set_text(text.text); + rt.set_x(text.x, self.th19.window_inner()); + rt.set_y(text.y, self.th19.window_inner()); + rt.color = text.color; + rt.font_type = text.font_type; + rt.drop_shadow = text.drop_shadow; + rt.horizontal_align = text.horizontal_align; + rt.vertical_align = text.vertical_align; + self.th19.render_text(self.text_renderer, &rt); } } diff --git a/crates/app/junowen/src/lobby/helper.rs b/crates/app/junowen/src/lobby/helper.rs index d2d8f30..fcad4de 100644 --- a/crates/app/junowen/src/lobby/helper.rs +++ b/crates/app/junowen/src/lobby/helper.rs @@ -9,44 +9,6 @@ const TEXT_LINE_TOP: u32 = 160; const TEXT_LINE_HEIGHT: u32 = 32; const SIGNALING_CODE_CHUNK_LEN: usize = 100; -pub fn render_title(th19: &Th19, text_renderer: &c_void, text: &[u8]) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(640, th19.window_inner()); - rt.set_y(64, th19.window_inner()); - rt.color = 0xff000000; - rt.font_type = 9; - rt.drop_shadow = true; - rt.horizontal_align = 0; - th19.render_text(text_renderer, &rt); - - rt.color = 0xffffffff; - rt.font_type = 7; - th19.render_text(text_renderer, &rt); -} - -pub fn render_menu_item( - th19: &Th19, - text_renderer: &c_void, - text: &[u8], - y: u32, - enabled: bool, - selected: bool, -) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(640, th19.window_inner()); - rt.set_y(y, th19.window_inner()); - rt.color = menu_item_color(9, enabled, selected); - rt.font_type = 9; - rt.horizontal_align = 0; - th19.render_text(text_renderer, &rt); - - rt.color = menu_item_color(7, enabled, selected); - rt.font_type = 7; - th19.render_text(text_renderer, &rt); -} - pub fn render_text_line(th19: &Th19, text_renderer: &c_void, line: u32, text: &[u8]) { let mut rt = RenderingText::default(); rt.set_text(text); @@ -104,49 +66,3 @@ pub fn overlay_description(string: String) -> Vec { string, }] } - -pub fn menu_item_color(font_type: u32, enabled: bool, selected: bool) -> u32 { - if !enabled { - match font_type { - 9 => 0x40ffffff, - 7 => 0xff808080, - _ => unreachable!(), - } - } else if selected { - match font_type { - 9 => 0xff000000, - 7 => 0xffffff80, - _ => unreachable!(), - } - } else { - match font_type { - 9 => 0xff404040, - 7 => 0xff808060, - _ => unreachable!(), - } - } -} - -pub fn render_label_value( - th19: &Th19, - text_renderer: &c_void, - height: u32, - vertical_align: u32, - label: &str, - value: &str, -) { - let mut rt = RenderingText::default(); - rt.set_text(format!("{:<11}:", label).as_bytes()); - rt.set_x(320, th19.window_inner()); - rt.set_y(height, th19.window_inner()); - rt.color = 0xffffffff; - rt.font_type = 0; - rt.horizontal_align = 1; - rt.vertical_align = vertical_align; - th19.render_text(text_renderer, &rt); - - rt.set_text(value.as_bytes()); - rt.color = 0xffffffa0; - rt.set_x(544, th19.window_inner()); - th19.render_text(text_renderer, &rt); -} diff --git a/crates/app/junowen/src/lobby/pure_p2p_guest.rs b/crates/app/junowen/src/lobby/pure_p2p_guest.rs index 54aecbd..34f4641 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_guest.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_guest.rs @@ -7,7 +7,6 @@ use junowen_lib::{ BATTLE_PROTOCOL, signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, - structs::input_devices::InputValue, }; use rust_i18n::t; use tokio::sync::mpsc; @@ -16,7 +15,10 @@ use crate::session::battle::BattleSession; use super::{ super::signaling::Signaling, - common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, + common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + Th19MenuRenderer, + }, helper::signaling_code_line_count, overlay::OverlayText, signaling_code::paste_signaling_code, @@ -60,8 +62,8 @@ impl PureP2pGuest { pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, session_rx: &mut Option>, ) -> Option { @@ -95,7 +97,7 @@ impl PureP2pGuest { } match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -170,7 +172,10 @@ impl PureP2pGuest { } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); + self.common_menu.on_render_texts(&Th19MenuRenderer { + th19, + text_renderer, + }); self.layout().render_codes(th19, text_renderer); } diff --git a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs index 530357a..15fa7ee 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs @@ -10,7 +10,6 @@ use junowen_lib::{ DataChannel, PeerConnection, signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, - structs::input_devices::InputValue, }; use rust_i18n::t; use tokio::sync::mpsc; @@ -18,7 +17,9 @@ use tracing::trace; use super::{ super::signaling::Signaling, - common_menu::{CommonMenu, LobbyScene, OnMenuInputResult}, + common_menu::{ + CommonMenu, LobbyScene, MenuButtons, OnMenuInputResult, Th19MenuDevice, Th19MenuRenderer, + }, helper::signaling_code_line_count, overlay::OverlayText, signaling_code::paste_signaling_code, @@ -74,8 +75,8 @@ where pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, session_rx: &mut Option>, ) -> Option { @@ -92,7 +93,7 @@ where } match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -175,7 +176,10 @@ where } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); + self.common_menu.on_render_texts(&Th19MenuRenderer { + th19, + text_renderer, + }); self.layout().render_codes(th19, text_renderer); } diff --git a/crates/app/junowen/src/lobby/pure_p2p_scenes.rs b/crates/app/junowen/src/lobby/pure_p2p_scenes.rs index a42e54b..6b42683 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_scenes.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_scenes.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use crate::{ session::{battle::BattleSession, spectator::SpectatorSession}, @@ -10,7 +10,7 @@ use crate::{ }; use super::{ - common_menu::LobbyScene, + common_menu::{LobbyScene, MenuButtons}, overlay::OverlayText, pure_p2p_guest::PureP2pGuest, pure_p2p_offerer::{PureP2pOfferer, pure_p2p_host, pure_p2p_spectator}, @@ -28,8 +28,8 @@ impl PureP2pScenes { pub fn on_input_menu( &mut self, scene: LobbyScene, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting_for_match: &mut Option, ) -> Option { diff --git a/crates/app/junowen/src/lobby/room.rs b/crates/app/junowen/src/lobby/room.rs index cbf2025..e93cb94 100644 --- a/crates/app/junowen/src/lobby/room.rs +++ b/crates/app/junowen/src/lobby/room.rs @@ -9,8 +9,8 @@ use junowen_lib::{Th19, structs::others::RenderingText}; use crate::signaling::waiting_for_match::WaitingInRoom; use super::{ - common_menu::CommonMenu, - helper::{render_label_value, render_text_line}, + common_menu::{CommonMenu, Th19MenuRenderer, render_label_value}, + helper::render_text_line, }; fn progress_alphas(progress: f64) -> Vec { @@ -123,10 +123,14 @@ pub fn on_render_texts( th19: &Th19, text_renderer: &c_void, ) { - menu.on_render_texts(th19, text_renderer); + let renderer = Th19MenuRenderer { + th19, + text_renderer, + }; + menu.on_render_texts(&renderer); if let Some(value) = value { - render_label_value(th19, text_renderer, 240 - 56, 1, label, value); + render_label_value(&renderer, 240 - 56, 1, label, value); } if let Some(waiting) = waiting { let elapsed = waiting.elapsed(); diff --git a/crates/app/junowen/src/lobby/room/reserved.rs b/crates/app/junowen/src/lobby/room/reserved.rs index 61b81aa..67a6ed6 100644 --- a/crates/app/junowen/src/lobby/room/reserved.rs +++ b/crates/app/junowen/src/lobby/room/reserved.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use crate::{ TOKIO_RUNTIME, @@ -12,7 +12,9 @@ use crate::{ }; use super::{ - super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, + super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + }, on_render_texts, }; @@ -70,8 +72,8 @@ impl ReservedRoom { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -96,7 +98,10 @@ impl ReservedRoom { } } - match self.menu.on_input_menu(current_input, prev_input, th19) { + match self + .menu + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) + { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { self.enter = false; diff --git a/crates/app/junowen/src/lobby/room/shared.rs b/crates/app/junowen/src/lobby/room/shared.rs index cf3f3f5..07ef256 100644 --- a/crates/app/junowen/src/lobby/room/shared.rs +++ b/crates/app/junowen/src/lobby/room/shared.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use rust_i18n::t; use crate::{ @@ -8,7 +8,9 @@ use crate::{ }; use super::{ - super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, + super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + }, on_render_texts, }; @@ -57,8 +59,8 @@ impl SharedRoom { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -76,7 +78,10 @@ impl SharedRoom { if let Some(waiting) = waiting { waiting.recv(); } - match self.menu.on_input_menu(current_input, prev_input, th19) { + match self + .menu + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) + { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => Some(LobbyScene::Root), OnMenuInputResult::SubScene(_) => unreachable!(), diff --git a/crates/app/junowen/src/lobby/room/tcp_signaling.rs b/crates/app/junowen/src/lobby/room/tcp_signaling.rs index 2078420..24df49a 100644 --- a/crates/app/junowen/src/lobby/room/tcp_signaling.rs +++ b/crates/app/junowen/src/lobby/room/tcp_signaling.rs @@ -2,7 +2,7 @@ mod menu; use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use rust_i18n::t; use crate::{ @@ -16,7 +16,7 @@ use self::menu::{ }; use super::{ - super::common_menu::{LobbyScene, MenuItem, OnMenuInputResult}, + super::common_menu::{LobbyScene, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice}, on_render_texts, }; @@ -40,8 +40,8 @@ impl TcpSignaling { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -59,11 +59,11 @@ impl TcpSignaling { waiting.recv(); } - match self - .menu - .common_menu_mut() - .on_input_menu(current_input, prev_input, th19) - { + match self.menu.common_menu_mut().on_input_menu( + current_input, + prev_input, + &Th19MenuDevice(th19), + ) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => Some(LobbyScene::Root), OnMenuInputResult::SubScene(_) => unreachable!(), diff --git a/crates/app/junowen/src/lobby/root.rs b/crates/app/junowen/src/lobby/root.rs index f1a42e2..31c60d5 100644 --- a/crates/app/junowen/src/lobby/root.rs +++ b/crates/app/junowen/src/lobby/root.rs @@ -1,12 +1,12 @@ use std::ffi::c_void; -use junowen_lib::{ - Th19, - structs::input_devices::{InputFlags, InputValue}, -}; +use junowen_lib::{Th19, structs::input_devices::InputFlags}; use rust_i18n::t; -use super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}; +use super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + Th19MenuRenderer, +}; pub struct Root { common_menu: CommonMenu, @@ -48,13 +48,13 @@ impl Root { pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &mut Th19, ) -> Option { match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -67,7 +67,10 @@ impl Root { } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); + self.common_menu.on_render_texts(&Th19MenuRenderer { + th19, + text_renderer, + }); } pub fn text(&self) -> String { diff --git a/crates/app/junowen/src/lobby/title_menu_modifier.rs b/crates/app/junowen/src/lobby/title_menu_modifier.rs index 5d14491..3ac5d2c 100644 --- a/crates/app/junowen/src/lobby/title_menu_modifier.rs +++ b/crates/app/junowen/src/lobby/title_menu_modifier.rs @@ -8,7 +8,7 @@ use junowen_lib::{ structs::others::RenderingText, }; -use super::helper::menu_item_color; +use super::common_menu::menu_item_color; fn direction(input: &Input, flag: InputFlags) -> bool { input.current().0 & InputValue::from(flag).0 != None diff --git a/crates/features/junowen-menu/Cargo.toml b/crates/features/junowen-menu/Cargo.toml new file mode 100644 index 0000000..1613883 --- /dev/null +++ b/crates/features/junowen-menu/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "junowen-menu" +edition = "2024" +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +derive-new.workspace = true +getset.workspace = true +windows.workspace = true + +[lints.clippy] +self_named_module_files = "warn" diff --git a/crates/features/junowen-menu/src/action.rs b/crates/features/junowen-menu/src/action.rs new file mode 100644 index 0000000..55f57c6 --- /dev/null +++ b/crates/features/junowen-menu/src/action.rs @@ -0,0 +1,24 @@ +use derive_new::new; +use getset::CopyGetters; + +#[derive(Debug, CopyGetters, new)] +pub struct Action { + #[get_copy = "pub"] + id: u8, + #[get_copy = "pub"] + play_sound: bool, + value: Option, +} + +impl Action { + pub fn value(&self) -> Option<&str> { + self.value.as_deref() + } +} + +pub enum OnMenuInputResult { + None, + Cancel, + Action(Action), + SubScene(S), +} diff --git a/crates/features/junowen-menu/src/common_menu.rs b/crates/features/junowen-menu/src/common_menu.rs new file mode 100644 index 0000000..9ea9a6d --- /dev/null +++ b/crates/features/junowen-menu/src/common_menu.rs @@ -0,0 +1,140 @@ +use getset::{Getters, MutGetters}; + +use crate::{ + action::{Action, OnMenuInputResult}, + device::{MenuDevice, MenuSound}, + input::{ + MenuButtons, MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, + OnTextInputResult, + }, + menu::{ + CurrentMenuSceneMutResult, CurrentMenuSceneResult, Menu, MenuItem, current_menu_scene, + current_menu_scene_mut, + }, + render::{MenuRenderer, render_title}, +}; + +#[derive(Getters, MutGetters)] +pub struct CommonMenu { + #[getset(get = "pub", get_mut = "pub")] + menu: Menu, + instant_exit: bool, + base_height: u32, + #[getset(get_mut = "pub")] + controller: MenuController, +} + +impl CommonMenu { + pub fn new(instant_exit: bool, base_height: u32, menu: Menu) -> Self { + Self { + menu, + instant_exit, + base_height, + controller: MenuController::default(), + } + } + + pub fn root_title(&self) -> &'static str { + self.menu.title() + } + + fn apply_decide_count(&mut self) -> Option> { + match self.controller.update_decide() { + MenuControllerUpdateDecideResult::None => None, + MenuControllerUpdateDecideResult::Wait => Some(OnMenuInputResult::None), + MenuControllerUpdateDecideResult::Decide => { + if let Some(scene) = self.menu.dig() { + return Some(OnMenuInputResult::SubScene(scene)); + } + None + } + MenuControllerUpdateDecideResult::Cancel => { + if !self.menu.bury() { + return Some(OnMenuInputResult::Cancel); + } + None + } + } + } + + pub fn on_input_menu( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + device: &impl MenuDevice, + ) -> OnMenuInputResult { + if let Some(result) = self.apply_decide_count() { + return result; + } + + match current_menu_scene(&self.menu) { + CurrentMenuSceneResult::SubScene(scene) => OnMenuInputResult::SubScene(scene), + CurrentMenuSceneResult::Menu(menu) => { + let ignore_decide = menu.items().is_empty(); + let instant_decide = + ignore_decide || matches!(menu.selected_item(), MenuItem::Plain(_)); + let play_decide_sound = !ignore_decide + && menu + .selected_item() + .decided_action() + .map(|x| x.play_sound()) + .unwrap_or(true); + let root_cancel = !self.menu.decided() && self.instant_exit; + + let input_result = + self.controller + .input(current_input, prev_input, instant_decide, root_cancel); + + if root_cancel && let MenuControllerInputResult::Cancel = input_result { + return OnMenuInputResult::Cancel; + } + let CurrentMenuSceneMutResult::Menu(menu) = current_menu_scene_mut(&mut self.menu) + else { + unreachable!() + }; + if let Some(action) = + menu.input(input_result, ignore_decide, play_decide_sound, device) + { + OnMenuInputResult::Action(action) + } else { + OnMenuInputResult::None + } + } + CurrentMenuSceneResult::TextInput(_, _) => { + let CurrentMenuSceneMutResult::TextInput(text_input) = + current_menu_scene_mut(&mut self.menu) + else { + unreachable!() + }; + match text_input.on_input_menu(device) { + OnTextInputResult::None => OnMenuInputResult::None, + OnTextInputResult::Cancel => { + device.play_sound(MenuSound::Cancel); + self.controller.force_cancel(); + OnMenuInputResult::None + } + OnTextInputResult::Decide(changed_action, new_room_name) => { + device.play_sound(MenuSound::Decide); + self.controller.force_cancel(); + let action = Action::new(changed_action, false, Some(new_room_name)); + OnMenuInputResult::Action(action) + } + } + } + } + } + + pub fn on_render_texts(&self, renderer: &impl MenuRenderer) { + match current_menu_scene(&self.menu) { + CurrentMenuSceneResult::SubScene { .. } => unreachable!(), + CurrentMenuSceneResult::Menu(menu) => { + render_title(renderer, menu.title().as_bytes()); + menu.on_render_texts(self.base_height, renderer); + } + CurrentMenuSceneResult::TextInput(label, text_input) => { + render_title(renderer, label.as_bytes()); + text_input.on_render_texts(renderer); + } + } + } +} diff --git a/crates/features/junowen-menu/src/device.rs b/crates/features/junowen-menu/src/device.rs new file mode 100644 index 0000000..b9d879c --- /dev/null +++ b/crates/features/junowen-menu/src/device.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Copy, Debug)] +pub enum MenuSound { + Cancel, + Decide, + Select, +} + +/// メニュー操作に使うゲームの機能 +pub trait MenuDevice { + fn play_sound(&self, sound: MenuSound); + /// 仮想キーコードごとの押下状態。最上位ビットが立っていれば押されている + fn keyboard_raw_keys(&self) -> &[u8; 256]; +} diff --git a/crates/features/junowen-menu/src/input/menu_buttons.rs b/crates/features/junowen-menu/src/input/menu_buttons.rs new file mode 100644 index 0000000..0e89251 --- /dev/null +++ b/crates/features/junowen-menu/src/input/menu_buttons.rs @@ -0,0 +1,41 @@ +/// メニュー操作に使うボタンの押下状態 +/// +/// 決定とキャンセルは、対応するボタンのいずれかが新たに押された時点で反応する。 +/// そのため、ボタンごとに 1 ビットを使う +#[derive(Clone, Copy, Debug, Default)] +pub struct MenuButtons { + pub up: bool, + pub down: bool, + pub decide: u8, + pub cancel: u8, +} + +impl MenuButtons { + pub fn decide_pulsed(self, prev: Self) -> bool { + self.decide & !prev.decide != 0 + } + + pub fn cancel_pulsed(self, prev: Self) -> bool { + self.cancel & !prev.cancel != 0 + } +} + +#[cfg(test)] +mod tests { + use super::MenuButtons; + + fn cancel(bits: u8) -> MenuButtons { + MenuButtons { + cancel: bits, + ..Default::default() + } + } + + #[test] + fn test_cancel_pulsed() { + assert!(cancel(0b01).cancel_pulsed(cancel(0b00))); + assert!(!cancel(0b01).cancel_pulsed(cancel(0b01))); + assert!(cancel(0b11).cancel_pulsed(cancel(0b01))); + assert!(!cancel(0b01).cancel_pulsed(cancel(0b11))); + } +} diff --git a/crates/app/junowen/src/lobby/common_menu/menu_controller.rs b/crates/features/junowen-menu/src/input/menu_controller.rs similarity index 68% rename from crates/app/junowen/src/lobby/common_menu/menu_controller.rs rename to crates/features/junowen-menu/src/input/menu_controller.rs index f85e742..41525a9 100644 --- a/crates/app/junowen/src/lobby/common_menu/menu_controller.rs +++ b/crates/features/junowen-menu/src/input/menu_controller.rs @@ -1,8 +1,4 @@ -use junowen_lib::structs::input_devices::{InputFlags, InputValue}; - -fn pulse(current: InputValue, prev: InputValue, flag: InputFlags) -> bool { - current.0 & flag != None && prev.0 & flag == None -} +use super::MenuButtons; pub enum MenuControllerUpdateDecideResult { None, @@ -47,11 +43,13 @@ impl MenuController { MenuControllerUpdateDecideResult::Wait } - fn cancel(&mut self, current_input: InputValue, prev_input: InputValue, instant: bool) -> bool { - if !pulse(current_input, prev_input, InputFlags::CHARGE) - && !pulse(current_input, prev_input, InputFlags::BOMB) - && !pulse(current_input, prev_input, InputFlags::PAUSE) - { + fn cancel( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + instant: bool, + ) -> bool { + if !current_input.cancel_pulsed(prev_input) { return false; } if !instant { @@ -64,10 +62,13 @@ impl MenuController { self.decide_count = -1; } - fn decide(&mut self, current_input: InputValue, prev_input: InputValue, instant: bool) -> bool { - if !pulse(current_input, prev_input, InputFlags::SHOT) - && !pulse(current_input, prev_input, InputFlags::ENTER) - { + fn decide( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + instant: bool, + ) -> bool { + if !current_input.decide_pulsed(prev_input) { return false; } if !instant { @@ -76,11 +77,9 @@ impl MenuController { true } - fn select(&mut self, current_input: InputValue, prev_input: InputValue) -> Option { + fn select(&mut self, current_input: MenuButtons, prev_input: MenuButtons) -> Option { let mut mv = None; - if current_input.0 & InputFlags::UP != None - && (prev_input.0 & InputFlags::UP == None || self.repeat_up > 0) - { + if current_input.up && (!prev_input.up || self.repeat_up > 0) { if [0, 25].contains(&self.repeat_up) { mv = Some(true); } @@ -91,9 +90,7 @@ impl MenuController { } else { self.repeat_up = 0; } - if current_input.0 & InputFlags::DOWN != None - && (prev_input.0 & InputFlags::DOWN == None || self.repeat_down > 0) - { + if current_input.down && (!prev_input.down || self.repeat_down > 0) { if [0, 25].contains(&self.repeat_down) { mv = Some(false); } @@ -109,8 +106,8 @@ impl MenuController { pub fn input( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, instant_decide: bool, instant_cancel: bool, ) -> MenuControllerInputResult { diff --git a/crates/features/junowen-menu/src/input/mod.rs b/crates/features/junowen-menu/src/input/mod.rs new file mode 100644 index 0000000..8745e56 --- /dev/null +++ b/crates/features/junowen-menu/src/input/mod.rs @@ -0,0 +1,9 @@ +mod menu_buttons; +mod menu_controller; +mod text_input; + +pub use menu_buttons::MenuButtons; +pub use menu_controller::{ + MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, +}; +pub use text_input::{OnTextInputResult, TextInput}; diff --git a/crates/app/junowen/src/lobby/common_menu/text_input.rs b/crates/features/junowen-menu/src/input/text_input.rs similarity index 72% rename from crates/app/junowen/src/lobby/common_menu/text_input.rs rename to crates/features/junowen-menu/src/input/text_input.rs index fa0f89f..c1a352c 100644 --- a/crates/app/junowen/src/lobby/common_menu/text_input.rs +++ b/crates/features/junowen-menu/src/input/text_input.rs @@ -1,10 +1,12 @@ -use std::{ffi::c_void, num::NonZeroU8}; +use std::num::NonZeroU8; use getset::Setters; -use junowen_lib::Th19; use windows::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode}; -use crate::lobby::helper::render_label_value; +use crate::{ + device::MenuDevice, + render::{MenuRenderer, render_label_value}, +}; fn to_ascii(vk: u32, current: &[u8; 256]) -> Option { let mut buf = [0u16; 2]; @@ -50,10 +52,10 @@ impl TextInputState { } } if current[self.current_vk as usize] & 0x80 != 0 { - if self.current_vk_count > 30 { - if let Some(ascii) = to_ascii(self.current_vk as u32, current) { - result.push(ascii.get()); - } + if self.current_vk_count > 30 + && let Some(ascii) = to_ascii(self.current_vk as u32, current) + { + result.push(ascii.get()); } self.current_vk_count += 1; } else { @@ -64,7 +66,7 @@ impl TextInputState { } } -pub enum OnMenuInputResult { +pub enum OnTextInputResult { None, Cancel, Decide(u8, String), @@ -97,33 +99,28 @@ impl TextInput { self.state.as_mut().unwrap() } - pub fn on_input_menu(&mut self, th19: &Th19) -> OnMenuInputResult { + pub fn on_input_menu(&mut self, device: &impl MenuDevice) -> OnTextInputResult { if self.state.is_none() { - self.state = Some(TextInputState::new( - th19.input_devices().keyboard_input().raw_keys(), - )); - return OnMenuInputResult::None; + self.state = Some(TextInputState::new(device.keyboard_raw_keys())); + return OnTextInputResult::None; } - for ascii in self - .state_mut() - .tick(th19.input_devices().keyboard_input().raw_keys()) - { + for ascii in self.state_mut().tick(device.keyboard_raw_keys()) { if (0x20..0x7f).contains(&ascii) { self.value.push(ascii as char); } else if ascii == 0x08 { self.value.pop(); } else if ascii == 0x0d { // CR - return OnMenuInputResult::Decide(self.changed_action, self.value.clone()); + return OnTextInputResult::Decide(self.changed_action, self.value.clone()); } else if ascii == 0x1b { // ESC - return OnMenuInputResult::Cancel; + return OnTextInputResult::Cancel; } } - OnMenuInputResult::None + OnTextInputResult::None } - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - render_label_value(th19, text_renderer, 480, 0, self.name, &self.value); + pub fn on_render_texts(&self, renderer: &impl MenuRenderer) { + render_label_value(renderer, 480, 0, self.name, &self.value); } } diff --git a/crates/features/junowen-menu/src/lib.rs b/crates/features/junowen-menu/src/lib.rs new file mode 100644 index 0000000..d73cbcb --- /dev/null +++ b/crates/features/junowen-menu/src/lib.rs @@ -0,0 +1,19 @@ +//! ロビー画面のメニュー部品 +//! +//! 描画・効果音・キーボード入力はゲームの機能を使うため、トレイトを介して利用側から受け取る + +mod action; +mod common_menu; +mod device; +mod input; +mod menu; +mod render; + +pub use action::{Action, OnMenuInputResult}; +pub use common_menu::CommonMenu; +pub use device::{MenuDevice, MenuSound}; +pub use input::{MenuButtons, MenuController, TextInput}; +pub use menu::{ + Menu, MenuItem, MenuPlainItem, MenuSubMenuItem, MenuSubSceneItem, MenuTextInputItem, +}; +pub use render::{MenuRenderer, MenuText, menu_item_color, render_label_value}; diff --git a/crates/features/junowen-menu/src/menu/current_scene.rs b/crates/features/junowen-menu/src/menu/current_scene.rs new file mode 100644 index 0000000..3a6ff21 --- /dev/null +++ b/crates/features/junowen-menu/src/menu/current_scene.rs @@ -0,0 +1,67 @@ +use crate::input::TextInput; + +use super::{Menu, MenuItem}; + +#[derive(Debug)] +pub enum CurrentMenuSceneResult<'a, S> { + Menu(&'a Menu), + SubScene(S), + TextInput(&'static str, &'a TextInput), +} + +pub enum CurrentMenuSceneMutResult<'a, S> { + Menu(&'a mut Menu), + SubScene, + TextInput(&'a mut TextInput), +} + +/// 決定済みのサブメニューをたどり、操作対象の画面を返す +pub fn current_menu_scene(root: &Menu) -> CurrentMenuSceneResult<'_, S> { + if !root.decided() { + return CurrentMenuSceneResult::Menu(root); + } + let mut menu = root; + loop { + match menu.selected_item() { + MenuItem::Plain(_) => unreachable!(), + MenuItem::SubMenu(item) => { + if item.sub_menu().decided() { + menu = item.sub_menu(); + continue; + } + return CurrentMenuSceneResult::Menu(item.sub_menu()); + } + MenuItem::SubScene(item) => { + return CurrentMenuSceneResult::SubScene(item.sub_scene()); + } + MenuItem::TextInput(item) => { + return CurrentMenuSceneResult::TextInput(item.label(), item.text_input()); + } + } + } +} + +pub fn current_menu_scene_mut(root: &mut Menu) -> CurrentMenuSceneMutResult<'_, S> { + if !root.decided() { + return CurrentMenuSceneMutResult::Menu(root); + } + let mut menu = root; + loop { + match menu.selected_item_mut() { + MenuItem::Plain(_) => unreachable!(), + MenuItem::SubMenu(item) => { + if item.sub_menu().decided() { + menu = item.sub_menu_mut(); + continue; + } + return CurrentMenuSceneMutResult::Menu(item.sub_menu_mut()); + } + MenuItem::SubScene(_item) => { + return CurrentMenuSceneMutResult::SubScene; + } + MenuItem::TextInput(item) => { + return CurrentMenuSceneMutResult::TextInput(item.text_input_mut()); + } + } + } +} diff --git a/crates/app/junowen/src/lobby/common_menu/menu_item.rs b/crates/features/junowen-menu/src/menu/menu_item.rs similarity index 88% rename from crates/app/junowen/src/lobby/common_menu/menu_item.rs rename to crates/features/junowen-menu/src/menu/menu_item.rs index 913729c..be5c282 100644 --- a/crates/app/junowen/src/lobby/common_menu/menu_item.rs +++ b/crates/features/junowen-menu/src/menu/menu_item.rs @@ -1,6 +1,8 @@ use getset::{CopyGetters, Getters, MutGetters, Setters}; -use super::{Action, LobbyScene, menu::Menu, text_input::TextInput}; +use crate::{action::Action, input::TextInput}; + +use super::Menu; #[derive(Debug, Setters)] pub struct MenuPlainItem { @@ -12,13 +14,13 @@ pub struct MenuPlainItem { } #[derive(Debug, Getters, MutGetters, Setters)] -pub struct MenuSubMenuItem { +pub struct MenuSubMenuItem { #[set = "pub"] label: &'static str, enabled: bool, decided_action: Option, #[getset(get = "pub", get_mut = "pub")] - sub_menu: Menu, + sub_menu: Menu, } #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] @@ -32,23 +34,28 @@ pub struct MenuTextInputItem { } #[derive(CopyGetters, Debug, Setters)] -pub struct MenuSubSceneItem { +pub struct MenuSubSceneItem { #[getset(get_copy = "pub", set = "pub")] label: &'static str, enabled: bool, - #[get_copy = "pub"] - sub_scene: LobbyScene, + sub_scene: S, +} + +impl MenuSubSceneItem { + pub fn sub_scene(&self) -> S { + self.sub_scene + } } #[derive(Debug)] -pub enum MenuItem { +pub enum MenuItem { Plain(MenuPlainItem), - SubMenu(MenuSubMenuItem), + SubMenu(MenuSubMenuItem), TextInput(MenuTextInputItem), - SubScene(MenuSubSceneItem), + SubScene(MenuSubSceneItem), } -impl MenuItem { +impl MenuItem { pub fn plain(label: &'static str, decided_action: u8, play_sound: bool) -> Self { Self::Plain(MenuPlainItem { label, @@ -58,7 +65,7 @@ impl MenuItem { }) } - pub fn sub_menu(label: &'static str, decided_action: Option, sub_menu: Menu) -> Self { + pub fn sub_menu(label: &'static str, decided_action: Option, sub_menu: Menu) -> Self { Self::SubMenu(MenuSubMenuItem { label, enabled: true, @@ -67,7 +74,7 @@ impl MenuItem { }) } - pub fn sub_scene(label: &'static str, sub_scene: LobbyScene) -> Self { + pub fn sub_scene(label: &'static str, sub_scene: S) -> Self { Self::SubScene(MenuSubSceneItem { label, enabled: true, diff --git a/crates/app/junowen/src/lobby/common_menu/menu.rs b/crates/features/junowen-menu/src/menu/mod.rs similarity index 74% rename from crates/app/junowen/src/lobby/common_menu/menu.rs rename to crates/features/junowen-menu/src/menu/mod.rs index dc0d219..8281608 100644 --- a/crates/app/junowen/src/lobby/common_menu/menu.rs +++ b/crates/features/junowen-menu/src/menu/mod.rs @@ -1,30 +1,40 @@ -use std::ffi::c_void; +mod current_scene; +mod menu_item; use getset::{CopyGetters, Getters, MutGetters, Setters}; -use junowen_lib::Th19; -use crate::lobby::helper::render_menu_item; +use crate::{ + action::Action, + device::{MenuDevice, MenuSound}, + input::MenuControllerInputResult, + render::{MenuRenderer, render_menu_item}, +}; -use super::{Action, LobbyScene, menu_controller::MenuControllerInputResult, menu_item::MenuItem}; +pub use current_scene::{ + CurrentMenuSceneMutResult, CurrentMenuSceneResult, current_menu_scene, current_menu_scene_mut, +}; +pub use menu_item::{ + MenuItem, MenuPlainItem, MenuSubMenuItem, MenuSubSceneItem, MenuTextInputItem, +}; #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] -pub struct Menu { +pub struct Menu { #[get_copy = "pub"] title: &'static str, canceled_action: Option, #[getset(get = "pub", get_mut = "pub")] - items: Vec, + items: Vec>, #[get_copy = "pub"] cursor: usize, #[get_copy = "pub"] decided: bool, } -impl Menu { +impl Menu { pub fn new( title: &'static str, canceled_action: Option, - items: Vec, + items: Vec>, cursor: usize, ) -> Self { Self { @@ -36,15 +46,15 @@ impl Menu { } } - pub fn selected_item(&self) -> &MenuItem { + pub fn selected_item(&self) -> &MenuItem { &self.items[self.cursor] } - pub fn selected_item_mut(&mut self) -> &mut MenuItem { + pub fn selected_item_mut(&mut self) -> &mut MenuItem { &mut self.items[self.cursor] } - pub fn dig(&mut self) -> Option { + pub fn dig(&mut self) -> Option { if !self.decided { if let MenuItem::SubScene(scene) = self.selected_item() { return Some(scene.sub_scene()); @@ -73,18 +83,11 @@ impl Menu { sub_menu.sub_menu_mut().bury() } - pub fn on_render_texts(&self, base_height: u32, th19: &Th19, text_renderer: &c_void) { + pub fn on_render_texts(&self, base_height: u32, renderer: &impl MenuRenderer) { for (i, item) in self.items().iter().enumerate() { let label = item.label().as_bytes(); let height = base_height + 56 * i as u32; - render_menu_item( - th19, - text_renderer, - label, - height, - item.enabled(), - i == self.cursor(), - ); + render_menu_item(renderer, label, height, item.enabled(), i == self.cursor()); } } @@ -114,12 +117,12 @@ impl Menu { input_result: MenuControllerInputResult, ignore_decide: bool, play_decide_sound: bool, - th19: &Th19, + device: &impl MenuDevice, ) -> Option { match input_result { MenuControllerInputResult::None => None, MenuControllerInputResult::Cancel => { - th19.play_sound(th19.sound_manager(), 0x09, 0); + device.play_sound(MenuSound::Cancel); self.canceled_action.map(|id| Action::new(id, false, None)) } MenuControllerInputResult::Decide => { @@ -127,7 +130,7 @@ impl Menu { return None; } if play_decide_sound { - th19.play_sound(th19.sound_manager(), 0x07, 0); + device.play_sound(MenuSound::Decide); } self.selected_item().decided_action() } @@ -135,14 +138,14 @@ impl Menu { if !self.decrement_cursor() { return None; } - th19.play_sound(th19.sound_manager(), 0x0a, 0); + device.play_sound(MenuSound::Select); None } MenuControllerInputResult::Down => { if !self.increment_cursor() { return None; } - th19.play_sound(th19.sound_manager(), 0x0a, 0); + device.play_sound(MenuSound::Select); None } } diff --git a/crates/features/junowen-menu/src/render.rs b/crates/features/junowen-menu/src/render.rs new file mode 100644 index 0000000..ec458d7 --- /dev/null +++ b/crates/features/junowen-menu/src/render.rs @@ -0,0 +1,123 @@ +/// ゲーム内フォントで描画するテキスト。座標はゲーム内と同じ 1280x960 換算 +#[derive(Clone, Copy, Debug)] +pub struct MenuText<'a> { + pub text: &'a [u8], + pub x: u32, + pub y: u32, + /// 0xaarrggbb + pub color: u32, + pub font_type: u32, + pub drop_shadow: bool, + /// 0: center, 1: left, 2: right + pub horizontal_align: u32, + /// 0: center, 1: top, 2: bottom + pub vertical_align: u32, +} + +impl Default for MenuText<'_> { + fn default() -> Self { + Self { + text: b"", + x: 0, + y: 0, + color: 0xffffffff, + font_type: 0, + drop_shadow: false, + horizontal_align: 1, + vertical_align: 1, + } + } +} + +pub trait MenuRenderer { + fn render_text(&self, text: &MenuText); +} + +pub fn render_title(renderer: &impl MenuRenderer, text: &[u8]) { + let mut mt = MenuText { + text, + x: 640, + y: 64, + color: 0xff000000, + font_type: 9, + drop_shadow: true, + horizontal_align: 0, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.color = 0xffffffff; + mt.font_type = 7; + renderer.render_text(&mt); +} + +pub fn render_menu_item( + renderer: &impl MenuRenderer, + text: &[u8], + y: u32, + enabled: bool, + selected: bool, +) { + let mut mt = MenuText { + text, + x: 640, + y, + color: menu_item_color(9, enabled, selected), + font_type: 9, + horizontal_align: 0, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.color = menu_item_color(7, enabled, selected); + mt.font_type = 7; + renderer.render_text(&mt); +} + +pub fn menu_item_color(font_type: u32, enabled: bool, selected: bool) -> u32 { + if !enabled { + match font_type { + 9 => 0x40ffffff, + 7 => 0xff808080, + _ => unreachable!(), + } + } else if selected { + match font_type { + 9 => 0xff000000, + 7 => 0xffffff80, + _ => unreachable!(), + } + } else { + match font_type { + 9 => 0xff404040, + 7 => 0xff808060, + _ => unreachable!(), + } + } +} + +pub fn render_label_value( + renderer: &impl MenuRenderer, + height: u32, + vertical_align: u32, + label: &str, + value: &str, +) { + let label = format!("{:<11}:", label); + let mut mt = MenuText { + text: label.as_bytes(), + x: 320, + y: height, + color: 0xffffffff, + font_type: 0, + horizontal_align: 1, + vertical_align, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.text = value.as_bytes(); + mt.color = 0xffffffa0; + mt.x = 544; + renderer.render_text(&mt); +} From 0643b885e0bc15cf9e5f6c9dda8c77f6beb0ea7c Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 18:12:45 +0900 Subject: [PATCH 25/29] =?UTF-8?q?refactor(junowen-lobby):=20junowen-menu?= =?UTF-8?q?=20=E3=82=92=20junowen-lobby=20=E3=81=AB=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=83=AD=E3=83=93=E3=83=BC=E3=81=AE=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=81=A8=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9=E3=82=92=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/app/junowen/Cargo.toml | 2 +- crates/app/junowen/src/lobby/common_menu.rs | 23 ++++--------------- crates/app/junowen/src/lobby/overlay.rs | 11 ++------- .../app/junowen/src/lobby/pure_p2p_offerer.rs | 8 +++---- .../junowen/src/lobby/room/tcp_signaling.rs | 11 ++++----- .../Cargo.toml | 2 +- crates/features/junowen-lobby/src/lib.rs | 15 ++++++++++++ .../src => junowen-lobby/src/menu}/action.rs | 0 .../src/menu}/common_menu.rs | 6 ++--- .../src => junowen-lobby/src/menu}/device.rs | 0 .../src/menu}/input/menu_buttons.rs | 0 .../src/menu}/input/menu_controller.rs | 0 .../src/menu}/input/mod.rs | 0 .../src/menu}/input/text_input.rs | 2 +- crates/features/junowen-lobby/src/menu/mod.rs | 15 ++++++++++++ .../src => junowen-lobby/src/menu}/render.rs | 0 .../src/menu/tree}/current_scene.rs | 2 +- .../src/menu/tree}/menu_item.rs | 2 +- .../src/menu/tree}/mod.rs | 6 ++--- .../junowen-lobby/src/overlay_text.rs | 8 +++++++ .../src/pure_p2p_offerer_menu.rs} | 2 +- crates/features/junowen-lobby/src/scene.rs | 17 ++++++++++++++ .../junowen-lobby/src/tcp_signaling_menu.rs} | 8 ++++++- crates/features/junowen-menu/src/lib.rs | 19 --------------- 26 files changed, 91 insertions(+), 76 deletions(-) rename crates/features/{junowen-menu => junowen-lobby}/Cargo.toml (91%) create mode 100644 crates/features/junowen-lobby/src/lib.rs rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/action.rs (100%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/common_menu.rs (99%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/device.rs (100%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/input/menu_buttons.rs (100%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/input/menu_controller.rs (100%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/input/mod.rs (100%) rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/input/text_input.rs (99%) create mode 100644 crates/features/junowen-lobby/src/menu/mod.rs rename crates/features/{junowen-menu/src => junowen-lobby/src/menu}/render.rs (100%) rename crates/features/{junowen-menu/src/menu => junowen-lobby/src/menu/tree}/current_scene.rs (98%) rename crates/features/{junowen-menu/src/menu => junowen-lobby/src/menu/tree}/menu_item.rs (98%) rename crates/features/{junowen-menu/src/menu => junowen-lobby/src/menu/tree}/mod.rs (97%) create mode 100644 crates/features/junowen-lobby/src/overlay_text.rs rename crates/{app/junowen/src/lobby/pure_p2p_offerer/menu.rs => features/junowen-lobby/src/pure_p2p_offerer_menu.rs} (92%) create mode 100644 crates/features/junowen-lobby/src/scene.rs rename crates/{app/junowen/src/lobby/room/tcp_signaling/menu.rs => features/junowen-lobby/src/tcp_signaling_menu.rs} (96%) delete mode 100644 crates/features/junowen-menu/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 15923de..cde6885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,7 +2023,7 @@ dependencies = [ "getset", "junowen-direct3d", "junowen-lib", - "junowen-menu", + "junowen-lobby", "reqwest", "rmp-serde", "rust-i18n", @@ -2085,7 +2085,7 @@ dependencies = [ ] [[package]] -name = "junowen-menu" +name = "junowen-lobby" version = "1.2.0" dependencies = [ "derive-new", diff --git a/Cargo.toml b/Cargo.toml index 1423eed..4f8bf48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ "crates/app/th19loader", "crates/features/junowen-direct3d", "crates/features/junowen-lib", - "crates/features/junowen-menu", + "crates/features/junowen-lobby", ] default-members = ["crates/app/junowen"] resolver = "2" @@ -32,7 +32,7 @@ derive-new = "0.6.0" getset = "0.1.2" junowen-direct3d = { path = "./crates/features/junowen-direct3d" } junowen-lib = { path = "./crates/features/junowen-lib" } -junowen-menu = { path = "./crates/features/junowen-menu" } +junowen-lobby = { path = "./crates/features/junowen-lobby" } rmp-serde = "1.3.0" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.135" diff --git a/crates/app/junowen/Cargo.toml b/crates/app/junowen/Cargo.toml index 717e311..affff3f 100644 --- a/crates/app/junowen/Cargo.toml +++ b/crates/app/junowen/Cargo.toml @@ -24,7 +24,7 @@ derive-new.workspace = true getset.workspace = true junowen-direct3d.workspace = true junowen-lib.workspace = true -junowen-menu.workspace = true +junowen-lobby.workspace = true reqwest = { version = "0.12.7", features = ["json"] } rmp-serde.workspace = true rust-i18n = "3.1.2" diff --git a/crates/app/junowen/src/lobby/common_menu.rs b/crates/app/junowen/src/lobby/common_menu.rs index 9c5e093..cef8e3d 100644 --- a/crates/app/junowen/src/lobby/common_menu.rs +++ b/crates/app/junowen/src/lobby/common_menu.rs @@ -7,25 +7,12 @@ use junowen_lib::{ others::RenderingText, }, }; -use junowen_menu::{MenuDevice, MenuRenderer, MenuSound, MenuText}; +use junowen_lobby::{MenuDevice, MenuRenderer, MenuSound, MenuText}; -pub use junowen_menu::{MenuButtons, menu_item_color, render_label_value}; - -#[derive(Clone, Copy, Debug)] -pub enum LobbyScene { - Root, - SharedRoom, - ReservedRoom, - TcpSignaling, - PureP2pHost, - PureP2pGuest, - PureP2pSpectator, -} - -pub type CommonMenu = junowen_menu::CommonMenu; -pub type Menu = junowen_menu::Menu; -pub type MenuItem = junowen_menu::MenuItem; -pub type OnMenuInputResult = junowen_menu::OnMenuInputResult; +pub use junowen_lobby::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, menu_item_color, + render_label_value, +}; pub fn to_menu_buttons(input: InputValue) -> MenuButtons { let bits = |flags: &[InputFlags]| { diff --git a/crates/app/junowen/src/lobby/overlay.rs b/crates/app/junowen/src/lobby/overlay.rs index 5159a41..9f605bc 100644 --- a/crates/app/junowen/src/lobby/overlay.rs +++ b/crates/app/junowen/src/lobby/overlay.rs @@ -16,6 +16,8 @@ use windows_core::{HSTRING, w}; use super::Lobby; +pub use junowen_lobby::OverlayText; + /// 縁取りの太さと文字サイズの比 const OUTLINE_RATIO: f64 = 1.0 / 29.0; @@ -104,15 +106,6 @@ fn render_text_to_screen( unsafe { DrawTextW(hdc, &mut text, &mut rect, DT_LEFT) }; } -/// ゲーム内フォントでは英数字しか描画できないため、多言語のテキストは -/// GDI で画面に重ねて描画する。座標はゲーム内と同じ 1280x960 換算。 -pub struct OverlayText { - pub left: i32, - pub top: i32, - pub font_size: u32, - pub string: String, -} - pub struct Font { obj: HGDIOBJ, size: u32, diff --git a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs index 15fa7ee..adb98c3 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs @@ -1,4 +1,3 @@ -mod menu; mod role; use std::ffi::c_void; @@ -11,6 +10,9 @@ use junowen_lib::{ signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, }; +use junowen_lobby::pure_p2p_offerer_menu::{ + COPY_ACTION_ID, PASTE_ACTION_ID, REGENERATE_ACTION_ID, make_empty_menu, make_menu, +}; use rust_i18n::t; use tokio::sync::mpsc; use tracing::trace; @@ -26,10 +28,6 @@ use super::{ text_layout::TextLayout, }; -use self::menu::{ - COPY_ACTION_ID, PASTE_ACTION_ID, REGENERATE_ACTION_ID, make_empty_menu, make_menu, -}; - pub use self::role::{Messages, pure_p2p_host, pure_p2p_spectator}; pub struct PureP2pOfferer { diff --git a/crates/app/junowen/src/lobby/room/tcp_signaling.rs b/crates/app/junowen/src/lobby/room/tcp_signaling.rs index 24df49a..549c48b 100644 --- a/crates/app/junowen/src/lobby/room/tcp_signaling.rs +++ b/crates/app/junowen/src/lobby/room/tcp_signaling.rs @@ -1,8 +1,10 @@ -mod menu; - use std::ffi::c_void; use junowen_lib::Th19; +use junowen_lobby::tcp_signaling_menu::{ + ADDRESS_INPUT_ACTION_ID, CHANGE_ADDRESS_ACTION_ID, GUEST_ACTION_ID, HOST_ACTION_ID, + OFFLINE_ACTION_ID, OFFLINE_ITEM_INDEX, TcpSignalingMenu, +}; use rust_i18n::t; use crate::{ @@ -10,11 +12,6 @@ use crate::{ signaling::waiting_for_match::WaitingForOpponentOverTcpSignaling, }; -use self::menu::{ - ADDRESS_INPUT_ACTION_ID, CHANGE_ADDRESS_ACTION_ID, GUEST_ACTION_ID, HOST_ACTION_ID, - OFFLINE_ACTION_ID, OFFLINE_ITEM_INDEX, TcpSignalingMenu, -}; - use super::{ super::common_menu::{LobbyScene, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice}, on_render_texts, diff --git a/crates/features/junowen-menu/Cargo.toml b/crates/features/junowen-lobby/Cargo.toml similarity index 91% rename from crates/features/junowen-menu/Cargo.toml rename to crates/features/junowen-lobby/Cargo.toml index 1613883..11b551d 100644 --- a/crates/features/junowen-menu/Cargo.toml +++ b/crates/features/junowen-lobby/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "junowen-menu" +name = "junowen-lobby" edition = "2024" version.workspace = true authors.workspace = true diff --git a/crates/features/junowen-lobby/src/lib.rs b/crates/features/junowen-lobby/src/lib.rs new file mode 100644 index 0000000..f33a0b2 --- /dev/null +++ b/crates/features/junowen-lobby/src/lib.rs @@ -0,0 +1,15 @@ +//! ロビー画面 +//! +//! 描画・効果音・キーボード入力はゲームの機能を使うため、トレイトを介して利用側から受け取る + +mod menu; +mod overlay_text; +pub mod pure_p2p_offerer_menu; +mod scene; +pub mod tcp_signaling_menu; + +pub use menu::{ + MenuButtons, MenuDevice, MenuRenderer, MenuSound, MenuText, menu_item_color, render_label_value, +}; +pub use overlay_text::OverlayText; +pub use scene::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}; diff --git a/crates/features/junowen-menu/src/action.rs b/crates/features/junowen-lobby/src/menu/action.rs similarity index 100% rename from crates/features/junowen-menu/src/action.rs rename to crates/features/junowen-lobby/src/menu/action.rs diff --git a/crates/features/junowen-menu/src/common_menu.rs b/crates/features/junowen-lobby/src/menu/common_menu.rs similarity index 99% rename from crates/features/junowen-menu/src/common_menu.rs rename to crates/features/junowen-lobby/src/menu/common_menu.rs index 9ea9a6d..979b57d 100644 --- a/crates/features/junowen-menu/src/common_menu.rs +++ b/crates/features/junowen-lobby/src/menu/common_menu.rs @@ -1,17 +1,17 @@ use getset::{Getters, MutGetters}; -use crate::{ +use crate::menu::{ action::{Action, OnMenuInputResult}, device::{MenuDevice, MenuSound}, input::{ MenuButtons, MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, OnTextInputResult, }, - menu::{ + render::{MenuRenderer, render_title}, + tree::{ CurrentMenuSceneMutResult, CurrentMenuSceneResult, Menu, MenuItem, current_menu_scene, current_menu_scene_mut, }, - render::{MenuRenderer, render_title}, }; #[derive(Getters, MutGetters)] diff --git a/crates/features/junowen-menu/src/device.rs b/crates/features/junowen-lobby/src/menu/device.rs similarity index 100% rename from crates/features/junowen-menu/src/device.rs rename to crates/features/junowen-lobby/src/menu/device.rs diff --git a/crates/features/junowen-menu/src/input/menu_buttons.rs b/crates/features/junowen-lobby/src/menu/input/menu_buttons.rs similarity index 100% rename from crates/features/junowen-menu/src/input/menu_buttons.rs rename to crates/features/junowen-lobby/src/menu/input/menu_buttons.rs diff --git a/crates/features/junowen-menu/src/input/menu_controller.rs b/crates/features/junowen-lobby/src/menu/input/menu_controller.rs similarity index 100% rename from crates/features/junowen-menu/src/input/menu_controller.rs rename to crates/features/junowen-lobby/src/menu/input/menu_controller.rs diff --git a/crates/features/junowen-menu/src/input/mod.rs b/crates/features/junowen-lobby/src/menu/input/mod.rs similarity index 100% rename from crates/features/junowen-menu/src/input/mod.rs rename to crates/features/junowen-lobby/src/menu/input/mod.rs diff --git a/crates/features/junowen-menu/src/input/text_input.rs b/crates/features/junowen-lobby/src/menu/input/text_input.rs similarity index 99% rename from crates/features/junowen-menu/src/input/text_input.rs rename to crates/features/junowen-lobby/src/menu/input/text_input.rs index c1a352c..923ccff 100644 --- a/crates/features/junowen-menu/src/input/text_input.rs +++ b/crates/features/junowen-lobby/src/menu/input/text_input.rs @@ -3,7 +3,7 @@ use std::num::NonZeroU8; use getset::Setters; use windows::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode}; -use crate::{ +use crate::menu::{ device::MenuDevice, render::{MenuRenderer, render_label_value}, }; diff --git a/crates/features/junowen-lobby/src/menu/mod.rs b/crates/features/junowen-lobby/src/menu/mod.rs new file mode 100644 index 0000000..3badc00 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/mod.rs @@ -0,0 +1,15 @@ +//! ロビー画面のメニュー部品 + +mod action; +mod common_menu; +mod device; +mod input; +mod render; +mod tree; + +pub use action::OnMenuInputResult; +pub use common_menu::CommonMenu; +pub use device::{MenuDevice, MenuSound}; +pub use input::MenuButtons; +pub use render::{MenuRenderer, MenuText, menu_item_color, render_label_value}; +pub use tree::{Menu, MenuItem}; diff --git a/crates/features/junowen-menu/src/render.rs b/crates/features/junowen-lobby/src/menu/render.rs similarity index 100% rename from crates/features/junowen-menu/src/render.rs rename to crates/features/junowen-lobby/src/menu/render.rs diff --git a/crates/features/junowen-menu/src/menu/current_scene.rs b/crates/features/junowen-lobby/src/menu/tree/current_scene.rs similarity index 98% rename from crates/features/junowen-menu/src/menu/current_scene.rs rename to crates/features/junowen-lobby/src/menu/tree/current_scene.rs index 3a6ff21..c052069 100644 --- a/crates/features/junowen-menu/src/menu/current_scene.rs +++ b/crates/features/junowen-lobby/src/menu/tree/current_scene.rs @@ -1,4 +1,4 @@ -use crate::input::TextInput; +use crate::menu::input::TextInput; use super::{Menu, MenuItem}; diff --git a/crates/features/junowen-menu/src/menu/menu_item.rs b/crates/features/junowen-lobby/src/menu/tree/menu_item.rs similarity index 98% rename from crates/features/junowen-menu/src/menu/menu_item.rs rename to crates/features/junowen-lobby/src/menu/tree/menu_item.rs index be5c282..8db4239 100644 --- a/crates/features/junowen-menu/src/menu/menu_item.rs +++ b/crates/features/junowen-lobby/src/menu/tree/menu_item.rs @@ -1,6 +1,6 @@ use getset::{CopyGetters, Getters, MutGetters, Setters}; -use crate::{action::Action, input::TextInput}; +use crate::menu::{action::Action, input::TextInput}; use super::Menu; diff --git a/crates/features/junowen-menu/src/menu/mod.rs b/crates/features/junowen-lobby/src/menu/tree/mod.rs similarity index 97% rename from crates/features/junowen-menu/src/menu/mod.rs rename to crates/features/junowen-lobby/src/menu/tree/mod.rs index 8281608..fb58ca2 100644 --- a/crates/features/junowen-menu/src/menu/mod.rs +++ b/crates/features/junowen-lobby/src/menu/tree/mod.rs @@ -3,7 +3,7 @@ mod menu_item; use getset::{CopyGetters, Getters, MutGetters, Setters}; -use crate::{ +use crate::menu::{ action::Action, device::{MenuDevice, MenuSound}, input::MenuControllerInputResult, @@ -13,9 +13,7 @@ use crate::{ pub use current_scene::{ CurrentMenuSceneMutResult, CurrentMenuSceneResult, current_menu_scene, current_menu_scene_mut, }; -pub use menu_item::{ - MenuItem, MenuPlainItem, MenuSubMenuItem, MenuSubSceneItem, MenuTextInputItem, -}; +pub use menu_item::MenuItem; #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] pub struct Menu { diff --git a/crates/features/junowen-lobby/src/overlay_text.rs b/crates/features/junowen-lobby/src/overlay_text.rs new file mode 100644 index 0000000..c61d520 --- /dev/null +++ b/crates/features/junowen-lobby/src/overlay_text.rs @@ -0,0 +1,8 @@ +/// ゲーム内フォントでは英数字しか描画できないため、多言語のテキストは +/// GDI で画面に重ねて描画する。座標はゲーム内と同じ 1280x960 換算。 +pub struct OverlayText { + pub left: i32, + pub top: i32, + pub font_size: u32, + pub string: String, +} diff --git a/crates/app/junowen/src/lobby/pure_p2p_offerer/menu.rs b/crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs similarity index 92% rename from crates/app/junowen/src/lobby/pure_p2p_offerer/menu.rs rename to crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs index 3dec54c..eabf7e5 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_offerer/menu.rs +++ b/crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs @@ -1,4 +1,4 @@ -use crate::lobby::common_menu::{CommonMenu, Menu, MenuItem}; +use crate::{CommonMenu, Menu, MenuItem}; const BASE_HEIGHT: u32 = 720; diff --git a/crates/features/junowen-lobby/src/scene.rs b/crates/features/junowen-lobby/src/scene.rs new file mode 100644 index 0000000..96b69d8 --- /dev/null +++ b/crates/features/junowen-lobby/src/scene.rs @@ -0,0 +1,17 @@ +use crate::menu; + +#[derive(Clone, Copy, Debug)] +pub enum LobbyScene { + Root, + SharedRoom, + ReservedRoom, + TcpSignaling, + PureP2pHost, + PureP2pGuest, + PureP2pSpectator, +} + +pub type CommonMenu = menu::CommonMenu; +pub type Menu = menu::Menu; +pub type MenuItem = menu::MenuItem; +pub type OnMenuInputResult = menu::OnMenuInputResult; diff --git a/crates/app/junowen/src/lobby/room/tcp_signaling/menu.rs b/crates/features/junowen-lobby/src/tcp_signaling_menu.rs similarity index 96% rename from crates/app/junowen/src/lobby/room/tcp_signaling/menu.rs rename to crates/features/junowen-lobby/src/tcp_signaling_menu.rs index 2bb1d33..3b503af 100644 --- a/crates/app/junowen/src/lobby/room/tcp_signaling/menu.rs +++ b/crates/features/junowen-lobby/src/tcp_signaling_menu.rs @@ -1,4 +1,4 @@ -use crate::lobby::common_menu::{CommonMenu, Menu, MenuItem}; +use crate::{CommonMenu, Menu, MenuItem}; const HOST_LABEL: &str = "Connect as a Host"; const GUEST_LABEL: &str = "Connect as a Guest"; @@ -47,6 +47,12 @@ pub struct TcpSignalingMenu { offline: Option, } +impl Default for TcpSignalingMenu { + fn default() -> Self { + Self::new() + } +} + impl TcpSignalingMenu { pub fn new() -> Self { Self { diff --git a/crates/features/junowen-menu/src/lib.rs b/crates/features/junowen-menu/src/lib.rs deleted file mode 100644 index d73cbcb..0000000 --- a/crates/features/junowen-menu/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! ロビー画面のメニュー部品 -//! -//! 描画・効果音・キーボード入力はゲームの機能を使うため、トレイトを介して利用側から受け取る - -mod action; -mod common_menu; -mod device; -mod input; -mod menu; -mod render; - -pub use action::{Action, OnMenuInputResult}; -pub use common_menu::CommonMenu; -pub use device::{MenuDevice, MenuSound}; -pub use input::{MenuButtons, MenuController, TextInput}; -pub use menu::{ - Menu, MenuItem, MenuPlainItem, MenuSubMenuItem, MenuSubSceneItem, MenuTextInputItem, -}; -pub use render::{MenuRenderer, MenuText, menu_item_color, render_label_value}; From 083a70c1a8399fe7f8a423cfbf09c708b62a7075 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 18:16:25 +0900 Subject: [PATCH 26/29] =?UTF-8?q?refactor(junowen-lobby):=20=E8=A1=8C?= =?UTF-8?q?=E5=8D=98=E4=BD=8D=E3=81=AE=E6=8F=8F=E7=94=BB=E3=81=A8=E3=83=86?= =?UTF-8?q?=E3=82=AD=E3=82=B9=E3=83=88=E9=85=8D=E7=BD=AE=E3=82=92=20MenuRe?= =?UTF-8?q?nderer=20=E7=B5=8C=E7=94=B1=E3=81=AB=E3=81=97=E3=81=A6=20junowe?= =?UTF-8?q?n-lobby=20=E3=81=B8=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/app/junowen/src/lobby.rs | 4 +- .../app/junowen/src/lobby/pure_p2p_guest.rs | 10 ++-- .../app/junowen/src/lobby/pure_p2p_offerer.rs | 10 ++-- crates/app/junowen/src/lobby/room.rs | 8 ++-- .../junowen-lobby/src}/helper.rs | 48 +++++++++---------- crates/features/junowen-lobby/src/lib.rs | 4 ++ .../junowen-lobby/src}/text_layout.rs | 12 ++--- 7 files changed, 44 insertions(+), 52 deletions(-) rename crates/{app/junowen/src/lobby => features/junowen-lobby/src}/helper.rs (53%) rename crates/{app/junowen/src/lobby => features/junowen-lobby/src}/text_layout.rs (80%) diff --git a/crates/app/junowen/src/lobby.rs b/crates/app/junowen/src/lobby.rs index 282cd4c..6b11756 100644 --- a/crates/app/junowen/src/lobby.rs +++ b/crates/app/junowen/src/lobby.rs @@ -1,5 +1,4 @@ mod common_menu; -mod helper; mod overlay; mod pure_p2p_guest; mod pure_p2p_offerer; @@ -7,13 +6,13 @@ mod pure_p2p_scenes; mod room; mod root; mod signaling_code; -mod text_layout; mod title_menu_modifier; use std::ffi::c_void; use getset::{Getters, MutGetters}; use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lobby::overlay_description; use crate::{ file::SettingsRepo, @@ -25,7 +24,6 @@ use crate::{ use self::{ common_menu::{LobbyScene, MenuButtons, to_menu_buttons}, - helper::overlay_description, pure_p2p_scenes::PureP2pScenes, room::{reserved::ReservedRoom, shared::SharedRoom, tcp_signaling::TcpSignaling}, root::Root, diff --git a/crates/app/junowen/src/lobby/pure_p2p_guest.rs b/crates/app/junowen/src/lobby/pure_p2p_guest.rs index 34f4641..d1b8d66 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_guest.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_guest.rs @@ -8,6 +8,7 @@ use junowen_lib::{ signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, }; +use junowen_lobby::{TextLayout, signaling_code_line_count}; use rust_i18n::t; use tokio::sync::mpsc; @@ -19,10 +20,8 @@ use super::{ CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, Th19MenuRenderer, }, - helper::signaling_code_line_count, overlay::OverlayText, signaling_code::paste_signaling_code, - text_layout::TextLayout, }; pub struct PureP2pGuest { @@ -172,11 +171,12 @@ impl PureP2pGuest { } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(&Th19MenuRenderer { + let renderer = Th19MenuRenderer { th19, text_renderer, - }); - self.layout().render_codes(th19, text_renderer); + }; + self.common_menu.on_render_texts(&renderer); + self.layout().render_codes(&renderer); } pub fn overlay_texts(&self) -> Vec { diff --git a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs index adb98c3..a0a2eef 100644 --- a/crates/app/junowen/src/lobby/pure_p2p_offerer.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs @@ -13,6 +13,7 @@ use junowen_lib::{ use junowen_lobby::pure_p2p_offerer_menu::{ COPY_ACTION_ID, PASTE_ACTION_ID, REGENERATE_ACTION_ID, make_empty_menu, make_menu, }; +use junowen_lobby::{TextLayout, signaling_code_line_count}; use rust_i18n::t; use tokio::sync::mpsc; use tracing::trace; @@ -22,10 +23,8 @@ use super::{ common_menu::{ CommonMenu, LobbyScene, MenuButtons, OnMenuInputResult, Th19MenuDevice, Th19MenuRenderer, }, - helper::signaling_code_line_count, overlay::OverlayText, signaling_code::paste_signaling_code, - text_layout::TextLayout, }; pub use self::role::{Messages, pure_p2p_host, pure_p2p_spectator}; @@ -174,11 +173,12 @@ where } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(&Th19MenuRenderer { + let renderer = Th19MenuRenderer { th19, text_renderer, - }); - self.layout().render_codes(th19, text_renderer); + }; + self.common_menu.on_render_texts(&renderer); + self.layout().render_codes(&renderer); } pub fn overlay_texts(&self) -> Vec { diff --git a/crates/app/junowen/src/lobby/room.rs b/crates/app/junowen/src/lobby/room.rs index e93cb94..17accba 100644 --- a/crates/app/junowen/src/lobby/room.rs +++ b/crates/app/junowen/src/lobby/room.rs @@ -5,13 +5,11 @@ pub mod tcp_signaling; use std::{f64::consts::PI, ffi::c_void}; use junowen_lib::{Th19, structs::others::RenderingText}; +use junowen_lobby::render_text_line; use crate::signaling::waiting_for_match::WaitingInRoom; -use super::{ - common_menu::{CommonMenu, Th19MenuRenderer, render_label_value}, - helper::render_text_line, -}; +use super::common_menu::{CommonMenu, Th19MenuRenderer, render_label_value}; fn progress_alphas(progress: f64) -> Vec { const LENGTH: f64 = 20.0; @@ -137,7 +135,7 @@ pub fn on_render_texts( render_progress(th19, text_renderer, elapsed.as_secs_f64() / 4.0); for (i, error) in waiting.errors().iter().rev().enumerate() { let error_msg = format!("Failed: {}", error); - render_text_line(th19, text_renderer, 13 + i as u32, error_msg.as_bytes()); + render_text_line(&renderer, 13 + i as u32, error_msg.as_bytes()); } } } diff --git a/crates/app/junowen/src/lobby/helper.rs b/crates/features/junowen-lobby/src/helper.rs similarity index 53% rename from crates/app/junowen/src/lobby/helper.rs rename to crates/features/junowen-lobby/src/helper.rs index fcad4de..8beb58b 100644 --- a/crates/app/junowen/src/lobby/helper.rs +++ b/crates/features/junowen-lobby/src/helper.rs @@ -1,40 +1,36 @@ -use std::ffi::c_void; - -use junowen_lib::{Th19, structs::others::RenderingText}; - -use super::overlay::OverlayText; +use crate::{MenuRenderer, MenuText, OverlayText}; const TEXT_LINE_LEFT: u32 = 32; const TEXT_LINE_TOP: u32 = 160; const TEXT_LINE_HEIGHT: u32 = 32; const SIGNALING_CODE_CHUNK_LEN: usize = 100; -pub fn render_text_line(th19: &Th19, text_renderer: &c_void, line: u32, text: &[u8]) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(TEXT_LINE_LEFT, th19.window_inner()); - rt.set_y(TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT, th19.window_inner()); - rt.color = 0xff000000; - rt.font_type = 8; - th19.render_text(text_renderer, &rt); +pub fn render_text_line(renderer: &impl MenuRenderer, line: u32, text: &[u8]) { + let mut mt = MenuText { + text, + x: TEXT_LINE_LEFT, + y: TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT, + color: 0xff000000, + font_type: 8, + ..Default::default() + }; + renderer.render_text(&mt); - rt.color = 0xffffffff; - rt.font_type = 6; - th19.render_text(text_renderer, &rt); + mt.color = 0xffffffff; + mt.font_type = 6; + renderer.render_text(&mt); } /// シグナリングコードは 1 行に収まらないので、半分の行高に詰めて折り返す -pub fn render_signaling_code(th19: &Th19, text_renderer: &c_void, line: u32, code: &str) { +pub fn render_signaling_code(renderer: &impl MenuRenderer, line: u32, code: &str) { for (i, chunk) in code.as_bytes().chunks(SIGNALING_CODE_CHUNK_LEN).enumerate() { - let mut rt = RenderingText::default(); - rt.set_text(chunk); - rt.set_x(TEXT_LINE_LEFT, th19.window_inner()); - rt.set_y( - TEXT_LINE_TOP + (line * 2 + i as u32) * (TEXT_LINE_HEIGHT / 2), - th19.window_inner(), - ); - rt.font_type = 1; - th19.render_text(text_renderer, &rt); + renderer.render_text(&MenuText { + text: chunk, + x: TEXT_LINE_LEFT, + y: TEXT_LINE_TOP + (line * 2 + i as u32) * (TEXT_LINE_HEIGHT / 2), + font_type: 1, + ..Default::default() + }); } } diff --git a/crates/features/junowen-lobby/src/lib.rs b/crates/features/junowen-lobby/src/lib.rs index f33a0b2..589169b 100644 --- a/crates/features/junowen-lobby/src/lib.rs +++ b/crates/features/junowen-lobby/src/lib.rs @@ -2,14 +2,18 @@ //! //! 描画・効果音・キーボード入力はゲームの機能を使うため、トレイトを介して利用側から受け取る +mod helper; mod menu; mod overlay_text; pub mod pure_p2p_offerer_menu; mod scene; pub mod tcp_signaling_menu; +mod text_layout; +pub use helper::{overlay_description, render_text_line, signaling_code_line_count}; pub use menu::{ MenuButtons, MenuDevice, MenuRenderer, MenuSound, MenuText, menu_item_color, render_label_value, }; pub use overlay_text::OverlayText; pub use scene::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}; +pub use text_layout::TextLayout; diff --git a/crates/app/junowen/src/lobby/text_layout.rs b/crates/features/junowen-lobby/src/text_layout.rs similarity index 80% rename from crates/app/junowen/src/lobby/text_layout.rs rename to crates/features/junowen-lobby/src/text_layout.rs index c04d108..45b86ac 100644 --- a/crates/app/junowen/src/lobby/text_layout.rs +++ b/crates/features/junowen-lobby/src/text_layout.rs @@ -1,10 +1,6 @@ -use std::ffi::c_void; - -use junowen_lib::Th19; - -use super::{ +use crate::{ + MenuRenderer, OverlayText, helper::{overlay_text_line, render_signaling_code}, - overlay::OverlayText, }; /// 画面に並ぶ要素を行番号で組み立てたもの。 @@ -25,9 +21,9 @@ impl TextLayout { self.codes.push((line, code.into())); } - pub fn render_codes(&self, th19: &Th19, text_renderer: &c_void) { + pub fn render_codes(&self, renderer: &impl MenuRenderer) { for (line, code) in &self.codes { - render_signaling_code(th19, text_renderer, *line, code); + render_signaling_code(renderer, *line, code); } } From fe833396f551f7933b5899298bee85ee22be0f74 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 18:36:51 +0900 Subject: [PATCH 27/29] =?UTF-8?q?docs(junowen-lobby):=20=E3=82=AF=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=A8=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E3=82=84=E3=81=99=E3=81=8F=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/features/junowen-lobby/src/lib.rs | 3 ++- crates/features/junowen-lobby/src/text_layout.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/features/junowen-lobby/src/lib.rs b/crates/features/junowen-lobby/src/lib.rs index 589169b..6786408 100644 --- a/crates/features/junowen-lobby/src/lib.rs +++ b/crates/features/junowen-lobby/src/lib.rs @@ -1,6 +1,7 @@ //! ロビー画面 //! -//! 描画・効果音・キーボード入力はゲームの機能を使うため、トレイトを介して利用側から受け取る +//! 描画、効果音、キーボード入力にはゲームの機能が必要になる。 +//! このクレートはそれらをトレイトとして定義し、実装は呼び出し元のクレートが渡す mod helper; mod menu; diff --git a/crates/features/junowen-lobby/src/text_layout.rs b/crates/features/junowen-lobby/src/text_layout.rs index 45b86ac..69e6450 100644 --- a/crates/features/junowen-lobby/src/text_layout.rs +++ b/crates/features/junowen-lobby/src/text_layout.rs @@ -4,8 +4,8 @@ use crate::{ }; /// 画面に並ぶ要素を行番号で組み立てたもの。 -/// テキストは多言語を表示するため overlay で、シグナリングコードは -/// 英数字だけなのでゲーム内フォントで描画する。 +/// 多言語のテキストは overlay で描画する。 +/// シグナリングコードは英数字だけなので、ゲーム内フォントで描画する。 #[derive(Default)] pub struct TextLayout { texts: Vec<(u32, String)>, From 3633b88e66bc7db000500a3a717e66d0d6a24144 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 18:41:58 +0900 Subject: [PATCH 28/29] =?UTF-8?q?docs(junowen-lobby):=20=E3=82=AF=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=AE=E8=AA=AC=E6=98=8E=E3=81=A7=E3=83=88?= =?UTF-8?q?=E3=83=AC=E3=82=A4=E3=83=88=E3=81=AE=E5=BD=B9=E5=89=B2=E3=82=92?= =?UTF-8?q?=E6=98=8E=E7=A2=BA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/features/junowen-lobby/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/features/junowen-lobby/src/lib.rs b/crates/features/junowen-lobby/src/lib.rs index 6786408..1e12df0 100644 --- a/crates/features/junowen-lobby/src/lib.rs +++ b/crates/features/junowen-lobby/src/lib.rs @@ -1,7 +1,8 @@ //! ロビー画面 //! -//! 描画、効果音、キーボード入力にはゲームの機能が必要になる。 -//! このクレートはそれらをトレイトとして定義し、実装は呼び出し元のクレートが渡す +//! 描画、効果音、キーボード入力はゲーム本体の機能で行う。 +//! このクレートはそれらの操作をトレイトで定義する。 +//! 実装は呼び出し元のクレートが用意する。 mod helper; mod menu; From 748dc124238b5e713051b93ff1eb475025cbd512 Mon Sep 17 00:00:00 2001 From: progre Date: Sun, 11 Oct 2026 11:30:00 +0900 Subject: [PATCH 29/29] =?UTF-8?q?feat(spectator):=20=E6=9C=80=E5=88=9D?= =?UTF-8?q?=E3=81=AE=E8=A6=B3=E6=88=A6=E8=80=85=E3=82=92=E8=A6=B3=E6=88=A6?= =?UTF-8?q?=E3=83=9B=E3=82=B9=E3=83=88=E3=81=AB=E3=81=97=E3=81=A6=E8=A4=87?= =?UTF-8?q?=E6=95=B0=E3=81=AE=E8=A6=B3=E6=88=A6=E8=80=85=E3=82=92=E4=B8=AD?= =?UTF-8?q?=E7=B6=99=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 対戦者は最初の観戦者のみを受け付け、その観戦者を観戦ホストに任命して 以降の観戦者の受け付けと試合の進行の中継を委譲する。 - DelegateSpectatorHost メッセージで観戦ホストを任命し、専有ルームの場合は部屋名とキーを渡す - 観戦ホストは自分が処理した試合開始・ラウンド初期化・入力を、処理した順に配下の観戦者へ中継する - 観戦ホストが離脱した場合、配下の観戦者もすべて離脱し、対戦者は Pure P2P で受け付けを再開する - SpectatorHostState を state 直下に移し、受け付け状態・試合記録・表示文言を別ファイルに分割する Co-Authored-By: Claude Sonnet 5.5 --- crates/app/junowen/src/session/spectator.rs | 84 ++++---- .../junowen/src/session/spectator/message.rs | 69 ++++++ .../src/session/spectator/relay_outbox.rs | 60 ++++++ .../app/junowen/src/session/spectator_host.rs | 13 +- .../src/signaling/waiting_for_match.rs | 25 +-- .../reserved_room_spectator_host_socket.rs | 4 - .../waiting_for_pure_p2p_spectator.rs | 170 +++++++++++++++ .../waiting_for_spectator.rs | 200 +++--------------- .../waiting_for_match/waiting_in_room.rs | 35 +-- crates/app/junowen/src/state.rs | 1 + .../app/junowen/src/state/battle_session.rs | 11 +- .../src/state/battle_session/battle_game.rs | 10 +- .../src/state/battle_session/battle_select.rs | 3 +- .../src/state/battle_session/in_session.rs | 43 +--- .../{battle_session => }/spectator_host.rs | 139 ++++-------- .../src/state/spectator_host/acceptance.rs | 74 +++++++ .../src/state/spectator_host/game_log.rs | 44 ++++ .../src/state/spectator_host/initial.rs | 57 +++++ .../src/state/spectator_host/status_text.rs | 36 ++++ .../junowen/src/state/spectator_session.rs | 64 ++---- .../state/spectator_session/game_initial.rs | 38 ++++ .../src/state/spectator_session/in_session.rs | 13 +- .../src/state/spectator_session/relay.rs | 61 ++++++ .../state/spectator_session/spectator_game.rs | 2 +- 24 files changed, 807 insertions(+), 449 deletions(-) create mode 100644 crates/app/junowen/src/session/spectator/message.rs create mode 100644 crates/app/junowen/src/session/spectator/relay_outbox.rs create mode 100644 crates/app/junowen/src/signaling/waiting_for_match/waiting_for_pure_p2p_spectator.rs rename crates/app/junowen/src/state/{battle_session => }/spectator_host.rs (53%) create mode 100644 crates/app/junowen/src/state/spectator_host/acceptance.rs create mode 100644 crates/app/junowen/src/state/spectator_host/game_log.rs create mode 100644 crates/app/junowen/src/state/spectator_host/initial.rs create mode 100644 crates/app/junowen/src/state/spectator_host/status_text.rs create mode 100644 crates/app/junowen/src/state/spectator_session/game_initial.rs create mode 100644 crates/app/junowen/src/state/spectator_session/relay.rs diff --git a/crates/app/junowen/src/session/spectator.rs b/crates/app/junowen/src/session/spectator.rs index ec1245b..d9bc19f 100644 --- a/crates/app/junowen/src/session/spectator.rs +++ b/crates/app/junowen/src/session/spectator.rs @@ -4,55 +4,20 @@ use std::{ }; use anyhow::Result; -use derive_new::new; use getset::{CopyGetters, Getters, Setters}; -use junowen_lib::{ - connection::{DataChannel, PeerConnection}, - structs::settings::GameSettings, -}; -use serde::{Deserialize, Serialize}; +use junowen_lib::connection::{DataChannel, PeerConnection}; use tracing::{error, info, warn}; use super::{session_message::RoundInitial, to_channel}; -#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] -pub struct SpectatorInitial { - #[get = "pub"] - p1_name: String, - #[get = "pub"] - p2_name: String, - #[get = "pub"] - game_settings: GameSettings, -} +mod message; +mod relay_outbox; -#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, PartialEq, Serialize)] -pub struct PlayerGameInitial { - #[get_copy = "pub"] - character: u8, - #[get_copy = "pub"] - card: u8, -} - -/// 試合開始時の状態。観戦者はこれに合わせてキャラクターとカードを決定する -#[derive(new, Clone, Debug, Deserialize, CopyGetters, Getters, Serialize)] -pub struct GameInitial { - #[get_copy = "pub"] - difficulty: u8, - #[get_copy = "pub"] - p1: PlayerGameInitial, - #[get_copy = "pub"] - p2: PlayerGameInitial, - #[get = "pub"] - round_initial: RoundInitial, -} - -#[derive(Debug, Deserialize, Serialize)] -pub enum SpectatorSessionMessage { - InitSpectator(SpectatorInitial), - InitGame(GameInitial), - InitRound(RoundInitial), - Inputs(u16, u16), -} +pub use message::{ + GameInitial, PlayerGameInitial, SpectatorHostDelegation, SpectatorInitial, SpectatorRelayRoom, + SpectatorSessionMessage, +}; +pub use relay_outbox::RelayOutbox; #[derive(CopyGetters, Getters, Setters)] pub struct SpectatorSession { @@ -63,6 +28,7 @@ pub struct SpectatorSession { disconnected: bool, spectator_initial: Option, round_initial: Option, + relay_outbox: RelayOutbox, } impl SpectatorSession { @@ -76,6 +42,7 @@ impl SpectatorSession { disconnected: false, spectator_initial: None, round_initial: None, + relay_outbox: RelayOutbox::default(), } } @@ -83,10 +50,18 @@ impl SpectatorSession { self.spectator_initial.as_ref() } + pub fn relay_outbox_mut(&mut self) -> &mut RelayOutbox { + &mut self.relay_outbox + } + fn fill_buffer(&mut self) { loop { match self.hook_incoming_rx.try_recv() { - Ok(msg) => self.buffer.push_back(msg), + Ok(msg) => { + if let Some(msg) = self.relay_outbox.filter_delegation(msg) { + self.buffer.push_back(msg); + } + } Err(TryRecvError::Empty) => return, Err(TryRecvError::Disconnected) => { self.disconnected = true; @@ -97,10 +72,21 @@ impl SpectatorSession { } fn next_message(&mut self) -> Result { + let msg = self.pop_message()?; + self.relay_outbox.push_processed(&msg); + Ok(msg) + } + + fn pop_message(&mut self) -> Result { if let Some(msg) = self.buffer.pop_front() { return Ok(msg); } - self.hook_incoming_rx.recv() + loop { + let msg = self.hook_incoming_rx.recv()?; + if let Some(msg) = self.relay_outbox.filter_delegation(msg) { + return Ok(msg); + } + } } /// 受信済みで未処理のメッセージ数を返す @@ -158,7 +144,11 @@ impl SpectatorSession { self.fill_buffer(); while let Some(msg) = self.buffer.pop_front() { match msg { - SpectatorSessionMessage::InitGame(init) => return Ok(Some(init)), + SpectatorSessionMessage::InitGame(init) => { + self.relay_outbox + .push(SpectatorSessionMessage::InitGame(init.clone())); + return Ok(Some(init)); + } SpectatorSessionMessage::InitSpectator(init) => { error!("unexpected init spectator message: {:?}", init); return Err(RecvError); @@ -192,6 +182,7 @@ impl SpectatorSession { return Ok(Some(round_initial)); } SpectatorSessionMessage::Inputs(..) => continue, + SpectatorSessionMessage::DelegateSpectatorHost(..) => unreachable!(), } } } @@ -215,6 +206,7 @@ impl SpectatorSession { Ok((0, 0)) } SpectatorSessionMessage::Inputs(p1, p2) => Ok((p1, p2)), + SpectatorSessionMessage::DelegateSpectatorHost(..) => unreachable!(), } } } diff --git a/crates/app/junowen/src/session/spectator/message.rs b/crates/app/junowen/src/session/spectator/message.rs new file mode 100644 index 0000000..84cd1f2 --- /dev/null +++ b/crates/app/junowen/src/session/spectator/message.rs @@ -0,0 +1,69 @@ +use derive_new::new; +use getset::{CopyGetters, Getters}; +use junowen_lib::structs::settings::GameSettings; +use serde::{Deserialize, Serialize}; + +use crate::session::RoundInitial; + +#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] +pub struct SpectatorInitial { + #[get = "pub"] + p1_name: String, + #[get = "pub"] + p2_name: String, + #[get = "pub"] + game_settings: GameSettings, +} + +#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, PartialEq, Serialize)] +pub struct PlayerGameInitial { + #[get_copy = "pub"] + character: u8, + #[get_copy = "pub"] + card: u8, +} + +/// 試合開始時の状態。観戦者はこれに合わせてキャラクターとカードを決定する +#[derive(new, Clone, Debug, Deserialize, CopyGetters, Getters, Serialize)] +pub struct GameInitial { + #[get_copy = "pub"] + difficulty: u8, + #[get_copy = "pub"] + p1: PlayerGameInitial, + #[get_copy = "pub"] + p2: PlayerGameInitial, + #[get = "pub"] + round_initial: RoundInitial, +} + +/// 観戦ホストが観戦者を受け付ける専有ルームの名前とキーを表す。 +#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] +pub struct SpectatorRelayRoom { + #[get = "pub"] + room_name: String, + #[get = "pub"] + key: String, +} + +/// 観戦ホストへの任命 +#[derive(new, Clone, Debug, Deserialize, Serialize)] +pub struct SpectatorHostDelegation { + /// 観戦者を待ち受ける専有ルームの情報を持つ。ない場合は、Pure P2P で観戦者を待ち受ける。 + room: Option, +} + +impl SpectatorHostDelegation { + pub fn into_room(self) -> Option { + self.room + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum SpectatorSessionMessage { + InitSpectator(SpectatorInitial), + InitGame(GameInitial), + InitRound(RoundInitial), + Inputs(u16, u16), + /// 受信した観戦者を観戦ホストに任命し、以降の観戦者の受け付けと中継を委譲する + DelegateSpectatorHost(SpectatorHostDelegation), +} diff --git a/crates/app/junowen/src/session/spectator/relay_outbox.rs b/crates/app/junowen/src/session/spectator/relay_outbox.rs new file mode 100644 index 0000000..d4d8528 --- /dev/null +++ b/crates/app/junowen/src/session/spectator/relay_outbox.rs @@ -0,0 +1,60 @@ +use tracing::info; + +use super::{SpectatorHostDelegation, SpectatorSessionMessage}; + +/// 観戦ホストとして配下の観戦者に中継するメッセージの記録 +/// +/// 観戦ホストは、自分が処理したのと同じ順序で配下の観戦者に試合の進行を送る +#[derive(Default)] +pub struct RelayOutbox { + delegation: Option, + /// 観戦ホストに任命されているか + active: bool, + /// 処理済みのメッセージのうち、まだ中継していないもの + messages: Vec, +} + +impl RelayOutbox { + /// 観戦ホストへの任命を取り込み、それ以外のメッセージを返す + pub fn filter_delegation( + &mut self, + msg: SpectatorSessionMessage, + ) -> Option { + match msg { + SpectatorSessionMessage::DelegateSpectatorHost(delegation) => { + info!("delegated spectator host"); + self.delegation = Some(delegation); + self.active = true; + None + } + msg => Some(msg), + } + } + + /// 観戦ホストに任命された場合、その任命を返す + pub fn take_delegation(&mut self) -> Option { + self.delegation.take() + } + + pub fn push(&mut self, msg: SpectatorSessionMessage) { + if self.active { + self.messages.push(msg); + } + } + + /// 試合中に処理したメッセージを記録する + /// + /// `InitGame` はバッファに戻されることがあるため、試合の開始時に記録する + pub fn push_processed(&mut self, msg: &SpectatorSessionMessage) { + if matches!( + msg, + SpectatorSessionMessage::InitRound(_) | SpectatorSessionMessage::Inputs(..) + ) { + self.push(msg.clone()); + } + } + + pub fn take_messages(&mut self) -> Vec { + std::mem::take(&mut self.messages) + } +} diff --git a/crates/app/junowen/src/session/spectator_host.rs b/crates/app/junowen/src/session/spectator_host.rs index 5ae2f57..0999576 100644 --- a/crates/app/junowen/src/session/spectator_host.rs +++ b/crates/app/junowen/src/session/spectator_host.rs @@ -5,7 +5,10 @@ use tracing::info; use super::{ session_message::RoundInitial, - spectator::{GameInitial, SpectatorInitial, SpectatorSessionMessage}, + spectator::{ + GameInitial, SpectatorHostDelegation, SpectatorInitial, SpectatorRelayRoom, + SpectatorSessionMessage, + }, to_channel, }; @@ -25,6 +28,14 @@ impl SpectatorHostSession { } } + pub fn send_delegate_spectator_host(&self, room: Option) -> Result<()> { + Ok(self + .hook_outgoing_tx + .send(SpectatorSessionMessage::DelegateSpectatorHost( + SpectatorHostDelegation::new(room), + ))?) + } + pub fn send_init_spectator(&self, init: SpectatorInitial) -> Result<()> { Ok(self .hook_outgoing_tx diff --git a/crates/app/junowen/src/signaling/waiting_for_match.rs b/crates/app/junowen/src/signaling/waiting_for_match.rs index 9b15048..8c64a23 100644 --- a/crates/app/junowen/src/signaling/waiting_for_match.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match.rs @@ -5,6 +5,7 @@ mod shared_room_opponent_socket; mod socket; mod tcp_signaling_guest_socket; mod tcp_signaling_host_socket; +mod waiting_for_pure_p2p_spectator; pub mod waiting_for_spectator; mod waiting_in_room; @@ -13,7 +14,8 @@ use tokio::sync::mpsc; use crate::session::{battle::BattleSession, spectator::SpectatorSession}; -pub use waiting_for_spectator::{WaitingForPureP2pSpectator, WaitingForSpectator}; +pub use waiting_for_pure_p2p_spectator::WaitingForPureP2pSpectator; +pub use waiting_for_spectator::WaitingForSpectator; pub use waiting_in_room::{ WaitingForOpponentInReservedRoom, WaitingForOpponentInSharedRoom, WaitingForOpponentOverTcpSignaling, WaitingForSpectatorHostInReservedRoom, WaitingInRoom, @@ -42,34 +44,19 @@ impl WaitingForOpponent { match self { Self::SharedRoom(waiting) => waiting .try_into_session() - .map(|session| { - ( - session, - WaitingForSpectator::PureP2p(WaitingForPureP2pSpectator::standby()), - ) - }) + .map(|session| (session, WaitingForSpectator::new(None))) .map_err(WaitingForOpponent::SharedRoom), Self::ReservedRoom(waiting) => waiting .try_into_session_and_waiting_for_spectator() .map_err(WaitingForOpponent::ReservedRoom), Self::TcpSignaling(waiting) => waiting .try_into_session() - .map(|session| { - ( - session, - WaitingForSpectator::PureP2p(WaitingForPureP2pSpectator::standby()), - ) - }) + .map(|session| (session, WaitingForSpectator::new(None))) .map_err(WaitingForOpponent::TcpSignaling), Self::PureP2p(mut waiting) => waiting .battle_session_rx .try_recv() - .map(|session| { - ( - session, - WaitingForSpectator::PureP2p(WaitingForPureP2pSpectator::standby()), - ) - }) + .map(|session| (session, WaitingForSpectator::new(None))) .map_err(|_| Self::PureP2p(waiting)), } } diff --git a/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs index 1beb47e..4a67aa2 100644 --- a/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs @@ -46,10 +46,6 @@ impl SignalingServerReservedRoomSpectatorHostSocket { } } - pub fn into_key(self) -> String { - self.key - } - async fn sleep_or_abort_and_delete_room(&mut self, retry_after: u32, key: &str) -> Result<()> { let url = &self.resource_url; sleep_or_abort_and_delete_room(retry_after, &mut self.abort_rx, &self.client, url, key) diff --git a/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_pure_p2p_spectator.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_pure_p2p_spectator.rs new file mode 100644 index 0000000..154aa5f --- /dev/null +++ b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_pure_p2p_spectator.rs @@ -0,0 +1,170 @@ +use std::mem; + +use anyhow::Result; +use clipboard_win::{get_clipboard_string, set_clipboard_string}; +use junowen_lib::{ + Th19, + connection::{ + SPECTATOR_PROTOCOL, + signaling::{ + SignalingCodeType, parse_signaling_code, + socket::async_read_write_socket::SignalingServerMessage, + }, + }, + structs::app::MainMenu, +}; +use tokio::sync::mpsc; +use tracing::info; + +use crate::session::spectator_host::SpectatorHostSession; + +use super::super::Signaling; + +fn try_start_signaling(th19: &Th19) -> Option { + let Ok(ok) = get_clipboard_string() else { + th19.play_sound(th19.sound_manager(), 0x10, 0); + return None; + }; + let Ok((SignalingCodeType::SpectatorOffer, offer)) = parse_signaling_code(&ok) else { + th19.play_sound(th19.sound_manager(), 0x10, 0); + return None; + }; + let (session_tx, session_rx) = mpsc::channel(1); + let mut signaling = Signaling::new(session_tx, SpectatorHostSession::new, SPECTATOR_PROTOCOL); + signaling + .msg_tx_mut() + .take() + .unwrap() + .send(SignalingServerMessage::RequestAnswer(offer)) + .unwrap(); + th19.play_sound(th19.sound_manager(), 0x07, 0); + Some(WaitingForPureP2pSpectator::SignalingCodeRecved { + signaling, + session_rx, + ready: false, + pushed: true, + }) +} + +pub enum WaitingForPureP2pSpectator { + Standby { + ready: bool, + pushed: bool, + }, + SignalingCodeRecved { + signaling: Signaling, + session_rx: mpsc::Receiver, + ready: bool, + pushed: bool, + }, + SignalingCodeSent { + _signaling: Signaling, + session_rx: mpsc::Receiver, + ready: bool, + pushed: bool, + }, +} + +impl WaitingForPureP2pSpectator { + pub fn standby() -> Self { + Self::Standby { + ready: false, + pushed: false, + } + } + + fn dummy() -> Self { + Self::Standby { + ready: false, + pushed: false, + } + } + + fn set_ready(&mut self, value: bool) { + match self { + Self::Standby { ready, .. } + | Self::SignalingCodeRecved { ready, .. } + | Self::SignalingCodeSent { ready, .. } => *ready = value, + } + } + + fn update_inner( + &mut self, + current_pushed: bool, + main_menu: Option<&MainMenu>, + th19: &Th19, + ) -> Result<()> { + // 観戦者の受け付けはメニュー画面 (難易度選択・キャラクター選択) で行う。 + // 観戦者は次の試合の開始 (`SpectatorHostState::start_game`) から合流する + self.set_ready(main_menu.is_some()); + + match self { + Self::Standby { pushed, .. } => { + let prev_pushed = *pushed; + *pushed = current_pushed; + if !prev_pushed && current_pushed { + if let Some(new_state) = try_start_signaling(th19) { + *self = new_state; + } + } + Ok(()) + } + Self::SignalingCodeRecved { + signaling, pushed, .. + } => { + let prev_pushed = *pushed; + *pushed = current_pushed; + if !prev_pushed && current_pushed { + if let Some(new_state) = try_start_signaling(th19) { + *self = new_state; + return Ok(()); + } + } + signaling.recv(); + let Some(answer) = signaling.answer() else { + return Ok(()); + }; + set_clipboard_string(&SignalingCodeType::SpectatorAnswer.to_string(answer)) + .unwrap(); + th19.play_sound(th19.sound_manager(), 0x57, 0); + + let Self::SignalingCodeRecved { + signaling, + session_rx, + ready, + pushed, + } = mem::replace(self, Self::dummy()) + else { + unreachable!() + }; + *self = Self::SignalingCodeSent { + _signaling: signaling, + session_rx, + ready, + pushed, + }; + Ok(()) + } + Self::SignalingCodeSent { pushed, .. } => { + let prev_pushed = *pushed; + *pushed = current_pushed; + if !prev_pushed && current_pushed { + if let Some(new_state) = try_start_signaling(th19) { + *self = new_state; + } + } + Ok(()) + } + } + } + + pub fn update(&mut self, pushed: bool, menu: Option<&MainMenu>, th19: &Th19) { + if let Err(err) = self.update_inner(pushed, menu, th19) { + info!("spectator host error: {:?}", err); + *self = Self::Standby { + ready: false, + pushed, + }; + } + } +} diff --git a/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index 3830336..ff8c542 100644 --- a/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -1,180 +1,44 @@ -use std::mem; +use junowen_lib::{Th19, structs::app::MainMenu}; +use tokio::sync::mpsc::error::TryRecvError; -use anyhow::Result; -use clipboard_win::{get_clipboard_string, set_clipboard_string}; -use junowen_lib::{ - Th19, - connection::{ - SPECTATOR_PROTOCOL, - signaling::{ - SignalingCodeType, parse_signaling_code, - socket::async_read_write_socket::SignalingServerMessage, - }, - }, - structs::app::MainMenu, -}; -use tokio::sync::mpsc::{self, error::TryRecvError}; -use tracing::info; - -use crate::session::spectator_host::SpectatorHostSession; +use crate::session::{spectator::SpectatorRelayRoom, spectator_host::SpectatorHostSession}; -use super::{super::Signaling, waiting_in_room::WaitingForSpectatorInReservedRoom}; - -fn try_start_signaling(th19: &Th19) -> Option { - let Ok(ok) = get_clipboard_string() else { - th19.play_sound(th19.sound_manager(), 0x10, 0); - return None; - }; - let Ok((SignalingCodeType::SpectatorOffer, offer)) = parse_signaling_code(&ok) else { - th19.play_sound(th19.sound_manager(), 0x10, 0); - return None; - }; - let (session_tx, session_rx) = mpsc::channel(1); - let mut signaling = Signaling::new(session_tx, SpectatorHostSession::new, SPECTATOR_PROTOCOL); - signaling - .msg_tx_mut() - .take() - .unwrap() - .send(SignalingServerMessage::RequestAnswer(offer)) - .unwrap(); - th19.play_sound(th19.sound_manager(), 0x07, 0); - Some(WaitingForPureP2pSpectator::SignalingCodeRecved { - signaling, - session_rx, - ready: false, - pushed: true, - }) -} +use super::{ + waiting_for_pure_p2p_spectator::WaitingForPureP2pSpectator, + waiting_in_room::WaitingForSpectatorInReservedRoom, +}; -pub enum WaitingForPureP2pSpectator { - Standby { - ready: bool, - pushed: bool, - }, - SignalingCodeRecved { - signaling: Signaling, - session_rx: mpsc::Receiver, - ready: bool, - pushed: bool, - }, - SignalingCodeSent { - _signaling: Signaling, - session_rx: mpsc::Receiver, - ready: bool, - pushed: bool, +pub enum WaitingForSpectator { + PureP2p(WaitingForPureP2pSpectator), + ReservedRoom { + room: SpectatorRelayRoom, + waiting: WaitingForSpectatorInReservedRoom, }, } -impl WaitingForPureP2pSpectator { - pub fn standby() -> Self { - Self::Standby { - ready: false, - pushed: false, - } - } - - fn dummy() -> Self { - Self::Standby { - ready: false, - pushed: false, - } - } - - fn set_ready(&mut self, value: bool) { - match self { - Self::Standby { ready, .. } - | Self::SignalingCodeRecved { ready, .. } - | Self::SignalingCodeSent { ready, .. } => *ready = value, - } - } - - fn update_inner( - &mut self, - current_pushed: bool, - main_menu: Option<&MainMenu>, - th19: &Th19, - ) -> Result<()> { - // 観戦者の受け付けはメニュー画面 (難易度選択・キャラクター選択) で行う。 - // 観戦者は次の試合の開始 (`SpectatorHostState::start_game`) から合流する - self.set_ready(main_menu.is_some()); - - match self { - Self::Standby { pushed, .. } => { - let prev_pushed = *pushed; - *pushed = current_pushed; - if !prev_pushed && current_pushed { - if let Some(new_state) = try_start_signaling(th19) { - *self = new_state; - } - } - Ok(()) - } - Self::SignalingCodeRecved { - signaling, pushed, .. - } => { - let prev_pushed = *pushed; - *pushed = current_pushed; - if !prev_pushed && current_pushed { - if let Some(new_state) = try_start_signaling(th19) { - *self = new_state; - return Ok(()); - } - } - signaling.recv(); - let Some(answer) = signaling.answer() else { - return Ok(()); - }; - set_clipboard_string(&SignalingCodeType::SpectatorAnswer.to_string(answer)) - .unwrap(); - th19.play_sound(th19.sound_manager(), 0x57, 0); - - let Self::SignalingCodeRecved { - signaling, - session_rx, - ready, - pushed, - } = mem::replace(self, Self::dummy()) - else { - unreachable!() - }; - *self = Self::SignalingCodeSent { - _signaling: signaling, - session_rx, - ready, - pushed, - }; - Ok(()) - } - Self::SignalingCodeSent { pushed, .. } => { - let prev_pushed = *pushed; - *pushed = current_pushed; - if !prev_pushed && current_pushed { - if let Some(new_state) = try_start_signaling(th19) { - *self = new_state; - } - } - Ok(()) +impl WaitingForSpectator { + /// 専有ルームの情報があれば専有ルームで、無ければ Pure P2P で観戦者を待ち受ける + pub fn new(room: Option) -> Self { + match room { + Some(room) => { + let waiting = WaitingForSpectatorInReservedRoom::new( + room.room_name().clone(), + room.key().clone(), + ); + Self::ReservedRoom { room, waiting } } + None => Self::PureP2p(WaitingForPureP2pSpectator::standby()), } } - pub fn update(&mut self, pushed: bool, menu: Option<&MainMenu>, th19: &Th19) { - if let Err(err) = self.update_inner(pushed, menu, th19) { - info!("spectator host error: {:?}", err); - *self = Self::Standby { - ready: false, - pushed, - }; + pub fn room(&self) -> Option<&SpectatorRelayRoom> { + match self { + Self::PureP2p(_) => None, + Self::ReservedRoom { room, .. } => Some(room), } } -} -pub enum WaitingForSpectator { - PureP2p(WaitingForPureP2pSpectator), - ReservedRoom(WaitingForSpectatorInReservedRoom), -} - -impl WaitingForSpectator { + /// 専有ルームの場合、観戦者を受信した後は再度 `new` で待ち受けを作り直す必要がある pub fn try_recv_session( &mut self, pushed: bool, @@ -202,13 +66,7 @@ impl WaitingForSpectator { } } } - Self::ReservedRoom(waiting) => match waiting.try_session_and_waiting_for_spectator() { - Ok((session, waiting)) => { - *self = waiting; - Some(session) - } - Err(_) => None, - }, + Self::ReservedRoom { waiting, .. } => waiting.try_recv_session(), } } } diff --git a/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs index 4a00dd1..a8126b5 100644 --- a/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs @@ -6,8 +6,7 @@ use junowen_lib::connection::{DataChannel, PeerConnection, signaling::socket::Si use tokio::{ sync::{ mpsc::{self}, - oneshot::{self, error::TryRecvError}, - watch, + oneshot, watch, }, task::JoinHandle, time::sleep, @@ -17,7 +16,9 @@ use tracing::{Instrument, debug, debug_span, info}; use crate::{ TOKIO_RUNTIME, session::{ - battle::BattleSession, spectator::SpectatorSession, spectator_host::SpectatorHostSession, + battle::BattleSession, + spectator::{SpectatorRelayRoom, SpectatorSession}, + spectator_host::SpectatorHostSession, }, signaling::waiting_for_match::{ reserved_room_opponent_socket::SignalingServerReservedRoomOpponentSocket, @@ -25,7 +26,6 @@ use crate::{ shared_room_opponent_socket::SignalingServerSharedRoomOpponentSocket, tcp_signaling_guest_socket::TcpSignalingGuestSocket, tcp_signaling_host_socket::TcpSignalingHostSocket, - waiting_for_spectator::WaitingForPureP2pSpectator, }, }; @@ -50,7 +50,7 @@ pub type WaitingForOpponentInSharedRoom = WaitingInRoom; /// TCP シグナリングでの対戦の待機。`room_name` には接続先アドレスが入る pub type WaitingForOpponentOverTcpSignaling = WaitingInRoom; pub type WaitingForOpponentInReservedRoom = WaitingInRoom<(BattleSession, Option)>; -pub type WaitingForSpectatorInReservedRoom = WaitingInRoom<(SpectatorHostSession, RoomKey)>; +pub type WaitingForSpectatorInReservedRoom = WaitingInRoom; pub type WaitingForSpectatorHostInReservedRoom = WaitingInRoom; impl WaitingInRoom @@ -168,13 +168,8 @@ impl WaitingForOpponentInReservedRoom { let Ok((session, key)) = self.session_rx.try_recv() else { return Err(self); }; - let waiting = if let Some(key) = key { - let waiting = WaitingForSpectatorInReservedRoom::new(self.room_name.clone(), key.0); - WaitingForSpectator::ReservedRoom(waiting) - } else { - WaitingForSpectator::PureP2p(WaitingForPureP2pSpectator::standby()) - }; - Ok((session, waiting)) + let room = key.map(|key| SpectatorRelayRoom::new(self.room_name.clone(), key.0)); + Ok((session, WaitingForSpectator::new(room))) } } @@ -186,23 +181,13 @@ impl WaitingForSpectatorInReservedRoom { origin, room_name, key, abort_rx, ) }, - |conn, dc, _host, socket| { - ( - SpectatorHostSession::new(conn, dc), - RoomKey(socket.into_key()), - ) - }, + |conn, dc, _host, _socket| SpectatorHostSession::new(conn, dc), room_name, ) } - pub fn try_session_and_waiting_for_spectator( - &mut self, - ) -> Result<(SpectatorHostSession, WaitingForSpectator), TryRecvError> { - let (session, key) = self.session_rx.try_recv()?; - let waiting = WaitingForSpectatorInReservedRoom::new(self.room_name.clone(), key.0); - let waiting = WaitingForSpectator::ReservedRoom(waiting); - Ok((session, waiting)) + pub fn try_recv_session(&mut self) -> Option { + self.session_rx.try_recv().ok() } } diff --git a/crates/app/junowen/src/state.rs b/crates/app/junowen/src/state.rs index d63094d..6276026 100644 --- a/crates/app/junowen/src/state.rs +++ b/crates/app/junowen/src/state.rs @@ -2,6 +2,7 @@ mod battle_session; mod prepare; mod render_parts; mod session; +mod spectator_host; mod spectator_session; pub mod standby; diff --git a/crates/app/junowen/src/state/battle_session.rs b/crates/app/junowen/src/state/battle_session.rs index d69ecf1..5e2d44b 100644 --- a/crates/app/junowen/src/state/battle_session.rs +++ b/crates/app/junowen/src/state/battle_session.rs @@ -1,7 +1,6 @@ mod battle_game; mod battle_select; mod in_session; -mod spectator_host; mod utils; use std::{ffi::c_void, sync::mpsc::RecvError}; @@ -20,9 +19,12 @@ use crate::{ use self::in_session::RenderingStatus; -use super::prepare::Prepare; +use super::{ + prepare::Prepare, + spectator_host::{SpectatorHostState, current_game_initial}, +}; -use {battle_game::BattleGame, battle_select::BattleSelect, spectator_host::SpectatorHostState}; +use {battle_game::BattleGame, battle_select::BattleSelect}; pub struct BattleSession { props: BattleSessionProps, @@ -79,7 +81,8 @@ impl BattleSession { let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); match main_menu.screen_id() { ScreenId::GameLoading => { - self.spectator_host_state.start_game(th19); + self.spectator_host_state + .start_game(current_game_initial(th19)); self.change_to_game_loading(); Some(Some(main_menu)) } diff --git a/crates/app/junowen/src/state/battle_session/battle_game.rs b/crates/app/junowen/src/state/battle_session/battle_game.rs index 1f791dc..fd8ff07 100644 --- a/crates/app/junowen/src/state/battle_session/battle_game.rs +++ b/crates/app/junowen/src/state/battle_session/battle_game.rs @@ -3,9 +3,13 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use crate::{helper::inputed_number, session::battle::BattleSession}; +use crate::{ + helper::inputed_number, + session::battle::BattleSession, + state::spectator_host::{SpectatorHostState, current_round_initial}, +}; -use super::{spectator_host::SpectatorHostState, utils::init_round}; +use super::utils::init_round; pub struct BattleGame; @@ -55,7 +59,7 @@ impl BattleGame { th19: &mut Th19, ) -> Result<(), RecvError> { init_round(th19, session)?; - spectator_host_state.send_init_round(th19); + spectator_host_state.send_init_round(current_round_initial(th19)); Ok(()) } } diff --git a/crates/app/junowen/src/state/battle_session/battle_select.rs b/crates/app/junowen/src/state/battle_session/battle_select.rs index fd63ffe..0357d1c 100644 --- a/crates/app/junowen/src/state/battle_session/battle_select.rs +++ b/crates/app/junowen/src/state/battle_session/battle_select.rs @@ -13,9 +13,10 @@ use tracing::trace; use crate::{ helper::{inputed_number, pushed_f1}, session::{MatchInitial, battle::BattleSession}, + state::spectator_host::SpectatorHostState, }; -use super::{spectator_host::SpectatorHostState, utils::init_round}; +use super::utils::init_round; fn init_match(th19: &mut Th19, battle_session: &mut BattleSession) -> Result<(), RecvError> { trace!("init_match"); diff --git a/crates/app/junowen/src/state/battle_session/in_session.rs b/crates/app/junowen/src/state/battle_session/in_session.rs index ca2bde6..e0d2dcd 100644 --- a/crates/app/junowen/src/state/battle_session/in_session.rs +++ b/crates/app/junowen/src/state/battle_session/in_session.rs @@ -1,14 +1,12 @@ -use std::{borrow::Cow, ffi::c_void}; +use std::ffi::c_void; use junowen_lib::{Th19, structs::settings::GameSettings}; -use crate::{ - signaling::waiting_for_match::{WaitingForPureP2pSpectator, WaitingForSpectator}, - state::render_parts::{render_footer, render_game_settings, render_names}, +use crate::state::{ + render_parts::{render_footer, render_game_settings, render_names}, + spectator_host::{SpectatorHostState, status_text}, }; -use super::spectator_host::SpectatorHostState; - pub struct RenderingStatus<'a> { pub host: bool, pub delay: u8, @@ -24,38 +22,7 @@ pub fn on_render_texts(th19: &Th19, text_renderer: &c_void, status: RenderingSta render_game_settings(th19, text_renderer, game_settings); } - let (msg2_rear, msg2_front) = if status.spectator_host_state.count_spectators() > 0 { - ( - " ", - Cow::Owned(format!( - "Spectator(s): {}", - status.spectator_host_state.count_spectators() - )), - ) - } else { - match status.spectator_host_state.waiting() { - WaitingForSpectator::PureP2p(waiting) => match waiting { - WaitingForPureP2pSpectator::Standby { ready: false, .. } - | WaitingForPureP2pSpectator::SignalingCodeRecved { ready: false, .. } - | WaitingForPureP2pSpectator::SignalingCodeSent { ready: false, .. } => { - ("", "".into()) - } - WaitingForPureP2pSpectator::Standby { .. } => ( - " __ ", - "(Press F1 to accept spectator from clipboard)".into(), - ), - WaitingForPureP2pSpectator::SignalingCodeRecved { .. } => ( - " ", - "(Generating signaling code...)".into(), - ), - WaitingForPureP2pSpectator::SignalingCodeSent { .. } => ( - " ", - "(Your signaling code has been copied to the clipboard)".into(), - ), - }, - WaitingForSpectator::ReservedRoom(_) => ("", "".into()), - } - }; + let (msg2_rear, msg2_front) = status_text(status.spectator_host_state); let delay_underline = if status.host { "_" } else { " " }; let msg_front/* _ */= format!("Delay: {} {}", status.delay, msg2_front); diff --git a/crates/app/junowen/src/state/battle_session/spectator_host.rs b/crates/app/junowen/src/state/spectator_host.rs similarity index 53% rename from crates/app/junowen/src/state/battle_session/spectator_host.rs rename to crates/app/junowen/src/state/spectator_host.rs index ecb87b8..4589048 100644 --- a/crates/app/junowen/src/state/battle_session/spectator_host.rs +++ b/crates/app/junowen/src/state/spectator_host.rs @@ -1,75 +1,25 @@ +mod acceptance; +mod game_log; +mod initial; +mod status_text; + use anyhow::Result; -use getset::Getters; -use junowen_lib::{ - Th19, - structs::{app::MainMenu, selection::Player}, -}; +use junowen_lib::{Th19, structs::app::MainMenu}; use tracing::info; use crate::{ - session::{ - RoundInitial, - battle::BattleSession, - spectator::{GameInitial, PlayerGameInitial, SpectatorInitial}, - spectator_host::SpectatorHostSession, - }, + session::{RoundInitial, spectator::GameInitial, spectator_host::SpectatorHostSession}, signaling::waiting_for_match::WaitingForSpectator, }; -fn current_round_initial(th19: &Th19) -> RoundInitial { - RoundInitial { - seed1: th19.rand_seed1().unwrap(), - seed2: th19.rand_seed2().unwrap(), - seed3: th19.rand_seed3().unwrap(), - seed4: th19.rand_seed4().unwrap(), - } -} - -fn create_spectator_initial(battle_session: &BattleSession, th19: &Th19) -> SpectatorInitial { - let local_player_name = th19.vs_mode().player_name().to_string(); - let remote_player_name = battle_session.remote_player_name().clone(); - let (p1_name, p2_name) = if battle_session.host() { - (local_player_name, remote_player_name) - } else { - (remote_player_name, local_player_name) - }; - SpectatorInitial::new( - p1_name, - p2_name, - battle_session - .match_initial() - .as_ref() - .unwrap() - .game_settings - .clone(), - ) -} - -/// 進行中の試合の記録。試合の途中で接続した観戦者は、これを受け取って試合の最初から早送りで追いかける -struct GameLog { - init: GameInitial, - messages: Vec, -} - -enum GameLogMessage { - InitRound(RoundInitial), - Inputs(u16, u16), -} - -impl GameLog { - fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { - session.send_init_game(self.init.clone())?; - for msg in &self.messages { - match msg { - GameLogMessage::InitRound(round_initial) => { - session.send_init_round(round_initial.clone())? - } - GameLogMessage::Inputs(p1, p2) => session.send_inputs(*p1, *p2)?, - } - } - Ok(()) - } -} +use self::{ + acceptance::Acceptance, + game_log::{GameLog, GameLogMessage}, +}; +pub use self::{ + initial::{SpectatorInitialSource, current_game_initial, current_round_initial}, + status_text::status_text, +}; /// 観戦者の管理 /// @@ -77,10 +27,12 @@ impl GameLog { /// 選択画面で接続した場合は、次の試合の開始時にキャラクター・カード・乱数シードを受け取り、 /// それに合わせて試合を始める。 /// 試合中に接続した場合は、その試合の開始時の状態とそれまでの入力を受け取り、試合の最初から追いかける -#[derive(Getters)] +/// +/// 対戦者は最初の観戦者のみを受け付け、その観戦者を観戦ホストに任命して以降の受け付けを委譲する。 +/// 観戦ホストは自分が処理した試合の進行を、同じ順序で配下の観戦者に中継する。 +/// 観戦ホストが離脱すると、配下の観戦者もすべて離脱する pub struct SpectatorHostState { - #[get = "pub"] - waiting: WaitingForSpectator, + acceptance: Acceptance, /// 次の試合の開始を待っている観戦者 standby_sessions: Vec, /// 試合を観戦している観戦者 @@ -90,15 +42,29 @@ pub struct SpectatorHostState { } impl SpectatorHostState { + /// 対戦者用。最初の観戦者を観戦ホストに任命する pub fn new(waiting: WaitingForSpectator) -> Self { + Self::with_acceptance(Acceptance::WaitingForSpectatorHost(waiting)) + } + + /// 観戦ホスト用 + pub fn new_relay(waiting: WaitingForSpectator) -> Self { + Self::with_acceptance(Acceptance::Relaying(waiting)) + } + + fn with_acceptance(acceptance: Acceptance) -> Self { Self { - waiting, + acceptance, standby_sessions: Vec::new(), sessions: Vec::new(), game_log: None, } } + pub fn waiting(&self) -> Option<&WaitingForSpectator> { + self.acceptance.waiting() + } + pub fn count_spectators(&self) -> usize { self.standby_sessions.len() + self.sessions.len() } @@ -109,14 +75,16 @@ impl SpectatorHostState { pushed: bool, main_menu: Option<&MainMenu>, th19: &Th19, - battle_session: &BattleSession, + initial_source: &impl SpectatorInitialSource, ) { - let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) else { + let has_spectators = self.count_spectators() > 0; + let Some(session) = + self.acceptance + .try_recv_session(has_spectators, pushed, main_menu, th19) + else { return; }; - if let Err(err) = - session.send_init_spectator(create_spectator_initial(battle_session, th19)) - { + if let Err(err) = session.send_init_spectator(initial_source.spectator_initial(th19)) { info!("initialize spectator failed: {:?}", err); return; } @@ -131,7 +99,7 @@ impl SpectatorHostState { } info!( "spectator joined during game. replaying {} messages", - game_log.messages.len() + game_log.message_count() ); self.sessions.push(session); } @@ -149,21 +117,12 @@ impl SpectatorHostState { true }); if let Some(game_log) = &mut self.game_log { - game_log.messages.push(msg); + game_log.push(msg); } } /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ - pub fn start_game(&mut self, th19: &Th19) { - let selection = th19.selection(); - let player = - |player: &Player| PlayerGameInitial::new(player.character as u8, player.card as u8); - let init = GameInitial::new( - selection.difficulty as u8, - player(selection.p1()), - player(selection.p2()), - current_round_initial(th19), - ); + pub fn start_game(&mut self, init: GameInitial) { self.sessions.append(&mut self.standby_sessions); self.sessions.retain(|session| { if let Err(err) = session.send_init_game(init.clone()) { @@ -172,10 +131,7 @@ impl SpectatorHostState { } true }); - self.game_log = Some(GameLog { - init, - messages: Vec::new(), - }); + self.game_log = Some(GameLog::new(init)); } /// 試合終了時 (試合から選択画面に戻るとき) に呼ぶ @@ -183,8 +139,7 @@ impl SpectatorHostState { self.game_log = None; } - pub fn send_init_round(&mut self, th19: &Th19) { - let round_initial = current_round_initial(th19); + pub fn send_init_round(&mut self, round_initial: RoundInitial) { self.broadcast( GameLogMessage::InitRound(round_initial.clone()), |session| session.send_init_round(round_initial.clone()), diff --git a/crates/app/junowen/src/state/spectator_host/acceptance.rs b/crates/app/junowen/src/state/spectator_host/acceptance.rs new file mode 100644 index 0000000..ce3fd1a --- /dev/null +++ b/crates/app/junowen/src/state/spectator_host/acceptance.rs @@ -0,0 +1,74 @@ +use junowen_lib::{Th19, structs::app::MainMenu}; +use tracing::info; + +use crate::{ + session::{spectator::SpectatorRelayRoom, spectator_host::SpectatorHostSession}, + signaling::waiting_for_match::WaitingForSpectator, +}; + +/// 観戦者の受け付け状態 +pub enum Acceptance { + /// 対戦者: 最初の観戦者を待ち受け、観戦ホストに任命する + WaitingForSpectatorHost(WaitingForSpectator), + /// 対戦者: 観戦ホストに受け付けを委譲している + Delegated, + /// 観戦ホスト: 観戦者を待ち受け、受け取った試合の進行を中継する + Relaying(WaitingForSpectator), +} + +/// 専有ルームの待ち受けは一度しか受信できないため、受信後に作り直す +fn rearm(waiting: &mut WaitingForSpectator, room: Option) { + if room.is_some() { + *waiting = WaitingForSpectator::new(room); + } +} + +impl Acceptance { + pub fn waiting(&self) -> Option<&WaitingForSpectator> { + match self { + Self::WaitingForSpectatorHost(waiting) | Self::Relaying(waiting) => Some(waiting), + Self::Delegated => None, + } + } + + /// 観戦者の接続を受け付ける + /// + /// 対戦者が最初に受け付けた観戦者は観戦ホストに任命され、以降の受け付けは観戦ホストに委譲される。 + /// 観戦ホストが離脱して配下の観戦者もいなくなった場合は、Pure P2P で受け付けを再開する + pub fn try_recv_session( + &mut self, + has_spectators: bool, + pushed: bool, + main_menu: Option<&MainMenu>, + th19: &Th19, + ) -> Option { + let (waiting, delegates) = match &mut *self { + Self::WaitingForSpectatorHost(waiting) => (waiting, true), + Self::Relaying(waiting) => (waiting, false), + Self::Delegated => { + if !has_spectators { + // 専有ルームは観戦ホストの離脱時に削除されるため、Pure P2P で待ち受ける + info!("spectator host left. resume waiting for spectator"); + *self = Self::WaitingForSpectatorHost(WaitingForSpectator::new(None)); + } + return None; + } + }; + let session = waiting.try_recv_session(pushed, main_menu, th19)?; + let room = waiting.room().cloned(); + if !delegates { + rearm(waiting, room); + return Some(session); + } + if let Err(err) = session.send_delegate_spectator_host(room.clone()) { + info!("delegate spectator host failed: {:?}", err); + rearm(waiting, room); + return None; + } + // 専有ルームの待ち受けは観戦ホストが引き継ぐため、ここでは作り直さない + // (作り直した待ち受けを破棄すると部屋が削除されてしまう) + info!("delegated spectator host"); + *self = Self::Delegated; + Some(session) + } +} diff --git a/crates/app/junowen/src/state/spectator_host/game_log.rs b/crates/app/junowen/src/state/spectator_host/game_log.rs new file mode 100644 index 0000000..c0c0e12 --- /dev/null +++ b/crates/app/junowen/src/state/spectator_host/game_log.rs @@ -0,0 +1,44 @@ +use anyhow::Result; + +use crate::session::{RoundInitial, spectator::GameInitial, spectator_host::SpectatorHostSession}; + +/// 進行中の試合の記録。試合の途中で接続した観戦者は、これを受け取って試合の最初から早送りで追いかける +pub struct GameLog { + init: GameInitial, + messages: Vec, +} + +pub enum GameLogMessage { + InitRound(RoundInitial), + Inputs(u16, u16), +} + +impl GameLog { + pub fn new(init: GameInitial) -> Self { + Self { + init, + messages: Vec::new(), + } + } + + pub fn message_count(&self) -> usize { + self.messages.len() + } + + pub fn push(&mut self, msg: GameLogMessage) { + self.messages.push(msg); + } + + pub fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { + session.send_init_game(self.init.clone())?; + for msg in &self.messages { + match msg { + GameLogMessage::InitRound(round_initial) => { + session.send_init_round(round_initial.clone())? + } + GameLogMessage::Inputs(p1, p2) => session.send_inputs(*p1, *p2)?, + } + } + Ok(()) + } +} diff --git a/crates/app/junowen/src/state/spectator_host/initial.rs b/crates/app/junowen/src/state/spectator_host/initial.rs new file mode 100644 index 0000000..ccd43cc --- /dev/null +++ b/crates/app/junowen/src/state/spectator_host/initial.rs @@ -0,0 +1,57 @@ +use junowen_lib::{Th19, structs::selection::Player}; + +use crate::session::{ + RoundInitial, + battle::BattleSession, + spectator::{GameInitial, PlayerGameInitial, SpectatorInitial}, +}; + +pub fn current_round_initial(th19: &Th19) -> RoundInitial { + RoundInitial { + seed1: th19.rand_seed1().unwrap(), + seed2: th19.rand_seed2().unwrap(), + seed3: th19.rand_seed3().unwrap(), + seed4: th19.rand_seed4().unwrap(), + } +} + +pub fn current_game_initial(th19: &Th19) -> GameInitial { + let selection = th19.selection(); + let player = + |player: &Player| PlayerGameInitial::new(player.character as u8, player.card as u8); + GameInitial::new( + selection.difficulty as u8, + player(selection.p1()), + player(selection.p2()), + current_round_initial(th19), + ) +} + +/// 観戦者が接続した直後に、プレイヤー名と設定を観戦者へ渡す +pub trait SpectatorInitialSource { + fn spectator_initial(&self, th19: &Th19) -> SpectatorInitial; +} + +impl SpectatorInitialSource for BattleSession { + fn spectator_initial(&self, th19: &Th19) -> SpectatorInitial { + let local_player_name = th19.vs_mode().player_name().to_string(); + let remote_player_name = self.remote_player_name().clone(); + let (p1_name, p2_name) = if self.host() { + (local_player_name, remote_player_name) + } else { + (remote_player_name, local_player_name) + }; + SpectatorInitial::new( + p1_name, + p2_name, + self.match_initial().as_ref().unwrap().game_settings.clone(), + ) + } +} + +/// 観戦ホストは、対戦者から受け取った情報をそのまま配下の観戦者に送る +impl SpectatorInitialSource for SpectatorInitial { + fn spectator_initial(&self, _th19: &Th19) -> SpectatorInitial { + self.clone() + } +} diff --git a/crates/app/junowen/src/state/spectator_host/status_text.rs b/crates/app/junowen/src/state/spectator_host/status_text.rs new file mode 100644 index 0000000..059d2ff --- /dev/null +++ b/crates/app/junowen/src/state/spectator_host/status_text.rs @@ -0,0 +1,36 @@ +use std::borrow::Cow; + +use crate::signaling::waiting_for_match::{WaitingForPureP2pSpectator, WaitingForSpectator}; + +use super::SpectatorHostState; + +/// 観戦者の受け付け状況を表示するためのテキスト (下線, 本文) を返す +pub fn status_text(state: &SpectatorHostState) -> (&'static str, Cow<'static, str>) { + let spectators = state.count_spectators(); + if spectators > 0 { + return ( + " ", + Cow::Owned(format!("Spectator(s): {}", spectators)), + ); + } + let Some(WaitingForSpectator::PureP2p(waiting)) = state.waiting() else { + return ("", "".into()); + }; + match waiting { + WaitingForPureP2pSpectator::Standby { ready: false, .. } + | WaitingForPureP2pSpectator::SignalingCodeRecved { ready: false, .. } + | WaitingForPureP2pSpectator::SignalingCodeSent { ready: false, .. } => ("", "".into()), + WaitingForPureP2pSpectator::Standby { .. } => ( + " __ ", + "(Press F1 to accept spectator from clipboard)".into(), + ), + WaitingForPureP2pSpectator::SignalingCodeRecved { .. } => ( + " ", + "(Generating signaling code...)".into(), + ), + WaitingForPureP2pSpectator::SignalingCodeSent { .. } => ( + " ", + "(Your signaling code has been copied to the clipboard)".into(), + ), + } +} diff --git a/crates/app/junowen/src/state/spectator_session.rs b/crates/app/junowen/src/state/spectator_session.rs index b9018cd..f414482 100644 --- a/crates/app/junowen/src/state/spectator_session.rs +++ b/crates/app/junowen/src/state/spectator_session.rs @@ -1,4 +1,6 @@ +mod game_initial; mod in_session; +mod relay; mod spectator_game; mod spectator_standby; @@ -18,55 +20,21 @@ use tracing::warn; use crate::{ helper::pushed_escape, present_skipper::PresentSkipper, - session::{ - RoundInitial, - spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, - }, + session::{RoundInitial, spectator::SpectatorSession as SpectatorSessionProps}, }; use super::prepare::Prepare; use { + game_initial::{set_rand_seeds, verify_game_initial}, + relay::Relay, spectator_game::{SpectatorGame, update_no_wait_for_catch_up}, spectator_standby::SpectatorStandby, }; -fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { - th19.set_rand_seed1(round_initial.seed1).unwrap(); - th19.set_rand_seed2(round_initial.seed2).unwrap(); - th19.set_rand_seed3(round_initial.seed3).unwrap(); - th19.set_rand_seed4(round_initial.seed4).unwrap(); -} - -/// 観戦者の試合開始時の状態がホストと一致しているか確認する -fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { - let selection = th19.selection(); - let actual = ( - selection.difficulty as u8, - selection.p1().character as u8, - selection.p1().card as u8, - selection.p2().character as u8, - selection.p2().card as u8, - ); - let expected = ( - init.difficulty(), - init.p1().character(), - init.p1().card(), - init.p2().character(), - init.p2().card(), - ); - if actual != expected { - warn!( - "game initial mismatch. expected={:?}, actual={:?}", - expected, actual - ); - return false; - } - true -} - pub struct SpectatorSession { props: SpectatorSessionProps, + relay: Relay, present_skipper: PresentSkipper, state: SpectatorSessionState, } @@ -84,6 +52,7 @@ impl SpectatorSession { pub fn prepare(props: SpectatorSessionProps) -> Self { Self { props, + relay: Relay::default(), present_skipper: PresentSkipper::default(), state: SpectatorSessionState::Prepare(Prepare::new()), } @@ -98,6 +67,7 @@ impl SpectatorSession { } pub fn change_to_prepare(&mut self) { + self.relay.end_game(); self.state = SpectatorSessionState::Prepare(Prepare::new()); } pub fn change_to_standby(&mut self, first_time: bool) { @@ -111,6 +81,7 @@ impl SpectatorSession { self.state = SpectatorSessionState::Game(SpectatorGame); } pub fn change_to_back_to_select(&mut self) { + self.relay.end_game(); self.state = SpectatorSessionState::BackToSelect; } @@ -192,10 +163,12 @@ impl SpectatorSession { menu: Option<&MainMenu>, th19: &mut Th19, ) -> Result<(), RecvError> { + self.relay.start_if_delegated(&mut self.props); match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_players(th19), SpectatorSessionState::Standby(standby) => { - standby.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? + standby.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)?; + self.relay.accept_spectators(&self.props, menu, th19); } SpectatorSessionState::GameLoading(round_initial) => { if let Some(round_initial) = round_initial.take() { @@ -204,14 +177,17 @@ impl SpectatorSession { update_no_wait_for_catch_up(&mut self.props, &self.present_skipper, th19); } SpectatorSessionState::Game(game) => { - game.update_th19(&mut self.props, &self.present_skipper, th19)? + game.update_th19(&mut self.props, &self.present_skipper, th19)?; + self.relay.accept_spectators(&self.props, None, th19); } SpectatorSessionState::BackToSelect => {} } + self.relay.forward(&mut self.props); Ok(()) } pub fn on_input_menu(&mut self, th19: &mut Th19) -> Result { + self.relay.start_if_delegated(&mut self.props); match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_menu(th19), SpectatorSessionState::Standby(standby) => { @@ -226,11 +202,14 @@ impl SpectatorSession { return Ok(false); } standby.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; + self.relay + .accept_spectators(&self.props, Some(&*main_menu), th19); } SpectatorSessionState::GameLoading(_) => {} SpectatorSessionState::Game { .. } => {} SpectatorSessionState::BackToSelect => {} } + self.relay.forward(&mut self.props); Ok(true) } @@ -251,6 +230,7 @@ impl SpectatorSession { initial.p1_name(), initial.p2_name(), waiting_for_host, + self.relay.state(), ); } @@ -258,6 +238,8 @@ impl SpectatorSession { let SpectatorSessionState::Game(game) = &mut self.state else { return Ok(()); }; - game.on_round_over(&mut self.props, th19) + game.on_round_over(&mut self.props, th19)?; + self.relay.forward(&mut self.props); + Ok(()) } } diff --git a/crates/app/junowen/src/state/spectator_session/game_initial.rs b/crates/app/junowen/src/state/spectator_session/game_initial.rs new file mode 100644 index 0000000..6715ba8 --- /dev/null +++ b/crates/app/junowen/src/state/spectator_session/game_initial.rs @@ -0,0 +1,38 @@ +use junowen_lib::Th19; +use tracing::warn; + +use crate::session::{RoundInitial, spectator::GameInitial}; + +pub fn set_rand_seeds(th19: &mut Th19, round_initial: &RoundInitial) { + th19.set_rand_seed1(round_initial.seed1).unwrap(); + th19.set_rand_seed2(round_initial.seed2).unwrap(); + th19.set_rand_seed3(round_initial.seed3).unwrap(); + th19.set_rand_seed4(round_initial.seed4).unwrap(); +} + +/// 観戦者の試合開始時の状態がホストと一致しているか確認する +pub fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { + let selection = th19.selection(); + let actual = ( + selection.difficulty as u8, + selection.p1().character as u8, + selection.p1().card as u8, + selection.p2().character as u8, + selection.p2().card as u8, + ); + let expected = ( + init.difficulty(), + init.p1().character(), + init.p1().card(), + init.p2().character(), + init.p2().card(), + ); + if actual != expected { + warn!( + "game initial mismatch. expected={:?}, actual={:?}", + expected, actual + ); + return false; + } + true +} diff --git a/crates/app/junowen/src/state/spectator_session/in_session.rs b/crates/app/junowen/src/state/spectator_session/in_session.rs index c1963fd..94a5da7 100644 --- a/crates/app/junowen/src/state/spectator_session/in_session.rs +++ b/crates/app/junowen/src/state/spectator_session/in_session.rs @@ -2,7 +2,10 @@ use std::ffi::c_void; use junowen_lib::Th19; -use crate::state::render_parts::{render_footer, render_names}; +use crate::state::{ + render_parts::{render_footer, render_names}, + spectator_host::{SpectatorHostState, status_text}, +}; pub fn on_render_texts_spectator( th19: &Th19, @@ -10,14 +13,18 @@ pub fn on_render_texts_spectator( p1_name: &str, p2_name: &str, waiting_for_next_game: bool, + relay: Option<&SpectatorHostState>, ) { render_names(th19, text_renderer, p1_name, p2_name); - let msg = if waiting_for_next_game { + let spectating = if waiting_for_next_game { "(Spectating: waiting for next game...)" } else { "(Spectating)" }; - render_footer(th19, text_renderer, msg, ""); + let (msg2_rear, msg2_front) = relay.map(status_text).unwrap_or_default(); + let msg_front/* _ */= format!("{} {}", spectating, msg2_front); + let msg_rear/* __ */= format!("{} {}", " ".repeat(spectating.len()), msg2_rear); + render_footer(th19, text_renderer, &msg_front, &msg_rear); } pub fn on_render_texts_waiting_for_host(th19: &Th19, text_renderer: &c_void) { diff --git a/crates/app/junowen/src/state/spectator_session/relay.rs b/crates/app/junowen/src/state/spectator_session/relay.rs new file mode 100644 index 0000000..9739528 --- /dev/null +++ b/crates/app/junowen/src/state/spectator_session/relay.rs @@ -0,0 +1,61 @@ +use junowen_lib::{Th19, structs::app::MainMenu}; + +use crate::{ + helper::pushed_f1, + session::spectator::{SpectatorSession as SpectatorSessionProps, SpectatorSessionMessage}, + signaling::waiting_for_match::WaitingForSpectator, + state::spectator_host::SpectatorHostState, +}; + +/// 観戦ホストに任命された場合に、配下の観戦者の受け付けと試合の進行の中継を行う +#[derive(Default)] +pub struct Relay(Option); + +impl Relay { + pub fn state(&self) -> Option<&SpectatorHostState> { + self.0.as_ref() + } + + pub fn start_if_delegated(&mut self, props: &mut SpectatorSessionProps) { + if let Some(delegation) = props.relay_outbox_mut().take_delegation() { + let waiting = WaitingForSpectator::new(delegation.into_room()); + self.0 = Some(SpectatorHostState::new_relay(waiting)); + } + } + + /// 配下の観戦者の接続を受け付ける + pub fn accept_spectators( + &mut self, + props: &SpectatorSessionProps, + main_menu: Option<&MainMenu>, + th19: &Th19, + ) { + let (Some(state), Some(initial)) = (&mut self.0, props.spectator_initial()) else { + return; + }; + state.update(pushed_f1(th19.input_devices()), main_menu, th19, initial); + } + + /// 処理したメッセージを、処理した順に配下の観戦者へ送る + pub fn forward(&mut self, props: &mut SpectatorSessionProps) { + let messages = props.relay_outbox_mut().take_messages(); + let Some(state) = &mut self.0 else { + return; + }; + for msg in messages { + match msg { + SpectatorSessionMessage::InitGame(init) => state.start_game(init), + SpectatorSessionMessage::InitRound(init) => state.send_init_round(init), + SpectatorSessionMessage::Inputs(p1, p2) => state.send_inputs(p1, p2), + SpectatorSessionMessage::InitSpectator(_) + | SpectatorSessionMessage::DelegateSpectatorHost(_) => {} + } + } + } + + pub fn end_game(&mut self) { + if let Some(state) = &mut self.0 { + state.end_game(); + } + } +} diff --git a/crates/app/junowen/src/state/spectator_session/spectator_game.rs b/crates/app/junowen/src/state/spectator_session/spectator_game.rs index 2de52bb..5358eb8 100644 --- a/crates/app/junowen/src/state/spectator_session/spectator_game.rs +++ b/crates/app/junowen/src/state/spectator_session/spectator_game.rs @@ -5,7 +5,7 @@ use junowen_lib::{Th19, structs::input_devices::InputValue}; use crate::{present_skipper::PresentSkipper, session::spectator::SpectatorSession}; -use super::set_rand_seeds; +use super::game_initial::set_rand_seeds; /// 未処理の入力がこのフレーム数を超えている場合、早送りして追いつく const CATCH_UP_THRESHOLD: usize = 30;