airship-phaser/src/game-v4.js

2564 lines
92 KiB
JavaScript

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,
});