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