/* ============================================================
   fighter.js — the stickman: physics, state machine, combat,
   and procedural skeletal animation via 2-bone IK.

   Local space: origin (0,0) sits at the feet on the ground.
   Negative Y is UP. +X is the direction the fighter faces;
   we flip the canvas for left-facing so all poses are authored
   facing right.
   ============================================================ */

const GROUND_Y = 612;     // y of the floor in world space
const STAGE_L = 70;       // left wall
const STAGE_R = 1210;     // right wall

// limb lengths
const L = {
  thigh: 56, shin: 54,
  upperArm: 40, foreArm: 40,
  torso: 70, neck: 10, headR: 17,
};

// 2-bone inverse kinematics: place a mid-joint (knee/elbow)
// between root (ax,ay) and target (bx,by). bend = +1/-1 side.
function ik(ax, ay, bx, by, l1, l2, bend) {
  let dx = bx - ax, dy = by - ay;
  let d = Math.hypot(dx, dy) || 0.0001;
  const maxD = l1 + l2 - 0.5;
  if (d > maxD) { dx *= maxD / d; dy *= maxD / d; d = maxD; bx = ax + dx; by = ay + dy; }
  const a = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
  const h = Math.sqrt(Math.max(0, l1 * l1 - a * a));
  const baseX = ax + a * dx / d, baseY = ay + a * dy / d;
  return {
    x: baseX + bend * h * (-dy / d),
    y: baseY + bend * h * (dx / d),
    end: { x: bx, y: by },
  };
}

/* ---------- attack definitions (frames @ 60fps) ----------
   limb: which limb visually thrusts.
   reach: extended target in local coords (negative y = up).
   height: 'high' | 'mid' | 'low'  (for blocking rules)
   special: meter on use; costs meter for super.            */
const ATTACKS = {
  jab:   { startup:3, active:4, recovery:7,  damage:4,  hitstun:13, kb:3,  limb:"arm", reach:{x:74,y:-92},  height:"high", meter:6,  push:2 },
  punch: { startup:5, active:5, recovery:11, damage:7,  hitstun:18, kb:6,  limb:"arm", reach:{x:90,y:-90},  height:"high", meter:8,  push:4 },
  kick:  { startup:7, active:6, recovery:13, damage:9,  hitstun:20, kb:8,  limb:"leg", reach:{x:96,y:-70},  height:"mid",  meter:9,  push:6 },
  sweep: { startup:6, active:6, recovery:18, damage:6,  hitstun:24, kb:5,  limb:"leg", reach:{x:92,y:-14},  height:"low",  meter:8,  push:3, knockdown:true },
  upper: { startup:4, active:9, recovery:24, damage:13, hitstun:26, kb:7,  limb:"arm", reach:{x:50,y:-150}, height:"mid",  meter:0,  push:2, launch:-13, rise:true, special:true, invuln:8 },
  fireball:{startup:9, active:3, recovery:20, damage:8, hitstun:22, kb:6,  limb:"arm", reach:{x:64,y:-96},  height:"mid",  meter:6,  push:0, special:true, projectile:true },
  exfireball:{startup:7,active:3,recovery:18,damage:14,hitstun:28,kb:9,    limb:"arm", reach:{x:64,y:-96},  height:"mid",  meter:0,  push:0, special:true, projectile:true, ex:true, cost:50 },
  // hurricane / spinning kick — travels forward, multi-hit
  tatsu: { startup:5, active:20, recovery:14, damage:4, hitstun:14, kb:3,  limb:"leg", reach:{x:90,y:-78},  height:"mid",  meter:0,  push:2, special:true, multi:true, spin:true, travel:6, launch:-4, cost:25, invuln:4 },
  // air spinning kick — multi-hit from the air
  spinkick:{startup:3, active:16, recovery:6, damage:4, hitstun:13, kb:3,  limb:"leg", reach:{x:88,y:-98},  height:"mid",  meter:7,  push:2, multi:true, spin:true, air:true },
  // dive / ground pound — falls fast, shockwave on landing
  airslam:{startup:5, active:40, recovery:14, damage:15, hitstun:30, kb:7, limb:"leg", reach:{x:36,y:24},   height:"mid",  meter:0,  push:4, special:true, slam:true, dive:true },
  // dash punch — explosive lunge
  dashpunch:{startup:4,active:5, recovery:14, damage:10, hitstun:22, kb:10,limb:"arm", reach:{x:104,y:-92}, height:"mid",  meter:9,  push:8, lunge:9 },
  // cinematic super — screen-wide beam, multi-hit
  super: { startup:10,active:30,recovery:30, damage:5,  hitstun:30, kb:14, limb:"arm", reach:{x:96,y:-96},  height:"mid",  meter:0,  push:0, special:true, cost:100, multi:true, beam:true, cinematic:true },
};

// rekka auto-combo chain: each press during recovery advances the string
const REKKA_NEXT = { jab: "punch", punch: "upper" };

class Fighter {
  constructor(id, x, facing, name, isCPU) {
    this.id = id;
    this.name = name;
    this.isCPU = isCPU;
    this.color = id === 1 ? "#39c6ff" : "#ff5a5a";

    this.x = x; this.y = GROUND_Y;
    this.vx = 0; this.vy = 0;
    this.facing = facing;          // +1 right, -1 left
    this.onGround = true;

    this.maxHp = 100; this.hp = 100;
    this.maxMeter = 100; this.meter = 0;

    this.state = "idle";           // idle|walk|jump|crouch|block|attack|hit|knockdown|ko|intro|win
    this.attack = null; this.aFrame = 0;
    this.hitstun = 0; this.invuln = 0; this.blockStun = 0;
    this.crouch = false; this.blocking = false;
    this.hasHitThisAttack = false; // prevent multi-hit (except multi attacks)
    this.multiHitTimer = 0;

    this.animT = 0;                // procedural anim phase
    this.walkPhase = 0;
    this.hitFlash = 0;
    this.facingLock = false;       // during attacks, don't flip
    this.wantProjectile = false;
    this.frozen = 0;               // hitstop
    this.scale = 1.0;

    // --- new: flashy movement / fx state ---
    this.afterimages = [];         // {pose,x,y,facing,color,life}
    this.ghostTimer = 0;           // frames left emitting afterimages
    this.dashTimer = 0;            // active dash frames
    this.dashDir = 0;
    this.lastTap = { dir: 0, t: -999 };
    this.airActions = 0;           // double jump / air dash budget
    this.spinAngle = 0;            // visual spin for tatsu/spinkick
    this.parryWindow = 0;          // frames a tapped block counts as parry
    this.parryFlash = 0;
    this.aura = 0;                  // 0..1 charge glow when meter full
    this.diveDone = false;

    // visuals memory for smoothing the rest pose
    this.lean = 0;
  }

  /* -------------------------------------------------- */
  get cx() { return this.x; }
  hurtbox() {
    const w = 56, h = this.crouch ? 86 : 150;
    return { x: this.x - w / 2, y: this.y - h, w, h };
  }
  headPoint() { return { x: this.x, y: this.y - (this.crouch ? 96 : 150) }; }

  canAct() {
    return this.frozen <= 0 && this.hitstun <= 0 && this.blockStun <= 0 &&
           this.state !== "knockdown" && this.state !== "ko" && this.state !== "attack";
  }
  alive() { return this.hp > 0; }

  faceTarget(opp) {
    if (this.facingLock || this.state === "attack" || this.hitstun > 0) return;
    if (this.onGround) this.facing = opp.x >= this.x ? 1 : -1;
  }

