In-game campaign, map and block script editors

Mission graph with branches, mission settings and goals, map editor on the
3D view (terrain, places, units, towers, traps, forts, rivals, zones; undo,
brush, rectangle, fill, mirror), block editor with palette, nesting, live
problems, undo, copy/paste and search; test play with a script debug view;
demo campaign and built-in missions as templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 17:10:11 +01:00
1 parent 65677504f6
commit 30dd8441b9
10 files changed
+3372 -19

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
// A small immediate-mode interface for the editors, drawn with the game's font and frames.
// Each frame: begin() reads the input, the screens call the widgets (which draw themselves and
// report clicks), finish() draws an open dropdown list and the tooltip on top.
use crate::gfx::*;
use crate::ui::*;
use macroquad::prelude::*;
#[allow(unused_imports)]
use crate::ui::{BLUE, GOLD, GREEN, RED};
pub const PANEL: Color = Color::new(0.07, 0.05, 0.06, 0.96);
pub const FIELD: Color = Color::new(0.12, 0.09, 0.08, 1.0);
pub const HOT: Color = Color::new(0.35, 0.22, 0.1, 1.0);
struct Popup {
id: u64,
rect: Rect, // where the list is drawn
options: Vec<String>,
scroll: usize,
rows: usize,
}
#[derive(Default)]
pub struct Im {
pub mx: f32,
pub my: f32,
pub pressed: bool, // left button went down this frame (and no popup took it)
pub released: bool, // left button went up this frame
pub down: bool,
pub rpressed: bool,
pub wheel: f32,
pub chars: Vec<char>,
pub ctrl: bool,
pub shift: bool,
pub focus: Option<u64>, // the text field that gets the typing
pub caret: usize, // caret position (characters) in the focused field
popup: Option<Popup>,
picked: Option<(u64, usize)>, // a dropdown choice made this frame
tip: Option<String>,
pub typing: bool, // a text field has the focus (keyboard shortcuts are off)
}
// a stable id from a name and an index
pub fn id(name: &str, k: usize) -> u64 {
let mut h: u64 = 1469598103934665603;
for b in name.bytes() {
h = (h ^ b as u64).wrapping_mul(1099511628211);
}
(h ^ k as u64).wrapping_mul(1099511628211)
}
impl Im {
pub fn begin(&mut self) {
let (mx, my) = mouse();
self.mx = mx;
self.my = my;
self.pressed = is_mouse_button_pressed(MouseButton::Left);
self.released = is_mouse_button_released(MouseButton::Left);
self.down = is_mouse_button_down(MouseButton::Left);
self.rpressed = is_mouse_button_pressed(MouseButton::Right);
self.wheel = mouse_wheel().1;
self.ctrl = is_key_down(KeyCode::LeftControl) || is_key_down(KeyCode::RightControl) || is_key_down(KeyCode::LeftSuper);
self.shift = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
self.chars.clear();
while let Some(c) = get_char_pressed() {
if !c.is_control() {
self.chars.push(c);
}
}
self.picked = None;
self.tip = None;
self.typing = self.focus.is_some();
// an open dropdown takes clicks and the wheel
if let Some(p) = self.popup.as_mut() {
let row_h = CH() * 1.5;
if self.wheel != 0.0 && p.rect.contains(vec2(mx, my)) {
let d = if self.wheel > 0.0 { -1 } else { 1 };
p.scroll = (p.scroll as i32 + d).clamp(0, p.options.len().saturating_sub(p.rows) as i32) as usize;
self.wheel = 0.0;
}
if self.pressed {
if p.rect.contains(vec2(mx, my)) {
let k = p.scroll + ((my - p.rect.y) / row_h).floor() as usize;
if k < p.options.len() {
self.picked = Some((p.id, k));
}
}
self.popup = None;
self.pressed = false;
}
}
// a click elsewhere ends the typing
if self.pressed {
self.focus = None;
}
}
pub fn over(&self, r: Rect) -> bool {
r.contains(vec2(self.mx, self.my))
}
pub fn popup_open(&self) -> bool {
self.popup.is_some()
}
// a click on r (it is taken: later widgets do not see it)
pub fn clicked(&mut self, r: Rect) -> bool {
if self.pressed && self.over(r) {
self.pressed = false;
true
} else {
false
}
}
pub fn tip(&mut self, r: Rect, text: &str) {
if self.over(r) && !text.is_empty() {
self.tip = Some(text.to_string());
}
}
pub fn button(&mut self, gfx: &Gfx, r: Rect, label: &str) -> bool {
self.button_c(gfx, r, label, FRAME)
}
pub fn button_c(&mut self, gfx: &Gfx, r: Rect, label: &str, accent: Color) -> bool {
let hot = self.over(r) && !self.popup_open();
draw_rectangle(r.x, r.y, r.w, r.h, if hot { HOT } else { Color::new(0.13, 0.09, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent });
let s = S();
let maxc = ((r.w / (GW * s)) as usize).saturating_sub(1).max(1);
let label: String = label.chars().take(maxc).collect();
gfx.text(&label, (r.x + (r.w - gfx.measure(&label, s)) * 0.5).floor(), (r.y + (r.h - GH * s) * 0.5).floor(), s, if hot { FRAME_HI } else { TXT });
self.clicked(r)
}
// a selectable entry (list rows, tabs): highlighted when selected
pub fn select(&mut self, gfx: &Gfx, r: Rect, label: &str, selected: bool, col: Color) -> bool {
let hot = self.over(r) && !self.popup_open();
if selected || hot {
draw_rectangle(r.x, r.y, r.w, r.h, if hot { HOT } else { Color::new(0.22, 0.15, 0.09, 1.0) });
}
let maxc = ((r.w / CW()) as usize).saturating_sub(1).max(1);
let label: String = label.chars().take(maxc).collect();
gfx.text(&label, r.x + CW() * 0.5, (r.y + (r.h - CH()) * 0.5).floor(), S(), if selected { FRAME_HI } else { col });
self.clicked(r)
}
// [x] label; returns true when it was flipped
pub fn toggle(&mut self, gfx: &Gfx, x: f32, y: f32, label: &str, v: &mut bool) -> bool {
let w = gfx.measure(label, S()) + CW() * 3.0;
let r = Rect::new(x, y, w, CH() * 1.5);
let hot = self.over(r) && !self.popup_open();
let bx = Rect::new(x, y + CH() * 0.1, CW() * 1.6, CH() * 1.3);
draw_rectangle(bx.x, bx.y, bx.w, bx.h, FIELD);
draw_rectangle_lines(bx.x, bx.y, bx.w, bx.h, 2.0, if hot { FRAME_HI } else { FRAME });
if *v {
gfx.glyph('√', bx.x + CW() * 0.3, bx.y + CH() * 0.15, S(), GOLD);
}
gfx.text(label, x + CW() * 2.2, y + CH() * 0.25, S(), if hot { FRAME_HI } else { TXT });
if self.clicked(r) {
*v = !*v;
true
} else {
false
}
}
// < value >: -1, +1 or 0 (shift: steps of 10)
pub fn stepper(&mut self, gfx: &Gfx, r: Rect, value: &str) -> i32 {
let bw = CW() * 2.0;
let l = Rect::new(r.x, r.y, bw, r.h);
let rr = Rect::new(r.x + r.w - bw, r.y, bw, r.h);
draw_rectangle(r.x, r.y, r.w, r.h, FIELD);
let mut d = 0;
if self.button(gfx, l, "<") {
d = -1;
}
if self.button(gfx, rr, ">") {
d = 1;
}
let v: String = value.chars().take(((r.w - bw * 2.0) / CW()) as usize).collect();
gfx.text(&v, (r.x + (r.w - gfx.measure(&v, S())) * 0.5).floor(), (r.y + (r.h - CH()) * 0.5).floor(), S(), GOLD);
if self.over(r) && self.wheel != 0.0 && !self.popup_open() {
d = if self.wheel > 0.0 { 1 } else { -1 };
self.wheel = 0.0;
}
if self.shift {
d * 10
} else {
d
}
}
// one line of text; returns true when it changed
pub fn text_field(&mut self, gfx: &Gfx, fid: u64, r: Rect, text: &mut String, max: usize) -> bool {
let focused = self.focus == Some(fid);
draw_rectangle(r.x, r.y, r.w, r.h, FIELD);
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else if self.over(r) { FRAME } else { Color::new(0.3, 0.24, 0.18, 1.0) });
if self.clicked(r) {
self.focus = Some(fid);
self.caret = text.chars().count();
}
let mut changed = false;
if self.focus == Some(fid) {
changed = self.edit(text, max, false);
}
let cols = ((r.w - CW()) / CW()).max(1.0) as usize;
let n = text.chars().count();
let first = if self.focus == Some(fid) { self.caret.saturating_sub(cols.saturating_sub(1)) } else { 0 };
let shown: String = text.chars().skip(first).take(cols).collect();
let ty = (r.y + (r.h - CH()) * 0.5).floor();
gfx.text(&shown, r.x + CW() * 0.5, ty, S(), TXT);
if self.focus == Some(fid) && (get_time() * 2.0) as i64 % 2 == 0 {
let cx = r.x + CW() * 0.5 + (self.caret.min(n) - first) as f32 * CW();
draw_rectangle(cx, ty, (S()).max(2.0), CH(), FRAME_HI);
}
changed
}
// several lines (wrapped); Enter starts a new line
pub fn text_area(&mut self, gfx: &Gfx, fid: u64, r: Rect, text: &mut String, max: usize) -> bool {
let focused = self.focus == Some(fid);
draw_rectangle(r.x, r.y, r.w, r.h, FIELD);
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else { Color::new(0.3, 0.24, 0.18, 1.0) });
if self.clicked(r) {
self.focus = Some(fid);
self.caret = text.chars().count();
}
let mut changed = false;
if self.focus == Some(fid) {
changed = self.edit(text, max, true);
}
let cols = ((r.w - CW()) / CW()).max(4.0) as usize;
let rows = ((r.h - CH() * 0.5) / CH()).max(1.0) as usize;
// wrap paragraph by paragraph; the caret is shown at the end of the text
let mut lines: Vec<String> = Vec::new();
for para in text.split('\n') {
let w = wrap(para, cols);
if w.is_empty() {
lines.push(String::new());
} else {
lines.extend(w);
}
}
let first = lines.len().saturating_sub(rows);
for (k, l) in lines.iter().skip(first).take(rows).enumerate() {
gfx.text(l, r.x + CW() * 0.5, r.y + CH() * 0.25 + k as f32 * CH(), S(), TXT);
}
if self.focus == Some(fid) && (get_time() * 2.0) as i64 % 2 == 0 {
let last = lines.last().map_or(0, |l| l.chars().count());
let k = lines.len().saturating_sub(1) - first;
draw_rectangle(r.x + CW() * 0.5 + last as f32 * CW(), r.y + CH() * 0.25 + k as f32 * CH(), S().max(2.0), CH(), FRAME_HI);
}
changed
}
// typing into the focused text
fn edit(&mut self, text: &mut String, max: usize, multiline: bool) -> bool {
let mut chars: Vec<char> = text.chars().collect();
let mut changed = false;
self.caret = self.caret.min(chars.len());
if multiline {
self.caret = chars.len();
}
for &c in self.chars.iter() {
if chars.len() < max {
chars.insert(self.caret, c);
self.caret += 1;
changed = true;
}
}
if is_key_pressed(KeyCode::Backspace) && self.caret > 0 {
chars.remove(self.caret - 1);
self.caret -= 1;
changed = true;
}
if is_key_pressed(KeyCode::Delete) && self.caret < chars.len() {
chars.remove(self.caret);
changed = true;
}
if !multiline {
if is_key_pressed(KeyCode::Left) {
self.caret = self.caret.saturating_sub(1);
}
if is_key_pressed(KeyCode::Right) {
self.caret = (self.caret + 1).min(chars.len());
}
if is_key_pressed(KeyCode::Home) {
self.caret = 0;
}
if is_key_pressed(KeyCode::End) {
self.caret = chars.len();
}
}
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
if multiline && chars.len() < max {
chars.push('\n');
self.caret = chars.len();
changed = true;
} else if !multiline {
self.focus = None;
}
}
if is_key_pressed(KeyCode::Escape) {
self.focus = None;
}
if changed {
*text = chars.into_iter().collect();
}
changed
}
// a dropdown field; Some(k) when an option was picked
pub fn dropdown(&mut self, gfx: &Gfx, did: u64, r: Rect, current: &str, options: &[String]) -> Option<usize> {
let open = self.popup.as_ref().map_or(false, |p| p.id == did);
let hot = self.over(r) && !self.popup_open() || open;
draw_rectangle(r.x, r.y, r.w, r.h, if hot { HOT } else { FIELD });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { FRAME });
let maxc = ((r.w / CW()) as usize).saturating_sub(3).max(1);
let cur: String = current.chars().take(maxc).collect();
gfx.text(&cur, r.x + CW() * 0.5, (r.y + (r.h - CH()) * 0.5).floor(), S(), if hot { FRAME_HI } else { TXT });
gfx.glyph('▼', r.x + r.w - CW() * 1.5, (r.y + (r.h - CH()) * 0.5).floor(), S(), DIM);
if self.clicked(r) && !options.is_empty() {
let row_h = CH() * 1.5;
let w = r.w.max(options.iter().map(|o| gfx.measure(o, S())).fold(0.0, f32::max) + CW() * 2.0);
let below = ((sh() - r.y - r.h - CH()) / row_h).floor().max(0.0) as usize;
let above = ((r.y - CH()) / row_h).floor().max(0.0) as usize;
let down = below >= options.len().min(8) || below >= above;
let rows = options.len().min(if down { below } else { above }).clamp(1, 16);
let y = if down { r.y + r.h } else { r.y - rows as f32 * row_h };
let cur_k = options.iter().position(|o| o == current).unwrap_or(0);
self.popup = Some(Popup { id: did, rect: Rect::new(r.x.min(sw() - w).max(0.0), y, w, rows as f32 * row_h), options: options.to_vec(), scroll: cur_k.saturating_sub(rows / 2).min(options.len() - rows), rows });
}
match self.picked {
Some((p, k)) if p == did => Some(k),
_ => None,
}
}
// draws what lies on top: the open dropdown list, the tooltip
pub fn finish(&mut self, gfx: &Gfx) {
if let Some(p) = self.popup.as_ref() {
let row_h = CH() * 1.5;
draw_rectangle(p.rect.x, p.rect.y, p.rect.w, p.rect.h, Color::new(0.05, 0.035, 0.04, 0.99));
for (k, o) in p.options.iter().enumerate().skip(p.scroll).take(p.rows) {
let r = Rect::new(p.rect.x, p.rect.y + (k - p.scroll) as f32 * row_h, p.rect.w, row_h);
if self.over(r) {
draw_rectangle(r.x, r.y, r.w, r.h, HOT);
}
gfx.text(o, r.x + CW() * 0.5, (r.y + (row_h - CH()) * 0.5).floor(), S(), if self.over(r) { FRAME_HI } else { TXT });
}
if p.scroll > 0 {
gfx.glyph('▲', p.rect.x + p.rect.w - CW() * 1.3, p.rect.y, S(), DIM);
}
if p.scroll + p.rows < p.options.len() {
gfx.glyph('▼', p.rect.x + p.rect.w - CW() * 1.3, p.rect.y + p.rect.h - CH(), S(), DIM);
}
draw_rectangle_lines(p.rect.x, p.rect.y, p.rect.w, p.rect.h, 2.0, FRAME_HI);
}
if let Some(t) = self.tip.take() {
let lines = wrap(&t, 46);
let w = lines.iter().map(|l| gfx.measure(l, SMALL())).fold(0.0, f32::max) + CW() * 2.0;
let h = lines.len() as f32 * GH * SMALL() + CH();
let x = (self.mx + CW() * 1.5).min(sw() - w - 2.0);
let y = (self.my + CH() * 1.5).min(sh() - h - 2.0);
draw_rectangle(x, y, w, h, Color::new(0.04, 0.03, 0.03, 0.97));
draw_rectangle_lines(x, y, w, h, 2.0, FRAME);
for (k, l) in lines.iter().enumerate() {
gfx.text(l, x + CW(), y + CH() * 0.5 + k as f32 * GH * SMALL(), SMALL(), TXT);
}
}
}
}
// a framed panel with an optional title
pub fn panel(gfx: &Gfx, r: Rect, title: &str) {
draw_rectangle(r.x, r.y, r.w, r.h, PANEL);
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, FRAME);
if !title.is_empty() {
gfx.text(title, r.x + CW(), r.y + CH() * 0.4, S(), FRAME_HI);
}
}
pub fn label(gfx: &Gfx, x: f32, y: f32, text: &str, col: Color) {
gfx.text(text, x, y, S(), col);
}