// 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), Play, // the whole campaign from its first mission, following the branches 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(); // an open dialog takes the clicks: the page behind it only draws let held = (self.im.pressed, self.im.released, self.im.wheel); if self.open_dialog { self.im.pressed = false; self.im.released = false; self.im.wheel = 0.0; } 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 { (self.im.pressed, self.im.released, self.im.wheel) = held; 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: the title and the buttons, then name, author, language 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.8, S(), FRAME_HI); let labels = [tr("ed.play"), tr("ed.new"), tr("ed.open"), tr("ed.save_file"), tr("ed.save_server"), tr("ed.close")]; let mut bx = w - CW(); for k in (0..labels.len()).rev() { let bw = gfx.measure(&labels[k], S()) + CW() * 2.0; bx -= bw; let r = Rect::new(bx, CH() * 0.5, bw, CH() * 1.7); let clicked = if k == 0 { im.button_c(gfx, r, &labels[k], GREEN) } else { im.button(gfx, r, &labels[k]) }; if clicked { match k { 0 => act = EdAction::Play, 1 => { self.doc = CampaignDoc { name: tr("ed.new_campaign"), language: crate::i18n::language(), ..CampaignDoc::default() }; self.doc.add_mission(); self.server_id = None; } 2 => { self.open_dialog = true; act = EdAction::ServerRefresh; } 3 => act = EdAction::SaveFile, 4 => act = EdAction::ServerSave, _ => act = EdAction::Close, } } bx -= CW() * 0.5; } let y2 = CH() * 2.9; let fw = (CW() * 22.0).min(w * 0.2); let mut x = CW(); label(gfx, x, y2 + CH() * 0.25, &tr("ed.name"), DIM); x += gfx.measure(&tr("ed.name"), S()) + CW(); im.text_field(gfx, id("cname", 0), Rect::new(x, y2, fw, CH() * 1.5), &mut self.doc.name, 40); x += fw + CW() * 1.5; label(gfx, x, y2 + CH() * 0.25, &tr("ed.author"), DIM); x += gfx.measure(&tr("ed.author"), S()) + CW(); im.text_field(gfx, id("cauthor", 0), Rect::new(x, y2, fw * 0.8, CH() * 1.5), &mut self.doc.author, 32); x += fw * 0.8 + CW() * 1.5; label(gfx, x, y2 + CH() * 0.25, &tr("ed.language"), DIM); x += gfx.measure(&tr("ed.language"), S()) + CW(); 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(x, y2, CW() * 14.0, CH() * 1.5), &cur, &names) { self.doc.language = langs[k].0.clone(); } x += CW() * 15.5; let mut publ = self.published; if self.server_id.is_some() && im.toggle(gfx, x, y2, &tr("ed.published"), &mut publ) { self.published = publ; act = EdAction::Publish(publ); } let al = tr("ed.add_mission"); let aw = gfx.measure(&al, S()) + CW() * 2.0; if im.button(gfx, Rect::new(w - CW() - aw, y2 - CH() * 0.1, aw, CH() * 1.7), &al) { let n = self.doc.missions.len(); self.doc.add_mission(); self.doc.missions[n].pos = (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.0", "diff.1", "diff.2"][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); } } }