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

3
.gitignore vendored
View file

@ -12,4 +12,5 @@ target/
*.tmp
lib/
*.vscode/
*.vscode/
.aider*

View file

@ -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,
}
}

View file

@ -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
)

View file

@ -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)

View file

@ -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

View file

@ -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"`
}