319 lines
11 KiB
GDScript
319 lines
11 KiB
GDScript
extends CanvasLayer
|
|
## Controlli on-screen per browser mobile e dispositivi touchscreen.
|
|
##
|
|
## Un solo Control a schermo intero smista il multi-touch per indice di tocco:
|
|
## - meta' sinistra -> joystick di movimento flottante (alimenta le azioni move_*)
|
|
## - meta' destra -> area di puntamento (trascina per ruotare la visuale)
|
|
## - cerchi in basso a destra -> fuoco (tenere premuto), granata, ricarica, salto
|
|
##
|
|
## Non dipende dall'emulazione mouse<->touch: l'emulazione copre un solo
|
|
## puntatore e romperebbe il multi-touch (es. fuoco + movimento insieme).
|
|
## Vedi docs/TOUCH-CONTROLS.md.
|
|
|
|
const DEAD_ZONE := 10.0 # px: sotto questa soglia il vettore e' nullo
|
|
const JOY_RADIUS := 92.0
|
|
const KNOB_RADIUS := 40.0
|
|
const BTN_RADIUS := 46.0
|
|
const BTN_FIRE_RADIUS := 62.0
|
|
const LOOK_TURN_RADIANS := PI # swipe largo quanto lo schermo = 180 gradi
|
|
const LOOK_VERTICAL_RATIO := 0.75
|
|
const EDGE := 24.0
|
|
|
|
const COLOR_BASE := Color(1, 1, 1, 0.09)
|
|
const COLOR_RING := Color(1, 1, 1, 0.34)
|
|
const COLOR_KNOB := Color(0.82, 0.88, 1.0, 0.42)
|
|
const COLOR_BTN := Color(1, 1, 1, 0.13)
|
|
const COLOR_ACTIVE := Color(1, 0.55, 0.15, 0.55)
|
|
const COLOR_TEXT := Color(1, 1, 1, 0.88)
|
|
|
|
## Giocatore controllato: riceve i delta di visuale. Impostato da main.gd.
|
|
var player: Node
|
|
## Modalita' mouse: trattare i drag del mouse come tocchi (solo test su desktop).
|
|
var mouse_fallback := false
|
|
## Finche' e' false i tocchi non vengono consumati: serve al gate di avvio Web,
|
|
## dove il primo tocco deve arrivare a main.gd per sbloccare audio e partita.
|
|
var enabled := true
|
|
|
|
enum Role { JOY, LOOK, FIRE, GRENADE, RELOAD, JUMP }
|
|
|
|
const MOUSE_INDEX := -1
|
|
const MOVE_ACTIONS := ["move_left", "move_right", "move_forward", "move_back"]
|
|
|
|
var move_vector := Vector2.ZERO
|
|
var _touches := {} # index -> Role
|
|
var _active := {} # Role -> bool (per il disegno)
|
|
var _joy_origin := Vector2.ZERO
|
|
var _joy_knob := Vector2.ZERO
|
|
var _buttons := {} # Role -> {center: Vector2, radius: float, label: String}
|
|
var _size := Vector2(1280, 720)
|
|
|
|
func _ready() -> void:
|
|
layer = 3
|
|
# I tocchi non devono essere tradotti in eventi mouse: la posizione di fuoco
|
|
# e' gestita solo dall'area di puntamento, altrimenti la visuale si muove due volte.
|
|
Input.set_emulate_mouse_from_touch(false)
|
|
var root := Control.new()
|
|
root.name = "TouchRoot"
|
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
# IGNORE, non STOP: un Control a schermo intero che cattura i click
|
|
# impedirebbe al gate di avvio Web (in _unhandled_input) di vedere il primo
|
|
# tocco. Node._input riceve comunque tutti gli eventi e il consumo avviene
|
|
# esplicitamente con set_input_as_handled() sugli eventi gestiti.
|
|
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
# I metodi draw_* valgono solo durante il disegno: il segnale draw e' il punto giusto.
|
|
root.draw.connect(_on_draw)
|
|
add_child(root)
|
|
_root = root
|
|
var vp := get_viewport().get_visible_rect().size
|
|
_size = root.size if root.size.x > 1.0 else vp
|
|
root.resized.connect(_on_resized)
|
|
_layout()
|
|
root.queue_redraw()
|
|
|
|
var _root: Control
|
|
|
|
func _on_resized() -> void:
|
|
_size = _root.size if _root.size.x > 1.0 else get_viewport().get_visible_rect().size
|
|
_layout()
|
|
_root.queue_redraw()
|
|
|
|
func _on_draw() -> void:
|
|
var font: Font = _root.get_theme_default_font()
|
|
var fs := 15
|
|
# joystick
|
|
var base := _joy_base()
|
|
_root.draw_circle(base, JOY_RADIUS, COLOR_BASE)
|
|
_root.draw_arc(base, JOY_RADIUS, 0, TAU, 64, COLOR_RING, 2.0)
|
|
_root.draw_circle(base + _joy_knob, KNOB_RADIUS, COLOR_KNOB)
|
|
_root.draw_arc(base + _joy_knob, KNOB_RADIUS, 0, TAU, 48, COLOR_RING, 1.5)
|
|
_draw_label(font, fs, "MUOVI", base + Vector2(-JOY_RADIUS + 6, JOY_RADIUS + 22), COLOR_TEXT)
|
|
# pulsanti
|
|
for role in _buttons:
|
|
var b: Dictionary = _buttons[role]
|
|
var c: Vector2 = b.center
|
|
var r: float = b.radius
|
|
_root.draw_circle(c, r, COLOR_ACTIVE if _active.get(role, false) else COLOR_BTN)
|
|
_root.draw_arc(c, r, 0, TAU, 48, COLOR_RING, 2.0)
|
|
var text: String = b.label
|
|
var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
|
|
_draw_label(font, fs, text, c - Vector2(w * 0.5, -fs * 0.35), COLOR_TEXT)
|
|
|
|
func _draw_label(font: Font, fs: int, text: String, pos: Vector2, color: Color) -> void:
|
|
_root.draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, color)
|
|
|
|
## Riposiziona pulsanti e base del joystick in base alla dimensione della finestra.
|
|
func _layout() -> void:
|
|
var w := _size.x
|
|
var h := _size.y
|
|
var fire := Vector2(w - EDGE - BTN_FIRE_RADIUS, h - EDGE - BTN_FIRE_RADIUS)
|
|
var gap := BTN_FIRE_RADIUS + BTN_RADIUS - 6.0
|
|
_buttons = {
|
|
Role.FIRE: {"center": fire, "radius": BTN_FIRE_RADIUS, "label": "FUOCO"},
|
|
Role.GRENADE: {"center": fire + Vector2(0, -gap), "radius": BTN_RADIUS, "label": "GRANATA"},
|
|
Role.RELOAD: {"center": fire + Vector2(-gap, 0), "radius": BTN_RADIUS, "label": "Ricarica"},
|
|
Role.JUMP: {"center": fire + Vector2(-gap, -gap), "radius": BTN_RADIUS, "label": "Salto"},
|
|
}
|
|
|
|
func _joy_base() -> Vector2:
|
|
if _touches.values().has(Role.JOY):
|
|
return _joy_origin
|
|
return Vector2(EDGE + JOY_RADIUS, _size.y - EDGE - JOY_RADIUS)
|
|
|
|
# ---------------------------------------------------------------- input
|
|
func _input(event: InputEvent) -> void:
|
|
if not enabled:
|
|
return
|
|
var index := -2
|
|
var pressed := false
|
|
var pos := Vector2.ZERO
|
|
var relative := Vector2.ZERO
|
|
var is_press := false
|
|
var is_release := false
|
|
var is_drag := false
|
|
if event is InputEventScreenTouch:
|
|
index = event.index
|
|
pos = event.position
|
|
is_press = event.pressed
|
|
is_release = not event.pressed
|
|
elif event is InputEventScreenDrag:
|
|
index = event.index
|
|
pos = event.position
|
|
relative = event.screen_relative
|
|
is_drag = true
|
|
elif mouse_fallback and event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
|
index = MOUSE_INDEX
|
|
pos = event.position
|
|
is_press = event.pressed
|
|
is_release = not event.pressed
|
|
elif mouse_fallback and event is InputEventMouseMotion and _touches.has(MOUSE_INDEX):
|
|
index = MOUSE_INDEX
|
|
pos = event.position
|
|
relative = event.relative
|
|
is_drag = true
|
|
else:
|
|
return
|
|
if is_press:
|
|
_press(index, pos)
|
|
get_viewport().set_input_as_handled()
|
|
elif is_release:
|
|
_release(index)
|
|
get_viewport().set_input_as_handled()
|
|
elif is_drag:
|
|
_drag(index, pos, relative)
|
|
get_viewport().set_input_as_handled()
|
|
|
|
func _press(index: int, pos: Vector2) -> void:
|
|
if _touches.has(index):
|
|
return
|
|
var role := _hit_test(pos)
|
|
if role != Role.JOY and _touches.values().has(role):
|
|
return # un solo dito per ruolo
|
|
if role == Role.JOY and not _in_left_half(pos):
|
|
return
|
|
_touches[index] = role
|
|
_active[role] = true
|
|
match role:
|
|
Role.JOY:
|
|
_joy_origin = pos
|
|
_joy_knob = Vector2.ZERO
|
|
_apply_move()
|
|
Role.LOOK:
|
|
pass
|
|
Role.FIRE:
|
|
Input.action_press("shoot")
|
|
Role.GRENADE:
|
|
_send_action("grenade")
|
|
Role.RELOAD:
|
|
_send_action("reload")
|
|
Role.JUMP:
|
|
_send_action("jump")
|
|
_root.queue_redraw()
|
|
|
|
func _release(index: int) -> void:
|
|
if not _touches.has(index):
|
|
return
|
|
var role: Role = _touches[index]
|
|
_touches.erase(index)
|
|
_active.erase(role)
|
|
match role:
|
|
Role.JOY:
|
|
_joy_knob = Vector2.ZERO
|
|
move_vector = Vector2.ZERO
|
|
for action in MOVE_ACTIONS:
|
|
Input.action_release(action)
|
|
Role.FIRE:
|
|
Input.action_release("shoot")
|
|
_root.queue_redraw()
|
|
|
|
func _drag(index: int, pos: Vector2, relative: Vector2) -> void:
|
|
if not _touches.has(index):
|
|
# un drag iniziato fuori dai Control (raro su Web) viene adottato come visuale
|
|
if not _in_left_half(pos):
|
|
_touches[index] = Role.LOOK
|
|
_active[Role.LOOK] = true
|
|
else:
|
|
return
|
|
var role: Role = _touches[index]
|
|
match role:
|
|
Role.JOY:
|
|
_joy_knob = (pos - _joy_origin).limit_length(JOY_RADIUS)
|
|
_apply_move()
|
|
_root.queue_redraw()
|
|
Role.LOOK, Role.FIRE:
|
|
if player and player.has_method("apply_look"):
|
|
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).
|
|
func _apply_move() -> void:
|
|
var v := _joy_knob / JOY_RADIUS
|
|
if v.length() * JOY_RADIUS < DEAD_ZONE:
|
|
v = Vector2.ZERO
|
|
move_vector = v.limit_length(1.0)
|
|
_set_action("move_left", -move_vector.x)
|
|
_set_action("move_right", move_vector.x)
|
|
_set_action("move_forward", -move_vector.y)
|
|
_set_action("move_back", move_vector.y)
|
|
|
|
func _set_action(action: String, strength: float) -> void:
|
|
if strength > 0.01:
|
|
Input.action_press(action, minf(1.0, strength))
|
|
else:
|
|
Input.action_release(action)
|
|
|
|
## Azioni a pressione singola (granata, ricarica, salto). Il giocatore le legge
|
|
## a polling: premere lo stato dell'azione e rilasciarlo qualche frame dopo
|
|
## evita sia l'evento sintetico perso sia il vedere premuto e rilasciato
|
|
## nello stesso frame (is_action_just_pressed).
|
|
func _send_action(action: String) -> void:
|
|
Input.action_press(action)
|
|
get_tree().create_timer(0.08).timeout.connect(func():
|
|
Input.action_release(action))
|
|
|
|
func _in_left_half(pos: Vector2) -> bool:
|
|
return pos.x < _size.x * 0.5
|
|
|
|
## Ordine di priorita': pulsanti, poi meta' sinistra (movimento), poi visuale.
|
|
func _hit_test(pos: Vector2) -> Role:
|
|
var best := Role.LOOK
|
|
var best_dist := INF
|
|
for role in _buttons:
|
|
var b: Dictionary = _buttons[role]
|
|
var d: float = pos.distance_to(b.center)
|
|
if d <= b.radius + 6.0 and d < best_dist:
|
|
best = role
|
|
best_dist = d
|
|
if best != Role.LOOK:
|
|
return best
|
|
if _in_left_half(pos):
|
|
return Role.JOY
|
|
return Role.LOOK
|
|
|
|
# ------------------------------------------------- rilevamento del contesto
|
|
## True se il gioco gira su un dispositivo che merita i controlli on-screen.
|
|
## Documentazione: in un export Web OS.has_feature("mobile") e' false, valgono
|
|
## i tag web_android / web_ios.
|
|
static func should_auto_enable() -> bool:
|
|
if OS.has_feature("web_android") or OS.has_feature("web_ios"):
|
|
return true
|
|
if DisplayServer.is_touchscreen_available():
|
|
return true
|
|
return false
|
|
|
|
## Forzatura per test: 1 = attivo, -1 = disattivo, 0 = automatico.
|
|
## Riga di comando: --touch / --no-touch. Sul Web anche ?touch=1 / ?touch=0.
|
|
static func force_mode() -> int:
|
|
var args := OS.get_cmdline_user_args()
|
|
if "--touch" in args:
|
|
return 1
|
|
if "--no-touch" in args:
|
|
return -1
|
|
if OS.has_feature("web"):
|
|
var query := str(JavaScriptBridge.eval("window.location.search", true))
|
|
if query.contains("touch=1"):
|
|
return 1
|
|
if query.contains("touch=0"):
|
|
return -1
|
|
return 0
|