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

291 lines
10 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/>.
## This class defines the multiplayer game type.
class_name Multiplayer extends Node
signal connection_failed
## Enumeration that defines supported game modes.
enum Mode {
## Free-for-all mode where players compete individually.
FREE_FOR_ALL,
## Rabbit mode where players chase and protect a designated "rabbit" player.
RABBIT,
## Capture the flag mode where teams compete to capture each other's flags.
CAPTURE_THE_FLAG,
## Arena mode where players engage in team deathmatch battles.
ARENA,
## Ball mode where teams aim to control the ball and score goals.
BALL
}
func _enter_tree() -> void:
# Sets our custom multiplayer as the main one in SceneTree.
get_tree().set_multiplayer(MultiplayerMatchExtension.new())
multiplayer.hello()
## The [Match] manager.
@export var match:Match
## The [Teams] manager.
@export var teams:Teams
var motion_service:MotionService
## The scoreboard to keep track of scores.
@export var scoreboard:Scoreboard
### The multiplayer mode.
#@export var mode:Mode = Mode.FREE_FOR_ALL
## The time it takes for a player to respawn when killed, secconds (s).
@export var VICTIM_RESPAWN_TIME:float = 3.0
## The total duration of a match, in secconds (s).
@export var MATCH_DURATION := 1200
## The total duration of the warmup phase of a match.
@export var WARMUP_START_DURATION := 25
@export_group("Spawned Scenes")
@export var _PLAYER:PackedScene
@export var _FLAG:PackedScene
## The spawn root for [Player] nodes.
@onready var players:Node = $Match/Players
## The spawn root for [Flag] nodes.
@onready var objectives:Node = $Match/Objectives
@onready var player_spawner:MultiplayerSpawner = $Match/PlayerSpawner
@onready var map_spawner:MultiplayerSpawner = $Match/MapSpawner
@onready var objectives_spawner:MultiplayerSpawner = $Match/ObjectiveSpawner
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
player_spawner.spawn_function = _on_player_spawn
map_spawner.spawn_function = _on_map_spawn
func _on_player_spawn(data:Dictionary) -> Player:
var player:Player = _PLAYER.instantiate()
# @NOTE: MultiplayerSpawner needs valid names so we need to set the node name
# > Unable to auto-spawn node with reserved name: @RigidBody3D@101.
# > Make sure to add your replicated scenes via 'add_child(node, true)'
# > to produce valid names.
player.peer_id = data.peer_id
player.name = str(player.peer_id)
player.username = data.username
if "global_position" in data:
player.set_deferred("global_position", data.global_position)
return player
func _on_map_spawn(data:Variant = null) -> Map:
return Game.maps[data.index].instantiate()
func _unhandled_input(event:InputEvent) -> void:
if event.is_action_pressed("exit"):
if is_peer_connected():
_leave_match.rpc_id(get_multiplayer_authority())
func is_peer_connected() -> bool:
if not multiplayer.multiplayer_peer or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
return false
return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
## This method starts a server.
func start_server(port:int, _p_mode:int = 0, map_index:int = 0, _username:String = "Mercury") -> void:
#mode = p_mode
motion_service = MotionService.new()
#multiplayer.peer_connected.connect(motion_service.register)
#multiplayer.peer_disconnected.connect(_on_peer_disconnected)
#multiplayer.peer_disconnected.connect(motion_service.unregister)
Game.network.serve(port)
Game.ping.synchronized.connect(match.scoreboard._on_ping_sync)
Game.ping.start()
map_spawner.spawn({ "index": map_index })
# start ping timer to ping connected clients and synchronize its state
#match mode:
#Mode.RABBIT:
#var flag:Flag = _FLAG.instantiate()
#objectives.add_child(flag)
#var spawn:Node3D = map.get_spawn_position("objectives")
#flag.global_position = spawn.global_position
#flag.respawn_timer.timeout.connect(func() -> void:
#if spawn:
#flag.waypoint.text = ""
#flag.global_position = spawn.global_position
#flag.state = flag.FlagState.ON_STAND
#)
#
#teams.team_added.connect(_on_team_added)
#teams.team_erased.connect(_on_team_erased)
#teams.add_teams(["rabbit", "chasers"])
#flag.grabbed.connect(
#func(carry:FlagHolder) -> void:
#carry.owner.hud.objective_label.set_visible(true)
#switch_team(carry.owner.peer_id, "rabbit")
#flag.respawn_timer.stop()
#)
#flag.dropped.connect(
#func(carry:FlagHolder) -> void:
#carry.owner.hud.objective_label.set_visible(false)
#switch_team(carry.owner.peer_id, "chasers")
#flag.respawn_timer.start()
#)
#scoreboard.add_child(
#RabbitScoringComponent.new(scoreboard, flag))
#scoreboard.add_child(
#DeathmatchScoringComponent.new(scoreboard, players))
#
#players.child_entered_tree.connect(func(player:Player) -> void:
#teams["chasers"].add(player.peer_id, player.username)
#player.damage.connect(_rabbit_damage_handler)
#)
#
#Mode.FREE_FOR_ALL:
#scoreboard.add_panel()
#scoreboard.add_child(DeathmatchScoringComponent.new(scoreboard, players))
#players.child_entered_tree.connect(func(player:Player) -> void:
#var panel:ScorePanel = scoreboard.get_panel(0)
#panel.add_entry(player.peer_id, player.username)
#player.damage.connect(_ffa_damage_handler)
#)
#
# when a new player is added into tree
#players.child_entered_tree.connect(func(_player:Player) -> void:
## make sure we have enough players to start the match
#if players.get_child_count() > 1 and timer.is_stopped():
#_start_match()
#)
#print("mode `%s` loaded" % Mode.keys()[mode])
#if DisplayServer.get_name() != "headless":
#add_player(1, username)
#func _rabbit_damage_handler(source: Node, target: Node, amount: float) -> void:
#assert(target.find_children("*", "Health"))
#if source == target or source.team_id != target.team_id:
#target.health.damage.rpc(amount, source.peer_id)
#
#func _ffa_damage_handler(source: Node, target: Node, amount: float) -> void:
#assert(target.find_children("*", "Health"))
#target.health.damage.rpc(amount, source.peer_id)
#func _on_peer_disconnected(peer_id:int) -> void:
#print("peer `%d` disconnected" % peer_id)
#if players.get_child_count() < 2:
## stop timer when there is not enough players
#timer.stop()
#var player: Player = players.get_child(0)
#if player:
#player.hud.timer_label.text = "Warmup"
#func _start_match() -> void:
#scoreboard.scoring = true
#timer.start(WARMUP_START_DURATION) # wait few seconds
#await timer.timeout # wait for the starting countdown to finish
#for player: Player in players.get_children():
#if player.has_flag():
#var flag: Flag = player.flag_holder.flag
#player.flag_holder.drop.rpc_id(get_multiplayer_authority())
#var spawn:Node3D = map.get_objective_spawn()
#flag.global_position = spawn.global_position
#scoreboard.reset_scores()
#timer.start(MATCH_DURATION) # restart timer with match duration
#
#players.respawn() # respawn everyone
#for player:Player in players:
#player.respawn.rpc_id(get_multiplayer_authority(), Game.map.get_spawn_position("players"))
#
#if not timer.timeout.is_connected(_on_post_match):
#timer.timeout.connect(_on_post_match)
#func _on_post_match() -> void:
## disconnect handler for timer reuse
#if timer.timeout.is_connected(_on_post_match):
#timer.timeout.disconnect(_on_post_match)
## @TODO: display end of match stats with scoreboard data
#scoreboard.reset_scores()
#scoreboard.scoring = false
#for player: Player in players.get_children():
#if player.has_flag():
#var flag: Flag = player.flag_holder.flag
#player.flag_holder.drop.rpc_id(get_multiplayer_authority())
#var spawn:Node3D = map.get_objective_spawn()
#flag.global_position = spawn.global_position
## restart match
#_start_match()
func join_server(host:String, port:int, username:String) -> void:
multiplayer.connected_to_server.connect(_on_connected_to_server.bind(username))
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
Game.network.join(host, port)
func _on_server_disconnected() -> void:
Game.change_scene() # back to boot menu
func add_player(peer_id:int, username:String) -> void:
player_spawner.spawn({
"peer_id": peer_id,
"username": username,
# @NOTE: match would include map and teams to deduce where to spawn
#"global_position": party.get_spawn(peer_id)
})
func _on_connected_to_server(username:String) -> void:
_join_match.rpc_id(get_multiplayer_authority(), username)
func _on_connection_failed() -> void:
connection_failed.emit()
func _on_player_killed(victim: Player, _killer:int) -> void:
await get_tree().create_timer(VICTIM_RESPAWN_TIME).timeout
var spawn: Node3D = Game.map.get_player_spawn()
victim.respawn.rpc_id(get_multiplayer_authority(), spawn.global_position)
# This method notifies the server that a player wants to join the match. It
# takes a single [param username] parameter and is invoked remotely by clients.
@rpc("any_peer", "call_remote", "reliable")
func _join_match(username:String) -> void:
if multiplayer.is_server():
var peer_id:int = multiplayer.get_remote_sender_id()
# make sure remote peer cannot add more than one player
if not players.has_node(str(peer_id)):
# spawn player on all connected peers
add_player(peer_id, username)
@rpc("any_peer", "call_local", "reliable")
func _leave_match() -> void:
if multiplayer.is_server():
var peer_id:int = multiplayer.get_remote_sender_id()
var player:Player = players.get_node(str(peer_id))
if player:
var team:Team = teams.get_peer_team(peer_id)
if team:
team.erase(peer_id)
# @TODO: verify that a peer disconnecting out of
# network problems drops the flag
if player.has_flag():
player.flag_holder.drop()
players.remove_child(player)
player.queue_free()
multiplayer.disconnect_peer(peer_id)
func _exit_tree() -> void:
if not is_multiplayer_authority():
_leave_match.rpc_id(get_multiplayer_authority())