Statistics with a chart at the end of a game, 64 achievements kept locally and on the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 23:36:19 +01:00
1 parent 193b199875
commit 9931ca9b54
37 files changed
+1833 -4

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
// Achievements: a lifetime profile of the player (totals of every game's statistics, wins,
// minutes played) and 64 achievements - tiers of counters (bronze, silver, gold, ...) and a few
// special, partly hidden ones. Kept in the browser; after a login merged with the server's copy
// (the larger counter wins, unlocked stays unlocked) and stored there too.
use crate::game::*;
use crate::stats as k;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const PROFILE_KEY: &str = "dc.profile";
// counters of the profile: the game statistics (stats.rs) and a few of its own
pub const STAT_NAMES: [&str; k::COUNTERS] = [
"heroes", "bosses", "lost", "dug", "wood", "walls", "rooms", "spells", "slaps", "traps", "towers", "drowned", "burnt", "siege", "captured", "gold", "drawbridge",
];
// tiered achievements: id (= counter), thresholds, icon cell
pub const TIERS: [(&str, &[f64], u32); 20] = [
("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::gfx::I_ARROW),
("bosses", &[1.0, 5.0, 20.0], crate::gfx::I_RUNE),
("dug", &[100.0, 1000.0, 10000.0], crate::gfx::I_PICK),
("gold", &[10000.0, 100000.0, 1000000.0], crate::gfx::I_SELL),
("wood", &[50.0, 500.0, 2000.0], crate::gfx::I_BRIDGE),
("walls", &[10.0, 100.0], crate::gfx::I_WALL),
("rooms", &[50.0, 500.0, 2000.0], crate::gfx::I_STEEL),
("spells", &[10.0, 100.0, 500.0], crate::gfx::I_BOLT),
("slaps", &[10.0, 100.0, 1000.0], crate::gfx::I_HAND),
("traps", &[5.0, 50.0, 200.0], crate::gfx::I_TRAPS),
("towers", &[5.0, 50.0], crate::gfx::I_CANNON),
("drowned", &[1.0, 25.0, 100.0], crate::gfx::I_FREEZE),
("burnt", &[5.0, 50.0], crate::gfx::I_DRAWBRIDGE),
("siege", &[1.0, 10.0], crate::gfx::I_CATAPULT),
("captured", &[1.0, 25.0, 100.0], crate::gfx::I_SUMMON),
("drawbridge", &[1.0, 50.0], crate::gfx::I_DRAWBRIDGE),
("wins", &[1.0, 10.0, 50.0], crate::gfx::I_FLAG),
("missions", &[1.0, 10.0, 30.0], crate::gfx::I_MAGIC),
("coop", &[1.0, 10.0, 20.0], crate::gfx::I_HEAL),
("minutes", &[60.0, 600.0, 3000.0], crate::gfx::I_SPELLS),
];
// special achievements: id, hidden until unlocked, icon cell
pub const SPECIAL: [(&str, bool, u32); 8] = [
("flawless", false, crate::gfx::I_HEAL),
("speedrun", false, crate::gfx::I_BOLT),
("campaign", false, crate::gfx::I_RUNE),
("surface", false, crate::gfx::I_WALL),
("defeat", true, crate::gfx::I_SELL),
("legend", false, crate::gfx::I_ARROW),
("wyrm", true, crate::gfx::I_SUMMON),
("fullhand", true, crate::gfx::I_HAND),
];
// number of achievements in all
#[cfg(test)]
pub fn count() -> usize {
TIERS.iter().map(|t| t.1.len()).sum::<usize>() + SPECIAL.len()
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Profile {
#[serde(default)]
pub totals: BTreeMap<String, f64>,
#[serde(default)]
pub unlocked: BTreeMap<String, u64>, // achievement id ("heroes.2", "flawless") -> unix time
}
impl Profile {
pub fn total(&self, id: &str) -> f64 {
self.totals.get(id).copied().unwrap_or(0.0)
}
// the larger counter wins, unlocked stays unlocked (with the earlier date)
pub fn merge(&mut self, other: &Profile) {
for (k, v) in &other.totals {
let e = self.totals.entry(k.clone()).or_insert(0.0);
*e = e.max(*v);
}
for (k, t) in &other.unlocked {
let e = self.unlocked.entry(k.clone()).or_insert(*t);
*e = (*e).min(*t);
}
}
// unlocks what the totals have reached; returns the new ones
pub fn check_tiers(&mut self, now: u64) -> Vec<String> {
let mut new = Vec::new();
for (id, steps, _) in TIERS.iter() {
let v = self.total(id);
for (t, &need) in steps.iter().enumerate() {
let key = format!("{}.{}", id, t + 1);
if v >= need && !self.unlocked.contains_key(&key) {
self.unlocked.insert(key.clone(), now);
new.push(key);
}
}
}
new
}
pub fn unlock(&mut self, id: &str, now: u64) -> bool {
if self.unlocked.contains_key(id) {
return false;
}
self.unlocked.insert(id.to_string(), now);
true
}
}
// what the tracker needs to know about the running game besides the game itself
pub struct Context {
pub coop: bool,
pub editor_test: bool, // test play from the editor counts nothing
}
// Follows the running game: adds what happened to the profile every few seconds and unlocks
pub struct Tracker {
pub profile: Profile,
flushed: Vec<f32>,
last_time: f32,
ended: bool,
t: f32,
pub dirty: bool, // changed since the last store
pub upload: bool, // something new for the server
}
impl Tracker {
pub fn new(profile: Profile) -> Tracker {
Tracker { profile, flushed: vec![0.0; k::COUNTERS], last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false }
}
// a different game started (new, loaded): nothing of it has been counted yet
fn restart(&mut self, g: &Game) {
self.flushed = (0..k::COUNTERS).map(|i| g.stats.get(i)).collect();
self.ended = g.state != 0;
}
// called every frame; returns achievements unlocked just now
pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec<String> {
if g.demo.is_some() || ctx.editor_test {
self.last_time = 0.0;
return Vec::new();
}
// a loaded or new game: its old counters are not counted again
if g.time + 0.5 < self.last_time || self.last_time == 0.0 {
self.restart(g);
}
self.last_time = g.time.max(0.01);
self.t += dt;
let ended_now = g.state != 0 && !self.ended;
if self.t < 5.0 && !ended_now {
return Vec::new();
}
let played = self.t;
self.t = 0.0;
let mut new = Vec::new();
// the counters of the game
for i in 0..k::COUNTERS {
let cur = g.stats.get(i);
let d = cur - self.flushed[i];
if d > 0.0 {
*self.profile.totals.entry(STAT_NAMES[i].to_string()).or_insert(0.0) += d as f64;
self.flushed[i] = cur;
self.dirty = true;
}
}
if g.state == 0 {
*self.profile.totals.entry("minutes".into()).or_insert(0.0) += (played / 60.0) as f64;
self.dirty = true;
}
// the special ones that can happen any time
if g.ents.iter().any(|e| e.alive && e.creature() && e.level >= 10) && self.profile.unlock("legend", now) {
new.push("legend".to_string());
}
if g.ents.iter().any(|e| e.alive && e.creature() && e.kind == EK::Wyrm) && self.profile.unlock("wyrm", now) {
new.push("wyrm".to_string());
}
if g.hand.len() >= 8 && self.profile.unlock("fullhand", now) {
new.push("fullhand".to_string());
}
// the end of the game
if ended_now {
self.ended = true;
if g.state == 1 {
let key = if ctx.coop {
"coop"
} else if g.mission.is_some() {
"missions"
} else {
"wins"
};
*self.profile.totals.entry(key.into()).or_insert(0.0) += 1.0;
if g.stats.get(k::LOST) == 0.0 && self.profile.unlock("flawless", now) {
new.push("flawless".into());
}
if g.time < 15.0 * 60.0 && self.profile.unlock("speedrun", now) {
new.push("speedrun".into());
}
let last = crate::campaign::missions().len().saturating_sub(1);
if !ctx.coop && g.mission.as_ref().map_or(false, |m| m.index == last) && self.profile.unlock("campaign", now) {
new.push("campaign".into());
}
if !g.map.sky.is_empty() && self.profile.unlock("surface", now) {
new.push("surface".into());
}
} else if self.profile.unlock("defeat", now) {
new.push("defeat".into());
}
self.dirty = true;
}
new.extend(self.profile.check_tiers(now));
if !new.is_empty() {
self.dirty = true;
self.upload = true;
}
new
}
}
// the name and description of an achievement ("heroes.2" or "flawless") in the current language
pub fn texts(id: &str) -> (String, String) {
use crate::i18n::{tr, trf};
match id.split_once('.') {
Some((cat, tier)) => {
let t: usize = tier.parse().unwrap_or(1);
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t - 1)).copied().unwrap_or(0.0);
let roman = ["I", "II", "III", "IV", "V"][(t - 1).min(4)];
(trf(&format!("ach.{}.name", cat), &[roman.to_string()]), trf(&format!("ach.{}.desc", cat), &[fmt_count(need)]))
}
None => (tr(&format!("ach.{}.name", id)), tr(&format!("ach.{}.desc", id))),
}
}
pub fn fmt_count(v: f64) -> String {
let n = v.round() as i64;
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push('.');
}
out.push(c);
}
out
}
// every achievement in display order: (id, hidden, icon, tier 0..)
pub fn all() -> Vec<(String, bool, u32, usize)> {
let mut v = Vec::new();
for (id, steps, icon) in TIERS.iter() {
for t in 0..steps.len() {
v.push((format!("{}.{}", id, t + 1), false, *icon, t));
}
}
for (id, hidden, icon) in SPECIAL.iter() {
v.push((id.to_string(), *hidden, *icon, 9));
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn more_than_fifty_achievements() {
assert!(count() >= 50, "{}", count());
assert_eq!(all().len(), count());
}
#[test]
fn the_tracker_counts_a_game_and_unlocks() {
let mut g = Game::new(3, 64, 64);
let mut t = Tracker::new(Profile::default());
let ctx = Context { coop: false, editor_test: false };
g.time = 1.0;
t.update(&g, &ctx, 0.1, 1);
g.count(k::HEROES, 3.0);
g.count(k::DUG, 150.0);
g.time = 10.0;
let new = t.update(&g, &ctx, 6.0, 2);
assert!(new.contains(&"heroes.1".to_string()) && new.contains(&"dug.1".to_string()), "{:?}", new);
assert_eq!(t.profile.total("heroes"), 3.0);
// the same counters are not counted twice
g.time = 20.0;
t.update(&g, &ctx, 6.0, 3);
assert_eq!(t.profile.total("heroes"), 3.0);
// a won free game without losses
g.state = 1;
let new = t.update(&g, &ctx, 0.1, 4);
assert!(new.contains(&"wins.1".to_string()) && new.contains(&"flawless".to_string()), "{:?}", new);
}
#[test]
fn profiles_merge() {
let mut a = Profile::default();
a.totals.insert("heroes".into(), 10.0);
a.unlocked.insert("heroes.1".into(), 5);
let mut b = Profile::default();
b.totals.insert("heroes".into(), 7.0);
b.totals.insert("dug".into(), 100.0);
b.unlocked.insert("heroes.1".into(), 3);
b.unlocked.insert("dug.1".into(), 4);
a.merge(&b);
assert_eq!(a.total("heroes"), 10.0);
assert_eq!(a.total("dug"), 100.0);
assert_eq!(a.unlocked["heroes.1"], 3);
assert!(a.unlocked.contains_key("dug.1"));
}
}
+1
View File
@@ -110,6 +110,7 @@ impl Game {
return;
}
self.ents[j].boss = 0;
self.count_local(crate::stats::BOSSES);
let got = self.add_gold(BOSS_GOLD);
self.rune_free += 1;
let (x, y) = (self.ents[j].x, self.ents[j].y);
+2
View File
@@ -128,6 +128,7 @@ impl Game {
}
if t >= BURN_TIME {
self.bridgework.burning.swap_remove(k);
self.count_local(crate::stats::BURNT);
self.bridge_falls(i);
continue;
}
@@ -262,6 +263,7 @@ impl Game {
t.res = (t.res & DB_LAVA) | state;
}
if raise {
self.count(crate::stats::DRAWBRIDGE, 1.0);
// whoever stands on it jumps to the bank (or falls)
for &g in &group {
self.ground_lost(g);
+20
View File
@@ -80,6 +80,8 @@ enum Req {
CampaignPublish,
PublicList,
PublicLoad,
ProfileLoad,
ProfileSave,
}
// the logged-in account
@@ -124,6 +126,7 @@ pub struct Cloud {
pub public_listed: bool,
pub campaign_error: Option<&'static str>, // language key of the last failed campaign request
loaded_campaign: Option<String>,
pub profile_loaded: Option<String>, // the profile from the server, waiting to be merged
saved_campaign: Option<CampaignInfo>,
pending: Option<(u32, Req)>,
save_name: String,
@@ -176,6 +179,7 @@ impl Cloud {
public_listed: false,
campaign_error: None,
loaded_campaign: None,
profile_loaded: None,
saved_campaign: None,
pending: Some((id, Req::Health)),
save_name: String::new(),
@@ -330,6 +334,13 @@ impl Cloud {
CloudEvent::Error(error_key(res.status))
}
}
Req::ProfileLoad => {
if ok {
self.profile_loaded = Some(String::from_utf8_lossy(&res.body).into_owned());
}
CloudEvent::None
}
Req::ProfileSave => CloudEvent::None,
Req::CreateUser | Req::DeleteUser => {
if ok {
self.load_users();
@@ -470,6 +481,15 @@ impl Cloud {
}
// JSON text of a campaign loaded by load_campaign() or load_public_campaign()
// the profile (statistics and achievements) of the logged-in player
pub fn load_profile(&mut self) -> bool {
self.session.is_some() && self.start("GET", "profile", &[], Req::ProfileLoad)
}
pub fn save_profile(&mut self, json: &str) -> bool {
self.session.is_some() && self.start("PUT", "profile", json.as_bytes(), Req::ProfileSave)
}
pub fn take_loaded_campaign(&mut self) -> Option<String> {
self.loaded_campaign.take()
}
+30
View File
@@ -304,6 +304,11 @@ pub struct Ent {
pub disp_y: f32,
}
// units of the neutral dwarves count for nobody
fn e_is_dwarf_side(side: u8) -> bool {
side == crate::dwarves::DWARF_SIDE
}
fn yes() -> bool {
true
}
@@ -596,6 +601,8 @@ pub struct Game {
#[serde(default)]
pub bridgework: crate::bridges::BridgeWork, // burning and worn bridges
#[serde(default)]
pub stats: crate::stats::GameStats, // statistics of the master at this computer (see stats.rs)
#[serde(default)]
pub wall_plans: Vec<(usize, u8)>, // walls to raise: tile, master side (see walls.rs)
#[serde(default)]
pub wood: f32, // the second resource (see wood.rs)
@@ -920,6 +927,7 @@ impl Game {
vrng: crate::particles::vrng(),
dwarf_caves: Vec::new(),
bridgework: Default::default(),
stats: Default::default(),
wall_plans: Vec::new(),
wood: crate::wood::WOOD_START,
woodwork: Default::default(),
@@ -1137,6 +1145,7 @@ impl Game {
self.gold += acc;
if acc > 0.0 {
self.incoming.push((res, acc, at));
self.count(crate::stats::GOLD, acc);
}
acc
}
@@ -1384,6 +1393,7 @@ impl Game {
}
let local = self.local_side;
self.switch_to(local);
self.sample_stats(STEP);
}
// ---------------------------------------------------------------- masters
@@ -2494,6 +2504,7 @@ impl Game {
t.hp = 0.0;
t.gold = 0;
let (x, y) = xy(i);
self.count(crate::stats::DUG, 1.0);
self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66);
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
}
@@ -2759,6 +2770,8 @@ impl Game {
e.patrol.clear();
let b = st(kind).bounty as f32;
let got = self.add_gold(b);
self.count_local(crate::stats::HEROES);
self.count_local(crate::stats::CAPTURED);
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::Capture, x, y);
self.msg("msg.hero_captured", vec![format!("@{}", st(kind).name), (got as i32).to_string()]);
@@ -2780,6 +2793,15 @@ impl Game {
self.bury(x, y);
}
let s = st(kind);
if hero {
self.count_local(crate::stats::HEROES);
} else if side == self.local_side && !e_is_dwarf_side(side) {
let me = self.me_side;
let local = self.local_side;
self.switch_to(local);
self.count(crate::stats::LOST, 1.0);
self.switch_to(me);
}
if hero {
let b = s.bounty as f32;
let got = self.add_gold(b);
@@ -2866,6 +2888,9 @@ impl Game {
if !e.alive || e.held || e.tile() != i || flies(e.kind) {
continue;
}
if e.hero {
self.count_local(crate::stats::DROWNED);
}
if lava {
self.burn(j);
continue;
@@ -4778,6 +4803,7 @@ impl Game {
if built == 0 && !broke {
self.hint(7, "hint.build_where");
}
self.count(crate::stats::ROOMS, built as f32);
self.recount();
}
@@ -4923,6 +4949,7 @@ impl Game {
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
let (px, py) = tower.pos();
self.towers.push(tower);
self.count(crate::stats::TOWERS, 1.0);
// units standing on the tile step aside
for e in self.ents.iter_mut() {
if e.alive && e.tile() == i {
@@ -5460,6 +5487,7 @@ impl Game {
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
self.sfx(Sfx::Slap, x, y);
self.script_event(crate::mscript::ScriptEvent::Slap);
self.count(crate::stats::SLAPS, 1.0);
return 1;
}
if self.ents[j].captive != 0 {
@@ -5498,6 +5526,7 @@ impl Game {
return;
}
self.mana -= COST;
self.count(crate::stats::SPELLS, 1.0);
self.sfx(Sfx::Lightning, wx, wy);
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
self.ignite_at(wx, wy);
@@ -5524,6 +5553,7 @@ impl Game {
return;
}
self.mana -= COST;
self.count(crate::stats::SPELLS, 1.0);
self.sfx(Sfx::Heal, wx, wy);
let mut fxs = Vec::new();
for e in self.ents.iter_mut() {
+49
View File
@@ -1,5 +1,6 @@
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
mod achievements;
mod audio;
mod campaign;
mod campaign_doc;
@@ -41,6 +42,7 @@ mod traps;
mod sound;
mod surface;
mod spells;
mod stats;
mod touch;
mod ui;
mod voice;
@@ -847,6 +849,12 @@ async fn main() {
let mut r3d = R3d::new();
let mut sounds = SoundBank::new();
let mut music = MusicDirector::new();
// lifetime statistics and achievements (browser storage, merged with the server after a login)
let stored_profile: achievements::Profile = platform::storage_get(achievements::PROFILE_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
let mut tracker = achievements::Tracker::new(stored_profile);
let mut want_profile = false; // fetch the server's profile when the client is free
let mut profile_store_t = 0.0f32;
let mut profile_upload_t = 0.0f32;
let mut ui = Ui::new();
let mut menu = Menu::new();
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
@@ -992,6 +1000,7 @@ async fn main() {
}
}
CloudEvent::LoggedIn => {
want_profile = true;
if menu.page == Some(Page::Login) {
menu.page = Some(Page::Title);
}
@@ -1939,8 +1948,47 @@ app.pending_briefing = briefing_of(&campaign, i);
| Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
| Some(Page::Achievements { from_title: true })
);
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz));
// ---------------- achievements: count the game, unlock, store and share the profile
{
let ctx = achievements::Context { coop: app.coop, editor_test: app.editor_testing };
let unix = miniquad::date::now() as u64;
let counting = !on_title && (playing || app.game.state != 0);
for id in if counting { tracker.update(&app.game, &ctx, dt, unix) } else { Vec::new() } {
let (name, _) = achievements::texts(&id);
app.game.msgs.push(game::Msg { key: "msg.achievement", args: vec![name] });
audio::play_sfx(game::Sfx::Victory.index(), 0.8, 0.0);
}
if let Some(json) = cloud.profile_loaded.take() {
if let Ok(p) = serde_json::from_str::<achievements::Profile>(&json) {
tracker.profile.merge(&p);
tracker.profile.check_tiers(unix);
tracker.dirty = true;
tracker.upload = tracker.profile != p;
}
}
if want_profile && cloud.session.is_some() && cloud.load_profile() {
want_profile = false;
}
profile_store_t += dt;
profile_upload_t += dt;
if tracker.dirty && (profile_store_t > 10.0 || tracker.upload) {
profile_store_t = 0.0;
tracker.dirty = false;
platform::storage_set(achievements::PROFILE_KEY, &serde_json::to_string(&tracker.profile).unwrap_or_default());
}
if tracker.upload && cloud.session.is_some() && profile_upload_t > 30.0 && !want_profile {
if cloud.save_profile(&serde_json::to_string(&tracker.profile).unwrap_or_default()) {
tracker.upload = false;
profile_upload_t = 0.0;
}
}
if matches!(menu.page, Some(Page::Achievements { .. })) {
menu.profile = tracker.profile.clone();
}
}
for m in app.game.msgs.drain(..) {
if app.settings.voice && voice::speaks(m.key) {
voice.say(&i18n::trf(m.key, &m.args), now, 12);
@@ -2039,6 +2087,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
| Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
| Some(Page::Achievements { from_title: true })
);
if !title_screen {
let info = if over_ui || (touch && inp.inspect.is_none()) {
+16 -1
View File
@@ -14,6 +14,7 @@ use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use macroquad::prelude::*;
mod achievements;
mod bestiary;
pub mod flags;
@@ -33,6 +34,7 @@ pub enum Page {
Account, // password, logout, users (admin)
Match { from_title: bool }, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
Achievements { from_title: bool },
}
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
@@ -294,6 +296,7 @@ enum Btn {
MatchLeave,
MatchChat,
Bestiary,
Achievements,
BestTab(usize),
BestSel(i32),
BestPick(usize),
@@ -441,6 +444,7 @@ pub struct Menu {
confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize,
rows_visible: usize,
pub profile: crate::achievements::Profile, // shown on the achievements page
buttons: Vec<(Rect, Btn)>,
lang_open: Option<Rect>, // the language dropdown is open below/above this field
lang_scroll: usize, // first language shown in the open dropdown
@@ -505,6 +509,7 @@ impl Menu {
lang_rows: 0,
cur_lang: String::new(),
rows_visible: 1,
profile: Default::default(),
buttons: Vec::new(),
}
}
@@ -530,6 +535,7 @@ impl Menu {
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Achievements { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
Some(Page::CustomBriefing) => None,
@@ -916,6 +922,11 @@ impl Menu {
}
MenuAction::Consumed
}
Some(Btn::Achievements) => {
self.page = Some(Page::Achievements { from_title });
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Bestiary) => {
self.open_bestiary(from_title);
MenuAction::Consumed
@@ -954,6 +965,7 @@ impl Menu {
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::CustomBriefing => self.draw_custom_briefing(gfx),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::Achievements { .. } => self.draw_achievements(gfx),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 5);
self.buttons.clear();
@@ -1098,6 +1110,7 @@ impl Menu {
}
items.push((Btn::Standalone, tr("menu.standalone")));
items.push((Btn::Bestiary, tr("menu.bestiary")));
items.push((Btn::Achievements, tr("menu.achievements")));
items.push((Btn::Help, tr("menu.help")));
let w = 26.0 * CW();
let y = ty + GH * big + CH() * 4.0;
@@ -1215,6 +1228,7 @@ impl Menu {
items.extend([
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
(Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("net.leave")),
]);
@@ -1225,6 +1239,7 @@ impl Menu {
(Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
(Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("menu.main")),
]);
@@ -1780,7 +1795,7 @@ impl Menu {
pub fn scrollable(&self) -> bool {
matches!(
self.page,
Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account)
Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) | Some(Page::Achievements { .. })
)
}
+70
View File
@@ -0,0 +1,70 @@
// The achievements page: every achievement with its badge (bronze, silver, gold, ... in the
// colour of its tier), name, description and the progress towards it. Hidden ones show "???"
// until they are unlocked.
use super::*;
use crate::achievements::{all, texts, TIERS};
const TIER_COL: [(f32, f32, f32); 5] = [(0.72, 0.45, 0.25), (0.75, 0.78, 0.85), (0.95, 0.78, 0.25), (0.55, 0.9, 1.0), (0.85, 0.5, 1.0)];
impl Menu {
pub(super) fn draw_achievements(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let cols = 70.min((sw / CW()) as i32 - 2);
let rows = 40.min((sh / CH()) as i32 - 2);
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.achievements"), FRAME_HI);
let list = all();
let done = list.iter().filter(|a| self.profile.unlocked.contains_key(&a.0)).count();
let head = trf("ach.count", &[done.to_string(), list.len().to_string(), crate::achievements::fmt_count(self.profile.total("minutes"))]);
gfx.text(&head, x + CW() * 3.0, y + CH() * 1.6, S(), GOLD);
let top = y + CH() * 3.5;
let bottom = y + (rows - 4) as f32 * CH();
let row_h = CH() * 3.2;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(list.len().saturating_sub(self.rows_visible));
let ix = x + CW() * 3.0;
let w = (cols - 6) as f32 * CW();
let maxc = ((w - CW() * 6.0) / (GW * SMALL())) as usize;
for (k, (id, hidden, icon, tier)) in list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let got = self.profile.unlocked.contains_key(id);
// the badge: a frame in the tier's colour around the icon (dark while locked)
let (r, g, b) = TIER_COL[(*tier).min(4)];
let bc = if got { Color::new(r, g, b, 1.0) } else { Color::new(0.25, 0.22, 0.22, 1.0) };
let pic = CH() * 2.6;
draw_rectangle(ix, ry, pic, pic, Color::new(0.08, 0.05, 0.05, 1.0));
draw_rectangle_lines(ix, ry, pic, pic, 3.0, bc);
let tint = if got { WHITE } else { Color::new(0.3, 0.3, 0.3, 1.0) };
draw_texture_ex(&gfx.atlas, ix + 4.0, ry + 4.0, tint, DrawTextureParams { source: Some(crate::gfx::Gfx::cell(*icon)), dest_size: Some(vec2(pic - 8.0, pic - 8.0)), ..Default::default() });
let tx = ix + pic + CW();
let (name, desc) = if *hidden && !got { ("???".to_string(), tr("ach.hidden")) } else { texts(id) };
gfx.text(&name, tx, ry + CH() * 0.1, S(), if got { GOLD } else { TXT });
let d: String = desc.chars().take(maxc).collect();
gfx.text(&d, tx, ry + CH() * 1.3, SMALL(), DIM);
// progress towards a tier
if let Some((cat, t)) = id.split_once('.') {
if !got {
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t.parse::<usize>().unwrap_or(1) - 1)).copied().unwrap_or(1.0);
let have = self.profile.total(cat);
let bar_x = x + (cols - 20) as f32 * CW();
crate::ui::ascii_bar(gfx, bar_x, ry + CH() * 0.1, 10, (have / need).min(1.0) as f32, FRAME_HI, SMALL());
let p = format!("{}/{}", crate::achievements::fmt_count(have.floor()), crate::achievements::fmt_count(need));
gfx.text(&p, bar_x, ry + CH() * 1.3, SMALL(), DIM);
}
}
}
// scrolling and back
let by = y + (rows - 3) as f32 * CH();
let (mx, my) = mouse();
let bw = 14.0 * CW();
let items = [(Btn::Scroll(-1), "▲".to_string()), (Btn::Scroll(1), "▼".to_string()), (Btn::Back, tr("menu.back"))];
for (k, (b, label)) in items.iter().enumerate() {
let r = Rect::new(ix + k as f32 * (bw + CW()), by, bw, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
}
}
}
+1
View File
@@ -48,6 +48,7 @@ impl Game {
return;
}
self.gold -= gold;
self.count(crate::stats::SIEGE, 1.0);
let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, 1);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = self.me_side;
+1
View File
@@ -263,6 +263,7 @@ impl Game {
}
}
self.mana -= cost;
self.count(crate::stats::SPELLS, 1.0);
}
// running spell effects on a unit (called every frame)
+108
View File
@@ -0,0 +1,108 @@
// Statistics of a game for the master at this computer: counters (heroes slain, tiles dug, wood
// felled, ...) and a sample of gold, creatures and army strength every few seconds for the charts
// at the end. They are local (not part of the shared match state) and feed the achievements.
use crate::game::*;
use serde::{Deserialize, Serialize};
// counters (index into GameStats::c)
pub const HEROES: usize = 0; // heroes slain or captured
pub const BOSSES: usize = 1; // champions of the Order brought down
pub const LOST: usize = 2; // own creatures lost
pub const DUG: usize = 3; // tiles dug out
pub const WOOD: usize = 4; // wood felled
pub const WALLS: usize = 5; // walls raised
pub const ROOMS: usize = 6; // room tiles built
pub const SPELLS: usize = 7; // spells cast (incl. lightning and healing)
pub const SLAPS: usize = 8; // creatures slapped
pub const TRAPS: usize = 9; // traps and doors ordered
pub const TOWERS: usize = 10; // towers built
pub const DROWNED: usize = 11; // heroes who fell into water or lava
pub const BURNT: usize = 12; // bridge tiles that burnt down
pub const SIEGE: usize = 13; // siege engines built
pub const CAPTURED: usize = 14; // heroes knocked out and carried off
pub const GOLD: usize = 15; // gold earned (treasure delivered, bounties, loot)
pub const DRAWBRIDGE: usize = 16; // drawbridges raised
pub const COUNTERS: usize = 17;
// one sample every SAMPLE_EVERY seconds: time, gold, creatures, army
pub const SAMPLE_EVERY: f32 = 10.0;
const MAX_SAMPLES: usize = 720;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct GameStats {
#[serde(default)]
pub c: Vec<f32>,
#[serde(default)]
pub samples: Vec<(f32, f32, u16, f32)>,
#[serde(default)]
pub sample_t: f32,
}
impl GameStats {
pub fn get(&self, k: usize) -> f32 {
self.c.get(k).copied().unwrap_or(0.0)
}
}
impl Game {
// counts for the master at this computer (another master's deeds count on his computer)
pub(crate) fn count(&mut self, k: usize, n: f32) {
if self.me_side != self.local_side || self.demo.is_some() {
return;
}
if self.stats.c.len() < COUNTERS {
self.stats.c.resize(COUNTERS, 0.0);
}
self.stats.c[k] += n;
}
// a hero fell or was captured: the master at this computer counts it in single player and
// co-op (the heroes are everybody's enemy there)
pub(crate) fn count_local(&mut self, k: usize) {
let me = self.me_side;
let local = self.local_side;
self.switch_to(local);
self.count(k, 1.0);
self.switch_to(me);
}
// every few seconds: how the local master stands
pub(crate) fn sample_stats(&mut self, dt: f32) {
if self.demo.is_some() || self.state != 0 {
return;
}
self.stats.sample_t -= dt;
if self.stats.sample_t > 0.0 {
return;
}
self.stats.sample_t = SAMPLE_EVERY;
let s = (self.time, self.gold, self.creature_count() as u16, self.army_value());
self.stats.samples.push(s);
if self.stats.samples.len() > MAX_SAMPLES {
// keep the whole game in view: every second sample goes
let v: Vec<_> = self.stats.samples.iter().copied().step_by(2).collect();
self.stats.samples = v;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_game_keeps_its_statistics() {
let mut g = Game::new(3, 64, 64);
for _ in 0..(35 * 20) {
g.update(0.05);
}
assert!(g.stats.samples.len() >= 3, "{} samples", g.stats.samples.len());
g.count(DUG, 2.0);
assert_eq!(g.stats.get(DUG), 2.0);
// saved with the game
let json = serde_json::to_string(&g.stats).unwrap();
let back: GameStats = serde_json::from_str(&json).unwrap();
assert_eq!(back.get(DUG), 2.0);
}
}
+1
View File
@@ -186,6 +186,7 @@ impl Game {
}
let id = self.next_id;
self.next_id += 1;
self.count(crate::stats::TRAPS, 1.0);
self.traps.push(Trap { id, kind, tile: i, stage: ORDERED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0, revealed: false, sprung: false });
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.4, 0xff40d0ff);
self.sfx_ui(Sfx::FlagSet);
+38 -3
View File
@@ -1334,7 +1334,10 @@ impl Ui {
}
lines.extend(wrap(para, tw));
}
let rows = 9 + lines.len() as i32;
// the chart of the game (gold, creatures, army) when there are a few samples
let chart = g.stats.samples.len() >= 3 && sh > CH() * (22.0 + lines.len() as f32);
let chart_rows = if chart { 10 } else { 0 };
let rows = 9 + lines.len() as i32 + chart_rows;
let x = grid_center(sw, cols as f32 * CW());
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let col = if win { GOLD } else { RED };
@@ -1347,6 +1350,38 @@ impl Ui {
}
let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM);
if chart {
use crate::stats as k;
let cy = y + CH() * (6.0 + lines.len() as f32);
let c = &g.stats;
let line2 = trf("end.stats2", &[(c.get(k::HEROES) as i32).to_string(), (c.get(k::LOST) as i32).to_string(), (c.get(k::DUG) as i32).to_string(), (c.get(k::GOLD) as i32).to_string(), (c.get(k::WOOD) as i32).to_string()]);
gfx.text(&line2, grid_center(sw, gfx.measure(&line2, SMALL())), cy, SMALL(), DIM);
// three lines, each scaled to its own maximum
let (px, py, pw, ph) = (x + CW() * 3.0, cy + CH() * 1.2, (cols - 6) as f32 * CW(), CH() * 6.0);
draw_rectangle(px, py, pw, ph, Color::new(0.0, 0.0, 0.0, 0.35));
draw_rectangle_lines(px, py, pw, ph, 1.0, Color::new(0.4, 0.3, 0.2, 1.0));
let n = c.samples.len();
let t_end = c.samples[n - 1].0.max(1.0);
let series: [(Box<dyn Fn(&(f32, f32, u16, f32)) -> f32>, Color); 3] = [(Box::new(|s| s.1), GOLD), (Box::new(|s| s.2 as f32), GREEN), (Box::new(|s| s.3), RED)];
for (f, col) in series.iter() {
let max = c.samples.iter().map(|s| f(s)).fold(1.0f32, f32::max);
for w in c.samples.windows(2) {
let (a, b) = (&w[0], &w[1]);
let p = |s: &(f32, f32, u16, f32)| (px + pw * (s.0 / t_end), py + ph - ph * 0.92 * (f(s) / max));
let (x0, y0) = p(a);
let (x1, y1) = p(b);
draw_line(x0, y0, x1, y1, 2.0 * K(), *col);
}
}
let legend = format!("{} {} {}", tr("hud.gold"), tr("hud.creatures"), tr("hud.army"));
let lx = px + CW();
gfx.text(&tr("hud.gold"), lx, py + ph + CH() * 0.3, SMALL(), GOLD);
let w1 = gfx.measure(&format!("{} ", tr("hud.gold")), SMALL());
gfx.text(&tr("hud.creatures"), lx + w1, py + ph + CH() * 0.3, SMALL(), GREEN);
let w2 = gfx.measure(&format!("{} {} ", tr("hud.gold"), tr("hud.creatures")), SMALL());
gfx.text(&tr("hud.army"), lx + w2, py + ph + CH() * 0.3, SMALL(), RED);
let _ = legend;
}
// buttons: free game: new game; mission: next / again and back to the campaign
let labels: Vec<(String, u8)> = match (mission, self.coop_end) {
// an editor campaign in co-op: the host goes on for everybody, the others wait or leave
@@ -1359,10 +1394,10 @@ impl Ui {
if let Some((host, true, sent)) = self.coop_end {
if !host || sent {
let t = tr("net.coop_wait_host");
gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32), SMALL(), GOLD);
gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32 + chart_rows as f32), SMALL(), GOLD);
}
}
let by = y + CH() * (6.0 + lines.len() as f32);
let by = y + CH() * (6.0 + lines.len() as f32 + chart_rows as f32);
let widths: Vec<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::<f32>() + CW() * 2.0 * (labels.len() as f32 - 1.0);
let mut bx = grid_center(sw, total);
+1
View File
@@ -119,6 +119,7 @@ impl Game {
tl.bed = 0;
tl.food = 0.0;
self.wall_plans.retain(|p| p.0 != w);
self.count(crate::stats::WALLS, 1.0);
self.fx(8, wx as f32 + 0.5, wy as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
self.sfx(Sfx::Reinforce, wx as f32 + 0.5, wy as f32 + 0.5);
false
+1
View File
@@ -79,6 +79,7 @@ impl Game {
let kind = self.map.t[i].kind;
let wood = if kind == Kind::Tree { WOOD_TREE } else { WOOD_MUSHROOM };
self.wood += wood;
self.count(crate::stats::WOOD, wood);
if kind == Kind::Mushroom {
self.woodwork.regrow.push((i, REGROW));
}