Server-side refactor
This commit is contained in:
parent
b2c3079310
commit
265bf61406
12 changed files with 423 additions and 337 deletions
|
|
@ -4,7 +4,11 @@
|
||||||
|
|
||||||
[node name="Game" type="Node"]
|
[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="."]
|
[node name="Camera3D" type="Camera3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.699727)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.699727)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
class_name ScalableSprite2D
|
class_name ScalableSprite2D
|
||||||
extends Sprite2D
|
extends Sprite2D
|
||||||
|
|
||||||
func scale_sprite(world_space_multiple: float):
|
func scale_sprite(world_space_width: float):
|
||||||
var scale_factor = (world_space_multiple * Globals.SERVER_SIZE.x) / self.texture.get_width()
|
var scale_factor = world_space_width / self.texture.get_width()
|
||||||
self.scale = Vector2(scale_factor, scale_factor)
|
self.scale = Vector2(scale_factor, scale_factor)
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData):
|
||||||
float(data["playerHitPos"]["x"]),
|
float(data["playerHitPos"]["x"]),
|
||||||
float(data["playerHitPos"]["y"])
|
float(data["playerHitPos"]["y"])
|
||||||
),
|
),
|
||||||
float(data["playerHitPos"]["radius_multiplier"]),
|
float(data["playerHitPos"]["radius"]),
|
||||||
float(data["playerGrazePos"]["radius_multiplier"])
|
float(data["playerGrazePos"]["radius"])
|
||||||
)
|
)
|
||||||
%GrazeLabel.text = "Graze: " + str(data["graze"])
|
%GrazeLabel.text = "Graze: " + str(data["graze"])
|
||||||
|
|
||||||
|
|
@ -54,14 +54,14 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData):
|
||||||
int(b["tick"]),
|
int(b["tick"]),
|
||||||
b["x"],
|
b["x"],
|
||||||
b["y"],
|
b["y"],
|
||||||
b["radius_multiplier"] * Globals.SERVER_SIZE.x,
|
b["radius"],
|
||||||
b["vel_x"],
|
b["vel_x"],
|
||||||
b["vel_y"])
|
b["vel_y"])
|
||||||
bullet.texture = load("res://test-bullet.png")
|
bullet.texture = load("res://test-bullet.png")
|
||||||
bullet.position = bullet.get_current_pos(int(b["tick"]))
|
bullet.position = bullet.get_current_pos(int(b["tick"]))
|
||||||
|
|
||||||
# Reimplemented from ScalableSprite2D here atm
|
# 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.scale = Vector2(scale_ratio, scale_ratio)
|
||||||
bullet.z_index = 4
|
bullet.z_index = 4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,11 @@ extends Node2D
|
||||||
var velocity := Vector2.ZERO
|
var velocity := Vector2.ZERO
|
||||||
var hurt_collision: DanmakuCircle = DanmakuCircle.new()
|
var hurt_collision: DanmakuCircle = DanmakuCircle.new()
|
||||||
var graze_collision: DanmakuCircle = DanmakuCircle.new()
|
var graze_collision: DanmakuCircle = DanmakuCircle.new()
|
||||||
|
const PLAYER_BODY_WIDTH_MULTIPLIER = 0.18
|
||||||
const PLAYER_BODY_WIDTH_MULTIPLIER = 0.18 # This is temporary, it should be defined per-sprite when I get to the skin system
|
|
||||||
|
|
||||||
var alive: bool = true
|
var alive: bool = true
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
$BodySprite.scale_sprite(PLAYER_BODY_WIDTH_MULTIPLIER)
|
$BodySprite.scale_sprite(PLAYER_BODY_WIDTH_MULTIPLIER * Globals.SERVER_SIZE.x)
|
||||||
|
|
||||||
func get_input():
|
func get_input():
|
||||||
if Input.is_action_pressed("Slow Mode"):
|
if Input.is_action_pressed("Slow Mode"):
|
||||||
|
|
@ -33,18 +31,18 @@ func _physics_process(delta: float):
|
||||||
|
|
||||||
set_position_data(attempted_position, null, null)
|
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
|
position = pos
|
||||||
hurt_collision.set_position(pos.x, pos.y)
|
hurt_collision.set_position(pos.x, pos.y)
|
||||||
graze_collision.set_position(pos.x, pos.y)
|
graze_collision.set_position(pos.x, pos.y)
|
||||||
|
|
||||||
if hurtcircle_scale_multiplier:
|
if hurtcircle_radius:
|
||||||
hurt_collision.set_radius(Globals.SERVER_SIZE.x*hurtcircle_scale_multiplier)
|
hurt_collision.set_radius(hurtcircle_radius)
|
||||||
$HurtcircleSprite.scale_sprite(hurtcircle_scale_multiplier*2)
|
$HurtcircleSprite.scale_sprite(hurtcircle_radius*2)
|
||||||
|
|
||||||
if grazecircle_scale_multiplier:
|
if grazecircle_radius:
|
||||||
graze_collision.set_radius(Globals.SERVER_SIZE.x*grazecircle_scale_multiplier)
|
graze_collision.set_radius(grazecircle_radius)
|
||||||
$GrazecircleSprite.scale_sprite(grazecircle_scale_multiplier*2)
|
$GrazecircleSprite.scale_sprite(grazecircle_radius*2)
|
||||||
|
|
||||||
|
|
||||||
func kill():
|
func kill():
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ type Bullet struct {
|
||||||
cptr *C.Bullet
|
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 {
|
func NewLinearBullet(tick int64, spawnX float64, spawnY float64, radius float64, velX float64, velY float64) *Bullet {
|
||||||
return &Bullet{
|
return &Bullet{
|
||||||
cptr: C.new_bullet(
|
cptr: C.new_bullet(
|
||||||
|
|
@ -77,3 +82,25 @@ func (b *Bullet) GetPos(tick int64) (float64, float64) {
|
||||||
|
|
||||||
return float64(x), float64(y)
|
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]),
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
26
server/game-modes/battle-royale/consts.go
Normal file
26
server/game-modes/battle-royale/consts.go
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
122
server/game-modes/battle-royale/game-loop.go
Normal file
122
server/game-modes/battle-royale/game-loop.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,8 @@ package battleroyale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"danmaku/ffi"
|
"danmaku/ffi"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlayerStageState struct {
|
type PlayerStageState struct {
|
||||||
|
|
@ -25,3 +27,93 @@ func NewPlayerStage() *PlayerStageState {
|
||||||
deathTimer: -1,
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
96
server/game-modes/battle-royale/presence-management-hooks.go
Normal file
96
server/game-modes/battle-royale/presence-management-hooks.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
39
server/game-modes/battle-royale/types.go
Normal file
39
server/game-modes/battle-royale/types.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
func ManualForceCreateBRMatchRPC(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
func ManualForceCreateBRMatchRPC(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
||||||
modulename := "battle-royale"
|
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
|
return "", err
|
||||||
} else {
|
} else {
|
||||||
return matchId, nil
|
return matchId, nil
|
||||||
|
|
|
||||||
Reference in a new issue