Wire shipyard UI into progression

This commit is contained in:
Admin 2026-09-20 21:33:46 +00:00
parent 35c9bb9bc8
commit 9a8e0d4a00
7 changed files with 148 additions and 42 deletions

View File

@ -132,7 +132,7 @@
</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 buy" data-action="open-shipyard" aria-label="Open shipyard">SHIPYARD</button>
<button class="mobile-control ship-action fit" data-action="fit-salvage" aria-label="Fit salvaged module">FIT</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>

View File

@ -1,2 +1,3 @@
import './game-v4.js'; import './game-v4.js';
import './mobile-hud.js'; import './mobile-hud.js';
import './shipyard-ui.js';

View File

@ -36,6 +36,7 @@ import {
createStarterProgression, createStarterProgression,
deriveShipStats, deriveShipStats,
installModule, installModule,
marketForPort,
salvageForPirate, salvageForPirate,
} from './progression/ship-loadout.js'; } from './progression/ship-loadout.js';
import { MODULE_CATALOG } from './progression/modules.js'; import { MODULE_CATALOG } from './progression/modules.js';
@ -102,6 +103,8 @@ 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.pendingMobileActions = new Set();
this.pendingModulePurchaseId = null;
this.shipyardOpen = false;
this.playerSpeed = 0; this.playerSpeed = 0;
this.playerHull = this.playerStats.maxHull; this.playerHull = this.playerStats.maxHull;
this.gameState = 'playing'; this.gameState = 'playing';
@ -223,7 +226,11 @@ class GameScene extends Phaser.Scene {
window.addEventListener('airship:helm-vector', this.handleHelmVector); window.addEventListener('airship:helm-vector', this.handleHelmVector);
this.handleMobileAction = (event) => { this.handleMobileAction = (event) => {
const action = String(event.detail?.action || ''); const action = String(event.detail?.action || '');
if (action) this.pendingMobileActions.add(action); if (!action) return;
if (action === 'purchase-module') {
this.pendingModulePurchaseId = String(event.detail?.moduleId || '');
}
this.pendingMobileActions.add(action);
}; };
window.addEventListener('airship:action', this.handleMobileAction); window.addEventListener('airship:action', this.handleMobileAction);
this.keys = this.input.keyboard.addKeys({ this.keys = this.input.keyboard.addKeys({
@ -233,6 +240,7 @@ class GameScene extends Phaser.Scene {
restart: Phaser.Input.Keyboard.KeyCodes.R, restart: Phaser.Input.Keyboard.KeyCodes.R,
buyUpgrade: Phaser.Input.Keyboard.KeyCodes.B, buyUpgrade: Phaser.Input.Keyboard.KeyCodes.B,
fitSalvage: Phaser.Input.Keyboard.KeyCodes.U, fitSalvage: Phaser.Input.Keyboard.KeyCodes.U,
closeShipyard: Phaser.Input.Keyboard.KeyCodes.ESC,
}); });
this.cameras.main.setBounds(0, 0, this.world.width, this.world.height); this.cameras.main.setBounds(0, 0, this.world.width, this.world.height);
@ -527,29 +535,60 @@ 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);
const openRequested = this.pendingMobileActions.delete('open-shipyard')
const mobileBuy = this.pendingMobileActions.delete('buy-upgrade'); || Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade);
const closeRequested = this.pendingMobileActions.delete('close-shipyard')
|| Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
const purchaseRequested = this.pendingMobileActions.delete('purchase-module');
const mobileFit = this.pendingMobileActions.delete('fit-salvage'); const mobileFit = this.pendingMobileActions.delete('fit-salvage');
if (mobileBuy || Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade)) { if (closeRequested && this.shipyardOpen) {
this.shipyardOpen = false;
this.pendingModulePurchaseId = null;
window.dispatchEvent(new CustomEvent('airship:shipyard-close'));
return;
}
if (openRequested) {
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 ENTER SHIPYARD');
this.time.delayedCall(1400, () => {
if (this.gameState === 'playing') this.status.setText('');
});
} else { } else {
const module = bestAffordableMarketUpgrade(this.progression, port); this.shipyardOpen = true;
this.playerSpeed = 0;
this.player.setVelocity(0, 0);
window.dispatchEvent(new CustomEvent('airship:shipyard-open', {
detail: this.buildHudState(),
}));
}
}
if (purchaseRequested) {
const moduleId = this.pendingModulePurchaseId;
this.pendingModulePurchaseId = null;
if (!this.shipyardOpen || !docked || !moduleId) return;
const stock = marketForPort(port);
const module = stock.find(({ id }) => id === moduleId);
if (!module) { if (!module) {
this.status.setText(`${port.name}\nNO AFFORDABLE UPGRADES`); this.status.setText('MODULE NOT STOCKED AT THIS PORT');
} else if (this.progression.loadout?.[module.slot] === module.id) {
this.status.setText(`${module.name}\nALREADY INSTALLED`);
} else if (this.progression.credits < module.price) {
this.status.setText(`${module.name}\nNEED ¢${module.price}`);
} else { } else {
const previousMaxHull = this.playerStats.maxHull; const previousMaxHull = this.playerStats.maxHull;
this.progression = buyAndInstallModule(this.progression, module.id); this.progression = buyAndInstallModule(this.progression, module.id);
this.applyProgressionStats(previousMaxHull); this.applyProgressionStats(previousMaxHull);
this.status.setText( this.status.setText(
`${port.name}\nINSTALLED ${module.name} // ¢${this.progression.credits}`, `INSTALLED ${module.name}\n¢${this.progression.credits} REMAINING`,
); );
} }
} window.dispatchEvent(new CustomEvent('airship:shipyard-state', {
this.time.delayedCall(1500, () => { detail: this.buildHudState(),
if (this.gameState === 'playing') this.status.setText(''); }));
});
} }
if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) {
@ -563,6 +602,11 @@ class GameScene extends Phaser.Scene {
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`); this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`);
} }
if (this.shipyardOpen) {
window.dispatchEvent(new CustomEvent('airship:shipyard-state', {
detail: this.buildHudState(),
}));
}
this.time.delayedCall(1400, () => { this.time.delayedCall(1400, () => {
if (this.gameState === 'playing') this.status.setText(''); if (this.gameState === 'playing') this.status.setText('');
}); });
@ -1147,8 +1191,16 @@ class GameScene extends Phaser.Scene {
}, 'debug'); }, 'debug');
} }
this.updatePlayer(dt, deltaMs);
this.updateProgressionInteraction(); this.updateProgressionInteraction();
if (this.shipyardOpen) {
this.player.setVelocity(0, 0);
const hudState = this.buildHudState();
if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, true);
return;
}
this.updatePlayer(dt, deltaMs);
this.updatePlayerSmoke(deltaMs); this.updatePlayerSmoke(deltaMs);
this.updateRollingClouds(deltaMs); this.updateRollingClouds(deltaMs);
this.updateCloudConcealment(); this.updateCloudConcealment();
@ -1605,6 +1657,7 @@ 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 docked = Boolean(portState.port && portState.range <= 230);
const marketStock = docked ? marketForPort(portState.port) : [];
const marketUpgrade = docked const marketUpgrade = docked
? bestAffordableMarketUpgrade(this.progression, portState.port) ? bestAffordableMarketUpgrade(this.progression, portState.port)
: null; : null;
@ -1651,6 +1704,15 @@ class GameScene extends Phaser.Scene {
nearestPort: portState.port?.name || null, nearestPort: portState.port?.name || null,
nearestPortRange: portState.range, nearestPortRange: portState.range,
docked, docked,
shipyardOpen: this.shipyardOpen,
shipStats: { ...this.playerStats },
loadout: { ...this.progression.loadout },
marketStock: marketStock.map((module) => ({
id: module.id,
name: module.name,
slot: module.slot,
price: module.price,
})),
marketUpgrade: marketUpgrade ? { marketUpgrade: marketUpgrade ? {
id: marketUpgrade.id, id: marketUpgrade.id,
name: marketUpgrade.name, name: marketUpgrade.name,
@ -1674,10 +1736,10 @@ class GameScene extends Phaser.Scene {
} }
publishHudState(state = this.buildHudState(), force = false) { publishHudState(state = this.buildHudState(), force = false) {
if (!this.mobileHudActive || !state) return; if (!state) return;
const now = this.time?.now ?? 0; const now = this.time?.now ?? 0;
if (!force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return; if (this.mobileHudActive && !force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return;
this.lastHudPublishAt = now; this.lastHudPublishAt = now;
window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state })); window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state }));

