sunder/scenes/multiplayer/match/scripts/match.gd
2026-02-18 18:33:17 -05:00

350 lines
13 KiB
GDScript

# This file is part of sunder.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
@icon("res://assets/icons/match.svg")
class_name Match extends Node
enum MatchState {
## The match is ready to begin.
READY,
## A brief training session to prepare before it starts.
WARMUP,
## The match is running as specified by a [Ruleset].
RUNNING,
## The match is paused (ex. a majority of players voted a pause).
PAUSED,
## The match has ended.
ENDED
}
## The current state of this [Match]
@export var state : MatchState = MatchState.READY:
set = set_state
func set_state(new_state:MatchState) -> void:
if state == new_state: return # noop
state = new_state
state_changed.emit(new_state)
const scene:PackedScene = preload("res://scenes/multiplayer/match/match.tscn")
static func instantiate() -> Match:
return scene.instantiate()
## Statically initialized array of available [Mode]
static var modes:Array[Mode] = _populate_modes()
## This method loads available match modes when the game is initialized
static func _populate_modes() -> Array[Mode]:
var _modes:Array[Mode] = []
# load builtin modes
const modes_dir:String = "res://scenes/multiplayer/match/assets"
var listing:PackedStringArray = ResourceLoader.list_directory(modes_dir)
for path in listing:
if path.get_extension() in ["res", "tres"]:
var _mode:Mode = load(modes_dir.path_join(path)) as Mode
_modes.append(_mode)
return _modes
## The current [Mode] responsible for this [Match] behavior
var mode:Mode
## The current [Map] used to dispute this [Match]
var map:Map
## The [Teams] manager
var teams:Teams = Teams.new()
## The match timer for map state rotation
@onready var timer:Timer = $Timer
## The [Scoreboard] to keep track of peer scores
@onready var scoreboard:Scoreboard = $Scoreboard
@onready var map_spawner:MultiplayerSpawner = $MapSpawner
@onready var player_spawner:MultiplayerSpawner = $PlayerSpawner
@onready var objective_spawner:MultiplayerSpawner = $ObjectiveSpawner
# @TODO: unregister for disconnecting and leaving peers
## The [member Player.peer_id] to [Player] instance map (set registered on spawn)
var players:Dictionary[int,Player] = {}
## Emitted after [member state] is changed
signal state_changed(new_state: MatchState)
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
map_spawner.spawn_function = _on_map_spawn
player_spawner.spawn_function = _on_player_spawn
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
## Starts the match as specified by [param p_map] and [param p_mode]
func setup(map_id:int, p_mode:int) -> void:
if OS.is_debug_build():
prints("match: setup with data", [map_id, p_mode])
if not multiplayer.is_server():
return
state_changed.connect(_on_state_changed)
teams.team_added.connect(_on_team_added)
teams.team_erased.connect(_on_team_erased)
map_spawner.spawn({ "map_id": map_id })
mode = modes[p_mode]
mode.setup(self)
## This method runs authority and client peers when a [Player] is spawned by [param player_spawner]
func _on_player_spawn(data:Variant = null) -> Player:
if OS.is_debug_build():
prints("%s:" % multiplayer.get_unique_id(), "spawn Player(%s) with data" % data.peer_id, data)
var player := Player.instantiate()
player.name = str(data.peer_id)
player.peer_id = data.peer_id
player.username = data.username
# enforce initial global transform
var global_transform := Transform3D()
global_transform.origin = data.global_position
player.global_transform = global_transform
# freeze all spawned player on all peers (except server pawn)
if not (multiplayer.is_server() and multiplayer.get_unique_id() == data.peer_id):
# server pawn never has to wait for spawns
if data.peer_id > 1 :
# freeze and hide player until spawn is acknowledged by all peers
player.freeze = true
player.hide()
# spawn and sync acks
if multiplayer.is_server():
# server pawn does not need to be acknowledged or synchronized
if player.peer_id > 1:
# make sure that existing player spawn syncs are visible to the joining peer so that when
# Player.SpawnSynchronizer._sync_ack is triggered from the synchronizer attached to the
# player in their local simulation it can ask the server to disable visibility for that
# remote peer to avoid syncing position more than once
for existing_player:Player in players.values():
existing_player.get_node("SpawnSynchronizer").set_visibility_for(player.peer_id, true)
prints("set Player(%s) sync visible for" % existing_player.peer_id, player.peer_id)
# allocate spawn ack bitmask for newly connected client
var _spawn_ack_bitmask:Dictionary[int, bool] = {}
spawn_acks[player.peer_id] = _spawn_ack_bitmask
# register each connected peer in bitmask
for peer in multiplayer.get_peers():
spawn_acks[player.peer_id][peer] = false # unacknowledge yet (frozen)
else:
# register one shot spawn ack rpc when the player is ready on client peers
player.ready.connect(_spawn_ack.rpc_id.bind(1, player.peer_id), CONNECT_ONE_SHOT)
# disable collision on clients
if not multiplayer.get_unique_id() == player.peer_id:
player.collision_mask = 0b1
# if local peer has input authority over the player
if multiplayer.get_unique_id() == data.peer_id:
# override camera polling termination mechanism in Terrain3D::_grab_camera (see 885e772)
map.set_camera(player.camera)
#map.set_physics_process(true)
# connect signal handler to hide player hud when the scoreboard key is pressed
scoreboard.visibility_changed.connect(func() -> void:
player.hud.set_visible(!scoreboard.visible))
# connect the match exit handler
Game.exit.connect(func() -> void:
player.hud.ask("Are you sure you want to leave?", func() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
Game.change_scene()
)
)
# keep references to player nodes on all peers
players[player.peer_id] = player
# good luck in there my friend!
return player
func _on_map_spawn(data:Variant = null) -> Map:
# @TODO: detect team spawns (a mode may setup many teams) and spawn locations can be picked
# randomly or not based on the mode
# ex. rabbit: random spawns, ctf: team-specific spawns
return Game.maps[data.map_id].instantiate()
## Called by client peers to request the server to spawn a new player in the [Match] scene.
@rpc("any_peer", "call_remote", "reliable")
func join(username:String) -> void:
if multiplayer.is_server():
var peer_id:int = multiplayer.get_remote_sender_id()
if peer_id == 0: peer_id = 1 # when called as fn from server
# make sure remote peer cannot add more than one player
if not player_spawner.has_node(str(peer_id)):
# spawn player on all connected peers
player_spawner.spawn({
"peer_id": peer_id,
"username": username,
"global_position": get_spawn_position(peer_id)
})
#@rpc("any_peer", "call_remote", "reliable")
#func leave() -> void:
#if multiplayer.is_server():
#var peer_id:int = multiplayer.get_remote_sender_id()
#var player:Player = players[peer_id]
#if player:
#var team:Team = teams.get_peer_team(peer_id)
#if team:
#team.erase(peer_id)
#
#if player.has_flag():
#player.flag_holder.drop()
#
#player_spawner.remove_child(player)
#player.free()
#
#multiplayer.disconnect_peer(peer_id)
func get_spawn_position(peer_id:int) -> Vector3:
# @TODO: get_spawn_position should deduce where to spawn the player based on peer_id
# it should 1) detect spawn points on the map and 2) which spawn location to use based on
# teams for the current match mode
return map.get_spawn_position("players")
func start() -> void:
state = MatchState.WARMUP # @TODO: some modes will want to disable warmups
## Stops the [Match]
func end() -> void:
state = MatchState.ENDED
## Pause the [Match] (ex. when most peers voted for a pause)
func pause() -> void:
push_warning("not implemented yet")
# Called when a client peer disconnects from the server. At this point, we likely want
# to cleanup nodes that are owned by that peer and deal with objectives ownership
# (ex. release flag into the world if carrier)
func _on_peer_disconnected(peer_id:int) -> void:
if multiplayer.is_server():
var player:Player = players[peer_id]
if player:
var team:Team = teams.get_peer_team(peer_id)
if team:
team.erase(peer_id)
if player.has_flag():
player.flag_holder.drop()
player_spawner.remove_child(player)
player.free()
multiplayer.disconnect_peer(peer_id)
## Emitted on the server when the state changes
func _on_state_changed(new_state:MatchState) -> void:
match new_state:
MatchState.READY:
state = MatchState.WARMUP
MatchState.WARMUP:
scoreboard.scoring = true
timer.timeout.connect(_on_warmup_timeout, CONNECT_ONE_SHOT)
timer.start(60) # starts timer for warmup duration
MatchState.RUNNING:
scoreboard.reset_scores()
#for player in players:
#if player.has_flag():
#player.flag_holder.flag.global_position = map.get_spawn_position("objectives")
#player.flag_holder.drop()
#timer.timeout.connect(_on_match_timeout, CONNECT_ONE_SHOT)
#timer.start(match_duration)
#for player in players:
#player.respawn.rpc()
func _on_warmup_timeout() -> void:
state = MatchState.RUNNING
func _on_match_timeout() -> void:
state = MatchState.RUNNING
# @TODO: show end match stats leaderboard and maybe restart the match
## The dictionary maintained by the server to track peer readyness on spawn so that the server can
## tell everyone to start local prediction
var spawn_acks:Dictionary[int,Dictionary] = {}
## This is called by players when they spawn to ack it to the server. When all peers have sent an ack
## then server send a global [member _spawn_unfreeze] rpc
@rpc("any_peer", "call_remote", "reliable")
func _spawn_ack(peer_id:int) -> void:
if multiplayer.is_server() and peer_id in spawn_acks:
# @TODO: ensure all peers are still connected and remove disconnected ones if any
#var dirty_peers:Array[int] = spawn_acks[peer_id].keys().filter(
#func(id:int) -> bool: return id not in multiplayer.get_peers())
#for peer in dirty_peers:
#spawn_acks[peer_id].erase(peer)
#players.erase(peer)
# ensure spawn ack is expected
if multiplayer.get_remote_sender_id() in spawn_acks[peer_id].keys():
# guard against ack peers sending extra spawn acks to trigger new global unfreeze calls
if spawn_acks[peer_id].values().min():
return
# acknowledge spawn of player node identified by peer_id on sending remote peer
spawn_acks[peer_id][multiplayer.get_remote_sender_id()] = true
# recheck if all peers are acks
if spawn_acks[peer_id].values().min():
# if so send a global unfreeze
_spawn_unfreeze.rpc(peer_id)
elif spawn_acks[peer_id].values().min():
# if the spawn ack is not expected it could be because the peer that has input authority
# over this node is already acknowledged so we can unfreeze it on the sending peer simulation
_spawn_unfreeze.rpc_id(multiplayer.get_remote_sender_id(), peer_id)
## This method is called by the match authority when all peers have recognized a new player's
## node spawn. See the [member _spawn_ack] rpc and [member spawn_acks].
@rpc("authority", "call_local", "reliable")
func _spawn_unfreeze(peer_id:int) -> void:
var player:Player = players[peer_id]
#player.collision_layer
player.freeze = false
player.show()
## This method switch a [param peer_id] to the specified team along with its score.
func switch_team(peer_id: int, to_team_name: String) -> void:
var score:Vector3i = scoreboard.get_score(peer_id)
teams.switch_team(peer_id, to_team_name)
scoreboard.set_score(peer_id, score)
## This method is called when the [member Teams.team_addded] signal is emitted.
func _on_team_added(team_name: String) -> void:
var panel:ScorePanel = scoreboard.add_panel(team_name)
panel.title.show()
var team:Team = teams[team_name]
panel.title.set_text(team_name)
team.renamed.connect(panel.title.set_text)
team.player_added.connect(_on_team_player_added)
team.player_erased.connect(_on_team_player_erased)
# This method is called when the [member Teams.team_erased] signal is emitted.
func _on_team_erased(team_name: String) -> void:
var panel:ScorePanel = scoreboard.panels.get_node(team_name)
scoreboard.remove_panel(panel)
## This method is called when the [member Team.player_added] signal is emitted.
func _on_team_player_added(team_name: String, peer_id: int, username:String = "newblood") -> void:
var panel:ScorePanel = scoreboard.panels.get_node(team_name)
panel.add_entry(peer_id, username)
var player:Player = player_spawner.get_node(str(peer_id))
player.team_id = teams[team_name].get_instance_id()
## This method is called when the [member Teams.Team.player_erased] signal is emitted.
func _on_team_player_erased(team_name: String, peer_id: int) -> void:
var panel:ScorePanel = scoreboard.panels.get_node(team_name)
panel.remove_entry_by_peer_id(peer_id)
var player:Player = player_spawner.get_node(str(peer_id))
player.team_id = -1