diff --git a/API_WEBSOCKET.md b/API_WEBSOCKET.md new file mode 100644 index 0000000..c7613e9 --- /dev/null +++ b/API_WEBSOCKET.md @@ -0,0 +1,348 @@ +# WebSocket API + +`go run . -port /dev/ttyUSB0 serve` exposes two WebSocket endpoints. They share the same UART link but serve different purposes. + +| URL | Port (default) | Role | +|-----|----------------|------| +| `ws://localhost:8080/ws` | Dashboard (`-addr`) | Server → client only: full `DashboardState` JSON (~2 s poll + live-stream accel/tap) | +| `ws://localhost:8081/ws` | External API (`-api-addr`) | Request/response commands + optional **accel** / **tap** push streams | + +Disable the external server with `-api-addr ""`. + +CLI overview and UART commands: [`../README.md`](../README.md). HTTP endpoints: [`API_REST.md`](API_REST.md). + +--- + +## External API (`:8081/ws`) + +### Connection flow + +1. Connect → server sends **`hello`** (receive off; lists available commands). +2. Send JSON commands → server replies with a matching `*_status` or `client_list` message (one reply per command). +3. After `set_stream` / `set_tap_stream` with `enable: true`, the server may send **`accel`** and/or **`tap`** messages **without** a prior command (push stream). + +Commands and stream pushes are multiplexed on one socket. While streaming, always parse `type` and branch (status vs sample vs error). + +### Two layers (accel and tap) + +| Layer | Commands | Effect | +|-------|----------|--------| +| **Firmware (ESP-NOW)** | `set_accel_stream`, `set_tap_notify` | Per `client_id`: slave sends accel or tap kinds to the master | +| **This connection (host)** | `set_stream`, `set_tap_stream` | Whether **you** receive push JSON and at what rate (`interval_ms`, 1 ms … 10 s) | + +- **Accel UART polling** runs only if at least one connection has `receive_accel: true` **and** at least one slave streams accel (`set_accel_stream` or dashboard). +- **Tap UART polling** runs only if at least one connection has `receive_tap: true` (`set_tap_stream`). `set_tap_notify` alone does **not** poll. + +Typical sequence: + +1. `list_clients` → slave IDs +2. Per slave: `set_accel_stream` / `set_tap_notify` as needed +3. `set_stream` and/or `set_tap_stream` with `"enable": true` +4. Read push messages in a loop + +There is **no per-slave filter** on push messages: each `accel` contains all cached slaves; each `tap` contains all visible events. Filter by `client_id` in your app. + +--- + +## Push stream messages + +These are the samples you get after enabling receive. Interval is per WebSocket connection; the server UART poll uses the **minimum** `interval_ms` among all subscribers that want accel or tap. + +### `accel` (type `"accel"`) + +Sent only when `set_stream` has `enable: true`, a slave streams accel, and the poll tick fires for this connection. + +**Success** — all slaves with a cache entry on the master (not only those with `valid: true`): + +```json +{ + "type": "accel", + "t": 1716900123456789012, + "success": true, + "clients": [ + { + "client_id": 16, + "valid": true, + "x": 12, + "y": -34, + "z": 16384, + "age_ms": 8 + }, + { + "client_id": 42, + "valid": false + } + ] +} +``` + +| Field | Meaning | +|-------|---------| +| `t` | Unix timestamp in **nanoseconds** when the host read the cache | +| `success` | `true` if `CACHE_STATUS` succeeded | +| `clients[]` | One entry per slave slot in the master cache | +| `client_id` | ESP-NOW client id (same as `list_clients`) | +| `valid` | `false` if no sample yet or stale; omit `x`/`y`/`z` when false | +| `x`, `y`, `z` | Raw accelerometer LSB (BMA456, ±2 g scale on the pod) | +| `age_ms` | Milliseconds since the master received this sample | + +**Failure** (e.g. UART busy): + +```json +{ + "type": "accel", + "t": 1716900123456789012, + "success": false, + "error": "uart busy" +} +``` + +No `clients` array on failure. + +### `tap` (type `"tap"`) + +Sent only when `set_tap_stream` has `enable: true` and there is at least one event to show. + +Events appear when the master cache reports a new tap. Each event stays in push payloads for **`tap_display_min_ms`** (2000 ms, also in `hello`) after the API first saw it, even if the hardware age grows. + +**Success**: + +```json +{ + "type": "tap", + "t": 1716900123456789012, + "success": true, + "events": [ + { + "client_id": 16, + "valid": true, + "kind": "single", + "age_ms": 3, + "shown_at_ms": 1717000000123 + } + ] +} +``` + +| Field | Meaning | +|-------|---------| +| `t` | Unix timestamp in **nanoseconds** (poll time) | +| `events[]` | All taps currently “on screen” for the API | +| `client_id` | Slave that tapped | +| `kind` | `"single"`, `"double"`, or `"triple"` | +| `age_ms` | Age in the master cache when read | +| `shown_at_ms` | Unix **milliseconds** when this host first included the event | + +If no events are visible, **no** `tap` message is sent on that tick (unlike accel, which can send empty `clients` only on success with cache data). + +**Failure**: + +```json +{ + "type": "tap", + "t": 1716900123456789012, + "success": false, + "error": "uart busy" +} +``` + +--- + +## Commands (request → response) + +Send one JSON object per message. Field `type` selects the command. + +### `hello` (server → client, on connect) + +```json +{ + "type": "hello", + "serial_port": "/dev/ttyUSB0", + "interval_ms": 16, + "tap_display_min_ms": 2000, + "note": "set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push", + "commands": [ + "list_clients", + "set_stream", "get_stream", + "set_accel_stream", "get_accel_stream", + "set_tap_stream", "get_tap_stream", + "set_tap_notify", "get_tap_notify", + "set_led_ring", "get_battery" + ] +} +``` + +### `list_clients` + +Request: `{"type":"list_clients"}` + +Response `client_list`: + +```json +{ + "type": "client_list", + "success": true, + "clients": [ + { + "id": 16, + "mac": "aa:bb:cc:dd:ee:10", + "version": 1, + "available": true, + "used": true, + "last_ping": 1234, + "last_success_ping": 1200, + "accel_stream": false, + "tap_notify_single": false, + "tap_notify_double": false, + "tap_notify_triple": false + } + ] +} +``` + +### `set_stream` / `get_stream` (receive accel on this connection) + +```json +{"type":"set_stream","enable":true,"interval_ms":32} +{"type":"get_stream"} +``` + +Response `stream_status`: + +```json +{"type":"stream_status","receive_accel":true,"interval_ms":32,"success":true} +``` + +### `set_accel_stream` / `get_accel_stream` (firmware, per slave) + +`client_id` required (> 0). + +```json +{"type":"set_accel_stream","client_id":16,"enable":true} +{"type":"get_accel_stream","client_id":16} +``` + +Response `accel_stream_status`: + +```json +{"type":"accel_stream_status","client_id":16,"enabled":true,"success":true} +``` + +### `set_tap_stream` / `get_tap_stream` (receive tap on this connection) + +```json +{"type":"set_tap_stream","enable":true,"interval_ms":16} +{"type":"get_tap_stream"} +``` + +Response `tap_stream_status`: + +```json +{"type":"tap_stream_status","receive_tap":true,"interval_ms":16,"success":true} +``` + +### `set_tap_notify` / `get_tap_notify` (firmware, per slave) + +Per client: `single`, `double_tap`, `triple` required on set. + +```json +{"type":"set_tap_notify","client_id":16,"single":true,"double_tap":false,"triple":false} +``` + +Broadcast: `"all_clients": true` with the three booleans. + +Response `tap_notify_status`: + +```json +{ + "type": "tap_notify_status", + "client_id": 16, + "success": true, + "single": true, + "double_tap": false, + "triple": false +} +``` + +### `set_led_ring` + +Same JSON body as [`POST /api/led-ring`](API_REST.md#led-ring) with `"type":"set_led_ring"` added. Reply: `led_ring_status`. + +### `get_battery` + +Body: `{"type":"get_battery","all_clients":true}` or `"client_id":16`. Default if omitted: all clients. + +Reply: `battery_status` with `samples[]` (see REST doc). + +--- + +## Examples + +### Accel stream + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://127.0.0.1:8081/ws") as ws: + print(await ws.recv()) # hello + await ws.send(json.dumps({"type": "list_clients"})) + clients = json.loads(await ws.recv())["clients"] + for c in clients: + if not c.get("available"): + continue + await ws.send(json.dumps({ + "type": "set_accel_stream", "client_id": c["id"], "enable": True + })) + await ws.recv() # accel_stream_status + await ws.send(json.dumps({"type": "set_stream", "enable": True, "interval_ms": 16})) + await ws.recv() # stream_status + while True: + msg = json.loads(await ws.recv()) + if msg.get("type") != "accel": + continue + if not msg.get("success"): + print("error:", msg.get("error")) + continue + for c in msg.get("clients", []): + if c.get("valid"): + print(c["client_id"], c["x"], c["y"], c["z"], "age", c.get("age_ms")) + +asyncio.run(main()) +``` + +### Tap stream + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://127.0.0.1:8081/ws") as ws: + print(await ws.recv()) # hello + await ws.send(json.dumps({ + "type": "set_tap_notify", "client_id": 16, + "single": True, "double_tap": False, "triple": False + })) + await ws.recv() # tap_notify_status + await ws.send(json.dumps({"type": "set_tap_stream", "enable": True, "interval_ms": 16})) + await ws.recv() # tap_stream_status + while True: + msg = json.loads(await ws.recv()) + if msg.get("type") == "tap" and msg.get("events"): + for e in msg["events"]: + print(e["client_id"], e["kind"], "age", e.get("age_ms")) + +asyncio.run(main()) +``` + +--- + +## Dashboard WebSocket (`:8080/ws`) + +Read-only from the browser’s perspective: the server pushes JSON whenever state changes. Clients do not send commands on this socket (messages are ignored). + +Payload shape: `DashboardState` — `updated_at`, `serial_port`, `uart_connected`, `live_stream`, `master`, `clients[]` (id, mac, accel, tap notify flags, battery, etc.). Accel/tap samples appear here when **Live stream** is enabled in the UI (`PUT /api/live-stream`). + +During OTA, additional messages with `"type":"ota_progress"` may appear on the same socket. + +Configure slaves via REST on `:8080` ([`API_REST.md`](API_REST.md)), not via this WebSocket. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..933dc9e --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +get_api_desc: + cp ../dev/esp/powerpod/goTool/docs/API_WEBSOCKET.md ./API_WEBSOCKET.md \ No newline at end of file diff --git a/project.godot b/project.godot index c0108d6..66ae2ed 100644 --- a/project.godot +++ b/project.godot @@ -11,12 +11,14 @@ config_version=5 [application] config/name="alox_test_game" -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" [display] +window/size/viewport_width=1920 +window/size/viewport_height=1080 window/stretch/mode="canvas_items" [physics] diff --git a/scenes/color_game.tscn b/scenes/color_game.tscn new file mode 100644 index 0000000..655f433 --- /dev/null +++ b/scenes/color_game.tscn @@ -0,0 +1,88 @@ +[gd_scene format=3 uid="uid://bcolorgame001"] + +[ext_resource type="Script" path="res://scripts/color_game.gd" id="1_color"] + +[node name="ColorGame" 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_color") + +[node name="ColorRect" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.5, 0.3, 0.7, 1) + +[node name="UiLayer" type="CanvasLayer" parent="."] + +[node name="BackButton" type="Button" parent="UiLayer"] +offset_left = 16.0 +offset_top = 12.0 +offset_right = 120.0 +offset_bottom = 44.0 +text = "Menü" + +[node name="Title" type="Label" parent="UiLayer"] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -200.0 +offset_top = 60.0 +offset_right = 200.0 +offset_bottom = 100.0 +grow_horizontal = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 28 +horizontal_alignment = 1 +text = "Color Game" + +[node name="TargetRect" type="ColorRect" parent="UiLayer"] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -60.0 +offset_top = 120.0 +offset_right = 60.0 +offset_bottom = 240.0 +grow_horizontal = 2 +color = Color(0.85, 0.2, 0.3, 1) + +[node name="TargetLabel" type="Label" parent="UiLayer"] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -100.0 +offset_top = 250.0 +offset_right = 100.0 +offset_bottom = 280.0 +grow_horizontal = 2 +theme_override_colors/font_color = Color(1, 1, 1, 0.9) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 +text = "Zielfarbe" + +[node name="HintLabel" type="Label" parent="UiLayer"] +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -320.0 +offset_top = -80.0 +offset_right = 320.0 +offset_bottom = -40.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_override_colors/font_color = Color(1, 1, 1, 0.85) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 +autowrap_mode = 3 +text = "Neige das Gerät, um die Farbe anzupassen" diff --git a/scenes/config.tscn b/scenes/config.tscn new file mode 100644 index 0000000..105a519 --- /dev/null +++ b/scenes/config.tscn @@ -0,0 +1,82 @@ +[gd_scene format=3 uid="uid://rbiqgb2rqnpc"] + +[ext_resource type="Script" path="res://scripts/config.gd" id="1_config"] + +[node name="Config" 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_config") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.08, 0.09, 0.14, 1) + +[node name="UiLayer" type="CanvasLayer" parent="."] + +[node name="BackButton" type="Button" parent="UiLayer"] +offset_left = 16.0 +offset_top = 12.0 +offset_right = 120.0 +offset_bottom = 44.0 +text = "Menü" + +[node name="Title" type="Label" parent="UiLayer"] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -200.0 +offset_top = 80.0 +offset_right = 200.0 +offset_bottom = 120.0 +grow_horizontal = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 28 +horizontal_alignment = 1 +text = "Config" + +[node name="Panel" type="VBoxContainer" parent="UiLayer"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -280.0 +offset_top = -80.0 +offset_right = 280.0 +offset_bottom = 80.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 20 +alignment = 1 + +[node name="WSUrlLabel" type="Label" parent="UiLayer/Panel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +text = "WebSocket: ws://localhost:9090/ws" + +[node name="ViewportLabel" type="Label" parent="UiLayer/Panel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +text = "Viewport: 1152 × 648" + +[node name="HintLabel" type="Label" parent="UiLayer/Panel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.65, 0.75, 1) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 +autowrap_mode = 3 +text = "Projekteinstellungen (weitere Optionen folgen)" diff --git a/scenes/demo.tscn b/scenes/demo.tscn new file mode 100644 index 0000000..12d3a04 --- /dev/null +++ b/scenes/demo.tscn @@ -0,0 +1,166 @@ +[gd_scene format=3 uid="uid://clxuqui2bti65"] + +[ext_resource type="Script" path="res://scripts/demo.gd" id="1_demo"] + +[node name="Demo" 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_demo") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.08, 0.09, 0.14, 1) + +[node name="UiLayer" type="CanvasLayer" parent="."] + +[node name="TopBar" type="HBoxContainer" parent="UiLayer"] +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 16.0 +offset_top = 12.0 +offset_right = -16.0 +offset_bottom = 52.0 +grow_horizontal = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="UiLayer/TopBar"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Menü" + +[node name="Title" type="Label" parent="UiLayer/TopBar"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 22 +text = "API Demo" + +[node name="UrlLabel" type="Label" parent="UiLayer/TopBar"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.55, 0.6, 0.7, 1) +theme_override_font_sizes/font_size = 13 +text = "ws://localhost:8081/ws" + +[node name="StatusLabel" type="Label" parent="UiLayer/TopBar"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 13 +text = "Verbinde…" + +[node name="MainSplit" type="HSplitContainer" parent="UiLayer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 16.0 +offset_top = 60.0 +offset_right = -16.0 +offset_bottom = -12.0 +grow_horizontal = 2 +grow_vertical = 2 +split_offset = 520 + +[node name="LeftPanel" type="VBoxContainer" parent="UiLayer/MainSplit"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 12 + +[node name="Toolbar" type="HBoxContainer" parent="UiLayer/MainSplit/LeftPanel"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ListClientsBtn" type="Button" parent="UiLayer/MainSplit/LeftPanel/Toolbar"] +custom_minimum_size = Vector2(140, 40) +layout_mode = 2 +text = "Clients laden" + +[node name="IntervalLabel" type="Label" parent="UiLayer/MainSplit/LeftPanel/Toolbar"] +layout_mode = 2 +text = "interval_ms:" + +[node name="IntervalSpin" type="SpinBox" parent="UiLayer/MainSplit/LeftPanel/Toolbar"] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +min_value = 1.0 +max_value = 10000.0 +value = 16.0 + +[node name="ClientsScroll" type="ScrollContainer" parent="UiLayer/MainSplit/LeftPanel"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="ClientsList" type="VBoxContainer" parent="UiLayer/MainSplit/LeftPanel/ClientsScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="EmptyHint" type="Label" parent="UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.55, 0.6, 0.7, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 3 +text = "„Clients laden“ drücken, pro Client Accel/Tap konfigurieren, dann Stream starten." + +[node name="StreamBar" type="HBoxContainer" parent="UiLayer/MainSplit/LeftPanel"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="StartStreamBtn" type="Button" parent="UiLayer/MainSplit/LeftPanel/StreamBar"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 16 +disabled = true +text = "Stream starten" + +[node name="StopStreamBtn" type="Button" parent="UiLayer/MainSplit/LeftPanel/StreamBar"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 16 +disabled = true +text = "Stream stoppen" + +[node name="LogPanel" type="VBoxContainer" parent="UiLayer/MainSplit"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 8 + +[node name="LogHeader" type="Label" parent="UiLayer/MainSplit/LogPanel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.55, 1) +theme_override_font_sizes/font_size = 14 +text = "Log" + +[node name="LogOutput" type="TextEdit" parent="UiLayer/MainSplit/LogPanel"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_font_sizes/font_size = 12 +editable = false +wrap_mode = 1 + +[node name="StreamHeader" type="Label" parent="UiLayer/MainSplit/LogPanel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.55, 1) +theme_override_font_sizes/font_size = 14 +text = "Live-Daten (alle Clients)" + +[node name="StreamOutput" type="Label" parent="UiLayer/MainSplit/LogPanel"] +custom_minimum_size = Vector2(0, 160) +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.85, 0.95, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 3 +text = "—" diff --git a/scenes/main.tscn b/scenes/main.tscn index 0e4b9bf..c56d35f 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -1,16 +1,16 @@ -[gd_scene load_steps=4 format=3 uid="uid://bmain2dscene01"] +[gd_scene format=3 uid="uid://cjs5bms5o6763"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] -[ext_resource type="Script" path="res://scripts/ball.gd" id="2_ball"] -[ext_resource type="Script" path="res://scripts/calibration_overlay.gd" id="3_calib"] +[ext_resource type="Script" uid="uid://bnvlevjo1fp48" path="res://scripts/ball.gd" id="2_ball"] +[ext_resource type="Script" uid="uid://crbm1833myr5l" path="res://scripts/calibration_overlay.gd" id="3_calib"] -[node name="Main" type="Node2D"] +[node name="Main" type="Node2D" unique_id=474005048] script = ExtResource("1_main") -[node name="BgLayer" type="CanvasLayer" parent="."] +[node name="BgLayer" type="CanvasLayer" parent="." unique_id=895115848] layer = -10 -[node name="Background" type="ColorRect" parent="BgLayer"] +[node name="Background" type="ColorRect" parent="BgLayer" unique_id=808294717] anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 @@ -19,18 +19,18 @@ grow_vertical = 2 mouse_filter = 2 color = Color(0.08, 0.09, 0.14, 1) -[node name="Platform" type="Polygon2D" parent="."] +[node name="Platform" type="Polygon2D" parent="." unique_id=1966405851] +position = Vector2(576, 600) color = Color(0.25, 0.72, 0.95, 1) polygon = PackedVector2Array(-80, -12, 80, -12, 80, 12, -80, -12) -position = Vector2(576, 600) -[node name="Ball" type="Node2D" parent="."] +[node name="Ball" type="Node2D" parent="." unique_id=1211114968] position = Vector2(576, 320) script = ExtResource("2_ball") -[node name="UiLayer" type="CanvasLayer" parent="."] +[node name="UiLayer" type="CanvasLayer" parent="." unique_id=635198279] -[node name="StatusLabel" type="Label" parent="UiLayer"] +[node name="StatusLabel" type="Label" parent="UiLayer" unique_id=601884942] offset_left = 16.0 offset_top = 12.0 offset_right = 560.0 @@ -39,47 +39,44 @@ theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) theme_override_font_sizes/font_size = 14 text = "Starting…" -[node name="ThresholdPanel" type="VBoxContainer" parent="UiLayer"] +[node name="ThresholdPanel" type="VBoxContainer" parent="UiLayer" unique_id=1490838898] offset_left = 16.0 offset_top = 48.0 offset_right = 420.0 offset_bottom = 148.0 -[node name="ThresholdCaption" type="Label" parent="UiLayer/ThresholdPanel"] +[node name="ThresholdCaption" type="Label" parent="UiLayer/ThresholdPanel" unique_id=1245613915] layout_mode = 2 theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) theme_override_font_sizes/font_size = 14 text = "Schwellwert: 3000" -[node name="ThresholdSlider" type="HSlider" parent="UiLayer/ThresholdPanel"] +[node name="ThresholdSlider" type="HSlider" parent="UiLayer/ThresholdPanel" unique_id=1874311831] custom_minimum_size = Vector2(380, 0) layout_mode = 2 size_flags_horizontal = 3 -min_value = 0.0 max_value = 15000.0 step = 50.0 value = 3000.0 tick_count = 16 ticks_on_borders = true -[node name="SensitivityCaption" type="Label" parent="UiLayer/ThresholdPanel"] +[node name="SensitivityCaption" type="Label" parent="UiLayer/ThresholdPanel" unique_id=1083787312] layout_mode = 2 theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) theme_override_font_sizes/font_size = 14 text = "Bewegungsstärke: 6 %" -[node name="SensitivitySlider" type="HSlider" parent="UiLayer/ThresholdPanel"] +[node name="SensitivitySlider" type="HSlider" parent="UiLayer/ThresholdPanel" unique_id=1497316063] custom_minimum_size = Vector2(380, 0) layout_mode = 2 size_flags_horizontal = 3 min_value = 1.0 -max_value = 100.0 -step = 1.0 value = 6.0 tick_count = 11 ticks_on_borders = true -[node name="AccelLabel" type="Label" parent="UiLayer"] +[node name="AccelLabel" type="Label" parent="UiLayer" unique_id=1538579724] anchors_preset = 3 anchor_left = 1.0 anchor_top = 1.0 @@ -93,10 +90,10 @@ grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.9, 0.92, 0.55, 1) theme_override_font_sizes/font_size = 16 -horizontal_alignment = 2 text = "x: — y: — z: —" +horizontal_alignment = 2 -[node name="RecalibButton" type="Button" parent="UiLayer"] +[node name="RecalibButton" type="Button" parent="UiLayer" unique_id=1145373245] visible = false anchors_preset = 1 anchor_left = 1.0 @@ -108,17 +105,16 @@ offset_bottom = 44.0 grow_horizontal = 0 text = "Kalibrierung neu starten" -[node name="CalibrationOverlay" type="Control" parent="UiLayer"] +[node name="CalibrationOverlay" type="Control" parent="UiLayer" unique_id=2105464363] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -mouse_filter = 0 script = ExtResource("3_calib") -[node name="HintLabel" type="Label" parent="UiLayer/CalibrationOverlay"] +[node name="HintLabel" type="Label" parent="UiLayer/CalibrationOverlay" unique_id=2126154488] layout_mode = 1 anchors_preset = 5 anchor_left = 0.5 @@ -130,11 +126,11 @@ offset_bottom = 140.0 grow_horizontal = 2 theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) theme_override_font_sizes/font_size = 20 +text = "Kalibrierung" horizontal_alignment = 1 autowrap_mode = 3 -text = "Kalibrierung" -[node name="StartButton" type="Button" parent="UiLayer/CalibrationOverlay"] +[node name="StartButton" type="Button" parent="UiLayer/CalibrationOverlay" unique_id=468536630] layout_mode = 1 anchors_preset = 7 anchor_left = 0.5 @@ -146,4 +142,5 @@ offset_top = -120.0 offset_right = 140.0 offset_bottom = -72.0 grow_horizontal = 2 +grow_vertical = 0 text = "Kalibrierung starten" diff --git a/scenes/menu.tscn b/scenes/menu.tscn new file mode 100644 index 0000000..2d03d82 --- /dev/null +++ b/scenes/menu.tscn @@ -0,0 +1,91 @@ +[gd_scene format=3 uid="uid://cmainmenu001"] + +[ext_resource type="Script" path="res://scripts/menu.gd" id="1_menu"] + +[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 +script = ExtResource("1_menu") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.08, 0.09, 0.14, 1) + +[node name="CenterBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -180.0 +offset_top = -240.0 +offset_right = 180.0 +offset_bottom = 240.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="Title" type="Label" parent="CenterBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "alox test game" + +[node name="Subtitle" type="Label" parent="CenterBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.65, 0.75, 1) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 +text = "Hauptmenü" + +[node name="Spacer" type="Control" parent="CenterBox"] +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 + +[node name="DemoButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Demo" + +[node name="ConfigButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Config" + +[node name="PongButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Pong" + +[node name="ColorGameButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Color Game" + +[node name="ExitButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Exit" diff --git a/scenes/pong.tscn b/scenes/pong.tscn new file mode 100644 index 0000000..15105e4 --- /dev/null +++ b/scenes/pong.tscn @@ -0,0 +1,156 @@ +[gd_scene format=3 uid="uid://dbq0yorbl7vwu"] + +[ext_resource type="Script" path="res://scripts/pong.gd" id="1_pong"] +[ext_resource type="Script" path="res://scripts/ball.gd" id="2_ball"] +[ext_resource type="Script" path="res://scripts/calibration_overlay.gd" id="3_calib"] + +[node name="Pong" type="Node2D"] +script = ExtResource("1_pong") + +[node name="BgLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="Background" type="ColorRect" parent="BgLayer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.08, 0.09, 0.14, 1) + +[node name="Platform" type="Polygon2D" parent="."] +color = Color(0.25, 0.72, 0.95, 1) +polygon = PackedVector2Array(-80, -12, 80, -12, 80, 12, -80, -12) +position = Vector2(576, 600) + +[node name="Ball" type="Node2D" parent="."] +position = Vector2(576, 320) +script = ExtResource("2_ball") + +[node name="UiLayer" type="CanvasLayer" parent="."] + +[node name="MenuButton" type="Button" parent="UiLayer"] +offset_left = 16.0 +offset_top = 12.0 +offset_right = 120.0 +offset_bottom = 44.0 +text = "Menü" + +[node name="StatusLabel" type="Label" parent="UiLayer"] +offset_left = 130.0 +offset_top = 12.0 +offset_right = 560.0 +offset_bottom = 40.0 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 14 +text = "Starting…" + +[node name="ThresholdPanel" type="VBoxContainer" parent="UiLayer"] +offset_left = 16.0 +offset_top = 48.0 +offset_right = 420.0 +offset_bottom = 148.0 + +[node name="ThresholdCaption" type="Label" parent="UiLayer/ThresholdPanel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 14 +text = "Schwellwert: 3000" + +[node name="ThresholdSlider" type="HSlider" parent="UiLayer/ThresholdPanel"] +custom_minimum_size = Vector2(380, 0) +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.0 +max_value = 15000.0 +step = 50.0 +value = 3000.0 +tick_count = 16 +ticks_on_borders = true + +[node name="SensitivityCaption" type="Label" parent="UiLayer/ThresholdPanel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 14 +text = "Bewegungsstärke: 6 %" + +[node name="SensitivitySlider" type="HSlider" parent="UiLayer/ThresholdPanel"] +custom_minimum_size = Vector2(380, 0) +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 1.0 +max_value = 100.0 +step = 1.0 +value = 6.0 +tick_count = 11 +ticks_on_borders = true + +[node name="AccelLabel" type="Label" parent="UiLayer"] +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -320.0 +offset_top = -72.0 +offset_right = -16.0 +offset_bottom = -20.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.9, 0.92, 0.55, 1) +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 2 +text = "x: — y: — z: —" + +[node name="RecalibButton" type="Button" parent="UiLayer"] +visible = false +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -220.0 +offset_top = 12.0 +offset_right = -16.0 +offset_bottom = 44.0 +grow_horizontal = 0 +text = "Kalibrierung neu starten" + +[node name="CalibrationOverlay" type="Control" parent="UiLayer"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +script = ExtResource("3_calib") + +[node name="HintLabel" type="Label" parent="UiLayer/CalibrationOverlay"] +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -360.0 +offset_top = 80.0 +offset_right = 360.0 +offset_bottom = 140.0 +grow_horizontal = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 20 +horizontal_alignment = 1 +autowrap_mode = 3 +text = "Kalibrierung" + +[node name="StartButton" type="Button" parent="UiLayer/CalibrationOverlay"] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -140.0 +offset_top = -120.0 +offset_right = 140.0 +offset_bottom = -72.0 +grow_horizontal = 2 +text = "Kalibrierung starten" diff --git a/scripts/color_game.gd b/scripts/color_game.gd new file mode 100644 index 0000000..83581f8 --- /dev/null +++ b/scripts/color_game.gd @@ -0,0 +1,66 @@ +extends Control + +const WS_URL := "ws://localhost:9090/ws" +const MENU_SCENE := "res://scenes/menu.tscn" + +@onready var color_rect: ColorRect = $ColorRect +@onready var target_rect: ColorRect = $UiLayer/TargetRect +@onready var hint_label: Label = $UiLayer/HintLabel +@onready var back_button: Button = $UiLayer/BackButton + +var _socket := WebSocketPeer.new() +var _accel := Vector3i.ZERO +var _target_hue := 0.0 + + +func _ready() -> void: + back_button.pressed.connect(_return_to_menu) + _new_target_color() + var err := _socket.connect_to_url(WS_URL) + if err != OK: + hint_label.text = "WebSocket connect failed: %s" % error_string(err) + else: + hint_label.text = "Neige das Gerät, um die Farbe anzupassen" + + +func _return_to_menu() -> void: + get_tree().change_scene_to_file(MENU_SCENE) + + +func _new_target_color() -> void: + _target_hue = randf() + target_rect.color = Color.from_hsv(_target_hue, 0.75, 0.85) + + +func _physics_process(_delta: float) -> void: + _socket.poll() + + if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN: + while _socket.get_available_packet_count() > 0: + var packet := _socket.get_packet().get_string_from_utf8() + _handle_accel_message(packet) + elif _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED: + _socket.connect_to_url(WS_URL) + + var hue := fmod(float(_accel.x) / 20000.0 + 1.0, 1.0) + color_rect.color = Color.from_hsv(hue, 0.75, 0.85) + + var diff := absf(hue - _target_hue) + diff = minf(diff, 1.0 - diff) + if diff < 0.04: + hint_label.text = "Treffer! Neues Ziel…" + _new_target_color() + else: + hint_label.text = "Passe deine Farbe der Zielfarbe an (Neigung steuert den Farbton)" + + +func _handle_accel_message(text: String) -> void: + var data = JSON.parse_string(text) + if typeof(data) != TYPE_DICTIONARY: + return + if data.get("type") != "accel" or not data.get("success", false): + return + if not data.has("x") or not data.has("y") or not data.has("z"): + return + + _accel = Vector3i(int(data["x"]), int(data["y"]), int(data["z"])) diff --git a/scripts/color_game.gd.uid b/scripts/color_game.gd.uid new file mode 100644 index 0000000..7486674 --- /dev/null +++ b/scripts/color_game.gd.uid @@ -0,0 +1 @@ +uid://gya8sitagfna diff --git a/scripts/config.gd b/scripts/config.gd new file mode 100644 index 0000000..32d6c43 --- /dev/null +++ b/scripts/config.gd @@ -0,0 +1,19 @@ +extends Control + +const MENU_SCENE := "res://scenes/menu.tscn" +const WS_URL := "ws://localhost:9090/ws" + +@onready var back_button: Button = $UiLayer/BackButton +@onready var ws_url_label: Label = $UiLayer/Panel/WSUrlLabel +@onready var viewport_label: Label = $UiLayer/Panel/ViewportLabel + + +func _ready() -> void: + back_button.pressed.connect(_return_to_menu) + ws_url_label.text = "WebSocket: %s" % WS_URL + var size := get_viewport_rect().size + viewport_label.text = "Viewport: %d × %d" % [int(size.x), int(size.y)] + + +func _return_to_menu() -> void: + get_tree().change_scene_to_file(MENU_SCENE) diff --git a/scripts/config.gd.uid b/scripts/config.gd.uid new file mode 100644 index 0000000..cdea307 --- /dev/null +++ b/scripts/config.gd.uid @@ -0,0 +1 @@ +uid://cdjjsf3f0o2ex diff --git a/scripts/demo.gd b/scripts/demo.gd new file mode 100644 index 0000000..71ed575 --- /dev/null +++ b/scripts/demo.gd @@ -0,0 +1,418 @@ +extends Control + +const WS_URL := "ws://localhost:8081/ws" +const MENU_SCENE := "res://scenes/menu.tscn" +const MAX_LOG_LINES := 200 +const TAP_FLASH_SEC := 2.0 + +const PANEL_STYLE_NORMAL := Color(0.11, 0.12, 0.17, 1.0) +const PANEL_BORDER_NORMAL := Color(0.22, 0.25, 0.32, 1.0) +const PANEL_STYLE_FLASH := Color(0.32, 0.28, 0.06, 1.0) +const PANEL_BORDER_FLASH := Color(1.0, 0.82, 0.15, 1.0) + +@onready var status_label: Label = $UiLayer/TopBar/StatusLabel +@onready var log_output: TextEdit = $UiLayer/MainSplit/LogPanel/LogOutput +@onready var stream_output: Label = $UiLayer/MainSplit/LogPanel/StreamOutput +@onready var back_button: Button = $UiLayer/TopBar/BackButton +@onready var interval_spin: SpinBox = $UiLayer/MainSplit/LeftPanel/Toolbar/IntervalSpin +@onready var list_clients_btn: Button = $UiLayer/MainSplit/LeftPanel/Toolbar/ListClientsBtn +@onready var clients_list: VBoxContainer = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList +@onready var empty_hint: Label = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList/EmptyHint +@onready var start_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StartStreamBtn +@onready var stop_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StopStreamBtn + +var _socket := WebSocketPeer.new() +var _connected := false +var _streaming := false +var _log_lines: PackedStringArray = [] +var _client_ui: Dictionary = {} + + +func _ready() -> void: + back_button.pressed.connect(_return_to_menu) + list_clients_btn.pressed.connect(func(): _send({"type": "list_clients"})) + start_stream_btn.pressed.connect(_start_stream) + stop_stream_btn.pressed.connect(_stop_stream) + _connect_ws() + + +func _return_to_menu() -> void: + get_tree().change_scene_to_file(MENU_SCENE) + + +func _connect_ws() -> void: + _connected = false + var err := _socket.connect_to_url(WS_URL) + if err != OK: + _set_status("Verbindung fehlgeschlagen: %s" % error_string(err)) + else: + _set_status("Verbinde mit %s…" % WS_URL) + + +func _interval_ms() -> int: + return int(interval_spin.value) + + +func _send(payload: Dictionary) -> void: + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + _log("Nicht verbunden – Befehl verworfen: %s" % payload.get("type", "?")) + return + var text := JSON.stringify(payload) + _socket.send_text(text) + _log("→ %s" % text) + + +func _start_stream() -> void: + if _client_ui.is_empty(): + _log("Keine Clients geladen") + return + + var want_accel := false + var want_tap := false + + for cid in _client_ui: + var ui: Dictionary = _client_ui[cid] + if not ui.get("available", false): + continue + + var accel_on: bool = ui["accel_cb"].button_pressed + _send({"type": "set_accel_stream", "client_id": cid, "enable": accel_on}) + if accel_on: + want_accel = true + + var tap_single: bool = ui["tap_single"].button_pressed + var tap_double: bool = ui["tap_double"].button_pressed + var tap_triple: bool = ui["tap_triple"].button_pressed + var tap_on := tap_single or tap_double or tap_triple + _send({ + "type": "set_tap_notify", + "client_id": cid, + "single": tap_single, + "double_tap": tap_double, + "triple": tap_triple, + }) + if tap_on: + want_tap = true + + _send({"type": "set_stream", "enable": want_accel, "interval_ms": _interval_ms()}) + _send({"type": "set_tap_stream", "enable": want_tap, "interval_ms": _interval_ms()}) + + _streaming = true + start_stream_btn.disabled = true + stop_stream_btn.disabled = false + _log("Stream gestartet (accel=%s, tap=%s)" % [want_accel, want_tap]) + + +func _stop_stream() -> void: + _send({"type": "set_stream", "enable": false, "interval_ms": _interval_ms()}) + _send({"type": "set_tap_stream", "enable": false, "interval_ms": _interval_ms()}) + + for cid in _client_ui: + var ui: Dictionary = _client_ui[cid] + if not ui.get("available", false): + continue + _send({"type": "set_accel_stream", "client_id": cid, "enable": false}) + _send({ + "type": "set_tap_notify", + "client_id": cid, + "single": false, + "double_tap": false, + "triple": false, + }) + ui["values_label"].text = "—" + + _streaming = false + start_stream_btn.disabled = _client_ui.is_empty() + stop_stream_btn.disabled = true + stream_output.text = "—" + _log("Stream gestoppt") + + +func _clear_client_panels() -> void: + _client_ui.clear() + for child in clients_list.get_children(): + if child != empty_hint: + child.queue_free() + + +func _populate_clients(clients: Array) -> void: + _clear_client_panels() + var has_available := false + + for entry in clients: + if typeof(entry) != TYPE_DICTIONARY: + continue + var cid := int(entry.get("id", 0)) + if cid <= 0: + continue + + var available: bool = entry.get("available", false) + if available: + has_available = true + + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var normal_style := _make_panel_style(PANEL_STYLE_NORMAL, PANEL_BORDER_NORMAL, 1) + var flash_style := _make_panel_style(PANEL_STYLE_FLASH, PANEL_BORDER_FLASH, 2) + panel.add_theme_stylebox_override("panel", normal_style) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_bottom", 8) + panel.add_child(margin) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 6) + margin.add_child(vbox) + + var mac: String = str(entry.get("mac", "?")) + var status := "online" if available else "offline" + var header := Label.new() + header.text = "Client #%d · %s · %s" % [cid, mac, status] + header.add_theme_font_size_override("font_size", 14) + header.add_theme_color_override("font_color", Color(0.9, 0.92, 0.98) if available else Color(0.55, 0.58, 0.65)) + vbox.add_child(header) + + var accel_cb := CheckBox.new() + accel_cb.text = "Accel Stream" + accel_cb.button_pressed = entry.get("accel_stream", false) + accel_cb.disabled = not available + vbox.add_child(accel_cb) + + var tap_label := Label.new() + tap_label.text = "Tap Notify:" + tap_label.add_theme_font_size_override("font_size", 12) + tap_label.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85)) + vbox.add_child(tap_label) + + var tap_row := HBoxContainer.new() + tap_row.add_theme_constant_override("separation", 12) + vbox.add_child(tap_row) + + var tap_single := CheckBox.new() + tap_single.text = "Single" + tap_single.button_pressed = entry.get("tap_notify_single", false) + tap_single.disabled = not available + + var tap_double := CheckBox.new() + tap_double.text = "Double" + tap_double.button_pressed = entry.get("tap_notify_double", false) + tap_double.disabled = not available + + var tap_triple := CheckBox.new() + tap_triple.text = "Triple" + tap_triple.button_pressed = entry.get("tap_notify_triple", false) + tap_triple.disabled = not available + + tap_row.add_child(tap_single) + tap_row.add_child(tap_double) + tap_row.add_child(tap_triple) + + var values_label := Label.new() + values_label.text = "—" + values_label.add_theme_font_size_override("font_size", 12) + values_label.add_theme_color_override("font_color", Color(0.65, 0.85, 0.75)) + values_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(values_label) + + clients_list.add_child(panel) + + _client_ui[cid] = { + "available": available, + "panel": panel, + "normal_style": normal_style, + "flash_style": flash_style, + "flash_token": 0, + "accel_cb": accel_cb, + "tap_single": tap_single, + "tap_double": tap_double, + "tap_triple": tap_triple, + "values_label": values_label, + "last_tap": "", + } + + empty_hint.visible = _client_ui.is_empty() + start_stream_btn.disabled = _client_ui.is_empty() or not has_available or _streaming + _log("%d Client(s) geladen" % _client_ui.size()) + + +func _physics_process(_delta: float) -> void: + _socket.poll() + var state := _socket.get_ready_state() + + if state == WebSocketPeer.STATE_OPEN: + while _socket.get_available_packet_count() > 0: + _handle_message(_socket.get_packet().get_string_from_utf8()) + + match state: + WebSocketPeer.STATE_OPEN: + if _connected: + _set_status("Verbunden · %s" % WS_URL) + WebSocketPeer.STATE_CONNECTING: + _set_status("Verbinde…") + WebSocketPeer.STATE_CLOSING, WebSocketPeer.STATE_CLOSED: + _connected = false + _streaming = false + start_stream_btn.disabled = _client_ui.is_empty() + stop_stream_btn.disabled = true + _set_status("Getrennt – erneuter Verbindungsversuch…") + if state == WebSocketPeer.STATE_CLOSED: + _connect_ws() + + +func _handle_message(text: String) -> void: + var data = JSON.parse_string(text) + if typeof(data) != TYPE_DICTIONARY: + _log("← (ungültiges JSON) %s" % text.substr(0, mini(text.length(), 120))) + return + + var msg_type: String = data.get("type", "") + match msg_type: + "hello": + _on_hello(data) + "client_list": + _on_client_list(data) + "accel": + _on_accel(data) + "tap": + _on_tap(data) + _: + if not _streaming or not msg_type.ends_with("_status"): + _log("← %s" % _format_response(data)) + + +func _on_hello(data: Dictionary) -> void: + _connected = true + if data.has("interval_ms"): + interval_spin.value = int(data["interval_ms"]) + _log("← hello (Port: %s)" % data.get("serial_port", "?")) + + +func _on_client_list(data: Dictionary) -> void: + if not data.get("success", false): + _log("← client_list FAILED: %s" % data.get("error", "?")) + return + _populate_clients(data.get("clients", [])) + + +func _on_accel(data: Dictionary) -> void: + if not data.get("success", false): + stream_output.text = "accel FEHLER: %s" % data.get("error", "?") + return + + var summary: PackedStringArray = [] + for client in data.get("clients", []): + if typeof(client) != TYPE_DICTIONARY: + continue + var cid := int(client.get("client_id", 0)) + if not _client_ui.has(cid): + continue + + var ui: Dictionary = _client_ui[cid] + var tap_part: String = ui.get("last_tap", "") + if client.get("valid", false): + var accel_text := "x=%d y=%d z=%d (age %sms)" % [ + int(client.get("x", 0)), + int(client.get("y", 0)), + int(client.get("z", 0)), + str(client.get("age_ms", "?")), + ] + ui["values_label"].text = accel_text + ("\n" + tap_part if tap_part else "") + summary.append("#%d: %s" % [cid, accel_text]) + else: + ui["values_label"].text = "accel: —" + ("\n" + tap_part if tap_part else "") + + if summary.is_empty(): + stream_output.text = "accel (keine gültigen Samples)" + else: + stream_output.text = "accel\n" + "\n".join(summary) + + +func _on_tap(data: Dictionary) -> void: + if not data.get("success", false): + return + + var summary: PackedStringArray = [] + for event in data.get("events", []): + if typeof(event) != TYPE_DICTIONARY: + continue + var cid := int(event.get("client_id", 0)) + if not _client_ui.has(cid): + continue + + var tap_text := "tap: %s (age %sms)" % [str(event.get("kind", "?")), str(event.get("age_ms", "?"))] + var ui: Dictionary = _client_ui[cid] + ui["last_tap"] = tap_text + + var existing: String = ui["values_label"].text + if existing == "—" or existing.begins_with("accel: —"): + ui["values_label"].text = tap_text + elif "\n" in existing: + ui["values_label"].text = existing.split("\n", false, 1)[0] + "\n" + tap_text + else: + ui["values_label"].text = existing + "\n" + tap_text + + summary.append("#%d: %s" % [cid, tap_text]) + _flash_client_panel(cid) + + if not summary.is_empty(): + stream_output.text = "tap\n" + "\n".join(summary) + + +func _make_panel_style(bg: Color, border: Color, border_width: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = bg + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(6) + style.set_content_margin_all(0) + return style + + +func _flash_client_panel(cid: int) -> void: + if not _client_ui.has(cid): + return + + var ui: Dictionary = _client_ui[cid] + var panel: PanelContainer = ui["panel"] + panel.add_theme_stylebox_override("panel", ui["flash_style"]) + + ui["flash_token"] = int(ui.get("flash_token", 0)) + 1 + var token: int = ui["flash_token"] + get_tree().create_timer(TAP_FLASH_SEC).timeout.connect( + func() -> void: + if not _client_ui.has(cid): + return + var current: Dictionary = _client_ui[cid] + if int(current.get("flash_token", 0)) != token: + return + current["panel"].add_theme_stylebox_override("panel", current["normal_style"]), + CONNECT_ONE_SHOT + ) + + +func _format_response(data: Dictionary) -> String: + var msg_type: String = data.get("type", "?") + if not data.get("success", true) and data.has("error"): + return "%s FAILED: %s" % [msg_type, data["error"]] + return JSON.stringify(data) + + +func _set_status(text: String) -> void: + status_label.text = text + + +func _log(text: String) -> void: + var line := "[%s] %s" % [_time_str(), text] + _log_lines.append(line) + while _log_lines.size() > MAX_LOG_LINES: + _log_lines.remove_at(0) + log_output.text = "\n".join(_log_lines) + log_output.set_caret_line(log_output.get_line_count() - 1) + + +func _time_str() -> String: + var t := Time.get_time_dict_from_system() + return "%02d:%02d:%02d" % [t.hour, t.minute, t.second] diff --git a/scripts/demo.gd.uid b/scripts/demo.gd.uid new file mode 100644 index 0000000..e145a56 --- /dev/null +++ b/scripts/demo.gd.uid @@ -0,0 +1 @@ +uid://cl77don5tdl5h diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid deleted file mode 100644 index 89ae20d..0000000 --- a/scripts/main.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://g8bye3sv5ifc diff --git a/scripts/menu.gd b/scripts/menu.gd new file mode 100644 index 0000000..d468edd --- /dev/null +++ b/scripts/menu.gd @@ -0,0 +1,28 @@ +extends Control + +const DEMO_SCENE := "res://scenes/demo.tscn" +const CONFIG_SCENE := "res://scenes/config.tscn" +const PONG_SCENE := "res://scenes/pong.tscn" +const COLOR_GAME_SCENE := "res://scenes/color_game.tscn" + +@onready var demo_button: Button = $CenterBox/DemoButton +@onready var config_button: Button = $CenterBox/ConfigButton +@onready var pong_button: Button = $CenterBox/PongButton +@onready var color_game_button: Button = $CenterBox/ColorGameButton +@onready var exit_button: Button = $CenterBox/ExitButton + + +func _ready() -> void: + demo_button.pressed.connect(func(): _open_scene(DEMO_SCENE)) + config_button.pressed.connect(func(): _open_scene(CONFIG_SCENE)) + pong_button.pressed.connect(func(): _open_scene(PONG_SCENE)) + color_game_button.pressed.connect(func(): _open_scene(COLOR_GAME_SCENE)) + exit_button.pressed.connect(_exit_game) + + +func _open_scene(path: String) -> void: + get_tree().change_scene_to_file(path) + + +func _exit_game() -> void: + get_tree().quit() diff --git a/scripts/menu.gd.uid b/scripts/menu.gd.uid new file mode 100644 index 0000000..e9f759b --- /dev/null +++ b/scripts/menu.gd.uid @@ -0,0 +1 @@ +uid://b32rtcn0rpvii diff --git a/scripts/main.gd b/scripts/pong.gd similarity index 97% rename from scripts/main.gd rename to scripts/pong.gd index 1f2db2d..aa45191 100644 --- a/scripts/main.gd +++ b/scripts/pong.gd @@ -13,6 +13,7 @@ enum GameState { CALIBRATION, PLAYING } @onready var threshold_panel: Control = $UiLayer/ThresholdPanel @onready var calibration_overlay: Control = $UiLayer/CalibrationOverlay @onready var recalib_button: Button = $UiLayer/RecalibButton +@onready var menu_button: Button = $UiLayer/MenuButton var _socket := WebSocketPeer.new() var _velocity_x := 0.0 @@ -21,6 +22,7 @@ var _state := GameState.CALIBRATION var _calibration := AccelCalibration.new() const WS_URL := "ws://localhost:9090/ws" +const MENU_SCENE := "res://scenes/menu.tscn" const MAX_SPEED := 900.0 const FRICTION := 0.9 const PLATFORM_HALF_WIDTH := 80.0 @@ -40,6 +42,7 @@ func _ready() -> void: calibration_overlay.calibration_finished.connect(_on_calibration_finished) recalib_button.pressed.connect(_restart_calibration) + menu_button.pressed.connect(_return_to_menu) calibration_overlay.start() var err := _socket.connect_to_url(WS_URL) @@ -49,6 +52,10 @@ func _ready() -> void: status_label.text = "Connecting to %s…" % WS_URL +func _return_to_menu() -> void: + get_tree().change_scene_to_file(MENU_SCENE) + + func _physics_process(delta: float) -> void: _socket.poll() _update_connection_status() diff --git a/scripts/pong.gd.uid b/scripts/pong.gd.uid new file mode 100644 index 0000000..c1e8b5b --- /dev/null +++ b/scripts/pong.gd.uid @@ -0,0 +1 @@ +uid://bs5f1aw8bcqbf