Tutorial: Crookfinger leads a new master through the first steps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 00:37:25 +01:00
1 parent 1fb6a6ab58
commit 160935bf40
23 files changed
+501 -3

No files matched your search

+101
View File
@@ -238,6 +238,16 @@ pub fn demo_campaign() -> CampaignDoc {
#[cfg(test)]
mod tests {
#[test]
fn the_tutorial_is_a_valid_campaign() {
let doc = super::tutorial_campaign();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.missions.len(), 1);
let g = crate::campaign::start_editor_mission(&doc.missions[0], 0, 1);
assert!(g.script.is_some());
assert!(!g.ents.iter().any(|e| e.alive && e.active_hero()), "no knight before the last step");
}
use super::*;
#[test]
@@ -278,3 +288,94 @@ mod tests {
assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there");
}
}
// The tutorial: Crookfinger leads a new master step by step - dig the tunnel, claim the hall,
// build a lair and a mushroom garden, wait for the first creatures, slap one, and beat the first
// two knights. Each step switches on the trigger of the next one. Played like an editor campaign
// (title menu: Tutorial); texts come from the language files (tut.*).
pub fn tutorial_campaign() -> CampaignDoc {
let mut doc = CampaignDoc { name: tr("tut.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
let mut m = MissionDoc {
key: "tutorial".into(),
title: tr("tut.title"),
story: tr("tut.story").replace("\\n", "\n"),
size: (64, 64),
seed: 777,
gold: 3000.0,
goals: Vec::new(),
gen: GenDoc { rivers: 0, lava: 0, forts: 0, rivals: 0, difficulty: 0, surface: false },
pos: (40.0, 60.0),
..MissionDoc::default()
};
let saved = (crate::map::mw(), crate::map::mh());
let mut g = crate::campaign::start_editor_mission(&m, 0, 777);
let (cx, cy) = g.map.core;
let (w, h) = (g.map.w as i32, g.map.h as i32);
let cl = |x: i32, y: i32| (x.clamp(3, w - 4), y.clamp(3, h - 4));
// east of the heart: a tunnel of earth to dig, then a hall of open ground behind it
let tunnel: Vec<(i32, i32)> = (cx + 4..cx + 8).flat_map(|x| [(x, cy + 1), (x, cy + 2)]).map(|(x, y)| cl(x, y)).collect();
let (hx0, hy0) = cl(cx + 8, cy - 2);
let (hx1, hy1) = cl(cx + 15, cy + 5);
for y in hy0 - 1..=hy1 + 1 {
for x in hx0..=hx1 + 1 {
let t = &mut g.map.t[crate::map::idx(x, y)];
let edge = y == hy0 - 1 || y == hy1 + 1 || x == hx1 + 1;
t.kind = if edge { crate::map::Kind::Earth } else { crate::map::Kind::Floor };
t.owner = crate::map::Owner::None;
t.gold = 0;
t.reinforced = false;
}
}
for &(x, y) in &tunnel {
let t = &mut g.map.t[crate::map::idx(x, y)];
t.kind = crate::map::Kind::Earth;
t.owner = crate::map::Owner::None;
t.hp = 3.0;
}
// the knights come from a cave further east
let (kx, ky) = cl(cx + 22, cy + 1);
for y in ky - 2..=ky + 2 {
for x in kx - 2..=kx + 2 {
let t = &mut g.map.t[crate::map::idx(x, y)];
t.kind = crate::map::Kind::Floor;
t.owner = crate::map::Owner::None;
}
}
for x in hx1 + 1..kx - 2 {
let t = &mut g.map.t[crate::map::idx(x, cy + 1)];
t.kind = crate::map::Kind::Earth;
t.owner = crate::map::Owner::None;
}
g.ents.retain(|e| !e.hero);
g.recount();
g.init_fog();
m.zones = vec![
ZoneDoc { name: "tunnel".into(), shape: "tiles".into(), at: None, radius: None, tiles: Some(tunnel), from: None, to: None, width: None },
zone_rect("hall", hx0, hy0, hx1, hy1),
ZoneDoc { name: "knights".into(), shape: "circle".into(), at: Some((kx as f32, ky as f32)), radius: Some(1.5), tiles: None, from: None, to: None, width: None },
];
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
crate::map::set_dims(saved.0, saved.1);
let s = &mut m.scripts;
let obj = |k: &str| Block::Objective { text: tr(k) };
let on = |t: &str| Block::Trigger { name: t.into(), on: true };
s.start = vec![say("tut.start"), obj("tut.obj1"), Block::SetVar { name: "knights".into(), value: n(0.0) }, Block::Camera { zone: "tunnel".into() }];
s.victory = vec![Block::If { cond: cmp(">=", var("knights"), n(2.0)), then: vec![say("tut.win"), Block::Win], els: vec![] }];
let off = |t: TriggerDoc| TriggerDoc { enabled: false, ..t };
s.triggers = vec![
trig("dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("tut.dug"), obj("tut.obj2"), on("lair")]),
off(trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![say("tut.lair"), obj("tut.obj3"), on("farm")])),
off(trig("farm", TriggerKind::RoomBuilt { room: "Farm".into() }, true, vec![say("tut.farm"), obj("tut.obj4"), on("creatures")])),
off(trig("creatures", TriggerKind::Value { what: "creatures".into(), var: String::new(), op: ">=".into(), n: 2.0 }, true, vec![say("tut.creatures"), obj("tut.obj5"), on("slap")])),
off(trig(
"slap",
TriggerKind::Slap,
true,
vec![say("tut.slap"), obj("tut.obj6"), Block::Wait { secs: n(20.0) }, Block::Spawn { side: 1, kind: "Knight".into(), count: n(2.0), level: n(1.0), place: Place::Zone("knights".into()), name: String::new() }, say("tut.knights"), Block::Camera { zone: "knights".into() }, on("knight")],
)),
off(trig("knight", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "knights".into(), value: n(1.0) }])),
];
doc.missions.push(m);
doc
}
+30 -1
View File
@@ -586,6 +586,7 @@ struct App {
coop_editor: bool, // the co-op match plays a mission of an editor campaign
coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands
offline: bool, // a co-op game played without the server: the other seat is an AI partner
tutorial: bool, // the tutorial mission is being played (its end leads back to the title)
fps: f32,
renderer: String,
frame_ms: f32,
@@ -881,7 +882,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, offline: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, offline: false, tutorial: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
apply_game_settings(&mut app.game, &app.settings);
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 };
@@ -1571,6 +1572,24 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
}
}
}
MenuAction::Tutorial => {
let doc = editor::templates::tutorial_campaign();
let progress = campaign_doc::CampaignProgress::for_doc(&doc);
(app.game, app.cam) = custom_mission_game(&doc, 0, progress.run_seed);
app.pending_briefing = briefing_of(&doc, 0);
apply_game_settings(&mut app.game, &app.settings);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(doc);
app.custom_progress = Some(progress);
app.custom_mission_index = Some(0);
app.tutorial = true;
menu.page = None;
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL),
@@ -1608,6 +1627,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu => {
app.tutorial = false;
if app.match_mode || app.game.in_match() {
app.leave_match();
} else if app.game.state == 0 {
@@ -1689,6 +1709,15 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
menu.error = Some(i18n::tr("net.status_offline"));
}
}
UiAction::EndNext | UiAction::EndCampaign if app.tutorial && app.game.state == 1 => {
app.tutorial = false;
app.custom_campaign = None;
app.custom_progress = None;
app.custom_mission_index = None;
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
}
UiAction::EndNext => {
if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone();
+4
View File
@@ -301,6 +301,7 @@ enum Btn {
MatchChat,
Bestiary,
Achievements,
Tutorial,
BestTab(usize),
BestSel(i32),
BestPick(usize),
@@ -392,6 +393,7 @@ pub enum MenuAction {
CampaignStart(usize),
OpenRepo,
OpenClaudeCode,
Tutorial, // start the tutorial mission
BackToEditor,
OpenOpenCode,
Login(String, String),
@@ -875,6 +877,7 @@ impl Menu {
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
_ => MenuAction::Consumed,
},
Some(Btn::Tutorial) => MenuAction::Tutorial,
Some(Btn::Campaign) => {
self.page = Some(Page::Campaign);
self.scroll = 0;
@@ -1104,6 +1107,7 @@ impl Menu {
items.push((Btn::Continue, tr("menu.continue")));
}
items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::Tutorial, tr("menu.tutorial")));
items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new")));