Named creatures: names per kind and language, epithets at higher levels, memorial stones for the fallen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
189da6f408
commit
ad5ceb8c13
26 files changed
+685
-6
No files matched your search
+17
-2
@@ -292,6 +292,8 @@ pub struct Ent {
|
||||
#[serde(default)]
|
||||
pub plague: f32, // spell: seconds of plague left (poison that spreads)
|
||||
#[serde(default)]
|
||||
pub name: u32, // seed of the creature's name (names.rs), 0: none (heroes, siege engines)
|
||||
#[serde(default)]
|
||||
pub ab_cd: f32, // cooldown of the learned ability
|
||||
#[serde(default)]
|
||||
pub mood_t: f32, // seconds until the next mood check (likes and hatreds)
|
||||
@@ -381,6 +383,7 @@ impl Ent {
|
||||
shield: 0.0,
|
||||
invis: 0.0,
|
||||
plague: 0.0,
|
||||
name: 0,
|
||||
ab_cd: 0.0,
|
||||
mood_t: 0.0,
|
||||
guard: false,
|
||||
@@ -638,6 +641,8 @@ pub struct Game {
|
||||
#[serde(default)]
|
||||
pub weather: crate::weather::Weather, // above ground (weather.rs)
|
||||
#[serde(default)]
|
||||
pub memorials: Vec<crate::names::Memorial>, // stones for fallen creatures (names.rs)
|
||||
#[serde(default)]
|
||||
pub trading: crate::traders::Trading, // the visiting trader (traders.rs)
|
||||
#[serde(default)]
|
||||
pub happenings: Option<crate::events::Events>, // random events (free games and versus), see events.rs
|
||||
@@ -994,6 +999,7 @@ impl Game {
|
||||
dwarf_caves: Vec::new(),
|
||||
bridgework: Default::default(),
|
||||
weather: Default::default(),
|
||||
memorials: Vec::new(),
|
||||
trading: Default::default(),
|
||||
happenings: None,
|
||||
snares: Vec::new(),
|
||||
@@ -1352,6 +1358,9 @@ impl Game {
|
||||
e.disp_x = x;
|
||||
e.disp_y = y;
|
||||
e.level = level;
|
||||
if !hero && !matches!(kind, EK::Ram | EK::Catapult | EK::Trader) {
|
||||
e.name = crate::names::seed_for(id);
|
||||
}
|
||||
e.max_hp = s.hp * (1.0 + 0.15 * (level - 1) as f32);
|
||||
e.hp = e.max_hp;
|
||||
e.alive = true;
|
||||
@@ -1777,6 +1786,7 @@ impl Game {
|
||||
if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() {
|
||||
self.reinforce_corners();
|
||||
self.tidy_torch_stands();
|
||||
self.tidy_memorials();
|
||||
}
|
||||
self.update_forge(dt);
|
||||
self.update_research();
|
||||
@@ -2482,7 +2492,7 @@ impl Game {
|
||||
self.sfx(Sfx::LevelUp, e.x, e.y);
|
||||
}
|
||||
if !e.hero && e.level >= 3 {
|
||||
self.msg("msg.level_up", vec![format!("@{}", s.name), e.level.to_string()]);
|
||||
self.msg("msg.level_up", vec![crate::names::arg(e), e.level.to_string()]);
|
||||
}
|
||||
}
|
||||
if e.chained {
|
||||
@@ -2948,6 +2958,9 @@ impl Game {
|
||||
}
|
||||
}
|
||||
self.release_all(id);
|
||||
// a named creature of some standing gets a memorial stone
|
||||
let dead = self.ents[j].clone();
|
||||
let remembered = self.raise_memorial(&dead);
|
||||
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
||||
if kind != EK::Worker && kind != EK::Revenant {
|
||||
self.bury(x, y);
|
||||
@@ -2980,7 +2993,9 @@ impl Game {
|
||||
self.msg("msg.rival_unit_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
|
||||
} else {
|
||||
self.sfx(Sfx::Death, x, y);
|
||||
self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
|
||||
if !remembered {
|
||||
self.msg("msg.creature_died", vec![crate::names::arg(&dead)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -97,7 +97,13 @@ pub fn tr(key: &str) -> String {
|
||||
pub fn trf(key: &str, args: &[String]) -> String {
|
||||
let mut s = tr(key);
|
||||
for (n, a) in args.iter().enumerate() {
|
||||
let v = if let Some(k) = a.strip_prefix('@') { tr(k) } else { a.clone() };
|
||||
let v = if let Some(k) = a.strip_prefix('@') {
|
||||
tr(k)
|
||||
} else if let Some(n) = a.strip_prefix('%') {
|
||||
crate::names::from_arg(n) // a creature's name (names.rs)
|
||||
} else {
|
||||
a.clone()
|
||||
};
|
||||
s = s.replace(&format!("{{{}}}", n), &v);
|
||||
}
|
||||
s
|
||||
|
||||
@@ -32,6 +32,7 @@ mod rival;
|
||||
mod rogues;
|
||||
mod talents;
|
||||
mod weather;
|
||||
mod names;
|
||||
mod render3d;
|
||||
mod rooms;
|
||||
mod save;
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// Named creatures: every creature of a master carries a name seed from its birth. The name
|
||||
// comes from the list of its kind in the language files (so it follows a language switch);
|
||||
// from level 5 on it earns a humorous epithet, from level 8 a grand one. A creature of level
|
||||
// 3 or more that falls leaves a memorial stone with its name on its master's ground.
|
||||
|
||||
use crate::game::*;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::map::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// the fallen get a stone from this level on; at most MAX_MEMORIALS stones (the oldest go)
|
||||
pub const MEMORIAL_LEVEL: u32 = 3;
|
||||
pub const MAX_MEMORIALS: usize = 24;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Memorial {
|
||||
pub tile: usize,
|
||||
pub kind: EK,
|
||||
pub name: u32,
|
||||
pub level: u32,
|
||||
}
|
||||
|
||||
// the name list of a kind of creature
|
||||
pub fn group(k: EK) -> &'static str {
|
||||
match k {
|
||||
EK::Worker => "imp",
|
||||
EK::Kobold => "kobold",
|
||||
EK::Spider => "spider",
|
||||
EK::Mage => "mage",
|
||||
EK::Ogre => "ogre",
|
||||
EK::Jailer => "jailer",
|
||||
EK::Priestess => "priestess",
|
||||
EK::Chainbreaker => "chainbreaker",
|
||||
EK::Bat => "bat",
|
||||
EK::Gargoyle => "gargoyle",
|
||||
EK::Owl => "owl",
|
||||
EK::Wyrm => "wyrm",
|
||||
EK::Cogling => "cogling",
|
||||
EK::Revenant => "revenant",
|
||||
EK::CaveBear | EK::Beetle => "beast",
|
||||
_ => "human",
|
||||
}
|
||||
}
|
||||
|
||||
// the seed of a new creature's name (from its id: the same on every computer of a match)
|
||||
pub fn seed_for(id: u32) -> u32 {
|
||||
(id.wrapping_mul(2654435761) ^ 0x5bd1_e995) | 1
|
||||
}
|
||||
|
||||
fn pick(key: &str, seed: u32) -> Option<String> {
|
||||
let list = tr(key);
|
||||
if list == key {
|
||||
return None; // no list in this language
|
||||
}
|
||||
let items: Vec<&str> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
(!items.is_empty()).then(|| items[seed as usize % items.len()].to_string())
|
||||
}
|
||||
|
||||
// the name with its epithet (if the level has earned one); "" if the creature has no name
|
||||
pub fn full_name(kind: EK, seed: u32, level: u32) -> String {
|
||||
if seed == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let Some(base) = pick(&format!("names.{}", group(kind)), seed) else { return String::new() };
|
||||
let list = if level >= 8 { "epithets.high" } else { "epithets.mid" };
|
||||
match pick(list, seed >> 7).filter(|_| level >= 5) {
|
||||
Some(ep) => trf("name.epithet", &[base, ep]),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
// the name of a unit, or its kind's name if it has none
|
||||
pub fn display_name(e: &Ent) -> String {
|
||||
let n = full_name(e.kind, e.name, e.level);
|
||||
if n.is_empty() { tr(st(e.kind).name) } else { n }
|
||||
}
|
||||
|
||||
// a message argument that is turned into the name when the message is shown (i18n::trf)
|
||||
pub fn arg(e: &Ent) -> String {
|
||||
if e.name == 0 {
|
||||
return format!("@{}", st(e.kind).name);
|
||||
}
|
||||
format!("%{}.{}.{}", crate::gfx::sprite_index(e.kind), e.name, e.level)
|
||||
}
|
||||
|
||||
// the counterpart of `arg`
|
||||
pub fn from_arg(a: &str) -> String {
|
||||
let p: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
match p.as_slice() {
|
||||
[k, seed, level] => {
|
||||
let kind = crate::gfx::SPRITE_KINDS.get(*k as usize).copied().unwrap_or(EK::Kobold);
|
||||
let n = full_name(kind, *seed, *level);
|
||||
if n.is_empty() { tr(st(kind).name) } else { n }
|
||||
}
|
||||
_ => a.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game {
|
||||
// a named creature of some standing fell: a stone with its name on its master's ground, as
|
||||
// near to where it fell as there is plain floor
|
||||
pub(crate) fn raise_memorial(&mut self, e: &Ent) -> bool {
|
||||
if e.hero || e.name == 0 || e.level < MEMORIAL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) {
|
||||
return false;
|
||||
}
|
||||
let owner = Owner::of_side(e.side);
|
||||
let (x, y) = (e.x as i32, e.y as i32);
|
||||
let taken: Vec<usize> = self.memorials.iter().map(|m| m.tile).collect();
|
||||
let mut best: Option<(usize, i32)> = None;
|
||||
for dy in -6..=6 {
|
||||
for dx in -6..=6 {
|
||||
let (tx, ty) = (x + dx, y + dy);
|
||||
if !inb(tx, ty) {
|
||||
continue;
|
||||
}
|
||||
let i = idx(tx, ty);
|
||||
let t = &self.map.t[i];
|
||||
if t.kind == Kind::Floor && t.owner == owner && !taken.contains(&i) {
|
||||
let d = dx * dx + dy * dy;
|
||||
if best.map_or(true, |b| d < b.1) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((tile, _)) = best else { return false };
|
||||
if self.memorials.len() >= MAX_MEMORIALS {
|
||||
self.memorials.remove(0);
|
||||
}
|
||||
self.memorials.push(Memorial { tile, kind: e.kind, name: e.name, level: e.level });
|
||||
if e.side == self.local_side {
|
||||
self.tell_local("msg.creature_fallen", vec![arg(e)], None);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// stones on ground that was lost or built over are taken away
|
||||
pub(crate) fn tidy_memorials(&mut self) {
|
||||
let map = &self.map;
|
||||
self.memorials.retain(|m| map.t[m.tile].kind == Kind::Floor && Owner::MASTER_SIDES.contains(&map.t[m.tile].owner.side()));
|
||||
}
|
||||
|
||||
pub fn memorial_at(&self, i: usize) -> Option<&Memorial> {
|
||||
self.memorials.iter().find(|m| m.tile == i)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creatures_have_names_and_earn_epithets() {
|
||||
let mut g = Game::new(9, 64, 64);
|
||||
let (cx, cy) = g.map.core;
|
||||
let id = g.spawn(EK::Ogre, false, cx as f32 + 0.5, cy as f32 + 4.5, 1);
|
||||
let e = g.ents.iter().find(|e| e.id == id).unwrap().clone();
|
||||
assert!(e.name != 0);
|
||||
let n1 = full_name(e.kind, e.name, 1);
|
||||
assert!(!n1.is_empty() && !n1.contains("names."), "{}", n1);
|
||||
let n5 = full_name(e.kind, e.name, 5);
|
||||
let n9 = full_name(e.kind, e.name, 9);
|
||||
assert!(n5.starts_with(&n1) || n5.ends_with(&n1), "{} / {}", n1, n5);
|
||||
assert!(n5.len() > n1.len() && n9 != n5, "{} {} {}", n1, n5, n9);
|
||||
assert_eq!(from_arg(arg(&e).trim_start_matches('%')), display_name(&e));
|
||||
// heroes have none
|
||||
let h = g.spawn(EK::Knight, true, cx as f32 + 0.5, cy as f32 + 5.5, 1);
|
||||
assert_eq!(g.ents.iter().find(|e| e.id == h).unwrap().name, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fallen_get_a_memorial_stone() {
|
||||
let mut g = Game::new(9, 64, 64);
|
||||
let (cx, cy) = g.map.core;
|
||||
for y in cy + 4..cy + 8 {
|
||||
for x in cx..cx + 4 {
|
||||
let t = &mut g.map.t[idx(x, y)];
|
||||
t.kind = Kind::Floor;
|
||||
t.owner = Owner::Player;
|
||||
}
|
||||
}
|
||||
let id = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 4);
|
||||
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
||||
g.kill(j);
|
||||
assert_eq!(g.memorials.len(), 1);
|
||||
let m = &g.memorials[0];
|
||||
assert_eq!((m.kind, m.level), (EK::Ogre, 4));
|
||||
assert!(g.memorial_at(m.tile).is_some());
|
||||
// a level 1 kobold is not remembered
|
||||
let k = g.spawn(EK::Kobold, false, cx as f32 + 2.5, cy as f32 + 5.5, 1);
|
||||
let j = g.ents.iter().position(|e| e.id == k).unwrap();
|
||||
g.kill(j);
|
||||
assert_eq!(g.memorials.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -2394,6 +2394,21 @@ fn draw_landmarks_solid(g: &Game, t: f32) {
|
||||
// soul core: an obsidian throne whose four curved spikes hold the crystal heart in chains
|
||||
let (cx, cy) = g.map.core;
|
||||
draw_throne(vec3(cx as f32 + 1.5, 0.0, cy as f32 + 1.5), t);
|
||||
// memorial stones of fallen creatures: a headstone on a little plinth, a rune that glows
|
||||
for m in &g.memorials {
|
||||
if !g.map.shown(m.tile) {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = xy(m.tile);
|
||||
let h = (m.name % 7) as f32 * 0.02;
|
||||
let c = vec3(x as f32 + 0.5 + ((m.name >> 3) % 5) as f32 * 0.04 - 0.08, 0.0, y as f32 + 0.5);
|
||||
let stone = Color::from_rgba(118, 114, 124, 255);
|
||||
draw_cube(c + vec3(0.0, 0.04, 0.0), vec3(0.42, 0.08, 0.26), None, Color::from_rgba(70, 66, 74, 255));
|
||||
draw_cube(c + vec3(0.0, 0.3 + h * 0.5, 0.0), vec3(0.3, 0.46 + h, 0.1), None, stone);
|
||||
draw_cube(c + vec3(0.0, 0.56 + h, 0.0), vec3(0.2, 0.06, 0.1), None, stone);
|
||||
let glow = if m.level >= 8 { Color::from_rgba(255, 210, 90, 255) } else { Color::from_rgba(150, 120, 255, 255) };
|
||||
draw_cube(c + vec3(0.0, 0.34 + h * 0.5, 0.052), vec3(0.06, 0.14, 0.01), None, glow);
|
||||
}
|
||||
// a ranger's snares: iron jaws on the floor, open and waiting
|
||||
for &i in &g.snares {
|
||||
if !g.map.shown(i) {
|
||||
|
||||
@@ -283,3 +283,21 @@ fn export_weather_scenes() {
|
||||
save(name, &mut g, x as f32 + 4.5, y as f32 + 0.5, 18.0, 0.0, false);
|
||||
}
|
||||
}
|
||||
|
||||
// named creatures and memorial stones of the fallen
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn export_names_scene() {
|
||||
std::fs::create_dir_all("/tmp/scenes").unwrap();
|
||||
let mut g = Game::new(11, 80, 64);
|
||||
let (cx, cy) = g.map.core;
|
||||
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
|
||||
g.place_all_torches();
|
||||
creatures(&mut g, &[(EK::Ogre, 9), (EK::Mage, 5), (EK::Kobold, 2)], cx as f32 + 6.5, cy as f32 + 5.0);
|
||||
for (k, &(kind, lv)) in [(EK::Spider, 4), (EK::Ogre, 8), (EK::Mage, 6)].iter().enumerate() {
|
||||
let id = g.spawn(kind, false, cx as f32 + 2.5 + k as f32 * 2.0, cy as f32 + 6.5, lv);
|
||||
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
||||
g.kill(j);
|
||||
}
|
||||
save("names", &mut g, cx as f32 + 5.0, cy as f32 + 5.5, 9.0, 0.0, false);
|
||||
}
|
||||
@@ -1402,8 +1402,9 @@ impl Ui {
|
||||
for e in group {
|
||||
let w = (st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)) as i32;
|
||||
let happy = (e.happy.clamp(0.0, 1.0) * 100.0) as i32;
|
||||
let job: String = tr(job_name(e)).chars().take(16).collect();
|
||||
let line = format!(" {:>2} {:>3}% {:>4}g {:<16}", e.level, happy, w, job);
|
||||
let job: String = tr(job_name(e)).chars().take(12).collect();
|
||||
let name: String = crate::names::display_name(e).chars().take(18).collect();
|
||||
let line = format!(" {:>2} {:<18} {:>3}% {:>4}g {:<12}", e.level, name, happy, w, job);
|
||||
let col = if e.happy > 0.6 { TXT } else if e.happy > 0.25 { GOLD } else { RED };
|
||||
lines.push((line, col, Some(e.id), e.hp / e.max_hp));
|
||||
}
|
||||
@@ -1778,6 +1779,10 @@ pub fn hover_info(g: &Game, ent: Option<usize>, tile: Option<(i32, i32)>) -> Vec
|
||||
out.push((trf("trader.left", &[(left.ceil() as i32).to_string()]), DIM));
|
||||
return out;
|
||||
}
|
||||
// a named creature: its name is the title
|
||||
if e.name != 0 {
|
||||
out.push((crate::names::display_name(e), GOLD));
|
||||
}
|
||||
out.push((trf("info.level", &[tr(s.name), e.level.to_string()]), if e.hero { BLUE } else { FRAME_HI }));
|
||||
out.push((trf("info.rank", &[tr(&format!("rank.{}", e.level.clamp(1, 10)))]), GOLD));
|
||||
out.push((trf("info.hp", &[(e.hp.max(0.0) as i32).to_string(), (e.max_hp as i32).to_string()]), GREEN));
|
||||
@@ -1840,6 +1845,13 @@ pub fn hover_info(g: &Game, ent: Option<usize>, tile: Option<(i32, i32)>) -> Vec
|
||||
}
|
||||
let Some(t) = g.map.get(x, y) else { return out };
|
||||
let i = idx(x, y);
|
||||
// a memorial stone: who lies here
|
||||
if let Some(m) = g.memorial_at(i) {
|
||||
let name = crate::names::full_name(m.kind, m.name, m.level);
|
||||
out.push((tr("tile.memorial"), DIM));
|
||||
out.push((trf("tile.memorial_info", &[name, tr(st(m.kind).name), m.level.to_string()]), TXT));
|
||||
return out;
|
||||
}
|
||||
let owner = match t.owner {
|
||||
Owner::Player => "owner.player",
|
||||
Owner::Hero => "owner.hero",
|
||||
|
||||
Reference in new issue
Block a user