diff --git a/autoloads/utils.gd b/autoloads/utils.gd new file mode 100644 index 0000000..53184f7 --- /dev/null +++ b/autoloads/utils.gd @@ -0,0 +1,9 @@ +extends Node + +var first_connect_done := false + +func get_external_freq_param(): + match OS.get_name(): + "Web": + return JavaScriptBridge.eval("new URL(window.location.href).searchParams.get('freq')") + return null diff --git a/autoloads/utils.gd.uid b/autoloads/utils.gd.uid new file mode 100644 index 0000000..0cdce87 --- /dev/null +++ b/autoloads/utils.gd.uid @@ -0,0 +1 @@ +uid://ua7dk3y0v21e diff --git a/export_presets.cfg b/export_presets.cfg index 2c029c6..5b7d0c8 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -129,7 +129,7 @@ permissions/install_location_provider=false permissions/install_packages=false permissions/install_shortcut=false permissions/internal_system_window=false -permissions/internet=false +permissions/internet=true permissions/kill_background_processes=false permissions/location_hardware=false permissions/manage_accounts=false diff --git a/icon.svg.import b/icon.svg.import index e3d1b7b..224ae46 100644 --- a/icon.svg.import +++ b/icon.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://dvaugiwdmfmge" +uid="uid://bkoamufjn5wa1" path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" metadata={ "vram_texture": false diff --git a/project.godot b/project.godot index b9b9a88..c493726 100644 --- a/project.godot +++ b/project.godot @@ -20,6 +20,7 @@ config/icon="res://icon.svg" [autoload] MorseState="*res://autoloads/morse_state.gd" +Utils="*res://autoloads/utils.gd" [display] diff --git a/scenes/GUITheme.tres b/scenes/GUITheme.tres new file mode 100644 index 0000000..b723596 --- /dev/null +++ b/scenes/GUITheme.tres @@ -0,0 +1,4 @@ +[gd_resource type="Theme" format=3 uid="uid://xxoc27tvaiut"] + +[resource] +default_font_size = 30 diff --git a/scenes/MultiMorseBanner.tscn b/scenes/MultiMorseBanner.tscn new file mode 100644 index 0000000..255334d --- /dev/null +++ b/scenes/MultiMorseBanner.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=3 format=3 uid="uid://ug3u6jf36dst"] + +[ext_resource type="Script" uid="uid://b1k6j1jti114u" path="res://scenes/multi_morse_banner.gd" id="1_a1ve8"] + +[sub_resource type="AudioStreamGenerator" id="AudioStreamGenerator_a1ve8"] +mix_rate = 22050.0 + +[node name="MorseBanner" type="Control"] +custom_minimum_size = Vector2(200, 100) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_a1ve8") + +[node name="Player" type="AudioStreamPlayer" parent="."] +stream = SubResource("AudioStreamGenerator_a1ve8") +volume_db = -100.0 +stream_paused = true diff --git a/scenes/MultiplayerConnect.tscn b/scenes/MultiplayerConnect.tscn new file mode 100644 index 0000000..516a5d4 --- /dev/null +++ b/scenes/MultiplayerConnect.tscn @@ -0,0 +1,153 @@ +[gd_scene load_steps=3 format=3 uid="uid://dnxcrx04kl3xy"] + +[ext_resource type="Theme" uid="uid://xxoc27tvaiut" path="res://scenes/GUITheme.tres" id="1_2wc0w"] +[ext_resource type="Script" uid="uid://di8r70441xdms" path="res://scenes/multiplayer_connect.gd" id="1_uyd8l"] + +[node name="MultiplayerConnect" 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_2wc0w") +script = ExtResource("1_uyd8l") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ConnectView" type="Control" parent="VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/ConnectView"] +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="VBoxContainer/ConnectView/VBoxContainer"] +layout_mode = 2 + +[node name="FrequencyCreator" type="TextEdit" parent="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "430.200" + +[node name="Label" type="Label" parent="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "MHz" + +[node name="CreateButton" type="Button" parent="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +size_flags_stretch_ratio = 2.41 +text = "Create Frequency" + +[node name="BackButton" type="Button" parent="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 10 +text = "Back" + +[node name="RefreshButton" type="Button" parent="VBoxContainer/ConnectView/VBoxContainer"] +layout_mode = 2 +text = "Refresh" + +[node name="FreqList" type="ItemList" parent="VBoxContainer/ConnectView/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MorseView" type="Control" parent="VBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/MorseView"] +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="VBoxContainer/MorseView/VBoxContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="VBoxContainer/MorseView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Freq:" + +[node name="FreqLabel" type="Label" parent="VBoxContainer/MorseView/VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="LeaveButton" type="Button" parent="VBoxContainer/MorseView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 10 +text = "Leave Frequency" + +[node name="PlayerContainer" type="VBoxContainer" parent="VBoxContainer/MorseView/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MorseButton" type="Button" parent="VBoxContainer/MorseView/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +size_flags_stretch_ratio = 2.0 +text = "MORSE" + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Status:" + +[node name="StatusLabel" type="Label" parent="VBoxContainer/HBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Disconnected" + +[node name="HBoxContainer2" type="HBoxContainer" parent="VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer/HBoxContainer2"] +layout_mode = 2 +mouse_filter = 0 +text = "Last Error:" + +[node name="ErrorLabel" type="Label" parent="VBoxContainer/HBoxContainer/HBoxContainer2"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +mouse_filter = 0 +text = "" +autowrap_mode = 2 + +[connection signal="pressed" from="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer/CreateButton" to="." method="_on_create_button_pressed"] +[connection signal="pressed" from="VBoxContainer/ConnectView/VBoxContainer/HBoxContainer/BackButton" to="." method="_on_back_button_pressed"] +[connection signal="pressed" from="VBoxContainer/ConnectView/VBoxContainer/RefreshButton" to="." method="_on_refresh_button_pressed"] +[connection signal="item_clicked" from="VBoxContainer/ConnectView/VBoxContainer/FreqList" to="." method="_on_freq_list_join"] +[connection signal="pressed" from="VBoxContainer/MorseView/VBoxContainer/HBoxContainer/LeaveButton" to="." method="_on_leave_button_pressed"] +[connection signal="button_down" from="VBoxContainer/MorseView/VBoxContainer/MorseButton" to="." method="_on_morse_button_button_down"] +[connection signal="button_up" from="VBoxContainer/MorseView/VBoxContainer/MorseButton" to="." method="_on_morse_button_button_up"] +[connection signal="gui_input" from="VBoxContainer/HBoxContainer/HBoxContainer2/Label" to="." method="_on_error_label_gui_input"] +[connection signal="gui_input" from="VBoxContainer/HBoxContainer/HBoxContainer2/ErrorLabel" to="." method="_on_error_label_gui_input"] diff --git a/scenes/main.gd b/scenes/main.gd index b80edb7..aa5a678 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -6,6 +6,7 @@ var phase = 0.0 var morse_state := false var playback: AudioStreamPlayback = null var vol_on := -30 +var multiplayer_enabled: bool = true # FIXME: maybe make this a tag? func _process(_delta): fill_buffer() @@ -19,14 +20,30 @@ func fill_buffer(): phase = fmod(phase + increment, 1.0) func _ready(): + match OS.get_name(): + "Android": + %WavButton.text = "Share Wav" + "Web": + %WavButton.text = "Download Wav" + $Player.stream.mix_rate = sample_hz $Player.volume_db = -100 $Player.play() playback = $Player.get_stream_playback() fill_buffer() - - OS.open_midi_inputs() - print(OS.get_connected_midi_inputs()) + + if OS.get_name() != "Web": + OS.open_midi_inputs() + print(OS.get_connected_midi_inputs()) + else: + %MidiButton.visible = true + + if multiplayer_enabled: + %MultiplayerButton.visible = true + + if Utils.get_external_freq_param(): + print("Direct connect to external freq: ", Utils.get_external_freq_param()) + _on_multiplayer_button_pressed() func set_morse_state(state: bool): MorseState.set_state(state) @@ -144,3 +161,12 @@ func _on_wav_button_pressed() -> void: func _on_reset_button_pressed() -> void: MorseState.reset() + + +func _on_multiplayer_button_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/MultiplayerConnect.tscn") + + +func _on_midi_button_pressed() -> void: + OS.open_midi_inputs() + print(OS.get_connected_midi_inputs()) diff --git a/scenes/main.tscn b/scenes/main.tscn index 61f554f..128742c 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=5 format=3 uid="uid://ctak1goemnnc5"] +[gd_scene load_steps=6 format=3 uid="uid://ctak1goemnnc5"] [ext_resource type="Script" uid="uid://dmeokosn7gr27" path="res://scenes/main.gd" id="1_8bx00"] +[ext_resource type="Theme" uid="uid://xxoc27tvaiut" path="res://scenes/GUITheme.tres" id="1_jyhfs"] [ext_resource type="PackedScene" uid="uid://xqic6oa5d7oc" path="res://scenes/MorseBanner.tscn" id="2_v02md"] [ext_resource type="Script" uid="uid://bjt60u6r1hqf7" path="res://addons/SharePlugin/Share.gd" id="3_sugp2"] @@ -15,6 +16,7 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +theme = ExtResource("1_jyhfs") script = ExtResource("1_8bx00") [node name="VBoxContainer" type="VBoxContainer" parent="."] @@ -53,16 +55,38 @@ size_flags_stretch_ratio = 2.0 text = "MORSE" [node name="WavButton" type="Button" parent="VBoxContainer"] +unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 -text = "Write Wav" +text = "Save Wav" -[node name="ResetButton" type="Button" parent="VBoxContainer"] +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] layout_mode = 2 size_flags_vertical = 3 + +[node name="MidiButton" type="Button" parent="VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_stretch_ratio = 0.25 +text = "Open +Midi" + +[node name="ResetButton" type="Button" parent="VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 size_flags_stretch_ratio = 0.5 text = "Reset" +[node name="MultiplayerButton" type="Button" parent="VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 0.5 +text = "Connect to Frequency" + [node name="Player" type="AudioStreamPlayer" parent="."] stream = SubResource("AudioStreamGenerator_kvn5v") volume_db = -80.0 @@ -73,4 +97,6 @@ script = ExtResource("3_sugp2") [connection signal="button_down" from="VBoxContainer/MorseButton" to="." method="_on_morse_button_down"] [connection signal="button_up" from="VBoxContainer/MorseButton" to="." method="_on_morse_button_up"] [connection signal="pressed" from="VBoxContainer/WavButton" to="." method="_on_wav_button_pressed"] -[connection signal="pressed" from="VBoxContainer/ResetButton" to="." method="_on_reset_button_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/MidiButton" to="." method="_on_midi_button_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/ResetButton" to="." method="_on_reset_button_pressed"] +[connection signal="pressed" from="VBoxContainer/HBoxContainer/MultiplayerButton" to="." method="_on_multiplayer_button_pressed"] diff --git a/scenes/multi_morse_banner.gd b/scenes/multi_morse_banner.gd new file mode 100644 index 0000000..04c4811 --- /dev/null +++ b/scenes/multi_morse_banner.gd @@ -0,0 +1,111 @@ +@tool +class_name MultiMorseBanner +extends Control + +static var sample_hz := 22050.0 +static var vol_on := -30 +var phase := 0.0 + +var num: int +var tone_hz: int +var color_on := Color(0, 128, 0) +var color_off := Color(0, 0, 0) +var last_delta := 0.0 +@export_range(0.1, 120.0) var display_sec := 5.0 +@export var stretch_display := false +var playback; + +func _ready() -> void: + $Player.stream.mix_rate = sample_hz + $Player.volume_db = -100 + $Player.play() + playback = $Player.get_stream_playback() + fill_buffer() + +var morse_step_perc := 0.45 +const MMB_SCENE := preload("res://scenes/MultiMorseBanner.tscn") + +class LocalMorseState: + var states: Array[int] = [] + var curr_state := false + var last_change: int = 0 + var start_time := 0 + + func reset() -> void: + last_change = Time.get_ticks_msec() + start_time = last_change + states = [] + + func set_state(state: bool) -> void: + if state == curr_state: + return + curr_state = state + + var now := Time.get_ticks_msec() + states.push_back(now - last_change) + last_change = now + +var morse_state := LocalMorseState.new() + +static func new_banner(num: int, color: Color, tone: int): + var mmb = MMB_SCENE.instantiate() + mmb.num = num + mmb.color_on = color + mmb.tone_hz = tone + + return mmb + +func _draw_morse_rect(x: float, width: float, state: bool): + var rect := Rect2(max(x, 0.0), morse_step_perc * size.y, width, (0.5 - morse_step_perc) * size.y) + draw_rect(rect, color_on if state else color_off, true, -1.0, true) + +func _draw(): + # black background + draw_rect(Rect2(0.0, 0.0, size.x, size.y), Color.BLACK) + + # in editor we only want a black rectangle + if Engine.is_editor_hint(): + return + + var morse_on := morse_state.curr_state + var first_time := Time.get_ticks_msec() - morse_state.last_change + var curr_x := float(size.x) + + var px_per_s := 0.0 + if not stretch_display: + px_per_s = size.x / display_sec + else: + px_per_s = size.x / (Time.get_ticks_msec() - morse_state.start_time) * 1000.0 + + for n in [-1] + range(morse_state.states.size() - 1, -1, -1): + var duration := first_time if n == -1 else morse_state.states[n] + var rect_width: float = min(duration / 1000.0 * px_per_s, curr_x) + curr_x -= rect_width + if morse_on: + # at the moment we only draw the morse rects + _draw_morse_rect(curr_x, rect_width, morse_on) + morse_on = not morse_on + if curr_x <= 0.0: + break + +func _process(_delta): + last_delta += _delta + queue_redraw() + fill_buffer() + +func set_morse_state(state: bool) -> void: + morse_state.set_state(state) + # morse_state = state + if state: + $Player.volume_db = vol_on + else: + $Player.volume_db = -100 + +func fill_buffer(): + var increment = tone_hz / sample_hz + var frames_available = playback.get_frames_available() + + for i in range(frames_available): + playback.push_frame(Vector2.ONE * sin(phase * TAU)) + phase = fmod(phase + increment, 1.0) + diff --git a/scenes/multi_morse_banner.gd.uid b/scenes/multi_morse_banner.gd.uid new file mode 100644 index 0000000..b170160 --- /dev/null +++ b/scenes/multi_morse_banner.gd.uid @@ -0,0 +1 @@ +uid://b1k6j1jti114u diff --git a/scenes/multiplayer_connect.gd b/scenes/multiplayer_connect.gd new file mode 100644 index 0000000..fab6f27 --- /dev/null +++ b/scenes/multiplayer_connect.gd @@ -0,0 +1,252 @@ +extends Control + +var server := "seba-geek.de" +var port := "3784" +var ws_url := "wss://seba-geek.de/godot/cw-generator-ws/" +var IS_DEBUG = false +var ws := WebSocketPeer.new() +var ws_last_status = -1 +var autoconnect_to_freq: String + +var available_freqs = [] +var mmb_self: MultiMorseBanner +var mmb_others: Dictionary[String, MultiMorseBanner] + +class PlayerData: + var num: int + var color: Color + var tone: int + + func _init(num, color, tone): + self.num = num + self.color = color + self.tone = tone + +var player_data := [ + PlayerData.new(0, Color( 0, 255, 0), 880), + PlayerData.new(1, Color(255, 0, 0), 1318), + PlayerData.new(2, Color( 0, 0, 255), 587), + PlayerData.new(3, Color(255, 0, 255), 440), + PlayerData.new(3, Color( 0, 255, 255), 783), + PlayerData.new(3, Color(255, 255, 0), 1567), +] + +func _ready() -> void: + # FIXME: connection handling / reconnect + # FIXME: status / error messages + # FIXME: randomize default join frquency + # FIXME: automatic refresh + _connect_ws() + + if not Utils.first_connect_done: + var cmdline_freq = Utils.get_external_freq_param() + if cmdline_freq: + autoconnect_to_freq = cmdline_freq + +func _connect_ws(): + if not IS_DEBUG: + print("Connecting to ", ws_url) + ws.connect_to_url(ws_url) + else: + print("ws://%s:%s" % [server, port]) + ws.connect_to_url("ws://%s:%s" % [server, port]) + +func _process(_delta: float) -> void: + ws.poll() + var state := ws.get_ready_state() + while state == WebSocketPeer.STATE_OPEN and ws.get_available_packet_count(): + _handle_packet() + if ws_last_status != state: + match state: + WebSocketPeer.STATE_CONNECTING: + %StatusLabel.text = "Connecting...!" + WebSocketPeer.STATE_OPEN: + %StatusLabel.text = "Connected!" + WebSocketPeer.STATE_CLOSING: + %StatusLabel.text = "Disconnecting..." + WebSocketPeer.STATE_CLOSED: + %StatusLabel.text = "Disconnected :(" + + # Trigger reconnect + _trigger_reconnect(1) + ws_last_status = state + +func _trigger_reconnect(delay: int): + await get_tree().create_timer(delay).timeout + _connect_ws() + +func _handle_packet() -> void: + var data := ws.get_packet().get_string_from_utf8() + var parsed: Dictionary = JSON.parse_string(data) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type"): + print("Error: Could not parse data: ", data) + return + + print("Recvd data: ", parsed) + + match parsed["type"]: + "hello": + # fetch frequency list on first join + _on_refresh_button_pressed() + + if autoconnect_to_freq: + send_data({"cmd": "create", "freq": autoconnect_to_freq, "join-if-present": true}) + "join": + _join_freq(parsed["freq"], parsed["self_id"], parsed["other_players"]) + + "freq-list": + %FreqList.clear() + for freq in parsed["freqs"]: + var text = "%s MHz (%d present)" % [freq["freq"], freq["players"]] + print("Adding ", text) + var idx: int = %FreqList.add_item(text) + %FreqList.set_item_metadata(idx, freq) + + "morse-state": + var from_player: String = parsed["from_player"] + if from_player not in mmb_others: + print("Error: Got morse state from unknown player ", from_player) + return + mmb_others[from_player].set_morse_state(parsed["state"]) + + "player-joined": + var new_player: String = parsed["player"] + if new_player in mmb_others: + print("Error: got player join message, but player ", new_player, " is already present") + return + + # find free player id + var player_n := 1 + while true: + var found := false + for other in mmb_others.values(): + if other.num == player_n: + print("New player, num ", player_n, " already taken") + found = true + break + if not found: + break + player_n += 1 + + make_player(player_n, new_player) + + "player-left": + var player: String = parsed["player"] + if player not in mmb_others: + print("Error: player not part of freq ", player) + return + remove_player(parsed["player"]) + "leave": + _leave_freq() + "error": + %ErrorLabel.text = parsed["message"] + _: + print("Unhandled message: ", parsed["type"]) + + +func _join_freq(freq: String, player_id: String, other_players: Array): + for child in %PlayerContainer.get_children(): + %PlayerContainer.remove_child(child) + + mmb_self = make_player(0, player_id) + for n in range(other_players.size()): + make_player(n + 1, other_players[n]) + + %FreqLabel.text = "%s MHz" % freq + %ConnectView.hide() + %MorseView.show() + +func _leave_freq(): + %MorseView.hide() + if mmb_self: + mmb_self.set_morse_state(false) + mmb_self = null + + for mmb: MultiMorseBanner in mmb_others.values(): + mmb.set_morse_state(false) + + mmb_others.clear() + for child in %PlayerContainer.get_children(): + %PlayerContainer.remove_child(child) + %ConnectView.show() + +func _on_refresh_button_pressed() -> void: + var refresh_cmd := {"cmd": "list", "type": "cw-generator"} + var data := JSON.stringify(refresh_cmd) + "\n" + ws.send_text(data) + + +func _on_create_button_pressed() -> void: + var freq: String = "%.3f" % float(%FrequencyCreator.text) + var refresh_cmd := {"cmd": "create", "type": "cw-generator", "freq": freq} + var data := JSON.stringify(refresh_cmd) + "\n" + ws.send_text(data) + + +func _on_freq_list_join(index: int, at_position: Vector2, mouse_button_index: int) -> void: + var meta = %FreqList.get_item_metadata(index) + var freq: String = meta["freq"] + print("Yop ", index, " metadata ", freq) + var join_cmd := {"cmd": "join", "freq": freq, "type": "cw-generator"} + ws.send_text(JSON.stringify(join_cmd) + "\n") + +func make_player(no: int, player_id: String): + var pd: PlayerData + if no < player_data.size(): + pd = player_data[no] + else: + pd = PlayerData.new(no, Color(randi_range(0, 255), randi_range(0, 255), randi_range(0, 255)), randi_range(440, 440 * 3)) + + var mmb = MultiMorseBanner.new_banner(pd.num, pd.color, pd.tone) + %PlayerContainer.add_child(mmb) + mmb_others[player_id] = mmb + return mmb + +func remove_player(player_id: String): + mmb_others[player_id].set_morse_state(false) + %PlayerContainer.remove_child(mmb_others[player_id]) + mmb_others.erase(player_id) + +func set_morse_state(state: bool): + mmb_self.set_morse_state(state) + send_data({"cmd": "morse-state", "state": state}) + +func send_data(data: Dictionary): + var text := JSON.stringify(data) + "\n" + ws.send_text(text) + +func _on_morse_button_button_down() -> void: + if not mmb_self: + return + set_morse_state(true) + +func _on_morse_button_button_up() -> void: + if not mmb_self: + return + set_morse_state(false) + +func _input(input_event): + if input_event is InputEventMIDI: + _process_midi_event(input_event) + +func _process_midi_event(midi_event): + if not mmb_self: + return + + if midi_event.channel in [0, 9]: + if midi_event.message == MIDI_MESSAGE_NOTE_ON: + set_morse_state(true) + elif midi_event.message == MIDI_MESSAGE_NOTE_OFF: + set_morse_state(false) + +func _on_leave_button_pressed() -> void: + send_data({"cmd": "leave"}) + + +func _on_error_label_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == 1: + %ErrorLabel.text = "" + + +func _on_back_button_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/main.tscn") diff --git a/scenes/multiplayer_connect.gd.uid b/scenes/multiplayer_connect.gd.uid new file mode 100644 index 0000000..a1328ac --- /dev/null +++ b/scenes/multiplayer_connect.gd.uid @@ -0,0 +1 @@ +uid://di8r70441xdms diff --git a/signalsrv/signalsrv.py b/signalsrv/signalsrv.py new file mode 100644 index 0000000..89fde8f --- /dev/null +++ b/signalsrv/signalsrv.py @@ -0,0 +1,204 @@ +import asyncio +import datetime +import json +import logging +import re + +from websockets.asyncio.server import serve + +__VERSION__ = "0.0.1" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s" +) + + +class Client: + freqs = {} + freq_re = re.compile(r"^\d+\.\d{3}$") + + def __init__(self, websocket): + self.websocket = websocket + self.curr_freq = None + + async def handle(self): + print(f" >>> New client {self.client} connected") + exc = None + try: + await self._handle_client() + except Exception as e: + exc = e + finally: + # FIXME: basically handle disconnect / leave from room + print(f" <<< Client {self.client} id {self.id} disconnected: {exc}") + if self.curr_freq: + await self._leave_room() + + @property + def client(self): + ip, port, *_ = self.websocket.remote_address + if ':' in ip: + ip = f"[{ip}]" + return f"{ip}:{port}" + + @property + def id(self): + return str(self.websocket.id) + + async def _handle_client(self): + await self._send(type="hello", name="LobbySrv 3000", version=__VERSION__) + async for data in self.websocket: + print(f" <-- client {self.client} sent {repr(data)}") + try: + data = json.loads(data) + except json.JSONDecodeError: + self._send_error("Could not decode message, invalid json") + continue + + if not isinstance(data, dict) or "cmd" not in data: + await self._send_error("Invalid format in json") + continue + + print(f"{datetime.datetime.now()} {self.client} wrote:", data) + + match data["cmd"]: + case "quit": + break + case "create": + await self._create_room(data) + case "join": + await self._join_room(data) + case "leave": + await self._leave_room() + case "list": + freqs = [{"freq": freq, "players": len(players)} + for freq, players in self.freqs.items()] + await self._send(type="freq-list", freqs=freqs) + case "disconnect": + pass + case "morse-state": + await self._handle_morse_state(data) + case _: + await self._send_error("Unknown command") + + async def _create_room(self, data): + if self.curr_freq: + await self._send_error(f"Already on frequency {self.curr_freq}") + return + + if "freq" not in data: + await self._send_error("No frequency in create message") + return + freq = data["freq"] + + if not self.freq_re.match(freq): + await self._send_error("Invalid frequency") + return + + if freq in self.freqs: + if data.get("join-if-present"): + await self._join_room({"freq": freq}) + else: + await self._send_error("Frequency already in use") + return + + self.curr_freq = freq + self.freqs[freq] = [self] + await self._send(type="join", freq=self.curr_freq, self_id=self.id, other_players=[]) + + async def _join_room(self, data): + if self.curr_freq: + await self._send_error(f"Already on frequency {self.curr_freq}") + return + + if "freq" not in data: + await self._send_error("No frequency in join message") + return + freq = data["freq"] + + if freq not in self.freqs: + await self._send_error(f"Frequency {freq} not available") + return + + self.curr_freq = freq + self.freqs[freq].append(self) + # FIXME: do we need locking here? + print("FREQ", self.curr_freq, freq, self.freqs) + await self._send(type="join", freq=self.curr_freq, self_id=self.id, + other_players=[c.id for c in self._others(freq)]) + await self._send_to_group(self._others(freq), type="player-joined", player=self.id) + + async def _handle_morse_state(self, data): + if not self.curr_freq: + await self._send_error("No frequency selected") + return + + if "state" not in data or not isinstance(data["state"], bool): + await self._send_error("No state key with type bool in data") + return + + await self._send_to_group(self._others(self.curr_freq), + type="morse-state", state=data["state"], from_player=self.id) + + async def _leave_room(self): + if not self.curr_freq: + self._send_error("You are not on a frequency") + return + + await self._send_to_group(self._others(self.curr_freq), + type="player-left", player=self.id) + try: + self.freqs[self.curr_freq].remove(self) + except ValueError: + print(f"Warning: Player {self.id} was not in freq {self.curr_freq}") + if not self.freqs[self.curr_freq]: + del self.freqs[self.curr_freq] + self.curr_freq = None + + try: + await self._send(type="leave") + except Exception: + pass + + def _others(self, freq): + return [c for c in self.freqs[freq] if c.id != self.id] + + async def _send(self, ignore_exceptions=False, **kwargs): + data = json.dumps(kwargs).encode() + print(f" --> sending out to {self.client}: {data}") + try: + await self.websocket.send(json.dumps(kwargs).encode() + b"\n") + except Exception as e: + print(f"Error sending data to {self.client}: {e}") + if not ignore_exceptions: + raise + + async def _send_to_group(self, group, **kwargs): + async with asyncio.TaskGroup() as tg: + for member in group: + tg.create_task(member._send(ignore_exceptions=True, **kwargs)) + + async def _send_error(self, msg: str): + await self._send(type="error", message=msg) + + +async def new_client(websocket): + try: + client = Client(websocket) + await client.handle() + finally: + pass + # async for message in websocket: + # await websocket.send(message) + + +async def main(): + HOST, PORT = "0.0.0.0", 3784 + async with serve(new_client, HOST, PORT) as server: + await server.serve_forever() + + +if __name__ == "__main__": + print("Starting server") + asyncio.run(main()) diff --git a/signalsrv/signalsrv2.py b/signalsrv/signalsrv2.py new file mode 100644 index 0000000..aad0300 --- /dev/null +++ b/signalsrv/signalsrv2.py @@ -0,0 +1,81 @@ +import json +import socketserver + +__VERSION__ = "0.0.1" + +# https://websockets.readthedocs.io/en/stable/reference/asyncio/server.html + + +class LobbyMgr: + __instance = None + + def __new__(cls): + if cls.__instance is None: + cls.__instance = LobbyMgr() + + return cls.__instance + + +class LobbyHandler(socketserver.StreamRequestHandler): + def handle(self): + print(f" >>> New client {self.client} connected") + exc = None + try: + self._handle_client() + except Exception as e: + exc = e + finally: + print(f" <<< Client {self.client} disconnected: {exc}") + + @property + def client(self): + return f"{':'.join(map(str, self.client_address))}" + + def _handle_client(self): + self._send(type="hello", name="LobbySrv 3000", version=__VERSION__) + while True: + data = self.rfile.readline(10000).rstrip() + print(f" <-- client {self.client} sent {repr(data)}") + + if not data.strip(): + continue + try: + data = json.loads(data) + except json.JSONDecodeError: + self._send_error("Could not decode message, invalid json") + continue + + if not isinstance(data, dict) or "cmd" not in data: + self._send_error("Invalid format in json") + continue + + print(f"{self.client_address[0]} wrote:", data) + + match data["cmd"]: + case "quit": + break + case _: + self._send_error("Unknown command") + + def _send(self, **kwargs): + data = json.dumps(kwargs).encode() + print(f" --> sending out to {self.client}: {data}") + self.wfile.write(json.dumps(kwargs).encode() + b"\n") + + def _send_error(self, msg: str): + self._send(type="error", message=msg) + + +class LobbyServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +def main(): + HOST, PORT = "localhost", 3784 + + with LobbyServer((HOST, PORT), LobbyHandler) as server: + server.serve_forever() + + +if __name__ == "__main__": + main()