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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 11:23:44 +01:00
1 parent 46755d68ac
commit 09e22b009e
5 files changed
+523 -39

No files matched your search

+72
View File
@@ -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.
+72
View File
@@ -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.
+64 -5
View File
@@ -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()) {
+222 -11
View File
@@ -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<Page>,
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<usize>, // mission being played (pause menu: goals)
confirm: Option<Btn>, // 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<Action> {
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<String> = 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()]),
}
}
+93 -23
View File
@@ -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<String> {
pub fn wrap(s: &str, cols: usize) -> Vec<String> {
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<String> = 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<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::<f32>() + 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));
}
}