import { ENEMIES, H, TILE, TOWERS, W, playerColor } from './config' import type { GameEngine } from './engine' import type { Enemy, Tower } from './types' import { mulberry32 } from './utils' /** * Draws the whole game to a canvas. Static background (grass, path, decor) * is pre-rendered once to an offscreen canvas. The backing store resolution * follows the CSS size of the canvas so the game stays sharp at any scale. */ export class Renderer { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private bg: HTMLCanvasElement private dpr = 1 /** physical pixels per logical game pixel (960x528 coordinate space) */ private pxScale = 1 private cssW = W private cssH = H constructor(canvas: HTMLCanvasElement) { this.canvas = canvas this.ctx = canvas.getContext('2d')! this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) this.bg = document.createElement('canvas') this.setCssSize(W, H) if (typeof window !== 'undefined') { window.addEventListener('resize', this.onResize) } } private onResize = (): void => { this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) this.setCssSize(this.cssW, this.cssH) } /** adapts backing-store resolution to the element's CSS size (keeps 960:528 ratio) */ setCssSize(cssW: number, cssH: number): void { this.cssW = cssW this.cssH = cssH this.pxScale = Math.min(3, (this.dpr * cssW) / W) this.canvas.width = Math.round(W * this.pxScale) this.canvas.height = Math.round(H * this.pxScale) this.buildBackground() } destroy(): void { if (typeof window !== 'undefined') { window.removeEventListener('resize', this.onResize) } } // ------------------------------------------------------------ background private buildBackground(): void { const c = this.bg c.width = this.canvas.width c.height = this.canvas.height const g = c.getContext('2d')! g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) // grass gradient const grad = g.createLinearGradient(0, 0, 0, H) grad.addColorStop(0, '#3d6b39') grad.addColorStop(1, '#48783f') g.fillStyle = grad g.fillRect(0, 0, W, H) // subtle checkerboard for (let ty = 0; ty < 11; ty++) { for (let tx = 0; tx < 20; tx++) { if ((tx + ty) % 2 === 0) { g.fillStyle = 'rgba(255,255,255,0.022)' g.fillRect(tx * TILE, ty * TILE, TILE, TILE) } } } // grass speckles const rng = mulberry32(1337) for (let i = 0; i < 500; i++) { const x = rng() * W const y = rng() * H g.fillStyle = rng() < 0.5 ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.04)' g.fillRect(x, y, 2, 2) } // grid lines g.strokeStyle = 'rgba(0,0,0,0.06)' g.lineWidth = 1 for (let x = 0; x <= 20; x++) { g.beginPath() g.moveTo(x * TILE + 0.5, 0) g.lineTo(x * TILE + 0.5, H) g.stroke() } for (let y = 0; y <= 11; y++) { g.beginPath() g.moveTo(0, y * TILE + 0.5) g.lineTo(W, y * TILE + 0.5) g.stroke() } } private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void { const pts = eng.pathPx g.lineJoin = 'round' g.lineCap = 'round' g.beginPath() g.moveTo(pts[0].x, pts[0].y) for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y) g.strokeStyle = '#5f4a33' g.lineWidth = TILE * 0.78 g.stroke() g.strokeStyle = '#7a6142' g.lineWidth = TILE * 0.7 g.stroke() g.strokeStyle = '#c2a276' g.lineWidth = TILE * 0.58 g.stroke() // dashed center line g.save() g.strokeStyle = 'rgba(216,195,154,0.55)' g.lineWidth = 3 g.setLineDash([12, 16]) g.beginPath() g.moveTo(pts[0].x, pts[0].y) for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y) g.stroke() g.restore() // pebbles on the path const rng = mulberry32(777) for (let i = 0; i < 90; i++) { const seg = 1 + Math.floor(rng() * (pts.length - 1)) const a = pts[seg - 1] const b = pts[seg] const t = rng() const off = (rng() - 0.5) * TILE * 0.45 const dx = b.x - a.x const dy = b.y - a.y const len = Math.hypot(dx, dy) || 1 const px = a.x + dx * t + (-dy / len) * off const py = a.y + dy * t + (dx / len) * off g.fillStyle = rng() < 0.5 ? 'rgba(95,74,51,0.5)' : 'rgba(230,210,175,0.5)' g.beginPath() g.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2) g.fill() } } private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void { for (const d of eng.decor) { const { x, y, s } = d if (d.type === 'flower') { const cols = ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0'] const col = cols[Math.floor(d.seed * cols.length)] g.fillStyle = col for (let i = 0; i < 3; i++) { const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5) const fy = y + ((i * 13) % 7 - 3) * s g.beginPath() g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2) g.fill() } } else if (d.type === 'bush') { g.fillStyle = 'rgba(0,0,0,0.15)' g.beginPath() g.ellipse(x, y + 8 * s, 11 * s, 4 * s, 0, 0, Math.PI * 2) g.fill() g.fillStyle = '#3e7a3a' g.beginPath() g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2) g.fill() g.fillStyle = '#4f8f45' g.beginPath() g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2) g.fill() } else if (d.type === 'rock') { g.fillStyle = 'rgba(0,0,0,0.18)' g.beginPath() g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2) g.fill() g.fillStyle = '#8b9298' g.beginPath() g.moveTo(x - 12 * s, y + 8 * s) g.lineTo(x - 9 * s, y - 7 * s) g.lineTo(x + 2 * s, y - 11 * s) g.lineTo(x + 12 * s, y - 2 * s) g.lineTo(x + 10 * s, y + 8 * s) g.closePath() g.fill() g.fillStyle = 'rgba(255,255,255,0.22)' g.beginPath() g.moveTo(x - 8 * s, y - 5 * s) g.lineTo(x + 1 * s, y - 9 * s) g.lineTo(x + 5 * s, y - 3 * s) g.closePath() g.fill() } else { // tree g.fillStyle = 'rgba(0,0,0,0.22)' g.beginPath() g.ellipse(x, y + 12 * s, 15 * s, 5 * s, 0, 0, Math.PI * 2) g.fill() g.fillStyle = '#6b4526' g.fillRect(x - 3.5 * s, y - 4 * s, 7 * s, 16 * s) const rng = mulberry32(Math.floor(d.seed * 1e9)) for (let i = 0; i < 4; i++) { const cx = x + (rng() - 0.5) * 16 * s const cy = y - 8 * s - rng() * 12 * s const r = (8 + rng() * 5) * s g.fillStyle = i % 2 === 0 ? '#2f6b33' : '#3b7c3c' g.beginPath() g.arc(cx, cy, r, 0, Math.PI * 2) g.fill() } g.fillStyle = 'rgba(255,255,255,0.12)' g.beginPath() g.arc(x - 4 * s, y - 16 * s, 6 * s, 0, Math.PI * 2) g.fill() } } } // ------------------------------------------------------------ main render render(eng: GameEngine): void { const g = this.ctx g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) g.clearRect(0, 0, W, H) g.save() if (eng.shake > 0) { const s = eng.shake * 5 g.translate((Math.random() - 0.5) * s, (Math.random() - 0.5) * s) } g.drawImage(this.bg, 0, 0, W, H) this.drawPath(g, eng) this.drawDecor(g, eng) this.drawSpawn(g, eng) this.drawBase(g, eng) if (eng.buildType) this.drawBuildHints(g, eng) for (const t of eng.towers) this.drawTower(g, eng, t, false) // ground enemies first, then flying on top for (const e of eng.enemies) if (!e.flying) this.drawEnemy(g, eng, e) for (const p of eng.projectiles) this.drawProjectile(g, p.kind, p.x, p.y, p.angle) for (const e of eng.enemies) if (e.flying) this.drawEnemy(g, eng, e) this.drawEffects(g, eng) this.drawBossBars(g, eng) // hover ghost if (eng.buildType && eng.hover && eng.hover.x >= 0 && eng.hover.x < W && eng.hover.y >= 0 && eng.hover.y < H) { this.drawGhost(g, eng) } // selected tower highlight const sel = eng.selectedTower() if (sel) { const def = TOWERS[sel.kind] const range = def.levels[sel.level - 1].range g.save() g.strokeStyle = 'rgba(255,255,255,0.8)' g.setLineDash([8, 6]) g.lineWidth = 2 g.beginPath() g.arc(sel.x, sel.y, range, 0, Math.PI * 2) g.stroke() g.fillStyle = 'rgba(255,255,255,0.06)' g.fill() g.restore() } // selected obstacle highlight const ob = eng.selectedObstacle if (ob) { g.save() g.strokeStyle = 'rgba(255,255,255,0.85)' g.setLineDash([8, 6]) g.lineWidth = 2 g.strokeRect(ob.tx * TILE + 3, ob.ty * TILE + 3, TILE - 6, TILE - 6) g.restore() } g.restore() } private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void { const p = eng.pathPx[0] g.save() g.translate(p.x + 18, p.y) g.fillStyle = '#2a1e3d' g.beginPath() g.ellipse(0, 0, 26, 30, 0, 0, Math.PI * 2) g.fill() const t = eng.time * 2 for (let i = 0; i < 3; i++) { g.strokeStyle = `rgba(178,120,255,${0.7 - i * 0.2})` g.lineWidth = 3 g.beginPath() g.arc(0, 0, 8 + i * 7 + Math.sin(t + i * 2) * 3, t * (i % 2 === 0 ? 1 : -1), t * (i % 2 === 0 ? 1 : -1) + 4.2) g.stroke() } g.fillStyle = 'rgba(200,160,255,0.9)' g.beginPath() g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2) g.fill() g.restore() } private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void { const p = eng.pathPx[eng.pathPx.length - 1] const x = p.x - 26 const y = p.y g.save() // shadow g.fillStyle = 'rgba(0,0,0,0.25)' g.beginPath() g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2) g.fill() // keep walls g.fillStyle = '#8d8577' g.fillRect(x - 24, y - 30, 48, 56) g.fillStyle = '#a49b8b' g.fillRect(x - 24, y - 30, 48, 8) // crenellations g.fillStyle = '#8d8577' for (let i = 0; i < 4; i++) g.fillRect(x - 24 + i * 13, y - 38, 8, 8) // gate g.fillStyle = '#4c3a28' g.beginPath() g.moveTo(x - 10, y + 26) g.lineTo(x - 10, y + 2) g.arc(x, y + 2, 10, Math.PI, 0) g.lineTo(x + 10, y + 26) g.closePath() g.fill() // banner carries the owner's color in duel mode (blue/orange) g.strokeStyle = '#5b4a35' g.lineWidth = 3 g.beginPath() g.moveTo(x, y - 38) g.lineTo(x, y - 62) g.stroke() const wave = Math.sin(eng.time * 4) * 3 g.fillStyle = eng.mpMode === 'duel' ? playerColor(eng.localPlayerId) : '#d84b3f' g.beginPath() g.moveTo(x + 1, y - 62) g.lineTo(x + 22 + wave, y - 56) g.lineTo(x + 1, y - 48) g.closePath() g.fill() // hearts = lives g.fillStyle = '#fff' g.font = 'bold 11px system-ui' g.textAlign = 'center' g.fillText('❤ ' + eng.lives, x, y - 12) g.restore() } private drawBuildHints(g: CanvasRenderingContext2D, eng: GameEngine): void { g.save() g.strokeStyle = 'rgba(255,255,255,0.1)' g.lineWidth = 1 for (let tx = 0; tx < 20; tx++) { for (let ty = 0; ty < 11; ty++) { const ok = eng.canPlace(tx, ty) if (!ok) continue g.strokeRect(tx * TILE + 3.5, ty * TILE + 3.5, TILE - 7, TILE - 7) } } g.restore() } // ------------------------------------------------------------ towers private drawTower(g: CanvasRenderingContext2D, eng: GameEngine, t: Tower, ghost: boolean): void { const def = TOWERS[t.kind] g.save() if (ghost) g.globalAlpha = 0.55 g.translate(t.x, t.y) // base plate g.fillStyle = 'rgba(0,0,0,0.25)' g.beginPath() g.ellipse(2, 6, 19, 12, 0, 0, Math.PI * 2) g.fill() g.fillStyle = '#39404a' this.roundRect(g, -18, -16, 36, 34, 7) g.fill() const baseGrad = g.createLinearGradient(0, -16, 0, 18) baseGrad.addColorStop(0, def.color) baseGrad.addColorStop(1, this.shade(def.color, -30)) g.fillStyle = baseGrad this.roundRect(g, -16, -16, 32, 32, 6) g.fill() g.strokeStyle = 'rgba(0,0,0,0.35)' g.lineWidth = 1.5 this.roundRect(g, -16, -16, 32, 32, 6) g.stroke() // multiplayer owner ring if (t.owner !== null) { g.strokeStyle = t.owner === 0 ? '#4da3ff' : '#ffb14d' g.lineWidth = 2.5 g.beginPath() g.arc(0, 2, 21, 0, Math.PI * 2) g.stroke() } // level pips for (let i = 0; i < t.level; i++) { g.fillStyle = '#ffd23e' g.beginPath() g.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2) g.fill() } const recoil = t.recoil * 4 const time = eng.time switch (t.kind) { case 'arrow': { g.rotate(t.angle) g.translate(-recoil, 0) // crossbow g.fillStyle = '#5b4a35' g.fillRect(-8, -3, 18, 6) g.strokeStyle = '#e8c27a' g.lineWidth = 2.5 g.beginPath() g.moveTo(4, -10) g.quadraticCurveTo(11, 0, 4, 10) g.stroke() g.strokeStyle = 'rgba(255,255,255,0.7)' g.lineWidth = 1 g.beginPath() g.moveTo(4, -10) g.lineTo(-4, 0) g.lineTo(4, 10) g.stroke() if (t.cooldown <= 0) { g.strokeStyle = '#fff' g.lineWidth = 1.5 g.beginPath() g.moveTo(-4, 0) g.lineTo(10, 0) g.stroke() } break } case 'cannon': { g.rotate(t.angle) // barrel g.fillStyle = '#2f353d' this.roundRect(g, -6 - recoil, -5.5, 24, 11, 4) g.fill() g.fillStyle = '#454d57' this.roundRect(g, -2 - recoil, -3.5, 18, 7, 3) g.fill() g.fillStyle = '#22262c' g.beginPath() g.arc(-6 - recoil, 0, 8, 0, Math.PI * 2) g.fill() g.fillStyle = def.accent g.beginPath() g.arc(-6 - recoil, 0, 3, 0, Math.PI * 2) g.fill() break } case 'frost': { const pulse = 1 + Math.sin(time * 3) * 0.06 + t.pulse * 0.35 g.rotate(time * 0.6) g.scale(pulse, pulse) g.fillStyle = '#d8f4ff' g.beginPath() g.moveTo(0, -13) g.lineTo(8, 0) g.lineTo(0, 13) g.lineTo(-8, 0) g.closePath() g.fill() g.fillStyle = '#8fd8f0' g.beginPath() g.moveTo(0, -9) g.lineTo(5.5, 0) g.lineTo(0, 9) g.lineTo(-5.5, 0) g.closePath() g.fill() break } case 'tesla': { g.fillStyle = '#3b3550' g.fillRect(-3, -14, 6, 20) const glow = 0.5 + Math.sin(time * 5) * 0.2 g.fillStyle = `rgba(200,180,255,${glow * 0.35})` g.beginPath() g.arc(0, -16, 12, 0, Math.PI * 2) g.fill() g.fillStyle = def.accent g.beginPath() g.arc(0, -16, 6.5, 0, Math.PI * 2) g.fill() g.fillStyle = '#fff' g.beginPath() g.arc(-1.5, -17.5, 2.2, 0, Math.PI * 2) g.fill() // idle sparks if (Math.random() < 0.12) { g.strokeStyle = 'rgba(200,180,255,0.8)' g.lineWidth = 1.5 const a = Math.random() * Math.PI * 2 g.beginPath() g.moveTo(Math.cos(a) * 7, -16 + Math.sin(a) * 7) g.lineTo(Math.cos(a) * 12 + 3, -16 + Math.sin(a) * 12 + 2) g.stroke() } break } case 'laser': { g.rotate(t.angle) const charge = t.cooldown <= 0 ? 1 : Math.max(0, 1 - t.cooldown * def.levels[t.level - 1].rate) // dish g.fillStyle = '#3a3f47' this.roundRect(g, -10, -8, 14, 16, 4) g.fill() g.fillStyle = '#565e69' this.roundRect(g, 0, -4, 14, 8, 3) g.fill() g.fillStyle = `rgba(255,138,122,${0.3 + charge * 0.7})` g.beginPath() g.arc(14, 0, 3 + charge * 1.5, 0, Math.PI * 2) g.fill() break } } g.restore() } // ------------------------------------------------------------ enemies private drawEnemy(g: CanvasRenderingContext2D, eng: GameEngine, e: Enemy): void { const def = ENEMIES[e.kind] const slowed = eng.time < e.slowUntil // lane offset perpendicular to heading const px = -Math.sin(e.heading) * e.laneOff const py = Math.cos(e.heading) * e.laneOff const x = e.x + px const y = e.y + py const bob = e.flying ? Math.sin(eng.time * 6 + e.wobble) * 3 - 12 : 0 g.save() // shadow g.fillStyle = 'rgba(0,0,0,0.28)' g.beginPath() g.ellipse(x, e.flying ? y + e.r + 6 : y + e.r * 0.7, e.r * 0.8, e.r * 0.3, 0, 0, Math.PI * 2) g.fill() g.translate(x, y + bob) const squish = 1 + Math.sin(eng.time * 8 + e.wobble) * 0.07 const body = def.color switch (e.kind) { case 'normal': { g.scale(1, squish) g.fillStyle = body g.beginPath() g.arc(0, 0, e.r, 0, Math.PI * 2) g.fill() g.fillStyle = 'rgba(0,0,0,0.18)' g.beginPath() g.arc(0, e.r * 0.45, e.r * 0.8, 0, Math.PI) g.fill() this.eyes(g, e.r) break } case 'fast': { g.rotate(e.heading) g.scale(1, squish) g.fillStyle = body g.beginPath() g.moveTo(e.r + 3, 0) g.lineTo(-e.r, -e.r * 0.8) g.lineTo(-e.r * 0.4, 0) g.lineTo(-e.r, e.r * 0.8) g.closePath() g.fill() g.fillStyle = 'rgba(0,0,0,0.2)' g.beginPath() g.moveTo(e.r + 3, 0) g.lineTo(-e.r * 0.4, 0) g.lineTo(-e.r, e.r * 0.8) g.closePath() g.fill() break } case 'tank': { g.scale(1, 1) // treads g.fillStyle = '#3d434c' this.roundRect(g, -e.r, -e.r - 2, e.r * 2, 6, 3) g.fill() this.roundRect(g, -e.r, e.r - 4, e.r * 2, 6, 3) g.fill() g.strokeStyle = 'rgba(255,255,255,0.25)' g.lineWidth = 1.5 const treadOff = (e.traveled / 6) % 8 for (let i = 0; i < 4; i++) { const tx = -e.r + 3 + ((i * 8 + treadOff) % (e.r * 2 - 4)) g.beginPath() g.moveTo(tx, -e.r - 1) g.lineTo(tx, -e.r + 3) g.stroke() g.beginPath() g.moveTo(tx, e.r - 3) g.lineTo(tx, e.r + 1) g.stroke() } // hull const hg = g.createLinearGradient(0, -e.r, 0, e.r) hg.addColorStop(0, '#8f9aa6') hg.addColorStop(1, '#68727d') g.fillStyle = hg this.roundRect(g, -e.r + 2, -e.r + 3, e.r * 2 - 4, e.r * 2 - 6, 4) g.fill() g.fillStyle = '#4c545e' g.beginPath() g.arc(0, 0, 5, 0, Math.PI * 2) g.fill() g.fillStyle = '#2f353d' g.fillRect(0, -2, e.r + 2, 4) break } case 'flyer': { const flap = Math.sin(eng.time * 12 + e.wobble) g.fillStyle = 'rgba(220,190,255,0.75)' g.beginPath() g.ellipse(-e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, -0.5 + flap * 0.25, 0, Math.PI * 2) g.fill() g.beginPath() g.ellipse(e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, 0.5 - flap * 0.25, 0, Math.PI * 2) g.fill() g.fillStyle = body g.beginPath() g.ellipse(0, 0, e.r * 0.85, e.r, 0, 0, Math.PI * 2) g.fill() this.eyes(g, e.r * 0.9) break } case 'boss': { // spikes g.fillStyle = '#8e2f27' for (let i = 0; i < 10; i++) { const a = (i / 10) * Math.PI * 2 + eng.time * 0.5 g.beginPath() g.moveTo(Math.cos(a) * (e.r - 3), Math.sin(a) * (e.r - 3)) g.lineTo(Math.cos(a + 0.18) * (e.r + 7), Math.sin(a + 0.18) * (e.r + 7)) g.lineTo(Math.cos(a + 0.36) * (e.r - 3), Math.sin(a + 0.36) * (e.r - 3)) g.closePath() g.fill() } const bg = g.createRadialGradient(-4, -4, 3, 0, 0, e.r) bg.addColorStop(0, '#f07a5f') bg.addColorStop(1, body) g.fillStyle = bg g.beginPath() g.arc(0, 0, e.r, 0, Math.PI * 2) g.fill() // crown g.fillStyle = '#ffd23e' g.beginPath() g.moveTo(-8, -e.r - 2) g.lineTo(-8, -e.r - 10) g.lineTo(-4, -e.r - 5) g.lineTo(0, -e.r - 11) g.lineTo(4, -e.r - 5) g.lineTo(8, -e.r - 10) g.lineTo(8, -e.r - 2) g.closePath() g.fill() // angry eyes g.fillStyle = '#fff' g.beginPath() g.arc(-5, -3, 3.4, 0, Math.PI * 2) g.arc(5, -3, 3.4, 0, Math.PI * 2) g.fill() g.fillStyle = '#301010' g.beginPath() g.arc(-4.2, -2.5, 1.7, 0, Math.PI * 2) g.arc(5.8, -2.5, 1.7, 0, Math.PI * 2) g.fill() break } } // slow indicator if (slowed) { g.strokeStyle = 'rgba(140,220,255,0.9)' g.lineWidth = 2 g.beginPath() g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2) g.stroke() g.fillStyle = 'rgba(160,230,255,0.25)' g.beginPath() g.arc(0, 0, e.r + 1, 0, Math.PI * 2) g.fill() } // hit flash if (e.flash > 0) { g.fillStyle = `rgba(255,255,255,${e.flash * 0.6})` g.beginPath() g.arc(0, 0, e.r + 1, 0, Math.PI * 2) g.fill() } g.restore() // hp bar const pct = Math.max(0, e.hp / e.maxHp) if (pct < 1 || e.kind === 'boss') { const bw = Math.max(20, e.r * 2.2) const bx = x - bw / 2 const by = y + bob - e.r - (e.kind === 'boss' ? 16 : 9) g.fillStyle = 'rgba(0,0,0,0.55)' g.fillRect(bx - 1, by - 1, bw + 2, 5.5) g.fillStyle = pct > 0.5 ? '#5cd74c' : pct > 0.25 ? '#ffd23e' : '#ff5f4e' g.fillRect(bx, by, bw * pct, 3.5) } } private eyes(g: CanvasRenderingContext2D, r: number): void { g.fillStyle = '#fff' g.beginPath() g.arc(-r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2) g.arc(r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2) g.fill() g.fillStyle = '#1c2026' g.beginPath() g.arc(-r * 0.28, -r * 0.1, r * 0.14, 0, Math.PI * 2) g.arc(r * 0.36, -r * 0.1, r * 0.14, 0, Math.PI * 2) g.fill() } // ------------------------------------------------------------ projectiles & effects private drawProjectile(g: CanvasRenderingContext2D, kind: 'arrow' | 'ball', x: number, y: number, angle: number): void { g.save() g.translate(x, y) g.rotate(angle) if (kind === 'arrow') { g.strokeStyle = '#d8b56a' g.lineWidth = 2.5 g.beginPath() g.moveTo(-7, 0) g.lineTo(5, 0) g.stroke() g.fillStyle = '#fff' g.beginPath() g.moveTo(8, 0) g.lineTo(3, -3) g.lineTo(3, 3) g.closePath() g.fill() } else { g.fillStyle = '#26292e' g.beginPath() g.arc(0, 0, 5, 0, Math.PI * 2) g.fill() g.fillStyle = 'rgba(255,255,255,0.35)' g.beginPath() g.arc(-1.5, -1.5, 1.8, 0, Math.PI * 2) g.fill() } g.restore() } private drawEffects(g: CanvasRenderingContext2D, eng: GameEngine): void { for (const fx of eng.effects) { const p = fx.life / fx.max switch (fx.type) { case 'particle': { g.globalAlpha = Math.min(1, p * 1.6) g.fillStyle = fx.color g.beginPath() g.arc(fx.x, fx.y, fx.size * (0.4 + p * 0.6), 0, Math.PI * 2) g.fill() g.globalAlpha = 1 break } case 'ring': { const r = fx.r0 + (fx.r1 - fx.r0) * (1 - p) g.globalAlpha = p g.strokeStyle = fx.color g.lineWidth = fx.width g.beginPath() g.arc(fx.x, fx.y, r, 0, Math.PI * 2) g.stroke() g.globalAlpha = 1 break } case 'beam': { g.save() g.globalAlpha = p g.globalCompositeOperation = 'lighter' g.strokeStyle = fx.color g.lineWidth = 7 * p + 1 g.beginPath() g.moveTo(fx.x1, fx.y1) g.lineTo(fx.x2, fx.y2) g.stroke() g.strokeStyle = '#fff' g.lineWidth = 2.5 g.beginPath() g.moveTo(fx.x1, fx.y1) g.lineTo(fx.x2, fx.y2) g.stroke() g.restore() break } case 'bolt': { g.save() g.globalAlpha = p g.globalCompositeOperation = 'lighter' for (let pass = 0; pass < 2; pass++) { g.strokeStyle = pass === 0 ? fx.color : '#fff' g.lineWidth = pass === 0 ? 4 : 1.8 g.beginPath() g.moveTo(fx.pts[0].x, fx.pts[0].y) for (let i = 1; i < fx.pts.length; i++) { const a = fx.pts[i - 1] const b = fx.pts[i] const mx = (a.x + b.x) / 2 + (Math.random() - 0.5) * 14 const my = (a.y + b.y) / 2 + (Math.random() - 0.5) * 14 g.lineTo(mx, my) g.lineTo(b.x, b.y) } g.stroke() } g.restore() break } case 'text': { g.globalAlpha = Math.min(1, p * 2) g.fillStyle = fx.color g.font = `bold ${fx.size}px system-ui` g.textAlign = 'center' g.strokeStyle = 'rgba(0,0,0,0.6)' g.lineWidth = 3 g.strokeText(fx.str, fx.x, fx.y) g.fillText(fx.str, fx.x, fx.y) g.globalAlpha = 1 break } case 'announce': { const inP = Math.min(1, (1 - p) * 5) const y = H / 2 - 30 g.globalAlpha = Math.min(1, p * 2.5) g.textAlign = 'center' g.font = `bold ${Math.round(30 + (1 - inP) * 20)}px system-ui` g.strokeStyle = 'rgba(0,0,0,0.75)' g.lineWidth = 6 g.strokeText(fx.str, W / 2, y) g.fillStyle = '#fff' g.fillText(fx.str, W / 2, y) g.font = 'bold 15px system-ui' g.strokeText(fx.sub, W / 2, y + 26) g.fillStyle = '#ffd23e' g.fillText(fx.sub, W / 2, y + 26) g.globalAlpha = 1 break } } } } private drawBossBars(g: CanvasRenderingContext2D, eng: GameEngine): void { const bosses = eng.enemies.filter((e) => e.kind === 'boss' && !e.dead) if (bosses.length === 0) return const bw = 300 for (let i = 0; i < bosses.length; i++) { const e = bosses[i] const pct = Math.max(0, e.hp / e.maxHp) const x = W / 2 - bw / 2 const y = 14 + i * 20 g.fillStyle = 'rgba(0,0,0,0.6)' this.roundRect(g, x - 2, y - 2, bw + 4, 14, 6) g.fill() g.fillStyle = '#7a2620' this.roundRect(g, x, y, bw, 10, 4) g.fill() g.fillStyle = '#e04b3a' this.roundRect(g, x, y, bw * pct, 10, 4) g.fill() g.fillStyle = '#fff' g.font = 'bold 10px system-ui' g.textAlign = 'center' g.fillText(`BOSS ${Math.ceil(e.hp)}`, W / 2, y + 8) } } // ------------------------------------------------------------ ghost private drawGhost(g: CanvasRenderingContext2D, eng: GameEngine): void { if (!eng.hover || !eng.buildType) return const def = TOWERS[eng.buildType] const tx = eng.hover.tx const ty = eng.hover.ty const cx = tx * TILE + TILE / 2 const cy = ty * TILE + TILE / 2 const valid = eng.canPlace(tx, ty) && eng.money >= def.cost const range = def.levels[0].range g.save() g.fillStyle = valid ? 'rgba(90,220,120,0.1)' : 'rgba(255,80,60,0.12)' g.strokeStyle = valid ? 'rgba(90,220,120,0.7)' : 'rgba(255,80,60,0.8)' g.lineWidth = 2 g.setLineDash([8, 6]) g.beginPath() g.arc(cx, cy, range, 0, Math.PI * 2) g.stroke() g.fill() g.setLineDash([]) g.strokeStyle = valid ? 'rgba(255,255,255,0.9)' : 'rgba(255,80,60,0.9)' g.strokeRect(tx * TILE + 2, ty * TILE + 2, TILE - 4, TILE - 4) if (valid) { this.drawTower( g, eng, { id: -1, kind: eng.buildType, tx, ty, x: cx, y: cy, level: 1, cooldown: 0, angle: -Math.PI / 2, kills: 0, damageDealt: 0, targeting: 'first', invested: 0, recoil: 0, pulse: 0, targetId: null, owner: null, }, true, ) } else { g.strokeStyle = 'rgba(255,80,60,0.95)' g.lineWidth = 4 g.beginPath() g.moveTo(cx - 10, cy - 10) g.lineTo(cx + 10, cy + 10) g.moveTo(cx + 10, cy - 10) g.lineTo(cx - 10, cy + 10) g.stroke() } g.restore() } // ------------------------------------------------------------ helpers private roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { g.beginPath() g.moveTo(x + r, y) g.arcTo(x + w, y, x + w, y + h, r) g.arcTo(x + w, y + h, x, y + h, r) g.arcTo(x, y + h, x, y, r) g.arcTo(x, y, x + w, y, r) g.closePath() } private shade(hex: string, amt: number): string { const n = parseInt(hex.slice(1), 16) const r = Math.max(0, Math.min(255, (n >> 16) + amt)) const gg = Math.max(0, Math.min(255, ((n >> 8) & 0xff) + amt)) const b = Math.max(0, Math.min(255, (n & 0xff) + amt)) return `rgb(${r},${gg},${b})` } }