- project.godot: joypad events on the ten existing actions (left stick move, A jump, right trigger/RB fire, X reload, B/LB grenade, Start pause, Back fullscreen), four new look_* actions on the right stick (deadzone 0.2) and ui_accept/ui_cancel redefined with the pad buttons A/B while keeping Enter/KP Enter/Space/Esc: the 4.7.1 engine maps no pad button on those. - scripts/player.gd: gamepad_mode turns on by itself on the first pad input; _can_aim() replaces the "mouse captured" gate of shoot and grenade (the pad never captures the pointer, same trap already fixed for touch); _handle_gamepad_look() converts the right stick into equivalent relative mouse pixels (900 px/s at full deflection, m^1.8 response) and still goes through apply_look(); restart accepts A/X and the death text follows the device in use. - scripts/start_screen.gd: pad hint shown only when a controller is connected, refreshed on Input.joy_connection_changed. - tools/test_gamepad.gd: 33 checks on map structure, keyboard preservation, real event pipeline, gamepad_mode and analog look. - docs/GAMEPAD.md: mapping, code map, verification, known limit (kiosk name entry still needs a keyboard: on-screen keyboard is the next step). Verified: test_gamepad 33/33, test_fullscreen_shortcut 8/8, test_touch_controls 14/14 (--touch), test_grenade 24/24, test_boss 50/50, test_rebalance_branding 14/14, test_player_profile 30/30. test_start_screen keeps its 2 pre-existing headless failures (identical on HEAD).
355 lines
14 KiB
GDScript
355 lines
14 KiB
GDScript
extends CharacterBody3D
|
|
signal died
|
|
var camera: Camera3D
|
|
var damage_pivot: Node3D
|
|
var damage_feedback: CanvasLayer
|
|
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
|
|
const MAX_HP := 100.0
|
|
const BULLET_DAMAGE := 17.5
|
|
var hp := MAX_HP
|
|
var invulnerable := false # only enabled by the explicit native boss simulation flag
|
|
var _regen_elapsed := 0.0
|
|
|
|
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
|
|
func _regenerate_health(dt: float) -> void:
|
|
if hp <= 0 or death_started or hp >= MAX_HP:
|
|
_regen_elapsed = 0.0
|
|
return
|
|
_regen_elapsed += dt
|
|
while _regen_elapsed >= 1.0:
|
|
_regen_elapsed -= 1.0
|
|
hp = minf(MAX_HP, hp + 1.0)
|
|
var ammo := 30
|
|
var cooldown := 0.0
|
|
var grenade_cooldown := 0.0
|
|
const GRENADE_COOLDOWN := 15.0
|
|
const Grenade = preload("res://scripts/grenade.gd")
|
|
var reloading := false
|
|
var gun: Node3D
|
|
var flash: OmniLight3D
|
|
var weapon_anim: AnimationPlayer
|
|
var step_timer := 0.0
|
|
var death_started := false
|
|
var death_overlay: ColorRect
|
|
## True quando la partita e' guidata dai controlli on-screen (touch): il mouse
|
|
## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito.
|
|
var touch_mode := false
|
|
## True quando l'ultimo input utile arriva da un gamepad. Il pad non cattura il
|
|
## puntatore, quindi serve un flag esplicito: senza, sparo e granata resterebbero
|
|
## bloccati dal gate "mouse catturato" (la stessa trappola gia' corretta per il touch).
|
|
var gamepad_mode := false
|
|
## Punti-equivalenti di mouse al secondo con lo stick destro a fondo corsa: 900 px/s
|
|
## per gli 0.0025 rad/px di apply_look danno 2.25 rad/s (~129 gradi/s).
|
|
const GAMEPAD_LOOK_SPEED := 900.0
|
|
## Risposta dello stick: con 1.8 i piccoli spostamenti restano fini per mirare e il
|
|
## fondo corsa continua a girare veloce.
|
|
const GAMEPAD_LOOK_EXPONENT := 1.8
|
|
## Oltre questa ampiezza un movimento di stick conta come "sto usando il pad".
|
|
const GAMEPAD_ACTIVITY := 0.3
|
|
## Vero solo dopo l'inizio della partita: al menu il cursore deve restare libero,
|
|
## altrimenti la schermata iniziale non e' cliccabile.
|
|
var playing := false
|
|
const Blood = preload("res://scripts/blood_effects.gd")
|
|
const Audio = preload("res://scripts/hl_audio.gd")
|
|
|
|
func _ready() -> void:
|
|
collision_layer = 2
|
|
collision_mask = 1 | 4
|
|
var cs := CollisionShape3D.new()
|
|
var cap := CapsuleShape3D.new()
|
|
cap.height = 1.8
|
|
cap.radius = 0.35
|
|
cs.shape = cap
|
|
cs.position.y = 0.9
|
|
add_child(cs)
|
|
camera = Camera3D.new()
|
|
camera.position.y = 1.65
|
|
camera.current = true
|
|
camera.fov = 80
|
|
damage_pivot = Node3D.new()
|
|
damage_pivot.name = "DamagePivot"
|
|
add_child(damage_pivot)
|
|
damage_pivot.add_child(camera)
|
|
damage_feedback = CanvasLayer.new()
|
|
damage_feedback.set_script(DamageFeedback)
|
|
damage_feedback.pivot = damage_pivot
|
|
add_child(damage_feedback)
|
|
gun = Node3D.new()
|
|
camera.add_child(gun)
|
|
var viewmodel: Node3D = load("res://assets/weapons/v_9mmar.scn").instantiate()
|
|
gun.add_child(viewmodel)
|
|
for area in viewmodel.find_children("*", "Area3D", true, false):
|
|
area.collision_layer = 0
|
|
area.collision_mask = 0
|
|
for mesh in viewmodel.find_children("*", "MeshInstance3D", true, false):
|
|
mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
mesh.layers = 2
|
|
weapon_anim = viewmodel.get_node("AnimationPlayer")
|
|
weapon_anim.animation_finished.connect(func(_name: StringName):
|
|
if not reloading and not death_started:
|
|
weapon_anim.play("idle1"))
|
|
weapon_anim.play("deploy")
|
|
flash = OmniLight3D.new()
|
|
flash.position = Vector3(0.28, -0.2, -1.1)
|
|
flash.light_color = Color(1, 0.7, 0.25)
|
|
flash.omni_range = 7
|
|
flash.light_energy = 0
|
|
camera.add_child(flash)
|
|
# Al menu il cursore deve essere visibile e libero: la cattura avviene solo quando
|
|
# la partita inizia (begin_play(), chiamata da main.gd::start_game()).
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
|
|
var restart_button: Button
|
|
var death_note: Label
|
|
|
|
## Inizio partita: qui, e solo qui, il pointer viene catturato. In modalita' touch il
|
|
## cursore serve ai controlli on-screen e il mirino e' emulato dal dito, quindi non
|
|
## viene mai catturato.
|
|
func begin_play() -> void:
|
|
playing = true
|
|
if not touch_mode and not gamepad_mode and DisplayServer.get_name() != "headless":
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
|
|
## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o
|
|
## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio.
|
|
func set_death_note(text: String) -> void:
|
|
if is_instance_valid(death_note):
|
|
death_note.text = text
|
|
death_note.modulate.a = 1.0
|
|
|
|
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")
|
|
|
|
## Riconosce l'attivita' del pad (pulsante premuto o stick fuori dalla zona morta):
|
|
## cosi' il gioco passa da mouse a gamepad senza nessuna configurazione.
|
|
func _is_gamepad_activity(event: InputEvent) -> bool:
|
|
if event is InputEventJoypadButton:
|
|
return event.pressed
|
|
if event is InputEventJoypadMotion:
|
|
return absf(event.axis_value) > GAMEPAD_ACTIVITY
|
|
return false
|
|
|
|
## Il giocatore puo' mirare/sparare quando il puntatore e' catturato, oppure con touch
|
|
## o gamepad, che non catturano nulla.
|
|
func _can_aim() -> bool:
|
|
return touch_mode or gamepad_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if _is_gamepad_activity(event):
|
|
gamepad_mode = true
|
|
if playing and 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
|
|
if event is InputEventMouseMotion and not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
apply_look(event.relative)
|
|
# Con il pad il puntatore non viene catturato: il toggle del mouse non ha senso.
|
|
if playing and event.is_action_pressed("pause") and not death_started and not gamepad_mode:
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
|
|
|
|
## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
|
|
func start_reload() -> void:
|
|
if reloading or death_started or hp <= 0 or ammo >= 30:
|
|
return
|
|
reloading = true
|
|
weapon_anim.play("reload")
|
|
Audio.play(self, "items/cliprelease1.wav")
|
|
get_tree().create_timer(0.8).timeout.connect(func():
|
|
if not death_started:
|
|
Audio.play(self, "items/clipinsert1.wav"))
|
|
get_tree().create_timer(1.57).timeout.connect(func():
|
|
if not death_started:
|
|
ammo = 30
|
|
reloading = false
|
|
weapon_anim.play("idle1"))
|
|
|
|
## Azioni a pressione singola lette a polling: stesso percorso per tastiera,
|
|
## joypad e pulsanti on-screen. Gli InputEventAction sintetici, generati dentro
|
|
## il dispatch di un altro evento (come fa un tocco), non arrivano affidabilmente
|
|
## a _unhandled_input; lo stato delle azioni invece si'.
|
|
func _handle_actions() -> void:
|
|
if Input.is_action_just_pressed("reload") and not reloading and ammo < 30:
|
|
start_reload()
|
|
if Input.is_action_just_pressed("grenade") and not death_started and _can_aim():
|
|
throw_grenade()
|
|
|
|
## Rotazione della visuale. Unico punto in cui vivono sensibilita' e limiti di
|
|
## inclinazione, condiviso da mouse e controlli touch.
|
|
func apply_look(relative: Vector2) -> void:
|
|
if hp <= 0 or death_started:
|
|
return
|
|
rotate_y(-relative.x * 0.0025)
|
|
camera.rotation.x = clampf(camera.rotation.x - relative.y * 0.0025, -1.4, 1.4)
|
|
|
|
## Visuale analogica: il mouse arriva come InputEventMouseMotion relativo, lo stick
|
|
## destro come azioni. Qui lo stick diventa "pixel relativi equivalenti" e passa ad
|
|
## apply_look(), che resta l'unico posto con sensibilita' e limiti di inclinazione.
|
|
## La curva viene applicata sulla sola ampiezza, quindi la direzione resta esatta.
|
|
func _handle_gamepad_look(dt: float) -> void:
|
|
if not gamepad_mode or not playing or touch_mode:
|
|
return
|
|
if hp <= 0 or death_started:
|
|
return
|
|
# Deadzone esplicita 0.2, la stessa del movimento.
|
|
var look := Input.get_vector("look_left", "look_right", "look_up", "look_down", 0.2)
|
|
if look == Vector2.ZERO:
|
|
return
|
|
var magnitude := clampf(look.length(), 0.0, 1.0)
|
|
var shaped := look.normalized() * pow(magnitude, GAMEPAD_LOOK_EXPONENT)
|
|
apply_look(shaped * GAMEPAD_LOOK_SPEED * dt)
|
|
|
|
func _physics_process(dt: float) -> void:
|
|
cooldown -= dt
|
|
grenade_cooldown = maxf(0, grenade_cooldown - dt)
|
|
flash.light_energy = maxf(0, flash.light_energy - dt * 60)
|
|
gun.position.z = lerpf(gun.position.z, 0, dt * 15)
|
|
if hp <= 0:
|
|
# R (tastiera) oppure X/A del pad: stessi tempi, nessun menu da navigare.
|
|
if Input.is_action_just_pressed("reload") or Input.is_action_just_pressed("jump"):
|
|
get_tree().reload_current_scene() # R, X/A del pad o pulsante Ricarica a partita finita
|
|
return
|
|
_regenerate_health(dt)
|
|
if ammo <= 0 and not reloading:
|
|
start_reload()
|
|
_handle_actions()
|
|
_handle_gamepad_look(dt)
|
|
# Deadzone esplicita piccola (0.2): con la 0.5 delle azioni la corsa utile del
|
|
# joystick touch si dimezzerebbe, mentre per tastiera/joypad nulla cambia.
|
|
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back", 0.2)
|
|
var direction := transform.basis * Vector3(input.x, 0, input.y)
|
|
velocity.x = direction.x * 5
|
|
velocity.z = direction.z * 5
|
|
velocity.y -= 20 * dt
|
|
if is_on_floor() and Input.is_action_just_pressed("jump"):
|
|
velocity.y = 7
|
|
move_and_slide()
|
|
step_timer -= dt
|
|
if is_on_floor() and direction.length() > 0.1 and step_timer <= 0:
|
|
step_timer = 0.42
|
|
Audio.play(self, "player/pl_metal%d.wav" % randi_range(1, 4), false, -18)
|
|
if Input.is_action_just_pressed("shoot") and ammo <= 0 and not reloading:
|
|
Audio.play(self, "weapons/dryfire1.wav")
|
|
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and _can_aim():
|
|
shoot()
|
|
|
|
func shoot() -> void:
|
|
if ammo <= 0 or reloading or hp <= 0:
|
|
return
|
|
weapon_anim.stop()
|
|
weapon_anim.play("shoot")
|
|
Audio.play(self, "weapons/hks%d.wav" % randi_range(1, 3), false, -13)
|
|
ammo -= 1
|
|
cooldown = 0.13
|
|
flash.light_energy = 3
|
|
gun.position.z = 0.06
|
|
var start := camera.global_position
|
|
var query := PhysicsRayQueryParameters3D.create(start, start - camera.global_basis.z * 80, 1 | 4)
|
|
var hit := get_world_3d().direct_space_state.intersect_ray(query)
|
|
if hit and hit.collider.has_method("take_damage"):
|
|
Blood.impact(get_parent(), hit.position, -camera.global_basis.z)
|
|
hit.collider.take_damage(BULLET_DAMAGE, hit.position.y)
|
|
if ammo <= 0:
|
|
start_reload()
|
|
|
|
func throw_grenade() -> RigidBody3D:
|
|
if death_started or hp <= 0 or grenade_cooldown > 0:
|
|
return null
|
|
var world := get_parent() as Node3D
|
|
var forward := -camera.global_basis.z
|
|
var start := camera.global_position
|
|
var destination := start + forward * 0.55
|
|
# Do not spawn through the wall when the camera is close to it.
|
|
var query := PhysicsRayQueryParameters3D.create(start, destination, 1)
|
|
var hit := get_world_3d().direct_space_state.intersect_ray(query)
|
|
if not hit.is_empty():
|
|
destination = start + forward * maxf(0, start.distance_to(hit.position) - 0.16)
|
|
var grenade := RigidBody3D.new()
|
|
grenade.set_script(Grenade)
|
|
grenade.thrower = self
|
|
grenade.position = world.to_local(destination)
|
|
world.add_child(grenade)
|
|
grenade.linear_velocity = forward * 12 + Vector3.UP * 3 + velocity
|
|
grenade.angular_velocity = Vector3(5, 3, 2)
|
|
grenade_cooldown = GRENADE_COOLDOWN
|
|
return grenade
|
|
|
|
func take_damage(amount: float) -> void:
|
|
if death_started or invulnerable or amount <= 0:
|
|
return
|
|
hp = maxf(0, hp - amount)
|
|
if hp <= 0:
|
|
_die()
|
|
else:
|
|
damage_feedback.hit(amount)
|
|
|
|
func _die() -> void:
|
|
death_started = true
|
|
damage_feedback.stop()
|
|
velocity = Vector3.ZERO
|
|
reloading = false
|
|
gun.hide()
|
|
flash.light_energy = 0
|
|
weapon_anim.stop()
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
# Original Half-Life player pain sample used as the death event vocal.
|
|
Audio.play(self, "player/pl_pain7.wav", false, -8)
|
|
Audio.play(self, "fvox/flatline.wav", false, -9)
|
|
var layer := CanvasLayer.new()
|
|
layer.layer = 5
|
|
add_child(layer)
|
|
death_overlay = ColorRect.new()
|
|
death_overlay.color = Color(0, 0, 0, 0)
|
|
death_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
layer.add_child(death_overlay)
|
|
death_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
var tween := create_tween()
|
|
tween.set_parallel(true)
|
|
tween.tween_property(camera, "position:y", 0.22, 0.9).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
|
tween.tween_property(camera, "rotation:z", deg_to_rad(-75), 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)
|
|
var message := Label.new()
|
|
message.text = "SEI MORTO" if touch_mode else (
|
|
"SEI MORTO\nPremi A o X per ricominciare" if gamepad_mode
|
|
else "SEI MORTO\nPremi R per ricominciare")
|
|
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
message.add_theme_font_size_override("font_size", 36)
|
|
message.modulate.a = 0
|
|
layer.add_child(message)
|
|
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
|
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2)
|
|
death_note = Label.new()
|
|
death_note.text = ""
|
|
death_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
death_note.add_theme_font_size_override("font_size", 22)
|
|
death_note.add_theme_color_override("font_color", Color(1.0, 0.66, 0.16))
|
|
death_note.modulate.a = 0
|
|
layer.add_child(death_note)
|
|
death_note.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
|
death_note.offset_top = 60
|
|
death_note.offset_bottom = 100
|
|
death_note.offset_left = -420
|
|
death_note.offset_right = 420
|
|
tween.tween_property(death_note, "modulate:a", 1.0, 0.5).set_delay(2.6)
|
|
died.emit()
|
|
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())
|