Add route-aware wind and moving storm fronts
This commit is contained in:
parent
e5571b9dee
commit
78eec83c91
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user