preview
WASD • Space
๐Ÿ”ฅ slow (GPU melter) ๐Ÿ“ฑ RIP mobile users
source
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  <title>Wizrad Map + Card Roguelike</title>
  <style>
    html, body { margin: 0; padding: 0; background: #0e1116; color: #e6e6e6; font-family: monospace; overflow: hidden; }
    canvas { display: block; }
    #cardUiRoot {
      position: fixed;
      left: 0;
      right: 0;
      bottom: 0;
      height: var(--card-ui-height, 250px);
      background: rgb(30, 30, 50);
      border-top: 1px solid rgb(70, 70, 100);
      z-index: 10;
      display: none;
    }
    #cardUiRoot.visible { display: block; }
    .card-panel {
      position: absolute;
      top: 10px;
      bottom: 10px;
      border-radius: 8px;
      background: rgb(40, 40, 60);
      border: 2px solid rgb(200, 200, 220);
      box-sizing: border-box;
      padding: 10px;
    }
    #cardStatsPanel { left: 10px; width: var(--card-stats-width, 180px); border: 0; }
    #cardEnemyPanel { right: 10px; width: var(--card-enemy-width, 180px); text-align: center; }
    #cardHandPanel {
      position: absolute;
      left: calc(var(--card-stats-width, 180px) + 20px);
      right: calc(var(--card-enemy-width, 180px) + 30px);
      top: 20px;
      display: flex;
      gap: 10px;
      align-items: flex-start;
      overflow-x: auto;
      overflow-y: hidden;
      padding-bottom: 8px;
    }
    .card {
      flex: 0 0 auto;
      width: 140px;
      height: 200px;
      border-radius: 8px;
      border: 2px solid rgb(200, 200, 220);
      background: rgb(50, 50, 80);
      color: #fff;
      box-sizing: border-box;
      padding: 8px;
      cursor: pointer;
      user-select: none;
      font: inherit;
      text-align: left;
    }
    .card:hover { background: rgb(70, 70, 100); }
    .card.selected { background: rgb(100, 150, 255); }
    .card.disabled {
      opacity: 0.55;
      cursor: default;
      outline: 2px solid rgba(255, 0, 0, 0.35);
      outline-offset: -2px;
    }
    .cardHeader { display: flex; justify-content: space-between; align-items: center; font-size: 12px; font-family: "Segoe UI Emoji", monospace; }
    .cardEmoji { text-align: center; font-size: 56px; line-height: 1; margin-top: 18px; margin-bottom: 14px; font-family: "Segoe UI Emoji", sans-serif; }
    .cardDesc { text-align: center; font-size: 11px; color: rgb(220, 220, 220); line-height: 1.25; }
    .hidden { display: none; }
    .card-help { margin-top: 10px; color: rgb(180, 180, 180); font-size: 10px; line-height: 1.5; }
    .enemyEmoji { font-size: 48px; margin: 12px 0 10px 0; font-family: "Segoe UI Emoji", sans-serif; }
    #gameCanvas {
      position: fixed; left: 0; top: 0; width: 100%; height: 100%;
      z-index: 1; pointer-events: auto;
    }
    #hudPanel {
      position: fixed; left: 12px; top: 12px; z-index: 5;
      padding: 10px; border-radius: 8px; background: rgba(0,0,0,0.67);
      width: fit-content; max-width: 280px; font-size: 13px; line-height: 1.4;
    }
    .shop-btn { padding: 4px 10px; border-radius: 6px; background: rgb(68,86,110); color: #f0f0f0; cursor: pointer; border: none; font: inherit; font-size: 12px; }
    .shop-btn:hover { background: rgb(80,98,125); }
    #messageToast {
      position: fixed; left: 12px; z-index: 8; padding: 11px 12px; border-radius: 8px;
      background: rgba(30,25,55,0.86); font-size: 16px; max-width: 680px;
      transition: bottom 0.2s; pointer-events: none;
    }
    #messageToast.hidden { display: none; }
    #encounterReportOverlay {
      position: fixed; left: 0; top: 0; right: 0; bottom: 0;
      background: rgba(0,0,0,0.72); z-index: 20; display: flex; align-items: center; justify-content: center;
    }
    #encounterReportOverlay.hidden { display: none; }
    #encounterReportPanel {
      background: rgb(20,24,34); padding: 18px; border-radius: 12px;
      max-width: 620px; max-height: 90vh; overflow-y: auto;
    }
    #encounterReportPanel h2 { margin: 0 0 12px 0; font-size: 24px; }
    #encounterReportPanel .report-btn { padding: 8px 24px; border-radius: 8px; background: rgb(66,84,112); color: #f0f0f0; cursor: pointer; border: none; float: right; margin-top: 12px; }
    #encounterReportPanel .report-btn:hover { background: rgb(80,100,130); }
    #deckViewOverlay {
      position: fixed; left: 0; top: 0; right: 0; bottom: 0;
      background: rgba(0,0,0,0.72); z-index: 20; display: flex; align-items: center; justify-content: center;
    }
    #deckViewOverlay.hidden { display: none; }
    #deckViewPanel {
      background: rgb(20,24,34); padding: 18px; border-radius: 12px;
      max-width: 90vw; max-height: 90vh; overflow-y: auto;
    }
    #deckViewPanel h2 { margin: 0 0 12px 0; font-size: 22px; }
    #deckViewPanel .deck-grid {
      display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0;
    }
    #deckViewPanel .deck-card {
      width: 120px; min-height: 170px; padding: 8px; border-radius: 8px;
      background: rgb(50,50,80); border: 2px solid rgb(200,200,220);
      cursor: default; font-size: 11px; box-sizing: border-box;
    }
    #deckViewPanel .deck-card.removable { cursor: pointer; }
    #deckViewPanel .deck-card.removable:hover { background: rgb(70,70,100); border-color: rgb(255,200,100); }
    #deckViewPanel .deck-card .deck-card-emoji { font-size: 40px; text-align: center; margin: 8px 0; }
    #deckViewPanel .deck-card .deck-card-count { font-size: 10px; color: rgb(180,180,180); margin-top: 4px; }
    #deckViewPanel .deck-remove-hint { color: rgb(200,180,120); font-size: 12px; margin-top: 8px; }
  </style>
</head>
<body>
<div id="hudPanel"></div>
<div id="messageToast" class="hidden"></div>
<div id="encounterReportOverlay" class="hidden">
  <div id="encounterReportPanel"></div>
</div>
<div id="deckViewOverlay" class="hidden">
  <div id="deckViewPanel"></div>
</div>
<canvas id="gameCanvas"></canvas>
<div id="cardUiRoot">
  <div id="cardStatsPanel" class="card-panel"></div>
  <div id="cardHandPanel"></div>
  <div id="cardEnemyPanel" class="card-panel"></div>
</div>
<script>
"use strict";

/* ========== VANILLA UTILITIES (replace p5) ========== */
let randSeed = 12345;
function randomSeed(s) { randSeed = s >>> 0; }
function random() {
  const r = () => (randSeed = (randSeed * 1103515245 + 12345) >>> 0) / 0x100000000;
  if (arguments.length === 0) return r();
  const a = arguments[0];
  if (Array.isArray(a)) return a[Math.floor(r() * a.length)];
  if (arguments.length === 1) return r() * a;
  const lo = Math.min(a, arguments[1]), hi = Math.max(a, arguments[1]);
  return lo + r() * (hi - lo);
}
function constrain(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function floor(n) { return Math.floor(n); }
function abs(n) { return Math.abs(n); }
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
function sq(n) { return n * n; }
function min(a, b) { return arguments.length === 2 ? Math.min(a, b) : Math.min(...arguments); }
function max(a, b) { return arguments.length === 2 ? Math.max(a, b) : Math.max(...arguments); }
function sin(x) { return Math.sin(x); }

/* ========== OVERWORLD (from wizrad) ========== */
const WORLD_W = 3600;
const WORLD_H = 2400;
const TERRAIN_SCALE = 0.0044;
const DANGER_SCALE = 0.0045;
const SEA_LEVEL = 0.3;
const BEACH_BAND = 0.03;
const GRID_CELL = 140;
const GRID_JITTER = 0.28;
const ROAD_NEIGHBORS = 3;
const PLAYER_SPEED = 160;
const CAMERA_ZOOM = 3.45;

const MAP_CENTER_X = WORLD_W * 0.5;
const MAP_CENTER_Y = WORLD_H * 0.5;
const MAX_DIST_FROM_CENTER = Math.hypot(MAP_CENTER_X, MAP_CENTER_Y);
const DANGER_NOISE_WEIGHT = 0.2;
const DANGER_DISTANCE_WEIGHT = 0.8;

const SITE_EMOJI_BY_DANGER = [
  { maxDanger: 0.2, emoji: "๐Ÿฐ", name: "Castle" },
  { maxDanger: 0.4, emoji: "๐Ÿ˜๏ธ", name: "Town" },
  { maxDanger: 0.6, emoji: "๐Ÿ ", name: "Village" },
  { maxDanger: 0.8, emoji: "๐Ÿš๏ธ", name: "Shack" },
  { maxDanger: 1.0, emoji: "๐Ÿ›–", name: "Hovel" }
];

const EVIL_SITE_MIN_DIST = GRID_CELL * 5;
const EVIL_SITE_PROB_POW = 1.5;
const EVIL_SITE_BASE_PROB = 0.3;

const FOREST_NOISE_SCALE = 0.003;
const FOREST_DENSITY_THRESHOLD = 0.38;
const FOREST_GRID_CELL = 24;
const FOREST_GRID_JITTER = 0.35;
const ROAD_CLEARANCE = 12;
const SETTLEMENT_CLEARANCE = 55;
const TREE_EMOJIS = ["๐ŸŒฒ", "๐ŸŒณ", "๐ŸŒฒ", "๐ŸŒณ", "๐ŸŒฒ"];
const EVIL_SITE_TYPES = [
  { name: "Cave", emoji: "๐Ÿ•ณ๏ธ" },
  { name: "Haunted House", emoji: "๐Ÿš๏ธ" },
  { name: "Crypt", emoji: "๐Ÿชฆ" },
  { name: "Cult Shrine", emoji: "๐Ÿ•ฏ๏ธ" },
  { name: "Blighted Tower", emoji: "๐Ÿ—ผ" }
];

const NAME_A = ["Bel", "Dor", "Fen", "Kor", "Tallow", "Mor", "Ash", "Rin", "Var", "Lum", "Grace", "Shorn", "Mort", "Glream"];
const NAME_B = ["ford", "haven", "watch", "crest", "gard", "mere", "hold", "spire", "port", "vale", "bridge", "wood", "gate", "hollow", "vale"];
const TRAVEL_ENCOUNTER_CHANCE = 0.2;

/* ========== CARD GAME (from cardrl5) ========== */
const CARD_UI_HEIGHT = 250;
const CARD_GRID_SIZE = 15;
const CARD_GRID_PADDING = 72;
const TREE_EMOJI_CULL_MARGIN = 128;
const CARD_STATS_WIDTH = 180;
const CARD_ENEMY_WIDTH = 180;
const CARD_HAND_PADDING = 10;
const CARD_DIST_FUDGE = 0.1;

const CARD_TYPES = {
  FIREBALL: { name: "Fireball", emoji: "๐Ÿ”ฅ", manaCost: 1, quick: false, range: 5, damage: 2, aoe: 3, selfDamage: true, description: "2 dmg, 5 range, 3 AOE (incl. self)" },
  MAGIC_MISSILE: { name: "Magic Missile", emoji: "โœจ", manaCost: 1, quick: true, range: 4, damage: 1, description: "1 dmg, 4 range (quick)" },
  FROST_BOLT: { name: "Frost Bolt", emoji: "โ„๏ธ", manaCost: 1, quick: false, range: 4, damage: 1, freeze: 3, description: "1 dmg, freeze 3 turns" },
  FROST_NOVA: { name: "Frost Nova", emoji: "๐Ÿฅถ", manaCost: 2, quick: false, range: 0, damage: 1, freeze: 3, aoe: 3, allInRange: true, description: "1 dmg, freeze 3, 3 AOE" },
  WATER_WAVE: { name: "Squirt", emoji: "๐Ÿ’ง", manaCost: 0, quick: true, range: 4, knockback: 3, description: "Knock back 3 (quick)" },
  HELLFIRE: { name: "Hellfire", emoji: "๐ŸŒ‹", manaCost: 3, quick: false, range: 0, damage: 2, aoe: 5, allInRange: true, description: "2 dmg to all in 5 range" },
  TSUNAMI: { name: "Tsunami", emoji: "๐ŸŒŠ", manaCost: 2, quick: false, range: 0, knockback: 3, aoe: 5, allInRange: true, description: "Knock back all in 5" },
  THINK: { name: "Think", emoji: "๐Ÿ’ญ", manaCost: 1, quick: true, range: 0, draw: 2, description: "Draw 2 cards (quick)" },
  RUMINATE: { name: "Ruminate", emoji: "๐Ÿง ", manaCost: 2, quick: true, range: 0, draw: 3, description: "Draw 3 cards (quick)" },
  IDEATE: { name: "Ideate", emoji: "๐Ÿ’ก", manaCost: 3, quick: true, range: 0, draw: 5, description: "Draw 5 cards (quick)" },
  RITUAL: { name: "Ritual", emoji: "โญ", manaCost: 0, quick: false, range: 0, manaGain: 3, description: "+3 mana" },
  FLASH: { name: "Flash", emoji: "โšก", manaCost: 0, quick: true, range: 0, manaGain: 1, description: "+1 mana (quick)" },
  BRAIN_SURGE: { name: "Brain Surge", emoji: "๐Ÿคฏ", manaCost: 2, quick: false, range: 0, drawPerCard: 1, description: "+1 card per card played" },
  BARRAGE: { name: "Barrage", emoji: "๐ŸŽฏ", manaCost: 2, quick: true, range: 4, damagePerCard: 1, description: "1 dmg/card played (quick)" },
  HASTE: { name: "Haste", emoji: "๐Ÿ‘Ÿ", manaCost: 1, quick: true, range: 0, nextCardQuick: true, description: "Next card gains quick" },
  NOVICE_BOLT: { name: "Novice Bolt", emoji: "๐Ÿชต", manaCost: 1, quick: false, range: 4, damage: 1, description: "1 dmg, 4 range" },
  FRANTIC_PLANNING: { name: "Frantic Planning", emoji: "๐Ÿ“‹", manaCost: 2, quick: true, range: 0, draw: 2, description: "Draw 2 cards (quick)" },
  FAINT_GLIMMER: { name: "Faint Glimmer", emoji: "โœจ", manaCost: 0, quick: false, range: 0, manaGain: 1, description: "+1 mana" }
};

const STARTING_CARD_KEYS = ["NOVICE_BOLT", "FRANTIC_PLANNING", "FAINT_GLIMMER"];
const DECK_REMOVE_COST = 100;

const CARD_ENEMY_TYPES = [
  { emoji: "๐Ÿ’€", name: "Skeleton", hp: 3, damage: 1, dangerTier: 0 },
  { emoji: "๐ŸงŸ", name: "Zombie", hp: 4, damage: 2, dangerTier: 0.15 },
  { emoji: "๐Ÿ‘ป", name: "Ghost", hp: 2, damage: 1, dangerTier: 0.1 },
  { emoji: "๐Ÿฆ‡", name: "Giant Bat", hp: 3, damage: 2, dangerTier: 0.25 },
  { emoji: "๐Ÿบ", name: "Dire Wolf", hp: 5, damage: 2, dangerTier: 0.35 },
  { emoji: "๐Ÿ•ท๏ธ", name: "Giant Spider", hp: 4, damage: 2, dangerTier: 0.4 },
  { emoji: "๐Ÿ‘น", name: "Goblin Brute", hp: 6, damage: 3, dangerTier: 0.5 },
  { emoji: "๐Ÿง™โ€โ™‚๏ธ", name: "Dark Mage", hp: 4, damage: 3, dangerTier: 0.6 },
  { emoji: "๐Ÿ‘บ", name: "Oni", hp: 8, damage: 4, dangerTier: 0.75 },
  { emoji: "๐Ÿฒ", name: "Young Dragon", hp: 10, damage: 4, dangerTier: 0.9 },
  { emoji: "๐Ÿ‘๏ธ", name: "Beholder", hp: 7, damage: 5, dangerTier: 0.95 }
];

let width = 1, height = 1, frameCount = 0;
const state = {
  terrainSeed: 0,
  dangerSeed: 0,
  forestSeed: 0,
  forestTrees: [],
  towns: [],
  edges: [],
  edgeSet: new Set(),
  player: null,
  uiButtons: [],
  message: "",
  messageTimer: 0,
  encountersEnabled: true,
  mode: "overworld",
  encounter: null,
  worldTime: 0,
  nextEncounterAt: 0,
  terrainRenderer: null,
  gameCanvas: null,
  gameCtx: null,
  camera: { x: null, y: null, lastFrame: -1 },
  cameraZoomMul: 1,
  battleZoomMul: 1,
  transition: null,
  postEncounterReport: null,
  pendingEncounterReport: null,
  lastReportRendered: null,
  cardGameMouseX: -1,
  cardGameMouseY: -1,
  hoveredCard: null,
  lastHandRenderKey: "",
  input: { heldKeys: new Set(), keyDownEvents: new Set(), heldLMB: false, lmbDown: false, lmbUp: false },
  mouseX: 0, mouseY: 0,
  deckViewOpen: false
};

function easeInOutCubic(t) {
  const x = constrain(t, 0, 1);
  return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
}
function u32(n) { return n >>> 0; }
function hash2u32(ix, iy, seed) {
  let h = u32(Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1442695041));
  h = u32(h ^ (h >>> 13));
  h = u32(Math.imul(h, 1274126177));
  return u32(h ^ (h >>> 16));
}
function smooth01(t) { return t * t * (3 - 2 * t); }
function valueNoise2D(x, y, scale, seed) {
  const px = x * scale, py = y * scale;
  const x0 = Math.floor(px), y0 = Math.floor(py), x1 = x0 + 1, y1 = y0 + 1;
  const tx = px - x0, ty = py - y0;
  const sx = smooth01(tx), sy = smooth01(ty);
  const v00 = hash2u32(x0, y0, seed) / 4294967295;
  const v10 = hash2u32(x1, y0, seed) / 4294967295;
  const v01 = hash2u32(x0, y1, seed) / 4294967295;
  const v11 = hash2u32(x1, y1, seed) / 4294967295;
  const nx0 = lerp(v00, v10, sx);
  const nx1 = lerp(v01, v11, sx);
  return Math.round(lerp(nx0, nx1, sy) * 65535) / 65535;
}

function makeTownName() { return random(NAME_A) + random(NAME_B); }
const FBM_OCTAVES = 6;
const FBM_LACUNARITY = 2.0;
const FBM_PERSISTENCE = 0.5;

function fbm2D(x, y, scale, seed) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxValue = 0;
  for (let i = 0; i < FBM_OCTAVES; i++) {
    value += amplitude * valueNoise2D(x * frequency + 300.123 + i * 17.7, y * frequency + 700.51 + i * 31.3, scale, seed);
    maxValue += amplitude;
    amplitude *= FBM_PERSISTENCE;
    frequency *= FBM_LACUNARITY;
  }
  return value / maxValue;
}

function terrainHeightAt(x, y) {
  const h = fbm2D(x, y, TERRAIN_SCALE, state.terrainSeed);
  return constrain(h, 0, 1);
}
function dangerNoiseAt(x, y) {
  const d = valueNoise2D(x + 90.37, y + 190.13, DANGER_SCALE, state.dangerSeed);
  return d * d;
}
function dangerAt(x, y) {
  const noiseD = dangerNoiseAt(x, y);
  const distFromCenter = dist(x, y, MAP_CENTER_X, MAP_CENTER_Y);
  const distFactor = constrain(distFromCenter / MAX_DIST_FROM_CENTER, 0, 1);
  return constrain(noiseD * DANGER_NOISE_WEIGHT + distFactor * DANGER_DISTANCE_WEIGHT, 0, 1);
}
function siteEmojiForDanger(dangerVal) {
  for (const tier of SITE_EMOJI_BY_DANGER) {
    if (dangerVal <= tier.maxDanger) return { emoji: tier.emoji, name: tier.name };
  }
  return SITE_EMOJI_BY_DANGER[SITE_EMOJI_BY_DANGER.length - 1];
}
function isLand(x, y) { return terrainHeightAt(x, y) >= SEA_LEVEL; }

function forestDensityAt(x, y) {
  return valueNoise2D(x * 1.3 + 555.7, y * 1.3 + 333.1, FOREST_NOISE_SCALE, state.forestSeed);
}

function distToNearestRoad(x, y) {
  let best = Infinity;
  for (const e of state.edges) {
    const a = state.towns[e.a], b = state.towns[e.b];
    const dx = b.x - a.x, dy = b.y - a.y;
    const len = Math.hypot(dx, dy) || 1;
    const t = constrain(((x - a.x) * dx + (y - a.y) * dy) / (len * len), 0, 1);
    const px = a.x + t * dx, py = a.y + t * dy;
    const d = dist(x, y, px, py);
    if (d < best) best = d;
  }
  return best;
}

function distToNearestSettlement(x, y) {
  let best = Infinity;
  for (const t of state.towns) {
    const d = dist(x, y, t.x, t.y);
    if (d < best) best = d;
  }
  return best;
}

function generateForestTrees() {
  state.forestTrees = [];
  const seed = state.forestSeed;
  for (let gy = 0; gy * FOREST_GRID_CELL < WORLD_H; gy++) {
    for (let gx = 0; gx * FOREST_GRID_CELL < WORLD_W; gx++) {
      const h = hash2u32(gx, gy, seed);
      const jx = (h / 4294967295 - 0.5) * FOREST_GRID_CELL * FOREST_GRID_JITTER * 2;
      const jy = (hash2u32(gx, gy + 777, seed) / 4294967295 - 0.5) * FOREST_GRID_CELL * FOREST_GRID_JITTER * 2;
      const x = gx * FOREST_GRID_CELL + FOREST_GRID_CELL * 0.5 + jx;
      const y = gy * FOREST_GRID_CELL + FOREST_GRID_CELL * 0.5 + jy;
      if (x < 4 || y < 4 || x > WORLD_W - 4 || y > WORLD_H - 4) continue;
      if (!isLand(x, y)) continue;
      if (forestDensityAt(x, y) < FOREST_DENSITY_THRESHOLD) continue;
      if (distToNearestRoad(x, y) < ROAD_CLEARANCE) continue;
      if (distToNearestSettlement(x, y) < SETTLEMENT_CLEARANCE) continue;
      const emojiIdx = (h >>> 16) % TREE_EMOJIS.length;
      state.forestTrees.push({ x, y, emoji: TREE_EMOJIS[emojiIdx] });
    }
  }
}

function getCamera() {
  const base = min(width / WORLD_W, height / WORLD_H) * 0.95;
  const scale = base * CAMERA_ZOOM * state.cameraZoomMul;
  const targetX = state.player ? state.player.x : WORLD_W * 0.5;
  const targetY = state.player ? state.player.y : WORLD_H * 0.5;
  if (state.camera.lastFrame !== frameCount) {
    if (state.camera.x == null || state.camera.y == null) {
      state.camera.x = targetX;
      state.camera.y = targetY;
    } else {
      state.camera.x = lerp(state.camera.x, targetX, 0.14);
      state.camera.y = lerp(state.camera.y, targetY, 0.14);
    }
    state.camera.lastFrame = frameCount;
  }
  const px = state.camera.x, py = state.camera.y;
  const halfViewW = width * 0.5 / scale;
  const halfViewH = height * 0.5 / scale;
  const cx = (halfViewW * 2 >= WORLD_W) ? WORLD_W * 0.5 : constrain(px, halfViewW, WORLD_W - halfViewW);
  const cy = (halfViewH * 2 >= WORLD_H) ? WORLD_H * 0.5 : constrain(py, halfViewH, WORLD_H - halfViewH);
  return { scale, cx, cy };
}
function worldToScreen(x, y) {
  const cam = getCamera();
  return { x: width * 0.5 + (x - cam.cx) * cam.scale, y: height * 0.5 + (y - cam.cy) * cam.scale };
}
function screenToWorld(sx, sy) {
  const cam = getCamera();
  return { x: cam.cx + (sx - width * 0.5) / cam.scale, y: cam.cy + (sy - height * 0.5) / cam.scale };
}

function createTerrainRenderer() {
  const canvas = document.createElement("canvas");
  canvas.style.position = "fixed";
  canvas.style.left = "0";
  canvas.style.top = "0";
  canvas.style.width = "100%";
  canvas.style.height = "100%";
  canvas.style.zIndex = "0";
  canvas.style.pointerEvents = "none";
  const first = document.body.firstElementChild;
  document.body.insertBefore(canvas, first || null);

  const gl = canvas.getContext("webgl2", { alpha: false, antialias: false, depth: false, stencil: false });
  if (!gl) throw new Error("WebGL2 is required.");

  const vertSrc = `#version 300 es
precision highp float;
const vec2 POS[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
void main() { gl_Position = vec4(POS[gl_VertexID], 0.0, 1.0); }`;

  const fragSrc = `#version 300 es
precision highp float;
precision highp int;
out vec4 outColor;
uniform vec2 uResolution;
uniform vec2 uWorldSize;
uniform vec2 uCamCenter;
uniform float uCamScale;
uniform uint uTerrainSeed;
uniform uint uDangerSeed;
uniform float uSeaLevel;
uniform float uBeachBand;
uint hash2u(uvec2 p, uint seed) {
  uint h = p.x * 374761393u + p.y * 668265263u + seed * 1442695041u;
  h ^= (h >> 13); h *= 1274126177u; h ^= (h >> 16);
  return h;
}
float smooth01(float t) { return t * t * (3.0 - 2.0 * t); }
float valueNoise(vec2 p, float scale, uint seed) {
  vec2 q = p * scale;
  ivec2 i0 = ivec2(floor(q)), i1 = i0 + ivec2(1);
  vec2 f = fract(q);
  float sx = smooth01(f.x), sy = smooth01(f.y);
  float v00 = float(hash2u(uvec2(i0), seed)) * (1.0 / 4294967295.0);
  float v10 = float(hash2u(uvec2(ivec2(i1.x, i0.y)), seed)) * (1.0 / 4294967295.0);
  float v01 = float(hash2u(uvec2(ivec2(i0.x, i1.y)), seed)) * (1.0 / 4294967295.0);
  float v11 = float(hash2u(uvec2(i1), seed)) * (1.0 / 4294967295.0);
  return floor((mix(mix(v00, v10, sx), mix(v01, v11, sx), sy) * 65535.0 + 0.5)) / 65535.0;
}
const float FBM_LACUNARITY = 2.0;
const float FBM_PERSISTENCE = 0.5;
const int FBM_OCTAVES = 6;
float fbm(vec2 world, float scale, uint seed) {
  float value = 0.0;
  float amplitude = 1.0;
  float frequency = 1.0;
  float maxValue = 0.0;
  for (int i = 0; i < FBM_OCTAVES; i++) {
    vec2 p = world * frequency + vec2(300.123 + float(i) * 17.7, 700.51 + float(i) * 31.3);
    value += amplitude * valueNoise(p, scale, seed);
    maxValue += amplitude;
    amplitude *= FBM_PERSISTENCE;
    frequency *= FBM_LACUNARITY;
  }
  return value / maxValue;
}
float terrainHeightAt(vec2 world) {
  return clamp(fbm(world, ${TERRAIN_SCALE.toFixed(10)}, uTerrainSeed), 0.0, 1.0);
}
void main() {
  vec2 screen = vec2(gl_FragCoord.x, uResolution.y - gl_FragCoord.y);
  vec2 world = uCamCenter + (screen - 0.5 * uResolution) / uCamScale;
  world = clamp(world, vec2(0.0), uWorldSize);
  float h = terrainHeightAt(world);
  vec3 col;
  if (h < uSeaLevel) col = vec3(20.0, 35.0 + h * 20.0, 110.0 + h * 70.0) / 255.0;
  else if (h < uSeaLevel + uBeachBand) col = vec3(214.0, 194.0, 132.0) / 255.0;
  else if (h < 0.8) {
    float t = clamp((h - (uSeaLevel + uBeachBand)) / (0.8 - (uSeaLevel + uBeachBand)), 0.0, 1.0);
    col = vec3(48.0 + t * 26.0, 124.0 + t * 46.0, 56.0 + t * 20.0) / 255.0;
  } else if (h < 0.92) {
    float t = clamp((h - 0.8) / (0.92 - 0.8), 0.0, 1.0);
    col = vec3(112.0 + t * 36.0) / 255.0;
  } else {
    float t = clamp((h - 0.92) / 0.08, 0.0, 1.0);
    col = vec3(230.0 + t * 22.0, 230.0 + t * 22.0, 236.0 + t * 19.0) / 255.0;
  }
  outColor = vec4(col, 1.0);
}`;

  function compile(type, src) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, src);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      throw new Error(gl.getShaderInfoLog(shader) || "shader compile error");
    }
    return shader;
  }
  const vs = compile(gl.VERTEX_SHADER, vertSrc);
  const fs = compile(gl.FRAGMENT_SHADER, fragSrc);
  const program = gl.createProgram();
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(program) || "program link error");
  }
  gl.deleteShader(vs);
  gl.deleteShader(fs);
  return {
    canvas, gl, program,
    uResolution: gl.getUniformLocation(program, "uResolution"),
    uWorldSize: gl.getUniformLocation(program, "uWorldSize"),
    uCamCenter: gl.getUniformLocation(program, "uCamCenter"),
    uCamScale: gl.getUniformLocation(program, "uCamScale"),
    uTerrainSeed: gl.getUniformLocation(program, "uTerrainSeed"),
    uDangerSeed: gl.getUniformLocation(program, "uDangerSeed"),
    uSeaLevel: gl.getUniformLocation(program, "uSeaLevel"),
    uBeachBand: gl.getUniformLocation(program, "uBeachBand")
  };
}

function resizeTerrainRenderer() {
  const tr = state.terrainRenderer;
  const dpr = window.devicePixelRatio || 1;
  const w = Math.max(1, Math.floor(window.innerWidth * dpr));
  const h = Math.max(1, Math.floor(window.innerHeight * dpr));
  if (tr.canvas.width !== w || tr.canvas.height !== h) {
    tr.canvas.width = w;
    tr.canvas.height = h;
  }
}

function renderTerrainRenderer(camCenterX, camCenterY, camScaleCss) {
  const tr = state.terrainRenderer;
  resizeTerrainRenderer();
  const gl = tr.gl;
  gl.viewport(0, 0, tr.canvas.width, tr.canvas.height);
  gl.useProgram(tr.program);
  gl.uniform2f(tr.uResolution, tr.canvas.width, tr.canvas.height);
  gl.uniform2f(tr.uWorldSize, WORLD_W, WORLD_H);
  gl.uniform2f(tr.uCamCenter, camCenterX, camCenterY);
  gl.uniform1f(tr.uCamScale, camScaleCss * (tr.canvas.width / Math.max(window.innerWidth, 1)));
  gl.uniform1ui(tr.uTerrainSeed, state.terrainSeed >>> 0);
  gl.uniform1ui(tr.uDangerSeed, state.dangerSeed >>> 0);
  gl.uniform1f(tr.uSeaLevel, SEA_LEVEL);
  gl.uniform1f(tr.uBeachBand, BEACH_BAND);
  gl.disable(gl.BLEND);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
}


function lineLandFraction(a, b) {
  const samples = 18;
  let landCount = 0;
  for (let i = 0; i <= samples; i++) {
    const t = i / samples;
    const x = lerp(a.x, b.x, t);
    const y = lerp(a.y, b.y, t);
    if (isLand(x, y)) landCount++;
  }
  return landCount / (samples + 1);
}

function addEdge(i, j) {
  if (i === j) return;
  const a = min(i, j), b = max(i, j);
  const key = `${a}|${b}`;
  if (state.edgeSet.has(key)) return;
  state.edgeSet.add(key);
  state.edges.push({ a, b });
}

function generateMap() {
  randomSeed(floor(random(1e9)));
  state.terrainSeed = floor(random(1, 0xffffffff)) >>> 0;
  state.dangerSeed = floor(random(1, 0xffffffff)) >>> 0;
  state.forestSeed = floor(random(1, 0xffffffff)) >>> 0;
  state.towns = [];
  state.edges = [];
  state.edgeSet.clear();
  state.mode = "overworld";
  state.encounter = null;
  state.nextEncounterAt = 0;
  state.transition = null;
  state.cameraZoomMul = 1;
  state.battleZoomMul = 1;
  state.postEncounterReport = null;
  state.pendingEncounterReport = null;
  state.camera.x = null;
  state.camera.y = null;
  document.getElementById("cardUiRoot").classList.remove("visible");
  hudCacheKey = "";

  for (let gy = 0; gy * GRID_CELL < WORLD_H; gy++) {
    for (let gx = 0; gx * GRID_CELL < WORLD_W; gx++) {
      const jx = (random() - 0.5) * GRID_CELL * GRID_JITTER * 2;
      const jy = (random() - 0.5) * GRID_CELL * GRID_JITTER * 2;
      const x = gx * GRID_CELL + GRID_CELL * 0.5 + jx;
      const y = gy * GRID_CELL + GRID_CELL * 0.5 + jy;
      if (x < 8 || y < 8 || x > WORLD_W - 8 || y > WORLD_H - 8) continue;
      if (!isLand(x, y)) continue;
      const siteDanger = dangerAt(x, y);
      const tier = siteEmojiForDanger(siteDanger);
      const shopCardPool = Object.keys(CARD_TYPES).filter((k) => !STARTING_CARD_KEYS.includes(k));
      const shopCards = [];
      for (let s = 0; s < 3; s++) {
        shopCards.push(shopCardPool[floor(random(shopCardPool.length))]);
      }
      state.towns.push({
        id: state.towns.length, x, y, gx, gy,
        name: makeTownName(),
        sizeTier: tier.name, emoji: tier.emoji,
        isEvilSite: false, siteType: null, siteEmoji: null, siteDanger,
        siteCleared: false, visited: false,
        shopCards,
        removalsUsed: 0
      });
    }
  }

  for (const t of state.towns) {
    const evilProb = min(1, EVIL_SITE_BASE_PROB + (1 - EVIL_SITE_BASE_PROB) * Math.pow(t.siteDanger, EVIL_SITE_PROB_POW));
    if (random() >= evilProb) continue;
    let tooClose = false;
    for (const s of state.towns) {
      if (s === t || !s.isEvilSite) continue;
      if (dist(t.x, t.y, s.x, s.y) < EVIL_SITE_MIN_DIST) { tooClose = true; break; }
    }
    if (tooClose) continue;
    const evilType = random(EVIL_SITE_TYPES);
    t.isEvilSite = true;
    t.siteType = evilType.name;
    t.siteEmoji = evilType.emoji;
    t.siteCleared = false;
  }

  function isGabrielEdge(i, j) {
    const a = state.towns[i], b = state.towns[j];
    const mx = (a.x + b.x) * 0.5, my = (a.y + b.y) * 0.5;
    const r2 = sq(a.x - b.x) * 0.25 + sq(a.y - b.y) * 0.25;
    for (let k = 0; k < state.towns.length; k++) {
      if (k === i || k === j) continue;
      const t = state.towns[k];
      if (sq(t.x - mx) + sq(t.y - my) < r2 - 1e-4) return false;
    }
    return true;
  }

  const nearest = new Array(state.towns.length).fill(-1);
  for (let i = 0; i < state.towns.length; i++) {
    const a = state.towns[i];
    let best = -1, bestD = Infinity;
    for (let j = 0; j < state.towns.length; j++) {
      if (i === j) continue;
      const b = state.towns[j];
      if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
      const d = dist(a.x, a.y, b.x, b.y);
      if (d < bestD && lineLandFraction(a, b) >= 0.65) { bestD = d; best = j; }
    }
    nearest[i] = best;
  }

  for (let i = 0; i < state.towns.length; i++) {
    const j = nearest[i];
    if (j < 0) continue;
    if (nearest[j] === i && isGabrielEdge(i, j)) addEdge(i, j);
  }

  for (let i = 0; i < state.towns.length; i++) {
    const a = state.towns[i];
    const local = [];
    for (let j = 0; j < state.towns.length; j++) {
      if (i === j) continue;
      const b = state.towns[j];
      if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
      local.push({ j, d: dist(a.x, a.y, b.x, b.y) });
    }
    local.sort((u, v) => u.d - v.d);
    let added = 0;
    for (const c of local) {
      if (added >= 4) break;
      if (!isGabrielEdge(i, c.j)) continue;
      if (lineLandFraction(a, state.towns[c.j]) < 0.65) continue;
      addEdge(i, c.j);
      added++;
    }
  }

  for (let i = 0; i < state.towns.length; i++) {
    if (state.edges.some((e) => e.a === i || e.b === i)) continue;
    const a = state.towns[i];
    let best = -1, bestD = Infinity;
    for (let j = 0; j < state.towns.length; j++) {
      if (i === j) continue;
      const b = state.towns[j];
      if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
      const d = dist(a.x, a.y, b.x, b.y);
      if (d < bestD && lineLandFraction(a, b) >= 0.55 && isGabrielEdge(i, j)) { best = j; bestD = d; }
    }
    if (best < 0) {
      for (let j = 0; j < state.towns.length; j++) {
        if (i === j) continue;
        const b = state.towns[j];
        if (abs(a.gx - b.gx) > 3 || abs(a.gy - b.gy) > 3) continue;
        const d = dist(a.x, a.y, b.x, b.y);
        if (d < bestD && lineLandFraction(a, b) >= 0.55) { best = j; bestD = d; }
      }
    }
    if (best >= 0) addEdge(i, best);
  }

  generateForestTrees();
  initPlayer();
}

function initPlayer() {
  if (state.towns.length < 2) { state.player = null; return; }
  let startIdx = -1;
  let bestScore = Infinity;
  for (let i = 0; i < state.towns.length; i++) {
    const t = state.towns[i];
    const d = dist(t.x, t.y, MAP_CENTER_X, MAP_CENTER_Y);
    const evilPenalty = t.isEvilSite ? 3000 : 0;
    const score = d + t.siteDanger * 800 + evilPenalty;
    if (score < bestScore) { bestScore = score; startIdx = i; }
  }
  if (startIdx < 0) startIdx = 0;

  state.towns[startIdx].visited = true;
  const startingCards = [];
  for (const k of STARTING_CARD_KEYS) for (let i = 0; i < 4; i++) startingCards.push(k);
  state.player = {
    atTown: startIdx, targetTown: null, progress: 0,
    x: state.towns[startIdx].x, y: state.towns[startIdx].y,
    gold: 20, hp: 20, hpMax: 20, mana: 70, manaMax: 100,
    startingCards,
    ownedCards: []
  };
  setMessage(`Start at ${state.towns[startIdx].name}.`);
}

function setMessage(msg) {
  state.message = msg;
  state.messageTimer = 3.2;
}

function neighborsOf(townId) {
  const out = [];
  for (const e of state.edges) {
    if (e.a === townId) out.push(e.b);
    else if (e.b === townId) out.push(e.a);
  }
  return out;
}

function startTravel(targetTownId) {
  if (!state.player || state.player.targetTown != null || state.mode !== "overworld") return;
  const nbs = neighborsOf(state.player.atTown);
  if (!nbs.includes(targetTownId)) return;
  state.player.targetTown = targetTownId;
  state.player.progress = 0;
  state.player.travelEncounterChecked = false;
}

function arriveTown() {
  const p = state.player;
  const town = state.towns[p.atTown];
  town.visited = true;
  if (town.isEvilSite && !town.siteCleared) {
    beginEncounterTransition(town.siteDanger, {
      label: `${town.siteType} encounter`,
      bonusGold: floor(60 + town.siteDanger * 120),
      bonusMana: floor(25 + town.siteDanger * 25),
      siteTownId: town.id
    });
    setMessage(`You entered a ${town.siteType} near ${town.name}.`);
    return;
  }
  setMessage(`Arrived at ${town.name}.`);
}

function maybeStartTravelEncounter(danger) {
  if (!state.encountersEnabled) return false;
  if (state.worldTime < state.nextEncounterAt) return false;
  if (random() >= TRAVEL_ENCOUNTER_CHANCE) return false;
  beginEncounterTransition(danger);
  return true;
}

function updateTravel(dt) {
  const p = state.player;
  if (!p || p.targetTown == null || state.mode !== "overworld") return;
  const a = state.towns[p.atTown];
  const b = state.towns[p.targetTown];
  const d = max(1, dist(a.x, a.y, b.x, b.y));
  p.progress += (PLAYER_SPEED * dt) / d;
  const rawT = constrain(p.progress, 0, 1);
  const t = rawT * rawT * (3 - 2 * rawT);
  p.x = lerp(a.x, b.x, t);
  p.y = lerp(a.y, b.y, t);
  if (!p.travelEncounterChecked && p.progress >= 0.5) {
    p.travelEncounterChecked = true;
    const routeDanger = max(a.siteDanger, b.siteDanger, dangerAt(p.x, p.y));
    if (maybeStartTravelEncounter(routeDanger)) return;
  }
  if (p.progress >= 1) {
    p.atTown = p.targetTown;
    p.targetTown = null;
    p.progress = 0;
    p.x = b.x;
    p.y = b.y;
    arriveTown();
  }
}

function drawWorld() {
  const ctx = state.gameCtx;
  if (!ctx) return;
  ctx.clearRect(0, 0, width, height);
  const cam = getCamera();
  renderTerrainRenderer(cam.cx, cam.cy, cam.scale);

  ctx.save();
  ctx.translate(width * 0.5, height * 0.5);
  ctx.scale(cam.scale, cam.scale);
  ctx.translate(-cam.cx, -cam.cy);
  const halfViewW = width * 0.5 / cam.scale;
  const halfViewH = height * 0.5 / cam.scale;
  const margin = 40;
  ctx.font = "24px 'Segoe UI Emoji', sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  for (const t of state.forestTrees) {
    if (t.x < cam.cx - halfViewW - margin || t.x > cam.cx + halfViewW + margin) continue;
    if (t.y < cam.cy - halfViewH - margin || t.y > cam.cy + halfViewH + margin) continue;
    ctx.fillText(t.emoji, t.x, t.y);
  }
  const playerTown = state.player ? state.player.atTown : -1;
  for (const e of state.edges) {
    const a = state.towns[e.a];
    const b = state.towns[e.b];
    const connectedToCurrent = e.a === playerTown || e.b === playerTown;
    ctx.strokeStyle = connectedToCurrent ? "rgba(180,140,90,0.95)" : "rgba(120,95,65,0.55)";
    ctx.lineWidth = (connectedToCurrent ? 4 : 3) / cam.scale;
    ctx.lineCap = "round";
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  for (const town of state.towns) {
    const isAtTown = state.player && town.id === state.player.atTown;
    ctx.fillStyle = town.visited ? "rgba(255,236,186,0.71)" : "rgba(220,205,185,0.53)";
    ctx.beginPath();
    ctx.arc(town.x, town.y, 8 / cam.scale, 0, Math.PI * 2);
    ctx.fill();
    if (isAtTown) {
      ctx.strokeStyle = "rgb(255,228,100)";
      ctx.lineWidth = 3 / cam.scale;
      ctx.beginPath();
      ctx.arc(town.x, town.y, (26 + sin(frameCount * 0.12) * 2.5) / cam.scale / 2, 0, Math.PI * 2);
      ctx.stroke();
    }
  }
  ctx.restore();

  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  for (const town of state.towns) {
    const s = worldToScreen(town.x, town.y);
    const icon = town.isEvilSite ? town.siteEmoji : town.emoji;
    ctx.font = "72px monospace";
    ctx.fillStyle = "#e6e6e6";
    ctx.fillText(icon, s.x, s.y - 6);
    ctx.fillStyle = "rgb(15,15,15)";
    ctx.font = "13px monospace";
    ctx.fillText(town.name, s.x, s.y + 18);
    if (town.isEvilSite) {
      ctx.fillStyle = town.siteCleared ? "rgb(160,240,170)" : "rgb(255,120,120)";
      ctx.font = "11px monospace";
      ctx.fillText(`${town.siteType}${town.siteCleared ? " (cleared)" : ""}`, s.x, s.y + 33);
    }
    if (state.player && town.id === state.player.atTown && state.player.targetTown == null) {
      ctx.fillStyle = "rgb(255,245,145)";
      ctx.font = "12px monospace";
      ctx.fillText("YOU ARE HERE", s.x, s.y - 32);
    }
  }
  if (state.player) {
    const ps = worldToScreen(state.player.x, state.player.y);
    ctx.font = "56px monospace";
    ctx.fillStyle = "#e6e6e6";
    ctx.fillText("๐Ÿง™", ps.x, ps.y - 34);
  }
}

let hudCacheKey = "";
function drawHud() {
  const hud = document.getElementById("hudPanel");
  if (!state.player) { hud.innerHTML = ""; hudCacheKey = ""; drawMessageToast(); return; }
  const p = state.player;
  const at = state.towns[p.atTown];
  const d = dangerAt(p.x, p.y);
  const shopCards = (p.targetTown == null && state.mode === "overworld") ? (at.shopCards || []) : [];
  const key = `${at.id}-${p.atTown}-${p.targetTown}-${p.hp}-${p.gold}-${d.toFixed(1)}-${state.encountersEnabled}-${shopCards.join(",")}`;
  if (key === hudCacheKey) { drawMessageToast(); return; }
  hudCacheKey = key;

  let shopHtml = "";
  if (shopCards.length > 0) {
    shopHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2);">Shop:`;
    for (const ckey of shopCards) {
      const c = CARD_TYPES[ckey];
      if (!c) continue;
      const price = cardPrice(ckey);
      shopHtml += `<div style="margin-top:4px;display:flex;align-items:center;gap:8px;"><button class="shop-btn" data-card="${ckey}" data-price="${price}">Buy</button><span>${c.emoji} ${c.name} ${price}g</span></div>`;
    }
    shopHtml += `</div>`;
  } else if (p.targetTown == null && state.mode === "overworld") {
    shopHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.2);color:rgb(160,160,160);">Shop: No cards for sale</div>`;
  }

  hud.innerHTML = `
    <div>${at.emoji} ${at.name} (${at.sizeTier})${p.targetTown != null ? " โ€” traveling" : ""}</div>
    <div style="margin-top:6px;">HP ${p.hp.toFixed(0)}/${p.hpMax} ยท Gold ${p.gold}</div>
    <div style="margin-top:4px;font-size:12px;color:rgb(180,180,180);">Danger ${(d * 100).toFixed(0)}% ยท Encounters ${state.encountersEnabled ? "ON" : "OFF"}</div>
    ${shopHtml}
    <div style="margin-top:10px;font-size:11px;color:rgb(160,160,160);">Click connected town to travel ยท E toggle encounters ยท R regenerate ยท WASD+cards in combat</div>
    <div style="margin-top:6px;"><button class="shop-btn deck-view-btn">View Deck</button></div>
  `;
  drawMessageToast();
}

function cardPrice(cardKey) {
  const c = CARD_TYPES[cardKey];
  return c ? floor(10 + (c.manaCost || 0) * 6) : 15;
}

function getFullDeck() {
  const p = state.player;
  if (!p) return [];
  const startingCounts = {};
  for (const k of (p.startingCards || [])) {
    startingCounts[k] = (startingCounts[k] || 0) + 1;
  }
  const ownedCounts = {};
  for (const k of (p.ownedCards || [])) {
    ownedCounts[k] = (ownedCounts[k] || 0) + 1;
  }
  const keys = new Set([...Object.keys(startingCounts), ...Object.keys(ownedCounts)]);
  return Array.from(keys).map((key) => {
    const card = CARD_TYPES[key];
    if (!card) return null;
    const starting = startingCounts[key] || 0;
    const owned = ownedCounts[key] || 0;
    const count = starting + owned;
    return { key, card, count, removableCount: count };
  }).filter(Boolean);
}

function tryBuyCard(cardKey, price) {
  const p = state.player;
  if (!p || !cardKey) return;
  if (p.gold < price) return setMessage("Not enough gold.");
  const c = CARD_TYPES[cardKey];
  if (!c) return;
  const town = state.towns[p.atTown];
  const idx = (town.shopCards || []).indexOf(cardKey);
  if (idx < 0) return setMessage("That card is not for sale here.");
  p.gold -= price;
  p.ownedCards.push(cardKey);
  town.shopCards.splice(idx, 1);
  setMessage(`Bought ${c.name}.`);
}

function openDeckView() {
  state.deckViewOpen = true;
  document.getElementById("deckViewOverlay").classList.remove("hidden");
  drawDeckView();
}

function closeDeckView() {
  state.deckViewOpen = false;
  document.getElementById("deckViewOverlay").classList.add("hidden");
}

function tryRemoveCard(cardKey) {
  const p = state.player;
  if (!p || !cardKey) return;
  const town = state.towns[p.atTown];
  if ((town.removalsUsed || 0) >= 1) return setMessage("This town allows only 1 removal per visit.");
  if (p.gold < DECK_REMOVE_COST) return setMessage(`Need ${DECK_REMOVE_COST}g to remove a card.`);
  let idx = (p.startingCards || []).indexOf(cardKey);
  if (idx >= 0) {
    p.startingCards.splice(idx, 1);
  } else {
    idx = (p.ownedCards || []).indexOf(cardKey);
    if (idx < 0) return setMessage("That card cannot be removed.");
    p.ownedCards.splice(idx, 1);
  }
  const c = CARD_TYPES[cardKey];
  p.gold -= DECK_REMOVE_COST;
  town.removalsUsed = (town.removalsUsed || 0) + 1;
  setMessage(`Removed ${c ? c.name : cardKey} from deck. (-${DECK_REMOVE_COST}g)`);
  drawDeckView();
}

function drawDeckView() {
  const overlay = document.getElementById("deckViewOverlay");
  const panel = document.getElementById("deckViewPanel");
  if (!state.deckViewOpen || !state.player) {
    overlay.classList.add("hidden");
    return;
  }
  const p = state.player;
  const town = state.towns[p.atTown];
  const totalDeckSize = (p.startingCards || []).length + (p.ownedCards || []).length;
  const canRemove = state.mode === "overworld" && p.targetTown == null &&
    (!town.isEvilSite || town.siteCleared) && (town.removalsUsed || 0) < 1 &&
    totalDeckSize > 0;
  const deck = getFullDeck();
  const total = deck.reduce((sum, e) => sum + e.count, 0);
  let cardsHtml = "";
  for (const { key, card, count, removableCount } of deck) {
    const removable = canRemove && count > 0 && p.gold >= DECK_REMOVE_COST;
    cardsHtml += `<div class="deck-card${removable ? " removable" : ""}" data-key="${key}" data-removable="${!!removable}">
      <div class="cardHeader">${card.quick ? "โšก" : "โฑ๏ธ"} ${card.name} <span>โšก${card.manaCost || 0}</span></div>
      <div class="deck-card-emoji">${card.emoji}</div>
      <div class="cardDesc">${card.description}</div>
      <div class="deck-card-count">ร—${count}</div>
    </div>`;
  }
  const removeHint = canRemove
    ? `<div class="deck-remove-hint">Click a removable card to remove it for ${DECK_REMOVE_COST}g</div>`
    : "";
  panel.innerHTML = `
    <h2>Your Deck (${total} cards)</h2>
    <div class="deck-grid">${cardsHtml}</div>
    ${removeHint}
    <button class="report-btn" id="deckViewClose">Close</button>
  `;
  panel.querySelector("#deckViewClose").addEventListener("click", closeDeckView);
  panel.querySelectorAll(".deck-card.removable").forEach((el) => {
    el.addEventListener("click", () => {
      if (el.dataset.removable === "true") tryRemoveCard(el.dataset.key);
    });
  });
}

function onWorldClick(mx, my) {
  const p = state.player;
  if (!p || p.targetTown != null || state.mode !== "overworld") return;
  const w = screenToWorld(mx, my);
  const nbs = neighborsOf(p.atTown);
  let best = null, bestD = 99999;
  for (const id of nbs) {
    const t = state.towns[id];
    const d = dist(w.x, w.y, t.x, t.y);
    if (d < bestD) { bestD = d; best = id; }
  }
  if (best != null && bestD <= 85) startTravel(best);
}

/* ========== CARD GAME ENCOUNTER ========== */
function createCardGameEncounter(danger, options) {
  const p = state.player;
  const card = {
    danger,
    special: options || null,
    originWorldX: p.x,
    originWorldY: p.y,
    startGold: p.gold,
    startHp: p.hp,
    startMana: p.mana,
    player: { x: 4, y: 4, hp: p.hp, maxHp: p.hpMax, mana: 3, maxMana: 3 },
    enemies: [],
    deck: [],
    hand: [],
    discardPile: [],
    selectedCard: null,
    selectedEnemy: null,
    targeting: false,
    turnInProgress: false,
    enemyRoster: {},
    cardsPlayedThisTurn: 0,
    nextCardQuick: false
  };

  const starting = (p.startingCards || []).map((k) => ({ ...CARD_TYPES[k], effect: getCardEffect(CARD_TYPES[k]) })).filter((c) => c && c.name);
  const owned = (p.ownedCards || []).map((k) => ({ ...CARD_TYPES[k], effect: getCardEffect(CARD_TYPES[k]) })).filter((c) => c && c.name);
  card.deck = [...starting.map((c) => ({ ...c, effect: getCardEffect(c) })), ...owned];
  shuffleCardDeck(card.deck);
  for (let i = 0; i < 3; i++) {
    if (card.deck.length > 0) card.hand.push(card.deck.pop());
  }
  card.player.mana += 1;

  const count = max(1, min(5, floor(1 + danger * 4)));
  const center = floor(CARD_GRID_SIZE / 2);
  const eligible = CARD_ENEMY_TYPES.filter((e) => (e.dangerTier || 0) <= danger + 0.05);
  const pool = eligible.length > 0 ? eligible : CARD_ENEMY_TYPES.slice(0, 3);
  for (let i = 0; i < count; i++) {
    let x, y;
    do {
      x = floor(random(CARD_GRID_SIZE));
      y = floor(random(CARD_GRID_SIZE));
    } while ((x === center && y === center) || card.enemies.some((e) => e.x === x && e.y === y) || (abs(x - center) < 2 && abs(y - center) < 2));
    const tiered = pool.filter((e) => (e.dangerTier || 0) <= danger + 0.08);
    const pickPool = tiered.length > 0 ? tiered : pool;
    const et = random(pickPool);
    const scale = 0.7 + danger * 0.6;
    const hp = max(1, floor(et.hp * scale));
    const dmg = max(1, floor(et.damage * scale));
    const enemy = { x, y, type: "enemy", emoji: et.emoji, name: et.name, hp, damage: dmg, maxHp: hp, frozen: 0 };
    card.enemies.push(enemy);
    card.enemyRoster[et.name] = (card.enemyRoster[et.name] || 0) + 1;
  }

  state.encounter = card;
  document.getElementById("cardUiRoot").classList.add("visible");
  document.documentElement.style.setProperty("--card-ui-height", `${CARD_UI_HEIGHT}px`);
  document.documentElement.style.setProperty("--card-stats-width", `${CARD_STATS_WIDTH}px`);
  document.documentElement.style.setProperty("--card-enemy-width", `${CARD_ENEMY_WIDTH}px`);
  state.lastHandRenderKey = "";
  setMessage(`Encounter! Danger ${(danger * 100).toFixed(0)}%`);
}

function cardDist(x1, y1, x2, y2) {
  return dist(x1, y1, x2, y2);
}

function cardEnemiesInRange(enc, cx, cy, radius) {
  return enc.enemies.filter((e) => cardDist(cx, cy, e.x, e.y) <= radius + CARD_DIST_FUDGE);
}

function cardKnockback(enc, enemy, squares) {
  const dx = enemy.x - enc.player.x;
  const dy = enemy.y - enc.player.y;
  if (dx === 0 && dy === 0) return;
  const nx = enemy.x + Math.sign(dx) * squares;
  const ny = enemy.y + Math.sign(dy) * squares;
  const clampedX = constrain(nx, 0, CARD_GRID_SIZE - 1);
  const clampedY = constrain(ny, 0, CARD_GRID_SIZE - 1);
  if (!cardIsPositionOccupied(enc, clampedX, clampedY) || (clampedX === enemy.x && clampedY === enemy.y)) {
    enemy.x = clampedX;
    enemy.y = clampedY;
  } else {
    for (let s = squares - 1; s >= 1; s--) {
      const tx = enemy.x + Math.sign(dx) * s;
      const ty = enemy.y + Math.sign(dy) * s;
      const cx = constrain(tx, 0, CARD_GRID_SIZE - 1);
      const cy = constrain(ty, 0, CARD_GRID_SIZE - 1);
      if (!cardIsPositionOccupied(enc, cx, cy) || (cx === enemy.x && cy === enemy.y)) {
        enemy.x = cx;
        enemy.y = cy;
        return;
      }
    }
  }
}

function getCardEffect(card) {
  if (card.name === "Fireball") return (enc, target, tx, ty) => {
    const cx = tx != null ? tx : (target ? target.x : enc.player.x);
    const cy = ty != null ? ty : (target ? target.y : enc.player.y);
    const targets = card.aoe ? cardEnemiesInRange(enc, cx, cy, card.aoe) : (target ? [target] : []);
    for (const e of targets) cardDamageEnemy(enc, e, card.damage || 0);
    if (card.selfDamage && cardDist(enc.player.x, enc.player.y, cx, cy) <= (card.aoe || 0) + CARD_DIST_FUDGE) {
      enc.player.hp -= card.damage || 0;
    }
  };
  if (card.name === "Magic Missile") return (enc, target) => target && cardDamageEnemy(enc, target, card.damage || 0);
  if (card.name === "Frost Bolt") return (enc, target) => {
    if (target) { cardDamageEnemy(enc, target, card.damage || 0); target.frozen = max(target.frozen || 0, card.freeze || 0); }
  };
  if (card.name === "Frost Nova") return (enc) => {
    const inAoe = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
    for (const e of inAoe) { cardDamageEnemy(enc, e, card.damage || 0); e.frozen = max(e.frozen || 0, card.freeze || 0); }
  };
  if (card.name === "Squirt") return (enc, target) => target && cardKnockback(enc, target, card.knockback || 0);
  if (card.name === "Hellfire") return (enc) => {
    const inRange = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
    for (const e of inRange) cardDamageEnemy(enc, e, card.damage || 0);
  };
  if (card.name === "Tsunami") return (enc) => {
    const inRange = cardEnemiesInRange(enc, enc.player.x, enc.player.y, card.aoe || 0);
    for (const e of inRange) cardKnockback(enc, e, card.knockback || 0);
  };
  if (card.name === "Think") return (enc) => cardDrawCards(enc, card.draw || 0);
  if (card.name === "Ruminate") return (enc) => cardDrawCards(enc, card.draw || 0);
  if (card.name === "Ideate") return (enc) => cardDrawCards(enc, card.draw || 0);
  if (card.name === "Ritual") return (enc) => { enc.player.mana += card.manaGain || 0; };
  if (card.name === "Flash") return (enc) => { enc.player.mana += card.manaGain || 0; };
  if (card.name === "Brain Surge") return (enc) => cardDrawCards(enc, (card.drawPerCard || 0) * enc.cardsPlayedThisTurn);
  if (card.name === "Barrage") return (enc, target) => {
    if (target) cardDamageEnemy(enc, target, (card.damagePerCard || 0) * enc.cardsPlayedThisTurn);
  };
  if (card.name === "Haste") return (enc) => { enc.nextCardQuick = true; };
  if (card.name === "Novice Bolt") return (enc, target) => target && cardDamageEnemy(enc, target, card.damage || 0);
  if (card.name === "Frantic Planning") return (enc) => cardDrawCards(enc, card.draw || 0);
  if (card.name === "Faint Glimmer") return (enc) => { enc.player.mana += card.manaGain || 0; };
  return (enc) => {};
}

function cardDamageEnemy(enc, enemy, amount) {
  enemy.hp -= amount;
  if (enemy.hp <= 0) {
    enc.enemies = enc.enemies.filter((e) => e !== enemy);
    if (enc.selectedEnemy === enemy) enc.selectedEnemy = null;
  }
}

function cardDrawCards(enc, count) {
  for (let i = 0; i < count; i++) {
    if (enc.deck.length === 0) {
      enc.deck = [...enc.discardPile];
      enc.discardPile = [];
      shuffleCardDeck(enc.deck);
    }
    if (enc.deck.length > 0) enc.hand.push(enc.deck.pop());
  }
}

function shuffleCardDeck(deck) {
  for (let i = deck.length - 1; i > 0; i--) {
    const j = floor(random(i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
}

function cardIsPositionOccupied(enc, x, y) {
  if (enc.player.x === x && enc.player.y === y) return true;
  return enc.enemies.some((e) => e.x === x && e.y === y);
}

function cardApplyAttacksOfOpportunity(enc) {
  const px = enc.player.x;
  const py = enc.player.y;
  for (let dx = -1; dx <= 1; dx++) {
    for (let dy = -1; dy <= 1; dy++) {
      if (dx === 0 && dy === 0) continue;
      const ex = px + dx;
      const ey = py + dy;
      const enemy = enc.enemies.find((e) => e.x === ex && e.y === ey);
      if (enemy) enc.player.hp -= enemy.damage;
    }
  }
}

function cardMoveEnemyTowardPlayer(enc, enemy) {
  const dx = enc.player.x - enemy.x;
  const dy = enc.player.y - enemy.y;
  if (Math.abs(dx) + Math.abs(dy) === 1) {
    enc.player.hp -= enemy.damage;
    return;
  }
  if (Math.abs(dx) > Math.abs(dy)) {
    const nx = enemy.x + Math.sign(dx);
    if (!cardIsPositionOccupied(enc, nx, enemy.y)) enemy.x = nx;
  } else if (dy !== 0) {
    const ny = enemy.y + Math.sign(dy);
    if (!cardIsPositionOccupied(enc, enemy.x, ny)) enemy.y = ny;
  }
}

function cardEndTurn(enc, drawCard = false) {
  enc.turnInProgress = true;
  for (const enemy of enc.enemies) {
    if (enemy.frozen > 0) { enemy.frozen--; continue; }
    cardMoveEnemyTowardPlayer(enc, enemy);
  }
  if (drawCard) cardDrawCards(enc, 1);
  enc.cardsPlayedThisTurn = 0;
  enc.turnInProgress = false;
}

function cardPlayCard(enc, card, targetX, targetY) {
  let target = null;
  const needsTargetCell = (card.range > 0 || card.aoe) && !card.allInRange;
  if (needsTargetCell) {
    target = enc.enemies.find((e) => e.x === targetX && e.y === targetY);
    const d = cardDist(enc.player.x, enc.player.y, targetX, targetY);
    if (d > (card.range || 0) + CARD_DIST_FUDGE) return;
    if (!target && (card.damage || card.knockback || card.damagePerCard) && !card.aoe) return;
  }
  enc.player.mana -= card.manaCost;
  enc.hand = enc.hand.filter((c) => c !== card);
  enc.discardPile.push(card);
  enc.cardsPlayedThisTurn++;
  const effectiveQuick = card.quick || enc.nextCardQuick;
  if (enc.nextCardQuick) enc.nextCardQuick = false;
  card.effect(enc, target, targetX, targetY);
  enc.selectedCard = null;
  enc.targeting = false;
  if (!effectiveQuick) cardEndTurn(enc);
}

function cardCheckEncounterEnd() {
  const enc = state.encounter;
  if (!enc) return null;
  if (enc.player.hp <= 0) return false;
  if (enc.enemies.length === 0) return true;
  return null;
}

function beginEncounterTransition(danger, options) {
  if (state.mode !== "overworld") return;
  state.mode = "transition_to_encounter";
  state.transition = { phase: "out", t: 0, outDuration: 0.8, inDuration: 0.9, danger, options };
}

function drawBlackOverlay(alpha) {
  if (alpha <= 0) return;
  const ctx = state.gameCtx;
  if (!ctx) return;
  ctx.fillStyle = `rgba(0,0,0,${constrain(alpha, 0, 255) / 255})`;
  ctx.fillRect(0, 0, width, height);
}

function updateEncounterTransition(dt) {
  const tr = state.transition;
  if (!tr) return;
  if (tr.phase === "out") {
    tr.t += dt;
    const p = constrain(tr.t / tr.outDuration, 0, 1);
    state.cameraZoomMul = lerp(1, 1.34, easeInOutCubic(p));
    drawWorld();
    drawHud();
    drawBlackOverlay(Math.pow(p, 1.05) * 255);
    if (p >= 1) {
      createCardGameEncounter(tr.danger, tr.options || null);
      tr.phase = "in";
      tr.t = 0;
      state.cameraZoomMul = 1;
    }
    return;
  }
  tr.t += dt;
  const p = constrain(tr.t / tr.inDuration, 0, 1);
  state.battleZoomMul = lerp(1.22, 1, easeInOutCubic(p));
  drawEncounterWorld();
  drawEncounterCardUi();
  drawBlackOverlay((1 - p) * 255);
  if (p >= 1) {
    state.battleZoomMul = 1;
    state.mode = "encounter";
    state.transition = null;
  }
}

function endCardEncounter(victory) {
  const enc = state.encounter;
  if (!enc || !state.player) return;
  const special = enc.special;
  if (victory) {
    const reward = floor(6 + enc.danger * 24 + random(0, 8));
    const bonusGold = special?.bonusGold || 0;
    const bonusMana = special?.bonusMana || 0;
    state.player.gold += reward + bonusGold;
    state.player.mana = min(state.player.manaMax, state.player.mana + 10 + enc.danger * 15 + bonusMana);
    state.player.hp = enc.player.hp;
    if (special?.siteTownId != null) {
      const t = state.towns[special.siteTownId];
      if (t) t.siteCleared = true;
    }
    setMessage(`Encounter won! +${reward + bonusGold}g`);
  } else {
    const loss = floor(8 + enc.danger * 20);
    state.player.gold = max(0, state.player.gold - loss);
    state.player.hp = max(1, floor(state.player.hpMax * 0.45));
    state.player.mana = min(state.player.mana, 40);
    state.player.targetTown = null;
    state.player.progress = 0;
    const at = state.towns[state.player.atTown];
    state.player.x = at.x;
    state.player.y = at.y;
    setMessage(`Defeated in encounter. Lost ${loss}g and retreated.`);
  }
  const enemyLines = Object.entries(enc.enemyRoster)
    .sort((a, b) => b[1] - a[1])
    .map(([name, count]) => `${name} x${count}`);
  state.pendingEncounterReport = {
    outcome: victory ? "Victory" : "Defeat",
    type: special?.label || "Roaming encounter",
    dangerPct: (enc.danger * 100).toFixed(0),
    enemies: enemyLines,
    goldDelta: state.player.gold - enc.startGold,
    hpDelta: state.player.hp - enc.startHp
  };
  state.nextEncounterAt = state.worldTime + 3.0;
  document.getElementById("cardUiRoot").classList.remove("visible");
  state.hoveredCard = null;
  state.mode = "transition_to_overworld";
  state.transition = { phase: "out", t: 0, outDuration: 0.55, inDuration: 0.72 };
}

function getCardGameArea() {
  const gameW = width;
  const gameH = max(1, height - CARD_UI_HEIGHT);
  const gridAreaW = gameW - CARD_GRID_PADDING * 2;
  const gridAreaH = gameH - CARD_GRID_PADDING * 2;
  const cellSize = min(gridAreaW, gridAreaH) / CARD_GRID_SIZE;
  const gridPxW = CARD_GRID_SIZE * cellSize;
  const gridPxH = CARD_GRID_SIZE * cellSize;
  const offsetX = (gameW - gridPxW) / 2;
  const offsetY = (gameH - gridPxH) / 2;
  return { gameW, gameH, vw: CARD_GRID_SIZE, vh: CARD_GRID_SIZE, cellSize, offsetX, offsetY };
}

function cardWorldToScreen(wx, wy, area) {
  return {
    x: area.offsetX + wx * area.cellSize + area.cellSize / 2,
    y: area.offsetY + wy * area.cellSize + area.cellSize / 2
  };
}

function cardScreenToWorld(sx, sy, area) {
  const gx = floor((sx - area.offsetX) / area.cellSize);
  const gy = floor((sy - area.offsetY) / area.cellSize);
  return { gx, gy, x: gx, y: gy };
}

function drawEncounterWorld() {
  const enc = state.encounter;
  const ctx = state.gameCtx;
  if (!enc || !ctx) return;

  const area = getCardGameArea();

  ctx.clearRect(0, 0, width, height);
  const encTerrainScale = min(area.gameW, area.gameH) / 75 * state.battleZoomMul;
  renderTerrainRenderer(enc.originWorldX, enc.originWorldY, encTerrainScale);

  const encWorldScale = encTerrainScale * (width / Math.max(window.innerWidth, 1));
  const halfViewW = width * 0.5 / encWorldScale;
  const halfViewH = height * 0.5 / encWorldScale;
  const encCenterX = width * 0.5;
  const encCenterY = height * 0.5;
  ctx.save();
  ctx.translate(encCenterX, encCenterY);
  ctx.scale(encWorldScale, encWorldScale);
  ctx.translate(-enc.originWorldX, -enc.originWorldY);
  const playerTown = state.player ? state.player.atTown : -1;
  for (const e of state.edges) {
    const a = state.towns[e.a], b = state.towns[e.b];
    const connectedToCurrent = e.a === playerTown || e.b === playerTown;
    ctx.strokeStyle = connectedToCurrent ? "rgba(180,140,90,0.9)" : "rgba(120,95,65,0.6)";
    ctx.lineWidth = (connectedToCurrent ? 3 : 2) / encWorldScale;
    ctx.lineCap = "round";
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
  ctx.restore();

  ctx.save();
  ctx.translate(area.offsetX, area.offsetY);
  for (let gx = 0; gx < area.vw; gx++) {
    for (let gy = 0; gy < area.vh; gy++) {
      ctx.strokeStyle = "rgba(50,50,70,0.7)";
      ctx.lineWidth = 1;
      ctx.strokeRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
    }
  }

  const drawEntity = (wx, wy, emoji, opts) => {
    if (wx < 0 || wx >= area.vw || wy < 0 || wy >= area.vh) return;
    if (opts && opts.frozen > 0) {
      ctx.save();
      ctx.globalAlpha = 0.7;
      ctx.fillStyle = "rgba(150,200,255,0.5)";
      ctx.fillRect(wx * area.cellSize, wy * area.cellSize, area.cellSize, area.cellSize);
      ctx.restore();
    }
    const lx = wx * area.cellSize + area.cellSize / 2;
    const ly = wy * area.cellSize + area.cellSize / 2;
    if (opts && opts.maxHp != null && opts.hp < opts.maxHp) {
      const barW = area.cellSize * 0.8;
      const barH = 4;
      const left = lx - barW / 2;
      const top = ly - area.cellSize * 0.5;
      ctx.fillStyle = "rgba(40,40,50,0.9)";
      ctx.fillRect(left, top, barW, barH);
      ctx.fillStyle = opts.hp <= 0 ? "rgb(80,80,80)" : "rgb(220,60,60)";
      ctx.fillRect(left, top, barW * (opts.hp / opts.maxHp), barH);
      ctx.strokeStyle = "rgba(255,255,255,0.4)";
      ctx.lineWidth = 1;
      ctx.strokeRect(left, top, barW, barH);
    }
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.font = `${max(28, area.cellSize * 0.7)}px monospace`;
    ctx.fillStyle = "#e6e6e6";
    ctx.fillText(emoji, lx, ly);
  };

  drawEntity(enc.player.x, enc.player.y, "๐Ÿง™", { hp: enc.player.hp, maxHp: enc.player.maxHp });
  for (const enemy of enc.enemies) drawEntity(enemy.x, enemy.y, enemy.emoji, { hp: enemy.hp, maxHp: enemy.maxHp, frozen: enemy.frozen || 0 });

  const hovered = state.hoveredCard;
  if (!enc.targeting && hovered && enc.hand.includes(hovered)) {
    const px = enc.player.x, py = enc.player.y;
    if (hovered.aoe && hovered.allInRange) {
      ctx.fillStyle = "rgba(255,100,80,0.35)";
      for (let gx = 0; gx < area.vw; gx++) {
        for (let gy = 0; gy < area.vh; gy++) {
          if (cardDist(gx, gy, px, py) <= hovered.aoe + CARD_DIST_FUDGE) {
            ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
          }
        }
      }
    }
    if (hovered.range > 0) {
      ctx.fillStyle = "rgba(255,255,255,0.3)";
      for (let gx = 0; gx < area.vw; gx++) {
        for (let gy = 0; gy < area.vh; gy++) {
          if (cardDist(gx, gy, px, py) <= hovered.range + CARD_DIST_FUDGE) {
            ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
          }
        }
      }
    }
  }

  if (enc.targeting && enc.selectedCard) {
    const card = enc.selectedCard;
    const px = enc.player.x, py = enc.player.y;
    ctx.fillStyle = "rgba(255,255,255,0.3)";
    for (let gx = 0; gx < area.vw; gx++) {
      for (let gy = 0; gy < area.vh; gy++) {
        if (cardDist(gx, gy, px, py) <= card.range + CARD_DIST_FUDGE) {
          ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
        }
      }
    }
    const mx = state.cardGameMouseX;
    const my = state.cardGameMouseY;
    if (mx >= area.offsetX && my >= area.offsetY && mx < area.offsetX + area.vw * area.cellSize && my < area.offsetY + area.vh * area.cellSize) {
      const pt = cardScreenToWorld(mx, my, area);
      if (pt.gx >= 0 && pt.gx < area.vw && pt.gy >= 0 && pt.gy < area.vh) {
        if (cardDist(pt.x, pt.y, px, py) <= card.range + CARD_DIST_FUDGE) {
          ctx.fillStyle = "rgba(255,255,100,0.4)";
          ctx.fillRect(pt.gx * area.cellSize, pt.gy * area.cellSize, area.cellSize, area.cellSize);
          if (card.aoe && !card.allInRange) {
            ctx.fillStyle = "rgba(255,100,80,0.35)";
            for (let gx = 0; gx < area.vw; gx++) {
              for (let gy = 0; gy < area.vh; gy++) {
                if (cardDist(gx, gy, pt.x, pt.y) <= card.aoe + CARD_DIST_FUDGE) {
                  ctx.fillRect(gx * area.cellSize, gy * area.cellSize, area.cellSize, area.cellSize);
                }
              }
            }
          }
        }
      }
    }
  }
  ctx.restore();

  ctx.save();
  ctx.translate(encCenterX, encCenterY);
  ctx.scale(encWorldScale, encWorldScale);
  ctx.translate(-enc.originWorldX, -enc.originWorldY);
  const m = TREE_EMOJI_CULL_MARGIN;
  const gridLeft = area.offsetX - m;
  const gridRight = area.offsetX + area.vw * area.cellSize + m;
  const gridTop = area.offsetY - m;
  const gridBottom = area.offsetY + area.vh * area.cellSize + m;
  ctx.font = "20px 'Segoe UI Emoji', sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillStyle = "#e6e6e6";
  for (const t of state.forestTrees) {
    if (t.x < enc.originWorldX - halfViewW - 60 || t.x > enc.originWorldX + halfViewW + 60) continue;
    if (t.y < enc.originWorldY - halfViewH - 60 || t.y > enc.originWorldY + halfViewH + 60) continue;
    const sx = encCenterX + (t.x - enc.originWorldX) * encWorldScale;
    const sy = encCenterY + (t.y - enc.originWorldY) * encWorldScale;
    if (sx >= gridLeft && sx <= gridRight && sy >= gridTop && sy <= gridBottom) continue;
    ctx.fillText(t.emoji, t.x, t.y);
  }
  ctx.restore();
}

function cardHtml(card) {
  const actionTag = card.quick ? "quick" : "1 action";
  return `<div class="cardHeader"><span>${card.quick ? "โšก" : "โฑ๏ธ"} ${card.name}</span><span>โšก${card.manaCost}</span></div>
<div class="cardEmoji">${card.emoji}</div><div class="cardDesc">${card.description}</div><div class="cardDesc" style="font-size:10px;margin-top:4px;">${actionTag}</div>`;
}

function handRenderKey(enc) {
  const selectedIndex = enc.hand.indexOf(enc.selectedCard);
  const handSignature = enc.hand.map((c) => `${c.name}|${c.manaCost}|${c.quick}`).join(";");
  const cardW = Math.min(140, (width - CARD_STATS_WIDTH - CARD_ENEMY_WIDTH - 60 - (9 * CARD_HAND_PADDING)) / 10);
  const cardH = cardW * 1.4;
  return [cardW, cardH, enc.player.mana, selectedIndex, handSignature].join("::");
}

function drawEncounterCardUi() {
  const enc = state.encounter;
  if (!enc) return;

  const statsPanel = document.getElementById("cardStatsPanel");
  const enemyPanel = document.getElementById("cardEnemyPanel");
  const handPanel = document.getElementById("cardHandPanel");

  statsPanel.innerHTML = `
    <div>HP: ${enc.player.hp}/${enc.player.maxHp}</div>
    <div style="margin-top:4px;">โšก Mana: ${enc.player.mana}</div>
    <div style="margin-top:4px;">Spells played: ${enc.cardsPlayedThisTurn}</div>
    <div style="margin-top:4px;">๐Ÿ“š Deck: ${enc.deck.length}</div>
    <div style="margin-top:4px;">๐Ÿ—‘๏ธ Discard: ${enc.discardPile.length}</div>
    <div class="card-help">
      <div>WASD/Arrows: Move</div>
      <div>.: Wait</div>
      <div>Hover enemy: Info</div>
    </div>
  `;

  if (!enc.selectedEnemy) {
    enemyPanel.innerHTML = `<div style="margin-top:72px;color:rgb(180,180,180);font-size:11px;">Hover an enemy</div><div style="color:rgb(180,180,180);font-size:11px;">to view info</div>`;
  } else {
    const e = enc.selectedEnemy;
    enemyPanel.innerHTML = `<div>${e.name}</div><div class="enemyEmoji">${e.emoji}</div><div>HP: ${e.hp}/${e.maxHp}</div><div style="margin-top:6px;">Damage: ${e.damage}</div>${(e.frozen || 0) > 0 ? `<div style="margin-top:4px;color:rgb(150,200,255);">Frozen: ${e.frozen}</div>` : ""}`;
  }

  const key = handRenderKey(enc);
  if (key !== state.lastHandRenderKey) {
    state.lastHandRenderKey = key;
    state.hoveredCard = null;
    const cardW = Math.min(140, (width - CARD_STATS_WIDTH - CARD_ENEMY_WIDTH - 60 - 9 * CARD_HAND_PADDING) / 10);
    const cardH = cardW * 1.4;
    handPanel.innerHTML = "";
    handPanel.style.gap = `${CARD_HAND_PADDING}px`;
    for (const card of enc.hand) {
      const el = document.createElement("div");
      el.className = "card";
      el.style.width = `${cardW}px`;
      el.style.height = `${cardH}px`;
      el.innerHTML = cardHtml(card);
      const selected = enc.selectedCard === card;
      const disabled = card.manaCost > enc.player.mana;
      if (selected) el.classList.add("selected");
      if (disabled) el.classList.add("disabled");
      el.addEventListener("mouseenter", () => { state.hoveredCard = card; });
      el.addEventListener("mouseleave", () => { state.hoveredCard = null; });
      el.addEventListener("click", () => {
        if (disabled) return;
        if (card.range === 0) {
          cardPlayCard(enc, card, enc.player.x, enc.player.y);
          return;
        }
        const wasSelected = enc.selectedCard === card;
        enc.selectedCard = wasSelected ? null : card;
        enc.targeting = !wasSelected;
      });
      handPanel.appendChild(el);
    }
  }
}

function updateReturnToOverworldTransition(dt) {
  const tr = state.transition;
  if (!tr) return;
  if (tr.phase === "out") {
    tr.t += dt;
    const p = constrain(tr.t / tr.outDuration, 0, 1);
    state.battleZoomMul = lerp(1, 1.2, easeInOutCubic(p));
    drawEncounterWorld();
    drawEncounterCardUi();
    drawMessageToast();
    drawBlackOverlay(Math.pow(p, 1.05) * 255);
    if (p >= 1) {
      state.encounter = null;
      tr.phase = "in";
      tr.t = 0;
      state.cameraZoomMul = 1.2;
      state.battleZoomMul = 1;
    }
    return;
  }
  tr.t += dt;
  const p = constrain(tr.t / tr.inDuration, 0, 1);
  state.cameraZoomMul = lerp(1.2, 1, easeInOutCubic(p));
  drawWorld();
  drawHud();
  drawBlackOverlay((1 - p) * 255);
  if (p >= 1) {
    state.mode = "overworld";
    state.postEncounterReport = state.pendingEncounterReport;
    state.pendingEncounterReport = null;
    state.transition = null;
    state.cameraZoomMul = 1;
    state.battleZoomMul = 1;
  }
}

function formatSigned(n) {
  const rounded = Math.round(n);
  return rounded > 0 ? `+${rounded}` : `${rounded}`;
}

function drawEncounterReportModal() {
  const overlay = document.getElementById("encounterReportOverlay");
  const panel = document.getElementById("encounterReportPanel");
  const rep = state.postEncounterReport;
  if (!rep || state.mode !== "overworld") {
    overlay.classList.add("hidden");
    state.lastReportRendered = null;
    return;
  }
  overlay.classList.remove("hidden");
  if (state.lastReportRendered === rep) return;
  state.lastReportRendered = rep;
  const enemyLines = rep.enemies.length === 0 ? "<div>- None</div>" : rep.enemies.map((line) => `<div>- ${line}</div>`).join("");
  panel.innerHTML = `
    <h2>Encounter Report</h2>
    <div style="font-size:15px;margin-bottom:8px;">Outcome: ${rep.outcome}    Type: ${rep.type}    Danger: ${rep.dangerPct}%</div>
    <div style="font-size:15px;margin-bottom:8px;">Gold: ${formatSigned(rep.goldDelta)}    HP: ${formatSigned(rep.hpDelta)}</div>
    <div style="font-size:16px;margin:12px 0 8px 0;">Enemies Encountered:</div>
    <div style="font-size:14px;line-height:1.5;">${enemyLines}</div>
    <button class="report-btn">Continue</button>
  `;
}

function drawMessageToast() {
  const toast = document.getElementById("messageToast");
  if (state.messageTimer <= 0) {
    toast.classList.add("hidden");
    return;
  }
  const isEncounter = state.mode === "encounter" || state.mode === "transition_to_encounter" || (state.transition && state.transition.phase === "in");
  toast.style.bottom = isEncounter ? `${CARD_UI_HEIGHT + 12}px` : "12px";
  toast.classList.remove("hidden");
  toast.textContent = state.message;
  toast.style.maxWidth = `${min(680, width - 24)}px`;
}

function resizeGameCanvas() {
  const canvas = document.getElementById("gameCanvas");
  width = Math.max(1, window.innerWidth);
  height = Math.max(1, window.innerHeight);
  canvas.width = width;
  canvas.height = height;
  state.gameCanvas = canvas;
  state.gameCtx = canvas.getContext("2d");
}

function init() {
  resizeGameCanvas();
  state.terrainRenderer = createTerrainRenderer();
  resizeTerrainRenderer();
  generateMap();

  const reportOverlay = document.getElementById("encounterReportOverlay");

  reportOverlay.addEventListener("click", () => {
    if (state.postEncounterReport) {
      state.postEncounterReport = null;
      reportOverlay.classList.add("hidden");
    }
  });

  document.getElementById("hudPanel").addEventListener("click", (e) => {
    const buyBtn = e.target.closest(".shop-btn[data-card]");
    if (buyBtn) {
      if (state.mode === "overworld") tryBuyCard(buyBtn.dataset.card, parseInt(buyBtn.dataset.price, 10));
      return;
    }
    if (e.target.closest(".deck-view-btn")) openDeckView();
  });

  document.getElementById("deckViewOverlay").addEventListener("click", (e) => {
    if (e.target === document.getElementById("deckViewOverlay")) closeDeckView();
  });

  window.addEventListener("mousedown", onMouseDown);
  window.addEventListener("mouseup", onMouseUp);
  window.addEventListener("mousemove", onMouseMove);
  window.addEventListener("keydown", onKeyDown);
  window.addEventListener("keyup", onKeyUp);
  window.addEventListener("resize", () => {
    resizeGameCanvas();
    resizeTerrainRenderer();
  });

  let lastTime = performance.now();
  function loop(t) {
    const dt = Math.min((t - lastTime) / 1000, 0.05);
    lastTime = t;
    tick(dt);
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
}

document.addEventListener("DOMContentLoaded", init);

function tick(dt) {
  frameCount++;
  state.worldTime += dt;
  if (state.messageTimer > 0) state.messageTimer -= dt;

  if (state.mode === "overworld") {
    if (state.player) updateTravel(dt);
    drawWorld();
    drawHud();
    drawEncounterReportModal();
  } else if (state.mode === "transition_to_encounter") {
    updateEncounterTransition(dt);
  } else if (state.mode === "transition_to_overworld") {
    updateReturnToOverworldTransition(dt);
  } else if (state.mode === "encounter") {
    const enc = state.encounter;
    const result = cardCheckEncounterEnd();
    if (result === true) endCardEncounter(true);
    else if (result === false) endCardEncounter(false);
    else {
      drawEncounterWorld();
      drawEncounterCardUi();
      drawMessageToast();
    }
  }

  state.input.keyDownEvents.clear();
  state.input.lmbDown = false;
  state.input.lmbUp = false;
}

function onMouseDown(e) {
  state.input.heldLMB = true;
  state.input.lmbDown = true;
  state.mouseX = e.clientX;
  state.mouseY = e.clientY;

  if (state.mode === "overworld") {
    if (state.postEncounterReport) return; // handled by overlay click
    if (e.target.closest("#hudPanel, #cardUiRoot, #encounterReportOverlay, #deckViewOverlay")) return;
    onWorldClick(state.mouseX, state.mouseY);
    return;
  }

  if (state.mode === "encounter") {
    const enc = state.encounter;
    if (!enc) return;
    const area = getCardGameArea();
    if (state.mouseY >= area.gameH) return;
    if (state.mouseX < area.offsetX || state.mouseX >= area.offsetX + area.vw * area.cellSize ||
        state.mouseY < area.offsetY || state.mouseY >= area.offsetY + area.vh * area.cellSize) return;

    const pt = cardScreenToWorld(state.mouseX, state.mouseY, area);

    if (enc.targeting && enc.selectedCard) {
      if (pt.gx >= 0 && pt.gx < area.vw && pt.gy >= 0 && pt.gy < area.vh) {
        const d = cardDist(pt.x, pt.y, enc.player.x, enc.player.y);
        if (d <= enc.selectedCard.range + CARD_DIST_FUDGE) {
          cardPlayCard(enc, enc.selectedCard, pt.x, pt.y);
        }
      }
      return;
    }
  }
}

function onMouseUp() {
  state.input.heldLMB = false;
  state.input.lmbUp = true;
}

function onMouseMove(e) {
  state.mouseX = e.clientX;
  state.mouseY = e.clientY;
  const area = getCardGameArea();
  if (state.mouseY < area.gameH) {
    state.cardGameMouseX = state.mouseX;
    state.cardGameMouseY = state.mouseY;
    const enc = state.encounter;
    if (enc && (state.mode === "encounter" || state.mode === "transition_to_encounter" || state.mode === "transition_to_overworld")) {
      if (state.mouseX >= area.offsetX && state.mouseX < area.offsetX + area.vw * area.cellSize &&
          state.mouseY >= area.offsetY && state.mouseY < area.offsetY + area.vh * area.cellSize) {
        const pt = cardScreenToWorld(state.mouseX, state.mouseY, area);
        enc.selectedEnemy = enc.enemies.find((e) => e.x === pt.x && e.y === pt.y) || null;
      } else {
        enc.selectedEnemy = null;
      }
    }
  } else {
    state.cardGameMouseX = -1;
    state.cardGameMouseY = -1;
    if (state.encounter) state.encounter.selectedEnemy = null;
  }
}

function onKeyDown(e) {
  const k = (e.key || "").toLowerCase();
  state.input.heldKeys.add(k);
  state.input.keyDownEvents.add(k);

  if (state.mode === "overworld") {
    if (state.deckViewOpen && k === "escape") {
      closeDeckView();
      e.preventDefault();
      return;
    }
    if (state.postEncounterReport && (k === " " || k === "enter" || k === "escape")) {
      state.postEncounterReport = null;
      document.getElementById("encounterReportOverlay").classList.add("hidden");
      e.preventDefault();
      return;
    }
    if (k === "r") { generateMap(); e.preventDefault(); return; }
    if (k === "e") {
      state.encountersEnabled = !state.encountersEnabled;
      setMessage(`Encounters ${state.encountersEnabled ? "enabled" : "disabled"}.`);
      e.preventDefault();
      return;
    }
  }

  if (state.mode === "encounter") {
    const enc = state.encounter;
    if (!enc || enc.turnInProgress) return;

    if (k === ".") {
      cardEndTurn(enc, true);
      e.preventDefault();
      return;
    }

    const deltas = {
      w: [0, -1], a: [-1, 0], s: [0, 1], d: [1, 0],
      arrowup: [0, -1], arrowleft: [-1, 0], arrowdown: [0, 1], arrowright: [1, 0]
    };
    const delta = deltas[k];
    if (delta) {
      const nx = enc.player.x + delta[0];
      const ny = enc.player.y + delta[1];
      if (nx >= 0 && nx < CARD_GRID_SIZE && ny >= 0 && ny < CARD_GRID_SIZE) {
        const enemy = enc.enemies.find((e) => e.x === nx && e.y === ny);
        if (enemy) cardDamageEnemy(enc, enemy, 1);
        else {
          enc.player.x = nx;
          enc.player.y = ny;
          cardApplyAttacksOfOpportunity(enc);
        }
        cardEndTurn(enc, true);
      }
      e.preventDefault();
    }
  }
}

function onKeyUp(e) {
  state.input.heldKeys.delete((e.key || "").toLowerCase());
}
</script>
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net