diff --git a/build.rs b/build.rs index 87390aa..80e1f67 100644 --- a/build.rs +++ b/build.rs @@ -35,11 +35,10 @@ fn main() { let status = cargo .status() .expect("failed to run cargo for the ui crate"); - if !status.success() { - panic!( - "building ui/ failed (see the cargo output above); the wasm32-unknown-unknown target must be installed: rustup target add wasm32-unknown-unknown" - ); - } + assert!( + status.success(), + "building ui/ failed (see the cargo output above); the wasm32-unknown-unknown target must be installed: rustup target add wasm32-unknown-unknown" + ); let profile = if release { "release" } else { "debug" }; let wasm = target_dir diff --git a/ui/src/api.rs b/ui/src/api.rs index 0e962f2..2c552c5 100644 --- a/ui/src/api.rs +++ b/ui/src/api.rs @@ -24,7 +24,7 @@ pub fn connect(on_state: impl Fn(String) + 'static) { "ws" }; let Ok(host) = loc.host() else { return }; - open(format!("{proto}://{host}/ws"), on_state.clone()); + open(&format!("{proto}://{host}/ws"), on_state.clone()); spawn_local(async move { loop { @@ -45,8 +45,8 @@ pub fn connect(on_state: impl Fn(String) + 'static) { }); } -fn open(url: String, on_state: Rc) { - let Ok(ws) = WebSocket::new(&url) else { return }; +fn open(url: &str, on_state: Rc) { + let Ok(ws) = WebSocket::new(url) else { return }; let cb = on_state.clone(); let onmessage = Closure::::new(move |e: MessageEvent| { if let Some(text) = e.data().as_string() { @@ -56,13 +56,13 @@ fn open(url: String, on_state: Rc) { ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget(); - let url2 = url.clone(); + let url = url.to_string(); let onclose = Closure::::new(move || { SOCKET.with(|s| *s.borrow_mut() = None); - let (url, on_state) = (url2.clone(), on_state.clone()); + let (url, on_state) = (url.clone(), on_state.clone()); spawn_local(async move { TimeoutFuture::new(1000).await; - open(url, on_state); + open(&url, on_state); }); }); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); diff --git a/ui/src/canvas.rs b/ui/src/canvas.rs index cc52bec..8686af0 100644 --- a/ui/src/canvas.rs +++ b/ui/src/canvas.rs @@ -1,4 +1,4 @@ -use crate::model::{Sel, State, T, Terrain}; +use crate::model::{Sel, State, T, Terrain, canvas_len, tile_offset}; use wasm_bindgen::JsCast; use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; @@ -7,8 +7,8 @@ const COLORS: [&str; 7] = [ ]; pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option) { - canvas.set_width((t.w as f64 * T) as u32); - canvas.set_height((t.h as f64 * T) as u32); + canvas.set_width(canvas_len(t.w)); + canvas.set_height(canvas_len(t.h)); let Some(ctx) = canvas .get_context("2d") .ok() @@ -21,7 +21,7 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option for y in 0..t.h { for x in 0..t.w { ctx.set_fill_style_str(COLORS[t.kind(x, y)]); - ctx.fill_rect(x as f64 * T, y as f64 * T, T, T); + ctx.fill_rect(tile_offset(x), tile_offset(y), T, T); } } @@ -29,8 +29,8 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option ctx.set_fill_style_str(color); ctx.begin_path(); let _ = ctx.arc( - x as f64 * T + T / 2.0, - y as f64 * T + T / 2.0, + tile_offset(x) + T / 2.0, + tile_offset(y) + T / 2.0, r, 0.0, std::f64::consts::TAU, @@ -44,16 +44,21 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option 2 => dot(r.0, r.1, 2.0, "#e85a8a"), _ => { ctx.set_fill_style_str("#666"); - ctx.fill_rect(r.0 as f64 * T + 2.0, r.1 as f64 * T + 2.0, 6.0, 6.0); + ctx.fill_rect(tile_offset(r.0) + 2.0, tile_offset(r.1) + 2.0, 6.0, 6.0); } } } ctx.set_fill_style_str("#7a4a22"); for h in &s.houses { - ctx.fill_rect(h.0 as f64 * T + 1.0, h.1 as f64 * T + 1.0, T - 2.0, T - 2.0); + ctx.fill_rect( + tile_offset(h.0) + 1.0, + tile_offset(h.1) + 1.0, + T - 2.0, + T - 2.0, + ); } ctx.set_fill_style_str("#b05a2a"); - ctx.fill_rect(s.home.0 as f64 * T, s.home.1 as f64 * T, T, T); + ctx.fill_rect(tile_offset(s.home.0), tile_offset(s.home.1), T, T); for a in &s.rabbits { dot(a.0, a.1, 2.5, "#fff"); } @@ -65,17 +70,17 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option } ctx.set_fill_style_str(&format!("rgba(5,10,50,{})", (1.0 - s.daylight) * 0.65)); - ctx.fill_rect(0.0, 0.0, t.w as f64 * T, t.h as f64 * T); + ctx.fill_rect(0.0, 0.0, tile_offset(t.w), tile_offset(t.h)); if let Some(sel) = sel { let (x0, y0, x1, y1) = sel.rect(); ctx.set_stroke_style_str("#fff"); ctx.set_line_width(2.0); ctx.stroke_rect( - x0 as f64 * T, - y0 as f64 * T, - (x1 - x0 + 1) as f64 * T, - (y1 - y0 + 1) as f64 * T, + tile_offset(x0), + tile_offset(y0), + tile_offset(x1 - x0 + 1), + tile_offset(y1 - y0 + 1), ); } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 44c902f..e2fb9aa 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -6,7 +6,7 @@ mod model; use leptos::mount::mount_to_body; use leptos::prelude::*; use leptos::task::spawn_local; -use model::{Person, Sel, State, T, Terrain}; +use model::{Person, Sel, State, Terrain, tile_at}; use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] @@ -53,7 +53,7 @@ fn App() -> impl IntoView { if let (Some(c), Some(s), Some(t)) = (canvas.get(), s.as_ref(), t.as_ref()) { canvas::draw(&c, t, s, sel); } - }) + }); }); }); @@ -62,17 +62,14 @@ fn App() -> impl IntoView { // Map through the displayed size so CSS scaling or zoom can't skew the tile. let r = c.get_bounding_client_rect(); let (sx, sy) = ( - c.width() as f64 / r.width().max(1.0), - c.height() as f64 / r.height().max(1.0), + f64::from(c.width()) / r.width().max(1.0), + f64::from(c.height()) / r.height().max(1.0), ); - let px = (ev.client_x() as f64 - r.left()) * sx; - let py = (ev.client_y() as f64 - r.top()) * sy; + let px = (f64::from(ev.client_x()) - r.left()) * sx; + let py = (f64::from(ev.client_y()) - r.top()) * sy; terrain.with_untracked(|t| { - t.as_ref().map(|t| { - let x = ((px / T).max(0.0) as usize).min(t.w - 1); - let y = ((py / T).max(0.0) as usize).min(t.h - 1); - (x, y) - }) + t.as_ref() + .map(|t| (tile_at(px, t.w - 1), tile_at(py, t.h - 1))) }) }; @@ -149,10 +146,10 @@ fn App() -> impl IntoView {

"World"

{move || state.with(|s| s.as_ref().map(|s| { - let (h, m) = (s.hour.floor() as u32, ((s.hour % 1.0) * 60.0) as u32); + let (h, m) = (s.hour.floor(), (s.hour.fract() * 60.0).floor()); view! {
{format!( - "Year {}, month {}, week {}, day {}, {h:02}:{m:02}", + "Year {}, month {}, week {}, day {}, {h:02.0}:{m:02.0}", s.year, s.month, s.week, s.date_day )}
{format!("People: {}", s.people.len())}
@@ -181,7 +178,7 @@ fn App() -> impl IntoView { }))}
" Speed " s.limits.animal_energy.to_string(), "spawn" => s.limits.spawn.to_string(), _ => s.limits.resource.to_string(), - } - }).unwrap_or_else(|| "100".to_string())) + }, + )) title="amount / energy" prop:value=move || amount.get() on:input=move |e| amount.set(event_target_value(&e)) @@ -398,12 +396,12 @@ fn StageFertility(state: RwSignal>, stage: String) -> impl IntoVie fn PersonEditor(p: Person, state: RwSignal>) -> impl IntoView { let id = p.0; let fields = [ - ("hunger", p.4 as u32), - ("food", p.5), - ("wood", p.6), - ("stone", p.7), + ("hunger", format!("{:.0}", p.4.trunc())), + ("food", p.5.to_string()), + ("wood", p.6.to_string()), + ("stone", p.7.to_string()), ] - .map(|(k, v)| (k, RwSignal::new(v.to_string()))); + .map(|(k, v)| (k, RwSignal::new(v))); view! {
{format!("Edit #{id}: ")} @@ -416,13 +414,14 @@ fn PersonEditor(p: Person, state: RwSignal>) -> impl IntoView { @@ -472,8 +471,10 @@ fn StockEditor(state: RwSignal>) -> impl IntoView { type="number" min="0" prop:max=move || state.with(|s| { - s.as_ref().map(|s| s.limits.stock.to_string()) - .unwrap_or_else(|| "9999".to_string()) + s.as_ref().map_or_else( + || "9999".to_string(), + |s| s.limits.stock.to_string(), + ) }) prop:value=move || s.get() on:input=move |e| s.set(event_target_value(&e)) diff --git a/ui/src/model.rs b/ui/src/model.rs index d6d6a12..9b70b10 100644 --- a/ui/src/model.rs +++ b/ui/src/model.rs @@ -1,6 +1,25 @@ use serde::Deserialize; -pub const T: f64 = 10.0; +/// Tile size in canvas pixels. +pub const TILE_PX: u32 = 10; +pub const T: f64 = TILE_PX as f64; + +/// Canvas offset in pixels of tile coordinate `n`. +pub fn tile_offset(n: usize) -> f64 { + f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * T +} + +/// Canvas length in pixels of `n` tiles. +pub fn canvas_len(n: usize) -> u32 { + u32::try_from(n).map_or(u32::MAX, |n| n.saturating_mul(TILE_PX)) +} + +/// Tile index under canvas pixel offset `px`, clamped to `0..=max`. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +pub fn tile_at(px: f64, max: usize) -> usize { + // Float-to-int `as` saturates and maps NaN to 0, so negative or huge offsets stay in range. + ((px / T) as usize).min(max) +} pub const TERRAIN: [&str; 7] = [ "Deep water", "Shallow water", @@ -114,8 +133,8 @@ impl Calendar { return Some(format!("stage {stage} fertile {}", u8::from(value != 0))); } let (min_days, max_days) = match bound { - "min_days" => (value.min(u32::MAX as u64) as u32, range.max_days), - "max_days" => (range.min_days, value.min(u32::MAX as u64) as u32), + "min_days" => (u32::try_from(value).unwrap_or(u32::MAX), range.max_days), + "max_days" => (range.min_days, u32::try_from(value).unwrap_or(u32::MAX)), _ => return None, }; Some(format!("stage {stage} {min_days} {max_days}"))