Sync verified procedural gameplay build

This commit is contained in:
Admin 2026-09-20 19:09:39 +00:00
parent 580994daee
commit c726f9333e
3 changed files with 336 additions and 57 deletions

View File

@ -69,6 +69,7 @@ export const environmentWindAt = ({
x = 0,
y = 0,
timeMs = 0,
islands = ENVIRONMENT_ISLANDS,
} = {}) => {
const time = timeMs / 1000;
const pattern = semiRandomWindPattern(timeMs);
@ -82,7 +83,7 @@ export const environmentWindAt = ({
let windX = Math.cos(localAngle) * localStrength;
let windY = Math.sin(localAngle) * localStrength;
for (const island of ENVIRONMENT_ISLANDS) {
for (const island of islands || ENVIRONMENT_ISLANDS) {
const dx = x - island.x;
const dy = y - island.y;
const distance = Math.hypot(dx, dy);

View File

@ -23,8 +23,24 @@ import {
environmentWindAt,
velocityWithEnvironment,
} from './environment-physics.js';
import {
generateWorld,
nearestPort,
pirateInfluenceAt,
pirateSpawnPointsForWave,
} from './world/world-generator.js';
import {
bestAffordableMarketUpgrade,
buyAndInstallModule,
collectSalvage,
createStarterProgression,
deriveShipStats,
installModule,
salvageForPirate,
} from './progression/ship-loadout.js';
import { MODULE_CATALOG } from './progression/modules.js';
const WORLD_SIZE = 2400;
const WORLD_SIZE = 3600;
const PLAYER_MAX_SPEED = 180;
const PLAYER_REVERSE_SPEED = -55;
const PLAYER_ACCELERATION = 92;
@ -69,11 +85,20 @@ class GameScene extends Phaser.Scene {
}
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.playerSpeed = 0;
this.playerHull = PLAYER_MAX_HULL;
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;
@ -83,10 +108,12 @@ class GameScene extends Phaser.Scene {
this.lastHudPublishAt = -Infinity;
this.wave = 1;
this.defeated = 0;
this.salvageSequence = 0;
airshipLog('scene:init', {
hull: this.playerHull,
wave: this.wave,
worldSeed: this.world.seed,
});
}
@ -107,12 +134,13 @@ class GameScene extends Phaser.Scene {
}
create() {
this.physics.world.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE);
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);
@ -122,12 +150,15 @@ class GameScene extends Phaser.Scene {
this.pirates = 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.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.player, this.salvageWrecks, this.collectSalvageWreck, null, this);
registerDebugSnapshot(() => ({
gameState: this.gameState,
@ -140,6 +171,10 @@ class GameScene extends Phaser.Scene {
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),
@ -154,7 +189,10 @@ class GameScene extends Phaser.Scene {
airshipLog('scene:create', {
hull: this.playerHull,
wave: this.wave,
worldSize: WORLD_SIZE,
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();
@ -168,9 +206,11 @@ class GameScene extends Phaser.Scene {
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,
});
this.cameras.main.setBounds(0, 0, WORLD_SIZE, WORLD_SIZE);
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);
@ -274,6 +314,7 @@ class GameScene extends Phaser.Scene {
x: cloud.x,
y: cloud.y,
timeMs: now,
islands: this.world?.islands,
});
const driftScale = cloud.getData('driftScale') || 1;
cloud.x += wind.x * driftScale * dt;
@ -375,6 +416,130 @@ class GameScene extends Phaser.Scene {
}
}
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;
this.progression = collectSalvage(
this.progression,
salvage,
this.playerStats,
);
wreck.destroy();
const moduleName = salvage.moduleId
? MODULE_CATALOG[salvage.moduleId]?.name || salvage.moduleId
: null;
const moduleCollected = this.progression.lastSalvage?.moduleCollected;
const moduleText = moduleName
? moduleCollected
? `\nSALVAGED: ${moduleName}`
: `\nHOLD FULL: ${moduleName}`
: '';
this.status.setText(
`SALVAGE +¢${salvage.credits} +${salvage.scrap} SCRAP${moduleText}`,
);
this.time.delayedCall(1400, () => {
if (this.gameState === 'playing') this.status.setText('');
});
}
applyProgressionStats(previousMaxHull = this.playerStats?.maxHull || PLAYER_MAX_HULL) {
this.playerStats = deriveShipStats(this.progression.loadout);
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);
if (Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade)) {
if (!docked) {
this.status.setText('GET WITHIN 230m OF A PORT TO BUY UPGRADES');
} else {
const module = bestAffordableMarketUpgrade(this.progression, port);
if (!module) {
this.status.setText(`${port.name}\nNO AFFORDABLE UPGRADES`);
} else {
const previousMaxHull = this.playerStats.maxHull;
this.progression = buyAndInstallModule(this.progression, module.id);
this.applyProgressionStats(previousMaxHull);
this.status.setText(
`${port.name}\nINSTALLED ${module.name} // ¢${this.progression.credits}`,
);
}
}
this.time.delayedCall(1500, () => {
if (this.gameState === 'playing') this.status.setText('');
});
}
if (Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) {
const moduleId = this.progression.inventory?.[0];
if (!moduleId) {
this.status.setText('NO SALVAGED MODULES IN HOLD');
} else {
const previousMaxHull = this.playerStats.maxHull;
this.progression = installModule(this.progression, moduleId);
this.applyProgressionStats(previousMaxHull);
const module = MODULE_CATALOG[moduleId];
this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`);
}
this.time.delayedCall(1400, () => {
if (this.gameState === 'playing') this.status.setText('');
});
}
}
createEnvironmentPhysics() {
if (!this.textures.exists('environment-collider')) {
const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false });
@ -384,7 +549,8 @@ class GameScene extends Phaser.Scene {
colliderTexture.destroy();
}
this.environmentObstacles = ENVIRONMENT_ISLANDS.map((island) => {
const islands = this.world?.islands || ENVIRONMENT_ISLANDS;
this.environmentObstacles = islands.map((island) => {
const obstacle = this.physics.add.staticImage(
island.x,
island.y,
@ -396,10 +562,10 @@ class GameScene extends Phaser.Scene {
.refreshBody();
obstacle.body.setCircle(island.radius);
this.physics.add.collider(this.player, obstacle, () => {
this.playerSpeed *= 0.7;
// Airships can cross islands, but lose momentum while flying through the terrain envelope.
this.physics.add.overlap(this.player, obstacle, () => {
this.playerSpeed *= 0.985;
});
this.physics.add.collider(this.pirates, obstacle);
this.physics.add.overlap(
this.playerShots,
obstacle,
@ -456,7 +622,12 @@ class GameScene extends Phaser.Scene {
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) {
const effectiveForwardThrust = clamp(
this.playerSpeed / (this.playerStats?.maxSpeed || PLAYER_MAX_SPEED),
0,
1,
);
if (this.gameState !== 'playing' || effectiveForwardThrust <= 0.04) {
this.playerSmokeAccumulatorMs = 0;
return;
}
@ -470,17 +641,18 @@ class GameScene extends Phaser.Scene {
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);
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, forwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16);
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;
@ -559,7 +731,7 @@ class GameScene extends Phaser.Scene {
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', {
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',
@ -616,19 +788,29 @@ class GameScene extends Phaser.Scene {
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'}`);
this.status.setText(
`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}${territory ? ` // ${territory.name}` : ''}`,
);
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]);
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, () => {
@ -662,26 +844,64 @@ class GameScene extends Phaser.Scene {
drawWorld() {
const bg = this.add.graphics();
bg.fillStyle(0x132335, 1);
bg.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE);
bg.fillRect(0, 0, this.world.width, this.world.height);
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);
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 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 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 island2 = this.add.graphics();
island2.fillStyle(0x5d694e, 1);
island2.fillEllipse(1940, 1840, 260, 165);
island2.fillStyle(0xa18a62, 1);
island2.fillEllipse(1940, 1830, 210, 112);
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) {
@ -720,6 +940,7 @@ class GameScene extends Phaser.Scene {
}
this.updatePlayer(dt, deltaMs);
this.updateProgressionInteraction();
this.updatePlayerSmoke(deltaMs);
this.updateRollingClouds(deltaMs);
this.updateCloudConcealment();
@ -748,6 +969,7 @@ class GameScene extends Phaser.Scene {
updatePlayer(dt, deltaMs) {
if (!this.player?.body) return;
const stats = this.playerStats || deriveShipStats(this.progression?.loadout);
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);
@ -755,28 +977,29 @@ class GameScene extends Phaser.Scene {
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 (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, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED);
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 += PLAYER_TURN_RATE * turnStrength * directionSign * turnInput * dt;
this.player.angle += stats.turnRate * turnStrength * directionSign * turnInput * dt;
const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1);
if (forwardThrust > 0.06) {
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 + forwardThrust * 1.35;
this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35;
} else {
if (this.player.anims.isPlaying) this.player.anims.stop();
this.player.setFrame(0);
@ -788,12 +1011,13 @@ class GameScene extends Phaser.Scene {
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,
1,
stats.windExposure,
);
this.player.setVelocity(playerVelocity.x, playerVelocity.y);
@ -846,6 +1070,7 @@ class GameScene extends Phaser.Scene {
x: pirate.x,
y: pirate.y,
timeMs: this.time.now,
islands: this.world?.islands,
});
const pirateVelocity = velocityWithEnvironment(
Math.cos(h) * speed,
@ -877,7 +1102,14 @@ class GameScene extends Phaser.Scene {
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.fireBroadside(
this.player,
side,
this.playerShots,
'cannonball',
this.playerStats?.cannonDamage || 16,
1.0,
);
this.lastPlayerShot[broadside] = this.time.now;
}
@ -912,6 +1144,7 @@ class GameScene extends Phaser.Scene {
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,
@ -954,13 +1187,19 @@ class GameScene extends Phaser.Scene {
});
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;
if (projectileOwner === 'player') {
this.defeated += 1;
this.spawnSalvageWreck(pirateX, pirateY, pirateClass);
}
if (this.pirates.countActive(true) === 0) {
this.wave += 1;
this.playerHull = Math.min(PLAYER_MAX_HULL, this.playerHull + 15);
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();
@ -1145,6 +1384,8 @@ class GameScene extends Phaser.Scene {
0,
1,
);
const portState = nearestPort(this.world, player.x, player.y);
const influence = pirateInfluenceAt(this.world, player.x, player.y);
return {
gameState: this.gameState,
@ -1155,7 +1396,7 @@ class GameScene extends Phaser.Scene {
kills: this.defeated,
player: {
hull: this.playerHull,
maxHull: PLAYER_MAX_HULL,
maxHull: this.playerStats?.maxHull || PLAYER_MAX_HULL,
hitSequence: this.playerHitSequence,
},
target: nearest ? {
@ -1173,6 +1414,17 @@ class GameScene extends Phaser.Scene {
windHeading: this.environmentWind?.heading || 0,
concealment: this.player?.getData?.('cloudConcealment') || 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,
},
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,
@ -1198,6 +1450,12 @@ class GameScene extends Phaser.Scene {
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() {
@ -1247,8 +1505,8 @@ class GameScene extends Phaser.Scene {
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}`);
? `K ${state.kills}\n¢${state.progression?.credits || 0}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`);
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(target

View File

@ -187,7 +187,7 @@ test('player and pirate broadsides use the cannonball image asset', async () =>
assert.match(source, /import cannonballUrl from '.\/assets\/cannonball\.png\?url'/);
assert.match(source, /this\.load\.image\('cannonball', cannonballUrl\)/);
assert.match(source, /this\.fireBroadside\(this\.player, side, this\.playerShots, 'cannonball'/);
assert.match(source, /this\.fireBroadside\([\s\S]*?this\.player,[\s\S]*?this\.playerShots,[\s\S]*?'cannonball',[\s\S]*?this\.playerStats\?\.cannonDamage/);
assert.match(source, /this\.fireBroadside\(pirate, side, this\.pirateShots, 'cannonball'/);
assert.match(source, /shot\.setDepth\(8\)\.setDisplaySize\(14, 14\)/);
assert.doesNotMatch(source, /generateTexture\('player-shot'/);
@ -202,7 +202,7 @@ test('pirate cannonballs damage other pirates but ignore their source ship', asy
assert.match(source, /const pirateShot = playerShot \? null : resolveTaggedProjectile\(first, second, 'pirate'\)/);
assert.match(source, /if \(projectileOwner === 'pirate' && sourceShip === pirate\) return/);
assert.match(source, /friendlyFire: projectileOwner === 'pirate'/);
assert.match(source, /if \(projectileOwner === 'player'\) this\.defeated \+= 1/);
assert.match(source, /if \(projectileOwner === 'player'\) \{[\s\S]*?this\.defeated \+= 1;[\s\S]*?this\.spawnSalvageWreck\(/);
});
test('player uses the four-frame north-facing sheet as a forward propulsion animation', async () => {
@ -216,7 +216,8 @@ test('player uses the four-frame north-facing sheet as a forward propulsion anim
assert.match(source, /key: 'player-forward'/);
assert.match(source, /generateFrameNumbers\('player-airship', \{ start: 0, end: 3 \}\)/);
assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/);
assert.match(source, /this\.player\.anims\.timeScale = 0\.65 \+ forwardThrust \* 1\.35/);
assert.match(source, /const effectiveForwardThrust = clamp\(this\.playerSpeed \/ stats\.maxSpeed, 0, 1\)/);
assert.match(source, /this\.player\.anims\.timeScale = 0\.65 \+ effectiveForwardThrust \* 1\.35/);
assert.match(source, /this\.player\.setFrame\(0\)/);
assert.doesNotMatch(source, /key: 'player-idle'/);
});
@ -229,14 +230,15 @@ test('player propulsion smoke emits at world engine positions with per-particle
assert.match(source, /frequency: -1/);
assert.match(source, /this\.updatePlayerSmoke\(deltaMs\)/);
assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| forwardThrust <= 0\.04\)/);
assert.match(source, /const effectiveForwardThrust = clamp\([\s\S]*?this\.playerStats\?\.maxSpeed \|\| PLAYER_MAX_SPEED[\s\S]*?\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| effectiveForwardThrust <= 0\.04\)/);
assert.match(source, /const sternX = this\.player\.x - forwardX \* PLAYER_SMOKE_REAR_OFFSET/);
assert.match(source, /const sternY = this\.player\.y - forwardY \* PLAYER_SMOKE_REAR_OFFSET/);
assert.match(source, /PLAYER_SMOKE_ENGINE_OFFSET \* engineSide/);
assert.match(source, /const particle = this\.playerSmoke\.emitParticleAt\(engineX, engineY, 1\)/);
assert.match(source, /particle\.velocityX = velocityX/);
assert.match(source, /particle\.velocityY = velocityY/);
assert.match(source, /Phaser\.Math\.Linear\(150, 48, forwardThrust\)/);
assert.match(source, /Phaser\.Math\.Linear\(150, 48, effectiveForwardThrust\)/);
assert.doesNotMatch(source, /this\.playerSmoke\.setPosition\(sternX, sternY\)/);
assert.doesNotMatch(source, /this\.playerSmoke\.setParticleSpeed\(/);
});
@ -250,8 +252,9 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /this\.createEnvironmentPhysics\(\)/);
assert.match(source, /this\.physics\.add\.staticImage/);
assert.match(source, /obstacle\.body\.setCircle\(island\.radius\)/);
assert.match(source, /this\.physics\.add\.collider\(this\.player, obstacle, \(\) => \{[\s\S]*?this\.playerSpeed \*= 0\.7;/);
assert.match(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/);
assert.match(source, /const islands = this\.world\?\.islands \|\| ENVIRONMENT_ISLANDS/);
assert.match(source, /this\.physics\.add\.overlap\(this\.player, obstacle, \(\) => \{[\s\S]*?this\.playerSpeed \*= 0\.985;/);
assert.doesNotMatch(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/);
assert.match(source, /this\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/);
assert.match(source, /this\.environmentWind = environmentWindAt\(\{/);
assert.match(source, /const pirateWind = environmentWindAt\(\{/);
@ -260,6 +263,23 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
});
test('procedural world and ship progression are wired into live gameplay', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /generateWorld\(\{/);
assert.match(source, /this\.player\.setPosition\(this\.world\.spawn\.x, this\.world\.spawn\.y\)/);
assert.match(source, /pirateSpawnPointsForWave\(/);
assert.match(source, /for \(const route of this\.world\.tradeRoutes\)/);
assert.match(source, /for \(const territory of this\.world\.pirateTerritories\)/);
assert.match(source, /this\.salvageWrecks = this\.physics\.add\.group\(\)/);
assert.match(source, /collectSalvageWreck\(/);
assert.match(source, /bestAffordableMarketUpgrade\(/);
assert.match(source, /buyAndInstallModule\(/);
assert.match(source, /installModule\(this\.progression, moduleId\)/);
assert.match(source, /this\.playerBroadsideReloadMs = this\.playerStats\.reloadMs/);
assert.match(source, /stats\.windExposure/);
});
test('rolling clouds include large dense banks that phase in and out with the wind', async () => {
const source = await read('src/game-v4.js');