Sync mobile-compatible progression controls

This commit is contained in:
Admin 2026-09-20 19:54:26 +00:00
parent 9eb085d596
commit 961a73f272
4 changed files with 144 additions and 3 deletions

View File

@ -132,6 +132,8 @@
</div> </div>
<button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button> <button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button>
<button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button> <button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button>
<button class="mobile-control ship-action buy" data-action="buy-upgrade" aria-label="Buy ship upgrade">BUY</button>
<button class="mobile-control ship-action fit" data-action="fit-salvage" aria-label="Fit salvaged module">FIT</button>
<button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button> <button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button>
</div> </div>
@ -203,7 +205,16 @@
stick.addEventListener('contextmenu', (event) => event.preventDefault()); stick.addEventListener('contextmenu', (event) => event.preventDefault());
for (const button of document.querySelectorAll('.mobile-control')) { for (const button of document.querySelectorAll('.mobile-control')) {
const isAction = Boolean(button.dataset.action);
const fire = (type) => { 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)); dispatchKey(type, button.dataset.key, button.dataset.code, Number(button.dataset.keycode));
}; };

View File

@ -101,6 +101,7 @@ class GameScene extends Phaser.Scene {
}); });
this.progression = createStarterProgression(); this.progression = createStarterProgression();
this.playerStats = deriveShipStats(this.progression.loadout); this.playerStats = deriveShipStats(this.progression.loadout);
this.pendingMobileActions = new Set();
this.playerSpeed = 0; this.playerSpeed = 0;
this.playerHull = this.playerStats.maxHull; this.playerHull = this.playerStats.maxHull;
this.gameState = 'playing'; this.gameState = 'playing';
@ -220,6 +221,11 @@ class GameScene extends Phaser.Scene {
this.helmVector.y = clamp(Number(event.detail?.y) || 0, -1, 1); this.helmVector.y = clamp(Number(event.detail?.y) || 0, -1, 1);
}; };
window.addEventListener('airship:helm-vector', this.handleHelmVector); 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({ this.keys = this.input.keyboard.addKeys({
firePort: Phaser.Input.Keyboard.KeyCodes.Q, firePort: Phaser.Input.Keyboard.KeyCodes.Q,
fireStarboard: Phaser.Input.Keyboard.KeyCodes.E, fireStarboard: Phaser.Input.Keyboard.KeyCodes.E,
@ -240,6 +246,7 @@ class GameScene extends Phaser.Scene {
this.scale.off('resize', this.layoutHud, this); this.scale.off('resize', this.layoutHud, this);
registerDebugSnapshot(null); registerDebugSnapshot(null);
window.removeEventListener('airship:helm-vector', this.handleHelmVector); window.removeEventListener('airship:helm-vector', this.handleHelmVector);
window.removeEventListener('airship:action', this.handleMobileAction);
airshipLog('scene:shutdown', { airshipLog('scene:shutdown', {
gameState: this.gameState, gameState: this.gameState,
hull: this.playerHull, 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 { port, range } = nearestPort(this.world, this.player.x, this.player.y);
const docked = Boolean(port && range <= 230); 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) { if (!docked) {
this.status.setText('GET WITHIN 230m OF A PORT TO BUY UPGRADES'); this.status.setText('GET WITHIN 230m OF A PORT TO BUY UPGRADES');
} else { } 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]; const moduleId = this.progression.inventory?.[0];
if (!moduleId) { if (!moduleId) {
this.status.setText('NO SALVAGED MODULES IN HOLD'); this.status.setText('NO SALVAGED MODULES IN HOLD');
@ -805,7 +815,9 @@ class GameScene extends Phaser.Scene {
} }
createMerchantTraffic() { 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) { for (const plan of plans) {
const route = this.world.tradeRoutes.find(({ id }) => id === plan.routeId); 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 portState = nearestPort(this.world, player.x, player.y);
const influence = pirateInfluenceAt(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 { return {
gameState: this.gameState, gameState: this.gameState,
@ -1632,6 +1650,16 @@ class GameScene extends Phaser.Scene {
inventoryCount: this.progression?.inventory?.length || 0, inventoryCount: this.progression?.inventory?.length || 0,
nearestPort: portState.port?.name || null, nearestPort: portState.port?.name || null,
nearestPortRange: portState.range, 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: { world: {
seed: this.world?.seed, seed: this.world?.seed,

View File

@ -389,6 +389,45 @@ if (!isDesktopPointer.matches) {
bottom: max(8px, env(safe-area-inset-bottom)); 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 { #mobile-controls.controller-active .mobile-control.restart {
left: auto; left: auto;
right: 1%; right: 1%;
@ -499,6 +538,22 @@ if (!isDesktopPointer.matches) {
right: calc(var(--steam-wide-edge-right) + var(--steam-wide-cannon-size) + 14px); 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 { #mobile-controls.controller-active .mobile-control.restart {
right: max(12px, env(safe-area-inset-right)); right: max(12px, env(safe-area-inset-right));
top: clamp(118px, 30vh, 168px); top: clamp(118px, 30vh, 168px);
@ -641,6 +696,8 @@ if (!isDesktopPointer.matches) {
const fields = Object.fromEntries( const fields = Object.fromEntries(
[...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), [...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'); 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['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`;
fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 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(); const statusText = String(state.status || '').trim();
fields.status.textContent = statusText; fields.status.textContent = statusText;
fields.status.classList.toggle('visible', Boolean(statusText)); fields.status.classList.toggle('visible', Boolean(statusText));

View File

@ -263,6 +263,33 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); 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 () => { test('merchant traffic follows trade routes and pirates can raid it', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');