  /* ---------------- main per-frame update ---------------- */
  update(input, opp, game) {
    // afterimages decay even during hitstop
    for (let i = this.afterimages.length - 1; i >= 0; i--) {
      this.afterimages[i].life -= 0.06;
      if (this.afterimages[i].life <= 0) this.afterimages.splice(i, 1);
    }
    if (this.frozen > 0) { this.frozen--; this.hitFlash *= 0.8; return; }

    this.hitFlash *= 0.85;
    if (this.parryFlash > 0) this.parryFlash *= 0.85;
    if (this.invuln > 0) this.invuln--;
    if (this.hitstun > 0) this.hitstun--;
    if (this.blockStun > 0) this.blockStun--;
    if (this.parryWindow > 0) this.parryWindow--;
    if (this.ghostTimer > 0) this.ghostTimer--;

    // aura glow when meter is full
    const auraTarget = this.meter >= 100 ? 1 : 0;
    this.aura = Utils.lerp(this.aura, auraTarget, 0.1);
    if (this.aura > 0.4 && Math.random() < 0.4)
      game.fx.ember(this.x, this.y - 30, this.color);

    // spin visual for hurricane / spin kicks
    if (this.attack && ATTACKS[this.attack].spin) this.spinAngle += 0.55;
    else this.spinAngle = 0;

    this.faceTarget(opp);

    // gravity / vertical
    this.vy += 0.7;
    this.y += this.vy;
    if (this.y >= GROUND_Y) {
      if (!this.onGround && this.vy > 4) game.fx.dust(this.x, GROUND_Y, this.facing);
      this.y = GROUND_Y; this.vy = 0;
      if (!this.onGround) { this.onGround = true; if (this.state === "jump") this.state = "idle"; }
    } else this.onGround = false;

    // horizontal + friction
    this.x += this.vx;
    if (this.onGround) this.vx *= 0.78; else this.vx *= 0.96;
    if (Math.abs(this.vx) < 0.05) this.vx = 0;
    this.x = Utils.clamp(this.x, STAGE_L, STAGE_R);

    if (this.hitstun <= 0 && this.state === "hit") this.state = this.onGround ? "idle" : "jump";

    // emit afterimages while dashing / spinning / diving
    const wantGhost = this.dashTimer > 0 || this.ghostTimer > 0 ||
      (this.attack && (ATTACKS[this.attack].spin || ATTACKS[this.attack].beam ||
                       ATTACKS[this.attack].dive || ATTACKS[this.attack].lunge));
    if (wantGhost && game.frameCount % 2 === 0) this.pushAfterimage();

    // dash movement
    if (this.dashTimer > 0) {
      this.dashTimer--;
      this.vx = this.dashDir * 13 * (this.dashTimer / 9 + 0.5);
      if (this.state !== "attack") this.state = "dash";
      // allow canceling a dash directly into an attack
      if (input && this.frozen <= 0) this.handleInput(input, opp, game);
      if (this.dashTimer <= 0 && this.state === "dash") this.state = "idle";
    } else if (this.state === "attack") this.updateAttack(input, opp, game);
    else if (this.canAct() && input) this.handleInput(input, opp, game);
    else if (this.state === "knockdown") this.updateKnockdown();

    // animation phase
    this.animT += 0.15;
    if (this.state === "walk") this.walkPhase += 0.22 * Utils.sign(this.vx || this.facing);
  }

  pushAfterimage() {
    if (!this._lastPose) return;
    // shallow snapshot of joint coords is enough for rendering
    const p = this._lastPose;
    const snap = (j) => ({ x: j.x, y: j.y, end: j.end ? { x: j.end.x, y: j.end.y } : null });
    this.afterimages.push({
      x: this.x, y: this.y, facing: this.facing, color: this.color, life: 1,
      pose: {
        hip: snap(p.hip), shoulder: snap(p.shoulder), headBase: snap(p.headBase),
        legF: snap(p.legF), legB: snap(p.legB), armF: snap(p.armF), armB: snap(p.armB),
      },
    });
    if (this.afterimages.length > 10) this.afterimages.shift();
  }

  startDash(dir, game) {
    this.dashTimer = 9; this.dashDir = dir;
    this.ghostTimer = 10; this.invuln = Math.max(this.invuln, 3);
    this.state = "dash";
    Sound.jump(); game.fx.dust(this.x, GROUND_Y, dir);
  }

  // detect motion specials; returns move name (and consumes buffer) or null
  checkSpecials(input, game) {
    const now = game.now;
    // SUPER — both buttons, full meter
    if (this.meter >= 100 &&
        ((input.pressed("punch") && input.down("kick")) ||
         (input.pressed("kick") && input.down("punch")))) return "super";

    const qcf = input.motion(["down","downfwd","fwd"], now) || input.motion(["down","fwd"], now);
    const dpf = input.motion(["fwd","down","downfwd"], now) || input.motion(["fwd","downfwd"], now);
    const qcb = input.motion(["down","downback","back"], now) || input.motion(["down","back"], now);

    if (input.pressed("punch")) {
      if (dpf) { input.consumeMotion(); return "upper"; }
      if (qcf) { input.consumeMotion(); return "fireball"; }
    }
    if (input.pressed("kick")) {
      if (qcf && this.meter >= 50) { input.consumeMotion(); return "exfireball"; }
      if (qcb && this.meter >= 25) { input.consumeMotion(); return "tatsu"; }
    }
    return null;
  }

