// fighter.js - Procedural skeletal fighter renderer. // Sprites are drawn from a pose description (like Ikemen's AnimElements) so the // engine needs no external binary assets and works fully offline in the browser. const SKIN = { palette: [ { key: 'A', main: '#f2c48c', dark: '#c08a52', line: '#5a3a1e' }, // skin { key: 'B', main: '#3b5bdb', dark: '#24389e', line: '#131c4a' }, // cloth { key: 'C', main: '#e8590c', dark: '#a63c05', line: '#4a1a02' }, // accent { key: 'D', main: '#ffffff', dark: '#c8c8c8', line: '#5a5a5a' }, // light ], }; /** * Pose = set of keyframed joints. Each pose is {t, joints:{...}} normalized. * Joint coords are in character space, origin at feet center, y negative up, * matching MUGEN's coordinate convention. Units roughly pixels at 1.0 scale. */ export const SKELETON = { hip: [0, -95], chest: [0, -135], neck: [0, -152], head: [0, -168], // arms: shoulder -> elbow -> hand shF: [4, -146], elF: [14, -126], haF: [22, -108], shB: [-4, -146], elB: [-12, -128], haB: [-18, -112], // legs: hip -> knee -> foot hipF: [4, -95], knF: [8, -50], ftF: [10, -2], hipB: [-4, -95], knB: [-8, -50], ftB: [-10, -2], }; /** Default pose: neutral idle stance */ function idlePose(t = 0) { const bob = Math.sin(t * 0.12) * 2; return { hip: [0, -95 + bob], chest: [0, -135 + bob], neck: [0, -152 + bob], head: [0, -168 + bob], shF: [5, -146 + bob], elF: [16, -124 + bob], haF: [26, -108 + bob], shB: [-5, -146 + bob], elB: [-15, -126 + bob], haB: [-24, -112 + bob], hipF: [5, -95 + bob], knF: [9, -50], ftF: [11, -2], hipB: [-5, -95 + bob], knB: [-9, -50], ftB: [-11, -2], }; } /** * Build the full animation table for a character from pose generators. * Each anim entry: { frames:[pose...], time: ticks per frame, loop:bool } */ export function buildAnims(def) { const A = {}; const mk = (name, frames, time, loop = true) => { A[name] = { frames, time, loop }; }; // ---- idle: subtle breathing ---- mk('stand', [idlePose(0), idlePose(30), idlePose(0)], 6, true); // ---- crouch ---- const crouch = { hip: [0, -58], chest: [0, -96], neck: [0, -112], head: [0, -126], shF: [6, -106], elF: [16, -88], haF: [22, -70], shB: [-6, -106], elB: [-16, -90], haB: [-22, -74], hipF: [6, -58], knF: [12, -32], ftF: [12, -2], hipB: [-6, -58], knB: [-10, -34], ftB: [-12, -2], }; mk('crouch', [crouch], 8, true); mk('crouch_turn', [crouch], 8, true); // ---- walk forward ---- const walkFrames = []; for (let i = 0; i < 6; i++) { const ph = i / 6 * Math.PI * 2; const swing = Math.sin(ph) * 18; const lift = Math.max(0, Math.sin(ph)) * 8; const bounce = Math.abs(Math.cos(ph)) * 2; walkFrames.push({ hip: [0, -95 + bounce], chest: [0, -135 + bounce], neck: [0, -152 + bounce], head: [0, -168 + bounce], shF: [5, -146 + bounce], elF: [14 - swing * 0.4, -126], haF: [20 - swing * 0.7, -110], shB: [-5, -146 + bounce], elB: [-12 + swing * 0.4, -128], haB: [-18 + swing * 0.6, -114], hipF: [5, -95 + bounce], knF: [8 + swing * 0.3, -50 + lift * 0.3], ftF: [10 + swing * 0.5, -2 - lift], hipB: [-5, -95 + bounce], knB: [-8 - swing * 0.3, -50 - lift * 0.3], ftB: [-10 - swing * 0.5, -2], }); } mk('walk_fwd', walkFrames, 5, true); // ---- walk back ---- const backFrames = walkFrames.map(f => ({ ...f, shF: [-f.shF[0], f.shF[1]], elF: [-f.elF[0], f.elF[1]], haF: [-f.haF[0], f.haF[1]], shB: [-f.shB[0], f.shB[1]], elB: [-f.elB[0], f.elB[1]], haB: [-f.haB[0], f.haB[1]], })); mk('walk_back', backFrames, 7, true); // ---- jump (rise / fall) ---- mk('jump_neutral', [{ hip: [0, -92], chest: [0, -130], neck: [0, -146], head: [0, -161], shF: [2, -140], elF: [2, -124], haF: [0, -146], shB: [-4, -140], elB: [-14, -128], haB: [-20, -116], hipF: [5, -92], knF: [12, -56], ftF: [14, -22], hipB: [-4, -92], knB: [-6, -54], ftB: [-8, -20], }], 6, false); mk('jump_fwd', [{ hip: [0, -92], chest: [0, -130], neck: [0, -146], head: [0, -161], shF: [8, -138], elF: [20, -126], haF: [30, -116], shB: [-8, -140], elB: [-20, -130], haB: [-30, -120], hipF: [8, -92], knF: [20, -58], ftF: [26, -24], hipB: [-6, -92], knB: [-2, -52], ftB: [-2, -18], }], 6, false); mk('jump_back', [{ hip: [0, -92], chest: [0, -130], neck: [0, -146], head: [0, -161], shF: [4, -142], elF: [10, -132], haF: [16, -122], shB: [-4, -140], elB: [-10, -128], haB: [-14, -116], hipF: [4, -92], knF: [10, -56], ftF: [8, -22], hipB: [-4, -92], knB: [-10, -54], ftB: [-14, -20], }], 6, false); // ---- crouching attack poses ---- mk('crouch_light', [ { ...crouch, shF: [10, -100], elF: [26, -92], haF: [38, -86] }, { ...crouch, shF: [12, -100], elF: [30, -90], haF: [44, -84] }, ], 4, false); mk('crouch_heavy', [ { ...crouch, shF: [12, -104], elF: [28, -96], haF: [40, -92], chest: [4, -96] }, { ...crouch, shF: [14, -106], elF: [32, -98], haF: [46, -94], chest: [6, -96] }, ], 6, false); // ---- standing punches ---- mk('stand_light', [ { ...idlePose(0), shF: [8, -146], elF: [22, -138], haF: [30, -134] }, { ...idlePose(0), shF: [10, -146], elF: [26, -138], haF: [36, -134], chest: [2, -135] }, ], 3, false); mk('stand_medium', [ { ...idlePose(0), shF: [10, -146], elF: [28, -142], haF: [40, -140], chest: [3, -135] }, { ...idlePose(0), shF: [12, -148], elF: [34, -144], haF: [48, -142], chest: [5, -135] }, ], 4, false); mk('stand_heavy', [ { ...idlePose(0), shF: [8, -150], elF: [26, -150], haF: [36, -150], chest: [2, -135] }, { ...idlePose(0), shF: [10, -150], elF: [32, -152], haF: [44, -152], chest: [5, -135] }, ], 6, false); // ---- kick ---- const kickBase = { hip: [0, -95], chest: [0, -135], neck: [0, -152], head: [0, -168], shF: [4, -146], elF: [12, -124], haF: [20, -110], shB: [-6, -146], elB: [-16, -128], haB: [-24, -114], hipF: [5, -95], knF: [9, -50], ftF: [11, -2], hipB: [-5, -95], knB: [-9, -50], ftB: [-11, -2], }; mk('stand_kick', [ { ...kickBase, hip: [0, -98], knF: [22, -84], ftF: [30, -56], chest: [-2, -137] }, { ...kickBase, hip: [0, -100], knF: [30, -90], ftF: [44, -66], chest: [-4, -138] }, ], 5, false); // ---- air attacks ---- const airBase = A['jump_neutral'].frames[0]; mk('air_light', [{ ...airBase, shF: [8, -134], elF: [22, -126], haF: [32, -120] }], 5, false); mk('air_heavy', [{ ...airBase, shF: [10, -138], elF: [28, -132], haF: [42, -128], hip: [0, -90] }], 6, false); mk('air_kick', [{ ...airBase, knF: [18, -76], ftF: [26, -52], hip: [0, -88] }], 5, false); // ---- special / projectile ---- mk('special', [ { ...idlePose(0), shF: [6, -140], elF: [4, -122], haF: [0, -104], chest: [-3, -136] }, { ...idlePose(0), shF: [8, -144], elF: [8, -128], haF: [4, -110], chest: [-4, -136] }, { ...idlePose(0), shF: [10, -148], elF: [18, -134], haF: [22, -122], chest: [-2, -135] }, ], 5, false); mk('special_air', [{ ...airBase, shF: [8, -136], elF: [2, -120], haF: [-2, -104] }], 6, false); // ---- uppercut / anti-air ---- mk('anti_air', [ { ...kickBase, hip: [0, -92], knF: [8, -60], ftF: [10, -30], shF: [4, -142], elF: [10, -124], haF: [16, -106], chest: [0, -132] }, { ...kickBase, hip: [0, -86], knF: [8, -56], ftF: [10, -26], shF: [6, -146], elF: [14, -128], haF: [20, -110], chest: [2, -136] }, { ...kickBase, hip: [0, -80], knF: [10, -52], ftF: [12, -22], shF: [8, -150], elF: [18, -136], haF: [26, -122], chest: [4, -140] }, ], 4, false); // ---- hurt / knockdown ---- mk('hurt_stand', [{ hip: [0, -93], chest: [-4, -133], neck: [-6, -150], head: [-10, -165], shF: [2, -144], elF: [8, -128], haF: [4, -118], shB: [-8, -144], elB: [-20, -130], haB: [-28, -118], hipF: [5, -93], knF: [11, -50], ftF: [14, -2], hipB: [-8, -93], knB: [-12, -48], ftB: [-16, -2], }], 5, false); mk('hurt_air', [{ hip: [0, -88], chest: [-6, -126], neck: [-8, -142], head: [-12, -156], shF: [-2, -138], elF: [-14, -126], haF: [-24, -114], shB: [-10, -136], elB: [-24, -126], haB: [-34, -116], hipF: [10, -88], knF: [22, -60], ftF: [26, -34], hipB: [-10, -88], knB: [-22, -58], ftB: [-26, -32], }], 6, false); mk('hurt_crouch', [{ hip: [0, -56], chest: [-5, -92], neck: [-7, -108], head: [-10, -122], shF: [4, -102], elF: [10, -88], haF: [6, -78], shB: [-8, -102], elB: [-18, -90], haB: [-24, -80], hipF: [7, -56], knF: [13, -32], ftF: [14, -2], hipB: [-8, -56], knB: [-12, -30], ftB: [-16, -2], }], 5, false); mk('knockdown', [{ hip: [0, -20], chest: [-6, -40], neck: [-10, -50], head: [-20, -60], shF: [-10, -46], elF: [-30, -40], haF: [-46, -34], shB: [-20, -44], elB: [-44, -38], haB: [-60, -32], hipF: [10, -20], knF: [26, -16], ftF: [34, -8], hipB: [-6, -20], knB: [-24, -16], ftB: [-36, -8], }], 8, false); mk('lie_down', [{ hip: [0, -14], chest: [-8, -22], neck: [-16, -26], head: [-30, -28], shF: [-12, -28], elF: [-34, -24], haF: [-52, -20], shB: [-24, -26], elB: [-48, -22], haB: [-66, -18], hipF: [16, -12], knF: [38, -10], ftF: [56, -6], hipB: [-10, -12], knB: [-30, -10], ftB: [-46, -6], }], 10, false); // ---- dizzy ---- mk('dizzy', [ { hip: [0, -90], chest: [0, -128], neck: [-4, -144], head: [-10, -158], shF: [0, -138], elF: [10, -126], haF: [14, -136], shB: [-8, -138], elB: [-18, -126], haB: [-22, -136], hipF: [4, -90], knF: [8, -48], ftF: [10, -2], hipB: [-4, -90], knB: [-8, -48], ftB: [-10, -2] }, { hip: [0, -89], chest: [2, -127], neck: [0, -143], head: [-6, -157], shF: [2, -137], elF: [12, -125], haF: [16, -125], shB: [-6, -137], elB: [-16, -125], haB: [-20, -125], hipF: [4, -89], knF: [8, -47], ftF: [10, -2], hipB: [-4, -89], knB: [-8, -47], ftB: [-10, -2] }, ], 10, true); // ---- guard ---- mk('guard', [{ hip: [0, -92], chest: [-4, -132], neck: [-4, -148], head: [-6, -164], shF: [4, -144], elF: [-2, -130], haF: [-8, -146], shB: [-6, -144], elB: [-16, -132], haB: [-10, -142], hipF: [5, -92], knF: [9, -48], ftF: [11, -2], hipB: [-5, -92], knB: [-9, -48], ftB: [-11, -2], }], 4, true); // ---- victory / taunt ---- mk('win', [ { ...idlePose(0), shF: [6, -150], elF: [16, -162], haF: [22, -174], chest: [0, -136] }, { ...idlePose(0), shF: [6, -152], elF: [14, -166], haF: [18, -178], chest: [0, -136] }, ], 8, true); // custom anims from character definition override/extend if (def.anims) { for (const k in def.anims) A[k] = def.anims[k]; } return A; } export { SKIN, idlePose }; /** * Draw a character using its current pose. * ctx must be already translated to the character's feet position, * and scaled so 1 unit = 1 pixel, with y pointing UP (caller does ctx.scale(1,-1)). */ export function drawFighter(ctx, char, pose, opts = {}) { const skin = opts.skin || 'A'; const cloth = opts.cloth || 'B'; const accent = opts.accent || 'C'; const S = SKIN.palette; const col = k => S.find(p => p.key === k); const cS = col(skin), cB = col(cloth), cC = col(accent); const bodyMain = opts.bodyColor || cB.main; const bodyDark = opts.bodyDark || cB.dark; const bodyLine = opts.bodyLine || cB.line; const skinMain = opts.skinColor || cS.main; const skinDark = opts.skinDark || cS.dark; const skinLine = opts.skinLine || cS.line; const accMain = opts.accentColor || cC.main; const accDark = opts.accentDark || cC.dark; const p = pose; if (!p) return; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // --- helper to draw a limb segment as a tapered capsule --- const limb = (a, b, r1, r2, fill, stroke) => { const dx = b[0] - a[0], dy = b[1] - a[1]; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len, ny = dx / len; const ang = Math.atan2(dy, dx); ctx.beginPath(); ctx.moveTo(a[0] + nx * r1, a[1] + ny * r1); ctx.lineTo(b[0] + nx * r2, b[1] + ny * r2); ctx.arc(b[0], b[1], r2, Math.atan2(ny, nx), Math.atan2(-ny, -nx), false); ctx.lineTo(a[0] - nx * r1, a[1] - ny * r1); ctx.arc(a[0], a[1], r1, Math.atan2(-ny, -nx), Math.atan2(ny, nx), false); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); if (stroke) { ctx.lineWidth = 2; ctx.strokeStyle = stroke; ctx.stroke(); } }; const mirror = opts.facing < 0 ? -1 : 1; // ---- BACK LEG ---- limb(p.hipB, p.knB, 7.5, 6, bodyDark, bodyLine); limb(p.knB, p.ftB, 6, 4.5, bodyDark, bodyLine); drawFoot(ctx, p.ftB, 7, 4, bodyDark, bodyLine); // ---- BACK ARM ---- limb(p.shB, p.elB, 6.5, 5, opts.backArm || bodyDark, bodyLine); limb(p.elB, p.haB, 5, 4, opts.backArm || bodyDark, bodyLine); drawFist(ctx, p.haB, 5.5, opts.backArm || skinDark, skinLine); // ---- TORSO ---- drawTorso(ctx, p, bodyMain, bodyDark, bodyLine, accMain, accDark, mirror); // ---- FRONT LEG ---- limb(p.hipF, p.knF, 8, 6.5, bodyMain, bodyLine); limb(p.knF, p.ftF, 6.5, 5, bodyMain, bodyLine); drawFoot(ctx, p.ftF, 8, 4.5, bodyMain, bodyLine); // ---- HEAD ---- drawHead(ctx, p, skinMain, skinDark, skinLine, bodyLine, opts, mirror); // ---- FRONT ARM ---- limb(p.shF, p.elF, 7, 5.5, bodyMain, bodyLine); limb(p.elF, p.haF, 5.5, 4.5, bodyMain, bodyLine); drawFist(ctx, p.haF, 6, opts.frontArm ? opts.frontArm : skinMain, skinLine); } function drawTorso(ctx, p, main, dark, line, acc, accDark, mirror) { const hip = p.hip, chest = p.chest, neck = p.neck; const wHip = 11, wChest = 14, wNeck = 7; ctx.beginPath(); ctx.moveTo(hip[0] - wHip, hip[1] + 4); ctx.quadraticCurveTo(hip[0] - wHip - 2, (hip[1] + chest[1]) / 2, chest[0] - wChest, chest[1] + 2); ctx.lineTo(chest[0] - wNeck, neck[1]); ctx.lineTo(chest[0] + wNeck, neck[1]); ctx.lineTo(chest[0] + wChest, chest[1] + 2); ctx.quadraticCurveTo(hip[0] + wHip + 2, (hip[1] + chest[1]) / 2, hip[0] + wHip, hip[1] + 4); ctx.closePath(); ctx.fillStyle = main; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = line; ctx.stroke(); // accent sash across chest ctx.beginPath(); ctx.moveTo(chest[0] - wChest + 1, chest[1] + 4); ctx.lineTo(chest[0] + wChest - 1, chest[1] - 6); ctx.lineTo(chest[0] + wChest - 3, chest[1] - 10); ctx.lineTo(chest[0] - wChest + 3, chest[1] + 1); ctx.closePath(); ctx.fillStyle = acc; ctx.fill(); ctx.strokeStyle = line; ctx.stroke(); // belt ctx.beginPath(); ctx.ellipse(hip[0], hip[1] + 3, wHip + 1.5, 4.5, 0, 0, Math.PI * 2); ctx.fillStyle = accDark; ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = line; ctx.stroke(); } function drawHead(ctx, p, main, dark, line, bodyLine, opts, mirror) { const n = p.neck, h = p.head; // neck ctx.beginPath(); ctx.moveTo(n[0] - 4.5, n[1] - 2); ctx.lineTo(n[0] + 4.5, n[1] - 2); ctx.lineTo(h[0] + 5, h[1] + 6); ctx.lineTo(h[0] - 5, h[1] + 6); ctx.closePath(); ctx.fillStyle = dark; ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = line; ctx.stroke(); // skull const r = 13; ctx.beginPath(); ctx.ellipse(h[0], h[1], r, r * 1.12, 0, 0, Math.PI * 2); ctx.fillStyle = main; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = line; ctx.stroke(); // hair / headband if (opts.hair !== false) { ctx.beginPath(); ctx.ellipse(h[0] - mirror * 1, h[1] + 4, r * 0.98, r * 0.95, 0, Math.PI * 0.95, Math.PI * 2.05); ctx.closePath(); ctx.fillStyle = opts.hairColor || '#2b2118'; ctx.fill(); // headband tail ctx.beginPath(); ctx.moveTo(h[0] - mirror * 6, h[1] + 3); ctx.lineTo(h[0] - mirror * 22, h[1] - 4); ctx.lineTo(h[0] - mirror * 20, h[1] - 10); ctx.closePath(); ctx.fillStyle = opts.accentColor || '#e8590c'; ctx.fill(); ctx.strokeStyle = bodyLine; ctx.lineWidth = 1.5; ctx.stroke(); } // face ctx.fillStyle = '#1a1a1a'; const eyeY = h[1] + 1; ctx.beginPath(); ctx.ellipse(h[0] + mirror * 4.5, eyeY, 1.9, 2.6, 0, 0, Math.PI * 2); ctx.fill(); // brow ctx.strokeStyle = '#2b2118'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(h[0] + mirror * 1.5, eyeY - 5); ctx.lineTo(h[0] + mirror * 8.5, eyeY - 3.5); ctx.stroke(); // mouth ctx.lineWidth = 1.6; ctx.strokeStyle = '#5a3a1e'; ctx.beginPath(); if (opts.mouthOpen) { ctx.ellipse(h[0] + mirror * 5, eyeY + 7, 3.2, 2.6, 0, 0, Math.PI * 2); ctx.fillStyle = '#5a2010'; ctx.fill(); } else { ctx.moveTo(h[0] + mirror * 2.5, eyeY + 7.5); ctx.lineTo(h[0] + mirror * 8, eyeY + 7.5); ctx.stroke(); } } function drawFist(ctx, p, r, fill, line) { ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.lineWidth = 1.8; ctx.strokeStyle = line; ctx.stroke(); } function drawFoot(ctx, p, w, h, fill, line) { ctx.beginPath(); ctx.ellipse(p[0], p[1] - h * 0.3, w, h, 0, 0, Math.PI * 2); ctx.fillStyle = fill; ctx.fill(); ctx.lineWidth = 1.8; ctx.strokeStyle = line; ctx.stroke(); }