From 1016ee5d6c5416daae47dc4c72d52774c02dbb49 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 15:32:18 +0100 Subject: [PATCH] Bestiary in the title and pause menu; attacks look bigger with the unit level - tabs: creatures, humans (incl. cave dwarves), dungeon buildings and towers, ranks - every unit at every level: sprite with gear, rank badge, health, attack, range, speed, wage or bounty, what attracts it - Crookfinger's texts: loving about the creatures, disgusted about the humans (EN/DE) - projectiles, hit sparks and splashes grow with the attacker's level, with halos from Lv 5 and glow at Lv 9+ Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 75 ++++++++++++ lang/en.lang | 75 ++++++++++++ src/game.rs | 22 +++- src/main.rs | 2 + src/menu.rs | 21 ++++ src/menu/bestiary.rs | 286 +++++++++++++++++++++++++++++++++++++++++++ src/render3d.rs | 24 ++-- src/ui.rs | 21 ++++ 8 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 src/menu/bestiary.rs diff --git a/lang/de.lang b/lang/de.lang index 56f41aa..1c690a0 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -638,3 +638,78 @@ info.dwarf = Bewacht den Schatz einer Zwergenhöhle info.dwarf_neutral = Neutral: kämpft gegen jeden, der zu nahe kommt, verlässt die Höhle nie. tile.rune_chest = Zwergische Runenkiste tile.rune_chest_info = Nimm sie ein: die Pläne eines Runenschmiede-Turms (einmal kostenlos bauen). + +# bestiary +menu.bestiary = Bestiarium +best.title = Krummfingers Bestiarium +best.tab.creatures = Kreaturen +best.tab.humans = Menschen +best.tab.dungeon = Verlies +best.tab.ranks = Dienstgrade +best.level = St. {0} +best.hp = Leben: {0} +best.attack = Angriff: {0} alle {1} s ({2}/s) +best.range = Reichweite: {0} Felder +best.melee = Reichweite: Nahkampf +best.speed = Tempo: {0} Felder/s +best.neutral = Neutral - bewacht seine Höhle +best.bounty = Kopfgeld: {0} Gold +best.wage = Lohn: {0} Gold pro Zahltag +best.lure = Kommt für: {0} +best.cost_rune = kostenlos aus einer Zwergenkiste (Ausbau {0} / {1} Gold) +best.rank_title = Stufe {0}: {1} +best.rank_bonus = +{0}% Leben und +{1}% Angriff gegenüber Stufe 1 +best.badge.0 = Abzeichen: noch keins +best.badge.1 = Abzeichen: bronzene Winkel +best.badge.2 = Abzeichen: silberne Balken +best.badge.3 = Abzeichen: goldene Sterne +best.gear.0 = Noch keine Ausrüstung. Jeder fängt in Lumpen an, Meister - selbst die Großen. +best.gear.1 = Stufe 3-4: Schulterstücke. Das erste Stück Eisen, getragen mit ungeheurem Stolz. +best.gear.2 = Stufe 5-6: dazu ein Helm. Schwerer am Kopf zu treffen, was vor allem dem Kopf hilft. +best.gear.3 = Stufe 7-8: obendrauf ein Umhang. Umhänge tun nichts, aber sie flattern, und Flattern erschreckt Helden. +best.gear.4 = Stufe 9-10: alles in Gold, mit Krone, Heiligenschein und schließlich einer leuchtenden Aura. Ihre Angriffe treffen härter und sehen auch so aus. +best.worker.lure = das Verlies selbst; der Beschwörungszauber bringt mehr +best.worker.text = Meine kleinen Vettern. Sie graben, nehmen ein, schleppen Gold, reparieren Türme und beschweren sich nie - na gut, sie beschweren sich, aber in einer Sprache, die keiner versteht. | Bis zu fünf Imps helfen sich bei einer Aufgabe. Behandle sie gut: Ohne Imps kein Verlies. +best.kobold.lure = irgendein Quartier: immer als Erster durchs Portal +best.kobold.text = Klein, schuppig, spitzzahnig und grenzenlos begeistert. Ein Kobold kämpft gegen alles, auch gegen Dinge, die zehnmal größer sind, und verliert mit fröhlichem Gesicht. | Billig im Unterhalt, flink auf den Beinen. Ich habe ein Glas mit ihren abgeworfenen Schuppen im Regal. +best.spider.lure = einen Pilzgarten ab 6 Feldern +best.spider.text = Acht Beine, acht Augen und ein Herz aus reinem Samt. Höhlenspinnen lieben die feuchte Wärme eines Pilzgartens und das knusprige Geräusch von Rüstungen. | Schnell und zäh. Lass sie nicht zuerst von den Rittern sehen: Ritter kreischen sehr hoch. +best.mage.lure = eine Bibliothek ab 6 Feldern +best.mage.text = Schattenmagier lesen alles, vergessen nichts und schleudern dunkle Blitze aus sicherer Entfernung. Sie sind höflich, leise und ein bisschen furchterregend. | Zerbrechlich, also lass sie hinter den Ogern. Sie zahlen es dir in verkohlten Paladinen zurück. +best.ogre.lure = eine Trainingshalle ab 9 Feldern +best.ogre.text = Ein Steinoger ist eine Mauer, die läuft und umarmt. Meistens umarmt er Feinde, und die stehen nicht wieder auf. | Langsam, stur und herrlich stabil. Füttere ihn gut, zahl pünktlich, und er steht bis ans Ende der Welt vor deinem Kern. +best.jailer.lure = eine Folterkammer ab 6 Feldern +best.jailer.text = Der Kerkermeister zieht Feinde mit seiner Kette heran und hat ein Händchen fürs Überzeugen: Gefangene, um die er sich kümmert, laufen viel öfter über. | Groß, still, summt bei der Arbeit. Kinder lieben ihn. Na ja, unsere. +best.priestess.lure = einen Tempel ab 6 Feldern +best.priestess.text = Die Flammenpriesterin wirft Feuerbälle, die auf alle rund ums Ziel überspritzen, und heilt den Seelenkern mit ihren Gebeten. | Sie schläft auf glühender Glut und riecht nach Zimt. Frag sie nicht nach den Rittern, die ihren Tempel verbrannt haben. Lass sie einfach arbeiten. +best.chainbreaker.lure = ein großes Verlies: 40+ Raumfelder, ein Veteran ab St. 5 und 4000 Gold +best.chainbreaker.text = Eine Legende. Zwillingsäxte, gesprengte Ketten und ein Temperament, das schon ganze Festungen platt gemacht hat. Immer nur einer, und nur für einen Meister, der es wert ist. | Jeder Hieb trifft auch einen zweiten Feind. In kleinen Verliesen wird er unruhig - gib ihm Platz und Feinde, dann gehört er dir. +best.bat.lure = einen Horst ab 4 Feldern +best.bat.text = Riesenfledermäuse! Schnell, frech, und sie fliegen über Wasser und Lava. Sie patrouillieren an den Rändern deines Reichs und stürzen sich auf einsame Feinde. | Allein schwach, im Schwarm eine Plage. Schwer verwundet fliehen sie und kommen ausgeruht zurück. +best.gargoyle.lure = einen Horst ab 6 Feldern und eine Folterkammer ab 4 +best.gargoyle.text = Ein Kettengargoyle ist gemeißelter Stein mit schlechten Manieren und einer Schwäche für Verliese mit ordentlicher Folterkammer. | Zäh wie eine Burgmauer, fliegt über Flüsse, und Pfeile stören ihn überhaupt nicht. Im Horst sitzt er ganz still - so entspannt er sich. +best.owl.lure = einen Horst ab 6 Feldern und eine Bibliothek ab 6 +best.owl.text = Die Arkaneule hat jedes Buch deiner Bibliothek zweimal gelesen und schießt jetzt leuchtende Blitze aus der Luft auf Helden. | Klug, zerbrechlich und furchtbar wertend. Sie schuhut, wenn du schiefe Räume baust. +best.wyrm.lure = einen Horst ab 16 Feldern und eine Schatzkammer ab 16 (nur einer) +best.wyrm.text = Ein Glutlindwurm. In deinem Verlies. Er speit Feuer auf Ritter. Meister, ich muss gleich weinen. | Sehr selten, sehr stark, verbrennt alles rund um sein Ziel. Er will einen riesigen Horst und eine reiche Schatzkammer, und den Himmel teilt er mit keinem zweiten Lindwurm. +best.knight.text = Das Rückgrat des Ordens: poliert, pünktlich und vollkommen frei von Fantasie. Ritter marschieren im Kreis, singen über die Sonne und riechen nach Stiefelwichse. | Sie schlagen hart zu und halten die Linie. Widerlich. Fang sie, wenn du kannst - manche lassen sich in der Folterkammer überzeugen. +best.archer.text = Bogenschützen stehen weit weg, schießen hinter Mauern hervor und nennen das Tapferkeit. Sie tragen kleine grüne Hütchen. Kleine grüne Hütchen, Meister. | Aus der Nähe schwach. Schick die Fledermäuse, das hassen sie. +best.priest.text = Priester heilen andere Helden aus der Ferne und beten so laut, dass die Milch sauer wird. Jeder von ihnen hat eine Predigt über uns, und keine davon ist kurz. | Erledige sie zuerst, sonst hört der Kampf nie auf. +best.dwarf.text = Die Zwergengräber des Ordens graben sich dreimal so schnell wie alle anderen durch die Erde, direkt auf deine Schatzkammer zu. Sie haben keinen Respekt vor Mauern - vor deinen schon gar nicht. | Verstärke deine Mauern. Bei Stahlwänden weinen sie. +best.paladin.text = Paladine des Lichts: leuchtend, riesig und vollkommen von sich überzeugt. Sie sind die Helden des Ordens und haben das Kopfgeld, um es zu beweisen. | Zäh wie ein Oger, doppelt so selbstgefällig. Bring alles mit, was du hast, und vielleicht einen Eimer gegen das Geblende. +best.cavedwarf.name = Höhlenzwerg +best.cavedwarf.text = Nicht alle Zwerge dienen dem Orden. Die Höhlenzwerge bleiben unter sich, in Höhlen voller Gold und Edelsteine, und wollen nur in Ruhe gelassen werden. | Sie kämpfen gegen jeden, der zu nahe kommt, und verlassen ihre Höhle nie. Je größer deine Armee wird, desto zäher werden sie - sie haben von dir gehört. Manche ihrer Kisten enthalten die Pläne des Runenschmiede-Turms. +best.b.treasury.text = Wo das Gold schläft. Je reicher die Schatzkammer, desto zufriedener alle. Imps bringen ihre Ladung zur nächsten Schatzkammer, und der Zahltag wird von hier bezahlt. | Jedes Feld fasst 500 Gold; ist alles voll, hält der Kern eine Reserve. +best.b.lair.text = Jede Kreatur will ihr eigenes Bett und kommt erst, wenn eines frei ist. Ein großes Quartier bringt außerdem schneller neue Kreaturen. +best.b.farm.text = Pilze! Saftig, bleich, leicht leuchtend. Hungrige Kreaturen sind grantige Kreaturen, und Spinnen lieben einen großen Garten. +best.b.training.text = Hier trainieren Kreaturen und steigen auf. Das Training wird am Zahltag abgerechnet. Oger kommen für eine große Halle. +best.b.library.text = Forschung, stilles Lesen und gelegentlich eine Explosion. Magier und Eulen kommen für eine Bibliothek. +best.b.torture.text = Gefangene Helden landen hier. Mit der Zeit und einem Kerkermeister laufen manche über. Gargoyles fühlen sich hier zu Hause. +best.b.temple.text = Gebete heilen den Seelenkern und heben die Stimmung. Die Flammenpriesterin kommt für einen Tempel. +best.b.aerie.text = Sitzstangen und Nester für Flieger. Fledermäuse, Gargoyles, Eulen und sogar der Glutlindwurm brauchen hier Platz. +best.b.bridge.text = Holz über Wasser, Stein über Lava. Die Ritter reißen deine Brücken ein - nimm dir ihre. +best.b.steel.text = Genietete Eisenplatten auf einer verstärkten Mauer. Da gräbt sich keiner schnell durch, nicht einmal Zwerge. +best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Ziel. Billig und zuverlässig. +best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava. +best.b.magic.text = Trifft immer, verlangsamt das Ziel und springt auf einen zweiten Feind über. Verbraucht Mana. +best.b.rune.text = Das Meisterstück der Höhlenzwerge: Runenblitze, die auf drei weitere Feinde überspringen, riesige Reichweite und Mauern aus Granit. Ein kostenloser Bau je Kiste; verkaufst du ihn, kommt er einmal zurück. diff --git a/lang/en.lang b/lang/en.lang index 58a4d62..119865a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -639,3 +639,78 @@ info.dwarf = Guards the hoard of a dwarf cave info.dwarf_neutral = Neutral: fights whoever comes close, never leaves the cave. tile.rune_chest = Dwarven rune chest tile.rune_chest_info = Claim it: the plans of a rune forge tower (one free build). + +# bestiary +menu.bestiary = Bestiary +best.title = Crookfinger's Bestiary +best.tab.creatures = Creatures +best.tab.humans = Humans +best.tab.dungeon = Dungeon +best.tab.ranks = Ranks +best.level = Lv {0} +best.hp = Health: {0} +best.attack = Attack: {0} every {1} s ({2}/s) +best.range = Range: {0} tiles +best.melee = Range: close combat +best.speed = Speed: {0} tiles/s +best.neutral = Neutral - guards its cave +best.bounty = Bounty: {0} gold +best.wage = Wage: {0} gold per payday +best.lure = Comes for: {0} +best.cost_rune = free from a dwarven chest (upgrades {0} / {1} gold) +best.rank_title = Level {0}: {1} +best.rank_bonus = +{0}% health and +{1}% attack compared to level 1 +best.badge.0 = Badge: none yet +best.badge.1 = Badge: bronze chevrons +best.badge.2 = Badge: silver bars +best.badge.3 = Badge: golden stars +best.gear.0 = No gear yet. Everybody starts in rags, master - even the greats. +best.gear.1 = Level 3-4: shoulder pieces. The first scrap of iron, worn with tremendous pride. +best.gear.2 = Level 5-6: a helmet as well. Harder to hit on the head, which mostly helps the head. +best.gear.3 = Level 7-8: a cape on top. Capes do nothing, but they flap, and flapping frightens heroes. +best.gear.4 = Level 9-10: everything in gold, with a crown, a halo and finally a glowing aura. Their attacks hit harder and look it too. +best.worker.lure = the dungeon itself; the summon spell brings more +best.worker.text = My little cousins. They dig, they claim, they carry gold, they repair towers and they never complain - well, they complain, but in a language nobody understands. | Up to five imps help each other with one task. Treat them well: without imps there is no dungeon. +best.kobold.lure = any lair: always the first through the portal +best.kobold.text = Small, scaly, sharp-toothed and endlessly enthusiastic. A kobold will fight anything, including things ten times its size, and lose with a happy face. | Cheap to keep, quick on its feet. I keep a jar of their shed scales on my shelf. +best.spider.lure = a mushroom garden of 6+ tiles +best.spider.text = Eight legs, eight eyes and a heart of pure velvet. Cave spiders love the damp warmth of a mushroom garden and the crunchy sound of armour. | Quick and tough. Do not let the knights see them first: knights scream in a very high voice. +best.mage.lure = a library of 6+ tiles +best.mage.text = Shadow mages read everything, forget nothing and hurl dark bolts from a safe distance. They are polite, quiet and slightly terrifying. | Fragile, so keep them behind the ogres. They pay you back in burnt paladins. +best.ogre.lure = a training hall of 9+ tiles +best.ogre.text = A stone ogre is a wall that walks and hugs. Mostly it hugs enemies, and they do not get up again. | Slow, stubborn and wonderfully sturdy. Feed it well, pay it on time and it will stand in front of your core until the end of the world. +best.jailer.lure = a torture chamber of 6+ tiles +best.jailer.text = The jailer pulls enemies close with his chain and has a gift for persuasion: prisoners he looks after change sides much more often. | Big, quiet, hums while he works. Children love him. Well, ours do. +best.priestess.lure = a temple of 6+ tiles +best.priestess.text = The flame priestess throws fireballs that splash onto everyone around the target, and she heals the soul core with her prayers. | She sleeps on glowing embers and smells of cinnamon. Do not ask her about the knights who burned her temple. Just let her work. +best.chainbreaker.lure = a big dungeon: 40+ room tiles, a Lv 5 veteran and 4000 gold +best.chainbreaker.text = A legend. Twin axes, broken chains, and a temper that has flattened whole fortresses. Only one at a time, and only for a master who is worth it. | Every swing also hits a second enemy. He gets restless when the dungeon is small - give him space and enemies, and he is yours. +best.bat.lure = an aerie of 4+ tiles +best.bat.text = Giant bats! Fast, cheeky, and they fly over water and lava. They patrol the edges of your realm and dive at lonely enemies. | Weak alone, a plague in swarms. They flee when badly hurt and come back once they have rested. +best.gargoyle.lure = an aerie of 6+ tiles and a torture chamber of 4+ +best.gargoyle.text = A chain gargoyle is carved stone with bad manners and a soft spot for dungeons with a proper torture chamber. | Tough as a castle wall, flies over rivers and does not mind arrows at all. It sits very still in the aerie - that is how it relaxes. +best.owl.lure = an aerie of 6+ tiles and a library of 6+ +best.owl.text = The arcane owl has read every book in your library twice and now shoots glowing bolts at heroes from the air. | Clever, fragile and terribly judgemental. It hoots when you build crooked rooms. +best.wyrm.lure = an aerie of 16+ tiles and a treasury of 16+ (only one) +best.wyrm.text = An ember wyrm. In your dungeon. Breathing fire on knights. Master, I may cry. | Very rare, very strong, burns everything around its target. It wants a huge aerie and a rich treasury, and it will not share the sky with another wyrm. +best.knight.text = The backbone of the Order: polished, punctual and utterly devoid of imagination. Knights march in circles, sing about the sun and smell of boot wax. | They hit hard and hold the line. Disgusting. Capture them if you can - some of them can be persuaded in the torture chamber. +best.archer.text = Archers stand far away, shoot from behind walls and call it bravery. They wear little green hats. Little green hats, master. | Weak up close. Send the bats, they hate it. +best.priest.text = Priests heal other heroes from afar and pray loudly enough to curdle milk. Every one of them has a sermon about us, and none of them is short. | Kill them first, or the fight never ends. +best.dwarf.text = The dwarf miners of the Order dig their way through earth three times as fast as anyone else, straight towards your treasury. They have no respect for walls - your walls in particular. | Reinforce your walls. Steel walls make them weep. +best.paladin.text = Paladins of light: glowing, enormous and absolutely convinced of themselves. They are the Order's heroes and have the bounty to prove it. | Tough as an ogre, twice as smug. Bring everything you have, and maybe a bucket for the glare. +best.cavedwarf.name = Cave dwarf +best.cavedwarf.text = Not all dwarves serve the Order. The cave dwarves keep to themselves in caves full of gold and gems and want nothing but to be left alone. | They fight whoever comes close and never leave their cave. The more your army grows, the tougher they get - they have heard of you. Some of their chests hold the plans of the rune forge tower. +best.b.treasury.text = Where the gold sleeps. The richer the treasury, the happier everyone is. Imps bring their loads to the nearest treasury, and payday is paid from here. | Each tile holds 500 gold; the core keeps a reserve when it is full. +best.b.lair.text = Every creature wants its own bed, and only arrives when one is free. A big lair also brings new creatures faster. +best.b.farm.text = Mushrooms! Juicy, pale, slightly glowing. Hungry creatures are grumpy creatures, and spiders love a big garden. +best.b.training.text = Creatures train here and gain levels. Training is billed on payday. Ogres come for a big hall. +best.b.library.text = Research, quiet reading and the occasional explosion. Mages and owls come for a library. +best.b.torture.text = Captured heroes end up here. With time and a jailer, some of them change sides. Gargoyles feel at home with one. +best.b.temple.text = Prayers heal the soul core and keep spirits high. The flame priestess comes for a temple. +best.b.aerie.text = Perches and nests for flying creatures. Bats, gargoyles, owls and even the ember wyrm need room here. +best.b.bridge.text = Wood over water, stone over lava. The knights tear down your bridges - claim theirs. +best.b.steel.text = Riveted iron plates on a reinforced wall. Nobody digs through quickly, not even dwarves. +best.b.arrow.text = The arrow pierces up to two more enemies behind the target. Cheap and reliable. +best.b.cannon.text = Splash damage and knockback - push the knights into the lava. +best.b.magic.text = Never misses, slows the target and jumps on to a second enemy. Uses mana. +best.b.rune.text = The masterpiece of the cave dwarves: rune bolts that jump on to three more enemies, enormous range and walls of granite. One free build per chest; sell it and it comes back once. diff --git a/src/game.rs b/src/game.rs index 8d62a55..eecbffc 100644 --- a/src/game.rs +++ b/src/game.rs @@ -375,6 +375,7 @@ pub struct Fx { pub col: u32, pub h: f32, // height of the start point (3D) pub h2: f32, // height of the end point (projectiles) + pub size: f32, // 1 = normal; attacks of higher levels look bigger } #[derive(Serialize, Deserialize)] @@ -916,7 +917,7 @@ impl Game { pub fn fx(&mut self, kind: u8, x: f32, y: f32, x2: f32, y2: f32, max: f32, col: u32) { let h = if kind == 0 { 0.45 } else { 0.4 }; if self.fx.len() < 400 { - self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45 }); + self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45, size: 1.0 }); } } @@ -924,14 +925,21 @@ impl Game { #[allow(clippy::too_many_arguments)] pub fn shot(&mut self, x: f32, y: f32, h: f32, x2: f32, y2: f32, h2: f32, max: f32, col: u32) { if self.fx.len() < 400 { - self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2 }); + self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0 }); + } + } + + // the last effect gets bigger (attacks of experienced units) + fn grow_last_fx(&mut self, size: f32) { + if let Some(f) = self.fx.last_mut() { + f.size = size; } } // effect at a given height pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) { if self.fx.len() < 400 { - self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h }); + self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0 }); } } @@ -1806,6 +1814,8 @@ impl Game { let dmg = e.atk() * (0.85 + self.rng.f() * 0.3); let (tx, ty) = (self.ents[j].x, self.ents[j].y); let s = st(e.kind); + // the higher the level, the bigger the attack looks (up to almost twice at Lv 10) + let lv = 1.0 + 0.1 * (e.level.max(1) - 1) as f32; if s.range > 1.5 { let col = match e.kind { EK::Mage => 0xffff44cc, @@ -1818,9 +1828,11 @@ impl Game { // flyers shoot from the air let h = if flies(e.kind) { 0.8 } else { 0.45 }; self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col); + self.grow_last_fx(lv); self.sfx(Sfx::Shoot, e.x, e.y); } else { - self.fx(2, tx, ty, 0.0, 0.0, 0.25, 0xffffffff); + self.fx(2, tx, ty, 0.0, 0.0, 0.25, if e.level >= 9 { 0xff60e0ff } else { 0xffffffff }); + self.grow_last_fx(lv); self.sfx(Sfx::MeleeHit, tx, ty); } e.xp += dmg * 0.6; @@ -1836,6 +1848,7 @@ impl Game { if let Some(o) = second { let (ox, oy) = (self.ents[o].x, self.ents[o].y); self.fx(2, ox, oy, 0.0, 0.0, 0.25, 0xff40a0ff); + self.grow_last_fx(lv); self.ents[o].hp -= dmg * 0.5; self.ents[o].flash = 0.15; if self.ents[o].hp <= 0.0 { @@ -1846,6 +1859,7 @@ impl Game { if e.kind == EK::Priestess || e.kind == EK::Wyrm { // fireball / fire breath: half damage to enemies around the target self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); + self.grow_last_fx(lv); let me = e.side(); let splash: Vec = self .ents diff --git a/src/main.rs b/src/main.rs index 28bb402..c66c843 100644 --- a/src/main.rs +++ b/src/main.rs @@ -994,6 +994,7 @@ async fn main() { | Some(Page::Briefing { ingame: false, .. }) | Some(Page::Login) | Some(Page::Account) + | Some(Page::Bestiary { from_title: true, .. }) ); music.update(&app.game, on_title, (app.cam.tx, app.cam.tz)); for m in app.game.msgs.drain(..) { @@ -1049,6 +1050,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M | Some(Page::Briefing { ingame: false, .. }) | Some(Page::Login) | Some(Page::Account) + | Some(Page::Bestiary { 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 4b4bd75..8b2294a 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -12,6 +12,8 @@ use crate::settings::{all_actions, key_name, Action, Settings}; use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; +mod bestiary; + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Page { Title, @@ -24,6 +26,7 @@ pub enum Page { Briefing { mission: usize, ingame: bool }, // story and goals of a mission Login, // login to the savegame service (before the title) Account, // password, logout, users (admin) + Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks } pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; @@ -214,6 +217,11 @@ enum Btn { Logout, CreateUser, DeleteUser(usize), + Bestiary, + BestTab(usize), + BestSel(i32), + BestPick(usize), + BestLevel(i32), } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; @@ -325,6 +333,8 @@ impl Menu { Some(Page::Campaign) => Some(Page::Title), Some(Page::Login) => Some(Page::Login), Some(Page::Account) => Some(Page::Title), + Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title), + Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign), Some(Page::Pause) => None, @@ -547,6 +557,14 @@ impl Menu { } MenuAction::Consumed } + Some(Btn::Bestiary) => { + self.open_bestiary(from_title); + MenuAction::Consumed + } + Some(b @ (Btn::BestTab(_) | Btn::BestSel(_) | Btn::BestPick(_) | Btn::BestLevel(_))) => { + self.bestiary_click(b); + MenuAction::Consumed + } Some(Btn::FileSave) => MenuAction::FileSave, Some(Btn::FileLoad) => MenuAction::FileLoad, } @@ -568,6 +586,7 @@ impl Menu { Page::Login => self.draw_login(gfx, cloud), Page::Account => self.draw_account(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), + Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 4); self.buttons.clear(); @@ -629,6 +648,7 @@ impl Menu { items.push((Btn::Account, trf("menu.account", &[s.name.clone()]))); items.push((Btn::Standalone, tr("menu.standalone"))); } + items.push((Btn::Bestiary, tr("menu.bestiary"))); items.push((Btn::Help, tr("menu.help"))); let w = 26.0 * CW(); let y = ty + GH * big + CH() * 4.0; @@ -725,6 +745,7 @@ impl Menu { (Btn::Save, tr("menu.save")), (Btn::Load, tr("menu.load")), (Btn::Settings, tr("menu.settings")), + (Btn::Bestiary, tr("menu.bestiary")), (Btn::Help, tr("menu.help")), (Btn::MainMenu, tr("menu.main")), ]); diff --git a/src/menu/bestiary.rs b/src/menu/bestiary.rs new file mode 100644 index 0000000..30427a5 --- /dev/null +++ b/src/menu/bestiary.rs @@ -0,0 +1,286 @@ +// The bestiary: every unit with its stats at each level, the buildings of the dungeon and the +// ranks. Crookfinger writes about his creatures with love and about the humans with disgust. + +use super::*; +use crate::game::{st, tower_stats, TowerKind, EK, ROOM_COST}; +use crate::ui::{tool_cost_static, BLUE as BLUE_C, GREEN as GREEN_C, RED as RED_C}; + +pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"]; + +const CREATURES: [EK; 12] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm]; +const HUMANS: [EK; 5] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin]; +// buildings: indices into ui::TOOLS +const BUILDINGS: [usize; 14] = [2, 3, 4, 5, 6, 12, 13, 18, 17, 19, 14, 15, 16, 20]; + +#[derive(Clone, Copy)] +enum Entry { + Unit(EK, bool), // kind, a cave dwarf (neutral) instead of the human dwarf + Building(usize), + Rank(u32), +} + +fn entries(tab: usize) -> Vec { + match tab { + 0 => CREATURES.iter().map(|&k| Entry::Unit(k, false)).collect(), + 1 => { + let mut v: Vec = HUMANS.iter().map(|&k| Entry::Unit(k, false)).collect(); + v.push(Entry::Unit(EK::Dwarf, true)); + v + } + 2 => BUILDINGS.iter().map(|&i| Entry::Building(i)).collect(), + _ => (1..=10).map(Entry::Rank).collect(), + } +} + +// language key part of a unit ("unit.kobold" -> "kobold") +fn unit_id(k: EK, cave: bool) -> String { + if cave { + "cavedwarf".into() + } else { + st(k).name.trim_start_matches("unit.").to_string() + } +} + +fn entry_name(e: Entry) -> String { + match e { + Entry::Unit(_, true) => tr("best.cavedwarf.name"), + Entry::Unit(k, false) => tr(st(k).name), + Entry::Building(i) => tool_info(TOOLS[i]).name, + Entry::Rank(l) => format!("{:>2} {}", l, tr(&format!("rank.{}", l))), + } +} + +fn tower_kind(i: usize) -> Option { + match TOOLS[i] { + crate::ui::Tool::Tower(k) => Some(k), + _ => None, + } +} + +// the highest level an entry can be shown at +fn max_level(e: Entry) -> u32 { + match e { + Entry::Unit(..) => 10, + Entry::Building(i) if tower_kind(i).is_some() => 3, + _ => 1, + } +} + +impl Menu { + pub(super) fn open_bestiary(&mut self, from_title: bool) { + self.page = Some(Page::Bestiary { tab: 0, sel: 1, level: 1, from_title }); + self.scroll = 0; + } + + pub(super) fn bestiary_click(&mut self, b: Btn) { + let Some(Page::Bestiary { tab, sel, level, from_title }) = self.page else { return }; + let page = match b { + Btn::BestTab(t) => Page::Bestiary { tab: t, sel: 0, level: 1, from_title }, + Btn::BestSel(d) => { + let n = entries(tab).len() as i32; + let s = (sel as i32 + d).rem_euclid(n) as usize; + Page::Bestiary { tab, sel: s, level: level.min(max_level(entries(tab)[s])).max(1), from_title } + } + Btn::BestPick(s) => Page::Bestiary { tab, sel: s, level: level.min(max_level(entries(tab)[s])).max(1), from_title }, + Btn::BestLevel(d) => { + let max = max_level(entries(tab)[sel]) as i32; + Page::Bestiary { tab, sel, level: (level as i32 + d).clamp(1, max) as u32, from_title } + } + _ => return, + }; + self.scroll = 0; + self.page = Some(page); + } + + pub(super) fn draw_bestiary(&mut self, gfx: &Gfx, tab: usize, sel: usize, level: u32) { + let (sw, sh) = (sw(), sh()); + let (mx, my) = mouse(); + let cols = ((sw / CW()) as i32 - 2).min(78); + let rows = ((sh / CH()) as i32 - 2).min(42); + 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("best.title"), FRAME_HI); + // tabs + let tw = ((cols - 4) / TABS.len() as i32) as f32 * CW(); + for (k, key) in TABS.iter().enumerate() { + let r = Rect::new(x + CW() * 2.0 + k as f32 * tw, y + CH() * 1.5, tw - CW() * 0.5, CH() * 2.0); + let label = tr(key); + let label: String = label.chars().take(((r.w / CW()) as usize).saturating_sub(1)).collect(); + button(gfx, r, &label, r.contains(vec2(mx, my)) || k == tab, if k == tab { FRAME_HI } else { FRAME }); + self.buttons.push((r, Btn::BestTab(k))); + } + let list = entries(tab); + let sel = sel.min(list.len() - 1); + let entry = list[sel]; + let top = y + CH() * 4.5; + let bottom = y + (rows - 4) as f32 * CH(); + // the list on the left (wide screens) or a selector row (narrow screens) + let wide = cols >= 60; + let (dx, dw) = if wide { + let lw = 22.0 * CW(); + let n_vis = (((bottom - top) / (CH() * 1.5)).floor() as usize).max(1); + let first = sel.saturating_sub(n_vis.saturating_sub(1)).min(list.len().saturating_sub(n_vis)); + for (k, e) in list.iter().enumerate().skip(first).take(n_vis) { + let r = Rect::new(x + CW() * 2.0, top + (k - first) as f32 * CH() * 1.5, lw, CH() * 1.4); + let hot = r.contains(vec2(mx, my)); + if k == sel || hot { + draw_rectangle(r.x, r.y, r.w, r.h, if k == sel { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.18, 0.12, 0.09, 1.0) }); + } + let name: String = entry_name(*e).chars().take(21).collect(); + gfx.text(&name, r.x + CW() * 0.5, r.y + CH() * 0.2, S(), if k == sel { FRAME_HI } else { TXT }); + self.buttons.push((r, Btn::BestPick(k))); + } + (x + lw + CW() * 4.0, (cols as f32 - 28.0) * CW()) + } else { + let r_prev = Rect::new(x + CW() * 2.0, top, CW() * 3.0, CH() * 2.0); + let r_next = Rect::new(x + (cols - 5) as f32 * CW(), top, CW() * 3.0, CH() * 2.0); + button(gfx, r_prev, "\u{25c4}", r_prev.contains(vec2(mx, my)), FRAME); + button(gfx, r_next, "\u{25ba}", r_next.contains(vec2(mx, my)), FRAME); + self.buttons.push((r_prev, Btn::BestSel(-1))); + self.buttons.push((r_next, Btn::BestSel(1))); + let name = format!("{} ({}/{})", entry_name(entry), sel + 1, list.len()); + gfx.text(&name, grid_center(sw, gfx.measure(&name, S())), top + CH() * 0.5, S(), FRAME_HI); + (x + CW() * 2.0, (cols as f32 - 4.0) * CW()) + }; + let dy = if wide { top } else { top + CH() * 3.0 }; + self.draw_entry(gfx, entry, level, dx, dy, dw, bottom); + // back + let by = y + (rows - 3) as f32 * CH(); + let back = tr("menu.back"); + let br = Rect::new(x + CW() * 2.0, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0); + button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME); + self.buttons.push((br, Btn::Back)); + } + + #[allow(clippy::too_many_arguments)] + fn draw_entry(&mut self, gfx: &Gfx, entry: Entry, level: u32, x: f32, y: f32, w: f32, bottom: f32) { + let (mx, my) = mouse(); + let k = (S() * 6.0).floor().max(3.0); // pixel size of the big picture + let pic = 12.0 * k; + draw_rectangle(x, y, pic + 2.0 * k, pic + 2.0 * k, Color::new(0.1, 0.07, 0.07, 1.0)); + draw_rectangle_lines(x, y, pic + 2.0 * k, pic + 2.0 * k, 2.0, FRAME); + let show_level = match entry { + Entry::Rank(l) => l, + _ => level, + }; + match entry { + Entry::Unit(kind, _) => draw_sprite(gfx, kind, show_level, x + k, y + k, pic), + Entry::Rank(l) => draw_sprite(gfx, EK::Ogre, l, x + k, y + k, pic), + Entry::Building(i) => { + let icon = tool_info(TOOLS[i]).icon; + draw_texture_ex(&gfx.atlas, x + k * 2.0, y + k * 2.0, WHITE, DrawTextureParams { source: Some(Gfx::cell(icon)), dest_size: Some(vec2(pic - 2.0 * k, pic - 2.0 * k)), ..Default::default() }); + } + } + // level selector with the rank badge under the picture + let ly = y + pic + 3.0 * k; + let maxl = max_level(entry); + if maxl > 1 { + let r_dn = Rect::new(x, ly, CW() * 2.5, CH() * 2.0); + let r_up = Rect::new(x + pic + 2.0 * k - CW() * 2.5, ly, CW() * 2.5, CH() * 2.0); + button(gfx, r_dn, "-", r_dn.contains(vec2(mx, my)), FRAME); + button(gfx, r_up, "+", r_up.contains(vec2(mx, my)), FRAME); + self.buttons.push((r_dn, Btn::BestLevel(-1))); + self.buttons.push((r_up, Btn::BestLevel(1))); + let lv = trf("best.level", &[level.to_string()]); + gfx.text(&lv, x + (pic + 2.0 * k - gfx.measure(&lv, S())) * 0.5, ly + CH() * 0.5, S(), GOLD); + } + if let Entry::Unit(..) | Entry::Rank(_) = entry { + let bk = (S() * 2.0).max(2.0); + gfx.badge(x + k + bk, y + k + bk, show_level, bk); + } + // facts beside the picture, the text below + let fx = x + pic + 4.0 * k; + let fw = ((w - (fx - x)) / CW()).max(10.0) as usize; + let mut facts: Vec<(String, Color)> = Vec::new(); + let text_key; + match entry { + Entry::Unit(kind, cave) => { + let s = st(kind); + let l = (level.max(1) - 1) as f32; + let hp = s.hp * (1.0 + 0.15 * l); + let atk = s.atk * (1.0 + 0.12 * l); + facts.push((entry_name(entry), FRAME_HI)); + facts.push((trf("info.rank", &[tr(&format!("rank.{}", level.clamp(1, 10)))]), GOLD)); + facts.push((trf("best.hp", &[(hp as i32).to_string()]), GREEN_C)); + facts.push((trf("best.attack", &[format!("{:.0}", atk), format!("{:.1}", s.interval), format!("{:.0}", atk / s.interval)]), RED_C)); + let range = if s.range > 1.5 { trf("best.range", &[format!("{:.1}", s.range)]) } else { tr("best.melee") }; + facts.push((range, TXT)); + facts.push((trf("best.speed", &[format!("{:.1}", s.speed)]), TXT)); + let id = unit_id(kind, cave); + if cave { + facts.push((tr("best.neutral"), Color::new(0.9, 0.6, 0.3, 1.0))); + } else if HUMANS.contains(&kind) { + facts.push((trf("best.bounty", &[s.bounty.to_string()]), GOLD)); + } else if s.wage > 0 { + facts.push((trf("best.wage", &[((s.wage as f32 * (1.0 + 0.1 * l)) as i32).to_string()]), GOLD)); + } + if !HUMANS.contains(&kind) && !cave { + facts.push((trf("best.lure", &[tr(&format!("best.{}.lure", id))]), BLUE_C)); + } + text_key = format!("best.{}.text", id); + } + Entry::Building(i) => { + let ti = tool_info(TOOLS[i]); + facts.push((ti.name.clone(), FRAME_HI)); + if let Some(kind) = tower_kind(i) { + let s = tower_stats(kind); + let l = (level.max(1) - 1) as usize; + facts.push((trf("best.hp", &[((s.hp * [1.0, 1.4, 1.9][l]) as i32).to_string()]), GREEN_C)); + facts.push((trf("best.attack", &[format!("{:.0}", s.dmg * [1.0, 1.5, 2.2][l]), format!("{:.1}", s.interval), format!("{:.0}", s.dmg * [1.0, 1.5, 2.2][l] / s.interval)]), RED_C)); + facts.push((trf("best.range", &[format!("{:.1}", s.range + 0.75 * l as f32)]), TXT)); + } + let cost = match TOOLS[i] { + crate::ui::Tool::Room(r) => trf("cost.gold_tile", &[ROOM_COST[r].to_string()]), + t => tool_cost_static(t), + }; + if !cost.is_empty() { + facts.push((trf("ui.cost", &[cost]), GOLD)); + } + for l in wrap(&ti.desc, fw) { + facts.push((l, DIM)); + } + let id = match TOOLS[i] { + crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(), + crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(), + crate::ui::Tool::Bridge => "bridge".into(), + _ => "steel".into(), + }; + text_key = format!("best.b.{}.text", id); + } + Entry::Rank(l) => { + facts.push((trf("best.rank_title", &[l.to_string(), tr(&format!("rank.{}", l))]), FRAME_HI)); + facts.push((trf("best.rank_bonus", &[format!("{}", 15 * (l - 1)), format!("{}", 12 * (l - 1))]), GREEN_C)); + facts.push((tr(&format!("best.badge.{}", match l { 1 => 0, 2..=4 => 1, 5..=7 => 2, _ => 3 })), GOLD)); + text_key = format!("best.gear.{}", crate::gfx::gear_tier(l)); + } + } + let mut fy = y; + for (l, c) in facts.iter() { + for part in wrap(l, fw) { + if fy + CH() > bottom { + break; + } + gfx.text(&part, fx, fy, S(), *c); + fy += CH(); + } + } + let mut ty = (ly + CH() * 3.0).max(fy + CH()); + let tw = (w / CW()).max(10.0) as usize; + for para in tr(&text_key).split(" | ") { + for l in wrap(para, tw) { + if ty + CH() > bottom { + return; + } + gfx.text(&l, x, ty, S(), TXT); + ty += CH(); + } + ty += CH() * 0.5; + } + } +} + +// a unit sprite with the gear of a level, scaled up +fn draw_sprite(gfx: &Gfx, kind: EK, level: u32, x: f32, y: f32, size: f32) { + let src = Gfx::sprite_cell(kind, false, level); + draw_texture_ex(&gfx.sprites, x, y, WHITE, DrawTextureParams { source: Some(src), dest_size: Some(vec2(size, size)), ..Default::default() }); +} diff --git a/src/render3d.rs b/src/render3d.rs index 8b031c9..817729b 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1692,9 +1692,13 @@ fn draw_fx(f: &Fx, t: f32) { let from = vec3(f.x, f.h, f.y); let to = vec3(f.x2, f.h2, f.y2); let head = from.lerp(to, p); - let tail = from.lerp(to, (p - 0.35).max(0.0)); - line3(tail, head, 0.06, a(0.8)); - draw_sphere(head, 0.08, None, c); + let tail = from.lerp(to, (p - 0.35 * f.size.min(1.5)).max(0.0)); + line3(tail, head, 0.06 * f.size, a(0.8)); + draw_sphere(head, 0.08 * f.size, None, c); + if f.size > 1.35 { + // experienced shooters: a glowing halo around the projectile + draw_sphere(head, 0.16 * f.size, None, a(0.25)); + } } 1 => { let mut prev = vec3(f.x, 7.0, f.y); @@ -1708,13 +1712,17 @@ fn draw_fx(f: &Fx, t: f32) { disc(vec3(f.x, 0.05, f.y), 1.6 * (0.5 + p), Color::new(1.0, 1.0, 0.7, 0.35 * (1.0 - p)), 24); } 2 => { - let r = 0.1 + 0.3 * p; - for k in 0..6 { - let ang = k as f32 * 1.047; - line3(at, at + vec3(ang.cos() * r, (k as f32 - 2.5) * 0.06, ang.sin() * r), 0.04, a(1.0 - p)); + let r = (0.1 + 0.3 * p) * f.size; + let n = if f.size > 1.35 { 10 } else { 6 }; + for k in 0..n { + let ang = k as f32 * std::f32::consts::TAU / n as f32; + line3(at, at + vec3(ang.cos() * r, (k as f32 - n as f32 * 0.5 + 0.5) * 0.06 * f.size, ang.sin() * r), 0.04 * f.size, a(1.0 - p)); + } + if f.size > 1.6 { + disc(vec3(f.x, 0.03, f.y), r * 1.3, a(0.3 * (1.0 - p)), 12); } } - 3 => draw_sphere(vec3(f.x, 0.3, f.y), 0.2 + 0.5 * p, None, a(0.4 * (1.0 - p))), + 3 => draw_sphere(vec3(f.x, 0.3, f.y), (0.2 + 0.5 * p) * f.size, None, a(0.4 * (1.0 - p))), 4 => { for k in 0..3 { let o = vec3((k as f32 - 1.0) * 0.2, 0.3 + p * 0.9 + k as f32 * 0.1, 0.0); diff --git a/src/ui.rs b/src/ui.rs index f198bcd..e8437ff 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -223,6 +223,27 @@ pub fn tool_info(t: Tool) -> ToolInfo { ToolInfo { label: tr(&format!("tool.{}.label", id)), name, desc: tr(&format!("tool.{}.desc", id)), icon } } +// costs that do not depend on the game (bestiary) +pub fn tool_cost_static(t: Tool) -> String { + match t { + Tool::Bridge => trf("cost.bridge", &[BRIDGE_COST[0].to_string(), BRIDGE_COST[1].to_string()]), + Tool::Steel => trf("cost.steel", &[STEEL_COST[0].to_string(), STEEL_COST[1].to_string()]), + Tool::Tower(TowerKind::Rune) => { + let s = tower_stats(TowerKind::Rune); + trf("best.cost_rune", &[s.cost[1].to_string(), s.cost[2].to_string()]) + } + Tool::Tower(k) => { + let s = tower_stats(k); + let mut c = trf("cost.tower", &[s.cost[0].to_string(), s.cost[1].to_string(), s.cost[2].to_string()]); + if s.mana > 0.0 { + c += &trf("cost.per_shot", &[(s.mana as i32).to_string()]); + } + c + } + _ => String::new(), + } +} + pub fn tool_cost(t: Tool, g: &Game) -> String { match t { Tool::Room(r) => trf("cost.gold_tile", &[ROOM_COST[r].to_string()]),