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;
115 use aetheris_protocol::traits::GameTransport;
116 use aetheris_protocol::types::ClientId;
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 shared_world: SharedWorld,
140 world_state: ClientWorld,
141 render_state: Option<RenderState>,
142 transport: Option<Box<dyn GameTransport>>,
143 worker_id: usize,
144 session_token: Option<String>,
145
146 snapshots: std::collections::VecDeque<SimulationSnapshot>,
148
149 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 render_buffer: Vec<SabSlot>,
162
163 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]
803 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
804 self.check_worker();
805
806 if let Some(transport) = &self.transport {
807 let encoder = SerdeEncoder::new();
808 let event = NetworkEvent::ClearWorld {
809 client_id: ClientId(0),
810 };
811 if let Ok(data) = encoder.encode_event(&event) {
812 transport
813 .send_reliable(ClientId(0), &data)
814 .await
815 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
816 tracing::info!("Sent ClearWorld command to server");
817 self.world_state.entities.clear();
818 }
819 } else {
820 self.world_state.entities.clear();
822 }
823 Ok(())
824 }
825
826 #[wasm_bindgen]
827 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
828 self.playground_rotation_enabled = enabled;
829 }
832
833 #[wasm_bindgen]
834 pub async fn playground_stress_test(
835 &mut self,
836 count: u16,
837 rotate: bool,
838 ) -> Result<(), JsValue> {
839 self.check_worker();
840
841 if let Some(transport) = &self.transport {
842 let encoder = SerdeEncoder::new();
843 let event = NetworkEvent::StressTest {
844 client_id: ClientId(0),
845 count,
846 rotate,
847 };
848
849 if let Ok(data) = encoder.encode_event(&event) {
850 transport
851 .send_reliable(ClientId(0), &data)
852 .await
853 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
854 tracing::info!(count, rotate, "Sent StressTest command to server");
855 }
856 self.playground_set_rotation_enabled(rotate);
857 } else {
858 self.playground_set_rotation_enabled(rotate);
860 self.playground_clear();
861 for _ in 0..count {
862 self.playground_spawn(1, 0.0, 0.0, 0.0); }
864 }
865
866 Ok(())
867 }
868
869 #[wasm_bindgen]
870 pub async fn tick_playground(&mut self) {
871 self.check_worker();
872
873 if self.first_playground_tick {
875 self.first_playground_tick = false;
876 with_collector(|c| {
877 c.push_event(
878 1,
879 "wasm_client",
880 "Playground simulation loop started",
881 "tick_playground_loop_start",
882 None,
883 );
884 });
885 }
886
887 let sim_start = crate::performance_now();
889 self.world_state.latest_tick += 1;
890
891 if self.playground_rotation_enabled {
892 for slot in self.world_state.entities.values_mut() {
893 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
894 }
895 }
896
897 let count = self.world_state.entities.len() as u32;
899 self.flush_to_shared_world(self.world_state.latest_tick);
900
901 let sim_time_ms = crate::performance_now() - sim_start;
902 with_collector(|c| {
903 c.record_sim(sim_time_ms);
904 c.update_entity_count(count);
905 });
906 }
907
908 pub fn render(&mut self) -> f64 {
910 self.check_worker();
911
912 let tick = self.shared_world.tick();
913 let entities = self.shared_world.get_read_buffer();
914
915 thread_local! {
917 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
918 }
919 FRAME_COUNT.with(|count| {
920 let current = count.get();
921 if current % 300 == 0 {
922 tracing::debug!(
923 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
924 tick,
925 entities.len(),
926 self.snapshots.len(),
927 );
928 }
929 count.set(current + 1);
930 });
931
932 if tick == 0 {
933 let mut frame_time_ms = 0.0;
935 if let Some(state) = &mut self.render_state {
936 frame_time_ms = state.render_frame_with_compact_slots(&[]);
937 with_collector(|c| {
938 c.record_frame(frame_time_ms, 0.0);
941 });
942 }
943 return frame_time_ms;
944 }
945
946 if self.snapshots.is_empty()
948 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
949 {
950 self.snapshots.push_back(SimulationSnapshot {
951 tick,
952 entities: entities.to_vec(),
953 });
954 }
955
956 let mut frame_time_ms = 0.0;
960 if !self.snapshots.is_empty() {
961 let latest_tick = self.snapshots.back().unwrap().tick as f32;
962 let target_tick = latest_tick - 2.0;
963 frame_time_ms = self.render_at_tick(target_tick);
964 }
965
966 let snap_count = self.snapshots.len() as u32;
968 with_collector(|c| {
969 c.record_frame(frame_time_ms, 0.0);
971 c.update_snapshot_count(snap_count);
972 });
973
974 frame_time_ms
975 }
976
977 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
978 if self.snapshots.len() < 2 {
979 if let Some(state) = &mut self.render_state {
982 let entities = if !self.snapshots.is_empty() {
983 self.snapshots[0].entities.clone()
984 } else {
985 Vec::new()
986 };
987 return state.render_frame_with_compact_slots(&entities);
988 }
989 return 0.0;
990 }
991
992 let mut s1_idx = 0;
994 let mut found = false;
995
996 for i in 0..self.snapshots.len() - 1 {
997 if (self.snapshots[i].tick as f32) <= target_tick
998 && (self.snapshots[i + 1].tick as f32) > target_tick
999 {
1000 s1_idx = i;
1001 found = true;
1002 break;
1003 }
1004 }
1005
1006 if !found {
1007 if target_tick < self.snapshots[0].tick as f32 {
1009 s1_idx = 0;
1010 } else {
1011 s1_idx = self.snapshots.len() - 2;
1012 }
1013 }
1014
1015 let s1 = self.snapshots.get(s1_idx).unwrap();
1016 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1017
1018 let tick_range = (s2.tick - s1.tick) as f32;
1019 let alpha = if tick_range > 0.0 {
1020 (target_tick - s1.tick as f32) / tick_range
1021 } else {
1022 1.0
1023 }
1024 .clamp(0.0, 1.0);
1025
1026 self.render_buffer.clear();
1028 self.render_buffer.extend_from_slice(&s2.entities);
1029
1030 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1032 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1033
1034 for ent in &mut self.render_buffer {
1035 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1036 ent.x = lerp(prev.x, ent.x, alpha);
1037 ent.y = lerp(prev.y, ent.y, alpha);
1038 ent.z = lerp(prev.z, ent.z, alpha);
1039 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1040 }
1041 }
1042
1043 let mut frame_time = 0.0;
1044 if let Some(state) = &mut self.render_state {
1045 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1046 }
1047
1048 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1052 self.snapshots.pop_front();
1053 }
1054
1055 while self.snapshots.len() > 16 {
1057 self.snapshots.pop_front();
1058 }
1059
1060 frame_time
1061 }
1062 }
1063
1064 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1065 a + (b - a) * alpha
1066 }
1067
1068 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1069 let mut diff = b - a;
1072 while diff < -std::f32::consts::PI {
1073 diff += std::f32::consts::TAU;
1074 }
1075 while diff > std::f32::consts::PI {
1076 diff -= std::f32::consts::TAU;
1077 }
1078 a + diff * alpha
1079 }
1080
1081 #[cfg(test)]
1082 mod tests {
1083 use super::*;
1084 use crate::transport_mock::MockTransport;
1085
1086 #[tokio::test]
1087 async fn test_transport_disconnection() {
1088 let mut client = AetherisClient::new(None).unwrap();
1089 let mock = MockTransport::new();
1090 client.transport = Some(Box::new(mock.clone()));
1091 client.connection_state = ConnectionState::InGame;
1092
1093 mock.set_closed(true);
1095
1096 client.tick().await;
1098
1099 assert_eq!(client.connection_state, ConnectionState::Disconnected);
1100 }
1101 }
1102}