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');