Files
dungeoncraft/src/editor/mod.rs
T
Richard ZinkandClaude Opus 5.5 aacc6a00b2 Editor layout fixes from a visual check
No fog while painting, readable tool bars, a two-row spawn block, a
dialog that keeps the clicks to itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:46:51 +01:00

1032 lines
46 KiB
Rust

// 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<usize>)>, // from card, branch (None: the "next" link)
panning: Option<Vec2>,
}
pub struct Editor {
pub doc: CampaignDoc,
pub screen: Screen,
tab: usize,
im: Im,
graph: Graph,
pub map: Option<map::MapEdit>,
pub blocks: blocks_ui::BlockEdit,
pub server_id: Option<String>,
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<usize>,
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<usize> {
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<String>) {
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<String> = 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<Rect> = self.doc.missions.iter().map(card_rect).collect();
// ports: "next" on the right edge, branches below it
let port = |k: usize, b: Option<usize>| {
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>, 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<usize> = None;
let mut delete: Option<usize> = 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<String> = 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<EdAction> {
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<String> = (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<String> = self.doc.missions.iter().map(|m| m.title.clone()).collect();
let keys: Vec<String> = self.doc.missions.iter().map(|m| m.key.clone()).collect();
let m = &mut self.doc.missions[i];
let goal_names: Vec<String> = 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<String> = 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<ScriptRef> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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);
}
}
}