586 lines
16 KiB
GDScript
586 lines
16 KiB
GDScript
# MIT License
|
|
#
|
|
# Copyright (c) 2020-2020 Macaroni Studios AB
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all
|
|
# copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
class_name _GotmImpl
|
|
#warnings-disable
|
|
|
|
|
|
# Utility sorter for 'sort_custom'.
|
|
class LobbySorter:
|
|
var fetch
|
|
var g
|
|
|
|
func sort(lhs, rhs) -> bool:
|
|
var a
|
|
var b
|
|
if fetch.sort_property.empty():
|
|
a = lhs._impl.created
|
|
b = rhs._impl.created
|
|
else:
|
|
a = lhs._impl.props[fetch.sort_property]
|
|
b = rhs._impl.props[fetch.sort_property]
|
|
|
|
if fetch.sort_ascending:
|
|
return _GotmImplUtility.is_less(a, b)
|
|
else:
|
|
return _GotmImplUtility.is_greater(a, b)
|
|
|
|
|
|
# Generate 20 characters long random string.
|
|
static func _generate_id() -> String:
|
|
var g = _get_gotm()
|
|
var id: String = ""
|
|
for i in range(20):
|
|
id += g._impl.chars[g._impl.rng.randi() % g._impl.chars.length()]
|
|
return id
|
|
|
|
|
|
# Retrieve scene statically via Engine singleton.
|
|
static func _get_tree() -> SceneTree:
|
|
return Engine.get_main_loop() as SceneTree
|
|
|
|
|
|
# Get autoloaded Gotm instance from scene's root.
|
|
static func _get_gotm() -> Node:
|
|
return _get_tree().root.get_node("Gotm")
|
|
|
|
|
|
# Simplify string somewhat. We want exact matches, but with some reasonable fuzziness.
|
|
static func _init_search_string_encoders() -> Array:
|
|
var encoders: Array = [
|
|
["[àáâãäå]", "a"],
|
|
["[èéêë]", "e"],
|
|
["[ìíîï]", "i"],
|
|
["[òóôõöő]", "o"],
|
|
["[ùúûüű]", "u"],
|
|
["[ýŷÿ]", "y"],
|
|
["ñ", "n"],
|
|
["[çc]", "k"],
|
|
["ß", "s"],
|
|
["[-/]", " "],
|
|
["[^a-z0-9 ]", ""],
|
|
["\\s+", " "],
|
|
["^\\s+", ""],
|
|
["\\s+$", ""]
|
|
]
|
|
for encoder in encoders:
|
|
var regex: RegEx = RegEx.new()
|
|
regex.compile(encoder[0])
|
|
encoder[0] = regex
|
|
|
|
return encoders
|
|
|
|
|
|
# Initialize socket for fetching lobbies on local network.
|
|
static func _init_socket() -> void:
|
|
var g = _get_gotm()
|
|
if not g._impl.sockets:
|
|
g._impl.sockets = []
|
|
var is_listening = false
|
|
for i in range(5):
|
|
var socket = PacketPeerUDP.new()
|
|
if socket.has_method("set_broadcast_enabled"):
|
|
socket.set_broadcast_enabled(true)
|
|
if not is_listening:
|
|
is_listening = socket.listen(8075 + i) == OK
|
|
socket.set_dest_address("255.255.255.255", 8075 + i)
|
|
g._impl.sockets.push_back(socket)
|
|
|
|
if not is_listening:
|
|
push_error("Failed to listen for lobbies. All ports 8075-8079 are busy.")
|
|
|
|
|
|
# Attach some global state to autoloaded Gotm instance.
|
|
static func _initialize(GotmLobbyT, GotmUserT) -> void:
|
|
var g = _get_gotm()
|
|
|
|
g._impl = {
|
|
"lobbies": [],
|
|
"chars": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-",
|
|
"rng": RandomNumberGenerator.new(),
|
|
"search_string_encoders": _init_search_string_encoders(),
|
|
"sockets": null,
|
|
"lobby_requests": {},
|
|
"GotmLobbyT": GotmLobbyT,
|
|
"GotmUserT": GotmUserT,
|
|
"is_listening": false
|
|
}
|
|
g._impl.rng.randomize()
|
|
g.user._impl.id = _generate_id()
|
|
g.user.address = "localhost"
|
|
|
|
|
|
static func _process() -> void:
|
|
var g = _get_gotm()
|
|
|
|
if g.lobby:
|
|
if OS.get_system_time_msecs() - g.lobby._impl.last_heartbeat > 2500:
|
|
_init_socket()
|
|
_put_sockets({
|
|
"op": "peer_heartbeat",
|
|
"data": {
|
|
"lobby_id": g.lobby.id,
|
|
"id": g.user._impl.id
|
|
}
|
|
})
|
|
g.lobby._impl.last_heartbeat = OS.get_system_time_msecs()
|
|
|
|
if g._impl.sockets:
|
|
for socket in g._impl.sockets:
|
|
while socket.get_available_packet_count() > 0:
|
|
var v = socket.get_var()
|
|
if v.op == "get_lobbies":
|
|
var data = null
|
|
if g.lobby and g.lobby.is_host():
|
|
data = {
|
|
"id": g.lobby.id,
|
|
"name": g.lobby.name,
|
|
"peers": [],
|
|
"invite_link": g.lobby.invite_link,
|
|
"_impl": g.lobby._impl
|
|
}
|
|
|
|
_put_sockets({"op": "lobby", "data": data, "id": v.id})
|
|
elif v.op == "leave_lobby":
|
|
if g.lobby and v.data.lobby_id == g.lobby.id:
|
|
if g.lobby.is_host():
|
|
_put_sockets({
|
|
"op": "peer_left",
|
|
"data": {
|
|
"lobby_id": g.lobby.id,
|
|
"id": v.data.id
|
|
}
|
|
})
|
|
elif v.data.id == g.lobby.host._impl.id:
|
|
_leave_lobby(g.lobby)
|
|
elif v.op == "join_lobby":
|
|
var data = null
|
|
if g.lobby and g.lobby.is_host() and v.data.lobby_id == g.lobby.id:
|
|
_put_sockets({
|
|
"op": "peer_joined",
|
|
"data": {
|
|
"lobby_id": g.lobby.id,
|
|
"address": socket.get_packet_ip(),
|
|
"id": v.data.id
|
|
},
|
|
"id": v.id
|
|
})
|
|
|
|
var peers = []
|
|
for peer in g.lobby.peers:
|
|
peers.push_back({"address": peer.address, "_impl": peer._impl})
|
|
data = {
|
|
"id": g.lobby.id,
|
|
"name": g.lobby.name,
|
|
"peers": peers,
|
|
"invite_link": g.lobby.invite_link,
|
|
"_impl": g.lobby._impl
|
|
}
|
|
_put_sockets({"op": "lobby", "data": data, "id": v.id})
|
|
elif v.op == "peer_left":
|
|
if g.lobby and v.data.lobby_id == g.lobby.id:
|
|
for peer in g.lobby.peers.duplicate():
|
|
if peer._impl.id == v.data.id:
|
|
g.lobby.peers.erase(peer)
|
|
g.lobby.emit_signal("peer_left", peer)
|
|
elif v.op == "peer_joined":
|
|
if g.lobby and v.data.lobby_id == g.lobby.id and not g._impl.lobby_requests.has(v.id):
|
|
var peer = g._impl.GotmUserT.new()
|
|
peer.address = v.data.address
|
|
peer._impl.id = v.data.id
|
|
g.lobby.peers.push_back(peer)
|
|
g.lobby.emit_signal("peer_joined", peer)
|
|
if g.lobby.is_host():
|
|
g.lobby._impl.heartbeats[v.data.id] = OS.get_system_time_msecs()
|
|
elif v.op == "peer_heartbeat":
|
|
if g.lobby and v.data.lobby_id == g.lobby.id:
|
|
g.lobby._impl.heartbeats[v.data.id] = OS.get_system_time_msecs()
|
|
elif v.op == "lobby":
|
|
if v.data and g._impl.lobby_requests.has(v.id):
|
|
var lobby = g._impl.GotmLobbyT.new()
|
|
var peers = []
|
|
if not v.data._impl.host_id.empty():
|
|
v.data.peers.push_back({
|
|
"address": socket.get_packet_ip(),
|
|
"_impl": {
|
|
"id": v.data._impl.host_id
|
|
}
|
|
})
|
|
for peer in v.data.peers:
|
|
var p = g._impl.GotmUserT.new()
|
|
p.address = peer.address
|
|
p._impl = peer._impl
|
|
peers.push_back(p)
|
|
|
|
lobby.hidden = false
|
|
lobby.locked = false
|
|
lobby.id = v.data.id
|
|
lobby.name = v.data.name
|
|
lobby.peers = peers
|
|
lobby.invite_link = v.data.invite_link
|
|
lobby._impl = v.data._impl
|
|
lobby._impl.address = socket.get_packet_ip()
|
|
lobby.me.address = "127.0.0.1"
|
|
lobby.host.address = socket.get_packet_ip()
|
|
lobby.host._impl.id = v.data._impl.host_id
|
|
g._impl.lobby_requests[v.id].push_back(lobby)
|
|
|
|
if g.lobby:
|
|
for peer_id in g.lobby._impl.heartbeats.duplicate():
|
|
if OS.get_system_time_msecs() - g.lobby._impl.heartbeats[peer_id] > 10000:
|
|
if g.lobby.is_host():
|
|
_put_sockets({
|
|
"op": "peer_left",
|
|
"data": {
|
|
"lobby_id": g.lobby.id,
|
|
"id": peer_id
|
|
}
|
|
})
|
|
g.lobby._impl.heartbeats.erase(peer_id)
|
|
elif peer_id == g.lobby.host._impl.id:
|
|
_leave_lobby(g.lobby)
|
|
break
|
|
|
|
|
|
|
|
|
|
# Improve search experience a little by adding fuzziness.
|
|
static func _encode_search_string(s: String) -> String:
|
|
s = s.to_lower()
|
|
var encoders: Array = _get_gotm()._impl.search_string_encoders
|
|
for encoder in encoders:
|
|
s = encoder[0].sub(s, encoder[1], true)
|
|
return s
|
|
|
|
|
|
# Return true if 'lobby' matches filter options in 'fetch'.
|
|
static func _match_lobby(lobby, fetch) -> bool:
|
|
if lobby.locked or lobby.hidden:
|
|
return false
|
|
|
|
if not fetch.filter_name.empty():
|
|
var name: String = _encode_search_string(lobby.name)
|
|
var query: String = _encode_search_string(fetch.filter_name)
|
|
if not query.empty() and name.find(query) < 0:
|
|
return false
|
|
|
|
var lobby_props: Dictionary = {}
|
|
for key in lobby._impl.filterable_props:
|
|
if not lobby._impl.props.has(key):
|
|
return false
|
|
if not fetch.filter_properties.has(key):
|
|
return false
|
|
|
|
var lhs = fetch.filter_properties[key]
|
|
var rhs = lobby._impl.props[key]
|
|
if lhs != null and lhs != rhs:
|
|
return false
|
|
|
|
return true
|
|
|
|
|
|
# Used to detect changes.
|
|
static func _stringify_fetch_state(fetch) -> String:
|
|
var d: Array = [
|
|
fetch.filter_name,
|
|
fetch.filter_properties,
|
|
fetch.sort_property,
|
|
fetch.sort_property,
|
|
fetch.sort_ascending,
|
|
fetch.sort_min,
|
|
fetch.sort_max,
|
|
fetch.sort_min_exclusive,
|
|
fetch.sort_max_exclusive
|
|
]
|
|
return JSON.print(d)
|
|
|
|
|
|
|
|
# Return sorted copy of 'lobbies' using sort options in 'fetch'.
|
|
static func _sort_lobbies(lobbies: Array, fetch) -> Array:
|
|
var sorted: Array = []
|
|
var g = _get_gotm()
|
|
for lobby in lobbies:
|
|
if fetch.sort_property.empty():
|
|
sorted.push_back(lobby)
|
|
|
|
var v = lobby._impl.props.get(fetch.sort_property)
|
|
if v == null:
|
|
continue
|
|
if fetch.sort_min != null:
|
|
if _GotmImplUtility.is_less(v, fetch.sort_min):
|
|
continue
|
|
if fetch.sort_min_exclusive and not _GotmImplUtility.is_greater(v, fetch.sort_min):
|
|
continue
|
|
if fetch.sort_max != null:
|
|
if _GotmImplUtility.is_greater(v, fetch.sort_max):
|
|
continue
|
|
if fetch.sort_max_exclusive and not _GotmImplUtility.is_less(v, fetch.sort_max):
|
|
continue
|
|
|
|
sorted.push_back(lobby)
|
|
|
|
|
|
var sorter: LobbySorter = LobbySorter.new()
|
|
sorter.fetch = fetch
|
|
sorter.g = g
|
|
sorted.sort_custom(sorter, "sort")
|
|
|
|
return sorted
|
|
|
|
|
|
static func _put_sockets(v: Dictionary):
|
|
_init_socket()
|
|
for socket in _get_gotm()._impl.sockets:
|
|
socket.put_var(v)
|
|
|
|
|
|
static func _request_lobbies() -> Array:
|
|
var g = _get_gotm()
|
|
var request_id: String = _generate_id()
|
|
g._impl.lobby_requests[request_id] = []
|
|
_put_sockets({"op": "get_lobbies", "id": request_id})
|
|
yield(g.get_tree().create_timer(0.5), "timeout")
|
|
|
|
var lobbies = g._impl.lobby_requests[request_id]
|
|
g._impl.lobby_requests.erase(request_id)
|
|
return lobbies
|
|
|
|
|
|
static func _request_join(lobby_id: String):
|
|
var g = _get_gotm()
|
|
var request_id: String = _generate_id()
|
|
g._impl.lobby_requests[request_id] = []
|
|
_put_sockets({
|
|
"op": "join_lobby",
|
|
"id": request_id,
|
|
"data": {
|
|
"lobby_id": lobby_id,
|
|
"id": g.user._impl.id
|
|
}
|
|
})
|
|
yield(g.get_tree().create_timer(0.5), "timeout")
|
|
|
|
var lobbies = g._impl.lobby_requests[request_id]
|
|
g._impl.lobby_requests.erase(request_id)
|
|
for lobby in lobbies:
|
|
if lobby:
|
|
return lobby
|
|
return null
|
|
|
|
|
|
static func _fetch_lobbies(fetch, count: int, type: String) -> Array:
|
|
var g = _get_gotm()
|
|
|
|
# Reset fetch state if user has modified any options.
|
|
var stringified_state: String = _stringify_fetch_state(fetch)
|
|
if not fetch._impl.has("last_state") or stringified_state != fetch._impl.last_state:
|
|
fetch._impl.last_state = stringified_state
|
|
fetch._impl.last_lobby = -1
|
|
fetch._impl.start_lobby = -1
|
|
|
|
|
|
# Apply filter options
|
|
var lobbies: Array = []
|
|
for lobby in yield(_request_lobbies(), "completed") + g._impl.lobbies:
|
|
if _match_lobby(lobby, fetch):
|
|
lobbies.push_back(lobby)
|
|
|
|
# Apply sort options
|
|
lobbies = _sort_lobbies(lobbies, fetch)
|
|
count = min(8, count)
|
|
var index: int = 0
|
|
if type == "first":
|
|
index = 0
|
|
elif type == "next":
|
|
index = fetch._impl.last_lobby + 1
|
|
elif type == "current":
|
|
index = fetch._impl.start_lobby + 1
|
|
elif type == "previous":
|
|
index = max(fetch._impl.start_lobby - count, 0)
|
|
|
|
# Get 'count' lobbies.
|
|
var result: Array = []
|
|
for i in range(index, min(index + count, lobbies.size())):
|
|
result.push_back(lobbies[i])
|
|
|
|
# Write down last lobby for subsequent 'next' calls.
|
|
if not result.empty():
|
|
var start: int = lobbies.find(result.front()) - 1
|
|
fetch._impl.start_lobby = max(start, -1)
|
|
fetch._impl.last_lobby = lobbies.find(result.back())
|
|
elif index > 0:
|
|
fetch._impl.start_lobby = fetch._impl.last_lobby
|
|
|
|
yield(_get_tree().create_timer(0.25), "timeout") # fake delay
|
|
return result
|
|
|
|
|
|
# Common initialization.
|
|
static func _add_lobby(lobby):
|
|
var g = _get_gotm()
|
|
|
|
lobby.id = _generate_id()
|
|
lobby.invite_link = "https://gotm.io/my-studio/my-game/"
|
|
lobby.invite_link += "?connectToken=" + _generate_id()
|
|
lobby._impl = {
|
|
# Not exposed to user, so doesn't have to be a real timestamp.
|
|
"created": OS.get_system_time_msecs(),
|
|
"props": {},
|
|
"sortable_props": [],
|
|
"filterable_props": [],
|
|
"heartbeats": {},
|
|
"last_heartbeat": 0,
|
|
"host_id": "",
|
|
"address": ""
|
|
}
|
|
lobby.me._impl.id = g.user._impl.id
|
|
|
|
g._impl.lobbies.push_back(lobby)
|
|
return lobby
|
|
|
|
|
|
static func _host_lobby(lobby):
|
|
var g = _get_gotm()
|
|
_leave_lobby(g.lobby)
|
|
|
|
lobby = _add_lobby(lobby)
|
|
lobby._impl.address = "127.0.0.1"
|
|
lobby.host.address = "127.0.0.1"
|
|
lobby._impl.host_id = g.user._impl.id
|
|
lobby.host._impl.id = g.user._impl.id
|
|
lobby.me.address = "127.0.0.1"
|
|
g.lobby = lobby
|
|
g.emit_signal("lobby_changed")
|
|
|
|
_init_socket()
|
|
|
|
return lobby
|
|
|
|
|
|
static func _join_lobby(lobby) -> bool:
|
|
var g = _get_gotm()
|
|
_leave_lobby(g.lobby)
|
|
|
|
if not g._impl.lobbies.has(lobby):
|
|
lobby = yield(_request_join(lobby.id), "completed")
|
|
else:
|
|
yield(g.get_tree().create_timer(0.25), "timeout")
|
|
|
|
if not lobby or lobby.locked:
|
|
return false
|
|
|
|
lobby.host.address = lobby._impl.address
|
|
lobby.host._impl.id = lobby._impl.host_id
|
|
lobby.me.address = "127.0.0.1"
|
|
g.lobby = lobby
|
|
g.emit_signal("lobby_changed")
|
|
return true
|
|
|
|
|
|
static func _is_lobby_host(lobby) -> bool:
|
|
var g = _get_gotm()
|
|
return lobby.host._impl.id == g.user._impl.id
|
|
|
|
|
|
static func _kick_lobby_peer(lobby, peer) -> bool:
|
|
var g = _get_gotm()
|
|
|
|
if not lobby.is_host():
|
|
return false
|
|
|
|
if g.user._impl.id == peer._impl.id:
|
|
_leave_lobby(lobby)
|
|
else:
|
|
for p in lobby.peers.duplicate():
|
|
if p._impl.id != peer._impl.id:
|
|
continue
|
|
lobby.peers.erase(p)
|
|
if lobby == g.lobby:
|
|
lobby.emit_signal("peer_left", p)
|
|
break
|
|
|
|
return true
|
|
|
|
|
|
static func _leave_lobby(lobby) -> void:
|
|
if not lobby:
|
|
return
|
|
|
|
var g = _get_gotm()
|
|
if g.lobby == lobby:
|
|
if lobby.host.address == lobby.me.address:
|
|
g._impl.lobbies.erase(lobby)
|
|
lobby.me.address = ""
|
|
lobby.host.address = ""
|
|
_put_sockets({
|
|
"op": "leave_lobby",
|
|
"data": {
|
|
"lobby_id": lobby.id,
|
|
"id": g.user._impl.id
|
|
}
|
|
})
|
|
g.lobby = null
|
|
g.emit_signal("lobby_changed")
|
|
|
|
|
|
static func _truncate_string(s: String) -> String:
|
|
return s.substr(0, 64)
|
|
|
|
static func _set_lobby_property(lobby, name: String, value) -> void:
|
|
name = _truncate_string(name)
|
|
if value == null:
|
|
lobby._impl.props.erase(name)
|
|
|
|
match typeof(value):
|
|
TYPE_BOOL:
|
|
pass
|
|
TYPE_INT:
|
|
pass
|
|
TYPE_REAL:
|
|
pass
|
|
TYPE_STRING:
|
|
value = _truncate_string(value)
|
|
_:
|
|
push_error("Invalid lobby property type.")
|
|
return
|
|
|
|
lobby._impl.props[name] = value
|
|
|
|
|
|
static func _get_lobby_property(lobby, name: String):
|
|
return lobby._impl.props[_truncate_string(name)]
|
|
|
|
static func _set_lobby_filterable(lobby, property_name: String, filterable: bool) -> void:
|
|
property_name = _truncate_string(property_name)
|
|
if not filterable:
|
|
lobby._impl.filterable_props.erase(property_name)
|
|
elif not lobby._impl.filterable_props.has(property_name):
|
|
lobby._impl.filterable_props.push_back(property_name)
|
|
|
|
|
|
static func _set_lobby_sortable(lobby, property_name: String, sortable: bool) -> void:
|
|
property_name = _truncate_string(property_name)
|
|
if not sortable:
|
|
lobby._impl.sortable_props.erase(property_name)
|
|
elif not lobby._impl.sortable_props.has(property_name):
|
|
lobby._impl.sortable_props.push_back(property_name)
|