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

View File

@ -23,8 +23,24 @@ import {
environmentWindAt, environmentWindAt,
velocityWithEnvironment, velocityWithEnvironment,
} from './environment-physics.js'; } 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_MAX_SPEED = 180;
const PLAYER_REVERSE_SPEED = -55; const PLAYER_REVERSE_SPEED = -55;
const PLAYER_ACCELERATION = 92; const PLAYER_ACCELERATION = 92;
@ -69,11 +85,20 @@ class GameScene extends Phaser.Scene {
} }
init() { 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.playerSpeed = 0;
this.playerHull = PLAYER_MAX_HULL; this.playerHull = this.playerStats.maxHull;
this.gameState = 'playing'; this.gameState = 'playing';
this.lastPlayerShot = { port: -Infinity, starboard: -Infinity }; this.lastPlayerShot = { port: -Infinity, starboard: -Infinity };
this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS; this.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS;
this.playerBroadsideReloadMs = this.playerStats.reloadMs;
this.helmVector = { x: 0, y: 0 }; this.helmVector = { x: 0, y: 0 };
this.pendingPlayerHits = []; this.pendingPlayerHits = [];
this.playerHitSequence = 0; this.playerHitSequence = 0;
@ -83,10 +108,12 @@ class GameScene extends Phaser.Scene {
this.lastHudPublishAt = -Infinity; this.lastHudPublishAt = -Infinity;
this.wave = 1; this.wave = 1;
this.defeated = 0; this.defeated = 0;
this.salvageSequence = 0;
airshipLog('scene:init', { airshipLog('scene:init', {
hull: this.playerHull, hull: this.playerHull,
wave: this.wave, wave: this.wave,
worldSeed: this.world.seed,
}); });
} }
@ -107,12 +134,13 @@ class GameScene extends Phaser.Scene {
} }
create() { 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.drawWorld();
this.createRollingClouds(); this.createRollingClouds();
this.createAnimations(); this.createAnimations();
this.player = this.physics.add.sprite(1200, 1550, 'player-airship', 0); 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.setDepth(6).setCollideWorldBounds(true).setDisplaySize(66, 126);
this.player.body.setAllowGravity(false); this.player.body.setAllowGravity(false);
this.player.body.setCircle(44, 20, 84); this.player.body.setCircle(44, 20, 84);
@ -122,12 +150,15 @@ class GameScene extends Phaser.Scene {
this.pirates = this.physics.add.group(); this.pirates = this.physics.add.group();
this.playerShots = this.physics.add.group(); this.playerShots = this.physics.add.group();
this.pirateShots = this.physics.add.group(); this.pirateShots = this.physics.add.group();
this.salvageWrecks = this.physics.add.group();
this.arcGraphics = this.add.graphics().setDepth(4); this.arcGraphics = this.add.graphics().setDepth(4);
this.createSalvageTexture();
this.createEnvironmentPhysics(); this.createEnvironmentPhysics();
this.physics.add.overlap(this.playerShots, this.pirates, this.hitPirate, null, this); 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.pirates, this.hitPirate, null, this);
this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, 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(() => ({ registerDebugSnapshot(() => ({
gameState: this.gameState, gameState: this.gameState,
@ -140,6 +171,10 @@ class GameScene extends Phaser.Scene {
wave: this.wave, wave: this.wave,
defeated: this.defeated, defeated: this.defeated,
environmentWind: this.environmentWind, 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, now: this.time?.now ?? null,
player: this.player ? { player: this.player ? {
x: Math.round(this.player.x), x: Math.round(this.player.x),
@ -154,7 +189,10 @@ class GameScene extends Phaser.Scene {
airshipLog('scene:create', { airshipLog('scene:create', {
hull: this.playerHull, hull: this.playerHull,
wave: this.wave, 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(); this.cursors = this.input.keyboard.createCursorKeys();
@ -168,9 +206,11 @@ class GameScene extends Phaser.Scene {
fireStarboard: Phaser.Input.Keyboard.KeyCodes.E, fireStarboard: Phaser.Input.Keyboard.KeyCodes.E,
stop: Phaser.Input.Keyboard.KeyCodes.SPACE, stop: Phaser.Input.Keyboard.KeyCodes.SPACE,
restart: Phaser.Input.Keyboard.KeyCodes.R, 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.startFollow(this.player, true, 0.08, 0.08);
this.cameras.main.setZoom(1.12); this.cameras.main.setZoom(1.12);
@ -274,6 +314,7 @@ class GameScene extends Phaser.Scene {
x: cloud.x, x: cloud.x,
y: cloud.y, y: cloud.y,
timeMs: now, timeMs: now,
islands: this.world?.islands,
}); });
const driftScale = cloud.getData('driftScale') || 1; const driftScale = cloud.getData('driftScale') || 1;
cloud.x += wind.x * driftScale * dt; 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() { createEnvironmentPhysics() {
if (!this.textures.exists('environment-collider')) { if (!this.textures.exists('environment-collider')) {
const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false }); const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false });
@ -384,7 +549,8 @@ class GameScene extends Phaser.Scene {
colliderTexture.destroy(); 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( const obstacle = this.physics.add.staticImage(
island.x, island.x,
island.y, island.y,
@ -396,10 +562,10 @@ class GameScene extends Phaser.Scene {
.refreshBody(); .refreshBody();
obstacle.body.setCircle(island.radius); obstacle.body.setCircle(island.radius);
this.physics.add.collider(this.player, obstacle, () => { // Airships can cross islands, but lose momentum while flying through the terrain envelope.
this.playerSpeed *= 0.7; this.physics.add.overlap(this.player, obstacle, () => {
this.playerSpeed *= 0.985;
}); });
this.physics.add.collider(this.pirates, obstacle);
this.physics.add.overlap( this.physics.add.overlap(
this.playerShots, this.playerShots,
obstacle, obstacle,
@ -456,7 +622,12 @@ class GameScene extends Phaser.Scene {
if (!this.playerSmoke || !this.player?.active || !this.player.visible) return; if (!this.playerSmoke || !this.player?.active || !this.player.visible) return;
const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1); 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; this.playerSmokeAccumulatorMs = 0;
return; return;
} }
@ -470,17 +641,18 @@ class GameScene extends Phaser.Scene {
const sternX = this.player.x - forwardX * PLAYER_SMOKE_REAR_OFFSET; const sternX = this.player.x - forwardX * PLAYER_SMOKE_REAR_OFFSET;
const sternY = this.player.y - forwardY * 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; this.playerSmokeAccumulatorMs += deltaMs;
while (this.playerSmokeAccumulatorMs >= emissionIntervalMs) { while (this.playerSmokeAccumulatorMs >= emissionIntervalMs) {
const spread = Phaser.Math.FloatBetween(-0.16, 0.16); const spread = Phaser.Math.FloatBetween(-0.16, 0.16);
const trailHeading = heading + Math.PI + spread; 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({ const localWind = environmentWindAt({
x: sternX, x: sternX,
y: sternY, y: sternY,
timeMs: this.time.now, timeMs: this.time.now,
islands: this.world?.islands,
}); });
const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4; const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4;
const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 1.4; const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 1.4;
@ -559,7 +731,7 @@ class GameScene extends Phaser.Scene {
align: 'center', align: 'center',
}).setOrigin(0.5, 0); }).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', fontSize: '10px',
color: '#91a8b7', color: '#91a8b7',
backgroundColor: '#07101899', backgroundColor: '#07101899',
@ -616,19 +788,29 @@ class GameScene extends Phaser.Scene {
spawnWave() { spawnWave() {
const roster = pirateRosterForWave(this.wave, MAX_PIRATES); const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
const count = roster.length; 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', { airshipLog('wave:spawn', {
wave: this.wave, wave: this.wave,
count, count,
classes: roster.map(({ key }) => key), 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++) { for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 * i / count) + Phaser.Math.FloatBetween(-0.25, 0.25); const point = spawnPoints[i];
const radius = Phaser.Math.Between(500, 760); const pirate = this.spawnPirate(point.x, point.y, point.angle, roster[i]);
const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120); pirate.setData('territoryId', point.territoryId);
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, () => { this.time.delayedCall(900, () => {
@ -662,26 +844,64 @@ class GameScene extends Phaser.Scene {
drawWorld() { drawWorld() {
const bg = this.add.graphics(); const bg = this.add.graphics();
bg.fillStyle(0x132335, 1); 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); bg.lineStyle(1, 0x8db3cc, 0.08);
for (let x = 0; x <= WORLD_SIZE; x += 100) bg.lineBetween(x, 0, x, WORLD_SIZE); for (let x = 0; x <= this.world.width; x += 100) bg.lineBetween(x, 0, x, this.world.height);
for (let y = 0; y <= WORLD_SIZE; y += 100) bg.lineBetween(0, y, WORLD_SIZE, y); for (let y = 0; y <= this.world.height; y += 100) bg.lineBetween(0, y, this.world.width, y);
const island = this.add.graphics(); const territoryGraphics = this.add.graphics().setDepth(0);
island.fillStyle(0x5d694e, 1); for (const territory of this.world.pirateTerritories) {
island.fillEllipse(430, 480, 230, 150); territoryGraphics.fillStyle(0x7d2636, 0.035 * territory.strength);
island.fillStyle(0xa18a62, 1); territoryGraphics.fillCircle(territory.center.x, territory.center.y, territory.radius);
island.fillEllipse(430, 470, 190, 105); territoryGraphics.lineStyle(2, 0xd65a68, 0.16);
island.fillStyle(0x283827, 1); territoryGraphics.strokeCircle(territory.center.x, territory.center.y, territory.radius);
island.fillCircle(390, 440, 18); }
island.fillCircle(455, 450, 14);
island.fillCircle(490, 425, 12);
const island2 = this.add.graphics(); const routeGraphics = this.add.graphics().setDepth(1);
island2.fillStyle(0x5d694e, 1); for (const route of this.world.tradeRoutes) {
island2.fillEllipse(1940, 1840, 260, 165); routeGraphics.lineStyle(route.width, 0x9bc6d8, 0.025 + route.traffic * 0.025);
island2.fillStyle(0xa18a62, 1); routeGraphics.lineBetween(route.from.x, route.from.y, route.to.x, route.to.y);
island2.fillEllipse(1940, 1830, 210, 112); 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) { update(_time, deltaMs) {
@ -720,6 +940,7 @@ class GameScene extends Phaser.Scene {
} }
this.updatePlayer(dt, deltaMs); this.updatePlayer(dt, deltaMs);
this.updateProgressionInteraction();
this.updatePlayerSmoke(deltaMs); this.updatePlayerSmoke(deltaMs);
this.updateRollingClouds(deltaMs); this.updateRollingClouds(deltaMs);
this.updateCloudConcealment(); this.updateCloudConcealment();
@ -748,6 +969,7 @@ class GameScene extends Phaser.Scene {
updatePlayer(dt, deltaMs) { updatePlayer(dt, deltaMs) {
if (!this.player?.body) return; 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 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 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 throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY);
const throttleReverse = this.cursors.down.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 (throttleForward > 0) this.playerSpeed += stats.acceleration * throttleForward * dt;
if (throttleReverse > 0) this.playerSpeed -= PLAYER_BRAKE * throttleReverse * dt; if (throttleReverse > 0) this.playerSpeed -= stats.brake * throttleReverse * dt;
if (this.keys.stop.isDown) this.playerSpeed *= Math.pow(0.025, dt); if (this.keys.stop.isDown) this.playerSpeed *= Math.pow(0.025, dt);
if (throttleForward === 0 && throttleReverse === 0 && !this.keys.stop.isDown) { if (throttleForward === 0 && throttleReverse === 0 && !this.keys.stop.isDown) {
this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667); 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 turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1);
const directionSign = this.playerSpeed < 0 ? -1 : 1; const directionSign = this.playerSpeed < 0 ? -1 : 1;
const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0); const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0);
const turnInput = keyboardTurn || helmX; 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); 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') { if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') {
this.player.play('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 { } else {
if (this.player.anims.isPlaying) this.player.anims.stop(); if (this.player.anims.isPlaying) this.player.anims.stop();
this.player.setFrame(0); this.player.setFrame(0);
@ -788,12 +1011,13 @@ class GameScene extends Phaser.Scene {
x: this.player.x, x: this.player.x,
y: this.player.y, y: this.player.y,
timeMs: this.time.now, timeMs: this.time.now,
islands: this.world?.islands,
}); });
const playerVelocity = velocityWithEnvironment( const playerVelocity = velocityWithEnvironment(
Math.cos(h) * this.playerSpeed, Math.cos(h) * this.playerSpeed,
Math.sin(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed,
this.environmentWind, this.environmentWind,
1, stats.windExposure,
); );
this.player.setVelocity(playerVelocity.x, playerVelocity.y); this.player.setVelocity(playerVelocity.x, playerVelocity.y);
@ -846,6 +1070,7 @@ class GameScene extends Phaser.Scene {
x: pirate.x, x: pirate.x,
y: pirate.y, y: pirate.y,
timeMs: this.time.now, timeMs: this.time.now,
islands: this.world?.islands,
}); });
const pirateVelocity = velocityWithEnvironment( const pirateVelocity = velocityWithEnvironment(
Math.cos(h) * speed, Math.cos(h) * speed,
@ -877,7 +1102,14 @@ class GameScene extends Phaser.Scene {
playerBroadside(side) { playerBroadside(side) {
const broadside = side < 0 ? 'port' : 'starboard'; const broadside = side < 0 ? 'port' : 'starboard';
if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return; 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; this.lastPlayerShot[broadside] = this.time.now;
} }
@ -912,6 +1144,7 @@ class GameScene extends Phaser.Scene {
x, x,
y, y,
timeMs: this.time.now, timeMs: this.time.now,
islands: this.world?.islands,
}); });
const shotVelocity = velocityWithEnvironment( const shotVelocity = velocityWithEnvironment(
Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25,
@ -954,13 +1187,19 @@ class GameScene extends Phaser.Scene {
}); });
if (hull <= 0) { 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); this.spawnExplosion(pirate.x, pirate.y, 1.25);
pirate.destroy(); 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) { if (this.pirates.countActive(true) === 0) {
this.wave += 1; 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.status.setText('WAVE CLEARED\n+15 HULL');
this.time.delayedCall(1100, () => { this.time.delayedCall(1100, () => {
if (this.gameState === 'playing' && this.pirates.countActive(true) === 0) this.spawnWave(); if (this.gameState === 'playing' && this.pirates.countActive(true) === 0) this.spawnWave();
@ -1145,6 +1384,8 @@ class GameScene extends Phaser.Scene {
0, 0,
1, 1,
); );
const portState = nearestPort(this.world, player.x, player.y);
const influence = pirateInfluenceAt(this.world, player.x, player.y);
return { return {
gameState: this.gameState, gameState: this.gameState,
@ -1155,7 +1396,7 @@ class GameScene extends Phaser.Scene {
kills: this.defeated, kills: this.defeated,
player: { player: {
hull: this.playerHull, hull: this.playerHull,
maxHull: PLAYER_MAX_HULL, maxHull: this.playerStats?.maxHull || PLAYER_MAX_HULL,
hitSequence: this.playerHitSequence, hitSequence: this.playerHitSequence,
}, },
target: nearest ? { target: nearest ? {
@ -1173,6 +1414,17 @@ class GameScene extends Phaser.Scene {
windHeading: this.environmentWind?.heading || 0, windHeading: this.environmentWind?.heading || 0,
concealment: this.player?.getData?.('cloudConcealment') || 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) => ({ radar: activePirates.slice(0, 10).map((pirate) => ({
dx: (pirate.x - player.x) / 900, dx: (pirate.x - player.x) / 900,
dy: (pirate.y - player.y) / 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(); 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() { drawWeaponArcs() {
@ -1247,8 +1505,8 @@ class GameScene extends Phaser.Scene {
this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`); 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.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`);
this.combatText.setText(compact this.combatText.setText(compact
? `K ${state.kills}\n${rangeText}` ? `K ${state.kills}\n¢${state.progression?.credits || 0}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}`); : `DEFEATED ${state.kills}\nRANGE ${rangeText}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`);
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`); this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(target 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, /import cannonballUrl from '.\/assets\/cannonball\.png\?url'/);
assert.match(source, /this\.load\.image\('cannonball', cannonballUrl\)/); 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, /this\.fireBroadside\(pirate, side, this\.pirateShots, 'cannonball'/);
assert.match(source, /shot\.setDepth\(8\)\.setDisplaySize\(14, 14\)/); assert.match(source, /shot\.setDepth\(8\)\.setDisplaySize\(14, 14\)/);
assert.doesNotMatch(source, /generateTexture\('player-shot'/); 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, /const pirateShot = playerShot \? null : resolveTaggedProjectile\(first, second, 'pirate'\)/);
assert.match(source, /if \(projectileOwner === 'pirate' && sourceShip === pirate\) return/); assert.match(source, /if \(projectileOwner === 'pirate' && sourceShip === pirate\) return/);
assert.match(source, /friendlyFire: projectileOwner === 'pirate'/); 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 () => { 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, /key: 'player-forward'/);
assert.match(source, /generateFrameNumbers\('player-airship', \{ start: 0, end: 3 \}\)/); 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, /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.match(source, /this\.player\.setFrame\(0\)/);
assert.doesNotMatch(source, /key: 'player-idle'/); 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, /frequency: -1/);
assert.match(source, /this\.updatePlayerSmoke\(deltaMs\)/); assert.match(source, /this\.updatePlayerSmoke\(deltaMs\)/);
assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/); 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 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, /const sternY = this\.player\.y - forwardY \* PLAYER_SMOKE_REAR_OFFSET/);
assert.match(source, /PLAYER_SMOKE_ENGINE_OFFSET \* engineSide/); assert.match(source, /PLAYER_SMOKE_ENGINE_OFFSET \* engineSide/);
assert.match(source, /const particle = this\.playerSmoke\.emitParticleAt\(engineX, engineY, 1\)/); assert.match(source, /const particle = this\.playerSmoke\.emitParticleAt\(engineX, engineY, 1\)/);
assert.match(source, /particle\.velocityX = velocityX/); assert.match(source, /particle\.velocityX = velocityX/);
assert.match(source, /particle\.velocityY = velocityY/); 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\.setPosition\(sternX, sternY\)/);
assert.doesNotMatch(source, /this\.playerSmoke\.setParticleSpeed\(/); 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\.createEnvironmentPhysics\(\)/);
assert.match(source, /this\.physics\.add\.staticImage/); assert.match(source, /this\.physics\.add\.staticImage/);
assert.match(source, /obstacle\.body\.setCircle\(island\.radius\)/); 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, /const islands = this\.world\?\.islands \|\| ENVIRONMENT_ISLANDS/);
assert.match(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/); 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\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/);
assert.match(source, /this\.environmentWind = environmentWindAt\(\{/); assert.match(source, /this\.environmentWind = environmentWindAt\(\{/);
assert.match(source, /const pirateWind = 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/); 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 () => { test('rolling clouds include large dense banks that phase in and out with the wind', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');