feat: build the frontend with Leptos and WebAssembly

This commit is contained in:
Steve Biedermann 2026-10-07 19:18:25 +02:00
parent 1e7a5f911a
commit e4a4cb0190
12 changed files with 2652 additions and 2 deletions

1606
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,9 @@ name = "sim"
version = "0.1.0"
edition = "2024"
[workspace]
members = ["ui"]
[dependencies]
rune = "0.14.2"
tiny_http = "0.12"

View File

@ -1,14 +1,50 @@
mod world;
use std::io::Read;
use std::path::Path;
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tiny_http::{Header, Method, Response, Server};
use tiny_http::{Header, Method, Request, Response, Server};
use world::{DEFAULT_SCRIPT, World};
const INDEX: &str = include_str!("index.html");
// Minimal shell; the whole UI is the Leptos app in ui/ compiled to wasm.
const INDEX: &str = r#"<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>World Simulation</title></head>
<body style="background:#111;color:#ddd;font:13px monospace"><script type="module">
try { const m = await import('/pkg/ui.js'); await m.default(); }
catch (e) { document.body.textContent = 'Failed to load the UI (did you run ui/build.sh?): ' + e; }
</script></body></html>"#;
fn serve_static(req: Request, dir: &Path, rel: &str) {
let rel = rel.split('?').next().unwrap_or("");
let safe = !rel.is_empty()
&& rel
.split('/')
.all(|c| !c.is_empty() && c != ".." && c != "." && !c.contains('\\'));
let ctype = match rel.rsplit('.').next() {
Some("js") => "application/javascript",
Some("wasm") => "application/wasm",
Some("css") => "text/css",
_ => "application/octet-stream",
};
match safe
.then(|| std::fs::read(dir.join(rel)))
.and_then(Result::ok)
{
Some(bytes) => {
let h = Header::from_bytes("Content-Type", ctype).unwrap();
let _ = req.respond(Response::from_data(bytes).with_header(h));
}
None => {
let _ = req.respond(
Response::from_string("not found (build the UI: see ui/build.sh)")
.with_status_code(404),
);
}
}
}
enum Msg {
Edit(String),
@ -64,6 +100,9 @@ fn main() {
}
let server = Server::http(&addr).expect("failed to bind");
let ui_dir = Arc::new(std::path::PathBuf::from(
std::env::var("SIM_UI_DIR").unwrap_or_else(|_| "ui/pkg".into()),
));
println!("Simulation running at http://{addr}");
for mut req in server.incoming_requests() {
let (terrain, state, script) = (
@ -72,8 +111,14 @@ fn main() {
Arc::clone(&script),
);
let tx = tx.clone();
let ui_dir = Arc::clone(&ui_dir);
thread::spawn(move || {
let post = *req.method() == Method::Post;
if !post {
if let Some(rel) = req.url().strip_prefix("/pkg/").map(str::to_owned) {
return serve_static(req, &ui_dir, &rel);
}
}
let mut input = String::new();
if post {
let _ = req.as_reader().take(65536).read_to_string(&mut input);

18
ui/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "ui"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
leptos = { version = "0.8", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] }
gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] }
gloo-timers = { version = "0.3", features = ["futures"] }
serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1"

5
ui/build.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/sh
# Builds the Leptos UI into ui/pkg, which the sim server serves at /pkg/.
set -e
cd "$(dirname "$0")"
wasm-pack build --target web --out-dir pkg --no-typescript "$@"

85
ui/js/editor.js Normal file
View File

@ -0,0 +1,85 @@
// Thin glue around Monaco (+ monaco-vim), both loaded lazily from a CDN.
const MONACO = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min';
const VIM = 'https://cdn.jsdelivr.net/npm/monaco-vim@0.4.2/dist/monaco-vim';
let monacoReady = null;
let editor = null;
let vimMode = null;
let vimLib = null;
let statusEl = null;
let applyCb = null;
function loadMonaco() {
monacoReady ??= new Promise((resolve, reject) => {
window.MonacoEnvironment = {
getWorkerUrl: () => URL.createObjectURL(new Blob(
[`self.MonacoEnvironment={baseUrl:'${MONACO}/'};importScripts('${MONACO}/vs/base/worker/workerMain.js');`],
{ type: 'text/javascript' })),
};
const s = document.createElement('script');
s.src = MONACO + '/vs/loader.js';
s.onload = () => {
require.config({ paths: { vs: MONACO + '/vs' } });
require(['vs/editor/editor.main'], () => resolve(window.monaco), reject);
};
s.onerror = () => { monacoReady = null; reject(new Error('could not load the editor from the CDN')); };
document.head.appendChild(s);
});
return monacoReady;
}
export async function editor_mount(container, status, src, vim, onApply) {
await loadMonaco();
if (editor) return;
statusEl = status;
applyCb = onApply;
editor = monaco.editor.create(container, {
value: src, language: 'rust', theme: 'vs-dark',
automaticLayout: true, minimap: { enabled: false }, fontSize: 13, tabSize: 4,
});
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => applyCb());
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => applyCb());
if (vim) await editor_set_vim(true);
}
export function editor_focus() { editor?.focus(); }
export function editor_value() { return editor ? editor.getValue() : ''; }
export async function editor_set_vim(on) {
if (!editor) return;
if (!on) {
vimMode?.dispose();
vimMode = null;
statusEl.textContent = '';
return;
}
vimLib ??= await new Promise((resolve, reject) => {
require.config({ paths: { 'monaco-vim': VIM } });
require(['monaco-vim'], resolve, reject);
});
if (vimMode) return;
vimMode = vimLib.initVimMode(editor, statusEl);
vimLib.VimMode.Vim.defineEx('write', 'w', () => applyCb());
editor.focus();
}
// Turns Rune diagnostics ("behavior:LINE:COL") into editor markers; empty text clears them.
export function editor_show_errors(text) {
if (!editor) return;
const model = editor.getModel();
const markers = [];
for (const block of text.split(/\n(?=error:|warning:)/)) {
const m = /behavior:(\d+):(\d+)/.exec(block);
if (!m) continue;
const line = +m[1], col = +m[2];
const word = model.getWordAtPosition({ lineNumber: line, column: col });
markers.push({
severity: block.startsWith('warning') ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error,
message: block.split('\n')[0].replace(/^(error|warning): /, ''),
startLineNumber: line, startColumn: col,
endLineNumber: line, endColumn: word ? word.endColumn : col + 1,
});
}
monaco.editor.setModelMarkers(model, 'rune', markers);
}

39
ui/src/api.rs Normal file
View File

@ -0,0 +1,39 @@
use gloo_net::http::Request;
use leptos::task::spawn_local;
use serde::de::DeserializeOwned;
pub async fn get_json<T: DeserializeOwned>(url: &str) -> Option<T> {
Request::get(url).send().await.ok()?.json().await.ok()
}
pub async fn get_text(url: &str) -> Option<String> {
Request::get(url).send().await.ok()?.text().await.ok()
}
/// Returns (success, response body).
pub async fn post(url: &str, body: String) -> Option<(bool, String)> {
let r = Request::post(url).body(body).ok()?.send().await.ok()?;
Some((r.ok(), r.text().await.ok()?))
}
pub fn edit(cmd: String) {
spawn_local(async move {
let _ = post("/api/edit", cmd).await;
});
}
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok().flatten()
}
pub fn vim_pref() -> bool {
storage()
.and_then(|s| s.get_item("vim").ok().flatten())
.is_none_or(|v| v != "0")
}
pub fn set_vim_pref(on: bool) {
if let Some(s) = storage() {
let _ = s.set_item("vim", if on { "1" } else { "0" });
}
}

