mirror of
https://codeberg.org/sunder/sunder.git
synced 2026-03-07 19:00:26 +00:00
350 lines
13 KiB
GDScript
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
|