  handleInput(input, opp, game) {
    const inAir = !this.onGround;
    const now = game.now;
    this.blocking = false; this.crouch = false;

    // ---- SPECIAL MOTIONS (highest priority) ----
    const sp = this.checkSpecials(input, game);
    if (sp) { this.startAttack(sp, game); return; }

    // ---- keep dashing: only attacks may interrupt ----
    if (this.dashTimer > 0) {
      if (input.pressed("punch")) { this.startAttack("dashpunch", game); return; }
      if (input.pressed("kick"))  { this.startAttack("kick", game); return; }
      return;
    }

    // ---- AIR options ----
    if (inAir) {
      if (input.pressed("kick") && input.down("down")) { this.startAttack("airslam", game); return; }
      if (input.pressed("kick"))  { this.startAttack("spinkick", game); return; }
      if (input.pressed("punch")) { this.startAttack("kick", game); return; }   // air punch
      // double jump / air dash
      if (input.pressed("up") && this.airActions > 0) {
        this.airActions--; this.vy = -13; this.ghostTimer = 8;
        if (input.down("left")) this.vx = -8; else if (input.down("right")) this.vx = 8;
        Sound.jump(); return;
      }
      if (input.down("left")) this.vx = Utils.clamp(this.vx - 0.5, -6, 6);
      else if (input.down("right")) this.vx = Utils.clamp(this.vx + 0.5, -6, 6);
      this.state = "jump";
      return;
    }

    // ---- DOUBLE-TAP DASH ----
    if (input.pressed("right")) {
      if (this.lastTap.dir === 1 && now - this.lastTap.t < 260) { this.startDash(1, game); this.lastTap.t = -999; return; }
      this.lastTap = { dir: 1, t: now };
    } else if (input.pressed("left")) {
      if (this.lastTap.dir === -1 && now - this.lastTap.t < 260) { this.startDash(-1, game); this.lastTap.t = -999; return; }
      this.lastTap = { dir: -1, t: now };
    }
    if (input._dash) { this.startDash(input._dash, game); return; }

    // ---- PARRY window opens on a fresh block tap ----
    if (input.pressed("block")) this.parryWindow = 8;

    // ---- BLOCK ----
    if (input.down("block")) {
      this.blocking = true; this.crouch = input.down("down");
      this.state = "block"; this.vx *= 0.5; return;
    }

    // ---- NORMALS (rekka starts with jab/punch) ----
    if (input.pressed("punch")) { this.startAttack(input.down("down") ? "jab" : "punch", game); return; }
    if (input.pressed("kick"))  { this.startAttack(input.down("down") ? "sweep" : "kick", game); return; }

    // ---- MOVEMENT ----
    if (input.down("down")) { this.crouch = true; this.state = "crouch"; this.vx *= 0.6; return; }
    if (input.pressed("up")) {
      this.vy = -15; this.onGround = false; this.state = "jump"; this.airActions = 1;
      if (input.down("left")) this.vx = -5; else if (input.down("right")) this.vx = 5;
      Sound.jump(); game.fx.dust(this.x, GROUND_Y, this.facing); return;
    }
    const speed = 3.3;
    if (input.down("left")) { this.vx = -speed; this.state = "walk"; }
    else if (input.down("right")) { this.vx = speed; this.state = "walk"; }
    else this.state = "idle";
  }

  /* ---------------- attacks ---------------- */
  startAttack(name, game) {
    const a = ATTACKS[name];
    if (a.cost && this.meter < a.cost) return;
    if (a.cost) this.meter -= a.cost;
    this.attack = name; this.aFrame = 0; this.state = "attack";
    this.hasHitThisAttack = false; this.facingLock = true;
    this.multiHitTimer = 0; this.dashTimer = 0;
    if (a.invuln) this.invuln = Math.max(this.invuln, a.invuln);
    if (a.rise)  { this.vy = a.launch; this.onGround = false; }
    if (a.lunge) { this.vx = this.facing * a.lunge; this.ghostTimer = 8; }
    if (a.dive)  { this.vy = 7; this.diveDone = false; }
    if (a.spin)  this.ghostTimer = a.active + a.recovery;
    if (a.beam)  this.wantBeam = true;
    if (a.cinematic) { game.startSuper(this); game.flash(0.7); game.shake(14); }
    if (name === "super" || a.spin) Sound.jump();
    else if (!a.projectile) Sound.whiff();
    this.wantProjectile = !!a.projectile;
  }

