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"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["ui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rune = "0.14.2"
|
rune = "0.14.2"
|
||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
|
|
|
||||||
49
src/main.rs
49
src/main.rs
|
|
@ -1,14 +1,50 @@
|
||||||
mod world;
|
mod world;
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::mpsc::{self, Sender};
|
use std::sync::mpsc::{self, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tiny_http::{Header, Method, Response, Server};
|
use tiny_http::{Header, Method, Request, Response, Server};
|
||||||
use world::{DEFAULT_SCRIPT, World};
|
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 {
|
enum Msg {
|
||||||
Edit(String),
|
Edit(String),
|
||||||
|
|
@ -64,6 +100,9 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = Server::http(&addr).expect("failed to bind");
|
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}");
|
println!("Simulation running at http://{addr}");
|
||||||
for mut req in server.incoming_requests() {
|
for mut req in server.incoming_requests() {
|
||||||
let (terrain, state, script) = (
|
let (terrain, state, script) = (
|
||||||
|
|
@ -72,8 +111,14 @@ fn main() {
|
||||||
Arc::clone(&script),
|
Arc::clone(&script),
|
||||||
);
|
);
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
let ui_dir = Arc::clone(&ui_dir);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let post = *req.method() == Method::Post;
|
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();
|
let mut input = String::new();
|
||||||
if post {
|
if post {
|
||||||
let _ = req.as_reader().take(65536).read_to_string(&mut input);
|
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