mirror of
https://codeberg.org/sunder/sunder.git
synced 2026-07-14 21:44:36 +00:00
first commit 🎉
This commit is contained in:
commit
6e724f67fe
805 changed files with 62098 additions and 0 deletions
350
scenes/multiplayer/match/scripts/match.gd
Normal file
350
scenes/multiplayer/match/scripts/match.gd
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# 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
|
||||
1
scenes/multiplayer/match/scripts/match.gd.uid
Normal file
1
scenes/multiplayer/match/scripts/match.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://qjfg7512prb
|
||||
7
scenes/multiplayer/match/scripts/mode.gd
Normal file
7
scenes/multiplayer/match/scripts/mode.gd
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class_name Mode extends Resource
|
||||
|
||||
@export var name:String
|
||||
@export var description:String
|
||||
|
||||
func setup(_match: Match) -> void:
|
||||
pass
|
||||
1
scenes/multiplayer/match/scripts/mode.gd.uid
Normal file
1
scenes/multiplayer/match/scripts/mode.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cfh83w7jb0iu4
|
||||
51
scenes/multiplayer/match/scripts/modes.gd
Normal file
51
scenes/multiplayer/match/scripts/modes.gd
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
class_name Modes extends Resource
|
||||
|
||||
var _iterable:Array[Mode]
|
||||
|
||||
# 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 # array
|
||||
return len(_iterable)
|
||||
|
||||
# This method checks if the iterator has a next value.
|
||||
func _iter_next(_arg : Variant) -> bool:
|
||||
_iter_cursor += 1
|
||||
return _iter_cursor < len(_iterable)
|
||||
|
||||
# This method gets the next iterator value.
|
||||
func _iter_get(_arg : Variant) -> Mode:
|
||||
return _iterable[_iter_cursor]
|
||||
|
||||
func _set(name:StringName, value:Variant) -> bool:
|
||||
var index:int = -1
|
||||
for mode:Mode in _iterable:
|
||||
if mode.name == name:
|
||||
index = _iterable.find(mode)
|
||||
if index > -1:
|
||||
# @NOTE: here would be a good place to avoid adding a null value in the
|
||||
# array in case it is the current behavior
|
||||
_iterable[index] = value
|
||||
return true
|
||||
else:
|
||||
return false
|
||||
|
||||
func _get(name:StringName) -> Variant:
|
||||
for mode:Mode in _iterable:
|
||||
if mode.name == name:
|
||||
return mode
|
||||
return null
|
||||
|
||||
func size() -> int:
|
||||
return len(_iterable)
|
||||
|
||||
func append(mode:Mode) -> void:
|
||||
_iterable.append(mode)
|
||||
|
||||
func erase(name:String) -> void:
|
||||
for mode in _iterable:
|
||||
if mode.name == name:
|
||||
_iterable.erase(mode)
|
||||
break
|
||||
1
scenes/multiplayer/match/scripts/modes.gd.uid
Normal file
1
scenes/multiplayer/match/scripts/modes.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ev0whaj67wlc
|
||||
17
scenes/multiplayer/match/scripts/modes/ffa.gd
Normal file
17
scenes/multiplayer/match/scripts/modes/ffa.gd
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
extends "res://scenes/multiplayer/match/scripts/mode.gd"
|
||||
|
||||
func setup(p_match:Match) -> void:
|
||||
p_match.scoreboard.add_panel()
|
||||
p_match.scoreboard.add_child(DeathmatchScoringComponent.new(p_match.scoreboard, p_match.player_spawner))
|
||||
#p_match.scoreboard.scoring_resources.append(DeathmatchScoringComponent.new(p_match.scoreboard, p_match.players))
|
||||
p_match.player_spawner.child_entered_tree.connect(func(player:Player) -> void:
|
||||
player.iff.fill = Color.RED
|
||||
var panel:ScorePanel = p_match.scoreboard.get_panel(0)
|
||||
panel.add_entry(player.peer_id, player.username)
|
||||
player.damage.connect(_ffa_damage_handler)
|
||||
)
|
||||
|
||||
|
||||
func _ffa_damage_handler(source: Node, target: Node, amount: float) -> void:
|
||||
assert(target.find_children("*", "Health"))
|
||||
target.health.damage.rpc(amount, source.peer_id)
|
||||
1
scenes/multiplayer/match/scripts/modes/ffa.gd.uid
Normal file
1
scenes/multiplayer/match/scripts/modes/ffa.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ker32mqppmyt
|
||||
42
scenes/multiplayer/match/scripts/modes/rabbit.gd
Normal file
42
scenes/multiplayer/match/scripts/modes/rabbit.gd
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
extends "res://scenes/multiplayer/match/scripts/mode.gd"
|
||||
|
||||
var match:Match
|
||||
|
||||
func setup(p_match:Match) -> void:
|
||||
var flag:Flag = Flag.instantiate()
|
||||
p_match.objective_spawner.add_child(flag)
|
||||
var spawn := p_match.map.get_spawn_position("objectives")
|
||||
flag.global_position = spawn
|
||||
flag.respawn_timer.timeout.connect(func() -> void:
|
||||
if spawn:
|
||||
flag.waypoint.text = ""
|
||||
flag.global_position = spawn
|
||||
flag.state = flag.FlagState.ON_STAND
|
||||
)
|
||||
p_match.teams.add_teams(["rabbit", "chasers"])
|
||||
flag.grabbed.connect(
|
||||
func(carry:FlagHolder) -> void:
|
||||
carry.owner.hud.objective_label.set_visible(true)
|
||||
p_match.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)
|
||||
p_match.switch_team(carry.owner.peer_id, "chasers")
|
||||
flag.respawn_timer.start()
|
||||
)
|
||||
p_match.scoreboard.add_child(
|
||||
RabbitScoringComponent.new(p_match.scoreboard, flag))
|
||||
p_match.scoreboard.add_child(
|
||||
DeathmatchScoringComponent.new(p_match.scoreboard, p_match.player_spawner))
|
||||
|
||||
p_match.player_spawner.child_entered_tree.connect(func(player:Player) -> void:
|
||||
p_match.teams["chasers"].add(player.peer_id, player.username)
|
||||
player.damage.connect(_rabbit_damage_handler)
|
||||
)
|
||||
|
||||
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)
|
||||
1
scenes/multiplayer/match/scripts/modes/rabbit.gd.uid
Normal file
1
scenes/multiplayer/match/scripts/modes/rabbit.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://blvw3hbunban1
|
||||
Loading…
Add table
Add a link
Reference in a new issue