Split into multiple modules
This commit is contained in:
parent
234b1df8f3
commit
58c11048a9
4 changed files with 340 additions and 331 deletions
2
Makefile
2
Makefile
|
|
@ -16,7 +16,7 @@ else
|
|||
RMDIR = rm -rf "$(SERVER_LIB_DIR)"
|
||||
endif
|
||||
|
||||
SERVER_LIB_DIR = server$(PATH_SEP)lib
|
||||
SERVER_LIB_DIR = server$(PATH_SEP)ffi$(PATH_SEP)lib
|
||||
FFI_LIB = ffi-wrapper$(PATH_SEP)target$(PATH_SEP)$(TARGET)$(PATH_SEP)release$(PATH_SEP)libffi_wrapper.a
|
||||
FFI_HEADER = ffi-wrapper$(PATH_SEP)target$(PATH_SEP)ffi-wrapper.h
|
||||
GODOT_LIB = godot-extension$(PATH_SEP)target$(PATH_SEP)release$(PATH_SEP)godot_extension.dll
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package ffi
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I ${SRCDIR}/lib
|
||||
336
server/game-modes/battle-royale/mode.go
Normal file
336
server/game-modes/battle-royale/mode.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
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
|
||||
}
|
||||
331
server/main.go
331
server/main.go
|
|
@ -3,338 +3,11 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"danmaku/game-modes/battle-royale"
|
||||
"github.com/heroiclabs/nakama-common/runtime"
|
||||
)
|
||||
|
||||
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 BattleRoyaleMatch struct{}
|
||||
|
||||
type PlayerStageState struct {
|
||||
hitCol Circle
|
||||
grazeCol Circle
|
||||
bullets []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 *BattleRoyaleMatch) 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 *BattleRoyaleMatch) 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 *BattleRoyaleMatch) 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: NewCircle(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1, STAGE_WIDTH*PLAYER_HIT_COL_RADIUS_MULTIPLIER),
|
||||
grazeCol: NewCircle(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1, STAGE_WIDTH*PLAYER_GRAZE_COL_RADIUS_MULTIPLIER),
|
||||
bullets: []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 *BattleRoyaleMatch) 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 {
|
||||
DestroyBullet(bullet)
|
||||
}
|
||||
DestroyCircle(playerState.stageState.hitCol)
|
||||
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 *BattleRoyaleMatch) 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 *BattleRoyaleMatch) 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 *BattleRoyaleMatch) 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 Bullet) bool {
|
||||
if b.BeyondKillBoundary(tick) {
|
||||
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 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 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 := 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
|
||||
}
|
||||
|
||||
// RPC for force-creating a match for debugging/development, separate from the matchmaking process
|
||||
func ManualForceCreateBRMatchRPC(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||||
modulename := "battle-royale"
|
||||
|
|
@ -350,7 +23,7 @@ func ManualForceCreateBRMatchRPC(ctx context.Context, logger runtime.Logger, db
|
|||
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
|
||||
// Register handlers for match lifecycle
|
||||
if err := initializer.RegisterMatch("battle-royale", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) {
|
||||
return &BattleRoyaleMatch{}, nil
|
||||
return &battleroyale.Match{}, nil
|
||||
}); err != nil {
|
||||
logger.Error("Unable to register match handler: %v", nil)
|
||||
return err
|
||||
|
|
|
|||
Reference in a new issue