Archived
1
0
Fork 0

First finished iteration of a grace system for late inputs

This commit is contained in:
Sebastian Benjamin 2025-06-07 14:44:18 -07:00
parent 7718a68991
commit 651adcc71a
6 changed files with 180 additions and 155 deletions

1
.gitignore vendored
View file

@ -13,3 +13,4 @@ target/
lib/ lib/
*.vscode/ *.vscode/
.aider*

View file

@ -38,12 +38,14 @@ func (c *Circle) GetPos() (float64, float64) {
// Bullets // Bullets
type Bullet struct { type Bullet struct {
cptr *C.Bullet cptr *C.Bullet
DeletionTick int64
} }
// Values for selecting bullet paths // Values for selecting bullet paths
const ( const (
BULLET_LINEAR = 0 BULLET_LINEAR = 0
ACTIVE_BULLET = -1
) )
func NewLinearBullet(tick int64, spawnX float64, spawnY float64, radius float64, velX float64, velY float64) *Bullet { 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(velX),
C.double(velY), C.double(velY),
), ),
DeletionTick: ACTIVE_BULLET,
} }
} }

View file

@ -1,12 +1,5 @@
package battleroyale package battleroyale
const TICK_RATE = 60
const (
PLAYER_DEATH_RESET = 0
PLAYER_ALIVE = -1
)
const ( const (
MATCH_LOADING = iota MATCH_LOADING = iota
MATCH_START MATCH_START
@ -16,11 +9,15 @@ const (
) )
const ( const (
TICK_RATE = 60
GRACE_WINDOW_TICKS = 15
ACTIVE_BULLET = -1
PLAYER_ALIVE = -1
STAGE_WIDTH float64 = 90.0 STAGE_WIDTH float64 = 90.0
STAGE_HEIGHT float64 = 160.0 STAGE_HEIGHT float64 = 160.0
BULLET_OFFSCREEN_BUFFER_WIDTH float64 = 16.0 BULLET_OFFSCREEN_BUFFER_WIDTH float64 = 16.0
PLAYER_HIT_COL_RADIUS_MULTIPLIER float64 = 0.01 PLAYER_HIT_COL_RADIUS_MULTIPLIER float64 = 0.01
PLAYER_GRAZE_COL_RADIUS_MULTIPLIER float64 = 0.04 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 GRAZE_ADDITION_MULTIPLIER int = 1000
) )

View file

@ -2,11 +2,9 @@ package battleroyale
import ( import (
"context" "context"
"danmaku/ffi"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"github.com/heroiclabs/nakama-common/runtime" "github.com/heroiclabs/nakama-common/runtime"
"math"
) )
func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool { func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool {
@ -24,7 +22,7 @@ func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool {
return false 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 { for _, msg := range messages {
_, exists := lobbyState.presences[msg.GetSessionId()] _, exists := lobbyState.presences[msg.GetSessionId()]
if !exists { if !exists {
@ -39,59 +37,13 @@ func RespondToInput(lobbyState *MatchState, messages []runtime.MatchData, logger
} }
// Store the input in the player's stage state // 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) { func BroadcastToPresences(tick int64, lobbyState *MatchState, logger *runtime.Logger, dispatcher *runtime.MatchDispatcher) {
for _, v := range lobbyState.presences { for _, v := range lobbyState.presences {
v.stageState.DeleteBulletsBeyondKillBoundary(tick) var tickData = v.stageState.MakeServerTick(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)
data, err := json.Marshal(tickData) data, err := json.Marshal(tickData)
if err != nil { if err != nil {
@ -115,10 +67,13 @@ func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB
return nil return nil
} }
RespondToInput(lobbyState, messages, &logger, tick, &dispatcher) StorePlayerInputs(lobbyState, messages, &logger, tick)
for _, playerState := range lobbyState.presences { for _, playerState := range lobbyState.presences {
playerState.stageState.MarkBulletsBeyondKillBoundary(tick)
playerState.stageState.ProcessPlayerInputs(tick) playerState.stageState.ProcessPlayerInputs(tick)
playerState.stageState.CleanupBullets(tick) playerState.stageState.HandleDeath(tick)
playerState.stageState.CleanupOldBullets(tick)
} }
BroadcastToPresences(tick, lobbyState, &logger, &dispatcher) BroadcastToPresences(tick, lobbyState, &logger, &dispatcher)

View file

@ -1,6 +1,7 @@
package battleroyale package battleroyale
import ( import (
"cmp"
"danmaku/ffi" "danmaku/ffi"
"math" "math"
"slices" "slices"
@ -8,54 +9,58 @@ import (
) )
type PlayerStageState struct { type PlayerStageState struct {
hitCol *ffi.Circle hitCol *ffi.Circle
grazeCol *ffi.Circle grazeCol *ffi.Circle
bullets []*Bullet bullets []*ffi.Bullet
updatePlayerPos bool updatePlayerPos bool
health int health int
graze int graze int
score int score int
deathTimer int deathTick int64
cancelDeath bool dead bool
playerInputs []ClientUpdate cancelDeath bool
lastInput *ClientUpdate survivedGraceWindow bool
playerInputs []*ClientUpdate
lastInput *ClientUpdate
} }
func (s *PlayerStageState) ProcessPlayerInputs(tick int64) { func (s *PlayerStageState) ProcessPlayerInputs(tick int64) {
// Sort inputs by tick // Clean up inputs outside the grace window
slices.SortFunc(s.playerInputs, func(a, b ClientUpdate) bool { s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input *ClientUpdate) bool {
return a.Tick < b.Tick return tick-input.Tick > GRACE_WINDOW_TICKS
}) })
// Replay each tick within the grace window // Sort inputs by tick
for t := tick - GRACE_WINDOW_TICKS; t < tick; t++ { slices.SortFunc(s.playerInputs, func(a, b *ClientUpdate) int {
// Find the input for the current tick return cmp.Compare(a.Tick, b.Tick)
var currentInput *ClientUpdate })
for _, input := range s.playerInputs {
if input.Tick == t {
currentInput = &input
break
}
}
// Apply the correct movement or no movement // Replay each tick within the grace window
if currentInput != nil { s.survivedGraceWindow = true
s.BoundsCheckedMove(currentInput.X, currentInput.Y) for t := tick - GRACE_WINDOW_TICKS; t < tick; t++ {
s.lastInput = currentInput // Find the input for the current tick
} else if s.lastInput != nil { var currentInput *ClientUpdate
s.BoundsCheckedMove(s.lastInput.X, s.lastInput.Y) for _, input := range s.playerInputs {
} if input.Tick == t {
currentInput = input
break
}
}
if s.CheckCollisionState(t) == PLAYER_DEAD { // Apply the correct movement or no movement
s.cancelDeath = false if currentInput != nil {
break 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 // If the player dies in the grace window, don't cancel their death
s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input ClientUpdate) bool { if s.CheckPlayerDeadOnTick(t) {
return tick-input.Tick > GRACE_WINDOW_TICKS s.survivedGraceWindow = false
}) break
}
}
} }
func NewPlayerStage() *PlayerStageState { func NewPlayerStage() *PlayerStageState {
@ -65,7 +70,7 @@ func NewPlayerStage() *PlayerStageState {
bullets: []*ffi.Bullet{}, bullets: []*ffi.Bullet{},
updatePlayerPos: true, updatePlayerPos: true,
health: 3, health: 3,
deathTimer: -1, deathTick: PLAYER_ALIVE,
} }
} }
@ -77,83 +82,146 @@ func (s *PlayerStageState) Delete() {
ffi.DestroyCircle(s.grazeCol) 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 clampedX := x < 0 || x > STAGE_WIDTH
clampedY := y < 0 || y > STAGE_HEIGHT clampedY := y < 0 || y > STAGE_HEIGHT
x = math.Max(0, math.Min(x, STAGE_WIDTH)) newX := math.Max(0, math.Min(x, STAGE_WIDTH))
y = math.Max(0, math.Min(y, STAGE_HEIGHT)) newY := math.Max(0, math.Min(y, STAGE_HEIGHT))
s.hitCol.UpdatePos(x, y) s.hitCol.UpdatePos(newX, newY)
s.grazeCol.UpdatePos(x, y) s.grazeCol.UpdatePos(newX, newY)
if clampedX || clampedY { return clampedX || clampedY
s.updatePlayerPos = true }
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 { s.bullets = slices.DeleteFunc(s.bullets, func(b *ffi.Bullet) bool {
if b.BeyondKillBoundary(tick) && b.deletionTick == 0 { if b.DeletionTick > ACTIVE_BULLET && tick-b.DeletionTick > GRACE_WINDOW_TICKS {
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 {
ffi.DestroyBullet(b) ffi.DestroyBullet(b)
return true return true
} }
return false return false
}) })
} }
func (s *PlayerStageState) UpdateDeathTimer(tick int64) { func (s *PlayerStageState) Kill(tick int64) {
// If the player is dead, decrement the death timer s.deathTick = tick
if s.deathTimer >= 0 { s.dead = true
s.deathTimer -= 1 }
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 { // If the player is currently dead and they survived the grace window, cancel their death
s.hitCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) if (s.dead == true) && s.survivedGraceWindow {
s.grazeCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) s.CancelDeath()
s.updatePlayerPos = true return
} else if s.deathTimer == PLAYER_ALIVE { }
// Use CheckCollisionState to determine if the player should be dead
if s.CheckCollisionState(tick) == PLAYER_DEAD { // If the player is currently dead and they have been dead for greater than PLAYER_DEATH_TIMER_MAX ticks, revive them
s.deathTimer = PLAYER_DEATH_TIMER_MAX 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 { if slices.ContainsFunc(s.bullets, func(b *ffi.Bullet) bool {
return b.CollidesWith(s.hitCol, tick) return b.CollidesWith(s.hitCol, tick)
}) { }) {
return PLAYER_DEAD return true
} }
return PLAYER_ALIVE return false
} }
func (s *PlayerStageState) UpdateGrazeMultiplier(tick int64) { 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) return b.CollidesWith(s.grazeCol, tick)
}) { }) {
s.graze += GRAZE_ADDITION_MULTIPLIER s.graze += GRAZE_ADDITION_MULTIPLIER
} }
return PLAYER_ALIVE
} }
func (s *PlayerStageState) AddBullet(b *ffi.Bullet) { func (s *PlayerStageState) AddBullet(b *ffi.Bullet) {
s.bullets = append(s.bullets, b) s.bullets = append(s.bullets, b)
} }
func (s *PlayerStageState) MakeServerTick(tick int64, serializedNewBullets []map[string]any) *ServerTickUpdate { func MakeTestFireBullets(tick int64) []*ffi.Bullet {
s.UpdateDeathTimer(tick) 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() hitPosX, hitPosY := s.hitCol.GetPos()
grazePosX, grazePosY := s.hitCol.GetPos() grazePosX, grazePosY := s.hitCol.GetPos()
var tickData = ServerTickUpdate{ var tickData = ServerTickUpdate{
@ -168,19 +236,16 @@ func (s *PlayerStageState) MakeServerTick(tick int64, serializedNewBullets []map
"y": grazePosY, "y": grazePosY,
"radius": STAGE_WIDTH * PLAYER_GRAZE_COL_RADIUS_MULTIPLIER, "radius": STAGE_WIDTH * PLAYER_GRAZE_COL_RADIUS_MULTIPLIER,
}, },
NewBullets: serializedNewBullets, StageStateDiff: s.GetBoardStateDiff(tick),
ForcePlayerPos: s.updatePlayerPos, ForcePlayerPos: s.updatePlayerPos,
DeathTimer: s.deathTimer,
Graze: s.graze, Graze: s.graze,
Dead: s.dead,
CancelDeath: s.cancelDeath,
DeathTick: s.deathTick,
UTCTime: float64(time.Now().UnixMilli()) / 1000.0, UTCTime: float64(time.Now().UnixMilli()) / 1000.0,
} }
if s.cancelDeath { s.cancelDeath = false
tickData.CancelDeath = true
s.cancelDeath = false
}
// When this is called, we want to transmit updatePlayerPos if it's true once and then reset
s.updatePlayerPos = false s.updatePlayerPos = false
return &tickData return &tickData

View file

@ -23,8 +23,9 @@ type PresenceState struct { // present time! hahahahahahahah!
// Struct to serialize client->server updates // Struct to serialize client->server updates
type ClientUpdate struct { type ClientUpdate struct {
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` Y float64 `json:"y"`
Tick int64 `json:"tick"`
} }
// Struct to serialize server->client updates // Struct to serialize server->client updates
@ -32,8 +33,11 @@ type ServerTickUpdate struct {
Tick int64 `json:"tick"` Tick int64 `json:"tick"`
PlayerHitPos map[string]any `json:"playerHitPos"` PlayerHitPos map[string]any `json:"playerHitPos"`
PlayerGrazePos map[string]any `json:"playerGrazePos"` PlayerGrazePos map[string]any `json:"playerGrazePos"`
NewBullets []map[string]any `json:"newBullets"` StageStateDiff []map[string]any `json:"stageStateDiff"`
ForcePlayerPos bool `json:"forcePlayerPos"` ForcePlayerPos bool `json:"forcePlayerPos"`
DeathTimer int `json:"deathTimer"` Dead bool `json:"dead"`
CancelDeath bool `json:"cancelDeath"`
DeathTick int64 `json:"deathTick"`
Graze int `json:"graze"` Graze int `json:"graze"`
UTCTime float64 `json:"utctime"`
} }