/* ========================================================= * REPRTERRA WEB — render.js * Isometric canvas renderer. Everything hand-drawn, no assets. * ========================================================= */ 'use strict'; window.RTS = window.RTS || {}; RTS.render = (function () { const U = RTS.util; const C = RTS.CONFIG; const W = C.WORLD.W, H = C.WORLD.H; const TW = C.WORLD.TILE_W, TH = C.WORLD.TILE_H; const TW2 = TW / 2, TH2 = TH / 2; let cv = null, ctx = null; let vw = 0, vh = 0, dpr = 1; const S = {}; S.cam = { x: W / 2, y: H / 2, zoom: 1 }; S.init = function (canvas) { cv = canvas; ctx = cv.getContext('2d'); resize(); }; S.resize = resize; function resize() { if (!cv) return; dpr = Math.min(window.devicePixelRatio || 1, 2); vw = cv.clientWidth; vh = cv.clientHeight; cv.width = Math.floor(vw * dpr); cv.height = Math.floor(vh * dpr); } function projX(wx, wy) { return (wx - wy) * TW2 * S.cam.zoom; } function projY(wx, wy) { return (wx + wy) * TH2 * S.cam.zoom; } S.worldToScreen = function (wx, wy) { return { x: projX(wx, wy) - projX(S.cam.x, S.cam.y) + vw / 2, y: projY(wx, wy) - projY(S.cam.x, S.cam.y) + vh / 2, }; }; S.screenToWorld = function (sx, sy) { const z = S.cam.zoom; const a = (sx - vw / 2) / (TW2 * z) + (S.cam.x - S.cam.y); const b = (sy - vh / 2) / (TH2 * z) + (S.cam.x + S.cam.y); return { x: (a + b) / 2, y: (b - a) / 2 }; }; S.clampCam = function () { S.cam.x = U.clamp(S.cam.x, 2, W - 2); S.cam.y = U.clamp(S.cam.y, 2, H - 2); S.cam.zoom = U.clamp(S.cam.zoom, 0.45, 2.4); }; // ========================================================= function diamond(sx, sy, w, h) { ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + w, sy + h); ctx.lineTo(sx, sy + 2 * h); ctx.lineTo(sx - w, sy + h); ctx.closePath(); } // iso box: center world (cx,cy), half-size hf (tiles), height px function isoBox(cx, cy, hf, hpx, cTop, cL, cR, lift) { const z = S.cam.zoom; const p = (dx, dy) => S.worldToScreen(cx + dx, cy + dy); const A = p(-hf, -hf), B = p(hf, -hf), Cp = p(hf, hf), D = p(-hf, hf); const L = (lift || 0) * S.cam.zoom; // left wall (D->C) ctx.fillStyle = cL; ctx.beginPath(); ctx.moveTo(D.x, D.y - L); ctx.lineTo(Cp.x, Cp.y - L); ctx.lineTo(Cp.x, Cp.y - L - hpx * z); ctx.lineTo(D.x, D.y - L - hpx * z); ctx.closePath(); ctx.fill(); // right wall (C->B) ctx.fillStyle = cR; ctx.beginPath(); ctx.moveTo(Cp.x, Cp.y - L); ctx.lineTo(B.x, B.y - L); ctx.lineTo(B.x, B.y - L - hpx * z); ctx.lineTo(Cp.x, Cp.y - L - hpx * z); ctx.closePath(); ctx.fill(); // top ctx.fillStyle = cTop; ctx.beginPath(); ctx.moveTo(A.x, A.y - L - hpx * z); ctx.lineTo(B.x, B.y - L - hpx * z); ctx.lineTo(Cp.x, Cp.y - L - hpx * z); ctx.lineTo(D.x, D.y - L - hpx * z); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1; ctx.stroke(); return { A, B, C: Cp, D, topY: A.y - L - hpx * z }; } // ========================================================= // MAIN DRAW // ========================================================= S.draw = function (st, ui, timeSec) { if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, vw, vh); // screen shake let shx = 0, shy = 0; if (st.shakeT > 0) { shx = (Math.random() - 0.5) * st.shakeMag * st.shakeT * 2; shy = (Math.random() - 0.5) * st.shakeMag * st.shakeT * 2; ctx.translate(shx, shy); } drawTerrain(st, timeSec); drawDecals(st); if (ui.placing) drawPlacementGuides(st, ui); // ---- gather depth-sorted drawables ---- const items = []; const T = st.world.tiles; for (let i = 0; i < W * H; i++) { if (T.tree[i] > 0 || T.rock[i] > 0) { const x = i % W, y = (i / W) | 0; if (st.vis[i] === 0) continue; items.push({ d: 0, x: x + 0.5, y: y + 0.5, t: T.tree[i] > 0 ? 'tree' : 'rock', v: T.tree[i] > 0 ? T.tree[i] : T.rock[i], vx: x, vy: y }); } } for (const b of st.buildings) { if (b.dead) continue; items.push({ d: 1, x: b.x, y: b.y + b.size * 0.5, t: 'bld', b }); } for (const u of st.units) { if (u.dead) continue; const ti = (u.y | 0) * W + (u.x | 0); if (U.inBounds(u.x | 0, u.y | 0, W, H) && st.vis[ti] === 0) continue; items.push({ d: 2, x: u.x, y: u.y, t: 'unit', u }); } for (const d of st.dinos) { if (d.dead) continue; const ti = (d.y | 0) * W + (d.x | 0); if (U.inBounds(d.x | 0, d.y | 0, W, H) && st.vis[ti] === 0) continue; items.push({ d: 3, x: d.x, y: d.y + (d.flying ? 0.01 : 0), t: 'dino', d }); } for (const e of st.eggs || []) { const ti = (e.y | 0) * W + (e.x | 0); if (U.inBounds(e.x | 0, e.y | 0, W, H) && st.vis[ti] === 0) continue; items.push({ d: 2, x: e.x, y: e.y + 0.02, t: 'egg', e }); } items.sort((a, b) => (a.y - b.y) || (a.x - b.x) || (a.d - b.d)); for (const it of items) { if (it.t === 'tree') drawTree(it.x, it.y, it.v, timeSec, it.vx, it.vy); else if (it.t === 'rock') drawRock(it.x, it.y, it.v); else if (it.t === 'bld') drawBuilding(st, it.b, ui, timeSec); else if (it.t === 'unit') drawUnit(it.u, timeSec); else if (it.t === 'egg') drawEgg(it.e, timeSec); else drawDino(it.d, timeSec); } drawProjectiles(st, timeSec); drawParticles(st); drawSelectionOverlays(st, ui); if (ui.placing) drawGhost(st, ui); drawFog(st); drawNightTint(st); drawTexts(st); // wave warning arrows if (st.warnT > 0 && !st.over) drawWarningArrows(st, timeSec); }; // --------------------------------------------------------- function drawTerrain(st, timeSec) { const T = st.world.tiles; const z = S.cam.zoom; // visible world bounds (4 screen corners -> world) const c0 = S.screenToWorld(0, 0), c1 = S.screenToWorld(vw, 0); const c2 = S.screenToWorld(0, vh), c3 = S.screenToWorld(vw, vh); const minX = Math.max(0, Math.floor(Math.min(c0.x, c1.x, c2.x, c3.x)) - 1); const maxX = Math.min(W - 1, Math.ceil(Math.max(c0.x, c1.x, c2.x, c3.x)) + 1); const minY = Math.max(0, Math.floor(Math.min(c0.y, c1.y, c2.y, c3.y)) - 1); const maxY = Math.min(H - 1, Math.ceil(Math.max(c0.y, c1.y, c2.y, c3.y)) + 2); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { const i = y * W + x; const p = S.worldToScreen(x + 0.5, y + 0.5); if (p.x < -TW || p.x > vw + TW || p.y < -TH * 2 || p.y > vh + TH * 2) continue; const t = T.terrain[i]; const v = T.variant[i]; let col; if (t === 3) { const wob = Math.sin(timeSec * 1.5 + v * 20 + x * 0.7 + y * 0.9) * 6; col = 'hsl(205,45%,' + (26 + wob) + '%)'; } else if (t === 2) col = shade('#8a795a', v * 8 - 4); else if (t === 1) col = shade('#55743a', v * 10 - 5); else col = shade('#5f8142', v * 10 - 5); diamond(p.x, p.y, TW2 * z, TH2 * z); ctx.fillStyle = col; ctx.fill(); if (v > 0.62 && t !== 3) { // speckle detail ctx.fillStyle = 'rgba(255,255,255,0.04)'; ctx.fill(); } } } } function shade(hex, amt) { const n = parseInt(hex.slice(1), 16); let r = (n >> 16) + amt, g = ((n >> 8) & 255) + amt, b = (n & 255) + amt; r = U.clamp(r, 0, 255); g = U.clamp(g, 0, 255); b = U.clamp(b, 0, 255); return 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')'; } // --------------------------------------------------------- function drawDecals(st) { for (const d of st.decals) { const p = S.worldToScreen(d.x, d.y); ctx.globalAlpha = d.alpha; ctx.fillStyle = d.color; if (d.rubble) { const z = S.cam.zoom * (d.size || 1); ctx.beginPath(); ctx.ellipse(p.x, p.y, 18 * z, 9 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#3a352c'; for (let k = 0; k < 3; k++) { ctx.fillRect(p.x - 10 * z + k * 9 * z, p.y - 4 * z + (k % 2) * 4 * z, 6 * z, 3 * z); } } else { const z = S.cam.zoom; ctx.beginPath(); ctx.ellipse(p.x, p.y, d.r * TW2 * z, d.r * TH2 * z, 0, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } } // --------------------------------------------------------- function drawTree(x, y, v, timeSec, tx, ty) { const p = S.worldToScreen(x, y); const z = S.cam.zoom; const s = (v / 8); const sway = Math.sin(timeSec * 1.2 + x * 3 + y * 2) * 1.5; // shadow ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(p.x, p.y + 2 * z, 13 * s * z, 6 * s * z, 0, 0, Math.PI * 2); ctx.fill(); // trunk ctx.fillStyle = '#5d452c'; ctx.fillRect(p.x - 2 * z, p.y - 14 * s * z, 4 * z, 14 * s * z); // canopy blobs const cy = p.y - 16 * s * z; ctx.fillStyle = '#39602f'; ctx.beginPath(); ctx.ellipse(p.x + sway * z, cy - 6 * s * z, 13 * s * z, 11 * s * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#4a7839'; ctx.beginPath(); ctx.ellipse(p.x - 4 * z + sway * z, cy - 10 * s * z, 8 * s * z, 7 * s * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#588c44'; ctx.beginPath(); ctx.ellipse(p.x + 5 * z + sway * z, cy - 4 * s * z, 6.5 * s * z, 5.5 * s * z, 0, 0, Math.PI * 2); ctx.fill(); } function drawRock(x, y, v) { const p = S.worldToScreen(x, y); const z = S.cam.zoom; const s = (v / 10) * 0.8 + 0.35; ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(p.x, p.y + 1 * z, 15 * s * z, 7 * s * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#7d8288'; ctx.beginPath(); ctx.moveTo(p.x - 13 * s * z, p.y); ctx.lineTo(p.x - 7 * s * z, p.y - 12 * s * z); ctx.lineTo(p.x + 2 * s * z, p.y - 14 * s * z); ctx.lineTo(p.x + 11 * s * z, p.y - 6 * s * z); ctx.lineTo(p.x + 12 * s * z, p.y); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#98a0a8'; ctx.beginPath(); ctx.moveTo(p.x - 7 * s * z, p.y - 12 * s * z); ctx.lineTo(p.x + 2 * s * z, p.y - 14 * s * z); ctx.lineTo(p.x + 5 * s * z, p.y - 8 * s * z); ctx.lineTo(p.x - 3 * s * z, p.y - 7 * s * z); ctx.closePath(); ctx.fill(); } // --------------------------------------------------------- function drawBuilding(st, b, ui, timeSec) { const def = C.BUILDINGS[b.defId]; const hf = b.size / 2 - 0.08; const sel = ui.sel && ui.sel.kind === 'building' && ui.sel.id === b.id; const off = !b.powered && b.done; if (sel) { const p = S.worldToScreen(b.x, b.y); diamond(p.x, p.y, (b.size / 2) * TW2 * S.cam.zoom * 1.06, (b.size / 2) * TH2 * S.cam.zoom * 1.06); ctx.strokeStyle = '#ffe08a'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = 'rgba(255,224,138,0.08)'; ctx.fill(); } ctx.globalAlpha = off ? 0.72 : 1; if (!b.done) { drawConstruction(b, timeSec); ctx.globalAlpha = 1; return; } const WALL = '#c9b18a', WALL_D = '#a08a66', WALL_R = '#b59a74'; switch (b.defId) { case 'hq': { isoBox(b.x, b.y, hf, 34, '#b8a07a', '#8f7a58', '#a58c66'); isoBox(b.x, b.y - 0.4, hf * 0.55, 26, '#a5502e', '#7a3b22', '#8f4428', 34); // banners const p = S.worldToScreen(b.x - hf + 0.25, b.y - hf + 0.25); const z = S.cam.zoom; ctx.strokeStyle = '#5d452c'; ctx.lineWidth = 2 * z; ctx.beginPath(); ctx.moveTo(p.x, p.y - 60 * z); ctx.lineTo(p.x, p.y - 100 * z); ctx.stroke(); const wav = Math.sin(timeSec * 4) * 3 * z; ctx.fillStyle = '#c23b2e'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 100 * z); ctx.quadraticCurveTo(p.x + 14 * z, p.y - 97 * z + wav, p.x + 26 * z, p.y - 94 * z); ctx.quadraticCurveTo(p.x + 13 * z, p.y - 91 * z - wav, p.x, p.y - 88 * z); ctx.closePath(); ctx.fill(); break; } case 'house': { isoBox(b.x, b.y, hf, 16, WALL, WALL_D, WALL_R); // gable roof const z = S.cam.zoom; const pA = S.worldToScreen(b.x - hf, b.y - hf), pB = S.worldToScreen(b.x + hf, b.y - hf); const pC = S.worldToScreen(b.x + hf, b.y + hf), pD = S.worldToScreen(b.x - hf, b.y + hf); const hh = 30 * z, mh = 40 * z; ctx.fillStyle = '#a5502e'; ctx.beginPath(); ctx.moveTo(pA.x, pA.y - hh); ctx.lineTo((pA.x + pB.x) / 2, (pA.y + pB.y) / 2 - mh); ctx.lineTo(pB.x, pB.y - hh); ctx.lineTo(pB.x, pB.y - 16 * z); ctx.lineTo(pA.x, pA.y - 16 * z); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#8f4428'; ctx.beginPath(); ctx.moveTo(pB.x, pB.y - hh); ctx.lineTo((pB.x + pC.x) / 2, (pB.y + pC.y) / 2 - mh); ctx.lineTo(pC.x, pC.y - hh); ctx.lineTo(pC.x, pC.y - 16 * z); ctx.lineTo(pB.x, pB.y - 16 * z); ctx.closePath(); ctx.fill(); break; } case 'farm': { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom, hw = hf * TW2 * z, hh = hf * TH2 * z; diamond(p.x, p.y, hw, hh); ctx.fillStyle = '#7a5f38'; ctx.fill(); ctx.save(); diamond(p.x, p.y, hw * 0.92, hh * 0.92); ctx.clip(); for (let k = -4; k <= 4; k++) { ctx.strokeStyle = k % 2 ? '#8fae4a' : '#6f9440'; ctx.lineWidth = 3.4 * z; ctx.beginPath(); ctx.moveTo(p.x - hw + k * hw / 4.4 - hw * 0.2, p.y + k * hh / 4.4); ctx.lineTo(p.x + k * hw / 4.4 + hw * 0.2, p.y + hh + k * hh / 4.4 - hh * 2); ctx.stroke(); } ctx.restore(); // fence posts ctx.fillStyle = '#6b5233'; for (const [fx, fy] of [[-1, -1], [1, -1], [1, 1], [-1, 1], [0, -1], [1, 0], [0, 1], [-1, 0]]) { const fp = S.worldToScreen(b.x + fx * hf * 0.98, b.y + fy * hf * 0.98); ctx.fillRect(fp.x - 1 * z, fp.y - 7 * z, 2 * z, 7 * z); } // windmill const mp = S.worldToScreen(b.x - hf + 0.35, b.y - hf + 0.35); ctx.strokeStyle = '#5d452c'; ctx.lineWidth = 2.5 * z; ctx.beginPath(); ctx.moveTo(mp.x, mp.y); ctx.lineTo(mp.x, mp.y - 34 * z); ctx.stroke(); const rot = timeSec * 2.4; ctx.save(); ctx.translate(mp.x, mp.y - 34 * z); for (let k = 0; k < 4; k++) { ctx.rotate(Math.PI / 2); ctx.fillStyle = '#cfc5a8'; ctx.fillRect(0, -1.2 * z, 13 * z, 2.4 * z); } ctx.restore(); break; } case 'forester': { isoBox(b.x, b.y, hf, 15, '#8a6842', '#6b5033', '#7a5c3a'); // log pile const lp = S.worldToScreen(b.x + hf - 0.3, b.y + hf - 0.3); const z = S.cam.zoom; for (let k = 0; k < 3; k++) { ctx.fillStyle = '#5d452c'; ctx.beginPath(); ctx.ellipse(lp.x - k * 7 * z, lp.y - 3 * z, 3.4 * z, 2.6 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c9a86a'; ctx.beginPath(); ctx.ellipse(lp.x - k * 7 * z, lp.y - 3 * z, 1.6 * z, 1.2 * z, 0, 0, Math.PI * 2); ctx.fill(); } break; } case 'quarry': { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom, hw = hf * TW2 * z, hh = hf * TH2 * z; diamond(p.x, p.y, hw, hh); ctx.fillStyle = '#8d8d90'; ctx.fill(); diamond(p.x, p.y, hw * 0.6, hh * 0.6); ctx.fillStyle = '#77777c'; ctx.fill(); ctx.fillStyle = '#a5abb2'; for (const [ox, oy, rr] of [[-0.4, -0.2, 5], [0.3, 0.1, 4], [0, 0.45, 3.4]]) { const rp = S.worldToScreen(b.x + ox, b.y + oy); ctx.beginPath(); ctx.moveTo(rp.x - rr * z, rp.y); ctx.lineTo(rp.x - rr * 0.4 * z, rp.y - rr * 1.3 * z); ctx.lineTo(rp.x + rr * 0.6 * z, rp.y - rr * 1.1 * z); ctx.lineTo(rp.x + rr * z, rp.y); ctx.closePath(); ctx.fill(); } // hoist const hp2 = S.worldToScreen(b.x - hf + 0.4, b.y - hf + 0.4); ctx.strokeStyle = '#5d452c'; ctx.lineWidth = 2 * z; ctx.beginPath(); ctx.moveTo(hp2.x, hp2.y); ctx.lineTo(hp2.x, hp2.y - 26 * z); ctx.lineTo(hp2.x + 12 * z, hp2.y - 20 * z); ctx.stroke(); break; } case 'generator': { isoBox(b.x, b.y, hf, 14, '#7f8a93', '#5f6a73', '#6e7982'); const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; ctx.strokeStyle = '#3d464e'; ctx.lineWidth = 2 * z; ctx.beginPath(); ctx.moveTo(p.x, p.y - 14 * z); ctx.lineTo(p.x, p.y - 30 * z); ctx.stroke(); ctx.fillStyle = '#ffd76a'; ctx.beginPath(); ctx.arc(p.x, p.y - 33 * z, 3.4 * z, 0, Math.PI * 2); ctx.fill(); if (!off && Math.random() < 0.15) { ctx.strokeStyle = 'rgba(255,230,120,0.8)'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 33 * z); ctx.lineTo(p.x + (Math.random() - 0.5) * 10 * z, p.y - 33 * z + (Math.random() - 0.5) * 10 * z); ctx.stroke(); } break; } case 'barracks': { isoBox(b.x, b.y, hf, 18, '#8f8577', '#6f665a', '#7d7365'); // pitched roof const z = S.cam.zoom; const pA = S.worldToScreen(b.x - hf, b.y - hf), pB = S.worldToScreen(b.x + hf, b.y - hf); const pC = S.worldToScreen(b.x + hf, b.y + hf), pD = S.worldToScreen(b.x - hf, b.y + hf); const hh2 = 34 * z; ctx.fillStyle = '#5d6a52'; ctx.beginPath(); ctx.moveTo(pA.x, pA.y - 18 * z); ctx.lineTo(pB.x, pB.y - 18 * z); ctx.lineTo(pC.x, pC.y - hh2); ctx.lineTo(pD.x, pD.y - hh2); ctx.closePath(); ctx.fill(); // flag const fp = S.worldToScreen(b.x, b.y); ctx.strokeStyle = '#3d3630'; ctx.lineWidth = 1.6 * z; ctx.beginPath(); ctx.moveTo(fp.x, fp.y - 34 * z); ctx.lineTo(fp.x, fp.y - 48 * z); ctx.stroke(); ctx.fillStyle = '#3f6d3f'; const wav = Math.sin(timeSec * 5) * 2 * z; ctx.beginPath(); ctx.moveTo(fp.x, fp.y - 48 * z); ctx.lineTo(fp.x + 12 * z, fp.y - 45 * z + wav); ctx.lineTo(fp.x, fp.y - 42 * z); ctx.closePath(); ctx.fill(); break; } case 'watchtower': { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(p.x, p.y + 1, 12 * z, 5 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#6b5033'; ctx.lineWidth = 3 * z; ctx.beginPath(); ctx.moveTo(p.x - 8 * z, p.y); ctx.lineTo(p.x - 3 * z, p.y - 34 * z); ctx.moveTo(p.x + 8 * z, p.y); ctx.lineTo(p.x + 3 * z, p.y - 34 * z); ctx.stroke(); // cabin isoBox(b.x, b.y, 0.32, 10, '#8a6842', '#6b5033', '#7a5c3a', 34); // roof ctx.fillStyle = '#544a3a'; ctx.beginPath(); ctx.moveTo(p.x - 14 * z, p.y - 44 * z); ctx.lineTo(p.x, p.y - 54 * z); ctx.lineTo(p.x + 14 * z, p.y - 44 * z); ctx.closePath(); ctx.fill(); // gunner glint if (b.cool > def.rof - 0.1) { ctx.fillStyle = '#fff3c0'; ctx.beginPath(); ctx.arc(p.x + 6 * z, p.y - 38 * z, 2 * z, 0, Math.PI * 2); ctx.fill(); } break; } case 'cannon': { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; diamond(p.x, p.y, TW2 * z * 0.8, TH2 * z * 0.8); ctx.fillStyle = '#8d8d90'; ctx.fill(); ctx.strokeStyle = '#5f5f63'; ctx.stroke(); // barrel rotates const ang = b.aimAng != null ? b.aimAng : -Math.PI / 4; ctx.save(); ctx.translate(p.x, p.y - 10 * z); ctx.fillStyle = '#3d4147'; ctx.beginPath(); ctx.arc(0, 0, 7 * z, 0, Math.PI * 2); ctx.fill(); ctx.rotate(ang); ctx.fillStyle = '#2f3338'; ctx.fillRect(0, -3 * z, 20 * z, 6 * z); ctx.restore(); break; } case 'primalpen': { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; const hw = hf * TW2 * z, hh = hf * TH2 * z; // dirt paddock diamond(p.x, p.y, hw, hh); ctx.fillStyle = '#6e5a3c'; ctx.fill(); diamond(p.x, p.y, hw * 0.82, hh * 0.82); ctx.fillStyle = '#82704c'; ctx.fill(); // fence rails + posts all around const corners = [[-hf, -hf], [hf, -hf], [hf, hf], [-hf, hf]]; for (let k = 0; k < 4; k++) { const a = corners[k], c = corners[(k + 1) % 4]; const pa = S.worldToScreen(b.x + a[0] * 0.95, b.y + a[1] * 0.95); const pc = S.worldToScreen(b.x + c[0] * 0.95, b.y + c[1] * 0.95); ctx.strokeStyle = '#6b5033'; ctx.lineWidth = 1.6 * z; ctx.beginPath(); ctx.moveTo(pa.x, pa.y - 12 * z); ctx.lineTo(pc.x, pc.y - 12 * z); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pa.x, pa.y - 6 * z); ctx.lineTo(pc.x, pc.y - 6 * z); ctx.stroke(); ctx.fillStyle = '#5d452c'; ctx.fillRect(pa.x - 1.5 * z, pa.y - 14 * z, 3 * z, 14 * z); } // feeding trough + hay const tp = S.worldToScreen(b.x + 0.45, b.y - 0.45); ctx.fillStyle = '#5d452c'; ctx.fillRect(tp.x - 7 * z, tp.y - 5 * z, 14 * z, 4 * z); ctx.fillStyle = '#c9a86a'; ctx.beginPath(); ctx.ellipse(tp.x, tp.y - 5 * z, 6 * z, 2.2 * z, 0, 0, Math.PI * 2); ctx.fill(); // skull totem const sp = S.worldToScreen(b.x - hf + 0.15, b.y - hf + 0.15); ctx.strokeStyle = '#5d452c'; ctx.lineWidth = 2 * z; ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(sp.x, sp.y - 20 * z); ctx.stroke(); ctx.fillStyle = '#e8dcc0'; ctx.beginPath(); ctx.arc(sp.x, sp.y - 23 * z, 4 * z, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#3d3630'; ctx.beginPath(); ctx.arc(sp.x - 1.4 * z, sp.y - 24 * z, 1 * z, 0, Math.PI * 2); ctx.fill(); break; } case 'wall': case 'gate': { isoBox(b.x, b.y, 0.46, 15, '#9aa0a6', '#767c82', '#878d93'); if (b.defId === 'gate') { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; ctx.fillStyle = '#4a3b28'; ctx.beginPath(); ctx.arc(p.x, p.y - 6 * z, 6 * z, Math.PI, 0); ctx.rect(p.x - 6 * z, p.y - 6 * z, 12 * z, 7 * z); ctx.fill(); } else { const p = S.worldToScreen(b.x, b.y - 0.0); const z = S.cam.zoom; ctx.fillStyle = 'rgba(255,255,255,0.13)'; ctx.fillRect(p.x - 10 * z, p.y - 15 * z, 20 * z, 2 * z); } break; } } // disabled bolt if (off) { const p = S.worldToScreen(b.x, b.y); const z = S.cam.zoom; ctx.font = (14 * z) + 'px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = '#ffd76a'; ctx.fillText('⚡', p.x, p.y - 40 * z + Math.sin(timeSec * 3) * 2 * z); } // burn smoke handled by sim ctx.globalAlpha = 1; // hp bar if (b.hp < b.maxHp) drawBar(b.x, b.y, b.size * 0.5, b.hp / b.maxHp, '#5fbf5f'); } function drawConstruction(b, timeSec) { const hf = b.size / 2 - 0.05; const z = S.cam.zoom; const pC = S.worldToScreen(b.x, b.y); // dirt pad diamond(pC.x, pC.y, hf * TW2 * z, hf * TH2 * z); ctx.fillStyle = '#7a6a4a'; ctx.fill(); // scaffold poles const corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]]; ctx.strokeStyle = '#8a6842'; ctx.lineWidth = 2 * z; for (const [cx2, cy2] of corners) { const p = S.worldToScreen(b.x + cx2 * hf, b.y + cy2 * hf); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x, p.y - 26 * z); ctx.stroke(); } // beams rise with progress const ph = 26 * z * b.progress; ctx.beginPath(); const pa = S.worldToScreen(b.x - hf, b.y - hf), pb = S.worldToScreen(b.x + hf, b.y - hf); ctx.moveTo(pa.x, pa.y - ph); ctx.lineTo(pb.x, pb.y - ph); ctx.stroke(); // materials pile ctx.fillStyle = '#a58c66'; ctx.beginPath(); ctx.ellipse(pC.x + 8 * z, pC.y + 2 * z, 6 * z, 3 * z, 0, 0, Math.PI * 2); ctx.fill(); drawBar(b.x, b.y, b.size * 0.5, b.progress, '#e0b45c'); } function drawBar(wx, wy, halfTiles, frac, colFront) { const p = S.worldToScreen(wx, wy - halfTiles); const z = S.cam.zoom; const w = Math.max(26 * z, halfTiles * 2 * TW2 * z * 0.7), h = 4 * z; const y = p.y - 8 * z; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(p.x - w / 2 - 1, y - 1, w + 2, h + 2); ctx.fillStyle = colFront; ctx.fillRect(p.x - w / 2, y, w * U.clamp(frac, 0, 1), h); } // --------------------------------------------------------- function drawUnit(u, timeSec) { const p = S.worldToScreen(u.x, u.y); const z = S.cam.zoom; const bob = Math.sin(u.animT * 9) * 1.2 * z; const bx = p.x, by = p.y - 6 * z + bob * 0.4; const isRanger = u.unitId === 'ranger'; const isTamer = u.unitId === 'tamer'; // shadow ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(p.x, p.y + 1 * z, 5 * z, 2.4 * z, 0, 0, Math.PI * 2); ctx.fill(); if (u.selected) { ctx.strokeStyle = '#cfe8ff'; ctx.lineWidth = 1.5 * z; ctx.beginPath(); ctx.ellipse(p.x, p.y + 1 * z, 7 * z, 3.4 * z, 0, 0, Math.PI * 2); ctx.stroke(); } // legs ctx.strokeStyle = '#3d3630'; ctx.lineWidth = 1.6 * z; const lg = Math.sin(u.animT * 11) * 2.4 * z; ctx.beginPath(); ctx.moveTo(bx, by + 2 * z); ctx.lineTo(bx - lg, p.y); ctx.moveTo(bx, by + 2 * z); ctx.lineTo(bx + lg, p.y); ctx.stroke(); // torso ctx.fillStyle = isTamer ? '#6b4a8a' : (isRanger ? '#3f6d3f' : '#8a6d4a'); ctx.beginPath(); ctx.ellipse(bx, by, 2.6 * z, 4 * z, 0, 0, Math.PI * 2); ctx.fill(); // head ctx.fillStyle = '#e0b48a'; ctx.beginPath(); ctx.arc(bx, by - 5.4 * z, 2.4 * z, 0, Math.PI * 2); ctx.fill(); // hat ctx.fillStyle = isTamer ? '#4a3266' : (isRanger ? '#2f4f33' : '#6b5233'); ctx.beginPath(); ctx.ellipse(bx, by - 6.6 * z, isTamer ? 3.6 * z : 3 * z, isTamer ? 1.1 * z : 1.4 * z, 0, 0, Math.PI * 2); ctx.fill(); // rifle / tranq gun const fa = u.facing; const dxs = (Math.cos(fa) - Math.sin(fa)) * TW2, dys = (Math.cos(fa) + Math.sin(fa)) * TH2; const al = Math.atan2(dys, dxs); ctx.strokeStyle = isTamer ? '#5a4472' : '#2c2c2c'; ctx.lineWidth = 1.6 * z; ctx.beginPath(); ctx.moveTo(bx + Math.cos(al) * 2 * z, by - 1.5 * z + Math.sin(al) * 1 * z); ctx.lineTo(bx + Math.cos(al) * 9 * z, by - 1.5 * z + Math.sin(al) * 4.5 * z); ctx.stroke(); // capture rope to the dino being collared if (u.channelId) { const tgt = RTS.sim.getDino(u.channelId); if (tgt) { const tp2 = S.worldToScreen(tgt.x, tgt.y); ctx.strokeStyle = 'rgba(176,111,224,0.85)'; ctx.setLineDash([3, 3]); ctx.lineWidth = 1.2 * z; ctx.beginPath(); ctx.moveTo(bx + 3 * z, by - 2 * z); ctx.lineTo(tp2.x, tp2.y - 10 * z); ctx.stroke(); ctx.setLineDash([]); } } // hp pip if (u.hp < u.maxHp) drawBar(u.x, u.y, 0.2, u.hp / u.maxHp, '#5fbf5f'); } // --------------------------------------------------------- // nest with incubating egg(s); wobbles harder as hatching nears function drawEgg(e, timeSec) { const p = S.worldToScreen(e.x, e.y); const z = S.cam.zoom; const frac = U.clamp(e.t / e.total, 0, 1); // nest ring ctx.fillStyle = '#6b5033'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 9 * z, 4 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#8a6842'; ctx.beginPath(); ctx.ellipse(p.x, p.y - 1 * z, 7.5 * z, 3.2 * z, 0, 0, Math.PI * 2); ctx.fill(); // eggs (two, second appears halfway) const wob = Math.sin(timeSec * (2 + frac * 14)) * frac; // shakes before hatching for (let k = 0; k < (frac > 0.5 ? 2 : 1); k++) { const ox = (k === 0 ? -2.5 : 2) * z + wob * 2 * z; const oy = -3 * z - k * 1.5 * z; ctx.fillStyle = '#efe6d2'; ctx.beginPath(); ctx.ellipse(p.x + ox, p.y + oy, 2.6 * z, 3.4 * z, wob * 0.25, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(120,100,80,0.35)'; ctx.beginPath(); ctx.ellipse(p.x + ox - 0.8 * z, p.y + oy - 1 * z, 0.9 * z, 1.2 * z, 0, 0, Math.PI * 2); ctx.fill(); } // cracks near hatch if (frac > 0.75) { ctx.strokeStyle = '#5d452c'; ctx.lineWidth = 1 * z; ctx.beginPath(); const ex = p.x - 2.5 * z + wob * 2 * z, ey = p.y - 3 * z; ctx.moveTo(ex - 1.5 * z, ey); ctx.lineTo(ex - 0.5 * z, ey + 1.5 * z); ctx.lineTo(ex + 0.8 * z, ey - 0.5 * z); ctx.stroke(); } // progress pip if (frac < 1) { ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(p.x - 8 * z, p.y - 10 * z, 16 * z, 2.5 * z); ctx.fillStyle = '#ffe9a8'; ctx.fillRect(p.x - 8 * z, p.y - 10 * z, 16 * z * frac, 2.5 * z); } } function drawDino(d, timeSec) { const p = S.worldToScreen(d.x, d.y); const z = S.cam.zoom; const sc = d.scale * z * (d.baby ? U.lerp(0.55, 1, d.growth || 0) : 1); const flyZ = d.flying ? (2.2 + Math.sin(timeSec * 3 + d.animT) * 0.35) : 0; // swimming? (amphibious dinos standing on water) const stt = RTS.sim.state(); let inWater = false; if (d.amphibious) { const ti = (d.y | 0) * W + (d.x | 0); inWater = U.inBounds(d.x | 0, d.y | 0, W, H) && stt.world.tiles.terrain[ti] === 3; } const swimZ = inWater ? -1.2 : 0; // shadow ctx.fillStyle = d.flying ? 'rgba(0,0,0,0.14)' : 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(p.x, p.y + 1 * z, 10 * sc, 4.4 * sc, 0, 0, Math.PI * 2); ctx.fill(); if (inWater) { // ripples const rr = (timeSec * 1.6 + d.animT) % 1; ctx.strokeStyle = 'rgba(200,230,255,' + (0.4 * (1 - rr)) + ')'; ctx.lineWidth = 1.4 * z; ctx.beginPath(); ctx.ellipse(p.x, p.y, (8 + 14 * rr) * sc, (3.5 + 6 * rr) * sc, 0, 0, Math.PI * 2); ctx.stroke(); } // orientation: world facing -> screen angle const fx = Math.cos(d.facing), fy = Math.sin(d.facing); const ang = Math.atan2((fx + fy) * TH2, (fx - fy) * TW2); ctx.save(); ctx.translate(p.x, p.y - (flyZ * 22 + swimZ * 8) * z); ctx.rotate(ang); ctx.scale(sc, sc); const bodyCol = d.hitFlash > 0 ? '#ffffff' : d.color; const darkCol = d.hitFlash > 0 ? '#ffffff' : shade(d.color, -34); const legSwing = Math.sin(d.animT * 10) * 2.6; if (d.flying) { // wings const flap = Math.sin(timeSec * 11 + d.animT) * 0.9; ctx.fillStyle = darkCol; for (const sgn of [-1, 1]) { ctx.save(); ctx.scale(1, sgn); ctx.beginPath(); ctx.moveTo(-2, -1); ctx.quadraticCurveTo(6, -10 * (0.6 + flap * 0.4), 16, -7 * (0.5 + flap)); ctx.quadraticCurveTo(8, -2, -2, 2); ctx.closePath(); ctx.fill(); ctx.restore(); } // body + head + crest ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.ellipse(0, 0, 8, 3, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(8.5, -2, 3.4, 2, 0.3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = darkCol; ctx.beginPath(); ctx.moveTo(9.5, -3.5); ctx.lineTo(14, -6.5); ctx.lineTo(11.5, -1.5); ctx.closePath(); ctx.fill(); // beak ctx.fillStyle = '#d8a03f'; ctx.beginPath(); ctx.moveTo(11, -1.6); ctx.lineTo(16, -0.4); ctx.lineTo(10.8, 0.4); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(9.8, -2.6, 0.7, 0, Math.PI * 2); ctx.fill(); } else { const big = d.dinoId === 'rex' || d.dinoId === 'trike' || d.dinoId === 'sucho'; const L = big ? 13 : 9; // body length const HH = big ? 6 : 3.8; // body height // far legs (hidden while swimming) if (!inWater) { ctx.strokeStyle = darkCol; ctx.lineWidth = big ? 3 : 2; ctx.beginPath(); ctx.moveTo(L * 0.45, 0); ctx.lineTo(L * 0.45 + legSwing * 0.6, HH + 3); ctx.moveTo(-L * 0.5, 1); ctx.lineTo(-L * 0.5 - legSwing * 0.6, HH + 3); ctx.stroke(); } // tail ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(-L * 0.7, -HH * 0.5); ctx.quadraticCurveTo(-L * 1.5, -HH * 0.2 + Math.sin(d.animT * 6) * 2, -L * 2.1, HH * 0.5 + Math.sin(d.animT * 6) * 2.5); ctx.quadraticCurveTo(-L * 1.4, HH * 0.6, -L * 0.7, HH * 0.5); ctx.closePath(); ctx.fill(); // body ctx.beginPath(); ctx.ellipse(0, 0, L, HH, 0, 0, Math.PI * 2); ctx.fill(); // belly stripe ctx.fillStyle = darkCol; ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.ellipse(0, HH * 0.45, L * 0.8, HH * 0.4, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; // near legs (hidden while swimming) if (!inWater) { ctx.strokeStyle = bodyCol; ctx.lineWidth = big ? 3.4 : 2.2; ctx.beginPath(); ctx.moveTo(L * 0.4, 1); ctx.lineTo(L * 0.4 - legSwing * 0.7, HH + 3.5); ctx.moveTo(-L * 0.45, 1.5); ctx.lineTo(-L * 0.45 + legSwing * 0.7, HH + 3.5); ctx.stroke(); } else { // paddle churn ctx.strokeStyle = 'rgba(220,240,255,0.5)'; ctx.lineWidth = 1.4; const churn = Math.sin(timeSec * 6 + d.animT) * 2; ctx.beginPath(); ctx.moveTo(L * 0.4, 1); ctx.lineTo(L * 0.4 + churn, HH * 0.6); ctx.stroke(); } // neck + head (species specific) const nx = L * 0.85, ny = -HH * 0.9; if (d.dinoId === 'trike') { // frill ctx.fillStyle = darkCol; ctx.beginPath(); ctx.arc(nx - 1, ny - 1, HH * 1.7, Math.PI * 0.75, Math.PI * 1.75); ctx.closePath(); ctx.fill(); ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.ellipse(nx + 2, ny, HH * 0.95, HH * 0.75, 0, 0, Math.PI * 2); ctx.fill(); // horns ctx.strokeStyle = '#e8dcc0'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(nx + 3, ny - 2); ctx.lineTo(nx + 8, ny - 6); ctx.moveTo(nx + 1, ny - 3.5); ctx.lineTo(nx + 4, ny - 8); ctx.stroke(); } else if (d.dinoId === 'sucho') { // long crocodilian neck & snout ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(L * 0.45, -HH * 0.8); ctx.quadraticCurveTo(nx - 2, ny - 7, nx + 3, ny - 5); ctx.quadraticCurveTo(nx, ny, L * 0.55, 0); ctx.closePath(); ctx.fill(); // head + long jaws ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.ellipse(nx + 4, ny - 4, 6.5, 2.4, 0.08, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#e8e0c8'; for (let k = 0; k < 5; k++) { ctx.beginPath(); ctx.moveTo(nx + k * 2.2, ny - 2.2); ctx.lineTo(nx + k * 2.2 + 1, ny - 0.6); ctx.lineTo(nx + k * 2.2 + 2, ny - 2.2); ctx.closePath(); ctx.fill(); } // back sail ctx.fillStyle = darkCol; ctx.beginPath(); ctx.moveTo(-L * 0.55, -HH * 0.75); ctx.quadraticCurveTo(-L * 0.1, -HH * 2.6, L * 0.35, -HH * 0.85); ctx.quadraticCurveTo(-L * 0.1, -HH * 1.15, -L * 0.55, -HH * 0.75); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(nx + 3, ny - 5.4, 0.9, 0, Math.PI * 2); ctx.fill(); } else if (d.dinoId === 'rex') { ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(L * 0.5, -HH); ctx.quadraticCurveTo(nx, ny - 8, nx + 5, ny - 6); ctx.quadraticCurveTo(nx + 2, ny - 1, L * 0.7, 0); ctx.closePath(); ctx.fill(); // head w/ jaw + teeth ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.ellipse(nx + 6, ny - 5, 5.5, 3.4, 0.15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; let tx0 = nx + 3; for (let k = 0; k < 4; k++) { ctx.beginPath(); ctx.moveTo(tx0 + k * 2.2, ny - 2.4); ctx.lineTo(tx0 + k * 2.2 + 1, ny); ctx.lineTo(tx0 + k * 2.2 + 2, ny - 2.4); ctx.closePath(); ctx.fill(); } ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(nx + 5, ny - 7, 0.9, 0, Math.PI * 2); ctx.fill(); // tiny arms ctx.strokeStyle = darkCol; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(L * 0.55, -HH * 0.3); ctx.lineTo(L * 0.75, HH * 0.3); ctx.stroke(); } else if (d.dinoId === 'dilo') { ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(L * 0.55, -HH * 0.8); ctx.quadraticCurveTo(nx, ny - 6, nx + 4, ny - 4); ctx.quadraticCurveTo(nx, ny, L * 0.6, 0); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.ellipse(nx + 4, ny - 3, 3.4, 2.2, 0.1, 0, Math.PI * 2); ctx.fill(); // neck frill ctx.fillStyle = '#c96a2a'; ctx.beginPath(); ctx.ellipse(nx - 2, ny - 2, 3, 4.4, -0.4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(nx + 4.5, ny - 4.4, 0.7, 0, Math.PI * 2); ctx.fill(); } else { // raptor / compy ctx.fillStyle = bodyCol; ctx.beginPath(); ctx.moveTo(L * 0.55, -HH * 0.9); ctx.quadraticCurveTo(nx, ny - 5, nx + 3.4, ny - 3); ctx.quadraticCurveTo(nx, ny, L * 0.6, 0); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.ellipse(nx + 3, ny - 2.4, 2.8, 1.7, 0.1, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c9a86a'; ctx.beginPath(); ctx.moveTo(nx + 5, ny - 2); ctx.lineTo(nx + 8, ny - 1); ctx.lineTo(nx + 5, ny - 0.6); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(nx + 3.6, ny - 3.4, 0.65, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); // capture-in-progress bar if (d.beingTamed > 0) { const w = Math.max(26 * z, d.r * 3 * TW2 * z); const yy = p.y - flyZ * 22 * z - (d.flying ? 34 : 20) * sc - 10 * z; ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(p.x - w / 2 - 1, yy - 1, w + 2, 5 * z + 2); ctx.fillStyle = '#b06fe0'; ctx.fillRect(p.x - w / 2, yy, w * U.clamp(d.beingTamed, 0, 1), 5 * z); ctx.font = 'bold ' + (8 * Math.max(z, 0.8)) + 'px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = '#e6c8ff'; ctx.fillText('TAMING…', p.x, yy - 3 * z); } // tamed collar marker if (d.tamed) { const cy = p.y - flyZ * 22 * z - (d.flying ? 30 : 18) * sc - 4 * z; const pulse = 0.7 + Math.sin(timeSec * 5 + d.animT) * 0.3; ctx.globalAlpha = pulse; ctx.fillStyle = '#7fd6ff'; diamond(p.x, cy, 3.4 * z, 1.9 * z); ctx.fill(); ctx.globalAlpha = 1; } // hp bar if (d.hp < d.maxHp || d.tamed) { const w = Math.max(20 * z, d.r * 2.6 * TW2 * z); const yy = p.y - (flyZ * 22 + swimZ * 8) * z - (d.flying ? 26 : 14) * sc - (d.beingTamed > 0 ? 16 : 6) * z; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(p.x - w / 2 - 1, yy - 1, w + 2, 4 * z + 2); ctx.fillStyle = d.tamed ? '#5fbf5f' : (d.mode === 'final' ? '#ff5f4a' : '#d84f3f'); ctx.fillRect(p.x - w / 2, yy, w * U.clamp(d.hp / d.maxHp, 0, 1), 4 * z); } } // --------------------------------------------------------- function drawProjectiles(st, timeSec) { const z = S.cam.zoom; for (const pr of st.projs) { const p = S.worldToScreen(pr.x, pr.y); const zh = (pr.zh || 0) * 22 * z; if (pr.type === 'bullet') { const pp = S.worldToScreen(pr.px != null ? pr.px : pr.x, pr.py != null ? pr.py : pr.y); ctx.strokeStyle = '#ffe08a'; ctx.lineWidth = 1.6 * z; ctx.beginPath(); ctx.moveTo(pp.x, pp.y - 8 * z); ctx.lineTo(p.x, p.y - 8 * z); ctx.stroke(); } else { // lobbed shell/spit ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 2.4 * z, 1.2 * z, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = pr.color; ctx.beginPath(); ctx.arc(p.x, p.y - 8 * z - zh, pr.type === 'shell' ? 3 * z : 2.2 * z, 0, Math.PI * 2); ctx.fill(); } } } function drawParticles(st) { const z = S.cam.zoom; for (const pt of st.parts) { const p = S.worldToScreen(pt.x, pt.y); const a = U.clamp(pt.life / pt.maxLife, 0, 1); ctx.globalAlpha = a * (pt.type === 'smoke' ? 0.5 : 0.95); ctx.fillStyle = pt.color; const sz = pt.size * z * (pt.type === 'smoke' ? (2 - a) : 1); ctx.beginPath(); ctx.arc(p.x, p.y - pt.z * z, sz, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } function drawTexts(st) { const z = S.cam.zoom; ctx.textAlign = 'center'; for (const t of st.texts) { const p = S.worldToScreen(t.x, t.y); const a = U.clamp(t.life / t.maxLife, 0, 1); ctx.globalAlpha = a; ctx.font = 'bold ' + (11 * Math.max(z, 0.8)) + 'px system-ui'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3; ctx.strokeText(t.str, p.x, p.y - 20 * z); ctx.fillStyle = t.color; ctx.fillText(t.str, p.x, p.y - 20 * z); } ctx.globalAlpha = 1; } // --------------------------------------------------------- function drawFog(st) { const z = S.cam.zoom; const c0 = S.screenToWorld(0, 0), c1 = S.screenToWorld(vw, 0); const c2 = S.screenToWorld(0, vh), c3 = S.screenToWorld(vw, vh); const minX = Math.max(0, Math.floor(Math.min(c0.x, c1.x, c2.x, c3.x)) - 1); const maxX = Math.min(W - 1, Math.ceil(Math.max(c0.x, c1.x, c2.x, c3.x)) + 1); const minY = Math.max(0, Math.floor(Math.min(c0.y, c1.y, c2.y, c3.y)) - 1); const maxY = Math.min(H - 1, Math.ceil(Math.max(c0.y, c1.y, c2.y, c3.y)) + 2); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { const v = st.vis[y * W + x]; if (v === 2) continue; const p = S.worldToScreen(x + 0.5, y + 0.5); if (p.x < -TW || p.x > vw + TW || p.y < -TH * 2 || p.y > vh + TH * 2) continue; diamond(p.x, p.y, TW2 * z, TH2 * z); ctx.fillStyle = v === 0 ? 'rgba(6,9,7,0.94)' : 'rgba(6,9,7,0.45)'; ctx.fill(); } } } // --------------------------------------------------------- function drawNightTint(st) { const DAY = C.WORLD.DAY_LENGTH; const ph = (st.time % DAY) / DAY; // 0 = dawn let dark; if (ph < 0.45) dark = 0; else if (ph < 0.55) dark = (ph - 0.45) / 0.10; else if (ph < 0.90) dark = 1; else dark = 1 - (ph - 0.90) / 0.10; dark *= 0.30; if (dark <= 0.005) return; ctx.fillStyle = 'rgba(14,22,52,' + dark.toFixed(3) + ')'; ctx.fillRect(0, 0, vw, vh); // warm glows around powered buildings at night ctx.globalCompositeOperation = 'lighter'; for (const b of st.buildings) { if (b.dead || !b.done || !b.powered) continue; const p2 = S.worldToScreen(b.x, b.y); const r = (b.size * 26 + 18) * S.cam.zoom; const g = ctx.createRadialGradient(p2.x, p2.y - 8 * S.cam.zoom, 2, p2.x, p2.y - 8 * S.cam.zoom, r); g.addColorStop(0, 'rgba(255,190,90,' + (dark * 0.55).toFixed(3) + ')'); g.addColorStop(1, 'rgba(255,190,90,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(p2.x, p2.y, r, r * 0.6, 0, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = 'source-over'; } // --------------------------------------------------------- function drawPlacementGuides(st, ui) { const def = C.BUILDINGS[ui.placing.defId]; const z = S.cam.zoom; // power radii ctx.strokeStyle = 'rgba(120,220,255,0.5)'; ctx.setLineDash([6, 6]); for (const b of st.buildings) { if (b.dead || !b.done) continue; const d2 = C.BUILDINGS[b.defId]; if (!d2.radius) continue; const p = S.worldToScreen(b.x, b.y); ctx.beginPath(); ctx.ellipse(p.x, p.y, d2.radius * TW2 * z, d2.radius * TH2 * z, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.setLineDash([]); // work range for harvesters if (def.range) { const p = S.worldToScreen(ui.placing.x, ui.placing.y); ctx.strokeStyle = def.id === 'forester' ? 'rgba(120,200,110,0.6)' : 'rgba(180,180,190,0.6)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, def.range * TW2 * z, def.range * TH2 * z, 0, 0, Math.PI * 2); ctx.stroke(); } } function drawGhost(st, ui) { const def = C.BUILDINGS[ui.placing.defId]; const chk = RTS.sim.canPlace(ui.placing.defId, ui.placing.x, ui.placing.y); const ok = chk.ok; const hf = def.size / 2 - 0.05; const p = S.worldToScreen(ui.placing.x, ui.placing.y); const z = S.cam.zoom; diamond(p.x, p.y, hf * TW2 * z, hf * TH2 * z); ctx.fillStyle = ok ? 'rgba(120,255,120,0.3)' : 'rgba(255,80,80,0.35)'; ctx.fill(); ctx.strokeStyle = ok ? '#8f8' : '#f55'; ctx.lineWidth = 2; ctx.stroke(); if (!ok) { ctx.font = (11 * Math.max(z, 0.8)) + 'px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = '#ffb0a8'; ctx.strokeStyle = 'rgba(0,0,0,0.8)'; ctx.lineWidth = 3; ctx.strokeText(chk.why, p.x, p.y - 26 * z); ctx.fillText(chk.why, p.x, p.y - 26 * z); } } function drawSelectionOverlays(st, ui) { const z = S.cam.zoom; // tower ranges if (ui.sel && ui.sel.kind === 'building') { const b = RTS.sim.getBuilding(ui.sel.id); if (b) { const def = C.BUILDINGS[b.defId]; if (def.range) { const p = S.worldToScreen(b.x, b.y); ctx.strokeStyle = 'rgba(255,220,120,0.55)'; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(p.x, p.y, playerRangeOf(def) * TW2 * z, playerRangeOf(def) * TH2 * z, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } if (b.defId === 'barracks' && b.rallyX != null) { const rp = S.worldToScreen(b.rallyX, b.rallyY); ctx.strokeStyle = '#cfe8ff'; ctx.lineWidth = 1.5 * z; ctx.beginPath(); ctx.moveTo(rp.x, rp.y); ctx.lineTo(rp.x, rp.y - 14 * z); ctx.moveTo(rp.x, rp.y - 14 * z); ctx.lineTo(rp.x + 8 * z, rp.y - 10 * z); ctx.stroke(); } } } // drag select rect if (ui.dragRect) { const x0 = Math.min(ui.dragRect.x0, ui.dragRect.x1), x1 = Math.max(ui.dragRect.x0, ui.dragRect.x1); const y0 = Math.min(ui.dragRect.y0, ui.dragRect.y1), y1 = Math.max(ui.dragRect.y0, ui.dragRect.y1); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; ctx.fillRect(x0, y0, x1 - x0, y1 - y0); ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); } } function playerRangeOf(def) { const st = RTS.sim.state(); const rngBonus = 1 + 0.12 * st.upgrades.range; return def.range * rngBonus; } function drawWarningArrows(st, timeSec) { const pulse = 0.5 + Math.sin(timeSec * 6) * 0.5; const ang = Math.atan2(st.warnDirY, st.warnDirX); // project direction to screen space const dxs = (Math.cos(ang) - Math.sin(ang)) * TW2; const dys = (Math.cos(ang) + Math.sin(ang)) * TH2; const sang = Math.atan2(dys, dxs); const cx = vw / 2, cy = vh / 2; const rad = Math.min(vw, vh) * 0.38; const ax = cx + Math.cos(sang) * rad, ay = cy + Math.sin(sang) * rad * 0.7; ctx.save(); ctx.globalAlpha = 0.55 + pulse * 0.45; ctx.translate(ax, ay); ctx.rotate(sang); ctx.fillStyle = '#ff4a3a'; ctx.beginPath(); ctx.moveTo(26, 0); ctx.lineTo(-10, -16); ctx.lineTo(-4, 0); ctx.lineTo(-10, 16); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.globalAlpha = 1; } // --------------------------------------------------------- // MINIMAP // --------------------------------------------------------- S.drawMinimap = function (mmCtx, st, size) { const s = size / W; mmCtx.clearRect(0, 0, size, size); const T = st.world.tiles; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const i = y * W + x; let col; if (st.vis[i] === 0) col = '#0a0d0b'; else { const t = T.terrain[i]; col = t === 3 ? '#2c4d6e' : (t === 2 ? '#71624a' : '#49653a'); if (T.tree[i] > 0) col = '#33552e'; else if (T.rock[i] > 0) col = '#71767c'; } mmCtx.fillStyle = col; mmCtx.fillRect(x * s, y * s, Math.ceil(s), Math.ceil(s)); } } // buildings for (const b of st.buildings) { if (b.dead) continue; mmCtx.fillStyle = b.id === st.hqId ? '#ffe08a' : '#7fd6ff'; mmCtx.fillRect(b.x * s - 1, b.y * s - 1, Math.max(2, b.size * s), Math.max(2, b.size * s)); } // dinos mmCtx.fillStyle = '#ff4a3a'; for (const d of st.dinos) { if (d.dead) continue; const ti = (d.y | 0) * W + (d.x | 0); if (st.vis[ti] !== 2 && d.mode !== 'final') continue; mmCtx.fillRect(d.x * s - 1, d.y * s - 1, 2, 2); } // view polygon mmCtx.strokeStyle = 'rgba(255,255,255,0.7)'; mmCtx.lineWidth = 1; mmCtx.beginPath(); const corners = [[0, 0], [vw, 0], [vw, vh], [0, vh]]; corners.forEach(([sx2, sy2], k) => { const wp = RTS.render.screenToWorld(sx2, sy2); if (k === 0) mmCtx.moveTo(wp.x * s, wp.y * s); else mmCtx.lineTo(wp.x * s, wp.y * s); }); mmCtx.closePath(); mmCtx.stroke(); }; return S; })();