/* global React, ReactDOM, useTweaks, TweaksPanel, TweakSection, TweakSlider, TweakToggle, TweakRadio, TweakButton, TweakColor */
const { useState, useEffect, useRef, useCallback } = React;

// ============== Constants ==============
const WORLD_W = 1280;
const WORLD_H = 720;
const GROUND_Y = 560;
const HERO_W = 96;
const HERO_H = 110;
const GOBLIN_W = 70;
const GOBLIN_H = 80;
const HERO_SPEED = 4.2;
const HERO_JUMP = -14;
const GRAVITY = 0.7;
const ATTACK_RANGE = 130;
const ATTACK_COOLDOWN = 420;
const GOBLIN_SPEED = 1.6;
const MAX_HP = 100;
const MAX_CHARGE = 100;
const HIT_CHARGE = 18;
const HURT_CHARGE = 8;

// ============== Yelu the Leaf-Dew Owlet (original design) ==============
// Plump, cream-bodied chibi owlet with leaf-shaped wingtip plumage and a
// laurel-leaf collar. Diving attacks trail a green leaf-wind crescent.
// PNG poses for Rowlet (the ultimate's companion). Natural pixel sizes captured
// at sprite-cleanup time so the renderer can do the centering math without
// reading them off an <img> ref. Source poses face roughly:
//   hover: front-facing (symmetric, no flip needed)
//   descend: descending with leaf trail above (used for intro)
//   dive: diving down-right with big leaf-blade VFX (used during attack dives)
//   fly_r: horizontal flight with motion blur (used for return-to-hover)
const ROWLET_POSES = {
  hover:   { src: 'assets/sprites_cleaned/rowlet_hover.png',   w: 443, h: 327 },
  descend: { src: 'assets/sprites_cleaned/rowlet_descend.png', w: 464, h: 345 },
  dive:    { src: 'assets/sprites_cleaned/rowlet_dive.png',    w: 488, h: 325 },
  fly_r:   { src: 'assets/sprites_cleaned/rowlet_fly_r.png',   w: 471, h: 297 },
};
// Preload so phase swaps don't trigger a network fetch mid-cinematic.
(() => {
  if (typeof window === 'undefined') return;
  Object.values(ROWLET_POSES).forEach(p => { const i = new Image(); i.src = p.src; });
})();

function OwlSummon({ x, y, scale = 1, alpha = 1, phase = 'hover', dir = 1, diveProgress = 0 }) {
  // Pose selection per phase:
  //   intro: bird arrives from above with trail -> 'descend'
  //   hover: idle scan above hero -> 'hover'
  //   dive: first half (going down to target) -> 'dive'; second half (return) -> 'fly_r'
  //   exit: same as hover but fading out
  let pose = 'hover';
  if (phase === 'intro') pose = 'descend';
  else if (phase === 'dive') pose = diveProgress < 0.5 ? 'dive' : 'fly_r';
  else if (phase === 'hover' || phase === 'exit') pose = 'hover';

  const p = ROWLET_POSES[pose];
  // Each pose natively shows the bird moving rightward (dive arc curls right,
  // fly_r flies right). Mirror when dir = -1.
  const RENDER = 0.55;
  const w = p.w * RENDER * scale;
  const h = p.h * RENDER * scale;

  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      width: 1, height: 1,
      transform: `scaleX(${dir})`,
      opacity: alpha,
      pointerEvents: 'none',
      filter: 'drop-shadow(0 0 14px rgba(140,220,140,0.65)) drop-shadow(0 0 28px rgba(180,240,160,0.35))',
    }}>
      <img
        src={p.src}
        width={w} height={h}
        style={{
          position: 'absolute',
          left: -w / 2, top: -h / 2,
          imageRendering: 'auto',
          userSelect: 'none',
          pointerEvents: 'none',
        }}
        draggable={false}
        alt=""
      />
    </div>
  );
}

// ============== Obstacle (fallen log) ==============
// Procedurally spawned along the world. Hero must jump or take light damage
// on contact. Drawn as a mossy log with end caps so it reads as "jumpable
// thing on the ground" at a glance.
function Obstacle({ x, w, h, hit }) {
  return (
    <div style={{
      position: 'absolute',
      left: x - w / 2, top: GROUND_Y - h,
      width: w, height: h + 8,
      pointerEvents: 'none',
      filter: hit ? 'brightness(0.7) saturate(0.6)' : 'none',
      transition: 'filter 0.2s',
    }}>
      <svg viewBox={`0 0 ${w} ${h + 8}`} width={w} height={h + 8} style={{ overflow: 'visible' }}>
        {/* ground shadow */}
        <ellipse cx={w / 2} cy={h + 4} rx={w / 2 - 2} ry="4" fill="rgba(0,0,0,0.38)" style={{ filter: 'blur(2px)' }}/>
        {/* main log body */}
        <rect x="6" y="2" width={w - 12} height={h - 4} rx={(h - 4) / 2} ry={(h - 4) / 2}
          fill="#7a4a28" stroke="#3a2010" strokeWidth="2"/>
        {/* end caps with concentric rings */}
        <ellipse cx="9" cy={h / 2} rx="6" ry={(h - 6) / 2}
          fill="#5a3018" stroke="#2a1008" strokeWidth="1.5"/>
        <ellipse cx="9" cy={h / 2} rx="3.5" ry={(h - 12) / 2} fill="none" stroke="#4a2410" strokeWidth="1"/>
        <ellipse cx={w - 9} cy={h / 2} rx="6" ry={(h - 6) / 2}
          fill="#5a3018" stroke="#2a1008" strokeWidth="1.5"/>
        <ellipse cx={w - 9} cy={h / 2} rx="3.5" ry={(h - 12) / 2} fill="none" stroke="#4a2410" strokeWidth="1"/>
        {/* moss patches on top */}
        <ellipse cx={w * 0.3} cy="4" rx="11" ry="3.5" fill="#5a8838"/>
        <ellipse cx={w * 0.55} cy="3" rx="9" ry="3" fill="#7aa848"/>
        <ellipse cx={w * 0.78} cy="5" rx="7" ry="2.5" fill="#5a8838"/>
        {/* wood grain stripe */}
        <path d={`M 14 ${h / 2 + 1} Q ${w / 2} ${h / 2 - 3} ${w - 14} ${h / 2 + 1}`}
          fill="none" stroke="#4a2814" strokeWidth="1.2" opacity="0.7"/>
      </svg>
    </div>
  );
}

// ============== Goblin (original pixel-style) ==============
function Goblin({ x, y, flipped, hurt, attacking, walkPhase }) {
  const bob = Math.sin(walkPhase) * 3;
  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      width: GOBLIN_W, height: GOBLIN_H,
      transform: `translate(-50%, -100%) scaleX(${flipped ? -1 : 1})`,
      // Subtle hit feedback — slight brightness bump + warm tint, not the old
      // bleached-white flash. Most of the damage feedback now comes from the
      // blood-burst particles at the hit point, plus the goblin's knockback.
      filter: hurt ? 'brightness(1.35) saturate(1.25) drop-shadow(0 0 6px rgba(220,40,40,0.55))' : 'none',
      transition: hurt ? 'none' : 'filter 0.1s',
    }}>
      <svg viewBox="0 0 70 80" width={GOBLIN_W} height={GOBLIN_H} style={{ overflow: 'visible' }}>
        {/* shadow */}
        <ellipse cx="35" cy="78" rx="22" ry="3.5" fill="rgba(0,0,0,0.35)"/>
        {/* legs */}
        <rect x="22" y={56 - bob*0.3} width="9" height="20" fill="#3d5e2a" stroke="#1a2810" strokeWidth="1.5"/>
        <rect x="39" y={56 + bob*0.3} width="9" height="20" fill="#3d5e2a" stroke="#1a2810" strokeWidth="1.5"/>
        {/* boots */}
        <rect x="20" y={72 - bob*0.3} width="13" height="6" fill="#3a2418" stroke="#1a1008" strokeWidth="1.5"/>
        <rect x="37" y={72 + bob*0.3} width="13" height="6" fill="#3a2418" stroke="#1a1008" strokeWidth="1.5"/>
        {/* body */}
        <path d={`M 18 ${36 + bob} Q 35 ${28 + bob} 52 ${36 + bob} L 50 60 L 20 60 Z`}
          fill="#587a3a" stroke="#1a2810" strokeWidth="2" strokeLinejoin="round"/>
        {/* belt */}
        <rect x="20" y={52 + bob} width="30" height="4" fill="#5a3a1a" stroke="#1a1008" strokeWidth="1"/>
        {/* arms */}
        <rect x={attacking ? 48 : 50} y={38 + bob} width="8" height="18" fill="#587a3a" stroke="#1a2810" strokeWidth="1.5"
          transform={attacking ? 'rotate(-40 54 47)' : ''}/>
        <rect x="14" y={38 + bob} width="8" height="18" fill="#4a6a30" stroke="#1a2810" strokeWidth="1.5"/>
        {/* club */}
        <g transform={attacking ? `rotate(-50 56 ${44 + bob})` : ''}>
          <rect x={54} y={32 + bob} width="5" height="22" fill="#5a3a1a" stroke="#1a1008" strokeWidth="1.2"/>
          <rect x={51} y={28 + bob} width="11" height="10" fill="#7a5a3a" stroke="#1a1008" strokeWidth="1.2"/>
        </g>
        {/* head */}
        <ellipse cx="35" cy={24 + bob} rx="15" ry="14" fill="#6a8c44" stroke="#1a2810" strokeWidth="2"/>
        {/* ears */}
        <path d={`M 20 ${22 + bob} L 12 ${16 + bob} L 22 ${28 + bob} Z`} fill="#6a8c44" stroke="#1a2810" strokeWidth="1.5"/>
        <path d={`M 50 ${22 + bob} L 58 ${16 + bob} L 48 ${28 + bob} Z`} fill="#6a8c44" stroke="#1a2810" strokeWidth="1.5"/>
        {/* eyes */}
        <circle cx="30" cy={23 + bob} r="3" fill="#fff"/>
        <circle cx="40" cy={23 + bob} r="3" fill="#fff"/>
        <circle cx="30.5" cy={23 + bob} r="1.6" fill="#c92020"/>
        <circle cx="40.5" cy={23 + bob} r="1.6" fill="#c92020"/>
        {/* fang mouth */}
        <path d={`M 28 ${30 + bob} L 42 ${30 + bob} L 40 ${34 + bob} L 38 ${31 + bob} L 36 ${34 + bob} L 34 ${31 + bob} L 32 ${34 + bob} Z`}
          fill="#1a1008" stroke="#1a1008" strokeWidth="1"/>
        {/* tusk */}
        <path d={`M 38 ${30 + bob} L 40 ${36 + bob} L 39 ${30 + bob} Z`} fill="#f4e6c8"/>
      </svg>
    </div>
  );
}

// ============== Background layers ==============
function Background({ camX, palette }) {
  const skyTop = palette.skyTop;
  const skyBot = palette.skyBot;
  const farMtn = palette.farMtn;
  const midMtn = palette.midMtn;
  const trees = palette.trees;
  const ground = palette.ground;
  const grass = palette.grass;

  // Repeating elements
  const range = (n) => Array.from({ length: n }, (_, i) => i);

  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
      {/* Sky */}
      <div style={{
        position: 'absolute', inset: 0,
        background: `linear-gradient(to bottom, ${skyTop} 0%, ${skyBot} 70%, ${palette.horizon} 100%)`,
      }} />
      {/* Sun */}
      <div style={{
        position: 'absolute',
        left: 980 - camX * 0.05, top: 110,
        width: 130, height: 130, borderRadius: '50%',
        background: 'radial-gradient(circle, #fff4c8 0%, #ffd47a 40%, rgba(255,180,90,0.0) 70%)',
        filter: 'blur(2px)',
      }} />
      <div style={{
        position: 'absolute',
        left: 1010 - camX * 0.05, top: 140,
        width: 70, height: 70, borderRadius: '50%',
        background: '#fff0c4',
        boxShadow: '0 0 60px 20px rgba(255,220,160,0.6)',
      }} />

      {/* Far mountains */}
      <div style={{
        position: 'absolute', bottom: 200,
        left: -((camX * 0.1) % 800),
        display: 'flex', gap: 0,
      }}>
        {range(6).map(i => (
          <svg key={i} width="800" height="240" viewBox="0 0 800 240" style={{ display: 'block' }}>
            <path d={`M 0 240 L 80 130 L 160 180 L 240 90 L 340 160 L 440 70 L 560 150 L 680 110 L 800 170 L 800 240 Z`}
              fill={farMtn} />
          </svg>
        ))}
      </div>

      {/* Mid mountains */}
      <div style={{
        position: 'absolute', bottom: 160,
        left: -((camX * 0.25) % 700),
        display: 'flex',
      }}>
        {range(6).map(i => (
          <svg key={i} width="700" height="220" viewBox="0 0 700 220" style={{ display: 'block' }}>
            <path d="M 0 220 L 70 120 L 180 170 L 290 80 L 400 150 L 520 100 L 620 160 L 700 130 L 700 220 Z"
              fill={midMtn} />
          </svg>
        ))}
      </div>

      {/* Tree line */}
      <div style={{
        position: 'absolute', bottom: 130,
        left: -((camX * 0.5) % 200),
        display: 'flex',
      }}>
        {range(20).map(i => (
          <svg key={i} width="200" height="160" viewBox="0 0 200 160" style={{ display: 'block' }}>
            <path d={`M 30 160 L 30 110 M 30 110 L 10 110 L 30 70 L 12 70 L 30 30 L 48 70 L 30 70 L 50 110 L 30 110`}
              stroke={trees} strokeWidth="6" fill={trees} strokeLinejoin="round"/>
            <path d={`M 110 160 L 110 120 M 110 120 L 88 120 L 110 80 L 92 80 L 110 50 L 128 80 L 110 80 L 132 120 L 110 120`}
              stroke={trees} strokeWidth="5" fill={trees} strokeLinejoin="round" opacity="0.85"/>
            <path d={`M 170 160 L 170 115 M 170 115 L 150 115 L 170 75 L 152 75 L 170 40 L 188 75 L 170 75 L 190 115 L 170 115`}
              stroke={trees} strokeWidth="5" fill={trees} strokeLinejoin="round"/>
          </svg>
        ))}
      </div>

      {/* Ground */}
      <div style={{
        position: 'absolute', left: 0, right: 0,
        top: GROUND_Y, height: WORLD_H - GROUND_Y,
        background: `linear-gradient(to bottom, ${grass} 0%, ${grass} 14px, ${ground} 14px, ${ground} 100%)`,
      }} />

      {/* Ground tile pattern */}
      <div style={{
        position: 'absolute', left: -((camX) % 64), top: GROUND_Y + 14,
        height: WORLD_H - GROUND_Y - 14,
        display: 'flex',
      }}>
        {range(Math.ceil(WORLD_W / 64) + 2).map(i => (
          <div key={i} style={{
            width: 64, height: '100%',
            borderRight: `2px dashed rgba(0,0,0,0.12)`,
          }}/>
        ))}
      </div>

      {/* Foreground grass tufts */}
      <div style={{
        position: 'absolute', bottom: WORLD_H - GROUND_Y - 4,
        left: -((camX * 1.1) % 180),
        display: 'flex',
      }}>
        {range(20).map(i => (
          <svg key={i} width="180" height="22" viewBox="0 0 180 22" style={{ display: 'block' }}>
            <path d="M 10 22 L 6 8 M 10 22 L 14 10 M 14 22 L 18 12" stroke={palette.grassTuft} strokeWidth="3" fill="none" strokeLinecap="round"/>
            <path d="M 70 22 L 66 6 M 70 22 L 74 8 M 74 22 L 78 12 M 78 22 L 82 10" stroke={palette.grassTuft} strokeWidth="3" fill="none" strokeLinecap="round"/>
            <path d="M 130 22 L 126 10 M 130 22 L 134 8 M 134 22 L 138 12" stroke={palette.grassTuft} strokeWidth="3" fill="none" strokeLinecap="round"/>
          </svg>
        ))}
      </div>
    </div>
  );
}

