From 24a49f810356413a325f95c80181bf0496a92f36 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 06:40:11 +0000 Subject: [PATCH] Sync shipping-route weather system --- server/main.go | 216 +++++++++++++++++++++++++++-- server/main_test.go | 48 +++++++ src/environment-physics.js | 148 +++++++++++++++++++- src/game-v4.js | 104 +++++++------- src/multiplayer.js | 26 ++++ tests/environment-physics.test.mjs | 57 +++++++- tests/wiring.test.mjs | 41 +++++- 7 files changed, 565 insertions(+), 75 deletions(-) diff --git a/server/main.go b/server/main.go index 7e7ba26..396270d 100644 --- a/server/main.go +++ b/server/main.go @@ -40,6 +40,13 @@ const ( respawnDelay = 2200 * time.Millisecond simTick = time.Second / 30 snapshotTick = 50 * time.Millisecond + windPatternMS = 16000.0 + windMinStrength = 7.0 + windMaxStrength = 24.0 + routeMinCoreRadius = 220.0 + routeEdgeDepth = 320.0 + stormFrontPeriodMS = 110000.0 + stormFrontRadius = 720.0 ) type config struct { @@ -149,14 +156,33 @@ type projectileState struct { bornAt time.Time } +type weatherRoute struct { + ID string `json:"id"` + FromX float64 `json:"fromX"` + FromY float64 `json:"fromY"` + ToX float64 `json:"toX"` + ToY float64 `json:"toY"` + Width float64 `json:"width"` +} + +type weatherWind struct { + X float64 + Y float64 + Strength float64 + RouteInfluence float64 + RouteEdge float64 + StormIntensity float64 +} + type clientMessage struct { - Type string `json:"type"` - Seq int64 `json:"seq"` - Throttle float64 `json:"throttle"` - Steer float64 `json:"steer"` - Stop bool `json:"stop"` - Docked bool `json:"docked"` - Side string `json:"side"` + Type string `json:"type"` + Seq int64 `json:"seq"` + Throttle float64 `json:"throttle"` + Steer float64 `json:"steer"` + Stop bool `json:"stop"` + Docked bool `json:"docked"` + Side string `json:"side"` + Routes []weatherRoute `json:"routes,omitempty"` } type serverMessage struct { @@ -193,6 +219,7 @@ type hub struct { projectiles map[string]projectileState nextProjectile uint64 worldSeed string + weatherRoutes []weatherRoute } func newHub(seed string, fakePlayers ...int) *hub { @@ -281,6 +308,152 @@ func (h *hub) count() int { return len(h.clients) } +func (h *hub) setWeatherRoutes(routes []weatherRoute) { + h.mu.Lock() + defer h.mu.Unlock() + if len(h.weatherRoutes) > 0 || len(routes) == 0 { + return + } + count := len(routes) + if count > 24 { + count = 24 + } + h.weatherRoutes = make([]weatherRoute, 0, count) + for _, route := range routes[:count] { + h.weatherRoutes = append(h.weatherRoutes, weatherRoute{ + ID: strings.TrimSpace(route.ID), + FromX: clampFloat(route.FromX, 0, worldSize), + FromY: clampFloat(route.FromY, 0, worldSize), + ToX: clampFloat(route.ToX, 0, worldSize), + ToY: clampFloat(route.ToY, 0, worldSize), + Width: clampFloat(route.Width, 120, 320), + }) + } +} + +func smootherStep01(value float64) float64 { + t := clampFloat(value, 0, 1) + return t * t * t * (t*(t*6-15) + 10) +} + +func hash01(index float64, salt float64) float64 { + value := math.Sin(index*12.9898+salt*78.233) * 43758.5453123 + return value - math.Floor(value) +} + +func shortestAngleDeltaRad(from, to float64) float64 { + delta := math.Mod(to-from, math.Pi*2) + if delta > math.Pi { + delta -= math.Pi * 2 + } + if delta < -math.Pi { + delta += math.Pi * 2 + } + return delta +} + +func windTargetForSlot(slot float64) (float64, float64) { + angle := hash01(slot, 1) * math.Pi * 2 + strength := windMinStrength + hash01(slot, 2)*(windMaxStrength-windMinStrength) + return angle, strength +} + +func semiRandomWindPatternAt(timeMs float64) (float64, float64) { + if timeMs < 0 { + timeMs = 0 + } + slot := math.Floor(timeMs / windPatternMS) + phase := math.Mod(timeMs, windPatternMS) / windPatternMS + currentAngle, currentStrength := windTargetForSlot(slot) + nextAngle, nextStrength := windTargetForSlot(slot + 1) + blend := smootherStep01((phase - 0.18) / 0.82) + angle := currentAngle + shortestAngleDeltaRad(currentAngle, nextAngle)*blend + strength := currentStrength + (nextStrength-currentStrength)*blend + gust := math.Sin(timeMs/2300+slot*0.73) * (1.1 + hash01(slot, 3)*1.8) + return angle, math.Max(4, strength+gust) +} + +func pointSegmentDistanceXY(x, y float64, route weatherRoute) float64 { + abX := route.ToX - route.FromX + abY := route.ToY - route.FromY + apX := x - route.FromX + apY := y - route.FromY + lengthSquared := abX*abX + abY*abY + if lengthSquared <= 1 { + return math.Hypot(apX, apY) + } + t := clampFloat((apX*abX+apY*abY)/lengthSquared, 0, 1) + nearestX := route.FromX + abX*t + nearestY := route.FromY + abY*t + return math.Hypot(x-nearestX, y-nearestY) +} + +func (h *hub) routeWeatherAtLocked(x, y float64) (float64, float64) { + if len(h.weatherRoutes) == 0 { + return 0, 0 + } + distance := math.Inf(1) + width := 220.0 + for _, route := range h.weatherRoutes { + candidate := pointSegmentDistanceXY(x, y, route) + if candidate < distance { + distance = candidate + width = route.Width + } + } + core := math.Max(routeMinCoreRadius, width*0.9) + outer := core + routeEdgeDepth + calmStart := core * 0.65 + influence := 1 - smootherStep01((distance-calmStart)/math.Max(1, outer-calmStart)) + edgeCenter := core + routeEdgeDepth*0.5 + edgeHalf := routeEdgeDepth * 0.62 + edge := smootherStep01(1 - math.Abs(distance-edgeCenter)/edgeHalf) + return clampFloat(influence, 0, 1), clampFloat(edge, 0, 1) +} + +func stormFrontIntensityAt(x, y, timeMs float64) float64 { + raw := math.Mod(timeMs, stormFrontPeriodMS) + if raw < 0 { + raw += stormFrontPeriodMS + } + phase := raw / stormFrontPeriodMS + front := func(offset, yBase, yWave, scale float64) float64 { + t := math.Mod(phase+offset, 1) + centerX := -900 + t*(worldSize+1800) + centerY := worldSize * (yBase + math.Sin(t*math.Pi*2+offset*4.7)*yWave) + distance := math.Hypot((x-centerX)/1.55, y-centerY) + return clampFloat(1-distance/stormFrontRadius, 0, 1) * scale + } + return math.Max( + front(0, 0.35, 0.18, 0.95), + front(0.53, 0.72, 0.13, 0.74), + ) +} + +func (h *hub) weatherWindAtLocked(x, y float64, now time.Time) weatherWind { + timeMs := float64(now.UnixMilli()) + timeSeconds := timeMs / 1000 + angle, strength := semiRandomWindPatternAt(timeMs) + routeInfluence, routeEdge := h.routeWeatherAtLocked(x, y) + openExposure := 1 - routeInfluence + storm := stormFrontIntensityAt(x, y, timeMs) + directionalVariance := 0.022 + openExposure*0.09 + routeEdge*0.055 + storm*0.2 + localAngle := angle + + math.Sin((x+y)/880+timeSeconds/31)*directionalVariance + + math.Sin(timeSeconds/5.2+x/1400)*storm*0.12 + strengthScale := 0.72 + openExposure*0.5 + routeEdge*0.16 + storm*0.68 + localStrength := strength*strengthScale + + math.Sin((x-y)/760+timeSeconds/4.7)*(0.45+openExposure*0.9+storm*1.2) + return weatherWind{ + X: math.Cos(localAngle) * localStrength, + Y: math.Sin(localAngle) * localStrength, + Strength: math.Abs(localStrength), + RouteInfluence: routeInfluence, + RouteEdge: routeEdge, + StormIntensity: storm, + } +} + func (h *hub) updateInput(id string, msg clientMessage) { h.mu.Lock() defer h.mu.Unlock() @@ -322,6 +495,7 @@ func (h *hub) fireLocked(c *client, side string, now time.Time) bool { base := headingRadians(c.state.Angle) + sideSign*math.Pi/2 shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed shipVY := math.Sin(headingRadians(c.state.Angle)) * c.state.Speed + shotWind := h.weatherWindAtLocked(c.state.X, c.state.Y, now) for _, offset := range []float64{-7, 0, 7} { angle := base + offset*math.Pi/180 @@ -331,8 +505,8 @@ func (h *hub) fireLocked(c *client, side string, now time.Time) bool { ID: projectileID, OwnerID: c.state.ID, X: c.state.X + math.Cos(base)*28, Y: c.state.Y + math.Sin(base)*28, - VX: math.Cos(angle)*shotSpeed + shipVX*0.25, - VY: math.Sin(angle)*shotSpeed + shipVY*0.25, + VX: math.Cos(angle)*shotSpeed + shipVX*0.25 + shotWind.X*0.22, + VY: math.Sin(angle)*shotSpeed + shipVY*0.25 + shotWind.Y*0.22, Damage: shotDamage, bornAt: now, } @@ -345,16 +519,17 @@ func (h *hub) step(now time.Time, dt float64) { defer h.mu.Unlock() for _, c := range h.clients { + wind := h.weatherWindAtLocked(c.state.X, c.state.Y, now) if c.state.Bot { input, fireSide := h.fakeCaptainDecisionLocked(c, now) c.input = input - stepPlayer(&c.state, c.input, now, dt) + stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y) if fireSide != "" { h.fireLocked(c, fireSide, now) } continue } - stepPlayer(&c.state, c.input, now, dt) + stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y) } for id, shot := range h.projectiles { @@ -482,6 +657,17 @@ func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, s } func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) { + stepPlayerWithWind(state, input, now, dt, 0, 0) +} + +func stepPlayerWithWind( + state *playerState, + input inputState, + now time.Time, + dt float64, + windX float64, + windY float64, +) { state.InputSeq = input.Seq if !state.Alive { if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt { @@ -522,8 +708,8 @@ func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) state.Angle = normalizeAngle(state.Angle + playerTurnRate*turnStrength*directionSign*input.Steer*dt) heading := headingRadians(state.Angle) - state.X = clampFloat(state.X+math.Cos(heading)*state.Speed*dt, 0, worldSize) - state.Y = clampFloat(state.Y+math.Sin(heading)*state.Speed*dt, 0, worldSize) + state.X = clampFloat(state.X+(math.Cos(heading)*state.Speed+windX)*dt, 0, worldSize) + state.Y = clampFloat(state.Y+(math.Sin(heading)*state.Speed+windY)*dt, 0, worldSize) } func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 } @@ -854,7 +1040,7 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli(), }) - conn.SetReadLimit(4096) + conn.SetReadLimit(8192) for { _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) var msg clientMessage @@ -866,6 +1052,8 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { a.hub.updateInput(t.Subject, msg) case "fire": a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now()) + case "weather-layout": + a.hub.setWeatherRoutes(msg.Routes) } } } diff --git a/server/main_test.go b/server/main_test.go index 9432b57..998eb5f 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -226,3 +226,51 @@ func TestFakeCaptainDoesNotAggroHumanAcrossWholeWorld(t *testing.T) { t.Fatalf("bot should patrol when human is far: %+v", input) } } + + +func TestShippingRouteWeatherCalmsAuthoritativeWind(t *testing.T) { + h := newHub("TEST") + h.setWeatherRoutes([]weatherRoute{{ + ID: "route-1", FromX: 1000, FromY: 1000, ToX: 3600, ToY: 1000, Width: 220, + }}) + now := time.UnixMilli(0) + lane := h.weatherWindAtLocked(2200, 1000, now) + open := h.weatherWindAtLocked(2200, 2200, now) + if lane.RouteInfluence < 0.9 { + t.Fatalf("lane influence=%v", lane.RouteInfluence) + } + if open.RouteInfluence > 0.05 { + t.Fatalf("open influence=%v", open.RouteInfluence) + } + if lane.Strength >= open.Strength { + t.Fatalf("lane wind=%v open wind=%v", lane.Strength, open.Strength) + } +} + +func TestWeatherLayoutIsSanitizedAndOnlyAcceptedOnce(t *testing.T) { + h := newHub("TEST") + h.setWeatherRoutes([]weatherRoute{{ + ID: "route", FromX: -20, FromY: 100, ToX: 9000, ToY: 200, Width: 900, + }}) + if len(h.weatherRoutes) != 1 { + t.Fatalf("routes=%d", len(h.weatherRoutes)) + } + route := h.weatherRoutes[0] + if route.FromX != 0 || route.ToX != worldSize || route.Width != 320 { + t.Fatalf("unsanitized route=%+v", route) + } + h.setWeatherRoutes([]weatherRoute{{ID: "replacement", FromX: 1, FromY: 1, ToX: 2, ToY: 2, Width: 120}}) + if h.weatherRoutes[0].ID != "route" { + t.Fatal("weather layout should be immutable after first accepted layout") + } +} + +func TestAuthoritativeMovementIncludesWeatherDrift(t *testing.T) { + state := newPlayerState("weather-player", "Weather", 0, 0) + state.X, state.Y = 2000, 2000 + startX, startY := state.X, state.Y + stepPlayerWithWind(&state, inputState{Seq: 1}, time.Unix(100, 0), 1, 12, -7) + if state.X == startX || state.Y == startY { + t.Fatalf("weather drift not applied: x=%v y=%v", state.X, state.Y) + } +} 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/src/game-v4.js b/src/game-v4.js index a90ca90..549bc60 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -159,6 +159,7 @@ class GameScene extends Phaser.Scene { this.multiplayerHasAuthoritativeState = false; this.multiplayerDead = false; this.multiplayerLastHull = null; + this.multiplayerServerClockOffsetMs = 0; this.multiplayerSendAccumulatorMs = 0; airshipLog('scene:init', { @@ -226,7 +227,12 @@ class GameScene extends Phaser.Scene { this.createPlayerSmoke(); this.handleMultiplayerSnapshot = (event) => { - this.syncMultiplayerSnapshot(event.detail || {}); + const detail = event.detail || {}; + const serverTime = Number(detail.serverTime) || 0; + if (serverTime > 0) { + this.multiplayerServerClockOffsetMs = serverTime - Date.now(); + } + this.syncMultiplayerSnapshot(detail); }; this.handleMultiplayerState = (event) => { if (event.detail?.state === 'online') return; @@ -242,6 +248,7 @@ class GameScene extends Phaser.Scene { }; window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState); + window.airshipMultiplayer?.setWeatherRoutes?.(this.world.tradeRoutes); this.pirates = this.physics.add.group(); this.merchants = this.physics.add.group(); @@ -362,6 +369,25 @@ class GameScene extends Phaser.Scene { + weatherTimeMs() { + if (window.airshipMultiplayer?.isConnected?.()) { + return Date.now() + (this.multiplayerServerClockOffsetMs || 0); + } + return this.time?.now ?? 0; + } + + weatherWindAt(x, y, timeMs = this.weatherTimeMs()) { + return environmentWindAt({ + x, + y, + timeMs, + islands: this.world?.islands, + routes: this.world?.tradeRoutes, + worldWidth: this.world?.width || WORLD_SIZE, + worldHeight: this.world?.height || WORLD_SIZE, + }); + } + createRollingClouds() { if (!this.textures.exists('rolling-cloud')) { const cloudTexture = this.make.graphics({ x: 0, y: 0, add: false }); @@ -434,15 +460,10 @@ class GameScene extends Phaser.Scene { if (!this.rollingClouds?.length) return; const dt = Math.min(deltaMs / 1000, 0.05); - const now = this.time?.now ?? 0; + const now = this.weatherTimeMs(); for (const cloud of this.rollingClouds) { - const wind = environmentWindAt({ - x: cloud.x, - y: cloud.y, - timeMs: now, - islands: this.world?.islands, - }); + const wind = this.weatherWindAt(cloud.x, cloud.y, now); const driftScale = cloud.getData('driftScale') || 1; cloud.x += wind.x * driftScale * dt; cloud.y += wind.y * driftScale * dt; @@ -459,8 +480,17 @@ class GameScene extends Phaser.Scene { const weatherPhase = cloud.getData('weatherPhase') || 0; const cyclePosition = (now % weatherCycleMs) / weatherCycleMs; const weatherWave = 0.5 + 0.5 * Math.sin(cyclePosition * Math.PI * 2 + weatherPhase); - const windPresence = clamp((wind.strength - 5) / 18, 0, 1); - const targetPresence = clamp(weatherWave * 0.72 + windPresence * 0.38 - 0.12, 0, 1); + const windPresence = clamp((wind.strength - 5) / 24, 0, 1); + const routeCloudiness = 0.32 + + (1 - (wind.routeInfluence || 0)) * 0.68 + + (wind.routeEdge || 0) * 0.3 + + (wind.stormIntensity || 0) * 0.85; + const targetPresence = clamp( + (weatherWave * 0.62 + windPresence * 0.32 - 0.1) * routeCloudiness + + (wind.stormIntensity || 0) * 0.28, + 0, + 1, + ); const previousPresence = cloud.getData('presence') ?? targetPresence; presence = Phaser.Math.Linear(previousPresence, targetPresence, Math.min(1, dt * 0.7)); cloud.setData('presence', presence); @@ -975,12 +1005,7 @@ class GameScene extends Phaser.Scene { const spread = Phaser.Math.FloatBetween(-0.16, 0.16); const trailHeading = heading + Math.PI + spread; const trailSpeed = Phaser.Math.Linear(16, 46, effectiveForwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16); - const localWind = environmentWindAt({ - x: sternX, - y: sternY, - timeMs: this.time.now, - islands: this.world?.islands, - }); + const localWind = this.weatherWindAt(sternX, sternY); const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4; const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 1.4; @@ -1190,12 +1215,7 @@ class GameScene extends Phaser.Scene { 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 wind = this.weatherWindAt(merchant.x, merchant.y); const velocity = velocityWithEnvironment( Math.cos(h) * (merchant.getData('speed') || 76), Math.sin(h) * (merchant.getData('speed') || 76), @@ -1780,14 +1800,15 @@ class GameScene extends Phaser.Scene { this.player.anims.timeScale = 1; } - this.environmentWind = environmentWindAt({ - x: this.player.x, - y: this.player.y, - timeMs: this.time.now, - islands: this.world?.islands, - }); + this.environmentWind = this.weatherWindAt(this.player.x, this.player.y); const h = headingRad(this.player); - this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed); + const predictedVelocity = velocityWithEnvironment( + Math.cos(h) * this.playerSpeed, + Math.sin(h) * this.playerSpeed, + this.environmentWind, + 1, + ); + this.player.setVelocity(predictedVelocity.x, predictedVelocity.y); if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); @@ -1836,12 +1857,7 @@ class GameScene extends Phaser.Scene { } const h = headingRad(this.player); - this.environmentWind = environmentWindAt({ - x: this.player.x, - y: this.player.y, - timeMs: this.time.now, - islands: this.world?.islands, - }); + this.environmentWind = this.weatherWindAt(this.player.x, this.player.y); const playerVelocity = velocityWithEnvironment( Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed, @@ -1902,12 +1918,7 @@ class GameScene extends Phaser.Scene { baseSpeed, }); const h = headingRad(pirate); - const pirateWind = environmentWindAt({ - x: pirate.x, - y: pirate.y, - timeMs: this.time.now, - islands: this.world?.islands, - }); + const pirateWind = this.weatherWindAt(pirate.x, pirate.y); const pirateVelocity = velocityWithEnvironment( Math.cos(h) * speed, Math.sin(h) * speed, @@ -1984,12 +1995,7 @@ class GameScene extends Phaser.Scene { shot.setData('projectileOwner', actor); shot.setData('sourceShip', ship); if (actor === 'pirate') shot.setData('playerHitQueued', false); - const shotWind = environmentWindAt({ - x, - y, - timeMs: this.time.now, - islands: this.world?.islands, - }); + const shotWind = this.weatherWindAt(x, y); const shotVelocity = velocityWithEnvironment( Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 0.25, @@ -2334,6 +2340,10 @@ class GameScene extends Phaser.Scene { windSpeed: this.environmentWind?.strength || 0, windHeading: this.environmentWind?.heading || 0, concealment: this.player?.getData?.('cloudConcealment') || 0, + weatherZone: this.environmentWind?.zone || 'open-sky', + routeInfluence: this.environmentWind?.routeInfluence || 0, + routeEdge: this.environmentWind?.routeEdge || 0, + stormIntensity: this.environmentWind?.stormIntensity || 0, }, trade: { activeMerchants: this.merchants?.countActive?.(true) || 0, diff --git a/src/multiplayer.js b/src/multiplayer.js index e885bd6..a23f016 100644 --- a/src/multiplayer.js +++ b/src/multiplayer.js @@ -22,6 +22,7 @@ let inputSequence = 0; let profile = null; let selfSnapshot = null; let worldSeed = 'SKYWARD-001'; +let weatherRoutes = []; let multiplayerEnabled = localStorage.getItem(MULTIPLAYER_ENABLED_KEY) !== 'false'; const statusButton = document.createElement('button'); @@ -148,6 +149,7 @@ const connect = async () => { inputSequence = 0; setStatus('online'); emitConnection('online'); + sendWeatherLayout(); }); ws.addEventListener('message', (event) => { @@ -217,6 +219,29 @@ const sendInput = (input = {}) => { return inputSequence; }; +const sendWeatherLayout = () => { + if (socket?.readyState !== WebSocket.OPEN || weatherRoutes.length === 0) return false; + socket.send(JSON.stringify({ + type: 'weather-layout', + routes: weatherRoutes, + })); + return true; +}; + +const setWeatherRoutes = (routes = []) => { + weatherRoutes = (routes || []) + .slice(0, 24) + .map((route) => ({ + id: String(route?.id || ''), + fromX: Number(route?.from?.x) || 0, + fromY: Number(route?.from?.y) || 0, + toX: Number(route?.to?.x) || 0, + toY: Number(route?.to?.y) || 0, + width: Number(route?.width) || 220, + })); + sendWeatherLayout(); +}; + const fire = (side) => { if (socket?.readyState !== WebSocket.OPEN) return false; const normalized = side === 'port' || side === -1 ? 'port' : 'starboard'; @@ -270,6 +295,7 @@ window.airshipMultiplayer = { logout, connect, setEnabled, + setWeatherRoutes, getState, getSelfId: () => selfId, isAuthenticated: () => authenticated, 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); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index aa11a40..37b79c6 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -322,10 +322,10 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /this\.physics\.add\.overlap\(this\.player, obstacle, \(\) => \{[\s\S]*?this\.playerSpeed \*= 0\.985;/); assert.doesNotMatch(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/); assert.match(source, /this\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/); - assert.match(source, /this\.environmentWind = environmentWindAt\(\{/); - assert.match(source, /const pirateWind = environmentWindAt\(\{/); - assert.match(source, /const shotWind = environmentWindAt\(\{/); - assert.match(source, /const localWind = environmentWindAt\(\{/); + assert.match(source, /this\.environmentWind = this\.weatherWindAt\(this\.player\.x, this\.player\.y\)/); + assert.match(source, /const pirateWind = this\.weatherWindAt\(pirate\.x, pirate\.y\)/); + assert.match(source, /const shotWind = this\.weatherWindAt\(x, y\)/); + assert.match(source, /const localWind = this\.weatherWindAt\(sternX, sternY\)/); assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); @@ -351,6 +351,32 @@ test('larger shared world uses smaller player ships and wider fake-captain spaci assert.match(server, /distance <= 1100/); }); +test('shipping-route weather is shared by client visuals and authoritative multiplayer movement', async () => { + const [game, environment, multiplayer, server] = await Promise.all([ + read('src/game-v4.js'), + read('src/environment-physics.js'), + read('src/multiplayer.js'), + read('server/main.go'), + ]); + + assert.match(environment, /routeWeatherAt/); + assert.match(environment, /stormFrontAt/); + assert.match(environment, /\? 'shipping-lane'/); + assert.match(environment, /\? 'route-edge'/); + assert.match(game, /routes: this\.world\?\.tradeRoutes/); + assert.match(game, /routeCloudiness/); + assert.match(game, /multiplayerServerClockOffsetMs/); + assert.match(game, /setWeatherRoutes\?\.\(this\.world\.tradeRoutes\)/); + assert.match(game, /weatherZone: this\.environmentWind\?\.zone/); + assert.match(multiplayer, /type: 'weather-layout'/); + assert.match(multiplayer, /setWeatherRoutes/); + assert.match(server, /type weatherRoute struct/); + assert.match(server, /func \(h \*hub\) setWeatherRoutes/); + assert.match(server, /func \(h \*hub\) weatherWindAtLocked/); + assert.match(server, /stepPlayerWithWind/); + assert.match(server, /case "weather-layout":/); +}); + test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => { const server = await read('server/main.go'); @@ -393,7 +419,7 @@ test('multiplayer transport uses Keycloak tickets with server-authoritative move assert.match(server, /func \(h \*hub\) updateInput/); assert.match(server, /func \(h \*hub\) fire/); assert.match(server, /func \(h \*hub\) step/); - assert.match(server, /stepPlayer\(&c\.state, c\.input/); + assert.match(server, /stepPlayerWithWind\(&c\.state, c\.input/); assert.match(server, /target\.state\.Hull = clampInt/); assert.match(server, /target\.state\.RespawnAt/); assert.match(server, /time\.NewTicker\(simTick\)/); @@ -561,8 +587,9 @@ test('rolling clouds include large dense banks that phase in and out with the wi assert.match(source, /Phaser\.Math\.FloatBetween\(DENSE_CLOUD_MIN_SCALE, DENSE_CLOUD_MAX_SCALE\)/); assert.match(source, /cloud\.setData\(\s*'concealmentRadius',[\s\S]*?DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE/); assert.match(source, /cloud\.setData\('weatherCycleMs', Phaser\.Math\.Between\(18000, 36000\)\)/); - assert.match(source, /const windPresence = clamp\(\(wind\.strength - 5\) \/ 18, 0, 1\)/); - assert.match(source, /const targetPresence = clamp\(weatherWave \* 0\.72 \+ windPresence \* 0\.38 - 0\.12, 0, 1\)/); + assert.match(source, /const windPresence = clamp\(\(wind\.strength - 5\) \/ 24, 0, 1\)/); + assert.match(source, /const routeCloudiness = 0\.32/); + assert.match(source, /\(weatherWave \* 0\.62 \+ windPresence \* 0\.32 - 0\.1\) \* routeCloudiness/); assert.match(source, /cloud\.setAlpha\(Phaser\.Math\.Linear\(alphaMin, alphaMax, visibility\) \* presence\)/); assert.match(source, /this\.reseedDenseCloudUpwind\(cloud, wind\)/); assert.match(source, /cloud\.x = centerX - windX \* \(WORLD_SIZE \/ 2 \+ margin\)/);