81
ui/src/canvas.rs Normal file
View File

@ -0,0 +1,81 @@
use crate::model::{Sel, State, T, Terrain};
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
const COLORS: [&str; 7] = [
"#10305e", "#2a62a8", "#e0d08a", "#6aaa4a", "#3f7d3a", "#8a8a6a", "#aaaaaa",
];
pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) {
canvas.set_width((t.w as f64 * T) as u32);
canvas.set_height((t.h as f64 * T) as u32);
let Some(ctx) = canvas
.get_context("2d")
.ok()
.flatten()
.and_then(|c| c.dyn_into::<CanvasRenderingContext2d>().ok())
else {
return;
};
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);
}
}
let dot = |x: usize, y: usize, r: f64, color: &str| {
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,
r,
0.0,
std::f64::consts::TAU,
);
ctx.fill();
};
for r in &s.res {
match r.2 {
1 => dot(r.0, r.1, 3.5, "#1d4a1d"),
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.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.set_fill_style_str("#b05a2a");
ctx.fill_rect(s.home.0 as f64 * T, s.home.1 as f64 * T, T, T);
for a in &s.rabbits {
dot(a.0, a.1, 2.5, "#fff");
}
for a in &s.wolves {
dot(a.0, a.1, 4.0, "#d22");
}
for p in &s.people {
dot(p.1, p.2, 4.0, "#ff0");
}
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);
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,
);
}
}

