Files
Fighting_Rthythm_game/scenes/characters/player.gd
2026-07-02 05:46:33 -07:00

549 lines
16 KiB
GDScript

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