  updateAttack(input, opp, game) {
    const a = ATTACKS[this.attack];
    const total = a.startup + a.active + a.recovery;
    this.aFrame++;

    const active = this.aFrame > a.startup && this.aFrame <= a.startup + a.active;
    const recovery = this.aFrame > a.startup + a.active;

    // forward travel (hurricane kick)
    if (a.travel && active) this.vx = this.facing * a.travel;

    // dive bomb — accelerate down, shockwave on landing
    if (a.dive) {
      this.vy = Math.max(this.vy, 10);
      if (this.onGround && !this.diveDone) {
        this.diveDone = true;
        game.groundPound(this, opp);
        this.aFrame = a.startup + a.active + 1; // jump to recovery
      }
    }

    // spawn projectile
    if (a.projectile && this.aFrame === a.startup + 1) {
      game.projectiles.push(new Projectile(this.x + this.facing * 60, this.y - 92, this.facing, this, !!a.ex));
      Sound.fireball();
      if (a.ex) { game.fx.shockwave(this.x + this.facing * 60, this.y - 92, this.color, 80, 5); game.shake(4); }
    }

    // spawn super beam
    if (a.beam && this.wantBeam && this.aFrame === a.startup + 1) {
      this.wantBeam = false;
      game.beams.push(new Beam(this.x + this.facing * 40, this.y - 92, this.facing, this));
      game.fx.shockwave(this.x, this.y - 80, this.color, 220, 10);
      Sound.super();
    }

    // melee active hits (projectile/dive/beam handled elsewhere)
    if (active && !a.projectile && !a.dive && !a.beam) {
      const allowHit = a.multi ? (this.multiHitTimer-- <= 0) : !this.hasHitThisAttack;
      if (allowHit && this.tryHit(a, opp, game)) {
        if (a.multi) this.multiHitTimer = 5; else this.hasHitThisAttack = true;
      }
    }

    // recovery cancels: special-cancel, then rekka chain
    if (recovery && input && this.onGround && this.frozen <= 0) {
      const sp = this.checkSpecials(input, game);
      if (sp) { this.startAttack(sp, game); return; }
      if (REKKA_NEXT[this.attack] && input.pressed("punch")) {
        this.startAttack(REKKA_NEXT[this.attack], game); return;
      }
    }

    if (this.aFrame >= total) {
      this.attack = null; this.facingLock = false;
      this.state = this.onGround ? "idle" : "jump";
    }
  }

  // returns the world-space tip of the active limb (for hitbox + fx)
  limbTip() {
    const pose = this._lastPose;
    if (!pose) return { x: this.x + this.facing * 70, y: this.y - 90 };
    const a = ATTACKS[this.attack];
    return a && a.limb === "leg" ? pose.frontFootW : pose.frontHandW;
  }

  tryHit(a, opp, game) {
    if (!opp.alive()) return false;
    const tip = this.limbTip();
    const hb = { x: tip.x - 22, y: tip.y - 22, w: 44, h: 44 };
    if (!Utils.aabb(hb, opp.hurtbox())) return false;
    if (opp.invuln > 0) return false;
    const dir = Utils.sign(opp.x - this.x) || this.facing;
    opp.takeHit(a, dir, this, game, tip);
    return true;
  }

  /* ---------------- receiving a hit ---------------- */
  takeHit(a, dir, attacker, game, tip) {
    const facingAttacker = Utils.sign(attacker.x - this.x) === this.facing;

    // ---- PARRY: fresh block tap just before impact ----
    if (this.parryWindow > 0 && facingAttacker && this.onGround) {
      this.parryWindow = 0; this.parryFlash = 1;
      this.addMeter(20); this.vx = 0;
      attacker.blockStun = 18; attacker.frozen = 8; this.frozen = 8;
      game.onParry(this, attacker, tip);
      return;
    }

    // determine block success
    let blocked = false;
    if (this.blocking && facingAttacker) {
      if (a.height === "low") blocked = this.crouch;
      else if (a.height === "high") blocked = !this.crouch || true; // high blockable standing or crouch here
      else blocked = true; // mid blockable either
      if (a.height === "low" && !this.crouch) blocked = false;
    }

    if (blocked) {
      const chip = Math.max(0, Math.round(a.damage * 0.12));
      this.hp = Math.max(0, this.hp - chip);
      this.vx = dir * (a.push * 0.6 + 2);
      this.blockStun = Math.round(a.hitstun * 0.4);
      this.meter = Math.min(this.maxMeter, this.meter + 4);
      attacker.meter = Math.min(attacker.maxMeter, attacker.meter + 2);
      Sound.block();
      game.fx.spark(tip.x, tip.y, "#cfe3ff", 6, 4);
      game.fx.ring(tip.x, tip.y, "#9ad0ff");
      game.fx.text(this.x, this.y - 160, "BLOCK", "#9ad0ff", 20);
      game.shake(3);
      return;
    }

    // clean hit
    this.hp = Math.max(0, this.hp - a.damage);
    this.hitstun = a.hitstun;
    this.hitFlash = 1;
    this.state = "hit";
    this.attack = null; this.facingLock = false;
    this.blocking = false; this.crouch = false;
    this.dashTimer = 0; this.ghostTimer = 0; this.airActions = 0;
    this.facing = -dir;                 // turn to face the hit
    this.vx = dir * a.kb;
    attacker.meter = Math.min(attacker.maxMeter, attacker.meter + (a.meter || 4));
    this.meter = Math.min(this.maxMeter, this.meter + 2);

    // hitstop for impact weight
    const stop = Math.min(12, 3 + Math.round(a.damage / 2));
    this.frozen = stop; attacker.frozen = stop;

    game.onHit(this, attacker, a, tip);

    if (a.launch) { this.vy = a.launch; this.onGround = false; }
    if (a.knockdown || this.hp <= 0) this.knockdown(dir, a);
    if (this.hp <= 0) { this.state = "ko"; }
  }

