Download js/render.js from HelloSun/sfdemo: direct link, hf CLI and curl.
- Browser
- Download file 15.5 kB
-
https://huggingface.co/spaces/HelloSun/sfdemo/resolve/main/js/render.js
- Command line
-
hf download hf://spaces/HelloSun/sfdemo/js/render.js
-
curl -L -o render.js https://huggingface.co/spaces/HelloSun/sfdemo/resolve/main/js/render.js
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); | |
| } | |
| } |