Co-op offline with an AI partner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3a9d32d61
commit
f87b59390e
22 files changed
+272
-5
No files matched your search
+67
-2
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user