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..0adc66a 100644 --- a/junowen-lib/src/connection/peer_connection.rs +++ b/junowen-lib/src/connection/peer_connection.rs @@ -77,7 +77,12 @@ 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 { @@ -130,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(PROTOCOL.to_owned()), + protocol: Some(protocol.to_owned()), ..Default::default() }), ) @@ -197,11 +203,15 @@ impl PeerConnection { Ok(()) } - pub async fn wait_for_open_data_channel(&mut self) -> Result { + /// 相手のプロトコルが `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; - 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..6e854de 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,10 +36,15 @@ 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 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?; @@ -60,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)) } } 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-lib/src/th19/structs/selection.rs b/junowen-lib/src/th19/structs/selection.rs index 638d11e..14b5423 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/junowen-lib/src/th19/structs/selection.rs @@ -3,16 +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], + _unknown3: [u8; 0x30], } #[derive(Clone, Copy, PartialEq)] 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..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,6 +138,9 @@ where hdestwindowoverride: HWND, pdirtyregion: *const RGNDATA, ) -> windows::core::Result<()> { + if self.delegate.should_skip_present() { + return Ok(()); + } self.delegate.on_before_present(&self.instance); unsafe { 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/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/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/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/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/present_skipper.rs b/junowen/src/present_skipper.rs new file mode 100644 index 0000000..2185342 --- /dev/null +++ b/junowen/src/present_skipper.rs @@ -0,0 +1,42 @@ +//! 観戦者がホストに追いつくまでの早送り中は、画面の表示 (`Present`) を間引く +//! +//! 試合中は、ゲームの no wait 設定を有効にしても処理が速くならない。 +//! 表示のたびに垂直同期を待つため、1 秒あたり 60 フレームに抑えられていると考えられる。 +//! そこで表示を間引き、この待ちを減らす。 + +use std::sync::atomic::{AtomicU32, Ordering}; + +/// 早送りを要求されてから、表示を間引き続けるフレーム数。 +/// 要求が途絶えたら (セッションが終了した場合など) すぐに通常の表示に戻す +const SKIP_FRAMES_PER_REQUEST: u32 = 2; +/// 間引いている間も、このフレーム数ごとに1回は表示する +const PRESENT_INTERVAL: u32 = 8; + +#[derive(Default)] +pub struct PresentSkipper { + remaining_frames: AtomicU32, + counter: AtomicU32, +} + +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) + } +} 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..ec1245b 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; @@ -8,33 +11,10 @@ use junowen_lib::{ 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, - Game, -} - -#[derive(new, Clone, Debug, Deserialize, CopyGetters, Serialize)] -pub struct InitialState { - #[get_copy = "pub"] - screen: Screen, - #[get_copy = "pub"] - difficulty: u8, - #[get_copy = "pub"] - p1_character: u8, - #[get_copy = "pub"] - p1_card: u8, - #[get_copy = "pub"] - p2_character: u8, - #[get_copy = "pub"] - p2_card: u8, -} - #[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] pub struct SpectatorInitial { #[get = "pub"] @@ -43,13 +23,33 @@ pub struct SpectatorInitial { p2_name: String, #[get = "pub"] game_settings: GameSettings, +} + +#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, PartialEq, Serialize)] +pub struct PlayerGameInitial { + #[get_copy = "pub"] + character: u8, + #[get_copy = "pub"] + card: u8, +} + +/// 試合開始時の状態。観戦者はこれに合わせてキャラクターとカードを決定する +#[derive(new, Clone, Debug, Deserialize, CopyGetters, Getters, Serialize)] +pub struct GameInitial { + #[get_copy = "pub"] + difficulty: u8, + #[get_copy = "pub"] + p1: PlayerGameInitial, + #[get_copy = "pub"] + p2: PlayerGameInitial, #[get = "pub"] - initial_state: InitialState, + round_initial: RoundInitial, } #[derive(Debug, Deserialize, Serialize)] pub enum SpectatorSessionMessage { InitSpectator(SpectatorInitial), + InitGame(GameInitial), InitRound(RoundInitial), Inputs(u16, u16), } @@ -58,6 +58,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 +72,8 @@ impl SpectatorSession { Self { _conn: conn, hook_incoming_rx, + buffer: VecDeque::new(), + disconnected: false, spectator_initial: None, round_initial: None, } @@ -78,8 +83,47 @@ 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 poll_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,20 +131,66 @@ impl SpectatorSession { } }; self.spectator_initial = Some(init); - Ok(()) + Ok(true) + } + + /// 観戦者の試合中に次の試合が始まったかどうか + /// + /// 観戦者の試合がホストとずれた場合に起こる。この場合は今の試合を打ち切り、次の試合から同期し直す + /// + /// 試合中に受け取った `InitGame` はバッファの先頭に戻しておき、次の試合の待機状態で受け取る + pub fn has_next_game_initial(&self) -> bool { + matches!( + self.buffer.front(), + Some(SpectatorSessionMessage::InitGame(_)) + ) + } + + /// 前の試合で残ったラウンドの初期化情報を破棄する。試合開始を待つ状態に入るときに呼ぶ + pub fn discard_round_initial(&mut self) { + self.round_initial = None; } - pub fn dequeue_init_round(&mut self) -> Result { + /// 試合開始時の状態をブロックせずに受信する + /// + /// 観戦者の試合がホストより早く終わった場合などに残った入力は読み捨てる + pub fn try_recv_init_game(&mut self) -> Result, RecvError> { + 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) + } + + /// 次の試合が始まっている場合は `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)); } loop { - match self.hook_incoming_rx.recv()? { + match self.next_message()? { SpectatorSessionMessage::InitSpectator(init) => { error!("unexpected init spectator message: {:?}", init); return Err(RecvError); } - SpectatorSessionMessage::InitRound(round_initial) => return Ok(round_initial), + msg @ SpectatorSessionMessage::InitGame(_) => { + warn!("next game started during round over"); + self.buffer.push_front(msg); + return Ok(None); + } + SpectatorSessionMessage::InitRound(round_initial) => { + return Ok(Some(round_initial)); + } SpectatorSessionMessage::Inputs(..) => continue, } } @@ -110,11 +200,16 @@ 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) } + msg @ SpectatorSessionMessage::InitGame(_) => { + warn!("next game started during game"); + self.buffer.push_front(msg); + Ok((0, 0)) + } 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/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 345b096..3830336 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs @@ -4,11 +4,14 @@ 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, ScreenId}, + structs::app::MainMenu, }; use tokio::sync::mpsc::{self, error::TryRecvError}; use tracing::info; @@ -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() @@ -91,13 +94,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, - ); + // 観戦者の受け付けはメニュー画面 (難易度選択・キャラクター選択) で行う。 + // 観戦者は次の試合の開始 (`SpectatorHostState::start_game`) から合流する + self.set_ready(main_menu.is_some()); match self { Self::Standby { pushed, .. } => { 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/battle_session.rs b/junowen/src/state/battle_session.rs index e72ae37..d69ecf1 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)) } @@ -100,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/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 fb4afc9..fd63ffe 100644 --- a/junowen/src/state/battle_session/battle_select.rs +++ b/junowen/src/state/battle_session/battle_select.rs @@ -59,13 +59,14 @@ 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 { 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) @@ -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(f1_pushed, Some(main_menu), th19, session); Ok(()) } @@ -109,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); Ok(()) } diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs index 7835082..ecb87b8 100644 --- a/junowen/src/state/battle_session/spectator_host.rs +++ b/junowen/src/state/battle_session/spectator_host.rs @@ -1,9 +1,8 @@ -use anyhow::{Result, bail}; +use anyhow::Result; use getset::Getters; use junowen_lib::{ Th19, - structs::app::{MainMenu, ScreenId}, - structs::selection::Selection, + structs::{app::MainMenu, selection::Player}, }; use tracing::info; @@ -11,27 +10,28 @@ use crate::{ session::{ RoundInitial, battle::BattleSession, - spectator::{self, InitialState, SpectatorInitial}, + spectator::{GameInitial, PlayerGameInitial, SpectatorInitial}, 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() +fn current_round_initial(th19: &Th19) -> RoundInitial { + RoundInitial { + seed1: th19.rand_seed1().unwrap(), + seed2: th19.rand_seed2().unwrap(), + seed3: th19.rand_seed3().unwrap(), + seed4: th19.rand_seed4().unwrap(), + } +} + +fn create_spectator_initial(battle_session: &BattleSession, th19: &Th19) -> SpectatorInitial { + let local_player_name = th19.vs_mode().player_name().to_string(); + let remote_player_name = battle_session.remote_player_name().clone(); + let (p1_name, p2_name) = if battle_session.host() { + (local_player_name, remote_player_name) } else { - local_player_name.to_owned() + (remote_player_name, local_player_name) }; SpectatorInitial::new( p1_name, @@ -42,115 +42,158 @@ fn create_spectator_initial( .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, - ), ) } +/// 進行中の試合の記録。試合の途中で接続した観戦者は、これを受け取って試合の最初から早送りで追いかける +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"] waiting: WaitingForSpectator, + /// 次の試合の開始を待っている観戦者 + standby_sessions: Vec, + /// 試合を観戦している観戦者 sessions: Vec, + /// 試合中のみ `Some`。記録は1試合分で、試合ごとに作り直す + game_log: Option, } impl SpectatorHostState { pub fn new(waiting: WaitingForSpectator) -> Self { Self { waiting, + standby_sessions: Vec::new(), sessions: Vec::new(), + game_log: 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) { - 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(), - }) { - info!("spectator host error: {:?}", err); - false - } else { - true - } - }); - } - - fn init_session( - &self, - session: &SpectatorHostSession, - battle_session: &BattleSession, + /// 観戦者の接続を受け付ける + pub fn update( + &mut self, + pushed: bool, main_menu: Option<&MainMenu>, th19: &Th19, - ) -> Result<()> { - let Some(main_menu) = main_menu else { - bail!("spectator not supported yet."); + battle_session: &BattleSession, + ) { + let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) else { + return; }; - let vs_mode = th19.vs_mode(); - if main_menu.screen_id() != ScreenId::DifficultySelect - || vs_mode.p1_card() != 0 - || vs_mode.p2_card() != 0 + if let Err(err) = + session.send_init_spectator(create_spectator_initial(battle_session, th19)) { - bail!( - "spectator not supported yet. screen={:?}, p1_card={}, p2_card={}", - main_menu.screen_id(), - vs_mode.p1_card(), - vs_mode.p2_card() - ); + 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(()) + 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); } - pub fn update( + fn broadcast( &mut self, - pushed: bool, - main_menu: Option<&MainMenu>, - th19: &Th19, - battle_session: &BattleSession, - p1_input: u16, - p2_input: u16, + msg: GameLogMessage, + send: impl Fn(&SpectatorHostSession) -> Result<()>, ) { - 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); + 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); } + } + + /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ + 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_inputs(p1_input, p2_input) { + if let Err(err) = session.send_init_game(init.clone()) { info!("spectator host error: {:?}", err); return false; } 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.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.broadcast(GameLogMessage::Inputs(p1_input, p2_input), |session| { + session.send_inputs(p1_input, p2_input) + }); } } 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/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 42b0c75..b9018cd 100644 --- a/junowen/src/state/spectator_session.rs +++ b/junowen/src/state/spectator_session.rs @@ -1,6 +1,6 @@ mod in_session; mod spectator_game; -mod spectator_select; +mod spectator_standby; use std::{ffi::c_void, sync::mpsc::RecvError}; @@ -13,22 +13,69 @@ use junowen_lib::{ input_devices::InputFlags, }, }; +use tracing::warn; -use crate::session::spectator::SpectatorSession as SpectatorSessionProps; +use crate::{ + helper::pushed_escape, + present_skipper::PresentSkipper, + session::{ + RoundInitial, + spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, + }, +}; use super::prepare::Prepare; -use {spectator_game::SpectatorGame, spectator_select::SpectatorSelect}; +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(); + th19.set_rand_seed2(round_initial.seed2).unwrap(); + th19.set_rand_seed3(round_initial.seed3).unwrap(); + th19.set_rand_seed4(round_initial.seed4).unwrap(); +} + +/// 観戦者の試合開始時の状態がホストと一致しているか確認する +fn verify_game_initial(th19: &Th19, init: &GameInitial) -> bool { + let selection = th19.selection(); + let actual = ( + selection.difficulty as u8, + selection.p1().character as u8, + selection.p1().card as u8, + selection.p2().character as u8, + selection.p2().card as u8, + ); + let expected = ( + init.difficulty(), + init.p1().character(), + init.p1().card(), + init.p2().character(), + init.p2().card(), + ); + if actual != expected { + warn!( + "game initial mismatch. expected={:?}, actual={:?}", + expected, actual + ); + return false; + } + true +} pub struct SpectatorSession { props: SpectatorSessionProps, + present_skipper: PresentSkipper, state: SpectatorSessionState, } enum SpectatorSessionState { Prepare(Prepare), - Select(SpectatorSelect), - GameLoading, + Standby(SpectatorStandby), + /// 最初のフレームで乱数シードを設定する + GameLoading(Option), Game(SpectatorGame), BackToSelect, } @@ -37,19 +84,28 @@ 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()) } - pub fn change_to_select(&mut self) { - self.state = SpectatorSessionState::Select(SpectatorSelect::new()); + 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) { - 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); @@ -65,28 +121,34 @@ impl SpectatorSession { return Some(None); }; if prepare.update_state(main_menu, th19) { - self.change_to_select(); + self.change_to_standby(true); } Some(Some(main_menu)) } - SpectatorSessionState::Select { .. } => { - let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); + SpectatorSessionState::Standby(standby) => { + // 自動で入力した 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()?; match main_menu.screen_id() { ScreenId::PlayerMatchupSelect => None, - ScreenId::CharacterSelect => { - if th19.input_devices().p1_input().current().0 & InputFlags::PAUSE != None { + ScreenId::GameLoading => { + let init = standby.take_game_initial(); + if let Some(init) = &init + && !verify_game_initial(th19, init) + { + // ずれた試合を見せ続けないよう、観戦を終了する return None; } - Some(Some(main_menu)) - } - ScreenId::GameLoading => { - self.change_to_game_loading(); + 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); }; @@ -100,20 +162,26 @@ 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); } 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(); + self.change_to_standby(false); Some(Some(main_menu)) } } @@ -126,16 +194,19 @@ impl SpectatorSession { ) -> Result<(), RecvError> { match &mut self.state { SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_players(th19), - 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 { .. } => { - if th19.no_wait() { - th19.set_no_wait(false); + SpectatorSessionState::GameLoading(round_initial) => { + if let Some(round_initial) = round_initial.take() { + set_rand_seeds(th19, &round_initial); } + 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 { .. } => {} + SpectatorSessionState::BackToSelect => {} } Ok(()) } @@ -143,7 +214,7 @@ 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) => { + SpectatorSessionState::Standby(standby) => { let main_menu = th19 .app_mut() .main_loop_tasks_mut() @@ -154,17 +225,24 @@ impl SpectatorSession { { return Ok(false); } - select.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; + standby.update_th19_on_input_menu(&mut self.props, main_menu, th19)?; } - SpectatorSessionState::GameLoading { .. } => {} + SpectatorSessionState::GameLoading(_) => {} SpectatorSessionState::Game { .. } => {} - SpectatorSessionState::BackToSelect { .. } => {} + SpectatorSessionState::BackToSelect => {} } 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 waiting_for_host { + in_session::on_render_texts_waiting_for_host(th19, text_renderer); + } return; }; in_session::on_render_texts_spectator( @@ -172,6 +250,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 252aca9..c1963fd 100644 --- a/junowen/src/state/spectator_session/in_session.rs +++ b/junowen/src/state/spectator_session/in_session.rs @@ -9,7 +9,17 @@ 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) { + 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..2de52bb 100644 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ b/junowen/src/state/spectator_session/spectator_game.rs @@ -3,7 +3,34 @@ use std::sync::mpsc::RecvError; use anyhow::Result; use junowen_lib::{Th19, structs::input_devices::InputValue}; -use crate::session::spectator::SpectatorSession; +use crate::{present_skipper::PresentSkipper, session::spectator::SpectatorSession}; + +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, + 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(); + if before != no_wait { + th19.set_no_wait(no_wait); + } + if no_wait { + // 試合中は no wait が効かないため、表示を間引いて垂直同期の待ちをなくす + present_skipper.request_skip(); + } +} pub struct SpectatorGame; @@ -11,6 +38,7 @@ impl SpectatorGame { pub fn update_th19( &mut self, session: &mut SpectatorSession, + present_skipper: &PresentSkipper, th19: &mut Th19, ) -> Result<(), RecvError> { // -1フレーム目、0フレーム目は複数回呼ばれ、回数が不定なのでスキップする @@ -24,6 +52,7 @@ impl SpectatorGame { .set_current(InputValue::empty()); return Ok(()); } + 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 @@ -40,11 +69,9 @@ impl SpectatorGame { session: &mut SpectatorSession, 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(); + if let Some(init) = session.dequeue_init_round()? { + 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 deleted file mode 100644 index c2dd359..0000000 --- a/junowen/src/state/spectator_session/spectator_select.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::sync::mpsc::RecvError; - -use anyhow::Result; -use derive_new::new; -use getset::{Getters, MutGetters}; -use junowen_lib::{ - Th19, - structs::{ - app::{MainMenu, ScreenId}, - input_devices::InputValue, - }, - th19_helpers::reset_cursors, -}; -use tracing::trace; - -use crate::session::spectator::{self, SpectatorSession}; - -#[derive(new, Getters, MutGetters)] -pub struct SpectatorSelect { - #[new(value = "0")] - initializing_state: u8, -} - -impl SpectatorSelect { - pub fn update_th19_on_input_players( - &mut self, - session: &mut SpectatorSession, - main_menu: &MainMenu, - th19: &mut Th19, - ) -> Result<(), RecvError> { - if self.initializing_state == 0 { - if session.spectator_initial().is_none() { - self.initializing_state = 1; - reset_cursors(th19); - session.recv_init_spectator()?; - } else { - self.initializing_state = 2; - } - 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(); - } - 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() { - spectator::Screen::DifficultySelect => { - return Ok(()); - } - spectator::Screen::CharacterSelect => unimplemented!(), - spectator::Screen::Game => unimplemented!(), - } - } - 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 menu = main_menu.menu_mut(); - if self.initializing_state == 1 { - let init = session.spectator_initial().unwrap(); - trace!("spectator_initial: {:?}", init); - let initial_state = init.initial_state(); - 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; - } - spectator::Screen::CharacterSelect => unimplemented!(), - spectator::Screen::Game => unimplemented!(), - } - } - - 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..abba666 --- /dev/null +++ b/junowen/src/state/spectator_session/spectator_standby.rs @@ -0,0 +1,198 @@ +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, warn}; + +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; +} + +/// 試合開始時の状態を受け取ってから、試合開始までに許容するフレーム数 +/// +/// 状態を合わせられない場合 (カーソルの値とキャラクター番号の対応が想定と違う場合など) に諦める +const MAX_SYNC_FRAMES: u32 = 60 * 60; + +/// 難易度選択画面またはキャラクター選択画面で、ホストの試合開始を待つ +/// +/// 試合開始時の状態を受け取ったら、難易度・キャラクター・カードを合わせ、 +/// 一致していることを確認してから両プレイヤーを決定させる +pub struct SpectatorStandby { + cursors_reset: bool, + game_initial: Option, + sync_frames: u32, + /// 直前のフレームで PAUSE を自動で入力したか。観戦者自身の PAUSE と区別するために使う + pause_injected: bool, +} + +impl SpectatorStandby { + /// `first_time` は観戦を開始した直後かどうか + pub fn new(first_time: bool) -> Self { + 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() + } + + /// 試合開始時の状態を返す。読み込み画面に移ったときに取り出す + 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); + } + 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"); + // NOTE: 呼び出し側はエラーでセッションを中断するため、`RecvError` を中断の合図として流用している + return Err(RecvError); + } + 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 { + self.pause_injected = true; + 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(()) + } +}