- title screen (continue/new/load/settings/help) and Esc pause menu - settings: language & sound, camera, graphics, game, rebindable keys; stored in localStorage and in every savegame (always applied on load) - savegames as JSON (.dcsave): download, load via file picker or drag & drop - autosave to localStorage every 2 minutes and when the tab is hidden/closed - configurable wall detail, light count, interface size, game speed, hints - audio behind a cargo feature so native tests run (cargo test --no-default-features) - savegame round-trip test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
10 KiB
Rust
341 lines
10 KiB
Rust
// Map, tiles, generation and path finding.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::cmp::Reverse;
|
|
use std::collections::BinaryHeap;
|
|
|
|
pub const MW: usize = 64;
|
|
pub const MH: usize = 64;
|
|
pub const N: usize = MW * MH;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum Kind {
|
|
Rock,
|
|
Earth,
|
|
Gold,
|
|
Floor,
|
|
Treasury,
|
|
Lair,
|
|
Farm,
|
|
Training,
|
|
Library,
|
|
Core,
|
|
Portal,
|
|
Gate,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum Owner {
|
|
None,
|
|
Player,
|
|
Hero,
|
|
}
|
|
|
|
// Short serde names keep savegames small (the map has 4096 tiles)
|
|
#[derive(Clone, Copy, Serialize, Deserialize)]
|
|
pub struct Tile {
|
|
#[serde(rename = "k")]
|
|
pub kind: Kind,
|
|
#[serde(rename = "o")]
|
|
pub owner: Owner,
|
|
#[serde(rename = "h")]
|
|
pub hp: f32, // dig resistance
|
|
#[serde(rename = "g")]
|
|
pub gold: i32, // gold left (gold vein)
|
|
#[serde(rename = "m")]
|
|
pub marked: bool, // marked for digging
|
|
#[serde(rename = "r")]
|
|
pub reserved: u32, // entity id + 1 working on this tile
|
|
#[serde(rename = "f")]
|
|
pub food: f32, // mushrooms in the mushroom garden
|
|
#[serde(rename = "b")]
|
|
pub bed: u32, // entity id + 1 sleeping here
|
|
#[serde(rename = "v")]
|
|
pub var: u8, // texture variant
|
|
}
|
|
|
|
impl Tile {
|
|
pub fn new(kind: Kind) -> Tile {
|
|
Tile { kind, owner: Owner::None, hp: 0.0, gold: 0, marked: false, reserved: 0, food: 0.0, bed: 0, var: 0 }
|
|
}
|
|
pub fn solid(&self) -> bool {
|
|
matches!(self.kind, Kind::Rock | Kind::Earth | Kind::Gold)
|
|
}
|
|
pub fn diggable(&self) -> bool {
|
|
matches!(self.kind, Kind::Earth | Kind::Gold)
|
|
}
|
|
pub fn walkable(&self) -> bool {
|
|
matches!(
|
|
self.kind,
|
|
Kind::Floor | Kind::Treasury | Kind::Lair | Kind::Farm | Kind::Training | Kind::Library | Kind::Portal | Kind::Gate
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn idx(x: i32, y: i32) -> usize {
|
|
y as usize * MW + x as usize
|
|
}
|
|
pub fn xy(i: usize) -> (i32, i32) {
|
|
((i % MW) as i32, (i / MW) as i32)
|
|
}
|
|
pub fn inb(x: i32, y: i32) -> bool {
|
|
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)];
|
|
pub const DIR8: [(i32, i32); 8] = [(0, -1), (1, 0), (0, 1), (-1, 0), (1, -1), (1, 1), (-1, 1), (-1, -1)];
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Rng(pub u32);
|
|
impl Rng {
|
|
pub fn next(&mut self) -> u32 {
|
|
let mut x = self.0;
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
self.0 = x;
|
|
x
|
|
}
|
|
pub fn f(&mut self) -> f32 {
|
|
(self.next() >> 8) as f32 / (1u32 << 24) as f32
|
|
}
|
|
pub fn range(&mut self, a: i32, b: i32) -> i32 {
|
|
if b <= a {
|
|
return a;
|
|
}
|
|
a + (self.next() % (b - a + 1) as u32) as i32
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Map {
|
|
pub t: Vec<Tile>,
|
|
pub core: (i32, i32), // top-left corner of the core (3x3)
|
|
pub portal: (i32, i32),
|
|
pub gate: (i32, i32),
|
|
}
|
|
|
|
impl Map {
|
|
pub fn get(&self, x: i32, y: i32) -> Option<&Tile> {
|
|
if inb(x, y) {
|
|
Some(&self.t[idx(x, y)])
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn walk(&self, x: i32, y: i32) -> bool {
|
|
inb(x, y) && self.t[idx(x, y)].walkable()
|
|
}
|
|
|
|
pub fn generate(seed: u32) -> Map {
|
|
let mut s = seed;
|
|
loop {
|
|
let m = Map::try_generate(s);
|
|
// 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;
|
|
}
|
|
s = s.wrapping_mul(747796405).wrapping_add(2891336453) | 1;
|
|
}
|
|
}
|
|
|
|
fn try_generate(seed: u32) -> Map {
|
|
let mut rng = Rng(seed | 1);
|
|
for _ in 0..10 {
|
|
rng.next();
|
|
}
|
|
let mut t = vec![Tile::new(Kind::Earth); N];
|
|
for (i, tile) in t.iter_mut().enumerate() {
|
|
let (x, y) = xy(i);
|
|
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 {
|
|
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 blob = |t: &mut Vec<Tile>, rng: &mut Rng, cx: i32, cy: i32, r: i32, k: Kind| {
|
|
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 {
|
|
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)];
|
|
tl.kind = k;
|
|
if k == Kind::Gold {
|
|
tl.gold = rng.range(250, 550);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// rock clusters
|
|
for _ in 0..22 {
|
|
let cx = rng.range(4, MW as i32 - 5);
|
|
let cy = rng.range(4, MH as i32 - 5);
|
|
let r = rng.range(1, 3);
|
|
blob(&mut t, &mut rng, cx, cy, r, Kind::Rock);
|
|
}
|
|
// gold veins
|
|
for _ in 0..16 {
|
|
let cx = rng.range(4, MW as i32 - 5);
|
|
let cy = rng.range(4, MH as i32 - 5);
|
|
let r = rng.range(1, 2);
|
|
blob(&mut t, &mut rng, cx, cy, r, Kind::Gold);
|
|
}
|
|
// 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);
|
|
}
|
|
|
|
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)];
|
|
tl.kind = Kind::Floor;
|
|
tl.owner = owner;
|
|
tl.gold = 0;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
|
|
// starting area
|
|
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)];
|
|
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;
|
|
}
|
|
}
|
|
// 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)];
|
|
tl.kind = Kind::Gate;
|
|
tl.owner = Owner::Hero;
|
|
}
|
|
}
|
|
for tl in t.iter_mut() {
|
|
if tl.kind == Kind::Gold {
|
|
tl.hp = 6.0;
|
|
}
|
|
}
|
|
Map { t, core, portal, gate }
|
|
}
|
|
|
|
// Generic Dijkstra search.
|
|
// cost(i) -> None = impassable, Some(c) = cost of entering.
|
|
// goal(i) -> true = goal reached.
|
|
pub fn search<C, G>(&self, start: usize, cost: C, goal: G, diag: bool) -> Option<Vec<usize>>
|
|
where
|
|
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 heap = BinaryHeap::new();
|
|
dist[start] = 0;
|
|
heap.push(Reverse((0u32, start)));
|
|
while let Some(Reverse((d, i))) = heap.pop() {
|
|
if d > dist[i] {
|
|
continue;
|
|
}
|
|
if goal(i) {
|
|
let mut path = Vec::new();
|
|
let mut c = i;
|
|
while c != start {
|
|
path.push(c);
|
|
c = prev[c];
|
|
}
|
|
path.reverse();
|
|
return Some(path);
|
|
}
|
|
let (x, y) = xy(i);
|
|
let dirs: &[(i32, i32)] = if diag { &DIR8 } else { &DIR4 };
|
|
for (k, &(dx, dy)) in dirs.iter().enumerate() {
|
|
let nx = x + dx;
|
|
let ny = y + dy;
|
|
if !inb(nx, ny) {
|
|
continue;
|
|
}
|
|
let ni = idx(nx, ny);
|
|
let Some(c) = cost(ni) else { continue };
|
|
let mut step = c * 10;
|
|
if k >= 4 {
|
|
// diagonal only if both neighbours are walkable (no corner cutting)
|
|
if c > 1 || !self.walk(x + dx, y) || !self.walk(x, y + dy) {
|
|
continue;
|
|
}
|
|
step = 14;
|
|
}
|
|
let nd = d + step;
|
|
if nd < dist[ni] {
|
|
dist[ni] = nd;
|
|
prev[ni] = i;
|
|
heap.push(Reverse((nd, ni)));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn walk_cost(&self) -> impl Fn(usize) -> Option<u32> + '_ {
|
|
move |i| if self.t[i].walkable() { Some(1) } else { None }
|
|
}
|
|
|
|
pub fn hero_cost(&self) -> impl Fn(usize) -> Option<u32> + '_ {
|
|
move |i| {
|
|
let tl = &self.t[i];
|
|
match tl.kind {
|
|
Kind::Earth => Some(7),
|
|
Kind::Gold => Some(12),
|
|
Kind::Rock | Kind::Core => None,
|
|
_ => Some(1),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn adj_core(&self, i: usize) -> bool {
|
|
let (x, y) = xy(i);
|
|
DIR4.iter().any(|&(dx, dy)| self.get(x + dx, y + dy).map_or(false, |t| t.kind == Kind::Core))
|
|
}
|
|
|
|
pub fn hero_path_to_core(&self, start: usize) -> Option<Vec<usize>> {
|
|
self.search(start, self.hero_cost(), |i| self.adj_core(i), true)
|
|
}
|
|
|
|
pub fn path_to(&self, start: usize, target: usize) -> Option<Vec<usize>> {
|
|
self.search(start, self.walk_cost(), |i| i == target, true)
|
|
}
|
|
}
|