Sync server-authoritative multiplayer PvP
This commit is contained in:
parent
afe5808731
commit
4299f9b0de
418
server/main.go
418
server/main.go
@ -6,11 +6,12 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"hash/fnv"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -20,6 +21,27 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
worldSize = 3600.0
|
||||||
|
spawnX = 1200.0
|
||||||
|
spawnY = 1550.0
|
||||||
|
playerMaxHull = 100
|
||||||
|
playerMaxSpeed = 180.0
|
||||||
|
playerReverseSpeed = -55.0
|
||||||
|
playerAcceleration = 92.0
|
||||||
|
playerBrake = 118.0
|
||||||
|
playerTurnRate = 76.0
|
||||||
|
playerCoastDrag = 0.992
|
||||||
|
broadsideReload = 850 * time.Millisecond
|
||||||
|
shotSpeed = 520.0
|
||||||
|
shotLifetime = 900 * time.Millisecond
|
||||||
|
shotDamage = 16
|
||||||
|
shipHitRadius = 44.0
|
||||||
|
respawnDelay = 2200 * time.Millisecond
|
||||||
|
simTick = time.Second / 30
|
||||||
|
snapshotTick = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Port string
|
Port string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
@ -53,6 +75,7 @@ type ticketStore struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} }
|
func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} }
|
||||||
|
|
||||||
func (s *ticketStore) issue(subject, name string) (string, error) {
|
func (s *ticketStore) issue(subject, name string) (string, error) {
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 24)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
@ -64,6 +87,7 @@ func (s *ticketStore) issue(subject, name string) (string, error) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ticketStore) consume(value string) (ticket, bool) {
|
func (s *ticketStore) consume(value string) (ticket, bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -74,6 +98,7 @@ func (s *ticketStore) consume(value string) (ticket, bool) {
|
|||||||
}
|
}
|
||||||
return item, true
|
return item, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ticketStore) prune() {
|
func (s *ticketStore) prune() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -86,35 +111,67 @@ func (s *ticketStore) prune() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type playerState struct {
|
type playerState struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
Angle float64 `json:"angle"`
|
Angle float64 `json:"angle"`
|
||||||
Hull int `json:"hull"`
|
Speed float64 `json:"speed"`
|
||||||
Online bool `json:"online"`
|
Hull int `json:"hull"`
|
||||||
|
MaxHull int `json:"maxHull"`
|
||||||
|
Alive bool `json:"alive"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Kills int `json:"kills"`
|
||||||
|
Deaths int `json:"deaths"`
|
||||||
|
InputSeq int64 `json:"inputSeq"`
|
||||||
|
RespawnAt int64 `json:"respawnAt,omitempty"`
|
||||||
|
LastKiller string `json:"lastKiller,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputState struct {
|
||||||
|
Throttle float64
|
||||||
|
Steer float64
|
||||||
|
Stop bool
|
||||||
|
Docked bool
|
||||||
|
Seq int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectileState struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OwnerID string `json:"ownerId"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
VX float64 `json:"vx"`
|
||||||
|
VY float64 `json:"vy"`
|
||||||
|
Damage int `json:"damage"`
|
||||||
|
bornAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientMessage struct {
|
type clientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
X float64 `json:"x"`
|
Seq int64 `json:"seq"`
|
||||||
Y float64 `json:"y"`
|
Throttle float64 `json:"throttle"`
|
||||||
Angle float64 `json:"angle"`
|
Steer float64 `json:"steer"`
|
||||||
Hull int `json:"hull"`
|
Stop bool `json:"stop"`
|
||||||
|
Docked bool `json:"docked"`
|
||||||
|
Side string `json:"side"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverMessage struct {
|
type serverMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
PlayerID string `json:"playerId,omitempty"`
|
PlayerID string `json:"playerId,omitempty"`
|
||||||
WorldSeed string `json:"worldSeed,omitempty"`
|
WorldSeed string `json:"worldSeed,omitempty"`
|
||||||
Players []playerState `json:"players,omitempty"`
|
Players []playerState `json:"players,omitempty"`
|
||||||
ServerTime int64 `json:"serverTime,omitempty"`
|
Projectiles []projectileState `json:"projectiles,omitempty"`
|
||||||
|
ServerTime int64 `json:"serverTime,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
state playerState
|
state playerState
|
||||||
|
input inputState
|
||||||
|
lastFire map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) writeJSON(v any) error {
|
func (c *client) writeJSON(v any) error {
|
||||||
@ -125,12 +182,39 @@ func (c *client) writeJSON(v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type hub struct {
|
type hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[string]*client
|
clients map[string]*client
|
||||||
worldSeed string
|
projectiles map[string]projectileState
|
||||||
|
nextProjectile uint64
|
||||||
|
worldSeed string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHub(seed string) *hub {
|
||||||
|
return &hub{
|
||||||
|
clients: map[string]*client{},
|
||||||
|
projectiles: map[string]projectileState{},
|
||||||
|
worldSeed: seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPlayerState(id, name string, kills, deaths int) playerState {
|
||||||
|
x, y := spawnFor(id)
|
||||||
|
return playerState{
|
||||||
|
ID: id, Name: name, X: x, Y: y, Angle: 0, Speed: 0,
|
||||||
|
Hull: playerMaxHull, MaxHull: playerMaxHull, Alive: true, Online: true,
|
||||||
|
Kills: kills, Deaths: deaths,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spawnFor(id string) (float64, float64) {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(id))
|
||||||
|
v := h.Sum32()
|
||||||
|
dx := float64(int(v%241) - 120)
|
||||||
|
dy := float64(int((v/241)%181) - 90)
|
||||||
|
return clampFloat(spawnX+dx, 90, worldSize-90), clampFloat(spawnY+dy, 90, worldSize-90)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHub(seed string) *hub { return &hub{clients: map[string]*client{}, worldSeed: seed} }
|
|
||||||
func (h *hub) add(c *client) {
|
func (h *hub) add(c *client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if previous := h.clients[c.state.ID]; previous != nil && previous != c {
|
if previous := h.clients[c.state.ID]; previous != nil && previous != c {
|
||||||
@ -139,6 +223,7 @@ func (h *hub) add(c *client) {
|
|||||||
h.clients[c.state.ID] = c
|
h.clients[c.state.ID] = c
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hub) remove(id string, c *client) {
|
func (h *hub) remove(id string, c *client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if h.clients[id] == c {
|
if h.clients[id] == c {
|
||||||
@ -146,37 +231,208 @@ func (h *hub) remove(id string, c *client) {
|
|||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
func (h *hub) update(id string, msg clientMessage) {
|
|
||||||
h.mu.Lock()
|
func (h *hub) count() int {
|
||||||
if c := h.clients[id]; c != nil {
|
h.mu.RLock()
|
||||||
c.state.X = clampFloat(msg.X, 0, 3600)
|
defer h.mu.RUnlock()
|
||||||
c.state.Y = clampFloat(msg.Y, 0, 3600)
|
return len(h.clients)
|
||||||
c.state.Angle = normalizeAngle(msg.Angle)
|
|
||||||
c.state.Hull = clampInt(msg.Hull, 0, 500)
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
func (h *hub) snapshot() ([]*client, []playerState) {
|
|
||||||
|
func (h *hub) updateInput(id string, msg clientMessage) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
c := h.clients[id]
|
||||||
|
if c == nil || msg.Seq <= c.input.Seq {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.input = inputState{
|
||||||
|
Throttle: clampFloat(msg.Throttle, -1, 1),
|
||||||
|
Steer: clampFloat(msg.Steer, -1, 1),
|
||||||
|
Stop: msg.Stop,
|
||||||
|
Docked: msg.Docked,
|
||||||
|
Seq: msg.Seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hub) fire(id, side string, now time.Time) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
c := h.clients[id]
|
||||||
|
if c == nil || !c.state.Alive || (side != "port" && side != "starboard") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.lastFire == nil {
|
||||||
|
c.lastFire = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
if last := c.lastFire[side]; !last.IsZero() && now.Sub(last) < broadsideReload {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.lastFire[side] = now
|
||||||
|
|
||||||
|
sideSign := 1.0
|
||||||
|
if side == "port" {
|
||||||
|
sideSign = -1
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
for _, offset := range []float64{-7, 0, 7} {
|
||||||
|
angle := base + offset*math.Pi/180
|
||||||
|
h.nextProjectile++
|
||||||
|
projectileID := id + ":" + strconv.FormatUint(h.nextProjectile, 10)
|
||||||
|
h.projectiles[projectileID] = projectileState{
|
||||||
|
ID: projectileID, OwnerID: id,
|
||||||
|
X: c.state.X + math.Cos(base)*34,
|
||||||
|
Y: c.state.Y + math.Sin(base)*34,
|
||||||
|
VX: math.Cos(angle)*shotSpeed + shipVX*0.25,
|
||||||
|
VY: math.Sin(angle)*shotSpeed + shipVY*0.25,
|
||||||
|
Damage: shotDamage,
|
||||||
|
bornAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hub) step(now time.Time, dt float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, c := range h.clients {
|
||||||
|
stepPlayer(&c.state, c.input, now, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, shot := range h.projectiles {
|
||||||
|
if now.Sub(shot.bornAt) >= shotLifetime {
|
||||||
|
delete(h.projectiles, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shot.X += shot.VX * dt
|
||||||
|
shot.Y += shot.VY * dt
|
||||||
|
if shot.X < -80 || shot.X > worldSize+80 || shot.Y < -80 || shot.Y > worldSize+80 {
|
||||||
|
delete(h.projectiles, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hit := false
|
||||||
|
for _, target := range h.clients {
|
||||||
|
if target.state.ID == shot.OwnerID || !target.state.Alive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dx := target.state.X - shot.X
|
||||||
|
dy := target.state.Y - shot.Y
|
||||||
|
if dx*dx+dy*dy > shipHitRadius*shipHitRadius {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target.state.Hull = clampInt(target.state.Hull-shot.Damage, 0, target.state.MaxHull)
|
||||||
|
if target.state.Hull <= 0 {
|
||||||
|
target.state.Alive = false
|
||||||
|
target.state.Speed = 0
|
||||||
|
target.state.Deaths++
|
||||||
|
target.state.RespawnAt = now.Add(respawnDelay).UnixMilli()
|
||||||
|
if owner := h.clients[shot.OwnerID]; owner != nil {
|
||||||
|
owner.state.Kills++
|
||||||
|
target.state.LastKiller = owner.state.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(h.projectiles, id)
|
||||||
|
hit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.projectiles[id] = shot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) {
|
||||||
|
state.InputSeq = input.Seq
|
||||||
|
if !state.Alive {
|
||||||
|
if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt {
|
||||||
|
x, y := spawnFor(state.ID)
|
||||||
|
state.X = x
|
||||||
|
state.Y = y
|
||||||
|
state.Angle = 0
|
||||||
|
state.Speed = 0
|
||||||
|
state.Hull = state.MaxHull
|
||||||
|
state.Alive = true
|
||||||
|
state.RespawnAt = 0
|
||||||
|
state.LastKiller = ""
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Docked {
|
||||||
|
state.Speed = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Throttle > 0 {
|
||||||
|
state.Speed += playerAcceleration * input.Throttle * dt
|
||||||
|
} else if input.Throttle < 0 {
|
||||||
|
state.Speed += playerBrake * input.Throttle * dt
|
||||||
|
} else if !input.Stop {
|
||||||
|
state.Speed *= math.Pow(playerCoastDrag, dt*60)
|
||||||
|
}
|
||||||
|
if input.Stop {
|
||||||
|
state.Speed *= math.Pow(0.025, dt)
|
||||||
|
}
|
||||||
|
state.Speed = clampFloat(state.Speed, playerReverseSpeed, playerMaxSpeed)
|
||||||
|
|
||||||
|
turnStrength := clampFloat(math.Abs(state.Speed)/100, 0.28, 1)
|
||||||
|
directionSign := 1.0
|
||||||
|
if state.Speed < 0 {
|
||||||
|
directionSign = -1
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 }
|
||||||
|
|
||||||
|
func (h *hub) snapshot() ([]*client, []playerState, []projectileState) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
clients := make([]*client, 0, len(h.clients))
|
clients := make([]*client, 0, len(h.clients))
|
||||||
players := make([]playerState, 0, len(h.clients))
|
players := make([]playerState, 0, len(h.clients))
|
||||||
|
projectiles := make([]projectileState, 0, len(h.projectiles))
|
||||||
for _, c := range h.clients {
|
for _, c := range h.clients {
|
||||||
clients = append(clients, c)
|
clients = append(clients, c)
|
||||||
players = append(players, c.state)
|
players = append(players, c.state)
|
||||||
}
|
}
|
||||||
return clients, players
|
for _, shot := range h.projectiles {
|
||||||
|
projectiles = append(projectiles, shot)
|
||||||
|
}
|
||||||
|
return clients, players, projectiles
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hub) run(ctx context.Context) {
|
func (h *hub) run(ctx context.Context) {
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
simTicker := time.NewTicker(simTick)
|
||||||
defer ticker.Stop()
|
snapshotTicker := time.NewTicker(snapshotTick)
|
||||||
|
defer simTicker.Stop()
|
||||||
|
defer snapshotTicker.Stop()
|
||||||
|
lastSim := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case now := <-ticker.C:
|
case now := <-simTicker.C:
|
||||||
clients, players := h.snapshot()
|
dt := now.Sub(lastSim).Seconds()
|
||||||
msg := serverMessage{Type: "snapshot", Players: players, ServerTime: now.UnixMilli()}
|
if dt <= 0 || dt > 0.1 {
|
||||||
|
dt = simTick.Seconds()
|
||||||
|
}
|
||||||
|
lastSim = now
|
||||||
|
h.step(now, dt)
|
||||||
|
case now := <-snapshotTicker.C:
|
||||||
|
clients, players, projectiles := h.snapshot()
|
||||||
|
msg := serverMessage{
|
||||||
|
Type: "snapshot", Players: players, Projectiles: projectiles,
|
||||||
|
ServerTime: now.UnixMilli(),
|
||||||
|
}
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
if err := c.writeJSON(msg); err != nil {
|
if err := c.writeJSON(msg); err != nil {
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
@ -198,6 +454,7 @@ func clampFloat(v, min, max float64) float64 {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func clampInt(v, min, max int) int {
|
func clampInt(v, min, max int) int {
|
||||||
if v < min {
|
if v < min {
|
||||||
return min
|
return min
|
||||||
@ -207,6 +464,7 @@ func clampInt(v, min, max int) int {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAngle(v float64) float64 {
|
func normalizeAngle(v float64) float64 {
|
||||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
return 0
|
return 0
|
||||||
@ -236,6 +494,7 @@ func loadConfig() (config, error) {
|
|||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func env(key, fallback string) string {
|
func env(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
@ -251,13 +510,18 @@ func migrate(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
credits INTEGER NOT NULL DEFAULT 650,
|
credits INTEGER NOT NULL DEFAULT 650,
|
||||||
scrap INTEGER NOT NULL DEFAULT 0,
|
scrap INTEGER NOT NULL DEFAULT 0,
|
||||||
progression JSONB NOT NULL DEFAULT '{}'::jsonb,
|
progression JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
pvp_kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pvp_deaths INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
);
|
||||||
|
ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_kills INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_deaths INTEGER NOT NULL DEFAULT 0
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error {
|
func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error {
|
||||||
_, err := db.Exec(ctx, `
|
_, err := db.Exec(ctx, `
|
||||||
INSERT INTO player_profiles (keycloak_sub, display_name)
|
INSERT INTO player_profiles (keycloak_sub, display_name)
|
||||||
@ -270,6 +534,24 @@ func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadPvPStats(ctx context.Context, db *pgxpool.Pool, sub string) (int, int, error) {
|
||||||
|
var kills, deaths int
|
||||||
|
err := db.QueryRow(ctx, `SELECT pvp_kills, pvp_deaths FROM player_profiles WHERE keycloak_sub = $1`, sub).Scan(&kills, &deaths)
|
||||||
|
return kills, deaths, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePvPStats(ctx context.Context, db *pgxpool.Pool, state playerState) error {
|
||||||
|
_, err := db.Exec(ctx, `
|
||||||
|
UPDATE player_profiles SET
|
||||||
|
pvp_kills = $2,
|
||||||
|
pvp_deaths = $3,
|
||||||
|
updated_at = now(),
|
||||||
|
last_seen_at = now()
|
||||||
|
WHERE keycloak_sub = $1
|
||||||
|
`, state.ID, state.Kills, state.Deaths)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
cfg config
|
cfg config
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
@ -314,6 +596,7 @@ func (a *app) authenticate(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func audienceMatches(c claims, clientID string) bool {
|
func audienceMatches(c claims, clientID string) bool {
|
||||||
if c.AuthorizedParty == clientID {
|
if c.AuthorizedParty == clientID {
|
||||||
return true
|
return true
|
||||||
@ -330,6 +613,7 @@ func audienceMatches(c claims, clientID string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) cors(next http.Handler) http.Handler {
|
func (a *app) cors(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
@ -346,11 +630,13 @@ func (a *app) cors(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, status int, value any) {
|
func jsonResponse(w http.ResponseWriter, status int, value any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(value)
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) health(w http.ResponseWriter, r *http.Request) {
|
func (a *app) health(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -358,12 +644,17 @@ func (a *app) health(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, 503, map[string]any{"ok": false, "database": false})
|
jsonResponse(w, 503, map[string]any{"ok": false, "database": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonResponse(w, 200, map[string]any{"ok": true, "database": true, "players": len(a.hub.clients), "worldSeed": a.cfg.WorldSeed})
|
jsonResponse(w, 200, map[string]any{
|
||||||
|
"ok": true, "database": true, "players": a.hub.count(),
|
||||||
|
"worldSeed": a.cfg.WorldSeed, "authority": "movement+pvp",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) me(w http.ResponseWriter, r *http.Request) {
|
func (a *app) me(w http.ResponseWriter, r *http.Request) {
|
||||||
c := r.Context().Value(claimsKey).(claims)
|
c := r.Context().Value(claimsKey).(claims)
|
||||||
jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed})
|
jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) {
|
func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
@ -377,6 +668,7 @@ func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30})
|
jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
||||||
value := r.URL.Query().Get("ticket")
|
value := r.URL.Query().Get("ticket")
|
||||||
t, ok := a.tickets.consume(value)
|
t, ok := a.tickets.consume(value)
|
||||||
@ -388,7 +680,16 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c := &client{conn: conn, state: playerState{ID: t.Subject, Name: t.Name, X: 1200, Y: 1550, Angle: 0, Hull: 100, Online: true}}
|
|
||||||
|
kills, deaths, err := loadPvPStats(r.Context(), a.db, t.Subject)
|
||||||
|
if err != nil {
|
||||||
|
kills, deaths = 0, 0
|
||||||
|
}
|
||||||
|
c := &client{
|
||||||
|
conn: conn,
|
||||||
|
state: newPlayerState(t.Subject, t.Name, kills, deaths),
|
||||||
|
lastFire: map[string]time.Time{},
|
||||||
|
}
|
||||||
a.hub.add(c)
|
a.hub.add(c)
|
||||||
defer func() {
|
defer func() {
|
||||||
a.hub.remove(t.Subject, c)
|
a.hub.remove(t.Subject, c)
|
||||||
@ -396,8 +697,13 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = ensurePlayer(ctx, a.db, t.Subject, t.Name)
|
_ = ensurePlayer(ctx, a.db, t.Subject, t.Name)
|
||||||
|
_ = savePvPStats(ctx, a.db, c.state)
|
||||||
}()
|
}()
|
||||||
_ = c.writeJSON(serverMessage{Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli()})
|
|
||||||
|
_ = c.writeJSON(serverMessage{
|
||||||
|
Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed,
|
||||||
|
ServerTime: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
conn.SetReadLimit(4096)
|
conn.SetReadLimit(4096)
|
||||||
for {
|
for {
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
||||||
@ -405,8 +711,11 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg.Type == "state" {
|
switch msg.Type {
|
||||||
a.hub.update(t.Subject, msg)
|
case "input":
|
||||||
|
a.hub.updateInput(t.Subject, msg)
|
||||||
|
case "fire":
|
||||||
|
a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -433,7 +742,10 @@ func main() {
|
|||||||
log.Fatalf("oidc provider: %v", err)
|
log.Fatalf("oidc provider: %v", err)
|
||||||
}
|
}
|
||||||
verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
|
verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
|
||||||
application := &app{cfg: cfg, db: db, verifier: verifier, tickets: newTicketStore(), hub: newHub(cfg.WorldSeed)}
|
application := &app{
|
||||||
|
cfg: cfg, db: db, verifier: verifier,
|
||||||
|
tickets: newTicketStore(), hub: newHub(cfg.WorldSeed),
|
||||||
|
}
|
||||||
application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
|
application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
return origin == "" || cfg.AllowedOrigins[origin]
|
return origin == "" || cfg.AllowedOrigins[origin]
|
||||||
@ -446,14 +758,16 @@ func main() {
|
|||||||
application.tickets.prune()
|
application.tickets.prune()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", application.health)
|
mux.HandleFunc("/health", application.health)
|
||||||
mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me))))
|
mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me))))
|
||||||
mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket))))
|
mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket))))
|
||||||
mux.HandleFunc("/ws", application.ws)
|
mux.HandleFunc("/ws", application.ws)
|
||||||
server := &http.Server{Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
|
server := &http.Server{
|
||||||
log.Printf("airship game server listening on :%s world=%s", cfg.Port, cfg.WorldSeed)
|
Addr: ":" + cfg.Port, Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
log.Printf("airship game server listening on :%s world=%s authority=movement+pvp", cfg.Port, cfg.WorldSeed)
|
||||||
log.Fatal(server.ListenAndServe())
|
log.Fatal(server.ListenAndServe())
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { _ = fmt.Sprintf }
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTicketSingleUse(t *testing.T) {
|
func TestTicketSingleUse(t *testing.T) {
|
||||||
@ -20,21 +21,18 @@ func TestTicketSingleUse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTransformSanitizers(t *testing.T) {
|
func TestTransformSanitizers(t *testing.T) {
|
||||||
if got := clampFloat(-5, 0, 3600); got != 0 {
|
if got := clampFloat(-5, 0, worldSize); got != 0 {
|
||||||
t.Fatalf("clamp low=%v", got)
|
t.Fatalf("clamp low=%v", got)
|
||||||
}
|
}
|
||||||
if got := clampFloat(4000, 0, 3600); got != 3600 {
|
if got := clampFloat(4000, 0, worldSize); got != worldSize {
|
||||||
t.Fatalf("clamp high=%v", got)
|
t.Fatalf("clamp high=%v", got)
|
||||||
}
|
}
|
||||||
if got := clampFloat(math.NaN(), 0, 3600); got != 0 {
|
if got := clampFloat(math.NaN(), 0, worldSize); got != 0 {
|
||||||
t.Fatalf("nan=%v", got)
|
t.Fatalf("nan=%v", got)
|
||||||
}
|
}
|
||||||
if got := normalizeAngle(-90); got != 270 {
|
if got := normalizeAngle(-90); got != 270 {
|
||||||
t.Fatalf("angle=%v", got)
|
t.Fatalf("angle=%v", got)
|
||||||
}
|
}
|
||||||
if got := clampInt(900, 0, 500); got != 500 {
|
|
||||||
t.Fatalf("hull=%v", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAudienceMatch(t *testing.T) {
|
func TestAudienceMatch(t *testing.T) {
|
||||||
@ -48,3 +46,99 @@ func TestAudienceMatch(t *testing.T) {
|
|||||||
t.Fatal("wrong client must not match")
|
t.Fatal("wrong client must not match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerMovementUsesInputNotClientTransform(t *testing.T) {
|
||||||
|
state := newPlayerState("player-1", "Dave", 0, 0)
|
||||||
|
startX, startY := state.X, state.Y
|
||||||
|
input := inputState{Throttle: 1, Steer: 0.4, Seq: 7}
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
stepPlayer(&state, input, now.Add(time.Duration(i)*simTick), simTick.Seconds())
|
||||||
|
}
|
||||||
|
if state.InputSeq != 7 {
|
||||||
|
t.Fatalf("input seq=%d", state.InputSeq)
|
||||||
|
}
|
||||||
|
if state.Speed <= 0 || state.Speed > playerMaxSpeed {
|
||||||
|
t.Fatalf("speed=%v", state.Speed)
|
||||||
|
}
|
||||||
|
if state.X == startX && state.Y == startY {
|
||||||
|
t.Fatal("authoritative simulation did not move player")
|
||||||
|
}
|
||||||
|
if state.Angle == 0 {
|
||||||
|
t.Fatal("steer input did not change angle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockedInputFreezesAuthoritativeShip(t *testing.T) {
|
||||||
|
state := newPlayerState("player-1", "Dave", 0, 0)
|
||||||
|
state.Speed = 150
|
||||||
|
x, y := state.X, state.Y
|
||||||
|
stepPlayer(&state, inputState{Docked: true, Seq: 1}, time.Now(), simTick.Seconds())
|
||||||
|
if state.Speed != 0 || state.X != x || state.Y != y {
|
||||||
|
t.Fatalf("docked ship moved: speed=%v x=%v y=%v", state.Speed, state.X, state.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadsideSpawnsThreeServerProjectilesAndEnforcesReload(t *testing.T) {
|
||||||
|
h := newHub("TEST")
|
||||||
|
c := &client{state: newPlayerState("one", "One", 0, 0), lastFire: map[string]time.Time{}}
|
||||||
|
h.clients[c.state.ID] = c
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
if !h.fire("one", "port", now) {
|
||||||
|
t.Fatal("first fire should succeed")
|
||||||
|
}
|
||||||
|
if len(h.projectiles) != 3 {
|
||||||
|
t.Fatalf("projectiles=%d", len(h.projectiles))
|
||||||
|
}
|
||||||
|
if h.fire("one", "port", now.Add(100*time.Millisecond)) {
|
||||||
|
t.Fatal("reload should reject early second broadside")
|
||||||
|
}
|
||||||
|
if !h.fire("one", "port", now.Add(broadsideReload)) {
|
||||||
|
t.Fatal("broadside should fire after reload")
|
||||||
|
}
|
||||||
|
if len(h.projectiles) != 6 {
|
||||||
|
t.Fatalf("projectiles after reload=%d", len(h.projectiles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerProjectileAppliesDamageDeathAndKill(t *testing.T) {
|
||||||
|
h := newHub("TEST")
|
||||||
|
shooter := &client{state: newPlayerState("one", "One", 0, 0), lastFire: map[string]time.Time{}}
|
||||||
|
target := &client{state: newPlayerState("two", "Two", 0, 0), lastFire: map[string]time.Time{}}
|
||||||
|
shooter.state.X, shooter.state.Y = 1000, 1000
|
||||||
|
target.state.X, target.state.Y = 1050, 1000
|
||||||
|
target.state.Hull = shotDamage
|
||||||
|
h.clients[shooter.state.ID] = shooter
|
||||||
|
h.clients[target.state.ID] = target
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
h.projectiles["shot"] = projectileState{
|
||||||
|
ID: "shot", OwnerID: shooter.state.ID, X: target.state.X, Y: target.state.Y,
|
||||||
|
Damage: shotDamage, bornAt: now,
|
||||||
|
}
|
||||||
|
h.step(now.Add(simTick), simTick.Seconds())
|
||||||
|
if target.state.Alive {
|
||||||
|
t.Fatal("target should be dead")
|
||||||
|
}
|
||||||
|
if target.state.Deaths != 1 || shooter.state.Kills != 1 {
|
||||||
|
t.Fatalf("kills/deaths=%d/%d", shooter.state.Kills, target.state.Deaths)
|
||||||
|
}
|
||||||
|
if target.state.LastKiller != "One" {
|
||||||
|
t.Fatalf("killer=%q", target.state.LastKiller)
|
||||||
|
}
|
||||||
|
if _, ok := h.projectiles["shot"]; ok {
|
||||||
|
t.Fatal("hit projectile should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerRespawnsDestroyedPlayer(t *testing.T) {
|
||||||
|
state := newPlayerState("player-1", "Dave", 0, 1)
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
state.Alive = false
|
||||||
|
state.Hull = 0
|
||||||
|
state.Speed = 120
|
||||||
|
state.RespawnAt = now.Add(respawnDelay).UnixMilli()
|
||||||
|
stepPlayer(&state, inputState{}, now.Add(respawnDelay+time.Millisecond), simTick.Seconds())
|
||||||
|
if !state.Alive || state.Hull != playerMaxHull || state.Speed != 0 {
|
||||||
|
t.Fatalf("bad respawn: %+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
253
src/game-v4.js
253
src/game-v4.js
@ -145,6 +145,11 @@ class GameScene extends Phaser.Scene {
|
|||||||
this.tradeLosses = 0;
|
this.tradeLosses = 0;
|
||||||
this.playerTradeLosses = 0;
|
this.playerTradeLosses = 0;
|
||||||
this.remotePlayers = new Map();
|
this.remotePlayers = new Map();
|
||||||
|
this.multiplayerProjectiles = new Map();
|
||||||
|
this.multiplayerAuthoritativeSelf = null;
|
||||||
|
this.multiplayerHasAuthoritativeState = false;
|
||||||
|
this.multiplayerDead = false;
|
||||||
|
this.multiplayerLastHull = null;
|
||||||
this.multiplayerSendAccumulatorMs = 0;
|
this.multiplayerSendAccumulatorMs = 0;
|
||||||
|
|
||||||
airshipLog('scene:init', {
|
airshipLog('scene:init', {
|
||||||
@ -185,9 +190,22 @@ class GameScene extends Phaser.Scene {
|
|||||||
this.createPlayerSmoke();
|
this.createPlayerSmoke();
|
||||||
|
|
||||||
this.handleMultiplayerSnapshot = (event) => {
|
this.handleMultiplayerSnapshot = (event) => {
|
||||||
this.syncRemotePlayers(event.detail?.players || []);
|
this.syncMultiplayerSnapshot(event.detail || {});
|
||||||
|
};
|
||||||
|
this.handleMultiplayerState = (event) => {
|
||||||
|
if (event.detail?.state === 'online') return;
|
||||||
|
this.clearMultiplayerEntities();
|
||||||
|
this.multiplayerAuthoritativeSelf = null;
|
||||||
|
this.multiplayerHasAuthoritativeState = false;
|
||||||
|
this.multiplayerDead = false;
|
||||||
|
this.multiplayerLastHull = null;
|
||||||
|
if (this.player && this.gameState === 'playing') {
|
||||||
|
this.player.setVisible(true);
|
||||||
|
this.playerHull = Math.max(1, this.playerHull || this.playerStats?.maxHull || PLAYER_MAX_HULL);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
||||||
|
window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState);
|
||||||
|
|
||||||
this.pirates = this.physics.add.group();
|
this.pirates = this.physics.add.group();
|
||||||
this.merchants = this.physics.add.group();
|
this.merchants = this.physics.add.group();
|
||||||
@ -284,11 +302,8 @@ class GameScene extends Phaser.Scene {
|
|||||||
window.removeEventListener('airship:helm-vector', this.handleHelmVector);
|
window.removeEventListener('airship:helm-vector', this.handleHelmVector);
|
||||||
window.removeEventListener('airship:action', this.handleMobileAction);
|
window.removeEventListener('airship:action', this.handleMobileAction);
|
||||||
window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
||||||
for (const remote of this.remotePlayers.values()) {
|
window.removeEventListener('airship:multiplayer-state', this.handleMultiplayerState);
|
||||||
remote.sprite?.destroy();
|
this.clearMultiplayerEntities();
|
||||||
remote.label?.destroy();
|
|
||||||
}
|
|
||||||
this.remotePlayers.clear();
|
|
||||||
airshipLog('scene:shutdown', {
|
airshipLog('scene:shutdown', {
|
||||||
gameState: this.gameState,
|
gameState: this.gameState,
|
||||||
hull: this.playerHull,
|
hull: this.playerHull,
|
||||||
@ -831,6 +846,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Airships can cross islands, but lose momentum while flying through the terrain envelope.
|
// Airships can cross islands, but lose momentum while flying through the terrain envelope.
|
||||||
this.physics.add.overlap(this.player, obstacle, () => {
|
this.physics.add.overlap(this.player, obstacle, () => {
|
||||||
|
if (window.airshipMultiplayer?.isConnected?.()) return;
|
||||||
this.playerSpeed *= 0.985;
|
this.playerSpeed *= 0.985;
|
||||||
});
|
});
|
||||||
this.physics.add.overlap(
|
this.physics.add.overlap(
|
||||||
@ -1451,6 +1467,71 @@ class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearMultiplayerEntities() {
|
||||||
|
for (const remote of this.remotePlayers.values()) {
|
||||||
|
remote.sprite?.destroy();
|
||||||
|
remote.label?.destroy();
|
||||||
|
}
|
||||||
|
this.remotePlayers.clear();
|
||||||
|
for (const projectile of this.multiplayerProjectiles.values()) {
|
||||||
|
projectile.sprite?.destroy();
|
||||||
|
}
|
||||||
|
this.multiplayerProjectiles.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMultiplayerSnapshot(snapshot = {}) {
|
||||||
|
this.syncAuthoritativeSelf(snapshot.self || null);
|
||||||
|
this.syncRemotePlayers(snapshot.players || []);
|
||||||
|
this.syncMultiplayerProjectiles(snapshot.projectiles || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAuthoritativeSelf(state) {
|
||||||
|
if (!state || !this.player) return;
|
||||||
|
|
||||||
|
const wasDead = this.multiplayerDead;
|
||||||
|
const firstState = !this.multiplayerHasAuthoritativeState;
|
||||||
|
const previousHull = this.multiplayerLastHull;
|
||||||
|
this.multiplayerAuthoritativeSelf = { ...state };
|
||||||
|
this.multiplayerHasAuthoritativeState = true;
|
||||||
|
this.multiplayerDead = state.alive === false;
|
||||||
|
this.multiplayerLastHull = Math.max(0, Number(state.hull) || 0);
|
||||||
|
|
||||||
|
if (this.multiplayerDead) {
|
||||||
|
if (!wasDead) {
|
||||||
|
this.spawnExplosion(this.player.x, this.player.y, 1.2);
|
||||||
|
this.status.setText(`SHOT DOWN${state.lastKiller ? ` BY ${state.lastKiller}` : ''}\nRESPAWNING...`);
|
||||||
|
}
|
||||||
|
this.playerHull = 0;
|
||||||
|
this.playerSpeed = 0;
|
||||||
|
this.player.setVelocity(0, 0);
|
||||||
|
this.player.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstState || wasDead) {
|
||||||
|
this.player.setVisible(true);
|
||||||
|
this.player.setPosition(Number(state.x) || this.world.spawn.x, Number(state.y) || this.world.spawn.y);
|
||||||
|
this.player.angle = Number(state.angle) || 0;
|
||||||
|
this.playerSpeed = Number(state.speed) || 0;
|
||||||
|
this.playerHull = Math.max(0, Number(state.hull) || PLAYER_MAX_HULL);
|
||||||
|
if (wasDead) {
|
||||||
|
this.status.setText('REDEPLOYED');
|
||||||
|
this.time.delayedCall(900, () => {
|
||||||
|
if (this.gameState === 'playing' && !this.multiplayerDead) this.status.setText('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousHull != null && state.hull < previousHull) {
|
||||||
|
const damage = previousHull - state.hull;
|
||||||
|
this.playerHitSequence += 1;
|
||||||
|
this.spawnExplosion(this.player.x, this.player.y, 0.32);
|
||||||
|
this.playPlayerHitReaction(damage);
|
||||||
|
}
|
||||||
|
this.playerHull = Math.max(0, Number(state.hull) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
syncRemotePlayers(players = []) {
|
syncRemotePlayers(players = []) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
@ -1488,16 +1569,23 @@ class GameScene extends Phaser.Scene {
|
|||||||
targetX: sprite.x,
|
targetX: sprite.x,
|
||||||
targetY: sprite.y,
|
targetY: sprite.y,
|
||||||
targetAngle: Number(state.angle) || 0,
|
targetAngle: Number(state.angle) || 0,
|
||||||
hull: Number(state.hull) || 100,
|
hull: Number(state.hull) || PLAYER_MAX_HULL,
|
||||||
|
alive: state.alive !== false,
|
||||||
};
|
};
|
||||||
this.remotePlayers.set(state.id, remote);
|
this.remotePlayers.set(state.id, remote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wasAlive = remote.alive;
|
||||||
remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width);
|
remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width);
|
||||||
remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height);
|
remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height);
|
||||||
remote.targetAngle = normalizeDegrees(Number(state.angle) || 0);
|
remote.targetAngle = normalizeDegrees(Number(state.angle) || 0);
|
||||||
remote.hull = Math.max(0, Number(state.hull) || 0);
|
remote.hull = Math.max(0, Number(state.hull) || 0);
|
||||||
remote.label.setText(`${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}`);
|
remote.alive = state.alive !== false;
|
||||||
|
if (wasAlive && !remote.alive) this.spawnExplosion(remote.sprite.x, remote.sprite.y, 1.05);
|
||||||
|
remote.sprite.setVisible(remote.alive);
|
||||||
|
remote.label.setText(remote.alive
|
||||||
|
? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}`
|
||||||
|
: `${state.name || 'CAPTAIN'} • DOWN`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, remote] of this.remotePlayers) {
|
for (const [id, remote] of this.remotePlayers) {
|
||||||
@ -1508,16 +1596,89 @@ class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncMultiplayerProjectiles(projectiles = []) {
|
||||||
|
const seen = new Set();
|
||||||
|
const selfId = window.airshipMultiplayer?.getSelfId?.();
|
||||||
|
|
||||||
|
for (const state of projectiles) {
|
||||||
|
if (!state?.id) continue;
|
||||||
|
seen.add(state.id);
|
||||||
|
let projectile = this.multiplayerProjectiles.get(state.id);
|
||||||
|
if (!projectile) {
|
||||||
|
const sprite = this.add.image(Number(state.x) || 0, Number(state.y) || 0, 'cannonball');
|
||||||
|
sprite
|
||||||
|
.setDepth(8)
|
||||||
|
.setDisplaySize(14, 14)
|
||||||
|
.setTint(state.ownerId === selfId ? 0xffdf91 : 0x8cd3ff);
|
||||||
|
projectile = { sprite, vx: 0, vy: 0 };
|
||||||
|
this.multiplayerProjectiles.set(state.id, projectile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetX = Number(state.x) || 0;
|
||||||
|
const targetY = Number(state.y) || 0;
|
||||||
|
const error = Math.hypot(projectile.sprite.x - targetX, projectile.sprite.y - targetY);
|
||||||
|
if (error > 90) {
|
||||||
|
projectile.sprite.setPosition(targetX, targetY);
|
||||||
|
} else {
|
||||||
|
projectile.sprite.x = Phaser.Math.Linear(projectile.sprite.x, targetX, 0.48);
|
||||||
|
projectile.sprite.y = Phaser.Math.Linear(projectile.sprite.y, targetY, 0.48);
|
||||||
|
}
|
||||||
|
projectile.vx = Number(state.vx) || 0;
|
||||||
|
projectile.vy = Number(state.vy) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, projectile] of this.multiplayerProjectiles) {
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
projectile.sprite?.destroy();
|
||||||
|
this.multiplayerProjectiles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplayerControlInput() {
|
||||||
|
if (!this.cursors || !this.keys) return { throttle: 0, steer: 0, stop: false, docked: true };
|
||||||
|
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
|
||||||
|
const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE);
|
||||||
|
const helmY = Math.abs(this.helmVector.y) > HELM_DEAD_ZONE ? this.helmVector.y : 0;
|
||||||
|
const throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY);
|
||||||
|
const throttleReverse = this.cursors.down.isDown ? 1 : Math.max(0, helmY);
|
||||||
|
const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0);
|
||||||
|
return {
|
||||||
|
throttle: clamp(throttleForward - throttleReverse, -1, 1),
|
||||||
|
steer: keyboardTurn || helmX,
|
||||||
|
stop: this.keys.stop.isDown,
|
||||||
|
docked: this.dockOpen || this.shipyardOpen || this.multiplayerDead,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
updateMultiplayer(deltaMs) {
|
updateMultiplayer(deltaMs) {
|
||||||
|
const online = Boolean(window.airshipMultiplayer?.isConnected?.());
|
||||||
|
if (!online) return;
|
||||||
|
|
||||||
this.multiplayerSendAccumulatorMs += deltaMs;
|
this.multiplayerSendAccumulatorMs += deltaMs;
|
||||||
if (this.multiplayerSendAccumulatorMs >= 100) {
|
if (this.multiplayerSendAccumulatorMs >= 50) {
|
||||||
this.multiplayerSendAccumulatorMs %= 100;
|
this.multiplayerSendAccumulatorMs %= 50;
|
||||||
window.airshipMultiplayer?.sendState?.({
|
window.airshipMultiplayer?.sendInput?.(this.multiplayerControlInput());
|
||||||
x: this.player?.x || 0,
|
}
|
||||||
y: this.player?.y || 0,
|
|
||||||
angle: this.player?.angle || 0,
|
const authoritative = this.multiplayerAuthoritativeSelf;
|
||||||
hull: this.playerHull,
|
if (authoritative && !this.multiplayerDead && this.player?.visible) {
|
||||||
});
|
const targetX = Number(authoritative.x) || 0;
|
||||||
|
const targetY = Number(authoritative.y) || 0;
|
||||||
|
const positionError = Math.hypot(this.player.x - targetX, this.player.y - targetY);
|
||||||
|
if (positionError > 180) {
|
||||||
|
this.player.setPosition(targetX, targetY);
|
||||||
|
} else {
|
||||||
|
this.player.x = Phaser.Math.Linear(this.player.x, targetX, 0.14);
|
||||||
|
this.player.y = Phaser.Math.Linear(this.player.y, targetY, 0.14);
|
||||||
|
}
|
||||||
|
this.player.angle += signedAngleDelta(Number(authoritative.angle) || 0, this.player.angle) * 0.16;
|
||||||
|
this.playerSpeed = Phaser.Math.Linear(this.playerSpeed, Number(authoritative.speed) || 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = Math.min(deltaMs / 1000, 0.05);
|
||||||
|
for (const projectile of this.multiplayerProjectiles.values()) {
|
||||||
|
projectile.sprite.x += projectile.vx * dt;
|
||||||
|
projectile.sprite.y += projectile.vy * dt;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const remote of this.remotePlayers.values()) {
|
for (const remote of this.remotePlayers.values()) {
|
||||||
@ -1530,8 +1691,52 @@ class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateMultiplayerPredictedPlayer(dt, deltaMs) {
|
||||||
|
if (!this.player?.body || this.multiplayerDead) return;
|
||||||
|
const input = this.multiplayerControlInput();
|
||||||
|
|
||||||
|
if (input.throttle > 0) this.playerSpeed += PLAYER_ACCELERATION * input.throttle * dt;
|
||||||
|
if (input.throttle < 0) this.playerSpeed += PLAYER_BRAKE * input.throttle * dt;
|
||||||
|
if (input.stop) this.playerSpeed *= Math.pow(0.025, dt);
|
||||||
|
if (input.throttle === 0 && !input.stop) {
|
||||||
|
this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playerSpeed = clamp(this.playerSpeed, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED);
|
||||||
|
const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1);
|
||||||
|
const directionSign = this.playerSpeed < 0 ? -1 : 1;
|
||||||
|
this.player.angle += PLAYER_TURN_RATE * turnStrength * directionSign * input.steer * dt;
|
||||||
|
|
||||||
|
const effectiveForwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1);
|
||||||
|
if (effectiveForwardThrust > 0.06) {
|
||||||
|
if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') {
|
||||||
|
this.player.play('player-forward');
|
||||||
|
}
|
||||||
|
this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35;
|
||||||
|
} else {
|
||||||
|
if (this.player.anims.isPlaying) this.player.anims.stop();
|
||||||
|
this.player.setFrame(0);
|
||||||
|
this.player.anims.timeScale = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.environmentWind = environmentWindAt({
|
||||||
|
x: this.player.x,
|
||||||
|
y: this.player.y,
|
||||||
|
timeMs: this.time.now,
|
||||||
|
islands: this.world?.islands,
|
||||||
|
});
|
||||||
|
const h = headingRad(this.player);
|
||||||
|
this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed);
|
||||||
|
|
||||||
|
if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1);
|
||||||
|
if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1);
|
||||||
|
}
|
||||||
updatePlayer(dt, deltaMs) {
|
updatePlayer(dt, deltaMs) {
|
||||||
if (!this.player?.body) return;
|
if (!this.player?.body) return;
|
||||||
|
if (window.airshipMultiplayer?.isConnected?.()) {
|
||||||
|
this.updateMultiplayerPredictedPlayer(dt, deltaMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions);
|
const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions);
|
||||||
|
|
||||||
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
|
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
|
||||||
@ -1673,7 +1878,10 @@ class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
playerBroadside(side) {
|
playerBroadside(side) {
|
||||||
const broadside = side < 0 ? 'port' : 'starboard';
|
const broadside = side < 0 ? 'port' : 'starboard';
|
||||||
if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return;
|
const multiplayerOnline = Boolean(window.airshipMultiplayer?.isConnected?.());
|
||||||
|
const reloadMs = multiplayerOnline ? PLAYER_BROADSIDE_RELOAD_MS : this.playerBroadsideReloadMs;
|
||||||
|
if (this.time.now - this.lastPlayerShot[broadside] < reloadMs) return;
|
||||||
|
if (multiplayerOnline) window.airshipMultiplayer?.fire?.(broadside);
|
||||||
this.fireBroadside(
|
this.fireBroadside(
|
||||||
this.player,
|
this.player,
|
||||||
side,
|
side,
|
||||||
@ -1706,6 +1914,9 @@ class GameScene extends Phaser.Scene {
|
|||||||
const y = ship.y + Math.sin(base) * 34;
|
const y = ship.y + Math.sin(base) * 34;
|
||||||
const shot = group.create(x, y, texture);
|
const shot = group.create(x, y, texture);
|
||||||
shot.setDepth(8).setDisplaySize(14, 14);
|
shot.setDepth(8).setDisplaySize(14, 14);
|
||||||
|
if (actor === 'player' && window.airshipMultiplayer?.isConnected?.()) {
|
||||||
|
shot.setVisible(false);
|
||||||
|
}
|
||||||
shot.body.setAllowGravity(false);
|
shot.body.setAllowGravity(false);
|
||||||
shot.setData('bornAt', this.time.now);
|
shot.setData('bornAt', this.time.now);
|
||||||
shot.setData('damage', projectileDamage);
|
shot.setData('damage', projectileDamage);
|
||||||
@ -1785,6 +1996,8 @@ class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
hitPlayer(first, second) {
|
hitPlayer(first, second) {
|
||||||
if (this.gameState !== 'playing') return;
|
if (this.gameState !== 'playing') return;
|
||||||
|
// Online player damage is owned by the Go server; local pirates remain client-side until AI migrates.
|
||||||
|
if (window.airshipMultiplayer?.isConnected?.()) return;
|
||||||
|
|
||||||
const shot = resolveTaggedProjectile(first, second, 'pirate');
|
const shot = resolveTaggedProjectile(first, second, 'pirate');
|
||||||
if (!shot?.active || shot === this.player) {
|
if (!shot?.active || shot === this.player) {
|
||||||
@ -1923,6 +2136,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateLivePlayerState() {
|
validateLivePlayerState() {
|
||||||
|
if (this.multiplayerDead) return;
|
||||||
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
|
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
|
||||||
|
|
||||||
const issues = [];
|
const issues = [];
|
||||||
@ -2037,7 +2251,9 @@ class GameScene extends Phaser.Scene {
|
|||||||
kills: this.defeated,
|
kills: this.defeated,
|
||||||
player: {
|
player: {
|
||||||
hull: this.playerHull,
|
hull: this.playerHull,
|
||||||
maxHull: this.playerStats?.maxHull || PLAYER_MAX_HULL,
|
maxHull: window.airshipMultiplayer?.isConnected?.()
|
||||||
|
? this.multiplayerAuthoritativeSelf?.maxHull || PLAYER_MAX_HULL
|
||||||
|
: this.playerStats?.maxHull || PLAYER_MAX_HULL,
|
||||||
hitSequence: this.playerHitSequence,
|
hitSequence: this.playerHitSequence,
|
||||||
},
|
},
|
||||||
target: nearest ? {
|
target: nearest ? {
|
||||||
@ -2251,6 +2467,7 @@ class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkGameState() {
|
checkGameState() {
|
||||||
|
if (window.airshipMultiplayer?.isConnected?.()) return;
|
||||||
if (this.playerHull <= 0) this.finishGame();
|
if (this.playerHull <= 0) this.finishGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,15 @@ const KEYCLOAK_CONFIG = {
|
|||||||
clientId: 'airship-web',
|
clientId: 'airship-web',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
const keycloak = new Keycloak(KEYCLOAK_CONFIG);
|
const keycloak = new Keycloak(KEYCLOAK_CONFIG);
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let selfId = null;
|
let selfId = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let playerCount = 0;
|
let playerCount = 0;
|
||||||
|
let inputSequence = 0;
|
||||||
|
|
||||||
const statusButton = document.createElement('button');
|
const statusButton = document.createElement('button');
|
||||||
statusButton.id = 'multiplayer-status';
|
statusButton.id = 'multiplayer-status';
|
||||||
@ -30,7 +33,7 @@ style.textContent = `
|
|||||||
left: max(10px, env(safe-area-inset-left));
|
left: max(10px, env(safe-area-inset-left));
|
||||||
z-index: 410;
|
z-index: 410;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
max-width: 180px;
|
max-width: 190px;
|
||||||
padding: 7px 11px;
|
padding: 7px 11px;
|
||||||
border: 1px solid rgba(220, 168, 79, .72);
|
border: 1px solid rgba(220, 168, 79, .72);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@ -95,6 +98,7 @@ const connect = async () => {
|
|||||||
socket = ws;
|
socket = ws;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
|
inputSequence = 0;
|
||||||
setStatus('online', 'MULTIPLAYER • ONLINE');
|
setStatus('online', 'MULTIPLAYER • ONLINE');
|
||||||
emitConnection('online');
|
emitConnection('online');
|
||||||
});
|
});
|
||||||
@ -110,13 +114,24 @@ const connect = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'snapshot') {
|
if (message.type === 'snapshot') {
|
||||||
const players = Array.isArray(message.players)
|
const allPlayers = Array.isArray(message.players)
|
||||||
? message.players.filter((player) => player?.id && player.id !== selfId)
|
? message.players.filter((player) => player?.id)
|
||||||
: [];
|
: [];
|
||||||
playerCount = players.length + 1;
|
const self = allPlayers.find((player) => player.id === selfId) || null;
|
||||||
setStatus('online', `MULTIPLAYER • ${playerCount}`);
|
const players = allPlayers.filter((player) => player.id !== selfId);
|
||||||
|
const projectiles = Array.isArray(message.projectiles)
|
||||||
|
? message.projectiles.filter((shot) => shot?.id)
|
||||||
|
: [];
|
||||||
|
playerCount = allPlayers.length;
|
||||||
|
const score = self ? ` • ${self.kills || 0}/${self.deaths || 0}` : '';
|
||||||
|
setStatus('online', `MULTIPLAYER • ${playerCount}${score}`);
|
||||||
window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', {
|
window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', {
|
||||||
detail: { players, serverTime: message.serverTime || 0 },
|
detail: {
|
||||||
|
self,
|
||||||
|
players,
|
||||||
|
projectiles,
|
||||||
|
serverTime: message.serverTime || 0,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -134,15 +149,24 @@ const connect = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendState = (state) => {
|
const sendInput = (input = {}) => {
|
||||||
if (socket?.readyState !== WebSocket.OPEN || !state) return false;
|
if (socket?.readyState !== WebSocket.OPEN) return 0;
|
||||||
|
inputSequence += 1;
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'state',
|
type: 'input',
|
||||||
x: Number(state.x) || 0,
|
seq: inputSequence,
|
||||||
y: Number(state.y) || 0,
|
throttle: clamp(Number(input.throttle) || 0, -1, 1),
|
||||||
angle: Number(state.angle) || 0,
|
steer: clamp(Number(input.steer) || 0, -1, 1),
|
||||||
hull: Math.max(0, Math.round(Number(state.hull) || 0)),
|
stop: Boolean(input.stop),
|
||||||
|
docked: Boolean(input.docked),
|
||||||
}));
|
}));
|
||||||
|
return inputSequence;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fire = (side) => {
|
||||||
|
if (socket?.readyState !== WebSocket.OPEN) return false;
|
||||||
|
const normalized = side === 'port' || side === -1 ? 'port' : 'starboard';
|
||||||
|
socket.send(JSON.stringify({ type: 'fire', side: normalized }));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -158,10 +182,12 @@ statusButton.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.airshipMultiplayer = {
|
window.airshipMultiplayer = {
|
||||||
sendState,
|
sendInput,
|
||||||
|
fire,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
connect,
|
connect,
|
||||||
|
getSelfId: () => selfId,
|
||||||
isAuthenticated: () => authenticated,
|
isAuthenticated: () => authenticated,
|
||||||
isConnected: () => socket?.readyState === WebSocket.OPEN,
|
isConnected: () => socket?.readyState === WebSocket.OPEN,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -178,7 +178,8 @@ test('player firing and reload HUD share one reload duration source', async () =
|
|||||||
const source = await read('src/game-v4.js');
|
const source = await read('src/game-v4.js');
|
||||||
|
|
||||||
assert.match(source, /this\.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS/);
|
assert.match(source, /this\.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS/);
|
||||||
assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < this\.playerBroadsideReloadMs/);
|
assert.match(source, /const reloadMs = multiplayerOnline \? PLAYER_BROADSIDE_RELOAD_MS : this\.playerBroadsideReloadMs/);
|
||||||
|
assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < reloadMs/);
|
||||||
assert.match(source, /Math\.max\(1, this\.playerBroadsideReloadMs\)/);
|
assert.match(source, /Math\.max\(1, this\.playerBroadsideReloadMs\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -263,7 +264,7 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
|
|||||||
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('multiplayer transport uses Keycloak tickets and renders remote Sparrows without physics authority', async () => {
|
test('multiplayer transport uses Keycloak tickets with server-authoritative movement and PvP', async () => {
|
||||||
const [game, entry, multiplayer, pkg, server] = await Promise.all([
|
const [game, entry, multiplayer, pkg, server] = await Promise.all([
|
||||||
read('src/game-v4.js'),
|
read('src/game-v4.js'),
|
||||||
read('src/game-entry.js'),
|
read('src/game-entry.js'),
|
||||||
@ -276,18 +277,28 @@ test('multiplayer transport uses Keycloak tickets and renders remote Sparrows wi
|
|||||||
assert.match(pkg, /"keycloak-js"/);
|
assert.match(pkg, /"keycloak-js"/);
|
||||||
assert.match(multiplayer, /clientId: 'airship-web'/);
|
assert.match(multiplayer, /clientId: 'airship-web'/);
|
||||||
assert.match(multiplayer, /\/api\/ws-ticket/);
|
assert.match(multiplayer, /\/api\/ws-ticket/);
|
||||||
assert.match(multiplayer, /Authorization:/);
|
assert.match(multiplayer, /type: 'input'/);
|
||||||
assert.match(multiplayer, /new WebSocket/);
|
assert.match(multiplayer, /type: 'fire'/);
|
||||||
|
assert.doesNotMatch(multiplayer, /type: 'state'/);
|
||||||
assert.match(game, /airship:multiplayer-snapshot/);
|
assert.match(game, /airship:multiplayer-snapshot/);
|
||||||
assert.match(game, /this\.remotePlayers = new Map\(\)/);
|
assert.match(game, /syncAuthoritativeSelf/);
|
||||||
assert.match(game, /window\.airshipMultiplayer\?\.sendState/);
|
assert.match(game, /syncMultiplayerProjectiles/);
|
||||||
assert.match(game, /this\.add\.sprite\([\s\S]*?'player-airship'/);
|
assert.match(game, /updateMultiplayerPredictedPlayer/);
|
||||||
assert.match(server, /CREATE TABLE IF NOT EXISTS player_profiles/);
|
assert.match(game, /window\.airshipMultiplayer\?\.sendInput/);
|
||||||
assert.match(server, /a\.tickets\.consume/);
|
assert.match(game, /window\.airshipMultiplayer\?\.fire/);
|
||||||
assert.match(server, /clampFloat\(msg\.X, 0, 3600\)/);
|
assert.match(game, /this\.multiplayerDead/);
|
||||||
assert.match(server, /time\.NewTicker\(100 \* time\.Millisecond\)/);
|
assert.match(server, /type inputState struct/);
|
||||||
|
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, /target\.state\.Hull = clampInt/);
|
||||||
|
assert.match(server, /target\.state\.RespawnAt/);
|
||||||
|
assert.match(server, /time\.NewTicker\(simTick\)/);
|
||||||
|
assert.match(server, /snapshotTick/);
|
||||||
|
assert.doesNotMatch(server, /msg\.X/);
|
||||||
|
assert.doesNotMatch(server, /msg\.Hull/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => {
|
test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => {
|
||||||
const [game, hud, index, entry, dockUi] = await Promise.all([
|
const [game, hud, index, entry, dockUi] = await Promise.all([
|
||||||
read('src/game-v4.js'),
|
read('src/game-v4.js'),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user