/* ============================================
   VISIBLEMILES — Actor Styles
   
   TERMINOLOGY:
   - Character = Player (unique, single instance)
   - Actor = Everything else (NPCs, enemies, guards, etc.)
   - Both use .actor base class; character adds .character
   
   STACKING CONTEXT:
   Each .actor creates its own stacking context via isolation: isolate.
   Child z-indexes are LOCAL to that actor:
   
     0: .shadow (ground shadow)
     1: .sprite (character image)  
     2: .hp-bar (health bar) - unified for all actors
     3: .level-badge (level indicator)
   
   Actor-to-actor ordering: Y-based depth via --actor-depth custom property.
   Higher Y = higher z-index = renders in front (top-down depth sorting).
   
   TINTING SYSTEM:
   data-tint attribute sets --actor-tint custom property via CSS.
   
   Role tints (NPCs/enemies):
     guard, medic, civilian, alpha, boss, passive, engaged, worker
   
   Skin tones (players — brightness+saturate+hue-rotate combos):
     sandstone, sienna, clay, umber, peat
     (alabaster = base art, no filter needed)
   
   No data-tint = base sprite color
   ============================================ */

/* ---------- Base Actor ---------- */
.actor {
  position: absolute;
  width: var(--tile-size);
  height: calc(var(--tile-size) * 4 / 3); /* 32px for 24px tiles */
  z-index: var(--actor-depth, 15);
  /* Create local stacking context - child z-indexes don't leak globally */
  isolation: isolate;
  /* Containment for performance - isolate layout/paint calculations */
  contain: layout style;
  /* PERF: No permanent GPU layer on .actor. Position uses a 2D translate()
     (below), so an idle actor holds no layer; movement auto-promotes via each
     type's transition: transform (enemies, NPCs, network players, and guards +
     workers via .npc) and demotes when it ends. Player has will-change via
     #player. Crates have their own backface-visibility: hidden.
     Removed: backface-visibility: hidden, transform-style: preserve-3d
     (were creating ~200 permanent GPU layers for mostly-idle actors) */
  
  /* Position via CSS custom properties - JS sets these, CSS handles transform
     This keeps animation on compositor thread for smooth 60fps */
  --actor-x: 0px;
  --actor-y: 0px;
  transform: translate(var(--actor-x), var(--actor-y));
  
  /* Spawn fade-in transition - smooth entry for all actors */
  opacity: 1;
  transition: opacity var(--fade-quick) ease-out;
}

/* Viewport-based rendering removal — eliminates ALL rendering cost for off-screen elements.
 * Applied/removed per frame by updateOffscreenAnimations() in render.js.
 *
 * content-visibility:hidden tells Chrome to skip the entire rendering pipeline
 * (style, layout, paint, composite) for the element's subtree. The element itself
 * keeps its position (transform still applies) and stays in the DOM for JS access.
 * This is critical because #actor-layer can contain 100+ elements — without this,
 * Chrome processes all of them every frame even when off-screen.
 *
 * animation:none is belt-and-suspenders: content-visibility:hidden skips rendering
 * but animations can still tick in the background. Explicit none stops them.
 * (Note: a paused animation is ALSO dormant — measured session 511, +0 per-frame
 * layout/recalc — so the old "paused still costs per-frame bookkeeping, Chromium
 * bug 40728212" rationale was disproven; none is kept here as the explicit stop.)
 *
 * The descendant wildcard covers ALL children (actor sprites, shadows, nameplate
 * quest icons, etc.) regardless of nesting depth or element type. */
.offscreen {
  content-visibility: hidden;
  animation: none !important;
  transition: none !important;
}
.offscreen *,
.offscreen::before,
.offscreen::after {
  animation: none !important;
  transition: none !important;
}

/* Video settings: "Animation Quality: Off" — disables ALL game-world actor animations.
 * Applied to #game by the Video settings in ui.js. Scoped to game-world elements only;
 * player character (#player), combat feedback, UI, and weather are excluded.
 * Note: updateOffscreenAnimations() still runs in this mode because .offscreen provides
 * content-visibility:hidden (rendering pipeline removal) which is independent of animations. */
.video-animations-off .actor:not(#player),
.video-animations-off .actor:not(#player) > .sprite,
.video-animations-off .actor:not(#player) > .shadow,
.video-animations-off .actor:not(#player) > .sprite-hair,
.video-animations-off .actor:not(#player) > .sprite-wrap,
.video-animations-off .vehicle,
.video-animations-off .vehicle > .sprite,
.video-animations-off .vehicle > .shadow,
.video-animations-off .train-unit,
.video-animations-off .train-unit > .sprite,
.video-animations-off .train-unit > .shadow,
.video-animations-off .ship,
.video-animations-off .ship > .sprite,
.video-animations-off .ship > .shadow {
  animation: none !important;
}

/* Video settings: "Animation Quality: Low" — disables decorative motion (walk-bob,
 * shadow-bounce) on non-player actors while keeping essential sprite sheet cycles.
 * Characters still visually walk but without the bounce/bob polish.
 * Reduces GPU compositor work: ~2 fewer animation timelines per walking actor.
 *
 * :not(#player) gives ID-level specificity (1,x,0) which naturally overrides the
 * class-only compound animation rules (0,x,0). No !important needed — and critically,
 * must NOT use !important or it would override .offscreen * { animation: none !important }
 * (specificity 0,1,0) and re-enable animations on culled offscreen actors.
 *
 * State exclusions (:not(.jumping) etc.) match the original walk/run selectors so
 * jump, dash, knockback, and sit animations are not overridden.
 *
 * Uses `animation` shorthand (not individual longhands) so all animation sub-properties
 * are reset to single-layer values. The original compound rules use two-layer shorthands;
 * longhand overrides would leave stale two-layer values on undeclared sub-properties
 * (e.g. animation-timing-function: steps(5), ease-in-out) — the second entry is currently
 * truncated per spec, but would silently misapply if the base rule layers were reordered. */
.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite,
.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite-hair,
.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-head,
.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-chest,
.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-weapon {
  animation: sheet-walk-cycle 0.4s steps(5) infinite;
}

.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite,
.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite-hair,
.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-head,
.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-chest,
.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-weapon {
  animation: sheet-run-cycle 0.24s steps(5) infinite;
}

.video-animations-low .actor:not(#player).moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow {
  animation: sheet-walk-cycle 0.4s steps(5) infinite;
}

.video-animations-low .actor:not(#player).moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow {
  animation: sheet-run-cycle 0.24s steps(5) infinite;
}

/* Spawning state - start invisible, fade in when class is removed */
.actor.spawning {
  opacity: 0;
}

/* Despawning state - fade out */
.actor.despawning {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fade-quick) ease-out !important;
}

/* Worker despawning — keeps transform transition so in-progress movement
   continues while fading out (prevents position snap from interrupted transitions).
   Uses --fade-standard (300ms) for a visible exit. */
.worker.despawning {
  opacity: 0;
  pointer-events: none;
  transition: transform var(--actor-move-speed) linear, opacity var(--fade-standard) ease-out !important;
}

/* ============================================
   WORKER COMBAT STATES
   ============================================ */

/* .worker.in-combat removed — Phase 0 combat overhaul (sprite art, not CSS filters) */

/* ============================================
   CARGO WORKER VARIANTS
   Styling for workers in cargo scenarios
   ============================================ */

/* Base cargo worker (no special styling, inherits from .worker) */
.worker.cargo-worker {
  /* Placeholder for future cargo-worker-specific styles */
}

/* Train cargo workers appear UNDER train cars (z-index 11)
   This creates the visual effect of workers working beside/under the train */
.worker.train-cargo-worker,
.worker.cargo-worker--under-vehicle {
  z-index: 10;
}

/* Sprite element - holds character image */
.actor > .sprite {
  position: absolute;
  inset: 0;
  z-index: 1; /* Above shadow (0) */
  /* Sprite rendering */
  background-image: var(--sprite-idle);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  /* Ensure pixel art scales sharply */
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  image-rendering: crisp-edges;
  /* Scale from feet so bob keeps feet planted */
  transform-origin: center bottom;
  /* Tint filter — --actor-tint is empty by default, set via data-tint attribute.
     Player/network-player sprites have outlines baked into art.
     NPC/enemy sprites still use CSS drop-shadow outlines (see below). */
  filter: var(--actor-tint);
  /* PERF: No permanent GPU layer on sprites — walk-bob animation auto-promotes
     when .moving class is active. Idle sprites don't need their own layer.
     Removed: backface-visibility: hidden (was creating ~200 permanent sprite layers) */
  /* Smooth return from leap/dash animations */
  transition: transform 0.1s ease-out;
  /* Allow clicks to pass through to parent .actor element */
  pointer-events: none;
  /* PERF: Containment to isolate paint/layout calculations */
  contain: strict;
}

/* ============================================
   TINTING SYSTEM
   Uses data-tint attribute to set --actor-tint custom property.
   Avoids repeating full filter chain for each tint variation.
   ============================================ */

/* Base: no tint (empty custom property) */
.actor { --actor-tint: ; }

/* Role tints - permanent, set at creation */
.actor[data-tint="guard"]    { --actor-tint: hue-rotate(180deg); }
.actor[data-tint="medic"]    { --actor-tint: hue-rotate(320deg); }
.actor[data-tint="civilian"] { --actor-tint: hue-rotate(35deg) saturate(0.9); }
.actor[data-tint="alpha"]    { --actor-tint: hue-rotate(40deg) saturate(1.3); }
.actor[data-tint="boss"]     { --actor-tint: hue-rotate(280deg) saturate(1.3); }

/* State tints — dynamic, change based on combat state */
.actor[data-tint="passive"]  { --actor-tint: hue-rotate(60deg); }
.actor[data-tint="engaged"]  { --actor-tint: hue-rotate(-30deg) saturate(1.5); }

/* Skin tones use exact OKLCH palette colors baked into SVG fills (sprites.js).
   data-tint is no longer used for skin — see getColoredBodySheetUri(). */

/* NPC/enemy sprites use the old skeleton without baked outlines — CSS drop-shadow needed */
.npc > .sprite,
.enemy > .sprite {
  filter:
    drop-shadow(1px 0 0 var(--sprite-outline))
    drop-shadow(-1px 0 0 var(--sprite-outline))
    drop-shadow(0 1px 0 var(--sprite-outline))
    drop-shadow(0 -1px 0 var(--sprite-outline))
    var(--actor-tint);
}

/* Combat facing — horizontal flip via CSS individual scale property (NOT transform).
   Uses `scale` to avoid conflict with walk-bob animation (which owns `transform`)
   and the `transition: transform 0.1s` on .sprite. Three values = 3D (X, Y, Z). */
.face-left > .sprite,
.face-left > .sprite-hair {
  scale: -1 1 1;
}

/* Selection glow color - set per actor type */
.actor            { --selection-glow: var(--danger); }
.npc              { --selection-glow: var(--success); }
.network-player   { --selection-glow: var(--nameplate-player); }
#player           { --selection-glow: var(--text-primary); }

/* Selection ring — standalone element in #actor-layer at z-index 1.
   Always behind every actor (100+). Positioned via --ring-x / --ring-y
   CSS custom properties set by selectRing.js. Replaces the old ::before
   pseudo-element which was trapped inside the actor's stacking context. */
#select-ring {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1;
  width: calc(var(--tile-size) * 2.1);
  height: calc(var(--tile-size) * 0.875);
  --ring-x: 0px;
  --ring-y: 0px;
  transform: translate3d(
    calc(var(--ring-x) - 50%),
    calc(var(--ring-y) - 60%),
    0
  );
  border: 2px solid var(--selection-glow, var(--danger));
  background: oklch(from var(--selection-glow, var(--danger)) l c h / 0.24);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  contain: layout style;
}

#select-ring.active {
  opacity: 0.8;
}

/* Pulse animation only when actively in combat AND ring is visible.
   .active gate prevents the keyframe opacity (0.5–1.0) from overriding
   the base opacity:0, which would leak visibility if .combat-active
   were ever toggled without .active. */
#select-ring.active.combat-active {
  animation: select-ring-pulse 1.5s ease-in-out infinite;
}

@keyframes select-ring-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}

/* Cast telegraph fill — expanding radial fill inside the selection ring.
   Uses scale3d for GPU-composited animation (no clip-path animation).
   --cast-duration is mirrored from the target element by processCombatTick(). */
#select-ring.active.casting::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  pointer-events: none;
  background: oklch(from var(--selection-glow, var(--danger)) l c h / 0.35);
  transform-origin: center center;
  animation: cast-fill-expand var(--cast-duration, 2s) linear forwards;
}

