In-game campaign, map and block script editors
Mission graph with branches, mission settings and goals, map editor on the 3D view (terrain, places, units, towers, traps, forts, rivals, zones; undo, brush, rectangle, fill, mirror), block editor with palette, nesting, live problems, undo, copy/paste and search; test play with a script debug view; demo campaign and built-in missions as templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
65677504f6
commit
30dd8441b9
10 files changed
+3372
-19
No files matched your search
+172
-8
@@ -6,6 +6,7 @@ mod campaign_doc;
|
||||
mod cloud;
|
||||
mod commands;
|
||||
mod demo;
|
||||
mod editor;
|
||||
mod dwarves;
|
||||
mod fog;
|
||||
mod fortress;
|
||||
@@ -190,11 +191,6 @@ fn prompt_line(title: &str, initial: &str, max: usize) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option<String> {
|
||||
let value = platform::prompt(title, initial)?;
|
||||
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
|
||||
Some(clean.trim().to_string())
|
||||
}
|
||||
|
||||
fn sanitize_campaign_key(value: &str) -> String {
|
||||
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
|
||||
@@ -441,6 +437,10 @@ struct App {
|
||||
frame_ms: f32,
|
||||
frame_hist: [f32; FRAME_HIST],
|
||||
frame_hist_pos: usize,
|
||||
editor: Option<editor::Editor>, // the campaign editor (takes the whole frame while open)
|
||||
editor_testing: bool, // a mission of the editor is being played
|
||||
editor_public_pending: bool, // the public campaign list still has to be fetched
|
||||
editor_loading: Option<String>, // server id of the own campaign being loaded
|
||||
custom_campaign: Option<campaign_doc::CampaignDoc>,
|
||||
custom_progress: Option<campaign_doc::CampaignProgress>,
|
||||
custom_mission_index: Option<usize>,
|
||||
@@ -652,7 +652,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 };
|
||||
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_public_pending: false, editor_loading: 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 };
|
||||
@@ -679,6 +679,17 @@ async fn main() {
|
||||
app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST;
|
||||
let mut settings_changed = false;
|
||||
|
||||
// ---------------- the campaign editor: opened from the title menu, takes the whole frame
|
||||
if menu.page == Some(Page::CampaignEditor) && app.editor.is_none() {
|
||||
app.editor = Some(editor::Editor::new(menu.campaign_doc.clone()));
|
||||
menu.page = Some(Page::Title);
|
||||
}
|
||||
if app.editor.is_some() && !app.editor_testing {
|
||||
editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now);
|
||||
next_frame().await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------------- touch or mouse: which interface is active
|
||||
// (with the touch interface touches are no longer turned into mouse events)
|
||||
simulate_mouse_with_touch(app.settings.touch_ui == 2);
|
||||
@@ -1346,6 +1357,8 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
MenuAction::Help => ui.show_help = true,
|
||||
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 => {
|
||||
if app.match_mode || app.game.in_match() {
|
||||
app.leave_match();
|
||||
@@ -1378,6 +1391,7 @@ async fn main() {
|
||||
app.coop = false;
|
||||
}
|
||||
// end of a mission: next mission (won) or try again (lost)
|
||||
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
|
||||
UiAction::EndNext => {
|
||||
if app.custom_campaign.is_some() {
|
||||
let campaign = app.custom_campaign.clone();
|
||||
@@ -1540,6 +1554,11 @@ async fn main() {
|
||||
app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32);
|
||||
app.cam.rx = app.cam.rx.clamp(0.0, mw() as f32);
|
||||
app.cam.rz = app.cam.rz.clamp(0.0, mh() as f32);
|
||||
// a mission script asked the camera to look somewhere
|
||||
if let Some(p) = app.game.script.as_mut().and_then(|s| s.camera.take()) {
|
||||
app.cam.tx = p.0;
|
||||
app.cam.tz = p.1;
|
||||
}
|
||||
app.cam.update_render(dt);
|
||||
|
||||
if settings_changed {
|
||||
@@ -1689,7 +1708,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
high: ui.tool == Tool::Dig,
|
||||
};
|
||||
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||||
if app.settings.debug_trigger_zones || app.settings.debug_path_lines {
|
||||
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
|
||||
draw_debug_3d(app, now);
|
||||
}
|
||||
let title_screen = matches!(
|
||||
@@ -1728,6 +1747,151 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
|
||||
draw_debug_fps(app, gfx);
|
||||
}
|
||||
if app.editor_testing && !menu.open() {
|
||||
draw_script_debug(app, gfx);
|
||||
}
|
||||
}
|
||||
|
||||
// test play from the editor: which trigger fired when, and the variables
|
||||
fn draw_script_debug(app: &App, gfx: &Gfx) {
|
||||
let Some(s) = app.game.script.as_ref() else { return };
|
||||
let lines: Vec<(String, Color)> = std::iter::once((i18n::tr("ed.debug_title"), ui::FRAME_HI))
|
||||
.chain(s.vars.iter().map(|(k, v)| (format!("{} = {}", k, v), ui::GOLD)))
|
||||
.chain(s.log.iter().rev().take(10).map(|(t, n)| (format!("{:>5.0}s {}", t, n), if n.starts_with('!') { ui::RED } else { ui::TXT })))
|
||||
.collect();
|
||||
let w = ui::CW() * 30.0;
|
||||
let x = ui::sw() - w - ui::CW();
|
||||
let y = ui::CH() * 5.0;
|
||||
draw_rectangle(x, y, w, ui::CH() * (lines.len() as f32 + 1.0), Color::new(0.04, 0.03, 0.03, 0.85));
|
||||
for (k, (l, c)) in lines.iter().enumerate() {
|
||||
let l: String = l.chars().take(29).collect();
|
||||
gfx.text(&l, x + ui::CW() * 0.5, y + ui::CH() * (0.5 + k as f32), ui::SMALL(), *c);
|
||||
}
|
||||
}
|
||||
|
||||
// a test play ends (pause menu, mission over): back into the editor
|
||||
fn back_to_editor(app: &mut App, menu: &mut Menu, r3d: &mut R3d) {
|
||||
app.editor_testing = false;
|
||||
menu.editor_test = false;
|
||||
menu.page = Some(Page::Title);
|
||||
app.custom_campaign = None;
|
||||
app.custom_progress = None;
|
||||
app.custom_mission_index = None;
|
||||
app.custom_next_index = None;
|
||||
r3d.invalidate();
|
||||
}
|
||||
|
||||
// one frame of the campaign editor and what it asks for
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn editor_frame(app: &mut App, menu: &mut Menu, cloud: &mut Cloud, r3d: &mut R3d, gfx: &Gfx, ui: &mut Ui, dt: f32, now: f64) {
|
||||
let Some(mut ed) = app.editor.take() else { return };
|
||||
// the savegame service: campaign lists, loads and saves
|
||||
if let CloudEvent::Error(key) = cloud.poll() {
|
||||
ed.set_status(i18n::tr(key));
|
||||
}
|
||||
if app.editor_public_pending && cloud.refresh_public_campaigns() {
|
||||
app.editor_public_pending = false;
|
||||
}
|
||||
ed.own_list = cloud.campaigns.iter().map(|c| (c.id.clone(), c.name.clone())).collect();
|
||||
ed.public_list = cloud.public_campaigns.iter().map(|c| (c.owner.clone(), c.id.clone(), c.name.clone())).collect();
|
||||
if let Some(info) = cloud.take_saved_campaign() {
|
||||
ed.server_id = Some(info.id.clone());
|
||||
ed.published = info.published;
|
||||
ed.set_status(i18n::tr("ed.saved_server"));
|
||||
}
|
||||
if let Some(text) = cloud.take_loaded_campaign() {
|
||||
match campaign_doc::CampaignDoc::load(&text) {
|
||||
Ok(doc) => {
|
||||
let id = app.editor_loading.take();
|
||||
ed.published = id.as_ref().and_then(|i| cloud.campaigns.iter().find(|c| c.id == *i)).map_or(false, |c| c.published);
|
||||
ed.load(doc, id);
|
||||
ed.set_status(i18n::tr("ed.loaded"));
|
||||
}
|
||||
Err(e) => ed.set_status(e),
|
||||
}
|
||||
}
|
||||
// a campaign file picked or dropped onto the window
|
||||
let mut incoming = platform::take_file();
|
||||
for f in get_dropped_files() {
|
||||
if let Some(b) = f.bytes {
|
||||
incoming = Some(b);
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = incoming {
|
||||
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
|
||||
Ok(doc) => {
|
||||
ed.load(doc, None);
|
||||
ed.set_status(i18n::tr("ed.loaded"));
|
||||
}
|
||||
Err(e) => ed.set_status(e),
|
||||
}
|
||||
}
|
||||
// the map editor's map has to be the active one (map size for tiles and drawing)
|
||||
if let Some(g) = ed.active_map() {
|
||||
g.map.activate();
|
||||
}
|
||||
let act = ed.frame(gfx, r3d, dt, now);
|
||||
match act {
|
||||
editor::EdAction::None => {}
|
||||
editor::EdAction::Close => {
|
||||
menu.campaign_doc = ed.doc.clone();
|
||||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
r3d.invalidate();
|
||||
menu.page = Some(Page::Title);
|
||||
return; // the editor is gone
|
||||
}
|
||||
editor::EdAction::Test(i) => {
|
||||
let progress = campaign_doc::CampaignProgress::for_doc(&ed.doc);
|
||||
(app.game, app.cam) = custom_mission_game(&ed.doc, i, progress.run_seed);
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
app.recorded = false;
|
||||
app.custom_campaign = Some(ed.doc.clone());
|
||||
app.custom_progress = Some(progress);
|
||||
app.custom_mission_index = Some(i);
|
||||
app.custom_next_index = None;
|
||||
app.editor_testing = true;
|
||||
menu.editor_test = true;
|
||||
menu.page = None;
|
||||
ui.log.clear();
|
||||
ui.show_help = false;
|
||||
r3d.invalidate();
|
||||
}
|
||||
editor::EdAction::SaveFile => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()),
|
||||
editor::EdAction::OpenFile => platform::open_file(),
|
||||
editor::EdAction::ServerSave => {
|
||||
if !cloud.available() {
|
||||
ed.set_status(i18n::tr("ed.no_server"));
|
||||
} else if !cloud.save_campaign(ed.server_id.as_deref(), &ed.doc.name, &serde_json::to_string(&ed.doc).unwrap_or_default()) {
|
||||
ed.set_status(i18n::tr("ed.busy"));
|
||||
}
|
||||
}
|
||||
editor::EdAction::ServerRefresh => {
|
||||
if cloud.available() {
|
||||
cloud.refresh_campaigns();
|
||||
app.editor_public_pending = true;
|
||||
}
|
||||
}
|
||||
editor::EdAction::ServerLoad(id) => {
|
||||
if cloud.load_campaign(&id) {
|
||||
app.editor_loading = Some(id);
|
||||
}
|
||||
}
|
||||
editor::EdAction::PublicLoad(owner, id) => {
|
||||
if cloud.load_public_campaign(&owner, &id) {
|
||||
app.editor_loading = None;
|
||||
}
|
||||
}
|
||||
editor::EdAction::Publish(on) => match ed.server_id.clone() {
|
||||
Some(id) => {
|
||||
cloud.publish_campaign(&id, on);
|
||||
}
|
||||
None => ed.set_status(i18n::tr("ed.save_first")),
|
||||
},
|
||||
}
|
||||
app.editor = Some(ed);
|
||||
}
|
||||
|
||||
fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
||||
@@ -1819,7 +1983,7 @@ fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
||||
fn draw_debug_3d(app: &App, now: f64) {
|
||||
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
|
||||
|
||||
if app.settings.debug_trigger_zones {
|
||||
if app.settings.debug_trigger_zones || app.editor_testing {
|
||||
if let Some(campaign) = app.custom_campaign.as_ref() {
|
||||
if let Some(index) = app.custom_mission_index {
|
||||
if let Some(mission) = campaign.missions.get(index) {
|
||||
|
||||
Reference in new issue
Block a user