import { Game, type BotConfig, type Difficulty, type HudRefs } from './game' import { VERSION } from './version' import { WEATHER, WEATHER_CHOICES, type WeatherChoice } from './weather' import { LobbySession, inviteLink, sessionFromUrl, MAX_PARTY, type BotSkill, } from './party' import { LobbyStage } from './lobby3d' import { assignSeats, validStart, type MatchStart, type Pose, type NetEvent } from './netmatch' import { ShopStage } from './shop3d' import { loadWallet, saveWallet, credit, purchase, equip, owns, shortfall, scoreMatch, scoreBreakdown, type WalletState, } from './economy' import { forSale, findItem, resolveLook, SKIN_PRICE, BODY_PRICE, type Category, type ShopItem } from './catalog' import { AudioEngine, loadAudioSettings } from './audio' import { AMBIENCE } from './ambience' import { MAP_ORDER, THEMES, SIZES, generateMap, prewarmTheme, isExperimental, isFoundry, SOLID, HEDGE, CRATE, BARREL, WATER, type MapId, type ArenaSize, } from './world' // ------------------------------------------------------------------ helpers function el(id: string): T { const node = document.getElementById(id) if (!node) throw new Error(`Missing element #${id}`) return node as T } const SCREENS = [ 'menuScreen', 'partyScreen', 'lobbyScreen', 'countdownScreen', 'resultScreen', 'profileScreen', 'settingsScreen', 'pauseScreen', 'shopScreen', ] function showScreen(id: string | null): void { for (const s of SCREENS) el(s).classList.remove('active') if (id) el(id).classList.add('active') } // ------------------------------------------------------------------ state const BOT_NAMES: Record = { easy: ['Rookie', 'Sparky', 'Novice', 'Pebble', 'Dozer', 'Tinker', 'Scout'], medium: ['Blaze', 'Vortex', 'Cinder', 'Ripple', 'Talon', 'Onyx', 'Flux'], hard: ['Overlord', 'Nemesis', 'Kaboom', 'Reaper', 'Havoc', 'Warden', 'Zenith'], } let bots: BotConfig[] = [] let selectedMap: MapId = 'jungleRuins' let selectedSize: ArenaSize = 'classic' let powerupsEnabled = true let dropPercent = 45 let selectedWeather: WeatherChoice = 'sunny' let raidMode = false function maxPlayers(): number { return SIZES[selectedSize].maxPlayers } function maxBots(): number { return maxPlayers() - 1 } // ------------------------------------------------------------------ lobby /** * The setup screen's read-only view of the party. * * Bots are added and removed in the lobby now, so this is a summary rather than * a control: two places to edit one roster is two places for it to drift apart. */ function renderPlayerList(): void { const list = el('playerList') list.innerHTML = '' for (const m of party.roster) { const row = document.createElement('div') row.className = `player-row${m.kind === 'bot' ? ' bot' : ''}` row.innerHTML = `
${m.kind === 'bot' ? 'πŸ€–' : m.isLocal ? '🧍' : 'πŸ‘€'}
` + `${m.name}${m.isLocal ? ' (you)' : ''}` + (m.kind === 'bot' ? `${m.skill.toUpperCase()}` : `${m.isHost ? 'HOST' : 'PLAYER'}`) list.appendChild(row) } const total = party.roster.length el('playerCount').textContent = total > maxPlayers() ? `${total} / ${maxPlayers()} β€” arena too small` : `${total} / ${maxPlayers()}` // A held seat means there is a match still running to walk back into const rejoinable = game.session.canRejoin el('rejoinBtn').style.display = rejoinable ? 'block' : 'none' el('startBtn').textContent = rejoinable ? 'START NEW MATCH' : 'START MATCH' } /** Removes one specific bot, by position in the list. */ /** * The experimental arena has a hand-built 51x21 layout, so the size buttons do * not apply to it. */ function syncSizeUI(): void { const locked = isExperimental(selectedMap) || isFoundry(selectedMap) el('sizeBtns').classList.toggle('disabled', locked) el('sizeNote').textContent = isFoundry(selectedMap) ? 'EX 2 β€” fixed 33 Γ— 25 factory floor' : isExperimental(selectedMap) ? 'EX 1 β€” fixed 51 Γ— 21 layout' : '' } function setSize(size: ArenaSize): void { selectedSize = size // Trim the roster if the smaller arena can't seat everyone if (bots.length > maxBots()) bots = bots.slice(0, maxBots()) document.querySelectorAll('.size-btn').forEach((b) => { b.classList.toggle('sel', b.dataset.s === size) }) buildMapGrid() renderPlayerList() syncSizeUI() } // ------------------------------------------------------------------ map picker function drawMapPreview(canvas: HTMLCanvasElement, id: MapId): void { const ctx = canvas.getContext('2d') if (!ctx) return const theme = THEMES[id] const { grid, w, h } = generateMap(id, selectedSize) const cw = canvas.width / w const ch = canvas.height / h const hex = (n: number): string => `#${n.toString(16).padStart(6, '0')}` for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const t = grid[y][x] let c = (x + y) % 2 === 0 ? theme.floorA : theme.floorB if (t === SOLID) c = theme.solid else if (t === HEDGE) c = theme.hedge else if (t === CRATE) c = theme.crate else if (t === BARREL) c = theme.barrel else if (t === WATER) c = theme.water ctx.fillStyle = hex(c) ctx.fillRect(x * cw, y * ch, cw + 0.6, ch + 0.6) } } } function buildMapGrid(): void { const wrap = el('mapGrid') wrap.innerHTML = '' for (const id of MAP_ORDER) { const card = document.createElement('div') card.className = 'map-card' + (id === selectedMap ? ' sel' : '') const cv = document.createElement('canvas') cv.width = 162 cv.height = 130 card.appendChild(cv) const label = document.createElement('span') label.textContent = THEMES[id].label card.appendChild(label) if (isExperimental(id) || id === 'foundry') card.classList.add('experimental') card.addEventListener('click', () => { selectedMap = id wrap.querySelectorAll('.map-card').forEach((c) => c.classList.remove('sel')) card.classList.add('sel') // Warms textures and starts this map's ambience, so the pick is audible warmSelectedMap() syncSizeUI() }) wrap.appendChild(card) drawMapPreview(cv, id) } } /** * Bake the chosen map's textures and ambience during lobby idle time. * * Both are a few hundred milliseconds of synchronous work β€” the jungle bed * alone is ~265ms at 44.1kHz β€” so doing it on the click keeps the hitch out of * the first frame of the round. */ function warmSelectedMap(): void { const id = selectedMap window.setTimeout(() => prewarmTheme(id), 0) window.setTimeout(() => audio.setMap(id), 30) } // ------------------------------------------------------------------ game const hudRefs: HudRefs = { hud: el('hud'), p1Hearts: el('p1Hearts'), p1Lives: el('p1Lives'), p1Wins: el('p1Wins'), roster: el('roster'), timerText: el('timerText'), roundText: el('roundText'), aliveText: el('aliveText'), minimap: el('minimap'), bombDial: el('bombDial'), bombIcon: el('bombIcon'), bombBadge: el('bombBadge'), bombLabel: el('bombLabel'), bombArc: el('bombArc') as unknown as SVGCircleElement, addonDial: el('addonDial'), addonIcon: el('addonIcon'), addonBadge: el('addonBadge'), addonLabel: el('addonLabel'), addonArc: el('addonArc') as unknown as SVGCircleElement, toast: el('toast'), zoneWarn: el('zoneWarn'), noticeFeed: el('noticeFeed'), raidPhase: el('raidPhase'), raidTime: el('raidTime'), raidNote: el('raidNote'), raidClock: document.querySelector('.raid-clock') as HTMLElement, compass: el('compass'), compassArrow: el('compassArrow'), compassDist: el('compassDist'), haulValue: el('haulValue'), haulCount: el('haulCount'), channelBar: el('channelBar'), channelFill: el('channelFill'), } let matchOver = false /** True when Settings was opened from the pause overlay, not the main menu. */ let cameFromPause = false // Audio cannot start before a user gesture, so the engine is inert until the // first click or keypress, then builds its context and picks up the map that // was already selected. const audio = new AudioEngine(loadAudioSettings()) audio.installUnlockHandler() const game = new Game(el('app'), hudRefs, (playerWon: boolean, isMatchOver: boolean) => { matchOver = isMatchOver const s = game.getScore() const won = isMatchOver ? s.player > s.bot : playerWon el('resultTitle').textContent = isMatchOver ? (won ? 'VICTORY!' : 'DEFEAT') : (won ? 'ROUND WON' : 'ROUND LOST') el('resultTitle').style.color = won ? '#57e08a' : '#ff4d5e' el('resultSub').textContent = isMatchOver ? 'MATCH COMPLETE' : `ROUND ${s.round} OF 5` el('stRounds').textContent = `${s.player}–${s.bot}` el('stCrates').textContent = String(s.crates) el('stPups').textContent = String(s.powerups) el('resultBtn').textContent = isMatchOver ? 'PLAY AGAIN' : 'NEXT ROUND' // Points are banked per round, not per match: a round you won and a round you // died in are different amounts of work, and holding it all back to the end // would mean quitting a match threw away everything earned in it. const tally = { ...game.tally, lastStanding: won } const earned = scoreMatch(tally) if (earned > 0) { wallet = credit(wallet, earned, isMatchOver ? 'Match' : `Round ${s.round}`) saveAndRender() } const rows = scoreBreakdown(tally) el('pointsEarned').textContent = `+${earned.toLocaleString()}` el('pointsRows').innerHTML = rows .map((r) => `
${r.label}+${r.points}
`) .join('') el('pointsBox').style.display = earned > 0 ? 'block' : 'none' el('raidScoreBox').style.display = 'none' showScreen('resultScreen') }, audio) function runCountdown(then: () => void): void { showScreen('countdownScreen') const num = el('cdNum') let n = 3 num.textContent = String(n) const tick = window.setInterval(() => { n-- if (n > 0) num.textContent = String(n) else if (n === 0) num.textContent = 'GO!' else { window.clearInterval(tick) showScreen(null) then() } }, 700) } function startMatch(): void { // With other people in the party, the host builds the arena and sends it, so // everybody plays the same one. if (party.hasRemote) { if (party.isHost) startNetworkedMatch() else el('linkNote').textContent = 'Waiting for the host to start the match…' return } // A party of one is a party of one; give them somebody to play against. if (party.bots.length === 0) party.addBot('medium') bots = party.bots.map((b) => ({ name: b.name, difficulty: b.skill })) runCountdown(() => game.startMatch(bots, selectedMap, selectedSize, dropChance(), selectedWeather)) } // ------------------------------------------------------------------ wiring document.querySelectorAll('[data-act]').forEach((btn) => { btn.addEventListener('click', () => { switch (btn.dataset.act) { case 'play': raidMode = false openParty() warmSelectedMap() break case 'shop': openShop() break case 'raid': // Extraction skips the map picker: the raid has its own arena. raidMode = true openParty() break case 'training': // Straight into a one-bot party, skipping the invite step if (party.bots.length === 0) party.addBot('easy') openParty() warmSelectedMap() break case 'profile': showScreen('profileScreen') break case 'menu': shopStage.stop() if (cameFromPause) { cameFromPause = false showScreen('pauseScreen') break } game.quitToMenu() showScreen('menuScreen') break case 'settings': syncSettingsUI() showScreen('settingsScreen') break case 'shop': alert('Coming soon.') break } }) }) document.querySelectorAll('.size-btn').forEach((btn) => { btn.addEventListener('click', () => setSize(btn.dataset.s as ArenaSize)) }) // ------------------------------------------------------------------ shop // // The wallet is the one piece of state that outlives a session, so it is loaded // once, saved on every change, and read defensively β€” see parseWallet. let wallet: WalletState = loadWallet() let shopCategory: Category = 'skin' let selectedItem: ShopItem | null = null const shopStage = new ShopStage(el('shopCanvas'), el('shopPreview')) function saveAndRender(): void { saveWallet(wallet) renderShop() el('menuBalance').textContent = wallet.balance.toLocaleString() game.setLook(resolveLook(wallet.equipped)) } /** The look the player currently walks out in. */ function currentLook(): ReturnType { return resolveLook(wallet.equipped) } function renderShop(): void { el('shopBalance').textContent = wallet.balance.toLocaleString() el('lifetimeEarned').textContent = `${wallet.lifetime.toLocaleString()} earned all time` const list = el('shopList') list.innerHTML = '' for (const item of forSale(shopCategory)) { const row = document.createElement('div') const held = owns(wallet, item.id) const worn = wallet.equipped[item.category] === item.id row.className = `shop-row${selectedItem?.id === item.id ? ' sel' : ''}${held ? ' owned' : ''}${worn ? ' worn' : ''}` const swatch = item.category === 'skin' ? `#${item.suit.toString(16).padStart(6, '0')}` : '#6a7690' row.innerHTML = `` + `${item.name}` + `${worn ? 'WORN' : held ? 'OWNED' : item.price.toLocaleString()}` row.addEventListener('click', () => { selectedItem = item shopStage.show(previewLook(item)) renderShop() }) list.appendChild(row) } // The buy panel says exactly one thing at a time, and the button does exactly // what it says β€” no guessing whether a click will buy or equip. const btn = el('buyBtn') if (!selectedItem) { el('buyName').textContent = 'β€”' el('buyBlurb').textContent = '' btn.textContent = 'SELECT AN ITEM' btn.disabled = true return } el('buyName').textContent = selectedItem.name el('buyBlurb').textContent = selectedItem.blurb btn.disabled = false if (wallet.equipped[selectedItem.category] === selectedItem.id) { btn.textContent = 'WEARING THIS' btn.disabled = true } else if (owns(wallet, selectedItem.id)) { btn.textContent = 'WEAR IT' } else { const short = shortfall(wallet, selectedItem.price) btn.textContent = short > 0 ? `NEED ${short.toLocaleString()} MORE` : `BUY Β· ${selectedItem.price.toLocaleString()}` btn.disabled = short > 0 } } /** What the turntable should show for an item: it, over everything else worn. */ function previewLook(item: ShopItem): ReturnType { return resolveLook({ ...wallet.equipped, [item.category]: item.id }) } el('buyBtn').addEventListener('click', () => { if (!selectedItem) return if (owns(wallet, selectedItem.id)) { wallet = equip(wallet, selectedItem) } else { const result = purchase(wallet, selectedItem) if (!result.ok) return wallet = result.state } saveAndRender() }) document.querySelectorAll('.shop-tab').forEach((tab) => { tab.addEventListener('click', () => { shopCategory = tab.dataset.cat as Category selectedItem = null document.querySelectorAll('.shop-tab').forEach((t) => t.classList.remove('sel')) tab.classList.add('sel') renderShop() }) }) el('historyBtn').addEventListener('click', () => { const panel = el('shopHistory') panel.classList.toggle('open') if (!panel.classList.contains('open')) return panel.innerHTML = '' if (wallet.ledger.length === 0) { panel.innerHTML = '

