From 494500003978d1ca8b18709abeb941df68de1669 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 23:34:03 +0000 Subject: [PATCH 01/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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