Files
dungeoncraft/src/main.rs
T
Richard ZinkandClaude Opus 5.5 fb7dab3683 Title screen, pause menu, settings dialog and savegames
- title screen (continue/new/load/settings/help) and Esc pause menu
- settings: language & sound, camera, graphics, game, rebindable keys;
  stored in localStorage and in every savegame (always applied on load)
- savegames as JSON (.dcsave): download, load via file picker or drag & drop
- autosave to localStorage every 2 minutes and when the tab is hidden/closed
- configurable wall detail, light count, interface size, game speed, hints
- audio behind a cargo feature so native tests run (cargo test --no-default-features)
- savegame round-trip test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:08:33 +01:00

567 lines
22 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 game;
mod gfx;
mod i18n;
mod map;
mod menu;
mod platform;
mod render3d;
mod save;
mod settings;
mod sound;
mod ui;
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: false,
..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) -> (Game, Cam) {
let g = Game::new(seed);
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, sounds: &mut SoundBank) {
i18n::set_language(&s.language);
ui::set_ui_scale(s.ui_scale as f32);
r3d.outlines = s.outlines;
r3d.set_wall_detail(s.wall_subdivisions());
r3d.max_lights = s.max_lights as usize;
sounds.volume = s.volume;
sounds.muted = s.muted;
}
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 secs = miniquad::date::now() as i64;
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, rem / 3600, rem % 3600 / 60)
}
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, sounds: &mut SoundBank, 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, sounds);
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();
let mut r3d = R3d::new();
let mut sounds = SoundBank::new();
sounds.generate_all().await;
let mut ui = Ui::new();
let mut menu = Menu::new();
// 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, &mut sounds);
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);
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();
let mut settings_changed = false;
// ---------------- 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, &mut sounds, now);
}
// ---------------- 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, &mut sounds);
store_settings(&app.settings);
menu.finish_capture();
}
}
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &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(mx, my);
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.game.hints = app.settings.hints;
}
}
if !dialog {
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(mx, my, &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 {
match menu.click(mx, my, &mut app.settings) {
MenuAction::SettingsChanged => settings_changed = true,
MenuAction::Resume => {}
MenuAction::NewGame => {
app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed);
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, &mut sounds, now);
}
}
MenuAction::Load => {
platform::open_file();
app.set_status(i18n::tr("save.choose"), now);
}
MenuAction::Save => {
let name = save_file_name();
platform::download(&name, app.autosave_json().as_bytes());
app.set_status(i18n::trf("save.saved", &[name]), now);
}
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(mx, my, &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.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, true);
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),
_ => {}
}
}
}
}
}
}
if inp.minimap_drag {
if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(mx, my) {
let (x, y) = ui.minimap_world(mx, my);
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, &mut sounds);
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;
for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), hear, now);
}
for m in app.game.msgs.drain(..) {
ui.log.push((m, now));
}
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &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, sounds: &SoundBank, inp: &Input, now: f64) {
let (mx, my) = mouse_position();
let blocked = menu.open() || ui.show_help || ui.show_langsel;
let over_ui = blocked || ui.over_ui(mx, my);
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),
};
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, sounds, &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()));
if !menu.open() || ui.show_help || ui.show_langsel {
ui.draw_dialogs(&app.game, gfx);
}
}