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/scenes/MultiMorseBanner.tscn b/scenes/MultiMorseBanner.tscn new file mode 100644 index 0000000..a9a5267 --- /dev/null +++ b/scenes/MultiMorseBanner.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3 uid="uid://ug3u6jf36dst"] + +[ext_resource type="Script" uid="uid://j1oei8suq5sj" path="res://scenes/morse_banner.gd" id="1_a1ve8"] + +[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") diff --git a/scenes/MultiplayerConnect.tscn b/scenes/MultiplayerConnect.tscn new file mode 100644 index 0000000..341033f --- /dev/null +++ b/scenes/MultiplayerConnect.tscn @@ -0,0 +1,89 @@ +[gd_scene load_steps=2 format=3 uid="uid://dnxcrx04kl3xy"] + +[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 +script = ExtResource("1_uyd8l") + +[node name="ConnectView" type="Control" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="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="ConnectView/VBoxContainer"] +layout_mode = 2 + +[node name="FrequencyCreator" type="TextEdit" parent="ConnectView/VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "430.200" + +[node name="Label" type="Label" parent="ConnectView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "MHz" + +[node name="CreateButton" type="Button" parent="ConnectView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +size_flags_stretch_ratio = 2.41 +text = "Create Frequency" + +[node name="RefreshButton" type="Button" parent="ConnectView/VBoxContainer"] +layout_mode = 2 +text = "Refresh" + +[node name="FreqList" type="ItemList" parent="ConnectView/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MorseView" type="Control" parent="."] +unique_name_in_owner = true +visible = false +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="VBoxContainer" type="VBoxContainer" parent="MorseView"] +layout_mode = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="HBoxContainer" type="HBoxContainer" parent="MorseView/VBoxContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="MorseView/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Freq:" + +[node name="FreqLabel" type="Label" parent="MorseView/VBoxContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="MorseButton" type="Button" parent="MorseView/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +size_flags_stretch_ratio = 2.0 +text = "MORSE" + +[connection signal="pressed" from="ConnectView/VBoxContainer/HBoxContainer/CreateButton" to="." method="_on_create_button_pressed"] +[connection signal="pressed" from="ConnectView/VBoxContainer/RefreshButton" to="." method="_on_refresh_button_pressed"] +[connection signal="item_clicked" from="ConnectView/VBoxContainer/FreqList" to="." method="_on_freq_list_join"] diff --git a/scenes/main.gd b/scenes/main.gd index b80edb7..881ac4b 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() @@ -24,6 +25,9 @@ func _ready(): $Player.play() playback = $Player.get_stream_playback() fill_buffer() + + if multiplayer_enabled: + $VBoxContainer/MultiplayerButton.visible = true OS.open_midi_inputs() print(OS.get_connected_midi_inputs()) @@ -144,3 +148,7 @@ 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") diff --git a/scenes/main.tscn b/scenes/main.tscn index 61f554f..e3f2099 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -63,6 +63,11 @@ size_flags_vertical = 3 size_flags_stretch_ratio = 0.5 text = "Reset" +[node name="MultiplayerButton" type="Button" parent="VBoxContainer"] +visible = false +layout_mode = 2 +text = "Connect to Frequency" + [node name="Player" type="AudioStreamPlayer" parent="."] stream = SubResource("AudioStreamGenerator_kvn5v") volume_db = -80.0 @@ -74,3 +79,4 @@ script = ExtResource("3_sugp2") [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/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..974bf3f --- /dev/null +++ b/scenes/multi_morse_banner.gd @@ -0,0 +1,48 @@ +@tool +extends Control + +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 morse_step_perc := 0.45 + + +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 := MorseState.curr_state + var first_time := Time.get_ticks_msec() - MorseState.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() - MorseState.start_time) * 1000.0 + + for n in [-1] + range(MorseState.states.size() - 1, -1, -1): + var duration := first_time if n == -1 else MorseState.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() 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..da121a1 --- /dev/null +++ b/scenes/multiplayer_connect.gd @@ -0,0 +1,69 @@ +extends Control + +var server := "localhost" +var port := "3784" +var ws := WebSocketPeer.new() + +var mb_scene := preload("res://scenes/MultiMorseBanner.tscn") +var available_freqs = [] + +func _ready() -> void: + print("ws://%s:%s" % [server, port]) + ws.connect_to_url("ws://%s:%s" % [server, port]) + +func refresh_list() -> void: + pass + + +func _process(_delta: float) -> void: + ws.poll() + var state := ws.get_ready_state() + # print("moin ", state) + while state == WebSocketPeer.STATE_OPEN and ws.get_available_packet_count(): + _handle_packet() + +func _handle_packet() -> void: + var data := ws.get_packet().get_string_from_utf8() + print(data) + var parsed: Dictionary = JSON.parse_string(data) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type"): + return + print("parsed ", parsed) + + match parsed["type"]: + "join": + _join_freq(parsed["freq"]) + + "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) + +func _join_freq(freq: String): + %FreqLabel.text = "%s MHz" % freq + %ConnectView.hide() + %MorseView.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") + 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..0e2bbec --- /dev/null +++ b/signalsrv/signalsrv.py @@ -0,0 +1,146 @@ +import asyncio +import json +import re + +from websockets.asyncio.server import serve + +__VERSION__ = "0.0.1" + + +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} disconnected: {exc}") + + @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: + self._send_error("Invalid format in json") + continue + + print(f"{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 "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": + # FIXME: send to all other clients + pass + 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: + 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, others=[]) + + 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.freqs[freq].append(self) + # FIXME: do we need locking here? + await self._send(type="join", freq=self.curr_freq, players=[c.id for c in self._others(freq)]) + for other in self._others(freq): + await self._send(type="player-joined", player=self.id) + + def _others(self, freq): + return [c for c in self.freqs[freq] if c != self.websocket] + + async def _send(self, **kwargs): + data = json.dumps(kwargs).encode() + print(f" --> sending out to {self.client}: {data}") + await self.websocket.send(json.dumps(kwargs).encode() + b"\n") + + 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 = "localhost", 3784 + async with serve(new_client, HOST, PORT) as server: + await server.serve_forever() + + +if __name__ == "__main__": + 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()