first commit 🎉

This commit is contained in:
anyreso 2026-02-17 23:36:57 -05:00
commit 6e724f67fe
805 changed files with 62098 additions and 0 deletions

View file

@ -0,0 +1,53 @@
extends MultiplayerAPIExtension
class_name LogMultiplayer
# We want to extend the default SceneMultiplayer.
var base_multiplayer = SceneMultiplayer.new()
func _init():
# Just passthrough base signals (copied to var to avoid cyclic reference)
var cts = connected_to_server
var cf = connection_failed
var pc = peer_connected
var pd = peer_disconnected
base_multiplayer.connected_to_server.connect(func(): cts.emit())
base_multiplayer.connection_failed.connect(func(): cf.emit())
base_multiplayer.peer_connected.connect(func(id): pc.emit(id))
base_multiplayer.peer_disconnected.connect(func(id): pd.emit(id))
func _poll():
return base_multiplayer.poll()
# Log RPC being made and forward it to the default multiplayer.
func _rpc(peer: int, object: Object, method: StringName, args: Array) -> Error:
print("Got RPC for %d: %s::%s(%s)" % [peer, object, method, args])
return base_multiplayer.rpc(peer, object, method, args)
# Log configuration add. E.g. root path (nullptr, NodePath), replication (Node, Spawner|Synchronizer), custom.
func _object_configuration_add(object, config: Variant) -> Error:
if config is MultiplayerSynchronizer:
print("Adding synchronization configuration for %s. Synchronizer: %s" % [object, config])
elif config is MultiplayerSpawner:
print("Adding node %s to the spawn list. Spawner: %s" % [object, config])
return base_multiplayer.object_configuration_add(object, config)
# Log configuration remove. E.g. root path (nullptr, NodePath), replication (Node, Spawner|Synchronizer), custom.
func _object_configuration_remove(object, config: Variant) -> Error:
if config is MultiplayerSynchronizer:
print("Removing synchronization configuration for %s. Synchronizer: %s" % [object, config])
elif config is MultiplayerSpawner:
print("Removing node %s from the spawn list. Spawner: %s" % [object, config])
return base_multiplayer.object_configuration_remove(object, config)
# These can be optional, but in our case we want to extend SceneMultiplayer, so forward everything.
func _set_multiplayer_peer(p_peer: MultiplayerPeer):
base_multiplayer.multiplayer_peer = p_peer
func _get_multiplayer_peer() -> MultiplayerPeer:
return base_multiplayer.multiplayer_peer
func _get_unique_id() -> int:
return base_multiplayer.get_unique_id()
func _get_peer_ids() -> PackedInt32Array:
return base_multiplayer.get_peers()

View file

@ -0,0 +1 @@
uid://dfc88e4xhn53e

View file

