Explosion_der_Spiele/scripts/player/player_controller.gd

333 lines
10 KiB
GDScript3
Raw Normal View History

@tool
extends CharacterBody3D
class_name PlayerController
## Third-person controller + focus highlight / attack feedback.
## @tool: only applies billboard look in the editor (no input/camera capture).
@export var move_speed: float = 6.0
@export var attack_range: float = 2.75 ## Longer than enemy melee so you can poke before they hit
@export var interact_range: float = 2.8
@export var attack_damage: int = 1
@export var mouse_sensitivity: float = 0.0025
@export var min_pitch_deg: float = -35.0
@export var max_pitch_deg: float = 55.0
@export var spring_length: float = 6.0
@export var camera_height: float = 1.62
signal interacted
signal attacked
signal focus_changed(target: Node, prompt: String)
@onready var _billboard: BillboardSprite = $BillboardSprite
@onready var _yaw_pivot: Node3D = $CameraYaw
@onready var _spring: SpringArm3D = $CameraYaw/SpringArm3D
@onready var _camera: Camera3D = $CameraYaw/SpringArm3D/Camera3D
var _locked: bool = false
var _pitch: float = deg_to_rad(-18.0)
var _focus: Node = null
var _attack_focus: Node = null
var _melee_vfx: MeleeVfx
func _ready() -> void:
_apply_editor_billboard()
if Engine.is_editor_hint():
set_physics_process(false)
set_process(false)
set_process_unhandled_input(false)
return
add_to_group("player")
camera_height = SpriteFit.WORLD_CAMERA_EYE
if _spring:
_spring.spring_length = spring_length
_spring.collision_mask = 1
_spring.position = Vector3(0.0, camera_height, 0.0)
if _camera:
_camera.current = true
_camera.fov = 70.0
_apply_pitch()
# Scene load may already have opened a blocking VN (pause + visible mouse).
# Capturing here would steal the cursor from Weiter / dialog UI.
var ui0 := get_node_or_null("/root/UIRoot")
if ui0 and ui0.has_method("is_dialog_open") and ui0.is_dialog_open():
_set_mouse_captured(false)
else:
_set_mouse_captured(true)
_apply_editor_billboard()
_melee_vfx = MeleeVfx.new()
_melee_vfx.name = "MeleeVfx"
add_child(_melee_vfx)
_melee_vfx.set_ranges(attack_range, interact_range)
func _apply_editor_billboard() -> void:
if _billboard == null:
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
if _billboard == null:
return
_billboard.max_width = SpriteFit.WORLD_MAX_WIDTH
_billboard.set_target_height(SpriteFit.WORLD_PLAYER_HEIGHT)
if _billboard.texture == null:
var tex: Texture2D = load("res://assets/sprites/placeholders/player.png")
if tex:
_billboard.set_texture(tex)
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_clear_focus_highlights()
_set_mouse_captured(false)
func _unhandled_input(event: InputEvent) -> void:
if Engine.is_editor_hint() or _locked or get_tree().paused:
return
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var motion := event as InputEventMouseMotion
_yaw_pivot.rotate_y(-motion.relative.x * mouse_sensitivity)
_pitch = clampf(
_pitch - motion.relative.y * mouse_sensitivity,
deg_to_rad(min_pitch_deg),
deg_to_rad(max_pitch_deg)
)
_apply_pitch()
elif event is InputEventMouseButton and event.pressed:
_set_mouse_captured(true)
func _physics_process(_delta: float) -> void:
if _locked or get_tree().paused:
velocity = Vector3.ZERO
_update_focus()
return
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
velocity = Vector3.ZERO
_update_focus()
return
var input := Vector2(
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_up", "move_down")
)
var basis_yaw := _yaw_pivot.global_transform.basis
var forward := -basis_yaw.z
forward.y = 0.0
forward = forward.normalized()
var right := basis_yaw.x
right.y = 0.0
right = right.normalized()
var dir := (right * input.x) + (forward * -input.y)
if dir.length() > 1.0:
dir = dir.normalized()
var speed := move_speed
if GameState.is_fat():
speed *= 0.15
elif GameState.is_glitched_slow():
speed *= 0.55
if GameState.is_shocked():
velocity = Vector3.ZERO
_update_focus()
return
velocity = dir * speed
move_and_slide()
_update_focus()
if Input.is_action_just_pressed("interact"):
_try_interact()
if Input.is_action_just_pressed("attack"):
_try_attack()
func lock_input(locked: bool) -> void:
_locked = locked
if locked:
_set_mouse_captured(false)
elif not get_tree().paused:
_set_mouse_captured(true)
func set_gameplay_focused(focused: bool) -> void:
if focused and not _locked:
_set_mouse_captured(true)
else:
_set_mouse_captured(false)
func _apply_pitch() -> void:
if _spring == null:
return
_spring.rotation.x = _pitch
func _set_mouse_captured(captured: bool) -> void:
if captured:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
else:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _interact_distance_to(body: Node3D) -> float:
if body.has_method("get_interact_distance"):
return float(body.call("get_interact_distance", global_position))
return global_position.distance_to(body.global_position)
func _nearest_in_group(group: String, radius: float) -> Node:
var best: Node = null
var best_d := radius
for body in get_tree().get_nodes_in_group(group):
if body == self or not (body is Node3D):
continue
if not is_instance_valid(body):
continue
var d: float = _interact_distance_to(body as Node3D)
if d <= best_d:
best_d = d
best = body
return best
func _update_focus() -> void:
if get_tree().paused or _locked or GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene"]:
_set_focus(null, null)
return
var interact := _nearest_in_group("interactable", interact_range)
if interact != null and interact.is_in_group("enemy"):
var talk := _nearest_talk_target(interact_range)
if talk != null:
interact = talk
var attack := _nearest_in_group("enemy", attack_range)
_set_focus(interact, attack)
func _nearest_talk_target(radius: float) -> Node:
var best: Node = null
var best_d := radius
for body in get_tree().get_nodes_in_group("interactable"):
if body == self or not (body is Node3D):
continue
if body.is_in_group("enemy"):
continue
var d: float = _interact_distance_to(body as Node3D)
if d <= best_d:
best_d = d
best = body
return best
func _set_focus(interact: Node, attack: Node) -> void:
if interact == _focus and attack == _attack_focus:
_emit_prompt()
return
_clear_focus_highlights()
_focus = interact
_attack_focus = attack
if _focus != null and _focus.has_method("set_interaction_highlight"):
_focus.set_interaction_highlight(true, Color(1.4, 1.35, 0.4))
elif _focus != null:
_highlight_node(_focus, true, Color(1.4, 1.35, 0.4))
if _attack_focus != null and _attack_focus != _focus:
if _attack_focus.has_method("set_interaction_highlight"):
_attack_focus.set_interaction_highlight(true, Color(1.5, 0.45, 0.35))
else:
_highlight_node(_attack_focus, true, Color(1.5, 0.45, 0.35))
_emit_prompt()
func _emit_prompt() -> void:
## Interact stays as text prompt; attack/range is visual (rings + swing), not text.
var prompt := ""
if _focus != null:
if _focus.has_method("get_interact_prompt"):
prompt = str(_focus.get_interact_prompt())
else:
prompt = Locale.tr_key("prompt_use")
var exit_hint := _near_exit_hint()
if exit_hint != "" and prompt == "":
prompt = exit_hint
elif exit_hint != "" and prompt != "":
prompt = "%s %s" % [prompt, exit_hint]
if _melee_vfx:
_melee_vfx.show_attack_range(_attack_focus != null)
_melee_vfx.show_interact_range(_focus != null)
focus_changed.emit(_focus, prompt)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("set_interact_prompt"):
ui.set_interact_prompt(prompt)
func _near_exit_hint() -> String:
for body in get_tree().get_nodes_in_group("exit_trigger"):
if body is Node3D and global_position.distance_to((body as Node3D).global_position) <= 3.5:
return Locale.tr_key("prompt_exit")
return ""
func _clear_focus_highlights() -> void:
if _focus != null and is_instance_valid(_focus):
if _focus.has_method("set_interaction_highlight"):
_focus.set_interaction_highlight(false)
else:
_highlight_node(_focus, false)
if _attack_focus != null and is_instance_valid(_attack_focus) and _attack_focus != _focus:
if _attack_focus.has_method("set_interaction_highlight"):
_attack_focus.set_interaction_highlight(false)
else:
_highlight_node(_attack_focus, false)
func _highlight_node(node: Node, enabled: bool, color: Color = Color(1.35, 1.25, 0.35)) -> void:
var bill = node.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_highlight"):
bill.set_highlight(enabled, color)
func _try_interact() -> void:
interacted.emit()
var target := _focus
if target == null:
target = _nearest_talk_target(interact_range)
if target == null:
target = _nearest_in_group("interactable", interact_range)
if target != null and target.has_method("on_interact"):
_flash_feedback(target, Color(1.6, 1.6, 0.6))
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("pulse_interact"):
ui.pulse_interact()
target.on_interact(self)
func _try_attack() -> void:
attacked.emit()
AudioDirector.play_hit()
if _melee_vfx:
_melee_vfx.play_swing()
if _billboard and _billboard.has_method("flash"):
_billboard.flash(Color(2.2, 2.0, 1.4), 0.1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("pulse_attack"):
ui.pulse_attack()
var dmg := attack_damage
if Inventory.has_item("tooth"):
dmg = max(dmg, 2)
if Inventory.has_item("moon_sword"):
dmg = max(dmg, 99)
if Inventory.has_item("wartchen"):
dmg = max(dmg, 3)
var hit_any := false
var targets: Array = []
if _attack_focus != null:
targets.append(_attack_focus)
for body in get_tree().get_nodes_in_group("enemy"):
if body in targets:
continue
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
targets.append(body)
for body in targets:
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
if body.has_method("take_hit"):
body.take_hit(dmg)
_flash_feedback(body, Color(2.0, 0.5, 0.4))
if _melee_vfx:
_melee_vfx.burst_at((body as Node3D).global_position, true)
hit_any = true
if not hit_any:
# Miss puff at swing tip in front of player
var tip := global_position + (-_yaw_pivot.global_transform.basis.z) * attack_range
tip.y = global_position.y
if _melee_vfx:
_melee_vfx.burst_at(tip, false)
func _flash_feedback(node: Node, color: Color) -> void:
var bill = node.get_node_or_null("BillboardSprite")
if bill and bill.has_method("flash"):
bill.flash(color, 0.15)