diff --git a/README.md b/README.md
index a937f5d..e133012 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,10 @@ of your forge to break a fortress in the forest.
Left: the world up there. Too bright. Right: a drawbridge up, a drawbridge down, and lava that thinks it is a floor.
+**🏆 Champions and trophies.** Every fifth wave a champion of the Order leads the knights - Sir Aldric, Dame
+Seraphine, Brother Halvard or Captain Rothgar, each with a trick of his own and loot for whoever brings him down.
+After every game a chart shows how it went, and 64 achievements wait - some of them secret, all of them petty.
+
**👑 Other masters want your realm, too.** Mossgrim the Mouldy (green, smells like a wet cellar) and Lady Nightwell
(blue, writes threatening letters on scented paper) dig their own dungeons in free play and in the campaign.
And the humans attack *everybody*. Let them bite each other first.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 68d3fd7..f3fc254 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -67,6 +67,14 @@ camera's surroundings lie above ground (4 s crossfade).
## 4. Additions
+Status: creature abilities (personality.rs: an ability per creature at level 3-7, used by itself) and
+creature relationships (likes, hates, brawls) existed already. Done in this block so far:
+bosses (`src/bosses.rs`: four champions of the Order lead every fifth wave - Sir Aldric's shield wall,
+Dame Seraphine's radiant burst, Brother Halvard's call to arms, Captain Rothgar's charge - with a health bar
+and loot), statistics (`src/stats.rs`: counters and a chart at the end of a game) and achievements
+(`src/achievements.rs`: 64 of them, kept in the browser and merged with `/api/profile` on the server after a
+login; the menu page *Achievements*). Room levels 2 and 3 will be researched in the library (per room kind).
+
1. **Random events** – earthquake (tunnels cave in), lava eruption, flood, gold vein found. Rare
(every 8–12 minutes), free play and multiplayer only, can be switched off in the menu; in
campaigns only through new script blocks.
diff --git a/docs/screenshots/achievements.png b/docs/screenshots/achievements.png
new file mode 100644
index 0000000..59ad2ca
Binary files /dev/null and b/docs/screenshots/achievements.png differ
diff --git a/lang/cs.lang b/lang/cs.lang
index 4a3a53a..d4099f1 100644
--- a/lang/cs.lang
+++ b/lang/cs.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Porazte Mossgrima a všechny jeho tvory.
coop.m20.boss = Mossgrim už není. Mech zase doroste. On ne.
coop.m20.t1 = Ten drak na východě je Mossgrim. Ano, nechal se trochu jít.
coop.m20.t2 = Každý poražený tvor je o jednoho méně, kdo bude tenhle příběh vyprávět špatně.
+
+# achievements and statistics
+menu.achievements = Úspěchy
+ach.count = Odemčeno {0} z {1} úspěchů · {2} minut hraní
+ach.hidden = Tajemství. Křivoprst nic neprozradí.
+msg.achievement = Úspěch odemčen: {0}!
+end.stats2 = Hrdinové {0} · Ztracení tvorové {1} · Vykopaná pole {2} · Zlato {3} · Dřevo {4}
+ach.heroes.name = Postrach hrdinů {0}
+ach.heroes.desc = Poraz {0} hrdinů.
+ach.bosses.name = Lamač šampionů {0}
+ach.bosses.desc = Svrhni {0} šampionů řádu.
+ach.dug.name = Hlubinný kopáč {0}
+ach.dug.desc = Vykopej {0} polí.
+ach.gold.name = Hromaditel {0}
+ach.gold.desc = Vydělej {0} zlata.
+ach.wood.name = Pán dřeva {0}
+ach.wood.desc = Pokácej {0} dřeva.
+ach.walls.name = Zedník {0}
+ach.walls.desc = Postav {0} zdí.
+ach.rooms.name = Architekt stínů {0}
+ach.rooms.desc = Postav {0} polí místností.
+ach.spells.name = Temný mág {0}
+ach.spells.desc = Sešli {0} kouzel.
+ach.slaps.name = Pevná ruka {0}
+ach.slaps.desc = Dej svým tvorům {0} pohlavků. Milují to. Asi.
+ach.traps.name = Pastičkář {0}
+ach.traps.desc = Objednej {0} pastí a dveří v kovárně.
+ach.towers.name = Pán věží {0}
+ach.towers.desc = Postav {0} věží.
+ach.drowned.name = Kurz plavání {0}
+ach.drowned.desc = Nech {0} hrdinů spadnout do vody nebo lávy.
+ach.burnt.name = Žhář mostů {0}
+ach.burnt.desc = Spal {0} mostních prken.
+ach.siege.name = Mistr obléhání {0}
+ach.siege.desc = Postav {0} obléhacích strojů.
+ach.captured.name = Žalářník {0}
+ach.captured.desc = Zajmi {0} hrdinů.
+ach.drawbridge.name = Nahoru s ním {0}
+ach.drawbridge.desc = Vytáhni padací mosty {0}krát.
+ach.wins.name = Dobyvatel {0}
+ach.wins.desc = Vyhraj {0} volných her.
+ach.missions.name = Hledač střepů {0}
+ach.missions.desc = Vyhraj {0} misí kampaně.
+ach.coop.name = Člen ligy {0}
+ach.coop.desc = Vyhraj {0} kooperativních misí.
+ach.minutes.name = Noční sova {0}
+ach.minutes.desc = Hraj {0} minut.
+ach.flawless.name = Ani škrábnutí
+ach.flawless.desc = Vyhraj, aniž bys ztratil jediného tvora.
+ach.speedrun.name = Než čaj vystydne
+ach.speedrun.desc = Vyhraj do patnácti minut.
+ach.campaign.name = Jediný mistr
+ach.campaign.desc = Vyhraj poslední misi kampaně.
+ach.surface.name = Spálení od slunce
+ach.surface.desc = Vyhraj hru, která sahá až na povrch.
+ach.defeat.name = Čaj a soucit
+ach.defeat.desc = Prohraj hru. Křivoprst přistaví vodu na čaj.
+ach.legend.name = Živoucí legenda
+ach.legend.desc = Tvor dosáhne úrovně deset.
+ach.wyrm.name = Zde jsou draci
+ach.wyrm.desc = Žhavý drak se přidá k tvé kobce.
+ach.fullhand.name = Plné ruce práce
+ach.fullhand.desc = Drž v ruce najednou osm jednotek.
diff --git a/lang/da.lang b/lang/da.lang
index 6d44b1c..dc102e4 100644
--- a/lang/da.lang
+++ b/lang/da.lang
@@ -1551,3 +1551,66 @@ coop.m20.goal = Besejr Mossgrim og alle hans væsner.
coop.m20.boss = Mossgrim er ikke mere. Mosset gror ud igen. Det gør han ikke.
coop.m20.t1 = Den lindorm i øst er Mossgrim. Ja, han har sluppet sig selv lidt løs.
coop.m20.t2 = Hvert væsen, I besejrer, er ét mindre, der vil fortælle denne historie forkert.
+
+# achievements and statistics
+menu.achievements = Præstationer
+ach.count = {0} af {1} præstationer låst op · {2} minutter spillet
+ach.hidden = En hemmelighed. Krumfinger siger ingenting.
+msg.achievement = Præstation låst op: {0}!
+end.stats2 = Helte {0} · Væsner tabt {1} · Felter gravet {2} · Guld {3} · Træ {4}
+ach.heroes.name = Heltenes Svøbe {0}
+ach.heroes.desc = Besejr {0} helte.
+ach.bosses.name = Kæmpeknuser {0}
+ach.bosses.desc = Fæld {0} af ordenens kæmper.
+ach.dug.name = Dybgraver {0}
+ach.dug.desc = Grav {0} felter ud.
+ach.gold.name = Skattesamler {0}
+ach.gold.desc = Tjen {0} guld.
+ach.wood.name = Tømmerfyrste {0}
+ach.wood.desc = Fæld {0} træ.
+ach.walls.name = Murer {0}
+ach.walls.desc = Rejs {0} mure.
+ach.rooms.name = Skyggernes Arkitekt {0}
+ach.rooms.desc = Byg {0} rumfelter.
+ach.spells.name = Mørk Troldmand {0}
+ach.spells.desc = Kast {0} besværgelser.
+ach.slaps.name = Fast Hånd {0}
+ach.slaps.desc = Giv dine væsner en lussing {0} gange. De elsker det. Formentlig.
+ach.traps.name = Fældesmed {0}
+ach.traps.desc = Bestil {0} fælder og døre i smedjen.
+ach.towers.name = Tårnherre {0}
+ach.towers.desc = Byg {0} tårne.
+ach.drowned.name = Svømmeundervisning {0}
+ach.drowned.desc = Lad {0} helte falde i vand eller lava.
+ach.burnt.name = Brobrænder {0}
+ach.burnt.desc = Brænd {0} broplanker ned.
+ach.siege.name = Belejringsmester {0}
+ach.siege.desc = Byg {0} belejringsmaskiner.
+ach.captured.name = Fangevogter {0}
+ach.captured.desc = Tag {0} helte til fange.
+ach.drawbridge.name = Op med Den {0}
+ach.drawbridge.desc = Hejs vindebroer {0} gange.
+ach.wins.name = Erobrer {0}
+ach.wins.desc = Vind {0} frie spil.
+ach.missions.name = Splintjæger {0}
+ach.missions.desc = Vind {0} kampagnemissioner.
+ach.coop.name = Forbundsfælle {0}
+ach.coop.desc = Vind {0} co-op-missioner.
+ach.minutes.name = Natteravn {0}
+ach.minutes.desc = Spil i {0} minutter.
+ach.flawless.name = Ikke en Skramme
+ach.flawless.desc = Vind uden at miste et eneste væsen.
+ach.speedrun.name = Før Teen Bliver Kold
+ach.speedrun.desc = Vind inden for femten minutter.
+ach.campaign.name = Den Ene Mester
+ach.campaign.desc = Vind kampagnens sidste mission.
+ach.surface.name = Solskoldning
+ach.surface.desc = Vind et spil, der når op til overfladen.
+ach.defeat.name = Te og Medfølelse
+ach.defeat.desc = Tab et spil. Krumfinger sætter kedlen over.
+ach.legend.name = Levende Legende
+ach.legend.desc = Et væsen når niveau ti.
+ach.wyrm.name = Her Er Drager
+ach.wyrm.desc = En glødelindorm slutter sig til dit dungeon.
+ach.fullhand.name = Hænderne Fulde
+ach.fullhand.desc = Hold otte enheder i hånden på én gang.
diff --git a/lang/de.lang b/lang/de.lang
index 8aac4b9..3d2af8f 100644
--- a/lang/de.lang
+++ b/lang/de.lang
@@ -1551,3 +1551,66 @@ coop.m20.goal = Besiegt Mossgrim und alle seine Kreaturen.
coop.m20.boss = Mossgrim ist nicht mehr. Das Moos wächst nach. Er nicht.
coop.m20.t1 = Der Wurm im Osten ist Mossgrim. Ja, er hat sich gehen lassen.
coop.m20.t2 = Jede Kreatur, die ihr besiegt, kann diese Geschichte nicht mehr falsch erzählen.
+
+# achievements and statistics
+menu.achievements = Erfolge
+ach.count = {0} von {1} Erfolgen freigeschaltet · {2} Minuten gespielt
+ach.hidden = Ein Geheimnis. Crookfinger verrät nichts.
+msg.achievement = Erfolg freigeschaltet: {0}!
+end.stats2 = Helden {0} · Kreaturen verloren {1} · Kacheln gegraben {2} · Gold {3} · Holz {4}
+ach.heroes.name = Heldenschreck {0}
+ach.heroes.desc = Besiege {0} Helden.
+ach.bosses.name = Championbrecher {0}
+ach.bosses.desc = Bring {0} Champions des Ordens zu Fall.
+ach.dug.name = Tiefgräber {0}
+ach.dug.desc = Grabe {0} Kacheln aus.
+ach.gold.name = Hortmeister {0}
+ach.gold.desc = Verdiene {0} Gold.
+ach.wood.name = Holzfürst {0}
+ach.wood.desc = Fälle {0} Holz.
+ach.walls.name = Maurer {0}
+ach.walls.desc = Richte {0} Wände auf.
+ach.rooms.name = Architekt der Schatten {0}
+ach.rooms.desc = Baue {0} Raumkacheln.
+ach.spells.name = Dunkler Zauberer {0}
+ach.spells.desc = Wirke {0} Zauber.
+ach.slaps.name = Strenge Hand {0}
+ach.slaps.desc = Gib deinen Kreaturen {0} Klapse. Sie lieben es. Vermutlich.
+ach.traps.name = Fallenschmied {0}
+ach.traps.desc = Gib {0} Fallen und Türen in der Schmiede in Auftrag.
+ach.towers.name = Turmherr {0}
+ach.towers.desc = Baue {0} Türme.
+ach.drowned.name = Schwimmkurs {0}
+ach.drowned.desc = Lass {0} Helden in Wasser oder Lava stürzen.
+ach.burnt.name = Brückenbrenner {0}
+ach.burnt.desc = Brenne {0} Brückenplanken nieder.
+ach.siege.name = Belagerungsmeister {0}
+ach.siege.desc = Baue {0} Belagerungsgeräte.
+ach.captured.name = Kerkermeister {0}
+ach.captured.desc = Nimm {0} Helden gefangen.
+ach.drawbridge.name = Hoch damit {0}
+ach.drawbridge.desc = Ziehe {0}-mal Zugbrücken hoch.
+ach.wins.name = Eroberer {0}
+ach.wins.desc = Gewinne {0} freie Spiele.
+ach.missions.name = Splittersucher {0}
+ach.missions.desc = Gewinne {0} Kampagnenmissionen.
+ach.coop.name = Bundesmitglied {0}
+ach.coop.desc = Gewinne {0} Koop-Missionen.
+ach.minutes.name = Nachteule {0}
+ach.minutes.desc = Spiele {0} Minuten lang.
+ach.flawless.name = Kein Kratzer
+ach.flawless.desc = Gewinne, ohne eine einzige Kreatur zu verlieren.
+ach.speedrun.name = Bevor der Tee kalt wird
+ach.speedrun.desc = Gewinne innerhalb von fünfzehn Minuten.
+ach.campaign.name = Der eine Meister
+ach.campaign.desc = Gewinne die letzte Mission der Kampagne.
+ach.surface.name = Sonnenbrand
+ach.surface.desc = Gewinne ein Spiel, das bis an die Oberfläche reicht.
+ach.defeat.name = Tee und Mitgefühl
+ach.defeat.desc = Verliere ein Spiel. Crookfinger setzt Wasser auf.
+ach.legend.name = Lebende Legende
+ach.legend.desc = Eine Kreatur erreicht Stufe zehn.
+ach.wyrm.name = Hier seien Drachen
+ach.wyrm.desc = Ein Glutwurm schließt sich deinem Kerker an.
+ach.fullhand.name = Alle Hände voll
+ach.fullhand.desc = Halte acht Einheiten gleichzeitig in der Hand.
diff --git a/lang/el.lang b/lang/el.lang
index c31960e..8abb891 100644
--- a/lang/el.lang
+++ b/lang/el.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Νικήστε τον Μόσγκριμ και όλα του τα
coop.m20.boss = Ο Μόσγκριμ δεν υπάρχει πια. Τα βρύα θα ξαναφυτρώσουν. Αυτός όχι.
coop.m20.t1 = Εκείνος ο δράκος στα ανατολικά είναι ο Μόσγκριμ. Ναι, παραμελήθηκε.
coop.m20.t2 = Κάθε πλάσμα που νικάτε είναι ένα λιγότερο που θα πει αυτή την ιστορία λάθος.
+
+# achievements and statistics
+menu.achievements = Επιτεύγματα
+ach.count = {0} από {1} επιτεύγματα ξεκλειδώθηκαν · {2} λεπτά παιχνιδιού
+ach.hidden = Μυστικό. Ο Στραβοδάχτυλος δεν λέει τίποτα.
+msg.achievement = Επίτευγμα ξεκλειδώθηκε: {0}!
+end.stats2 = Ήρωες {0} · Χαμένα πλάσματα {1} · Σκαμμένα πλακίδια {2} · Χρυσός {3} · Ξύλο {4}
+ach.heroes.name = Μάστιγα ηρώων {0}
+ach.heroes.desc = Νίκησε {0} ήρωες.
+ach.bosses.name = Συντρίπτης πρωταθλητών {0}
+ach.bosses.desc = Ρίξε {0} πρωταθλητές του Τάγματος.
+ach.dug.name = Βαθύς σκαφτιάς {0}
+ach.dug.desc = Σκάψε {0} πλακίδια.
+ach.gold.name = Θησαυριστής {0}
+ach.gold.desc = Κέρδισε {0} χρυσό.
+ach.wood.name = Άρχοντας του ξύλου {0}
+ach.wood.desc = Κόψε {0} ξύλο.
+ach.walls.name = Χτίστης {0}
+ach.walls.desc = Ύψωσε {0} τοίχους.
+ach.rooms.name = Αρχιτέκτονας των σκιών {0}
+ach.rooms.desc = Χτίσε {0} πλακίδια δωματίων.
+ach.spells.name = Σκοτεινός μάγος {0}
+ach.spells.desc = Ρίξε {0} ξόρκια.
+ach.slaps.name = Σταθερό χέρι {0}
+ach.slaps.desc = Χαστούκισε τα πλάσματά σου {0} φορές. Τους αρέσει. Μάλλον.
+ach.traps.name = Παγιδοποιός {0}
+ach.traps.desc = Παράγγειλε {0} παγίδες και πόρτες στο σιδηρουργείο.
+ach.towers.name = Άρχοντας των πύργων {0}
+ach.towers.desc = Χτίσε {0} πύργους.
+ach.drowned.name = Μαθήματα κολύμβησης {0}
+ach.drowned.desc = Ρίξε {0} ήρωες σε νερό ή λάβα.
+ach.burnt.name = Καψογέφυρος {0}
+ach.burnt.desc = Κάψε {0} σανίδες γεφυρών.
+ach.siege.name = Πολιορκητής {0}
+ach.siege.desc = Χτίσε {0} πολιορκητικές μηχανές.
+ach.captured.name = Δεσμοφύλακας {0}
+ach.captured.desc = Αιχμαλώτισε {0} ήρωες.
+ach.drawbridge.name = Και πάνω! {0}
+ach.drawbridge.desc = Σήκωσε κινητές γέφυρες {0} φορές.
+ach.wins.name = Κατακτητής {0}
+ach.wins.desc = Κέρδισε {0} ελεύθερα παιχνίδια.
+ach.missions.name = Κυνηγός θραυσμάτων {0}
+ach.missions.desc = Κέρδισε {0} αποστολές εκστρατείας.
+ach.coop.name = Μέλος της λίγκας {0}
+ach.coop.desc = Κέρδισε {0} συνεργατικές αποστολές.
+ach.minutes.name = Νυχτοπούλι {0}
+ach.minutes.desc = Παίξε για {0} λεπτά.
+ach.flawless.name = Ούτε γρατζουνιά
+ach.flawless.desc = Νίκησε χωρίς να χάσεις ούτε ένα πλάσμα.
+ach.speedrun.name = Πριν κρυώσει το τσάι
+ach.speedrun.desc = Νίκησε μέσα σε δεκαπέντε λεπτά.
+ach.campaign.name = Ο Ένας Άρχοντας
+ach.campaign.desc = Κέρδισε την τελευταία αποστολή της εκστρατείας.
+ach.surface.name = Ηλιοκαμένος
+ach.surface.desc = Κέρδισε ένα παιχνίδι που φτάνει ως την επιφάνεια.
+ach.defeat.name = Τσάι και συμπόνια
+ach.defeat.desc = Χάσε ένα παιχνίδι. Ο Στραβοδάχτυλος βάζει νερό να βράσει.
+ach.legend.name = Ζωντανός θρύλος
+ach.legend.desc = Ένα πλάσμα φτάνει στο επίπεδο δέκα.
+ach.wyrm.name = Εδώ υπάρχουν δράκοι
+ach.wyrm.desc = Ένας δράκος της θράκας μπαίνει στο μπουντρούμι σου.
+ach.fullhand.name = Γεμάτα χέρια
+ach.fullhand.desc = Κράτα οκτώ μονάδες στο χέρι σου ταυτόχρονα.
diff --git a/lang/en.lang b/lang/en.lang
index 39417ce..fd62083 100644
--- a/lang/en.lang
+++ b/lang/en.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
+
+# achievements and statistics
+menu.achievements = Achievements
+ach.count = {0} of {1} achievements unlocked · {2} minutes played
+ach.hidden = A secret. Crookfinger is not telling.
+msg.achievement = Achievement unlocked: {0}!
+end.stats2 = Heroes {0} · Creatures lost {1} · Tiles dug {2} · Gold {3} · Wood {4}
+ach.heroes.name = Hero Bane {0}
+ach.heroes.desc = Defeat {0} heroes.
+ach.bosses.name = Champion Breaker {0}
+ach.bosses.desc = Bring down {0} champions of the Order.
+ach.dug.name = Deep Digger {0}
+ach.dug.desc = Dig out {0} tiles.
+ach.gold.name = Hoarder {0}
+ach.gold.desc = Earn {0} gold.
+ach.wood.name = Lumberlord {0}
+ach.wood.desc = Fell {0} wood.
+ach.walls.name = Mason {0}
+ach.walls.desc = Raise {0} walls.
+ach.rooms.name = Architect of Shadows {0}
+ach.rooms.desc = Build {0} room tiles.
+ach.spells.name = Dark Caster {0}
+ach.spells.desc = Cast {0} spells.
+ach.slaps.name = Firm Hand {0}
+ach.slaps.desc = Slap your creatures {0} times. They love it. Probably.
+ach.traps.name = Trapsmith {0}
+ach.traps.desc = Order {0} traps and doors in the forge.
+ach.towers.name = Tower Lord {0}
+ach.towers.desc = Build {0} towers.
+ach.drowned.name = Swimming Lessons {0}
+ach.drowned.desc = Let {0} heroes fall into water or lava.
+ach.burnt.name = Bridge Burner {0}
+ach.burnt.desc = Burn down {0} bridge planks.
+ach.siege.name = Siege Master {0}
+ach.siege.desc = Build {0} siege engines.
+ach.captured.name = Warden {0}
+ach.captured.desc = Capture {0} heroes.
+ach.drawbridge.name = Up She Goes {0}
+ach.drawbridge.desc = Raise drawbridges {0} times.
+ach.wins.name = Conqueror {0}
+ach.wins.desc = Win {0} free games.
+ach.missions.name = Shard Seeker {0}
+ach.missions.desc = Win {0} campaign missions.
+ach.coop.name = League Member {0}
+ach.coop.desc = Win {0} co-op missions.
+ach.minutes.name = Night Owl {0}
+ach.minutes.desc = Play for {0} minutes.
+ach.flawless.name = Not a Scratch
+ach.flawless.desc = Win without losing a single creature.
+ach.speedrun.name = Before the Tea Gets Cold
+ach.speedrun.desc = Win within fifteen minutes.
+ach.campaign.name = The One Master
+ach.campaign.desc = Win the last mission of the campaign.
+ach.surface.name = Sunburn
+ach.surface.desc = Win a game that reaches the surface.
+ach.defeat.name = Tea and Sympathy
+ach.defeat.desc = Lose a game. Crookfinger puts the kettle on.
+ach.legend.name = Living Legend
+ach.legend.desc = A creature reaches level ten.
+ach.wyrm.name = Here Be Dragons
+ach.wyrm.desc = An ember wyrm joins your dungeon.
+ach.fullhand.name = Hands Full
+ach.fullhand.desc = Hold eight units in your hand at once.
diff --git a/lang/es.lang b/lang/es.lang
index 2009ce4..748ff15 100644
--- a/lang/es.lang
+++ b/lang/es.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Derrotad a Mossgrim y a todas sus criaturas.
coop.m20.boss = Mossgrim ya no existe. El musgo volverá a crecer. Él no.
coop.m20.t1 = Ese dragón del este es Mossgrim. Sí, se ha descuidado bastante.
coop.m20.t2 = Cada criatura que derrotáis es una menos que contará mal esta historia.
+
+# achievements and statistics
+menu.achievements = Logros
+ach.count = {0} de {1} logros desbloqueados · {2} minutos jugados
+ach.hidden = Un secreto. Dedotuerto no suelta prenda.
+msg.achievement = Logro desbloqueado: ¡{0}!
+end.stats2 = Héroes {0} · Criaturas perdidas {1} · Casillas excavadas {2} · Oro {3} · Madera {4}
+ach.heroes.name = Azote de héroes {0}
+ach.heroes.desc = Derrota a {0} héroes.
+ach.bosses.name = Rompecampeones {0}
+ach.bosses.desc = Derriba a {0} campeones de la Orden.
+ach.dug.name = Excavador profundo {0}
+ach.dug.desc = Excava {0} casillas.
+ach.gold.name = Acaparador {0}
+ach.gold.desc = Gana {0} de oro.
+ach.wood.name = Señor de la madera {0}
+ach.wood.desc = Tala {0} de madera.
+ach.walls.name = Albañil {0}
+ach.walls.desc = Levanta {0} muros.
+ach.rooms.name = Arquitecto de las sombras {0}
+ach.rooms.desc = Construye {0} casillas de sala.
+ach.spells.name = Hechicero oscuro {0}
+ach.spells.desc = Lanza {0} hechizos.
+ach.slaps.name = Mano firme {0}
+ach.slaps.desc = Abofetea a tus criaturas {0} veces. Les encanta. Seguramente.
+ach.traps.name = Forjatrampas {0}
+ach.traps.desc = Encarga {0} trampas y puertas en la forja.
+ach.towers.name = Señor de las torres {0}
+ach.towers.desc = Construye {0} torres.
+ach.drowned.name = Clases de natación {0}
+ach.drowned.desc = Haz que {0} héroes caigan al agua o a la lava.
+ach.burnt.name = Quemapuentes {0}
+ach.burnt.desc = Quema {0} tablones de puente.
+ach.siege.name = Maestro de asedio {0}
+ach.siege.desc = Construye {0} máquinas de asedio.
+ach.captured.name = Carcelero {0}
+ach.captured.desc = Captura a {0} héroes.
+ach.drawbridge.name = ¡Arriba con él! {0}
+ach.drawbridge.desc = Alza puentes levadizos {0} veces.
+ach.wins.name = Conquistador {0}
+ach.wins.desc = Gana {0} partidas libres.
+ach.missions.name = Buscafragmentos {0}
+ach.missions.desc = Gana {0} misiones de campaña.
+ach.coop.name = Miembro de la liga {0}
+ach.coop.desc = Gana {0} misiones cooperativas.
+ach.minutes.name = Ave nocturna {0}
+ach.minutes.desc = Juega durante {0} minutos.
+ach.flawless.name = Ni un rasguño
+ach.flawless.desc = Gana sin perder una sola criatura.
+ach.speedrun.name = Antes de que se enfríe el té
+ach.speedrun.desc = Gana en menos de quince minutos.
+ach.campaign.name = El Único Amo
+ach.campaign.desc = Gana la última misión de la campaña.
+ach.surface.name = Quemadura solar
+ach.surface.desc = Gana una partida que llegue hasta la superficie.
+ach.defeat.name = Té y compasión
+ach.defeat.desc = Pierde una partida. Dedotuerto pone el agua a hervir.
+ach.legend.name = Leyenda viviente
+ach.legend.desc = Una criatura alcanza el nivel diez.
+ach.wyrm.name = Aquí hay dragones
+ach.wyrm.desc = Un dragón de brasas se une a tu mazmorra.
+ach.fullhand.name = Manos llenas
+ach.fullhand.desc = Sostén ocho unidades en la mano a la vez.
diff --git a/lang/fi.lang b/lang/fi.lang
index 8013cb8..b2d8914 100644
--- a/lang/fi.lang
+++ b/lang/fi.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Kukistakaa Mossgrim ja kaikki hänen olentonsa.
coop.m20.boss = Mossgrimia ei enää ole. Sammal kasvaa takaisin. Hän ei.
coop.m20.t1 = Tuo lohikäärme idässä on Mossgrim. Niin, hän päästi itsensä rapistumaan.
coop.m20.t2 = Jokainen kukistamanne olento on yksi vähemmän kertomassa tätä tarinaa väärin.
+
+# achievements and statistics
+menu.achievements = Saavutukset
+ach.count = {0}/{1} saavutusta avattu · {2} minuuttia pelattu
+ach.hidden = Salaisuus. Crookfinger ei kerro.
+msg.achievement = Saavutus avattu: {0}!
+end.stats2 = Sankarit {0} · Menetetyt olennot {1} · Kaivetut ruudut {2} · Kulta {3} · Puu {4}
+ach.heroes.name = Sankarikauhu {0}
+ach.heroes.desc = Kukista {0} sankaria.
+ach.bosses.name = Mestarinmurskaaja {0}
+ach.bosses.desc = Kaada {0} ritarikunnan mestaria.
+ach.dug.name = Syväkaivaja {0}
+ach.dug.desc = Kaiva {0} ruutua.
+ach.gold.name = Aarteenkahmija {0}
+ach.gold.desc = Ansaitse {0} kultaa.
+ach.wood.name = Puuruhtinas {0}
+ach.wood.desc = Kaada {0} puuta.
+ach.walls.name = Muurari {0}
+ach.walls.desc = Pystytä {0} seinää.
+ach.rooms.name = Varjojen arkkitehti {0}
+ach.rooms.desc = Rakenna {0} huoneruutua.
+ach.spells.name = Pimeä loitsija {0}
+ach.spells.desc = Loitsi {0} loitsua.
+ach.slaps.name = Luja käsi {0}
+ach.slaps.desc = Läimäytä olentojasi {0} kertaa. Ne rakastavat sitä. Luultavasti.
+ach.traps.name = Ansaseppä {0}
+ach.traps.desc = Tilaa pajasta {0} ansaa ja ovea.
+ach.towers.name = Tornin herra {0}
+ach.towers.desc = Rakenna {0} tornia.
+ach.drowned.name = Uimakoulu {0}
+ach.drowned.desc = Anna {0} sankarin pudota veteen tai laavaan.
+ach.burnt.name = Siltojenpolttaja {0}
+ach.burnt.desc = Polta {0} siltalankkua.
+ach.siege.name = Piiritysmestari {0}
+ach.siege.desc = Rakenna {0} piiritysvälinettä.
+ach.captured.name = Vanginvartija {0}
+ach.captured.desc = Vangitse {0} sankaria.
+ach.drawbridge.name = Ylös vaan {0}
+ach.drawbridge.desc = Nosta laskusiltoja {0} kertaa.
+ach.wins.name = Valloittaja {0}
+ach.wins.desc = Voita {0} vapaata peliä.
+ach.missions.name = Sirpaleenetsijä {0}
+ach.missions.desc = Voita {0} kampanjatehtävää.
+ach.coop.name = Liiton jäsen {0}
+ach.coop.desc = Voita {0} yhteistyötehtävää.
+ach.minutes.name = Yökyöpeli {0}
+ach.minutes.desc = Pelaa {0} minuuttia.
+ach.flawless.name = Ei naarmuakaan
+ach.flawless.desc = Voita menettämättä yhtäkään olentoa.
+ach.speedrun.name = Ennen kuin tee jäähtyy
+ach.speedrun.desc = Voita viidessätoista minuutissa.
+ach.campaign.name = Se yksi mestari
+ach.campaign.desc = Voita kampanjan viimeinen tehtävä.
+ach.surface.name = Auringonpolttama
+ach.surface.desc = Voita peli, joka ulottuu maan pinnalle.
+ach.defeat.name = Teetä ja sympatiaa
+ach.defeat.desc = Häviä peli. Crookfinger laittaa teeveden kiehumaan.
+ach.legend.name = Elävä legenda
+ach.legend.desc = Olento saavuttaa tason kymmenen.
+ach.wyrm.name = Täällä on lohikäärmeitä
+ach.wyrm.desc = Hehkulohikäärme liittyy luolastoosi.
+ach.fullhand.name = Kädet täynnä
+ach.fullhand.desc = Pidä kahdeksaa yksikköä kädessäsi yhtä aikaa.
diff --git a/lang/hr.lang b/lang/hr.lang
index 0ab130b..eb4a9c2 100644
--- a/lang/hr.lang
+++ b/lang/hr.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Porazite Mossgrima i sva njegova stvorenja.
coop.m20.boss = Mossgrima više nema. Mahovina će ponovo izrasti. On neće.
coop.m20.t1 = Onaj zmaj na istoku je Mossgrim. Da, zapustio se.
coop.m20.t2 = Svako stvorenje koje porazite jedno je manje koje će ovu priču krivo ispričati.
+
+# achievements and statistics
+menu.achievements = Postignuća
+ach.count = Otključano {0} od {1} postignuća · {2} minuta igre
+ach.hidden = Tajna. Krivoprst ne govori.
+msg.achievement = Postignuće otključano: {0}!
+end.stats2 = Junaci {0} · Izgubljena stvorenja {1} · Iskopana polja {2} · Zlato {3} · Drvo {4}
+ach.heroes.name = Strah junaka {0}
+ach.heroes.desc = Porazi {0} junaka.
+ach.bosses.name = Lomitelj prvaka {0}
+ach.bosses.desc = Obori {0} prvaka reda.
+ach.dug.name = Duboki kopač {0}
+ach.dug.desc = Iskopaj {0} polja.
+ach.gold.name = Gomilatelj {0}
+ach.gold.desc = Zaradi {0} zlata.
+ach.wood.name = Gospodar drva {0}
+ach.wood.desc = Posijeci {0} drva.
+ach.walls.name = Zidar {0}
+ach.walls.desc = Podigni {0} zidova.
+ach.rooms.name = Arhitekt sjena {0}
+ach.rooms.desc = Izgradi {0} polja soba.
+ach.spells.name = Mračni čarobnjak {0}
+ach.spells.desc = Baci {0} čarolija.
+ach.slaps.name = Čvrsta ruka {0}
+ach.slaps.desc = Pljusni svoja stvorenja {0} puta. Obožavaju to. Valjda.
+ach.traps.name = Kovač zamki {0}
+ach.traps.desc = Naruči {0} zamki i vrata u kovačnici.
+ach.towers.name = Gospodar tornjeva {0}
+ach.towers.desc = Izgradi {0} tornjeva.
+ach.drowned.name = Tečaj plivanja {0}
+ach.drowned.desc = Pusti {0} junaka da padnu u vodu ili lavu.
+ach.burnt.name = Palikuća mostova {0}
+ach.burnt.desc = Spali {0} dasaka mosta.
+ach.siege.name = Majstor opsade {0}
+ach.siege.desc = Izgradi {0} opsadnih sprava.
+ach.captured.name = Tamničar {0}
+ach.captured.desc = Zarobi {0} junaka.
+ach.drawbridge.name = Gore s njim {0}
+ach.drawbridge.desc = Podigni pokretne mostove {0} puta.
+ach.wins.name = Osvajač {0}
+ach.wins.desc = Pobijedi u {0} slobodnih igara.
+ach.missions.name = Tragač za krhotinama {0}
+ach.missions.desc = Pobijedi u {0} misija kampanje.
+ach.coop.name = Član saveza {0}
+ach.coop.desc = Pobijedi u {0} co-op misija.
+ach.minutes.name = Noćna ptica {0}
+ach.minutes.desc = Igraj {0} minuta.
+ach.flawless.name = Ni ogrebotine
+ach.flawless.desc = Pobijedi bez gubitka ijednog stvorenja.
+ach.speedrun.name = Prije nego se čaj ohladi
+ach.speedrun.desc = Pobijedi unutar petnaest minuta.
+ach.campaign.name = Jedini gospodar
+ach.campaign.desc = Pobijedi u posljednjoj misiji kampanje.
+ach.surface.name = Opeklina od sunca
+ach.surface.desc = Pobijedi u igri koja seže do površine.
+ach.defeat.name = Čaj i suosjećanje
+ach.defeat.desc = Izgubi igru. Krivoprst stavlja vodu za čaj.
+ach.legend.name = Živa legenda
+ach.legend.desc = Stvorenje dostiže razinu deset.
+ach.wyrm.name = Ovdje su zmajevi
+ach.wyrm.desc = Žarni zmaj pridružuje se tvojoj tamnici.
+ach.fullhand.name = Pune ruke posla
+ach.fullhand.desc = Drži osam jedinica u ruci odjednom.
diff --git a/lang/hu.lang b/lang/hu.lang
index 872e984..22b63f5 100644
--- a/lang/hu.lang
+++ b/lang/hu.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Győzd le Mossgrimot és minden lényét.
coop.m20.boss = Mossgrim nincs többé. A moha visszanő. Ő nem.
coop.m20.t1 = Az a keleti sárkány Mossgrim. Igen, elhanyagolta magát.
coop.m20.t2 = Minden legyőzött lény eggyel kevesebb, aki rosszul meséli majd el ezt a történetet.
+
+# achievements and statistics
+menu.achievements = Teljesítmények
+ach.count = {0}/{1} teljesítmény feloldva · {2} perc játékidő
+ach.hidden = Titok. Crookfinger nem árulja el.
+msg.achievement = Teljesítmény feloldva: {0}!
+end.stats2 = Hősök {0} · Elvesztett lények {1} · Kiásott mezők {2} · Arany {3} · Fa {4}
+ach.heroes.name = Hősök réme {0}
+ach.heroes.desc = Győzz le {0} hőst.
+ach.bosses.name = Bajnoktörő {0}
+ach.bosses.desc = Dönts le {0} bajnokot a rendből.
+ach.dug.name = Mélyásó {0}
+ach.dug.desc = Áss ki {0} mezőt.
+ach.gold.name = Kincsgyűjtő {0}
+ach.gold.desc = Szerezz {0} aranyat.
+ach.wood.name = Faúr {0}
+ach.wood.desc = Vágj ki {0} fát.
+ach.walls.name = Kőműves {0}
+ach.walls.desc = Emelj {0} falat.
+ach.rooms.name = Az árnyak építésze {0}
+ach.rooms.desc = Építs {0} teremmezőt.
+ach.spells.name = Sötét varázsló {0}
+ach.spells.desc = Mondj el {0} varázslatot.
+ach.slaps.name = Kemény kéz {0}
+ach.slaps.desc = Pofozd fel a lényeidet {0} alkalommal. Imádják. Valószínűleg.
+ach.traps.name = Csapdakovács {0}
+ach.traps.desc = Rendelj {0} csapdát és ajtót a kovácsműhelyben.
+ach.towers.name = Toronyúr {0}
+ach.towers.desc = Építs {0} tornyot.
+ach.drowned.name = Úszólecke {0}
+ach.drowned.desc = Hagyd, hogy {0} hős vízbe vagy lávába zuhanjon.
+ach.burnt.name = Hídégető {0}
+ach.burnt.desc = Égess fel {0} hídpallót.
+ach.siege.name = Ostrommester {0}
+ach.siege.desc = Építs {0} ostromgépet.
+ach.captured.name = Börtönőr {0}
+ach.captured.desc = Ejts foglyul {0} hőst.
+ach.drawbridge.name = Fel vele {0}
+ach.drawbridge.desc = Húzz fel felvonóhidat {0} alkalommal.
+ach.wins.name = Hódító {0}
+ach.wins.desc = Nyerj meg {0} szabad játékot.
+ach.missions.name = Szilánkkereső {0}
+ach.missions.desc = Teljesíts {0} hadjárat-küldetést.
+ach.coop.name = Szövetségi tag {0}
+ach.coop.desc = Teljesíts {0} koop küldetést.
+ach.minutes.name = Éjjeli bagoly {0}
+ach.minutes.desc = Játssz {0} percet.
+ach.flawless.name = Egy karcolás sem
+ach.flawless.desc = Nyerj úgy, hogy egyetlen lényt sem veszítesz.
+ach.speedrun.name = Mielőtt kihűl a tea
+ach.speedrun.desc = Nyerj tizenöt percen belül.
+ach.campaign.name = Az egyetlen mester
+ach.campaign.desc = Nyerd meg a hadjárat utolsó küldetését.
+ach.surface.name = Leégés
+ach.surface.desc = Nyerj meg egy játékot, amely a felszínig ér.
+ach.defeat.name = Tea és együttérzés
+ach.defeat.desc = Veszíts el egy játékot. Crookfinger felteszi a teavizet.
+ach.legend.name = Élő legenda
+ach.legend.desc = Egy lény eléri a tizedik szintet.
+ach.wyrm.name = Itt sárkányok laknak
+ach.wyrm.desc = Egy parázssárkány csatlakozik a kazamatádhoz.
+ach.fullhand.name = Tele a kéz
+ach.fullhand.desc = Tarts egyszerre nyolc egységet a kezedben.
diff --git a/lang/it.lang b/lang/it.lang
index 1851df8..41d211e 100644
--- a/lang/it.lang
+++ b/lang/it.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Sconfiggete Mossgrim e tutte le sue creature.
coop.m20.boss = Mossgrim non c'è più. Il muschio ricrescerà. Lui no.
coop.m20.t1 = Quel drago a est è Mossgrim. Sì, si è lasciato andare.
coop.m20.t2 = Ogni creatura che sconfiggete è una in meno che racconterà male questa storia.
+
+# achievements and statistics
+menu.achievements = Obiettivi
+ach.count = {0} di {1} obiettivi sbloccati · {2} minuti giocati
+ach.hidden = Un segreto. Ditastorte non parla.
+msg.achievement = Obiettivo sbloccato: {0}!
+end.stats2 = Eroi {0} · Creature perse {1} · Caselle scavate {2} · Oro {3} · Legno {4}
+ach.heroes.name = Flagello degli eroi {0}
+ach.heroes.desc = Sconfiggi {0} eroi.
+ach.bosses.name = Spezzacampioni {0}
+ach.bosses.desc = Abbatti {0} campioni dell'Ordine.
+ach.dug.name = Scavatore profondo {0}
+ach.dug.desc = Scava {0} caselle.
+ach.gold.name = Accumulatore {0}
+ach.gold.desc = Guadagna {0} oro.
+ach.wood.name = Signore del legno {0}
+ach.wood.desc = Abbatti {0} legno.
+ach.walls.name = Muratore {0}
+ach.walls.desc = Alza {0} muri.
+ach.rooms.name = Architetto delle ombre {0}
+ach.rooms.desc = Costruisci {0} caselle di stanza.
+ach.spells.name = Incantatore oscuro {0}
+ach.spells.desc = Lancia {0} magie.
+ach.slaps.name = Mano ferma {0}
+ach.slaps.desc = Dai {0} schiaffi alle tue creature. Lo adorano. Probabilmente.
+ach.traps.name = Fabbro di trappole {0}
+ach.traps.desc = Ordina {0} trappole e porte alla fucina.
+ach.towers.name = Signore delle torri {0}
+ach.towers.desc = Costruisci {0} torri.
+ach.drowned.name = Lezioni di nuoto {0}
+ach.drowned.desc = Fai cadere {0} eroi in acqua o nella lava.
+ach.burnt.name = Bruciaponti {0}
+ach.burnt.desc = Brucia {0} assi di ponte.
+ach.siege.name = Maestro d'assedio {0}
+ach.siege.desc = Costruisci {0} macchine d'assedio.
+ach.captured.name = Carceriere {0}
+ach.captured.desc = Cattura {0} eroi.
+ach.drawbridge.name = Su che si va! {0}
+ach.drawbridge.desc = Alza i ponti levatoi {0} volte.
+ach.wins.name = Conquistatore {0}
+ach.wins.desc = Vinci {0} partite libere.
+ach.missions.name = Cercaframmenti {0}
+ach.missions.desc = Vinci {0} missioni della campagna.
+ach.coop.name = Membro della lega {0}
+ach.coop.desc = Vinci {0} missioni co-op.
+ach.minutes.name = Nottambulo {0}
+ach.minutes.desc = Gioca per {0} minuti.
+ach.flawless.name = Neanche un graffio
+ach.flawless.desc = Vinci senza perdere nemmeno una creatura.
+ach.speedrun.name = Prima che il tè si raffreddi
+ach.speedrun.desc = Vinci entro quindici minuti.
+ach.campaign.name = L'Unico Maestro
+ach.campaign.desc = Vinci l'ultima missione della campagna.
+ach.surface.name = Scottatura
+ach.surface.desc = Vinci una partita che arriva fino in superficie.
+ach.defeat.name = Tè e compassione
+ach.defeat.desc = Perdi una partita. Ditastorte mette su il bollitore.
+ach.legend.name = Leggenda vivente
+ach.legend.desc = Una creatura raggiunge il livello dieci.
+ach.wyrm.name = Hic sunt dracones
+ach.wyrm.desc = Un drago di brace si unisce al tuo dungeon.
+ach.fullhand.name = Mani piene
+ach.fullhand.desc = Tieni in mano otto unità contemporaneamente.
diff --git a/lang/nl.lang b/lang/nl.lang
index e1631ef..ff21d84 100644
--- a/lang/nl.lang
+++ b/lang/nl.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Versla Mossgrim en al zijn wezens.
coop.m20.boss = Mossgrim is niet meer. Het mos groeit wel terug. Hij niet.
coop.m20.t1 = Die draak in het oosten is Mossgrim. Ja, hij heeft zich laten gaan.
coop.m20.t2 = Elk wezen dat jullie verslaan, is er een minder dat dit verhaal verkeerd vertelt.
+
+# achievements and statistics
+menu.achievements = Prestaties
+ach.count = {0} van {1} prestaties ontgrendeld · {2} minuten gespeeld
+ach.hidden = Een geheim. Krommevinger zegt niks.
+msg.achievement = Prestatie ontgrendeld: {0}!
+end.stats2 = Helden {0} · Wezens verloren {1} · Vakken gegraven {2} · Goud {3} · Hout {4}
+ach.heroes.name = Heldenplaag {0}
+ach.heroes.desc = Versla {0} helden.
+ach.bosses.name = Kampioenbreker {0}
+ach.bosses.desc = Breng {0} kampioenen van de Orde ten val.
+ach.dug.name = Diepgraver {0}
+ach.dug.desc = Graaf {0} vakken uit.
+ach.gold.name = Schatgraaier {0}
+ach.gold.desc = Verdien {0} goud.
+ach.wood.name = Houthakkersvorst {0}
+ach.wood.desc = Kap {0} hout.
+ach.walls.name = Metselaar {0}
+ach.walls.desc = Richt {0} muren op.
+ach.rooms.name = Architect der Schaduwen {0}
+ach.rooms.desc = Bouw {0} kamervakken.
+ach.spells.name = Duistere Magiër {0}
+ach.spells.desc = Spreek {0} spreuken uit.
+ach.slaps.name = Strenge Hand {0}
+ach.slaps.desc = Geef je wezens {0} keer een mep. Ze vinden het heerlijk. Waarschijnlijk.
+ach.traps.name = Vallensmid {0}
+ach.traps.desc = Bestel {0} vallen en deuren in de smederij.
+ach.towers.name = Torenheer {0}
+ach.towers.desc = Bouw {0} torens.
+ach.drowned.name = Zwemles {0}
+ach.drowned.desc = Laat {0} helden in water of lava vallen.
+ach.burnt.name = Bruggenbrander {0}
+ach.burnt.desc = Brand {0} brugplanken af.
+ach.siege.name = Belegeringsmeester {0}
+ach.siege.desc = Bouw {0} belegeringswerktuigen.
+ach.captured.name = Cipier {0}
+ach.captured.desc = Neem {0} helden gevangen.
+ach.drawbridge.name = Hup, omhoog {0}
+ach.drawbridge.desc = Haal {0} keer ophaalbruggen op.
+ach.wins.name = Veroveraar {0}
+ach.wins.desc = Win {0} vrije spellen.
+ach.missions.name = Scherfzoeker {0}
+ach.missions.desc = Win {0} veldtochtmissies.
+ach.coop.name = Bondgenoot {0}
+ach.coop.desc = Win {0} co-opmissies.
+ach.minutes.name = Nachtuil {0}
+ach.minutes.desc = Speel {0} minuten lang.
+ach.flawless.name = Geen Schrammetje
+ach.flawless.desc = Win zonder één enkel wezen te verliezen.
+ach.speedrun.name = Voor de Thee Koud Is
+ach.speedrun.desc = Win binnen vijftien minuten.
+ach.campaign.name = De Ene Meester
+ach.campaign.desc = Win de laatste missie van de veldtocht.
+ach.surface.name = Zonnebrand
+ach.surface.desc = Win een spel dat tot aan de oppervlakte reikt.
+ach.defeat.name = Thee en Medeleven
+ach.defeat.desc = Verlies een spel. Krommevinger zet water op.
+ach.legend.name = Levende Legende
+ach.legend.desc = Een wezen bereikt niveau tien.
+ach.wyrm.name = Hier Zijn Draken
+ach.wyrm.desc = Een gloeddraak sluit zich aan bij je kerker.
+ach.fullhand.name = Handen Vol
+ach.fullhand.desc = Houd acht units tegelijk in je hand.
diff --git a/lang/no.lang b/lang/no.lang
index 820f07c..f53708f 100644
--- a/lang/no.lang
+++ b/lang/no.lang
@@ -1551,3 +1551,66 @@ coop.m20.goal = Beseir Mossgrim og alle skapningene hans.
coop.m20.boss = Mossgrim er ikke mer. Mosen gror tilbake. Det gjør ikke han.
coop.m20.t1 = Den lindormen i øst er Mossgrim. Ja, han har sluppet seg litt løs.
coop.m20.t2 = Hver skapning dere beseirer, er én mindre som kommer til å fortelle denne historien feil.
+
+# achievements and statistics
+menu.achievements = Prestasjoner
+ach.count = {0} av {1} prestasjoner låst opp · {2} minutter spilt
+ach.hidden = En hemmelighet. Krokfinger sier ingenting.
+msg.achievement = Prestasjon låst opp: {0}!
+end.stats2 = Helter {0} · Skapninger tapt {1} · Ruter gravd {2} · Gull {3} · Tre {4}
+ach.heroes.name = Heltenes Svøpe {0}
+ach.heroes.desc = Beseire {0} helter.
+ach.bosses.name = Forkjemperknuser {0}
+ach.bosses.desc = Felle {0} av ordenens forkjempere.
+ach.dug.name = Dypgraver {0}
+ach.dug.desc = Grav ut {0} ruter.
+ach.gold.name = Skattesamler {0}
+ach.gold.desc = Tjen {0} gull.
+ach.wood.name = Tømmerfyrste {0}
+ach.wood.desc = Felle {0} tre.
+ach.walls.name = Murer {0}
+ach.walls.desc = Reis {0} murer.
+ach.rooms.name = Skyggenes Arkitekt {0}
+ach.rooms.desc = Bygg {0} romruter.
+ach.spells.name = Mørk Trollmann {0}
+ach.spells.desc = Kast {0} trollformler.
+ach.slaps.name = Fast Hånd {0}
+ach.slaps.desc = Gi skapningene dine en ørefik {0} ganger. De elsker det. Sannsynligvis.
+ach.traps.name = Fellesmed {0}
+ach.traps.desc = Bestill {0} feller og dører i smia.
+ach.towers.name = Tårnherre {0}
+ach.towers.desc = Bygg {0} tårn.
+ach.drowned.name = Svømmeopplæring {0}
+ach.drowned.desc = La {0} helter falle i vann eller lava.
+ach.burnt.name = Brobrenner {0}
+ach.burnt.desc = Brenn ned {0} broplanker.
+ach.siege.name = Beleiringsmester {0}
+ach.siege.desc = Bygg {0} beleiringsmaskiner.
+ach.captured.name = Fangevokter {0}
+ach.captured.desc = Ta {0} helter til fange.
+ach.drawbridge.name = Opp med Den {0}
+ach.drawbridge.desc = Heis vindebroer {0} ganger.
+ach.wins.name = Erobrer {0}
+ach.wins.desc = Vinn {0} frie spill.
+ach.missions.name = Skårjeger {0}
+ach.missions.desc = Vinn {0} kampanjeoppdrag.
+ach.coop.name = Forbundsfelle {0}
+ach.coop.desc = Vinn {0} co-op-oppdrag.
+ach.minutes.name = Nattugle {0}
+ach.minutes.desc = Spill i {0} minutter.
+ach.flawless.name = Ikke en Skramme
+ach.flawless.desc = Vinn uten å miste en eneste skapning.
+ach.speedrun.name = Før Teen Blir Kald
+ach.speedrun.desc = Vinn innen femten minutter.
+ach.campaign.name = Den Ene Mesteren
+ach.campaign.desc = Vinn kampanjens siste oppdrag.
+ach.surface.name = Solbrent
+ach.surface.desc = Vinn et spill som når opp til overflaten.
+ach.defeat.name = Te og Medfølelse
+ach.defeat.desc = Tap et spill. Krokfinger setter på kjelen.
+ach.legend.name = Levende Legende
+ach.legend.desc = En skapning når nivå ti.
+ach.wyrm.name = Her Være Drager
+ach.wyrm.desc = En glødlindorm slutter seg til dungeonet ditt.
+ach.fullhand.name = Hendene Fulle
+ach.fullhand.desc = Hold åtte enheter i hånden samtidig.
diff --git a/lang/pt.lang b/lang/pt.lang
index 554bf0c..f625877 100644
--- a/lang/pt.lang
+++ b/lang/pt.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Derrotem o Mossgrim e todas as suas criaturas.
coop.m20.boss = O Mossgrim já não existe. O musgo volta a crescer. Ele não.
coop.m20.t1 = Aquela serpe a este é o Mossgrim. Sim, ele desleixou-se.
coop.m20.t2 = Cada criatura que derrotarem é menos uma para contar mal esta história.
+
+# achievements and statistics
+menu.achievements = Conquistas
+ach.count = {0} de {1} conquistas desbloqueadas · {2} minutos jogados
+ach.hidden = Um segredo. O Dedotorto não conta.
+msg.achievement = Conquista desbloqueada: {0}!
+end.stats2 = Heróis {0} · Criaturas perdidas {1} · Casas escavadas {2} · Ouro {3} · Madeira {4}
+ach.heroes.name = Flagelo dos Heróis {0}
+ach.heroes.desc = Derrota {0} heróis.
+ach.bosses.name = Quebra-Campeões {0}
+ach.bosses.desc = Derruba {0} campeões da Ordem.
+ach.dug.name = Escavador Profundo {0}
+ach.dug.desc = Escava {0} casas.
+ach.gold.name = Açambarcador {0}
+ach.gold.desc = Ganha {0} de ouro.
+ach.wood.name = Senhor da Lenha {0}
+ach.wood.desc = Abate {0} de madeira.
+ach.walls.name = Pedreiro {0}
+ach.walls.desc = Ergue {0} paredes.
+ach.rooms.name = Arquiteto das Sombras {0}
+ach.rooms.desc = Constrói {0} casas de salas.
+ach.spells.name = Feiticeiro Sombrio {0}
+ach.spells.desc = Lança {0} feitiços.
+ach.slaps.name = Mão Firme {0}
+ach.slaps.desc = Esbofeteia as tuas criaturas {0} vezes. Elas adoram. Provavelmente.
+ach.traps.name = Armadilheiro {0}
+ach.traps.desc = Encomenda {0} armadilhas e portas na forja.
+ach.towers.name = Senhor das Torres {0}
+ach.towers.desc = Constrói {0} torres.
+ach.drowned.name = Aulas de Natação {0}
+ach.drowned.desc = Deixa {0} heróis cair na água ou na lava.
+ach.burnt.name = Incendiário de Pontes {0}
+ach.burnt.desc = Queima {0} tábuas de ponte.
+ach.siege.name = Mestre de Cerco {0}
+ach.siege.desc = Constrói {0} máquinas de cerco.
+ach.captured.name = Carcereiro {0}
+ach.captured.desc = Captura {0} heróis.
+ach.drawbridge.name = Para Cima! {0}
+ach.drawbridge.desc = Levanta pontes levadiças {0} vezes.
+ach.wins.name = Conquistador {0}
+ach.wins.desc = Vence {0} jogos livres.
+ach.missions.name = Caçador de Fragmentos {0}
+ach.missions.desc = Vence {0} missões da campanha.
+ach.coop.name = Membro da Liga {0}
+ach.coop.desc = Vence {0} missões cooperativas.
+ach.minutes.name = Ave Noturna {0}
+ach.minutes.desc = Joga durante {0} minutos.
+ach.flawless.name = Nem um Arranhão
+ach.flawless.desc = Vence sem perder uma única criatura.
+ach.speedrun.name = Antes que o Chá Arrefeça
+ach.speedrun.desc = Vence em menos de quinze minutos.
+ach.campaign.name = O Mestre Único
+ach.campaign.desc = Vence a última missão da campanha.
+ach.surface.name = Escaldão
+ach.surface.desc = Vence um jogo que chega à superfície.
+ach.defeat.name = Chá e Compaixão
+ach.defeat.desc = Perde um jogo. O Dedotorto põe a chaleira ao lume.
+ach.legend.name = Lenda Viva
+ach.legend.desc = Uma criatura atinge o nível dez.
+ach.wyrm.name = Aqui Há Dragões
+ach.wyrm.desc = Uma serpe de brasa junta-se à tua masmorra.
+ach.fullhand.name = Mãos Cheias
+ach.fullhand.desc = Segura oito unidades na mão ao mesmo tempo.
diff --git a/lang/ro.lang b/lang/ro.lang
index 3a3a695..e770ce7 100644
--- a/lang/ro.lang
+++ b/lang/ro.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Învingeți-l pe Mossgrim și toate creaturile lui.
coop.m20.boss = Mossgrim nu mai e. Mușchiul va crește la loc. El nu.
coop.m20.t1 = Balaurul de la est e Mossgrim. Da, s-a lăsat cam pe tânjală.
coop.m20.t2 = Fiecare creatură pe care o învingeți e una mai puțin care va povesti greșit această istorie.
+
+# achievements and statistics
+menu.achievements = Realizări
+ach.count = {0} din {1} realizări deblocate · {2} minute jucate
+ach.hidden = Un secret. Crookfinger nu spune nimic.
+msg.achievement = Realizare deblocată: {0}!
+end.stats2 = Eroi {0} · Creaturi pierdute {1} · Dale săpate {2} · Aur {3} · Lemn {4}
+ach.heroes.name = Groaza eroilor {0}
+ach.heroes.desc = Învinge {0} eroi.
+ach.bosses.name = Zdrobitor de campioni {0}
+ach.bosses.desc = Doboară {0} campioni ai Ordinului.
+ach.dug.name = Săpător adânc {0}
+ach.dug.desc = Sapă {0} dale.
+ach.gold.name = Strângător de comori {0}
+ach.gold.desc = Câștigă {0} aur.
+ach.wood.name = Stăpânul lemnului {0}
+ach.wood.desc = Taie {0} lemn.
+ach.walls.name = Zidar {0}
+ach.walls.desc = Ridică {0} ziduri.
+ach.rooms.name = Arhitectul umbrelor {0}
+ach.rooms.desc = Construiește {0} dale de cameră.
+ach.spells.name = Vrăjitor întunecat {0}
+ach.spells.desc = Aruncă {0} vrăji.
+ach.slaps.name = Mână fermă {0}
+ach.slaps.desc = Dă-le creaturilor tale {0} palme. Le place. Probabil.
+ach.traps.name = Făurar de capcane {0}
+ach.traps.desc = Comandă {0} capcane și uși la forjă.
+ach.towers.name = Stăpânul turnurilor {0}
+ach.towers.desc = Construiește {0} turnuri.
+ach.drowned.name = Lecții de înot {0}
+ach.drowned.desc = Lasă {0} eroi să cadă în apă sau în lavă.
+ach.burnt.name = Incendiator de poduri {0}
+ach.burnt.desc = Arde {0} scânduri de pod.
+ach.siege.name = Maestru al asediului {0}
+ach.siege.desc = Construiește {0} mașinării de asediu.
+ach.captured.name = Temnicer {0}
+ach.captured.desc = Capturează {0} eroi.
+ach.drawbridge.name = Sus cu el {0}
+ach.drawbridge.desc = Ridică podurile mobile de {0} ori.
+ach.wins.name = Cuceritor {0}
+ach.wins.desc = Câștigă {0} jocuri libere.
+ach.missions.name = Căutător de cioburi {0}
+ach.missions.desc = Câștigă {0} misiuni de campanie.
+ach.coop.name = Membru al Ligii {0}
+ach.coop.desc = Câștigă {0} misiuni co-op.
+ach.minutes.name = Bufniță de noapte {0}
+ach.minutes.desc = Joacă {0} minute.
+ach.flawless.name = Nicio zgârietură
+ach.flawless.desc = Câștigă fără să pierzi nicio creatură.
+ach.speedrun.name = Până nu se răcește ceaiul
+ach.speedrun.desc = Câștigă în cel mult cincisprezece minute.
+ach.campaign.name = Singurul Stăpân
+ach.campaign.desc = Câștigă ultima misiune a campaniei.
+ach.surface.name = Arsură de soare
+ach.surface.desc = Câștigă un joc care ajunge la suprafață.
+ach.defeat.name = Ceai și compasiune
+ach.defeat.desc = Pierde un joc. Crookfinger pune de un ceai.
+ach.legend.name = Legendă vie
+ach.legend.desc = O creatură ajunge la nivelul zece.
+ach.wyrm.name = Aici sunt dragoni
+ach.wyrm.desc = Un balaur de jar se alătură temniței tale.
+ach.fullhand.name = Mâinile pline
+ach.fullhand.desc = Ține opt unități în mână deodată.
diff --git a/lang/ru.lang b/lang/ru.lang
index ca16e3b..f388adf 100644
--- a/lang/ru.lang
+++ b/lang/ru.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Победите Моссгрима и всех его суще
coop.m20.boss = Моссгрима больше нет. Мох отрастёт. Он - нет.
coop.m20.t1 = Этот змий на востоке - Моссгрим. Да, он себя запустил.
coop.m20.t2 = Каждое побеждённое существо - на одного меньше, кто перескажет эту историю неправильно.
+
+# achievements and statistics
+menu.achievements = Достижения
+ach.count = Открыто достижений: {0} из {1} · Сыграно минут: {2}
+ach.hidden = Секрет. Кривопал не расскажет.
+msg.achievement = Достижение открыто: {0}!
+end.stats2 = Герои {0} · Потеряно существ {1} · Выкопано клеток {2} · Золото {3} · Дерево {4}
+ach.heroes.name = Гроза героев {0}
+ach.heroes.desc = Победи героев: {0}.
+ach.bosses.name = Сокрушитель чемпионов {0}
+ach.bosses.desc = Повергни чемпионов ордена: {0}.
+ach.dug.name = Глубокий копатель {0}
+ach.dug.desc = Выкопай клеток: {0}.
+ach.gold.name = Скопидом {0}
+ach.gold.desc = Заработай {0} золота.
+ach.wood.name = Лесной барон {0}
+ach.wood.desc = Сруби {0} дерева.
+ach.walls.name = Каменщик {0}
+ach.walls.desc = Возведи стен: {0}.
+ach.rooms.name = Зодчий теней {0}
+ach.rooms.desc = Построй клеток комнат: {0}.
+ach.spells.name = Тёмный чародей {0}
+ach.spells.desc = Сотвори чар: {0}.
+ach.slaps.name = Твёрдая рука {0}
+ach.slaps.desc = Отшлёпай своих существ {0} раз. Им нравится. Наверное.
+ach.traps.name = Мастер ловушек {0}
+ach.traps.desc = Закажи в кузнице ловушек и дверей: {0}.
+ach.towers.name = Владыка башен {0}
+ach.towers.desc = Построй башен: {0}.
+ach.drowned.name = Уроки плавания {0}
+ach.drowned.desc = Пусть героев упадёт в воду или лаву: {0}.
+ach.burnt.name = Поджигатель мостов {0}
+ach.burnt.desc = Сожги досок мостов: {0}.
+ach.siege.name = Мастер осады {0}
+ach.siege.desc = Построй осадных машин: {0}.
+ach.captured.name = Тюремщик {0}
+ach.captured.desc = Возьми героев в плен: {0}.
+ach.drawbridge.name = Поднять мост! {0}
+ach.drawbridge.desc = Подними подъёмные мосты {0} раз.
+ach.wins.name = Завоеватель {0}
+ach.wins.desc = Выиграй свободных игр: {0}.
+ach.missions.name = Искатель осколков {0}
+ach.missions.desc = Выиграй миссий кампании: {0}.
+ach.coop.name = Член лиги {0}
+ach.coop.desc = Выиграй кооп-миссий: {0}.
+ach.minutes.name = Полуночник {0}
+ach.minutes.desc = Сыграй {0} минут.
+ach.flawless.name = Ни царапины
+ach.flawless.desc = Победи, не потеряв ни одного существа.
+ach.speedrun.name = Пока чай не остыл
+ach.speedrun.desc = Победи за пятнадцать минут.
+ach.campaign.name = Единый Властелин
+ach.campaign.desc = Выиграй последнюю миссию кампании.
+ach.surface.name = Солнечный ожог
+ach.surface.desc = Выиграй игру, которая доходит до поверхности.
+ach.defeat.name = Чай и сочувствие
+ach.defeat.desc = Проиграй игру. Кривопал ставит чайник.
+ach.legend.name = Живая легенда
+ach.legend.desc = Существо достигает десятого уровня.
+ach.wyrm.name = Здесь водятся драконы
+ach.wyrm.desc = Огненный змий присоединяется к твоему подземелью.
+ach.fullhand.name = Полные руки
+ach.fullhand.desc = Держи в руке восемь существ одновременно.
diff --git a/lang/sk.lang b/lang/sk.lang
index f51ce7d..051dd9a 100644
--- a/lang/sk.lang
+++ b/lang/sk.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Porazte Mossgrima a všetky jeho tvory.
coop.m20.boss = Mossgrim už nie je. Mach zase dorastie. On nie.
coop.m20.t1 = Ten šarkan na východe je Mossgrim. Áno, trochu sa zanedbal.
coop.m20.t2 = Každý porazený tvor je o jedného menej, kto bude tento príbeh rozprávať zle.
+
+# achievements and statistics
+menu.achievements = Úspechy
+ach.count = Odomknuté {0} z {1} úspechov · {2} minút hrania
+ach.hidden = Tajomstvo. Krivoprst nič neprezradí.
+msg.achievement = Úspech odomknutý: {0}!
+end.stats2 = Hrdinovia {0} · Stratené tvory {1} · Vykopané polia {2} · Zlato {3} · Drevo {4}
+ach.heroes.name = Postrach hrdinov {0}
+ach.heroes.desc = Poraz {0} hrdinov.
+ach.bosses.name = Lamač šampiónov {0}
+ach.bosses.desc = Zvrhni {0} šampiónov rádu.
+ach.dug.name = Hlbinný kopáč {0}
+ach.dug.desc = Vykop {0} polí.
+ach.gold.name = Hromadič {0}
+ach.gold.desc = Zarob {0} zlata.
+ach.wood.name = Drevorubačský pán {0}
+ach.wood.desc = Vyrúbaj {0} dreva.
+ach.walls.name = Murár {0}
+ach.walls.desc = Postav {0} múrov.
+ach.rooms.name = Architekt tieňov {0}
+ach.rooms.desc = Postav {0} polí miestností.
+ach.spells.name = Temný mág {0}
+ach.spells.desc = Zošli {0} kúziel.
+ach.slaps.name = Pevná ruka {0}
+ach.slaps.desc = Daj svojim tvorom {0} záuch. Milujú to. Asi.
+ach.traps.name = Pascový kováč {0}
+ach.traps.desc = Objednaj {0} pascí a dverí vo vyhni.
+ach.towers.name = Pán veží {0}
+ach.towers.desc = Postav {0} veží.
+ach.drowned.name = Kurz plávania {0}
+ach.drowned.desc = Nechaj {0} hrdinov spadnúť do vody alebo lávy.
+ach.burnt.name = Podpaľač mostov {0}
+ach.burnt.desc = Spáľ {0} mostových dosiek.
+ach.siege.name = Majster obliehania {0}
+ach.siege.desc = Postav {0} obliehacích strojov.
+ach.captured.name = Žalárnik {0}
+ach.captured.desc = Zajmi {0} hrdinov.
+ach.drawbridge.name = Hore s ním {0}
+ach.drawbridge.desc = Vytiahni padacie mosty {0}-krát.
+ach.wins.name = Dobyvateľ {0}
+ach.wins.desc = Vyhraj {0} voľných hier.
+ach.missions.name = Hľadač črepov {0}
+ach.missions.desc = Vyhraj {0} misií kampane.
+ach.coop.name = Člen ligy {0}
+ach.coop.desc = Vyhraj {0} kooperatívnych misií.
+ach.minutes.name = Nočná sova {0}
+ach.minutes.desc = Hraj {0} minút.
+ach.flawless.name = Ani škrabanec
+ach.flawless.desc = Vyhraj bez straty jediného tvora.
+ach.speedrun.name = Kým čaj nevychladne
+ach.speedrun.desc = Vyhraj do pätnástich minút.
+ach.campaign.name = Jediný majster
+ach.campaign.desc = Vyhraj poslednú misiu kampane.
+ach.surface.name = Úpal
+ach.surface.desc = Vyhraj hru, ktorá siaha až na povrch.
+ach.defeat.name = Čaj a súcit
+ach.defeat.desc = Prehraj hru. Krivoprst pristaví vodu na čaj.
+ach.legend.name = Živá legenda
+ach.legend.desc = Tvor dosiahne úroveň desať.
+ach.wyrm.name = Tu sú draci
+ach.wyrm.desc = Žeravý šarkan sa pridá k tvojej kobke.
+ach.fullhand.name = Plné ruky práce
+ach.fullhand.desc = Drž v ruke naraz osem jednotiek.
diff --git a/lang/sr.lang b/lang/sr.lang
index 488e6e6..ff1b7f0 100644
--- a/lang/sr.lang
+++ b/lang/sr.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Поразите Мосгрима и сва његова ств
coop.m20.boss = Мосгрима више нема. Маховина ће поново израсти. Он неће.
coop.m20.t1 = Онај змај на истоку је Мосгрим. Да, запустио се.
coop.m20.t2 = Свако створење које поразите једно је мање које ће ову причу погрешно испричати.
+
+# achievements and statistics
+menu.achievements = Достигнућа
+ach.count = Откључано {0} од {1} достигнућа · {2} минута игре
+ach.hidden = Тајна. Кривопрст не говори.
+msg.achievement = Достигнуће откључано: {0}!
+end.stats2 = Јунаци {0} · Изгубљена створења {1} · Ископана поља {2} · Злато {3} · Дрво {4}
+ach.heroes.name = Страх јунака {0}
+ach.heroes.desc = Порази {0} јунака.
+ach.bosses.name = Ломитељ првака {0}
+ach.bosses.desc = Обори {0} првака реда.
+ach.dug.name = Дубоки копач {0}
+ach.dug.desc = Ископај {0} поља.
+ach.gold.name = Гомилач {0}
+ach.gold.desc = Заради {0} злата.
+ach.wood.name = Господар дрва {0}
+ach.wood.desc = Посеци {0} дрва.
+ach.walls.name = Зидар {0}
+ach.walls.desc = Подигни {0} зидова.
+ach.rooms.name = Архитекта сенки {0}
+ach.rooms.desc = Изгради {0} поља соба.
+ach.spells.name = Мрачни чаробњак {0}
+ach.spells.desc = Баци {0} чини.
+ach.slaps.name = Чврста рука {0}
+ach.slaps.desc = Ошамари своја створења {0} пута. Обожавају то. Ваљда.
+ach.traps.name = Ковач замки {0}
+ach.traps.desc = Наручи {0} замки и врата у ковачници.
+ach.towers.name = Господар кула {0}
+ach.towers.desc = Изгради {0} кула.
+ach.drowned.name = Курс пливања {0}
+ach.drowned.desc = Пусти {0} јунака да падну у воду или лаву.
+ach.burnt.name = Палитељ мостова {0}
+ach.burnt.desc = Спали {0} дасака моста.
+ach.siege.name = Мајстор опсаде {0}
+ach.siege.desc = Изгради {0} опсадних справа.
+ach.captured.name = Тамничар {0}
+ach.captured.desc = Зароби {0} јунака.
+ach.drawbridge.name = Горе с њим {0}
+ach.drawbridge.desc = Подигни покретне мостове {0} пута.
+ach.wins.name = Освајач {0}
+ach.wins.desc = Победи у {0} слободних игара.
+ach.missions.name = Трагач за крхотинама {0}
+ach.missions.desc = Победи у {0} мисија кампање.
+ach.coop.name = Члан савеза {0}
+ach.coop.desc = Победи у {0} ко-оп мисија.
+ach.minutes.name = Ноћна птица {0}
+ach.minutes.desc = Играј {0} минута.
+ach.flawless.name = Ни огреботине
+ach.flawless.desc = Победи без губитка иједног створења.
+ach.speedrun.name = Пре него што се чај охлади
+ach.speedrun.desc = Победи у року од петнаест минута.
+ach.campaign.name = Једини господар
+ach.campaign.desc = Победи у последњој мисији кампање.
+ach.surface.name = Опекотина од сунца
+ach.surface.desc = Победи у игри која сеже до површине.
+ach.defeat.name = Чај и саосећање
+ach.defeat.desc = Изгуби игру. Кривопрст ставља воду за чај.
+ach.legend.name = Жива легенда
+ach.legend.desc = Створење достиже ниво десет.
+ach.wyrm.name = Овде су змајеви
+ach.wyrm.desc = Жарки змај се придружује твојој тамници.
+ach.fullhand.name = Пуне руке посла
+ach.fullhand.desc = Држи осам јединица у руци одједном.
diff --git a/lang/sv.lang b/lang/sv.lang
index 99940c5..642d70b 100644
--- a/lang/sv.lang
+++ b/lang/sv.lang
@@ -1552,3 +1552,66 @@ coop.m20.goal = Besegra Mossgrim och alla hans varelser.
coop.m20.boss = Mossgrim finns inte mer. Mossan växer tillbaka. Det gör inte han.
coop.m20.t1 = Den där lindormen i öster är Mossgrim. Ja, han har släppt taget om sig själv.
coop.m20.t2 = Varje varelse ni besegrar är en mindre som berättar den här historien fel.
+
+# achievements and statistics
+menu.achievements = Prestationer
+ach.count = {0} av {1} prestationer upplåsta · {2} minuter spelade
+ach.hidden = En hemlighet. Crookfinger säger ingenting.
+msg.achievement = Prestation upplåst: {0}!
+end.stats2 = Hjältar {0} · Förlorade varelser {1} · Grävda rutor {2} · Guld {3} · Trä {4}
+ach.heroes.name = Hjälteskräck {0}
+ach.heroes.desc = Besegra {0} hjältar.
+ach.bosses.name = Kämpebrytare {0}
+ach.bosses.desc = Fäll {0} av ordens kämpar.
+ach.dug.name = Djupgrävare {0}
+ach.dug.desc = Gräv ut {0} rutor.
+ach.gold.name = Skattsamlare {0}
+ach.gold.desc = Tjäna {0} guld.
+ach.wood.name = Timmerfurste {0}
+ach.wood.desc = Fäll {0} trä.
+ach.walls.name = Murare {0}
+ach.walls.desc = Res {0} väggar.
+ach.rooms.name = Skuggornas arkitekt {0}
+ach.rooms.desc = Bygg {0} rumsrutor.
+ach.spells.name = Mörk besvärjare {0}
+ach.spells.desc = Kasta {0} trollformler.
+ach.slaps.name = Fast hand {0}
+ach.slaps.desc = Ge dina varelser {0} örfilar. De älskar det. Troligen.
+ach.traps.name = Fällsmed {0}
+ach.traps.desc = Beställ {0} fällor och dörrar i smedjan.
+ach.towers.name = Tornherre {0}
+ach.towers.desc = Bygg {0} torn.
+ach.drowned.name = Simskola {0}
+ach.drowned.desc = Låt {0} hjältar falla i vatten eller lava.
+ach.burnt.name = Brobrännare {0}
+ach.burnt.desc = Bränn ner {0} broplankor.
+ach.siege.name = Belägringsmästare {0}
+ach.siege.desc = Bygg {0} belägringsmaskiner.
+ach.captured.name = Fångvaktare {0}
+ach.captured.desc = Ta {0} hjältar till fånga.
+ach.drawbridge.name = Upp med den {0}
+ach.drawbridge.desc = Hissa upp vindbryggor {0} gånger.
+ach.wins.name = Erövrare {0}
+ach.wins.desc = Vinn {0} fria spel.
+ach.missions.name = Skärvsökare {0}
+ach.missions.desc = Vinn {0} kampanjuppdrag.
+ach.coop.name = Förbundsmedlem {0}
+ach.coop.desc = Vinn {0} co-op-uppdrag.
+ach.minutes.name = Nattuggla {0}
+ach.minutes.desc = Spela i {0} minuter.
+ach.flawless.name = Inte en skråma
+ach.flawless.desc = Vinn utan att förlora en enda varelse.
+ach.speedrun.name = Innan teet kallnar
+ach.speedrun.desc = Vinn inom femton minuter.
+ach.campaign.name = Den ende mästaren
+ach.campaign.desc = Vinn kampanjens sista uppdrag.
+ach.surface.name = Solbränna
+ach.surface.desc = Vinn ett spel som når upp till ytan.
+ach.defeat.name = Te och medkänsla
+ach.defeat.desc = Förlora ett spel. Crookfinger sätter på tevattnet.
+ach.legend.name = Levande legend
+ach.legend.desc = En varelse når nivå tio.
+ach.wyrm.name = Här finns drakar
+ach.wyrm.desc = En glödlindorm ansluter sig till din håla.
+ach.fullhand.name = Fullt upp
+ach.fullhand.desc = Håll åtta enheter i handen samtidigt.
diff --git a/server/src/main.rs b/server/src/main.rs
index bb9c614..4e4db0a 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -562,6 +562,43 @@ async fn list_campaigns(State(st): State, headers: HeaderMap) -> Respo
Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response()
}
+// the player's profile: lifetime statistics and achievements (an opaque JSON object; the client
+// merges it with what it kept locally)
+const MAX_PROFILE: usize = 64 * 1024;
+
+async fn get_profile(State(st): State, headers: HeaderMap) -> Response {
+ let u = match st.auth(&headers).await {
+ Ok(u) => u,
+ Err(r) => return r,
+ };
+ match tokio::fs::read(st.user_dir(&u.name).join("profile.json")).await {
+ Ok(b) => ([("content-type", "application/json")], b).into_response(),
+ Err(_) => ([("content-type", "application/json")], b"{}".to_vec()).into_response(),
+ }
+}
+
+async fn put_profile(State(st): State, headers: HeaderMap, body: Bytes) -> Response {
+ let u = match st.auth(&headers).await {
+ Ok(u) => u,
+ Err(r) => return r,
+ };
+ if body.len() > MAX_PROFILE {
+ return err(StatusCode::PAYLOAD_TOO_LARGE, "profile too large");
+ }
+ if limited(&st.writes, WRITES_PER_MINUTE).await {
+ return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
+ }
+ if !serde_json::from_slice::(&body).map_or(false, |v| v.is_object()) {
+ return err(StatusCode::BAD_REQUEST, "not a JSON object");
+ }
+ let dir = st.user_dir(&u.name);
+ let _ = tokio::fs::create_dir_all(&dir).await;
+ match tokio::fs::write(dir.join("profile.json"), &body).await {
+ Ok(_) => StatusCode::NO_CONTENT.into_response(),
+ Err(_) => err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"),
+ }
+}
+
async fn get_campaign(State(st): State, headers: HeaderMap, Path(id): Path) -> Response {
let u = match st.auth(&headers).await {
Ok(u) => u,
@@ -754,6 +791,7 @@ fn router(state: AppState) -> Router {
.route("/api/users", get(list_users).post(create_user))
.route("/api/users/{name}", delete(delete_user))
.route("/api/netplay/ws", get(netplay::netplay_ws))
+ .route("/api/profile", get(get_profile).put(put_profile))
.route("/api/saves", get(list).post(create))
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
.route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit()))
diff --git a/src/achievements.rs b/src/achievements.rs
new file mode 100644
index 0000000..224b1a5
--- /dev/null
+++ b/src/achievements.rs
@@ -0,0 +1,310 @@
+// Achievements: a lifetime profile of the player (totals of every game's statistics, wins,
+// minutes played) and 64 achievements - tiers of counters (bronze, silver, gold, ...) and a few
+// special, partly hidden ones. Kept in the browser; after a login merged with the server's copy
+// (the larger counter wins, unlocked stays unlocked) and stored there too.
+
+use crate::game::*;
+use crate::stats as k;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+pub const PROFILE_KEY: &str = "dc.profile";
+
+// counters of the profile: the game statistics (stats.rs) and a few of its own
+pub const STAT_NAMES: [&str; k::COUNTERS] = [
+ "heroes", "bosses", "lost", "dug", "wood", "walls", "rooms", "spells", "slaps", "traps", "towers", "drowned", "burnt", "siege", "captured", "gold", "drawbridge",
+];
+
+// tiered achievements: id (= counter), thresholds, icon cell
+pub const TIERS: [(&str, &[f64], u32); 20] = [
+ ("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::gfx::I_ARROW),
+ ("bosses", &[1.0, 5.0, 20.0], crate::gfx::I_RUNE),
+ ("dug", &[100.0, 1000.0, 10000.0], crate::gfx::I_PICK),
+ ("gold", &[10000.0, 100000.0, 1000000.0], crate::gfx::I_SELL),
+ ("wood", &[50.0, 500.0, 2000.0], crate::gfx::I_BRIDGE),
+ ("walls", &[10.0, 100.0], crate::gfx::I_WALL),
+ ("rooms", &[50.0, 500.0, 2000.0], crate::gfx::I_STEEL),
+ ("spells", &[10.0, 100.0, 500.0], crate::gfx::I_BOLT),
+ ("slaps", &[10.0, 100.0, 1000.0], crate::gfx::I_HAND),
+ ("traps", &[5.0, 50.0, 200.0], crate::gfx::I_TRAPS),
+ ("towers", &[5.0, 50.0], crate::gfx::I_CANNON),
+ ("drowned", &[1.0, 25.0, 100.0], crate::gfx::I_FREEZE),
+ ("burnt", &[5.0, 50.0], crate::gfx::I_DRAWBRIDGE),
+ ("siege", &[1.0, 10.0], crate::gfx::I_CATAPULT),
+ ("captured", &[1.0, 25.0, 100.0], crate::gfx::I_SUMMON),
+ ("drawbridge", &[1.0, 50.0], crate::gfx::I_DRAWBRIDGE),
+ ("wins", &[1.0, 10.0, 50.0], crate::gfx::I_FLAG),
+ ("missions", &[1.0, 10.0, 30.0], crate::gfx::I_MAGIC),
+ ("coop", &[1.0, 10.0, 20.0], crate::gfx::I_HEAL),
+ ("minutes", &[60.0, 600.0, 3000.0], crate::gfx::I_SPELLS),
+];
+
+// special achievements: id, hidden until unlocked, icon cell
+pub const SPECIAL: [(&str, bool, u32); 8] = [
+ ("flawless", false, crate::gfx::I_HEAL),
+ ("speedrun", false, crate::gfx::I_BOLT),
+ ("campaign", false, crate::gfx::I_RUNE),
+ ("surface", false, crate::gfx::I_WALL),
+ ("defeat", true, crate::gfx::I_SELL),
+ ("legend", false, crate::gfx::I_ARROW),
+ ("wyrm", true, crate::gfx::I_SUMMON),
+ ("fullhand", true, crate::gfx::I_HAND),
+];
+
+// number of achievements in all
+#[cfg(test)]
+pub fn count() -> usize {
+ TIERS.iter().map(|t| t.1.len()).sum::() + SPECIAL.len()
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
+pub struct Profile {
+ #[serde(default)]
+ pub totals: BTreeMap,
+ #[serde(default)]
+ pub unlocked: BTreeMap, // achievement id ("heroes.2", "flawless") -> unix time
+}
+
+impl Profile {
+ pub fn total(&self, id: &str) -> f64 {
+ self.totals.get(id).copied().unwrap_or(0.0)
+ }
+
+ // the larger counter wins, unlocked stays unlocked (with the earlier date)
+ pub fn merge(&mut self, other: &Profile) {
+ for (k, v) in &other.totals {
+ let e = self.totals.entry(k.clone()).or_insert(0.0);
+ *e = e.max(*v);
+ }
+ for (k, t) in &other.unlocked {
+ let e = self.unlocked.entry(k.clone()).or_insert(*t);
+ *e = (*e).min(*t);
+ }
+ }
+
+ // unlocks what the totals have reached; returns the new ones
+ pub fn check_tiers(&mut self, now: u64) -> Vec {
+ let mut new = Vec::new();
+ for (id, steps, _) in TIERS.iter() {
+ let v = self.total(id);
+ for (t, &need) in steps.iter().enumerate() {
+ let key = format!("{}.{}", id, t + 1);
+ if v >= need && !self.unlocked.contains_key(&key) {
+ self.unlocked.insert(key.clone(), now);
+ new.push(key);
+ }
+ }
+ }
+ new
+ }
+
+ pub fn unlock(&mut self, id: &str, now: u64) -> bool {
+ if self.unlocked.contains_key(id) {
+ return false;
+ }
+ self.unlocked.insert(id.to_string(), now);
+ true
+ }
+}
+
+// what the tracker needs to know about the running game besides the game itself
+pub struct Context {
+ pub coop: bool,
+ pub editor_test: bool, // test play from the editor counts nothing
+}
+
+// Follows the running game: adds what happened to the profile every few seconds and unlocks
+pub struct Tracker {
+ pub profile: Profile,
+ flushed: Vec,
+ last_time: f32,
+ ended: bool,
+ t: f32,
+ pub dirty: bool, // changed since the last store
+ pub upload: bool, // something new for the server
+}
+
+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 }
+ }
+
+ // 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.ended = g.state != 0;
+ }
+
+ // called every frame; returns achievements unlocked just now
+ pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec {
+ if g.demo.is_some() || ctx.editor_test {
+ self.last_time = 0.0;
+ return Vec::new();
+ }
+ // a loaded or new game: its old counters are not counted again
+ if g.time + 0.5 < self.last_time || self.last_time == 0.0 {
+ self.restart(g);
+ }
+ self.last_time = g.time.max(0.01);
+ self.t += dt;
+ let ended_now = g.state != 0 && !self.ended;
+ if self.t < 5.0 && !ended_now {
+ return Vec::new();
+ }
+ let played = self.t;
+ self.t = 0.0;
+ let mut new = Vec::new();
+ // the counters of the game
+ for i in 0..k::COUNTERS {
+ let cur = g.stats.get(i);
+ let d = cur - self.flushed[i];
+ if d > 0.0 {
+ *self.profile.totals.entry(STAT_NAMES[i].to_string()).or_insert(0.0) += d as f64;
+ self.flushed[i] = cur;
+ self.dirty = true;
+ }
+ }
+ if g.state == 0 {
+ *self.profile.totals.entry("minutes".into()).or_insert(0.0) += (played / 60.0) as f64;
+ self.dirty = true;
+ }
+ // the special ones that can happen any time
+ if g.ents.iter().any(|e| e.alive && e.creature() && e.level >= 10) && self.profile.unlock("legend", now) {
+ new.push("legend".to_string());
+ }
+ if g.ents.iter().any(|e| e.alive && e.creature() && e.kind == EK::Wyrm) && self.profile.unlock("wyrm", now) {
+ new.push("wyrm".to_string());
+ }
+ if g.hand.len() >= 8 && self.profile.unlock("fullhand", now) {
+ new.push("fullhand".to_string());
+ }
+ // the end of the game
+ if ended_now {
+ self.ended = true;
+ if g.state == 1 {
+ let key = if ctx.coop {
+ "coop"
+ } else if g.mission.is_some() {
+ "missions"
+ } else {
+ "wins"
+ };
+ *self.profile.totals.entry(key.into()).or_insert(0.0) += 1.0;
+ if g.stats.get(k::LOST) == 0.0 && self.profile.unlock("flawless", now) {
+ new.push("flawless".into());
+ }
+ if g.time < 15.0 * 60.0 && self.profile.unlock("speedrun", now) {
+ new.push("speedrun".into());
+ }
+ let last = crate::campaign::missions().len().saturating_sub(1);
+ if !ctx.coop && g.mission.as_ref().map_or(false, |m| m.index == last) && self.profile.unlock("campaign", now) {
+ new.push("campaign".into());
+ }
+ if !g.map.sky.is_empty() && self.profile.unlock("surface", now) {
+ new.push("surface".into());
+ }
+ } else if self.profile.unlock("defeat", now) {
+ new.push("defeat".into());
+ }
+ self.dirty = true;
+ }
+ new.extend(self.profile.check_tiers(now));
+ if !new.is_empty() {
+ self.dirty = true;
+ self.upload = true;
+ }
+ new
+ }
+}
+
+// the name and description of an achievement ("heroes.2" or "flawless") in the current language
+pub fn texts(id: &str) -> (String, String) {
+ use crate::i18n::{tr, trf};
+ match id.split_once('.') {
+ Some((cat, tier)) => {
+ let t: usize = tier.parse().unwrap_or(1);
+ let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t - 1)).copied().unwrap_or(0.0);
+ let roman = ["I", "II", "III", "IV", "V"][(t - 1).min(4)];
+ (trf(&format!("ach.{}.name", cat), &[roman.to_string()]), trf(&format!("ach.{}.desc", cat), &[fmt_count(need)]))
+ }
+ None => (tr(&format!("ach.{}.name", id)), tr(&format!("ach.{}.desc", id))),
+ }
+}
+
+pub fn fmt_count(v: f64) -> String {
+ let n = v.round() as i64;
+ let s = n.to_string();
+ let mut out = String::new();
+ for (i, c) in s.chars().enumerate() {
+ if i > 0 && (s.len() - i) % 3 == 0 {
+ out.push('.');
+ }
+ out.push(c);
+ }
+ out
+}
+
+// every achievement in display order: (id, hidden, icon, tier 0..)
+pub fn all() -> Vec<(String, bool, u32, usize)> {
+ let mut v = Vec::new();
+ for (id, steps, icon) in TIERS.iter() {
+ for t in 0..steps.len() {
+ v.push((format!("{}.{}", id, t + 1), false, *icon, t));
+ }
+ }
+ for (id, hidden, icon) in SPECIAL.iter() {
+ v.push((id.to_string(), *hidden, *icon, 9));
+ }
+ v
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn more_than_fifty_achievements() {
+ assert!(count() >= 50, "{}", count());
+ assert_eq!(all().len(), count());
+ }
+
+ #[test]
+ fn the_tracker_counts_a_game_and_unlocks() {
+ let mut g = Game::new(3, 64, 64);
+ let mut t = Tracker::new(Profile::default());
+ let ctx = Context { coop: false, editor_test: false };
+ g.time = 1.0;
+ t.update(&g, &ctx, 0.1, 1);
+ g.count(k::HEROES, 3.0);
+ g.count(k::DUG, 150.0);
+ g.time = 10.0;
+ let new = t.update(&g, &ctx, 6.0, 2);
+ assert!(new.contains(&"heroes.1".to_string()) && new.contains(&"dug.1".to_string()), "{:?}", new);
+ assert_eq!(t.profile.total("heroes"), 3.0);
+ // the same counters are not counted twice
+ g.time = 20.0;
+ t.update(&g, &ctx, 6.0, 3);
+ assert_eq!(t.profile.total("heroes"), 3.0);
+ // a won free game without losses
+ g.state = 1;
+ let new = t.update(&g, &ctx, 0.1, 4);
+ assert!(new.contains(&"wins.1".to_string()) && new.contains(&"flawless".to_string()), "{:?}", new);
+ }
+
+ #[test]
+ fn profiles_merge() {
+ let mut a = Profile::default();
+ a.totals.insert("heroes".into(), 10.0);
+ a.unlocked.insert("heroes.1".into(), 5);
+ let mut b = Profile::default();
+ b.totals.insert("heroes".into(), 7.0);
+ b.totals.insert("dug".into(), 100.0);
+ b.unlocked.insert("heroes.1".into(), 3);
+ b.unlocked.insert("dug.1".into(), 4);
+ a.merge(&b);
+ assert_eq!(a.total("heroes"), 10.0);
+ assert_eq!(a.total("dug"), 100.0);
+ assert_eq!(a.unlocked["heroes.1"], 3);
+ assert!(a.unlocked.contains_key("dug.1"));
+ }
+}
diff --git a/src/bosses.rs b/src/bosses.rs
index 1b6fd63..d104093 100644
--- a/src/bosses.rs
+++ b/src/bosses.rs
@@ -110,6 +110,7 @@ impl Game {
return;
}
self.ents[j].boss = 0;
+ self.count_local(crate::stats::BOSSES);
let got = self.add_gold(BOSS_GOLD);
self.rune_free += 1;
let (x, y) = (self.ents[j].x, self.ents[j].y);
diff --git a/src/bridges.rs b/src/bridges.rs
index a519a25..e09bb0f 100644
--- a/src/bridges.rs
+++ b/src/bridges.rs
@@ -128,6 +128,7 @@ impl Game {
}
if t >= BURN_TIME {
self.bridgework.burning.swap_remove(k);
+ self.count_local(crate::stats::BURNT);
self.bridge_falls(i);
continue;
}
@@ -262,6 +263,7 @@ impl Game {
t.res = (t.res & DB_LAVA) | state;
}
if raise {
+ self.count(crate::stats::DRAWBRIDGE, 1.0);
// whoever stands on it jumps to the bank (or falls)
for &g in &group {
self.ground_lost(g);
diff --git a/src/cloud.rs b/src/cloud.rs
index f26f92d..7be1c45 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -80,6 +80,8 @@ enum Req {
CampaignPublish,
PublicList,
PublicLoad,
+ ProfileLoad,
+ ProfileSave,
}
// the logged-in account
@@ -124,6 +126,7 @@ pub struct Cloud {
pub public_listed: bool,
pub campaign_error: Option<&'static str>, // language key of the last failed campaign request
loaded_campaign: Option,
+ pub profile_loaded: Option, // the profile from the server, waiting to be merged
saved_campaign: Option,
pending: Option<(u32, Req)>,
save_name: String,
@@ -176,6 +179,7 @@ impl Cloud {
public_listed: false,
campaign_error: None,
loaded_campaign: None,
+ profile_loaded: None,
saved_campaign: None,
pending: Some((id, Req::Health)),
save_name: String::new(),
@@ -330,6 +334,13 @@ impl Cloud {
CloudEvent::Error(error_key(res.status))
}
}
+ Req::ProfileLoad => {
+ if ok {
+ self.profile_loaded = Some(String::from_utf8_lossy(&res.body).into_owned());
+ }
+ CloudEvent::None
+ }
+ Req::ProfileSave => CloudEvent::None,
Req::CreateUser | Req::DeleteUser => {
if ok {
self.load_users();
@@ -470,6 +481,15 @@ impl Cloud {
}
// JSON text of a campaign loaded by load_campaign() or load_public_campaign()
+ // the profile (statistics and achievements) of the logged-in player
+ pub fn load_profile(&mut self) -> bool {
+ self.session.is_some() && self.start("GET", "profile", &[], Req::ProfileLoad)
+ }
+
+ pub fn save_profile(&mut self, json: &str) -> bool {
+ self.session.is_some() && self.start("PUT", "profile", json.as_bytes(), Req::ProfileSave)
+ }
+
pub fn take_loaded_campaign(&mut self) -> Option {
self.loaded_campaign.take()
}
diff --git a/src/game.rs b/src/game.rs
index 8f0ed04..abcc841 100644
--- a/src/game.rs
+++ b/src/game.rs
@@ -304,6 +304,11 @@ pub struct Ent {
pub disp_y: f32,
}
+// units of the neutral dwarves count for nobody
+fn e_is_dwarf_side(side: u8) -> bool {
+ side == crate::dwarves::DWARF_SIDE
+}
+
fn yes() -> bool {
true
}
@@ -596,6 +601,8 @@ pub struct Game {
#[serde(default)]
pub bridgework: crate::bridges::BridgeWork, // burning and worn bridges
#[serde(default)]
+ pub stats: crate::stats::GameStats, // statistics of the master at this computer (see stats.rs)
+ #[serde(default)]
pub wall_plans: Vec<(usize, u8)>, // walls to raise: tile, master side (see walls.rs)
#[serde(default)]
pub wood: f32, // the second resource (see wood.rs)
@@ -920,6 +927,7 @@ impl Game {
vrng: crate::particles::vrng(),
dwarf_caves: Vec::new(),
bridgework: Default::default(),
+ stats: Default::default(),
wall_plans: Vec::new(),
wood: crate::wood::WOOD_START,
woodwork: Default::default(),
@@ -1137,6 +1145,7 @@ impl Game {
self.gold += acc;
if acc > 0.0 {
self.incoming.push((res, acc, at));
+ self.count(crate::stats::GOLD, acc);
}
acc
}
@@ -1384,6 +1393,7 @@ impl Game {
}
let local = self.local_side;
self.switch_to(local);
+ self.sample_stats(STEP);
}
// ---------------------------------------------------------------- masters
@@ -2494,6 +2504,7 @@ impl Game {
t.hp = 0.0;
t.gold = 0;
let (x, y) = xy(i);
+ self.count(crate::stats::DUG, 1.0);
self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66);
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
}
@@ -2759,6 +2770,8 @@ impl Game {
e.patrol.clear();
let b = st(kind).bounty as f32;
let got = self.add_gold(b);
+ self.count_local(crate::stats::HEROES);
+ self.count_local(crate::stats::CAPTURED);
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::Capture, x, y);
self.msg("msg.hero_captured", vec![format!("@{}", st(kind).name), (got as i32).to_string()]);
@@ -2780,6 +2793,15 @@ impl Game {
self.bury(x, y);
}
let s = st(kind);
+ if hero {
+ self.count_local(crate::stats::HEROES);
+ } else if side == self.local_side && !e_is_dwarf_side(side) {
+ let me = self.me_side;
+ let local = self.local_side;
+ self.switch_to(local);
+ self.count(crate::stats::LOST, 1.0);
+ self.switch_to(me);
+ }
if hero {
let b = s.bounty as f32;
let got = self.add_gold(b);
@@ -2866,6 +2888,9 @@ impl Game {
if !e.alive || e.held || e.tile() != i || flies(e.kind) {
continue;
}
+ if e.hero {
+ self.count_local(crate::stats::DROWNED);
+ }
if lava {
self.burn(j);
continue;
@@ -4778,6 +4803,7 @@ impl Game {
if built == 0 && !broke {
self.hint(7, "hint.build_where");
}
+ self.count(crate::stats::ROOMS, built as f32);
self.recount();
}
@@ -4923,6 +4949,7 @@ impl Game {
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
let (px, py) = tower.pos();
self.towers.push(tower);
+ self.count(crate::stats::TOWERS, 1.0);
// units standing on the tile step aside
for e in self.ents.iter_mut() {
if e.alive && e.tile() == i {
@@ -5460,6 +5487,7 @@ impl Game {
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
self.sfx(Sfx::Slap, x, y);
self.script_event(crate::mscript::ScriptEvent::Slap);
+ self.count(crate::stats::SLAPS, 1.0);
return 1;
}
if self.ents[j].captive != 0 {
@@ -5498,6 +5526,7 @@ impl Game {
return;
}
self.mana -= COST;
+ self.count(crate::stats::SPELLS, 1.0);
self.sfx(Sfx::Lightning, wx, wy);
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
self.ignite_at(wx, wy);
@@ -5524,6 +5553,7 @@ impl Game {
return;
}
self.mana -= COST;
+ self.count(crate::stats::SPELLS, 1.0);
self.sfx(Sfx::Heal, wx, wy);
let mut fxs = Vec::new();
for e in self.ents.iter_mut() {
diff --git a/src/main.rs b/src/main.rs
index 4a3ad4c..1fc5fc7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
+mod achievements;
mod audio;
mod campaign;
mod campaign_doc;
@@ -41,6 +42,7 @@ mod traps;
mod sound;
mod surface;
mod spells;
+mod stats;
mod touch;
mod ui;
mod voice;
@@ -847,6 +849,12 @@ async fn main() {
let mut r3d = R3d::new();
let mut sounds = SoundBank::new();
let mut music = MusicDirector::new();
+ // lifetime statistics and achievements (browser storage, merged with the server after a login)
+ let stored_profile: achievements::Profile = platform::storage_get(achievements::PROFILE_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
+ let mut tracker = achievements::Tracker::new(stored_profile);
+ let mut want_profile = false; // fetch the server's profile when the client is free
+ let mut profile_store_t = 0.0f32;
+ let mut profile_upload_t = 0.0f32;
let mut ui = Ui::new();
let mut menu = Menu::new();
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
@@ -992,6 +1000,7 @@ async fn main() {
}
}
CloudEvent::LoggedIn => {
+ want_profile = true;
if menu.page == Some(Page::Login) {
menu.page = Some(Page::Title);
}
@@ -1939,8 +1948,47 @@ app.pending_briefing = briefing_of(&campaign, i);
| Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
+ | Some(Page::Achievements { from_title: true })
);
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz));
+ // ---------------- achievements: count the game, unlock, store and share the profile
+ {
+ let ctx = achievements::Context { coop: app.coop, editor_test: app.editor_testing };
+ let unix = miniquad::date::now() as u64;
+ let counting = !on_title && (playing || app.game.state != 0);
+ for id in if counting { tracker.update(&app.game, &ctx, dt, unix) } else { Vec::new() } {
+ let (name, _) = achievements::texts(&id);
+ app.game.msgs.push(game::Msg { key: "msg.achievement", args: vec![name] });
+ audio::play_sfx(game::Sfx::Victory.index(), 0.8, 0.0);
+ }
+ if let Some(json) = cloud.profile_loaded.take() {
+ if let Ok(p) = serde_json::from_str::(&json) {
+ tracker.profile.merge(&p);
+ tracker.profile.check_tiers(unix);
+ tracker.dirty = true;
+ tracker.upload = tracker.profile != p;
+ }
+ }
+ if want_profile && cloud.session.is_some() && cloud.load_profile() {
+ want_profile = false;
+ }
+ profile_store_t += dt;
+ profile_upload_t += dt;
+ if tracker.dirty && (profile_store_t > 10.0 || tracker.upload) {
+ profile_store_t = 0.0;
+ tracker.dirty = false;
+ platform::storage_set(achievements::PROFILE_KEY, &serde_json::to_string(&tracker.profile).unwrap_or_default());
+ }
+ if tracker.upload && cloud.session.is_some() && profile_upload_t > 30.0 && !want_profile {
+ if cloud.save_profile(&serde_json::to_string(&tracker.profile).unwrap_or_default()) {
+ tracker.upload = false;
+ profile_upload_t = 0.0;
+ }
+ }
+ if matches!(menu.page, Some(Page::Achievements { .. })) {
+ menu.profile = tracker.profile.clone();
+ }
+ }
for m in app.game.msgs.drain(..) {
if app.settings.voice && voice::speaks(m.key) {
voice.say(&i18n::trf(m.key, &m.args), now, 12);
@@ -2039,6 +2087,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
| Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
+ | Some(Page::Achievements { from_title: true })
);
if !title_screen {
let info = if over_ui || (touch && inp.inspect.is_none()) {
diff --git a/src/menu.rs b/src/menu.rs
index 037aafe..959c544 100644
--- a/src/menu.rs
+++ b/src/menu.rs
@@ -14,6 +14,7 @@ use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use macroquad::prelude::*;
+mod achievements;
mod bestiary;
pub mod flags;
@@ -33,6 +34,7 @@ pub enum Page {
Account, // password, logout, users (admin)
Match { from_title: bool }, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
+ Achievements { from_title: bool },
}
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
@@ -294,6 +296,7 @@ enum Btn {
MatchLeave,
MatchChat,
Bestiary,
+ Achievements,
BestTab(usize),
BestSel(i32),
BestPick(usize),
@@ -441,6 +444,7 @@ pub struct Menu {
confirm: Option, // overwrite / delete waiting for a second click
scroll: usize,
rows_visible: usize,
+ pub profile: crate::achievements::Profile, // shown on the achievements page
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
@@ -505,6 +509,7 @@ impl Menu {
lang_rows: 0,
cur_lang: String::new(),
rows_visible: 1,
+ profile: Default::default(),
buttons: Vec::new(),
}
}
@@ -530,6 +535,7 @@ impl Menu {
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
+ Some(Page::Achievements { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
Some(Page::CustomBriefing) => None,
@@ -916,6 +922,11 @@ impl Menu {
}
MenuAction::Consumed
}
+ Some(Btn::Achievements) => {
+ self.page = Some(Page::Achievements { from_title });
+ self.scroll = 0;
+ MenuAction::Consumed
+ }
Some(Btn::Bestiary) => {
self.open_bestiary(from_title);
MenuAction::Consumed
@@ -954,6 +965,7 @@ impl Menu {
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::CustomBriefing => self.draw_custom_briefing(gfx),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
+ Page::Achievements { .. } => self.draw_achievements(gfx),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 5);
self.buttons.clear();
@@ -1098,6 +1110,7 @@ impl Menu {
}
items.push((Btn::Standalone, tr("menu.standalone")));
items.push((Btn::Bestiary, tr("menu.bestiary")));
+ items.push((Btn::Achievements, tr("menu.achievements")));
items.push((Btn::Help, tr("menu.help")));
let w = 26.0 * CW();
let y = ty + GH * big + CH() * 4.0;
@@ -1215,6 +1228,7 @@ impl Menu {
items.extend([
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
+ (Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("net.leave")),
]);
@@ -1225,6 +1239,7 @@ impl Menu {
(Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
+ (Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("menu.main")),
]);
@@ -1780,7 +1795,7 @@ impl Menu {
pub fn scrollable(&self) -> bool {
matches!(
self.page,
- Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account)
+ Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) | Some(Page::Achievements { .. })
)
}
diff --git a/src/menu/achievements.rs b/src/menu/achievements.rs
new file mode 100644
index 0000000..828588b
--- /dev/null
+++ b/src/menu/achievements.rs
@@ -0,0 +1,70 @@
+// The achievements page: every achievement with its badge (bronze, silver, gold, ... in the
+// colour of its tier), name, description and the progress towards it. Hidden ones show "???"
+// until they are unlocked.
+
+use super::*;
+use crate::achievements::{all, texts, TIERS};
+
+const TIER_COL: [(f32, f32, f32); 5] = [(0.72, 0.45, 0.25), (0.75, 0.78, 0.85), (0.95, 0.78, 0.25), (0.55, 0.9, 1.0), (0.85, 0.5, 1.0)];
+
+impl Menu {
+ pub(super) fn draw_achievements(&mut self, gfx: &Gfx) {
+ let sw = sw();
+ let sh = sh();
+ let cols = 70.min((sw / CW()) as i32 - 2);
+ 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 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"))]);
+ gfx.text(&head, 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() * 3.2;
+ self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
+ self.scroll = self.scroll.min(list.len().saturating_sub(self.rows_visible));
+ let ix = x + CW() * 3.0;
+ let w = (cols - 6) as f32 * CW();
+ let maxc = ((w - CW() * 6.0) / (GW * SMALL())) as usize;
+ for (k, (id, hidden, icon, tier)) in list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
+ let ry = top + (k - self.scroll) as f32 * row_h;
+ let got = self.profile.unlocked.contains_key(id);
+ // the badge: a frame in the tier's colour around the icon (dark while locked)
+ let (r, g, b) = TIER_COL[(*tier).min(4)];
+ let bc = if got { Color::new(r, g, b, 1.0) } else { Color::new(0.25, 0.22, 0.22, 1.0) };
+ let pic = CH() * 2.6;
+ draw_rectangle(ix, ry, pic, pic, Color::new(0.08, 0.05, 0.05, 1.0));
+ draw_rectangle_lines(ix, ry, pic, pic, 3.0, bc);
+ let tint = if got { WHITE } else { Color::new(0.3, 0.3, 0.3, 1.0) };
+ draw_texture_ex(&gfx.atlas, ix + 4.0, ry + 4.0, tint, DrawTextureParams { source: Some(crate::gfx::Gfx::cell(*icon)), dest_size: Some(vec2(pic - 8.0, pic - 8.0)), ..Default::default() });
+ let tx = ix + pic + CW();
+ let (name, desc) = if *hidden && !got { ("???".to_string(), tr("ach.hidden")) } else { texts(id) };
+ gfx.text(&name, tx, ry + CH() * 0.1, S(), if got { GOLD } else { TXT });
+ let d: String = desc.chars().take(maxc).collect();
+ gfx.text(&d, tx, ry + CH() * 1.3, SMALL(), DIM);
+ // progress towards a tier
+ if let Some((cat, t)) = id.split_once('.') {
+ if !got {
+ let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t.parse::().unwrap_or(1) - 1)).copied().unwrap_or(1.0);
+ let have = self.profile.total(cat);
+ let bar_x = x + (cols - 20) as f32 * CW();
+ crate::ui::ascii_bar(gfx, bar_x, ry + CH() * 0.1, 10, (have / need).min(1.0) as f32, FRAME_HI, SMALL());
+ let p = format!("{}/{}", crate::achievements::fmt_count(have.floor()), crate::achievements::fmt_count(need));
+ gfx.text(&p, bar_x, ry + CH() * 1.3, SMALL(), DIM);
+ }
+ }
+ }
+ // scrolling and back
+ 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"))];
+ 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));
+ }
+ }
+}
diff --git a/src/siege.rs b/src/siege.rs
index c64083d..6b4a1b1 100644
--- a/src/siege.rs
+++ b/src/siege.rs
@@ -48,6 +48,7 @@ impl Game {
return;
}
self.gold -= gold;
+ self.count(crate::stats::SIEGE, 1.0);
let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, 1);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = self.me_side;
diff --git a/src/spells.rs b/src/spells.rs
index 6439ab4..7cca226 100644
--- a/src/spells.rs
+++ b/src/spells.rs
@@ -263,6 +263,7 @@ impl Game {
}
}
self.mana -= cost;
+ self.count(crate::stats::SPELLS, 1.0);
}
// running spell effects on a unit (called every frame)
diff --git a/src/stats.rs b/src/stats.rs
new file mode 100644
index 0000000..5c7f3be
--- /dev/null
+++ b/src/stats.rs
@@ -0,0 +1,108 @@
+// Statistics of a game for the master at this computer: counters (heroes slain, tiles dug, wood
+// felled, ...) and a sample of gold, creatures and army strength every few seconds for the charts
+// at the end. They are local (not part of the shared match state) and feed the achievements.
+
+use crate::game::*;
+use serde::{Deserialize, Serialize};
+
+// counters (index into GameStats::c)
+pub const HEROES: usize = 0; // heroes slain or captured
+pub const BOSSES: usize = 1; // champions of the Order brought down
+pub const LOST: usize = 2; // own creatures lost
+pub const DUG: usize = 3; // tiles dug out
+pub const WOOD: usize = 4; // wood felled
+pub const WALLS: usize = 5; // walls raised
+pub const ROOMS: usize = 6; // room tiles built
+pub const SPELLS: usize = 7; // spells cast (incl. lightning and healing)
+pub const SLAPS: usize = 8; // creatures slapped
+pub const TRAPS: usize = 9; // traps and doors ordered
+pub const TOWERS: usize = 10; // towers built
+pub const DROWNED: usize = 11; // heroes who fell into water or lava
+pub const BURNT: usize = 12; // bridge tiles that burnt down
+pub const SIEGE: usize = 13; // siege engines built
+pub const CAPTURED: usize = 14; // heroes knocked out and carried off
+pub const GOLD: usize = 15; // gold earned (treasure delivered, bounties, loot)
+pub const DRAWBRIDGE: usize = 16; // drawbridges raised
+pub const COUNTERS: usize = 17;
+
+// one sample every SAMPLE_EVERY seconds: time, gold, creatures, army
+pub const SAMPLE_EVERY: f32 = 10.0;
+const MAX_SAMPLES: usize = 720;
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+pub struct GameStats {
+ #[serde(default)]
+ pub c: Vec,
+ #[serde(default)]
+ pub samples: Vec<(f32, f32, u16, f32)>,
+ #[serde(default)]
+ pub sample_t: f32,
+}
+
+impl GameStats {
+ pub fn get(&self, k: usize) -> f32 {
+ self.c.get(k).copied().unwrap_or(0.0)
+ }
+}
+
+impl Game {
+ // counts for the master at this computer (another master's deeds count on his computer)
+ pub(crate) fn count(&mut self, k: usize, n: f32) {
+ if self.me_side != self.local_side || self.demo.is_some() {
+ return;
+ }
+ if self.stats.c.len() < COUNTERS {
+ self.stats.c.resize(COUNTERS, 0.0);
+ }
+ self.stats.c[k] += n;
+ }
+
+ // a hero fell or was captured: the master at this computer counts it in single player and
+ // co-op (the heroes are everybody's enemy there)
+ pub(crate) fn count_local(&mut self, k: usize) {
+ let me = self.me_side;
+ let local = self.local_side;
+ self.switch_to(local);
+ self.count(k, 1.0);
+ self.switch_to(me);
+ }
+
+ // every few seconds: how the local master stands
+ pub(crate) fn sample_stats(&mut self, dt: f32) {
+ if self.demo.is_some() || self.state != 0 {
+ return;
+ }
+ self.stats.sample_t -= dt;
+ if self.stats.sample_t > 0.0 {
+ return;
+ }
+ self.stats.sample_t = SAMPLE_EVERY;
+ let s = (self.time, self.gold, self.creature_count() as u16, self.army_value());
+ self.stats.samples.push(s);
+ if self.stats.samples.len() > MAX_SAMPLES {
+ // keep the whole game in view: every second sample goes
+ let v: Vec<_> = self.stats.samples.iter().copied().step_by(2).collect();
+ self.stats.samples = v;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_game_keeps_its_statistics() {
+ let mut g = Game::new(3, 64, 64);
+ for _ in 0..(35 * 20) {
+ g.update(0.05);
+ }
+ assert!(g.stats.samples.len() >= 3, "{} samples", g.stats.samples.len());
+ g.count(DUG, 2.0);
+ assert_eq!(g.stats.get(DUG), 2.0);
+ // saved with the game
+ let json = serde_json::to_string(&g.stats).unwrap();
+ let back: GameStats = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.get(DUG), 2.0);
+ }
+}
diff --git a/src/traps.rs b/src/traps.rs
index 05bd951..1f2a75f 100644
--- a/src/traps.rs
+++ b/src/traps.rs
@@ -186,6 +186,7 @@ impl Game {
}
let id = self.next_id;
self.next_id += 1;
+ self.count(crate::stats::TRAPS, 1.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, sprung: 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);
diff --git a/src/ui.rs b/src/ui.rs
index 0b57f81..2527e34 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1334,7 +1334,10 @@ impl Ui {
}
lines.extend(wrap(para, tw));
}
- let rows = 9 + lines.len() as i32;
+ // the chart of the game (gold, creatures, army) when there are a few samples
+ let chart = g.stats.samples.len() >= 3 && sh > CH() * (22.0 + lines.len() as f32);
+ let chart_rows = if chart { 10 } else { 0 };
+ let rows = 9 + lines.len() as i32 + chart_rows;
let x = grid_center(sw, cols as f32 * CW());
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let col = if win { GOLD } else { RED };
@@ -1347,6 +1350,38 @@ impl Ui {
}
let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM);
+ if chart {
+ use crate::stats as k;
+ let cy = y + CH() * (6.0 + lines.len() as f32);
+ let c = &g.stats;
+ let line2 = trf("end.stats2", &[(c.get(k::HEROES) as i32).to_string(), (c.get(k::LOST) as i32).to_string(), (c.get(k::DUG) as i32).to_string(), (c.get(k::GOLD) as i32).to_string(), (c.get(k::WOOD) as i32).to_string()]);
+ gfx.text(&line2, grid_center(sw, gfx.measure(&line2, SMALL())), cy, SMALL(), DIM);
+ // three lines, each scaled to its own maximum
+ let (px, py, pw, ph) = (x + CW() * 3.0, cy + CH() * 1.2, (cols - 6) as f32 * CW(), CH() * 6.0);
+ draw_rectangle(px, py, pw, ph, Color::new(0.0, 0.0, 0.0, 0.35));
+ draw_rectangle_lines(px, py, pw, ph, 1.0, Color::new(0.4, 0.3, 0.2, 1.0));
+ let n = c.samples.len();
+ let t_end = c.samples[n - 1].0.max(1.0);
+ let series: [(Box f32>, Color); 3] = [(Box::new(|s| s.1), GOLD), (Box::new(|s| s.2 as f32), GREEN), (Box::new(|s| s.3), RED)];
+ for (f, col) in series.iter() {
+ let max = c.samples.iter().map(|s| f(s)).fold(1.0f32, f32::max);
+ for w in c.samples.windows(2) {
+ let (a, b) = (&w[0], &w[1]);
+ let p = |s: &(f32, f32, u16, f32)| (px + pw * (s.0 / t_end), py + ph - ph * 0.92 * (f(s) / max));
+ let (x0, y0) = p(a);
+ let (x1, y1) = p(b);
+ draw_line(x0, y0, x1, y1, 2.0 * K(), *col);
+ }
+ }
+ let legend = format!("{} {} {}", tr("hud.gold"), tr("hud.creatures"), tr("hud.army"));
+ let lx = px + CW();
+ gfx.text(&tr("hud.gold"), lx, py + ph + CH() * 0.3, SMALL(), GOLD);
+ let w1 = gfx.measure(&format!("{} ", tr("hud.gold")), SMALL());
+ gfx.text(&tr("hud.creatures"), lx + w1, py + ph + CH() * 0.3, SMALL(), GREEN);
+ let w2 = gfx.measure(&format!("{} {} ", tr("hud.gold"), tr("hud.creatures")), SMALL());
+ gfx.text(&tr("hud.army"), lx + w2, py + ph + CH() * 0.3, SMALL(), RED);
+ let _ = legend;
+ }
// buttons: free game: new game; mission: next / again and back to the campaign
let labels: Vec<(String, u8)> = match (mission, self.coop_end) {
// an editor campaign in co-op: the host goes on for everybody, the others wait or leave
@@ -1359,10 +1394,10 @@ impl Ui {
if let Some((host, true, sent)) = self.coop_end {
if !host || sent {
let t = tr("net.coop_wait_host");
- gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32), SMALL(), GOLD);
+ gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32 + chart_rows as f32), SMALL(), GOLD);
}
}
- let by = y + CH() * (6.0 + lines.len() as f32);
+ let by = y + CH() * (6.0 + lines.len() as f32 + chart_rows as f32);
let widths: Vec = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::() + CW() * 2.0 * (labels.len() as f32 - 1.0);
let mut bx = grid_center(sw, total);
diff --git a/src/walls.rs b/src/walls.rs
index 463d2b4..a53882f 100644
--- a/src/walls.rs
+++ b/src/walls.rs
@@ -119,6 +119,7 @@ impl Game {
tl.bed = 0;
tl.food = 0.0;
self.wall_plans.retain(|p| p.0 != w);
+ self.count(crate::stats::WALLS, 1.0);
self.fx(8, wx as f32 + 0.5, wy as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
self.sfx(Sfx::Reinforce, wx as f32 + 0.5, wy as f32 + 0.5);
false
diff --git a/src/wood.rs b/src/wood.rs
index 8efafd4..a96796e 100644
--- a/src/wood.rs
+++ b/src/wood.rs
@@ -79,6 +79,7 @@ impl Game {
let kind = self.map.t[i].kind;
let wood = if kind == Kind::Tree { WOOD_TREE } else { WOOD_MUSHROOM };
self.wood += wood;
+ self.count(crate::stats::WOOD, wood);
if kind == Kind::Mushroom {
self.woodwork.regrow.push((i, REGROW));
}