@ -0,0 +1,87 @@
class_name MultiplayerMatchExtension extends MultiplayerAPIExtension
var _multiplayer := SceneMultiplayer.new()
## The maximum number of concurrent connections to accept
@export_range(1, 4095) var max_peers:int = 32
signal scene_changed
func _init() -> void:
# Just passthrough base signals (copied to var to avoid cyclic reference)
var cs := connected_to_server
var cf := connection_failed
var pc := peer_connected
var pd := peer_disconnected
_multiplayer.connected_to_server.connect(cs.emit)
_multiplayer.connection_failed.connect(cf.emit)
_multiplayer.peer_connected.connect(pc.emit)
_multiplayer.peer_disconnected.connect(pd.emit)
func _poll() -> Error:
return _multiplayer.poll()
# Log RPC being made and forward it to the default multiplayer.
func _rpc(peer:int, object:Object, method:StringName, args:Array) -> Error:
print("Got RPC for %d: %s::%s(%s)" % [peer, object, method, args])
return _multiplayer.rpc(peer, object, method, args)
## Log configuration add. E.g. root path (nullptr, NodePath), replication (Node, Spawner|Synchronizer), custom.
#func _object_configuration_add(object, config: Variant) -> Error:
#if config is MultiplayerSynchronizer:
#print("Adding synchronization configuration for %s. Synchronizer: %s" % [object, config])
#elif config is MultiplayerSpawner:
#print("Adding node %s to the spawn list. Spawner: %s" % [object, config])
#return _multiplayer.object_configuration_add(object, config)
#
## Log configuration remove. E.g. root path (nullptr, NodePath), replication (Node, Spawner|Synchronizer), custom.
#func _object_configuration_remove(object, config: Variant) -> Error:
#if config is MultiplayerSynchronizer:
#print("Removing synchronization configuration for %s. Synchronizer: %s" % [object, config])
#elif config is MultiplayerSpawner:
#print("Removing node %s from the spawn list. Spawner: %s" % [object, config])
#return _multiplayer.object_configuration_remove(object, config)
# These can be optional, but in our case we want to extend SceneMultiplayer, so forward everything.
func _set_multiplayer_peer(p_peer:MultiplayerPeer) -> void:
_multiplayer.multiplayer_peer = p_peer
func _get_multiplayer_peer() -> MultiplayerPeer:
return _multiplayer.multiplayer_peer
func _get_unique_id() -> int:
return _multiplayer.get_unique_id()
func _get_remote_sender_id() -> int:
return _multiplayer.get_remote_sender_id()
func _get_peer_ids() -> PackedInt32Array:
return _multiplayer.get_peers()
## Creates a server peer and listens for connections on all intefaces for specifed [param port]
func serve(port:int = 9000) -> Error:
var peer:ENetMultiplayerPeer = ENetMultiplayerPeer.new()
peer_connected.connect(_on_peer_connected)
peer_disconnected.connect(_on_peer_disconnected)
var err:Error = peer.create_server(port, max_peers)
if err == Error.OK:
print("listening for connections on *:%s" % port)
_set_multiplayer_peer(peer)
return err
## Creates a client peer and connects to specified [param host] and [param port]
func join(host:String = "localhost", port:int = 9000) -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_client(host, port)
if peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED:
push_error("failed to start multiplayer client")
return
_set_multiplayer_peer(peer)
## Emitted when a multiplayer peer successfully connects to a server. Only emitted on the server.
func _on_peer_connected(peer_id:int) -> void:
print("peer `%d` connected" % peer_id)
func _on_peer_disconnected(peer_id:int) -> void:
print("peer `%d` disconnected" % peer_id)

View file

@ -0,0 +1 @@
uid://cae2lsprl15be

View file

@ -0,0 +1,98 @@
class_name OldQueue
extends RefCounted
var capacity:int
var head:int = 0
var tail:int = 0
var _block_size:int
var _data:Array = []
var _length:int = 0
func _init(p_capacity:int = 128) -> void:
_block_size = p_capacity
capacity = _block_size
var _block := []
_block.resize(_block_size)
_data = [_block]
_data.resize(len(_data))
func _expand() -> void:
capacity += _block_size
var _block := []
_block.resize(_block_size)
_data.resize(len(_data) + 1)
func enqueue(item:Variant) -> void:
if (_length + 1) % capacity == 0:
_expand()
@warning_ignore("integer_division")
_data[tail / _block_size][tail % _block_size] = item
tail = (tail + 1) % capacity
_length += 1
func dequeue(shrink:bool = true) -> Variant:
if is_empty():
return null
@warning_ignore("integer_division")
var item:Variant = _data[head / _block_size][head % _block_size]
head = (head + 1) % capacity
_length -= 1
if shrink:
_shrink()
return item
func _shrink() -> void:
if (_data.size() <= 1) or (capacity <= _block_size) or (_length > capacity - _block_size):
return
prints("old head / tail", head, tail)
var lhs:int = _block_size - (head % _block_size)
var rhs:int = abs((tail % _block_size) - _block_size)
var head_block:int = head / _block_size
var tail_block:int = tail / _block_size
var move_tail:bool = (head < tail and lhs > rhs) or (head > tail and lhs >= rhs)
if move_tail:
for i in range(rhs):
_data[head_block][i] = _data[tail_block][i]
tail = (tail - rhs + capacity) % capacity
_data.erase(tail_block)
else:
for i in range(lhs):
enqueue(dequeue(false))
_data.erase(head_block)
head = (head + lhs) % capacity
prints("new head / tail", head, tail)
capacity -= _block_size
_data.resize(capacity)
func peek() -> Variant:
if is_empty():
return null
return _data[head]
func is_empty() -> bool:
return head == tail
func size() -> int:
return _length
# This is the iterator index cursor.
var _iter_cursor:int = 0
# This method is an iterator initializer.
func _iter_init(_arg:Variant) -> bool:
_iter_cursor = head # reset
return head != tail
# This method checks if the iterator has a next value.
func _iter_next(_arg:Variant) -> bool:
_iter_cursor = (_iter_cursor + 1) % capacity
return _iter_cursor < _length
# This method gets the next iterator value.
func _iter_get(_arg:Variant) -> Variant:
@warning_ignore("integer_division")
return _data[_iter_cursor / _block_size][_iter_cursor % _block_size]

View file

@ -0,0 +1 @@
uid://qwgu1g1yifsl

View file

@ -0,0 +1,33 @@
class_name MotionPack extends RefCounted
static var tick:int = 0
var direction := Vector2.ZERO
var mask:Array[bool]
func _init() -> void:
mask.resize(256)
mask.fill(false)
func pack() -> PackedByteArray:
var buffer := StreamPeerBuffer.new()
buffer.put_32(tick)
buffer.put_float(direction.x)
buffer.put_float(direction.y)
# bitpack mask array into single byte
var packed:int = 0
for i in mask.size():
if mask[i]: packed |= (1 << i)
buffer.put_u8(packed)
return buffer.data_array
static func unpack(data:PackedByteArray) -> MotionPack:
var buffer := StreamPeerBuffer.new()
buffer.data_array = data
var move := MotionPack.new()
move.tick = buffer.get_32()
move.direction = Vector2(buffer.get_float(), buffer.get_float())
# unpack mask array
var packed:int = buffer.get_u8()
for i in range(256): # 0 to 255
move.mask[i] = bool(packed & (1 << i))
return move

View file

@ -0,0 +1 @@
uid://b6mtcibai0qaa

View file

@ -0,0 +1,81 @@
class_name MotionService extends RefCounted
@export var RATE_HZ := 64
var running := false
var delta:float:
get: return 1.0 / RATE_HZ
set(value): pass
var _threads:Dictionary[int, Thread]
var _clock:Thread
var _semaphore:Semaphore
var _mutex:Mutex
var tick := 0:
set(value):
tick = value % RATE_HZ
func _init() -> void:
_clock = Thread.new()
_mutex = Mutex.new()
_semaphore = Semaphore.new()
#multiplayer.peer_connected.connect(_on_peer_connected)
#multiplayer.peer_disconnected.connect(_on_peer_disconnected)
func register(id:int) -> void:
if !running:
running = true
_clock.start(_loop)
prints("motion_service: starting clock")
prints("motion_service: register peer", id)
_threads[id] = Thread.new()
_threads[id].start(_worker.bind(id))
func unregister(id:int) -> void:
if running:
prints("motion_service: unregistering peer", id)
if _threads[id].is_alive():
_threads[id].wait_to_finish()
_threads.erase(id)
if len(_threads) == 0:
running = false
if _clock.is_alive():
_clock.wait_to_finish()
_clock = null
func _exit_tree() -> void:
if running:
for id:int in _threads.keys():
unregister(id)
func _loop() -> void:
var next_time:float = Time.get_ticks_msec() / 1000.
while running:
_semaphore.post(len(_threads))
_mutex.lock()
next_time += delta
_mutex.unlock()
var sleep_time:float = next_time - (Time.get_ticks_msec() / 1000.)
if sleep_time > 0:
# delay current thread
OS.delay_msec(int(sleep_time * 1000))
else:
_mutex.lock()
# we are behind; reset next_time to avoid drift
next_time = Time.get_ticks_msec() / 1000.
_mutex.unlock()
_mutex.lock()
tick += 1
_mutex.unlock()
func _worker(peer_id:int) -> void:
while running:
_semaphore.wait() # wait until posted
#prints("tick", tick, "for peer", peer_id)
#var player:Player = owner.players.get_node(str(peer_id))
#if player:
#player.send_position.rpc(player.global_position)

View file

@ -0,0 +1 @@
uid://gejmvnktjldp

View file

@ -0,0 +1,291 @@
# 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())

View file

@ -0,0 +1 @@
uid://d4c6vseetdfpi

View file

