Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/ui.js
'use strict';
// BLACK SITE - menus, loadout customization, drag-and-drop inventory, input wiring.

let MODE = 'menu'; // menu | loadout | maps | shop | campaign | play | paused | end
const $ = id => document.getElementById(id);

// Saved data goes through the shell-backed store that src/main.ts installs before boot.
const store = globalThis.BLACKSITE_STORE;
let LOADOUT = store.get('blacksite.loadout', null) || { primary: 'm4a1', secondary: 'm1911', throwable: 'frag' };
if (!PRIMARY_IDS.includes(LOADOUT.primary)) LOADOUT.primary = 'm4a1';
if (!SECONDARY_IDS.includes(LOADOUT.secondary)) LOADOUT.secondary = 'm1911';
if (!THROWABLE_IDS.includes(LOADOUT.throwable)) LOADOUT.throwable = null;

const PROFILE = Object.assign({ bank: 0, levels: {}, armours: [], armour: null, unlocked: [], armourLevels: {}, armourBonus: {} }, store.get('blacksite.profile', {}));
// the loadout throwable only counts once it's unlocked in the armory
let MAP_ID = store.get('blacksite.map', 'town');
if (!MAPS.some(m => m.id === MAP_ID)) MAP_ID = 'town';
const mapName = id => (MAPS.find(m => m.id === id) || MAPS[0]).name;
const throwableOk = () => !!LOADOUT.throwable && PROFILE.unlocked.includes(LOADOUT.throwable);
function saveProfile() { store.set('blacksite.profile', PROFILE); }
const money = n => '$' + n.toLocaleString();

// Campaign: all five maps in order. Inventory carries over between maps, kills and intel go
// into the funds hold, and the hold (plus a bonus) is paid out only when the campaign is complete.
// Dying ends the campaign. Progress is saved after every cleared map.
const CAMPAIGN_BONUS = 3000;
let CAMPAIGN = store.get('blacksite.campaign', null);
function saveCampaign() { store.set('blacksite.campaign', CAMPAIGN); }
// Quitting a campaign map partway through (quit, restart, or closing the game) costs the hold $400.
const QUIT_PENALTY = 400;
function chargeCampaignQuit() {
  if (!CAMPAIGN || !CAMPAIGN.inMap) return 0;
  const cut = Math.min(QUIT_PENALTY, CAMPAIGN.hold);
  CAMPAIGN.hold -= cut;
  CAMPAIGN.inMap = false;
  CAMPAIGN.lastQuit = cut;
  saveCampaign();
  return cut;
}
// a campaign map that was still in progress when the game was closed counts as a quit
chargeCampaignQuit();
const clone = o => JSON.parse(JSON.stringify(o));

let CHAR_ID = store.get('blacksite.character', 'assaulter');
if (!CHARACTERS[CHAR_ID]) CHAR_ID = 'assaulter';

let DIFF_ID = store.get('blacksite.difficulty', 'normal');
if (!DIFFICULTIES[DIFF_ID]) DIFF_ID = 'normal';
function renderDifficulty() {
  const d = DIFFICULTIES[DIFF_ID];
  $('diffSlider').value = DIFFICULTY_ORDER.indexOf(DIFF_ID);
  $('diffName').textContent = d.name;
  $('diffName').className = 'd-' + DIFF_ID;
  $('diffDesc').textContent = d.desc + (CAMPAIGN ? '  (Your saved campaign keeps the difficulty it was started on.)' : '');
}
$('diffSlider').addEventListener('input', e => {
  DIFF_ID = DIFFICULTY_ORDER[+e.target.value] || 'normal';
  store.set('blacksite.difficulty', DIFF_ID);
  renderDifficulty();
});

function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }

const UI = {
  show(id) {
    for (const s of document.querySelectorAll('.screen')) s.classList.toggle('on', s.id === id);
  },
  menu() {
    MODE = 'menu';
    G = null;
    const best = store.get('blacksite.best', null);
    $('best').textContent = best ? `BEST PAYOUT $${best.payout.toLocaleString()}  -  ${best.time}` : '';
    $('bank').textContent = `FUNDS ${money(PROFILE.bank)}`;
    renderDifficulty();
    $('menuMap').textContent = `MAP: ${mapName(MAP_ID)}`;
    $('menuCampaign').textContent = CAMPAIGN ? `CAMPAIGN IN PROGRESS: MAP ${CAMPAIGN.stage + 1} / ${MAPS.length}  -  HOLD ${money(CAMPAIGN.hold)}${CAMPAIGN.lastQuit ? `  (-${money(CAMPAIGN.lastQuit)} FOR QUITTING)` : ''}` : '';
    $('menuChar').textContent = `CHARACTER: ${CHARACTERS[CHAR_ID].name}`;
    $('menuLoadout').textContent = `${WEAPONS[LOADOUT.primary].name}  /  ${WEAPONS[LOADOUT.secondary].name}  /  ${throwableOk() ? ITEMS[LOADOUT.throwable].name : 'No throwable'}`;
    UI.show('menu');
  },
  deploy() {
    Sfx.init();
    newGame(LOADOUT, MAP_ID, null, DIFF_ID);
    MODE = 'play';
    UI.show(null);
  },
  deployCampaign() {
    if (!CAMPAIGN) { UI.campaign(); return; }
    Sfx.init();
    newGame(LOADOUT, MAPS[CAMPAIGN.stage].id, CAMPAIGN, CAMPAIGN.difficulty);
    CAMPAIGN.inMap = true;
    CAMPAIGN.lastQuit = 0;
    saveCampaign();
    MODE = 'play';
    UI.show(null);
  },
  newCampaign() {
    const kit = startingKit(LOADOUT, CHAR_ID);
    // a campaign keeps the difficulty it was started on
    CAMPAIGN = { stage: 0, hold: 0, kills: 0, time: 0, weapons: kit.weapons, items: kit.items, difficulty: DIFF_ID };
    saveCampaign();
    UI.deployCampaign();
  },
  campaign() {
    MODE = 'campaign';
    G = null;
    renderCampaign();
    UI.show('campaign');
  },
  pause() {
    if (MODE !== 'play' || !G || G.over) return;
    MODE = 'paused';
    input.mdown = false;
    $('pauseQuit').textContent = G.campaign ? `QUIT TO MENU  (-${money(QUIT_PENALTY)} FUNDS HOLD)` : 'QUIT TO MENU';
    $('pauseRestart').textContent = G.campaign ? `RESTART MAP  (-${money(QUIT_PENALTY)} FUNDS HOLD)` : 'RESTART MISSION';
    UI.show('pause');
  },
  resume() { MODE = 'play'; UI.show(null); },
  showEnd() {
    MODE = 'end';
    if (G.campaign) { showCampaignEnd(); return; }
    for (const b of document.querySelectorAll('#end .quick')) b.style.display = '';
    for (const b of document.querySelectorAll('#end .camp')) b.style.display = 'none';
    const won = G.won;
    const t = `${Math.floor(G.time / 60)}:${String(Math.floor(G.time % 60)).padStart(2, '0')}`;
    $('endTitle').textContent = won ? 'EXTRACTED' : 'K.I.A.';
    $('endTitle').className = won ? 'win' : 'lose';
    $('endSub').textContent = won ? 'Mission complete. Exfil successful.' : 'You were killed in action.';
    const acc = G.shots ? Math.round((G.hits / G.shots) * 100) : 0;
    const rows = [
      ['MAP', LEVEL.name], ['DIFFICULTY', G.diff.name], ['TIME', t], ['KILLS', G.kills], ['ACCURACY', `${acc}%`], ['DAMAGE TAKEN', Math.round(G.dmgTaken)],
    ];
    if (G.penalty) rows.push(['TIME PENALTY', '-' + money(G.penalty)]);
    if (won) {
      PROFILE.bank += G.payout;
      saveProfile();
      rows.push(['INTEL SECURED', money(G.valuables)], ['PAYOUT', money(G.payout)]);
      const best = store.get('blacksite.best', null);
      if (!best || G.payout > best.payout) { store.set('blacksite.best', { payout: G.payout, time: t }); rows.push(['', 'NEW BEST']); }
    }
    rows.push(['FUNDS', money(PROFILE.bank)]);
    $('endStats').innerHTML = rows.map(([k, v]) => `<div>${esc(k)}</div><div>${esc(v)}</div>`).join('');
    UI.show('end');
  },
};

// ---------------------------------------------------------------- campaign

function fmtTime(s) { return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }

function showCampaignEnd() {
  const won = G.won;
  for (const b of document.querySelectorAll('#end .quick')) b.style.display = 'none';
  const rows = [['MAP', `${LEVEL.name}  (${G.campaignStage + 1}/${MAPS.length})`], ['DIFFICULTY', G.diff.name], ['TIME', fmtTime(G.time)], ['KILLS', G.kills]];
  let next = false;
  if (won && CAMPAIGN) {
    // bank this map's kills and intel into the hold; the intel leaves the inventory as cash
    CAMPAIGN.hold += G.payout;
    CAMPAIGN.kills += G.kills;
    CAMPAIGN.time += G.time;
    CAMPAIGN.stage++;
    CAMPAIGN.inMap = false;
    CAMPAIGN.weapons = clone(G.player.weapons);
    CAMPAIGN.items = clone(G.player.items.map(it => (it && ITEMS[it.id].kind === 'valuable' ? null : it)));
    if (G.penalty) rows.push(['TIME PENALTY', '-' + money(G.penalty)]);
    rows.push(['INTEL SECURED', money(G.valuables)], ['ADDED TO HOLD', money(G.payout)], ['FUNDS HOLD', money(CAMPAIGN.hold)]);
    if (CAMPAIGN.stage >= MAPS.length) {
      const bonus = Math.round(CAMPAIGN_BONUS * G.diff.money);
      const total = CAMPAIGN.hold + bonus;
      PROFILE.bank += total;
      saveProfile();
      rows.push(['COMPLETION BONUS', money(bonus)], ['TOTAL PAID', money(total)], ['TOTAL KILLS', CAMPAIGN.kills], ['TOTAL TIME', fmtTime(CAMPAIGN.time)], ['FUNDS', money(PROFILE.bank)]);
      $('endTitle').textContent = 'CAMPAIGN COMPLETE';
      $('endSub').textContent = 'All five maps cleared. The funds hold has been paid out.';
      CAMPAIGN = null;
    } else {
      $('endTitle').textContent = 'MAP CLEARED';
      $('endSub').textContent = `Next: ${MAPS[CAMPAIGN.stage].name} (${CAMPAIGN.stage + 1}/${MAPS.length}). Health and armour will be restored. Progress saved.`;
      next = true;
    }
    $('endTitle').className = 'win';
  } else {
    rows.push(['FUNDS HOLD LOST', money(CAMPAIGN ? CAMPAIGN.hold : 0)]);
    $('endTitle').textContent = 'K.I.A.';
    $('endTitle').className = 'lose';
    $('endSub').textContent = 'You were killed in action. The campaign is over and cannot be continued.';
    CAMPAIGN = null;
  }
  saveCampaign();
  $('endContinue').style.display = next ? '' : 'none';
  $('endStats').innerHTML = rows.map(([k, v]) => `<div>${esc(k)}</div><div>${esc(v)}</div>`).join('');
  UI.show('end');
}

let confirmNew = false;
function renderCampaign() {
  confirmNew = false;
  const c = CAMPAIGN;
  $('campHold').textContent = money(c ? c.hold : 0);
  $('campMaps').innerHTML = MAPS.map((m, i) => {
    if (!PREVIEW_URL[m.id]) PREVIEW_URL[m.id] = drawMapPreview(buildLevel(m.id)).toDataURL();
    const state = !c ? '' : i < c.stage ? 'CLEARED' : i === c.stage ? 'NEXT' : 'LOCKED';
    return `<div class="mcard${state === 'NEXT' ? ' sel' : ''}${state === 'CLEARED' ? ' done' : ''}">
      <img src="${PREVIEW_URL[m.id]}" alt="">
      <div class="lname">${i + 1}. ${esc(m.name)}</div>
      <div class="lmeta">${state || 'HOSTILE POSITIONS UNKNOWN'}</div>
    </div>`;
  }).join('');
  if (c) {
    const names = [...c.weapons, ...c.items].filter(Boolean).map(it => itemDef(it).name);
    $('campStatus').textContent = `${(DIFFICULTIES[c.difficulty] || DIFFICULTIES.normal).name}  -  MAP ${c.stage + 1} / ${MAPS.length}  -  ${c.kills} KILLS SO FAR  -  CARRYING: ${names.join(', ') || 'nothing'}`;
  } else $('campStatus').textContent = 'No campaign in progress. A new campaign starts with your current loadout.';
  $('campContinue').style.display = c ? '' : 'none';
  $('campNew').textContent = 'NEW CAMPAIGN';
}
$('campContinue').addEventListener('click', () => { Sfx.init(); Sfx.click(); UI.deployCampaign(); });
$('campNew').addEventListener('click', () => {
  Sfx.init(); Sfx.click();
  // starting a new campaign wipes the saved one, so ask for a second click first
  if (CAMPAIGN && !confirmNew) { confirmNew = true; $('campNew').textContent = 'CLICK AGAIN - THIS DELETES YOUR SAVED CAMPAIGN'; return; }
  UI.newCampaign();
});

// ---------------------------------------------------------------- loadout

function statBars(w) {
  const stats = [
    ['DAMAGE', (w.dmg * w.pellets) / 120], ['FIRE RATE', w.rpm / 1100], ['RANGE', w.range / 2400],
    ['MOBILITY', (w.mobility - 0.75) / 0.33], ['CAPACITY', w.mag / 60],
  ];
  return stats.map(([n, v]) => `<div class="stat"><span>${n}</span><i><b style="width:${Math.round(clamp(v, 0.05, 1) * 100)}%"></b></i></div>`).join('');
}

function renderLoadout() {
  const card = (kind, id, sel) => {
    const w = WEAPONS[id];
    return `<div class="lcard${sel ? ' sel' : ''}" data-kind="${kind}" data-id="${id}">
      <div class="lhead"><div><div class="lname">${esc(w.name)}</div><div class="lcls">${esc(w.cls)}${weaponLevel(id) ? `  -  LV ${weaponLevel(id)}` : ''}</div></div>
      <img src="${iconURL({ type: 'weapon', id })}" alt=""></div>
      ${statBars(w)}
      <div class="lmeta">${w.mag} RDS  /  ${w.auto ? 'AUTO' : 'SEMI'}  /  ${w.dmg}${w.pellets > 1 ? 'x' + w.pellets : ''} DMG</div>
    </div>`;
  };
  $('lPrimary').innerHTML = PRIMARY_IDS.map(id => card('primary', id, LOADOUT.primary === id)).join('');
  $('lSecondary').innerHTML = SECONDARY_IDS.map(id => card('secondary', id, LOADOUT.secondary === id)).join('');
  $('lThrow').innerHTML = THROWABLE_IDS.map(id => `<div class="lcard small${throwableOk() && LOADOUT.throwable === id ? ' sel' : ''}${PROFILE.unlocked.includes(id) ? '' : ' locked'}" data-kind="throwable" data-id="${id}">
      <div class="lhead"><div><div class="lname">${esc(ITEMS[id].name)}</div><div class="lcls">${PROFILE.unlocked.includes(id) ? ITEMS[id].cls : 'LOCKED - UNLOCK IN THE ARMORY'}</div></div>
      <img class="sq" src="${iconURL({ type: 'item', id })}" alt=""></div>
      <div class="ldesc">${esc(ITEMS[id].desc)}</div></div>`).join('');
}

$('loadout').addEventListener('click', e => {
  const c = e.target.closest('.lcard');
  if (!c) return;
  Sfx.init();
  if (c.classList.contains('locked')) { Sfx.deny(); return; }
  // clicking the selected throwable again deploys without one
  LOADOUT[c.dataset.kind] = c.dataset.kind === 'throwable' && LOADOUT.throwable === c.dataset.id ? null : c.dataset.id;
  store.set('blacksite.loadout', LOADOUT);
  Sfx.init(); Sfx.click();
  renderLoadout();
});

// ---------------------------------------------------------------- character select

const CHAR_PREVIEW = {};
function charPreview(id) {
  if (!CHAR_PREVIEW[id]) {
    const c = mkCanvas(160, 120), x = c.getContext('2d');
    x.scale(2.6, 2.6);
    const gun = { assaulter: 'm4a1', gunner: 'm249', sniper: 'barrett', cutthroat: 'mp5', hauler: 'ak47' }[id];
    drawSoldier(x, { x: 26, y: 23, a: 0, pal: CHARACTERS[id].pal, weapon: gun, hold: 'gun', walk: 0.6, player: true });
    CHAR_PREVIEW[id] = c.toDataURL();
  }
  return CHAR_PREVIEW[id];
}
function renderCharacters() {
  $('charGrid').innerHTML = CHARACTER_IDS.map(id => {
    const ch = CHARACTERS[id];
    return `<div class="mcard${id === CHAR_ID ? ' sel' : ''}" data-char="${id}">
      <img class="charimg" src="${charPreview(id)}" alt="">
      <div class="lname">${esc(ch.name)}</div>
      <div class="lcls">${esc(ch.role)}</div>
      <ul class="perks">${ch.perks.map(p => `<li>${esc(p)}</li>`).join('')}</ul>
    </div>`;
  }).join('');
}
$('character').addEventListener('click', e => {
  const c = e.target.closest('[data-char]');
  if (!c) return;
  CHAR_ID = c.dataset.char;
  store.set('blacksite.character', CHAR_ID);
  Sfx.init(); Sfx.click();
  renderCharacters();
});

// ---------------------------------------------------------------- map select

const PREVIEW_URL = {};
function renderMaps() {
  $('mapGrid').innerHTML = MAPS.map(m => {
    if (!PREVIEW_URL[m.id]) PREVIEW_URL[m.id] = drawMapPreview(buildLevel(m.id)).toDataURL();
    return `<div class="mcard${m.id === MAP_ID ? ' sel' : ''}" data-map="${m.id}">
      <img src="${PREVIEW_URL[m.id]}" alt="">
      <div class="lname">${esc(m.name)}</div>
      <div class="ldesc">${esc(m.desc)}</div>
      <div class="lmeta">HOSTILE POSITIONS UNKNOWN</div>
    </div>`;
  }).join('');
}
$('maps').addEventListener('click', e => {
  const c = e.target.closest('.mcard');
  if (!c) return;
  MAP_ID = c.dataset.map;
  store.set('blacksite.map', MAP_ID);
  Sfx.init(); Sfx.click();
  renderMaps();
});

// ---------------------------------------------------------------- armory

function renderShop() {
  $('shopFunds').textContent = money(PROFILE.bank);
  $('sWeapons').innerHTML = [...PRIMARY_IDS, ...SECONDARY_IDS].map(id => {
    const w = WEAPONS[id], lvl = weaponLevel(id), max = lvl >= MAX_WEAPON_LEVEL;
    const cost = max ? 0 : upgradeCost(lvl);
    const per = RPM_UPGRADES[id] || LEVEL_DMG_BONUS, stat = RPM_UPGRADES[id] ? 'FIRE RATE' : 'DAMAGE';
    const pct = n => Math.round(n * per * 100);
    const next = max ? '  -  LASER SIGHT' : `  ->  +${pct(lvl + 1)}%${lvl + 1 === MAX_WEAPON_LEVEL ? '  + LASER SIGHT' : ''}`;
    return `<div class="lcard${max ? ' sel' : ''}">
      <div class="lhead"><div><div class="lname">${esc(w.name)}</div><div class="lcls">${esc(w.cls)}</div></div>
      <img src="${iconURL({ type: 'weapon', id })}" alt=""></div>
      <div class="lvlrow"><span>LV ${lvl} / ${MAX_WEAPON_LEVEL}</span><i><b style="width:${(lvl / MAX_WEAPON_LEVEL) * 100}%"></b></i></div>
      <div class="lmeta">${stat} +${pct(lvl)}%${next}</div>
      <button class="buy" data-up="${id}" ${max || PROFILE.bank < cost ? 'disabled' : ''}>${max ? 'MAX LEVEL' : 'UPGRADE  ' + money(cost)}</button>
    </div>`;
  }).join('');
  $('sThrow').innerHTML = THROWABLE_IDS.map(id => {
    const it = ITEMS[id], owned = PROFILE.unlocked.includes(id);
    return `<div class="lcard${owned ? ' sel' : ''}">
      <div class="lhead"><div><div class="lname">${esc(it.name)}</div><div class="lcls">${it.cls}</div></div>
      <img class="sq" src="${iconURL({ type: 'item', id })}" alt=""></div>
      <div class="ldesc">${esc(it.desc)}</div>
      <button class="buy" data-unlock="${id}" ${owned || PROFILE.bank < it.unlock ? 'disabled' : ''}>${owned ? 'UNLOCKED - PICK IT IN LOADOUT' : 'UNLOCK  ' + money(it.unlock)}</button>
    </div>`;
  }).join('');
  $('sArmour').innerHTML = ARMOUR_IDS.map(id => {
    const a = ARMOURS[id], owned = PROFILE.armours.includes(id), on = PROFILE.armour === id;
    const label = on ? 'EQUIPPED - CLICK TO REMOVE' : owned ? 'EQUIP' : 'BUY  ' + money(a.price);
    const lvl = PROFILE.armourLevels[id] || 0, maxed = lvl >= MAX_ARMOUR_LEVEL;
    const upgrade = owned
      ? `<button class="buy" data-armour-up="${id}" ${maxed || PROFILE.bank < ARMOUR_UPGRADE_COST ? 'disabled' : ''}>${maxed ? 'FULLY UPGRADED' : `UPGRADE +${ARMOUR_UPGRADE_HP} HP  ${money(ARMOUR_UPGRADE_COST)}`}</button>`
      : '';
    return `<div class="lcard${on ? ' sel' : ''}">
      <div class="lhead"><div><div class="lname">${esc(a.name)}</div><div class="lcls">BODY ARMOUR</div></div>
      <img class="sq" src="${iconURL({ type: 'armour', id })}" alt=""></div>
      <div class="ldesc">Takes ${a.reduce} damage off every enemy bullet that hits you, until its health runs out.</div>
      <div class="lvlrow"><span>${armourMax(id)} HP</span><i><b class="ahp" style="width:${(lvl / MAX_ARMOUR_LEVEL) * 100}%"></b></i></div>
      <div class="lmeta">UPGRADES ${lvl} / ${MAX_ARMOUR_LEVEL}${lvl ? `  (+${lvl * ARMOUR_UPGRADE_HP} HP)` : ''}</div>
      <button class="buy" data-armour="${id}" ${!owned && PROFILE.bank < a.price ? 'disabled' : ''}>${label}</button>
      ${upgrade}
    </div>`;
  }).join('');
}

$('shop').addEventListener('click', e => {
  const b = e.target.closest('button.buy');
  if (!b || b.disabled) return;
  Sfx.init();
  if (b.dataset.unlock) {
    const id = b.dataset.unlock, cost = ITEMS[id].unlock;
    if (PROFILE.unlocked.includes(id) || PROFILE.bank < cost) { Sfx.deny(); return; }
    PROFILE.bank -= cost;
    PROFILE.unlocked.push(id);
    if (!throwableOk()) { LOADOUT.throwable = id; store.set('blacksite.loadout', LOADOUT); }
  } else if (b.dataset.up) {
    const id = b.dataset.up, lvl = weaponLevel(id), cost = upgradeCost(lvl);
    if (lvl >= MAX_WEAPON_LEVEL || PROFILE.bank < cost) { Sfx.deny(); return; }
    PROFILE.bank -= cost;
    PROFILE.levels[id] = lvl + 1;
  } else if (b.dataset.armourUp) {
    const id = b.dataset.armourUp, lvl = PROFILE.armourLevels[id] || 0;
    if (!PROFILE.armours.includes(id) || lvl >= MAX_ARMOUR_LEVEL || PROFILE.bank < ARMOUR_UPGRADE_COST) { Sfx.deny(); return; }
    PROFILE.bank -= ARMOUR_UPGRADE_COST;
    PROFILE.armourLevels[id] = lvl + 1;
  } else {
    const id = b.dataset.armour, a = ARMOURS[id];
    if (PROFILE.armour === id) PROFILE.armour = null;
    else if (PROFILE.armours.includes(id)) PROFILE.armour = id;
    else {
      if (PROFILE.bank < a.price) { Sfx.deny(); return; }
      PROFILE.bank -= a.price;
      PROFILE.armours.push(id);
      PROFILE.armour = id;
    }
  }
  saveProfile();
  Sfx.pickup();
  renderShop();
});

// ---------------------------------------------------------------- inventory

const Inv = (() => {
  let drag = null;
  const ghost = $('ghost');

  const get = ref => {
    const [k, i] = ref.split(':');
    if (k === 'loot') return G.invPile.items[+i];
    if (k === 'w') return G.player.weapons[+i];
    return G.player.items[+i];
  };
  const set = (ref, v) => {
    const [k, i] = ref.split(':');
    if (k === 'loot') G.invPile.items[+i] = v;
    else if (k === 'w') G.player.weapons[+i] = v;
    else G.player.items[+i] = v;
  };
  // Which items a slot can hold: weapon slots take one rifle / one pistol, item slots take items.
  const accepts = (ref, it) => {
    if (!it) return true;
    const [k, i] = ref.split(':');
    if (k === 'loot') return true;
    if (k === 'w') return it.type === 'weapon' && WEAPONS[it.id].slot === (i === '0' ? 'primary' : 'secondary');
    if (k === 'i' && +i === GRENADE_SLOT && hasGrenadeSlot()) return it.type === 'item' && ITEMS[it.id].kind === 'throwable';
    return it.type === 'item';
  };
  const rejectReason = (ref, it) => {
    const [k, i] = ref.split(':');
    if (k === 'w' && it.type !== 'weapon') return 'Only weapons go in weapon slots';
    if (k === 'i' && +i === GRENADE_SLOT && hasGrenadeSlot()) return 'The grenade slot only takes throwables';
    if (k === 'w') return i === '0' ? 'Pistols go in the secondary slot' : 'Rifles go in the primary slot';
    if (k === 'i' && it.type === 'weapon') return 'Weapons go in the weapon slots';
    return 'Can\'t place that there';
  };

  function move(from, to) {
    if (from === to) return false;
    const a = get(from), b = get(to);
    if (!accepts(to, a)) { tip(rejectReason(to, a), true); Sfx.deny(); return false; }
    if (b && !accepts(from, b)) { tip(rejectReason(from, b), true); Sfx.deny(); return false; }
    set(to, a); set(from, b);
    if (hasGrenadeSlot()) armGrenadeSlot(G.player.items);
    const p = G.player;
    if (from[0] === 'w' || to[0] === 'w') { p.reloadT = 0; p.autoReload = false; }
    cancelUse();
    if (p.equip === 'none' && (p.weapons[0] || p.weapons[1])) p.equip = p.weapons[0] ? 'primary' : 'secondary';
    ensureEquip();
    Sfx.pickup();
    return true;
  }
  function toLoot(from) {
    const pile = G.invPile;
    let idx = pile.items.indexOf(null);
    if (idx < 0) { pile.items.push(null); idx = pile.items.length - 1; }
    return move(from, 'loot:' + idx);
  }
  // Double-click: loot goes into a free inventory slot (if there is room); inventory items go back to the loot side.
  function quick(ref) {
    const it = get(ref);
    if (!it) return;
    if (ref.startsWith('loot')) {
      let to;
      if (it.type === 'weapon') {
        to = WEAPONS[it.id].slot === 'primary' ? 'w:0' : 'w:1';
        if (get(to)) { tip(`No room - your ${to === 'w:0' ? 'primary' : 'secondary'} slot is full. Drag onto it to swap.`, true); Sfx.deny(); return; }
      } else {
        const idx = freeSlotFor(it);
        if (idx < 0) { tip('No room - your item slots are full. Drag onto an item to swap.', true); Sfx.deny(); return; }
        to = 'i:' + idx;
      }
      move(ref, to);
    } else toLoot(ref);
    render();
  }
  let lastClick = { ref: null, t: 0 };

  // first empty item slot this item may go in (grenades prefer the grenade slot)
  function freeSlotFor(it) {
    const items = G.player.items;
    if (hasGrenadeSlot() && !items[GRENADE_SLOT] && accepts('i:' + GRENADE_SLOT, it)) return GRENADE_SLOT;
    for (let i = 0; i < items.length; i++) if (!items[i] && accepts('i:' + i, it)) return i;
    return -1;
  }
  function cellHTML(ref, it, placeholder) {
    if (!it) return `<div class="cell empty" data-ref="${ref}"><span class="ph">${placeholder}</span></div>`;
    const d = itemDef(it);
    let sub = '';
    if (it.type === 'weapon') sub = `${it.mag} | ${it.reserve}`;
    else if (d.kind === 'valuable') sub = `$${Math.round(d.value * G.diff.money).toLocaleString()}`;
    else if (d.kind === 'medical') sub = `+${d.heal} HP`;
    else if (it.uses > 1) sub = `${it.uses} THROWS LEFT`;
    else sub = d.kind.toUpperCase();
    return `<div class="cell${it.type === 'weapon' ? ' wpn' : ''}" data-ref="${ref}">
      <img src="${iconURL(it)}" alt="" draggable="false"><div class="nm">${esc(d.name)}</div><div class="sb">${esc(sub)}</div></div>`;
  }

  function render() {
    if (!G || !G.invOpen) return;
    const pile = G.invPile, p = G.player;
    $('lootTitle').textContent = pile.kind === 'cache' ? 'SUPPLY CACHE' : pile.kind === 'body' ? 'ENEMY BODY' : pile.kind === 'drop' ? 'DROPPED ITEMS' : 'GROUND';
    $('lootGrid').innerHTML = pile.items.map((it, i) => cellHTML('loot:' + i, it, '')).join('');
    $('lootEmpty').style.display = pile.items.some(Boolean) ? 'none' : 'block';
    $('wSlots').innerHTML =
      `<div class="slotlbl">1  PRIMARY</div>${cellHTML('w:0', p.weapons[0], 'RIFLE')}` +
      `<div class="slotlbl">2  SECONDARY</div>${cellHTML('w:1', p.weapons[1], 'PISTOL')}`;
    $('iSlots').innerHTML = p.items.map((it, i) => cellHTML('i:' + i, it, i === GRENADE_SLOT && hasGrenadeSlot() ? 'GRENADE SLOT (3 THROWS)' : 'EMPTY')).join('');
    const ar = p.armour && ARMOURS[p.armour];
    $('aSlot').innerHTML = ar
      ? `<div class="cell locked"><img src="${iconURL({ type: 'armour', id: p.armour })}" alt="" draggable="false"><div class="nm">${esc(ar.name)}</div><div class="sb">${p.armourHp > 0 ? `-${ar.reduce} DMG  /  ${Math.ceil(p.armourHp)} / ${armourMax(p.armour)} HP` : "BROKEN"}</div></div>`
      : '<div class="cell empty locked"><span class="ph">BUY ARMOUR IN THE ARMORY</span></div>';
    $('invHp').style.width = `${p.hp}%`;
    $('invHpTxt').textContent = Math.ceil(p.hp);
  }

  let tipTimer = 0;
  function tip(text, bad) {
    const el = $('invTip');
    el.textContent = text;
    el.classList.toggle('bad', !!bad);
    clearTimeout(tipTimer);
    if (bad) tipTimer = setTimeout(() => { el.textContent = ''; el.classList.remove('bad'); }, 1800);
  }
  function describe(it) {
    const d = itemDef(it);
    if (it.type === 'weapon') return `${d.name} - ${d.cls}. ${d.dmg}${d.pellets > 1 ? 'x' + d.pellets : ''} damage, ${d.rpm} RPM, ${d.mag} round magazine. ${d.slot === 'primary' ? 'Primary' : 'Secondary'} slot.`;
    return `${d.name} - ${d.desc}`;
  }

  function open(pile) {
    if (!G || G.over) return;
    G.invOpen = true;
    G.invPile = pile;
    input.mdown = false;
    if (pile.kind === 'cache' && pile.items.some(Boolean)) Sfx.pickup();
    tip('Drag items between the panels. Double-click to quick-move. Right-click a medical item to use it.');
    $('inv').classList.add('on');
    render();
  }
  function close() {
    if (!G || !G.invOpen) return;
    const pile = G.invPile;
    if (pile.virtual && pile.items.some(Boolean)) { pile.virtual = false; pile.kind = 'drop'; G.piles.push(pile); }
    G.piles = G.piles.filter(pl => pl.kind === 'cache' || pl.items.some(Boolean));
    G.invOpen = false; G.invPile = null;
    endDrag();
    $('inv').classList.remove('on');
    ensureEquip();
  }

  function endDrag() {
    if (drag) drag.cell.classList.remove('src');
    drag = null;
    ghost.style.display = 'none';
    for (const c of document.querySelectorAll('.cell.ok, .cell.no')) c.classList.remove('ok', 'no');
  }

  const inv = $('inv');
  inv.addEventListener('pointerdown', e => {
    if (e.button !== 0) return;
    const cell = e.target.closest('.cell');
    if (!cell || !cell.dataset.ref || !get(cell.dataset.ref)) return;
    drag = { ref: cell.dataset.ref, cell, x0: e.clientX, y0: e.clientY, moved: false };
    e.preventDefault();
  });
  window.addEventListener('pointermove', e => {
    if (!G || !G.invOpen) return;
    if (!drag) {
      const cell = e.target.closest && e.target.closest('.cell');
      const it = cell && cell.dataset.ref && get(cell.dataset.ref);
      if (it && !$('invTip').classList.contains('bad')) tip(describe(it));
      return;
    }
    if (!drag.moved && Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 4) return;
    if (!drag.moved) {
      drag.moved = true;
      drag.cell.classList.add('src');
      ghost.innerHTML = drag.cell.innerHTML;
      ghost.className = 'cell ghost' + (drag.cell.classList.contains('wpn') ? ' wpn' : '');
      ghost.style.display = 'flex';
    }
    ghost.style.left = `${e.clientX - ghost.offsetWidth / 2}px`;
    ghost.style.top = `${e.clientY - ghost.offsetHeight / 2}px`;
    for (const c of document.querySelectorAll('.cell.ok, .cell.no')) c.classList.remove('ok', 'no');
    const el = document.elementFromPoint(e.clientX, e.clientY);
    const target = el && el.closest('#inv .cell[data-ref]');
    if (target && target !== drag.cell) {
      const a = get(drag.ref), b = get(target.dataset.ref);
      target.classList.add(accepts(target.dataset.ref, a) && (!b || accepts(drag.ref, b)) ? 'ok' : 'no');
    }
  });
  window.addEventListener('pointerup', e => {
    if (!drag) return;
    if (drag.moved) {
      const el = document.elementFromPoint(e.clientX, e.clientY);
      const target = el && el.closest('#inv .cell[data-ref]');
      if (target) move(drag.ref, target.dataset.ref);
      else if (el && el.closest('#invRight')) {
        // dropped on the inventory panel background: first free fitting slot
        const it = get(drag.ref);
        if (drag.ref.startsWith('loot')) {
          if (it.type === 'weapon') move(drag.ref, WEAPONS[it.id].slot === 'primary' ? 'w:0' : 'w:1');
          else { const idx = freeSlotFor(it); if (idx >= 0) move(drag.ref, 'i:' + idx); else { tip('Inventory full', true); Sfx.deny(); } }
        }
      } else if (!drag.ref.startsWith('loot')) toLoot(drag.ref); // loot panel or the open ground
      lastClick = { ref: null, t: 0 };
      endDrag();
      render();
      return;
    }
    // A plain click. Detected here rather than with 'dblclick' so it keeps working
    // even though the panels get re-rendered between clicks.
    const ref = drag.ref, now = performance.now();
    endDrag();
    if (lastClick.ref === ref && now - lastClick.t < 400) { lastClick = { ref: null, t: 0 }; quick(ref); }
    else lastClick = { ref, t: now };
  });
  inv.addEventListener('contextmenu', e => {
    e.preventDefault();
    const cell = e.target.closest('.cell');
    if (!cell || !cell.dataset.ref || !cell.dataset.ref.startsWith('i:')) return;
    const p = G.player, i = +cell.dataset.ref.slice(2), it = p.items[i];
    if (!it || ITEMS[it.id].kind !== 'medical') return;
    if (p.hp >= 100) { tip('Health is already full', true); Sfx.deny(); return; }
    p.reloadT = 0;
    p.equip = 'medical'; p.itemSlot = i; p.useSlot = i;
    p.useT = p.useMax = ITEMS[it.id].use;
    Sfx.useMed(it.id);
    close();
  });
  $('invClose').addEventListener('click', () => close());

  return { open, close, render };
})();

// ---------------------------------------------------------------- input

// <napplet-keyboard>
installGameKeyboard(
  (code, down) => { input.keys[code] = down; },
  code => {
    if (MODE === 'play' && G && !G.over) {
      if (G.invOpen) {
        if (code === 'KeyF' || code === 'Escape' || code === 'KeyI') Inv.close();
        return;
      }
      switch (code) {
        case 'Digit1': equip('primary'); break;
        case 'Digit2': equip('secondary'); break;
        case 'Digit3': equip('throwable'); break;
        case 'Digit4': equip('medical'); break;
        case 'KeyR': startReload(); break;
        case 'KeyQ': knifeAttack(); break;
        case 'KeyF': interact(); break;
        case 'KeyI': openInventory(); break;
        case 'KeyP':
        case 'Escape': UI.pause(); break;
      }
    } else if (MODE === 'paused' && (code === 'Escape' || code === 'KeyP')) UI.resume();
  },
  () => { input.keys = Object.create(null); input.mdown = false; },
);
// </napplet-keyboard>
window.addEventListener('mousemove', e => { input.mx = e.clientX; input.my = e.clientY; });
canvas.addEventListener('mousedown', e => {
  if (e.button !== 0) return;
  Sfx.init();
  input.mdown = true; input.clicked = true;
});
window.addEventListener('mouseup', e => { if (e.button === 0) input.mdown = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('resize', resize);

for (const b of document.querySelectorAll('[data-go]')) {
  b.addEventListener('click', () => {
    Sfx.init(); Sfx.click();
    const go = b.dataset.go;
    if (go === 'deploy') UI.deploy();
    else if (go === 'restart') { if (G && G.campaign) { chargeCampaignQuit(); UI.deployCampaign(); } else UI.deploy(); }
    else if (go === 'quit') { if (G && G.campaign) chargeCampaignQuit(); UI.menu(); }
    else if (go === 'campaign') UI.campaign();
    else if (go === 'campaign-next') UI.deployCampaign();
    else if (go === 'character') { MODE = 'character'; G = null; renderCharacters(); UI.show('character'); }
    else if (go === 'maps') { MODE = 'maps'; G = null; renderMaps(); UI.show('maps'); }
    else if (go === 'shop') { MODE = 'shop'; G = null; renderShop(); UI.show('shop'); }
    else if (go === 'loadout') { MODE = 'loadout'; G = null; renderLoadout(); UI.show('loadout'); }
    else if (go === 'menu') UI.menu();
    else if (go === 'resume') UI.resume();
  });
}

resize();
UI.menu();
requestAnimationFrame(frame);