// render.js - Canvas 2D renderer: stage, fighters, HUD, effects, debug boxes. import * as E from './engine.js'; import { drawFighter } from './fighter.js'; export class Renderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d', { alpha: false }); this.scale = 1; this.offsetX = 0; this.offsetY = 0; this.sparks = []; this.dust = []; this.shake = 0; this.resize(); } resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = this.canvas.clientWidth, h = this.canvas.clientHeight; this.canvas.width = Math.floor(w * dpr); this.canvas.height = Math.floor(h * dpr); this.dpr = dpr; // fit 1280x720 stage into canvas this.scale = Math.min(w / E.STAGE_LOCALWIDTH, h / E.STAGE_LOCALHEIGHT); this.offsetX = (w - E.STAGE_LOCALWIDTH * this.scale) / 2; this.offsetY = (h - E.STAGE_LOCALHEIGHT * this.scale) / 2; this.cssW = w; this.cssH = h; } addSpark(x, y, guarded, sparkno, sparkScale = 1) { this.sparks.push({ x, y, guarded, t: 0, life: guarded ? 12 : 16, scale: sparkScale, no: sparkno }); } hitSpark(x, y, guarded, no, scale) { this.addSpark(x, y, guarded, no, scale); } addDust(x, y, n = 4, dir = 1) { for (let i = 0; i < n; i++) { this.dust.push({ x, y, vx: (Math.random() - 0.5) * 2 + dir, vy: -Math.random() * 1.5, t: 0, life: 20 + Math.random() * 10 }); } } updateEffects() { this.sparks = this.sparks.filter(s => ++s.t < s.life); this.dust = this.dust.filter(d => { d.t++; d.x += d.vx; d.y += d.vy; d.vy += 0.08; d.vx *= 0.94; return d.t < d.life; }); if (this.shake > 0) this.shake *= 0.85; } /** camera: follow midpoint, clamp to stage bounds */ computeCamera(chars) { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const c of chars) { minX = Math.min(minX, c.pos[0] - 120); maxX = Math.max(maxX, c.pos[0] + 120); minY = Math.min(minY, c.pos[1]); maxY = Math.max(maxY, c.pos[1]); } const midX = (minX + maxX) / 2; const midY = (minY + maxY) / 2 - 30; const need = Math.max(maxX - minX, 640); const zoom = Math.min(1, (E.STAGE_LOCALWIDTH / need)); this.camZoom = this.camZoom === undefined ? zoom : this.camZoom * 0.9 + zoom * 0.1; this.camX = midX; this.camY = Math.min(midY, -40); return { x: this.camX, y: this.camY, zoom: this.camZoom }; } render(sys, debug = false) { const ctx = this.ctx; const cam = this.computeCamera(sys.chars); ctx.save(); ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, this.cssW, this.cssH); // shake let sx = 0, sy = 0; if (this.shake > 0.4) { sx = (Math.random() - 0.5) * this.shake; sy = (Math.random() - 0.5) * this.shake; } ctx.translate(this.offsetX + sx, this.offsetY + sy); ctx.scale(this.scale, this.scale); this.drawStageBackground(sys); ctx.save(); // camera transform: world -> local ctx.translate(E.STAGE_LOCALWIDTH / 2 - cam.x * cam.zoom, E.STAGE_LOCALHEIGHT - E.STAGE_FLOORY + cam.y * cam.zoom + E.STAGE_FLOORY - E.STAGE_LOCALHEIGHT + 120); ctx.scale(cam.zoom, cam.zoom); this.drawFloor(); this.drawShadows(sys.chars); // sort by z for depth const sorted = [...sys.chars].sort((a, b) => a.pos[2] - b.pos[2]); for (const c of sorted) this.drawChar(ctx, c); this.drawEffects(cam); if (debug) this.drawClsn(sys); ctx.restore(); ctx.restore(); this.drawHUD(sys); } drawStageBackground(sys) { const ctx = this.ctx; const W = E.STAGE_LOCALWIDTH, H = E.STAGE_LOCALHEIGHT; // sky gradient (dusk) const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#141a3a'); g.addColorStop(0.42, '#3d2a5e'); g.addColorStop(0.68, '#c85a3c'); g.addColorStop(0.82, '#f0a05a'); g.addColorStop(1, '#2a1a2e'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); const cam = { x: this.camX || 0, zoom: this.camZoom || 1 }; // sun const sunX = W * 0.62 - cam.x * 2; ctx.save(); const sg = ctx.createRadialGradient(sunX, H * 0.62, 10, sunX, H * 0.62, 160); sg.addColorStop(0, 'rgba(255,240,190,0.95)'); sg.addColorStop(0.4, 'rgba(255,180,90,0.55)'); sg.addColorStop(1, 'rgba(255,120,60,0)'); ctx.fillStyle = sg; ctx.fillRect(sunX - 200, H * 0.62 - 200, 400, 400); ctx.restore(); // distant mountains (parallax layers) this.mountains(cam, H * 0.72, '#241a3c', 0.6, 180, 0.9); this.mountains(cam, H * 0.78, '#1b1430', 1.0, 130, 1.6); // pagoda silhouettes this.pagoda(W * 0.16 - cam.x * 5, H * 0.72, 150, '#120e24'); this.pagoda(W * 0.86 - cam.x * 5, H * 0.76, 110, '#120e24'); // ground band const gg = ctx.createLinearGradient(0, H * 0.82, 0, H); gg.addColorStop(0, '#241830'); gg.addColorStop(0.3, '#15101f'); gg.addColorStop(1, '#0a0712'); ctx.fillStyle = gg; ctx.fillRect(0, H * 0.82, W, H * 0.18); } mountains(cam, baseY, color, par, amp, freq) { const ctx = this.ctx; const W = E.STAGE_LOCALWIDTH; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, E.STAGE_LOCALHEIGHT); for (let x = 0; x <= W; x += 16) { const wx = (x + (cam.x || 0) * par) * freq; const y = baseY - Math.abs(Math.sin(wx * 0.0031)) * amp - Math.sin(wx * 0.0017) * amp * 0.5; ctx.lineTo(x, y); } ctx.lineTo(W, E.STAGE_LOCALHEIGHT); ctx.closePath(); ctx.fill(); } pagoda(x, baseY, h, color) { const ctx = this.ctx; ctx.fillStyle = color; const tiers = 4; for (let i = 0; i < tiers; i++) { const w = (140 - i * 28) * (h / 150); const y = baseY - (h / tiers) * i - 6; ctx.beginPath(); ctx.moveTo(x - w / 2, y); ctx.lineTo(x + w / 2, y); ctx.lineTo(x + w / 2 - 8, y - (h / tiers) * 0.75); ctx.lineTo(x - w / 2 + 8, y - (h / tiers) * 0.75); ctx.closePath(); ctx.fill(); } // spire ctx.beginPath(); ctx.moveTo(x, baseY - h - 24); ctx.lineTo(x + 4, baseY - h); ctx.lineTo(x - 4, baseY - h); ctx.closePath(); ctx.fill(); } drawFloor() { const ctx = this.ctx; // wooden platform edge const y = E.STAGE_FLOORY; ctx.fillStyle = '#3b2418'; ctx.fillRect(E.STAGE_LEFTBOUND, y, E.STAGE_RIGHTBOUND - E.STAGE_LEFTBOUND, 40); ctx.fillStyle = '#5a3a24'; ctx.fillRect(E.STAGE_LEFTBOUND, y, E.STAGE_RIGHTBOUND - E.STAGE_LEFTBOUND, 8); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 2; for (let x = E.STAGE_LEFTBOUND; x < E.STAGE_RIGHTBOUND; x += 48) { ctx.beginPath(); ctx.moveTo(x, y + 8); ctx.lineTo(x, y + 40); ctx.stroke(); } } drawShadows(chars) { const ctx = this.ctx; for (const c of chars) { const w = Math.max(20, 42 - c.pos[1] * 0.12); const alpha = Math.max(0.12, 0.5 - c.pos[1] * 0.004); ctx.save(); ctx.translate(c.pos[0], E.STAGE_FLOORY); ctx.scale(1, 0.22); ctx.beginPath(); ctx.arc(0, 0, w, 0, Math.PI * 2); ctx.fillStyle = `rgba(0,0,0,${alpha})`; ctx.fill(); ctx.restore(); } } drawChar(ctx, c) { const pose = c.currentFrame(); if (!pose) return; ctx.save(); ctx.translate(c.pos[0], E.STAGE_FLOORY + c.pos[1]); ctx.scale(c.facing, -1); // y-up space, mirror by facing // hit flash: draw silhouette in white if (c.hitFlash > 0) { ctx.save(); ctx.globalCompositeOperation = 'source-over'; drawFighter(ctx, c, pose, { ...c.def.look, facing: 1, hair: c.def.look.hair }); ctx.restore(); } else { const look = { ...c.def.look, facing: 1, mouthOpen: c.stateNo === 210 || c.stateNo === 230 || c.stateNo === 410 }; drawFighter(ctx, c, pose, look); } ctx.restore(); } drawEffects(cam) { const ctx = this.ctx; // sparks for (const s of this.sparks) { const k = 1 - s.t / s.life; ctx.save(); ctx.translate(s.x, E.STAGE_FLOORY + s.y); ctx.globalAlpha = k; const r = (s.guarded ? 16 : 26) * s.scale * (0.6 + (1 - k) * 0.7); const g = ctx.createRadialGradient(0, 0, 1, 0, 0, r); if (s.guarded) { g.addColorStop(0, 'rgba(180,230,255,1)'); g.addColorStop(0.5, 'rgba(90,160,230,0.7)'); g.addColorStop(1, 'rgba(40,90,160,0)'); } else { g.addColorStop(0, 'rgba(255,255,220,1)'); g.addColorStop(0.35, 'rgba(255,200,60,0.9)'); g.addColorStop(1, 'rgba(255,90,20,0)'); } ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); // spikes ctx.strokeStyle = s.guarded ? 'rgba(200,240,255,0.8)' : 'rgba(255,240,180,0.85)'; ctx.lineWidth = 3; for (let i = 0; i < 7; i++) { const a = (i / 7) * Math.PI * 2 + s.t * 0.4; const len = r * (1.25 + (i % 2) * 0.35); ctx.beginPath(); ctx.moveTo(Math.cos(a) * r * 0.4, Math.sin(a) * r * 0.4); ctx.lineTo(Math.cos(a) * len, Math.sin(a) * len); ctx.stroke(); } ctx.restore(); } // dust for (const d of this.dust) { const k = 1 - d.t / d.life; ctx.save(); ctx.globalAlpha = k * 0.5; ctx.fillStyle = '#9a8878'; ctx.beginPath(); ctx.arc(d.x, E.STAGE_FLOORY + d.y, 3 + (1 - k) * 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } drawClsn(sys) { const ctx = this.ctx; for (const c of sys.chars) { for (const cl of c.clsn) { const b = c.worldBox(cl.box); const x0 = b[0], y0 = E.STAGE_FLOORY + b[1], x1 = b[2], y1 = E.STAGE_FLOORY + b[3]; ctx.strokeStyle = cl.type === 'hit' ? '#ff2244' : cl.type === 'throw' ? '#ff8800' : cl.type === 'push' ? '#00ccff' : '#33ff55'; ctx.lineWidth = 2; ctx.strokeRect(x0, y0, x1 - x0, y1 - y0); } } } drawHUD(sys) { const ctx = this.ctx; const W = this.cssW, H = this.cssH; ctx.save(); ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); ctx.scale(this.scale, this.scale); const w = E.STAGE_LOCALWIDTH; const pad = 40; const barW = 470, barH = 34; const [p1, p2] = sys.chars; // P1 bar (drains right-to-left) this.drawHealthBar(ctx, pad, 40, barW, barH, p1, false); // P2 bar (drains left-to-right) this.drawHealthBar(ctx, w - pad - barW, 40, barW, barH, p2, true); // names ctx.font = 'bold 26px system-ui, sans-serif'; ctx.fillStyle = '#ffe9b0'; ctx.textBaseline = 'top'; ctx.fillText(p1?.def.name || 'P1', pad, 84); ctx.textAlign = 'right'; ctx.fillText(p2?.def.name || 'CPU', w - pad, 84); ctx.textAlign = 'left'; // stun/meter bars this.drawMeterBar(ctx, pad, 116, barW * 0.62, 12, p1, '#ffcc00', 0.25); this.drawMeterBar(ctx, w - pad - barW * 0.62, 116, barW * 0.62, 12, p2, '#ffcc00', 0.25); // power bars this.drawPowerBar(ctx, pad, 82, barW * 0.5, 10, p1); this.drawPowerBar(ctx, w - pad - barW * 0.5, 82, barW * 0.5, 10, p2); // round win pips for (const c of [p1, p2]) { if (!c) continue; for (let i = 0; i < (sys.roundsToWin || 2); i++) { const cx = c.teamside === 0 ? pad + 16 + i * 26 : w - pad - 16 - i * 26; ctx.beginPath(); ctx.arc(cx, 150, 9, 0, Math.PI * 2); ctx.fillStyle = i < c.roundsWon ? '#ffd24a' : 'rgba(255,255,255,0.16)'; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = '#00000088'; ctx.stroke(); } } // timer const t = Math.max(0, sys.timer); ctx.textAlign = 'center'; ctx.font = 'bold 64px ui-monospace, monospace'; ctx.fillStyle = '#00000088'; ctx.fillText(String(Math.ceil(t)).padStart(2, '0'), w / 2 + 3, 46); ctx.fillStyle = t <= 10 ? '#ff4444' : '#ffffff'; ctx.fillText(String(Math.ceil(t)).padStart(2, '0'), w / 2, 44); ctx.font = 'bold 16px system-ui'; ctx.fillStyle = '#cccccc'; ctx.fillText('TIME', w / 2, 108); // combo counter for (const c of [p1, p2]) { if (!c || c.comboCount < 2 || sys.frame - c.lastHitFrame > 90) continue; const right = c.teamside === 1; const x = right ? w - pad - 10 : pad + 10; ctx.textAlign = right ? 'right' : 'left'; ctx.font = 'bold 40px system-ui'; ctx.fillStyle = '#ffe066'; ctx.fillText(`${c.comboCount}`, x, 200); ctx.font = 'bold 18px system-ui'; ctx.fillText('HITS', x, 244); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 16px system-ui'; ctx.fillText(`${c.comboDmg} dmg`, x, 266); } ctx.textAlign = 'left'; // state message if (sys.message && sys.messageTime > 0) { const a = Math.min(1, sys.messageTime / 20); ctx.globalAlpha = a; ctx.textAlign = 'center'; ctx.font = '900 88px system-ui, sans-serif'; ctx.fillStyle = '#000000aa'; ctx.fillText(sys.message, w / 2 + 4, 250); ctx.fillStyle = '#ffe9b0'; ctx.fillText(sys.message, w / 2, 246); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } ctx.restore(); } drawHealthBar(ctx, x, y, w, h, c, mirrored) { // frame ctx.fillStyle = '#0d0a12'; ctx.fillRect(x - 5, y - 5, w + 10, h + 10); ctx.fillStyle = '#2a2028'; ctx.fillRect(x, y, w, h); if (!c) return; const k = Math.max(0, c.hp / c.data.health); const fillW = w * k; const fx = mirrored ? x + w - fillW : x; const g = ctx.createLinearGradient(x, y, x, y + h); if (k > 0.5) { g.addColorStop(0, '#7ef07e'); g.addColorStop(0.5, '#31c431'); g.addColorStop(1, '#177f17'); } else if (k > 0.22) { g.addColorStop(0, '#ffe37e'); g.addColorStop(0.5, '#f0a020'); g.addColorStop(1, '#a05f08'); } else { g.addColorStop(0, '#ff9c9c'); g.addColorStop(0.5, '#e02020'); g.addColorStop(1, '#8a0d0d'); } ctx.fillStyle = g; ctx.fillRect(fx, y, fillW, h); // damage-trail (delayed red) const trail = Math.max(0, (c.hp + (c.hpTrail || 0)) / c.data.health); if (trail > k) { const tw = w * trail; const tx = mirrored ? x + w - tw : x; ctx.fillStyle = 'rgba(255,80,80,0.55)'; ctx.fillRect(tx, y, tw, h); } ctx.strokeStyle = '#000000cc'; ctx.lineWidth = 3; ctx.strokeRect(x, y, w, h); // tick marks ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1; for (let i = 1; i < 10; i++) { const tx = x + (w / 10) * i; ctx.beginPath(); ctx.moveTo(tx, y); ctx.lineTo(tx, y + h); ctx.stroke(); } } drawMeterBar(ctx, x, y, w, h, c, color, dangerFrac) { if (!c) return; const k = Math.min(1, c.stun / c.data.stunMax); ctx.fillStyle = '#0d0a12'; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); ctx.fillStyle = '#241c24'; ctx.fillRect(x, y, w, h); const fillW = w * k; ctx.fillStyle = k > dangerFrac ? '#ff4433' : color; ctx.fillRect(x, y, fillW, h); if (k > dangerFrac) { ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(x + fillW - 3, y, 3, h); } } drawPowerBar(ctx, x, y, w, h, c) { if (!c) return; const k = Math.min(1, c.power / c.data.powerMax); ctx.fillStyle = '#0d0a12'; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); ctx.fillStyle = '#182028'; ctx.fillRect(x, y, w, h); const g = ctx.createLinearGradient(x, 0, x + w, 0); g.addColorStop(0, '#3aa0ff'); g.addColorStop(1, '#9de0ff'); ctx.fillStyle = g; ctx.fillRect(x, y, w * k, h); } }