Three new heroes: giant, fairy spy, battle mage

- giant (from wave 6): slow and enormous, smashes doors three times as hard and wrecks traps by standing next to them
- fairy spy (from wave 3): flies, finds the dungeon's traps (marked with a glint) so the heroes wreck them, and heals heroes around it
- battle mage (from wave 4): throws fireballs with splash and blasts reinforced walls (steel resists him better)
- sprites, bestiary texts EN/DE, test: fairies find traps, giants wreck them

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:40:20 +01:00
1 parent a58497e3a9
commit 4ce2c2919e
7 files changed
+186 -15

No files matched your search

+43 -5
View File
@@ -109,6 +109,10 @@ pub enum EK {
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
// more heroes
Giant, // slow, huge, smashes doors and traps
Fairy, // flies, finds the dungeon's traps and heals heroes a little
BattleMage, // blasts reinforced walls from afar, throws fireballs
}
pub struct St {
@@ -147,12 +151,15 @@ pub fn st(k: EK) -> St {
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),
EK::Giant => s("unit.giant", 900.0, 34.0, 1.1, 1.3, 1.6, 200, 600),
EK::Fairy => s("unit.fairy", 70.0, 5.0, 3.5, 3.2, 1.2, 60, 150),
EK::BattleMage => s("unit.battlemage", 120.0, 18.0, 5.0, 1.8, 1.8, 120, 250),
}
}
// flying creatures cross water and lava and sleep in the aerie
pub fn flies(k: EK) -> bool {
matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm)
matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm | EK::Fairy)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
@@ -1599,6 +1606,13 @@ impl Game {
}
if w >= 3 {
pool.push((Priest, 2));
pool.push((Fairy, 1));
}
if w >= 4 {
pool.push((BattleMage, 1));
}
if w >= 6 {
pool.push((Giant, 1));
}
let boss = if w % 5 == 0 { Some(Paladin) } else { None };
let base_level = 1 + (w - 1) / 3;
@@ -1822,15 +1836,27 @@ impl Game {
e.timer = 0.0;
let pow = match e.kind {
EK::Dwarf => 3.0,
EK::Paladin => 2.0,
EK::Paladin | EK::Giant => 2.0,
_ => 1.0,
} * if tile.kind == Kind::Steel {
} * if e.kind == EK::BattleMage && (tile.reinforced || tile.kind == Kind::Steel) {
// the battle mage blasts reinforced walls (steel resists better)
if tile.kind == Kind::Steel {
0.4
} else {
1.6
}
} else if tile.kind == Kind::Steel {
0.05
} else if tile.reinforced {
0.2
} else {
1.0
};
if e.kind == EK::BattleMage {
let (sx, sy) = (e.x, e.y);
self.shot(sx, sy, 0.6, tx as f32 + 0.5, ty as f32 + 0.5, 0.5, 0.3, 0xff3080ff);
self.style_last_fx(crate::particles::ST_FIRE);
}
self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.4, 0xff3a5a7a);
self.sfx(Sfx::Dig, tx as f32 + 0.5, ty as f32 + 0.5);
let t = &mut self.map.t[next];
@@ -2020,7 +2046,7 @@ impl Game {
EK::Archer => ST_ARROW,
EK::Mage => ST_SHADOW,
EK::Priest => ST_HOLY,
EK::Priestess | EK::Wyrm => ST_FIRE,
EK::Priestess | EK::Wyrm | EK::BattleMage => ST_FIRE,
EK::Owl => ST_ARCANE,
_ => ST_PLAIN,
});
@@ -2051,7 +2077,7 @@ impl Game {
}
}
}
if e.kind == EK::Priestess || e.kind == EK::Wyrm {
if e.kind == EK::Priestess || e.kind == EK::Wyrm || e.kind == EK::BattleMage {
// fireball / fire breath: half damage to enemies around the target
let me = e.side();
let splash: Vec<usize> = self
@@ -3746,6 +3772,18 @@ impl Game {
// ---------------------------------------------------------------- heroes
fn hero_ai(&mut self, e: &mut Ent, dt: f32) {
if e.kind == EK::Fairy {
// fairy dust: the heroes around are healed a little all the time
for o in self.ents.iter_mut() {
if o.alive && o.active_hero() && o.hp < o.max_hp && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 9.0 {
o.hp = (o.hp + 6.0 * dt).min(o.max_hp);
}
}
if self.vrng.f() < 5.0 * dt {
let vx = self.vrng.f() * 0.6 - 0.3;
self.part(crate::particles::EMBER, (e.x, e.y, 0.9), (vx, 0.0, 0.2), 1.0, crate::particles::abgr(255, 240, 180), 0.03);
}
}
if e.think <= 0.0 {
e.think = 0.5;
if e.kind == EK::Priest && e.cd <= 0.0 {
+44 -2
View File
@@ -1278,6 +1278,45 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
"..kCk.kCk.",
"..kk...kk.",
],
// giant: a huge brute in a leather jerkin with a tree-trunk club
EK::Giant => [
"...kkkk...",
"..kffffk..",
"..kfefek.b",
"..kffffk.b",
".kBBBBBBkb",
"kfBuuuuBfb",
"kfBuuuuBf.",
".kBBBBBBk.",
".kBBk.kBBk",
".kkk...kkk",
],
// fairy: a tiny glowing spy with dragonfly wings
EK::Fairy => [
"..........",
"ww.....ww.",
"www.kk.www",
".wwkffkww.",
"..wkefkw..",
"...kuuk...",
"...kuuk...",
"....kk....",
"...y..y...",
"..........",
],
// battle mage: robes of the order, a burning staff
EK::BattleMage => [
"....kk...o",
"...kuuk.oy",
"..kuuuuk.o",
".kkffffk.b",
"..kfefek.b",
"..kwuuwk.b",
".kuwuuwukb",
".kuuwwuuk.",
".kuuuuuuk.",
"..kkkkkk..",
],
EK::Wyrm => [
"R.......kw",
"RR....krrk",
@@ -1347,7 +1386,7 @@ fn pal(ch: char) -> Option<Color> {
})
}
pub const SPRITE_KINDS: [EK; 19] = [
pub const SPRITE_KINDS: [EK; 22] = [
EK::Worker,
EK::Kobold,
EK::Spider,
@@ -1367,6 +1406,9 @@ pub const SPRITE_KINDS: [EK; 19] = [
EK::Wyrm,
EK::Cogling,
EK::Revenant,
EK::Giant,
EK::Fairy,
EK::BattleMage,
];
// width of the sprite texture in pixels
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32;
@@ -1384,7 +1426,7 @@ pub fn gear_tier(level: u32) -> u32 {
}
fn is_hero_kind(k: EK) -> bool {
matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin)
matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin | EK::Giant | EK::Fairy | EK::BattleMage)
}
// Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front
+1 -1
View File
@@ -8,7 +8,7 @@ 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; 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];
const HUMANS: [EK; 8] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Fairy, EK::BattleMage, EK::Giant, EK::Paladin];
// buildings: indices into ui::TOOLS
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];
+8 -3
View File
@@ -1283,7 +1283,7 @@ impl R3d {
if !e.alive || e.held || unit_hidden(g, e) {
continue;
}
let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Wyrm | EK::Gargoyle);
let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Wyrm | EK::Gargoyle | EK::Giant);
let lift = if flies(e.kind) && !(matches!(e.job, Job::Sleep(_)) && !e.moving) { 0.45 } else { 0.0 };
let head = proj(vec3(e.x, lift + if big { 0.9 } else { 0.72 }, e.y));
// bar width in block characters, depending on the zoom
@@ -1363,7 +1363,7 @@ fn shade_c(r: u8, g: u8, b: u8, f: f32) -> Color {
// size of a unit's sprite (same as in the billboards)
fn sprite_size(k: EK) -> f32 {
if matches!(k, EK::Chainbreaker | EK::Wyrm) {
if matches!(k, EK::Chainbreaker | EK::Wyrm | EK::Giant) {
1.0
} else if matches!(k, EK::Ogre | EK::Paladin | EK::Jailer | EK::Gargoyle) {
0.8
@@ -2086,7 +2086,7 @@ fn draw_shadows(g: &Game) {
if !e.alive || e.held || unit_hidden(g, e) {
continue;
}
let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker) { 0.32 } else { 0.24 };
let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Giant) { 0.32 } else { 0.24 };
let fy = floor_y(g, e.x, e.y);
disc(vec3(e.x, fy + 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12);
let rc = if e.captive != 0 {
@@ -2296,6 +2296,11 @@ fn draw_traps_glow(g: &Game, t: f32) {
if tr.stage != ARMED {
continue;
}
if tr.revealed && !tr.kind.door() {
// found by a fairy: a white warning glint above it
let a = 0.5 + 0.5 * (t * 6.0).sin();
draw_cube(c + vec3(0.0, 0.7 + 0.05 * (t * 3.0).sin(), 0.0), vec3(0.06, 0.18, 0.06), None, Color::new(1.0, 1.0, 1.0, a));
}
match tr.kind {
TrapKind::Poison => disc(c + vec3(0.0, 0.07, 0.0), 0.22, Color::new(0.4, 1.0, 0.3, 0.3 + 0.15 * (t * 2.0).sin()), 12),
TrapKind::Fire => {
+72 -4
View File
@@ -98,6 +98,8 @@ pub struct Trap {
pub carrier: u32, // imp carrying or installing it (entity id + 1)
pub fired: f32, // time since it last went off (for drawing)
pub aim: f32, // direction of the last shot (fire jet, roller)
#[serde(default)]
pub revealed: bool, // a fairy has found it: heroes wreck it
}
// a boulder rolling down a corridor
@@ -122,8 +124,12 @@ pub struct Cloud {
const CLOUD_TIME: f32 = 6.0;
// how hard a unit hits doors
pub fn door_breaker(_k: EK) -> f32 {
1.0
pub fn door_breaker(k: EK) -> f32 {
if k == EK::Giant {
3.0
} else {
1.0
}
}
fn enemy(e: &Ent) -> bool {
@@ -172,7 +178,7 @@ impl Game {
}
let id = self.next_id;
self.next_id += 1;
self.traps.push(Trap { id, kind, tile: i, stage: ORDERED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0 });
self.traps.push(Trap { id, kind, tile: i, stage: ORDERED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0, revealed: false });
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.4, 0xff40d0ff);
self.sfx_ui(Sfx::FlagSet);
}
@@ -408,6 +414,45 @@ impl Game {
}
rollers.retain(|r| r.t < 50.0);
self.rollers = rollers;
// fairies find traps, giants and heroes wreck the traps they know about
let mut wrecked = Vec::new();
for k in 0..self.traps.len() {
let t = &self.traps[k];
if t.stage != ARMED || t.kind.door() {
continue;
}
let (tx, ty) = xy(t.tile);
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
let mut damage = 0.0;
let mut found = t.revealed;
for e in self.ents.iter().filter(|e| e.alive && !e.held && e.active_hero()) {
let d2 = (e.x - cx).powi(2) + (e.y - cy).powi(2);
if e.kind == EK::Fairy && d2 < 9.0 {
found = true;
}
if d2 < 1.6 && (found || e.kind == EK::Giant) {
damage += if e.kind == EK::Giant { 40.0 } else { 15.0 };
}
}
let t = &mut self.traps[k];
if found && !t.revealed {
t.revealed = true;
}
if damage > 0.0 {
t.hp -= damage * dt;
t.fired = 0.0;
if t.hp <= 0.0 {
wrecked.push(k);
}
}
}
for &k in wrecked.iter().rev() {
let t = self.traps.remove(k);
let (tx, ty) = xy(t.tile);
self.fx(3, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.8, 0xff505070);
self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.5, 0xff3a5a7a);
self.msg("msg.trap_wrecked", vec![format!("@trap.{}", t.kind.id())]);
}
for k in 0..self.traps.len() {
if k >= self.traps.len() {
break;
@@ -583,7 +628,7 @@ mod tests {
fn doors_stop_enemies_until_smashed_and_lock() {
let (mut g, cx, cy) = forge_game();
let i = idx(cx + 10, cy);
g.traps.push(Trap { id: 999, kind: TrapKind::WoodDoor, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: 400.0, locked: false, carrier: 0, fired: 99.0, aim: 0.0 });
g.traps.push(Trap { id: 999, kind: TrapKind::WoodDoor, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: 400.0, locked: false, carrier: 0, fired: 99.0, aim: 0.0, revealed: false });
g.map.t[i].kind = Kind::Door;
assert!(g.map.walk_cost()(i).is_some());
g.place_trap(TrapKind::WoodDoor, cx + 10, cy);
@@ -601,4 +646,27 @@ mod tests {
assert!(g.trap_at(i).is_none(), "door smashed");
assert_eq!(g.map.t[i].kind, Kind::Floor);
}
#[test]
fn fairies_find_traps_and_giants_wreck_them() {
let (mut g, cx, cy) = forge_game();
let a = idx(cx + 10, cy);
let b = idx(cx + 10, cy + 4);
for (id, i, kind) in [(1001, a, TrapKind::Frost), (1002, b, TrapKind::Alarm)] {
g.traps.push(Trap { id, kind, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 99.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0, revealed: false });
}
g.spawn(EK::Fairy, true, cx as f32 + 11.5, cy as f32 + 0.5, 1);
g.spawn(EK::Giant, true, cx as f32 + 10.5, cy as f32 + 5.5, 1);
for j in 0..g.ents.len() {
g.ents[j].stun = 999.0; // stand still
}
for _ in 0..(20 * 15) {
g.update(0.05);
for j in 0..g.ents.len() {
g.ents[j].stun = 999.0;
}
}
assert!(g.traps.iter().all(|t| t.id != 1002), "the giant wrecked the alarm");
assert!(g.traps.iter().find(|t| t.id == 1001).map_or(true, |t| t.revealed), "the fairy found the frost glow");
}
}