1232 lines
41 KiB
JavaScript
1232 lines
41 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';
|
|
|
|
const WORLD_SIZE = 2400;
|
|
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 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 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() {
|
|
this.playerSpeed = 0;
|
|
this.playerHull = PLAYER_MAX_HULL;
|
|
this.gameState = 'playing';
|
|
this.lastPlayerShot = { port: -Infinity, starboard: -Infinity };
|
|
this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS;
|
|
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;
|
|
|
|
airshipLog('scene:init', {
|
|
hull: this.playerHull,
|
|
wave: this.wave,
|
|
});
|
|
}
|
|
|
|
preload() {
|
|
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() {
|
|
this.physics.world.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE);
|
|
this.drawWorld();
|
|
this.createRollingClouds();
|
|
this.createAnimations();
|
|
|
|
this.player = this.physics.add.sprite(1200, 1550, 'player-airship', 0);
|
|
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.pirates = this.physics.add.group();
|
|
this.playerShots = this.physics.add.group();
|
|
this.pirateShots = this.physics.add.group();
|
|
this.arcGraphics = this.add.graphics().setDepth(4);
|
|
this.createEnvironmentPhysics();
|
|
|
|
this.physics.add.overlap(this.playerShots, this.pirates, this.hitPirate, null, this);
|
|
this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, null, this);
|
|
|
|
registerDebugSnapshot(() => ({
|
|
gameState: this.gameState,
|
|
playerHull: this.playerHull,
|
|
playerSpeed: this.playerSpeed,
|
|
pendingPlayerHits: this.pendingPlayerHits?.length || 0,
|
|
activePirates: this.pirates?.countActive?.(true) || 0,
|
|
playerShots: this.playerShots?.countActive?.(true) || 0,
|
|
pirateShots: this.pirateShots?.countActive?.(true) || 0,
|
|
wave: this.wave,
|
|
defeated: this.defeated,
|
|
environmentWind: this.environmentWind,
|
|
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: WORLD_SIZE,
|
|
});
|
|
|
|
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.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,
|
|
});
|
|
|
|
this.cameras.main.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE);
|
|
this.cameras.main.startFollow(this.player, true, 0.08, 0.08);
|
|
this.cameras.main.setZoom(1.12);
|
|
|
|
this.createHud();
|
|
|
|
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);
|
|
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();
|
|
}
|
|
|
|
|
|
|
|
|
|
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 foreground = Phaser.Math.Between(0, 4) === 0;
|
|
const baseScale = Phaser.Math.FloatBetween(0.85, 2.35);
|
|
const alphaMin = foreground
|
|
? Phaser.Math.FloatBetween(0.08, 0.13)
|
|
: Phaser.Math.FloatBetween(0.025, 0.07);
|
|
const alphaMax = 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(foreground ? 7 : 2)
|
|
.setScale(baseScale)
|
|
.setAlpha(alphaMin)
|
|
.setBlendMode(Phaser.BlendModes.SCREEN);
|
|
|
|
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));
|
|
|
|
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,
|
|
});
|
|
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);
|
|
cloud.setAlpha(Phaser.Math.Linear(alphaMin, alphaMax, visibility));
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
this.environmentObstacles = ENVIRONMENT_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);
|
|
|
|
this.physics.add.collider(this.player, obstacle, () => {
|
|
this.playerSpeed *= 0.7;
|
|
});
|
|
this.physics.add.collider(this.pirates, obstacle);
|
|
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);
|
|
if (this.gameState !== 'playing' || forwardThrust <= 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, forwardThrust);
|
|
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, forwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16);
|
|
const localWind = environmentWindAt({
|
|
x: sternX,
|
|
y: sternY,
|
|
timeMs: this.time.now,
|
|
});
|
|
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 • SPACE brake • R restart', {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
spawnWave() {
|
|
const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
|
|
const count = roster.length;
|
|
airshipLog('wave:spawn', {
|
|
wave: this.wave,
|
|
count,
|
|
classes: roster.map(({ key }) => key),
|
|
});
|
|
this.status.setText(`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}`);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i / count) + Phaser.Math.FloatBetween(-0.25, 0.25);
|
|
const radius = Phaser.Math.Between(500, 760);
|
|
const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120);
|
|
const y = clamp(this.player.y + Math.sin(angle) * radius, 120, WORLD_SIZE - 120);
|
|
this.spawnPirate(x, y, angle, roster[i]);
|
|
}
|
|
|
|
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, WORLD_SIZE, WORLD_SIZE);
|
|
bg.lineStyle(1, 0x8db3cc, 0.08);
|
|
for (let x = 0; x <= WORLD_SIZE; x += 100) bg.lineBetween(x, 0, x, WORLD_SIZE);
|
|
for (let y = 0; y <= WORLD_SIZE; y += 100) bg.lineBetween(0, y, WORLD_SIZE, y);
|
|
|
|
const island = this.add.graphics();
|
|
island.fillStyle(0x5d694e, 1);
|
|
island.fillEllipse(430, 480, 230, 150);
|
|
island.fillStyle(0xa18a62, 1);
|
|
island.fillEllipse(430, 470, 190, 105);
|
|
island.fillStyle(0x283827, 1);
|
|
island.fillCircle(390, 440, 18);
|
|
island.fillCircle(455, 450, 14);
|
|
island.fillCircle(490, 425, 12);
|
|
|
|
const island2 = this.add.graphics();
|
|
island2.fillStyle(0x5d694e, 1);
|
|
island2.fillEllipse(1940, 1840, 260, 165);
|
|
island2.fillStyle(0xa18a62, 1);
|
|
island2.fillEllipse(1940, 1830, 210, 112);
|
|
}
|
|
|
|
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.updatePlayer(dt, deltaMs);
|
|
this.updatePlayerSmoke(deltaMs);
|
|
this.updateRollingClouds(deltaMs);
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
updatePlayer(dt, deltaMs) {
|
|
if (!this.player?.body) return;
|
|
|
|
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 += PLAYER_ACCELERATION * throttleForward * dt;
|
|
if (throttleReverse > 0) this.playerSpeed -= PLAYER_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, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED);
|
|
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 += PLAYER_TURN_RATE * turnStrength * directionSign * turnInput * dt;
|
|
|
|
const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1);
|
|
if (forwardThrust > 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 + forwardThrust * 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,
|
|
});
|
|
const playerVelocity = velocityWithEnvironment(
|
|
Math.cos(h) * this.playerSpeed,
|
|
Math.sin(h) * this.playerSpeed,
|
|
this.environmentWind,
|
|
1,
|
|
);
|
|
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 dx = this.player.x - pirate.x;
|
|
const dy = this.player.y - pirate.y;
|
|
const distance = Math.hypot(dx, dy);
|
|
const bearing = bearingTo(pirate, this.player);
|
|
const preferredRange = pirate.getData('preferredRange') || 335;
|
|
const retreatRange = pirate.getData('retreatRange') || 210;
|
|
const turnRate = pirate.getData('turnRate') || 52;
|
|
const baseSpeed = pirate.getData('speed') || 118;
|
|
|
|
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,
|
|
});
|
|
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;
|
|
|
|
if (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';
|
|
if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return;
|
|
this.fireBroadside(this.player, side, this.playerShots, 'cannonball', 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);
|
|
shot.body.setAllowGravity(false);
|
|
shot.setData('bornAt', this.time.now);
|
|
shot.setData('damage', projectileDamage);
|
|
shot.setData('projectileOwner', actor);
|
|
if (actor === 'pirate') shot.setData('playerHitQueued', false);
|
|
const shotWind = environmentWindAt({
|
|
x,
|
|
y,
|
|
timeMs: this.time.now,
|
|
});
|
|
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 shot = resolveTaggedProjectile(first, second, 'player');
|
|
const pirate = shot === first ? second : first;
|
|
if (!shot?.active || !pirate?.active || pirate === this.player) 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,
|
|
});
|
|
|
|
if (hull <= 0) {
|
|
this.spawnExplosion(pirate.x, pirate.y, 1.25);
|
|
pirate.destroy();
|
|
this.defeated += 1;
|
|
|
|
if (this.pirates.countActive(true) === 0) {
|
|
this.wave += 1;
|
|
this.playerHull = Math.min(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, 0xffcccc);
|
|
}
|
|
|
|
hitPlayer(first, second) {
|
|
if (this.gameState !== 'playing') 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.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,
|
|
);
|
|
|
|
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: 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'),
|
|
},
|
|
environment: {
|
|
windSpeed: this.environmentWind?.strength || 0,
|
|
windHeading: this.environmentWind?.heading || 0,
|
|
},
|
|
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 (!this.mobileHudActive || !state) return;
|
|
|
|
const now = this.time?.now ?? 0;
|
|
if (!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();
|
|
}
|
|
}
|
|
}
|
|
|
|
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'}`);
|
|
this.combatText.setText(compact
|
|
? `K ${state.kills}\n${rangeText}`
|
|
: `DEFEATED ${state.kills}\nRANGE ${rangeText}`);
|
|
|
|
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 (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,
|
|
}); |