18
ui/src/editor.rs Normal file
View File

@ -0,0 +1,18 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/editor.js")]
extern "C" {
#[wasm_bindgen(catch)]
pub async fn editor_mount(
container: &web_sys::HtmlElement,
status: &web_sys::HtmlElement,
src: &str,
vim: bool,
on_apply: &JsValue,
) -> Result<(), JsValue>;
pub fn editor_focus();
pub fn editor_value() -> String;
#[wasm_bindgen(catch)]
pub async fn editor_set_vim(on: bool) -> Result<(), JsValue>;
pub fn editor_show_errors(text: &str);
}

427
ui/src/lib.rs Normal file
View File

@ -0,0 +1,427 @@
mod api;
mod canvas;
mod editor;
mod model;
use gloo_timers::future::TimeoutFuture;
use leptos::mount::mount_to_body;
use leptos::prelude::*;
use leptos::task::spawn_local;
use model::{Person, Sel, State, T, Terrain};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
let state = RwSignal::new(None::<State>);
let terrain = RwSignal::new(None::<Terrain>);
let sel = RwSignal::new(None::<Sel>);
let dragging = RwSignal::new(false);
let tool = RwSignal::new("inspect".to_string());
let param = RwSignal::new(String::new());
let amount = RwSignal::new("5".to_string());
// (selection counter, people at the time of selection); the counter forces editor rows to rebuild.
let editing = RwSignal::new((0u64, Vec::<Person>::new()));
spawn_local(async move {
let mut rev = None;
loop {
if let Some(s) = api::get_json::<State>("/api/state").await {
if rev != Some(s.rev) || terrain.with_untracked(|t| t.is_none()) {
if let Some(t) = api::get_json::<Terrain>("/api/terrain").await {
terrain.set(Some(t));
rev = Some(s.rev);
}
}
state.set(Some(s));
}
TimeoutFuture::new(200).await;
}
});
let canvas = NodeRef::<leptos::html::Canvas>::new();
Effect::new(move |_| {
let sel = sel.get();
state.with(|s| {
terrain.with(|t| {
if let (Some(c), Some(s), Some(t)) = (canvas.get(), s.as_ref(), t.as_ref()) {
canvas::draw(&c, t, s, sel);
}
})
});
});
let tile = move |ev: &web_sys::MouseEvent| -> Option<(usize, usize)> {
let c = canvas.get_untracked()?;
// 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),
);
let px = (ev.client_x() as f64 - r.left()) * sx;
let py = (ev.client_y() as f64 - 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)
})
})
};
window_event_listener(leptos::ev::mouseup, move |_| {
if !dragging.get_untracked() {
return;
}
dragging.set(false);
let Some(sel) = sel.get_untracked() else {
return;
};
if let Some(cmd) = model::tool_command(
&tool.get_untracked(),
&param.get_untracked(),
&amount.get_untracked(),
sel,
) {
api::edit(cmd);
}
let (x0, y0, x1, y1) = sel.rect();
let people = state.with_untracked(|s| {
s.as_ref()
.map(|s| {
s.people
.iter()
.filter(|p| p.1 >= x0 && p.1 <= x1 && p.2 >= y0 && p.2 <= y1)
.take(5)
.cloned()
.collect()
})
.unwrap_or_default()
});
editing.update(|e| *e = (e.0 + 1, people));
});
let info = move || {
let sel = sel.get();
state.with(|s| {
terrain.with(|t| match (sel, s.as_ref(), t.as_ref()) {
(Some(sel), Some(s), Some(t)) => model::describe(sel, s, t),
_ => vec!["click a tile or drag to select a region".to_string()],
})
})
};
let paused = move || state.with(|s| s.as_ref().is_some_and(|s| s.paused));
view! {
<style>{include_str!("../style.css")}</style>
<div class="app">
<div class="map">
<canvas
node_ref=canvas
on:mousedown=move |ev| {
if let Some((x, y)) = tile(&ev) {
sel.set(Some(Sel { ax: x, ay: y, bx: x, by: y }));
dragging.set(true);
}
}
on:mousemove=move |ev| {
if dragging.get_untracked() {
if let Some((x, y)) = tile(&ev) {
sel.update(|s| {
if let Some(s) = s {
s.bx = x;
s.by = y;
}
});
}
}
}
></canvas>
</div>
<div class="side">
<h3>"World"</h3>
{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);
view! {
<div>{format!("Day {}, {h:02}:{m:02}", s.day)}</div>
<div>{format!("People: {}", s.people.len())}</div>
<div>{format!("Rabbits: {}", s.rabbits.len())}</div>
<div>{format!("Wolves: {}", s.wolves.len())}</div>
<div>{format!("Houses: {}", s.houses.len())}</div>
<div>{format!(
"Stockpile: food {}, wood {}, stone {}, tools {}",
s.stock.food, s.stock.wood, s.stock.stone, s.stock.tools
)}</div>
}
}))}
<div>
<button on:click=move |_| {
api::edit(format!("pause {}", if paused() { 0 } else { 1 }))
}>{move || if paused() { "Resume" } else { "Pause" }}</button>
" Speed "
<select
prop:value=move || {
state.with(|s| s.as_ref().map(|s| s.speed.to_string()).unwrap_or_default())
}
on:change=move |e| api::edit(format!("speed {}", event_target_value(&e)))
>
<option value="1">"1"</option>
<option value="2">"2"</option>
<option value="4">"4"</option>
<option value="8">"8"</option>
</select>
</div>
<h3>"Tool"</h3>
<div>
<select on:change=move |e| {
let v = event_target_value(&e);
param.set(model::tool_options(&v).first().map(|o| o.0.clone()).unwrap_or_default());
tool.set(v);
}>
<option value="inspect">"Inspect"</option>
<option value="terrain">"Paint terrain"</option>
<option value="res">"Place resource"</option>
<option value="spawn">"Spawn"</option>
<option value="remove">"Remove"</option>
<option value="house">"Add house"</option>
<option value="energy">"Set animal energy"</option>
</select>
<Show when=move || !model::tool_options(&tool.get()).is_empty()>
<select on:change=move |e| param.set(event_target_value(&e))>
{move || {
model::tool_options(&tool.get())
.into_iter()
.map(|(v, n)| view! { <option value=v>{n}</option> })
.collect_view()
}}
</select>
</Show>
<input
type="number"
min="1"
max="100"
title="amount / energy"
prop:value=move || amount.get()
on:input=move |e| amount.set(event_target_value(&e))
/>
</div>
<div class="hint">{move || model::tool_help(&tool.get())}</div>
<h3>"Selected"</h3>
<div class="hover">
{move || info().into_iter().map(|l| view! { <div>{l}</div> }).collect_view()}
</div>
<For
each=move || {
let (seq, people) = editing.get();
people.into_iter().map(|p| (seq, p)).collect::<Vec<_>>()
}
key=|(seq, p)| (*seq, p.0)
children=|(_, p)| view! { <PersonEditor p=p /> }
/>
<h3>"Stockpile"</h3>
<StockEditor state=state />
<h3>"Behaviour script (Rune)"</h3>
<ScriptEditor />
<h3>"Events"</h3>
<div class="log">
{move || state.with(|s| s.as_ref().map(|s| {
s.log.iter().map(|l| view! { <div>{l.clone()}</div> }).collect_view()
}))}
</div>
<h3>"Legend"</h3>
<div>
"yellow: person, white: rabbit, red: wolf" <br />
"green dot: tree, pink: berries, grey: stone" <br />
"brown square: house"
</div>
</div>
</div>
}
}
#[component]
fn PersonEditor(p: Person) -> impl IntoView {
let id = p.0;
let fields = [
("hunger", p.4 as u32),
("food", p.5),
("wood", p.6),
("stone", p.7),
]
.map(|(k, v)| (k, RwSignal::new(v.to_string())));
view! {
<div>
{format!("Edit #{id}: ")}
{fields
.into_iter()
.map(|(k, s)| {
view! {
<span>
{k} " "
<input
type="number"
min="0"
max="99"
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))
/>
" "
</span>
}
})
.collect_view()}
<button on:click=move |_| {
for (k, s) in fields {
let v = s.get_untracked().parse::<i64>().unwrap_or(0);
api::edit(format!("person {id} {k} {v}"));
}
}>"Set"</button>
</div>
}
}
#[component]
fn StockEditor(state: RwSignal<Option<State>>) -> impl IntoView {
let names = ["food", "wood", "stone", "tools"];
let vals = names.map(|_| RwSignal::new("0".to_string()));
let loaded = StoredValue::new(false);
// Prefill once; later polls must not overwrite what the user is typing.
Effect::new(move |_| {
if loaded.get_value() {
return;
}
if let Some(s) = state.get() {
let v = [s.stock.food, s.stock.wood, s.stock.stone, s.stock.tools];
for (sig, n) in vals.iter().zip(v) {
sig.set(n.to_string());
}
loaded.set_value(true);
}
});
view! {
<div>
{names
.into_iter()
.zip(vals)
.map(|(k, s)| {
view! {
<span>
{k} " "
<input
type="number"
min="0"
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))
/>
" "
</span>
}
})
.collect_view()}
<button on:click=move |_| {
for (k, s) in names.into_iter().zip(vals) {
let v = s.get_untracked().parse::<i64>().unwrap_or(0);
api::edit(format!("stock {k} {v}"));
}
}>"Set"</button>
</div>
}
}
#[component]
fn ScriptEditor() -> impl IntoView {
let open = RwSignal::new(false);
let vim = RwSignal::new(api::vim_pref());
let status = RwSignal::new(String::new());
let errors = RwSignal::new(String::new());
let mounted = StoredValue::new(false);
let host = NodeRef::<leptos::html::Div>::new();
let vim_el = NodeRef::<leptos::html::Div>::new();
let apply = move || {
spawn_local(async move {
match api::post("/api/script", editor::editor_value()).await {
Some((true, _)) => {
editor::editor_show_errors("");
errors.set(String::new());
status.set("Applied.".into());
}
Some((false, text)) => {
status.set("Rejected; the previous script is still running.".into());
editor::editor_show_errors(&text);
errors.set(text);
}
None => status.set("Request failed.".into()),
}
});
};
let on_open = move |_| {
open.set(true);
spawn_local(async move {
if mounted.get_value() {
editor::editor_focus();
return;
}
let (Some(h), Some(v)) = (host.get_untracked(), vim_el.get_untracked()) else {
return;
};
let Some(src) = api::get_text("/api/script").await else {
errors.set("Could not fetch the script.".into());
return;
};
let cb = Closure::<dyn Fn()>::new(move || apply()).into_js_value();
match editor::editor_mount(&h, &v, &src, vim.get_untracked(), &cb).await {
Ok(()) => {
mounted.set_value(true);
editor::editor_focus();
}
Err(e) => errors.set(format!("Editor failed to load: {e:?}")),
}
});
};
view! {
<div>
<button on:click=on_open>"Open script editor"</button>
</div>
<div class="editor-panel" style:display=move || if open.get() { "flex" } else { "none" }>
<div class="editor-bar">
<b>"behavior.rn"</b>
<button on:click=move |_| apply()>"Apply (Ctrl+Enter)"</button>
<button on:click=move |_| open.set(false)>"Close"</button>
<label>
<input
type="checkbox"
prop:checked=move || vim.get()
on:change=move |e| {
let on = event_target_checked(&e);
vim.set(on);
api::set_vim_pref(on);
spawn_local(async move {
let _ = editor::editor_set_vim(on).await;
});
}
/>
" Vim"
</label>
<span>{move || status.get()}</span>
</div>
<div class="editor-host" node_ref=host></div>
<div class="vim-status" node_ref=vim_el></div>
<div class="script-err">{move || errors.get()}</div>
</div>
}
}

