From 09e22b009e6d21984d0dde06302cf80dcfd2e544 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 11:23:44 +0100 Subject: [PATCH] Campaign menu and story: mission list, briefings, goals panel, mission end flow - Title screen: Campaign with the list of the 20 missions (done / open / locked) - Briefing per mission: story told by old Crookfinger (EN/DE), goals, new tools, creatures and gold carried over; start button - In the game: goals panel with progress (click/tap opens the briefing again), the pause menu has a Briefing entry to read the whole briefing again - Mission end: next mission / try again and back to the campaign; won missions and the carry-over are stored in the browser; epilogue after the last mission Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 72 ++++++++++++++++ lang/en.lang | 72 ++++++++++++++++ src/main.rs | 69 +++++++++++++-- src/menu.rs | 233 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 116 ++++++++++++++++++++----- 5 files changed, 523 insertions(+), 39 deletions(-) diff --git a/lang/de.lang b/lang/de.lang index 743807c..a1f1ab4 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -430,3 +430,75 @@ msg.mission_won = Mission erfüllt! msg.time_up = Die Zeit ist um - die Ritter haben ihre Schätze in Sicherheit gebracht. hud.time_left = Zeit msg.tool_locked = Das steht in dieser Mission noch nicht zur Verfügung. + +# campaign menu and goals +menu.campaign = Kampagne +menu.goals = Briefing +camp.goals_panel = Ziele (Klick: Briefing) +camp.title = Der gebrochene Kern +camp.locked = ??? +camp.progress = {0} von {1} Missionen erfüllt +camp.mission_title = Mission {0}: {1} +camp.goals = Ziele +camp.time_limit = Zeitlimit: {0} +camp.new_tools = Neu: {0} +camp.carry = Aus der letzten Mission kommen mit: {0} Kreaturen und {1} Gold. +camp.start = Starten +goal.shards = Hole die Splitter zurück +goal.heart = Zerstöre das Tor des Ordens +goal.killall = Besiege alle Ritter +goal.loot = Erbeute {0} aus den Ritterschätzen +goal.towers = Reiße alle Rittertürme nieder +goal.survive = Schlage {0} Wellen zurück +goal.left = (noch {0}) +end.mission_won = Mission erfüllt! +end.mission_won_sub = Krummfinger nickt anerkennend. Deine stärksten Kreaturen und ein Fünftel des Goldes ziehen mit in die nächste Mission. +end.mission_lost = Mission gescheitert +end.mission_lost_sub = Die Ritter feiern. Krummfinger schlägt vor, es noch einmal zu versuchen - die Schaufel hat er schon geschärft. +end.again = Nochmal +end.next = Nächste Mission +end.campaign = Kampagne +end.to_campaign = Zur Kampagne + +# campaign story: told by old Crookfinger, the imp advisor (" | " starts a new paragraph) +camp.m01.title = Ein Splitter im Dunkeln +camp.m01.story = Aufwachen, Meister. Ja, du. Vor hundert Jahren hat der Orden der Lichtwacht deinen Seelenkern zerschlagen und die Splitter wie Trophäen davongetragen. Was von dir übrig ist, passt in eine kleine Höhle, vier Wichte und mich - den alten Krummfinger. | Den ersten Splitter verwahren die Ritter in einem kleinen Außenposten im Osten, bewacht von Rekruten, die noch an polierte Stiefel glauben. Grabe dich hin und lass deine Wichte die Splitterkammer erobern. Ritterboden zu erobern dauert, und es geht nicht, solange Ritter danebenstehen. +camp.m02.title = Der Grenzbach +camp.m02.story = Ein Bach! Unterirdisch! Die Ritter glauben, ein bisschen Wasser hält uns auf. Reizend. | Bau eine Brücke hinüber - Holz ist billig, Ritterstolz nicht. Der Wachposten am anderen Ufer hütet den nächsten Splitter, und diesmal hat er einen Pfeilturm. Türme laufen dir nicht nach: Bleib außer Reichweite oder lass deine Kreaturen sie einreißen. +camp.m03.title = Die Wachstube +camp.m03.story = Die Lichtwacht unterhält hier eine Wachstube, in der junge Ritter lernen, im Kreis zu marschieren. Sie nennen es Patrouille. Wir nennen es Fahrplan. | Lerne ihn, schlag zu, wenn sie dir den Rücken zukehren, und mach die ganze Besatzung nieder. Endlich hast du einen Trainingsraum - Training macht Kreaturen stärker, aber die Rechnung kommt am Zahltag. +camp.m04.title = Der Orden schlägt zurück +camp.m04.story = Nun, das hat sie aufgeschreckt. Der Orden schickt Welle um Welle aus seinem Tor, um das kleine Feuer im Dunkeln auszutreten - uns. | Vier Wellen, dann gehen ihnen die Freiwilligen aus, oder zumindest die Begeisterung. Baue Pfeiltürme, halte den Blitz bereit und lass sie nicht an den Kern. +camp.m05.title = Die Goldkammern von Hellwacht +camp.m05.story = In Hellwacht verwahrt der Orden seine Soldtruhen, was erklärt, warum die Ritter hier so gut gelaunt sind: Sie werden bezahlt. | Ihre Schatzkammern bergen Gold, Silber und sogar Edelsteine. Erobere die Schatzkammern, und der Schatz gehört dir. Sechstausend sollten unsere Ausgaben decken. Und unsere gekränkten Gefühle. +camp.m06.title = Die Brücke der Lichtwacht +camp.m06.story = Die Lichtwacht hat eine Brücke über den großen Fluss gebaut und an ihr Ende ein Tor gesetzt, mit einem Banner, auf dem eine Sonne prangt. Natürlich eine Sonne. | Zerstöre das Tor des Ordens, und der Posten versinkt in höflicher Panik. Übrigens haben sie Kanonen dabei. Kanonen schubsen deine Kreaturen herum - halte sie dicht beisammen. +camp.m07.title = Lavagrund +camp.m07.story = Lavagrund. Warm, gemütlich, ein wenig zu hell. Holzbrücken würden hier brennen, also baust du steinerne: teurer, aber feuerfest. | Was in die Lava geschubst wird, ist für immer weg, Ritter eingeschlossen. Vor allem Ritter. Der Splitter liegt in der Festung hinter dem Glühen. Außerdem steht dir jetzt eine Folterkammer offen - manche Gefangene lassen sich zum Seitenwechsel überreden. +camp.m08.title = Der Raubzug +camp.m08.story = Ein Raubzug! Heute kein Splitter, nur Bares. Zwei Schatzfesten, vollgestopft mit dem Zehnten des Ordens. | Du hast fünfzehn Minuten, bevor sie ihre Schätze in Sicherheit bringen, und ein paar angeheuerte Schläger für den Anfang. Erbeute achttausend aus ihren Schatzkammern. Schnell, Meister - Krummfinger hat Schulden. +camp.m09.title = Die Zwillingsfesten +camp.m09.story = Zwei Festen an beiden Ufern eines Flusses, jede hütet einen Splitter. Die Ritter nennen sie die Brüder. Der eine Bruder sorgt sich um seine Stiefel, der andere um seine Frisur. | Hol dir beide Splitter. Über den Fluss führen Ritterbrücken: Erobere sie, sonst kommen die Ritter darüber zu Besuch. +camp.m10.title = Die Belagerung +camp.m10.story = Der Orden ist wütend. Richtig wütend, mit Fanfaren. Sechs Wellen werden gegen deine Hallen ziehen. | Tempel und Magieturm sind bereit - bete, zahle und halte stand. Glüht der Kern nach der sechsten Welle noch, brauchen sie ein neues Werbelied. +camp.m11.title = Patrouillenpfade +camp.m11.story = Diese Festung läuft wie ein Uhrwerk: Vier Patrouillen marschieren um den Innenhof, und jede Ecke wird von einem Turm beäugt. | Studiere ihre Runden, schneide sie einzeln ab, und wenn sich nichts in silberner Rüstung mehr regt, gehört der Ort uns. +camp.m12.title = Feuer und Wasser +camp.m12.story = Feuer auf der einen Seite, Wasser auf der anderen, und dazwischen eine Festung mit Splitter und Tor. Die Ritter haben sie zum Angeben gebaut. | Überquere beide Flüsse, zerstöre das Tor des Ordens und nimm dir den Splitter. Und achte darauf, auf welcher Brücke du gerade stehst. +camp.m13.title = Das Schatzhaus des Ordens +camp.m13.story = Das Schatzhaus des Ordens: dreizehntausend in den Kammern - ihre ganze Altersvorsorge. | Schatzkammern zu erobern macht Lärm, die Besatzung wird angerannt kommen. Plane entsprechend. Oder lass es und genieß das Feuerwerk. +camp.m14.title = Der Turmring +camp.m14.story = Acht Türme im Ring, bis ganz oben ausgebaut. Der Kommandant hier glaubt an Mauern und Pfeile wie Wichte an das Mittagessen. | Reiße jeden einzelnen Turm nieder. Deine Kreaturen greifen Türme in ihrer Reichweite an - schick sie mit der Flagge und halte den Heilzauber bereit. +camp.m15.title = Unter dem Strom +camp.m15.story = Hier unten gibt es kein Portal. Keine neuen Rekruten. Nur was du mitbringst, was ich anheuern konnte, und eine Festung jenseits eines kalten, reißenden Flusses. | Besiege jeden Ritter mit der Armee, die du hast. Jede Kreatur zählt - halte sie satt, bezahlt und wütend. +camp.m16.title = Gegenschlag +camp.m16.story = Jenseits der Lava schickt das Tor des Ordens Welle um Welle, und es hört erst auf, wenn das Tor fällt. | Halte deine Stellung, dann stoß vor. Brich das Tor, und ihr Gegenangriff stirbt mit ihm. +camp.m17.title = Die Brückenstadt +camp.m17.story = Eine ganze Stadt auf Brücken: drei Flüsse, zwei Festen, zwei Splitter und reichlich Schätze. Ritter lieben Brücken - deine reißen sie ein, ihre eigenen behalten sie. | Hol dir beide Splitter und sechstausend aus ihren Schatzkammern. +camp.m18.title = Die Lavazitadelle des Marschalls +camp.m18.story = Marschall Gerold der Unbeugsame thront in seiner Lavazitadelle, ein Paladin, der sich noch nie gebeugt hat - weder sein Knie noch seine Meinung. | Sein Tor bewacht den Weg zum Großmeister. Brich es. Gerold wird versuchen, dich zuerst zu brechen. +camp.m19.title = Die letzte Wacht +camp.m19.story = Drei Festen hüten die letzten Splitter vor der Zitadelle. Der Orden hat alles hineingeworfen, was ihm geblieben ist, einschließlich seiner letzten guten Laune. | Hol dir alle drei Splitter. Danach steht nur noch ein alter Mann in glänzender Rüstung zwischen dir und deinem Kern. +camp.m20.title = Der Großmeister +camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitadelle jenseits des großen Flusses, mit dem letzten Splitter und dem Tor des Ordens. Hundert Jahre lang hat er gebetet, poliert und sich vorbereitet. | Zerstöre sein Tor, hol dir den Splitter und mach deinen Kern wieder ganz. Ich bin direkt hinter dir. Ein gutes Stück hinter dir. +camp.epilogue = Der letzte Splitter versinkt im Kern, und die Dunkelheit atmet wieder. Irgendwo dort oben schreibt der Orden der Lichtwacht einen sehr langen Bericht. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir. diff --git a/lang/en.lang b/lang/en.lang index 539b040..637f51f 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -431,3 +431,75 @@ msg.mission_won = Mission accomplished! msg.time_up = Time is up - the knights have secured their treasures. hud.time_left = Time msg.tool_locked = This is not available yet in this mission. + +# campaign menu and goals +menu.campaign = Campaign +menu.goals = Briefing +camp.goals_panel = Goals (click: briefing) +camp.title = The Broken Core +camp.locked = ??? +camp.progress = {0} of {1} missions accomplished +camp.mission_title = Mission {0}: {1} +camp.goals = Goals +camp.time_limit = Time limit: {0} +camp.new_tools = New: {0} +camp.carry = Coming along from the last mission: {0} creatures and {1} gold. +camp.start = Start +goal.shards = Take back the shards +goal.heart = Destroy the gate of the order +goal.killall = Defeat all knights +goal.loot = Take {0} from the knight vaults +goal.towers = Raze all knight towers +goal.survive = Beat {0} waves +goal.left = ({0} left) +end.mission_won = Mission accomplished! +end.mission_won_sub = Crookfinger nods approvingly. Your strongest creatures and a fifth of the gold come along into the next mission. +end.mission_lost = Mission failed +end.mission_lost_sub = The knights are celebrating. Crookfinger suggests you try again - he has already sharpened the shovel. +end.again = Try again +end.next = Next mission +end.campaign = Campaign +end.to_campaign = Back to the campaign + +# campaign story: told by old Crookfinger, the imp advisor (" | " starts a new paragraph) +camp.m01.title = A Shard in the Dark +camp.m01.story = Wake up, master. Yes, you. A hundred years ago the Order of the Lightwatch smashed your soul core and carried the shards off like trophies. What is left of you fits into one small cave, four imps and me - old Crookfinger. | The knights keep the first shard in a little outpost to the east, guarded by recruits who still believe in polished boots. Dig a way there and let your imps claim the shard chamber. Knight ground takes them a while to claim, and not while knights stand next to it. +camp.m02.title = The Border Brook +camp.m02.story = A brook! Underground! The knights think a bit of water keeps us out. Charming. | Build a bridge across it - wood is cheap, knight pride is not. The watch post on the far bank holds the next shard, and this time it has an arrow tower. Towers do not chase you: stay out of range or let your creatures tear them down. +camp.m03.title = The Guardhouse +camp.m03.story = The Lightwatch keeps a guardhouse here where young knights learn to march in circles. They call it patrolling. We call it a timetable. | Learn it, strike when they turn their backs, and wipe out the whole garrison. A training room is finally yours - training makes creatures stronger, but the bill comes on payday. +camp.m04.title = The Order Strikes Back +camp.m04.story = Well, that got their attention. The Order sends wave after wave from its gate to put out the little fire in the dark - us. | Four waves, then they run out of volunteers, or at least of enthusiasm. Build arrow towers, keep the lightning ready and do not let them touch the core. +camp.m05.title = The Gold Chambers of Hellwatch +camp.m05.story = Hellwatch is where the Order keeps its pay chests, which explains why the knights here are so cheerful: they get paid. | Their vaults hold gold, silver and even gems. Claim the vault chambers and the treasure is yours. Six thousand should cover our expenses. And our hurt feelings. +camp.m06.title = The Bridge of the Lightwatch +camp.m06.story = The Lightwatch built a bridge over the great river and put a gate at its end, with a banner that has a sun on it. Of course it has a sun on it. | Destroy the gate of the order and the outpost collapses into polite panic. They brought cannons, by the way. Cannons push your creatures around - keep them close together. +camp.m07.title = Lava Ground +camp.m07.story = Lava ground. Warm, cosy, a bit too bright. Wooden bridges would burn here, so you build stone ones: dearer, but fireproof. | Whatever is pushed into the lava is gone for good, knights included. Especially knights. The shard lies in the fortress beyond the glow. And a torture chamber is open to you now - some prisoners can be persuaded to change sides. +camp.m08.title = The Raid +camp.m08.story = A raid! No shard today, just cash. Two vault keeps, stuffed with the tithes of the Order. | You have fifteen minutes before they bring their treasure to safety, and a few hired thugs to get going. Take eight thousand worth from their vaults. Quickly, master - Crookfinger has debts. +camp.m09.title = The Twin Keeps +camp.m09.story = Two keeps on both banks of a river, each guarding a shard. The knights call them the Brothers. One brother fusses about his boots, the other about his hair. | Take both shards. The river has knight bridges: claim them, or the knights will use them to visit you. +camp.m10.title = The Siege +camp.m10.story = The Order is angry. Properly angry, with trumpets. Six waves will march against your halls. | The temple and the magic tower are ready - pray, pay and hold. If the core still glows after the sixth wave, they will need a new recruiting song. +camp.m11.title = Patrol Paths +camp.m11.story = This fortress runs like clockwork: four patrols march around the courtyard, and every corner has a tower looking at it. | Study their rounds, cut them off one by one, and when nothing in silver armour moves any more, the place is ours. +camp.m12.title = Fire and Water +camp.m12.story = Fire on one side, water on the other, and in between a fortress with a shard and a gate. The knights built it to show off. | Cross both rivers, destroy the gate of the order and take the shard. And mind which bridge you are standing on. +camp.m13.title = The Treasure House of the Order +camp.m13.story = The treasure house of the Order: thirteen thousand worth in the vaults - their whole retirement plan. | Claiming vaults makes noise, so the garrison will come running. Plan accordingly. Or do not, and enjoy the fireworks. +camp.m14.title = The Ring of Towers +camp.m14.story = Eight towers in a ring, upgraded to the very top. The commander here believes in walls and arrows the way imps believe in lunch. | Raze every single tower. Your creatures attack towers they can reach - send them with the flag and keep the healing spell handy. +camp.m15.title = Beneath the Current +camp.m15.story = No portal down here. No new recruits. Just what you brought, what I could hire, and a fortress across a cold, fast river. | Defeat every knight with the army you have. Every creature counts - keep them fed, paid and angry. +camp.m16.title = Counterstrike +camp.m16.story = Beyond the lava the gate of the order keeps sending waves, and it will not stop until the gate falls. | Hold your line, then push. Break the gate and their counterattack dies with it. +camp.m17.title = The Bridge Town +camp.m17.story = A whole town on bridges: three rivers, two keeps, two shards and plenty of treasure. Knights love bridges - they tear down yours and keep their own. | Take both shards and six thousand from their vaults. +camp.m18.title = The Marshal's Lava Citadel +camp.m18.story = Marshal Gerold the Unbending sits in his lava citadel, a paladin who has never bent anything - not his knee, not his opinion. | His gate guards the road to the Grand Master. Break it. Gerold will try to break you first. +camp.m19.title = The Last Watch +camp.m19.story = Three keeps hold the last shards before the citadel. The Order has thrown everything it has left into them, including its last good mood. | Take all three shards. After this, only one old man in shiny armour stands between you and your core. +camp.m20.title = The Grand Master +camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond the great river, with the last shard and the gate of the order. He has prayed, polished and prepared for a hundred years. | Destroy his gate, take back the shard and make your core whole again. I will be right behind you. Well behind you. +camp.epilogue = The last shard sinks into the core, and the darkness breathes again. Somewhere above, the Order of the Lightwatch is writing a very long report. | Crookfinger has made tea. You are whole, master. The underworld is yours. diff --git a/src/main.rs b/src/main.rs index 78bc56e..763a03f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,6 +122,20 @@ fn new_game(seed: u32, s: &Settings) -> (Game, Cam) { (g, cam) } +// a campaign mission with what the last mission handed over +fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) { + let g = campaign::start_mission(i, progress.carry_into(i)); + let (cx, cy) = g.map.core; + let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); + (g, cam) +} + +const PROGRESS_KEY: &str = "dc.campaign"; + +fn store_progress(p: &campaign::Progress) { + platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default()); +} + fn next_seed(seed: u32) -> u32 { seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 } @@ -223,6 +237,7 @@ struct App { settings: Settings, seed: u32, status: Option<(String, f64)>, + recorded: bool, // the end of the current mission has been recorded in the campaign progress } impl App { @@ -286,7 +301,8 @@ async fn main() { let seed = (miniquad::date::now() * 1000.0) as u32 | 1; let (game, cam) = new_game(seed, &settings); - let mut app = App { game, cam, settings, seed, status: None }; + let mut app = App { game, cam, settings, seed, status: None, recorded: false }; + menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); app.game.hints = app.settings.hints; let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); @@ -361,10 +377,10 @@ async fn main() { } } } - if matches!(menu.page, Some(Page::Saves { .. })) { + if menu.scrollable() { let (_, wheel) = mouse_wheel(); if wheel != 0.0 { - menu.scroll_by(if wheel > 0.0 { -1 } else { 1 }, cloud.list.len()); + menu.scroll_lines(if wheel > 0.0 { -1 } else { 1 }); } } @@ -633,6 +649,15 @@ async fn main() { MenuAction::CloudDelete(id) => { cloud.delete(&id); } + MenuAction::StartMission(i) => { + (app.game, app.cam) = mission_game(i, &menu.progress); + app.game.hints = app.settings.hints; + app.recorded = false; + menu.page = None; + ui.log.clear(); + ui.show_help = false; + autosave_t = 0.0; + } MenuAction::EditName => { if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) { menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect(); @@ -664,6 +689,25 @@ async fn main() { (app.game, app.cam) = new_game(app.seed, &app.settings); app.game.hints = app.settings.hints; } + // end of a mission: next mission (won) or try again (lost) + UiAction::EndNext => { + if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { + if app.game.state == 1 { + menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign }); + } else { + (app.game, app.cam) = mission_game(i, &menu.progress); + app.game.hints = app.settings.hints; + app.recorded = false; + ui.log.clear(); + } + } + } + UiAction::EndCampaign => menu.page = Some(Page::Campaign), + UiAction::Briefing => { + if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { + menu.open_briefing(i); + } + } UiAction::OpenMenu => menu.page = Some(Page::Pause), UiAction::Locked => { app.game.msg("msg.tool_locked", vec![]); @@ -785,7 +829,22 @@ async fn main() { for ev in app.game.events.drain(..) { sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now); } - let on_title = menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame)); + // a won mission goes into the campaign progress (with the carry-over) + if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { + if app.game.state != 0 { + app.recorded = true; + if app.game.state == 1 { + menu.progress.set_done(i, campaign::carry_from(&app.game)); + store_progress(&menu.progress); + } + } + } + menu.mission = app.game.mission.as_ref().map(|m| m.index); + let on_title = menu.page == Some(Page::Title) + || matches!( + menu.page, + Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. }) + ); music.update(&app.game, on_title, (app.cam.tx, app.cam.tz)); for m in app.game.msgs.drain(..) { ui.log.push((m, now)); @@ -832,7 +891,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M r3d.draw(&app.game, gfx, &app.cam, now, &ov); let title_screen = matches!( menu.page, - Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) + Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. }) ); if !title_screen { let info = if over_ui || (touch && inp.inspect.is_none()) { diff --git a/src/menu.rs b/src/menu.rs index 138933b..39aec25 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -2,12 +2,14 @@ // (tabs, values, key bindings). // Drawn as CP437 windows on top of the (paused) game world. +use crate::campaign::{missions, Progress}; use crate::cloud::{date_string, duration_string, Cloud}; +use crate::game::Goal; use crate::gfx::*; use crate::i18n::{languages, tr, trf}; use crate::map::{MAX_SIZE, MIN_SIZE}; use crate::settings::{all_actions, key_name, Action, Settings}; -use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; +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::*; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -18,6 +20,8 @@ pub enum Page { NewGame, KeyCapture { action: usize, from_title: bool }, Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode) + Campaign, // list of the campaign missions + Briefing { mission: usize, ingame: bool }, // story and goals of a mission } pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; @@ -185,6 +189,10 @@ enum Btn { FileSave, FileLoad, NameField, + Campaign, + Mission(usize), + StartMission, + Goals, } pub enum MenuAction { @@ -204,12 +212,15 @@ pub enum MenuAction { CloudLoad(String), CloudDelete(String), EditName, // the name field was tapped (touch: ask with a browser dialog) + StartMission(usize), } pub struct Menu { pub page: Option, pub has_autosave: bool, pub save_name: String, // name for a new savegame (typed on the savegame page) + pub progress: Progress, // campaign progress + pub mission: Option, // mission being played (pause menu: goals) confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, @@ -225,7 +236,17 @@ fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) { impl Menu { pub fn new() -> Menu { - Menu { page: Some(Page::Title), has_autosave: false, save_name: String::new(), confirm: None, scroll: 0, rows_visible: 1, buttons: Vec::new() } + Menu { + page: Some(Page::Title), + has_autosave: false, + save_name: String::new(), + progress: Progress::default(), + mission: None, + confirm: None, + scroll: 0, + rows_visible: 1, + buttons: Vec::new(), + } } pub fn open(&self) -> bool { @@ -241,6 +262,9 @@ impl Menu { Some(Page::NewGame) => Some(Page::Title), Some(Page::Saves { from_title: true, .. }) => Some(Page::Title), Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause), + Some(Page::Campaign) => Some(Page::Title), + Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), + Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign), Some(Page::Pause) => None, Some(Page::Title) => Some(Page::Title), None => Some(Page::Pause), @@ -266,11 +290,6 @@ impl Menu { } } - pub fn scroll_by(&mut self, d: i32, total: usize) { - let max = total.saturating_sub(self.rows_visible); - self.scroll = (self.scroll as i32 + d).clamp(0, max as i32) as usize; - } - pub fn capturing(&self) -> Option { match self.page { Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(), @@ -371,10 +390,32 @@ impl Menu { } } Some(Btn::Scroll(d)) => { - self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len()); + // each page clamps the scroll position when it is drawn + self.scroll_lines(d * self.rows_visible.max(1) as i32); MenuAction::Consumed } Some(Btn::NameField) => MenuAction::EditName, + Some(Btn::Campaign) => { + self.page = Some(Page::Campaign); + self.scroll = 0; + MenuAction::Consumed + } + Some(Btn::Mission(i)) => { + self.page = Some(Page::Briefing { mission: i, ingame: false }); + self.scroll = 0; + MenuAction::Consumed + } + Some(Btn::StartMission) => match self.page { + Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission), + _ => MenuAction::Consumed, + }, + Some(Btn::Goals) => { + if let Some(m) = self.mission { + self.page = Some(Page::Briefing { mission: m, ingame: true }); + self.scroll = 0; + } + MenuAction::Consumed + } Some(Btn::FileSave) => MenuAction::FileSave, Some(Btn::FileLoad) => MenuAction::FileLoad, } @@ -392,6 +433,8 @@ impl Menu { Page::NewGame => self.draw_new_game(gfx, settings), Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab), Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save), + Page::Campaign => self.draw_campaign(gfx), + Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 4); self.buttons.clear(); @@ -439,6 +482,7 @@ impl Menu { if self.has_autosave { items.push((Btn::Continue, tr("menu.continue"))); } + items.push((Btn::Campaign, tr("menu.campaign"))); items.push((Btn::New, tr("menu.new"))); items.push((Btn::Load, tr("menu.load"))); items.push((Btn::Settings, tr("menu.settings"))); @@ -490,14 +534,17 @@ impl Menu { fn draw_pause(&mut self, gfx: &Gfx) { let sw = sw(); let sh = sh(); - let items = vec![ - (Btn::Resume, tr("menu.resume")), + let mut items = vec![(Btn::Resume, tr("menu.resume"))]; + if self.mission.is_some() { + items.push((Btn::Goals, tr("menu.goals"))); + } + items.extend([ (Btn::Save, tr("menu.save")), (Btn::Load, tr("menu.load")), (Btn::Settings, tr("menu.settings")), (Btn::Help, tr("menu.help")), (Btn::MainMenu, tr("menu.main")), - ]; + ]); let cols = 32; let rows = items.len() as i32 * 3 + 4; let x = grid_center(sw, cols as f32 * CW()); @@ -693,4 +740,168 @@ impl Menu { button(gfx, fr, &fl, fr.contains(vec2(mx, my)), FRAME); self.buttons.push((fr, if save { Btn::FileSave } else { Btn::FileLoad })); } + + // Campaign: list of the missions (done, open, locked) + fn draw_campaign(&mut self, gfx: &Gfx) { + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); + let cols = ((sw / CW()) as i32 - 2).min(60); + let rows = ((sh / CH()) as i32 - 2).min(36); + 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("camp.title"), FRAME_HI); + let inner_x = x + CW() * 2.0; + let inner_w = (cols - 4) as f32 * CW(); + let top = y + CH() * 2.0; + let bottom = y + (rows - 5) as f32 * CH(); + let row_h = CH() * 2.5; + let all = missions(); + let n = all.len(); + self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1); + self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible)); + let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 }; + for (k, d) in all.iter().enumerate().skip(self.scroll).take(self.rows_visible) { + let ry = top + (k - self.scroll) as f32 * row_h; + let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, CH() * 2.0); + let open = self.progress.unlocked(k); + let done = self.progress.is_done(k); + let hot = open && r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else if open { FRAME } else { Color::new(0.25, 0.2, 0.15, 1.0) }); + let mark = if done { "\u{221a}" } else if open { "\u{25ba}" } else { "-" }; + let title = if open { tr(&format!("camp.{}.title", d.key)) } else { tr("camp.locked") }; + let line = format!("{} {:02} {}", mark, k + 1, title); + let maxc = ((r.w - CW() * 2.0) / CW()) as usize; + let line: String = line.chars().take(maxc).collect(); + gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if done { GOLD } else if open { TXT } else { DIM }); + if open { + self.buttons.push((r, Btn::Mission(k))); + } + } + if scroll_w > 0.0 { + let sx = inner_x + inner_w - CW() * 2.5; + let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0); + let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0); + button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME); + button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME); + self.buttons.push((up, Btn::Scroll(-1))); + self.buttons.push((down, Btn::Scroll(1))); + } + let done = (0..n).filter(|&k| self.progress.is_done(k)).count(); + let st = trf("camp.progress", &[done.to_string(), n.to_string()]); + gfx.text(&st, inner_x, bottom + CH() * 0.5, SMALL(), DIM); + let by = y + (rows - 4) as f32 * CH(); + let back = tr("menu.back"); + let br = Rect::new(inner_x, 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)); + } + + // Briefing: the old imp tells the story, then the goals and what comes along + fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) { + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); + let all = missions(); + let Some(d) = all.get(mission) else { return }; + let cols = ((sw / CW()) as i32 - 2).min(64); + let rows = ((sh / CH()) as i32 - 2).min(38); + 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); + let title = trf("camp.mission_title", &[(mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); + // all lines: story paragraphs (" | " separates them), goals, carry-over + let tw = (cols - 6) as usize; + let mut lines: Vec<(String, Color)> = Vec::new(); + for (k, para) in tr(&format!("camp.{}.story", d.key)).split(" | ").enumerate() { + if k > 0 { + lines.push((String::new(), TXT)); + } + for l in wrap(para, tw) { + lines.push((l, TXT)); + } + } + lines.push((String::new(), TXT)); + lines.push((tr("camp.goals"), GOLD)); + for g in &d.goals { + for l in wrap(&format!("\u{25ba} {}", goal_text(*g)), tw) { + lines.push((l, FRAME_HI)); + } + } + if d.time_limit > 0.0 { + lines.push((format!("\u{25ba} {}", trf("camp.time_limit", &[duration_string(d.time_limit as f64)])), FRAME_HI)); + } + // newly available tools + let new_tools: Vec = crate::campaign::TOOL_UNLOCK.iter().enumerate().filter(|&(_, &u)| u == mission && mission > 0).map(|(i, _)| tool_info(TOOLS[i]).name).collect(); + if !new_tools.is_empty() { + lines.push((String::new(), TXT)); + for l in wrap(&trf("camp.new_tools", &[new_tools.join(", ")]), tw) { + lines.push((l, GOLD)); + } + } + if !ingame { + if let Some(c) = self.progress.carry_into(mission) { + lines.push((String::new(), TXT)); + for l in wrap(&trf("camp.carry", &[c.creatures.len().to_string(), (c.gold as i32).to_string()]), tw) { + lines.push((l, DIM)); + } + } + } + let top = y + CH() * 2.0; + let bottom = y + (rows - 5) as f32 * CH(); + self.rows_visible = (((bottom - top) / CH()).floor() as usize).max(1); + self.scroll = self.scroll.min(lines.len().saturating_sub(self.rows_visible)); + for (k, (l, c)) in lines.iter().enumerate().skip(self.scroll).take(self.rows_visible) { + gfx.text(l, x + CW() * 3.0, top + (k - self.scroll) as f32 * CH(), S(), *c); + } + if lines.len() > self.rows_visible { + let sx = x + (cols - 4) as f32 * CW(); + let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0); + let down = Rect::new(sx, bottom - CH() * 2.0, CW() * 2.5, CH() * 2.0); + button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME); + button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME); + self.buttons.push((up, Btn::Scroll(-1))); + self.buttons.push((down, Btn::Scroll(1))); + } + let by = y + (rows - 4) 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)); + if !ingame { + let start = tr("camp.start"); + let w = gfx.measure(&start, S()) + CW() * 6.0; + let sr = Rect::new(x + cols as f32 * CW() - w - CW() * 2.0, by, w, CH() * 2.0); + button(gfx, sr, &start, sr.contains(vec2(mx, my)), FRAME_HI); + self.buttons.push((sr, Btn::StartMission)); + } + } + + // the briefing of the running mission, again (story and goals) + pub fn open_briefing(&mut self, mission: usize) { + self.page = Some(Page::Briefing { mission, ingame: true }); + self.scroll = 0; + } + + // scrolling on pages with lists (mouse wheel) + pub fn scrollable(&self) -> bool { + matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::Briefing { .. })) + } + + pub fn scroll_lines(&mut self, d: i32) { + self.scroll = (self.scroll as i32 + d).max(0) as usize; + } +} + +// description of a mission goal (without progress) +pub fn goal_text(g: Goal) -> String { + match g { + Goal::Shards => tr("goal.shards"), + Goal::Heart => tr("goal.heart"), + Goal::KillAll => tr("goal.killall"), + Goal::Loot(n) => trf("goal.loot", &[n.to_string()]), + Goal::Towers => tr("goal.towers"), + Goal::Survive(n) => trf("goal.survive", &[n.to_string()]), + } } diff --git a/src/ui.rs b/src/ui.rs index 0e7662f..1f3fed9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -254,6 +254,9 @@ enum TBtn { pub enum UiAction { None, + EndNext, // mission over: next mission (won) or try again (lost) + EndCampaign, // mission over: back to the campaign + Briefing, // show the briefing of the current mission again Locked, // a tool that is not available yet (campaign) OpenMenu, SelectTool(Tool), @@ -274,7 +277,8 @@ pub struct Ui { minimap_tex: Texture2D, minimap_t: f64, minimap_rect: Rect, - end_button: Rect, + end_buttons: Vec<(Rect, u8)>, + goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign lang_rects: Vec<(Rect, String)>, pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time // touch interface @@ -284,7 +288,7 @@ pub struct Ui { hud_h: f32, } -fn wrap(s: &str, cols: usize) -> Vec { +pub fn wrap(s: &str, cols: usize) -> Vec { let mut out = Vec::new(); let mut line = String::new(); for word in s.split(' ') { @@ -324,7 +328,8 @@ impl Ui { minimap_tex: tex, minimap_t: -1.0, minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0), - end_button: Rect::new(0.0, 0.0, 0.0, 0.0), + end_buttons: Vec::new(), + goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0), lang_rects: Vec::new(), hand_anim: None, open_cat: None, @@ -358,8 +363,14 @@ impl Ui { return UiAction::Consumed; } if g.state != 0 { - if self.end_button.contains(p) { - return UiAction::NewGame; + for &(r, code) in &self.end_buttons { + if r.contains(p) { + return match code { + 0 => UiAction::NewGame, + 1 => UiAction::EndNext, + _ => UiAction::EndCampaign, + }; + } } return UiAction::Consumed; } @@ -395,6 +406,9 @@ impl Ui { } // a tap elsewhere closes an open category row self.open_cat = None; + if g.mission.is_some() && self.goals_rect.contains(p) { + return UiAction::Briefing; + } if self.minimap_rect.contains(p) { let (wx, wy) = self.minimap_world(x, y); return UiAction::MinimapJump(wx, wy); @@ -652,6 +666,35 @@ impl Ui { draw_line(pa.x, pa.y, pb.x, pb.y, px, WHITE); } + // ---------------- mission goals (campaign) + if let Some(m) = &g.mission { + let gcols = 34.min(cols - 2); + let mut lines: Vec<(String, Color)> = Vec::new(); + for &goal in &m.goals { + let done = g.goal_done(goal); + let progress = match goal { + Goal::Shards => format!(" {}/{}", g.shards_taken.min(m.shards), m.shards), + Goal::Heart => format!(" {}%", (g.gate_hp / GATE_HP * 100.0).ceil().max(0.0) as i32), + Goal::KillAll => trf("goal.left", &[g.ents.iter().filter(|e| e.alive && e.active_hero()).count().to_string()]), + Goal::Loot(n) => format!(" {}/{}", (g.loot as u32).min(n), n), + Goal::Towers => trf("goal.left", &[g.towers.iter().filter(|t| t.hero).count().to_string()]), + Goal::Survive(n) => format!(" {}/{}", g.wave.min(n), n), + }; + let text = format!("{} {}{}", if done { "\u{221a}" } else { "\u{25ba}" }, crate::menu::goal_text(goal), progress); + for l in wrap(&text, gcols as usize - 4) { + lines.push((l, if done { GREEN } else { TXT })); + } + } + let grows = lines.len() as i32 + 2; + gfx.window_titled(0.0, self.hud_h, gcols, grows, S(), false, FRAME, BG, &tr("camp.goals_panel"), GOLD); + let r = Rect::new(0.0, self.hud_h, gcols as f32 * CW(), grows as f32 * CH()); + self.rects.push(r); + self.goals_rect = r; + for (k, (l, c)) in lines.iter().enumerate() { + gfx.text(l, CW() * 2.0, self.hud_h + CH() * (k as f32 + 1.0), S(), *c); + } + } + // ---------------- info panel if !info.is_empty() { let icols = 30.min(cols - 2); @@ -852,29 +895,56 @@ impl Ui { let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.5)); let win = g.state == 1; - let cols = 44; - let rows = 11; + let mission = g.mission.as_ref().map(|m| m.index); + let last = mission.map_or(false, |i| i + 1 == crate::campaign::missions().len()); + let cols = 50.min((sw / CW()) as i32); + let tw = (cols - 6) as usize; + let (title, sub) = match (mission, win) { + (Some(_), true) => ("end.mission_won", if last { "camp.epilogue" } else { "end.mission_won_sub" }), + (Some(_), false) => ("end.mission_lost", "end.mission_lost_sub"), + (None, true) => ("end.victory", "end.victory_sub"), + (None, false) => ("end.defeat", "end.defeat_sub"), + }; + let mut lines: Vec = Vec::new(); + for (k, para) in tr(sub).split(" | ").enumerate() { + if k > 0 { + lines.push(String::new()); + } + lines.extend(wrap(para, tw)); + } + let rows = 9 + lines.len() as i32; let x = grid_center(sw, cols as f32 * CW()); - let y = grid_center_y(sh, rows as f32 * CH()); + let y = grid_center_y(sh, rows as f32 * CH()).max(0.0); let col = if win { GOLD } else { RED }; gfx.window(x, y, cols, rows, S(), true, col, Color::new(0.05, 0.03, 0.04, 0.97)); - let title = tr(if win { "end.victory" } else { "end.defeat" }); - gfx.text(&title, grid_center(sw, gfx.measure(&title, TITLE())), y + CH(), TITLE(), col); - let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" }); - gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), y + CH() * 4.5, S(), TXT); + let title = tr(title); + let big = if gfx.measure(&title, TITLE()) < cols as f32 * CW() - CW() * 4.0 { TITLE() } else { S() }; + gfx.text(&title, grid_center(sw, gfx.measure(&title, big)), y + CH(), big, col); + for (k, l) in lines.iter().enumerate() { + gfx.text(l, x + CW() * 3.0, y + CH() * (4.0 + k as f32), S(), TXT); + } 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() * 6.0, S(), DIM); - let b = format!(" {} ", tr("end.new")); - let b = b.as_str(); - let bw = gfx.measure(b, S()) + CW() * 2.0; - let bx = grid_center(sw, bw); - let by = y + CH() * 7.75; - self.end_button = Rect::new(bx, by, bw, CH() * 2.0); + gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM); + // buttons: free game: new game; mission: next / again and back to the campaign + let labels: Vec<(String, u8)> = match mission { + None => vec![(tr("end.new"), 0)], + Some(_) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)], + }; + let by = y + CH() * (6.0 + lines.len() 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); let (mx, my) = mouse(); - let hov = self.end_button.contains(vec2(mx, my)); - draw_rectangle(bx, by, bw, CH() * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); - draw_rectangle_lines(bx, by, bw, CH() * 2.0, 2.0, col); - gfx.text(b, bx + CW(), by + CH() * 0.5, S(), TXT); + self.end_buttons.clear(); + for ((label, code), w) in labels.iter().zip(widths) { + let r = Rect::new(bx, by, w, CH() * 2.0); + let hov = r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, col); + gfx.text(label, r.x + CW() * 2.0, r.y + CH() * 0.5, S(), TXT); + self.end_buttons.push((r, *code)); + bx += w + CW() * 2.0; + } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } }