nightshade_api/web/
engine.rs1use crate::wire::{FromWorker, SelectedEntity, ToWorker};
5use leptos::prelude::*;
6use serde::Serialize;
7use wasm_bindgen::{JsCast, JsValue};
8
9use crate::web::viewport::{Bridge, Viewport, ViewportEvent};
10
11#[derive(Clone, Copy)]
14pub struct EngineState {
15 pub ready: RwSignal<bool>,
17 pub adapter: RwSignal<String>,
19 pub fps: RwSignal<f32>,
21 pub entity_count: RwSignal<u32>,
23 pub selected: RwSignal<Option<SelectedEntity>>,
25 pub grabbing: RwSignal<bool>,
27}
28
29impl EngineState {
30 fn new() -> Self {
31 Self {
32 ready: RwSignal::new(false),
33 adapter: RwSignal::new(String::new()),
34 fps: RwSignal::new(0.0),
35 entity_count: RwSignal::new(0),
36 selected: RwSignal::new(None),
37 grabbing: RwSignal::new(false),
38 }
39 }
40}
41
42#[derive(Clone, Copy)]
46pub struct Engine {
47 pub state: EngineState,
49 bridge: StoredValue<Option<Bridge>, LocalStorage>,
50 custom_handler: StoredValue<Option<Callback<serde_json::Value>>, LocalStorage>,
51 queue: StoredValue<Vec<ToWorker>, LocalStorage>,
52 worker_url: StoredValue<String>,
53}
54
55impl Engine {
56 pub fn send<Message: Serialize>(&self, message: &Message) {
59 if let Ok(value) = serde_json::to_value(message) {
60 self.dispatch(ToWorker::Custom(value));
61 }
62 }
63
64 pub fn on_custom(&self, handler: Callback<serde_json::Value>) {
66 self.custom_handler.set_value(Some(handler));
67 }
68
69 fn dispatch(&self, message: ToWorker) {
70 if let Some(bridge) = self.bridge.get_value() {
71 bridge.send(&message);
72 } else {
73 self.queue.update_value(|queue| queue.push(message));
74 }
75 }
76
77 fn connect(&self, bridge: Bridge, canvas: &web_sys::OffscreenCanvas, width: f32, height: f32) {
78 bridge.send_with_canvas(&ToWorker::Init { width, height }, canvas);
79 for message in self.queue.get_value() {
80 bridge.send(&message);
81 }
82 self.queue.set_value(Vec::new());
83 self.bridge.set_value(Some(bridge));
84 }
85}
86
87pub fn use_engine(worker_url: impl Into<String>) -> Engine {
91 let engine = Engine {
92 state: EngineState::new(),
93 bridge: StoredValue::new_local(None),
94 custom_handler: StoredValue::new_local(None),
95 queue: StoredValue::new_local(Vec::new()),
96 worker_url: StoredValue::new(worker_url.into()),
97 };
98
99 let _ = window_event_listener(leptos::ev::keydown, move |event| {
100 if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
101 return;
102 }
103 let text = (event.key().chars().count() == 1).then(|| event.key());
104 engine.dispatch(ToWorker::Key {
105 code: event.code(),
106 pressed: true,
107 text,
108 });
109 });
110 let _ = window_event_listener(leptos::ev::keyup, move |event| {
111 if event.ctrl_key() || event.meta_key() || typing_in_field(&event) {
112 return;
113 }
114 engine.dispatch(ToWorker::Key {
115 code: event.code(),
116 pressed: false,
117 text: None,
118 });
119 });
120
121 engine
122}
123
124#[component]
128pub fn EngineViewport(engine: Engine) -> impl IntoView {
129 let state = engine.state;
130 let custom_handler = engine.custom_handler;
131
132 let on_input = Callback::new(move |event: ViewportEvent| {
133 let message = match event {
134 ViewportEvent::Resize { width, height } => ToWorker::Resize { width, height },
135 ViewportEvent::PointerMove { x, y } => ToWorker::PointerMove { x, y },
136 ViewportEvent::PointerButton { button, pressed } => {
137 ToWorker::PointerButton { button, pressed }
138 }
139 ViewportEvent::Wheel { delta } => ToWorker::Wheel { delta },
140 ViewportEvent::Touch { id, phase, x, y } => ToWorker::Touch { id, phase, x, y },
141 ViewportEvent::Pick { x, y } => ToWorker::Pick { x, y },
142 };
143 engine.dispatch(message);
144 });
145
146 let on_connect = Callback::new(
147 move |(connected, canvas, width, height): (Bridge, web_sys::OffscreenCanvas, f32, f32)| {
148 engine.connect(connected, &canvas, width, height);
149 },
150 );
151
152 let on_message = Callback::new(move |payload: JsValue| {
153 let Ok(message) = serde_wasm_bindgen::from_value::<FromWorker>(payload) else {
154 return;
155 };
156 match message {
157 FromWorker::Ready { adapter } => {
158 state.adapter.set(adapter);
159 state.ready.set(true);
160 }
161 FromWorker::Stats { fps, entity_count } => {
162 state.fps.set(fps);
163 state.entity_count.set(entity_count);
164 }
165 FromWorker::Selected { detail } => state.selected.set(detail),
166 FromWorker::Custom(value) => {
167 if let Some(handler) = custom_handler.get_value() {
168 handler.run(value);
169 }
170 }
171 }
172 });
173
174 view! {
175 <Viewport
176 worker_url=engine.worker_url.get_value()
177 grabbing=state.grabbing
178 on_input=on_input
179 on_connect=on_connect
180 on_message=on_message
181 />
182 }
183}
184
185fn typing_in_field(event: &web_sys::KeyboardEvent) -> bool {
186 event
187 .target()
188 .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
189 .map(|element| {
190 let tag = element.tag_name();
191 tag.eq_ignore_ascii_case("input")
192 || tag.eq_ignore_ascii_case("textarea")
193 || tag.eq_ignore_ascii_case("select")
194 || element.is_content_editable()
195 })
196 .unwrap_or(false)
197}