package main /* #cgo CFLAGS: -I ${SRCDIR}/lib #cgo LDFLAGS: -L ${SRCDIR}/lib -l ffi_wrapper #include "ffi-wrapper.h" #include */ 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 ) // Interface for registering match handlers type BattleRoyaleMatch struct{} type PlayerStageState struct { col *C.Circle bullets []*C.Bullet updatePlayerPos bool health int graze 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"` } 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*0.09)), bullets: []*C.Bullet{}, updatePlayerPos: true, health: 3, }, } } 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 }) var newBulletsToBroadcast = []map[string]interface{}{} // Test bullet spawning if tick%10 == 0 { velx := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate) vely := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate) radius := 0.03 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), 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": float64(radius), "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, }, NewBullets: newBulletsToBroadcast, ForcePlayerPos: v.stageState.updatePlayerPos, } 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 }