View File

@ -799,13 +799,10 @@ if (!isDesktopPointer.matches) {
const progression = state.progression || {}; const progression = state.progression || {};
if (buyButton) { if (buyButton) {
const upgrade = progression.marketUpgrade; buyButton.disabled = !progression.docked;
buyButton.disabled = !progression.docked || !upgrade; buyButton.textContent = progression.docked
buyButton.textContent = !progression.docked ? 'SHIPYARD'
? 'BUY\nPORT' : 'SHIPYARD\nPORT';
: upgrade
? `BUY\n${upgrade.name} ¢${upgrade.price}`
: 'BUY\nNO UPGRADE';
} }
if (fitButton) { if (fitButton) {
const module = progression.salvageModule; const module = progression.salvageModule;

View File

@ -24,10 +24,10 @@ export const MODULE_CATALOG = {
}, },
'tuned-propellers': { 'tuned-propellers': {
id: 'tuned-propellers', id: 'tuned-propellers',
name: 'Tuned Propellers', name: 'Engine Lv 1',
slot: 'engine', slot: 'engine',
rarity: 'improved', rarity: 'improved',
price: 280, price: 800,
modifiers: { maxSpeed: 20, acceleration: 10 }, modifiers: { maxSpeed: 20, acceleration: 10 },
}, },
'corsair-overdrive': { 'corsair-overdrive': {
@ -72,10 +72,10 @@ export const MODULE_CATALOG = {
}, },
'reinforced-keel': { 'reinforced-keel': {
id: 'reinforced-keel', id: 'reinforced-keel',
name: 'Reinforced Keel', name: 'Hull Plating Lv 1',
slot: 'hull', slot: 'hull',
rarity: 'improved', rarity: 'improved',
price: 420, price: 600,
modifiers: { maxHull: 28, maxSpeed: -7 }, modifiers: { maxHull: 28, maxSpeed: -7 },
}, },
'stormglass-nav': { 'stormglass-nav': {
@ -104,12 +104,20 @@ export const MODULE_CATALOG = {
}, },
'expanded-hold': { 'expanded-hold': {
id: 'expanded-hold', id: 'expanded-hold',
name: 'Expanded Cargo Hold', name: 'Cargo Hold Lv 1',
slot: 'cargo', slot: 'cargo',
rarity: 'improved', rarity: 'improved',
price: 350, price: 600,
modifiers: { cargoCapacity: 4, maxSpeed: -4 }, modifiers: { cargoCapacity: 4, maxSpeed: -4 },
}, },
'reinforced-rudder': {
id: 'reinforced-rudder',
name: 'Rudder Lv 1',
slot: 'rudder',
rarity: 'improved',
price: 400,
modifiers: { turnRate: 12 },
},
}; };
export const STARTER_LOADOUT = { export const STARTER_LOADOUT = {
@ -119,6 +127,7 @@ export const STARTER_LOADOUT = {
navigation: 'brass-compass', navigation: 'brass-compass',
utility: null, utility: null,
cargo: null, cargo: null,
rudder: null,
}; };
export const purchasableModules = () => Object.values(MODULE_CATALOG) export const purchasableModules = () => Object.values(MODULE_CATALOG)

View File

@ -31,9 +31,12 @@ test('installed modules derive meaningful ship stat changes', () => {
test('port purchase deducts credits and installs the module', () => { test('port purchase deducts credits and installs the module', () => {
const starter = createStarterProgression(); const starter = createStarterProgression();
const result = buyAndInstallModule(starter, 'tuned-propellers'); const result = buyAndInstallModule(starter, 'tuned-propellers');
assert.equal(result.purchased, true); assert.equal(result.purchased, false);
assert.equal(result.credits, starter.credits - 280); const funded = { ...starter, credits: 1000 };
assert.equal(result.loadout.engine, 'tuned-propellers'); const fundedResult = buyAndInstallModule(funded, 'tuned-propellers');
assert.equal(fundedResult.purchased, true);
assert.equal(fundedResult.credits, funded.credits - 800);
assert.equal(fundedResult.loadout.engine, 'tuned-propellers');
}); });
test('pirate salvage is deterministic and collectible within salvage capacity', () => { test('pirate salvage is deterministic and collectible within salvage capacity', () => {
@ -53,3 +56,13 @@ test('market can select an affordable upgrade for a port', () => {
assert.ok(upgrade); assert.ok(upgrade);
assert.ok(upgrade.price <= starter.credits); assert.ok(upgrade.price <= starter.credits);
}); });
test('rudder tier matches shipyard art and improves handling', () => {
const starter = { ...createStarterProgression(), credits: 650 };
const result = buyAndInstallModule(starter, 'reinforced-rudder');
const stats = deriveShipStats(result.loadout);
assert.equal(result.purchased, true);
assert.equal(result.credits, 250);
assert.equal(stats.turnRate, 88);
});

View File

@ -270,20 +270,44 @@ test('mobile controls expose native buy and salvage-fit actions', async () => {
read('index.html'), read('index.html'),
]); ]);
assert.match(index, /data-action="buy-upgrade"/); assert.match(index, /data-action="open-shipyard"/);
assert.match(index, /data-action="fit-salvage"/); assert.match(index, /data-action="fit-salvage"/);
assert.match(index, /CustomEvent\('airship:action'/); assert.match(index, /CustomEvent\('airship:action'/);
assert.match(game, /addEventListener\('airship:action', this\.handleMobileAction\)/); assert.match(game, /addEventListener\('airship:action', this\.handleMobileAction\)/);
assert.match(game, /this\.pendingMobileActions\.delete\('buy-upgrade'\)/); assert.match(game, /this\.pendingMobileActions\.delete\('open-shipyard'\)/);
assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/); assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/);
assert.match(game, /marketUpgrade: marketUpgrade \? \{/); assert.match(game, /marketUpgrade: marketUpgrade \? \{/);
assert.match(game, /salvageModule: salvageModule \? \{/); assert.match(game, /salvageModule: salvageModule \? \{/);
assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/); assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/);
assert.match(hud, /root\.querySelector\('\.mobile-control\.fit'\)/); assert.match(hud, /root\.querySelector\('\.mobile-control\.fit'\)/);
assert.match(hud, /buyButton\.disabled = !progression\.docked \|\| !upgrade/); assert.match(hud, /buyButton\.disabled = !progression\.docked/);
assert.match(hud, /fitButton\.disabled = !module/); assert.match(hud, /fitButton\.disabled = !module/);
}); });
test('shipyard overlay uses supplied component art and explicit module purchases', async () => {
const [game, entry, shipyard, modules] = await Promise.all([
read('src/game-v4.js'),
read('src/game-entry.js'),
read('src/shipyard-ui.js'),
read('src/progression/modules.js'),
]);
assert.match(entry, /import '.\/shipyard-ui\.js'/);
assert.match(shipyard, /shipyard-ui\/title_banner\.png\?url/);
assert.match(shipyard, /shipyard-ui\/ship_selector\.png\?url/);
assert.match(shipyard, /shipyard-ui\/ship_preview\.png\?url/);
assert.match(shipyard, /shipyard-ui\/upgrades\.png\?url/);
assert.match(shipyard, /airship:shipyard-open/);
assert.match(shipyard, /action: 'purchase-module'/);
assert.match(game, /marketForPort\(port\)/);
assert.match(game, /airship:shipyard-state/);
assert.match(game, /this\.shipyardOpen = true/);
assert.match(modules, /'reinforced-rudder'/);
assert.match(modules, /price: 800/);
assert.match(modules, /price: 600/);
assert.match(modules, /price: 400/);
});
test('mobile merchant traffic uses a reduced render budget', async () => { test('mobile merchant traffic uses a reduced render budget', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');
assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/); assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/);