From 961a73f27261ec552d8f0ba50d4158637d3fcdb6 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 20 Sep 2026 19:54:26 +0000 Subject: [PATCH] Sync mobile-compatible progression controls --- index.html | 11 +++++++ src/game-v4.js | 34 ++++++++++++++++++-- src/mobile-hud.js | 75 +++++++++++++++++++++++++++++++++++++++++++ tests/wiring.test.mjs | 27 ++++++++++++++++ 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 8002bca..2850b8a 100644 --- a/index.html +++ b/index.html @@ -132,6 +132,8 @@ + + @@ -203,7 +205,16 @@ stick.addEventListener('contextmenu', (event) => event.preventDefault()); for (const button of document.querySelectorAll('.mobile-control')) { + const isAction = Boolean(button.dataset.action); const fire = (type) => { + if (isAction) { + if (type === 'keydown') { + window.dispatchEvent(new CustomEvent('airship:action', { + detail: { action: button.dataset.action }, + })); + } + return; + } dispatchKey(type, button.dataset.key, button.dataset.code, Number(button.dataset.keycode)); }; diff --git a/src/game-v4.js b/src/game-v4.js index 72aae33..bcce4f9 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -101,6 +101,7 @@ class GameScene extends Phaser.Scene { }); this.progression = createStarterProgression(); this.playerStats = deriveShipStats(this.progression.loadout); + this.pendingMobileActions = new Set(); this.playerSpeed = 0; this.playerHull = this.playerStats.maxHull; this.gameState = 'playing'; @@ -220,6 +221,11 @@ class GameScene extends Phaser.Scene { this.helmVector.y = clamp(Number(event.detail?.y) || 0, -1, 1); }; window.addEventListener('airship:helm-vector', this.handleHelmVector); + this.handleMobileAction = (event) => { + const action = String(event.detail?.action || ''); + if (action) this.pendingMobileActions.add(action); + }; + window.addEventListener('airship:action', this.handleMobileAction); this.keys = this.input.keyboard.addKeys({ firePort: Phaser.Input.Keyboard.KeyCodes.Q, fireStarboard: Phaser.Input.Keyboard.KeyCodes.E, @@ -240,6 +246,7 @@ class GameScene extends Phaser.Scene { this.scale.off('resize', this.layoutHud, this); registerDebugSnapshot(null); window.removeEventListener('airship:helm-vector', this.handleHelmVector); + window.removeEventListener('airship:action', this.handleMobileAction); airshipLog('scene:shutdown', { gameState: this.gameState, hull: this.playerHull, @@ -521,7 +528,10 @@ class GameScene extends Phaser.Scene { const { port, range } = nearestPort(this.world, this.player.x, this.player.y); const docked = Boolean(port && range <= 230); - if (Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade)) { + const mobileBuy = this.pendingMobileActions.delete('buy-upgrade'); + const mobileFit = this.pendingMobileActions.delete('fit-salvage'); + + if (mobileBuy || Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade)) { if (!docked) { this.status.setText('GET WITHIN 230m OF A PORT TO BUY UPGRADES'); } else { @@ -542,7 +552,7 @@ class GameScene extends Phaser.Scene { }); } - if (Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { + if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { const moduleId = this.progression.inventory?.[0]; if (!moduleId) { this.status.setText('NO SALVAGED MODULES IN HOLD'); @@ -805,7 +815,9 @@ class GameScene extends Phaser.Scene { } createMerchantTraffic() { - const plans = createMerchantTrafficPlans(this.world, { maxShips: MAX_MERCHANTS }); + const desktopPointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches; + const merchantLimit = desktopPointer ? MAX_MERCHANTS : Math.min(12, MAX_MERCHANTS); + const plans = createMerchantTrafficPlans(this.world, { maxShips: merchantLimit }); for (const plan of plans) { const route = this.world.tradeRoutes.find(({ id }) => id === plan.routeId); @@ -1592,6 +1604,12 @@ class GameScene extends Phaser.Scene { ); const portState = nearestPort(this.world, player.x, player.y); const influence = pirateInfluenceAt(this.world, player.x, player.y); + const docked = Boolean(portState.port && portState.range <= 230); + const marketUpgrade = docked + ? bestAffordableMarketUpgrade(this.progression, portState.port) + : null; + const salvageModuleId = this.progression?.inventory?.[0] || null; + const salvageModule = salvageModuleId ? MODULE_CATALOG[salvageModuleId] : null; return { gameState: this.gameState, @@ -1632,6 +1650,16 @@ class GameScene extends Phaser.Scene { inventoryCount: this.progression?.inventory?.length || 0, nearestPort: portState.port?.name || null, nearestPortRange: portState.range, + docked, + marketUpgrade: marketUpgrade ? { + id: marketUpgrade.id, + name: marketUpgrade.name, + price: marketUpgrade.price, + } : null, + salvageModule: salvageModule ? { + id: salvageModule.id, + name: salvageModule.name, + } : null, }, world: { seed: this.world?.seed, diff --git a/src/mobile-hud.js b/src/mobile-hud.js index b7073b9..0f8abca 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -389,6 +389,45 @@ if (!isDesktopPointer.matches) { bottom: max(8px, env(safe-area-inset-bottom)); } + #mobile-controls.controller-active .mobile-control.ship-action { + display: grid; + place-items: center; + left: 50%; + right: auto; + bottom: max(10px, env(safe-area-inset-bottom)); + width: min(28vw, 126px); + min-width: 92px; + height: 48px; + padding: 5px 9px; + border: 1px solid rgba(232, 184, 91, .65); + border-radius: 12px; + background: rgba(8, 25, 31, .88); + color: #ffe5a0; + font: 700 clamp(9px, 2.7vw, 12px)/1.05 Georgia, 'Times New Roman', serif; + text-align: center; + box-shadow: 0 3px 10px rgba(0, 0, 0, .38); + transform: translateX(-50%); + } + + #mobile-controls.controller-active .mobile-control.ship-action:disabled { + opacity: .34; + filter: grayscale(.7); + } + + #mobile-controls.controller-active .mobile-control.ship-action.active, + #mobile-controls.controller-active .mobile-control.ship-action:active { + background: rgba(117, 78, 31, .9); + transform: translateX(-50%) scale(.96); + } + + #mobile-controls.controller-active .mobile-control.buy { + margin-left: -16%; + } + + #mobile-controls.controller-active .mobile-control.fit { + margin-left: 16%; + } + #mobile-controls.controller-active .mobile-control.restart { left: auto; right: 1%; @@ -499,6 +538,22 @@ if (!isDesktopPointer.matches) { right: calc(var(--steam-wide-edge-right) + var(--steam-wide-cannon-size) + 14px); } + #mobile-controls.controller-active .mobile-control.ship-action { + bottom: var(--steam-wide-bottom); + width: clamp(100px, 15vw, 142px); + height: 50px; + min-width: 100px; + font-size: clamp(9px, 1.15vw, 12px); + } + + #mobile-controls.controller-active .mobile-control.buy { + margin-left: -86px; + } + + #mobile-controls.controller-active .mobile-control.fit { + margin-left: 86px; + } + #mobile-controls.controller-active .mobile-control.restart { right: max(12px, env(safe-area-inset-right)); top: clamp(118px, 30vh, 168px); @@ -641,6 +696,8 @@ if (!isDesktopPointer.matches) { const fields = Object.fromEntries( [...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), ); + const buyButton = root.querySelector('.mobile-control.buy'); + const fitButton = root.querySelector('.mobile-control.fit'); const radarContacts = radarPiece.querySelector('#steam-radar-contacts'); @@ -740,6 +797,24 @@ if (!isDesktopPointer.matches) { fields['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`; fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 1)})`; + const progression = state.progression || {}; + if (buyButton) { + const upgrade = progression.marketUpgrade; + buyButton.disabled = !progression.docked || !upgrade; + buyButton.textContent = !progression.docked + ? 'BUY\nPORT' + : upgrade + ? `BUY\n${upgrade.name} ¢${upgrade.price}` + : 'BUY\nNO UPGRADE'; + } + if (fitButton) { + const module = progression.salvageModule; + fitButton.disabled = !module; + fitButton.textContent = module + ? `FIT\n${module.name}` + : 'FIT\nEMPTY'; + } + const statusText = String(state.status || '').trim(); fields.status.textContent = statusText; fields.status.classList.toggle('visible', Boolean(statusText)); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 3c4e048..cba4590 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -263,6 +263,33 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); +test('mobile controls expose native buy and salvage-fit actions', async () => { + const [game, hud, index] = await Promise.all([ + read('src/game-v4.js'), + read('src/mobile-hud.js'), + read('index.html'), + ]); + + assert.match(index, /data-action="buy-upgrade"/); + assert.match(index, /data-action="fit-salvage"/); + assert.match(index, /CustomEvent\('airship:action'/); + assert.match(game, /addEventListener\('airship:action', this\.handleMobileAction\)/); + assert.match(game, /this\.pendingMobileActions\.delete\('buy-upgrade'\)/); + assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/); + assert.match(game, /marketUpgrade: marketUpgrade \? \{/); + assert.match(game, /salvageModule: salvageModule \? \{/); + assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/); + assert.match(hud, /root\.querySelector\('\.mobile-control\.fit'\)/); + assert.match(hud, /buyButton\.disabled = !progression\.docked \|\| !upgrade/); + assert.match(hud, /fitButton\.disabled = !module/); +}); + +test('mobile merchant traffic uses a reduced render budget', async () => { + const source = await read('src/game-v4.js'); + assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/); + assert.match(source, /createMerchantTrafficPlans\(this\.world, \{ maxShips: merchantLimit \}\)/); +}); + test('merchant traffic follows trade routes and pirates can raid it', async () => { const source = await read('src/game-v4.js');