Co-op offline with an AI partner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 21:04:49 +01:00
1 parent e3a9d32d61
commit f87b59390e
22 files changed
+272 -5

No files matched your search

+67 -2
View File
@@ -343,6 +343,16 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
menu.error = Some(i18n::tr("net.coop_editor_none"));
}
}
MenuAction::MatchCoopSolo(doc, mission) => {
// the open lobby connection is not needed: the game runs on this computer alone
if app.start_offline_coop(doc, mission, None, Vec::new()) {
menu.page = None;
menu.error = None;
app.set_status(i18n::tr("net.coop_solo_started"), now);
} else {
menu.error = Some(i18n::tr("net.coop_editor_none"));
}
}
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
@@ -500,6 +510,7 @@ struct App {
coop: bool,
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
fps: f32,
renderer: String,
frame_ms: f32,
@@ -624,9 +635,32 @@ impl App {
self.match_mode = false;
self.coop = false;
self.coop_editor = false;
self.offline = false;
self.client.on_disconnect();
}
// A co-op mission without the server: built like a match start with the player on side 0 and an
// AI partner on side 2, then the world runs on this computer alone (no turns, no relay).
// `doc` is an editor campaign (None: a built-in co-op mission); `run_seed`/`carry` continue a run.
fn start_offline_coop(&mut self, doc: Option<campaign_doc::CampaignDoc>, mission: usize, run_seed: Option<u32>, carry: Vec<(u8, campaign::Carry)>) -> bool {
let size = match &doc {
Some(d) => d.missions.get(mission).map(|m| m.size),
None => campaign::missions().get(mission).map(|m| m.size),
};
let Some((map_w, map_h)) = size else { return false };
let next = run_seed.is_some();
let seed = run_seed.unwrap_or_else(|| next_seed(self.seed));
let editor = doc.is_some();
let info = StartInfo { seed, map_w, map_h, difficulty: self.settings.difficulty, sides: vec![0, 2], local_side: Some(0), mission: Some(mission), ai: vec![2], doc, editor, teams: Vec::new(), next, carry };
if !self.start_match_game(info) {
return false;
}
self.game.end_match();
self.offline = true;
self.recorded = false;
true
}
// the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings
fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool {
let local = info.local_side.unwrap_or(0);
@@ -758,7 +792,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, 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, 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 };
@@ -1471,6 +1505,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
| MenuAction::MatchAbort
| MenuAction::MatchLeave
| MenuAction::MatchCoopEditorCreate(..)
| MenuAction::MatchCoopSolo(..)
| MenuAction::MatchChat(..)
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => {
@@ -1517,13 +1552,41 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
// a co-op mission of an editor campaign is over: the host starts the next one (or the
// same one again) for everybody; the others wait for it
UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => {
if let Some(run) = app.coop_run.as_mut() {
if app.offline {
// no relay: the next mission of the run starts here at once
let run = app.coop_run.take().expect("a co-op run");
let next = run.next.unwrap_or(run.mission);
if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) {
ui.log.clear();
autosave_t = 0.0;
}
} else if let Some(run) = app.coop_run.as_mut() {
if let Some(next) = run.next {
app.client.next_mission(next, run.run_seed, &run.carry_out);
run.sent = true;
}
}
}
// a built-in co-op mission without the server: the next one after a victory, else again
UiAction::EndNext if app.offline && !app.coop_editor => {
let i = app.game.mission.as_ref().map_or(0, |m| m.index);
let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i };
if next <= i && app.game.state == 1 {
app.leave_match();
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
} else if app.start_offline_coop(None, next, None, Vec::new()) {
ui.log.clear();
autosave_t = 0.0;
}
}
UiAction::EndCampaign if app.offline && !app.coop_editor => {
app.leave_match();
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
}
UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {}
// ... or the run is over (or the player leaves): the match ends, back to the lobby
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
@@ -1755,6 +1818,8 @@ app.pending_briefing = briefing_of(&campaign, i);
run.finish(&app.game);
}
}
} else if app.coop {
// a built-in co-op mission is no step of the single-player campaign: no progress
} else if app.custom_campaign.is_none() {
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 {