Five new rooms: prison, graveyard, guard post, arena, sacrifice altar; new creature: the revenant

- prison: captured heroes sit in cells apart from the torture chamber, do not defect and starve in time; the hand can move prisoners between cells and torture
- graveyard: the fallen are buried there; after a while some rise as revenants (undead: no food, no sleep, life drain from level 5)
- guard post: creatures hold the position and attack whatever comes into view
- arena: creatures spar in pairs for fast, free experience, at the cost of bruises
- sacrifice altar: creatures dropped on it are sacrificed; certain pairs bring a rare creature, otherwise a blessing (mood and mana)
- researched in the library, campaign unlocks, textures, furniture, texts EN/DE, bestiary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-04 07:35:12 +01:00
1 parent 2c8db33a69
commit a58497e3a9
14 files changed
+603 -25

No files matched your search

+33
View File
@@ -860,3 +860,36 @@ ability.inferno.name = Inferno
ability.inferno.desc = sein Feueratem breitet sich viel weiter aus.
ability.bomb.name = Bombe
ability.bomb.desc = wirft eine selbstgebaute Bombe, die zwischen den Feinden explodiert.
# more rooms
room.prison = Gefängnis
room.graveyard = Friedhof
room.guardpost = Wachposten
room.arena = Arena
room.altar = Opferaltar
tool.prison.label = Gefängnis
tool.prison.desc = Gefangene Helden sitzen hier in Zellen - getrennt von der Folterkammer. Ohne Folter laufen sie nicht über, und der Hunger zehrt an ihnen. Mit der Hand kannst du einen Gefangenen in die Folterkammer bringen.
tool.graveyard.label = Friedhof
tool.graveyard.desc = Gefallene - Helden wie Kreaturen - werden hier begraben. Nach einer Weile erheben sich manche als Wiedergänger und dienen dir.
tool.guardpost.label = Wachposten
tool.guardpost.desc = Kreaturen halten hier eine Weile Stellung und blicken weit: Sie greifen jeden Feind an, der in Sicht kommt.
tool.arena.label = Arena
tool.arena.desc = Kreaturen kämpfen paarweise zur Übung: doppelt so viel Erfahrung wie im Training und kostenlos, aber mit blauen Flecken.
tool.altar.label = Altar
tool.altar.desc = Lass eine Kreatur auf den Altar fallen, um sie zu opfern. Bestimmte Opferpaare bringen eine seltene Kreatur, sonst wird das Verlies gesegnet: bessere Laune und Mana.
unit.revenant = Wiedergänger
job.guard_post = Hält Wache
job.spar = Übungskampf
msg.revenant_rises = Ein Wiedergänger erhebt sich vom Friedhof und dient dir!
msg.prisoner_starved = Ein gefangener {0} ist im Gefängnis verhungert.
msg.altar_gift = Der Altar nimmt das Paar an: ein {0} erscheint!
msg.altar_blessing = {0} wird geopfert. Das Verlies ist gesegnet.
best.revenant.lure = erhebt sich vom Friedhof
best.revenant.text = Ein Wiedergänger isst nicht, schläft nicht und beschwert sich nicht - der perfekte Angestellte, wenn man den Geruch aushält. | Mit etwas Erfahrung trinkt er die Kraft derer, die er trifft. Mit der Priesterin versteht er sich aus naheliegenden Gründen nicht.
best.b.prison.text = Zellenreihen. Helden verrotten hier in Ruhe, bis jemand entscheidet, was mit ihnen geschieht.
best.b.graveyard.text = Moos, Steine und frische Erde. Hier unten bleibt niemand lange tot.
best.b.guardpost.text = Ein Feuerbecken, ein Banner und eine gelangweilte Kreatur mit scharfen Augen.
best.b.arena.text = Sand, Schweiß und gelegentlich ein Zahn. Erfahrung kommt hier schnell.
best.b.altar.text = Schwarzer Marmor, der trinkt. Man sagt, er höre auf Paare. Oger und Spinne, Magier und Fledermaus, Kerkermeister und Priesterin, Kobold und Cogling...
ability.drain.name = Lebensraub
ability.drain.desc = heilt sich um ein Drittel des Schadens, den er austeilt.
+33
View File
@@ -861,3 +861,36 @@ ability.inferno.name = Inferno
ability.inferno.desc = its fire breath spreads much wider.
ability.bomb.name = Bomb
ability.bomb.desc = throws a home-made bomb that explodes among the enemies.
# more rooms
room.prison = Prison
room.graveyard = Graveyard
room.guardpost = Guard Post
room.arena = Arena
room.altar = Sacrifice Altar
tool.prison.label = Prison
tool.prison.desc = Captured heroes sit in cells here - apart from the torture chamber. Without torture they do not change sides, and hunger wears them down. Pick a prisoner up with the hand to move it to the torture chamber.
tool.graveyard.label = Graveyard
tool.graveyard.desc = The fallen - heroes and creatures - are buried here. After a while some of them rise as revenants and serve you.
tool.guardpost.label = Guard post
tool.guardpost.desc = Creatures hold the position here for a while and watch far: they attack every enemy that comes into view.
tool.arena.label = Arena
tool.arena.desc = Creatures spar in pairs: twice as much experience as training and free of charge, but they get bruised.
tool.altar.label = Altar
tool.altar.desc = Drop a creature on the altar to sacrifice it. Certain pairs of sacrifices bring a rare creature, otherwise the dungeon is blessed: better mood and mana.
unit.revenant = Revenant
job.guard_post = On guard
job.spar = Sparring
msg.revenant_rises = A revenant rises from the graveyard and serves you!
msg.prisoner_starved = A captured {0} starved in prison.
msg.altar_gift = The altar accepts the pair: a {0} appears!
msg.altar_blessing = The {0} is sacrificed. The dungeon is blessed.
best.revenant.lure = rises from the graveyard
best.revenant.text = A revenant does not eat, does not sleep and does not complain - the perfect employee, if you can stand the smell. | It drinks the strength of those it strikes once it has some experience. The priestess and it do not get along, for obvious reasons.
best.b.prison.text = Rows of cells. Heroes rot here quietly until somebody decides what to do with them.
best.b.graveyard.text = Moss, stones and fresh earth. Nobody stays dead for long down here.
best.b.guardpost.text = A brazier, a banner and a bored creature with sharp eyes.
best.b.arena.text = Sand, sweat and the occasional tooth. Experience comes fast here.
best.b.altar.text = Black marble that drinks. Some say it listens to pairs. Ogre and spider, mage and bat, jailer and priestess, kobold and cogling...
ability.drain.name = Life drain
ability.drain.desc = heals itself by a third of the damage it deals.
+1 -1
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
// From which mission (0-based) on each tool is available (same order as ui::TOOLS):
// dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp,
// torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel
pub const TOOL_UNLOCK: [usize; 40] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22];
pub const TOOL_UNLOCK: [usize; 45] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18];
pub struct FortDef {
pub at: (f32, f32), // centre as a fraction of the map size
+129 -17
View File
@@ -108,6 +108,7 @@ pub enum EK {
Owl, // aerie + library: arcane bolts from afar
Wyrm, // big aerie + treasury: rare, very strong, fire breath
Cogling, // forge: a sooty little tinkerer, builds traps and doors fastest
Revenant, // rises from the graveyard: undead, needs no food and no bed
}
pub struct St {
@@ -145,6 +146,7 @@ pub fn st(k: EK) -> St {
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
EK::Cogling => s("unit.cogling", 90.0, 9.0, 1.0, 2.4, 0.9, 70, 0),
EK::Revenant => s("unit.revenant", 160.0, 14.0, 1.0, 1.9, 1.2, 30, 0),
}
}
@@ -184,6 +186,8 @@ pub enum Job {
Forge(usize), // creature works in the forge (traps and doors)
Install(u32), // imp carries a finished trap or door (id) to its spot and installs it
Brawl(u32), // two creatures that cannot stand each other scuffle (never to the death)
Guard(usize), // holds a guard post
Spar(usize), // sparring in the arena
}
#[derive(Clone, Serialize, Deserialize)]
@@ -489,6 +493,10 @@ pub struct Game {
pub research: f32, // research points towards the current item
#[serde(default)]
pub reveals: Vec<(f32, f32, f32)>, // seer's eye: x, y, seconds left
#[serde(default)]
pub graves: Vec<crate::rooms::Grave>,
#[serde(default)]
pub altar_last: Option<EK>, // the last sacrifice that waits for its pair
#[serde(skip)]
pub fog_t: f32, // fog of war: seconds until the next update
#[serde(skip)]
@@ -533,11 +541,41 @@ impl Torch {
}
}
pub const N_ROOMS: usize = 9;
pub const N_ROOMS: usize = 14;
pub const ROOM_FORGE: usize = 8;
pub const ROOM_KINDS: [Kind; N_ROOMS] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple, Kind::Aerie, Kind::Forge];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150];
pub const ROOM_NAMES: [&str; N_ROOMS] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple", "room.aerie", "room.forge"];
pub const ROOM_KINDS: [Kind; N_ROOMS] = [
Kind::Treasury,
Kind::Lair,
Kind::Farm,
Kind::Training,
Kind::Library,
Kind::Torture,
Kind::Temple,
Kind::Aerie,
Kind::Forge,
Kind::Prison,
Kind::Graveyard,
Kind::GuardPost,
Kind::Arena,
Kind::Altar,
];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150, 160, 140, 90, 180, 250];
pub const ROOM_NAMES: [&str; N_ROOMS] = [
"room.treasury",
"room.lair",
"room.farm",
"room.training",
"room.library",
"room.torture",
"room.temple",
"room.aerie",
"room.forge",
"room.prison",
"room.graveyard",
"room.guardpost",
"room.arena",
"room.altar",
];
// bridge cost per tile: over water, over lava
pub const BRIDGE_COST: [i32; 2] = [30, 75];
@@ -663,6 +701,8 @@ impl Game {
clouds: Vec::new(),
forge_work: 0.0,
fog_t: 0.0,
graves: Vec::new(),
altar_last: None,
reveals: Vec::new(),
known: Vec::new(),
researchable: Vec::new(),
@@ -1116,6 +1156,7 @@ impl Game {
self.update_forge(dt);
self.update_research();
self.update_reveals(dt);
self.update_graves(dt);
self.update_fog(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
@@ -1850,7 +1891,7 @@ impl Game {
}
}
fn release_all(&mut self, id: u32) {
pub(crate) fn release_all(&mut self, id: u32) {
let me = id + 1;
for t in self.map.t.iter_mut() {
if t.reserved == me {
@@ -2034,6 +2075,9 @@ impl Game {
}
}
// learned abilities that come with a blow
if crate::personality::knows(e, "drain") {
e.hp = (e.hp + dmg * 0.3).min(e.max_hp);
}
if crate::personality::knows(e, "web") && self.rng.f() < 0.3 {
self.ents[j].slow = self.ents[j].slow.max(3.0);
}
@@ -2070,7 +2114,7 @@ impl Game {
// free places in the torture chamber (one prisoner per 2 tiles)
pub fn prison_space(&self) -> usize {
let cap = self.counts[5] / 2;
let cap = self.counts[5] / 2 + self.counts[crate::rooms::ROOM_PRISON];
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
cap.saturating_sub(used)
}
@@ -2111,6 +2155,9 @@ impl Game {
}
self.release_all(id);
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
if kind != EK::Worker && kind != EK::Revenant {
self.bury(x, y);
}
let s = st(kind);
if hero {
let b = s.bounty as f32;
@@ -2906,10 +2953,16 @@ impl Game {
fn creature_ai(&mut self, e: &mut Ent, dt: f32) {
let me = e.id + 1;
if e.kind == EK::Revenant {
// the undead neither eat nor sleep
e.hunger = 0.0;
e.energy = 1.0;
} else {
e.hunger += dt / 75.0;
if !matches!(e.job, Job::Sleep(_)) {
e.energy -= dt / 110.0;
}
}
if e.hunger > 1.8 {
e.happy -= dt * 0.004;
}
@@ -2962,8 +3015,8 @@ impl Game {
return;
}
}
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_)) {
let radius = if e.job == Job::Rally { 8.5 } else if flies(e.kind) { 9.0 } else { 7.0 };
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_) | Job::Brawl(_)) {
let radius = if e.job == Job::Rally { 8.5 } else if matches!(e.job, Job::Guard(_)) { 10.0 } else if flies(e.kind) { 9.0 } else { 7.0 };
if let Some(id) = self.nearest_enemy(e, radius) {
self.unreserve(me);
e.job = Job::Attack(id);
@@ -3120,6 +3173,19 @@ impl Game {
e.job = Job::None;
}
}
Job::Guard(t) => {
e.timer -= dt;
if cur != t || self.map.t[t].kind != Kind::GuardPost || e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Spar(t) => {
if !self.spar(e, t, dt) {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Forge(t) => {
if cur != t || self.map.t[t].kind != Kind::Forge || !self.forge_orders() {
self.unreserve(me);
@@ -3432,6 +3498,27 @@ impl Game {
}
}
}
// guard posts and the arena
if self.counts[crate::rooms::ROOM_GUARD] > 0 && self.rng.f() < 0.2 {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.free_post(i, me), true) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Guard(t);
e.timer = 35.0 + self.rng.f() * 20.0;
return;
}
}
if self.counts[crate::rooms::ROOM_ARENA] > 0 && e.hp > e.max_hp * 0.8 && self.rng.f() < 0.25 {
if let Some(p) = self.find_room_tile(cur, Kind::Arena, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Spar(t);
e.timer = 14.0 + self.rng.f() * 6.0;
return;
}
}
// the forge: orders waiting (coglings love it, others lend a hand now and then)
if self.forge_orders() && self.counts[ROOM_FORGE] > 0 {
let r = self.rng.f();
@@ -3501,6 +3588,17 @@ impl Game {
}
}
}
_ if self.map.t[e.tile()].kind == Kind::Prison => {
// in prison: no torture, but hunger wears the prisoner down
e.hp -= e.max_hp / 180.0 * dt;
if e.hp <= 0.0 {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.msg("msg.prisoner_starved", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
_ => {
let t = e.tile();
if self.map.t[t].kind != Kind::Torture {
@@ -3533,6 +3631,7 @@ impl Game {
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.sfx(Sfx::HeroDeath, e.x, e.y);
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
}
@@ -3566,11 +3665,6 @@ impl Game {
}).map(|o| o.id)
}
fn free_torture_tile(&self, i: usize) -> bool {
let t = &self.map.t[i];
t.kind == Kind::Torture && t.owner == Owner::Player && t.bed == 0
}
fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
let Some(k) = self.find(id) else {
e.job = Job::None;
@@ -3582,7 +3676,7 @@ impl Game {
let (cx, cy) = (self.ents[k].x, self.ents[k].y);
if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 {
// pick up and look for a free place in the torture chamber
match self.map.search(cur, self.map.walk_cost(), |i| self.free_torture_tile(i), true) {
match self.map.search(cur, self.map.walk_cost(), |i| self.free_cell(i), true) {
Some(p) => {
let target = p.last().copied().unwrap_or(cur);
self.map.t[target].bed = id + 1;
@@ -4497,7 +4591,7 @@ impl Game {
return false; // the rivals' creatures and the dwarves cannot be picked up
}
if e.hero {
if e.captive != 1 {
if e.captive != 1 && e.captive != 3 {
return false;
}
if !self.on_own_ground(e.x, e.y) {
@@ -4509,6 +4603,12 @@ impl Game {
let id = self.ents[j].id;
let me = id + 1;
self.unreserve(me);
if self.ents[j].captive == 3 {
// taken out of its cell
for t in self.map.t.iter_mut().filter(|t| t.bed == me && matches!(t.kind, Kind::Torture | Kind::Prison)) {
t.bed = 0;
}
}
for o in self.ents.iter_mut() {
if o.carrier == id && o.captive != 0 {
o.captive = 1;
@@ -4541,7 +4641,17 @@ impl Game {
return false;
}
self.hand.pop();
let imprison = t.kind == Kind::Torture && t.bed == 0;
let imprison = matches!(t.kind, Kind::Torture | Kind::Prison) && t.bed == 0;
// the sacrifice altar takes creatures
if t.kind == Kind::Altar {
if let Some(k) = self.ents.iter().position(|e| e.id == id && !e.hero) {
self.ents[k].held = false;
self.ents[k].x = wx;
self.ents[k].y = wy;
self.sacrifice(k);
return true;
}
}
if let Some(k) = self.ents.iter().position(|e| e.id == id) {
let e = &mut self.ents[k];
e.held = false;
@@ -4790,6 +4900,8 @@ pub fn job_name(e: &Ent) -> &'static str {
Job::Train(_) => "job.train",
Job::Forge(_) => "job.forge",
Job::Brawl(_) => "job.brawl",
Job::Guard(_) => "job.guard_post",
Job::Spar(_) => "job.spar",
Job::Install(_) => "job.install",
Job::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
@@ -5362,7 +5474,7 @@ mod tests {
#[test]
fn aerie_attracts_flyers() {
let mut g = Game::new(3, 64, 64);
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0];
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0, 0, 0, 0, 0, 0];
let mut seen = std::collections::HashSet::new();
for _ in 0..200 {
g.try_portal_for_test();
+59 -1
View File
@@ -73,6 +73,11 @@ pub const I_RUNE: u32 = 143; // rune forge tower
pub const T_FORGE: u32 = 144; // 4 variants: sooty flagstones with glowing cracks
pub const I_TRAPS: u32 = 148; // 10 icons in TRAP_KINDS order
pub const I_SPELLS: u32 = 158; // 8 icons in SPELLS order
pub const T_PRISON: u32 = 166; // 4 variants each
pub const T_GRAVEYARD: u32 = 170;
pub const T_GUARDPOST: u32 = 174;
pub const T_ARENA: u32 = 178;
pub const T_ALTAR: u32 = 182;
pub struct Gfx {
pub atlas: Texture2D,
@@ -765,6 +770,40 @@ fn gen_atlas() -> Image {
}
});
}
// prison: dark flagstones with drain grates
for v in 0..4u32 {
cv.cell(T_PRISON + v, |x, y| {
if (5..=10).contains(&x) && (5..=10).contains(&y) && (x == 5 || x == 10 || y == 5 || y == 10 || x % 2 == 0) && v % 2 == 0 {
Some(c(30, 30, 34))
} else {
Some(stones(x, y, v + 310, (58, 56, 60), (28, 26, 30), 6))
}
});
// graveyard: dark earth, moss and pebbles
cv.cell(T_GRAVEYARD + v, |x, y| {
let r = rnd(x, y, v + 320);
Some(if r > 0.86 { c(70, 96, 52) } else if r > 0.8 { c(110, 106, 100) } else { shade((52, 40, 34), (rnd(x / 2, y / 2, v + 321) * 16.0) as i32 - 8) })
});
// guard post: flagstones with a red border
cv.cell(T_GUARDPOST + v, |x, y| {
if x == 0 || y == 0 || x == 15 || y == 15 {
Some(c(120, 30, 34))
} else {
Some(stones(x, y, v + 330, (76, 66, 62), (36, 30, 30), 8))
}
});
// arena: raked sand with dark stains
cv.cell(T_ARENA + v, |x, y| {
let rake = (y + (x / 4 + v as i32) % 2) % 3 == 0;
let stain = rnd(x / 3, y / 3, v + 340) > 0.9;
Some(if stain { c(110, 60, 40) } else if rake { c(170, 140, 96) } else { shade((196, 164, 112), (rnd(x, y, v + 341) * 14.0) as i32 - 7) })
});
// altar: black marble with red veins
cv.cell(T_ALTAR + v, |x, y| {
let vein = ((x as f32 * 0.7 + (y as f32 * 0.5 + v as f32).sin() * 3.0) as i32 + y) % 11 == 0;
Some(if vein { c(150, 20, 30) } else { shade((30, 26, 32), (rnd(x, y, v + 350) * 10.0) as i32 - 5) })
});
}
// trap and door icons
let icons: [fn(i32, i32) -> Option<Color>; 10] = [
// spikes
@@ -1226,6 +1265,19 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
"...kO.Ok..",
"...kk.kk..",
],
// revenant: a pale, hollow-eyed corpse with a rusty blade and green grave-light
EK::Revenant => [
"...kkkk...",
"..kwwwwk..",
"..kwgwgk..",
"..kwwwwk.S",
"...kCCk..S",
"..kCcCCk.S",
".kCkcCkCkb",
"..kCcCCk..",
"..kCk.kCk.",
"..kk...kk.",
],
EK::Wyrm => [
"R.......kw",
"RR....krrk",
@@ -1295,7 +1347,7 @@ fn pal(ch: char) -> Option<Color> {
})
}
pub const SPRITE_KINDS: [EK; 18] = [
pub const SPRITE_KINDS: [EK; 19] = [
EK::Worker,
EK::Kobold,
EK::Spider,
@@ -1314,6 +1366,7 @@ pub const SPRITE_KINDS: [EK; 18] = [
EK::Owl,
EK::Wyrm,
EK::Cogling,
EK::Revenant,
];
// width of the sprite texture in pixels
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32;
@@ -1727,6 +1780,11 @@ impl Gfx {
Kind::ManaSpring => T_PORTALFLOOR + v,
Kind::RuneChest => T_FLOOR + v,
Kind::Forge => T_FORGE + v,
Kind::Prison => T_PRISON + v,
Kind::Graveyard => T_GRAVEYARD + v,
Kind::GuardPost => T_GUARDPOST + v,
Kind::Arena => T_ARENA + v,
Kind::Altar => T_ALTAR + v,
Kind::Door => {
if claimed {
T_CLAIM + v
+1
View File
@@ -19,6 +19,7 @@ mod props;
mod research;
mod rival;
mod render3d;
mod rooms;
mod save;
mod settings;
mod sfx;
+16 -1
View File
@@ -63,6 +63,11 @@ pub enum Kind {
RuneChest, // dwarven chest with the plans of a rune forge tower (claim it: one free build)
Forge, // room: creatures manufacture traps and doors
Door, // floor with an installed door (res bit 0: locked)
Prison, // room: prisoners wait in cells
Graveyard, // room: the fallen are buried, some rise again
GuardPost, // room: creatures hold the position
Arena, // room: sparring
Altar, // room: sacrifices
}
// options for the map generator (campaign missions)
@@ -91,7 +96,7 @@ pub struct River {
}
// order matters for the packed savegame codec: only append new kinds
const KINDS: [Kind; 29] = [
const KINDS: [Kind; 34] = [
Kind::Rock,
Kind::Earth,
Kind::Gold,
@@ -121,6 +126,11 @@ const KINDS: [Kind; 29] = [
Kind::RuneChest,
Kind::Forge,
Kind::Door,
Kind::Prison,
Kind::Graveyard,
Kind::GuardPost,
Kind::Arena,
Kind::Altar,
];
// treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond
@@ -219,6 +229,11 @@ impl Tile {
| Kind::RuneChest
| Kind::Forge
| Kind::Door
| Kind::Prison
| Kind::Graveyard
| Kind::GuardPost
| Kind::Arena
| Kind::Altar
)
}
// river tiles (open, but nobody can walk there)
+2 -2
View File
@@ -7,10 +7,10 @@ use crate::ui::{tool_cost_static, BLUE as BLUE_C, GREEN as GREEN_C, RED as RED_C
pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"];
const CREATURES: [EK; 13] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm];
const CREATURES: [EK; 14] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Revenant, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm];
const HUMANS: [EK; 5] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin];
// buildings: indices into ui::TOOLS
const BUILDINGS: [usize; 25] = [2, 3, 4, 5, 6, 12, 13, 18, 21, 17, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
const BUILDINGS: [usize; 30] = [2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
#[derive(Clone, Copy)]
enum Entry {
+1
View File
@@ -27,6 +27,7 @@ pub fn persona(k: EK) -> Persona {
EK::Owl => p(Kind::Library, &[EK::Gargoyle], Some((5, "volley"))),
EK::Wyrm => p(Kind::Treasury, &[EK::Bat], Some((7, "inferno"))),
EK::Cogling => p(Kind::Forge, &[EK::Ogre], Some((4, "bomb"))),
EK::Revenant => p(Kind::Graveyard, &[EK::Priestess], Some((5, "drain"))),
_ => p(Kind::Lair, &[], None),
}
}
+80
View File
@@ -143,11 +143,26 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) {
Kind::ManaSpring => spring_basin(c, l),
Kind::RuneChest => rune_chest(c, l),
Kind::Forge if (x + z) % 2 == 0 => anvil(c, h, l),
Kind::Prison => prison_bars(g, x, z, c, l),
Kind::Graveyard if h % 4 == 0 => tombstone(c + vec3(0.25, 0.0, -0.25), h, l, 0.6),
Kind::GuardPost if x % 2 == 0 && z % 2 == 0 => guard_brazier(c, l),
Kind::Arena if h % 5 == 0 => arena_post(c, h, l),
Kind::Altar if x % 3 == 1 && z % 3 == 1 => altar(c, l),
Kind::Forge if h % 4 == 1 => furnace(c, l),
_ => {}
}
}
}
// graves: a mound and a stone, the fresher the darker the earth
for gr in &g.graves {
let (gx, gz) = xy(gr.tile);
if gx < x0 || gx > x1 || gz < z0 || gz > z1 {
continue;
}
let c = vec3(gx as f32 + 0.5, 0.0, gz as f32 + 0.5);
cube(c + vec3(0.0, 0.05, 0.08), vec3(0.34, 0.1, 0.6), rgb(70, 52, 40, 0.7));
tombstone(c + vec3(0.0, 0.0, -0.28), gr.tile as u32, 0.8, 1.0);
}
// creatures in chains: an iron post with chains to the creature
for e in g.ents.iter().filter(|e| e.alive && e.chained) {
if (e.x as i32) < x0 || (e.x as i32) > x1 || (e.y as i32) < z0 || (e.y as i32) > z1 {
@@ -174,6 +189,59 @@ fn rune_chest(c: Vec3, l: f32) {
cube(c + vec3(0.0, 0.22, 0.18), vec3(0.08, 0.1, 0.03), rgb(200, 170, 80, l));
}
// prison: iron bars along the edges of the room (cell walls)
fn prison_bars(g: &Game, x: i32, z: i32, c: Vec3, l: f32) {
let iron = rgb(70, 70, 78, l);
for (dx, dz) in [(0i32, -1i32), (1, 0), (0, 1), (-1, 0)] {
let open = g.map.get(x + dx, z + dz).map_or(false, |t| t.kind != Kind::Prison && t.walkable());
if !open {
continue;
}
let edge = c + vec3(dx as f32 * 0.46, 0.0, dz as f32 * 0.46);
let side = vec3(dz as f32, 0.0, dx as f32);
for k in 0..5 {
let o = (k as f32 - 2.0) * 0.2;
cube(edge + side * o + vec3(0.0, 0.4, 0.0), vec3(0.04, 0.8, 0.04), iron);
}
cube(edge + vec3(0.0, 0.78, 0.0), side * 1.0 + vec3(0.05, 0.04, 0.05), iron);
}
}
fn tombstone(c: Vec3, h: u32, l: f32, size: f32) {
let tilt = ((h >> 3) % 3) as f32 * 0.04 - 0.04;
cube(c + vec3(tilt, 0.2 * size, 0.0), vec3(0.24, 0.4, 0.08) * size, rgb(110, 108, 112, l));
cube(c + vec3(tilt, 0.42 * size, 0.0), vec3(0.16, 0.06, 0.08) * size, rgb(120, 118, 122, l));
cube(c + vec3(tilt, 0.28 * size, 0.045), vec3(0.06, 0.16, 0.01) * size, rgb(60, 58, 62, l));
}
// guard post: a brazier on a tripod (its fire glows in the glow pass) and a small banner
fn guard_brazier(c: Vec3, l: f32) {
for k in 0..3 {
let a = k as f32 * 2.1;
cube(c + vec3(a.cos() * 0.12, 0.2, a.sin() * 0.12), vec3(0.04, 0.4, 0.04), rgb(60, 60, 66, l));
}
cube(c + vec3(0.0, 0.44, 0.0), vec3(0.3, 0.08, 0.3), rgb(70, 66, 70, l));
cube(c + vec3(0.35, 0.5, 0.0), vec3(0.03, 1.0, 0.03), rgb(90, 64, 40, l));
cube(c + vec3(0.45, 0.82, 0.0), vec3(0.2, 0.28, 0.02), rgb(150, 24, 30, l));
}
// arena: a wooden post with a training weapon leaning against it
fn arena_post(c: Vec3, h: u32, l: f32) {
let o = vec3(((h >> 4) % 3) as f32 * 0.2 - 0.2, 0.0, ((h >> 6) % 3) as f32 * 0.2 - 0.2);
cube(c + o + vec3(0.0, 0.35, 0.0), vec3(0.1, 0.7, 0.1), rgb(110, 76, 44, l));
cube(c + o + vec3(0.12, 0.35, 0.0), vec3(0.03, 0.6, 0.03), rgb(150, 150, 160, l));
}
// sacrifice altar: a black block with a channel, candles at the corners (they glow)
fn altar(c: Vec3, l: f32) {
cube(c + vec3(0.0, 0.08, 0.0), vec3(1.6, 0.16, 1.2), rgb(36, 30, 38, l));
cube(c + vec3(0.0, 0.32, 0.0), vec3(1.0, 0.32, 0.6), rgb(28, 24, 30, l));
cube(c + vec3(0.0, 0.49, 0.0), vec3(0.8, 0.02, 0.1), rgb(90, 10, 20, l));
for (dx, dz) in [(-0.7f32, -0.5f32), (0.7, -0.5), (0.7, 0.5), (-0.7, 0.5)] {
cube(c + vec3(dx, 0.25, dz), vec3(0.06, 0.18, 0.06), rgb(220, 210, 190, l));
}
}
// forge: an anvil on a block, a hammer leaning against it
fn anvil(c: Vec3, h: u32, l: f32) {
let o = vec3(((h >> 3) % 3) as f32 * 0.08 - 0.08, 0.0, ((h >> 5) % 3) as f32 * 0.08 - 0.08);
@@ -258,6 +326,18 @@ pub fn draw_props_glow(g: &Game, cam: &Cam, t: f32) {
}
}
}
Kind::GuardPost if x % 2 == 0 && z % 2 == 0 => {
let f = 0.8 + 0.2 * (t * 8.0 + k).sin();
draw_sphere(c + vec3(0.0, 0.55, 0.0), 0.11 * f, None, Color::new(1.0, 0.55, 0.1, 0.95));
draw_sphere(c + vec3(0.0, 0.6, 0.0), 0.25 * f, None, Color::new(1.0, 0.35, 0.05, 0.2));
}
Kind::Altar if x % 3 == 1 && z % 3 == 1 => {
for (dx, dz) in [(-0.7f32, -0.5f32), (0.7, -0.5), (0.7, 0.5), (-0.7, 0.5)] {
flame(c + vec3(dx, 0.38, dz), t, k + dx);
}
let p = 0.5 + 0.5 * (t * 1.5 + k).sin();
draw_cube(c + vec3(0.0, 0.5, 0.0), vec3(0.8, 0.02, 0.1), None, Color::new(1.0, 0.1, 0.15, 0.4 + 0.5 * p));
}
Kind::Forge if h % 4 == 1 && (x + z) % 2 != 0 => {
// furnace fire
let f = 0.7 + 0.3 * (t * 9.0 + k).sin();
+5
View File
@@ -2574,6 +2574,11 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] {
Kind::ManaSpring => [90, 230, 255, 255],
Kind::RuneChest => [80, 200, 255, 255],
Kind::Forge => [150, 70, 40, 255],
Kind::Prison => [90, 90, 100, 255],
Kind::Graveyard => [70, 90, 60, 255],
Kind::GuardPost => [150, 50, 50, 255],
Kind::Arena => [200, 170, 110, 255],
Kind::Altar => [120, 20, 40, 255],
Kind::Door => [140, 90, 50, 255],
}
}
+5
View File
@@ -15,7 +15,9 @@ pub const RESEARCH: &[(usize, f32)] = &[
(29, 250.0), // wooden door
(32, 300.0), // seer's eye
(15, 400.0), // cannon tower
(42, 300.0), // guard post
(12, 450.0), // torture chamber
(40, 450.0), // prison
(26, 300.0), // alarm bell
(35, 400.0), // stoneskin
(11, 500.0), // summon
@@ -24,6 +26,8 @@ pub const RESEARCH: &[(usize, f32)] = &[
(30, 500.0), // iron door
(33, 550.0), // cave-in
(18, 550.0), // aerie
(43, 550.0), // arena
(41, 600.0), // graveyard
(16, 650.0), // magic tower
(36, 600.0), // gold rain
(24, 650.0), // fire jet
@@ -34,6 +38,7 @@ pub const RESEARCH: &[(usize, f32)] = &[
(38, 850.0), // plague
(27, 900.0), // rune mine
(39, 950.0), // quake
(44, 900.0), // sacrifice altar
(31, 1100.0), // magic door
];
+225
View File
@@ -0,0 +1,225 @@
// More rooms: prison (prisoners sit there, apart from the torture, and starve in time),
// graveyard (the fallen are buried and may rise as revenants), guard post (creatures hold the
// position and watch far), arena (sparring: fast experience, but bruises) and the sacrifice
// altar (creatures dropped on it are sacrificed: certain pairs bring rare creatures, otherwise
// a blessing).
use crate::game::*;
use crate::map::*;
use crate::particles::{abgr, EMBER, MOTE, RING};
use serde::{Deserialize, Serialize};
pub const ROOM_PRISON: usize = 9;
pub const ROOM_GRAVEYARD: usize = 10;
pub const ROOM_GUARD: usize = 11;
pub const ROOM_ARENA: usize = 12;
const RISE_AFTER: f32 = 75.0; // seconds a grave rests before the dead may rise
const CRUMBLE_AFTER: f32 = 240.0; // graves that stay quiet disappear
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Grave {
pub tile: usize,
pub t: f32,
pub tried: bool,
}
// sacrifice recipes: two kinds (in any order) -> a rare creature
const RECIPES: [(EK, EK, EK, u32); 4] = [
(EK::Ogre, EK::Spider, EK::Gargoyle, 3),
(EK::Mage, EK::Bat, EK::Owl, 3),
(EK::Jailer, EK::Priestess, EK::Chainbreaker, 2),
(EK::Kobold, EK::Cogling, EK::Ogre, 4),
];
impl Game {
// ---- prison
pub(crate) fn free_cell(&self, i: usize) -> bool {
let t = &self.map.t[i];
matches!(t.kind, Kind::Torture | Kind::Prison) && t.owner == Owner::Player && t.bed == 0
}
// ---- graveyard: a fallen unit is buried in a free grave
pub(crate) fn bury(&mut self, x: f32, y: f32) {
if self.counts[ROOM_GRAVEYARD] == 0 {
return;
}
let taken: Vec<usize> = self.graves.iter().map(|g| g.tile).collect();
let spot = self
.map
.t
.iter()
.enumerate()
.filter(|(i, t)| t.kind == Kind::Graveyard && t.owner == Owner::Player && !taken.contains(i))
.min_by_key(|(i, _)| {
let (tx, ty) = xy(*i);
((tx as f32 - x).powi(2) + (ty as f32 - y).powi(2)) as i32
})
.map(|(i, _)| i);
if let Some(tile) = spot {
self.graves.push(Grave { tile, t: 0.0, tried: false });
let (gx, gy) = xy(tile);
self.fx(6, gx as f32 + 0.5, gy as f32 + 0.5, 0.0, 0.0, 0.5, 0xff3a5a7a);
}
}
pub(crate) fn update_graves(&mut self, dt: f32) {
let mut rise = Vec::new();
for gr in self.graves.iter_mut() {
gr.t += dt;
if gr.t > RISE_AFTER && !gr.tried {
gr.tried = true;
if self.rng.f() < 0.5 {
rise.push(gr.tile);
}
}
}
let map = &self.map;
self.graves.retain(|g| !rise.contains(&g.tile) && g.t < CRUMBLE_AFTER && map.t[g.tile].kind == Kind::Graveyard);
for tile in rise {
let (x, y) = xy(tile);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let level = 1 + self.rng.next() % 3;
let id = self.spawn(EK::Revenant, false, fx, fy, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.happy = 0.8;
}
for _ in 0..12 {
let a = self.vrng.f() * 6.28;
self.part(MOTE, (fx + a.cos() * 0.3, fy + a.sin() * 0.3, 0.1), (0.0, 0.0, 0.8), 1.4, abgr(150, 255, 170), 0.04);
}
self.sfx(Sfx::Summon, fx, fy);
self.msg("msg.revenant_rises", vec![]);
}
}
// ---- guard post: a free post for a creature
pub(crate) fn free_post(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
t.kind == Kind::GuardPost && t.owner == Owner::Player && (t.reserved == 0 || t.reserved == me)
}
// ---- arena: sparring with a partner nearby; fast experience, some bruises
pub(crate) fn spar(&mut self, e: &mut Ent, t: usize, dt: f32) -> bool {
if e.tile() != t || self.map.t[t].kind != Kind::Arena {
return false;
}
e.timer -= dt;
if e.timer <= 0.0 || e.hp < e.max_hp * 0.5 {
return false;
}
let partner = self.ents.iter().find(|o| o.alive && o.id != e.id && matches!(o.job, Job::Spar(_)) && o.path.is_empty() && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 6.0).map(|o| (o.x, o.y));
if let Some((px, py)) = partner {
e.working = true;
e.face = if px > e.x { 1.0 } else { -1.0 };
e.dir = (py - e.y).atan2(px - e.x);
e.xp += 14.0 * dt;
e.hp = (e.hp - e.max_hp * 0.02 * dt).max(e.max_hp * 0.5);
if self.vrng.f() < 1.5 * dt {
self.fx(2, (e.x + px) * 0.5, (e.y + py) * 0.5, 0.0, 0.0, 0.25, 0xffffffff);
}
}
true
}
// ---- altar: the creature dropped on it is sacrificed
pub(crate) fn sacrifice(&mut self, j: usize) {
let (kind, level, x, y) = (self.ents[j].kind, self.ents[j].level, self.ents[j].x, self.ents[j].y);
let id = self.ents[j].id;
self.release_all(id);
self.ents[j].alive = false;
for _ in 0..20 {
let v = (self.vrng.f() * 2.0 - 1.0, self.vrng.f() * 2.0 - 1.0, 2.0 + self.vrng.f() * 2.0);
self.part(EMBER, (x, y, 0.5), v, 1.2, abgr(255, 60, 40), 0.05);
}
self.part(RING, (x, y, 0.1), (0.0, 0.0, 0.0), 0.6, abgr(255, 40, 40), 1.5);
self.sfx(Sfx::HeroDeath, x, y);
// the last two sacrifices make a pair
let pair = self.altar_last.take();
let recipe = pair.and_then(|p| RECIPES.iter().find(|r| (r.0 == p && r.1 == kind) || (r.1 == p && r.0 == kind)));
match recipe {
Some(&(_, _, gift, lv)) => {
let nid = self.spawn(gift, false, x, y, lv);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == nid) {
e.happy = 1.0;
}
self.fx(7, x, y, 0.0, 0.0, 1.4, 0xff4040ff);
self.msg("msg.altar_gift", vec![format!("@{}", st(gift).name)]);
}
None => {
self.altar_last = Some(kind);
// a blessing: everybody cheers up, some mana for the strength of the sacrifice
for o in self.ents.iter_mut().filter(|o| o.alive && o.creature()) {
o.happy = (o.happy + 0.2).min(1.0);
}
self.mana = (self.mana + 15.0 * level as f32).min(self.mana_max());
self.msg("msg.altar_blessing", vec![format!("@{}", st(kind).name)]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn room(g: &mut Game, kind: Kind, x0: i32, y0: i32, w: i32, h: i32) {
for y in y0..y0 + h {
for x in x0..x0 + w {
let t = &mut g.map.t[idx(x, y)];
t.kind = kind;
t.owner = Owner::Player;
}
}
g.recount();
}
#[test]
fn the_dead_rise_from_the_graveyard() {
let mut g = Game::new(10, 64, 64);
let (cx, cy) = g.map.core;
room(&mut g, Kind::Graveyard, cx + 4, cy - 6, 3, 3);
for k in 0..6 {
g.bury(cx as f32 + 5.0, cy as f32 - 5.0 + k as f32 * 0.1);
}
assert_eq!(g.graves.len(), 6);
for _ in 0..(20 * 90) {
g.update(0.05);
}
assert!(g.ents.iter().any(|e| e.alive && e.kind == EK::Revenant), "a revenant rose");
}
#[test]
fn altar_pairs_bring_rare_creatures() {
let mut g = Game::new(10, 64, 64);
let (cx, cy) = g.map.core;
let a = g.spawn(EK::Ogre, false, cx as f32 - 0.5, cy as f32, 2);
let b = g.spawn(EK::Spider, false, cx as f32 - 0.5, cy as f32, 2);
let j = g.find(a).unwrap();
g.sacrifice(j);
g.ents.retain(|e| e.alive);
let j = g.find(b).unwrap();
g.sacrifice(j);
assert!(g.ents.iter().any(|e| e.alive && e.kind == EK::Gargoyle));
}
#[test]
fn sparring_gives_experience() {
let mut g = Game::new(10, 64, 64);
let (cx, cy) = g.map.core;
room(&mut g, Kind::Arena, cx + 4, cy - 6, 3, 3);
let ids: Vec<u32> = (0..2).map(|k| g.spawn(EK::Kobold, false, cx as f32 + 4.5 + k as f32, cy as f32 - 5.5, 1)).collect();
for &id in &ids {
let j = g.find(id).unwrap();
let t = g.ents[j].tile();
g.ents[j].job = Job::Spar(t);
g.ents[j].timer = 10.0;
}
let xp0: f32 = ids.iter().map(|&id| g.ents[g.find(id).unwrap()].xp).sum();
for _ in 0..40 {
g.update(0.05);
}
let xp1: f32 = ids.iter().map(|&id| g.find(id).map_or(0.0, |j| g.ents[j].xp + 100.0 * (g.ents[j].level - 1) as f32)).sum();
assert!(xp1 > xp0 + 10.0, "{} -> {}", xp0, xp1);
}
}
+13 -3
View File
@@ -162,7 +162,7 @@ pub enum Tool {
}
// New tools are appended so that existing key bindings ("tool<i>") keep their meaning
pub const TOOLS: [Tool; 40] = [
pub const TOOLS: [Tool; 45] = [
Tool::Dig,
Tool::Hand,
Tool::Room(0),
@@ -203,6 +203,11 @@ pub const TOOLS: [Tool; 40] = [
Tool::Spell(crate::spells::Spell::Invisible),
Tool::Spell(crate::spells::Spell::Plague),
Tool::Spell(crate::spells::Spell::Quake),
Tool::Room(9),
Tool::Room(10),
Tool::Room(11),
Tool::Room(12),
Tool::Room(13),
];
@@ -225,7 +230,12 @@ pub fn tool_info(t: Tool) -> ToolInfo {
Tool::Room(5) => ("torture", T_TORTURE, Some(5)),
Tool::Room(6) => ("temple", T_TEMPLE, Some(6)),
Tool::Room(7) => ("aerie", T_AERIE, Some(7)),
Tool::Room(_) => ("forge", T_FORGE, Some(8)),
Tool::Room(8) => ("forge", T_FORGE, Some(8)),
Tool::Room(9) => ("prison", T_PRISON, Some(9)),
Tool::Room(10) => ("graveyard", T_GRAVEYARD, Some(10)),
Tool::Room(11) => ("guardpost", T_GUARDPOST, Some(11)),
Tool::Room(12) => ("arena", T_ARENA, Some(12)),
Tool::Room(_) => ("altar", T_ALTAR, Some(13)),
Tool::Trap(k) => (k.id(), I_TRAPS + crate::traps::TRAP_KINDS.iter().position(|&t| t == k).unwrap_or(0) as u32, None),
Tool::Spell(sp) => (sp.id(), I_SPELLS + crate::spells::SPELLS.iter().position(|&t| t == sp).unwrap_or(0) as u32, None),
Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None),
@@ -299,7 +309,7 @@ pub fn tool_cost(t: Tool, g: &Game) -> String {
pub const CATS: [(&str, &[usize]); 7] = [
("cat.dig", &[0]),
("cat.hand", &[1]),
("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 18, 21, 17]),
("cat.rooms", &[2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17]),
("cat.towers", &[14, 15, 16, 20]),
("cat.traps", &[22, 23, 24, 25, 26, 27, 28, 29, 30, 31]),
("cat.spells", &[9, 10, 11, 32, 33, 34, 35, 36, 37, 38, 39]),