class_name ChargeComponent extends Node signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool) # wave: CHARGING UP OVERLAY loops while holding S (author spec 50fps). # blade_rain: SWORD RAIN PREP OVERLAY plays frames 1-16 (ring forms) and holds; # frames 17-19 (ring launches) belong to the blade_rain_cast release animation. const OVERLAY_PROFILES := { &"wave": { "path": "res://assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png", "hframes": 5, "vframes": 5, "first": 0, "last": 24, "fps": 50.0, "offset": Vector2(-92.0, -140.0), "loop": true, }, &"blade_rain": { "path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png", "hframes": 10, "vframes": 2, "first": 1, "last": 16, "fps": 25.0, "offset": Vector2(-64.0, -305.0), "loop": false, }, } # Fallback ramp when no ActionController drives the gauge (standalone use). @export var charge_duration := 1.1 @export var animation_player_path: NodePath @export var effect_sprite_path: NodePath var value := 0.0 var charge_ready := false var active := false var overlay_profile: StringName = &"wave" var _effect_time := 0.0 var _animation_time := 0.0 var _maximum := 1.1 var _last_level := 0 @onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer @onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D @onready var _action_controller: Node = get_node_or_null("../ActionController") func tick(delta: float, is_charging: bool) -> void: if not is_charging: if active or value > 0.0 or charge_ready: cancel() return if not active: _start() if not active: return _update_charge_animation(delta) var charge_state := _controller_charge_state() if charge_state.is_empty(): _maximum = charge_duration value = minf(charge_duration, value + delta) charge_ready = value >= charge_duration else: # The gauge is level-space: 0 units = level 1 fresh, max units = top # level. The cast level itself is owned by ActionController. var max_level := maxi(2, int(charge_state.get("max_level", 3))) var level := int(charge_state.get("level", 1)) _maximum = float(max_level - 1) value = clampf(float(charge_state.get("progress_units", 0.0)), 0.0, _maximum) charge_ready = level >= max_level if level > _last_level and _last_level > 0: _pulse_level_up() _last_level = level _update_charge_effect(delta) _emit_changed() func cancel() -> void: active = false value = 0.0 charge_ready = false _animation_time = 0.0 _last_level = 0 _set_effect_visible(false) if _effect_sprite != null: _effect_sprite.modulate = Color.WHITE _emit_changed() func is_active() -> bool: return active func is_ready() -> bool: return charge_ready func maximum() -> float: return _maximum func _start() -> void: active = true value = 0.0 charge_ready = false _effect_time = 0.0 _animation_time = 0.0 _last_level = 1 _setup_charge_overlay() if _effect_sprite != null: _effect_sprite.modulate = Color.WHITE _update_charge_effect(0.0) _emit_changed() func _controller_charge_state() -> Dictionary: if _action_controller == null: _action_controller = get_node_or_null("../ActionController") if _action_controller == null or not _action_controller.has_method("charge_state"): return {} var charge_state = _action_controller.call("charge_state") if charge_state is Dictionary and bool((charge_state as Dictionary).get("charging", false)): return charge_state return {} func _pulse_level_up() -> void: if _effect_sprite != null: _effect_sprite.modulate = Color(1.7, 1.7, 1.7) func set_overlay_profile(profile: StringName) -> void: if profile == overlay_profile or not OVERLAY_PROFILES.has(profile): return overlay_profile = profile if active: _effect_time = 0.0 _setup_charge_overlay() func _current_overlay() -> Dictionary: return OVERLAY_PROFILES.get(overlay_profile, OVERLAY_PROFILES[&"wave"]) func _setup_charge_overlay() -> void: if _effect_sprite == null: return var overlay := _current_overlay() var texture_path := str(overlay.get("path", "")) var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null if texture == null: return _effect_sprite.texture = texture _effect_sprite.hframes = maxi(1, int(overlay.get("hframes", 1))) _effect_sprite.vframes = maxi(1, int(overlay.get("vframes", 1))) _effect_sprite.offset = overlay.get("offset", Vector2.ZERO) _effect_sprite.frame = clampi(int(overlay.get("first", 0)), 0, _effect_sprite.hframes * _effect_sprite.vframes - 1) func _update_charge_effect(delta: float) -> void: if _effect_sprite == null: return _effect_sprite.visible = active if not active: return _effect_time += delta if _effect_sprite.modulate != Color.WHITE: _effect_sprite.modulate = _effect_sprite.modulate.lerp(Color.WHITE, minf(1.0, delta * 6.0)) var overlay := _current_overlay() var first := int(overlay.get("first", 0)) var last := int(overlay.get("last", first)) var span := maxi(1, last - first + 1) var step := int(_effect_time * float(overlay.get("fps", 25.0))) var frame_index := first + (step % span if bool(overlay.get("loop", true)) else mini(step, span - 1)) _effect_sprite.frame = clampi(frame_index, 0, _effect_sprite.hframes * _effect_sprite.vframes - 1) func _update_charge_animation(delta: float) -> void: _animation_time += delta var intro_length := _animation_length(&"warrior_charge_intro") if _animation_time < intro_length: _play_charge_animation(&"warrior_charge_intro") else: _play_charge_animation(&"warrior_charge_loop") func _play_charge_animation(animation_name: StringName) -> void: if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name: _animation_player.play(animation_name) func _animation_length(animation_name: StringName) -> float: if _animation_player != null and _animation_player.has_animation(animation_name): return maxf(0.1, _animation_player.get_animation(animation_name).length) return 0.1 func _set_effect_visible(is_visible: bool) -> void: if _effect_sprite != null: _effect_sprite.visible = is_visible func _emit_changed() -> void: charge_changed.emit(value, _maximum, charge_ready, active)