From c52fc4c4ae0011993aed917c02765be2d9716036 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Fri, 6 Nov 2026 13:48:06 +0100 Subject: [PATCH] Hall of the fallen: kills and causes of death, in the game and over all games in the profile Co-Authored-By: Claude Opus 5.5 --- docs/ROADMAP.md | 4 ++ lang/cs.lang | 14 +++++++ lang/da.lang | 14 +++++++ lang/de.lang | 14 +++++++ lang/el.lang | 14 +++++++ lang/en.lang | 14 +++++++ lang/es.lang | 14 +++++++ lang/fi.lang | 14 +++++++ lang/hr.lang | 14 +++++++ lang/hu.lang | 14 +++++++ lang/it.lang | 14 +++++++ lang/nl.lang | 14 +++++++ lang/no.lang | 14 +++++++ lang/pt.lang | 14 +++++++ lang/ro.lang | 14 +++++++ lang/ru.lang | 14 +++++++ lang/sk.lang | 14 +++++++ lang/sr.lang | 14 +++++++ lang/sv.lang | 14 +++++++ src/achievements.rs | 29 +++++++++++++- src/game.rs | 25 ++++++++++++ src/main.rs | 11 +++++- src/menu.rs | 8 ++++ src/menu/achievements.rs | 71 +++++++++++++++++++++++++++++++++- src/names.rs | 82 ++++++++++++++++++++++++++++++++++++++++ src/rogues.rs | 1 + src/ui.rs | 55 ++++++++++++++++++++++++++- src/weather.rs | 1 + 28 files changed, 534 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 44fe2d7..c3b76c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -142,3 +142,7 @@ grid), guard and attack markers (shields and swords), savegames with the game st language files (20 per kind, per language), a humorous epithet from level 5 and a grand one from level 8; names in the hover info, the creature overview and the messages. A creature of level 3+ that falls leaves a memorial stone on its master's ground (hover: who lies there; at most 24). +7. **Hall of the fallen** (done, v202611-00073) - every named creature of level 3+ that falls is remembered + with its level, heroes slain and how it fell (slain by a kind, lava, water, snare, lightning): in the game + (creature overview "Fallen", or a click with the hand on a memorial stone) and over all games on the + achievements page (kept in the profile, at most 200, merged with the server copy). diff --git a/lang/cs.lang b/lang/cs.lang index 3f5ead4..ac69d4a 100644 --- a/lang/cs.lang +++ b/lang/cs.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Padl tvůj tvor: {0}. Pamětní kámen uchová jméno. tile.memorial = Pamětní kámen tile.memorial_info = Zde odpočívá {0} ({1}, úroveň {2}). + +# hall of the fallen +hall.title = Síň padlých +hall.count = Padlí tvorové v paměti: {0} +hall.empty = Zatím nikdo nepadl. Ať to tak vydrží. +hall.kills = Zabití hrdinové: {0} +ov.fallen = Padlí ({0}) +ov.living = Živí +cause.slain = vrah: {0} +cause.lava = smrt v lávě +cause.water = utonutí +cause.snare = smrt v pasti +cause.lightning = zásah bleskem +cause.fell = smrt v boji diff --git a/lang/da.lang b/lang/da.lang index 9fe671f..d612b6d 100644 --- a/lang/da.lang +++ b/lang/da.lang @@ -1753,3 +1753,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} er faldet. En mindesten bevarer navnet. tile.memorial = Mindesten tile.memorial_info = Her hviler {0} ({1}, niveau {2}). + +# hall of the fallen +hall.title = De faldnes hal +hall.count = Faldne væsner i erindring: {0} +hall.empty = Endnu er ingen faldet. Længe må det vare. +hall.kills = Dræbte helte: {0} +ov.fallen = Faldne ({0}) +ov.living = Levende +cause.slain = dræbt af: {0} +cause.lava = brændt i lava +cause.water = druknet +cause.snare = trådt i en saks +cause.lightning = ramt af lynet +cause.fell = faldet diff --git a/lang/de.lang b/lang/de.lang index 47454ba..aa11aad 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -1753,3 +1753,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} ist gefallen. Ein Gedenkstein bewahrt den Namen. tile.memorial = Gedenkstein tile.memorial_info = Hier ruht {0} ({1}, Stufe {2}). + +# hall of the fallen +hall.title = Halle der Gefallenen +hall.count = {0} gefallene Kreaturen in Erinnerung +hall.empty = Noch ist niemand gefallen. Möge es so bleiben. +hall.kills = {0} Helden erschlagen +ov.fallen = Gefallene ({0}) +ov.living = Lebende +cause.slain = erschlagen von: {0} +cause.lava = in Lava verbrannt +cause.water = ertrunken +cause.snare = in eine Falle getreten +cause.lightning = vom Blitz getroffen +cause.fell = gefallen diff --git a/lang/el.lang b/lang/el.lang index 3ba9d2d..a4938b0 100644 --- a/lang/el.lang +++ b/lang/el.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Έπεσε: {0}. Μια αναμνηστική πέτρα κρατά το όνομα. tile.memorial = Αναμνηστική πέτρα tile.memorial_info = Εδώ αναπαύεται: {0} ({1}, επίπεδο {2}). + +# hall of the fallen +hall.title = Αίθουσα των πεσόντων +hall.count = Πεσόντα πλάσματα στη μνήμη: {0} +hall.empty = Κανείς δεν έχει πέσει ακόμη. Είθε να κρατήσει. +hall.kills = Ήρωες που σκοτώθηκαν: {0} +ov.fallen = Πεσόντες ({0}) +ov.living = Ζωντανοί +cause.slain = φονιάς: {0} +cause.lava = θάνατος στη λάβα +cause.water = πνιγμός +cause.snare = θάνατος σε παγίδα +cause.lightning = χτύπημα κεραυνού +cause.fell = θάνατος στη μάχη diff --git a/lang/en.lang b/lang/en.lang index 26bc718..3b84840 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} has fallen. A memorial stone keeps the name. tile.memorial = Memorial stone tile.memorial_info = Here lies {0} ({1}, level {2}). + +# hall of the fallen +hall.title = Hall of the fallen +hall.count = {0} fallen creatures remembered +hall.empty = No one has fallen yet. Long may it last. +hall.kills = {0} heroes slain +ov.fallen = Fallen ({0}) +ov.living = Living +cause.slain = slain by: {0} +cause.lava = burned in lava +cause.water = drowned +cause.snare = caught in a snare +cause.lightning = struck by lightning +cause.fell = fell diff --git a/lang/es.lang b/lang/es.lang index e82a791..c6bce5d 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} ha caído. Una piedra conmemorativa guarda su nombre. tile.memorial = Piedra conmemorativa tile.memorial_info = Aquí yace {0} ({1}, nivel {2}). + +# hall of the fallen +hall.title = Salón de los caídos +hall.count = Criaturas caídas en el recuerdo: {0} +hall.empty = Aún no ha caído nadie. Que siga así. +hall.kills = Héroes abatidos: {0} +ov.fallen = Caídos ({0}) +ov.living = Vivos +cause.slain = muerte a manos de: {0} +cause.lava = muerte en lava +cause.water = ahogamiento +cause.snare = muerte en un cepo +cause.lightning = muerte por rayo +cause.fell = caída en combate diff --git a/lang/fi.lang b/lang/fi.lang index d4e8785..ad9f564 100644 --- a/lang/fi.lang +++ b/lang/fi.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} on kaatunut. Muistokivi säilyttää nimen. tile.memorial = Muistokivi tile.memorial_info = Tässä lepää {0} ({1}, taso {2}). + +# hall of the fallen +hall.title = Kaatuneiden sali +hall.count = Muistettuja kaatuneita olentoja: {0} +hall.empty = Kukaan ei ole vielä kaatunut. Kauan näin jatkukoon. +hall.kills = Surmattuja sankareita: {0} +ov.fallen = Kaatuneet ({0}) +ov.living = Elävät +cause.slain = surmaaja: {0} +cause.lava = paloi laavassa +cause.water = hukkui +cause.snare = astui ansaan +cause.lightning = salama iski +cause.fell = kaatui diff --git a/lang/hr.lang b/lang/hr.lang index 6e51cdd..52126bc 100644 --- a/lang/hr.lang +++ b/lang/hr.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Palo je tvoje stvorenje: {0}. Spomen-kamen čuva ime. tile.memorial = Spomen-kamen tile.memorial_info = Ovdje počiva {0} ({1}, razina {2}). + +# hall of the fallen +hall.title = Dvorana palih +hall.count = Palih stvorenja u sjećanju: {0} +hall.empty = Još nitko nije pao. Neka tako i ostane. +hall.kills = Ubijenih junaka: {0} +ov.fallen = Pali ({0}) +ov.living = Živi +cause.slain = ubojica: {0} +cause.lava = smrt u lavi +cause.water = utapanje +cause.snare = smrt u zamci +cause.lightning = udar munje +cause.fell = pogibija u boju diff --git a/lang/hu.lang b/lang/hu.lang index d582873..13b00ba 100644 --- a/lang/hu.lang +++ b/lang/hu.lang @@ -1754,3 +1754,17 @@ name.epithet = {1} {0} msg.creature_fallen = Elesett: {0}. Egy emlékkő őrzi a nevét. tile.memorial = Emlékkő tile.memorial_info = Itt nyugszik {0} ({1}, szint: {2}). + +# hall of the fallen +hall.title = Az elesettek csarnoka +hall.count = {0} elesett lényre emlékezünk +hall.empty = Még senki sem esett el. Így maradjon sokáig. +hall.kills = Legyőzött hősök: {0} +ov.fallen = Elesettek ({0}) +ov.living = Élők +cause.slain = megölte: {0} +cause.lava = lávában égett +cause.water = vízbe fulladt +cause.snare = csapdába lépett +cause.lightning = villám sújtotta +cause.fell = elesett diff --git a/lang/it.lang b/lang/it.lang index eadc573..eb6fa70 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} ci ha lasciati. Una lapide commemorativa ne custodisce il nome. tile.memorial = Lapide commemorativa tile.memorial_info = Qui giace {0} ({1}, livello {2}). + +# hall of the fallen +hall.title = Sala dei caduti +hall.count = Creature cadute ricordate: {0} +hall.empty = Nessuno è ancora caduto. Che duri a lungo. +hall.kills = Eroi uccisi: {0} +ov.fallen = Caduti ({0}) +ov.living = Vivi +cause.slain = morte per mano di: {0} +cause.lava = morte nella lava +cause.water = annegamento +cause.snare = morte in una tagliola +cause.lightning = colpo di fulmine +cause.fell = caduta in battaglia diff --git a/lang/nl.lang b/lang/nl.lang index 710f917..e38ab5e 100644 --- a/lang/nl.lang +++ b/lang/nl.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} is gesneuveld. Een gedenksteen bewaart de naam. tile.memorial = Gedenksteen tile.memorial_info = Hier rust {0} ({1}, niveau {2}). + +# hall of the fallen +hall.title = Hal der gevallenen +hall.count = Gevallen wezens in herinnering: {0} +hall.empty = Nog niemand is gevallen. Moge het zo blijven. +hall.kills = Verslagen helden: {0} +ov.fallen = Gevallen ({0}) +ov.living = Levend +cause.slain = verslagen door: {0} +cause.lava = verbrand in lava +cause.water = verdronken +cause.snare = in een klem getrapt +cause.lightning = door de bliksem getroffen +cause.fell = gesneuveld diff --git a/lang/no.lang b/lang/no.lang index ac3ced2..b08b14c 100644 --- a/lang/no.lang +++ b/lang/no.lang @@ -1753,3 +1753,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} har falt. En minnestein bevarer navnet. tile.memorial = Minnestein tile.memorial_info = Her hviler {0} ({1}, nivå {2}). + +# hall of the fallen +hall.title = De falnes hall +hall.count = Falne skapninger i minnet: {0} +hall.empty = Ingen har falt ennå. Lenge må det vare. +hall.kills = Drepte helter: {0} +ov.fallen = Falne ({0}) +ov.living = Levende +cause.slain = drept av: {0} +cause.lava = brent i lava +cause.water = druknet +cause.snare = tråkket i en saks +cause.lightning = truffet av lynet +cause.fell = falt diff --git a/lang/pt.lang b/lang/pt.lang index e3c4658..2338e44 100644 --- a/lang/pt.lang +++ b/lang/pt.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} tombou. Uma pedra memorial guarda o seu nome. tile.memorial = Pedra memorial tile.memorial_info = Aqui jaz {0} ({1}, nível {2}). + +# hall of the fallen +hall.title = Salão dos caídos +hall.count = Criaturas caídas recordadas: {0} +hall.empty = Ainda ninguém caiu. Que assim continue. +hall.kills = Heróis abatidos: {0} +ov.fallen = Caídos ({0}) +ov.living = Vivos +cause.slain = morte às mãos de: {0} +cause.lava = morte na lava +cause.water = afogamento +cause.snare = morte numa armadilha +cause.lightning = morte por raio +cause.fell = queda em combate diff --git a/lang/ro.lang b/lang/ro.lang index bed5b1c..8d26b33 100644 --- a/lang/ro.lang +++ b/lang/ro.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} a căzut. O piatră comemorativă păstrează numele. tile.memorial = Piatră comemorativă tile.memorial_info = Aici se odihnește {0} ({1}, nivelul {2}). + +# hall of the fallen +hall.title = Sala celor căzuți +hall.count = Creaturi căzute amintite: {0} +hall.empty = Încă n-a căzut nimeni. Să rămână așa. +hall.kills = Eroi uciși: {0} +ov.fallen = Căzuți ({0}) +ov.living = Vii +cause.slain = ucigaș: {0} +cause.lava = moarte în lavă +cause.water = înec +cause.snare = moarte într-o capcană +cause.lightning = lovitură de fulger +cause.fell = moarte în luptă diff --git a/lang/ru.lang b/lang/ru.lang index 4f0e0f0..c865253 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Пал твой боец: {0}. Памятный камень хранит имя. tile.memorial = Памятный камень tile.memorial_info = Здесь покоится {0} ({1}, уровень {2}). + +# hall of the fallen +hall.title = Зал павших +hall.count = Павших существ в памяти: {0} +hall.empty = Пока никто не пал. Пусть так и будет. +hall.kills = Убито героев: {0} +ov.fallen = Павшие ({0}) +ov.living = Живые +cause.slain = убийца: {0} +cause.lava = гибель в лаве +cause.water = утопление +cause.snare = гибель в капкане +cause.lightning = удар молнии +cause.fell = гибель в бою diff --git a/lang/sk.lang b/lang/sk.lang index 394c7bc..0d4bf9e 100644 --- a/lang/sk.lang +++ b/lang/sk.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Padol tvoj tvor: {0}. Pamätný kameň uchová meno. tile.memorial = Pamätný kameň tile.memorial_info = Tu odpočíva {0} ({1}, úroveň {2}). + +# hall of the fallen +hall.title = Sieň padlých +hall.count = Padlé tvory v pamäti: {0} +hall.empty = Zatiaľ nikto nepadol. Nech to tak vydrží. +hall.kills = Zabití hrdinovia: {0} +ov.fallen = Padlí ({0}) +ov.living = Živí +cause.slain = vrah: {0} +cause.lava = smrť v láve +cause.water = utopenie +cause.snare = smrť v pasci +cause.lightning = zásah bleskom +cause.fell = smrť v boji diff --git a/lang/sr.lang b/lang/sr.lang index 6730897..d6160b8 100644 --- a/lang/sr.lang +++ b/lang/sr.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = Пало је твоје створење: {0}. Спомен-камен чува име. tile.memorial = Спомен-камен tile.memorial_info = Овде почива {0} ({1}, ниво {2}). + +# hall of the fallen +hall.title = Дворана палих +hall.count = Палих створења у сећању: {0} +hall.empty = Још нико није пао. Нека тако и остане. +hall.kills = Убијених хероја: {0} +ov.fallen = Пали ({0}) +ov.living = Живи +cause.slain = убица: {0} +cause.lava = смрт у лави +cause.water = утапање +cause.snare = смрт у замци +cause.lightning = удар муње +cause.fell = погибија у боју diff --git a/lang/sv.lang b/lang/sv.lang index 1e3ce96..e3a4a08 100644 --- a/lang/sv.lang +++ b/lang/sv.lang @@ -1754,3 +1754,17 @@ name.epithet = {0} {1} msg.creature_fallen = {0} har fallit. En minnessten bevarar namnet. tile.memorial = Minnessten tile.memorial_info = Här vilar {0} ({1}, nivå {2}). + +# hall of the fallen +hall.title = De stupades sal +hall.count = Stupade varelser i minnet: {0} +hall.empty = Ingen har stupat än. Må det länge förbli så. +hall.kills = Dräpta hjältar: {0} +ov.fallen = Stupade ({0}) +ov.living = Levande +cause.slain = dräpt av: {0} +cause.lava = brann i lava +cause.water = drunknade +cause.snare = klev i en fälla +cause.lightning = träffad av blixten +cause.fell = stupade diff --git a/src/achievements.rs b/src/achievements.rs index faf3d6e..4101094 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -63,8 +63,13 @@ pub struct Profile { pub totals: BTreeMap, #[serde(default)] pub unlocked: BTreeMap, // achievement id ("heroes.2", "flawless") -> unix time + #[serde(default)] + pub hall: Vec, // the hall of the fallen over all games (newest last) } +// the hall of the fallen keeps this many +pub const HALL_MAX: usize = 200; + impl Profile { pub fn total(&self, id: &str) -> f64 { self.totals.get(id).copied().unwrap_or(0.0) @@ -80,6 +85,15 @@ impl Profile { let e = self.unlocked.entry(k.clone()).or_insert(*t); *e = (*e).min(*t); } + // the fallen of both, once each, in the order they fell + for f in &other.hall { + if !self.hall.iter().any(|h| h.name == f.name && h.kind == f.kind && h.when == f.when) { + self.hall.push(f.clone()); + } + } + self.hall.sort_by_key(|h| h.when); + let extra = self.hall.len().saturating_sub(HALL_MAX); + self.hall.drain(..extra); } // unlocks what the totals have reached; returns the new ones @@ -117,6 +131,7 @@ pub struct Context { pub struct Tracker { pub profile: Profile, flushed: Vec, + hall_seen: usize, // entries of the game's hall already in the profile last_time: f32, ended: bool, t: f32, @@ -126,12 +141,13 @@ pub struct Tracker { impl Tracker { pub fn new(profile: Profile) -> Tracker { - Tracker { profile, flushed: vec![0.0; k::COUNTERS], last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false } + Tracker { profile, flushed: vec![0.0; k::COUNTERS], hall_seen: 0, last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false } } // a different game started (new, loaded): nothing of it has been counted yet fn restart(&mut self, g: &Game) { self.flushed = (0..k::COUNTERS).map(|i| g.stats.get(i)).collect(); + self.hall_seen = g.hall.len(); self.ended = g.state != 0; } @@ -154,6 +170,17 @@ impl Tracker { let played = self.t; self.t = 0.0; let mut new = Vec::new(); + // the newly fallen go into the profile's hall + if g.hall.len() > self.hall_seen { + for f in &g.hall[self.hall_seen..] { + self.profile.hall.push(crate::names::Fallen { when: now, ..f.clone() }); + } + self.hall_seen = g.hall.len(); + let extra = self.profile.hall.len().saturating_sub(HALL_MAX); + self.profile.hall.drain(..extra); + self.dirty = true; + self.upload = true; + } // the counters of the game for i in 0..k::COUNTERS { let cur = g.stats.get(i); diff --git a/src/game.rs b/src/game.rs index bc3eb6f..a8df9ea 100644 --- a/src/game.rs +++ b/src/game.rs @@ -303,6 +303,10 @@ pub struct Ent { #[serde(default)] pub name: u32, // seed of the creature's name (names.rs), 0: none (heroes, siege engines) #[serde(default)] + pub kills: u32, // heroes this creature has slain (names.rs: the hall of the fallen) + #[serde(default)] + pub bane: u8, // what hurt it last: sprite index + 1 of a unit kind, or names::BANE_* + #[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) @@ -393,6 +397,8 @@ impl Ent { invis: 0.0, plague: 0.0, name: 0, + kills: 0, + bane: 0, ab_cd: 0.0, mood_t: 0.0, guard: false, @@ -652,6 +658,8 @@ pub struct Game { #[serde(default)] pub memorials: Vec, // stones for fallen creatures (names.rs) #[serde(default)] + pub hall: Vec, // the local master's fallen of this game (names.rs) + #[serde(default)] pub trading: crate::traders::Trading, // the visiting trader (traders.rs) #[serde(default)] pub happenings: Option, // random events (free games and versus), see events.rs @@ -1009,6 +1017,7 @@ impl Game { bridgework: Default::default(), weather: Default::default(), memorials: Vec::new(), + hall: Vec::new(), trading: Default::default(), happenings: None, snares: Vec::new(), @@ -2791,6 +2800,11 @@ impl Game { true } + #[cfg(test)] + pub(crate) fn hit_for_test(&mut self, e: &mut Ent, j: usize) { + self.hit(e, j); + } + fn hit(&mut self, e: &mut Ent, j: usize) { // the Warlord talent of the attacker's master let boost = if !e.hero && Owner::MASTER_SIDES.contains(&e.side) { self.talents_of(e.side).damage() } else { 1.0 }; @@ -2910,6 +2924,10 @@ impl Game { let t = &mut self.ents[j]; t.hp -= if t.shield > 0.0 { dmg * 0.5 } else { dmg } * armour; t.flash = 0.15; + t.bane = crate::gfx::sprite_index(e.kind) as u8 + 1; + if t.hp <= 0.0 && t.hero && t.captive == 0 { + e.kills += 1; + } if t.creature() && !matches!(t.job, Job::Attack(_)) { t.job = Job::Attack(my_id); t.path.clear(); @@ -3025,6 +3043,13 @@ impl Game { } self.release_all(id); self.hand.retain(|&h| h != id); + // a named creature lost in lava or water is remembered too + if !hero { + let mut dead = self.ents[j].clone(); + let lava = self.map.get(x as i32, y as i32).map_or(false, |t| t.river() == Some(Kind::Lava)); + dead.bane = if lava { crate::names::BANE_LAVA } else { crate::names::BANE_WATER }; + self.raise_memorial(&dead); + } self.fx(3, x, y, 0.0, 0.0, 1.0, 0xff2060ff); self.fx(2, x, y, 0.0, 0.0, 0.5, 0xff30a0ff); self.sfx(Sfx::Death, x, y); diff --git a/src/main.rs b/src/main.rs index dc068c7..5a05f65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,7 +141,16 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, (Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall, Some(t)) => inp.ldown = Some(t), (Tool::Hand, _) => { let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); - let Some(j) = j else { return }; + let Some(j) = j else { + // a memorial stone: the hall of the fallen opens + let (gx, gy) = (wx.floor() as i32, wy.floor() as i32); + if map::inb(gx, gy) && app.game.memorial_at(map::idx(gx, gy)).is_some() { + ui.show_overview = true; + ui.ov_fallen = true; + ui.ov_scroll = 0; + } + return; + }; let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf()); if dwarf { return; diff --git a/src/menu.rs b/src/menu.rs index 7378a5a..c30b5cb 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -303,6 +303,7 @@ enum Btn { MatchChat, Bestiary, Achievements, + AchHall, // achievements page: switch to the hall of the fallen and back Tutorial, BestTab(usize), BestSel(i32), @@ -454,6 +455,7 @@ pub struct Menu { scroll: usize, rows_visible: usize, pub profile: crate::achievements::Profile, // shown on the achievements page + ach_hall: bool, // the achievements page shows the hall of the fallen buttons: Vec<(Rect, Btn)>, lang_open: Option, // the language dropdown is open below/above this field lang_scroll: usize, // first language shown in the open dropdown @@ -497,6 +499,7 @@ impl Menu { focus: 0, error: None, match_ui: MatchUi::default(), + ach_hall: false, coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), match_size: 128, coop_editor: false, @@ -939,6 +942,11 @@ impl Menu { } MenuAction::Consumed } + Some(Btn::AchHall) => { + self.ach_hall = !self.ach_hall; + self.scroll = 0; + MenuAction::Consumed + } Some(Btn::Achievements) => { self.page = Some(Page::Achievements { from_title }); self.scroll = 0; diff --git a/src/menu/achievements.rs b/src/menu/achievements.rs index d622ac0..2faa51a 100644 --- a/src/menu/achievements.rs +++ b/src/menu/achievements.rs @@ -15,7 +15,13 @@ impl Menu { let rows = 40.min((sh / CH()) as i32 - 2); let x = grid_center(sw, cols as f32 * CW()).max(0.0); let y = grid_center_y(sh, rows as f32 * CH()).max(0.0); - gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.achievements"), FRAME_HI); + let title = if self.ach_hall { tr("hall.title") } else { tr("menu.achievements") }; + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); + if self.ach_hall { + self.draw_hall_page(gfx, x, y, cols, rows); + self.ach_buttons(gfx, x, y, rows); + return; + } let list = all(); let done = list.iter().filter(|a| self.profile.unlocked.contains_key(&a.0)).count(); let head = trf("ach.count", &[done.to_string(), list.len().to_string(), crate::achievements::fmt_count(self.profile.total("minutes"))]); @@ -57,14 +63,75 @@ impl Menu { } } // scrolling and back + let _ = ix; + self.ach_buttons(gfx, x, y, rows); + } + + // scrolling, the switch between achievements and the hall, back + fn ach_buttons(&mut self, gfx: &Gfx, x: f32, y: f32, rows: i32) { + let ix = x + CW() * 3.0; let by = y + (rows - 3) as f32 * CH(); let (mx, my) = mouse(); let bw = 14.0 * CW(); - let items = [(Btn::Scroll(-1), "▲".to_string()), (Btn::Scroll(1), "▼".to_string()), (Btn::Back, tr("menu.back"))]; + let switch = if self.ach_hall { tr("menu.achievements") } else { tr("hall.title") }; + let items = [(Btn::Scroll(-1), "▲".to_string()), (Btn::Scroll(1), "▼".to_string()), (Btn::AchHall, switch), (Btn::Back, tr("menu.back"))]; + let bw = bw.min((sw() - ix - CW() * 4.0) / 4.0 - CW()); for (k, (b, label)) in items.iter().enumerate() { let r = Rect::new(ix + k as f32 * (bw + CW()), by, bw, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } } + + // the hall of the fallen over all games: newest first, two lines each + fn draw_hall_page(&mut self, gfx: &Gfx, x: f32, y: f32, cols: i32, rows: i32) { + let hall = &self.profile.hall; + gfx.text(&trf("hall.count", &[hall.len().to_string()]), x + CW() * 3.0, y + CH() * 1.6, S(), GOLD); + let top = y + CH() * 3.5; + let bottom = y + (rows - 4) as f32 * CH(); + let row_h = CH() * 2.4; + self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1); + self.scroll = self.scroll.min(hall.len().saturating_sub(self.rows_visible)); + let ix = x + CW() * 3.0; + let maxc = (cols - 6) as usize; + if hall.is_empty() { + gfx.text(&tr("hall.empty"), ix, top, S(), DIM); + } + for (k, f) in hall.iter().rev().enumerate().skip(self.scroll).take(self.rows_visible) { + let ry = top + (k - self.scroll) as f32 * row_h; + let name = format!("{} ({})", crate::names::full_name(f.kind, f.name, f.level), trf("info.level", &[tr(crate::game::st(f.kind).name), f.level.to_string()])); + gfx.text(&name.chars().take(maxc).collect::(), ix, ry, S(), FRAME_HI); + let kills = if f.kills > 0 { format!(" · {}", trf("hall.kills", &[f.kills.to_string()])) } else { String::new() }; + let line = format!("{}{} · {}", crate::names::cause(f.bane), kills, date(f.when)); + gfx.text(&line.chars().take(maxc * 2).collect::(), ix + CW() * 2.0, ry + CH(), SMALL(), DIM); + } + } +} + +// a unix time as a date (year-month-day) +fn date(unix: u64) -> String { + if unix == 0 { + return String::new(); + } + // days since 1970-01-01 to a civil date (Howard Hinnant's algorithm) + let z = (unix / 86400) as i64 + 719468; + let era = z.div_euclid(146097); + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + format!("{:04}-{:02}-{:02}", y, m, d) +} + +#[cfg(test)] +mod tests { + #[test] + fn dates_are_civil() { + assert_eq!(super::date(0), ""); + assert_eq!(super::date(1_798_675_200), "2026-12-31"); + assert_eq!(super::date(1_709_164_800), "2024-02-29"); + } } diff --git a/src/names.rs b/src/names.rs index ebbd08c..c0c1fb2 100644 --- a/src/names.rs +++ b/src/names.rs @@ -12,6 +12,40 @@ use serde::{Deserialize, Serialize}; pub const MEMORIAL_LEVEL: u32 = 3; pub const MAX_MEMORIALS: usize = 24; +// what killed a creature besides another unit (Ent::bane) +pub const BANE_SNARE: u8 = 250; +pub const BANE_LIGHTNING: u8 = 251; +pub const BANE_LAVA: u8 = 252; +pub const BANE_WATER: u8 = 253; + +// an entry of the hall of the fallen (in the game and in the player's profile) +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Fallen { + pub kind: EK, + pub name: u32, + pub level: u32, + pub kills: u32, + pub bane: u8, + pub time: f32, // game seconds when it fell + #[serde(default)] + pub when: u64, // unix time (set when it goes into the profile) +} + +// how it fell, in words +pub fn cause(bane: u8) -> String { + match bane { + BANE_SNARE => tr("cause.snare"), + BANE_LIGHTNING => tr("cause.lightning"), + BANE_LAVA => tr("cause.lava"), + BANE_WATER => tr("cause.water"), + 0 => tr("cause.fell"), + k => match crate::gfx::SPRITE_KINDS.get(k as usize - 1) { + Some(&kind) => trf("cause.slain", &[tr(st(kind).name)]), + None => tr("cause.fell"), + }, + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Memorial { pub tile: usize, @@ -103,6 +137,10 @@ impl Game { if e.hero || e.name == 0 || e.level < MEMORIAL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) { return false; } + // the hall of the fallen keeps the local master's (also without room for a stone) + if e.side == self.local_side { + self.hall.push(Fallen { kind: e.kind, name: e.name, level: e.level, kills: e.kills, bane: e.bane, time: self.time, when: 0 }); + } let owner = Owner::of_side(e.side); let (x, y) = (e.x as i32, e.y as i32); let taken: Vec = self.memorials.iter().map(|m| m.tile).collect(); @@ -168,6 +206,50 @@ mod tests { assert_eq!(g.ents.iter().find(|e| e.id == h).unwrap().name, 0); } + #[test] + fn the_hall_remembers_kills_and_how_they_fell() { + let mut g = Game::new(9, 64, 64); + let (cx, cy) = g.map.core; + for y in cy + 4..cy + 8 { + for x in cx..cx + 6 { + let t = &mut g.map.t[idx(x, y)]; + t.kind = Kind::Floor; + t.owner = Owner::Player; + } + } + let mut t = crate::achievements::Tracker::new(Default::default()); + let ctx = crate::achievements::Context { coop: false, editor_test: false }; + g.time = 1.0; + t.update(&g, &ctx, 6.0, 1_800_000_000); + let ogre = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 5); + let knight = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 5.5, 1); + // the ogre slays the knight + let (oj, kj) = (g.ents.iter().position(|e| e.id == ogre).unwrap(), g.ents.iter().position(|e| e.id == knight).unwrap()); + g.ents[kj].hp = 1.0; + let mut o = g.ents[oj].clone(); + g.hit_for_test(&mut o, kj); + assert_eq!(o.kills, 1, "a hero slain"); + g.ents[oj] = o; + // a second knight brings the ogre down + let k2 = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 6.5, 1); + let k2j = g.ents.iter().position(|e| e.id == k2).unwrap(); + let oj = g.ents.iter().position(|e| e.id == ogre).unwrap(); + g.ents[oj].hp = 1.0; + let mut k = g.ents[k2j].clone(); + g.hit_for_test(&mut k, oj); + assert_eq!(g.hall.len(), 1); + let f = &g.hall[0]; + assert_eq!((f.kind, f.kills), (EK::Ogre, 1)); + assert_eq!(f.bane as u32, crate::gfx::sprite_index(EK::Knight) + 1); + assert!(cause(f.bane).contains(&crate::i18n::tr("unit.knight"))); + // into the profile, once + g.time = 100.0; + t.update(&g, &ctx, 6.0, 1_800_000_100); + t.update(&g, &ctx, 6.0, 1_800_000_200); + assert_eq!(t.profile.hall.len(), 1); + assert_eq!(t.profile.hall[0].when, 1_800_000_100); + } + #[test] fn the_fallen_get_a_memorial_stone() { let mut g = Game::new(9, 64, 64); diff --git a/src/rogues.rs b/src/rogues.rs index dda59ee..de5e0ff 100644 --- a/src/rogues.rs +++ b/src/rogues.rs @@ -193,6 +193,7 @@ impl Game { let e = &mut self.ents[j]; e.stun = e.stun.max(SNARE_STUN); e.hp -= SNARE_DAMAGE; + e.bane = crate::names::BANE_SNARE; e.flash = 0.2; let (x, y, side, kind, dead) = (e.x, e.y, e.side, e.kind, e.hp <= 0.0); self.fx(2, x, y, 0.0, 0.0, 0.4, 0xff4040ff); diff --git a/src/ui.rs b/src/ui.rs index 099102f..6cf34e6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -461,6 +461,7 @@ enum TBtn { Learn(u8), // spend a point on a branch OvRow(u32), OvScroll(i32), + OvFallen, // switch the overview between the living and the fallen GoldTo, // the next ally to receive gold Gold(u32), // send that much gold to him } @@ -491,7 +492,8 @@ pub struct Ui { pub show_talents: bool, // the master's talents pub coop_end: Option<(bool, bool, bool)>, // end of an editor co-op mission: (host, a next mission, asked for it) gold_to: usize, // the ally chosen in the creature overview - ov_scroll: usize, + pub ov_scroll: usize, + pub ov_fallen: bool, // the overview shows the hall of the fallen ov_rect: Option, // where the creature overview is (mouse wheel, swipe) pub show_langsel: bool, pub log: Vec<(Msg, f64)>, @@ -603,6 +605,7 @@ impl Ui { coop_end: None, gold_to: 0, ov_scroll: 0, + ov_fallen: false, ov_rect: None, show_langsel: false, log: Vec::new(), @@ -702,6 +705,11 @@ impl Ui { UiAction::Consumed } TBtn::Learn(b) => UiAction::LearnTalent(b), + TBtn::OvFallen => { + self.ov_fallen = !self.ov_fallen; + self.ov_scroll = 0; + UiAction::Consumed + } TBtn::OvScroll(d) => { self.ov_scroll = (self.ov_scroll as i32 + d).max(0) as usize; UiAction::Consumed @@ -1343,6 +1351,36 @@ impl Ui { } } + // the hall of the fallen of this game: name, kind, level, heroes slain, how it fell + fn draw_hall(&mut self, g: &Game, gfx: &Gfx, x: f32, y: f32, cols: i32, rows: i32) { + gfx.text(&tr("hall.title"), x + CW() * 2.0, y + CH(), S(), GOLD); + let mut lines: Vec<(String, Color)> = Vec::new(); + for f in g.hall.iter().rev() { + lines.push((format!("{:>2} {}", f.level, crate::names::full_name(f.kind, f.name, f.level)), FRAME_HI)); + let kills = if f.kills > 0 { format!(" · {}", trf("hall.kills", &[f.kills.to_string()])) } else { String::new() }; + lines.push((format!(" {} · {}{} · {}", tr(st(f.kind).name), crate::names::cause(f.bane), kills, mmss(f.time)), DIM)); + } + if lines.is_empty() { + lines.push((tr("hall.empty"), DIM)); + } + let first = y + CH() * 2.6; + let visible = (rows - 4).max(1) as usize; + self.ov_scroll = self.ov_scroll.min(lines.len().saturating_sub(visible)); + let maxc = (cols - 4) as usize; + for (n, (text, col)) in lines.iter().enumerate().skip(self.ov_scroll).take(visible) { + let t: String = text.chars().take(maxc).collect(); + gfx.text(&t, x + CW() * 2.0, first + (n - self.ov_scroll) as f32 * CH(), S(), *col); + } + if lines.len() > visible { + let up = Rect::new(x + (cols - 4) as f32 * CW(), y + CH() * 2.4, CW() * 2.0, CH() * 1.5); + let down = Rect::new(x + (cols - 4) as f32 * CW(), y + (rows - 2) as f32 * CH(), CW() * 2.0, CH() * 1.5); + gfx.text("\u{25b2}", up.x + CW() * 0.5, up.y, S(), FRAME_HI); + gfx.text("\u{25bc}", down.x + CW() * 0.5, down.y, S(), FRAME_HI); + self.touch_btns.push((up, TBtn::OvScroll(-2))); + self.touch_btns.push((down, TBtn::OvScroll(2))); + } + } + fn draw_overview(&mut self, g: &Game, gfx: &Gfx, bottom: f32) { let (sw, _) = (sw(), sh()); let (mx, my) = mouse(); @@ -1357,6 +1395,21 @@ impl Ui { let mine: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); let wage: f32 = mine.iter().map(|e| st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)).sum(); let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::() / mine.len() as f32 }; + // the switch to the hall of the fallen (top right) + { + let label = if self.ov_fallen { tr("ov.living") } else { trf("ov.fallen", &[g.hall.len().to_string()]) }; + let bw = gfx.measure(&label, SMALL()) + CW() * 2.0; + let r = Rect::new(x + (cols - 3) as f32 * CW() - bw, y + CH() * 0.8, bw, CH() * 1.4); + let hov = r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, FRAME_HI); + gfx.text(&label, r.x + CW(), r.y + (r.h - GH * SMALL()) * 0.5, SMALL(), FRAME_HI); + self.touch_btns.push((r, TBtn::OvFallen)); + } + if self.ov_fallen { + self.draw_hall(g, gfx, x, y, cols, rows); + return; + } let head = trf("ov.summary", &[mine.len().to_string(), (wage as i32).to_string(), ((mood * 100.0) as i32).to_string()]); gfx.text(&head, x + CW() * 2.0, y + CH(), S(), GOLD); // a match with allies: a row to send them gold (the name switches between the allies) diff --git a/src/weather.rs b/src/weather.rs index 8aaef26..e91c128 100644 --- a/src/weather.rs +++ b/src/weather.rs @@ -145,6 +145,7 @@ impl Game { let e = &mut self.ents[j]; e.hp -= BOLT_DAMAGE; e.flash = 0.3; + e.bane = crate::names::BANE_LIGHTNING; let (x, y, side, kind, hero, dead) = (e.x, e.y, e.side, e.kind, e.hero, e.hp <= 0.0); if !hero && side == self.local_side { self.tell_local("msg.lightning_unit", vec![format!("@{}", st(kind).name)], None);