From f710a19351b13c09773622d78e14930e64935d53 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 06:34:09 +0100 Subject: [PATCH] Configurable map size (64-256, non-square), one portal per 64x64 tiles, guarded treasure caves, glowing dig-mark blocks - New Game dialog with map width/height (steps of 32), remembered in settings - map dimensions are per game; renderer chunks, minimap and camera follow them - content scales with area; grace period grows with the distance to the Hero Gate - treasure caves with rich gold, guarded by hero squads that hold their post - marked blocks: translucent yellow box with outline on all edges - savegame v2: packed run-length encoded tiles (256x256 map ~320 KB), v1 still loads - tests: savegame round trip on 96x128, 256x256 generation/simulation/save Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 8 ++ README.md | 1 + lang/de.lang | 6 + lang/en.lang | 6 + src/game.rs | 73 +++++++--- src/main.rs | 22 +-- src/map.rs | 365 ++++++++++++++++++++++++++++++++++++++++-------- src/menu.rs | 58 +++++++- src/render3d.rs | 125 ++++++++++++----- src/save.rs | 28 +++- src/settings.rs | 4 + src/ui.rs | 29 ++-- 12 files changed, 588 insertions(+), 137 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 0133978..908bd8e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -18,6 +18,7 @@ An original dungeon management game (genre: dungeon building & defence) that run - Textures: **procedural pixel art** (tile atlas) - Units: **pixel sprites as billboards** (always facing the camera) - Lighting: **dark + torches** – dark base level; claimed areas and light sources (torches, core, portal, gate) are bright, flickering light +- **Dig marking**: a marked block glows as a translucent yellow box with a yellow outline around all edges - **Tile outlines**: subtle dark lines around every tile (floors, wall tops, vertical wall edges), following the irregular surface; toggle with G (later in the graphics settings) - The UI is a 2D layer on top, including a minimap @@ -43,6 +44,13 @@ An original dungeon management game (genre: dungeon building & defence) that run - Music changes with **soft crossfades** (2.5 s): title → build → battle when heroes invade → storm music while attacking the gate → jingles - Sound effects are **synthesised chip sounds** (`src/sfx.rs`), positioned by distance and stereo panning +## Map size +- Chosen in the **New Game dialog**: width and height separately, **64–256 tiles** in steps of 32 (remembered in the settings) +- Content scales with the area: rock, gold veins and caves; the Hero Gate lies on the far side, the **grace period grows with the distance** to the gate +- **One portal per 64×64 tiles**; every claimed portal brings creatures (more portals = faster arrivals, still limited by the lair) +- Bigger maps get **guarded treasure caves** (one less than the number of portals): very rich gold veins, defended by a hero squad that stays at its post +- Savegames store the tiles packed (run-length encoded, base64), so even 256×256 maps stay small (~300 KB); version 1 savegames still load + ## Game content - **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground - **Rooms**: treasury, lair, mushroom garden, training hall, library diff --git a/README.md b/README.md index 8cefab8..b7a1777 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Dig out your underground realm, claim the portal to attract creatures, keep them - 3D isometric view with a freely rotatable and tiltable camera - Procedural pixel art, irregular cave walls, flickering torch light - CP437 text-mode style interface with the DINOBYTE bitmap font +- Map sizes from 64×64 to 256×256 tiles (one portal per 64×64 tiles, guarded treasure caves on bigger maps) - English and German (language files in `lang/`, easy to extend) See [DESIGN.md](DESIGN.md) for all design decisions and [CREDITS.md](CREDITS.md) for third-party assets. diff --git a/lang/de.lang b/lang/de.lang index d19b5b8..bb747ff 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -266,3 +266,9 @@ save.loaded = Spiel geladen. save.err_invalid = Diese Datei ist kein gültiger DungeonCraft-Spielstand. save.err_version = Dieser Spielstand stammt aus einer neueren DungeonCraft-Version. save.choose = Spielstand-Datei auswählen ... + +# ---- Neues Spiel +newgame.width = Kartenbreite +newgame.height = Kartenhöhe +newgame.start = Starten +newgame.info = {0} Portal(e), {1} bewachte Schatzhöhle(n) diff --git a/lang/en.lang b/lang/en.lang index 96df502..785dc33 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -267,3 +267,9 @@ save.loaded = Game loaded. save.err_invalid = This file is not a valid DungeonCraft savegame. save.err_version = This savegame comes from a newer version of DungeonCraft. save.choose = Choose a savegame file ... + +# ---- new game +newgame.width = Map width +newgame.height = Map height +newgame.start = Start +newgame.info = {0} portal(s), {1} guarded treasure cave(s) diff --git a/src/game.rs b/src/game.rs index a35e80c..f6eb5cb 100644 --- a/src/game.rs +++ b/src/game.rs @@ -159,7 +159,9 @@ pub struct Ent { pub face: f32, pub moving: bool, pub working: bool, - pub guard: bool, // gate defender + pub guard: bool, // stays at its home (gate defenders, treasure guards) + #[serde(default)] + pub home: Option<(f32, f32)>, // guard post } impl Ent { @@ -193,10 +195,11 @@ impl Ent { moving: false, working: false, guard: false, + home: None, } } pub fn tile(&self) -> usize { - idx((self.x as i32).clamp(0, MW as i32 - 1), (self.y as i32).clamp(0, MH as i32 - 1)) + idx((self.x as i32).clamp(0, mw() as i32 - 1), (self.y as i32).clamp(0, mh() as i32 - 1)) } pub fn atk(&self) -> f32 { st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32) @@ -293,8 +296,8 @@ fn sgn(v: f32) -> f32 { } impl Game { - pub fn new(seed: u32) -> Game { - let map = Map::generate(seed); + pub fn new(seed: u32, w: usize, h: usize) -> Game { + let map = Map::generate(seed, w, h); let mut g = Game { map, ents: Vec::new(), @@ -330,11 +333,28 @@ impl Game { g.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + k as f32 * 0.7 + 0.3, 1); } g.recount(); - for i in 0..N { + for i in 0..n() { if g.map.t[i].owner == Owner::Player { g.place_torches(i); } } + // heroes take longer to reach the core on big maps: longer grace period + let (gx, gy) = g.map.gate; + let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32; + g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0); + // treasure caves are guarded by heroes + for k in 0..g.map.treasures.len() { + let (tx, ty) = g.map.treasures[k]; + let squad = [EK::Knight, EK::Archer, EK::Dwarf, if k % 2 == 0 { EK::Priest } else { EK::Knight }]; + for (j, &kind) in squad.iter().enumerate() { + let x = tx as f32 + 0.5 + (j as f32 - 1.5) * 0.6; + let id = g.spawn(kind, true, x, ty as f32 + 0.5, 2); + if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { + e.guard = true; + e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5)); + } + } + } g.msg("msg.welcome1", vec![]); g.msg("msg.welcome2", vec![]); g.msg("msg.welcome3", vec![]); @@ -428,8 +448,19 @@ impl Game { acc } + pub fn portals_owned(&self) -> usize { + (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).count() + } + + // gate defenders alive (not the treasure guards) + pub fn gate_defenders_alive(&self) -> bool { + let (gx, gy) = self.gate_center(); + self.ents.iter().any(|e| e.alive && e.guard && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0)) + } + + #[allow(dead_code)] pub fn portal_owned(&self) -> bool { - let (px, py) = self.map.portal; + let Some(&(px, py)) = self.map.portals.first() else { return false }; self.map.t[idx(px, py)].owner == Owner::Player } @@ -499,10 +530,11 @@ impl Game { } } - if self.portal_owned() { + let owned = self.portals_owned(); + if owned > 0 { self.portal_timer -= dt; if self.portal_timer <= 0.0 { - self.portal_timer = 20.0 + self.rng.f() * 14.0; + self.portal_timer = (20.0 + self.rng.f() * 14.0) / (owned as f32).sqrt(); self.try_portal(); } } @@ -589,7 +621,9 @@ impl Game { } r -= w; } - let (px, py) = self.map.portal; + let owned: Vec = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect(); + let pk = owned[(self.rng.next() as usize) % owned.len()]; + let (px, py) = self.map.portals[pk]; self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1); self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff); self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5); @@ -670,8 +704,12 @@ impl Game { let x = gx as f32 + 0.5 + self.rng.f() * 2.0; let y = gy as f32 + 0.5 + self.rng.f() * 2.0; let id = self.spawn(k, true, x, y, l); + let home = (gx as f32 + 1.5, gy as f32 + 1.5); if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { e.guard = guard; + if guard { + e.home = Some(home); + } } } self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc); @@ -1120,8 +1158,9 @@ impl Game { if e.timer >= 1.0 { self.unreserve(me); e.job = Job::None; - if self.map.t[t].kind == Kind::Portal { - let (px, py) = self.map.portal; + let (tx, ty) = xy(t); + if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) { + let (px, py) = self.map.portals[pk]; for y in 0..3 { for x in 0..3 { self.map.t[idx(px + x, py + y)].owner = Owner::Player; @@ -1569,7 +1608,7 @@ impl Game { } } if let Job::Attack(id) = e.job { - let (gx, gy) = self.gate_center(); + let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center()); let far = e.guard && (e.x - gx).powi(2) + (e.y - gy).powi(2) > 14.0 * 14.0; if !far && self.attack_job(e, id, dt) { return; @@ -1579,8 +1618,8 @@ impl Game { e.think = 1.5; } if e.guard { - // gate defenders stay at the gate - let (gx, gy) = self.gate_center(); + // guards stay at their post (gate or treasure cave) + let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center()); let d2 = (e.x - gx).powi(2) + (e.y - gy).powi(2); if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 { e.repath = 1.0; @@ -1854,9 +1893,9 @@ mod tests { #[test] fn simulate_long_game() { for seed in [1u32, 7, 12345] { - let mut g = Game::new(seed); + let mut g = Game::new(seed, 64, 64); let (cx, cy) = g.map.core; - let (px, py) = g.map.portal; + let (px, py) = g.map.portals[0]; // dig a way to the portal (through earth/gold, around rock) let start = idx(cx - 1, cy + 1); let goal = idx(px + 1, py + 1); @@ -1912,7 +1951,7 @@ mod tests { g.army_value() ); } - if g.portal_owned() && step % 20 == 0 && !portal_logged { + if g.portals_owned() > 0 && step % 20 == 0 && !portal_logged { portal_logged = true; println!("seed {} portal claimed after {:.0}s", seed, g.time); } diff --git a/src/main.rs b/src/main.rs index 3309009..28e20c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ mod xm; use game::{Game, Msg}; use gfx::Gfx; use macroquad::prelude::*; -use map::{idx, inb, MH, MW}; +use map::{idx, inb, mh, mw}; use menu::{Menu, MenuAction, Page}; use render3d::{Cam, Overlay, R3d}; use settings::{bindable_keys, Action, Settings}; @@ -53,8 +53,8 @@ struct Input { minimap_drag: bool, } -fn new_game(seed: u32) -> (Game, Cam) { - let g = Game::new(seed); +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) @@ -82,7 +82,7 @@ impl MusicDirector { fn update(&mut self, g: &Game, title: bool) { use audio::Track; const BUILD: [Track; 3] = [Track::Build1, Track::Build2, Track::Build3]; - let gate_fight = g.time - g.gate_hit_t < 12.0 || g.ents.iter().any(|e| e.alive && e.guard); + let gate_fight = g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive(); let invasion = g.ents.iter().any(|e| e.alive && e.hero && !e.guard); let want = if title { Track::Title @@ -193,7 +193,7 @@ async fn main() { 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 (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 }; @@ -279,7 +279,7 @@ async fn main() { } 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, app.cam) = new_game(app.seed, &app.settings); app.game.hints = app.settings.hints; } } @@ -416,7 +416,7 @@ async fn main() { MenuAction::Resume => {} MenuAction::NewGame => { app.seed = next_seed(app.seed); - (app.game, app.cam) = new_game(app.seed); + (app.game, app.cam) = new_game(app.seed, &app.settings); app.game.hints = app.settings.hints; menu.page = None; ui.log.clear(); @@ -460,7 +460,7 @@ async fn main() { } UiAction::NewGame => { app.seed = next_seed(app.seed); - (app.game, app.cam) = new_game(app.seed); + (app.game, app.cam) = new_game(app.seed, &app.settings); app.game.hints = app.settings.hints; } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} @@ -507,8 +507,8 @@ async fn main() { } } } - app.cam.tx = app.cam.tx.clamp(0.0, MW as f32); - app.cam.tz = app.cam.tz.clamp(0.0, MH as f32); + 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); @@ -571,7 +571,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M }; 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)))), + 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, diff --git a/src/map.rs b/src/map.rs index c82a951..4ad6520 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,12 +1,36 @@ // Map, tiles, generation and path finding. +// +// The map size is chosen per game (64..256 tiles per side). Only one map is active at a time; +// its dimensions are kept in a thread-local so the many small helpers (idx, xy, inb) stay simple. +// Map::generate and loading a savegame (Map::activate) set them. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::cell::Cell; use std::cmp::Reverse; use std::collections::BinaryHeap; -pub const MW: usize = 64; -pub const MH: usize = 64; -pub const N: usize = MW * MH; +pub const MIN_SIZE: usize = 64; +pub const MAX_SIZE: usize = 256; + +thread_local! { + static DIMS: Cell<(usize, usize)> = Cell::new((64, 64)); +} + +fn set_dims(w: usize, h: usize) { + DIMS.with(|d| d.set((w, h))); +} +#[inline] +pub fn mw() -> usize { + DIMS.with(|d| d.get().0) +} +#[inline] +pub fn mh() -> usize { + DIMS.with(|d| d.get().1) +} +#[inline] +pub fn n() -> usize { + mw() * mh() +} #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Kind { @@ -24,6 +48,21 @@ pub enum Kind { Gate, } +const KINDS: [Kind; 12] = [ + Kind::Rock, + Kind::Earth, + Kind::Gold, + Kind::Floor, + Kind::Treasury, + Kind::Lair, + Kind::Farm, + Kind::Training, + Kind::Library, + Kind::Core, + Kind::Portal, + Kind::Gate, +]; + #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Owner { None, @@ -31,8 +70,10 @@ pub enum Owner { Hero, } -// Short serde names keep savegames small (the map has 4096 tiles) -#[derive(Clone, Copy, Serialize, Deserialize)] +const OWNERS: [Owner; 3] = [Owner::None, Owner::Player, Owner::Hero]; + +// Short serde names (only used by version 1 savegames, newer ones use the packed codec) +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Tile { #[serde(rename = "k")] pub kind: Kind, @@ -73,13 +114,14 @@ impl Tile { } pub fn idx(x: i32, y: i32) -> usize { - y as usize * MW + x as usize + y as usize * mw() + x as usize } pub fn xy(i: usize) -> (i32, i32) { - ((i % MW) as i32, (i / MW) as i32) + let w = mw(); + ((i % w) as i32, (i / w) as i32) } pub fn inb(x: i32, y: i32) -> bool { - x >= 0 && y >= 0 && (x as usize) < MW && (y as usize) < MH + x >= 0 && y >= 0 && (x as usize) < mw() && (y as usize) < mh() } pub const DIR4: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)]; @@ -109,13 +151,37 @@ impl Rng { #[derive(Serialize, Deserialize)] pub struct Map { + #[serde(default = "default_size")] + pub w: usize, + #[serde(default = "default_size")] + pub h: usize, + #[serde(serialize_with = "tiles_ser", deserialize_with = "tiles_de")] pub t: Vec, - pub core: (i32, i32), // top-left corner of the core (3x3) - pub portal: (i32, i32), + pub core: (i32, i32), // top-left corner of the core (3x3) + #[serde(default)] + pub portals: Vec<(i32, i32)>, // top-left corners of the portals (3x3) + #[serde(default, rename = "portal", skip_serializing)] + legacy_portal: Option<(i32, i32)>, // version 1 savegames had a single portal pub gate: (i32, i32), + #[serde(default)] + pub treasures: Vec<(i32, i32)>, // centres of the treasure caves +} + +fn default_size() -> usize { + 64 } impl Map { + // Make this map's dimensions the active ones (after loading a savegame) + pub fn activate(&mut self) { + set_dims(self.w, self.h); + if self.portals.is_empty() { + if let Some(p) = self.legacy_portal.take() { + self.portals.push(p); + } + } + } + pub fn get(&self, x: i32, y: i32) -> Option<&Tile> { if inb(x, y) { Some(&self.t[idx(x, y)]) @@ -128,10 +194,23 @@ impl Map { inb(x, y) && self.t[idx(x, y)].walkable() } - pub fn generate(seed: u32) -> Map { + // Index of the portal containing tile (x, y) + pub fn portal_at(&self, x: i32, y: i32) -> Option { + self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3) + } + + pub fn portal_owned(&self, k: usize) -> bool { + let (px, py) = self.portals[k]; + self.t[idx(px, py)].owner == Owner::Player + } + + pub fn generate(seed: u32, w: usize, h: usize) -> Map { + let w = w.clamp(MIN_SIZE, MAX_SIZE); + let h = h.clamp(MIN_SIZE, MAX_SIZE); + set_dims(w, h); let mut s = seed; loop { - let m = Map::try_generate(s); + let m = Map::try_generate(s, w, h); // make sure heroes can reach the core if m.hero_path_to_core(idx(m.gate.0 + 1, m.gate.1 + 1)).is_some() { return m; @@ -140,68 +219,99 @@ impl Map { } } - fn try_generate(seed: u32) -> Map { + fn try_generate(seed: u32, w: usize, h: usize) -> Map { + let (wi, hi) = (w as i32, h as i32); let mut rng = Rng(seed | 1); for _ in 0..10 { rng.next(); } - let mut t = vec![Tile::new(Kind::Earth); N]; + // content scales with the area; one portal per 64x64 tiles + let area = (w * h) as f32 / 4096.0; + let scale = |n: f32| (n * area).round() as i32; + let n_portals = (area.round() as usize).max(1); + let n_treasures = n_portals - 1; + + let mut t = vec![Tile::new(Kind::Earth); w * h]; for (i, tile) in t.iter_mut().enumerate() { - let (x, y) = xy(i); + let (x, y) = ((i % w) as i32, (i / w) as i32); tile.var = (rng.next() % 4) as u8; tile.hp = 6.0; - if x < 2 || y < 2 || x >= MW as i32 - 2 || y >= MH as i32 - 2 { + if x < 2 || y < 2 || x >= wi - 2 || y >= hi - 2 { tile.kind = Kind::Rock; } } - let core = (11, rng.range(26, 35)); - let portal = (rng.range(24, 30), if core.1 > 31 { rng.range(12, 18) } else { rng.range(44, 50) }); - let gate = (rng.range(51, 55), rng.range(10, 50)); + let inner = |x: i32, y: i32| x >= 2 && y >= 2 && x < wi - 2 && y < hi - 2; + let core = (11, hi / 2 + rng.range(-5, 3)); + let gate = (wi - 13 + rng.range(-2, 2), rng.range(10, hi - 14)); - let blob = |t: &mut Vec, rng: &mut Rng, cx: i32, cy: i32, r: i32, k: Kind| { + // places features far apart: best of several random candidates + let mut taken: Vec<(i32, i32)> = vec![(core.0 + 1, core.1 + 1), (gate.0 + 1, gate.1 + 1)]; + let place = |rng: &mut Rng, taken: &mut Vec<(i32, i32)>, margin: i32| -> (i32, i32) { + let mut best = (margin, margin); + let mut bd = -1; + for _ in 0..40 { + let p = (rng.range(margin, wi - margin - 3), rng.range(margin, hi - margin - 3)); + let d = taken.iter().map(|q| (p.0 - q.0).pow(2) + (p.1 - q.1).pow(2)).min().unwrap_or(0); + if d > bd { + bd = d; + best = p; + } + } + taken.push(best); + best + }; + // the first portal is close to the start + let first = (core.0 + rng.range(13, 19), (core.1 + if rng.f() < 0.5 { rng.range(-14, -10) } else { rng.range(10, 14) }).clamp(5, hi - 9)); + taken.push(first); + let mut portals = vec![first]; + for _ in 1..n_portals { + portals.push(place(&mut rng, &mut taken, 6)); + } + let mut treasures = Vec::new(); + for _ in 0..n_treasures { + let p = place(&mut rng, &mut taken, 7); + treasures.push((p.0 + 1, p.1 + 1)); + } + + let blob = |t: &mut Vec, rng: &mut Rng, cx: i32, cy: i32, r: i32, k: Kind, gold: (i32, i32)| { for y in cy - r..=cy + r { for x in cx - r..=cx + r { - if !inb(x, y) || x < 2 || y < 2 || x >= MW as i32 - 2 || y >= MH as i32 - 2 { + if !inner(x, y) { continue; } let d = ((x - cx) * (x - cx) + (y - cy) * (y - cy)) as f32; if d <= (r as f32 + 0.5).powi(2) - rng.f() * r as f32 { - let tl = &mut t[idx(x, y)]; + let tl = &mut t[y as usize * w + x as usize]; tl.kind = k; if k == Kind::Gold { - tl.gold = rng.range(250, 550); + tl.gold = rng.range(gold.0, gold.1); } } } } }; - - // rock clusters - for _ in 0..22 { - let cx = rng.range(4, MW as i32 - 5); - let cy = rng.range(4, MH as i32 - 5); + for _ in 0..scale(22.0) { + let (cx, cy) = (rng.range(4, wi - 5), rng.range(4, hi - 5)); let r = rng.range(1, 3); - blob(&mut t, &mut rng, cx, cy, r, Kind::Rock); + blob(&mut t, &mut rng, cx, cy, r, Kind::Rock, (0, 0)); } - // gold veins - for _ in 0..16 { - let cx = rng.range(4, MW as i32 - 5); - let cy = rng.range(4, MH as i32 - 5); + for _ in 0..scale(16.0) { + let (cx, cy) = (rng.range(4, wi - 5), rng.range(4, hi - 5)); let r = rng.range(1, 2); - blob(&mut t, &mut rng, cx, cy, r, Kind::Gold); + blob(&mut t, &mut rng, cx, cy, r, Kind::Gold, (250, 550)); } // guaranteed gold near the start for k in 0..3 { let cx = core.0 + rng.range(-3, 9); let cy = core.1 + if k % 2 == 0 { rng.range(-9, -6) } else { rng.range(7, 9) }; - blob(&mut t, &mut rng, cx, cy, 1, Kind::Gold); + blob(&mut t, &mut rng, cx, cy, 1, Kind::Gold, (250, 550)); } let room = |t: &mut Vec, x0: i32, y0: i32, x1: i32, y1: i32, owner: Owner| { for y in y0..=y1 { for x in x0..=x1 { - if inb(x, y) && x >= 2 && y >= 2 && x < MW as i32 - 2 && y < MH as i32 - 2 { - let tl = &mut t[idx(x, y)]; + if inner(x, y) { + let tl = &mut t[y as usize * w + x as usize]; tl.kind = Kind::Floor; tl.owner = owner; tl.gold = 0; @@ -209,38 +319,40 @@ impl Map { } } }; - // small natural caves - for _ in 0..4 { - let cx = rng.range(20, 48); - let cy = rng.range(6, 58); - let w = rng.range(1, 2); - let h = rng.range(1, 2); - room(&mut t, cx - w, cy - h, cx + w, cy + h, Owner::None); + for _ in 0..scale(4.0) { + let (cx, cy) = (rng.range(20, wi - 16), rng.range(6, hi - 6)); + let (cw, ch) = (rng.range(1, 2), rng.range(1, 2)); + room(&mut t, cx - cw, cy - ch, cx + cw, cy + ch, Owner::None); } - - // starting area + // treasure caves: a cave surrounded by very rich gold veins (guarded by heroes, see Game::new) + for &(cx, cy) in &treasures { + blob(&mut t, &mut rng, cx, cy, 4, Kind::Gold, (1000, 1600)); + room(&mut t, cx - 2, cy - 2, cx + 2, cy + 2, Owner::None); + } + // starting area and core room(&mut t, core.0 - 3, core.1 - 3, core.0 + 5, core.1 + 5, Owner::Player); - // core 3x3 for y in 0..3 { for x in 0..3 { - let tl = &mut t[idx(core.0 + x, core.1 + y)]; + let tl = &mut t[(core.1 + y) as usize * w + (core.0 + x) as usize]; tl.kind = Kind::Core; tl.owner = Owner::Player; } } - // portal cave - room(&mut t, portal.0 - 2, portal.1 - 2, portal.0 + 4, portal.1 + 4, Owner::None); - for y in 0..3 { - for x in 0..3 { - t[idx(portal.0 + x, portal.1 + y)].kind = Kind::Portal; + // portal caves + for &(px, py) in &portals { + room(&mut t, px - 2, py - 2, px + 4, py + 4, Owner::None); + for y in 0..3 { + for x in 0..3 { + t[(py + y) as usize * w + (px + x) as usize].kind = Kind::Portal; + } } } // hero gate room(&mut t, gate.0 - 2, gate.1 - 2, gate.0 + 4, gate.1 + 4, Owner::Hero); for y in 0..3 { for x in 0..3 { - let tl = &mut t[idx(gate.0 + x, gate.1 + y)]; + let tl = &mut t[(gate.1 + y) as usize * w + (gate.0 + x) as usize]; tl.kind = Kind::Gate; tl.owner = Owner::Hero; } @@ -250,7 +362,7 @@ impl Map { tl.hp = 6.0; } } - Map { t, core, portal, gate } + Map { w, h, t, core, portals, legacy_portal: None, gate, treasures } } // Generic Dijkstra search. @@ -261,8 +373,8 @@ impl Map { C: Fn(usize) -> Option, G: Fn(usize) -> bool, { - let mut dist = vec![u32::MAX; N]; - let mut prev = vec![usize::MAX; N]; + let mut dist = vec![u32::MAX; self.t.len()]; + let mut prev = vec![usize::MAX; self.t.len()]; let mut heap = BinaryHeap::new(); dist[start] = 0; heap.push(Reverse((0u32, start))); @@ -338,3 +450,140 @@ impl Map { self.search(start, self.walk_cost(), |i| i == target, true) } } + +// ------------------------------------------------------------------ compact tile codec +// +// Savegame version 2 stores the tiles as one base64 string: the 2-bit texture variants, +// then run-length encoded tile records (kind, owner, marked, hp, gold, food, reserved, bed). +// A 256x256 map shrinks from ~4.5 MB of JSON to a few hundred KB. + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len() * 4 / 3 + 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let v = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + for k in 0..4 { + if k <= chunk.len() { + out.push(B64[(v >> (18 - 6 * k) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn b64_decode(s: &str) -> Option> { + let mut val = [255u8; 256]; + for (i, &c) in B64.iter().enumerate() { + val[c as usize] = i as u8; + } + let bytes: Vec = s.bytes().filter(|&c| c != b'=').collect(); + let mut out = Vec::with_capacity(bytes.len() * 3 / 4); + for chunk in bytes.chunks(4) { + let mut v = 0u32; + for (k, &c) in chunk.iter().enumerate() { + let d = val[c as usize]; + if d == 255 { + return None; + } + v |= (d as u32) << (18 - 6 * k); + } + let n = chunk.len() * 6 / 8; + for k in 0..n { + out.push((v >> (16 - 8 * k)) as u8); + } + } + Some(out) +} + +const REC: usize = 23; + +fn tile_record(t: &Tile) -> [u8; REC] { + let mut r = [0u8; REC]; + r[0] = KINDS.iter().position(|&k| k == t.kind).unwrap_or(0) as u8; + r[1] = OWNERS.iter().position(|&o| o == t.owner).unwrap_or(0) as u8; + r[2] = t.marked as u8; + r[3..7].copy_from_slice(&t.hp.to_le_bytes()); + r[7..11].copy_from_slice(&t.gold.to_le_bytes()); + r[11..15].copy_from_slice(&t.food.to_le_bytes()); + r[15..19].copy_from_slice(&t.reserved.to_le_bytes()); + r[19..23].copy_from_slice(&t.bed.to_le_bytes()); + r +} + +fn tiles_ser(tiles: &[Tile], s: S) -> Result { + let mut out = Vec::new(); + out.extend_from_slice(&(tiles.len() as u32).to_le_bytes()); + // texture variants, 4 per byte + for chunk in tiles.chunks(4) { + let mut b = 0u8; + for (k, t) in chunk.iter().enumerate() { + b |= (t.var & 3) << (k * 2); + } + out.push(b); + } + // runs of identical records + let mut i = 0; + while i < tiles.len() { + let rec = tile_record(&tiles[i]); + let mut run = 1; + while i + run < tiles.len() && run < 65535 && tile_record(&tiles[i + run]) == rec { + run += 1; + } + out.extend_from_slice(&(run as u16).to_le_bytes()); + out.extend_from_slice(&rec); + i += run; + } + s.serialize_str(&format!("v2:{}", b64_encode(&out))) +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum TilesIn { + Packed(String), + Plain(Vec), // version 1 savegames +} + +fn tiles_de<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + use serde::de::Error; + match TilesIn::deserialize(d)? { + TilesIn::Plain(v) => Ok(v), + TilesIn::Packed(s) => { + let data = s.strip_prefix("v2:").and_then(b64_decode).ok_or_else(|| D::Error::custom("bad tile data"))?; + let rd32 = |o: usize| -> Option<[u8; 4]> { data.get(o..o + 4).map(|s| [s[0], s[1], s[2], s[3]]) }; + let count = u32::from_le_bytes(rd32(0).ok_or_else(|| D::Error::custom("bad tile data"))?) as usize; + if count > MAX_SIZE * MAX_SIZE { + return Err(D::Error::custom("map too large")); + } + let mut tiles = Vec::with_capacity(count); + let mut o = 4 + (count + 3) / 4; + while tiles.len() < count { + let run = data.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).ok_or_else(|| D::Error::custom("bad tile data"))? as usize; + let r = data.get(o + 2..o + 2 + REC).ok_or_else(|| D::Error::custom("bad tile data"))?; + let f = |a: usize| [r[a], r[a + 1], r[a + 2], r[a + 3]]; + let t = Tile { + kind: *KINDS.get(r[0] as usize).unwrap_or(&Kind::Rock), + owner: *OWNERS.get(r[1] as usize).unwrap_or(&Owner::None), + marked: r[2] != 0, + hp: f32::from_le_bytes(f(3)), + gold: i32::from_le_bytes(f(7)), + food: f32::from_le_bytes(f(11)), + reserved: u32::from_le_bytes(f(15)), + bed: u32::from_le_bytes(f(19)), + var: 0, + }; + for _ in 0..run.min(count - tiles.len()) { + tiles.push(t); + } + o += 2 + REC; + } + for (i, t) in tiles.iter_mut().enumerate() { + t.var = (data[4 + i / 4] >> ((i % 4) * 2)) & 3; + } + Ok(tiles) + } + } +} diff --git a/src/menu.rs b/src/menu.rs index d405f35..12cd2f0 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -3,6 +3,7 @@ use crate::gfx::*; use crate::i18n::{languages, tr, trf}; +use crate::map::{MAX_SIZE, MIN_SIZE}; use crate::settings::{all_actions, key_name, Action, Settings}; use crate::ui::{tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, TOOLS, TXT}; use macroquad::prelude::*; @@ -12,6 +13,7 @@ pub enum Page { Title, Pause, Settings { tab: usize, from_title: bool }, + NewGame, KeyCapture { action: usize, from_title: bool }, } @@ -150,6 +152,9 @@ enum Btn { Back, Tab(usize), Change(usize, i32), + MapW(i32), + MapH(i32), + Start, Key(usize), ResetKeys, } @@ -159,7 +164,7 @@ pub enum MenuAction { Consumed, Continue, // load the autosave Resume, - NewGame, + NewGame, // start a new game with the chosen map size Load, Save, Help, @@ -195,6 +200,7 @@ impl Menu { Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, from_title }), Some(Page::Settings { from_title: true, .. }) => Some(Page::Title), Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause), + Some(Page::NewGame) => Some(Page::Title), Some(Page::Pause) => None, Some(Page::Title) => Some(Page::Title), None => Some(Page::Pause), @@ -227,7 +233,19 @@ impl Menu { self.page = None; MenuAction::Resume } - Some(Btn::New) => MenuAction::NewGame, + Some(Btn::New) => { + self.page = Some(Page::NewGame); + MenuAction::Consumed + } + Some(Btn::Start) => MenuAction::NewGame, + Some(Btn::MapW(d)) => { + settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; + MenuAction::SettingsChanged + } + Some(Btn::MapH(d)) => { + settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; + MenuAction::SettingsChanged + } Some(Btn::Load) => MenuAction::Load, Some(Btn::Save) => MenuAction::Save, Some(Btn::Help) => MenuAction::Help, @@ -273,6 +291,7 @@ impl Menu { match page { Page::Title => self.draw_title(gfx), Page::Pause => self.draw_pause(gfx), + Page::NewGame => self.draw_new_game(gfx, settings), Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 4); @@ -334,6 +353,41 @@ impl Menu { gfx.text(&credit, (sw - gfx.measure(&credit, (S() / 2.0).max(1.0))) * 0.5, sh - CH() * 6.5, (S() / 2.0).max(1.0), DIM); } + // New game: choose the map size (width and height separately) + fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) { + let sw = screen_width(); + let sh = screen_height(); + let (mx, my) = mouse_position(); + let cols = 44; + let rows = 17; + let x = (sw - cols as f32 * CW()) * 0.5; + let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI); + let lines = [("newgame.width", s.map_w, 0), ("newgame.height", s.map_h, 1)]; + for (k, &(key, val, which)) in lines.iter().enumerate() { + let by = y + CH() * (2.0 + k as f32 * 2.5); + gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.4, S(), TXT); + let vw = 14.0 * CW(); + let vx = x + cols as f32 * CW() - vw - CW() * 3.0; + let left = Rect::new(vx, by, CW() * 2.5, CH() * 1.8); + let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 1.8); + button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); + button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); + let v = val.to_string(); + gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.4, S(), GOLD); + let (l, r) = if which == 0 { (Btn::MapW(-1), Btn::MapW(1)) } else { (Btn::MapH(-1), Btn::MapH(1)) }; + self.buttons.push((left, l)); + self.buttons.push((right, r)); + } + // what the size means for the game + let area = (s.map_w * s.map_h) as f32 / 4096.0; + let portals = (area.round() as usize).max(1); + let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]); + gfx.text(&info, x + CW() * 3.0, y + CH() * 7.5, S(), DIM); + let w = (cols - 6) as f32 * CW(); + self.column(gfx, x + CW() * 3.0, y + CH() * 9.5, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]); + } + fn draw_pause(&mut self, gfx: &Gfx) { let sw = screen_width(); let sh = screen_height(); diff --git a/src/render3d.rs b/src/render3d.rs index 36ef737..911ba57 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -12,7 +12,6 @@ use serde::{Deserialize, Serialize}; pub const WALL_H: f32 = 0.95; const CHUNK: i32 = 16; -const NCH: i32 = MW as i32 / CHUNK; pub const MAX_LIGHTS: usize = 24; const ATLAS_W: f32 = 256.0; const ATLAS_H: f32 = 80.0; @@ -350,7 +349,7 @@ impl MB { // Ambient light field: own territory is lit, foreign areas are dim fn ambient_field(map: &Map) -> Vec { - let mut f = vec![0.0f32; N]; + let mut f = vec![0.0f32; n()]; for (i, t) in map.t.iter().enumerate() { f[i] = match (t.solid(), t.owner) { (false, Owner::Player) => 1.0, @@ -363,8 +362,8 @@ fn ambient_field(map: &Map) -> Vec { } for _ in 0..2 { let src = f.clone(); - for y in 0..MH as i32 { - for x in 0..MW as i32 { + for y in 0..mh() as i32 { + for x in 0..mw() as i32 { let mut s = 0.0; let mut n = 0.0; for dy in -1..=1 { @@ -383,11 +382,11 @@ fn ambient_field(map: &Map) -> Vec { } fn amb_at(f: &[f32], x: f32, z: f32) -> f32 { - let gx = (x - 0.5).clamp(0.0, MW as f32 - 1.001); - let gz = (z - 0.5).clamp(0.0, MH as f32 - 1.001); + let gx = (x - 0.5).clamp(0.0, mw() as f32 - 1.001); + let gz = (z - 0.5).clamp(0.0, mh() as f32 - 1.001); let (x0, z0) = (gx.floor() as i32, gz.floor() as i32); let (fx, fz) = (gx - x0 as f32, gz - z0 as f32); - let g = |x: i32, z: i32| f[idx(x.min(MW as i32 - 1), z.min(MH as i32 - 1))]; + let g = |x: i32, z: i32| f[idx(x.min(mw() as i32 - 1), z.min(mh() as i32 - 1))]; let a = g(x0, z0) * (1.0 - fx) + g(x0 + 1, z0) * fx; let b = g(x0, z0 + 1) * (1.0 - fx) + g(x0 + 1, z0 + 1) * fx; 0.15 + 0.45 * (a * (1.0 - fz) + b * fz) @@ -412,13 +411,48 @@ fn edge_pts(a: Vec3, b: Vec3, n: usize) -> Vec { .collect() } -fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D, wall_sub: usize) -> (Option, Option, Option) { +const MARK_FILL: [u8; 4] = [255, 205, 50, 70]; +const MARK_LINE: [u8; 4] = [255, 220, 70, 235]; + +// Glowing yellow box around a block that is marked for digging +fn mark_block(mk: &mut MB, map: &Map, x: i32, z: i32) { + let (x0, z0) = (x as f32 - 0.06, z as f32 - 0.06); + let (x1, z1) = (x as f32 + 1.06, z as f32 + 1.06); + let top = WALL_H + 0.16; + let c = [x0, z0, x1, z1]; + let p = |xx: f32, y: f32, zz: f32| vec3(xx, y, zz); + // top face + mk.quad([p(c[0], top, c[1]), p(c[2], top, c[1]), p(c[2], top, c[3]), p(c[0], top, c[3])], [Vec2::ZERO; 4], [MARK_FILL; 4], [Vec3::Y; 4]); + let corners = [p(c[0], 0.0, c[1]), p(c[2], 0.0, c[1]), p(c[2], 0.0, c[3]), p(c[0], 0.0, c[3])]; + // side faces towards open neighbours, outline on all edges + for k in 0..4 { + let a = corners[k]; + let b = corners[(k + 1) % 4]; + let (dx, dz) = DIR4[k]; + let up = vec3(0.0, top, 0.0); + let out = vec3(dx as f32, 0.0, dz as f32); + if !solid_at(map, x + dx, z + dz) { + mk.quad([a, b, b + up, a + up], [Vec2::ZERO; 4], [MARK_FILL; 4], [out; 4]); + mk.strip(a + vec3(0.0, 0.02, 0.0), b + vec3(0.0, 0.02, 0.0), out, 0.05, MARK_LINE); + } + mk.strip(a + up, b + up, Vec3::Y, 0.06, MARK_LINE); + // vertical edge at this corner: down to the floor only where a side is open, + // otherwise just the part that sticks out above the neighbouring walls + let (pdx, pdz) = DIR4[(k + 3) % 4]; + let open_here = !solid_at(map, x + dx, z + dz) || !solid_at(map, x + pdx, z + pdz); + let bottom = if open_here { 0.0 } else { WALL_H - 0.05 }; + mk.strip(a + vec3(0.0, bottom, 0.0), a + up, out, 0.05, MARK_LINE); + } +} + +fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D, wall_sub: usize) -> [Option; 4] { let mut mb = MB::new(); - let mut ov = MB::new(); + let ov = MB::new(); // textured overlays (currently unused) let mut ln = MB::new(); + let mut mk = MB::new(); let ld = LIGHT_DIR.normalize(); - for z in cz * CHUNK..(cz + 1) * CHUNK { - for x in cx * CHUNK..(cx + 1) * CHUNK { + for z in cz * CHUNK..((cz + 1) * CHUNK).min(mh() as i32) { + for x in cx * CHUNK..((cx + 1) * CHUNK).min(mw() as i32) { let t = map.t[idx(x, z)]; let tex = Gfx::tile_tex(t.kind, t.owner == Owner::Player, t.owner == Owner::Hero, t.var); let (ua, ub) = cell_uv(tex); @@ -510,14 +544,7 @@ fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: ln.polyline(&edge_pts(tr, br, n), Vec3::Y, OUTLINE_W, OUTLINE_C); } if t.marked { - let (a, b) = cell_uv(T_MARK); - let y = WALL_H + 0.12; - ov.quad( - [vec3(xf, y, zf), vec3(xf + 1.0, y, zf), vec3(xf + 1.0, y, zf + 1.0), vec3(xf, y, zf + 1.0)], - [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)], - [[255, 255, 255, 230]; 4], - [Vec3::Y; 4], - ); + mark_block(&mut mk, map, x, z); } for (d, &(dx, dz)) in DIR4.iter().enumerate() { if solid_at(map, x + dx, z + dz) { @@ -550,7 +577,7 @@ fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: } } } - (mb.into_mesh(atlas), ov.into_mesh(atlas), ln.into_plain_mesh()) + [mb.into_mesh(atlas), ov.into_mesh(atlas), ln.into_plain_mesh(), mk.into_plain_mesh()] } // Subdivided, displaced face. pos(u, v) gives the undisplaced point (u, v in 0..1). @@ -601,6 +628,7 @@ struct Chunk { mesh: Option, overlay: Option, lines: Option, + marks: Option, } pub struct Overlay { @@ -615,6 +643,8 @@ pub struct R3d { lit: Material, glow: Material, chunks: Vec, + ncx: i32, // chunks per row / column for the current map size + ncz: i32, pub outlines: bool, // tile outlines on/off wall_sub: usize, // wall face subdivisions (detail) pub max_lights: usize, @@ -647,8 +677,7 @@ fn chunk_sig(map: &Map, cx: i32, cz: i32, fill_bucket: u8) -> u64 { impl R3d { pub fn new() -> R3d { - let chunks = (0..NCH * NCH).map(|_| Chunk { sig: 0, mesh: None, overlay: None, lines: None }).collect(); - R3d { lit: lit_material(), glow: glow_material(), chunks, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS } + R3d { lit: lit_material(), glow: glow_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS } } // Changing the wall detail rebuilds all chunks @@ -665,20 +694,37 @@ impl R3d { } } - fn update_chunks(&mut self, g: &Game, gfx: &Gfx) { + // (Re)creates the chunk grid when the map size changes + fn ensure_chunks(&mut self) { + let ncx = (mw() as i32 + CHUNK - 1) / CHUNK; + let ncz = (mh() as i32 + CHUNK - 1) / CHUNK; + if ncx != self.ncx || ncz != self.ncz { + self.ncx = ncx; + self.ncz = ncz; + self.chunks = (0..ncx * ncz).map(|_| Chunk { sig: 0, mesh: None, overlay: None, lines: None, marks: None }).collect(); + } + } + + // Rebuilds visible chunks whose content changed (off-screen chunks are updated when they appear) + fn update_chunks(&mut self, g: &Game, gfx: &Gfx, m: &Mat4) { + self.ensure_chunks(); let fill = (g.gold / g.gold_cap()).clamp(0.0, 1.0); let bucket = (fill * 6.0) as u8; let mut amb: Option> = None; - for cz in 0..NCH { - for cx in 0..NCH { + for cz in 0..self.ncz { + for cx in 0..self.ncx { + if !Self::chunk_visible(m, cx, cz) { + continue; + } let sig = chunk_sig(&g.map, cx, cz, bucket); - let ch = &mut self.chunks[(cz * NCH + cx) as usize]; + let ch = &mut self.chunks[(cz * self.ncx + cx) as usize]; if ch.sig != sig { let a = amb.get_or_insert_with(|| ambient_field(&g.map)); - let (m, o, l) = build_chunk(&g.map, a, cx, cz, bucket as f32 / 6.0, &gfx.atlas, self.wall_sub); + let [m, o, l, k] = build_chunk(&g.map, a, cx, cz, bucket as f32 / 6.0, &gfx.atlas, self.wall_sub); ch.mesh = m; ch.overlay = o; ch.lines = l; + ch.marks = k; ch.sig = sig; } } @@ -718,9 +764,10 @@ impl R3d { let (cx, cy) = g.map.core; let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); push(vec3(cx as f32 + 1.5, 1.2, cy as f32 + 1.5), 7.0, vec3(1.0, 0.25, 0.2), 1.1 + 0.2 * (t * 3.0).sin() * (1.5 - hp), 100.0); - let (px, py) = g.map.portal; - let owned = g.portal_owned(); - push(vec3(px as f32 + 1.5, 0.8, py as f32 + 1.5), 6.0, vec3(0.7, 0.3, 1.0), if owned { 1.0 } else { 0.35 }, 50.0); + for (k, &(px, py)) in g.map.portals.iter().enumerate() { + let owned = g.map.portal_owned(k); + push(vec3(px as f32 + 1.5, 0.8, py as f32 + 1.5), 6.0, vec3(0.7, 0.3, 1.0), if owned { 1.0 } else { 0.35 }, 20.0); + } if g.gate_hp > 0.0 { let (gx, gz) = g.gate_center(); push(vec3(gx, 1.2, gz), 7.0, vec3(0.75, 0.85, 1.0), 1.0, 50.0); @@ -765,8 +812,8 @@ impl R3d { pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, time: f64, ov: &Overlay) { let t = time as f32; - self.update_chunks(g, gfx); let m = cam.matrix(); + self.update_chunks(g, gfx, &m); EYE.with(|e| e.set(cam.eye_dir())); set_camera(&cam.camera()); @@ -777,12 +824,12 @@ impl R3d { self.lit.set_uniform_array("LightCol", &lc[..]); self.lit.set_uniform("LightCount", count); gl_use_material(&self.lit); - for cz in 0..NCH { - for cx in 0..NCH { + for cz in 0..self.ncz { + for cx in 0..self.ncx { if !Self::chunk_visible(&m, cx, cz) { continue; } - if let Some(mesh) = &self.chunks[(cz * NCH + cx) as usize].mesh { + if let Some(mesh) = &self.chunks[(cz * self.ncx + cx) as usize].mesh { draw_mesh(mesh); } } @@ -810,6 +857,9 @@ impl R3d { if let Some(o) = &c.overlay { draw_mesh(o); } + if let Some(k) = &c.marks { + draw_mesh(k); + } } for (k, tr) in g.torches.iter().enumerate() { let (x, z) = tr.pos(); @@ -1008,9 +1058,9 @@ fn draw_landmarks_glow(g: &Game, t: f32) { draw_sphere(c, 0.75 * pulse, None, Color::new(0.4 + 0.5 * hp, 0.05, 0.08, 1.0)); draw_sphere(c, 0.95 * pulse, None, Color::new(1.0, 0.2, 0.15, 0.25)); draw_sphere(c + vec3(-0.2, 0.25, 0.2), 0.18, None, Color::new(1.0, 0.85, 0.7, 0.8)); - // portal - let (px, py) = g.map.portal; - let owned = g.portal_owned(); + // portals + for (pk, &(px, py)) in g.map.portals.iter().enumerate() { + let owned = g.map.portal_owned(pk); let pc = vec3(px as f32 + 1.5, 0.03, py as f32 + 1.5); let arm = if owned { Color::new(0.8, 0.4, 1.0, 1.0) } else { Color::new(0.45, 0.42, 0.5, 1.0) }; disc(pc, 1.35, if owned { Color::new(0.2, 0.05, 0.3, 0.9) } else { Color::new(0.1, 0.1, 0.12, 0.9) }, 32); @@ -1024,6 +1074,7 @@ fn draw_landmarks_glow(g: &Game, t: f32) { draw_cube(p, Vec3::splat(0.05 + 0.06 * (1.0 - f)), None, Color::new(arm.r, arm.g, arm.b, 0.4 + 0.6 * (1.0 - f))); } } + } // gate light if g.gate_hp > 0.0 { let (gx, gz) = g.gate_center(); diff --git a/src/save.rs b/src/save.rs index 411719d..83e6988 100644 --- a/src/save.rs +++ b/src/save.rs @@ -7,7 +7,7 @@ use crate::settings::Settings; use serde::{Deserialize, Serialize}; pub const FORMAT: &str = "dungeoncraft-save"; -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; // 2: variable map size, several portals, packed tiles pub const AUTOSAVE_KEY: &str = "dc.autosave"; #[derive(Serialize)] @@ -48,6 +48,7 @@ pub fn from_bytes(data: &[u8]) -> Result { return Err("save.err_version"); } let mut game = s.game; + game.map.activate(); game.recount(); Ok(Loaded { game, camera: s.camera, settings: s.settings }) } @@ -58,7 +59,7 @@ mod tests { #[test] fn save_roundtrip() { - let mut g = Game::new(42); + let mut g = Game::new(42, 96, 128); let (cx, cy) = g.map.core; g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true); for _ in 0..3000 { @@ -76,6 +77,9 @@ mod tests { assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked)); assert_eq!(l.settings.unwrap().language, "de"); assert_eq!(l.camera.unwrap().tx, 10.0); + assert_eq!((l.game.map.w, l.game.map.h), (96, 128)); + assert_eq!(l.game.map.portals.len(), g.map.portals.len()); + assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold)); // the loaded game keeps running identically let mut a = l.game; let mut b = g; @@ -87,4 +91,24 @@ mod tests { assert_eq!(a.ents.len(), b.ents.len()); assert!(from_bytes(b"{\"format\":\"x\"}").is_err()); } + + #[test] + fn huge_map() { + let t0 = std::time::Instant::now(); + let mut g = Game::new(7, 256, 256); + let gen = t0.elapsed(); + assert_eq!(g.map.portals.len(), 16); + assert_eq!(g.map.treasures.len(), 15); + let (cx, cy) = g.map.core; + g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true); + let t1 = std::time::Instant::now(); + for _ in 0..(120 * 20) { + g.update(0.05); + } + let sim = t1.elapsed(); + let json = to_json(&g, &Cam::new(0.0, 0.0), &Settings::default()); + println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024); + let l = from_bytes(json.as_bytes()).expect("load"); + assert_eq!(l.game.map.t.len(), 256 * 256); + } } diff --git a/src/settings.rs b/src/settings.rs index 2e6fcf9..3fde209 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -145,6 +145,8 @@ pub struct Settings { // game pub game_speed: f32, // 1 or 2 pub hints: bool, + pub map_w: usize, // size of new maps (64..256) + pub map_h: usize, // key bindings: action id -> key name pub keys: BTreeMap, } @@ -166,6 +168,8 @@ impl Default for Settings { ui_scale: 2, game_speed: 1.0, hints: true, + map_w: 64, + map_h: 64, keys: BTreeMap::new(), } } diff --git a/src/ui.rs b/src/ui.rs index 405856f..4affd5a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -170,7 +170,7 @@ fn mmss(t: f32) -> String { impl Ui { pub fn new() -> Ui { - let img = Image::gen_image_color(MW as u16, MH as u16, BLACK); + let img = Image::gen_image_color(64, 64, BLACK); let tex = Texture2D::from_image(&img); tex.set_filter(FilterMode::Nearest); Ui { @@ -199,7 +199,7 @@ impl Ui { pub fn minimap_world(&self, x: f32, y: f32) -> (f32, f32) { let r = self.minimap_rect; - ((x - r.x) / r.w * MW as f32, (y - r.y) / r.h * MH as f32) + ((x - r.x) / r.w * mw() as f32, (y - r.y) / r.h * mh() as f32) } pub fn click(&mut self, x: f32, y: f32, g: &Game) -> UiAction { @@ -344,6 +344,13 @@ impl Ui { let mm_y = tb_y - mm_h; gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S(), false, FRAME, BG, &tr("ui.map"), FRAME_HI); self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h)); + // the minimap image follows the map size + if self.minimap_img.width as usize != mw() || self.minimap_img.height as usize != mh() { + self.minimap_img = Image::gen_image_color(mw() as u16, mh() as u16, BLACK); + self.minimap_tex = Texture2D::from_image(&self.minimap_img); + self.minimap_tex.set_filter(FilterMode::Nearest); + self.minimap_t = -1.0; + } if now - self.minimap_t > 0.25 { self.minimap_t = now; for (i, t) in g.map.t.iter().enumerate() { @@ -356,12 +363,14 @@ impl Ui { } self.minimap_tex.update(&self.minimap_img); } - let map_px = (mm_w - CW() * 2.0).min(mm_h - CH() * 2.0); - let mrx = mm_x + (mm_w - map_px) * 0.5; - let mry = mm_y + CH() + (mm_h - CH() * 2.0 - map_px) * 0.5; - self.minimap_rect = Rect::new(mrx, mry, map_px, map_px); - draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_px, map_px)), ..Default::default() }); - let ms = map_px / MW as f32; + // keep the map's aspect ratio inside the window + let avail = (mm_w - CW() * 2.0).min(mm_h - CH() * 2.0); + let ms = avail / mw().max(mh()) as f32; + let (map_w, map_h) = (mw() as f32 * ms, mh() as f32 * ms); + let mrx = mm_x + (mm_w - map_w) * 0.5; + let mry = mm_y + CH() + (mm_h - CH() * 2.0 - map_h) * 0.5; + self.minimap_rect = Rect::new(mrx, mry, map_w, map_h); + draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_w, map_h)), ..Default::default() }); for e in &g.ents { if e.alive && !e.held { let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) }; @@ -377,7 +386,7 @@ impl Ui { for k in 0..4 { let a = corners[k]; let b = corners[(k + 1) % 4]; - let clampp = |p: Vec2| vec2(mrx + p.x.clamp(0.0, MW as f32) * ms, mry + p.y.clamp(0.0, MH as f32) * ms); + let clampp = |p: Vec2| vec2(mrx + p.x.clamp(0.0, mw() as f32) * ms, mry + p.y.clamp(0.0, mh() as f32) * ms); let (pa, pb) = (clampp(a), clampp(b)); draw_line(pa.x, pa.y, pb.x, pb.y, 1.5, WHITE); } @@ -623,7 +632,7 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec } Kind::Portal => { out.push((tr("tile.portal"), MANA)); - out.push((tr(if g.portal_owned() { "portal.claimed" } else { "portal.neutral" }), DIM)); + out.push((tr(if t.owner == Owner::Player { "portal.claimed" } else { "portal.neutral" }), DIM)); } Kind::Gate => { out.push((tr("tile.gate"), BLUE));