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

+11 -5
View File
@@ -507,10 +507,6 @@ fn start_mission_mode(i: usize, carry: Option<&Carry>, coop: bool) -> Game {
// A mission generated from the campaign editor file: same start pipeline, but without the
// built-in campaign table and without language-file story keys for the title.
pub fn start_mission_doc(d: &MissionDef, i: usize, carry: Option<&Carry>) -> Game {
start_mission_def(d, i, carry, false)
}
fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool) -> Game {
let (w, h) = d.size;
let opts = GenOpts { portals: Some(d.portals), treasures: Some(d.treasures), gate: d.gate, core_y: None };
@@ -673,6 +669,16 @@ pub fn start_editor_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run
g
}
// the map editor sets a knight fortress with its middle at (x, y); level 1..3, with a shard
pub fn editor_fort(g: &mut Game, x: i32, y: i32, level: u32, shard: bool) {
let (w, h) = (g.map.w, g.map.h);
let lvl = level.clamp(1, 3);
let mut f = fort((x as f32 / w as f32, y as f32 / h as f32), (15 + 2 * lvl as i32, 13 + 2 * lvl as i32), &[3], &[TowerKind::Arrow, TowerKind::Arrow], lvl, 2 + 2 * lvl, lvl, lvl, 1, 800 * lvl as i32);
f.shard = shard;
g.build_fortress(&f.spec(w, h));
g.recount();
}
// a painted map: the game state saved by the map editor
pub fn load_fixed_map(json: &str) -> Option<Game> {
let mut g: Game = serde_json::from_str(json).ok()?;
@@ -732,7 +738,7 @@ mod tests {
assert_eq!(def.key, "custom01");
assert_eq!(def.goals, vec![Goal::KillAll]);
let g = start_mission_doc(&def, 0, None);
let g = start_mission_def(&def, 0, None, false);
let m = g.mission.as_ref().expect("the custom mission has mission state");
assert_eq!(m.goals, vec![Goal::KillAll]);
assert_eq!(g.gold, 3500.0);
+747
View File
@@ -0,0 +1,747 @@
// The block editor: a script as a stack of coloured puzzle blocks. Blocks come from the
// palette on the left (drag them into the script), are moved by their grip, nest inside
// "if" and "repeat", and have their settings right inside them. Problems (missing zones,
// unset variables, wrong values) are marked red while editing; undo/redo, copy/paste and a
// search are there too.
use super::widgets::*;
use super::{kind_label, side_label, ScriptRef};
use crate::blocks::*;
use crate::campaign_doc::MissionDoc;
use crate::gfx::*;
use crate::i18n::{tr, trf};
use crate::ui::*;
use macroquad::prelude::*;
#[allow(unused_imports)]
use crate::ui::{BLUE, GOLD, GREEN, RED};
#[derive(Clone)]
struct Drag {
block: Block,
from: Option<(Vec<usize>, usize)>, // where it was taken from (None: the palette)
}
#[derive(Default)]
pub struct BlockEdit {
cat: usize,
undo: Vec<String>,
redo: Vec<String>,
last_json: String,
last_change: f64,
clipboard: Option<Block>,
selected: Option<Vec<usize>>,
drag: Option<Drag>,
search: String,
scroll: f32,
num_buf: String, // the number being typed (focused number field)
num_focus: Option<u64>,
help: String,
}
enum Op {
Remove(Vec<usize>),
Select(Vec<usize>),
StartDrag(Vec<usize>),
}
// what the fields may offer
struct Ctx {
zones: Vec<String>,
vars: Vec<String>,
triggers: Vec<String>,
problems: Vec<(Vec<usize>, &'static str)>,
search: String,
}
fn cat_color(cat: &str) -> Color {
match cat {
"say" => Color::new(0.42, 0.25, 0.55, 1.0),
"units" => Color::new(0.58, 0.2, 0.18, 1.0),
"world" => Color::new(0.3, 0.42, 0.2, 1.0),
"logic" => Color::new(0.62, 0.4, 0.12, 1.0),
"vars" => Color::new(0.18, 0.34, 0.58, 1.0),
_ => Color::new(0.55, 0.47, 0.14, 1.0),
}
}
fn fid(path: &[usize], field: usize) -> u64 {
let mut h: u64 = 0x9e3779b97f4a7c15;
for &p in path {
h = (h ^ p as u64).wrapping_mul(0x100000001b3);
}
(h ^ (field as u64 + 7919)).wrapping_mul(0x100000001b3)
}
fn list_mut<'a>(root: &'a mut Vec<Block>, lp: &[usize]) -> Option<&'a mut Vec<Block>> {
let mut cur = root;
let mut k = 0;
while k + 1 < lp.len() + 1 && k < lp.len() {
let (i, branch) = (lp[k], *lp.get(k + 1)?);
cur = match cur.get_mut(i)? {
Block::If { then, els, .. } => {
if branch == 0 {
then
} else {
els
}
}
Block::Repeat { body, .. } => body,
_ => return None,
};
k += 2;
}
Some(cur)
}
fn script_mut<'a>(m: &'a mut MissionDoc, s: ScriptRef) -> Option<&'a mut Vec<Block>> {
Some(match s {
ScriptRef::Start => &mut m.scripts.start,
ScriptRef::Victory => &mut m.scripts.victory,
ScriptRef::Defeat => &mut m.scripts.defeat,
ScriptRef::Tick => &mut m.scripts.tick,
ScriptRef::Trigger(k) => &mut m.scripts.triggers.get_mut(k)?.script,
})
}
// does a block (or anything in it) contain the search text?
fn block_text(b: &Block) -> String {
match b {
Block::Message { text } | Block::Objective { text } | Block::Hint { text } => text.clone(),
Block::SetVar { name, .. } | Block::AddVar { name, .. } => name.clone(),
Block::Spawn { kind, name, place, .. } => format!("{} {} {:?}", kind, name, place),
Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } | Block::Terrain { zone, .. } => zone.clone(),
Block::Trigger { name, .. } => name.clone(),
_ => String::new(),
}
}
const ROW: f32 = 2.2; // block row height in CH
impl BlockEdit {
pub fn open(&mut self, _s: ScriptRef) {
self.undo.clear();
self.redo.clear();
self.last_json.clear();
self.selected = None;
self.drag = None;
self.scroll = 0.0;
}
// returns true when the editor is left
pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, m: &mut MissionDoc, sref: ScriptRef) -> bool {
let (w, h) = (sw(), sh());
let mut back = false;
let title = match sref {
ScriptRef::Start => tr("ed.script_start"),
ScriptRef::Victory => tr("ed.script_victory"),
ScriptRef::Defeat => tr("ed.script_defeat"),
ScriptRef::Tick => tr("ed.script_tick"),
ScriptRef::Trigger(k) => trf("ed.trigger_script", &[m.scripts.triggers.get(k).map(|t| t.name.clone()).unwrap_or_default()]),
};
let probs = {
let list = match sref {
ScriptRef::Start => &m.scripts.start,
ScriptRef::Victory => &m.scripts.victory,
ScriptRef::Defeat => &m.scripts.defeat,
ScriptRef::Tick => &m.scripts.tick,
ScriptRef::Trigger(k) => match m.scripts.triggers.get(k) {
Some(t) => &t.script,
None => return true,
},
};
m.problems(list)
};
let ctx = Ctx {
zones: m.zone_names(),
vars: m.vars(),
triggers: m.trigger_names(),
problems: probs.iter().map(|p| (p.path.clone(), p.key)).collect(),
search: self.search.to_lowercase(),
};
let Some(list) = script_mut(m, sref) else { return true };
let before = serde_json::to_string(&*list).unwrap_or_default();
if self.last_json.is_empty() {
self.last_json = before.clone();
}
// ---- top bar
panel(gfx, Rect::new(0.0, 0.0, w, CH() * 3.2), "");
if im.button(gfx, Rect::new(CW(), CH() * 0.7, CW() * 12.0, CH() * 1.7), &tr("ed.back")) || (is_key_pressed(KeyCode::Escape) && !im.typing && self.drag.is_none()) {
back = true;
}
gfx.text(&title, CW() * 14.0, CH() * 1.0, S(), FRAME_HI);
let mut bx = w - CW();
let buttons = [tr("ed.delete"), tr("ed.paste"), tr("ed.copy"), tr("ed.redo"), tr("ed.undo")];
let mut pressed_btn = None;
for (k, l) in buttons.iter().enumerate() {
let bw = gfx.measure(l, S()) + CW() * 2.0;
bx -= bw;
if im.button(gfx, Rect::new(bx, CH() * 0.7, bw, CH() * 1.7), l) {
pressed_btn = Some(k);
}
bx -= CW() * 0.5;
}
let sw_ = CW() * 18.0;
bx -= sw_ + CW();
im.text_field(gfx, id("bsearch", 0), Rect::new(bx, CH() * 0.7, sw_, CH() * 1.7), &mut self.search, 24);
if self.search.is_empty() && im.focus != Some(id("bsearch", 0)) {
gfx.text(&tr("ed.search"), bx + CW() * 0.5, CH() * 1.0, S(), DIM);
}
let ptext = if probs.is_empty() { tr("ed.no_problems") } else { trf("ed.problems", &[probs.len().to_string()]) };
gfx.text(&ptext, CW() * 14.0, CH() * 2.2, SMALL(), if probs.is_empty() { GREEN } else { RED });
// keyboard shortcuts
let keys = !im.typing;
if keys && im.ctrl && is_key_pressed(KeyCode::F) {
im.focus = Some(id("bsearch", 0));
}
let mut act = pressed_btn;
if keys && im.ctrl && is_key_pressed(KeyCode::Z) {
act = Some(4);
}
if keys && im.ctrl && is_key_pressed(KeyCode::Y) {
act = Some(3);
}
if keys && im.ctrl && is_key_pressed(KeyCode::C) {
act = Some(2);
}
if keys && im.ctrl && is_key_pressed(KeyCode::V) {
act = Some(1);
}
if keys && is_key_pressed(KeyCode::Delete) {
act = Some(0);
}
match act {
Some(4) => {
if let Some(s) = self.undo.pop() {
self.redo.push(before.clone());
if let Ok(l) = serde_json::from_str(&s) {
*list = l;
}
self.last_json = s;
self.selected = None;
}
}
Some(3) => {
if let Some(s) = self.redo.pop() {
self.undo.push(before.clone());
if let Ok(l) = serde_json::from_str(&s) {
*list = l;
}
self.last_json = s;
self.selected = None;
}
}
Some(2) => {
if let Some(p) = self.selected.clone() {
if let Some(l) = list_mut(list, &p[..p.len() - 1]) {
self.clipboard = l.get(p[p.len() - 1]).cloned();
}
}
}
Some(1) => {
if let Some(b) = self.clipboard.clone() {
match self.selected.clone() {
Some(p) => {
if let Some(l) = list_mut(list, &p[..p.len() - 1]) {
let at = (p[p.len() - 1] + 1).min(l.len());
l.insert(at, b);
}
}
None => list.push(b),
}
}
}
Some(0) => {
if let Some(p) = self.selected.take() {
if let Some(l) = list_mut(list, &p[..p.len() - 1]) {
if p[p.len() - 1] < l.len() {
l.remove(p[p.len() - 1]);
}
}
}
}
_ => {}
}
// ---- palette
let pal_w = CW() * 28.0;
let top = CH() * 3.2;
panel(gfx, Rect::new(0.0, top, pal_w, h - top), "");
let mut y = top + CH() * 0.6;
for (k, c) in CATEGORIES.iter().enumerate() {
let r = Rect::new(CW() * 0.5 + (k % 2) as f32 * (pal_w * 0.5 - CW() * 0.25), y + (k / 2) as f32 * CH() * 1.8, pal_w * 0.5 - CW() * 0.75, CH() * 1.6);
draw_rectangle(r.x, r.y, r.w, r.h, if self.cat == k { cat_color(c) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, cat_color(c));
gfx.text(&tr(&format!("blk.cat.{}", c)), r.x + CW() * 0.5, r.y + CH() * 0.3, S(), TXT);
if im.clicked(r) {
self.cat = k;
}
}
y += CH() * 6.0;
self.help.clear();
for (c, kind) in PALETTE.iter().filter(|(c, _)| *c == CATEGORIES[self.cat]) {
let r = Rect::new(CW() * 0.5, y, pal_w - CW(), CH() * 1.8);
draw_block_shape(r, cat_color(c), im.over(r), false);
gfx.text(&tr(&format!("blk.{}", kind)), r.x + CW() * 1.6, r.y + CH() * 0.4, S(), TXT);
if im.over(r) {
self.help = tr(&format!("blk.help.{}", kind));
}
if im.pressed && im.over(r) {
im.pressed = false;
if let Some(b) = Block::template(kind) {
self.drag = Some(Drag { block: b, from: None });
}
}
y += CH() * 2.2;
}
gfx.text(&tr("ed.blocks_hint"), CW() * 0.5, h - CH() * 6.0, SMALL(), DIM);
// ---- the script
let ws = Rect::new(pal_w, top, w - pal_w, h - top - CH() * 2.0);
if im.over(ws) && im.wheel != 0.0 && !im.popup_open() {
self.scroll = (self.scroll - im.wheel.signum() * CH() * 3.0).max(0.0);
}
let mut ops = Vec::new();
let mut slots: Vec<(Rect, Vec<usize>, usize)> = Vec::new();
let x0 = ws.x + CW() * 2.0;
let y0 = ws.y + CH() - self.scroll;
let height = self.draw_list(im, gfx, list, &[], x0, y0, ws.w - CW() * 4.0, &ctx, &mut ops, &mut slots);
self.scroll = self.scroll.min((height - ws.h * 0.5).max(0.0));
if list.is_empty() {
gfx.text(&tr("ed.empty_script"), x0, y0 + CH(), S(), DIM);
}
// apply what was clicked
for op in ops {
match op {
Op::Select(p) => self.selected = Some(p),
Op::Remove(p) => {
if let Some(l) = list_mut(list, &p[..p.len() - 1]) {
if p[p.len() - 1] < l.len() {
l.remove(p[p.len() - 1]);
}
}
self.selected = None;
}
Op::StartDrag(p) => {
if let Some(l) = list_mut(list, &p[..p.len() - 1]) {
let k = p[p.len() - 1];
if k < l.len() {
let b = l.remove(k);
self.drag = Some(Drag { block: b, from: Some((p[..p.len() - 1].to_vec(), k)) });
self.selected = None;
}
}
}
}
}
// dragging: the block follows the pointer; dropped into the nearest gap
if let Some(d) = self.drag.clone() {
let target = slots
.iter()
.filter(|(r, _, _)| r.contains(vec2(im.mx, im.my)))
.max_by_key(|(_, lp, _)| lp.len())
.map(|(r, lp, k)| (*r, lp.clone(), *k))
.or_else(|| {
// below everything: the end of the script
(im.over(ws)).then(|| (Rect::new(x0, y0 + height, ws.w, CH()), Vec::new(), list.len()))
});
if let Some((r, _, _)) = &target {
draw_rectangle(r.x, r.y + r.h * 0.5 - 2.0, ws.w * 0.5, 4.0, GOLD);
}
let gr = Rect::new(im.mx - CW(), im.my - CH() * 0.5, CW() * 24.0, CH() * 1.8);
draw_block_shape(gr, Color::new(cat_color(d.block.category()).r, cat_color(d.block.category()).g, cat_color(d.block.category()).b, 0.85), true, false);
gfx.text(&tr(&format!("blk.{}", d.block.kind_name())), gr.x + CW() * 1.6, gr.y + CH() * 0.4, S(), TXT);
if !im.down {
let over_palette = im.mx < pal_w;
match (target, over_palette) {
(Some((_, lp, k)), false) => {
if let Some(l) = list_mut(list, &lp) {
let k = k.min(l.len());
l.insert(k, d.block.clone());
let mut p = lp.clone();
p.push(k);
self.selected = Some(p);
}
}
(_, true) => {} // dropped on the palette: gone
_ => {
// nowhere: back where it came from
if let Some((lp, k)) = d.from {
if let Some(l) = list_mut(list, &lp) {
l.insert(k.min(l.len()), d.block.clone());
}
}
}
}
self.drag = None;
}
}
// the help line
panel(gfx, Rect::new(pal_w, h - CH() * 2.0, w - pal_w, CH() * 2.0), "");
if !self.help.is_empty() {
let line: String = self.help.chars().take(((w - pal_w) / CW()) as usize - 2).collect();
gfx.text(&line, pal_w + CW(), h - CH() * 1.5, S(), TXT);
}
// undo: one step per change (typing in one field within a second is one step)
let after = serde_json::to_string(&*list).unwrap_or_default();
if after != self.last_json && act.map_or(true, |a| a < 3) {
let now = get_time();
if !(im.typing && now - self.last_change < 1.0) {
self.undo.push(self.last_json.clone());
if self.undo.len() > 100 {
self.undo.remove(0);
}
self.redo.clear();
}
self.last_change = now;
self.last_json = after;
} else if act.map_or(false, |a| a >= 3) {
self.last_json = after;
}
back
}
// draws a list of blocks; returns its height
#[allow(clippy::too_many_arguments)]
fn draw_list(&mut self, im: &mut Im, gfx: &Gfx, list: &mut Vec<Block>, lp: &[usize], x: f32, y0: f32, w: f32, ctx: &Ctx, ops: &mut Vec<Op>, slots: &mut Vec<(Rect, Vec<usize>, usize)>) -> f32 {
let mut y = y0;
if list.is_empty() {
slots.push((Rect::new(x, y, w, CH() * 1.6), lp.to_vec(), 0));
if !lp.is_empty() {
draw_rectangle_lines(x, y, CW() * 18.0, CH() * 1.4, 1.0, Color::new(0.4, 0.33, 0.25, 0.8));
gfx.text(&tr("ed.drop_here"), x + CW() * 0.5, y + CH() * 0.2, SMALL(), DIM);
}
return CH() * 1.7;
}
for k in 0..list.len() {
let mut path = lp.to_vec();
path.push(k);
let cat = list[k].category();
let col = cat_color(cat);
let header = Rect::new(x, y, w, CH() * ROW);
let problem = ctx.problems.iter().find(|(p, _)| *p == path).map(|(_, k)| *k);
let selected = self.selected.as_ref() == Some(&path);
let found = !ctx.search.is_empty() && (tr(&format!("blk.{}", list[k].kind_name())).to_lowercase().contains(&ctx.search) || block_text(&list[k]).to_lowercase().contains(&ctx.search));
// slots: upper half before this block, lower half after it
slots.push((Rect::new(x, y - CH() * 0.3, w, CH() * ROW * 0.5 + CH() * 0.3), lp.to_vec(), k));
slots.push((Rect::new(x, y + CH() * ROW * 0.5, w, CH() * ROW * 0.5), lp.to_vec(), k + 1));
let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. });
// the block: header row with its fields
draw_block_shape(header, col, selected, found);
if let Some(p) = problem {
draw_rectangle_lines(header.x - 2.0, header.y - 2.0, header.w + 4.0, header.h + 4.0, 3.0, RED);
im.tip(header, &tr(p));
}
// grip: drag the block from here
let grip = Rect::new(x, y, CW() * 1.4, CH() * ROW);
gfx.text("≡", x + CW() * 0.3, y + CH() * 0.6, S(), Color::new(1.0, 1.0, 1.0, 0.6));
if im.pressed && im.over(grip) {
im.pressed = false;
ops.push(Op::StartDrag(path.clone()));
}
let fy = y + (CH() * ROW - CH() * 1.5) * 0.5;
let mut cx = x + CW() * 1.8;
self.block_fields(im, gfx, &mut list[k], &path, &mut cx, fy, ctx);
// a click on the block's free area selects it
if im.clicked(header) {
ops.push(Op::Select(path.clone()));
}
// the delete cross
let xr = Rect::new(x + w - CW() * 2.0, y + CH() * 0.4, CW() * 1.6, CH() * 1.4);
if im.over(header) {
gfx.text("x", xr.x + CW() * 0.4, xr.y + CH() * 0.15, S(), TXT);
if im.clicked(xr) {
ops.push(Op::Remove(path.clone()));
}
self.help = tr(&format!("blk.help.{}", list[k].kind_name()));
}
y += CH() * ROW;
if nested {
// the C shape: bodies indented, with an "else" bar for if
let inner_x = x + CW() * 2.5;
let inner_w = w - CW() * 2.5;
let branches = if matches!(list[k], Block::If { .. }) { 2 } else { 1 };
for b in 0..branches {
let mut inner_lp = path.clone();
inner_lp.push(b);
let body: &mut Vec<Block> = match &mut list[k] {
Block::If { then, els, .. } => {
if b == 0 {
then
} else {
els
}
}
Block::Repeat { body, .. } => body,
_ => unreachable!(),
};
let start = y;
let hh = self.draw_list(im, gfx, body, &inner_lp, inner_x, y + CH() * 0.3, inner_w, ctx, ops, slots);
y += hh + CH() * 0.6;
draw_rectangle(x, start, CW() * 1.2, y - start, col);
if b == 0 && branches == 2 {
let er = Rect::new(x, y, w * 0.4, CH() * 1.4);
draw_rectangle(er.x, er.y, er.w, er.h, col);
gfx.text(&tr("blk.else"), er.x + CW() * 1.6, er.y + CH() * 0.2, S(), TXT);
y += CH() * 1.4;
}
}
draw_rectangle(x, y, w * 0.3, CH() * 0.6, col);
y += CH() * 0.6;
}
y += CH() * 0.25;
}
y - y0
}
// the fields inside a block's header row
#[allow(clippy::too_many_arguments)]
fn block_fields(&mut self, im: &mut Im, gfx: &Gfx, b: &mut Block, path: &[usize], cx: &mut f32, y: f32, ctx: &Ctx) {
let word = |cx: &mut f32, s: &str| {
gfx.text(s, *cx, y + CH() * 0.25, S(), TXT);
*cx += gfx.measure(s, S()) + CW() * 0.6;
};
let name = tr(&format!("blk.{}", b.kind_name()));
word(cx, &name);
let is_set = matches!(b, Block::SetVar { .. });
let fh = CH() * 1.5;
match b {
Block::Message { text } | Block::Objective { text } | Block::Hint { text } => {
im.text_field(gfx, fid(path, 0), Rect::new(*cx, y, CW() * 44.0, fh), text, 200);
*cx += CW() * 45.0;
}
Block::Spawn { side, kind, count, level, place, name } => {
self.value(im, gfx, count, Ty::Num, path, 1, cx, y, ctx);
word(cx, "×");
self.kind_dd(im, gfx, kind, false, path, 2, cx, y);
word(cx, &tr("blk.w_level"));
self.value(im, gfx, level, Ty::Num, path, 3, cx, y, ctx);
self.side_dd(im, gfx, side, false, path, 4, cx, y);
word(cx, &tr("blk.w_at"));
let mut opts = vec![tr("blk.place_core"), tr("blk.place_event")];
opts.extend(ctx.zones.iter().map(|z| trf("blk.place_zone", &[z.clone()])));
let cur = match place {
Place::Core => opts[0].clone(),
Place::Event => opts[1].clone(),
Place::Zone(z) => trf("blk.place_zone", &[z.clone()]),
};
if let Some(c) = im.dropdown(gfx, fid(path, 5), Rect::new(*cx, y, CW() * 16.0, fh), &cur, &opts) {
*place = match c {
0 => Place::Core,
1 => Place::Event,
c => Place::Zone(ctx.zones[c - 2].clone()),
};
}
*cx += CW() * 17.0;
word(cx, &tr("blk.w_name"));
im.text_field(gfx, fid(path, 6), Rect::new(*cx, y, CW() * 10.0, fh), name, 16);
*cx += CW() * 11.0;
}
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, cx, y, ctx),
Block::SetVar { name, value } | Block::AddVar { name, value } => {
im.text_field(gfx, fid(path, 0), Rect::new(*cx, y, CW() * 12.0, fh), name, 24);
*cx += CW() * 13.0;
word(cx, &tr(if is_set { "blk.w_to" } else { "blk.w_by" }));
self.value(im, gfx, value, Ty::Num, path, 1, cx, y, ctx);
}
Block::If { cond, .. } => self.value(im, gfx, cond, Ty::Bool, path, 1, cx, y, ctx),
Block::Repeat { times, .. } => {
self.value(im, gfx, times, Ty::Num, path, 1, cx, y, ctx);
word(cx, &tr("blk.w_times"));
}
Block::Win | Block::Lose => {}
Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx),
Block::Terrain { zone, kind } => {
self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx);
word(cx, &tr("blk.w_to"));
let opts: Vec<String> = TERRAIN.iter().map(|t| tr(&format!("ed.terrain.{}", t))).collect();
let cur = tr(&format!("ed.terrain.{}", kind));
if let Some(c) = im.dropdown(gfx, fid(path, 2), Rect::new(*cx, y, CW() * 14.0, fh), &cur, &opts) {
*kind = TERRAIN[c].to_string();
}
*cx += CW() * 15.0;
}
Block::Kill { zone, side } => {
self.side_dd(im, gfx, side, true, path, 2, cx, y);
word(cx, &tr("blk.w_in"));
self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx);
}
Block::Trigger { name, on } => {
let cur = if name.is_empty() { tr("ed.pick_trigger") } else { name.clone() };
if let Some(c) = im.dropdown(gfx, fid(path, 1), Rect::new(*cx, y, CW() * 16.0, fh), &cur, &ctx.triggers) {
*name = ctx.triggers[c].clone();
}
*cx += CW() * 17.0;
let opts = vec![tr("blk.on"), tr("blk.off")];
if let Some(c) = im.dropdown(gfx, fid(path, 2), Rect::new(*cx, y, CW() * 10.0, fh), &opts[if *on { 0 } else { 1 }], &opts) {
*on = c == 0;
}
*cx += CW() * 11.0;
}
}
}
#[allow(clippy::too_many_arguments)]
fn zone_dd(&mut self, im: &mut Im, gfx: &Gfx, zone: &mut String, path: &[usize], f: usize, cx: &mut f32, y: f32, ctx: &Ctx) {
let cur = if zone.is_empty() { tr("ed.pick_zone") } else { zone.clone() };
if let Some(c) = im.dropdown(gfx, fid(path, f), Rect::new(*cx, y, CW() * 14.0, CH() * 1.5), &cur, &ctx.zones) {
*zone = ctx.zones[c].clone();
}
*cx += CW() * 15.0;
}
#[allow(clippy::too_many_arguments)]
fn side_dd(&mut self, im: &mut Im, gfx: &Gfx, side: &mut i32, any: bool, path: &[usize], f: usize, cx: &mut f32, y: f32) {
let sides: Vec<i32> = SIDES.iter().copied().filter(|&s| any || s >= 0).collect();
let opts: Vec<String> = sides.iter().map(|&s| side_label(s)).collect();
if let Some(c) = im.dropdown(gfx, fid(path, f), Rect::new(*cx, y, CW() * 14.0, CH() * 1.5), &side_label(*side), &opts) {
*side = sides[c];
}
*cx += CW() * 15.0;
}
#[allow(clippy::too_many_arguments)]
fn kind_dd(&mut self, im: &mut Im, gfx: &Gfx, kind: &mut String, any: bool, path: &[usize], f: usize, cx: &mut f32, y: f32) {
let mut opts: Vec<String> = Vec::new();
if any {
opts.push(tr("ed.any_kind"));
}
opts.extend(KINDS.iter().map(|k| kind_label(k)));
if let Some(c) = im.dropdown(gfx, fid(path, f), Rect::new(*cx, y, CW() * 14.0, CH() * 1.5), &kind_label(kind), &opts) {
*kind = if any && c == 0 { String::new() } else { KINDS[c - any as usize].to_string() };
}
*cx += CW() * 15.0;
}
// a value slot: a small switch for the value's kind, then the value itself
#[allow(clippy::too_many_arguments)]
fn value(&mut self, im: &mut Im, gfx: &Gfx, v: &mut Val, want: Ty, path: &[usize], f: usize, cx: &mut f32, y: f32, ctx: &Ctx) {
let fh = CH() * 1.5;
let kinds: &[&str] = if want == Ty::Num { &NUM_VALUES } else { &BOOL_VALUES };
let opts: Vec<String> = kinds.iter().map(|k| tr(&format!("blk.v.{}", k))).collect();
// the kind switch
let sw_ = Rect::new(*cx, y, CW() * 1.6, fh);
draw_rectangle(sw_.x, sw_.y, sw_.w, sw_.h, Color::new(0.0, 0.0, 0.0, 0.35));
if let Some(c) = im.dropdown(gfx, fid(path, f * 31 + 1), sw_, "", &opts) {
if let Some(nv) = Val::template(kinds[c]) {
*v = nv;
}
}
*cx += CW() * 1.8;
let word = |cx: &mut f32, s: &str| {
gfx.text(s, *cx, y + CH() * 0.25, S(), TXT);
*cx += gfx.measure(s, S()) + CW() * 0.5;
};
let sub = |k: usize| f * 31 + 2 + k;
let and = matches!(v, Val::And { .. });
match v {
Val::Num { n } => {
let id_ = fid(path, f * 31);
let r = Rect::new(*cx, y, CW() * 8.0, fh);
let mut s = if im.focus == Some(id_) && self.num_focus == Some(id_) { self.num_buf.clone() } else { fmt_num(*n) };
if im.focus == Some(id_) && self.num_focus != Some(id_) {
self.num_focus = Some(id_);
self.num_buf = s.clone();
}
if im.text_field(gfx, id_, r, &mut s, 10) {
self.num_buf = s.clone();
if let Ok(x) = s.trim().replace(',', ".").parse::<f64>() {
*n = x;
}
}
*cx += CW() * 8.6;
}
Val::Bool { b } => {
let o = vec![tr("blk.yes"), tr("blk.no")];
if let Some(c) = im.dropdown(gfx, fid(path, sub(0)), Rect::new(*cx, y, CW() * 7.0, fh), &o[if *b { 0 } else { 1 }], &o) {
*b = c == 0;
}
*cx += CW() * 7.6;
}
Val::Var { name } => {
let cur = if name.is_empty() { tr("ed.pick_var") } else { name.clone() };
if let Some(c) = im.dropdown(gfx, fid(path, sub(0)), Rect::new(*cx, y, CW() * 12.0, fh), &cur, &ctx.vars) {
*name = ctx.vars[c].clone();
}
*cx += CW() * 12.6;
}
Val::Gold | Val::Mana | Val::Time => word(cx, &tr(&format!("blk.v.{}", v.kind_name()))),
Val::Creatures { side, kind } => {
word(cx, &tr("blk.v.creatures"));
self.kind_dd(im, gfx, kind, true, path, sub(0), cx, y);
self.side_dd(im, gfx, side, true, path, sub(1), cx, y);
}
Val::InZone { zone, side, kind } => {
word(cx, &tr("blk.v.inzone"));
self.kind_dd(im, gfx, kind, true, path, sub(0), cx, y);
self.side_dd(im, gfx, side, true, path, sub(1), cx, y);
word(cx, &tr("blk.w_in"));
self.zone_dd(im, gfx, zone, path, sub(2), cx, y, ctx);
}
Val::Random { a, b } => {
word(cx, &tr("blk.v.random"));
self.value(im, gfx, a, Ty::Num, path, sub(0), cx, y, ctx);
word(cx, "..");
self.value(im, gfx, b, Ty::Num, path, sub(1), cx, y, ctx);
}
Val::Math { op, a, b } | Val::Cmp { op, a, b } => {
let ops: Vec<String> = if matches!(v_kind_is_math(op), true) { MATH_OPS.iter().map(|s| s.to_string()).collect() } else { CMP_OPS.iter().map(|s| s.to_string()).collect() };
word(cx, "(");
self.value(im, gfx, a, Ty::Num, path, sub(0), cx, y, ctx);
if let Some(c) = im.dropdown(gfx, fid(path, sub(1)), Rect::new(*cx, y, CW() * 4.0, fh), op, &ops) {
*op = ops[c].clone();
}
*cx += CW() * 4.6;
self.value(im, gfx, b, Ty::Num, path, sub(2), cx, y, ctx);
word(cx, ")");
}
Val::And { a, b } | Val::Or { a, b } => {
word(cx, "(");
self.value(im, gfx, a, Ty::Bool, path, sub(0), cx, y, ctx);
word(cx, &tr(if and { "blk.and" } else { "blk.or" }));
self.value(im, gfx, b, Ty::Bool, path, sub(1), cx, y, ctx);
word(cx, ")");
}
Val::Not { a } => {
word(cx, &tr("blk.not"));
self.value(im, gfx, a, Ty::Bool, path, sub(0), cx, y, ctx);
}
Val::EventKind { kind } => {
word(cx, &tr("blk.v.eventkind"));
self.kind_dd(im, gfx, kind, false, path, sub(0), cx, y);
}
}
}
}
// math operators are + - * /, comparisons the rest
fn v_kind_is_math(op: &str) -> bool {
MATH_OPS.contains(&op)
}
fn fmt_num(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e12 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
// a block: a coloured plate with a notch on top and a tab below (like a puzzle piece)
fn draw_block_shape(r: Rect, col: Color, selected: bool, found: bool) {
draw_rectangle(r.x, r.y, r.w, r.h, col);
let dark = Color::new(col.r * 0.6, col.g * 0.6, col.b * 0.6, col.a);
let light = Color::new((col.r * 1.3).min(1.0), (col.g * 1.3).min(1.0), (col.b * 1.3).min(1.0), col.a);
draw_rectangle(r.x, r.y, r.w, 2.0, light);
draw_rectangle(r.x, r.y + r.h - 3.0, r.w, 3.0, dark);
// notch and tab
let nx = r.x + CW() * 2.0;
draw_rectangle(nx, r.y, CW() * 1.5, CH() * 0.25, Color::new(0.04, 0.03, 0.03, 1.0));
draw_rectangle(nx, r.y + r.h, CW() * 1.5, CH() * 0.25, col);
if selected {
draw_rectangle_lines(r.x - 1.0, r.y - 1.0, r.w + 2.0, r.h + 2.0, 3.0, FRAME_HI);
}
if found {
draw_rectangle_lines(r.x - 3.0, r.y - 3.0, r.w + 6.0, r.h + 6.0, 2.0, GOLD);
}
}
+796
View File
@@ -0,0 +1,796 @@
// The map editor: the mission's map in the game's 3D view, with a tool bar on the left.
// Terrain and rooms are painted (brush, rectangle, fill, mirrored), places, units, towers,
// traps, fortresses and rival masters are set with a click, trigger zones are drawn.
// "Done" stores the whole game state as the mission's fixed map.
use super::widgets::*;
use crate::campaign_doc::{MapDoc, MissionDoc, ZoneDoc};
use crate::game::*;
use crate::gfx::*;
use crate::i18n::{tr, trf};
use crate::map::*;
use crate::render3d::{line3, Cam, Overlay, R3d};
use crate::ui::*;
use macroquad::prelude::*;
#[allow(unused_imports)]
use crate::ui::{BLUE, GOLD, GREEN, RED};
pub enum MapResult {
Stay,
Done,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Cat {
Terrain,
Rooms,
Places,
Units,
Towers,
Traps,
Forts,
Zones,
Erase,
}
const CATS: [Cat; 9] = [Cat::Terrain, Cat::Rooms, Cat::Places, Cat::Units, Cat::Towers, Cat::Traps, Cat::Forts, Cat::Zones, Cat::Erase];
const TERRAIN: [&str; 13] = ["floor", "earth", "reinforced", "rock", "water", "lava", "bridge", "lavabridge", "gold", "silver", "ruby", "diamond", "steel"];
const PLACES: [&str; 7] = ["core", "portal", "gate", "spring", "shard", "chest", "chained"];
const OWNERS: [Owner; 6] = [Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3, Owner::None];
const ZONE_SHAPES: [&str; 4] = ["rect", "circle", "tiles", "line"];
const MAX_UNDO_BYTES: usize = 48 * 1024 * 1024;
pub struct MapEdit {
pub game: Game,
pub cam: Cam,
cat: Cat,
item: usize, // index into the category's list
owner: usize,
side: usize, // index into blocks::SIDES (units, rivals)
kind: usize, // index into blocks::KINDS
level: u32,
brush: i32,
rect: bool,
fill: bool,
mirror: u8, // 0 off, 1 left/right, 2 top/bottom, 3 both
drag: Option<(i32, i32)>,
stroke: bool, // painting: one undo step per stroke
undo: Vec<String>,
redo: Vec<String>,
zone: usize, // selected zone
zone_shape: usize, // shape for a new zone
zone_name: String,
last_mouse: Option<Vec2>,
}
fn kind_of_terrain(t: &str) -> (Kind, bool) {
match t {
"earth" => (Kind::Earth, false),
"reinforced" => (Kind::Earth, true),
"rock" => (Kind::Rock, false),
"water" => (Kind::Water, false),
"lava" => (Kind::Lava, false),
"bridge" => (Kind::Bridge, false),
"lavabridge" => (Kind::LavaBridge, false),
"gold" => (Kind::Gold, false),
"silver" => (Kind::Silver, false),
"ruby" => (Kind::Ruby, false),
"diamond" => (Kind::Diamond, false),
"steel" => (Kind::Steel, false),
_ => (Kind::Floor, false),
}
}
fn snapshot(g: &Game) -> String {
serde_json::to_string(g).unwrap_or_default()
}
impl MapEdit {
pub fn new(mut game: Game) -> MapEdit {
game.map.activate();
game.recount();
game.ents.retain(|e| e.alive);
let (cx, cy) = game.map.core;
let mut cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
cam.zoom = 28.0;
MapEdit {
game,
cam,
cat: Cat::Terrain,
item: 1,
owner: 0,
side: 2,
kind: 14,
level: 3,
brush: 1,
rect: false,
fill: false,
mirror: 0,
drag: None,
stroke: false,
undo: Vec::new(),
redo: Vec::new(),
zone: 0,
zone_shape: 0,
zone_name: String::new(),
last_mouse: None,
}
}
fn push_undo(&mut self) {
self.undo.push(snapshot(&self.game));
self.redo.clear();
while self.undo.iter().map(|s| s.len()).sum::<usize>() > MAX_UNDO_BYTES && self.undo.len() > 1 {
self.undo.remove(0);
}
}
fn restore(&mut self, json: &str) {
if let Some(g) = crate::campaign::load_fixed_map(json) {
self.game = g;
}
}
fn undo(&mut self) {
if let Some(s) = self.undo.pop() {
self.redo.push(snapshot(&self.game));
self.restore(&s);
}
}
fn redo(&mut self) {
if let Some(s) = self.redo.pop() {
self.undo.push(snapshot(&self.game));
self.restore(&s);
}
}
// the map as the mission's fixed map: fog, torches and counts brought up to date
pub fn store(&mut self, m: &mut MissionDoc) {
let g = &mut self.game;
g.state = 0;
g.time = 0.0;
g.msgs.clear();
g.recount();
g.place_all_torches();
g.init_fog();
m.size = (g.map.w, g.map.h);
m.map = MapDoc::Fixed { game: snapshot(g) };
}
// the tiles a click or drag paints (brush, rectangle; mirrored)
fn targets(&self, x: i32, y: i32) -> Vec<(i32, i32)> {
let mut out = Vec::new();
let (x0, y0, x1, y1) = match (self.rect, self.drag) {
(true, Some((sx, sy))) => (sx.min(x), sy.min(y), sx.max(x), sy.max(y)),
_ => {
let r = self.brush - 1;
(x - r / 2 - r % 2 * 0, y - r / 2, x + (r - r / 2), y + (r - r / 2))
}
};
for yy in y0..=y1 {
for xx in x0..=x1 {
out.push((xx, yy));
}
}
let (w, h) = (self.game.map.w as i32, self.game.map.h as i32);
let mut all = out.clone();
for &(xx, yy) in &out {
if self.mirror & 1 != 0 {
all.push((w - 1 - xx, yy));
}
if self.mirror & 2 != 0 {
all.push((xx, h - 1 - yy));
}
if self.mirror == 3 {
all.push((w - 1 - xx, h - 1 - yy));
}
}
all.retain(|&(a, b)| a > 0 && b > 0 && a < w - 1 && b < h - 1);
all.sort_unstable();
all.dedup();
all
}
fn paint_tile(&mut self, x: i32, y: i32) {
let i = idx(x, y);
let t = self.game.map.t[i];
if matches!(t.kind, Kind::Core | Kind::Portal | Kind::Gate | Kind::Tower) {
return; // places are moved or erased with their tools
}
match self.cat {
Cat::Terrain => {
let (k, reinforced) = kind_of_terrain(TERRAIN[self.item.min(TERRAIN.len() - 1)]);
self.game.torches.retain(|tr| tr.wall != i);
let owner = if k == Kind::Floor || reinforced || matches!(k, Kind::Bridge | Kind::LavaBridge) { OWNERS[self.owner] } else { Owner::None };
let tl = &mut self.game.map.t[i];
tl.kind = k;
tl.reinforced = reinforced;
tl.owner = if reinforced && owner == Owner::None { Owner::Player } else { owner };
tl.marked = false;
tl.gold = match k {
Kind::Gold => 1200,
Kind::Silver => 900,
Kind::Ruby => 1500,
Kind::Diamond => 2500,
_ => 0,
};
if let Some(e) = self.game.map.explored.get_mut(i) {
*e = true;
}
}
Cat::Rooms => {
if t.solid() || t.liquid() {
return;
}
let tl = &mut self.game.map.t[i];
if self.item == 0 {
tl.kind = Kind::Floor;
} else {
tl.kind = ROOM_KINDS[self.item - 1];
}
tl.owner = OWNERS[self.owner];
}
_ => {}
}
}
// fills the connected area of the same kind and owner as (x, y)
fn flood(&mut self, x: i32, y: i32) {
let start = self.game.map.t[idx(x, y)];
let mut stack = vec![(x, y)];
let mut seen = vec![false; self.game.map.t.len()];
let mut n = 0;
while let Some((cx, cy)) = stack.pop() {
if !inb(cx, cy) || cx == 0 || cy == 0 || cx >= self.game.map.w as i32 - 1 || cy >= self.game.map.h as i32 - 1 {
continue;
}
let i = idx(cx, cy);
let t = self.game.map.t[i];
if seen[i] || t.kind != start.kind || t.owner != start.owner {
continue;
}
seen[i] = true;
n += 1;
if n > 20000 {
break;
}
for (tx, ty) in self.targets(cx, cy).into_iter().take(1).chain(std::iter::once((cx, cy))) {
self.paint_tile(tx, ty);
}
for (dx, dy) in DIR4 {
stack.push((cx + dx, cy + dy));
}
}
}
fn clear_tile_objects(&mut self, i: usize) {
self.game.towers.retain(|t| t.tile != i);
self.game.traps.retain(|t| t.tile != i);
let (x, y) = xy(i);
self.game.ents.retain(|e| !(e.x as i32 == x && e.y as i32 == y));
}
// a click with a place, unit, tower, trap, fortress or eraser tool
fn place(&mut self, x: i32, y: i32) {
let i = idx(x, y);
let g = &mut self.game;
let owner = OWNERS[self.owner];
let side = crate::blocks::SIDES[self.side.min(5)].max(0);
match self.cat {
Cat::Places => match PLACES[self.item.min(PLACES.len() - 1)] {
"core" => {
let (ox, oy) = g.map.core;
for yy in oy..oy + 3 {
for xx in ox..ox + 3 {
let t = &mut g.map.t[idx(xx, yy)];
t.kind = Kind::Floor;
}
}
let (nx, ny) = (x - 1, y - 1);
for yy in ny - 2..ny + 5 {
for xx in nx - 2..nx + 5 {
if inb(xx, yy) {
let t = &mut g.map.t[idx(xx, yy)];
if yy >= ny && yy < ny + 3 && xx >= nx && xx < nx + 3 {
t.kind = Kind::Core;
} else if t.solid() || t.liquid() {
t.kind = Kind::Floor;
}
t.owner = Owner::Player;
}
}
}
g.map.core = (nx, ny);
}
"portal" => {
let (px, py) = (x - 1, y - 1);
for yy in py..py + 3 {
for xx in px..px + 3 {
let t = &mut g.map.t[idx(xx, yy)];
t.kind = Kind::Portal;
t.owner = Owner::None;
}
}
g.map.portals.push((px, py));
}
"gate" => {
let (ox, oy) = g.map.gate;
for yy in oy..oy + 3 {
for xx in ox..ox + 3 {
if inb(xx, yy) && g.map.t[idx(xx, yy)].kind == Kind::Gate {
g.map.t[idx(xx, yy)].kind = Kind::Floor;
}
}
}
let (gx, gy) = (x - 1, y - 1);
for yy in gy..gy + 3 {
for xx in gx..gx + 3 {
let t = &mut g.map.t[idx(xx, yy)];
t.kind = Kind::Gate;
t.owner = Owner::Hero;
}
}
g.map.gate = (gx, gy);
g.gate_hp = GATE_HP;
g.gate_on = true;
}
"spring" => {
g.map.t[i].kind = Kind::ManaSpring;
g.map.t[i].owner = Owner::None;
}
"shard" => {
g.map.t[i].kind = Kind::Shard;
}
"chest" => {
g.map.t[i].kind = Kind::RuneChest;
g.map.t[i].owner = Owner::None;
}
_ => {
let k = crate::mscript::kind_by_name(crate::blocks::KINDS[self.kind]).unwrap_or(EK::Ogre);
let id = g.spawn(k, false, x as f32 + 0.5, y as f32 + 0.5, self.level);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.chained = true;
}
}
},
Cat::Units => {
if !g.map.t[i].walkable() {
return;
}
let k = crate::mscript::kind_by_name(crate::blocks::KINDS[self.kind]).unwrap_or(EK::Knight);
g.spawn_for_side(side, k, x as f32 + 0.5, y as f32 + 0.5, self.level);
}
Cat::Towers => {
if g.map.t[i].solid() || g.map.t[i].liquid() {
return;
}
let kind = [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic, TowerKind::Rune][self.item.min(3)];
let hero = owner == Owner::Hero;
self.clear_tile_objects(i);
let g = &mut self.game;
g.map.t[i].kind = Kind::Tower;
g.map.t[i].owner = if hero { Owner::Hero } else { Owner::Player };
let s = tower_stats(kind);
let id = g.next_id;
g.next_id += 1;
let mut tw = Tower { id, kind, tile: i, level: self.level.clamp(1, 3), hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero, rune_final: false };
tw.hp = tw.max_hp();
g.towers.push(tw);
}
Cat::Traps => {
if !g.map.t[i].walkable() || g.traps.iter().any(|t| t.tile == i) {
return;
}
use crate::traps::*;
let kinds = [TrapKind::Spikes, TrapKind::Poison, TrapKind::Fire, TrapKind::Roller, TrapKind::Alarm, TrapKind::Mine, TrapKind::Frost, TrapKind::WoodDoor, TrapKind::IronDoor, TrapKind::MagicDoor];
let kind = kinds[self.item.min(kinds.len() - 1)];
let id = g.next_id;
g.next_id += 1;
if kind.door() {
g.map.t[i].kind = Kind::Door;
}
g.traps.push(Trap { id, kind, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0, revealed: false, sprung: false });
}
Cat::Forts => {
if self.item == 0 || self.item == 1 {
crate::campaign::editor_fort(g, x, y, self.level.clamp(1, 3), self.item == 1);
} else {
let s = (self.item as u8).clamp(2, 4);
g.add_rival(s, x, y, self.level.clamp(1, 3) as u8 - 1);
}
g.recount();
}
Cat::Erase => {
self.clear_tile_objects(i);
let g = &mut self.game;
if g.map.t[i].kind == Kind::Tower {
g.map.t[i].kind = Kind::Floor;
}
if g.map.t[i].kind == Kind::Door {
g.map.t[i].kind = Kind::Floor;
}
}
_ => {}
}
}
// a zone drawn from (sx, sy) to (x, y)
fn draw_zone(&mut self, m: &mut MissionDoc, sx: i32, sy: i32, x: i32, y: i32) {
let shape = ZONE_SHAPES[self.zone_shape];
let mut name = self.zone_name.trim().to_string();
if name.is_empty() || m.zones.iter().any(|z| z.name == name) && shape != "tiles" {
let mut n = m.zones.len() + 1;
while m.zones.iter().any(|z| z.name == format!("zone{}", n)) {
n += 1;
}
name = format!("zone{}", n);
}
let mut z = ZoneDoc { name: name.clone(), shape: shape.into(), at: None, radius: None, tiles: None, from: None, to: None, width: None };
match shape {
"rect" => {
z.from = Some((sx.min(x) as f32, sy.min(y) as f32));
z.to = Some((sx.max(x) as f32, sy.max(y) as f32));
}
"circle" => {
z.at = Some((sx as f32, sy as f32));
z.radius = Some((((x - sx).pow(2) + (y - sy).pow(2)) as f32).sqrt().max(1.0));
}
"line" => {
z.from = Some((sx as f32, sy as f32));
z.to = Some((x as f32, y as f32));
}
_ => {
// painted tiles: add to the selected tiles zone, or start one
if let Some(old) = m.zones.get_mut(self.zone).filter(|z| z.shape == "tiles") {
let t = old.tiles.get_or_insert_with(Vec::new);
for (tx, ty) in self.targets(x, y) {
if !t.contains(&(tx, ty)) {
t.push((tx, ty));
}
}
return;
}
z.tiles = Some(self.targets(x, y));
}
}
m.zones.push(z);
self.zone = m.zones.len() - 1;
self.zone_name.clear();
}
pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64, m: &mut MissionDoc) -> MapResult {
let (cssx, cssy) = mouse_position();
let bar_w = CW() * 30.0;
let over_ui = im.mx < bar_w || im.my < CH() * 3.0 || im.popup_open();
// camera: keys, wheel, right button drag pans, middle drag turns
if !im.typing {
let (ax, az) = self.cam.ground_axes();
let sp = self.cam.zoom * 0.9 * dt;
let mut mv = vec2(0.0, 0.0);
if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) {
mv += az;
}
if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) {
mv -= az;
}
if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) {
mv -= ax;
}
if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) {
mv += ax;
}
self.cam.tx += mv.x * sp;
self.cam.tz += mv.y * sp;
if is_key_down(KeyCode::Q) {
self.cam.yaw -= 1.5 * dt;
}
if is_key_down(KeyCode::E) {
self.cam.yaw += 1.5 * dt;
}
if im.ctrl && is_key_pressed(KeyCode::Z) {
self.undo();
}
if im.ctrl && is_key_pressed(KeyCode::Y) {
self.redo();
}
}
if !over_ui && im.wheel != 0.0 {
self.cam.zoom = (self.cam.zoom * if im.wheel > 0.0 { 0.88 } else { 1.0 / 0.88 }).clamp(6.0, 90.0);
}
let p = vec2(cssx, cssy);
if is_mouse_button_down(MouseButton::Right) || is_mouse_button_down(MouseButton::Middle) {
if let Some(last) = self.last_mouse {
if is_mouse_button_down(MouseButton::Middle) {
self.cam.yaw -= (p.x - last.x) * 0.008;
self.cam.pitch = (self.cam.pitch + (p.y - last.y) * 0.005).clamp(0.35, 1.45);
} else {
let a = self.cam.ground_point(last.x, last.y);
let b = self.cam.ground_point(p.x, p.y);
self.cam.tx -= b.x - a.x;
self.cam.tz -= b.y - a.y;
}
}
self.last_mouse = Some(p);
} else {
self.last_mouse = None;
}
let (w, h) = (self.game.map.w as f32, self.game.map.h as f32);
self.cam.tx = self.cam.tx.clamp(0.0, w);
self.cam.tz = self.cam.tz.clamp(0.0, h);
self.cam.update_render(dt);
// the tile under the pointer
let hover = if over_ui { None } else { self.cam.pick_tile(&self.game.map, cssx, cssy).map(|(x, y, _)| (x, y)).filter(|&(x, y)| inb(x, y)) };
// painting and placing
let paints = matches!(self.cat, Cat::Terrain | Cat::Rooms);
if let Some((x, y)) = hover {
if im.pressed {
self.push_undo();
if self.cat == Cat::Zones || (paints && self.rect) {
self.drag = Some((x, y));
} else if paints && self.fill {
self.flood(x, y);
} else if paints {
self.stroke = true;
} else {
self.place(x, y);
// mirrored: the same placement on the other side(s)
let mirrored: Vec<(i32, i32)> = if self.mirror != 0 && self.brush == 1 { self.targets(x, y).into_iter().filter(|&p| p != (x, y)).collect() } else { Vec::new() };
for (tx, ty) in mirrored {
self.place(tx, ty);
}
}
im.pressed = false;
}
if self.stroke && im.down {
for (tx, ty) in self.targets(x, y) {
self.paint_tile(tx, ty);
}
}
if im.released {
if let Some((sx, sy)) = self.drag {
if self.cat == Cat::Zones {
self.draw_zone(m, sx, sy, x, y);
} else {
for (tx, ty) in self.targets(x, y) {
self.paint_tile(tx, ty);
}
}
}
}
}
if !im.down {
if self.stroke || self.drag.is_some() {
self.game.recount();
}
self.stroke = false;
self.drag = None;
}
// the 3D view
let rect = match (hover, self.drag) {
(Some((x, y)), Some((sx, sy))) if self.cat != Cat::Zones || ZONE_SHAPES[self.zone_shape] == "rect" => Some((sx, sy, x, y)),
_ => None,
};
let ov = Overlay { hover, rect, color: Color::new(0.4, 0.85, 1.0, 1.0), radius: None, high: matches!(self.cat, Cat::Terrain) };
r3d.draw(&self.game, gfx, &self.cam, now, &ov);
self.draw_zones(m, hover, now);
begin_ui();
let done = self.tool_bar(im, gfx, m, bar_w);
// the zone being drawn: a hint
if let (Some((x, y)), Some((sx, sy))) = (hover, self.drag) {
if self.cat == Cat::Zones {
let t = format!("({},{}) - ({},{})", sx, sy, x, y);
gfx.text(&t, im.mx + CW() * 2.0, im.my + CH(), SMALL(), GOLD);
}
}
if let Some((x, y)) = hover {
gfx.text(&format!("{}, {}", x, y), sw() - CW() * 10.0, sh() - CH() * 1.5, SMALL(), DIM);
}
if done {
self.store(m);
MapResult::Done
} else {
MapResult::Stay
}
}
// trigger zones in the 3D view: outlines, the selected one bright
fn draw_zones(&self, m: &MissionDoc, hover: Option<(i32, i32)>, now: f64) {
set_camera(&self.cam.camera());
let pulse = 0.6 + 0.4 * (now as f32 * 3.0).sin();
for (k, z) in m.zones.iter().enumerate() {
let sel = k == self.zone && self.cat == Cat::Zones;
let c = if sel { Color::new(1.0, 0.85, 0.3, pulse) } else { Color::new(0.4, 0.85, 1.0, 0.7) };
let y = 0.08;
match z.shape.as_str() {
"rect" => {
if let (Some(a), Some(b)) = (z.from, z.to) {
let (x0, z0, x1, z1) = (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0) + 1.0, a.1.max(b.1) + 1.0);
for (p, q) in [((x0, z0), (x1, z0)), ((x1, z0), (x1, z1)), ((x1, z1), (x0, z1)), ((x0, z1), (x0, z0))] {
line3(vec3(p.0, y, p.1), vec3(q.0, y, q.1), 0.08, c);
}
}
}
"circle" => {
let (cx, cz) = z.at.unwrap_or((0.0, 0.0));
let r = z.radius.unwrap_or(2.0);
for s in 0..32 {
let a0 = s as f32 / 32.0 * std::f32::consts::TAU;
let a1 = (s + 1) as f32 / 32.0 * std::f32::consts::TAU;
line3(vec3(cx + 0.5 + a0.cos() * r, y, cz + 0.5 + a0.sin() * r), vec3(cx + 0.5 + a1.cos() * r, y, cz + 0.5 + a1.sin() * r), 0.08, c);
}
}
"line" => {
if let (Some(a), Some(b)) = (z.from, z.to) {
line3(vec3(a.0 + 0.5, y + 0.05, a.1 + 0.5), vec3(b.0 + 0.5, y + 0.05, b.1 + 0.5), 0.14, c);
}
}
_ => {
for i in crate::mscript::zone_tiles(z) {
let (x, zz) = xy(i);
let (x, zz) = (x as f32, zz as f32);
draw_cube(vec3(x + 0.5, y, zz + 0.5), vec3(0.9, 0.02, 0.9), None, Color::new(c.r, c.g, c.b, 0.35));
}
}
}
}
let _ = hover;
}
// the tool bar on the left and the options bar on top; true: done
fn tool_bar(&mut self, im: &mut Im, gfx: &Gfx, m: &mut MissionDoc, bar_w: f32) -> bool {
let h = sh();
panel(gfx, Rect::new(0.0, 0.0, bar_w, h), "");
panel(gfx, Rect::new(bar_w, 0.0, sw() - bar_w, CH() * 3.0), "");
let mut done = false;
let x = CW();
let mut y = CH() * 0.6;
if im.button_c(gfx, Rect::new(x, y, bar_w - CW() * 2.0, CH() * 1.7), &tr("ed.map_done"), GREEN) {
done = true;
}
y += CH() * 2.3;
// categories as a grid of buttons
let cw = (bar_w - CW() * 2.5) / 3.0;
for (k, c) in CATS.iter().enumerate() {
let r = Rect::new(x + (k % 3) as f32 * (cw + CW() * 0.25), y + (k / 3) as f32 * CH() * 1.9, cw, CH() * 1.7);
let name = tr(&format!("ed.cat.{:?}", c).to_lowercase());
if im.select(gfx, r, &name, self.cat == *c, TXT) {
self.cat = *c;
self.item = 0;
}
}
y += CH() * 6.0;
// the items of the category
let items: Vec<String> = match self.cat {
Cat::Terrain => TERRAIN.iter().map(|t| tr(&format!("ed.terrain.{}", t))).collect(),
Cat::Rooms => std::iter::once(tr("ed.terrain.floor")).chain(ROOM_KINDS.iter().map(|k| tr(&format!("room.{}", format!("{:?}", k).to_lowercase())))).collect(),
Cat::Places => PLACES.iter().map(|p| tr(&format!("ed.place.{}", p))).collect(),
Cat::Towers => ["arrow", "cannon", "magic", "rune"].iter().map(|t| tr(&format!("ed.tower.{}", t))).collect(),
Cat::Traps => ["spikes", "poison", "fire", "roller", "alarm", "mine", "frost", "wooddoor", "irondoor", "magicdoor"].iter().map(|t| tr(&format!("ed.trap.{}", t))).collect(),
Cat::Forts => vec![tr("ed.fort"), tr("ed.fort_shard"), tr("ed.side.mossgrim"), tr("ed.side.nightwell"), tr("ed.side.rival3")],
_ => Vec::new(),
};
let row = CH() * 1.5;
for (k, it) in items.iter().enumerate() {
let r = Rect::new(x, y + k as f32 * row, bar_w - CW() * 2.0, row - 2.0);
if im.select(gfx, r, it, self.item == k, TXT) {
self.item = k;
}
}
y += items.len() as f32 * row + CH() * 0.8;
// what the tool needs: owner, side, kind, level
let fw = bar_w - CW() * 2.0;
let needs_owner = matches!(self.cat, Cat::Terrain | Cat::Rooms | Cat::Towers);
if needs_owner {
label(gfx, x, y, &tr("ed.owner"), DIM);
y += CH() * 1.2;
let owners: Vec<String> = [0, 1, 2, 3, 4, -1].iter().map(|&s| super::side_label(s)).collect();
if let Some(c) = im.dropdown(gfx, id("mowner", 0), Rect::new(x, y, fw, CH() * 1.6), &owners[self.owner], &owners) {
self.owner = c;
}
y += CH() * 2.1;
}
if self.cat == Cat::Units {
label(gfx, x, y, &tr("ed.side"), DIM);
y += CH() * 1.2;
let sides: Vec<String> = crate::blocks::SIDES.iter().skip(1).map(|&s| super::side_label(s)).collect();
let cur = self.side.max(1) - 1;
if let Some(c) = im.dropdown(gfx, id("mside", 0), Rect::new(x, y, fw, CH() * 1.6), &sides[cur.min(sides.len() - 1)], &sides) {
self.side = c + 1;
}
y += CH() * 2.1;
}
if self.cat == Cat::Units || (self.cat == Cat::Places && PLACES[self.item.min(PLACES.len() - 1)] == "chained") {
label(gfx, x, y, &tr("ed.unit_kind"), DIM);
y += CH() * 1.2;
let kinds: Vec<String> = crate::blocks::KINDS.iter().map(|k| super::kind_label(k)).collect();
if let Some(c) = im.dropdown(gfx, id("mkind", 0), Rect::new(x, y, fw, CH() * 1.6), &kinds[self.kind], &kinds) {
self.kind = c;
}
y += CH() * 2.1;
}
if matches!(self.cat, Cat::Units | Cat::Towers | Cat::Forts | Cat::Places) {
label(gfx, x, y, &tr("ed.level"), DIM);
y += CH() * 1.2;
let max = if matches!(self.cat, Cat::Towers | Cat::Forts) { 3 } else { 10 };
let d = im.stepper(gfx, Rect::new(x, y, fw, CH() * 1.6), &self.level.min(max).to_string());
self.level = (self.level.min(max) as i32 + d.signum()).clamp(1, max as i32) as u32;
y += CH() * 2.1;
}
if self.cat == Cat::Zones {
label(gfx, x, y, &tr("ed.zone_shape"), DIM);
y += CH() * 1.2;
let shapes: Vec<String> = ZONE_SHAPES.iter().map(|s| tr(&format!("ed.shape.{}", s))).collect();
if let Some(c) = im.dropdown(gfx, id("zshape", 0), Rect::new(x, y, fw, CH() * 1.6), &shapes[self.zone_shape], &shapes) {
self.zone_shape = c;
}
y += CH() * 2.1;
label(gfx, x, y, &tr("ed.zone_new_name"), DIM);
y += CH() * 1.2;
im.text_field(gfx, id("zname", 0), Rect::new(x, y, fw, CH() * 1.6), &mut self.zone_name, 20);
y += CH() * 2.3;
label(gfx, x, y, &tr("ed.zones"), FRAME_HI);
y += CH() * 1.4;
let mut remove = None;
for k in 0..m.zones.len() {
if y > h - CH() * 3.0 {
break;
}
let r = Rect::new(x, y, fw - CW() * 3.5, CH() * 1.5);
if self.zone == k {
im.text_field(gfx, id("zrename", k), r, &mut m.zones[k].name, 20);
} else if im.select(gfx, r, &format!("{} ({})", m.zones[k].name, tr(&format!("ed.shape.{}", m.zones[k].shape))), false, TXT) {
self.zone = k;
}
if im.button_c(gfx, Rect::new(x + fw - CW() * 3.0, y, CW() * 3.0, CH() * 1.5), "x", RED) {
remove = Some(k);
}
y += CH() * 1.7;
}
if let Some(k) = remove {
m.zones.remove(k);
self.zone = self.zone.min(m.zones.len().saturating_sub(1));
}
}
// the options bar
let mut bx = bar_w + CW();
let by = CH() * 0.6;
if matches!(self.cat, Cat::Terrain | Cat::Rooms | Cat::Zones) {
label(gfx, bx, by + CH() * 0.3, &tr("ed.brush"), DIM);
bx += gfx.measure(&tr("ed.brush"), S()) + CW();
let d = im.stepper(gfx, Rect::new(bx, by, CW() * 9.0, CH() * 1.7), &self.brush.to_string());
self.brush = (self.brush + d.signum()).clamp(1, 5);
bx += CW() * 10.0;
}
if matches!(self.cat, Cat::Terrain | Cat::Rooms) {
if im.toggle(gfx, bx, by + CH() * 0.15, &tr("ed.rect"), &mut self.rect) && self.rect {
self.fill = false;
}
bx += gfx.measure(&tr("ed.rect"), S()) + CW() * 4.0;
if im.toggle(gfx, bx, by + CH() * 0.15, &tr("ed.fill"), &mut self.fill) && self.fill {
self.rect = false;
}
bx += gfx.measure(&tr("ed.fill"), S()) + CW() * 4.0;
}
let mirrors: Vec<String> = (0..4).map(|k| tr(&format!("ed.mirror{}", k))).collect();
if let Some(c) = im.dropdown(gfx, id("mirror", 0), Rect::new(bx, by, CW() * 18.0, CH() * 1.7), &mirrors[self.mirror as usize], &mirrors) {
self.mirror = c as u8;
}
bx += CW() * 19.0;
if im.button(gfx, Rect::new(bx, by, CW() * 8.0, CH() * 1.7), &tr("ed.undo")) {
self.undo();
}
bx += CW() * 8.5;
if im.button(gfx, Rect::new(bx, by, CW() * 8.0, CH() * 1.7), &tr("ed.redo")) {
self.redo();
}
bx += CW() * 9.0;
let info = trf("ed.map_info", &[self.game.map.w.to_string(), self.game.map.h.to_string(), self.game.ents.iter().filter(|e| e.alive).count().to_string(), self.game.towers.len().to_string()]);
gfx.text(&info, bx, by + CH() * 0.3, SMALL(), DIM);
gfx.text(&tr("ed.map_hint"), bar_w + CW(), sh() - CH() * 1.5, SMALL(), DIM);
done
}
}
+1012
View File
File diff suppressed because it is too large. Load diff
+238
View File
@@ -0,0 +1,238 @@
// Ready-made content for the editor: missions of the built-in campaign as copies (map, texts,
// goals) and the demo campaign that shows every trigger and every kind of block.
use crate::blocks::{Block, Place, Val};
use crate::campaign_doc::*;
use crate::game::Goal;
use crate::i18n::tr;
fn goal_string(g: &Goal) -> String {
match g {
Goal::Shards => "Shards".into(),
Goal::Heart => "Heart".into(),
Goal::KillAll => "KillAll".into(),
Goal::Towers => "Towers".into(),
Goal::Rivals => "Rivals".into(),
Goal::Loot(n) => format!("Loot({})", n),
Goal::Survive(n) => format!("Survive({})", n),
Goal::Springs(n) => format!("Springs({})", n),
Goal::Freed(n) => format!("Freed({})", n),
}
}
fn free_key(doc: &CampaignDoc, base: &str) -> String {
let mut k = base.to_string();
let mut n = 2;
while doc.missions.iter().any(|m| m.key == k) {
k = format!("{}_{}", base, n);
n += 1;
}
k
}
// mission i of the built-in campaign as an editor mission (its map fixed as it starts)
pub fn builtin_mission(i: usize, doc: &CampaignDoc) -> MissionDoc {
let defs = crate::campaign::missions();
let d = &defs[i.min(defs.len() - 1)];
let mut g = crate::campaign::start_mission(i.min(defs.len() - 1), None);
g.msgs.clear();
let game = serde_json::to_string(&g).unwrap_or_default();
MissionDoc {
key: free_key(doc, &d.key),
title: tr(&format!("camp.{}.title", d.key)),
story: tr(&format!("camp.{}.story", d.key)).replace(" | ", "\n\n"),
size: d.size,
seed: d.seed,
gold: d.gold,
portals: d.portals,
treasures: d.treasures,
goals: d.goals.iter().map(goal_string).collect(),
waves: d.waves,
wave_limit: d.wave_limit,
first_wave: d.first_wave,
time_limit: d.time_limit,
map: MapDoc::Fixed { game },
..MissionDoc::default()
}
}
fn zone_rect(name: &str, x0: i32, y0: i32, x1: i32, y1: i32) -> ZoneDoc {
ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some((x0 as f32, y0 as f32)), to: Some((x1 as f32, y1 as f32)), width: None }
}
fn n(x: f64) -> Val {
Val::num(x)
}
fn var(name: &str) -> Val {
Val::Var { name: name.into() }
}
fn cmp(op: &str, a: Val, b: Val) -> Val {
Val::Cmp { op: op.into(), a: Box::new(a), b: Box::new(b) }
}
fn say(t: &str) -> Block {
Block::Message { text: tr(t) }
}
fn trig(name: &str, kind: TriggerKind, once: bool, script: Vec<Block>) -> TriggerDoc {
TriggerDoc { name: name.into(), kind, once, enabled: true, script }
}
// the demo campaign: one painted mission with every trigger kind and every special script,
// and a random-map mission behind a branch
pub fn demo_campaign() -> CampaignDoc {
let mut doc = CampaignDoc { name: tr("demo.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
// mission 1: a generated map, fixed, with zones around the dungeon's heart
let mut m = MissionDoc {
key: "demo1".into(),
title: tr("demo.m1.title"),
story: tr("demo.m1.story"),
size: (80, 64),
seed: 4242,
gold: 4000.0,
goals: vec!["Towers".into()],
gen: GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 0 },
next: Some("demo2".into()),
branches: vec![BranchDoc { id: "rich".into(), goal: "Loot(2000)".into(), title: tr("demo.branch"), next: "demo2".into(), priority: Some(1) }],
pos: (40.0, 60.0),
..MissionDoc::default()
};
let saved = (crate::map::mw(), crate::map::mh());
let mut g = crate::campaign::start_editor_mission(&m, 0, 4242);
let (cx, cy) = g.map.core;
let (w, h) = (g.map.w as i32, g.map.h as i32);
let cl = |x: i32, y: i32| (x.clamp(2, w - 3), y.clamp(2, h - 3));
let (ax, ay) = cl(cx + 7, cy - 2);
let (bx, by) = cl(cx + 10, cy + 2);
let vault = zone_rect("vault", ax, ay, bx, by);
let (dx, dy) = cl(cx - 2, cy + 6);
let den = ZoneDoc { name: "den".into(), shape: "circle".into(), at: Some((dx as f32, dy as f32)), radius: Some(2.5), tiles: None, from: None, to: None, width: None };
let (lx0, ly0) = cl(cx + 14, cy - 8);
let (lx1, ly1) = cl(cx + 14, cy + 8);
let line = ZoneDoc { name: "border".into(), shape: "line".into(), at: None, radius: None, tiles: None, from: Some((lx0 as f32, ly0 as f32)), to: Some((lx1 as f32, ly1 as f32)), width: None };
let (gx0, gy0) = cl(cx + 16, cy - 5);
let (gx1, gy1) = cl(cx + 22, cy + 5);
let guard = zone_rect("field", gx0, gy0, gx1, gy1);
let dig_tiles: Vec<(i32, i32)> = (0..3).flat_map(|k| [(cx - 6 - k, cy), (cx - 6 - k, cy + 1)]).map(|(x, y)| cl(x, y)).collect();
let dig = ZoneDoc { name: "tunnel".into(), shape: "tiles".into(), at: None, radius: None, tiles: Some(dig_tiles.clone()), from: None, to: None, width: None };
// the den is open ground, the tunnel solid earth to dig
for i in crate::mscript::zone_tiles(&den) {
if g.map.t[i].solid() {
g.dig_out(i);
}
}
for &(x, y) in &dig_tiles {
let t = &mut g.map.t[crate::map::idx(x, y)];
t.kind = crate::map::Kind::Earth;
t.owner = crate::map::Owner::None;
}
g.recount();
g.init_fog();
m.zones = vec![vault, den, line, guard, dig];
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
crate::map::set_dims(saved.0, saved.1);
m.goals = vec!["Towers".into()];
let s = &mut m.scripts;
s.start = vec![
say("demo.start"),
Block::Objective { text: tr("demo.objective") },
Block::SetVar { name: "kills".into(), value: n(0.0) },
Block::SetVar { name: "ticks".into(), value: n(0.0) },
Block::SetVar { name: "noted".into(), value: n(0.0) },
Block::Terrain { zone: "vault".into(), kind: "floor".into() },
Block::Reveal { zone: "vault".into() },
Block::Spawn { side: 1, kind: "Knight".into(), count: n(2.0), level: n(2.0), place: Place::Zone("field".into()), name: "captain".into() },
];
s.victory = vec![Block::If { cond: cmp(">=", var("kills"), n(8.0)), then: vec![say("demo.win"), Block::Win], els: vec![] }];
s.defeat = vec![Block::If {
cond: Val::And { a: Box::new(cmp("=", Val::Creatures { side: 0, kind: String::new() }, n(0.0))), b: Box::new(cmp(">", Val::Time, n(600.0))) },
then: vec![say("demo.lose"), Block::Lose],
els: vec![],
}];
s.tick = vec![Block::If {
cond: Val::And { a: Box::new(cmp(">=", var("ticks"), n(4.0))), b: Box::new(cmp("=", var("noted"), n(0.0))) },
then: vec![say("demo.tick"), Block::SetVar { name: "noted".into(), value: n(1.0) }],
els: vec![],
}];
s.triggers = vec![
trig("den_enter", TriggerKind::ZoneEnter { zone: "den".into(), side: 0, kind: String::new() }, true, vec![say("demo.den"), Block::Spawn { side: 0, kind: "Ogre".into(), count: n(1.0), level: n(3.0), place: Place::Event, name: String::new() }]),
trig("den_leave", TriggerKind::ZoneLeave { zone: "den".into(), side: 0, kind: String::new() }, true, vec![say("demo.den_leave")]),
trig("border", TriggerKind::LineCross { zone: "border".into(), side: 1, kind: String::new() }, true, vec![Block::Hint { text: tr("demo.border") }, Block::Camera { zone: "border".into() }]),
trig("vault_claimed", TriggerKind::ZoneClaimed { zone: "vault".into() }, true, vec![say("demo.vault"), Block::Gold { amount: n(500.0) }]),
trig("tunnel_dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("demo.tunnel")]),
trig("crowd", TriggerKind::ZoneCount { zone: "field".into(), side: 1, kind: String::new(), op: ">=".into(), n: 4.0 }, true, vec![say("demo.crowd"), Block::Camera { zone: "field".into() }]),
trig("reinforcements", TriggerKind::Timer { secs: 90.0, every: false }, true, vec![say("demo.wave"), Block::Spawn { side: 1, kind: "Archer".into(), count: n(2.0), level: n(2.0), place: Place::Zone("field".into()), name: String::new() }]),
trig("clock", TriggerKind::Timer { secs: 30.0, every: true }, false, vec![Block::AddVar { name: "ticks".into(), value: n(1.0) }]),
trig("knight_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "kills".into(), value: n(1.0) }, Block::Message { text: tr("demo.kills") }]),
trig("captain_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: "captain".into() }, true, vec![say("demo.captain"), Block::Mana { amount: n(50.0) }]),
trig("tower_down", TriggerKind::TowerFalls { side: 1 }, false, vec![say("demo.tower"), Block::Gold { amount: Val::Random { a: Box::new(n(100.0)), b: Box::new(n(300.0)) } }]),
trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 8000.0 }, true, vec![say("demo.rich")]),
trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![Block::Hint { text: tr("demo.lair") }]),
trig("spell", TriggerKind::SpellCast { spell: String::new() }, false, vec![Block::Mana { amount: n(10.0) }]),
trig("slap", TriggerKind::Slap, true, vec![say("demo.slap")]),
trig("research", TriggerKind::Research, true, vec![say("demo.research"), Block::Repeat { times: n(2.0), body: vec![Block::Gold { amount: n(100.0) }] }]),
];
doc.missions.push(m);
// mission 2: a random map, rolled once per run of the campaign
let mut m2 = MissionDoc {
key: "demo2".into(),
title: tr("demo.m2.title"),
story: tr("demo.m2.story"),
size: (72, 64),
seed: 77,
gold: 3000.0,
goals: vec!["KillAll".into()],
gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 0, difficulty: 0 },
pos: (300.0, 60.0),
..MissionDoc::default()
};
m2.scripts.start = vec![say("demo.m2.start")];
m2.scripts.triggers = vec![trig("half_time", TriggerKind::Timer { secs: 120.0, every: false }, true, vec![Block::Wave { count: n(1.0) }, say("demo.m2.wave")])];
doc.missions.push(m2);
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_demo_campaign_is_valid_and_plays() {
let doc = demo_campaign();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
let kinds: Vec<&str> = doc.missions[0].scripts.triggers.iter().map(|t| t.kind.kind_name()).collect();
for k in TRIGGER_KINDS {
assert!(kinds.contains(&k), "the demo shows the trigger {}", k);
}
// it starts on its painted map and the scripts run without errors
let mut g = crate::campaign::start_editor_mission(&doc.missions[0], 0, 99);
assert!(g.script.is_some());
for _ in 0..(20 * 40) {
g.update(0.05);
}
let s = g.script.as_ref().unwrap();
assert!(s.error.is_none(), "{:?}", s.error);
assert!(s.vars.contains_key("kills"), "the start script ran");
// the random-map mission starts too
let g2 = crate::campaign::start_editor_mission(&doc.missions[1], 1, 99);
assert!(g2.mission.is_some());
// the file round-trips
let back = CampaignDoc::load(&doc.save()).unwrap();
assert_eq!(back.missions.len(), 2);
assert_eq!(back.missions[0].scripts, doc.missions[0].scripts);
}
#[test]
fn built_in_missions_open_as_copies() {
let doc = CampaignDoc::default();
let m = builtin_mission(0, &doc);
assert!(matches!(m.map, MapDoc::Fixed { .. }));
assert!(!m.goals.is_empty());
let g = crate::campaign::start_editor_mission(&m, 0, 1);
assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there");
}
}
+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);
}
+172 -8
View File
@@ -6,6 +6,7 @@ mod campaign_doc;
mod cloud;
mod commands;
mod demo;
mod editor;
mod dwarves;
mod fog;
mod fortress;
@@ -190,11 +191,6 @@ fn prompt_line(title: &str, initial: &str, max: usize) -> Option<String> {
}
}
fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option<String> {
let value = platform::prompt(title, initial)?;
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
Some(clean.trim().to_string())
}
fn sanitize_campaign_key(value: &str) -> String {
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
@@ -441,6 +437,10 @@ struct App {
frame_ms: f32,
frame_hist: [f32; FRAME_HIST],
frame_hist_pos: usize,
editor: Option<editor::Editor>, // the campaign editor (takes the whole frame while open)
editor_testing: bool, // a mission of the editor is being played
editor_public_pending: bool, // the public campaign list still has to be fetched
editor_loading: Option<String>, // server id of the own campaign being loaded
custom_campaign: Option<campaign_doc::CampaignDoc>,
custom_progress: Option<campaign_doc::CampaignProgress>,
custom_mission_index: Option<usize>,
@@ -652,7 +652,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None };
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_public_pending: false, editor_loading: None };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
apply_game_settings(&mut app.game, &app.settings);
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
@@ -679,6 +679,17 @@ async fn main() {
app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST;
let mut settings_changed = false;
// ---------------- the campaign editor: opened from the title menu, takes the whole frame
if menu.page == Some(Page::CampaignEditor) && app.editor.is_none() {
app.editor = Some(editor::Editor::new(menu.campaign_doc.clone()));
menu.page = Some(Page::Title);
}
if app.editor.is_some() && !app.editor_testing {
editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now);
next_frame().await;
continue;
}
// ---------------- touch or mouse: which interface is active
// (with the touch interface touches are no longer turned into mouse events)
simulate_mouse_with_touch(app.settings.touch_ui == 2);
@@ -1346,6 +1357,8 @@ async fn main() {
}
}
MenuAction::Help => ui.show_help = true,
MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu => {
if app.match_mode || app.game.in_match() {
app.leave_match();
@@ -1378,6 +1391,7 @@ async fn main() {
app.coop = false;
}
// end of a mission: next mission (won) or try again (lost)
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
UiAction::EndNext => {
if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone();
@@ -1540,6 +1554,11 @@ async fn main() {
app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32);
app.cam.rx = app.cam.rx.clamp(0.0, mw() as f32);
app.cam.rz = app.cam.rz.clamp(0.0, mh() as f32);
// a mission script asked the camera to look somewhere
if let Some(p) = app.game.script.as_mut().and_then(|s| s.camera.take()) {
app.cam.tx = p.0;
app.cam.tz = p.1;
}
app.cam.update_render(dt);
if settings_changed {
@@ -1689,7 +1708,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
high: ui.tool == Tool::Dig,
};
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
if app.settings.debug_trigger_zones || app.settings.debug_path_lines {
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
draw_debug_3d(app, now);
}
let title_screen = matches!(
@@ -1728,6 +1747,151 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
draw_debug_fps(app, gfx);
}
if app.editor_testing && !menu.open() {
draw_script_debug(app, gfx);
}
}
// test play from the editor: which trigger fired when, and the variables
fn draw_script_debug(app: &App, gfx: &Gfx) {
let Some(s) = app.game.script.as_ref() else { return };
let lines: Vec<(String, Color)> = std::iter::once((i18n::tr("ed.debug_title"), ui::FRAME_HI))
.chain(s.vars.iter().map(|(k, v)| (format!("{} = {}", k, v), ui::GOLD)))
.chain(s.log.iter().rev().take(10).map(|(t, n)| (format!("{:>5.0}s {}", t, n), if n.starts_with('!') { ui::RED } else { ui::TXT })))
.collect();
let w = ui::CW() * 30.0;
let x = ui::sw() - w - ui::CW();
let y = ui::CH() * 5.0;
draw_rectangle(x, y, w, ui::CH() * (lines.len() as f32 + 1.0), Color::new(0.04, 0.03, 0.03, 0.85));
for (k, (l, c)) in lines.iter().enumerate() {
let l: String = l.chars().take(29).collect();
gfx.text(&l, x + ui::CW() * 0.5, y + ui::CH() * (0.5 + k as f32), ui::SMALL(), *c);
}
}
// a test play ends (pause menu, mission over): back into the editor
fn back_to_editor(app: &mut App, menu: &mut Menu, r3d: &mut R3d) {
app.editor_testing = false;
menu.editor_test = false;
menu.page = Some(Page::Title);
app.custom_campaign = None;
app.custom_progress = None;
app.custom_mission_index = None;
app.custom_next_index = None;
r3d.invalidate();
}
// one frame of the campaign editor and what it asks for
#[allow(clippy::too_many_arguments)]
fn editor_frame(app: &mut App, menu: &mut Menu, cloud: &mut Cloud, r3d: &mut R3d, gfx: &Gfx, ui: &mut Ui, dt: f32, now: f64) {
let Some(mut ed) = app.editor.take() else { return };
// the savegame service: campaign lists, loads and saves
if let CloudEvent::Error(key) = cloud.poll() {
ed.set_status(i18n::tr(key));
}
if app.editor_public_pending && cloud.refresh_public_campaigns() {
app.editor_public_pending = false;
}
ed.own_list = cloud.campaigns.iter().map(|c| (c.id.clone(), c.name.clone())).collect();
ed.public_list = cloud.public_campaigns.iter().map(|c| (c.owner.clone(), c.id.clone(), c.name.clone())).collect();
if let Some(info) = cloud.take_saved_campaign() {
ed.server_id = Some(info.id.clone());
ed.published = info.published;
ed.set_status(i18n::tr("ed.saved_server"));
}
if let Some(text) = cloud.take_loaded_campaign() {
match campaign_doc::CampaignDoc::load(&text) {
Ok(doc) => {
let id = app.editor_loading.take();
ed.published = id.as_ref().and_then(|i| cloud.campaigns.iter().find(|c| c.id == *i)).map_or(false, |c| c.published);
ed.load(doc, id);
ed.set_status(i18n::tr("ed.loaded"));
}
Err(e) => ed.set_status(e),
}
}
// a campaign file picked or dropped onto the window
let mut incoming = platform::take_file();
for f in get_dropped_files() {
if let Some(b) = f.bytes {
incoming = Some(b);
}
}
if let Some(bytes) = incoming {
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
Ok(doc) => {
ed.load(doc, None);
ed.set_status(i18n::tr("ed.loaded"));
}
Err(e) => ed.set_status(e),
}
}
// the map editor's map has to be the active one (map size for tiles and drawing)
if let Some(g) = ed.active_map() {
g.map.activate();
}
let act = ed.frame(gfx, r3d, dt, now);
match act {
editor::EdAction::None => {}
editor::EdAction::Close => {
menu.campaign_doc = ed.doc.clone();
(app.game, app.cam) = Game::new_demo(app.seed);
apply_game_settings(&mut app.game, &app.settings);
r3d.invalidate();
menu.page = Some(Page::Title);
return; // the editor is gone
}
editor::EdAction::Test(i) => {
let progress = campaign_doc::CampaignProgress::for_doc(&ed.doc);
(app.game, app.cam) = custom_mission_game(&ed.doc, i, progress.run_seed);
apply_game_settings(&mut app.game, &app.settings);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(ed.doc.clone());
app.custom_progress = Some(progress);
app.custom_mission_index = Some(i);
app.custom_next_index = None;
app.editor_testing = true;
menu.editor_test = true;
menu.page = None;
ui.log.clear();
ui.show_help = false;
r3d.invalidate();
}
editor::EdAction::SaveFile => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()),
editor::EdAction::OpenFile => platform::open_file(),
editor::EdAction::ServerSave => {
if !cloud.available() {
ed.set_status(i18n::tr("ed.no_server"));
} else if !cloud.save_campaign(ed.server_id.as_deref(), &ed.doc.name, &serde_json::to_string(&ed.doc).unwrap_or_default()) {
ed.set_status(i18n::tr("ed.busy"));
}
}
editor::EdAction::ServerRefresh => {
if cloud.available() {
cloud.refresh_campaigns();
app.editor_public_pending = true;
}
}
editor::EdAction::ServerLoad(id) => {
if cloud.load_campaign(&id) {
app.editor_loading = Some(id);
}
}
editor::EdAction::PublicLoad(owner, id) => {
if cloud.load_public_campaign(&owner, &id) {
app.editor_loading = None;
}
}
editor::EdAction::Publish(on) => match ed.server_id.clone() {
Some(id) => {
cloud.publish_campaign(&id, on);
}
None => ed.set_status(i18n::tr("ed.save_first")),
},
}
app.editor = Some(ed);
}
fn draw_debug_fps(app: &App, gfx: &Gfx) {
@@ -1819,7 +1983,7 @@ fn draw_debug_fps(app: &App, gfx: &Gfx) {
fn draw_debug_3d(app: &App, now: f64) {
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
if app.settings.debug_trigger_zones {
if app.settings.debug_trigger_zones || app.editor_testing {
if let Some(campaign) = app.custom_campaign.as_ref() {
if let Some(index) = app.custom_mission_index {
if let Some(mission) = campaign.missions.get(index) {
+1 -1
View File
@@ -16,7 +16,7 @@ thread_local! {
static DIMS: Cell<(usize, usize)> = Cell::new((64, 64));
}
fn set_dims(w: usize, h: usize) {
pub fn set_dims(w: usize, h: usize) {
DIMS.with(|d| d.set((w, h)));
}
#[inline]
+8
View File
@@ -291,6 +291,7 @@ enum Btn {
BestPick(usize),
BestLevel(i32),
LangOpen, // the language dropdown field
BackToEditor,
}
pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft";
@@ -376,6 +377,7 @@ pub enum MenuAction {
CampaignStart(usize),
OpenRepo,
OpenClaudeCode,
BackToEditor,
OpenOpenCode,
Login(String, String),
Logout,
@@ -412,6 +414,7 @@ pub struct Menu {
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
pub coop_mission: usize, // co-op campaign mission selected on the Match page
pub in_match: bool, // a match is being played; the pause menu changes its shape
pub editor_test: bool, // a mission of the editor is being tested: back to the editor
pub campaign_doc: CampaignDoc,
pub campaign_selected: usize,
pub campaign_pending: bool,
@@ -447,6 +450,7 @@ impl Menu {
match_ui: MatchUi::default(),
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
in_match: false,
editor_test: false,
campaign_doc: CampaignDoc::default(),
campaign_selected: 0,
campaign_pending: false,
@@ -699,6 +703,7 @@ impl Menu {
Some(Btn::Repo) => MenuAction::OpenRepo,
Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode,
Some(Btn::OpenCode) => MenuAction::OpenOpenCode,
Some(Btn::BackToEditor) => MenuAction::BackToEditor,
Some(Btn::Field(i)) => {
self.focus = i;
if matches!(self.page, Some(Page::Match { .. })) {
@@ -1109,6 +1114,9 @@ impl Menu {
let sw = sw();
let sh = sh();
let mut items = vec![(Btn::Resume, tr("menu.resume"))];
if self.editor_test {
items.push((Btn::BackToEditor, tr("menu.back_editor")));
}
if self.mission.is_some() {
items.push((Btn::Goals, tr("menu.goals")));
}
-5
View File
@@ -328,11 +328,6 @@ fn engine() -> Engine {
e
}
// compiles a program (for the editor's check and for running); Err: the error text
pub fn compile(code: &str) -> Result<(), String> {
engine().compile(code).map(|_| ()).map_err(|e| e.to_string())
}
// runs one function of the mission's program; returns the actions or an error
fn run_fn(g: &Game, code: &str, name: &str, vars: &mut BTreeMap<String, f64>, rng: &mut u32, event: EventInfo) -> Result<Vec<Act>, String> {
PROGRAM.with(|p| {