1#![warn(clippy::all, clippy::pedantic)]
7#![cfg_attr(
11 all(target_arch = "wasm32", feature = "nightly"),
12 feature(thread_local)
13)]
14
15pub mod auth;
16pub mod shared_world;
17pub mod world_state;
18
19#[cfg(target_arch = "wasm32")]
20pub mod metrics;
21
22#[cfg(test)]
23#[cfg(target_arch = "wasm32")]
24pub mod smoke_test;
25
26pub mod auth_proto {
30 #![allow(clippy::must_use_candidate, clippy::doc_markdown)]
31 tonic::include_proto!("aetheris.auth.v1");
32}
33
34#[cfg(target_arch = "wasm32")]
35pub mod transport;
36
37#[cfg(test)]
38pub mod transport_mock;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render;
42
43#[cfg(any(target_arch = "wasm32", test))]
44pub mod render_primitives;
45
46#[cfg(any(target_arch = "wasm32", test))]
47pub mod assets;
48
49#[cfg(target_arch = "wasm32")]
50#[cfg_attr(feature = "nightly", thread_local)]
51static _TLS_ANCHOR: u8 = 0;
52
53use std::sync::atomic::AtomicUsize;
54#[cfg(target_arch = "wasm32")]
55use std::sync::atomic::Ordering;
56
57#[allow(dead_code)]
58static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
59
60#[cfg(target_arch = "wasm32")]
61thread_local! {
62 static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
63}
64
65#[must_use]
67pub fn performance_now() -> f64 {
68 #[cfg(target_arch = "wasm32")]
69 {
70 use wasm_bindgen::JsCast;
71 let global = js_sys::global();
72
73 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
75 return worker.performance().map(|p| p.now()).unwrap_or(0.0);
76 }
77
78 if let Ok(window) = global.dyn_into::<web_sys::Window>() {
80 return window.performance().map(|p| p.now()).unwrap_or(0.0);
81 }
82
83 js_sys::Date::now()
85 }
86 #[cfg(not(target_arch = "wasm32"))]
87 {
88 0.0
89 }
90}
91
92#[allow(dead_code)]
93pub(crate) fn get_worker_id() -> usize {
94 #[cfg(target_arch = "wasm32")]
95 {
96 WORKER_ID.with(|&id| id)
97 }
98 #[cfg(not(target_arch = "wasm32"))]
99 {
100 0
101 }
102}
103
104#[cfg(target_arch = "wasm32")]
105mod wasm_impl {
106 use crate::assets;
107 use crate::metrics::with_collector;
108 use crate::performance_now;
109 use crate::render::RenderState;
110 use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
111 use crate::transport::WebTransportBridge;
112 use crate::world_state::ClientWorld;
113 use aetheris_encoder_serde::SerdeEncoder;
114 use aetheris_protocol::events::{NetworkEvent, ReplicationEvent};
115 use aetheris_protocol::traits::{Encoder, GameTransport};
116 use aetheris_protocol::types::{ClientId, ComponentKind, InputCommand, NetworkId};
117 use wasm_bindgen::prelude::*;
118
119 #[wasm_bindgen]
120 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
121 pub enum ConnectionState {
122 Disconnected,
123 Connecting,
124 InGame,
125 Reconnecting,
126 Failed,
127 }
128
129 #[derive(Clone)]
131 pub struct SimulationSnapshot {
132 pub tick: u64,
133 pub entities: Vec<SabSlot>,
134 }
135
136 #[wasm_bindgen]
138 pub struct AetherisClient {
139 pub(crate) shared_world: SharedWorld,
140 pub(crate) world_state: ClientWorld,
141 pub(crate) render_state: Option<RenderState>,
142 pub(crate) transport: Option<Box<dyn GameTransport>>,
143 pub(crate) worker_id: usize,
144 pub(crate) session_token: Option<String>,
145
146 pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
148
149 pub(crate) last_rtt_ms: f64,
151 ping_counter: u64,
152
153 reassembler: aetheris_protocol::Reassembler,
154 connection_state: ConnectionState,
155 reconnect_attempts: u32,
156 playground_rotation_enabled: bool,
157 playground_next_network_id: u64,
158 first_playground_tick: bool,
159
160 pub(crate) render_buffer: Vec<SabSlot>,
162
163 pub(crate) asset_registry: assets::AssetRegistry,
164 }
165
166 #[wasm_bindgen]
167 impl AetherisClient {
168 #[wasm_bindgen(constructor)]
171 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
172 console_error_panic_hook::set_once();
173
174 use std::sync::atomic::{AtomicBool, Ordering};
177 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
178 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
179 let config = tracing_wasm::WASMLayerConfigBuilder::new()
180 .set_max_level(tracing::Level::INFO)
181 .build();
182 tracing_wasm::set_as_global_default_with_config(config);
183 }
184
185 let shared_world = if let Some(ptr_val) = shared_world_ptr {
186 let ptr = ptr_val as *mut u8;
187
188 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
190 return Err(JsValue::from_str(
191 "Invalid shared_world_ptr: null or unaligned",
192 ));
193 }
194
195 unsafe { SharedWorld::from_ptr(ptr) }
200 } else {
201 SharedWorld::new()
202 };
203
204 tracing::info!(
205 "AetherisClient initialized on worker {}",
206 crate::get_worker_id()
207 );
208
209 with_collector(|c| {
211 c.push_event(
212 1,
213 "wasm_client",
214 "AetherisClient initialized",
215 "wasm_init",
216 None,
217 );
218 });
219
220 Ok(Self {
221 shared_world,
222 world_state: ClientWorld::new(),
223 render_state: None,
224 transport: None,
225 worker_id: crate::get_worker_id(),
226 session_token: None,
227 snapshots: std::collections::VecDeque::with_capacity(8),
228 last_rtt_ms: 0.0,
229 ping_counter: 0,
230 reassembler: aetheris_protocol::Reassembler::new(),
231 connection_state: ConnectionState::Disconnected,
232 reconnect_attempts: 0,
233 playground_rotation_enabled: false,
234 playground_next_network_id: 1,
235 first_playground_tick: true,
236 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
237 asset_registry: assets::AssetRegistry::new(),
238 })
239 }
240
241 #[wasm_bindgen]
242 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
243 crate::auth::request_otp(base_url, email).await
244 }
245
246 #[wasm_bindgen]
247 pub async fn login_with_otp(
248 base_url: String,
249 request_id: String,
250 code: String,
251 ) -> Result<String, String> {
252 crate::auth::login_with_otp(base_url, request_id, code).await
253 }
254
255 #[wasm_bindgen]
256 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
257 crate::auth::logout(base_url, session_token).await
258 }
259
260 #[wasm_bindgen(getter)]
261 pub fn connection_state(&self) -> ConnectionState {
262 self.connection_state
263 }
264
265 fn check_worker(&self) {
266 debug_assert_eq!(
267 self.worker_id,
268 crate::get_worker_id(),
269 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
270 );
271 }
272
273 pub fn shared_world_ptr(&self) -> u32 {
275 self.shared_world.as_ptr() as u32
276 }
277
278 pub async fn connect(
279 &mut self,
280 url: String,
281 cert_hash: Option<Vec<u8>>,
282 ) -> Result<(), JsValue> {
283 self.check_worker();
284
285 if self.connection_state == ConnectionState::Connecting
286 || self.connection_state == ConnectionState::InGame
287 || self.connection_state == ConnectionState::Reconnecting
288 {
289 return Ok(());
290 }
291
292 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
294 with_collector(|c| {
295 c.push_event(
296 2,
297 "transport",
298 "Triggering reconnection",
299 "reconnect_attempt",
300 None,
301 );
302 });
303 }
304
305 self.connection_state = ConnectionState::Connecting;
306 tracing::info!(url = %url, "Connecting to server...");
307
308 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
309
310 match transport_result {
311 Ok(transport) => {
312 if let Some(token) = &self.session_token {
314 let encoder = SerdeEncoder::new();
315 let auth_event = NetworkEvent::Auth {
316 session_token: token.clone(),
317 };
318
319 match encoder.encode_event(&auth_event) {
320 Ok(data) => {
321 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
322 self.connection_state = ConnectionState::Failed;
323 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
324 return Err(JsValue::from_str(&format!(
325 "Failed to send auth packet: {:?}",
326 e
327 )));
328 }
329 tracing::info!("Auth packet sent to server");
330 }
331 Err(e) => {
332 self.connection_state = ConnectionState::Failed;
333 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
334 return Err(JsValue::from_str("Failed to encode auth packet"));
335 }
336 }
337 } else {
338 tracing::warn!(
339 "Connecting without session token! Server will likely discard data."
340 );
341 }
342
343 self.transport = Some(Box::new(transport));
344 self.connection_state = ConnectionState::InGame;
345 self.reconnect_attempts = 0;
346 tracing::info!("WebTransport connection established");
347 with_collector(|c| {
349 c.push_event(
350 1,
351 "transport",
352 &format!("WebTransport connected: {url}"),
353 "connect_handshake",
354 None,
355 );
356 });
357 Ok(())
358 }
359 Err(e) => {
360 self.connection_state = ConnectionState::Failed;
361 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
362 with_collector(|c| {
364 c.push_event(
365 3,
366 "transport",
367 &format!("WebTransport failed: {url} — {e:?}"),
368 "connect_handshake_failed",
369 None,
370 );
371 });
372 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
373 }
374 }
375 }
376
377 #[wasm_bindgen]
378 pub async fn reconnect(
379 &mut self,
380 url: String,
381 cert_hash: Option<Vec<u8>>,
382 ) -> Result<(), JsValue> {
383 self.check_worker();
384 self.connection_state = ConnectionState::Reconnecting;
385 self.reconnect_attempts += 1;
386
387 tracing::info!(
388 "Attempting reconnection... (attempt {})",
389 self.reconnect_attempts
390 );
391
392 self.connect(url, cert_hash).await
393 }
394
395 #[wasm_bindgen]
396 pub async fn wasm_load_asset(
397 &mut self,
398 handle: assets::AssetHandle,
399 url: String,
400 ) -> Result<(), JsValue> {
401 self.asset_registry.load_asset(handle, &url).await
402 }
403
404 pub fn set_session_token(&mut self, token: String) {
406 self.session_token = Some(token);
407 }
408
409 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
412 self.check_worker();
413 use wasm_bindgen::JsCast;
414
415 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
416 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
417 flags: wgpu::InstanceFlags::default(),
418 ..wgpu::InstanceDescriptor::new_without_display_handle()
419 });
420
421 let (surface_target, width, height) =
423 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
424 let width = html_canvas.width();
425 let height = html_canvas.height();
426 tracing::info!(
427 "Initializing renderer on HTMLCanvasElement ({}x{})",
428 width,
429 height
430 );
431 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
432 } else if let Ok(offscreen_canvas) =
433 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
434 {
435 let width = offscreen_canvas.width();
436 let height = offscreen_canvas.height();
437 tracing::info!(
438 "Initializing renderer on OffscreenCanvas ({}x{})",
439 width,
440 height
441 );
442
443 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
446 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
447 })?;
448
449 (
450 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
451 width,
452 height,
453 )
454 } else {
455 return Err(JsValue::from_str(
456 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
457 ));
458 };
459
460 let surface = instance
461 .create_surface(surface_target)
462 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
463
464 let render_state = RenderState::new(&instance, surface, width, height)
465 .await
466 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
467
468 self.render_state = Some(render_state);
469
470 with_collector(|c| {
472 c.push_event(
473 1,
474 "render_worker",
475 &format!("Renderer initialized ({}x{})", width, height),
476 "render_pipeline_setup",
477 None,
478 );
479 });
480
481 Ok(())
482 }
483
484 #[wasm_bindgen]
485 pub fn resize(&mut self, width: u32, height: u32) {
486 if let Some(state) = &mut self.render_state {
487 state.resize(width, height);
488 }
489 }
490
491 #[cfg(debug_assertions)]
492 #[wasm_bindgen]
493 pub fn set_debug_mode(&mut self, mode: u32) {
494 self.check_worker();
495 if let Some(state) = &mut self.render_state {
496 state.set_debug_mode(match mode {
497 0 => crate::render::DebugRenderMode::Off,
498 1 => crate::render::DebugRenderMode::Wireframe,
499 2 => crate::render::DebugRenderMode::Components,
500 _ => crate::render::DebugRenderMode::Full,
501 });
502 }
503 }
504
505 #[wasm_bindgen]
506 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
507 self.check_worker();
508 let clear = crate::render::parse_css_color(bg_base);
509 let label = crate::render::parse_css_color(text_primary);
510
511 tracing::info!(
512 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
513 bg_base,
514 clear,
515 text_primary,
516 label
517 );
518
519 if let Some(state) = &mut self.render_state {
520 state.set_clear_color(clear);
521 #[cfg(debug_assertions)]
522 state.set_label_color([
523 label.r as f32,
524 label.g as f32,
525 label.b as f32,
526 label.a as f32,
527 ]);
528 }
529 }
530
531 #[cfg(debug_assertions)]
532 #[wasm_bindgen]
533 pub fn cycle_debug_mode(&mut self) {
534 if let Some(state) = &mut self.render_state {
535 state.cycle_debug_mode();
536 }
537 }
538
539 #[cfg(debug_assertions)]
540 #[wasm_bindgen]
541 pub fn toggle_grid(&mut self) {
542 if let Some(state) = &mut self.render_state {
543 state.toggle_grid();
544 }
545 }
546
547 pub async fn tick(&mut self) {
549 self.check_worker();
550 use aetheris_protocol::traits::{Encoder, WorldState};
551
552 let encoder = SerdeEncoder::new();
553
554 if let Some(_transport) = &self.transport {}
557
558 if let Some(transport) = &mut self.transport {
560 self.ping_counter = self.ping_counter.wrapping_add(1);
561 if self.ping_counter % 60 == 0 {
562 let now = performance_now();
564 let tick_u64 = now as u64;
565
566 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
567 client_id: ClientId(0), tick: tick_u64,
569 }) {
570 tracing::trace!(tick = tick_u64, "Sending Ping");
571 let _ = transport.send_unreliable(ClientId(0), &data).await;
572 }
573 }
574 }
575
576 if let Some(transport) = &mut self.transport {
578 let events = match transport.poll_events().await {
579 Ok(e) => e,
580 Err(e) => {
581 tracing::error!("Transport poll failure: {:?}", e);
582 return;
583 }
584 };
585 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
586 Vec::new();
587
588 for event in events {
589 match event {
590 NetworkEvent::UnreliableMessage { data, client_id }
591 | NetworkEvent::ReliableMessage { data, client_id } => {
592 match encoder.decode(&data) {
593 Ok(update) => {
594 tracing::debug!(
595 network_id = update.network_id.0,
596 kind = update.component_kind.0,
597 tick = update.tick,
598 "Decoded component update"
599 );
600 updates.push((client_id, update));
601 }
602 Err(e) => {
603 tracing::warn!(error = ?e, "Failed to decode server message");
604 }
605 }
606 }
607 NetworkEvent::ClientConnected(id) => {
608 tracing::info!(?id, "Server connected");
609 }
610 NetworkEvent::ClientDisconnected(id) => {
611 tracing::warn!(?id, "Server disconnected");
612 }
613 NetworkEvent::Disconnected(_id) => {
614 tracing::error!("Transport disconnected locally");
615 self.connection_state = ConnectionState::Disconnected;
616 }
617 NetworkEvent::Ping { client_id: _, tick } => {
618 let pong = NetworkEvent::Pong { tick };
620 if let Ok(data) = encoder.encode_event(&pong) {
621 let _ = transport.send_reliable(ClientId(0), &data).await;
622 }
623 }
624 NetworkEvent::Pong { tick } => {
625 let now = performance_now();
627 let rtt = now - (tick as f64);
628 self.last_rtt_ms = rtt;
629
630 with_collector(|c| {
631 c.update_rtt(rtt);
632 });
633
634 #[cfg(feature = "metrics")]
635 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
636
637 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
638 }
639 NetworkEvent::Auth { .. } => {
640 tracing::debug!("Received Auth event from server (unexpected)");
642 }
643 NetworkEvent::SessionClosed(id) => {
644 tracing::warn!(?id, "WebTransport session closed");
645 }
646 NetworkEvent::StreamReset(id) => {
647 tracing::error!(?id, "WebTransport stream reset");
648 }
649 NetworkEvent::Fragment {
650 client_id,
651 fragment,
652 } => {
653 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
654 if let Ok(update) = encoder.decode(&data) {
655 updates.push((client_id, update));
656 }
657 }
658 }
659 NetworkEvent::StressTest { .. } => {
660 }
663 NetworkEvent::Spawn { .. } => {
664 }
666 NetworkEvent::ClearWorld { .. } => {
667 tracing::info!("Server initiated world clear");
668 self.world_state.entities.clear();
669 }
670 }
671 }
672
673 if !updates.is_empty() {
675 tracing::debug!(count = updates.len(), "Applying server updates to world");
676 }
677 self.world_state.apply_updates(&updates);
678 }
679
680 if self.playground_rotation_enabled {
682 for slot in self.world_state.entities.values_mut() {
683 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
684 }
685 }
686
687 self.world_state.latest_tick += 1;
690
691 let sim_start = crate::performance_now();
693
694 self.flush_to_shared_world(self.world_state.latest_tick);
696
697 let sim_time_ms = crate::performance_now() - sim_start;
698 let count = self.world_state.entities.len() as u32;
699 with_collector(|c| {
700 c.record_sim(sim_time_ms);
701 c.update_entity_count(count);
702 });
703 }
704
705 fn flush_to_shared_world(&mut self, tick: u64) {
706 let entities = &self.world_state.entities;
707 let write_buffer = self.shared_world.get_write_buffer();
708
709 let mut count = 0;
710 for (i, slot) in entities.values().enumerate() {
711 if i >= MAX_ENTITIES {
712 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
713 break;
714 }
715 write_buffer[i] = *slot;
716 count += 1;
717 }
718
719 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
720 self.shared_world.commit_write(count as u32, tick);
721 }
722
723 #[wasm_bindgen]
724 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
725 if self.world_state.entities.len() >= MAX_ENTITIES {
727 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
728 return;
729 }
730
731 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
733 self.playground_next_network_id = self
734 .world_state
735 .entities
736 .keys()
737 .map(|k| k.0)
738 .max()
739 .unwrap_or(0)
740 + 1;
741 }
742
743 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
744 self.playground_next_network_id += 1;
745 let slot = SabSlot {
746 network_id: id.0,
747 x,
748 y,
749 z: 0.0,
750 rotation,
751 dx: 0.0,
752 dy: 0.0,
753 dz: 0.0,
754 hp: 100,
755 shield: 100,
756 entity_type,
757 flags: 0x01, padding: [0; 5],
759 };
760 self.world_state.entities.insert(id, slot);
761 }
762
763 #[wasm_bindgen]
764 pub async fn playground_spawn_net(
765 &mut self,
766 entity_type: u16,
767 x: f32,
768 y: f32,
769 rot: f32,
770 ) -> Result<(), JsValue> {
771 self.check_worker();
772
773 if let Some(transport) = &self.transport {
774 let encoder = SerdeEncoder::new();
775 let event = NetworkEvent::Spawn {
776 client_id: ClientId(0),
777 entity_type,
778 x,
779 y,
780 rot,
781 };
782
783 if let Ok(data) = encoder.encode_event(&event) {
784 transport
785 .send_reliable(ClientId(0), &data)
786 .await
787 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
788 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
789 }
790 } else {
791 self.playground_spawn(entity_type, x, y, rot);
793 }
794 Ok(())
795 }
796
797 #[wasm_bindgen]
798 pub fn playground_clear(&mut self) {
799 self.world_state.entities.clear();
800 }
801
802 #[wasm_bindgen]
807 pub async fn send_input(
808 &mut self,
809 tick: u64,
810 move_x: f32,
811 move_y: f32,
812 actions: u32,
813 ) -> Result<(), JsValue> {
814 self.check_worker();
815
816 let transport = self.transport.as_ref().ok_or_else(|| {
817 JsValue::from_str("Cannot send input: transport not initialized or closed")
818 })?;
819
820 let cmd = InputCommand {
822 tick,
823 move_x,
824 move_y,
825 actions,
826 }
827 .clamped();
828
829 let payload = rmp_serde::to_vec(&cmd)
833 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
834
835 let update = ReplicationEvent {
836 network_id: NetworkId(0), component_kind: ComponentKind(128),
838 payload,
839 tick,
840 };
841
842 let mut buffer = [0u8; 1024];
843 let encoder = SerdeEncoder::new();
844 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
845 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
846 })?;
847
848 transport
850 .send_unreliable(ClientId(0), &buffer[..len])
851 .await
852 .map_err(|e| {
853 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
854 })?;
855
856 Ok(())
857 }
858
859 #[wasm_bindgen]
860 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
861 self.check_worker();
862
863 if let Some(transport) = &self.transport {
864 let encoder = SerdeEncoder::new();
865 let event = NetworkEvent::ClearWorld {
866 client_id: ClientId(0),
867 };
868 if let Ok(data) = encoder.encode_event(&event) {
869 transport
870 .send_reliable(ClientId(0), &data)
871 .await
872 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
873 tracing::info!("Sent ClearWorld command to server");
874 self.world_state.entities.clear();
875 }
876 } else {
877 self.world_state.entities.clear();
879 }
880 Ok(())
881 }
882
883 #[wasm_bindgen]
884 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
885 self.playground_rotation_enabled = enabled;
886 }
889
890 #[wasm_bindgen]
891 pub async fn playground_stress_test(
892 &mut self,
893 count: u16,
894 rotate: bool,
895 ) -> Result<(), JsValue> {
896 self.check_worker();
897
898 if let Some(transport) = &self.transport {
899 let encoder = SerdeEncoder::new();
900 let event = NetworkEvent::StressTest {
901 client_id: ClientId(0),
902 count,
903 rotate,
904 };
905
906 if let Ok(data) = encoder.encode_event(&event) {
907 transport
908 .send_reliable(ClientId(0), &data)
909 .await
910 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
911 tracing::info!(count, rotate, "Sent StressTest command to server");
912 }
913 self.playground_set_rotation_enabled(rotate);
914 } else {
915 self.playground_set_rotation_enabled(rotate);
917 self.playground_clear();
918 for _ in 0..count {
919 self.playground_spawn(1, 0.0, 0.0, 0.0); }
921 }
922
923 Ok(())
924 }
925
926 #[wasm_bindgen]
927 pub async fn tick_playground(&mut self) {
928 self.check_worker();
929
930 if self.first_playground_tick {
932 self.first_playground_tick = false;
933 with_collector(|c| {
934 c.push_event(
935 1,
936 "wasm_client",
937 "Playground simulation loop started",
938 "tick_playground_loop_start",
939 None,
940 );
941 });
942 }
943
944 let sim_start = crate::performance_now();
946 self.world_state.latest_tick += 1;
947
948 if self.playground_rotation_enabled {
949 for slot in self.world_state.entities.values_mut() {
950 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
951 }
952 }
953
954 let count = self.world_state.entities.len() as u32;
956 self.flush_to_shared_world(self.world_state.latest_tick);
957
958 let sim_time_ms = crate::performance_now() - sim_start;
959 with_collector(|c| {
960 c.record_sim(sim_time_ms);
961 c.update_entity_count(count);
962 });
963 }
964
965 pub fn render(&mut self) -> f64 {
967 self.check_worker();
968
969 let tick = self.shared_world.tick();
970 let entities = self.shared_world.get_read_buffer();
971
972 thread_local! {
974 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
975 }
976 FRAME_COUNT.with(|count| {
977 let current = count.get();
978 if current % 300 == 0 {
979 tracing::debug!(
980 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
981 tick,
982 entities.len(),
983 self.snapshots.len(),
984 );
985 }
986 count.set(current + 1);
987 });
988
989 if tick == 0 {
990 let mut frame_time_ms = 0.0;
992 if let Some(state) = &mut self.render_state {
993 frame_time_ms = state.render_frame_with_compact_slots(&[]);
994 with_collector(|c| {
995 c.record_frame(frame_time_ms, 0.0);
998 });
999 }
1000 return frame_time_ms;
1001 }
1002
1003 if self.snapshots.is_empty()
1005 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
1006 {
1007 self.snapshots.push_back(SimulationSnapshot {
1008 tick,
1009 entities: entities.to_vec(),
1010 });
1011 }
1012
1013 let mut frame_time_ms = 0.0;
1017 if !self.snapshots.is_empty() {
1018 let latest_tick = self.snapshots.back().unwrap().tick as f32;
1019 let target_tick = latest_tick - 2.0;
1020 frame_time_ms = self.render_at_tick(target_tick);
1021 }
1022
1023 let snap_count = self.snapshots.len() as u32;
1025 with_collector(|c| {
1026 c.record_frame(frame_time_ms, 0.0);
1028 c.update_snapshot_count(snap_count);
1029 });
1030
1031 frame_time_ms
1032 }
1033
1034 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1035 if self.snapshots.len() < 2 {
1036 if let Some(state) = &mut self.render_state {
1039 let entities = if !self.snapshots.is_empty() {
1040 self.snapshots[0].entities.clone()
1041 } else {
1042 Vec::new()
1043 };
1044 return state.render_frame_with_compact_slots(&entities);
1045 }
1046 return 0.0;
1047 }
1048
1049 let mut s1_idx = 0;
1051 let mut found = false;
1052
1053 for i in 0..self.snapshots.len() - 1 {
1054 if (self.snapshots[i].tick as f32) <= target_tick
1055 && (self.snapshots[i + 1].tick as f32) > target_tick
1056 {
1057 s1_idx = i;
1058 found = true;
1059 break;
1060 }
1061 }
1062
1063 if !found {
1064 if target_tick < self.snapshots[0].tick as f32 {
1066 s1_idx = 0;
1067 } else {
1068 s1_idx = self.snapshots.len() - 2;
1069 }
1070 }
1071
1072 let s1 = self.snapshots.get(s1_idx).unwrap();
1073 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1074
1075 let tick_range = (s2.tick - s1.tick) as f32;
1076 let alpha = if tick_range > 0.0 {
1077 (target_tick - s1.tick as f32) / tick_range
1078 } else {
1079 1.0
1080 }
1081 .clamp(0.0, 1.0);
1082
1083 self.render_buffer.clear();
1085 self.render_buffer.extend_from_slice(&s2.entities);
1086
1087 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1089 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1090
1091 for ent in &mut self.render_buffer {
1092 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1093 ent.x = lerp(prev.x, ent.x, alpha);
1094 ent.y = lerp(prev.y, ent.y, alpha);
1095 ent.z = lerp(prev.z, ent.z, alpha);
1096 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1097 }
1098 }
1099
1100 let mut frame_time = 0.0;
1101 if let Some(state) = &mut self.render_state {
1102 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1103 }
1104
1105 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1109 self.snapshots.pop_front();
1110 }
1111
1112 while self.snapshots.len() > 16 {
1114 self.snapshots.pop_front();
1115 }
1116
1117 frame_time
1118 }
1119 }
1120
1121 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1122 a + (b - a) * alpha
1123 }
1124
1125 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1126 let mut diff = b - a;
1129 while diff < -std::f32::consts::PI {
1130 diff += std::f32::consts::TAU;
1131 }
1132 while diff > std::f32::consts::PI {
1133 diff -= std::f32::consts::TAU;
1134 }
1135 a + diff * alpha
1136 }
1137
1138 #[cfg(test)]
1139 mod tests {
1140 }
1159}