v0.4 — in-engine room editor, four screens deep

F8 toggles edit mode. Tools: brush/line/rect/ellipse/flood/gradient/picker; ink meanings write LOOK/FLOOR/WALL/WATER/DEPTH across the four screens; V overlays priority/control/hotspot; hotspot rects, prop placement, spawn, and a :colon command line (save/name/enter/music/weather/exit/room/hotmsg). Every editor action is a bus Command (paint_op, undo_op, clear_ops, set_spawn/exit/music/weather/room_name/enter_text, upsert/delete hotspot+prop) so MCP clients wield identical powers. PicOp::Brush added; docs/EDITOR.md. Verified: 5 suites green, playthrough events byte-identical to pre-editor build, GUI boots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 00:38:35 +10:00
parent 0191b4680d
commit 662e6c4bc4
7 changed files with 826 additions and 21 deletions

View File

@ -127,6 +127,8 @@ real game logic. The AI can suggest; only the sim decides. Configure with
in-core font + annotated frames, GPU palette/CRT shader
- [x] v0.3 — world bundles carry art (`--export-bundle`/`--bundle`, the
wasm path), AI parser lane, dialogue talker portraits
- [x] v0.4 — in-engine room editor: F8, four-screen paint meanings,
overlay views, hotspot/prop placement, `:` commands — all over the
bus, so MCP clients wield identical powers ([docs/EDITOR.md](docs/EDITOR.md))
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
- [ ] In-engine room editor (MRPGI's, upgraded to four screens)
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)

View File

@ -59,6 +59,19 @@ Lines starting with `#` are comments (script files too).
{"cmd":"set_seed","seed":41}
{"cmd":"save_game","slot":"street"}
{"cmd":"restore_game","slot":"street"}
{"cmd":"paint_op","op":{"Rect":{"x":10,"y":120,"w":30,"h":20,"ink":{"color":42,"pri":"Band","ctl":{"Set":0},"hot":null}}}}
{"cmd":"undo_op"}
{"cmd":"clear_ops"}
{"cmd":"set_spawn","x":60,"y":150}
{"cmd":"set_exit","dir":1,"target":2}
{"cmd":"set_music","mood":6}
{"cmd":"set_weather","kind":1}
{"cmd":"set_room_name","name":"Neon Row"}
{"cmd":"set_enter_text","text":"Rain again."}
{"cmd":"upsert_hotspot","hotspot":{"name":"door","rect":[206,104,34,12],"exit_to":1}}
{"cmd":"delete_hotspot","name":"door"}
{"cmd":"upsert_prop","prop":{"name":"crate-2","sprite":"crate","x":100,"y":140,"solid":true}}
{"cmd":"delete_prop","name":"crate-2"}
{"cmd":"upsert_room","n":7,"doc":{"spawn":[160,170]},"persist":true}
{"cmd":"upsert_script","file":"room7.rhai","source":"fn on_enter() { say(\"hi\"); }"}
{"cmd":"save_room"}

49
docs/EDITOR.md Normal file
View File

@ -0,0 +1,49 @@
# The in-engine room editor
Press **F8** in the game to flip into edit mode (the sim freezes; F8 again
to play-test instantly). Everything the editor does is a bus `Command`
an LLM on the MCP surface has exactly the same powers with the same JSON.
## Tools & keys
| Key | Meaning |
|---|---|
| `B` `L` `R` `O` `F` `G` `K` | brush · line · rect · ellipse · flood fill · gradient · color picker |
| `M` | cycle what the ink writes: LOOK → FLOOR → WALL → WATER → DEPTH → HOTSPOT |
| `V` | overlay the invisible screens: priority / control / hotspot |
| `[` `]` | depth band 015 (DEPTH mode) |
| `-` `=` | brush size |
| `U` | undo last paint op |
| `S` | set the room spawn at the cursor |
| `P` | place a prop of the selected sprite (`,` `.` cycle sprites) |
| `X` | delete the prop under the cursor |
| `Enter` | open the `:` command line |
| palette panel | left-click = primary color, right-click = secondary (gradients) |
Meanings write multiple screens at once, MRPGI-style but with four screens:
**FLOOR** paints color + default depth band + open control; **WALL** paints
color + blocked control; **WATER** color + water; **DEPTH** forces a band
(walk-behind scenery); **HOTSPOT** + the rect tool creates a named clickable
region (no pixels painted — it lives on the hotspot screen).
## Colon commands
```
:save write rooms/roomN.json
:name Neon Row room display name
:enter TEXT entry prose
:music N 0 off · 1 calm · 2 eerie · 3 tense · 4 jolly · 5 spooky · 6 noir
:weather N 0 none · 1 rain · 2 snow · 3 embers
:exit e 2 edge exit (n/e/s/w) to room 2; `:exit e -` clears
:room 7 jump rooms (a missing room is a fresh blank one)
:clear drop all paint ops
:hotspot NAME rename the most recently drawn hotspot
:hotmsg look TEXT give the newest hotspot a verb response
:delhot NAME delete a hotspot
:help the cheat sheet
```
Dialogue trees, prop lock-and-key fields, scale tables and palettes are
data — edit the room JSON (or drive `upsert_room` over the bus) for those.
The editor covers the 90%: geometry, paint, walkability, depth, hotspots,
props, exits, mood.