@keyframes cast-fill-expand {
  0%   { transform: scale3d(0, 0, 1); }
  100% { transform: scale3d(1, 1, 1); }
}

/* ============================================
   TELEGRAPH GROUND INDICATORS
   Two-layer model: wrapper (position) > zone (instant boundary) + fill (animated countdown).
   Circle only — centered on target position. Player dodges out before it fires.
   Created/removed by NetworkEnemies.js telegraph event handlers.
   ============================================ */
.telegraph {
  position: absolute;
  pointer-events: none;
  z-index: 5;
  border-radius: 50%;
  contain: layout style paint;
}

.telegraph-zone {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--telegraph-zone);
  border: 2px solid var(--telegraph-border);
  animation: telegraph-zone-pulse var(--telegraph-duration, 1.5s) ease-in-out infinite;
}

@keyframes telegraph-zone-pulse {
  0%, 100% { opacity: 0.7; }
  50%      { opacity: 1; }
}

.telegraph-fill {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--telegraph-fill);
  transform-origin: center center;
  animation: telegraph-fill-expand var(--telegraph-duration, 1.5s) linear forwards;
}

@keyframes telegraph-fill-expand {
  0%   { transform: scale3d(0, 0, 1); opacity: 0.3; }
  100% { transform: scale3d(1, 1, 1); opacity: 1; }
}

/* --- Fire flash (propagates to children) --- */
.telegraph-fire,
.telegraph-fire > * {
  animation: telegraph-flash 0.3s ease-out forwards !important;
}

@keyframes telegraph-flash {
  0%   { opacity: 1; }
  30%  { opacity: 1; }
  100% { opacity: 0; }
}

/* --- Ground effects (burning ground, etc.) --- */
.ground-effect {
  position: absolute;
  pointer-events: none;
  z-index: 4;
  border-radius: 50%;
  background: radial-gradient(circle, var(--aoe-warm) 0%, oklch(0.62 0.125 58 / 0) 70%);
  contain: layout style paint;
  animation: ground-pulse 1.2s ease-in-out infinite;
}

@keyframes ground-pulse {
  0%, 100% { opacity: 0.4; }
  50%      { opacity: 0.7; }
}

/* ============================================
   UNIFIED HP BAR
   Same structure for character and all actors.
   Uses child elements (not pseudo-elements) for flexibility.
   ============================================ */
.actor > .hp-bar {
  position: absolute;
  top: -8px;
  left: 2px;
  right: 2px;
  height: 4px;
  background: var(--hp-bg, var(--alpha-black-60));
  overflow: hidden;
  z-index: 2;
  transition: opacity var(--fade-standard) ease;
}

.actor > .hp-bar > .hp-fill {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--enemy-hp-color);
  transform: scale3d(calc(var(--hp-pct, 100) / 100), 1, 1);
  transform-origin: left center;
  transition: transform 0.2s ease;
}

/* Character (player) uses green HP bar */
.character > .hp-bar > .hp-fill {
  background: var(--player-hp-color);
}

/* NPCs use NPC HP color */
.npc > .hp-bar > .hp-fill {
  background: var(--npc-hp-color);
}

/* Hide HP bar when at full health */
.actor.full-health > .hp-bar {
  opacity: 0;
}

/* Dynamic silhouette shadow - projects the sprite shape as a shadow
   CSS custom properties are set by time.js based on sun position:
   --shadow-skew: skewX angle (0 for overhead sun, +/- for east/west)
   --shadow-scale-y: vertical flatten (shorter at noon, longer at dawn/dusk)
   --shadow-scale-x: horizontal stretch (wider at low sun angles)
   (opacity is fixed at 0.2 - not dynamic)
*/
.actor > .shadow {
  position: absolute;
  z-index: 0; /* Behind sprite (1) */
  /* Match sprite dimensions */
  width: 100%;
  height: 100%;
  bottom: 0;
  left: 0;
  /* Use same sprite as the character for silhouette shadow */
  background-image: var(--sprite-idle);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  /* Transform to create projected shadow effect - values from :root (set by time.js)
     Order MUST match shadow-bounce animation: scale3d → skewX → translate3d */
  transform-origin: center bottom;
  transform: 
    scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1)
    skewX(var(--shadow-skew, 0deg))
    translate3d(0, 0, 0);
  /* PERF: Removed blur filter - just brightness(0) for black silhouette
     The transform flatten + low opacity already creates soft appearance */
  filter: brightness(0);
  opacity: 0.2;
  pointer-events: none;
  /* Transition smooths: animation exit, sun position updates */
  transition: transform var(--fade-snappy) ease-out, opacity var(--fade-slow) ease-out;
  /* PERF: Containment to isolate paint/layout calculations */
  contain: strict;
}

/* ============================================
   SPRITE SHEET SYSTEM (Player + Network Players only)
   Enemies/NPCs/guards stay on the single-SVG system above.
   Sprite sheets: 5 cols x 34 rows (120x1088), 24x32 frames.
   8 direction groups of 4 rows each: idle (2 frames in cols 0-1), walk (5 frames), run (5 frames), jump (5 frames).
   Rows 32-33: sit-down (5 frames), stand-up (5 frames) — direction-independent.
   --dir: 0=down, 1=down-left, 2=down-right, 3=right, 4=left, 5=up, 6=up-left, 7=up-right.
   --state-offset: 0=idle row, 1=walk row, 2=run row, 3=jump row (set by .moving/.sprinting/.jumping classes).
   Row = dir * 4 + state-offset. Y% = row * 100% / 33.
   ============================================ */

/* SPECIFICITY NOTE: walk/run rules MUST exclude ALL mutually exclusive states
   so --state-offset falls back to 0 (idle row) during those states.
   :not(.jumping) ensures the jump rule at (0,3,0) wins over walk/run at (0,10,0).
   :not(.dashing/:vehicle-knockback/:vehicle-knockback-heavy) prevents walk sprite
   row during dash/knockback — especially on network players where .moving and
   .vehicle-knockback can coexist (NetworkPlayers.js adds knockback without
   removing .moving from in-flight moves).
   Must stay in sync with the animation selectors below (~line 481). */
.character, .network-player { --state-offset: 0; }
.character.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up),
.network-player.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) { --state-offset: 1; }
.character.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up),
.network-player.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) { --state-offset: 2; }
.actor.character.jumping,
.actor.network-player.jumping { --state-offset: 3; }

/* Idle blink: col 1 of the idle row. JS toggles .blinking at random intervals.
   Excluded during sit states — blink specificity (0-4-0) beats sit rules (0-3-0),
   so the blink-x position would override the sit frame. JS also stops blink during sit. */
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .sprite,
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .shadow,
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .sprite-hair,
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-head,
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-chest,
.character:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-weapon,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .sprite,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .shadow,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .sprite-hair,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-head,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-chest,
.network-player:not(.moving):not(.sitting-down):not(.sitting):not(.standing-up).blinking > .equip-weapon {
  background-position-x: calc(100% / 4);
}

.character > .sprite,
.network-player > .sprite {
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc((var(--dir, 0) * 4 + var(--state-offset, 0)) * 100% / 33);
}

.character > .shadow,
.network-player > .shadow {
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc((var(--dir, 0) * 4 + var(--state-offset, 0)) * 100% / 33);
}

/* Walk cycle (8 frames, all cols). Excluded during sprint, jump, dash, and sit so those
   animations win cleanly without specificity conflicts. */
.actor.character.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite,
.actor.network-player.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite {
  animation: sheet-walk-cycle 0.4s steps(5) infinite, walk-bob 0.2s ease-in-out infinite;
}

.actor.character.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow,
.actor.network-player.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow {
  animation: sheet-walk-cycle 0.4s steps(5) infinite, shadow-bounce 0.4s ease-in-out infinite;
}

/* Run cycle (8 frames, all cols). 0.24s matches sprint speed (~236ms/tile). */
.actor.character.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite,
.actor.network-player.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite {
  animation: sheet-run-cycle 0.24s steps(5) infinite, walk-bob 0.2s ease-in-out infinite;
}

.actor.character.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow,
.actor.network-player.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow {
  animation: sheet-run-cycle 0.24s steps(5) infinite, shadow-bounce 0.24s ease-in-out infinite;
}

@keyframes sheet-walk-cycle {
  from { background-position-x: 0; }
  to   { background-position-x: calc(500% / 4); }
}

@keyframes sheet-run-cycle {
  from { background-position-x: 0; }
  to   { background-position-x: calc(500% / 4); }
}

@keyframes sheet-jump-cycle {
  from { background-position-x: 0; }
  to   { background-position-x: calc(500% / 4); }
}

@keyframes sheet-sit-down {
  from { background-position-x: 0; }
  to   { background-position-x: calc(500% / 4); }
}

@keyframes sheet-stand-up {
  from { background-position-x: 0; }
  to   { background-position-x: calc(500% / 4); }
}

/* Sit-down animation: row 32 (direction-independent). Plays once, no forwards fill —
   JS switches to .sitting on animationend which holds the last frame explicitly. */
.character.sitting-down > .sprite,
.character.sitting-down > .shadow,
.network-player.sitting-down > .sprite,
.network-player.sitting-down > .shadow {
  background-position-y: calc(32 * 100% / 33);
  animation: sheet-sit-down 0.2s steps(5);
}

.character.sitting-down > .sprite-hair,
.character.sitting-down > .equip-head,
.character.sitting-down > .equip-chest,
.character.sitting-down > .equip-weapon,
.network-player.sitting-down > .sprite-hair,
.network-player.sitting-down > .equip-head,
.network-player.sitting-down > .equip-chest,
.network-player.sitting-down > .equip-weapon {
  background-position-y: calc(32 * 100% / 33);
  animation: sheet-sit-down 0.2s steps(5);
}

/* Sitting (seated idle): hold last frame of sit-down row. */
.character.sitting > .sprite,
.character.sitting > .shadow,
.network-player.sitting > .sprite,
.network-player.sitting > .shadow {
  background-position-y: calc(32 * 100% / 33);
  background-position-x: 100%;
  animation: none;
}

.character.sitting > .sprite-hair,
.character.sitting > .equip-head,
.character.sitting > .equip-chest,
.character.sitting > .equip-weapon,
.network-player.sitting > .sprite-hair,
.network-player.sitting > .equip-head,
.network-player.sitting > .equip-chest,
.network-player.sitting > .equip-weapon {
  background-position-y: calc(32 * 100% / 33);
  background-position-x: 100%;
  animation: none;
}

/* Stand-up animation: row 33. Plays once, JS cleans up via animationend. */
.character.standing-up > .sprite,
.character.standing-up > .shadow,
.network-player.standing-up > .sprite,
.network-player.standing-up > .shadow {
  background-position-y: calc(33 * 100% / 33);
  animation: sheet-stand-up 0.2s steps(5);
}

.character.standing-up > .sprite-hair,
.character.standing-up > .equip-head,
.character.standing-up > .equip-chest,
.character.standing-up > .equip-weapon,
.network-player.standing-up > .sprite-hair,
.network-player.standing-up > .equip-head,
.network-player.standing-up > .equip-chest,
.network-player.standing-up > .equip-weapon {
  background-position-y: calc(33 * 100% / 33);
  animation: sheet-stand-up 0.2s steps(5);
}

/* --- Hair Overlay Layer (player + network players only) ---
   Hidden by default — setHairVisuals() shows it when a hair sheet URI is set.
   NPCs/enemies/guards never call setHairVisuals, so their hair stays hidden. */
.sprite-hair {
  position: absolute;
  inset: 0;
  z-index: 2;
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc((var(--dir, 0) * 4 + var(--state-offset, 0)) * 100% / 33);
  background-repeat: no-repeat;
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  image-rendering: crisp-edges;
  transform-origin: center bottom;
  display: none;
  pointer-events: none;
  contain: strict;
}

/* --- Equipment Overlay Layers (player + network players only) --- */
.equip-head, .equip-chest, .equip-weapon {
  position: absolute;
  inset: 0;
  z-index: 3;
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc((var(--dir, 0) * 4 + var(--state-offset, 0)) * 100% / 33);
  background-repeat: no-repeat;
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  image-rendering: crisp-edges;
  transform-origin: center bottom;
  display: none;
  pointer-events: none;
  contain: strict;
}

.equip-chest { z-index: 4; }
.equip-weapon { z-index: 5; }

.actor.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite-hair,
.actor.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-head,
.actor.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-chest,
.actor.moving:not(.sprinting):not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-weapon {
  animation: sheet-walk-cycle 0.4s steps(5) infinite, walk-bob 0.2s ease-in-out infinite;
}

