Renderer, Oberfläche, Eingabe, Sound-Modul, Simulationstest und Docker-Setup (Port 18093)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-02 22:16:41 +01:00
1 parent 14e481cffb
commit db417b3af8
13 files changed
+1930 -19

No files matched your search

+80 -4
View File
@@ -380,9 +380,6 @@ impl Game {
pub fn worker_count(&self) -> usize {
self.ents.iter().filter(|e| e.alive && !e.hero && e.kind == EK::Worker).count()
}
pub fn hero_count(&self) -> usize {
self.ents.iter().filter(|e| e.alive && e.hero).count()
}
pub fn summon_cost(&self) -> f32 {
80.0 + 20.0 * (self.worker_count() as f32 - 4.0).max(0.0)
}
@@ -1027,7 +1024,7 @@ impl Game {
let fxx = (e.x + tx as f32 + 0.5) * 0.5;
let fxy = (e.y + ty as f32 + 0.5) * 0.5;
let tl = &mut self.map.t[t];
let mut done = false;
let done;
if tl.kind == Kind::Gold {
let take = tl.gold.min(25);
tl.gold -= take;
@@ -1802,3 +1799,82 @@ pub fn job_name(e: &Ent) -> &'static str {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Simuliert ein Spiel mit einfacher "KI-Spielerin" und prüft, dass nichts abstürzt.
#[test]
fn simulate_long_game() {
for seed in [1u32, 7, 12345] {
let mut g = Game::new(seed);
let (cx, cy) = g.map.core;
let (px, py) = g.map.portal;
// Weg zum Portal graben (durch Erde/Gold, um Fels herum)
let start = idx(cx - 1, cy + 1);
let goal = idx(px + 1, py + 1);
let path = g
.map
.search(start, |i| if g.map.t[i].kind == Kind::Rock { None } else { Some(1) }, |i| i == goal, false)
.expect("Portal erreichbar");
for i in path {
if g.map.t[i].diggable() {
g.map.t[i].marked = true;
}
}
g.mark_rect(cx - 6, cy - 6, cx + 9, cy + 9, true);
let (ox, oy) = (cx - 3, cy - 3);
g.build_rect(1, ox, oy, ox + 2, oy + 1); // Quartier 3x2
g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // Pilzgarten 3x2
let dt = 0.05;
let mut built = false;
let mut portal_logged = false;
for step in 0..(40 * 60 * 20) {
g.update(dt);
g.events.clear();
g.msgs.clear();
if step % 200 == 0 && !built && g.gold > 1900.0 {
g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // Schatzkammer
g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // Training
g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // Quartier erweitern
built = true;
}
// Bot nutzt den Blitz gegen Helden am Kern
if step % 20 == 0 && g.mana >= 50.0 {
let target = g.ents.iter().find(|e| e.alive && e.hero && (e.x - cx as f32).abs() < 4.0 && (e.y - cy as f32).abs() < 4.0).map(|e| (e.x, e.y));
if let Some((x, y)) = target {
g.lightning(x, y);
}
}
if step == 20 * 60 * 20 {
let (gx, gy) = g.map.gate;
g.set_flag(gx + 1, gy + 1);
}
if step % 2400 == 0 {
println!(
"seed {} t={:>5.0}s gold={:>5.0} creatures={} workers={} wave={} core={:.0} gate={:.0} heroes={} army={:.0}",
seed,
g.time,
g.gold,
g.creature_count(),
g.worker_count(),
g.wave,
g.core_hp,
g.gate_hp,
g.ents.iter().filter(|e| e.alive && e.hero).count(),
g.army_value()
);
}
if g.portal_owned() && step % 20 == 0 && !portal_logged {
portal_logged = true;
println!("seed {} Portal erobert nach {:.0}s", seed, g.time);
}
if g.state != 0 {
println!("seed {} Ende nach {:.0}s, state={}", seed, g.time, g.state);
break;
}
}
}
}
}
+275
View File
@@ -0,0 +1,275 @@
// Dungeoncraft – Dungeon-Aufbau & Verteidigung im Browser (Rust + macroquad, WASM).
mod game;
mod gfx;
mod map;
mod render;
mod sound;
mod ui;
use game::Game;
use gfx::Gfx;
use macroquad::prelude::*;
use map::{idx, inb, MH, MW};
use render::{draw_world, Overlay, View, ZOOMS};
use sound::SoundBank;
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
fn conf() -> Conf {
Conf { window_title: "Dungeoncraft".into(), window_width: 1280, window_height: 800, high_dpi: false, ..Default::default() }
}
struct Input {
ldown: Option<(i32, i32)>, // Start des Auswahlrechtecks
rdown: Option<(f32, f32)>, // Startpunkt rechte Maustaste
rlast: (f32, f32),
rdrag: bool,
minimap_drag: bool,
}
fn new_game(seed: u32) -> (Game, View) {
let g = Game::new(seed);
let (cx, cy) = g.map.core;
let zoom = 32.0;
let view = View { cam_x: cx as f32 + 1.5 - screen_width() / zoom * 0.5, cam_y: cy as f32 + 1.5 - screen_height() / zoom * 0.5, zoom };
(g, view)
}
fn clamp_cam(v: &mut View) {
let vw = screen_width() / v.zoom;
let vh = screen_height() / v.zoom;
v.cam_x = v.cam_x.clamp(-vw * 0.5, MW as f32 - vw * 0.5);
v.cam_y = v.cam_y.clamp(-vh * 0.5, MH as f32 - vh * 0.5);
}
fn center_on(v: &mut View, wx: f32, wy: f32) {
v.cam_x = wx - screen_width() / v.zoom * 0.5;
v.cam_y = wy - screen_height() / v.zoom * 0.5;
}
#[macroquad::main(conf)]
async fn main() {
let gfx = Gfx::new();
let mut sounds = SoundBank::new();
sounds.generate_all().await;
let mut seed = (miniquad::date::now() * 1000.0) as u32 | 1;
let (mut game, mut view) = new_game(seed);
let mut ui = Ui::new();
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, minimap_drag: false };
loop {
let dt = get_frame_time();
let now = get_time();
let (mx, my) = mouse_position();
let over_ui = ui.over_ui(mx, my);
let (wx, wy) = view.world(mx, my);
let hover_tile = (wx.floor() as i32, wy.floor() as i32);
// ---------------- Tastatur
for (i, k) in [
KeyCode::Key1,
KeyCode::Key2,
KeyCode::Key3,
KeyCode::Key4,
KeyCode::Key5,
KeyCode::Key6,
KeyCode::Key7,
KeyCode::Key8,
KeyCode::Key9,
KeyCode::Key0,
KeyCode::Q,
KeyCode::E,
]
.iter()
.enumerate()
{
if is_key_pressed(*k) {
ui.tool = TOOLS[i];
inp.ldown = None;
}
}
if is_key_pressed(KeyCode::H) || (is_key_pressed(KeyCode::Escape) && ui.show_help) {
ui.show_help = !ui.show_help;
}
if is_key_pressed(KeyCode::Escape) {
inp.ldown = None;
}
if (is_key_pressed(KeyCode::P) || is_key_pressed(KeyCode::Space)) && game.state == 0 {
game.paused = !game.paused;
}
if is_key_pressed(KeyCode::M) {
sounds.muted = !sounds.muted;
}
if is_key_pressed(KeyCode::Equal) || is_key_pressed(KeyCode::KpAdd) {
sounds.change_volume(0.1);
}
if is_key_pressed(KeyCode::Minus) || is_key_pressed(KeyCode::KpSubtract) {
sounds.change_volume(-0.1);
}
if is_key_pressed(KeyCode::N) && game.state != 0 {
seed = seed.wrapping_mul(1103515245).wrapping_add(12345) | 1;
(game, view) = new_game(seed);
ui.show_help = false;
}
// ---------------- Kamera
let pan = 900.0 * dt / view.zoom;
if is_key_down(KeyCode::W) || is_key_down(KeyCode::Up) {
view.cam_y -= pan;
}
if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) {
view.cam_y += pan;
}
if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) {
view.cam_x -= pan;
}
if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) {
view.cam_x += pan;
}
// Bildschirmrand
let edge = 6.0;
if !ui.show_help && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() {
if mx < edge {
view.cam_x -= pan;
} else if mx > screen_width() - edge {
view.cam_x += pan;
}
if my < edge {
view.cam_y -= pan;
} else if my > screen_height() - edge {
view.cam_y += pan;
}
}
// Zoom
let (_, wheel) = mouse_wheel();
if wheel != 0.0 && !over_ui {
let zi = ZOOMS.iter().position(|&z| z >= view.zoom).unwrap_or(2) as i32;
let ni = (zi + if wheel > 0.0 { 1 } else { -1 }).clamp(0, ZOOMS.len() as i32 - 1) as usize;
if ZOOMS[ni] != view.zoom {
view.zoom = ZOOMS[ni];
view.cam_x = wx - mx / view.zoom;
view.cam_y = wy - my / view.zoom;
}
}
// Rechte Maustaste: ziehen = verschieben, klicken = absetzen
if is_mouse_button_pressed(MouseButton::Right) {
inp.rdown = Some((mx, my));
inp.rlast = (mx, my);
inp.rdrag = false;
}
if let Some((sx, sy)) = inp.rdown {
if is_mouse_button_down(MouseButton::Right) {
if (mx - sx).abs() + (my - sy).abs() > 5.0 {
inp.rdrag = true;
}
if inp.rdrag {
view.cam_x -= (mx - inp.rlast.0) / view.zoom;
view.cam_y -= (my - inp.rlast.1) / view.zoom;
}
inp.rlast = (mx, my);
}
}
if is_mouse_button_released(MouseButton::Right) {
if !inp.rdrag && !over_ui && game.state == 0 {
if inp.ldown.is_some() {
inp.ldown = None;
} else {
game.drop(wx, wy);
}
}
inp.rdown = None;
}
// ---------------- Linke Maustaste
if is_mouse_button_pressed(MouseButton::Left) {
match ui.click(mx, my, &game) {
UiAction::SelectTool(t) => {
ui.tool = t;
game.sfx_ui(game::Sfx::Click);
}
UiAction::MinimapJump(x, y) => {
center_on(&mut view, x, y);
inp.minimap_drag = true;
}
UiAction::CloseHelp => ui.show_help = false,
UiAction::NewGame => {
seed = seed.wrapping_mul(1103515245).wrapping_add(12345) | 1;
(game, view) = new_game(seed);
ui.show_help = false;
}
UiAction::Consumed => {}
UiAction::None => {
if game.state == 0 && inb(hover_tile.0, hover_tile.1) {
match ui.tool {
Tool::Dig | Tool::Room(_) | Tool::Sell => inp.ldown = Some(hover_tile),
Tool::Hand => {
game.pickup(wx, wy);
}
Tool::Flag => game.set_flag(hover_tile.0, hover_tile.1),
Tool::Lightning => game.lightning(wx, wy),
Tool::Heal => game.heal(wx, wy),
Tool::Summon => game.summon(wx, wy),
}
}
}
}
}
if inp.minimap_drag {
if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(mx, my) {
let (x, y) = ui.minimap_world(mx, my);
center_on(&mut view, x, y);
} else if !is_mouse_button_down(MouseButton::Left) {
inp.minimap_drag = false;
}
}
if is_mouse_button_released(MouseButton::Left) {
if let Some((sx, sy)) = inp.ldown.take() {
let (ex, ey) = (hover_tile.0.clamp(0, MW as i32 - 1), hover_tile.1.clamp(0, MH as i32 - 1));
match ui.tool {
Tool::Dig => {
let on = !game.map.t[idx(sx, sy)].marked;
game.mark_rect(sx, sy, ex, ey, on);
}
Tool::Room(r) => game.build_rect(r, sx, sy, ex, ey),
Tool::Sell => game.sell_rect(sx, sy, ex, ey),
_ => {}
}
}
}
clamp_cam(&mut view);
// ---------------- Update
game.update(dt);
for ev in game.events.drain(..) {
sounds.play(ev, &view, now);
}
for m in game.msgs.drain(..) {
ui.log.push((m, now));
}
// ---------------- Zeichnen
clear_background(Color::from_rgba(8, 6, 8, 255));
let (hover_color, radius) = match ui.tool {
Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None),
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None),
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((wx, wy, 1.6))),
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((wx, wy, 2.55))),
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
};
let ov = Overlay {
hover: if over_ui || !inb(hover_tile.0, hover_tile.1) { None } else { Some(hover_tile) },
hover_color,
rect: inp.ldown.map(|(sx, sy)| (sx, sy, hover_tile.0.clamp(0, MW as i32 - 1), hover_tile.1.clamp(0, MH as i32 - 1))),
rect_color: hover_color,
radius: if over_ui { None } else { radius },
};
draw_world(&game, &gfx, &view, now, &ov);
let info = if over_ui { Vec::new() } else { hover_info(&game, wx, wy) };
ui.draw(&game, &gfx, &view, &sounds, &info, now);
next_frame().await
}
}
+2 -9
View File
@@ -59,9 +59,6 @@ impl Tile {
Kind::Floor | Kind::Treasury | Kind::Lair | Kind::Farm | Kind::Training | Kind::Library | Kind::Portal | Kind::Gate
)
}
pub fn is_room(&self) -> bool {
matches!(self.kind, Kind::Treasury | Kind::Lair | Kind::Farm | Kind::Training | Kind::Library)
}
}
pub fn idx(x: i32, y: i32) -> usize {
@@ -148,7 +145,7 @@ impl Map {
let portal = (rng.range(24, 30), if core.1 > 31 { rng.range(12, 18) } else { rng.range(44, 50) });
let gate = (rng.range(51, 55), rng.range(10, 50));
let mut blob = |t: &mut Vec<Tile>, rng: &mut Rng, cx: i32, cy: i32, r: i32, k: Kind| {
let blob = |t: &mut Vec<Tile>, rng: &mut Rng, cx: i32, cy: i32, r: i32, k: Kind| {
for y in cy - r..=cy + r {
for x in cx - r..=cx + r {
if !inb(x, y) || x < 2 || y < 2 || x >= MW as i32 - 2 || y >= MH as i32 - 2 {
@@ -187,7 +184,7 @@ impl Map {
blob(&mut t, &mut rng, cx, cy, 1, Kind::Gold);
}
let mut room = |t: &mut Vec<Tile>, x0: i32, y0: i32, x1: i32, y1: i32, owner: Owner| {
let room = |t: &mut Vec<Tile>, x0: i32, y0: i32, x1: i32, y1: i32, owner: Owner| {
for y in y0..=y1 {
for x in x0..=x1 {
if inb(x, y) && x >= 2 && y >= 2 && x < MW as i32 - 2 && y < MH as i32 - 2 {
@@ -327,8 +324,4 @@ impl Map {
pub fn path_to(&self, start: usize, target: usize) -> Option<Vec<usize>> {
self.search(start, self.walk_cost(), |i| i == target, true)
}
pub fn count(&self, k: Kind) -> usize {
self.t.iter().filter(|t| t.kind == k && t.owner == Owner::Player).count()
}
}
+455
View File
@@ -0,0 +1,455 @@
// Zeichnet die Spielwelt: Kacheln, Wände, Räume, Kern/Portal/Tor, Einheiten, Effekte.
use crate::game::*;
use crate::gfx::*;
use crate::map::*;
use macroquad::prelude::*;
pub const ZOOMS: [f32; 6] = [16.0, 24.0, 32.0, 40.0, 48.0, 64.0];
pub struct View {
pub cam_x: f32, // Weltkoordinate (Kacheln) am linken Bildschirmrand
pub cam_y: f32,
pub zoom: f32, // Pixel pro Kachel
}
impl View {
pub fn sx(&self, wx: f32) -> f32 {
((wx - self.cam_x) * self.zoom).floor()
}
pub fn sy(&self, wy: f32) -> f32 {
((wy - self.cam_y) * self.zoom).floor()
}
pub fn world(&self, sx: f32, sy: f32) -> (f32, f32) {
(sx / self.zoom + self.cam_x, sy / self.zoom + self.cam_y)
}
}
// Overlay-Infos aus der Eingabe (Hover, Auswahlrechteck, Zauberradius)
pub struct Overlay {
pub hover: Option<(i32, i32)>,
pub hover_color: Color,
pub rect: Option<(i32, i32, i32, i32)>,
pub rect_color: Color,
pub radius: Option<(f32, f32, f32)>, // Weltpos + Radius
}
pub fn rgba(u: u32) -> Color {
Color::from_rgba(u as u8, (u >> 8) as u8, (u >> 16) as u8, (u >> 24) as u8)
}
fn with_a(c: Color, a: f32) -> Color {
Color::new(c.r, c.g, c.b, a)
}
fn open(t: Option<&Tile>) -> bool {
match t {
Some(t) => !t.solid() && t.kind != Kind::Core,
None => false,
}
}
fn solid(t: Option<&Tile>) -> bool {
t.map_or(true, |t| t.solid())
}
pub fn draw_world(g: &Game, gfx: &Gfx, v: &View, time: f64, ov: &Overlay) {
let z = v.zoom;
let t = time as f32;
let x0 = (v.cam_x.floor() as i32 - 1).max(0);
let y0 = (v.cam_y.floor() as i32 - 1).max(0);
let x1 = ((v.cam_x + screen_width() / z).ceil() as i32 + 1).min(MW as i32 - 1);
let y1 = ((v.cam_y + screen_height() / z).ceil() as i32 + 1).min(MH as i32 - 1);
let m = &g.map;
let gold_fill = (g.gold / g.gold_cap()).clamp(0.0, 1.0);
// --- Kacheln
for y in y0..=y1 {
for x in x0..=x1 {
let tile = &m.t[idx(x, y)];
let px = v.sx(x as f32);
let py = v.sy(y as f32);
let tex = Gfx::tile_tex(tile.kind, tile.owner == Owner::Player, tile.owner == Owner::Hero, tile.var);
gfx.draw_cell(tex, px, py, z, z, WHITE);
if tile.solid() {
// Wandfront, wenn unten offen
if open(m.get(x, y + 1)) {
let face = match tile.kind {
Kind::Rock => T_FACE_ROCK,
Kind::Gold => T_FACE_GOLD,
_ => T_FACE_EARTH,
};
let fh = (z * 0.3).floor();
gfx.draw_cell(face, px, py + z - fh, z, fh, WHITE);
draw_rectangle(px, py + z - fh, z, (z / 16.0).max(1.0), Color::new(0.0, 0.0, 0.0, 0.35));
}
if open(m.get(x, y - 1)) {
draw_rectangle(px, py, z, (z / 16.0).max(1.0), Color::new(1.0, 1.0, 1.0, 0.14));
}
if open(m.get(x - 1, y)) {
draw_rectangle(px, py, (z / 16.0).max(1.0), z, Color::new(0.0, 0.0, 0.0, 0.3));
}
if open(m.get(x + 1, y)) {
draw_rectangle(px + z - (z / 16.0).max(1.0), py, (z / 16.0).max(1.0), z, Color::new(0.0, 0.0, 0.0, 0.3));
}
if tile.marked {
let a = 0.75 + 0.25 * (t * 4.0).sin();
gfx.draw_cell(T_MARK, px, py, z, z, with_a(WHITE, a));
}
} else {
// Schatten von angrenzenden Wänden
if solid(m.get(x, y - 1)) {
draw_rectangle(px, py, z, z * 0.28, Color::new(0.0, 0.0, 0.0, 0.22));
draw_rectangle(px, py, z, z * 0.12, Color::new(0.0, 0.0, 0.0, 0.25));
}
if solid(m.get(x - 1, y)) {
draw_rectangle(px, py, z * 0.14, z, Color::new(0.0, 0.0, 0.0, 0.22));
}
if solid(m.get(x + 1, y)) {
draw_rectangle(px + z * 0.9, py, z * 0.1, z, Color::new(0.0, 0.0, 0.0, 0.18));
}
// Raumdeko
match tile.kind {
Kind::Treasury if gold_fill > 0.02 => {
let s = z * (0.35 + 0.6 * gold_fill);
gfx.draw_cell(D_COINS, px + (z - s) * 0.5, py + (z - s) * 0.7, s, s, WHITE);
}
Kind::Lair => {
gfx.draw_cell(D_BED, px + z * 0.05, py + z * 0.05, z * 0.9, z * 0.9, WHITE);
}
Kind::Farm => {
let pos = [(0.05, 0.1), (0.45, 0.05), (0.25, 0.45)];
for (k, &(ox, oy)) in pos.iter().enumerate() {
if tile.food >= k as f32 + 1.0 {
gfx.draw_cell(D_MUSHROOM, px + z * ox, py + z * oy, z * 0.55, z * 0.55, WHITE);
}
}
}
Kind::Training if (x + y) % 2 == 0 => {
gfx.draw_cell(D_DUMMY, px + z * 0.1, py, z * 0.8, z * 0.8, WHITE);
}
Kind::Library if y % 2 == 0 => {
gfx.draw_cell(D_SHELF, px, py, z, z, WHITE);
}
_ => {}
}
}
}
}
draw_core(g, v, t);
draw_portal(g, v, t);
draw_gate(g, gfx, v, t);
// --- Angriffsflagge
if let Some(f) = g.flag {
let (fx, fy) = xy(f);
let bx = v.sx(fx as f32 + 0.35);
let by = v.sy(fy as f32 + 0.9);
let top = v.sy(fy as f32 + 0.05);
draw_line(bx, by, bx, top, (z / 12.0).max(2.0), Color::from_rgba(200, 200, 200, 255));
let wave = (t * 6.0).sin() * z * 0.06;
let p1 = vec2(bx, top);
let p2 = vec2(bx + z * 0.55, top + z * 0.15 + wave);
let p3 = vec2(bx, top + z * 0.35);
draw_triangle(p1, p2, p3, Color::from_rgba(210, 40, 40, 255));
draw_triangle_lines(p1, p2, p3, 1.0, Color::from_rgba(90, 10, 10, 255));
}
// --- Einheiten (nach y sortiert)
let mut order: Vec<usize> = (0..g.ents.len())
.filter(|&i| {
let e = &g.ents[i];
e.alive && !e.held && e.x >= x0 as f32 - 1.0 && e.x <= x1 as f32 + 2.0 && e.y >= y0 as f32 - 1.0 && e.y <= y1 as f32 + 2.0
})
.collect();
order.sort_by(|&a, &b| g.ents[a].y.partial_cmp(&g.ents[b].y).unwrap_or(std::cmp::Ordering::Equal));
for i in order {
draw_ent(&g.ents[i], gfx, v, t);
}
// --- Effekte
for f in &g.fx {
draw_fx(f, v, t);
}
// --- Overlay
if let Some((x0, y0, x1, y1)) = ov.rect {
let (ax, bx) = (x0.min(x1), x0.max(x1));
let (ay, by) = (y0.min(y1), y0.max(y1));
let px = v.sx(ax as f32);
let py = v.sy(ay as f32);
let w = (bx - ax + 1) as f32 * z;
let h = (by - ay + 1) as f32 * z;
draw_rectangle(px, py, w, h, with_a(ov.rect_color, 0.18));
draw_rectangle_lines(px, py, w, h, 2.0, ov.rect_color);
} else if let Some((hx, hy)) = ov.hover {
draw_rectangle_lines(v.sx(hx as f32), v.sy(hy as f32), z, z, 2.0, ov.hover_color);
}
if let Some((wx, wy, r)) = ov.radius {
draw_circle_lines(v.sx(wx), v.sy(wy), r * z, 2.0, with_a(ov.hover_color, 0.8));
}
}
fn draw_core(g: &Game, v: &View, t: f32) {
let (cx, cy) = g.map.core;
let x = v.sx(cx as f32 + 1.5);
let y = v.sy(cy as f32 + 1.5);
let z = v.zoom;
let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0);
let pulse = 1.0 + (t * (2.0 + (1.0 - hp) * 6.0)).sin() * 0.05;
let r = z * 1.15 * pulse;
// Sockel
draw_circle(x, y + z * 0.15, z * 1.35, Color::from_rgba(30, 10, 14, 255));
draw_circle_lines(x, y + z * 0.15, z * 1.35, (z / 10.0).max(2.0), Color::from_rgba(90, 40, 40, 255));
for k in 0..6 {
let f = k as f32 / 6.0;
let rr = r * (1.0 - f * 0.8);
let col = Color::new(0.35 + 0.65 * f * hp + 0.2, 0.05 + 0.5 * f * f * hp, 0.08 + 0.1 * f, 0.9);
draw_circle(x, y - f * z * 0.15, rr, col);
}
// Glanzpunkt
draw_circle(x - r * 0.3, y - r * 0.4, r * 0.15, Color::new(1.0, 0.9, 0.8, 0.6));
// Risse bei Schaden
if hp < 0.6 {
let n = ((1.0 - hp) * 8.0) as i32;
for k in 0..n {
let a = k as f32 * 2.39;
let l = r * (0.5 + 0.4 * ((k * 7 % 5) as f32 / 5.0));
draw_line(x, y, x + a.cos() * l, y + a.sin() * l, 2.0, Color::new(0.1, 0.0, 0.0, 0.8));
}
}
}
fn draw_portal(g: &Game, v: &View, t: f32) {
let (px, py) = g.map.portal;
let x = v.sx(px as f32 + 1.5);
let y = v.sy(py as f32 + 1.5);
let z = v.zoom;
let owned = g.portal_owned();
let (base, arm) = if owned {
(Color::from_rgba(40, 10, 60, 255), Color::from_rgba(200, 90, 255, 255))
} else {
(Color::from_rgba(30, 28, 36, 255), Color::from_rgba(110, 100, 120, 255))
};
draw_circle(x, y, z * 1.35, base);
let speed = if owned { 1.6 } else { 0.3 };
for a in 0..3 {
for k in 0..28 {
let f = k as f32 / 28.0;
let ang = t * speed + a as f32 * 2.094 + f * 4.0;
let r = f * z * 1.3;
draw_circle(x + ang.cos() * r, y + ang.sin() * r, z * (0.04 + 0.07 * f), with_a(arm, 0.3 + 0.7 * (1.0 - f)));
}
}
draw_circle_lines(x, y, z * 1.35, (z / 10.0).max(2.0), with_a(arm, 0.7));
}
fn draw_gate(g: &Game, gfx: &Gfx, v: &View, t: f32) {
let (gx, gy) = g.gate_center();
let x = v.sx(gx);
let y = v.sy(gy);
let z = v.zoom;
let hp = (g.gate_hp / GATE_HP).clamp(0.0, 1.0);
if hp <= 0.0 {
for k in 0..12 {
let a = k as f32 * 1.7;
draw_circle(x + a.cos() * z * 0.9, y + a.sin() * z * 0.7, z * 0.2, Color::from_rgba(120, 120, 130, 255));
}
return;
}
draw_circle(x, y, z * 1.4, Color::from_rgba(150, 160, 190, 255));
draw_circle(x, y, z * 1.15, Color::from_rgba(30, 40, 70, 255));
// Lichtstrahlen
for k in 0..10 {
let a = t * 0.5 + k as f32 * 0.628;
let r0 = z * 0.25;
let r1 = z * (0.8 + 0.25 * ((t * 2.0 + k as f32).sin()));
draw_line(x + a.cos() * r0, y + a.sin() * r0, x + a.cos() * r1, y + a.sin() * r1, (z / 12.0).max(2.0), Color::new(0.8, 0.9, 1.0, 0.5 * hp + 0.2));
}
draw_circle(x, y, z * (0.35 + 0.05 * (t * 3.0).sin()), Color::new(0.95, 0.97, 1.0, 0.9));
draw_circle_lines(x, y, z * 1.4, (z / 10.0).max(2.0), Color::from_rgba(230, 210, 120, 255));
// HP-Balken
if hp < 1.0 {
let w = z * 3.0;
let bx = x - w * 0.5;
let by = y - z * 1.9;
draw_rectangle(bx - 1.0, by - 1.0, w + 2.0, 8.0, BLACK);
draw_rectangle(bx, by, w * hp, 6.0, Color::from_rgba(120, 180, 255, 255));
gfx.text_sh("Heldentor", bx, by - 10.0, 1.0, WHITE);
}
}
fn draw_ent(e: &Ent, gfx: &Gfx, v: &View, t: f32) {
let z = v.zoom;
let big = matches!(e.kind, EK::Ogre | EK::Paladin);
let sc = z / 16.0 * if big { 1.25 } else { 1.0 };
let w = 10.0 * sc;
let h = 10.0 * sc;
let cx = v.sx(e.x);
let foot = v.sy(e.y + 0.32);
let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving;
let bob = if e.moving { -((e.anim * 12.0).sin().abs()) * sc * 1.2 } else { 0.0 };
let x = cx - w * 0.5;
let y = foot - h + bob;
// Schatten + Teamring
draw_ellipse(cx, foot, w * 0.42, w * 0.16, 0.0, Color::new(0.0, 0.0, 0.0, 0.35));
let ring = if e.guard {
Color::new(1.0, 0.85, 0.3, 0.8)
} else if e.hero {
Color::new(0.5, 0.8, 1.0, 0.7)
} else {
Color::new(0.9, 0.25, 0.3, 0.6)
};
draw_ellipse_lines(cx, foot, w * 0.45, w * 0.18, 0.0, 1.5, ring);
let rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 };
let tint = if sleeping { Color::new(0.55, 0.55, 0.7, 1.0) } else { WHITE };
let params = |white: bool| DrawTextureParams {
dest_size: Some(vec2(w, h)),
source: Some(Gfx::sprite_rect(e.kind, white)),
flip_x: e.face < 0.0,
rotation: rot,
..Default::default()
};
draw_texture_ex(&gfx.sprites, x, y, tint, params(false));
if e.flash > 0.0 {
draw_texture_ex(&gfx.sprites, x, y, Color::new(1.0, 1.0, 1.0, (e.flash / 0.15).min(1.0)), params(true));
}
if sleeping {
let k = (t * 1.5 + e.anim) % 1.0;
gfx.glyph(b'z', cx + w * 0.3, y - k * z * 0.4, (z / 32.0).max(1.0).round(), Color::new(0.8, 0.85, 1.0, 1.0 - k));
}
if e.carry > 0 {
let r = (sc * 1.5).max(2.0);
draw_circle(cx + w * 0.45, foot - h * 0.35, r, Color::from_rgba(250, 210, 60, 255));
draw_circle_lines(cx + w * 0.45, foot - h * 0.35, r, 1.0, Color::from_rgba(120, 80, 10, 255));
}
// HP-Balken
if e.hp < e.max_hp || e.hero {
let bw = w.max(12.0);
let bx = cx - bw * 0.5;
let by = y - 5.0;
let f = (e.hp / e.max_hp).clamp(0.0, 1.0);
let col = if e.hero {
Color::from_rgba(120, 180, 255, 255)
} else if f > 0.5 {
Color::from_rgba(90, 220, 90, 255)
} else if f > 0.25 {
Color::from_rgba(240, 200, 60, 255)
} else {
Color::from_rgba(230, 60, 50, 255)
};
draw_rectangle(bx - 1.0, by - 1.0, bw + 2.0, 4.0, Color::new(0.0, 0.0, 0.0, 0.8));
draw_rectangle(bx, by, bw * f, 2.0, col);
}
if e.level > 1 && z >= 24.0 {
gfx.text_sh(&e.level.to_string(), cx - w * 0.5 - 7.0, y - 7.0, 1.0, Color::from_rgba(255, 220, 90, 255));
}
}
fn draw_fx(f: &Fx, v: &View, t: f32) {
let z = v.zoom;
let p = (f.t / f.max).clamp(0.0, 1.0);
let col = rgba(f.col);
let x = v.sx(f.x);
let y = v.sy(f.y);
match f.kind {
0 => {
let x2 = v.sx(f.x2);
let y2 = v.sy(f.y2);
let hx = x + (x2 - x) * p;
let hy = y + (y2 - y) * p;
let q = (p - 0.35).max(0.0);
let tx = x + (x2 - x) * q;
let ty = y + (y2 - y) * q;
draw_line(tx, ty, hx, hy, (z / 12.0).max(2.0), with_a(col, 0.6));
draw_circle(hx, hy, (z * 0.1).max(2.0), col);
}
1 => {
let a = 1.0 - p;
let mut px = x;
let mut py = y - z * 7.0;
let steps = 9;
for k in 1..=steps {
let ny = y - z * 7.0 + z * 7.0 * k as f32 / steps as f32;
let jitter = if k == steps { 0.0 } else { ((t * 50.0 + k as f32 * 13.7).sin()) * z * 0.6 };
let nx = x + jitter;
draw_line(px, py, nx, ny, (z / 8.0).max(2.0), Color::new(1.0, 1.0, 0.85, a));
px = nx;
py = ny;
}
draw_circle(x, y, z * 1.3 * (0.5 + p), Color::new(1.0, 1.0, 0.7, 0.35 * a));
}
2 => {
let r = z * (0.1 + 0.3 * p);
for k in 0..4 {
let a = k as f32 * 1.571 + 0.785;
draw_line(x + a.cos() * r * 0.4, y + a.sin() * r * 0.4, x + a.cos() * r, y + a.sin() * r, 2.0, with_a(col, 1.0 - p));
}
}
3 => {
draw_circle(x, y, z * (0.2 + 0.6 * p), with_a(col, 0.45 * (1.0 - p)));
}
4 => {
for k in 0..3 {
let ox = (k as f32 - 1.0) * z * 0.25;
let oy = -p * z * 0.8 - k as f32 * z * 0.1;
let s = (z * 0.08).max(1.5);
let c = with_a(col, 1.0 - p);
draw_rectangle(x + ox - s * 1.5, y + oy - s * 0.5, s * 3.0, s, c);
draw_rectangle(x + ox - s * 0.5, y + oy - s * 1.5, s, s * 3.0, c);
}
}
5 => {
for k in 0..4 {
let a = k as f32 * 1.9 + f.x * 3.0;
let ox = a.cos() * z * 0.3 * p;
let oy = -p * z * 0.7 + a.sin() * z * 0.1;
let s = (z * 0.09).max(2.0);
draw_rectangle(x + ox, y + oy, s, s, with_a(col, 1.0 - p));
}
}
6 => {
for k in 0..4 {
let a = k as f32 * 1.7 + f.y * 5.0;
let ox = a.cos() * z * 0.35 * p;
let oy = a.sin().abs() * z * 0.2 * p + p * z * 0.2;
let s = (z * 0.08).max(2.0);
draw_rectangle(x + ox, y + oy, s, s, with_a(col, 1.0 - p));
}
}
7 => {
draw_circle_lines(x, y, z * (0.3 + 1.2 * p), 2.0, with_a(col, 1.0 - p));
}
8 => {
draw_rectangle(x - z * 0.5, y - z * 0.5, z, z, with_a(col, 0.45 * (1.0 - p)));
}
_ => {}
}
}
// Minikarten-Farbe einer Kachel
pub fn minimap_color(tile: &Tile) -> [u8; 4] {
match tile.kind {
Kind::Rock => [40, 40, 48, 255],
Kind::Earth => [86, 60, 40, 255],
Kind::Gold => [220, 180, 50, 255],
Kind::Floor => match tile.owner {
Owner::Player => [140, 60, 80, 255],
Owner::Hero => [170, 175, 190, 255],
Owner::None => [70, 64, 60, 255],
},
Kind::Treasury => [200, 160, 60, 255],
Kind::Lair => [150, 110, 60, 255],
Kind::Farm => [70, 140, 60, 255],
Kind::Training => [160, 100, 60, 255],
Kind::Library => [150, 40, 60, 255],
Kind::Core => [255, 60, 60, 255],
Kind::Portal => [190, 90, 240, 255],
Kind::Gate => [230, 230, 255, 255],
}
}
+98
View File
@@ -0,0 +1,98 @@
// Sound-Wiedergabe. Die Spiellogik meldet Ereignisse (Sfx), hier werden sie abgespielt.
// Klänge werden später generiert und per `SoundBank::set` (WAV-Bytes) registriert.
// Ohne registrierten Klang bleibt ein Ereignis stumm.
use crate::game::{Sfx, SfxEvent, SFX_ALL};
use crate::render::View;
use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound};
use macroquad::prelude::*;
pub struct SoundBank {
sounds: Vec<Option<Sound>>,
last: Vec<f64>,
pub volume: f32,
pub muted: bool,
}
impl SoundBank {
pub fn new() -> SoundBank {
SoundBank { sounds: vec![None; SFX_ALL.len()], last: vec![-1.0; SFX_ALL.len()], volume: 0.7, muted: false }
}
// Klang für ein Ereignis registrieren (WAV- oder OGG-Bytes)
#[allow(dead_code)]
pub async fn set(&mut self, sfx: Sfx, bytes: &[u8]) -> bool {
match load_sound_from_bytes(bytes).await {
Ok(s) => {
self.sounds[sfx.index()] = Some(s);
true
}
Err(_) => false,
}
}
// Hier werden später die prozedural erzeugten Klänge registriert.
pub async fn generate_all(&mut self) {}
pub fn change_volume(&mut self, d: f32) {
self.volume = (self.volume + d).clamp(0.0, 1.0);
}
// Mindestabstand zwischen zwei gleichen Klängen (gegen Klangbrei)
fn min_gap(sfx: Sfx) -> f64 {
match sfx {
Sfx::Dig | Sfx::GoldDig | Sfx::MeleeHit | Sfx::Shoot | Sfx::GateHit | Sfx::CoreHit => 0.08,
Sfx::Claim | Sfx::Deposit | Sfx::DigDone => 0.12,
_ => 0.03,
}
}
pub fn play(&mut self, ev: SfxEvent, view: &View, now: f64) {
if self.muted || self.volume <= 0.0 {
return;
}
let i = ev.sfx.index();
let Some(sound) = &self.sounds[i] else { return };
if now - self.last[i] < Self::min_gap(ev.sfx) {
return;
}
// Lautstärke nach Entfernung zur Bildschirmmitte
let mut vol = self.volume;
if let Some((wx, wy)) = ev.pos {
let (cx, cy) = view.world(screen_width() * 0.5, screen_height() * 0.5);
let d = ((wx - cx).powi(2) + (wy - cy).powi(2)).sqrt();
let hear = screen_width().max(screen_height()) / view.zoom * 0.6;
let f = 1.0 - ((d - hear * 0.5) / hear).clamp(0.0, 1.0);
if f <= 0.05 {
return;
}
vol *= f;
}
self.last[i] = now;
play_sound(sound, PlaySoundParams { looped: false, volume: vol });
}
}
// Hilfsfunktion für die spätere Generierung: Samples (-1..1, mono) -> WAV (16 bit PCM)
#[allow(dead_code)]
pub fn wav_from_samples(samples: &[f32], rate: u32) -> Vec<u8> {
let data_len = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_len as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_len).to_le_bytes());
out.extend_from_slice(b"WAVEfmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
out.extend_from_slice(&1u16.to_le_bytes()); // mono
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&(rate * 2).to_le_bytes());
out.extend_from_slice(&2u16.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&data_len.to_le_bytes());
for &s in samples {
let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
out.extend_from_slice(&v.to_le_bytes());
}
out
}
+553
View File
@@ -0,0 +1,553 @@
// Benutzeroberfläche im Canvas: CP437-Fenster mit DINOBYTE-Schrift.
use crate::game::*;
use crate::gfx::*;
use crate::map::*;
use crate::render::{minimap_color, View};
use crate::sound::SoundBank;
use macroquad::prelude::*;
pub const S: f32 = 2.0; // UI-Skalierung der Schrift
pub const CW: f32 = GW * S; // Zeichenbreite in px
pub const CH: f32 = GH * S; // Zeichenhöhe in px
pub const FRAME: Color = Color::new(0.62, 0.52, 0.36, 1.0);
pub const FRAME_HI: Color = Color::new(0.95, 0.8, 0.4, 1.0);
pub const BG: Color = Color::new(0.06, 0.04, 0.05, 0.92);
pub const TXT: Color = Color::new(0.9, 0.86, 0.78, 1.0);
pub const DIM: Color = Color::new(0.6, 0.56, 0.5, 1.0);
pub const GOLD: Color = Color::new(1.0, 0.84, 0.25, 1.0);
pub const MANA: Color = Color::new(0.7, 0.45, 1.0, 1.0);
pub const RED: Color = Color::new(0.95, 0.3, 0.3, 1.0);
pub const GREEN: Color = Color::new(0.45, 0.9, 0.45, 1.0);
pub const BLUE: Color = Color::new(0.5, 0.75, 1.0, 1.0);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tool {
Dig,
Hand,
Room(usize),
Sell,
Flag,
Lightning,
Heal,
Summon,
}
pub const TOOLS: [Tool; 12] = [
Tool::Dig,
Tool::Hand,
Tool::Room(0),
Tool::Room(1),
Tool::Room(2),
Tool::Room(3),
Tool::Room(4),
Tool::Sell,
Tool::Flag,
Tool::Lightning,
Tool::Heal,
Tool::Summon,
];
pub const TOOL_KEYS: [&str; 12] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "Q", "E"];
pub struct ToolInfo {
pub label: &'static str,
pub name: &'static str,
pub desc: &'static str,
pub icon: u32,
}
pub fn tool_info(t: Tool) -> ToolInfo {
let i = |label, name, desc, icon| ToolInfo { label, name, desc, icon };
match t {
Tool::Dig => i("Graben", "Graben", "Erde und Gold markieren. Ziehen für Rechteck; auf markiertem Feld beginnen hebt Markierung auf.", I_PICK),
Tool::Hand => i("Hand", "Hand", "Eigene Einheit aufnehmen (max. 8). Rechtsklick setzt sie auf eigenem Gebiet ab.", I_HAND),
Tool::Room(0) => i("Schatz", "Schatzkammer", "Lagert Gold: +500 Kapazität pro Feld.", T_TREASURY),
Tool::Room(1) => i("Quartier", "Quartier", "Ein Schlafplatz pro Feld. Bestimmt, wie viele Kreaturen kommen.", T_LAIR),
Tool::Room(2) => i("Pilze", "Pilzgarten", "Nahrung für Kreaturen. Ab 6 Feldern kommen Höhlenspinnen.", T_FARM),
Tool::Room(3) => i("Training", "Trainingshalle", "Kreaturen steigen Stufen auf (kostet Gold). Ab 9 Feldern kommen Steinoger.", T_TRAINING),
Tool::Room(4) => i("Bücher", "Bibliothek", "Kreaturen erzeugen Mana. Ab 6 Feldern kommen Schattenmagier.", T_LIBRARY),
Tool::Sell => i("Verkauf", "Verkaufen", "Räume zurück in Boden verwandeln (50 % Erstattung).", I_SELL),
Tool::Flag => i("Flagge", "Angriffsflagge", "Kampfbereite Kreaturen sammeln sich hier. Nahe am Heldentor greifen sie es an. Erneut klicken entfernt sie.", I_FLAG),
Tool::Lightning => i("Blitz", "Blitz", "55 Schaden an Helden im Umkreis.", I_BOLT),
Tool::Heal => i("Heilung", "Heilung", "Heilt eigene Einheiten im Umkreis um 50 %.", I_HEAL),
Tool::Summon => i("Wicht", "Grubenwicht beschwören", "Erschafft einen neuen Grubenwicht auf eigenem Gebiet.", I_SUMMON),
Tool::Room(_) => i("?", "?", "", 0),
}
}
pub fn tool_cost(t: Tool, g: &Game) -> String {
match t {
Tool::Room(r) => format!("{} Gold/Feld", ROOM_COST[r]),
Tool::Lightning => "50 Mana".into(),
Tool::Heal => "60 Mana".into(),
Tool::Summon => format!("{} Mana", g.summon_cost() as i32),
_ => String::new(),
}
}
pub enum UiAction {
None,
SelectTool(Tool),
MinimapJump(f32, f32),
CloseHelp,
NewGame,
Consumed,
}
pub struct Ui {
pub tool: Tool,
pub show_help: bool,
pub log: Vec<(String, f64)>,
pub rects: Vec<Rect>,
minimap_img: Image,
minimap_tex: Texture2D,
minimap_t: f64,
minimap_rect: Rect,
tool_rects: Vec<Rect>,
end_button: Rect,
}
fn wrap(s: &str, cols: usize) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in s.split(' ') {
let len = line.chars().count();
if len > 0 && len + 1 + word.chars().count() > cols {
out.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
out.push(line);
}
out
}
fn bar(x: f32, y: f32, w: f32, h: f32, frac: f32, col: Color) {
draw_rectangle(x - 1.0, y - 1.0, w + 2.0, h + 2.0, Color::new(0.0, 0.0, 0.0, 1.0));
draw_rectangle(x, y, w, h, Color::new(col.r * 0.25, col.g * 0.25, col.b * 0.25, 1.0));
draw_rectangle(x, y, w * frac.clamp(0.0, 1.0), h, col);
draw_rectangle(x, y, w * frac.clamp(0.0, 1.0), (h * 0.3).max(1.0), Color::new(1.0, 1.0, 1.0, 0.25));
}
fn mmss(t: f32) -> String {
let t = t.max(0.0) as i32;
format!("{}:{:02}", t / 60, t % 60)
}
impl Ui {
pub fn new() -> Ui {
let img = Image::gen_image_color(MW as u16, MH as u16, BLACK);
let tex = Texture2D::from_image(&img);
tex.set_filter(FilterMode::Nearest);
Ui {
tool: Tool::Dig,
show_help: true,
log: Vec::new(),
rects: Vec::new(),
minimap_img: img,
minimap_tex: tex,
minimap_t: -1.0,
minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0),
tool_rects: Vec::new(),
end_button: Rect::new(0.0, 0.0, 0.0, 0.0),
}
}
pub fn over_ui(&self, x: f32, y: f32) -> bool {
self.show_help || self.rects.iter().any(|r| r.contains(vec2(x, y)))
}
pub fn minimap_contains(&self, x: f32, y: f32) -> bool {
self.minimap_rect.contains(vec2(x, y))
}
pub fn minimap_world(&self, x: f32, y: f32) -> (f32, f32) {
let r = self.minimap_rect;
((x - r.x) / r.w * MW as f32, (y - r.y) / r.h * MH as f32)
}
pub fn click(&mut self, x: f32, y: f32, g: &Game) -> UiAction {
let p = vec2(x, y);
if g.state != 0 {
if self.end_button.contains(p) {
return UiAction::NewGame;
}
return UiAction::Consumed;
}
if self.show_help {
return UiAction::CloseHelp;
}
for (i, r) in self.tool_rects.iter().enumerate() {
if r.contains(p) {
return UiAction::SelectTool(TOOLS[i]);
}
}
if self.minimap_rect.contains(p) {
let (wx, wy) = self.minimap_world(x, y);
return UiAction::MinimapJump(wx, wy);
}
if self.over_ui(x, y) {
return UiAction::Consumed;
}
UiAction::None
}
pub fn draw(&mut self, g: &Game, gfx: &Gfx, view: &View, snd: &SoundBank, info: &[(String, Color)], now: f64) {
self.rects.clear();
let sw = screen_width();
let sh = screen_height();
let (mx, my) = mouse_position();
// ---------------- obere Leiste
let cols = (sw / CW).ceil() as i32;
gfx.window(0.0, 0.0, cols, 3, S, false, FRAME, BG);
self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH));
let y = CH;
let mut x = CW * 2.0;
let seg = |label: &str, lc: Color, value: &str, vc: Color, x: &mut f32| {
gfx.text(label, *x, y, S, lc);
*x += gfx.measure(label, S) + CW;
gfx.text(value, *x, y, S, vc);
*x += gfx.measure(value, S) + CW * 2.0;
};
seg("Gold", GOLD, &format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut x);
gfx.text("Mana", x, y, S, MANA);
x += gfx.measure("Mana", S) + CW * 0.5;
bar(x, y + 3.0, 70.0, 10.0, g.mana / MANA_MAX, MANA);
x += 70.0 + CW * 0.5;
gfx.text(&format!("{}", g.mana as i32), x, y, S, TXT);
x += CW * 5.0;
gfx.text("Kern", x, y, S, RED);
x += gfx.measure("Kern", S) + CW * 0.5;
bar(x, y + 3.0, 70.0, 10.0, g.core_hp / CORE_HP, RED);
x += 70.0 + CW * 1.5;
gfx.text("Tor", x, y, S, BLUE);
x += gfx.measure("Tor", S) + CW * 0.5;
bar(x, y + 3.0, 70.0, 10.0, g.gate_hp / GATE_HP, BLUE);
x += 70.0 + CW * 1.5;
let cap = g.counts[1].min(30);
seg("Kreaturen", TXT, &format!("{}/{}", g.creature_count(), cap), TXT, &mut x);
seg("Wichte", TXT, &format!("{}", g.worker_count()), TXT, &mut x);
let wave_col = if g.wave_timer < 30.0 { RED } else { TXT };
seg(&format!("Welle {}", g.wave + 1), wave_col, &mmss(g.wave_timer), wave_col, &mut x);
seg("Armee", TXT, &format!("{}", g.army_value() as i32), TXT, &mut x);
let vol = if snd.muted { "aus".to_string() } else { format!("{}%", (snd.volume * 100.0).round() as i32) };
seg("♪", DIM, &vol, DIM, &mut x);
// ---------------- Werkzeugleiste
let tb_rows = 5;
let tb_y = sh - tb_rows as f32 * CH;
gfx.window(0.0, tb_y, cols, tb_rows, S, false, FRAME, BG);
self.rects.push(Rect::new(0.0, tb_y, sw, tb_rows as f32 * CH));
self.tool_rects.clear();
let bw = 80.0;
let mut hover_tool = None;
for (i, &t) in TOOLS.iter().enumerate() {
let bx = CW + i as f32 * (bw + 4.0);
let by = tb_y + CH * 0.75;
let r = Rect::new(bx, by, bw, CH * 3.5);
self.tool_rects.push(r);
let hovered = r.contains(vec2(mx, my));
if hovered {
hover_tool = Some(i);
}
let sel = self.tool == t;
draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.35, 0.22, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if sel { FRAME_HI } else { Color::new(0.35, 0.28, 0.2, 1.0) });
let ti = tool_info(t);
gfx.draw_cell(ti.icon, bx + (bw - 32.0) * 0.5, by + 4.0, 32.0, 32.0, WHITE);
gfx.text(TOOL_KEYS[i], bx + 3.0, by + 3.0, 1.0, if sel { FRAME_HI } else { DIM });
let lw = gfx.measure(ti.label, 1.0);
gfx.text(ti.label, bx + (bw - lw) * 0.5, by + 42.0, 1.0, if sel { FRAME_HI } else { TXT });
}
// Hand
let hx = CW + TOOLS.len() as f32 * (bw + 4.0) + CW;
if hx + 120.0 < sw - 200.0 {
gfx.text("Hand", hx, tb_y + CH, S, TXT);
for (k, id) in g.hand.iter().enumerate() {
if let Some(e) = g.ents.iter().find(|e| e.id == *id) {
let sx = hx + (k % 4) as f32 * 26.0;
let sy = tb_y + CH * 2.2 + (k / 4) as f32 * 24.0;
draw_texture_ex(
&gfx.sprites,
sx,
sy,
WHITE,
DrawTextureParams { dest_size: Some(vec2(20.0, 20.0)), source: Some(Gfx::sprite_rect(e.kind, false)), ..Default::default() },
);
}
}
if g.hand.is_empty() {
gfx.text("leer", hx, tb_y + CH * 2.5, 1.0, DIM);
}
}
// ---------------- Minikarte
let mm_cols = 16;
let mm_rows = 12;
let mm_w = mm_cols as f32 * CW;
let mm_h = mm_rows as f32 * CH;
let mm_x = sw - mm_w;
let mm_y = tb_y - mm_h;
gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S, false, FRAME, BG, "Karte", FRAME_HI);
self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h));
if now - self.minimap_t > 0.25 {
self.minimap_t = now;
for (i, t) in g.map.t.iter().enumerate() {
let (x, y) = xy(i);
let mut c = minimap_color(t);
if t.marked {
c = [255, 230, 90, 255];
}
self.minimap_img.set_pixel(x as u32, y as u32, Color::from_rgba(c[0], c[1], c[2], c[3]));
}
self.minimap_tex.update(&self.minimap_img);
}
let map_px = (mm_w - CW * 2.0).min(mm_h - CH * 2.0);
let mrx = mm_x + (mm_w - map_px) * 0.5;
let mry = mm_y + CH + (mm_h - CH * 2.0 - map_px) * 0.5;
self.minimap_rect = Rect::new(mrx, mry, map_px, map_px);
draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_px, map_px)), ..Default::default() });
let ms = map_px / MW as f32;
for e in &g.ents {
if e.alive && !e.held {
let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) };
draw_rectangle(mrx + e.x * ms - 1.0, mry + e.y * ms - 1.0, 3.0, 3.0, c);
}
}
if let Some(f) = g.flag {
let (fx, fy) = xy(f);
draw_rectangle(mrx + fx as f32 * ms - 2.0, mry + fy as f32 * ms - 2.0, 5.0, 5.0, RED);
}
// Kamerarahmen
let vw = sw / view.zoom * ms;
let vh = sh / view.zoom * ms;
draw_rectangle_lines(mrx + view.cam_x * ms, mry + view.cam_y * ms, vw, vh, 1.5, WHITE);
// ---------------- Infofeld
if !info.is_empty() {
let icols = 30;
let mut lines: Vec<(String, Color)> = Vec::new();
for (s, c) in info.iter().skip(1) {
for l in wrap(s, icols as usize - 4) {
lines.push((l, *c));
}
}
let irows = lines.len() as i32 + 2;
let ix = sw - icols as f32 * CW;
let iy = 3.0 * CH;
gfx.window_titled(ix, iy, icols, irows, S, false, FRAME, BG, &info[0].0, info[0].1);
for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, ix + CW * 2.0, iy + CH * (k as f32 + 1.0), S, *c);
}
}
// ---------------- Meldungen
let recent: Vec<&(String, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect();
if !recent.is_empty() {
let mcols = (((sw - mm_w) / CW) as i32 - 2).clamp(20, 70);
let mut lines: Vec<(String, f32)> = Vec::new();
for (s, t) in recent.iter().rev().take(5).rev() {
let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0);
for l in wrap(s, mcols as usize - 4) {
lines.push((l, a));
}
}
let mrows = lines.len() as i32 + 2;
let my0 = tb_y - mrows as f32 * CH;
gfx.window(0.0, my0, mcols, mrows, S, false, FRAME, Color::new(0.06, 0.04, 0.05, 0.75));
self.rects.push(Rect::new(0.0, my0, mcols as f32 * CW, mrows as f32 * CH));
for (k, (l, a)) in lines.iter().enumerate() {
gfx.text(l, CW * 2.0, my0 + CH * (k as f32 + 1.0), S, Color::new(TXT.r, TXT.g, TXT.b, *a));
}
}
if self.log.len() > 100 {
self.log.drain(0..50);
}
// ---------------- Tooltip
if let Some(i) = hover_tool {
let t = TOOLS[i];
let ti = tool_info(t);
let tcols = 34;
let mut lines: Vec<(String, Color)> = wrap(ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect();
let cost = tool_cost(t, g);
if !cost.is_empty() {
lines.push((format!("Kosten: {}", cost), GOLD));
}
lines.push((format!("Taste: {}", TOOL_KEYS[i]), DIM));
let rows = lines.len() as i32 + 2;
let tx = (mx - tcols as f32 * CW * 0.5).clamp(0.0, sw - tcols as f32 * CW);
let ty = tb_y - rows as f32 * CH - 4.0;
gfx.window_titled(tx, ty, tcols as i32, rows, S, false, FRAME_HI, BG, ti.name, FRAME_HI);
for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, tx + CW * 2.0, ty + CH * (k as f32 + 1.0), S, *c);
}
}
// ---------------- Dialoge
if g.state != 0 {
self.draw_end(g, gfx);
} else if self.show_help {
self.draw_help(gfx);
} else if g.paused {
let w = 24;
let x = (sw - w as f32 * CW) * 0.5;
let y = sh * 0.35;
gfx.window(x, y, w, 5, S, true, FRAME_HI, BG);
let t = "PAUSE";
gfx.text(t, (sw - gfx.measure(t, 3.0)) * 0.5, y + CH, 3.0, FRAME_HI);
let t2 = "P: weiter";
gfx.text(t2, (sw - gfx.measure(t2, S)) * 0.5, y + CH * 3.0, S, DIM);
}
}
fn draw_help(&mut self, gfx: &Gfx) {
let lines: &[(&str, Color)] = &[
("Ziel: Zerstöre das Heldentor und schütze deinen Seelenkern.", GOLD),
("", TXT),
("Linksklick Werkzeug benutzen (Graben/Räume: Rechteck ziehen)", TXT),
("Rechtsklick Einheit aus der Hand absetzen", TXT),
("Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand)", TXT),
("Mausrad Zoomen", TXT),
("1-9, 0, Q, E Werkzeug wählen", TXT),
("P / Leertaste Pause M Ton an/aus +/- Lautstärke", TXT),
("H Diese Hilfe", TXT),
("", TXT),
("1. Grabe zum Portal und lass es von Grubenwichten erobern.", TXT),
("2. Baue Quartier, Pilzgarten und Schatzkammer.", TXT),
("3. Trainiere deine Kreaturen, zahle ihren Lohn pünktlich.", TXT),
("4. Wehre die Heldenwellen ab. Sie passen sich deiner Armee an.", TXT),
("5. Setze die Angriffsflagge nahe ans Tor und greife an!", TXT),
("", TXT),
("Schrift: DINOBYTE von mby (CC BY 4.0)", DIM),
("", TXT),
("Klick zum Starten", FRAME_HI),
];
let cols = 74;
let rows = lines.len() as i32 + 4;
let sw = screen_width();
let sh = screen_height();
let x = ((sw - cols as f32 * CW) * 0.5).max(0.0);
let y = ((sh - rows as f32 * CH) * 0.5).max(0.0);
gfx.window_titled(x, y, cols, rows, S, true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), "DUNGEONCRAFT", FRAME_HI);
for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, x + CW * 3.0, y + CH * (k as f32 + 2.0), S, *c);
}
self.rects.push(Rect::new(0.0, 0.0, sw, sh));
}
fn draw_end(&mut self, g: &Game, gfx: &Gfx) {
let sw = screen_width();
let sh = screen_height();
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.5));
let win = g.state == 1;
let cols = 44;
let rows = 11;
let x = (sw - cols as f32 * CW) * 0.5;
let y = (sh - rows as f32 * CH) * 0.5;
let col = if win { GOLD } else { RED };
gfx.window(x, y, cols, rows, S, true, col, Color::new(0.05, 0.03, 0.04, 0.97));
let title = if win { "SIEG!" } else { "NIEDERLAGE" };
gfx.text(title, (sw - gfx.measure(title, 4.0)) * 0.5, y + CH * 1.5, 4.0, col);
let sub = if win { "Das Heldentor ist gefallen." } else { "Dein Seelenkern ist zerbrochen." };
gfx.text(sub, (sw - gfx.measure(sub, S)) * 0.5, y + CH * 4.5, S, TXT);
let st = format!("Spielzeit {} Wellen {} Armee {}", mmss(g.time), g.wave, g.army_value() as i32);
gfx.text(&st, (sw - gfx.measure(&st, S)) * 0.5, y + CH * 6.0, S, DIM);
let b = " Neues Spiel (N) ";
let bw = gfx.measure(b, S) + CW * 2.0;
let bx = (sw - bw) * 0.5;
let by = y + CH * 7.75;
self.end_button = Rect::new(bx, by, bw, CH * 2.0);
let (mx, my) = mouse_position();
let hov = self.end_button.contains(vec2(mx, my));
draw_rectangle(bx, by, bw, CH * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(bx, by, bw, CH * 2.0, 2.0, col);
gfx.text(b, bx + CW, by + CH * 0.5, S, TXT);
self.rects.push(Rect::new(0.0, 0.0, sw, sh));
}
}
// Infotext für Einheit oder Kachel unter dem Mauszeiger (erste Zeile = Titel)
pub fn hover_info(g: &Game, wx: f32, wy: f32) -> Vec<(String, Color)> {
let mut out = Vec::new();
if let Some(j) = g.ent_at(wx, wy, false) {
let e = &g.ents[j];
let s = st(e.kind);
out.push((format!("{} (Stufe {})", s.name, e.level), if e.hero { BLUE } else { FRAME_HI }));
out.push((format!("HP {}/{}", e.hp.max(0.0) as i32, e.max_hp as i32), GREEN));
out.push((job_name(e).to_string(), TXT));
if e.hero {
out.push((if e.guard { "Torwächter".into() } else { "Eindringling".into() }, BLUE));
} else if e.creature() {
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() as i32;
out.push((format!("Energie {}% Hunger {}%", pct(e.energy), pct(e.hunger / 2.0)), TXT));
let mood = if e.happy > 0.6 {
("zufrieden", GREEN)
} else if e.happy > 0.25 {
("mürrisch", GOLD)
} else {
("wütend", RED)
};
out.push((format!("Laune: {}", mood.0), mood.1));
out.push((format!("Lohn: {} Gold", (s.wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)) as i32), GOLD));
} else if e.carry > 0 {
out.push((format!("Trägt {} Gold", e.carry), GOLD));
}
return out;
}
let (x, y) = (wx.floor() as i32, wy.floor() as i32);
let Some(t) = g.map.get(x, y) else { return out };
let owner = match t.owner {
Owner::Player => "erobert",
Owner::Hero => "Heldengebiet",
Owner::None => "neutral",
};
match t.kind {
Kind::Rock => out.push(("Fels".into(), DIM)),
Kind::Earth => out.push(("Erde".into(), FRAME_HI)),
Kind::Gold => {
out.push(("Goldader".into(), GOLD));
out.push((format!("{} Gold", t.gold), GOLD));
}
Kind::Floor => {
out.push(("Boden".into(), TXT));
out.push((owner.into(), DIM));
}
Kind::Core => {
out.push(("Seelenkern".into(), RED));
out.push((format!("HP {}/{}", g.core_hp as i32, CORE_HP as i32), RED));
}
Kind::Portal => {
out.push(("Portal".into(), MANA));
out.push((if g.portal_owned() { "erobert".into() } else { "neutral: Wichte können es erobern".into() }, DIM));
}
Kind::Gate => {
out.push(("Heldentor".into(), BLUE));
out.push((format!("HP {}/{}", g.gate_hp as i32, GATE_HP as i32), BLUE));
}
k => {
if let Some(r) = room_index(k) {
out.push((ROOM_NAMES[r].into(), FRAME_HI));
match k {
Kind::Farm => out.push((format!("{} Pilze", t.food as i32), GREEN)),
Kind::Lair => out.push((if t.bed != 0 { "Schlafplatz belegt".into() } else { "Schlafplatz frei".into() }, DIM)),
_ => {}
}
}
}
}
if t.kind == Kind::Rock {
out.push(("unzerstörbar".into(), DIM));
}
if t.marked {
out.push(("zum Graben markiert".into(), GOLD));
}
out
}