class_name Player extends Character const OneShotFxScript := preload("res://scenes/combat/one_shot_fx.gd") 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 action_requested(action_id: String) @export var combo_clear_display_time := 0.35 @export var max_energy := 100 @export var current_energy := 0 @export var base_move_distance := 100.0 @export var visual_ground_offset := 0.0 @export var action_animation_min_speed_scale := 0.35 @export var action_animation_max_speed_scale := 5.0 # Manual sheet animations. "fx" entries drive Visual/FxOverlay on a parallel timeline: # frame 0 of every author overlay sheet is the embedded idle alignment reference and is # never played; offsets below are computed by aligning that reference to the idle pose. const PLAYER_ANIMATION_SPECS := { &"atk_ground_3": { "fps": 20.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/03_basic_attack_iii/air_prep.png", "frames": 3, "hframes": 3, "vframes": 1}, {"path": "res://assets/art/characters/player/03_basic_attack_iii/air_swing.png", "frames": 3, "hframes": 3, "vframes": 1}, ], "fx": [ {"path": "res://assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-147, -191), "start": 0.15}, ], }, &"dash_slash": { "fps": 25.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/04_dash_slash/dashattack.png", "frames": 12, "hframes": 6, "vframes": 2}, ], "fx": [ {"path": "res://assets/art/characters/player/04_dash_slash/dash_overlay_fx.png", "hframes": 4, "vframes": 3, "first": 1, "last": 11, "fps": 25.0, "offset": Vector2(-98, -128), "start": 0.0}, ], }, &"combo_finisher": { "fps": 20.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/05_combo_finisher/prep.png", "frames": 5, "hframes": 5, "vframes": 1}, {"path": "res://assets/art/characters/player/05_combo_finisher/air_swing.png", "frames": 6, "hframes": 6, "vframes": 1}, ], "fx": [ {"path": "res://assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-147, -191), "start": 0.25}, ], }, &"ground_smash": { "fps": 20.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/06_ground_smash/prep.png", "frames": 8, "hframes": 4, "vframes": 2}, {"path": "res://assets/art/characters/player/06_ground_smash/swing.png", "frames": 6, "hframes": 3, "vframes": 2}, ], }, &"blade_rain_charge": { "fps": 25.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/08_sword_charge/air_cast_prep.png", "frames": 20, "hframes": 10, "vframes": 2}, ], }, &"blade_rain_cast": { "fps": 20.0, "loop": true, "segments": [ {"path": "res://assets/art/characters/player/09_blade_rain/air_casting_loop.png", "frames": 2, "hframes": 2, "vframes": 1}, ], "fx": [ {"path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png", "hframes": 10, "vframes": 2, "first": 17, "last": 19, "fps": 25.0, "offset": Vector2(-64, -305), "start": 0.0}, ], }, &"blade_wave_cast": { "fps": 20.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/11_blade_wave/cast.png", "frames": 4, "hframes": 4, "vframes": 1}, ], "fx": [ {"path": "res://assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png", "hframes": 5, "vframes": 1, "first": 1, "last": 4, "fps": 20.0, "offset": Vector2(-115, -172), "start": 0.0}, ], }, &"atk_air": { "fps": 25.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/12_aerial_combo/prep.png", "frames": 2, "hframes": 2, "vframes": 1}, {"path": "res://assets/art/characters/player/12_aerial_combo/swing_start.png", "frames": 3, "hframes": 3, "vframes": 1}, {"path": "res://assets/art/characters/player/12_aerial_combo/falling_loop.png", "frames": 3, "hframes": 3, "vframes": 1, "fps": 20.0}, ], "fx": [ {"path": "res://assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 25.0, "offset": Vector2(-107, -198), "start": 0.08}, {"path": "res://assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png", "hframes": 2, "vframes": 1, "first": 1, "last": 1, "fps": 20.0, "offset": Vector2(-107, -198), "start": 0.2, "hold": true}, ], }, &"air_slam_land": { "fps": 20.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/12_aerial_combo/land.png", "frames": 6, "hframes": 3, "vframes": 2}, ], "fx": [ {"path": "res://assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-71, -166), "start": 0.0}, ], }, &"plunge_start": { "fps": 25.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/13_plunging_strike/prep.png", "frames": 2, "hframes": 2, "vframes": 1}, {"path": "res://assets/art/characters/player/13_plunging_strike/swing_start.png", "frames": 3, "hframes": 3, "vframes": 1}, {"path": "res://assets/art/characters/player/13_plunging_strike/falling_loop.png", "frames": 3, "hframes": 3, "vframes": 1, "fps": 20.0}, ], "fx": [ {"path": "res://assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 25.0, "offset": Vector2(-107, -198), "start": 0.08}, {"path": "res://assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png", "hframes": 2, "vframes": 1, "first": 1, "last": 1, "fps": 20.0, "offset": Vector2(-107, -198), "start": 0.2, "hold": true}, ], }, &"hit_stun": { "fps": 14.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/14_hit_stun/damaged.png", "frames": 3, "hframes": 3, "vframes": 1}, ], }, &"death": { "fps": 12.0, "loop": false, "segments": [ {"path": "res://assets/art/characters/player/15_death/initial_death_stun_hold.png", "frames": 1, "hframes": 1, "vframes": 1, "fps": 2.0}, {"path": "res://assets/art/characters/player/15_death/fall_back.png", "frames": 10, "hframes": 5, "vframes": 2}, {"path": "res://assets/art/characters/player/15_death/he_s_finally_dead_hold.png", "frames": 1, "hframes": 1, "vframes": 1, "fps": 1.0}, ], }, } @onready var state_machine: Node = $StateMachine @onready var input_component: Node = $InputComponent @onready var movement_motor: Node = $MovementMotor @onready var combo_window: Node = $ComboWindow @onready var action_controller: Node = $ActionController @onready var motion_executor: Node = $MotionExecutor @onready var burst_component: Node = $BurstComponent @onready var charge_component: Node = $ChargeComponent @onready var energy_component: Node = $EnergyComponent @onready var effect_container: Node = $EffectContainer @onready var fx_overlay: Sprite2D = $Visual/FxOverlay var last_requested_action_id := "" var current_action_animation := "idle" var _manual_animation_segments: Array = [] var _manual_animation_segment := 0 var _manual_animation_frame := 0 var _manual_animation_elapsed := 0.0 var _manual_animation_frame_time := 0.05 var _manual_animation_fps := 20.0 var _manual_animation_speed_scale := 1.0 var _manual_animation_loop := false var _manual_animation_active := false var _manual_animation_time := 0.0 var _manual_animation_name: StringName = &"" var _base_sprite_offset := Vector2(-64.0, -128.0) var _death_visible_bottom_anchor := 0.0 var _manual_fx: Array = [] var _hitstun_animation_started := false var _death_animation_started := false var _charge_body_animation_active := false var _pending_air_slam_land := false func _ready() -> void: if character_sprite != null: _base_sprite_offset = character_sprite.offset _death_visible_bottom_anchor = _current_sprite_visible_bottom_local() combo_window.clear_display_time = combo_clear_display_time input_component.intent_created.connect(_on_input_intent_created) action_controller.action_started.connect(_on_action_started) action_controller.action_active_started.connect(_on_action_active_started) action_controller.action_finished.connect(_on_action_finished) action_controller.action_cancelled.connect(_on_action_cancelled) combo_window.combo_updated.connect(_on_combo_updated) combo_window.combo_cleared.connect(_on_combo_cleared) charge_component.charge_changed.connect(_on_charge_component_changed) energy_component.energy_changed.connect(_on_energy_component_changed) energy_component.set_values(current_energy, max_energy) func _exit_tree() -> void: super._exit_tree() func _process(delta: float) -> void: charge_component.tick(delta, _is_action_phase(&"Charging")) if charge_component.is_active(): _apply_charging_presentation() elif _charge_body_animation_active: _charge_body_animation_active = false _stop_manual_animation() _tick_manual_animation(delta) func _apply_charging_presentation() -> void: state = PRESENTATION_IDLE attack_time_left = 0.0 stop_attack_motion() var symbol := _active_charge_symbol() var blade_rain_charge := symbol == &"A" or symbol == &"D" charge_component.set_overlay_profile(&"blade_rain" if blade_rain_charge else &"wave") if blade_rain_charge and not _charge_body_animation_active: _charge_body_animation_active = true if animation_player != null: _reset_action_animation_speed() animation_player.stop() _start_manual_animation(&"blade_rain_charge") func _active_charge_symbol() -> StringName: var entry = action_controller.get("_active_charge_entry") if entry is Dictionary: return StringName(str((entry as Dictionary).get("symbol", &""))) return &"" func _input(event: InputEvent) -> void: if input_component.handle_input_event(event): _mark_input_handled() func _unhandled_input(event: InputEvent) -> void: if input_component.handle_input_event(event): _mark_input_handled() func handle_input() -> void: if charge_component.is_active(): velocity = Vector2.ZERO return movement_motor.handle_input() func handle_air_time(delta: float) -> void: movement_motor.handle_air_time(delta) func handle_movement() -> void: movement_motor.handle_movement() func handle_animations() -> void: var life_state := _life_state_name() if life_state == &"Dead": _play_death_animation_once() return _death_animation_started = false if life_state == &"Hitstun": _play_hitstun_animation_once() return if _hitstun_animation_started: _hitstun_animation_started = false _stop_manual_animation() if _charge_body_animation_active and charge_component.is_active(): return if _pending_air_slam_land: if _grounded_now(): _pending_air_slam_land = false _play_air_slam_land_animation() else: # Still falling: hold the falling pose until touchdown. state = PRESENTATION_ATTACK attack_time_left = maxf(attack_time_left, 0.1) return if current_action_animation == "air_slam_land" and _manual_animation_active: return super.handle_animations() func _life_state_name() -> StringName: if state_machine != null and state_machine.has_method("get_life_state"): return state_machine.get_life_state() return &"Alive" func _play_air_slam_land_animation() -> void: motion_executor.cancel() stop_attack_motion() current_action_animation = "air_slam_land" anim_map[PRESENTATION_ATTACK] = "air_slam_land" state = PRESENTATION_ATTACK attack_time_left = _animation_length("air_slam_land") + 0.05 if animation_player != null: _reset_action_animation_speed() animation_player.stop() _start_manual_animation(&"air_slam_land") func _grounded_now() -> bool: return height <= 0.0 and is_zero_approx(height_speed) func _play_death_animation_once() -> void: if _death_animation_started: return _death_animation_started = true _pending_air_slam_land = false motion_executor.cancel() stop_attack_motion() attack_time_left = 0.0 state = PRESENTATION_IDLE if animation_player != null: _reset_action_animation_speed() animation_player.stop() _start_manual_animation(&"death") func _play_hitstun_animation_once() -> void: if _hitstun_animation_started: return _hitstun_animation_started = true _pending_air_slam_land = false if animation_player != null: _reset_action_animation_speed() animation_player.stop() _start_manual_animation(&"hit_stun") func set_heading() -> void: movement_motor.set_heading() func set_sprite_height_position() -> void: if visual != null: visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height func start_jump() -> void: movement_motor.start_jump() func apply_knockback(knockback: Vector2) -> void: movement_motor.apply_knockback(knockback) func submit_combo_input(symbol: String, forced_rating := "") -> String: var data := _symbol_to_intent_data(symbol) if data.is_empty(): return "" var intent: RefCounted = load("res://scenes/components/input_intent.gd").create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec())) if not forced_rating.is_empty(): intent.judgement = _rating_result(StringName(forced_rating), 0.0) action_controller.submit_intent(intent) if data["symbol"] == &"A" or data["symbol"] == &"D": var release_intent: RefCounted = load("res://scenes/components/input_intent.gd").create(data["symbol"], data["rhythm_action"], &"released", float(Time.get_ticks_msec())) if not forced_rating.is_empty(): release_intent.judgement = _rating_result(StringName(forced_rating), 0.0) action_controller.submit_intent(release_intent) return last_requested_action_id func get_combo_slots() -> Array[StringName]: return combo_window.get_slots() func projectile_spawn_position(action: Resource) -> Vector2: var aim := projectile_direction(action) var forward := -1.0 if aim.x < 0.0 else 1.0 return global_position + Vector2(forward * 30.0, -70.0) func projectile_direction(_action: Resource) -> Vector2: # Charge releases (zhan_bo) fire along the player's current facing, never # auto-aimed at the nearest enemy: facing left releases left. return Vector2.RIGHT if heading.x > 0.0 else Vector2.LEFT func _symbol_to_intent_data(symbol: String) -> Dictionary: match symbol: "W": return {"symbol": &"W", "rhythm_action": &"w"} "A": return {"symbol": &"A", "rhythm_action": &"a"} "D": return {"symbol": &"D", "rhythm_action": &"d"} "S": return {"symbol": &"S", "rhythm_action": &"s"} "SP": return {"symbol": &"SP", "rhythm_action": &"space"} return {} func _play_action_animation(animation_name: String, action: Resource = null) -> void: current_action_animation = animation_name anim_map[PRESENTATION_ATTACK] = animation_name state = PRESENTATION_ATTACK attack_time_left = _animation_length(animation_name) if action != null: attack_time_left = maxf(attack_time_left, float(action.get("action_beats")) * _beat_time() + 0.25) var speed_scale := _action_animation_speed_scale(animation_name, action) _charge_body_animation_active = false _pending_air_slam_land = false if action != null: var direction := _displacement_direction(action) if direction != Vector2.ZERO: heading = direction flip_sprites() if animation_player != null and animation_player.has_animation(animation_name): _stop_manual_animation() if fx_overlay != null: fx_overlay.visible = false animation_player.stop() animation_player.speed_scale = speed_scale animation_player.play(animation_name) elif has_player_animation(StringName(animation_name)): if animation_player != null: animation_player.stop() _start_manual_animation(StringName(animation_name), speed_scale) func has_player_animation(animation_name: StringName) -> bool: if animation_player != null and animation_player.has_animation(str(animation_name)): return true return PLAYER_ANIMATION_SPECS.has(animation_name) func _start_manual_animation(animation_name: StringName, speed_scale := 1.0) -> void: var spec: Dictionary = PLAYER_ANIMATION_SPECS.get(animation_name, {}) _manual_animation_name = animation_name _manual_animation_segments = spec.get("segments", []) _manual_animation_segment = 0 _manual_animation_frame = 0 _manual_animation_elapsed = 0.0 _manual_animation_time = 0.0 _manual_animation_fps = maxf(1.0, float(spec.get("fps", 20.0))) _manual_animation_speed_scale = maxf(0.01, speed_scale) _manual_animation_frame_time = _segment_frame_time(_current_manual_segment()) _manual_animation_loop = bool(spec.get("loop", false)) _manual_fx = spec.get("fx", []) if fx_overlay != null: fx_overlay.visible = false _manual_animation_active = not _manual_animation_segments.is_empty() if _manual_animation_active: _apply_manual_animation_frame() _update_manual_fx() func _stop_manual_animation() -> void: _manual_animation_name = &"" _manual_animation_segments = [] _manual_animation_segment = 0 _manual_animation_frame = 0 _manual_animation_elapsed = 0.0 _manual_animation_time = 0.0 _manual_animation_speed_scale = 1.0 _manual_animation_active = false if not _manual_fx.is_empty(): _manual_fx = [] if fx_overlay != null: fx_overlay.visible = false func _tick_manual_animation(delta: float) -> void: if not _manual_animation_active: return var scaled_delta := delta * _manual_animation_speed_scale _manual_animation_time += scaled_delta _manual_animation_elapsed += scaled_delta while _manual_animation_elapsed >= _manual_animation_frame_time and _manual_animation_active: _manual_animation_elapsed -= _manual_animation_frame_time _advance_manual_animation_frame() _update_manual_fx() func _advance_manual_animation_frame() -> void: var segment := _current_manual_segment() if segment.is_empty(): _stop_manual_animation() return _manual_animation_frame += 1 if _manual_animation_frame < int(segment.get("frames", 1)): _apply_manual_animation_frame() return _manual_animation_segment += 1 _manual_animation_frame = 0 if _manual_animation_segment >= _manual_animation_segments.size(): if _manual_animation_loop: _manual_animation_segment = 0 else: _manual_animation_segment = _manual_animation_segments.size() - 1 _manual_animation_frame = maxi(0, int(_current_manual_segment().get("frames", 1)) - 1) _apply_manual_animation_frame() _manual_animation_active = false if _manual_animation_name == &"death": queue_free() return _manual_animation_frame_time = _segment_frame_time(_current_manual_segment()) _apply_manual_animation_frame() func _segment_frame_time(segment: Dictionary) -> float: var fps := _manual_animation_fps if not segment.is_empty() and segment.has("fps"): fps = maxf(1.0, float(segment.get("fps"))) return 1.0 / fps func _update_manual_fx() -> void: if _manual_fx.is_empty() or fx_overlay == null: return var active_fx: Dictionary = {} for entry: Variant in _manual_fx: if entry is Dictionary and _manual_animation_time >= float((entry as Dictionary).get("start", 0.0)): active_fx = entry if active_fx.is_empty(): fx_overlay.visible = false return var first := int(active_fx.get("first", 1)) var last := int(active_fx.get("last", first)) var fx_fps := maxf(1.0, float(active_fx.get("fps", 20.0))) var local_time := _manual_animation_time - float(active_fx.get("start", 0.0)) var frame_index := first + int(local_time * fx_fps) if frame_index > last: if bool(active_fx.get("hold", false)): frame_index = last else: fx_overlay.visible = false return var texture_path := str(active_fx.get("path", "")) var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null if texture == null: fx_overlay.visible = false return fx_overlay.texture = texture fx_overlay.hframes = maxi(1, int(active_fx.get("hframes", 1))) fx_overlay.vframes = maxi(1, int(active_fx.get("vframes", 1))) fx_overlay.offset = active_fx.get("offset", Vector2.ZERO) fx_overlay.frame = clampi(frame_index, 0, fx_overlay.hframes * fx_overlay.vframes - 1) fx_overlay.visible = true func _apply_manual_animation_frame() -> void: var segment := _current_manual_segment() if segment.is_empty() or character_sprite == null: return var texture := load(str(segment.get("path", ""))) as Texture2D if texture == null: return character_sprite.texture = texture character_sprite.hframes = maxi(1, int(segment.get("hframes", int(segment.get("frames", 1))))) character_sprite.vframes = maxi(1, int(segment.get("vframes", 1))) character_sprite.frame = clampi(_manual_animation_frame, 0, int(segment.get("frames", 1)) - 1) if _manual_animation_name == &"death": _align_sprite_visible_bottom_to(_death_visible_bottom_anchor) func _current_manual_segment() -> Dictionary: if _manual_animation_segment < 0 or _manual_animation_segment >= _manual_animation_segments.size(): return {} var segment: Variant = _manual_animation_segments[_manual_animation_segment] return segment if segment is Dictionary else {} func _align_sprite_visible_bottom_to(anchor: float) -> void: if character_sprite == null: return var bottom := _current_sprite_visible_bottom_pixel() if bottom == INF: return character_sprite.offset.y = anchor - bottom func _current_sprite_visible_bottom_local() -> float: if character_sprite == null: return INF return _visible_frame_bottom_local(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.offset) func _current_sprite_visible_bottom_pixel() -> float: if character_sprite == null: return INF return _visible_frame_bottom_pixel(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame) func _visible_frame_bottom_local(texture: Texture2D, hframes: int, vframes: int, frame_index: int, sprite_offset: Vector2) -> float: var bottom := _visible_frame_bottom_pixel(texture, hframes, vframes, frame_index) if bottom == INF: return INF return sprite_offset.y + bottom func _visible_frame_bottom_pixel(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float: if texture == null: return INF var image := texture.get_image() if image == null or image.is_empty(): return INF var safe_hframes := maxi(1, hframes) var safe_vframes := maxi(1, vframes) var frame_width := image.get_width() / safe_hframes var frame_height := image.get_height() / safe_vframes var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1) var column := safe_frame % safe_hframes var row := int(safe_frame / safe_hframes) var bottom := -1 for y: int in range(frame_height): for x: int in range(frame_width): var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) if pixel.a > 0.01: bottom = y if bottom < 0: return INF return float(bottom) func _displacement_direction(action: Resource) -> Vector2: var tags = action.get("action_tags") if tags is Array: if (tags as Array).has(&"left"): return Vector2.LEFT if (tags as Array).has(&"right"): return Vector2.RIGHT var move_x := float(action.get("move_mult_x")) if move_x == 0.0: return Vector2.ZERO if heading == Vector2.LEFT or heading == Vector2.RIGHT: return heading return Vector2.LEFT if move_x < 0.0 else Vector2.RIGHT func _animation_length(animation_name: String) -> float: if animation_player != null and animation_player.has_animation(animation_name): return maxf(0.1, animation_player.get_animation(animation_name).length) if PLAYER_ANIMATION_SPECS.has(StringName(animation_name)): var spec: Dictionary = PLAYER_ANIMATION_SPECS[StringName(animation_name)] var spec_fps := maxf(1.0, float(spec.get("fps", 20.0))) var length := 0.0 for segment: Variant in spec.get("segments", []): if segment is Dictionary: var segment_fps := maxf(1.0, float((segment as Dictionary).get("fps", spec_fps))) length += float((segment as Dictionary).get("frames", 1)) / segment_fps return maxf(0.1, length) return attack_duration func _action_animation_speed_scale(animation_name: String, action: Resource) -> float: if action == null or animation_name.is_empty(): return 1.0 var natural_length := _animation_length(animation_name) var action_seconds := _action_visual_duration(action) if natural_length <= 0.0 or action_seconds <= 0.0: return 1.0 return clampf(natural_length / action_seconds, action_animation_min_speed_scale, action_animation_max_speed_scale) func _action_visual_duration(action: Resource) -> float: var startup_beats := float(action.get("startup_beats")) var active_beats := float(action.get("active_beats")) var recovery_beats := float(action.get("recovery_beats")) if action_controller != null: var snapshot_value = action_controller.get("_action_snapshot") if snapshot_value is Dictionary: var snapshot: Dictionary = snapshot_value startup_beats = float(snapshot.get("startup_beats", startup_beats)) active_beats = float(snapshot.get("active_beats", active_beats)) recovery_beats = float(snapshot.get("recovery_beats", recovery_beats)) var duration := maxf(0.05, (startup_beats + active_beats + recovery_beats) * _beat_time()) if action_controller != null: duration += maxf(0.0, float(action_controller.get("startup_stretch_seconds"))) return duration func _beat_time() -> float: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null if rhythm != null: return float(rhythm.get("beat_time")) return 0.5 func _on_input_intent_created(intent) -> void: action_controller.submit_intent(intent) func _on_action_started(action: Resource, intent) -> void: current_energy = energy_component.current last_requested_action_id = str(action.get("id")) action_requested.emit(last_requested_action_id) var judgement := StringName(str(intent.judgement.get("label", "perfect"))) if intent != null else &"perfect" var bus := _event_bus() if bus.has_signal("skill_executed"): bus.emit_signal("skill_executed", action, judgement) _play_action_animation(str(action.get("animation")), action) func _on_action_active_started(action: Resource, _intent) -> void: current_energy = energy_component.current _start_action_motion(action) var animation_name := str(action.get("animation")) if animation_name == "atk_air" or animation_name == "plunge_start": _pending_air_slam_land = true _spawn_world_fx(action) func _spawn_world_fx(action: Resource) -> void: # World FX live beside the player (ActorsContainer), addressed relatively so # the player never reaches upward through the scene tree API surface. var fx_parent := get_node_or_null(NodePath("..")) if fx_parent == null: return var action_id := str(action.get("id")) var forward := -1.0 if heading.x < 0.0 else 1.0 if action_id.begins_with("ground_smash"): for index: int in range(3): OneShotFxScript.spawn(fx_parent, global_position + Vector2(forward * (60.0 + 55.0 * index), -54.0), { "path": "res://assets/art/characters/player/06_ground_smash/shockwave_tile.png", "hframes": 6, "vframes": 2, "first": 0, "last": 11, "fps": 20.0, "delay": 0.07 * index, "flip_h": forward < 0.0, "scale": 0.9, }) elif action_id.begins_with("jian_yu"): var level := 1 if action_id.ends_with("lv2"): level = 2 elif action_id.ends_with("lv3"): level = 3 var strike_count := 2 * (1 + level) # Lv3 farthest drop = 40 + 240 = 280, matching the action's range so the # visual footprint covers the hitbox edge-to-edge with no phantom strikes. var spread := 120.0 + 40.0 * float(level) var drop_height := BLADE_RAIN_DROP_FALL_SPEED * BLADE_RAIN_DROP_FALL_TIME for index: int in range(strike_count): var offset_x := forward * (40.0 + spread * float(index) / float(maxi(1, strike_count - 1))) var stagger := 0.09 * index var tile: Dictionary = BLADE_RAIN_TILE_SPECS[index % BLADE_RAIN_TILE_SPECS.size()] var tile_scale := float(tile.get("scale", 1.0)) # Falling blade first; its flight ends exactly when the landing # tile starts, whose own first frames carry the streak + impact. OneShotFxScript.spawn(fx_parent, global_position + Vector2(offset_x, VISIBLE_GROUND_LINE_OFFSET - 50.0 - drop_height), { "path": "res://assets/art/characters/player/09_blade_rain/raindrop_%d.png" % ((index % 4) + 1), "hframes": 1, "vframes": 1, "first": 0, "last": 0, "fps": 20.0, "delay": stagger, "duration": BLADE_RAIN_DROP_FALL_TIME, "velocity": Vector2(0.0, BLADE_RAIN_DROP_FALL_SPEED), "scale": tile_scale, "flip_h": forward < 0.0, }) var tile_config := tile.duplicate() tile_config["delay"] = stagger + BLADE_RAIN_DROP_FALL_TIME tile_config["flip_h"] = forward < 0.0 # The tile art is bottom-anchored inside its cell: center it so the # cell bottom (= visible lowest pixel) lands exactly on the world's # visible ground line where the player's feet stand. var tile_center_y := VISIBLE_GROUND_LINE_OFFSET - float(tile.get("cell_height", 360.0)) * tile_scale * 0.5 OneShotFxScript.spawn(fx_parent, global_position + Vector2(offset_x, tile_center_y), tile_config) # The authored player sheets keep ~40px of transparent margin under the feet # inside the 128px cell, so the visible feet baseline sits 40px above the actor # origin (decisions.md: actors anchor at y=560, visible feet line verified at # y=520). Ground-contact FX must anchor to that visible line, not the origin, # or they render sunk into the floor. const VISIBLE_GROUND_LINE_OFFSET := -40.0 const BLADE_RAIN_DROP_FALL_TIME := 0.24 const BLADE_RAIN_DROP_FALL_SPEED := 1900.0 const BLADE_RAIN_TILE_SPECS := [ {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png", "hframes": 6, "vframes": 2, "first": 0, "last": 11, "fps": 20.0, "scale": 1.0, "cell_height": 332.0}, {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png", "hframes": 9, "vframes": 1, "first": 0, "last": 8, "fps": 20.0, "scale": 1.0, "cell_height": 376.0}, {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png", "hframes": 5, "vframes": 2, "first": 0, "last": 9, "fps": 20.0, "scale": 1.0, "cell_height": 346.0}, {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png", "hframes": 5, "vframes": 2, "first": 0, "last": 9, "fps": 20.0, "scale": 1.0, "cell_height": 394.0}, ] func _on_action_finished(_action: Resource) -> void: if _pending_air_slam_land: # Action timed out mid-fall: keep dropping in the falling pose; # handle_animations plays the land impact on touchdown. motion_executor.cancel() stop_attack_motion() return if current_action_animation == "air_slam_land" and _manual_animation_active: motion_executor.cancel() return _set_idle_presentation() func _on_action_cancelled(_action: Resource, _reason: StringName) -> void: _pending_air_slam_land = false motion_executor.cancel() _set_idle_presentation() func _start_action_motion(action: Resource) -> void: var direction := _displacement_direction(action) var move_y := float(action.get("move_mult_y")) if move_y > 0.0: height_speed = jump_intensity * move_y height = maxf(height, 0.1) state_machine.call("set_ground_state", &"Airborne") elif move_y < 0.0 and height > 0.0: height_speed = jump_intensity * move_y if direction != Vector2.ZERO: heading = direction motion_executor.execute(action, direction, _beat_time(), _action_motion_speed(action)) attack_time_left = maxf(attack_time_left, motion_executor.duration) begin_attack_motion(motion_executor.duration, motion_executor.velocity) func _action_motion_speed(action: Resource) -> float: # S6 conversion rule: move_mult_x of 1.0 means 100px of total displacement over the action. # The motion only runs from Active start to action end, so the speed must be # computed over that window or every action under-travels by the startup # fraction (the 260px dash used to realize only ~195px and could stall # inside the boss instead of piercing through). var travel := absf(float(action.get("move_mult_x"))) * base_move_distance if travel <= 0.0: return 0.0 var motion_beats := float(action.get("action_beats")) - float(action.get("startup_beats")) var duration := maxf(0.05, motion_beats * _beat_time()) return travel / duration func _set_idle_presentation() -> void: motion_executor.cancel() stop_attack_motion() attack_time_left = 0.0 state = PRESENTATION_IDLE current_action_animation = "idle" _stop_manual_animation() if animation_player != null and animation_player.has_animation("idle"): _reset_action_animation_speed() animation_player.play("idle") func _reset_action_animation_speed() -> void: if animation_player != null: animation_player.speed_scale = 1.0 _manual_animation_speed_scale = 1.0 func _on_combo_updated(slots: Array[StringName]) -> void: combo_window_changed.emit(slots) func _on_combo_cleared(reason: StringName) -> void: combo_window_cleared.emit(str(reason)) func _on_charge_component_changed(current: float, maximum: float, ready: bool, active: bool) -> void: charge_changed.emit(current, maximum, ready, active) var bus := _event_bus() if bus.has_signal("player_charge_changed"): bus.emit_signal("player_charge_changed", current, maximum, ready, active) func _on_energy_component_changed(current: int, maximum: int) -> void: current_energy = current max_energy = maximum energy_changed.emit(current, maximum) func _rating_result(label: StringName, offset_ms: float) -> Dictionary: return { "label": str(label), "diff": offset_ms / 1000.0, "abs_diff": absf(offset_ms / 1000.0), } func _mark_input_handled() -> void: var viewport := get_viewport() if viewport != null: viewport.set_input_as_handled() func _event_bus() -> Node: var root := get_tree().root var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus func _is_action_phase(phase_name: StringName) -> bool: return state_machine != null and state_machine.get_action_phase() == phase_name