Fix mobile restart and swipe aiming while firing

This commit is contained in:
enne2
2026-10-11 00:13:06 +02:00
parent dee0b387ba
commit f246ede94f
5 changed files with 107 additions and 7 deletions
+19 -1
View File
@@ -31,7 +31,25 @@ carries a single pointer and would break simultaneous aiming and firing.
- left half: floating joystick (its base recenters where the finger lands), feeds the analog - left half: floating joystick (its base recenters where the finger lands), feeds the analog
`move_*` actions; `move_*` actions;
- right half: look area, drag rotates the view through `player.apply_look()`; - right half: look area, drag rotates the view through `player.apply_look()`;
- bottom right: FIRE (hold to keep shooting), GRENADE, RELOAD, JUMP. - bottom right: FIRE (hold and drag to shoot AND aim), GRENADE, RELOAD, JUMP.
- game over: controls release all actions and hide; a central RICOMINCIA button
handles native screen touches even with mouse emulation disabled. A 0.6-second
guard prevents the killing-frame gesture from accidentally restarting.
### Swipe aiming
Prefer direct swipe displacement to a rate-based right stick: releasing the thumb
stops rotation immediately. FIRE also acts as a look pad, so two thumbs can move,
aim and shoot simultaneously. This follows the combined action/touchpad pattern in
[Microsoft's designer guide](https://learn.microsoft.com/en-us/gaming/gdk/docs/features/common/game-streaming/building-touch-layouts/game-streaming-tak-designers-guide).
Use `InputEventScreenDrag.screen_relative`, not stretch-scaled `relative`, as
[Godot explicitly recommends for touch aiming](https://docs.godotengine.org/en/stable/classes/class_inputeventscreendrag.html).
Normalize by the physical window width: one full-width swipe turns 180 degrees;
vertical gain is 75% for finer pitch control. No time delta or acceleration is
applied (these are displacements), no smoothing latency, desktop mouse unchanged.
Lift/reposition the finger without a camera jump. Constants `LOOK_TURN_RADIANS`
and `LOOK_VERTICAL_RATIO` tune the feel.
Movement uses the existing InputMap actions, so keyboard, gamepad and touch share one path. Reload, Movement uses the existing InputMap actions, so keyboard, gamepad and touch share one path. Reload,
grenade and jump are read by polling (`Input.is_action_just_pressed`) instead of by event handling: grenade and jump are read by polling (`Input.is_action_just_pressed`) instead of by event handling:
+1 -1
View File
@@ -146,7 +146,7 @@ func _process(_dt: float) -> void:
if player.touch_mode: if player.touch_mode:
hud.text += "\ntrascina a sinistra: muovi · a destra: guarda" hud.text += "\ntrascina a sinistra: muovi · a destra: guarda"
if player.hp <= 0: if player.hp <= 0:
hud.text += "\nSEI MORTO — premi R per ricominciare" hud.text += "\nSEI MORTO — tocca RICOMINCIA" if player.touch_mode else "\nSEI MORTO — premi R per ricominciare"
func zombies_for_wave(number: int) -> int: func zombies_for_wave(number: int) -> int:
return 4 + number * 2 return 4 + number * 2
+28 -1
View File
@@ -68,6 +68,16 @@ func _ready() -> void:
if DisplayServer.get_name() != "headless" and not OS.has_feature("web"): if DisplayServer.get_name() != "headless" and not OS.has_feature("web"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
var restart_button: Button
func _input(event: InputEvent) -> void:
# Touch nativo: funziona anche con emulate_mouse_from_touch disattivato.
if event is InputEventScreenTouch and event.pressed and is_instance_valid(restart_button):
if not restart_button.disabled and restart_button.get_global_rect().has_point(event.position):
restart_button.disabled = true
get_viewport().set_input_as_handled()
get_tree().call_deferred("reload_current_scene")
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started: if OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
@@ -211,10 +221,27 @@ func _die() -> void:
tween.tween_property(camera, "rotation:x", 0.08, 0.9) tween.tween_property(camera, "rotation:x", 0.08, 0.9)
tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7) tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7)
var message := Label.new() var message := Label.new()
message.text = "SEI MORTO\nPremi R per ricominciare" message.text = "SEI MORTO" if touch_mode else "SEI MORTO\nPremi R per ricominciare"
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.add_theme_font_size_override("font_size", 36) message.add_theme_font_size_override("font_size", 36)
message.modulate.a = 0 message.modulate.a = 0
layer.add_child(message) layer.add_child(message)
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER) message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2) tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2)
if touch_mode:
var restart := Button.new()
restart_button = restart
restart.name = "RestartButton"
restart.text = "RICOMINCIA"
restart.add_theme_font_size_override("font_size", 28)
layer.add_child(restart)
restart.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
restart.offset_left = -150
restart.offset_right = 150
restart.offset_top = 90
restart.offset_bottom = 162
restart.disabled = true
get_tree().create_timer(0.6).timeout.connect(func(): restart.disabled = false)
restart.pressed.connect(func():
restart.disabled = true
get_tree().reload_current_scene())
+27 -4
View File
@@ -15,7 +15,8 @@ const JOY_RADIUS := 92.0
const KNOB_RADIUS := 40.0 const KNOB_RADIUS := 40.0
const BTN_RADIUS := 46.0 const BTN_RADIUS := 46.0
const BTN_FIRE_RADIUS := 62.0 const BTN_FIRE_RADIUS := 62.0
const LOOK_SENSITIVITY := 1.7 # moltiplicatore del trascinamento nell'area di puntamento const LOOK_TURN_RADIANS := PI # swipe largo quanto lo schermo = 180 gradi
const LOOK_VERTICAL_RATIO := 0.75
const EDGE := 24.0 const EDGE := 24.0
const COLOR_BASE := Color(1, 1, 1, 0.09) const COLOR_BASE := Color(1, 1, 1, 0.09)
@@ -137,7 +138,7 @@ func _input(event: InputEvent) -> void:
elif event is InputEventScreenDrag: elif event is InputEventScreenDrag:
index = event.index index = event.index
pos = event.position pos = event.position
relative = event.relative relative = event.screen_relative
is_drag = true is_drag = true
elif mouse_fallback and event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: elif mouse_fallback and event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
index = MOUSE_INDEX index = MOUSE_INDEX
@@ -218,9 +219,31 @@ func _drag(index: int, pos: Vector2, relative: Vector2) -> void:
_joy_knob = (pos - _joy_origin).limit_length(JOY_RADIUS) _joy_knob = (pos - _joy_origin).limit_length(JOY_RADIUS)
_apply_move() _apply_move()
_root.queue_redraw() _root.queue_redraw()
Role.LOOK: Role.LOOK, Role.FIRE:
if player and player.has_method("apply_look"): if player and player.has_method("apply_look"):
player.apply_look(relative * LOOK_SENSITIVITY) player.apply_look(_look_delta(relative, _screen_width()))
## Stessa frazione di swipe = stessa rotazione, senza stretch o delta temporale.
static func _look_delta(relative: Vector2, screen_width: float) -> Vector2:
return relative * Vector2(1.0, LOOK_VERTICAL_RATIO) * LOOK_TURN_RADIANS / maxf(screen_width, 1.0) / 0.0025
func _screen_width() -> float:
return _size.x if DisplayServer.get_name() == "headless" else float(DisplayServer.window_get_size().x)
func release_all() -> void:
for index in _touches.keys():
_release(index)
for action in MOVE_ACTIONS + ["shoot", "grenade", "reload", "jump"]:
Input.action_release(action)
func _process(_dt: float) -> void:
if player and player.hp <= 0 and enabled:
release_all()
enabled = false
_root.hide()
func _exit_tree() -> void:
release_all()
## Traduce il vettore del joystick nelle azioni di movimento (analogiche). ## Traduce il vettore del joystick nelle azioni di movimento (analogiche).
func _apply_move() -> void: func _apply_move() -> void:
+32
View File
@@ -46,7 +46,9 @@ func _drag(index: int, pos: Vector2, relative: Vector2) -> void:
ev.index = index ev.index = index
ev.position = pos ev.position = pos
ev.relative = relative ev.relative = relative
ev.screen_relative = relative
Input.parse_input_event(ev) Input.parse_input_event(ev)
Input.flush_buffered_events()
func _steps(count: int) -> void: func _steps(count: int) -> void:
for i in count: for i in count:
@@ -128,6 +130,12 @@ func run_test() -> void:
_touch(0, fire_pos, true) _touch(0, fire_pos, true)
await _steps(10) await _steps(10)
assert(player.ammo < 30) assert(player.ammo < 30)
var fire_yaw: float = player.rotation.y
_drag(0, fire_pos + Vector2(-80, 10), Vector2(-80, 10))
await _frame()
assert(player.rotation.y > fire_yaw + 0.001)
assert(Input.is_action_pressed("shoot"))
print("PASS: trascinare FUOCO mira senza interrompere lo sparo")
var after_fire: int = player.ammo var after_fire: int = player.ammo
_touch(0, fire_pos, false) _touch(0, fire_pos, false)
await _steps(10) await _steps(10)
@@ -166,6 +174,30 @@ func run_test() -> void:
# --- rilevamento: su questo contesto (desktop headless) la UI non e' automatica --- # --- rilevamento: su questo contesto (desktop headless) la UI non e' automatica ---
assert(not TouchScript.should_auto_enable()) assert(not TouchScript.should_auto_enable())
print("PASS: TouchControls.should_auto_enable() = false su desktop -> la UI compare solo se serve") print("PASS: TouchControls.should_auto_enable() = false su desktop -> la UI compare solo se serve")
assert(TouchScript._look_delta(Vector2(100, 20), 1000).is_equal_approx(TouchScript._look_delta(Vector2(200, 40), 2000)))
print("PASS: sensibilita' invariata per swipe proporzionali su schermi diversi")
_touch(0, start_pos, true)
player.take_damage(1000)
await _frame()
await _frame()
assert(not touch.enabled and touch._touches.is_empty())
assert(not Input.is_action_pressed("shoot"))
assert(is_instance_valid(player.restart_button))
await create_timer(0.7).timeout
assert(not player.restart_button.disabled)
current_scene = world
# Verifica anche il reload effettivo, non solo il pulsante.
var restart_event := InputEventScreenTouch.new()
restart_event.pressed = true
restart_event.position = player.restart_button.get_global_rect().get_center()
player._input(restart_event)
assert(player.restart_button.disabled)
await _frame()
await _frame()
assert(current_scene != world)
assert(current_scene.player.hp > 0)
world = current_scene
print("PASS: morte libera input, RICOMINCIA touch ricarica davvero la partita")
print("PASS: controlli touch completi") print("PASS: controlli touch completi")
world.queue_free() world.queue_free()
await _frame() await _frame()