diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d63053d..272d3ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,11 +39,15 @@ jobs: target/i686-pc-windows-msvc/release/junowen-standalone.exe \ target/i686-pc-windows-msvc/release/junowen.exe cargo build --locked --release --workspace + cargo build --locked --release --package junowen --example mock_client cargo bundle-licenses --format yaml --output THIRDPARTY.yml - mkdir --parents artifact/modules + mkdir --parents artifact/modules artifact/examples mv \ target/i686-pc-windows-msvc/release/th19_*.dll \ artifact/modules/ + mv \ + target/i686-pc-windows-msvc/release/examples/mock_client.exe \ + artifact/examples/ mv \ target/i686-pc-windows-msvc/release/*.dll \ target/i686-pc-windows-msvc/release/*.exe \ diff --git a/Cargo.lock b/Cargo.lock index ce85850..cde6885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2021,7 +2021,9 @@ dependencies = [ "clipboard-win", "derive-new", "getset", + "junowen-direct3d", "junowen-lib", + "junowen-lobby", "reqwest", "rmp-serde", "rust-i18n", @@ -2042,6 +2044,14 @@ dependencies = [ "windows-core 0.59.0", ] +[[package]] +name = "junowen-direct3d" +version = "1.2.0" +dependencies = [ + "windows 0.59.0", + "windows-core 0.59.0", +] + [[package]] name = "junowen-lib" version = "1.2.0" @@ -2074,6 +2084,15 @@ dependencies = [ "windows 0.59.0", ] +[[package]] +name = "junowen-lobby" +version = "1.2.0" +dependencies = [ + "derive-new", + "getset", + "windows 0.59.0", +] + [[package]] name = "junowen-server" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 39ad324..4f8bf48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,14 @@ members = [ "archives/th19replayrecorder", "archives/th19savesettingsseparately", "archives/th19seed", - "junowen", - "junowen-lib", - "junowen-server", - "th19loader", + "crates/app/junowen", + "crates/app/junowen-server", + "crates/app/th19loader", + "crates/features/junowen-direct3d", + "crates/features/junowen-lib", + "crates/features/junowen-lobby", ] -default-members = ["junowen"] +default-members = ["crates/app/junowen"] resolver = "2" [workspace.package] @@ -26,7 +28,11 @@ anyhow = { version = "1.0.95", features = ["backtrace"] } async-trait = "0.1.85" bytes = "1.9.0" clipboard-win = "5.4.0" -junowen-lib = { path = "./junowen-lib" } +derive-new = "0.6.0" +getset = "0.1.2" +junowen-direct3d = { path = "./crates/features/junowen-direct3d" } +junowen-lib = { path = "./crates/features/junowen-lib" } +junowen-lobby = { path = "./crates/features/junowen-lobby" } rmp-serde = "1.3.0" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.135" diff --git a/junowen-server/Cargo.toml b/crates/app/junowen-server/Cargo.toml similarity index 100% rename from junowen-server/Cargo.toml rename to crates/app/junowen-server/Cargo.toml diff --git a/junowen-server/README.md b/crates/app/junowen-server/README.md similarity index 100% rename from junowen-server/README.md rename to crates/app/junowen-server/README.md diff --git a/junowen-server/docs/activity-diagrams.md b/crates/app/junowen-server/docs/activity-diagrams.md similarity index 100% rename from junowen-server/docs/activity-diagrams.md rename to crates/app/junowen-server/docs/activity-diagrams.md diff --git a/junowen-server/src/bin/access-stats/args.rs b/crates/app/junowen-server/src/bin/access-stats/args.rs similarity index 100% rename from junowen-server/src/bin/access-stats/args.rs rename to crates/app/junowen-server/src/bin/access-stats/args.rs diff --git a/junowen-server/src/bin/access-stats/cloudwatch.rs b/crates/app/junowen-server/src/bin/access-stats/cloudwatch.rs similarity index 100% rename from junowen-server/src/bin/access-stats/cloudwatch.rs rename to crates/app/junowen-server/src/bin/access-stats/cloudwatch.rs diff --git a/junowen-server/src/bin/access-stats/main.rs b/crates/app/junowen-server/src/bin/access-stats/main.rs similarity index 100% rename from junowen-server/src/bin/access-stats/main.rs rename to crates/app/junowen-server/src/bin/access-stats/main.rs diff --git a/junowen-server/src/bin/access-stats/output.rs b/crates/app/junowen-server/src/bin/access-stats/output.rs similarity index 100% rename from junowen-server/src/bin/access-stats/output.rs rename to crates/app/junowen-server/src/bin/access-stats/output.rs diff --git a/junowen-server/src/bin/access-stats/room_activity.rs b/crates/app/junowen-server/src/bin/access-stats/room_activity.rs similarity index 100% rename from junowen-server/src/bin/access-stats/room_activity.rs rename to crates/app/junowen-server/src/bin/access-stats/room_activity.rs diff --git a/junowen-server/src/database.rs b/crates/app/junowen-server/src/database.rs similarity index 100% rename from junowen-server/src/database.rs rename to crates/app/junowen-server/src/database.rs diff --git a/junowen-server/src/database/dynamodb.rs b/crates/app/junowen-server/src/database/dynamodb.rs similarity index 100% rename from junowen-server/src/database/dynamodb.rs rename to crates/app/junowen-server/src/database/dynamodb.rs diff --git a/junowen-server/src/database/dynamodb/reserved_room.rs b/crates/app/junowen-server/src/database/dynamodb/reserved_room.rs similarity index 100% rename from junowen-server/src/database/dynamodb/reserved_room.rs rename to crates/app/junowen-server/src/database/dynamodb/reserved_room.rs diff --git a/junowen-server/src/database/dynamodb/shared_room.rs b/crates/app/junowen-server/src/database/dynamodb/shared_room.rs similarity index 100% rename from junowen-server/src/database/dynamodb/shared_room.rs rename to crates/app/junowen-server/src/database/dynamodb/shared_room.rs diff --git a/junowen-server/src/database/file.rs b/crates/app/junowen-server/src/database/file.rs similarity index 100% rename from junowen-server/src/database/file.rs rename to crates/app/junowen-server/src/database/file.rs diff --git a/junowen-server/src/main.rs b/crates/app/junowen-server/src/main.rs similarity index 100% rename from junowen-server/src/main.rs rename to crates/app/junowen-server/src/main.rs diff --git a/junowen-server/src/routes.rs b/crates/app/junowen-server/src/routes.rs similarity index 100% rename from junowen-server/src/routes.rs rename to crates/app/junowen-server/src/routes.rs diff --git a/junowen-server/src/routes/custom.rs b/crates/app/junowen-server/src/routes/custom.rs similarity index 100% rename from junowen-server/src/routes/custom.rs rename to crates/app/junowen-server/src/routes/custom.rs diff --git a/junowen-server/src/routes/reserved_room.rs b/crates/app/junowen-server/src/routes/reserved_room.rs similarity index 100% rename from junowen-server/src/routes/reserved_room.rs rename to crates/app/junowen-server/src/routes/reserved_room.rs diff --git a/junowen-server/src/routes/reserved_room/create.rs b/crates/app/junowen-server/src/routes/reserved_room/create.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/create.rs rename to crates/app/junowen-server/src/routes/reserved_room/create.rs diff --git a/junowen-server/src/routes/reserved_room/delete.rs b/crates/app/junowen-server/src/routes/reserved_room/delete.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/delete.rs rename to crates/app/junowen-server/src/routes/reserved_room/delete.rs diff --git a/junowen-server/src/routes/reserved_room/read.rs b/crates/app/junowen-server/src/routes/reserved_room/read.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/read.rs rename to crates/app/junowen-server/src/routes/reserved_room/read.rs diff --git a/junowen-server/src/routes/reserved_room/update.rs b/crates/app/junowen-server/src/routes/reserved_room/update.rs similarity index 100% rename from junowen-server/src/routes/reserved_room/update.rs rename to crates/app/junowen-server/src/routes/reserved_room/update.rs diff --git a/junowen-server/src/routes/room_utils.rs b/crates/app/junowen-server/src/routes/room_utils.rs similarity index 100% rename from junowen-server/src/routes/room_utils.rs rename to crates/app/junowen-server/src/routes/room_utils.rs diff --git a/junowen-server/src/tracing_helper.rs b/crates/app/junowen-server/src/tracing_helper.rs similarity index 100% rename from junowen-server/src/tracing_helper.rs rename to crates/app/junowen-server/src/tracing_helper.rs diff --git a/junowen/Cargo.toml b/crates/app/junowen/Cargo.toml similarity index 88% rename from junowen/Cargo.toml rename to crates/app/junowen/Cargo.toml index c2c61bd..affff3f 100644 --- a/junowen/Cargo.toml +++ b/crates/app/junowen/Cargo.toml @@ -20,9 +20,11 @@ anyhow.workspace = true async-trait.workspace = true bytes.workspace = true clipboard-win.workspace = true -derive-new = "0.6.0" -getset = "0.1.2" +derive-new.workspace = true +getset.workspace = true +junowen-direct3d.workspace = true junowen-lib.workspace = true +junowen-lobby.workspace = true reqwest = { version = "0.12.7", features = ["json"] } rmp-serde.workspace = true rust-i18n = "3.1.2" diff --git a/junowen/build.rs b/crates/app/junowen/build.rs similarity index 100% rename from junowen/build.rs rename to crates/app/junowen/build.rs diff --git a/crates/app/junowen/examples/mock_client.rs b/crates/app/junowen/examples/mock_client.rs new file mode 100644 index 0000000..577e1c6 --- /dev/null +++ b/crates/app/junowen/examples/mock_client.rs @@ -0,0 +1,336 @@ +//! ゲームを起動せずに対戦相手や観戦者として振る舞う、テスト用のクライアント +//! +//! ```sh +//! cargo run -p junowen --example mock_client -- [--name ] +//! ``` +//! +//! - `host`: Pure P2P の「Connect as a Host」として振る舞う。ゲーム側は「Connect as a Guest」で接続する +//! - `guest`: Pure P2P の「Connect as a Guest」として振る舞う。ゲーム側は「Connect as a Host」で接続する +//! - `spectator`: Pure P2P の「Connect as a Spectator」として振る舞う。ゲーム側は対戦中に F1 で受け入れる +//! +//! 対戦者は、受け取った相手の入力をそのまま自分の入力として送り返す(ミラー入力)。 +//! ホストとして送る試合設定は `GameSettings` の既定値で、乱数シードはラウンドごとに現在時刻から作る。 +//! +//! シグナリングコードはクリップボードにコピーする。相手のシグナリングコードは、 +//! 待ち始めた後にクリップボードにコピーされたものか、標準入力に貼り付けたものを使う。 + +// junowen は cdylib なので直接参照できない。`#[path]` で session.rs を直接読み込むと +// 子モジュールが src/ 直下から探されるため、inline モジュールで囲んで src/session/ から読み込ませる +#[path = "../src"] +mod app { + #[allow(dead_code)] + pub mod session; +} + +use std::{ + env, io, + sync::mpsc::{self, TryRecvError}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Result, bail}; +use bytes::Bytes; +use clipboard_win::{get_clipboard_string, seq_num, set_clipboard_string}; +use junowen_lib::{ + connection::{ + BATTLE_PROTOCOL, DataChannel, PeerConnection, SPECTATOR_PROTOCOL, + signaling::{ + CompressedSdp, SignalingCodeType, parse_signaling_code, socket::DEFAULT_STUN_SERVER_URL, + }, + }, + structs::settings::GameSettings, +}; +use serde::Serialize; +use tracing_subscriber::EnvFilter; + +use app::session::{ + MatchInitial, RoundInitial, session_message::SessionMessage, spectator::SpectatorSessionMessage, +}; + +const TIMEOUT: Duration = Duration::from_secs(20 * 60); +/// 試合中に経過を表示する間隔(フレーム数) +const PROGRESS_INTERVAL: u32 = 600; + +#[derive(Clone, Copy)] +enum Role { + Host, + Guest, + Spectator, +} + +struct Args { + role: Role, + name: String, +} + +fn parse_args() -> Result { + let mut role = None; + let mut name = "Mock".to_owned(); + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "host" => role = Some(Role::Host), + "guest" => role = Some(Role::Guest), + "spectator" => role = Some(Role::Spectator), + "--name" => { + let Some(value) = args.next() else { + bail!("--name requires a value"); + }; + name = value; + } + _ => bail!("unknown argument: {}", arg), + } + } + let Some(role) = role else { + bail!("usage: mock_client [--name ]"); + }; + Ok(Args { role, name }) +} + +fn spawn_stdin_reader() -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + for line in io::stdin().lines() { + let Ok(line) = line else { + return; + }; + if tx.send(line).is_err() { + return; + } + } + }); + rx +} + +fn print_and_copy_code(code_type: SignalingCodeType, desc: &CompressedSdp) { + let code = code_type.to_string(desc); + println!(); + println!("{}", code); + println!(); + match set_clipboard_string(&code) { + Ok(()) => println!("Your signaling code has been copied to the clipboard."), + Err(err) => println!("Failed to copy to the clipboard: {}", err), + } +} + +/// 相手のシグナリングコードを、クリップボードか標準入力から受け取る +/// +/// 前回の接続のコードを使わないように、待ち始めた後にコピーされたものだけを使う +async fn wait_for_code( + code_type: SignalingCodeType, + stdin_rx: &mpsc::Receiver, +) -> Result { + println!("Waiting for the opponent's signaling code (copy it or paste it here)..."); + let mut clipboard_seq_num = seq_num(); + loop { + match stdin_rx.try_recv() { + Ok(line) if !line.trim().is_empty() => match parse_signaling_code(&line) { + Ok((t, desc)) if t == code_type => return Ok(desc), + Ok(_) => println!("Unexpected type of signaling code."), + Err(err) => println!("Invalid signaling code: {}", err), + }, + Ok(_) | Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => bail!("stdin closed"), + } + let current_seq_num = seq_num(); + if current_seq_num != clipboard_seq_num { + clipboard_seq_num = current_seq_num; + if let Ok(clipboard) = get_clipboard_string() + && let Ok((t, desc)) = parse_signaling_code(&clipboard) + && t == code_type + { + return Ok(desc); + } + } + tokio::time::sleep(Duration::from_millis(300)).await; + } +} + +async fn connect_as_offerer( + offer_type: SignalingCodeType, + answer_type: SignalingCodeType, + protocol: &'static str, + stdin_rx: &mpsc::Receiver, +) -> Result<(PeerConnection, DataChannel)> { + let mut conn = PeerConnection::new(TIMEOUT, vec![DEFAULT_STUN_SERVER_URL.to_owned()]).await?; + let offer = conn.start_as_offerer(protocol).await?; + print_and_copy_code(offer_type, &offer); + let answer = wait_for_code(answer_type, stdin_rx).await?; + conn.set_answer_desc(answer).await?; + println!("Waiting for connection..."); + let data_channel = conn.wait_for_open_data_channel(protocol).await?; + Ok((conn, data_channel)) +} + +async fn connect_as_answerer( + offer_type: SignalingCodeType, + answer_type: SignalingCodeType, + protocol: &'static str, + stdin_rx: &mpsc::Receiver, +) -> Result<(PeerConnection, DataChannel)> { + let offer = wait_for_code(offer_type, stdin_rx).await?; + let mut conn = PeerConnection::new(TIMEOUT, vec![DEFAULT_STUN_SERVER_URL.to_owned()]).await?; + let answer = conn.start_as_answerer(offer).await?; + print_and_copy_code(answer_type, &answer); + println!("Waiting for connection..."); + let data_channel = conn.wait_for_open_data_channel(protocol).await?; + Ok((conn, data_channel)) +} + +fn random_round_initial() -> RoundInitial { + let mut x = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_nanos() + | 1; + let mut next = || { + // xorshift32 + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + RoundInitial { + seed1: next(), + seed2: next(), + seed3: next(), + seed4: next(), + } +} + +async fn send(data_channel: &DataChannel, msg: &T) -> Result<()> { + let data = Bytes::from(rmp_serde::to_vec(msg)?); + data_channel.message_sender.send(data).await?; + Ok(()) +} + +/// 相手のメッセージに応答して対戦を進める +/// +/// 相手の入力をそのまま送り返すので、両プレイヤーは同じフレームに同じ入力をする +async fn run_battle(mut data_channel: DataChannel, host: bool, name: &str) -> Result<()> { + let mut frames = 0; + while let Some(data) = data_channel.recv().await { + let reply = match rmp_serde::from_slice(&data)? { + SessionMessage::InitMatch((remote_name, init)) => { + println!("InitMatch: remote={:?}, initial={:?}", remote_name, init); + if host == init.is_some() { + println!("warning: the opponent has the same role"); + } + let init = host.then(|| MatchInitial { + game_settings: GameSettings::default(), + }); + SessionMessage::InitMatch((name.to_owned(), init)) + } + SessionMessage::InitRound(init) => { + println!("InitRound: frames={}, initial={:?}", frames, init); + if host == init.is_some() { + println!("warning: the opponent has the same role"); + } + frames = 0; + SessionMessage::InitRound(host.then(random_round_initial)) + } + SessionMessage::Delay(delay) => { + println!("Delay: {}", delay); + continue; + } + SessionMessage::Input(input) => { + frames += 1; + if frames % PROGRESS_INTERVAL == 0 { + println!("frames={}, input={:#06x}", frames, input); + } + SessionMessage::Input(input) + } + }; + send(&data_channel, &reply).await?; + } + println!("Disconnected."); + Ok(()) +} + +/// ホストから受け取ったメッセージを表示する +async fn run_spectator(mut data_channel: DataChannel) -> Result<()> { + let mut frames = 0; + while let Some(data) = data_channel.recv().await { + match rmp_serde::from_slice(&data)? { + SpectatorSessionMessage::InitSpectator(init) => { + println!( + "InitSpectator: p1={:?}, p2={:?}, settings={:?}", + init.p1_name(), + init.p2_name(), + init.game_settings() + ); + } + SpectatorSessionMessage::InitGame(init) => { + println!( + "InitGame: frames={}, difficulty={}, p1={:?}, p2={:?}, round={:?}", + frames, + init.difficulty(), + init.p1(), + init.p2(), + init.round_initial() + ); + frames = 0; + } + SpectatorSessionMessage::InitRound(init) => { + println!("InitRound: frames={}, initial={:?}", frames, init); + frames = 0; + } + SpectatorSessionMessage::Inputs(p1, p2) => { + frames += 1; + if frames % PROGRESS_INTERVAL == 0 { + println!("frames={}, p1={:#06x}, p2={:#06x}", frames, p1, p2); + } + } + } + } + println!("Disconnected."); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + let args = parse_args()?; + let stdin_rx = spawn_stdin_reader(); + + match args.role { + Role::Host => { + let (_conn, data_channel) = connect_as_offerer( + SignalingCodeType::BattleOffer, + SignalingCodeType::BattleAnswer, + BATTLE_PROTOCOL, + &stdin_rx, + ) + .await?; + println!("Connected."); + run_battle(data_channel, true, &args.name).await + } + Role::Guest => { + let (_conn, data_channel) = connect_as_answerer( + SignalingCodeType::BattleOffer, + SignalingCodeType::BattleAnswer, + BATTLE_PROTOCOL, + &stdin_rx, + ) + .await?; + println!("Connected."); + run_battle(data_channel, false, &args.name).await + } + Role::Spectator => { + let (_conn, data_channel) = connect_as_offerer( + SignalingCodeType::SpectatorOffer, + SignalingCodeType::SpectatorAnswer, + SPECTATOR_PROTOCOL, + &stdin_rx, + ) + .await?; + println!("Connected."); + run_spectator(data_channel).await + } + } +} diff --git a/junowen/locales/de.toml b/crates/app/junowen/locales/de.toml similarity index 100% rename from junowen/locales/de.toml rename to crates/app/junowen/locales/de.toml diff --git a/junowen/locales/en.toml b/crates/app/junowen/locales/en.toml similarity index 100% rename from junowen/locales/en.toml rename to crates/app/junowen/locales/en.toml diff --git a/junowen/locales/eo.toml b/crates/app/junowen/locales/eo.toml similarity index 100% rename from junowen/locales/eo.toml rename to crates/app/junowen/locales/eo.toml diff --git a/junowen/locales/es-ES.toml b/crates/app/junowen/locales/es-ES.toml similarity index 100% rename from junowen/locales/es-ES.toml rename to crates/app/junowen/locales/es-ES.toml diff --git a/junowen/locales/es.toml b/crates/app/junowen/locales/es.toml similarity index 100% rename from junowen/locales/es.toml rename to crates/app/junowen/locales/es.toml diff --git a/junowen/locales/fr.toml b/crates/app/junowen/locales/fr.toml similarity index 100% rename from junowen/locales/fr.toml rename to crates/app/junowen/locales/fr.toml diff --git a/junowen/locales/it.toml b/crates/app/junowen/locales/it.toml similarity index 100% rename from junowen/locales/it.toml rename to crates/app/junowen/locales/it.toml diff --git a/junowen/locales/ja.toml b/crates/app/junowen/locales/ja.toml similarity index 100% rename from junowen/locales/ja.toml rename to crates/app/junowen/locales/ja.toml diff --git a/junowen/locales/ko.toml b/crates/app/junowen/locales/ko.toml similarity index 100% rename from junowen/locales/ko.toml rename to crates/app/junowen/locales/ko.toml diff --git a/junowen/locales/pt-BR.toml b/crates/app/junowen/locales/pt-BR.toml similarity index 100% rename from junowen/locales/pt-BR.toml rename to crates/app/junowen/locales/pt-BR.toml diff --git a/junowen/locales/ru.toml b/crates/app/junowen/locales/ru.toml similarity index 100% rename from junowen/locales/ru.toml rename to crates/app/junowen/locales/ru.toml diff --git a/junowen/locales/zh-CN.toml b/crates/app/junowen/locales/zh-CN.toml similarity index 100% rename from junowen/locales/zh-CN.toml rename to crates/app/junowen/locales/zh-CN.toml diff --git a/junowen/locales/zh-TW.toml b/crates/app/junowen/locales/zh-TW.toml similarity index 100% rename from junowen/locales/zh-TW.toml rename to crates/app/junowen/locales/zh-TW.toml diff --git a/junowen/src/bin/junowen-standalone.rs b/crates/app/junowen/src/bin/junowen-standalone.rs similarity index 100% rename from junowen/src/bin/junowen-standalone.rs rename to crates/app/junowen/src/bin/junowen-standalone.rs diff --git a/junowen/src/bin/lang/ja.toml b/crates/app/junowen/src/bin/lang/ja.toml similarity index 100% rename from junowen/src/bin/lang/ja.toml rename to crates/app/junowen/src/bin/lang/ja.toml diff --git a/junowen/src/bin/lang/mod.rs b/crates/app/junowen/src/bin/lang/mod.rs similarity index 100% rename from junowen/src/bin/lang/mod.rs rename to crates/app/junowen/src/bin/lang/mod.rs diff --git a/junowen/src/file.rs b/crates/app/junowen/src/file.rs similarity index 100% rename from junowen/src/file.rs rename to crates/app/junowen/src/file.rs diff --git a/junowen/src/helper.rs b/crates/app/junowen/src/helper.rs similarity index 72% rename from junowen/src/helper.rs rename to crates/app/junowen/src/helper.rs index 70e9a99..1fe5e14 100644 --- a/junowen/src/helper.rs +++ b/crates/app/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/crates/app/junowen/src/junowen.rs similarity index 95% rename from junowen/src/junowen.rs rename to crates/app/junowen/src/junowen.rs index 5de05ed..a17b7f9 100644 --- a/junowen/src/junowen.rs +++ b/crates/app/junowen/src/junowen.rs @@ -1,5 +1,6 @@ use std::ffi::c_void; +use junowen_direct3d::Direct3DDeviceEventListener; use junowen_lib::{ Th19, Th19EventListener, structs::{app::ScreenId, others::RenderingText}, @@ -7,7 +8,6 @@ use junowen_lib::{ use windows::Win32::Graphics::Direct3D9::IDirect3DDevice9; use crate::{ - custom_direct_3d::Direct3DDeviceEventListener, file::{Features, SettingsRepo}, lobby::{self, Lobby, TitleMenuModifier}, state::State, @@ -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/crates/app/junowen/src/lib.rs similarity index 98% rename from junowen/src/lib.rs rename to crates/app/junowen/src/lib.rs index 996bc61..81908b8 100644 --- a/junowen/src/lib.rs +++ b/crates/app/junowen/src/lib.rs @@ -1,8 +1,8 @@ -mod custom_direct_3d; mod file; mod helper; mod junowen; mod lobby; +mod present_skipper; mod session; mod signaling; mod state; @@ -12,7 +12,7 @@ rust_i18n::i18n!("locales", fallback = "en"); use std::{cell::OnceCell, mem::take, path::Path, ptr::null_mut, slice, sync::LazyLock}; -use custom_direct_3d::CustomDirect3D9; +use junowen_direct3d::CustomDirect3D9; use junowen_lib::{ Th19, Th19EventDispatcher, hook_utils::{WELL_KNOWN_VERSION_HASHES, calc_th19_hash, show_warn_dialog}, diff --git a/junowen/src/lobby.rs b/crates/app/junowen/src/lobby.rs similarity index 94% rename from junowen/src/lobby.rs rename to crates/app/junowen/src/lobby.rs index e64aa20..6b11756 100644 --- a/junowen/src/lobby.rs +++ b/crates/app/junowen/src/lobby.rs @@ -1,5 +1,4 @@ mod common_menu; -mod helper; mod overlay; mod pure_p2p_guest; mod pure_p2p_offerer; @@ -7,13 +6,13 @@ mod pure_p2p_scenes; mod room; mod root; mod signaling_code; -mod text_layout; mod title_menu_modifier; use std::ffi::c_void; use getset::{Getters, MutGetters}; use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lobby::overlay_description; use crate::{ file::SettingsRepo, @@ -24,8 +23,7 @@ use crate::{ }; use self::{ - common_menu::LobbyScene, - helper::overlay_description, + common_menu::{LobbyScene, MenuButtons, to_menu_buttons}, pure_p2p_scenes::PureP2pScenes, room::{reserved::ReservedRoom, shared::SharedRoom, tcp_signaling::TcpSignaling}, root::Root, @@ -44,7 +42,7 @@ pub struct Lobby { reserved_room: ReservedRoom, tcp_signaling: TcpSignaling, pure_p2p: PureP2pScenes, - prev_input: InputValue, + prev_input: MenuButtons, #[getset(get = "pub", get_mut = "pub")] waiting_for_match: Option, } @@ -61,17 +59,17 @@ impl Lobby { reserved_room: ReservedRoom::new(), tcp_signaling: TcpSignaling::new(), pure_p2p: PureP2pScenes::default(), - prev_input: InputValue::full(), + prev_input: to_menu_buttons(InputValue::full()), } } pub fn clear_input(&mut self) { - self.prev_input = InputValue::full(); + self.prev_input = to_menu_buttons(InputValue::full()); } pub fn on_input_menu(&mut self, th19: &mut Th19) { self.prev_scene = self.scene; - let current_input = th19.menu_input().current(); + let current_input = to_menu_buttons(th19.menu_input().current()); th19.menu_input_mut().set_current(InputValue::empty()); if let Some(scene) = match self.scene { @@ -134,7 +132,7 @@ impl Lobby { ), } { self.scene = scene; - self.prev_input = InputValue::full(); + self.prev_input = to_menu_buttons(InputValue::full()); } else { self.prev_input = current_input; } diff --git a/crates/app/junowen/src/lobby/common_menu.rs b/crates/app/junowen/src/lobby/common_menu.rs new file mode 100644 index 0000000..cef8e3d --- /dev/null +++ b/crates/app/junowen/src/lobby/common_menu.rs @@ -0,0 +1,68 @@ +use std::ffi::c_void; + +use junowen_lib::{ + Th19, + structs::{ + input_devices::{InputFlags, InputValue}, + others::RenderingText, + }, +}; +use junowen_lobby::{MenuDevice, MenuRenderer, MenuSound, MenuText}; + +pub use junowen_lobby::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, menu_item_color, + render_label_value, +}; + +pub fn to_menu_buttons(input: InputValue) -> MenuButtons { + let bits = |flags: &[InputFlags]| { + flags + .iter() + .enumerate() + .filter(|&(_, &flag)| input.0.contains(flag)) + .fold(0u8, |acc, (i, _)| acc | (1 << i)) + }; + MenuButtons { + up: input.0.contains(InputFlags::UP), + down: input.0.contains(InputFlags::DOWN), + decide: bits(&[InputFlags::SHOT, InputFlags::ENTER]), + cancel: bits(&[InputFlags::CHARGE, InputFlags::BOMB, InputFlags::PAUSE]), + } +} + +pub struct Th19MenuDevice<'a>(pub &'a Th19); + +impl MenuDevice for Th19MenuDevice<'_> { + fn play_sound(&self, sound: MenuSound) { + let id = match sound { + MenuSound::Cancel => 0x09, + MenuSound::Decide => 0x07, + MenuSound::Select => 0x0a, + }; + self.0.play_sound(self.0.sound_manager(), id, 0); + } + + fn keyboard_raw_keys(&self) -> &[u8; 256] { + self.0.input_devices().keyboard_input().raw_keys() + } +} + +pub struct Th19MenuRenderer<'a> { + pub th19: &'a Th19, + pub text_renderer: &'a c_void, +} + +impl MenuRenderer for Th19MenuRenderer<'_> { + fn render_text(&self, text: &MenuText) { + let mut rt = RenderingText::default(); + rt.set_text(text.text); + rt.set_x(text.x, self.th19.window_inner()); + rt.set_y(text.y, self.th19.window_inner()); + rt.color = text.color; + rt.font_type = text.font_type; + rt.drop_shadow = text.drop_shadow; + rt.horizontal_align = text.horizontal_align; + rt.vertical_align = text.vertical_align; + self.th19.render_text(self.text_renderer, &rt); + } +} diff --git a/junowen/src/lobby/overlay.rs b/crates/app/junowen/src/lobby/overlay.rs similarity index 93% rename from junowen/src/lobby/overlay.rs rename to crates/app/junowen/src/lobby/overlay.rs index 5159a41..9f605bc 100644 --- a/junowen/src/lobby/overlay.rs +++ b/crates/app/junowen/src/lobby/overlay.rs @@ -16,6 +16,8 @@ use windows_core::{HSTRING, w}; use super::Lobby; +pub use junowen_lobby::OverlayText; + /// 縁取りの太さと文字サイズの比 const OUTLINE_RATIO: f64 = 1.0 / 29.0; @@ -104,15 +106,6 @@ fn render_text_to_screen( unsafe { DrawTextW(hdc, &mut text, &mut rect, DT_LEFT) }; } -/// ゲーム内フォントでは英数字しか描画できないため、多言語のテキストは -/// GDI で画面に重ねて描画する。座標はゲーム内と同じ 1280x960 換算。 -pub struct OverlayText { - pub left: i32, - pub top: i32, - pub font_size: u32, - pub string: String, -} - pub struct Font { obj: HGDIOBJ, size: u32, diff --git a/junowen/src/lobby/pure_p2p_guest.rs b/crates/app/junowen/src/lobby/pure_p2p_guest.rs similarity index 86% rename from junowen/src/lobby/pure_p2p_guest.rs rename to crates/app/junowen/src/lobby/pure_p2p_guest.rs index 4c3d155..d1b8d66 100644 --- a/junowen/src/lobby/pure_p2p_guest.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_guest.rs @@ -3,11 +3,12 @@ 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, }; +use junowen_lobby::{TextLayout, signaling_code_line_count}; use rust_i18n::t; use tokio::sync::mpsc; @@ -15,11 +16,12 @@ use crate::session::battle::BattleSession; use super::{ super::signaling::Signaling, - common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, - helper::signaling_code_line_count, + common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + Th19MenuRenderer, + }, overlay::OverlayText, signaling_code::paste_signaling_code, - text_layout::TextLayout, }; pub struct PureP2pGuest { @@ -45,7 +47,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, @@ -55,8 +61,8 @@ impl PureP2pGuest { pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, session_rx: &mut Option>, ) -> Option { @@ -90,7 +96,7 @@ impl PureP2pGuest { } match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -165,8 +171,12 @@ impl PureP2pGuest { } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); - self.layout().render_codes(th19, text_renderer); + let renderer = Th19MenuRenderer { + th19, + text_renderer, + }; + self.common_menu.on_render_texts(&renderer); + self.layout().render_codes(&renderer); } pub fn overlay_texts(&self) -> Vec { diff --git a/junowen/src/lobby/pure_p2p_offerer.rs b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs similarity index 89% rename from junowen/src/lobby/pure_p2p_offerer.rs rename to crates/app/junowen/src/lobby/pure_p2p_offerer.rs index adb2e27..a0a2eef 100644 --- a/junowen/src/lobby/pure_p2p_offerer.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_offerer.rs @@ -1,4 +1,3 @@ -mod menu; mod role; use std::ffi::c_void; @@ -10,23 +9,22 @@ use junowen_lib::{ DataChannel, PeerConnection, signaling::{SignalingCodeType, socket::async_read_write_socket::SignalingServerMessage}, }, - structs::input_devices::InputValue, }; +use junowen_lobby::pure_p2p_offerer_menu::{ + COPY_ACTION_ID, PASTE_ACTION_ID, REGENERATE_ACTION_ID, make_empty_menu, make_menu, +}; +use junowen_lobby::{TextLayout, signaling_code_line_count}; use rust_i18n::t; use tokio::sync::mpsc; use tracing::trace; use super::{ super::signaling::Signaling, - common_menu::{CommonMenu, LobbyScene, OnMenuInputResult}, - helper::signaling_code_line_count, + common_menu::{ + CommonMenu, LobbyScene, MenuButtons, OnMenuInputResult, Th19MenuDevice, Th19MenuRenderer, + }, overlay::OverlayText, signaling_code::paste_signaling_code, - text_layout::TextLayout, -}; - -use self::menu::{ - COPY_ACTION_ID, PASTE_ACTION_ID, REGENERATE_ACTION_ID, make_empty_menu, make_menu, }; pub use self::role::{Messages, pure_p2p_host, pure_p2p_spectator}; @@ -35,6 +33,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 +51,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 +60,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, @@ -71,8 +72,8 @@ where pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, session_rx: &mut Option>, ) -> Option { @@ -89,7 +90,7 @@ where } match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -172,8 +173,12 @@ where } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); - self.layout().render_codes(th19, text_renderer); + let renderer = Th19MenuRenderer { + th19, + text_renderer, + }; + self.common_menu.on_render_texts(&renderer); + self.layout().render_codes(&renderer); } pub fn overlay_texts(&self) -> Vec { @@ -185,6 +190,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/crates/app/junowen/src/lobby/pure_p2p_offerer/role.rs similarity index 88% rename from junowen/src/lobby/pure_p2p_offerer/role.rs rename to crates/app/junowen/src/lobby/pure_p2p_offerer/role.rs index 10d902b..3b78649 100644 --- a/junowen/src/lobby/pure_p2p_offerer/role.rs +++ b/crates/app/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/lobby/pure_p2p_scenes.rs b/crates/app/junowen/src/lobby/pure_p2p_scenes.rs similarity index 96% rename from junowen/src/lobby/pure_p2p_scenes.rs rename to crates/app/junowen/src/lobby/pure_p2p_scenes.rs index a42e54b..6b42683 100644 --- a/junowen/src/lobby/pure_p2p_scenes.rs +++ b/crates/app/junowen/src/lobby/pure_p2p_scenes.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use crate::{ session::{battle::BattleSession, spectator::SpectatorSession}, @@ -10,7 +10,7 @@ use crate::{ }; use super::{ - common_menu::LobbyScene, + common_menu::{LobbyScene, MenuButtons}, overlay::OverlayText, pure_p2p_guest::PureP2pGuest, pure_p2p_offerer::{PureP2pOfferer, pure_p2p_host, pure_p2p_spectator}, @@ -28,8 +28,8 @@ impl PureP2pScenes { pub fn on_input_menu( &mut self, scene: LobbyScene, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting_for_match: &mut Option, ) -> Option { diff --git a/junowen/src/lobby/room.rs b/crates/app/junowen/src/lobby/room.rs similarity index 91% rename from junowen/src/lobby/room.rs rename to crates/app/junowen/src/lobby/room.rs index cbf2025..17accba 100644 --- a/junowen/src/lobby/room.rs +++ b/crates/app/junowen/src/lobby/room.rs @@ -5,13 +5,11 @@ pub mod tcp_signaling; use std::{f64::consts::PI, ffi::c_void}; use junowen_lib::{Th19, structs::others::RenderingText}; +use junowen_lobby::render_text_line; use crate::signaling::waiting_for_match::WaitingInRoom; -use super::{ - common_menu::CommonMenu, - helper::{render_label_value, render_text_line}, -}; +use super::common_menu::{CommonMenu, Th19MenuRenderer, render_label_value}; fn progress_alphas(progress: f64) -> Vec { const LENGTH: f64 = 20.0; @@ -123,17 +121,21 @@ pub fn on_render_texts( th19: &Th19, text_renderer: &c_void, ) { - menu.on_render_texts(th19, text_renderer); + let renderer = Th19MenuRenderer { + th19, + text_renderer, + }; + menu.on_render_texts(&renderer); if let Some(value) = value { - render_label_value(th19, text_renderer, 240 - 56, 1, label, value); + render_label_value(&renderer, 240 - 56, 1, label, value); } if let Some(waiting) = waiting { let elapsed = waiting.elapsed(); render_progress(th19, text_renderer, elapsed.as_secs_f64() / 4.0); for (i, error) in waiting.errors().iter().rev().enumerate() { let error_msg = format!("Failed: {}", error); - render_text_line(th19, text_renderer, 13 + i as u32, error_msg.as_bytes()); + render_text_line(&renderer, 13 + i as u32, error_msg.as_bytes()); } } } diff --git a/junowen/src/lobby/room/reserved.rs b/crates/app/junowen/src/lobby/room/reserved.rs similarity index 93% rename from junowen/src/lobby/room/reserved.rs rename to crates/app/junowen/src/lobby/room/reserved.rs index 61b81aa..67a6ed6 100644 --- a/junowen/src/lobby/room/reserved.rs +++ b/crates/app/junowen/src/lobby/room/reserved.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use crate::{ TOKIO_RUNTIME, @@ -12,7 +12,9 @@ use crate::{ }; use super::{ - super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, + super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + }, on_render_texts, }; @@ -70,8 +72,8 @@ impl ReservedRoom { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -96,7 +98,10 @@ impl ReservedRoom { } } - match self.menu.on_input_menu(current_input, prev_input, th19) { + match self + .menu + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) + { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { self.enter = false; diff --git a/junowen/src/lobby/room/shared.rs b/crates/app/junowen/src/lobby/room/shared.rs similarity index 92% rename from junowen/src/lobby/room/shared.rs rename to crates/app/junowen/src/lobby/room/shared.rs index cf3f3f5..07ef256 100644 --- a/junowen/src/lobby/room/shared.rs +++ b/crates/app/junowen/src/lobby/room/shared.rs @@ -1,6 +1,6 @@ use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; use rust_i18n::t; use crate::{ @@ -8,7 +8,9 @@ use crate::{ }; use super::{ - super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}, + super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + }, on_render_texts, }; @@ -57,8 +59,8 @@ impl SharedRoom { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -76,7 +78,10 @@ impl SharedRoom { if let Some(waiting) = waiting { waiting.recv(); } - match self.menu.on_input_menu(current_input, prev_input, th19) { + match self + .menu + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) + { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => Some(LobbyScene::Root), OnMenuInputResult::SubScene(_) => unreachable!(), diff --git a/junowen/src/lobby/room/tcp_signaling.rs b/crates/app/junowen/src/lobby/room/tcp_signaling.rs similarity index 92% rename from junowen/src/lobby/room/tcp_signaling.rs rename to crates/app/junowen/src/lobby/room/tcp_signaling.rs index 2078420..549c48b 100644 --- a/junowen/src/lobby/room/tcp_signaling.rs +++ b/crates/app/junowen/src/lobby/room/tcp_signaling.rs @@ -1,8 +1,10 @@ -mod menu; - use std::ffi::c_void; -use junowen_lib::{Th19, structs::input_devices::InputValue}; +use junowen_lib::Th19; +use junowen_lobby::tcp_signaling_menu::{ + ADDRESS_INPUT_ACTION_ID, CHANGE_ADDRESS_ACTION_ID, GUEST_ACTION_ID, HOST_ACTION_ID, + OFFLINE_ACTION_ID, OFFLINE_ITEM_INDEX, TcpSignalingMenu, +}; use rust_i18n::t; use crate::{ @@ -10,13 +12,8 @@ use crate::{ signaling::waiting_for_match::WaitingForOpponentOverTcpSignaling, }; -use self::menu::{ - ADDRESS_INPUT_ACTION_ID, CHANGE_ADDRESS_ACTION_ID, GUEST_ACTION_ID, HOST_ACTION_ID, - OFFLINE_ACTION_ID, OFFLINE_ITEM_INDEX, TcpSignalingMenu, -}; - use super::{ - super::common_menu::{LobbyScene, MenuItem, OnMenuInputResult}, + super::common_menu::{LobbyScene, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice}, on_render_texts, }; @@ -40,8 +37,8 @@ impl TcpSignaling { pub fn on_input_menu( &mut self, settings_repo: &SettingsRepo, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &Th19, waiting: &mut Option, ) -> Option { @@ -59,11 +56,11 @@ impl TcpSignaling { waiting.recv(); } - match self - .menu - .common_menu_mut() - .on_input_menu(current_input, prev_input, th19) - { + match self.menu.common_menu_mut().on_input_menu( + current_input, + prev_input, + &Th19MenuDevice(th19), + ) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => Some(LobbyScene::Root), OnMenuInputResult::SubScene(_) => unreachable!(), diff --git a/junowen/src/lobby/root.rs b/crates/app/junowen/src/lobby/root.rs similarity index 83% rename from junowen/src/lobby/root.rs rename to crates/app/junowen/src/lobby/root.rs index f1a42e2..31c60d5 100644 --- a/junowen/src/lobby/root.rs +++ b/crates/app/junowen/src/lobby/root.rs @@ -1,12 +1,12 @@ use std::ffi::c_void; -use junowen_lib::{ - Th19, - structs::input_devices::{InputFlags, InputValue}, -}; +use junowen_lib::{Th19, structs::input_devices::InputFlags}; use rust_i18n::t; -use super::common_menu::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}; +use super::common_menu::{ + CommonMenu, LobbyScene, Menu, MenuButtons, MenuItem, OnMenuInputResult, Th19MenuDevice, + Th19MenuRenderer, +}; pub struct Root { common_menu: CommonMenu, @@ -48,13 +48,13 @@ impl Root { pub fn on_input_menu( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, th19: &mut Th19, ) -> Option { match self .common_menu - .on_input_menu(current_input, prev_input, th19) + .on_input_menu(current_input, prev_input, &Th19MenuDevice(th19)) { OnMenuInputResult::None => None, OnMenuInputResult::Cancel => { @@ -67,7 +67,10 @@ impl Root { } pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - self.common_menu.on_render_texts(th19, text_renderer); + self.common_menu.on_render_texts(&Th19MenuRenderer { + th19, + text_renderer, + }); } pub fn text(&self) -> String { diff --git a/junowen/src/lobby/signaling_code.rs b/crates/app/junowen/src/lobby/signaling_code.rs similarity index 100% rename from junowen/src/lobby/signaling_code.rs rename to crates/app/junowen/src/lobby/signaling_code.rs diff --git a/junowen/src/lobby/title_menu_modifier.rs b/crates/app/junowen/src/lobby/title_menu_modifier.rs similarity index 99% rename from junowen/src/lobby/title_menu_modifier.rs rename to crates/app/junowen/src/lobby/title_menu_modifier.rs index 5d14491..3ac5d2c 100644 --- a/junowen/src/lobby/title_menu_modifier.rs +++ b/crates/app/junowen/src/lobby/title_menu_modifier.rs @@ -8,7 +8,7 @@ use junowen_lib::{ structs::others::RenderingText, }; -use super::helper::menu_item_color; +use super::common_menu::menu_item_color; fn direction(input: &Input, flag: InputFlags) -> bool { input.current().0 & InputValue::from(flag).0 != None diff --git a/crates/app/junowen/src/present_skipper.rs b/crates/app/junowen/src/present_skipper.rs new file mode 100644 index 0000000..2185342 --- /dev/null +++ b/crates/app/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.rs b/crates/app/junowen/src/session.rs similarity index 98% rename from junowen/src/session.rs rename to crates/app/junowen/src/session.rs index 66f59f3..6c90a20 100644 --- a/junowen/src/session.rs +++ b/crates/app/junowen/src/session.rs @@ -1,6 +1,6 @@ pub mod battle; mod delayed_inputs; -mod session_message; +pub mod session_message; pub mod spectator; pub mod spectator_host; diff --git a/junowen/src/session/battle.rs b/crates/app/junowen/src/session/battle.rs similarity index 100% rename from junowen/src/session/battle.rs rename to crates/app/junowen/src/session/battle.rs diff --git a/junowen/src/session/delayed_inputs.rs b/crates/app/junowen/src/session/delayed_inputs.rs similarity index 100% rename from junowen/src/session/delayed_inputs.rs rename to crates/app/junowen/src/session/delayed_inputs.rs diff --git a/junowen/src/session/session_message.rs b/crates/app/junowen/src/session/session_message.rs similarity index 91% rename from junowen/src/session/session_message.rs rename to crates/app/junowen/src/session/session_message.rs index baae8d4..616358e 100644 --- a/junowen/src/session/session_message.rs +++ b/crates/app/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/crates/app/junowen/src/session/spectator.rs b/crates/app/junowen/src/session/spectator.rs new file mode 100644 index 0000000..ec1245b --- /dev/null +++ b/crates/app/junowen/src/session/spectator.rs @@ -0,0 +1,226 @@ +use std::{ + collections::VecDeque, + sync::mpsc::{RecvError, TryRecvError}, +}; + +use anyhow::Result; +use derive_new::new; +use getset::{CopyGetters, Getters, Setters}; +use junowen_lib::{ + connection::{DataChannel, PeerConnection}, + structs::settings::GameSettings, +}; +use serde::{Deserialize, Serialize}; +use tracing::{error, info, warn}; + +use super::{session_message::RoundInitial, to_channel}; + +#[derive(new, Clone, Debug, Deserialize, Getters, Serialize)] +pub struct SpectatorInitial { + #[get = "pub"] + p1_name: String, + #[get = "pub"] + p2_name: String, + #[get = "pub"] + game_settings: GameSettings, +} + +#[derive(new, Clone, Copy, Debug, Deserialize, CopyGetters, PartialEq, Serialize)] +pub struct PlayerGameInitial { + #[get_copy = "pub"] + character: u8, + #[get_copy = "pub"] + card: u8, +} + +/// 試合開始時の状態。観戦者はこれに合わせてキャラクターとカードを決定する +#[derive(new, Clone, Debug, Deserialize, CopyGetters, Getters, Serialize)] +pub struct GameInitial { + #[get_copy = "pub"] + difficulty: u8, + #[get_copy = "pub"] + p1: PlayerGameInitial, + #[get_copy = "pub"] + p2: PlayerGameInitial, + #[get = "pub"] + round_initial: RoundInitial, +} + +#[derive(Debug, Deserialize, Serialize)] +pub enum SpectatorSessionMessage { + InitSpectator(SpectatorInitial), + InitGame(GameInitial), + InitRound(RoundInitial), + Inputs(u16, u16), +} + +#[derive(CopyGetters, Getters, Setters)] +pub struct SpectatorSession { + _conn: PeerConnection, + hook_incoming_rx: std::sync::mpsc::Receiver, + /// 受信済みで未処理のメッセージ。試合開始がホストより遅れた分を把握するために使う + buffer: VecDeque, + disconnected: bool, + spectator_initial: Option, + round_initial: Option, +} + +impl SpectatorSession { + pub fn new(conn: PeerConnection, data_channel: DataChannel) -> Self { + let (_hook_outgoing_tx, hook_incoming_rx) = + to_channel(data_channel, |input| rmp_serde::from_slice(input)); + Self { + _conn: conn, + hook_incoming_rx, + buffer: VecDeque::new(), + disconnected: false, + spectator_initial: None, + round_initial: None, + } + } + + pub fn spectator_initial(&self) -> Option<&SpectatorInitial> { + self.spectator_initial.as_ref() + } + + 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); + return Err(RecvError); + } + }; + self.spectator_initial = Some(init); + 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 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(Some(round_initial)); + } + loop { + match self.next_message()? { + SpectatorSessionMessage::InitSpectator(init) => { + error!("unexpected init spectator message: {:?}", init); + return Err(RecvError); + } + 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, + } + } + } + + pub fn dequeue_inputs(&mut self) -> Result<(u16, u16), RecvError> { + if self.round_initial.is_some() { + return Ok((0, 0)); + } + 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)) + } + SpectatorSessionMessage::Inputs(p1, p2) => Ok((p1, p2)), + } + } +} + +impl Drop for SpectatorSession { + fn drop(&mut self) { + info!("spectator session guest closed"); + } +} diff --git a/junowen/src/session/spectator_host.rs b/crates/app/junowen/src/session/spectator_host.rs similarity index 84% rename from junowen/src/session/spectator_host.rs rename to crates/app/junowen/src/session/spectator_host.rs index 157a9af..5ae2f57 100644 --- a/junowen/src/session/spectator_host.rs +++ b/crates/app/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/crates/app/junowen/src/signaling.rs similarity index 97% rename from junowen/src/signaling.rs rename to crates/app/junowen/src/signaling.rs index f34a120..971a8c1 100644 --- a/junowen/src/signaling.rs +++ b/crates/app/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.rs b/crates/app/junowen/src/signaling/waiting_for_match.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match.rs rename to crates/app/junowen/src/signaling/waiting_for_match.rs diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_opponent_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs similarity index 92% rename from junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs index c52becb..1beb47e 100644 --- a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_host_socket.rs @@ -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/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs similarity index 93% rename from junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs index 9914bca..eabad77 100644 --- a/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs +++ b/crates/app/junowen/src/signaling/waiting_for_match/reserved_room_spectator_socket.rs @@ -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/shared_room_opponent_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/shared_room_opponent_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/socket.rs diff --git a/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_guest_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs b/crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs rename to crates/app/junowen/src/signaling/waiting_for_match/tcp_signaling_host_socket.rs diff --git a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs similarity index 92% rename from junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs rename to crates/app/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs index 345b096..3830336 100644 --- a/junowen/src/signaling/waiting_for_match/waiting_for_spectator.rs +++ b/crates/app/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/signaling/waiting_for_match/waiting_in_room.rs b/crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs similarity index 100% rename from junowen/src/signaling/waiting_for_match/waiting_in_room.rs rename to crates/app/junowen/src/signaling/waiting_for_match/waiting_in_room.rs diff --git a/junowen/src/state.rs b/crates/app/junowen/src/state.rs similarity index 96% rename from junowen/src/state.rs rename to crates/app/junowen/src/state.rs index 36d3296..d63094d 100644 --- a/junowen/src/state.rs +++ b/crates/app/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/crates/app/junowen/src/state/battle_session.rs similarity index 98% rename from junowen/src/state/battle_session.rs rename to crates/app/junowen/src/state/battle_session.rs index e72ae37..d69ecf1 100644 --- a/junowen/src/state/battle_session.rs +++ b/crates/app/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/crates/app/junowen/src/state/battle_session/battle_game.rs similarity index 88% rename from junowen/src/state/battle_session/battle_game.rs rename to crates/app/junowen/src/state/battle_session/battle_game.rs index 4502aa0..1f791dc 100644 --- a/junowen/src/state/battle_session/battle_game.rs +++ b/crates/app/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/crates/app/junowen/src/state/battle_session/battle_select.rs similarity index 91% rename from junowen/src/state/battle_session/battle_select.rs rename to crates/app/junowen/src/state/battle_session/battle_select.rs index fb4afc9..fd63ffe 100644 --- a/junowen/src/state/battle_session/battle_select.rs +++ b/crates/app/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/in_session.rs b/crates/app/junowen/src/state/battle_session/in_session.rs similarity index 100% rename from junowen/src/state/battle_session/in_session.rs rename to crates/app/junowen/src/state/battle_session/in_session.rs diff --git a/crates/app/junowen/src/state/battle_session/spectator_host.rs b/crates/app/junowen/src/state/battle_session/spectator_host.rs new file mode 100644 index 0000000..ecb87b8 --- /dev/null +++ b/crates/app/junowen/src/state/battle_session/spectator_host.rs @@ -0,0 +1,199 @@ +use anyhow::Result; +use getset::Getters; +use junowen_lib::{ + Th19, + structs::{app::MainMenu, selection::Player}, +}; +use tracing::info; + +use crate::{ + session::{ + RoundInitial, + battle::BattleSession, + spectator::{GameInitial, PlayerGameInitial, SpectatorInitial}, + spectator_host::SpectatorHostSession, + }, + signaling::waiting_for_match::WaitingForSpectator, +}; + +fn current_round_initial(th19: &Th19) -> RoundInitial { + RoundInitial { + seed1: th19.rand_seed1().unwrap(), + seed2: th19.rand_seed2().unwrap(), + seed3: th19.rand_seed3().unwrap(), + seed4: th19.rand_seed4().unwrap(), + } +} + +fn create_spectator_initial(battle_session: &BattleSession, th19: &Th19) -> SpectatorInitial { + let local_player_name = th19.vs_mode().player_name().to_string(); + let remote_player_name = battle_session.remote_player_name().clone(); + let (p1_name, p2_name) = if battle_session.host() { + (local_player_name, remote_player_name) + } else { + (remote_player_name, local_player_name) + }; + SpectatorInitial::new( + p1_name, + p2_name, + battle_session + .match_initial() + .as_ref() + .unwrap() + .game_settings + .clone(), + ) +} + +/// 進行中の試合の記録。試合の途中で接続した観戦者は、これを受け取って試合の最初から早送りで追いかける +struct GameLog { + init: GameInitial, + messages: Vec, +} + +enum GameLogMessage { + InitRound(RoundInitial), + Inputs(u16, u16), +} + +impl GameLog { + fn send_to(&self, session: &SpectatorHostSession) -> Result<()> { + session.send_init_game(self.init.clone())?; + for msg in &self.messages { + match msg { + GameLogMessage::InitRound(round_initial) => { + session.send_init_round(round_initial.clone())? + } + GameLogMessage::Inputs(p1, p2) => session.send_inputs(*p1, *p2)?, + } + } + Ok(()) + } +} + +/// 観戦者の管理 +/// +/// 観戦者は接続するとプレイヤー名と設定を受け取る。 +/// 選択画面で接続した場合は、次の試合の開始時にキャラクター・カード・乱数シードを受け取り、 +/// それに合わせて試合を始める。 +/// 試合中に接続した場合は、その試合の開始時の状態とそれまでの入力を受け取り、試合の最初から追いかける +#[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.standby_sessions.len() + self.sessions.len() + } + + /// 観戦者の接続を受け付ける + pub fn update( + &mut self, + pushed: bool, + main_menu: Option<&MainMenu>, + th19: &Th19, + battle_session: &BattleSession, + ) { + let Some(session) = self.waiting.try_recv_session(pushed, main_menu, th19) else { + return; + }; + if let Err(err) = + session.send_init_spectator(create_spectator_initial(battle_session, th19)) + { + info!("initialize spectator failed: {:?}", err); + return; + } + 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); + } + } + + /// 試合開始時 (キャラクター選択画面から読み込み画面に移ったとき) に呼ぶ + 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 + }); + 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/crates/app/junowen/src/state/battle_session/utils.rs similarity index 74% rename from junowen/src/state/battle_session/utils.rs rename to crates/app/junowen/src/state/battle_session/utils.rs index 12bbfc1..0144152 100644 --- a/junowen/src/state/battle_session/utils.rs +++ b/crates/app/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/prepare.rs b/crates/app/junowen/src/state/prepare.rs similarity index 100% rename from junowen/src/state/prepare.rs rename to crates/app/junowen/src/state/prepare.rs diff --git a/junowen/src/state/render_parts.rs b/crates/app/junowen/src/state/render_parts.rs similarity index 100% rename from junowen/src/state/render_parts.rs rename to crates/app/junowen/src/state/render_parts.rs diff --git a/junowen/src/state/session.rs b/crates/app/junowen/src/state/session.rs similarity index 92% rename from junowen/src/state/session.rs rename to crates/app/junowen/src/state/session.rs index df0e3bd..7e2a0a8 100644 --- a/junowen/src/state/session.rs +++ b/crates/app/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/crates/app/junowen/src/state/spectator_session.rs b/crates/app/junowen/src/state/spectator_session.rs new file mode 100644 index 0000000..b9018cd --- /dev/null +++ b/crates/app/junowen/src/state/spectator_session.rs @@ -0,0 +1,263 @@ +mod in_session; +mod spectator_game; +mod spectator_standby; + +use std::{ffi::c_void, sync::mpsc::RecvError}; + +use anyhow::Result; +use junowen_lib::{ + Th19, + structs::settings::GameSettings, + structs::{ + app::{MainMenu, ScreenId}, + input_devices::InputFlags, + }, +}; +use tracing::warn; + +use crate::{ + helper::pushed_escape, + present_skipper::PresentSkipper, + session::{ + RoundInitial, + spectator::{GameInitial, SpectatorSession as SpectatorSessionProps}, + }, +}; + +use super::prepare::Prepare; + +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), + Standby(SpectatorStandby), + /// 最初のフレームで乱数シードを設定する + GameLoading(Option), + Game(SpectatorGame), + BackToSelect, +} + +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_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) { + self.state = SpectatorSessionState::GameLoading(round_initial); + } + pub fn change_to_game(&mut self) { + self.state = SpectatorSessionState::Game(SpectatorGame); + } + pub fn change_to_back_to_select(&mut self) { + self.state = SpectatorSessionState::BackToSelect; + } + + pub fn update_state(&mut self, th19: &Th19) -> Option> { + match &mut self.state { + SpectatorSessionState::Prepare(prepare) => { + let Some(main_menu) = th19.app().main_loop_tasks().find_main_menu() else { + return Some(None); + }; + if prepare.update_state(main_menu, th19) { + self.change_to_standby(true); + } + Some(Some(main_menu)) + } + 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::GameLoading => { + let init = standby.take_game_initial(); + 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)) + } + _ => Some(Some(main_menu)), + } + } + SpectatorSessionState::GameLoading(_) => { + let Some(round_frame) = th19.round_frame() else { + return Some(None); + }; + if !round_frame.is_first_frame() { + return Some(None); + } + self.change_to_game(); + Some(None) + } + SpectatorSessionState::Game { .. } => { + 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 => { + 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_standby(false); + Some(Some(main_menu)) + } + } + } + + pub fn update_th19_on_input_players( + &mut self, + menu: Option<&MainMenu>, + th19: &mut Th19, + ) -> Result<(), RecvError> { + match &mut self.state { + SpectatorSessionState::Prepare(prepare) => prepare.update_th19_on_input_players(th19), + SpectatorSessionState::Standby(standby) => { + standby.update_th19_on_input_players(&mut self.props, menu.unwrap(), th19)? + } + 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::BackToSelect => {} + } + Ok(()) + } + + 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::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::Game { .. } => {} + 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( + th19, + text_renderer, + initial.p1_name(), + initial.p2_name(), + waiting_for_host, + ); + } + + pub fn on_round_over(&mut self, th19: &mut Th19) -> Result<(), RecvError> { + let SpectatorSessionState::Game(game) = &mut self.state else { + return Ok(()); + }; + game.on_round_over(&mut self.props, th19) + } +} diff --git a/crates/app/junowen/src/state/spectator_session/in_session.rs b/crates/app/junowen/src/state/spectator_session/in_session.rs new file mode 100644 index 0000000..c1963fd --- /dev/null +++ b/crates/app/junowen/src/state/spectator_session/in_session.rs @@ -0,0 +1,25 @@ +use std::ffi::c_void; + +use junowen_lib::Th19; + +use crate::state::render_parts::{render_footer, render_names}; + +pub fn on_render_texts_spectator( + th19: &Th19, + text_renderer: &c_void, + p1_name: &str, + p2_name: &str, + waiting_for_next_game: bool, +) { + render_names(th19, text_renderer, p1_name, p2_name); + 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/crates/app/junowen/src/state/spectator_session/spectator_game.rs b/crates/app/junowen/src/state/spectator_session/spectator_game.rs new file mode 100644 index 0000000..2de52bb --- /dev/null +++ b/crates/app/junowen/src/state/spectator_session/spectator_game.rs @@ -0,0 +1,77 @@ +use std::sync::mpsc::RecvError; + +use anyhow::Result; +use junowen_lib::{Th19, structs::input_devices::InputValue}; + +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; + +impl SpectatorGame { + pub fn update_th19( + &mut self, + session: &mut SpectatorSession, + present_skipper: &PresentSkipper, + th19: &mut Th19, + ) -> Result<(), RecvError> { + // -1フレーム目、0フレーム目は複数回呼ばれ、回数が不定なのでスキップする + if th19.round_frame().unwrap().frame < 1 { + 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(()); + } + 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 + .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 on_round_over( + &mut self, + session: &mut SpectatorSession, + th19: &mut Th19, + ) -> Result<(), RecvError> { + if let Some(init) = session.dequeue_init_round()? { + set_rand_seeds(th19, &init); + } + Ok(()) + } +} diff --git a/crates/app/junowen/src/state/spectator_session/spectator_standby.rs b/crates/app/junowen/src/state/spectator_session/spectator_standby.rs new file mode 100644 index 0000000..abba666 --- /dev/null +++ b/crates/app/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(()) + } +} diff --git a/junowen/src/state/standby.rs b/crates/app/junowen/src/state/standby.rs similarity index 100% rename from junowen/src/state/standby.rs rename to crates/app/junowen/src/state/standby.rs diff --git a/junowen/src/tracing_helper.rs b/crates/app/junowen/src/tracing_helper.rs similarity index 100% rename from junowen/src/tracing_helper.rs rename to crates/app/junowen/src/tracing_helper.rs diff --git a/th19loader/Cargo.toml b/crates/app/th19loader/Cargo.toml similarity index 89% rename from th19loader/Cargo.toml rename to crates/app/th19loader/Cargo.toml index 110ae2b..49cf1f0 100644 --- a/th19loader/Cargo.toml +++ b/crates/app/th19loader/Cargo.toml @@ -15,7 +15,7 @@ winres = "0.1" [dependencies] anyhow.workspace = true -junowen-lib = { path = "../junowen-lib" } +junowen-lib = { path = "../../features/junowen-lib" } windows.workspace = true [package.metadata.winres] diff --git a/th19loader/build.rs b/crates/app/th19loader/build.rs similarity index 100% rename from th19loader/build.rs rename to crates/app/th19loader/build.rs diff --git a/th19loader/src/lib.rs b/crates/app/th19loader/src/lib.rs similarity index 100% rename from th19loader/src/lib.rs rename to crates/app/th19loader/src/lib.rs diff --git a/crates/features/junowen-direct3d/Cargo.toml b/crates/features/junowen-direct3d/Cargo.toml new file mode 100644 index 0000000..f19afbf --- /dev/null +++ b/crates/features/junowen-direct3d/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "junowen-direct3d" +edition = "2024" +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +windows.workspace = true +windows-core.workspace = true diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_9.rs b/crates/features/junowen-direct3d/src/custom_direct_3d_9.rs similarity index 100% rename from junowen/src/custom_direct_3d/custom_direct_3d_9.rs rename to crates/features/junowen-direct3d/src/custom_direct_3d_9.rs diff --git a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs b/crates/features/junowen-direct3d/src/custom_direct_3d_device_9.rs similarity index 99% rename from junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs rename to crates/features/junowen-direct3d/src/custom_direct_3d_device_9.rs index d678998..fd75c3c 100644 --- a/junowen/src/custom_direct_3d/custom_direct_3d_device_9.rs +++ b/crates/features/junowen-direct3d/src/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/crates/features/junowen-direct3d/src/direct_3d_device_event_listener.rs similarity index 62% rename from junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs rename to crates/features/junowen-direct3d/src/direct_3d_device_event_listener.rs index c3a36b0..db470b0 100644 --- a/junowen/src/custom_direct_3d/direct_3d_device_event_listener.rs +++ b/crates/features/junowen-direct3d/src/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/custom_direct_3d.rs b/crates/features/junowen-direct3d/src/lib.rs similarity index 100% rename from junowen/src/custom_direct_3d.rs rename to crates/features/junowen-direct3d/src/lib.rs diff --git a/junowen-lib/Cargo.toml b/crates/features/junowen-lib/Cargo.toml similarity index 100% rename from junowen-lib/Cargo.toml rename to crates/features/junowen-lib/Cargo.toml diff --git a/junowen-lib/examples/webrtc_guest.rs b/crates/features/junowen-lib/examples/webrtc_guest.rs similarity index 100% rename from junowen-lib/examples/webrtc_guest.rs rename to crates/features/junowen-lib/examples/webrtc_guest.rs diff --git a/junowen-lib/examples/webrtc_host.rs b/crates/features/junowen-lib/examples/webrtc_host.rs similarity index 100% rename from junowen-lib/examples/webrtc_host.rs rename to crates/features/junowen-lib/examples/webrtc_host.rs diff --git a/crates/features/junowen-lib/src/connection.rs b/crates/features/junowen-lib/src/connection.rs new file mode 100644 index 0000000..b4dbfc7 --- /dev/null +++ b/crates/features/junowen-lib/src/connection.rs @@ -0,0 +1,8 @@ +mod data_channel; +mod peer_connection; +pub mod signaling; + +pub use self::{ + data_channel::DataChannel, + peer_connection::{BATTLE_PROTOCOL, PeerConnection, SPECTATOR_PROTOCOL}, +}; diff --git a/junowen-lib/src/connection/data_channel.rs b/crates/features/junowen-lib/src/connection/data_channel.rs similarity index 100% rename from junowen-lib/src/connection/data_channel.rs rename to crates/features/junowen-lib/src/connection/data_channel.rs diff --git a/junowen-lib/src/connection/peer_connection.rs b/crates/features/junowen-lib/src/connection/peer_connection.rs similarity index 90% rename from junowen-lib/src/connection/peer_connection.rs rename to crates/features/junowen-lib/src/connection/peer_connection.rs index ca188a7..0adc66a 100644 --- a/junowen-lib/src/connection/peer_connection.rs +++ b/crates/features/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.rs b/crates/features/junowen-lib/src/connection/signaling.rs similarity index 100% rename from junowen-lib/src/connection/signaling.rs rename to crates/features/junowen-lib/src/connection/signaling.rs diff --git a/junowen-lib/src/connection/signaling/socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket.rs similarity index 85% rename from junowen-lib/src/connection/signaling/socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket.rs index fc9c36b..6e854de 100644 --- a/junowen-lib/src/connection/signaling/socket.rs +++ b/crates/features/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/async_read_write_server_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_server_socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/async_read_write_socket.rs diff --git a/junowen-lib/src/connection/signaling/socket/channel_socket.rs b/crates/features/junowen-lib/src/connection/signaling/socket/channel_socket.rs similarity index 91% rename from junowen-lib/src/connection/signaling/socket/channel_socket.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/channel_socket.rs index 3191eb6..f85164d 100644 --- a/junowen-lib/src/connection/signaling/socket/channel_socket.rs +++ b/crates/features/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/connection/signaling/socket/frame.rs b/crates/features/junowen-lib/src/connection/signaling/socket/frame.rs similarity index 100% rename from junowen-lib/src/connection/signaling/socket/frame.rs rename to crates/features/junowen-lib/src/connection/signaling/socket/frame.rs diff --git a/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs b/crates/features/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs similarity index 100% rename from junowen-lib/src/connection/signaling/stdio_signaling_interface.rs rename to crates/features/junowen-lib/src/connection/signaling/stdio_signaling_interface.rs diff --git a/junowen-lib/src/find_process_id.rs b/crates/features/junowen-lib/src/find_process_id.rs similarity index 100% rename from junowen-lib/src/find_process_id.rs rename to crates/features/junowen-lib/src/find_process_id.rs diff --git a/junowen-lib/src/hook_utils.rs b/crates/features/junowen-lib/src/hook_utils.rs similarity index 100% rename from junowen-lib/src/hook_utils.rs rename to crates/features/junowen-lib/src/hook_utils.rs diff --git a/junowen-lib/src/hook_utils/dll_injection.rs b/crates/features/junowen-lib/src/hook_utils/dll_injection.rs similarity index 100% rename from junowen-lib/src/hook_utils/dll_injection.rs rename to crates/features/junowen-lib/src/hook_utils/dll_injection.rs diff --git a/junowen-lib/src/hook_utils/load_library_w_addr.rs b/crates/features/junowen-lib/src/hook_utils/load_library_w_addr.rs similarity index 100% rename from junowen-lib/src/hook_utils/load_library_w_addr.rs rename to crates/features/junowen-lib/src/hook_utils/load_library_w_addr.rs diff --git a/junowen-lib/src/lang.rs b/crates/features/junowen-lib/src/lang.rs similarity index 100% rename from junowen-lib/src/lang.rs rename to crates/features/junowen-lib/src/lang.rs diff --git a/junowen-lib/src/lib.rs b/crates/features/junowen-lib/src/lib.rs similarity index 100% rename from junowen-lib/src/lib.rs rename to crates/features/junowen-lib/src/lib.rs diff --git a/junowen-lib/src/macros.rs b/crates/features/junowen-lib/src/macros.rs similarity index 100% rename from junowen-lib/src/macros.rs rename to crates/features/junowen-lib/src/macros.rs diff --git a/junowen-lib/src/memory_accessors.rs b/crates/features/junowen-lib/src/memory_accessors.rs similarity index 100% rename from junowen-lib/src/memory_accessors.rs rename to crates/features/junowen-lib/src/memory_accessors.rs diff --git a/junowen-lib/src/memory_accessors/external_process.rs b/crates/features/junowen-lib/src/memory_accessors/external_process.rs similarity index 100% rename from junowen-lib/src/memory_accessors/external_process.rs rename to crates/features/junowen-lib/src/memory_accessors/external_process.rs diff --git a/junowen-lib/src/memory_accessors/hooked_process.rs b/crates/features/junowen-lib/src/memory_accessors/hooked_process.rs similarity index 100% rename from junowen-lib/src/memory_accessors/hooked_process.rs rename to crates/features/junowen-lib/src/memory_accessors/hooked_process.rs diff --git a/junowen-lib/src/signaling_server.rs b/crates/features/junowen-lib/src/signaling_server.rs similarity index 100% rename from junowen-lib/src/signaling_server.rs rename to crates/features/junowen-lib/src/signaling_server.rs diff --git a/junowen-lib/src/signaling_server/custom.rs b/crates/features/junowen-lib/src/signaling_server/custom.rs similarity index 100% rename from junowen-lib/src/signaling_server/custom.rs rename to crates/features/junowen-lib/src/signaling_server/custom.rs diff --git a/junowen-lib/src/signaling_server/reserved_room.rs b/crates/features/junowen-lib/src/signaling_server/reserved_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/reserved_room.rs rename to crates/features/junowen-lib/src/signaling_server/reserved_room.rs diff --git a/junowen-lib/src/signaling_server/room.rs b/crates/features/junowen-lib/src/signaling_server/room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room.rs rename to crates/features/junowen-lib/src/signaling_server/room.rs diff --git a/junowen-lib/src/signaling_server/room/delete_room.rs b/crates/features/junowen-lib/src/signaling_server/room/delete_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/delete_room.rs rename to crates/features/junowen-lib/src/signaling_server/room/delete_room.rs diff --git a/junowen-lib/src/signaling_server/room/post_room_join.rs b/crates/features/junowen-lib/src/signaling_server/room/post_room_join.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/post_room_join.rs rename to crates/features/junowen-lib/src/signaling_server/room/post_room_join.rs diff --git a/junowen-lib/src/signaling_server/room/post_room_keep.rs b/crates/features/junowen-lib/src/signaling_server/room/post_room_keep.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/post_room_keep.rs rename to crates/features/junowen-lib/src/signaling_server/room/post_room_keep.rs diff --git a/junowen-lib/src/signaling_server/room/put_room.rs b/crates/features/junowen-lib/src/signaling_server/room/put_room.rs similarity index 100% rename from junowen-lib/src/signaling_server/room/put_room.rs rename to crates/features/junowen-lib/src/signaling_server/room/put_room.rs diff --git a/junowen-lib/src/th19.rs b/crates/features/junowen-lib/src/th19.rs similarity index 100% rename from junowen-lib/src/th19.rs rename to crates/features/junowen-lib/src/th19.rs diff --git a/junowen-lib/src/th19/structs.rs b/crates/features/junowen-lib/src/th19/structs.rs similarity index 100% rename from junowen-lib/src/th19/structs.rs rename to crates/features/junowen-lib/src/th19/structs.rs diff --git a/junowen-lib/src/th19/structs/app.rs b/crates/features/junowen-lib/src/th19/structs/app.rs similarity index 98% rename from junowen-lib/src/th19/structs/app.rs rename to crates/features/junowen-lib/src/th19/structs/app.rs index 25dd369..bc1f6c0 100644 --- a/junowen-lib/src/th19/structs/app.rs +++ b/crates/features/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/input_devices.rs b/crates/features/junowen-lib/src/th19/structs/input_devices.rs similarity index 100% rename from junowen-lib/src/th19/structs/input_devices.rs rename to crates/features/junowen-lib/src/th19/structs/input_devices.rs diff --git a/junowen-lib/src/th19/structs/others.rs b/crates/features/junowen-lib/src/th19/structs/others.rs similarity index 100% rename from junowen-lib/src/th19/structs/others.rs rename to crates/features/junowen-lib/src/th19/structs/others.rs diff --git a/junowen-lib/src/th19/structs/selection.rs b/crates/features/junowen-lib/src/th19/structs/selection.rs similarity index 94% rename from junowen-lib/src/th19/structs/selection.rs rename to crates/features/junowen-lib/src/th19/structs/selection.rs index 638d11e..14b5423 100644 --- a/junowen-lib/src/th19/structs/selection.rs +++ b/crates/features/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-lib/src/th19/structs/settings.rs b/crates/features/junowen-lib/src/th19/structs/settings.rs similarity index 100% rename from junowen-lib/src/th19/structs/settings.rs rename to crates/features/junowen-lib/src/th19/structs/settings.rs diff --git a/junowen-lib/src/th19/th19_event_dispatcher.rs b/crates/features/junowen-lib/src/th19/th19_event_dispatcher.rs similarity index 100% rename from junowen-lib/src/th19/th19_event_dispatcher.rs rename to crates/features/junowen-lib/src/th19/th19_event_dispatcher.rs diff --git a/junowen-lib/src/th19/th19_helpers.rs b/crates/features/junowen-lib/src/th19/th19_helpers.rs similarity index 100% rename from junowen-lib/src/th19/th19_helpers.rs rename to crates/features/junowen-lib/src/th19/th19_helpers.rs diff --git a/junowen-lib/src/win_api_wrappers.rs b/crates/features/junowen-lib/src/win_api_wrappers.rs similarity index 100% rename from junowen-lib/src/win_api_wrappers.rs rename to crates/features/junowen-lib/src/win_api_wrappers.rs diff --git a/crates/features/junowen-lobby/Cargo.toml b/crates/features/junowen-lobby/Cargo.toml new file mode 100644 index 0000000..11b551d --- /dev/null +++ b/crates/features/junowen-lobby/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "junowen-lobby" +edition = "2024" +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +derive-new.workspace = true +getset.workspace = true +windows.workspace = true + +[lints.clippy] +self_named_module_files = "warn" diff --git a/crates/features/junowen-lobby/src/helper.rs b/crates/features/junowen-lobby/src/helper.rs new file mode 100644 index 0000000..8beb58b --- /dev/null +++ b/crates/features/junowen-lobby/src/helper.rs @@ -0,0 +1,64 @@ +use crate::{MenuRenderer, MenuText, OverlayText}; + +const TEXT_LINE_LEFT: u32 = 32; +const TEXT_LINE_TOP: u32 = 160; +const TEXT_LINE_HEIGHT: u32 = 32; +const SIGNALING_CODE_CHUNK_LEN: usize = 100; + +pub fn render_text_line(renderer: &impl MenuRenderer, line: u32, text: &[u8]) { + let mut mt = MenuText { + text, + x: TEXT_LINE_LEFT, + y: TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT, + color: 0xff000000, + font_type: 8, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.color = 0xffffffff; + mt.font_type = 6; + renderer.render_text(&mt); +} + +/// シグナリングコードは 1 行に収まらないので、半分の行高に詰めて折り返す +pub fn render_signaling_code(renderer: &impl MenuRenderer, line: u32, code: &str) { + for (i, chunk) in code.as_bytes().chunks(SIGNALING_CODE_CHUNK_LEN).enumerate() { + renderer.render_text(&MenuText { + text: chunk, + x: TEXT_LINE_LEFT, + y: TEXT_LINE_TOP + (line * 2 + i as u32) * (TEXT_LINE_HEIGHT / 2), + font_type: 1, + ..Default::default() + }); + } +} + +/// render_signaling_code が消費する行数 +pub fn signaling_code_line_count(code: &str) -> u32 { + let chunks = code.as_bytes().chunks(SIGNALING_CODE_CHUNK_LEN).len(); + (chunks as f64 / 2.0).ceil() as u32 +} + +/// render_text_line と同じ位置へ多言語テキストを重ねる +pub fn overlay_text_line(line: u32, string: String) -> OverlayText { + OverlayText { + left: TEXT_LINE_LEFT as i32, + top: (TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT) as i32, + font_size: TEXT_LINE_HEIGHT, + string, + } +} + +/// 画面下部にメニュー項目の説明文を重ねる +pub fn overlay_description(string: String) -> Vec { + if string.is_empty() { + return vec![]; + } + vec![OverlayText { + left: 96, + top: 640, + font_size: 58, + string, + }] +} diff --git a/crates/features/junowen-lobby/src/lib.rs b/crates/features/junowen-lobby/src/lib.rs new file mode 100644 index 0000000..1e12df0 --- /dev/null +++ b/crates/features/junowen-lobby/src/lib.rs @@ -0,0 +1,21 @@ +//! ロビー画面 +//! +//! 描画、効果音、キーボード入力はゲーム本体の機能で行う。 +//! このクレートはそれらの操作をトレイトで定義する。 +//! 実装は呼び出し元のクレートが用意する。 + +mod helper; +mod menu; +mod overlay_text; +pub mod pure_p2p_offerer_menu; +mod scene; +pub mod tcp_signaling_menu; +mod text_layout; + +pub use helper::{overlay_description, render_text_line, signaling_code_line_count}; +pub use menu::{ + MenuButtons, MenuDevice, MenuRenderer, MenuSound, MenuText, menu_item_color, render_label_value, +}; +pub use overlay_text::OverlayText; +pub use scene::{CommonMenu, LobbyScene, Menu, MenuItem, OnMenuInputResult}; +pub use text_layout::TextLayout; diff --git a/crates/features/junowen-lobby/src/menu/action.rs b/crates/features/junowen-lobby/src/menu/action.rs new file mode 100644 index 0000000..55f57c6 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/action.rs @@ -0,0 +1,24 @@ +use derive_new::new; +use getset::CopyGetters; + +#[derive(Debug, CopyGetters, new)] +pub struct Action { + #[get_copy = "pub"] + id: u8, + #[get_copy = "pub"] + play_sound: bool, + value: Option, +} + +impl Action { + pub fn value(&self) -> Option<&str> { + self.value.as_deref() + } +} + +pub enum OnMenuInputResult { + None, + Cancel, + Action(Action), + SubScene(S), +} diff --git a/crates/features/junowen-lobby/src/menu/common_menu.rs b/crates/features/junowen-lobby/src/menu/common_menu.rs new file mode 100644 index 0000000..979b57d --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/common_menu.rs @@ -0,0 +1,140 @@ +use getset::{Getters, MutGetters}; + +use crate::menu::{ + action::{Action, OnMenuInputResult}, + device::{MenuDevice, MenuSound}, + input::{ + MenuButtons, MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, + OnTextInputResult, + }, + render::{MenuRenderer, render_title}, + tree::{ + CurrentMenuSceneMutResult, CurrentMenuSceneResult, Menu, MenuItem, current_menu_scene, + current_menu_scene_mut, + }, +}; + +#[derive(Getters, MutGetters)] +pub struct CommonMenu { + #[getset(get = "pub", get_mut = "pub")] + menu: Menu, + instant_exit: bool, + base_height: u32, + #[getset(get_mut = "pub")] + controller: MenuController, +} + +impl CommonMenu { + pub fn new(instant_exit: bool, base_height: u32, menu: Menu) -> Self { + Self { + menu, + instant_exit, + base_height, + controller: MenuController::default(), + } + } + + pub fn root_title(&self) -> &'static str { + self.menu.title() + } + + fn apply_decide_count(&mut self) -> Option> { + match self.controller.update_decide() { + MenuControllerUpdateDecideResult::None => None, + MenuControllerUpdateDecideResult::Wait => Some(OnMenuInputResult::None), + MenuControllerUpdateDecideResult::Decide => { + if let Some(scene) = self.menu.dig() { + return Some(OnMenuInputResult::SubScene(scene)); + } + None + } + MenuControllerUpdateDecideResult::Cancel => { + if !self.menu.bury() { + return Some(OnMenuInputResult::Cancel); + } + None + } + } + } + + pub fn on_input_menu( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + device: &impl MenuDevice, + ) -> OnMenuInputResult { + if let Some(result) = self.apply_decide_count() { + return result; + } + + match current_menu_scene(&self.menu) { + CurrentMenuSceneResult::SubScene(scene) => OnMenuInputResult::SubScene(scene), + CurrentMenuSceneResult::Menu(menu) => { + let ignore_decide = menu.items().is_empty(); + let instant_decide = + ignore_decide || matches!(menu.selected_item(), MenuItem::Plain(_)); + let play_decide_sound = !ignore_decide + && menu + .selected_item() + .decided_action() + .map(|x| x.play_sound()) + .unwrap_or(true); + let root_cancel = !self.menu.decided() && self.instant_exit; + + let input_result = + self.controller + .input(current_input, prev_input, instant_decide, root_cancel); + + if root_cancel && let MenuControllerInputResult::Cancel = input_result { + return OnMenuInputResult::Cancel; + } + let CurrentMenuSceneMutResult::Menu(menu) = current_menu_scene_mut(&mut self.menu) + else { + unreachable!() + }; + if let Some(action) = + menu.input(input_result, ignore_decide, play_decide_sound, device) + { + OnMenuInputResult::Action(action) + } else { + OnMenuInputResult::None + } + } + CurrentMenuSceneResult::TextInput(_, _) => { + let CurrentMenuSceneMutResult::TextInput(text_input) = + current_menu_scene_mut(&mut self.menu) + else { + unreachable!() + }; + match text_input.on_input_menu(device) { + OnTextInputResult::None => OnMenuInputResult::None, + OnTextInputResult::Cancel => { + device.play_sound(MenuSound::Cancel); + self.controller.force_cancel(); + OnMenuInputResult::None + } + OnTextInputResult::Decide(changed_action, new_room_name) => { + device.play_sound(MenuSound::Decide); + self.controller.force_cancel(); + let action = Action::new(changed_action, false, Some(new_room_name)); + OnMenuInputResult::Action(action) + } + } + } + } + } + + pub fn on_render_texts(&self, renderer: &impl MenuRenderer) { + match current_menu_scene(&self.menu) { + CurrentMenuSceneResult::SubScene { .. } => unreachable!(), + CurrentMenuSceneResult::Menu(menu) => { + render_title(renderer, menu.title().as_bytes()); + menu.on_render_texts(self.base_height, renderer); + } + CurrentMenuSceneResult::TextInput(label, text_input) => { + render_title(renderer, label.as_bytes()); + text_input.on_render_texts(renderer); + } + } + } +} diff --git a/crates/features/junowen-lobby/src/menu/device.rs b/crates/features/junowen-lobby/src/menu/device.rs new file mode 100644 index 0000000..b9d879c --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/device.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Copy, Debug)] +pub enum MenuSound { + Cancel, + Decide, + Select, +} + +/// メニュー操作に使うゲームの機能 +pub trait MenuDevice { + fn play_sound(&self, sound: MenuSound); + /// 仮想キーコードごとの押下状態。最上位ビットが立っていれば押されている + fn keyboard_raw_keys(&self) -> &[u8; 256]; +} diff --git a/crates/features/junowen-lobby/src/menu/input/menu_buttons.rs b/crates/features/junowen-lobby/src/menu/input/menu_buttons.rs new file mode 100644 index 0000000..0e89251 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/input/menu_buttons.rs @@ -0,0 +1,41 @@ +/// メニュー操作に使うボタンの押下状態 +/// +/// 決定とキャンセルは、対応するボタンのいずれかが新たに押された時点で反応する。 +/// そのため、ボタンごとに 1 ビットを使う +#[derive(Clone, Copy, Debug, Default)] +pub struct MenuButtons { + pub up: bool, + pub down: bool, + pub decide: u8, + pub cancel: u8, +} + +impl MenuButtons { + pub fn decide_pulsed(self, prev: Self) -> bool { + self.decide & !prev.decide != 0 + } + + pub fn cancel_pulsed(self, prev: Self) -> bool { + self.cancel & !prev.cancel != 0 + } +} + +#[cfg(test)] +mod tests { + use super::MenuButtons; + + fn cancel(bits: u8) -> MenuButtons { + MenuButtons { + cancel: bits, + ..Default::default() + } + } + + #[test] + fn test_cancel_pulsed() { + assert!(cancel(0b01).cancel_pulsed(cancel(0b00))); + assert!(!cancel(0b01).cancel_pulsed(cancel(0b01))); + assert!(cancel(0b11).cancel_pulsed(cancel(0b01))); + assert!(!cancel(0b01).cancel_pulsed(cancel(0b11))); + } +} diff --git a/junowen/src/lobby/common_menu/menu_controller.rs b/crates/features/junowen-lobby/src/menu/input/menu_controller.rs similarity index 68% rename from junowen/src/lobby/common_menu/menu_controller.rs rename to crates/features/junowen-lobby/src/menu/input/menu_controller.rs index f85e742..41525a9 100644 --- a/junowen/src/lobby/common_menu/menu_controller.rs +++ b/crates/features/junowen-lobby/src/menu/input/menu_controller.rs @@ -1,8 +1,4 @@ -use junowen_lib::structs::input_devices::{InputFlags, InputValue}; - -fn pulse(current: InputValue, prev: InputValue, flag: InputFlags) -> bool { - current.0 & flag != None && prev.0 & flag == None -} +use super::MenuButtons; pub enum MenuControllerUpdateDecideResult { None, @@ -47,11 +43,13 @@ impl MenuController { MenuControllerUpdateDecideResult::Wait } - fn cancel(&mut self, current_input: InputValue, prev_input: InputValue, instant: bool) -> bool { - if !pulse(current_input, prev_input, InputFlags::CHARGE) - && !pulse(current_input, prev_input, InputFlags::BOMB) - && !pulse(current_input, prev_input, InputFlags::PAUSE) - { + fn cancel( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + instant: bool, + ) -> bool { + if !current_input.cancel_pulsed(prev_input) { return false; } if !instant { @@ -64,10 +62,13 @@ impl MenuController { self.decide_count = -1; } - fn decide(&mut self, current_input: InputValue, prev_input: InputValue, instant: bool) -> bool { - if !pulse(current_input, prev_input, InputFlags::SHOT) - && !pulse(current_input, prev_input, InputFlags::ENTER) - { + fn decide( + &mut self, + current_input: MenuButtons, + prev_input: MenuButtons, + instant: bool, + ) -> bool { + if !current_input.decide_pulsed(prev_input) { return false; } if !instant { @@ -76,11 +77,9 @@ impl MenuController { true } - fn select(&mut self, current_input: InputValue, prev_input: InputValue) -> Option { + fn select(&mut self, current_input: MenuButtons, prev_input: MenuButtons) -> Option { let mut mv = None; - if current_input.0 & InputFlags::UP != None - && (prev_input.0 & InputFlags::UP == None || self.repeat_up > 0) - { + if current_input.up && (!prev_input.up || self.repeat_up > 0) { if [0, 25].contains(&self.repeat_up) { mv = Some(true); } @@ -91,9 +90,7 @@ impl MenuController { } else { self.repeat_up = 0; } - if current_input.0 & InputFlags::DOWN != None - && (prev_input.0 & InputFlags::DOWN == None || self.repeat_down > 0) - { + if current_input.down && (!prev_input.down || self.repeat_down > 0) { if [0, 25].contains(&self.repeat_down) { mv = Some(false); } @@ -109,8 +106,8 @@ impl MenuController { pub fn input( &mut self, - current_input: InputValue, - prev_input: InputValue, + current_input: MenuButtons, + prev_input: MenuButtons, instant_decide: bool, instant_cancel: bool, ) -> MenuControllerInputResult { diff --git a/crates/features/junowen-lobby/src/menu/input/mod.rs b/crates/features/junowen-lobby/src/menu/input/mod.rs new file mode 100644 index 0000000..8745e56 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/input/mod.rs @@ -0,0 +1,9 @@ +mod menu_buttons; +mod menu_controller; +mod text_input; + +pub use menu_buttons::MenuButtons; +pub use menu_controller::{ + MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, +}; +pub use text_input::{OnTextInputResult, TextInput}; diff --git a/junowen/src/lobby/common_menu/text_input.rs b/crates/features/junowen-lobby/src/menu/input/text_input.rs similarity index 72% rename from junowen/src/lobby/common_menu/text_input.rs rename to crates/features/junowen-lobby/src/menu/input/text_input.rs index fa0f89f..923ccff 100644 --- a/junowen/src/lobby/common_menu/text_input.rs +++ b/crates/features/junowen-lobby/src/menu/input/text_input.rs @@ -1,10 +1,12 @@ -use std::{ffi::c_void, num::NonZeroU8}; +use std::num::NonZeroU8; use getset::Setters; -use junowen_lib::Th19; use windows::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode}; -use crate::lobby::helper::render_label_value; +use crate::menu::{ + device::MenuDevice, + render::{MenuRenderer, render_label_value}, +}; fn to_ascii(vk: u32, current: &[u8; 256]) -> Option { let mut buf = [0u16; 2]; @@ -50,10 +52,10 @@ impl TextInputState { } } if current[self.current_vk as usize] & 0x80 != 0 { - if self.current_vk_count > 30 { - if let Some(ascii) = to_ascii(self.current_vk as u32, current) { - result.push(ascii.get()); - } + if self.current_vk_count > 30 + && let Some(ascii) = to_ascii(self.current_vk as u32, current) + { + result.push(ascii.get()); } self.current_vk_count += 1; } else { @@ -64,7 +66,7 @@ impl TextInputState { } } -pub enum OnMenuInputResult { +pub enum OnTextInputResult { None, Cancel, Decide(u8, String), @@ -97,33 +99,28 @@ impl TextInput { self.state.as_mut().unwrap() } - pub fn on_input_menu(&mut self, th19: &Th19) -> OnMenuInputResult { + pub fn on_input_menu(&mut self, device: &impl MenuDevice) -> OnTextInputResult { if self.state.is_none() { - self.state = Some(TextInputState::new( - th19.input_devices().keyboard_input().raw_keys(), - )); - return OnMenuInputResult::None; + self.state = Some(TextInputState::new(device.keyboard_raw_keys())); + return OnTextInputResult::None; } - for ascii in self - .state_mut() - .tick(th19.input_devices().keyboard_input().raw_keys()) - { + for ascii in self.state_mut().tick(device.keyboard_raw_keys()) { if (0x20..0x7f).contains(&ascii) { self.value.push(ascii as char); } else if ascii == 0x08 { self.value.pop(); } else if ascii == 0x0d { // CR - return OnMenuInputResult::Decide(self.changed_action, self.value.clone()); + return OnTextInputResult::Decide(self.changed_action, self.value.clone()); } else if ascii == 0x1b { // ESC - return OnMenuInputResult::Cancel; + return OnTextInputResult::Cancel; } } - OnMenuInputResult::None + OnTextInputResult::None } - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - render_label_value(th19, text_renderer, 480, 0, self.name, &self.value); + pub fn on_render_texts(&self, renderer: &impl MenuRenderer) { + render_label_value(renderer, 480, 0, self.name, &self.value); } } diff --git a/crates/features/junowen-lobby/src/menu/mod.rs b/crates/features/junowen-lobby/src/menu/mod.rs new file mode 100644 index 0000000..3badc00 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/mod.rs @@ -0,0 +1,15 @@ +//! ロビー画面のメニュー部品 + +mod action; +mod common_menu; +mod device; +mod input; +mod render; +mod tree; + +pub use action::OnMenuInputResult; +pub use common_menu::CommonMenu; +pub use device::{MenuDevice, MenuSound}; +pub use input::MenuButtons; +pub use render::{MenuRenderer, MenuText, menu_item_color, render_label_value}; +pub use tree::{Menu, MenuItem}; diff --git a/crates/features/junowen-lobby/src/menu/render.rs b/crates/features/junowen-lobby/src/menu/render.rs new file mode 100644 index 0000000..ec458d7 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/render.rs @@ -0,0 +1,123 @@ +/// ゲーム内フォントで描画するテキスト。座標はゲーム内と同じ 1280x960 換算 +#[derive(Clone, Copy, Debug)] +pub struct MenuText<'a> { + pub text: &'a [u8], + pub x: u32, + pub y: u32, + /// 0xaarrggbb + pub color: u32, + pub font_type: u32, + pub drop_shadow: bool, + /// 0: center, 1: left, 2: right + pub horizontal_align: u32, + /// 0: center, 1: top, 2: bottom + pub vertical_align: u32, +} + +impl Default for MenuText<'_> { + fn default() -> Self { + Self { + text: b"", + x: 0, + y: 0, + color: 0xffffffff, + font_type: 0, + drop_shadow: false, + horizontal_align: 1, + vertical_align: 1, + } + } +} + +pub trait MenuRenderer { + fn render_text(&self, text: &MenuText); +} + +pub fn render_title(renderer: &impl MenuRenderer, text: &[u8]) { + let mut mt = MenuText { + text, + x: 640, + y: 64, + color: 0xff000000, + font_type: 9, + drop_shadow: true, + horizontal_align: 0, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.color = 0xffffffff; + mt.font_type = 7; + renderer.render_text(&mt); +} + +pub fn render_menu_item( + renderer: &impl MenuRenderer, + text: &[u8], + y: u32, + enabled: bool, + selected: bool, +) { + let mut mt = MenuText { + text, + x: 640, + y, + color: menu_item_color(9, enabled, selected), + font_type: 9, + horizontal_align: 0, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.color = menu_item_color(7, enabled, selected); + mt.font_type = 7; + renderer.render_text(&mt); +} + +pub fn menu_item_color(font_type: u32, enabled: bool, selected: bool) -> u32 { + if !enabled { + match font_type { + 9 => 0x40ffffff, + 7 => 0xff808080, + _ => unreachable!(), + } + } else if selected { + match font_type { + 9 => 0xff000000, + 7 => 0xffffff80, + _ => unreachable!(), + } + } else { + match font_type { + 9 => 0xff404040, + 7 => 0xff808060, + _ => unreachable!(), + } + } +} + +pub fn render_label_value( + renderer: &impl MenuRenderer, + height: u32, + vertical_align: u32, + label: &str, + value: &str, +) { + let label = format!("{:<11}:", label); + let mut mt = MenuText { + text: label.as_bytes(), + x: 320, + y: height, + color: 0xffffffff, + font_type: 0, + horizontal_align: 1, + vertical_align, + ..Default::default() + }; + renderer.render_text(&mt); + + mt.text = value.as_bytes(); + mt.color = 0xffffffa0; + mt.x = 544; + renderer.render_text(&mt); +} diff --git a/crates/features/junowen-lobby/src/menu/tree/current_scene.rs b/crates/features/junowen-lobby/src/menu/tree/current_scene.rs new file mode 100644 index 0000000..c052069 --- /dev/null +++ b/crates/features/junowen-lobby/src/menu/tree/current_scene.rs @@ -0,0 +1,67 @@ +use crate::menu::input::TextInput; + +use super::{Menu, MenuItem}; + +#[derive(Debug)] +pub enum CurrentMenuSceneResult<'a, S> { + Menu(&'a Menu), + SubScene(S), + TextInput(&'static str, &'a TextInput), +} + +pub enum CurrentMenuSceneMutResult<'a, S> { + Menu(&'a mut Menu), + SubScene, + TextInput(&'a mut TextInput), +} + +/// 決定済みのサブメニューをたどり、操作対象の画面を返す +pub fn current_menu_scene(root: &Menu) -> CurrentMenuSceneResult<'_, S> { + if !root.decided() { + return CurrentMenuSceneResult::Menu(root); + } + let mut menu = root; + loop { + match menu.selected_item() { + MenuItem::Plain(_) => unreachable!(), + MenuItem::SubMenu(item) => { + if item.sub_menu().decided() { + menu = item.sub_menu(); + continue; + } + return CurrentMenuSceneResult::Menu(item.sub_menu()); + } + MenuItem::SubScene(item) => { + return CurrentMenuSceneResult::SubScene(item.sub_scene()); + } + MenuItem::TextInput(item) => { + return CurrentMenuSceneResult::TextInput(item.label(), item.text_input()); + } + } + } +} + +pub fn current_menu_scene_mut(root: &mut Menu) -> CurrentMenuSceneMutResult<'_, S> { + if !root.decided() { + return CurrentMenuSceneMutResult::Menu(root); + } + let mut menu = root; + loop { + match menu.selected_item_mut() { + MenuItem::Plain(_) => unreachable!(), + MenuItem::SubMenu(item) => { + if item.sub_menu().decided() { + menu = item.sub_menu_mut(); + continue; + } + return CurrentMenuSceneMutResult::Menu(item.sub_menu_mut()); + } + MenuItem::SubScene(_item) => { + return CurrentMenuSceneMutResult::SubScene; + } + MenuItem::TextInput(item) => { + return CurrentMenuSceneMutResult::TextInput(item.text_input_mut()); + } + } + } +} diff --git a/junowen/src/lobby/common_menu/menu_item.rs b/crates/features/junowen-lobby/src/menu/tree/menu_item.rs similarity index 87% rename from junowen/src/lobby/common_menu/menu_item.rs rename to crates/features/junowen-lobby/src/menu/tree/menu_item.rs index 913729c..8db4239 100644 --- a/junowen/src/lobby/common_menu/menu_item.rs +++ b/crates/features/junowen-lobby/src/menu/tree/menu_item.rs @@ -1,6 +1,8 @@ use getset::{CopyGetters, Getters, MutGetters, Setters}; -use super::{Action, LobbyScene, menu::Menu, text_input::TextInput}; +use crate::menu::{action::Action, input::TextInput}; + +use super::Menu; #[derive(Debug, Setters)] pub struct MenuPlainItem { @@ -12,13 +14,13 @@ pub struct MenuPlainItem { } #[derive(Debug, Getters, MutGetters, Setters)] -pub struct MenuSubMenuItem { +pub struct MenuSubMenuItem { #[set = "pub"] label: &'static str, enabled: bool, decided_action: Option, #[getset(get = "pub", get_mut = "pub")] - sub_menu: Menu, + sub_menu: Menu, } #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] @@ -32,23 +34,28 @@ pub struct MenuTextInputItem { } #[derive(CopyGetters, Debug, Setters)] -pub struct MenuSubSceneItem { +pub struct MenuSubSceneItem { #[getset(get_copy = "pub", set = "pub")] label: &'static str, enabled: bool, - #[get_copy = "pub"] - sub_scene: LobbyScene, + sub_scene: S, +} + +impl MenuSubSceneItem { + pub fn sub_scene(&self) -> S { + self.sub_scene + } } #[derive(Debug)] -pub enum MenuItem { +pub enum MenuItem { Plain(MenuPlainItem), - SubMenu(MenuSubMenuItem), + SubMenu(MenuSubMenuItem), TextInput(MenuTextInputItem), - SubScene(MenuSubSceneItem), + SubScene(MenuSubSceneItem), } -impl MenuItem { +impl MenuItem { pub fn plain(label: &'static str, decided_action: u8, play_sound: bool) -> Self { Self::Plain(MenuPlainItem { label, @@ -58,7 +65,7 @@ impl MenuItem { }) } - pub fn sub_menu(label: &'static str, decided_action: Option, sub_menu: Menu) -> Self { + pub fn sub_menu(label: &'static str, decided_action: Option, sub_menu: Menu) -> Self { Self::SubMenu(MenuSubMenuItem { label, enabled: true, @@ -67,7 +74,7 @@ impl MenuItem { }) } - pub fn sub_scene(label: &'static str, sub_scene: LobbyScene) -> Self { + pub fn sub_scene(label: &'static str, sub_scene: S) -> Self { Self::SubScene(MenuSubSceneItem { label, enabled: true, diff --git a/junowen/src/lobby/common_menu/menu.rs b/crates/features/junowen-lobby/src/menu/tree/mod.rs similarity index 75% rename from junowen/src/lobby/common_menu/menu.rs rename to crates/features/junowen-lobby/src/menu/tree/mod.rs index dc0d219..fb58ca2 100644 --- a/junowen/src/lobby/common_menu/menu.rs +++ b/crates/features/junowen-lobby/src/menu/tree/mod.rs @@ -1,30 +1,38 @@ -use std::ffi::c_void; +mod current_scene; +mod menu_item; use getset::{CopyGetters, Getters, MutGetters, Setters}; -use junowen_lib::Th19; -use crate::lobby::helper::render_menu_item; +use crate::menu::{ + action::Action, + device::{MenuDevice, MenuSound}, + input::MenuControllerInputResult, + render::{MenuRenderer, render_menu_item}, +}; -use super::{Action, LobbyScene, menu_controller::MenuControllerInputResult, menu_item::MenuItem}; +pub use current_scene::{ + CurrentMenuSceneMutResult, CurrentMenuSceneResult, current_menu_scene, current_menu_scene_mut, +}; +pub use menu_item::MenuItem; #[derive(CopyGetters, Debug, Getters, MutGetters, Setters)] -pub struct Menu { +pub struct Menu { #[get_copy = "pub"] title: &'static str, canceled_action: Option, #[getset(get = "pub", get_mut = "pub")] - items: Vec, + items: Vec>, #[get_copy = "pub"] cursor: usize, #[get_copy = "pub"] decided: bool, } -impl Menu { +impl Menu { pub fn new( title: &'static str, canceled_action: Option, - items: Vec, + items: Vec>, cursor: usize, ) -> Self { Self { @@ -36,15 +44,15 @@ impl Menu { } } - pub fn selected_item(&self) -> &MenuItem { + pub fn selected_item(&self) -> &MenuItem { &self.items[self.cursor] } - pub fn selected_item_mut(&mut self) -> &mut MenuItem { + pub fn selected_item_mut(&mut self) -> &mut MenuItem { &mut self.items[self.cursor] } - pub fn dig(&mut self) -> Option { + pub fn dig(&mut self) -> Option { if !self.decided { if let MenuItem::SubScene(scene) = self.selected_item() { return Some(scene.sub_scene()); @@ -73,18 +81,11 @@ impl Menu { sub_menu.sub_menu_mut().bury() } - pub fn on_render_texts(&self, base_height: u32, th19: &Th19, text_renderer: &c_void) { + pub fn on_render_texts(&self, base_height: u32, renderer: &impl MenuRenderer) { for (i, item) in self.items().iter().enumerate() { let label = item.label().as_bytes(); let height = base_height + 56 * i as u32; - render_menu_item( - th19, - text_renderer, - label, - height, - item.enabled(), - i == self.cursor(), - ); + render_menu_item(renderer, label, height, item.enabled(), i == self.cursor()); } } @@ -114,12 +115,12 @@ impl Menu { input_result: MenuControllerInputResult, ignore_decide: bool, play_decide_sound: bool, - th19: &Th19, + device: &impl MenuDevice, ) -> Option { match input_result { MenuControllerInputResult::None => None, MenuControllerInputResult::Cancel => { - th19.play_sound(th19.sound_manager(), 0x09, 0); + device.play_sound(MenuSound::Cancel); self.canceled_action.map(|id| Action::new(id, false, None)) } MenuControllerInputResult::Decide => { @@ -127,7 +128,7 @@ impl Menu { return None; } if play_decide_sound { - th19.play_sound(th19.sound_manager(), 0x07, 0); + device.play_sound(MenuSound::Decide); } self.selected_item().decided_action() } @@ -135,14 +136,14 @@ impl Menu { if !self.decrement_cursor() { return None; } - th19.play_sound(th19.sound_manager(), 0x0a, 0); + device.play_sound(MenuSound::Select); None } MenuControllerInputResult::Down => { if !self.increment_cursor() { return None; } - th19.play_sound(th19.sound_manager(), 0x0a, 0); + device.play_sound(MenuSound::Select); None } } diff --git a/crates/features/junowen-lobby/src/overlay_text.rs b/crates/features/junowen-lobby/src/overlay_text.rs new file mode 100644 index 0000000..c61d520 --- /dev/null +++ b/crates/features/junowen-lobby/src/overlay_text.rs @@ -0,0 +1,8 @@ +/// ゲーム内フォントでは英数字しか描画できないため、多言語のテキストは +/// GDI で画面に重ねて描画する。座標はゲーム内と同じ 1280x960 換算。 +pub struct OverlayText { + pub left: i32, + pub top: i32, + pub font_size: u32, + pub string: String, +} diff --git a/junowen/src/lobby/pure_p2p_offerer/menu.rs b/crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs similarity index 92% rename from junowen/src/lobby/pure_p2p_offerer/menu.rs rename to crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs index 3dec54c..eabf7e5 100644 --- a/junowen/src/lobby/pure_p2p_offerer/menu.rs +++ b/crates/features/junowen-lobby/src/pure_p2p_offerer_menu.rs @@ -1,4 +1,4 @@ -use crate::lobby::common_menu::{CommonMenu, Menu, MenuItem}; +use crate::{CommonMenu, Menu, MenuItem}; const BASE_HEIGHT: u32 = 720; diff --git a/crates/features/junowen-lobby/src/scene.rs b/crates/features/junowen-lobby/src/scene.rs new file mode 100644 index 0000000..96b69d8 --- /dev/null +++ b/crates/features/junowen-lobby/src/scene.rs @@ -0,0 +1,17 @@ +use crate::menu; + +#[derive(Clone, Copy, Debug)] +pub enum LobbyScene { + Root, + SharedRoom, + ReservedRoom, + TcpSignaling, + PureP2pHost, + PureP2pGuest, + PureP2pSpectator, +} + +pub type CommonMenu = menu::CommonMenu; +pub type Menu = menu::Menu; +pub type MenuItem = menu::MenuItem; +pub type OnMenuInputResult = menu::OnMenuInputResult; diff --git a/junowen/src/lobby/room/tcp_signaling/menu.rs b/crates/features/junowen-lobby/src/tcp_signaling_menu.rs similarity index 96% rename from junowen/src/lobby/room/tcp_signaling/menu.rs rename to crates/features/junowen-lobby/src/tcp_signaling_menu.rs index 2bb1d33..3b503af 100644 --- a/junowen/src/lobby/room/tcp_signaling/menu.rs +++ b/crates/features/junowen-lobby/src/tcp_signaling_menu.rs @@ -1,4 +1,4 @@ -use crate::lobby::common_menu::{CommonMenu, Menu, MenuItem}; +use crate::{CommonMenu, Menu, MenuItem}; const HOST_LABEL: &str = "Connect as a Host"; const GUEST_LABEL: &str = "Connect as a Guest"; @@ -47,6 +47,12 @@ pub struct TcpSignalingMenu { offline: Option, } +impl Default for TcpSignalingMenu { + fn default() -> Self { + Self::new() + } +} + impl TcpSignalingMenu { pub fn new() -> Self { Self { diff --git a/junowen/src/lobby/text_layout.rs b/crates/features/junowen-lobby/src/text_layout.rs similarity index 65% rename from junowen/src/lobby/text_layout.rs rename to crates/features/junowen-lobby/src/text_layout.rs index c04d108..69e6450 100644 --- a/junowen/src/lobby/text_layout.rs +++ b/crates/features/junowen-lobby/src/text_layout.rs @@ -1,15 +1,11 @@ -use std::ffi::c_void; - -use junowen_lib::Th19; - -use super::{ +use crate::{ + MenuRenderer, OverlayText, helper::{overlay_text_line, render_signaling_code}, - overlay::OverlayText, }; /// 画面に並ぶ要素を行番号で組み立てたもの。 -/// テキストは多言語を表示するため overlay で、シグナリングコードは -/// 英数字だけなのでゲーム内フォントで描画する。 +/// 多言語のテキストは overlay で描画する。 +/// シグナリングコードは英数字だけなので、ゲーム内フォントで描画する。 #[derive(Default)] pub struct TextLayout { texts: Vec<(u32, String)>, @@ -25,9 +21,9 @@ impl TextLayout { self.codes.push((line, code.into())); } - pub fn render_codes(&self, th19: &Th19, text_renderer: &c_void) { + pub fn render_codes(&self, renderer: &impl MenuRenderer) { for (line, code) in &self.codes { - render_signaling_code(th19, text_renderer, *line, code); + render_signaling_code(renderer, *line, code); } } diff --git a/junowen-lib/src/connection.rs b/junowen-lib/src/connection.rs deleted file mode 100644 index 374567d..0000000 --- a/junowen-lib/src/connection.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod data_channel; -mod peer_connection; -pub mod signaling; - -pub use self::{data_channel::DataChannel, peer_connection::PeerConnection}; diff --git a/junowen/src/lobby/common_menu.rs b/junowen/src/lobby/common_menu.rs deleted file mode 100644 index fd83592..0000000 --- a/junowen/src/lobby/common_menu.rs +++ /dev/null @@ -1,243 +0,0 @@ -mod menu; -mod menu_controller; -mod menu_item; -mod text_input; - -use std::ffi::c_void; - -use derive_new::new; -use getset::{CopyGetters, Getters, MutGetters}; -use junowen_lib::{Th19, structs::input_devices::InputValue}; - -use self::{ - menu_controller::{ - MenuController, MenuControllerInputResult, MenuControllerUpdateDecideResult, - }, - text_input::TextInput, -}; - -use super::helper::render_title; - -pub use {menu::Menu, menu_item::MenuItem}; - -#[derive(Debug, CopyGetters, new)] -pub struct Action { - #[get_copy = "pub"] - id: u8, - #[get_copy = "pub"] - play_sound: bool, - value: Option, -} - -impl Action { - pub fn value(&self) -> Option<&str> { - self.value.as_deref() - } -} - -#[derive(Clone, Copy, Debug)] -pub enum LobbyScene { - Root, - SharedRoom, - ReservedRoom, - TcpSignaling, - PureP2pHost, - PureP2pGuest, - PureP2pSpectator, -} - -pub enum OnMenuInputResult { - None, - Cancel, - Action(Action), - SubScene(LobbyScene), -} - -#[derive(Debug)] -enum CurrentMenuSceneResult<'a> { - Menu(&'a Menu), - SubScene(LobbyScene), - TextInput(&'static str, &'a TextInput), -} - -enum CurrentMenuSceneMutResult<'a> { - Menu(&'a mut Menu), - SubScene, - TextInput(&'a mut TextInput), -} - -#[derive(Getters, MutGetters)] -pub struct CommonMenu { - #[getset(get = "pub", get_mut = "pub")] - menu: Menu, - instant_exit: bool, - base_height: u32, - #[getset(get_mut = "pub")] - controller: MenuController, -} - -impl CommonMenu { - pub fn new(instant_exit: bool, base_height: u32, menu: Menu) -> Self { - Self { - menu, - instant_exit, - base_height, - controller: MenuController::default(), - } - } - - pub fn root_title(&self) -> &'static str { - self.menu.title() - } - - fn apply_decide_count(&mut self) -> Option { - match self.controller.update_decide() { - MenuControllerUpdateDecideResult::None => None, - MenuControllerUpdateDecideResult::Wait => Some(OnMenuInputResult::None), - MenuControllerUpdateDecideResult::Decide => { - if let Some(scene) = self.menu.dig() { - return Some(OnMenuInputResult::SubScene(scene)); - } - None - } - MenuControllerUpdateDecideResult::Cancel => { - if !self.menu.bury() { - return Some(OnMenuInputResult::Cancel); - } - None - } - } - } - - pub fn on_input_menu( - &mut self, - current_input: InputValue, - prev_input: InputValue, - th19: &Th19, - ) -> OnMenuInputResult { - if let Some(result) = self.apply_decide_count() { - return result; - } - - match self.current_menu_scene() { - CurrentMenuSceneResult::SubScene(scene) => OnMenuInputResult::SubScene(scene), - CurrentMenuSceneResult::Menu(menu) => { - let ignore_decide = menu.items().is_empty(); - let instant_decide = - ignore_decide || matches!(menu.selected_item(), MenuItem::Plain(_)); - let play_decide_sound = !ignore_decide - && menu - .selected_item() - .decided_action() - .map(|x| x.play_sound()) - .unwrap_or(true); - let root_cancel = !self.menu.decided() && self.instant_exit; - - let input_result = - self.controller - .input(current_input, prev_input, instant_decide, root_cancel); - - if root_cancel { - if let MenuControllerInputResult::Cancel = input_result { - return OnMenuInputResult::Cancel; - } - } - let CurrentMenuSceneMutResult::Menu(menu) = self.current_menu_scene_mut() else { - unreachable!() - }; - if let Some(action) = - menu.input(input_result, ignore_decide, play_decide_sound, th19) - { - OnMenuInputResult::Action(action) - } else { - OnMenuInputResult::None - } - } - CurrentMenuSceneResult::TextInput(_, _) => { - let CurrentMenuSceneMutResult::TextInput(text_input) = - self.current_menu_scene_mut() - else { - unreachable!() - }; - match text_input.on_input_menu(th19) { - text_input::OnMenuInputResult::None => OnMenuInputResult::None, - text_input::OnMenuInputResult::Cancel => { - th19.play_sound(th19.sound_manager(), 0x09, 0); - self.controller.force_cancel(); - OnMenuInputResult::None - } - text_input::OnMenuInputResult::Decide(changed_action, new_room_name) => { - th19.play_sound(th19.sound_manager(), 0x07, 0); - self.controller.force_cancel(); - let action = Action::new(changed_action, false, Some(new_room_name)); - OnMenuInputResult::Action(action) - } - } - } - } - } - - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - match self.current_menu_scene() { - CurrentMenuSceneResult::SubScene { .. } => unreachable!(), - CurrentMenuSceneResult::Menu(menu) => { - render_title(th19, text_renderer, menu.title().as_bytes()); - menu.on_render_texts(self.base_height, th19, text_renderer); - } - CurrentMenuSceneResult::TextInput(label, text_input) => { - render_title(th19, text_renderer, label.as_bytes()); - text_input.on_render_texts(th19, text_renderer); - } - } - } - - fn current_menu_scene(&self) -> CurrentMenuSceneResult { - if !self.menu.decided() { - return CurrentMenuSceneResult::Menu(&self.menu); - } - let mut menu = &self.menu; - loop { - match menu.selected_item() { - MenuItem::Plain(_) => unreachable!(), - MenuItem::SubMenu(item) => { - if item.sub_menu().decided() { - menu = item.sub_menu(); - continue; - } - return CurrentMenuSceneResult::Menu(item.sub_menu()); - } - MenuItem::SubScene(item) => { - return CurrentMenuSceneResult::SubScene(item.sub_scene()); - } - MenuItem::TextInput(item) => { - return CurrentMenuSceneResult::TextInput(item.label(), item.text_input()); - } - } - } - } - - fn current_menu_scene_mut(&mut self) -> CurrentMenuSceneMutResult { - if !self.menu.decided() { - return CurrentMenuSceneMutResult::Menu(&mut self.menu); - } - let mut menu = &mut self.menu; - loop { - match menu.selected_item_mut() { - MenuItem::Plain(_) => unreachable!(), - MenuItem::SubMenu(item) => { - if item.sub_menu().decided() { - menu = item.sub_menu_mut(); - continue; - } - return CurrentMenuSceneMutResult::Menu(item.sub_menu_mut()); - } - MenuItem::SubScene(_item) => { - return CurrentMenuSceneMutResult::SubScene; - } - MenuItem::TextInput(item) => { - return CurrentMenuSceneMutResult::TextInput(item.text_input_mut()); - } - } - } - } -} diff --git a/junowen/src/lobby/helper.rs b/junowen/src/lobby/helper.rs deleted file mode 100644 index d2d8f30..0000000 --- a/junowen/src/lobby/helper.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::ffi::c_void; - -use junowen_lib::{Th19, structs::others::RenderingText}; - -use super::overlay::OverlayText; - -const TEXT_LINE_LEFT: u32 = 32; -const TEXT_LINE_TOP: u32 = 160; -const TEXT_LINE_HEIGHT: u32 = 32; -const SIGNALING_CODE_CHUNK_LEN: usize = 100; - -pub fn render_title(th19: &Th19, text_renderer: &c_void, text: &[u8]) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(640, th19.window_inner()); - rt.set_y(64, th19.window_inner()); - rt.color = 0xff000000; - rt.font_type = 9; - rt.drop_shadow = true; - rt.horizontal_align = 0; - th19.render_text(text_renderer, &rt); - - rt.color = 0xffffffff; - rt.font_type = 7; - th19.render_text(text_renderer, &rt); -} - -pub fn render_menu_item( - th19: &Th19, - text_renderer: &c_void, - text: &[u8], - y: u32, - enabled: bool, - selected: bool, -) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(640, th19.window_inner()); - rt.set_y(y, th19.window_inner()); - rt.color = menu_item_color(9, enabled, selected); - rt.font_type = 9; - rt.horizontal_align = 0; - th19.render_text(text_renderer, &rt); - - rt.color = menu_item_color(7, enabled, selected); - rt.font_type = 7; - th19.render_text(text_renderer, &rt); -} - -pub fn render_text_line(th19: &Th19, text_renderer: &c_void, line: u32, text: &[u8]) { - let mut rt = RenderingText::default(); - rt.set_text(text); - rt.set_x(TEXT_LINE_LEFT, th19.window_inner()); - rt.set_y(TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT, th19.window_inner()); - rt.color = 0xff000000; - rt.font_type = 8; - th19.render_text(text_renderer, &rt); - - rt.color = 0xffffffff; - rt.font_type = 6; - th19.render_text(text_renderer, &rt); -} - -/// シグナリングコードは 1 行に収まらないので、半分の行高に詰めて折り返す -pub fn render_signaling_code(th19: &Th19, text_renderer: &c_void, line: u32, code: &str) { - for (i, chunk) in code.as_bytes().chunks(SIGNALING_CODE_CHUNK_LEN).enumerate() { - let mut rt = RenderingText::default(); - rt.set_text(chunk); - rt.set_x(TEXT_LINE_LEFT, th19.window_inner()); - rt.set_y( - TEXT_LINE_TOP + (line * 2 + i as u32) * (TEXT_LINE_HEIGHT / 2), - th19.window_inner(), - ); - rt.font_type = 1; - th19.render_text(text_renderer, &rt); - } -} - -/// render_signaling_code が消費する行数 -pub fn signaling_code_line_count(code: &str) -> u32 { - let chunks = code.as_bytes().chunks(SIGNALING_CODE_CHUNK_LEN).len(); - (chunks as f64 / 2.0).ceil() as u32 -} - -/// render_text_line と同じ位置へ多言語テキストを重ねる -pub fn overlay_text_line(line: u32, string: String) -> OverlayText { - OverlayText { - left: TEXT_LINE_LEFT as i32, - top: (TEXT_LINE_TOP + line * TEXT_LINE_HEIGHT) as i32, - font_size: TEXT_LINE_HEIGHT, - string, - } -} - -/// 画面下部にメニュー項目の説明文を重ねる -pub fn overlay_description(string: String) -> Vec { - if string.is_empty() { - return vec![]; - } - vec![OverlayText { - left: 96, - top: 640, - font_size: 58, - string, - }] -} - -pub fn menu_item_color(font_type: u32, enabled: bool, selected: bool) -> u32 { - if !enabled { - match font_type { - 9 => 0x40ffffff, - 7 => 0xff808080, - _ => unreachable!(), - } - } else if selected { - match font_type { - 9 => 0xff000000, - 7 => 0xffffff80, - _ => unreachable!(), - } - } else { - match font_type { - 9 => 0xff404040, - 7 => 0xff808060, - _ => unreachable!(), - } - } -} - -pub fn render_label_value( - th19: &Th19, - text_renderer: &c_void, - height: u32, - vertical_align: u32, - label: &str, - value: &str, -) { - let mut rt = RenderingText::default(); - rt.set_text(format!("{:<11}:", label).as_bytes()); - rt.set_x(320, th19.window_inner()); - rt.set_y(height, th19.window_inner()); - rt.color = 0xffffffff; - rt.font_type = 0; - rt.horizontal_align = 1; - rt.vertical_align = vertical_align; - th19.render_text(text_renderer, &rt); - - rt.set_text(value.as_bytes()); - rt.color = 0xffffffa0; - rt.set_x(544, th19.window_inner()); - th19.render_text(text_renderer, &rt); -} diff --git a/junowen/src/session/spectator.rs b/junowen/src/session/spectator.rs deleted file mode 100644 index 67cd8b5..0000000 --- a/junowen/src/session/spectator.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::sync::mpsc::RecvError; - -use anyhow::Result; -use derive_new::new; -use getset::{CopyGetters, Getters, Setters}; -use junowen_lib::{ - connection::{DataChannel, PeerConnection}, - structs::settings::GameSettings, -}; -use serde::{Deserialize, Serialize}; -use tracing::{error, info}; - -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"] - p1_name: String, - #[get = "pub"] - p2_name: String, - #[get = "pub"] - game_settings: GameSettings, - #[get = "pub"] - initial_state: InitialState, -} - -#[derive(Debug, Deserialize, Serialize)] -pub enum SpectatorSessionMessage { - InitSpectator(SpectatorInitial), - InitRound(RoundInitial), - Inputs(u16, u16), -} - -#[derive(CopyGetters, Getters, Setters)] -pub struct SpectatorSession { - _conn: PeerConnection, - hook_incoming_rx: std::sync::mpsc::Receiver, - spectator_initial: Option, - round_initial: Option, -} - -impl SpectatorSession { - pub fn new(conn: PeerConnection, data_channel: DataChannel) -> Self { - let (_hook_outgoing_tx, hook_incoming_rx) = - to_channel(data_channel, |input| rmp_serde::from_slice(input)); - Self { - _conn: conn, - hook_incoming_rx, - spectator_initial: None, - round_initial: None, - } - } - - pub fn spectator_initial(&self) -> Option<&SpectatorInitial> { - self.spectator_initial.as_ref() - } - - pub fn recv_init_spectator(&mut self) -> Result<(), RecvError> { - let init = match self.hook_incoming_rx.recv()? { - SpectatorSessionMessage::InitSpectator(init) => init, - msg => { - error!("unexpected message: {:?}", msg); - return Err(RecvError); - } - }; - self.spectator_initial = Some(init); - Ok(()) - } - - pub fn dequeue_init_round(&mut self) -> Result { - if let Some(round_initial) = self.round_initial.take() { - return Ok(round_initial); - } - loop { - match self.hook_incoming_rx.recv()? { - SpectatorSessionMessage::InitSpectator(init) => { - error!("unexpected init spectator message: {:?}", init); - return Err(RecvError); - } - 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() { - return Ok((0, 0)); - } - match self.hook_incoming_rx.recv()? { - SpectatorSessionMessage::InitSpectator(init) => { - error!("unexpected init spectator message: {:?}", init); - Err(RecvError) - } - SpectatorSessionMessage::InitRound(round_initial) => { - self.round_initial = Some(round_initial); - Ok((0, 0)) - } - SpectatorSessionMessage::Inputs(p1, p2) => Ok((p1, p2)), - } - } -} - -impl Drop for SpectatorSession { - fn drop(&mut self) { - info!("spectator session guest closed"); - } -} diff --git a/junowen/src/state/battle_session/spectator_host.rs b/junowen/src/state/battle_session/spectator_host.rs deleted file mode 100644 index 7835082..0000000 --- a/junowen/src/state/battle_session/spectator_host.rs +++ /dev/null @@ -1,156 +0,0 @@ -use anyhow::{Result, bail}; -use getset::Getters; -use junowen_lib::{ - Th19, - structs::app::{MainMenu, ScreenId}, - structs::selection::Selection, -}; -use tracing::info; - -use crate::{ - session::{ - RoundInitial, - battle::BattleSession, - spectator::{self, InitialState, 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() - } 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, - ), - ) -} - -#[derive(Getters)] -pub struct SpectatorHostState { - #[get = "pub"] - waiting: WaitingForSpectator, - sessions: Vec, -} - -impl SpectatorHostState { - pub fn new(waiting: WaitingForSpectator) -> Self { - Self { - waiting, - sessions: Vec::new(), - } - } - - pub fn count_spectators(&self) -> usize { - 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, - main_menu: Option<&MainMenu>, - th19: &Th19, - ) -> Result<()> { - let Some(main_menu) = main_menu else { - bail!("spectator not supported yet."); - }; - 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() - ); - } - 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(()) - } - - pub fn update( - &mut self, - pushed: bool, - main_menu: Option<&MainMenu>, - th19: &Th19, - battle_session: &BattleSession, - 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); - } - } - self.sessions.retain(|session| { - if let Err(err) = session.send_inputs(p1_input, p2_input) { - info!("spectator host error: {:?}", err); - return false; - } - true - }); - } -} diff --git a/junowen/src/state/spectator_session.rs b/junowen/src/state/spectator_session.rs deleted file mode 100644 index 42b0c75..0000000 --- a/junowen/src/state/spectator_session.rs +++ /dev/null @@ -1,184 +0,0 @@ -mod in_session; -mod spectator_game; -mod spectator_select; - -use std::{ffi::c_void, sync::mpsc::RecvError}; - -use anyhow::Result; -use junowen_lib::{ - Th19, - structs::settings::GameSettings, - structs::{ - app::{MainMenu, ScreenId}, - input_devices::InputFlags, - }, -}; - -use crate::session::spectator::SpectatorSession as SpectatorSessionProps; - -use super::prepare::Prepare; - -use {spectator_game::SpectatorGame, spectator_select::SpectatorSelect}; - -pub struct SpectatorSession { - props: SpectatorSessionProps, - state: SpectatorSessionState, -} - -enum SpectatorSessionState { - Prepare(Prepare), - Select(SpectatorSelect), - GameLoading, - Game(SpectatorGame), - BackToSelect, -} - -impl SpectatorSession { - pub fn prepare(props: SpectatorSessionProps) -> Self { - Self { - props, - state: SpectatorSessionState::Prepare(Prepare::new()), - } - } - - 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_game_loading(&mut self) { - self.state = SpectatorSessionState::GameLoading; - } - pub fn change_to_game(&mut self) { - self.state = SpectatorSessionState::Game(SpectatorGame); - } - pub fn change_to_back_to_select(&mut self) { - self.state = SpectatorSessionState::BackToSelect; - } - - pub fn update_state(&mut self, th19: &Th19) -> Option> { - match &mut self.state { - SpectatorSessionState::Prepare(prepare) => { - let Some(main_menu) = th19.app().main_loop_tasks().find_main_menu() else { - return Some(None); - }; - if prepare.update_state(main_menu, th19) { - self.change_to_select(); - } - Some(Some(main_menu)) - } - SpectatorSessionState::Select { .. } => { - let main_menu = th19.app().main_loop_tasks().find_main_menu().unwrap(); - 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(); - Some(Some(main_menu)) - } - _ => Some(Some(main_menu)), - } - } - SpectatorSessionState::GameLoading { .. } => { - let Some(round_frame) = th19.round_frame() else { - return Some(None); - }; - if !round_frame.is_first_frame() { - return Some(None); - } - self.change_to_game(); - Some(None) - } - SpectatorSessionState::Game { .. } => { - if th19.input_devices().p1_input().current().0 & InputFlags::PAUSE != None { - return None; - } - if th19.round_frame().is_some() { - return Some(None); - } - self.change_to_back_to_select(); - Some(None) - } - 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(); - Some(Some(main_menu)) - } - } - } - - pub fn update_th19_on_input_players( - &mut self, - menu: Option<&MainMenu>, - th19: &mut Th19, - ) -> 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::GameLoading { .. } => { - if th19.no_wait() { - th19.set_no_wait(false); - } - } - SpectatorSessionState::Game(game) => game.update_th19(&mut self.props, th19)?, - SpectatorSessionState::BackToSelect { .. } => {} - } - Ok(()) - } - - 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::GameLoading { .. } => {} - SpectatorSessionState::Game { .. } => {} - SpectatorSessionState::BackToSelect { .. } => {} - } - Ok(true) - } - - pub fn on_render_texts(&self, th19: &Th19, text_renderer: &c_void) { - let Some(initial) = self.props.spectator_initial() else { - return; - }; - in_session::on_render_texts_spectator( - th19, - text_renderer, - initial.p1_name(), - initial.p2_name(), - ); - } - - pub fn on_round_over(&mut self, th19: &mut Th19) -> Result<(), RecvError> { - let SpectatorSessionState::Game(game) = &mut self.state else { - return Ok(()); - }; - game.on_round_over(&mut self.props, th19) - } -} diff --git a/junowen/src/state/spectator_session/in_session.rs b/junowen/src/state/spectator_session/in_session.rs deleted file mode 100644 index 252aca9..0000000 --- a/junowen/src/state/spectator_session/in_session.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::ffi::c_void; - -use junowen_lib::Th19; - -use crate::state::render_parts::{render_footer, render_names}; - -pub fn on_render_texts_spectator( - th19: &Th19, - text_renderer: &c_void, - p1_name: &str, - p2_name: &str, -) { - render_names(th19, text_renderer, p1_name, p2_name); - render_footer(th19, text_renderer, "(Spectating)", ""); -} diff --git a/junowen/src/state/spectator_session/spectator_game.rs b/junowen/src/state/spectator_session/spectator_game.rs deleted file mode 100644 index d46e96e..0000000 --- a/junowen/src/state/spectator_session/spectator_game.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::sync::mpsc::RecvError; - -use anyhow::Result; -use junowen_lib::{Th19, structs::input_devices::InputValue}; - -use crate::session::spectator::SpectatorSession; - -pub struct SpectatorGame; - -impl SpectatorGame { - pub fn update_th19( - &mut self, - session: &mut SpectatorSession, - th19: &mut Th19, - ) -> Result<(), RecvError> { - // -1フレーム目、0フレーム目は複数回呼ばれ、回数が不定なのでスキップする - if th19.round_frame().unwrap().frame < 1 { - 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 input_devices = th19.input_devices_mut(); - let (p1, p2) = session.dequeue_inputs()?; - 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 on_round_over( - &mut self, - 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(); - 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(()) - } -}