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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 06:34:09 +01:00
1 parent 925be17e82
commit f710a19351
12 files changed
+588 -137

No files matched your search

+56 -17
View File
@@ -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<usize> = (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);
}
+11 -11
View File
@@ -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,
+307 -58
View File
@@ -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<Tile>,
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<usize> {
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<Tile>, 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<Tile>, 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<Tile>, 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<u32>,
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<Vec<u8>> {
let mut val = [255u8; 256];
for (i, &c) in B64.iter().enumerate() {
val[c as usize] = i as u8;
}
let bytes: Vec<u8> = 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<S: Serializer>(tiles: &[Tile], s: S) -> Result<S::Ok, S::Error> {
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<Tile>), // version 1 savegames
}
fn tiles_de<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Tile>, 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)
}
}
}
+56 -2
View File
@@ -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();
+88 -37
View File
@@ -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<f32> {
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<f32> {
}
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<f32> {
}
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<Vec3> {
.collect()
}
fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D, wall_sub: usize) -> (Option<Mesh>, Option<Mesh>, Option<Mesh>) {
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<Mesh>; 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<Mesh>,
overlay: Option<Mesh>,
lines: Option<Mesh>,
marks: Option<Mesh>,
}
pub struct Overlay {
@@ -615,6 +643,8 @@ pub struct R3d {
lit: Material,
glow: Material,
chunks: Vec<Chunk>,
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<Vec<f32>> = 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();
+26 -2
View File
@@ -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<Loaded, &'static str> {
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);
}
}
+4
View File
@@ -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<String, String>,
}
@@ -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(),
}
}
+19 -10
View File
@@ -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<usize>, 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));