Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413). Co-authored-by: Cursor <cursoragent@cursor.com>
332 lines
10 KiB
GDScript
332 lines
10 KiB
GDScript
@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)
|