From 58c11048a94ce44f1840e84d6e072d244c465a79 Mon Sep 17 00:00:00 2001 From: Sebastian Benjamin Date: Tue, 22 Apr 2025 18:40:05 -0700 Subject: [PATCH] Split into multiple modules --- Makefile | 2 +- server/{ => ffi}/ffi.go | 2 +- server/game-modes/battle-royale/mode.go | 336 ++++++++++++++++++++++++ server/main.go | 331 +---------------------- 4 files changed, 340 insertions(+), 331 deletions(-) rename server/{ => ffi}/ffi.go (99%) create mode 100644 server/game-modes/battle-royale/mode.go diff --git a/Makefile b/Makefile index 9c7a167..6e5ede4 100644 --- a/Makefile +++ b/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 diff --git a/server/ffi.go b/server/ffi/ffi.go similarity index 99% rename from server/ffi.go rename to server/ffi/ffi.go index de81095..a3d02a0 100644 --- a/server/ffi.go +++ b/server/ffi/ffi.go @@ -1,4 +1,4 @@ -package main +package ffi /* #cgo CFLAGS: -I ${SRCDIR}/lib diff --git a/server/game-modes/battle-royale/mode.go b/server/game-modes/battle-royale/mode.go new file mode 100644 index 0000000..f5b3d7f --- /dev/null +++ b/server/game-modes/battle-royale/mode.go @@ -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 +} diff --git a/server/main.go b/server/main.go index 941828f..d92f9e8 100644 --- a/server/main.go +++ b/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