diff --git a/lang/de.lang b/lang/de.lang index 31ea90f..0f8b3cb 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -827,3 +827,36 @@ tool.quake.label = Beben tool.quake.name = Beben tool.quake.desc = Der Boden bebt: Feinde rund um die Stelle werden verletzt und zwei Sekunden betäubt. Deine Kreaturen bleiben standfest. tile.unexplored = Unerforschte Finsternis + +# personality +msg.brawl = Eine Prügelei! {0} und {1} können sich nicht ausstehen. +job.brawl = Prügelt sich +best.likes = Liebt: {0} +best.hates = Kann nicht leiden: {0} +best.ability = Ab St. {0}: {1} - {2} +ability.dig.name = Alter Hase +ability.dig.desc = gräbt ein Viertel schneller. +ability.dash.name = Sprung +ability.dash.desc = springt Feinde aus ein paar Feldern Entfernung mit einem schweren Hieb an. +ability.web.name = Netz +ability.web.desc = nach ihren Bissen bleiben Feinde oft kleben und werden langsam. +ability.teleport.name = Schattenschritt +ability.teleport.desc = im Kampf schwer verletzt, verschwindet er zurück in sein Bett. +ability.stomp.name = Stampfer +ability.stomp.desc = lässt den Boden beben: Feinde ringsum werden verletzt und betäubt. +ability.shackle.name = Fessel +ability.shackle.desc = seine Hiebe legen Feinde manchmal in Ketten. +ability.pulse.name = Heilwelle +ability.pulse.desc = heilt alle verletzten Kreaturen um sich herum auf einmal. +ability.whirl.name = Wirbelwind +ability.whirl.desc = wirbelt seine Äxte durch jeden Feind ringsum. +ability.shriek.name = Kreischen +ability.shriek.desc = ein durchdringender Schrei, der Feinde in der Nähe betäubt. +ability.stoneform.name = Steingestalt +ability.stoneform.desc = schwer verletzt, wird er zu Stein und nimmt eine Weile nur halben Schaden. +ability.volley.name = Salve +ability.volley.desc = jedem Geschoss folgt ein zweites auf einen anderen Feind. +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. diff --git a/lang/en.lang b/lang/en.lang index 52f0cd9..1ea9378 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -828,3 +828,36 @@ tool.quake.label = Quake tool.quake.name = Quake tool.quake.desc = The ground shakes: enemies around the spot are hurt and stunned for two seconds. Your creatures keep their footing. tile.unexplored = Unexplored darkness + +# personality +msg.brawl = A brawl! The {0} and the {1} cannot stand each other. +job.brawl = Brawling +best.likes = Loves: {0} +best.hates = Cannot stand: {0} +best.ability = From Lv {0}: {1} - {2} +ability.dig.name = Old hand +ability.dig.desc = digs a quarter faster. +ability.dash.name = Dash +ability.dash.desc = leaps at enemies a few tiles away with a heavy blow. +ability.web.name = Web +ability.web.desc = its bites often leave enemies stuck and slow. +ability.teleport.name = Shadow step +ability.teleport.desc = badly hurt in a fight, it vanishes back to its bed. +ability.stomp.name = Stomp +ability.stomp.desc = shakes the ground: enemies around it are hurt and stunned. +ability.shackle.name = Shackle +ability.shackle.desc = its blows sometimes chain enemies in place. +ability.pulse.name = Healing pulse +ability.pulse.desc = heals all hurt creatures around her at once. +ability.whirl.name = Whirlwind +ability.whirl.desc = spins its axes through every enemy around it. +ability.shriek.name = Shriek +ability.shriek.desc = a piercing cry that stuns enemies nearby. +ability.stoneform.name = Stone form +ability.stoneform.desc = badly hurt, it turns to stone and takes half damage for a while. +ability.volley.name = Volley +ability.volley.desc = every bolt is followed by a second one at another enemy. +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. diff --git a/src/game.rs b/src/game.rs index b531921..865ebce 100644 --- a/src/game.rs +++ b/src/game.rs @@ -183,6 +183,7 @@ pub enum Job { Help(usize), // imp with nothing else to do helps another imp at this tile 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) } #[derive(Clone, Serialize, Deserialize)] @@ -242,6 +243,10 @@ pub struct Ent { pub invis: f32, // spell: seconds of invisibility left (until it attacks) #[serde(default)] pub plague: f32, // spell: seconds of plague left (poison that spreads) + #[serde(default)] + pub ab_cd: f32, // cooldown of the learned ability + #[serde(default)] + pub mood_t: f32, // seconds until the next mood check (likes and hatreds) pub guard: bool, // stays at its home (gate defenders, treasure guards) #[serde(default)] pub home: Option<(f32, f32)>, // guard post @@ -311,6 +316,8 @@ impl Ent { shield: 0.0, invis: 0.0, plague: 0.0, + ab_cd: 0.0, + mood_t: 0.0, guard: false, home: None, patrol: Vec::new(), @@ -1732,7 +1739,7 @@ impl Game { }; let target = match e.job { Job::Dig(t) | Job::Reinforce(t) | Job::Help(t) | Job::TearBridge(t) => tile(t), - Job::Attack(id) | Job::Torment(id) | Job::Carry(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)), + Job::Attack(id) | Job::Torment(id) | Job::Carry(id) | Job::Brawl(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)), Job::Repair(id) | Job::AttackTower(id) => self.towers.iter().find(|t| t.id == id).map(|t| t.pos()), _ => None, }; @@ -1855,7 +1862,7 @@ impl Game { } } - fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool { + pub(crate) fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool { let dx = x1 - x0; let dy = y1 - y0; let d = (dx * dx + dy * dy).sqrt(); @@ -1918,6 +1925,9 @@ impl Game { e.path.clear(); e.face = sgn(tx - e.x); e.working = true; + if e.cd <= 0.0 && self.use_ability(e, j) { + return true; + } if e.cd <= 0.0 { e.cd = s.interval; self.hit(e, j); @@ -2007,7 +2017,7 @@ impl Game { .ents .iter() .enumerate() - .filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44) + .filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < if crate::personality::knows(e, "inferno") { 6.25 } else { 1.44 }) .map(|(o, _)| o) .collect(); for o in splash { @@ -2023,6 +2033,27 @@ impl Game { return; } } + // learned abilities that come with a blow + if crate::personality::knows(e, "web") && self.rng.f() < 0.3 { + self.ents[j].slow = self.ents[j].slow.max(3.0); + } + if crate::personality::knows(e, "shackle") && self.rng.f() < 0.2 { + self.ents[j].stun = self.ents[j].stun.max(2.0); + } + if crate::personality::knows(e, "volley") { + let me = e.side(); + let second = self.ents.iter().enumerate().find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 6.0).map(|(o, _)| o); + if let Some(o) = second { + let (ox, oy) = (self.ents[o].x, self.ents[o].y); + self.shot(e.x, e.y, 0.8, ox, oy, 0.45, 0.25, 0xffffa060); + self.style_last_fx(crate::particles::ST_ARCANE); + self.ents[o].hp -= dmg * 0.6; + self.ents[o].flash = 0.15; + if self.ents[o].hp <= 0.0 { + self.kill(o); + } + } + } let my_id = e.id; e.invis = 0.0; // attacking breaks invisibility let t = &mut self.ents[j]; @@ -2605,7 +2636,8 @@ impl Game { let (tx, ty) = xy(t); e.face = sgn(tx as f32 + 0.5 - e.x); e.working = true; - e.timer += dt * self.help_speed(t); + let skilled = if crate::personality::knows(e, "dig") { 1.25 } else { 1.0 }; + e.timer += dt * self.help_speed(t) * skilled; if e.timer >= 0.45 { e.timer = 0.0; let fxx = (e.x + tx as f32 + 0.5) * 0.5; @@ -3008,6 +3040,12 @@ impl Game { self.hint(20, "hint.chainbreaker_restless"); } } + self.mood_check(e, dt); + self.passive_abilities(e); + if let Job::Brawl(id) = e.job { + self.brawl(e, id, dt); + return; + } if let Job::Attack(id) = e.job { if !self.attack_job(e, id, dt) { e.job = Job::None; @@ -4751,6 +4789,7 @@ pub fn job_name(e: &Ent) -> &'static str { Job::Eat(_) => "job.eat", Job::Train(_) => "job.train", Job::Forge(_) => "job.forge", + Job::Brawl(_) => "job.brawl", Job::Install(_) => "job.install", Job::Study(_) => "job.study", Job::Attack(_) => "job.fight", diff --git a/src/main.rs b/src/main.rs index b38d3db..97912a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod gfx; mod i18n; mod map; mod menu; +mod personality; mod platform; mod particles; mod props; diff --git a/src/menu/bestiary.rs b/src/menu/bestiary.rs index b8851e8..5dec21b 100644 --- a/src/menu/bestiary.rs +++ b/src/menu/bestiary.rs @@ -216,6 +216,19 @@ impl Menu { } if !HUMANS.contains(&kind) && !cave { facts.push((trf("best.lure", &[tr(&format!("best.{}.lure", id))]), BLUE_C)); + // personality: favourite room, hatreds, learned ability + let p = crate::personality::persona(kind); + if let Some(r) = crate::game::room_index(p.likes) { + facts.push((trf("best.likes", &[tr(crate::game::ROOM_NAMES[r])]), GREEN_C)); + } + if !p.hates.is_empty() { + let names: Vec = p.hates.iter().map(|&h| tr(st(h).name)).collect(); + facts.push((trf("best.hates", &[names.join(", ")]), RED_C)); + } + if let Some((lv, a)) = p.ability { + let line = trf("best.ability", &[lv.to_string(), tr(&format!("ability.{}.name", a)), tr(&format!("ability.{}.desc", a))]); + facts.push((line, if level >= lv { GOLD } else { DIM })); + } } text_key = format!("best.{}.text", id); } diff --git a/src/personality.rs b/src/personality.rs new file mode 100644 index 0000000..ce3e589 --- /dev/null +++ b/src/personality.rs @@ -0,0 +1,279 @@ +// Creature personality: every kind loves one room and cannot stand certain other kinds (bad +// mood, now and then a brawl - never to the death), and learns an ability at a certain level. + +use crate::game::*; +use crate::map::*; +use crate::particles::{abgr, RING}; + +pub struct Persona { + pub likes: Kind, + pub hates: &'static [EK], + pub ability: Option<(u32, &'static str)>, // level, id (ability..name / .desc) +} + +pub fn persona(k: EK) -> Persona { + let p = |likes, hates, ability| Persona { likes, hates, ability }; + match k { + EK::Worker => p(Kind::Treasury, &[], Some((3, "dig"))), + EK::Kobold => p(Kind::Training, &[EK::Mage], Some((5, "dash"))), + EK::Spider => p(Kind::Farm, &[EK::Bat], Some((4, "web"))), + EK::Mage => p(Kind::Library, &[EK::Kobold, EK::Ogre], Some((6, "teleport"))), + EK::Ogre => p(Kind::Training, &[EK::Mage, EK::Cogling], Some((4, "stomp"))), + EK::Jailer => p(Kind::Torture, &[EK::Priestess], Some((7, "shackle"))), + EK::Priestess => p(Kind::Temple, &[EK::Jailer], Some((5, "pulse"))), + EK::Chainbreaker => p(Kind::Training, &[], Some((6, "whirl"))), + EK::Bat => p(Kind::Aerie, &[EK::Spider], Some((5, "shriek"))), + EK::Gargoyle => p(Kind::Torture, &[EK::Owl], Some((5, "stoneform"))), + 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"))), + _ => p(Kind::Lair, &[], None), + } +} + +// has this unit learned its ability? +pub fn knows(e: &Ent, id: &str) -> bool { + !e.hero && persona(e.kind).ability.map_or(false, |(lv, a)| a == id && e.level >= lv) +} + +fn works_in(e: &Ent, g: &Game, kind: Kind) -> bool { + match e.job { + Job::Train(t) | Job::Study(t) | Job::Pray(t) | Job::Forge(t) | Job::Sleep(t) | Job::Eat(t) => g.map.t[t].kind == kind && e.path.is_empty(), + Job::Torment(_) => kind == Kind::Torture && e.path.is_empty(), + _ => g.map.t[e.tile()].kind == kind, + } +} + +impl Game { + // every few seconds: the favourite room cheers up, hated company sours the mood + pub(crate) fn mood_check(&mut self, e: &mut Ent, dt: f32) { + e.mood_t -= dt; + if e.mood_t > 0.0 { + return; + } + e.mood_t = 4.0; + let p = persona(e.kind); + if works_in(e, self, p.likes) { + e.happy = (e.happy + 0.03).min(1.0); + } + if p.hates.is_empty() || matches!(e.job, Job::Brawl(_) | Job::Attack(_)) { + return; + } + let foe = self.ents.iter().position(|o| o.alive && !o.held && o.creature() && p.hates.contains(&o.kind) && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 9.0); + let Some(j) = foe else { return }; + e.happy -= 0.015; + let idle = |job: Job| matches!(job, Job::None | Job::Wander); + if idle(e.job) && idle(self.ents[j].job) && self.rng.f() < 0.15 { + // a brawl breaks out + let other = self.ents[j].id; + e.job = Job::Brawl(other); + e.timer = 0.0; + e.path.clear(); + self.ents[j].job = Job::Brawl(e.id); + self.ents[j].timer = 0.0; + self.ents[j].path.clear(); + let (a, b) = (st(e.kind).name, st(self.ents[j].kind).name); + self.msg("msg.brawl", vec![format!("@{}", a), format!("@{}", b)]); + } + } + + // two creatures scuffling: they stop at 30% health, after eight seconds or when slapped + pub(crate) fn brawl(&mut self, e: &mut Ent, id: u32, dt: f32) { + e.timer += dt; + let Some(j) = self.find(id) else { + e.job = Job::None; + return; + }; + let over = e.timer > 8.0 || e.hp < e.max_hp * 0.3 || self.ents[j].hp < self.ents[j].max_hp * 0.3 || !matches!(self.ents[j].job, Job::Brawl(_)); + if over { + e.job = Job::None; + if self.ents[j].job == Job::Brawl(e.id) { + self.ents[j].job = Job::None; + } + e.happy -= 0.03; + return; + } + let (tx, ty) = (self.ents[j].x, self.ents[j].y); + let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt(); + if d > 1.1 { + if e.path.is_empty() || e.repath <= 0.0 { + e.repath = 0.8; + if let Some(p) = self.map.path_to(e.tile(), idx(tx as i32, ty as i32)) { + e.path = p; + } + } + self.step_path(e, dt); + return; + } + e.path.clear(); + e.working = true; + e.face = if tx > e.x { 1.0 } else { -1.0 }; + if e.cd <= 0.0 { + e.cd = st(e.kind).interval; + let o = &mut self.ents[j]; + o.hp = (o.hp - e.atk() * 0.4).max(o.max_hp * 0.25); + o.flash = 0.15; + self.fx(2, tx, ty, 0.0, 0.0, 0.25, 0xffffffff); + self.sfx(Sfx::MeleeHit, tx, ty); + } + } + + // active abilities in a fight; true = used instead of a normal blow + pub(crate) fn use_ability(&mut self, e: &mut Ent, j: usize) -> bool { + if e.ab_cd > 0.0 || e.hero || e.side != 0 { + return false; + } + let (tx, ty) = (self.ents[j].x, self.ents[j].y); + let me = e.side(); + let foes_near = |g: &Game, x: f32, y: f32, r: f32| -> Vec { + (0..g.ents.len()).filter(|&o| { + let u = &g.ents[o]; + u.alive && !u.held && u.side() != me && u.captive == 0 && !u.chained && (u.x - x).powi(2) + (u.y - y).powi(2) < r * r + }).collect() + }; + let hurt = |g: &mut Game, o: usize, dmg: f32| { + let u = &mut g.ents[o]; + u.hp -= if u.shield > 0.0 { dmg * 0.5 } else { dmg }; + u.flash = 0.15; + if u.hp <= 0.0 { + g.kill(o); + } + }; + if knows(e, "stomp") || knows(e, "whirl") || knows(e, "shriek") { + let (r, cd) = if knows(e, "shriek") { (2.0, 10.0) } else if knows(e, "whirl") { (1.6, 7.0) } else { (1.6, 8.0) }; + let hit = foes_near(self, e.x, e.y, r); + if hit.len() < 2 && !knows(e, "shriek") { + return false; + } + for o in hit { + if knows(e, "shriek") { + self.ents[o].stun = self.ents[o].stun.max(1.2); + } else { + if knows(e, "stomp") { + self.ents[o].stun = self.ents[o].stun.max(1.0); + } + let dmg = e.atk() * if knows(e, "whirl") { 0.8 } else { 0.6 }; + hurt(self, o, dmg); + } + } + self.part(RING, (e.x, e.y, 0.05), (0.0, 0.0, 0.0), 0.4, abgr(230, 200, 150), r); + self.sfx(Sfx::Cannon, e.x, e.y); + e.ab_cd = cd; + e.cd = st(e.kind).interval; + return true; + } + if knows(e, "bomb") { + self.shot(e.x, e.y, 0.5, tx, ty, 0.2, 0.4, 0xff303030); + self.style_last_fx(crate::particles::ST_CANNON); + for o in foes_near(self, tx, ty, 1.3) { + hurt(self, o, 40.0); + } + e.ab_cd = 10.0; + e.cd = st(e.kind).interval; + return true; + } + if knows(e, "dash") { + let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt(); + if d > 2.5 && d < 6.0 && self.los(e.x, e.y, tx, ty) { + let (nx, ny) = (tx - (tx - e.x) / d * 0.8, ty - (ty - e.y) / d * 0.8); + if self.map.walk(nx as i32, ny as i32) { + self.fx(0, e.x, e.y, nx, ny, 0.15, 0xffffffff); + e.x = nx; + e.y = ny; + e.path.clear(); + hurt(self, j, e.atk() * 1.2); + e.ab_cd = 8.0; + return true; + } + } + } + false + } + + // abilities that work on their own (escape, protection, healing) + pub(crate) fn passive_abilities(&mut self, e: &mut Ent) { + if e.ab_cd > 0.0 || e.hero || e.side != 0 { + return; + } + let fighting = matches!(e.job, Job::Attack(_)); + if knows(e, "teleport") && fighting && e.hp < e.max_hp * 0.3 { + let (cx, cy) = self.map.core; + let to = e.bed.map(|b| (xy(b).0 as f32 + 0.5, xy(b).1 as f32 + 0.5)).unwrap_or((cx as f32 - 0.5, cy as f32 + 1.5)); + self.fx(7, e.x, e.y, 0.0, 0.0, 0.6, 0xffff66cc); + e.x = to.0; + e.y = to.1; + e.path.clear(); + e.job = Job::None; + self.fx(7, e.x, e.y, 0.0, 0.0, 0.6, 0xffff66cc); + self.sfx(Sfx::Summon, e.x, e.y); + e.ab_cd = 30.0; + } else if knows(e, "stoneform") && e.hp < e.max_hp * 0.4 { + e.shield = 6.0; + e.ab_cd = 25.0; + self.fx(7, e.x, e.y, 0.0, 0.0, 0.6, 0xffa0a0a0); + } else if knows(e, "pulse") { + let hurt: Vec = (0..self.ents.len()).filter(|&o| { + let u = &self.ents[o]; + u.alive && !u.held && u.creature() && u.hp < u.max_hp * 0.7 && (u.x - e.x).powi(2) + (u.y - e.y).powi(2) < 9.0 + }).collect(); + if hurt.len() >= 2 { + for o in hurt { + let u = &mut self.ents[o]; + u.hp = (u.hp + 30.0).min(u.max_hp); + let (ux, uy) = (u.x, u.y); + self.fx(4, ux, uy, 0.0, 0.0, 0.8, 0xff60ff80); + } + self.part(RING, (e.x, e.y, 0.05), (0.0, 0.0, 0.0), 0.5, abgr(120, 255, 140), 3.0); + e.ab_cd = 12.0; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hated_neighbours_brawl_but_nobody_dies() { + let mut g = Game::new(8, 64, 64); + let (cx, cy) = g.map.core; + let a = g.spawn(EK::Mage, false, cx as f32 - 0.5, cy as f32 + 0.5, 2); + let b = g.spawn(EK::Ogre, false, cx as f32 - 0.5, cy as f32 + 1.5, 2); + for id in [a, b] { + let j = g.find(id).unwrap(); + g.ents[j].settled = true; + } + let mut brawled = false; + for _ in 0..(20 * 120) { + g.update(0.05); + brawled |= g.ents.iter().any(|e| matches!(e.job, Job::Brawl(_))); + for id in [a, b] { + let j = g.find(id).unwrap(); + g.ents[j].hunger = 0.0; + g.ents[j].energy = 1.0; + if !matches!(g.ents[j].job, Job::Brawl(_)) { + g.ents[j].job = Job::None; + g.ents[j].think = 99.0; + } + } + } + assert!(brawled, "they quarrel"); + assert!(g.find(a).is_some() && g.find(b).is_some(), "nobody died"); + } + + #[test] + fn ogres_learn_to_stomp() { + let mut g = Game::new(8, 64, 64); + let (cx, cy) = g.map.core; + let o = g.spawn(EK::Ogre, false, cx as f32 - 0.5, cy as f32 + 1.5, 4); + for k in 0..2 { + g.spawn(EK::Knight, true, cx as f32 - 0.5 + k as f32 * 0.3, cy as f32 + 2.3, 1); + } + let j = g.find(o).unwrap(); + let mut e = g.ents[j].clone(); + let target = g.ents.iter().position(|x| x.hero).unwrap(); + assert!(g.use_ability(&mut e, target)); + assert!(g.ents.iter().filter(|x| x.hero).all(|x| x.stun > 0.0)); + } +} diff --git a/src/spells.rs b/src/spells.rs index dbfdb95..0e58e03 100644 --- a/src/spells.rs +++ b/src/spells.rs @@ -238,6 +238,7 @@ impl Game { // running spell effects on a unit (called every frame) pub(crate) fn tick_effects(&mut self, e: &mut Ent, dt: f32) { e.haste = (e.haste - dt).max(0.0); + e.ab_cd = (e.ab_cd - dt).max(0.0); e.shield = (e.shield - dt).max(0.0); e.invis = (e.invis - dt).max(0.0); if e.plague > 0.0 {