View File

@ -70,6 +70,17 @@ impl Ink {
}
}
/// Paint a filled disc — the editor's brush footprint.
pub fn disc(s: &mut Screens, cx: i32, cy: i32, size: i32, ink: Ink) {
for dy in -size..=size {
for dx in -size..=size {
if dx * dx + dy * dy <= size * size {
px(s, cx + dx, cy + dy, ink);
}
}
}
}
/// Paint one pixel through an ink.
#[inline]
pub fn px(s: &mut Screens, x: i32, y: i32, ink: Ink) {
@ -409,6 +420,8 @@ pub fn scatter(s: &mut Screens, x: i32, y: i32, w: i32, h: i32, colors: &[u8], d
#[derive(Clone, Serialize, Deserialize)]
pub enum PicOp {
Px { x: i32, y: i32, ink: Ink },
/// A freehand stroke: discs of `size` stamped along the point trail.
Brush { pts: Vec<(i32, i32)>, size: i32, ink: Ink },
Line { pts: Vec<(i32, i32)>, ink: Ink },
Rect { x: i32, y: i32, w: i32, h: i32, ink: Ink },
Ellipse { cx: i32, cy: i32, rx: i32, ry: i32, ink: Ink },
@ -438,6 +451,11 @@ pub enum PicOp {
pub fn apply_op(s: &mut Screens, op: &PicOp) {
match op {
PicOp::Px { x, y, ink } => px(s, *x, *y, *ink),
PicOp::Brush { pts, size, ink } => {
for &(x, y) in pts {
disc(s, x, y, *size, *ink);
}
}
PicOp::Line { pts, ink } => {
for w in pts.windows(2) {
line(s, w[0], w[1], *ink);

View File

@ -75,7 +75,26 @@ pub enum Command {
/// Save/restore anywhere — a named slot under `saves/`.
SaveGame { slot: String },
RestoreGame { slot: String },
// --- authoring (the same bus an editor would use) ----------------------
// --- authoring (the editor IS a bus client; so is a lore-writing LLM) --
/// Paint one PicOp into the current room (applied live + recorded).
PaintOp { op: crate::pic::PicOp },
/// Pop the last paint op and rebake.
UndoOp,
/// Drop every paint op (keeps background/hotspots/props).
ClearOps,
SetSpawn { x: i32, y: i32 },
/// dir: 0 N, 1 E, 2 S, 3 W. None clears the exit.
SetExit { dir: u8, target: Option<u32> },
SetMusic { mood: u8 },
SetWeather { kind: u8 },
SetRoomName { name: String },
SetEnterText { text: String },
/// Insert/replace a hotspot (matched by name) in the current room.
UpsertHotspot { hotspot: crate::room::Hotspot },
DeleteHotspot { name: String },
/// Insert/replace a prop (matched by name) in `room` (None = current).
UpsertProp { room: Option<u32>, prop: crate::room::PropDef },
DeleteProp { room: Option<u32>, name: String },
/// Drop a whole room in as JSON — the "lore ingestion" command.
UpsertRoom { n: u32, doc: RoomDoc, #[serde(default)] persist: bool },
/// Drop a script in (file name → source), compiled on next room entry.
@ -107,6 +126,8 @@ pub enum Event {
State { state: StateSnapshot },
SavedGame { slot: String },
Restored { slot: String },
/// The room's authored content changed (paint, hotspots, props, meta).
Edited { room: u32, ops: usize },
RoomUpserted { room: u32, persisted: bool },
RoomSaved { room: u32, path: String },
GameLoaded { name: String, room: u32 },
@ -382,6 +403,105 @@ impl GameState {
None => ev.push(Event::Error { message: format!("no save named '{}'", slot) }),
}
}
Command::PaintOp { op } => {
crate::pic::apply_op(&mut self.world.screens, &op);
self.world.doc.ops.push(op);
ev.push(self.edited());
}
Command::UndoOp => {
if self.world.doc.ops.pop().is_some() {
self.rebake();
}
ev.push(self.edited());
}
Command::ClearOps => {
self.world.doc.ops.clear();
self.rebake();
ev.push(self.edited());
}
Command::SetSpawn { x, y } => {
self.world.doc.spawn = (x, y);
ev.push(self.edited());
}
Command::SetExit { dir, target } => {
if dir < 4 {
self.world.doc.exits[dir as usize] = target;
ev.push(self.edited());
} else {
ev.push(Event::Error { message: "exit dir must be 0..=3 (N,E,S,W)".into() });
}
}
Command::SetMusic { mood } => {
self.world.doc.music = mood;
ev.push(Event::Music { mood });
ev.push(self.edited());
}
Command::SetWeather { kind } => {
self.world.doc.weather = kind;
self.weather = Weather::new(kind, self.world.current as u64 + 1);
ev.push(self.edited());
}
Command::SetRoomName { name } => {
self.world.doc.name = name;
ev.push(self.edited());
}
Command::SetEnterText { text } => {
self.world.doc.enter_text = text;
ev.push(self.edited());
}
Command::UpsertHotspot { hotspot } => {
let hs = &mut self.world.doc.hotspots;
if let Some(existing) = hs.iter_mut().find(|h| h.name == hotspot.name) {
*existing = hotspot;
} else if hs.len() >= 255 {
ev.push(Event::Error { message: "a room holds at most 255 hotspots".into() });
return ev;
} else {
hs.push(hotspot);
}
self.rebake();
ev.push(self.edited());
}
Command::DeleteHotspot { name } => {
self.world.doc.hotspots.retain(|h| h.name != name);
self.rebake();
ev.push(self.edited());
}
Command::UpsertProp { room, prop } => {
let n = room.unwrap_or(self.world.current);
if n == self.world.current {
let ps = &mut self.world.doc.props;
if let Some(existing) = ps.iter_mut().find(|p| p.name == prop.name) {
*existing = prop;
} else {
ps.push(prop);
}
let alive = self.alive_prop_names();
self.world.stamp_solid_props(&alive);
} else {
let mut doc = self.world.peek_room(n).unwrap_or_default();
let ps = &mut doc.props;
if let Some(existing) = ps.iter_mut().find(|p| p.name == prop.name) {
*existing = prop;
} else {
ps.push(prop);
}
self.world.overlay.insert(n, doc);
}
ev.push(Event::Edited { room: n, ops: 0 });
}
Command::DeleteProp { room, name } => {
let n = room.unwrap_or(self.world.current);
if n == self.world.current {
self.world.doc.props.retain(|p| p.name != name);
let alive = self.alive_prop_names();
self.world.stamp_solid_props(&alive);
} else if let Some(mut doc) = self.world.peek_room(n) {
doc.props.retain(|p| p.name != name);
self.world.overlay.insert(n, doc);
}
ev.push(Event::Edited { room: n, ops: 0 });
}
Command::UpsertRoom { n, doc, persist } => match self.world.validate_room(n, &doc) {
Ok(()) => {
if let Err(e) = self.world.upsert_room(n, doc, persist) {
@ -454,6 +574,18 @@ impl GameState {
Event::Transcript { line: s.to_string() }
}
fn edited(&mut self) -> Event {
Event::Edited { room: self.world.current, ops: self.world.doc.ops.len() }
}
/// Full rebake + solid-prop footprints — after any authoring change
/// that can't be applied incrementally.
fn rebake(&mut self) {
self.world.bake();
let alive = self.alive_prop_names();
self.world.stamp_solid_props(&alive);
}
fn new_game(&mut self, room: Option<u32>) -> Vec<Event> {
let target = room.unwrap_or(self.world.manifest.start_room);
self.inventory.clear();

569
mrpci/src/editor.rs Normal file
View File

@ -0,0 +1,569 @@
//! The in-engine room editor — MRPGI's paint tool, grown up for four
//! screens. Every mutation is a bus [`Command`], so the editor has no
//! private powers: anything it can do, an LLM on the MCP surface can do
//! with the same commands, and undo/save work identically for both.
//!
//! F8 toggles edit mode. Tools: B brush · L line · R rect · O ellipse ·
//! F fill · G gradient · K color picker. M cycles what the ink writes
//! (LOOK / FLOOR / WALL / WATER / DEPTH / HOTSPOT), V cycles the invisible
//! screen overlays, [ ] set the depth band, - + brush size, U undo,
//! S spawn-at-cursor, P place prop (,/. picks the sprite), X delete prop
//! under cursor, Enter opens the `:` command line (`:help`).
use macroquad::prelude::*;
use mrpci_core::pic::{CtlInk, Ink, PicOp, PriInk};
use mrpci_core::room::{Hotspot, PropDef};
use mrpci_core::screens::{CTL_BLOCK, CTL_WATER, PIC_H, PIC_W};
use mrpci_core::{Command, Event, GameState};
use crate::{BAR_H, SCALE, WIN_W};
#[derive(Clone, Copy, PartialEq)]
pub enum Tool {
Brush,
Line,
Rect,
Ellipse,
Fill,
Gradient,
Pick,
}
impl Tool {
fn label(self) -> &'static str {
match self {
Tool::Brush => "brush",
Tool::Line => "line",
Tool::Rect => "rect",
Tool::Ellipse => "ellipse",
Tool::Fill => "fill",
Tool::Gradient => "gradient",
Tool::Pick => "pick",
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Meaning {
Look,
Floor,
Wall,
Water,
Depth,
Hotspot,
}
impl Meaning {
fn label(self) -> &'static str {
match self {
Meaning::Look => "LOOK",
Meaning::Floor => "FLOOR",
Meaning::Wall => "WALL",
Meaning::Water => "WATER",
Meaning::Depth => "DEPTH",
Meaning::Hotspot => "HOTSPOT",
}
}
fn next(self) -> Self {
match self {
Meaning::Look => Meaning::Floor,
Meaning::Floor => Meaning::Wall,
Meaning::Wall => Meaning::Water,
Meaning::Water => Meaning::Depth,
Meaning::Depth => Meaning::Hotspot,
Meaning::Hotspot => Meaning::Look,
}
}
}
pub struct Editor {
pub active: bool,
tool: Tool,
meaning: Meaning,
color: u8,
color2: u8,
size: i32,
band: u8,
/// 0 = none, 1 = priority, 2 = control, 3 = hotspot overlay.
view: u8,
drag: Option<(i32, i32)>,
trail: Vec<(i32, i32)>,
sprite_i: usize,
hot_count: u32,
last_hotspot: String,
colon: Option<String>,
status: String,
overlay_img: Option<(Image, Texture2D)>,
}
impl Editor {
pub fn new() -> Self {
Editor {
active: false,
tool: Tool::Brush,
meaning: Meaning::Look,
color: 7,
color2: 0,
size: 2,
band: 8,
view: 0,
drag: None,
trail: Vec::new(),
sprite_i: 0,
hot_count: 0,
last_hotspot: String::new(),
colon: None,
status: "F8 leaves edit mode · :help for commands".into(),
overlay_img: None,
}
}
fn ink(&self) -> Ink {
match self.meaning {
Meaning::Look => Ink { color: Some(self.color), pri: PriInk::Keep, ctl: CtlInk::Keep, hot: None },
Meaning::Floor => Ink { color: Some(self.color), pri: PriInk::Band, ctl: CtlInk::Set(0), hot: None },
Meaning::Wall => Ink { color: Some(self.color), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_BLOCK), hot: None },
Meaning::Water => Ink { color: Some(self.color), pri: PriInk::Keep, ctl: CtlInk::Set(CTL_WATER), hot: None },
Meaning::Depth => Ink { color: Some(self.color), pri: PriInk::Set(self.band), ctl: CtlInk::Keep, hot: None },
Meaning::Hotspot => Ink { color: None, pri: PriInk::Keep, ctl: CtlInk::Keep, hot: None },
}
}
fn pic_pos() -> Option<(i32, i32)> {
let (mx, my) = mouse_position();
let px = (mx / SCALE) as i32;
let py = ((my - BAR_H) / SCALE) as i32;
if px >= 0 && px < PIC_W as i32 && py >= 0 && py < PIC_H as i32 {
Some((px, py))
} else {
None
}
}
/// Handle one frame of editor input. Returns bus events for the caller.
pub fn update(&mut self, gs: &mut GameState) -> Vec<Event> {
let mut ev = Vec::new();
// --- the colon command line ----------------------------------------
if let Some(line) = &mut self.colon {
while let Some(c) = get_char_pressed() {
if !c.is_control() {
line.push(c);
}
}
if is_key_pressed(KeyCode::Backspace) {
line.pop();
}
if is_key_pressed(KeyCode::Escape) {
self.colon = None;
} else if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
let cmd = self.colon.take().unwrap_or_default();
ev.extend(self.run_colon(gs, &cmd));
}
return ev;
}
// --- keys ------------------------------------------------------------
if is_key_pressed(KeyCode::B) {
self.tool = Tool::Brush;
}
if is_key_pressed(KeyCode::L) {
self.tool = Tool::Line;
}
if is_key_pressed(KeyCode::R) {
self.tool = Tool::Rect;
}
if is_key_pressed(KeyCode::O) {
self.tool = Tool::Ellipse;
}
if is_key_pressed(KeyCode::F) {
self.tool = Tool::Fill;
}
if is_key_pressed(KeyCode::G) {
self.tool = Tool::Gradient;
}
if is_key_pressed(KeyCode::K) {
self.tool = Tool::Pick;
}
if is_key_pressed(KeyCode::M) {
self.meaning = self.meaning.next();
}
if is_key_pressed(KeyCode::V) {
self.view = (self.view + 1) % 4;
}
if is_key_pressed(KeyCode::U) {
ev.extend(gs.apply(Command::UndoOp));
self.status = "undo".into();
}
if is_key_pressed(KeyCode::LeftBracket) {
self.band = self.band.saturating_sub(1);
}
if is_key_pressed(KeyCode::RightBracket) {
self.band = (self.band + 1).min(15);
}
if is_key_pressed(KeyCode::Minus) {
self.size = (self.size - 1).max(0);
}
if is_key_pressed(KeyCode::Equal) {
self.size = (self.size + 1).min(24);
}
if is_key_pressed(KeyCode::Comma) && !gs.world.cels.is_empty() {
self.sprite_i = (self.sprite_i + gs.world.cels.len() - 1) % gs.world.cels.len();
}
if is_key_pressed(KeyCode::Period) && !gs.world.cels.is_empty() {
self.sprite_i = (self.sprite_i + 1) % gs.world.cels.len();
}
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
get_char_pressed(); // swallow the enter itself
self.colon = Some(String::new());
return ev;
}
if is_key_pressed(KeyCode::S) {
if let Some((x, y)) = Self::pic_pos() {
ev.extend(gs.apply(Command::SetSpawn { x, y }));
self.status = format!("spawn set to {},{}", x, y);
}
}
if is_key_pressed(KeyCode::P) {
if let (Some((x, y)), Some((sprite, _))) = (Self::pic_pos(), gs.world.cels.get(self.sprite_i)) {
let name = format!("{}-{}", sprite, gs.world.doc.props.len() + 1);
let prop = PropDef { name: name.clone(), sprite: sprite.clone(), x, y, ..Default::default() };
ev.extend(gs.apply(Command::UpsertProp { room: None, prop }));
self.status = format!("placed prop '{}' (edit msgs in room JSON)", name);
}
}
if is_key_pressed(KeyCode::X) {
if let Some((x, y)) = Self::pic_pos() {
let hit = gs
.world
.doc
.props
.iter()
.find(|p| (p.x - x).abs() < 14 && y <= p.y && y > p.y - 40)
.map(|p| p.name.clone());
if let Some(name) = hit {
ev.extend(gs.apply(Command::DeleteProp { room: None, name: name.clone() }));
self.status = format!("deleted prop '{}'", name);
}
}
}
// --- palette clicks ---------------------------------------------------
let (mx, my) = mouse_position();
let pal_y = BAR_H + PIC_H as f32 * SCALE;
if my >= pal_y && (is_mouse_button_pressed(MouseButton::Left) || is_mouse_button_pressed(MouseButton::Right)) {
let col = (mx / (WIN_W as f32 / 64.0)) as i32;
let row = ((my - pal_y) / 14.0) as i32;
if (0..64).contains(&col) && (0..4).contains(&row) {
let idx = (row * 64 + col) as u8;
if is_mouse_button_pressed(MouseButton::Left) {
self.color = idx;
} else {
self.color2 = idx;
}
}
return ev;
}
// --- painting ---------------------------------------------------------
let pos = Self::pic_pos();
if is_mouse_button_pressed(MouseButton::Left) {
if let Some(p) = pos {
match self.tool {
Tool::Fill => {
if self.meaning != Meaning::Hotspot {
ev.extend(gs.apply(Command::PaintOp { op: PicOp::Flood { x: p.0, y: p.1, ink: self.ink() } }));
}
}
Tool::Pick => {
self.color = gs.world.screens.visual[p.1 as usize * PIC_W + p.0 as usize];
self.status = format!("picked color {}", self.color);
}
Tool::Brush => {
self.drag = Some(p);
self.trail = vec![p];
}
_ => self.drag = Some(p),
}
}
}
if is_mouse_button_down(MouseButton::Left) && self.tool == Tool::Brush {
if let (Some(p), true) = (pos, self.drag.is_some()) {
if self.trail.last() != Some(&p) {
self.trail.push(p);
}
}
}
if is_mouse_button_released(MouseButton::Left) {
if let Some(a) = self.drag.take() {
let b = pos.unwrap_or(a);
let ink = self.ink();
let op = match self.tool {
Tool::Brush => {
let pts = std::mem::take(&mut self.trail);
if self.meaning == Meaning::Hotspot {
None
} else {
Some(PicOp::Brush { pts, size: self.size, ink })
}
}
Tool::Line => Some(PicOp::Line { pts: vec![a, b], ink }),
Tool::Rect | Tool::Gradient => {
let (x0, x1) = (a.0.min(b.0), a.0.max(b.0));
let (y0, y1) = (a.1.min(b.1), a.1.max(b.1));
let (w, h) = (x1 - x0 + 1, y1 - y0 + 1);
if self.meaning == Meaning::Hotspot {
self.hot_count += 1;
let name = format!("hot{}", self.hot_count);
self.last_hotspot = name.clone();
let hotspot = Hotspot {
name: name.clone(),
rect: Some([x0, y0, w, h]),
..default_hotspot()
};
ev.extend(gs.apply(Command::UpsertHotspot { hotspot }));
self.status = format!(":hotspot NAME renames '{}' · :hotmsg VERB TEXT gives it words", name);
None
} else if self.tool == Tool::Gradient {
Some(PicOp::VGradient { x: x0, y: y0, w, h, ramp: vec![self.color, self.color2], ink })
} else {
Some(PicOp::Rect { x: x0, y: y0, w, h, ink })
}
}
Tool::Ellipse => {
let cx = (a.0 + b.0) / 2;
let cy = (a.1 + b.1) / 2;
Some(PicOp::Ellipse { cx, cy, rx: (b.0 - a.0).abs() / 2, ry: (b.1 - a.1).abs() / 2, ink })
}
Tool::Fill | Tool::Pick => None,
};
if let Some(op) = op {
if self.meaning == Meaning::Hotspot {
self.status = "hotspots are drawn with the RECT tool".into();
} else {
ev.extend(gs.apply(Command::PaintOp { op }));
}
}
}
}
ev
}
fn run_colon(&mut self, gs: &mut GameState, line: &str) -> Vec<Event> {
let mut ev = Vec::new();
let words: Vec<&str> = line.split_whitespace().collect();
let rest = |n: usize| words[n..].join(" ");
match words.first().copied().unwrap_or("") {
"help" | "?" => {
self.status = ":save :name T :enter T :music N :weather N :exit nesw N|- :room N :clear :hotspot NAME :hotmsg VERB TEXT :delhot NAME".into();
}
"save" => {
ev.extend(gs.apply(Command::SaveRoom { n: None }));
self.status = "room saved".into();
}
"name" => {
ev.extend(gs.apply(Command::SetRoomName { name: rest(1) }));
self.status = "room renamed".into();
}
"enter" => {
ev.extend(gs.apply(Command::SetEnterText { text: rest(1) }));
self.status = "enter text set".into();
}
"music" => {
let mood = words.get(1).and_then(|w| w.parse().ok()).unwrap_or(0);
ev.extend(gs.apply(Command::SetMusic { mood }));
self.status = format!("music mood {}", mood);
}
"weather" => {
let kind = words.get(1).and_then(|w| w.parse().ok()).unwrap_or(0);
ev.extend(gs.apply(Command::SetWeather { kind }));
self.status = format!("weather {}", kind);
}
"exit" => {
let dir = match words.get(1).copied().unwrap_or("") {
"n" => 0u8,
"e" => 1,
"s" => 2,
"w" => 3,
_ => 255,
};
if dir == 255 {
self.status = "usage: :exit n|e|s|w ROOM (or - to clear)".into();
} else {
let target = words.get(2).and_then(|w| w.parse().ok());
ev.extend(gs.apply(Command::SetExit { dir, target }));
self.status = "exit set".into();
}
}
"room" => {
if let Some(n) = words.get(1).and_then(|w| w.parse().ok()) {
ev.extend(gs.apply(Command::GotoRoom { n }));
self.status = format!("room {}", n);
}
}
"clear" => {
ev.extend(gs.apply(Command::ClearOps));
self.status = "paint ops cleared".into();
}
"hotspot" => {
let new_name = rest(1);
if new_name.is_empty() || self.last_hotspot.is_empty() {
self.status = "draw a hotspot rect first, then :hotspot NAME".into();
} else if let Some(mut h) = gs.world.doc.hotspots.iter().find(|h| h.name == self.last_hotspot).cloned() {
ev.extend(gs.apply(Command::DeleteHotspot { name: h.name.clone() }));
h.name = new_name.clone();
ev.extend(gs.apply(Command::UpsertHotspot { hotspot: h }));
self.last_hotspot = new_name;
self.status = "hotspot renamed".into();
}
}
"hotmsg" => {
if words.len() < 3 || self.last_hotspot.is_empty() {
self.status = "usage: :hotmsg VERB TEXT (acts on the newest hotspot)".into();
} else if let Some(mut h) = gs.world.doc.hotspots.iter().find(|h| h.name == self.last_hotspot).cloned() {
h.msgs.insert(words[1].to_string(), rest(2));
ev.extend(gs.apply(Command::UpsertHotspot { hotspot: h }));
self.status = format!("{} message set on '{}'", words[1], self.last_hotspot);
}
}
"delhot" => {
ev.extend(gs.apply(Command::DeleteHotspot { name: rest(1) }));
self.status = "hotspot deleted".into();
}
"" => {}
other => self.status = format!("unknown :{} — try :help", other),
}
ev
}
/// Editor chrome, drawn after the frame.
pub fn draw(&mut self, gs: &GameState) {
// Overlay one of the invisible screens.
if self.view > 0 {
let which = ["", "priority", "control", "hotspot"][self.view as usize];
let rgba = mrpci_core::render::debug_screen(&gs.world.screens, which);
if self.overlay_img.is_none() {
let img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK);
let tex = Texture2D::from_image(&img);
tex.set_filter(FilterMode::Nearest);
self.overlay_img = Some((img, tex));
}
if let Some((img, tex)) = &mut self.overlay_img {
img.bytes.copy_from_slice(&rgba);
tex.update(img);
draw_texture_ex(
tex,
0.0,
BAR_H,
Color::from_rgba(255, 255, 255, 140),
DrawTextureParams { dest_size: Some(vec2(PIC_W as f32 * SCALE, PIC_H as f32 * SCALE)), ..Default::default() },
);
}
draw_text(which, 12.0, BAR_H + 22.0, 22.0, YELLOW);
}
// Hotspot rects + names while relevant.
if self.view == 3 || self.meaning == Meaning::Hotspot {
for h in &gs.world.doc.hotspots {
if let Some([x, y, w, hh]) = h.rect {
let (sx, sy) = (x as f32 * SCALE, BAR_H + y as f32 * SCALE);
draw_rectangle_lines(sx, sy, w as f32 * SCALE, hh as f32 * SCALE, 2.0, ORANGE);
draw_text(&h.name, sx + 3.0, sy + 14.0, 16.0, ORANGE);
}
}
}
// Spawn marker.
let (spx, spy) = gs.world.doc.spawn;
let (sx, sy) = (spx as f32 * SCALE, BAR_H + spy as f32 * SCALE);
draw_line(sx - 8.0, sy - 8.0, sx + 8.0, sy + 8.0, 2.0, GREEN);
draw_line(sx - 8.0, sy + 8.0, sx + 8.0, sy - 8.0, 2.0, GREEN);
// Drag preview.
if let (Some(a), Some(b)) = (self.drag, Self::pic_pos()) {
let (ax, ay) = (a.0 as f32 * SCALE, BAR_H + a.1 as f32 * SCALE);
let (bx, by) = (b.0 as f32 * SCALE, BAR_H + b.1 as f32 * SCALE);
match self.tool {
Tool::Line => draw_line(ax, ay, bx, by, 2.0, WHITE),
Tool::Rect | Tool::Gradient => {
draw_rectangle_lines(ax.min(bx), ay.min(by), (bx - ax).abs(), (by - ay).abs(), 2.0, WHITE)
}
Tool::Ellipse => {
draw_circle_lines((ax + bx) / 2.0, (ay + by) / 2.0, ((bx - ax).abs() + (by - ay).abs()) / 4.0, 2.0, WHITE)
}
_ => {}
}
}
if self.tool == Tool::Brush {
let (mx, my) = mouse_position();
draw_circle_lines(mx, my, (self.size as f32 + 0.5) * SCALE, 1.5, WHITE);
}
// Top bar: editor state.
draw_rectangle(0.0, 0.0, WIN_W as f32, BAR_H, Color::from_rgba(40, 24, 24, 255));
let sprite = gs.world.cels.get(self.sprite_i).map(|(n, _)| n.as_str()).unwrap_or("-");
let info = format!(
"EDIT {} · {} · col {}/{} · size {} · band {} · sprite {} · room {} ({} ops)",
self.tool.label(),
self.meaning.label(),
self.color,
self.color2,
self.size,
self.band,
sprite,
gs.world.current,
gs.world.doc.ops.len(),
);
draw_text(&info, 10.0, 20.0, 20.0, WHITE);
draw_text(
"B/L/R/O/F/G/K tools · M meaning · V overlay · U undo · S spawn · P prop · X delprop · Enter :cmd",
10.0,
40.0,
17.0,
Color::from_rgba(200, 180, 160, 255),
);
// Palette panel over the log area.
let pal_y = BAR_H + PIC_H as f32 * SCALE;
let cell_w = WIN_W as f32 / 64.0;
for i in 0..256usize {
let (row, col) = (i / 64, i % 64);
let [r, g, b, _] = gs.world.palette.rgba(i as u8);
let (x, y) = (col as f32 * cell_w, pal_y + row as f32 * 14.0);
draw_rectangle(x, y, cell_w, 14.0, Color::from_rgba(r, g, b, 255));
if i as u8 == self.color {
draw_rectangle_lines(x, y, cell_w, 14.0, 3.0, WHITE);
} else if i as u8 == self.color2 {
draw_rectangle_lines(x, y, cell_w, 14.0, 3.0, BLACK);
}
}
// Status / colon line.
let line = match &self.colon {
Some(c) => format!(":{}_", c),
None => self.status.clone(),
};
draw_rectangle(0.0, pal_y - 24.0, WIN_W as f32, 24.0, Color::from_rgba(10, 12, 18, 220));
draw_text(&line, 10.0, pal_y - 7.0, 19.0, Color::from_rgba(255, 230, 150, 255));
// Cursor coords.
if let Some((x, y)) = Self::pic_pos() {
draw_text(&format!("{},{}", x, y), WIN_W as f32 - 90.0, BAR_H + 20.0, 18.0, WHITE);
}
}
}
fn default_hotspot() -> Hotspot {
Hotspot {
name: String::new(),
rect: None,
poly: Vec::new(),
msgs: Default::default(),
exit_to: None,
arrive: None,
requires_flag: String::new(),
blocked_text: String::new(),
trigger: false,
blocks: false,
}
}

View File

@ -11,6 +11,7 @@
//! F5 / F7 save / restore ("quick" slot) F9 new game
//! M mute · N scanlines · Esc closes overlays / quits
mod editor;
mod sound;
use macroquad::prelude::*;
@ -154,11 +155,25 @@ async fn amain(game_dir: String) {
.expect("CRT shader compiles");
let mut last_dir_sent: u8 = 0;
let mut ed = editor::Editor::new();
show_mouse(false);
loop {
// --- edit-mode toggle -------------------------------------------------
if is_key_pressed(KeyCode::F8) {
ed.active = !ed.active;
if ed.active {
ui.typing = false;
let evs = gs.apply(Command::EndDialogue);
handle_events(&evs, &mut ui, &mut audio);
}
}
// --- input ---------------------------------------------------------
if ui.typing {
if ed.active {
let evs = ed.update(&mut gs);
handle_events(&evs, &mut ui, &mut audio);
} else if ui.typing {
while let Some(c) = get_char_pressed() {
if !c.is_control() {
ui.input.push(c);
@ -297,9 +312,11 @@ async fn amain(game_dir: String) {
}
}
// --- sim -------------------------------------------------------------
let evs = gs.tick(get_frame_time());
handle_events(&evs, &mut ui, &mut audio);
// --- sim (frozen while editing: paint in peace) -----------------------
if !ed.active {
let evs = gs.tick(get_frame_time());
handle_events(&evs, &mut ui, &mut audio);
}
audio.tick();
ui.flash = (ui.flash - get_frame_time()).max(0.0);
ui.fade = (ui.fade + get_frame_time() * 2.6).min(1.0);
@ -340,23 +357,28 @@ async fn amain(game_dir: String) {
);
}
draw_bar(&gs, &ui);
draw_log(&ui);
if ed.active {
ed.draw(&gs);
draw_cursor(&ui, false);
} else {
draw_bar(&gs, &ui);
draw_log(&ui);
if let Some(d) = &gs.dlg {
draw_dialogue(&d.lines(&gs.flags), &mut gs, &mut ui, &mut audio);
}
if ui.inventory_open {
draw_inventory(&gs);
}
if gs.won {
center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift.");
}
if ui.typing {
draw_input(&ui);
}
if let Some(d) = &gs.dlg {
draw_dialogue(&d.lines(&gs.flags), &mut gs, &mut ui, &mut audio);
}
if ui.inventory_open {
draw_inventory(&gs);
}
if gs.won {
center_banner("CASE CLOSED", "The city sleeps a little safer. F9 starts a new shift.");
}
if ui.typing {
draw_input(&ui);
}
draw_cursor(&ui, gs.dlg.is_some());
draw_cursor(&ui, gs.dlg.is_some());
}
next_frame().await;
}