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:
1 parent
193b199875
commit
9931ca9b54
37 files changed
+1833
-4
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user