use std::cell::RefCell;
use std::rc::Rc;
use ratatui::Terminal;
use ratzilla::backend::webgl2::{FontAtlasData, WebGl2BackendOptions};
use ratzilla::event::{
KeyCode as RzKeyCode, KeyEvent as RzKeyEvent, MouseButton as RzMouseButton,
MouseEvent as RzMouseEvent, MouseEventKind as RzMouseEventKind,
};
use ratzilla::{WebGl2Backend, WebRenderer};
use wasm_bindgen::prelude::*;
use crate::game::state::TICK_HZ;
use crate::input::{
self, InputContext, InputEvent, KeyCode, Modifiers, MouseButton, UiState, WheelDelta,
};
use crate::platform::Persistence;
use crate::sim::{self, Action, SimGeometry};
use crate::ui;
const SIM_TICK_MS: f64 = 1000.0 / TICK_HZ as f64;
const MAX_TICK_CATCHUP: u32 = 20;
const SAVE_INTERVAL_TICKS: u64 = TICK_HZ as u64;
struct WebUi {
ui: UiState,
layout: crate::ui::DrawOutput,
last_tick_ms: f64,
ticks_since_save: u64,
cell_pixel_w: f64,
cell_pixel_h: f64,
}
impl WebUi {
fn new() -> Self {
Self {
ui: UiState::new(),
layout: Default::default(),
last_tick_ms: now_ms(),
ticks_since_save: 0,
cell_pixel_w: 0.0,
cell_pixel_h: 0.0,
}
}
}
#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
crate::i18n::init();
let atlas = FontAtlasData::from_binary(include_bytes!("../assets/jetbrains-mono.atlas"))
.map_err(|e| JsValue::from_str(&format!("FontAtlasData::from_binary failed: {e:?}")))?;
let backend = WebGl2Backend::new_with_options(WebGl2BackendOptions::new().font_atlas(atlas))
.map_err(|e| JsValue::from_str(&format!("WebGl2Backend::new failed: {e}")))?;
let terminal = Terminal::new(backend)
.map_err(|e| JsValue::from_str(&format!("Terminal::new failed: {e}")))?;
let persistence = Persistence::new();
let state = Rc::new(RefCell::new(persistence.load()));
let web = Rc::new(RefCell::new(WebUi::new()));
let geom = Rc::new(RefCell::new(SimGeometry::default()));
let actions = Rc::new(RefCell::new(Vec::<Action>::with_capacity(4)));
{
let state = state.clone();
let web = web.clone();
let geom = geom.clone();
let actions = actions.clone();
terminal.on_key_event(move |k| {
if let Some(ev) = translate_key(k) {
dispatch(ev, &state, &web, &geom, &actions);
}
});
}
{
let state = state.clone();
let web = web.clone();
let geom = geom.clone();
let actions = actions.clone();
terminal.on_mouse_event(move |m| {
let evs = {
let w = web.borrow();
translate_mouse(m, &w)
};
for ev in evs {
dispatch(ev, &state, &web, &geom, &actions);
}
});
}
{
let state = state.clone();
let web = web.clone();
let geom = geom.clone();
let actions = actions.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |e: web_sys::WheelEvent| {
e.prevent_default();
let (col, row) = pixel_to_cell(e.client_x() as u32, e.client_y() as u32, &web.borrow())
.unwrap_or((0, 0));
let delta = if e.delta_y() < 0.0 {
WheelDelta::Up
} else if e.delta_y() > 0.0 {
WheelDelta::Down
} else {
return;
};
dispatch(
InputEvent::Wheel { col, row, delta },
&state,
&web,
&geom,
&actions,
);
});
web_sys::window()
.ok_or_else(|| JsValue::from_str("no window"))?
.set_onwheel(Some(closure.as_ref().unchecked_ref()));
closure.forget();
}
{
let state = state.clone();
let flush = Closure::<dyn FnMut(_)>::new(move |_e: web_sys::Event| {
let p = Persistence::new();
if let Ok(s) = state.try_borrow() {
let _ = p.save(&s);
}
});
let win = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
win.add_event_listener_with_callback("beforeunload", flush.as_ref().unchecked_ref())
.map_err(|e| {
JsValue::from_str(&format!("addEventListener beforeunload failed: {e:?}"))
})?;
win.add_event_listener_with_callback("pagehide", flush.as_ref().unchecked_ref())
.map_err(|e| JsValue::from_str(&format!("addEventListener pagehide failed: {e:?}")))?;
flush.forget();
}
let s = state.clone();
let w = web.clone();
let g = geom.clone();
terminal.draw_web(move |f| {
let mut state = s.borrow_mut();
let mut web = w.borrow_mut();
let mut geom = g.borrow_mut();
let now = now_ms();
let mut catchup = 0u32;
while now - web.last_tick_ms >= SIM_TICK_MS {
sim::sim_tick(&mut state, &geom);
web.last_tick_ms += SIM_TICK_MS;
web.ticks_since_save += 1;
if web.ticks_since_save >= SAVE_INTERVAL_TICKS {
web.ticks_since_save = 0;
let _ = persistence.save(&state);
}
catchup += 1;
if catchup >= MAX_TICK_CATCHUP {
web.last_tick_ms = now;
break;
}
}
let area = f.area();
let debug = crate::build_info::is_dev_build();
let prestige_confirm_pending = web.ui.prestige_confirm_pending;
web.layout = ui::draw(
f,
&state,
web.ui.mode,
web.ui.zoom_idx,
debug,
web.ui.last_mouse_pos,
&mut web.ui.tree_render,
prestige_confirm_pending,
);
geom.biscuit = web.layout.biscuit_rect;
geom.powerups_paused = web.ui.mode == crate::ui::Mode::Tree;
if area.width > 0
&& area.height > 0
&& let Some(canvas) = canvas_element()
{
let rect = canvas.get_bounding_client_rect();
let buf_w = canvas.width() as f64;
let buf_h = canvas.height() as f64;
let atlas_w = (buf_w / area.width as f64).floor();
let atlas_h = (buf_h / area.height as f64).floor();
web.cell_pixel_w = if buf_w > 0.0 {
atlas_w * rect.width() / buf_w
} else {
atlas_w
};
web.cell_pixel_h = if buf_h > 0.0 {
atlas_h * rect.height() / buf_h
} else {
atlas_h
};
}
});
Ok(())
}
fn dispatch(
ev: InputEvent,
state: &Rc<RefCell<crate::game::state::GameState>>,
web: &Rc<RefCell<WebUi>>,
geom: &Rc<RefCell<SimGeometry>>,
actions: &Rc<RefCell<Vec<Action>>>,
) {
let mut state = state.borrow_mut();
let mut web_ref = web.borrow_mut();
let mut geom = geom.borrow_mut();
let mut actions = actions.borrow_mut();
let WebUi { ui, layout, .. } = &mut *web_ref;
let ctx = InputContext::from_layout(layout, &state, crate::build_info::is_dev_build());
actions.clear();
input::process_input_event(ev, ui, &ctx, &mut actions);
for a in actions.drain(..) {
sim::apply_action(&mut state, a, &mut geom);
}
}
fn translate_key(k: RzKeyEvent) -> Option<InputEvent> {
let code = match k.code {
RzKeyCode::Char(c) => KeyCode::Char(c),
RzKeyCode::Esc => KeyCode::Esc,
RzKeyCode::F(n) => KeyCode::F(n),
RzKeyCode::Up => KeyCode::Up,
RzKeyCode::Down => KeyCode::Down,
RzKeyCode::Left => KeyCode::Left,
RzKeyCode::Right => KeyCode::Right,
RzKeyCode::Enter => KeyCode::Enter,
_ => return None,
};
Some(InputEvent::KeyPress {
code,
mods: Modifiers {
shift: k.shift,
alt: k.alt,
ctrl: k.ctrl,
},
})
}
fn translate_mouse(m: RzMouseEvent, web: &WebUi) -> Vec<InputEvent> {
let Some((col, row)) = pixel_to_cell(m.x, m.y, web) else {
return Vec::new();
};
let mods = Modifiers {
shift: m.shift,
alt: m.alt,
ctrl: m.ctrl,
};
match m.event {
RzMouseEventKind::Pressed => {
let button = match m.button {
RzMouseButton::Left => MouseButton::Left,
RzMouseButton::Right => MouseButton::Right,
_ => return Vec::new(),
};
vec![InputEvent::MouseDown {
col,
row,
button,
mods,
}]
}
RzMouseEventKind::Released => {
let button = match m.button {
RzMouseButton::Left => MouseButton::Left,
RzMouseButton::Right => MouseButton::Right,
_ => return Vec::new(),
};
vec![InputEvent::MouseUp { col, row, button }]
}
RzMouseEventKind::Moved => vec![InputEvent::MouseMoved { col, row }],
_ => Vec::new(),
}
}
fn now_ms() -> f64 {
web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now())
.unwrap_or(0.0)
}
fn canvas_element() -> Option<web_sys::HtmlCanvasElement> {
let doc = web_sys::window()?.document()?;
let elem = doc.query_selector("canvas").ok().flatten()?;
elem.dyn_into::<web_sys::HtmlCanvasElement>().ok()
}
fn pixel_to_cell(x: u32, y: u32, web: &WebUi) -> Option<(u16, u16)> {
if web.cell_pixel_w <= 0.0 || web.cell_pixel_h <= 0.0 {
return None;
}
let canvas = canvas_element()?;
let rect = canvas.get_bounding_client_rect();
let col_f = (x as f64 - rect.left()) / web.cell_pixel_w;
let row_f = (y as f64 - rect.top()) / web.cell_pixel_h;
if col_f < 0.0 || row_f < 0.0 {
return None;
}
Some((col_f.floor() as u16, row_f.floor() as u16))
}