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 {
+5
View File
@@ -279,6 +279,7 @@ enum Btn {
MatchCoopNext,
MatchCoopCreate,
MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchCoopSolo, // the selected co-op mission without the server, with an AI partner
MatchTake(usize),
MatchAi(usize),
MatchTeam(usize), // next team for that seat
@@ -397,6 +398,7 @@ pub enum MenuAction {
MatchCreate,
MatchCoopCreate(usize),
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchCoopSolo(Option<crate::campaign_doc::CampaignDoc>, usize), // offline with an AI partner: editor campaign or built-in, mission
MatchTakeSide(u8),
MatchSetAi(u8, bool),
MatchSetTeam(u8, u8, bool), // side, team, the own seat
@@ -798,6 +800,8 @@ impl Menu {
self.confirm = None;
MenuAction::Consumed
}
Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission),
Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission),
Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission),
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
@@ -1992,6 +1996,7 @@ impl Menu {
items.push((Btn::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
items.push((Btn::MatchCoopNext, ">".to_string()));
items.push((Btn::MatchCoopSolo, tr("net.coop_solo")));
}
// the button names what comes next
let next = match (editor_coop, self.coop_league) {
+139 -3
View File
@@ -7,6 +7,10 @@ use crate::map::*;
use serde::{Deserialize, Serialize};
pub const RIVAL_CORE_HP: f32 = 2000.0;
// a co-op AI partner helps masters short of gold: every minute it may send some
pub const PARTNER_GIFT_EVERY: f32 = 60.0;
pub const PARTNER_GIFT_BELOW: f32 = 300.0;
pub const PARTNER_GIFT: f32 = 400.0;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rival {
@@ -20,6 +24,8 @@ pub struct Rival {
pub attack_t: f32,
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
pub power: f32, // grows with the time: level of the new creatures
#[serde(default)]
pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold
}
struct Pace {
@@ -135,7 +141,7 @@ impl Game {
t.gold = 300;
t.res = 0;
}
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0 });
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY });
for k in 0..3 {
self.spawn_rival_unit(side, EK::Kobold, 1, k as f32);
}
@@ -167,6 +173,14 @@ impl Game {
r.spawn_t -= dt;
r.attack_t -= dt;
}
// a co-op partner sends gold to allied masters who run short
if self.is_partner(side) {
self.rivals[k].gift_t -= dt;
if self.rivals[k].gift_t <= 0.0 {
self.rivals[k].gift_t = PARTNER_GIFT_EVERY;
self.partner_gift(side);
}
}
// growth: one more ring of ground (no imps needed)
if self.rivals[k].grow_t <= 0.0 {
self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4);
@@ -294,7 +308,14 @@ impl Game {
let (x, y) = self.rivals[k].centre();
self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff);
self.sfx(Sfx::Victory, x, y);
self.msg(if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }, vec![]);
let key = if self.is_partner(side) {
"msg.partner_out"
} else if side == 2 {
"msg.rival1_out"
} else {
"msg.rival2_out"
};
self.msg(key, vec![]);
// its creatures scatter and vanish, its ground is free
for e in self.ents.iter_mut() {
if e.alive && !e.hero && e.side == side {
@@ -385,6 +406,41 @@ impl Game {
}
// units of a rival master: fight enemies nearby, march when sent, otherwise guard home
// an AI seat of a co-op game: an allied master who builds his own dungeon
pub fn is_partner(&self, side: u8) -> bool {
self.coop && self.ai_sides.contains(&side)
}
// the partner gives gold to every allied master below the limit
fn partner_gift(&mut self, side: u8) {
let me = self.me_side;
for s in self.master_sides() {
if self.is_out(s) || self.gold_of(s) >= PARTNER_GIFT_BELOW {
continue;
}
self.switch_to(s);
let got = self.add_gold(PARTNER_GIFT).floor();
if got >= 1.0 {
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{side}")]);
self.sfx_ui(Sfx::Payday);
}
}
self.switch_to(me);
}
// a co-op partner's creatures follow the attack flag of the first master still standing
fn partner_flag(&self, side: u8) -> Option<usize> {
if !self.is_partner(side) {
return None;
}
let lead = self.master_sides().into_iter().find(|s| !self.is_out(*s))?;
if lead == self.me_side {
self.flag
} else {
self.masters.iter().find(|m| m.side == lead)?.flag
}
}
pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) {
if e.think <= 0.0 {
e.think = 0.5;
@@ -397,18 +453,41 @@ impl Game {
}
}
}
// two of three healthy creatures of a co-op partner join the attack at the allies' flag
let assist = if e.id % 3 != 0 && e.hp > e.max_hp * 0.4 { self.partner_flag(e.side) } else { None };
if let Job::Attack(id) = e.job {
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let far = e.target.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
let far = e.target.is_none() && assist.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
if !far && self.attack_job(e, id, dt) {
return;
}
e.job = Job::None;
e.path.clear();
}
if let Some(f) = assist {
// the flag at the humans' gate: attack the gate
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
let (gx, gy) = self.gate_center();
if e.target.is_none() && self.has_gate() && self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0 {
e.target = Some(1);
}
}
if self.march_on(e, dt) {
return;
}
if let Some(f) = assist {
let (fx, fy) = xy(f);
let d2 = (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2);
if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), f) {
e.path = p;
}
}
self.step_path(e, dt);
return;
}
// guard the own dungeon
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2);
@@ -538,4 +617,61 @@ mod tests {
g.spawn_wave();
assert!(g.ents[before..].iter().filter(|e| e.hero).all(|e| e.target.is_none()), "the wave comes for the player");
}
// the first mission of the co-op league, set up like an offline co-op game: player on side 0,
// an AI partner on side 2, no server
fn offline_league_game() -> Game {
let doc = crate::editor::coop_campaign::coop_campaign();
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77);
g.coop = true;
assert!(g.start_match(&[0, 2], 0));
g.sync_ai_sides(&[2], 1);
g.end_match();
g
}
#[test]
fn an_offline_coop_partner_is_an_allied_ai_master() {
let mut g = offline_league_game();
assert!(g.netplay.is_none() && g.masters.is_empty());
assert!(g.rival_alive(2) && g.is_partner(2));
assert!(g.allied(0, 2) && g.allied(2, 0));
// short of gold: the partner helps out within a minute
g.gold = 0.0;
for _ in 0..(65 * 20) {
g.update(0.05);
}
assert!(g.msgs.iter().any(|m| m.key == "msg.gold_received"), "the partner sent gold");
assert_eq!(g.state, 0);
// the partner's creatures never fight the player's
assert!(!g.ents.iter().any(|e| e.alive && !e.hero && e.side == 0 && e.hp < e.max_hp && matches!(e.job, Job::Attack(_))));
}
#[test]
fn the_partner_follows_the_attack_flag() {
let mut g = offline_league_game();
for _ in 0..(90 * 20) {
g.update(0.05);
}
let (rx, ry) = g.rivals.iter().find(|r| r.side == 2).unwrap().centre();
// a flag on the partner's own ground a little away from its core
let f = (0..g.map.t.len())
.find(|&i| {
let (x, y) = xy(i);
let d2 = (x as f32 - rx).powi(2) + (y as f32 - ry).powi(2);
g.map.t[i].owner == Owner::of_side(2) && g.map.walk(x, y) && d2 > 16.0 && d2 < 36.0
})
.expect("partner ground");
g.flag = Some(f);
let (fx, fy) = xy(f);
let mut near = false;
for _ in 0..(30 * 20) {
g.update(0.05);
near |= g.ents.iter().any(|e| e.alive && e.side == 2 && !e.hero && e.id % 3 != 0 && (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2) < 3.0 * 3.0);
if near {
break;
}
}
assert!(near, "partner creatures gather at the flag");
}
}