diff --git a/addons/nohub.gd/lobby.gd b/addons/nohub.gd/lobby.gd new file mode 100644 index 00000000..1be23fa0 --- /dev/null +++ b/addons/nohub.gd/lobby.gd @@ -0,0 +1,10 @@ +extends RefCounted +class_name NohubLobby + +var id: String = "" +var is_visible: bool = true +var is_locked: bool = false +var data: Dictionary = {} + +func _to_string() -> String: + return "NohubLobby(id=%s, is_visible=%s, is_locked=%s, data=%s)" % [id, is_visible, is_locked, data] diff --git a/addons/nohub.gd/lobby.gd.uid b/addons/nohub.gd/lobby.gd.uid new file mode 100644 index 00000000..f7c7159f --- /dev/null +++ b/addons/nohub.gd/lobby.gd.uid @@ -0,0 +1 @@ +uid://darwb07a50hht diff --git a/addons/nohub.gd/nohub.gd b/addons/nohub.gd/nohub.gd new file mode 100644 index 00000000..5af87f6c --- /dev/null +++ b/addons/nohub.gd/nohub.gd @@ -0,0 +1,2 @@ +@tool +extends EditorPlugin diff --git a/addons/nohub.gd/nohub.gd.uid b/addons/nohub.gd/nohub.gd.uid new file mode 100644 index 00000000..f5e1d8af --- /dev/null +++ b/addons/nohub.gd/nohub.gd.uid @@ -0,0 +1 @@ +uid://c4r2pqwp1rtml diff --git a/addons/nohub.gd/nohub_client.gd b/addons/nohub.gd/nohub_client.gd new file mode 100644 index 00000000..04d7ddd3 --- /dev/null +++ b/addons/nohub.gd/nohub_client.gd @@ -0,0 +1,140 @@ +extends RefCounted +class_name NohubClient + + +var _connection: StreamPeerTCP +var _reactor: TrimsockTCPClientReactor + + +func _init(connection: StreamPeerTCP): + _connection = connection + _connection.set_no_delay(true) + + _reactor = TrimsockTCPClientReactor.new(connection) + +func poll() -> void: + _reactor.poll() + +func set_game(id: String) -> NohubResult: + var request := TrimsockCommand.request("session/set-game")\ + .with_params([id]) + return await _bool_request(request) + +func create_lobby(address: String, data: Dictionary) -> NohubResult.Lobby: + var request := TrimsockCommand.request("lobby/create")\ + .with_params([address]) + for key in data: + request.with_kv_pairs([TrimsockCommand.pair_of(key, data[key])]) + + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.Lobby.of_value(_command_to_lobby(response)) + else: + return _command_to_error(response) + +func get_lobby(id: String, properties: Array[String] = []) -> NohubResult.Lobby: + var request := TrimsockCommand.request("lobby/get")\ + .with_params([id] + properties) + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.Lobby.of_value(_command_to_lobby(response)) + else: + return _command_to_error(response) + +func list_lobbies(fields: Array[String] = []) -> NohubResult.LobbyList: + var result := [] as Array[NohubLobby] + var request := TrimsockCommand.request("lobby/list")\ + .with_params(fields) + + var xchg := _reactor.submit_request(request) + while xchg.is_open(): + var cmd := await xchg.read() + + if cmd.is_error(): + return _command_to_error(cmd) + if not cmd.is_stream_chunk(): + continue + + result.append(_command_to_lobby(cmd)) + + return NohubResult.LobbyList.of_value(result) + +func delete_lobby(lobby_id: String) -> NohubResult: + var request := TrimsockCommand.request("lobby/delete")\ + .with_params([lobby_id]) + return await _bool_request(request) + +func join_lobby(lobby_id: String) -> NohubResult.Address: + var request := TrimsockCommand.request("lobby/join")\ + .with_params([lobby_id]) + + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return NohubResult.Address.of_value(response.params[0]) + else: + return _command_to_error(response) + +func lock_lobby(lobby_id: String) -> NohubResult: + var request := TrimsockCommand.request("lobby/lock")\ + .with_params([lobby_id]) + return await _bool_request(request) + +func unlock_lobby(lobby_id: String) -> NohubResult: + var request := TrimsockCommand.request("lobby/unlock")\ + .with_params([lobby_id]) + return await _bool_request(request) + +func hide_lobby(lobby_id: String) -> NohubResult: + var request := TrimsockCommand.request("lobby/hide")\ + .with_params([lobby_id]) + return await _bool_request(request) + +func publish_lobby(lobby_id: String) -> NohubResult: + var request := TrimsockCommand.request("lobby/publish")\ + .with_params([lobby_id]) + return await _bool_request(request) + +func set_lobby_data(lobby_id: String, data: Dictionary) -> NohubResult: + var request := TrimsockCommand.request("lobby/set-data")\ + .with_params([lobby_id])\ + .with_kv_map(data) + return await _bool_request(request) + +func whereami() -> String: + var request := TrimsockCommand.request("whereami") + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + + if response.is_success(): + return response.text + else: + return "" + +func _bool_request(request: TrimsockCommand) -> NohubResult: + var xchg := _reactor.submit_request(request) + var response := await xchg.read() + if response.is_success(): + return NohubResult.of_success() + else: + return _command_to_error(response) + +func _command_to_lobby(command: TrimsockCommand) -> NohubLobby: + var lobby := NohubLobby.new() + lobby.id = command.params[0] + lobby.is_locked = command.params.find("locked", 1) >= 0 + lobby.is_visible = command.params.find("hidden", 1) < 0 + lobby.data = command.kv_map + + return lobby + +func _command_to_error(command: TrimsockCommand) -> NohubResult: + if command.is_error() and command.params.size() >= 2: + return NohubResult.of_error(command.params[0], command.params[1]) + else: + return NohubResult.of_error(command.name, "") diff --git a/addons/nohub.gd/nohub_client.gd.uid b/addons/nohub.gd/nohub_client.gd.uid new file mode 100644 index 00000000..1e3acb0c --- /dev/null +++ b/addons/nohub.gd/nohub_client.gd.uid @@ -0,0 +1 @@ +uid://b5po2uj4gudcc diff --git a/addons/nohub.gd/plugin.cfg b/addons/nohub.gd/plugin.cfg new file mode 100644 index 00000000..2e46bf2d --- /dev/null +++ b/addons/nohub.gd/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="nohub.gd" +description="A Godot client for nohub, the open source lobby service" +author="Tamás Gálffy" +version="0.9.0" +script="nohub.gd" diff --git a/addons/nohub.gd/result.gd b/addons/nohub.gd/result.gd new file mode 100644 index 00000000..7207b328 --- /dev/null +++ b/addons/nohub.gd/result.gd @@ -0,0 +1,84 @@ +extends RefCounted +class_name NohubResult + +class ErrorData: + var name: String + var message: String + + func _init(p_name: String, p_message: String): + name = p_name + message = p_message + + func _to_string() -> String: + return "%s: %s" % [name, message] + +class Lobby extends NohubResult: + static func of_value(value: NohubLobby) -> Lobby: + var result := Lobby.new() + result._is_success = true + result._value = value + return result + + func value() -> NohubLobby: + if _is_success: + return _value as NohubLobby + else: + return null + +class LobbyList extends NohubResult: + static func of_value(value: Array[NohubLobby]) -> LobbyList: + var result := LobbyList.new() + result._is_success = true + result._value = value + return result + + func value() -> Array[NohubLobby]: + if _is_success: + return _value as Array[NohubLobby] + else: + return [] + +class Address extends NohubResult: + static func of_value(value: String) -> Address: + var result := Address.new() + result._is_success = true + result._value = value + return result + + func value() -> String: + if _is_success: + return _value as String + else: + return "" + +var _is_success: bool +var _value: Variant +var _error: ErrorData + + +static func of_error(error: String, message: String) -> NohubResult: + var result := NohubResult.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + +static func of_success() -> NohubResult: + var result := NohubResult.new() + result._is_success = true + return result + + +func is_success() -> bool: + return _is_success + +func error() -> ErrorData: + if _is_success: + return null + else: + return _error + +func _to_string() -> String: + if _is_success: + return str(_value) + else: + return str(_error) diff --git a/addons/nohub.gd/result.gd.uid b/addons/nohub.gd/result.gd.uid new file mode 100644 index 00000000..a3955c78 --- /dev/null +++ b/addons/nohub.gd/result.gd.uid @@ -0,0 +1 @@ +uid://cv4r0rosao3v2 diff --git a/addons/trimsock.gd/command.gd b/addons/trimsock.gd/command.gd new file mode 100644 index 00000000..d0b25213 --- /dev/null +++ b/addons/trimsock.gd/command.gd @@ -0,0 +1,373 @@ +extends RefCounted +class_name TrimsockCommand + +class Chunk: + var text: String + var is_quoted: bool + + static func quoted(p_text: String) -> Chunk: + var chunk := Chunk.new() + chunk.is_quoted = true + chunk.text = p_text + return chunk + + static func unquoted(p_text: String) -> Chunk: + var chunk := Chunk.new() + chunk.is_quoted = false + chunk.text = p_text + return chunk + + static func of_text(p_text: String) -> Chunk: + var chunk := Chunk.new() + chunk.is_quoted = p_text.contains(" ") + chunk.text = p_text + return chunk + +class Pair: + var key: String + var value: String + +enum Type { + SIMPLE, + REQUEST, + SUCCESS_RESPONSE, + ERROR_RESPONSE, + STREAM_CHUNK, + STREAM_FINISH +} + +# Core properties +var name: String = "" +var text: String = "" +var chunks: Array[Chunk] = [] +var is_raw: bool = false +var raw: PackedByteArray + +# Multiparam +var params: Array[String] + +# Key-value pairs +var kv_pairs: Array[Pair] +var kv_map: Dictionary + +# Request-response + Stream +var exchange_id: String +var type: Type = Type.SIMPLE + +static func from_buffer(name: String, data: PackedByteArray) -> TrimsockCommand: + var command := TrimsockCommand.new() + command.is_raw = true + command.raw = data + return command + +static func simple(name: String, text: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + if text: + command.chunks.append(Chunk.of_text(text)) + + return command + +static func request(name: String, exchange_id: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + command.type = Type.REQUEST + command.exchange_id = exchange_id + return command + +static func success_response(name: String, exchange_id: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + command.type = Type.SUCCESS_RESPONSE + command.exchange_id = exchange_id + return command + +static func error_response(name: String, exchange_id: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + command.type = Type.ERROR_RESPONSE + command.exchange_id = exchange_id + return command + +static func stream_chunk(name: String, exchange_id: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + command.type = Type.STREAM_CHUNK + command.exchange_id = exchange_id + return command + +static func stream_finish(name: String, exchange_id: String = "") -> TrimsockCommand: + var command := TrimsockCommand.new() + command.name = name + command.type = Type.STREAM_FINISH + command.exchange_id = exchange_id + return command + +static func error_from(command: TrimsockCommand, name: String, data) -> TrimsockCommand: + var result := TrimsockCommand.new() + + if not result.is_simple(): + result.name = "" + result.type = Type.ERROR_RESPONSE + result.exchange_id = command.exchange_id + else: + result.name = name + + if typeof(data) == TYPE_ARRAY: + for param in data: + result.params.append(str(param)) + else: + result.chunks.append(Chunk.of_text(str(data))) + + return result + +static func unescape(what: String) -> String: + return (what + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\\"", "\"") + ) + +static func escape_quoted(what: String) -> String: + return what.replace("\"", "\\\"") + +static func escape_unquoted(what: String) -> String: + return (what + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\"", "\\\"") + ) + +static func pair_of(key: String, value: String) -> Pair: + var pair := Pair.new() + pair.key = key + pair.value = value + return pair + +static func type_string(type: Type) -> String: + match type: + Type.SIMPLE: return "Simple" + Type.REQUEST: return "Request" + Type.SUCCESS_RESPONSE: return "Success Response" + Type.ERROR_RESPONSE: return "Error Response" + Type.STREAM_CHUNK: return "Stream Chunk" + Type.STREAM_FINISH: return "Stream Finish" + return "%d???" % [type] + + +func is_simple() -> bool: + return type == Type.SIMPLE + +func is_request() -> bool: + return type == Type.REQUEST + +func is_success() -> bool: + return type == Type.SUCCESS_RESPONSE + +func is_error() -> bool: + return type == Type.ERROR_RESPONSE + +func is_stream() -> bool: + return is_stream_chunk() or is_stream_end() + +func is_stream_chunk() -> bool: + return type == Type.STREAM_CHUNK + +func is_stream_end() -> bool: + return type == Type.STREAM_FINISH + +func is_empty() -> bool: + if is_raw: + return raw.is_empty() + else: + return text.is_empty() and chunks.is_empty() and params.is_empty() and kv_pairs.is_empty() and kv_map.is_empty() + +func clear(): + raw.clear() + chunks.clear() + params.clear() + kv_pairs.clear() + kv_map.clear() + text = "" + +func with_name(p_name: String) -> TrimsockCommand: + name = p_name + return self + +func with_text(p_text: String) -> TrimsockCommand: + text = p_text + return self + +func with_chunks(p_chunks: Array[Chunk]) -> TrimsockCommand: + chunks += p_chunks + return self + +func as_raw() -> TrimsockCommand: + is_raw = true + text = "" + chunks = [] + return self + +func with_data(data: PackedByteArray) -> TrimsockCommand: + as_raw() + raw = data + return self + +func with_params(p_params: Array[String]) -> TrimsockCommand: + params += p_params + return self + +func with_kv_pairs(p_kv_pairs: Array[Pair]) -> TrimsockCommand: + kv_pairs += p_kv_pairs + for pair in kv_pairs: + kv_map[pair.key] = pair.value + return self + +func with_kv_map(p_kv_map: Dictionary) -> TrimsockCommand: + for key in p_kv_map: + var value = p_kv_map[key] + kv_pairs.append(pair_of(key, value)) + kv_map.merge(p_kv_map, true) + return self + +func with_exchange_id(p_exchange_id: String) -> TrimsockCommand: + exchange_id = p_exchange_id + return self + +func as_request() -> TrimsockCommand: + type = Type.REQUEST + return self + +func as_success_response() -> TrimsockCommand: + type = Type.SUCCESS_RESPONSE + return self + +func as_error_response() -> TrimsockCommand: + type = Type.ERROR_RESPONSE + return self + +func as_stream() -> TrimsockCommand: + type = Type.STREAM_FINISH if is_empty() else Type.STREAM_CHUNK + return self + +func serialize() -> PackedByteArray: + var out := PackedByteArray() + serialize_to_array(out) + return out + +func serialize_to_array(out: PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + serialize_to_stream(buffer) + out.append_array(buffer.data_array) + +func serialize_to_stream(out: StreamPeer) -> void: + # Add raw marker + if is_raw: + out.put_8(_ord("\r")) + + # Add name + if name: + out.put_data(_escape_name(name).to_utf8_buffer()) + + # Add separator if request / stream + match type: + Type.REQUEST: out.put_u8(_ord("?")) + Type.SUCCESS_RESPONSE: out.put_u8(_ord(".")) + Type.ERROR_RESPONSE: out.put_u8(_ord("!")) + Type.STREAM_CHUNK, Type.STREAM_FINISH: + out.put_u8(_ord("|")) + + # Add ID + if type != Type.SIMPLE: + out.put_data(exchange_id.to_utf8_buffer()) + + # Short-circuit on empty command + if is_empty() and not is_raw: + out.put_u8(_ord("\n")) + return + + # Space after name + out.put_u8(_ord(" ")) + + # Short-circuit if raw + if is_raw: + out.put_data(str(raw.size()).to_ascii_buffer()) + out.put_u8(_ord("\n")) + out.put_data(raw) + out.put_u8(_ord("\n")) + return + + # Print content + if not chunks.is_empty(): + # Prefer chunks, if available + for chunk in chunks: + if chunk.is_quoted: + out.put_data(_quoted_chunk(chunk.text).to_utf8_buffer()) + else: + out.put_data(_unquoted_chunk(chunk.text).to_utf8_buffer()) + elif not kv_pairs.is_empty() or not kv_map.is_empty() or not params.is_empty(): + # Fall back to params if no chunks + var tokens := PackedStringArray() + + # Print params first + for param in params: + tokens.append(_autoquoted_chunk(param)) + + # Print kv-params, either from `kv_pairs`, or `kv_map` + if not kv_pairs.is_empty(): + for pair in kv_pairs: + tokens.append(_autoquoted_chunk(pair.key) + "=" + _autoquoted_chunk(pair.value)) + else: + for key in kv_map: + var value = kv_map[key] + tokens.append(_autoquoted_chunk(key) + "=" + _autoquoted_chunk(value)) + + # Push to buffer + out.put_data(" ".join(tokens).to_utf8_buffer()) + else: + # Use `text` as last resort + out.put_data(_autoquoted_chunk(text).to_utf8_buffer()) + + # Add closing NL + out.put_u8(_ord("\n")) + +func equals(what) -> bool: + if not what is TrimsockCommand: + return false + + var command := what as TrimsockCommand + + if not command.name == name or \ + not command.type == type: + return false + + if not is_simple() and exchange_id != command.exchange_id: + return false + + if not is_raw: + return text == command.text + else: + return raw == command.raw + +func _ord(chr: String) -> int: + return chr.unicode_at(0) + +func _escape_name(what: String) -> String: + return _autoquoted_chunk(what) + +func _quoted_chunk(what: String) -> String: + return "\"%s\"" % [escape_quoted(what)] + +func _unquoted_chunk(what: String) -> String: + return escape_unquoted(what) + +func _autoquoted_chunk(what: String) -> String: + if what.contains(" "): + return _quoted_chunk(what) + else: + return _unquoted_chunk(what) + +func _to_string() -> String: + if is_raw: + return "(raw)" + serialize().get_string_from_utf8() + return serialize().get_string_from_utf8() diff --git a/addons/trimsock.gd/command.gd.uid b/addons/trimsock.gd/command.gd.uid new file mode 100644 index 00000000..cfda87c9 --- /dev/null +++ b/addons/trimsock.gd/command.gd.uid @@ -0,0 +1 @@ +uid://cdylwggrco2m6 diff --git a/addons/trimsock.gd/conventions.gd b/addons/trimsock.gd/conventions.gd new file mode 100644 index 00000000..fc9c9eac --- /dev/null +++ b/addons/trimsock.gd/conventions.gd @@ -0,0 +1,86 @@ +extends Object +class_name _TrimsockConventions + + +static func apply(command: TrimsockCommand) -> void: + parse_type(command) + parse_params(command) + +static func parse_type(command: TrimsockCommand) -> void: + var at := 0 + + # Figure out command type + while true: + at = command.name.find("?") + if at >= 0: + command.type = TrimsockCommand.Type.REQUEST + break + + at = command.name.find(".") + if at >= 0: + command.type = TrimsockCommand.Type.SUCCESS_RESPONSE + break + + at = command.name.find("!") + if at >= 0: + command.type = TrimsockCommand.Type.ERROR_RESPONSE + break + + at = command.name.find("|") + if at >= 0: + if ((command.is_raw and command.raw.is_empty()) or (not command.is_raw and command.text.is_empty())): + command.type = TrimsockCommand.Type.STREAM_FINISH + else: + command.type = TrimsockCommand.Type.STREAM_CHUNK + break + return + + # Extract data + var name := command.name.substr(0, at) + var id := command.name.substr(at + 1) + + command.name = name + command.exchange_id = id + +static func parse_params(command: TrimsockCommand) -> void: + if command.is_raw or command.chunks.is_empty(): + return + + var chunks := [] as Array[String] + for chunk in command.chunks: + if chunk.is_quoted: + # Quoted chunks go in verbatim + chunks.append(chunk.text) + else: + # Unquoted chunks are separated by spaces, and then each separated + # word is checked for equal signs + for word in chunk.text.split(" ", false): + var at := word.find("=") + if at >= 0: + chunks.append(word.substr(0, at)) + chunks.append("=") + chunks.append(word.substr(at + 1)) + else: + chunks.append(word) + chunks = chunks.filter(func(it): return it) + + # Extract params and kv-pairs + for i in range(chunks.size()): + var chunk := chunks[i] + var prev := chunks[i-1] if i > 0 else "" + var next := chunks[i+1] if i < chunks.size() - 1 else "" + + if next == "=" or prev == "=": + continue + if chunk == "=" and prev and next: + command.kv_pairs.append(TrimsockCommand.pair_of(prev, next)) + else: + command.params.append(chunk) + + # Calculate kv-map + if not command.kv_pairs.is_empty(): + for pair in command.kv_pairs: + command.kv_map[pair.key] = pair.value + +func _init(): + assert(false, "This class shouldn't be instantiated!") diff --git a/addons/trimsock.gd/conventions.gd.uid b/addons/trimsock.gd/conventions.gd.uid new file mode 100644 index 00000000..b4440a3e --- /dev/null +++ b/addons/trimsock.gd/conventions.gd.uid @@ -0,0 +1 @@ +uid://do2ws0pyjmpvs diff --git a/addons/trimsock.gd/exchange.gd b/addons/trimsock.gd/exchange.gd new file mode 100644 index 00000000..8933f800 --- /dev/null +++ b/addons/trimsock.gd/exchange.gd @@ -0,0 +1,140 @@ +extends RefCounted +class_name TrimsockExchange + +var _source: Variant +var _reactor: TrimsockReactor +var _command: TrimsockCommand + +var _is_open: bool = true +var _queue: Array[TrimsockCommand] = [] + + +signal _on_command(command: TrimsockCommand) + + +func _init(command: TrimsockCommand, source: Variant, reactor: TrimsockReactor): + _command = command + _source = source + _reactor = reactor + +#region Properties +func source() -> Variant: + return _source + +func id() -> String: + return _command.exchange_id + +func session() -> Variant: + return _reactor.get_session(_source) + +func set_session(data: Variant) -> void: + _reactor.set_session(_source, data) + +func is_open() -> bool: + return _is_open + +func can_reply() -> bool: + return _command.type != TrimsockCommand.Type.SIMPLE + +func close() -> void: + _is_open = false +#endregion + +#region Write +func send(command: TrimsockCommand) -> bool: + if not is_open(): + return false + + _reactor._write(_source, command) + return true + +func send_and_close(command: TrimsockCommand) -> bool: + if not send(command): + return false + + close() + return true + +func reply(command: TrimsockCommand) -> bool: + if not can_reply() or not is_open(): + return false + + command.as_success_response() + command.name = "" + command.exchange_id = id() + + send(command) + close() + return true + +func fail(command: TrimsockCommand) -> bool: + if not can_reply() or not is_open(): + return false + + command.as_error_response() + command.name = "" + command.exchange_id = id() + + send(command) + close() + return true + +func stream(command: TrimsockCommand) -> bool: + if not can_reply() or not is_open(): + return false + + command.as_stream() + command.name = "" + command.exchange_id = id() + + send(command) + return true + +func stream_finish(command: TrimsockCommand) -> bool: + if not can_reply() or not is_open(): + return false + + command.clear() + command.as_stream() + command.name = "" + command.exchange_id = id() + + send(command) + close() + return true + +func reply_or_send(command: TrimsockCommand) -> bool: + if not is_open(): + return false + + if not reply(command): + send_and_close(command) + + return true + +func fail_or_send(command: TrimsockCommand) -> bool: + if not is_open(): + return false + + if not fail(command): + send_and_close(command) + + return true +#endregion + +#region Read +func push(command: TrimsockCommand) -> void: + match command.type: + TrimsockCommand.Type.SUCCESS_RESPONSE,\ + TrimsockCommand.Type.ERROR_RESPONSE,\ + TrimsockCommand.Type.STREAM_FINISH: + close() + + _queue.append(command) + _on_command.emit(command) + +func read() -> TrimsockCommand: + while _queue.is_empty(): + await _on_command + return _queue.pop_front() +#endregion diff --git a/addons/trimsock.gd/exchange.gd.uid b/addons/trimsock.gd/exchange.gd.uid new file mode 100644 index 00000000..54252795 --- /dev/null +++ b/addons/trimsock.gd/exchange.gd.uid @@ -0,0 +1 @@ +uid://6n8wdgeod2wt diff --git a/addons/trimsock.gd/id/incremental_id_generator.gd b/addons/trimsock.gd/id/incremental_id_generator.gd new file mode 100644 index 00000000..2031985e --- /dev/null +++ b/addons/trimsock.gd/id/incremental_id_generator.gd @@ -0,0 +1,10 @@ +extends TrimsockIDGenerator +class_name IncrementalTrimsockIDGenerator + + +var _at := -1 + + +func get_id() -> String: + _at += 1 + return "%x" % _at diff --git a/addons/trimsock.gd/id/incremental_id_generator.gd.uid b/addons/trimsock.gd/id/incremental_id_generator.gd.uid new file mode 100644 index 00000000..4855753d --- /dev/null +++ b/addons/trimsock.gd/id/incremental_id_generator.gd.uid @@ -0,0 +1 @@ +uid://ckr74u3yx2182 diff --git a/addons/trimsock.gd/id/random_id_generator.gd b/addons/trimsock.gd/id/random_id_generator.gd new file mode 100644 index 00000000..dceebae5 --- /dev/null +++ b/addons/trimsock.gd/id/random_id_generator.gd @@ -0,0 +1,20 @@ +extends TrimsockIDGenerator +class_name RandomTrimsockIDGenerator + + +var charset := "abcdeghijklmnopqrstuvwxyz" + "ABCDEFGHIJLKMNOPQRSTUVWXYZ" + "0123456789" +var length := 8 + +var _rng := RandomNumberGenerator.new() + + +func _init(p_length: int = 8, p_charset: String = ""): + length = p_length + if p_charset: + charset = p_charset + +func get_id() -> String: + var id := "" + for i in length: + id += charset[_rng.randi() % charset.length()] + return id diff --git a/addons/trimsock.gd/id/random_id_generator.gd.uid b/addons/trimsock.gd/id/random_id_generator.gd.uid new file mode 100644 index 00000000..a4afc19f --- /dev/null +++ b/addons/trimsock.gd/id/random_id_generator.gd.uid @@ -0,0 +1 @@ +uid://xafiylpfvpnj diff --git a/addons/trimsock.gd/id_generator.gd b/addons/trimsock.gd/id_generator.gd new file mode 100644 index 00000000..549a2e6a --- /dev/null +++ b/addons/trimsock.gd/id_generator.gd @@ -0,0 +1,5 @@ +extends RefCounted +class_name TrimsockIDGenerator + +func get_id() -> String: + return "" diff --git a/addons/trimsock.gd/id_generator.gd.uid b/addons/trimsock.gd/id_generator.gd.uid new file mode 100644 index 00000000..5e2e5165 --- /dev/null +++ b/addons/trimsock.gd/id_generator.gd.uid @@ -0,0 +1 @@ +uid://cu054kqjijxe diff --git a/addons/trimsock.gd/line_parser.gd b/addons/trimsock.gd/line_parser.gd new file mode 100644 index 00000000..5dc0e868 --- /dev/null +++ b/addons/trimsock.gd/line_parser.gd @@ -0,0 +1,112 @@ +extends RefCounted +class_name _TrimsockLineParser + +var line := "" +var at := 0 + +func parse(p_line: String) -> TrimsockCommand: + rewind(p_line) + var command := TrimsockCommand.new() + + # Empty command + if is_eol(): + return command + + # Check for raw message + command.is_raw = chr() == "\r" + if command.is_raw: at += 1 + + # Read command name + command.name = read_name() + at += 1 # Skip over space after name + + # Read chunks until available + while not is_eol(): + command.chunks.append(read_chunk()) + + # Calculate text + command.text = "" + for chunk in command.chunks: + command.text += chunk.text + + unescape(command) + + return command + +func read_name() -> String: + if chr() == "\"": + return read_quoted() + else: + return read_identifier() + +func read_chunk() -> TrimsockCommand.Chunk: + var chunk := TrimsockCommand.Chunk.new() + if chr() == "\"": + chunk.is_quoted = true + chunk.text = read_quoted() + else: + chunk.is_quoted = false + chunk.text = read_unquoted() + return chunk + +func read_identifier() -> String: + var from := at + + while not is_eol() and chr() != " ": + at += 1 + + return line.substr(from, at - from) + +func read_unquoted() -> String: + var from := at + + while not is_eol(): + if chr() == "\\": + at += 1 + elif chr() == "\n" or chr() == "\"": + break + at += 1 + + return line.substr(from, at - from) + +func read_quoted() -> String: + var from := at + + # Skip opening quote + at += 1 + + # Iterate until end + while true: + if chr() == "\\": + # Skip escape + at += 1 + elif chr() == "\"": + # Found closing quote, stop + break + elif is_eol(): + # String ended unexpectedly + push_warning("Command line ended unexpectedly while reading quoted data: " + line) + break + at += 1 + + # Step over closing quotes + at += 1 + + # Return string between quotes + return line.substr(from + 1, (at - 1) - (from + 1)) + +func unescape(command: TrimsockCommand) -> void: + command.name = TrimsockCommand.unescape(command.name) + for chunk in command.chunks: + chunk.text = TrimsockCommand.unescape(chunk.text) + command.text = TrimsockCommand.unescape(command.text) + +func chr() -> String: + return line[at] + +func is_eol() -> bool: + return at >= line.length() + +func rewind(p_line: String) -> void: + line = p_line + at = 0 diff --git a/addons/trimsock.gd/line_parser.gd.uid b/addons/trimsock.gd/line_parser.gd.uid new file mode 100644 index 00000000..9431b8b5 --- /dev/null +++ b/addons/trimsock.gd/line_parser.gd.uid @@ -0,0 +1 @@ +uid://b2e1q00pni4ud diff --git a/addons/trimsock.gd/line_reader.gd b/addons/trimsock.gd/line_reader.gd new file mode 100644 index 00000000..fabb6bb0 --- /dev/null +++ b/addons/trimsock.gd/line_reader.gd @@ -0,0 +1,61 @@ +extends RefCounted +class_name _TrimsockLineReader + +var buffer := PackedByteArray() +var max_size := 16384 +var at := 0 +var is_quote := false +var is_escape := false + +func ingest(data: PackedByteArray) -> Error: + var new_size := buffer.size() + data.size() + if new_size > max_size: + buffer.clear() + return ERR_OUT_OF_MEMORY + + buffer.append_array(data) + return OK + +func read_text() -> String: + while not is_eob(): + if is_escape: + is_escape = false + elif chr() == "\"": + is_quote = not is_quote + elif chr() == "\\": + is_escape = true + elif chr() == "\n" and not is_quote: + return _flush_line() + at += 1 + return "" + +func has_data(size: int) -> bool: + return buffer.size() >= size + +func read_data(size: int) -> PackedByteArray: + assert(has_data(size), "Trying to read more bytes than available!") + + # Grab result + var result := buffer.slice(0, size) + buffer = buffer.slice(size) + + # Reset flags + is_escape = false + is_quote = false + + return result + +func chr() -> String: + return String.chr(buffer[at]) + +func is_eob() -> bool: + return at >= buffer.size() + +# Return string up to the current character ( exclusive ), and discard +# everything before ( inclusive ) the current character +func _flush_line() -> String: + var line := buffer.slice(0, at).get_string_from_utf8() + buffer = buffer.slice(at + 1) + at = 0 + + return line diff --git a/addons/trimsock.gd/line_reader.gd.uid b/addons/trimsock.gd/line_reader.gd.uid new file mode 100644 index 00000000..02e1298d --- /dev/null +++ b/addons/trimsock.gd/line_reader.gd.uid @@ -0,0 +1 @@ +uid://cycjokupoirxj diff --git a/addons/trimsock.gd/plugin.cfg b/addons/trimsock.gd/plugin.cfg new file mode 100644 index 00000000..b60b59ea --- /dev/null +++ b/addons/trimsock.gd/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="trimsock.gd" +description="Godot implementation of the trimsock protocol" +author="Tamás Gálffy" +version="0.12.0" +script="trimsock.gd" diff --git a/addons/trimsock.gd/reactor.gd b/addons/trimsock.gd/reactor.gd new file mode 100644 index 00000000..b2389ab3 --- /dev/null +++ b/addons/trimsock.gd/reactor.gd @@ -0,0 +1,132 @@ +extends RefCounted +class_name TrimsockReactor + +var _sources: Array = [] +var _sessions: Dictionary = {} # source to session data +var _readers: Dictionary = {} # source to reader +var _handlers: Dictionary = {} # command name to handler method +var _exchanges: Array[TrimsockExchange] = [] +var _unknown_handler: Callable = func(_cmd, _xchg): pass # TODO(trimsock): Add _xchg param +var _id_generator: TrimsockIDGenerator = RandomTrimsockIDGenerator.new(12) + + +signal on_attach(source: Variant) +signal on_detach(source: Variant) + + +func poll() -> void: + _poll() + + for source in _sources: + var reader := _readers[source] as TrimsockReader + while true: + var command := reader.read() + if not command: + break + + _handle(command, source) + +func send(target: Variant, command: TrimsockCommand) -> TrimsockExchange: + # Send command + _write(target, command) + + # Ensure exchange + var xchg := _get_exchange_for(command, target) + if xchg == null: + xchg = _make_exchange_for(command, target) + + return xchg + +func request(target: Variant, command: TrimsockCommand) -> TrimsockExchange: + command.as_request() + if not command.exchange_id: + command.exchange_id = _id_generator.get_id() + return send(target, command) + +func stream(target: Variant, command: TrimsockCommand) -> TrimsockExchange: + command.as_stream() + if not command.exchange_id: + command.exchange_id = _id_generator.get_id() + return send(target, command) + +func attach(source: Variant) -> void: + if _sources.has(source): + return + + _sources.append(source) + _readers[source] = TrimsockReader.new() + on_attach.emit(source) + +func detach(source: Variant) -> void: + if not _sources.has(source): + return + + _sources.erase(source) + _sessions.erase(source) + _readers.erase(source) + on_detach.emit(source) + +func set_session(source: Variant, data: Variant) -> void: + _sessions[source] = data + +func get_session(source: Variant) -> Variant: + return _sessions.get(source) + +func set_id_generator(id_generator: TrimsockIDGenerator) -> void: + _id_generator = id_generator + +func on(command_name: String, handler: Callable) -> TrimsockReactor: + _handlers[command_name] = handler + return self + +func on_unknown(handler: Callable) -> TrimsockReactor: + _unknown_handler = handler + return self + + +# Grab incoming data, call `_ingest()` +func _poll() -> void: + pass + +# Send command to target +func _write(target: Variant, command: TrimsockCommand) -> void: + pass + +func _ingest(source: Variant, data: PackedByteArray) -> Error: + assert(_readers.has(source), "Ingesting data from unknown source! Did you call `attach()`?") + var reader := _readers[source] as TrimsockReader + return reader.ingest_bytes(data) + +func _handle(command: TrimsockCommand, source: Variant) -> void: + var xchg := _get_exchange_for(command, source) + if xchg != null: + # Known exchange, handle it there + xchg.push(command) + else: + # New exchange, create instance and pass to handler + xchg = _make_exchange_for(command, source) + var handler := (_handlers.get(command.name) if _handlers.has(command.name) else _unknown_handler) as Callable + + var result := await handler.call(command, xchg) + if xchg.is_open() and result is TrimsockCommand: + xchg.send_and_close(result) + + # Free exchange if needed + if not xchg.is_open(): + _exchanges.erase(xchg) + +func _get_exchange_for(command: TrimsockCommand, source: Variant) -> TrimsockExchange: + if not command.is_simple(): + # Try and find known exchange + for xchg in _exchanges: + if xchg.id() == command.exchange_id and xchg._source == source: + return xchg + + # Command has no ID, or ID not found + return null + +func _make_exchange_for(command: TrimsockCommand, source: Variant) -> TrimsockExchange: + var xchg := TrimsockExchange.new(command, source, self) + if not command.is_simple(): + _exchanges.append(xchg) + return xchg diff --git a/addons/trimsock.gd/reactor.gd.uid b/addons/trimsock.gd/reactor.gd.uid new file mode 100644 index 00000000..64b32e49 --- /dev/null +++ b/addons/trimsock.gd/reactor.gd.uid @@ -0,0 +1 @@ +uid://cn2bot4vb3q24 diff --git a/addons/trimsock.gd/reactors/tcp_client_reactor.gd b/addons/trimsock.gd/reactors/tcp_client_reactor.gd new file mode 100644 index 00000000..25a176cd --- /dev/null +++ b/addons/trimsock.gd/reactors/tcp_client_reactor.gd @@ -0,0 +1,36 @@ +extends TrimsockReactor +class_name TrimsockTCPClientReactor + +var _connection: StreamPeerTCP + + +func _init(connection: StreamPeerTCP): + _connection = connection + attach(_connection) + +func submit(command: TrimsockCommand) -> TrimsockExchange: + return send(_connection, command) + +func submit_request(command: TrimsockCommand) -> TrimsockExchange: + return request(_connection, command) + +func submit_stream(command: TrimsockCommand) -> TrimsockExchange: + return stream(_connection, command) + +func _poll() -> void: + _connection.poll() + + if _connection.get_status() != StreamPeerTCP.STATUS_CONNECTED: + # Can't read + return + + # Grab available data + var available := _connection.get_available_bytes() + var res := _connection.get_partial_data(available) + if res[0] == OK: + _ingest(_connection, res[1]) + +func _write(target: Variant, command: TrimsockCommand) -> void: + assert(target is StreamPeerTCP, "Invalid target!") + var peer := target as StreamPeerTCP + command.serialize_to_stream(peer) diff --git a/addons/trimsock.gd/reactors/tcp_client_reactor.gd.uid b/addons/trimsock.gd/reactors/tcp_client_reactor.gd.uid new file mode 100644 index 00000000..31a8aecf --- /dev/null +++ b/addons/trimsock.gd/reactors/tcp_client_reactor.gd.uid @@ -0,0 +1 @@ +uid://baw6mwso5an43 diff --git a/addons/trimsock.gd/reactors/tcp_server_reactor.gd b/addons/trimsock.gd/reactors/tcp_server_reactor.gd new file mode 100644 index 00000000..93bd3a1b --- /dev/null +++ b/addons/trimsock.gd/reactors/tcp_server_reactor.gd @@ -0,0 +1,37 @@ +extends TrimsockReactor +class_name TrimsockTCPServerReactor + +var _server: TCPServer + +func _init(server: TCPServer): + _server = server + +func _poll() -> void: + # Handle incoming connections + while _server.is_connection_available(): + attach(_server.take_connection()) + + # Poll each connection + for source in _sources: + var stream := source as StreamPeerTCP + + # Update status + stream.poll() + + # Detach closed connections + # Don't process any further data from them if we can't reply + var status := stream.get_status() + if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR: + detach(stream) + continue + + # Grab available data + var available := stream.get_available_bytes() + var res := stream.get_partial_data(available) + if res[0] == OK: + _ingest(stream, res[1]) + +func _write(target: Variant, command: TrimsockCommand) -> void: + assert(target is StreamPeerTCP, "Invalid target!") + var peer := target as StreamPeerTCP + command.serialize_to_stream(peer) diff --git a/addons/trimsock.gd/reactors/tcp_server_reactor.gd.uid b/addons/trimsock.gd/reactors/tcp_server_reactor.gd.uid new file mode 100644 index 00000000..40296208 --- /dev/null +++ b/addons/trimsock.gd/reactors/tcp_server_reactor.gd.uid @@ -0,0 +1 @@ +uid://bstql6w3f5fnu diff --git a/addons/trimsock.gd/reader.gd b/addons/trimsock.gd/reader.gd new file mode 100644 index 00000000..48d00d42 --- /dev/null +++ b/addons/trimsock.gd/reader.gd @@ -0,0 +1,49 @@ +extends RefCounted +class_name TrimsockReader + +var _line_reader: _TrimsockLineReader = _TrimsockLineReader.new() +var _line_parser: _TrimsockLineParser = _TrimsockLineParser.new() +var _queued_raw: TrimsockCommand = null + +func ingest_text(text: String) -> Error: + return _line_reader.ingest(text.to_utf8_buffer()) + +func ingest_bytes(bytes: PackedByteArray) -> Error: + return _line_reader.ingest(bytes) + +func read() -> TrimsockCommand: + var command := _pop() + if command: + _TrimsockConventions.apply(command) + return command + +func _pop() -> TrimsockCommand: + # We read a raw command earlier, waiting to have enough data + if _queued_raw: + var data_size := int(_queued_raw.text) + if not _line_reader.has_data(data_size): + return null + + _queued_raw.raw = _line_reader.read_data(data_size) + _queued_raw.text = "" + _queued_raw.chunks.clear() + + var result := _queued_raw + _queued_raw = null + return result + + # No queued command, try to read a new one + var line := _line_reader.read_text() + if not line: + return null + + var command := _line_parser.parse(line) + if command.is_raw: + # Command is raw, we'll keep it in the queue until we read the binary + # data for it + _queued_raw = command + + # Try getting it immediately, in case we already have the data in buffer + return _pop() + + return command diff --git a/addons/trimsock.gd/reader.gd.uid b/addons/trimsock.gd/reader.gd.uid new file mode 100644 index 00000000..b1852a34 --- /dev/null +++ b/addons/trimsock.gd/reader.gd.uid @@ -0,0 +1 @@ +uid://ch6fw168jf1jt diff --git a/addons/trimsock.gd/trimsock.gd b/addons/trimsock.gd/trimsock.gd new file mode 100644 index 00000000..5af87f6c --- /dev/null +++ b/addons/trimsock.gd/trimsock.gd @@ -0,0 +1,2 @@ +@tool +extends EditorPlugin diff --git a/addons/trimsock.gd/trimsock.gd.uid b/addons/trimsock.gd/trimsock.gd.uid new file mode 100644 index 00000000..a69927f6 --- /dev/null +++ b/addons/trimsock.gd/trimsock.gd.uid @@ -0,0 +1 @@ +uid://nb3mrm43j6ux diff --git a/examples/forest-brawl/forest-brawl-connector.gd b/examples/forest-brawl/forest-brawl-connector.gd new file mode 100644 index 00000000..044efea8 --- /dev/null +++ b/examples/forest-brawl/forest-brawl-connector.gd @@ -0,0 +1,269 @@ +extends Node +class_name ForestBrawlConnector + +class ServiceHosts: + var name: String + var noray_address: String + var nohub_address: String + + func _init(p_name: String, p_noray_address: String, p_nohub_address: String): + name = p_name + noray_address = p_noray_address + nohub_address = p_nohub_address + +const GAME_ID := "WK6koYfZ7cEMjcsba3ovxQF1lM9XjkWh" + +static var known_service_hosts: Array[ServiceHosts] = [ + ServiceHosts.new("foxssake.studio", "foxssake.studio:8890", "foxssake.studio:12980"), + ServiceHosts.new("localhost", "localhost:8890", "localhost:9980") +] + +static var _instance: ForestBrawlConnector +static var _logger := _NetfoxLogger.new("forest-brawl", "ForestBrawlConnector") + +var _noray_connector: ForestBrawlNorayConnector + +var _noray_address := "" +var _nohub_address := "" + +var _nohub_peer: StreamPeerTCP +var _nohub_client: NohubClient + +var _hosted_lobby: NohubLobby + +static func _static_init(): + known_service_hosts.make_read_only() + +static func nohub() -> NohubClient: + if not _instance: return null + return _instance._nohub_client + +static func noray_address() -> String: + if not _instance: return "" + return _instance._noray_address + +static func nohub_address() -> String: + if not _instance: return "" + return _instance._nohub_address + +static func connect_to_service_hosts(services: ServiceHosts) -> Error: + return await connect_to_services(services.noray_address, services.nohub_address) + +static func connect_to_any_service_host() -> Error: + # TODO: Find one based on ping + return await connect_to_service_hosts(known_service_hosts[0]) + +static func connect_to_services(p_noray_address: String, p_nohub_address: String) -> Error: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return await _instance._connect_to_services(p_noray_address, p_nohub_address) + +static func disconnect_from_services() -> void: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + _instance._disconnect_from_services() + +static func is_connected_to_services() -> bool: + if not _instance: return false + return _instance._is_connected_to_services() + +static func join(address: String) -> Error: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return _instance._join(address) + +static func join_noray(oid: String) -> Error: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return _instance._join_noray(oid) + +static func host_lobby(name: String, address: String, max_players: int = 8) -> NohubResult.Lobby: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return await _instance._host_lobby(name, address, max_players) + +static func host_quick_play(address: String, max_players: int = 8) -> NohubResult.Lobby: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return await _instance._host_quick_play(address, max_players) + +static func host_noray() -> Error: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + return await _instance._host_noray() + +static func update_player_count(player_count: int) -> void: + assert(_instance, "ForestBrawlConnector instance missing from Scene Tree!") + await _instance._update_player_count(player_count) + +func _connect_to_services(p_noray_address: String, p_nohub_address: String) -> Error: + _disconnect_from_services() + + var noray_address = _parse_address(p_noray_address, 8890) + var nohub_address = _parse_address(p_nohub_address, 12980) + + # Connect to noray + _logger.info("Connecting to noray at %s:%d...", [noray_address[0], noray_address[1]]) + var err := await Noray.connect_to_host(noray_address[0], noray_address[1]) + if err != OK: + _logger.info("Failed to connect to noray: %s" % [error_string(err)]) + _disconnect_from_services() + return err + _logger.info("Successfully connected to noray!") + + # Connect to nohub + _logger.info("Connecting to nohub at %s:%d...", [noray_address[0], noray_address[1]]) + var peer := StreamPeerTCP.new() + peer.connect_to_host(nohub_address[0], nohub_address[1]) + while true: + peer.poll() + match peer.get_status(): + StreamPeerTCP.STATUS_CONNECTED: + _logger.info("Successfully connected to nohub!") + break + StreamPeerTCP.STATUS_ERROR: + _logger.info("Failed to connect to nohub!") + _disconnect_from_services() + return ERR_CONNECTION_ERROR + await get_tree().process_frame + + _nohub_peer = peer + _nohub_client = NohubClient.new(peer) + + # Register with noray + _logger.info("Registering host with noray... ") + Noray.register_host() + await Noray.on_pid + _logger.info("Success!") + + _logger.info("Registering remote with noray... ") + err = await Noray.register_remote() + if err != OK: + _logger.info("Failed registering remote address: %s" % error_string(err)) + _disconnect_from_services() + return ERR_CANT_ACQUIRE_RESOURCE + _logger.info("Success!") + + # Set GameID in nohub + _logger.info("Setting game ID with nohub... ") + await get_tree().process_frame + var response := await _nohub_client.set_game(GAME_ID) + if not response.is_success(): + _logger.info("Failed to set game ID! %s" % [response]) + _disconnect_from_services() + return ERR_QUERY_FAILED + _logger.info("Success!") + + # Success + _noray_address = "%s:%d" % noray_address + _nohub_address = "%s:%d" % nohub_address + + return OK + +func _disconnect_from_services() -> void: + _hosted_lobby = null + + if Noray.is_connected_to_host(): + Noray.disconnect_from_host() + _noray_address = "" + + if _nohub_peer != null: + _nohub_peer.disconnect_from_host() + _nohub_peer = null + _nohub_client = null + _nohub_address = "" + +func _is_connected_to_services() -> bool: + return Noray.is_connected_to_host() and _nohub_peer != null and _nohub_peer.get_status() == StreamPeerTCP.STATUS_CONNECTED + +func _join(address: String) -> Error: + var uri := _parse_uri(address) + if uri.is_empty(): + _logger.info("Failed to parse URI: %s", [address]) + return ERR_PARSE_ERROR + + if uri["protocol"] == "noray": + # TODO: Support different hosts + var oid := uri["path"] as String + return _join_noray(oid) + + _logger.info("Unknown schema: %s" % [uri["protocol"]]) + return ERR_UNAVAILABLE + +func _join_noray(oid: String) -> Error: + return _noray_connector.join(oid, ForestBrawlSettings.get_active().force_relay) + +func _host_lobby(name: String, address: String, max_players: int = 8, extra_data: Dictionary = {}) -> NohubResult.Lobby: + if not _nohub_client: + return NohubResult.of_error("NotConnectedError", "No nohub client present!") + + # TODO(nohub.gd): Stringify data values + var base_data := { "name": name, "player-count": "0", "player-capacity": str(max_players) } + var data := extra_data.duplicate() + data.merge(base_data, true) + + var response := await _nohub_client.create_lobby(address, data) + if response.is_success(): + _hosted_lobby = response.value() + return response + +func _host_quick_play(address: String, max_players: int = 8) -> NohubResult.Lobby: + var name := "Quick Play #%x" % [randi_range(0x10000000, 0xFFFFFFFF)] + return await _host_lobby(name, address, max_players, { "quick-play": "enabled" }) + +func _host_noray() -> Error: + return await _noray_connector.host() + +func _update_player_count(player_count: int) -> void: + if not _nohub_client: + return + if not _hosted_lobby: + return + + _hosted_lobby.data["player-count"] = str(player_count) + await _nohub_client.set_lobby_data(_hosted_lobby.id, _hosted_lobby.data) + +func _report_player_count() -> void: + if multiplayer.is_server(): + _update_player_count(multiplayer.get_peers().size() + 1) + +func _ready(): + _instance = self + _noray_connector = ForestBrawlNorayConnector.new() + add_child(_noray_connector) + + NetworkEvents.on_peer_join.connect(func(__): _report_player_count()) + NetworkEvents.on_peer_leave.connect(func(__): _report_player_count()) + NetworkEvents.on_server_start.connect(func(): _report_player_count()) + NetworkEvents.on_server_stop.connect(func(): + if _hosted_lobby and _nohub_client: + await _nohub_client.delete_lobby(_hosted_lobby.id) + _hosted_lobby = null + ) + +func _process(_dt) -> void: + if _nohub_peer: + var err := _nohub_peer.poll() + if err != OK: + _logger.info("Failed polling nohub: %s", [error_string(err)]) + _disconnect_from_services() + + if _nohub_client: + # TODO(trimsock): Return poll result, so we don't need to poll the peer separately + _nohub_client.poll() + +func _parse_address(address: String, default_port: int = 0) -> Array: + var result = ["", default_port] + if address.contains(":"): + var idx := address.rfind(":") + result[0] = address.substr(0, idx) + result[1] = int(address.substr(idx + 1)) + else: + result[0] = address + return result + +func _parse_uri(uri: String) -> Dictionary: + var pattern := RegEx.create_from_string("([a-zA-Z0-9]+)://([^/:]+):?([0-9]+)?/(.*)") + var hit := pattern.search(uri) + if not hit: return {} + + return { + "uri": uri, + "protocol": hit.strings[1], + "host": hit.strings[2], + "port": hit.strings[3], + "path": hit.strings[4] + } diff --git a/examples/forest-brawl/forest-brawl-connector.gd.uid b/examples/forest-brawl/forest-brawl-connector.gd.uid new file mode 100644 index 00000000..b5f6a8d8 --- /dev/null +++ b/examples/forest-brawl/forest-brawl-connector.gd.uid @@ -0,0 +1 @@ +uid://clnp3t5017c1h diff --git a/examples/forest-brawl/forest-brawl-noray-connector.gd b/examples/forest-brawl/forest-brawl-noray-connector.gd new file mode 100644 index 00000000..c3001c1b --- /dev/null +++ b/examples/forest-brawl/forest-brawl-noray-connector.gd @@ -0,0 +1,133 @@ +extends Node +class_name ForestBrawlNorayConnector + +var _is_host := false +var _is_client := false +var _target_oid := "" + +static var _logger := _NetfoxLogger.new("forest-brawl", "ForestBrawlNorayConnector") + +func _ready(): + Noray.on_connect_nat.connect(_handle_connect_nat) + Noray.on_connect_relay.connect(_handle_connect_relay) + +func host() -> Error: + if Noray.local_port <= 0: + return ERR_UNCONFIGURED + + # Start host + var err = OK + var port = Noray.local_port + _logger.info("Starting host on port %s" % port) + + var peer = ENetMultiplayerPeer.new() + err = peer.create_server(port) + if err != OK: + _logger.info("Failed to listen on port %s: %s" % [port, error_string(err)]) + return err + + get_tree().get_multiplayer().multiplayer_peer = peer + _logger.info("Listening on port %s" % port) + + # Wait for server to start + while peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING: + await get_tree().process_frame + + if peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: + OS.alert("Failed to start server!") + return FAILED + + get_tree().get_multiplayer().server_relay = true + + _is_host = true + _is_client = false + + return OK + +func join(oid: String, force_relay: bool = false) -> Error: + _is_host = false + _is_client = true + _target_oid = oid + + if force_relay: + _logger.info("Connecting over relay to %s", [oid]) + return Noray.connect_relay(oid) + else: + _logger.info("Connecting over NAT to %s", [oid]) + return Noray.connect_nat(oid) + +func _handle_connect_nat(address: String, port: int) -> Error: + _logger.info("Received NAT connect command to %s:%d", [address, port]) + var err = await _handle_connect(address, port) + + # If client failed to connect over NAT, try again over relay + if err != OK and not _is_host: + _logger.info("NAT connect failed with reason %s, retrying with relay to %s", [error_string(err), _target_oid]) + Noray.connect_relay(_target_oid) + err = OK + + return err + +func _handle_connect_relay(address: String, port: int) -> Error: + _logger.info("Received relay connect command to %s:%d", [address, port]) + return await _handle_connect(address, port) + +func _handle_connect(address: String, port: int) -> Error: + if not Noray.local_port: + return ERR_UNCONFIGURED + + var err = OK + + if not _is_host and not _is_client: + _logger.info("Refusing connection, not running as client nor host") + err = ERR_UNAVAILABLE + + if _is_client: + var udp = PacketPeerUDP.new() + udp.bind(Noray.local_port) + udp.set_dest_address(address, port) + + _logger.info("Attempting handshake with %s:%s" % [address, port]) + err = await PacketHandshake.over_packet_peer(udp) + udp.close() + + if err != OK: + if err == ERR_BUSY: + _logger.info("Handshake to %s:%s succeeded partially, attempting connection anyway" % [address, port]) + else: + _logger.info("Handshake to %s:%s failed: %s" % [address, port, error_string(err)]) + return err + else: + _logger.info("Handshake to %s:%s succeeded" % [address, port]) + + # Connect + var peer = ENetMultiplayerPeer.new() + err = peer.create_client(address, port, 0, 0, 0, Noray.local_port) + if err != OK: + _logger.info("Failed to create client: %s" % error_string(err)) + return err + + get_tree().get_multiplayer().multiplayer_peer = peer + + # Wait for connection to succeed + await Async.condition( + func(): return peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTING + ) + + if peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: + _logger.info("Failed to connect to %s:%s with status %s" % [address, port, peer.get_connection_status()]) + get_tree().get_multiplayer().multiplayer_peer = null + return ERR_CANT_CONNECT + + if _is_host: + # We should already have the connection configured, only thing to do is a handshake + var peer = get_tree().get_multiplayer().multiplayer_peer as ENetMultiplayerPeer + + err = await PacketHandshake.over_enet_peer(peer, address, port) + + if err != OK: + _logger.info("Handshake to %s:%s failed: %s" % [address, port, error_string(err)]) + return err + _logger.info("Handshake to %s:%s concluded" % [address, port]) + + return err diff --git a/examples/forest-brawl/forest-brawl-noray-connector.gd.uid b/examples/forest-brawl/forest-brawl-noray-connector.gd.uid new file mode 100644 index 00000000..36250585 --- /dev/null +++ b/examples/forest-brawl/forest-brawl-noray-connector.gd.uid @@ -0,0 +1 @@ +uid://chjoyedqttcl6 diff --git a/examples/forest-brawl/forest-brawl-settings.gd b/examples/forest-brawl/forest-brawl-settings.gd new file mode 100644 index 00000000..634da27a --- /dev/null +++ b/examples/forest-brawl/forest-brawl-settings.gd @@ -0,0 +1,66 @@ +extends RefCounted +class_name ForestBrawlSettings + +const DEFAULT_PATH = "user://settings.json" + +var player_name: String = NameProvider.name() +var randomize_name: bool = false +var force_relay: bool = false +var full_screen: bool = false +var vsync: bool = true +var confine_mouse: bool = false +var master_volume: float = 1. + +static var _active: ForestBrawlSettings + +func to_dictionary() -> Dictionary: + return { + "player_name": player_name, + "randomize_name": randomize_name, + "force_relay": force_relay, + "full_screen": full_screen, + "vsync": vsync, + "confine_mouse": confine_mouse, + "master_volume": master_volume + } + +func serialize() -> String: + return JSON.stringify(to_dictionary(), " ") + +func save(path: String = DEFAULT_PATH) -> void: + var file := FileAccess.open(path, FileAccess.WRITE) + file.store_string(serialize()) + file.close() + +static func from_dictionary(data: Dictionary) -> ForestBrawlSettings: + var result := ForestBrawlSettings.new() + + result.player_name = data.get("player_name", result.player_name) + result.randomize_name = data.get("randomize_name", result.randomize_name) + result.force_relay = data.get("force_relay", result.force_relay) + result.full_screen = data.get("full_screen", result.full_screen) + result.vsync = data.get("vsync", result.vsync) + result.confine_mouse = data.get("confine_mouse", result.confine_mouse) + result.master_volume = data.get("master_volume", result.master_volume) + + return result + +static func load(path: String = DEFAULT_PATH) -> ForestBrawlSettings: + if not FileAccess.file_exists(path): + return ForestBrawlSettings.new() + + var text := FileAccess.get_file_as_string(path) + var data = JSON.parse_string(text) + + if typeof(data) == TYPE_DICTIONARY: + return ForestBrawlSettings.from_dictionary(data as Dictionary) + else: + return ForestBrawlSettings.new() + +static func set_active(settings: ForestBrawlSettings) -> void: + _active = settings + +static func get_active() -> ForestBrawlSettings: + if not _active: + _active = ForestBrawlSettings.load() + return _active diff --git a/examples/forest-brawl/forest-brawl-settings.gd.uid b/examples/forest-brawl/forest-brawl-settings.gd.uid new file mode 100644 index 00000000..7ee5364a --- /dev/null +++ b/examples/forest-brawl/forest-brawl-settings.gd.uid @@ -0,0 +1 @@ +uid://cvvregmiprsqt diff --git a/examples/forest-brawl/forest-brawl.tscn b/examples/forest-brawl/forest-brawl.tscn index f93ea239..d9e7b99e 100644 --- a/examples/forest-brawl/forest-brawl.tscn +++ b/examples/forest-brawl/forest-brawl.tscn @@ -1,23 +1,18 @@ -[gd_scene load_steps=20 format=3 uid="uid://cwh2p0qb5872o"] +[gd_scene load_steps=15 format=3 uid="uid://cwh2p0qb5872o"] [ext_resource type="PackedScene" uid="uid://d1544gxqaoptc" path="res://examples/forest-brawl/maps/three-peaks.tscn" id="1_xksrt"] +[ext_resource type="Script" path="res://examples/forest-brawl/forest-brawl-connector.gd" id="2_wafqi"] [ext_resource type="Script" path="res://examples/forest-brawl/scripts/brawler-spawner.gd" id="5_qv1fx"] [ext_resource type="Script" path="res://examples/forest-brawl/scripts/following-camera.gd" id="5_yxhn7"] [ext_resource type="PackedScene" uid="uid://wi4owat0bml3" path="res://examples/forest-brawl/scenes/brawler.tscn" id="7_tcy3g"] [ext_resource type="PackedScene" uid="uid://bpf1jdr255nr0" path="res://examples/shared/ui/time-display.tscn" id="9_d2tot"] [ext_resource type="Script" path="res://examples/forest-brawl/scripts/score-manager.gd" id="9_vxjwh"] -[ext_resource type="LabelSettings" uid="uid://b4u1aluftkajy" path="res://examples/forest-brawl/ui-settings/player-stat-label.tres" id="10_0ix7v"] [ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="10_ld676"] -[ext_resource type="Script" path="res://examples/forest-brawl/scripts/settings/vsync-checkbutton.gd" id="11_4x74a"] -[ext_resource type="Script" path="res://examples/forest-brawl/scripts/random-name-input.gd" id="11_cf8pu"] [ext_resource type="PackedScene" uid="uid://b1vadi3ma8uiq" path="res://examples/forest-brawl/scenes/brawler-crown.tscn" id="11_eeeag"] -[ext_resource type="Script" path="res://examples/shared/scripts/noray-bootstrapper.gd" id="11_vpdh0"] [ext_resource type="Script" path="res://examples/forest-brawl/scripts/player-stats-display.gd" id="12_5kocp"] -[ext_resource type="Script" path="res://examples/shared/scripts/lan-bootstrapper.gd" id="12_gjc7i"] -[ext_resource type="Script" path="res://examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd" id="13_ujuuj"] [ext_resource type="PackedScene" uid="uid://ojh5xofoserg" path="res://examples/forest-brawl/scenes/score_screen.tscn" id="14_85lvt"] -[ext_resource type="Script" path="res://examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd" id="14_h1iqv"] -[ext_resource type="Script" path="res://examples/forest-brawl/scripts/settings/volume-slider.gd" id="16_6pky3"] +[ext_resource type="PackedScene" uid="uid://dbnx63lgo7288" path="res://examples/forest-brawl/menu.tscn" id="16_3ljtn"] +[ext_resource type="LabelSettings" uid="uid://b4u1aluftkajy" path="res://examples/forest-brawl/ui/player-stat-label.tres" id="17_48up8"] [sub_resource type="LabelSettings" id="LabelSettings_l686d"] font_size = 64 @@ -28,14 +23,16 @@ font_size = 64 [node name="Network" type="Node" parent="."] -[node name="Brawler Spawner" type="Node" parent="Network" node_paths=PackedStringArray("spawn_root", "camera", "joining_screen", "name_input")] +[node name="ForestBrawlConnector" type="Node" parent="Network"] +script = ExtResource("2_wafqi") + +[node name="Brawler Spawner" type="Node" parent="Network" node_paths=PackedStringArray("spawn_root", "camera", "joining_screen")] unique_name_in_owner = true script = ExtResource("5_qv1fx") player_scene = ExtResource("7_tcy3g") spawn_root = NodePath("../../Players") camera = NodePath("../../Camera3D") joining_screen = NodePath("../../UI/Joining Screen") -name_input = NodePath("../../UI/Network Popup/Settings/Player Name/Player Name Input") [node name="Players" type="Node" parent="."] @@ -77,226 +74,8 @@ grow_horizontal = 0 grow_vertical = 1 horizontal_alignment = 2 -[node name="Network Popup" type="TabContainer" parent="UI"] -custom_minimum_size = Vector2(320, 240) +[node name="Menu" parent="UI" instance=ExtResource("16_3ljtn")] layout_mode = 1 -anchors_preset = 8 -anchor_left = 0.5 -anchor_top = 0.5 -anchor_right = 0.5 -anchor_bottom = 0.5 -offset_left = -125.5 -offset_top = -105.0 -offset_right = 125.5 -offset_bottom = 105.0 -grow_horizontal = 2 -grow_vertical = 2 -use_hidden_tabs_for_min_size = true - -[node name="Settings" type="VBoxContainer" parent="UI/Network Popup"] -layout_mode = 2 -size_flags_vertical = 3 - -[node name="Player Name" type="HBoxContainer" parent="UI/Network Popup/Settings"] -layout_mode = 2 - -[node name="Player Name Label" type="Label" parent="UI/Network Popup/Settings/Player Name"] -layout_mode = 2 -text = "Player Name:" - -[node name="Player Name Input" type="LineEdit" parent="UI/Network Popup/Settings/Player Name"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "Nameless Brawler" -clear_button_enabled = true -script = ExtResource("11_cf8pu") - -[node name="GridContainer" type="GridContainer" parent="UI/Network Popup/Settings"] -layout_mode = 2 -columns = 2 - -[node name="Fullscreen" type="HBoxContainer" parent="UI/Network Popup/Settings/GridContainer"] -layout_mode = 2 - -[node name="Fullscreen Label" type="Label" parent="UI/Network Popup/Settings/GridContainer/Fullscreen"] -layout_mode = 2 -text = "Fullscreen:" - -[node name="Fullscreen CheckButton" type="CheckButton" parent="UI/Network Popup/Settings/GridContainer/Fullscreen"] -layout_mode = 2 -script = ExtResource("14_h1iqv") - -[node name="V-Sync" type="HBoxContainer" parent="UI/Network Popup/Settings/GridContainer"] -layout_mode = 2 - -[node name="V-Sync Label" type="Label" parent="UI/Network Popup/Settings/GridContainer/V-Sync"] -layout_mode = 2 -text = "V-Sync:" - -[node name="V-Sync CheckButton" type="CheckButton" parent="UI/Network Popup/Settings/GridContainer/V-Sync"] -layout_mode = 2 -script = ExtResource("11_4x74a") - -[node name="Confine mouse" type="HBoxContainer" parent="UI/Network Popup/Settings/GridContainer"] -layout_mode = 2 - -[node name="Confine Mouse Label" type="Label" parent="UI/Network Popup/Settings/GridContainer/Confine mouse"] -layout_mode = 2 -text = "Confine mouse:" - -[node name="Confine Mouse CheckButton" type="CheckButton" parent="UI/Network Popup/Settings/GridContainer/Confine mouse"] -layout_mode = 2 -script = ExtResource("13_ujuuj") - -[node name="Volume" type="HBoxContainer" parent="UI/Network Popup/Settings"] -layout_mode = 2 - -[node name="Volume Label" type="Label" parent="UI/Network Popup/Settings/Volume"] -layout_mode = 2 -text = "Volume:" - -[node name="Volume Slider" type="HSlider" parent="UI/Network Popup/Settings/Volume"] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 4 -value = 100.0 -script = ExtResource("16_6pky3") - -[node name="LAN" type="VBoxContainer" parent="UI/Network Popup"] -visible = false -layout_mode = 2 - -[node name="Address Row" type="HBoxContainer" parent="UI/Network Popup/LAN"] -layout_mode = 2 -size_flags_vertical = 2 - -[node name="Address Label" type="Label" parent="UI/Network Popup/LAN/Address Row"] -layout_mode = 2 -text = "Address:" - -[node name="Address LineEdit" type="LineEdit" parent="UI/Network Popup/LAN/Address Row"] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 0 -text = "localhost" - -[node name="Port Label" type="Label" parent="UI/Network Popup/LAN/Address Row"] -layout_mode = 2 -text = "Port:" - -[node name="Port LineEdit" type="LineEdit" parent="UI/Network Popup/LAN/Address Row"] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 0 -text = "16384" - -[node name="Actions Row" type="HBoxContainer" parent="UI/Network Popup/LAN"] -layout_mode = 2 -size_flags_horizontal = 4 - -[node name="Host Only Button" type="Button" parent="UI/Network Popup/LAN/Actions Row"] -layout_mode = 2 -size_flags_horizontal = 4 -text = "Host Only" - -[node name="Host Button" type="Button" parent="UI/Network Popup/LAN/Actions Row"] -layout_mode = 2 -size_flags_horizontal = 4 -text = "Host" - -[node name="Join Button" type="Button" parent="UI/Network Popup/LAN/Actions Row"] -layout_mode = 2 -size_flags_horizontal = 4 -text = "Join" - -[node name="Noray" type="VBoxContainer" parent="UI/Network Popup"] -visible = false -layout_mode = 2 - -[node name="Noray Address Row" type="HBoxContainer" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="Address Label" type="Label" parent="UI/Network Popup/Noray/Noray Address Row"] -layout_mode = 2 -text = "noray host:" - -[node name="Address LineEdit" type="LineEdit" parent="UI/Network Popup/Noray/Noray Address Row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "tomfol.io:8890" -placeholder_text = "noray.example.com:8890" - -[node name="OID Row" type="HBoxContainer" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="OID Label" type="Label" parent="UI/Network Popup/Noray/OID Row"] -layout_mode = 2 -text = "Open ID: " - -[node name="OID Value" type="LineEdit" parent="UI/Network Popup/Noray/OID Row"] -layout_mode = 2 -size_flags_horizontal = 3 -text = "123456789" -editable = false - -[node name="Noray Actions Row" type="HBoxContainer" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="Connect Button" type="Button" parent="UI/Network Popup/Noray/Noray Actions Row"] -layout_mode = 2 -text = "Connect" - -[node name="Disconnect Button" type="Button" parent="UI/Network Popup/Noray/Noray Actions Row"] -layout_mode = 2 -text = "Disconnect" - -[node name="HSeparator" type="HSeparator" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="Connect Host Row" type="HBoxContainer" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="Host Label" type="Label" parent="UI/Network Popup/Noray/Connect Host Row"] -layout_mode = 2 -text = "Target Host: " - -[node name="Host LineEdit" type="LineEdit" parent="UI/Network Popup/Noray/Connect Host Row"] -layout_mode = 2 -size_flags_horizontal = 3 -placeholder_text = "Host OID" - -[node name="Connect Actions Row" type="HBoxContainer" parent="UI/Network Popup/Noray"] -layout_mode = 2 - -[node name="Host Only Button" type="Button" parent="UI/Network Popup/Noray/Connect Actions Row"] -layout_mode = 2 -text = "Host Only" - -[node name="Host Button" type="Button" parent="UI/Network Popup/Noray/Connect Actions Row"] -layout_mode = 2 -text = "Host" - -[node name="Join Button" type="Button" parent="UI/Network Popup/Noray/Connect Actions Row"] -layout_mode = 2 -text = "Join" - -[node name="Force Relay Checkbox" type="CheckBox" parent="UI/Network Popup/Noray/Connect Actions Row"] -layout_mode = 2 -text = "Force Relay" - -[node name="LAN Bootstrapper" type="Node" parent="UI/Network Popup" node_paths=PackedStringArray("connect_ui", "address_input", "port_input")] -script = ExtResource("12_gjc7i") -connect_ui = NodePath("..") -address_input = NodePath("../LAN/Address Row/Address LineEdit") -port_input = NodePath("../LAN/Address Row/Port LineEdit") - -[node name="Noray Bootstrapper" type="Node" parent="UI/Network Popup" node_paths=PackedStringArray("connect_ui", "noray_address_input", "oid_input", "host_oid_input", "force_relay_check")] -script = ExtResource("11_vpdh0") -connect_ui = NodePath("..") -noray_address_input = NodePath("../Noray/Noray Address Row/Address LineEdit") -oid_input = NodePath("../Noray/OID Row/OID Value") -host_oid_input = NodePath("../Noray/Connect Host Row/Host LineEdit") -force_relay_check = NodePath("../Noray/Connect Actions Row/Force Relay Checkbox") [node name="Player stats" type="Control" parent="UI" node_paths=PackedStringArray("score_label", "score_manager")] visible = false @@ -322,13 +101,13 @@ layout_mode = 2 [node name="Score Label" type="Label" parent="UI/Player stats/VBoxContainer/Score HBox"] layout_mode = 2 text = "Score:" -label_settings = ExtResource("10_0ix7v") +label_settings = ExtResource("17_48up8") [node name="Score Value" type="Label" parent="UI/Player stats/VBoxContainer/Score HBox"] layout_mode = 2 text = "8 " -label_settings = ExtResource("10_0ix7v") +label_settings = ExtResource("17_48up8") [node name="Joining Screen" type="Control" parent="UI"] visible = false @@ -363,12 +142,3 @@ vertical_alignment = 1 [node name="Score Screen" parent="UI" instance=ExtResource("14_85lvt")] visible = false layout_mode = 1 - -[connection signal="pressed" from="UI/Network Popup/LAN/Actions Row/Host Only Button" to="UI/Network Popup/LAN Bootstrapper" method="host_only"] -[connection signal="pressed" from="UI/Network Popup/LAN/Actions Row/Host Button" to="UI/Network Popup/LAN Bootstrapper" method="host"] -[connection signal="pressed" from="UI/Network Popup/LAN/Actions Row/Join Button" to="UI/Network Popup/LAN Bootstrapper" method="join"] -[connection signal="pressed" from="UI/Network Popup/Noray/Noray Actions Row/Connect Button" to="UI/Network Popup/Noray Bootstrapper" method="connect_to_noray"] -[connection signal="pressed" from="UI/Network Popup/Noray/Noray Actions Row/Disconnect Button" to="UI/Network Popup/Noray Bootstrapper" method="disconnect_from_noray"] -[connection signal="pressed" from="UI/Network Popup/Noray/Connect Actions Row/Host Only Button" to="UI/Network Popup/Noray Bootstrapper" method="host_only"] -[connection signal="pressed" from="UI/Network Popup/Noray/Connect Actions Row/Host Button" to="UI/Network Popup/Noray Bootstrapper" method="host"] -[connection signal="pressed" from="UI/Network Popup/Noray/Connect Actions Row/Join Button" to="UI/Network Popup/Noray Bootstrapper" method="join"] diff --git a/examples/forest-brawl/menu.tscn b/examples/forest-brawl/menu.tscn new file mode 100644 index 00000000..44cb1b50 --- /dev/null +++ b/examples/forest-brawl/menu.tscn @@ -0,0 +1,1044 @@ +[gd_scene load_steps=26 format=3 uid="uid://dbnx63lgo7288"] + +[ext_resource type="Texture2D" uid="uid://cdb8di7e1p6h6" path="res://icon.png" id="1_6gbgt"] +[ext_resource type="Theme" uid="uid://cg8p4yow3i3ly" path="res://examples/forest-brawl/ui/menu-theme.tres" id="1_xmgyy"] +[ext_resource type="Texture2D" uid="uid://4vyxbqthy3nf" path="res://examples/forest-brawl/ui/gauss-bg.png" id="2_f45cx"] +[ext_resource type="AudioStream" uid="uid://b1tjkexft6b6p" path="res://examples/forest-brawl/sounds/switch/switch1.ogg" id="2_fg4qs"] +[ext_resource type="AudioStream" uid="uid://cr7ui6x21ywtf" path="res://examples/forest-brawl/sounds/switch/switch2.ogg" id="3_x70pt"] +[ext_resource type="AudioStream" uid="uid://b175yiuxb4out" path="res://examples/forest-brawl/sounds/switch/switch3.ogg" id="4_gji41"] +[ext_resource type="Texture2D" uid="uid://c6bp4x3j4l27k" path="res://examples/forest-brawl/ui/die-icon.svg" id="4_w03d4"] +[ext_resource type="AudioStream" uid="uid://br4qdiu4g7uuc" path="res://examples/forest-brawl/sounds/switch/switch4.ogg" id="5_6auhi"] +[ext_resource type="AudioStream" uid="uid://ct4mldhp1j0p1" path="res://examples/forest-brawl/sounds/switch/switch5.ogg" id="6_47cu0"] +[ext_resource type="AudioStream" uid="uid://b51qiut02m5hq" path="res://examples/forest-brawl/sounds/switch/switch6.ogg" id="7_g4pxc"] +[ext_resource type="AudioStream" uid="uid://cg2pl8kegpluj" path="res://examples/forest-brawl/sounds/switch/switch7.ogg" id="8_si12f"] +[ext_resource type="AudioStream" uid="uid://cwpvdoq22ewxj" path="res://examples/forest-brawl/sounds/switch/switch8.ogg" id="9_j72q5"] +[ext_resource type="AudioStream" uid="uid://vopvgimi40ci" path="res://examples/forest-brawl/sounds/click/click_001.ogg" id="10_xypl4"] +[ext_resource type="AudioStream" uid="uid://d4fnb7fq15ud7" path="res://examples/forest-brawl/sounds/click/click_002.ogg" id="11_6mefx"] +[ext_resource type="AudioStream" uid="uid://xm1rrumqubqo" path="res://examples/forest-brawl/sounds/click/click_003.ogg" id="12_880g4"] +[ext_resource type="AudioStream" uid="uid://pya5p1mgjj0r" path="res://examples/forest-brawl/sounds/click/click_004.ogg" id="13_iojt7"] +[ext_resource type="AudioStream" uid="uid://dgxn16sobxnj6" path="res://examples/forest-brawl/sounds/click/click_005.ogg" id="14_uoh15"] + +[sub_resource type="GDScript" id="GDScript_hv8dj"] +script/source = "extends Control + +@onready var audio_player := %\"UI Audio\" as AudioStreamPlayer + +@export var toggle_sounds: Array[AudioStream] = [] +@export var click_sounds: Array[AudioStream] = [] + +func _ready(): + # Hide when game starts + NetworkEvents.on_client_start.connect(func(__): hide()) + NetworkEvents.on_server_start.connect(func(): hide()) + + # Show when game ends + NetworkEvents.on_client_stop.connect(func(__): show()) + NetworkEvents.on_server_stop.connect(func(): show()) + + # Make sure the main menu is visible by default + for child in get_children(): + if child is Control: + child.hide() + + for i in range(2): + get_child(i).show() + + # Play a sound when something is toggled + for control in find_children(\"*\", \"Control\"): + if control is CheckButton or control is CheckBox: + var toggle := control as Button + toggle.toggled.connect(func(__): _play_random_sfx(toggle_sounds, -4)) + elif control is Button: + var button := control as Button + button.pressed.connect(func(): _play_random_sfx(click_sounds, +8)) + +func _play_random_sfx(pool: Array[AudioStream], gain_db: float = 0.0) -> void: + if pool.is_empty(): + return + + var sfx := pool.pick_random() as AudioStream + audio_player.stream = sfx + audio_player.volume_db = gain_db + audio_player.play() +" + +[sub_resource type="GDScript" id="GDScript_jmdql"] +script/source = "extends Control + +@onready var quick_play_button := $\"Quick Play Button\" as Button +@onready var find_game_button := $\"Find a Game Button\" as Button +@onready var play_lan_button := $\"Play LAN Button\" as Button +@onready var settings_button := $\"Settings Button\" as Button +@onready var quit_button := $\"Quit Button\" as Button + +@onready var quick_play_menu := %\"Quick Play Menu\" as Control +@onready var games_menu := %\"Games Menu\" as Control +@onready var lan_menu := %\"LAN Menu\" as Control +@onready var settings_menu := %\"Settings Menu\" as Control + +func _ready() -> void: + quick_play_button.pressed.connect(_quick_play) + find_game_button.pressed.connect(_find_game) + play_lan_button.pressed.connect(_play_lan) + settings_button.pressed.connect(_settings) + quit_button.pressed.connect(_quit) + +func _quick_play() -> void: + _switch_to(quick_play_menu) + +func _find_game() -> void: + _switch_to(games_menu) + +func _play_lan() -> void: + _switch_to(lan_menu) + +func _settings() -> void: + _switch_to(settings_menu) + +func _quit() -> void: + get_tree().quit() + +# TODO: Deduplicate? +func _switch_to(menu: Control) -> void: + menu.show() + hide() +" + +[sub_resource type="GDScript" id="GDScript_pewsl"] +script/source = "extends Control + +@onready var label := $Label as Label +@onready var back_button := $\"HBoxContainer/Back Button\" as Button +@onready var host_button := $\"HBoxContainer/Host Button\" as Button + +@onready var main_menu := %\"Main Menu\" as Control + +func is_active() -> bool: + return is_visible_in_tree() + +func _ready() -> void: + visibility_changed.connect(func(): + if is_visible_in_tree(): _execute() + else: _cancel() + ) + + back_button.pressed.connect(_back) + host_button.pressed.connect(_host) + +func _execute() -> void: + label.text = \"Connecting to services...\" + var err := await ForestBrawlConnector.connect_to_any_service_host() + if err != OK: + label.text = \"Connection failed: %s\" % [error_string(err)] + return + + label.text = \"Looking for games...\" + var expanded_search := false + var search_time := 0.0 + var lobby: NohubLobby + + while is_active(): + await get_tree().create_timer(1.0).timeout + search_time += 1.0 + + var response := await ForestBrawlConnector.nohub().list_lobbies() + if not response.is_success(): + label.text = \"nohub error: %s\" % [response.error().message] + + # Only consider quick-play lobbies + var lobbies := response.value()\\ + .filter(func (it: NohubLobby): + return it.data.get(\"quick-play\", \"\") == \"enabled\" or expanded_search + ) + + if not lobbies.is_empty(): + # TODO: More advanced strategies? + lobby = lobbies.pick_random() + break + + if search_time > 5.0 and not expanded_search: + label.text = \"Expanding search...\" + expanded_search = true + + if not is_active(): + return + + label.text = \"Joining...\" + var response := await ForestBrawlConnector.nohub().join_lobby(lobby.id) + if not response.is_success(): + label.text = \"nohub error: %s\" % [response.error().message] + + var address := response.value() + print(\"Joining address: %s\" % [address]) + err = ForestBrawlConnector.join(address) + if err != OK: + label.text = \"Couldn't join %s: %s\" % [address, error_string(err)] + +func _cancel() -> void: + pass + +func _back() -> void: + _switch_to(main_menu) + +func _host() -> void: + label.text = \"Creating lobby...\" + + # Create lobby + var lobby_name := \"Quick Play #%x\" % [randi_range(0x10000000, 0xFFFFFFFF)] + var player_capacity := 8 + var address := \"noray://%s/%s\" % [ForestBrawlConnector.noray_address(), Noray.oid] + + var response := await ForestBrawlConnector.host_quick_play(address, player_capacity) + if not response.is_success(): + label.text = \"Lobby fail: %s\" % [response.error().message] + + # Start game + label.text = \"Starting...\" + var err := await ForestBrawlConnector.host_noray() + if err != OK: + label.text = \"Fail: %s\" % [error_string(err)] + +# TODO: Deduplicate? +func _switch_to(menu: Control) -> void: + menu.show() + hide() +" + +[sub_resource type="Animation" id="Animation_7nw6r"] +resource_name = "spin" +length = 1.5 +loop_mode = 1 +step = 0.0416667 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath(".:modulate") +tracks/0/interp = 2 +tracks/0/loop_wrap = true +tracks/0/keys = { +"times": PackedFloat32Array(0, 0.75, 1.5), +"transitions": PackedFloat32Array(1, 1, 1), +"update": 0, +"values": [Color(1, 1, 1, 0.501961), Color(1, 1, 1, 1), Color(1, 1, 1, 0.501961)] +} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_dy8vn"] +_data = { +"spin": SubResource("Animation_7nw6r") +} + +[sub_resource type="GDScript" id="GDScript_2payw"] +script/source = "extends Control + +@onready var presets_option := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer/Presets Option\" as OptionButton +@onready var noray_input := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer/noray Input\" as LineEdit +@onready var nohub_input := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer/nohub Input\" as LineEdit +@onready var connect_button := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer/Connect Button\" as Button +@onready var status_label := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer/Status Label\" as Label +@onready var dock_button := $\"MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/Dock Button\" as Button + +@onready var lobbies_container := $\"MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/ScrollContainer/Lobbies Container\" as GridContainer +@onready var lobby_name_input := $\"MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer/Lobby Name Input\" as LineEdit +@onready var lobby_player_limit_input := $\"MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer/Lobby Player Limit Input\" as LineEdit +@onready var back_button := $\"MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/HBoxContainer/Back Button\" as Button +@onready var host_button := $\"MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/HBoxContainer/Host Button\" as Button + +@onready var dock_container := $MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer as Control +@onready var dock_panel := $MarginContainer/HBoxContainer/PanelContainer + +@onready var main_menu := %\"Main Menu\" as Control + +var _poll_interval := 2. +var _poll_wait := 0. + +var _is_hosting := false + +func is_active() -> bool: + return is_visible_in_tree() + +func _ready() -> void: + # TODO: Deduplicate? + visibility_changed.connect(func(): + if is_visible_in_tree(): _execute() + else: _cancel() + , CONNECT_DEFERRED) + + presets_option.item_selected.connect(_select_preset) + connect_button.pressed.connect(_connect) + dock_button.pressed.connect(_dock) + back_button.pressed.connect(_back) + host_button.pressed.connect(_host) + + # Populate presets + presets_option.clear() + for hosts in ForestBrawlConnector.known_service_hosts: + presets_option.add_item(hosts.name) + presets_option.select(0) + _select_preset(0) + +func _process(dt: float) -> void: + if not is_active(): + return + + # Poll lobbies + _poll_wait -= dt + if _poll_wait < 0.0 and ForestBrawlConnector.is_connected_to_services(): + print(\"Listing lobbies...\") + _poll_wait = _poll_interval + var response := await ForestBrawlConnector.nohub().list_lobbies() + if not response.is_success(): + print(\"Failed listing lobbies: %s\" % [response]) + else: + print(\"Found lobbies: %s\" % [response.value()]) + _render_lobbies(response.value()) + +func _execute() -> void: + _connect() + +func _cancel() -> void: + _disconnect() + _render_lobbies([]) + +func _select_preset(idx: int) -> void: + var preset := ForestBrawlConnector.known_service_hosts[idx] + noray_input.text = preset.noray_address + nohub_input.text = preset.nohub_address + +func _connect() -> void: + _disconnect() + + status_label.text = \"Status: Connecting...\" + var err := await ForestBrawlConnector.connect_to_services(noray_input.text, nohub_input.text) + if err != OK: + print(\"Failed to connect to services: \", error_string(err)) + _disconnect() + else: + status_label.text = \"Status: Online\" + +func _disconnect() -> void: + if not _is_hosting: + ForestBrawlConnector.disconnect_from_services() + status_label.text = \"Status: Offline\" + _is_hosting = false + +func _dock() -> void: + if dock_container.visible: + dock_container.hide() + dock_button.text = \">\" + else: + dock_container.show() + dock_button.text = \"<\" + dock_panel.size_flags_horizontal ^= Control.SIZE_EXPAND + +func _back() -> void: + _switch_to(main_menu) + +func _host() -> void: + if not ForestBrawlConnector.is_connected_to_services(): + return + + var lobby_name := lobby_name_input.text + var lobby_limit := lobby_player_limit_input.text + + var noray_address := ForestBrawlConnector.noray_address() + + if not lobby_name: + print(\"Lobby name can't be empty!\") + return + if not lobby_limit.is_valid_int(): + print(\"Player limit is not a number!\") + return + if int(lobby_limit) <= 0: + print(\"Invalid player limit!\") + return + + var player_limit := int(lobby_limit) + var address := \"noray://%s/%s\" % [noray_address, Noray.oid] + + var response := await ForestBrawlConnector.host_lobby(lobby_name, address, player_limit) + if not response.is_success(): + print(\"Failed to create lobby! \", response) + return + else: + print(\"Created lobby! \", response.value()) + _poll_wait = -1. + + var err := await ForestBrawlConnector.host_noray() + if err != OK: + prints(\"Failed to host game:\", error_string(err)) + return + + # Success! + _is_hosting = true + +func _join(lobby_id: String) -> void: + if not ForestBrawlConnector.is_connected_to_services(): + return + + print(\"Attempting to join lobby #%s\" % [lobby_id]) + var response := await ForestBrawlConnector.nohub().join_lobby(lobby_id) + if not response.is_success(): + print(\"Failed to join lobby %s: %s\" % [lobby_id, response]) + return + + var address := response.value() + print(\"Received address: %s\" % address) + ForestBrawlConnector.join(address) + +# TODO: Deduplicate? +func _switch_to(menu: Control) -> void: + menu.show() + hide() + +func _render_lobbies(lobbies: Array[NohubLobby]) -> void: + # Clear container, retain header + var children := lobbies_container.get_children() + for i in range(lobbies_container.columns, lobbies_container.get_child_count()): + children[i].queue_free() + + # Render list + for lobby in lobbies: + var name_label := Label.new() + name_label.text = lobby.data.get(\"name\", \"???\") + + var players_label := Label.new() + players_label.text = \"%s / %s\" % [lobby.data.get(\"player-count\", \"?\"), lobby.data.get(\"player-capacity\", \"?\")] + + var join_button := Button.new() + join_button.text = \">\" + join_button.tooltip_text = \"Join this lobby\" + join_button.pressed.connect(func(): _join(lobby.id)) + + lobbies_container.add_child(name_label) + lobbies_container.add_child(players_label) + lobbies_container.add_child(join_button) +" + +[sub_resource type="GDScript" id="GDScript_u7g6r"] +script/source = "extends Control + +@onready var main_menu := %\"Main Menu\" as Control + +@onready var host_input := $\"MarginContainer/VBoxContainer/GridContainer/Host Input\" as LineEdit +@onready var port_input := $\"MarginContainer/VBoxContainer/GridContainer/Port Input\" as LineEdit + +@onready var back_button := $\"MarginContainer/VBoxContainer/HBoxContainer/Back Button\" as Button +@onready var connect_button := $\"MarginContainer/VBoxContainer/HBoxContainer/Connect Button\" as Button +@onready var host_button := $\"MarginContainer/VBoxContainer/HBoxContainer/Host Button\" as Button + +func _ready(): + back_button.pressed.connect(_back) + connect_button.pressed.connect(_connect) + host_button.pressed.connect(_host) + +func _back(): + _switch_to(main_menu) + +func _connect(): + var host := host_input.text + var port = _get_port() + + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client(host, port) + if err != OK: + push_error(\"Failed to start client: %s\" % [error_string(err)]) + return + + multiplayer.multiplayer_peer = peer + + while peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING: + peer.poll() + await get_tree().process_frame + + if peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: + push_error(\"Failed to connect!\") + return + + get_parent_control().hide() # Success + +func _host(): + var port := _get_port() + + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(port) + if err != OK: + push_error(\"Failed to start server: %s\" % [error_string(err)]) + return + + multiplayer.multiplayer_peer = peer + get_parent_control().hide() # Success + +# TODO: Deduplicate? +func _switch_to(menu: Control) -> void: + menu.show() + hide() + +func _get_port() -> int: + if not port_input.text.is_valid_int(): + return 16384 + else: + return int(port_input.text) +" + +[sub_resource type="GDScript" id="GDScript_xlfi6"] +script/source = "extends Control + +@onready var confirm_button := $\"MarginContainer/Settings VBox/HBoxContainer/Confirm Button\" as Button +@onready var main_menu := %\"Main Menu\" as Control + +@onready var player_name_input := $\"MarginContainer/Settings VBox/Player Name/Player Name Input\" as LineEdit +@onready var randomize_button := $\"MarginContainer/Settings VBox/Player Name/Randomize Button\" as Button +@onready var always_randomize_checkbox := $\"MarginContainer/Settings VBox/Always Randomize CheckBox\" as CheckBox +@onready var force_relay_checkbox := $\"MarginContainer/Settings VBox/Force relay CheckBox\" +@onready var fullscreen_toggle := $\"MarginContainer/Settings VBox/GridContainer/Fullscreen CheckButton\" as CheckButton +@onready var vsync_toggle := $\"MarginContainer/Settings VBox/GridContainer/V-Sync CheckButton\" as CheckButton +@onready var confine_mouse_toggle := $\"MarginContainer/Settings VBox/GridContainer/Confine Mouse CheckButton\" as CheckButton +@onready var volume_slider := $\"MarginContainer/Settings VBox/Volume/Volume Slider\" as HSlider + +var _settings: ForestBrawlSettings + +func _ready() -> void: + visibility_changed.connect(func(): + if is_visible_in_tree(): _execute() + else: _cancel() + ) + + _settings = ForestBrawlSettings.load() + _apply_settings(_settings) + + player_name_input.text_changed.connect(func(__): _on_change()) + always_randomize_checkbox.toggled.connect(func(__): _on_change()) + force_relay_checkbox.toggled.connect(func(__): _on_change()) + fullscreen_toggle.toggled.connect(func(__): _on_change()) + vsync_toggle.toggled.connect(func(__): _on_change()) + confine_mouse_toggle.toggled.connect(func(__): _on_change()) + volume_slider.changed.connect(func(__): _on_change()) + + confirm_button.pressed.connect(_confirm) + randomize_button.pressed.connect(_randomize_name) + +func _execute() -> void: + _settings = ForestBrawlSettings.load() + _render_settings(_settings) + +func _cancel() -> void: + if _settings: + _settings.save() + ForestBrawlSettings.set_active(_settings) + print(\"Saved settings: %s\" % _settings.serialize()) + +func _randomize_name(): + player_name_input.text = NameProvider.name() + +func _confirm() -> void: + _switch_to(main_menu) + +func _on_change() -> void: + _settings = _read_settings() + _apply_settings(_settings) + +func _render_settings(settings: ForestBrawlSettings) -> void: + player_name_input.text = settings.player_name + always_randomize_checkbox.set_pressed_no_signal(settings.randomize_name) + force_relay_checkbox.set_pressed_no_signal(settings.force_relay) + fullscreen_toggle.set_pressed_no_signal(settings.full_screen) + vsync_toggle.set_pressed_no_signal(settings.vsync) + confine_mouse_toggle.set_pressed_no_signal(settings.confine_mouse) + volume_slider.set_value_no_signal(lerpf(volume_slider.min_value, volume_slider.max_value, settings.master_volume)) + +func _read_settings() -> ForestBrawlSettings: + var settings := ForestBrawlSettings.new() + + settings.player_name = player_name_input.text + settings.randomize_name = always_randomize_checkbox.button_pressed + settings.force_relay = force_relay_checkbox.button_pressed + settings.full_screen = fullscreen_toggle.button_pressed + settings.vsync = vsync_toggle.button_pressed + settings.confine_mouse = confine_mouse_toggle.button_pressed + settings.master_volume = inverse_lerp(volume_slider.min_value, volume_slider.max_value, volume_slider.value) + + return settings + +func _apply_settings(settings: ForestBrawlSettings) -> void: + # Randomize name + player_name_input.editable = not settings.randomize_name + + # Full screen + if settings.full_screen: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + else: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + + # V-sync + if settings.vsync: + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE) + else: + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + + # Confine mouse + if settings.confine_mouse: + DisplayServer.mouse_set_mode(DisplayServer.MOUSE_MODE_CONFINED) + else: + DisplayServer.mouse_set_mode(DisplayServer.MOUSE_MODE_VISIBLE) + + # Volume + var volume = lerp(-60, 0, settings.master_volume) + var mute = volume < -59.5 + + AudioServer.set_bus_volume_db(0, volume) + AudioServer.set_bus_mute(0, mute) + +# TODO: Deduplicate? +func _switch_to(menu: Control) -> void: + menu.show() + hide() +" + +[node name="Menu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_xmgyy") +script = SubResource("GDScript_hv8dj") +toggle_sounds = Array[AudioStream]([ExtResource("2_fg4qs"), ExtResource("3_x70pt"), ExtResource("4_gji41"), ExtResource("5_6auhi"), ExtResource("6_47cu0"), ExtResource("7_g4pxc"), ExtResource("8_si12f"), ExtResource("9_j72q5")]) +click_sounds = Array[AudioStream]([ExtResource("10_xypl4"), ExtResource("11_6mefx"), ExtResource("12_880g4"), ExtResource("13_iojt7"), ExtResource("14_uoh15")]) + +[node name="TextureRect" type="TextureRect" parent="."] +modulate = Color(0, 0, 0, 0.501961) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("2_f45cx") +stretch_mode = 6 + +[node name="Main Menu" type="VBoxContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +alignment = 1 +script = SubResource("GDScript_jmdql") + +[node name="Quick Play Button" type="Button" parent="Main Menu"] +layout_mode = 2 +theme_type_variation = &"MainMenuButton" +text = "Quick Play" +flat = true + +[node name="Find a Game Button" type="Button" parent="Main Menu"] +layout_mode = 2 +theme_type_variation = &"MainMenuButton" +text = "Find a Game" +flat = true + +[node name="Play LAN Button" type="Button" parent="Main Menu"] +layout_mode = 2 +theme_type_variation = &"MainMenuButton" +text = "Play on LAN" +flat = true + +[node name="Settings Button" type="Button" parent="Main Menu"] +layout_mode = 2 +theme_type_variation = &"MainMenuButton" +text = "Settings" +flat = true + +[node name="Quit Button" type="Button" parent="Main Menu"] +layout_mode = 2 +theme_type_variation = &"MainMenuButton" +text = "Quit" +flat = true + +[node name="Quick Play Menu" type="VBoxContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +alignment = 1 +script = SubResource("GDScript_pewsl") + +[node name="Label" type="Label" parent="Quick Play Menu"] +layout_mode = 2 +theme_type_variation = &"HeaderLarge" +text = "Searching..." +horizontal_alignment = 1 + +[node name="Spinner" type="TextureRect" parent="Quick Play Menu"] +modulate = Color(1, 1, 1, 0.501961) +layout_mode = 2 +texture = ExtResource("1_6gbgt") +stretch_mode = 5 + +[node name="AnimationPlayer" type="AnimationPlayer" parent="Quick Play Menu/Spinner"] +autoplay = "spin" +libraries = { +"": SubResource("AnimationLibrary_dy8vn") +} + +[node name="HBoxContainer" type="HBoxContainer" parent="Quick Play Menu"] +layout_mode = 2 +alignment = 1 + +[node name="Back Button" type="Button" parent="Quick Play Menu/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +text = "Back" + +[node name="Host Button" type="Button" parent="Quick Play Menu/HBoxContainer"] +layout_mode = 2 +text = "Host" + +[node name="Games Menu" type="Control" parent="."] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(960, 540) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -20.0 +offset_top = -20.0 +offset_right = 20.0 +offset_bottom = 20.0 +grow_horizontal = 2 +grow_vertical = 2 +script = SubResource("GDScript_2payw") + +[node name="MarginContainer" type="MarginContainer" parent="Games Menu"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="Games Menu/MarginContainer"] +layout_mode = 2 + +[node name="PanelContainer" type="PanelContainer" parent="Games Menu/MarginContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer"] +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="VBoxContainer" type="GridContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer"] +layout_mode = 2 +columns = 2 + +[node name="Presets Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Presets:" + +[node name="Presets Option" type="OptionButton" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +allow_reselect = true + +[node name="noray Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "noray:" + +[node name="noray Input" type="LineEdit" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="nohub Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "nohub:" + +[node name="nohub Input" type="LineEdit" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 + +[node name="Connect Button" type="Button" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Connect" + +[node name="Status Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Status: " + +[node name="Dock Button" type="Button" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer/HBoxContainer"] +layout_mode = 2 +text = "<" + +[node name="PanelContainer2" type="PanelContainer" parent="Games Menu/MarginContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 2.0 + +[node name="MarginContainer2" type="MarginContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Title Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer"] +layout_mode = 2 +theme_type_variation = &"HeaderMedium" +text = "Games" +horizontal_alignment = 1 + +[node name="ScrollContainer" type="ScrollContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Lobbies Container" type="GridContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +columns = 3 + +[node name="Name Header" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/ScrollContainer/Lobbies Container"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"EmbossLabel" +text = "Name" + +[node name="Players Header" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/ScrollContainer/Lobbies Container"] +layout_mode = 2 +theme_type_variation = &"EmbossLabel" +text = "Players" + +[node name="Tail Header" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/ScrollContainer/Lobbies Container"] +custom_minimum_size = Vector2(32, 0) +layout_mode = 2 +theme_type_variation = &"EmbossLabel" +text = " " + +[node name="GridContainer" type="GridContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer"] +layout_mode = 2 +columns = 2 + +[node name="Name Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Lobby name" + +[node name="Player Limit Label" type="Label" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Player Limit" + +[node name="Lobby Name Input" type="LineEdit" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Lobby Player Limit Input" type="LineEdit" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/GridContainer"] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 4 + +[node name="Back Button" type="Button" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Back" + +[node name="Host Button" type="Button" parent="Games Menu/MarginContainer/HBoxContainer/PanelContainer2/MarginContainer2/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Host Game" + +[node name="LAN Menu" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(480, 320) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -20.0 +offset_top = -20.0 +offset_right = 20.0 +offset_bottom = 20.0 +grow_horizontal = 2 +grow_vertical = 2 +script = SubResource("GDScript_u7g6r") + +[node name="MarginContainer" type="MarginContainer" parent="LAN Menu"] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="LAN Menu/MarginContainer"] +layout_mode = 2 + +[node name="Title Label" type="Label" parent="LAN Menu/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_type_variation = &"HeaderMedium" +text = "Play on LAN" +horizontal_alignment = 1 + +[node name="GridContainer" type="GridContainer" parent="LAN Menu/MarginContainer/VBoxContainer"] +layout_mode = 2 +columns = 2 + +[node name="Host Label" type="Label" parent="LAN Menu/MarginContainer/VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Host: " + +[node name="Host Input" type="LineEdit" parent="LAN Menu/MarginContainer/VBoxContainer/GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "localhost" + +[node name="Port Label" type="Label" parent="LAN Menu/MarginContainer/VBoxContainer/GridContainer"] +layout_mode = 2 +text = "Port: " + +[node name="Port Input" type="LineEdit" parent="LAN Menu/MarginContainer/VBoxContainer/GridContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "16384" + +[node name="HBoxContainer" type="HBoxContainer" parent="LAN Menu/MarginContainer/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 10 +alignment = 1 + +[node name="Back Button" type="Button" parent="LAN Menu/MarginContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Back" + +[node name="Connect Button" type="Button" parent="LAN Menu/MarginContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Connect" + +[node name="Host Button" type="Button" parent="LAN Menu/MarginContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Host" + +[node name="Settings Menu" type="PanelContainer" parent="."] +unique_name_in_owner = true +custom_minimum_size = Vector2(480, 320) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +script = SubResource("GDScript_xlfi6") + +[node name="MarginContainer" type="MarginContainer" parent="Settings Menu"] +layout_mode = 2 + +[node name="Settings VBox" type="VBoxContainer" parent="Settings Menu/MarginContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Title Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 +theme_type_variation = &"HeaderMedium" +text = "Settings" +horizontal_alignment = 1 + +[node name="Player Name" type="HBoxContainer" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 + +[node name="Player Name Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox/Player Name"] +layout_mode = 2 +text = "Player Name:" + +[node name="Player Name Input" type="LineEdit" parent="Settings Menu/MarginContainer/Settings VBox/Player Name"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Nameless Brawler" +clear_button_enabled = true + +[node name="Randomize Button" type="Button" parent="Settings Menu/MarginContainer/Settings VBox/Player Name"] +custom_minimum_size = Vector2(32, 0) +layout_mode = 2 +tooltip_text = "Generate a random name" +icon = ExtResource("4_w03d4") +icon_alignment = 1 +expand_icon = true + +[node name="Always Randomize CheckBox" type="CheckBox" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 +text = "Always randomize name" + +[node name="Force relay CheckBox" type="CheckBox" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 +text = "Force relay" + +[node name="HSeparator" type="HSeparator" parent="Settings Menu/MarginContainer/Settings VBox"] +visible = false +layout_mode = 2 + +[node name="GridContainer" type="GridContainer" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 +columns = 8 + +[node name="Fullscreen Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 +text = "Fullscreen:" + +[node name="Fullscreen CheckButton" type="CheckButton" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 + +[node name="V-Sync Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 +text = "V-Sync:" + +[node name="V-Sync CheckButton" type="CheckButton" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 + +[node name="Confine Mouse Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 +text = "Confine mouse:" + +[node name="Confine Mouse CheckButton" type="CheckButton" parent="Settings Menu/MarginContainer/Settings VBox/GridContainer"] +layout_mode = 2 + +[node name="HSeparator2" type="HSeparator" parent="Settings Menu/MarginContainer/Settings VBox"] +visible = false +layout_mode = 2 + +[node name="Volume" type="HBoxContainer" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 + +[node name="Volume Label" type="Label" parent="Settings Menu/MarginContainer/Settings VBox/Volume"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Volume:" + +[node name="Volume Slider" type="HSlider" parent="Settings Menu/MarginContainer/Settings VBox/Volume"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +size_flags_stretch_ratio = 3.0 +value = 100.0 + +[node name="HBoxContainer" type="HBoxContainer" parent="Settings Menu/MarginContainer/Settings VBox"] +layout_mode = 2 +size_flags_vertical = 10 +alignment = 1 + +[node name="Confirm Button" type="Button" parent="Settings Menu/MarginContainer/Settings VBox/HBoxContainer"] +layout_mode = 2 +text = "Confirm" + +[node name="UI Audio" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true diff --git a/examples/forest-brawl/scripts/brawler-spawner.gd b/examples/forest-brawl/scripts/brawler-spawner.gd index 37f37160..fbb32576 100644 --- a/examples/forest-brawl/scripts/brawler-spawner.gd +++ b/examples/forest-brawl/scripts/brawler-spawner.gd @@ -82,7 +82,8 @@ func _spawn(id: int) -> BrawlerController: GameEvents.on_own_brawler_spawn.emit(avatar) # Submit name - var player_name = name_input.text + var settings := ForestBrawlSettings.get_active() + var player_name = NameProvider.name() if settings.randomize_name else settings.player_name print("Submitting player name " + player_name) _submit_name.rpc(player_name) diff --git a/examples/forest-brawl/scripts/name-provider.gd b/examples/forest-brawl/scripts/name-provider.gd index 17f28eba..85dc9381 100644 --- a/examples/forest-brawl/scripts/name-provider.gd +++ b/examples/forest-brawl/scripts/name-provider.gd @@ -7,7 +7,7 @@ static var _animals: PackedStringArray static func _pick_random(from: PackedStringArray) -> String: return from[randi_range(0, from.size()-1)] -static func name(): +static func name() -> String: return ("%s %s" % [ NameProvider._pick_random(NameProvider._adjectives), NameProvider._pick_random(NameProvider._animals) diff --git a/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd b/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd deleted file mode 100644 index cf9c24b0..00000000 --- a/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd +++ /dev/null @@ -1,7 +0,0 @@ -extends CheckButton - -func _toggled(toggle): - if toggle: - DisplayServer.mouse_set_mode(DisplayServer.MOUSE_MODE_CONFINED) - else: - DisplayServer.mouse_set_mode(DisplayServer.MOUSE_MODE_VISIBLE) diff --git a/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd.uid b/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd.uid deleted file mode 100644 index d969b2ff..00000000 --- a/examples/forest-brawl/scripts/settings/confine-mouse-checkbutton.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://nvr3jr0bviin diff --git a/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd b/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd deleted file mode 100644 index dc3e588d..00000000 --- a/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd +++ /dev/null @@ -1,7 +0,0 @@ -extends CheckButton - -func _toggled(toggle): - if toggle: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - else: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) diff --git a/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd.uid b/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd.uid deleted file mode 100644 index 13209d26..00000000 --- a/examples/forest-brawl/scripts/settings/fullscreen-checkbutton.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://doidsx4hyb4gd diff --git a/examples/forest-brawl/scripts/settings/volume-slider.gd b/examples/forest-brawl/scripts/settings/volume-slider.gd deleted file mode 100644 index 4731d25c..00000000 --- a/examples/forest-brawl/scripts/settings/volume-slider.gd +++ /dev/null @@ -1,9 +0,0 @@ -extends HSlider - -func _value_changed(new_value): - var f = new_value / 100.0 - var volume = lerp(-60, 0, f) - var mute = f < 0.01 - - AudioServer.set_bus_volume_db(0, volume) - AudioServer.set_bus_mute(0, mute) diff --git a/examples/forest-brawl/scripts/settings/volume-slider.gd.uid b/examples/forest-brawl/scripts/settings/volume-slider.gd.uid deleted file mode 100644 index b3cd9292..00000000 --- a/examples/forest-brawl/scripts/settings/volume-slider.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ghfyw0kak8vn diff --git a/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd b/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd deleted file mode 100644 index ded00238..00000000 --- a/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd +++ /dev/null @@ -1,7 +0,0 @@ -extends CheckButton - -func _toggled(toggle): - if toggle: - DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE) - else: - DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) diff --git a/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd.uid b/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd.uid deleted file mode 100644 index fc4f7efa..00000000 --- a/examples/forest-brawl/scripts/settings/vsync-checkbutton.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gyiplrcda6mg diff --git a/examples/forest-brawl/sounds/attribution.md b/examples/forest-brawl/sounds/attribution.md index 30a0121c..8b8f743d 100644 --- a/examples/forest-brawl/sounds/attribution.md +++ b/examples/forest-brawl/sounds/attribution.md @@ -109,6 +109,31 @@ Files: *"Slide Whistle, Descending, A.wav"* by [InspectorJ], under the [Attribution 4.0] license. No changes were made to the original sound effect. +## Switch sounds + +[Source](https://kenney.nl/assets/ui-audio) + +Played in menus when toggling items. + +Files: +* `switch/*` + +*UI Audio* by [Kenney], under the [Creative Commons CC0] license. No changes +were made to the original sound effect. + +## Click sounds + +[Source](https://kenney.nl/assets/interface-sounds) + +Played in menus when pressing button. + +Files: +* `click/*` + +*Interface Sounds* by [Kenney], under the [Creative Commons CC0] license. No +changes were made to the original sound effect. + + [studiomandragore]: https://freesound.org/people/studiomandragore/ [qubodup]: https://freesound.org/people/qubodup/ [Breviceps]: https://freesound.org/people/Breviceps/sounds/452998/ @@ -117,8 +142,10 @@ Files: [oganesson]: https://freesound.org/people/oganesson/ [silversatyr]: https://freesound.org/people/silversatyr/ [InspectorJ]: https://freesound.org/people/InspectorJ/ +[Kenney]: https://kenney.nl/ [Creative Commons 0]: https://creativecommons.org/publicdomain/zero/1.0/ [Attribution 3.0]: https://creativecommons.org/licenses/by/3.0/ [Attribution 4.0]: https://creativecommons.org/licenses/by/4.0/ +[Creative Commons CC0]: https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/examples/forest-brawl/sounds/click/click_001.ogg b/examples/forest-brawl/sounds/click/click_001.ogg new file mode 100644 index 00000000..7ca77c71 Binary files /dev/null and b/examples/forest-brawl/sounds/click/click_001.ogg differ diff --git a/examples/forest-brawl/sounds/click/click_001.ogg.import b/examples/forest-brawl/sounds/click/click_001.ogg.import new file mode 100644 index 00000000..e99bb3f0 --- /dev/null +++ b/examples/forest-brawl/sounds/click/click_001.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://vopvgimi40ci" +path="res://.godot/imported/click_001.ogg-72e9730de2b60b33a89880a1de20492f.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/click/click_001.ogg" +dest_files=["res://.godot/imported/click_001.ogg-72e9730de2b60b33a89880a1de20492f.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/click/click_002.ogg b/examples/forest-brawl/sounds/click/click_002.ogg new file mode 100644 index 00000000..4564b888 Binary files /dev/null and b/examples/forest-brawl/sounds/click/click_002.ogg differ diff --git a/examples/forest-brawl/sounds/click/click_002.ogg.import b/examples/forest-brawl/sounds/click/click_002.ogg.import new file mode 100644 index 00000000..793aabe9 --- /dev/null +++ b/examples/forest-brawl/sounds/click/click_002.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://d4fnb7fq15ud7" +path="res://.godot/imported/click_002.ogg-cafda1b0619c2c53ecaa27cf77305839.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/click/click_002.ogg" +dest_files=["res://.godot/imported/click_002.ogg-cafda1b0619c2c53ecaa27cf77305839.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/click/click_003.ogg b/examples/forest-brawl/sounds/click/click_003.ogg new file mode 100644 index 00000000..62de5e45 Binary files /dev/null and b/examples/forest-brawl/sounds/click/click_003.ogg differ diff --git a/examples/forest-brawl/sounds/click/click_003.ogg.import b/examples/forest-brawl/sounds/click/click_003.ogg.import new file mode 100644 index 00000000..af8826a0 --- /dev/null +++ b/examples/forest-brawl/sounds/click/click_003.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://xm1rrumqubqo" +path="res://.godot/imported/click_003.ogg-556b570cff6493f7c19189b38898afa7.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/click/click_003.ogg" +dest_files=["res://.godot/imported/click_003.ogg-556b570cff6493f7c19189b38898afa7.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/click/click_004.ogg b/examples/forest-brawl/sounds/click/click_004.ogg new file mode 100644 index 00000000..d569cdaf Binary files /dev/null and b/examples/forest-brawl/sounds/click/click_004.ogg differ diff --git a/examples/forest-brawl/sounds/click/click_004.ogg.import b/examples/forest-brawl/sounds/click/click_004.ogg.import new file mode 100644 index 00000000..95df3cc6 --- /dev/null +++ b/examples/forest-brawl/sounds/click/click_004.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://pya5p1mgjj0r" +path="res://.godot/imported/click_004.ogg-7892088d552f4ac4953ceb9f80b84214.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/click/click_004.ogg" +dest_files=["res://.godot/imported/click_004.ogg-7892088d552f4ac4953ceb9f80b84214.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/click/click_005.ogg b/examples/forest-brawl/sounds/click/click_005.ogg new file mode 100644 index 00000000..2f0b9aa3 Binary files /dev/null and b/examples/forest-brawl/sounds/click/click_005.ogg differ diff --git a/examples/forest-brawl/sounds/click/click_005.ogg.import b/examples/forest-brawl/sounds/click/click_005.ogg.import new file mode 100644 index 00000000..fe03deeb --- /dev/null +++ b/examples/forest-brawl/sounds/click/click_005.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://dgxn16sobxnj6" +path="res://.godot/imported/click_005.ogg-8ef763b2b35f34e16d966048c50cf94c.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/click/click_005.ogg" +dest_files=["res://.godot/imported/click_005.ogg-8ef763b2b35f34e16d966048c50cf94c.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch1.ogg b/examples/forest-brawl/sounds/switch/switch1.ogg new file mode 100644 index 00000000..f72d399c Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch1.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch1.ogg.import b/examples/forest-brawl/sounds/switch/switch1.ogg.import new file mode 100644 index 00000000..2d1aa2e2 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch1.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://b1tjkexft6b6p" +path="res://.godot/imported/switch1.ogg-b041d0998650d6e264a927e6cb616c9c.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch1.ogg" +dest_files=["res://.godot/imported/switch1.ogg-b041d0998650d6e264a927e6cb616c9c.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch2.ogg b/examples/forest-brawl/sounds/switch/switch2.ogg new file mode 100644 index 00000000..a38d7756 Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch2.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch2.ogg.import b/examples/forest-brawl/sounds/switch/switch2.ogg.import new file mode 100644 index 00000000..d3a1f31e --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch2.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://cr7ui6x21ywtf" +path="res://.godot/imported/switch2.ogg-47688a7e3b7ae70203498ff561b12437.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch2.ogg" +dest_files=["res://.godot/imported/switch2.ogg-47688a7e3b7ae70203498ff561b12437.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch3.ogg b/examples/forest-brawl/sounds/switch/switch3.ogg new file mode 100644 index 00000000..673f0488 Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch3.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch3.ogg.import b/examples/forest-brawl/sounds/switch/switch3.ogg.import new file mode 100644 index 00000000..b9ff3cd7 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch3.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://b175yiuxb4out" +path="res://.godot/imported/switch3.ogg-681331c20ad8c097d410fbb61b196c90.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch3.ogg" +dest_files=["res://.godot/imported/switch3.ogg-681331c20ad8c097d410fbb61b196c90.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch4.ogg b/examples/forest-brawl/sounds/switch/switch4.ogg new file mode 100644 index 00000000..8e11491e Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch4.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch4.ogg.import b/examples/forest-brawl/sounds/switch/switch4.ogg.import new file mode 100644 index 00000000..96619c68 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch4.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://br4qdiu4g7uuc" +path="res://.godot/imported/switch4.ogg-a2eceb97efa2d775762d9d0263baba6a.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch4.ogg" +dest_files=["res://.godot/imported/switch4.ogg-a2eceb97efa2d775762d9d0263baba6a.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch5.ogg b/examples/forest-brawl/sounds/switch/switch5.ogg new file mode 100644 index 00000000..754facf2 Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch5.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch5.ogg.import b/examples/forest-brawl/sounds/switch/switch5.ogg.import new file mode 100644 index 00000000..b88551a0 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch5.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://ct4mldhp1j0p1" +path="res://.godot/imported/switch5.ogg-fd5ab6ef619a24ff825fdf15c9bca7f8.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch5.ogg" +dest_files=["res://.godot/imported/switch5.ogg-fd5ab6ef619a24ff825fdf15c9bca7f8.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch6.ogg b/examples/forest-brawl/sounds/switch/switch6.ogg new file mode 100644 index 00000000..6dabf0b7 Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch6.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch6.ogg.import b/examples/forest-brawl/sounds/switch/switch6.ogg.import new file mode 100644 index 00000000..64850da1 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch6.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://b51qiut02m5hq" +path="res://.godot/imported/switch6.ogg-a5a7d4f8f1b009f826d505c9bd477532.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch6.ogg" +dest_files=["res://.godot/imported/switch6.ogg-a5a7d4f8f1b009f826d505c9bd477532.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch7.ogg b/examples/forest-brawl/sounds/switch/switch7.ogg new file mode 100644 index 00000000..b70b49df Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch7.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch7.ogg.import b/examples/forest-brawl/sounds/switch/switch7.ogg.import new file mode 100644 index 00000000..6c157a64 --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch7.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://cg2pl8kegpluj" +path="res://.godot/imported/switch7.ogg-1c5b5746aba87a2346e2fec3244e374b.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch7.ogg" +dest_files=["res://.godot/imported/switch7.ogg-1c5b5746aba87a2346e2fec3244e374b.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/sounds/switch/switch8.ogg b/examples/forest-brawl/sounds/switch/switch8.ogg new file mode 100644 index 00000000..55b0159e Binary files /dev/null and b/examples/forest-brawl/sounds/switch/switch8.ogg differ diff --git a/examples/forest-brawl/sounds/switch/switch8.ogg.import b/examples/forest-brawl/sounds/switch/switch8.ogg.import new file mode 100644 index 00000000..8e169cfa --- /dev/null +++ b/examples/forest-brawl/sounds/switch/switch8.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://cwpvdoq22ewxj" +path="res://.godot/imported/switch8.ogg-43a19b1ac90684c51b78e9a5449d6564.oggvorbisstr" + +[deps] + +source_file="res://examples/forest-brawl/sounds/switch/switch8.ogg" +dest_files=["res://.godot/imported/switch8.ogg-43a19b1ac90684c51b78e9a5449d6564.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/examples/forest-brawl/ui/die-icon.svg b/examples/forest-brawl/ui/die-icon.svg new file mode 100644 index 00000000..58d150ed --- /dev/null +++ b/examples/forest-brawl/ui/die-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/forest-brawl/ui/die-icon.svg.import b/examples/forest-brawl/ui/die-icon.svg.import new file mode 100644 index 00000000..997b9fe3 --- /dev/null +++ b/examples/forest-brawl/ui/die-icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6bp4x3j4l27k" +path="res://.godot/imported/die-icon.svg-b75822ea2b6919dba23b8da30e58af37.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://examples/forest-brawl/ui/die-icon.svg" +dest_files=["res://.godot/imported/die-icon.svg-b75822ea2b6919dba23b8da30e58af37.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=4.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/examples/forest-brawl/ui/gauss-bg.png b/examples/forest-brawl/ui/gauss-bg.png new file mode 100644 index 00000000..17385533 Binary files /dev/null and b/examples/forest-brawl/ui/gauss-bg.png differ diff --git a/examples/forest-brawl/ui/gauss-bg.png.import b/examples/forest-brawl/ui/gauss-bg.png.import new file mode 100644 index 00000000..dc5d382a --- /dev/null +++ b/examples/forest-brawl/ui/gauss-bg.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4vyxbqthy3nf" +path="res://.godot/imported/gauss-bg.png-beb4006ca033bc0b9dd58ece44796325.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://examples/forest-brawl/ui/gauss-bg.png" +dest_files=["res://.godot/imported/gauss-bg.png-beb4006ca033bc0b9dd58ece44796325.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/examples/forest-brawl/ui/menu-theme.tres b/examples/forest-brawl/ui/menu-theme.tres new file mode 100644 index 00000000..3050d9ae --- /dev/null +++ b/examples/forest-brawl/ui/menu-theme.tres @@ -0,0 +1,33 @@ +[gd_resource type="Theme" load_steps=5 format=3 uid="uid://cg8p4yow3i3ly"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wbjcp"] +bg_color = Color(0.25, 0.25, 0.25, 0.752941) +corner_radius_top_left = 2 +corner_radius_top_right = 2 +corner_radius_bottom_right = 2 +corner_radius_bottom_left = 2 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1tayi"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_2kkly"] + +[sub_resource type="SystemFont" id="SystemFont_43mgq"] +generate_mipmaps = true +multichannel_signed_distance_field = true + +[resource] +default_font = SubResource("SystemFont_43mgq") +EmbossLabel/base_type = &"Label" +EmbossLabel/styles/normal = SubResource("StyleBoxFlat_wbjcp") +MainMenuButton/base_type = &"Button" +MainMenuButton/colors/font_color = Color(1, 1, 1, 0.752941) +MainMenuButton/colors/font_focus_color = Color(1, 1, 1, 0.878431) +MainMenuButton/colors/font_hover_color = Color(1, 1, 1, 0.878431) +MainMenuButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1) +MainMenuButton/font_sizes/font_size = 48 +MainMenuButton/styles/focus = SubResource("StyleBoxEmpty_1tayi") +MainMenuButton/styles/pressed = SubResource("StyleBoxEmpty_2kkly") +MarginContainer/constants/margin_bottom = 4 +MarginContainer/constants/margin_left = 4 +MarginContainer/constants/margin_right = 4 +MarginContainer/constants/margin_top = 4 diff --git a/examples/forest-brawl/ui-settings/player-stat-label.tres b/examples/forest-brawl/ui/player-stat-label.tres similarity index 100% rename from examples/forest-brawl/ui-settings/player-stat-label.tres rename to examples/forest-brawl/ui/player-stat-label.tres diff --git a/project.godot b/project.godot index 6ec07406..b98a55e8 100644 --- a/project.godot +++ b/project.godot @@ -33,19 +33,28 @@ NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" [display] -window/size/viewport_width=540 -window/size/viewport_height=540 +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/size/window_width_override=540 +window/size/window_height_override=540 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" window/vsync/vsync_mode=0 [editor_plugins] -enabled=PackedStringArray("res://addons/netfox.extras/plugin.cfg", "res://addons/netfox.internals/plugin.cfg", "res://addons/netfox.noray/plugin.cfg", "res://addons/netfox/plugin.cfg", "res://addons/vest/plugin.cfg") +enabled=PackedStringArray("res://addons/netfox.extras/plugin.cfg", "res://addons/netfox.internals/plugin.cfg", "res://addons/netfox.noray/plugin.cfg", "res://addons/netfox/plugin.cfg", "res://addons/nohub.gd/plugin.cfg", "res://addons/trimsock.gd/plugin.cfg", "res://addons/vest/plugin.cfg") [filesystem] import/blender/enabled=false import/fbx/enabled=false +[gui] + +theme/default_font_multichannel_signed_distance_field=true +theme/default_font_generate_mipmaps=true + [input] move_west={