2564 lines
92 KiB
JavaScript
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,
|
|
}); |