.actor.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite-hair,
.actor.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-head,
.actor.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-chest,
.actor.moving.sprinting:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .equip-weapon {
  animation: sheet-run-cycle 0.24s steps(5) infinite, walk-bob 0.2s ease-in-out infinite;
}

.actor[class*="death-"] > .sprite-hair,
.actor[class*="death-"] > .equip-head,
.actor[class*="death-"] > .equip-chest,
.actor[class*="death-"] > .equip-weapon {
  will-change: opacity;
  animation: death-sprite-fade 0.15s ease-out forwards;
}

/* Jump overrides for sprite sheet actors.
   Walk/run rules and generic walk-bob all exclude :not(.jumping) so the
   jump animation is the only matching rule when .jumping is present.
   Sheet-jump-cycle handles background-position-x (frame), jump-arc handles transform (arc).
   Equipment layers need the same animations so they follow the sprite. */
.actor.character.jumping > .sprite,
.actor.network-player.jumping > .sprite {
  animation: sheet-jump-cycle 450ms steps(5), jump-arc 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.character.jumping > .shadow,
.actor.network-player.jumping > .shadow {
  animation: sheet-jump-cycle 450ms steps(5), jump-shadow 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.character.jumping > .sprite-hair,
.actor.character.jumping > .equip-head,
.actor.character.jumping > .equip-chest,
.actor.character.jumping > .equip-weapon,
.actor.network-player.jumping > .sprite-hair,
.actor.network-player.jumping > .equip-head,
.actor.network-player.jumping > .equip-chest,
.actor.network-player.jumping > .equip-weapon {
  animation: sheet-jump-cycle 450ms steps(5), jump-arc 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* Generic actor jump (NPCs/enemies — no sprite sheet, transform-only) */
.actor.jumping > .sprite-hair,
.actor.jumping > .equip-head,
.actor.jumping > .equip-chest,
.actor.jumping > .equip-weapon {
  animation: jump-arc 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* Walk bob for all moving actors - sprite bobs, shadow bounces in sync
   IMPORTANT: Use translate3d NOT scale3d for the bob animation.
   Scaling causes drop-shadow filter to bleed over edges (outline appears thicker).
   Translation moves the sprite down, simulating the same head-bob effect without
   affecting the outline rendering.
   :not() excludes knockback, jump, dash, and sit states so their animations win cleanly. */
.actor.moving:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .sprite {
  animation: walk-bob 0.2s ease-in-out infinite;
}

/* Shadow bounce synced with sprite bob - matches footfall rhythm.
   Transform transition disabled during animation to prevent conflict. */
.actor.moving:not(.jumping):not(.dashing):not(.vehicle-knockback):not(.vehicle-knockback-heavy):not(.sitting-down):not(.sitting):not(.standing-up) > .shadow {
  animation: shadow-bounce 0.2s ease-in-out infinite;
  transition: opacity var(--fade-slow) ease-out;
}

@keyframes walk-bob {
  /* Translate-based bob (NOT scale) to avoid drop-shadow bleed on outline.
     Moving down 1px simulates the head dipping, feet stay planted via transform-origin: center bottom */
  0%, 100% { transform: translate3d(0, 0, 0); }
  50% { transform: translate3d(0, 1px, 0); }
}

/* Shadow bounce: subtle squash on footfall (50%), return to normal (0%/100%)
   Uses CSS variables so sun position is respected.
   Note: skewX has no 3D equivalent but is still GPU-composited in the transform chain */
@keyframes shadow-bounce {
  0%, 100% { 
    transform: 
      scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1) 
      skewX(var(--shadow-skew, 0deg)) 
      translate3d(0, 0, 0); 
  }
  50% { 
    transform: 
      scale3d(calc(var(--shadow-scale-x, 1.1) * 1.03), calc(var(--shadow-scale-y, 0.4) * 0.94), 1) 
      skewX(var(--shadow-skew, 0deg)) 
      translate3d(0, 0, 0);
  }
}

/* ---------- Jump Animation ---------- */
/* Voluntary jump — sprite rises in a parabolic arc, shadow shrinks/fades.
   Walk/run/walk-bob rules all exclude :not(.jumping) so jump always wins.
   No 'forwards' fill — animation ends at identity, JS cleans up via animationend.
   
   The arc's horizontal component comes from the tile transition system (the actor
   element slides between tiles via CSS transition). The jump animation handles
   ONLY the vertical arc + squash/stretch. When walking and jumping, the combined
   result is a natural parabolic arc: constant horizontal velocity from tile movement
   + curved vertical from jump-arc.
   
   Sprint jumps get a higher peak via --jump-peak CSS variable (set by JS on the
   sprite element before adding .jumping). */
.actor.jumping > .sprite {
  animation: jump-arc 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.jumping > .shadow {
  animation: jump-shadow 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

@keyframes jump-arc {
  /* Takeoff squash */
  0% {
    transform: scale3d(1.08, 0.92, 1) translate3d(0, 2px, 0);
  }
  /* Peak — --jump-peak: hop height (default -28px, sprint -42px). Set by JS on sprite. */
  45% {
    transform: scale3d(0.96, 1.08, 1) translate3d(0, var(--jump-peak, -28px), 0);
  }
  /* Landing squash */
  85% {
    transform: scale3d(1.06, 0.94, 1) translate3d(0, 1px, 0);
  }
  /* Settle */
  100% {
    transform: scale3d(1, 1, 1) translate3d(0, 0, 0);
  }
}

@keyframes jump-shadow {
  /* Takeoff — shadow normal */
  0% {
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
  /* Peak — shadow at minimum (player highest) */
  45% {
    opacity: 0.06;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 0.5), calc(var(--shadow-scale-y, 0.4) * 0.5), 1);
  }
  /* Landing — shadow expands briefly */
  85% {
    opacity: 0.22;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 1.1), calc(var(--shadow-scale-y, 0.4) * 1.05), 1);
  }
  /* Settle to normal */
  100% {
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
}

/* ---------- Character (Player) ---------- */
/* Character uses #player ID for JS performance + .character class for styling */
#player,
.character {
  cursor: pointer;
  /* No CSS transition - movement animated via JS for Safari smoothness */
  will-change: transform;
}

/* Keep compositor layers pre-promoted for the local player's animated
   children. Without this, Chrome tears down layers during idle and must
   re-promote + re-rasterize (through skin-tone filters) when walk-bob or
   shadow-bounce starts — adding ~1ms to first-move render cost.
   Only 2 extra GPU layers; scoped to #player so the ~200 NPC/enemy sprites
   (which still have expensive drop-shadow filters) are unaffected. */
#player > .sprite,
#player > .shadow {
  will-change: transform;
}

/* Character states */
.character.ghost {
  opacity: 0.5;
  animation: ghost-pulse 2s ease-in-out infinite;
  filter: brightness(1.2) saturate(0.5);
}

.character.sprinting {
  filter: brightness(1.1);
}

/* Dashing visual effect for Leap and similar abilities
   Creates an airborne woosh: fast arc with squash/stretch
   PERF: No blur filter - it's expensive on animated elements */
.character.dashing {
  filter: brightness(1.3) saturate(1.1);
}

.character.dashing > .sprite,
.character.dashing > .sprite-hair {
  /* Leap arc: anticipation squash → stretch up → arc peak → land squash */
  animation: leap-arc 0.25s cubic-bezier(0.2, 0, 0.3, 1) forwards;
}

.character.dashing > .shadow {
  /* Shadow compresses during leap, shows we're airborne */
  animation: leap-shadow 0.25s cubic-bezier(0.2, 0, 0.3, 1) forwards;
}

@keyframes leap-arc {
  /* Anticipation squash */
  0% { 
    transform: scale3d(1.15, 0.85, 1) translate3d(0, 2px, 0);
  }
  /* Launch - stretch and rise */
  20% { 
    transform: scale3d(0.9, 1.2, 1) translate3d(0, -12px, 0);
  }
  /* Peak of arc */
  50% { 
    transform: scale3d(0.85, 1.25, 1) translate3d(0, -16px, 0);
  }
  /* Descending */
  80% { 
    transform: scale3d(0.9, 1.15, 1) translate3d(0, -8px, 0);
  }
  /* Land squash */
  100% { 
    transform: scale3d(1.1, 0.9, 1) translate3d(0, 1px, 0);
  }
}

@keyframes leap-shadow {
  0% { 
    opacity: 0.2;
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1)
               skewX(var(--shadow-skew, 0deg))
               translate3d(0, 0, 0);
  }
  /* Shadow shrinks when airborne (character higher = shadow smaller/lighter) */
  50% { 
    opacity: 0.08;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 0.6), calc(var(--shadow-scale-y, 0.4) * 0.6), 1)
               skewX(var(--shadow-skew, 0deg))
               translate3d(0, 0, 0);
  }
  100% { 
    opacity: 0.2;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 1.15), calc(var(--shadow-scale-y, 0.4) * 1.1), 1)
               skewX(var(--shadow-skew, 0deg))
               translate3d(0, 0, 0);
  }
}

/* ---------- Vehicle Knockback Animation ---------- */
/* Any actor hit by vehicle - flung into the air with smooth arc
   Scale INCREASES at peak to simulate height (actor appears larger when "closer" to camera in air)
   Slower, more cinematic feel with proper arc trajectory */
.actor.vehicle-knockback {
  filter: brightness(1.4) saturate(0.8);
  pointer-events: none;
  transition-timing-function: ease-out;
}

.actor.vehicle-knockback > .sprite {
  /* No 'forwards' fill - animation ends at identity transform, JS cleans up */
  animation: vehicle-knockback-arc var(--knockback-duration, 600ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.vehicle-knockback > .shadow {
  animation: vehicle-knockback-shadow var(--knockback-duration, 600ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* Smooth parabolic arc - fewer keyframes for smoother interpolation
   Scale increases at peak (player appears larger when "higher" = closer to camera)
   IMPORTANT: End at scale3d(1,1,1) to avoid sub-pixel artifacts on recovery */
@keyframes vehicle-knockback-arc {
  /* Impact - squash on hit */
  0% { 
    transform: scale3d(1.3, 0.7, 1) translate3d(0, 4px, 0) rotate3d(0, 0, 1, 0deg);
  }
  /* Peak of arc - maximum scale, maximum height, slight rotation */
  45% { 
    transform: scale3d(1.5, 1.5, 1) translate3d(0, -52px, 0) rotate3d(0, 0, 1, -15deg);
  }
  /* Landing squash */
  85% { 
    transform: scale3d(1.2, 0.8, 1) translate3d(0, 2px, 0) rotate3d(0, 0, 1, 0deg);
  }
  /* Settle to clean 1:1 scale - no sub-pixel artifacts */
  100% { 
    transform: scale3d(1, 1, 1) translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg);
  }
}

@keyframes vehicle-knockback-shadow {
  /* Impact - shadow normal */
  0% { 
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
  /* Peak - shadow at minimum (player highest) */
  45% { 
    opacity: 0.04;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 0.25), calc(var(--shadow-scale-y, 0.4) * 0.25), 1);
  }
  /* Landing - shadow expands briefly */
  85% { 
    opacity: 0.25;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 1.15), calc(var(--shadow-scale-y, 0.4) * 1.1), 1);
  }
  /* Settle to normal - clean values */
  100% { 
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
}

/* Heavy hit variant - for high-speed collisions (bigger arc, more dramatic) */
.actor.vehicle-knockback-heavy {
  filter: brightness(1.4) saturate(0.8);
  pointer-events: none;
  transition-timing-function: ease-out;
}

.actor.vehicle-knockback-heavy > .sprite {
  /* No 'forwards' fill - animation ends at identity transform, JS cleans up */
  animation: vehicle-knockback-heavy-arc var(--knockback-duration, 900ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.vehicle-knockback-heavy > .shadow {
  animation: vehicle-knockback-heavy-shadow var(--knockback-duration, 900ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* Heavy arc - more extreme scale and height, with tumble rotation
   IMPORTANT: End at scale3d(1,1,1) to avoid sub-pixel artifacts on recovery */
@keyframes vehicle-knockback-heavy-arc {
  /* Violent impact - extreme squash */
  0% { 
    transform: scale3d(1.5, 0.5, 1) translate3d(0, 6px, 0) rotate3d(0, 0, 1, 0deg);
  }
  /* Apex - maximum scale, max height, peak rotation */
  45% { 
    transform: scale3d(1.7, 1.7, 1) translate3d(0, -75px, 0) rotate3d(0, 0, 1, -30deg);
  }
  /* Crumple landing - squash on impact */
  82% { 
    transform: scale3d(1.25, 0.75, 1) translate3d(0, 4px, 0) rotate3d(0, 0, 1, 0deg);
  }
  /* Settle to clean 1:1 scale */
  100% { 
    transform: scale3d(1, 1, 1) translate3d(0, 0, 0) rotate3d(0, 0, 1, 0deg);
  }
}

/* Heavy shadow - follows the same arc */
@keyframes vehicle-knockback-heavy-shadow {
  /* Impact - shadow normal */
  0% { 
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
  /* Peak - shadow at minimum (player highest) */
  45% { 
    opacity: 0.03;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 0.18), calc(var(--shadow-scale-y, 0.4) * 0.18), 1);
  }
  /* Landing impact - shadow expands */
  82% { 
    opacity: 0.28;
    transform: scale3d(calc(var(--shadow-scale-x, 1.1) * 1.2), calc(var(--shadow-scale-y, 0.4) * 1.15), 1);
  }
  /* Settle to normal - clean values */
  100% { 
    opacity: var(--shadow-opacity, 0.2);
    transform: scale3d(var(--shadow-scale-x, 1.1), var(--shadow-scale-y, 0.4), 1);
  }
}

/* Equipment layers follow the knockback arc so they stay attached to the sprite.
   Matches the jump pattern (lines above). Currently display:none until art ships. */
.actor.vehicle-knockback > .sprite-hair,
.actor.vehicle-knockback > .equip-head,
.actor.vehicle-knockback > .equip-chest,
.actor.vehicle-knockback > .equip-weapon {
  animation: vehicle-knockback-arc var(--knockback-duration, 600ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

.actor.vehicle-knockback-heavy > .sprite-hair,
.actor.vehicle-knockback-heavy > .equip-head,
.actor.vehicle-knockback-heavy > .equip-chest,
.actor.vehicle-knockback-heavy > .equip-weapon {
  animation: vehicle-knockback-heavy-arc var(--knockback-duration, 900ms) cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* .character.healing + heal-glow removed — Phase 0 combat overhaul */

@keyframes ghost-pulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 0.6; }
}

.character.downed {
  opacity: 0.6;
  filter: grayscale(0.6) brightness(0.8);
  animation: downed-pulse 2s ease-in-out infinite;
}

@keyframes downed-pulse {
  0%, 100% { opacity: 0.4; filter: grayscale(0.6) brightness(0.7); }
  50% { opacity: 0.7; filter: grayscale(0.4) brightness(0.9); }
}

.character.downed > .shadow {
  opacity: 0;
}

/* ---------- Player Silhouette (Under Roof) ---------- */
/* Shows when player is beneath an obscuring element like the depot roof.
   Positioned above the roof so player remains visible.
   Uses ID selector to avoid collision with .player-silhouette in characterpanel.css */
#player-silhouette {
  position: absolute;
  width: var(--tile-size);
  height: calc(var(--tile-size) * 4 / 3);
  pointer-events: none;
  /* Above roof (z-index 15) and its darkness overlay */
  z-index: 17;
  /* Hidden by default - instant hide, quick fade-in */
  opacity: 0;
  transition: opacity 0.1s ease-out;
}

#player-silhouette.visible {
  opacity: 1;
  transition: opacity 0.15s ease-out;
}

#player-silhouette > .sprite {
  position: absolute;
  inset: 0;
  background-image: var(--sprite-idle);
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc(var(--dir, 0) * 4 * 100% / 33);
  background-repeat: no-repeat;
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  image-rendering: crisp-edges;
  transform-origin: center bottom;
  /* Black silhouette with subtle white stroke (4 directional shadows) */
  filter: 
    brightness(0)
    drop-shadow(1px 0 0 var(--alpha-white-50))
    drop-shadow(-1px 0 0 var(--alpha-white-50))
    drop-shadow(0 1px 0 var(--alpha-white-50))
    drop-shadow(0 -1px 0 var(--alpha-white-50));
  opacity: 0.7;
}

#player-silhouette > .silhouette-hair {
  position: absolute;
  inset: 0;
  background-size: 500% 3400%;
  background-position-x: 0;
  background-position-y: calc(var(--dir, 0) * 4 * 100% / 33);
  background-repeat: no-repeat;
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  image-rendering: crisp-edges;
  transform-origin: center bottom;
  filter: 
    brightness(0)
    drop-shadow(1px 0 0 var(--alpha-white-50))
    drop-shadow(-1px 0 0 var(--alpha-white-50))
    drop-shadow(0 1px 0 var(--alpha-white-50))
    drop-shadow(0 -1px 0 var(--alpha-white-50));
  opacity: 0.7;
}

#player-silhouette.moving > .sprite,
#player-silhouette.moving > .silhouette-hair {
  animation: walk-bob 0.2s ease-in-out infinite;
}

#player-silhouette.jumping > .sprite,
#player-silhouette.jumping > .silhouette-hair {
  animation: jump-arc 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

/* ---------- Death Overlay (Cinematic Fade) ---------- */
#death-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--bg-void);
  opacity: 0;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 2s ease-in;
  contain: layout style paint;
}

#death-overlay.active {
  opacity: 1;
  pointer-events: auto;
}

#death-overlay.fade-out {
  opacity: 0;
  /* Slow reveal - like waking from a coma */
  transition: opacity 4s ease-out;
}

/* Death message - quirky text */
.death-message {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 4vw, 2.5rem);
  color: var(--death-message);
  text-align: center;
  padding: 2rem;
  max-width: 80vw;
  opacity: 0;
  transform: translate3d(0, 20px, 0);
  /* Smooth fade in for message */
  transition: opacity 1.5s ease-out, transform 1.5s ease-out;
  text-shadow: 0 2px 8px var(--alpha-black-80);
  letter-spacing: 0.05em;
}

.death-message.visible {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

.death-recap {
  position: absolute;
  bottom: 20%;
  left: 50%;
  transform: translate3d(-50%, 0, 0);
  max-width: min(400px, 80vw);
  width: 100%;
  opacity: 0;
  transition: opacity 1.2s ease-out;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  color: var(--text-muted);
  contain: layout style paint;
}

.death-recap.visible {
  opacity: 1;
}

.death-recap-header {
  color: var(--danger);
  font-family: var(--font-display);
  font-size: var(--font-size-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-bottom: var(--gui-gap);
  text-align: center;
}

.death-recap-row {
  display: flex;
  justify-content: space-between;
  gap: var(--gui-gap);
  padding: 2px 0;
  border-bottom: 1px solid oklch(1 0 0 / 0.06);
}

.death-recap-source { flex: 1; color: var(--text-secondary); }
.death-recap-ability { flex: 1; text-align: center; }
.death-recap-damage { color: var(--danger); min-width: 3em; text-align: right; }
.death-recap-time { color: var(--text-muted); min-width: 4em; text-align: right; }

.character.immune {
  animation: immune-flash 0.35s ease-in-out infinite;
  pointer-events: none;
}

@keyframes immune-flash {
  0%, 100% { opacity: 1; filter: brightness(1.5) saturate(1.2); }
  50% { opacity: 0.4; filter: brightness(1) saturate(1); }
}

/* ---------- Player Corpse (Visual + Marker) ---------- */
.player-corpse {
  position: absolute;
  width: var(--tile-size);
  height: calc(var(--tile-size) * 4 / 3); /* 32px for 24px tiles */
  z-index: 5; /* Below living actors, on ground level */
  pointer-events: auto;
  cursor: pointer;
}

/* Corpse sprite - fallen body matching death animation end state */
.player-corpse > .sprite {
  position: absolute;
  width: var(--tile-size);
  height: calc(var(--tile-size) * 4 / 3);
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center bottom;
  /* Fallen pose - crumpled heap */
  transform: scale3d(1.4, 0.35, 1) translate3d(calc(4px * var(--death-dir, -1)), 15px, 0) rotate3d(0, 0, 1, calc(-22deg * var(--death-dir, -1)));
  filter: brightness(0.4) grayscale(1);
  opacity: 0.6;
  /* Subtle outline to make visible against terrain */
  -webkit-filter: 
    drop-shadow(1px 0 0 var(--alpha-black-50))
    drop-shadow(-1px 0 0 var(--alpha-black-50))
    drop-shadow(0 1px 0 var(--alpha-black-50))
    drop-shadow(0 -1px 0 var(--alpha-black-50))
    brightness(0.4) grayscale(1);
  filter: 
    drop-shadow(1px 0 0 var(--alpha-black-50))
    drop-shadow(-1px 0 0 var(--alpha-black-50))
    drop-shadow(0 1px 0 var(--alpha-black-50))
    drop-shadow(0 -1px 0 var(--alpha-black-50))
    brightness(0.4) grayscale(1);
}


/* ---------- NPCs ---------- */
.npc {
  cursor: pointer;
  /* Movement duration set dynamically via --actor-move-speed CSS variable */
  --actor-move-speed: 800ms;
  transition: transform var(--actor-move-speed) linear, opacity var(--fade-quick) ease-out, z-index var(--actor-move-speed) linear;
  /* PERF: Removed will-change: transform — CSS transition auto-promotes to
     compositor during movement. No permanent layer needed for idle NPCs. */
}

/* .npc.aiming removed — Phase 0 combat overhaul (cast bar is the telegraph) */

/* Snap position without animation (for sync/teleport) - keeps opacity transitions */
.npc.snap-position {
  transition: opacity var(--fade-quick) ease-out !important;
}

/* NPC targeted state and role tints now handled by unified system
   (see TINTING SYSTEM above) */

.npc[data-hidden="true"] { display: none; }

/* Quest markers now handled by .nameplate-quest in nameplate system */

/* NPC role tints now handled by data-tint attribute (see TINTING SYSTEM above) */

/* .npc.downed removed — dead code (CSS defined, never applied by JS) */

/* ---------- Enemies ---------- */
.enemy {
  cursor: pointer;
  /* Movement duration set dynamically via --actor-move-speed CSS variable */
  --actor-move-speed: 400ms;
  transition: transform var(--actor-move-speed) linear, opacity var(--fade-quick) ease-out, z-index var(--actor-move-speed) linear;
  /* PERF: Removed will-change: transform — CSS transition auto-promotes to
     compositor during movement. No permanent layer needed for idle enemies. */
}

/* Snap position without animation (for sync/teleport) - keeps opacity transitions */
.enemy.snap-position {
  transition: opacity var(--fade-quick) ease-out !important;
}

/* Enraged state — pulsing brightness/saturation on the actor container.
   Values kept low because parent filter stacks multiplicatively with
   child .sprite filters (.hit brightness 1.8, .burning brightness 1.3).
   .offscreen overrides via !important. */
.enemy.enraged {
  animation: enrage-pulse 0.6s ease-in-out infinite;
}

@keyframes enrage-pulse {
  0%, 100% { filter: brightness(1); }
  50% { filter: brightness(1.12) saturate(1.2); }
}

/* .enemy.repositioning, .enemy.fleeing, .enemy.flinching removed —
   Phase 0 combat overhaul (sprite art, not CSS opacity/filters) */

/* Enemy tints and selection outlines now handled by data-tint attribute 
   and unified .actor.targeted system (see TINTING SYSTEM above) */

/* ---------- Enemy Death Animation System ---------- */
/* Five particle-based death styles based on kill type:
   - death-fire: Laser kills - sprite collapses + ember pixels rise (existing)
   - death-explode: AoE/ability kills - flash-vanish + pixel burst outward
   - death-dice: Melee kills - flash-vanish + pixels fall, bounce, scatter
   - death-vaporize: Burn/DoT kills - dissolve + mist pixels drift upward
   - death-crush: Vehicle kills - squash-vanish + pixels scatter flat on ground
   All end in debris pile that fades out. */

/* Shared death state base — universal for ALL actor types */
.actor.death-fire,
.actor.death-explode,
.actor.death-dice,
.actor.death-vaporize,
.actor.death-crush {
  pointer-events: none;
  contain: layout style;
}

/* ---------- ALL DEATH TYPES — Sprite & Shadow ---------- */
/* Sprite does a quick opacity fade; particles carry the full visual. */
/* Brightness/saturation filter gives a brief "flash" before fade. */

.actor.death-dice > .sprite {
  will-change: opacity;
  filter: brightness(1.8) saturate(0.6);
  animation: death-sprite-fade 0.15s ease-out forwards;
}

.actor.death-explode > .sprite {
  will-change: opacity;
  filter: brightness(2.5) saturate(0.3);
  animation: death-sprite-fade 0.15s ease-out forwards;
}

.actor.death-vaporize > .sprite {
  will-change: opacity;
  filter: brightness(1.6) saturate(0.4);
  animation: death-sprite-fade 0.15s ease-out forwards;
}

.actor.death-crush > .sprite {
  will-change: opacity;
  filter: brightness(1.3) saturate(0.5);
  animation: death-sprite-fade 0.15s ease-out forwards;
}

/* All shadows fade together with sprite */
.actor.death-dice > .shadow,
.actor.death-explode > .shadow,
.actor.death-vaporize > .shadow,
.actor.death-crush > .shadow {
  will-change: opacity;
  animation: death-shadow-fade 0.15s ease-out forwards;
}

/* Simple opacity fade — no scaling, no shrinking */
@keyframes death-sprite-fade {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

/* ---------- FIRE DEATH (Laser) ---------- */
/* Fire death — same quick fade, smolder particles do the visual work */
.actor.death-fire > .sprite {
  will-change: opacity;
  filter: brightness(2) saturate(0.5);
  animation: death-sprite-fade 0.15s ease-out forwards;
}

.actor.death-fire > .shadow {
  will-change: opacity;
  animation: death-shadow-fade 0.15s ease-out forwards;
}

/* Shared shadow fade — simple opacity fade, no scale change */
@keyframes death-shadow-fade {
  0%   { opacity: 0.2; }
  100% { opacity: 0; }
}

/* Dead/debris state - all death types end in debris pile */
.enemy.dead {
  pointer-events: none;
  z-index: 4; /* Below living actors */
}

.enemy.dead > .sprite {
  /* Dark debris smudge on ground */
  background-image: none !important;
  background: radial-gradient(ellipse 100% 100% at 50% 50%,
    var(--death-debris-core) 0%,
    var(--death-debris-mid) 40%,
    var(--death-debris-outer) 70%,
    oklch(from var(--death-debris-outer) l c h / 0) 100%
  );
  transform: scale3d(1.2, 0.35, 1) translate3d(0, 18px, 0);
  transform-origin: center bottom;
  filter: none;
  border-radius: 50%;
  opacity: 0.7;
  animation: debris-fade 6s ease-out forwards;
}

.enemy.dead > .shadow {
  opacity: 0;
}

@keyframes debris-fade {
  0% { opacity: 0.7; }
  100% { opacity: 0.5; }
}

/* Nameplate hidden for dead enemies (handled by .actor-nameplate section) */

/* Despawning - debris fades out */
.enemy.despawning {
  pointer-events: none;
}

.enemy.despawning > .sprite,
.enemy.despawning > .shadow {
  opacity: 0;
  transition: opacity var(--fade-dramatic) ease-out;
}

/* (Removed: laser-disintegrate, laser-disintegrate-shadow, death-flash-vanish,
   death-dissolve-vanish, death-squash-vanish — all replaced by death-sprite-fade) */

/* ---------- Ember Smoke Particles ---------- */
/* Rising smoke/ember pixels from laser disintegration ash piles.
   Similar to vehicle dust but with red/orange palette and upward drift.
   PERF: CSS-only animation, GPU composited (transform + opacity only). */

#ember-smoke-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12; /* Above dust, below actors */
  contain: strict;
}

.ember-particle {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 1px;
  /* Subtle pixel outline like vehicle dust */
  box-shadow: inset 0 0 0 0.25px var(--particle-outline);
  /* CSS animations auto-promote to compositor during playback — no will-change needed */
  /* Hidden until activated */
  opacity: 0;
  pointer-events: none;
}

/* Color variants - ember/smoke palette */
.ember-particle[data-variant="0"] { background: var(--particle-ember-1); } /* Bright orange */
.ember-particle[data-variant="1"] { background: var(--particle-ember-2); } /* Orange-red */
.ember-particle[data-variant="2"] { background: var(--particle-ember-3); } /* Red */
.ember-particle[data-variant="3"] { background: var(--particle-ember-4); } /* Dark red */
.ember-particle[data-variant="4"] { background: var(--particle-ember-5); } /* Charcoal */
.ember-particle[data-variant="5"] { background: var(--particle-ember-6); } /* Dark smoke */

/* Ember rise animations - particles drift upward with slight sway */
@keyframes ember-rise-0 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.5, 0.5, 1); opacity: 0; }
  10% { transform: translate3d(2px, -4px, 0) scale3d(1, 1, 1); opacity: 0.9; }
  50% { transform: translate3d(-1px, -18px, 0) scale3d(0.9, 0.9, 1); opacity: 0.7; }
  100% { transform: translate3d(3px, -35px, 0) scale3d(0.4, 0.4, 1); opacity: 0; }
}

@keyframes ember-rise-1 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.6, 0.6, 1); opacity: 0; }
  10% { transform: translate3d(-3px, -3px, 0) scale3d(1.1, 1.1, 1); opacity: 0.85; }
  50% { transform: translate3d(2px, -20px, 0) scale3d(0.85, 0.85, 1); opacity: 0.6; }
  100% { transform: translate3d(-2px, -40px, 0) scale3d(0.3, 0.3, 1); opacity: 0; }
}

@keyframes ember-rise-2 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.4, 0.4, 1); opacity: 0; }
  15% { transform: translate3d(1px, -5px, 0) scale3d(0.9, 0.9, 1); opacity: 0.95; }
  60% { transform: translate3d(-2px, -22px, 0) scale3d(0.7, 0.7, 1); opacity: 0.5; }
  100% { transform: translate3d(1px, -38px, 0) scale3d(0.25, 0.25, 1); opacity: 0; }
}

@keyframes ember-rise-3 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.7, 0.7, 1); opacity: 0; }
  12% { transform: translate3d(-2px, -4px, 0) scale3d(1.05, 1.05, 1); opacity: 0.8; }
  55% { transform: translate3d(3px, -16px, 0) scale3d(0.8, 0.8, 1); opacity: 0.55; }
  100% { transform: translate3d(-1px, -32px, 0) scale3d(0.35, 0.35, 1); opacity: 0; }
}

@keyframes ember-rise-4 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.5, 0.5, 1); opacity: 0; }
  8% { transform: translate3d(2px, -2px, 0) scale3d(0.95, 0.95, 1); opacity: 0.9; }
  45% { transform: translate3d(-3px, -14px, 0) scale3d(0.75, 0.75, 1); opacity: 0.65; }
  100% { transform: translate3d(2px, -28px, 0) scale3d(0.3, 0.3, 1); opacity: 0; }
}

@keyframes ember-rise-5 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.55, 0.55, 1); opacity: 0; }
  10% { transform: translate3d(-1px, -5px, 0) scale3d(1, 1, 1); opacity: 0.85; }
  50% { transform: translate3d(2px, -24px, 0) scale3d(0.8, 0.8, 1); opacity: 0.5; }
  100% { transform: translate3d(-2px, -45px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}

/* ---------- Explosion Particles ---------- */
/* Radial burst particles from ability/AoE kills.
   PERF: CSS-only animation, GPU composited (transform + opacity only). */

#explosion-particle-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12;
  contain: strict;
}

.explosion-particle {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 1px;
  box-shadow: inset 0 0 0 0.25px var(--particle-outline);
  opacity: 0;
  pointer-events: none;
}

.explosion-particle[data-variant="0"] { background: var(--particle-explode-1); }
.explosion-particle[data-variant="1"] { background: var(--particle-explode-2); }
.explosion-particle[data-variant="2"] { background: var(--particle-explode-3); }
.explosion-particle[data-variant="3"] { background: var(--particle-explode-4); }
.explosion-particle[data-variant="4"] { background: var(--particle-explode-5); }
.explosion-particle[data-variant="5"] { background: var(--particle-explode-6); }

/* Explosion burst animations — particles fly outward radially */
@keyframes explode-burst-0 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.2, 1.2, 1); opacity: 0; }
  8% { transform: translate3d(4px, -8px, 0) scale3d(1, 1, 1); opacity: 1; }
  40% { transform: translate3d(12px, -24px, 0) scale3d(0.8, 0.8, 1); opacity: 0.7; }
  100% { transform: translate3d(18px, -36px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes explode-burst-1 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.1, 1.1, 1); opacity: 0; }
  8% { transform: translate3d(-6px, -6px, 0) scale3d(1, 1, 1); opacity: 1; }
  40% { transform: translate3d(-18px, -18px, 0) scale3d(0.75, 0.75, 1); opacity: 0.65; }
  100% { transform: translate3d(-28px, -28px, 0) scale3d(0.15, 0.15, 1); opacity: 0; }
}
@keyframes explode-burst-2 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.3, 1.3, 1); opacity: 0; }
  10% { transform: translate3d(8px, 3px, 0) scale3d(1.1, 1.1, 1); opacity: 0.95; }
  45% { transform: translate3d(24px, 9px, 0) scale3d(0.7, 0.7, 1); opacity: 0.5; }
  100% { transform: translate3d(35px, 14px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes explode-burst-3 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  8% { transform: translate3d(-3px, 7px, 0) scale3d(1.05, 1.05, 1); opacity: 0.9; }
  40% { transform: translate3d(-9px, 21px, 0) scale3d(0.8, 0.8, 1); opacity: 0.6; }
  100% { transform: translate3d(-14px, 32px, 0) scale3d(0.25, 0.25, 1); opacity: 0; }
}
@keyframes explode-burst-4 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.2, 1.2, 1); opacity: 0; }
  10% { transform: translate3d(6px, 5px, 0) scale3d(1, 1, 1); opacity: 1; }
  45% { transform: translate3d(18px, 15px, 0) scale3d(0.7, 0.7, 1); opacity: 0.55; }
  100% { transform: translate3d(26px, 22px, 0) scale3d(0.15, 0.15, 1); opacity: 0; }
}
@keyframes explode-burst-5 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.1, 1.1, 1); opacity: 0; }
  8% { transform: translate3d(-7px, -2px, 0) scale3d(1, 1, 1); opacity: 0.95; }
  40% { transform: translate3d(-22px, -6px, 0) scale3d(0.75, 0.75, 1); opacity: 0.6; }
  100% { transform: translate3d(-33px, -9px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}

/* ---------- Dice Particles ---------- */
/* Falling/bouncing particles from melee kills.
   PERF: CSS-only animation, GPU composited (transform + opacity only). */

#dice-particle-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12;
  contain: strict;
}

.dice-particle {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 1px;
  box-shadow: inset 0 0 0 0.25px var(--particle-outline);
  opacity: 0;
  pointer-events: none;
}

.dice-particle[data-variant="0"] { background: var(--particle-dice-1); }
.dice-particle[data-variant="1"] { background: var(--particle-dice-2); }
.dice-particle[data-variant="2"] { background: var(--particle-dice-3); }
.dice-particle[data-variant="3"] { background: var(--particle-dice-4); }
.dice-particle[data-variant="4"] { background: var(--particle-dice-5); }
.dice-particle[data-variant="5"] { background: var(--particle-dice-6); }

/* Dice fall animations — particles fall downward with gravity arc and bounce */
@keyframes dice-fall-0 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  5% { transform: translate3d(2px, -4px, 0) scale3d(1, 1, 1); opacity: 1; }
  30% { transform: translate3d(8px, 12px, 0) scale3d(0.9, 0.9, 1); opacity: 0.85; }
  45% { transform: translate3d(10px, 18px, 0) scale3d(0.8, 0.8, 1); opacity: 0.7; }
  55% { transform: translate3d(11px, 14px, 0) scale3d(0.7, 0.7, 1); opacity: 0.55; }
  70% { transform: translate3d(13px, 20px, 0) scale3d(0.5, 0.5, 1); opacity: 0.35; }
  100% { transform: translate3d(15px, 22px, 0) scale3d(0.3, 0.3, 1); opacity: 0; }
}
@keyframes dice-fall-1 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  5% { transform: translate3d(-3px, -3px, 0) scale3d(1.1, 1.1, 1); opacity: 0.95; }
  30% { transform: translate3d(-10px, 10px, 0) scale3d(0.9, 0.9, 1); opacity: 0.8; }
  45% { transform: translate3d(-12px, 16px, 0) scale3d(0.75, 0.75, 1); opacity: 0.65; }
  55% { transform: translate3d(-13px, 12px, 0) scale3d(0.65, 0.65, 1); opacity: 0.5; }
  70% { transform: translate3d(-14px, 19px, 0) scale3d(0.45, 0.45, 1); opacity: 0.3; }
  100% { transform: translate3d(-16px, 21px, 0) scale3d(0.25, 0.25, 1); opacity: 0; }
}
@keyframes dice-fall-2 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.8, 0.8, 1); opacity: 0; }
  8% { transform: translate3d(1px, -2px, 0) scale3d(1, 1, 1); opacity: 1; }
  35% { transform: translate3d(4px, 14px, 0) scale3d(0.85, 0.85, 1); opacity: 0.75; }
  50% { transform: translate3d(5px, 20px, 0) scale3d(0.7, 0.7, 1); opacity: 0.6; }
  60% { transform: translate3d(6px, 16px, 0) scale3d(0.6, 0.6, 1); opacity: 0.45; }
  75% { transform: translate3d(7px, 22px, 0) scale3d(0.4, 0.4, 1); opacity: 0.25; }
  100% { transform: translate3d(8px, 24px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes dice-fall-3 {
  0% { transform: translate3d(0, 0, 0) scale3d(1.1, 1.1, 1); opacity: 0; }
  5% { transform: translate3d(-5px, -5px, 0) scale3d(1, 1, 1); opacity: 0.9; }
  30% { transform: translate3d(-14px, 8px, 0) scale3d(0.85, 0.85, 1); opacity: 0.75; }
  45% { transform: translate3d(-16px, 15px, 0) scale3d(0.7, 0.7, 1); opacity: 0.6; }
  55% { transform: translate3d(-17px, 11px, 0) scale3d(0.6, 0.6, 1); opacity: 0.45; }
  70% { transform: translate3d(-18px, 18px, 0) scale3d(0.4, 0.4, 1); opacity: 0.3; }
  100% { transform: translate3d(-20px, 20px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes dice-fall-4 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.9, 0.9, 1); opacity: 0; }
  6% { transform: translate3d(4px, -1px, 0) scale3d(1, 1, 1); opacity: 1; }
  30% { transform: translate3d(12px, 11px, 0) scale3d(0.85, 0.85, 1); opacity: 0.8; }
  45% { transform: translate3d(14px, 17px, 0) scale3d(0.7, 0.7, 1); opacity: 0.6; }
  55% { transform: translate3d(15px, 13px, 0) scale3d(0.55, 0.55, 1); opacity: 0.45; }
  70% { transform: translate3d(16px, 20px, 0) scale3d(0.4, 0.4, 1); opacity: 0.25; }
  100% { transform: translate3d(18px, 23px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes dice-fall-5 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  5% { transform: translate3d(-1px, -6px, 0) scale3d(1.05, 1.05, 1); opacity: 0.9; }
  30% { transform: translate3d(-3px, 9px, 0) scale3d(0.9, 0.9, 1); opacity: 0.75; }
  45% { transform: translate3d(-4px, 16px, 0) scale3d(0.75, 0.75, 1); opacity: 0.6; }
  55% { transform: translate3d(-4px, 12px, 0) scale3d(0.6, 0.6, 1); opacity: 0.45; }
  70% { transform: translate3d(-5px, 19px, 0) scale3d(0.4, 0.4, 1); opacity: 0.25; }
  100% { transform: translate3d(-6px, 22px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}

/* ---------- Vaporize Particles ---------- */
/* Slow drifting mist particles from burn/DoT kills.
   PERF: CSS-only animation, GPU composited (transform + opacity only). */

#vaporize-particle-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12;
  contain: strict;
}

.vaporize-particle {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}

.vaporize-particle[data-variant="0"] { background: var(--particle-vapor-1); }
.vaporize-particle[data-variant="1"] { background: var(--particle-vapor-2); }
.vaporize-particle[data-variant="2"] { background: var(--particle-vapor-3); }
.vaporize-particle[data-variant="3"] { background: var(--particle-vapor-4); }
.vaporize-particle[data-variant="4"] { background: var(--particle-vapor-5); }
.vaporize-particle[data-variant="5"] { background: var(--particle-vapor-6); }

/* Vaporize drift animations — slow upward spread like dissipating mist */
@keyframes vapor-drift-0 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.6, 0.6, 1); opacity: 0; }
  12% { transform: translate3d(3px, -5px, 0) scale3d(1.2, 1.2, 1); opacity: 0.7; }
  50% { transform: translate3d(10px, -22px, 0) scale3d(1.5, 1.5, 1); opacity: 0.5; }
  100% { transform: translate3d(16px, -45px, 0) scale3d(2, 2, 1); opacity: 0; }
}
@keyframes vapor-drift-1 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.5, 0.5, 1); opacity: 0; }
  10% { transform: translate3d(-4px, -4px, 0) scale3d(1.1, 1.1, 1); opacity: 0.65; }
  55% { transform: translate3d(-12px, -26px, 0) scale3d(1.6, 1.6, 1); opacity: 0.4; }
  100% { transform: translate3d(-18px, -50px, 0) scale3d(2.2, 2.2, 1); opacity: 0; }
}
@keyframes vapor-drift-2 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.7, 0.7, 1); opacity: 0; }
  15% { transform: translate3d(1px, -6px, 0) scale3d(1.3, 1.3, 1); opacity: 0.75; }
  50% { transform: translate3d(-2px, -24px, 0) scale3d(1.7, 1.7, 1); opacity: 0.45; }
  100% { transform: translate3d(-5px, -48px, 0) scale3d(2.3, 2.3, 1); opacity: 0; }
}
@keyframes vapor-drift-3 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.6, 0.6, 1); opacity: 0; }
  12% { transform: translate3d(-2px, -3px, 0) scale3d(1, 1, 1); opacity: 0.6; }
  50% { transform: translate3d(6px, -20px, 0) scale3d(1.4, 1.4, 1); opacity: 0.4; }
  100% { transform: translate3d(12px, -42px, 0) scale3d(1.8, 1.8, 1); opacity: 0; }
}
@keyframes vapor-drift-4 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.5, 0.5, 1); opacity: 0; }
  10% { transform: translate3d(5px, -4px, 0) scale3d(1.1, 1.1, 1); opacity: 0.7; }
  55% { transform: translate3d(14px, -28px, 0) scale3d(1.5, 1.5, 1); opacity: 0.35; }
  100% { transform: translate3d(20px, -52px, 0) scale3d(2.1, 2.1, 1); opacity: 0; }
}
@keyframes vapor-drift-5 {
  0% { transform: translate3d(0, 0, 0) scale3d(0.7, 0.7, 1); opacity: 0; }
  12% { transform: translate3d(-3px, -5px, 0) scale3d(1.2, 1.2, 1); opacity: 0.65; }
  50% { transform: translate3d(-8px, -18px, 0) scale3d(1.6, 1.6, 1); opacity: 0.4; }
  100% { transform: translate3d(-14px, -38px, 0) scale3d(2, 2, 1); opacity: 0; }
}

/* ---------- Crush Particles ---------- */
/* Flat ground-scatter particles from vehicle kills.
   PERF: CSS-only animation, GPU composited (transform + opacity only). */

#crush-particle-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 12;
  contain: strict;
}

.crush-particle {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 1px;
  box-shadow: inset 0 0 0 0.25px var(--particle-outline);
  opacity: 0;
  pointer-events: none;
}

.crush-particle[data-variant="0"] { background: var(--particle-crush-1); }
.crush-particle[data-variant="1"] { background: var(--particle-crush-2); }
.crush-particle[data-variant="2"] { background: var(--particle-crush-3); }
.crush-particle[data-variant="3"] { background: var(--particle-crush-4); }
.crush-particle[data-variant="4"] { background: var(--particle-crush-5); }
.crush-particle[data-variant="5"] { background: var(--particle-crush-6); }

/* Crush scatter animations — flat horizontal burst along ground plane */
@keyframes crush-scatter-0 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  8% { transform: translate3d(6px, 1px, 0) scale3d(1, 0.6, 1); opacity: 1; }
  40% { transform: translate3d(22px, 3px, 0) scale3d(0.8, 0.5, 1); opacity: 0.7; }
  100% { transform: translate3d(32px, 4px, 0) scale3d(0.3, 0.3, 1); opacity: 0; }
}
@keyframes crush-scatter-1 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  8% { transform: translate3d(-7px, -1px, 0) scale3d(1, 0.6, 1); opacity: 0.95; }
  40% { transform: translate3d(-24px, -2px, 0) scale3d(0.75, 0.5, 1); opacity: 0.65; }
  100% { transform: translate3d(-35px, -3px, 0) scale3d(0.25, 0.25, 1); opacity: 0; }
}
@keyframes crush-scatter-2 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  10% { transform: translate3d(5px, 3px, 0) scale3d(0.9, 0.6, 1); opacity: 1; }
  45% { transform: translate3d(18px, 6px, 0) scale3d(0.7, 0.45, 1); opacity: 0.6; }
  100% { transform: translate3d(28px, 8px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}
@keyframes crush-scatter-3 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  8% { transform: translate3d(-4px, 2px, 0) scale3d(1, 0.7, 1); opacity: 0.9; }
  40% { transform: translate3d(-16px, 5px, 0) scale3d(0.8, 0.5, 1); opacity: 0.6; }
  100% { transform: translate3d(-25px, 7px, 0) scale3d(0.3, 0.3, 1); opacity: 0; }
}
@keyframes crush-scatter-4 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  10% { transform: translate3d(3px, -2px, 0) scale3d(0.9, 0.6, 1); opacity: 1; }
  45% { transform: translate3d(14px, -4px, 0) scale3d(0.7, 0.45, 1); opacity: 0.55; }
  100% { transform: translate3d(22px, -5px, 0) scale3d(0.25, 0.25, 1); opacity: 0; }
}
@keyframes crush-scatter-5 {
  0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); opacity: 0; }
  8% { transform: translate3d(-5px, -1px, 0) scale3d(1, 0.65, 1); opacity: 0.95; }
  40% { transform: translate3d(-20px, -3px, 0) scale3d(0.75, 0.45, 1); opacity: 0.6; }
  100% { transform: translate3d(-30px, -4px, 0) scale3d(0.2, 0.2, 1); opacity: 0; }
}

/* Dead state - guard hidden until respawn (pixel death animation already played) */
.npc.dead {
  pointer-events: none;
}

.npc.dead > .sprite,
.npc.dead > .shadow {
  opacity: 0;
}

/* .guard-braced, .enemy.is-teleporting, .enemy.retreating removed —
   Phase 0 combat overhaul (sprite art, not CSS filters) */

/* Enemy/NPC vehicle knockback — unified to .actor.vehicle-knockback above.
   Former npc-vehicle-knockback + npc-vehicle-knockback-shadow keyframes deleted. */

/* ---------- Legacy Enemy UI Elements (DEPRECATED) ---------- */
/* Old enemy-level-badge and enemy-hp-bar styles removed.
   Now using unified .actor-nameplate system below. */

/* Boss and parked-vehicle-npc tints handled by data-tint attribute
   (see TINTING SYSTEM above) */

/* ============================================
   ACTOR NAMEPLATE SYSTEM
   Unified nameplate for all actors (player, NPCs, enemies)
   
   Structure:
   - Row 1: Name
   - Row 2: Subtitle (type/affiliation)
   - Row 3: Bars (HP + Charge stacked) + Level box
   ============================================ */

/* PERF: Nameplates use contain to isolate layout/paint.
   Only opacity transitions (GPU-accelerated).
   No max-height transitions - use display:none for hiding. */
.actor-nameplate {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translate3d(-50%, 0, 0);
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-bottom: 10px;
  z-index: 4;
  pointer-events: none;
  white-space: nowrap;
  contain: layout style;
}

/* Row 1: Name */
.nameplate-name {
  font-family: var(--font-nameplate);
  font-size: 11px;
  font-weight: 600;
  color: var(--text-primary);
  text-shadow: 0 1px 2px var(--alpha-black-60);
  line-height: 1.1;
}

/* Row 2: Subtitle */
.nameplate-subtitle {
  font-family: var(--font-nameplate);
  font-size: 8px;
  font-weight: 400;
  color: var(--text-primary);
  text-shadow: 0 1px 2px var(--alpha-black-60);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  line-height: 1.1;
  margin-top: 5px;
}

/* Row 3: Stats container (bars + level) */
.nameplate-stats {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-top: 8px;
}

/* Bars container (HP + Charge stacked) */
.nameplate-bars {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 28px;
}

/* Individual bar */
.nameplate-bar {
  height: 4px;
  background: var(--alpha-black-60);
  overflow: hidden;
  width: 100%;
}

/* HP bar is taller */
.nameplate-bar.hp {
  height: 5px;
}

/* PERF: Only transform transitions on bar fills (GPU-accelerated) */
.nameplate-bar-fill {
  display: block;
  height: 100%;
  width: 100%;
  transform: scale3d(calc(var(--bar-pct, 100) / 100), 1, 1);
  transform-origin: left center;
  transition: transform 0.2s ease;
}

/* HP bar - role-based colors */
.nameplate-bar.hp .nameplate-bar-fill {
  background: var(--enemy-hp-color);
}

.character .nameplate-bar.hp .nameplate-bar-fill {
  background: var(--player-hp-color);
}

.npc .nameplate-bar.hp .nameplate-bar-fill {
  background: var(--npc-hp-color);
}

/* Charge bar - role-based colors */
.nameplate-bar.charge .nameplate-bar-fill {
  background: var(--enemy-charge-color);
}

.character .nameplate-bar.charge .nameplate-bar-fill {
  background: var(--player-charge-color);
}

.npc .nameplate-bar.charge .nameplate-bar-fill {
  background: var(--npc-charge-color);
}

/* Hide bars if actor doesn't have that resource */
.nameplate-bar.charge.hidden {
  display: none;
}

/* ============================================
   ENEMY CAST BAR
   Displayed below HP/charge bars during cast-time abilities.
   Fill animates from 0% to 100% over the cast duration.
   ============================================ */
.nameplate-cast-bar {
  position: relative;
  width: 100%;
  height: 5px;
  background: var(--cast-bar-bg);
  border-radius: 1px;
  margin-top: 1px;
  overflow: hidden;
}

.nameplate-cast-bar.hidden {
  display: none;
}

.nameplate-cast-fill {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
  background: var(--cast-enemy);  /* Hostile warm — casting in progress */
  border-radius: 1px;
  transform: scale3d(0, 1, 1);
  transform-origin: left center;
  transition: transform 0ms linear;
}

.nameplate-cast-bar.interruptible .nameplate-cast-fill {
  background: var(--cast-interruptible);  /* Red-orange — interruptible (higher urgency) */
}

.nameplate-cast-label {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-numbers);
  font-size: 7px;
  font-weight: bold;
  color: var(--text-primary);
  text-shadow: 0 0 2px var(--bg-void);
  pointer-events: none;
  line-height: 1;
}

/* Level box */
.nameplate-level {
  padding: 2px var(--gui-padding-sm);
  background: var(--bg-panel-alpha);
  font-family: var(--font-numbers);
  font-size: 9px;
  font-weight: bold;
  color: var(--text-primary);
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}

/* Quest icon in nameplate - matches level badge styling */
.nameplate-quest {
  padding: 2px var(--gui-padding-sm);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-numbers);
  font-size: 11px;
  font-weight: bold;
  margin-bottom: 10px;
  animation: nameplate-quest-bounce 1s ease-in-out infinite;
}

.nameplate-quest.available {
  background: var(--warning);
  color: var(--text-on-fill);
}

.nameplate-quest.available::before {
  content: '!';
}

.nameplate-quest.return {
  background: var(--success);
  color: var(--text-on-fill);
}

.nameplate-quest.return::before {
  content: '?';
}

@keyframes nameplate-quest-bounce {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50% { transform: translate3d(0, -3px, 0); }
}

/* Hide nameplate during spawn and all death states */
/* PERF: Uses display:none - no transitions needed */
.actor.spawning .actor-nameplate,
.actor.death-fire .actor-nameplate,
.actor.death-explode .actor-nameplate,
.actor.death-dice .actor-nameplate,
.actor.death-vaporize .actor-nameplate,
.actor.death-crush .actor-nameplate,
.actor.dead .actor-nameplate,
.actor.despawning .actor-nameplate {
  display: none;
}

/* ============================================
   NAMEPLATE VISIBILITY SETTINGS
   PERF: Uses display:none for hiding (no transitions)
   
   Player nameplate has its own toggle.
   Actor settings only affect .enemy and .npc (not .character)
   ============================================ */

/* Player Nameplate: Off - hide entire player nameplate */
.player-nameplate-off .character .actor-nameplate {
  display: none;
}

/* ---------- Nameplate Layer Settings (enemies, NPCs, bosses) ---------- */

/* Player nameplate: Off */
.player-nameplate-off .nameplate-player {
  display: none;
}

/* Names: Off - hide names for non-player nameplates */
.nameplate-names-off .nameplate-enemy .nameplate-name,
.nameplate-names-off .nameplate-enemy .nameplate-subtitle,
.nameplate-names-off .nameplate-npc .nameplate-name,
.nameplate-names-off .nameplate-npc .nameplate-subtitle,
.nameplate-names-off .nameplate-boss .nameplate-name,
.nameplate-names-off .nameplate-boss .nameplate-subtitle {
  display: none;
}

/* Names: Conditional - only show for key NPCs (has quest or important flag) */
.nameplate-names-conditional .nameplate-enemy .nameplate-name,
.nameplate-names-conditional .nameplate-enemy .nameplate-subtitle,
.nameplate-names-conditional .nameplate-npc:not([data-key-npc]) .nameplate-name,
.nameplate-names-conditional .nameplate-npc:not([data-key-npc]) .nameplate-subtitle {
  display: none;
}

/* Subtitles: Off - hide subtitles for all non-player nameplates */
.nameplate-subtitle-off .nameplate-enemy .nameplate-subtitle,
.nameplate-subtitle-off .nameplate-npc .nameplate-subtitle,
.nameplate-subtitle-off .nameplate-boss .nameplate-subtitle {
  display: none;
}

/* Health Bars: Off - hide bars for non-player nameplates */
.nameplate-hp-off .nameplate-enemy .nameplate-bar,
.nameplate-hp-off .nameplate-npc .nameplate-bar,
.nameplate-hp-off .nameplate-boss .nameplate-bar {
  display: none;
}

/* Hide bars container when bars are off */
.nameplate-hp-off .nameplate-enemy .nameplate-bars,
.nameplate-hp-off .nameplate-npc .nameplate-bars,
.nameplate-hp-off .nameplate-boss .nameplate-bars {
  display: none;
}

/* Health Bars: Conditional - only show bars when below 100% */
.nameplate-hp-conditional .nameplate-enemy.full-health .nameplate-bar,
.nameplate-hp-conditional .nameplate-npc.full-health .nameplate-bar,
.nameplate-hp-conditional .nameplate-boss.full-health .nameplate-bar {
  display: none;
}

/* Hide bars container when bars are conditionally hidden */
.nameplate-hp-conditional .nameplate-enemy.full-health .nameplate-bars,
.nameplate-hp-conditional .nameplate-npc.full-health .nameplate-bars,
.nameplate-hp-conditional .nameplate-boss.full-health .nameplate-bars {
  display: none;
}

/* Levels: Off - hide level badges for non-player nameplates */
.nameplate-level-off .nameplate-enemy .nameplate-level,
.nameplate-level-off .nameplate-npc .nameplate-level,
.nameplate-level-off .nameplate-boss .nameplate-level {
  display: none;
}

/* Levels: Conditional - only show when below 100% */
.nameplate-level-conditional .nameplate-enemy.full-health .nameplate-level,
.nameplate-level-conditional .nameplate-npc.full-health .nameplate-level,
.nameplate-level-conditional .nameplate-boss.full-health .nameplate-level {
  display: none;
}

/* Cast bar ALWAYS visible when active — immune to nameplate visibility settings.
   Players cannot turn off telegraphs. This is core combat information.
   (WoW convention: cast bars are not optional.)
   :not(.hidden) ensures the JS .hidden toggle still works when no cast is active.
   Fog exception: .fog-hidden still hides the entire nameplate including cast bar. */
.nameplate-names-off .nameplate-cast-bar:not(.hidden),
.nameplate-hp-off .nameplate-cast-bar:not(.hidden),
.nameplate-level-off .nameplate-cast-bar:not(.hidden),
.nameplate-names-conditional .nameplate-cast-bar:not(.hidden),
.nameplate-hp-conditional .nameplate-cast-bar:not(.hidden),
.nameplate-level-conditional .nameplate-cast-bar:not(.hidden) {
  display: revert !important;
}

/* Smooth position updates and fade transitions */
.parked-vehicle-npc {
  /* Movement duration set by server via --actor-move-speed (matches .npc pattern) */
  transition: transform var(--actor-move-speed) linear, opacity var(--fade-quick) ease-out, z-index var(--actor-move-speed) linear;
  opacity: 1;
}

/* Fade in on spawn */
.parked-vehicle-npc.spawning {
  opacity: 0;
}

/* Fade out on despawn */
.parked-vehicle-npc.despawning {
  opacity: 0;
  pointer-events: none;
}

/* ============================================
   DOCK WORKER TINT
   Industrial tan/brown for cargo workers
   ============================================ */
[data-tint="worker"] > .sprite {
  --actor-tint: hue-rotate(25deg) saturate(0.8);
}

/* ============================================
   CARGO CRATES
   Pushable cargo objects for delivery scenario
   ============================================ */

/* Crate layer - contains all crate elements */
.crate-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Below vehicles (12) and actors (10), so crates slide "into" vehicles */
  z-index: 9;
}

/* Individual crate */
.crate {
  position: absolute;
  width: var(--tile-size);
  height: var(--tile-size);
  z-index: 1;
  background: var(--crate-color, oklch(0.58 0.110 55));  /* fallback: warm brown */
  
  /* GPU compositing — crates use JS lerp (per-frame transform updates)
     so they need a persistent compositor layer */
  backface-visibility: hidden;
  contain: layout style;
  /* PERF: Removed transform-style: preserve-3d (unnecessary 3D context) */
  
  /* Spawn fade */
  opacity: 1;
  transition: opacity var(--fade-quick) ease-out;
}

/* Crate spawning - fade in */
.crate.spawning {
  opacity: 0;
}

/* Crate despawning - fade out */
.crate.despawning {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fade-standard) ease-out !important;
}

/* Crate moving - raise z-index above other crates */
.crate.moving {
  z-index: 2;
}

/* ============================================
   OTHER PLAYERS
   Other players rendered from server state.
   Uses direct JS transforms - no CSS transitions.
   ============================================ */
.network-player {
  cursor: pointer;
  /* Movement duration set dynamically via --actor-move-speed CSS variable */
  --actor-move-speed: 400ms;
  transition: transform var(--actor-move-speed) linear, opacity var(--fade-quick) ease-out, z-index var(--actor-move-speed) linear;
  /* PERF: Removed will-change: transform — CSS transition auto-promotes.
     Few network players exist at a time, minimal layer savings but consistent. */
}

/* Snap position without animation (for sync/teleport) - keeps opacity transitions */
.network-player.snap-position {
  transition: opacity var(--fade-quick) ease-out !important;
}

/* Burn visual — warm orange tint while burning.
   Subtle brightness bump + sepia + warm hue keeps the silhouette
   readable. Re-applied each burn tick (800ms toggle).
   MUST come BEFORE .hit — hit flash (200ms) should override burn (800ms)
   when both are active (same specificity, cascade order decides).
   NPC/enemy rules preserve CSS drop-shadow outlines in the filter chain. */
.enemy.burning > .sprite,
.npc.burning > .sprite {
  filter:
    drop-shadow(1px 0 0 var(--sprite-outline))
    drop-shadow(-1px 0 0 var(--sprite-outline))
    drop-shadow(0 1px 0 var(--sprite-outline))
    drop-shadow(0 -1px 0 var(--sprite-outline))
    brightness(1.3) sepia(0.6) hue-rotate(-10deg) saturate(2.5);
}
.network-player.burning > .sprite,
#player.burning > .sprite {
  filter: brightness(1.3) sepia(0.6) hue-rotate(-10deg) saturate(2.5);
}
.network-player.burning > .sprite-hair,
#player.burning > .sprite-hair {
  filter: brightness(1.3) sepia(0.6) hue-rotate(-10deg) saturate(2.5) !important;
}

/* Hit flash — classic pixel game red tint.
   brightness → sepia → hue-rotate → saturate chain preserves
   sprite silhouette and works on any sprite at any scale.
   Applied via class toggle for 200ms. Comes AFTER .burning so the
   brief hit flash always overrides the sustained burn tint.
   NPC/enemy rules preserve CSS drop-shadow outlines in the filter chain. */
.enemy.hit > .sprite,
.npc.hit > .sprite {
  filter:
    drop-shadow(1px 0 0 var(--sprite-outline))
    drop-shadow(-1px 0 0 var(--sprite-outline))
    drop-shadow(0 1px 0 var(--sprite-outline))
    drop-shadow(0 -1px 0 var(--sprite-outline))
    brightness(1.8) sepia(1) hue-rotate(-25deg) saturate(4);
}
.network-player.hit > .sprite,
#player.hit > .sprite {
  filter: brightness(1.8) sepia(1) hue-rotate(-25deg) saturate(4);
}
.network-player.hit > .sprite-hair,
#player.hit > .sprite-hair {
  filter: brightness(1.8) sepia(1) hue-rotate(-25deg) saturate(4) !important;
}

/* Hit-stop: brief recoil on the LOCAL player's body layers when one of YOUR
   ranged shots is confirmed by the server (roadmap 2.11 — gives the strike
   weight; without it ranged hits feel "papery"). Triggered + cleared in
   combat.js (handleServerHitConfirmation); also cleared on death by
   playDeathAnimation and on teardown by game.js stopGame (reused #player).
   Rides the individual `translate` property so it composes
   with facing (`scale`) and walk-bob (`transform`) instead of fighting them;
   the `animation` shorthand suppresses walk/idle for the freeze, then reverts.
   #player ID specificity wins over the walk/idle/run/jump/sit class chains.
   Shadow excluded so it stays grounded. Layer set matches the blink rule.
   Reduced-motion is gated at the JS trigger (combat.js), so there's no
   reduced-motion rule here. */
#player.hit-stop > .sprite,
#player.hit-stop > .sprite-hair,
#player.hit-stop > .equip-head,
#player.hit-stop > .equip-chest,
#player.hit-stop > .equip-weapon {
  /* 2.11 tuning placeholder: duration in the 80-120ms band. Keep loosely in
     sync with the ~150ms removal timer in combat.js (timer > duration so the
     recoil settles before the class clears). */
  animation: hit-stop-kick 100ms ease-out;
}

/* Crit: a bigger, longer beat layered on .hit-stop (scaled-by-impact stays fresh;
   uniform hit-stop goes stale, per game-feel research). Reuses hit-stop-kick via the
   --hit-stop-kick-dist var read inside the keyframe; only magnitude + duration scale.
   Higher class-specificity wins animation-duration + the var; animation-name stays
   inherited from the base shorthand above. 2.11 tuning placeholders, like the base.
   The var is read only inside `translate` (never an animation-* property), so the
   "animation-tainted" rule never bites; keep the duration a literal. */
#player.hit-stop.hit-stop-crit > .sprite,
#player.hit-stop.hit-stop-crit > .sprite-hair,
#player.hit-stop.hit-stop-crit > .equip-head,
#player.hit-stop.hit-stop-crit > .equip-chest,
#player.hit-stop.hit-stop-crit > .equip-weapon {
  --hit-stop-kick-dist: 4px;
  animation-duration: 160ms;
}

@keyframes hit-stop-kick {
  /* 2.11 tuning placeholder: magnitude (~2px default; crits bump it via
     --hit-stop-kick-dist), direction (vertical brace), and the hold split (50% to
     70% is the "freeze") are feel-tuned in playtest.
     Vertical, not horizontal: the individual `translate` applies before `scale`
     in the transform matrix, so a horizontal kick would read the same screen
     direction regardless of which way the player faces. Translate channel only
     (never transform/scale) so facing and the position transition are untouched. */
  0%   { translate: 0 0 0; }
  20%  { translate: 0 var(--hit-stop-kick-dist, 2px) 0; }
  70%  { translate: 0 var(--hit-stop-kick-dist, 2px) 0; }
  100% { translate: 0 0 0; }
}

/* Network player dead state - corpse on ground */
.network-player.dead {
  pointer-events: none;
  z-index: 5; /* Below living actors */
}

.network-player.dead > .sprite,
.network-player.dead > .sprite-hair {
  transform: scale3d(1.4, 0.35, 1) translate3d(calc(3px * var(--death-dir, -1)), 14px, 0) rotate3d(0, 0, 1, calc(-20deg * var(--death-dir, -1)));
  filter: 
    drop-shadow(1px 0 0 var(--sprite-outline))
    drop-shadow(-1px 0 0 var(--sprite-outline))
    drop-shadow(0 1px 0 var(--sprite-outline))
    drop-shadow(0 -1px 0 var(--sprite-outline))
    brightness(0.35) grayscale(1);
  opacity: 0.5;
}

.network-player.dead > .shadow {
  opacity: 0;
}

/* Network player ghost mode (corpse run) */
.network-player.ghost {
  opacity: 0.5;
  animation: ghost-pulse 2s ease-in-out infinite;
  filter: brightness(1.2) saturate(0.5);
}

/* ============================================
   NAMEPLATE LAYER SYSTEM
   Dedicated layer for all actor nameplates.
   Ensures nameplates are never obscured by actors.
   ============================================ */

/* Individual nameplate in the layer - positioned via transform */
.nameplate {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  pointer-events: none;
  white-space: nowrap;
  /* PERF: Removed will-change: transform — ~150+ nameplates don't need
     permanent GPU layers. Position transitions auto-promote when active. */
  contain: layout style;
  /* Gap between nameplate and sprite head */
  padding-bottom: 10px;
  /* 
   * JS sets --np-x, --np-y (position in px).
   * Position transition is set directly on element by JS for reliable animation control.
   * 100% GPU-accelerated via translate3d.
   */
  transform: translate3d(
    calc(var(--np-x, 0px) - 50%), 
    calc(var(--np-y, 0px) - 100%), 
    0
  );
  /* Default: no transform transition. JS sets it directly when animating position. */
}

/* Clickable nameplates — all types except local player */
.nameplate-enemy,
.nameplate-boss,
.nameplate-npc,
.nameplate-network-player {
  pointer-events: auto;
  cursor: pointer;
}

/* Nameplate follows sprite during jump — vertical offset via translate property
   (composes independently with the base transform positioning) */
.nameplate.jumping {
  animation: nameplate-jump 450ms cubic-bezier(0.25, 0.1, 0.25, 1);
}

@keyframes nameplate-jump {
  0% { translate: 0 2px 0; }
  45% { translate: 0 var(--jump-peak, -28px) 0; }
  85% { translate: 0 1px 0; }
  100% { translate: 0 0 0; }
}

/* Stack offset wrapper - separate transform layer for independent transitions */
.nameplate-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Use gap instead of margin-top on children - gap auto-adjusts when items are hidden */
  gap: 5px;
  /* Stack offset via transform - GPU accelerated, independent of position */
  transform: translate3d(0, calc(var(--np-stack, 0px) * -1), 0);
  transition: transform 200ms ease-out;
  /* PERF: Removed will-change: transform — stacking transition auto-promotes. */
}

/* ─── Chat Bubbles (above nameplate) ─── */

.chat-bubble {
  max-width: 180px;
  padding: 4px 8px;
  background: var(--bg-panel-alpha);
  border: 1px solid var(--alpha-white-08);
  border-radius: 6px;
  font-family: var(--font-sans);
  font-size: var(--font-size-xs);
  color: var(--text-primary);
  line-height: 1.3;
  text-align: center;
  word-break: break-word;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(0, 4px, 0);
  transition: opacity 300ms ease-out, transform 300ms ease-out;
}

.chat-bubble-visible {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

.chat-font-medium .chat-bubble { font-size: var(--font-size-sm); }
.chat-font-large .chat-bubble { font-size: var(--font-size-md); }

/* ─── Large Text nameplate overrides (hardcoded px → scaled) ───
   Specificity 0-3-0 required: later .nameplate .nameplate-* rules (0-2-0) would
   otherwise win over .ui-text-large .nameplate-* (also 0-2-0) due to source order. */
.ui-text-large .nameplate .nameplate-name     { font-size: 14px; }
.ui-text-large .nameplate .nameplate-subtitle { font-size: 10px; }
.ui-text-large .nameplate .nameplate-level    { font-size: 11px; }
.ui-text-large .nameplate .nameplate-quest    { font-size: 14px; }
.ui-text-large .nameplate-boss .nameplate-name { font-size: 15px; }

/* Reuse existing nameplate child styles */
.nameplate .nameplate-name {
  font-family: var(--font-nameplate);
  font-size: 11px;
  font-weight: 600;
  color: var(--text-primary);
  text-shadow: 0 1px 2px var(--alpha-black-60);
  line-height: 1;
}

.nameplate .nameplate-subtitle {
  font-family: var(--font-nameplate);
  font-size: 8px;
  font-weight: 400;
  color: var(--text-primary);
  text-shadow: 0 1px 2px var(--alpha-black-60);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  line-height: 1;
  /* Gap handled by parent flex container */
}

/* Row 3: Stats container (bars + level) - matches .actor-nameplate */
.nameplate .nameplate-stats {
  display: flex;
  align-items: center;
  gap: 2px;
  /* Gap handled by parent flex container */
}

/* Bars container (HP + Charge stacked) */
.nameplate .nameplate-bars {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 28px;
}

/* Individual bar */
.nameplate .nameplate-bar {
  height: 4px;
  background: var(--alpha-black-60);
  overflow: hidden;
  width: 100%;
}

/* HP bar is taller */
.nameplate .nameplate-bar.hp {
  height: 5px;
}

/* PERF: Only transform transitions on bar fills (GPU-accelerated) */
.nameplate .nameplate-bar-fill {
  display: block;
  height: 100%;
  width: 100%;
  transform: scale3d(calc(var(--bar-pct, 100) / 100), 1, 1);
  transform-origin: left center;
  transition: transform 0.2s ease;
}

/* HP bar - role-based colors */
.nameplate .nameplate-bar.hp .nameplate-bar-fill {
  background: var(--enemy-hp-color);
}

.nameplate-player .nameplate-bar.hp .nameplate-bar-fill,
.nameplate-network-player .nameplate-bar.hp .nameplate-bar-fill {
  background: var(--player-hp-color);
}

.nameplate-npc .nameplate-bar.hp .nameplate-bar-fill {
  background: var(--npc-hp-color);
}

/* Charge bar - role-based colors */
.nameplate .nameplate-bar.charge .nameplate-bar-fill {
  background: var(--enemy-charge-color);
}

.nameplate-player .nameplate-bar.charge .nameplate-bar-fill,
.nameplate-network-player .nameplate-bar.charge .nameplate-bar-fill {
  background: var(--player-charge-color);
}

.nameplate-npc .nameplate-bar.charge .nameplate-bar-fill {
  background: var(--npc-charge-color);
}

/* Level box - matches .actor-nameplate */
.nameplate .nameplate-level {
  padding: 2px var(--gui-padding-sm);
  background: var(--bg-panel-alpha);
  font-family: var(--font-numbers);
  font-size: 9px;
  font-weight: bold;
  color: var(--text-primary);
  text-shadow: 0 1px 2px var(--alpha-black-60);
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Quest icon in nameplate */
.nameplate .nameplate-quest {
  padding: 2px var(--gui-padding-sm);
  /* Gap handled by parent flex container */
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-numbers);
  font-size: 11px;
  font-weight: bold;
  animation: nameplate-quest-bounce 1s ease-in-out infinite;
}

.nameplate .nameplate-quest.available {
  background: var(--warning);
  color: var(--text-on-fill);
}

.nameplate .nameplate-quest.return {
  background: var(--success);
  color: var(--text-on-fill);
}

.nameplate .nameplate-quest.available::before {
  content: '!';
}

.nameplate .nameplate-quest.return::before {
  content: '?';
}

/* Targeted nameplate - highlighted */
.nameplate.targeted .nameplate-name {
  color: var(--target-highlight, var(--nameplate-target));
  text-shadow: 0 0 4px var(--target-glow, var(--nameplate-target-glow));
}

/* Hovered nameplate - subtle highlight */
.nameplate.hovered .nameplate-name {
  color: var(--text-primary);
}

/* Type-specific colors */
.nameplate-enemy .nameplate-name {
  color: var(--enemy-name, var(--nameplate-enemy));
}

.nameplate-boss .nameplate-name {
  color: var(--boss-name, var(--nameplate-boss));
  font-size: 12px;
}

.nameplate-npc .nameplate-name {
  color: var(--npc-name, var(--nameplate-npc));
}

.nameplate-network-player .nameplate-name {
  color: var(--player-name, var(--nameplate-player));
}

.nameplate-network-player.player-group .nameplate-name {
  color: var(--nameplate-player-group);
}

.nameplate-network-player.player-guild .nameplate-name {
  color: var(--nameplate-player-guild);
}

/* Fade out during death states */
.nameplate.dying,
.nameplate.dead {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fade-quick) ease-out;
}

/* Spawning state - fade in */
.nameplate.spawning {
  opacity: 0;
  pointer-events: none;
}

/* Hidden by fog of war - smooth fade */
.nameplate.fog-hidden {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fade-quick) ease-out;
}

/* Revealed from fog - smooth fade in */
.nameplate.fog-revealed {
  transition: opacity var(--fade-quick) ease-out;
}
