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);
}