🎮ArcadeLab

Bitter Horizon — A Western Requiem

by QuantumFox55
2429 lines95.2 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bitter Horizon — A Western Requiem</title>
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:#1a1410;
    font-family:Georgia,'Times New Roman',serif;color:#e8dcc0;user-select:none}
  #game{display:block;cursor:none}
  .overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
    flex-direction:column;text-align:center;padding:24px;z-index:10;overflow-y:auto;
    background:radial-gradient(circle at 50% 40%, rgba(70,46,26,.88), rgba(8,6,4,.98))}
  .hidden{display:none!important}
  h1{font-size:clamp(30px,7vw,62px);letter-spacing:.09em;margin:0 0 6px;color:#e8c98a;
    text-shadow:0 3px 0 #2a1a10, 0 0 34px rgba(232,201,138,.28)}
  h1.small{font-size:clamp(24px,5vw,42px)}
  h1.gold{color:#ffdf9a;text-shadow:0 3px 0 #2a1a10, 0 0 46px rgba(255,223,154,.5)}
  h1.red{color:#e05050;text-shadow:0 3px 0 #2a0a0a, 0 0 46px rgba(224,80,80,.5)}
  .sub{color:#b09a72;letter-spacing:.3em;text-transform:uppercase;font-size:11px;margin-bottom:20px}
  .keys{max-width:660px;line-height:2;color:#cbb891;font-size:14px}
  .keys b{color:#e8c98a}
  button{margin-top:12px;background:#3a2718;color:#e8c98a;border:1px solid #7a5a34;
    padding:12px 32px;font-family:inherit;font-size:15px;letter-spacing:.14em;
    text-transform:uppercase;cursor:pointer;transition:.15s}
  button:hover{background:#54381f;border-color:#e8c98a}
  button:disabled{opacity:.35;cursor:not-allowed}
  button:disabled:hover{background:#3a2718;border-color:#7a5a34}
  .bankLine{margin:14px 0;font-size:18px;color:#e8c98a;letter-spacing:.06em}
  .bankLine b{color:#ffdf9a;font-size:22px}
  .menuCol{display:flex;flex-direction:column;gap:6px;margin-top:8px;align-items:center}

  .upgrade-list{width:min(760px,96vw);max-height:56vh;overflow-y:auto;
    display:grid;grid-template-columns:1fr;gap:8px;margin:12px 0;padding:6px;
    border:1px solid rgba(232,201,138,.2);background:rgba(20,14,8,.45)}
  .upgrade-item{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;
    padding:10px 14px;background:rgba(58,39,24,.55);border:1px solid rgba(122,90,52,.5);
    text-align:left;transition:.15s}
  .upgrade-item:hover{background:rgba(84,56,31,.65);border-color:rgba(232,201,138,.5)}
  .upgrade-item.maxed{opacity:.75}
  .up-info b{display:block;color:#e8c98a;font-size:15px;letter-spacing:.05em}
  .up-info span{display:block;color:#cbb891;font-size:12px;margin-top:2px}
  .up-level{color:#b09a72;font-size:12px;letter-spacing:.08em;text-align:right;min-width:70px}
  .up-buy{margin:0;padding:8px 18px;font-size:13px;min-width:96px}
  .up-buy.owned{background:#2a1c10;color:#7a5a34;border-color:#4a3220;cursor:default}

  .lore-list{width:min(820px,96vw);max-height:64vh;overflow-y:auto;
    display:grid;grid-template-columns:1fr;gap:10px;margin:14px 0;padding:8px;
    border:1px solid rgba(232,201,138,.2);background:rgba(20,14,8,.45);text-align:left}
  .lore-item{padding:14px 16px;background:rgba(58,39,24,.55);
    border:1px solid rgba(122,90,52,.5);transition:.15s}
  .lore-item.locked{background:rgba(30,22,14,.6);border-color:rgba(80,60,40,.5)}
  .lore-title{color:#ffdf9a;font-size:16px;letter-spacing:.08em;margin-bottom:8px;
    font-weight:bold;display:flex;justify-content:space-between;align-items:center}
  .lore-title .badge{font-size:10px;color:#b09a72;letter-spacing:.14em;font-weight:normal}
  .lore-body{color:#cbb891;font-size:13px;line-height:1.7;font-style:italic}
  .lore-item.locked .lore-title{color:#7a6a52}
  .lore-item.locked .lore-body{color:transparent;text-shadow:0 0 8px rgba(180,150,100,.8);
    filter:blur(3px);user-select:none}
  .lore-count{color:#b09a72;font-size:12px;letter-spacing:.16em;margin-bottom:6px}

  .sequelTease{margin:24px 0 8px;padding:20px 30px;border:1px solid rgba(232,201,138,.45);
    background:linear-gradient(180deg, rgba(58,39,24,.55), rgba(20,14,8,.65));
    text-align:center;max-width:600px;border-radius:2px;
    box-shadow:0 0 40px rgba(232,201,138,.12) inset, 0 0 30px rgba(0,0,0,.5)}
  .sequelTease.secret{border-color:rgba(224,80,80,.6);
    box-shadow:0 0 40px rgba(224,80,80,.15) inset, 0 0 30px rgba(0,0,0,.6)}
  .sequelLine{color:#cbb891;font-size:14px;letter-spacing:.08em;margin-bottom:10px;
    font-style:italic;opacity:.9}
  .sequelTitle{color:#ffdf9a;font-size:clamp(20px,4vw,32px);letter-spacing:.26em;
    text-shadow:0 0 26px rgba(255,223,154,.55);margin:8px 0 6px;font-weight:bold}
  .sequelTease.secret .sequelTitle{color:#e05050;text-shadow:0 0 26px rgba(224,80,80,.6)}
  .sequelSub{color:#b09a72;font-size:11px;letter-spacing:.34em;text-transform:uppercase}

  .toast{position:fixed;left:50%;top:14%;transform:translateX(-50%);
    padding:10px 20px;background:rgba(20,14,8,.85);border:1px solid #e8c98a;
    color:#e8c98a;font-size:14px;letter-spacing:.08em;z-index:30;
    opacity:0;transition:opacity .25s,top .25s;pointer-events:none}
  .toast.show{opacity:1;top:12%}
  .toast.bad{border-color:#c94a4a;color:#e08e8e}
  .toast.good{border-color:#8ee08e;color:#b8f0b8}
  .toast.lore{border-color:#ffdf9a;color:#ffdf9a;background:rgba(30,20,10,.9)}
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="startScreen" class="overlay">
  <h1>BITTER HORIZON</h1>
  <div class="sub">A Western Requiem</div>
  <div class="bankLine">BANK: $<b id="startBank">0</b></div>
  <div class="keys">
    <b>WASD</b> move &nbsp;·&nbsp; <b>Mouse</b> aim &nbsp;·&nbsp; <b>Click</b> fire &nbsp;·&nbsp; <b>R</b> reload &nbsp;·&nbsp; <b>F</b> melee<br>
    <b>Shift</b> Focus &nbsp;·&nbsp; <b>E</b> mount / interact &nbsp;·&nbsp; <b>ESC/P</b> pause<br>
    Ride into town to heal, buy rounds, clear notoriety, or <b>buy a new horse at the Stable</b>.<br>
    Melee hits hard up close — and hits harder from the saddle.<br>
    <span style="color:#e8c98a">Survive all 10 waves to end the Iron Guard's grip on the Meridian.</span><br>
    <span style="color:#b09a72;font-size:12px">Rare books drop from the fallen. Collect them all for a darker fate.</span>
  </div>
  <div class="menuCol">
    <button id="startBtn">Ride Out</button>
    <button id="upgradesBtn">Upgrades</button>
    <button id="loreHomeBtn">Lore</button>
    <button id="resetBtn" style="background:transparent;border-color:#5a3a2a;color:#a08060;font-size:11px;padding:8px 20px;margin-top:8px">Reset Save</button>
  </div>
</div>

<div id="upgradeScreen" class="overlay hidden">
  <h1 class="small">UPGRADES</h1>
  <div class="sub">Spend your blood money</div>
  <div class="bankLine">BANK: $<b id="upBank">0</b></div>
  <div id="upgradeList" class="upgrade-list"></div>
  <button id="upgradeBackBtn">Back</button>
</div>

<div id="loreScreen" class="overlay hidden">
  <h1 class="small">THE MERIDIAN FILES</h1>
  <div class="sub">Pages torn from the frontier</div>
  <div class="lore-count" id="loreCount">0 / 10 COLLECTED</div>
  <div id="loreList" class="lore-list"></div>
  <button id="loreBackBtn">Back</button>
</div>

<div id="pauseScreen" class="overlay hidden">
  <h1 class="small">PAUSED</h1>
  <div class="sub">The dust settles for a moment</div>
  <div class="menuCol">
    <button id="resumeBtn">Resume</button>
    <button id="lorePauseBtn">Lore</button>
    <button id="endRunBtn" style="border-color:#8a3a2a;color:#e08e8e">End Run</button>
  </div>
</div>

<div id="endScreen" class="overlay hidden">
  <h1 id="endTitle">YOU DIED</h1>
  <div class="sub" id="endSub">The Meridian keeps its dead</div>
  <div class="keys" id="endStats"></div>
  <button id="restartBtn">Return to Camp</button>
</div>

<div id="victoryScreen" class="overlay hidden">
  <h1 id="victoryTitle" class="small gold">THE MERIDIAN ENDURES</h1>
  <div class="sub" id="victorySub">You survived all ten waves</div>
  <div class="keys" id="victoryStats"></div>
  <div class="sequelTease" id="victoryTease">
    <div class="sequelLine" id="victoryTeaseLine">The Meridian was only the beginning.</div>
    <div class="sequelTitle" id="victoryTeaseTitle">BITTER HORIZON 2</div>
    <div class="sequelSub" id="victoryTeaseSub">Coming when the dust settles</div>
  </div>
  <button id="victoryBtn">Return to Camp</button>
</div>

<div id="toast" class="toast"></div>

<script>
(() => {
"use strict";

/* ============== CANVAS ============== */
const cv  = document.getElementById('game');
const ctx = cv.getContext('2d');
let VW = 0, VH = 0, DPR = 1;
function resize(){
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  VW = window.innerWidth; VH = window.innerHeight;
  cv.width  = Math.floor(VW * DPR);
  cv.height = Math.floor(VH * DPR);
  cv.style.width  = VW + 'px';
  cv.style.height = VH + 'px';
}
window.addEventListener('resize', resize);
resize();

/* ============== HELPERS ============== */
const clamp = (v,a,b) => v < a ? a : v > b ? b : v;
const rnd   = (a,b) => a + Math.random() * (b - a);
const rndi  = (a,b) => Math.floor(rnd(a,b+1));
const TAU   = Math.PI * 2;

const TOTAL_WAVES = 10;
const FINAL_WAVE_BONUS_BASE = 500;
const FINAL_WAVE_BONUS_PER_KILL = 8;

/* ============== TOAST ============== */
const toastEl = document.getElementById('toast');
let toastTimer = null;
function toast(text, kind){
  toastEl.textContent = text;
  toastEl.className = 'toast show' + (kind ? ' ' + kind : '');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { toastEl.className = 'toast'; }, 1600);
}

/* ============== SAVE ============== */
const SAVE_KEY = 'bitter_horizon_save_v4';
const DEFAULT_SAVE = {
  bank:0, upgrades:{}, totalEarned:0, totalKills:0, bestWave:0, runs:0,
  victories:0, secretVictories:0, books:[], horseTier:0
};
let save = JSON.parse(JSON.stringify(DEFAULT_SAVE));

function loadSave(){
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (raw){
      const parsed = JSON.parse(raw);
      save = Object.assign(JSON.parse(JSON.stringify(DEFAULT_SAVE)), parsed);
      save.upgrades = Object.assign({}, parsed.upgrades || {});
      save.books = Array.isArray(parsed.books) ? parsed.books : [];
      save.horseTier = typeof parsed.horseTier === 'number' ? parsed.horseTier : 0;
    }
  } catch(e){}
}
function persistSave(){
  try { localStorage.setItem(SAVE_KEY, JSON.stringify(save)); } catch(e){}
}
function wipeSave(){
  save = JSON.parse(JSON.stringify(DEFAULT_SAVE));
  persistSave();
}
loadSave();

/* ============== HORSE TIERS ============== */
const HORSE_TYPES = [
  { name:'Mustang',   cost:0,   hp:150, speedMult:1.00, trampleMult:1.00,
    color:'#5a3d26', mane:'#2c1d10', accent:'#8a6a4a',
    blurb:'A steady range horse. Reliable, unremarkable.' },
  { name:'Appaloosa', cost:75,  hp:190, speedMult:1.15, trampleMult:1.00,
    color:'#6a5040', mane:'#3a2a1a', accent:'#a88868',
    blurb:'Faster on the trail. Favored by scouts and mail riders.' },
  { name:'Shire',     cost:190, hp:280, speedMult:0.95, trampleMult:1.55,
    color:'#3a2a1a', mane:'#1a0a00', accent:'#5a4030',
    blurb:'A wall of muscle. Slow, but it tramples anything in its path.' },
  { name:'Arabian',   cost:420, hp:230, speedMult:1.35, trampleMult:1.20,
    color:'#c8b89a', mane:'#8a7a5a', accent:'#e8d8ba',
    blurb:'Fast, tough, and expensive. The best mount in the Meridian.' }
];

/* ============== LORE ============== */
const LORE_ENTRIES = [
  { id:'meridian', title:'The Meridian', body:
    'Two rivers, one coast, and the last free passage west. The Meridian Territories were never surveyed by any honest cartographer. They were drawn by men who wanted them to stay unnamed — so that whatever happened out here would never be written down.' },
  { id:'ironguard', title:'The Iron Guard', body:
    'Sovereign Rail & Trust pays in silver and in silence. The Iron Guard are men who traded their names for a uniform and their consciences for a wage. Riders, marksmen, scouts — all of them answering to one man.' },
  { id:'ledger', title:'Cornelius Ledger', body:
    'President of Sovereign Rail & Trust. He calls the frontier a moral failing and the railroad a civilizing hand. He has never bled, and he has never apologized for the men who bleed for him.' },
  { id:'cash', title:'Cassius Okafor', body:
    'You were a scout for the Army. You learned the land. You learned to shoot. Then you learned what the Army did to the Nine Nations — and you walked away with a uniform full of holes and a name they still use when they want you found.' },
  { id:'debts', title:'The Debts', body:
    'You ran. You gambled. You owed the wrong men in three territories and the Army owed you nothing. The Meridian was supposed to be a place to disappear. Instead you are the most wanted man in it, and every man who rides after you is riding on somebody else\'s payroll.' },
  { id:'orpheus', title:'The Orpheus', body:
    'Ines Duval named her paddlewheeler after a man who looked back at the wrong time. She says the boat is the only honest thing she owns. She says the same about you, and she says it like a warning.' },
  { id:'field_scout', title:'Field Manual: The Fast Ones', body:
    'Scouts close distance in short bursts — five seconds between dashes. They are not built to brawl. Keep them in front of you. If you let them flank, they will not stop at your shoulder.' },
  { id:'field_heavy', title:'Field Manual: The Heavies', body:
    'Brutes take punishment. Shotgunners want the room you are standing in. Bombers will not close unless they have to. Give ground when the grenade lands. There is no dishonor in stepping back.' },
  { id:'field_command', title:'Field Manual: Command', body:
    'Captains hold their men together. They do not shoot to kill — they shoot to keep the line. Kill them first and the rest will break. If you cannot reach them, break the men around them.' },
  { id:'next', title:'The Next Horizon', body:
    'There is a rumor in every saloon east of the Meridian. A place past the last rail. Where a man with a new name can start again. Or so they say. Cassius has heard it before. Cassius has believed it before.' }
];
const TOTAL_BOOKS = LORE_ENTRIES.length;

/* ============== UPGRADES ============== */
const UPGRADES = [
  { id:'health',      name:'Iron Constitution', desc:'+20 Max Health per level',
    baseCost:25, scale:1.55, max:12, fmt:v => '+' + (v*20) },
  { id:'focus',       name:'Steady Nerves',     desc:'+15 Max Focus per level',
    baseCost:20, scale:1.50, max:12, fmt:v => '+' + (v*15) },
  { id:'focusEff',    name:'Calm Under Fire',   desc:'-8% Focus drain per level',
    baseCost:32, scale:1.62, max:8,  fmt:v => '-' + Math.round((1-Math.pow(.92,v))*100) + '%' },
  { id:'speed',       name:'Trail Legs',        desc:'+8% move & horse speed per level',
    baseCost:28, scale:1.58, max:10, fmt:v => '+' + (v*8) + '%' },
  { id:'damage',      name:'Heavy Lead',        desc:'+6 bullet damage per level',
    baseCost:38, scale:1.62, max:12, fmt:v => '+' + (v*6) },
  { id:'reload',      name:'Quick Hands',       desc:'-10% reload time per level',
    baseCost:24, scale:1.50, max:8,  fmt:v => '-' + Math.round((1-Math.pow(.90,v))*100) + '%' },
  { id:'ammo',        name:'Bandolier',         desc:'+12 starting reserve ammo per level',
    baseCost:22, scale:1.48, max:8,  fmt:v => '+' + (v*12) },
  { id:'cash',        name:'Blood Money',       desc:'+6% cash from kills per level',
    baseCost:42, scale:1.65, max:6,  fmt:v => '+' + (v*6) + '%' },
  { id:'trample',     name:'Heavy Hooves',      desc:'+30% horse trample damage per level',
    baseCost:35, scale:1.60, max:6,  fmt:v => '+' + (v*30) + '%' },
  { id:'horseHealth', name:'Warhorse Blood',    desc:'+25 max horse health per level',
    baseCost:30, scale:1.55, max:8,  fmt:v => '+' + (v*25) + ' HP' }
];

function upgradeCost(def, level){
  return Math.round(def.baseCost * Math.pow(def.scale, level));
}
function computeStats(){
  const u = save.upgrades; const g = id => u[id] || 0;
  return {
    maxHp:         100 + g('health') * 20,
    maxComposure:  100 + g('focus')  * 15,
    focusDrain:     24 * Math.pow(0.92, g('focusEff')),
    speed:         200 * (1 + g('speed') * 0.08),
    horseSpeed:    400 * (1 + g('speed') * 0.08),
    damage:         42 + g('damage') * 6,
    reloadTime:    1.15 * Math.pow(0.90, g('reload')),
    startAmmo:      48 + g('ammo') * 12,
    cashMult:        1 + g('cash') * 0.06,
    trampleMult:    1 + g('trample') * 0.30,
    horseHpBonus:   g('horseHealth') * 25
  };
}

/* ============== UPGRADE SCREEN ============== */
const upgradeListEl = document.getElementById('upgradeList');
const upBankEl      = document.getElementById('upBank');
function renderUpgrades(){
  upBankEl.textContent = save.bank;
  upgradeListEl.innerHTML = '';
  for (const def of UPGRADES){
    const lvl = save.upgrades[def.id] || 0;
    const maxed = lvl >= def.max;
    const cost  = maxed ? 0 : upgradeCost(def, lvl);
    const can   = !maxed && save.bank >= cost;
    const row = document.createElement('div');
    row.className = 'upgrade-item' + (maxed ? ' maxed' : '');
    row.innerHTML =
      '<div class="up-info"><b>' + def.name + '</b>' +
      '<span>' + def.desc + ' &nbsp;·&nbsp; now: ' + def.fmt(lvl) + '</span></div>' +
      '<div class="up-level">Lv ' + lvl + ' / ' + def.max + '</div>' +
      '<button class="up-buy' + (maxed ? ' owned' : '') + '"' + (can ? '' : ' disabled') + '>' +
      (maxed ? 'MAXED' : '$' + cost) + '</button>';
    if (!maxed) row.querySelector('button').addEventListener('click', () => buyUpgrade(def.id));
    upgradeListEl.appendChild(row);
  }
}
function buyUpgrade(id){
  const def = UPGRADES.find(u => u.id === id);
  if (!def) return;
  const lvl = save.upgrades[id] || 0;
  if (lvl >= def.max) return;
  const cost = upgradeCost(def, lvl);
  if (save.bank < cost){ toast('Not enough cash', 'bad'); return; }
  save.bank -= cost;
  save.upgrades[id] = lvl + 1;
  persistSave();
  renderUpgrades();
  toast(def.name + ' → Lv ' + (lvl+1), 'good');
  if (actx){ tone(520,.12,.09,'triangle',780); setTimeout(()=>tone(760,.14,.08,'triangle',980),90); }
}

/* ============== LORE SCREEN ============== */
const loreListEl = document.getElementById('loreList');
const loreCountEl = document.getElementById('loreCount');
function renderLore(){
  loreCountEl.textContent = save.books.length + ' / ' + TOTAL_BOOKS + ' COLLECTED';
  loreListEl.innerHTML = '';
  for (const entry of LORE_ENTRIES){
    const found = save.books.includes(entry.id);
    const item = document.createElement('div');
    item.className = 'lore-item' + (found ? '' : ' locked');
    item.innerHTML =
      '<div class="lore-title">' + (found ? entry.title : '?????') +
      '<span class="badge">' + (found ? 'RECOVERED' : 'MISSING') + '</span></div>' +
      '<div class="lore-body">' + (found ? entry.body : entry.body.replace(/[A-Za-z]/g, '#')) + '</div>';
    loreListEl.appendChild(item);
  }
}

/* ============== AUDIO ============== */
let actx = null;
function initAudio(){
  if (actx) return;
  try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){}
}
function noise(dur, freq, vol, q){
  if (!actx) return;
  const t = actx.currentTime;
  const n = Math.max(1, Math.floor(actx.sampleRate * dur));
  const buf = actx.createBuffer(1, n, actx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < n; i++) d[i] = (Math.random()*2-1) * Math.pow(1 - i/n, 3);
  const s = actx.createBufferSource(); s.buffer = buf;
  const f = actx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq; f.Q.value=q||1;
  const g = actx.createGain(); g.gain.value = vol;
  s.connect(f); f.connect(g); g.connect(actx.destination); s.start(t);
}
function tone(f0, dur, vol, type, f1){
  if (!actx) return;
  const t = actx.currentTime;
  const o = actx.createOscillator(); o.type = type || 'sine';
  o.frequency.setValueAtTime(f0, t);
  if (f1) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1), t + dur);
  const g = actx.createGain();
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + dur + .03);
}
function victoryFanfare(){
  if (!actx) return;
  [392, 523, 659, 784, 1047].forEach((f, i) =>
    setTimeout(() => tone(f, 0.55, 0.10, 'triangle', f*1.005), i * 140));
}
function secretFanfare(){
  if (!actx) return;
  [261, 311, 392, 466, 587].forEach((f, i) =>
    setTimeout(() => tone(f, 0.7, 0.09, 'sawtooth', f*0.995), i * 180));
}
const sfx = {
  shot:  () => { noise(0.16, 1600, 0.22, 1); tone(180, .09, .07, 'square', 60); },
  minigun:() => { noise(0.07, 2000, 0.16, 1); tone(220, .04, .05, 'square', 120); },
  enemy: () => { noise(0.14, 1000, 0.10, 1); },
  shotgun:() => { noise(0.24, 800, 0.20, 1); tone(120, .12, .09, 'square', 50); },
  bomb:  () => { tone(140, .5, .08, 'sawtooth', 40); },
  boom:  () => { noise(0.4, 400, 0.28, 1); tone(90, .4, .14, 'square', 30); },
  heal:  () => { tone(700, .14, .06, 'sine', 1050); },
  buff:  () => { tone(200, .35, .05, 'sine', 320); },
  hit:   () => { noise(0.09, 500,  0.16, 2); },
  die:   () => { tone(220, .35, .10, 'sawtooth', 60); noise(0.25, 400, 0.12, 1); },
  pickup:() => { tone(660, .10, .09, 'triangle', 990); },
  power: () => { tone(520, .09, .10, 'triangle', 780); setTimeout(()=>tone(880,.12,.09,'triangle',1180),70); },
  book:  () => { tone(440,.15,.08,'triangle',660); setTimeout(()=>tone(660,.2,.08,'triangle',880),140); setTimeout(()=>tone(880,.24,.06,'triangle',1100),300); },
  reload:() => { tone(320, .05, .06, 'square'); setTimeout(()=>tone(420,.06,.06,'square'), 110); },
  mount: () => { tone(300, .18, .07, 'sawtooth', 460); },
  trample:()=> { noise(0.2, 700, 0.14, 2); tone(160, .18, .08, 'sawtooth', 60); },
  melee: () => { noise(0.12, 900, 0.14, 2); tone(300, .07, .06, 'square', 120); },
  meleeHit:()=> { noise(0.10, 450, 0.20, 2); tone(180, .1, .09, 'sawtooth', 80); },
  horseBuy:()=> { tone(392,.12,.08,'triangle',523); setTimeout(()=>tone(523,.14,.08,'triangle',659),110); setTimeout(()=>tone(784,.2,.09,'triangle',1047),240); },
  hurt:  () => { tone(140, .22, .12, 'sawtooth', 70); noise(.12, 700, .10, 1); },
  wave:  () => { tone(160, .8, .08, 'sawtooth', 90); },
  final: () => { tone(110, 1.6, .10, 'sawtooth', 55); tone(165, 1.6, .06, 'square', 82); }
};

/* ============== INPUT ============== */
const keys = {};
window.addEventListener('keydown', e => {
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
  if (!keys[e.code]) {
    if (e.code === 'KeyR' && game.state === 'play') startReload();
    if (e.code === 'KeyE' && game.state === 'play') tryInteract();
    if (e.code === 'KeyF' && game.state === 'play') tryMelee();
    if ((e.code === 'Escape' || e.code === 'KeyP') && (game.state === 'play' || game.state === 'paused')) togglePause();
  }
  keys[e.code] = true;
});
window.addEventListener('keyup', e => { keys[e.code] = false; });

const mouse = { x:0, y:0, down:false };
cv.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; });
cv.addEventListener('mousedown', e => { if (e.button === 0){ mouse.down = true; initAudio(); } });
window.addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
cv.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('blur', () => { mouse.down = false; for (const k in keys) keys[k] = false; });

/* ============== WORLD ============== */
const WORLD = 3200;
const TOWN  = { x: 1600, y: 1560 };
const circles = [];
const rects   = [];

rects.push({ x: TOWN.x-330, y: TOWN.y-60,  w: 165, h: 125, type:'saloon'  });
rects.push({ x: TOWN.x-90,  y: TOWN.y-100, w: 180, h: 140, type:'store'   });
rects.push({ x: TOWN.x+150, y: TOWN.y-70,  w: 155, h: 120, type:'sheriff' });
rects.push({ x: TOWN.x+330, y: TOWN.y+20,  w: 170, h: 130, type:'stable'  });

circles.push({ x: TOWN.x-20, y: TOWN.y+60, r: 26, type:'trough' });
circles.push({ x: TOWN.x+120, y: TOWN.y+90, r: 20, type:'crate' });
circles.push({ x: TOWN.x-140, y: TOWN.y+110, r: 18, type:'crate' });
for (let i = 0; i < 8; i++)
  circles.push({ x: TOWN.x - 360 + i*100, y: TOWN.y + 190, r: 12, type:'post' });

for (let i = 0; i < 190; i++){
  const x = rnd(120, WORLD-120), y = rnd(120, WORLD-120);
  if (Math.hypot(x-TOWN.x, y-TOWN.y) < 460) continue;
  circles.push({ x, y, r: rnd(16, 46), type: Math.random() < 0.62 ? 'rock' : 'cactus' });
}
for (let i = 0; i < 9; i++){
  const x = rnd(400, WORLD-400), y = rnd(400, WORLD-400);
  if (Math.hypot(x-TOWN.x, y-TOWN.y) < 700) continue;
  circles.push({ x, y, r: rnd(70, 120), type:'mesa' });
}

const groundPattern = (() => {
  const c = document.createElement('canvas');
  c.width = c.height = 256;
  const g = c.getContext('2d');
  g.fillStyle = '#c0a068';
  g.fillRect(0, 0, 256, 256);
  for (let i = 0; i < 2600; i++){
    const x = Math.random()*256, y = Math.random()*256;
    const v = Math.random();
    g.fillStyle = v < .5 ? 'rgba(150,120,74,.30)'
                : v < .8 ? 'rgba(210,186,132,.30)'
                         : 'rgba(120,96,60,.22)';
    g.fillRect(x, y, 2, 2);
  }
  for (let i = 0; i < 90; i++){
    g.fillStyle = 'rgba(110,88,58,.35)';
    g.beginPath();
    g.arc(Math.random()*256, Math.random()*256, Math.random()*2.2+.6, 0, TAU);
    g.fill();
  }
  return ctx.createPattern(c, 'repeat');
})();

/* ============== STATE ============== */
const game = {
  state:'menu', t:0, focus:false, shake:0, spawnTimer:3, heat:0,
  wave:1, waveTimer:0, kills:0, message:'', messageTimer:0, hurtFlash:0,
  runEarned:0, finalWaveStart:null, victoryTimer:0, manualEnd:false
};
const cam = { x:0, y:0 };
const player = {
  x: TOWN.x, y: TOWN.y + 300,
  r: 14, angle: 0,
  hp:100, maxHp:100, composure:100, maxComposure:100,
  cylinder:6, maxCylinder:6, reserve:48,
  cash:0, notoriety:0,
  mounted:false, fireCd:0, reloadT:0, invuln:0, dead:false, recoil:0,
  meleeCd:0, meleeSwing:0,
  stats:null,
  buffs:{ rapid:0, power:0, swift:0, shield:0, calm:0, minigun:0, multishot:0 },
  isMoving:false
};
const horse = {
  x: TOWN.x + 110, y: TOWN.y + 330,
  r: 20, hp:150, maxHp:150,
  angle:0, alive:true, bob:0,
  tier:0, speedMult:1, trampleMult:1,
  color:'#5a3d26', mane:'#2c1d10', accent:'#8a6a4a', name:'Mustang'
};

function applyHorseTier(){
  const tier = HORSE_TYPES[clamp(save.horseTier || 0, 0, HORSE_TYPES.length-1)];
  const bonusHp = (save.upgrades.horseHealth || 0) * 25;
  horse.tier = save.horseTier || 0;
  horse.maxHp = tier.hp + bonusHp;
  horse.hp = Math.min(horse.hp, horse.maxHp);
  horse.speedMult = tier.speedMult;
  horse.trampleMult = tier.trampleMult;
  horse.color = tier.color;
  horse.mane = tier.mane;
  horse.accent = tier.accent;
  horse.name = tier.name;
}

let bullets   = [];
let grenades  = [];
let enemies   = [];
let particles = [];
let pickups   = [];
let floaters  = [];
let healBeams = [];
let dmgNumbers = [];

/* ============== POWER-UPS ============== */
const POWERUPS = {
  rapid:     { name:'Rapid Fire',  color:'#ff8c3a', dur:8,  icon:'⚡' },
  power:     { name:'Heavy Load',  color:'#e8433a', dur:10, icon:'✦' },
  swift:     { name:'Swift Boots', color:'#3ae8c0', dur:12, icon:'»' },
  shield:    { name:'Iron Hide',   color:'#3a8ce8', dur:10, icon:'◆' },
  calm:      { name:'Dead Calm',   color:'#e8d43a', dur:7,  icon:'◎' },
  minigun:   { name:'Minigun',     color:'#c9c9c9', dur:10, icon:'⚙' },
  multishot: { name:'Multi-Shot',  color:'#ff44aa', dur:15, icon:'⁂' }
};
const POWERUP_KEYS = Object.keys(POWERUPS);

/* ============== ENEMIES ============== */
const ENEMY_TYPES = {
  guard:        { hp:46,  speed:96,  r:14, dmg:9,  cd:1.5,  range:300, color:'#3b4a5a', hat:'#25303c', name:'Iron Guard',  behavior:'ranged', tier:0 },
  scout:        { hp:26,  speed:130, r:12, dmg:6,  cd:0.9,  range:170, color:'#5a6a3a', hat:'#3a4a20', name:'Scout',       behavior:'harass', tier:1,
                  dashCd:5, dashDur:0.35, dashSpeed:560 },
  shotgunner:   { hp:82,  speed:140, r:16, dmg:6,  cd:1.7,  range:150, color:'#4a4a5a', hat:'#2a2a3a', name:'Shotgunner',  behavior:'shotgun', tier:1 },
  sharpshooter: { hp:36,  speed:62,  r:14, dmg:19, cd:2.3,  range:620, color:'#5a4a34', hat:'#3a2f20', name:'Marksman',    behavior:'ranged', tier:2 },
  rider:        { hp:62,  speed:178, r:15, dmg:12, cd:1.15, range:240, color:'#4a3a52', hat:'#2c2135', name:'Outrider',    behavior:'ranged', tier:2 },
  bomber:       { hp:52,  speed:72,  r:14, dmg:28, cd:3.0,  range:380, color:'#6a4a2a', hat:'#3a2a10', name:'Bomber',      behavior:'bomb',   tier:3 },
  brute:        { hp:175, speed:62,  r:22, dmg:24, cd:1.8,  range:78,  color:'#7a3a2a', hat:'#4a1e10', name:'Brute',       behavior:'melee',  tier:3 },
  medic:        { hp:58,  speed:105, r:14, dmg:0,  cd:2.4,  range:280, color:'#6a3a4a', hat:'#3a1a2a', name:'Field Medic', behavior:'medic',  tier:4 },
  captain:      { hp:115, speed:88,  r:18, dmg:14, cd:1.35, range:320, color:'#3a2a5a', hat:'#1a1a3a', name:'Captain',     behavior:'captain',tier:4 }
};

function pickEnemyType(wave){
  const options = [
    { type:'guard',        w: 10 },
    { type:'scout',        w: wave >= 2 ? 6 : 0 },
    { type:'shotgunner',   w: wave >= 2 ? 5 : 0 },
    { type:'sharpshooter', w: wave >= 3 ? 5 : 0 },
    { type:'rider',        w: wave >= 4 ? 5 : 0 },
    { type:'bomber',       w: wave >= 4 ? 4 : 0 },
    { type:'brute',        w: wave >= 5 ? 4 : 0 },
    { type:'medic',        w: wave >= 5 ? 3 : 0 },
    { type:'captain',      w: wave >= 6 ? 2.5 : 0 }
  ];
  const total = options.reduce((s,o) => s + o.w, 0);
  let r = Math.random() * total;
  for (const o of options){ r -= o.w; if (r <= 0) return o.type; }
  return 'guard';
}

function spawnEnemy(){
  if (enemies.length >= 34) return;
  const a = Math.random() * TAU;
  const d = Math.max(VW, VH) * 0.62 + 180;
  const x = clamp(player.x + Math.cos(a)*d, 60, WORLD-60);
  const y = clamp(player.y + Math.sin(a)*d, 60, WORLD-60);
  const type = pickEnemyType(game.wave);
  const t = ENEMY_TYPES[type];
  enemies.push({
    x, y, r:t.r, hp:t.hp, maxHp:t.hp,
    speed:t.speed, dmg:t.dmg, cd:t.cd, range:t.range,
    color:t.color, hat:t.hat, name:t.name, type, behavior:t.behavior,
    fireCd: rnd(0.6, 1.8), angle:0, hitFlash:0,
    strafe: Math.random() < .5 ? 1 : -1,
    abilityCd: rnd(0.8, 2.2),
    meleeWindup: 0,
    buffDmg: 1, buffSpd: 1,
    dirTimer: rnd(0.4, 1.2),
    seed: Math.random() * TAU,
    dashTimer: 0, dashCd: rnd(1.5, 3), dashAngle: 0,
    trampleHitCd: 0
  });
}

function spawnWave(){
  game.wave++;
  if (game.wave > TOTAL_WAVES) game.wave = TOTAL_WAVES;
  if (game.wave === TOTAL_WAVES){
    game.finalWaveStart = game.t;
    game.victoryTimer = 0;
    const n = 15;
    for (let i = 0; i < n; i++) setTimeout(spawnEnemy, i * 90);
    game.message = 'FINAL WAVE — THE LAST STAND';
    game.messageTimer = 5.0;
    sfx.final();
    toast('FINAL WAVE — SURVIVE IT', 'bad');
  } else {
    const n = Math.min(3 + Math.floor(game.wave * 0.8), 11);
    for (let i = 0; i < n; i++) setTimeout(spawnEnemy, i * 110);
    game.message = 'WAVE ' + game.wave + ' / ' + TOTAL_WAVES + ' — THE IRON GUARD RIDES';
    game.messageTimer = 3.0;
    sfx.wave();
  }
}

/* ============== EFFECTS ============== */
function burst(x, y, n, color, spd, life, size){
  for (let i = 0; i < n; i++){
    const a = Math.random() * TAU;
    const s = rnd(spd*0.35, spd);
    particles.push({
      x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s,
      life: rnd(life*0.5, life), max: life,
      color, size: rnd(size*0.6, size)
    });
  }
}
function floater(x, y, text, color){ floaters.push({ x, y, text, color, life: 1.1 }); }
function dmgNumber(x, y, amount, crit){ dmgNumbers.push({ x, y, amount: Math.round(amount), life: 0.7, max: 0.7, crit }); }

/* ============== SHOOTING ============== */
function firePlayerBullet(){
  const p = player;
  const hasMinigun = p.buffs.minigun > 0;
  if (!hasMinigun){
    if (p.cylinder <= 0 || p.reloadT > 0) return;
    p.cylinder--;
  }
  const baseCd = hasMinigun ? 0.05 : (game.focus ? 0.14 : 0.17);
  if (hasMinigun) p.fireCd = baseCd;
  else if (p.buffs.rapid > 0) p.fireCd = baseCd * 0.42;
  else p.fireCd = baseCd;
  p.recoil = 1;
  game.shake = Math.min(game.shake + (hasMinigun ? 1.6 : 3.4), hasMinigun ? 6 : 11);
  hasMinigun ? sfx.minigun() : sfx.shot();

  const spread = hasMinigun ? 0.09 : (game.focus ? 0.006 : 0.045);
  const muzzle = 22;
  let dmg = p.stats.damage;
  if (game.focus) dmg *= 1.5;
  if (p.buffs.power > 0) dmg *= 1.7;
  if (hasMinigun) dmg *= 0.55;
  const shots = p.buffs.multishot > 0 ? 3 : 1;
  for (let s = 0; s < shots; s++){
    const offset = shots > 1 ? (s - (shots-1)/2) * 0.085 : 0;
    const a = p.angle + offset + rnd(-spread, spread);
    const bx = p.x + Math.cos(a) * muzzle;
    const by = p.y + Math.sin(a) * muzzle;
    const speed = game.focus ? 1500 : 1050;
    bullets.push({ x:bx, y:by, vx:Math.cos(a)*speed, vy:Math.sin(a)*speed,
      life:1.1, dmg, from:'player', r:3 });
    burst(bx, by, 3, '#ffd98a', 180, 0.13, 2.6);
  }
  if (!hasMinigun && p.cylinder === 0) startReload();
}
function fireEnemyBullet(e){
  const a = Math.atan2(player.y - e.y, player.x - e.x) + rnd(-0.09, 0.09);
  bullets.push({
    x: e.x + Math.cos(a) * 20, y: e.y + Math.sin(a) * 20,
    vx: Math.cos(a) * 620, vy: Math.sin(a) * 620,
    life:1.4, dmg:e.dmg * e.buffDmg, from:'enemy', r:3
  });
  sfx.enemy();
  burst(e.x + Math.cos(a)*22, e.y + Math.sin(a)*22, 3, '#ffcf7a', 150, 0.14, 2.4);
}
function fireShotgunSpread(e){
  const base = Math.atan2(player.y - e.y, player.x - e.x);
  const pellets = 5;
  for (let i = 0; i < pellets; i++){
    const a = base + (i - (pellets-1)/2) * 0.14 + rnd(-0.03, 0.03);
    bullets.push({
      x: e.x + Math.cos(a) * 22, y: e.y + Math.sin(a) * 22,
      vx: Math.cos(a) * 540, vy: Math.sin(a) * 540,
      life:0.55, dmg:e.dmg * e.buffDmg, from:'enemy', r:3
    });
  }
  sfx.shotgun();
  burst(e.x + Math.cos(base)*26, e.y + Math.sin(base)*26, 8, '#ffcf7a', 220, 0.22, 3);
}
function throwGrenade(e){
  grenades.push({
    sx:e.x, sy:e.y, tx:player.x, ty:player.y,
    x:e.x, y:e.y, t:0, dur:1.4,
    dmg:e.dmg * e.buffDmg, radius:78
  });
  sfx.bomb();
}
function startReload(){
  const p = player;
  if (p.buffs.minigun > 0) return;
  if (p.reloadT > 0 || p.cylinder >= p.maxCylinder || p.reserve <= 0) return;
  p.reloadT = p.stats.reloadTime;
  sfx.reload();
}

/* ============== MELEE ============== */
function tryMelee(){
  const p = player;
  if (p.meleeCd > 0) return;

  const isMounted = p.mounted;
  p.meleeCd = isMounted ? 0.75 : 0.5;
  p.meleeSwing = 1;

  const range = isMounted ? 74 : 52;
  const arc   = isMounted ? 2.0 : 1.5;
  const baseDmg = isMounted ? 90 : 55;
  let dmg = baseDmg;
  if (p.buffs.power > 0) dmg *= 1.5;
  if (game.focus) dmg *= 1.25;

  let hitAny = false;
  for (let i = enemies.length - 1; i >= 0; i--){
    const e = enemies[i];
    const dx = e.x - p.x, dy = e.y - p.y;
    const d = Math.hypot(dx, dy);
    if (d > range + e.r) continue;
    const ea = Math.atan2(dy, dx);
    let da = ea - p.angle;
    while (da > Math.PI) da -= TAU;
    while (da < -Math.PI) da += TAU;
    if (Math.abs(da) > arc/2) continue;

    e.hp -= dmg;
    e.hitFlash = 1;
    dmgNumber(e.x, e.y - e.r - 8, dmg, false);
    const ka = Math.atan2(dy, dx);
    e.x += Math.cos(ka) * 42;
    e.y += Math.sin(ka) * 42;
    burst(e.x, e.y, 10, '#ffd98a', 240, 0.35, 3.4);
    hitAny = true;
    if (e.hp <= 0){ killEnemy(e); enemies.splice(i, 1); }
  }

  if (hitAny){
    game.shake = Math.min(game.shake + 4, 10);
    sfx.meleeHit();
  } else {
    sfx.melee();
  }
}

/* ============== INTERACTION ============== */
function tryInteract(){
  const p = player;

  for (const r of rects){
    const cx = r.x + r.w/2, cy = r.y + r.h/2;
    if (Math.hypot(p.x - cx, p.y - cy) > 140) continue;

    if (r.type === 'saloon'){
      if (p.cash >= 6 && p.hp < p.maxHp){
        p.cash -= 6; p.hp = Math.min(p.maxHp, p.hp + 45);
        floater(cx, cy - 30, '+45 HEALTH  -$6', '#8ee08e'); sfx.pickup();
      } else if (p.hp >= p.maxHp) floater(cx, cy - 30, 'YOU LOOK FINE', '#cbb891');
      else floater(cx, cy - 30, 'NEED $6', '#e08e8e');
      return;
    }
    if (r.type === 'store'){
      if (p.cash >= 10 && p.reserve < 90){
        p.cash -= 10; p.reserve = Math.min(90, p.reserve + 24);
        floater(cx, cy - 30, '+24 ROUNDS  -$10', '#8ee08e'); sfx.pickup();
      } else if (p.reserve >= 90) floater(cx, cy - 30, 'YOU ARE LADEN', '#cbb891');
      else floater(cx, cy - 30, 'NEED $10', '#e08e8e');
      return;
    }
    if (r.type === 'stable'){
      const cur = save.horseTier || 0;
      const next = cur + 1;
      if (next >= HORSE_TYPES.length){
        floater(cx, cy - 30, 'BEST MOUNT IN THE MERIDIAN', '#cbb891');
        return;
      }
      const def = HORSE_TYPES[next];
      if (p.cash >= def.cost){
        p.cash -= def.cost;
        save.horseTier = next;
        persistSave();
        applyHorseTier();
        horse.hp = horse.maxHp;
        floater(cx, cy - 30, 'BOUGHT ' + def.name.toUpperCase(), '#ffdf9a');
        burst(horse.x, horse.y, 24, '#ffdf9a', 240, 0.8, 4);
        sfx.horseBuy();
        toast(def.name + ' purchased', 'good');
      } else {
        floater(cx, cy - 30, 'NEED $' + def.cost + ' FOR ' + def.name.toUpperCase(), '#e08e8e');
      }
      return;
    }
    if (r.type === 'sheriff'){
      if (p.notoriety > 0){
        const cost = Math.ceil(p.notoriety * 2);
        if (p.cash >= cost){
          p.cash -= cost; p.notoriety = 0;
          floater(cx, cy - 30, 'RECORD CLEARED  -$' + cost, '#8ee08e'); sfx.pickup();
        } else floater(cx, cy - 30, 'NEED $' + cost, '#e08e8e');
      } else floater(cx, cy - 30, 'NOTHING ON YOU', '#cbb891');
      return;
    }
  }

  // Horse mount/dismount (only after buildings)
  if (horse.alive){
    if (p.mounted){
      p.mounted = false;
      p.x += 30; p.y += 30;
      sfx.mount(); return;
    }
    if (Math.hypot(p.x - horse.x, p.y - horse.y) < 78){
      p.mounted = true;
      sfx.mount();
      floater(p.x, p.y - 40, 'MOUNTED — ' + horse.name.toUpperCase(), '#e8c98a');
      return;
    }
  }
}

/* ============== COLLISION ============== */
function collideWorld(e){
  for (const c of circles){
    const dx = e.x - c.x, dy = e.y - c.y;
    const d = Math.hypot(dx, dy);
    const rr = c.r + e.r;
    if (d < rr && d > 0.0001){ e.x = c.x + dx/d * rr; e.y = c.y + dy/d * rr; }
  }
  for (const r of rects){
    const cx = clamp(e.x, r.x, r.x + r.w);
    const cy = clamp(e.y, r.y, r.y + r.h);
    const dx = e.x - cx, dy = e.y - cy;
    const d = Math.hypot(dx, dy);
    if (d < e.r){
      if (d < 0.0001){
        const l = e.x - r.x, ri = r.x + r.w - e.x;
        const t = e.y - r.y, b = r.y + r.h - e.y;
        const m = Math.min(l, ri, t, b);
        if (m === l)       e.x = r.x - e.r;
        else if (m === ri) e.x = r.x + r.w + e.r;
        else if (m === t)  e.y = r.y - e.r;
        else               e.y = r.y + r.h + e.r;
      } else {
        const push = (e.r - d) / d;
        e.x += dx * push; e.y += dy * push;
      }
    }
  }
  e.x = clamp(e.x, e.r, WORLD - e.r);
  e.y = clamp(e.y, e.r, WORLD - e.r);
}
function pointBlocked(x, y){
  for (const c of circles) if (Math.hypot(x-c.x, y-c.y) < c.r) return true;
  for (const r of rects) if (x > r.x && x < r.x+r.w && y > r.y && y < r.y+r.h) return true;
  return false;
}

/* ============== UPDATE PLAYER ============== */
function updatePlayer(dt){
  const p = player;
  if (p.dead) return;
  for (const k in p.buffs){ if (p.buffs[k] > 0) p.buffs[k] = Math.max(0, p.buffs[k] - dt); }
  p.fireCd   = Math.max(0, p.fireCd - dt);
  p.invuln   = Math.max(0, p.invuln - dt);
  p.recoil   = Math.max(0, p.recoil - dt * 6);
  p.meleeCd  = Math.max(0, p.meleeCd - dt);
  p.meleeSwing = Math.max(0, p.meleeSwing - dt * 4);

  if (p.reloadT > 0){
    p.reloadT -= dt;
    if (p.reloadT <= 0){
      const need = p.maxCylinder - p.cylinder;
      const take = Math.min(need, p.reserve);
      p.cylinder += take; p.reserve -= take;
    }
  }

  let mx = (keys['KeyD']||keys['ArrowRight'] ? 1:0) - (keys['KeyA']||keys['ArrowLeft'] ? 1:0);
  let my = (keys['KeyS']||keys['ArrowDown']  ? 1:0) - (keys['KeyW']||keys['ArrowUp']   ? 1:0);
  const len = Math.hypot(mx, my);
  if (len > 0){ mx /= len; my /= len; }
  p.isMoving = len > 0;

  let speed = p.mounted ? (p.stats.horseSpeed * horse.speedMult) : p.stats.speed;
  if (p.buffs.swift > 0) speed *= 1.35;

  p.x += mx * speed * dt;
  p.y += my * speed * dt;
  collideWorld(p);

  const scrX = p.x - cam.x, scrY = p.y - cam.y;
  p.angle = Math.atan2(mouse.y - scrY, mouse.x - scrX);

  game.focus = (keys['ShiftLeft'] || keys['ShiftRight']) && p.composure > 1;
  if (game.focus){
    if (p.buffs.calm <= 0)
      p.composure = Math.max(0, p.composure - p.stats.focusDrain * dt);
  } else p.composure = Math.min(p.maxComposure, p.composure + 15 * dt);

  if (mouse.down && p.fireCd <= 0){
    if (p.buffs.minigun > 0) firePlayerBullet();
    else if (p.reloadT <= 0){
      if (p.cylinder > 0) firePlayerBullet();
      else startReload();
    }
  }

  if (p.mounted){
    horse.x = p.x; horse.y = p.y;
    horse.angle = p.angle;
    horse.bob += dt * 14 * (len > 0 ? 1 : 0.15);

    if (p.isMoving){
      const trampleDmg = 95 * p.stats.trampleMult * horse.trampleMult;
      for (const e of enemies){
        if (e.trampleHitCd > 0) continue;
        if (Math.hypot(p.x - e.x, p.y - e.y) < e.r + horse.r + 4){
          e.hp -= trampleDmg;
          e.hitFlash = 1;
          dmgNumber(e.x, e.y - e.r - 8, trampleDmg, false);
          const a = Math.atan2(e.y - p.y, e.x - p.x);
          e.x += Math.cos(a) * 60;
          e.y += Math.sin(a) * 60;
          e.trampleHitCd = 0.18;
          burst(e.x, e.y, 6, '#ffb060', 220, 0.3, 3);
          sfx.trample();
          if (e.hp <= 0){ killEnemy(e); enemies.splice(enemies.indexOf(e), 1); }
        }
      }
    }
  }
}

/* ============== UPDATE HORSE ============== */
function updateHorse(dt){
  if (!horse.alive) return;
  if (player.mounted) return;
  const d = Math.hypot(horse.x - player.x, horse.y - player.y);
  if (d > 130){
    const a = Math.atan2(player.y - horse.y, player.x - horse.x);
    horse.x += Math.cos(a) * 165 * dt;
    horse.y += Math.sin(a) * 165 * dt;
    horse.angle = a;
    horse.bob += dt * 10;
  } else horse.bob += dt * 1.6;
  collideWorld(horse);
}

/* ============== UPDATE ENEMIES ============== */
function updateEnemies(dt){
  for (const e of enemies){ e.buffDmg = 1; e.buffSpd = 1; }
  for (const c of enemies){
    if (c.type !== 'captain') continue;
    for (const e of enemies){
      if (e === c) continue;
      if (Math.hypot(e.x - c.x, e.y - c.y) < 230){
        e.buffDmg = Math.max(e.buffDmg, 1.35);
        e.buffSpd = Math.max(e.buffSpd, 1.22);
      }
    }
  }

  for (let i = enemies.length - 1; i >= 0; i--){
    const e = enemies[i];
    e.hitFlash = Math.max(0, e.hitFlash - dt * 4);
    e.abilityCd -= dt;
    if (e.trampleHitCd > 0) e.trampleHitCd -= dt;

    const dx = player.x - e.x, dy = player.y - e.y;
    const d  = Math.hypot(dx, dy) || 1;
    e.angle = Math.atan2(dy, dx);

    switch (e.behavior){
      case 'ranged': {
        let move = 0;
        if (d > e.range * 0.82) move = 1;
        else if (d < e.range * 0.45) move = -0.65;
        e.x += (dx/d) * e.speed * e.buffSpd * move * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * move * dt;
        e.x += (-dy/d) * e.speed * 0.38 * e.buffSpd * e.strafe * dt;
        e.y += ( dx/d) * e.speed * 0.38 * e.buffSpd * e.strafe * dt;
        if (Math.random() < 0.006) e.strafe *= -1;
        e.fireCd -= dt;
        if (e.fireCd <= 0 && d < e.range && !player.dead){
          e.fireCd = e.cd * rnd(0.85, 1.25);
          fireEnemyBullet(e);
        }
        break;
      }
      case 'harass': {
        if (e.dashTimer > 0){
          e.dashTimer -= dt;
          e.x += Math.cos(e.dashAngle) * e.dashSpeed * dt;
          e.y += Math.sin(e.dashAngle) * e.dashSpeed * dt;
          if (Math.random() < 0.7) burst(e.x, e.y, 1, '#aaff88', 40, 0.35, 3);
          if (Math.hypot(e.x - player.x, e.y - player.y) < e.r + player.r + 4 &&
              player.invuln <= 0) damagePlayer(7);
        } else {
          e.dashCd -= dt;
          if (e.dashCd <= 0){
            e.dashCd = 5;
            e.dashTimer = e.dashDur;
            e.dashAngle = Math.atan2(dy, dx) + rnd(-0.35, 0.35);
            burst(e.x, e.y, 8, '#aaff88', 160, 0.35, 3);
          }
          const idealR = 200;
          const radial = (d - idealR) / idealR;
          e.x += (dx/d) * e.speed * e.buffSpd * clamp(radial, -0.8, 1) * dt;
          e.y += (dy/d) * e.speed * e.buffSpd * clamp(radial, -0.8, 1) * dt;
          e.x += (-dy/d) * e.speed * 0.85 * e.buffSpd * e.strafe * dt;
          e.y += ( dx/d) * e.speed * 0.85 * e.buffSpd * e.strafe * dt;
          e.dirTimer -= dt;
          if (e.dirTimer <= 0){ e.dirTimer = rnd(0.5, 1.4); if (Math.random() < 0.45) e.strafe *= -1; }
          e.fireCd -= dt;
          if (e.fireCd <= 0 && d < e.range && !player.dead){
            e.fireCd = e.cd * rnd(0.85, 1.25);
            fireEnemyBullet(e);
          }
        }
        break;
      }
      case 'shotgun': {
        const ideal = 110;
        const radial = (d - ideal) / ideal;
        e.x += (dx/d) * e.speed * e.buffSpd * clamp(radial, -0.4, 1) * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * clamp(radial, -0.4, 1) * dt;
        e.fireCd -= dt;
        if (e.fireCd <= 0 && d < e.range && !player.dead){
          e.fireCd = e.cd * rnd(0.9, 1.15);
          fireShotgunSpread(e);
        }
        break;
      }
      case 'melee': {
        e.x += (dx/d) * e.speed * e.buffSpd * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * dt;
        if (e.meleeWindup > 0){
          e.meleeWindup -= dt;
          if (e.meleeWindup <= 0 && d < e.range + 14){
            damagePlayer(e.dmg * e.buffDmg);
            game.shake = Math.min(game.shake + 10, 18);
            burst(player.x, player.y, 14, '#ff9a3a', 280, 0.45, 4);
          }
        } else {
          e.fireCd -= dt;
          if (e.fireCd <= 0 && d < e.range && !player.dead){
            e.fireCd = e.cd * rnd(0.9, 1.2);
            e.meleeWindup = 0.4;
            burst(e.x + Math.cos(e.angle)*20, e.y + Math.sin(e.angle)*20, 8, '#ffb060', 200, 0.3, 3);
          }
        }
        break;
      }
      case 'bomb': {
        const ideal = 300;
        const radial = (d - ideal) / ideal;
        e.x += (dx/d) * e.speed * e.buffSpd * clamp(radial, -0.7, 1) * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * clamp(radial, -0.7, 1) * dt;
        e.x += (-dy/d) * e.speed * 0.4 * e.buffSpd * e.strafe * dt;
        e.y += ( dx/d) * e.speed * 0.4 * e.buffSpd * e.strafe * dt;
        if (Math.random() < 0.008) e.strafe *= -1;
        if (e.abilityCd <= 0 && d < e.range && d > 120 && !player.dead){
          e.abilityCd = e.cd * rnd(0.85, 1.2);
          throwGrenade(e);
        }
        break;
      }
      case 'medic': {
        const ideal = 320;
        const radial = (d - ideal) / ideal;
        e.x += (dx/d) * e.speed * e.buffSpd * clamp(radial, -0.6, 1) * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * clamp(radial, -0.6, 1) * dt;
        if (e.abilityCd <= 0){
          let target = null, bestD = e.range;
          for (const o of enemies){
            if (o === e) continue;
            if (o.hp >= o.maxHp) continue;
            const od = Math.hypot(o.x - e.x, o.y - e.y);
            if (od < bestD){ bestD = od; target = o; }
          }
          if (target){
            target.hp = Math.min(target.maxHp, target.hp + 22);
            healBeams.push({ x1:e.x, y1:e.y, x2:target.x, y2:target.y, life:0.45, max:0.45 });
            burst(target.x, target.y, 8, '#6ad48a', 140, 0.4, 3);
            sfx.heal();
            e.abilityCd = e.cd * rnd(0.9, 1.3);
          } else e.abilityCd = 0.6;
        }
        break;
      }
      case 'captain': {
        let move = 0;
        if (d > e.range * 0.7) move = 1;
        else if (d < e.range * 0.4) move = -0.6;
        e.x += (dx/d) * e.speed * e.buffSpd * move * dt;
        e.y += (dy/d) * e.speed * e.buffSpd * move * dt;
        e.fireCd -= dt;
        if (e.fireCd <= 0 && d < e.range && !player.dead){
          e.fireCd = e.cd * rnd(0.85, 1.2);
          fireEnemyBullet(e);
        }
        if (e.abilityCd <= 0){ e.abilityCd = 2.2; sfx.buff(); }
        break;
      }
    }
    collideWorld(e);
  }
}

/* ============== BULLETS ============== */
function updateBullets(dt){
  for (let i = bullets.length - 1; i >= 0; i--){
    const b = bullets[i];
    const steps = 2;
    let dead = false;
    for (let s = 0; s < steps && !dead; s++){
      b.x += b.vx * dt / steps;
      b.y += b.vy * dt / steps;
      if (b.x < 0 || b.y < 0 || b.x > WORLD || b.y > WORLD){ dead = true; break; }
      if (pointBlocked(b.x, b.y)){
        burst(b.x, b.y, 5, '#d8c8a0', 160, 0.22, 2.6); sfx.hit();
        dead = true; break;
      }
      if (b.from === 'player'){
        for (const e of enemies){
          if (Math.hypot(b.x - e.x, b.y - e.y) < e.r + b.r){
            e.hp -= b.dmg;
            e.hitFlash = 1;
            dmgNumber(b.x, b.y - 6, b.dmg, game.focus);
            burst(b.x, b.y, 7, '#8b1a1a', 190, 0.35, 3.4);
            sfx.hit();
            dead = true;
            if (e.hp <= 0){ killEnemy(e); enemies.splice(enemies.indexOf(e), 1); }
            break;
          }
        }
      } else {
        if (player.mounted && horse.alive &&
            Math.hypot(b.x - horse.x, b.y - horse.y) < horse.r + b.r){
          horse.hp -= b.dmg * 0.45;
          burst(b.x, b.y, 6, '#8b1a1a', 170, 0.3, 3); sfx.hit();
          dead = true;
          if (horse.hp <= 0) killHorse();
        } else if (!player.dead && player.invuln <= 0 &&
                   Math.hypot(b.x - player.x, b.y - player.y) < player.r + b.r){
          damagePlayer(b.dmg);
          burst(b.x, b.y, 8, '#8b1a1a', 200, 0.34, 3.4);
          dead = true;
        }
      }
    }
    b.life -= dt;
    if (b.life <= 0) dead = true;
    if (dead) bullets.splice(i, 1);
  }
}

/* ============== GRENADES / BEAMS ============== */
function updateGrenades(dt){
  for (let i = grenades.length - 1; i >= 0; i--){
    const g = grenades[i];
    g.t += dt;
    const k = clamp(g.t / g.dur, 0, 1);
    g.x = g.sx + (g.tx - g.sx) * k;
    g.y = g.sy + (g.ty - g.sy) * k;
    if (Math.random() < 0.4) burst(g.x, g.y, 1, '#ffb060', 60, 0.3, 2);
    if (g.t >= g.dur){
      const hit = Math.hypot(player.x - g.x, player.y - g.y) < g.radius;
      if (hit && !player.dead) damagePlayer(g.dmg);
      burst(g.x, g.y, 30, '#ff8030', 400, 0.7, 5);
      burst(g.x, g.y, 14, '#ffdd90', 260, 0.5, 4);
      game.shake = Math.min(game.shake + 9, 18);
      sfx.boom();
      grenades.splice(i, 1);
    }
  }
}
function updateHealBeams(dt){
  for (let i = healBeams.length - 1; i >= 0; i--){
    healBeams[i].life -= dt;
    if (healBeams[i].life <= 0) healBeams.splice(i, 1);
  }
}

/* ============== DAMAGE / DEATH ============== */
function damagePlayer(amount){
  const p = player;
  let dmg = amount;
  if (p.buffs.shield > 0) dmg *= 0.5;
  p.hp -= dmg;
  p.invuln = 0.12;
  game.shake = Math.min(game.shake + 6, 15);
  game.hurtFlash = 1;
  sfx.hurt();
  if (p.hp <= 0){ p.hp = 0; p.dead = true; endGame(false); }
}
function killHorse(){
  horse.alive = false; horse.hp = 0; player.mounted = false;
  burst(horse.x, horse.y, 26, '#8b1a1a', 260, 0.8, 5);
  floater(horse.x, horse.y - 40, 'YOUR HORSE IS DOWN', '#e08e8e');
  sfx.die();
}
function killEnemy(e){
  game.kills++; game.heat++; player.notoriety++;
  save.totalKills++;
  burst(e.x, e.y, 16, '#8b1a1a', 240, 0.6, 4);
  sfx.die();

  const base = rndi(4, 18) + game.wave;
  let cash = Math.round(base * player.stats.cashMult);
  const tier = ENEMY_TYPES[e.type].tier;
  cash = Math.round(cash * (1 + tier * 0.35));
  pickups.push({ x:e.x, y:e.y, type:'cash', amount:cash, t:0 });

  if (Math.random() < 0.34)
    pickups.push({ x:e.x + rnd(-16,16), y:e.y + rnd(-16,16), type:'ammo', amount:8, t:0 });
  if (Math.random() < 0.13)
    pickups.push({ x:e.x + rnd(-16,16), y:e.y + rnd(-16,16), type:'health', amount:18, t:0 });
  if ((e.type === 'brute' || e.type === 'captain') && Math.random() < 0.4)
    pickups.push({ x:e.x + rnd(-16,16), y:e.y + rnd(-16,16), type:'health', amount:24, t:0 });

  let pChance = 0.06 + game.wave * 0.005 + tier * 0.02;
  if (Math.random() < pChance){
    const kind = POWERUP_KEYS[rndi(0, POWERUP_KEYS.length - 1)];
    pickups.push({ x:e.x + rnd(-20,20), y:e.y + rnd(-20,20), type:'power', kind, t:0 });
  }

  const remaining = LORE_ENTRIES.filter(le => !save.books.includes(le.id));
  if (remaining.length > 0 && Math.random() < 0.05){
    const book = remaining[rndi(0, remaining.length - 1)];
    pickups.push({ x:e.x + rnd(-24,24), y:e.y + rnd(-24,24), type:'book', bookId:book.id, t:0 });
  }
}

/* ============== PICKUPS ============== */
function applyPowerup(kind){
  const def = POWERUPS[kind];
  if (!def) return;
  if (kind === 'calm') player.composure = player.maxComposure;
  if (kind === 'minigun'){ player.buffs.minigun = def.dur; player.reloadT = 0; }
  if (def.dur > 0 && kind !== 'minigun') player.buffs[kind] = Math.max(player.buffs[kind], def.dur);
  floater(player.x, player.y - 46, def.name.toUpperCase(), def.color);
  burst(player.x, player.y, 18, def.color, 260, 0.7, 4);
  sfx.power();
  toast(def.name + ' — ' + def.icon, 'good');
}
function updatePickups(dt){
  for (let i = pickups.length - 1; i >= 0; i--){
    const k = pickups[i];
    k.t += dt;
    const d = Math.hypot(k.x - player.x, k.y - player.y);
    if (d < 130){
      const a = Math.atan2(player.y - k.y, player.x - k.x);
      const pull = (1 - d/130) * 420;
      k.x += Math.cos(a) * pull * dt;
      k.y += Math.sin(a) * pull * dt;
    }
    if (d < player.r + 16){
      if (k.type === 'cash'){
        player.cash += k.amount; game.runEarned += k.amount;
        floater(k.x, k.y, '+$' + k.amount, '#e8c98a');
      } else if (k.type === 'ammo'){
        player.reserve = Math.min(90, player.reserve + k.amount);
        floater(k.x, k.y, '+' + k.amount + ' ROUNDS', '#c8d8e8');
      } else if (k.type === 'health'){
        player.hp = Math.min(player.maxHp, player.hp + k.amount);
        floater(k.x, k.y, '+' + k.amount + ' HEALTH', '#8ee08e');
      } else if (k.type === 'power'){
        applyPowerup(k.kind);
      } else if (k.type === 'book'){
        if (!save.books.includes(k.bookId)){
          save.books.push(k.bookId);
          persistSave();
          const entry = LORE_ENTRIES.find(le => le.id === k.bookId);
          floater(k.x, k.y - 20, 'BOOK: ' + entry.title, '#ffdf9a');
          burst(k.x, k.y, 20, '#ffdf9a', 200, 0.9, 4);
          sfx.book();
          toast('Lore recovered: ' + entry.title, 'lore');
          if (save.books.length === TOTAL_BOOKS)
            toast('ALL PAGES RECOVERED — something waits at the end', 'lore');
        }
      }
      if (k.type !== 'power' && k.type !== 'book') sfx.pickup();
      pickups.splice(i, 1);
      continue;
    }
    if (k.t > 45) pickups.splice(i, 1);
  }
}
function updateParticles(dt){
  for (let i = particles.length - 1; i >= 0; i--){
    const p = particles[i];
    p.x += p.vx * dt; p.y += p.vy * dt;
    p.vx *= 0.92; p.vy *= 0.92;
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }
  if (particles.length > 600) particles.splice(0, particles.length - 600);
}
function updateFloaters(dt){
  for (let i = floaters.length - 1; i >= 0; i--){
    const f = floaters[i];
    f.y -= 34 * dt; f.life -= dt;
    if (f.life <= 0) floaters.splice(i, 1);
  }
}
function updateDmgNumbers(dt){
  for (let i = dmgNumbers.length - 1; i >= 0; i--){
    const n = dmgNumbers[i];
    n.y -= 46 * dt; n.life -= dt;
    if (n.life <= 0) dmgNumbers.splice(i, 1);
  }
}

/* ============== MAIN UPDATE ============== */
function update(dt){
  game.t += dt;
  cam.x = clamp(player.x - VW/2, 0, Math.max(0, WORLD - VW));
  cam.y = clamp(player.y - VH/2, 0, Math.max(0, WORLD - VH));

  updatePlayer(dt);

  const wdt = dt * (game.focus ? 0.34 : 1);
  updateHorse(wdt);
  updateEnemies(wdt);
  updateBullets(wdt);
  updateGrenades(wdt);
  updateHealBeams(wdt);
  updateParticles(wdt);
  updatePickups(dt);
  updateFloaters(dt);
  updateDmgNumbers(dt);

  const isFinal = game.wave >= TOTAL_WAVES;
  if (!isFinal){
    const interval = Math.max(0.85, 4.6 - game.wave * 0.32 - game.heat * 0.012);
    game.spawnTimer -= dt;
    if (game.spawnTimer <= 0 && !player.dead){
      game.spawnTimer = interval * rnd(0.7, 1.25);
      spawnEnemy();
      if (game.wave >= 4 && Math.random() < 0.4) spawnEnemy();
      if (game.wave >= 7 && Math.random() < 0.25) spawnEnemy();
    }
  }
  game.waveTimer += dt;
  if (game.waveTimer > 32 && game.wave < TOTAL_WAVES){ game.waveTimer = 0; spawnWave(); }
  if (isFinal && game.finalWaveStart !== null && !player.dead &&
      game.t - game.finalWaveStart > 2.5 && enemies.length === 0){
    game.victoryTimer += dt;
    if (game.victoryTimer > 1.4) victory();
  } else if (enemies.length > 0 || !isFinal){ game.victoryTimer = 0; }

  if (game.messageTimer > 0) game.messageTimer -= dt;
  game.shake *= 0.86;
  if (game.shake < 0.1) game.shake = 0;
  game.hurtFlash = Math.max(0, game.hurtFlash - dt * 2.6);
}

/* ============== DRAW ============== */
function drawGround(){
  ctx.fillStyle = groundPattern;
  ctx.fillRect(cam.x - 20, cam.y - 20, VW + 40, VH + 40);
}
function drawShadow(x, y, rx, ry, alpha){
  ctx.fillStyle = 'rgba(40,26,14,' + alpha + ')';
  ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill();
}
function drawCircles(){
  for (const c of circles){
    if (c.x < cam.x - 160 || c.x > cam.x + VW + 160 ||
        c.y < cam.y - 160 || c.y > cam.y + VH + 160) continue;
    if (c.type === 'cactus'){
      drawShadow(c.x, c.y + c.r*0.5, c.r*0.9, c.r*0.36, 0.22);
      ctx.fillStyle = '#4b6b3e';
      ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, TAU); ctx.fill();
      ctx.fillStyle = '#5d8250';
      ctx.beginPath(); ctx.arc(c.x - c.r*0.25, c.y - c.r*0.25, c.r*0.7, 0, TAU); ctx.fill();
      ctx.fillStyle = '#3a5330';
      ctx.beginPath(); ctx.arc(c.x + c.r*0.3, c.y + c.r*0.3, c.r*0.42, 0, TAU); ctx.fill();
    } else if (c.type === 'mesa'){
      drawShadow(c.x, c.y + c.r*0.5, c.r*1.05, c.r*0.42, 0.26);
      ctx.fillStyle = '#8a6a4a';
      ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, TAU); ctx.fill();
      ctx.fillStyle = '#a8804f';
      ctx.beginPath(); ctx.arc(c.x - c.r*0.18, c.y - c.r*0.2, c.r*0.78, 0, TAU); ctx.fill();
      ctx.fillStyle = '#c39a63';
      ctx.beginPath(); ctx.arc(c.x - c.r*0.3, c.y - c.r*0.32, c.r*0.45, 0, TAU); ctx.fill();
    } else if (c.type === 'crate'){
      drawShadow(c.x, c.y + c.r*0.6, c.r, c.r*0.4, 0.24);
      ctx.fillStyle = '#7a5530';
      ctx.fillRect(c.x - c.r, c.y - c.r, c.r*2, c.r*2);
      ctx.strokeStyle = '#5a3d20'; ctx.lineWidth = 3;
      ctx.strokeRect(c.x - c.r, c.y - c.r, c.r*2, c.r*2);
      ctx.beginPath();
      ctx.moveTo(c.x - c.r, c.y - c.r); ctx.lineTo(c.x + c.r, c.y + c.r);
      ctx.moveTo(c.x + c.r, c.y - c.r); ctx.lineTo(c.x - c.r, c.y + c.r);
      ctx.stroke();
    } else if (c.type === 'trough'){
      drawShadow(c.x, c.y + c.r*0.6, c.r, c.r*0.4, 0.24);
      ctx.fillStyle = '#6b4a2a';
      ctx.fillRect(c.x - c.r, c.y - c.r*0.6, c.r*2, c.r*1.2);
      ctx.fillStyle = '#3f6b7a';
      ctx.fillRect(c.x - c.r + 4, c.y - c.r*0.6 + 4, c.r*2 - 8, c.r*1.2 - 8);
    } else if (c.type === 'post'){
      drawShadow(c.x, c.y + c.r*0.6, c.r*0.8, c.r*0.35, 0.2);
      ctx.fillStyle = '#6b4a2a';
      ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, TAU); ctx.fill();
    } else {
      drawShadow(c.x, c.y + c.r*0.55, c.r*0.95, c.r*0.4, 0.24);
      ctx.fillStyle = '#7d6b57';
      ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, TAU); ctx.fill();
      ctx.fillStyle = '#94806a';
      ctx.beginPath(); ctx.arc(c.x - c.r*0.22, c.y - c.r*0.24, c.r*0.68, 0, TAU); ctx.fill();
      ctx.fillStyle = '#a8967c';
      ctx.beginPath(); ctx.arc(c.x - c.r*0.3, c.y - c.r*0.32, c.r*0.34, 0, TAU); ctx.fill();
    }
  }
}
function drawRects(){
  for (const r of rects){
    if (r.x + r.w < cam.x - 80 || r.x > cam.x + VW + 80 ||
        r.y + r.h < cam.y - 80 || r.y > cam.y + VH + 80) continue;
    ctx.fillStyle = 'rgba(40,26,14,.28)';
    ctx.fillRect(r.x + 8, r.y + r.h, r.w, 14);
    ctx.fillStyle = '#6b4b32';
    ctx.fillRect(r.x, r.y, r.w, r.h);
    ctx.strokeStyle = 'rgba(48,30,18,.35)'; ctx.lineWidth = 1;
    for (let y = r.y + 10; y < r.y + r.h; y += 12){
      ctx.beginPath(); ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); ctx.stroke();
    }
    ctx.fillStyle = '#4a3220';
    ctx.fillRect(r.x - 6, r.y - 14, r.w + 12, 20);
    ctx.fillStyle = '#5c3d27';
    ctx.fillRect(r.x - 6, r.y - 14, r.w + 12, 5);
    ctx.fillStyle = '#33210f';
    ctx.fillRect(r.x + r.w/2 - 16, r.y + r.h - 34, 32, 34);
    ctx.fillStyle = '#2a1c10';
    ctx.fillRect(r.x + 10, r.y + 16, r.w - 20, 24);
    ctx.fillStyle = '#e8c98a';
    ctx.font = 'bold 13px Georgia, serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    const label = r.type === 'saloon' ? 'SALOON'
                : r.type === 'store'  ? 'GENERAL STORE'
                : r.type === 'stable' ? 'STABLE'
                : 'SHERIFF';
    ctx.fillText(label, r.x + r.w/2, r.y + 29);

    const cx = r.x + r.w/2, cy = r.y + r.h/2;
    const pd = Math.hypot(player.x - cx, player.y - cy);
    if (pd < 140 && game.state === 'play'){
      ctx.strokeStyle = 'rgba(232,201,138,' + (0.5 + 0.3*Math.sin(game.t*6)) + ')';
      ctx.lineWidth = 2;
      ctx.strokeRect(r.x - 4, r.y - 4, r.w + 8, r.h + 8);
      ctx.fillStyle = '#e8c98a';
      ctx.font = 'bold 13px Georgia, serif';
      ctx.textAlign = 'center';
      let hint;
      if (r.type === 'saloon') hint = '[E] WHISKEY  $6';
      else if (r.type === 'store') hint = '[E] ROUNDS  $10';
      else if (r.type === 'stable'){
        const next = (save.horseTier || 0) + 1;
        if (next >= HORSE_TYPES.length) hint = 'BEST MOUNT OWNED';
        else hint = '[E] ' + HORSE_TYPES[next].name + '  $' + HORSE_TYPES[next].cost;
      } else hint = '[E] PAY NOTORIETY  $' + Math.ceil(player.notoriety*2);
      ctx.fillText(hint, cx, r.y - 26);
    }
  }
}
function drawHorse(){
  if (!horse.alive){
    ctx.save(); ctx.translate(horse.x, horse.y);
    ctx.fillStyle = 'rgba(60,40,26,.5)';
    ctx.beginPath(); ctx.ellipse(0, 0, 30, 16, 0, 0, TAU); ctx.fill();
    ctx.restore(); return;
  }
  const bob = Math.sin(horse.bob) * 1.6;
  drawShadow(horse.x, horse.y + 14, 26, 11, 0.26);
  ctx.save();
  ctx.translate(horse.x, horse.y + bob);
  ctx.rotate(horse.angle + Math.PI/2);
  ctx.fillStyle = horse.color;
  ctx.beginPath(); ctx.ellipse(0, 0, 15, 26, 0, 0, TAU); ctx.fill();
  ctx.fillStyle = horse.mane;
  ctx.fillRect(-12, -18, 7, 14); ctx.fillRect(5, -18, 7, 14);
  ctx.fillRect(-12, 6, 7, 14);   ctx.fillRect(5, 6, 7, 14);
  ctx.fillStyle = horse.accent;
  ctx.beginPath(); ctx.ellipse(0, -30, 9, 13, 0, 0, TAU); ctx.fill();
  ctx.fillStyle = horse.mane;
  ctx.fillRect(-3, -24, 6, 22);
  // Spot pattern for higher tiers
  if (horse.tier >= 1){
    ctx.fillStyle = horse.mane;
    ctx.beginPath(); ctx.arc(-6, -4, 3, 0, TAU); ctx.fill();
    ctx.beginPath(); ctx.arc(5, 4, 2.5, 0, TAU); ctx.fill();
    ctx.beginPath(); ctx.arc(-4, 8, 2, 0, TAU); ctx.fill();
  }
  ctx.restore();
  if (horse.hp < horse.maxHp){
    const w = 44;
    ctx.fillStyle = 'rgba(0,0,0,.5)';
    ctx.fillRect(horse.x - w/2, horse.y - 46, w, 5);
    ctx.fillStyle = horse.hp / horse.maxHp > 0.5 ? '#c98a4a' : '#c94a4a';
    ctx.fillRect(horse.x - w/2, horse.y - 46, w * (horse.hp/horse.maxHp), 5);
    ctx.fillStyle = '#e8dcc0';
    ctx.font = 'bold 10px Georgia, serif';
    ctx.textAlign = 'center';
    ctx.fillText(horse.name, horse.x, horse.y - 52);
  }
  if (!player.mounted && game.state === 'play'){
    const d = Math.hypot(player.x - horse.x, player.y - horse.y);
    if (d < 78){
      ctx.fillStyle = '#e8c98a';
      ctx.font = 'bold 13px Georgia, serif';
      ctx.textAlign = 'center';
      ctx.fillText('[E] MOUNT ' + horse.name.toUpperCase(), horse.x, horse.y - 54);
    }
  }
}
function drawPlayer(){
  const p = player;
  if (p.dead || p.mounted) return;
  const bob = Math.sin(game.t * 9) * 0.9;
  drawShadow(p.x, p.y + 12, 15, 7, 0.28);
  if (p.buffs.shield > 0){
    ctx.strokeStyle = 'rgba(58,140,232,' + (0.55 + 0.25*Math.sin(game.t*10)) + ')';
    ctx.lineWidth = 2.5;
    ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, TAU); ctx.stroke();
  }
  if (p.buffs.minigun > 0){
    ctx.strokeStyle = 'rgba(201,201,201,' + (0.6 + 0.3*Math.sin(game.t*18)) + ')';
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(p.x, p.y, 26, 0, TAU); ctx.stroke();
  }
  if (p.buffs.multishot > 0){
    ctx.strokeStyle = 'rgba(255,68,170,' + (0.5 + 0.3*Math.sin(game.t*8)) + ')';
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(p.x, p.y, 24, 0, TAU); ctx.stroke();
  }
  ctx.save();
  ctx.translate(p.x, p.y + bob);
  ctx.fillStyle = '#3d3226';
  ctx.beginPath(); ctx.arc(-5, 8, 5, 0, TAU); ctx.fill();
  ctx.beginPath(); ctx.arc( 5, 8, 5, 0, TAU); ctx.fill();
  ctx.fillStyle = p.invuln > 0 ? '#ffd9a0' : '#a8916a';
  ctx.beginPath(); ctx.arc(0, 0, 13, 0, TAU); ctx.fill();
  ctx.fillStyle = '#7d6a4c';
  ctx.beginPath(); ctx.arc(0, 0, 9.5, 0, TAU); ctx.fill();
  ctx.save();
  ctx.rotate(p.angle);
  const rec = p.recoil * 5;
  if (p.buffs.minigun > 0){
    ctx.fillStyle = '#5a4632';
    ctx.fillRect(6 - rec, -4.5, 14, 9);
    ctx.fillStyle = '#888';
    ctx.fillRect(18 - rec, -3, 16, 6);
    ctx.fillStyle = '#333';
    ctx.fillRect(32 - rec, -2.5, 4, 5);
  } else {
    ctx.fillStyle = '#5a4632';
    ctx.fillRect(6 - rec, -3.5, 16, 7);
    ctx.fillStyle = '#3a3a3a';
    ctx.fillRect(20 - rec, -2.5, 10, 5);
  }
  ctx.restore();
  ctx.fillStyle = '#4a3826';
  ctx.beginPath(); ctx.ellipse(0, -3, 12, 11, 0, 0, TAU); ctx.fill();
  ctx.fillStyle = '#5c4630';
  ctx.beginPath(); ctx.ellipse(0, -6, 7.5, 7, 0, 0, TAU); ctx.fill();
  ctx.restore();
  if (game.focus){
    ctx.strokeStyle = 'rgba(232,201,138,' + (0.5 + 0.35*Math.sin(game.t*14)) + ')';
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(p.x, p.y, 22 + Math.sin(game.t*8)*2, 0, TAU); ctx.stroke();
  }
}
function drawMountedPlayer(){
  const p = player;
  if (!p.mounted || !horse.alive) return;
  drawShadow(horse.x, horse.y + 14, 28, 12, 0.3);
  ctx.save();
  ctx.translate(horse.x, horse.y);
  ctx.rotate(horse.angle + Math.PI/2);
  ctx.fillStyle = horse.color;
  ctx.beginPath(); ctx.ellipse(0, 0, 15, 26, 0, 0, TAU); ctx.fill();
  ctx.fillStyle = horse.mane;
  ctx.fillRect(-12, -18, 7, 14); ctx.fillRect(5, -18, 7, 14);
  ctx.fillRect(-12, 6, 7, 14);   ctx.fillRect(5, 6, 7, 14);
  ctx.fillStyle = horse.accent;
  ctx.beginPath(); ctx.ellipse(0, -30, 9, 13, 0, 0, TAU); ctx.fill();
  ctx.fillStyle = horse.mane;
  ctx.fillRect(-3, -24, 6, 22);
  if (horse.tier >= 1){
    ctx.fillStyle = horse.mane;
    ctx.beginPath(); ctx.arc(-6, -4, 3, 0, TAU); ctx.fill();
    ctx.beginPath(); ctx.arc(5, 4, 2.5, 0, TAU); ctx.fill();
  }
  ctx.fillStyle = '#a8916a';
  ctx.beginPath(); ctx.arc(0, 4, 11, 0, TAU); ctx.fill();
  ctx.fillStyle = '#4a3826';
  ctx.beginPath(); ctx.ellipse(0, 0, 10, 9, 0, 0, TAU); ctx.fill();
  ctx.restore();
  ctx.save();
  ctx.translate(horse.x, horse.y);
  ctx.rotate(player.angle);
  const rec = p.recoil * 5;
  if (p.buffs.minigun > 0){
    ctx.fillStyle = '#5a4632';
    ctx.fillRect(8 - rec, -4.5, 14, 9);
    ctx.fillStyle = '#888';
    ctx.fillRect(20 - rec, -3, 16, 6);
  } else {
    ctx.fillStyle = '#5a4632';
    ctx.fillRect(8 - rec, -3.5, 16, 7);
    ctx.fillStyle = '#3a3a3a';
    ctx.fillRect(22 - rec, -2.5, 10, 5);
  }
  ctx.restore();
}
function drawMeleeSwing(){
  if (player.meleeSwing <= 0) return;
  const p = player;
  const prog = 1 - p.meleeSwing;
  const arc = p.mounted ? 2.0 : 1.5;
  const range = (p.mounted ? 74 : 52) * (0.85 + prog * 0.35);
  const a0 = p.angle - arc/2;
  const a1 = p.angle + arc/2;
  ctx.save();
  ctx.globalAlpha = p.meleeSwing * 0.75;
  const g = ctx.createRadialGradient(p.x, p.y, 8, p.x, p.y, range);
  g.addColorStop(0, 'rgba(255,240,200,.1)');
  g.addColorStop(1, 'rgba(255,223,154,.55)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.moveTo(p.x, p.y);
  ctx.arc(p.x, p.y, range, a0, a1);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,223,154,' + (p.meleeSwing * 0.9) + ')';
  ctx.lineWidth = 2.5;
  ctx.beginPath();
  ctx.arc(p.x, p.y, range, a0, a1);
  ctx.stroke();
  ctx.restore();
}
function drawEnemies(){
  for (const e of enemies){
    if (e.x < cam.x - 120 || e.x > cam.x + VW + 120 ||
        e.y < cam.y - 120 || e.y > cam.y + VH + 120) continue;
    const bob = Math.sin(game.t * 8 + e.seed) * 0.8;
    drawShadow(e.x, e.y + 12, e.r + 1, 6.5, 0.26);
    if (e.type === 'captain'){
      ctx.strokeStyle = 'rgba(140,90,220,' + (0.35 + 0.15*Math.sin(game.t*3)) + ')';
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(e.x, e.y, 230, 0, TAU); ctx.stroke();
    }
    if (e.type === 'medic'){
      ctx.strokeStyle = 'rgba(106,212,138,' + (0.25 + 0.1*Math.sin(game.t*4)) + ')';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(e.x, e.y, e.range, 0, TAU); ctx.stroke();
    }
    if (e.type === 'brute' && e.meleeWindup > 0){
      ctx.strokeStyle = 'rgba(255,120,40,' + (0.8 * (e.meleeWindup / 0.4)) + ')';
      ctx.lineWidth = 3;
      ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 14, 0, TAU); ctx.stroke();
    }
    if (e.type === 'scout' && e.dashTimer > 0){
      ctx.strokeStyle = 'rgba(170,255,136,' + (0.5 + 0.3*Math.sin(game.t*20)) + ')';
      ctx.lineWidth = 2.5;
      ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 8, 0, TAU); ctx.stroke();
    }
    ctx.save();
    ctx.translate(e.x, e.y + bob);
    ctx.fillStyle = '#20282f';
    ctx.beginPath(); ctx.arc(-e.r*0.36, e.r*0.6, e.r*0.34, 0, TAU); ctx.fill();
    ctx.beginPath(); ctx.arc( e.r*0.36, e.r*0.6, e.r*0.34, 0, TAU); ctx.fill();
    ctx.fillStyle = e.hitFlash > 0 ? '#e8d0c0' : e.color;
    ctx.beginPath(); ctx.arc(0, 0, e.r * 0.9, 0, TAU); ctx.fill();
    ctx.save();
    ctx.rotate(e.angle);
    if (e.behavior === 'melee'){
      ctx.fillStyle = '#3a2a1a'; ctx.fillRect(e.r*0.6, -5, 14, 10);
    } else if (e.behavior === 'shotgun'){
      ctx.fillStyle = '#2a2a2a'; ctx.fillRect(e.r*0.5, -4, 22, 8);
      ctx.fillStyle = '#4a4a4a'; ctx.fillRect(e.r*0.5 + 18, -5, 5, 10);
    } else if (e.behavior === 'bomb'){
      ctx.fillStyle = '#2a1a0a'; ctx.beginPath(); ctx.arc(e.r*0.9, 0, 5, 0, TAU); ctx.fill();
      ctx.fillStyle = '#8a4a2a'; ctx.beginPath(); ctx.arc(e.r*0.9, 0, 3.5, 0, TAU); ctx.fill();
    } else if (e.behavior === 'medic'){
      ctx.fillStyle = '#c94a4a'; ctx.fillRect(-4, -3, 8, 8);
    } else {
      ctx.fillStyle = '#2a2a2a'; ctx.fillRect(e.r*0.4, -2, 20, 4);
    }
    ctx.restore();
    ctx.fillStyle = e.hat;
    ctx.beginPath(); ctx.ellipse(0, -e.r*0.2, e.r*0.85, e.r*0.78, 0, 0, TAU); ctx.fill();
    ctx.fillStyle = e.hitFlash > 0 ? '#e8d0c0' : '#3a4654';
    ctx.beginPath(); ctx.ellipse(0, -e.r*0.4, e.r*0.5, e.r*0.5, 0, 0, TAU); ctx.fill();
    if (e.type === 'medic'){
      ctx.fillStyle = '#8ee08e';
      ctx.fillRect(-1.6, -e.r*0.65, 3.2, 8);
      ctx.fillRect(-4, -e.r*0.65 + 2.4, 8, 3.2);
    } else if (e.type === 'captain'){
      ctx.fillStyle = '#ffdf9a';
      ctx.beginPath();
      for (let i = 0; i < 10; i++){
        const a = -Math.PI/2 + i * Math.PI/5;
        const rad = i % 2 === 0 ? 5 : 2.2;
        const px = Math.cos(a) * rad;
        const py = -e.r*0.55 + Math.sin(a) * rad;
        if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
      }
      ctx.closePath(); ctx.fill();
    } else if (e.type === 'bomber'){
      ctx.fillStyle = '#ffb060';
      ctx.beginPath(); ctx.arc(0, -e.r*0.6, 2, 0, TAU); ctx.fill();
    } else if (e.type === 'scout'){
      ctx.fillStyle = '#c94a4a';
      ctx.fillRect(-e.r*0.55, -e.r*0.1, e.r*1.1, 3);
    }
    ctx.restore();
    if (e.hp < e.maxHp){
      const w = e.type === 'brute' ? 44 : e.type === 'captain' ? 38 : 30;
      ctx.fillStyle = 'rgba(0,0,0,.55)';
      ctx.fillRect(e.x - w/2, e.y - e.r - 20, w, 4);
      ctx.fillStyle = e.type === 'brute' ? '#e08a3a'
                   : e.type === 'captain' ? '#a86ae8'
                   : e.type === 'medic' ? '#6ad48a'
                   : '#c0392b';
      ctx.fillRect(e.x - w/2, e.y - e.r - 20, w * (e.hp/e.maxHp), 4);
    }
    if (e.buffDmg > 1 && e.type !== 'captain'){
      ctx.fillStyle = 'rgba(200,140,255,.9)';
      ctx.font = 'bold 10px Georgia, serif';
      ctx.textAlign = 'center';
      ctx.fillText('▲', e.x, e.y - e.r - 26);
    }
  }
}
function drawGrenades(){
  for (const g of grenades){
    const pulse = 1 + Math.sin(game.t * 12) * 0.15;
    ctx.strokeStyle = 'rgba(255,120,40,' + (0.7 / pulse) + ')';
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(g.tx, g.ty, g.radius * pulse, 0, TAU); ctx.stroke();
    ctx.fillStyle = 'rgba(255,120,40,.12)';
    ctx.beginPath(); ctx.arc(g.tx, g.ty, g.radius, 0, TAU); ctx.fill();
    drawShadow(g.x, g.y + 6, 6, 3, 0.35);
    const k = g.t / g.dur;
    const height = Math.sin(k * Math.PI) * 26;
    ctx.save();
    ctx.translate(g.x, g.y - height);
    ctx.fillStyle = '#3a3a3a';
    ctx.beginPath(); ctx.arc(0, 0, 6, 0, TAU); ctx.fill();
    ctx.fillStyle = '#8a4a2a';
    ctx.beginPath(); ctx.arc(0, 0, 4, 0, TAU); ctx.fill();
    ctx.fillStyle = '#ffb060';
    ctx.beginPath(); ctx.arc(2, -3, 1.8, 0, TAU); ctx.fill();
    ctx.restore();
  }
}
function drawHealBeams(){
  for (const b of healBeams){
    const a = clamp(b.life / b.max, 0, 1);
    ctx.strokeStyle = 'rgba(106,212,138,' + (a * 0.85) + ')';
    ctx.lineWidth = 3;
    ctx.beginPath(); ctx.moveTo(b.x1, b.y1); ctx.lineTo(b.x2, b.y2); ctx.stroke();
    ctx.strokeStyle = 'rgba(200,255,210,' + (a * 0.5) + ')';
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(b.x1, b.y1); ctx.lineTo(b.x2, b.y2); ctx.stroke();
  }
}
function drawBullets(){
  for (const b of bullets){
    const a = Math.atan2(b.vy, b.vx);
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.rotate(a);
    const len = b.from === 'player' ? 18 : 14;
    const grad = ctx.createLinearGradient(-len, 0, 6, 0);
    if (b.from === 'player'){
      grad.addColorStop(0, 'rgba(255,220,140,0)');
      grad.addColorStop(1, 'rgba(255,240,190,1)');
    } else {
      grad.addColorStop(0, 'rgba(255,150,110,0)');
      grad.addColorStop(1, 'rgba(255,190,140,1)');
    }
    ctx.strokeStyle = grad;
    ctx.lineWidth = 3;
    ctx.lineCap = 'round';
    ctx.beginPath(); ctx.moveTo(-len, 0); ctx.lineTo(4, 0); ctx.stroke();
    ctx.restore();
  }
}
function drawParticles(){
  for (const p of particles){
    const a = clamp(p.life / p.max, 0, 1);
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.beginPath(); ctx.arc(p.x, p.y, p.size * a, 0, TAU); ctx.fill();
  }
  ctx.globalAlpha = 1;
}
function drawPickups(){
  for (const k of pickups){
    const bob = Math.sin(game.t * 4 + k.x * 0.02) * 3;
    ctx.save();
    ctx.translate(k.x, k.y + bob);
    if (k.type === 'cash'){
      ctx.fillStyle = '#3f6b3f'; ctx.fillRect(-9, -6, 18, 12);
      ctx.fillStyle = '#7bc47b'; ctx.fillRect(-9, -6, 18, 3);
      ctx.strokeStyle = '#c9e8c9'; ctx.lineWidth = 1.5; ctx.strokeRect(-9, -6, 18, 12);
    } else if (k.type === 'ammo'){
      ctx.fillStyle = '#8a6a3a'; ctx.fillRect(-9, -6, 18, 12);
      ctx.fillStyle = '#e8c98a'; ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, TAU); ctx.fill();
    } else if (k.type === 'health'){
      ctx.fillStyle = '#8b1a1a'; ctx.fillRect(-6, -8, 12, 16);
      ctx.fillStyle = '#c94a4a'; ctx.fillRect(-4, -5, 8, 5);
    } else if (k.type === 'power'){
      const def = POWERUPS[k.kind];
      const pulse = 1 + Math.sin(game.t * 6) * 0.15;
      ctx.strokeStyle = def.color; ctx.lineWidth = 2;
      ctx.globalAlpha = 0.65;
      ctx.beginPath(); ctx.arc(0, 0, 13 * pulse, 0, TAU); ctx.stroke();
      ctx.globalAlpha = 1;
      ctx.fillStyle = def.color;
      ctx.beginPath(); ctx.arc(0, 0, 8, 0, TAU); ctx.fill();
      ctx.fillStyle = 'rgba(255,255,255,.85)';
      ctx.beginPath(); ctx.arc(-2, -2, 2.6, 0, TAU); ctx.fill();
      ctx.fillStyle = '#1a1410';
      ctx.font = 'bold 11px Georgia, serif';
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(def.icon, 0, 0.5);
    } else if (k.type === 'book'){
      const pulse = 1 + Math.sin(game.t * 5) * 0.12;
      ctx.strokeStyle = 'rgba(255,223,154,' + (0.6 + 0.3*Math.sin(game.t*4)) + ')';
      ctx.lineWidth = 2;
      ctx.globalAlpha = 0.7;
      ctx.beginPath(); ctx.arc(0, 0, 16 * pulse, 0, TAU); ctx.stroke();
      ctx.globalAlpha = 1;
      ctx.fillStyle = '#5a3a1a'; ctx.fillRect(-10, -8, 20, 16);
      ctx.fillStyle = '#8a5a2a'; ctx.fillRect(-8, -6, 16, 12);
      ctx.fillStyle = '#ffdf9a'; ctx.fillRect(-10, -1, 20, 2); ctx.fillRect(-2, -8, 4, 16);
      ctx.strokeStyle = '#2a1a0a'; ctx.lineWidth = 1; ctx.strokeRect(-10, -8, 20, 16);
    }
    ctx.restore();
  }
}
function drawFloaters(){
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.font = 'bold 14px Georgia, serif';
  for (const f of floaters){
    const a = clamp(f.life / 1.1, 0, 1);
    ctx.globalAlpha = a;
    ctx.fillStyle = 'rgba(0,0,0,.6)';
    ctx.fillText(f.text, f.x + 1, f.y + 1);
    ctx.fillStyle = f.color;
    ctx.fillText(f.text, f.x, f.y);
  }
  ctx.globalAlpha = 1;
}
function drawDmgNumbers(){
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  for (const n of dmgNumbers){
    const a = clamp(n.life / n.max, 0, 1);
    ctx.globalAlpha = a;
    ctx.font = n.crit ? 'bold 18px Georgia, serif' : 'bold 13px Georgia, serif';
    ctx.fillStyle = 'rgba(0,0,0,.7)';
    ctx.fillText(n.amount, n.x + 1, n.y + 1);
    ctx.fillStyle = n.crit ? '#ffe79a' : '#ffffff';
    ctx.fillText(n.amount, n.x, n.y);
  }
  ctx.globalAlpha = 1;
}

/* ============== HUD ============== */
function bar(x, y, w, h, pct, fill, bg, label){
  ctx.fillStyle = 'rgba(20,14,8,.72)';
  ctx.fillRect(x - 2, y - 2, w + 4, h + 4);
  ctx.fillStyle = bg; ctx.fillRect(x, y, w, h);
  ctx.fillStyle = fill; ctx.fillRect(x, y, w * clamp(pct, 0, 1), h);
  ctx.strokeStyle = 'rgba(232,201,138,.35)'; ctx.lineWidth = 1;
  ctx.strokeRect(x, y, w, h);
  if (label){
    ctx.fillStyle = '#e8dcc0';
    ctx.font = 'bold 10px Georgia, serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(label, x + 6, y + h/2 + 1);
  }
}
function drawBuffs(){
  const active = [];
  for (const k in player.buffs){
    if (player.buffs[k] > 0) active.push({ k, t: player.buffs[k], def: POWERUPS[k] });
  }
  if (!active.length) return;
  let x = 24, y = 96;
  for (const b of active){
    const w = 130, h = 22;
    ctx.fillStyle = 'rgba(20,14,8,.72)';
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = b.def.color; ctx.lineWidth = 1;
    ctx.strokeRect(x, y, w, h);
    ctx.fillStyle = b.def.color; ctx.globalAlpha = 0.22;
    ctx.fillRect(x, y, w * (b.t / b.def.dur), h);
    ctx.globalAlpha = 1;
    ctx.fillStyle = b.def.color;
    ctx.font = 'bold 12px Georgia, serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(b.def.icon + ' ' + b.def.name, x + 8, y + h/2 + 1);
    ctx.fillStyle = b.t < 2 ? '#ff7a7a' : '#e8dcc0';
    ctx.textAlign = 'right';
    ctx.fillText(b.t.toFixed(1), x + w - 8, y + h/2 + 1);
    y += h + 4;
  }
}
function drawHUD(){
  const p = player;
  bar(24, 24, 230, 16, p.hp / p.maxHp, '#b4342a', '#3a1a16', 'HEALTH');
  bar(24, 48, 230, 12, p.composure / p.maxComposure,
      game.focus ? '#ffdf9a' : '#c9a44a', '#2e2415', 'FOCUS');
  if (p.mounted && horse.alive){
    bar(24, 70, 200, 12, horse.hp / horse.maxHp, '#a8753a', '#2e2415', 'HORSE');
    ctx.fillStyle = '#b09a72';
    ctx.font = 'bold 10px Georgia, serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(horse.name.toUpperCase() + '  ' + Math.ceil(horse.hp) + ' / ' + Math.ceil(horse.maxHp), 232, 77);
  }
  if (p.meleeCd > 0){
    const w = 40, x = 24, y = 88;
    ctx.fillStyle = 'rgba(20,14,8,.72)';
    ctx.fillRect(x, y, w, 6);
    ctx.fillStyle = 'rgba(232,201,138,.35)';
    ctx.fillRect(x, y, w, 6);
    ctx.fillStyle = '#ffdf9a';
    const maxCd = p.mounted ? 0.75 : 0.5;
    ctx.fillRect(x, y, w * (1 - p.meleeCd / maxCd), 6);
    ctx.fillStyle = '#b09a72';
    ctx.font = 'bold 9px Georgia, serif';
    ctx.fillText('MELEE', x + w + 6, y + 3);
  }

  drawBuffs();

  ctx.textAlign = 'right'; ctx.textBaseline = 'top';
  ctx.font = 'bold 22px Georgia, serif';
  ctx.fillStyle = '#e8c98a';
  ctx.fillText('$' + p.cash, VW - 24, 22);
  ctx.font = 'bold 12px Georgia, serif';
  ctx.fillStyle = '#cbb891';
  ctx.fillText('NOTORIETY ' + p.notoriety, VW - 24, 50);
  ctx.fillText('WAVE ' + game.wave + ' / ' + TOTAL_WAVES + '   ·   KILLS ' + game.kills, VW - 24, 68);
  ctx.fillText('BANK $' + save.bank, VW - 24, 86);
  ctx.fillStyle = '#ffdf9a';
  ctx.fillText('BOOKS ' + save.books.length + ' / ' + TOTAL_BOOKS, VW - 24, 104);

  const wpW = 180, wpX = VW - 24 - wpW, wpY = 122;
  ctx.fillStyle = 'rgba(20,14,8,.7)';
  ctx.fillRect(wpX - 1, wpY - 1, wpW + 2, 6);
  const wpPct = clamp((game.wave - 1 + (game.waveTimer / 32)) / TOTAL_WAVES, 0, 1);
  ctx.fillStyle = game.wave >= TOTAL_WAVES ? '#ffdf9a' : '#e8c98a';
  ctx.fillRect(wpX, wpY, wpW * wpPct, 4);
  ctx.strokeStyle = 'rgba(232,201,138,.4)';
  ctx.strokeRect(wpX - 1, wpY - 1, wpW + 2, 6);

  const cx0 = VW/2 - 60, cy0 = VH - 54;
  ctx.fillStyle = 'rgba(20,14,8,.7)';
  ctx.fillRect(cx0 - 16, cy0 - 16, 152, 38);
  ctx.strokeStyle = 'rgba(232,201,138,.3)';
  ctx.strokeRect(cx0 - 16, cy0 - 16, 152, 38);
  if (p.buffs.minigun > 0){
    ctx.fillStyle = '#c9c9c9';
    ctx.font = 'bold 16px Georgia, serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText('MINIGUN', cx0 + 10, cy0);
    ctx.font = 'bold 11px Georgia, serif';
    ctx.fillStyle = '#e8dcc0';
    ctx.fillText('∞ ammo', cx0 + 54, cy0 + 18);
  } else {
    for (let i = 0; i < p.maxCylinder; i++){
      const a = (i / p.maxCylinder) * TAU - Math.PI/2;
      const bx = cx0 + 12 + Math.cos(a) * 15;
      const by = cy0 + Math.sin(a) * 15;
      ctx.beginPath(); ctx.arc(bx, by, 5, 0, TAU);
      ctx.fillStyle = i < p.cylinder ? '#e8c98a' : 'rgba(90,70,45,.55)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(0,0,0,.5)'; ctx.lineWidth = 1; ctx.stroke();
    }
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.font = 'bold 20px Georgia, serif';
    ctx.fillStyle = '#e8dcc0';
    ctx.fillText(p.reserve, cx0 + 54, cy0);
    if (p.reloadT > 0){
      ctx.fillStyle = '#e8c98a';
      ctx.font = 'bold 12px Georgia, serif';
      ctx.fillText('RELOADING…', cx0 + 54, cy0 + 18);
    }
  }

  // Minimap
  const MS = 150, MX = VW - MS - 24, MY = VH - MS - 24;
  ctx.save();
  ctx.beginPath(); ctx.arc(MX + MS/2, MY + MS/2, MS/2, 0, TAU); ctx.clip();
  ctx.fillStyle = 'rgba(24,18,12,.82)'; ctx.fillRect(MX, MY, MS, MS);
  const scale = MS / WORLD;
  const ox = MX + MS/2, oy = MY + MS/2;
  ctx.fillStyle = 'rgba(232,201,138,.55)';
  ctx.fillRect(ox + (TOWN.x - WORLD/2)*scale - 8, oy + (TOWN.y - WORLD/2)*scale - 8, 16, 16);
  const enemyColors = {
    guard:'#d95555', scout:'#7fd955', shotgunner:'#d99a55',
    sharpshooter:'#d9c655', rider:'#d955a8', bomber:'#d95530',
    brute:'#ff5a2a', medic:'#6ad48a', captain:'#b06ae8'
  };
  for (const e of enemies){
    ctx.fillStyle = enemyColors[e.type] || '#d95555';
    const px = ox + (e.x - WORLD/2)*scale;
    const py = oy + (e.y - WORLD/2)*scale;
    const rr = e.type === 'brute' ? 3.4 : e.type === 'captain' ? 3.2 : 2.4;
    ctx.beginPath(); ctx.arc(px, py, rr, 0, TAU); ctx.fill();
  }
  ctx.fillStyle = '#ffd25a';
  for (const k of pickups){
    if (k.type === 'power'){
      ctx.beginPath(); ctx.arc(ox + (k.x - WORLD/2)*scale, oy + (k.y - WORLD/2)*scale, 3, 0, TAU); ctx.fill();
    }
  }
  ctx.fillStyle = '#ffdf9a';
  for (const k of pickups){
    if (k.type === 'book'){
      ctx.beginPath(); ctx.arc(ox + (k.x - WORLD/2)*scale, oy + (k.y - WORLD/2)*scale, 3.6, 0, TAU); ctx.fill();
    }
  }
  ctx.fillStyle = 'rgba(255,120,40,.85)';
  for (const g of grenades){
    ctx.beginPath(); ctx.arc(ox + (g.tx - WORLD/2)*scale, oy + (g.ty - WORLD/2)*scale, 2.5, 0, TAU); ctx.fill();
  }
  if (horse.alive){
    ctx.fillStyle = horse.color;
    ctx.beginPath(); ctx.arc(ox + (horse.x - WORLD/2)*scale, oy + (horse.y - WORLD/2)*scale, 2.6, 0, TAU); ctx.fill();
  }
  ctx.fillStyle = '#ffffff';
  ctx.beginPath(); ctx.arc(ox + (player.x - WORLD/2)*scale, oy + (player.y - WORLD/2)*scale, 3.4, 0, TAU); ctx.fill();
  ctx.restore();
  ctx.strokeStyle = 'rgba(232,201,138,.5)'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.arc(MX + MS/2, MY + MS/2, MS/2, 0, TAU); ctx.stroke();

  if (game.messageTimer > 0){
    const a = Math.min(1, game.messageTimer);
    ctx.globalAlpha = a;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.font = 'bold 34px Georgia, serif';
    ctx.fillStyle = 'rgba(0,0,0,.65)';
    ctx.fillText(game.message, VW/2 + 2, VH * 0.24 + 2);
    ctx.fillStyle = game.wave >= TOTAL_WAVES ? '#ffdf9a' : '#e8c98a';
    ctx.fillText(game.message, VW/2, VH * 0.24);
    ctx.globalAlpha = 1;
  }

  if (game.hurtFlash > 0){
    ctx.save();
    const g = ctx.createRadialGradient(VW/2, VH/2, Math.min(VW,VH)*0.25,
                                       VW/2, VH/2, Math.max(VW,VH)*0.72);
    g.addColorStop(0, 'rgba(140,0,0,0)');
    g.addColorStop(1, 'rgba(140,0,0,' + (0.62 * game.hurtFlash) + ')');
    ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH);
    ctx.restore();
  }

  if (game.wave >= TOTAL_WAVES && game.state === 'play' && game.finalWaveStart !== null){
    const fade = clamp((game.t - game.finalWaveStart) / 4, 0, 1);
    ctx.save();
    const g = ctx.createRadialGradient(VW/2, VH/2, Math.min(VW,VH)*0.2,
                                       VW/2, VH/2, Math.max(VW,VH)*0.78);
    g.addColorStop(0, 'rgba(180,80,20,0)');
    g.addColorStop(1, 'rgba(120,40,10,' + (0.38 * fade) + ')');
    ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH);
    ctx.restore();
  }

  if (game.focus){
    ctx.save();
    const g = ctx.createRadialGradient(VW/2, VH/2, Math.min(VW,VH)*0.18,
                                       VW/2, VH/2, Math.max(VW,VH)*0.68);
    g.addColorStop(0, 'rgba(120,90,30,0)');
    g.addColorStop(1, 'rgba(90,60,15,.55)');
    ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH);
    ctx.restore();
  }

  // Crosshair
  const mx = mouse.x, my = mouse.y;
  ctx.save();
  ctx.translate(mx, my);
  ctx.strokeStyle = game.focus ? 'rgba(255,225,150,.95)' : 'rgba(240,230,200,.75)';
  ctx.lineWidth = 1.6;
  const g0 = game.focus ? 4 : 8;
  const g1 = game.focus ? 12 : 16;
  ctx.beginPath();
  ctx.moveTo(-g1, 0); ctx.lineTo(-g0, 0);
  ctx.moveTo( g0, 0); ctx.lineTo( g1, 0);
  ctx.moveTo(0, -g1); ctx.lineTo(0, -g0);
  ctx.moveTo(0,  g0); ctx.lineTo(0,  g1);
  ctx.stroke();
  ctx.beginPath(); ctx.arc(0, 0, 2, 0, TAU);
  ctx.fillStyle = game.focus ? '#ffe79a' : 'rgba(240,230,200,.8)';
  ctx.fill();
  ctx.restore();

  // Offscreen arrows
  ctx.save();
  for (const e of enemies){
    const sx = e.x - cam.x, sy = e.y - cam.y;
    if (sx > -30 && sx < VW + 30 && sy > -30 && sy < VH + 30) continue;
    const a = Math.atan2(sy - VH/2, sx - VW/2);
    const rx = VW/2 + Math.cos(a) * (Math.min(VW,VH)/2 - 40);
    const ry = VH/2 + Math.sin(a) * (Math.min(VW,VH)/2 - 40);
    ctx.save();
    ctx.translate(rx, ry); ctx.rotate(a);
    ctx.fillStyle = 'rgba(200,70,60,.7)';
    ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -6); ctx.lineTo(-6, 6);
    ctx.closePath(); ctx.fill();
    ctx.restore();
  }
  ctx.restore();
}

function draw(){
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  ctx.clearRect(0, 0, VW, VH);
  const sx = (Math.random() - 0.5) * game.shake;
  const sy = (Math.random() - 0.5) * game.shake;
  ctx.save();
  ctx.translate(-cam.x + sx, -cam.y + sy);
  drawGround();
  drawRects();
  drawCircles();
  drawPickups();
  drawGrenades();
  drawHorse();
  drawEnemies();
  drawHealBeams();
  drawPlayer();
  drawMountedPlayer();
  drawMeleeSwing();
  drawBullets();
  drawParticles();
  drawDmgNumbers();
  drawFloaters();
  ctx.restore();
  drawHUD();
}

/* ============== LOOP ============== */
let last = performance.now();
function loop(now){
  requestAnimationFrame(loop);
  let dt = (now - last) / 1000;
  last = now;
  if (dt > 0.05) dt = 0.05;
  if (game.state === 'play') update(dt);
  else {
    cam.x = clamp(player.x - VW/2, 0, Math.max(0, WORLD - VW));
    cam.y = clamp(player.y - VH/2, 0, Math.max(0, WORLD - VH));
    game.t += dt;
  }
  draw();
}
requestAnimationFrame(loop);

/* ============== FLOW ============== */
function resetGame(){
  player.stats = computeStats();
  player.x = TOWN.x;
  player.y = TOWN.y + 300;
  player.maxHp = player.stats.maxHp;
  player.maxComposure = player.stats.maxComposure;
  player.hp = player.maxHp;
  player.composure = player.maxComposure;
  player.cylinder = player.maxCylinder;
  player.reserve = player.stats.startAmmo;
  player.cash = 0;
  player.notoriety = 0;
  player.mounted = false;
  player.fireCd = 0; player.reloadT = 0; player.invuln = 0;
  player.dead = false; player.recoil = 0;
  player.meleeCd = 0; player.meleeSwing = 0;
  player.buffs = { rapid:0, power:0, swift:0, shield:0, calm:0, minigun:0, multishot:0 };

  horse.x = TOWN.x + 110; horse.y = TOWN.y + 330;
  applyHorseTier();
  horse.hp = horse.maxHp;
  horse.alive = true;
  horse.angle = 0; horse.bob = 0;

  bullets = []; grenades = []; enemies = []; particles = [];
  pickups = []; floaters = []; healBeams = []; dmgNumbers = [];

  game.state = 'play';
  game.t = 0;
  game.focus = false;
  game.shake = 0;
  game.spawnTimer = 4;
  game.heat = 0;
  game.wave = 1;
  game.waveTimer = 0;
  game.kills = 0;
  game.message = 'THE MERIDIAN TERRITORIES — 1889';
  game.messageTimer = 3.6;
  game.hurtFlash = 0;
  game.runEarned = 0;
  game.finalWaveStart = null;
  game.victoryTimer = 0;
  game.manualEnd = false;

  cam.x = clamp(player.x - VW/2, 0, Math.max(0, WORLD - VW));
  cam.y = clamp(player.y - VH/2, 0, Math.max(0, WORLD - VH));
}

function togglePause(){
  if (game.state === 'play'){
    game.state = 'paused';
    document.getElementById('pauseScreen').classList.remove('hidden');
  } else if (game.state === 'paused'){
    game.state = 'play';
    document.getElementById('pauseScreen').classList.add('hidden');
  }
}

function endGame(manual){
  if (game.state !== 'play' && game.state !== 'paused') return;
  game.state = 'dead';
  document.getElementById('pauseScreen').classList.add('hidden');

  const earned = player.cash;
  save.bank += earned;
  save.totalEarned += earned;
  save.runs += 1;
  if (game.wave > save.bestWave) save.bestWave = game.wave;
  persistSave();

  const endScreen = document.getElementById('endScreen');
  document.getElementById('endTitle').textContent = manual ? 'RUN ENDED' : 'YOU DIED';
  document.getElementById('endSub').textContent = manual
    ? 'You laid down your arms. The Meridian keeps its dead.'
    : 'The Meridian keeps its dead';
  document.getElementById('endStats').innerHTML =
    'Waves survived: <b>' + game.wave + ' / ' + TOTAL_WAVES + '</b><br>' +
    'Iron Guard killed: <b>' + game.kills + '</b><br>' +
    'Cash carried out: <b>$' + earned + '</b><br>' +
    'Banked total: <b>$' + save.bank + '</b><br>' +
    'Best wave ever: <b>' + save.bestWave + '</b><br>' +
    'Books recovered: <b>' + save.books.length + ' / ' + TOTAL_BOOKS + '</b>';
  endScreen.classList.remove('hidden');
}

function victory(){
  if (game.state !== 'play') return;
  const hasAllBooks = save.books.length >= TOTAL_BOOKS;
  game.state = hasAllBooks ? 'secretVictory' : 'victory';

  const bonus = FINAL_WAVE_BONUS_BASE + game.kills * FINAL_WAVE_BONUS_PER_KILL;
  player.cash += bonus;
  const earned = player.cash;
  save.bank += earned;
  save.totalEarned += earned;
  save.runs += 1;
  if (hasAllBooks) save.secretVictories = (save.secretVictories || 0) + 1;
  else save.victories = (save.victories || 0) + 1;
  if (game.wave > save.bestWave) save.bestWave = game.wave;
  persistSave();

  const victoryScreen = document.getElementById('victoryScreen');
  const title = document.getElementById('victoryTitle');
  const sub = document.getElementById('victorySub');
  const tease = document.getElementById('victoryTease');
  const teaseLine = document.getElementById('victoryTeaseLine');
  const teaseTitle = document.getElementById('victoryTeaseTitle');
  const teaseSub = document.getElementById('victoryTeaseSub');

  if (hasAllBooks){
    title.className = 'small red';
    title.textContent = 'THE LEDGER IS CLOSED';
    sub.textContent = 'You survived all ten waves — and read every page';
    secretFanfare();
    document.getElementById('victoryStats').innerHTML =
      'Waves cleared: <b>' + TOTAL_WAVES + ' / ' + TOTAL_WAVES + '</b><br>' +
      'Iron Guard killed: <b>' + game.kills + '</b><br>' +
      'Books recovered: <b>' + save.books.length + ' / ' + TOTAL_BOOKS + '</b><br>' +
      'Survivor\'s bonus: <b>+$' + bonus + '</b><br>' +
      'Cash carried out: <b>$' + earned + '</b><br>' +
      'Banked total: <b>$' + save.bank + '</b><br>' +
      'Secret endings: <b>' + save.secretVictories + '</b>';
    tease.classList.add('secret');
    teaseLine.textContent = 'You read every page. You knew what came next.';
    teaseTitle.textContent = 'BITTER HORIZON 2';
    teaseSub.textContent = 'A debt is only paid when the debt is named';
  } else {
    title.className = 'small gold';
    title.textContent = 'THE MERIDIAN ENDURES';
    sub.textContent = 'You survived all ten waves';
    victoryFanfare();
    document.getElementById('victoryStats').innerHTML =
      'Waves cleared: <b>' + TOTAL_WAVES + ' / ' + TOTAL_WAVES + '</b><br>' +
      'Iron Guard killed: <b>' + game.kills + '</b><br>' +
      'Books recovered: <b>' + save.books.length + ' / ' + TOTAL_BOOKS + '</b><br>' +
      'Survivor\'s bonus: <b>+$' + bonus + '</b><br>' +
      'Cash carried out: <b>$' + earned + '</b><br>' +
      'Banked total: <b>$' + save.bank + '</b><br>' +
      'Victories: <b>' + save.victories + '</b><br>' +
      '<span style="color:#b09a72;font-size:12px">Find every page to unlock a darker ending.</span>';
    tease.classList.remove('secret');
    teaseLine.textContent = 'The Meridian was only the beginning.';
    teaseTitle.textContent = 'BITTER HORIZON 2';
    teaseSub.textContent = 'Coming when the dust settles';
  }
  victoryScreen.classList.remove('hidden');
}

/* ============== UI WIRING ============== */
const startScreen   = document.getElementById('startScreen');
const upgradeScreen = document.getElementById('upgradeScreen');
const loreScreen    = document.getElementById('loreScreen');
const pauseScreen   = document.getElementById('pauseScreen');
const endScreen     = document.getElementById('endScreen');
const victoryScreen = document.getElementById('victoryScreen');

function refreshHome(){ document.getElementById('startBank').textContent = save.bank; }

document.getElementById('startBtn').addEventListener('click', () => {
  initAudio();
  startScreen.classList.add('hidden');
  upgradeScreen.classList.add('hidden');
  loreScreen.classList.add('hidden');
  pauseScreen.classList.add('hidden');
  endScreen.classList.add('hidden');
  victoryScreen.classList.add('hidden');
  resetGame();
});
document.getElementById('upgradesBtn').addEventListener('click', () => {
  initAudio();
  startScreen.classList.add('hidden');
  renderUpgrades();
  upgradeScreen.classList.remove('hidden');
});
document.getElementById('upgradeBackBtn').addEventListener('click', () => {
  upgradeScreen.classList.add('hidden');
  refreshHome();
  startScreen.classList.remove('hidden');
});
function openLore(fromPause){
  renderLore();
  if (fromPause){
    pauseScreen.classList.add('hidden');
    loreScreen.classList.remove('hidden');
    loreScreen.dataset.returnTo = 'pause';
  } else {
    startScreen.classList.add('hidden');
    loreScreen.classList.remove('hidden');
    loreScreen.dataset.returnTo = 'home';
  }
}
document.getElementById('loreHomeBtn').addEventListener('click', () => openLore(false));
document.getElementById('lorePauseBtn').addEventListener('click', () => openLore(true));
document.getElementById('loreBackBtn').addEventListener('click', () => {
  loreScreen.classList.add('hidden');
  if (loreScreen.dataset.returnTo === 'pause'){
    pauseScreen.classList.remove('hidden');
    game.state = 'paused';
  } else {
    refreshHome();
    startScreen.classList.remove('hidden');
  }
});
document.getElementById('resumeBtn').addEventListener('click', togglePause);
document.getElementById('endRunBtn').addEventListener('click', () => endGame(true));
document.getElementById('restartBtn').addEventListener('click', () => {
  endScreen.classList.add('hidden');
  refreshHome();
  startScreen.classList.remove('hidden');
});
document.getElementById('victoryBtn').addEventListener('click', () => {
  victoryScreen.classList.add('hidden');
  refreshHome();
  startScreen.classList.remove('hidden');
});
document.getElementById('resetBtn').addEventListener('click', () => {
  if (confirm('Wipe all saved progress, upgrades, banked cash, lore, and horses?')){
    wipeSave();
    refreshHome();
    toast('Save wiped', 'bad');
  }
});

refreshHome();
applyHorseTier();

})();
</script>
</body>
</html>

Game Source: Bitter Horizon — A Western Requiem

Creator: QuantumFox55

Libraries: none

Complexity: complex (2429 lines, 95.2 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: bitter-horizon-a-western-requiem-quantumfox55" to link back to the original. Then publish at arcadelab.ai/publish.