Archived
1
0
Fork 0
This repository has been archived on 2026-01-19. You can view files and clone it, but cannot push or open issues or pull requests.
Danmaku/server/game-modes/battle-royale/mode.go

336 lines
11 KiB
Go

package battleroyale
import (
"context"
"danmaku/ffi"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
"math"
"slices"
)
const (
MATCH_LOADING = iota
MATCH_START
STATE_UPDATE
FINAL_PHASE
MATCH_END
)
const (
BULLET_LINEAR = 0
)
const (
STAGE_WIDTH float64 = 90.0
STAGE_HEIGHT float64 = 160.0
BULLET_KILL_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
GRAZE_ADDITION_MULTIPLIER int = 1000
)
// Interface for registering match handlers
type Match struct{}
type PlayerStageState struct {
hitCol ffi.Circle
grazeCol ffi.Circle
bullets []ffi.Bullet
updatePlayerPos bool
health int
graze int
score int
deathTimer int
}
type PlayerUpdate struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type GameTickUpdate struct {
Tick int64 `json:"tick"`
PlayerHitPos map[string]interface{} `json:"playerHitPos"`
PlayerGrazePos map[string]interface{} `json:"playerGrazePos"`
NewBullets []map[string]interface{} `json:"newBullets"`
ForcePlayerPos bool `json:"forcePlayerPos"`
DeathTimer int `json:"deathTimer"`
Graze int `json:"graze"`
}
type PresenceState struct { // present time! hahahahahahahah!
presence runtime.Presence
stageState PlayerStageState
}
type BattleRoyaleMatchState struct {
tickRate int
currentMatchPhase int
presences map[string]*PresenceState
emptyTicks int
}
// Run on match start, initializes game state and sets tick rate
func (m *Match) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
tickRate := 60 // MatchLoop invocations per second
state := &BattleRoyaleMatchState{
tickRate: tickRate,
presences: map[string]*PresenceState{},
emptyTicks: 0,
currentMatchPhase: MATCH_LOADING,
}
label := ""
return state, tickRate, label
}
// Run when a user attempts to join or rejoin a match. Responsible for deciding whether or not to let them in.
func (m *Match) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchJoin.")
return nil, false, "Failed to join match: match does not exist."
}
accepted := true
rejectedMessage := ""
return lobbyState, accepted, rejectedMessage
}
// Run when a user successfully joins a match, registers their presence in the game state
func (m *Match) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchJoin.")
return nil
}
for i := 0; i < len(presences); i++ {
lobbyState.presences[presences[i].GetSessionId()] = &PresenceState{
presence: presences[i],
stageState: PlayerStageState{
hitCol: ffi.NewCircle(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1, STAGE_WIDTH*PLAYER_HIT_COL_RADIUS_MULTIPLIER),
grazeCol: ffi.NewCircle(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1, STAGE_WIDTH*PLAYER_GRAZE_COL_RADIUS_MULTIPLIER),
bullets: []ffi.Bullet{},
updatePlayerPos: true,
health: 3,
deathTimer: -1,
},
}
}
return lobbyState
}
// Run when a user successfully leaves a match, de-registers their presence in the game state
func (m *Match) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchLeave.")
return nil
}
for i := 0; i < len(presences); i++ {
sessionID := presences[i].GetSessionId()
playerState, exists := lobbyState.presences[sessionID]
if exists {
for _, bullet := range playerState.stageState.bullets {
ffi.DestroyBullet(bullet)
}
ffi.DestroyCircle(playerState.stageState.hitCol)
ffi.DestroyCircle(playerState.stageState.grazeCol)
delete(lobbyState.presences, sessionID)
}
}
return lobbyState
}
// Run when a match gets an arbitrary signal from the Nakama runtime (probably from the matchmaker/match lister APIs)
func (m *Match) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchSignal.")
return nil, "Failed to get valid state for return signal"
}
returnMessage := ""
return lobbyState, returnMessage
}
// Run when the server enters the graceful shutdown flow. Gives the match a chance to shutdown cleanly within graceSeconds.
func (m *Match) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchTerminate.")
return nil
}
return lobbyState
}
// Main game loop, executed at tickRate per second specified in MatchInit
func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
lobbyState, ok := state.(*BattleRoyaleMatchState)
if !ok {
logger.Error("State is not a valid lobby state object for MatchLoop.")
return nil
}
// If we have no presences in the match according to the match state, increment the empty ticks count
if len(lobbyState.presences) == 0 {
lobbyState.emptyTicks++
}
// If the match has been empty for more than 100 ticks, end the match by returning nil
if lobbyState.emptyTicks > 100 {
return nil
}
// Respond to player input
for _, msg := range messages {
// Validate player existence
_, exists := lobbyState.presences[msg.GetSessionId()]
if !exists {
logger.Warn("Received input for non-existent player session ID: %v", msg.GetSessionId())
continue
}
// Parse player message
var update PlayerUpdate
if err := json.Unmarshal(msg.GetData(), &update); err != nil {
logger.Warn("Failed to parse input: %v", err)
continue
}
// Player movement bounds detection
clampedX := update.X < 0 || update.X > STAGE_WIDTH
clampedY := update.Y < 0 || update.Y > STAGE_HEIGHT
update.X = math.Max(0, math.Min(update.X, STAGE_WIDTH))
update.Y = math.Max(0, math.Min(update.Y, STAGE_HEIGHT))
lobbyState.presences[msg.GetSessionId()].stageState.hitCol.UpdatePos(update.X, update.Y)
lobbyState.presences[msg.GetSessionId()].stageState.grazeCol.UpdatePos(update.X, update.Y)
if clampedX || clampedY {
lobbyState.presences[msg.GetSessionId()].stageState.updatePlayerPos = true
}
}
// Compute and broadcast per-presence state
for _, v := range lobbyState.presences {
// Clean up bullets when they pass off the board
v.stageState.bullets = slices.DeleteFunc(v.stageState.bullets, func(b ffi.Bullet) bool {
if b.BeyondKillBoundary(tick) {
ffi.DestroyBullet(b)
return true
}
return false
})
// If the player is dead. Decrement the death timer
if v.stageState.deathTimer >= 0 {
v.stageState.deathTimer -= 1
}
if v.stageState.deathTimer == 0 { // If the player's death timer has run out, reset them. 0 is a special deathTimer tick that indicates reset to the clients.
v.stageState.hitCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1)
v.stageState.grazeCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1)
v.stageState.updatePlayerPos = true
} else if v.stageState.deathTimer == -1 { // If the player is alive, check if the player collided with a bullet and kill them if so
if slices.ContainsFunc(v.stageState.bullets, func(b ffi.Bullet) bool {
return b.CollidesWith(v.stageState.hitCol, tick)
}) {
v.stageState.deathTimer = PLAYER_DEATH_TIMER_MAX
} else if slices.ContainsFunc(v.stageState.bullets, func(b ffi.Bullet) bool { // Otherwise, check the graze col and increment the graze and score
return b.CollidesWith(v.stageState.grazeCol, tick)
}) {
v.stageState.graze += GRAZE_ADDITION_MULTIPLIER
}
}
var newBulletsToBroadcast = []map[string]interface{}{}
if tick%30 == 0 && v.stageState.deathTimer == -1 {
numBullets := 20
spreadAngle := 60.0 // Spread in degrees
startAngle := 90 - (spreadAngle / 2)
bulletSpeed := STAGE_WIDTH / float64(lobbyState.tickRate) / 3
bulletRadiusMult := 0.03
// 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 := 0; i < numBullets; i++ {
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,
bulletRadiusMult*STAGE_WIDTH,
velx,
vely,
)
v.stageState.bullets = append(v.stageState.bullets, bullet)
x, y := bullet.GetPos(tick)
bulletData := map[string]interface{}{
"class": BULLET_LINEAR,
"tick": tick,
"x": x,
"y": y,
"radius_multiplier": bulletRadiusMult,
"vel_x": velx,
"vel_y": vely,
}
newBulletsToBroadcast = append(newBulletsToBroadcast, bulletData)
}
}
hitPosX, hitPosY := v.stageState.hitCol.GetPos()
grazePosX, grazePosY := v.stageState.hitCol.GetPos()
var tickData = GameTickUpdate{
Tick: tick,
PlayerHitPos: map[string]interface{}{
"x": hitPosX,
"y": hitPosY,
"radius_multiplier": PLAYER_HIT_COL_RADIUS_MULTIPLIER,
},
PlayerGrazePos: map[string]interface{}{
"x": grazePosX,
"y": grazePosY,
"radius_multiplier": PLAYER_GRAZE_COL_RADIUS_MULTIPLIER,
},
NewBullets: newBulletsToBroadcast,
ForcePlayerPos: v.stageState.updatePlayerPos,
DeathTimer: v.stageState.deathTimer,
Graze: v.stageState.graze,
}
v.stageState.updatePlayerPos = false
data, err := json.Marshal(tickData)
if err != nil {
logger.Error("Error marshalling bullet data", err)
} else {
reliable := true
dispatcher.BroadcastMessage(STATE_UPDATE, data, nil, nil, reliable)
}
}
return lobbyState
}