@ -0,0 +1,53 @@
# 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/>.
class_name Network extends Node
## The maximum number of concurrent connections to accept
@export_range(1, 4095) var max_peers:int = 32
## Emitted when a [MultiplayerPeer] sends a [member leave] rpc, right before it is disconnected
signal peer_disconnecting(peer_id:int)
## Creates a server peer and listens for connections on all intefaces for specifed [param port]
func serve(port:int = 9000) -> void:
var peer:ENetMultiplayerPeer = ENetMultiplayerPeer.new()
var err:Error = peer.create_server(port, max_peers)
if err != Error.OK:
Game.change_scene(Game.GameType.NONE)
return
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.multiplayer_peer = peer
print("listening for connections on *:%s" % port)
## Creates a client peer and connects to specified [param host] and [param port]
func join(host:String = "localhost", port:int = 9000) -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_client(host, port)
multiplayer.multiplayer_peer = peer
#@rpc("any_peer", "call_remote", "reliable")
#func leave() -> void:
#if multiplayer.is_server():
#var peer_id:int = multiplayer.get_remote_sender_id()
#peer_disconnecting.emit(peer_id)
#multiplayer.disconnect_peer(peer_id)
## Emitted when a multiplayer peer successfully connects to a server. Only emitted on the server.
func _on_peer_connected(peer_id: int) -> void:
print("peer `%d` connected" % peer_id)
func _on_peer_disconnected(peer_id:int) -> void:
print("peer `%d` disconnected" % peer_id)

View file

@ -0,0 +1 @@
uid://b38or3gq60cdq

145
scripts/multiplayer/ping.gd Normal file
View file

@ -0,0 +1,145 @@
# 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 defines a ping manager.
##
## When added to your scene and connect the [member synchronized] signal to your
## custom handler in order to receive ping updates on a regular basis (as
## defined by [param ping_interval] and synchronize the state for each peer.
## [codeblock]
## func _ready() -> void:
## $Ping.synchronized.connect(_on_ping_sync)
##
## func _on_ping_sync(state: Dictionary) -> void:
## for peer_id in state:
## print("peer `%s` latency is %d ms" % [peer_id, state[peer_id]])
## [/codeblock]
@icon("res://assets/icons/ping.svg")
class_name Ping extends Timer
## Emitted when the state is synchronized.
signal synchronized(state : Dictionary)
## The size of the ping history buffer used to store past ping times for each client.
@export var ping_history: int = 8:
set = set_ping_history
## The ping state being synchronized to connected peers
@export var _state := {}
## The dictionary of [PingInfo] for each connected peer id
var _infos := {}
## The array of times when a ping request occured
var _pings := []
var _last_ping := 0
## This class defines a ping information to keep track of ping results
## and provide average ping.
class PingInfo:
const SIZE := 32
var samples := PackedInt32Array()
var pos := 0
func _init() -> void:
samples.resize(SIZE)
samples.fill(0)
func get_average() -> int:
var value := 0
for sample in samples:
value += sample
@warning_ignore("integer_division")
return value / SIZE if value > 0 else 0
func set_next(value: int) -> void:
samples[pos] = value
pos = (pos + 1) % SIZE
func _ready() -> void:
clear_pings()
multiplayer.peer_connected.connect(_add_peer)
multiplayer.peer_disconnected.connect(_del_peer)
timeout.connect(_on_timeout)
func _on_timeout() -> void:
if _infos:
# check and update ping intervals
var now := Time.get_ticks_msec()
if now >= _pings[_last_ping] + wait_time:
_last_ping = (_last_ping + 1) % ping_history
_pings[_last_ping] = now
_ping.rpc(now)
# reset state
_state.clear()
# iterate over registered clients
for peer_id:int in _infos:
# set client average ping state
_state[peer_id] = _infos[peer_id].get_average()
# send state to connected peers
_synchronize.rpc(_state)
## Clears the ping history array.
func clear_pings() -> void:
_last_ping = 0
_pings.resize(ping_history)
_pings.fill(0)
## Sets the size of the ping history buffer and clears existing ping data.
func set_ping_history(value: int) -> void:
if value < 1:
return
ping_history = value
clear_pings()
## Adds a new peer to the registry
func _add_peer(peer_id: int) -> void:
_infos[peer_id] = PingInfo.new()
## Deletes a peer from the registry
func _del_peer(peer_id: int) -> void:
_infos.erase(peer_id)
@rpc("authority", "call_remote", "unreliable")
func _ping(time: int) -> void:
_pong.rpc_id(get_multiplayer_authority(), time)
@rpc("any_peer", "call_remote", "unreliable")
func _pong(time: int) -> void:
if not multiplayer.is_server():
return
# get id of the peer sending the pong
var peer_id: int = multiplayer.get_remote_sender_id()
# check if peer exists in the registered clients dictionary
if not _infos.has(peer_id):
return
# init variables
var now := Time.get_ticks_msec()
var last := (_last_ping + 1) % ping_history
var found := ping_history * wait_time
# search related ping in history
for i in range(ping_history):
# check if current ping matches received pong
if time == _pings[last]:
found = _pings[last]
break
# move to next ping in history
last = (last + 1) % ping_history
# compute round-trip time (RTT) and update ping info for this peer
_infos[peer_id].set_next((now - found) / 2)
@rpc("authority", "call_local", "unreliable")
func _synchronize(state: Dictionary) -> void:
_state = state
synchronized.emit(_state)

