First finished iteration of a grace system for late inputs
This commit is contained in:
parent
7718a68991
commit
651adcc71a
6 changed files with 180 additions and 155 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,3 +13,4 @@ target/
|
|||
|
||||
lib/
|
||||
*.vscode/
|
||||
.aider*
|
||||
|
|
|
|||
|
|
@ -39,11 +39,13 @@ func (c *Circle) GetPos() (float64, float64) {
|
|||
// Bullets
|
||||
type Bullet struct {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package battleroyale
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"danmaku/ffi"
|
||||
"math"
|
||||
"slices"
|
||||
|
|
@ -10,52 +11,56 @@ import (
|
|||
type PlayerStageState struct {
|
||||
hitCol *ffi.Circle
|
||||
grazeCol *ffi.Circle
|
||||
bullets []*Bullet
|
||||
bullets []*ffi.Bullet
|
||||
updatePlayerPos bool
|
||||
health int
|
||||
graze int
|
||||
score int
|
||||
deathTimer int
|
||||
deathTick int64
|
||||
dead bool
|
||||
cancelDeath bool
|
||||
playerInputs []ClientUpdate
|
||||
survivedGraceWindow bool
|
||||
playerInputs []*ClientUpdate
|
||||
lastInput *ClientUpdate
|
||||
}
|
||||
|
||||
func (s *PlayerStageState) ProcessPlayerInputs(tick int64) {
|
||||
// Clean up inputs outside the grace window
|
||||
s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input *ClientUpdate) bool {
|
||||
return tick-input.Tick > GRACE_WINDOW_TICKS
|
||||
})
|
||||
|
||||
// Sort inputs by tick
|
||||
slices.SortFunc(s.playerInputs, func(a, b ClientUpdate) bool {
|
||||
return a.Tick < b.Tick
|
||||
slices.SortFunc(s.playerInputs, func(a, b *ClientUpdate) int {
|
||||
return cmp.Compare(a.Tick, b.Tick)
|
||||
})
|
||||
|
||||
// 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
|
||||
currentInput = input
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the correct movement or no movement
|
||||
if currentInput != nil {
|
||||
s.BoundsCheckedMove(currentInput.X, currentInput.Y)
|
||||
s.clampedMove(currentInput.X, currentInput.Y)
|
||||
s.lastInput = currentInput
|
||||
} else if s.lastInput != nil {
|
||||
s.BoundsCheckedMove(s.lastInput.X, s.lastInput.Y)
|
||||
s.clampedMove(s.lastInput.X, s.lastInput.Y)
|
||||
}
|
||||
|
||||
if s.CheckCollisionState(t) == PLAYER_DEAD {
|
||||
s.cancelDeath = false
|
||||
// If the player dies in the grace window, don't cancel their death
|
||||
if s.CheckPlayerDeadOnTick(t) {
|
||||
s.survivedGraceWindow = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up inputs outside the grace window
|
||||
s.playerInputs = slices.DeleteFunc(s.playerInputs, func(input ClientUpdate) bool {
|
||||
return tick-input.Tick > GRACE_WINDOW_TICKS
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if s.deathTimer == PLAYER_DEATH_RESET {
|
||||
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
|
||||
} 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
|
||||
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 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.updatePlayerPos = false
|
||||
|
||||
return &tickData
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type PresenceState struct { // present time! hahahahahahahah!
|
|||
type ClientUpdate struct {
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue