Co-op matches of editor campaigns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 17:39:38 +01:00
1 parent 0966e3318f
commit abe01ff23e
13 files changed
+394 -44

No files matched your search

+58 -3
View File
@@ -619,13 +619,68 @@ pub fn run_map_seed(run_seed: u32, mission: usize) -> u32 {
// a mission of an editor campaign: its painted map or a random map (rolled from the run's
// seed), the goals it can actually have, and its block scripts
pub fn start_editor_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32) -> Game {
editor_mission_mode(doc, i, run_seed, false)
}
// the same mission for a co-op match: the other master seats (2, 3, 4) belong to the players, so
// rival masters of the map are left out (like the built-in co-op missions) and so is the Rivals goal
pub fn start_editor_coop_mission(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32) -> Game {
editor_mission_mode(doc, i, run_seed, true)
}
// editor missions that can be played in co-op: the master areas need a map of at least 72x72,
// and a goal other than beating rival masters
pub fn is_editor_coop_mission(doc: &crate::campaign_doc::MissionDoc) -> bool {
doc.size.0 >= 72 && doc.size.1 >= 72 && crate::campaign_doc::parse_goals(&doc.goals).iter().any(|g| *g != Goal::Rivals)
}
pub fn editor_coop_indexes(doc: &crate::campaign_doc::CampaignDoc) -> Vec<usize> {
doc.missions.iter().enumerate().filter(|(_, m)| is_editor_coop_mission(m)).map(|(i, _)| i).collect()
}
pub fn next_editor_coop_mission(doc: &crate::campaign_doc::CampaignDoc, i: usize, dir: i32) -> usize {
let list = editor_coop_indexes(doc);
if list.is_empty() {
return 0;
}
let pos = list.iter().position(|m| *m == i).unwrap_or(0);
list[((pos as i32 + dir).rem_euclid(list.len() as i32)) as usize]
}
// a painted map without its rival masters: their creatures go, their tiles become unowned
fn remove_rivals(g: &mut Game) {
let sides: Vec<u8> = g.rivals.iter().map(|r| r.side).collect();
if sides.is_empty() {
return;
}
g.rivals.clear();
g.ents.retain(|e| e.hero || !sides.contains(&e.side));
let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect();
for t in g.map.t.iter_mut() {
if owners.contains(&t.owner) {
t.owner = Owner::None;
if t.kind == Kind::Core {
t.kind = Kind::Floor;
}
}
}
g.recount();
}
fn editor_mission_mode(doc: &crate::campaign_doc::MissionDoc, i: usize, run_seed: u32, coop: bool) -> Game {
use crate::campaign_doc::MapDoc;
let generated = || start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, coop);
let mut g = match &doc.map {
MapDoc::Fixed { game } => match load_fixed_map(game) {
Some(g) => g,
None => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false),
Some(mut g) => {
if coop {
remove_rivals(&mut g);
}
g
}
None => generated(),
},
MapDoc::Random => start_mission_def(&generated_def(doc, run_map_seed(run_seed, i)), i, None, false),
MapDoc::Random => generated(),
};
g.msgs.clear();
g.state = 0;
+8 -3
View File
@@ -1624,9 +1624,12 @@ impl Game {
}
fn after_economy(&mut self, dt: f32) {
// from here on it is about the local master and the humans: that side is switched in first
// the shared world (waves, units, towers, rivals ...) runs from the first master's side: the
// same on every computer of a match, whichever master plays at this one. Single-player
// games only have that one side.
let local = self.local_side;
self.switch_to(local);
let first = self.master_sides().first().copied().unwrap_or(local);
self.switch_to(first);
if self.waves_on() {
self.wave_tick(dt);
}
@@ -1636,7 +1639,7 @@ impl Game {
self.update_contested();
self.update_units(dt);
self.switch_to(local); // update_units worked master by master
self.switch_to(first); // update_units worked master by master
self.update_towers(dt);
self.update_reveals(dt);
self.update_graves(dt);
@@ -1644,6 +1647,8 @@ impl Game {
self.update_rivals(dt);
}
self.update_dwarves(dt);
// the end of the game and the fog are about the master at this computer
self.switch_to(local);
self.check_end();
self.update_fog(dt);
}
+108 -2
View File
@@ -79,6 +79,9 @@ pub struct StartInfo {
pub local_side: Option<u8>,
pub mission: Option<usize>,
pub ai: Vec<u8>,
// an editor campaign sent along by the host: `mission` is then one of its missions
pub doc: Option<crate::campaign_doc::CampaignDoc>,
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
}
#[derive(Clone, Debug, Default)]
@@ -211,7 +214,20 @@ impl MatchClient {
self.send_create(sides, map_w, map_h, difficulty, Some(mission));
}
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
// it to every client with the start message
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size;
let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
}
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
self.send_create_with(sides, map_w, map_h, difficulty, mission, None);
}
fn send_create_with(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>, doc: Option<Value>) {
if !self.connected {
return;
}
@@ -219,7 +235,11 @@ impl MatchClient {
self.status = None;
self.chat.clear();
self.chat_seen = 0;
self.outbox.push_back(json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission}).to_string());
let mut msg = json!({"t":"create","sides":sides,"map_w":map_w,"map_h":map_h,"difficulty":difficulty,"mission":mission});
if let Some(doc) = doc {
msg["doc"] = doc;
}
self.outbox.push_back(msg.to_string());
}
pub fn join(&mut self, code: &str) {
@@ -449,6 +469,8 @@ impl MatchClient {
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
let editor = v.get("doc").map_or(false, |d| d.is_object());
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() {
if let Some(r) = self.room.as_ref() {
@@ -467,7 +489,7 @@ impl MatchClient {
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai });
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor });
self.error = None;
self.status = None;
}
@@ -655,4 +677,88 @@ mod tests {
assert_eq!(start.sides, vec![0u8, 3u8]);
assert_eq!(start.ai, vec![3u8]);
}
// a small editor campaign: a random map with a rival master and block scripts, and a painted
// map that still holds a rival master
fn coop_doc() -> crate::campaign_doc::CampaignDoc {
use crate::blocks::{Block, Val};
use crate::campaign_doc::*;
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1 }, ..MissionDoc::default() };
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
doc.missions.push(m.clone());
let mut painted = crate::campaign::start_editor_mission(&MissionDoc { size: (96, 96), seed: 9, ..m.clone() }, 1, 3);
assert!(!painted.rivals.is_empty(), "the painted map has a rival master");
painted.state = 0;
doc.missions.push(MissionDoc { key: "c2".into(), title: "Painted".into(), size: (96, 96), map: MapDoc::Fixed { game: serde_json::to_string(&painted).unwrap() }, ..m });
doc
}
// what the game does with a co-op start of an editor campaign (main.rs start_coop_game)
fn coop_game(info: &StartInfo) -> crate::game::Game {
let doc = info.doc.as_ref().expect("the start carries the campaign");
let i = info.mission.expect("a mission");
assert!(crate::campaign::is_editor_coop_mission(&doc.missions[i]));
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[i], i, info.seed);
g.coop = true;
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
g.sync_ai_sides(&info.ai, info.difficulty);
g
}
#[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc();
let mut host = MatchClient::new();
host.on_connect("red");
pop(&mut host); // rooms
host.create_coop_doc(&doc, 1, &[0, 2, 3, 4], 1);
let create = pop(&mut host);
assert_eq!(create["mission"].as_u64(), Some(1));
assert_eq!(create["map_w"].as_u64(), Some(96));
assert_eq!(create["doc"]["missions"].as_array().map(|a| a.len()), Some(2), "the whole campaign goes along");
for mission in [0usize, 1] {
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2,3,4],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true},{"side":3,"name":"","connected":false,"ai":true}],"ai":[3],"turn":0,"started":false}"#;
let start = json!({"t":"start","seed":4711,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2,3],"mission":mission,"ai":[3],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(info.editor);
games.push(coop_game(&info));
}
let (a, b) = games.split_at_mut(1);
let (a, b) = (&mut a[0], &mut b[0]);
assert_eq!((a.local_side, b.local_side), (0, 2));
assert!(a.rivals.iter().all(|r| r.side == 3), "only the AI seat plays as a rival in co-op");
assert!(!a.mission.as_ref().unwrap().goals.contains(&crate::game::Goal::Rivals));
assert!(a.script.is_some());
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the same start");
let (cx, cy) = a.map.core;
for t in 0..120u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
a.receive_turn(&cmds);
b.receive_turn(&cmds);
if t % 10 == 9 {
assert_eq!(a.checksum(), b.checksum(), "mission {mission}: the worlds stayed identical up to turn {t}");
}
}
assert!(a.sim_steps > 0 && a.sim_steps == b.sim_steps);
assert!(a.script.as_ref().unwrap().error.is_none(), "{:?}", a.script.as_ref().unwrap().error);
}
}
#[test]
fn a_broken_editor_campaign_in_the_start_is_noticed() {
let mut client = MatchClient::new();
client.on_connect("red");
client.feed(r#"{"t":"start","seed":1,"sides":[0],"mission":0,"doc":{"missions":"nope"}}"#);
let info = client.take_start().expect("start info");
assert!(info.editor && info.doc.is_none(), "the game refuses the start instead of playing a built-in mission");
}
}
+66 -19
View File
@@ -286,6 +286,13 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
menu.error = Some(i18n::tr("net.status_closed"));
}
}
MenuAction::MatchCoopEditorCreate(mission) => {
if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
} 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::MatchReady(ready) => app.client.ready(ready),
@@ -296,12 +303,14 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
app.game.coop = false;
app.match_mode = false;
app.coop = false;
app.coop_editor = false;
}
MenuAction::MatchLeave => {
app.client.leave();
app.game.coop = false;
app.match_mode = false;
app.coop = false;
app.coop_editor = false;
}
MenuAction::MatchChat(text) => app.client.chat(&text, false),
MenuAction::MatchEditField(i) => {
@@ -438,6 +447,7 @@ struct App {
ws_id: i32,
match_mode: bool,
coop: bool,
coop_editor: bool, // the co-op match plays a mission of an editor campaign
fps: f32,
renderer: String,
frame_ms: f32,
@@ -561,33 +571,56 @@ impl App {
self.game.coop = false;
self.match_mode = false;
self.coop = false;
self.coop_editor = false;
self.client.on_disconnect();
}
// 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);
game.coop = true;
if !game.start_match(&info.sides, local) {
return false;
}
game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty);
apply_game_settings(&mut game, &self.settings);
self.game = game;
let (cx, cy) = self.game.map.core;
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
self.match_mode = true;
self.coop = true;
self.seed = info.seed.wrapping_mul(31) | 1;
true
}
fn start_match_game(&mut self, info: StartInfo) -> bool {
self.coop_editor = false;
if !info.sides.contains(&0) {
return false;
}
if info.editor {
// a co-op mission of an editor campaign: every client builds it from the same file and
// the room's shared seed (random maps and block scripts roll from it)
let (Some(doc), Some(mission)) = (info.doc.as_ref(), info.mission) else { return false };
let Some(mission_doc) = doc.missions.get(mission) else { return false };
if !campaign::is_editor_coop_mission(mission_doc) {
return false;
}
let game = campaign::start_editor_coop_mission(mission_doc, mission, info.seed);
if !self.start_coop_game(game, &info) {
return false;
}
self.coop_editor = true;
self.pending_briefing = briefing_of(doc, mission);
return true;
}
if let Some(mission) = info.mission {
if !campaign::is_coop_mission(mission) {
return false;
}
let local = info.local_side.unwrap_or(0);
let mut game = campaign::start_coop_mission(mission, None);
game.coop = true;
if !game.start_match(&info.sides, local) {
return false;
}
game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty);
apply_game_settings(&mut game, &self.settings);
self.game = game;
let (cx, cy) = self.game.map.core;
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
self.match_mode = true;
self.coop = true;
self.seed = info.seed.wrapping_mul(31) | 1;
return true;
let game = campaign::start_coop_mission(mission, None);
return self.start_coop_game(game, &info);
}
if info.map_w < 72 || info.map_h < 72 {
return false;
@@ -660,7 +693,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, 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, 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 };
@@ -1368,6 +1401,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
| MenuAction::MatchResume
| MenuAction::MatchAbort
| MenuAction::MatchLeave
| MenuAction::MatchCoopEditorCreate(..)
| MenuAction::MatchChat(..)
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => {
@@ -1411,6 +1445,16 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
}
// end of a mission: next mission (won) or try again (lost)
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
// a co-op mission of an editor campaign is over: the match ends, back to the lobby
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
app.leave_match();
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
menu.open_match(true);
if !app.connect_match(&cloud) {
menu.error = Some(i18n::tr("net.status_offline"));
}
}
UiAction::EndNext => {
if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone();
@@ -1621,8 +1665,11 @@ app.pending_briefing = briefing_of(&campaign, i);
for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
}
// a won mission goes into the campaign progress (with the carry-over)
if app.custom_campaign.is_none() {
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
// editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor {
app.recorded = app.game.state != 0;
} 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 {
app.recorded = true;
+46 -8
View File
@@ -278,6 +278,7 @@ enum Btn {
MatchCoopPrev,
MatchCoopNext,
MatchCoopCreate,
MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchTake(usize),
MatchAi(usize),
MatchReady,
@@ -392,6 +393,7 @@ pub enum MenuAction {
MatchJoin(String),
MatchCreate,
MatchCoopCreate(usize),
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchTakeSide(u8),
MatchSetAi(u8, bool),
MatchReady(bool),
@@ -414,6 +416,8 @@ pub struct Menu {
pub error: Option<String>, // message on the login and account pages
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
pub coop_mission: usize, // co-op campaign mission selected on the Match page
pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
pub in_match: bool, // a match is being played; the pause menu changes its shape
pub editor_test: bool, // a mission of the editor is being tested: back to the editor
pub custom_briefing: (String, String), // title and text of an editor mission's briefing
@@ -451,6 +455,8 @@ impl Menu {
error: None,
match_ui: MatchUi::default(),
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
coop_editor: false,
coop_editor_mission: 0,
in_match: false,
editor_test: false,
custom_briefing: (String::new(), String::new()),
@@ -746,16 +752,23 @@ impl Menu {
None => MenuAction::Consumed,
},
Some(Btn::MatchCreate) => MenuAction::MatchCreate,
Some(Btn::MatchCoopPrev) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1);
Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => {
let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 };
if self.coop_editor {
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, dir);
} else {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir);
}
self.confirm = None;
MenuAction::Consumed
}
Some(Btn::MatchCoopNext) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1);
Some(Btn::MatchCoopSource) => {
self.coop_editor = !self.coop_editor && !self.campaign_doc.missions.is_empty();
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, 0);
self.confirm = None;
MenuAction::Consumed
}
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),
Some(Btn::MatchStart) => MenuAction::MatchStart,
@@ -1866,7 +1879,27 @@ impl Menu {
yy += CH() * 1.6;
}
if !ui.started && !ui.room_open() {
// the editor campaign can only be offered while it has missions (it may have been replaced)
let editor_coop = self.coop_editor && !self.campaign_doc.missions.is_empty();
let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&self.campaign_doc) } else { Vec::new() };
if editor_coop && !editor_list.contains(&self.coop_editor_mission) {
self.coop_editor_mission = editor_list.first().copied().unwrap_or(0);
}
if !ui.started && !ui.room_open() && editor_coop {
let line = match self.campaign_doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) {
Some(m) => {
let name = if self.campaign_doc.name.trim().is_empty() { "?".to_string() } else { self.campaign_doc.name.clone() };
let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() };
trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title])
}
None => tr("net.coop_editor_none"),
};
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
for text in wrap(&line, maxc).iter().take(2) {
gfx.text(text, ix, yy, SMALL(), GOLD);
yy += GH * SMALL() * 1.35;
}
} else if !ui.started && !ui.room_open() {
if let Some(d) = missions().get(self.coop_mission) {
let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
@@ -1885,9 +1918,14 @@ impl Menu {
items.push((Btn::MatchCreate, tr("net.create")));
items.push((Btn::MatchJoin, tr("net.join")));
if !ui.room_open() {
items.push((Btn::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
items.push((Btn::MatchCoopNext, ">".to_string()));
if !editor_coop || !editor_list.is_empty() {
items.push((Btn::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
items.push((Btn::MatchCoopNext, ">".to_string()));
}
if !self.campaign_doc.missions.is_empty() {
items.push((Btn::MatchCoopSource, tr(if editor_coop { "net.coop_builtin" } else { "net.coop_editor" })));
}
}
if ui.room_open() {
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));