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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 12:40:22 +01:00
1 parent 189da6f408
commit ad5ceb8c13
26 files changed
+685 -6

No files matched your search

+17 -2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -32,6 +32,7 @@ mod rival;
mod rogues;
mod talents;
mod weather;
mod names;
mod render3d;
mod rooms;
mod save;
+195
View File
@@ -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);
}
}
+15
View File
@@ -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) {
+18
View File
@@ -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);
}
+14 -2
View File
@@ -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",