Add merchant trade traffic and pirate raiding

This commit is contained in:
Admin 2026-09-20 19:45:33 +00:00
parent 8f2c10fdc4
commit 3f3d4500cf
4 changed files with 336 additions and 7 deletions

View File

@ -39,6 +39,11 @@ import {
salvageForPirate,
} from './progression/ship-loadout.js';
import { MODULE_CATALOG } from './progression/modules.js';
import {
createMerchantTrafficPlans,
merchantPointOnRoute,
merchantRouteEndpoint,
} from './world/trade-traffic.js';
const WORLD_SIZE = 3600;
const PLAYER_MAX_SPEED = 180;
@ -54,6 +59,9 @@ const SHOT_SPEED = 520;
const SHOT_LIFETIME = 900;
const PIRATE_FALLBACK_MAX_HULL = 120;
const MAX_PIRATES = 6;
const MAX_MERCHANTS = 18;
const PIRATE_MERCHANT_DETECTION_RANGE = 760;
const MERCHANT_PORT_ARRIVAL_RANGE = 58;
const HUD_PUBLISH_INTERVAL_MS = 50;
const PLAYER_SMOKE_REAR_OFFSET = 48;
const PLAYER_SMOKE_ENGINE_OFFSET = 17;
@ -109,6 +117,9 @@ class GameScene extends Phaser.Scene {
this.wave = 1;
this.defeated = 0;
this.salvageSequence = 0;
this.tradeDeliveries = 0;
this.tradeLosses = 0;
this.playerTradeLosses = 0;
airshipLog('scene:init', {
hull: this.playerHull,
@ -148,16 +159,20 @@ class GameScene extends Phaser.Scene {
this.createPlayerSmoke();
this.pirates = this.physics.add.group();
this.merchants = 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.createMerchantTraffic();
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.pirateShots, this.merchants, this.hitMerchant, null, this);
this.physics.add.overlap(this.playerShots, this.merchants, this.hitMerchant, null, this);
this.physics.add.overlap(this.player, this.salvageWrecks, this.collectSalvageWreck, null, this);
registerDebugSnapshot(() => ({
@ -166,6 +181,10 @@ class GameScene extends Phaser.Scene {
playerSpeed: this.playerSpeed,
pendingPlayerHits: this.pendingPlayerHits?.length || 0,
activePirates: this.pirates?.countActive?.(true) || 0,
activeMerchants: this.merchants?.countActive?.(true) || 0,
tradeDeliveries: this.tradeDeliveries,
tradeLosses: this.tradeLosses,
playerTradeLosses: this.playerTradeLosses,
playerShots: this.playerShots?.countActive?.(true) || 0,
pirateShots: this.pirateShots?.countActive?.(true) || 0,
wave: this.wave,
@ -785,6 +804,183 @@ class GameScene extends Phaser.Scene {
}
}
createMerchantTraffic() {
const plans = createMerchantTrafficPlans(this.world, { maxShips: MAX_MERCHANTS });
for (const plan of plans) {
const route = this.world.tradeRoutes.find(({ id }) => id === plan.routeId);
if (!route) continue;
this.spawnMerchant(route, plan);
}
airshipLog('trade:traffic-created', {
merchants: this.merchants.countActive(true),
routes: this.world.tradeRoutes.length,
});
}
spawnMerchant(route, plan) {
const point = merchantPointOnRoute(route, plan.progress, plan.laneOffset);
const merchant = this.merchants.create(point.x, point.y, 'airships', 0);
merchant
.setDepth(5)
.setCollideWorldBounds(true)
.setDisplaySize(62, 62)
.setTint(0xb7d6b0);
merchant.body.setAllowGravity(false);
merchant.body.setSize(44, 54, true);
const endpoint = merchantRouteEndpoint(route, plan.direction, plan.laneOffset);
merchant.angle = bearingTo(merchant, endpoint);
merchant.setData('routeId', route.id);
merchant.setData('routeDirection', plan.direction);
merchant.setData('laneOffset', plan.laneOffset);
merchant.setData('speed', plan.speed);
merchant.setData('hull', plan.maxHull);
merchant.setData('maxHull', plan.maxHull);
merchant.setData('cargoValue', plan.cargoValue);
merchant.setData('deliveries', 0);
merchant.setData('shipKind', 'merchant');
return merchant;
}
updateMerchants(dt) {
for (const merchant of this.merchants.getChildren()) {
if (!merchant.active || !merchant.body) continue;
const route = this.world.tradeRoutes
.find(({ id }) => id === merchant.getData('routeId'));
if (!route) {
merchant.destroy();
continue;
}
const direction = merchant.getData('routeDirection') || 1;
const laneOffset = merchant.getData('laneOffset') || 0;
const target = merchantRouteEndpoint(route, direction, laneOffset);
const range = Math.hypot(target.x - merchant.x, target.y - merchant.y);
if (range <= MERCHANT_PORT_ARRIVAL_RANGE) {
merchant.setData('routeDirection', -direction);
merchant.setData('deliveries', (merchant.getData('deliveries') || 0) + 1);
this.tradeDeliveries += 1;
continue;
}
const desiredHeading = bearingTo(merchant, target);
const delta = signedAngleDelta(desiredHeading, merchant.angle);
merchant.angle += clamp(delta, -34 * dt, 34 * dt);
const h = headingRad(merchant);
const wind = environmentWindAt({
x: merchant.x,
y: merchant.y,
timeMs: this.time.now,
islands: this.world?.islands,
});
const velocity = velocityWithEnvironment(
Math.cos(h) * (merchant.getData('speed') || 76),
Math.sin(h) * (merchant.getData('speed') || 76),
wind,
0.55,
);
merchant.setVelocity(velocity.x, velocity.y);
}
}
choosePirateTarget(pirate) {
const playerDistance = Math.hypot(
this.player.x - pirate.x,
this.player.y - pirate.y,
);
let merchantTarget = null;
let merchantDistance = Infinity;
let merchantScore = Infinity;
for (const merchant of this.merchants.getChildren()) {
if (!merchant.active) continue;
const range = Math.hypot(merchant.x - pirate.x, merchant.y - pirate.y);
if (range > PIRATE_MERCHANT_DETECTION_RANGE) continue;
const cargoValue = merchant.getData('cargoValue') || 50;
const score = range / (1 + Math.min(2, cargoValue / 140));
if (score < merchantScore) {
merchantTarget = merchant;
merchantDistance = range;
merchantScore = score;
}
}
const merchantPreferred = merchantTarget
&& (playerDistance > 260 || merchantDistance < playerDistance * 1.15);
return merchantPreferred
? { target: merchantTarget, kind: 'merchant', distance: merchantDistance }
: { target: this.player, kind: 'player', distance: playerDistance };
}
hitMerchant(first, second) {
if (this.gameState !== 'playing') return;
const playerShot = resolveTaggedProjectile(first, second, 'player');
const pirateShot = playerShot ? null : resolveTaggedProjectile(first, second, 'pirate');
const shot = playerShot || pirateShot;
const merchant = shot === first ? second : first;
if (!shot?.active || !merchant?.active || merchant === this.player) return;
if (merchant.getData?.('shipKind') !== 'merchant') return;
const damage = shot.getData('damage') || 10;
const projectileOwner = shot.getData('projectileOwner');
const previousHull = merchant.getData('hull') || merchant.getData('maxHull') || 70;
const hull = Math.max(0, previousHull - damage);
const impactX = shot.x;
const impactY = shot.y;
shot.destroy();
merchant.setData('hull', hull);
this.spawnExplosion(impactX, impactY, 0.28);
if (hull > 0) {
this.flashShip(merchant, 0xffe2a8);
return;
}
const cargoValue = merchant.getData('cargoValue') || 50;
const x = merchant.x;
const y = merchant.y;
merchant.destroy();
this.spawnExplosion(x, y, 1.05);
this.tradeLosses += 1;
if (projectileOwner === 'player') this.playerTradeLosses += 1;
const cargoSalvage = {
moduleId: null,
scrap: Math.max(1, Math.round(cargoValue / 70)),
credits: Math.max(15, Math.round(cargoValue * 0.45)),
condition: null,
};
this.spawnCargoWreck(x, y, cargoSalvage);
airshipLog('trade:merchant-destroyed', {
projectileOwner,
cargoValue,
tradeLosses: this.tradeLosses,
playerTradeLosses: this.playerTradeLosses,
});
}
spawnCargoWreck(x, y, salvage) {
const wreck = this.salvageWrecks.create(x, y, 'salvage-wreck');
wreck.setDepth(5).setDisplaySize(30, 30).setTint(0xe0c27f);
wreck.body.setAllowGravity(false);
wreck.body.setCircle(10);
wreck.setData('salvage', salvage);
wreck.setData('bornAt', this.time.now);
wreck.setData('wreckKind', 'cargo');
return wreck;
}
spawnWave() {
const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
const count = roster.length;
@ -944,6 +1140,7 @@ class GameScene extends Phaser.Scene {
this.updatePlayerSmoke(deltaMs);
this.updateRollingClouds(deltaMs);
this.updateCloudConcealment();
this.updateMerchants(dt);
this.updatePirates(dt);
this.updateProjectiles();
this.drawWeaponArcs();
@ -1031,15 +1228,22 @@ class GameScene extends Phaser.Scene {
for (const pirate of pirates) {
if (!pirate.active || !pirate.body) continue;
const dx = this.player.x - pirate.x;
const dy = this.player.y - pirate.y;
const targetState = this.choosePirateTarget(pirate);
const target = targetState.target;
if (!target?.active || !target?.body) continue;
const dx = target.x - pirate.x;
const dy = target.y - pirate.y;
const distance = Math.hypot(dx, dy);
const bearing = bearingTo(pirate, this.player);
const bearing = bearingTo(pirate, target);
const preferredRange = pirate.getData('preferredRange') || 335;
const retreatRange = pirate.getData('retreatRange') || 210;
const turnRate = pirate.getData('turnRate') || 52;
const baseSpeed = pirate.getData('speed') || 118;
pirate.setData('targetKind', targetState.kind);
pirate.setData('targetShip', target);
let desiredHeading = pirateDesiredHeading({
bearing,
currentHeading: pirate.angle,
@ -1087,8 +1291,10 @@ class GameScene extends Phaser.Scene {
const broadsideTolerance = pirate.getData('broadsideError') || 38;
const reloadMs = pirate.getData('reloadMs') || 1650;
const projectileDamage = pirate.getData('projectileDamage') || DEFAULT_PIRATE_SHOT_DAMAGE;
const playerConcealment = this.player.getData('cloudConcealment') || 0;
const targetObscured = playerConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT
const targetConcealment = targetState.kind === 'player'
? this.player.getData('cloudConcealment') || 0
: 0;
const targetObscured = targetConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT
&& distance > CLOUD_CLOSE_ENGAGE_RANGE;
if (!targetObscured && distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) {
@ -1414,6 +1620,12 @@ class GameScene extends Phaser.Scene {
windHeading: this.environmentWind?.heading || 0,
concealment: this.player?.getData?.('cloudConcealment') || 0,
},
trade: {
activeMerchants: this.merchants?.countActive?.(true) || 0,
deliveries: this.tradeDeliveries,
losses: this.tradeLosses,
playerLosses: this.playerTradeLosses,
},
progression: {
credits: this.progression?.credits || 0,
scrap: this.progression?.scrap || 0,
@ -1505,8 +1717,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¢${state.progression?.credits || 0}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`);
? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`);
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(target

View File

@ -0,0 +1,50 @@
import { createSeededRandom, randomBetween } from './seeded-random.js';
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export const createMerchantTrafficPlans = (
world,
{ maxShips = 18 } = {},
) => {
const routes = world?.tradeRoutes || [];
const random = createSeededRandom(`${world?.seed || 'world'}:merchant-traffic`);
const plans = [];
for (const route of routes) {
const desired = clamp(1 + Math.floor((route.traffic || 0) * 2.4), 1, 3);
for (let index = 0; index < desired && plans.length < maxShips; index += 1) {
plans.push({
id: `merchant-${plans.length + 1}`,
routeId: route.id,
progress: randomBetween(random, 0.08, 0.92),
direction: random() < 0.5 ? -1 : 1,
laneOffset: randomBetween(random, -route.width * 0.18, route.width * 0.18),
speed: randomBetween(random, 66, 86) + (route.traffic || 0) * 18,
maxHull: Math.round(54 + (route.value || 0) * 46),
cargoValue: Math.round(45 + (route.value || 0) * 155),
});
}
if (plans.length >= maxShips) break;
}
return plans;
};
export const merchantPointOnRoute = (route, progress, laneOffset = 0) => {
const t = clamp(Number(progress) || 0, 0, 1);
const dx = route.to.x - route.from.x;
const dy = route.to.y - route.from.y;
const length = Math.max(1, Math.hypot(dx, dy));
const normalX = -dy / length;
const normalY = dx / length;
return {
x: route.from.x + dx * t + normalX * laneOffset,
y: route.from.y + dy * t + normalY * laneOffset,
};
};
export const merchantRouteEndpoint = (route, direction = 1, laneOffset = 0) =>
merchantPointOnRoute(route, direction >= 0 ? 1 : 0, laneOffset);

View File

@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateWorld } from '../src/world/world-generator.js';
import {
createMerchantTrafficPlans,
merchantPointOnRoute,
merchantRouteEndpoint,
} from '../src/world/trade-traffic.js';
test('merchant traffic plans are deterministic for a world seed', () => {
const world = generateWorld({ seed: 'MERCHANTS' });
assert.deepEqual(
createMerchantTrafficPlans(world),
createMerchantTrafficPlans(world),
);
});
test('merchant traffic plans reference generated trade routes and stay bounded', () => {
const world = generateWorld({ seed: 'ROUTES' });
const plans = createMerchantTrafficPlans(world, { maxShips: 12 });
const routeIds = new Set(world.tradeRoutes.map(({ id }) => id));
assert.ok(plans.length > 0);
assert.ok(plans.length <= 12);
for (const plan of plans) {
assert.ok(routeIds.has(plan.routeId));
assert.ok(plan.progress >= 0 && plan.progress <= 1);
assert.ok(plan.maxHull > 0);
assert.ok(plan.cargoValue > 0);
}
});
test('merchant route point applies lane offset perpendicular to the route', () => {
const route = {
from: { x: 0, y: 0 },
to: { x: 100, y: 0 },
};
const point = merchantPointOnRoute(route, 0.5, 20);
assert.deepEqual(point, { x: 50, y: 20 });
});
test('merchant route endpoint follows direction', () => {
const route = {
from: { x: 10, y: 20 },
to: { x: 110, y: 120 },
};
assert.deepEqual(merchantRouteEndpoint(route, 1, 0), route.to);
assert.deepEqual(merchantRouteEndpoint(route, -1, 0), route.from);
});

View File

@ -263,6 +263,23 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
});
test('merchant traffic follows trade routes and pirates can raid it', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /createMerchantTrafficPlans\(this\.world/);
assert.match(source, /this\.merchants = this\.physics\.add\.group\(\)/);
assert.match(source, /this\.createMerchantTraffic\(\)/);
assert.match(source, /merchantPointOnRoute\(/);
assert.match(source, /merchantRouteEndpoint\(/);
assert.match(source, /this\.updateMerchants\(dt\)/);
assert.match(source, /choosePirateTarget\(pirate\)/);
assert.match(source, /PIRATE_MERCHANT_DETECTION_RANGE/);
assert.match(source, /targetState\.kind === 'player'/);
assert.match(source, /this\.physics\.add\.overlap\(this\.pirateShots, this\.merchants, this\.hitMerchant/);
assert.match(source, /trade:merchant-destroyed/);
assert.match(source, /this\.spawnCargoWreck\(/);
});
test('procedural world and ship progression are wired into live gameplay', async () => {
const source = await read('src/game-v4.js');