// ============== Slash effect ==============
function Slash({ x, y, dir, age }) {
  const t = Math.min(1, age / 280);
  const opacity = (1 - t) * 1.0;
  const scale = 0.8 + t * 0.8;
  return (
    <div style={{
      position: 'absolute',
      left: x, top: y,
      transform: `translate(-50%, -50%) scaleX(${dir}) scale(${scale}) rotate(${t * 40 - 20}deg)`,
      opacity,
      pointerEvents: 'none',
      mixBlendMode: 'screen',
    }}>
      <svg width="260" height="200" viewBox="-130 -100 260 200" style={{ overflow: 'visible' }}>
        {/* Outer arc — bright golden-white */}
        <path d="M -110 30 Q 0 -90 110 10 Q 30 -30 -110 30 Z"
          fill="rgba(255,238,180,0.9)"
          stroke="#fff5d8" strokeWidth="4" strokeLinejoin="round"
          style={{ filter: 'drop-shadow(0 0 14px #ffd24a) drop-shadow(0 0 24px #fff)' }} />
        {/* Inner highlight */}
        <path d="M -95 32 Q 0 -75 95 14" stroke="rgba(255,255,255,1)" strokeWidth="4" fill="none"/>
        {/* Streaks */}
        <path d="M -100 36 Q -60 20 -20 5" stroke="rgba(255,220,140,0.7)" strokeWidth="2" fill="none"/>
        <path d="M -80 42 Q -40 28 0 14" stroke="rgba(255,220,140,0.7)" strokeWidth="2" fill="none"/>
      </svg>
    </div>
  );
}

// ============== Damage number ==============
function DamageNumber({ x, y, value, age, crit }) {
  const t = age / 800;
  return (
    <div style={{
      position: 'absolute',
      left: x, top: y - t * 40,
      transform: 'translate(-50%, -50%)',
      opacity: 1 - t,
      color: crit ? '#ffd24a' : '#fff',
      fontFamily: '"Press Start 2P", monospace',
      fontSize: crit ? 28 : 20,
      textShadow: '2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000',
      pointerEvents: 'none',
    }}>
      {crit ? `${value}!` : value}
    </div>
  );
}

// ============== Particle ==============
function Particle({ p }) {
  return (
    <div style={{
      position: 'absolute',
      left: p.x, top: p.y,
      width: p.size, height: p.size,
      background: p.color,
      borderRadius: p.round ? '50%' : 0,
      opacity: p.life,
      transform: `translate(-50%, -50%) rotate(${p.rot}deg)`,
      pointerEvents: 'none',
    }}/>
  );
}

// ============== Hero Sprites ==============
// Cleaned via assets/_work/step4_finalize.py:
//   1. rembg (isnet-anime) removed the cream paper background.
//   2. Every sprite was rescaled so the detected face width = 112px
//      (matching b4_ready). One uniform `scale` value now works for every
//      state — character body appears at consistent size across idle/walk/
//      attack/jump.
//   3. ax/ay are the character's foot-anchor position WITHIN the cleaned
//      png (fraction of w/h), so the foot lands exactly on the hero's
//      world position regardless of pose.
// All sprites face RIGHT natively; we mirror with scaleX(-1) when facing left.
const HERO_SPRITES = {
  idle:   { src: 'assets/sprites_cleaned/b4_ready_R.png',    w: 356, h: 506, scale: 0.34, ax: 0.461, ay: 0.972, faceDir: 1 },
  walk:   { src: 'assets/sprites_cleaned/b4_ready_R.png',    w: 356, h: 506, scale: 0.34, ax: 0.461, ay: 0.972, faceDir: 1 },
  jump:   { src: 'assets/sprites_cleaned/b1_jump_cheer.png', w: 324, h: 428, scale: 0.34, ax: 0.654, ay: 0.932, faceDir: 1 },
  attack: { src: 'assets/sprites_cleaned/b3_slash.png',      w: 733, h: 465, scale: 0.34, ax: 0.458, ay: 0.970, faceDir: 1 },
  cast:   { src: 'assets/sprites_cleaned/b1_jump_cheer.png', w: 324, h: 428, scale: 0.34, ax: 0.654, ay: 0.932, faceDir: 1 },
};

// Preload all hero sprite PNGs so swapping state during play doesn't trigger
// a network fetch (which can leave the <img> blank for the brief attack frame).
const HERO_SPRITE_PRELOADS = (() => {
  if (typeof window === 'undefined') return [];
  return Array.from(new Set(Object.values(HERO_SPRITES).map(s => s.src))).map(src => {
    const img = new Image();
    img.src = src;
    return img;
  });
})();

