Files
dungeoncraft/src/main.rs
T
Richard ZinkandClaude Opus 5.5 2c8db33a69 Creature personality: favourite rooms, hatreds and brawls, abilities learned with the level
- 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>
2026-11-04 07:25:47 +01:00

1099 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
}