diff --git a/src/game-v4.js b/src/game-v4.js index 04f9674..72aae33 100644 --- a/src/game-v4.js +++ b/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 diff --git a/src/world/trade-traffic.js b/src/world/trade-traffic.js new file mode 100644 index 0000000..1679c54 --- /dev/null +++ b/src/world/trade-traffic.js @@ -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); diff --git a/tests/trade-traffic.test.mjs b/tests/trade-traffic.test.mjs new file mode 100644 index 0000000..0bd6a10 --- /dev/null +++ b/tests/trade-traffic.test.mjs @@ -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); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index a4a1456..d64999d 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -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');