// 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::*; use serde::{Deserialize, Serialize}; pub const WALL_H: f32 = 0.95; const CHUNK: i32 = 16; pub const MAX_LIGHTS: usize = 24; const ATLAS_W: f32 = 256.0; const ATLAS_H: f32 = 144.0; const DISP_AMP: f32 = 0.11; // wall surface irregularity // ------------------------------------------------------------------ camera #[derive(Clone, Serialize, Deserialize)] 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, // miniquad only enables the depth *test* together with depth writes, so this // material writes depth too; large translucent glows are therefore drawn last depth_write: true, 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], ); } } // Thin strip lying on a surface (normal n) from p to q, used for tile outlines fn strip(&mut self, p: Vec3, q: Vec3, n: Vec3, w: f32, c: [u8; 4]) { let side = (q - p).cross(n).normalize_or_zero() * (w * 0.5); let o = n * 0.006; self.quad([p - side + o, q - side + o, q + side + o, p + side + o], [Vec2::ZERO; 4], [c; 4], [n; 4]); } // Polyline along displaced points fn polyline(&mut self, pts: &[Vec3], n: Vec3, w: f32, c: [u8; 4]) { for k in 0..pts.len().saturating_sub(1) { self.strip(pts[k], pts[k + 1], n, w, c); } } fn into_plain_mesh(self) -> Option { if self.v.is_empty() { None } else { Some(Mesh { vertices: self.v, indices: self.i, texture: None }) } } 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.river() == Some(Kind::Lava) { f[i] = f[i].max(0.8); // lava lights up its surroundings } 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()) } const OUTLINE_W: f32 = 0.035; const LIQ_D: f32 = 0.3; // depth of the river surface below the floor const DECK_Y: f32 = 0.03; // height of bridge decks const OUTLINE_C: [u8; 4] = [8, 5, 6, 150]; // Displaced points along a straight edge (same displacement as the wall grids -> lines follow the surface) fn edge_pts(a: Vec3, b: Vec3, n: usize) -> Vec { (0..=n) .map(|k| { let p = a.lerp(b, k as f32 / n as f32); p + displace(p) }) .collect() } const MARK_FILL: [u8; 4] = [255, 205, 50, 70]; const MARK_LINE: [u8; 4] = [255, 220, 70, 235]; // Glowing yellow box around a block that is marked for digging fn mark_block(mk: &mut MB, map: &Map, x: i32, z: i32, n: usize) { block_shell(mk, map, x, z, n, MARK_FILL, MARK_LINE, 1.0, 0.03); } // Colored, slightly lifted copy of a displaced face (same grid and displacement as the wall) fn shell_face(mk: &mut MB, pos: impl Fn(f32, f32) -> Vec3, face_n: Vec3, n: usize, fill: [u8; 4], lift: f32) { 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) + face_n * lift); } } let b = mk.v.len() as u16; for &q in &p { mk.vert(q, Vec2::ZERO, fill, face_n); } for j in 0..n { for i in 0..n { let a = b + (j * m + i) as u16; mk.i.extend_from_slice(&[a, a + 1, a + m as u16 + 1, a, a + m as u16 + 1, a + m as u16]); } } } // Highlight that follows the block's real (irregular) shape: tinted top and open sides, // outline along the displaced edges. lw = line width factor, lift = distance off the surface. fn block_shell(mk: &mut MB, map: &Map, x: i32, z: i32, n: usize, fill: [u8; 4], line: [u8; 4], lw: f32, lift: f32) { let (xf, zf) = (x as f32, z as f32); let up = Vec3::Y * lift; shell_face(mk, |u, v| vec3(xf + u, WALL_H, zf + v), Vec3::Y, n, fill, lift); let tops = [vec3(xf, WALL_H, zf), vec3(xf + 1.0, WALL_H, zf), vec3(xf + 1.0, WALL_H, zf + 1.0), vec3(xf, WALL_H, zf + 1.0)]; for k in 0..4 { let (dx, dz) = DIR4[k]; let out = vec3(dx as f32, 0.0, dz as f32); let (ta, tb) = (tops[k], tops[(k + 1) % 4]); // top edge (lifted a bit outwards so it sits on the rim) let rim: Vec = edge_pts(ta, tb, n).into_iter().map(|q| q + up + out * lift).collect(); mk.polyline(&rim, Vec3::Y, 0.06 * lw, line); let open = !solid_at(map, x + dx, z + dz); if open { let (ga, gb) = (ta - Vec3::Y * WALL_H, tb - Vec3::Y * WALL_H); shell_face(mk, |u, v| ga + (gb - ga) * u + vec3(0.0, WALL_H * (1.0 - v), 0.0), out, n, fill, lift); let foot: Vec = edge_pts(ga, gb, n).into_iter().map(|q| q + out * lift + Vec3::Y * 0.02).collect(); mk.polyline(&foot, out, 0.05 * lw, line); } // vertical edge at corner ta: only where one of the two sides meeting there is open let (pdx, pdz) = DIR4[(k + 3) % 4]; if open || !solid_at(map, x + pdx, z + pdz) { let g = ta - Vec3::Y * WALL_H; let pout = vec3(pdx as f32, 0.0, pdz as f32); let col: Vec = edge_pts(g, ta, n).into_iter().map(|q| q + (out + pout) * lift).collect(); mk.polyline(&col, (out + pout).normalize(), 0.05 * lw, line); } } } fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, atlas: &Texture2D, wall_sub: usize) -> [Option; 4] { let mut mb = MB::new(); let ov = MB::new(); // textured overlays (currently unused) let mut ln = MB::new(); let mut mk = MB::new(); let ld = LIGHT_DIR.normalize(); for z in cz * CHUNK..((cz + 1) * CHUNK).min(mh() as i32) { for x in cx * CHUNK..((cx + 1) * CHUNK).min(mw() as i32) { let t = map.t[idx(x, z)]; let tex = Gfx::tile_tex(t.kind, t.owner, t.var, t.reinforced); let (ua, ub) = cell_uv(tex); let (xf, zf) = (x as f32, z as f32); if let Some(river) = t.river() { // ---- river bed: sunken surface, banks where the ground or a wall begins let lava = river == Kind::Lava; let (la, lb) = cell_uv(if lava { T_LAVA } else { T_WATER } + (t.var as u32 & 3)); let a = amb_at(amb, xf + 0.5, zf + 0.5); let lc = if lava { col(vec3(1.0, 0.95, 0.9), 1.0) } else { col(Vec3::splat(0.25 + a * 0.9), 1.0) }; mb.quad( [vec3(xf, -LIQ_D, zf), vec3(xf + 1.0, -LIQ_D, zf), vec3(xf + 1.0, -LIQ_D, zf + 1.0), vec3(xf, -LIQ_D, zf + 1.0)], [vec2(la.x, la.y), vec2(lb.x, la.y), vec2(lb.x, lb.y), vec2(la.x, lb.y)], [lc; 4], [Vec3::Y; 4], ); let (fa, fb) = cell_uv(T_FACE_EARTH); for (d, &(dx, dz)) in DIR4.iter().enumerate() { if map.get(x + dx, z + dz).map_or(false, |o| o.river().is_some()) { continue; } // edge seen from inside the river: from a to b, facing back into this tile let (p, q) = match d { 0 => (vec3(xf, 0.0, zf), vec3(xf + 1.0, 0.0, zf)), 1 => (vec3(xf + 1.0, 0.0, zf), vec3(xf + 1.0, 0.0, zf + 1.0)), 2 => (vec3(xf + 1.0, 0.0, zf + 1.0), vec3(xf, 0.0, zf + 1.0)), _ => (vec3(xf, 0.0, zf + 1.0), vec3(xf, 0.0, zf)), }; let inward = vec3(-dx as f32, 0.0, -dz as f32); let bc = col(Vec3::splat(if lava { 0.7 } else { 0.18 + a * 0.5 }), 1.0); mb.quad( [p, q, q - Vec3::Y * LIQ_D, p - Vec3::Y * LIQ_D], [vec2(fa.x, fa.y), vec2(fb.x, fa.y), vec2(fb.x, fb.y), vec2(fa.x, fb.y)], [bc; 4], [inward; 4], ); } if t.bridge() { // deck: planks across the direction of the bridge, rails along it let run_x = |dx: i32| map.get(x + dx, z).map_or(false, |o| o.bridge() || (o.walkable() && !o.bridge())); let run_z = |dz: i32| map.get(x, z + dz).map_or(false, |o| o.bridge() || (o.walkable() && !o.bridge())); let along_x = (run_x(-1) as i32 + run_x(1) as i32) > (run_z(-1) as i32 + run_z(1) as i32); let (ba, bb) = cell_uv(tex); let dc = col(Vec3::splat(amb_at(amb, xf + 0.5, zf + 0.5).max(if lava { 0.6 } else { 0.0 })), 1.0); let uv = if along_x { [vec2(ba.x, ba.y), vec2(ba.x, bb.y), vec2(bb.x, bb.y), vec2(bb.x, ba.y)] } else { [vec2(ba.x, ba.y), vec2(bb.x, ba.y), vec2(bb.x, bb.y), vec2(ba.x, bb.y)] }; mb.quad([vec3(xf, DECK_Y, zf), vec3(xf + 1.0, DECK_Y, zf), vec3(xf + 1.0, DECK_Y, zf + 1.0), vec3(xf, DECK_Y, zf + 1.0)], uv, [dc; 4], [Vec3::Y; 4]); // deck edges above open river for (d, &(dx, dz)) in DIR4.iter().enumerate() { if !map.get(x + dx, z + dz).map_or(false, |o| o.liquid()) { continue; } let (p, q) = match d { 0 => (vec3(xf + 1.0, DECK_Y, zf), vec3(xf, DECK_Y, zf)), 1 => (vec3(xf + 1.0, DECK_Y, zf + 1.0), vec3(xf + 1.0, DECK_Y, zf)), 2 => (vec3(xf, DECK_Y, zf + 1.0), vec3(xf + 1.0, DECK_Y, zf + 1.0)), _ => (vec3(xf, DECK_Y, zf), vec3(xf, DECK_Y, zf + 1.0)), }; let out = vec3(dx as f32, 0.0, dz as f32); let ec = col(Vec3::splat(0.3), 1.0); mb.quad([p, q, q - Vec3::Y * 0.14, p - Vec3::Y * 0.14], [vec2(ba.x, ba.y), vec2(bb.x, ba.y), vec2(bb.x, ba.y + 0.002), vec2(ba.x, ba.y + 0.002)], [ec; 4], [out; 4]); ln.strip(p + out * 0.01, q + out * 0.01, Vec3::Y, OUTLINE_W, OUTLINE_C); } } continue; } if t.kind == Kind::Shard { let dc = col(Vec3::splat(1.0), 1.0); mb.cross(xf + 0.5, zf + 0.5, 0.7, 0.8, D_SHARD, dc); } 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], ); // outlines: north and west edge, only between two open tiles (each edge once) if !solid_at(map, x, z - 1) { ln.strip(vec3(xf, 0.0, zf), vec3(xf + 1.0, 0.0, zf), Vec3::Y, OUTLINE_W, OUTLINE_C); } if !solid_at(map, x - 1, z) { ln.strip(vec3(xf, 0.0, zf), vec3(xf, 0.0, zf + 1.0), Vec3::Y, OUTLINE_W, OUTLINE_C); } // room decor let dc = col(Vec3::splat(amb_at(amb, xf + 0.5, zf + 0.5)), 1.0); // wear and tear: grit and cracks on plain floors if matches!(t.kind, Kind::Floor) { let hsh = (x as u32).wrapping_mul(2654435761) ^ (z as u32).wrapping_mul(2246822519); let hsh = (hsh ^ (hsh >> 15)).wrapping_mul(2246822519); let decal = match hsh % 11 { 0 | 1 => Some(D_RUBBLE), 2 => Some(D_CRACK), _ => None, }; if let Some(d) = decal { let (a, b) = cell_uv(d); // rotate by a quarter turn for variety let uv = [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)]; let r = (hsh >> 8) as usize % 4; let uv = [uv[r % 4], uv[(r + 1) % 4], uv[(r + 2) % 4], uv[(r + 3) % 4]]; mb.quad([vec3(xf, 0.012, zf), vec3(xf + 1.0, 0.012, zf), vec3(xf + 1.0, 0.012, zf + 1.0), vec3(xf, 0.012, zf + 1.0)], uv, [dc; 4], [Vec3::Y; 4]); } } match t.kind { 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); } } } // training, torture, temple and library furniture and the beds: props.rs // each treasury tile shows what it holds (up to TREASURY_TILE) // (every third treasury tile holds a chest instead, see props.rs) Kind::Treasury if t.gold > 10 && !crate::props::chest_tile(x, z) => { let fill = (t.gold as f32 / TREASURY_TILE).clamp(0.0, 1.0); let s = 0.35 + 0.55 * fill; let sprite = [D_COINS, D_SILVER, D_RUBY, D_DIAMOND][(t.res & 3) as usize]; mb.cross(xf + 0.5, zf + 0.5, s, s * 0.8, sprite, 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.95 + 0.1; 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))), ); // outlines on the wall top: north/west always, south/east where no wall continues let tl = vec3(xf, WALL_H, zf); let tr = vec3(xf + 1.0, WALL_H, zf); let br = vec3(xf + 1.0, WALL_H, zf + 1.0); let bl = vec3(xf, WALL_H, zf + 1.0); ln.polyline(&edge_pts(tl, tr, n), Vec3::Y, OUTLINE_W, OUTLINE_C); ln.polyline(&edge_pts(tl, bl, n), Vec3::Y, OUTLINE_W, OUTLINE_C); if !solid_at(map, x, z + 1) { ln.polyline(&edge_pts(bl, br, n), Vec3::Y, OUTLINE_W, OUTLINE_C); } if !solid_at(map, x + 1, z) { ln.polyline(&edge_pts(tr, br, n), Vec3::Y, OUTLINE_W, OUTLINE_C); } // vertex budget: one draw call holds at most 40000 vertices if t.marked && mk.v.len() < 34000 { mark_block(&mut mk, map, x, z, n); } 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); // vertical outlines at both ends of the exposed face (tile borders and corners) for end in [a, b] { ln.polyline(&edge_pts(end, end + vec3(0.0, WALL_H, 0.0), n), out, OUTLINE_W, OUTLINE_C); } 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), ln.into_plain_mesh(), mk.into_plain_mesh()] } // 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, lines: Option, marks: 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, ncx: i32, // chunks per row / column for the current map size ncz: i32, pub outlines: bool, // tile outlines on/off wall_sub: usize, // wall face subdivisions (detail) pub max_lights: usize, pub render_div: u32, // 3D resolution divisor (pixel look) target: Option<(RenderTarget, u32, u32)>, // low resolution render target } // The 3D resolution setting refers to a window 1080 device pixels high: smaller windows use a // proportionally smaller divisor, so the 3D pixels keep their size relative to the window. // 1 (full resolution) stays 1. pub const RENDER_REF_HEIGHT: f32 = 1080.0; pub fn effective_div(setting: u32, height: f32) -> u32 { if setting <= 1 { return 1; } ((setting as f32 * height / RENDER_REF_HEIGHT).round() as u32).max(1) } 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) -> 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 => (t.gold / 50) as u64 | (t.res as u64) << 5, _ => 0, }; (t.kind as u64) | (t.owner as u64) << 5 | (t.marked as u64) << 7 | (t.reinforced as u64) << 8 | extra << 9 } None => 0xff, }; h = (h ^ v).wrapping_mul(1099511628211); } } h } impl R3d { pub fn new() -> R3d { R3d { lit: lit_material(), glow: glow_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS, render_div: 1, target: None } } // Changing the wall detail rebuilds all chunks pub fn set_wall_detail(&mut self, sub: usize) { if sub != self.wall_sub { self.wall_sub = sub; self.invalidate(); } } pub fn invalidate(&mut self) { for c in self.chunks.iter_mut() { c.sig = 0; } } // (Re)creates the chunk grid when the map size changes fn ensure_chunks(&mut self) { let ncx = (mw() as i32 + CHUNK - 1) / CHUNK; let ncz = (mh() as i32 + CHUNK - 1) / CHUNK; if ncx != self.ncx || ncz != self.ncz { self.ncx = ncx; self.ncz = ncz; self.chunks = (0..ncx * ncz).map(|_| Chunk { sig: 0, mesh: None, overlay: None, lines: None, marks: None }).collect(); } } // Rebuilds visible chunks whose content changed (off-screen chunks are updated when they appear) fn update_chunks(&mut self, g: &Game, gfx: &Gfx, m: &Mat4) { self.ensure_chunks(); let mut amb: Option> = None; for cz in 0..self.ncz { for cx in 0..self.ncx { if !Self::chunk_visible(m, cx, cz) { continue; } let sig = chunk_sig(&g.map, cx, cz); let ch = &mut self.chunks[(cz * self.ncx + cx) as usize]; if ch.sig != sig { let a = amb.get_or_insert_with(|| ambient_field(&g.map)); let [m, o, l, k] = build_chunk(&g.map, a, cx, cz, &gfx.atlas, self.wall_sub); ch.mesh = m; ch.overlay = o; ch.lines = l; ch.marks = k; 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.6, 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); for (k, &(px, py)) in g.map.portals.iter().enumerate() { let owned = g.map.portal_owned(k); 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 }, 20.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 rv in g.rivals.iter().filter(|r| r.alive) { let (rx, rz) = rv.centre(); let c = if rv.side == 2 { vec3(0.3, 1.0, 0.4) } else { vec3(0.35, 0.55, 1.0) }; push(vec3(rx, 1.4, rz), 7.0, c, 1.1, 50.0); } // glowing lava near the camera (every third tile) let r = (cam.zoom * 0.9) as i32; let (ccx, ccz) = (cam.tx as i32, cam.tz as i32); for z in (ccz - r).max(0)..(ccz + r).min(mh() as i32) { if z % 3 != 0 { continue; } for x in (ccx - r).max(0)..(ccx + r).min(mw() as i32) { if x % 3 == 0 && g.map.t[idx(x, z)].kind == Kind::Lava { let fl = 0.8 + 0.2 * (t * 2.0 + (x * 7 + z * 3) as f32).sin(); push(vec3(x as f32 + 0.5, 0.3, z as f32 + 0.5), 4.0, vec3(1.0, 0.45, 0.12), 0.9 * fl, 0.0); } } } let pulse = 0.5 + 0.5 * (t * 4.0).sin(); for &(ex, ey) in &g.planned_entries { push(vec3(ex as f32 + 0.5, 1.2, ey as f32 + 0.5), 5.0, vec3(1.0, 0.12, 0.08), 0.6 + 1.0 * pulse, 30.0); } for tw in &g.towers { if tw.kind == TowerKind::Magic { let (x, z) = tw.pos(); push(vec3(x, tower_height(tw) + 0.6, z), 4.5, vec3(0.75, 0.4, 1.0), 0.8 + tw.flash * 3.0, 10.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(self.max_lights.min(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; let m = cam.matrix(); self.update_chunks(g, gfx, &m); EYE.with(|e| e.set(cam.eye_dir())); // pixel look: render the world at 1/div resolution and scale it up by exactly div let d = crate::ui::dpr(); let (pw, ph) = ((screen_width() * d).round(), (screen_height() * d).round()); let div = effective_div(self.render_div, ph); let mut cam3 = cam.camera(); if div > 1 { let (tw, th) = ((pw / div as f32).ceil() as u32, (ph / div as f32).ceil() as u32); if self.target.as_ref().map_or(true, |(_, w, h)| *w != tw || *h != th) { let rt = render_target_ex(tw, th, RenderTargetParams { sample_count: 1, depth: true }); rt.texture.set_filter(FilterMode::Nearest); self.target = Some((rt, tw, th)); } let (rt, tw, th) = self.target.as_ref().unwrap(); cam3.render_target = Some(rt.clone()); cam3.aspect = Some(*tw as f32 / *th as f32); } else { self.target = None; } set_camera(&cam3); if div > 1 { clear_background(Color::from_rgba(4, 3, 5, 255)); } // ---- 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..self.ncz { for cx in 0..self.ncx { if !Self::chunk_visible(&m, cx, cz) { continue; } if let Some(mesh) = &self.chunks[(cz * self.ncx + 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); draw_towers_solid(g); crate::props::draw_props_solid(g, cam, t); // ---- unlit glow / overlays gl_use_material(&self.glow); for c in &self.chunks { if self.outlines { if let Some(l) = &c.lines { draw_mesh(l); } } if let Some(o) = &c.overlay { draw_mesh(o); } if let Some(k) = &c.marks { draw_mesh(k); } } 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); if let Some(f) = g.flag { draw_flag(f, t); } draw_overlay(&g.map, ov, self.wall_sub); draw_entry_warnings(g, t, self.wall_sub); // translucent glows and effects last (they write depth, see glow_material) draw_landmarks_glow(g, t); draw_towers_glow(g, t); draw_gear_glow(g, cam, t); crate::props::draw_props_glow(g, cam, t); for f in &g.fx { draw_fx(f, t); } gl_use_default_material(); crate::ui::begin_ui(); if let (true, Some((rt, tw, th))) = (div > 1, self.target.as_ref()) { let size = vec2((*tw * div) as f32, (*th * div) as f32); draw_texture_ex(&rt.texture, 0.0, 0.0, WHITE, DrawTextureParams { dest_size: Some(size), flip_y: true, ..Default::default() }); } // ---- 2D overlay in device pixels: 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 } }; // the camera's right and viewing direction on the ground (for the eight views) let rg = vec2(r.x, r.z).normalize_or_zero(); let tg = vec2(toward.x, toward.z).normalize_or_zero(); let mut mb = MB::new(); let sw = SPRITE_TEX_W; let sh = SPRITE_TEX_H; for e in &g.ents { if !e.alive || e.held { continue; } let w = sprite_size(e.kind); let h = w; let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; // which of the eight directions the unit shows (five views, three of them mirrored) let d = vec2(e.dir.cos(), e.dir.sin()); let ang = d.dot(rg).atan2(d.dot(tg)); let view = ((ang / std::f32::consts::FRAC_PI_4).round().abs() as u32).min(4); let mirror = ang < 0.0; let fwd = if mirror { -1.0 } else { 1.0 }; // screen direction the unit faces let ph = t * 10.0 + e.id as f32 * 1.7; let (mut sx, mut sy, mut lunge) = (1.0f32, 1.0f32, 0.0f32); let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; let mut rot = 0.0; if e.moving { // a little sway while walking; loads are carried leaning back rot = (e.anim * 12.0).sin() * 0.05; if e.carry > 0 || matches!(e.job, Job::Carry(_)) { rot += 0.12 * fwd; bob *= 0.6; } } else if e.working && !sleeping { match e.job { // claiming: stomping the ground, squashed on landing Job::Claim(_) | Job::Help(_) => { let k = (ph * 1.1).sin(); bob = k.max(0.0) * 0.13; if k < 0.0 { sy = 1.0 + k * 0.14; sx = 1.0 - k * 0.1; } } // digging: a swing at the wall Job::Dig(_) | Job::TearBridge(_) => { let k = (ph * 1.3).sin(); rot = -(k * 0.35).max(-0.12) * fwd; lunge = k.max(0.0) * 0.07; } // hammering Job::Reinforce(_) | Job::Repair(_) => { let k = (ph * 2.2).sin(); rot = -k.max(0.0) * 0.25 * fwd; bob = (ph * 2.2).cos().max(0.0) * 0.03; } // fighting: lunging at the enemy Job::Attack(_) | Job::AttackTower(_) | Job::AttackCore | Job::Torment(_) => { let k = (ph * 0.9).sin().max(0.0).powi(2); lunge = k * 0.14; rot = -k * 0.2 * fwd; } // training: quick punches Job::Train(_) => { let k = (ph * 1.6).sin(); lunge = k.max(0.0) * 0.08; rot = -k * 0.12 * fwd; bob = (ph * 3.2).sin().abs() * 0.03; } // eating: nodding over the food Job::Eat(_) => { sy = 1.0 - (ph * 0.8).sin().abs() * 0.1; rot = -(ph * 0.8).sin().abs() * 0.12 * fwd; } // reading and praying: a slow sway Job::Study(_) | Job::Pray(_) | Job::HealCore => rot = (t * 1.8 + e.id as f32).sin() * 0.07, _ => rot = (ph * 1.4).sin() * 0.15, } } else if !sleeping { // breathing sy = 1.0 + (t * 2.4 + e.id as f32).sin() * 0.025; } // flying creatures hover and flap (on their perch while sleeping) if flies(e.kind) && !sleeping { bob = 0.45 + (t * 7.0 + e.anim * 3.0).sin() * 0.06; sx *= 1.0 + (t * 14.0 + e.id as f32).sin() * 0.06; } else if sleeping { // asleep: lying in bed rot = std::f32::consts::FRAC_PI_2 * 0.9 * fwd; bob = 0.04; } // prisoners: lying unconscious, carried above an imp, or shaking in the torture chamber match e.captive { 1 => rot = std::f32::consts::FRAC_PI_2, 2 => { rot = std::f32::consts::FRAC_PI_2; bob = 0.4; } 3 => rot = (t * 25.0 + e.anim).sin() * 0.08, _ => {} } if e.chained { // pulling at the chains rot = (t * 3.0 + e.id as f32).sin() * 0.1; lunge = 0.0; } let white = e.flash > 0.07; let src = Gfx::sprite_cell_view(e.kind, white, e.level, if e.captive != 0 { 0 } else { view }); 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 mirror { std::mem::swap(&mut u0, &mut u1); } let a = amb(e.x, e.y) * if sleeping || e.captive == 1 { 0.6 } else { 1.0 }; let c = if white { [255, 255, 255, 255] } else if e.captive == 3 { col(vec3(a * 1.2, a * 0.55, a * 0.55), 1.0) } else { col(Vec3::splat(a), 1.0) }; // pulled towards the camera: unchanged on screen (orthographic), but walls behind // the unit no longer cut into the sprite let base = vec3(e.x, bob, e.y) + toward * 0.45 + r * (lunge * fwd); let (sr, cr) = rot.sin_cos(); let corner = |dx: f32, dy: f32| { let (dx, dy) = (dx * sx, dy * sy); let x = dx * cr - dy * sr; let y = dx * sr + dy * cr; base + r * x + Vec3::Y * y }; // the 12x12 cell (sprite with a one pixel border for the gear): the sprite's feet // (bottom of its 11th pixel row) stay on the ground mb.quad( [corner(-w * 0.6, h * 1.1), corner(w * 0.6, h * 1.1), corner(w * 0.6, -h * 0.1), corner(-w * 0.6, -h * 0.1)], [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) { use crate::ui::{ascii_bar, dpr, F, S, SMALL}; let d = dpr(); // projected positions are CSS pixels; the overlay is drawn in whole device pixels let proj = |p: Vec3| { let v = Cam::project(m, p) * d; vec2(v.x.floor(), v.y.floor()) }; let small = SMALL(); let cw = GW * small; // width of one small block character let px_per_tile = screen_height() / cam.zoom * d; for e in &g.ents { if !e.alive || e.held { continue; } let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Wyrm | EK::Gargoyle); let lift = if flies(e.kind) && !(matches!(e.job, Job::Sleep(_)) && !e.moving) { 0.45 } else { 0.0 }; let head = proj(vec3(e.x, lift + if big { 0.9 } else { 0.72 }, e.y)); // bar width in block characters, depending on the zoom let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8); if (e.hp < e.max_hp || e.hero) && e.captive == 0 { 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) }; let x = head.x - (cols as f32 * cw * 0.5).floor(); ascii_bar(gfx, x, head.y - GH * small, cols, f, c, small); } if e.guard { gfx.glyph(0x0F, head.x - (GW * small * 0.5).floor(), head.y - 2.0 * GH * small, small, Color::from_rgba(255, 220, 90, 255)); } // rank badge left of the health bar if e.level > 1 && px_per_tile >= 20.0 * F() as f32 { let k = 2.0 * F() as f32; let x = head.x - (cols as f32 * cw * 0.5).floor() - 12.0 * k; gfx.badge(x, head.y - GH * small - 4.0 * k, e.level, k); } if matches!(e.job, Job::Sleep(_)) && !e.moving { let k = (t * 1.5 + e.anim) % 1.0; let rise = (k * 14.0 * F() as f32).floor(); gfx.glyph(b'z', head.x + cw * 2.0, head.y - GH * S() - rise, S(), Color::new(0.8, 0.85, 1.0, 1.0 - k)); } if e.carry > 0 { let p = proj(vec3(e.x, 0.35, e.y)); gfx.glyph(0x0F, p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255)); } } for tw in &g.towers { if tw.hp < tw.max_hp() { let (x, z) = tw.pos(); let p = proj(vec3(x, tower_height(tw) + 0.9, z)); ascii_bar(gfx, p.x - (3.0 * cw).floor(), p.y, 6, tw.hp / tw.max_hp(), Color::from_rgba(255, 160, 60, 255), small); } } if g.gate_hp > 0.0 && g.gate_hp < GATE_HP { let (gx, gz) = g.gate_center(); let p = proj(vec3(gx, 2.4, gz)); let cols = 12; ascii_bar(gfx, p.x - (cols as f32 * GW * S() * 0.5).floor(), p.y, cols, g.gate_hp / GATE_HP, Color::from_rgba(120, 180, 255, 255), S()); } } } // ------------------------------------------------------------------ towers pub fn tower_height(t: &Tower) -> f32 { t.top() } // the colours of the knights' towers from the same model colours: brightness is stretched // (strong contrast between masonry and joints), warm parts (wood, bronze) turn royal blue fn knight_c(r: u8, g: u8, b: u8, f: f32) -> Color { let l = ((r as f32 + g as f32 + b as f32) / (3.0 * 255.0) * 1.7).min(1.0); let lerp = |a: f32, b: f32| a + (b - a) * l; let warm = r as i32 - b as i32 > 30; let (cr, cg, cb) = if warm { (lerp(20.0, 70.0), lerp(30.0, 110.0), lerp(80.0, 230.0)) } else { (lerp(28.0, 238.0), lerp(34.0, 232.0), lerp(72.0, 214.0)) }; Color::new(cr / 255.0 * f, cg / 255.0 * f, cb / 255.0 * f, 1.0) } fn shade_c(r: u8, g: u8, b: u8, f: f32) -> Color { Color::new(r as f32 / 255.0 * f, g as f32 / 255.0 * f, b as f32 / 255.0 * f, 1.0) } // size of a unit's sprite (same as in the billboards) fn sprite_size(k: EK) -> f32 { if matches!(k, EK::Chainbreaker | EK::Wyrm) { 1.0 } else if matches!(k, EK::Ogre | EK::Paladin | EK::Jailer | EK::Gargoyle) { 0.8 } else { 0.62 } } fn gear_shown(e: &Ent) -> bool { e.alive && !e.held && e.captive == 0 && e.level >= 3 && !(matches!(e.job, Job::Sleep(_)) && !e.moving) } // Lv 9+: a glowing ring above the head, Lv 10 also a faint aura at the feet fn draw_gear_glow(g: &Game, cam: &Cam, t: f32) { let toward = cam.eye_dir(); for e in g.ents.iter().filter(|e| gear_shown(e) && e.level >= 9) { let w = sprite_size(e.kind); let lift = if flies(e.kind) { 0.45 } else { 0.0 }; let base = vec3(e.x, lift, e.y) + toward * 0.5; let c = if e.hero { Color::new(0.8, 0.9, 1.0, 0.8) } else { Color::new(1.0, 0.8, 0.3, 0.8) }; let y = w * 1.12 + (t * 2.0 + e.anim).sin() * 0.02; for k in 0..8 { let a0 = k as f32 * 0.785 + t; let a1 = a0 + 0.785; line3(base + vec3(a0.cos() * 0.12, y, a0.sin() * 0.12), base + vec3(a1.cos() * 0.12, y, a1.sin() * 0.12), 0.025, c); } if e.level >= 10 { disc(vec3(e.x, 0.015, e.y), 0.42 + 0.03 * (t * 3.0).sin(), Color::new(c.r, c.g, c.b, 0.18), 16); } } } fn draw_towers_solid(g: &Game) { for tw in &g.towers { let (x, z) = tw.pos(); let c = vec3(x, 0.0, z); let h = tower_height(tw); let f = 0.75 + tw.flash * 1.5; // brightens briefly when firing // knight towers: bright limestone with dark blue shadows, wood and fittings in royal blue let col = |r: u8, g: u8, b: u8| if tw.hero { knight_c(r, g, b, f) } else { shade_c(r, g, b, f) }; let bx = |p: Vec3, s: Vec3, cl: Color| draw_cube(c + p, s, None, cl); match tw.kind { TowerKind::Arrow => { // stone plinth, masonry body with courses and arrow slits bx(vec3(0.0, 0.08, 0.0), vec3(0.86, 0.16, 0.86), col(86, 80, 76)); bx(vec3(0.0, h * 0.5, 0.0), vec3(0.66, h, 0.66), col(118, 108, 98)); let courses = (h / 0.22) as i32; for k in 1..courses { bx(vec3(0.0, k as f32 * 0.22, 0.0), vec3(0.68, 0.025, 0.68), col(80, 72, 66)); } for (dx, dz) in [(0.0f32, 0.34f32), (0.34, 0.0), (0.0, -0.34), (-0.34, 0.0)] { bx(vec3(dx, h * 0.62, dz), vec3(if dx == 0.0 { 0.06 } else { 0.02 }, 0.2, if dz == 0.0 { 0.06 } else { 0.02 }), col(24, 18, 16)); } // corbels and the wooden platform for (dx, dz) in [(-0.3f32, -0.3f32), (0.3, -0.3), (0.3, 0.3), (-0.3, 0.3)] { bx(vec3(dx * 1.2, h - 0.08, dz * 1.2), vec3(0.12, 0.12, 0.12), col(96, 88, 80)); } bx(vec3(0.0, h + 0.04, 0.0), vec3(0.94, 0.08, 0.94), col(120, 84, 50)); // battlements: merlons with gaps for k in 0..4 { let d = -0.39 + k as f32 * 0.26; for (px, pz) in [(d, -0.42), (d, 0.42), (-0.42, d), (0.42, d)] { bx(vec3(px, h + 0.17, pz), vec3(0.11, 0.18, 0.11), col(128, 118, 106)); } } // pennant and ladder bx(vec3(0.3, h + 0.45, 0.3), vec3(0.03, 0.7, 0.03), col(140, 140, 150)); bx(vec3(0.42, h + 0.7, 0.3), vec3(0.22, 0.12, 0.02), if tw.hero { shade_c(60, 90, 180, f) } else { shade_c(170, 30, 40, f) }); for k in 0..((h / 0.15) as i32) { bx(vec3(-0.2, 0.1 + k as f32 * 0.15, 0.35), vec3(0.2, 0.025, 0.03), col(110, 76, 44)); } bx(vec3(-0.29, h * 0.5, 0.35), vec3(0.025, h, 0.03), col(100, 70, 40)); bx(vec3(-0.11, h * 0.5, 0.35), vec3(0.025, h, 0.03), col(100, 70, 40)); } TowerKind::Cannon => { // squat round tower (stacked rings), iron bands, carriage, barrel, cannon balls for k in 0..4 { let r = 0.92 - k as f32 * 0.03; let y = h * (k as f32 + 0.5) / 4.0; bx(vec3(0.0, y, 0.0), vec3(r, h / 4.0 + 0.01, r * 0.82), col(104, 104, 112)); bx(vec3(0.0, y, 0.0), vec3(r * 0.82, h / 4.0 + 0.01, r), col(96, 96, 104)); } bx(vec3(0.0, h * 0.35, 0.0), vec3(0.94, 0.05, 0.94), col(54, 54, 60)); bx(vec3(0.0, h * 0.75, 0.0), vec3(0.9, 0.05, 0.9), col(54, 54, 60)); bx(vec3(0.0, h + 0.05, 0.0), vec3(0.62, 0.1, 0.62), col(80, 62, 44)); let (ax, az) = (tw.aim.cos(), tw.aim.sin()); // carriage cheeks beside the barrel for side in [-1.0f32, 1.0] { bx(vec3(-az * 0.14 * side, h + 0.17, ax * 0.14 * side), vec3(0.14, 0.16, 0.14), col(96, 70, 44)); } for k in 0..5 { let d = 0.0 + k as f32 * 0.13; let r = 0.2 - k as f32 * 0.012; bx(vec3(ax * d, h + 0.22, az * d), Vec3::splat(r), col(40, 40, 46)); } bx(vec3(ax * 0.58, h + 0.22, az * 0.58), Vec3::splat(0.2), col(70, 70, 78)); for (dx, dz, y) in [(0.3f32, -0.28f32, 0.0f32), (0.38, -0.2, 0.0), (0.34, -0.24, 0.08)] { bx(vec3(dx, h + 0.15 + y, dz), Vec3::splat(0.07), col(30, 30, 34)); } } TowerKind::Magic => { // rune-cut obelisk on a stepped base with buttresses (crystal and runes glow) bx(vec3(0.0, 0.06, 0.0), vec3(0.82, 0.12, 0.82), col(62, 56, 76)); bx(vec3(0.0, 0.17, 0.0), vec3(0.62, 0.1, 0.62), col(72, 64, 90)); bx(vec3(0.0, h * 0.5, 0.0), vec3(0.4, h, 0.4), col(84, 74, 104)); bx(vec3(0.0, h * 0.75, 0.0), vec3(0.32, h * 0.5, 0.46), col(92, 82, 114)); for (dx, dz) in [(0.25f32, 0.0f32), (-0.25, 0.0), (0.0, 0.25), (0.0, -0.25)] { bx(vec3(dx, h * 0.22, dz), vec3(if dx == 0.0 { 0.12 } else { 0.1 }, h * 0.44, if dz == 0.0 { 0.12 } else { 0.1 }), col(70, 62, 88)); } bx(vec3(0.0, h, 0.0), vec3(0.58, 0.08, 0.58), col(110, 96, 130)); for (dx, dz) in [(-0.24f32, -0.24f32), (0.24, -0.24), (0.24, 0.24), (-0.24, 0.24)] { bx(vec3(dx, h + 0.12, dz), vec3(0.06, 0.18, 0.06), col(120, 104, 140)); } } TowerKind::Rune => { // dwarven rune forge: broad granite foot, bronze bands, a tapering keep with // carved buttresses, an anvil crown and a forge hearth on top (runes glow) bx(vec3(0.0, 0.07, 0.0), vec3(0.96, 0.14, 0.96), col(70, 66, 70)); bx(vec3(0.0, 0.19, 0.0), vec3(0.84, 0.1, 0.84), col(84, 80, 86)); let steps = 5; for k in 0..steps { let y0 = 0.24 + (h - 0.24) * k as f32 / steps as f32; let hh = (h - 0.24) / steps as f32; let w = 0.72 - k as f32 * 0.05; bx(vec3(0.0, y0 + hh * 0.5, 0.0), vec3(w, hh + 0.005, w), col(96 - k as u8 * 2, 92 - k as u8 * 2, 98)); bx(vec3(0.0, y0 + 0.01, 0.0), vec3(w + 0.03, 0.035, w + 0.03), shade_c(176, 112, 56, f)); } for (dx, dz) in [(0.38f32, 0.38f32), (-0.38, 0.38), (0.38, -0.38), (-0.38, -0.38)] { bx(vec3(dx, h * 0.3, dz), vec3(0.14, h * 0.6, 0.14), col(78, 74, 80)); bx(vec3(dx, h * 0.6 + 0.05, dz), vec3(0.17, 0.08, 0.17), shade_c(176, 112, 56, f)); } // gate with a bronze door bx(vec3(0.0, 0.42, 0.37), vec3(0.26, 0.4, 0.04), shade_c(120, 76, 40, f)); bx(vec3(0.0, 0.64, 0.38), vec3(0.32, 0.06, 0.05), col(110, 106, 112)); // anvil crown and hearth bx(vec3(0.0, h + 0.05, 0.0), vec3(0.62, 0.1, 0.62), col(60, 58, 64)); bx(vec3(0.0, h + 0.16, 0.0), vec3(0.3, 0.12, 0.42), col(48, 48, 54)); bx(vec3(0.0, h + 0.25, 0.0), vec3(0.42, 0.08, 0.56), col(56, 56, 62)); for (dx, dz) in [(-0.27f32, -0.27f32), (0.27, -0.27), (0.27, 0.27), (-0.27, 0.27)] { bx(vec3(dx, h + 0.16, dz), vec3(0.08, 0.22, 0.08), shade_c(176, 112, 56, f)); } } } // level pips on the plinth for k in 0..tw.level { draw_cube(vec3(x - 0.2 + k as f32 * 0.2, 0.25, z + 0.46), Vec3::splat(0.09), None, Color::from_rgba(240, 200, 70, 255)); } } } fn draw_towers_glow(g: &Game, t: f32) { for (k, tw) in g.towers.iter().enumerate() { if tw.kind == TowerKind::Rune { // forge glow on the crown, runes on the keep and three rune stones circling it let (x, z) = tw.pos(); let h = tower_height(tw); let pulse = 1.0 + (t * 3.0 + k as f32).sin() * 0.12 + tw.flash * 2.5; draw_sphere(vec3(x, h + 0.34, z), 0.1 * pulse, None, Color::new(1.0, 0.75, 0.3, 0.95)); draw_sphere(vec3(x, h + 0.36, z), 0.24 * pulse, None, Color::new(1.0, 0.55, 0.15, 0.22)); for r in 0..4 { let y = 0.4 + (h - 0.5) * r as f32 / 4.0; let glow = 0.35 + 0.65 * ((t * 2.0 - r as f32 * 0.9 + k as f32).sin() * 0.5 + 0.5); let w = 0.72 - r as f32 * 0.05; for (dx, dz) in [(w * 0.5 + 0.01, 0.0f32), (-w * 0.5 - 0.01, 0.0), (0.0, w * 0.5 + 0.01), (0.0, -w * 0.5 - 0.01)] { draw_cube(vec3(x + dx, y + 0.1, z + dz), vec3(if dx == 0.0 { 0.12 } else { 0.02 }, 0.1, if dz == 0.0 { 0.12 } else { 0.02 }), None, Color::new(0.35, 0.8, 1.0, glow)); } } for n in 0..(2 + tw.level) { let a = t * 1.2 + n as f32 * std::f32::consts::TAU / (2 + tw.level) as f32; let p = vec3(x + a.cos() * 0.62, h * 0.7 + (t * 2.0 + n as f32).sin() * 0.08, z + a.sin() * 0.62); draw_cube(p, vec3(0.1, 0.12, 0.1), None, Color::new(0.3, 0.75, 1.0, 0.9)); draw_sphere(p, 0.12, None, Color::new(0.3, 0.7, 1.0, 0.2)); } continue; } if tw.kind != TowerKind::Magic { continue; } let (x, z) = tw.pos(); let c = vec3(x, tower_height(tw) + 0.45 + (t * 2.0 + k as f32).sin() * 0.06, z); let pulse = 1.0 + (t * 4.0 + k as f32).sin() * 0.1 + tw.flash * 2.0; draw_sphere(c, 0.16 * pulse, None, Color::new(0.85, 0.5, 1.0, 0.95)); draw_sphere(c, 0.3 * pulse, None, Color::new(0.7, 0.3, 1.0, 0.25)); // runes on the four faces of the obelisk, glowing one after another let h = tower_height(tw); for r in 0..3 { let y = h * (0.35 + r as f32 * 0.22); let glow = 0.4 + 0.6 * ((t * 2.5 - r as f32 * 0.8 + k as f32).sin() * 0.5 + 0.5); for (dx, dz) in [(0.21f32, 0.0f32), (-0.21, 0.0), (0.0, 0.21), (0.0, -0.21)] { draw_cube(vec3(x + dx, y, z + dz), vec3(if dx == 0.0 { 0.1 } else { 0.02 }, 0.08, if dz == 0.0 { 0.1 } else { 0.02 }), None, Color::new(0.8, 0.45, 1.0, glow)); } } } } // ------------------------------------------------------------------ landmarks, fx, overlay // Camera direction for camera-facing line quads (set once per frame) thread_local! { static EYE: std::cell::Cell = std::cell::Cell::new(Vec3::Y); } // Thick 3D line as a camera-facing quad (GL lines don't work with custom materials) pub fn line3(a: Vec3, b: Vec3, w: f32, color: Color) { let eye = EYE.with(|e| e.get()); let side = (b - a).cross(eye).normalize_or_zero() * (w * 0.5); let c = [(color.r * 255.0) as u8, (color.g * 255.0) as u8, (color.b * 255.0) as u8, (color.a * 255.0) as u8]; let v = |p: Vec3| Vertex { position: p, uv: Vec2::ZERO, color: c, normal: Vec4::Y }; draw_mesh(&Mesh { vertices: vec![v(a - side), v(a + side), v(b + side), v(b - side)], indices: vec![0, 1, 2, 0, 2, 3], texture: None }); } pub 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; line3(c + vec3(a0.cos() * r, 0.0, a0.sin() * r), c + vec3(a1.cos() * r, 0.0, a1.sin() * r), 0.035, color); } } fn draw_landmarks_solid(g: &Game, t: f32) { // soul core: stepped obsidian pedestal with four spikes holding the orb 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.08, 0.0), vec3(2.8, 0.16, 2.8), None, Color::from_rgba(34, 14, 18, 255)); draw_cube(c + vec3(0.0, 0.22, 0.0), vec3(2.1, 0.14, 2.1), None, Color::from_rgba(48, 18, 24, 255)); draw_cube(c + vec3(0.0, 0.35, 0.0), vec3(1.3, 0.14, 1.3), None, Color::from_rgba(66, 24, 30, 255)); for (k, (dx, dz)) in [(-1.15f32, -1.15f32), (1.15, -1.15), (1.15, 1.15), (-1.15, 1.15)].iter().enumerate() { let b = c + vec3(*dx, 0.0, *dz); draw_cube(b + vec3(0.0, 0.45, 0.0), vec3(0.26, 0.9, 0.26), None, Color::from_rgba(28, 20, 26, 255)); draw_cube(b + vec3(0.0, 1.05, 0.0), vec3(0.16, 0.3, 0.16), None, Color::from_rgba(40, 28, 36, 255)); draw_cube(b + vec3(0.0, 1.26, 0.0), vec3(0.08, 0.14, 0.08), None, Color::from_rgba(180, 140, 60, 255)); // claws reaching for the orb line3(b + vec3(0.0, 1.0, 0.0), c + vec3(dx * 0.45, 1.55 - (k % 2) as f32 * 0.1, dz * 0.45), 0.07, Color::from_rgba(36, 24, 30, 255)); } // rival masters' cores: obsidian pedestal (the orb glows in the glow pass) for rv in g.rivals.iter().filter(|r| r.alive) { let (rx, rz) = rv.centre(); let c = vec3(rx, 0.0, rz); draw_cube(c + vec3(0.0, 0.08, 0.0), vec3(2.6, 0.16, 2.6), None, Color::from_rgba(24, 22, 30, 255)); draw_cube(c + vec3(0.0, 0.22, 0.0), vec3(1.8, 0.14, 1.8), None, Color::from_rgba(34, 32, 42, 255)); for (dx, dz) in [(-1.1f32, -1.1f32), (1.1, -1.1), (1.1, 1.1), (-1.1, 1.1)] { draw_cube(c + vec3(dx, 0.5, dz), vec3(0.22, 1.0, 0.22), None, Color::from_rgba(30, 28, 38, 255)); } } // portal circles: eight standing stones of different height for &(px, py) in &g.map.portals { let pc = vec3(px as f32 + 1.5, 0.0, py as f32 + 1.5); for k in 0..8 { let a = k as f32 * 0.785 + 0.39; let h = 0.8 + ((k * 5 + px as usize) % 4) as f32 * 0.15; let p = pc + vec3(a.cos() * 1.5, h * 0.5, a.sin() * 1.5); draw_cube(p, vec3(0.28, h, 0.28), None, Color::from_rgba(52 + (k * 3) as u8, 44, 62, 255)); draw_cube(p + vec3(0.0, h * 0.5 + 0.04, 0.0), vec3(0.22, 0.08, 0.22), None, Color::from_rgba(70, 60, 82, 255)); } } // gate pillars let (gx, gz) = g.gate_center(); if g.gate_hp > 0.0 { let stone = Color::from_rgba(150, 155, 172, 255); let dark = Color::from_rgba(112, 116, 132, 255); let gold = Color::from_rgba(230, 200, 100, 255); let iron = Color::from_rgba(56, 58, 66, 255); for side in [-1.1f32, 1.1] { draw_cube(vec3(gx + side, 0.12, gz), vec3(0.6, 0.24, 0.6), None, dark); // plinth draw_cube(vec3(gx + side, 1.0, gz), vec3(0.45, 1.8, 0.45), None, stone); for y in [0.6f32, 1.2] { draw_cube(vec3(gx + side, y, gz), vec3(0.48, 0.05, 0.48), None, dark); // bands } draw_cube(vec3(gx + side, 1.92, gz), vec3(0.56, 0.12, 0.56), None, dark); // capital // banner of the order: white with a golden sun draw_cube(vec3(gx + side, 1.35, gz + 0.25), vec3(0.32, 0.7, 0.02), None, Color::from_rgba(232, 234, 242, 255)); draw_cube(vec3(gx + side, 1.45, gz + 0.265), vec3(0.14, 0.14, 0.01), None, gold); draw_cube(vec3(gx + side, 1.02, gz + 0.26), vec3(0.32, 0.05, 0.02), None, Color::from_rgba(60, 90, 170, 255)); } // stepped arch with a keystone draw_cube(vec3(gx, 2.1, gz), vec3(2.8, 0.3, 0.55), None, stone); draw_cube(vec3(gx, 1.88, gz), vec3(1.7, 0.14, 0.5), None, dark); draw_cube(vec3(gx, 2.32, gz), vec3(2.2, 0.14, 0.5), None, dark); draw_cube(vec3(gx, 2.2, gz), vec3(0.34, 0.5, 0.58), None, gold); // portcullis (iron bars) in the opening, half raised for k in 0..5 { draw_cube(vec3(gx - 0.6 + k as f32 * 0.3, 1.45, gz), vec3(0.05, 0.85, 0.05), None, iron); } for y in [1.15f32, 1.5, 1.8] { draw_cube(vec3(gx, y, gz), vec3(1.7, 0.05, 0.05), None, iron); } } 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: pulsing orb (faster and more unsteady when hurt), energy threads to the // spikes, runes circling around it let (cx, cy) = g.map.core; let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); let beat = t * (2.0 + (1.0 - hp) * 6.0); let pulse = 1.0 + beat.sin() * 0.06 + if hp < 0.4 { (t * 31.0).sin() * 0.03 } else { 0.0 }; let base = vec3(cx as f32 + 1.5, 0.0, cy as f32 + 1.5); let c = base + vec3(0.0, 1.6 + (t * 1.3).sin() * 0.06, 0.0); draw_sphere(c, 0.62 * pulse, None, Color::new(0.4 + 0.5 * hp, 0.04, 0.07, 1.0)); draw_sphere(c, 0.32 * pulse, None, Color::new(1.0, 0.5 + 0.3 * hp, 0.4, 0.55)); draw_sphere(c, 0.85 * pulse, None, Color::new(1.0, 0.2, 0.15, 0.22)); draw_sphere(c, 1.15 * pulse, None, Color::new(1.0, 0.1, 0.1, 0.08)); draw_sphere(c + vec3(-0.18, 0.2, 0.18), 0.13, None, Color::new(1.0, 0.85, 0.7, 0.8)); for (k, (dx, dz)) in [(-1.15f32, -1.15f32), (1.15, -1.15), (1.15, 1.15), (-1.15, 1.15)].iter().enumerate() { let tip = base + vec3(*dx, 1.33, *dz); let flick = 0.5 + 0.5 * (t * 5.0 + k as f32 * 1.7).sin(); let mid = (tip + c) * 0.5 + vec3((t * 7.0 + k as f32).sin() * 0.08, 0.15, (t * 6.0 + k as f32).cos() * 0.08); let col = Color::new(1.0, 0.25 + 0.3 * flick, 0.2, 0.35 + 0.45 * flick * hp.max(0.3)); line3(tip, mid, 0.04, col); line3(mid, c, 0.04, col); draw_sphere(tip + vec3(0.0, 0.05, 0.0), 0.07, None, Color::new(1.0, 0.6, 0.3, 0.6 + 0.4 * flick)); } for k in 0..6 { let a = t * 0.7 + k as f32 * 1.047; let p = c + vec3(a.cos() * 1.05, (t * 1.1 + k as f32).sin() * 0.25, a.sin() * 1.05); draw_cube(p, vec3(0.1, 0.16, 0.04), None, Color::new(1.0, 0.7, 0.3, 0.85)); } // rival masters' cores in their colours for rv in g.rivals.iter().filter(|r| r.alive) { let (rx, rz) = rv.centre(); let hp = (rv.hp / crate::rival::RIVAL_CORE_HP).clamp(0.0, 1.0); let col = if rv.side == 2 { vec3(0.3, 0.95, 0.4) } else { vec3(0.35, 0.55, 1.0) }; let pulse = 1.0 + (t * (2.0 + (1.0 - hp) * 6.0) + rv.side as f32).sin() * 0.06; let c = vec3(rx, 1.3 + (t * 1.2).sin() * 0.05, rz); draw_sphere(c, 0.55 * pulse, None, Color::new(col.x * (0.4 + 0.6 * hp), col.y * (0.4 + 0.6 * hp), col.z * (0.4 + 0.6 * hp), 1.0)); draw_sphere(c, 0.85 * pulse, None, Color::new(col.x, col.y, col.z, 0.22)); for k in 0..4 { let a = t * 0.8 + k as f32 * 1.57; draw_cube(c + vec3(a.cos() * 0.9, (t + k as f32).sin() * 0.2, a.sin() * 0.9), vec3(0.08, 0.14, 0.04), None, Color::new(col.x, col.y, col.z, 0.8)); } } // portals: a circle of standing stones with runes, the vortex, a column of light, sparks for (pk, &(px, py)) in g.map.portals.iter().enumerate() { let owned = g.map.portal_owned(pk); 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))); } } // runes on the stones for k in 0..8 { let a = k as f32 * 0.785 + 0.39; let glow = 0.5 + 0.5 * (t * 2.0 + k as f32 * 0.8).sin(); let h = 0.45 + (k % 3) as f32 * 0.12; let p = pc + vec3(a.cos() * 1.4, h, a.sin() * 1.4); draw_cube(p, vec3(0.08, 0.14, 0.08), None, Color::new(arm.r, arm.g, arm.b, if owned { 0.4 + 0.6 * glow } else { 0.25 })); } if owned { // column of light and rising sparks for k in 0..5 { let y = 0.4 + k as f32 * 0.5; draw_sphere(pc + vec3(0.0, y, 0.0), 0.35 - k as f32 * 0.05, None, Color::new(0.75, 0.45, 1.0, 0.1)); } for k in 0..10 { let ph = (t * 0.6 + k as f32 * 0.37).fract(); let a = k as f32 * 2.4 + t * 0.5; let p = pc + vec3(a.cos() * (0.3 + 0.5 * ph), 0.2 + ph * 2.4, a.sin() * (0.3 + 0.5 * ph)); draw_cube(p, Vec3::splat(0.05), None, Color::new(0.9, 0.7, 1.0, 1.0 - ph)); } } } // 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; line3(gc, gc + vec3(a.cos() * 1.0, a.sin() * 0.9, 0.0), 0.05, 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 | EK::Jailer | EK::Chainbreaker) { 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.captive != 0 { Color::new(0.6, 0.6, 0.6, 0.6) } else if e.dwarf() { Color::new(0.85, 0.55, 0.25, 0.85) } else if e.rival() { if e.side == 2 { Color::new(0.35, 0.95, 0.4, 0.85) } else { Color::new(0.4, 0.6, 1.0, 0.85) } } else 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, f.h, f.y); match f.kind { 0 => { let from = vec3(f.x, f.h, f.y); let to = vec3(f.x2, f.h2, f.y2); let head = from.lerp(to, p); let tail = from.lerp(to, (p - 0.35 * f.size.min(1.5)).max(0.0)); line3(tail, head, 0.06 * f.size, a(0.8)); draw_sphere(head, 0.08 * f.size, None, c); if f.size > 1.35 { // experienced shooters: a glowing halo around the projectile draw_sphere(head, 0.16 * f.size, None, a(0.25)); } } 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); line3(prev, next, 0.12, 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) * f.size; let n = if f.size > 1.35 { 10 } else { 6 }; for k in 0..n { let ang = k as f32 * std::f32::consts::TAU / n as f32; line3(at, at + vec3(ang.cos() * r, (k as f32 - n as f32 * 0.5 + 0.5) * 0.06 * f.size, ang.sin() * r), 0.04 * f.size, a(1.0 - p)); } if f.size > 1.6 { disc(vec3(f.x, 0.03, f.y), r * 1.3, a(0.3 * (1.0 - p)), 12); } } 3 => draw_sphere(vec3(f.x, 0.3, f.y), (0.2 + 0.5 * p) * f.size, 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 { line3(p[k], p[(k + 1) % 4], 0.06, c); } } // Expected hero entry points: the walls around them glow red and pulse fn draw_entry_warnings(g: &Game, t: f32, n: usize) { if g.planned_entries.is_empty() { return; } let p = 0.5 + 0.5 * (t * 4.0).sin(); let fill = [255, 40, 30, (50.0 + 90.0 * p) as u8]; let line = [255, 90, 60, (120.0 + 120.0 * p) as u8]; let mut mb = MB::new(); for &(ex, ey) in &g.planned_entries { for dy in -2..=2 { for dx in -2..=2 { let (x, y) = (ex + dx, ey + dy); let Some(tile) = g.map.get(x, y) else { continue }; // walls of the realm's border near the entry point (not rock) let border = DIR4.iter().any(|&(ax, ay)| g.map.get(x + ax, y + ay).map_or(false, |o| o.owner == Owner::Player && !o.solid())); if tile.diggable() && border && dx.abs() + dy.abs() <= 3 && mb.v.len() < 30000 { block_shell(&mut mb, &g.map, x, y, n, fill, line, 1.0, 0.04); } } } } if let Some(m) = mb.into_plain_mesh() { draw_mesh(&m); } } // Hover feedback: wall blocks get a faint version of the dig-mark box (in the tool colour), // floor tiles an outline. While dragging a dig rectangle, every block inside is tinted. fn draw_overlay(map: &Map, ov: &Overlay, n: usize) { const MAX_BOXES: usize = 150; // keeps the per-frame mesh within one draw call let c = ov.color; let fill = [(c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8, 28]; let line = [(c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8, 110]; let solid = |x: i32, z: i32| map.get(x, z).map_or(false, |t| t.solid()); let mut mb = MB::new(); 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 mut boxes = 0; if ov.high { 'outer: for z in az..=bz { for x in ax..=bx { if map.get(x, z).map_or(false, |t| t.diggable()) { if boxes >= MAX_BOXES || mb.v.len() > 30000 { break 'outer; } block_shell(&mut mb, map, x, z, n, fill, line, 0.6, 0.05); boxes += 1; } } } } let y = if ov.high { WALL_H + 0.18 } 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 { if solid(x, z) { block_shell(&mut mb, map, x, z, n, fill, line, 0.6, 0.05); } else { outline(x as f32, z as f32, x as f32 + 1.0, z as f32 + 1.0, 0.04, ov.color); } } if let Some(m) = mb.into_plain_mesh() { draw_mesh(&m); } 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 if tile.reinforced => [120, 70, 70, 255], Kind::Earth => [86, 60, 40, 255], Kind::Gold => [220, 180, 50, 255], Kind::Silver => [190, 195, 210, 255], Kind::Ruby => [220, 40, 70, 255], Kind::Diamond => [170, 235, 255, 255], Kind::Floor => match tile.owner { Owner::Player => [140, 60, 80, 255], Owner::Hero => [170, 175, 190, 255], Owner::Rival1 => [70, 150, 80, 255], Owner::Rival2 => [70, 100, 180, 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 => match tile.owner { Owner::Rival1 => [90, 240, 110, 255], Owner::Rival2 => [100, 150, 255, 255], _ => [255, 60, 60, 255], }, Kind::Portal => [190, 90, 240, 255], Kind::Gate => [230, 230, 255, 255], Kind::Torture => [120, 40, 40, 255], Kind::Tower => [255, 140, 60, 255], Kind::Temple => [200, 110, 50, 255], Kind::Water => [40, 80, 150, 255], Kind::Lava => [250, 110, 30, 255], Kind::Bridge => [150, 110, 60, 255], Kind::LavaBridge => [130, 120, 115, 255], Kind::Shard => [255, 60, 120, 255], Kind::Aerie => [170, 140, 90, 255], Kind::Steel => [150, 160, 180, 255], Kind::ManaSpring => [90, 230, 255, 255], Kind::RuneChest => [80, 200, 255, 255], } } #[cfg(test)] mod tests { use super::*; #[test] fn pixel_size_follows_the_window() { assert_eq!(effective_div(1, 400.0), 1); assert_eq!(effective_div(4, 1080.0), 4); assert_eq!(effective_div(4, 540.0), 2); assert_eq!(effective_div(4, 2160.0), 8); assert_eq!(effective_div(16, 200.0), 3); assert_eq!(effective_div(2, 100.0), 1); } }