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:
parent
0191b4680d
commit
662e6c4bc4
@ -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)
|
||||
|
||||
@ -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
49
docs/EDITOR.md
Normal 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 0–15 (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.
|
||||
@ -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);
|
||||
|
||||
@ -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
569
mrpci/src/editor.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user