View file

@ -0,0 +1 @@
uid://cyobq87tprm0d

View file

@ -0,0 +1,55 @@
extends Node
const TICK_RATE := 128
const TICK_INTERVAL := 1.0 / TICK_RATE
var tick_timer := 0.0
var server_time := 0.0
# Keep track of players
var players := {}
func _start_server(port:int = 9000) -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_server(port, 1024)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
prints("server started on port", port)
func _process(delta: float) -> void:
tick_timer += delta
while tick_timer >= TICK_INTERVAL:
_server_tick()
tick_timer -= TICK_INTERVAL
func _server_tick() -> void:
server_time += TICK_INTERVAL
# for each player, update logic
for id:int in players.keys():
var player:Player = players[id]
player["position"] += player["velocity"] * TICK_INTERVAL
# broadcast game state to all clients
update_player_states.rpc(players)
func _on_peer_connected(peer_id:int) -> void:
print("Player connected:", peer_id)
players[peer_id] = {
"position": Vector2.ZERO,
"velocity": Vector2.ZERO
}
func _on_peer_disconnected(peer_id:int) -> void:
print("Player disconnected:", peer_id)
players.erase(peer_id)
@rpc("any_peer")
func send_input(input_velocity: Vector2) -> void:
var id:int = multiplayer.get_remote_sender_id()
if players.has(id):
players[id]["velocity"] = input_velocity
@rpc("authority", "unreliable")
func update_player_states(state:Array[Dictionary]) -> void:
print(state)
pass # No-op on server

View file

@ -0,0 +1 @@
uid://con7k2g4jmaht

View file

@ -0,0 +1,85 @@
# 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 a Team.
class_name Team extends RefCounted
## Emitted when the [Team] is renamed.
signal renamed(team_name: String)
## Emitted when a peer_id is added to the [Team].
signal player_added(team_name: String, peer_id: int, username: String)
## Emitted when a peer_id is erased from the [Team].
signal player_erased(team_name: String, peer_id: int)
var name : String:
set(new_name):
name = new_name
renamed.emit(name)
var players: Dictionary = {}
## Constructor.
func _init(team_name: String, team_players: Dictionary = {}) -> void:
name = team_name
for peer_id : int in team_players:
add(peer_id, team_players[peer_id])
# This is the iterator index cursor.
var _iter_cursor : int = 0
# This method is an iterator initializer.
func _iter_init(_arg : Variant) -> bool:
_iter_cursor = 0 # reset
print(_iter_cursor < players.size())
return _iter_cursor < players.size()
# This method checks if the iterator has a next value.
func _iter_next(_arg : Variant) -> bool:
_iter_cursor += 1
return _iter_cursor < players.size()
# This method gets the next iterator value.
func _iter_get(_arg : Variant) -> int:
return players.keys()[_iter_cursor]
## Add [param peer_id] to the [Team] if not already present.
func add(peer_id: int, username : String = "") -> void:
if peer_id not in players:
players[peer_id] = username
player_added.emit(name, peer_id, username)
## Erase [param peer_id] from the [Team].
func erase(peer_id: int) -> bool:
if players.erase(peer_id):
player_erased.emit(name, peer_id)
return true
return false
## This method pops out the related [param peer_id] string.
func pop(peer_id: int) -> String:
var value : String = players[peer_id]
erase(peer_id)
return value
## Returns the size of the team.
func size() -> int:
return players.size()
func serialize() -> Variant:
return [name, players]
func _to_string() -> String:
return "<Team(%s)#%s>" % [name, get_instance_id()]

View file

@ -0,0 +1 @@
uid://d4dn67d01lrkr

View file