// ============== Hero ==============
function Hero({ x, y, facing, attacking, hurt, invuln, walkPhase, jumping, channel, moving }) {
  let key;
  if (channel) key = 'cast';
  else if (attacking) key = 'attack';
  else if (jumping) key = 'jump';
  else if (moving) key = 'walk';
  else key = 'idle';
  const sp = HERO_SPRITES[key];
  // Small "weighty hit" size bump when attacking. Subtle — too much reads as
  // a bug rather than impact feedback. Tune via this single constant. Scaling
  // happens around the sprite's own anchor (foot/face center), so feet stay
  // planted on the ground line during the bump.
  const ATTACK_SIZE = 1.06;
  const sizeBoost = attacking ? ATTACK_SIZE : 1;
  const sw = sp.w * sp.scale * sizeBoost;
  const sh = sp.h * sp.scale * sizeBoost;

  // Cosmetic motion via CSS transform (NOT swapping sprites — that caused
  // size jumps). Walking: small vertical bob + slight rotation oscillation.
  // Attacking: forward lean + tiny lunge. Hurt: brief horizontal jitter so
  // the player still feels the hit without the old whole-body red flash.
  const bob = jumping ? 0 : (moving ? Math.abs(Math.sin(walkPhase * 1.3)) * -4
                                    : Math.sin(performance.now() / 600) * 1.2);
  const tilt = attacking ? 8
             : (moving && !jumping ? Math.sin(walkPhase * 1.3) * 3 : 0);
  const lunge = attacking ? 12 : 0;
  const hurtJitter = hurt ? (Math.sin(performance.now() / 18) * 3) : 0;
  // Invulnerability strobe: blink between full and 40% opacity at ~12Hz so
  // the player can tell they're protected — replaces the red filter as the
  // "I just got hit" indicator without recoloring the sprite.
  const invulnAlpha = invuln ? (Math.floor(performance.now() / 80) % 2 ? 0.42 : 1) : 1;

  return (
    <div style={{
      position: 'absolute',
      left: x + lunge * facing + hurtJitter, top: y + bob,
      width: 1, height: 1,
      pointerEvents: 'none',
      transform: `scaleX(${facing * sp.faceDir})`,
      transformOrigin: 'center top',
      opacity: invulnAlpha,
      filter: channel ? 'drop-shadow(0 0 16px #c8a8ff) brightness(1.05)'
                      : 'drop-shadow(2px 6px 0 rgba(0,0,0,0.25))',
    }}>
      {/* glow ring while channeling */}
      {channel && (
        <div style={{
          position: 'absolute',
          left: 0, top: -6,
          transform: 'translateX(-50%)',
          width: 140, height: 34, borderRadius: '50%',
          background: 'radial-gradient(ellipse, rgba(200,168,255,0.75) 0%, rgba(200,168,255,0) 70%)',
        }}/>
      )}
      {/* ground shadow */}
      <div style={{
        position: 'absolute',
        left: 0, top: -3,
        transform: 'translateX(-50%)',
        width: 78, height: 14, borderRadius: '50%',
        background: 'rgba(0,0,0,0.32)',
        filter: 'blur(3px)',
      }}/>
      {/* sprite */}
      <img
        key={key}
        src={sp.src}
        width={sw} height={sh}
        style={{
          position: 'absolute',
          left: -sp.ax * sw,
          top: -sp.ay * sh,
          transform: `rotate(${tilt}deg)`,
          transformOrigin: `${sp.ax * 100}% ${sp.ay * 100}%`,
          imageRendering: 'auto',
          userSelect: 'none',
          pointerEvents: 'none',
        }}
        draggable={false}
        alt=""
      />
    </div>
  );
}

// ============== Palettes ==============
const PALETTES = {
  dusk: {
    skyTop: '#3a2a5e', skyBot: '#e88a5a', horizon: '#f4c878',
    farMtn: '#2a1f48', midMtn: '#4a3068', trees: '#2a1830',
    ground: '#5a3a2a', grass: '#7a8a3a', grassTuft: '#3a4a18',
  },
  dawn: {
    skyTop: '#8ab8d8', skyBot: '#f4d8c8', horizon: '#f8e8d0',
    farMtn: '#6a7898', midMtn: '#8a8ab0', trees: '#3a5840',
    ground: '#6a5440', grass: '#8aa850', grassTuft: '#4a6028',
  },
  night: {
    skyTop: '#0a0820', skyBot: '#1a1840', horizon: '#3a2858',
    farMtn: '#080618', midMtn: '#1a1430', trees: '#0a0818',
    ground: '#2a2030', grass: '#3a4828', grassTuft: '#1a2810',
  },
};

// ============== Main Game ==============
function Game({ tweaks }) {
  const palette = PALETTES[tweaks.palette] || PALETTES.dusk;

  // -------- State --------
  const [, force] = useState(0);
  const rerender = useCallback(() => force(n => n + 1), []);

  const stateRef = useRef({
    hero: { x: 240, y: GROUND_Y, vx: 0, vy: 0, facing: 1, hp: MAX_HP, charge: 0,
            attackCooldown: 0, attacking: 0, hurt: 0, walkPhase: 0, jumping: false,
            invuln: 0 },
    cam: 0,
    goblins: [],
    slashes: [],
    damageNums: [],
    particles: [],
    obstacles: [],            // { id, x, w, h, hit }
    nextObstacleX: 1100,      // x of next obstacle to spawn ahead
    owl: null,   // { x, y, vx, vy, flap, alpha, life }
    owlCharge: null, // pre-summon charge-up state
    keys: {},
    spawnTimer: 80,
    distance: 0,
    kills: 0,
    score: 0,
    gameOver: false,
    paused: false,
    shake: 0,
    flash: 0,
    time: 0,
    nextId: 1,
  });

  const lastTimeRef = useRef(performance.now());

  // -------- Input --------
  useEffect(() => {
    const down = (e) => {
      const k = e.key.toLowerCase();
      if (['arrowleft','arrowright','arrowup','arrowdown',' ','q','r'].includes(k)) e.preventDefault();
      stateRef.current.keys[k] = true;
      if (k === 'r' && stateRef.current.gameOver) reset();
      if (k === 'p') stateRef.current.paused = !stateRef.current.paused;
    };
    const up = (e) => { stateRef.current.keys[e.key.toLowerCase()] = false; };
    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);
    return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
  }, []);

  // -------- Reset --------
  function reset() {
    const s = stateRef.current;
    s.hero = { x: 240, y: GROUND_Y, vx: 0, vy: 0, facing: 1, hp: MAX_HP, charge: 0,
               attackCooldown: 0, attacking: 0, hurt: 0, walkPhase: 0, jumping: false, invuln: 0 };
    s.cam = 0; s.goblins = []; s.slashes = []; s.damageNums = []; s.particles = [];
    s.obstacles = []; s.nextObstacleX = 1100;
    s.owl = null; s.owlCharge = null;
    s.spawnTimer = 80; s.distance = 0; s.kills = 0; s.score = 0;
    s.gameOver = false; s.shake = 0; s.flash = 0;
  }

  // -------- Helpers --------
  function spawnParticles(x, y, color, count = 8, opts = {}) {
    const s = stateRef.current;
    for (let i = 0; i < count; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = (opts.speed || 3) * (0.5 + Math.random());
      s.particles.push({
        x, y,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - (opts.up || 0),
        size: opts.size || (3 + Math.random() * 4),
        color,
        life: 1,
        decay: opts.decay || 0.025,
        gravity: opts.gravity ?? 0.15,
        round: opts.round ?? true,
        rot: Math.random() * 360,
      });
    }
  }

  // Three-layer blood burst at a hit point. Replaces the old "whole sprite
  // flashes red" feedback with a localized splatter that reads as impact
  // without overwhelming the chibi sprites.
  //   • spray   — small fast crimson specks in all directions
  //   • drops   — heavier dark-red droplets that arc and fall under gravity
  //   • shine   — tiny bright highlights for "wet" feel
  // `scale` (default 1) scales the count + droplet size — pass ~1.2 for player
  // hits, ~0.8 for enemy hits.
  function spawnBlood(x, y, scale = 1) {
    spawnParticles(x, y, '#c41818', Math.round(12 * scale),
      { speed: 5.5, decay: 0.045, gravity: 0.18, size: 3 });
    spawnParticles(x, y, '#7a0e0e', Math.round(5 * scale),
      { speed: 3, decay: 0.022, gravity: 0.38, size: 6 * scale });
    spawnParticles(x, y, '#ff5870', Math.round(4 * scale),
      { speed: 2.2, decay: 0.055, gravity: 0.1, size: 2 });
  }

  function spawnGoblin() {
    const s = stateRef.current;
    const fromLeft = Math.random() < 0.25;
    const x = fromLeft ? s.cam - 80 : s.cam + WORLD_W + 80;
    const diff = tweaks.difficulty;
    const hpMul = diff === 'easy' ? 0.7 : diff === 'hard' ? 1.4 : 1;
    const spMul = diff === 'easy' ? 0.8 : diff === 'hard' ? 1.25 : 1;
    s.goblins.push({
      id: s.nextId++,
      x, y: GROUND_Y,
      hp: Math.round((24 + Math.random() * 16) * hpMul),
      maxHp: 40,
      flipped: !fromLeft,
      attackCd: 30 + Math.random() * 40,
      hurt: 0, attacking: 0,
      walkPhase: Math.random() * 6,
      speed: GOBLIN_SPEED * spMul * (0.9 + Math.random() * 0.3),
      knockback: 0,
    });
  }

  function summonOwl() {
    const s = stateRef.current;
    if (s.hero.charge < MAX_CHARGE || s.owl || s.owlCharge) return;
    s.hero.charge = 0;
    s.owlCharge = { life: 60 }; // 1 second charge-up
    s.flash = 0.5;
  }

  // -------- Game loop --------
  useEffect(() => {
    let raf;
    const loop = (now) => {
      const dt = Math.min(48, now - lastTimeRef.current);
      lastTimeRef.current = now;
      const s = stateRef.current;
      if (!s.paused && !s.gameOver) {
        step(dt);
      }
      rerender();
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [tweaks.difficulty]);

  function step(dt) {
    const s = stateRef.current;
    const k = s.keys;
    const h = s.hero;
    s.time += dt;

    // Movement
    let mv = 0;
    if (k['arrowleft'] || k['a']) mv -= 1;
    if (k['arrowright'] || k['d']) mv += 1;
    h.vx = mv * HERO_SPEED;
    if (mv !== 0) h.facing = mv > 0 ? 1 : -1;
    if (!h.jumping && (k['arrowup'] || k['w'] || k[' '])) {
      // space only jumps if not also used for attack; we use J for attack
    }
    if (!h.jumping && (k['arrowup'] || k['w'])) {
      h.vy = HERO_JUMP;
      h.jumping = true;
    }

    // Attack (J or K or space)
    if ((k['j'] || k['k'] || k[' ']) && h.attackCooldown <= 0 && !s.owl && !s.owlCharge) {
      h.attackCooldown = ATTACK_COOLDOWN;
      h.attacking = 350;
      const ax = h.x + h.facing * 60;
      const ay = h.y - 60;
      s.slashes.push({ x: ax, y: ay, dir: h.facing, age: 0 });
      // hit goblins in range
      let hit = false;
      for (const g of s.goblins) {
        if (g.hp <= 0) continue;
        const dx = g.x - h.x;
        if (Math.sign(dx) !== h.facing && Math.abs(dx) > 30) continue;
        if (Math.abs(dx) < ATTACK_RANGE && Math.abs(g.y - h.y) < 100) {
          const crit = Math.random() < 0.15;
          const dmg = crit ? 20 : (10 + Math.floor(Math.random() * 6));
          g.hp -= dmg;
          g.hurt = 12;
          g.knockback = h.facing * 8;
          s.damageNums.push({ id: s.nextId++, x: g.x, y: g.y - 80, value: dmg, age: 0, crit });
          // Yellow impact spark (the "clang" of metal on flesh) + blood burst.
          spawnParticles(g.x, g.y - 40, '#ffe070', 5, { speed: 5, decay: 0.06, size: 4 });
          spawnBlood(g.x, g.y - 40, crit ? 1.1 : 0.8);
          h.charge = Math.min(MAX_CHARGE, h.charge + HIT_CHARGE);
          hit = true;
          if (g.hp <= 0) {
            s.kills++;
            s.score += crit ? 150 : 100;
            spawnParticles(g.x, g.y - 40, '#3d5e2a', 18, { speed: 5, decay: 0.02 });
            spawnParticles(g.x, g.y - 30, '#587a3a', 12, { speed: 4 });
          }
        }
      }
      if (hit) s.shake = Math.max(s.shake, 6);
    }

    // Ultimate
    if (k['q']) summonOwl();

    if (h.attackCooldown > 0) h.attackCooldown -= dt;
    if (h.attacking > 0) h.attacking -= dt;
    if (h.hurt > 0) h.hurt -= dt;
    if (h.invuln > 0) h.invuln -= dt;

    // Physics
    h.x += h.vx;
    h.y += h.vy;
    h.vy += GRAVITY;
    if (h.y >= GROUND_Y) { h.y = GROUND_Y; h.vy = 0; h.jumping = false; }
    if (mv !== 0 && !h.jumping) h.walkPhase += 0.25;
    else h.walkPhase *= 0.9;

    // Camera follows hero
    const targetCam = h.x - 380;
    s.cam += (targetCam - s.cam) * 0.08;
    if (s.cam < 0) s.cam = 0;
    s.distance = Math.max(s.distance, Math.floor(h.x / 10));

    // Spawn
    s.spawnTimer -= dt * 0.06 * (tweaks.difficulty === 'hard' ? 1.3 : tweaks.difficulty === 'easy' ? 0.7 : 1);
    if (s.spawnTimer <= 0 && s.goblins.length < (tweaks.difficulty === 'hard' ? 7 : 5)) {
      spawnGoblin();
      s.spawnTimer = 60 + Math.random() * 100;
    }

    // Obstacle spawn: keep ~one screen-width of obstacles queued ahead of the
    // camera. Pre-spawning beyond visible area means they fade in naturally as
    // the hero advances rather than popping into existence on-screen.
    while (s.cam + WORLD_W + 200 > s.nextObstacleX) {
      s.obstacles.push({
        id: s.nextId++,
        x: s.nextObstacleX,
        w: 90 + Math.floor(Math.random() * 30),  // 90–120
        h: 32 + Math.floor(Math.random() * 12),  // 32–44 (low enough to clear with a normal jump)
        hit: false,
      });
      // 480 ~ 920 px gap so cadence is "every few seconds at running speed"
      s.nextObstacleX += 480 + Math.random() * 440;
    }
    // Despawn obstacles that scrolled fully off-screen left
    s.obstacles = s.obstacles.filter(ob => ob.x > s.cam - 200);

    // Obstacle collision. Hero is treated as a thin vertical pole at h.x —
    // they collide if their x-distance to the log is within the log's half-
    // width + a small fudge (HERO_W/4), AND they didn't jump high enough to
    // clear it (h.y is below the log's top edge).
    for (const ob of s.obstacles) {
      if (ob.hit) continue;
      const dx = Math.abs(h.x - ob.x);
      const obTop = GROUND_Y - ob.h;
      if (dx < ob.w / 2 + 18 && h.y > obTop - 8) {
        ob.hit = true;
        if (h.invuln <= 0) {
          const dmg = 5;
          h.hp -= dmg;
          h.hurt = 180;
          h.invuln = 450;
          // bounce back away from the obstacle
          h.vx = -Math.sign(h.x - ob.x || 1) * 5;
          h.charge = Math.min(MAX_CHARGE, h.charge + HURT_CHARGE);
          s.shake = Math.max(s.shake, 7);
          s.damageNums.push({ id: s.nextId++, x: h.x, y: h.y - 90, value: dmg, age: 0, crit: false, red: true });
          spawnBlood(h.x, h.y - 30, 0.6);
          if (h.hp <= 0) { h.hp = 0; s.gameOver = true; }
        }
      }
    }

    // Goblins (frozen during ult — drama time)
    const ultActive = !!s.owl || !!s.owlCharge;
    for (const g of s.goblins) {
      if (g.hp <= 0) continue;
      const dx = h.x - g.x;
      const dist = Math.abs(dx);
      g.flipped = dx < 0;
      if (!ultActive) g.walkPhase += 0.18;
      if (g.knockback !== 0) {
        g.x += g.knockback;
        g.knockback *= 0.6;
        if (Math.abs(g.knockback) < 0.3) g.knockback = 0;
      } else if (!ultActive && dist > 60) {
        g.x += Math.sign(dx) * g.speed;
      }
      if (g.hurt > 0) g.hurt -= dt;
      if (g.attacking > 0) g.attacking -= dt;
      if (!ultActive) {
        g.attackCd -= dt * 0.06;
        if (dist < 80 && g.attackCd <= 0) {
          g.attackCd = 60 + Math.random() * 40;
          g.attacking = 250;
          // hit hero
          if (h.invuln <= 0 && Math.abs(h.y - g.y) < 100) {
            const dmg = tweaks.difficulty === 'hard' ? 12 : tweaks.difficulty === 'easy' ? 5 : 8;
            h.hp -= dmg;
            h.hurt = 200;
            h.invuln = 600;
            h.vx += -Math.sign(dx) * 4;
            h.charge = Math.min(MAX_CHARGE, h.charge + HURT_CHARGE);
            s.shake = Math.max(s.shake, 10);
            s.damageNums.push({ id: s.nextId++, x: h.x, y: h.y - 100, value: dmg, age: 0, crit: false, red: true });
            spawnBlood(h.x, h.y - 50, 1.2);
            if (h.hp <= 0) {
              h.hp = 0;
              s.gameOver = true;
            }
          }
        }
      }
    }
    // Goblin cleanup. Hero (4.2 px/frame) outruns goblins (1.6) so chasers
    // pile up off-screen-left — and the spawn cap then blocks new enemies from
    // appearing ahead. Recycle alive goblins that have fallen > 1 screen behind
    // the camera so the world can refill from the front.
    s.goblins = s.goblins.filter(g => {
      if (g.hp <= 0) return g.hurt > -200;
      return g.x > s.cam - WORLD_W;
    });
    for (const g of s.goblins) if (g.hp <= 0) g.hurt -= dt;

    // Slashes
    for (const sl of s.slashes) sl.age += dt;
    s.slashes = s.slashes.filter(sl => sl.age < 280);

    // Damage numbers
    for (const dn of s.damageNums) dn.age += dt;
    s.damageNums = s.damageNums.filter(dn => dn.age < 800);

    // Particles
    for (const p of s.particles) {
      p.x += p.vx; p.y += p.vy;
      p.vy += p.gravity;
      p.life -= p.decay;
      p.rot += 5;
    }
    s.particles = s.particles.filter(p => p.life > 0);

    // Owl charge
    if (s.owlCharge) {
      s.owlCharge.life -= dt * 0.06;
      // sparkles around hero
      if (Math.random() < 0.5) {
        spawnParticles(h.x + (Math.random() - 0.5) * 80, h.y - 50 - Math.random() * 60,
          '#c8a8ff', 1, { speed: 0.5, decay: 0.04, gravity: -0.05, size: 4 });
      }
      if (s.owlCharge.life <= 0) {
        // Spawn companion owl: descends from sky and hovers above hero
        s.owl = {
          phase: 'intro',
          phaseTime: 0,
          x: h.x, y: -120,
          flap: 0,
          alpha: 1,
          diveCount: 0,
          maxDives: 3,
          targetId: null,
          targetX: 0, targetY: 0,
          diveStartX: 0, diveStartY: 0,
          hitThisDive: false,
        };
        s.owlCharge = null;
        s.flash = 0.7;
        s.shake = Math.max(s.shake, 14);
      }
    }

    // Owl ultimate — companion mode: hover above hero, dive on nearest goblin x3
    if (s.owl) {
      const o = s.owl;
      o.phaseTime += dt;
      o.flap += 0.32;
      const hoverX = () => h.x;
      const hoverY = () => h.y - 280 + Math.sin(s.time / 240) * 6;

      const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
      const easeInCubic  = (t) => t * t * t;

      if (o.phase === 'intro') {
        // descend from sky to hover position
        const T = 700;
        const t = Math.min(1, o.phaseTime / T);
        o.x = hoverX();
        o.y = -120 + (hoverY() - (-120)) * easeOutCubic(t);
        if (t >= 1) { o.phase = 'hover'; o.phaseTime = 0; }
      }
      else if (o.phase === 'hover') {
        // float above hero, scanning
        o.x = hoverX() + Math.sin(s.time / 180) * 24;
        o.y = hoverY();
        if (o.phaseTime > 400) {
          // pick nearest alive goblin (camera-visible preferred)
          const alive = s.goblins.filter(g => g.hp > 0);
          if (alive.length === 0 || o.diveCount >= o.maxDives) {
            o.phase = 'exit'; o.phaseTime = 0;
          } else {
            alive.sort((a, b) =>
              Math.hypot(a.x - h.x, a.y - h.y) - Math.hypot(b.x - h.x, b.y - h.y)
            );
            const tgt = alive[0];
            o.targetId = tgt.id;
            o.targetX = tgt.x;
            o.targetY = tgt.y - 50;
            o.diveStartX = o.x;
            o.diveStartY = o.y;
            o.hitThisDive = false;
            o.phase = 'dive';
            o.phaseTime = 0;
          }
        }
      }
      else if (o.phase === 'dive') {
        const T_DIVE = 380;
        const T_BACK = 420;
        if (o.phaseTime < T_DIVE) {
          // swoop down to target
          const t = o.phaseTime / T_DIVE;
          const e = easeInCubic(t);
          // re-aim if target alive
          const tgt = s.goblins.find(g => g.id === o.targetId);
          if (tgt && tgt.hp > 0) {
            o.targetX = tgt.x;
            o.targetY = tgt.y - 50;
          }
          o.x = o.diveStartX + (o.targetX - o.diveStartX) * e;
          o.y = o.diveStartY + (o.targetY - o.diveStartY) * e;
          // trail
          if (Math.random() < 0.85) {
            spawnParticles(o.x, o.y, '#8ad868', 1,
              { speed: 0.4, decay: 0.03, gravity: -0.02, size: 5 });
          }
          // check hit
          if (!o.hitThisDive && tgt && tgt.hp > 0) {
            const d = Math.hypot(o.x - tgt.x, o.y - (tgt.y - 50));
            if (d < 70) {
              o.hitThisDive = true;
              const dmg = 60;
              tgt.hp -= dmg;
              tgt.hurt = 22;
              tgt.knockback = (tgt.x > h.x ? 1 : -1) * 16;
              s.damageNums.push({ id: s.nextId++, x: tgt.x, y: tgt.y - 80, value: dmg, age: 0, crit: true });
              spawnParticles(tgt.x, tgt.y - 40, '#8ad868', 26, { speed: 6, decay: 0.018 });
              spawnParticles(tgt.x, tgt.y - 40, '#ffd24a', 16, { speed: 5 });
              spawnParticles(tgt.x, tgt.y - 40, '#fff', 8, { speed: 8, decay: 0.04, size: 6 });
              s.shake = Math.max(s.shake, 10);
              s.flash = Math.max(s.flash, 0.35);
              if (tgt.hp <= 0) {
                s.kills++;
                s.score += 250;
                spawnParticles(tgt.x, tgt.y - 40, '#3d5e2a', 22, { speed: 5 });
              }
            }
          }
        } else if (o.phaseTime < T_DIVE + T_BACK) {
          // return up to hover position
          const t = (o.phaseTime - T_DIVE) / T_BACK;
          const e = easeOutCubic(t);
          const hx = hoverX(), hy = hoverY();
          o.x = o.targetX + (hx - o.targetX) * e;
          o.y = o.targetY + (hy - o.targetY) * e;
        } else {
          o.diveCount++;
          o.phase = 'hover';
          o.phaseTime = 0;
        }
      }
      else if (o.phase === 'exit') {
        const T = 600;
        const t = Math.min(1, o.phaseTime / T);
        o.x = hoverX();
        o.y = hoverY() - 400 * easeInCubic(t);
        o.alpha = 1 - t;
        if (t >= 1) {
          s.owl = null;
        }
      }
    }

    // Shake / flash
    if (s.shake > 0) s.shake *= 0.85;
    if (s.flash > 0) s.flash -= 0.04;
  }

  const s = stateRef.current;
  const shakeX = s.shake && tweaks.screenShake ? (Math.random() - 0.5) * s.shake : 0;
  const shakeY = s.shake && tweaks.screenShake ? (Math.random() - 0.5) * s.shake : 0;

  return (
    <div style={{
      width: WORLD_W, height: WORLD_H,
      position: 'relative',
      overflow: 'hidden',
      background: '#000',
      fontFamily: '"Press Start 2P", monospace',
      transform: `translate(${shakeX}px, ${shakeY}px)`,
      boxShadow: '0 30px 90px rgba(0,0,0,0.6), 0 0 0 6px #1a1230, 0 0 0 10px #4a3578',
    }}>
      <Background camX={s.cam} palette={palette} />

      {/* Ult: purple cinematic tint overlay (under the world but above background) */}
      {(s.owl || s.owlCharge) && (
        <div style={{
          position: 'absolute', inset: 0,
          background: 'radial-gradient(ellipse at center, rgba(80,30,140,0.0) 0%, rgba(40,15,70,0.45) 70%, rgba(20,5,40,0.7) 100%)',
          pointerEvents: 'none',
          mixBlendMode: 'multiply',
        }}/>
      )}

      {/* Ult: crescent moon rising behind hero */}
      {(s.owl || s.owlCharge) && (() => {
        const heroScreenX = s.hero.x - s.cam;
        // Fade in during cast + intro; full during dives; fade out on exit
        let alpha = 0.85;
        if (s.owlCharge) alpha = (60 - s.owlCharge.life) / 60 * 0.85;
        if (s.owl && s.owl.phase === 'exit') alpha = (1 - Math.min(1, s.owl.phaseTime / 600)) * 0.85;
        return (
          <div style={{
            position: 'absolute',
            left: heroScreenX - 240, top: 60,
            width: 480, height: 480,
            opacity: alpha,
            pointerEvents: 'none',
          }}>
            <svg viewBox="-100 -100 200 200" width="480" height="480" style={{ overflow: 'visible' }}>
              <defs>
                <radialGradient id="moonGlow" cx="50%" cy="50%" r="50%">
                  <stop offset="0%" stopColor="#fff4e0" stopOpacity="0.9"/>
                  <stop offset="40%" stopColor="#e8c8ff" stopOpacity="0.5"/>
                  <stop offset="100%" stopColor="#c8a8ff" stopOpacity="0"/>
                </radialGradient>
              </defs>
              <circle cx="0" cy="0" r="100" fill="url(#moonGlow)"/>
              {/* crescent: big circle minus offset circle */}
              <mask id="crescentMask">
                <rect x="-100" y="-100" width="200" height="200" fill="black"/>
                <circle cx="0" cy="0" r="76" fill="white"/>
                <circle cx="22" cy="-6" r="72" fill="black"/>
              </mask>
              <circle cx="0" cy="0" r="76" fill="#fff8e0" mask="url(#crescentMask)"
                style={{ filter: 'drop-shadow(0 0 30px #c8a8ff) drop-shadow(0 0 60px #8868d8)' }}/>
              {/* sparkle stars around */}
              {[[-80, -50], [60, -70], [70, 40], [-70, 60], [0, -90], [-90, 10]].map(([x, y], i) => (
                <g key={i} transform={`translate(${x},${y}) rotate(${(s.time * 0.05 + i * 30) % 360})`}>
                  <path d="M 0 -6 L 1.5 -1.5 L 6 0 L 1.5 1.5 L 0 6 L -1.5 1.5 L -6 0 L -1.5 -1.5 Z"
                    fill="#fff"
                    style={{ filter: 'drop-shadow(0 0 4px #fff)' }}/>
                </g>
              ))}
            </svg>
          </div>
        );
      })()}

      {/* World layer */}
      <div style={{ position: 'absolute', inset: 0, transform: `translateX(${-s.cam}px)` }}>
        {/* particles behind */}
        {s.particles.filter((_, i) => i % 2 === 0).map((p, i) => <Particle key={`pb${i}`} p={p}/>)}

        {/* obstacles (logs) — drawn under hero/goblins so jumps look "above" them */}
        {s.obstacles.map(ob => (
          <Obstacle key={ob.id} x={ob.x} w={ob.w} h={ob.h} hit={ob.hit}/>
        ))}

        {/* hero */}
        <Hero x={s.hero.x} y={s.hero.y} facing={s.hero.facing}
          attacking={s.hero.attacking > 0} hurt={s.hero.hurt > 0}
          invuln={s.hero.invuln > 0}
          walkPhase={s.hero.walkPhase} jumping={s.hero.jumping}
          channel={!!s.owlCharge}
          moving={Math.abs(s.hero.vx) > 0.1}/>

        {/* goblins */}
        {s.goblins.map(g => (
          <Goblin key={g.id} x={g.x} y={g.y} flipped={g.flipped}
            hurt={g.hurt > 0} attacking={g.attacking > 0} walkPhase={g.walkPhase}/>
        ))}

        {/* slashes */}
        {s.slashes.map((sl, i) => <Slash key={`sl${i}`} x={sl.x} y={sl.y} dir={sl.dir} age={sl.age}/>)}

        {/* owl */}
        {s.owl && (() => {
          // dir = +1 for right-facing, -1 for left. Used for hover (matches
          // hero facing so the bird looks where the hero looks) and for dive
          // (matches the dive's horizontal direction so the leaf arc points
          // the right way).
          const o = s.owl;
          let dir = s.hero.facing;
          if (o.phase === 'dive') dir = (o.targetX >= o.diveStartX) ? 1 : -1;
          // diveProgress (0..1) tells the renderer to swap between the
          // attacking pose (front half) and the return-flight pose (back half).
          const T_DIVE = 380, T_BACK = 420;
          let diveProgress = 0;
          if (o.phase === 'dive') {
            diveProgress = Math.min(1, o.phaseTime / (T_DIVE + T_BACK));
          }
          return <OwlSummon x={o.x} y={o.y} scale={1.6} alpha={o.alpha ?? 1}
                            phase={o.phase} dir={dir} diveProgress={diveProgress}/>;
        })()}

        {/* foreground particles */}
        {s.particles.filter((_, i) => i % 2 === 1).map((p, i) => <Particle key={`pf${i}`} p={p}/>)}

        {/* damage numbers */}
        {s.damageNums.map(dn => (
          <DamageNumber key={`dn${dn.id}`} x={dn.x} y={dn.y} value={dn.value} age={dn.age} crit={dn.crit}/>
        ))}
      </div>

      {/* Flash overlay */}
      {s.flash > 0 && (
        <div style={{
          position: 'absolute', inset: 0,
          background: '#fff',
          opacity: s.flash,
          pointerEvents: 'none',
        }}/>
      )}

      {/* HUD */}
      <HUD hero={s.hero} kills={s.kills} score={s.score} distance={s.distance} showHints={tweaks.showHints}/>

      {/* Charging ultimate banner */}
      {s.owlCharge && (
        <div style={{
          position: 'absolute',
          top: 200, left: '50%', transform: 'translateX(-50%)',
          color: '#fff', fontSize: 32,
          textShadow: '0 0 16px #c8a8ff, 4px 4px 0 #1a1230',
          letterSpacing: 4,
          animation: 'pulse 0.5s infinite alternate',
        }}>
          ✦ 召喚 · 木木梟 ✦
        </div>
      )}

      {/* Game Over */}
      {s.gameOver && (
        <div style={{
          position: 'absolute', inset: 0,
          background: 'rgba(10,8,24,0.85)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          flexDirection: 'column', gap: 24,
          color: '#fff',
        }}>
          <div style={{ fontSize: 56, color: '#ff5050', textShadow: '4px 4px 0 #1a1230' }}>GAME OVER</div>
          <div style={{ fontSize: 18, color: '#c8a8ff' }}>擊殺 {s.kills}　·　{s.score} 分　·　{s.distance}m</div>
          <button onClick={reset} style={{
            padding: '16px 32px',
            background: '#c8a8ff', color: '#1a1230',
            border: '4px solid #4a3578',
            fontFamily: 'inherit', fontSize: 18,
            cursor: 'pointer',
            boxShadow: '4px 4px 0 #4a3578',
          }}>再 來 一 次 [R]</button>
        </div>
      )}

      <style>{`
        @keyframes pulse { from { opacity: 0.7; transform: translateX(-50%) scale(1); } to { opacity: 1; transform: translateX(-50%) scale(1.05); } }
      `}</style>
    </div>
  );
}

// ============== HUD ==============
function HUD({ hero, kills, score, distance, showHints }) {
  const hpPct = hero.hp / MAX_HP;
  const chargePct = hero.charge / MAX_CHARGE;
  const ready = hero.charge >= MAX_CHARGE;
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', color: '#fff' }}>
      {/* Top-left: portrait + HP */}
      <div style={{ position: 'absolute', left: 18, top: 18, display: 'flex', gap: 12, alignItems: 'flex-start' }}>
        <div style={{
          width: 72, height: 72,
          background: 'url(assets/hero.jpg) center/cover',
          border: '3px solid #fff',
          boxShadow: '4px 4px 0 #1a1230',
          borderRadius: 6,
        }}/>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 2 }}>
          <div style={{ fontSize: 11, letterSpacing: 2, color: '#fff', textShadow: '2px 2px 0 #1a1230' }}>HP</div>
          <div style={{
            width: 260, height: 22,
            background: '#1a1230',
            border: '3px solid #fff',
            boxShadow: '3px 3px 0 #1a1230',
            position: 'relative', overflow: 'hidden',
          }}>
            <div style={{
              width: `${hpPct * 100}%`, height: '100%',
              background: `linear-gradient(to bottom, #ff8080 0%, #c92020 50%, #8a1010 100%)`,
              transition: 'width 0.18s',
            }}/>
            <div style={{
              position: 'absolute', inset: 0,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              fontSize: 11, textShadow: '1px 1px 0 #000',
            }}>{Math.max(0, Math.round(hero.hp))} / {MAX_HP}</div>
          </div>
          <div style={{ fontSize: 11, letterSpacing: 2, marginTop: 4, color: ready ? '#ffd24a' : '#fff', textShadow: '2px 2px 0 #1a1230' }}>
            {ready ? '✦ ULTIMATE READY [Q] ✦' : 'CHARGE'}
          </div>
          <div style={{
            width: 260, height: 16,
            background: '#1a1230',
            border: '3px solid #fff',
            boxShadow: '3px 3px 0 #1a1230',
            position: 'relative', overflow: 'hidden',
          }}>
            <div style={{
              width: `${chargePct * 100}%`, height: '100%',
              background: ready
                ? `linear-gradient(90deg, #ffd24a, #c8a8ff, #ffd24a)`
                : `linear-gradient(to bottom, #c8a8ff 0%, #8868d8 50%, #4a3578 100%)`,
              backgroundSize: ready ? '200% 100%' : 'auto',
              animation: ready ? 'chargeShine 0.8s linear infinite' : 'none',
              transition: 'width 0.12s',
            }}/>
          </div>
        </div>
      </div>

      {/* Top-right: stats */}
      <div style={{
        position: 'absolute', right: 18, top: 18,
        display: 'flex', flexDirection: 'column', gap: 8,
        alignItems: 'flex-end',
      }}>
        <div style={{
          background: 'rgba(26,18,48,0.85)',
          border: '3px solid #fff',
          boxShadow: '4px 4px 0 #1a1230',
          padding: '10px 14px',
          fontSize: 13,
          letterSpacing: 2,
        }}>
          KILLS <span style={{ color: '#ffd24a' }}>{String(kills).padStart(3, '0')}</span>
        </div>
        <div style={{
          background: 'rgba(26,18,48,0.85)',
          border: '3px solid #fff',
          boxShadow: '4px 4px 0 #1a1230',
          padding: '10px 14px',
          fontSize: 13,
          letterSpacing: 2,
        }}>
          SCORE <span style={{ color: '#ffd24a' }}>{String(score).padStart(5, '0')}</span>
        </div>
        <div style={{
          background: 'rgba(26,18,48,0.85)',
          border: '3px solid #fff',
          boxShadow: '4px 4px 0 #1a1230',
          padding: '10px 14px',
          fontSize: 13,
          letterSpacing: 2,
        }}>
          DIST <span style={{ color: '#ffd24a' }}>{distance}m</span>
        </div>
      </div>

      {/* Bottom-center: control hints */}
      {showHints && (
        <div style={{
          position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)',
          display: 'flex', gap: 10,
        }}>
          {[
            { k: '← →', l: '移動' },
            { k: '↑', l: '跳躍' },
            { k: 'J', l: '揮砍' },
            { k: 'Q', l: '召喚' },
            { k: 'P', l: '暫停' },
          ].map(({ k, l }) => (
            <div key={k} style={{
              background: 'rgba(26,18,48,0.7)',
              border: '2px solid #c8a8ff',
              padding: '6px 10px',
              fontSize: 10,
              letterSpacing: 1,
              color: '#fff',
              display: 'flex', gap: 6, alignItems: 'center',
            }}>
              <span style={{ color: '#ffd24a' }}>{k}</span> {l}
            </div>
          ))}
        </div>
      )}

      <style>{`
        @keyframes chargeShine {
          0% { background-position: 0% 0%; }
          100% { background-position: 200% 0%; }
        }
      `}</style>
    </div>
  );
}