221
ui/src/model.rs Normal file
View File

@ -0,0 +1,221 @@
use serde::Deserialize;
pub const T: f64 = 10.0;
pub const TERRAIN: [&str; 7] = [
"Deep water",
"Shallow water",
"Sand",
"Grass",
"Forest",
"Hills",
"Mountain",
];
pub const RES_NAMES: [&str; 4] = ["", "Tree", "Berry bush", "Stone deposit"];
#[derive(Deserialize, Clone, Default)]
pub struct Stock {
pub food: u32,
pub wood: u32,
pub stone: u32,
pub tools: u32,
}
/// id, x, y, action, hunger, food, wood, stone
#[derive(Deserialize, Clone)]
pub struct Person(
pub u32,
pub usize,
pub usize,
pub String,
pub f32,
pub u32,
pub u32,
pub u32,
);
/// x, y, energy
#[derive(Deserialize, Clone)]
pub struct Animal(pub usize, pub usize, pub f32);
/// x, y, kind, amount
#[derive(Deserialize, Clone)]
pub struct Res(pub usize, pub usize, pub u8, pub u16);
#[derive(Deserialize, Clone)]
pub struct State {
pub paused: bool,
pub speed: u32,
pub rev: u32,
pub day: u64,
pub hour: f32,
pub daylight: f32,
pub stock: Stock,
pub home: (usize, usize),
pub houses: Vec<(usize, usize)>,
pub people: Vec<Person>,
pub rabbits: Vec<Animal>,
pub wolves: Vec<Animal>,
pub res: Vec<Res>,
pub log: Vec<String>,
}
#[derive(Deserialize, Clone)]
pub struct Terrain {
pub w: usize,
pub h: usize,
pub tiles: String,
}
impl Terrain {
pub fn kind(&self, x: usize, y: usize) -> usize {
self.tiles
.as_bytes()
.get(y * self.w + x)
.map_or(0, |b| (b - b'0') as usize)
.min(6)
}
}
#[derive(Clone, Copy)]
pub struct Sel {
pub ax: usize,
pub ay: usize,
pub bx: usize,
pub by: usize,
}
impl Sel {
/// Normalised (x0, y0, x1, y1), inclusive.
pub fn rect(&self) -> (usize, usize, usize, usize) {
(
self.ax.min(self.bx),
self.ay.min(self.by),
self.ax.max(self.bx),
self.ay.max(self.by),
)
}
}
pub fn tool_options(tool: &str) -> Vec<(String, &'static str)> {
match tool {
"terrain" => TERRAIN
.iter()
.enumerate()
.map(|(i, n)| (i.to_string(), *n))
.collect(),
"res" => vec![
("1".into(), "Tree"),
("2".into(), "Berry bush"),
("3".into(), "Stone"),
("0".into(), "Clear"),
],
"spawn" => vec![
("person".into(), "Person"),
("rabbit".into(), "Rabbit"),
("wolf".into(), "Wolf"),
],
"remove" => vec![
("all".into(), "Everything"),
("people".into(), "People"),
("rabbits".into(), "Rabbits"),
("wolves".into(), "Wolves"),
("houses".into(), "Houses"),
("res".into(), "Resources"),
],
_ => vec![],
}
}
pub fn tool_help(tool: &str) -> &'static str {
match tool {
"terrain" => "Drag a region to paint it.",
"res" => "Drag a region; amount sets units per tile.",
"spawn" => "Drag a region; one per walkable tile (max 25).",
"remove" => "Drag a region to remove things in it.",
"house" => "Drag a region to add houses.",
"energy" => "Drag a region; amount sets animal energy (1-100).",
_ => "Click or drag to inspect and edit.",
}
}
pub fn tool_command(tool: &str, param: &str, amount: &str, sel: Sel) -> Option<String> {
let (x0, y0, x1, y1) = sel.rect();
let r = format!("{x0} {y0} {x1} {y1}");
let a = amount.parse::<i64>().unwrap_or(1);
match tool {
"terrain" => Some(format!("terrain {r} {param}")),
"res" => Some(format!("res {r} {param} {a}")),
"spawn" => Some(format!("spawn {param} {r}")),
"remove" => Some(format!("remove {r} {param}")),
"house" => Some(format!("house {r}")),
"energy" => Some(format!("energy {r} {a}")),
_ => None,
}
}
pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec<String> {
let (x0, y0, x1, y1) = sel.rect();
let inside = |x: usize, y: usize| x >= x0 && x <= x1 && y >= y0 && y <= y1;
let people: Vec<&Person> = s.people.iter().filter(|p| inside(p.1, p.2)).collect();
let rabbits: Vec<&Animal> = s.rabbits.iter().filter(|a| inside(a.0, a.1)).collect();
let wolves: Vec<&Animal> = s.wolves.iter().filter(|a| inside(a.0, a.1)).collect();
let res: Vec<&Res> = s.res.iter().filter(|r| inside(r.0, r.1)).collect();
let houses = s.houses.iter().filter(|h| inside(h.0, h.1)).count();
let mut counts = [0usize; 7];
for y in y0..=y1 {
for x in x0..=x1 {
counts[t.kind(x, y)] += 1;
}
}
let mut out = vec![];
if (x0, y0) == (x1, y1) {
out.push(format!("Tile ({x0}, {y0})"));
out.push(format!("Terrain: {}", TERRAIN[t.kind(x0, y0)]));
if s.home == (x0, y0) {
out.push("Village center (stockpile)".into());
}
if houses > 0 {
out.push("House".into());
}
for r in &res {
out.push(format!("{}: {} left", RES_NAMES[r.2 as usize], r.3));
}
} else {
out.push(format!("Region ({x0},{y0}) to ({x1},{y1})"));
let terrain: Vec<String> = counts
.iter()
.enumerate()
.filter(|(_, n)| **n > 0)
.map(|(i, n)| format!("{} {n}", TERRAIN[i]))
.collect();
out.push(format!("Terrain: {}", terrain.join(", ")));
let kinds: Vec<String> = (1..=3u8)
.map(|k| {
format!(
"{} {}",
RES_NAMES[k as usize],
res.iter().filter(|r| r.2 == k).count()
)
})
.collect();
out.push(format!("Resources: {}", kinds.join(", ")));
out.push(format!("Houses: {houses}"));
}
out.push(format!(
"People: {}, rabbits: {}, wolves: {}",
people.len(),
rabbits.len(),
wolves.len()
));
for p in &people {
out.push(format!(
"- Person #{}: {}, hunger {}, carrying food {} wood {} stone {}",
p.0, p.3, p.4, p.5, p.6, p.7
));
}
if rabbits.len() + wolves.len() <= 6 {
out.extend(rabbits.iter().map(|a| format!("- Rabbit: energy {}", a.2)));
out.extend(wolves.iter().map(|a| format!("- Wolf: energy {}", a.2)));
}
out
}

