diff --git a/lang/de.lang b/lang/de.lang index 363ba52..39847f2 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -893,3 +893,12 @@ best.b.arena.text = Sand, Schweiß und gelegentlich ein Zahn. Erfahrung kommt hi 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. + +# more heroes +unit.giant = Riese +unit.fairy = Feenspion +unit.battlemage = Kampfmagier +msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}. +best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne. +best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber. +best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so. diff --git a/lang/en.lang b/lang/en.lang index 9e33596..f970228 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -894,3 +894,12 @@ best.b.arena.text = Sand, sweat and the occasional tooth. Experience comes fast 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. + +# more heroes +unit.giant = Giant +unit.fairy = Fairy Spy +unit.battlemage = Battle Mage +msg.trap_wrecked = The heroes wrecked a trap: {0}. +best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar. +best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure. +best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good. diff --git a/src/game.rs b/src/game.rs index 656df53..a1e8c12 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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 = 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 { diff --git a/src/gfx.rs b/src/gfx.rs index 0858c46..055083f 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -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 { }) } -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 diff --git a/src/menu/bestiary.rs b/src/menu/bestiary.rs index 6c7b8be..c4e5dde 100644 --- a/src/menu/bestiary.rs +++ b/src/menu/bestiary.rs @@ -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]; diff --git a/src/render3d.rs b/src/render3d.rs index d2c3cb0..b6807dc 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -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 => { diff --git a/src/traps.rs b/src/traps.rs index 6774ccd..0e105b7 100644 --- a/src/traps.rs +++ b/src/traps.rs @@ -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"); + } }