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 }