@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)