From 729dd4fe4c7448b418b4ab86b0fa7486f62a4147 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 12:47:34 +0100 Subject: [PATCH] Rank badges and level equipment; no edge scrolling before the mouse has moved - Military rank badges instead of level numbers: Lv 2-4 bronze chevrons, Lv 5-7 silver bars, Lv 8-9 gold stars, Lv 10 gold star in a wreath; rank names in the unit info (EN/DE) - Equipment grows with the level: shoulder pieces (bronze/silver/gold), helmets with horns (creatures) or plumes (heroes), capes from Lv 7, glowing ring from Lv 9, aura at Lv 10 - Fix: before the first mouse move the pointer counts as (0, 0) and the edge scrolling moved the camera at the start and after loading a savegame Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 13 ++++++++ lang/en.lang | 13 ++++++++ src/gfx.rs | 57 ++++++++++++++++++++++++++++++++++ src/main.rs | 9 ++++-- src/render3d.rs | 82 +++++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 1 + 6 files changed, 170 insertions(+), 5 deletions(-) diff --git a/lang/de.lang b/lang/de.lang index c117b19..efcae86 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -504,3 +504,16 @@ camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitade camp.epilogue = Der letzte Splitter versinkt im Kern, und die Dunkelheit atmet wieder. Irgendwo dort oben schreibt der Orden der Lichtwacht einen sehr langen Bericht. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir. lair.arrivals = {0} freie Plätze: Neue Kreaturen kommen mit {1}% Tempo job.help = Hilft mit + +# ranks (levels 1-10) +info.rank = Dienstgrad: {0} +rank.1 = Rekrut +rank.2 = Gefreiter +rank.3 = Obergefreiter +rank.4 = Korporal +rank.5 = Feldwebel +rank.6 = Oberfeldwebel +rank.7 = Hauptfeldwebel +rank.8 = Hauptmann +rank.9 = Oberst +rank.10 = Feldherr diff --git a/lang/en.lang b/lang/en.lang index 1381bd9..2172fa2 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -505,3 +505,16 @@ camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond camp.epilogue = The last shard sinks into the core, and the darkness breathes again. Somewhere above, the Order of the Lightwatch is writing a very long report. | Crookfinger has made tea. You are whole, master. The underworld is yours. lair.arrivals = {0} free beds: new creatures arrive at {1}% speed job.help = Helping + +# ranks (levels 1-10) +info.rank = Rank: {0} +rank.1 = Recruit +rank.2 = Private +rank.3 = Lance Corporal +rank.4 = Corporal +rank.5 = Sergeant +rank.6 = Staff Sergeant +rank.7 = Master Sergeant +rank.8 = Captain +rank.9 = Colonel +rank.10 = Warlord diff --git a/src/gfx.rs b/src/gfx.rs index 0efea75..c191f9e 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1183,6 +1183,63 @@ impl Gfx { s.chars().count() as f32 * GW * scale } + // Military rank badge for a level (k = size of one badge pixel in device pixels): + // Lv 2-4: 1-3 bronze chevrons, Lv 5-7: 1-3 silver bars, Lv 8-9: 1-2 gold stars, + // Lv 10: a gold star in a green wreath. Lv 1 has none. Returns the width drawn. + pub fn badge(&self, x: f32, y: f32, level: u32, k: f32) -> f32 { + let px = |gx: i32, gy: i32, c: Color| draw_rectangle(x + gx as f32 * k, y + gy as f32 * k, k, k, c); + let plate = |w: i32, h: i32| draw_rectangle(x - k, y - k, (w + 2) as f32 * k, (h + 2) as f32 * k, Color::new(0.05, 0.03, 0.04, 0.85)); + let bronze = Color::from_rgba(214, 140, 70, 255); + let silver = Color::from_rgba(215, 222, 235, 255); + let gold = Color::from_rgba(255, 210, 70, 255); + let star = |ox: i32, oy: i32, c: Color| { + for (gx, gy) in [(2, 0), (1, 1), (2, 1), (3, 1), (0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (1, 3), (2, 3), (3, 3), (0, 4), (1, 4), (3, 4), (4, 4)] { + px(ox + gx, oy + gy, c); + } + }; + match level { + 0 | 1 => 0.0, + 2..=4 => { + let n = (level - 1) as i32; + plate(5, n * 2 + 1); + for c in 0..n { + // a chevron pointing up + for (gx, gy) in [(2, 0), (1, 1), (3, 1), (0, 2), (4, 2)] { + px(gx, c * 2 + gy - if gy == 2 { 1 } else { 0 }, bronze); + } + } + 7.0 * k + } + 5..=7 => { + let n = (level - 4) as i32; + plate(5, n * 2 - 1); + for b in 0..n { + for gx in 0..5 { + px(gx, b * 2, silver); + } + } + 7.0 * k + } + 8 | 9 => { + let n = (level - 7) as i32; + plate(n * 6 - 1, 5); + for s in 0..n { + star(s * 6, 0, gold); + } + (n * 6 + 1) as f32 * k + } + _ => { + plate(9, 7); + let green = Color::from_rgba(90, 170, 80, 255); + for (gx, gy) in [(0, 2), (0, 3), (0, 4), (1, 5), (2, 6), (8, 2), (8, 3), (8, 4), (7, 5), (6, 6), (3, 6), (5, 6), (1, 1), (7, 1)] { + px(gx, gy, green); + } + star(2, 1, gold); + 11.0 * k + } + } + } + // text with a dark shadow pub fn text_sh(&self, s: &str, x: f32, y: f32, scale: f32, color: Color) { self.text(s, x + scale, y + scale, scale, Color::new(0.0, 0.0, 0.0, color.a * 0.85)); diff --git a/src/main.rs b/src/main.rs index 030000c..c833195 100644 --- a/src/main.rs +++ b/src/main.rs @@ -322,7 +322,9 @@ async fn main() { app.game.hints = app.settings.hints; let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); - let mut auto_touch = platform::coarse_pointer(); // automatic mode: touch interface after a touch, desktop after a mouse click + let mut auto_touch = platform::coarse_pointer(); + // 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 cache_t = 0.0f32; @@ -504,7 +506,10 @@ async fn main() { move_cam(pan, 0.0, cam); } let edge = 6.0; - if keys.edge_scroll && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { + if !mouse_moved && mouse_delta_position() != Vec2::ZERO { + mouse_moved = true; + } + if keys.edge_scroll && mouse_moved && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { if mx < edge { move_cam(-pan, 0.0, cam); } else if mx > screen_width() - edge { diff --git a/src/render3d.rs b/src/render3d.rs index 8e99c2d..d934f2f 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1020,6 +1020,7 @@ impl R3d { } draw_landmarks_solid(g, t); draw_towers_solid(g); + draw_gear(g, cam); crate::props::draw_props_solid(g, cam, t); // ---- unlit glow / overlays @@ -1052,6 +1053,7 @@ impl R3d { // translucent glows and effects last (they write depth, see glow_material) draw_landmarks_glow(g, t); draw_towers_glow(g, t); + draw_gear_glow(g, cam, t); crate::props::draw_props_glow(g, cam, t); for f in &g.fx { draw_fx(f, t); @@ -1176,9 +1178,11 @@ impl R3d { if e.guard { gfx.glyph(0x0F, head.x - (GW * small * 0.5).floor(), head.y - 2.0 * GH * small, small, Color::from_rgba(255, 220, 90, 255)); } - if e.level > 1 && px_per_tile >= 24.0 * F() as f32 { - let x = head.x - (cols as f32 * cw * 0.5).floor() - 2.0 * cw; - gfx.text_sh(&e.level.to_string(), x, head.y - GH * small, small, Color::from_rgba(255, 220, 90, 255)); + // rank badge left of the health bar + if e.level > 1 && px_per_tile >= 20.0 * F() as f32 { + let k = 2.0 * F() as f32; + let x = head.x - (cols as f32 * cw * 0.5).floor() - 12.0 * k; + gfx.badge(x, head.y - GH * small - 4.0 * k, e.level, k); } if matches!(e.job, Job::Sleep(_)) && !e.moving { let k = (t * 1.5 + e.anim) % 1.0; @@ -1216,6 +1220,78 @@ fn shade_c(r: u8, g: u8, b: u8, f: f32) -> Color { Color::new(r as f32 / 255.0 * f, g as f32 / 255.0 * f, b as f32 / 255.0 * f, 1.0) } +// size of a unit's sprite (same as in the billboards) +fn sprite_size(k: EK) -> f32 { + if k == EK::Chainbreaker { + 1.0 + } else if matches!(k, EK::Ogre | EK::Paladin | EK::Jailer) { + 0.8 + } else { + 0.62 + } +} + +fn gear_shown(e: &Ent) -> bool { + e.alive && !e.held && e.captive == 0 && e.level >= 3 && !(matches!(e.job, Job::Sleep(_)) && !e.moving) +} + +// Equipment that grows with the level: shoulder pieces (Lv 3+: bronze, 5+: silver, 8+: gold), +// a helmet (Lv 5+: horns for creatures, a plume for heroes) and a cape (Lv 7+). +fn draw_gear(g: &Game, cam: &Cam) { + let (_, r, _) = cam.basis(); + let toward = cam.eye_dir(); + for e in g.ents.iter().filter(|e| gear_shown(e)) { + let w = sprite_size(e.kind); + let bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + let base = vec3(e.x, bob, e.y) + toward * 0.5; + let at = |dx: f32, dy: f32| base + r * (dx * w) + Vec3::Y * (dy * w); + let metal = if e.level >= 8 { + Color::from_rgba(240, 200, 70, 255) + } else if e.level >= 5 { + Color::from_rgba(200, 206, 220, 255) + } else { + Color::from_rgba(190, 120, 60, 255) + }; + if e.level >= 7 { + // cape behind the figure + let back = base - toward * 0.1; + draw_cube(back + Vec3::Y * (0.5 * w), vec3(0.42 * w, 0.55 * w, 0.42 * w), None, if e.hero { Color::from_rgba(40, 70, 150, 255) } else { Color::from_rgba(120, 20, 30, 255) }); + } + for side in [-1.0f32, 1.0] { + draw_cube(at(side * 0.26, 0.6), Vec3::splat(0.09 * w + 0.02), None, metal); + } + if e.level >= 5 { + draw_cube(at(0.0, 0.93), vec3(0.2 * w, 0.06 * w, 0.2 * w), None, metal); + if e.hero { + draw_cube(at(0.0, 1.02), vec3(0.04, 0.1 * w, 0.04), None, Color::from_rgba(200, 40, 40, 255)); + } else { + for side in [-1.0f32, 1.0] { + draw_cube(at(side * 0.16, 1.0), vec3(0.035, 0.09 * w, 0.035), None, Color::from_rgba(230, 220, 200, 255)); + } + } + } + } +} + +// Lv 9+: a glowing ring above the head, Lv 10 also a faint aura at the feet +fn draw_gear_glow(g: &Game, cam: &Cam, t: f32) { + let toward = cam.eye_dir(); + for e in g.ents.iter().filter(|e| gear_shown(e) && e.level >= 9) { + let w = sprite_size(e.kind); + let base = vec3(e.x, 0.0, e.y) + toward * 0.5; + let c = if e.hero { Color::new(0.8, 0.9, 1.0, 0.8) } else { Color::new(1.0, 0.8, 0.3, 0.8) }; + let y = w * 1.12 + (t * 2.0 + e.anim).sin() * 0.02; + for k in 0..8 { + let a0 = k as f32 * 0.785 + t; + let a1 = a0 + 0.785; + line3(base + vec3(a0.cos() * 0.12, y, a0.sin() * 0.12), base + vec3(a1.cos() * 0.12, y, a1.sin() * 0.12), 0.025, c); + } + if e.level >= 10 { + disc(vec3(e.x, 0.015, e.y), 0.42 + 0.03 * (t * 3.0).sin(), Color::new(c.r, c.g, c.b, 0.18), 16); + } + } +} + fn draw_towers_solid(g: &Game) { for tw in &g.towers { let (x, z) = tw.pos(); diff --git a/src/ui.rs b/src/ui.rs index 3b26256..351e211 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1023,6 +1023,7 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec let e = &g.ents[j]; let s = st(e.kind); 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)); if e.hero && e.captive != 0 { out.push((tr(["", "info.unconscious", "info.carried", "info.tortured"][e.captive.min(3) as usize]), RED));