Initial commit: Godot Pong game with WebSocket accelerometer control.

Includes platform steering via calibrated accel axes, ball physics,
calibration overlay with axis detection, and runtime tuning sliders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 21:52:35 +02:00
co-authored by Cursor
commit bc25226a31
15 changed files with 808 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
class_name AccelCalibration
extends RefCounted
enum Axis { X, Y, Z }
var axis: Axis = Axis.X
var sign: float = 1.0
var center: float = 0.0
var suggested_threshold: float = 3000.0
var _samples_right: Array[Vector3] = []
var _samples_left: Array[Vector3] = []
func reset() -> void:
_samples_right.clear()
_samples_left.clear()
func record_right(accel: Vector3i) -> void:
_samples_right.append(Vector3(accel))
func record_left(accel: Vector3i) -> void:
_samples_left.append(Vector3(accel))
func is_ready() -> bool:
return _samples_right.size() >= 20 and _samples_left.size() >= 20
func analyze() -> bool:
if not is_ready():
return false
var mean_r := _mean(_samples_right)
var mean_l := _mean(_samples_left)
var diff := mean_r - mean_l
var diffs := [absf(diff.x), absf(diff.y), absf(diff.z)]
axis = Axis.X
var best: float = diffs[0]
if diffs[1] > best:
best = diffs[1]
axis = Axis.Y
if diffs[2] > best:
best = diffs[2]
axis = Axis.Z
var delta := _component(diff, axis)
sign = 1.0 if delta >= 0.0 else -1.0
var all_samples: Array[Vector3] = []
all_samples.append_array(_samples_right)
all_samples.append_array(_samples_left)
var projections: Array[float] = []
for sample in all_samples:
projections.append(_component(sample, axis) * sign)
projections.sort()
center = projections[projections.size() / 2]
var deviations: Array[float] = []
for value in projections:
deviations.append(absf(value - center))
deviations.sort()
var median_idx := deviations.size() / 2
var spread := deviations[median_idx] if not deviations.is_empty() else 500.0
suggested_threshold = clampf(spread * 1.35, 200.0, 12000.0)
return true
func project(accel: Vector3i) -> float:
return _component(Vector3(accel), axis) * sign
func axis_name() -> String:
match axis:
Axis.X:
return "x"
Axis.Y:
return "y"
Axis.Z:
return "z"
return "?"
func _mean(samples: Array[Vector3]) -> Vector3:
if samples.is_empty():
return Vector3.ZERO
var sum := Vector3.ZERO
for s in samples:
sum += s
return sum / float(samples.size())
func _mean_scalar(samples: Array[Vector3], ax: Axis) -> float:
return _component(_mean(samples), ax)
func _component(v: Vector3, ax: Axis) -> float:
match ax:
Axis.X:
return v.x
Axis.Y:
return v.y
Axis.Z:
return v.z
return 0.0
+1
View File
@@ -0,0 +1 @@
uid://c0x3puwhmk8f3
+81
View File
@@ -0,0 +1,81 @@
extends Node2D
const RADIUS := 10.0
const SPEED := 420.0
const PLATFORM_HALF := Vector2(80.0, 12.0)
var velocity := Vector2.ZERO
func _ready() -> void:
velocity = Vector2(320.0, -360.0).normalized() * SPEED
func reset_to(center: Vector2) -> void:
position = center
var dir := Vector2(randf_range(-0.6, 0.6), -1.0).normalized()
velocity = dir * SPEED
queue_redraw()
func step(delta: float, platform_pos: Vector2, platform_vx: float, bounds: Rect2) -> void:
position += velocity * delta
_bounce_walls(bounds)
_bounce_platform_top(platform_pos, platform_vx)
queue_redraw()
func _bounce_walls(bounds: Rect2) -> void:
var min_x := bounds.position.x + RADIUS
var max_x := bounds.end.x - RADIUS
var min_y := bounds.position.y + RADIUS
var max_y := bounds.end.y - RADIUS
if position.x < min_x:
position.x = min_x
velocity.x = absf(velocity.x)
elif position.x > max_x:
position.x = max_x
velocity.x = -absf(velocity.x)
if position.y < min_y:
position.y = min_y
velocity.y = absf(velocity.y)
elif position.y > max_y:
position.y = max_y
velocity.y = -absf(velocity.y)
_normalize_speed()
func _bounce_platform_top(platform_pos: Vector2, platform_vx: float) -> void:
var left := platform_pos.x - PLATFORM_HALF.x
var right := platform_pos.x + PLATFORM_HALF.x
var top := platform_pos.y - PLATFORM_HALF.y
var bottom := platform_pos.y + PLATFORM_HALF.y
if position.x < left - RADIUS or position.x > right + RADIUS:
return
if velocity.y <= 0.0:
return
if position.y + RADIUS < top:
return
if position.y - RADIUS > bottom + 4.0:
return
position.y = top - RADIUS
velocity.y = -absf(velocity.y)
velocity.x += platform_vx * 0.45
_normalize_speed()
func _normalize_speed() -> void:
if velocity.length_squared() < 1.0:
velocity = Vector2.RIGHT * SPEED
else:
velocity = velocity.normalized() * SPEED
func _draw() -> void:
draw_circle(Vector2.ZERO, RADIUS, Color(1.0, 0.88, 0.35))
draw_arc(Vector2.ZERO, RADIUS, 0.0, TAU, 24, Color(1.0, 0.95, 0.7), 1.5)
+1
View File
@@ -0,0 +1 @@
uid://bnvlevjo1fp48
+149
View File
@@ -0,0 +1,149 @@
extends Control
signal calibration_finished(calibration: AccelCalibration)
enum Phase { WAITING, RIGHT, LEFT, DONE }
const TRAVEL_PX := 140.0
const PHASE_DURATION := 4.5
const CIRCLE_RADIUS := 44.0
const NEEDLE_LEN := 58.0
var _phase := Phase.WAITING
var _phase_time := 0.0
var _guide_offset := Vector2.ZERO
var _instruction := ""
var _calibration := AccelCalibration.new()
var _connection_notified := false
@onready var _hint_label: Label = $HintLabel
@onready var _start_button: Button = $StartButton
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_PASS
_start_button.pressed.connect(_on_start_pressed)
_show_waiting()
func start() -> void:
_calibration.reset()
_connection_notified = false
_phase = Phase.WAITING
_phase_time = 0.0
_guide_offset = Vector2.ZERO
visible = true
_start_button.visible = true
_show_waiting()
queue_redraw()
func is_active() -> bool:
return _phase != Phase.DONE
func feed_accel(accel: Vector3i) -> void:
match _phase:
Phase.RIGHT:
_calibration.record_right(accel)
Phase.LEFT:
_calibration.record_left(accel)
func notify_connected() -> void:
if _phase != Phase.WAITING or _connection_notified:
return
_hint_label.text = "Verbunden tippe „Kalibrierung starten“ oder warte…"
# Automatisch starten sobald verbunden
begin_calibration()
func begin_calibration() -> void:
if _phase == Phase.RIGHT or _phase == Phase.LEFT:
return
_connection_notified = true
_start_button.visible = false
_begin_phase(Phase.RIGHT)
func _on_start_pressed() -> void:
begin_calibration()
func _process(delta: float) -> void:
if _phase == Phase.WAITING or _phase == Phase.DONE:
return
_phase_time += delta
var t := clampf(_phase_time / PHASE_DURATION, 0.0, 1.0)
var ease := t * t * (3.0 - 2.0 * t)
match _phase:
Phase.RIGHT:
_guide_offset.x = TRAVEL_PX * ease
_instruction = "Neige das Gerät nach rechts folge dem Kreis →"
if _phase_time >= PHASE_DURATION:
_begin_phase(Phase.LEFT)
Phase.LEFT:
_guide_offset.x = -TRAVEL_PX * ease
_instruction = "Neige das Gerät nach links folge dem Kreis ←"
if _phase_time >= PHASE_DURATION:
_finish()
_hint_label.text = _instruction
queue_redraw()
func _begin_phase(phase: Phase) -> void:
_phase = phase
_phase_time = 0.0
_guide_offset = Vector2.ZERO
match phase:
Phase.RIGHT:
_instruction = "Neige das Gerät nach rechts folge dem Kreis →"
Phase.LEFT:
_instruction = "Neige das Gerät nach links folge dem Kreis ←"
_hint_label.text = _instruction
queue_redraw()
func _finish() -> void:
_phase = Phase.DONE
visible = false
if _calibration.analyze():
calibration_finished.emit(_calibration)
else:
_hint_label.text = "Kalibrierung fehlgeschlagen zu wenig Daten"
visible = true
_start_button.visible = true
_show_waiting()
func _show_waiting() -> void:
_instruction = "Warte auf Verbindung zu localhost:9090…"
_hint_label.text = _instruction + "\n(Oder „Kalibrierung starten“ ohne Verbindung)"
func _screen_center() -> Vector2:
return get_viewport_rect().size * 0.5
func _draw() -> void:
if _phase == Phase.DONE:
return
var pos := _screen_center() + _guide_offset
draw_circle(pos, CIRCLE_RADIUS, Color(0.25, 0.72, 0.95, 0.2))
draw_arc(pos, CIRCLE_RADIUS, 0.0, TAU, 64, Color(0.35, 0.82, 1.0), 2.5)
var needle_tip := pos + Vector2(0.0, -NEEDLE_LEN)
draw_line(pos, needle_tip, Color(1.0, 1.0, 1.0, 0.95), 3.0)
draw_circle(needle_tip, 5.0, Color(1.0, 1.0, 1.0))
var center := _screen_center()
if _phase == Phase.RIGHT:
draw_line(center, center + Vector2(TRAVEL_PX, 0.0), Color(1.0, 1.0, 1.0, 0.12), 1.0)
elif _phase == Phase.LEFT:
draw_line(center, center + Vector2(-TRAVEL_PX, 0.0), Color(1.0, 1.0, 1.0, 0.12), 1.0)
+1
View File
@@ -0,0 +1 @@
uid://crbm1833myr5l
+232
View File
@@ -0,0 +1,232 @@
extends Node2D
enum GameState { CALIBRATION, PLAYING }
@onready var platform: Polygon2D = $Platform
@onready var ball: Node2D = $Ball
@onready var status_label: Label = $UiLayer/StatusLabel
@onready var accel_label: Label = $UiLayer/AccelLabel
@onready var threshold_slider: HSlider = $UiLayer/ThresholdPanel/ThresholdSlider
@onready var threshold_caption: Label = $UiLayer/ThresholdPanel/ThresholdCaption
@onready var sensitivity_slider: HSlider = $UiLayer/ThresholdPanel/SensitivitySlider
@onready var sensitivity_caption: Label = $UiLayer/ThresholdPanel/SensitivityCaption
@onready var threshold_panel: Control = $UiLayer/ThresholdPanel
@onready var calibration_overlay: Control = $UiLayer/CalibrationOverlay
@onready var recalib_button: Button = $UiLayer/RecalibButton
var _socket := WebSocketPeer.new()
var _velocity_x := 0.0
var _accel := Vector3i.ZERO
var _state := GameState.CALIBRATION
var _calibration := AccelCalibration.new()
const WS_URL := "ws://localhost:9090/ws"
const MAX_SPEED := 900.0
const FRICTION := 0.9
const PLATFORM_HALF_WIDTH := 80.0
const MARGIN := 24.0
const PLAY_TOP := 160.0
func _ready() -> void:
_place_platform_bottom_center()
ball.reset_to(_play_area().get_center())
_set_playing_ui_visible(false)
threshold_slider.value_changed.connect(_on_threshold_changed)
_on_threshold_changed(threshold_slider.value)
sensitivity_slider.value_changed.connect(_on_sensitivity_changed)
_on_sensitivity_changed(sensitivity_slider.value)
calibration_overlay.calibration_finished.connect(_on_calibration_finished)
recalib_button.pressed.connect(_restart_calibration)
calibration_overlay.start()
var err := _socket.connect_to_url(WS_URL)
if err != OK:
status_label.text = "WebSocket connect failed: %s" % error_string(err)
else:
status_label.text = "Connecting to %s" % WS_URL
func _physics_process(delta: float) -> void:
_socket.poll()
_update_connection_status()
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)
if _state == GameState.CALIBRATION:
if calibration_overlay.is_active():
calibration_overlay.feed_accel(_accel)
_update_accel_label()
return
var eff := _effective_accel()
if eff == 0.0:
_velocity_x *= FRICTION
else:
var target_v := eff * _velocity_scale()
_velocity_x = move_toward(_velocity_x, target_v, maxf(absf(target_v) * 0.35, 120.0) * delta)
_velocity_x = clampf(_velocity_x, -MAX_SPEED, MAX_SPEED)
var next_x := platform.position.x + _velocity_x * delta
platform.position.x = _clamp_x(next_x)
ball.step(delta, platform.position, _velocity_x, _play_area())
_update_accel_label()
func _restart_calibration() -> void:
_state = GameState.CALIBRATION
_velocity_x = 0.0
_set_playing_ui_visible(false)
calibration_overlay.start()
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
calibration_overlay.notify_connected()
else:
status_label.text = "Kalibrierung tippe „Kalibrierung starten“"
func _on_calibration_finished(calibration: AccelCalibration) -> void:
_calibration = calibration
threshold_slider.value = calibration.suggested_threshold
_on_threshold_changed(calibration.suggested_threshold)
_start_playing()
func _start_playing() -> void:
_state = GameState.PLAYING
_set_playing_ui_visible(true)
_velocity_x = 0.0
_place_platform_bottom_center()
ball.reset_to(_play_area().get_center())
status_label.text = "Kalibriert: Achse %s · Schwellwert %d" % [
_calibration.axis_name(), int(threshold_slider.value)
]
func _set_playing_ui_visible(visible: bool) -> void:
platform.visible = visible
ball.visible = visible
threshold_panel.visible = visible
recalib_button.visible = visible
func _on_threshold_changed(value: float) -> void:
threshold_caption.text = "Schwellwert |%s|: %d" % [_calibration.axis_name(), int(value)]
func _on_sensitivity_changed(value: float) -> void:
sensitivity_caption.text = "Bewegungsstärke: %d %%" % int(value)
func _velocity_scale() -> float:
return sensitivity_slider.value / 100.0
func _calibrated_raw() -> float:
return _calibration.project(_accel)
func _effective_accel() -> float:
var raw := _calibrated_raw()
var centered := raw - _calibration.center
var threshold := threshold_slider.value
var abs_v := absf(centered)
if abs_v <= threshold:
return 0.0
return signf(centered) * (abs_v - threshold)
func _update_connection_status() -> void:
if _state == GameState.CALIBRATION:
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
status_label.text = "Kalibrierung verbunden"
calibration_overlay.notify_connected()
WebSocketPeer.STATE_CONNECTING:
status_label.text = "Kalibrierung verbinde…"
_:
status_label.text = "Kalibrierung keine Verbindung"
return
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
var eff := _effective_accel()
status_label.text = "Connected · Achse %s · vx=%.0f · eff=%.0f" % [
_calibration.axis_name(), _velocity_x, eff
]
WebSocketPeer.STATE_CONNECTING:
status_label.text = "Connecting…"
WebSocketPeer.STATE_CLOSING, WebSocketPeer.STATE_CLOSED:
status_label.text = "Disconnected retrying…"
if _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
_socket.connect_to_url(WS_URL)
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
_accel = Vector3i(int(data["x"]), int(data["y"]), int(data["z"]))
func _play_area() -> Rect2:
var size := get_viewport_rect().size
var bottom_y := size.y - MARGIN
return Rect2(
MARGIN,
PLAY_TOP,
size.x - MARGIN * 2.0,
bottom_y - PLAY_TOP
)
func _place_platform_bottom_center() -> void:
var size := get_viewport_rect().size
platform.position = Vector2(_clamp_x(size.x * 0.5), size.y - 48.0)
func _platform_x_limits() -> Vector2:
var view_w := get_viewport_rect().size.x
return Vector2(PLATFORM_HALF_WIDTH + MARGIN, view_w - PLATFORM_HALF_WIDTH - MARGIN)
func _clamp_x(x: float) -> float:
var limits := _platform_x_limits()
var min_x := limits.x
var max_x := limits.y
if x < min_x:
if _velocity_x < 0.0:
_velocity_x = 0.0
return min_x
if x > max_x:
if _velocity_x > 0.0:
_velocity_x = 0.0
return max_x
return x
func _update_accel_label() -> void:
if _state == GameState.CALIBRATION:
accel_label.text = "x: %d y: %d z: %d\nKalibrierung läuft…" % [_accel.x, _accel.y, _accel.z]
return
var eff := _effective_accel()
accel_label.text = "x: %d y: %d z: %d\nAchse %s%.0f (Schw. %d, eff %.0f)" % [
_accel.x, _accel.y, _accel.z,
_calibration.axis_name(), _calibration.project(_accel),
int(threshold_slider.value), eff
]
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_SIZE_CHANGED:
platform.position.y = get_viewport_rect().size.y - 48.0
platform.position.x = _clamp_x(platform.position.x)
if _state == GameState.PLAYING:
ball.reset_to(_play_area().get_center())
+1
View File
@@ -0,0 +1 @@
uid://g8bye3sv5ifc