Nothing yet. Go and earn some.

' return } for (const entry of wallet.ledger) { const item = findItem(entry.note) const row = document.createElement('div') row.className = `ledger-row ${entry.kind}` row.innerHTML = `${item ? item.name : entry.note}` + `${entry.kind === 'earned' ? '+' : 'βˆ’'}${entry.amount.toLocaleString()}` + `${new Date(entry.at).toLocaleDateString()}` panel.appendChild(row) } }) function openShop(): void { selectedItem = null renderShop() shopStage.show(currentLook()) showScreen('shopScreen') shopStage.start() } // ------------------------------------------------------------------ party lobby // // A session is created on load, or joined if the page was opened from an invite // link. Members sync between tabs and windows of the same browser over // BroadcastChannel: real join and leave events, real host handover, but no // reach beyond this machine. Crossing machines needs a socket server, and // everything above this line is already shaped for one. const party = new LobbySession(sessionFromUrl(window.location.href), 'You', window.location.href) const stage = new LobbyStage( el('lobbyCanvas'), el('partyStage'), el('lobbyPlates'), ) function renderParty(): void { const roster = party.roster stage.setRoster(roster) el('sessionCode').textContent = party.code el('partyCount').textContent = `${roster.length} / ${MAX_PARTY}` // Say plainly what is and is not connected. "Nobody has joined" and "this // browser cannot carry a party at all" look identical otherwise. const others = party.humans.length - 1 const link = party.linkKind el('partyStatus').textContent = link === 'none' ? 'Not connected β€” you can still play against bots' : others > 0 ? `${others} other player${others === 1 ? '' : 's'} connected Β· ${party.isHost ? 'you host' : 'someone else hosts'}` : link === 'socket' ? 'Connected to the server β€” send your invite link to anyone' : 'Same-browser only (no server) β€” open the link in another tab' const list = el('partyRoster') list.innerHTML = '' for (const m of roster) { const row = document.createElement('div') row.className = `player-row${m.kind === 'bot' ? ' bot' : ''}` row.innerHTML = `
${m.kind === 'bot' ? 'πŸ€–' : m.isLocal ? '🧍' : 'πŸ‘€'}
` + `${m.name}${m.isLocal ? ' (you)' : ''}` + (m.kind === 'bot' ? `${m.skill.toUpperCase()}` : `${m.isHost ? 'HOST' : 'PLAYER'}`) if (!m.isLocal) { const drop = document.createElement('button') drop.className = 'row-remove' drop.textContent = 'βœ•' drop.title = `Remove ${m.name}` drop.addEventListener('click', () => party.remove(m.id)) row.appendChild(drop) } list.appendChild(row) } } party.watch(renderParty) document.querySelectorAll('[data-bot]').forEach((btn) => { btn.addEventListener('click', () => { if (!party.addBot(btn.dataset.bot as BotSkill)) { el('linkNote').textContent = `The party is full at ${MAX_PARTY}.` } }) }) // Somebody arriving or leaving is worth saying out loud, since the line-up // rebuilding is easy to miss if you are not looking at it. party.onPartyChange(() => { const note = el('linkNote') const others = party.humans.length - 1 note.textContent = others > 0 ? `${others} other player${others === 1 ? '' : 's'} in the party.` : 'Everyone else has left.' }) el('copyLinkBtn').addEventListener('click', async () => { const link = inviteLink(window.location.href, party.code) const note = el('linkNote') try { await navigator.clipboard.writeText(link) note.textContent = 'Link copied β€” open it in another tab to join.' } catch { // Clipboard access is often blocked; showing the link still lets them copy it note.textContent = link } }) el('toSetupBtn').addEventListener('click', () => { stage.stop() bots = party.bots.map((b) => ({ name: b.name, difficulty: b.skill })) if (raidMode) { // Straight in: there is nothing to choose, and the raid arena is fixed. if (bots.length === 0) { party.addBot('medium'); party.addBot('hard') } bots = party.bots.map((b) => ({ name: b.name, difficulty: b.skill })) runCountdown(() => game.startRaid(bots, bots.length >= 5 ? 3 : 2)) return } renderPlayerList() showScreen('lobbyScreen') warmSelectedMap() }) // ------------------------------------------------------------------ net match // // The host builds the arena and sends it; everyone else plays the one they were // sent. Nothing is regenerated on a guest, so there is nothing that can differ // between machines. function startNetworkedMatch(): void { const humans = party.humans const seats = assignSeats(humans.map((m) => m.id)) const generated = generateMap(selectedMap, selectedSize) const start: MatchStart = { t: 'start', grid: generated.grid, w: generated.w, h: generated.h, spawns: generated.spawns, teleports: generated.teleports, mapId: selectedMap, weather: selectedWeather === 'random' ? 'sunny' : selectedWeather, dropChance: dropChance(), seats, bots: party.bots.map((b, i) => ({ id: b.id, name: b.name, difficulty: b.skill, seat: humans.length + i, })), } party.sendGame(start) beginNetMatch(start, true) } function beginNetMatch(start: MatchStart, isHost: boolean): void { runCountdown(() => { game.startNetMatch(start, party.localId, isHost) // Everyone else in the party gets a fighter driven by their own machine for (const m of party.humans) { if (m.isLocal) continue game.addRemote(m.id, start.seats[m.id] ?? 0, m.name) } // A guest does not simulate the bots; it watches the host's if (!isHost) game.adoptHostBots(start.bots.length) game.onLocalPose((pose: Pose) => { party.sendGame({ t: 'pose', pose: { ...pose, id: pose.id === 'me' ? party.localId : pose.id } }) }) game.onLocalEvent((event: NetEvent) => { party.sendGame({ t: 'event', event: { ...event, id: party.localId } }) }) }) } party.onGame((from, payload) => { const msg = payload as { t?: string; pose?: Pose; event?: NetEvent } if (!msg || typeof msg.t !== 'string') return if (msg.t === 'start') { // Guests follow the host into the match rather than being asked if (!validStart(payload)) return stage.stop() beginNetMatch(payload as MatchStart, false) return } if (msg.t === 'pose' && msg.pose) { game.applyPose({ ...msg.pose, id: msg.pose.id.startsWith('bot-') ? msg.pose.id : from }) return } if (msg.t === 'event' && msg.event) { game.applyEvent({ ...msg.event, id: from } as NetEvent) } }) el('backToPartyBtn').addEventListener('click', () => openParty()) function openParty(): void { el('toSetupBtn').textContent = raidMode ? 'DEPLOY β†’' : 'CHOOSE MAP β†’' el('partyTitle').textContent = raidMode ? 'EXTRACTION' : 'LOBBY' renderParty() showScreen('partyScreen') stage.start() } // Presence keeps beating while the page is open, or other tabs time this one // out and drop it from their roster. let lastBeat = performance.now() setInterval(() => { const now = performance.now() party.tick((now - lastBeat) / 1000) lastBeat = now }, 500) window.addEventListener('beforeunload', () => party.leave()) // Arriving from an invite link should land in the lobby, not on the main menu // with an invisible party attached. Without this the link appears to do // nothing: you have joined, but there is no sign of it anywhere on screen. if (sessionFromUrl(window.location.href)) { openParty() el('linkNote').textContent = `Joined session ${party.code}.` } // ------------------------------------------------------------------ weather const WEATHER_ICON: Record = { sunny: 'β˜€οΈ', rain: '🌧️', thunder: 'β›ˆοΈ', random: '🎲', } const WEATHER_LABEL: Record = { sunny: 'SUNNY', rain: 'RAIN', thunder: 'THUNDER', random: 'RANDOM', } function buildWeatherPicker(): void { const wrap = el('weatherBtns') wrap.innerHTML = '' for (const id of WEATHER_CHOICES) { const btn = document.createElement('button') btn.className = 'weather-btn' + (id === selectedWeather ? ' sel' : '') btn.title = id === 'random' ? 'a different sky each round' : WEATHER[id].blurb btn.innerHTML = `${WEATHER_ICON[id]}${WEATHER_LABEL[id]}` btn.addEventListener('click', () => { selectedWeather = id wrap.querySelectorAll('.weather-btn').forEach((b) => b.classList.remove('sel')) btn.classList.add('sel') }) wrap.appendChild(btn) } } // ------------------------------------------------------------------ power-up setup function syncPowerupUI(): void { el('powerupsOn').checked = powerupsEnabled el('dropRate').value = String(dropPercent) el('dropRateVal').textContent = `${dropPercent}%` el('dropRow').classList.toggle('disabled', !powerupsEnabled) } el('powerupsOn').addEventListener('change', (e) => { powerupsEnabled = (e.target as HTMLInputElement).checked syncPowerupUI() }) el('dropRate').addEventListener('input', (e) => { dropPercent = Number((e.target as HTMLInputElement).value) el('dropRateVal').textContent = `${dropPercent}%` }) /** Fraction of barrels that should hold a power-up, or 0 when switched off. */ function dropChance(): number { return powerupsEnabled ? dropPercent / 100 : 0 } el('startBtn').addEventListener('click', startMatch) el('resultBtn').addEventListener('click', () => { showScreen(null) if (matchOver) runCountdown(() => game.startMatch(bots, selectedMap, selectedSize, dropChance(), selectedWeather)) else runCountdown(() => game.nextRound()) }) // ------------------------------------------------------------------ settings function syncSettingsUI(): void { const s = audio.getSettings() const master = el('volMaster') const amb = el('volAmbience') const sfx = el('volSfx') master.value = String(Math.round(s.master * 100)) amb.value = String(Math.round(s.ambience * 100)) sfx.value = String(Math.round(s.sfx * 100)) el('volMasterVal').textContent = master.value el('volAmbienceVal').textContent = amb.value el('volSfxVal').textContent = sfx.value el('muteBtn').textContent = s.muted ? 'SOUND: OFF' : 'SOUND: ON' el('voiceOn').checked = game.voice.enabled el('ambienceNote').textContent = `Now playing: ${AMBIENCE[selectedMap].label}.` } el('volMaster').addEventListener('input', (e) => { const v = Number((e.target as HTMLInputElement).value) audio.updateSettings({ master: v / 100 }) el('volMasterVal').textContent = String(v) }) el('volSfx').addEventListener('input', (e) => { const v = Number((e.target as HTMLInputElement).value) audio.updateSettings({ sfx: v / 100 }) el('volSfxVal').textContent = String(v) }) el('volAmbience').addEventListener('input', (e) => { const v = Number((e.target as HTMLInputElement).value) audio.updateSettings({ ambience: v / 100 }) el('volAmbienceVal').textContent = String(v) }) el('voiceOn').addEventListener('change', (e) => { game.voice.setEnabled((e.target as HTMLInputElement).checked) }) el('muteBtn').addEventListener('click', () => { const next = !audio.getSettings().muted audio.updateSettings({ muted: next }) el('muteBtn').textContent = next ? 'SOUND: OFF' : 'SOUND: ON' }) // ------------------------------------------------------------------ pause menu // The overlay is shown by the game itself (it owns the ESC key), and closing it // must not resume anything β€” the match never stopped. // The raid ends on its own terms: extracted, or dead, or the pad gone. game.onRaidResult((extracted, score, value, pieces) => { el('resultTitle').textContent = extracted ? 'EXTRACTED' : 'LOST IN THE ZONE' el('resultSub').textContent = extracted ? `${pieces} pieces recovered, worth ${value}` : 'Your haul stayed in the zone' el('resultScore').textContent = String(score) el('raidScoreBox').style.display = 'block' el('pointsBox').style.display = 'none' // A raid pays into the same wallet, so a good run buys a skin if (score > 0) { wallet = credit(wallet, score, extracted ? 'Extraction' : 'Raid') saveAndRender() } el('resultBtn').textContent = 'BACK TO LOBBY' matchOver = true showScreen('resultScreen') }) game.setPauseHandler((open) => { if (open) showScreen('pauseScreen') else showScreen(null) }) el('resumeBtn').addEventListener('click', () => game.setMenuOpen(false)) el('pauseSettingsBtn').addEventListener('click', () => { syncSettingsUI() showScreen('settingsScreen') cameFromPause = true }) el('leaveMatchBtn').addEventListener('click', () => { // Steps out but holds the seat: the match carries on without you. game.leaveMatch() renderPlayerList() showScreen('lobbyScreen') }) el('quitMatchBtn').addEventListener('click', () => { game.quitToMenu() showScreen('menuScreen') }) el('rejoinBtn').addEventListener('click', () => { if (game.rejoinMatch()) showScreen(null) }) // Exposed for the automated smoke test / devtools poking. ;(window as unknown as { __game: Game }).__game = game // ------------------------------------------------------------------ boot // Version stamps, driven from the single constant in version.ts for (const id of ['menuVersion', 'hudVersion']) { const node = document.getElementById(id) if (node) node.textContent = `v${VERSION}` } document.title = `BOMBER ARENA v${VERSION}` setSize('classic') syncPowerupUI() syncSizeUI() buildWeatherPicker() showScreen('menuScreen') warmSelectedMap()