// In-canvas user interface: CP437 windows with the DINOBYTE bitmap font. // All texts come from the language files (see i18n.rs). use crate::game::*; use crate::gfx::*; use crate::i18n::{languages, tr, trf}; use crate::map::*; use crate::render3d::{minimap_color, Cam}; use crate::settings::{key_name, Action, Settings}; use macroquad::prelude::*; // ---- interface scaling // The interface is drawn in device pixels (see begin_ui). Every size is an integer multiple // of the font's 6x8 pixel glyphs: small text = 1*F, normal text = 2*F, titles = 4*F, where F // is the interface factor (automatic from the resolution or fixed in the settings). thread_local! { static UI_FACTOR_SETTING: std::cell::Cell = std::cell::Cell::new(0); static TOUCH: std::cell::Cell = std::cell::Cell::new(false); static POINTER: std::cell::Cell<(f32, f32)> = std::cell::Cell::new((-1e5, -1e5)); } pub fn set_ui_factor(f: u8) { UI_FACTOR_SETTING.with(|c| c.set(f)); } // touch interface active (bigger interface, category toolbar, gestures) pub fn set_touch(on: bool) { TOUCH.with(|c| c.set(on)); } pub fn touch_ui() -> bool { TOUCH.with(|c| c.get()) } // in touch mode the pointer is the finger (device pixels), set by the input code each frame pub fn set_pointer(x: f32, y: f32) { POINTER.with(|c| c.set((x, y))); } // device pixels per CSS pixel pub fn dpr() -> f32 { screen_dpi_scale() } // screen size and mouse in device pixels pub fn sw() -> f32 { (screen_width() * dpr()).round() } pub fn sh() -> f32 { (screen_height() * dpr()).round() } pub fn mouse() -> (f32, f32) { if touch_ui() { return POINTER.with(|c| c.get()); } let (x, y) = mouse_position(); (x * dpr(), y * dpr()) } // interface factor (integer) #[allow(non_snake_case)] pub fn F() -> u32 { let set = UI_FACTOR_SETTING.with(|c| c.get()); if set > 0 { return set as u32; } let auto = ((sw() / 1280.0).min(sh() / 760.0).floor() as u32).clamp(1, 4); if touch_ui() { // fingers need bigger buttons: about 2/3 of the device pixel ratio, // as long as at least 40 columns fit across the screen let want = (dpr() * 2.0 / 3.0).round() as u32; let fit = (sw() / (40.0 * GW * 2.0)).floor() as u32; return auto.max(want.min(fit)).clamp(1, 4); } auto } // text scales: 1x, 2x and 4x the glyph size, times F #[allow(non_snake_case)] pub fn SMALL() -> f32 { F() as f32 } #[allow(non_snake_case)] pub fn S() -> f32 { 2.0 * F() as f32 } #[allow(non_snake_case)] pub fn TITLE() -> f32 { 4.0 * F() as f32 } // character cell of normal text = the layout grid #[allow(non_snake_case)] pub fn CW() -> f32 { GW * S() } #[allow(non_snake_case)] pub fn CH() -> f32 { GH * S() } // pixel factor for icons and other pixel art #[allow(non_snake_case)] pub fn K() -> f32 { F() as f32 } // x position that centres a width on the character grid pub fn grid_center(total: f32, w: f32) -> f32 { (((total - w) * 0.5) / CW()).floor() * CW() } pub fn grid_center_y(total: f32, h: f32) -> f32 { (((total - h) * 0.5) / CH()).floor() * CH() } // 2D camera mapping one unit to one device pixel (top-left origin) pub fn begin_ui() { let (w, h) = (sw(), sh()); set_camera(&Camera2D { target: vec2(w * 0.5, h * 0.5), zoom: vec2(2.0 / w, 2.0 / h), ..Default::default() }); } // bar made of CP437 block characters: full blocks, a half block, shaded rest pub fn ascii_bar(gfx: &Gfx, x: f32, y: f32, cols: usize, frac: f32, col: Color, scale: f32) { let f = frac.clamp(0.0, 1.0) * cols as f32; let full = f.floor() as usize; let half = f - full as f32 >= 0.5; let dim = Color::new(col.r * 0.35, col.g * 0.35, col.b * 0.35, col.a); for k in 0..cols { let px = x + k as f32 * GW * scale; if k < full { gfx.glyph(0xDB, px, y, scale, col); } else if k == full && half { gfx.glyph(0xB0, px, y, scale, dim); gfx.glyph(0xDD, px, y, scale, col); } else { gfx.glyph(0xB0, px, y, scale, dim); } } } pub const FRAME: Color = Color::new(0.62, 0.52, 0.36, 1.0); pub const FRAME_HI: Color = Color::new(0.95, 0.8, 0.4, 1.0); pub const BG: Color = Color::new(0.06, 0.04, 0.05, 0.92); pub const TXT: Color = Color::new(0.9, 0.86, 0.78, 1.0); pub const DIM: Color = Color::new(0.6, 0.56, 0.5, 1.0); pub const GOLD: Color = Color::new(1.0, 0.84, 0.25, 1.0); pub const MANA: Color = Color::new(0.7, 0.45, 1.0, 1.0); pub const RED: Color = Color::new(0.95, 0.3, 0.3, 1.0); pub const GREEN: Color = Color::new(0.45, 0.9, 0.45, 1.0); pub const BLUE: Color = Color::new(0.5, 0.75, 1.0, 1.0); #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Tool { Dig, Hand, Room(usize), Sell, Flag, Lightning, Heal, Summon, Tower(TowerKind), Bridge, } // New tools are appended so that existing key bindings ("tool") keep their meaning pub const TOOLS: [Tool; 18] = [ Tool::Dig, Tool::Hand, Tool::Room(0), Tool::Room(1), Tool::Room(2), Tool::Room(3), Tool::Room(4), Tool::Sell, Tool::Flag, Tool::Lightning, Tool::Heal, Tool::Summon, Tool::Room(5), Tool::Room(6), Tool::Tower(TowerKind::Arrow), Tool::Tower(TowerKind::Cannon), Tool::Tower(TowerKind::Magic), Tool::Bridge, ]; pub struct ToolInfo { pub label: String, pub name: String, pub desc: String, pub icon: u32, } pub fn tool_info(t: Tool) -> ToolInfo { let (id, icon, room) = match t { Tool::Dig => ("dig", I_PICK, None), Tool::Hand => ("hand", I_HAND, None), Tool::Room(0) => ("treasury", T_TREASURY, Some(0)), Tool::Room(1) => ("lair", T_LAIR, Some(1)), Tool::Room(2) => ("farm", T_FARM, Some(2)), Tool::Room(3) => ("training", T_TRAINING, Some(3)), Tool::Room(4) => ("library", T_LIBRARY, Some(4)), Tool::Room(5) => ("torture", T_TORTURE, Some(5)), Tool::Room(_) => ("temple", T_TEMPLE, Some(6)), Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), Tool::Tower(TowerKind::Magic) => ("magic", I_MAGIC, None), Tool::Sell => ("sell", I_SELL, None), Tool::Flag => ("flag", I_FLAG, None), Tool::Lightning => ("lightning", I_BOLT, None), Tool::Heal => ("heal", I_HEAL, None), Tool::Summon => ("summon", I_SUMMON, None), Tool::Bridge => ("bridge", I_BRIDGE, None), }; let name = match room { Some(r) => tr(ROOM_NAMES[r]), None => tr(&format!("tool.{}.name", id)), }; ToolInfo { label: tr(&format!("tool.{}.label", id)), name, desc: tr(&format!("tool.{}.desc", id)), icon } } pub fn tool_cost(t: Tool, g: &Game) -> String { match t { Tool::Room(r) => trf("cost.gold_tile", &[ROOM_COST[r].to_string()]), Tool::Lightning => trf("cost.mana", &["50".into()]), Tool::Heal => trf("cost.mana", &["60".into()]), Tool::Summon => trf("cost.mana", &[(g.summon_cost() as i32).to_string()]), Tool::Bridge => trf("cost.bridge", &[BRIDGE_COST[0].to_string(), BRIDGE_COST[1].to_string()]), Tool::Tower(k) => { let s = tower_stats(k); let mut c = trf("cost.tower", &[s.cost[0].to_string(), s.cost[1].to_string(), s.cost[2].to_string()]); if s.mana > 0.0 { c += &trf("cost.per_shot", &[(s.mana as i32).to_string()]); } c } _ => String::new(), } } // Touch toolbar: tool categories (indices into TOOLS); categories with several tools open a row pub const CATS: [(&str, &[usize]); 6] = [ ("cat.dig", &[0]), ("cat.hand", &[1]), ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 17]), ("cat.towers", &[14, 15, 16]), ("cat.spells", &[9, 10, 11]), ("cat.orders", &[8, 7]), ]; #[derive(Clone, Copy, PartialEq, Debug)] enum TBtn { Cat(usize), Tool(usize), Menu, } pub enum UiAction { None, OpenMenu, SelectTool(Tool), MinimapJump(f32, f32), CloseHelp, NewGame, SelectLanguage(String), Consumed, } pub struct Ui { pub tool: Tool, pub show_help: bool, pub show_langsel: bool, pub log: Vec<(Msg, f64)>, pub rects: Vec, minimap_img: Image, minimap_tex: Texture2D, minimap_t: f64, minimap_rect: Rect, end_button: Rect, lang_rects: Vec<(Rect, String)>, pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time // touch interface open_cat: Option, tip: Option<(usize, f64)>, // tool whose description is shown, since touch_btns: Vec<(Rect, TBtn)>, hud_h: f32, } fn wrap(s: &str, cols: usize) -> Vec { let mut out = Vec::new(); let mut line = String::new(); for word in s.split(' ') { let len = line.chars().count(); if len > 0 && len + 1 + word.chars().count() > cols { out.push(std::mem::take(&mut line)); } if !line.is_empty() { line.push(' '); } line.push_str(word); } if !line.is_empty() { out.push(line); } out } fn mmss(t: f32) -> String { let t = t.max(0.0) as i32; format!("{}:{:02}", t / 60, t % 60) } impl Ui { pub fn new() -> Ui { let img = Image::gen_image_color(64, 64, BLACK); let tex = Texture2D::from_image(&img); tex.set_filter(FilterMode::Nearest); Ui { tool: Tool::Dig, show_help: true, show_langsel: false, log: Vec::new(), rects: Vec::new(), minimap_img: img, minimap_tex: tex, minimap_t: -1.0, minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0), end_button: Rect::new(0.0, 0.0, 0.0, 0.0), lang_rects: Vec::new(), hand_anim: None, open_cat: None, tip: None, touch_btns: Vec::new(), hud_h: 0.0, } } pub fn over_ui(&self, x: f32, y: f32) -> bool { self.show_help || self.show_langsel || self.rects.iter().any(|r| r.contains(vec2(x, y))) } pub fn minimap_contains(&self, x: f32, y: f32) -> bool { self.minimap_rect.contains(vec2(x, y)) } pub fn minimap_world(&self, x: f32, y: f32) -> (f32, f32) { let r = self.minimap_rect; ((x - r.x) / r.w * mw() as f32, (y - r.y) / r.h * mh() as f32) } pub fn click(&mut self, x: f32, y: f32, g: &Game) -> UiAction { let p = vec2(x, y); if self.show_langsel { for (r, code) in &self.lang_rects { if r.contains(p) { return UiAction::SelectLanguage(code.clone()); } } return UiAction::Consumed; } if g.state != 0 { if self.end_button.contains(p) { return UiAction::NewGame; } return UiAction::Consumed; } if self.show_help { return UiAction::CloseHelp; } if let Some(&(_, b)) = self.touch_btns.iter().find(|(r, _)| r.contains(p)) { return match b { TBtn::Menu => { self.open_cat = None; UiAction::OpenMenu } TBtn::Tool(i) => { self.open_cat = None; self.tip = Some((i, get_time())); UiAction::SelectTool(TOOLS[i]) } TBtn::Cat(c) => { let tools = CATS[c].1; if tools.len() == 1 { self.open_cat = None; self.tip = Some((tools[0], get_time())); UiAction::SelectTool(TOOLS[tools[0]]) } else { self.open_cat = if self.open_cat == Some(c) { None } else { Some(c) }; UiAction::Consumed } } }; } // a tap elsewhere closes an open category row self.open_cat = None; if self.minimap_rect.contains(p) { let (wx, wy) = self.minimap_world(x, y); return UiAction::MinimapJump(wx, wy); } if self.over_ui(x, y) { return UiAction::Consumed; } UiAction::None } pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, settings: &Settings, info: &[(String, Color)], now: f64) { self.rects.clear(); let keys: Vec = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect(); let sw = sw(); let sh = sh(); let (mx, my) = mouse(); // ---------------- top bar: segments flow over up to three lines on narrow screens let cols = (sw / CW()).floor() as i32; enum Part { T(String, Color), Bar(f32, Color), Gap, } let txt = |s: String, c: Color| Part::T(s, c); let cap = g.counts[1].min(30); let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) }; let mut gold_seg = vec![txt(tr("hud.gold"), GOLD), Part::Gap, txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT)]; let due = g.next_payday_cost().ceil() as i32; if due > 0 { // what the next payday costs, in red gold_seg.push(Part::Gap); gold_seg.push(txt(format!("-{}", due), RED)); } let mut segs: Vec> = vec![ gold_seg, vec![txt(tr("hud.mana"), MANA), Part::Gap, Part::Bar(g.mana / MANA_MAX, MANA), Part::Gap, txt(format!("{:<3}", g.mana as i32), TXT)], vec![txt(tr("hud.core"), RED), Part::Gap, Part::Bar(g.core_hp / CORE_HP, RED)], vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)], vec![txt(tr("hud.creatures"), TXT), Part::Gap, txt(format!("{}/{}", g.creature_count(), cap), TXT)], vec![txt(tr("hud.workers"), TXT), Part::Gap, txt(g.worker_count().to_string(), TXT)], vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)], vec![txt(tr("hud.payday"), if g.payday <= PAYDAY_WARN { RED } else { GOLD }), Part::Gap, txt(mmss(g.payday), TXT)], vec![txt(tr("hud.army"), TXT), Part::Gap, txt((g.army_value() as i32).to_string(), TXT)], ]; if !g.hand.is_empty() { segs.push(vec![txt(tr("hud.hand"), FRAME_HI), Part::Gap, txt(format!("{}/8", g.hand.len()), TXT)]); } segs.push(vec![txt("\u{266a}".to_string(), DIM), Part::Gap, txt(vol, DIM)]); let width = |seg: &Vec| -> usize { seg.iter().map(|p| match p { Part::T(s, _) => s.chars().count(), Part::Bar(..) => 6, Part::Gap => 1, }).sum() }; // place the segments: (line, column) let mut placed: Vec<(usize, usize)> = Vec::new(); let (mut line, mut c) = (0usize, 2usize); for seg in &segs { let w = width(seg); if c > 2 && c + w > cols as usize - 1 { line += 1; c = 2; } if line >= 3 || c + w > cols as usize - 1 { break; } placed.push((line, c)); c += w + 2; } let lines = placed.iter().map(|p| p.0).max().unwrap_or(0) + 1; let hud_rows = lines as i32 + 2; self.hud_h = hud_rows as f32 * CH(); gfx.window(0.0, 0.0, cols, hud_rows, S(), false, FRAME, BG); self.rects.push(Rect::new(0.0, 0.0, sw, self.hud_h)); for (seg, &(l, c0)) in segs.iter().zip(placed.iter()) { let y = CH() * (1 + l) as f32; let mut c = c0; for p in seg { match p { Part::T(s, col) => { gfx.text(s, c as f32 * CW(), y, S(), *col); c += s.chars().count(); } Part::Bar(f, col) => { ascii_bar(gfx, c as f32 * CW(), y, 6, *f, *col, S()); c += 6; } Part::Gap => c += 1, } } } // ---------------- toolbar: buttons of 6 x 3 characters, icons and labels on the grid let tb_rows = 5; let tb_y = sh - tb_rows as f32 * CH(); let tb_y = (tb_y / CH()).floor() * CH(); let tb_h = sh - tb_y; let tb_rows = (tb_h / CH()).floor() as i32; gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); self.rects.push(Rect::new(0.0, tb_y, sw, tb_h)); self.touch_btns.clear(); let mut hover_tool = None; let px = K(); // one pixel of the font, times the factor let pulse = (now * 2.5) as i64 % 2 == 0; // one toolbar button: icon, optional key, label in small text let tool_button = |bx: f32, by: f32, bw: f32, icon_id: u32, key: &str, label: &str, sel: bool, hovered: bool| { let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.42, 0.26, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); // the active tool: pulsing frame let frame = if sel { if pulse { WHITE } else { FRAME_HI } } else { Color::new(0.35, 0.28, 0.2, 1.0) }; draw_rectangle_lines(r.x, r.y, r.w, r.h, if sel { 3.0 * px } else { 2.0 * px }, frame); // icon: the 16 px atlas cell at 2*F, centred on the grid let icon = 32.0 * px; gfx.draw_cell(icon_id, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); if !key.is_empty() { gfx.text(key, bx + 3.0 * px, by + 3.0 * px, SMALL(), if sel { FRAME_HI } else { DIM }); } // label in small text, centred on whole small-character cells let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize; let label: String = label.chars().take(maxc.min(11)).collect(); let lw = gfx.measure(&label, SMALL()); let lx = bx + ((bw - lw) / (GW * SMALL()) / 2.0).floor() * GW * SMALL(); gfx.text(&label, lx, by + 2.0 * CH() + 4.0 * px, SMALL(), if sel { FRAME_HI } else { TXT }); r }; let mut popup_top = tb_y; // categories + menu button; a category with several tools opens a row above // (the same layout for mouse and touch; on the desktop the keys still select tools directly) let n = CATS.len() + 1; let bcols = (((cols - 2) as usize / n) as f32).clamp(4.0, 8.0); let bw = bcols * CW(); let x0 = grid_center(sw, bw * n as f32); let by = tb_y + CH(); let cur = TOOLS.iter().position(|&t| t == self.tool).unwrap_or(0); for (c, (key, tools)) in CATS.iter().enumerate() { let bx = x0 + c as f32 * bw; let has = tools.contains(&cur); let shown = if has { cur } else { tools[0] }; let ti = tool_info(TOOLS[shown]); let label = if tools.len() == 1 { ti.label.clone() } else { tr(key) }; let hovered = !touch_ui() && Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()).contains(vec2(mx, my)); // single tools show their key (desktop) and their description when hovered let key = if tools.len() == 1 && !touch_ui() { keys[tools[0]].as_str() } else { "" }; if hovered && tools.len() == 1 { hover_tool = Some(tools[0]); } let r = tool_button(bx, by, bw, ti.icon, key, &label, has, self.open_cat == Some(c) || hovered); if has { gfx.glyph(0x1F, bx + (bw - CW()) * 0.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI }); } if tools.len() > 1 { // small arrow: this button opens a row gfx.glyph(0x1E, r.x + r.w - GW * SMALL() - 2.0 * px, r.y + 2.0 * px, SMALL(), if self.open_cat == Some(c) { FRAME_HI } else { DIM }); } self.touch_btns.push((r, TBtn::Cat(c))); } // menu button let bx = x0 + CATS.len() as f32 * bw; let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); let hovered = !touch_ui() && r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0 * px, Color::new(0.35, 0.28, 0.2, 1.0)); gfx.glyph(0xF0, bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI); let ml = tr("cat.menu"); let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize; let ml: String = ml.chars().take(maxc).collect(); gfx.text(&ml, bx + (bw - gfx.measure(&ml, SMALL())) * 0.5, by + 2.0 * CH() + 4.0 * px, SMALL(), TXT); self.touch_btns.push((r, TBtn::Menu)); // open category row: (category, x0, button width, columns) let popup = self.open_cat.map(|c| (c, x0, bw, bcols)); // touch: after choosing a tool its description is shown for a moment if let Some((i, t)) = self.tip.filter(|_| touch_ui()) { if now - t < 3.5 && self.open_cat.is_none() { hover_tool = Some(i); } else if now - t >= 3.5 { self.tip = None; } } // ---------------- minimap: whole device pixels per tile let mm_size = if touch_ui() { 10.0 } else { 14.0 }; let ms = ((mm_size * CW()) / mw().max(mh()) as f32).floor().max(1.0); let (map_w, map_h) = (mw() as f32 * ms, mh() as f32 * ms); let mm_cols = ((map_w / CW()).ceil() as i32 + 2).max(10); let mm_rows = (map_h / CH()).ceil() as i32 + 2; let mm_w = mm_cols as f32 * CW(); let mm_h = mm_rows as f32 * CH(); let mm_x = ((sw - mm_w) / CW()).floor() * CW(); let mm_y = tb_y - mm_h; gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S(), false, FRAME, BG, &tr("ui.map"), FRAME_HI); self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h)); // the minimap image follows the map size if self.minimap_img.width as usize != mw() || self.minimap_img.height as usize != mh() { self.minimap_img = Image::gen_image_color(mw() as u16, mh() as u16, BLACK); self.minimap_tex = Texture2D::from_image(&self.minimap_img); self.minimap_tex.set_filter(FilterMode::Nearest); self.minimap_t = -1.0; } if now - self.minimap_t > 0.25 { self.minimap_t = now; for (i, t) in g.map.t.iter().enumerate() { let (x, y) = xy(i); let mut c = minimap_color(t); if t.marked { c = [255, 230, 90, 255]; } self.minimap_img.set_pixel(x as u32, y as u32, Color::from_rgba(c[0], c[1], c[2], c[3])); } self.minimap_tex.update(&self.minimap_img); } let mrx = (mm_x + (mm_w - map_w) * 0.5).floor(); let mry = (mm_y + (mm_h - map_h) * 0.5).floor(); self.minimap_rect = Rect::new(mrx, mry, map_w, map_h); draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_w, map_h)), ..Default::default() }); let dot = (2.0 * px).max(ms); for e in &g.ents { if e.alive && !e.held { let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) }; draw_rectangle((mrx + e.x.floor() * ms).floor(), (mry + e.y.floor() * ms).floor(), dot, dot, c); } } if let Some(f) = g.flag { let (fx, fy) = xy(f); draw_rectangle(mrx + fx as f32 * ms - px, mry + fy as f32 * ms - px, dot + 2.0 * px, dot + 2.0 * px, RED); } // expected entry points of the hero wave (blinking) if (now * 3.0) as i64 % 2 == 0 { for &(ex, ey) in &g.planned_entries { let (ppx, ppy) = (mrx + ex as f32 * ms, mry + ey as f32 * ms); draw_rectangle_lines(ppx - 4.0 * px, ppy - 4.0 * px, 9.0 * px, 9.0 * px, 2.0 * px, Color::new(0.6, 0.85, 1.0, 1.0)); } } // camera footprint on the ground (the 3D camera works in CSS pixels) let (lw, lh) = (screen_width(), screen_height()); let corners = [cam.ground_point(0.0, 0.0), cam.ground_point(lw, 0.0), cam.ground_point(lw, lh), cam.ground_point(0.0, lh)]; for k in 0..4 { let a = corners[k]; let b = corners[(k + 1) % 4]; let clampp = |p: Vec2| vec2(mrx + p.x.clamp(0.0, mw() as f32) * ms, mry + p.y.clamp(0.0, mh() as f32) * ms); let (pa, pb) = (clampp(a), clampp(b)); draw_line(pa.x, pa.y, pb.x, pb.y, px, WHITE); } // ---------------- info panel if !info.is_empty() { let icols = 30.min(cols - 2); let mut lines: Vec<(String, Color)> = Vec::new(); for (s, c) in info.iter().skip(1) { for l in wrap(s, icols as usize - 4) { lines.push((l, *c)); } } let irows = lines.len() as i32 + 2; let ix = sw - icols as f32 * CW(); let iy = self.hud_h; gfx.window_titled(ix, iy, icols, irows, S(), false, FRAME, BG, &info[0].0, info[0].1); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, ix + CW() * 2.0, iy + CH() * (k as f32 + 1.0), S(), *c); } } // ---------------- messages (translated at display time, so a language switch applies at once) let recent: Vec<&(Msg, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect(); if !recent.is_empty() { let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70); let mut lines: Vec<(String, f32)> = Vec::new(); let keep = if touch_ui() { 3 } else { 5 }; for (m, t) in recent.iter().rev().take(keep).rev() { let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); for l in wrap(&trf(m.key, &m.args), mcols as usize - 4) { lines.push((l, a)); } } let mrows = lines.len() as i32 + 2; let my0 = tb_y - mrows as f32 * CH(); gfx.window(0.0, my0, mcols, mrows, S(), false, FRAME, Color::new(0.06, 0.04, 0.05, 0.75)); self.rects.push(Rect::new(0.0, my0, mcols as f32 * CW(), mrows as f32 * CH())); for (k, (l, a)) in lines.iter().enumerate() { gfx.text(l, CW() * 2.0, my0 + CH() * (k as f32 + 1.0), S(), Color::new(TXT.r, TXT.g, TXT.b, *a)); } } if self.log.len() > 100 { self.log.drain(0..50); } // the open row of tools (wraps into several rows if needed) if let Some((c, x0, bw, bcols)) = popup { let tools = CATS[c].1; let per_row = (((cols - 2) as f32 / bcols).floor() as usize).max(1).min(tools.len()); let nrows = (tools.len() + per_row - 1) / per_row; let pw = per_row as f32 * bw + 2.0 * CW(); let ph = (nrows as f32 * 3.0 + 2.0) * CH(); let pcols = (pw / CW()) as i32; let prows = (ph / CH()) as i32; let px0 = ((x0 + c as f32 * bw + bw * 0.5 - pw * 0.5) / CW()).floor().clamp(0.0, ((sw - pw) / CW()).floor()) * CW(); let py0 = tb_y - ph; gfx.window_titled(px0, py0, pcols, prows, S(), false, FRAME_HI, BG, &tr(CATS[c].0), FRAME_HI); self.rects.push(Rect::new(px0, py0, pw, ph)); for (k, &i) in tools.iter().enumerate() { let bx = px0 + CW() + (k % per_row) as f32 * bw; let by = py0 + CH() + (k / per_row) as f32 * 3.0 * CH(); let ti = tool_info(TOOLS[i]); let hovered = !touch_ui() && Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()).contains(vec2(mx, my)); if hovered { hover_tool = Some(i); } let key = if touch_ui() { "" } else { keys[i].as_str() }; let r = tool_button(bx, by, bw, ti.icon, key, &ti.label, self.tool == TOOLS[i], hovered); self.touch_btns.push((r, TBtn::Tool(i))); } popup_top = py0; } // ---------------- Tooltip if let Some(i) = hover_tool { let t = TOOLS[i]; let ti = tool_info(t); let tcols = 34.min(cols as usize); let mut lines: Vec<(String, Color)> = wrap(&ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect(); let cost = tool_cost(t, g); if !cost.is_empty() { lines.push((trf("ui.cost", &[cost]), GOLD)); } if !touch_ui() { lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); } let rows = lines.len() as i32 + 2; let anchor = if touch_ui() { sw * 0.5 } else { mx }; let tx = (((anchor - tcols as f32 * CW() * 0.5) / CW()).floor() * CW()).clamp(0.0, (sw - tcols as f32 * CW()).max(0.0)); let ty = popup_top - rows as f32 * CH(); gfx.window_titled(tx, ty, tcols as i32, rows, S(), false, FRAME_HI, BG, &ti.name, FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, tx + CW() * 2.0, ty + CH() * (k as f32 + 1.0), S(), *c); } } } // Dialogs on top of everything (help, language selection, end screen, pause) pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) { let sw = sw(); let sh = sh(); if self.show_langsel { self.draw_langsel(gfx); } else if g.state != 0 { self.draw_end(g, gfx); } else if self.show_help { self.draw_help(gfx); } else if g.paused { let w = 24; let x = grid_center(sw, w as f32 * CW()); let y = sh * 0.35; gfx.window(x, y, w, 5, S(), true, FRAME_HI, BG); let t = tr("ui.pause"); gfx.text(&t, grid_center(sw, gfx.measure(&t, TITLE())), y + CH(), TITLE(), FRAME_HI); let t2 = tr("ui.pause_hint"); gfx.text(&t2, grid_center(sw, gfx.measure(&t2, S())), y + CH() * 3.0, S(), DIM); } } fn draw_help(&mut self, gfx: &Gfx) { let controls: &[&str] = if touch_ui() { &["help.t_tap", "help.t_drag", "help.t_two", "help.t_long", "help.t_double", "help.t_tools"] } else { &["help.k_left", "help.k_right", "help.k_slap", "help.k_pan", "help.k_rot", "help.k_zoom", "help.k_tools", "help.k_misc", "help.k_misc2"] }; let mut keys: Vec<(&str, Color)> = vec![("help.goal", GOLD), ("", TXT)]; keys.extend(controls.iter().map(|k| (*k, TXT))); keys.extend_from_slice(&[ ("", TXT), ("help.s1", TXT), ("help.s2", TXT), ("help.s3", TXT), ("help.s4", TXT), ("help.s5", TXT), ("", TXT), ("help.font", DIM), ("", TXT), ("help.start", FRAME_HI), ]); let cols = 74.min((sw() / CW()) as i32); let mut lines: Vec<(String, Color)> = Vec::new(); for (k, c) in &keys { if k.is_empty() { lines.push((String::new(), *c)); } else { let key = if *k == "help.start" && touch_ui() { "help.start_touch" } else { k }; for l in wrap(&tr(key), cols as usize - 6) { lines.push((l, *c)); } } } let rows = lines.len() as i32 + 4; let sw = sw(); let sh = sh(); let x = (grid_center(sw, cols as f32 * CW())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), "DungeonCraft", FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, x + CW() * 3.0, y + CH() * (k as f32 + 2.0), S(), *c); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } // First start: choose the language (names shown in their own language) fn draw_langsel(&mut self, gfx: &Gfx) { let sw = sw(); let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6)); let langs = languages(); let cols = 50; let rows = 6 + langs.len() as i32 * 3; let x = (grid_center(sw, cols as f32 * CW())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); // the title shows all language names, since no language is chosen yet let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::>().join(" / "); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); self.lang_rects.clear(); let (mx, my) = mouse(); for (k, (code, name)) in langs.iter().enumerate() { let bw = 24.0 * CW(); let bx = grid_center(sw, bw); let by = y + CH() * (2.0 + k as f32 * 3.0); let r = Rect::new(bx, by, bw, CH() * 2.0); let hov = r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hov { FRAME_HI } else { FRAME }); gfx.text(name, bx + (bw - gfx.measure(name, S())) * 0.5, by + CH() * 0.5, S(), TXT); self.lang_rects.push((r, code.clone())); } let hint_y = y + CH() * (2.0 + langs.len() as f32 * 3.0 + 0.5); for (k, (c, _)) in langs.iter().enumerate() { let h = crate::i18n::tr_in(c, "langsel.hint"); gfx.text(&h, x + CW() * 2.0, hint_y + CH() * k as f32, SMALL(), DIM); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } fn draw_end(&mut self, g: &Game, gfx: &Gfx) { let sw = sw(); let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.5)); let win = g.state == 1; let cols = 44; let rows = 11; let x = grid_center(sw, cols as f32 * CW()); let y = grid_center_y(sh, rows as f32 * CH()); let col = if win { GOLD } else { RED }; gfx.window(x, y, cols, rows, S(), true, col, Color::new(0.05, 0.03, 0.04, 0.97)); let title = tr(if win { "end.victory" } else { "end.defeat" }); gfx.text(&title, grid_center(sw, gfx.measure(&title, TITLE())), y + CH(), TITLE(), col); let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" }); gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), y + CH() * 4.5, S(), TXT); let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]); gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * 6.0, S(), DIM); let b = format!(" {} ", tr("end.new")); let b = b.as_str(); let bw = gfx.measure(b, S()) + CW() * 2.0; let bx = grid_center(sw, bw); let by = y + CH() * 7.75; self.end_button = Rect::new(bx, by, bw, CH() * 2.0); let (mx, my) = mouse(); let hov = self.end_button.contains(vec2(mx, my)); draw_rectangle(bx, by, bw, CH() * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); draw_rectangle_lines(bx, by, bw, CH() * 2.0, 2.0, col); gfx.text(b, bx + CW(), by + CH() * 0.5, S(), TXT); self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } } impl Ui { // Pixel-art mouse pointer: pointing hand with the active tool's icon, or the grabbing // hand for the hand tool (fist while holding units, animated slap/punch) pub fn draw_cursor(&self, gfx: &Gfx, g: &Game, now: f64, in_menu: bool) { let (mx, my) = mouse(); let px = K(); let sc = 2.0 * px; // cursor pixel size let anim = self.hand_anim.filter(|(_, t)| now - t < 0.25); let hand_tool = self.tool == Tool::Hand && !in_menu; let (frame, mut ox, mut oy) = match (anim, hand_tool) { (Some((1, t)), _) => { // backhand: swings from right to left let k = ((now - t) / 0.25) as f32; (CURSOR_BACKHAND, ((1.0 - 2.0 * k) * 6.0).round() * sc, 0.0) } (Some((_, t)), _) => { // punch: short thrust forward and back let k = ((now - t) / 0.25) as f32; let d = if k < 0.4 { -k / 0.4 } else { -(1.0 - k) / 0.6 }; (CURSOR_PUNCH, (d * 3.0).round() * sc, (d * 3.0).round() * sc) } (None, true) if !g.hand.is_empty() => (CURSOR_FIST, 0.0, 0.0), (None, true) => (CURSOR_OPEN, 0.0, 0.0), _ => (CURSOR_POINT, 0.0, 0.0), }; // idle: the pointing hand wiggles by one pixel if frame == CURSOR_POINT && (now * 2.0) as i64 % 2 == 0 { oy += sc; } if frame == CURSOR_OPEN && (now * 1.5) as i64 % 2 == 0 { ox += sc; } let (hx, hy) = CURSOR_HOT[frame as usize]; let x = (mx - hx * sc + ox).floor(); let y = (my - hy * sc + oy).floor(); // units in the hand dangle below the fist if hand_tool && !g.hand.is_empty() { if let Some(e) = g.hand.last().and_then(|id| g.ents.iter().find(|e| e.id == *id)) { let s = 10.0 * sc; draw_texture_ex( &gfx.sprites, x + 3.0 * sc, y + 12.0 * sc, WHITE, DrawTextureParams { dest_size: Some(vec2(s, s)), source: Some(Gfx::sprite_rect(e.kind, false)), rotation: 0.3, ..Default::default() }, ); } gfx.text_sh(&g.hand.len().to_string(), x + 15.0 * sc, y + 10.0 * sc, S(), FRAME_HI); } draw_texture_ex( &gfx.cursors, x, y, WHITE, DrawTextureParams { dest_size: Some(vec2(16.0 * sc, 16.0 * sc)), source: Some(Rect::new(frame as f32 * 16.0, 0.0, 16.0, 16.0)), ..Default::default() }, ); // small icon of the active action next to the pointing hand if frame == CURSOR_POINT && !in_menu { let icon = 16.0 * px; let (ix, iy) = (x + 11.0 * sc, y + 11.0 * sc); draw_rectangle(ix - px, iy - px, icon + 2.0 * px, icon + 2.0 * px, Color::new(0.05, 0.03, 0.04, 0.85)); draw_rectangle_lines(ix - px, iy - px, icon + 2.0 * px, icon + 2.0 * px, px, FRAME); gfx.draw_cell(tool_info(self.tool).icon, ix, iy, icon, icon, WHITE); } } } // Info text for the unit or tile under the mouse (first line = title) pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec<(String, Color)> { let mut out = Vec::new(); if let Some(j) = ent { let e = &g.ents[j]; let s = st(e.kind); out.push((trf("info.level", &[tr(s.name), e.level.to_string()]), if e.hero { BLUE } else { FRAME_HI })); out.push((trf("info.hp", &[(e.hp.max(0.0) as i32).to_string(), (e.max_hp as i32).to_string()]), GREEN)); if e.hero && e.captive != 0 { out.push((tr(["", "info.unconscious", "info.carried", "info.tortured"][e.captive.min(3) as usize]), RED)); return out; } out.push((tr(job_name(e)), TXT)); if e.hero { out.push((tr(if e.guard { "info.guard" } else { "info.intruder" }), BLUE)); } else if e.creature() { let pct = |v: f32| ((v.clamp(0.0, 1.0) * 100.0).round() as i32).to_string(); out.push((trf("info.needs", &[pct(e.energy), pct(e.hunger / 2.0)]), TXT)); let mood = if e.happy > 0.6 { ("mood.happy", GREEN) } else if e.happy > 0.25 { ("mood.grumpy", GOLD) } else { ("mood.angry", RED) }; out.push((trf("info.mood", &[tr(mood.0)]), mood.1)); let wage = (s.wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)) as i32; out.push((trf("info.wage", &[wage.to_string()]), GOLD)); } else if e.carry > 0 { out.push((trf("info.carries", &[e.carry.to_string()]), GOLD)); } return out; } let Some((x, y)) = tile else { return out }; let Some(t) = g.map.get(x, y) else { return out }; let owner = match t.owner { Owner::Player => "owner.player", Owner::Hero => "owner.hero", Owner::None => "owner.none", }; match t.kind { Kind::Rock => out.push((tr("tile.rock"), DIM)), Kind::Earth if t.reinforced => { out.push((tr("tile.reinforced"), FRAME_HI)); out.push((tr("tile.reinforced_info"), DIM)); } Kind::Earth => out.push((tr("tile.earth"), FRAME_HI)), Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => { let key = ["tile.gold", "tile.silver", "tile.ruby", "tile.diamond"][t.vein().unwrap_or(0) as usize]; out.push((tr(key), GOLD)); out.push((trf("tile.gold_amount", &[t.gold.to_string()]), GOLD)); } Kind::Floor => { out.push((tr("tile.floor"), TXT)); out.push((tr(owner), DIM)); } Kind::Core => { out.push((tr("tile.core"), RED)); out.push((trf("info.hp", &[(g.core_hp as i32).to_string(), (CORE_HP as i32).to_string()]), RED)); } Kind::Portal => { out.push((tr("tile.portal"), MANA)); out.push((tr(if t.owner == Owner::Player { "portal.claimed" } else { "portal.neutral" }), DIM)); } Kind::Tower => { if let Some(tw) = g.tower_at(idx(x, y)) { out.push((trf("info.tower", &[tr(tower_name(tw.kind)), tw.level.to_string()]), FRAME_HI)); out.push((trf("info.hp", &[(tw.hp as i32).to_string(), (tw.max_hp() as i32).to_string()]), GREEN)); out.push((trf("info.tower_stats", &[format!("{:.0}", tw.dmg()), format!("{:.1}", tw.range())]), TXT)); if tw.level < 3 { out.push((trf("info.tower_upgrade", &[tower_stats(tw.kind).cost[tw.level as usize].to_string()]), GOLD)); } } } Kind::Water => { out.push((tr("tile.water"), BLUE)); out.push((tr("tile.water_info"), DIM)); } Kind::Lava => { out.push((tr("tile.lava"), RED)); out.push((tr("tile.lava_info"), DIM)); } Kind::Bridge | Kind::LavaBridge => { out.push((tr(if t.kind == Kind::Bridge { "tile.bridge" } else { "tile.lavabridge" }), FRAME_HI)); out.push((tr(owner), DIM)); } Kind::Shard => { out.push((tr("tile.shard"), RED)); out.push((tr("tile.shard_info"), DIM)); } Kind::Gate => { out.push((tr("tile.gate"), BLUE)); out.push((trf("info.hp", &[(g.gate_hp as i32).to_string(), (GATE_HP as i32).to_string()]), BLUE)); } k => { if let Some(r) = room_index(k) { out.push((tr(ROOM_NAMES[r]), FRAME_HI)); match k { Kind::Farm => out.push((trf("farm.food", &[(t.food as i32).to_string()]), GREEN)), Kind::Treasury if t.gold > 0 => { let what = ["res.gold", "res.silver", "res.ruby", "res.diamond"][(t.res & 3) as usize]; out.push((trf("treasury.content", &[t.gold.to_string(), tr(what)]), GOLD)); } Kind::Lair => out.push((tr(if t.bed != 0 { "lair.taken" } else { "lair.free" }), DIM)), Kind::Torture => { let used = g.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count(); out.push((trf("info.prison", &[used.to_string(), (g.counts[5] / 2).to_string()]), DIM)); } _ => {} } } } } if t.kind == Kind::Rock { out.push((tr("tile.indestructible"), DIM)); } if t.marked { out.push((tr("tile.marked"), GOLD)); } out }