Crookfinger's voice: he babbles in a squeaky imp language in the rhythm of his hints, important messages and the mission briefings (can be switched off in the sound settings)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-04 08:25:31 +01:00
1 parent 776c9ad9de
commit ada14951c4
9 files changed
+118 -3

No files matched your search

+11 -1
View File
@@ -54,14 +54,24 @@ pub enum Sfx {
Convert,
Slap,
Punch,
// Crookfinger's voice: squeaky syllables
Voice1,
Voice2,
Voice3,
Voice4,
Voice5,
Voice6,
}
pub const SFX_ALL: [Sfx; 36] = [
pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6];
pub const SFX_ALL: [Sfx; 42] = [
Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit,
Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive,
Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp,
Sfx::Pickup, Sfx::Drop, Sfx::Payday, Sfx::FlagSet, Sfx::Victory, Sfx::Defeat, Sfx::Click, Sfx::Error,
Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch,
Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6,
];
impl Sfx {
+19
View File
@@ -30,6 +30,7 @@ mod sound;
mod spells;
mod touch;
mod ui;
mod voice;
mod wilds;
mod xm;
@@ -402,6 +403,8 @@ async fn main() {
// until the mouse has really moved its position is (0, 0): no edge scrolling before that
let mut mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click
let mut autosave_t = 0.0f32;
let mut voice = voice::Voice::new();
let mut was_briefing = false;
let mut cache_t = 0.0f32;
loop {
@@ -1028,8 +1031,24 @@ async fn main() {
);
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
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);
}
ui.log.push((m, now));
}
// a briefing opens: Crookfinger tells the story
let briefing = matches!(menu.page, Some(Page::Briefing { .. }));
if briefing && !was_briefing && app.settings.voice {
if let Some(Page::Briefing { mission, .. }) = menu.page {
let key = campaign::missions().get(mission).map(|d| format!("camp.{}.story", d.key)).unwrap_or_default();
voice.say(&i18n::tr(&key), now, 22);
}
}
if !briefing && was_briefing {
voice.stop();
}
was_briefing = briefing;
voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume });
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
next_frame().await
+5 -1
View File
@@ -50,12 +50,13 @@ pub enum Item {
GameSpeed,
Hints,
TouchUi,
Voice,
}
fn tab_items(tab: usize) -> &'static [Item] {
use Item::*;
match tab {
0 => &[Language, Volume, MusicVolume, Mute],
0 => &[Language, Volume, MusicVolume, Mute, Voice],
1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
3 => &[GameSpeed, Hints, TouchUi],
@@ -81,6 +82,7 @@ fn item_label(it: Item) -> String {
Item::GameSpeed => "set.game_speed",
Item::Hints => "set.hints",
Item::TouchUi => "set.touch_ui",
Item::Voice => "set.voice",
})
}
@@ -98,6 +100,7 @@ fn item_value(it: Item, s: &Settings) -> String {
Item::Volume => pct(s.volume),
Item::MusicVolume => pct(s.music_volume),
Item::Mute => onoff(s.muted),
Item::Voice => onoff(s.voice),
Item::RotateSpeed => pct(s.rotate_speed),
Item::ZoomSpeed => pct(s.zoom_speed),
Item::EdgeScroll => onoff(s.edge_scroll),
@@ -155,6 +158,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
Item::Volume => s.volume = step(s.volume, d, 0.0, 1.0, 0.1),
Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1),
Item::Mute => s.muted = !s.muted,
Item::Voice => s.voice = !s.voice,
Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
Item::EdgeScroll => s.edge_scroll = !s.edge_scroll,
+2
View File
@@ -136,6 +136,7 @@ pub struct Settings {
pub volume: f32,
pub music_volume: f32,
pub muted: bool,
pub voice: bool, // Crookfinger's babbling voice
// camera
pub rotate_speed: f32,
pub zoom_speed: f32,
@@ -165,6 +166,7 @@ impl Default for Settings {
volume: 0.7,
music_volume: 0.6,
muted: false,
voice: true,
rotate_speed: 1.0,
zoom_speed: 1.0,
edge_scroll: true,
+7
View File
@@ -157,6 +157,13 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
Sfx::Convert => l(&arp(Saw, &[220.0, 262.0, 330.0, 440.0], 0.1, 0.22), 47),
Sfx::Slap => mix(vec![l(&[seg(Noise, 5000.0, 2500.0, 0.06, 0.5, 40.0)], 48), l(&[seg(Square(0.5), 900.0, 500.0, 0.05, 0.12, 30.0)], 49)]),
Sfx::Punch => mix(vec![l(&[seg(Tri, 160.0, 60.0, 0.15, 0.8, 18.0)], 50), l(&[seg(Noise, 3000.0, 800.0, 0.08, 0.4, 30.0)], 51)]),
// Crookfinger's syllables: a nasal squeak gliding up or down, some with a hissy consonant
Sfx::Voice1 => mix(vec![l(&[vib(seg(Square(0.2), 620.0, 760.0, 0.08, 0.16, 6.0), 0.04)], 60), l(&[seg(Noise, 6000.0, 4000.0, 0.02, 0.05, 40.0)], 61)]),
Sfx::Voice2 => l(&[vib(seg(Square(0.18), 780.0, 600.0, 0.09, 0.16, 6.0), 0.05)], 62),
Sfx::Voice3 => mix(vec![l(&[vib(seg(Square(0.22), 520.0, 640.0, 0.07, 0.15, 7.0), 0.03)], 63), l(&[seg(Tri, 260.0, 300.0, 0.07, 0.12, 8.0)], 64)]),
Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65),
Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]),
Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68),
}
}
+71
View File
@@ -0,0 +1,71 @@
// Crookfinger's voice: he "speaks" in a squeaky made-up imp language - short synthesised
// syllables in the rhythm of the text (the text itself is shown as usual).
use crate::audio;
use crate::game::VOICE_SYLLABLES;
pub struct Voice {
queue: Vec<(f64, usize)>, // time, sound index
busy_until: f64,
}
// which messages Crookfinger says out loud
pub fn speaks(key: &str) -> bool {
key.starts_with("hint.")
|| key.starts_with("msg.wave_warn")
|| matches!(
key,
"msg.payday_soon" | "msg.researched" | "msg.mission_start" | "msg.mission_won" | "msg.rune_chest" | "msg.trap_made" | "msg.revenant_rises" | "msg.altar_gift" | "msg.altar_blessing" | "msg.brawl" | "msg.alarm" | "msg.creature_freed" | "msg.spring_taken"
)
}
impl Voice {
pub fn new() -> Voice {
Voice { queue: Vec::new(), busy_until: 0.0 }
}
// babble a text: one syllable per vowel group, pauses at spaces and punctuation
pub fn say(&mut self, text: &str, now: f64, max_syllables: usize) {
if now < self.busy_until {
return;
}
let mut t = now + 0.05;
let mut n = 0;
let mut h: u32 = 2166136261;
let mut in_vowel = false;
for ch in text.chars() {
h = (h ^ ch as u32).wrapping_mul(16777619);
let vowel = "aeiouäöüyAEIOUÄÖÜY".contains(ch);
if vowel && !in_vowel {
let v = VOICE_SYLLABLES[(h >> 7) as usize % VOICE_SYLLABLES.len()];
self.queue.push((t, v.index()));
t += 0.085 + ((h >> 13) % 4) as f64 * 0.012;
n += 1;
if n >= max_syllables {
break;
}
}
in_vowel = vowel;
match ch {
' ' => t += 0.035,
',' | ';' | '-' => t += 0.14,
'.' | '!' | '?' => t += 0.28,
_ => {}
}
}
self.busy_until = t + 4.0;
}
pub fn update(&mut self, now: f64, volume: f32) {
let due: Vec<usize> = self.queue.iter().filter(|q| q.0 <= now).map(|q| q.1).collect();
self.queue.retain(|q| q.0 > now);
for i in due {
audio::play_sfx(i, 0.75 * volume.max(0.0), 0.0);
}
}
pub fn stop(&mut self) {
self.queue.clear();
}
}