891 lines
34 KiB
GDScript
891 lines
34 KiB
GDScript
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
|