import Phaser from 'phaser'; import './style.css'; import airshipSheetUrl from './assets/airships.svg?url'; import playerAirshipUrl from './assets/player-airship-4frame.png?url'; import explosionSheetUrl from './assets/explosions.svg?url'; import cannonballUrl from './assets/cannonball.png?url'; import { airshipLog, registerDebugSnapshot } from './diagnostics.js'; import { DEFAULT_PIRATE_SHOT_DAMAGE, PLAYER_MAX_HULL, normalizePlayerDamage, reducePlayerProjectileHits, resolveTaggedProjectile, } from './game-logic.js'; import { pirateDesiredHeading, pirateRosterForWave, pirateSpeedForRange, separationVector, } from './pirate-ai.js'; import { ENVIRONMENT_ISLANDS, environmentWindAt, velocityWithEnvironment, } from './environment-physics.js'; import { generateWorld, nearestPort, pirateInfluenceAt, pirateSpawnPointsForWave, } from './world/world-generator.js'; import { bestAffordableMarketUpgrade, buyAndInstallModule, collectSalvage, createStarterProgression, deriveShipStats, describeInventoryEntry, installInventoryModule, installModule, marketForPort, salvageForPirate, scrapInventoryModule, upgradePathForSlot, } from './progression/ship-loadout.js'; import { MODULE_CATALOG } from './progression/modules.js'; import { acceptMission, bestTradeDestination, buyTradeGood, completeMission, marketAtPort, missionOffersAtPort, missionStatus, payForHullRepair, recordRouteTradeEvent, repairQuote, sellTradeGood, tradeCargoUnits, } from './progression/dock-economy.js'; import { createMerchantTrafficPlans, merchantPointOnRoute, merchantRouteEndpoint, } from './world/trade-traffic.js'; const WORLD_SIZE = 3600; const PLAYER_MAX_SPEED = 180; const PLAYER_REVERSE_SPEED = -55; const PLAYER_ACCELERATION = 92; const PLAYER_BRAKE = 118; const PLAYER_TURN_RATE = 76; const PLAYER_COAST_DRAG = 0.992; const HELM_DEAD_ZONE = 0.1; const HELM_STEER_CURVE = 1.18; const PLAYER_BROADSIDE_RELOAD_MS = 850; const SHOT_SPEED = 520; const SHOT_LIFETIME = 900; const PIRATE_FALLBACK_MAX_HULL = 120; const MAX_PIRATES = 6; const MAX_MERCHANTS = 18; const PIRATE_MERCHANT_DETECTION_RANGE = 760; const MERCHANT_PORT_ARRIVAL_RANGE = 58; const HUD_PUBLISH_INTERVAL_MS = 50; const PLAYER_SMOKE_REAR_OFFSET = 48; const PLAYER_SMOKE_ENGINE_OFFSET = 17; const ROLLING_CLOUD_COUNT = 34; const ROLLING_CLOUD_WRAP_MARGIN = 180; const DENSE_CLOUD_MIN_COUNT = 8; const DENSE_CLOUD_MIN_SCALE = 2.2; const DENSE_CLOUD_MAX_SCALE = 3.9; const DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE = 82; const CLOUD_FIRE_SUPPRESSION_CONCEALMENT = 0.55; const CLOUD_CLOSE_ENGAGE_RANGE = 180; const AUTO_RESET_MS = 2200; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const degToRad = (deg) => deg * Math.PI / 180; const normalizeDegrees = (deg) => ((deg % 360) + 360) % 360; const signedAngleDelta = (target, current) => { let delta = normalizeDegrees(target) - normalizeDegrees(current); if (delta > 180) delta -= 360; if (delta < -180) delta += 360; return delta; }; const bearingTo = (from, to) => normalizeDegrees(Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI + 90); const headingRad = (ship) => degToRad(ship.angle - 90); class GameScene extends Phaser.Scene { constructor() { super('game'); } init() { const querySeed = new URLSearchParams(window.location.search).get('seed'); this.world = generateWorld({ seed: querySeed || 'SKYWARD-001', width: WORLD_SIZE, height: WORLD_SIZE, }); this.progression = createStarterProgression(); this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions); this.pendingMobileActions = new Set(); this.pendingActionDetails = new Map(); this.pendingModulePurchaseId = null; this.dockOpen = false; this.shipyardOpen = false; this.playerSpeed = 0; this.playerHull = this.playerStats.maxHull; this.gameState = 'playing'; this.lastPlayerShot = { port: -Infinity, starboard: -Infinity }; this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS; this.playerBroadsideReloadMs = this.playerStats.reloadMs; this.helmVector = { x: 0, y: 0 }; this.pendingPlayerHits = []; this.playerHitSequence = 0; this.playerSmokeAccumulatorMs = 0; this.environmentWind = { x: 0, y: 0, strength: 0, heading: 0 }; this.lastDiagnosticsAt = -Infinity; this.lastHudPublishAt = -Infinity; this.wave = 1; this.defeated = 0; this.salvageSequence = 0; this.tradeDeliveries = 0; this.tradeLosses = 0; this.playerTradeLosses = 0; this.remotePlayers = new Map(); this.multiplayerProjectiles = new Map(); this.multiplayerAuthoritativeSelf = null; this.multiplayerHasAuthoritativeState = false; this.multiplayerDead = false; this.multiplayerLastHull = null; this.multiplayerSendAccumulatorMs = 0; airshipLog('scene:init', { hull: this.playerHull, wave: this.wave, worldSeed: this.world.seed, }); } preload() { window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 0.16, phase: 'Loading ship systems…' }, })); this.load.on('progress', (value) => { window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 0.16 + value * 0.54, phase: 'Loading airship assets…', }, })); }); this.load.once('complete', () => { window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 0.72, phase: 'Charting the skies…' }, })); }); this.load.spritesheet('airships', airshipSheetUrl, { frameWidth: 64, frameHeight: 64, }); this.load.spritesheet('player-airship', playerAirshipUrl, { frameWidth: 128, frameHeight: 256, }); this.load.spritesheet('explosions', explosionSheetUrl, { frameWidth: 64, frameHeight: 64, }); this.load.image('cannonball', cannonballUrl); } create() { window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 0.78, phase: 'Generating trade routes and skyports…' }, })); this.physics.world.setBounds(0, 0, this.world.width, this.world.height); this.drawWorld(); this.createRollingClouds(); this.createAnimations(); this.player = this.physics.add.sprite(1200, 1550, 'player-airship', 0); this.player.setPosition(this.world.spawn.x, this.world.spawn.y); this.player.setDepth(6).setCollideWorldBounds(true).setDisplaySize(66, 126); this.player.body.setAllowGravity(false); this.player.body.setCircle(44, 20, 84); this.player.setFrame(0); this.createPlayerSmoke(); this.handleMultiplayerSnapshot = (event) => { this.syncMultiplayerSnapshot(event.detail || {}); }; this.handleMultiplayerState = (event) => { if (event.detail?.state === 'online') return; this.clearMultiplayerEntities(); this.multiplayerAuthoritativeSelf = null; this.multiplayerHasAuthoritativeState = false; this.multiplayerDead = false; this.multiplayerLastHull = null; if (this.player && this.gameState === 'playing') { this.player.setVisible(true); this.playerHull = Math.max(1, this.playerHull || this.playerStats?.maxHull || PLAYER_MAX_HULL); } }; window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState); this.pirates = this.physics.add.group(); this.merchants = this.physics.add.group(); this.playerShots = this.physics.add.group(); this.pirateShots = this.physics.add.group(); this.salvageWrecks = this.physics.add.group(); this.arcGraphics = this.add.graphics().setDepth(4); this.createSalvageTexture(); this.createEnvironmentPhysics(); this.createMerchantTraffic(); this.physics.add.overlap(this.playerShots, this.pirates, this.hitPirate, null, this); this.physics.add.overlap(this.pirateShots, this.pirates, this.hitPirate, null, this); this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, null, this); this.physics.add.overlap(this.pirateShots, this.merchants, this.hitMerchant, null, this); this.physics.add.overlap(this.playerShots, this.merchants, this.hitMerchant, null, this); this.physics.add.overlap(this.player, this.salvageWrecks, this.collectSalvageWreck, null, this); registerDebugSnapshot(() => ({ gameState: this.gameState, playerHull: this.playerHull, playerSpeed: this.playerSpeed, pendingPlayerHits: this.pendingPlayerHits?.length || 0, activePirates: this.pirates?.countActive?.(true) || 0, activeMerchants: this.merchants?.countActive?.(true) || 0, tradeDeliveries: this.tradeDeliveries, tradeLosses: this.tradeLosses, playerTradeLosses: this.playerTradeLosses, playerShots: this.playerShots?.countActive?.(true) || 0, pirateShots: this.pirateShots?.countActive?.(true) || 0, wave: this.wave, defeated: this.defeated, environmentWind: this.environmentWind, worldSeed: this.world?.seed, credits: this.progression?.credits, scrap: this.progression?.scrap, salvageInventory: this.progression?.inventory?.length || 0, now: this.time?.now ?? null, player: this.player ? { x: Math.round(this.player.x), y: Math.round(this.player.y), angle: Math.round(this.player.angle), active: this.player.active, visible: this.player.visible, hasBody: Boolean(this.player.body), } : null, })); airshipLog('scene:create', { hull: this.playerHull, wave: this.wave, worldSize: this.world.width, ports: this.world.ports.length, tradeRoutes: this.world.tradeRoutes.length, pirateTerritories: this.world.pirateTerritories.length, }); this.cursors = this.input.keyboard.createCursorKeys(); this.handleHelmVector = (event) => { this.helmVector.x = clamp(Number(event.detail?.x) || 0, -1, 1); 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) return; this.pendingActionDetails.set(action, { ...(event.detail || {}) }); 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({ firePort: Phaser.Input.Keyboard.KeyCodes.Q, fireStarboard: Phaser.Input.Keyboard.KeyCodes.E, stop: Phaser.Input.Keyboard.KeyCodes.SPACE, 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); this.cameras.main.startFollow(this.player, true, 0.08, 0.08); this.cameras.main.setZoom(1.12); this.createHud(); window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 0.94, phase: 'Bringing the Sparrow online…' }, })); this.scale.on('resize', this.layoutHud, this); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.scale.off('resize', this.layoutHud, this); registerDebugSnapshot(null); window.removeEventListener('airship:helm-vector', this.handleHelmVector); window.removeEventListener('airship:action', this.handleMobileAction); window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); window.removeEventListener('airship:multiplayer-state', this.handleMultiplayerState); this.clearMultiplayerEntities(); airshipLog('scene:shutdown', { gameState: this.gameState, hull: this.playerHull, wave: this.wave, defeated: this.defeated, }); }); this.layoutHud({ width: this.scale.width, height: this.scale.height }); this.spawnWave(); window.dispatchEvent(new CustomEvent('airship:loading-progress', { detail: { progress: 1, phase: 'Ready to sail' }, })); window.dispatchEvent(new CustomEvent('airship:game-ready')); } createRollingClouds() { if (!this.textures.exists('rolling-cloud')) { const cloudTexture = this.make.graphics({ x: 0, y: 0, add: false }); cloudTexture.fillStyle(0xe7f0f4, 0.34); cloudTexture.fillEllipse(64, 42, 92, 46); cloudTexture.fillStyle(0xf5f8fa, 0.26); cloudTexture.fillCircle(42, 38, 27); cloudTexture.fillCircle(67, 29, 31); cloudTexture.fillCircle(88, 40, 25); cloudTexture.generateTexture('rolling-cloud', 128, 84); cloudTexture.destroy(); } this.rollingClouds = []; for (let i = 0; i < ROLLING_CLOUD_COUNT; i++) { const denseCloud = i < DENSE_CLOUD_MIN_COUNT || Phaser.Math.Between(0, 5) === 0; const foreground = denseCloud || Phaser.Math.Between(0, 4) === 0; const baseScale = denseCloud ? Phaser.Math.FloatBetween(DENSE_CLOUD_MIN_SCALE, DENSE_CLOUD_MAX_SCALE) : Phaser.Math.FloatBetween(0.85, 2.35); const alphaMin = denseCloud ? Phaser.Math.FloatBetween(0.4, 0.52) : foreground ? Phaser.Math.FloatBetween(0.08, 0.13) : Phaser.Math.FloatBetween(0.025, 0.07); const alphaMax = denseCloud ? Phaser.Math.FloatBetween(0.72, 0.9) : foreground ? Phaser.Math.FloatBetween(0.18, 0.3) : Phaser.Math.FloatBetween(0.08, 0.16); const cloud = this.add.image( Phaser.Math.Between(-ROLLING_CLOUD_WRAP_MARGIN, WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN), Phaser.Math.Between(-ROLLING_CLOUD_WRAP_MARGIN, WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN), 'rolling-cloud', ); cloud .setDepth(denseCloud ? 8 : foreground ? 7 : 2) .setScale(baseScale) .setAlpha(alphaMin) .setBlendMode(denseCloud ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.SCREEN); cloud.setData('denseCloud', denseCloud); cloud.setData('foreground', foreground); cloud.setData('baseScale', baseScale); cloud.setData('alphaMin', alphaMin); cloud.setData('alphaMax', alphaMax); cloud.setData('alphaPhase', Phaser.Math.FloatBetween(0, Math.PI * 2)); cloud.setData('alphaRate', Phaser.Math.FloatBetween(0.00018, 0.00048)); cloud.setData('rollPhase', Phaser.Math.FloatBetween(0, Math.PI * 2)); cloud.setData('driftScale', Phaser.Math.FloatBetween(0.55, 1.35)); cloud.setData( 'concealmentRadius', denseCloud ? baseScale * DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE : 0, ); cloud.setData( 'concealmentStrength', denseCloud ? Phaser.Math.FloatBetween(0.82, 0.98) : 0, ); cloud.setData('presence', denseCloud ? Phaser.Math.FloatBetween(0.25, 1) : 1); cloud.setData('weatherCycleMs', Phaser.Math.Between(18000, 36000)); cloud.setData('weatherPhase', Phaser.Math.FloatBetween(0, Math.PI * 2)); cloud.setData('weatherCycleIndex', -1); this.rollingClouds.push(cloud); } } updateRollingClouds(deltaMs) { if (!this.rollingClouds?.length) return; const dt = Math.min(deltaMs / 1000, 0.05); const now = this.time?.now ?? 0; for (const cloud of this.rollingClouds) { const wind = environmentWindAt({ x: cloud.x, y: cloud.y, timeMs: now, islands: this.world?.islands, }); const driftScale = cloud.getData('driftScale') || 1; cloud.x += wind.x * driftScale * dt; cloud.y += wind.y * driftScale * dt; const phase = cloud.getData('alphaPhase') || 0; const rate = cloud.getData('alphaRate') || 0.0003; const alphaMin = cloud.getData('alphaMin') || 0.04; const alphaMax = cloud.getData('alphaMax') || 0.14; const visibility = 0.5 + 0.5 * Math.sin(now * rate + phase); let presence = 1; if (cloud.getData('denseCloud')) { const weatherCycleMs = cloud.getData('weatherCycleMs') || 26000; const weatherPhase = cloud.getData('weatherPhase') || 0; const cyclePosition = (now % weatherCycleMs) / weatherCycleMs; const weatherWave = 0.5 + 0.5 * Math.sin(cyclePosition * Math.PI * 2 + weatherPhase); const windPresence = clamp((wind.strength - 5) / 18, 0, 1); const targetPresence = clamp(weatherWave * 0.72 + windPresence * 0.38 - 0.12, 0, 1); const previousPresence = cloud.getData('presence') ?? targetPresence; presence = Phaser.Math.Linear(previousPresence, targetPresence, Math.min(1, dt * 0.7)); cloud.setData('presence', presence); const cycleIndex = Math.floor(now / weatherCycleMs); const previousCycleIndex = cloud.getData('weatherCycleIndex'); if (presence < 0.06 && cycleIndex !== previousCycleIndex) { this.reseedDenseCloudUpwind(cloud, wind); cloud.setData('weatherCycleIndex', cycleIndex); } } cloud.setAlpha(Phaser.Math.Linear(alphaMin, alphaMax, visibility) * presence); const baseScale = cloud.getData('baseScale') || 1; const rollPhase = cloud.getData('rollPhase') || 0; const roll = 1 + Math.sin(now * 0.00022 + rollPhase) * 0.045; cloud.setScale(baseScale * roll, baseScale / roll); if (cloud.x > WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN) cloud.x = -ROLLING_CLOUD_WRAP_MARGIN; if (cloud.y > WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN) cloud.y = -ROLLING_CLOUD_WRAP_MARGIN; if (cloud.x < -ROLLING_CLOUD_WRAP_MARGIN) cloud.x = WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN; if (cloud.y < -ROLLING_CLOUD_WRAP_MARGIN) cloud.y = WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN; } } reseedDenseCloudUpwind(cloud, wind) { const magnitude = Math.max(1, Math.hypot(wind?.x || 0, wind?.y || 0)); const windX = (wind?.x || 1) / magnitude; const windY = (wind?.y || 0) / magnitude; const margin = ROLLING_CLOUD_WRAP_MARGIN; const crossOffset = Phaser.Math.Between(-Math.round(WORLD_SIZE * 0.45), Math.round(WORLD_SIZE * 0.45)); const centerX = WORLD_SIZE / 2; const centerY = WORLD_SIZE / 2; const sideX = -windY; const sideY = windX; cloud.x = centerX - windX * (WORLD_SIZE / 2 + margin) + sideX * crossOffset; cloud.y = centerY - windY * (WORLD_SIZE / 2 + margin) + sideY * crossOffset; cloud.setData('driftScale', Phaser.Math.FloatBetween(0.65, 1.45)); cloud.setData('weatherPhase', Phaser.Math.FloatBetween(0, Math.PI * 2)); } cloudConcealmentAt(x, y) { if (!this.rollingClouds?.length) return 0; let concealment = 0; for (const cloud of this.rollingClouds) { if (!cloud.getData('denseCloud')) continue; const radius = cloud.getData('concealmentRadius') || 0; if (radius <= 0) continue; const distance = Phaser.Math.Distance.Between(x, y, cloud.x, cloud.y); if (distance >= radius) continue; const radialStrength = 1 - (distance / radius); const strength = cloud.getData('concealmentStrength') || 0; const presence = cloud.getData('presence') ?? 1; concealment = Math.max(concealment, radialStrength * strength * presence); } return clamp(concealment, 0, 1); } updateCloudConcealment() { if (!this.player?.active) return; const playerConcealment = this.cloudConcealmentAt(this.player.x, this.player.y); this.player.setAlpha(Phaser.Math.Linear(1, 0.3, playerConcealment)); this.player.setData('cloudConcealment', playerConcealment); for (const pirate of this.pirates?.getChildren?.() || []) { if (!pirate?.active) continue; const concealment = this.cloudConcealmentAt(pirate.x, pirate.y); pirate.setAlpha(Phaser.Math.Linear(1, 0.36, concealment)); pirate.setData('cloudConcealment', concealment); } } createSalvageTexture() { if (this.textures.exists('salvage-wreck')) return; const wreckTexture = this.make.graphics({ x: 0, y: 0, add: false }); wreckTexture.fillStyle(0x70543a, 0.95); wreckTexture.fillCircle(11, 11, 8); wreckTexture.lineStyle(2, 0xd2aa6d, 0.9); wreckTexture.lineBetween(4, 15, 18, 7); wreckTexture.lineBetween(6, 5, 17, 17); wreckTexture.generateTexture('salvage-wreck', 22, 22); wreckTexture.destroy(); } spawnSalvageWreck(x, y, classKey = 'raider') { if (!this.salvageWrecks) return null; const salvage = salvageForPirate({ seed: this.world?.seed || 'world', classKey, wave: this.wave, sequence: this.salvageSequence, }); this.salvageSequence += 1; const wreck = this.salvageWrecks.create(x, y, 'salvage-wreck'); wreck.setDepth(5).setDisplaySize(28, 28); wreck.body.setAllowGravity(false); wreck.body.setCircle(10); wreck.setData('salvage', salvage); wreck.setData('bornAt', this.time.now); return wreck; } collectSalvageWreck(first, second) { const wreck = first === this.player ? second : first; if (!wreck?.active || wreck === this.player) return; const salvage = wreck.getData?.('salvage'); if (!salvage) return; const hullBefore = this.playerHull; const repairAvailable = Math.max(0, salvage.repair || 0); this.playerHull = Math.min( this.playerStats?.maxHull || PLAYER_MAX_HULL, this.playerHull + repairAvailable, ); const repairedHull = Math.max(0, this.playerHull - hullBefore); this.progression = collectSalvage( this.progression, salvage, this.playerStats, ); this.progression.lastSalvage = { ...this.progression.lastSalvage, repairedHull, }; wreck.destroy(); const moduleName = salvage.moduleId ? MODULE_CATALOG[salvage.moduleId]?.name || salvage.moduleId : null; const moduleCollected = this.progression.lastSalvage?.moduleCollected; const moduleText = moduleName ? moduleCollected ? `\nMODULE STORED: ${moduleName}\nFIT OR SCRAP AT SHIPYARD` : `\nSALVAGE HOLD FULL: ${moduleName}` : ''; this.status.setText( `SALVAGE +¢${salvage.credits} +${salvage.scrap} SCRAP +${repairedHull} HULL${moduleText}`, ); this.time.delayedCall(2200, () => { if (this.gameState === 'playing') this.status.setText(''); }); } applyProgressionStats(previousMaxHull = this.playerStats?.maxHull || PLAYER_MAX_HULL) { this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions); this.playerBroadsideReloadMs = this.playerStats.reloadMs; if (this.playerStats.maxHull > previousMaxHull) { this.playerHull = Math.min( this.playerStats.maxHull, this.playerHull + (this.playerStats.maxHull - previousMaxHull), ); } else { this.playerHull = Math.min(this.playerHull, this.playerStats.maxHull); } } updateProgressionInteraction() { if (!this.player || !this.keys) return; const { port, range } = nearestPort(this.world, this.player.x, this.player.y); const docked = Boolean(port && range <= 230); const takeAction = (action) => { const requested = this.pendingMobileActions.delete(action); const detail = this.pendingActionDetails.get(action) || null; if (requested) this.pendingActionDetails.delete(action); return requested ? (detail || {}) : null; }; const openDockAction = takeAction('open-dock'); const openShipyardAction = takeAction('open-shipyard'); const closeShipyardAction = takeAction('close-shipyard'); const closeDockAction = takeAction('close-dock'); const purchaseAction = takeAction('purchase-module'); const fitAction = takeAction('fit-salvage'); const scrapAction = takeAction('scrap-salvage'); const buyTradeAction = takeAction('buy-trade-good'); const sellTradeAction = takeAction('sell-trade-good'); const acceptMissionAction = takeAction('accept-mission'); const completeMissionAction = takeAction('complete-mission'); const repairHullAction = takeAction('repair-hull'); const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade); const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); if ((closeShipyardAction || (keyboardEscape && this.shipyardOpen)) && this.shipyardOpen) { this.shipyardOpen = false; this.pendingModulePurchaseId = null; window.dispatchEvent(new CustomEvent('airship:shipyard-close')); window.dispatchEvent(new CustomEvent('airship:dock-return', { detail: this.buildHudState(), })); return; } if ((closeDockAction || (keyboardEscape && this.dockOpen && !this.shipyardOpen)) && this.dockOpen) { this.shipyardOpen = false; this.dockOpen = false; this.pendingModulePurchaseId = null; window.dispatchEvent(new CustomEvent('airship:shipyard-close')); window.dispatchEvent(new CustomEvent('airship:dock-close')); return; } if (openDockAction || keyboardDock) { if (!docked) { this.status.setText('GET WITHIN 230m OF A PORT TO DOCK'); this.time.delayedCall(1400, () => { if (this.gameState === 'playing') this.status.setText(''); }); } else { this.dockOpen = true; this.shipyardOpen = false; this.playerSpeed = 0; this.player.setVelocity(0, 0); window.dispatchEvent(new CustomEvent('airship:dock-open', { detail: this.buildHudState(), })); } return; } if (openShipyardAction && this.dockOpen && docked && !this.shipyardOpen) { this.shipyardOpen = true; window.dispatchEvent(new CustomEvent('airship:dock-hide')); window.dispatchEvent(new CustomEvent('airship:shipyard-open', { detail: this.buildHudState(), })); return; } if (purchaseAction) { const moduleId = String(purchaseAction.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 (fitAction || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { if (!this.shipyardOpen || !docked) { this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD'); } else { const inventoryIndex = Math.max(0, Number(fitAction?.inventoryIndex) || 0); const salvageEntry = this.progression.inventory?.[inventoryIndex]; const moduleId = typeof salvageEntry === 'string' ? salvageEntry : salvageEntry?.moduleId; if (!moduleId) { this.status.setText('SALVAGE HOLD IS EMPTY'); } else { const previousMaxHull = this.playerStats.maxHull; this.progression = installInventoryModule(this.progression, inventoryIndex); this.applyProgressionStats(previousMaxHull); const module = MODULE_CATALOG[moduleId]; const condition = Math.round((this.progression.installedCondition || 1) * 100); this.status.setText(`FITTED ${module?.name || moduleId} // ${condition}% CONDITION`); } window.dispatchEvent(new CustomEvent('airship:shipyard-state', { detail: this.buildHudState(), })); } } if (scrapAction) { if (!this.shipyardOpen || !docked) { this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD'); } else { const beforeScrap = this.progression.scrap || 0; const inventoryIndex = Math.max(0, Number(scrapAction?.inventoryIndex) || 0); this.progression = scrapInventoryModule(this.progression, inventoryIndex); const gained = Math.max(0, (this.progression.scrap || 0) - beforeScrap); this.status.setText( this.progression.scrapped ? `SALVAGED MODULE SCRAPPED +${gained} SCRAP` : 'SALVAGE HOLD IS EMPTY', ); window.dispatchEvent(new CustomEvent('airship:shipyard-state', { detail: this.buildHudState(), })); } } if (this.dockOpen && docked && !this.shipyardOpen) { const market = marketAtPort(this.world, port, this.progression.economy); if (buyTradeAction) { const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId); this.progression = buyTradeGood(this.progression, item, this.playerStats, 1); this.status.setText( this.progression.traded ? `BOUGHT ${item?.name || 'CARGO'} // ¢${this.progression.credits}` : this.progression.reason === 'cargo-full' ? 'CARGO HOLD FULL' : 'NOT ENOUGH COIN', ); } if (sellTradeAction) { const item = market.find(({ goodId }) => goodId === sellTradeAction.goodId); this.progression = sellTradeGood(this.progression, item, 1); this.status.setText( this.progression.traded ? `SOLD ${item?.name || 'CARGO'} // ¢${this.progression.credits}` : 'NONE OF THAT CARGO IN HOLD', ); } if (acceptMissionAction) { const offers = missionOffersAtPort( this.world, port, this.defeated, this.progression.completedMissions || 0, ); const mission = offers.find(({ id }) => id === acceptMissionAction.missionId); this.progression = acceptMission( this.progression, mission, this.playerStats, this.defeated, ); this.status.setText( this.progression.missionAccepted ? `MISSION ACCEPTED\n${mission?.title || ''}` : this.progression.reason === 'cargo-full' ? 'NOT ENOUGH CARGO SPACE' : 'FINISH CURRENT MISSION FIRST', ); } if (completeMissionAction) { const activeBefore = this.progression.activeMission; this.progression = completeMission(this.progression, { portId: port.id, defeated: this.defeated, }); this.status.setText( this.progression.missionCompleted ? `MISSION COMPLETE +¢${activeBefore?.reward || 0}` : 'MISSION NOT COMPLETE YET', ); } if (repairHullAction) { const result = payForHullRepair( this.progression, this.playerHull, this.playerStats.maxHull, port, ); this.progression = result.progression; this.playerHull = result.hull; this.status.setText( result.repaired ? `HULL REPAIRED // -¢${result.cost}` : result.reason === 'full-hull' ? 'HULL ALREADY AT FULL STRENGTH' : `REPAIR COST ¢${result.cost} // NOT ENOUGH COIN`, ); } if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction || repairHullAction) { window.dispatchEvent(new CustomEvent('airship:dock-state', { detail: this.buildHudState(), })); } } } createEnvironmentPhysics() { if (!this.textures.exists('environment-collider')) { const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false }); colliderTexture.fillStyle(0xffffff, 1); colliderTexture.fillRect(0, 0, 4, 4); colliderTexture.generateTexture('environment-collider', 4, 4); colliderTexture.destroy(); } const islands = this.world?.islands || ENVIRONMENT_ISLANDS; this.environmentObstacles = islands.map((island) => { const obstacle = this.physics.add.staticImage( island.x, island.y, 'environment-collider', ); obstacle .setDisplaySize(island.radius * 2, island.radius * 2) .setVisible(false) .refreshBody(); obstacle.body.setCircle(island.radius); // Airships can cross islands, but lose momentum while flying through the terrain envelope. this.physics.add.overlap(this.player, obstacle, () => { if (window.airshipMultiplayer?.isConnected?.()) return; this.playerSpeed *= 0.985; }); this.physics.add.overlap( this.playerShots, obstacle, this.hitEnvironmentProjectile, null, this, ); this.physics.add.overlap( this.pirateShots, obstacle, this.hitEnvironmentProjectile, null, this, ); return obstacle; }); } hitEnvironmentProjectile(first, second) { const shot = first?.getData?.('projectileOwner') ? first : second; if (!shot?.active || !shot.getData?.('projectileOwner')) return; const { x, y } = shot; shot.destroy(); this.spawnExplosion(x, y, 0.2); } createPlayerSmoke() { if (!this.textures.exists('player-smoke')) { const smokeTexture = this.make.graphics({ x: 0, y: 0, add: false }); smokeTexture.fillStyle(0xe3ecee, 0.18); smokeTexture.fillCircle(8, 8, 7); smokeTexture.fillStyle(0xf4f7f7, 0.28); smokeTexture.fillCircle(7, 7, 4.5); smokeTexture.generateTexture('player-smoke', 16, 16); smokeTexture.destroy(); } this.playerSmoke = this.add.particles(0, 0, 'player-smoke', { lifespan: { min: 620, max: 980 }, scale: { start: 0.5, end: 1.45 }, alpha: { start: 0.46, end: 0 }, rotate: { min: 0, max: 360 }, speedX: 0, speedY: 0, frequency: -1, quantity: 1, }); this.playerSmoke.setDepth(5); } updatePlayerSmoke(deltaMs) { if (!this.playerSmoke || !this.player?.active || !this.player.visible) return; const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1); const effectiveForwardThrust = clamp( this.playerSpeed / (this.playerStats?.maxSpeed || PLAYER_MAX_SPEED), 0, 1, ); if (this.gameState !== 'playing' || effectiveForwardThrust <= 0.04) { this.playerSmokeAccumulatorMs = 0; return; } const heading = headingRad(this.player); const forwardX = Math.cos(heading); const forwardY = Math.sin(heading); const sideX = -forwardY; const sideY = forwardX; const sternX = this.player.x - forwardX * PLAYER_SMOKE_REAR_OFFSET; const sternY = this.player.y - forwardY * PLAYER_SMOKE_REAR_OFFSET; const emissionIntervalMs = Phaser.Math.Linear(150, 48, effectiveForwardThrust); this.playerSmokeAccumulatorMs += deltaMs; while (this.playerSmokeAccumulatorMs >= emissionIntervalMs) { const spread = Phaser.Math.FloatBetween(-0.16, 0.16); const trailHeading = heading + Math.PI + spread; const trailSpeed = Phaser.Math.Linear(16, 46, effectiveForwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16); const localWind = environmentWindAt({ x: sternX, y: sternY, timeMs: this.time.now, islands: this.world?.islands, }); const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4; const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 1.4; for (const engineSide of [-1, 1]) { const jitter = Phaser.Math.FloatBetween(-1.5, 1.5); const engineX = sternX + sideX * (PLAYER_SMOKE_ENGINE_OFFSET * engineSide + jitter); const engineY = sternY + sideY * (PLAYER_SMOKE_ENGINE_OFFSET * engineSide + jitter); const particle = this.playerSmoke.emitParticleAt(engineX, engineY, 1); if (particle) { particle.velocityX = velocityX; particle.velocityY = velocityY; } } this.playerSmokeAccumulatorMs -= emissionIntervalMs; } } createHud() { this.mobileHudActive = !window.matchMedia('(hover: hover) and (pointer: fine)').matches; const fixedText = (x, y, text, style = {}) => this.add.text(x, y, text, { fontFamily: 'monospace', color: '#e8f2f7', ...style, }).setScrollFactor(0).setDepth(31); this.hudGraphics = this.add.graphics().setScrollFactor(0).setDepth(30); this.brandText = fixedText(14, 10, 'ARROWSTACK // SPARROW', { fontSize: '10px', color: '#8cd3ff', }); this.speedText = fixedText(14, 29, '', { fontSize: '16px', color: '#ffffff', }); this.headingText = fixedText(this.scale.width / 2, 10, '', { fontSize: '17px', color: '#d8ecff', align: 'center', }).setOrigin(0.5, 0); this.waveText = fixedText(this.scale.width / 2, 32, '', { fontSize: '11px', color: '#8cd3ff', align: 'center', }).setOrigin(0.5, 0); this.combatText = fixedText(this.scale.width - 76, 11, '', { fontSize: '10px', color: '#c8d6df', align: 'right', lineSpacing: 2, }).setOrigin(1, 0); this.playerHullLabel = fixedText(14, 55, '', { fontSize: '10px', color: '#a7f0c1', }); this.enemyHullLabel = fixedText(this.scale.width - 14, 55, '', { fontSize: '10px', color: '#ffb8b8', align: 'right', }).setOrigin(1, 0); this.status = fixedText(this.scale.width / 2, 102, '', { fontSize: '15px', color: '#ffffff', backgroundColor: '#071018dd', padding: { x: 14, y: 8 }, align: 'center', }).setOrigin(0.5, 0); this.controlsHint = fixedText(this.scale.width / 2, this.scale.height - 22, 'ARROWS move • Q/E broadside • B buy at port • U fit salvage • SPACE brake', { fontSize: '10px', color: '#91a8b7', backgroundColor: '#07101899', padding: { x: 8, y: 4 }, }).setOrigin(0.5, 1); if (this.mobileHudActive) { for (const item of [ this.hudGraphics, this.brandText, this.speedText, this.headingText, this.waveText, this.combatText, this.playerHullLabel, this.enemyHullLabel, this.status, this.controlsHint, ]) { item?.setVisible(false); } } } createAnimations() { if (!this.anims.exists('player-forward')) { this.anims.create({ key: 'player-forward', frames: this.anims.generateFrameNumbers('player-airship', { start: 0, end: 3 }), frameRate: 6, repeat: -1, }); } if (!this.anims.exists('pirate-idle')) { this.anims.create({ key: 'pirate-idle', frames: this.anims.generateFrameNumbers('airships', { start: 4, end: 7 }), frameRate: 5, repeat: -1, }); } if (!this.anims.exists('impact-explosion')) { this.anims.create({ key: 'impact-explosion', frames: this.anims.generateFrameNumbers('explosions', { start: 0, end: 5 }), frameRate: 18, repeat: 0, }); } } createMerchantTraffic() { 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); if (!route) continue; this.spawnMerchant(route, plan); } airshipLog('trade:traffic-created', { merchants: this.merchants.countActive(true), routes: this.world.tradeRoutes.length, }); } spawnMerchant(route, plan) { const point = merchantPointOnRoute(route, plan.progress, plan.laneOffset); const merchant = this.merchants.create(point.x, point.y, 'airships', 0); merchant .setDepth(5) .setCollideWorldBounds(true) .setDisplaySize(62, 62) .setTint(0xb7d6b0); merchant.body.setAllowGravity(false); merchant.body.setSize(44, 54, true); const endpoint = merchantRouteEndpoint(route, plan.direction, plan.laneOffset); merchant.angle = bearingTo(merchant, endpoint); merchant.setData('routeId', route.id); merchant.setData('routeDirection', plan.direction); merchant.setData('laneOffset', plan.laneOffset); merchant.setData('speed', plan.speed); merchant.setData('hull', plan.maxHull); merchant.setData('maxHull', plan.maxHull); merchant.setData('cargoValue', plan.cargoValue); merchant.setData('deliveries', 0); merchant.setData('shipKind', 'merchant'); return merchant; } updateMerchants(dt) { for (const merchant of this.merchants.getChildren()) { if (!merchant.active || !merchant.body) continue; const route = this.world.tradeRoutes .find(({ id }) => id === merchant.getData('routeId')); if (!route) { merchant.destroy(); continue; } const direction = merchant.getData('routeDirection') || 1; const laneOffset = merchant.getData('laneOffset') || 0; const target = merchantRouteEndpoint(route, direction, laneOffset); const range = Math.hypot(target.x - merchant.x, target.y - merchant.y); if (range <= MERCHANT_PORT_ARRIVAL_RANGE) { const destinationPortId = direction >= 0 ? route.toPortId : route.fromPortId; this.progression = recordRouteTradeEvent( this.progression, this.world, route, destinationPortId, { kind: 'delivery' }, ); merchant.setData('routeDirection', -direction); merchant.setData('deliveries', (merchant.getData('deliveries') || 0) + 1); this.tradeDeliveries += 1; continue; } const desiredHeading = bearingTo(merchant, target); const delta = signedAngleDelta(desiredHeading, merchant.angle); merchant.angle += clamp(delta, -34 * dt, 34 * dt); const h = headingRad(merchant); const wind = environmentWindAt({ x: merchant.x, y: merchant.y, timeMs: this.time.now, islands: this.world?.islands, }); const velocity = velocityWithEnvironment( Math.cos(h) * (merchant.getData('speed') || 76), Math.sin(h) * (merchant.getData('speed') || 76), wind, 0.55, ); merchant.setVelocity(velocity.x, velocity.y); } } choosePirateTarget(pirate) { const playerDistance = Math.hypot( this.player.x - pirate.x, this.player.y - pirate.y, ); let merchantTarget = null; let merchantDistance = Infinity; let merchantScore = Infinity; for (const merchant of this.merchants.getChildren()) { if (!merchant.active) continue; const range = Math.hypot(merchant.x - pirate.x, merchant.y - pirate.y); if (range > PIRATE_MERCHANT_DETECTION_RANGE) continue; const cargoValue = merchant.getData('cargoValue') || 50; const score = range / (1 + Math.min(2, cargoValue / 140)); if (score < merchantScore) { merchantTarget = merchant; merchantDistance = range; merchantScore = score; } } const merchantPreferred = merchantTarget && (playerDistance > 260 || merchantDistance < playerDistance * 1.15); return merchantPreferred ? { target: merchantTarget, kind: 'merchant', distance: merchantDistance } : { target: this.player, kind: 'player', distance: playerDistance }; } hitMerchant(first, second) { if (this.gameState !== 'playing') return; const playerShot = resolveTaggedProjectile(first, second, 'player'); const pirateShot = playerShot ? null : resolveTaggedProjectile(first, second, 'pirate'); const shot = playerShot || pirateShot; const merchant = shot === first ? second : first; if (!shot?.active || !merchant?.active || merchant === this.player) return; if (merchant.getData?.('shipKind') !== 'merchant') return; const damage = shot.getData('damage') || 10; const projectileOwner = shot.getData('projectileOwner'); const previousHull = merchant.getData('hull') || merchant.getData('maxHull') || 70; const hull = Math.max(0, previousHull - damage); const impactX = shot.x; const impactY = shot.y; shot.destroy(); merchant.setData('hull', hull); this.spawnExplosion(impactX, impactY, 0.28); if (hull > 0) { this.flashShip(merchant, 0xffe2a8); return; } const cargoValue = merchant.getData('cargoValue') || 50; const x = merchant.x; const y = merchant.y; const route = this.world.tradeRoutes .find(({ id }) => id === merchant.getData('routeId')); const direction = merchant.getData('routeDirection') || 1; const destinationPortId = direction >= 0 ? route?.toPortId : route?.fromPortId; merchant.destroy(); this.spawnExplosion(x, y, 1.05); this.tradeLosses += 1; if (projectileOwner === 'player') this.playerTradeLosses += 1; if (route && destinationPortId) { this.progression = recordRouteTradeEvent( this.progression, this.world, route, destinationPortId, { kind: 'loss', playerCaused: projectileOwner === 'player' }, ); } if (projectileOwner === 'player') { this.status.setText( `MERCHANT DESTROYED // WANTED ${this.progression.economy?.wanted || 0}`, ); } const cargoSalvage = { moduleId: null, scrap: Math.max(1, Math.round(cargoValue / 70)), credits: Math.max(15, Math.round(cargoValue * 0.45)), repair: 0, condition: null, }; this.spawnCargoWreck(x, y, cargoSalvage); airshipLog('trade:merchant-destroyed', { projectileOwner, cargoValue, tradeLosses: this.tradeLosses, playerTradeLosses: this.playerTradeLosses, }); } spawnCargoWreck(x, y, salvage) { const wreck = this.salvageWrecks.create(x, y, 'salvage-wreck'); wreck.setDepth(5).setDisplaySize(30, 30).setTint(0xe0c27f); wreck.body.setAllowGravity(false); wreck.body.setCircle(10); wreck.setData('salvage', salvage); wreck.setData('bornAt', this.time.now); wreck.setData('wreckKind', 'cargo'); return wreck; } spawnWave() { const roster = pirateRosterForWave(this.wave, MAX_PIRATES); const count = roster.length; const spawnPoints = pirateSpawnPointsForWave( this.world, this.player, count, this.wave, ); const territory = this.world.pirateTerritories .find(({ id }) => id === spawnPoints[0]?.territoryId); airshipLog('wave:spawn', { wave: this.wave, count, classes: roster.map(({ key }) => key), territoryId: territory?.id || null, }); this.status.setText( `WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}${territory ? ` // ${territory.name}` : ''}`, ); for (let i = 0; i < count; i++) { const point = spawnPoints[i]; const pirate = this.spawnPirate(point.x, point.y, point.angle, roster[i]); pirate.setData('territoryId', point.territoryId); } this.time.delayedCall(900, () => { if (this.gameState === 'playing') this.status.setText(''); }); } spawnPirate(x, y, angle, pirateClass) { const pirate = this.pirates.create(x, y, 'airships', 4); pirate.setDepth(6).setCollideWorldBounds(true).setDisplaySize(pirateClass.displaySize, pirateClass.displaySize); pirate.body.setAllowGravity(false); pirate.body.setSize(46, 58, true); pirate.angle = normalizeDegrees(Phaser.Math.RadToDeg(angle) + 90); pirate.setData('class', pirateClass.key); pirate.setData('classLabel', pirateClass.label); pirate.setData('hull', pirateClass.hull); pirate.setData('maxHull', pirateClass.hull); pirate.setData('speed', pirateClass.speed); pirate.setData('turnRate', pirateClass.turnRate); pirate.setData('reloadMs', pirateClass.reloadMs); pirate.setData('projectileDamage', pirateClass.projectileDamage); pirate.setData('preferredRange', pirateClass.preferredRange); pirate.setData('retreatRange', pirateClass.retreatRange); pirate.setData('broadsideRange', pirateClass.broadsideRange); pirate.setData('broadsideError', pirateClass.broadsideError); pirate.setData('lastShot', this.time.now + Phaser.Math.Between(-700, 300)); pirate.play('pirate-idle'); return pirate; } drawWorld() { const bg = this.add.graphics(); bg.fillStyle(0x132335, 1); bg.fillRect(0, 0, this.world.width, this.world.height); bg.lineStyle(1, 0x8db3cc, 0.08); for (let x = 0; x <= this.world.width; x += 100) bg.lineBetween(x, 0, x, this.world.height); for (let y = 0; y <= this.world.height; y += 100) bg.lineBetween(0, y, this.world.width, y); const territoryGraphics = this.add.graphics().setDepth(0); for (const territory of this.world.pirateTerritories) { territoryGraphics.fillStyle(0x7d2636, 0.035 * territory.strength); territoryGraphics.fillCircle(territory.center.x, territory.center.y, territory.radius); territoryGraphics.lineStyle(2, 0xd65a68, 0.16); territoryGraphics.strokeCircle(territory.center.x, territory.center.y, territory.radius); } const routeGraphics = this.add.graphics().setDepth(1); for (const route of this.world.tradeRoutes) { routeGraphics.lineStyle(route.width, 0x9bc6d8, 0.025 + route.traffic * 0.025); routeGraphics.lineBetween(route.from.x, route.from.y, route.to.x, route.to.y); routeGraphics.lineStyle(2, 0xb9dcea, 0.16 + route.traffic * 0.16); routeGraphics.lineBetween(route.from.x, route.from.y, route.to.x, route.to.y); } const islandGraphics = this.add.graphics().setDepth(2); for (const island of this.world.islands) { islandGraphics.fillStyle(0x5d694e, 1); islandGraphics.fillEllipse( island.x, island.y + 8, island.radius * 2.15, island.radius * 1.45, ); islandGraphics.fillStyle(0xa18a62, 1); islandGraphics.fillEllipse( island.x, island.y, island.radius * 1.78, island.radius * 1.08, ); islandGraphics.fillStyle(0x283827, 0.95); islandGraphics.fillCircle( island.x - island.radius * 0.2, island.y - island.radius * 0.18, Math.max(8, island.radius * 0.16), ); } for (const port of this.world.ports) { const portMarker = this.add.circle(port.x, port.y, 9, 0xf4d27a, 0.95) .setStrokeStyle(2, 0x4a3016, 0.95) .setDepth(3); portMarker.setData('portId', port.id); this.add.text(port.x + 13, port.y - 8, port.name, { fontFamily: 'monospace', fontSize: '11px', color: '#f5df9a', backgroundColor: '#071018aa', padding: { x: 3, y: 2 }, }).setDepth(3); } } update(_time, deltaMs) { const dt = Math.min(deltaMs / 1000, 0.05); const hadQueuedPlayerHit = this.pendingPlayerHits.length > 0; if (this.gameState !== 'playing') { if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart(); const hudState = this.buildHudState(); if (!this.mobileHudActive) this.drawHud(hudState); this.publishHudState(hudState, true); return; } this.processPlayerHits(); this.validateLivePlayerState(); if (this.gameState !== 'playing') { const hudState = this.buildHudState(); if (!this.mobileHudActive) this.drawHud(hudState); this.publishHudState(hudState, true); return; } if (this.time.now - this.lastDiagnosticsAt >= 1000) { this.lastDiagnosticsAt = this.time.now; airshipLog('scene:heartbeat', { gameState: this.gameState, hull: this.playerHull, pendingHits: this.pendingPlayerHits.length, activePirates: this.pirates?.countActive?.(true) || 0, playerShots: this.playerShots?.countActive?.(true) || 0, pirateShots: this.pirateShots?.countActive?.(true) || 0, wave: this.wave, now: this.time.now, }, 'debug'); } this.updateProgressionInteraction(); this.updateMultiplayer(deltaMs); if (this.dockOpen) { 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(); this.updateMerchants(dt); this.updatePirates(dt); this.updateProjectiles(); this.drawWeaponArcs(); this.checkGameState(); const hudState = this.buildHudState(); if (!this.mobileHudActive) this.drawHud(hudState); this.publishHudState(hudState, hadQueuedPlayerHit); if (hadQueuedPlayerHit) { airshipLog('combat:post-hit-frame-complete', { gameState: this.gameState, hull: this.playerHull, playerActive: this.player?.active ?? null, playerVisible: this.player?.visible ?? null, bodyEnabled: this.player?.body?.enable ?? null, velocityX: this.player?.body?.velocity?.x ?? null, velocityY: this.player?.body?.velocity?.y ?? null, now: this.time.now, }); } } clearMultiplayerEntities() { for (const remote of this.remotePlayers.values()) { remote.sprite?.destroy(); remote.label?.destroy(); } this.remotePlayers.clear(); for (const projectile of this.multiplayerProjectiles.values()) { projectile.sprite?.destroy(); } this.multiplayerProjectiles.clear(); } syncMultiplayerSnapshot(snapshot = {}) { this.syncAuthoritativeSelf(snapshot.self || null); this.syncRemotePlayers(snapshot.players || []); this.syncMultiplayerProjectiles(snapshot.projectiles || []); } syncAuthoritativeSelf(state) { if (!state || !this.player) return; const wasDead = this.multiplayerDead; const firstState = !this.multiplayerHasAuthoritativeState; const previousHull = this.multiplayerLastHull; this.multiplayerAuthoritativeSelf = { ...state }; this.multiplayerHasAuthoritativeState = true; this.multiplayerDead = state.alive === false; this.multiplayerLastHull = Math.max(0, Number(state.hull) || 0); if (this.multiplayerDead) { if (!wasDead) { this.spawnExplosion(this.player.x, this.player.y, 1.2); this.status.setText(`SHOT DOWN${state.lastKiller ? ` BY ${state.lastKiller}` : ''}\nRESPAWNING...`); } this.playerHull = 0; this.playerSpeed = 0; this.player.setVelocity(0, 0); this.player.setVisible(false); return; } if (firstState || wasDead) { this.player.setVisible(true); this.player.setPosition(Number(state.x) || this.world.spawn.x, Number(state.y) || this.world.spawn.y); this.player.angle = Number(state.angle) || 0; this.playerSpeed = Number(state.speed) || 0; this.playerHull = Math.max(0, Number(state.hull) || PLAYER_MAX_HULL); if (wasDead) { this.status.setText('REDEPLOYED'); this.time.delayedCall(900, () => { if (this.gameState === 'playing' && !this.multiplayerDead) this.status.setText(''); }); } return; } if (previousHull != null && state.hull < previousHull) { const damage = previousHull - state.hull; this.playerHitSequence += 1; this.spawnExplosion(this.player.x, this.player.y, 0.32); this.playPlayerHitReaction(damage); } this.playerHull = Math.max(0, Number(state.hull) || 0); } syncRemotePlayers(players = []) { const seen = new Set(); for (const state of players) { if (!state?.id) continue; seen.add(state.id); let remote = this.remotePlayers.get(state.id); if (!remote) { const sprite = this.add.sprite( Number(state.x) || this.world.spawn.x, Number(state.y) || this.world.spawn.y, 'player-airship', 0, ); sprite .setDepth(5.5) .setDisplaySize(60, 114) .setTint(0x8cd3ff) .setAlpha(0.86); sprite.play('player-forward'); sprite.anims.timeScale = 0.72; const label = this.add.text(sprite.x, sprite.y - 70, state.name || 'CAPTAIN', { fontFamily: 'monospace', fontSize: '10px', color: '#bfe6ff', backgroundColor: '#071018aa', padding: { x: 4, y: 2 }, }).setOrigin(0.5).setDepth(12); remote = { sprite, label, targetX: sprite.x, targetY: sprite.y, targetAngle: Number(state.angle) || 0, hull: Number(state.hull) || PLAYER_MAX_HULL, alive: state.alive !== false, }; this.remotePlayers.set(state.id, remote); } const wasAlive = remote.alive; remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width); remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height); remote.targetAngle = normalizeDegrees(Number(state.angle) || 0); remote.hull = Math.max(0, Number(state.hull) || 0); remote.alive = state.alive !== false; if (wasAlive && !remote.alive) this.spawnExplosion(remote.sprite.x, remote.sprite.y, 1.05); remote.sprite.setVisible(remote.alive); remote.label.setText(remote.alive ? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}` : `${state.name || 'CAPTAIN'} • DOWN`); } for (const [id, remote] of this.remotePlayers) { if (seen.has(id)) continue; remote.sprite?.destroy(); remote.label?.destroy(); this.remotePlayers.delete(id); } } syncMultiplayerProjectiles(projectiles = []) { const seen = new Set(); const selfId = window.airshipMultiplayer?.getSelfId?.(); for (const state of projectiles) { if (!state?.id) continue; seen.add(state.id); let projectile = this.multiplayerProjectiles.get(state.id); if (!projectile) { const sprite = this.add.image(Number(state.x) || 0, Number(state.y) || 0, 'cannonball'); sprite .setDepth(8) .setDisplaySize(14, 14) .setTint(state.ownerId === selfId ? 0xffdf91 : 0x8cd3ff); projectile = { sprite, vx: 0, vy: 0 }; this.multiplayerProjectiles.set(state.id, projectile); } const targetX = Number(state.x) || 0; const targetY = Number(state.y) || 0; const error = Math.hypot(projectile.sprite.x - targetX, projectile.sprite.y - targetY); if (error > 90) { projectile.sprite.setPosition(targetX, targetY); } else { projectile.sprite.x = Phaser.Math.Linear(projectile.sprite.x, targetX, 0.48); projectile.sprite.y = Phaser.Math.Linear(projectile.sprite.y, targetY, 0.48); } projectile.vx = Number(state.vx) || 0; projectile.vy = Number(state.vy) || 0; } for (const [id, projectile] of this.multiplayerProjectiles) { if (seen.has(id)) continue; projectile.sprite?.destroy(); this.multiplayerProjectiles.delete(id); } } multiplayerControlInput() { if (!this.cursors || !this.keys) return { throttle: 0, steer: 0, stop: false, docked: true }; const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE); const helmY = Math.abs(this.helmVector.y) > HELM_DEAD_ZONE ? this.helmVector.y : 0; const throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY); const throttleReverse = this.cursors.down.isDown ? 1 : Math.max(0, helmY); const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0); return { throttle: clamp(throttleForward - throttleReverse, -1, 1), steer: keyboardTurn || helmX, stop: this.keys.stop.isDown, docked: this.dockOpen || this.shipyardOpen || this.multiplayerDead, }; } updateMultiplayer(deltaMs) { const online = Boolean(window.airshipMultiplayer?.isConnected?.()); if (!online) return; this.multiplayerSendAccumulatorMs += deltaMs; if (this.multiplayerSendAccumulatorMs >= 50) { this.multiplayerSendAccumulatorMs %= 50; window.airshipMultiplayer?.sendInput?.(this.multiplayerControlInput()); } const authoritative = this.multiplayerAuthoritativeSelf; if (authoritative && !this.multiplayerDead && this.player?.visible) { const targetX = Number(authoritative.x) || 0; const targetY = Number(authoritative.y) || 0; const positionError = Math.hypot(this.player.x - targetX, this.player.y - targetY); if (positionError > 180) { this.player.setPosition(targetX, targetY); } else { this.player.x = Phaser.Math.Linear(this.player.x, targetX, 0.14); this.player.y = Phaser.Math.Linear(this.player.y, targetY, 0.14); } this.player.angle += signedAngleDelta(Number(authoritative.angle) || 0, this.player.angle) * 0.16; this.playerSpeed = Phaser.Math.Linear(this.playerSpeed, Number(authoritative.speed) || 0, 0.16); } const dt = Math.min(deltaMs / 1000, 0.05); for (const projectile of this.multiplayerProjectiles.values()) { projectile.sprite.x += projectile.vx * dt; projectile.sprite.y += projectile.vy * dt; } for (const remote of this.remotePlayers.values()) { const sprite = remote.sprite; if (!sprite?.active) continue; sprite.x = Phaser.Math.Linear(sprite.x, remote.targetX, 0.32); sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32); sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32; remote.label.setPosition(sprite.x, sprite.y - 70); } } updateMultiplayerPredictedPlayer(dt, deltaMs) { if (!this.player?.body || this.multiplayerDead) return; const input = this.multiplayerControlInput(); if (input.throttle > 0) this.playerSpeed += PLAYER_ACCELERATION * input.throttle * dt; if (input.throttle < 0) this.playerSpeed += PLAYER_BRAKE * input.throttle * dt; if (input.stop) this.playerSpeed *= Math.pow(0.025, dt); if (input.throttle === 0 && !input.stop) { this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667); } this.playerSpeed = clamp(this.playerSpeed, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED); const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1); const directionSign = this.playerSpeed < 0 ? -1 : 1; this.player.angle += PLAYER_TURN_RATE * turnStrength * directionSign * input.steer * dt; const effectiveForwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1); if (effectiveForwardThrust > 0.06) { if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') { this.player.play('player-forward'); } this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35; } else { if (this.player.anims.isPlaying) this.player.anims.stop(); this.player.setFrame(0); this.player.anims.timeScale = 1; } this.environmentWind = environmentWindAt({ x: this.player.x, y: this.player.y, timeMs: this.time.now, islands: this.world?.islands, }); const h = headingRad(this.player); this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed); if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); } updatePlayer(dt, deltaMs) { if (!this.player?.body) return; if (window.airshipMultiplayer?.isConnected?.()) { this.updateMultiplayerPredictedPlayer(dt, deltaMs); return; } const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions); const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE); const helmY = Math.abs(this.helmVector.y) > HELM_DEAD_ZONE ? this.helmVector.y : 0; const throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY); const throttleReverse = this.cursors.down.isDown ? 1 : Math.max(0, helmY); if (throttleForward > 0) this.playerSpeed += stats.acceleration * throttleForward * dt; if (throttleReverse > 0) this.playerSpeed -= stats.brake * throttleReverse * dt; if (this.keys.stop.isDown) this.playerSpeed *= Math.pow(0.025, dt); if (throttleForward === 0 && throttleReverse === 0 && !this.keys.stop.isDown) { this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667); } this.playerSpeed = clamp(this.playerSpeed, stats.reverseSpeed, stats.maxSpeed); const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1); const directionSign = this.playerSpeed < 0 ? -1 : 1; const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0); const turnInput = keyboardTurn || helmX; this.player.angle += stats.turnRate * turnStrength * directionSign * turnInput * dt; const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1); const effectiveForwardThrust = clamp(this.playerSpeed / stats.maxSpeed, 0, 1); if (effectiveForwardThrust > 0.06) { if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') { this.player.play('player-forward'); } this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35; } else { if (this.player.anims.isPlaying) this.player.anims.stop(); this.player.setFrame(0); this.player.anims.timeScale = 1; } const h = headingRad(this.player); this.environmentWind = environmentWindAt({ x: this.player.x, y: this.player.y, timeMs: this.time.now, islands: this.world?.islands, }); const playerVelocity = velocityWithEnvironment( Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed, this.environmentWind, stats.windExposure, ); this.player.setVelocity(playerVelocity.x, playerVelocity.y); if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); } updatePirates(dt) { const pirates = this.pirates.getChildren(); for (const pirate of pirates) { if (!pirate.active || !pirate.body) continue; const targetState = this.choosePirateTarget(pirate); const target = targetState.target; if (!target?.active || !target?.body) continue; const dx = target.x - pirate.x; const dy = target.y - pirate.y; const distance = Math.hypot(dx, dy); const bearing = bearingTo(pirate, target); const preferredRange = pirate.getData('preferredRange') || 335; const retreatRange = pirate.getData('retreatRange') || 210; const turnRate = pirate.getData('turnRate') || 52; const baseSpeed = pirate.getData('speed') || 118; pirate.setData('targetKind', targetState.kind); pirate.setData('targetShip', target); let desiredHeading = pirateDesiredHeading({ bearing, currentHeading: pirate.angle, distance, preferredRange, retreatRange, }); const separation = separationVector(pirate, pirates); if (separation.x || separation.y) { const desiredRad = degToRad(desiredHeading - 90); const steerX = Math.cos(desiredRad) + separation.x * 1.15; const steerY = Math.sin(desiredRad) + separation.y * 1.15; desiredHeading = normalizeDegrees(Math.atan2(steerY, steerX) * 180 / Math.PI + 90); } const delta = signedAngleDelta(desiredHeading, pirate.angle); pirate.angle += clamp(delta, -turnRate * dt, turnRate * dt); const speed = pirateSpeedForRange({ distance, preferredRange, retreatRange, baseSpeed, }); const h = headingRad(pirate); const pirateWind = environmentWindAt({ x: pirate.x, y: pirate.y, timeMs: this.time.now, islands: this.world?.islands, }); const pirateVelocity = velocityWithEnvironment( Math.cos(h) * speed, Math.sin(h) * speed, pirateWind, 0.9, ); pirate.setVelocity(pirateVelocity.x, pirateVelocity.y); const relative = signedAngleDelta(bearing, pirate.angle); const broadsideError = Math.abs(Math.abs(relative) - 90); const lastShot = pirate.getData('lastShot') || 0; const broadsideRange = pirate.getData('broadsideRange') || 420; const broadsideTolerance = pirate.getData('broadsideError') || 38; const reloadMs = pirate.getData('reloadMs') || 1650; const projectileDamage = pirate.getData('projectileDamage') || DEFAULT_PIRATE_SHOT_DAMAGE; const targetConcealment = targetState.kind === 'player' ? this.player.getData('cloudConcealment') || 0 : 0; const targetObscured = targetConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT && distance > CLOUD_CLOSE_ENGAGE_RANGE; if (!targetObscured && distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) { const side = relative >= 0 ? 1 : -1; this.fireBroadside(pirate, side, this.pirateShots, 'cannonball', projectileDamage, 0.9); pirate.setData('lastShot', this.time.now); } } } playerBroadside(side) { const broadside = side < 0 ? 'port' : 'starboard'; const multiplayerOnline = Boolean(window.airshipMultiplayer?.isConnected?.()); const reloadMs = multiplayerOnline ? PLAYER_BROADSIDE_RELOAD_MS : this.playerBroadsideReloadMs; if (this.time.now - this.lastPlayerShot[broadside] < reloadMs) return; if (multiplayerOnline) window.airshipMultiplayer?.fire?.(broadside); this.fireBroadside( this.player, side, this.playerShots, 'cannonball', this.playerStats?.cannonDamage || 16, 1.0, ); this.lastPlayerShot[broadside] = this.time.now; } fireBroadside(ship, side, group, texture, projectileDamage, velocityScale) { if (!ship?.body) return; const actor = group === this.pirateShots ? 'pirate' : 'player'; const base = headingRad(ship) + side * Math.PI / 2; airshipLog('combat:broadside-fired', { actor, side: side < 0 ? 'port' : 'starboard', damage: projectileDamage, x: Math.round(ship.x), y: Math.round(ship.y), angle: Math.round(ship.angle), }, 'debug'); const offsets = [-7, 0, 7]; for (const offset of offsets) { const angle = base + degToRad(offset); const x = ship.x + Math.cos(base) * 34; const y = ship.y + Math.sin(base) * 34; const shot = group.create(x, y, texture); shot.setDepth(8).setDisplaySize(14, 14); if (actor === 'player' && window.airshipMultiplayer?.isConnected?.()) { shot.setVisible(false); } shot.body.setAllowGravity(false); shot.setData('bornAt', this.time.now); shot.setData('damage', projectileDamage); shot.setData('projectileOwner', actor); shot.setData('sourceShip', ship); if (actor === 'pirate') shot.setData('playerHitQueued', false); const shotWind = environmentWindAt({ x, y, timeMs: this.time.now, islands: this.world?.islands, }); const shotVelocity = velocityWithEnvironment( Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 0.25, shotWind, 0.22, ); shot.setVelocity(shotVelocity.x, shotVelocity.y); } } hitPirate(first, second) { if (this.gameState !== 'playing') return; const playerShot = resolveTaggedProjectile(first, second, 'player'); const pirateShot = playerShot ? null : resolveTaggedProjectile(first, second, 'pirate'); const shot = playerShot || pirateShot; const pirate = shot === first ? second : first; if (!shot?.active || !pirate?.active || pirate === this.player) return; const projectileOwner = shot.getData('projectileOwner'); const sourceShip = shot.getData('sourceShip'); if (projectileOwner === 'pirate' && sourceShip === pirate) return; const damage = shot.getData('damage') || 10; const { x, y } = shot; shot.destroy(); this.spawnExplosion(x, y, 0.32); const previousHull = pirate.getData('hull') || pirate.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL; const hull = Math.max(0, previousHull - damage); pirate.setData('hull', hull); airshipLog('combat:pirate-hit', { damage, hullBefore: previousHull, hullAfter: hull, projectileOwner, friendlyFire: projectileOwner === 'pirate', }); if (hull <= 0) { const pirateClass = pirate.getData('class') || 'raider'; const pirateX = pirate.x; const pirateY = pirate.y; this.spawnExplosion(pirate.x, pirate.y, 1.25); pirate.destroy(); if (projectileOwner === 'player') { this.defeated += 1; this.spawnSalvageWreck(pirateX, pirateY, pirateClass); } if (this.pirates.countActive(true) === 0) { this.wave += 1; this.playerHull = Math.min(this.playerStats?.maxHull || PLAYER_MAX_HULL, this.playerHull + 15); this.status.setText('WAVE CLEARED\n+15 HULL'); this.time.delayedCall(1100, () => { if (this.gameState === 'playing' && this.pirates.countActive(true) === 0) this.spawnWave(); }); } return; } this.flashShip(pirate, projectileOwner === 'pirate' ? 0xffd29a : 0xffcccc); } hitPlayer(first, second) { if (this.gameState !== 'playing') return; // Online player damage is owned by the Go server; local pirates remain client-side until AI migrates. if (window.airshipMultiplayer?.isConnected?.()) return; const shot = resolveTaggedProjectile(first, second, 'pirate'); if (!shot?.active || shot === this.player) { airshipLog('combat:player-hit-rejected', { firstOwner: first?.getData?.('projectileOwner') ?? null, secondOwner: second?.getData?.('projectileOwner') ?? null, firstIsPlayer: first === this.player, secondIsPlayer: second === this.player, }, 'warn'); return; } if (shot.getData('playerHitQueued')) return; const damage = normalizePlayerDamage(shot.getData('damage'), DEFAULT_PIRATE_SHOT_DAMAGE); // The overlap callback only records a verified pirate projectile. // Hull mutation and projectile destruction happen on the next scene update, // outside the Arcade Physics callback. shot.setData('playerHitQueued', true); this.pendingPlayerHits.push({ shot, damage, x: shot.x, y: shot.y, }); airshipLog('combat:player-hit-queued', { damage, pendingHits: this.pendingPlayerHits.length, shotAgeMs: this.time.now - (shot.getData('bornAt') || this.time.now), hull: this.playerHull, }); } processPlayerHits() { if (!this.pendingPlayerHits.length || this.gameState !== 'playing') return; const hits = this.pendingPlayerHits; this.pendingPlayerHits = []; const beforeHull = this.playerHull; const playerStateBefore = { active: this.player?.active ?? null, visible: this.player?.visible ?? null, bodyEnabled: this.player?.body?.enable ?? null, velocityX: this.player?.body?.velocity?.x ?? null, velocityY: this.player?.body?.velocity?.y ?? null, gameState: this.gameState, }; const result = reducePlayerProjectileHits({ hull: this.playerHull, hits, maxHull: PLAYER_MAX_HULL, fallbackDamage: DEFAULT_PIRATE_SHOT_DAMAGE, }); this.playerHull = result.hull; const { totalDamage, appliedHits, gameOver } = result; for (const impact of hits) { const shot = impact.shot; if ( shot !== this.player && shot?.active && shot?.getData?.('projectileOwner') === 'pirate' ) { shot.destroy(); } } if (appliedHits > 0) { try { for (const impact of hits) { this.spawnExplosion(impact.x, impact.y, 0.32); } this.playerHitSequence += 1; this.playPlayerHitReaction(totalDamage); airshipLog('combat:player-hit-fx', { impacts: hits.length, scale: 0.32, }, 'debug'); } catch (error) { airshipLog('combat:player-hit-fx-error', { message: error?.message || String(error), }, 'error'); } } airshipLog('combat:player-hit-processed', { queuedHits: hits.length, appliedHits, ignoredHits: 0, damage: totalDamage, hullBefore: beforeHull, hullAfter: this.playerHull, gameOver, now: this.time.now, playerStateBefore, playerStateAfter: { active: this.player?.active ?? null, visible: this.player?.visible ?? null, bodyEnabled: this.player?.body?.enable ?? null, velocityX: this.player?.body?.velocity?.x ?? null, velocityY: this.player?.body?.velocity?.y ?? null, gameState: this.gameState, }, }, gameOver ? 'warn' : 'info'); if (gameOver) this.finishGame(); } spawnExplosion(x, y, scale = 1) { const fx = this.add.sprite(x, y, 'explosions', 0).setDepth(20).setScale(scale); fx.play('impact-explosion'); fx.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => fx.destroy()); } flashShip(ship, tint) { ship.setTint(tint); this.time.delayedCall(90, () => { if (ship.active) ship.clearTint(); }); } playPlayerHitReaction(damage = DEFAULT_PIRATE_SHOT_DAMAGE) { if (!this.player?.active) return; this.player.setTint(0xffd7d7); this.time.delayedCall(110, () => { if (this.player?.active && this.gameState === 'playing') this.player.clearTint(); }); this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012)); } validateLivePlayerState() { if (this.multiplayerDead) return; if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return; const issues = []; if (!this.player.active) issues.push('inactive'); if (!this.player.visible) issues.push('hidden'); if (this.player.alpha !== 1) issues.push('alpha'); if (!this.player.body) issues.push('body-missing'); else if (!this.player.body.enable) issues.push('body-disabled'); if (issues.length > 0) { airshipLog('player:live-state-invalid', { issues, hull: this.playerHull, active: this.player.active, visible: this.player.visible, alpha: this.player.alpha, bodyEnabled: this.player.body?.enable ?? null, }, 'warn'); } } buildHudState() { const player = this.player; if (!player) return null; const activePirates = (this.pirates?.getChildren?.() || []).filter((pirate) => pirate?.active); const { pirate: nearest, range } = this.nearestPirate(); const now = this.time?.now ?? 0; const reloadReady = (side) => clamp( (now - (this.lastPlayerShot?.[side] ?? -Infinity)) / Math.max(1, this.playerBroadsideReloadMs), 0, 1, ); 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; const salvageItems = (this.progression?.inventory || []) .map((entry, index) => { const details = describeInventoryEntry(entry); return details ? { ...details, inventoryIndex: index } : null; }) .filter(Boolean); const salvageItem = salvageItems[0] || null; const upgradePaths = Object.fromEntries( ['engine', 'hull', 'cargo', 'rudder'].map((slot) => [ slot, upgradePathForSlot(this.progression.loadout, slot, marketStock), ]), ); const dockMarket = docked ? marketAtPort(this.world, portState.port, this.progression.economy) : []; const cargoUsed = tradeCargoUnits(this.progression); const missionOffers = docked ? missionOffersAtPort( this.world, portState.port, this.defeated, this.progression.completedMissions || 0, ) : []; const activeMission = missionStatus(this.progression, { portId: docked ? portState.port?.id : null, defeated: this.defeated, }); const repair = docked ? repairQuote( this.playerHull, this.playerStats.maxHull, portState.port, this.progression.economy?.wanted || 0, ) : null; let objective = null; if (activeMission?.type === 'delivery') { const targetPort = this.world.ports .find(({ id }) => id === activeMission.targetPortId); if (targetPort) { const objectiveBearing = bearingTo(player, targetPort); objective = { type: 'delivery', label: targetPort.name, distance: Math.hypot(targetPort.x - player.x, targetPort.y - player.y), bearing: objectiveBearing, relativeBearing: signedAngleDelta(objectiveBearing, normalizeDegrees(player.angle)), progressText: activeMission.progressText, ready: activeMission.ready, }; } } else if (activeMission?.type === 'bounty') { objective = { type: 'bounty', label: 'PIRATE BOUNTY', progressText: activeMission.progressText, kills: activeMission.kills || 0, targetKills: activeMission.targetKills, ready: activeMission.ready, }; } return { gameState: this.gameState, speed: this.playerSpeed, heading: normalizeDegrees(player.angle), wave: this.wave, hostiles: activePirates.length, kills: this.defeated, player: { hull: this.playerHull, maxHull: window.airshipMultiplayer?.isConnected?.() ? this.multiplayerAuthoritativeSelf?.maxHull || PLAYER_MAX_HULL : this.playerStats?.maxHull || PLAYER_MAX_HULL, hitSequence: this.playerHitSequence, }, target: nearest ? { hull: nearest.getData('hull') || 0, maxHull: nearest.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL, name: nearest.getData('classLabel') || 'PIRATE', range, } : null, reload: { port: reloadReady('port'), starboard: reloadReady('starboard'), }, objective, environment: { windSpeed: this.environmentWind?.strength || 0, windHeading: this.environmentWind?.heading || 0, concealment: this.player?.getData?.('cloudConcealment') || 0, }, trade: { activeMerchants: this.merchants?.countActive?.(true) || 0, deliveries: this.tradeDeliveries, losses: this.tradeLosses, playerLosses: this.playerTradeLosses, }, dock: { open: this.dockOpen, shipyardOpen: this.shipyardOpen, portId: docked ? portState.port?.id : null, portName: docked ? portState.port?.name : null, portType: docked ? portState.port?.type : null, portExports: docked ? portState.port?.exports || [] : [], cargoUsed, cargoCapacity: this.playerStats?.cargoCapacity || 0, market: dockMarket.map((item) => { const held = this.progression.tradeCargo?.[item.goodId] || 0; const opportunity = bestTradeDestination( this.world, portState.port, item.goodId, this.progression.economy, ); return { ...item, held, opportunity, canBuy: (this.progression.credits || 0) >= item.buyPrice && cargoUsed < (this.playerStats?.cargoCapacity || 0), canSell: held > 0, }; }), missionOffers, activeMission, repair, wanted: this.progression.economy?.wanted || 0, reputation: this.progression.economy?.reputation || 0, }, progression: { credits: this.progression?.credits || 0, scrap: this.progression?.scrap || 0, inventoryCount: this.progression?.inventory?.length || 0, nearestPort: portState.port?.name || null, nearestPortRange: portState.range, docked, dockOpen: this.dockOpen, shipyardOpen: this.shipyardOpen, shipStats: { ...this.playerStats }, loadout: { ...this.progression.loadout }, installedConditions: { ...(this.progression.installedConditions || {}) }, marketStock: marketStock.map((module) => ({ id: module.id, name: module.name, slot: module.slot, tier: module.tier ?? null, price: module.price, effectSummary: module.effectSummary || '', })), upgradePaths, marketUpgrade: marketUpgrade ? { id: marketUpgrade.id, name: marketUpgrade.name, price: marketUpgrade.price, } : null, salvageItems, salvageItem, salvageModule: salvageItem ? { id: salvageItem.moduleId, name: salvageItem.name, } : null, }, world: { seed: this.world?.seed, pirateInfluence: influence, }, radar: activePirates.slice(0, 10).map((pirate) => ({ dx: (pirate.x - player.x) / 900, dy: (pirate.y - player.y) / 900, })), status: this.status?.text || '', }; } publishHudState(state = this.buildHudState(), force = false) { if (!state) return; const now = this.time?.now ?? 0; if (this.mobileHudActive && !force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return; this.lastHudPublishAt = now; window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state })); } updateProjectiles() { for (const group of [this.playerShots, this.pirateShots]) { for (const shot of group.getChildren()) { if (!shot.active) continue; if (this.time.now - shot.getData('bornAt') > SHOT_LIFETIME) shot.destroy(); } } for (const wreck of this.salvageWrecks?.getChildren?.() || []) { if (wreck.active && this.time.now - (wreck.getData('bornAt') || 0) > 45000) { wreck.destroy(); } } } drawWeaponArcs() { this.arcGraphics.clear(); if (this.gameState !== 'playing') return; const radius = 190; const forward = headingRad(this.player); this.arcGraphics.lineStyle(2, 0xffc857, 0.28); for (const side of [-1, 1]) { const center = forward + side * Math.PI / 2; this.arcGraphics.beginPath(); this.arcGraphics.arc(this.player.x, this.player.y, radius, center - Math.PI / 4, center + Math.PI / 4); this.arcGraphics.strokePath(); } } nearestPirate() { let nearest = null; let nearestRange = Infinity; for (const pirate of this.pirates.getChildren()) { if (!pirate.active) continue; const range = Math.hypot(this.player.x - pirate.x, this.player.y - pirate.y); if (range < nearestRange) { nearest = pirate; nearestRange = range; } } return { pirate: nearest, range: nearestRange }; } drawHud(state = this.buildHudState()) { if (!state) return; const width = this.scale.width; const compact = width < 700; const target = state.target; const playerHull = state.player?.hull ?? PLAYER_MAX_HULL; const playerMaxHull = state.player?.maxHull ?? PLAYER_MAX_HULL; const nearestHull = target?.hull ?? 0; const nearestMaxHull = target?.maxHull ?? PIRATE_FALLBACK_MAX_HULL; const nearestClass = target?.name || 'PIRATE'; const rangeText = target && Number.isFinite(target.range) ? `${target.range.toFixed(0)}m` : '--'; this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`); this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`); this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`); const objectiveText = state.objective?.type === 'delivery' ? `${state.objective.label} ${Math.round(state.objective.distance || 0)}m` : state.objective?.type === 'bounty' ? state.objective.progressText : null; this.combatText.setText(compact ? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}${objectiveText ? `\n${objectiveText}` : ''}` : `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}${objectiveText ? `\nMISSION ${objectiveText}` : ''}`); this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`); this.enemyHullLabel.setText(target ? `${nearestClass} ${nearestHull}/${nearestMaxHull}` : `WAVE ${state.wave} CLEAR`); const side = 14; const gap = 14; const barY = compact ? 75 : 72; const barHeight = compact ? 8 : 10; const barWidth = (width - side * 2 - gap) / 2; const enemyX = side + barWidth + gap; this.hudGraphics.clear(); this.hudGraphics.fillStyle(0x071018, 0.78); this.hudGraphics.fillRect(0, 0, width, compact ? 96 : 98); this.hudGraphics.lineStyle(1, 0x8cd3ff, 0.15); this.hudGraphics.lineBetween(0, compact ? 96 : 98, width, compact ? 96 : 98); this.hudGraphics.fillStyle(0x17232d, 0.95); this.hudGraphics.fillRoundedRect(side, barY, barWidth, barHeight, 4); this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth, barHeight, 4); this.hudGraphics.fillStyle(0x61c98b, 1); this.hudGraphics.fillRoundedRect(side, barY, barWidth * (playerHull / playerMaxHull), barHeight, 4); if (target) { this.hudGraphics.fillStyle(0xd65757, 1); this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth * (nearestHull / nearestMaxHull), barHeight, 4); } } checkGameState() { if (window.airshipMultiplayer?.isConnected?.()) return; if (this.playerHull <= 0) this.finishGame(); } finishGame() { if (this.gameState !== 'playing') return; airshipLog('game:finish', { hull: this.playerHull, wave: this.wave, defeated: this.defeated, pendingHits: this.pendingPlayerHits?.length || 0, }, 'warn'); this.gameState = 'lost'; this.playerSmokeAccumulatorMs = 0; this.player.setVelocity(0, 0); this.playerShots.clear(true, true); this.pirateShots.clear(true, true); for (const pirate of this.pirates.getChildren()) { if (pirate?.body) pirate.setVelocity(0, 0); } this.spawnExplosion(this.player.x, this.player.y, 1.35); this.player.setVisible(false); this.status.setText('SHIP DESTROYED\nRestarting in 2s...'); this.time.delayedCall(AUTO_RESET_MS, () => { this.scene.restart(); }); } layoutHud(gameSize) { const width = gameSize.width; const height = gameSize.height; const compact = width < 700; if (this.headingText) this.headingText.setPosition(width / 2, 9); if (this.waveText) this.waveText.setPosition(width / 2, 32); if (this.combatText) this.combatText.setPosition(width - (compact ? 76 : 14), 11); if (this.enemyHullLabel) this.enemyHullLabel.setPosition(width - 14, compact ? 57 : 54); if (this.playerHullLabel) this.playerHullLabel.setPosition(14, compact ? 57 : 54); if (this.status) this.status.setPosition(width / 2, compact ? 104 : 108); if (this.controlsHint) { this.controlsHint.setPosition(width / 2, height - 12); this.controlsHint.setVisible(!compact); } } } new Phaser.Game({ type: Phaser.AUTO, parent: 'game', width: window.innerWidth, height: window.innerHeight, backgroundColor: '#132335', physics: { default: 'arcade', arcade: { debug: false }, }, scale: { mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH, }, scene: GameScene, });