diff --git a/src/environment-physics.js b/src/environment-physics.js index e30618f..a19f90e 100644 --- a/src/environment-physics.js +++ b/src/environment-physics.js @@ -18,6 +18,10 @@ export const ENVIRONMENT_ISLANDS = [ const WIND_PATTERN_MS = 16000; const WIND_MIN_STRENGTH = 7; const WIND_MAX_STRENGTH = 24; +const ROUTE_MIN_CORE_RADIUS = 220; +const ROUTE_EDGE_DEPTH = 320; +const STORM_FRONT_PERIOD_MS = 110000; +const STORM_FRONT_RADIUS = 720; const clamp01 = (value) => Math.max(0, Math.min(1, value)); const smootherStep = (value) => { @@ -65,20 +69,142 @@ export const semiRandomWindPattern = (timeMs = 0) => { }; }; +const pointSegmentDistance = (point, from, to) => { + const abX = (to?.x || 0) - (from?.x || 0); + const abY = (to?.y || 0) - (from?.y || 0); + const apX = (point?.x || 0) - (from?.x || 0); + const apY = (point?.y || 0) - (from?.y || 0); + const lengthSquared = abX * abX + abY * abY; + if (lengthSquared <= 1) return Math.hypot(apX, apY); + const t = Math.max(0, Math.min(1, (apX * abX + apY * abY) / lengthSquared)); + const nearestX = (from?.x || 0) + abX * t; + const nearestY = (from?.y || 0) + abY * t; + return Math.hypot((point?.x || 0) - nearestX, (point?.y || 0) - nearestY); +}; + +export const routeWeatherAt = ({ + x = 0, + y = 0, + routes = [], +} = {}) => { + let nearestRoute = null; + let routeDistance = Infinity; + + for (const route of routes || []) { + const distance = pointSegmentDistance({ x, y }, route?.from, route?.to); + if (distance < routeDistance) { + nearestRoute = route; + routeDistance = distance; + } + } + + if (!nearestRoute) { + return { + routeId: null, + routeDistance: Infinity, + routeInfluence: 0, + routeEdge: 0, + openExposure: 1, + zone: 'open-sky', + }; + } + + const coreRadius = Math.max( + ROUTE_MIN_CORE_RADIUS, + Math.max(0, Number(nearestRoute.width) || 0) * 0.9, + ); + const outerRadius = coreRadius + ROUTE_EDGE_DEPTH; + const calmStart = coreRadius * 0.65; + const routeInfluence = 1 - smootherStep( + (routeDistance - calmStart) / Math.max(1, outerRadius - calmStart), + ); + const edgeCenter = coreRadius + ROUTE_EDGE_DEPTH * 0.5; + const edgeHalfWidth = ROUTE_EDGE_DEPTH * 0.62; + const routeEdge = smootherStep( + 1 - Math.abs(routeDistance - edgeCenter) / edgeHalfWidth, + ); + const openExposure = 1 - routeInfluence; + + return { + routeId: nearestRoute.id || null, + routeDistance, + routeInfluence: clamp01(routeInfluence), + routeEdge: clamp01(routeEdge), + openExposure: clamp01(openExposure), + zone: routeInfluence >= 0.72 + ? 'shipping-lane' + : routeInfluence >= 0.08 + ? 'route-edge' + : 'open-sky', + }; +}; + +export const stormFrontAt = ({ + x = 0, + y = 0, + timeMs = 0, + worldWidth = 5400, + worldHeight = 5400, +} = {}) => { + const period = STORM_FRONT_PERIOD_MS; + const raw = ((Number(timeMs) || 0) % period + period) % period; + const phase = raw / period; + + const frontIntensity = (offset, yBase, yWave, scale = 1) => { + const t = (phase + offset) % 1; + const centerX = -900 + t * (worldWidth + 1800); + const centerY = worldHeight * ( + yBase + Math.sin(t * Math.PI * 2 + offset * 4.7) * yWave + ); + const distance = Math.hypot( + (x - centerX) / 1.55, + y - centerY, + ); + return clamp01(1 - distance / STORM_FRONT_RADIUS) * scale; + }; + + return Math.max( + frontIntensity(0, 0.35, 0.18, 0.95), + frontIntensity(0.53, 0.72, 0.13, 0.74), + ); +}; + export const environmentWindAt = ({ x = 0, y = 0, timeMs = 0, islands = ENVIRONMENT_ISLANDS, + routes = [], + worldWidth = 5400, + worldHeight = 5400, } = {}) => { const time = timeMs / 1000; const pattern = semiRandomWindPattern(timeMs); + const routeWeather = routeWeatherAt({ x, y, routes }); + const stormIntensity = stormFrontAt({ + x, + y, + timeMs, + worldWidth, + worldHeight, + }); - // Small spatial variation prevents the whole map feeling like one rigid conveyor belt. + // Shipping lanes damp the broad wind pattern. Their edges are more unstable, + // while open sky and moving storm fronts amplify both strength and direction. + const directionalVariance = 0.022 + + routeWeather.openExposure * 0.09 + + routeWeather.routeEdge * 0.055 + + stormIntensity * 0.2; const localAngle = pattern.angle - + Math.sin((x + y) / 880 + time / 31) * 0.055; - const localStrength = pattern.strength - + Math.sin((x - y) / 760 + time / 4.7) * 0.9; + + Math.sin((x + y) / 880 + time / 31) * directionalVariance + + Math.sin(time / 5.2 + x / 1400) * stormIntensity * 0.12; + const strengthScale = 0.72 + + routeWeather.openExposure * 0.5 + + routeWeather.routeEdge * 0.16 + + stormIntensity * 0.68; + const localStrength = pattern.strength * strengthScale + + Math.sin((x - y) / 760 + time / 4.7) + * (0.45 + routeWeather.openExposure * 0.9 + stormIntensity * 1.2); let windX = Math.cos(localAngle) * localStrength; let windY = Math.sin(localAngle) * localStrength; @@ -101,11 +227,23 @@ export const environmentWindAt = ({ windY += normalY * updraft + tangentY * swirl; } + const strength = Math.hypot(windX, windY); + const zone = stormIntensity >= 0.55 + ? 'storm' + : routeWeather.zone; + return { x: windX, y: windY, - strength: Math.hypot(windX, windY), + strength, heading: ((Math.atan2(windY, windX) * 180 / Math.PI) + 450) % 360, + routeId: routeWeather.routeId, + routeDistance: routeWeather.routeDistance, + routeInfluence: routeWeather.routeInfluence, + routeEdge: routeWeather.routeEdge, + openExposure: routeWeather.openExposure, + stormIntensity, + zone, }; }; diff --git a/tests/environment-physics.test.mjs b/tests/environment-physics.test.mjs index 73c63ae..88f64e0 100644 --- a/tests/environment-physics.test.mjs +++ b/tests/environment-physics.test.mjs @@ -4,7 +4,9 @@ import assert from 'node:assert/strict'; import { ENVIRONMENT_ISLANDS, environmentWindAt, + routeWeatherAt, semiRandomWindPattern, + stormFrontAt, velocityWithEnvironment, } from '../src/environment-physics.js'; @@ -47,8 +49,8 @@ test('ambient wind changes over time and remains bounded', () => { const first = environmentWindAt({ x: 1200, y: 1200, timeMs: 0 }); const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 24000 }); - assert.ok(first.strength > 3 && first.strength < 30); - assert.ok(later.strength > 3 && later.strength < 30); + assert.ok(first.strength > 3 && first.strength < 55); + assert.ok(later.strength > 3 && later.strength < 55); assert.notDeepEqual( [Math.round(first.x * 100), Math.round(first.y * 100)], [Math.round(later.x * 100), Math.round(later.y * 100)], @@ -80,3 +82,54 @@ test('environment velocity adds wind according to exposure', () => { assert.equal(result.x, 106); assert.equal(result.y, -16); }); + + +test('shipping lanes create calm cores with unstable edges and open sky beyond', () => { + const routes = [{ + id: 'shipping-1', + from: { x: 1000, y: 1200 }, + to: { x: 3600, y: 1200 }, + width: 220, + }]; + const lane = routeWeatherAt({ x: 2200, y: 1200, routes }); + const edge = routeWeatherAt({ x: 2200, y: 1500, routes }); + const open = routeWeatherAt({ x: 2200, y: 2200, routes }); + + assert.equal(lane.zone, 'shipping-lane'); + assert.ok(lane.routeInfluence > 0.9); + assert.equal(edge.zone, 'route-edge'); + assert.ok(edge.routeEdge > lane.routeEdge); + assert.equal(open.zone, 'open-sky'); + assert.ok(open.routeInfluence < 0.05); +}); + +test('moving storm fronts can cross the world independent of route corridors', () => { + const timeMs = 110000 * 0.25; + const intensity = stormFrontAt({ + x: 900, + y: 5400 * 0.53, + timeMs, + worldWidth: 5400, + worldHeight: 5400, + }); + assert.ok(intensity > 0.75); +}); + +test('route-aware wind is calmer in a shipping lane than nearby open sky', () => { + const routes = [{ + id: 'shipping-1', + from: { x: 1000, y: 1000 }, + to: { x: 3600, y: 1000 }, + width: 220, + }]; + const lane = environmentWindAt({ + x: 2200, y: 1000, timeMs: 0, routes, islands: [], + }); + const open = environmentWindAt({ + x: 2200, y: 2200, timeMs: 0, routes, islands: [], + }); + + assert.equal(lane.zone, 'shipping-lane'); + assert.equal(open.zone, 'open-sky'); + assert.ok(lane.strength < open.strength); +});