diff --git a/src/campaign.rs b/src/campaign.rs index fdc9983..ed87349 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -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 { 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); diff --git a/src/editor/blocks_ui.rs b/src/editor/blocks_ui.rs new file mode 100644 index 0000000..5a27177 --- /dev/null +++ b/src/editor/blocks_ui.rs @@ -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)>, // where it was taken from (None: the palette) +} + +#[derive(Default)] +pub struct BlockEdit { + cat: usize, + undo: Vec, + redo: Vec, + last_json: String, + last_change: f64, + clipboard: Option, + selected: Option>, + drag: Option, + search: String, + scroll: f32, + num_buf: String, // the number being typed (focused number field) + num_focus: Option, + help: String, +} + +enum Op { + Remove(Vec), + Select(Vec), + StartDrag(Vec), +} + +// what the fields may offer +struct Ctx { + zones: Vec, + vars: Vec, + triggers: Vec, + problems: Vec<(Vec, &'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, lp: &[usize]) -> Option<&'a mut Vec> { + 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> { + 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)> = 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, lp: &[usize], x: f32, y0: f32, w: f32, ctx: &Ctx, ops: &mut Vec, slots: &mut Vec<(Rect, Vec, 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 = 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 = 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 = SIDES.iter().copied().filter(|&s| any || s >= 0).collect(); + let opts: Vec = 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 = 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 = 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::() { + *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 = 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); + } +} diff --git a/src/editor/map.rs b/src/editor/map.rs new file mode 100644 index 0000000..db25df0 --- /dev/null +++ b/src/editor/map.rs @@ -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, + redo: Vec, + zone: usize, // selected zone + zone_shape: usize, // shape for a new zone + zone_name: String, + last_mouse: Option, +} + +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::() > 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 = 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 = [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 = 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 = 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 = 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 = (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 + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..5ae2784 --- /dev/null +++ b/src/editor/mod.rs @@ -0,0 +1,1012 @@ +// The in-game campaign editor: a campaign as a graph of mission cards, each mission with its +// settings, goals and branches, its map (random per campaign run, or painted in the map +// editor) and its block scripts (special scripts and triggers, edited in the block editor). +// +// While the editor is open it takes the whole frame: main.rs calls Editor::frame and only +// handles what it returns (test play, files, the savegame service). + +pub mod blocks_ui; +pub mod map; +pub mod templates; +pub mod widgets; + +use crate::campaign_doc::*; +use crate::gfx::*; +use crate::i18n::{languages, tr, trf}; +use crate::render3d::R3d; +use crate::ui::*; +use macroquad::prelude::*; +#[allow(unused_imports)] +use crate::ui::{BLUE, GOLD, GREEN, RED}; +use widgets::*; + +// which list of blocks is being edited +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScriptRef { + Start, + Victory, + Defeat, + Tick, + Trigger(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Screen { + Graph, + Mission(usize), + Map(usize), + Blocks(usize, ScriptRef), +} + +// what main.rs has to do for the editor +pub enum EdAction { + None, + Close, + Test(usize), + SaveFile, + OpenFile, + ServerSave, + ServerRefresh, // fetch the own and the public campaign lists + ServerLoad(String), + PublicLoad(String, String), + Publish(bool), +} + +#[derive(Default)] +struct Graph { + pan: Vec2, + drag: Option<(usize, Vec2, bool)>, // card, grab offset, moved + link: Option<(usize, Option)>, // from card, branch (None: the "next" link) + panning: Option, +} + +pub struct Editor { + pub doc: CampaignDoc, + pub screen: Screen, + tab: usize, + im: Im, + graph: Graph, + pub map: Option, + pub blocks: blocks_ui::BlockEdit, + pub server_id: Option, + pub published: bool, + pub own_list: Vec<(String, String)>, // (id, name) on the server + pub public_list: Vec<(String, String, String)>, // (owner, id, name) + open_dialog: bool, + template_pick: usize, + status: Option<(String, f64)>, + confirm_delete: Option, + scroll: f32, // scripts tab +} + +pub const GOAL_KINDS: [&str; 9] = ["KillAll", "Shards", "Heart", "Towers", "Rivals", "Loot", "Survive", "Springs", "Freed"]; + +fn goal_parts(raw: &str) -> (String, u32) { + match raw.find('(') { + Some(p) => (raw[..p].to_string(), raw[p + 1..].trim_end_matches(')').parse().unwrap_or(0)), + None => (raw.to_string(), 0), + } +} + +fn goal_has_number(kind: &str) -> bool { + matches!(kind, "Loot" | "Survive" | "Springs" | "Freed") +} + +fn goal_raw(kind: &str, n: u32) -> String { + if goal_has_number(kind) { + format!("{}({})", kind, n.max(1)) + } else { + kind.to_string() + } +} + +pub fn goal_label(raw: &str) -> String { + let (k, n) = goal_parts(raw); + let name = tr(&format!("ed.goal.{}", k.to_lowercase())); + if goal_has_number(&k) { + format!("{} {}", name, n) + } else { + name + } +} + +pub fn side_label(side: i32) -> String { + tr(match side { + 0 => "ed.side.player", + 1 => "ed.side.heroes", + 2 => "ed.side.mossgrim", + 3 => "ed.side.nightwell", + 4 => "ed.side.rival3", + _ => "ed.side.any", + }) +} + +pub fn kind_label(kind: &str) -> String { + if kind.is_empty() { + return tr("ed.any_kind"); + } + match crate::mscript::kind_by_name(kind) { + Some(k) => tr(crate::game::st(k).name), + None => kind.to_string(), + } +} + +// a row of tabs; returns the clicked one +fn tabs(im: &mut Im, gfx: &Gfx, x: f32, y: f32, labels: &[String], cur: usize) -> Option { + let mut tx = x; + let mut out = None; + for (k, l) in labels.iter().enumerate() { + let w = gfx.measure(l, S()) + CW() * 2.0; + let r = Rect::new(tx, y, w, CH() * 1.8); + draw_rectangle(r.x, r.y, r.w, r.h, if k == cur { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if k == cur || im.over(r) { FRAME_HI } else { FRAME }); + gfx.text(l, r.x + CW(), r.y + CH() * 0.4, S(), if k == cur { FRAME_HI } else { TXT }); + if im.clicked(r) { + out = Some(k); + } + tx += w + CW() * 0.5; + } + out +} + +impl Editor { + pub fn new(doc: CampaignDoc) -> Editor { + let mut doc = doc; + if doc.missions.is_empty() { + doc.add_mission(); + } + if doc.language.is_empty() { + doc.language = crate::i18n::language(); + } + Editor { + doc, + screen: Screen::Graph, + tab: 0, + im: Im::default(), + graph: Graph::default(), + map: None, + blocks: blocks_ui::BlockEdit::default(), + server_id: None, + published: false, + own_list: Vec::new(), + public_list: Vec::new(), + open_dialog: false, + template_pick: 0, + status: None, + confirm_delete: None, + scroll: 0.0, + } + } + + pub fn set_status(&mut self, s: String) { + self.status = Some((s, get_time())); + } + + // a campaign was loaded (file, server): edit it from the graph + pub fn load(&mut self, doc: CampaignDoc, server_id: Option) { + let mut doc = doc; + if doc.missions.is_empty() { + doc.add_mission(); + } + self.doc = doc; + self.server_id = server_id; + self.screen = Screen::Graph; + self.map = None; + self.open_dialog = false; + } + + // the map editor works on its own game: its map has to be the active one while it is open + pub fn active_map(&mut self) -> Option<&mut crate::game::Game> { + match self.screen { + Screen::Map(_) => self.map.as_mut().map(|m| &mut m.game), + _ => None, + } + } + + pub fn frame(&mut self, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64) -> EdAction { + clear_background(Color::from_rgba(4, 3, 5, 255)); + self.im.begin(); + let mut act = EdAction::None; + match self.screen { + Screen::Map(i) => { + if let Some(m) = self.map.as_mut() { + match m.frame(&mut self.im, gfx, r3d, dt, now, &mut self.doc.missions[i]) { + map::MapResult::Done => self.screen = Screen::Mission(i), + map::MapResult::Stay => {} + } + } else { + self.screen = Screen::Mission(i); + } + } + Screen::Blocks(i, sref) => { + begin_ui(); + if self.blocks.frame(&mut self.im, gfx, &mut self.doc.missions[i], sref) { + self.screen = Screen::Mission(i); + self.tab = 3; + } + } + Screen::Graph => { + begin_ui(); + act = self.graph_screen(gfx); + } + Screen::Mission(i) => { + begin_ui(); + act = self.mission_screen(gfx, i); + } + } + if self.open_dialog { + begin_ui(); + if let Some(a) = self.open_screen(gfx) { + act = a; + } + } + // status line + if let Some((s, t)) = self.status.clone() { + if now - t < 4.0 { + let w = gfx.measure(&s, S()) + CW() * 2.0; + let r = Rect::new(grid_center(sw(), w), sh() - CH() * 3.5, w, CH() * 1.6); + draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.05, 0.03, 0.04, 0.95)); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, GOLD); + gfx.text(&s, r.x + CW(), r.y + CH() * 0.3, S(), GOLD); + } else { + self.status = None; + } + } + self.im.finish(gfx); + act + } + + // ------------------------------------------------------------------ campaign graph + + fn graph_screen(&mut self, gfx: &Gfx) -> EdAction { + let mut act = EdAction::None; + let (w, h) = (sw(), sh()); + let im = &mut self.im; + // top bar: name, author, language, file and server buttons + let bar_h = CH() * 5.0; + panel(gfx, Rect::new(0.0, 0.0, w, bar_h), ""); + gfx.text(&tr("ed.campaign"), CW(), CH() * 0.4, S(), FRAME_HI); + let fw = (CW() * 24.0).min(w * 0.25); + label(gfx, CW(), CH() * 2.0, &tr("ed.name"), DIM); + im.text_field(gfx, id("cname", 0), Rect::new(CW() * 8.0, CH() * 1.7, fw, CH() * 1.5), &mut self.doc.name, 40); + label(gfx, CW(), CH() * 3.5, &tr("ed.author"), DIM); + im.text_field(gfx, id("cauthor", 0), Rect::new(CW() * 8.0, CH() * 3.3, fw, CH() * 1.5), &mut self.doc.author, 32); + let lx = CW() * 9.0 + fw; + label(gfx, lx, CH() * 2.0, &tr("ed.language"), DIM); + let langs = languages(); + let names: Vec = langs.iter().map(|(_, n)| n.clone()).collect(); + let cur = langs.iter().find(|(c, _)| *c == self.doc.language).map(|(_, n)| n.clone()).unwrap_or_else(|| self.doc.language.clone()); + if let Some(k) = im.dropdown(gfx, id("clang", 0), Rect::new(lx, CH() * 3.3, CW() * 14.0, CH() * 1.5), &cur, &names) { + self.doc.language = langs[k].0.clone(); + } + // buttons on the right + let labels = [tr("ed.new"), tr("ed.open"), tr("ed.save_file"), tr("ed.save_server"), tr("ed.close")]; + let bw = labels.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 2.0; + let mut bx = w - CW() - bw; + for k in (0..labels.len()).rev() { + let r = Rect::new(bx, CH() * 0.6, bw, CH() * 1.7); + if im.button(gfx, r, &labels[k]) { + match k { + 0 => { + self.doc = CampaignDoc { name: tr("ed.new_campaign"), language: crate::i18n::language(), ..CampaignDoc::default() }; + self.doc.add_mission(); + self.server_id = None; + } + 1 => { + self.open_dialog = true; + act = EdAction::ServerRefresh; + } + 2 => act = EdAction::SaveFile, + 3 => act = EdAction::ServerSave, + _ => act = EdAction::Close, + } + } + bx -= bw + CW() * 0.5; + } + let mut publ = self.published; + if self.server_id.is_some() && im.toggle(gfx, w - CW() * 2.0 - bw * 3.0, CH() * 3.1, &tr("ed.published"), &mut publ) { + self.published = publ; + act = EdAction::Publish(publ); + } + let add = Rect::new(w - CW() - bw, CH() * 3.0, bw, CH() * 1.7); + if im.button(gfx, add, &tr("ed.add_mission")) { + let n = self.doc.missions.len(); + self.doc.add_mission(); + self.doc.missions[n].pos = (-self.graph.pan.x / CW() * 0.0 + 40.0 + (n % 5) as f32 * 220.0, 40.0 + (n / 5) as f32 * 140.0); + } + + // the canvas: cards and arrows + let top = bar_h; + let card_w = CW() * 26.0; + let card_h = CH() * 7.0; + let scale = CW() / 8.0; // card positions are stored in a font-independent unit + let origin = vec2(CW(), top + CH()) + self.graph.pan; + let card_rect = |m: &MissionDoc| Rect::new(origin.x + m.pos.0 * scale, origin.y + m.pos.1 * scale, card_w, card_h); + let n = self.doc.missions.len(); + let rects: Vec = self.doc.missions.iter().map(card_rect).collect(); + // ports: "next" on the right edge, branches below it + let port = |k: usize, b: Option| { + let r = rects[k]; + match b { + None => vec2(r.x + r.w, r.y + CH() * 1.2), + Some(j) => vec2(r.x + r.w, r.y + CH() * (2.8 + j as f32 * 1.2)), + } + }; + let key_index = |key: &str| self.doc.missions.iter().position(|m| m.key == key); + // arrows first (under the cards) + for k in 0..n { + let m = &self.doc.missions[k]; + let mut links: Vec<(Option, usize, Color)> = Vec::new(); + if let Some(t) = m.next.as_deref().and_then(key_index) { + links.push((None, t, FRAME_HI)); + } + for (j, b) in m.branches.iter().enumerate() { + if let Some(t) = key_index(&b.next) { + links.push((Some(j), t, BLUE)); + } + } + for (b, t, col) in links { + let a = port(k, b); + let tr_ = rects[t]; + let e = vec2(tr_.x, tr_.y + tr_.h * 0.5); + let mid = (a.x + e.x) * 0.5; + draw_line(a.x, a.y, mid, a.y, 3.0, col); + draw_line(mid, a.y, mid, e.y, 3.0, col); + draw_line(mid, e.y, e.x, e.y, 3.0, col); + draw_triangle(vec2(e.x, e.y), vec2(e.x - CW(), e.y - CH() * 0.4), vec2(e.x - CW(), e.y + CH() * 0.4), col); + } + } + // a link being dragged + if let Some((from, b)) = self.graph.link { + let a = port(from, b); + draw_line(a.x, a.y, im.mx, im.my, 3.0, GOLD); + } + let mut open: Option = None; + let mut delete: Option = None; + for k in 0..n { + let r = rects[k]; + let m = &self.doc.missions[k]; + let probs = m.script_problems().len(); + let hot = im.over(r) && self.graph.drag.is_none(); + draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.1, 0.07, 0.06, 0.97)); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { FRAME }); + let title: String = m.title.chars().take(22).collect(); + gfx.text(&title, r.x + CW() * 0.6, r.y + CH() * 0.4, S(), FRAME_HI); + let map_kind = tr(match m.map { + MapDoc::Random => "ed.map_random", + MapDoc::Fixed { .. } => "ed.map_fixed", + }); + gfx.text(&format!("{} {}x{}", map_kind, m.size.0, m.size.1), r.x + CW() * 0.6, r.y + CH() * 1.8, SMALL(), DIM); + let goals: Vec = m.goals.iter().map(|g| goal_label(g)).collect(); + let gl: String = goals.join(", ").chars().take(30).collect(); + gfx.text(&gl, r.x + CW() * 0.6, r.y + CH() * 3.0, SMALL(), TXT); + let sc = trf("ed.card_scripts", &[m.scripts.triggers.len().to_string()]); + gfx.text(&sc, r.x + CW() * 0.6, r.y + CH() * 4.2, SMALL(), DIM); + if probs > 0 { + gfx.text(&trf("ed.card_problems", &[probs.to_string()]), r.x + CW() * 0.6, r.y + CH() * 5.4, SMALL(), RED); + } + // ports + let pn = port(k, None); + draw_circle(pn.x, pn.y, CW() * 0.5, FRAME_HI); + for j in 0..m.branches.len() { + let p = port(k, Some(j)); + draw_circle(p.x, p.y, CW() * 0.45, BLUE); + } + let pnew = port(k, Some(m.branches.len())); + draw_circle_lines(pnew.x, pnew.y, CW() * 0.45, 2.0, BLUE); + // delete + let xr = Rect::new(r.x + r.w - CW() * 1.8, r.y + CH() * 0.2, CW() * 1.5, CH() * 1.2); + if hot { + let sure = self.confirm_delete == Some(k); + gfx.text(if sure { "?" } else { "x" }, xr.x + CW() * 0.4, xr.y, S(), if sure { RED } else { DIM }); + if n > 1 && im.clicked(xr) { + if sure { + delete = Some(k); + } else { + self.confirm_delete = Some(k); + } + } + } + // start a link at a port, or grab the card + if im.pressed && self.graph.link.is_none() { + let near = |p: Vec2| (p - vec2(im.mx, im.my)).length() < CW(); + if near(pn) { + self.graph.link = Some((k, None)); + im.pressed = false; + } else if let Some(j) = (0..=m.branches.len()).find(|&j| near(port(k, Some(j)))) { + self.graph.link = Some((k, Some(j))); + im.pressed = false; + } else if im.over(r) { + self.graph.drag = Some((k, vec2(im.mx - r.x, im.my - r.y), false)); + im.pressed = false; + } + } + } + // dragging a card + if let Some((k, off, moved)) = self.graph.drag { + if im.down { + let p = vec2(im.mx, im.my) - off - origin; + let np = ((p.x / scale).round(), (p.y / scale).round()); + let m = &mut self.doc.missions[k]; + let moved = moved || (np.0 - m.pos.0).abs() + (np.1 - m.pos.1).abs() > 2.0; + if moved { + m.pos = np; + } + self.graph.drag = Some((k, off, moved)); + } else { + if !moved { + open = Some(k); + } + self.graph.drag = None; + } + } + // dropping a link on a card (or on empty space: the link is removed) + if let Some((from, b)) = self.graph.link { + if !im.down { + let target = rects.iter().position(|r| im.over(*r)).filter(|&t| t != from); + let key = target.map(|t| self.doc.missions[t].key.clone()); + let m = &mut self.doc.missions[from]; + match b { + None => m.next = key, + Some(j) => match key { + Some(key) if j < m.branches.len() => m.branches[j].next = key, + Some(key) => m.branches.push(BranchDoc { id: format!("b{}", j + 1), goal: "KillAll".into(), title: String::new(), next: key, priority: None }), + None if j < m.branches.len() => { + m.branches.remove(j); + } + None => {} + }, + } + self.graph.link = None; + } + } + // panning the canvas (right button or middle button drag) + if is_mouse_button_down(MouseButton::Right) || is_mouse_button_down(MouseButton::Middle) { + let p = vec2(im.mx, im.my); + if let Some(last) = self.graph.panning { + self.graph.pan += p - last; + } + self.graph.panning = Some(p); + } else { + self.graph.panning = None; + } + if let Some(k) = delete { + let key = self.doc.missions[k].key.clone(); + self.doc.missions.remove(k); + for m in self.doc.missions.iter_mut() { + if m.next.as_deref() == Some(key.as_str()) { + m.next = None; + } + m.branches.retain(|b| b.next != key); + } + self.confirm_delete = None; + } + if let Some(k) = open { + self.screen = Screen::Mission(k); + self.tab = 0; + self.confirm_delete = None; + } + let hint = tr("ed.graph_hint"); + gfx.text(&hint, CW(), h - CH() * 1.5, SMALL(), DIM); + act + } + + // ------------------------------------------------------------------ open dialog + + fn open_screen(&mut self, gfx: &Gfx) -> Option { + let (w, h) = (sw(), sh()); + let pw = (CW() * 70.0).min(w - CW() * 2.0); + let ph = (CH() * 34.0).min(h - CH() * 2.0); + let r = Rect::new(grid_center(w, pw), grid_center_y(h, ph), pw, ph); + draw_rectangle(0.0, 0.0, w, h, Color::new(0.0, 0.0, 0.0, 0.5)); + panel(gfx, r, &tr("ed.open")); + let im = &mut self.im; + let mut out = None; + let x = r.x + CW() * 1.5; + let mut y = r.y + CH() * 2.0; + let bw = (pw - CW() * 4.0) * 0.5; + if im.button(gfx, Rect::new(x, y, bw, CH() * 1.7), &tr("ed.open_file")) { + out = Some(EdAction::OpenFile); + self.open_dialog = false; + } + if im.button(gfx, Rect::new(x + bw + CW(), y, bw, CH() * 1.7), &tr("ed.open_demo")) { + self.load(templates::demo_campaign(), None); + self.open_dialog = false; + return None; + } + y += CH() * 2.6; + // a mission of the built-in campaign as a copy + label(gfx, x, y, &tr("ed.template"), FRAME_HI); + y += CH() * 1.4; + let names: Vec = (0..crate::campaign::missions().len()).map(|i| format!("{}. {}", i + 1, tr(&format!("camp.{}.title", crate::campaign::missions()[i].key)))).collect(); + let cur = names.get(self.template_pick).cloned().unwrap_or_default(); + if let Some(k) = im.dropdown(gfx, id("tpl", 0), Rect::new(x, y, bw * 1.3, CH() * 1.6), &cur, &names) { + self.template_pick = k; + } + if im.button(gfx, Rect::new(x + bw * 1.3 + CW(), y, bw * 0.7 - CW(), CH() * 1.6), &tr("ed.template_add")) { + let m = templates::builtin_mission(self.template_pick, &self.doc); + let n = self.doc.missions.len(); + self.doc.missions.push(m); + self.doc.missions[n].pos = (40.0 + (n % 5) as f32 * 220.0, 40.0 + (n / 5) as f32 * 140.0); + self.open_dialog = false; + self.screen = Screen::Graph; + return None; + } + y += CH() * 2.6; + // the own campaigns on the server, and the published ones of all players + let half = (pw - CW() * 4.0) * 0.5; + label(gfx, x, y, &tr("ed.own_campaigns"), FRAME_HI); + label(gfx, x + half + CW(), y, &tr("ed.public_campaigns"), FRAME_HI); + y += CH() * 1.4; + let rows = (((r.y + r.h - CH() * 3.0) - y) / (CH() * 1.5)).max(1.0) as usize; + if self.own_list.is_empty() { + gfx.text(&tr("ed.none"), x, y, SMALL(), DIM); + } + for (k, (sid, name)) in self.own_list.iter().take(rows).enumerate() { + if im.select(gfx, Rect::new(x, y + k as f32 * CH() * 1.5, half, CH() * 1.4), name, false, TXT) { + out = Some(EdAction::ServerLoad(sid.clone())); + } + } + if self.public_list.is_empty() { + gfx.text(&tr("ed.none"), x + half + CW(), y, SMALL(), DIM); + } + for (k, (owner, pid, name)) in self.public_list.iter().take(rows).enumerate() { + if im.select(gfx, Rect::new(x + half + CW(), y + k as f32 * CH() * 1.5, half, CH() * 1.4), &format!("{} ({})", name, owner), false, TXT) { + out = Some(EdAction::PublicLoad(owner.clone(), pid.clone())); + } + } + if im.button(gfx, Rect::new(r.x + r.w - CW() * 12.0, r.y + r.h - CH() * 2.3, CW() * 10.5, CH() * 1.7), &tr("ed.cancel")) || is_key_pressed(KeyCode::Escape) { + self.open_dialog = false; + } + if out.is_some() { + self.open_dialog = false; + } + out + } + + // ------------------------------------------------------------------ one mission + + fn mission_screen(&mut self, gfx: &Gfx, i: usize) -> EdAction { + let mut act = EdAction::None; + let (w, h) = (sw(), sh()); + if i >= self.doc.missions.len() { + self.screen = Screen::Graph; + return act; + } + panel(gfx, Rect::new(0.0, 0.0, w, h), ""); + let im = &mut self.im; + if im.button(gfx, Rect::new(CW(), CH() * 0.6, CW() * 14.0, CH() * 1.7), &tr("ed.back_graph")) || (is_key_pressed(KeyCode::Escape) && !im.typing) { + self.screen = Screen::Graph; + return act; + } + let title: String = self.doc.missions[i].title.chars().take(40).collect(); + gfx.text(&title, CW() * 16.5, CH() * 0.9, S(), FRAME_HI); + let tl = tr("ed.test"); + let tw = gfx.measure(&tl, S()) + CW() * 3.0; + if im.button_c(gfx, Rect::new(w - tw - CW(), CH() * 0.6, tw, CH() * 1.7), &tl, GREEN) { + act = EdAction::Test(i); + } + let labels = [tr("ed.tab_general"), tr("ed.tab_goals"), tr("ed.tab_map"), tr("ed.tab_scripts")]; + if let Some(t) = tabs(im, gfx, CW(), CH() * 3.0, &labels, self.tab) { + self.tab = t; + self.scroll = 0.0; + } + let area = Rect::new(CW(), CH() * 5.5, w - CW() * 2.0, h - CH() * 6.5); + match self.tab { + 0 => self.general_tab(gfx, i, area), + 1 => self.goals_tab(gfx, i, area), + 2 => self.map_tab(gfx, i, area), + _ => self.scripts_tab(gfx, i, area), + } + act + } + + fn general_tab(&mut self, gfx: &Gfx, i: usize, a: Rect) { + let im = &mut self.im; + let m = &mut self.doc.missions[i]; + let lx = a.x + CW(); + let fx = a.x + CW() * 22.0; + let fw = (a.w - CW() * 24.0).min(CW() * 60.0); + let mut y = a.y + CH() * 0.5; + label(gfx, lx, y + CH() * 0.25, &tr("ed.title"), TXT); + im.text_field(gfx, id("mtitle", i), Rect::new(fx, y, fw, CH() * 1.6), &mut m.title, 48); + y += CH() * 2.2; + label(gfx, lx, y + CH() * 0.25, &tr("ed.briefing"), TXT); + im.text_area(gfx, id("mstory", i), Rect::new(fx, y, fw, CH() * 9.0), &mut m.story, 1500); + y += CH() * 9.7; + let sw_ = CW() * 18.0; + label(gfx, lx, y + CH() * 0.25, &tr("ed.gold"), TXT); + let d = im.stepper(gfx, Rect::new(fx, y, sw_, CH() * 1.6), &format!("{}", m.gold as i32)); + m.gold = (m.gold + d as f32 * 250.0).clamp(0.0, 100000.0); + y += CH() * 2.1; + label(gfx, lx, y + CH() * 0.25, &tr("ed.time_limit"), TXT); + let tl = if m.time_limit <= 0.0 { tr("ed.none") } else { format!("{} min", (m.time_limit / 60.0).round()) }; + let d = im.stepper(gfx, Rect::new(fx, y, sw_, CH() * 1.6), &tl); + m.time_limit = (m.time_limit + d as f32 * 60.0).clamp(0.0, 600.0 * 60.0); + y += CH() * 2.1; + im.toggle(gfx, lx, y, &tr("ed.waves"), &mut m.waves); + if m.waves { + label(gfx, fx, y + CH() * 0.25, &tr("ed.wave_limit"), DIM); + let wl = if m.wave_limit == 0 { tr("ed.endless") } else { m.wave_limit.to_string() }; + let d = im.stepper(gfx, Rect::new(fx + CW() * 16.0, y, sw_, CH() * 1.6), &wl); + m.wave_limit = (m.wave_limit as i32 + d).clamp(0, 99) as u32; + y += CH() * 2.1; + label(gfx, fx, y + CH() * 0.25, &tr("ed.first_wave"), DIM); + let d = im.stepper(gfx, Rect::new(fx + CW() * 16.0, y, sw_, CH() * 1.6), &format!("{} s", m.first_wave as i32)); + m.first_wave = (m.first_wave + d as f32 * 30.0).clamp(10.0, 3600.0); + } + } + + fn goals_tab(&mut self, gfx: &Gfx, i: usize, a: Rect) { + let im = &mut self.im; + let titles: Vec = self.doc.missions.iter().map(|m| m.title.clone()).collect(); + let keys: Vec = self.doc.missions.iter().map(|m| m.key.clone()).collect(); + let m = &mut self.doc.missions[i]; + let goal_names: Vec = GOAL_KINDS.iter().map(|k| tr(&format!("ed.goal.{}", k.to_lowercase()))).collect(); + let lx = a.x + CW(); + let mut y = a.y + CH() * 0.5; + label(gfx, lx, y, &tr("ed.goals"), FRAME_HI); + y += CH() * 1.6; + gfx.text(&tr("ed.goals_hint"), lx, y, SMALL(), DIM); + y += CH() * 1.4; + let mut remove = None; + for k in 0..m.goals.len() { + let (kind, n) = goal_parts(&m.goals[k]); + let cur = tr(&format!("ed.goal.{}", kind.to_lowercase())); + if let Some(c) = im.dropdown(gfx, id("goal", i * 100 + k), Rect::new(lx, y, CW() * 26.0, CH() * 1.6), &cur, &goal_names) { + m.goals[k] = goal_raw(GOAL_KINDS[c], n.max(1) * if goal_has_number(GOAL_KINDS[c]) { 1 } else { 0 }); + if goal_has_number(GOAL_KINDS[c]) && n == 0 { + m.goals[k] = goal_raw(GOAL_KINDS[c], if GOAL_KINDS[c] == "Loot" { 3000 } else { 3 }); + } + } + if goal_has_number(&kind) { + let step = if kind == "Loot" { 500 } else { 1 }; + let d = im.stepper(gfx, Rect::new(lx + CW() * 27.0, y, CW() * 14.0, CH() * 1.6), &n.to_string()); + if d != 0 { + m.goals[k] = goal_raw(&kind, (n as i32 + d * step).max(1) as u32); + } + } + if im.button(gfx, Rect::new(lx + CW() * 42.0, y, CW() * 3.0, CH() * 1.6), "x") { + remove = Some(k); + } + y += CH() * 2.0; + } + if let Some(k) = remove { + m.goals.remove(k); + } + if im.button(gfx, Rect::new(lx, y, CW() * 18.0, CH() * 1.6), &tr("ed.add_goal")) { + m.goals.push("KillAll".into()); + } + y += CH() * 3.0; + // the next mission + label(gfx, lx, y + CH() * 0.25, &tr("ed.next_mission"), FRAME_HI); + let mut opts = vec![tr("ed.none")]; + opts.extend(titles.iter().cloned()); + let cur = m.next.as_ref().and_then(|n| keys.iter().position(|k| k == n)).map(|p| titles[p].clone()).unwrap_or_else(|| tr("ed.none")); + if let Some(c) = im.dropdown(gfx, id("next", i), Rect::new(lx + CW() * 22.0, y, CW() * 30.0, CH() * 1.6), &cur, &opts) { + m.next = if c == 0 { None } else { Some(keys[c - 1].clone()) }; + } + y += CH() * 3.0; + // branches: another goal reached leads to another mission + label(gfx, lx, y, &tr("ed.branches"), FRAME_HI); + y += CH() * 1.6; + gfx.text(&tr("ed.branches_hint"), lx, y, SMALL(), DIM); + y += CH() * 1.4; + let mut remove = None; + for k in 0..m.branches.len() { + let (kind, n) = goal_parts(&m.branches[k].goal); + let cur = tr(&format!("ed.goal.{}", kind.to_lowercase())); + if let Some(c) = im.dropdown(gfx, id("bgoal", i * 100 + k), Rect::new(lx, y, CW() * 22.0, CH() * 1.6), &cur, &goal_names) { + m.branches[k].goal = goal_raw(GOAL_KINDS[c], if GOAL_KINDS[c] == "Loot" { 3000 } else { 3 }); + } + let mut bx = lx + CW() * 23.0; + if goal_has_number(&kind) { + let step = if kind == "Loot" { 500 } else { 1 }; + let d = im.stepper(gfx, Rect::new(bx, y, CW() * 12.0, CH() * 1.6), &n.to_string()); + if d != 0 { + m.branches[k].goal = goal_raw(&kind, (n as i32 + d * step).max(1) as u32); + } + bx += CW() * 13.0; + } + gfx.text("→", bx, y + CH() * 0.3, S(), FRAME_HI); + let cur = keys.iter().position(|kk| *kk == m.branches[k].next).map(|p| titles[p].clone()).unwrap_or_else(|| tr("ed.none")); + if let Some(c) = im.dropdown(gfx, id("bnext", i * 100 + k), Rect::new(bx + CW() * 2.0, y, CW() * 24.0, CH() * 1.6), &cur, &titles) { + m.branches[k].next = keys[c].clone(); + } + if im.button(gfx, Rect::new(bx + CW() * 27.0, y, CW() * 3.0, CH() * 1.6), "x") { + remove = Some(k); + } + y += CH() * 2.0; + } + if let Some(k) = remove { + m.branches.remove(k); + } + if im.button(gfx, Rect::new(lx, y, CW() * 18.0, CH() * 1.6), &tr("ed.add_branch")) && !keys.is_empty() { + let n = m.branches.len() + 1; + let target = keys.iter().find(|k| **k != m.key).cloned().unwrap_or_else(|| keys[0].clone()); + m.branches.push(BranchDoc { id: format!("b{}", n), goal: "Loot(3000)".into(), title: String::new(), next: target, priority: Some(n as u32) }); + } + } + + fn map_tab(&mut self, gfx: &Gfx, i: usize, a: Rect) { + let im = &mut self.im; + let m = &mut self.doc.missions[i]; + let lx = a.x + CW(); + let fx = a.x + CW() * 24.0; + let sw_ = CW() * 16.0; + let mut y = a.y + CH() * 0.5; + let fixed = matches!(m.map, MapDoc::Fixed { .. }); + gfx.text(&tr(if fixed { "ed.map_is_fixed" } else { "ed.map_is_random" }), lx, y, S(), FRAME_HI); + y += CH() * 1.6; + gfx.text(&tr(if fixed { "ed.map_fixed_hint" } else { "ed.map_random_hint" }), lx, y, SMALL(), DIM); + y += CH() * 2.0; + let mut rows: Vec<(String, String, i32)> = vec![ + (tr("ed.map_width"), m.size.0.to_string(), 0), + (tr("ed.map_height"), m.size.1.to_string(), 1), + (tr("ed.map_seed"), m.seed.to_string(), 2), + (tr("ed.rivers"), m.gen.rivers.to_string(), 3), + (tr("ed.lava_rivers"), m.gen.lava.to_string(), 4), + (tr("ed.forts"), m.gen.forts.to_string(), 5), + (tr("ed.rivals"), m.gen.rivals.to_string(), 6), + (tr("ed.difficulty"), tr(["diff.easy", "diff.medium", "diff.hard"][m.gen.difficulty.min(2) as usize]), 7), + (tr("ed.portals"), m.portals.to_string(), 8), + ]; + if fixed { + rows.truncate(0); + } + for (name, val, k) in rows { + label(gfx, lx, y + CH() * 0.25, &name, TXT); + let d = im.stepper(gfx, Rect::new(fx, y, sw_, CH() * 1.6), &val); + if d != 0 { + match k { + 0 => m.size.0 = (m.size.0 as i32 + d.signum() * 8).clamp(48, 256) as usize, + 1 => m.size.1 = (m.size.1 as i32 + d.signum() * 8).clamp(48, 256) as usize, + 2 => m.seed = (m.seed as i64 + d as i64).max(0) as u32, + 3 => m.gen.rivers = (m.gen.rivers as i32 + d.signum()).clamp(0, 3) as u8, + 4 => m.gen.lava = (m.gen.lava as i32 + d.signum()).clamp(0, 2) as u8, + 5 => m.gen.forts = (m.gen.forts as i32 + d.signum()).clamp(0, 3) as u8, + 6 => m.gen.rivals = (m.gen.rivals as i32 + d.signum()).clamp(0, 2) as u8, + 7 => m.gen.difficulty = (m.gen.difficulty as i32 + d.signum()).clamp(0, 2) as u8, + _ => m.portals = (m.portals as i32 + d.signum()).clamp(0, 6) as usize, + } + } + y += CH() * 2.0; + } + y += CH(); + let bw = CW() * 30.0; + if fixed { + if im.button_c(gfx, Rect::new(lx, y, bw, CH() * 1.8), &tr("ed.edit_map"), GREEN) { + if let MapDoc::Fixed { game } = &m.map { + if let Some(g) = crate::campaign::load_fixed_map(game) { + self.map = Some(map::MapEdit::new(g)); + self.screen = Screen::Map(i); + } + } + } + if im.button(gfx, Rect::new(lx + bw + CW(), y, bw, CH() * 1.8), &tr("ed.make_random")) { + m.map = MapDoc::Random; + } + } else if im.button_c(gfx, Rect::new(lx, y, bw, CH() * 1.8), &tr("ed.paint_map"), GREEN) { + // the generator's map becomes the start of a painted map + let g = crate::campaign::start_editor_mission(&MissionDoc { scripts: MissionScripts::default(), map: MapDoc::Random, ..m.clone() }, i, m.seed.max(1).wrapping_mul(7)); + self.map = Some(map::MapEdit::new(g)); + self.screen = Screen::Map(i); + } + y += CH() * 3.0; + let zones: Vec = m.zones.iter().map(|z| z.name.clone()).collect(); + gfx.text(&trf("ed.zones_list", &[zones.len().to_string(), zones.join(", ").chars().take(80).collect()]), lx, y, SMALL(), DIM); + } + + fn scripts_tab(&mut self, gfx: &Gfx, i: usize, a: Rect) { + let im = &mut self.im; + let lx = a.x + CW(); + let mut y = a.y + CH() * 0.5 - self.scroll; + let mut goto: Option = None; + { + let m = &self.doc.missions[i]; + label(gfx, lx, y, &tr("ed.special_scripts"), FRAME_HI); + y += CH() * 1.8; + for (sref, key, list) in [ + (ScriptRef::Start, "ed.script_start", &m.scripts.start), + (ScriptRef::Victory, "ed.script_victory", &m.scripts.victory), + (ScriptRef::Defeat, "ed.script_defeat", &m.scripts.defeat), + (ScriptRef::Tick, "ed.script_tick", &m.scripts.tick), + ] { + let probs = m.problems(list).len(); + label(gfx, lx, y + CH() * 0.25, &tr(key), TXT); + let info = if probs > 0 { trf("ed.blocks_problems", &[list.len().to_string(), probs.to_string()]) } else { trf("ed.blocks_count", &[list.len().to_string()]) }; + gfx.text(&info, lx + CW() * 34.0, y + CH() * 0.25, SMALL(), if probs > 0 { RED } else { DIM }); + if im.button(gfx, Rect::new(lx + CW() * 54.0, y, CW() * 14.0, CH() * 1.6), &tr("ed.edit_blocks")) { + goto = Some(sref); + } + y += CH() * 2.0; + } + } + y += CH(); + label(gfx, lx, y, &tr("ed.triggers"), FRAME_HI); + y += CH() * 1.6; + gfx.text(&tr("ed.triggers_hint"), lx, y, SMALL(), DIM); + y += CH() * 1.6; + let zones = self.doc.missions[i].zone_names(); + let vars = self.doc.missions[i].vars(); + let kind_names: Vec = TRIGGER_KINDS.iter().map(|k| tr(&format!("ed.trig.{}", k))).collect(); + let mut remove = None; + let n = self.doc.missions[i].scripts.triggers.len(); + for k in 0..n { + let probs = { + let m = &self.doc.missions[i]; + let t = &m.scripts.triggers[k]; + m.problems(&t.script).len() + t.kind.zone().map_or(0, |z| (!zones.iter().any(|x| x == z)) as usize) + }; + let row = Rect::new(lx - CW() * 0.5, y - CH() * 0.3, a.w - CW(), CH() * 4.6); + draw_rectangle_lines(row.x, row.y, row.w, row.h, 1.0, if probs > 0 { RED } else { Color::new(0.3, 0.24, 0.18, 1.0) }); + let t = &mut self.doc.missions[i].scripts.triggers[k]; + im.text_field(gfx, id("tname", i * 1000 + k), Rect::new(lx, y, CW() * 18.0, CH() * 1.6), &mut t.name, 24); + let cur = tr(&format!("ed.trig.{}", t.kind.kind_name())); + if let Some(c) = im.dropdown(gfx, id("tkind", i * 1000 + k), Rect::new(lx + CW() * 19.0, y, CW() * 24.0, CH() * 1.6), &cur, &kind_names) { + if let Some(nk) = TriggerKind::template(TRIGGER_KINDS[c]) { + t.kind = nk; + if let TriggerKind::Timer { every: true, .. } = t.kind { + t.once = false; + } + } + } + im.toggle(gfx, lx + CW() * 44.0, y, &tr("ed.once"), &mut t.once); + im.toggle(gfx, lx + CW() * 54.0, y, &tr("ed.enabled"), &mut t.enabled); + if im.button(gfx, Rect::new(row.x + row.w - CW() * 26.0, y, CW() * 16.0, CH() * 1.6), &trf("ed.edit_trigger_blocks", &[t.script.len().to_string()])) { + goto = Some(ScriptRef::Trigger(k)); + } + let sure = self.confirm_delete == Some(10000 + k); + if im.button_c(gfx, Rect::new(row.x + row.w - CW() * 9.0, y, CW() * 8.0, CH() * 1.6), if sure { "?" } else { "x" }, RED) { + if sure { + remove = Some(k); + } else { + self.confirm_delete = Some(10000 + k); + } + } + condition_form(im, gfx, lx, y + CH() * 2.2, i * 1000 + k, &mut t.kind, &zones, &vars); + y += CH() * 5.2; + } + if let Some(k) = remove { + self.doc.missions[i].scripts.triggers.remove(k); + self.confirm_delete = None; + } + if im.button(gfx, Rect::new(lx, y, CW() * 20.0, CH() * 1.6), &tr("ed.add_trigger")) { + let m = &mut self.doc.missions[i]; + let mut n = m.scripts.triggers.len() + 1; + while m.scripts.triggers.iter().any(|t| t.name == format!("trigger{}", n)) { + n += 1; + } + let zone = m.zones.first().map(|z| z.name.clone()).unwrap_or_default(); + let mut kind = TriggerKind::template("zone_enter").unwrap(); + if let TriggerKind::ZoneEnter { zone: z, .. } = &mut kind { + *z = zone; + } + if m.zones.is_empty() { + kind = TriggerKind::template("timer").unwrap(); + } + m.scripts.triggers.push(TriggerDoc { name: format!("trigger{}", n), kind, once: true, enabled: true, script: Vec::new() }); + } + y += CH() * 3.0; + // scrolling the list + let content = y + self.scroll - a.y; + if im.over(a) && im.wheel != 0.0 && !im.popup_open() { + self.scroll = (self.scroll - im.wheel.signum() * CH() * 3.0).clamp(0.0, (content - a.h).max(0.0)); + } + if let Some(s) = goto { + self.blocks.open(s); + self.screen = Screen::Blocks(i, s); + } + } +} + +// the condition of a trigger: the fields its kind needs +fn condition_form(im: &mut Im, gfx: &Gfx, x: f32, y: f32, k: usize, kind: &mut TriggerKind, zones: &[String], vars: &[String]) { + let fh = CH() * 1.6; + let mut cx = x; + let zone_dd = |im: &mut Im, cx: &mut f32, zone: &mut String| { + let cur = if zone.is_empty() { tr("ed.pick_zone") } else { zone.clone() }; + if let Some(c) = im.dropdown(gfx, id("tzone", k), Rect::new(*cx, y, CW() * 18.0, fh), &cur, zones) { + *zone = zones[c].clone(); + } + *cx += CW() * 19.0; + }; + let side_opts: Vec = crate::blocks::SIDES.iter().map(|&s| side_label(s)).collect(); + let side_dd = |im: &mut Im, cx: &mut f32, side: &mut i32| { + if let Some(c) = im.dropdown(gfx, id("tside", k), Rect::new(*cx, y, CW() * 16.0, fh), &side_label(*side), &side_opts) { + *side = crate::blocks::SIDES[c]; + } + *cx += CW() * 17.0; + }; + let mut kinds: Vec = vec![tr("ed.any_kind")]; + kinds.extend(crate::blocks::KINDS.iter().map(|k| kind_label(k))); + let kind_dd = |im: &mut Im, cx: &mut f32, kd: &mut String| { + if let Some(c) = im.dropdown(gfx, id("tkd", k), Rect::new(*cx, y, CW() * 18.0, fh), &kind_label(kd), &kinds) { + *kd = if c == 0 { String::new() } else { crate::blocks::KINDS[c - 1].to_string() }; + } + *cx += CW() * 19.0; + }; + let ops: Vec = crate::blocks::CMP_OPS.iter().map(|s| s.to_string()).collect(); + match kind { + TriggerKind::ZoneEnter { zone, side, kind } | TriggerKind::ZoneLeave { zone, side, kind } | TriggerKind::LineCross { zone, side, kind } => { + zone_dd(im, &mut cx, zone); + side_dd(im, &mut cx, side); + kind_dd(im, &mut cx, kind); + } + TriggerKind::ZoneClaimed { zone } | TriggerKind::ZoneDug { zone } => zone_dd(im, &mut cx, zone), + TriggerKind::ZoneCount { zone, side, kind, op, n } => { + zone_dd(im, &mut cx, zone); + side_dd(im, &mut cx, side); + kind_dd(im, &mut cx, kind); + if let Some(c) = im.dropdown(gfx, id("top", k), Rect::new(cx, y, CW() * 6.0, fh), op, &ops) { + *op = ops[c].clone(); + } + cx += CW() * 7.0; + let d = im.stepper(gfx, Rect::new(cx, y, CW() * 10.0, fh), &format!("{}", *n as i64)); + *n = (*n + d as f64).max(0.0); + } + TriggerKind::Timer { secs, every } => { + label(gfx, cx, y + CH() * 0.25, &tr(if *every { "ed.every" } else { "ed.after" }), DIM); + cx += CW() * 10.0; + let d = im.stepper(gfx, Rect::new(cx, y, CW() * 14.0, fh), &format!("{} s", *secs as i64)); + *secs = (*secs + d as f64 * 5.0).clamp(1.0, 36000.0); + cx += CW() * 15.0; + im.toggle(gfx, cx, y, &tr("ed.repeat"), every); + } + TriggerKind::UnitDies { side, kind, name } => { + side_dd(im, &mut cx, side); + kind_dd(im, &mut cx, kind); + label(gfx, cx, y + CH() * 0.25, &tr("ed.unit_name"), DIM); + im.text_field(gfx, id("tuname", k), Rect::new(cx + CW() * 8.0, y, CW() * 14.0, fh), name, 20); + } + TriggerKind::TowerFalls { side } => { + let opts = vec![tr("ed.side.any"), tr("ed.towers_dungeon"), tr("ed.towers_knights")]; + let cur = opts[(*side + 1).clamp(0, 2) as usize].clone(); + if let Some(c) = im.dropdown(gfx, id("ttower", k), Rect::new(cx, y, CW() * 22.0, fh), &cur, &opts) { + *side = c as i32 - 1; + } + } + TriggerKind::Value { what, var, op, n } => { + let whats = ["gold", "mana", "creatures", "heroes", "var"]; + let opts: Vec = whats.iter().map(|w| tr(&format!("ed.value.{}", w))).collect(); + let cur = tr(&format!("ed.value.{}", what)); + if let Some(c) = im.dropdown(gfx, id("twhat", k), Rect::new(cx, y, CW() * 16.0, fh), &cur, &opts) { + *what = whats[c].to_string(); + } + cx += CW() * 17.0; + if what == "var" { + let cur = if var.is_empty() { tr("ed.pick_var") } else { var.clone() }; + if let Some(c) = im.dropdown(gfx, id("tvar", k), Rect::new(cx, y, CW() * 16.0, fh), &cur, vars) { + *var = vars[c].clone(); + } + cx += CW() * 17.0; + } + if let Some(c) = im.dropdown(gfx, id("top", k), Rect::new(cx, y, CW() * 6.0, fh), op, &ops) { + *op = ops[c].clone(); + } + cx += CW() * 7.0; + let step = if what == "gold" { 100.0 } else { 1.0 }; + let d = im.stepper(gfx, Rect::new(cx, y, CW() * 14.0, fh), &format!("{}", *n as i64)); + *n = (*n + d as f64 * step).max(0.0); + } + TriggerKind::RoomBuilt { room } => { + let mut opts = vec![tr("ed.any_room")]; + opts.extend(crate::game::ROOM_KINDS.iter().map(|r| format!("{:?}", r))); + let cur = if room.is_empty() { tr("ed.any_room") } else { room.clone() }; + if let Some(c) = im.dropdown(gfx, id("troom", k), Rect::new(cx, y, CW() * 20.0, fh), &cur, &opts) { + *room = if c == 0 { String::new() } else { opts[c].clone() }; + } + } + TriggerKind::SpellCast { spell } => { + let mut opts = vec![tr("ed.any_spell")]; + opts.extend(crate::spells::SPELLS.iter().map(|s| crate::mscript::spell_name(*s))); + let cur = if spell.is_empty() { tr("ed.any_spell") } else { spell.clone() }; + if let Some(c) = im.dropdown(gfx, id("tspell", k), Rect::new(cx, y, CW() * 20.0, fh), &cur, &opts) { + *spell = if c == 0 { String::new() } else { opts[c].clone() }; + } + } + TriggerKind::Slap | TriggerKind::Research => { + gfx.text(&tr("ed.no_settings"), cx, y + CH() * 0.25, SMALL(), DIM); + } + } +} diff --git a/src/editor/templates.rs b/src/editor/templates.rs new file mode 100644 index 0000000..261fe45 --- /dev/null +++ b/src/editor/templates.rs @@ -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) -> 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"); + } +} diff --git a/src/editor/widgets.rs b/src/editor/widgets.rs new file mode 100644 index 0000000..d19906f --- /dev/null +++ b/src/editor/widgets.rs @@ -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, + 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, + pub ctrl: bool, + pub shift: bool, + pub focus: Option, // the text field that gets the typing + pub caret: usize, // caret position (characters) in the focused field + popup: Option, + picked: Option<(u64, usize)>, // a dropdown choice made this frame + tip: Option, + 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 = 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 = 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 { + 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); +} diff --git a/src/main.rs b/src/main.rs index 90c34d5..074ddb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { } } -fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option { - 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, // 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, // server id of the own campaign being loaded custom_campaign: Option, custom_progress: Option, custom_mission_index: Option, @@ -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) { diff --git a/src/map.rs b/src/map.rs index a4bed9a..864f2a7 100644 --- a/src/map.rs +++ b/src/map.rs @@ -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] diff --git a/src/menu.rs b/src/menu.rs index b6634e7..3cbd33f 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -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"))); } diff --git a/src/mscript.rs b/src/mscript.rs index 3160ef1..75a9517 100644 --- a/src/mscript.rs +++ b/src/mscript.rs @@ -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, rng: &mut u32, event: EventInfo) -> Result, String> { PROGRAM.with(|p| {