Add merchant trade traffic and pirate raiding
This commit is contained in:
parent
8f2c10fdc4
commit
3f3d4500cf
226
src/game-v4.js
226
src/game-v4.js
@ -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
|
||||
|
||||
50
src/world/trade-traffic.js
Normal file
50
src/world/trade-traffic.js
Normal 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);
|
||||
50
tests/trade-traffic.test.mjs
Normal file
50
tests/trade-traffic.test.mjs
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user