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:
1 parent
1fb6a6ab58
commit
160935bf40
23 files changed
+501
-3
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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")));
|
||||
|
||||
Reference in new issue
Block a user