Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+890
View File
@@ -0,0 +1,890 @@
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