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