- every kind loves one room (working there lifts the mood) and cannot stand certain other kinds; close by they sour each other's mood and now and then brawl (they stop at 30% health) - learned abilities: imps dig faster, kobold dash, spider web, mage shadow step, ogre stomp, jailer shackle, priestess healing pulse, chainbreaker whirlwind, bat shriek, gargoyle stone form, owl volley, wyrm inferno, cogling bomb - bestiary lists likes, hatreds and the ability (highlighted from its level on) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1099 lines
45 KiB
Rust
1099 lines
45 KiB
Rust
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||
|
||
mod audio;
|
||
mod campaign;
|
||
mod cloud;
|
||
mod demo;
|
||
mod dwarves;
|
||
mod fog;
|
||
mod fortress;
|
||
mod game;
|
||
mod gfx;
|
||
mod i18n;
|
||
mod map;
|
||
mod menu;
|
||
mod personality;
|
||
mod platform;
|
||
mod particles;
|
||
mod props;
|
||
mod research;
|
||
mod rival;
|
||
mod render3d;
|
||
mod save;
|
||
mod settings;
|
||
mod sfx;
|
||
mod tracks;
|
||
mod traps;
|
||
mod sound;
|
||
mod spells;
|
||
mod touch;
|
||
mod ui;
|
||
mod xm;
|
||
|
||
use cloud::{Cloud, CloudEvent};
|
||
use game::{Game, Msg};
|
||
use gfx::Gfx;
|
||
use macroquad::prelude::*;
|
||
use map::{idx, inb, mh, mw};
|
||
use menu::{Menu, MenuAction, Page};
|
||
use render3d::{Cam, Overlay, R3d};
|
||
use settings::{bindable_keys, Action, Settings};
|
||
use sound::SoundBank;
|
||
use touch::{Gesture, TouchInput};
|
||
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
|
||
|
||
const SETTINGS_KEY: &str = "dc.settings";
|
||
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
|
||
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
|
||
|
||
fn conf() -> macroquad::conf::Conf {
|
||
macroquad::conf::Conf {
|
||
miniquad_conf: miniquad::conf::Conf {
|
||
window_title: "DungeonCraft".into(),
|
||
window_width: 1280,
|
||
window_height: 800,
|
||
high_dpi: true,
|
||
..Default::default()
|
||
},
|
||
draw_call_vertex_capacity: 40000,
|
||
draw_call_index_capacity: 60000,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
struct Input {
|
||
ldown: Option<(i32, i32)>, // start of the selection rectangle
|
||
rdown: Option<(f32, f32)>, // right button press position
|
||
rlast: (f32, f32),
|
||
rdrag: bool,
|
||
mlast: Option<(f32, f32)>, // middle button drag
|
||
minimap_drag: bool,
|
||
// touch
|
||
tlast: Option<Vec2>, // last position of a one-finger pan (device pixels)
|
||
touch_world: bool, // the current finger started on the game world
|
||
inspect: Option<(f32, f32, f64)>, // long press: show information for this point (CSS pixels)
|
||
}
|
||
|
||
// Tile under the pointer for a tool: digging (and the hand) aim at walls too,
|
||
// building and everything else only at the floor below the pointer
|
||
fn tool_tile(cam: &Cam, map: &map::Map, tool: Tool, mx: f32, my: f32) -> Option<(i32, i32)> {
|
||
if matches!(tool, Tool::Dig | Tool::Hand | Tool::Steel) {
|
||
return cam.pick_tile(map, mx, my).map(|(x, z, _)| (x, z));
|
||
}
|
||
let p = cam.ground_point(mx, my);
|
||
let (x, z) = (p.x.floor() as i32, p.y.floor() as i32);
|
||
if inb(x, z) {
|
||
Some((x, z))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn area_tool(t: Tool) -> bool {
|
||
matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel)
|
||
}
|
||
|
||
// Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, my: f32, slap: bool, now: f64) {
|
||
if app.game.state != 0 {
|
||
return;
|
||
}
|
||
let tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my);
|
||
let ground = app.cam.ground_point(mx, my);
|
||
let (wx, wy) = (ground.x, ground.y);
|
||
match (ui.tool, tile) {
|
||
(Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel, Some(t)) => inp.ldown = Some(t),
|
||
(Tool::Hand, _) => {
|
||
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||
if slap {
|
||
let kind = app.game.slap(j);
|
||
if kind > 0 {
|
||
ui.hand_anim = Some((kind, now));
|
||
}
|
||
} else {
|
||
app.game.pickup(j);
|
||
}
|
||
}
|
||
(Tool::Flag, Some((x, z))) => app.game.set_flag(x, z),
|
||
(Tool::Lightning, _) => app.game.lightning(wx, wy),
|
||
(Tool::Heal, _) => app.game.heal(wx, wy),
|
||
(Tool::Summon, _) => app.game.summon(wx, wy),
|
||
(Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z),
|
||
(Tool::Trap(k), Some((x, z))) => app.game.place_trap(k, x, z),
|
||
(Tool::Spell(sp), _) => app.game.cast(sp, wx, wy),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Finish a selection rectangle at the tile under (mx, my)
|
||
fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
|
||
let Some((ex, ey)) = tool_tile(&app.cam, &app.game.map, tool, mx, my) else { return };
|
||
let (sx, sy) = start;
|
||
match tool {
|
||
Tool::Dig => {
|
||
let on = !app.game.map.t[idx(sx, sy)].marked;
|
||
app.game.mark_rect(sx, sy, ex, ey, on);
|
||
}
|
||
Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey),
|
||
Tool::Sell => app.game.sell_rect(sx, sy, ex, ey),
|
||
Tool::Bridge => app.game.build_bridges(sx, sy, ex, ey),
|
||
Tool::Steel => app.game.build_steel(sx, sy, ex, ey),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
|
||
let g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
|
||
let (cx, cy) = g.map.core;
|
||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||
(g, cam)
|
||
}
|
||
|
||
// a campaign mission with what the last mission handed over
|
||
fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
|
||
let g = campaign::start_mission(i, progress.carry_into(i));
|
||
let (cx, cy) = g.map.core;
|
||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||
(g, cam)
|
||
}
|
||
|
||
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
|
||
|
||
fn store_progress(p: &campaign::Progress) {
|
||
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
|
||
}
|
||
|
||
// login, account and standalone actions of the menu
|
||
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
|
||
match a {
|
||
MenuAction::Login(name, pw) => {
|
||
if name.is_empty() || pw.is_empty() {
|
||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||
} else if cloud.login(&name, &pw) {
|
||
menu.error = Some(i18n::tr("saves.wait"));
|
||
}
|
||
}
|
||
MenuAction::Logout => {
|
||
cloud.logout();
|
||
menu.open_login();
|
||
menu.error = None;
|
||
}
|
||
MenuAction::OpenAccount => {
|
||
if cloud.session.as_ref().map_or(false, |s| s.admin) {
|
||
cloud.load_users();
|
||
}
|
||
}
|
||
MenuAction::ChangePassword(old, new) => {
|
||
if new.chars().count() < 4 {
|
||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||
} else {
|
||
cloud.change_password(&old, &new);
|
||
}
|
||
}
|
||
MenuAction::CreateUser(name, pw) => {
|
||
if name.is_empty() || pw.chars().count() < 4 {
|
||
menu.error = Some(i18n::tr("login.err_invalid"));
|
||
} else {
|
||
cloud.create_user(&name, &pw);
|
||
}
|
||
}
|
||
MenuAction::DeleteUser(name) => {
|
||
cloud.delete_user(&name);
|
||
}
|
||
MenuAction::DownloadStandalone => {
|
||
platform::fetch_download("standalone/DungeonCraft.html", "DungeonCraft.html");
|
||
app.set_status(i18n::tr("standalone.running"), now);
|
||
}
|
||
MenuAction::EditField(i) => {
|
||
// touch screens have no keyboard in the canvas: the browser asks
|
||
if ui::touch_ui() {
|
||
let secret = matches!((menu.page, i), (Some(Page::Login), 1) | (Some(Page::Account), 0 | 1 | 3));
|
||
let title = i18n::tr(if secret { "login.password" } else { "login.name" });
|
||
if let Some(t) = platform::prompt(&title, if secret { "" } else { &menu.fields[i] }) {
|
||
menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(40).collect();
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn next_seed(seed: u32) -> u32 {
|
||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
||
}
|
||
|
||
fn apply_settings(s: &Settings, r3d: &mut R3d) {
|
||
i18n::set_language(&s.language);
|
||
ui::set_ui_factor(s.ui_factor);
|
||
r3d.outlines = s.outlines;
|
||
r3d.set_wall_detail(s.wall_subdivisions());
|
||
r3d.max_lights = s.max_lights as usize;
|
||
r3d.render_div = s.render_scale.clamp(1, 16) as u32;
|
||
audio::set_volumes(s.music_volume, s.volume, s.muted);
|
||
}
|
||
|
||
// Chooses the music for the current situation. Each category rotates through its tracks;
|
||
// build and title music switch to the next track after two loops.
|
||
struct MusicDirector {
|
||
next: std::collections::HashMap<u8, usize>, // category -> next index within the category
|
||
current: Option<audio::Category>,
|
||
}
|
||
|
||
impl MusicDirector {
|
||
fn new() -> MusicDirector {
|
||
MusicDirector { next: std::collections::HashMap::new(), current: None }
|
||
}
|
||
|
||
fn pick(&mut self, c: audio::Category) -> usize {
|
||
let list = audio::tracks_in(c);
|
||
let k = self.next.entry(c as u8).or_insert(0);
|
||
let t = list[*k % list.len()];
|
||
*k += 1;
|
||
t
|
||
}
|
||
|
||
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) {
|
||
use audio::Category as C;
|
||
let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e));
|
||
let near_room = |k: map::Kind| {
|
||
let (cx, cz) = (cam.0 as i32, cam.1 as i32);
|
||
let mut n = 0;
|
||
for y in cz - 5..=cz + 5 {
|
||
for x in cx - 5..=cx + 5 {
|
||
if g.map.get(x, y).map_or(false, |t| t.kind == k) {
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
n >= 4
|
||
};
|
||
let want = if title {
|
||
C::Title
|
||
} else if g.state == 1 {
|
||
C::Victory
|
||
} else if g.state == 2 {
|
||
C::Defeat
|
||
} else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin && e.guard) {
|
||
C::BossWarden
|
||
} else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin) {
|
||
C::BossPaladin
|
||
} else if g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive() {
|
||
C::Assault
|
||
} else if alive(&|e| e.active_hero() && !e.guard) {
|
||
C::Battle
|
||
} else if near_room(map::Kind::Torture) {
|
||
C::Torture
|
||
} else if near_room(map::Kind::Temple) {
|
||
C::Temple
|
||
} else {
|
||
C::Build
|
||
};
|
||
let rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2;
|
||
if self.current != Some(want) || rotate || audio::current_track().is_none() {
|
||
self.current = Some(want);
|
||
let t = self.pick(want);
|
||
let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else { 2.5 };
|
||
audio::play_music(t, fade);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn store_settings(s: &Settings) {
|
||
platform::storage_set(SETTINGS_KEY, &s.to_json());
|
||
}
|
||
|
||
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||
fn save_file_name() -> String {
|
||
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||
format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, h, mi)
|
||
}
|
||
|
||
// suggested name for a new savegame on the server: "Wave 3 - 2026-11-03 14:20"
|
||
fn default_save_name(g: &Game) -> String {
|
||
i18n::trf("saves.default_name", &[(g.wave + 1).to_string(), cloud::date_string(miniquad::date::now() as i64)])
|
||
}
|
||
|
||
struct App {
|
||
game: Game,
|
||
cam: Cam,
|
||
settings: Settings,
|
||
seed: u32,
|
||
status: Option<(String, f64)>,
|
||
recorded: bool, // the end of the current mission has been recorded in the campaign progress
|
||
}
|
||
|
||
impl App {
|
||
fn set_status(&mut self, text: String, now: f64) {
|
||
self.status = Some((text, now));
|
||
}
|
||
|
||
// Load a savegame (settings in the savegame are always applied)
|
||
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) {
|
||
match save::from_bytes(bytes) {
|
||
Ok(l) => {
|
||
self.game = l.game;
|
||
if let Some(c) = l.camera {
|
||
self.cam = c;
|
||
}
|
||
if let Some(s) = l.settings {
|
||
self.settings = s;
|
||
apply_settings(&self.settings, r3d);
|
||
store_settings(&self.settings);
|
||
}
|
||
self.game.hints = self.settings.hints;
|
||
r3d.invalidate();
|
||
menu.page = None;
|
||
ui.show_help = false;
|
||
ui.log.clear();
|
||
ui.log.push((Msg { key: "save.loaded", args: vec![] }, now));
|
||
self.status = None;
|
||
}
|
||
Err(key) => self.set_status(i18n::tr(key), now),
|
||
}
|
||
}
|
||
|
||
fn autosave_json(&self) -> String {
|
||
save::to_json(&self.game, &self.cam, &self.settings)
|
||
}
|
||
}
|
||
|
||
#[macroquad::main(conf)]
|
||
async fn main() {
|
||
let gfx = Gfx::new();
|
||
show_mouse(false); // the game draws its own pixel-art pointer
|
||
let mut r3d = R3d::new();
|
||
let mut sounds = SoundBank::new();
|
||
let mut music = MusicDirector::new();
|
||
let mut ui = Ui::new();
|
||
let mut menu = Menu::new();
|
||
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
|
||
|
||
// settings: stored in the browser; on the very first start the player picks a language
|
||
let stored = platform::storage_get(SETTINGS_KEY).and_then(|s| Settings::from_json(&s));
|
||
let mut settings = stored.clone().unwrap_or_default();
|
||
if stored.is_none() {
|
||
match platform::storage_get("dc.lang") {
|
||
Some(code) => settings.language = code,
|
||
None => ui.show_langsel = true,
|
||
}
|
||
}
|
||
apply_settings(&settings, &mut r3d);
|
||
ui.show_help = false;
|
||
menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some();
|
||
|
||
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 };
|
||
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||
app.game.hints = app.settings.hints;
|
||
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 };
|
||
let mut touch = TouchInput::new();
|
||
let mut auto_touch = platform::coarse_pointer();
|
||
// until the mouse has really moved its position is (0, 0): no edge scrolling before that
|
||
let mut mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click
|
||
let mut autosave_t = 0.0f32;
|
||
let mut cache_t = 0.0f32;
|
||
|
||
loop {
|
||
let dt = get_frame_time().min(0.1);
|
||
let now = get_time();
|
||
let mut settings_changed = false;
|
||
|
||
// ---------------- 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);
|
||
let gestures = if app.settings.touch_ui == 2 { Vec::new() } else { touch.update(now, ui.tool == Tool::Hand && !menu.open()) };
|
||
if !touches().is_empty() {
|
||
auto_touch = true;
|
||
} else if app.settings.touch_ui != 2 && (is_mouse_button_pressed(MouseButton::Left) || is_mouse_button_pressed(MouseButton::Right)) {
|
||
auto_touch = false;
|
||
}
|
||
let touch_mode = match app.settings.touch_ui {
|
||
1 => true,
|
||
2 => false,
|
||
_ => auto_touch,
|
||
};
|
||
ui::set_touch(touch_mode);
|
||
if touch_mode {
|
||
// the pointer is the finger, or the position of this frame's gesture
|
||
let gpos = gestures.iter().rev().find_map(|g| match *g {
|
||
Gesture::Press(p) | Gesture::Tap(p) | Gesture::Release(p) | Gesture::LongPress(p) | Gesture::DoubleTap(p) => Some(p),
|
||
_ => None,
|
||
});
|
||
let p = touch.finger().or(gpos).unwrap_or(vec2(-1e5, -1e5));
|
||
ui::set_pointer(p.x, p.y);
|
||
}
|
||
let (ux, uy) = ui::mouse(); // device pixels (interface)
|
||
let (mx, my) = if touch_mode { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking)
|
||
|
||
// ---------------- savegame files: file picker result or dropped onto the window
|
||
let mut incoming: Option<Vec<u8>> = platform::take_file();
|
||
for f in get_dropped_files() {
|
||
if let Some(b) = f.bytes {
|
||
incoming = Some(b);
|
||
}
|
||
}
|
||
if let Some(bytes) = incoming {
|
||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||
}
|
||
|
||
// ---------------- savegame service: finished requests
|
||
match cloud.poll() {
|
||
CloudEvent::None => {}
|
||
CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now),
|
||
CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now),
|
||
CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now),
|
||
CloudEvent::Error(key) => {
|
||
if matches!(menu.page, Some(Page::Account)) {
|
||
menu.error = Some(i18n::tr(key));
|
||
} else {
|
||
app.set_status(i18n::tr(key), now);
|
||
}
|
||
}
|
||
// accounts: the login page comes before the title screen
|
||
CloudEvent::LoginNeeded => {
|
||
if matches!(menu.page, Some(Page::Title) | None) {
|
||
menu.open_login();
|
||
menu.error = None;
|
||
}
|
||
}
|
||
CloudEvent::LoggedIn => {
|
||
if menu.page == Some(Page::Login) {
|
||
menu.page = Some(Page::Title);
|
||
}
|
||
menu.error = None;
|
||
if let Some(s) = &cloud.session {
|
||
app.set_status(i18n::trf("login.welcome", &[s.name.clone()]), now);
|
||
}
|
||
}
|
||
CloudEvent::LoginFailed(key) => menu.error = Some(i18n::tr(key)),
|
||
CloudEvent::PasswordChanged => {
|
||
menu.fields[0].clear();
|
||
menu.fields[1].clear();
|
||
menu.error = None;
|
||
app.set_status(i18n::tr("account.pw_changed"), now);
|
||
}
|
||
CloudEvent::UsersChanged => {
|
||
menu.fields[2].clear();
|
||
menu.fields[3].clear();
|
||
menu.error = None;
|
||
app.set_status(i18n::tr("account.users_changed"), now);
|
||
}
|
||
}
|
||
// download of the standalone file
|
||
match platform::fetch_download_state() {
|
||
2 => app.set_status(i18n::tr("standalone.done"), now),
|
||
3 => app.set_status(i18n::tr("standalone.failed"), now),
|
||
_ => {}
|
||
}
|
||
|
||
// ---------------- text fields (savegame name, login, account)
|
||
if menu.typing() {
|
||
// Tab switches the field right away (the following characters go to the next one)
|
||
let mut tab_char = false;
|
||
while let Some(c) = get_char_pressed() {
|
||
if c == '\t' {
|
||
tab_char = true;
|
||
menu.next_field();
|
||
} else {
|
||
menu.type_char(c);
|
||
}
|
||
}
|
||
if is_key_pressed(KeyCode::Tab) && !tab_char {
|
||
menu.next_field();
|
||
}
|
||
if is_key_pressed(KeyCode::Backspace) {
|
||
menu.backspace();
|
||
}
|
||
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
|
||
if matches!(menu.page, Some(Page::Saves { .. })) {
|
||
let name = menu.save_name.trim().to_string();
|
||
if cloud.save(None, &name, &app.autosave_json()) {
|
||
app.set_status(i18n::tr("saves.wait"), now);
|
||
}
|
||
} else {
|
||
let a = menu.submit();
|
||
account_action(a, &mut cloud, &mut menu, &mut app, now);
|
||
}
|
||
}
|
||
}
|
||
if menu.scrollable() {
|
||
let (_, wheel) = mouse_wheel();
|
||
if wheel != 0.0 {
|
||
menu.scroll_lines(if wheel > 0.0 { -1 } else { 1 });
|
||
}
|
||
}
|
||
|
||
// ---------------- key capture for the key binding dialog
|
||
if let Some(action) = menu.capturing() {
|
||
if is_key_pressed(KeyCode::Escape) {
|
||
menu.finish_capture();
|
||
} else if let Some(k) = get_last_key_pressed() {
|
||
if bindable_keys().any(|b| b == k) {
|
||
app.settings.bind(action, k);
|
||
apply_settings(&app.settings, &mut r3d);
|
||
store_settings(&app.settings);
|
||
menu.finish_capture();
|
||
}
|
||
}
|
||
let p = mouse_position();
|
||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now);
|
||
next_frame().await;
|
||
continue;
|
||
}
|
||
|
||
let dialog = ui.show_langsel || ui.show_help;
|
||
let in_menu = menu.open();
|
||
let playing = !in_menu && !dialog;
|
||
let over_ui = ui.over_ui(ux, uy);
|
||
let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my);
|
||
let ground = app.cam.ground_point(mx, my);
|
||
let (wx, wy) = (ground.x, ground.y);
|
||
let keys = app.settings.clone();
|
||
let key = |a: Action| keys.key(a);
|
||
|
||
// ---------------- Esc: dialogs, menus, pause menu
|
||
if is_key_pressed(KeyCode::Escape) {
|
||
if ui.show_langsel {
|
||
} else if ui.show_help {
|
||
ui.show_help = false;
|
||
} else if in_menu {
|
||
menu.back();
|
||
} else if inp.ldown.is_some() {
|
||
inp.ldown = None;
|
||
} else {
|
||
menu.page = Some(Page::Pause);
|
||
}
|
||
}
|
||
|
||
// ---------------- keyboard (only while playing)
|
||
if playing {
|
||
for i in 0..TOOLS.len() {
|
||
let k = key(Action::Tool(i));
|
||
if k != KeyCode::Unknown && is_key_pressed(k) && app.game.tool_allowed(i) && app.game.tool_visible(i) {
|
||
ui.tool = TOOLS[i];
|
||
inp.ldown = None;
|
||
}
|
||
}
|
||
if is_key_pressed(key(Action::Help)) {
|
||
ui.show_help = true;
|
||
}
|
||
if is_key_pressed(key(Action::Pause)) && app.game.state == 0 {
|
||
app.game.paused = !app.game.paused;
|
||
}
|
||
if is_key_pressed(key(Action::Outlines)) {
|
||
app.settings.outlines = !app.settings.outlines;
|
||
settings_changed = true;
|
||
}
|
||
if is_key_pressed(KeyCode::N) && app.game.state != 0 {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||
app.game.hints = app.settings.hints;
|
||
}
|
||
}
|
||
if !dialog && !menu.typing() {
|
||
if is_key_pressed(key(Action::Mute)) {
|
||
app.settings.muted = !app.settings.muted;
|
||
settings_changed = true;
|
||
}
|
||
if is_key_pressed(key(Action::VolumeUp)) || is_key_pressed(KeyCode::KpAdd) {
|
||
app.settings.volume = (app.settings.volume + 0.1).min(1.0);
|
||
settings_changed = true;
|
||
}
|
||
if is_key_pressed(key(Action::VolumeDown)) || is_key_pressed(KeyCode::KpSubtract) {
|
||
app.settings.volume = (app.settings.volume - 0.1).max(0.0);
|
||
settings_changed = true;
|
||
}
|
||
}
|
||
|
||
// ---------------- camera
|
||
if playing {
|
||
let cam = &mut app.cam;
|
||
let (right, fwd) = cam.ground_axes();
|
||
let pan = cam.zoom * 0.9 * dt;
|
||
let move_cam = |dx: f32, dz: f32, cam: &mut Cam| {
|
||
let d = right * dx + fwd * dz;
|
||
cam.tx += d.x;
|
||
cam.tz += d.y;
|
||
};
|
||
if is_key_down(key(Action::CamForward)) || is_key_down(KeyCode::Up) {
|
||
move_cam(0.0, pan, cam);
|
||
}
|
||
if is_key_down(key(Action::CamBack)) || is_key_down(KeyCode::Down) {
|
||
move_cam(0.0, -pan, cam);
|
||
}
|
||
if is_key_down(key(Action::CamLeft)) || is_key_down(KeyCode::Left) {
|
||
move_cam(-pan, 0.0, cam);
|
||
}
|
||
if is_key_down(key(Action::CamRight)) || is_key_down(KeyCode::Right) {
|
||
move_cam(pan, 0.0, cam);
|
||
}
|
||
let edge = 6.0;
|
||
if !mouse_moved && mouse_delta_position() != Vec2::ZERO {
|
||
mouse_moved = true;
|
||
}
|
||
if keys.edge_scroll && mouse_moved && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() {
|
||
if mx < edge {
|
||
move_cam(-pan, 0.0, cam);
|
||
} else if mx > screen_width() - edge {
|
||
move_cam(pan, 0.0, cam);
|
||
}
|
||
if my < edge {
|
||
move_cam(0.0, pan, cam);
|
||
} else if my > screen_height() - edge {
|
||
move_cam(0.0, -pan, cam);
|
||
}
|
||
}
|
||
let rot = 1.8 * dt * keys.rotate_speed;
|
||
if is_key_down(key(Action::RotateLeft)) {
|
||
cam.yaw += rot;
|
||
}
|
||
if is_key_down(key(Action::RotateRight)) {
|
||
cam.yaw -= rot;
|
||
}
|
||
if is_key_down(key(Action::TiltUp)) {
|
||
cam.pitch += rot * 0.6;
|
||
}
|
||
if is_key_down(key(Action::TiltDown)) {
|
||
cam.pitch -= rot * 0.6;
|
||
}
|
||
if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
|
||
if let Some((lx, ly)) = inp.mlast {
|
||
let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
|
||
cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv;
|
||
cam.pitch += (my - ly) * 0.005 * keys.rotate_speed * inv;
|
||
}
|
||
inp.mlast = Some((mx, my));
|
||
} else {
|
||
inp.mlast = None;
|
||
}
|
||
cam.pitch = cam.pitch.clamp(0.35, 1.45);
|
||
let (_, wheel) = mouse_wheel();
|
||
if wheel != 0.0 && !over_ui {
|
||
let before = cam.ground_point(mx, my);
|
||
let f = 0.88f32.powf(keys.zoom_speed);
|
||
cam.zoom = (cam.zoom * if wheel > 0.0 { f } else { 1.0 / f }).clamp(6.0, 45.0);
|
||
let after = cam.ground_point(mx, my);
|
||
cam.tx += before.x - after.x;
|
||
cam.tz += before.y - after.y;
|
||
}
|
||
// touch: two fingers move, zoom and rotate the camera
|
||
for g in &gestures {
|
||
if let Gesture::Camera { center, pan, zoom, rotate } = *g {
|
||
let d = ui::dpr();
|
||
let a = cam.ground_point((center.x - pan.x) / d, (center.y - pan.y) / d);
|
||
let b = cam.ground_point(center.x / d, center.y / d);
|
||
cam.tx -= b.x - a.x;
|
||
cam.tz -= b.y - a.y;
|
||
let before = cam.ground_point(center.x / d, center.y / d);
|
||
cam.zoom = (cam.zoom / zoom.max(0.01)).clamp(6.0, 45.0);
|
||
cam.yaw += rotate; // the scene turns with the fingers
|
||
let after = cam.ground_point(center.x / d, center.y / d);
|
||
cam.tx += before.x - after.x;
|
||
cam.tz += before.y - after.y;
|
||
}
|
||
}
|
||
// right button: drag = pan, click = drop
|
||
if is_mouse_button_pressed(MouseButton::Right) {
|
||
inp.rdown = Some((mx, my));
|
||
inp.rlast = (mx, my);
|
||
inp.rdrag = false;
|
||
}
|
||
if let Some((sx, sy)) = inp.rdown {
|
||
if is_mouse_button_down(MouseButton::Right) {
|
||
if (mx - sx).abs() + (my - sy).abs() > 5.0 {
|
||
inp.rdrag = true;
|
||
}
|
||
if inp.rdrag {
|
||
let a = cam.ground_point(inp.rlast.0, inp.rlast.1);
|
||
let b = cam.ground_point(mx, my);
|
||
cam.tx -= b.x - a.x;
|
||
cam.tz -= b.y - a.y;
|
||
}
|
||
inp.rlast = (mx, my);
|
||
}
|
||
}
|
||
if is_mouse_button_released(MouseButton::Right) {
|
||
if !inp.rdrag && !over_ui && app.game.state == 0 {
|
||
if inp.ldown.is_some() {
|
||
inp.ldown = None;
|
||
} else {
|
||
app.game.drop(wx, wy);
|
||
}
|
||
}
|
||
inp.rdown = None;
|
||
}
|
||
}
|
||
|
||
// ---------------- left button (touch: a finger touching the interface)
|
||
let pressed = if touch_mode { gestures.iter().any(|g| matches!(g, Gesture::Press(_))) } else { is_mouse_button_pressed(MouseButton::Left) };
|
||
if touch_mode && pressed {
|
||
inp.touch_world = playing && !over_ui;
|
||
inp.inspect = None;
|
||
}
|
||
if pressed {
|
||
if dialog {
|
||
match ui.click(ux, uy, &app.game) {
|
||
UiAction::SelectLanguage(code) => {
|
||
app.settings.language = code;
|
||
settings_changed = true;
|
||
ui.show_langsel = false;
|
||
}
|
||
UiAction::CloseHelp => ui.show_help = false,
|
||
_ => {}
|
||
}
|
||
} else if in_menu {
|
||
let from_title = menu.page == Some(Page::Title);
|
||
match menu.click(ux, uy, &mut app.settings, &cloud) {
|
||
MenuAction::SettingsChanged => settings_changed = true,
|
||
MenuAction::Resume => {}
|
||
MenuAction::NewGame => {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||
app.game.hints = app.settings.hints;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = true;
|
||
autosave_t = 0.0;
|
||
}
|
||
MenuAction::Continue => {
|
||
if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) {
|
||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now);
|
||
}
|
||
}
|
||
// with the savegame service: list on the server, otherwise savegame files
|
||
MenuAction::Load if cloud.available() => {
|
||
menu.open_saves(false, from_title, String::new());
|
||
cloud.refresh();
|
||
}
|
||
MenuAction::Save if cloud.available() => {
|
||
menu.open_saves(true, false, default_save_name(&app.game));
|
||
cloud.refresh();
|
||
}
|
||
MenuAction::Load | MenuAction::FileLoad => {
|
||
platform::open_file();
|
||
app.set_status(i18n::tr("save.choose"), now);
|
||
}
|
||
MenuAction::Save | MenuAction::FileSave => {
|
||
let name = save_file_name();
|
||
platform::download(&name, app.autosave_json().as_bytes());
|
||
app.set_status(i18n::trf("save.saved", &[name]), now);
|
||
}
|
||
MenuAction::CloudSave(id, name) => {
|
||
if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
|
||
app.set_status(i18n::tr("saves.wait"), now);
|
||
}
|
||
}
|
||
MenuAction::CloudLoad(id) => {
|
||
if cloud.load(&id) {
|
||
app.set_status(i18n::tr("saves.wait"), now);
|
||
}
|
||
}
|
||
MenuAction::CloudDelete(id) => {
|
||
cloud.delete(&id);
|
||
}
|
||
MenuAction::StartMission(i) => {
|
||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||
app.game.hints = app.settings.hints;
|
||
app.recorded = false;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
autosave_t = 0.0;
|
||
}
|
||
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
||
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
|
||
a @ (MenuAction::Login(..)
|
||
| MenuAction::Logout
|
||
| MenuAction::OpenAccount
|
||
| MenuAction::ChangePassword(..)
|
||
| MenuAction::CreateUser(..)
|
||
| MenuAction::DeleteUser(..)
|
||
| MenuAction::DownloadStandalone
|
||
| MenuAction::EditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
|
||
MenuAction::EditName => {
|
||
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
|
||
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
|
||
}
|
||
}
|
||
MenuAction::Help => ui.show_help = true,
|
||
MenuAction::MainMenu => {
|
||
if app.game.state == 0 {
|
||
platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
menu.has_autosave = true;
|
||
}
|
||
menu.page = Some(Page::Title);
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
}
|
||
MenuAction::None | MenuAction::Consumed => {}
|
||
}
|
||
} else {
|
||
match ui.click(ux, uy, &app.game) {
|
||
UiAction::SelectTool(t) => {
|
||
ui.tool = t;
|
||
app.game.sfx_ui(game::Sfx::Click);
|
||
}
|
||
UiAction::MinimapJump(x, y) => {
|
||
app.cam.tx = x;
|
||
app.cam.tz = y;
|
||
inp.minimap_drag = true;
|
||
}
|
||
UiAction::NewGame => {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||
app.game.hints = app.settings.hints;
|
||
}
|
||
// end of a mission: next mission (won) or try again (lost)
|
||
UiAction::EndNext => {
|
||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||
if app.game.state == 1 {
|
||
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
|
||
} else {
|
||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||
app.game.hints = app.settings.hints;
|
||
app.recorded = false;
|
||
ui.log.clear();
|
||
}
|
||
}
|
||
}
|
||
UiAction::EndCampaign => menu.page = Some(Page::Campaign),
|
||
UiAction::Briefing => {
|
||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||
menu.open_briefing(i);
|
||
}
|
||
}
|
||
UiAction::OpenMenu => menu.page = Some(Page::Pause),
|
||
UiAction::Locked => {
|
||
let locked = ui.last_locked.unwrap_or(usize::MAX);
|
||
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
|
||
app.game.sfx_ui(game::Sfx::Error);
|
||
}
|
||
UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {}
|
||
UiAction::None => {
|
||
if touch_mode {
|
||
// touch: area tools start their rectangle, everything else acts on the tap
|
||
if area_tool(ui.tool) && app.game.state == 0 {
|
||
inp.ldown = hover_tile;
|
||
}
|
||
} else {
|
||
let slap = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
|
||
world_action(&mut app, &mut ui, &r3d, &mut inp, mx, my, slap, now);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) };
|
||
if inp.minimap_drag {
|
||
if held && ui.minimap_contains(ux, uy) {
|
||
let (x, y) = ui.minimap_world(ux, uy);
|
||
app.cam.tx = x;
|
||
app.cam.tz = y;
|
||
} else if !held {
|
||
inp.minimap_drag = false;
|
||
}
|
||
}
|
||
|
||
// ---------------- touch gestures on the game world
|
||
if touch_mode {
|
||
let d = ui::dpr();
|
||
for g in &gestures {
|
||
match *g {
|
||
Gesture::Cancel => {
|
||
inp.ldown = None;
|
||
inp.tlast = None;
|
||
}
|
||
Gesture::Tap(p) | Gesture::DoubleTap(p) if playing && !ui.over_ui(p.x, p.y) => {
|
||
if !area_tool(ui.tool) {
|
||
let slap = matches!(g, Gesture::DoubleTap(_));
|
||
world_action(&mut app, &mut ui, &r3d, &mut inp, p.x / d, p.y / d, slap, now);
|
||
}
|
||
}
|
||
Gesture::LongPress(p) if playing && inp.touch_world => {
|
||
inp.ldown = None;
|
||
if ui.tool == Tool::Hand && !app.game.hand.is_empty() {
|
||
let gp = app.cam.ground_point(p.x / d, p.y / d);
|
||
app.game.drop(gp.x, gp.y);
|
||
} else {
|
||
inp.inspect = Some((p.x / d, p.y / d, now));
|
||
}
|
||
}
|
||
Gesture::DragStart(p) if playing && inp.touch_world && inp.ldown.is_none() => inp.tlast = Some(p),
|
||
Gesture::DragMove(p) => {
|
||
if let Some(l) = inp.tlast {
|
||
let a = app.cam.ground_point(l.x / d, l.y / d);
|
||
let b = app.cam.ground_point(p.x / d, p.y / d);
|
||
app.cam.tx -= b.x - a.x;
|
||
app.cam.tz -= b.y - a.y;
|
||
inp.tlast = Some(p);
|
||
}
|
||
}
|
||
Gesture::Release(p) => {
|
||
inp.tlast = None;
|
||
if let Some(start) = inp.ldown.take() {
|
||
if playing && inp.touch_world {
|
||
finish_rect(&mut app, ui.tool, start, p.x / d, p.y / d);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if inp.inspect.map_or(false, |(_, _, t)| now - t > 6.0) {
|
||
inp.inspect = None;
|
||
}
|
||
}
|
||
if !touch_mode && is_mouse_button_released(MouseButton::Left) {
|
||
if let Some(start) = inp.ldown.take() {
|
||
if playing {
|
||
finish_rect(&mut app, ui.tool, start, mx, my);
|
||
}
|
||
}
|
||
}
|
||
app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32);
|
||
app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32);
|
||
|
||
if settings_changed {
|
||
apply_settings(&app.settings, &mut r3d);
|
||
store_settings(&app.settings);
|
||
app.game.hints = app.settings.hints;
|
||
}
|
||
|
||
// ---------------- update (the world stands still while menus or dialogs are open;
|
||
// the bridge battle behind the title menu goes on, the camera slowly circles it)
|
||
if app.game.demo.is_some() && !playing {
|
||
app.game.update(dt);
|
||
app.cam.yaw += dt * 0.03;
|
||
app.game.events.retain(|ev| ev.pos.is_none());
|
||
}
|
||
if playing {
|
||
for _ in 0..(app.settings.game_speed.round().max(1.0) as usize) {
|
||
app.game.update(dt);
|
||
}
|
||
// autosave: snapshot for leaving the tab + regular autosave
|
||
if app.game.state == 0 {
|
||
cache_t += dt;
|
||
autosave_t += dt;
|
||
if cache_t >= AUTOSAVE_CACHE_EVERY {
|
||
cache_t = 0.0;
|
||
platform::cache_on_leave(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
}
|
||
if autosave_t >= AUTOSAVE_EVERY {
|
||
autosave_t = 0.0;
|
||
platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
menu.has_autosave = true;
|
||
}
|
||
}
|
||
}
|
||
let hear = app.cam.zoom * 1.2;
|
||
let (right, _) = app.cam.ground_axes();
|
||
for ev in app.game.events.drain(..) {
|
||
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now);
|
||
}
|
||
// a won mission goes into the campaign progress (with the carry-over)
|
||
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
||
if app.game.state != 0 {
|
||
app.recorded = true;
|
||
if app.game.state == 1 {
|
||
menu.progress.set_done(i, campaign::carry_from(&app.game));
|
||
store_progress(&menu.progress);
|
||
}
|
||
}
|
||
}
|
||
menu.mission = app.game.mission.as_ref().map(|m| m.index);
|
||
let on_title = menu.page == Some(Page::Title)
|
||
|| matches!(
|
||
menu.page,
|
||
Some(Page::Settings { from_title: true, .. })
|
||
| Some(Page::KeyCapture { from_title: true, .. })
|
||
| Some(Page::NewGame)
|
||
| Some(Page::Campaign)
|
||
| Some(Page::Briefing { ingame: false, .. })
|
||
| Some(Page::Login)
|
||
| Some(Page::Account)
|
||
| Some(Page::Bestiary { from_title: true, .. })
|
||
);
|
||
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
|
||
for m in app.game.msgs.drain(..) {
|
||
ui.log.push((m, now));
|
||
}
|
||
|
||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
|
||
next_frame().await
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
// pointer: CSS pixels of the mouse or finger; touch: hover highlight and information only
|
||
// while a finger is down or for a long-pressed point
|
||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, pointer: (f32, f32), touch: bool, now: f64) {
|
||
let (mx, my) = match (touch, inp.inspect) {
|
||
(true, Some((x, y, _))) if inp.ldown.is_none() => (x, y),
|
||
_ => pointer,
|
||
};
|
||
let (ux, uy) = (mx * ui::dpr(), my * ui::dpr());
|
||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
||
let over_ui = blocked || ui.over_ui(ux, uy);
|
||
let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my);
|
||
let ground = app.cam.ground_point(mx, my);
|
||
clear_background(Color::from_rgba(4, 3, 5, 255));
|
||
let (color, radius) = match ui.tool {
|
||
Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None),
|
||
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
|
||
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
|
||
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
|
||
Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None),
|
||
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))),
|
||
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))),
|
||
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
|
||
Tool::Bridge => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
||
Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None),
|
||
Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None),
|
||
Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None),
|
||
Tool::Spell(sp) => (Color::new(0.8, 0.55, 1.0, 1.0), Some((ground.x, ground.y, sp.radius().max(0.6)))),
|
||
};
|
||
let ov = Overlay {
|
||
hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) },
|
||
rect: inp.ldown.and_then(|(sx, sy)| hover_tile.map(|(ex, ey)| (sx, sy, ex.clamp(0, mw() as i32 - 1), ey.clamp(0, mh() as i32 - 1)))),
|
||
color,
|
||
radius: if over_ui { None } else { radius },
|
||
high: ui.tool == Tool::Dig,
|
||
};
|
||
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||
let title_screen = matches!(
|
||
menu.page,
|
||
Some(Page::Title)
|
||
| Some(Page::Settings { from_title: true, .. })
|
||
| Some(Page::KeyCapture { from_title: true, .. })
|
||
| Some(Page::Campaign)
|
||
| Some(Page::Briefing { ingame: false, .. })
|
||
| Some(Page::Login)
|
||
| Some(Page::Account)
|
||
| Some(Page::Bestiary { from_title: true, .. })
|
||
);
|
||
if !title_screen {
|
||
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
||
Vec::new()
|
||
} else {
|
||
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||
hover_info(&app.game, ent, hover_tile)
|
||
};
|
||
ui.draw(&app.game, gfx, &app.cam, &app.settings, &info, now);
|
||
} else {
|
||
ui.rects.clear();
|
||
}
|
||
if app.status.as_ref().map_or(false, |(_, t)| now - t > 4.0) {
|
||
app.status = None;
|
||
}
|
||
menu.draw(gfx, &app.settings, app.status.as_ref().map(|(s, _)| s.as_str()), cloud);
|
||
if !menu.open() || ui.show_help || ui.show_langsel {
|
||
ui.draw_dialogs(&app.game, gfx);
|
||
}
|
||
if !touch {
|
||
ui.draw_cursor(gfx, &app.game, now, blocked);
|
||
}
|
||
}
|