  knockdown(dir, a) {
    if (this.state === "ko") return;
    this.state = "knockdown";
    this.kdTimer = 50;
    this.vy = -8; this.vx = dir * (a ? a.kb * 0.8 : 5);
    this.onGround = false;
  }
  updateKnockdown() {
    if (this.kdTimer > 0) this.kdTimer--;
    if (this.kdTimer <= 0 && this.onGround && this.alive()) {
      this.state = "idle"; this.invuln = 30;
    }
  }

  addMeter(v) { this.meter = Utils.clamp(this.meter + v, 0, this.maxMeter); }

  /* ============================================================
     DRAWING — compute joints, then render the stickman.
     ============================================================ */
  toWorld(px, py) { return { x: this.x + this.facing * px, y: this.y + py }; }

  computePose() {
    // base rest values
    let hipY = -98;
    let footFront = { x: 18, y: 0 }, footBack = { x: -16, y: 0 };
    let handFront = { x: 20, y: -78 }, handBack = { x: -14, y: -80 };
    let lean = 0.04;
    let headTilt = 0;

    const t = this.animT;

    switch (this.state) {
      case "idle": {
        const b = Math.sin(t * 0.7) * 2;
        hipY = -98 + b;
        handFront = { x: 22, y: -78 + b }; handBack = { x: -14, y: -80 + b };
        lean = 0.05 + Math.sin(t * 0.7) * 0.01;
        break;
      }
      case "walk": {
        const s = Math.sin(this.walkPhase), c = Math.cos(this.walkPhase);
        hipY = -96 + Math.abs(c) * 4;
        footFront = { x: 18 + s * 22, y: -Math.max(0, s) * 16 };
        footBack  = { x: -16 - s * 22, y: -Math.max(0, -s) * 16 };
        handFront = { x: 20 - s * 14, y: -78 };
        handBack  = { x: -14 + s * 14, y: -80 };
        lean = 0.12;
        break;
      }
      case "crouch":
      case "block": {
        const crouching = this.crouch || this.state === "crouch";
        hipY = crouching ? -58 : -92;
        footFront = { x: 24, y: 0 }; footBack = { x: -24, y: 0 };
        if (this.state === "block") {
          // guard up, arms across the front
          handFront = { x: 30, y: crouching ? -54 : -94 };
          handBack  = { x: 22, y: crouching ? -62 : -104 };
          lean = -0.08;
        } else {
          handFront = { x: 20, y: -52 }; handBack = { x: -14, y: -54 };
          lean = 0.18;
        }
        break;
      }
      case "jump": {
        const rising = this.vy < 0;
        hipY = -104;
        footFront = { x: 14, y: rising ? -20 : -6 };
        footBack  = { x: -18, y: rising ? -34 : -16 };
        handFront = { x: 26, y: rising ? -110 : -70 };
        handBack  = { x: -20, y: rising ? -108 : -72 };
        lean = rising ? 0.1 : 0.2;
        break;
      }
      case "dash": {
        hipY = -90;
        lean = 0.5;                       // hard forward lean
        footFront = { x: 40, y: -8 }; footBack = { x: -34, y: -2 };
        handFront = { x: -8, y: -86 }; handBack = { x: -34, y: -96 };
        break;
      }
      case "hit": {
        hipY = -94;
        lean = -0.35;
        const sh = Math.sin(t * 2) * 3;
        handFront = { x: -6 + sh, y: -86 }; handBack = { x: -30, y: -92 };
        footFront = { x: 6, y: 0 }; footBack = { x: -30, y: 0 };
        headTilt = -0.3;
        break;
      }
      case "knockdown": {
        // rotate whole body toward lying down based on timer
        const p = 1 - Utils.clamp(this.kdTimer / 50, 0, 1);
        hipY = Utils.lerp(-94, -22, Utils.easeOutCubic(p));
        lean = Utils.lerp(-0.4, -1.4, p);
        footFront = { x: 40, y: -Utils.lerp(0, 6, p) };
        footBack = { x: -30, y: 0 };
        handFront = { x: -40, y: -Utils.lerp(80, 10, p) };
        handBack = { x: -50, y: -Utils.lerp(82, 8, p) };
        headTilt = -p * 0.8;
        break;
      }
      case "ko": {
        hipY = -18; lean = -1.5;
        footFront = { x: 44, y: -4 }; footBack = { x: -34, y: 0 };
        handFront = { x: -54, y: -8 }; handBack = { x: -60, y: -10 };
        headTilt = -1.0;
        break;
      }
      case "win": {
        const b = Math.sin(t * 0.9) * 4;
        hipY = -100 + b;
        handFront = { x: 10, y: -150 + b }; // fist up
        handBack = { x: -18, y: -78 };
        lean = -0.05;
        break;
      }
      case "attack": {
        const a = ATTACKS[this.attack];
        const peak = a.startup + a.active;
        const f = this.aFrame;
        let ext;
        if (f <= peak) ext = Utils.easeOutCubic(f / peak);
        else ext = 1 - Utils.easeInOut((f - peak) / a.recovery);
        ext = Utils.clamp(ext, 0, 1);

        const guardHand = { x: 22, y: -80 };
        if (a.limb === "arm") {
          handFront = {
            x: Utils.lerp(guardHand.x, a.reach.x, ext),
            y: Utils.lerp(guardHand.y, a.reach.y, ext),
          };
          handBack = { x: -16 - ext * 10, y: -82 };
          lean = 0.05 + ext * (a.reach.y < -120 ? -0.25 : 0.18);
          hipY = a.rise ? -98 : -98;
          if (a.rise) {
            footFront = { x: 6, y: -ext * 30 }; footBack = { x: -14, y: -ext * 18 };
          }
        } else { // leg attacks
          const baseFoot = { x: 18, y: 0 };
          footFront = {
            x: Utils.lerp(baseFoot.x, a.reach.x, ext),
            y: Utils.lerp(baseFoot.y, a.reach.y, ext),
          };
          footBack = { x: -22, y: 0 };
          hipY = a.height === "low" ? Utils.lerp(-92, -64, ext) : -94;
          handFront = { x: -10, y: -84 }; handBack = { x: 18, y: -88 };
          lean = a.height === "low" ? 0.25 : 0.12 - ext * 0.05;
        }
        break;
      }
    }

    // ---- assemble joints in local space (y negative up) ----
    const hip = { x: 0, y: hipY };
    const shoulder = {
      x: hip.x + Math.sin(lean) * L.torso,
      y: hip.y - Math.cos(lean) * L.torso,
    };
    const headBase = {
      x: shoulder.x + Math.sin(lean + headTilt) * (L.neck + L.headR),
      y: shoulder.y - Math.cos(lean + headTilt) * (L.neck + L.headR),
    };

    // IK limbs (bend signs make knees go forward, elbows back)
    const legF = ik(hip.x, hip.y, footFront.x, footFront.y, L.thigh, L.shin, -1);
    const legB = ik(hip.x, hip.y, footBack.x, footBack.y, L.thigh, L.shin, -1);
    const armF = ik(shoulder.x, shoulder.y, handFront.x, handFront.y, L.upperArm, L.foreArm, 1);
    const armB = ik(shoulder.x, shoulder.y, handBack.x, handBack.y, L.upperArm, L.foreArm, 1);

    const pose = { hip, shoulder, headBase, legF, legB, armF, armB,
                   footFront, footBack, handFront, handBack };

    // cache world positions of front hand/foot for hit detection
    this._lastPose = pose;
    pose.frontHandW = this.toWorld(handFront.x, handFront.y);
    pose.frontFootW = this.toWorld(footFront.x, footFront.y);
    return pose;
  }

