feat: build the frontend with Leptos and WebAssembly
This commit is contained in:
parent
1e7a5f911a
commit
e4a4cb0190
File diff suppressed because it is too large
Load Diff
|
|
@ -3,6 +3,9 @@ name = "sim"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
members = ["ui"]
|
||||
|
||||
[dependencies]
|
||||
rune = "0.14.2"
|
||||
tiny_http = "0.12"
|
||||
|
|
|
|||
49
src/main.rs
49
src/main.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
¶m.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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue