Compare commits

...
3 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 24ba7fb5e3 Bump version to v202611-00073
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:48:07 +01:00
Richard ZinkandClaude Opus 5.5 beb53a2e0c Serve the wasm and scripts gzip-compressed (packed at build time)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:48:07 +01:00
Richard ZinkandClaude Opus 5.5 c52fc4c4ae 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 <noreply@anthropic.com>
2026-11-06 13:48:06 +01:00
31 changed files with 544 additions and 6 deletions

No files matched your search

+2
View File
@@ -31,4 +31,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY www/ /usr/share/nginx/html/
COPY --from=build /out/ /usr/share/nginx/html/
COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/
# packed once for gzip_static (nginx.conf)
RUN cd /usr/share/nginx/html && gzip -k -9 dungeoncraft.wasm mq_js_bundle.js standalone/DungeonCraft.html
EXPOSE 80
+1 -1
View File
@@ -1 +1 @@
v202611-00072
v202611-00073
+4
View File
@@ -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).
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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 = θάνατος στη μάχη
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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ă
+14
View File
@@ -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 = гибель в бою
+14
View File
@@ -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
+14
View File
@@ -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 = погибија у боју
+14
View File
@@ -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
+7
View File
@@ -4,6 +4,13 @@ server {
index index.html;
# test build: always reload fresh files
add_header Cache-Control "no-cache";
# compressed downloads: the big files are packed once at build time (*.gz, see Dockerfile),
# the rest on the fly (the wasm goes from about 8.8 MB to 2.6 MB)
gzip_static on;
gzip on;
gzip_types application/wasm application/javascript text/css application/json application/manifest+json image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
location / {
try_files $uri $uri/ =404;
}
+28 -1
View File
@@ -63,8 +63,13 @@ pub struct Profile {
pub totals: BTreeMap<String, f64>,
#[serde(default)]
pub unlocked: BTreeMap<String, u64>, // achievement id ("heroes.2", "flawless") -> unix time
#[serde(default)]
pub hall: Vec<crate::names::Fallen>, // 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<f32>,
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);
+25
View File
@@ -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<crate::names::Memorial>, // stones for fallen creatures (names.rs)
#[serde(default)]
pub hall: Vec<crate::names::Fallen>, // 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<crate::events::Events>, // 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);
+10 -1
View File
@@ -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;
+8
View File
@@ -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<Rect>, // 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;
+69 -2
View File
@@ -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::<String>(), 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::<String>(), 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");
}
}
+82
View File
@@ -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<usize> = 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);
+1
View File
@@ -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);
+54 -1
View File
@@ -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<Rect>, // 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::<f32>() / 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)
+1
View File
@@ -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);