Sync shipping-route weather system
This commit is contained in:
parent
920710e56e
commit
24a49f8103
202
server/main.go
202
server/main.go
@ -40,6 +40,13 @@ const (
|
|||||||
respawnDelay = 2200 * time.Millisecond
|
respawnDelay = 2200 * time.Millisecond
|
||||||
simTick = time.Second / 30
|
simTick = time.Second / 30
|
||||||
snapshotTick = 50 * time.Millisecond
|
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 {
|
type config struct {
|
||||||
@ -149,6 +156,24 @@ type projectileState struct {
|
|||||||
bornAt time.Time
|
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 clientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Seq int64 `json:"seq"`
|
Seq int64 `json:"seq"`
|
||||||
@ -157,6 +182,7 @@ type clientMessage struct {
|
|||||||
Stop bool `json:"stop"`
|
Stop bool `json:"stop"`
|
||||||
Docked bool `json:"docked"`
|
Docked bool `json:"docked"`
|
||||||
Side string `json:"side"`
|
Side string `json:"side"`
|
||||||
|
Routes []weatherRoute `json:"routes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverMessage struct {
|
type serverMessage struct {
|
||||||
@ -193,6 +219,7 @@ type hub struct {
|
|||||||
projectiles map[string]projectileState
|
projectiles map[string]projectileState
|
||||||
nextProjectile uint64
|
nextProjectile uint64
|
||||||
worldSeed string
|
worldSeed string
|
||||||
|
weatherRoutes []weatherRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHub(seed string, fakePlayers ...int) *hub {
|
func newHub(seed string, fakePlayers ...int) *hub {
|
||||||
@ -281,6 +308,152 @@ func (h *hub) count() int {
|
|||||||
return len(h.clients)
|
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) {
|
func (h *hub) updateInput(id string, msg clientMessage) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
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
|
base := headingRadians(c.state.Angle) + sideSign*math.Pi/2
|
||||||
shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed
|
shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed
|
||||||
shipVY := math.Sin(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} {
|
for _, offset := range []float64{-7, 0, 7} {
|
||||||
angle := base + offset*math.Pi/180
|
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,
|
ID: projectileID, OwnerID: c.state.ID,
|
||||||
X: c.state.X + math.Cos(base)*28,
|
X: c.state.X + math.Cos(base)*28,
|
||||||
Y: c.state.Y + math.Sin(base)*28,
|
Y: c.state.Y + math.Sin(base)*28,
|
||||||
VX: math.Cos(angle)*shotSpeed + shipVX*0.25,
|
VX: math.Cos(angle)*shotSpeed + shipVX*0.25 + shotWind.X*0.22,
|
||||||
VY: math.Sin(angle)*shotSpeed + shipVY*0.25,
|
VY: math.Sin(angle)*shotSpeed + shipVY*0.25 + shotWind.Y*0.22,
|
||||||
Damage: shotDamage,
|
Damage: shotDamage,
|
||||||
bornAt: now,
|
bornAt: now,
|
||||||
}
|
}
|
||||||
@ -345,16 +519,17 @@ func (h *hub) step(now time.Time, dt float64) {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
for _, c := range h.clients {
|
for _, c := range h.clients {
|
||||||
|
wind := h.weatherWindAtLocked(c.state.X, c.state.Y, now)
|
||||||
if c.state.Bot {
|
if c.state.Bot {
|
||||||
input, fireSide := h.fakeCaptainDecisionLocked(c, now)
|
input, fireSide := h.fakeCaptainDecisionLocked(c, now)
|
||||||
c.input = input
|
c.input = input
|
||||||
stepPlayer(&c.state, c.input, now, dt)
|
stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y)
|
||||||
if fireSide != "" {
|
if fireSide != "" {
|
||||||
h.fireLocked(c, fireSide, now)
|
h.fireLocked(c, fireSide, now)
|
||||||
}
|
}
|
||||||
continue
|
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 {
|
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) {
|
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
|
state.InputSeq = input.Seq
|
||||||
if !state.Alive {
|
if !state.Alive {
|
||||||
if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt {
|
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)
|
state.Angle = normalizeAngle(state.Angle + playerTurnRate*turnStrength*directionSign*input.Steer*dt)
|
||||||
|
|
||||||
heading := headingRadians(state.Angle)
|
heading := headingRadians(state.Angle)
|
||||||
state.X = clampFloat(state.X+math.Cos(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*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 }
|
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,
|
Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed,
|
||||||
ServerTime: time.Now().UnixMilli(),
|
ServerTime: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
conn.SetReadLimit(4096)
|
conn.SetReadLimit(8192)
|
||||||
for {
|
for {
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||||
var msg clientMessage
|
var msg clientMessage
|
||||||
@ -866,6 +1052,8 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.hub.updateInput(t.Subject, msg)
|
a.hub.updateInput(t.Subject, msg)
|
||||||
case "fire":
|
case "fire":
|
||||||
a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now())
|
a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now())
|
||||||
|
case "weather-layout":
|
||||||
|
a.hub.setWeatherRoutes(msg.Routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,3 +226,51 @@ func TestFakeCaptainDoesNotAggroHumanAcrossWholeWorld(t *testing.T) {
|
|||||||
t.Fatalf("bot should patrol when human is far: %+v", input)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
src/game-v4.js
104
src/game-v4.js
@ -159,6 +159,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
this.multiplayerHasAuthoritativeState = false;
|
this.multiplayerHasAuthoritativeState = false;
|
||||||
this.multiplayerDead = false;
|
this.multiplayerDead = false;
|
||||||
this.multiplayerLastHull = null;
|
this.multiplayerLastHull = null;
|
||||||
|
this.multiplayerServerClockOffsetMs = 0;
|
||||||
this.multiplayerSendAccumulatorMs = 0;
|
this.multiplayerSendAccumulatorMs = 0;
|
||||||
|
|
||||||
airshipLog('scene:init', {
|
airshipLog('scene:init', {
|
||||||
@ -226,7 +227,12 @@ class GameScene extends Phaser.Scene {
|
|||||||
this.createPlayerSmoke();
|
this.createPlayerSmoke();
|
||||||
|
|
||||||
this.handleMultiplayerSnapshot = (event) => {
|
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) => {
|
this.handleMultiplayerState = (event) => {
|
||||||
if (event.detail?.state === 'online') return;
|
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-snapshot', this.handleMultiplayerSnapshot);
|
||||||
window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState);
|
window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState);
|
||||||
|
window.airshipMultiplayer?.setWeatherRoutes?.(this.world.tradeRoutes);
|
||||||
|
|
||||||
this.pirates = this.physics.add.group();
|
this.pirates = this.physics.add.group();
|
||||||
this.merchants = 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() {
|
createRollingClouds() {
|
||||||
if (!this.textures.exists('rolling-cloud')) {
|
if (!this.textures.exists('rolling-cloud')) {
|
||||||
const cloudTexture = this.make.graphics({ x: 0, y: 0, add: false });
|
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;
|
if (!this.rollingClouds?.length) return;
|
||||||
|
|
||||||
const dt = Math.min(deltaMs / 1000, 0.05);
|
const dt = Math.min(deltaMs / 1000, 0.05);
|
||||||
const now = this.time?.now ?? 0;
|
const now = this.weatherTimeMs();
|
||||||
|
|
||||||
for (const cloud of this.rollingClouds) {
|
for (const cloud of this.rollingClouds) {
|
||||||
const wind = environmentWindAt({
|
const wind = this.weatherWindAt(cloud.x, cloud.y, now);
|
||||||
x: cloud.x,
|
|
||||||
y: cloud.y,
|
|
||||||
timeMs: now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const driftScale = cloud.getData('driftScale') || 1;
|
const driftScale = cloud.getData('driftScale') || 1;
|
||||||
cloud.x += wind.x * driftScale * dt;
|
cloud.x += wind.x * driftScale * dt;
|
||||||
cloud.y += wind.y * driftScale * dt;
|
cloud.y += wind.y * driftScale * dt;
|
||||||
@ -459,8 +480,17 @@ class GameScene extends Phaser.Scene {
|
|||||||
const weatherPhase = cloud.getData('weatherPhase') || 0;
|
const weatherPhase = cloud.getData('weatherPhase') || 0;
|
||||||
const cyclePosition = (now % weatherCycleMs) / weatherCycleMs;
|
const cyclePosition = (now % weatherCycleMs) / weatherCycleMs;
|
||||||
const weatherWave = 0.5 + 0.5 * Math.sin(cyclePosition * Math.PI * 2 + weatherPhase);
|
const weatherWave = 0.5 + 0.5 * Math.sin(cyclePosition * Math.PI * 2 + weatherPhase);
|
||||||
const windPresence = clamp((wind.strength - 5) / 18, 0, 1);
|
const windPresence = clamp((wind.strength - 5) / 24, 0, 1);
|
||||||
const targetPresence = clamp(weatherWave * 0.72 + windPresence * 0.38 - 0.12, 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;
|
const previousPresence = cloud.getData('presence') ?? targetPresence;
|
||||||
presence = Phaser.Math.Linear(previousPresence, targetPresence, Math.min(1, dt * 0.7));
|
presence = Phaser.Math.Linear(previousPresence, targetPresence, Math.min(1, dt * 0.7));
|
||||||
cloud.setData('presence', presence);
|
cloud.setData('presence', presence);
|
||||||
@ -975,12 +1005,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
const spread = Phaser.Math.FloatBetween(-0.16, 0.16);
|
const spread = Phaser.Math.FloatBetween(-0.16, 0.16);
|
||||||
const trailHeading = heading + Math.PI + spread;
|
const trailHeading = heading + Math.PI + spread;
|
||||||
const trailSpeed = Phaser.Math.Linear(16, 46, effectiveForwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16);
|
const trailSpeed = Phaser.Math.Linear(16, 46, effectiveForwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16);
|
||||||
const localWind = environmentWindAt({
|
const localWind = this.weatherWindAt(sternX, sternY);
|
||||||
x: sternX,
|
|
||||||
y: sternY,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4;
|
const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4;
|
||||||
const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 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);
|
merchant.angle += clamp(delta, -34 * dt, 34 * dt);
|
||||||
|
|
||||||
const h = headingRad(merchant);
|
const h = headingRad(merchant);
|
||||||
const wind = environmentWindAt({
|
const wind = this.weatherWindAt(merchant.x, merchant.y);
|
||||||
x: merchant.x,
|
|
||||||
y: merchant.y,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const velocity = velocityWithEnvironment(
|
const velocity = velocityWithEnvironment(
|
||||||
Math.cos(h) * (merchant.getData('speed') || 76),
|
Math.cos(h) * (merchant.getData('speed') || 76),
|
||||||
Math.sin(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.player.anims.timeScale = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.environmentWind = environmentWindAt({
|
this.environmentWind = this.weatherWindAt(this.player.x, this.player.y);
|
||||||
x: this.player.x,
|
|
||||||
y: this.player.y,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const h = headingRad(this.player);
|
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.firePort)) this.playerBroadside(-1);
|
||||||
if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) 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);
|
const h = headingRad(this.player);
|
||||||
this.environmentWind = environmentWindAt({
|
this.environmentWind = this.weatherWindAt(this.player.x, this.player.y);
|
||||||
x: this.player.x,
|
|
||||||
y: this.player.y,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const playerVelocity = velocityWithEnvironment(
|
const playerVelocity = velocityWithEnvironment(
|
||||||
Math.cos(h) * this.playerSpeed,
|
Math.cos(h) * this.playerSpeed,
|
||||||
Math.sin(h) * this.playerSpeed,
|
Math.sin(h) * this.playerSpeed,
|
||||||
@ -1902,12 +1918,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
baseSpeed,
|
baseSpeed,
|
||||||
});
|
});
|
||||||
const h = headingRad(pirate);
|
const h = headingRad(pirate);
|
||||||
const pirateWind = environmentWindAt({
|
const pirateWind = this.weatherWindAt(pirate.x, pirate.y);
|
||||||
x: pirate.x,
|
|
||||||
y: pirate.y,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const pirateVelocity = velocityWithEnvironment(
|
const pirateVelocity = velocityWithEnvironment(
|
||||||
Math.cos(h) * speed,
|
Math.cos(h) * speed,
|
||||||
Math.sin(h) * speed,
|
Math.sin(h) * speed,
|
||||||
@ -1984,12 +1995,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
shot.setData('projectileOwner', actor);
|
shot.setData('projectileOwner', actor);
|
||||||
shot.setData('sourceShip', ship);
|
shot.setData('sourceShip', ship);
|
||||||
if (actor === 'pirate') shot.setData('playerHitQueued', false);
|
if (actor === 'pirate') shot.setData('playerHitQueued', false);
|
||||||
const shotWind = environmentWindAt({
|
const shotWind = this.weatherWindAt(x, y);
|
||||||
x,
|
|
||||||
y,
|
|
||||||
timeMs: this.time.now,
|
|
||||||
islands: this.world?.islands,
|
|
||||||
});
|
|
||||||
const shotVelocity = velocityWithEnvironment(
|
const shotVelocity = velocityWithEnvironment(
|
||||||
Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25,
|
Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25,
|
||||||
Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 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,
|
windSpeed: this.environmentWind?.strength || 0,
|
||||||
windHeading: this.environmentWind?.heading || 0,
|
windHeading: this.environmentWind?.heading || 0,
|
||||||
concealment: this.player?.getData?.('cloudConcealment') || 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: {
|
trade: {
|
||||||
activeMerchants: this.merchants?.countActive?.(true) || 0,
|
activeMerchants: this.merchants?.countActive?.(true) || 0,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ let inputSequence = 0;
|
|||||||
let profile = null;
|
let profile = null;
|
||||||
let selfSnapshot = null;
|
let selfSnapshot = null;
|
||||||
let worldSeed = 'SKYWARD-001';
|
let worldSeed = 'SKYWARD-001';
|
||||||
|
let weatherRoutes = [];
|
||||||
let multiplayerEnabled = localStorage.getItem(MULTIPLAYER_ENABLED_KEY) !== 'false';
|
let multiplayerEnabled = localStorage.getItem(MULTIPLAYER_ENABLED_KEY) !== 'false';
|
||||||
|
|
||||||
const statusButton = document.createElement('button');
|
const statusButton = document.createElement('button');
|
||||||
@ -148,6 +149,7 @@ const connect = async () => {
|
|||||||
inputSequence = 0;
|
inputSequence = 0;
|
||||||
setStatus('online');
|
setStatus('online');
|
||||||
emitConnection('online');
|
emitConnection('online');
|
||||||
|
sendWeatherLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
@ -217,6 +219,29 @@ const sendInput = (input = {}) => {
|
|||||||
return inputSequence;
|
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) => {
|
const fire = (side) => {
|
||||||
if (socket?.readyState !== WebSocket.OPEN) return false;
|
if (socket?.readyState !== WebSocket.OPEN) return false;
|
||||||
const normalized = side === 'port' || side === -1 ? 'port' : 'starboard';
|
const normalized = side === 'port' || side === -1 ? 'port' : 'starboard';
|
||||||
@ -270,6 +295,7 @@ window.airshipMultiplayer = {
|
|||||||
logout,
|
logout,
|
||||||
connect,
|
connect,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
|
setWeatherRoutes,
|
||||||
getState,
|
getState,
|
||||||
getSelfId: () => selfId,
|
getSelfId: () => selfId,
|
||||||
isAuthenticated: () => authenticated,
|
isAuthenticated: () => authenticated,
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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.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.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\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/);
|
||||||
assert.match(source, /this\.environmentWind = environmentWindAt\(\{/);
|
assert.match(source, /this\.environmentWind = this\.weatherWindAt\(this\.player\.x, this\.player\.y\)/);
|
||||||
assert.match(source, /const pirateWind = environmentWindAt\(\{/);
|
assert.match(source, /const pirateWind = this\.weatherWindAt\(pirate\.x, pirate\.y\)/);
|
||||||
assert.match(source, /const shotWind = environmentWindAt\(\{/);
|
assert.match(source, /const shotWind = this\.weatherWindAt\(x, y\)/);
|
||||||
assert.match(source, /const localWind = environmentWindAt\(\{/);
|
assert.match(source, /const localWind = this\.weatherWindAt\(sternX, sternY\)/);
|
||||||
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
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/);
|
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 () => {
|
test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => {
|
||||||
const server = await read('server/main.go');
|
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\) updateInput/);
|
||||||
assert.match(server, /func \(h \*hub\) fire/);
|
assert.match(server, /func \(h \*hub\) fire/);
|
||||||
assert.match(server, /func \(h \*hub\) step/);
|
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\.Hull = clampInt/);
|
||||||
assert.match(server, /target\.state\.RespawnAt/);
|
assert.match(server, /target\.state\.RespawnAt/);
|
||||||
assert.match(server, /time\.NewTicker\(simTick\)/);
|
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, /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\(\s*'concealmentRadius',[\s\S]*?DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE/);
|
||||||
assert.match(source, /cloud\.setData\('weatherCycleMs', Phaser\.Math\.Between\(18000, 36000\)\)/);
|
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 windPresence = clamp\(\(wind\.strength - 5\) \/ 24, 0, 1\)/);
|
||||||
assert.match(source, /const targetPresence = clamp\(weatherWave \* 0\.72 \+ windPresence \* 0\.38 - 0\.12, 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, /cloud\.setAlpha\(Phaser\.Math\.Linear\(alphaMin, alphaMax, visibility\) \* presence\)/);
|
||||||
assert.match(source, /this\.reseedDenseCloudUpwind\(cloud, wind\)/);
|
assert.match(source, /this\.reseedDenseCloudUpwind\(cloud, wind\)/);
|
||||||
assert.match(source, /cloud\.x = centerX - windX \* \(WORLD_SIZE \/ 2 \+ margin\)/);
|
assert.match(source, /cloud\.x = centerX - windX \* \(WORLD_SIZE \/ 2 \+ margin\)/);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user