diff --git a/Actors/Objects/ElectricSocket/ElectricSocket.tscn b/Actors/Objects/ElectricSocket/ElectricSocket.tscn index 641fba7..76c865c 100644 --- a/Actors/Objects/ElectricSocket/ElectricSocket.tscn +++ b/Actors/Objects/ElectricSocket/ElectricSocket.tscn @@ -14,13 +14,13 @@ void fragment() { if (col.r/col.g > 2.) { if (length(cable_color) == 0.) { if (UV.y > 0.6 && UV.y < 0.85) { - if (int(UV.x * 200.) % 12 > 4) { + if (float(int(UV.x * 200.) % 12) > 4.) { col.rgb = vec3(0.94, 0.08, 0.08) * length(col.rgb); } else { col.rgb = vec3(0.04, 0.58, 0.98) * length(col.rgb); } } else { - if (int(UV.y * 200.) % 12 > 4) { + if (float(int(UV.y * 200.) % 12) > 4.) { col.rgb = vec3(0.94, 0.08, 0.08) * length(col.rgb); } else { col.rgb = vec3(0.04, 0.58, 0.98) * length(col.rgb); @@ -32,7 +32,6 @@ void fragment() { } COLOR = col; }" -custom_defines = "" [sub_resource type="ShaderMaterial" id=2] shader = SubResource( 1 ) diff --git a/Classes/ResourceQueue.gd b/Classes/ResourceQueue.gd index 86652cc..b88c58c 100644 --- a/Classes/ResourceQueue.gd +++ b/Classes/ResourceQueue.gd @@ -4,6 +4,7 @@ var thread var mutex var sem +var gotm_mode = false var time_max = 100 # Milliseconds. var queue = [] @@ -58,6 +59,8 @@ func cancel_resource(path): func get_progress(path): + if gotm_mode: + return 1.0 _lock("get_progress") var ret = -1 if path in pending: @@ -70,6 +73,8 @@ func get_progress(path): func is_ready(path): + if gotm_mode: + return true var ret _lock("is_ready") if path in pending: @@ -81,6 +86,8 @@ func is_ready(path): func _wait_for_resource(res, path): + if gotm_mode: + return get_resource(path) _unlock("wait_for_resource") while true: VisualServer.sync() @@ -92,6 +99,8 @@ func _wait_for_resource(res, path): func get_resource(path): + if gotm_mode: + return load(path) _lock("get_resource") if path in pending: if pending[path] is ResourceInteractiveLoader: @@ -140,8 +149,10 @@ func thread_func(_u): thread_process() -func start(): - mutex = Mutex.new() - sem = Semaphore.new() - thread = Thread.new() - thread.start(self, "thread_func", 0) +func start(gotm: bool): + gotm_mode = gotm + if not gotm: + mutex = Mutex.new() + sem = Semaphore.new() + thread = Thread.new() + thread.start(self, "thread_func", 0) diff --git a/Scenes/Global/Multiplayer.gd b/Scenes/Global/Multiplayer.gd index 14408df..117da2a 100644 --- a/Scenes/Global/Multiplayer.gd +++ b/Scenes/Global/Multiplayer.gd @@ -15,12 +15,14 @@ const SYSTEMS_UPDATE_INTERVAL = 10 const MASTER_SERVER_ADDR = "fgms.zyg.ovh" const MASTER_SERVER_UDP_PORT = 9434 const MS_GAME_CODE = "odyssey-0-a1" +const GOTM_OVERRIDE = false # Master server entry var ms_active = false var ms_key = "" var server_name = "" +onready var gotm_mode = Gotm.is_live() or GOTM_OVERRIDE var hosting = false export var player_name = "" @@ -32,6 +34,8 @@ onready var scene_manager = $"/root/SceneManager" func _ready(): player_name = "tider-" + str(randi() % 1000) + if gotm_mode: + Gotm.connect("lobby_changed", self, "_lobby_changed") func bind_events(): get_tree().connect("network_peer_connected", self, "_player_connected") @@ -71,13 +75,23 @@ func host(): # Wait just a sec to draw yield(get_tree().create_timer(0.3), "timeout") - print("Running UPNP magicks") - if discover_upnp() == UPNP.UPNP_RESULT_SUCCESS: - print("UPNP mapping added") - else: - push_warning("UPNP magicks fail, punching NAT in the face") - yield(punch_nat(), "completed") + # Run port forwarding/nat punchthrough if the platform doesn't do it already + if not gotm_mode: + print("Running UPNP magicks") + if discover_upnp() == UPNP.UPNP_RESULT_SUCCESS: + print("UPNP mapping added") + else: + push_warning("UPNP magicks fail, punching NAT in the face") + yield(punch_nat(), "completed") + server_name = player_name + "'s server" + player_info[1] = { "name": player_name } + round_info = { "map": "odyssey" } + + if gotm_mode: + # Add to master server before hosting (in GOTM mode) + create_ms_entry() + bind_events() var peer = NetworkedMultiplayerENet.new() var server_res = peer.create_server(port, MAX_PLAYERS) @@ -91,9 +105,6 @@ func host(): get_tree().network_peer = peer print("Hosting") hosting = true - server_name = player_name + "'s server" - player_info[1] = { "name": player_name } - round_info = { "map": "odyssey" } scene_manager.loading_text = null @@ -101,18 +112,27 @@ func host(): "res://Scenes/Maps/odyssey.tscn" ]) - # Add to master server - create_ms_entry() + if not gotm_mode: + # Add to master server after hosting + create_ms_entry() -func join(addr: String): +func join(server): scene_manager.enter_loader() - scene_manager.loading_text = "Joining server " + str(addr) + scene_manager.loading_text = "Joining server" # Wait just a sec to draw yield(get_tree().create_timer(0.3), "timeout") bind_events() var peer = NetworkedMultiplayerENet.new() + + var addr = null + if gotm_mode: + var success = yield(server.join(), "completed") + addr = Gotm.lobby.host.address + else: + addr = server.addr + peer.create_client(addr, SERVER_PORT) get_tree().network_peer = peer print("Connecting to ", addr) @@ -172,15 +192,20 @@ func _ms_request(endpoint: String, data): push_error("An error occurred in the HTTP request.") func ms_get_entries(): - var http_request = HTTPRequest.new() - add_child(http_request) - http_request.connect("request_completed", self, "_ms_response", ["list_games"]) - var error = http_request.request( - "https://" + MASTER_SERVER_ADDR + "/" + MS_GAME_CODE, - ["Content-Type: application/json"], - true, HTTPClient.METHOD_GET) - if error != OK: - push_error("An error occurred in the HTTP request.") + if gotm_mode: + var fetch = GotmLobbyFetch.new() + var lobbies = yield(fetch.first(), "completed") + emit_signal("ms_updated", "list_games", lobbies) + else: + var http_request = HTTPRequest.new() + add_child(http_request) + http_request.connect("request_completed", self, "_ms_response", ["list_games"]) + var error = http_request.request( + "https://" + MASTER_SERVER_ADDR + "/" + MS_GAME_CODE, + ["Content-Type: application/json"], + true, HTTPClient.METHOD_GET) + if error != OK: + push_error("An error occurred in the HTTP request.") func get_game_data(): return { @@ -191,18 +216,28 @@ func get_game_data(): } func create_ms_entry(): - _ms_request("new", { - "game_id": MS_GAME_CODE, - "data": get_game_data() - }) + if gotm_mode: + Gotm.host_lobby(true) + Gotm.lobby.hidden = false + else: + _ms_request("new", { + "game_id": MS_GAME_CODE, + "data": get_game_data() + }) update_ms_entry() func update_ms_entry(): - if ms_active: - _ms_request("update", { - "key": ms_key, - "data": get_game_data() - }) + if gotm_mode: + var data = get_game_data() + Gotm.lobby.name = data.name + for key in data: + Gotm.lobby.set_property(key, data[key]) + else: + if ms_active: + _ms_request("update", { + "key": ms_key, + "data": get_game_data() + }) var time_left = 30 func _process(delta): @@ -240,3 +275,6 @@ func get_current_map(): return GameWorld.Map.RUNTIME _: return GameWorld.Map.EMPTY + +func _lobby_changed(): + print("Lobby changed ", Gotm.lobby) diff --git a/Scenes/Global/SceneManager.gd b/Scenes/Global/SceneManager.gd index 26c6230..916c4a0 100644 --- a/Scenes/Global/SceneManager.gd +++ b/Scenes/Global/SceneManager.gd @@ -6,27 +6,33 @@ var loading_text = null var loader = preload("res://Scenes/Loader.tscn") +onready var netgame = $"/root/Multiplayer" + func _ready() -> void: - queue.start() + queue.start(netgame.gotm_mode) func enter_loader() -> void: get_tree().change_scene_to(loader) func load_scene(scene_path: String, dependencies: Array) -> void: - target_scene = scene_path - queue.queue_resource(scene_path) - for dep in dependencies: - queue.queue_resource(dep) + if not netgame.gotm_mode: + target_scene = scene_path + queue.queue_resource(scene_path) + for dep in dependencies: + queue.queue_resource(dep) + else: + get_tree().change_scene(scene_path) func _physics_process(_delta: float) -> void: - if target_scene != null: - var remaining = queue.pending.size() - for path in queue.pending: - if queue.is_ready(path): - remaining -= 1 - if remaining == 0: - get_tree().change_scene_to(queue.get_resource(target_scene)) - target_scene = null + if not netgame.gotm_mode: + if target_scene != null: + var remaining = queue.pending.size() + for path in queue.pending: + if queue.is_ready(path): + remaining -= 1 + if remaining == 0: + get_tree().change_scene_to(queue.get_resource(target_scene)) + target_scene = null func get_progress() -> String: if loading_text != null: diff --git a/Scenes/Loader.tscn b/Scenes/Loader.tscn index 308fa1c..3358828 100644 --- a/Scenes/Loader.tscn +++ b/Scenes/Loader.tscn @@ -4,7 +4,7 @@ [ext_resource path="res://Graphics/UI/iosevka-aile-regular.ttf" type="DynamicFontData" id=2] [ext_resource path="res://Scenes/Loader.gd" type="Script" id=3] -[sub_resource type="Shader" id=2] +[sub_resource type="Shader" id=1] code = "shader_type canvas_item; uniform vec2 tex_size; @@ -14,23 +14,22 @@ void fragment() { vec2 uv_adjusted = (UV - (uv_rect.xy / tex_size)) * (tex_size / uv_rect.zw); float dist = distance(uv_adjusted, vec2(0.5)); if (dist < 0.26) { - COLOR = vec4(1); + COLOR = vec4(1.); } else { COLOR = texture(TEXTURE, UV); } }" -custom_defines = "" -[sub_resource type="ShaderMaterial" id=3] -shader = SubResource( 2 ) +[sub_resource type="ShaderMaterial" id=2] +shader = SubResource( 1 ) shader_param/tex_size = Vector2( 240, 180 ) shader_param/uv_rect = Plane( 126, 16, 82, 84 ) -[sub_resource type="AtlasTexture" id=1] +[sub_resource type="AtlasTexture" id=3] atlas = ExtResource( 1 ) region = Rect2( 126, 16, 82, 84 ) -[sub_resource type="Shader" id=5] +[sub_resource type="Shader" id=4] code = "shader_type canvas_item; uniform vec2 tex_size; @@ -47,23 +46,21 @@ void fragment() { COLOR = vec4(tex.aaa, 1.-tex.a); } }" -custom_defines = "" -[sub_resource type="ShaderMaterial" id=6] -shader = SubResource( 5 ) +[sub_resource type="ShaderMaterial" id=5] +shader = SubResource( 4 ) shader_param/tex_size = Vector2( 240, 180 ) shader_param/uv_rect = Plane( 146, 39, 39, 38 ) -[sub_resource type="AtlasTexture" id=4] +[sub_resource type="AtlasTexture" id=6] atlas = ExtResource( 1 ) region = Rect2( 146, 39, 39, 38 ) -[sub_resource type="DynamicFont" id=8] +[sub_resource type="DynamicFont" id=7] size = 32 font_data = ExtResource( 2 ) -[sub_resource type="Animation" id=7] -resource_name = "spinner" +[sub_resource type="Animation" id=8] length = 7.0 loop = true tracks/0/type = "value" @@ -102,26 +99,26 @@ __meta__ = { } [node name="logo-temp-pixel2" type="TextureRect" parent="BottomRight"] -material = SubResource( 3 ) +material = SubResource( 2 ) margin_left = -7.11853 margin_top = -6.38116 margin_right = 92.8815 margin_bottom = 93.6188 rect_pivot_offset = Vector2( 50, 50 ) -texture = SubResource( 1 ) +texture = SubResource( 3 ) stretch_mode = 4 __meta__ = { "_edit_use_anchors_": false } [node name="logo-temp-pixel" type="TextureRect" parent="BottomRight"] -material = SubResource( 6 ) +material = SubResource( 5 ) margin_left = -1.5 margin_top = -1.0 margin_right = 88.5 margin_bottom = 89.0 rect_pivot_offset = Vector2( 79, 59 ) -texture = SubResource( 4 ) +texture = SubResource( 6 ) stretch_mode = 4 __meta__ = { "_edit_use_anchors_": false @@ -133,10 +130,10 @@ margin_top = 63.0 margin_right = 174.0 margin_bottom = 104.0 rect_scale = Vector2( 0.5, 0.5 ) -custom_fonts/font = SubResource( 8 ) +custom_fonts/font = SubResource( 7 ) custom_colors/font_color = Color( 1, 1, 1, 1 ) align = 2 [node name="AnimationPlayer" type="AnimationPlayer" parent="."] autoplay = "spinner" -anims/spinner = SubResource( 7 ) +anims/spinner = SubResource( 8 ) diff --git a/Scenes/Menu.gd b/Scenes/Menu.gd index 90cd2ec..6df077d 100644 --- a/Scenes/Menu.gd +++ b/Scenes/Menu.gd @@ -21,6 +21,10 @@ func _ready() -> void: $"/root/Music/BGM".play() netgame.connect("ms_updated", self, "_ms_update") request_servers() + if netgame.gotm_mode: + # Hide manual connect + $Popup/MarginContainer/VBoxContainer/Label2.visible = false + $Popup/MarginContainer/VBoxContainer/HBoxContainer.visible = false func _process(delta: float) -> void: refresh_server_remaining -= delta @@ -45,9 +49,14 @@ func _ms_update(action, result): if action == "list_games": # Reset server list server_list.clear() - servers = result - for server in servers: - server_list.add_item(server.data.name + " (" + server.address + ") - " + str(server.data.players) + "/" + str(server.data.max_players) + " players") + if netgame.gotm_mode: + servers = result + for server in servers: + server_list.add_item(server.name + " (" + server.id + ") - " + str(server.peers.size()) + "/" + str(server.get_property("max_players")) + " players") + else: + servers = result + for server in servers: + server_list.add_item(server.data.name + " (" + server.address + ") - " + str(server.data.players) + "/" + str(server.data.max_players) + " players") func set_scale(val) -> void: scale = val @@ -67,9 +76,9 @@ func _host_pressed() -> void: func _join_pressed() -> void: $Popup.popup_centered_ratio() -func join_server(addr: String) -> void: +func join_server(server) -> void: $"/root/Music/BGM".stop() - $"/root/Multiplayer".join(addr) + netgame.join(server) func _server_addr_changed(new_text: String) -> void: $Popup/MarginContainer/VBoxContainer/HBoxContainer/Button.disabled = new_text.length() < 1 @@ -78,4 +87,5 @@ func _manual_join_pressed(): join_server($Popup/MarginContainer/VBoxContainer/HBoxContainer/LineEdit.text) func _server_item_clicked(index): - join_server(servers[index].address) + $"/root/Music/BGM".stop() + join_server(servers[index]) diff --git a/Scenes/Menu.tscn b/Scenes/Menu.tscn index b68e5b6..1262121 100644 --- a/Scenes/Menu.tscn +++ b/Scenes/Menu.tscn @@ -168,6 +168,7 @@ custom_fonts/font = SubResource( 6 ) text = "Join an existing game" [node name="Popup" type="PopupDialog" parent="."] +visible = true anchor_right = 1.0 anchor_bottom = 1.0 margin_left = 100.0 diff --git a/export_presets.cfg b/export_presets.cfg index fae577f..fe7e0c0 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -43,7 +43,7 @@ application/trademarks="" [preset.1] -name="HTML5" +name="GOTM" platform="HTML5" runnable=true custom_features="" diff --git a/gotm/Gotm.gd b/gotm/Gotm.gd new file mode 100644 index 0000000..e68ab21 --- /dev/null +++ b/gotm/Gotm.gd @@ -0,0 +1,117 @@ +# 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. + +extends Node +#warnings-disable + + +# Official GDScript API for games on gotm.io +# This plugin serves as a polyfill while developing against the API locally. + +# The 'real' API calls are only available when running the game live on gotm.io. +# Running the game in the web player (gotm.io/web-player) also counts as live. + +# Add this script as a global autoload. Make sure the global autoload is named +# "Gotm". It must be named "Gotm" for it to work. + + +############################################################## +# SIGNALS +############################################################## +# You connected or disconnected from a lobby. Access it at 'Gotm.lobby' +signal lobby_changed() + +# Files were drag'n'dropped into the screen. +# The 'files' argument is an array of 'GotmFile'. +signal files_dropped(files, screen) + + + +############################################################## +# PROPERTIES +############################################################## +# These are all read only. + +# Player information. +var user: GotmUser = GotmUser.new() + +# Current lobby you are in. +# Is null when not in a lobby. +var lobby: GotmLobby = null + + +############################################################## +# METHODS +############################################################## + +# The API is live when the game runs on gotm.io. +# Running the game in the web player (gotm.io/web-player) also counts as live. +func is_live() -> bool: + return false + + +# Create a new lobby and join it. +# +# If 'show_invitation' is true, show an invitation link in a popup. +# +# By default, the lobby is hidden and is only accessible directly through +# its 'invite_link'. +# Set 'lobby.hidden' to false to make it fetchable with 'GotmLobbyFetch'. +# +# Returns the hosted lobby (also accessible at 'Gotm.lobby'). +static func host_lobby(show_invitation: bool = true) -> GotmLobby: + return _GotmImpl._host_lobby(GotmLobby.new()) + + +# Play an audio snippet with 'message' as a synthesized voice. +# 'language' is in BCP 47 format (e.g. "en-US" for american english). +# If specified language is not available "en-US" is used. +# Return true if playback succeeded. +func text_to_speech(message: String, language: String = "en-US") -> bool: + return true # pretend it worked + + +# Asynchronously open up the browser's file picker. +# +# If 'types' is specified, limit the file picker to files with matching file +# types (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers). +# If 'only_one' is true, only allow the user to pick one file. +# +# If a picking-session is already in progress, an empty +# array is asynchronously returned. +# +# Asynchronously return an array of 'GotmFile'. +# Use 'yield(pick_files(), "completed")' to retrieve the return value. +func pick_files(types: Array = Array(), only_one: bool = false) -> Array: + yield(get_tree().create_timer(0.25), "timeout") + return [] + + + +############################################################## +# PRIVATE +############################################################## +func _ready() -> void: + _GotmImpl._initialize(GotmLobby, GotmUser) +func _process(delta) -> void: + _GotmImpl._process() +var _impl: Dictionary = {} diff --git a/gotm/GotmDebug.gd b/gotm/GotmDebug.gd new file mode 100644 index 0000000..1c14f1f --- /dev/null +++ b/gotm/GotmDebug.gd @@ -0,0 +1,65 @@ +# 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 GotmDebug +#warnings-disable + +# Helper library for testing against the API locally, as if it would be live. +# +# These functions do not make real API calls. They fake operations and +# trigger relevant signals as if they happened live. +# +# These functions do nothing when the game is running live on gotm.io. +# Running the game in the web player (gotm.io/web-player) also counts as live. + +# Host a lobby without joining it. +# Note that the lobby is hidden by default and not fetchable with +# 'GotmLobbyFetch'. To make it fetchable, set 'hidden' to false. +# The lobby is only fetchable and joinable in this game process. +# Returns added lobby. +static func add_lobby() -> GotmLobby: + return _GotmDebugImpl._add_lobby(GotmLobby.new()) + + +# Remove a lobby created with 'add_lobby', as if its host (you) disconnected from it. +# Triggers 'lobby_changed' if you are in that lobby. +static func remove_lobby(lobby: GotmLobby) -> void: + _GotmDebugImpl._remove_lobby(lobby) + + +# Remove all lobbies. +static func clear_lobbies() -> void: + _GotmDebugImpl._clear_lobbies() + + +# Add yourself to the lobby, without joining it. +# Triggers 'peer_joined' if you are in that lobby. +# Returns joined peer. +static func add_lobby_peer(lobby: GotmLobby) -> GotmUser: + return _GotmDebugImpl._add_lobby_player(lobby, GotmUser.new()) + + +# Remove a peer created with 'add_lobby_peer' from the lobby, as if the peer (you) disconnected +# from the lobby. +# Triggers 'peer_left' if you are in that lobby. +static func remove_lobby_peer(lobby: GotmLobby, peer: GotmUser) -> void: + _GotmDebugImpl._remove_lobby_player(lobby, peer) diff --git a/gotm/GotmFile.gd b/gotm/GotmFile.gd new file mode 100644 index 0000000..c5938e6 --- /dev/null +++ b/gotm/GotmFile.gd @@ -0,0 +1,50 @@ +# 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 GotmFile +#warnings-disable + +# A simple in-memory file descriptor used by 'Gotm.pick_files' and +# 'Gotm.files_dropped'. + + + +############################################################## +# PROPERTIES +############################################################## +# File name. +var name: String + +# File data. +var data: PoolByteArray + +# Last time the file was modified in unix time (seconds since epoch). +var modified_time: int + + + +############################################################## +# METHODS +############################################################## +# Save the file to the browser's download folder. +func download() -> void: + pass diff --git a/gotm/GotmLobby.gd b/gotm/GotmLobby.gd new file mode 100644 index 0000000..90656e6 --- /dev/null +++ b/gotm/GotmLobby.gd @@ -0,0 +1,155 @@ +# 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 GotmLobby +#warnings-disable + +# A lobby is a way of connecting players with eachother as if they +# were on the same local network. +# +# Lobbies can be joined either directly through an 'invite_link', or by +# joining lobbies fetched with the 'GotmLobbyFetch' class. + +############################################################## +# SIGNALS +############################################################## +# Peer joined the lobby. +# 'peer_user' is a 'GotmUser' instance. +# This is only emitted if you are in this lobby. +signal peer_joined(peer_user) + +# Peer left the lobby. +# 'peer_user' is a 'GotmUser' instance. +# This is only emitted if you are in this lobby. +signal peer_left(peer_user) + + + +############################################################## +# READ-ONLY PROPERTIES +############################################################## +# Globally unique identifier. +var id: String + +# Other peers in the lobby with addresses. +# Is an array of 'GotmUser'. +var peers: Array = [] + +# You with address. +var me: GotmUser = GotmUser.new() + +# Host user with address. +var host: GotmUser = GotmUser.new() + +# Peers can join the lobby directly through this link. +var invite_link: String + + + +############################################################## +# WRITABLE PROPERTIES +############################################################## +# Note that only the host can write to these properties. + +# Name that is searchable using 'GotmLobbyFetch' +# Names longer than 64 characters are truncated. +var name: String = "" + +# Prevent the lobby from showing up in fetches? +# Peers may still join directly through 'invite_link' +var hidden: bool = true + +# Prevent new peers from joining? +# Also prevents the lobby from showing up in fetches. +var locked: bool = false + + + +############################################################## +# METHODS +############################################################## +# Asynchronously join this lobby after leaving current lobby. +# +# Use 'var success = yield(lobby.join(), "completed")' to wait for the call to complete +# and retrieve the return value. +# +# Sets 'Gotm.lobby' to the joined lobby if successful. +# +# Asyncronously returns true if successful, else false. +func join() -> bool: + return yield(_GotmImpl._join_lobby(self), "completed") + + +# Leave this lobby. +func leave() -> void: + _GotmImpl._leave_lobby(self) + + +# Am I the host of this lobby? +func is_host() -> bool: + return _GotmImpl._is_lobby_host(self) + + +# Get a custom property. +func get_property(name: String): + return _GotmImpl._get_lobby_property(self, name) + + + +################################ +# Host-only methods +################################ +# Kick peer from this lobby. +# Returns true if successful, else false. +func kick(peer: GotmUser) -> bool: + return _GotmImpl._kick_lobby_peer(self, peer) + + +# Store up to 10 of your own custom properties in the lobby. +# These are visible to other peers when fetching lobbies. +# Only properties of types String, int, float or bool are allowed. +# Integers are converted to floats. +# Strings longer than 64 characters are truncated. +# Setting 'value' to null removes the property. +func set_property(name: String, value) -> void: + _GotmImpl._set_lobby_property(self, name, value) + + +# Make this lobby filterable by a custom property. +# Filtering is done when fetching lobbies with 'GotmLobbyFetch'. +# Up to 3 properties can be set as filterable at once. +func set_filterable(property_name: String, filterable: bool = true) -> void: + _GotmImpl._set_lobby_filterable(self, property_name, filterable) + + +# Make this lobby sortable by a custom property. +# Sorting is done when fetching lobbies with 'GotmLobbyFetch'. +# Up to 3 properties can be set as sortable at once. +func set_sortable(property_name: String, sortable: bool = true) -> void: + _GotmImpl._set_lobby_sortable(self, property_name, sortable) + + + +################################ +# PRIVATE +################################ +var _impl: Dictionary = {} diff --git a/gotm/GotmLobbyFetch.gd b/gotm/GotmLobbyFetch.gd new file mode 100644 index 0000000..9f3748f --- /dev/null +++ b/gotm/GotmLobbyFetch.gd @@ -0,0 +1,122 @@ +# 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 GotmLobbyFetch +#warnings-disable + +# Used for fetching non-hidden and non-locked lobbies. + + + +############################################################## +# PROPERTIES +############################################################## +################ +# Filter options +################ +# If not empty, fetch lobbies whose 'Lobby.name' contains 'name'. +var filter_name: String = "" + +# If not empty, fetch lobbies whose filterable custom properties +# matches those in 'filter_properties'. +# +# For example, setting 'filter_properties.difficulty = 2' will +# only fetch lobbies that have been set up with both 'lobby.set_property("difficulty", 2)' +# and 'lobby.set_filterable("difficulty", true)'. +# +# If your lobby has multiple filterable props, you must provide every filterable +# prop in 'filter_properties'. Setting a prop's value to 'null' will match any +# value of that prop. +var filter_properties: Dictionary = {} + + +################ +# Sort options +################ +# If not empty, sort by a sortable custom property. +# +# For example, setting 'sort_property = "difficulty"' will +# only fetch lobbies that have been set up with both 'lobby.set_property("difficulty", some_value)' +# and 'lobby.set_sortable("difficulty", true)'. +# +# If your lobby has a sortable prop, you must always provide a 'sort_property'. +var sort_property: String = "" + +# Sort results in ascending order? +var sort_ascending: bool = false + +# If not null, fetch lobbies whose sort property's value is equal to or greater than 'sort_min'. +var sort_min = null + +# If not null, fetch lobbies whose sort property's value is equal to or lesser than 'sort_max'. +var sort_max = null + +# If true, and 'sort_min' is provided, exclude lobbies whose sort property's value is equal to 'sort_min'. +var sort_min_exclusive = false + +# If true, and 'sort_max' is provided, exclude lobbies whose sort property's value is equal to 'sort_max'. +var sort_max_exclusive = false + + + +############################################################## +# METHODS +############################################################## +# All these methods asynchronously fetch up to 8 non-hidden +# and non-locked lobbies. +# +# Modifying any filtering or sorting option resets the state of this +# 'GotmLobbyFetch' instance and causes the next fetch call to +# fetch the first lobbies. +# +# All calls asynchronously return an array of fetched lobbies. +# Use 'yield(fetch.next(), "completed")' to retrieve it. + + +# Fetch the next lobbies, starting after the last lobby fetched +# in the previous call. +func next(count: int = 8) -> Array: + return yield(_GotmImpl._fetch_lobbies(self, count, "next"), "completed") + + +# Fetch the previous lobbies, ending before the first lobby +# that was fetched in the previous call. +func previous(count: int = 8) -> Array: + return yield(_GotmImpl._fetch_lobbies(self, count, "previous"), "completed") + + +# Fetch the first lobbies. +func first(count: int = 8) -> Array: + return yield(_GotmImpl._fetch_lobbies(self, count, "first"), "completed") + + +# Fetch lobbies at the current position. +# Useful for refreshing lobbies without changing the page. +func current(count: int = 8) -> Array: + return yield(_GotmImpl._fetch_lobbies(self, count, "current"), "completed") + + + +############################################################## +# PRIVATE +############################################################## +var _impl: Dictionary = {} diff --git a/gotm/GotmUser.gd b/gotm/GotmUser.gd new file mode 100644 index 0000000..e4a5021 --- /dev/null +++ b/gotm/GotmUser.gd @@ -0,0 +1,52 @@ +# 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 GotmUser +#warnings-disable + +# Holds information about a Gotm user. + + + +############################################################## +# PROPERTIES +############################################################## +# These are all read-only. + +# Globally unique ID. +# Is empty if user is not logged in. +var id: String = "" + +# Current nickname. +var display_name: String = "" + +# The IP address of the user. +# Is empty if you are not in the same lobby. +var address: String = "" + +# Is user logged in? +var is_logged_in: bool = false + +############################################################## +# PRIVATE +############################################################## +var _impl: Dictionary = {} diff --git a/gotm/LICENSE b/gotm/LICENSE new file mode 100644 index 0000000..8195d8a --- /dev/null +++ b/gotm/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/gotm/README.md b/gotm/README.md new file mode 100644 index 0000000..caf63d6 --- /dev/null +++ b/gotm/README.md @@ -0,0 +1,30 @@ +

+ gotm.io +
+ Access Gotm's API with GDScript +
+ Learn more +

+ +# Install + +_Supports versions 3.1.0 and newer._ + +## 1. Download + +Download the plugin from the [AssetLib](https://docs.godotengine.org/en/stable/tutorials/assetlib/using_assetlib.html#in-the-editor) in the Godot Editor and follow its instructions. + +You can also download it directly [here](https://github.com/PlayGotm/GDGotm/archive/master.zip). Extract the contents into your project's directory. + +## 2. Setup + +Add Gotm.gd to your autoloads at "Project Settings -> AutoLoad". Make sure the global autoload is named "Gotm". It must be named "Gotm" for it to work. + +# Examples + +[Examples](https://github.com/PlayGotM/game-examples) + +# Notes + +This plugin serves as a polyfill when developing against the API locally. +The "real" API calls are only available when running the game live on gotm.io. diff --git a/gotm/_impl/_GotmDebugImpl.gd b/gotm/_impl/_GotmDebugImpl.gd new file mode 100644 index 0000000..482a8a5 --- /dev/null +++ b/gotm/_impl/_GotmDebugImpl.gd @@ -0,0 +1,108 @@ +# 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 _GotmDebugImpl +#warnings-disable + +static func _login() -> void: + var g = _GotmImpl._get_gotm() + if g.is_live(): + return + + _logout() + + g.user_id = _GotmImpl._generate_id() + g.emit_signal("user_changed") + + +static func _logout() -> void: + var g = _GotmImpl._get_gotm() + if g.is_live(): + return + + if !g.has_user(): + return + + g.user_id = "" + g.emit_signal("user_changed") + + +static func _add_lobby(lobby): + var g = _GotmImpl._get_gotm() + if g.is_live(): + return lobby + + lobby = _GotmImpl._add_lobby(lobby) + lobby._impl.address = "127.0.0.1" + lobby._impl.host_id = g.user._impl.id + lobby.host._impl.id = g.user._impl.id + lobby.peers = [] + return lobby + + +static func _remove_lobby(lobby) -> void: + var g = _GotmImpl._get_gotm() + if g.is_live(): + return + + _GotmImpl._leave_lobby(lobby) + g._impl.lobbies.erase(lobby) + + +static func _clear_lobbies() -> void: + var g = _GotmImpl._get_gotm() + if g.is_live(): + return + + for lobby in g._impl.lobbies.duplicate(): + _remove_lobby(lobby) + + +static func _add_lobby_player(lobby, peer): + var g = _GotmImpl._get_gotm() + if g.is_live(): + return null + + peer.address = "127.0.0.1" + peer._impl.id = peer._impl.id + lobby.peers.push_back(peer) + if lobby == g.lobby: + lobby.emit_signal("peer_joined", peer) + return peer + + +static func _remove_lobby_player(lobby, peer) -> void: + var g = _GotmImpl._get_gotm() + if g.is_live(): + return + + for p in lobby.peers.duplicate(): + if peer._impl.id != p._impl.id: + continue + if peer._impl.id == lobby.host._impl.id: + _remove_lobby(lobby) + else: + lobby.peers.erase(p) + if lobby == g.lobby: + lobby.emit_signal("peer_left", p) + + diff --git a/gotm/_impl/_GotmImpl.gd b/gotm/_impl/_GotmImpl.gd new file mode 100644 index 0000000..236ce13 --- /dev/null +++ b/gotm/_impl/_GotmImpl.gd @@ -0,0 +1,586 @@ +# 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) diff --git a/gotm/_impl/_GotmImplUtility.gd b/gotm/_impl/_GotmImplUtility.gd new file mode 100644 index 0000000..7528ab7 --- /dev/null +++ b/gotm/_impl/_GotmImplUtility.gd @@ -0,0 +1,48 @@ +# 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 _GotmImplUtility +#warnings-disable + + +static func _fuzzy_compare(a, b, compare_less: bool) -> bool: + if typeof(a) == typeof(b): + return a < b if compare_less else a > b + + # GDScript doesn't handle comparison of different types very well. + # Abuse Array's min and max functions instead. + var m = [a, b].min() if compare_less else [a, b].max() + if m != null or a == null or b == null: + return m == a + + # Array method failed. Go with strings instead. + a = String(a) + b = String(b) + return a < b if compare_less else a > b + + +static func is_less(a, b) -> bool: + return _fuzzy_compare(a, b, true) + + +static func is_greater(a, b) -> bool: + return _fuzzy_compare(a, b, false) \ No newline at end of file diff --git a/project.godot b/project.godot index 77a1d3a..9eb70f1 100644 --- a/project.godot +++ b/project.godot @@ -64,6 +64,31 @@ _global_script_classes=[ { "language": "GDScript", "path": "res://Scenes/World.gd" }, { +"base": "Reference", +"class": "GotmDebug", +"language": "GDScript", +"path": "res://gotm/GotmDebug.gd" +}, { +"base": "Reference", +"class": "GotmFile", +"language": "GDScript", +"path": "res://gotm/GotmFile.gd" +}, { +"base": "Reference", +"class": "GotmLobby", +"language": "GDScript", +"path": "res://gotm/GotmLobby.gd" +}, { +"base": "Reference", +"class": "GotmLobbyFetch", +"language": "GDScript", +"path": "res://gotm/GotmLobbyFetch.gd" +}, { +"base": "Reference", +"class": "GotmUser", +"language": "GDScript", +"path": "res://gotm/GotmUser.gd" +}, { "base": "TileMap", "class": "MapTiles", "language": "GDScript", @@ -103,6 +128,21 @@ _global_script_classes=[ { "class": "UICommand", "language": "GDScript", "path": "res://Classes/UICommand.gd" +}, { +"base": "Reference", +"class": "_GotmDebugImpl", +"language": "GDScript", +"path": "res://gotm/_impl/_GotmDebugImpl.gd" +}, { +"base": "Reference", +"class": "_GotmImpl", +"language": "GDScript", +"path": "res://gotm/_impl/_GotmImpl.gd" +}, { +"base": "Reference", +"class": "_GotmImplUtility", +"language": "GDScript", +"path": "res://gotm/_impl/_GotmImplUtility.gd" } ] _global_script_class_icons={ "ActivationRange": "", @@ -116,6 +156,11 @@ _global_script_class_icons={ "GameObjectPowerStorage": "", "GameObjectScanner": "", "GameWorld": "", +"GotmDebug": "", +"GotmFile": "", +"GotmLobby": "", +"GotmLobbyFetch": "", +"GotmUser": "", "MapTiles": "", "Occluder": "", "POI": "", @@ -123,7 +168,10 @@ _global_script_class_icons={ "PowerNetwork": "", "ProbeElectric": "", "ResourceQueue": "", -"UICommand": "" +"UICommand": "", +"_GotmDebugImpl": "", +"_GotmImpl": "", +"_GotmImplUtility": "" } [application] @@ -139,6 +187,7 @@ config/icon="res://icon.png" Music="*res://Scenes/Global/Music.tscn" Multiplayer="*res://Scenes/Global/Multiplayer.gd" SceneManager="*res://Scenes/Global/SceneManager.gd" +Gotm="*res://gotm/Gotm.gd" [display]