TRXTD/src/game/render.ts
Tronax ef75203db7
feat(duel): allow swapping main view to opponent's board
- Add a toggle on the picture-in-picture window to swap the big board
  with the mini view of the opponent's field
- Color-coded frame and badge identify whose board is currently shown
  (blue = player 1, orange = player 2)
- Block building and rushing while spectating the opponent's board;
  the PiP then shows your own field instead
- Clarify duel-mode descriptions in lobby and start screen, add hints
  in the multiplayer bar
- Replace previous announcement banners so only one shows at a time
2026-08-16 13:35:46 +02:00

993 lines
29 KiB
TypeScript

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})`
}
}