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