Add campaign editor script runtime and debug overlays
This commit is contained in:
1 parent
cd35ca8e3d
commit
b06c48172b
13 files changed
+2211
-66
No files matched your search
+649
-41
@@ -2,6 +2,7 @@
|
||||
|
||||
mod audio;
|
||||
mod campaign;
|
||||
mod campaign_doc;
|
||||
mod cloud;
|
||||
mod commands;
|
||||
mod demo;
|
||||
@@ -25,6 +26,7 @@ mod rival;
|
||||
mod render3d;
|
||||
mod rooms;
|
||||
mod save;
|
||||
mod script;
|
||||
mod settings;
|
||||
mod sfx;
|
||||
mod tracks;
|
||||
@@ -45,13 +47,14 @@ use macroquad::prelude::*;
|
||||
use map::{inb, mh, mw};
|
||||
use menu::{Menu, MenuAction, Page};
|
||||
use lobby::{MatchClient, Phase, StartInfo};
|
||||
use render3d::{Cam, Overlay, R3d};
|
||||
use render3d::{Cam, Overlay, R3d, disc, line3};
|
||||
use settings::{bindable_keys, Action, Settings};
|
||||
use sound::SoundBank;
|
||||
use touch::{Gesture, TouchInput};
|
||||
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
|
||||
use ui::{chat_color, hover_info, Tool, Ui, UiAction, TOOLS};
|
||||
|
||||
const SETTINGS_KEY: &str = "dc.settings";
|
||||
const FRAME_HIST: usize = 64;
|
||||
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
|
||||
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
|
||||
|
||||
@@ -175,6 +178,38 @@ fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
|
||||
(g, cam)
|
||||
}
|
||||
|
||||
fn prompt_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();
|
||||
let clean = clean.trim().to_string();
|
||||
if clean.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(clean)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) {
|
||||
let g = campaign::start_mission_doc(def, i, None);
|
||||
let (cx, cy) = g.map.core;
|
||||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||
(g, cam)
|
||||
}
|
||||
|
||||
fn custom_next_choice(campaign: &campaign_doc::CampaignDoc, i: usize, g: &Game) -> (Option<usize>, Option<String>) {
|
||||
campaign.next_mission_choice(i, |goal| g.goal_done(goal))
|
||||
}
|
||||
|
||||
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
|
||||
|
||||
fn store_progress(p: &campaign::Progress) {
|
||||
@@ -372,6 +407,197 @@ fn store_settings(s: &Settings) {
|
||||
platform::storage_set(SETTINGS_KEY, &s.to_json());
|
||||
}
|
||||
|
||||
fn apply_game_settings(g: &mut Game, s: &Settings) {
|
||||
g.hints = s.hints;
|
||||
g.debug_markers = s.debug_command_markers;
|
||||
}
|
||||
|
||||
fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) {
|
||||
let Some(script) = doc.script.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if script.source.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") {
|
||||
g.msg("msg.script_language", vec![script.language.clone()]);
|
||||
return;
|
||||
}
|
||||
match script::run_start(&script.source) {
|
||||
Ok(m) => {
|
||||
for message in m.messages() {
|
||||
g.msg("msg.script", vec![message]);
|
||||
}
|
||||
if let Some(objective) = m.objective() {
|
||||
g.msg("msg.script_objective", vec![objective]);
|
||||
}
|
||||
for action in m.actions() {
|
||||
apply_script_action(g, doc, action);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
g.msg("msg.script_error", vec![error]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> {
|
||||
match script::run_start(source) {
|
||||
Ok(m) => {
|
||||
for action in m.actions() {
|
||||
match action {
|
||||
script::ScriptAction::Spawn { kind, .. } => {
|
||||
if script::parse_spawn_kind(&kind).is_none() {
|
||||
return Err(format!("unknown creature: {kind}"));
|
||||
}
|
||||
}
|
||||
script::ScriptAction::SpawnInZone { zone, kind, .. } => {
|
||||
if script::parse_spawn_kind(&kind).is_none() {
|
||||
return Err(format!("unknown creature: {kind}"));
|
||||
}
|
||||
if !doc.zones.iter().any(|z| z.name == zone) {
|
||||
return Err(format!("unknown zone: {zone}"));
|
||||
}
|
||||
}
|
||||
script::ScriptAction::Wave { .. } => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) {
|
||||
match action {
|
||||
script::ScriptAction::Spawn { side, kind, x, y, count, level } => {
|
||||
let Some(kind) = script::parse_spawn_kind(&kind) else {
|
||||
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
|
||||
return;
|
||||
};
|
||||
spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level);
|
||||
}
|
||||
script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => {
|
||||
let Some(kind) = script::parse_spawn_kind(&kind) else {
|
||||
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
|
||||
return;
|
||||
};
|
||||
let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else {
|
||||
g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]);
|
||||
return;
|
||||
};
|
||||
let positions = zone_spawn_positions(g, zone_doc, count);
|
||||
if positions.is_empty() {
|
||||
g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]);
|
||||
return;
|
||||
}
|
||||
spawn_script_units(g, side, kind, &positions, level);
|
||||
}
|
||||
script::ScriptAction::Wave { count } => {
|
||||
apply_script_wave(g, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_script_wave(g: &mut Game, count: i32) {
|
||||
let count = count.max(0) as u32;
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = g.mission.as_mut() {
|
||||
m.waves = true;
|
||||
if m.wave_limit != 0 {
|
||||
m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count));
|
||||
}
|
||||
}
|
||||
g.wave_timer = 0.0;
|
||||
}
|
||||
|
||||
fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) {
|
||||
let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect();
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let old_side = g.me_side;
|
||||
match side {
|
||||
0 => g.me_side = 0,
|
||||
1 => g.me_side = 1,
|
||||
2 | 3 | 4 => g.me_side = side as u8,
|
||||
_ => {
|
||||
g.msg("msg.script_error", vec![format!("unknown script side: {side}")]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (x, y) in positions {
|
||||
g.spawn(kind, side == 1, x, y, level.max(1) as u32);
|
||||
}
|
||||
g.me_side = old_side;
|
||||
}
|
||||
|
||||
fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool {
|
||||
x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32)
|
||||
}
|
||||
|
||||
fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> {
|
||||
let count = count.clamp(1, 10) as usize;
|
||||
let core = g.map.core;
|
||||
let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
|
||||
let mut out = Vec::new();
|
||||
match zone.shape.as_str() {
|
||||
"rect" | "rectangle" | "square" => {
|
||||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||
let x0 = from.0.min(to.0);
|
||||
let x1 = from.0.max(to.0);
|
||||
let y0 = from.1.min(to.1);
|
||||
let y1 = from.1.max(to.1);
|
||||
let cols = ((count as f32).sqrt().ceil()) as usize;
|
||||
for k in 0..count {
|
||||
let r = k / cols;
|
||||
let c = k % cols;
|
||||
let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 };
|
||||
let rows = ((count as f32) / cols as f32).ceil() as usize;
|
||||
let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 };
|
||||
out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"line" | "path" => {
|
||||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||
let x0 = from.0 + 0.5;
|
||||
let y0 = from.1 + 0.5;
|
||||
let x1 = to.0 + 0.5;
|
||||
let y1 = to.1 + 0.5;
|
||||
for k in 0..count {
|
||||
let t = (k as f32 + 0.5) / count as f32;
|
||||
out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"tiles" => {
|
||||
if let Some(tiles) = zone.tiles.as_ref() {
|
||||
for (x, y) in tiles.iter().take(count) {
|
||||
out.push((*x as f32 + 0.5, *y as f32 + 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let radius = zone.radius.unwrap_or(2.0).max(0.0);
|
||||
let cx = center.0 + 0.5;
|
||||
let cy = center.1 + 0.5;
|
||||
if count == 1 || radius <= 0.5 {
|
||||
out.push((cx, cy));
|
||||
} else {
|
||||
let r = radius.min(radius.max(0.5) * 0.75);
|
||||
for k in 0..count {
|
||||
let angle = (k as f32) * std::f32::consts::TAU / count as f32;
|
||||
out.push((cx + angle.cos() * r, cy + angle.sin() * r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect()
|
||||
}
|
||||
|
||||
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||||
fn save_file_name() -> String {
|
||||
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||||
@@ -396,6 +622,13 @@ struct App {
|
||||
coop: bool,
|
||||
fps: f32,
|
||||
renderer: String,
|
||||
frame_ms: f32,
|
||||
frame_hist: [f32; FRAME_HIST],
|
||||
frame_hist_pos: usize,
|
||||
custom_campaign: Option<campaign_doc::CampaignDoc>,
|
||||
custom_progress: Option<campaign_doc::CampaignProgress>,
|
||||
custom_mission_index: Option<usize>,
|
||||
custom_next_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -417,7 +650,7 @@ impl App {
|
||||
apply_settings(&self.settings, r3d);
|
||||
store_settings(&self.settings);
|
||||
}
|
||||
self.game.hints = self.settings.hints;
|
||||
apply_game_settings(&mut self.game, &self.settings);
|
||||
self.game.coop = false;
|
||||
self.coop = false;
|
||||
r3d.invalidate();
|
||||
@@ -454,7 +687,7 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
fn poll_match(&mut self) {
|
||||
fn poll_match(&mut self, now: f64) {
|
||||
if self.ws_id <= 0 {
|
||||
if self.client.connected {
|
||||
self.client.on_disconnect();
|
||||
@@ -462,8 +695,9 @@ impl App {
|
||||
return;
|
||||
}
|
||||
while let Some(msg) = platform::ws_recv(self.ws_id) {
|
||||
self.client.feed(&msg);
|
||||
self.client.feed_at(&msg, now);
|
||||
}
|
||||
self.client.tick_ping(now);
|
||||
if self.client.phase() == Phase::Closed && self.game.in_match() {
|
||||
self.game.end_match();
|
||||
self.match_mode = false;
|
||||
@@ -522,7 +756,7 @@ impl App {
|
||||
}
|
||||
game.ai_difficulty = info.difficulty;
|
||||
game.sync_ai_sides(&info.ai, info.difficulty);
|
||||
game.hints = self.settings.hints;
|
||||
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);
|
||||
@@ -542,7 +776,7 @@ impl App {
|
||||
self.game.coop = false;
|
||||
self.game.ai_difficulty = info.difficulty;
|
||||
self.game.sync_ai_sides(&info.ai, info.difficulty);
|
||||
self.game.hints = self.settings.hints;
|
||||
apply_game_settings(&mut self.game, &self.settings);
|
||||
self.game.init_fog();
|
||||
let (cx, cy) = self.game.map.core;
|
||||
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||
@@ -602,9 +836,9 @@ 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() };
|
||||
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 };
|
||||
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||
app.game.hints = app.settings.hints;
|
||||
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 };
|
||||
let mut touch = TouchInput::new();
|
||||
let mut auto_touch = platform::coarse_pointer();
|
||||
@@ -621,6 +855,10 @@ async fn main() {
|
||||
let now = get_time();
|
||||
let fps_now = 1.0 / raw_dt.max(0.001);
|
||||
app.fps = if app.fps <= 0.0 { fps_now } else { app.fps + (fps_now - app.fps) * 0.08 };
|
||||
let raw_ms = raw_dt * 1000.0;
|
||||
app.frame_ms = if app.frame_ms <= 0.0 { raw_ms } else { app.frame_ms + (raw_ms - app.frame_ms) * 0.08 };
|
||||
app.frame_hist[app.frame_hist_pos] = raw_ms;
|
||||
app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST;
|
||||
let mut settings_changed = false;
|
||||
|
||||
// ---------------- touch or mouse: which interface is active
|
||||
@@ -658,7 +896,25 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = incoming {
|
||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||||
if menu.campaign_pending {
|
||||
menu.campaign_pending = false;
|
||||
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
|
||||
Ok(campaign) => {
|
||||
let errors = campaign.validate();
|
||||
if errors.is_empty() {
|
||||
app.set_status(i18n::trf("campaign.loaded", &[campaign.name.clone()]), now);
|
||||
menu.campaign_doc = campaign;
|
||||
menu.campaign_selected = 0;
|
||||
menu.page = Some(Page::CampaignEditor);
|
||||
} else {
|
||||
app.set_status(i18n::trf("campaign.invalid", &[errors.join(" | ")]), now);
|
||||
}
|
||||
}
|
||||
Err(e) => app.set_status(i18n::trf("campaign.invalid", &[e]), now),
|
||||
}
|
||||
} else {
|
||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- savegame service: finished requests
|
||||
@@ -712,7 +968,7 @@ async fn main() {
|
||||
}
|
||||
|
||||
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
|
||||
app.poll_match();
|
||||
app.poll_match(now);
|
||||
menu.in_match = app.match_mode || app.game.in_match();
|
||||
if matches!(menu.page, Some(Page::Match { .. })) {
|
||||
menu.set_match_ui(app.client.ui());
|
||||
@@ -871,7 +1127,7 @@ async fn main() {
|
||||
if is_key_pressed(KeyCode::N) && app.game.state != 0 {
|
||||
app.seed = next_seed(app.seed);
|
||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
}
|
||||
}
|
||||
if !dialog && !menu.typing() {
|
||||
@@ -1035,7 +1291,7 @@ async fn main() {
|
||||
MenuAction::NewGame => {
|
||||
app.seed = next_seed(app.seed);
|
||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
menu.page = None;
|
||||
ui.log.clear();
|
||||
ui.show_help = true;
|
||||
@@ -1079,7 +1335,7 @@ async fn main() {
|
||||
}
|
||||
MenuAction::StartMission(i) => {
|
||||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
app.recorded = false;
|
||||
@@ -1088,6 +1344,140 @@ async fn main() {
|
||||
ui.show_help = false;
|
||||
autosave_t = 0.0;
|
||||
}
|
||||
MenuAction::CampaignNew => {
|
||||
menu.campaign_doc = campaign_doc::CampaignDoc::default();
|
||||
menu.campaign_doc.add_mission();
|
||||
menu.campaign_selected = 0;
|
||||
menu.page = Some(Page::CampaignEditor);
|
||||
app.set_status(i18n::tr("campaign.new"), now);
|
||||
}
|
||||
MenuAction::CampaignAdd => {
|
||||
menu.campaign_doc.add_mission();
|
||||
menu.campaign_selected = menu.campaign_doc.missions.len().saturating_sub(1);
|
||||
menu.page = Some(Page::CampaignEditor);
|
||||
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||||
}
|
||||
MenuAction::CampaignOpen => {
|
||||
menu.campaign_pending = true;
|
||||
platform::open_file();
|
||||
app.set_status(i18n::tr("campaign.choose"), now);
|
||||
}
|
||||
MenuAction::CampaignSave => {
|
||||
let name = menu.campaign_doc.file_name();
|
||||
platform::download(&name, menu.campaign_doc.save().as_bytes());
|
||||
app.set_status(i18n::trf("campaign.saved", &[name]), now);
|
||||
}
|
||||
MenuAction::CampaignEditName => {
|
||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
|
||||
menu.campaign_doc.name = value;
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignEditTitle => {
|
||||
let i = menu.campaign_selected;
|
||||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.title.clone()) {
|
||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_title"), &initial, 80) {
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.title = value;
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignEditGoals => {
|
||||
let i = menu.campaign_selected;
|
||||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.goals.join(", ")) {
|
||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_goals"), &initial, 120) {
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.goals = campaign_doc::parse_list(&value);
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignEditNext => {
|
||||
let i = menu.campaign_selected;
|
||||
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.next.clone()).unwrap_or_default();
|
||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_next"), &initial, 48) {
|
||||
let key = sanitize_campaign_key(&value);
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.next = Some(key);
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignEditBranches => {
|
||||
let i = menu.campaign_selected;
|
||||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| campaign_doc::branches_to_text(&m.branches)) {
|
||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_branches"), &initial, 180) {
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.branches = campaign_doc::parse_branches(&value);
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignEditScript => {
|
||||
let i = menu.campaign_selected;
|
||||
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default();
|
||||
if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) {
|
||||
if source.trim().is_empty() {
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.script = None;
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
} else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() {
|
||||
match validate_script_source(&mission, &source) {
|
||||
Ok(()) => {
|
||||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||||
mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source });
|
||||
}
|
||||
app.set_status(i18n::tr("campaign.updated"), now);
|
||||
}
|
||||
Err(error) => {
|
||||
app.set_status(i18n::trf("msg.script_error", &vec![error]), now);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.set_status(i18n::tr("campaign.error_no_mission"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignDelete(i) => {
|
||||
if i < menu.campaign_doc.missions.len() {
|
||||
menu.campaign_doc.missions.remove(i);
|
||||
menu.campaign_selected = menu.campaign_selected.min(menu.campaign_doc.missions.len().saturating_sub(1));
|
||||
if i == app.custom_mission_index.unwrap_or(usize::MAX) {
|
||||
app.custom_campaign = None;
|
||||
app.custom_progress = None;
|
||||
app.custom_mission_index = None;
|
||||
app.custom_next_index = None;
|
||||
}
|
||||
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||||
}
|
||||
}
|
||||
MenuAction::CampaignStart(i) => {
|
||||
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
|
||||
if menu.campaign_doc.validate().is_empty() {
|
||||
let def = campaign::mission_def_from_doc(mission_doc);
|
||||
(app.game, app.cam) = custom_mission_game(&def, i);
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
start_custom_script(&mut app.game, mission_doc);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
app.recorded = false;
|
||||
app.custom_campaign = Some(menu.campaign_doc.clone());
|
||||
app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc));
|
||||
app.custom_mission_index = Some(i);
|
||||
menu.page = None;
|
||||
ui.log.clear();
|
||||
ui.show_help = false;
|
||||
autosave_t = 0.0;
|
||||
} else {
|
||||
app.set_status(i18n::tr("campaign.invalid"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
|
||||
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
|
||||
a @ (MenuAction::Login(..)
|
||||
@@ -1144,18 +1534,65 @@ async fn main() {
|
||||
UiAction::NewGame => {
|
||||
app.seed = next_seed(app.seed);
|
||||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
}
|
||||
// end of a mission: next mission (won) or try again (lost)
|
||||
UiAction::EndNext => {
|
||||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||||
if app.custom_campaign.is_some() {
|
||||
let campaign = app.custom_campaign.clone();
|
||||
let progress = app.custom_progress.clone();
|
||||
if app.game.state == 1 {
|
||||
if let Some(next) = app.custom_next_index {
|
||||
if let Some(campaign) = campaign {
|
||||
if next < campaign.missions.len() {
|
||||
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
|
||||
(app.game, app.cam) = custom_mission_game(&def, next);
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
start_custom_script(&mut app.game, &campaign.missions[next]);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
app.recorded = false;
|
||||
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||||
app.custom_campaign = Some(campaign);
|
||||
app.custom_progress = Some(progress);
|
||||
app.custom_mission_index = Some(next);
|
||||
app.custom_next_index = None;
|
||||
menu.page = None;
|
||||
ui.log.clear();
|
||||
ui.show_help = false;
|
||||
autosave_t = 0.0;
|
||||
} else {
|
||||
menu.page = Some(Page::CampaignEditor);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
menu.page = Some(Page::CampaignEditor);
|
||||
}
|
||||
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
|
||||
if i < campaign.missions.len() {
|
||||
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
|
||||
(app.game, app.cam) = custom_mission_game(&def, i);
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
start_custom_script(&mut app.game, &campaign.missions[i]);
|
||||
app.game.coop = false;
|
||||
app.coop = false;
|
||||
app.recorded = false;
|
||||
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||||
app.custom_campaign = Some(campaign);
|
||||
app.custom_progress = Some(progress);
|
||||
app.custom_mission_index = Some(i);
|
||||
app.custom_next_index = None;
|
||||
ui.log.clear();
|
||||
}
|
||||
}
|
||||
} else if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||||
if app.game.state == 1 {
|
||||
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
|
||||
} else {
|
||||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
app.recorded = false;
|
||||
ui.log.clear();
|
||||
}
|
||||
@@ -1273,7 +1710,7 @@ async fn main() {
|
||||
if settings_changed {
|
||||
apply_settings(&app.settings, &mut r3d);
|
||||
store_settings(&app.settings);
|
||||
app.game.hints = app.settings.hints;
|
||||
apply_game_settings(&mut app.game, &app.settings);
|
||||
}
|
||||
|
||||
// ---------------- update (single-player worlds stand still while menus or dialogs are open;
|
||||
@@ -1310,12 +1747,32 @@ async fn main() {
|
||||
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 let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
||||
if app.game.state != 0 {
|
||||
app.recorded = true;
|
||||
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;
|
||||
if app.game.state == 1 {
|
||||
menu.progress.set_done(i, campaign::carry_from(&app.game));
|
||||
store_progress(&menu.progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if app.game.state != 0 && !app.recorded {
|
||||
app.recorded = true;
|
||||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||||
app.custom_mission_index = Some(i);
|
||||
if app.game.state == 1 {
|
||||
menu.progress.set_done(i, campaign::carry_from(&app.game));
|
||||
store_progress(&menu.progress);
|
||||
let next_info = match &app.custom_campaign {
|
||||
Some(campaign) => Some(custom_next_choice(campaign, i, &app.game)),
|
||||
None => None,
|
||||
};
|
||||
let mission_count = app.custom_campaign.as_ref().map(|c| c.missions.len()).unwrap_or(0);
|
||||
if let Some((next_i, choice)) = next_info {
|
||||
if let Some(progress) = &mut app.custom_progress {
|
||||
progress.mark_done(i, mission_count, choice);
|
||||
}
|
||||
app.custom_next_index = next_i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1327,6 +1784,7 @@ async fn main() {
|
||||
| Some(Page::KeyCapture { from_title: true, .. })
|
||||
| Some(Page::NewGame)
|
||||
| Some(Page::Campaign)
|
||||
| Some(Page::CampaignEditor)
|
||||
| Some(Page::Briefing { ingame: false, .. })
|
||||
| Some(Page::Login)
|
||||
| Some(Page::Account)
|
||||
@@ -1396,12 +1854,16 @@ 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 {
|
||||
draw_debug_3d(app, now);
|
||||
}
|
||||
let title_screen = matches!(
|
||||
menu.page,
|
||||
Some(Page::Title)
|
||||
| Some(Page::Settings { from_title: true, .. })
|
||||
| Some(Page::KeyCapture { from_title: true, .. })
|
||||
| Some(Page::Campaign)
|
||||
| Some(Page::CampaignEditor)
|
||||
| Some(Page::Briefing { ingame: false, .. })
|
||||
| Some(Page::Login)
|
||||
| Some(Page::Account)
|
||||
@@ -1428,7 +1890,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
if !touch {
|
||||
ui.draw_cursor(gfx, &app.game, now, blocked);
|
||||
}
|
||||
if app.settings.debug_fps {
|
||||
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
|
||||
draw_debug_fps(app, gfx);
|
||||
}
|
||||
}
|
||||
@@ -1436,24 +1898,170 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
||||
ui::begin_ui();
|
||||
let scale = ui::SMALL().max(1.0);
|
||||
let fps = app.fps.max(0.0).round() as i32;
|
||||
let renderer = {
|
||||
let r = app.renderer.trim();
|
||||
if r.is_empty() {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
"WebGL"
|
||||
let text_h = scale * 10.0 + 4.0;
|
||||
|
||||
if app.settings.debug_fps {
|
||||
let fps = app.fps.max(0.0).round() as i32;
|
||||
let renderer = {
|
||||
let r = app.renderer.trim();
|
||||
if r.is_empty() {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
"WebGL"
|
||||
} else {
|
||||
"OpenGL"
|
||||
}
|
||||
} else {
|
||||
"OpenGL"
|
||||
r
|
||||
}
|
||||
} else {
|
||||
r
|
||||
};
|
||||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||||
let head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
|
||||
let head_w = gfx.measure(&head, scale);
|
||||
let head_x = (ui::sw() - head_w - 2.0).max(2.0);
|
||||
draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||
gfx.text(&head, head_x, 2.0, scale, GOLD);
|
||||
}
|
||||
|
||||
if app.settings.debug_frame_time {
|
||||
let ms = app.frame_ms.max(0.0);
|
||||
let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0);
|
||||
let graph_h = (scale * 30.0).clamp(20.0, 52.0);
|
||||
let frame_y = (ui::sh() - text_h - 2.0).max(12.0);
|
||||
let graph_y = (frame_y - graph_h - 6.0).max(12.0);
|
||||
let graph_x = (ui::sw() - graph_w - 2.0).max(2.0);
|
||||
|
||||
let mut max_ms = ms.max(16.7);
|
||||
for v in app.frame_hist.iter() {
|
||||
max_ms = max_ms.max(*v);
|
||||
}
|
||||
};
|
||||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||||
let line: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
|
||||
let w = gfx.measure(&line, scale);
|
||||
let x = (ui::sw() - w - 2.0).max(2.0);
|
||||
let y = 2.0;
|
||||
draw_rectangle(x - 2.0, y - 2.0, w + 4.0, scale * 10.0 + 4.0, Color::from_rgba(0, 0, 0, 150));
|
||||
gfx.text(&line, x, y, scale, GOLD);
|
||||
let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0);
|
||||
|
||||
draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170));
|
||||
let bar_w = graph_w / FRAME_HIST as f32;
|
||||
for k in 0..FRAME_HIST {
|
||||
let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST];
|
||||
if v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08);
|
||||
let col = if v <= 16.7 {
|
||||
Color::from_rgba(90, 220, 120, 235)
|
||||
} else if v <= 33.3 {
|
||||
Color::from_rgba(240, 210, 100, 235)
|
||||
} else {
|
||||
Color::from_rgba(235, 90, 90, 235)
|
||||
};
|
||||
draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col);
|
||||
}
|
||||
draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50));
|
||||
|
||||
let frame_line = format!("{:.1} ms", ms);
|
||||
let frame_w = gfx.measure(&frame_line, scale);
|
||||
let frame_x = (ui::sw() - frame_w - 2.0).max(2.0);
|
||||
draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||
gfx.text(&frame_line, frame_x, frame_y, scale, GOLD);
|
||||
}
|
||||
|
||||
if app.settings.debug_ping {
|
||||
let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() };
|
||||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||||
let line: String = format!("ping {}", ping).chars().take(max_cols).collect();
|
||||
let line_w = gfx.measure(&line, scale);
|
||||
draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||||
let color = if !app.client.connected {
|
||||
ui::DIM
|
||||
} else if app.client.ping_ms <= 120.0 {
|
||||
ui::GREEN
|
||||
} else if app.client.ping_ms <= 300.0 {
|
||||
Color::from_rgba(240, 210, 100, 255)
|
||||
} else {
|
||||
ui::RED
|
||||
};
|
||||
gfx.text(&line, 2.0, ui::sh() - text_h, scale, color);
|
||||
}
|
||||
}
|
||||
|
||||
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 let Some(campaign) = app.custom_campaign.as_ref() {
|
||||
if let Some(index) = app.custom_mission_index {
|
||||
if let Some(mission) = campaign.missions.get(index) {
|
||||
let done = app.custom_progress.as_ref().map(|p| p.is_done(index)).unwrap_or(false);
|
||||
let zone_pulse = if done { pulse * 0.45 } else { pulse };
|
||||
for zone in mission.zones.iter().take(80) {
|
||||
draw_debug_zone(zone, app.game.map.core, zone_pulse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.settings.debug_path_lines {
|
||||
let mut drawn = 0usize;
|
||||
for e in app.game.ents.iter() {
|
||||
if !e.alive || e.held || e.hero || e.side != app.game.local_side || e.path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if drawn >= 120 {
|
||||
break;
|
||||
}
|
||||
drawn += 1;
|
||||
let color = chat_color(Some(e.side), 0.55);
|
||||
let mut prev = vec3(e.disp_x, 0.075, e.disp_y);
|
||||
for tile in e.path.iter().take(12) {
|
||||
let (tx, ty) = map::xy(*tile);
|
||||
let p = vec3(tx as f32 + 0.5, 0.055, ty as f32 + 0.5);
|
||||
line3(prev, p, 0.04, color);
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_debug_zone(zone: &campaign_doc::ZoneDoc, core: (i32, i32), pulse: f32) {
|
||||
let c = debug_zone_color(&zone.name);
|
||||
let color = Color::new(c.r, c.g, c.b, pulse);
|
||||
let at = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
|
||||
let shape = zone.shape.as_str();
|
||||
|
||||
match shape {
|
||||
"rect" | "rectangle" | "square" => {
|
||||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||
let a = vec3(from.0 + 0.5, 0.05, from.1 + 0.5);
|
||||
let b = vec3(to.0 + 0.5, 0.05, to.1 + 0.5);
|
||||
line3(vec3(a.x, 0.05, a.y), vec3(b.x, 0.05, a.y), 0.05, color);
|
||||
line3(vec3(b.x, 0.05, a.y), vec3(b.x, 0.05, b.y), 0.05, color);
|
||||
line3(vec3(b.x, 0.05, b.y), vec3(a.x, 0.05, b.y), 0.05, color);
|
||||
line3(vec3(a.x, 0.05, b.y), vec3(a.x, 0.05, a.y), 0.05, color);
|
||||
}
|
||||
}
|
||||
"line" | "path" => {
|
||||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||||
let w = zone.width.unwrap_or(1.0).max(0.2);
|
||||
line3(vec3(from.0 + 0.5, 0.05, from.1 + 0.5), vec3(to.0 + 0.5, 0.05, to.1 + 0.5), w * 0.05, color);
|
||||
}
|
||||
}
|
||||
"tiles" => {
|
||||
if let Some(tiles) = zone.tiles.as_ref() {
|
||||
for (x, y) in tiles.iter().take(200) {
|
||||
disc(vec3(*x as f32 + 0.5, 0.045, *y as f32 + 0.5), 0.45, color, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let r = zone.radius.unwrap_or(2.0);
|
||||
disc(vec3(at.0 + 0.5, 0.04, at.1 + 0.5), r, color, 48);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_zone_color(name: &str) -> Color {
|
||||
let mut h: u64 = 2166136261;
|
||||
for b in name.bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(1099511628211);
|
||||
}
|
||||
Color::from_rgba(80 + ((h % 176) as u8), 90 + (((h >> 8) % 166) as u8), 120 + (((h >> 16) % 136) as u8), 255)
|
||||
}
|
||||
Reference in new issue
Block a user