// The block editor: a script as a stack of coloured puzzle blocks. Blocks come from the // palette on the left (drag them into the script), are moved by their grip, nest inside // "if" and "repeat", and have their settings right inside them. Problems (missing zones, // unset variables, wrong values) are marked red while editing; undo/redo, copy/paste and a // search are there too. use super::widgets::*; use super::{kind_label, side_label, ScriptRef}; use crate::blocks::*; use crate::campaign_doc::MissionDoc; use crate::gfx::*; use crate::i18n::{tr, trf}; use crate::ui::*; use macroquad::prelude::*; #[allow(unused_imports)] use crate::ui::{BLUE, GOLD, GREEN, RED}; #[derive(Clone)] struct Drag { block: Block, from: Option<(Vec, usize)>, // where it was taken from (None: the palette) } #[derive(Default)] pub struct BlockEdit { cat: usize, undo: Vec, redo: Vec, last_json: String, last_change: f64, clipboard: Option, selected: Option>, drag: Option, search: String, scroll: f32, num_buf: String, // the number being typed (focused number field) num_focus: Option, help: String, rows: std::collections::HashMap, // rows each block's fields needed (wrapping) } enum Op { Remove(Vec), Select(Vec), StartDrag(Vec), } // what the fields may offer struct Ctx { zones: Vec, vars: Vec, triggers: Vec, problems: Vec<(Vec, &'static str)>, search: String, } fn cat_color(cat: &str) -> Color { match cat { "say" => Color::new(0.42, 0.25, 0.55, 1.0), "units" => Color::new(0.58, 0.2, 0.18, 1.0), "world" => Color::new(0.3, 0.42, 0.2, 1.0), "logic" => Color::new(0.62, 0.4, 0.12, 1.0), "vars" => Color::new(0.18, 0.34, 0.58, 1.0), _ => Color::new(0.55, 0.47, 0.14, 1.0), } } fn fid(path: &[usize], field: usize) -> u64 { let mut h: u64 = 0x9e3779b97f4a7c15; for &p in path { h = (h ^ p as u64).wrapping_mul(0x100000001b3); } (h ^ (field as u64 + 7919)).wrapping_mul(0x100000001b3) } fn list_mut<'a>(root: &'a mut Vec, lp: &[usize]) -> Option<&'a mut Vec> { let mut cur = root; let mut k = 0; while k + 1 < lp.len() + 1 && k < lp.len() { let (i, branch) = (lp[k], *lp.get(k + 1)?); cur = match cur.get_mut(i)? { Block::If { then, els, .. } => { if branch == 0 { then } else { els } } Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body, _ => return None, }; k += 2; } Some(cur) } fn script_mut<'a>(m: &'a mut MissionDoc, s: ScriptRef) -> Option<&'a mut Vec> { Some(match s { ScriptRef::Start => &mut m.scripts.start, ScriptRef::Victory => &mut m.scripts.victory, ScriptRef::Defeat => &mut m.scripts.defeat, ScriptRef::Tick => &mut m.scripts.tick, ScriptRef::Trigger(k) => &mut m.scripts.triggers.get_mut(k)?.script, }) } // does a block (or anything in it) contain the search text? fn block_text(b: &Block) -> String { match b { Block::Message { text } | Block::Objective { text } | Block::Hint { text } => text.clone(), Block::SetVar { name, .. } | Block::AddVar { name, .. } => name.clone(), Block::Spawn { kind, name, place, .. } => format!("{} {} {:?}", kind, name, place), Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } | Block::Terrain { zone, .. } => zone.clone(), Block::Trigger { name, .. } => name.clone(), _ => String::new(), } } const ROW: f32 = 2.2; // block row height in CH impl BlockEdit { pub fn open(&mut self, _s: ScriptRef) { self.undo.clear(); self.redo.clear(); self.last_json.clear(); self.selected = None; self.drag = None; self.scroll = 0.0; } // returns true when the editor is left pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, m: &mut MissionDoc, sref: ScriptRef) -> bool { let (w, h) = (sw(), sh()); let mut back = false; let title = match sref { ScriptRef::Start => tr("ed.script_start"), ScriptRef::Victory => tr("ed.script_victory"), ScriptRef::Defeat => tr("ed.script_defeat"), ScriptRef::Tick => tr("ed.script_tick"), ScriptRef::Trigger(k) => trf("ed.trigger_script", &[m.scripts.triggers.get(k).map(|t| t.name.clone()).unwrap_or_default()]), }; let probs = { let list = match sref { ScriptRef::Start => &m.scripts.start, ScriptRef::Victory => &m.scripts.victory, ScriptRef::Defeat => &m.scripts.defeat, ScriptRef::Tick => &m.scripts.tick, ScriptRef::Trigger(k) => match m.scripts.triggers.get(k) { Some(t) => &t.script, None => return true, }, }; m.problems(list) }; let ctx = Ctx { zones: m.zone_names(), vars: m.vars(), triggers: m.trigger_names(), problems: probs.iter().map(|p| (p.path.clone(), p.key)).collect(), search: self.search.to_lowercase(), }; let Some(list) = script_mut(m, sref) else { return true }; let before = serde_json::to_string(&*list).unwrap_or_default(); if self.last_json.is_empty() { self.last_json = before.clone(); } // ---- top bar panel(gfx, Rect::new(0.0, 0.0, w, CH() * 3.2), ""); if im.button(gfx, Rect::new(CW(), CH() * 0.7, CW() * 12.0, CH() * 1.7), &tr("ed.back")) || (is_key_pressed(KeyCode::Escape) && !im.typing && self.drag.is_none()) { back = true; } 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); // the script's name, as much as fits before the search field let room = ((bx - CW() * 15.0) / CW()).max(4.0) as usize; let title: String = title.chars().take(room).collect(); gfx.text(&title, CW() * 14.0, CH() * 1.0, S(), FRAME_HI); 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; } for (k, l) in wrap(&tr("ed.blocks_hint"), ((pal_w - CW()) / (GW * SMALL())) as usize).iter().enumerate() { gfx.text(l, CW() * 0.5, h - CH() * 6.0 + k as f32 * GH * SMALL() * 1.2, SMALL(), DIM); } // ---- the script let ws = Rect::new(pal_w, top, w - pal_w, h - top - CH() * 2.0); if im.over(ws) && im.wheel != 0.0 && !im.popup_open() { self.scroll = (self.scroll - im.wheel.signum() * CH() * 3.0).max(0.0); } let mut ops = Vec::new(); let mut slots: Vec<(Rect, Vec, usize)> = Vec::new(); let x0 = ws.x + CW() * 2.0; let y0 = ws.y + CH() - self.scroll; let height = self.draw_list(im, gfx, list, &[], x0, y0, ws.w - CW() * 4.0, &ctx, &mut ops, &mut slots); self.scroll = self.scroll.min((height - ws.h * 0.5).max(0.0)); if list.is_empty() { gfx.text(&tr("ed.empty_script"), x0, y0 + CH(), S(), DIM); } // apply what was clicked for op in ops { match op { Op::Select(p) => self.selected = Some(p), Op::Remove(p) => { if let Some(l) = list_mut(list, &p[..p.len() - 1]) { if p[p.len() - 1] < l.len() { l.remove(p[p.len() - 1]); } } self.selected = None; } Op::StartDrag(p) => { if let Some(l) = list_mut(list, &p[..p.len() - 1]) { let k = p[p.len() - 1]; if k < l.len() { let b = l.remove(k); self.drag = Some(Drag { block: b, from: Some((p[..p.len() - 1].to_vec(), k)) }); self.selected = None; } } } } } // dragging: the block follows the pointer; dropped into the nearest gap if let Some(d) = self.drag.clone() { let target = slots .iter() .filter(|(r, _, _)| r.contains(vec2(im.mx, im.my))) .max_by_key(|(_, lp, _)| lp.len()) .map(|(r, lp, k)| (*r, lp.clone(), *k)) .or_else(|| { // below everything: the end of the script (im.over(ws)).then(|| (Rect::new(x0, y0 + height, ws.w, CH()), Vec::new(), list.len())) }); if let Some((r, _, _)) = &target { draw_rectangle(r.x, r.y + r.h * 0.5 - 2.0, ws.w * 0.5, 4.0, GOLD); } let gr = Rect::new(im.mx - CW(), im.my - CH() * 0.5, CW() * 24.0, CH() * 1.8); draw_block_shape(gr, Color::new(cat_color(d.block.category()).r, cat_color(d.block.category()).g, cat_color(d.block.category()).b, 0.85), true, false); gfx.text(&tr(&format!("blk.{}", d.block.kind_name())), gr.x + CW() * 1.6, gr.y + CH() * 0.4, S(), TXT); if !im.down { let over_palette = im.mx < pal_w; match (target, over_palette) { (Some((_, lp, k)), false) => { if let Some(l) = list_mut(list, &lp) { let k = k.min(l.len()); l.insert(k, d.block.clone()); let mut p = lp.clone(); p.push(k); self.selected = Some(p); } } (_, true) => {} // dropped on the palette: gone _ => { // nowhere: back where it came from if let Some((lp, k)) = d.from { if let Some(l) = list_mut(list, &lp) { l.insert(k.min(l.len()), d.block.clone()); } } } } self.drag = None; } } // the help line panel(gfx, Rect::new(pal_w, h - CH() * 2.0, w - pal_w, CH() * 2.0), ""); if !self.help.is_empty() { let line: String = self.help.chars().take(((w - pal_w) / CW()) as usize - 2).collect(); gfx.text(&line, pal_w + CW(), h - CH() * 1.5, S(), TXT); } // undo: one step per change (typing in one field within a second is one step) let after = serde_json::to_string(&*list).unwrap_or_default(); if after != self.last_json && act.map_or(true, |a| a < 3) { let now = get_time(); if !(im.typing && now - self.last_change < 1.0) { self.undo.push(self.last_json.clone()); if self.undo.len() > 100 { self.undo.remove(0); } self.redo.clear(); } self.last_change = now; self.last_json = after; } else if act.map_or(false, |a| a >= 3) { self.last_json = after; } back } // draws a list of blocks; returns its height #[allow(clippy::too_many_arguments)] fn draw_list(&mut self, im: &mut Im, gfx: &Gfx, list: &mut Vec, lp: &[usize], x: f32, y0: f32, w: f32, ctx: &Ctx, ops: &mut Vec, slots: &mut Vec<(Rect, Vec, usize)>) -> f32 { let mut y = y0; if list.is_empty() { slots.push((Rect::new(x, y, w, CH() * 1.6), lp.to_vec(), 0)); if !lp.is_empty() { draw_rectangle_lines(x, y, CW() * 18.0, CH() * 1.4, 1.0, Color::new(0.4, 0.33, 0.25, 0.8)); gfx.text(&tr("ed.drop_here"), x + CW() * 0.5, y + CH() * 0.2, SMALL(), DIM); } return CH() * 1.7; } for k in 0..list.len() { let mut path = lp.to_vec(); path.push(k); let cat = list[k].category(); let col = cat_color(cat); // the spawn block has many fields: two rows // rows the fields needed last frame (they wrap when the block is too narrow) let rows = self.rows.get(&fid(&path, 9999)).copied().unwrap_or(1) as f32; let header = Rect::new(x, y, w, CH() * ROW * rows); 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, header.h * 0.5 + CH() * 0.3), lp.to_vec(), k)); slots.push((Rect::new(x, y + header.h * 0.5, w, header.h * 0.5), lp.to_vec(), k + 1)); let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. }); // 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, header.h); 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 cx = x + CW() * 1.8; let mut lay = Lay { x: cx, y: fy, x2: x + CW() * 3.0, max: x + w - CW() * 2.5, rows: 1 }; self.block_fields(im, gfx, &mut list[k], &path, &mut lay, ctx); self.rows.insert(fid(&path, 9999), lay.rows); // 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 += header.h; if nested { // the C shape: bodies indented, with an "else" bar for if let inner_x = x + CW() * 2.5; let inner_w = w - CW() * 2.5; let branches = if matches!(list[k], Block::If { .. }) { 2 } else { 1 }; for b in 0..branches { let mut inner_lp = path.clone(); inner_lp.push(b); let body: &mut Vec = match &mut list[k] { Block::If { then, els, .. } => { if b == 0 { then } else { els } } Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { 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 (they wrap into more rows when the block is too narrow) fn block_fields(&mut self, im: &mut Im, gfx: &Gfx, b: &mut Block, path: &[usize], lay: &mut Lay, ctx: &Ctx) { let name = tr(&format!("blk.{}", b.kind_name())); lay.word(gfx, &name); let is_set = matches!(b, Block::SetVar { .. }); match b { Block::Message { text } | Block::Objective { text } | Block::Hint { text } => { let w = (lay.max - lay.x).max(CW() * 24.0).min(CW() * 60.0); let r = lay.take(w); im.text_field(gfx, fid(path, 0), r, text, 200); } Block::Spawn { side, kind, count, level, place, name } => { self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx); lay.word(gfx, "×"); self.kind_dd(im, gfx, kind, false, path, 2, lay); lay.word(gfx, &tr("blk.w_level")); self.value(im, gfx, level, Ty::Num, path, 3, lay, ctx); self.side_dd(im, gfx, side, false, path, 4, lay); lay.word(gfx, &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()]), }; let r = lay.take(CW() * 24.0); if let Some(c) = im.dropdown(gfx, fid(path, 5), r, &cur, &opts) { *place = match c { 0 => Place::Core, 1 => Place::Event, c => Place::Zone(ctx.zones[c - 2].clone()), }; } lay.word(gfx, &tr("blk.w_name")); let r = lay.take(CW() * 10.0); im.text_field(gfx, fid(path, 6), r, name, 16); } Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } | Block::TalentXp { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx), Block::Weather { kind, secs } => { weather_dd(im, gfx, kind, fid(path, 2), lay); lay.word(gfx, &tr("blk.w_for")); self.value(im, gfx, secs, Ty::Num, path, 1, lay, ctx); lay.word(gfx, &tr("blk.w_seconds")); } Block::SetVar { name, value } | Block::AddVar { name, value } => { let r = lay.take(CW() * 12.0); im.text_field(gfx, fid(path, 0), r, name, 24); lay.word(gfx, &tr(if is_set { "blk.w_to" } else { "blk.w_by" })); self.value(im, gfx, value, Ty::Num, path, 1, lay, ctx); } Block::If { cond, .. } | Block::Until { cond, .. } | Block::DoUntil { cond, .. } => self.value(im, gfx, cond, Ty::Bool, path, 1, lay, ctx), Block::Repeat { times, .. } => { self.value(im, gfx, times, Ty::Num, path, 1, lay, ctx); lay.word(gfx, &tr("blk.w_times")); } Block::Wait { secs } => { self.value(im, gfx, secs, Ty::Num, path, 1, lay, ctx); lay.word(gfx, &tr("blk.w_seconds")); } Block::Win | Block::Lose => {} Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, lay, ctx), Block::Terrain { zone, kind } => { self.zone_dd(im, gfx, zone, path, 1, lay, ctx); lay.word(gfx, &tr("blk.w_to")); let opts: Vec = TERRAIN.iter().map(|t| tr(&format!("ed.terrain.{}", t))).collect(); let cur = tr(&format!("ed.terrain.{}", kind)); let r = lay.take(CW() * 19.0); if let Some(c) = im.dropdown(gfx, fid(path, 2), r, &cur, &opts) { *kind = TERRAIN[c].to_string(); } } Block::Kill { zone, side } => { self.side_dd(im, gfx, side, true, path, 2, lay); lay.word(gfx, &tr("blk.w_in")); self.zone_dd(im, gfx, zone, path, 1, lay, ctx); } Block::Trigger { name, on } => { let cur = if name.is_empty() { tr("ed.pick_trigger") } else { name.clone() }; let r = lay.take(CW() * 16.0); if let Some(c) = im.dropdown(gfx, fid(path, 1), r, &cur, &ctx.triggers) { *name = ctx.triggers[c].clone(); } let opts = vec![tr("blk.on"), tr("blk.off")]; let r = lay.take(CW() * 10.0); if let Some(c) = im.dropdown(gfx, fid(path, 2), r, &opts[if *on { 0 } else { 1 }], &opts) { *on = c == 0; } } } } #[allow(clippy::too_many_arguments)] fn zone_dd(&mut self, im: &mut Im, gfx: &Gfx, zone: &mut String, path: &[usize], f: usize, lay: &mut Lay, ctx: &Ctx) { let cur = if zone.is_empty() { tr("ed.pick_zone") } else { zone.clone() }; let r = lay.take(CW() * 14.0); if let Some(c) = im.dropdown(gfx, fid(path, f), r, &cur, &ctx.zones) { *zone = ctx.zones[c].clone(); } } #[allow(clippy::too_many_arguments)] fn side_dd(&mut self, im: &mut Im, gfx: &Gfx, side: &mut i32, any: bool, path: &[usize], f: usize, lay: &mut Lay) { let sides: Vec = SIDES.iter().copied().filter(|&s| any || s >= 0).collect(); let opts: Vec = sides.iter().map(|&s| side_label(s)).collect(); let r = lay.take(CW() * 16.0); if let Some(c) = im.dropdown(gfx, fid(path, f), r, &side_label(*side), &opts) { *side = sides[c]; } } #[allow(clippy::too_many_arguments)] fn kind_dd(&mut self, im: &mut Im, gfx: &Gfx, kind: &mut String, any: bool, path: &[usize], f: usize, lay: &mut Lay) { let mut opts: Vec = Vec::new(); if any { opts.push(tr("ed.any_kind")); } opts.extend(KINDS.iter().map(|k| kind_label(k))); let r = lay.take(CW() * 17.0); if let Some(c) = im.dropdown(gfx, fid(path, f), r, &kind_label(kind), &opts) { *kind = if any && c == 0 { String::new() } else { KINDS[c - any as usize].to_string() }; } } // 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, lay: &mut Lay, ctx: &Ctx) { let kinds: &[&str] = if want == Ty::Num { &NUM_VALUES } else { &BOOL_VALUES }; let opts: Vec = kinds.iter().map(|k| tr(&format!("blk.v.{}", k))).collect(); // the kind switch let sw_ = lay.take_tight(CW() * 1.6); 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; } } 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 = lay.take(CW() * 8.0); let mut s = if im.focus == Some(id_) && self.num_focus == Some(id_) { self.num_buf.clone() } else { fmt_num(*n) }; if im.focus == Some(id_) && self.num_focus != Some(id_) { self.num_focus = Some(id_); self.num_buf = s.clone(); } if im.text_field(gfx, id_, r, &mut s, 10) { self.num_buf = s.clone(); if let Ok(x) = s.trim().replace(',', ".").parse::() { *n = x; } } } Val::Bool { b } => { let o = vec![tr("blk.yes"), tr("blk.no")]; let r = lay.take(CW() * 7.0); if let Some(c) = im.dropdown(gfx, fid(path, sub(0)), r, &o[if *b { 0 } else { 1 }], &o) { *b = c == 0; } } Val::Var { name } => { let cur = if name.is_empty() { tr("ed.pick_var") } else { name.clone() }; let r = lay.take(CW() * 15.0); if let Some(c) = im.dropdown(gfx, fid(path, sub(0)), r, &cur, &ctx.vars) { *name = ctx.vars[c].clone(); } } Val::Gold | Val::Mana | Val::Time => lay.word(gfx, &tr(&format!("blk.v.{}", v.kind_name()))), Val::Creatures { side, kind } => { lay.word(gfx, &tr("blk.v.creatures")); self.kind_dd(im, gfx, kind, true, path, sub(0), lay); self.side_dd(im, gfx, side, true, path, sub(1), lay); } Val::InZone { zone, side, kind } => { lay.word(gfx, &tr("blk.v.inzone")); self.kind_dd(im, gfx, kind, true, path, sub(0), lay); self.side_dd(im, gfx, side, true, path, sub(1), lay); lay.word(gfx, &tr("blk.w_in")); self.zone_dd(im, gfx, zone, path, sub(2), lay, ctx); } Val::Random { a, b } => { lay.word(gfx, &tr("blk.v.random")); self.value(im, gfx, a, Ty::Num, path, sub(0), lay, ctx); lay.word(gfx, ".."); self.value(im, gfx, b, Ty::Num, path, sub(1), lay, ctx); } Val::Math { op, a, b } | Val::Cmp { op, a, b } => { let ops: Vec = if v_kind_is_math(op) { MATH_OPS.iter().map(|s| s.to_string()).collect() } else { CMP_OPS.iter().map(|s| s.to_string()).collect() }; lay.word(gfx, "("); self.value(im, gfx, a, Ty::Num, path, sub(0), lay, ctx); let r = lay.take(CW() * 4.0); if let Some(c) = im.dropdown(gfx, fid(path, sub(1)), r, op, &ops) { *op = ops[c].clone(); } self.value(im, gfx, b, Ty::Num, path, sub(2), lay, ctx); lay.word(gfx, ")"); } Val::And { a, b } | Val::Or { a, b } => { lay.word(gfx, "("); self.value(im, gfx, a, Ty::Bool, path, sub(0), lay, ctx); lay.word(gfx, &tr(if and { "blk.and" } else { "blk.or" })); self.value(im, gfx, b, Ty::Bool, path, sub(1), lay, ctx); lay.word(gfx, ")"); } Val::Not { a } => { lay.word(gfx, &tr("blk.not")); self.value(im, gfx, a, Ty::Bool, path, sub(0), lay, ctx); } Val::EventKind { kind } => { lay.word(gfx, &tr("blk.v.eventkind")); self.kind_dd(im, gfx, kind, false, path, sub(0), lay); } Val::WeatherIs { kind } => { lay.word(gfx, &tr("blk.v.weatheris")); weather_dd(im, gfx, kind, fid(path, sub(0)), lay); } } } } // a dropdown with the kinds of weather fn weather_dd(im: &mut Im, gfx: &Gfx, kind: &mut String, id: u64, lay: &mut Lay) { let opts: Vec = WEATHER.iter().map(|w| tr(&format!("weather.{}", w))).collect(); let cur = tr(&format!("weather.{}", kind)); let r = lay.take(CW() * 12.0); if let Some(c) = im.dropdown(gfx, id, r, &cur, &opts) { *kind = WEATHER[c].to_string(); } } // where the next field of a block goes: left to right, into a new row at the block's edge struct Lay { x: f32, y: f32, x2: f32, // start of a new row max: f32, // right edge rows: usize, } impl Lay { fn need(&mut self, w: f32) { if self.x + w > self.max && self.x > self.x2 + 1.0 { self.x = self.x2; self.y += CH() * ROW; self.rows += 1; } } // room for a field of width w (wraps if needed) fn take(&mut self, w: f32) -> Rect { self.need(w); let r = Rect::new(self.x, self.y, w, CH() * 1.5); self.x += w + CW() * 0.6; r } fn take_tight(&mut self, w: f32) -> Rect { // the kind switch stays with its value: room for both self.need(w + CW() * 8.0); let r = Rect::new(self.x, self.y, w, CH() * 1.5); self.x += w + CW() * 0.2; r } fn word(&mut self, gfx: &Gfx, s: &str) { let w = gfx.measure(s, S()); self.need(w); gfx.text(s, self.x, self.y + CH() * 0.25, S(), TXT); self.x += w + CW() * 0.6; } } // 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); } }