diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e71299d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# quad-snd (macroquad-Audio) importiert JS-Funktionen aus mq_js_bundle.js. +# Neuere Rust-Versionen verlangen dafür explizit --allow-undefined. +[target.wasm32-unknown-unknown] +rustflags = ["-C", "link-arg=--allow-undefined"] diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..82eb2ab --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,9 @@ +# Credits + +## Schrift: DINOBYTE bitmap font +- Autor: **mby** – https://mby.itch.io/dinobyte +- Lizenz: **Creative Commons Attribution 4.0 International (CC BY 4.0)** – https://creativecommons.org/licenses/by/4.0/ +- Datei: `assets/dbyte_1x.png` (unverändert). Im Spiel wird sie ganzzahlig skaliert und eingefärbt dargestellt. + +## Alles andere +Code, prozedurale Grafiken und Spieldesign: Dungeoncraft-Projekt. diff --git a/Cargo.toml b/Cargo.toml index 9aae1b7..0700999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,16 @@ [package] -name = "unterreich" +name = "dungeoncraft" version = "0.1.0" edition = "2021" -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" +[dependencies] +macroquad = { version = "0.4", features = ["audio"] } [profile.release] opt-level = 3 lto = true panic = "abort" codegen-units = 1 + +[profile.dev] +opt-level = 1 diff --git a/DESIGN.md b/DESIGN.md index d53d868..9c4792a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,8 +4,10 @@ Eigenständiges Dungeon-Management-Spiel (Genre: Dungeon-Aufbau & Verteidigung), ## Technik - Rust + **macroquad** (WebGL), Ziel `wasm32-unknown-unknown` -- Oberfläche komplett **im Canvas**, eingebettete Schrift DejaVu Sans (freie Lizenz) für Umlaute -- Kein Sound in Version 1 (später nachrüsten) +- Oberfläche komplett **im Canvas** +- Schrift: **DINOBYTE** Bitmap-Font (CP437, 6×8 px, CC BY 4.0, siehe CREDITS.md) für alle Texte, ganzzahlig skaliert +- Alle UI-Fenster mit **CP437-Rahmen** (einfach für Panels, doppelt für Dialoge); Balken (HP/Mana/Tor) als Pixel-Balken +- Sound: Event-System + macroquad-Audio vorbereitet (Lautstärke, Stumm-Taste M); Klänge werden später generiert ## Grafik - Top-Down, **prozedurale Pixelart**: Kacheln und Figuren-Sprites werden im Code erzeugt @@ -40,3 +42,7 @@ Eigenständiges Dungeon-Management-Spiel (Genre: Dungeon-Aufbau & Verteidigung), - Wellenstärke **mitwachsend 60 % → 100 %** des Armeewerts, mit etwas Zufall - **Schonfrist** 5 Minuten, danach **Mindestwelle** (z. B. 2 Ritter) auch bei schwacher Armee - Ziel: immer schaffbar, aber herausfordernd + +## Betrieb / Test +- Build im Docker (Multi-Stage: Rust → nginx:alpine) +- Testversion auf Port **18093**, später zusätzlich Subdomain über Nginx Proxy Manager diff --git a/assets/dbyte_1x.png b/assets/dbyte_1x.png new file mode 100644 index 0000000..08a4b03 Binary files /dev/null and b/assets/dbyte_1x.png differ diff --git a/src/game.rs b/src/game.rs index 9a685c1..6926a6e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -2,11 +2,65 @@ use crate::map::*; -pub const WAVES: u32 = 6; pub const CORE_HP: f32 = 2000.0; +pub const GATE_HP: f32 = 3000.0; pub const PAYDAY: f32 = 150.0; pub const FIRST_WAVE: f32 = 300.0; -pub const WAVE_GAP: f32 = 210.0; +pub const WAVE_GAP: f32 = 180.0; +pub const MAX_SQUAD: usize = 18; + +// Sound-Ereignisse. Die Spiellogik meldet sie, das Sound-Modul spielt sie ab. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Sfx { + Dig, + GoldDig, + DigDone, + Claim, + Deposit, + Build, + Sell, + MeleeHit, + Shoot, + Death, + HeroDeath, + Lightning, + Heal, + Summon, + PortalArrive, + WaveAlarm, + WaveStart, + CoreHit, + GateHit, + GateDefenders, + LevelUp, + Pickup, + Drop, + Payday, + FlagSet, + Victory, + Defeat, + Click, + Error, +} + +pub const SFX_ALL: [Sfx; 29] = [ + 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, +]; + +impl Sfx { + pub fn index(self) -> usize { + self as usize + } +} + +#[derive(Clone, Copy)] +pub struct SfxEvent { + pub sfx: Sfx, + pub pos: Option<(f32, f32)>, // Weltposition (für Lautstärke nach Entfernung), None = UI +} pub const MANA_MAX: f32 = 400.0; pub const CARRY_MAX: i32 = 300; @@ -65,6 +119,7 @@ pub enum Job { Attack(u32), AttackCore, Leave, + Rally, } #[derive(Clone)] @@ -96,6 +151,7 @@ pub struct Ent { pub face: f32, pub moving: bool, pub working: bool, + pub guard: bool, // Torverteidiger } impl Ent { @@ -128,6 +184,7 @@ impl Ent { face: 1.0, moving: false, working: false, + guard: false, } } pub fn tile(&self) -> usize { @@ -173,6 +230,12 @@ pub struct Game { pub paused: bool, pub hand: Vec, pub counts: [usize; 5], // Schatzkammer, Quartier, Pilzgarten, Trainingshalle, Bibliothek + pub gate_hp: f32, + pub gate_hit_t: f32, + pub gate_stage: u32, // ausgelöste Verteidiger-Schwellen (0..3) + pub flag: Option, + pub events: Vec, + pub last_wave_value: f32, hint_t: [f32; 16], } @@ -221,6 +284,12 @@ impl Game { paused: false, hand: Vec::new(), counts: [0; 5], + gate_hp: GATE_HP, + gate_hit_t: -100.0, + gate_stage: 0, + flag: None, + events: Vec::new(), + last_wave_value: 0.0, hint_t: [-1000.0; 16], }; let (cx, cy) = g.map.core; @@ -228,9 +297,10 @@ impl Game { g.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + k as f32 * 0.7 + 0.3, 1); } g.recount(); - g.msg("Willkommen, Herrscher des Unterreichs! Beschütze deinen Seelenkern.".into()); + g.msg("Willkommen, Herrscher des Unterreichs! Beschütze deinen Seelenkern und zerstöre das Heldentor.".into()); g.msg("Markiere Erde und Goldadern mit dem Werkzeug »Graben« (Taste 1). Deine Grubenwichte erledigen den Rest.".into()); g.msg("Grabe dich zum Portal durch und erobere es. Kreaturen brauchen ein Quartier und einen Pilzgarten.".into()); + g.msg("Mit der Angriffsflagge (Taste 9) schickst du deine Kreaturen in den Kampf, auch gegen das Heldentor.".into()); g } @@ -241,6 +311,34 @@ impl Game { } } + pub fn sfx(&mut self, sfx: Sfx, x: f32, y: f32) { + if self.events.len() < 64 { + self.events.push(SfxEvent { sfx, pos: Some((x, y)) }); + } + } + + pub fn sfx_ui(&mut self, sfx: Sfx) { + if self.events.len() < 64 { + self.events.push(SfxEvent { sfx, pos: None }); + } + } + + pub fn gate_center(&self) -> (f32, f32) { + (self.map.gate.0 as f32 + 1.5, self.map.gate.1 as f32 + 1.5) + } + + // Armeewert: sqrt(Summe HP * Summe Schaden/s) aller eigenen Kreaturen. + pub fn army_value(&self) -> f32 { + let (mut h, mut d) = (0.0, 0.0); + for e in &self.ents { + if e.alive && e.creature() { + h += e.max_hp; + d += e.atk() / st(e.kind).interval; + } + } + (h * d).sqrt() + } + fn hint(&mut self, key: usize, s: &str) { if self.time - self.hint_t[key] > 30.0 { self.hint_t[key] = self.time; @@ -359,18 +457,20 @@ impl Game { self.do_payday(); } - if self.wave < WAVES { - self.wave_timer -= dt; - if !self.warned && self.wave_timer < 30.0 { - self.warned = true; - self.msg(format!("Späher melden: Welle {} der Helden erreicht in 30 Sekunden das Tor!", self.wave + 1)); - } - if self.wave_timer <= 0.0 { - self.spawn_wave(); - self.wave += 1; - self.wave_timer = WAVE_GAP; - self.warned = false; - } + self.wave_timer -= dt; + if !self.warned && self.wave_timer < 30.0 { + self.warned = true; + self.msg(format!("Späher melden: Welle {} der Helden erreicht in 30 Sekunden das Tor!", self.wave + 1)); + self.sfx_ui(Sfx::WaveAlarm); + } + if self.wave_timer <= 0.0 { + self.spawn_wave(); + self.wave += 1; + self.wave_timer = WAVE_GAP + self.rng.f() * 40.0; + self.warned = false; + } + if self.time - self.gate_hit_t > 10.0 { + self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP); } for i in 0..self.ents.len() { @@ -387,9 +487,12 @@ impl Game { self.core_hp = 0.0; self.state = 2; self.msg("Dein Seelenkern ist zerbrochen. Das Unterreich ist verloren.".into()); - } else if self.wave >= WAVES && self.hero_count() == 0 { + self.sfx_ui(Sfx::Defeat); + } else if self.gate_hp <= 0.0 { + self.gate_hp = 0.0; self.state = 1; - self.msg("Alle Heldenwellen sind vernichtet. Das Unterreich gehört dir!".into()); + self.msg("Das Heldentor ist zerstört. Das Unterreich gehört dir!".into()); + self.sfx_ui(Sfx::Victory); } } @@ -433,6 +536,7 @@ impl Game { let (px, py) = self.map.portal; self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1); self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff); + self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5); self.msg(format!("Ein {} ist durch das Portal gekommen.", st(kind).name)); } @@ -458,6 +562,7 @@ impl Game { } } if paid + unpaid > 0 { + self.sfx_ui(Sfx::Payday); if unpaid > 0 { self.msg(format!("Zahltag! {} Kreaturen bezahlt ({} Gold), {} gingen leer aus und sind wütend!", paid, total as i32, unpaid)); } else { @@ -466,32 +571,129 @@ impl Game { } } + // Stärke eines Trupps (gleiche Formel wie der Armeewert) + fn squad_value(list: &[(EK, u32)]) -> f32 { + let (mut h, mut d) = (0.0, 0.0); + for &(k, l) in list { + let s = st(k); + h += s.hp * (1.0 + 0.15 * (l - 1) as f32); + d += s.atk * (1.0 + 0.12 * (l - 1) as f32) / s.interval; + } + (h * d).sqrt() + } + + // Stellt einen Heldentrupp mit mindestens der Zielstärke zusammen. + fn build_squad(&mut self, target: f32, base_level: u32, pool: &[(EK, u32)], boss: Option, min: usize) -> Vec<(EK, u32)> { + let total: u32 = pool.iter().map(|p| p.1).sum(); + let mut list = Vec::new(); + for level in base_level.min(10)..=10 { + list.clear(); + if let Some(b) = boss { + list.push((b, level)); + } + while list.len() < MAX_SQUAD && (list.len() < min || Self::squad_value(&list) < target) { + let mut r = self.rng.next() % total; + for &(k, w) in pool { + if r < w { + list.push((k, level)); + break; + } + r -= w; + } + } + if Self::squad_value(&list) >= target { + break; + } + } + list + } + + fn spawn_squad(&mut self, list: &[(EK, u32)], guard: bool) { + let (gx, gy) = self.map.gate; + for &(k, l) in list { + let x = gx as f32 + 0.5 + self.rng.f() * 2.0; + let y = gy as f32 + 0.5 + self.rng.f() * 2.0; + let id = self.spawn(k, true, x, y, l); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.guard = guard; + } + } + self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc); + } + + // Endlose Wellen: Stärke 60 % -> 100 % des Armeewerts, mit etwas Zufall, mindestens 2 Ritter. fn spawn_wave(&mut self) { use EK::*; let w = self.wave + 1; - let list: Vec = match w { - 1 => vec![Knight, Knight, Archer], - 2 => vec![Knight, Knight, Archer, Archer, Dwarf], - 3 => vec![Knight, Knight, Knight, Archer, Archer, Priest, Dwarf], - 4 => vec![Knight, Knight, Knight, Knight, Archer, Archer, Archer, Priest, Dwarf, Dwarf], - 5 => vec![Knight, Knight, Knight, Knight, Knight, Archer, Archer, Archer, Archer, Priest, Priest, Dwarf, Dwarf], - _ => vec![Paladin, Knight, Knight, Knight, Knight, Archer, Archer, Archer, Archer, Priest, Priest, Dwarf], - }; - let (gx, gy) = self.map.gate; - let n = list.len(); - for k in list { - let x = gx as f32 + 0.5 + self.rng.f() * 2.0; - let y = gy as f32 + 0.5 + self.rng.f() * 2.0; - self.spawn(k, true, x, y, w); + let ratio = (0.6 + 0.05 * (w - 1) as f32).min(1.0) * (0.88 + self.rng.f() * 0.24); + let min_value = Self::squad_value(&[(Knight, 1), (Knight, 1)]); + let target = (self.army_value() * ratio).max(min_value); + let mut pool = vec![(Knight, 4), (Archer, 3)]; + if w >= 2 { + pool.push((Dwarf, 2)); } - self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc); - if w == WAVES { - self.msg(format!("Letzte Welle: Der Lichtpaladin führt {} Helden in dein Reich!", n)); + if w >= 3 { + pool.push((Priest, 2)); + } + let boss = if w % 5 == 0 { Some(Paladin) } else { None }; + let base_level = 1 + (w - 1) / 3; + let list = self.build_squad(target, base_level, &pool, boss, 2); + self.last_wave_value = Self::squad_value(&list); + self.spawn_squad(&list, false); + self.sfx_ui(Sfx::WaveStart); + if boss.is_some() { + self.msg(format!("Welle {}: Ein Lichtpaladin führt {} Helden in dein Reich!", w, list.len())); } else { - self.msg(format!("Welle {}: {} Helden dringen durch das Tor!", w, n)); + self.msg(format!("Welle {}: {} Helden dringen durch das Tor!", w, list.len())); } } + // Verteidiger bei 75 / 50 / 25 % Tor-HP + fn check_gate_stage(&mut self) { + use EK::*; + let frac = self.gate_hp / GATE_HP; + let want = if frac <= 0.25 { 3 } else if frac <= 0.5 { 2 } else if frac <= 0.75 { 1 } else { 0 }; + while self.gate_stage < want { + self.gate_stage += 1; + let st_i = self.gate_stage; + let ratio = [0.5, 0.65, 0.85][(st_i - 1) as usize] * (0.9 + self.rng.f() * 0.2); + let min_value = Self::squad_value(&[(Knight, 2), (Knight, 2), (Archer, 2)]); + let target = (self.army_value() * ratio).max(min_value); + let pool = [(Knight, 4), (Archer, 3), (Priest, 2), (Dwarf, 1)]; + let boss = if st_i == 3 { Some(Paladin) } else { None }; + let base_level = 2 + self.wave / 3 + st_i; + let list = self.build_squad(target, base_level, &pool, boss, 3); + self.spawn_squad(&list, true); + self.sfx_ui(Sfx::GateDefenders); + if st_i == 3 { + self.msg(format!("Das Tor wankt! Der Torwächter erscheint mit {} Verteidigern!", list.len() - 1)); + } else { + self.msg(format!("Das Tor ruft {} Verteidiger herbei!", list.len())); + } + } + } + + fn hit_gate(&mut self, e: &mut Ent) { + let s = st(e.kind); + e.cd = s.interval; + let dmg = e.atk() * (0.85 + self.rng.f() * 0.3); + self.gate_hp -= dmg; + self.gate_hit_t = self.time; + e.xp += dmg * 0.3; + let (gx, gy) = self.gate_center(); + if s.range > 1.5 { + let col = if e.kind == EK::Mage { 0xffff44cc } else { 0xff70b0d0 }; + self.fx(0, e.x, e.y - 0.3, gx, gy, 0.25, col); + } else { + let fx = (e.x + gx) * 0.5; + let fy = (e.y + gy) * 0.5; + self.fx(2, fx, fy, 0.0, 0.0, 0.25, 0xffffffff); + } + self.sfx(Sfx::GateHit, gx, gy); + self.hint(10, "Deine Kreaturen greifen das Heldentor an!"); + self.check_gate_stage(); + } + fn update_ent(&mut self, e: &mut Ent, dt: f32) { e.anim += dt; e.flash = (e.flash - dt).max(0.0); @@ -508,6 +710,9 @@ impl Game { e.max_hp = s.hp * (1.0 + 0.15 * (e.level - 1) as f32); e.hp += e.max_hp - old; self.fx(7, e.x, e.y, 0.0, 0.0, 0.8, 0xff44ddff); + if !e.hero { + self.sfx(Sfx::LevelUp, e.x, e.y); + } if !e.hero && e.level >= 3 { self.msg(format!("{} erreicht Stufe {}.", s.name, e.level)); } @@ -543,6 +748,7 @@ impl Game { _ => 1.0, }; self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.4, 0xff3a5a7a); + self.sfx(Sfx::Dig, tx as f32 + 0.5, ty as f32 + 0.5); let t = &mut self.map.t[next]; t.hp -= pow; if t.hp <= 0.0 { @@ -588,6 +794,7 @@ impl Game { t.gold = 0; let (x, y) = xy(i); self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66); + self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5); } fn unreserve(&mut self, me: u32) { @@ -694,8 +901,10 @@ impl Game { _ => 0xff70b0d0, }; self.fx(0, e.x, e.y - 0.3, tx, ty - 0.3, 0.25, col); + self.sfx(Sfx::Shoot, e.x, e.y); } else { self.fx(2, tx, ty - 0.2, 0.0, 0.0, 0.25, 0xffffffff); + self.sfx(Sfx::MeleeHit, tx, ty); } e.xp += dmg * 0.6; let my_id = e.id; @@ -722,8 +931,10 @@ impl Game { let b = s.bounty as f32; let got = self.add_gold(b); self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff); + self.sfx(Sfx::HeroDeath, x, y); self.msg(format!("{} besiegt! Beute: {} Gold.", s.name, got as i32)); } else { + self.sfx(Sfx::Death, x, y); self.msg(format!("Dein {} ist gefallen.", s.name)); } } @@ -823,10 +1034,12 @@ impl Game { e.carry += take; done = tl.gold <= 0; self.fx(5, fxx, fxy, 0.0, 0.0, 0.5, 0xff30d0ff); + self.sfx(Sfx::GoldDig, fxx, fxy); } else { tl.hp -= 1.0; done = tl.hp <= 0.0; self.fx(6, fxx, fxy, 0.0, 0.0, 0.4, 0xff3a5a7a); + self.sfx(Sfx::Dig, fxx, fxy); } if done { self.dig_out(t); @@ -864,6 +1077,7 @@ impl Game { } let (x, y) = xy(t); self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0); + self.sfx(Sfx::Claim, x as f32 + 0.5, y as f32 + 0.5); } } Job::Deliver => { @@ -875,6 +1089,7 @@ impl Game { } if got > 0.0 { self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff); + self.sfx(Sfx::Deposit, e.x, e.y); } e.carry = 0; e.job = Job::None; @@ -950,7 +1165,8 @@ impl Game { if e.think <= 0.0 { e.think = 0.4 + self.rng.f() * 0.3; if !matches!(e.job, Job::Attack(_) | Job::Leave) { - if let Some(id) = self.nearest_enemy(e, 7.0) { + let radius = if e.job == Job::Rally { 8.5 } else { 7.0 }; + if let Some(id) = self.nearest_enemy(e, radius) { self.unreserve(me); e.job = Job::Attack(id); e.path.clear(); @@ -965,6 +1181,10 @@ impl Game { } return; } + if e.job == Job::Rally { + self.rally(e, dt); + return; + } if self.step_path(e, dt) { return; } @@ -1060,6 +1280,104 @@ impl Game { } } + fn wants_rally(&self, e: &Ent) -> bool { + self.flag.is_some() && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && e.hunger < 1.8 && e.happy > 0.0 + } + + // Angriffsflagge: zur Flagge marschieren; liegt das Tor in der Nähe, wird es angegriffen. + fn rally(&mut self, e: &mut Ent, dt: f32) { + let Some(f) = self.flag else { + e.job = Job::None; + e.path.clear(); + return; + }; + if e.hp < e.max_hp * 0.3 || e.energy < 0.1 || e.hunger > 2.2 { + // erschöpft oder schwer verletzt: zurückziehen + e.job = Job::None; + e.path.clear(); + return; + } + let (fx, fy) = xy(f); + let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5); + let (gx, gy) = self.gate_center(); + let gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0; + let s = st(e.kind); + if gate_near { + let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt(); + if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) { + e.path.clear(); + e.face = sgn(gx - e.x); + e.working = true; + if e.cd <= 0.0 { + self.hit_gate(e); + } + return; + } + } + let (target, tx, ty, near) = if gate_near { + (idx(gx as i32, gy as i32), gx, gy, s.range + 1.2) + } else { + (f, fx, fy, 1.5) + }; + let d = ((e.x - tx).powi(2) + (e.y - ty).powi(2)).sqrt(); + if d <= near && e.path.is_empty() { + return; // wartet an der Flagge + } + if e.path.is_empty() || e.repath <= 0.0 { + e.repath = 1.5; + match self.map.path_to(e.tile(), target) { + Some(p) => e.path = p, + None => { + self.hint(11, "Die Angriffsflagge ist für deine Kreaturen nicht erreichbar."); + e.job = Job::Wander; + e.timer = 3.0; + return; + } + } + } + self.step_path(e, dt); + } + + pub fn set_flag(&mut self, x: i32, y: i32) { + if !inb(x, y) { + return; + } + let i = idx(x, y); + if self.flag == Some(i) { + self.flag = None; + self.msg("Angriffsflagge entfernt. Deine Kreaturen kehren zurück.".into()); + self.sfx_ui(Sfx::Click); + for e in self.ents.iter_mut() { + if e.alive && e.creature() && e.job == Job::Rally { + e.job = Job::None; + e.path.clear(); + } + } + return; + } + if !self.map.t[i].walkable() { + self.hint(12, "Die Flagge muss auf begehbarem Boden stehen."); + self.sfx_ui(Sfx::Error); + return; + } + self.flag = Some(i); + self.sfx(Sfx::FlagSet, x as f32 + 0.5, y as f32 + 0.5); + self.msg("Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort.".into()); + let mut ids = Vec::new(); + for e in self.ents.iter() { + if e.alive && !e.held && e.creature() && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::Rally) { + ids.push(e.id); + } + } + for id in ids { + self.unreserve(id + 1); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.job = Job::None; + e.path.clear(); + } + } + } + fn creature_choose(&mut self, e: &mut Ent) { let me = e.id + 1; let cur = e.tile(); @@ -1104,7 +1422,7 @@ impl Game { e.job = Job::Sleep(cur); return; } - if e.hunger > 1.0 { + if e.hunger > 1.0 && !(self.flag.is_some() && e.hunger < 1.5) { if let Some(p) = self.find_room_tile(cur, Kind::Farm, me, true) { let t = p.last().copied().unwrap_or(cur); self.map.t[t].reserved = me; @@ -1117,6 +1435,18 @@ impl Game { self.hint(5, "Deine Kreaturen hungern! Vergrößere den Pilzgarten."); } } + if self.wants_rally(e) { + e.job = Job::Rally; + e.path.clear(); + e.repath = 0.0; + return; + } + if e.hp < e.max_hp * 0.5 && e.energy >= 0.25 && self.counts[1] > 0 { + // verletzt: im Quartier ausruhen + e.energy = e.energy.min(0.2); + self.creature_choose(e); + return; + } let r = self.rng.f(); if (e.kind == EK::Mage && r < 0.8) || r < 0.2 { if let Some(p) = self.find_room_tile(cur, Kind::Library, me, false) { @@ -1172,7 +1502,8 @@ impl Game { } } if !matches!(e.job, Job::Attack(_)) { - if let Some(id) = self.nearest_enemy(e, 6.5) { + let radius = if e.guard { 8.0 } else { 6.5 }; + if let Some(id) = self.nearest_enemy(e, radius) { e.job = Job::Attack(id); e.path.clear(); e.repath = 0.0; @@ -1180,13 +1511,29 @@ impl Game { } } if let Job::Attack(id) = e.job { - if self.attack_job(e, id, dt) { + let (gx, gy) = self.gate_center(); + let far = e.guard && (e.x - gx).powi(2) + (e.y - gy).powi(2) > 14.0 * 14.0; + if !far && self.attack_job(e, id, dt) { return; } e.job = Job::AttackCore; e.path.clear(); e.think = 1.5; } + if e.guard { + // Torwächter bleiben am Tor + let (gx, gy) = self.gate_center(); + let d2 = (e.x - gx).powi(2) + (e.y - gy).powi(2); + if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 { + e.repath = 1.0; + let target = idx(gx as i32, gy as i32); + if let Some(p) = self.map.path_to(e.tile(), target) { + e.path = p; + } + } + self.step_path(e, dt); + return; + } let cur = e.tile(); if e.path.is_empty() && self.map.adj_core(cur) { e.working = true; @@ -1198,6 +1545,7 @@ impl Game { let fx = (e.x + cx as f32 + 1.5) * 0.5; let fy = (e.y + cy as f32 + 1.5) * 0.5; self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff); + self.sfx(Sfx::CoreHit, fx, fy); self.hint(6, "Dein Seelenkern wird angegriffen!"); } return; @@ -1250,11 +1598,15 @@ impl Game { t.bed = 0; built += 1; self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff); + if built == 1 { + self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5); + } } } } if broke { self.msg("Nicht genug Gold!".into()); + self.sfx_ui(Sfx::Error); } if built == 0 && !broke { self.hint(7, "Räume können nur auf eroberten, leeren Bodenfeldern gebaut werden."); @@ -1283,6 +1635,7 @@ impl Game { self.recount(); if refund > 0.0 { let got = self.add_gold(refund); + self.sfx_ui(Sfx::Sell); self.msg(format!("Räume verkauft: +{} Gold.", got as i32)); } } @@ -1317,6 +1670,7 @@ impl Game { e.job = Job::None; e.path.clear(); self.hand.push(id); + self.sfx_ui(Sfx::Pickup); return true; } false @@ -1343,6 +1697,7 @@ impl Game { e.path.clear(); } self.fx(3, wx, wy, 0.0, 0.0, 0.4, 0xff707090); + self.sfx(Sfx::Drop, wx, wy); true } @@ -1350,9 +1705,11 @@ impl Game { const COST: f32 = 50.0; if self.mana < COST { self.msg("Nicht genug Mana für den Blitz.".into()); + self.sfx_ui(Sfx::Error); return; } self.mana -= COST; + self.sfx(Sfx::Lightning, wx, wy); self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa); let mut kills = Vec::new(); for (j, e) in self.ents.iter_mut().enumerate() { @@ -1373,9 +1730,11 @@ impl Game { const COST: f32 = 60.0; if self.mana < COST { self.msg("Nicht genug Mana für die Heilung.".into()); + self.sfx_ui(Sfx::Error); return; } self.mana -= COST; + self.sfx(Sfx::Heal, wx, wy); let mut fxs = Vec::new(); for e in self.ents.iter_mut() { if e.alive && !e.held && !e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 6.5 { @@ -1398,9 +1757,11 @@ impl Game { } if self.mana < cost { self.msg(format!("Nicht genug Mana ({} benötigt).", cost as i32)); + self.sfx_ui(Sfx::Error); return; } self.mana -= cost; + self.sfx(Sfx::Summon, wx, wy); self.spawn(EK::Worker, false, wx, wy, 1); self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xffff66cc); } @@ -1432,5 +1793,12 @@ pub fn job_name(e: &Ent) -> &'static str { } } Job::Leave => "Verlässt das Reich", + Job::Rally => { + if e.working { + "Greift das Heldentor an" + } else { + "Folgt der Angriffsflagge" + } + } } } diff --git a/src/gfx.rs b/src/gfx.rs new file mode 100644 index 0000000..bac6019 --- /dev/null +++ b/src/gfx.rs @@ -0,0 +1,748 @@ +// Prozedural erzeugte Pixelart: Kachel-Atlas und Figuren-Sprites. + +use crate::game::EK; +use crate::map::Kind; +use macroquad::prelude::*; + +pub const CELL: f32 = 16.0; +const ATLAS_COLS: u32 = 16; +const ATLAS_ROWS: u32 = 5; + +// Atlas-Indizes (jeweils 4 Varianten) +pub const T_ROCK: u32 = 0; +pub const T_EARTH: u32 = 4; +pub const T_GOLD: u32 = 8; +pub const T_FLOOR: u32 = 12; +pub const T_CLAIM: u32 = 16; +pub const T_HEROFLOOR: u32 = 20; +pub const T_TREASURY: u32 = 24; +pub const T_LAIR: u32 = 28; +pub const T_FARM: u32 = 32; +pub const T_TRAINING: u32 = 36; +pub const T_LIBRARY: u32 = 40; +pub const T_COREFLOOR: u32 = 44; +pub const T_PORTALFLOOR: u32 = 48; +pub const T_GATEFLOOR: u32 = 52; +// Einzelzellen +pub const T_FACE_EARTH: u32 = 56; +pub const T_FACE_ROCK: u32 = 57; +pub const T_FACE_GOLD: u32 = 58; +pub const T_MARK: u32 = 59; +pub const D_BED: u32 = 60; +pub const D_MUSHROOM: u32 = 61; +pub const D_DUMMY: u32 = 62; +pub const D_COINS: u32 = 63; +pub const D_SHELF: u32 = 64; +pub const I_PICK: u32 = 65; +pub const I_HAND: u32 = 66; +pub const I_SELL: u32 = 67; +pub const I_FLAG: u32 = 68; +pub const I_BOLT: u32 = 69; +pub const I_HEAL: u32 = 70; +pub const I_SUMMON: u32 = 71; + +pub struct Gfx { + pub atlas: Texture2D, + pub sprites: Texture2D, + pub font: Texture2D, // DINOBYTE, CP437, 16x16 Zeichen à 6x8 px +} + +// ------------------------------------------------------------------ Bitmap-Schrift (CP437) + +pub const GW: f32 = 6.0; // Glyphbreite +pub const GH: f32 = 8.0; // Glyphhöhe + +const CP437_LOW: &str = " ☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼"; +const CP437_HIGH: &str = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}"; + +pub fn cp437(ch: char) -> u8 { + let u = ch as u32; + if (32..127).contains(&u) { + return u as u8; + } + if ch == '⌂' { + return 127; + } + if let Some(i) = CP437_LOW.chars().position(|c| c == ch) { + if i > 0 { + return i as u8; + } + } + if let Some(i) = CP437_HIGH.chars().position(|c| c == ch) { + return 128 + i as u8; + } + match ch { + 'ẞ' => 0xE1, + '–' | '—' => b'-', + '„' | '“' | '”' => b'"', + '‚' | '‘' | '’' => b'\'', + '…' => b'.', + '×' => b'x', + _ => b'?', + } +} + +// Rahmenzeichen: [oben-links, oben-rechts, unten-links, unten-rechts, waagrecht, senkrecht] +pub const BOX_SINGLE: [u8; 6] = [0xDA, 0xBF, 0xC0, 0xD9, 0xC4, 0xB3]; +pub const BOX_DOUBLE: [u8; 6] = [0xC9, 0xBB, 0xC8, 0xBC, 0xCD, 0xBA]; + +fn hash(x: i32, y: i32, s: u32) -> u32 { + let mut h = (x as u32).wrapping_mul(374761393) ^ (y as u32).wrapping_mul(668265263) ^ s.wrapping_mul(2246822519); + h = (h ^ (h >> 13)).wrapping_mul(1274126177); + h ^ (h >> 16) +} +fn rnd(x: i32, y: i32, s: u32) -> f32 { + (hash(x, y, s) & 0xffff) as f32 / 65535.0 +} + +fn c(r: i32, g: i32, b: i32) -> Color { + Color::from_rgba(r.clamp(0, 255) as u8, g.clamp(0, 255) as u8, b.clamp(0, 255) as u8, 255) +} +fn shade(base: (i32, i32, i32), d: i32) -> Color { + c(base.0 + d, base.1 + d, base.2 + d) +} + +struct Canvas { + img: Image, +} + +impl Canvas { + fn cell(&mut self, id: u32, f: impl Fn(i32, i32) -> Option) { + let ox = (id % ATLAS_COLS) * 16; + let oy = (id / ATLAS_COLS) * 16; + for y in 0..16 { + for x in 0..16 { + if let Some(col) = f(x, y) { + self.img.set_pixel(ox + x as u32, oy + y as u32, col); + } + } + } + } +} + +// Pflasterstein-Muster mit Fugen +fn stones(x: i32, y: i32, v: u32, base: (i32, i32, i32), grout: (i32, i32, i32), var: i32) -> Color { + let row = y / 8; + let off = if row % 2 == 1 { 4 } else { 0 }; + let sx = (x + off) % 16; + if y % 8 == 0 || sx % 8 == 0 { + return shade(grout, (rnd(x, y, v + 7) * 6.0) as i32); + } + let stone = hash((x + off) / 8, row, v + 3) % 5; + let d = (rnd(x, y, v) * var as f32) as i32 - var / 2 + stone as i32 * 3 - 6; + // Lichtkante oben/links, Schatten unten/rechts + let edge = if y % 8 == 1 || sx % 8 == 1 { + 10 + } else if y % 8 == 7 || sx % 8 == 7 { + -8 + } else { + 0 + }; + shade(base, d + edge) +} + +fn earth_px(x: i32, y: i32, v: u32) -> Color { + let base = (98, 68, 44); + let big = rnd(x / 3, y / 3, v + 11) * 16.0 - 8.0; + let fine = rnd(x, y, v) * 14.0 - 7.0; + if hash(x, y, v + 99) % 37 == 0 { + return c(150, 120, 90); + } + if hash(x, y, v + 98) % 29 == 0 { + return c(62, 42, 28); + } + shade(base, (big + fine) as i32) +} + +fn gen_atlas() -> Image { + let mut cv = Canvas { img: Image::gen_image_color((ATLAS_COLS * 16) as u16, (ATLAS_ROWS * 16) as u16, Color::new(0.0, 0.0, 0.0, 0.0)) }; + for v in 0..4u32 { + // Fels + cv.cell(T_ROCK + v, |x, y| { + let blk = rnd(x / 4 + (y / 4) % 2, y / 4, v + 21) * 20.0 - 10.0; + let fine = rnd(x, y, v + 5) * 10.0 - 5.0; + let crack = (x + y * 3 + v as i32 * 5) % 23 == 0 || (x * 2 - y + v as i32) % 31 == 0; + if crack { + Some(c(40, 40, 48)) + } else { + Some(shade((80, 80, 92), (blk + fine) as i32)) + } + }); + // Erde + cv.cell(T_EARTH + v, |x, y| Some(earth_px(x, y, v))); + // Gold + cv.cell(T_GOLD + v, |x, y| { + let mut best = 99.0f32; + for k in 0..6 { + let nx = 2 + (hash(k, 1, v + 40) % 12) as i32; + let ny = 2 + (hash(k, 2, v + 40) % 12) as i32; + let d = (((x - nx) * (x - nx) + (y - ny) * (y - ny)) as f32).sqrt(); + best = best.min(d - if k % 2 == 0 { 0.5 } else { 0.0 }); + } + if best < 0.6 { + Some(c(255, 240, 150)) + } else if best < 1.4 { + Some(c(240, 196, 52)) + } else if best < 2.0 { + Some(c(168, 118, 30)) + } else { + Some(earth_px(x, y, v + 60)) + } + }); + cv.cell(T_FLOOR + v, |x, y| Some(stones(x, y, v, (64, 56, 50), (34, 30, 28), 10))); + cv.cell(T_CLAIM + v, |x, y| { + let col = stones(x, y, v + 10, (78, 44, 58), (36, 20, 28), 10); + // kleines Hoheitszeichen in der Mitte jedes zweiten Steins + if (x % 8 == 4 && y % 8 == 4) && hash(x / 8, y / 8, v) % 2 == 0 { + Some(c(150, 60, 80)) + } else { + Some(col) + } + }); + cv.cell(T_HEROFLOOR + v, |x, y| Some(stones(x, y, v + 20, (132, 136, 150), (80, 84, 96), 12))); + cv.cell(T_TREASURY + v, |x, y| { + let chk = ((x / 4) + (y / 4)) % 2 == 0; + let base = if chk { (120, 92, 36) } else { (100, 74, 30) }; + let d = (rnd(x, y, v + 30) * 10.0) as i32 - 5; + if x % 4 == 0 || y % 4 == 0 { + Some(shade((70, 52, 22), d)) + } else { + Some(shade(base, d)) + } + }); + cv.cell(T_LAIR + v, |x, y| { + let streak = ((x * 2 + y + (hash(y, 0, v) % 3) as i32) % 5) as i32; + let d = (rnd(x, y, v + 31) * 18.0) as i32 - 9 + streak * 4; + Some(shade((112, 86, 46), d)) + }); + cv.cell(T_FARM + v, |x, y| { + let moss = rnd(x / 2, y / 2, v + 32); + let d = (rnd(x, y, v + 33) * 14.0) as i32 - 7; + if moss > 0.55 { + Some(shade((58, 98, 40), d)) + } else { + Some(shade((62, 46, 32), d)) + } + }); + cv.cell(T_TRAINING + v, |x, y| { + let plank = y / 4; + let off = (hash(plank, 0, v + 34) % 16) as i32; + if y % 4 == 0 || (x + off) % 16 == 0 { + Some(c(58, 38, 24)) + } else { + let grain = ((x + plank * 5) % 7 == 0) as i32 * -10; + Some(shade((116, 80, 48), (rnd(x, y, v + 35) * 10.0) as i32 + grain)) + } + }); + cv.cell(T_LIBRARY + v, |x, y| { + if x >= 3 && x <= 12 && y >= 3 && y <= 12 { + let border = x == 3 || x == 12 || y == 3 || y == 12; + if border { + Some(c(170, 130, 50)) + } else { + Some(shade((116, 30, 42), (rnd(x, y, v + 36) * 10.0) as i32)) + } + } else { + let plank = x / 4; + if x % 4 == 0 { + Some(c(40, 24, 16)) + } else { + Some(shade((74, 46, 32), (rnd(x, y, v + 37) * 10.0) as i32 + (plank % 2) * 4)) + } + } + }); + cv.cell(T_COREFLOOR + v, |x, y| Some(stones(x, y, v + 40, (86, 32, 34), (40, 14, 16), 10))); + cv.cell(T_PORTALFLOOR + v, |x, y| Some(stones(x, y, v + 50, (66, 44, 92), (32, 20, 46), 10))); + cv.cell(T_GATEFLOOR + v, |x, y| { + let vein = ((x as f32 * 0.7 + (y as f32 * 0.5).sin() * 3.0 + v as f32 * 4.0) as i32) % 9 == 0; + if vein { + Some(c(150, 150, 165)) + } else { + Some(shade((196, 196, 206), (rnd(x, y, v + 60) * 12.0) as i32 - 6)) + } + }); + } + // Wandfronten (Pseudo-3D) + cv.cell(T_FACE_EARTH, |x, y| { + let streak = (hash(x, 0, 70) % 3) as i32 * 5; + Some(shade((62, 42, 28), (rnd(x, y, 71) * 10.0) as i32 - streak - y / 2)) + }); + cv.cell(T_FACE_ROCK, |x, y| { + let streak = (hash(x, 0, 72) % 3) as i32 * 5; + Some(shade((54, 54, 64), (rnd(x, y, 73) * 10.0) as i32 - streak - y / 2)) + }); + cv.cell(T_FACE_GOLD, |x, y| { + if hash(x, y, 74) % 9 == 0 { + Some(c(200, 160, 40)) + } else { + Some(shade((62, 42, 28), (rnd(x, y, 75) * 10.0) as i32 - y / 2)) + } + }); + // Grab-Markierung: diagonale Streifen + cv.cell(T_MARK, |x, y| { + if (x + y) % 6 < 2 { + Some(Color::from_rgba(255, 220, 60, 150)) + } else if x == 0 || y == 0 || x == 15 || y == 15 { + Some(Color::from_rgba(255, 220, 60, 200)) + } else { + None + } + }); + // Bett + cv.cell(D_BED, |x, y| { + let dx = (x as f32 - 7.5) / 6.5; + let dy = (y as f32 - 8.0) / 5.5; + let d = dx * dx + dy * dy; + if d < 0.55 { + Some(if x < 5 { c(200, 190, 160) } else { c(110, 50, 60) }) + } else if d < 1.0 { + Some(c(150, 118, 64)) + } else { + None + } + }); + // Pilze + cv.cell(D_MUSHROOM, |x, y| { + let caps = [(4, 6, 3), (11, 5, 2), (8, 11, 3)]; + for &(cx, cy, r) in &caps { + let dx = x - cx; + let dy = y - cy; + if dy <= 0 && dx * dx + dy * dy * 2 <= r * r + 1 { + return Some(if (x + y) % 3 == 0 && dy < 0 { c(250, 230, 220) } else { c(200, 40, 50) }); + } + if dx.abs() <= 0 && dy > 0 && dy <= r { + return Some(c(230, 220, 200)); + } + } + None + }); + // Trainingspuppe + cv.cell(D_DUMMY, |x, y| { + if (x == 7 || x == 8) && y >= 4 { + Some(c(90, 60, 36)) + } else if y == 7 && (3..=12).contains(&x) { + Some(c(90, 60, 36)) + } else if (x - 7).pow(2) + (y - 3).pow(2) <= 5 { + Some(c(200, 170, 110)) + } else if y >= 14 && (4..=11).contains(&x) { + Some(c(60, 40, 24)) + } else { + None + } + }); + // Goldhaufen + cv.cell(D_COINS, |x, y| { + let dx = (x as f32 - 7.5) / 7.0; + let dy = (y as f32 - 11.0) / 5.0; + let d = dx * dx + dy * dy; + if d < 1.0 && y <= 14 { + let hl = hash(x, y, 80) % 5 == 0; + Some(if hl { c(255, 245, 170) } else if d > 0.6 { c(190, 140, 30) } else { c(242, 200, 60) }) + } else { + None + } + }); + // Bücherregal + cv.cell(D_SHELF, |x, y| { + if y > 9 || x == 0 || x == 15 { + return if y <= 10 { Some(c(60, 36, 22)) } else { None }; + } + if y == 0 || y == 5 { + return Some(c(60, 36, 22)); + } + let book = hash(x, y / 5, 81) % 5; + Some(match book { + 0 => c(150, 40, 40), + 1 => c(40, 80, 140), + 2 => c(60, 120, 60), + 3 => c(160, 130, 50), + _ => c(100, 60, 120), + }) + }); + // Werkzeug-Icons + cv.cell(I_PICK, |x, y| { + let handle = (x + y == 16 || x + y == 17) && x >= 3; + let head = (y == 3 || y == 4) && (2..=13).contains(&x) && (x - 8).abs() + (y - 2) * 2 < 9; + if head { + Some(c(200, 205, 215)) + } else if handle { + Some(c(140, 90, 50)) + } else { + None + } + }); + cv.cell(I_HAND, |x, y| { + let palm = (3..=12).contains(&x) && (7..=14).contains(&y); + let fingers = y >= 2 && y < 8 && (x == 4 || x == 6 || x == 8 || x == 10) && !(x == 10 && y < 4); + let thumb = (x == 13 || x == 14) && (6..=10).contains(&y); + if palm || fingers || thumb { + Some(c(220, 180, 150)) + } else { + None + } + }); + cv.cell(I_SELL, |x, y| { + let d = (x - 8) * (x - 8) + (y - 8) * (y - 8); + if d <= 36 && d >= 20 { + Some(c(240, 200, 60)) + } else if (x == 8 && (4..=12).contains(&y)) || (y == 8 && (5..=11).contains(&x)) { + Some(c(240, 200, 60)) + } else { + None + } + }); + cv.cell(I_FLAG, |x, y| { + if x == 3 && y >= 1 { + Some(c(180, 180, 180)) + } else if x > 3 && x < 14 && y >= 2 && y < 9 - ((x - 4) / 3) % 2 { + Some(c(210, 40, 40)) + } else { + None + } + }); + cv.cell(I_BOLT, |x, y| { + let line1 = y < 8 && (x - (10 - y / 2)).abs() <= 1; + let line2 = y >= 7 && y <= 9 && (5..=11).contains(&x); + let line3 = y > 8 && (x - (8 - (y - 9) / 2)).abs() <= 1; + if line1 || line2 || line3 { + Some(c(255, 240, 120)) + } else { + None + } + }); + cv.cell(I_HEAL, |x, y| { + if ((6..=9).contains(&x) && (2..=13).contains(&y)) || ((2..=13).contains(&x) && (6..=9).contains(&y)) { + Some(c(90, 230, 110)) + } else { + None + } + }); + cv.cell(I_SUMMON, |x, y| { + let d = (((x - 8) * (x - 8) + (y - 8) * (y - 8)) as f32).sqrt(); + let a = ((y - 8) as f32).atan2((x - 8) as f32); + if d < 7.0 && ((a * 3.0 + d * 0.9).sin() > 0.3) { + Some(c(200, 90, 230)) + } else { + None + } + }); + cv.img +} + +// ------------------------------------------------------------------ Sprites (10x10) + +const SPR: usize = 10; +const SPR_CELL: u32 = 12; + +fn sprite_rows(k: EK) -> [&'static str; 10] { + match k { + EK::Worker => [ + "..........", + "k..kkkk..k", + "okkooookko", + ".koeooeok.", + "..kooook..", + "...kRRk..S", + "..kRRRRkSS", + "..kRRRRkb.", + "...kRRkb..", + "...k..k...", + ], + EK::Kobold => [ + "........s.", + "..kkkk..s.", + ".kggggk.b.", + ".kgegegkb.", + ".kggggkkb.", + "..kGGkggb.", + ".kgGGGgkb.", + ".kgGGGk.b.", + "..kGkGk...", + "..kk.kk...", + ], + EK::Spider => [ + "..........", + "...kkkk...", + "k.kPPPPk.k", + ".kPPPPPPk.", + "kkPePPePkk", + ".kPPPPPPk.", + "k.kPPPPk.k", + ".k.kkkk.k.", + "k........k", + "..........", + ], + EK::Mage => [ + "....kk....", + "...kPPk...", + "..kPPPPk..", + ".kkkkkkkk.", + "..kfefek.y", + "..kPPPPk.b", + ".kPpPPpPkb", + ".kPPpPPPkb", + ".kPPPPPPk.", + "..kkkkkk..", + ], + EK::Ogre => [ + "..kkkkkk..", + ".kcccccck.", + ".kcecceck.", + ".kcCccCck.", + "kcccccccck", + "kcCcccccCk", + "kccCccCcck", + ".kcccccck.", + ".kcCk.kCk.", + ".kkk...kk.", + ], + EK::Knight => [ + "...kkkk...", + "..kssssk..", + "..kSkkSk.s", + "..kssssk.s", + "..kuuuuk.s", + ".ksuuuusks", + ".kSuyyuSk.", + "..kuuuuk..", + "..kSkkSk..", + "..kk..kk..", + ], + EK::Archer => [ + "...kkkk..b", + "..kGGGGkbk", + "..kGfkfkb.", + "..kGfffkb.", + "..kgggggb.", + ".kfgggggb.", + "..kggggkbk", + "..kBBBBk.b", + "..kBkkBk..", + "..kk..kk..", + ], + EK::Priest => [ + "....yy....", + "...kkkk...", + "..kffffk..", + "..kfkfkk..", + "..kwwwwk..", + ".kwwyywwk.", + ".kwwyywwk.", + ".kwwwwwwk.", + ".kwwwwwwk.", + "..kkkkkk..", + ], + EK::Dwarf => [ + "..........", + "...kkkk...", + "..kSSSSk..", + "..kSSSSkS.", + "..kfkfkkSS", + "..kooook.b", + ".kboooobkb", + ".kbBBBBbk.", + "..kBkkBk..", + "..kk..kk..", + ], + EK::Paladin => [ + "...kkkk..w", + "..kyyyyk.w", + "..kYkkYk.w", + "..kyyyykkw", + ".kwwwwwwkk", + "kywwyywwyk", + ".kwyyyywk.", + "..kwwwwk..", + "..kYkkYk..", + "..kk..kk..", + ], + } +} + +fn pal(ch: char) -> Option { + Some(match ch { + 'k' => c(22, 16, 22), + 'w' => c(240, 240, 236), + 'y' => c(250, 210, 60), + 'Y' => c(190, 140, 30), + 'r' => c(200, 40, 40), + 'R' => c(130, 34, 30), + 'g' => c(96, 176, 64), + 'G' => c(44, 104, 44), + 'b' => c(140, 92, 52), + 'B' => c(92, 58, 32), + 's' => c(206, 210, 220), + 'S' => c(124, 128, 144), + 'p' => c(176, 96, 220), + 'P' => c(92, 40, 124), + 'u' => c(64, 104, 204), + 'U' => c(32, 52, 124), + 'f' => c(232, 184, 144), + 'e' => c(255, 240, 80), + 'c' => c(150, 152, 140), + 'C' => c(96, 98, 92), + 'o' => c(226, 124, 48), + 'O' => c(160, 72, 24), + _ => return None, + }) +} + +pub const SPRITE_KINDS: [EK; 10] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin]; + +pub fn sprite_index(k: EK) -> u32 { + SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as u32 +} + +// Zeile 0: normale Sprites, Zeile 1: weiße Silhouetten (Treffer-Blitz) +fn gen_sprites() -> Image { + let n = SPRITE_KINDS.len() as u32; + let mut img = Image::gen_image_color((n * SPR_CELL) as u16, (2 * SPR_CELL) as u16, Color::new(0.0, 0.0, 0.0, 0.0)); + for (i, &k) in SPRITE_KINDS.iter().enumerate() { + let rows = sprite_rows(k); + for (y, row) in rows.iter().enumerate() { + for (x, ch) in row.chars().enumerate().take(SPR) { + if let Some(col) = pal(ch) { + let px = i as u32 * SPR_CELL + 1 + x as u32; + let py = 1 + y as u32; + img.set_pixel(px, py, col); + img.set_pixel(px, py + SPR_CELL, WHITE); + } + } + } + } + img +} + +impl Gfx { + pub fn new() -> Gfx { + let atlas = Texture2D::from_image(&gen_atlas()); + atlas.set_filter(FilterMode::Nearest); + let sprites = Texture2D::from_image(&gen_sprites()); + sprites.set_filter(FilterMode::Nearest); + // Schrift laden; Hintergrund transparent machen, Glyphen weiß (zum Einfärben) + let mut img = Image::from_file_with_format(include_bytes!("../assets/dbyte_1x.png"), Some(ImageFormat::Png)).expect("Schrift"); + for px in img.get_image_data_mut().iter_mut() { + let on = px[3] > 128 && px[0] > 128; + *px = if on { [255, 255, 255, 255] } else { [0, 0, 0, 0] }; + } + let font = Texture2D::from_image(&img); + font.set_filter(FilterMode::Nearest); + Gfx { atlas, sprites, font } + } + + pub fn cell(id: u32) -> Rect { + Rect::new((id % ATLAS_COLS) as f32 * CELL, (id / ATLAS_COLS) as f32 * CELL, CELL, CELL) + } + + pub fn sprite_rect(k: EK, white: bool) -> Rect { + let i = sprite_index(k); + Rect::new((i * SPR_CELL + 1) as f32, (1 + if white { SPR_CELL } else { 0 }) as f32, SPR as f32, SPR as f32) + } + + pub fn tile_tex(kind: Kind, claimed: bool, hero: bool, var: u8) -> u32 { + let v = var as u32 & 3; + match kind { + Kind::Rock => T_ROCK + v, + Kind::Earth => T_EARTH + v, + Kind::Gold => T_GOLD + v, + Kind::Floor => { + if claimed { + T_CLAIM + v + } else if hero { + T_HEROFLOOR + v + } else { + T_FLOOR + v + } + } + Kind::Treasury => T_TREASURY + v, + Kind::Lair => T_LAIR + v, + Kind::Farm => T_FARM + v, + Kind::Training => T_TRAINING + v, + Kind::Library => T_LIBRARY + v, + Kind::Core => T_COREFLOOR + v, + Kind::Portal => T_PORTALFLOOR + v, + Kind::Gate => T_GATEFLOOR + v, + } + } + + pub fn draw_cell(&self, id: u32, x: f32, y: f32, w: f32, h: f32, color: Color) { + draw_texture_ex( + &self.atlas, + x, + y, + color, + DrawTextureParams { dest_size: Some(vec2(w, h)), source: Some(Self::cell(id)), ..Default::default() }, + ); + } + + // Einzelnes CP437-Zeichen zeichnen (x, y = linke obere Ecke) + pub fn glyph(&self, code: u8, x: f32, y: f32, scale: f32, color: Color) { + let src = Rect::new((code % 16) as f32 * GW, (code / 16) as f32 * GH, GW, GH); + draw_texture_ex( + &self.font, + x.floor(), + y.floor(), + color, + DrawTextureParams { dest_size: Some(vec2(GW * scale, GH * scale)), source: Some(src), ..Default::default() }, + ); + } + + // Text zeichnen (x, y = linke obere Ecke); scale sollte ganzzahlig sein + pub fn text(&self, s: &str, x: f32, y: f32, scale: f32, color: Color) { + let mut cx = x; + for ch in s.chars() { + if ch != ' ' { + self.glyph(cp437(ch), cx, y, scale, color); + } + cx += GW * scale; + } + } + + pub fn measure(&self, s: &str, scale: f32) -> f32 { + s.chars().count() as f32 * GW * scale + } + + // Text mit dunklem Schatten + 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)); + self.text(s, x, y, scale, color); + } + + // Fenster mit CP437-Rahmen; Größe in Zeichen (cols x rows inkl. Rahmen) + pub fn window(&self, x: f32, y: f32, cols: i32, rows: i32, scale: f32, double: bool, frame: Color, bg: Color) { + let cw = GW * scale; + let ch = GH * scale; + draw_rectangle(x, y, cols as f32 * cw, rows as f32 * ch, bg); + let b = if double { BOX_DOUBLE } else { BOX_SINGLE }; + for cx in 0..cols { + for cy in 0..rows { + let top = cy == 0; + let bottom = cy == rows - 1; + let left = cx == 0; + let right = cx == cols - 1; + let code = match (top, bottom, left, right) { + (true, _, true, _) => b[0], + (true, _, _, true) => b[1], + (_, true, true, _) => b[2], + (_, true, _, true) => b[3], + (true, _, _, _) | (_, true, _, _) => b[4], + (_, _, true, _) | (_, _, _, true) => b[5], + _ => continue, + }; + self.glyph(code, x + cx as f32 * cw, y + cy as f32 * ch, scale, frame); + } + } + } + + // Fenster mit Titel in der oberen Rahmenlinie + pub fn window_titled(&self, x: f32, y: f32, cols: i32, rows: i32, scale: f32, double: bool, frame: Color, bg: Color, title: &str, tcol: Color) { + self.window(x, y, cols, rows, scale, double, frame, bg); + if !title.is_empty() { + let cw = GW * scale; + let t = format!(" {} ", title); + let tx = x + 2.0 * cw; + draw_rectangle(tx, y, self.measure(&t, scale), GH * scale, bg); + self.text(&t, tx, y, scale, tcol); + } + } +}