class_name Player extends Character signal combo_window_changed(slots: Array) signal combo_window_cleared(reason: String) signal charge_changed(current: float, maximum: float, ready: bool, active: bool) signal energy_changed(current: int, maximum: int) signal health_changed(current: int, maximum: int) signal skill_requested(skill_id: String) @export var combo_clear_display_time := 0.35 @export var charge_duration := 1.1 @export var max_health := 100 @export var current_health := 100 @export var max_energy := 10 @export var current_energy := 0 var combo_window := ComboWindow.new() var last_requested_skill_id := "" var current_skill_animation := "" var combo_clear_timer: Timer var charge_value := 0.0 var charge_ready := false var charge_active := false var _pending_combo_clear_reason := "" var _charge_effect_time := 0.0 var _charge_hold_symbol := "" var _charge_hold_direction := Vector2.ZERO func _ready() -> void: combo_clear_timer = Timer.new() combo_clear_timer.one_shot = true combo_clear_timer.wait_time = combo_clear_display_time combo_clear_timer.timeout.connect(flush_pending_combo_clear) add_child(combo_clear_timer) combo_window.window_cleared.connect(_on_combo_window_cleared) _emit_combo_window_changed() _emit_charge_changed() _emit_health_changed() _emit_energy_changed() func _process(delta: float) -> void: _update_charge(delta) func _input(event: InputEvent) -> void: if _handle_combo_key_event(event): _mark_input_handled() func _unhandled_input(event: InputEvent) -> void: if _handle_combo_key_event(event): _mark_input_handled() func _handle_combo_key_event(event: InputEvent) -> bool: var key_event := event as InputEventKey if key_event == null or key_event.echo: return false if not key_event.pressed: if _event_matches_key(key_event, KEY_A): _finish_charge_hold("A") return true elif _event_matches_key(key_event, KEY_D): _finish_charge_hold("D") return true return false if _event_matches_key(key_event, KEY_W): _submit_combo_input_from_event("W") return true elif _event_matches_key(key_event, KEY_A): heading = Vector2.LEFT var skill_id := _submit_combo_input_from_event("A") if skill_id == "skill_a": _begin_charge_hold("A", Vector2.LEFT) return true elif _event_matches_key(key_event, KEY_D): heading = Vector2.RIGHT var skill_id := _submit_combo_input_from_event("D") if skill_id == "skill_d": _begin_charge_hold("D", Vector2.RIGHT) return true elif _event_matches_key(key_event, KEY_S): _submit_combo_input_from_event("S") return true elif _event_matches_key(key_event, KEY_SPACE): _submit_combo_input_from_event("SP") return true return false func handle_input() -> void: if charge_active: velocity = Vector2.ZERO return _apply_horizontal_movement() if Input.is_action_just_pressed("jump"): judge_rhythm_action("jump") if can_jump(): start_jump() func set_heading() -> void: if velocity.x > 0.0: heading = Vector2.RIGHT elif velocity.x < 0.0: heading = Vector2.LEFT func get_combo_slots() -> Array[String]: return combo_window.get_slots() func get_energy() -> int: return current_energy func get_max_energy() -> int: return max_energy func get_health() -> int: return current_health func get_max_health() -> int: return max_health func get_charge() -> float: return charge_value func get_max_charge() -> float: return charge_duration func is_charge_active() -> bool: return charge_active func is_charge_ready() -> bool: return charge_ready func submit_combo_input(symbol: String, forced_rating := "") -> String: match symbol: "W": return _record_combo_symbol("W", "w", forced_rating) "A": return _record_combo_symbol("A", "a", forced_rating) "D": return _record_combo_symbol("D", "d", forced_rating) "S": return _record_combo_symbol("S", "s", forced_rating) "SP": return _record_combo_symbol("SP", "space", forced_rating) return "" func _record_combo_symbol(symbol: String, rhythm_action: String, forced_rating := "") -> String: var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating) _apply_energy_reward(str(rating.get("label", "perfect"))) if not _record_rated_combo_input(symbol, rating): if symbol == "A" or symbol == "D": _cancel_missed_direction_action() return "" var resolved := InputResolver.resolve(combo_window) if resolved.is_empty() and _pending_combo_clear_reason == "full": resolved = _resolve_full_window_fallback(symbol) if not resolved.is_empty(): if not _execute_combo_skill(resolved): return "" if symbol == "SP" and not _is_projectile_space_chain() and _pending_combo_clear_reason.is_empty(): _schedule_combo_clear("space") return last_requested_skill_id if not resolved.is_empty() else "" func _submit_combo_input_from_event(symbol: String) -> String: return submit_combo_input(symbol) func _event_matches_key(event: InputEventKey, key: Key) -> bool: return event.physical_keycode == key or event.keycode == key func _event_matches_any_key(event: InputEventKey, keys: Array[Key]) -> bool: for key: Key in keys: if _event_matches_key(event, key): return true return false func _mark_input_handled() -> void: var viewport := get_viewport() if viewport != null: viewport.set_input_as_handled() func _record_combo_direction(symbol: String, direction: Vector2, rhythm_action: String, forced_rating := "") -> void: heading = direction var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating) _record_rated_combo_input(symbol, rating) if state == State.IDLE or state == State.WALK: velocity.x = 0.0 func _record_rated_combo_input(symbol: String, rating: Dictionary) -> bool: if str(rating.get("label", "perfect")) == "miss": _record_combo_input("Ø") return false _record_combo_input(symbol) return true func _record_combo_input(symbol: String) -> void: if combo_window.has_pending_clear() or not _pending_combo_clear_reason.is_empty(): if _pending_combo_clear_reason.begins_with("skill:") and not combo_window.has_pending_clear(): _cancel_pending_combo_clear() else: flush_pending_combo_clear() combo_window.record(symbol) _emit_combo_window_changed() var reason := combo_window.consume_pending_clear_reason() if not reason.is_empty(): _schedule_combo_clear(reason) func _rating_or_forced(rating: Dictionary, forced_rating: String) -> Dictionary: if forced_rating.is_empty(): return rating var forced := rating.duplicate() forced["label"] = forced_rating return forced func _execute_combo_skill(skill: Dictionary) -> bool: if not _spend_skill_energy(skill): return false last_requested_skill_id = str(skill.get("id", "")) current_skill_animation = str(skill.get("animation", "warrior_idle")) skill_requested.emit(last_requested_skill_id) judge_rhythm_action(last_requested_skill_id) _play_skill_animation(current_skill_animation, _skill_displacement_direction(skill)) if bool(skill.get("projectile", false)): _fire_projectile() _emit_combo_window_changed() if bool(skill.get("clear_window", false)): _schedule_combo_clear("skill:%s" % last_requested_skill_id) return true func _play_skill_animation(animation_name: String, displacement_direction := Vector2.ZERO) -> void: var player_animation := _get_animation_player() anim_map[State.ATTACK] = animation_name state = State.ATTACK attack_time_left = _animation_length(animation_name) if displacement_direction == Vector2.ZERO: attack_lunge_time_left = 0.0 velocity = Vector2.ZERO else: heading = displacement_direction attack_lunge_time_left = attack_lunge_duration velocity = Vector2(displacement_direction.x * attack_lunge_speed, 0.0) if player_animation != null and player_animation.has_animation(animation_name): player_animation.play(animation_name) func _skill_displacement_direction(skill: Dictionary) -> Vector2: match str(skill.get("displacement", "")): "left": return Vector2.LEFT "right": return Vector2.RIGHT return Vector2.ZERO func _apply_energy_reward(rating_label: String) -> void: match rating_label: "perfect": _change_energy(2) "good": _change_energy(1) func _spend_skill_energy(skill: Dictionary) -> bool: var energy_cost := int(skill.get("energy_cost", 0)) if energy_cost <= 0: return true if current_energy < energy_cost: return false _change_energy(-energy_cost) return true func _change_energy(delta: int) -> void: var next_energy := clampi(current_energy + delta, 0, max_energy) if next_energy == current_energy: return current_energy = next_energy _emit_energy_changed() func _begin_charge_hold(symbol: String, direction: Vector2) -> void: _charge_hold_symbol = symbol _charge_hold_direction = direction func _finish_charge_hold(symbol: String) -> void: if _charge_hold_symbol != symbol: return var release_ready := charge_active and charge_ready var release_direction := _charge_hold_direction var release_skill := "skill_a_charge_release" if symbol == "A" else "skill_d_charge_release" _cancel_charge() if release_ready: _execute_charge_release(release_skill, release_direction) func _update_charge(delta: float) -> void: if _charge_hold_symbol.is_empty(): return if not charge_active: if state != State.IDLE: return _start_charge() if not charge_active: return state = State.IDLE attack_time_left = 0.0 attack_lunge_time_left = 0.0 velocity = Vector2.ZERO var player_animation := _get_animation_player() if player_animation != null and player_animation.has_animation("warrior_idle") and player_animation.current_animation != "warrior_idle": player_animation.play("warrior_idle") charge_value = minf(charge_duration, charge_value + delta) charge_ready = charge_value >= charge_duration _update_charge_effect(delta) _emit_charge_changed() func _start_charge() -> void: charge_active = true charge_value = 0.0 charge_ready = false _charge_effect_time = 0.0 _update_charge_effect(0.0) _emit_charge_changed() func _cancel_charge() -> void: _charge_hold_symbol = "" _charge_hold_direction = Vector2.ZERO charge_active = false charge_value = 0.0 charge_ready = false _set_charge_effect_visible(false) _emit_charge_changed() func _execute_charge_release(skill_id: String, direction: Vector2) -> void: last_requested_skill_id = skill_id current_skill_animation = "warrior_charge_release" skill_requested.emit(last_requested_skill_id) _play_skill_animation(current_skill_animation, direction) func _update_charge_effect(delta: float) -> void: var sprite := _get_charge_effect_sprite() if sprite == null: return sprite.visible = charge_active if not charge_active: return _charge_effect_time += delta sprite.frame = int(_charge_effect_time * 12.0) % 5 func _set_charge_effect_visible(is_visible: bool) -> void: var sprite := _get_charge_effect_sprite() if sprite != null: sprite.visible = is_visible func _get_charge_effect_sprite() -> Sprite2D: if has_node("ChargeEffectSprite"): return get_node("ChargeEffectSprite") as Sprite2D return null func _resolve_full_window_fallback(symbol: String) -> Dictionary: if symbol != "A" and symbol != "D": return {} return InputResolver.resolve_pattern(symbol) func _cancel_missed_direction_action() -> void: velocity = Vector2.ZERO attack_lunge_time_left = 0.0 attack_time_left = 0.0 state = State.IDLE anim_map[State.ATTACK] = "warrior_a" var player_animation := _get_animation_player() if player_animation != null and player_animation.has_animation("warrior_idle"): player_animation.play("warrior_idle") func _is_projectile_space_chain() -> bool: var pattern := combo_window.get_contiguous_pattern() return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP" func _apply_horizontal_movement() -> void: if charge_active: velocity.x = 0.0 return if state != State.IDLE and state != State.WALK: return var direction := 0.0 if Input.is_action_pressed("player_a"): direction -= 1.0 if Input.is_action_pressed("player_d"): direction += 1.0 if direction < 0.0: heading = Vector2.LEFT elif direction > 0.0: heading = Vector2.RIGHT velocity.x = direction * speed func _animation_length(animation_name: String) -> float: var player_animation := _get_animation_player() if player_animation != null and player_animation.has_animation(animation_name): return maxf(0.1, player_animation.get_animation(animation_name).length) return attack_duration func _get_animation_player() -> AnimationPlayer: if animation_player == null and has_node("AnimationPlayer"): animation_player = get_node("AnimationPlayer") as AnimationPlayer return animation_player func _fire_projectile() -> void: var projectile := PlayerProjectile.new() projectile.direction = heading projectile.global_position = global_position + Vector2(heading.x * 36.0, -30.0) var parent := get_parent() if parent != null: parent.add_child(projectile) else: add_child(projectile) projectile.add_to_group("player_projectiles") func _cancel_pending_combo_clear() -> void: _pending_combo_clear_reason = "" if combo_clear_timer != null: combo_clear_timer.stop() func _schedule_combo_clear(reason: String) -> void: _pending_combo_clear_reason = reason if combo_clear_timer == null: return combo_clear_timer.stop() combo_clear_timer.wait_time = combo_clear_display_time combo_clear_timer.start() func flush_pending_combo_clear() -> void: var reason := _pending_combo_clear_reason if reason.is_empty(): reason = combo_window.consume_pending_clear_reason() else: combo_window.consume_pending_clear_reason() if reason.is_empty(): return _pending_combo_clear_reason = "" if combo_clear_timer != null: combo_clear_timer.stop() combo_window.clear(reason) func _on_combo_window_cleared(reason: String) -> void: combo_window_cleared.emit(reason) _emit_combo_window_changed() func _emit_combo_window_changed() -> void: combo_window_changed.emit(combo_window.get_slots()) func _emit_charge_changed() -> void: charge_changed.emit(charge_value, charge_duration, charge_ready, charge_active) func _emit_energy_changed() -> void: energy_changed.emit(current_energy, max_energy) func _emit_health_changed() -> void: health_changed.emit(current_health, max_health) func judge_rhythm_action(action_name: String) -> Dictionary: if not is_inside_tree(): return {"label": "perfect"} var conductor: Node = get_tree().get_first_node_in_group("rhythm_conductor") if conductor != null and conductor.has_method("judge_action"): return conductor.call("judge_action", action_name) as Dictionary return {"label": "perfect"}