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, WorldState};
116 use aetheris_protocol::types::{
117 ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118 };
119 use wasm_bindgen::prelude::*;
120
121 #[wasm_bindgen]
122 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123 pub enum ConnectionState {
124 Disconnected,
125 Connecting,
126 InGame,
127 Reconnecting,
128 Failed,
129 }
130
131 #[derive(Clone)]
133 pub struct SimulationSnapshot {
134 pub tick: u64,
135 pub entities: Vec<SabSlot>,
136 }
137
138 #[wasm_bindgen]
140 pub struct AetherisClient {
141 pub(crate) shared_world: SharedWorld,
142 pub(crate) world_state: ClientWorld,
143 pub(crate) render_state: Option<RenderState>,
144 pub(crate) transport: Option<Box<dyn GameTransport>>,
145 pub(crate) worker_id: usize,
146 pub(crate) session_token: Option<String>,
147
148 pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151 pub(crate) last_rtt_ms: f64,
153 ping_counter: u64,
154
155 reassembler: aetheris_protocol::Reassembler,
156 connection_state: ConnectionState,
157 reconnect_attempts: u32,
158 playground_rotation_enabled: bool,
159 playground_next_network_id: u64,
160 first_playground_tick: bool,
161
162 pub(crate) render_buffer: Vec<SabSlot>,
164 pub(crate) asset_registry: assets::AssetRegistry,
165
166 last_input_target: Option<NetworkId>,
168 last_input_actions: Vec<PlayerInputKind>,
169
170 pending_clear: bool,
171 last_clear_tick: u64,
172
173 last_process_time: f64,
175 tick_accumulator: f64,
176
177 playground_move_x: f32,
179 playground_move_y: f32,
180 playground_actions: u32,
181 last_fraction: f32,
182 last_actions_mask: u32,
183 }
184
185 #[wasm_bindgen]
186 impl AetherisClient {
187 #[wasm_bindgen(constructor)]
190 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
191 console_error_panic_hook::set_once();
192
193 use std::sync::atomic::{AtomicBool, Ordering};
196 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
197 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
198 let config = tracing_wasm::WASMLayerConfigBuilder::new()
199 .set_max_level(tracing::Level::INFO)
200 .build();
201 tracing_wasm::set_as_global_default_with_config(config);
202 }
203
204 let shared_world = if let Some(ptr_val) = shared_world_ptr {
205 let ptr = ptr_val as *mut u8;
206
207 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
209 return Err(JsValue::from_str(
210 "Invalid shared_world_ptr: null or unaligned",
211 ));
212 }
213
214 unsafe { SharedWorld::from_ptr(ptr) }
219 } else {
220 SharedWorld::new()
221 };
222
223 let global = js_sys::global();
224 let (ua, lang) =
225 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
226 let n = worker.navigator();
227 (n.user_agent().ok(), n.language())
228 } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
229 let n = window.navigator();
230 (n.user_agent().ok(), n.language())
231 } else {
232 (None, None)
233 };
234
235 tracing::info!(
236 "Aetheris Client: Environment [UA: {}, Lang: {}]",
237 ua.as_deref().unwrap_or("Unknown"),
238 lang.as_deref().unwrap_or("Unknown")
239 );
240
241 tracing::info!(
242 "AetherisClient initialized on worker {}",
243 crate::get_worker_id()
244 );
245
246 with_collector(|c| {
248 c.push_event(
249 1,
250 "wasm_client",
251 "AetherisClient initialized",
252 "wasm_init",
253 None,
254 );
255 });
256
257 let mut world_state = ClientWorld::new();
258 world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
259
260 Ok(Self {
261 shared_world,
262 world_state,
263 render_state: None,
264 transport: None,
265 worker_id: crate::get_worker_id(),
266 session_token: None,
267 snapshots: std::collections::VecDeque::with_capacity(8),
268 last_rtt_ms: 0.0,
269 ping_counter: 0,
270 reassembler: aetheris_protocol::Reassembler::new(),
271 connection_state: ConnectionState::Disconnected,
272 reconnect_attempts: 0,
273 playground_rotation_enabled: false,
274 playground_next_network_id: 1,
275 first_playground_tick: true,
276 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
277 asset_registry: assets::AssetRegistry::new(),
278 last_input_target: None,
279 last_input_actions: Vec::new(),
280 pending_clear: false,
281 last_clear_tick: 0,
282 last_process_time: crate::performance_now(),
283 tick_accumulator: 0.0,
284 playground_move_x: 0.0,
285 playground_move_y: 0.0,
286 playground_actions: 0,
287 last_fraction: 0.0,
288 last_actions_mask: 0,
289 })
290 }
291
292 #[wasm_bindgen]
293 pub fn set_view_state(&mut self, state: u32) {
294 use crate::render::ViewState;
295 let state = match state {
296 0 => ViewState::Logo,
297 1 => ViewState::Roaming,
298 2 => ViewState::Entering,
299 3 => ViewState::Playing,
300 _ => return,
301 };
302
303 if let Some(rs) = &mut self.render_state {
304 rs.set_view_state(state);
305 } else {
306 tracing::warn!("set_view_state called but render_state is None");
307 }
308 }
309
310 #[wasm_bindgen]
311 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
312 crate::auth::request_otp(base_url, email).await
313 }
314
315 #[wasm_bindgen]
316 pub async fn login_with_otp(
317 base_url: String,
318 request_id: String,
319 code: String,
320 ) -> Result<String, String> {
321 crate::auth::login_with_otp(base_url, request_id, code).await
322 }
323
324 #[wasm_bindgen]
325 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
326 crate::auth::logout(base_url, session_token).await
327 }
328
329 #[wasm_bindgen(getter)]
330 pub fn connection_state(&self) -> ConnectionState {
331 self.connection_state
332 }
333
334 fn check_worker(&self) {
335 debug_assert_eq!(
336 self.worker_id,
337 crate::get_worker_id(),
338 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
339 );
340 }
341
342 pub fn shared_world_ptr(&self) -> u32 {
344 self.shared_world.as_ptr() as u32
345 }
346
347 pub async fn connect(
348 &mut self,
349 url: String,
350 cert_hash: Option<Vec<u8>>,
351 ) -> Result<(), JsValue> {
352 self.check_worker();
353
354 if self.connection_state == ConnectionState::Connecting
355 || self.connection_state == ConnectionState::InGame
356 || self.connection_state == ConnectionState::Reconnecting
357 {
358 return Ok(());
359 }
360
361 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
363 with_collector(|c| {
364 c.push_event(
365 2,
366 "transport",
367 "Triggering reconnection",
368 "reconnect_attempt",
369 None,
370 );
371 });
372 }
373
374 self.connection_state = ConnectionState::Connecting;
375 tracing::info!(url = %url, "Connecting to server...");
376
377 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
378
379 match transport_result {
380 Ok(transport) => {
381 if let Some(token) = &self.session_token {
383 let encoder = SerdeEncoder::new();
384 let auth_event = NetworkEvent::Auth {
385 session_token: token.clone(),
386 };
387
388 match encoder.encode_event(&auth_event) {
389 Ok(data) => {
390 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
391 self.connection_state = ConnectionState::Failed;
392 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
393 return Err(JsValue::from_str(&format!(
394 "Failed to send auth packet: {:?}",
395 e
396 )));
397 }
398 tracing::info!("Auth packet sent to server");
399 }
400 Err(e) => {
401 self.connection_state = ConnectionState::Failed;
402 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
403 return Err(JsValue::from_str("Failed to encode auth packet"));
404 }
405 }
406 } else {
407 tracing::warn!(
408 "Connecting without session token! Server will likely discard data."
409 );
410 }
411
412 self.transport = Some(Box::new(transport));
413 self.connection_state = ConnectionState::InGame;
414 self.reconnect_attempts = 0;
415 tracing::info!("WebTransport connection established");
416 with_collector(|c| {
418 c.push_event(
419 1,
420 "transport",
421 &format!("WebTransport connected: {url}"),
422 "connect_handshake",
423 None,
424 );
425 });
426 Ok(())
427 }
428 Err(e) => {
429 self.connection_state = ConnectionState::Failed;
430 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
431 with_collector(|c| {
433 c.push_event(
434 3,
435 "transport",
436 &format!("WebTransport failed: {url} — {e:?}"),
437 "connect_handshake_failed",
438 None,
439 );
440 });
441 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
442 }
443 }
444 }
445
446 #[wasm_bindgen]
447 pub async fn reconnect(
448 &mut self,
449 url: String,
450 cert_hash: Option<Vec<u8>>,
451 ) -> Result<(), JsValue> {
452 self.check_worker();
453 self.connection_state = ConnectionState::Reconnecting;
454 self.reconnect_attempts += 1;
455
456 tracing::info!(
457 "Attempting reconnection... (attempt {})",
458 self.reconnect_attempts
459 );
460
461 self.connect(url, cert_hash).await
462 }
463
464 #[wasm_bindgen]
465 pub async fn wasm_load_asset(
466 &mut self,
467 handle: assets::AssetHandle,
468 url: String,
469 ) -> Result<(), JsValue> {
470 self.asset_registry.load_asset(handle, &url).await
471 }
472
473 pub fn set_session_token(&mut self, token: String) {
475 self.session_token = Some(token);
476 }
477
478 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
481 self.check_worker();
482 use wasm_bindgen::JsCast;
483
484 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
485 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
486 flags: wgpu::InstanceFlags::default(),
487 ..wgpu::InstanceDescriptor::new_without_display_handle()
488 });
489
490 let (surface_target, width, height) =
492 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
493 let width = html_canvas.width();
494 let height = html_canvas.height();
495 tracing::info!(
496 "Initializing renderer on HTMLCanvasElement ({}x{})",
497 width,
498 height
499 );
500 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
501 } else if let Ok(offscreen_canvas) =
502 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
503 {
504 let width = offscreen_canvas.width();
505 let height = offscreen_canvas.height();
506 tracing::info!(
507 "Initializing renderer on OffscreenCanvas ({}x{})",
508 width,
509 height
510 );
511
512 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
515 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
516 })?;
517
518 (
519 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
520 width,
521 height,
522 )
523 } else {
524 return Err(JsValue::from_str(
525 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
526 ));
527 };
528
529 let surface = instance
530 .create_surface(surface_target)
531 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
532
533 let render_state = RenderState::new(&instance, surface, width, height)
534 .await
535 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
536
537 self.render_state = Some(render_state);
538
539 with_collector(|c| {
541 c.push_event(
542 1,
543 "render_worker",
544 &format!("Renderer initialized ({}x{})", width, height),
545 "render_pipeline_setup",
546 None,
547 );
548 });
549
550 Ok(())
551 }
552
553 #[wasm_bindgen]
554 pub fn resize(&mut self, width: u32, height: u32) {
555 if let Some(state) = &mut self.render_state {
556 state.resize(width, height);
557 }
558 }
559
560 #[cfg(debug_assertions)]
561 #[wasm_bindgen]
562 pub fn set_debug_mode(&mut self, mode: u32) {
563 self.check_worker();
564 if let Some(state) = &mut self.render_state {
565 state.set_debug_mode(match mode {
566 0 => crate::render::DebugRenderMode::Off,
567 1 => crate::render::DebugRenderMode::Wireframe,
568 2 => crate::render::DebugRenderMode::Components,
569 _ => crate::render::DebugRenderMode::Full,
570 });
571 }
572 }
573
574 #[wasm_bindgen]
575 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
576 self.check_worker();
577 let clear = crate::render::parse_css_color(bg_base);
578 let label = crate::render::parse_css_color(text_primary);
579
580 tracing::info!(
581 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
582 bg_base,
583 clear,
584 text_primary,
585 label
586 );
587
588 if let Some(state) = &mut self.render_state {
589 state.set_clear_color(clear);
590 #[cfg(debug_assertions)]
591 state.set_label_color([
592 label.r as f32,
593 label.g as f32,
594 label.b as f32,
595 label.a as f32,
596 ]);
597 }
598 }
599
600 #[cfg(debug_assertions)]
601 #[wasm_bindgen]
602 pub fn cycle_debug_mode(&mut self) {
603 if let Some(state) = &mut self.render_state {
604 state.cycle_debug_mode();
605 }
606 }
607
608 #[cfg(debug_assertions)]
609 #[wasm_bindgen]
610 pub fn toggle_grid(&mut self) {
611 if let Some(state) = &mut self.render_state {
612 state.toggle_grid();
613 }
614 }
615
616 #[wasm_bindgen]
617 pub fn latest_tick(&self) -> u64 {
618 self.world_state.latest_tick
619 }
620
621 #[wasm_bindgen]
622 pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
623 self.check_worker();
624 self.playground_move_x = move_x;
625 self.playground_move_y = move_y;
626 self.playground_actions = actions_mask;
627 }
628
629 pub async fn tick(&mut self) {
631 self.check_worker();
632 use aetheris_protocol::traits::{Encoder, WorldState};
633
634 let encoder = SerdeEncoder::new();
635
636 if let Some(_transport) = &self.transport {}
639
640 if let Some(transport) = &mut self.transport {
642 self.ping_counter = self.ping_counter.wrapping_add(1);
643 if self.ping_counter % 60 == 0 {
644 let now = performance_now();
646 let tick_u64 = now as u64;
647
648 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
649 client_id: ClientId(0), tick: tick_u64,
651 }) {
652 tracing::trace!(tick = tick_u64, "Sending Ping");
653 let _ = transport.send_unreliable(ClientId(0), &data).await;
654 }
655 }
656 }
657
658 if let Some(transport) = &mut self.transport {
660 let events = match transport.poll_events().await {
661 Ok(e) => e,
662 Err(e) => {
663 tracing::error!("Transport poll failure: {:?}", e);
664 return;
665 }
666 };
667 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
668 Vec::new();
669
670 for event in events {
671 match event {
672 NetworkEvent::UnreliableMessage { data, client_id }
673 | NetworkEvent::ReliableMessage { data, client_id } => {
674 match encoder.decode(&data) {
675 Ok(update) => {
676 if self.last_clear_tick == 0
680 || update.tick > self.last_clear_tick
681 {
682 updates.push((client_id, update));
683 } else {
684 tracing::debug!(
685 network_id = update.network_id.0,
686 tick = update.tick,
687 last_clear_tick = self.last_clear_tick,
688 "Discarding stale update (tick <= last_clear_tick)"
689 );
690 }
691 }
692 Err(_) => {
693 if let Ok(event) = encoder.decode_event(&data) {
695 match event {
696 aetheris_protocol::events::NetworkEvent::GameEvent {
697 event: game_event,
698 ..
699 } => match &game_event {
700 aetheris_protocol::events::GameEvent::AsteroidDepleted {
701 network_id,
702 } => {
703 tracing::info!(?network_id, "Asteroid depleted");
704 self.world_state.entities.remove(&network_id);
705
706 for slot in self.world_state.entities.values_mut() {
707 if (slot.flags & 0x04) != 0
708 && slot.mining_target_id == (network_id.0 as u16)
709 {
710 slot.mining_active = 0;
711 slot.mining_target_id = 0;
712 tracing::info!("Cleared local mining target due to depletion");
713 }
714 }
715 }
716 aetheris_protocol::events::GameEvent::Possession {
717 network_id: _,
718 }
719 | aetheris_protocol::events::GameEvent::DamageEvent { .. }
720 | aetheris_protocol::events::GameEvent::DeathEvent { .. }
721 | aetheris_protocol::events::GameEvent::RespawnEvent { .. }
722 | aetheris_protocol::events::GameEvent::CargoCollected { .. } => {
723 self.world_state.handle_game_event(&game_event);
724 }
725 aetheris_protocol::events::GameEvent::SystemManifest {
726 manifest,
727 } => {
728 tracing::debug!(
729 count = manifest.len(),
730 "Received SystemManifest from server"
731 );
732 self.world_state.system_manifest = manifest.clone();
733 }
734 },
735 aetheris_protocol::events::NetworkEvent::ClearWorld {
736 ..
737 } => {
738 tracing::info!(
739 "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
740 );
741 self.pending_clear = false;
742 }
743 _ => {}
744 }
745 } else {
746 tracing::warn!(
747 "Failed to decode server message as update or wire event"
748 );
749 }
750 }
751 }
752 }
753 NetworkEvent::ClientConnected(id) => {
754 tracing::info!(?id, "Server connected");
755 }
756 NetworkEvent::ClientDisconnected(id) => {
757 tracing::warn!(?id, "Server disconnected");
758 }
759 NetworkEvent::Disconnected(_id) => {
760 tracing::error!("Transport disconnected locally");
761 self.connection_state = ConnectionState::Disconnected;
762 }
763 NetworkEvent::Ping { client_id: _, tick } => {
764 let pong = NetworkEvent::Pong { tick };
766 if let Ok(data) = encoder.encode_event(&pong) {
767 let _ = transport.send_reliable(ClientId(0), &data).await;
768 }
769 }
770 NetworkEvent::Pong { tick } => {
771 let now = performance_now();
773 let rtt = now - (tick as f64);
774 self.last_rtt_ms = rtt;
775
776 with_collector(|c| {
777 c.update_rtt(rtt);
778 });
779
780 #[cfg(feature = "metrics")]
781 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
782
783 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
784 }
785 NetworkEvent::Auth { .. } => {
786 tracing::debug!("Received Auth event from server (unexpected)");
788 }
789 NetworkEvent::SessionClosed(id) => {
790 tracing::warn!(?id, "WebTransport session closed");
791 }
792 NetworkEvent::StreamReset(id) => {
793 tracing::error!(?id, "WebTransport stream reset");
794 }
795 NetworkEvent::ReplicationBatch { events, client_id } => {
796 for event in events {
797 if self.last_clear_tick == 0 || event.tick > self.last_clear_tick {
798 updates.push((
799 client_id,
800 aetheris_protocol::events::ComponentUpdate {
801 network_id: event.network_id,
802 component_kind: event.component_kind,
803 payload: event.payload,
804 tick: event.tick,
805 },
806 ));
807 }
808 }
809 }
810 NetworkEvent::Fragment {
811 client_id,
812 fragment,
813 } => {
814 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
815 if let Ok(update) = encoder.decode(&data) {
816 if self.last_clear_tick == 0
817 || update.tick > self.last_clear_tick
818 {
819 updates.push((client_id, update));
820 }
821 }
822 }
823 }
824 NetworkEvent::StressTest { .. } => {
825 }
828 NetworkEvent::Spawn { .. } => {
829 }
831 NetworkEvent::ClearWorld { .. } => {
832 tracing::info!("Server ClearWorld ack received — gate lowered");
837 self.pending_clear = false;
838 }
839 NetworkEvent::GameEvent {
840 event: game_event, ..
841 } => {
842 match &game_event {
845 aetheris_protocol::events::GameEvent::AsteroidDepleted {
846 network_id,
847 } => {
848 tracing::info!(
849 ?network_id,
850 "Asteroid depleted (via GameEvent)"
851 );
852 self.world_state.entities.remove(&network_id);
854
855 for slot in self.world_state.entities.values_mut() {
857 if (slot.flags & 0x04) != 0
859 && slot.mining_target_id == (network_id.0 as u16)
860 {
861 slot.mining_active = 0;
862 slot.mining_target_id = 0;
863 tracing::info!(
864 "Cleared local mining target due to depletion"
865 );
866 }
867 }
868 }
869 aetheris_protocol::events::GameEvent::SystemManifest {
870 manifest,
871 } => {
872 tracing::info!(
873 count = manifest.len(),
874 "Received SystemManifest from server (via GameEvent)"
875 );
876 self.world_state.system_manifest = manifest.clone();
877 }
878 aetheris_protocol::events::GameEvent::Possession {
879 network_id: _,
880 }
881 | aetheris_protocol::events::GameEvent::DamageEvent { .. }
882 | aetheris_protocol::events::GameEvent::DeathEvent { .. }
883 | aetheris_protocol::events::GameEvent::RespawnEvent { .. }
884 | aetheris_protocol::events::GameEvent::CargoCollected { .. } => {
885 self.world_state.handle_game_event(&game_event);
886 }
887 }
888 }
889 #[allow(unreachable_patterns)]
890 _ => {
891 tracing::debug!("Unhandled outer NetworkEvent variant");
892 }
893 }
894 }
895
896 if self.pending_clear {
900 if !updates.is_empty() {
901 tracing::debug!(
902 count = updates.len(),
903 "Discarding updates — pending_clear gate is raised"
904 );
905 }
906 } else {
907 if !updates.is_empty() {
908 let max_tick = updates.iter().map(|(_, u)| u.tick).max().unwrap_or(0);
909
910 if max_tick > 0 {
912 let drift =
913 (self.world_state.latest_tick as i32 - max_tick as i32).abs();
914 if self.first_playground_tick || drift > 20 {
915 tracing::info!(
916 latest = self.world_state.latest_tick,
917 server = max_tick,
918 drift,
919 "Syncing client latest_tick to server heartbeat"
920 );
921 self.world_state.latest_tick = max_tick;
922 self.first_playground_tick = false;
923 }
924 }
925
926 tracing::debug!(count = updates.len(), "Applying server updates to world");
927 self.world_state.apply_updates(&updates);
928 }
929 }
930 }
931
932 let now = crate::performance_now();
938 let delta_ms = now - self.last_process_time;
939 self.last_process_time = now;
940
941 let delta_ms = delta_ms.min(100.0);
943 self.tick_accumulator += delta_ms;
944
945 const DT_MS: f64 = 1000.0 / 60.0;
946 while self.tick_accumulator >= DT_MS {
947 let applied = self.world_state.playground_apply_input(
951 self.playground_move_x,
952 self.playground_move_y,
953 self.playground_actions,
954 );
955
956 if !applied && self.world_state.latest_tick % 120 == 0 {
957 tracing::warn!(
958 tick = self.world_state.latest_tick,
959 "Simulation loop running but no LocalPlayer (0x04) entity found to apply input to"
960 );
961 }
962
963 self.world_state.latest_tick += 1;
965 self.world_state.simulate();
966 self.tick_accumulator -= DT_MS;
967 }
968
969 let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
972 let alpha = 0.8;
973 self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
974 self.shared_world.set_sub_tick_fraction(self.last_fraction);
975 tracing::trace!(fraction, "Updated sub-tick fraction");
976
977 let sim_start = crate::performance_now();
981
982 self.flush_to_shared_world(self.world_state.latest_tick);
984
985 let sim_time_ms = crate::performance_now() - sim_start;
986 let count = self.world_state.entities.len() as u32;
987
988 let (cargo_ore, cargo_cap) = self
990 .world_state
991 .player_network_id
992 .and_then(|id| self.world_state.entities.get(&id))
993 .map_or((0, 0), |s| (s.cargo_ore as u32, s.cargo_capacity as u32));
994
995 with_collector(|c| {
996 c.record_sim(sim_time_ms);
997 c.update_entity_count(count);
998 c.update_cargo(cargo_ore, cargo_cap);
999 });
1000 }
1001
1002 fn flush_to_shared_world(&mut self, tick: u64) {
1003 let entities = &self.world_state.entities;
1004 let write_buffer = self.shared_world.get_write_buffer();
1005
1006 let mut count = 0;
1007 for (i, slot) in entities.values().enumerate() {
1008 if i >= MAX_ENTITIES {
1009 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
1010 break;
1011 }
1012 write_buffer[i] = *slot;
1013 count += 1;
1014 }
1015
1016 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
1017 self.shared_world.commit_write(count as u32, tick);
1018 }
1019
1020 #[wasm_bindgen]
1021 pub async fn request_system_manifest(&mut self) -> Result<(), JsValue> {
1022 self.check_worker();
1023
1024 if let Some(transport) = &self.transport {
1025 let encoder = SerdeEncoder::new();
1026 let event = NetworkEvent::RequestSystemManifest {
1027 client_id: ClientId(0),
1028 };
1029
1030 if let Ok(data) = encoder.encode_event(&event) {
1031 transport
1032 .send_reliable(ClientId(0), &data)
1033 .await
1034 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1035 tracing::info!("Sent RequestSystemManifest command to server");
1036 }
1037 }
1038 Ok(())
1039 }
1040
1041 #[wasm_bindgen]
1042 pub fn get_system_info(&self) -> Result<JsValue, JsValue> {
1043 serde_wasm_bindgen::to_value(&self.world_state.system_manifest)
1044 .map_err(|e| JsValue::from_str(&e.to_string()))
1045 }
1046
1047 #[wasm_bindgen]
1048 pub fn wasm_get_entity_statuses(&self) -> JsValue {
1049 #[derive(serde::Serialize)]
1050 struct EntityStatus {
1051 network_id: String,
1052 hp: u16,
1053 shield: u16,
1054 entity_type: u16,
1055 is_player: bool,
1056 }
1057
1058 let mut entities: Vec<&SabSlot> = self.world_state.entities.values().collect();
1059 entities.sort_by_key(|slot| slot.network_id);
1060
1061 let statuses: Vec<EntityStatus> = entities
1062 .into_iter()
1063 .map(|slot| EntityStatus {
1064 network_id: slot.network_id.to_string(),
1065 hp: slot.hp,
1066 shield: slot.shield,
1067 entity_type: slot.entity_type,
1068 is_player: (slot.flags & 0x04) != 0,
1069 })
1070 .collect();
1071
1072 match serde_wasm_bindgen::to_value(&statuses) {
1073 Ok(val) => val,
1074 Err(e) => {
1075 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1076 "wasm_get_entity_statuses: serde_wasm_bindgen::to_value failed: {e}"
1077 )));
1078 wasm_bindgen::JsValue::NULL
1079 }
1080 }
1081 }
1082
1083 #[wasm_bindgen]
1084 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
1085 if self.world_state.entities.len() >= MAX_ENTITIES {
1087 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1088 return;
1089 }
1090
1091 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
1093 self.playground_next_network_id = self
1094 .world_state
1095 .entities
1096 .keys()
1097 .map(|k| k.0)
1098 .max()
1099 .unwrap_or(0)
1100 + 1;
1101 }
1102
1103 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
1104 self.playground_next_network_id += 1;
1105 let slot = SabSlot {
1106 network_id: id.0,
1107 x,
1108 y,
1109 z: 0.0,
1110 rotation,
1111 dx: 0.0,
1112 dy: 0.0,
1113 dz: 0.0,
1114 hp: 100,
1115 shield: 100,
1116 entity_type,
1117 flags: 0x01, mining_active: 0,
1119 cargo_ore: 0,
1120 cargo_capacity: 0,
1121 mining_target_id: 0,
1122 combat_target_id: 0,
1123 combat_flash_ticks: 0,
1124 padding: [0; 3],
1125 };
1126 self.world_state.entities.insert(id, slot);
1127 }
1128
1129 #[wasm_bindgen]
1130 pub async fn playground_spawn_net(
1131 &mut self,
1132 entity_type: u16,
1133 x: f32,
1134 y: f32,
1135 rot: f32,
1136 ) -> Result<(), JsValue> {
1137 self.check_worker();
1138
1139 if let Some(transport) = &self.transport {
1140 let encoder = SerdeEncoder::new();
1141 let event = NetworkEvent::Spawn {
1142 client_id: ClientId(0),
1143 entity_type,
1144 x,
1145 y,
1146 rot,
1147 };
1148
1149 if let Ok(data) = encoder.encode_event(&event) {
1150 transport
1151 .send_reliable(ClientId(0), &data)
1152 .await
1153 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1154 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1155 }
1156 } else {
1157 self.playground_spawn(entity_type, x, y, rot);
1159 }
1160 Ok(())
1161 }
1162
1163 #[wasm_bindgen]
1164 pub fn playground_clear(&mut self) {
1165 self.world_state.entities.clear();
1166 }
1167
1168 #[wasm_bindgen]
1172 pub async fn start_session_net(&mut self) -> Result<(), JsValue> {
1173 self.check_worker();
1174
1175 if let Some(transport) = &self.transport {
1176 let encoder = SerdeEncoder::new();
1177 let event = NetworkEvent::StartSession {
1178 client_id: ClientId(0),
1179 };
1180 if let Ok(data) = encoder.encode_event(&event) {
1181 transport
1182 .send_reliable(ClientId(0), &data)
1183 .await
1184 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1185 tracing::info!("Sent StartSession command to server");
1186 }
1187 }
1188 Ok(())
1189 }
1190
1191 #[wasm_bindgen]
1196 pub async fn send_input(
1197 &mut self,
1198 tick: u64,
1199 move_x: f32,
1200 move_y: f32,
1201 actions_mask: u32,
1202 target_id_arg: Option<u64>,
1203 ) -> Result<(), JsValue> {
1204 self.check_worker();
1205
1206 let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1208 tracing::trace!(
1211 network_id = owned_id.0,
1212 "[send_input] Using player_network_id for input target"
1213 );
1214 Some(owned_id)
1215 } else {
1216 let fallback = self
1218 .world_state
1219 .entities
1220 .iter()
1221 .find(|(_, slot)| (slot.flags & 0x04) != 0)
1222 .map(|(id, _)| *id);
1223 tracing::trace!(
1224 fallback_id = ?fallback,
1225 "[send_input] No player_network_id set - using 0x04 flag fallback"
1226 );
1227 fallback
1228 };
1229
1230 let Some(target_id) = target_id else {
1231 tracing::trace!("[send_input] Input dropped: no controlled entity found");
1232 return Ok(());
1233 };
1234
1235 tracing::trace!(
1236 target_id = target_id.0,
1237 move_x,
1238 move_y,
1239 "[send_input] Sending input for entity"
1240 );
1241
1242 let mut actions = Vec::new();
1244
1245 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1247 actions.push(PlayerInputKind::Move {
1248 x: move_x,
1249 y: move_y,
1250 });
1251 }
1252
1253 if (actions_mask & 0x04) != 0 {
1256 actions.push(PlayerInputKind::FirePrimary);
1257 }
1258 if (actions_mask & 0x02) != 0 && (self.last_actions_mask & 0x02) == 0 {
1260 let target = if let Some(id) = target_id_arg {
1261 Some(NetworkId(id))
1262 } else {
1263 if let Some(player_slot) = self.world_state.entities.get(&target_id) {
1265 let player_pos = (player_slot.x, player_slot.y);
1266 self.world_state
1267 .entities
1268 .iter()
1269 .filter(|(_, slot)| slot.entity_type == 5) .map(|(id, slot)| {
1271 let dx = slot.x - player_pos.0;
1272 let dy = slot.y - player_pos.1;
1273 (id, dx * dx + dy * dy)
1274 })
1275 .min_by(|a, b| {
1276 a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
1277 })
1278 .map(|(id, _)| *id)
1279 } else {
1280 None
1281 }
1282 };
1283
1284 if let Some(id) = target {
1285 actions.push(PlayerInputKind::ToggleMining { target: id });
1286 } else {
1287 tracing::warn!(
1288 "ToggleMining requested without target_id and no asteroid nearby; dropping action"
1289 );
1290 }
1291 }
1292
1293 self.last_actions_mask = actions_mask;
1294
1295 let is_repeated = self.last_input_actions.len() == actions.len()
1297 && self
1298 .last_input_actions
1299 .iter()
1300 .zip(actions.iter())
1301 .all(|(a, b)| a == b)
1302 && self.last_input_target == Some(target_id);
1303
1304 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1305 if is_repeated {
1306 tracing::trace!(
1307 tick,
1308 move_x,
1309 move_y,
1310 actions_mask,
1311 "Client sending input (repeated)"
1312 );
1313 } else {
1314 tracing::trace!(tick, move_x, move_y, actions_mask, "Client sending input");
1315 }
1316 }
1317
1318 let transport = self.transport.as_ref().ok_or_else(|| {
1319 JsValue::from_str("Cannot send input: transport not initialized or closed")
1320 })?;
1321
1322 if is_repeated {
1323 tracing::trace!(
1324 ?target_id,
1325 x = move_x,
1326 y = move_y,
1327 "Sending InputCommand (repeated)"
1328 );
1329 } else {
1330 tracing::trace!(?target_id, x = move_x, y = move_y, "Sending InputCommand");
1331 }
1332
1333 self.last_input_target = Some(target_id);
1335 self.last_input_actions = actions.clone();
1336
1337 let cmd = InputCommand {
1338 tick,
1339 actions,
1340 actions_mask,
1341 last_seen_input_tick: None,
1342 }
1343 .clamped();
1344
1345 let payload = rmp_serde::to_vec(&cmd)
1349 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1350
1351 let update = ReplicationEvent {
1352 network_id: target_id,
1353 component_kind: ComponentKind(128),
1354 payload,
1355 tick,
1356 };
1357
1358 let mut buffer = [0u8; 1024];
1359 let encoder = SerdeEncoder::new();
1360 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1361 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1362 })?;
1363
1364 transport
1366 .send_unreliable(ClientId(0), &buffer[..len])
1367 .await
1368 .map_err(|e| {
1369 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1370 })?;
1371
1372 Ok(())
1373 }
1374
1375 #[wasm_bindgen]
1376 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
1377 self.check_worker();
1378
1379 if let Some(transport) = &self.transport {
1380 let encoder = SerdeEncoder::new();
1381 let event = NetworkEvent::ClearWorld {
1382 client_id: ClientId(0),
1383 };
1384 if let Ok(data) = encoder.encode_event(&event) {
1385 transport
1386 .send_reliable(ClientId(0), &data)
1387 .await
1388 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1389 tracing::info!(
1390 "Sent ClearWorld command to server — suppressing updates until ack"
1391 );
1392 self.world_state.entities.clear();
1397 self.world_state.player_network_id = None;
1398 self.pending_clear = true;
1399 self.last_clear_tick = self.world_state.latest_tick;
1400 }
1401 } else {
1402 self.world_state.entities.clear();
1404 self.world_state.player_network_id = None;
1405 }
1406 Ok(())
1407 }
1408
1409 #[wasm_bindgen]
1410 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
1411 self.playground_rotation_enabled = enabled;
1412 }
1415
1416 #[wasm_bindgen]
1417 pub async fn playground_stress_test(
1418 &mut self,
1419 count: u16,
1420 rotate: bool,
1421 ) -> Result<(), JsValue> {
1422 self.check_worker();
1423
1424 if let Some(transport) = &self.transport {
1425 let encoder = SerdeEncoder::new();
1426 let event = NetworkEvent::StressTest {
1427 client_id: ClientId(0),
1428 count,
1429 rotate,
1430 };
1431
1432 if let Ok(data) = encoder.encode_event(&event) {
1433 transport
1434 .send_reliable(ClientId(0), &data)
1435 .await
1436 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1437 tracing::info!(count, rotate, "Sent StressTest command to server");
1438 }
1439 self.playground_set_rotation_enabled(rotate);
1440 } else {
1441 self.playground_set_rotation_enabled(rotate);
1443 self.playground_clear();
1444 for _ in 0..count {
1445 self.playground_spawn(1, 0.0, 0.0, 0.0); }
1447 }
1448
1449 Ok(())
1450 }
1451
1452 #[wasm_bindgen]
1453 pub fn tick_playground(&mut self) {
1454 self.check_worker();
1455
1456 let sim_start = crate::performance_now();
1458
1459 let now = crate::performance_now();
1460 let delta_ms = now - self.last_process_time;
1461 self.last_process_time = now;
1462
1463 let delta_ms = delta_ms.min(100.0);
1465 self.tick_accumulator += delta_ms;
1466
1467 const DT_MS: f64 = 1000.0 / 60.0;
1468 let mut steps = 0;
1469 while self.tick_accumulator >= DT_MS {
1470 self.world_state.latest_tick += 1;
1471
1472 self.world_state.playground_apply_input(
1474 self.playground_move_x,
1475 self.playground_move_y,
1476 self.playground_actions,
1477 );
1478
1479 self.world_state.simulate();
1481
1482 self.tick_accumulator -= DT_MS;
1483 steps += 1;
1484 }
1485
1486 if steps > 0 {
1488 let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1490 let alpha = 0.8;
1491 self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
1492 self.shared_world.set_sub_tick_fraction(self.last_fraction);
1493
1494 let count = self.world_state.entities.len() as u32;
1495 self.flush_to_shared_world(self.world_state.latest_tick);
1496
1497 let sim_time_ms = crate::performance_now() - sim_start;
1498 with_collector(|c| {
1499 c.record_sim(sim_time_ms);
1500 c.update_entity_count(count);
1501 });
1502 }
1503 }
1504
1505 pub fn render(&mut self) -> f64 {
1507 self.check_worker();
1508
1509 let tick = self.shared_world.tick();
1510 let entities = self.shared_world.get_read_buffer();
1511 let bounds = self.shared_world.get_room_bounds();
1512 if let Some(state) = &mut self.render_state {
1513 state.set_room_bounds(bounds);
1514 }
1515
1516 thread_local! {
1518 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1519 }
1520 FRAME_COUNT.with(|count| {
1521 let current = count.get();
1522 if current % 300 == 0 {
1523 tracing::debug!(
1524 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1525 tick,
1526 entities.len(),
1527 self.snapshots.len(),
1528 );
1529 }
1530 count.set(current + 1);
1531 });
1532
1533 let back_tick = self.snapshots.back().map(|s| s.tick).unwrap_or(0);
1535 if tick < back_tick && tick != 0 {
1536 tracing::warn!(
1537 tick,
1538 back_tick,
1539 "Simulation time went backwards! Clearing snapshot buffer."
1540 );
1541 self.snapshots.clear();
1542 }
1543
1544 if self.snapshots.is_empty() || tick > back_tick {
1545 tracing::trace!(tick, "Pushing new snapshot to buffer");
1546 self.snapshots.push_back(SimulationSnapshot {
1547 tick,
1548 entities: entities.to_vec(),
1549 });
1550 } else if tick == back_tick && tick != 0 {
1551 thread_local! {
1553 static STAGNANT_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1554 }
1555 STAGNANT_COUNT.with(|count| {
1556 let cur = count.get() + 1;
1557 if cur % 300 == 0 {
1558 tracing::warn!(tick, "Render loop stalled on same tick for 300 frames");
1559 }
1560 count.set(cur);
1561 });
1562 }
1563
1564 let latest_tick = self.snapshots.back().map(|s| s.tick as f32).unwrap_or(0.0);
1569 let fraction = self.shared_world.sub_tick_fraction();
1570 let mut target_tick = latest_tick - 1.0 + fraction;
1571
1572 if !self.snapshots.is_empty() {
1574 let oldest_tick = self.snapshots[0].tick as f32;
1575 if target_tick < oldest_tick {
1576 target_tick = oldest_tick;
1577 }
1578 }
1579
1580 let ent_count = entities.len();
1581 let frame_time_ms = self.render_at_tick(target_tick);
1582
1583 let snap_count = self.snapshots.len() as u32;
1585 if tick % 60 == 0 {
1586 tracing::trace!(
1587 tick,
1588 ent_count,
1589 snap_count,
1590 target_tick,
1591 "Render Loop Active"
1592 );
1593 }
1594 with_collector(|c| {
1595 c.record_frame(frame_time_ms, 0.0);
1597 c.update_snapshot_count(snap_count);
1598 });
1599
1600 frame_time_ms
1601 }
1602
1603 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1604 if self.snapshots.len() < 2 {
1605 if let Some(state) = &mut self.render_state {
1608 let entities = if !self.snapshots.is_empty() {
1609 self.snapshots[0].entities.clone()
1610 } else {
1611 Vec::new()
1612 };
1613 return state.render_frame_with_compact_slots(&entities);
1614 }
1615 return 0.0;
1616 }
1617
1618 let mut s1_idx = 0;
1620 let mut found = false;
1621
1622 for i in 0..self.snapshots.len() - 1 {
1623 if (self.snapshots[i].tick as f32) <= target_tick
1624 && (self.snapshots[i + 1].tick as f32) > target_tick
1625 {
1626 s1_idx = i;
1627 found = true;
1628 break;
1629 }
1630 }
1631
1632 if !found {
1633 if target_tick < self.snapshots[0].tick as f32 {
1635 s1_idx = 0;
1636 } else {
1637 s1_idx = self.snapshots.len() - 2;
1638 }
1639 }
1640
1641 let s1 = self.snapshots.get(s1_idx).unwrap();
1642 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1643
1644 let tick_range = (s2.tick - s1.tick) as f32;
1645 let alpha = if tick_range > 0.0 {
1646 (target_tick - s1.tick as f32) / tick_range
1647 } else {
1648 1.0
1649 }
1650 .clamp(0.0, 1.0);
1651
1652 self.render_buffer.clear();
1654 self.render_buffer.extend_from_slice(&s2.entities);
1655
1656 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1658 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1659
1660 for ent in &mut self.render_buffer {
1661 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1662 if let Some(bounds) = &self.world_state.room_bounds {
1663 ent.x = lerp_wrapped(prev.x, ent.x, alpha, bounds.min_x, bounds.max_x);
1664 ent.y = lerp_wrapped(prev.y, ent.y, alpha, bounds.min_y, bounds.max_y);
1665 } else {
1666 ent.x = lerp(prev.x, ent.x, alpha);
1667 ent.y = lerp(prev.y, ent.y, alpha);
1668 }
1669 ent.z = lerp(prev.z, ent.z, alpha);
1670 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1671 } else {
1672 let dt = 1.0 / 60.0;
1676 let remaining = 1.0 - alpha;
1677
1678 if let Some(bounds) = &self.world_state.room_bounds {
1679 ent.x = lerp_wrapped(
1681 ent.x,
1682 ent.x - ent.dx * dt * remaining,
1683 1.0,
1684 bounds.min_x,
1685 bounds.max_x,
1686 );
1687 ent.y = lerp_wrapped(
1688 ent.y,
1689 ent.y - ent.dy * dt * remaining,
1690 1.0,
1691 bounds.min_y,
1692 bounds.max_y,
1693 );
1694 } else {
1695 ent.x -= ent.dx * dt * remaining;
1696 ent.y -= ent.dy * dt * remaining;
1697 }
1698 ent.z -= ent.dz * dt * remaining;
1699 }
1700 }
1701
1702 let mut frame_time = 0.0;
1703 if let Some(state) = &mut self.render_state {
1704 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1705 }
1706
1707 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1711 self.snapshots.pop_front();
1712 }
1713
1714 while self.snapshots.len() > 16 {
1716 self.snapshots.pop_front();
1717 }
1718
1719 frame_time
1720 }
1721 }
1722
1723 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1724 a + (b - a) * alpha
1725 }
1726
1727 fn lerp_wrapped(a: f32, b: f32, alpha: f32, min: f32, max: f32) -> f32 {
1728 let size = max - min;
1729 if size <= 0.0 {
1730 return a + (b - a) * alpha;
1731 }
1732
1733 let a_norm = (a - min).rem_euclid(size) + min;
1735 let b_norm = (b - min).rem_euclid(size) + min;
1736
1737 let mut diff = b_norm - a_norm;
1738 if diff.abs() > size * 0.5 {
1739 if diff > 0.0 {
1740 diff -= size;
1741 } else {
1742 diff += size;
1743 }
1744 }
1745 let res = a_norm + diff * alpha;
1746 (res - min).rem_euclid(size) + min
1748 }
1749
1750 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1751 let mut diff = b - a;
1754 while diff < -std::f32::consts::PI {
1755 diff += std::f32::consts::TAU;
1756 }
1757 while diff > std::f32::consts::PI {
1758 diff -= std::f32::consts::TAU;
1759 }
1760 a + diff * alpha
1761 }
1762}