From 651adcc71ae7972f882dc3952c27c244a11c837c Mon Sep 17 00:00:00 2001 From: Sebastian Benjamin Date: Sat, 7 Jun 2025 14:44:18 -0700 Subject: [PATCH] First finished iteration of a grace system for late inputs --- .gitignore | 3 +- server/ffi/ffi.go | 5 +- server/game-modes/battle-royale/consts.go | 13 +- server/game-modes/battle-royale/game-loop.go | 61 +---- .../game-modes/battle-royale/player-stage.go | 241 +++++++++++------- server/game-modes/battle-royale/types.go | 12 +- 6 files changed, 180 insertions(+), 155 deletions(-) diff --git a/.gitignore b/.gitignore index 5641b27..b4d57cd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ target/ *.tmp lib/ -*.vscode/ \ No newline at end of file +*.vscode/ +.aider* diff --git a/server/ffi/ffi.go b/server/ffi/ffi.go index b75b0e3..75c0a94 100644 --- a/server/ffi/ffi.go +++ b/server/ffi/ffi.go @@ -38,12 +38,14 @@ func (c *Circle) GetPos() (float64, float64) { // Bullets type Bullet struct { - cptr *C.Bullet + cptr *C.Bullet + DeletionTick int64 } // Values for selecting bullet paths const ( BULLET_LINEAR = 0 + ACTIVE_BULLET = -1 ) func NewLinearBullet(tick int64, spawnX float64, spawnY float64, radius float64, velX float64, velY float64) *Bullet { @@ -57,6 +59,7 @@ func NewLinearBullet(tick int64, spawnX float64, spawnY float64, radius float64, C.double(velX), C.double(velY), ), + DeletionTick: ACTIVE_BULLET, } } diff --git a/server/game-modes/battle-royale/consts.go b/server/game-modes/battle-royale/consts.go index ed0abe0..b476401 100644 --- a/server/game-modes/battle-royale/consts.go +++ b/server/game-modes/battle-royale/consts.go @@ -1,12 +1,5 @@ package battleroyale -const TICK_RATE = 60 - -const ( - PLAYER_DEATH_RESET = 0 - PLAYER_ALIVE = -1 -) - const ( MATCH_LOADING = iota MATCH_START @@ -16,11 +9,15 @@ const ( ) const ( + TICK_RATE = 60 + GRACE_WINDOW_TICKS = 15 + ACTIVE_BULLET = -1 + PLAYER_ALIVE = -1 STAGE_WIDTH float64 = 90.0 STAGE_HEIGHT float64 = 160.0 BULLET_OFFSCREEN_BUFFER_WIDTH float64 = 16.0 PLAYER_HIT_COL_RADIUS_MULTIPLIER float64 = 0.01 PLAYER_GRAZE_COL_RADIUS_MULTIPLIER float64 = 0.04 - PLAYER_DEATH_TIMER_MAX int = 180 + PLAYER_DEATH_TIMER_MAX int64 = 180 GRAZE_ADDITION_MULTIPLIER int = 1000 ) diff --git a/server/game-modes/battle-royale/game-loop.go b/server/game-modes/battle-royale/game-loop.go index 4190d5d..ebbde42 100644 --- a/server/game-modes/battle-royale/game-loop.go +++ b/server/game-modes/battle-royale/game-loop.go @@ -2,11 +2,9 @@ package battleroyale import ( "context" - "danmaku/ffi" "database/sql" "encoding/json" "github.com/heroiclabs/nakama-common/runtime" - "math" ) func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool { @@ -24,7 +22,7 @@ func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool { return false } -func RespondToInput(lobbyState *MatchState, messages []runtime.MatchData, logger *runtime.Logger, tick int64, dispatcher *runtime.MatchDispatcher) { +func StorePlayerInputs(lobbyState *MatchState, messages []runtime.MatchData, logger *runtime.Logger, tick int64) { for _, msg := range messages { _, exists := lobbyState.presences[msg.GetSessionId()] if !exists { @@ -39,59 +37,13 @@ func RespondToInput(lobbyState *MatchState, messages []runtime.MatchData, logger } // Store the input in the player's stage state - lobbyState.presences[msg.GetSessionId()].stageState.playerInputs = append(lobbyState.presences[msg.GetSessionId()].stageState.playerInputs, update) + lobbyState.presences[msg.GetSessionId()].stageState.playerInputs = append(lobbyState.presences[msg.GetSessionId()].stageState.playerInputs, &update) } } -func TestFireBullets(tick int64) []*ffi.Bullet { - var bullets = []*ffi.Bullet{} - if tick%30 == 0 { - numBullets := 20 - spreadAngle := 60.0 - startAngle := 90 - (spreadAngle / 2) - bulletSpeed := STAGE_WIDTH / float64(TICK_RATE) / 3 - bulletRadius := 0.01 * STAGE_WIDTH - - // Define a single spawn point near the top of the screen - spawnX := STAGE_WIDTH / 2 // Centered horizontally - spawnY := STAGE_HEIGHT * 0.1 // 10% from the top - - for i := range numBullets { - angle := startAngle + (spreadAngle/float64(numBullets-1))*float64(i) - angleRad := angle * (math.Pi / 180.0) - - velx := bulletSpeed * math.Cos(angleRad) - vely := bulletSpeed * math.Sin(angleRad) - - bullet := ffi.NewLinearBullet( - tick, - spawnX, - spawnY, - bulletRadius, - velx, - vely, - ) - bullets = append(bullets, bullet) - } - } - return bullets -} - func BroadcastToPresences(tick int64, lobbyState *MatchState, logger *runtime.Logger, dispatcher *runtime.MatchDispatcher) { for _, v := range lobbyState.presences { - v.stageState.DeleteBulletsBeyondKillBoundary(tick) - - var newBulletsToBroadcast = []map[string]any{} - if v.stageState.CheckCollisionState(tick) == PLAYER_ALIVE { - newBullets := TestFireBullets(tick) - - for _, bullet := range newBullets { - v.stageState.AddBullet(bullet) - newBulletsToBroadcast = append(newBulletsToBroadcast, bullet.Serialize(tick)) - } - } - - var tickData = v.stageState.MakeServerTick(tick, newBulletsToBroadcast) + var tickData = v.stageState.MakeServerTick(tick) data, err := json.Marshal(tickData) if err != nil { @@ -115,10 +67,13 @@ func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB return nil } - RespondToInput(lobbyState, messages, &logger, tick, &dispatcher) + StorePlayerInputs(lobbyState, messages, &logger, tick) + for _, playerState := range lobbyState.presences { + playerState.stageState.MarkBulletsBeyondKillBoundary(tick) playerState.stageState.ProcessPlayerInputs(tick) - playerState.stageState.CleanupBullets(tick) + playerState.stageState.HandleDeath(tick) + playerState.stageState.CleanupOldBullets(tick) } BroadcastToPresences(tick, lobbyState, &logger, &dispatcher) diff --git a/server/game-modes/battle-royale/player-stage.go b/server/game-modes/battle-royale/player-stage.go index 115ef73..a2aacfb 100644 --- a/server/game-modes/battle-royale/player-stage.go +++ b/server/game-modes/battle-royale/player-stage.go @@ -1,6 +1,7 @@ package battleroyale import ( + "cmp" "danmaku/ffi" "math" "slices" @@ -8,54 +9,58 @@ import ( ) type PlayerStageState struct { - hitCol *ffi.Circle - grazeCol *ffi.Circle - bullets []*Bullet - updatePlayerPos bool - health int - graze int - score int - deathTimer int - cancelDeath bool - playerInputs []ClientUpdate - lastInput *ClientUpdate + hitCol *ffi.Circle + grazeCol *ffi.Circle + bullets []*ffi.Bullet + updatePlayerPos bool + health int + graze int + score int + deathTick int64 + dead bool + cancelDeath bool + survivedGraceWindow bool + playerInputs []*ClientUpdate + lastInput *ClientUpdate } func (s *PlayerStageState) ProcessPlayerInputs(tick int64) { - // Sort inputs by tick - slices.SortFunc(s.playerInputs, func(a, b ClientUpdate) bool { - return a.Tick < b.Tick - }) + // Clean up inputs outside the grace window + s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input *ClientUpdate) bool { + return tick-input.Tick > GRACE_WINDOW_TICKS + }) - // Replay each tick within the grace window - for t := tick - GRACE_WINDOW_TICKS; t < tick; t++ { - // Find the input for the current tick - var currentInput *ClientUpdate - for _, input := range s.playerInputs { - if input.Tick == t { - currentInput = &input - break - } - } + // Sort inputs by tick + slices.SortFunc(s.playerInputs, func(a, b *ClientUpdate) int { + return cmp.Compare(a.Tick, b.Tick) + }) - // Apply the correct movement or no movement - if currentInput != nil { - s.BoundsCheckedMove(currentInput.X, currentInput.Y) - s.lastInput = currentInput - } else if s.lastInput != nil { - s.BoundsCheckedMove(s.lastInput.X, s.lastInput.Y) - } + // Replay each tick within the grace window + s.survivedGraceWindow = true + for t := tick - GRACE_WINDOW_TICKS; t < tick; t++ { + // Find the input for the current tick + var currentInput *ClientUpdate + for _, input := range s.playerInputs { + if input.Tick == t { + currentInput = input + break + } + } - if s.CheckCollisionState(t) == PLAYER_DEAD { - s.cancelDeath = false - break - } - } + // Apply the correct movement or no movement + if currentInput != nil { + s.clampedMove(currentInput.X, currentInput.Y) + s.lastInput = currentInput + } else if s.lastInput != nil { + s.clampedMove(s.lastInput.X, s.lastInput.Y) + } - // Clean up inputs outside the grace window - s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input ClientUpdate) bool { - return tick-input.Tick > GRACE_WINDOW_TICKS - }) + // If the player dies in the grace window, don't cancel their death + if s.CheckPlayerDeadOnTick(t) { + s.survivedGraceWindow = false + break + } + } } func NewPlayerStage() *PlayerStageState { @@ -65,7 +70,7 @@ func NewPlayerStage() *PlayerStageState { bullets: []*ffi.Bullet{}, updatePlayerPos: true, health: 3, - deathTimer: -1, + deathTick: PLAYER_ALIVE, } } @@ -77,83 +82,146 @@ func (s *PlayerStageState) Delete() { ffi.DestroyCircle(s.grazeCol) } -func (s *PlayerStageState) BoundsCheckedMove(x float64, y float64) { +func (s *PlayerStageState) clampedMove(x, y float64) bool { clampedX := x < 0 || x > STAGE_WIDTH clampedY := y < 0 || y > STAGE_HEIGHT - x = math.Max(0, math.Min(x, STAGE_WIDTH)) - y = math.Max(0, math.Min(y, STAGE_HEIGHT)) + newX := math.Max(0, math.Min(x, STAGE_WIDTH)) + newY := math.Max(0, math.Min(y, STAGE_HEIGHT)) - s.hitCol.UpdatePos(x, y) - s.grazeCol.UpdatePos(x, y) + s.hitCol.UpdatePos(newX, newY) + s.grazeCol.UpdatePos(newX, newY) - if clampedX || clampedY { - s.updatePlayerPos = true + return clampedX || clampedY +} + +func (s *PlayerStageState) MarkBulletsBeyondKillBoundary(tick int64) { + for _, b := range s.bullets { + if b.BeyondKillBoundary(tick) && b.DeletionTick == ACTIVE_BULLET { + b.DeletionTick = tick + } } } -func (s *PlayerStageState) DeleteBulletsBeyondKillBoundary(tick int64) { +func (s *PlayerStageState) CleanupOldBullets(tick int64) { s.bullets = slices.DeleteFunc(s.bullets, func(b *ffi.Bullet) bool { - if b.BeyondKillBoundary(tick) && b.deletionTick == 0 { - b.deletionTick = tick - } - }) -} - -func (s *PlayerStageState) CleanupBullets(tick int64) { - s.bullets = slices.DeleteFunc(s.bullets, func(b *Bullet) bool { - if b.deletionTick > 0 && tick-b.deletionTick > GRACE_WINDOW_TICKS { + if b.DeletionTick > ACTIVE_BULLET && tick-b.DeletionTick > GRACE_WINDOW_TICKS { ffi.DestroyBullet(b) return true } return false }) - } -func (s *PlayerStageState) UpdateDeathTimer(tick int64) { - // If the player is dead, decrement the death timer - if s.deathTimer >= 0 { - s.deathTimer -= 1 +func (s *PlayerStageState) Kill(tick int64) { + s.deathTick = tick + s.dead = true +} + +func (s *PlayerStageState) Revive() { + s.hitCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) + s.grazeCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) + s.updatePlayerPos = true + s.playerInputs = nil + s.dead = false + s.deathTick = PLAYER_ALIVE +} + +func (s *PlayerStageState) CancelDeath() { + s.dead = false + s.deathTick = PLAYER_ALIVE + s.cancelDeath = true +} + +func (s *PlayerStageState) HandleDeath(tick int64) { + // If the player didn't survive the grace window and they're currently alive, kill them + if !(s.survivedGraceWindow) && (s.dead == false) { + s.Kill(tick) + return } - if s.deathTimer == PLAYER_DEATH_RESET { - s.hitCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) - s.grazeCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) - s.updatePlayerPos = true - } else if s.deathTimer == PLAYER_ALIVE { - // Use CheckCollisionState to determine if the player should be dead - if s.CheckCollisionState(tick) == PLAYER_DEAD { - s.deathTimer = PLAYER_DEATH_TIMER_MAX - } + // If the player is currently dead and they survived the grace window, cancel their death + if (s.dead == true) && s.survivedGraceWindow { + s.CancelDeath() + return + } + + // If the player is currently dead and they have been dead for greater than PLAYER_DEATH_TIMER_MAX ticks, revive them + if s.dead == true && ((tick - s.deathTick) > PLAYER_DEATH_TIMER_MAX) { + s.Revive() + return } } -func (s *PlayerStageState) CheckCollisionState(tick int64) int { +func (s *PlayerStageState) CheckPlayerDeadOnTick(tick int64) bool { if slices.ContainsFunc(s.bullets, func(b *ffi.Bullet) bool { return b.CollidesWith(s.hitCol, tick) }) { - return PLAYER_DEAD + return true } - return PLAYER_ALIVE + return false } func (s *PlayerStageState) UpdateGrazeMultiplier(tick int64) { - if slices.ContainsFunc(s.bullets, func(b *Bullet) bool { + if slices.ContainsFunc(s.bullets, func(b *ffi.Bullet) bool { return b.CollidesWith(s.grazeCol, tick) }) { s.graze += GRAZE_ADDITION_MULTIPLIER } - return PLAYER_ALIVE } func (s *PlayerStageState) AddBullet(b *ffi.Bullet) { s.bullets = append(s.bullets, b) } -func (s *PlayerStageState) MakeServerTick(tick int64, serializedNewBullets []map[string]any) *ServerTickUpdate { - s.UpdateDeathTimer(tick) +func MakeTestFireBullets(tick int64) []*ffi.Bullet { + var bullets = []*ffi.Bullet{} + if tick%30 == 0 { + numBullets := 20 + spreadAngle := 60.0 + startAngle := 90 - (spreadAngle / 2) + bulletSpeed := STAGE_WIDTH / float64(TICK_RATE) / 3 + bulletRadius := 0.01 * STAGE_WIDTH + // Define a single spawn point near the top of the screen + spawnX := STAGE_WIDTH / 2 // Centered horizontally + spawnY := STAGE_HEIGHT * 0.1 // 10% from the top + + for i := range numBullets { + angle := startAngle + (spreadAngle/float64(numBullets-1))*float64(i) + angleRad := angle * (math.Pi / 180.0) + + velx := bulletSpeed * math.Cos(angleRad) + vely := bulletSpeed * math.Sin(angleRad) + + bullet := ffi.NewLinearBullet( + tick, + spawnX, + spawnY, + bulletRadius, + velx, + vely, + ) + bullets = append(bullets, bullet) + } + } + return bullets +} + +func (s *PlayerStageState) GetBoardStateDiff(tick int64) []map[string]any { + var newBulletsToBroadcast = []map[string]any{} + if !s.dead { + newBullets := MakeTestFireBullets(tick) + + for _, bullet := range newBullets { + s.AddBullet(bullet) + newBulletsToBroadcast = append(newBulletsToBroadcast, bullet.Serialize(tick)) + } + } + return newBulletsToBroadcast +} + +func (s *PlayerStageState) MakeServerTick(tick int64) *ServerTickUpdate { hitPosX, hitPosY := s.hitCol.GetPos() grazePosX, grazePosY := s.hitCol.GetPos() var tickData = ServerTickUpdate{ @@ -168,19 +236,16 @@ func (s *PlayerStageState) MakeServerTick(tick int64, serializedNewBullets []map "y": grazePosY, "radius": STAGE_WIDTH * PLAYER_GRAZE_COL_RADIUS_MULTIPLIER, }, - NewBullets: serializedNewBullets, + StageStateDiff: s.GetBoardStateDiff(tick), ForcePlayerPos: s.updatePlayerPos, - DeathTimer: s.deathTimer, Graze: s.graze, + Dead: s.dead, + CancelDeath: s.cancelDeath, + DeathTick: s.deathTick, UTCTime: float64(time.Now().UnixMilli()) / 1000.0, } - if s.cancelDeath { - tickData.CancelDeath = true - s.cancelDeath = false - } - - // When this is called, we want to transmit updatePlayerPos if it's true once and then reset + s.cancelDeath = false s.updatePlayerPos = false return &tickData diff --git a/server/game-modes/battle-royale/types.go b/server/game-modes/battle-royale/types.go index ed3e539..9fe299c 100644 --- a/server/game-modes/battle-royale/types.go +++ b/server/game-modes/battle-royale/types.go @@ -23,8 +23,9 @@ type PresenceState struct { // present time! hahahahahahahah! // Struct to serialize client->server updates type ClientUpdate struct { - X float64 `json:"x"` - Y float64 `json:"y"` + X float64 `json:"x"` + Y float64 `json:"y"` + Tick int64 `json:"tick"` } // Struct to serialize server->client updates @@ -32,8 +33,11 @@ type ServerTickUpdate struct { Tick int64 `json:"tick"` PlayerHitPos map[string]any `json:"playerHitPos"` PlayerGrazePos map[string]any `json:"playerGrazePos"` - NewBullets []map[string]any `json:"newBullets"` + StageStateDiff []map[string]any `json:"stageStateDiff"` ForcePlayerPos bool `json:"forcePlayerPos"` - DeathTimer int `json:"deathTimer"` + Dead bool `json:"dead"` + CancelDeath bool `json:"cancelDeath"` + DeathTick int64 `json:"deathTick"` Graze int `json:"graze"` + UTCTime float64 `json:"utctime"` }