@ -0,0 +1,141 @@
# 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 a Teams manager
class_name Teams extends Resource
## Emitted when a [Team] is created.
signal team_added(team_name : String)
## Emitted when a [Team] is removed.
signal team_erased(team_name : String)
## Emitted when a [Player] is added to a [Team].
signal player_added(team_name: String, peer_id: int, username: String)
## Emitted when a [Player] is removed from a [Team].
signal player_erased(team_name: String, peer_id: int)
# The synced teams state.
@export var _state : Dictionary = {}:
get = get_state
func get_state() -> Dictionary:
for team : Team in _teams.values():
_state[team.name] = team.players
return _state
# The internal dictionary of teams.
var _teams : Dictionary = {}
## Retrieves an [Array] of [int] by [param team_name].
## [codeblock]
## var teams := Teams.new()
## teams.add_team("Phoenix")
## print(teams["Phoenix"]) # this calls `_get`
## [/codeblock]
func _get(team_name : StringName) -> Variant:
return _teams.get(team_name)
## Sets a [Team] by [param name] with an optional [param default].
## [codeblock]
## var teams := Teams.new()
## teams["Phoenix"] = Teams.Team.new("Phoenix") # this calls `_set`
## teams["Phoenix"] = null # refcount for Team is now 0
## [/codeblock]
func _set(key : StringName, team : Variant) -> bool:
if team == null:
return erase(key)
if team is Team:
_teams[key] = team
team_added.emit(key)
return true
return false
## This method retrieves the array of managed [Team] names.
func keys() -> Array:
return _teams.keys()
## This method retrieves the array of managed [Team].
func values() -> Array:
return _teams.values()
# This is the iterator index cursor.
var _iter_cursor : int = 0
# This method is an iterator initializer.
func _iter_init(_arg : Variant) -> bool:
_iter_cursor = 0 # reset
return _iter_cursor < len(_teams)
# This method checks if the iterator has a next value.
func _iter_next(_arg : Variant) -> bool:
_iter_cursor += 1
return _iter_cursor < len(_teams)
# This method gets the next iterator value.
func _iter_get(_arg : Variant) -> Team:
return _teams.values()[_iter_cursor]
## This method adds a new [Team] into the manager.
func add_team(key: String) -> bool:
if not _teams.has(key):
var team : Team = Team.new(key)
team.player_added.connect(_on_team_player_added)
team.player_erased.connect(_on_team_player_erased)
return _set(key, team)
return false
## This method adds new [Team] in batch from [param team_names].
func add_teams(team_names: Array[String]) -> Teams:
for team_name in team_names:
add_team(team_name)
return self
func get_peer_team(peer_id : int) -> Team:
for team : Team in _teams.values():
if peer_id in team.players:
return team
return null
func erase(team_name: String) -> bool:
var res: bool = _teams.erase(team_name)
if res:
team_erased.emit(team_name)
return res
func erase_player(peer_id: int) -> void:
for team : Team in _teams.values():
if team.erase(peer_id):
break
## The number of [Team].
func size() -> int:
return len(_teams)
## This method eases peer team switching by moving them to the specified [param team_name].
## It does not do anything if the peer is not already added to a [Team], see [method Team.add].
func switch_team(peer_id: int, team_name: String) -> void:
var lhs:Team = get_peer_team(peer_id)
var rhs:Team = _get(team_name)
if lhs and rhs:
rhs.add(peer_id, lhs.pop(peer_id))
# This method runs when a Team emits `player_added` signal
func _on_team_player_added(team_name: String, peer_id: int, username: String) -> void:
player_added.emit(team_name, peer_id, username) # emit manager signal
# This method runs when a Team emits `player_erased` signal
func _on_team_player_erased(team_name: String, peer_id: int) -> void:
player_erased.emit(team_name, peer_id) # emit manager signal

View file

@ -0,0 +1 @@
uid://dgapvl7gqfd5r

View file

@ -0,0 +1,43 @@
# 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/>.
class_name Singleplayer extends Node
@export var player:Player
@export var flag:Flag
@export var map:Map
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
player.health.exhausted.connect(_on_player_dead)
var spawn:Node = map.get_objective_spawn()
flag.global_position = spawn.global_position
player.damage.connect(
func(_source: Node, target: Node, amount: int) -> void:
target.health.damage(amount, 0))
var _on_exit := func() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
Game.change_scene()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("exit"):
if OS.is_debug_build():
_on_exit.call()
else:
player.hud.ask("Are you sure you want to leave?", _on_exit)
func _on_player_dead(_player:Player) -> void:
var spawn:Node3D = map.get_player_spawn()
player.respawn(spawn.global_position)

View file

@ -0,0 +1 @@
uid://bjcxla4jmjui1