// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). mod audio; mod cloud; mod game; mod gfx; mod i18n; mod map; mod menu; mod platform; mod render3d; mod save; mod settings; mod sfx; mod tracks; mod sound; 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 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, } fn new_game(seed: u32, s: &Settings) -> (Game, Cam) { let g = Game::new(seed, s.map_w, s.map_h); let (cx, cy) = g.map.core; let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); (g, cam) } 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, 4) 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, // category -> next index within the category current: Option, } 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)>, } 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; let (game, cam) = new_game(seed, &settings); let mut app = App { game, cam, settings, seed, status: None }; 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 }; 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 (mx, my) = mouse_position(); // CSS pixels (3D picking) let (ux, uy) = ui::mouse(); // device pixels (interface) let mut settings_changed = false; // ---------------- savegame files: file picker result or dropped onto the window let mut incoming: Option> = 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) => app.set_status(i18n::tr(key), now), } // ---------------- name field of the savegame page if menu.typing() { while let Some(c) = get_char_pressed() { menu.type_char(c); } if is_key_pressed(KeyCode::Backspace) { menu.save_name.pop(); } if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { let name = menu.save_name.trim().to_string(); if cloud.save(None, &name, &app.autosave_json()) { app.set_status(i18n::tr("saves.wait"), now); } } } if matches!(menu.page, Some(Page::Saves { .. })) { let (_, wheel) = mouse_wheel(); if wheel != 0.0 { menu.scroll_by(if wheel > 0.0 { -1 } else { 1 }, cloud.list.len()); } } // ---------------- 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(); } } draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, 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 picked = app.cam.pick_tile(&app.game.map, mx, my); let hover_tile = picked.map(|(x, z, _)| (x, z)); 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() { if is_key_pressed(key(Action::Tool(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 keys.edge_scroll && 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) { 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; } // 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 if is_mouse_button_pressed(MouseButton::Left) { 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::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); } 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; } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} UiAction::None => { if app.game.state == 0 { match (ui.tool, hover_tile) { (Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t), (Tool::Hand, _) => { let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); if is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift) { 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), _ => {} } } } } } } if inp.minimap_drag { if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(ux, uy) { let (x, y) = ui.minimap_world(ux, uy); app.cam.tx = x; app.cam.tz = y; } else if !is_mouse_button_down(MouseButton::Left) { inp.minimap_drag = false; } } if is_mouse_button_released(MouseButton::Left) { if let Some((sx, sy)) = inp.ldown.take() { if let (true, Some((ex, ey))) = (playing, hover_tile) { match ui.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), _ => {} } } } } 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) 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); } 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)); 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, now); next_frame().await } } #[allow(clippy::too_many_arguments)] fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, now: f64) { let (mx, my) = mouse_position(); let (ux, uy) = ui::mouse(); let blocked = menu.open() || ui.show_help || ui.show_langsel; let over_ui = blocked || ui.over_ui(ux, uy); let hover_tile = app.cam.pick_tile(&app.game.map, mx, my).map(|(x, z, _)| (x, z)); 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::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), }; 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, .. }) ); if !title_screen { let info = if over_ui { 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); } ui.draw_cursor(gfx, &app.game, now, blocked); }