346 lines
12 KiB
Go
346 lines
12 KiB
Go
package main
|
|
|
|
/*
|
|
#cgo CFLAGS: -I ${SRCDIR}/lib
|
|
#cgo LDFLAGS: -L ${SRCDIR}/lib -l ffi_wrapper
|
|
#include "ffi-wrapper.h"
|
|
#include <stdlib.h>
|
|
*/
|
|
import "C"
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"math"
|
|
"math/rand"
|
|
"slices"
|
|
|
|
"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_COL_RADIUS_MULTIPLIER float64 = 0.01
|
|
PLAYER_DEATH_TIMER_MAX int = 180
|
|
)
|
|
|
|
// Interface for registering match handlers
|
|
type BattleRoyaleMatch struct{}
|
|
|
|
type PlayerStageState struct {
|
|
col *C.Circle
|
|
bullets []*C.Bullet
|
|
updatePlayerPos bool
|
|
health int
|
|
graze int
|
|
deathTimer int
|
|
}
|
|
|
|
type PlayerUpdate struct {
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
}
|
|
|
|
type GameTickUpdate struct {
|
|
Tick int64 `json:"tick"`
|
|
PlayerPos map[string]interface{} `json:"playerPos"`
|
|
NewBullets []map[string]interface{} `json:"newBullets"`
|
|
ForcePlayerPos bool `json:"forcePlayerPos"`
|
|
DeathTimer int `json:"deathTimer"`
|
|
}
|
|
|
|
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{
|
|
col: C.new_circle(C.double(STAGE_WIDTH*0.5), C.double(STAGE_HEIGHT-STAGE_HEIGHT*0.1), C.double(STAGE_WIDTH*PLAYER_COL_RADIUS_MULTIPLIER)),
|
|
bullets: []*C.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 {
|
|
C.destroy_bullet(bullet)
|
|
}
|
|
C.destroy_circle(playerState.stageState.col)
|
|
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.col.x = C.double(update.X)
|
|
lobbyState.presences[msg.GetSessionId()].stageState.col.y = C.double(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 *C.Bullet) bool {
|
|
if C.bullet_beyond_kill_boundary(b, C.int64_t(tick)) {
|
|
C.destroy_bullet(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.col.x = C.double(STAGE_WIDTH * 0.5)
|
|
v.stageState.col.y = C.double(STAGE_HEIGHT - STAGE_HEIGHT*0.1)
|
|
v.stageState.updatePlayerPos = true
|
|
} else { // 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 *C.Bullet) bool {
|
|
return bool(C.bullet_collides_with(b, C.int64_t(tick), v.stageState.col))
|
|
}) {
|
|
v.stageState.deathTimer = PLAYER_DEATH_TIMER_MAX
|
|
}
|
|
}
|
|
|
|
var newBulletsToBroadcast = []map[string]interface{}{}
|
|
|
|
// Test bullet spawning, only when player is alive
|
|
if tick%10 == 0 && v.stageState.deathTimer == -1 {
|
|
velx := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
|
vely := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
|
radius_multiplier := 0.01 + rand.Float64()*(0.1-0.01)
|
|
vel_x_sign := 2*rand.Intn(2) - 1
|
|
vel_y_sign := 2*rand.Intn(2) - 1
|
|
|
|
bullet := C.new_bullet(
|
|
C.uint8_t(BULLET_LINEAR),
|
|
C.int64_t(tick),
|
|
C.double(STAGE_WIDTH*rand.Float64()),
|
|
C.double(STAGE_HEIGHT*rand.Float64()),
|
|
C.double(radius_multiplier*STAGE_WIDTH),
|
|
C.double(float64(vel_x_sign)*velx),
|
|
C.double(float64(vel_y_sign)*vely),
|
|
)
|
|
|
|
v.stageState.bullets = append(v.stageState.bullets, bullet)
|
|
|
|
var x, y C.double
|
|
C.bullet_get_current_pos(bullet, C.int64_t(tick), &x, &y)
|
|
|
|
bulletData := map[string]interface{}{
|
|
"class": BULLET_LINEAR,
|
|
"tick": tick,
|
|
"x": float64(x),
|
|
"y": float64(y),
|
|
"radius_multiplier": float64(radius_multiplier),
|
|
"vel_x": float64(vel_x_sign) * velx,
|
|
"vel_y": float64(vel_y_sign) * vely,
|
|
}
|
|
|
|
newBulletsToBroadcast = append(newBulletsToBroadcast, bulletData)
|
|
}
|
|
|
|
var tickData = GameTickUpdate{
|
|
Tick: tick,
|
|
PlayerPos: map[string]interface{}{
|
|
"x": v.stageState.col.x,
|
|
"y": v.stageState.col.y,
|
|
"radius_multiplier": PLAYER_COL_RADIUS_MULTIPLIER,
|
|
},
|
|
NewBullets: newBulletsToBroadcast,
|
|
ForcePlayerPos: v.stageState.updatePlayerPos,
|
|
DeathTimer: v.stageState.deathTimer,
|
|
}
|
|
|
|
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"
|
|
|
|
if matchId, err := nk.MatchCreate(ctx, modulename, make(map[string]interface{})); err != nil {
|
|
return "", err
|
|
} else {
|
|
return matchId, nil
|
|
}
|
|
}
|
|
|
|
// main function for hooking into the nakama runtime, responsible for setting up all handlers
|
|
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
|
|
}); err != nil {
|
|
logger.Error("Unable to register match handler: %v", nil)
|
|
return err
|
|
}
|
|
|
|
// Register RPCs
|
|
if err := initializer.RegisterRpc("manual_force_create_br_match_rpc", ManualForceCreateBRMatchRPC); err != nil {
|
|
logger.Error("Unable to register: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|