From 265bf6140615ea6182baa1f57647ccceb8aca26a Mon Sep 17 00:00:00 2001 From: Sebastian Benjamin Date: Tue, 29 Apr 2025 19:52:15 -0700 Subject: [PATCH] Server-side refactor --- client/danmaku!/Game.tscn | 6 +- client/danmaku!/ScalableSprite2D.gd | 4 +- client/danmaku!/network_manager.gd | 8 +- client/danmaku!/player.gd | 20 +- server/ffi/ffi.go | 27 ++ server/game-modes/battle-royale/consts.go | 26 ++ server/game-modes/battle-royale/game-loop.go | 122 +++++++ server/game-modes/battle-royale/mode.go | 318 ------------------ .../game-modes/battle-royale/player-stage.go | 92 +++++ .../presence-management-hooks.go | 96 ++++++ server/game-modes/battle-royale/types.go | 39 +++ server/main.go | 2 +- 12 files changed, 423 insertions(+), 337 deletions(-) create mode 100644 server/game-modes/battle-royale/consts.go create mode 100644 server/game-modes/battle-royale/game-loop.go delete mode 100644 server/game-modes/battle-royale/mode.go create mode 100644 server/game-modes/battle-royale/presence-management-hooks.go create mode 100644 server/game-modes/battle-royale/types.go diff --git a/client/danmaku!/Game.tscn b/client/danmaku!/Game.tscn index f39d700..6e000a1 100644 --- a/client/danmaku!/Game.tscn +++ b/client/danmaku!/Game.tscn @@ -4,7 +4,11 @@ [node name="Game" type="Node"] -[node name="BoardRenderer" parent="." instance=ExtResource("1_pv3ov")] +[node name="NetworkManager" type="Node" parent="."] + +[node name="Boards" type="Node3D" parent="."] + +[node name="PlayerBoard" parent="Boards" instance=ExtResource("1_pv3ov")] [node name="Camera3D" type="Camera3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.699727) diff --git a/client/danmaku!/ScalableSprite2D.gd b/client/danmaku!/ScalableSprite2D.gd index 5eff03d..84694d8 100644 --- a/client/danmaku!/ScalableSprite2D.gd +++ b/client/danmaku!/ScalableSprite2D.gd @@ -1,6 +1,6 @@ class_name ScalableSprite2D extends Sprite2D -func scale_sprite(world_space_multiple: float): - var scale_factor = (world_space_multiple * Globals.SERVER_SIZE.x) / self.texture.get_width() +func scale_sprite(world_space_width: float): + var scale_factor = world_space_width / self.texture.get_width() self.scale = Vector2(scale_factor, scale_factor) diff --git a/client/danmaku!/network_manager.gd b/client/danmaku!/network_manager.gd index 9bcbe4a..5524f1a 100644 --- a/client/danmaku!/network_manager.gd +++ b/client/danmaku!/network_manager.gd @@ -35,8 +35,8 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData): float(data["playerHitPos"]["x"]), float(data["playerHitPos"]["y"]) ), - float(data["playerHitPos"]["radius_multiplier"]), - float(data["playerGrazePos"]["radius_multiplier"]) + float(data["playerHitPos"]["radius"]), + float(data["playerGrazePos"]["radius"]) ) %GrazeLabel.text = "Graze: " + str(data["graze"]) @@ -54,14 +54,14 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData): int(b["tick"]), b["x"], b["y"], - b["radius_multiplier"] * Globals.SERVER_SIZE.x, + b["radius"], b["vel_x"], b["vel_y"]) bullet.texture = load("res://test-bullet.png") bullet.position = bullet.get_current_pos(int(b["tick"])) # Reimplemented from ScalableSprite2D here atm - var scale_ratio = ((b["radius_multiplier"] * 2) * Globals.SERVER_SIZE.x) / bullet.texture.get_width() + var scale_ratio = (b["radius"] * 2) / bullet.texture.get_width() bullet.scale = Vector2(scale_ratio, scale_ratio) bullet.z_index = 4 diff --git a/client/danmaku!/player.gd b/client/danmaku!/player.gd index 16465c6..704f239 100644 --- a/client/danmaku!/player.gd +++ b/client/danmaku!/player.gd @@ -5,13 +5,11 @@ extends Node2D var velocity := Vector2.ZERO var hurt_collision: DanmakuCircle = DanmakuCircle.new() var graze_collision: DanmakuCircle = DanmakuCircle.new() - -const PLAYER_BODY_WIDTH_MULTIPLIER = 0.18 # This is temporary, it should be defined per-sprite when I get to the skin system - +const PLAYER_BODY_WIDTH_MULTIPLIER = 0.18 var alive: bool = true func _ready() -> void: - $BodySprite.scale_sprite(PLAYER_BODY_WIDTH_MULTIPLIER) + $BodySprite.scale_sprite(PLAYER_BODY_WIDTH_MULTIPLIER * Globals.SERVER_SIZE.x) func get_input(): if Input.is_action_pressed("Slow Mode"): @@ -33,18 +31,18 @@ func _physics_process(delta: float): set_position_data(attempted_position, null, null) -func set_position_data(pos: Vector2, hurtcircle_scale_multiplier, grazecircle_scale_multiplier): +func set_position_data(pos: Vector2, hurtcircle_radius, grazecircle_radius): position = pos hurt_collision.set_position(pos.x, pos.y) graze_collision.set_position(pos.x, pos.y) - if hurtcircle_scale_multiplier: - hurt_collision.set_radius(Globals.SERVER_SIZE.x*hurtcircle_scale_multiplier) - $HurtcircleSprite.scale_sprite(hurtcircle_scale_multiplier*2) + if hurtcircle_radius: + hurt_collision.set_radius(hurtcircle_radius) + $HurtcircleSprite.scale_sprite(hurtcircle_radius*2) - if grazecircle_scale_multiplier: - graze_collision.set_radius(Globals.SERVER_SIZE.x*grazecircle_scale_multiplier) - $GrazecircleSprite.scale_sprite(grazecircle_scale_multiplier*2) + if grazecircle_radius: + graze_collision.set_radius(grazecircle_radius) + $GrazecircleSprite.scale_sprite(grazecircle_radius*2) func kill(): diff --git a/server/ffi/ffi.go b/server/ffi/ffi.go index 254d453..b75b0e3 100644 --- a/server/ffi/ffi.go +++ b/server/ffi/ffi.go @@ -41,6 +41,11 @@ type Bullet struct { cptr *C.Bullet } +// Values for selecting bullet paths +const ( + BULLET_LINEAR = 0 +) + func NewLinearBullet(tick int64, spawnX float64, spawnY float64, radius float64, velX float64, velY float64) *Bullet { return &Bullet{ cptr: C.new_bullet( @@ -77,3 +82,25 @@ func (b *Bullet) GetPos(tick int64) (float64, float64) { return float64(x), float64(y) } + +func (b *Bullet) GetType() int { + return int(b.cptr.class_) +} + +func (b *Bullet) GetRadius() float64 { + return float64(b.cptr.radius) +} + +func (b *Bullet) Serialize(tick int64) map[string]any { + x, y := b.GetPos(tick) + return map[string]any{ + "class": b.GetType(), + "tick": tick, + "x": x, + "y": y, + "radius": b.GetRadius(), + "vel_x": float64(b.cptr.parameters[0]), + "vel_y": float64(b.cptr.parameters[1]), + } + +} diff --git a/server/game-modes/battle-royale/consts.go b/server/game-modes/battle-royale/consts.go new file mode 100644 index 0000000..ed0abe0 --- /dev/null +++ b/server/game-modes/battle-royale/consts.go @@ -0,0 +1,26 @@ +package battleroyale + +const TICK_RATE = 60 + +const ( + PLAYER_DEATH_RESET = 0 + PLAYER_ALIVE = -1 +) + +const ( + MATCH_LOADING = iota + MATCH_START + STATE_UPDATE + FINAL_PHASE + MATCH_END +) + +const ( + STAGE_WIDTH float64 = 90.0 + STAGE_HEIGHT float64 = 160.0 + BULLET_OFFSCREEN_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 +) diff --git a/server/game-modes/battle-royale/game-loop.go b/server/game-modes/battle-royale/game-loop.go new file mode 100644 index 0000000..6f88fc0 --- /dev/null +++ b/server/game-modes/battle-royale/game-loop.go @@ -0,0 +1,122 @@ +package battleroyale + +import ( + "context" + "danmaku/ffi" + "database/sql" + "encoding/json" + "github.com/heroiclabs/nakama-common/runtime" + "math" +) + +func CheckMatchTerminate(lobbyState *MatchState, logger *runtime.Logger) bool { + // 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 { + (*logger).Info("Match terminated due to empty lobby.") + return true + } + + return false +} + +func RespondToInput(lobbyState *MatchState, messages []runtime.MatchData, logger *runtime.Logger) { + for _, msg := range messages { + _, 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 ClientUpdate + if err := json.Unmarshal(msg.GetData(), &update); err != nil { + (*logger).Warn("Failed to parse input: %v", err) + continue + } + + lobbyState.presences[msg.GetSessionId()].stageState.BoundsCheckedMove(update.X, update.Y) + } +} + +func TestFireBullets(tick int64) []*ffi.Bullet { + var bullets = []*ffi.Bullet{} + if tick%30 == 0 { + numBullets := 20 + spreadAngle := 60.0 + startAngle := 90 - (spreadAngle / 2) + bulletSpeed := STAGE_WIDTH / float64(TICK_RATE) / 3 + bulletRadius := 0.01 * STAGE_WIDTH + + // 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 := range numBullets { + 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, + bulletRadius, + velx, + vely, + ) + bullets = append(bullets, bullet) + } + } + return bullets +} + +func BroadcastToPresences(tick int64, lobbyState *MatchState, logger *runtime.Logger, dispatcher *runtime.MatchDispatcher) { + for _, v := range lobbyState.presences { + v.stageState.DeleteBulletsBeyondKillBoundary(tick) + + var newBulletsToBroadcast = []map[string]any{} + if v.stageState.CheckDeathState(tick) == PLAYER_ALIVE { + newBullets := TestFireBullets(tick) + + for _, bullet := range newBullets { + v.stageState.AddBullet(bullet) + newBulletsToBroadcast = append(newBulletsToBroadcast, bullet.Serialize(tick)) + } + } + + var tickData = v.stageState.MakeServerTick(tick, newBulletsToBroadcast) + + 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) + } + } +} + +// 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 any, messages []runtime.MatchData) any { + lobbyState, ok := state.(*MatchState) + if !ok { + logger.Error("State is not a valid lobby state object for MatchLoop.") + return nil + } + + if CheckMatchTerminate(lobbyState, &logger) { + return nil + } + + RespondToInput(lobbyState, messages, &logger) + BroadcastToPresences(tick, lobbyState, &logger, &dispatcher) + + return lobbyState +} diff --git a/server/game-modes/battle-royale/mode.go b/server/game-modes/battle-royale/mode.go deleted file mode 100644 index 41b4a0a..0000000 --- a/server/game-modes/battle-royale/mode.go +++ /dev/null @@ -1,318 +0,0 @@ -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 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: NewPlayerStage(), - } - } - - 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 -} diff --git a/server/game-modes/battle-royale/player-stage.go b/server/game-modes/battle-royale/player-stage.go index d78e034..e30c2bf 100644 --- a/server/game-modes/battle-royale/player-stage.go +++ b/server/game-modes/battle-royale/player-stage.go @@ -2,6 +2,8 @@ package battleroyale import ( "danmaku/ffi" + "math" + "slices" ) type PlayerStageState struct { @@ -25,3 +27,93 @@ func NewPlayerStage() *PlayerStageState { deathTimer: -1, } } + +func (s *PlayerStageState) Delete() { + for _, bullet := range s.bullets { + ffi.DestroyBullet(bullet) + } + ffi.DestroyCircle(s.hitCol) + ffi.DestroyCircle(s.grazeCol) +} + +func (s *PlayerStageState) BoundsCheckedMove(x float64, y float64) { + clampedX := x < 0 || x > STAGE_WIDTH + clampedY := y < 0 || y > STAGE_HEIGHT + + x = math.Max(0, math.Min(x, STAGE_WIDTH)) + y = math.Max(0, math.Min(y, STAGE_HEIGHT)) + + s.hitCol.UpdatePos(x, y) + s.grazeCol.UpdatePos(x, y) + + if clampedX || clampedY { + s.updatePlayerPos = true + } +} + +func (s *PlayerStageState) DeleteBulletsBeyondKillBoundary(tick int64) { + s.bullets = slices.DeleteFunc(s.bullets, func(b *ffi.Bullet) bool { + if b.BeyondKillBoundary(tick) { + ffi.DestroyBullet(b) + return true + } + return false + }) + +} + +func (s *PlayerStageState) CheckDeathState(tick int64) int { + // If the player is dead. Decrement the death timer + if s.deathTimer >= 0 { + s.deathTimer -= 1 + } + + if s.deathTimer == PLAYER_DEATH_RESET { // If the player's death timer has run out, reset them. 0 is a special deathTimer tick that indicates reset to the clients. + s.hitCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) + s.grazeCol.UpdatePos(STAGE_WIDTH*0.5, STAGE_HEIGHT-STAGE_HEIGHT*0.1) + s.updatePlayerPos = true + } else if s.deathTimer == PLAYER_ALIVE { // If the player is alive, check if the player collided with a bullet and kill them if so + if slices.ContainsFunc(s.bullets, func(b *ffi.Bullet) bool { + return b.CollidesWith(s.hitCol, tick) + }) { + s.deathTimer = PLAYER_DEATH_TIMER_MAX + } else if slices.ContainsFunc(s.bullets, func(b *ffi.Bullet) bool { // Otherwise, check the graze col and increment the graze and score + return b.CollidesWith(s.grazeCol, tick) + }) { + s.graze += GRAZE_ADDITION_MULTIPLIER + } + } + + return s.deathTimer +} + +func (s *PlayerStageState) AddBullet(b *ffi.Bullet) { + s.bullets = append(s.bullets, b) +} + +func (s *PlayerStageState) MakeServerTick(tick int64, serializedNewBullets []map[string]any) *ServerTickUpdate { + hitPosX, hitPosY := s.hitCol.GetPos() + grazePosX, grazePosY := s.hitCol.GetPos() + var tickData = ServerTickUpdate{ + Tick: tick, + PlayerHitPos: map[string]any{ + "x": hitPosX, + "y": hitPosY, + "radius": STAGE_WIDTH * PLAYER_HIT_COL_RADIUS_MULTIPLIER, + }, + PlayerGrazePos: map[string]any{ + "x": grazePosX, + "y": grazePosY, + "radius": STAGE_WIDTH * PLAYER_GRAZE_COL_RADIUS_MULTIPLIER, + }, + NewBullets: serializedNewBullets, + ForcePlayerPos: s.updatePlayerPos, + DeathTimer: s.deathTimer, + Graze: s.graze, + } + + // When this is called, we want to transmit updatePlayerPos if it's true once and then reset + s.updatePlayerPos = false + + return &tickData +} diff --git a/server/game-modes/battle-royale/presence-management-hooks.go b/server/game-modes/battle-royale/presence-management-hooks.go new file mode 100644 index 0000000..525a0bb --- /dev/null +++ b/server/game-modes/battle-royale/presence-management-hooks.go @@ -0,0 +1,96 @@ +package battleroyale + +import ( + "context" + "database/sql" + "github.com/heroiclabs/nakama-common/runtime" +) + +// 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]any) (any, int, string) { + state := &MatchState{ + tickRate: TICK_RATE, + presences: map[string]*PresenceState{}, + emptyTicks: 0, + currentMatchPhase: MATCH_LOADING, + } + + return state, TICK_RATE, "" +} + +// 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 any, presence runtime.Presence, metadata map[string]string) (any, bool, string) { + lobbyState, ok := state.(*MatchState) + 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 any, presences []runtime.Presence) any { + lobbyState, ok := state.(*MatchState) + if !ok { + logger.Error("State is not a valid lobby state object for MatchJoin.") + return nil + } + + // Register every new presence associated with the new player + for i := range len(presences) { + lobbyState.presences[presences[i].GetSessionId()] = &PresenceState{ + presence: &presences[i], + stageState: NewPlayerStage(), + } + } + + 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 any, presences []runtime.Presence) any { + lobbyState, ok := state.(*MatchState) + if !ok { + logger.Error("State is not a valid lobby state object for MatchLeave.") + return nil + } + + // De-register every presence associated with the leaving player + for i := range len(presences) { + sessionID := presences[i].GetSessionId() + + playerState, exists := lobbyState.presences[sessionID] + if exists { + playerState.stageState.Delete() + 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 any, data string) (any, string) { + lobbyState, ok := state.(*MatchState) + 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 any, graceSeconds int) any { + lobbyState, ok := state.(*MatchState) + if !ok { + logger.Error("State is not a valid lobby state object for MatchTerminate.") + return nil + } + + return lobbyState +} diff --git a/server/game-modes/battle-royale/types.go b/server/game-modes/battle-royale/types.go new file mode 100644 index 0000000..ed3e539 --- /dev/null +++ b/server/game-modes/battle-royale/types.go @@ -0,0 +1,39 @@ +package battleroyale + +import ( + "github.com/heroiclabs/nakama-common/runtime" +) + +// Interface for registering match handlers +type Match struct{} + +// State for the whole match +type MatchState struct { + tickRate int // MatchLoop invocations per second + currentMatchPhase int // Match phase enum + presences map[string]*PresenceState // List of player connections + emptyTicks int // Counter for terminating an empty match +} + +// State for one player connection +type PresenceState struct { // present time! hahahahahahahah! + presence *runtime.Presence // Nakama presence + stageState *PlayerStageState // Per-player application state +} + +// Struct to serialize client->server updates +type ClientUpdate struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Struct to serialize server->client updates +type ServerTickUpdate struct { + Tick int64 `json:"tick"` + PlayerHitPos map[string]any `json:"playerHitPos"` + PlayerGrazePos map[string]any `json:"playerGrazePos"` + NewBullets []map[string]any `json:"newBullets"` + ForcePlayerPos bool `json:"forcePlayerPos"` + DeathTimer int `json:"deathTimer"` + Graze int `json:"graze"` +} diff --git a/server/main.go b/server/main.go index d92f9e8..ef676e2 100644 --- a/server/main.go +++ b/server/main.go @@ -12,7 +12,7 @@ import ( 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 { + if matchId, err := nk.MatchCreate(ctx, modulename, make(map[string]any)); err != nil { return "", err } else { return matchId, nil