(function () {
// Hero background game — samurai + Spring Forest parallax backdrop, running
// in a Phaser canvas behind the Hero section's text. Idle + WASD left/right
// movement, a left-click 3-hit combo attack (see ATTACK_KEYS below), a
// day/night tint driven by the site's dark/light toggle, and a breathing
// lamp light that's only visible at night (see the LAMP_LIGHT_* comment
// further down). No viewport-responsive scaling yet — deferred; see
// PortfolioShell/rawassets/HeroGame.
//
// Phaser loads as a classic UMD <script> (window.Phaser) the same way
// React/Babel do in index.html — this project has no bundler, so there's no
// import to reach for here.

// Permanent, intentional — not a dev-only flag. Delays even starting the
// Phaser.Game construction, so the rest of the page (nav, hero text, scroll,
// buttons — the actually-critical content) gets a head start on bandwidth
// and CPU instead of competing with the Phaser library + game asset
// downloads from the very first frame. The game is decorative; it can
// afford to be last.
const GAME_LOAD_DELAY_MS = 2000;

// How long the canvas takes to fade from invisible to fully visible once the
// scene is ready. 500ms read as a "pop" — the whole scene (backdrop, props,
// character) is already fully drawn the instant this starts, so revealing
// that much at once needs a slower fade to actually register as gradual
// rather than abrupt.
const CANVAS_FADE_IN_MS = 2000;

// Height, as a percentage of the container, of the bottom-edge blend (see
// the overlay div in HeroGame's return below) — a solid var(--bg) to
// transparent gradient so the ground/gravel platform bleeds into the page's
// actual background color at the section's lower edge instead of cutting
// off with a hard line. Roughly matches where the ground strip itself sits
// (GROUND_Y is much closer to the bottom than this, but the gradient needs
// enough travel to read as a gradual blend rather than a stripe).
const BOTTOM_FADE_HEIGHT_PCT = 14;

// Native frame size shared by every Samurai #3 animation sheet. Every frame
// has ~9px of transparent padding below the feet (measured from IDLE.png,
// frame 0's alpha channel) — animations like attacks need the extra headroom
// even though idle/run don't use it — so the sprite's own bottom edge isn't
// where the feet actually are; CHAR_FOOT_PAD corrects for that when placing
// the character on the ground line.
const FRAME_W = 106;
const FRAME_H = 84;
const CHAR_FOOT_PAD = 9;

// Native size of every Spring Forest background layer + the single ground
// tile sliced out of Tileset.png (tile at grid row 1, col 2 — confirmed
// seamless when repeated horizontally).
const LAYER_W = 384;
const LAYER_H = 216;
const GROUND_TILE = 16;

// ── Tuning knobs ────────────────────────────────────────────────────────
// Fixed internal design resolution. The whole scene (backdrop + character)
// renders at this size and Phaser's Scale Manager stretches the canvas to
// fill whatever the Hero section's actual box turns out to be — so the
// character-to-backdrop ratio never changes, only overall crispness does.
// Revisit once viewport-responsive layout is in scope.
const DESIGN_W = 768;
const DESIGN_H = 320;

// Backdrop layers are tiled to fill DESIGN_W x DESIGN_H. tileScale is
// derived (not hardcoded) so exactly ONE copy of the layer's height fills
// DESIGN_H — this is what stops the vertical repeat seam; don't set a
// separate vertical size/scale for the layers without keeping this relation.
const BACKDROP_SCALE = DESIGN_H / LAYER_H;

// Character size relative to the backdrop — this is the one to change for
// "make the character bigger/smaller". 1 = native 106x84 pixels.
const CHAR_SCALE = 1.62;

// Ground strip height (matches backdrop pixel density). GROUND_MARGIN_BOTTOM
// used to hold the ground line off the very bottom edge; now 0, so the
// ground sits flush against it instead — closes the empty gap ENVELOP-mode
// scaling was revealing past the ground on taller/shorter-than-design
// containers (the flat, un-drawn band below the platform).
const GROUND_H = GROUND_TILE * BACKDROP_SCALE;
const GROUND_MARGIN_BOTTOM = DESIGN_H * -0.005;
const GROUND_Y = DESIGN_H - GROUND_H - GROUND_MARGIN_BOTTOM;

// Every object that's meant to sit "on the ground" anchors off GROUND_Y —
// not an independent fraction of DESIGN_H — so retuning GROUND_MARGIN_BOTTOM
// moves the whole scene together instead of leaving some objects behind
// (this was the bug: the blacksmith/trees used their own fixed DESIGN_H
// fractions, which only looked right by coincidence at one specific
// GROUND_MARGIN_BOTTOM value and drifted out of sync from everything else
// whenever it changed).
//
// The blacksmith and the trees each need their OWN sink amount, not one
// shared value — a wide building only needs a sliver of overlap to read as
// grounded, while a tree's narrow trunk needs to sink much further before it
// reaches anything reliably solid (the bushes layer is an irregular
// silhouette; a thin trunk can land in a gap in it and still read as
// floating even where the row is mostly opaque elsewhere).
//
// PROP_SINK: ~0 — the blacksmith's foundation lands right at GROUND_Y, which
// puts it flush with the platform's own front edge (the grass-tile row
// starts at that same Y), so it reads as grounded without needing bush
// overlap at all.
const PROP_SINK = 0;
const PROP_Y = GROUND_Y + PROP_SINK;

// TREE_SINK: past GROUND_H, deliberately landing inside the ground-fill
// rectangle's territory (guaranteed solid, drawn after the trees) rather
// than relying on the bushes layer happening to be opaque at that exact x —
// the trunk visually disappears behind the platform's own front edge.
const TREE_SINK = GROUND_H + DESIGN_H * 0.02;
const TREE_Y = GROUND_Y + TREE_SINK;

// Blacksmith prop (single static frame — frame 0 of Blacksmith.png's 13,
// 176x112 each). Positioned as an offset from DESIGN_W's center, not from
// the left edge — ENVELOP scale mode crops the design canvas symmetrically
// around its center to cover the actual container, so content anchored near
// an edge can end up cropped out of view entirely depending on viewport
// aspect ratio; center-relative offsets stay visible across that range.
// Offset is intentionally small — Hero.jsx's readability scrim (a DOM layer
// painted over the whole canvas, darkest on the left) was fully smothering
// the prop further out; this keeps it left-of-center but past the scrim's
// heaviest zone.
const PROP_FRAME_W = 176;
const PROP_FRAME_H = 112;
const PROP_OFFSET_FROM_CENTER = DESIGN_W * 0.20;
const PROP_CENTER_X = DESIGN_W / 2 - PROP_OFFSET_FROM_CENTER;

// Cherry blossom trees. tree-a/tree-b are the foreground pair (Trees/Color 1,
// two different tree shapes from that sheet) at the same depth as the
// blacksmith prop. tree-c is a third, from Trees/Color 2 (the paler
// variant), placed behind the workshop — matching the reference composition
// — at a smaller offset so it just peeks out rather than being fully
// hidden. Positions are offsets from DESIGN_W's center, same rule as above.
const TREE_A_OFFSET_FROM_CENTER = -DESIGN_W * 0.34;
const TREE_B_OFFSET_FROM_CENTER = DESIGN_W * 0.17;
const TREE_C_OFFSET_FROM_CENTER = PROP_OFFSET_FROM_CENTER === 0 ? 0 : -PROP_OFFSET_FROM_CENTER * 1.15;
const TREE_A_X = DESIGN_W / 2 + TREE_A_OFFSET_FROM_CENTER;
const TREE_B_X = DESIGN_W / 2 + TREE_B_OFFSET_FROM_CENTER;
const TREE_C_X = DESIGN_W / 2 + TREE_C_OFFSET_FROM_CENTER;

// Small ground props — grass, mushroom, bush (x3), flower, and rocks (2
// small + 1 big) — sit right on the platform at GROUND_Y, same as the
// character. Every crop has an identical 16px band of transparent padding
// below its content (measured, not guessed — same class of issue as
// CHAR_FOOT_PAD) baked in from Props.png's shared cell height.
// GROUND_PROP_FOOT_PAD closes that gap.
//
// The lamp is separate — Lamp_Animated.png (12 frames, 32x48 each,
// flickering light), not the static Props.png crop it used to be. Same
// offset/scale/foot-pad as before, just now an animated sprite instead of a
// plain image (see the props loop vs. the lamp's own block in create()).
//
// Offsets from center, same rule as the trees/blacksmith above.
const GROUND_PROP_FOOT_PAD = 16;
const GROUND_PROP_FOOT_PAD_LAMP = 2;
const LAMP_FRAME_W = 32;
const LAMP_FRAME_H = 48;
const PROP_GRASS_OFFSET = DESIGN_W * 0.05;
const PROP_MUSHROOM_OFFSET = DESIGN_W * 0.03;
const PROP_FLOWER_OFFSET = DESIGN_W * 0.21;
const PROP_LAMP_OFFSET = DESIGN_W * 0.04;
const PROP_ROCK_SMALL_1_OFFSET = DESIGN_W * 0.27;
const PROP_ROCK_SMALL_2_OFFSET = DESIGN_W * -0.35;
const PROP_BUSH_1_OFFSET = DESIGN_W * 0.19;
const PROP_BUSH_2_OFFSET = DESIGN_W * 0.13;
const PROP_BUSH_3_OFFSET = DESIGN_W * -0.38;
const PROP_ROCK_BIG_OFFSET = DESIGN_W * -0.015;
const GROUND_PROP_Y = GROUND_Y;

// Character spawn — also expressed as an offset from center rather than an
// absolute fraction of DESIGN_W, same reasoning as the prop above.
const CHAR_SPAWN_OFFSET_FROM_CENTER = DESIGN_W * 0.1;
const CHAR_SPAWN_X = DESIGN_W / 2 + CHAR_SPAWN_OFFSET_FROM_CENTER;
const MOVE_SPEED = 140; // px/sec at design resolution
const CLOUD_DRIFT_SPEED = 5; // px/sec at design resolution — slow rightward drift

// Left-click combo attack — three swings chained by clicking again before
// the current one finishes; a click that lands after the third wraps back
// around to the first rather than being dropped, so holding the trigger down
// just keeps the combo cycling. A click with no attack already in progress
// always starts fresh at attack-1. Faster than run's 10fps — a swing should
// read as a quick, decisive hit, not a lingering pose.
const ATTACK_FRAME_RATE = 14;
const ATTACK_KEYS = ["attack-1", "attack-2", "attack-3"];

// Day/night tint, driven by the site's dark/light toggle (`mode` prop — same
// one Hero.jsx/Nav.jsx already take). Every sprite in the scene — including
// the lamp and the character — gets tinted, so the full day/night + lamp
// light effect is visible everywhere at once. One shared tween drives a
// 0-100 "night factor"; each tick, every registered object's color is
// interpolated between white (day — no tint) and its own night-tint target
// via Phaser's Color.Interpolate (see registerTint/applyDayNight/
// updateTintTargets below), so the whole scene crossfades in lockstep
// instead of snapping. Sky/mountains get their own tint (mountains darker,
// so the peaks read as silhouetted against the sky); everything else
// (backdrop trees/bushes layers, sakura trees, blacksmith, ground clutter,
// ground tile, lamp, character) shares one "environment" tint.
const DAY_NIGHT_TRANSITION_MS = 800;
const SKY_NIGHT_TINT = 0x445b8c;
const MOUNTAIN_NIGHT_TINT = 0x33406b;
const ENV_NIGHT_TINT = 0x4a5a85;
const CHAR_NIGHT_TINT = 0x6478ab; // lighter than ENV_NIGHT_TINT — character-specific
const LAYER_NIGHT_TINTS = {
  "layer1-sky": SKY_NIGHT_TINT,
  "layer2-mountains": MOUNTAIN_NIGHT_TINT,
  "layer3-trees": ENV_NIGHT_TINT,
  "layer4-bushes": ENV_NIGHT_TINT
};

// Lamp light — a real Phaser Light2D light (WebGL-only; guarded below via
// lightingEnabled) instead of a flat ADD-blended sprite. The earlier sprite
// version couldn't do genuine proximity brightening — it was an
// alpha-blended blob sitting in front of whatever was underneath, not real
// per-pixel lighting — so nearby registered objects (the mushroom below,
// sakura branches above, etc. — see registerTint()) never actually picked
// up partial illumination from it. Light2D does that for real: every object
// opted into the "Light2D" pipeline is lit based on its distance from every
// active light, so the same registerTint() call that opts an object into
// night tinting also opts it into being lit by this lamp. Breathing/
// day-night-scaling logic is unchanged from the old glow-sprite version —
// still driven every frame in update(), just setting `intensity` now
// instead of `alpha`. Y offset is measured from lamp-animated.png — the lit
// window sits in the upper third of the 48px frame, well above the
// sprite's bottom-anchored origin. Ambient stays pure white
// (setAmbientColor(0xffffff) below) so Light2D never dims anything on its
// own — the existing day/night tint system still fully owns global
// brightness; this only ever ADDS local warm light near the lamp.
const LAMP_STATIC_FRAME = 0;
const LAMP_LIGHT_COLOR = 0xffcf5e;
const LAMP_LIGHT_RADIUS = 500;
const LAMP_LIGHT_Y_OFFSET = -46;
const LAMP_LIGHT_INTENSITY_MIN = 2.5;
const LAMP_LIGHT_INTENSITY_MAX = 3.5;
const LAMP_BREATH_MS = 2000; // one half-cycle (min->max or max->min)

// Forge light — same mechanism as the lamp light above (a Light2D light,
// breathing intensity gated by the day/night factor), positioned at the
// blacksmith's forge instead. X/Y offsets are measured from
// blacksmith-sheet.png: the fire sits roughly 53px left of frame-center and
// 42px above the frame's bottom edge in the raw art (176x112 frames), scaled
// by BACKDROP_SCALE — but the blacksmith sprite renders with setFlipX(true),
// which mirrors the X offset (left becomes right), so FORGE_LIGHT_X_OFFSET
// is positive here even though the fire sits left of center in the source
// art. Rough eyeball measurement, not exact — expect this needs the same
// hand-tuning pass as everything else once actually visible. Faster breath
// than the lamp (short cycle) reads more like a flickering fire than a slow
// pulse.
const FORGE_LIGHT_COLOR = 0xff7a33;
const FORGE_LIGHT_RADIUS = 80;
const FORGE_LIGHT_X_OFFSET = -15;
const FORGE_LIGHT_Y_OFFSET = -23;
const FORGE_LIGHT_INTENSITY_MIN = 1.2;
const FORGE_LIGHT_INTENSITY_MAX = 4.2;
// A real flame doesn't breathe smoothly — it jitters. forgeBreathFactor
// jumps to a new random value with no easing between steps (see the
// recursive delayedCall in create(), not a tweened counter like the lamp's),
// on a randomized interval so the flicker itself doesn't fall into a
// noticeable rhythm.
const FORGE_FLICKER_MIN_MS = 260;
const FORGE_FLICKER_MAX_MS = 350;
// ─────────────────────────────────────────────────────────────────────────

class HeroScene extends window.Phaser.Scene {
  preload() {
    this.load.image("layer1-sky", "/assets/HeroGame/backdrop/layer1-sky.png");
    this.load.image("layer2-mountains", "/assets/HeroGame/backdrop/layer2-mountains.png");
    this.load.image("layer3-trees", "/assets/HeroGame/backdrop/layer3-trees.png");
    this.load.image("layer4-bushes", "/assets/HeroGame/backdrop/layer4-bushes.png");
    this.load.image("ground-tile", "/assets/HeroGame/backdrop/ground-tile.png");
    this.load.spritesheet("blacksmith-sheet", "/assets/HeroGame/backdrop/blacksmith-sheet.png", {
      frameWidth: PROP_FRAME_W,
      frameHeight: PROP_FRAME_H
    });
    this.load.image("tree-a", "/assets/HeroGame/backdrop/tree-a.png");
    this.load.image("tree-b", "/assets/HeroGame/backdrop/tree-b.png");
    this.load.image("tree-c", "/assets/HeroGame/backdrop/tree-c.png");
    ["prop-grass", "prop-mushroom", "prop-flower", "prop-rock-small", "prop-bush", "prop-rock-big"].forEach(key => {
      this.load.image(key, `/assets/HeroGame/backdrop/${key}.png`);
    });
    this.load.spritesheet("lamp-sheet", "/assets/HeroGame/backdrop/lamp-animated.png", {
      frameWidth: LAMP_FRAME_W,
      frameHeight: LAMP_FRAME_H
    });
    this.load.spritesheet("samurai-idle", "/assets/HeroGame/samurai/idle.png", {
      frameWidth: FRAME_W,
      frameHeight: FRAME_H
    });
    this.load.spritesheet("samurai-run", "/assets/HeroGame/samurai/run.png", {
      frameWidth: FRAME_W,
      frameHeight: FRAME_H
    });
    ATTACK_KEYS.forEach(key => {
      this.load.spritesheet(`samurai-${key}`, `/assets/HeroGame/samurai/${key}.png`, {
        frameWidth: FRAME_W,
        frameHeight: FRAME_H
      });
    });
  }
  create() {
    // Day/night tint bookkeeping — see the constants comment up top.
    // Populated by registerTint() as each environment object below is
    // created; consumed by applyDayNight()/updateTintTargets() further down.
    this.tintTargets = [];
    this.dayColor = window.Phaser.Display.Color.ValueToColor(0xffffff);
    this.nightFactor = 0;

    // Light2D is WebGL-only — Phaser.AUTO already resolves to WebGL on
    // virtually every real browser, but this guards the rare Canvas
    // fallback so registerTint()/the lamp light below just skip lighting
    // entirely (falling back to tint-only) instead of erroring against a
    // pipeline that doesn't exist there. Ambient stays pure white — see the
    // LAMP_LIGHT_* comment up top for why.
    this.lightingEnabled = this.sys.game.renderer.type === window.Phaser.WEBGL;
    if (this.lightingEnabled) this.lights.enable().setAmbientColor(0xffffff);

    // Bushes draws in this same first batch (behind the blacksmith/trees
    // below), not after them — sits behind everything in front of it
    // instead of overlapping the workshop/trees' lower halves.
    ["layer1-sky", "layer2-mountains", "layer3-trees", "layer4-bushes"].forEach(key => {
      const layer = this.add.tileSprite(0, 0, DESIGN_W, DESIGN_H, key).setOrigin(0, 0).setTileScale(BACKDROP_SCALE);
      if (key === "layer1-sky") this.skyLayer = layer;
      this.registerTint(layer, LAYER_NIGHT_TINTS[key]);
    });

    // Wrapped — these are decorative and load from files that can be
    // missing/stale on a dev server that hasn't picked up newly-added assets
    // yet; a failure here shouldn't take out the ground/character/input
    // setup that follows.
    try {
      // Drawn first so it renders behind the blacksmith building.
      this.registerTint(this.add.image(TREE_C_X, TREE_Y, "tree-c").setOrigin(0.5, 1).setScale(BACKDROP_SCALE), ENV_NIGHT_TINT);
      this.anims.create({
        key: "blacksmith-work",
        frames: this.anims.generateFrameNumbers("blacksmith-sheet", {}),
        frameRate: 8,
        repeat: -1
      });
      this.registerTint(this.add.sprite(PROP_CENTER_X, PROP_Y, "blacksmith-sheet").setOrigin(0.5, 1).setScale(BACKDROP_SCALE).setFlipX(true).play("blacksmith-work"), ENV_NIGHT_TINT);

      // Forge light — see the FORGE_LIGHT_* comment up top.
      if (this.lightingEnabled) {
        this.forgeLight = this.lights.addLight(PROP_CENTER_X + FORGE_LIGHT_X_OFFSET, PROP_Y + FORGE_LIGHT_Y_OFFSET, FORGE_LIGHT_RADIUS, FORGE_LIGHT_COLOR, 0);
        this.forgeBreathFactor = 0;
        // Random jitter, interpolated — pure instant snaps between random
        // values read as strobing rather than a flame, so each new random
        // target is reached via a short tween (duration itself randomized
        // in the same FORGE_FLICKER_MIN_MS-MAX_MS range) instead of a pop,
        // then immediately picks another random target on completion.
        const jitterForge = () => {
          this.tweens.add({
            targets: this,
            forgeBreathFactor: Math.random(),
            duration: FORGE_FLICKER_MIN_MS + Math.random() * (FORGE_FLICKER_MAX_MS - FORGE_FLICKER_MIN_MS),
            ease: "Sine.easeInOut",
            onComplete: jitterForge
          });
        };
        jitterForge();
      }
      this.registerTint(this.add.image(TREE_A_X, TREE_Y, "tree-b").setOrigin(0.5, 1).setScale(BACKDROP_SCALE).setFlipX(true), ENV_NIGHT_TINT);
      this.registerTint(this.add.image(TREE_B_X, TREE_Y, "tree-a").setOrigin(0.5, 1).setScale(BACKDROP_SCALE), ENV_NIGHT_TINT);
    } catch (e) {
      console.warn("HeroGame: blacksmith/tree props failed to load, continuing without them", e);
    }

    // Small ground props, scattered across the platform — same try/catch
    // resilience as the blacksmith/trees above. prop-grass is flipped for a
    // little variety against its own silhouette. Lamp goes first (draws
    // behind the props added after it).
    try {
      // Lamp — static now (LAMP_STATIC_FRAME), not the old frame-based
      // flicker; the breathing light below replaces that animation's job.
      // Position/scale/foot-pad unchanged. Tinted/lit like everything else
      // now (see registerTint) — it sits right next to its own light, so it
      // reads as visibly glowing warmer in sync with the breathing.
      const lampX = DESIGN_W / 2 + PROP_LAMP_OFFSET;
      const lampY = GROUND_PROP_Y + GROUND_PROP_FOOT_PAD_LAMP * BACKDROP_SCALE;
      this.registerTint(this.add.sprite(lampX, lampY, "lamp-sheet", LAMP_STATIC_FRAME).setOrigin(0.5, 1).setScale(BACKDROP_SCALE), ENV_NIGHT_TINT);

      // Light — see the LAMP_LIGHT_* comment up top.
      if (this.lightingEnabled) {
        this.lampLight = this.lights.addLight(lampX, lampY + LAMP_LIGHT_Y_OFFSET, LAMP_LIGHT_RADIUS, LAMP_LIGHT_COLOR, 0);
        this.lampBreathFactor = 0;
        this.tweens.addCounter({
          from: 0,
          to: 1,
          duration: LAMP_BREATH_MS,
          yoyo: true,
          repeat: -1,
          ease: "Sine.easeInOut",
          onUpdate: tween => {
            this.lampBreathFactor = tween.getValue();
          }
        });
      }
      [["prop-grass", PROP_GRASS_OFFSET, GROUND_PROP_FOOT_PAD, true], ["prop-rock-big", PROP_ROCK_BIG_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-rock-small", PROP_ROCK_SMALL_2_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-flower", PROP_FLOWER_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-bush", PROP_BUSH_1_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-bush", PROP_BUSH_2_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-bush", PROP_BUSH_3_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-mushroom", PROP_MUSHROOM_OFFSET, GROUND_PROP_FOOT_PAD], ["prop-rock-small", PROP_ROCK_SMALL_1_OFFSET, GROUND_PROP_FOOT_PAD]].forEach(([key, offset, footPad, flip]) => {
        this.registerTint(this.add.image(DESIGN_W / 2 + offset, GROUND_PROP_Y + footPad * BACKDROP_SCALE, key).setOrigin(0.5, 1).setScale(BACKDROP_SCALE).setFlipX(!!flip), ENV_NIGHT_TINT);
      });
    } catch (e) {
      console.warn("HeroGame: ground props failed to load, continuing without them", e);
    }
    this.registerTint(this.add.tileSprite(0, GROUND_Y, DESIGN_W, GROUND_H, "ground-tile").setOrigin(0, 0).setTileScale(BACKDROP_SCALE), ENV_NIGHT_TINT);
    this.anims.create({
      key: "idle",
      frames: this.anims.generateFrameNumbers("samurai-idle", {}),
      frameRate: 8,
      repeat: -1
    });
    this.anims.create({
      key: "run",
      frames: this.anims.generateFrameNumbers("samurai-run", {}),
      frameRate: 10,
      repeat: -1
    });
    ATTACK_KEYS.forEach(key => {
      this.anims.create({
        key,
        frames: this.anims.generateFrameNumbers(`samurai-${key}`, {}),
        frameRate: ATTACK_FRAME_RATE,
        repeat: 0
      });
    });

    // Tinted/lit like the environment now (see registerTint) — brightens as
    // it walks near the lamp, same as everything else registered.
    this.character = this.registerTint(this.add.sprite(CHAR_SPAWN_X, GROUND_Y + CHAR_FOOT_PAD * CHAR_SCALE, "samurai-idle").setScale(CHAR_SCALE).setOrigin(0.5, 1).play("idle"), CHAR_NIGHT_TINT);
    const halfWidth = FRAME_W * CHAR_SCALE / 2;
    this.minX = halfWidth;
    this.maxX = DESIGN_W - halfWidth;
    this.keys = this.input.keyboard.addKeys("W,A,S,D");

    // Day/night — apply the initial mode instantly (no crossfade on first
    // load, only on a live toggle — see DAY_NIGHT_TRANSITION_MS up top),
    // then expose setDayNight so React can push toggle changes into this
    // already-running scene without reconstructing the whole game (which
    // would reset the character's position and replay the canvas fade-in).
    // Deliberately placed AFTER every registerTint() call above (including
    // the character's, just above) — this snap only reaches objects already
    // in tintTargets at the moment it runs, so running it any earlier left
    // whatever was created after it (the character) stuck unlit until the
    // next live toggle re-ran updateTintTargets and picked it up for the
    // first time.
    this.applyDayNight(this.game.initialMode, false);
    this.game.setDayNight = mode => this.applyDayNight(mode, true);

    // Left-click combo attack — native DOM listener on the canvas, not
    // Phaser's own mouse manager (which stays disabled below, input.mouse:
    // false — re-enabling it previously captured wheel-scroll over the
    // canvas and blocked page scroll). Same pattern as the contextmenu
    // listener just below. this.attacking gates update()'s WASD handling so
    // movement doesn't fight the swing; comboStep/comboQueued implement the
    // chain — see ATTACK_KEYS' own comment up top for the wrap-around rule.
    this.attacking = false;
    this.comboStep = 0;
    this.comboQueued = false;
    this.game.canvas.addEventListener("click", () => {
      if (!this.attacking) {
        this.attacking = true;
        this.comboStep = 0;
        this.character.play(ATTACK_KEYS[0]);
      } else {
        this.comboQueued = true;
      }
    });
    this.character.on("animationcomplete", () => {
      if (!this.attacking) return;
      if (this.comboQueued) {
        this.comboStep = (this.comboStep + 1) % ATTACK_KEYS.length;
        this.comboQueued = false;
        this.character.play(ATTACK_KEYS[this.comboStep]);
      } else {
        this.attacking = false;
        this.comboStep = 0;
      }
    });

    // Casual right-click "save image" deterrent on the game canvas only —
    // doesn't touch the rest of the page, and doesn't stop devtools-based
    // extraction (see conversation), just the one-click native save.
    this.game.canvas.addEventListener("contextmenu", e => e.preventDefault());

    // Fade the canvas in now that everything's actually assembled, instead
    // of it popping in fully-formed the instant assets finish loading — the
    // opposite (starting hidden, no CSS transition set at all now) is set
    // where the canvas is created, below.
    //
    // Web Animations API, not a CSS transition — two attempts at getting a
    // transition to fire (longer duration, double rAF) both still popped,
    // which points at the CSS-transition mechanism itself never engaging
    // (it only animates from a value the browser already painted a frame
    // of; if that never happened, no duration fixes it). .animate() plays
    // an explicit 0->1 keyframe regardless of prior paint state, so it
    // can't fall into that failure mode.
    this.game.canvas.animate([{
      opacity: 0
    }, {
      opacity: 1
    }], {
      duration: CANVAS_FADE_IN_MS,
      easing: "ease",
      fill: "forwards"
    });

    // Lets Hero.jsx's own overlays (the readability scrim) fade in at the
    // same moment instead of being visible before there's anything to
    // read them against — see the onReady prop on HeroGame below.
    if (typeof this.game.onHeroGameReady === "function") this.game.onHeroGameReady();
  }
  update(_time, delta) {
    const dt = delta / 1000;

    // Decreasing tilePositionX (not increasing) is what reads as the
    // texture drifting right — tilePosition is the sampling offset into the
    // source image, so moving it left is what makes the visible pattern
    // appear to slide right.
    if (this.skyLayer) this.skyLayer.tilePositionX -= CLOUD_DRIFT_SPEED * dt;

    // Lamp light — breathing intensity scaled by the current day/night
    // factor (nightFactor: 0 day - 100 night), so it's fully off during the
    // day and fades in smoothly alongside the rest of the night crossfade
    // rather than an abrupt on/off switch. Runs every frame regardless of
    // this.attacking below — the light shouldn't stop breathing just
    // because the character is mid-swing.
    if (this.lampLight) {
      const breathIntensity = LAMP_LIGHT_INTENSITY_MIN + (LAMP_LIGHT_INTENSITY_MAX - LAMP_LIGHT_INTENSITY_MIN) * this.lampBreathFactor;
      this.lampLight.intensity = breathIntensity * (this.nightFactor / 100);
    }

    // Forge light — same day/night-scaled breathing as the lamp above.
    if (this.forgeLight) {
      const forgeIntensity = FORGE_LIGHT_INTENSITY_MIN + (FORGE_LIGHT_INTENSITY_MAX - FORGE_LIGHT_INTENSITY_MIN) * this.forgeBreathFactor;
      this.forgeLight.intensity = forgeIntensity * (this.nightFactor / 100);
    }

    // The combo owns the sprite's animation/facing until it resolves (see
    // the click listener in create()) — skip WASD handling entirely rather
    // than letting a run/idle switch interrupt a swing mid-frame.
    if (this.attacking) return;
    const {
      A,
      D
    } = this.keys;
    const movingLeft = A.isDown;
    const movingRight = D.isDown;
    if (movingLeft === movingRight) {
      if (this.character.anims.currentAnim?.key !== "idle") this.character.play("idle");
    } else if (movingLeft) {
      this.character.x = Math.max(this.minX, this.character.x - MOVE_SPEED * dt);
      this.character.setFlipX(true);
      if (this.character.anims.currentAnim?.key !== "run") this.character.play("run");
    } else {
      this.character.x = Math.min(this.maxX, this.character.x + MOVE_SPEED * dt);
      this.character.setFlipX(false);
      if (this.character.anims.currentAnim?.key !== "run") this.character.play("run");
    }
  }

  // Registers a sprite/tileSprite/image for day/night tinting AND (when
  // lightingEnabled) opts it into the Light2D pipeline, so it's also a
  // valid target for the lamp light's proximity brightening — see the
  // LAMP_LIGHT_* comment up top for why these two are bundled into one
  // call. Called at creation time for every sprite in the scene.
  registerTint(obj, nightColor) {
    this.tintTargets.push({
      obj,
      night: window.Phaser.Display.Color.ValueToColor(nightColor)
    });
    if (this.lightingEnabled) obj.setPipeline("Light2D");
    return obj;
  }

  // mode: "dark" | anything else (treated as day), same convention as
  // Hero.jsx's own `mode === "dark"` checks. animate: false snaps straight
  // to the target (initial load); true crossfades over
  // DAY_NIGHT_TRANSITION_MS (a live toggle) via a counter tween — tint is a
  // packed color, not a plain number, so it can't be tweened directly;
  // Color.Interpolate is the standard way to animate one.
  applyDayNight(mode, animate) {
    const target = mode === "dark" ? 100 : 0;
    if (this.dayNightTween) this.dayNightTween.stop();
    if (!animate) {
      this.nightFactor = target;
      this.updateTintTargets(target);
      return;
    }
    this.dayNightTween = this.tweens.addCounter({
      from: this.nightFactor,
      to: target,
      duration: DAY_NIGHT_TRANSITION_MS,
      onUpdate: tween => {
        this.nightFactor = tween.getValue();
        this.updateTintTargets(this.nightFactor);
      }
    });
  }

  // t: 0 (day) - 100 (night). Applied to every registered object each call —
  // cheap enough at this object count to just recompute all of them rather
  // than tracking which ones changed.
  updateTintTargets(t) {
    const {
      Color
    } = window.Phaser.Display;
    this.tintTargets.forEach(({
      obj,
      night
    }) => {
      const c = Color.Interpolate.ColorWithColor(this.dayColor, night, 100, t);
      obj.setTint(Color.GetColor(c.r, c.g, c.b));
    });
  }
}
function HeroGame({
  mode,
  onReady
}) {
  const containerRef = React.useRef(null);
  // gameRef lets the mode-watching effect below reach the live game
  // instance from outside the mount effect's own closure. modeRef exists
  // because the mount effect's setTimeout (GAME_LOAD_DELAY_MS) runs well
  // after this render — it needs whatever mode is current AT THAT MOMENT,
  // not whatever mode was current when the effect was first scheduled.
  const gameRef = React.useRef(null);
  const modeRef = React.useRef(mode);
  modeRef.current = mode;
  React.useEffect(() => {
    if (!containerRef.current) return;
    let game = null;

    // See GAME_LOAD_DELAY_MS at the top of this file — permanent, not a
    // dev-only flag. The container sits empty/transparent for that long
    // while the rest of the page (nav, hero text, scroll, buttons) loads
    // and is fully live around it.
    const timer = setTimeout(() => {
      if (!window.Phaser || !containerRef.current) return;
      game = new window.Phaser.Game({
        type: window.Phaser.AUTO,
        parent: containerRef.current,
        width: DESIGN_W,
        height: DESIGN_H,
        transparent: true,
        pixelArt: true,
        // Only keyboard is used (WASD) — leaving Phaser's mouse/touch
        // managers enabled was capturing wheel scroll over the canvas and
        // blocking the page's own scroll, since the canvas covers the whole
        // Hero section.
        input: {
          keyboard: true,
          mouse: false,
          touch: false
        },
        scale: {
          // ENVELOP (cover-style crop, no letterbox bars) rather than FIT —
          // this is meant to read as a full-bleed background behind the
          // Hero text, so filling the section completely matters more here
          // than showing every pixel of the backdrop layers.
          mode: window.Phaser.Scale.ENVELOP,
          autoCenter: window.Phaser.Scale.CENTER_BOTH
        },
        scene: HeroScene
      });
      // Read by HeroScene.create() to apply the correct day/night state on
      // first load, without a crossfade (see applyDayNight's animate
      // param) — only a live toggle after that should animate.
      game.initialMode = modeRef.current;
      gameRef.current = game;
      // Read by HeroScene.create() at the point it's actually done — lets
      // Hero.jsx's overlays fade in at the same moment instead of before
      // there's anything to read them against. Also broadcast as a window
      // event (not just this local callback) so other components with no
      // React relationship to Hero.jsx — e.g. BottomDock.jsx's dock
      // placement — can react to the backdrop coming online without
      // polling the canvas for it every frame.
      game.onHeroGameReady = () => {
        window.dispatchEvent(new CustomEvent("ft-hero-game-loaded", {
          detail: {
            loaded: true
          }
        }));
        onReady();
      };
      // Starts hidden — HeroScene.create() fades it in (via .animate(), not
      // a CSS transition — see the comment down there) once the scene is
      // actually assembled, so loading resolves into a soft reveal instead
      // of a pop-in.
      game.canvas.style.opacity = "0";
    }, GAME_LOAD_DELAY_MS);

    // Pauses the game's own render/update loop entirely (not just the
    // scene) once the section scrolls out of view — a Phaser canvas nobody
    // can see was still rendering animations and running update() every
    // frame for nothing. game.loop.sleep()/wake() stop and restart the
    // requestAnimationFrame loop itself, so this is a real CPU/GPU saving,
    // not a cosmetic one — and it's instant to resume (no reload, textures
    // stay resident), unlike destroying the game entirely would be.
    const observer = new IntersectionObserver(([entry]) => {
      if (!game) return;
      if (entry.isIntersecting) {
        game.loop.wake();
        game.input.keyboard.enabled = true;
      } else {
        game.loop.sleep();
        // Sleeping the loop above only stops render/update — Phaser's
        // KeyboardManager keeps its native window keydown/keyup listener
        // (and WASD's preventDefault capture) running independent of that,
        // so it kept intercepting W/A/S/D typed into the contact form
        // below while this component was merely off-screen, not unmounted.
        game.input.keyboard.enabled = false;
      }
    }, {
      threshold: 0
    });
    observer.observe(containerRef.current);
    return () => {
      clearTimeout(timer);
      observer.disconnect();
      if (game) game.destroy(true);
      gameRef.current = null;
      // Mirrors the "loaded" broadcast above — fires unconditionally (even
      // if the game never got past GAME_LOAD_DELAY_MS) so a listener that
      // only ever saw "loaded" stays correct across a mid-load unmount, e.g.
      // Hero.jsx dropping this component when the viewport crosses into the
      // narrow/mobile breakpoint.
      window.dispatchEvent(new CustomEvent("ft-hero-game-loaded", {
        detail: {
          loaded: false
        }
      }));
    };
  }, []);

  // Pushes a live toggle into the already-running scene — see
  // game.setDayNight in HeroScene.create(). A no-op if the game hasn't
  // finished loading yet (still inside GAME_LOAD_DELAY_MS, or the scene
  // hasn't reached create()) — the correct initial mode is picked up at
  // that point instead, via game.initialMode above.
  React.useEffect(() => {
    gameRef.current?.setDayNight?.(mode);
  }, [mode]);
  return (
    /*#__PURE__*/
    // top is pulled up by var(--nav-h) instead of a plain inset: 0 — Nav's
    // <header> is position: sticky with an explicit height, so it reserves
    // real space in normal flow and Hero's own section starts right below
    // it, not behind it. Without this offset the canvas would only ever
    // reach the section's own top edge, leaving nothing behind Nav's
    // frosted band for its backdrop-filter to actually blur (see Nav.jsx's
    // frostVisible/gameLoaded gate). Hero's own text-scrim panel is NOT
    // extended the same way — it deliberately stays clear of Nav's bar (see
    // PANEL_TOP_OFFSET's comment in Hero.jsx) — only the game art spills
    // upward. The section itself has no overflow: hidden, so this is free
    // to render past its top edge.
    //
    // Split into two children (this wrapper + containerRef below) instead
    // of one so the bottom-fade overlay further down can paint on top of
    // the canvas without fighting Phaser's own DOM management of it (Phaser
    // appends the <canvas> into containerRef itself, so that ref needs to
    // stay on a dedicated child, not this box).
    React.createElement("div", {
      "aria-hidden": "true",
      style: {
        position: "absolute",
        top: "calc(-1 * var(--nav-h))",
        left: 0,
        right: 0,
        bottom: 0,
        overflow: "hidden",
        pointerEvents: "auto"
      }
    }, /*#__PURE__*/React.createElement("div", {
      ref: containerRef,
      style: {
        position: "absolute",
        inset: 0
      }
    }), /*#__PURE__*/React.createElement("div", {
      "aria-hidden": "true",
      style: {
        position: "absolute",
        left: 0,
        right: 0,
        bottom: 0,
        height: `${BOTTOM_FADE_HEIGHT_PCT}%`,
        background: "linear-gradient(to top, var(--bg) 0%, transparent 100%)",
        pointerEvents: "none"
      }
    }))
  );
}
window.HeroGame = HeroGame;
})();
