feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
993
src/game/render.ts
Normal file
993
src/game/render.ts
Normal file
|
|
@ -0,0 +1,993 @@
|
|||
import { ENEMIES, H, TILE, TOWERS, W } 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
|
||||
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 = '#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})`
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue