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.
'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
