diff --git a/DESIGN.md b/DESIGN.md index 264f0f7..a3a25df 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,6 +30,8 @@ Eigenständiges Dungeon-Management-Spiel (Genre: Dungeon-Aufbau & Verteidigung), - Repo komplett auf Englisch; Spieltexte in separaten Sprachdateien (EN + DE, erweiterbar), Sprache im Spiel umschaltbar - Menü: Speichern (Download *.dcsave), Laden (Dateiauswahl + Drag & Drop), Autosave im Browser (localStorage) - Einstellungen: Sprache + Ton, Kamera, Grafik, Spiel, Tastenbelegung – gespeichert im Browser **und** im Savegame +- Musik: eigener **XM/MOD-Replayer in Rust**, gemeinsamer Mixer für Musik + Effekte (Streaming über JS-Audio-Plugin, ersetzt macroquad-Audio) +- Soundtrack (als .xm komponiert, in MilkyTracker bearbeitbar): Titel/Menü, mehrere ruhige Aufbau-Tracks, Kampf, Sieg/Niederlage-Jingles; **Titelmelodie als Leitmotiv in allen Stücken erkennbar** ## Spielinhalt (volles Konzept) - **Graben**: Erde/Gold markieren → Grubenwichte graben, bringen Gold heim, erobern Boden diff --git a/src/render3d.rs b/src/render3d.rs new file mode 100644 index 0000000..e2edafe --- /dev/null +++ b/src/render3d.rs @@ -0,0 +1,1082 @@ +// 3D world renderer: isometric (orthographic) camera with free rotation and tilt, +// chunked tile meshes with irregular (noise-displaced) walls, a lit material with +// flickering point lights (torches, core, portal, gate), billboard sprites and 3D effects. + +use crate::game::*; +use crate::gfx::*; +use crate::map::*; +use macroquad::miniquad::{BlendFactor, BlendState, BlendValue, Comparison, Equation, PipelineParams, UniformDesc, UniformType}; +use macroquad::models::{Mesh, Vertex}; +use macroquad::prelude::*; + +pub const WALL_H: f32 = 0.95; +const CHUNK: i32 = 16; +const NCH: i32 = MW as i32 / CHUNK; +pub const MAX_LIGHTS: usize = 24; +const ATLAS_W: f32 = 256.0; +const ATLAS_H: f32 = 80.0; +const WALL_SUB: usize = 3; // subdivisions per wall face edge +const DISP_AMP: f32 = 0.11; // wall surface irregularity + +// ------------------------------------------------------------------ camera + +pub struct Cam { + pub tx: f32, // look-at target on the ground (world x) + pub tz: f32, // look-at target on the ground (world z = map y) + pub yaw: f32, + pub pitch: f32, + pub zoom: f32, // visible world height in tiles (orthographic) +} + +impl Cam { + pub fn new(tx: f32, tz: f32) -> Cam { + Cam { tx, tz, yaw: 0.785, pitch: 0.6155, zoom: 18.0 } + } + + pub fn eye_dir(&self) -> Vec3 { + vec3(self.pitch.cos() * self.yaw.sin(), self.pitch.sin(), self.pitch.cos() * self.yaw.cos()) + } + + fn target(&self) -> Vec3 { + vec3(self.tx, 0.0, self.tz) + } + + pub fn camera(&self) -> Camera3D { + Camera3D { + position: self.target() + self.eye_dir() * 60.0, + target: self.target(), + up: Vec3::Y, + fovy: self.zoom, + projection: Projection::Orthographics, + z_near: 1.0, + z_far: 200.0, + ..Default::default() + } + } + + // forward (into the screen), right, up + fn basis(&self) -> (Vec3, Vec3, Vec3) { + let f = -self.eye_dir(); + let r = f.cross(Vec3::Y).normalize(); + let u = r.cross(f); + (f, r, u) + } + + // Ground-plane directions for panning: screen-right and screen-up + pub fn ground_axes(&self) -> (Vec2, Vec2) { + let (f, r, _) = self.basis(); + (vec2(r.x, r.z).normalize(), vec2(f.x, f.z).normalize()) + } + + pub fn ray(&self, sx: f32, sy: f32) -> (Vec3, Vec3) { + let (f, r, u) = self.basis(); + let half_h = self.zoom * 0.5; + let half_w = half_h * screen_width() / screen_height(); + let nx = sx / screen_width() * 2.0 - 1.0; + let ny = 1.0 - sy / screen_height() * 2.0; + let pos = self.target() + self.eye_dir() * 60.0; + (pos + r * (nx * half_w) + u * (ny * half_h), f) + } + + pub fn ground_point(&self, sx: f32, sy: f32) -> Vec2 { + let (o, d) = self.ray(sx, sy); + let t = -o.y / d.y.min(-0.001); + let p = o + d * t; + vec2(p.x, p.z) + } + + pub fn matrix(&self) -> Mat4 { + self.camera().matrix() + } + + pub fn project(m: &Mat4, p: Vec3) -> Vec2 { + let c = *m * p.extend(1.0); + let n = c.truncate() / c.w; + vec2((n.x + 1.0) * 0.5 * screen_width(), (1.0 - n.y) * 0.5 * screen_height()) + } + + // Tile under the mouse: wall tops/sides first, otherwise the floor + pub fn pick_tile(&self, map: &Map, sx: f32, sy: f32) -> Option<(i32, i32, Vec3)> { + let (o, d) = self.ray(sx, sy); + if d.y >= -0.001 { + return None; + } + let top = WALL_H + 0.3; + let t0 = (o.y - top) / -d.y; + let t1 = o.y / -d.y; + let mut t = t0; + while t <= t1 { + let p = o + d * t; + let (x, z) = (p.x.floor() as i32, p.z.floor() as i32); + if inb(x, z) && map.t[idx(x, z)].solid() && p.y <= WALL_H { + return Some((x, z, p)); + } + t += 0.03; + } + let p = o + d * t1; + let (x, z) = (p.x.floor() as i32, p.z.floor() as i32); + if inb(x, z) { + Some((x, z, p)) + } else { + None + } + } +} + +// ------------------------------------------------------------------ noise + +fn h3(x: i32, y: i32, z: i32, s: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(374761393) ^ (y as u32).wrapping_mul(668265263) ^ (z as u32).wrapping_mul(2147483647) ^ s.wrapping_mul(2246822519); + h = (h ^ (h >> 13)).wrapping_mul(1274126177); + h ^= h >> 16; + (h & 0xffff) as f32 / 32767.5 - 1.0 +} + +fn vnoise(p: Vec3, s: u32) -> f32 { + let (x0, y0, z0) = (p.x.floor(), p.y.floor(), p.z.floor()); + let (fx, fy, fz) = (p.x - x0, p.y - y0, p.z - z0); + let sm = |t: f32| t * t * (3.0 - 2.0 * t); + let (ux, uy, uz) = (sm(fx), sm(fy), sm(fz)); + let (xi, yi, zi) = (x0 as i32, y0 as i32, z0 as i32); + let mut acc = 0.0; + for dz in 0..2 { + for dy in 0..2 { + for dx in 0..2 { + let w = (if dx == 1 { ux } else { 1.0 - ux }) * (if dy == 1 { uy } else { 1.0 - uy }) * (if dz == 1 { uz } else { 1.0 - uz }); + acc += w * h3(xi + dx, yi + dy, zi + dz, s); + } + } + } + acc +} + +// Displacement depends only on the world position -> seamless between neighbours +fn displace(p: Vec3) -> Vec3 { + let q = p * 2.3; + let r = p * 5.1; + let n = |s: u32| vnoise(q, s) + 0.35 * vnoise(r, s + 10); + let dy = if p.y < 0.01 { 0.0 } else { n(2) * 0.6 }; + vec3(n(1), dy, n(3)) * DISP_AMP +} + +// ------------------------------------------------------------------ lit material + +const VERTEX: &str = r#"#version 100 +attribute vec3 position; +attribute vec2 texcoord; +attribute vec4 color0; +attribute vec4 normal; + +varying lowp vec2 uv; +varying lowp vec4 color; +varying highp vec3 wpos; +varying mediump vec3 wnorm; + +uniform mat4 Model; +uniform mat4 Projection; + +void main() { + vec4 w = Model * vec4(position, 1.0); + wpos = w.xyz; + wnorm = normal.xyz; + color = color0 / 255.0; + uv = texcoord; + gl_Position = Projection * w; +} +"#; + +const FRAGMENT: &str = r#"#version 100 +precision mediump float; +varying lowp vec2 uv; +varying lowp vec4 color; +varying highp vec3 wpos; +varying mediump vec3 wnorm; + +uniform sampler2D Texture; +uniform vec4 LightPos[24]; +uniform vec4 LightCol[24]; +uniform float LightCount; + +void main() { + vec4 tex = texture2D(Texture, uv); + if (tex.a < 0.5) discard; + vec3 n = normalize(wnorm); + vec3 light = color.rgb; + for (int i = 0; i < 24; i++) { + if (float(i) >= LightCount) break; + vec3 d = LightPos[i].xyz - wpos; + float dist = length(d); + float att = clamp(1.0 - dist / LightPos[i].w, 0.0, 1.0); + att *= att; + float ndl = max(dot(n, d / max(dist, 0.001)), 0.0) * 0.8 + 0.2; + light += LightCol[i].rgb * LightCol[i].a * att * ndl; + } + gl_FragColor = vec4(tex.rgb * min(light, vec3(1.7)), 1.0); +} +"#; + +fn lit_material() -> Material { + load_material( + ShaderSource::Glsl { vertex: VERTEX, fragment: FRAGMENT }, + MaterialParams { + pipeline_params: PipelineParams { depth_test: Comparison::LessOrEqual, depth_write: true, ..Default::default() }, + uniforms: vec![ + UniformDesc::new("LightPos", UniformType::Float4).array(MAX_LIGHTS), + UniformDesc::new("LightCol", UniformType::Float4).array(MAX_LIGHTS), + UniformDesc::new("LightCount", UniformType::Float1), + ], + textures: vec![], + }, + ) + .expect("lit material") +} + +// Unlit, alpha-blended material for glows, shadows and effects +fn glow_material() -> Material { + const V: &str = r#"#version 100 +attribute vec3 position; +attribute vec2 texcoord; +attribute vec4 color0; +attribute vec4 normal; +varying lowp vec2 uv; +varying lowp vec4 color; +uniform mat4 Model; +uniform mat4 Projection; +void main() { + gl_Position = Projection * Model * vec4(position, 1.0); + color = color0 / 255.0; + uv = texcoord; +} +"#; + const F: &str = r#"#version 100 +precision mediump float; +varying lowp vec4 color; +varying lowp vec2 uv; +uniform sampler2D Texture; +void main() { + gl_FragColor = color * texture2D(Texture, uv); +} +"#; + load_material( + ShaderSource::Glsl { vertex: V, fragment: F }, + MaterialParams { + pipeline_params: PipelineParams { + depth_test: Comparison::LessOrEqual, + depth_write: false, + color_blend: Some(BlendState::new(Equation::Add, BlendFactor::Value(BlendValue::SourceAlpha), BlendFactor::OneMinusValue(BlendValue::SourceAlpha))), + ..Default::default() + }, + uniforms: vec![], + textures: vec![], + }, + ) + .expect("glow material") +} + +// ------------------------------------------------------------------ mesh building + +struct MB { + v: Vec, + i: Vec, +} + +fn col(c: Vec3, a: f32) -> [u8; 4] { + [(c.x.clamp(0.0, 1.0) * 255.0) as u8, (c.y.clamp(0.0, 1.0) * 255.0) as u8, (c.z.clamp(0.0, 1.0) * 255.0) as u8, (a.clamp(0.0, 1.0) * 255.0) as u8] +} + +fn cell_uv(id: u32) -> (Vec2, Vec2) { + let r = Gfx::cell(id); + let inset = 0.3; + (vec2((r.x + inset) / ATLAS_W, (r.y + inset) / ATLAS_H), vec2((r.x + r.w - inset) / ATLAS_W, (r.y + r.h - inset) / ATLAS_H)) +} + +impl MB { + fn new() -> MB { + MB { v: Vec::new(), i: Vec::new() } + } + fn vert(&mut self, p: Vec3, uv: Vec2, c: [u8; 4], n: Vec3) -> u16 { + self.v.push(Vertex { position: p, uv, color: c, normal: n.extend(0.0) }); + (self.v.len() - 1) as u16 + } + fn quad(&mut self, p: [Vec3; 4], uv: [Vec2; 4], c: [[u8; 4]; 4], n: [Vec3; 4]) { + let b = self.v.len() as u16; + for k in 0..4 { + self.vert(p[k], uv[k], c[k], n[k]); + } + self.i.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]); + } + // Two crossed vertical quads (decor, billboards in the mesh) + fn cross(&mut self, cx: f32, cz: f32, w: f32, h: f32, cell: u32, c: [u8; 4]) { + let (a, b) = cell_uv(cell); + let n = Vec3::Y; + for k in 0..2 { + let (dx, dz) = if k == 0 { (w * 0.5, 0.0) } else { (0.0, w * 0.5) }; + self.quad( + [vec3(cx - dx, h, cz - dz), vec3(cx + dx, h, cz + dz), vec3(cx + dx, 0.0, cz + dz), vec3(cx - dx, 0.0, cz - dz)], + [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)], + [c; 4], + [n; 4], + ); + } + } + fn into_mesh(self, tex: &Texture2D) -> Option { + if self.v.is_empty() { + None + } else { + Some(Mesh { vertices: self.v, indices: self.i, texture: Some(tex.clone()) }) + } + } +} + +// Ambient light field: own territory is lit, foreign areas are dim +fn ambient_field(map: &Map) -> Vec { + let mut f = vec![0.0f32; N]; + for (i, t) in map.t.iter().enumerate() { + f[i] = match (t.solid(), t.owner) { + (false, Owner::Player) => 1.0, + (false, Owner::Hero) => 0.6, + _ => 0.0, + }; + if t.kind == Kind::Core { + f[i] = 1.0; + } + } + for _ in 0..2 { + let src = f.clone(); + for y in 0..MH as i32 { + for x in 0..MW as i32 { + let mut s = 0.0; + let mut n = 0.0; + for dy in -1..=1 { + for dx in -1..=1 { + if inb(x + dx, y + dy) { + s += src[idx(x + dx, y + dy)]; + n += 1.0; + } + } + } + f[idx(x, y)] = (s / n).max(src[idx(x, y)] * 0.85); + } + } + } + f +} + +fn amb_at(f: &[f32], x: f32, z: f32) -> f32 { + let gx = (x - 0.5).clamp(0.0, MW as f32 - 1.001); + let gz = (z - 0.5).clamp(0.0, MH as f32 - 1.001); + let (x0, z0) = (gx.floor() as i32, gz.floor() as i32); + let (fx, fz) = (gx - x0 as f32, gz - z0 as f32); + let g = |x: i32, z: i32| f[idx(x.min(MW as i32 - 1), z.min(MH as i32 - 1))]; + let a = g(x0, z0) * (1.0 - fx) + g(x0 + 1, z0) * fx; + let b = g(x0, z0 + 1) * (1.0 - fx) + g(x0 + 1, z0 + 1) * fx; + 0.15 + 0.45 * (a * (1.0 - fz) + b * fz) +} + +const LIGHT_DIR: Vec3 = Vec3::new(0.45, 0.8, 0.35); + +fn solid_at(map: &Map, x: i32, z: i32) -> bool { + map.get(x, z).map_or(true, |t| t.solid()) +} + +fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D) -> (Option, Option) { + let mut mb = MB::new(); + let mut ov = MB::new(); + let ld = LIGHT_DIR.normalize(); + for z in cz * CHUNK..(cz + 1) * CHUNK { + for x in cx * CHUNK..(cx + 1) * CHUNK { + let t = map.t[idx(x, z)]; + let tex = Gfx::tile_tex(t.kind, t.owner == Owner::Player, t.owner == Owner::Hero, t.var); + let (ua, ub) = cell_uv(tex); + let (xf, zf) = (x as f32, z as f32); + if !t.solid() { + // floor with corner ambient occlusion + let corners = [(0, 0), (1, 0), (1, 1), (0, 1)]; + let mut c = [[0u8; 4]; 4]; + for (k, &(ox, oz)) in corners.iter().enumerate() { + let (vx, vz) = (x + ox, z + oz); + let solid_n = [(vx - 1, vz - 1), (vx, vz - 1), (vx - 1, vz), (vx, vz)].iter().filter(|&&(a, b)| solid_at(map, a, b)).count(); + let ao = 1.0 - 0.17 * solid_n as f32; + let a = amb_at(amb, vx as f32, vz as f32) * ao; + c[k] = col(Vec3::splat(a), 1.0); + } + mb.quad( + [vec3(xf, 0.0, zf), vec3(xf + 1.0, 0.0, zf), vec3(xf + 1.0, 0.0, zf + 1.0), vec3(xf, 0.0, zf + 1.0)], + [vec2(ua.x, ua.y), vec2(ub.x, ua.y), vec2(ub.x, ub.y), vec2(ua.x, ub.y)], + c, + [Vec3::Y; 4], + ); + // room decor + let dc = col(Vec3::splat(amb_at(amb, xf + 0.5, zf + 0.5)), 1.0); + match t.kind { + Kind::Lair => { + let (a, b) = cell_uv(D_BED); + mb.quad( + [vec3(xf + 0.05, 0.02, zf + 0.05), vec3(xf + 0.95, 0.02, zf + 0.05), vec3(xf + 0.95, 0.02, zf + 0.95), vec3(xf + 0.05, 0.02, zf + 0.95)], + [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)], + [dc; 4], + [Vec3::Y; 4], + ); + } + Kind::Farm => { + let pos = [(0.3, 0.3), (0.7, 0.4), (0.45, 0.72)]; + for (k, &(ox, oz)) in pos.iter().enumerate() { + if t.food >= k as f32 + 1.0 { + mb.cross(xf + ox, zf + oz, 0.45, 0.45, D_MUSHROOM, dc); + } + } + } + Kind::Training if (x + z) % 2 == 0 => mb.cross(xf + 0.5, zf + 0.5, 0.7, 0.75, D_DUMMY, dc), + Kind::Library if z % 2 == 0 => mb.cross(xf + 0.5, zf + 0.5, 0.9, 0.85, D_SHELF, dc), + Kind::Treasury if gold_fill > 0.02 => { + let s = 0.35 + 0.55 * gold_fill; + mb.cross(xf + 0.5, zf + 0.5, s, s * 0.8, D_COINS, dc); + } + _ => {} + } + continue; + } + + // ---- wall: dark floor below (fills gaps), bumpy top, irregular open sides + let dark = col(Vec3::splat(0.04), 1.0); + mb.quad( + [vec3(xf, -0.01, zf), vec3(xf + 1.0, -0.01, zf), vec3(xf + 1.0, -0.01, zf + 1.0), vec3(xf, -0.01, zf + 1.0)], + [ua, ua, ua, ua], + [dark; 4], + [Vec3::Y; 4], + ); + let n = WALL_SUB; + let top_amb = amb_at(amb, xf + 0.5, zf + 0.5) * 0.85 + 0.05; + build_grid( + &mut mb, + |u, v| vec3(xf + u, WALL_H, zf + v), + Vec3::Y, + n, + (ua, ub), + |_p, nrm| Vec3::splat(top_amb * (0.75 + 0.25 * nrm.dot(ld).max(0.0))), + ); + if t.marked { + let (a, b) = cell_uv(T_MARK); + let y = WALL_H + 0.12; + ov.quad( + [vec3(xf, y, zf), vec3(xf + 1.0, y, zf), vec3(xf + 1.0, y, zf + 1.0), vec3(xf, y, zf + 1.0)], + [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)], + [[255, 255, 255, 230]; 4], + [Vec3::Y; 4], + ); + } + for (d, &(dx, dz)) in DIR4.iter().enumerate() { + if solid_at(map, x + dx, z + dz) { + continue; + } + let side_amb = amb_at(amb, xf + 0.5 + dx as f32 * 0.9, zf + 0.5 + dz as f32 * 0.9); + // edge from a to b as seen from outside, u along the edge, v = height + let (a, b) = match d { + 0 => (vec3(xf + 1.0, 0.0, zf), vec3(xf, 0.0, zf)), // north + 1 => (vec3(xf + 1.0, 0.0, zf + 1.0), vec3(xf + 1.0, 0.0, zf)), // east + 2 => (vec3(xf, 0.0, zf + 1.0), vec3(xf + 1.0, 0.0, zf + 1.0)), // south + _ => (vec3(xf, 0.0, zf), vec3(xf, 0.0, zf + 1.0)), // west + }; + let out = vec3(dx as f32, 0.0, dz as f32); + build_grid( + &mut mb, + |u, v| a + (b - a) * u + vec3(0.0, WALL_H * (1.0 - v), 0.0), + out, + n, + (ua, ub), + |p, nrm| { + let h = (p.y / WALL_H).clamp(0.0, 1.0); + Vec3::splat(side_amb * (0.5 + 0.5 * nrm.dot(ld).max(0.0)) * (0.55 + 0.45 * h)) + }, + ); + } + } + } + (mb.into_mesh(atlas), ov.into_mesh(atlas)) +} + +// Subdivided, displaced face. pos(u, v) gives the undisplaced point (u, v in 0..1). +fn build_grid(mb: &mut MB, pos: impl Fn(f32, f32) -> Vec3, face_n: Vec3, n: usize, uv: (Vec2, Vec2), shade: impl Fn(Vec3, Vec3) -> Vec3) { + let m = n + 1; + let mut p = Vec::with_capacity(m * m); + for j in 0..m { + for i in 0..m { + let base = pos(i as f32 / n as f32, j as f32 / n as f32); + p.push(base + displace(base)); + } + } + let at = |i: usize, j: usize| p[j * m + i]; + let base_idx = mb.v.len() as u16; + for j in 0..m { + for i in 0..m { + let du = at((i + 1).min(n), j) - at(i.saturating_sub(1), j); + let dv = at(i, (j + 1).min(n)) - at(i, j.saturating_sub(1)); + let mut nrm = du.cross(dv).normalize_or_zero(); + if nrm.dot(face_n) < 0.0 { + nrm = -nrm; + } + if nrm == Vec3::ZERO { + nrm = face_n; + } + let u = i as f32 / n as f32; + let v = j as f32 / n as f32; + let tuv = vec2(uv.0.x + (uv.1.x - uv.0.x) * u, uv.0.y + (uv.1.y - uv.0.y) * v); + let c = shade(at(i, j), nrm); + mb.vert(at(i, j), tuv, col(c, 1.0), nrm); + } + } + for j in 0..n { + for i in 0..n { + let a = base_idx + (j * m + i) as u16; + let b = a + 1; + let c = a + m as u16 + 1; + let d = a + m as u16; + mb.i.extend_from_slice(&[a, b, c, a, c, d]); + } + } +} + +// ------------------------------------------------------------------ renderer + +struct Chunk { + sig: u64, + mesh: Option, + overlay: Option, +} + +pub struct Overlay { + pub hover: Option<(i32, i32)>, + pub rect: Option<(i32, i32, i32, i32)>, + pub color: Color, + pub radius: Option<(f32, f32, f32)>, + pub high: bool, // outline on wall tops (digging) +} + +pub struct R3d { + lit: Material, + glow: Material, + chunks: Vec, +} + +fn rgba(u: u32) -> Color { + Color::from_rgba(u as u8, (u >> 8) as u8, (u >> 16) as u8, (u >> 24) as u8) +} + +fn chunk_sig(map: &Map, cx: i32, cz: i32, fill_bucket: u8) -> u64 { + let mut h: u64 = 1469598103934665603; + for z in cz * CHUNK - 3..(cz + 1) * CHUNK + 3 { + for x in cx * CHUNK - 3..(cx + 1) * CHUNK + 3 { + let v = match map.get(x, z) { + Some(t) => { + let extra = match t.kind { + Kind::Farm => t.food as u64, + Kind::Treasury => fill_bucket as u64, + _ => 0, + }; + (t.kind as u64) | (t.owner as u64) << 4 | (t.marked as u64) << 6 | extra << 8 + } + None => 0xff, + }; + h = (h ^ v).wrapping_mul(1099511628211); + } + } + h +} + +impl R3d { + pub fn new() -> R3d { + let chunks = (0..NCH * NCH).map(|_| Chunk { sig: 0, mesh: None, overlay: None }).collect(); + R3d { lit: lit_material(), glow: glow_material(), chunks } + } + + fn update_chunks(&mut self, g: &Game, gfx: &Gfx) { + let fill = (g.gold / g.gold_cap()).clamp(0.0, 1.0); + let bucket = (fill * 6.0) as u8; + let mut amb: Option> = None; + for cz in 0..NCH { + for cx in 0..NCH { + let sig = chunk_sig(&g.map, cx, cz, bucket); + let ch = &mut self.chunks[(cz * NCH + cx) as usize]; + if ch.sig != sig { + let a = amb.get_or_insert_with(|| ambient_field(&g.map)); + let (m, o) = build_chunk(&g.map, a, cx, cz, bucket as f32 / 6.0, &gfx.atlas); + ch.mesh = m; + ch.overlay = o; + ch.sig = sig; + } + } + } + } + + fn chunk_visible(m: &Mat4, cx: i32, cz: i32) -> bool { + let (x0, z0) = ((cx * CHUNK) as f32, (cz * CHUNK) as f32); + let (x1, z1) = (x0 + CHUNK as f32, z0 + CHUNK as f32); + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for &x in &[x0, x1] { + for &z in &[z0, z1] { + for &y in &[0.0, WALL_H + 0.5] { + let s = Cam::project(m, vec3(x, y, z)); + minx = minx.min(s.x); + maxx = maxx.max(s.x); + miny = miny.min(s.y); + maxy = maxy.max(s.y); + } + } + } + maxx >= 0.0 && minx <= screen_width() && maxy >= 0.0 && miny <= screen_height() + } + + fn lights(&self, g: &Game, cam: &Cam, t: f32) -> (Vec, Vec) { + let mut l: Vec<(f32, Vec4, Vec4)> = Vec::new(); + let center = vec2(cam.tx, cam.tz); + let mut push = |p: Vec3, radius: f32, c: Vec3, intensity: f32, prio: f32| { + let d = vec2(p.x, p.z).distance(center) - prio; + l.push((d, p.extend(radius), c.extend(intensity))); + }; + for (k, tr) in g.torches.iter().enumerate() { + let (x, z) = tr.pos(); + let fl = 0.85 + 0.1 * (t * 9.0 + k as f32 * 1.7).sin() + 0.05 * (t * 23.0 + k as f32).sin(); + push(vec3(x, 0.75, z), 4.5, vec3(1.0, 0.62, 0.28), 0.9 * fl, 0.0); + } + let (cx, cy) = g.map.core; + let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); + push(vec3(cx as f32 + 1.5, 1.2, cy as f32 + 1.5), 7.0, vec3(1.0, 0.25, 0.2), 1.1 + 0.2 * (t * 3.0).sin() * (1.5 - hp), 100.0); + let (px, py) = g.map.portal; + let owned = g.portal_owned(); + push(vec3(px as f32 + 1.5, 0.8, py as f32 + 1.5), 6.0, vec3(0.7, 0.3, 1.0), if owned { 1.0 } else { 0.35 }, 50.0); + if g.gate_hp > 0.0 { + let (gx, gz) = g.gate_center(); + push(vec3(gx, 1.2, gz), 7.0, vec3(0.75, 0.85, 1.0), 1.0, 50.0); + } + for f in &g.fx { + if f.kind == 1 { + push(vec3(f.x, 2.0, f.y), 8.0, vec3(1.0, 1.0, 0.85), 3.0 * (1.0 - f.t / f.max), 200.0); + } + } + l.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + l.truncate(MAX_LIGHTS); + let mut pos = vec![Vec4::ZERO; MAX_LIGHTS]; + let mut colr = vec![Vec4::ZERO; MAX_LIGHTS]; + for (k, (_, p, c)) in l.iter().enumerate() { + pos[k] = *p; + colr[k] = *c; + } + let n = l.len() as f32; + pos.push(Vec4::new(n, 0.0, 0.0, 0.0)); + (pos, colr) + } + + // Entity under the mouse (screen-space test against the projected sprite) + pub fn pick_ent(&self, g: &Game, cam: &Cam, sx: f32, sy: f32, player_only: bool) -> Option { + let m = cam.matrix(); + let px_per_tile = screen_height() / cam.zoom; + let mut best = None; + let mut bd = (px_per_tile * 0.4).max(10.0); + for (j, e) in g.ents.iter().enumerate() { + if !e.alive || e.held || (player_only && e.hero) { + continue; + } + let s = Cam::project(&m, vec3(e.x, 0.3, e.y)); + let d = s.distance(vec2(sx, sy)); + if d < bd { + bd = d; + best = Some(j); + } + } + best + } + + pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, time: f64, ov: &Overlay) { + let t = time as f32; + self.update_chunks(g, gfx); + let m = cam.matrix(); + set_camera(&cam.camera()); + + // ---- lit geometry + let (lp, lc) = self.lights(g, cam, t); + let count = lp[MAX_LIGHTS].x; + self.lit.set_uniform_array("LightPos", &lp[..MAX_LIGHTS]); + self.lit.set_uniform_array("LightCol", &lc[..]); + self.lit.set_uniform("LightCount", count); + gl_use_material(&self.lit); + for cz in 0..NCH { + for cx in 0..NCH { + if !Self::chunk_visible(&m, cx, cz) { + continue; + } + if let Some(mesh) = &self.chunks[(cz * NCH + cx) as usize].mesh { + draw_mesh(mesh); + } + } + } + // sprites as billboards + if let Some(mesh) = self.billboards(g, gfx, cam, t) { + draw_mesh(&mesh); + } + // solid unlit objects (default material, depth tested) + gl_use_default_material(); + for tr in &g.torches { + let (x, z) = tr.pos(); + draw_cube(vec3(x, 0.55, z), vec3(0.07, 0.3, 0.07), None, Color::from_rgba(70, 45, 28, 255)); + } + draw_landmarks_solid(g, t); + + // ---- unlit glow / overlays + gl_use_material(&self.glow); + for c in &self.chunks { + if let Some(o) = &c.overlay { + draw_mesh(o); + } + } + for (k, tr) in g.torches.iter().enumerate() { + let (x, z) = tr.pos(); + let fl = 1.0 + 0.15 * (t * 11.0 + k as f32 * 2.3).sin(); + draw_sphere(vec3(x, 0.78, z), 0.09 * fl, None, Color::new(1.0, 0.55, 0.15, 0.9)); + draw_sphere(vec3(x, 0.8, z), 0.05 * fl, None, Color::new(1.0, 0.95, 0.6, 1.0)); + } + draw_shadows(g); + draw_landmarks_glow(g, t); + if let Some(f) = g.flag { + draw_flag(f, t); + } + for f in &g.fx { + draw_fx(f, t); + } + draw_overlay(&g.map, ov); + gl_use_default_material(); + set_default_camera(); + + // ---- 2D overlay: health bars, sleep marks, levels, gate HP + self.draw_2d(g, gfx, cam, &m, t); + } + + fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32) -> Option { + let (_, r, _) = cam.basis(); + let toward = cam.eye_dir(); + let amb = |x: f32, z: f32| { + let (ix, iz) = (x as i32, z as i32); + let own = g.map.get(ix, iz).map_or(false, |t| t.owner == Owner::Player); + if own { + 0.75 + } else { + 0.5 + } + }; + let mut mb = MB::new(); + let sw = 120.0; + let sh = 24.0; + for e in &g.ents { + if !e.alive || e.held { + continue; + } + let big = matches!(e.kind, EK::Ogre | EK::Paladin); + let w = if big { 0.8 } else { 0.62 }; + let h = w; + let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; + let bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + let rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 }; + let white = e.flash > 0.07; + let src = Gfx::sprite_rect(e.kind, white); + let (mut u0, v0) = (src.x / sw, src.y / sh); + let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh); + if e.face < 0.0 { + std::mem::swap(&mut u0, &mut u1); + } + let a = amb(e.x, e.y) * if sleeping { 0.6 } else { 1.0 }; + let c = if white { [255, 255, 255, 255] } else { col(Vec3::splat(a), 1.0) }; + let base = vec3(e.x, bob, e.y); + let (sr, cr) = rot.sin_cos(); + let corner = |dx: f32, dy: f32| { + let x = dx * cr - dy * sr; + let y = dx * sr + dy * cr; + base + r * x + Vec3::Y * y + }; + mb.quad( + [corner(-w * 0.5, h), corner(w * 0.5, h), corner(w * 0.5, 0.0), corner(-w * 0.5, 0.0)], + [vec2(u0, v0), vec2(u1, v0), vec2(u1, v1), vec2(u0, v1)], + [c; 4], + [toward; 4], + ); + } + if mb.v.is_empty() { + None + } else { + Some(Mesh { vertices: mb.v, indices: mb.i, texture: Some(gfx.sprites.clone()) }) + } + } + + fn draw_2d(&self, g: &Game, gfx: &Gfx, cam: &Cam, m: &Mat4, t: f32) { + let px_per_tile = screen_height() / cam.zoom; + for e in &g.ents { + if !e.alive || e.held { + continue; + } + let big = matches!(e.kind, EK::Ogre | EK::Paladin); + let head = Cam::project(m, vec3(e.x, if big { 0.9 } else { 0.72 }, e.y)); + let w = (px_per_tile * 0.6).clamp(12.0, 40.0); + if e.hp < e.max_hp || e.hero { + let f = (e.hp / e.max_hp).clamp(0.0, 1.0); + let c = 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(head.x - w * 0.5 - 1.0, head.y - 1.0, w + 2.0, 4.0, Color::new(0.0, 0.0, 0.0, 0.8)); + draw_rectangle(head.x - w * 0.5, head.y, w * f, 2.0, c); + } + if e.guard { + gfx.glyph(0x0F, head.x - 3.0, head.y - 11.0, 1.0, Color::from_rgba(255, 220, 90, 255)); + } + if e.level > 1 && px_per_tile >= 24.0 { + gfx.text_sh(&e.level.to_string(), head.x - w * 0.5 - 8.0, head.y - 3.0, 1.0, Color::from_rgba(255, 220, 90, 255)); + } + if matches!(e.job, Job::Sleep(_)) && !e.moving { + let k = (t * 1.5 + e.anim) % 1.0; + gfx.glyph(b'z', head.x + w * 0.3, head.y - 6.0 - k * 14.0, 2.0, Color::new(0.8, 0.85, 1.0, 1.0 - k)); + } + if e.carry > 0 { + let p = Cam::project(m, vec3(e.x, 0.35, e.y)); + draw_circle(p.x + w * 0.35, p.y, 3.0, Color::from_rgba(250, 210, 60, 255)); + } + } + if g.gate_hp > 0.0 && g.gate_hp < GATE_HP { + let (gx, gz) = g.gate_center(); + let p = Cam::project(m, vec3(gx, 2.4, gz)); + let w = 100.0; + draw_rectangle(p.x - w * 0.5 - 1.0, p.y - 1.0, w + 2.0, 8.0, BLACK); + draw_rectangle(p.x - w * 0.5, p.y, w * g.gate_hp / GATE_HP, 6.0, Color::from_rgba(120, 180, 255, 255)); + } + } +} + +// ------------------------------------------------------------------ landmarks, fx, overlay + +fn disc(c: Vec3, r: f32, color: Color, seg: usize) { + let mut v = Vec::with_capacity(seg + 1); + let cc = [(color.r * 255.0) as u8, (color.g * 255.0) as u8, (color.b * 255.0) as u8, (color.a * 255.0) as u8]; + v.push(Vertex { position: c, uv: Vec2::ZERO, color: cc, normal: Vec4::Y }); + for k in 0..seg { + let a = k as f32 / seg as f32 * std::f32::consts::TAU; + v.push(Vertex { position: c + vec3(a.cos() * r, 0.0, a.sin() * r), uv: Vec2::ZERO, color: cc, normal: Vec4::Y }); + } + let mut i = Vec::with_capacity(seg * 3); + for k in 0..seg as u16 { + i.extend_from_slice(&[0, 1 + k, 1 + (k + 1) % seg as u16]); + } + draw_mesh(&Mesh { vertices: v, indices: i, texture: None }); +} + +fn ring(c: Vec3, r: f32, color: Color) { + let seg = 32; + for k in 0..seg { + let a0 = k as f32 / seg as f32 * std::f32::consts::TAU; + let a1 = (k + 1) as f32 / seg as f32 * std::f32::consts::TAU; + draw_line_3d(c + vec3(a0.cos() * r, 0.0, a0.sin() * r), c + vec3(a1.cos() * r, 0.0, a1.sin() * r), color); + } +} + +fn draw_landmarks_solid(g: &Game, t: f32) { + // core pedestal + let (cx, cy) = g.map.core; + let c = vec3(cx as f32 + 1.5, 0.0, cy as f32 + 1.5); + draw_cube(c + vec3(0.0, 0.15, 0.0), vec3(2.4, 0.3, 2.4), None, Color::from_rgba(45, 16, 20, 255)); + draw_cube(c + vec3(0.0, 0.4, 0.0), vec3(1.4, 0.2, 1.4), None, Color::from_rgba(70, 26, 30, 255)); + // gate pillars + let (gx, gz) = g.gate_center(); + if g.gate_hp > 0.0 { + let stone = Color::from_rgba(150, 155, 172, 255); + draw_cube(vec3(gx - 1.1, 1.0, gz), vec3(0.45, 2.0, 0.45), None, stone); + draw_cube(vec3(gx + 1.1, 1.0, gz), vec3(0.45, 2.0, 0.45), None, stone); + draw_cube(vec3(gx, 2.1, gz), vec3(2.8, 0.35, 0.55), None, stone); + draw_cube(vec3(gx, 2.35, gz), vec3(0.5, 0.2, 0.5), None, Color::from_rgba(230, 200, 100, 255)); + } else { + for k in 0..10 { + let a = k as f32 * 1.7; + draw_cube(vec3(gx + a.cos() * 1.0, 0.15, gz + a.sin() * 0.8), vec3(0.35, 0.3, 0.3), None, Color::from_rgba(150, 150, 160, 255)); + } + } + let _ = t; +} + +fn draw_landmarks_glow(g: &Game, t: f32) { + // soul core + let (cx, cy) = g.map.core; + 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.06; + let c = vec3(cx as f32 + 1.5, 1.15 + (t * 1.3).sin() * 0.06, cy as f32 + 1.5); + draw_sphere(c, 0.75 * pulse, None, Color::new(0.4 + 0.5 * hp, 0.05, 0.08, 1.0)); + draw_sphere(c, 0.95 * pulse, None, Color::new(1.0, 0.2, 0.15, 0.25)); + draw_sphere(c + vec3(-0.2, 0.25, 0.2), 0.18, None, Color::new(1.0, 0.85, 0.7, 0.8)); + // portal + let (px, py) = g.map.portal; + let owned = g.portal_owned(); + let pc = vec3(px as f32 + 1.5, 0.03, py as f32 + 1.5); + let arm = if owned { Color::new(0.8, 0.4, 1.0, 1.0) } else { Color::new(0.45, 0.42, 0.5, 1.0) }; + disc(pc, 1.35, if owned { Color::new(0.2, 0.05, 0.3, 0.9) } else { Color::new(0.1, 0.1, 0.12, 0.9) }, 32); + let speed = if owned { 1.6 } else { 0.3 }; + for a in 0..3 { + for k in 0..18 { + let f = k as f32 / 18.0; + let ang = t * speed + a as f32 * 2.094 + f * 4.0; + let r = f * 1.3; + let p = pc + vec3(ang.cos() * r, 0.1 + (1.0 - f) * 0.5, ang.sin() * r); + draw_cube(p, Vec3::splat(0.05 + 0.06 * (1.0 - f)), None, Color::new(arm.r, arm.g, arm.b, 0.4 + 0.6 * (1.0 - f))); + } + } + // gate light + if g.gate_hp > 0.0 { + let (gx, gz) = g.gate_center(); + let gc = vec3(gx, 1.0, gz); + draw_sphere(gc, 0.45 + 0.04 * (t * 3.0).sin(), None, Color::new(0.95, 0.97, 1.0, 0.95)); + draw_sphere(gc, 0.8, None, Color::new(0.7, 0.85, 1.0, 0.2)); + for k in 0..8 { + let a = t * 0.6 + k as f32 * 0.785; + draw_line_3d(gc, gc + vec3(a.cos() * 1.0, a.sin() * 0.9, 0.0), Color::new(0.85, 0.92, 1.0, 0.6)); + } + } +} + +fn draw_shadows(g: &Game) { + for e in &g.ents { + if !e.alive || e.held { + continue; + } + let r = if matches!(e.kind, EK::Ogre | EK::Paladin) { 0.32 } else { 0.24 }; + disc(vec3(e.x, 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12); + let rc = if e.guard { + Color::new(1.0, 0.85, 0.3, 0.9) + } else if e.hero { + Color::new(0.5, 0.8, 1.0, 0.8) + } else { + Color::new(0.95, 0.3, 0.35, 0.7) + }; + ring(vec3(e.x, 0.02, e.y), r + 0.03, rc); + } +} + +fn draw_flag(f: usize, t: f32) { + let (fx, fz) = xy(f); + let base = vec3(fx as f32 + 0.4, 0.0, fz as f32 + 0.5); + draw_cube(base + vec3(0.0, 0.7, 0.0), vec3(0.05, 1.4, 0.05), None, Color::from_rgba(200, 200, 200, 255)); + let wave = (t * 6.0).sin() * 0.08; + let p1 = base + vec3(0.0, 1.38, 0.0); + let p2 = base + vec3(0.6, 1.22 + wave, 0.1); + let p3 = base + vec3(0.0, 1.0, 0.0); + let c = [210, 40, 40, 255]; + let v = vec![ + Vertex { position: p1, uv: Vec2::ZERO, color: c, normal: Vec4::Y }, + Vertex { position: p2, uv: Vec2::ZERO, color: c, normal: Vec4::Y }, + Vertex { position: p3, uv: Vec2::ZERO, color: c, normal: Vec4::Y }, + ]; + draw_mesh(&Mesh { vertices: v, indices: vec![0, 1, 2], texture: None }); +} + +fn draw_fx(f: &Fx, t: f32) { + let p = (f.t / f.max).clamp(0.0, 1.0); + let c = rgba(f.col); + let a = |alpha: f32| Color::new(c.r, c.g, c.b, alpha); + let at = vec3(f.x, 0.4, f.y); + match f.kind { + 0 => { + let from = vec3(f.x, 0.45, f.y); + let to = vec3(f.x2, 0.45, f.y2); + let head = from.lerp(to, p); + let tail = from.lerp(to, (p - 0.35).max(0.0)); + draw_line_3d(tail, head, a(0.8)); + draw_sphere(head, 0.08, None, c); + } + 1 => { + let mut prev = vec3(f.x, 7.0, f.y); + for k in 1..=9 { + let y = 7.0 - 7.0 * k as f32 / 9.0; + let j = if k == 9 { 0.0 } else { (t * 50.0 + k as f32 * 13.7).sin() * 0.5 }; + let next = vec3(f.x + j, y, f.y + j * 0.5); + draw_line_3d(prev, next, Color::new(1.0, 1.0, 0.85, 1.0 - p)); + prev = next; + } + disc(vec3(f.x, 0.05, f.y), 1.6 * (0.5 + p), Color::new(1.0, 1.0, 0.7, 0.35 * (1.0 - p)), 24); + } + 2 => { + let r = 0.1 + 0.3 * p; + for k in 0..6 { + let ang = k as f32 * 1.047; + draw_line_3d(at, at + vec3(ang.cos() * r, (k as f32 - 2.5) * 0.06, ang.sin() * r), a(1.0 - p)); + } + } + 3 => draw_sphere(vec3(f.x, 0.3, f.y), 0.2 + 0.5 * p, None, a(0.4 * (1.0 - p))), + 4 => { + for k in 0..3 { + let o = vec3((k as f32 - 1.0) * 0.2, 0.3 + p * 0.9 + k as f32 * 0.1, 0.0); + draw_cube(at + o, vec3(0.16, 0.05, 0.05), None, a(1.0 - p)); + draw_cube(at + o, vec3(0.05, 0.16, 0.05), None, a(1.0 - p)); + } + } + 5 => { + for k in 0..4 { + let ang = k as f32 * 1.9 + f.x * 3.0; + let o = vec3(ang.cos() * 0.3 * p, 0.2 + p * 0.7, ang.sin() * 0.3 * p); + draw_cube(at + o, Vec3::splat(0.06), None, a(1.0 - p)); + } + } + 6 => { + for k in 0..4 { + let ang = k as f32 * 1.7 + f.y * 5.0; + let o = vec3(ang.cos() * 0.35 * p, 0.6 - p * 0.5, ang.sin() * 0.35 * p); + draw_cube(at + o, Vec3::splat(0.06), None, a(1.0 - p)); + } + } + 7 => ring(vec3(f.x, 0.05, f.y), 0.3 + 1.2 * p, a(1.0 - p)), + 8 => disc(vec3(f.x, 0.03, f.y), 0.55, a(0.45 * (1.0 - p)), 4), + _ => {} + } +} + +fn outline(x0: f32, z0: f32, x1: f32, z1: f32, y: f32, c: Color) { + let p = [vec3(x0, y, z0), vec3(x1, y, z0), vec3(x1, y, z1), vec3(x0, y, z1)]; + for k in 0..4 { + draw_line_3d(p[k], p[(k + 1) % 4], c); + // double line for visibility + draw_line_3d(p[k] + vec3(0.0, 0.015, 0.0), p[(k + 1) % 4] + vec3(0.0, 0.015, 0.0), c); + } +} + +fn draw_overlay(map: &Map, ov: &Overlay) { + let y_of = |x: i32, z: i32| if ov.high && map.get(x, z).map_or(false, |t| t.solid()) { WALL_H + 0.14 } else { 0.04 }; + if let Some((x0, z0, x1, z1)) = ov.rect { + let (ax, bx) = (x0.min(x1), x0.max(x1)); + let (az, bz) = (z0.min(z1), z0.max(z1)); + let y = if ov.high { WALL_H + 0.14 } else { 0.04 }; + outline(ax as f32, az as f32, bx as f32 + 1.0, bz as f32 + 1.0, y, ov.color); + } else if let Some((x, z)) = ov.hover { + outline(x as f32, z as f32, x as f32 + 1.0, z as f32 + 1.0, y_of(x, z), ov.color); + } + if let Some((x, z, r)) = ov.radius { + ring(vec3(x, 0.05, z), r, ov.color); + } +} + +// Minimap colour of a tile +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], + } +}