From 9a8e0d4a0057b3d3b4365850c6c4c98427659956 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 20 Sep 2026 21:33:46 +0000 Subject: [PATCH] Wire shipyard UI into progression --- index.html | 2 +- src/game-entry.js | 1 + src/game-v4.js | 106 +++++++++++++++++++++++++++++-------- src/mobile-hud.js | 11 ++-- src/progression/modules.js | 21 +++++--- tests/progression.test.mjs | 19 +++++-- tests/wiring.test.mjs | 30 +++++++++-- 7 files changed, 148 insertions(+), 42 deletions(-) diff --git a/index.html b/index.html index 2850b8a..a1f7473 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@ - + diff --git a/src/game-entry.js b/src/game-entry.js index 35dbbc1..029fec5 100644 --- a/src/game-entry.js +++ b/src/game-entry.js @@ -1,2 +1,3 @@ import './game-v4.js'; import './mobile-hud.js'; +import './shipyard-ui.js'; diff --git a/src/game-v4.js b/src/game-v4.js index bcce4f9..d6fc565 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -36,6 +36,7 @@ import { createStarterProgression, deriveShipStats, installModule, + marketForPort, salvageForPirate, } from './progression/ship-loadout.js'; import { MODULE_CATALOG } from './progression/modules.js'; @@ -102,6 +103,8 @@ class GameScene extends Phaser.Scene { this.progression = createStarterProgression(); this.playerStats = deriveShipStats(this.progression.loadout); this.pendingMobileActions = new Set(); + this.pendingModulePurchaseId = null; + this.shipyardOpen = false; this.playerSpeed = 0; this.playerHull = this.playerStats.maxHull; this.gameState = 'playing'; @@ -223,7 +226,11 @@ class GameScene extends Phaser.Scene { window.addEventListener('airship:helm-vector', this.handleHelmVector); this.handleMobileAction = (event) => { 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); this.keys = this.input.keyboard.addKeys({ @@ -233,6 +240,7 @@ class GameScene extends Phaser.Scene { restart: Phaser.Input.Keyboard.KeyCodes.R, buyUpgrade: Phaser.Input.Keyboard.KeyCodes.B, fitSalvage: Phaser.Input.Keyboard.KeyCodes.U, + closeShipyard: Phaser.Input.Keyboard.KeyCodes.ESC, }); 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 docked = Boolean(port && range <= 230); - - const mobileBuy = this.pendingMobileActions.delete('buy-upgrade'); + const openRequested = this.pendingMobileActions.delete('open-shipyard') + || 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'); - 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) { - 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 { - const module = bestAffordableMarketUpgrade(this.progression, port); - if (!module) { - this.status.setText(`${port.name}\nNO AFFORDABLE UPGRADES`); - } else { - const previousMaxHull = this.playerStats.maxHull; - this.progression = buyAndInstallModule(this.progression, module.id); - this.applyProgressionStats(previousMaxHull); - this.status.setText( - `${port.name}\nINSTALLED ${module.name} // ¢${this.progression.credits}`, - ); - } + this.shipyardOpen = true; + this.playerSpeed = 0; + this.player.setVelocity(0, 0); + window.dispatchEvent(new CustomEvent('airship:shipyard-open', { + detail: this.buildHudState(), + })); } - this.time.delayedCall(1500, () => { - if (this.gameState === 'playing') this.status.setText(''); - }); + } + + 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) { + 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 { + const previousMaxHull = this.playerStats.maxHull; + this.progression = buyAndInstallModule(this.progression, module.id); + this.applyProgressionStats(previousMaxHull); + this.status.setText( + `INSTALLED ${module.name}\n¢${this.progression.credits} REMAINING`, + ); + } + window.dispatchEvent(new CustomEvent('airship:shipyard-state', { + detail: this.buildHudState(), + })); } if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { @@ -563,6 +602,11 @@ class GameScene extends Phaser.Scene { const module = MODULE_CATALOG[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, () => { if (this.gameState === 'playing') this.status.setText(''); }); @@ -1147,8 +1191,16 @@ class GameScene extends Phaser.Scene { }, 'debug'); } - this.updatePlayer(dt, deltaMs); 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.updateRollingClouds(deltaMs); this.updateCloudConcealment(); @@ -1605,6 +1657,7 @@ 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 marketStock = docked ? marketForPort(portState.port) : []; const marketUpgrade = docked ? bestAffordableMarketUpgrade(this.progression, portState.port) : null; @@ -1651,6 +1704,15 @@ class GameScene extends Phaser.Scene { nearestPort: portState.port?.name || null, nearestPortRange: portState.range, 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 ? { id: marketUpgrade.id, name: marketUpgrade.name, @@ -1674,10 +1736,10 @@ class GameScene extends Phaser.Scene { } publishHudState(state = this.buildHudState(), force = false) { - if (!this.mobileHudActive || !state) return; + if (!state) return; 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; window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state })); diff --git a/src/mobile-hud.js b/src/mobile-hud.js index 0f8abca..f642fc2 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -799,13 +799,10 @@ if (!isDesktopPointer.matches) { 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'; + buyButton.disabled = !progression.docked; + buyButton.textContent = progression.docked + ? 'SHIPYARD' + : 'SHIPYARD\nPORT'; } if (fitButton) { const module = progression.salvageModule; diff --git a/src/progression/modules.js b/src/progression/modules.js index 6cc6780..d4366c5 100644 --- a/src/progression/modules.js +++ b/src/progression/modules.js @@ -24,10 +24,10 @@ export const MODULE_CATALOG = { }, 'tuned-propellers': { id: 'tuned-propellers', - name: 'Tuned Propellers', + name: 'Engine Lv 1', slot: 'engine', rarity: 'improved', - price: 280, + price: 800, modifiers: { maxSpeed: 20, acceleration: 10 }, }, 'corsair-overdrive': { @@ -72,10 +72,10 @@ export const MODULE_CATALOG = { }, 'reinforced-keel': { id: 'reinforced-keel', - name: 'Reinforced Keel', + name: 'Hull Plating Lv 1', slot: 'hull', rarity: 'improved', - price: 420, + price: 600, modifiers: { maxHull: 28, maxSpeed: -7 }, }, 'stormglass-nav': { @@ -104,12 +104,20 @@ export const MODULE_CATALOG = { }, 'expanded-hold': { id: 'expanded-hold', - name: 'Expanded Cargo Hold', + name: 'Cargo Hold Lv 1', slot: 'cargo', rarity: 'improved', - price: 350, + price: 600, 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 = { @@ -119,6 +127,7 @@ export const STARTER_LOADOUT = { navigation: 'brass-compass', utility: null, cargo: null, + rudder: null, }; export const purchasableModules = () => Object.values(MODULE_CATALOG) diff --git a/tests/progression.test.mjs b/tests/progression.test.mjs index d2fdb49..d754558 100644 --- a/tests/progression.test.mjs +++ b/tests/progression.test.mjs @@ -31,9 +31,12 @@ test('installed modules derive meaningful ship stat changes', () => { test('port purchase deducts credits and installs the module', () => { const starter = createStarterProgression(); const result = buyAndInstallModule(starter, 'tuned-propellers'); - assert.equal(result.purchased, true); - assert.equal(result.credits, starter.credits - 280); - assert.equal(result.loadout.engine, 'tuned-propellers'); + assert.equal(result.purchased, false); + const funded = { ...starter, credits: 1000 }; + 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', () => { @@ -53,3 +56,13 @@ test('market can select an affordable upgrade for a port', () => { assert.ok(upgrade); 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); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index cba4590..e5532de 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -270,20 +270,44 @@ test('mobile controls expose native buy and salvage-fit actions', async () => { 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, /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\('open-shipyard'\)/); 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, /buyButton\.disabled = !progression\.docked/); 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 () => { const source = await read('src/game-v4.js'); assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/);