  // render a skeleton from a pose under the current transform
  drawSkeleton(ctx, pose, opt) {
    const color = opt.color, lw = opt.lw || 9;
    ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha;
    ctx.lineCap = "round"; ctx.lineJoin = "round";
    const dark = opt.flash ? "#dddddd" : this.shade(color, -40);
    const limb = (a, m, b, c) => {
      ctx.strokeStyle = c; ctx.lineWidth = lw;
      ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(m.x, m.y); ctx.lineTo(b.x, b.y); ctx.stroke();
    };
    limb(pose.hip, pose.legB, pose.legB.end, dark);
    limb(pose.shoulder, pose.armB, pose.armB.end, dark);
    ctx.strokeStyle = color; ctx.lineWidth = lw + 2;
    ctx.beginPath(); ctx.moveTo(pose.hip.x, pose.hip.y); ctx.lineTo(pose.shoulder.x, pose.shoulder.y); ctx.stroke();
    ctx.lineWidth = lw;
    ctx.beginPath(); ctx.moveTo(pose.hip.x - 8, pose.hip.y); ctx.lineTo(pose.hip.x + 8, pose.hip.y); ctx.stroke();
    limb(pose.hip, pose.legF, pose.legF.end, color);
    limb(pose.shoulder, pose.armF, pose.armF.end, color);
    ctx.fillStyle = color;
    ctx.beginPath(); ctx.arc(pose.headBase.x, pose.headBase.y, L.headR, 0, Math.PI * 2); ctx.fill();
    if (!opt.ghost) {
      ctx.fillStyle = opt.flash ? "#222" : "#0b0e17";
      ctx.beginPath(); ctx.arc(pose.headBase.x + 7, pose.headBase.y - 2, 2.6, 0, Math.PI * 2); ctx.fill();
    }
    ctx.globalAlpha = 1;
  }

