diff --git a/src/environment-physics.js b/src/environment-physics.js index ac4e59c..9c76321 100644 --- a/src/environment-physics.js +++ b/src/environment-physics.js @@ -15,21 +15,72 @@ export const ENVIRONMENT_ISLANDS = [ }, ]; +const WIND_PATTERN_MS = 16000; +const WIND_MIN_STRENGTH = 7; +const WIND_MAX_STRENGTH = 24; + +const clamp01 = (value) => Math.max(0, Math.min(1, value)); +const smootherStep = (value) => { + const t = clamp01(value); + return t * t * t * (t * (t * 6 - 15) + 10); +}; +const hash01 = (index, salt = 0) => { + const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453123; + return value - Math.floor(value); +}; +const shortestAngleDelta = (from, to) => { + let delta = (to - from) % (Math.PI * 2); + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + return delta; +}; + +const windTargetForSlot = (slot) => ({ + angle: hash01(slot, 1) * Math.PI * 2, + strength: WIND_MIN_STRENGTH + + hash01(slot, 2) * (WIND_MAX_STRENGTH - WIND_MIN_STRENGTH), +}); + +export const semiRandomWindPattern = (timeMs = 0) => { + const safeTime = Math.max(0, Number(timeMs) || 0); + const slot = Math.floor(safeTime / WIND_PATTERN_MS); + const phase = (safeTime % WIND_PATTERN_MS) / WIND_PATTERN_MS; + const current = windTargetForSlot(slot); + const next = windTargetForSlot(slot + 1); + + // Hold each weather state briefly, then ease toward the next random target. + const blend = smootherStep((phase - 0.18) / 0.82); + const angle = current.angle + + shortestAngleDelta(current.angle, next.angle) * blend; + const strength = current.strength + + (next.strength - current.strength) * blend; + + // A smaller deterministic gust keeps the air alive without frame-to-frame jitter. + const gust = Math.sin(safeTime / 2300 + slot * 0.73) + * (1.1 + hash01(slot, 3) * 1.8); + + return { + angle, + strength: Math.max(4, strength + gust), + }; +}; + export const environmentWindAt = ({ x = 0, y = 0, timeMs = 0, } = {}) => { const time = timeMs / 1000; - const angle = 0.62 - + Math.sin(time / 29) * 0.2 - + Math.sin((x + y) / 880) * 0.06; - const strength = 14 - + Math.sin(time / 8.5) * 3.2 - + Math.sin(time / 2.9) * 1.4; + const pattern = semiRandomWindPattern(timeMs); - let windX = Math.cos(angle) * strength; - let windY = Math.sin(angle) * strength; + // Small spatial variation prevents the whole map feeling like one rigid conveyor belt. + 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; + + let windX = Math.cos(localAngle) * localStrength; + let windY = Math.sin(localAngle) * localStrength; for (const island of ENVIRONMENT_ISLANDS) { const dx = x - island.x; diff --git a/tests/environment-physics.test.mjs b/tests/environment-physics.test.mjs index a375f45..73c63ae 100644 --- a/tests/environment-physics.test.mjs +++ b/tests/environment-physics.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { ENVIRONMENT_ISLANDS, environmentWindAt, + semiRandomWindPattern, velocityWithEnvironment, } from '../src/environment-physics.js'; @@ -15,12 +16,39 @@ test('environment exposes collidable island geometry', () => { } }); +test('semi-random wind varies strength and direction across weather windows', () => { + const samples = [0, 16000, 32000, 48000, 64000] + .map((timeMs) => semiRandomWindPattern(timeMs)); + + const roundedStrengths = new Set(samples.map(({ strength }) => Math.round(strength * 10))); + const roundedHeadings = new Set(samples.map(({ angle }) => Math.round(angle * 10))); + + assert.ok(roundedStrengths.size >= 3); + assert.ok(roundedHeadings.size >= 3); + for (const sample of samples) { + assert.ok(sample.strength >= 4 && sample.strength < 28); + } +}); + +test('semi-random wind changes smoothly instead of jumping each frame', () => { + const before = semiRandomWindPattern(15950); + const after = semiRandomWindPattern(16050); + const vector = ({ angle, strength }) => ({ + x: Math.cos(angle) * strength, + y: Math.sin(angle) * strength, + }); + const a = vector(before); + const b = vector(after); + + assert.ok(Math.hypot(b.x - a.x, b.y - a.y) < 2.5); +}); + 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: 18000 }); + const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 24000 }); - assert.ok(first.strength > 5 && first.strength < 30); - assert.ok(later.strength > 5 && later.strength < 30); + assert.ok(first.strength > 3 && first.strength < 30); + assert.ok(later.strength > 3 && later.strength < 30); assert.notDeepEqual( [Math.round(first.x * 100), Math.round(first.y * 100)], [Math.round(later.x * 100), Math.round(later.y * 100)],