/* Integer-raster directional animation rig for the Doll Atelier paper doll.
 * Every paint operation has integer coordinates and opaque palette colours.
 * The 64 px character is placed on a 96 px action canvas; no art is rescaled.
 */
(function () {
  'use strict';

  const metadata = {
    size: 96,
    characterSize: 64,
    anchor: [48, 76],
    directions: ['down', 'left', 'right', 'up'],
    actions: {
      idle: { frames: 4, fps: 4, loop: true },
      walk: { frames: 8, fps: 10, loop: true },
      attack: { frames: 8, fps: 12, loop: false, impactFrames: [3, 4] },
    },
    layers: ['hair-back', 'far-arm', 'weapon-back', 'effects-back', 'legs', 'torso', 'near-arm', 'face', 'eyes', 'hair-front', 'weapon-front', 'effects'],
    eyes: ['明亮眼', '半瞇眼', '微笑眼', '堅定眼'],
    face: ['圓潤臉', '尖下巴', '方圓臉', '長橢圓臉'],
    hair: ['短翹髮', '齊耳鮑伯', '側馬尾', '蓬鬆捲髮'],
    body: ['纖細', '圓潤', '寬肩'],
    outfit: ['森林遊俠', '星月法師', '學院書生', '遠行旅人'],
    palette: ['苔綠・栗棕', '靛藍・午夜', '陶紅・蜂蜜', '青綠・銀霜'],
    weapon: 'short-sword',
    handedness: 'right',
    notes: [
      'Front head reuses PixelDoll assets at native scale. Side and rear views are separately drawn.',
      'All outfits share one short-sword attack rig. The robe has a split hem for walking.',
      'Eyes are intentionally invisible in the rear view. Side geometry shares a rig, with directional highlights and accessory placement.',
      'Effects and weapons can be exported independently. Ordered layers exactly reproduce the composite.',
    ],
  };
  const palettes = [
    { skin: '#e3af83', skinHi: '#f6cca0', skinSh: '#b97560', blush: '#c98575', hair: '#82513e', hairSh: '#4d3534', hairHi: '#b67a4e', coat: '#6e8858', coatHi: '#a6b473', coatSh: '#435b48', dark: '#344843', lining: '#c3c39a', accent: '#e0af55' },
    { skin: '#ca9478', skinHi: '#e6b394', skinSh: '#a36859', blush: '#b86e69', hair: '#353b54', hairSh: '#252b40', hairHi: '#626a88', coat: '#646c99', coatHi: '#979fbd', coatSh: '#414769', dark: '#333c54', lining: '#c2c6ce', accent: '#d3b97a' },
    { skin: '#efbd95', skinHi: '#ffdbac', skinSh: '#c38a70', blush: '#d68c83', hair: '#c29351', hairSh: '#825c3e', hairHi: '#efd58c', coat: '#a45b51', coatHi: '#d18c6a', coatSh: '#704546', dark: '#50424c', lining: '#e2c39c', accent: '#e0b660' },
    { skin: '#ac7969', skinHi: '#c9957a', skinSh: '#805552', blush: '#ad6968', hair: '#a8b7b9', hairSh: '#687c89', hairHi: '#dde3d4', coat: '#508c89', coatHi: '#91bbb0', coatSh: '#36616b', dark: '#354a60', lining: '#c0d7ca', accent: '#e5c270' },
  ];
  const headCache = new Map();
  const ink = '#2c2935', leather = '#6d4d43', leatherHi = '#9c7055';
  const steel = '#b8d0d3', steelHi = '#eff5dc', steelSh = '#687f91';
  const mod = (n, len) => ((n % len) + len) % len;

  function render(canvas, input = {}, options = {}) {
    const c = {};
    for (const key of ['eyes', 'face', 'hair', 'body', 'outfit', 'palette']) {
      c[key] = mod(Number.isFinite(Number(input[key])) ? Math.trunc(Number(input[key])) : 0, metadata[key].length);
    }
    const direction = metadata.directions.includes(options.direction) ? options.direction : 'down';
    const action = metadata.actions[options.action] ? options.action : 'idle';
    const frame = mod(Number.isFinite(Number(options.frame)) ? Math.trunc(Number(options.frame)) : 0, metadata.actions[action].frames);
    canvas.width = canvas.height = 96;
    const ctx = canvas.getContext('2d');
    ctx.imageSmoothingEnabled = false;
    ctx.clearRect(0, 0, 96, 96);
    const p = palettes[c.palette];
    const side = direction === 'left' || direction === 'right';
    const rear = direction === 'up';
    const sign = direction === 'left' ? -1 : 1;
    const half = side ? [5, 6, 7][c.body] : [7, 9, 11][c.body];
    const stride = action === 'walk' ? [0, 3, 5, 3, 0, -3, -5, -3][frame] : 0;
    const bob = action === 'walk' ? [0, -1, 0, 0, 0, -1, 0, 0][frame] : action === 'idle' ? [0, 0, -1, 0][frame] : [0, 0, -1, 0, 1, 1, 0, 0][frame];
    const lean = action === 'attack' && side ? sign * [0, -1, -2, 1, 2, 1, 0, 0][frame] : 0;
    const sway = action === 'walk' ? [0, 0, 1, 1, 0, 0, -1, -1][frame] : action === 'attack' ? [0, 0, -1, 1, 2, 1, 0, 0][frame] : 0;

    function rect(x, y, w, h, colour) {
      ctx.fillStyle = colour;
      ctx.fillRect(Math.round(x) + 16, Math.round(y) + 16, Math.round(w), Math.round(h));
    }
    function polygon(points, colour, dx = 0, dy = 0) {
      const low = Math.floor(Math.min(...points.map(a => a[1])));
      const high = Math.ceil(Math.max(...points.map(a => a[1])));
      for (let y = low; y < high; y++) {
        const crosses = [], scan = y + 0.5;
        for (let i = 0; i < points.length; i++) {
          const a = points[i], b = points[(i + 1) % points.length];
          if ((a[1] <= scan && b[1] > scan) || (b[1] <= scan && a[1] > scan)) crosses.push(a[0] + (scan - a[1]) * (b[0] - a[0]) / (b[1] - a[1]));
        }
        crosses.sort((a, b) => a - b);
        for (let i = 0; i + 1 < crosses.length; i += 2) {
          const first = Math.ceil(crosses[i] - 0.5), last = Math.ceil(crosses[i + 1] - 0.5);
          rect(first + dx, y + dy, last - first, 1, colour);
        }
      }
    }
    function shape(points, colour) {
      for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) polygon(points, ink, dx, dy);
      polygon(points, colour);
    }
    function line(a, b, colour, width = 1) {
      let x = Math.round(a[0]), y = Math.round(a[1]);
      const x2 = Math.round(b[0]), y2 = Math.round(b[1]), dx = Math.abs(x2 - x), sx = x < x2 ? 1 : -1, dy = -Math.abs(y2 - y), sy = y < y2 ? 1 : -1;
      let error = dx + dy;
      for (;;) {
        rect(x - Math.floor(width / 2), y - Math.floor(width / 2), width, width, colour);
        if (x === x2 && y === y2) break;
        const twice = error * 2;
        if (twice >= dy) { error += dy; x += sx; }
        if (twice <= dx) { error += dx; y += sy; }
      }
    }
    const at = ([x, y]) => [x + lean, y + bob];
    const bodyShape = (points, colour) => shape(points.map(at), colour);
    const bodyPoly = (points, colour) => polygon(points.map(at), colour);
    const bodyRect = (x, y, w, h, colour) => rect(x + lean, y + bob, w, h, colour);
    const spt = ([x, y]) => [32 + sign * (x - 32) + lean, y + bob];
    const sideShape = (points, colour) => shape(points.map(spt), colour);
    const sidePoly = (points, colour) => polygon(points.map(spt), colour);
    const sideLine = (a, b, colour, width) => line(spt(a), spt(b), colour, width);
    function frontAsset(layer, dx = 0, dy = 0) {
      if (!window.PixelDoll) throw new Error('PixelMotion requires pixel-renderer.js before pixel-motion.js.');
      const key = [layer, c.face, c.eyes, c.hair, c.palette].join(':');
      if (!headCache.has(key)) {
        const source = document.createElement('canvas');
        window.PixelDoll.render(source, c, { layer });
        headCache.set(key, source);
      }
      ctx.drawImage(headCache.get(key), 16 + lean + dx, 16 + bob + dy);
    }

    function hairBack() {
      if (!side && !rear) { frontAsset('hair-back', c.hair === 2 ? sway : 0); return; }
      if (side) {
        if (c.hair === 2) {
          sideShape([[26, 14], [21, 14], [18 + sway, 20], [19 + sway, 26], [22 + sway, 28], [19 + sway, 35], [22 + sway, 39], [26, 34], [24, 28], [27, 21]], p.hair);
          sidePoly([[22, 17], [20 + sway, 21], [21 + sway, 26], [24, 28], [21 + sway, 35], [23, 35], [26, 28], [23, 23]], p.hairSh);
          sideLine([21, 19], [21 + sway, 24], p.hairHi, 1);
        }
        const bottom = c.hair === 1 ? 35 : c.hair === 3 ? 34 : 29;
        sideShape([[24, 15], [29, 10], [36, 11], [40, 16], [38, 28], [31, bottom], [25, bottom - 1], [22, 26], [22, 19]], p.hairSh);
        if (c.hair === 1) sideLine([24, 21], [25, 32], p.hair, 2);
        if (c.hair === 3) {
          sideShape([[23, 22], [25, 24], [23, 28], [26, 30], [24, 34], [21, 31], [20, 26]], p.hair);
          sideLine([22, 27], [23, 29], p.hairHi, 1);
        }
      } else if (c.hair === 2) {
        bodyShape([[24, 14], [19, 14], [16 + sway, 20], [18 + sway, 26], [20, 28], [17 + sway, 34], [21 + sway, 39], [25, 34], [23, 27], [26, 19]], p.hair);
        bodyPoly([[19, 18], [18 + sway, 22], [21, 27], [19 + sway, 34], [21, 36], [23, 29], [21, 23]], p.hairSh);
      }
    }

    // Right hand is screen-left from the front, screen-right from the rear.
    const swordShoulder = side ? at([32 + sign * 1, 37]) : at([32 + (rear ? half : -half), 37]);
    const freeShoulder = side ? at([32 - sign * 2, 37]) : at([32 + (rear ? -half : half), 37]);
    let swordHand, freeHand, swordVector;
    if (side) {
      swordHand = at([32 + sign * (2 - Math.round(stride * .6)), 46]);
      freeHand = at([32 + sign * (Math.round(stride * .65)), 46]);
      swordVector = [sign * .35, .94];
      if (action === 'attack') {
        const hand = [[34, 46], [29, 40], [29, 34], [42, 39], [46, 44], [42, 48], [36, 47], [34, 46]][frame];
        swordHand = spt(hand);
        const vector = [[.35, .94], [-.65, -.76], [-.15, -1], [.8, -.6], [1, .15], [.55, .85], [.4, .9], [.35, .94]][frame];
        swordVector = [vector[0] * sign, vector[1]];
        freeHand = spt([[31, 46], [29, 46], [28, 45], [26, 44], [25, 43], [28, 45], [30, 46], [31, 46]][frame]);
      }
    } else {
      const a = rear ? 1 : -1;
      swordHand = at([32 + a * (half + 2), 46 - Math.round(stride * .45)]);
      freeHand = at([32 - a * (half + 2), 46 + Math.round(stride * .45)]);
      swordVector = [a * .32, .95];
      if (action === 'attack') {
        const hand = [[half + 2, 46], [half + 4, 40], [half, 34], [-1, 39], [-half - 2, 46], [-2, 49], [half, 47], [half + 2, 46]][frame];
        swordHand = at([32 + a * hand[0], rear && frame >= 2 && frame <= 4 ? hand[1] - 5 : hand[1]]);
        const vector = rear
          ? [[.32, .95], [.8, -.6], [.3, -1], [-.7, -.7], [-1, -.15], [-.2, .95], [.25, .95], [.32, .95]][frame]
          : [[.32, .95], [.9, -.5], [.3, -1], [-.95, -.05], [-.55, .85], [.1, 1], [.3, .95], [.32, .95]][frame];
        swordVector = [a * vector[0], vector[1]];
        freeHand = at([32 - a * (half + 2 + (frame >= 2 && frame <= 5 ? 2 : 0)), 46 + (frame >= 2 && frame <= 4 ? -2 : 0)]);
      }
    }
    function arm(shoulder, hand, isFar) {
      const elbow = [Math.round(shoulder[0] * .42 + hand[0] * .58), Math.round(shoulder[1] * .42 + hand[1] * .58)];
      const shirt = c.outfit === 2 ? p.lining : c.outfit === 1 ? p.coatSh : p.coat;
      line(shoulder, elbow, ink, 7);
      line(shoulder, elbow, isFar ? p.coatSh : shirt, 5);
      line([shoulder[0] - 1, shoulder[1]], [elbow[0] - 1, elbow[1] - 1], isFar ? p.dark : c.outfit === 2 ? '#eee0bf' : p.coatHi, 1);
      line(elbow, hand, ink, 5);
      line(elbow, hand, p.skin, 3);
      line([elbow[0] - 1, elbow[1] - 1], [elbow[0] + 1, elbow[1] - 1], c.outfit === 1 ? p.accent : leather, 2);
      rect(hand[0] - 1, hand[1] - 1, 3, 3, p.skin);
      rect(hand[0] - 1, hand[1] - 1, 2, 1, p.skinHi);
    }
    const swordIsFar = rear || direction === 'left';
    function farArm() { arm(swordIsFar ? swordShoulder : freeShoulder, swordIsFar ? swordHand : freeHand, true); }
    function nearArm() { arm(swordIsFar ? freeShoulder : swordShoulder, swordIsFar ? freeHand : swordHand, false); }

    function boot(x, y, back) {
      if (side) {
        shape([[x - sign * 3, y - 6], [x + sign * 2, y - 6], [x + sign * 2, y - 2], [x + sign * 5, y - 1], [x + sign * 5, y + 1], [x - sign * 3, y + 1], [x - sign * 3, y - 2]], back ? '#57413e' : leather);
        line([x - 2, y - 4], [x + 1, y - 4], leatherHi, 1);
        line([x - sign * 2, y - 1], [x + sign * 4, y - 1], back ? leather : leatherHi, 1);
      } else {
        shape([[x - 3, y - 6], [x + 2, y - 6], [x + 3, y - 1], [x + 3, y + 1], [x - 4, y + 1], [x - 4, y - 1]], back ? '#57413e' : leather);
        rect(x - 2, y - 5, 4, 1, leatherHi);
        rect(x - 3, y - 1, 5, 1, rear ? leather : leatherHi);
      }
    }
    function legs() {
      const attackWide = action === 'attack' && frame >= 1 && frame <= 5 ? 2 : 0;
      const feet = [];
      for (let n = 0; n < 2; n++) {
        const sideSign = n === 0 ? -1 : 1;
        const gait = n === 0 ? stride : -stride;
        const phase = action === 'walk' ? mod(frame + n * 4, 8) : 0;
        const lift = action === 'walk' ? [0, 1, 2, 1, 0, 0, 0, 0][phase] : 0;
        const x = side ? 32 + sign * (sideSign * (2 + attackWide) + gait) : 32 + sideSign * (5 + attackWide) + (gait > 2 ? sideSign : gait < -2 ? -sideSign : 0);
        // Frontal motion needs more foreshortening than profile motion: one
        // boot lifts four pixels while the opposite boot holds the ground.
        const y = 59 - lift * (side ? 1 : 2);
        const hip = side ? at([32 + sideSign * 1, 47]) : at([32 + sideSign * 4, 47]);
        const knee = [Math.round((hip[0] + x) / 2) + (side && lift ? sign * 2 : 0), 52 - Math.round(lift / 2)];
        feet.push({ x, y, hip, knee, back: side ? n === 0 : gait < 0 });
      }
      if (!side) feet.sort((a, b) => a.y - b.y);
      for (const foot of feet) {
        line(foot.hip, foot.knee, ink, 7);
        line(foot.knee, [foot.x, foot.y - 4], ink, 6);
        line(foot.hip, foot.knee, foot.back ? p.dark : p.coatSh, 5);
        line(foot.knee, [foot.x, foot.y - 4], foot.back ? p.dark : p.coatSh, 4);
        line([foot.knee[0] - 1, foot.knee[1]], [foot.x - 1, foot.y - 5], p.coat, 1);
        boot(foot.x, foot.y, foot.back);
      }
    }

    function torso() {
      const left = 32 - half, right = 32 + half;
      bodyShape([[29, 31], [35, 31], [35, 37], [29, 37]], p.skin);
      bodyRect(30, 31, 4, 3, p.skinSh);
      if (side) {
        const hem = c.outfit === 1 ? 55 : c.outfit === 2 ? 49 : 52;
        sideShape([[32 - half + 1, 35], [33 + half - 1, 35], [34 + half - 1, 43], [34 + half, hem - 1], [31, hem], [30 - half, hem - 1], [31 - half, 44]], c.outfit === 2 ? p.coatSh : p.coat);
        sidePoly([[32 - half + 1, 37], [32 - half + 3, 38], [31 - half + 3, hem - 2], [31 - half + 1, hem - 2]], p.coatSh);
        sideLine([33 + half - 2, 38], [33 + half - 1, hem - 3], p.coatHi, 1);
        if (c.outfit === 0) {
          sideShape([[31 - half, 34], [32, 33], [35, 35], [32, 40], [29 - half, 40]], p.dark);
          sideLine([31 - half, 36], [31, 35], p.coatHi, 1);
          sideLine([32 - half, 46], [33 + half, 46], leather, 3);
          if (direction === 'left') sideShape([[31, 46], [36, 46], [36, 51], [31, 51]], leatherHi);
        } else if (c.outfit === 1) {
          sideLine([31 - half, 54], [34 + half, 54], p.accent, 2);
          sideLine([33 + half - 1, 36], [33 + half - 1, 52], p.lining, 1);
          sideLine([32 - half, 46], [33 + half, 46], p.dark, 2);
          sideLine([34, 48], [33, 52], p.coatSh, 1);
        } else if (c.outfit === 2) {
          sidePoly([[32 + half - 1, 35], [34 + half - 1, 35], [34 + half - 1, 43], [32 + half - 1, 41]], p.lining);
          if (direction === 'left') {
            sideShape([[28, 45], [34, 45], [34, 52], [28, 52]], leather);
            sidePoly([[29, 46], [33, 46], [33, 50], [29, 50]], p.lining);
            sideLine([29, 46], [29, 51], p.accent, 1);
          }
        } else {
          sideLine([30 - half, 35], [35 + half, 35], p.accent, 3);
          sideShape([[31 - half, 35], [33 - half, 36], [29 - half - sway, 43], [27 - half - sway, 42]], p.accent);
          sideLine([33 + half - 1, 36], [32 - half, 47], leather, 2);
          if (direction === 'right') {
            sideShape([[28, 45], [34, 45], [35, 51], [28, 52]], leather);
            sideLine([29, 47], [33, 47], leatherHi, 2);
            rect(...[...spt([31, 48]), 2, 1, p.accent]);
          }
        }
        return;
      }
      const hem = c.outfit === 1 ? 55 : c.outfit === 2 ? 50 : 52;
      bodyShape([[left + 2, 35], [right - 2, 35], [right - 1, 45], [right + (c.outfit === 1 ? 1 : 0), hem], [35, hem], [32, hem - 2], [29, hem], [left - (c.outfit === 1 ? 1 : 0), hem], [left + 1, 45]], c.outfit === 2 ? p.coatSh : p.coat);
      bodyPoly([[left + 2, 38], [left + 4, 38], [left + 3, hem - 2], [left + 1, hem - 2]], p.coatHi);
      bodyPoly([[right - 4, 38], [right - 2, 38], [right - 1, hem - 1], [right - 3, hem - 1]], p.coatSh);
      if (rear) {
        bodyRect(31, 37, 1, hem - 40, p.coatSh);
        if (c.outfit === 0) {
          bodyShape([[right - 1, 34], [33, 33], [28, 37], [right + 2, 43]], p.dark);
          bodyPoly([[right - 1, 35], [34, 34], [32, 36], [right + 1, 40]], p.coatHi);
          bodyRect(left + 1, 45, half * 2 - 2, 3, leather);
          bodyShape([[left - 1, 46], [left + 4, 46], [left + 4, 51], [left - 1, 51]], leatherHi);
        } else if (c.outfit === 1) {
          bodyShape([[27, 34], [37, 34], [38, 38], [35, 42], [29, 42], [26, 38]], p.dark);
          bodyLine([[29, 37], [32, 39], [35, 37]], p.coatHi);
          bodyRect(left, 54, half * 2, 2, p.accent);
          bodyRect(left + 1, 45, half * 2 - 2, 2, p.dark);
          bodyPoly([[32, 46], [34, 49], [32, 52], [30, 49]], p.lining);
          bodyRect(32, 48, 1, 3, p.coatSh);
        } else if (c.outfit === 2) {
          bodyRect(28, 35, 8, 2, p.lining);
          bodyRect(left + 2, 46, half * 2 - 4, 2, leather);
          bodyRect(31, 46, 3, 2, p.accent);
          bodyShape([[left - 4, 45], [left + 1, 45], [left + 2, 52], [left - 4, 52]], leather);
          bodyRect(left - 3, 46, 3, 4, p.lining);
        } else {
          bodyRect(27, 34, 10, 3, p.accent);
          bodyShape([[28, 36], [31, 36], [30 + sway, 44], [27 + sway, 43]], p.accent);
          bodyPoly([[left + 2, 36], [left + 4, 35], [right - 1, 47], [right - 3, 48]], leather);
          bodyShape([[right - 5, 46], [right + 1, 46], [right + 2, 52], [right - 5, 52]], leather);
          bodyRect(right - 4, 47, 4, 2, leatherHi);
          bodyRect(right - 2, 49, 2, 1, p.accent);
        }
        return;
      }
      if (c.outfit === 0) {
        bodyShape([[left + 1, 34], [28, 33], [31, 35], [27, 39], [left - 3, 40]], p.dark);
        bodyPoly([[left + 1, 35], [27, 34], [28, 35], [left - 1, 38]], p.coatHi);
        bodyRect(31, 36, 2, 7, p.lining);
        bodyRect(32, 39, 1, 1, leather);
        bodyRect(32, 41, 1, 1, leather);
        bodyRect(left + 1, 45, half * 2 - 2, 3, leather);
        bodyRect(31, 45, 3, 3, p.accent);
        bodyRect(32, 46, 1, 1, leather);
        bodyShape([[right - 4, 46], [right, 46], [right + 1, 50], [right - 4, 50]], leatherHi);
        bodyRect(right - 3, 47, 3, 1, p.accent);
      } else if (c.outfit === 1) {
        bodyPoly([[28, 35], [32, 38], [36, 35], [34, 44], [30, 44]], p.dark);
        bodyRect(31, 39, 2, 14, p.lining);
        bodyRect(left, 54, half * 2, 2, p.accent);
        bodyRect(left + 1, 45, half * 2 - 2, 2, p.dark);
        bodyShape([[32, 39], [34, 41], [32, 44], [30, 41]], '#8bbdce');
        bodyRect(32, 40, 1, 2, '#d6edf1');
        bodyPoly([[right - 4, 48], [right - 2, 50], [right - 4, 52], [right - 6, 50]], '#773e49');
        bodyRect(32, 49, 1, 5, p.coatSh);
      } else if (c.outfit === 2) {
        bodyPoly([[28, 35], [36, 35], [34, 42], [30, 42]], p.lining);
        bodyPoly([[left + 2, 36], [28, 36], [30, 43], [30, 48], [left + 2, 48]], p.coat);
        bodyPoly([[36, 36], [right - 2, 36], [right - 2, 48], [34, 48], [34, 43]], p.coat);
        bodyRect(31, 37, 2, 2, p.accent);
        bodyPoly([[31, 39], [33, 39], [34, 43], [32, 45], [30, 43]], p.accent);
        bodyRect(32, 46, 1, 1, p.lining);
        bodyRect(left + 2, 48, half * 2 - 4, 1, leather);
        bodyShape([[right - 1, 45], [right + 4, 46], [right + 3, 52], [right - 2, 51]], leather);
        bodyPoly([[right, 46], [right + 3, 47], [right + 2, 50], [right - 1, 50]], p.lining);
        bodyRect(right - 1, 46, 1, 5, p.accent);
      } else {
        bodyPoly([[29, 36], [35, 36], [34, 48], [30, 48]], p.lining);
        bodyShape([[28, 34], [36, 34], [37, 37], [29, 38], [27, 36]], p.accent);
        bodyPoly([[34, 37], [37, 36], [38 + sway, 43], [35 + sway, 42]], p.accent);
        bodyRect(29, 35, 6, 1, p.lining);
        bodyPoly([[right - 3, 36], [right - 1, 37], [left + 3, 47], [left + 1, 46]], leather);
        bodyShape([[left - 1, 45], [left + 5, 45], [left + 6, 51], [left - 1, 52]], leather);
        bodyRect(left, 46, 4, 2, leatherHi);
        bodyRect(left + 2, 48, 2, 1, p.accent);
      }
    }
    function bodyLine(points, colour, width = 1) {
      for (let i = 1; i < points.length; i++) line(at(points[i - 1]), at(points[i]), colour, width);
    }

    function face() {
      if (rear) { bodyRect(28, 29, 8, 5, p.skinSh); return; }
      if (!side) { frontAsset('face'); return; }
      const chins = [32, 33, 31, 34], brow = c.face === 2 ? 40 : 39;
      sideShape([[27, 14], [35, 13], [brow, 17], [brow, 22], [42, 24], [40, 26], [40, 29], [36, chins[c.face]], [31, chins[c.face]], [26, 28], [25, 20]], p.skin);
      sidePoly([[31, 17], [37, 17], [38, 20], [38, 25], [34, 26], [30, 23]], p.skinHi);
      sideShape([[26, 22], [29, 21], [30, 24], [28, 27], [26, 26]], p.skin);
      sideLine([27, 23], [28, 25], p.skinSh, 1);
      sideLine([35, 28], [37, 28], p.blush, 1);
      sideLine([32, chins[c.face] - 1], [35, chins[c.face] - 1], p.skinSh, 1);
    }
    function eyes() {
      if (rear) return;
      if (!side) { frontAsset('eyes'); return; }
      const x = c.face === 2 ? 37 : 36;
      if (c.eyes === 2) {
        sideLine([x - 1, 24], [x, 23], ink, 1);
        sideLine([x, 23], [x + 2, 24], ink, 1);
        sideLine([38, 29], [39, 28], p.skinSh, 1);
      } else {
        const y = c.eyes === 0 ? 22 : 23;
        sideLine([x - 1, y], [x + 2, y], ink, 1);
        sidePoly([[x, y + 1], [x + 3, y + 1], [x + 3, 26], [x, 26]], '#fff1d0');
        sideLine([x + 1, y + 1], [x + 1, 25], ink, 2);
        if (c.eyes === 0) sideLine([x + 1, y + 1], [x + 1, y + 1], '#fff1d0', 1);
        sideLine([38, 29], [39, 29], p.skinSh, 1);
      }
      sideLine([x - 1, c.eyes === 3 ? 20 : 20], [x + 2, c.eyes === 3 ? 21 : 20], p.hairSh, 1);
    }
    function hairFront() {
      if (!side && !rear) { frontAsset('hair-front'); return; }
      if (side) {
        const outlines = [
          [[23, 16], [25, 12], [29, 11], [29, 8], [33, 10], [36, 9], [38, 12], [40, 14], [40, 19], [38, 21], [37, 16], [34, 19], [33, 16], [30, 21], [28, 22], [26, 21], [25, 27], [23, 25]],
          [[23, 16], [25, 12], [30, 10], [36, 11], [39, 14], [40, 19], [37, 19], [34, 17], [31, 20], [29, 23], [29, 31], [26, 34], [23, 31], [22, 25]],
          [[23, 16], [26, 12], [30, 10], [36, 11], [39, 14], [40, 20], [37, 21], [37, 16], [34, 17], [32, 20], [29, 21], [27, 24], [25, 26], [23, 23]],
          [[22, 16], [25, 12], [29, 10], [33, 11], [35, 9], [39, 13], [41, 17], [39, 21], [37, 19], [36, 16], [33, 19], [31, 18], [29, 22], [26, 21], [27, 25], [24, 28], [22, 25]],
        ];
        sideShape(outlines[c.hair], p.hair);
        sidePoly([[25, 15], [28, 12], [34, 12], [37, 14], [33, 14], [29, 16], [26, 19], [24, 19]], p.hairHi);
        sideLine([24, 21], [25, 25], p.hairSh, 1);
        sideLine([31, 17], [30, 19], p.hairSh, 1);
        if (c.hair === 1 && direction === 'left') sideLine([29, 21], [29, 23], p.accent, 2);
        if (c.hair === 2) sideLine([23, 16], [24, 18], p.accent, 2);
        if (direction === 'left') sideLine([23, 18], [23, 22], p.hairHi, 1);
        return;
      }
      const silhouettes = [
        [[22, 16], [24, 12], [28, 11], [28, 8], [33, 10], [36, 8], [38, 11], [41, 13], [42, 18], [41, 27], [37, 30], [35, 32], [32, 30], [29, 32], [25, 29], [22, 25]],
        [[22, 17], [24, 12], [28, 10], [36, 10], [40, 12], [42, 17], [43, 30], [41, 35], [36, 34], [32, 35], [27, 34], [23, 35], [21, 31]],
        [[23, 16], [26, 11], [30, 10], [37, 10], [41, 14], [42, 22], [40, 28], [36, 31], [28, 31], [24, 28], [22, 22]],
        [[21, 17], [23, 12], [28, 10], [33, 10], [35, 9], [40, 12], [43, 17], [42, 21], [44, 26], [42, 31], [38, 34], [33, 32], [28, 34], [23, 32], [20, 27], [22, 22]],
      ];
      bodyShape(silhouettes[c.hair], p.hair);
      bodyPoly([[25, 15], [29, 12], [35, 12], [38, 14], [35, 15], [30, 15], [27, 18], [23, 20]], p.hairHi);
      bodyPoly([[39, 17], [41, 19], [40, 26], [37, 29], [36, 26], [38, 22]], p.hairSh);
      if (c.hair === 0) {
        bodyLine([[28, 19], [27, 25], [29, 28]], p.hairSh);
        bodyLine([[33, 17], [32, 23], [33, 29]], p.hairSh);
        bodyLine([[36, 19], [35, 24]], p.hairHi);
      } else if (c.hair === 1) {
        for (const x of [25, 29, 34, 38]) bodyLine([[x, 20], [x, 31]], x === 29 ? p.hairHi : p.hairSh);
        bodyRect(23, 19, 3, 2, p.accent);
      } else if (c.hair === 2) {
        bodyLine([[36, 16], [30, 22], [24, 23]], p.hairSh);
        bodyLine([[38, 20], [32, 26], [25, 26]], p.hairSh);
        bodyLine([[30, 17], [25, 21]], p.hairHi);
        bodyRect(21, 17, 3, 3, p.accent);
      } else {
        bodyLine([[27, 18], [25, 22], [28, 25], [26, 29]], p.hairSh);
        bodyLine([[34, 17], [31, 21], [34, 25], [32, 30]], p.hairSh);
        bodyLine([[39, 24], [37, 28], [39, 31]], p.hairHi);
      }
    }

    const swordBehind = rear || (side && swordIsFar && !(action === 'attack' && frame >= 3 && frame <= 5));
    function weapon(back) {
      if (back !== swordBehind) return;
      const len = 16, vlen = Math.hypot(...swordVector), v = swordVector.map(a => a / vlen), perp = [-v[1], v[0]];
      const point = (dist, off = 0) => [Math.round(swordHand[0] + v[0] * dist + perp[0] * off), Math.round(swordHand[1] + v[1] * dist + perp[1] * off)];
      shape([point(3, -2), point(len - 3, -2), point(len + 1, 0), point(len - 3, 2), point(3, 2)], steel);
      line(point(4, -1), point(len - 2, -1), steelHi, 1);
      line(point(4, 1), point(len - 3, 1), steelSh, 1);
      line(point(2, -4), point(2, 4), ink, 3);
      line(point(2, -3), point(2, 3), p.accent, 1);
      line(point(-3), point(0), ink, 3);
      line(point(-3), point(0), leatherHi, 1);
      rect(...point(-4), 2, 2, p.accent);
      // Grip is repainted so the hilt always remains in the hand, even when
      // the attack crosses the torso or the rear weapon is behind the head.
      rect(swordHand[0] - 1, swordHand[1] - 1, 3, 3, p.skin);
      rect(swordHand[0] - 1, swordHand[1] - 1, 2, 1, p.skinHi);
    }
    function effects(back = false) {
      if (back !== rear) return;
      if (action !== 'attack' || frame < 3 || frame > 5) return;
      let points;
      if (side) {
        points = frame === 3 ? [[41, 19], [50, 24], [58, 33], [62, 41]] : frame === 4 ? [[57, 31], [64, 40], [66, 47], [63, 54]] : [[63, 48], [60, 55], [53, 59]];
        points = points.map(([x, y]) => [32 + sign * (x - 32) + lean, y + bob]);
      } else if (rear) {
        points = frame === 3 ? [[21, 20], [29, 15], [40, 16], [47, 21]] : frame === 4 ? [[37, 14], [47, 17], [54, 24], [56, 32]] : [[53, 27], [56, 33], [53, 38]];
        points = points.map(at);
      } else {
        points = frame === 3 ? [[23, 37], [31, 43], [44, 47], [53, 45]] : frame === 4 ? [[30, 58], [40, 64], [51, 61], [58, 54]] : [[45, 65], [53, 61], [57, 56]];
        points = points.map(at);
      }
      for (let i = 1; i < points.length; i++) line(points[i - 1], points[i], frame === 5 ? '#8bbdce' : '#8baeb9', frame === 5 ? 1 : 3);
      if (frame !== 5) for (let i = 1; i < points.length; i++) line(points[i - 1], points[i], '#edf5de', 1);
      if (frame === 4) {
        const end = points[points.length - 1];
        line([end[0] - 2, end[1] - 4], [end[0] + 2, end[1] - 4], '#edf5de', 1);
        line([end[0], end[1] - 6], [end[0], end[1] - 2], '#edf5de', 1);
      }
    }

    const painters = { 'hair-back': hairBack, 'far-arm': farArm, 'weapon-back': () => weapon(true), 'effects-back': () => effects(true), legs, torso, 'near-arm': nearArm, face, eyes, 'hair-front': hairFront, 'weapon-front': () => weapon(false), effects: () => effects(false) };
    if (options.layer && options.layer !== 'composite') {
      if (painters[options.layer]) painters[options.layer]();
    } else for (const layer of metadata.layers) painters[layer]();
    return canvas;
  }
  window.PixelMotion = { render, metadata };
}());