  draw(ctx) {
    const pose = this.computePose();
    const spinning = this.attack && ATTACKS[this.attack].spin;

    // ---- afterimages first (each its own transform) ----
    for (const g of this.afterimages) {
      ctx.save();
      ctx.translate(g.x, g.y); ctx.scale(g.facing, 1);
      this.drawSkeleton(ctx, g.pose, { color: g.color, alpha: g.life * 0.4, ghost: true, lw: 8 });
      ctx.restore();
    }

    ctx.save();
    ctx.translate(this.x, this.y);

    // ground shadow
    const shY = GROUND_Y - this.y;
    const lift = Utils.clamp((GROUND_Y - this.y) / 200, 0, 1);
    ctx.fillStyle = "rgba(0,0,0,0.32)";
    ctx.beginPath();
    ctx.ellipse(0, shY, 40 * (1 - lift * 0.4), 8 * (1 - lift * 0.4), 0, 0, Math.PI * 2);
    ctx.fill();

    // aura glow when meter is full
    if (this.aura > 0.05) {
      const r = 78 + Math.sin(this.animT * 2) * 7;
      const grd = ctx.createRadialGradient(0, -70, 12, 0, -70, r);
      grd.addColorStop(0, "rgba(255,255,255,0)");
      grd.addColorStop(0.62, this.hexA(this.color, 0));
      grd.addColorStop(0.9, this.hexA(this.color, 0.3 * this.aura));
      grd.addColorStop(1, "rgba(0,0,0,0)");
      ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(0, -70, r, 0, Math.PI * 2); ctx.fill();
    }

    ctx.scale(this.facing, 1);
    if (spinning) {
      ctx.translate(pose.hip.x, pose.hip.y);
      ctx.rotate(this.spinAngle);
      ctx.translate(-pose.hip.x, -pose.hip.y);
    }

    const flash = this.hitFlash > 0.05 || this.parryFlash > 0.2;
    this.drawSkeleton(ctx, pose, { color: flash ? "#ffffff" : this.color, flash, lw: 9 });

    // active-frame impact glow on the striking limb
    if (this.state === "attack") {
      const a = ATTACKS[this.attack];
      if (this.aFrame > a.startup && this.aFrame <= a.startup + a.active) {
        const tip = a.limb === "leg" ? pose.footFront : pose.handFront;
        ctx.fillStyle = "rgba(255,255,255,0.85)";
        ctx.shadowColor = this.color; ctx.shadowBlur = 18;
        ctx.beginPath(); ctx.arc(tip.x, tip.y, 11, 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
      }
    }
    ctx.restore();

    // parry flash ring
    if (this.parryFlash > 0.05) {
      ctx.save(); ctx.globalAlpha = this.parryFlash;
      ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 5;
      ctx.beginPath(); ctx.arc(this.x, this.y - 80, 56 * (1.4 - this.parryFlash), 0, Math.PI * 2); ctx.stroke();
      ctx.restore();
    }

    // invuln shimmer
    if (this.invuln > 0 && this.alive() && Math.floor(this.invuln / 3) % 2 === 0) {
      ctx.save();
      ctx.globalAlpha = 0.18; ctx.fillStyle = "#fff";
      const hb = this.hurtbox();
      ctx.fillRect(hb.x, hb.y, hb.w, hb.h);
      ctx.restore();
    }
  }

  hexA(hex, a) {
    const n = parseInt(hex.slice(1), 16);
    return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
  }

  shade(hex, amt) {
    const n = parseInt(hex.slice(1), 16);
    let r = (n >> 16) + amt, g = ((n >> 8) & 255) + amt, b = (n & 255) + amt;
    r = Utils.clamp(r, 0, 255); g = Utils.clamp(g, 0, 255); b = Utils.clamp(b, 0, 255);
    return `rgb(${r},${g},${b})`;
  }
}
