sfdemo / js /render.js
HelloSun's picture
Build playable pure HTML and JavaScript fight demo
c7b3e0f verified
Raw History Blame Contribute Delete
15.5 kB
// 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);
}
}