mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-14 20:13:08 -06:00
Update source files
This commit is contained in:
33
crates/borders-wasm/Cargo.toml
Normal file
33
crates/borders-wasm/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "borders-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||
borders-core = { path = "../borders-core" }
|
||||
console_error_panic_hook = "0.1"
|
||||
flume = "0.11"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
js-sys = "0.3"
|
||||
lazy_static = "1.5"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
serde_json = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
wasm-tracing = { version = "2.1", features = ["tracing-log"] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["getrandom", "bevy_ecs"]
|
||||
45
crates/borders-wasm/src/bridge.rs
Normal file
45
crates/borders-wasm/src/bridge.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! WASM-JS bridge for game communication
|
||||
//!
|
||||
//! This module provides shared state and utilities for WASM bindings.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
|
||||
|
||||
// Global input sender (needs to be accessible from WASM bindings)
|
||||
lazy_static::lazy_static! {
|
||||
static ref INPUT_SENDER: Mutex<Option<flume::Sender<borders_core::game::input::InputEvent>>> =
|
||||
Mutex::new(None);
|
||||
}
|
||||
|
||||
/// Set the global input sender (called during game initialization)
|
||||
pub fn set_input_sender(sender: flume::Sender<borders_core::game::input::InputEvent>) {
|
||||
*INPUT_SENDER.lock().unwrap() = Some(sender);
|
||||
}
|
||||
|
||||
/// Get the global input sender (for WASM bindings to send input)
|
||||
pub fn get_input_sender() -> Option<flume::Sender<borders_core::game::input::InputEvent>> {
|
||||
INPUT_SENDER.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Track an analytics event (separate from game protocol)
|
||||
#[wasm_bindgen]
|
||||
pub fn track_analytics_event(event: JsValue) -> Result<(), JsValue> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AnalyticsEventPayload {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
let payload: AnalyticsEventPayload = serde_wasm_bindgen::from_value(event).map_err(|e| JsValue::from_str(&format!("Failed to deserialize analytics event: {}", e)))?;
|
||||
|
||||
let telemetry_event = borders_core::telemetry::TelemetryEvent { event: payload.event, properties: payload.properties };
|
||||
|
||||
// Spawn a task to track the event asynchronously
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
borders_core::telemetry::track(telemetry_event).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
188
crates/borders-wasm/src/lib.rs
Normal file
188
crates/borders-wasm/src/lib.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
pub mod bridge;
|
||||
pub mod render_bridge;
|
||||
|
||||
use borders_core::time::Time;
|
||||
use borders_core::ui::protocol::FrontendMessage;
|
||||
use render_bridge::INBOUND_MESSAGES;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Synchronization flag to ensure callbacks are registered before game starts
|
||||
static CALLBACKS_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Called by frontend worker after both callbacks are registered
|
||||
#[wasm_bindgen]
|
||||
pub fn signal_callbacks_ready() {
|
||||
CALLBACKS_READY.store(true, Ordering::SeqCst);
|
||||
tracing::debug!("Frontend callbacks registered, game can start");
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
// Set up panic hook for better error messages in the browser
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Initialize tracing for WASM (outputs to browser console)
|
||||
// Debug builds: debug level for our crates, info for dependencies
|
||||
// Release builds: warn level for our crates, error for dependencies
|
||||
#[cfg(debug_assertions)]
|
||||
let level_filter = "borders_core=debug,borders_protocol=debug,borders_wasm=debug,info";
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let level_filter = "borders_core=warn,borders_protocol=warn,borders_wasm=warn,error";
|
||||
|
||||
if let Err(e) = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(level_filter))
|
||||
.with(wasm_tracing::WasmLayer::new(
|
||||
wasm_tracing::WasmLayerConfig::new()
|
||||
.set_show_fields(true)
|
||||
.set_report_logs_in_timings(true)
|
||||
.set_console_config(wasm_tracing::ConsoleConfig::ReportWithConsoleColor)
|
||||
// Only show origin (filename, line number) in debug builds
|
||||
.set_show_origin(true)
|
||||
.clone(),
|
||||
))
|
||||
.try_init()
|
||||
{
|
||||
eprintln!("Failed to initialize tracing: {}", e);
|
||||
}
|
||||
|
||||
// Log build information
|
||||
tracing::info!("Iron Borders v{}", borders_core::build_info::VERSION);
|
||||
tracing::info!("Git: {} | Built: {}", borders_core::build_info::git_commit_short(), borders_core::build_info::BUILD_TIME);
|
||||
tracing::info!("© 2025 Ryan Walters. All Rights Reserved.");
|
||||
|
||||
// Initialize telemetry in background (non-blocking)
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
|
||||
borders_core::telemetry::track_session_start().await;
|
||||
tracing::info!("Telemetry initialized");
|
||||
});
|
||||
|
||||
// Start the game immediately (don't wait for telemetry)
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
run().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for StartGame message from frontend
|
||||
async fn wait_for_start_game() {
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
info!("Waiting for StartGame message from frontend...");
|
||||
|
||||
loop {
|
||||
// Check if StartGame message has arrived
|
||||
let should_start = INBOUND_MESSAGES.with(|messages| {
|
||||
let mut msgs = messages.borrow_mut();
|
||||
if let Some(idx) = msgs.iter().position(|msg| matches!(msg, FrontendMessage::StartGame)) {
|
||||
// Remove StartGame from queue so it's not processed again
|
||||
msgs.remove(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if should_start {
|
||||
info!("StartGame received, creating game...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 10ms
|
||||
gloo_timers::future::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create and run the game until QuitGame is received
|
||||
async fn run_game_until_quit() {
|
||||
use borders_core::game::GameBuilder;
|
||||
use borders_core::game::NationId;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
// Load terrain first
|
||||
info!("Loading terrain data...");
|
||||
let terrain_data = match borders_core::game::TerrainData::load_world_map() {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to load World map: {}", e);
|
||||
panic!("Cannot start game without terrain data");
|
||||
}
|
||||
};
|
||||
|
||||
let terrain_arc = Arc::new(terrain_data);
|
||||
|
||||
// Create the Game with all configuration via GameBuilder
|
||||
// GameBuilder handles all initialization automatically: plugins, resources, and lifecycle
|
||||
info!("Creating Game with GameBuilder...");
|
||||
let builder = GameBuilder::new();
|
||||
let input_sender = builder.input_sender();
|
||||
|
||||
// Store input sender globally for WASM bindings to access
|
||||
bridge::set_input_sender(input_sender);
|
||||
|
||||
let mut game = builder.with_map(terrain_arc).with_bots(500).with_local_player(NationId::ZERO).with_network(borders_core::networking::NetworkMode::Local).with_frontend(render_bridge::WasmTransport).build();
|
||||
|
||||
info!("Game created and initialized successfully");
|
||||
|
||||
// Manual update loop at 60 FPS (worker-compatible)
|
||||
let frame_time = Duration::from_millis(16); // ~60 FPS
|
||||
|
||||
loop {
|
||||
// Check for QuitGame message (matches desktop's pattern)
|
||||
let should_quit = INBOUND_MESSAGES.with(|messages| {
|
||||
let mut msgs = messages.borrow_mut();
|
||||
if let Some(idx) = msgs.iter().position(|msg| matches!(msg, FrontendMessage::QuitGame)) {
|
||||
msgs.remove(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if should_quit {
|
||||
info!("QuitGame received, shutting down game...");
|
||||
drop(game);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tick time to measure frame delta
|
||||
if let Some(mut time) = game.world_mut().get_resource_mut::<Time>() {
|
||||
time.tick();
|
||||
}
|
||||
|
||||
game.update();
|
||||
|
||||
gloo_timers::future::sleep(frame_time).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
// Wait for frontend to register callbacks before game can start
|
||||
info!("Waiting for frontend callbacks to be registered...");
|
||||
while !CALLBACKS_READY.load(Ordering::SeqCst) {
|
||||
gloo_timers::future::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
info!("Frontend callbacks ready");
|
||||
|
||||
// Main loop: wait for StartGame, run game until QuitGame, repeat
|
||||
// This allows multiple game sessions (desktop does the same)
|
||||
loop {
|
||||
// Phase 1: Wait for StartGame message
|
||||
wait_for_start_game().await;
|
||||
|
||||
// Phase 2: Run game until QuitGame is received
|
||||
run_game_until_quit().await;
|
||||
|
||||
// After QuitGame, loop back to wait for next StartGame
|
||||
info!("Game session ended, ready for next StartGame");
|
||||
}
|
||||
}
|
||||
109
crates/borders-wasm/src/render_bridge.rs
Normal file
109
crates/borders-wasm/src/render_bridge.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! WASM-specific frontend transport using JavaScript callbacks
|
||||
//!
|
||||
//! This module provides the WASM implementation of FrontendTransport,
|
||||
//! sending render messages and UI events through JavaScript callbacks to the browser frontend.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use borders_core::game::input::InputEvent;
|
||||
use borders_core::ui::FrontendTransport;
|
||||
use borders_core::ui::protocol::{BackendMessage, BinaryMessageType, FrontendMessage, encode_binary_message};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// Thread-local storage for callbacks and inbound messages
|
||||
thread_local! {
|
||||
static BACKEND_MESSAGE_CALLBACK: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
|
||||
static BINARY_CALLBACK: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
|
||||
pub(crate) static INBOUND_MESSAGES: RefCell<VecDeque<FrontendMessage>> = const { RefCell::new(VecDeque::new()) };
|
||||
}
|
||||
|
||||
/// Register a callback for backend JSON messages (BackendMessage protocol)
|
||||
#[wasm_bindgen]
|
||||
pub fn register_backend_message_callback(callback: js_sys::Function) {
|
||||
BACKEND_MESSAGE_CALLBACK.with(|cb| {
|
||||
*cb.borrow_mut() = Some(callback);
|
||||
});
|
||||
}
|
||||
|
||||
/// Register a callback for binary data (terrain/territory init and deltas)
|
||||
/// Callback receives a single Uint8Array with envelope: [type:1][payload:N]
|
||||
#[wasm_bindgen]
|
||||
pub fn register_binary_callback(callback: js_sys::Function) {
|
||||
BINARY_CALLBACK.with(|cb| {
|
||||
*cb.borrow_mut() = Some(callback);
|
||||
});
|
||||
}
|
||||
|
||||
/// Unified message handler for all frontend->backend communication
|
||||
/// Handles FrontendMessage (JSON) payloads
|
||||
#[wasm_bindgen]
|
||||
pub fn send_message(msg: JsValue) -> Result<(), JsValue> {
|
||||
// Try to deserialize as FrontendMessage
|
||||
if let Ok(message) = serde_wasm_bindgen::from_value::<FrontendMessage>(msg) {
|
||||
INBOUND_MESSAGES.with(|messages_cell| {
|
||||
messages_cell.borrow_mut().push_back(message);
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(JsValue::from_str("Failed to deserialize message as FrontendMessage"))
|
||||
}
|
||||
|
||||
/// Handle input events from the frontend (separate from FrontendMessage protocol)
|
||||
/// This mirrors the desktop's Tauri command for architectural consistency
|
||||
#[wasm_bindgen]
|
||||
pub fn handle_render_input(event: JsValue) -> Result<(), JsValue> {
|
||||
let input_event = serde_wasm_bindgen::from_value::<InputEvent>(event).map_err(|e| JsValue::from_str(&format!("Failed to deserialize InputEvent: {}", e)))?;
|
||||
|
||||
// Send to input queue
|
||||
let sender = crate::bridge::get_input_sender().ok_or_else(|| JsValue::from_str("Input sender not initialized"))?;
|
||||
|
||||
sender.send(input_event).map_err(|e| JsValue::from_str(&format!("Failed to send input event: {}", e)))
|
||||
}
|
||||
|
||||
/// WASM-specific frontend transport using JavaScript callbacks
|
||||
#[derive(Clone)]
|
||||
pub struct WasmTransport;
|
||||
|
||||
impl FrontendTransport for WasmTransport {
|
||||
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
|
||||
BACKEND_MESSAGE_CALLBACK.with(|cb_cell| {
|
||||
if let Some(cb) = cb_cell.borrow().as_ref() {
|
||||
match serde_wasm_bindgen::to_value(message) {
|
||||
Ok(js_payload) => {
|
||||
let this = JsValue::null();
|
||||
if let Err(e) = cb.call1(&this, &js_payload) {
|
||||
return Err(format!("Backend message callback failed: {:?}", e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Failed to serialize backend message: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("No backend message callback registered".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_binary(&self, msg_type: BinaryMessageType, payload: Vec<u8>) -> Result<(), String> {
|
||||
BINARY_CALLBACK.with(|cb_cell| {
|
||||
if let Some(cb) = cb_cell.borrow().as_ref() {
|
||||
// Encode with type tag envelope
|
||||
let data = encode_binary_message(msg_type, payload);
|
||||
let uint8_array = js_sys::Uint8Array::from(&data[..]);
|
||||
let this = JsValue::null();
|
||||
if let Err(e) = cb.call1(&this, &uint8_array) {
|
||||
return Err(format!("Binary callback failed: {:?}", e));
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("No binary callback registered".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
|
||||
INBOUND_MESSAGES.with(|messages_cell| messages_cell.borrow_mut().pop_front())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user