diff --git a/README.md b/README.md index 77cb54c..24e1e8f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index baa06d3..0574582 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -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"} diff --git a/docs/EDITOR.md b/docs/EDITOR.md new file mode 100644 index 0000000..2ab5ae6 --- /dev/null +++ b/docs/EDITOR.md @@ -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. diff --git a/mrpci-core/src/pic.rs b/mrpci-core/src/pic.rs index d6e6858..6910432 100644 --- a/mrpci-core/src/pic.rs +++ b/mrpci-core/src/pic.rs @@ -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); diff --git a/mrpci-core/src/state.rs b/mrpci-core/src/state.rs index ecd93f5..8154da0 100644 --- a/mrpci-core/src/state.rs +++ b/mrpci-core/src/state.rs @@ -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 }, + 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, prop: crate::room::PropDef }, + DeleteProp { room: Option, 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) -> Vec { let target = room.unwrap_or(self.world.manifest.start_room); self.inventory.clear(); diff --git a/mrpci/src/editor.rs b/mrpci/src/editor.rs new file mode 100644 index 0000000..82cce3b --- /dev/null +++ b/mrpci/src/editor.rs @@ -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, + 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 { + 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 { + 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, + } +} diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index 9e92f99..83acf2e 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -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; }