102
ui/style.css Normal file
View File

@ -0,0 +1,102 @@
body {
margin: 0;
background: #111;
color: #ddd;
font: 13px monospace;
}
.app {
display: flex;
}
.map {
flex: 1;
min-width: 0;
overflow: auto;
max-height: 100vh;
}
canvas {
display: block;
}
@media (max-width: 900px) {
.app {
flex-direction: column;
}
.map {
max-height: none;
}
.side {
width: 100%;
height: auto;
}
}
.side {
flex: none;
width: 340px;
height: 100vh;
overflow-y: auto;
padding: 10px;
box-sizing: border-box;
}
.side input[type=number] {
width: 56px;
}
.log div {
margin: 2px 0;
color: #aaa;
}
.hover {
color: #fc6;
min-height: 1.2em;
}
.hint {
color: #888;
}
.editor-panel {
display: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(760px, 70vw);
flex-direction: column;
background: #1e1e1e;
border-left: 1px solid #444;
z-index: 10;
}
.editor-bar {
padding: 6px 10px;
display: flex;
gap: 8px;
align-items: center;
}
.editor-host {
flex: 1;
min-height: 0;
}
.vim-status {
padding: 2px 10px;
font: 12px monospace;
min-height: 1.4em;
}
.script-err {
padding: 6px 10px;
max-height: 25vh;
overflow: auto;
color: #f77;
white-space: pre-wrap;
}