Sync verified procedural gameplay build
This commit is contained in:
parent
580994daee
commit
c726f9333e
@ -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);
|
||||
|
||||
356
src/game-v4.js
356
src/game-v4.js
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user