// ============== Tweaks-aware wrapper ==============
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "palette": "dusk",
  "difficulty": "normal",
  "screenShake": true,
  "showHints": true
}/*EDITMODE-END*/;

function App() {
  const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
  return (
    <>
      <Game tweaks={tweaks}/>
      <TweaksPanel title="Tweaks">
        <TweakSection label="場景"/>
        <TweakRadio label="時刻" value={tweaks.palette}
          onChange={(v) => setTweak('palette', v)}
          options={[
            { value: 'dusk', label: '黃昏' },
            { value: 'dawn', label: '清晨' },
            { value: 'night', label: '夜晚' },
          ]}/>
        <TweakSection label="遊戲性"/>
        <TweakRadio label="難度" value={tweaks.difficulty}
          onChange={(v) => setTweak('difficulty', v)}
          options={[
            { value: 'easy', label: '簡單' },
            { value: 'normal', label: '普通' },
            { value: 'hard', label: '困難' },
          ]}/>
        <TweakToggle label="畫面震動" value={tweaks.screenShake} onChange={(v) => setTweak('screenShake', v)}/>
        <TweakToggle label="操作提示" value={tweaks.showHints} onChange={(v) => setTweak('showHints', v)}/>
      </TweaksPanel>
    </>
  );
}

// Render immediately — Babel script runs after DOMContentLoaded
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
