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_PATTERN_MS = 16000;
|
||||||
const WIND_MIN_STRENGTH = 7;
|
const WIND_MIN_STRENGTH = 7;
|
||||||
const WIND_MAX_STRENGTH = 24;
|
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 clamp01 = (value) => Math.max(0, Math.min(1, value));
|
||||||
const smootherStep = (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 = ({
|
export const environmentWindAt = ({
|
||||||
x = 0,
|
x = 0,
|
||||||
y = 0,
|
y = 0,
|
||||||
timeMs = 0,
|
timeMs = 0,
|
||||||
islands = ENVIRONMENT_ISLANDS,
|
islands = ENVIRONMENT_ISLANDS,
|
||||||
|
routes = [],
|
||||||
|
worldWidth = 5400,
|
||||||
|
worldHeight = 5400,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const time = timeMs / 1000;
|
const time = timeMs / 1000;
|
||||||
const pattern = semiRandomWindPattern(timeMs);
|
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
|
const localAngle = pattern.angle
|
||||||
+ Math.sin((x + y) / 880 + time / 31) * 0.055;
|
+ Math.sin((x + y) / 880 + time / 31) * directionalVariance
|
||||||
const localStrength = pattern.strength
|
+ Math.sin(time / 5.2 + x / 1400) * stormIntensity * 0.12;
|
||||||
+ Math.sin((x - y) / 760 + time / 4.7) * 0.9;
|
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 windX = Math.cos(localAngle) * localStrength;
|
||||||
let windY = Math.sin(localAngle) * localStrength;
|
let windY = Math.sin(localAngle) * localStrength;
|
||||||
@ -101,11 +227,23 @@ export const environmentWindAt = ({
|
|||||||
windY += normalY * updraft + tangentY * swirl;
|
windY += normalY * updraft + tangentY * swirl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const strength = Math.hypot(windX, windY);
|
||||||
|
const zone = stormIntensity >= 0.55
|
||||||
|
? 'storm'
|
||||||
|
: routeWeather.zone;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: windX,
|
x: windX,
|
||||||
y: windY,
|
y: windY,
|
||||||
strength: Math.hypot(windX, windY),
|
strength,
|
||||||
heading: ((Math.atan2(windY, windX) * 180 / Math.PI) + 450) % 360,
|
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 {
|
import {
|
||||||
ENVIRONMENT_ISLANDS,
|
ENVIRONMENT_ISLANDS,
|
||||||
environmentWindAt,
|
environmentWindAt,
|
||||||
|
routeWeatherAt,
|
||||||
semiRandomWindPattern,
|
semiRandomWindPattern,
|
||||||
|
stormFrontAt,
|
||||||
velocityWithEnvironment,
|
velocityWithEnvironment,
|
||||||
} from '../src/environment-physics.js';
|
} 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 first = environmentWindAt({ x: 1200, y: 1200, timeMs: 0 });
|
||||||
const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 24000 });
|
const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 24000 });
|
||||||
|
|
||||||
assert.ok(first.strength > 3 && first.strength < 30);
|
assert.ok(first.strength > 3 && first.strength < 55);
|
||||||
assert.ok(later.strength > 3 && later.strength < 30);
|
assert.ok(later.strength > 3 && later.strength < 55);
|
||||||
assert.notDeepEqual(
|
assert.notDeepEqual(
|
||||||
[Math.round(first.x * 100), Math.round(first.y * 100)],
|
[Math.round(first.x * 100), Math.round(first.y * 100)],
|
||||||
[Math.round(later.x * 100), Math.round(later.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.x, 106);
|
||||||
assert.equal(result.y, -16);
|
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