Initial project sync
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
class_name AudioLayerController
|
||||
extends Node
|
||||
|
||||
## Presentation-layer dual music loops (AnchorV1.0 chapter 20).
|
||||
## Past and Future layers start together and stay in sync forever; a phase
|
||||
## switch only crossfades volumes. The clock authority stays with
|
||||
## RhythmManager — drift correction seeks THESE players, never the clock.
|
||||
##
|
||||
## Placeholder assets: both layers use the same song; the Future layer is
|
||||
## routed through a low-pass bus so the switch is audible until a real
|
||||
## Future loop asset lands (BPM/beat-aligned per the design doc).
|
||||
|
||||
const FUTURE_BUS_NAME := "TimePhaseFuture"
|
||||
const MUSIC_BUS_NAME := "Music"
|
||||
|
||||
@export var past_stream: AudioStream
|
||||
@export var future_stream: AudioStream
|
||||
@export var default_stream_path := "res://assets/audio/ev_past1.mp3"
|
||||
@export var crossfade_seconds := 0.35
|
||||
@export var active_volume_db := 0.0
|
||||
@export var inactive_volume_db := -60.0
|
||||
@export var drift_check_interval := 2.0
|
||||
@export var drift_threshold_seconds := 0.015
|
||||
@export var mute_clock := true
|
||||
@export var future_lowpass_cutoff_hz := 2200.0
|
||||
|
||||
var past_player: AudioStreamPlayer
|
||||
var future_player: AudioStreamPlayer
|
||||
|
||||
var _fade_tween: Tween
|
||||
var _drift_accum := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
if past_stream == null and ResourceLoader.exists(default_stream_path):
|
||||
past_stream = load(default_stream_path)
|
||||
if future_stream == null:
|
||||
future_stream = past_stream
|
||||
_ensure_future_bus()
|
||||
# 双音轨走 Music 总线(Future 层经 TimePhaseFuture 低通后 send 到 Music),
|
||||
# 与标题音乐一致,音乐音量设置才能同时约束标题与局内音乐。
|
||||
past_player = _make_layer_player("PastLoop", past_stream, MUSIC_BUS_NAME if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1 else "Master")
|
||||
future_player = _make_layer_player("FutureLoop", future_stream, FUTURE_BUS_NAME)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
_start_layers()
|
||||
_apply_phase_volumes(_current_time_phase(), true)
|
||||
if mute_clock:
|
||||
var rhythm := _rhythm_manager_or_null()
|
||||
if rhythm != null:
|
||||
rhythm.set("volume_db", -80.0)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if past_player == null:
|
||||
return
|
||||
_drift_accum += delta
|
||||
if _drift_accum < drift_check_interval:
|
||||
return
|
||||
_drift_accum = 0.0
|
||||
_correct_layer_drift()
|
||||
|
||||
|
||||
## Snaps both layers back onto the clock immediately (a fresh run just
|
||||
## re-zeroed RhythmManager) instead of waiting for the next drift check.
|
||||
func restart_layers() -> void:
|
||||
_start_layers()
|
||||
_apply_phase_volumes(_current_time_phase(), true)
|
||||
_drift_accum = 0.0
|
||||
|
||||
|
||||
func _start_layers() -> void:
|
||||
var rhythm := _rhythm_manager_or_null()
|
||||
var start_position := 0.0
|
||||
if rhythm != null and rhythm.has_method("song_position"):
|
||||
start_position = maxf(0.0, float(rhythm.call("song_position")))
|
||||
for player: AudioStreamPlayer in [past_player, future_player]:
|
||||
if player != null and player.stream != null:
|
||||
player.play(start_position)
|
||||
|
||||
|
||||
func _correct_layer_drift() -> void:
|
||||
var rhythm := _rhythm_manager_or_null()
|
||||
if rhythm == null or not rhythm.has_method("song_position"):
|
||||
return
|
||||
if not bool(rhythm.get("running")):
|
||||
return
|
||||
var target := float(rhythm.call("song_position"))
|
||||
for player: AudioStreamPlayer in [past_player, future_player]:
|
||||
if player == null or not player.playing or player.stream == null:
|
||||
continue
|
||||
var length := float(player.stream.get_length())
|
||||
var wrapped_target := fmod(target, length) if length > 0.0 else target
|
||||
var raw_drift := absf(player.get_playback_position() - wrapped_target)
|
||||
var drift := minf(raw_drift, length - raw_drift) if length > 0.0 else raw_drift
|
||||
if drift > drift_threshold_seconds:
|
||||
player.seek(wrapped_target)
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
_apply_phase_volumes(current, false)
|
||||
|
||||
|
||||
func _apply_phase_volumes(time_phase: StringName, instant: bool) -> void:
|
||||
if past_player == null or future_player == null:
|
||||
return
|
||||
var past_target := active_volume_db if time_phase != &"future" else inactive_volume_db
|
||||
var future_target := active_volume_db if time_phase == &"future" else inactive_volume_db
|
||||
if _fade_tween != null and _fade_tween.is_valid():
|
||||
_fade_tween.kill()
|
||||
if instant:
|
||||
past_player.volume_db = past_target
|
||||
future_player.volume_db = future_target
|
||||
return
|
||||
_fade_tween = create_tween()
|
||||
_fade_tween.set_parallel(true)
|
||||
_fade_tween.tween_property(past_player, "volume_db", past_target, crossfade_seconds)
|
||||
_fade_tween.tween_property(future_player, "volume_db", future_target, crossfade_seconds)
|
||||
|
||||
|
||||
func _make_layer_player(player_name: String, stream: AudioStream, bus_name: String) -> AudioStreamPlayer:
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = player_name
|
||||
player.stream = stream
|
||||
player.volume_db = inactive_volume_db
|
||||
if AudioServer.get_bus_index(bus_name) != -1:
|
||||
player.bus = bus_name
|
||||
add_child(player)
|
||||
return player
|
||||
|
||||
|
||||
func _ensure_future_bus() -> void:
|
||||
var existing := AudioServer.get_bus_index(FUTURE_BUS_NAME)
|
||||
if existing != -1:
|
||||
_route_bus_to_music(existing)
|
||||
return
|
||||
AudioServer.add_bus()
|
||||
var bus_index := AudioServer.bus_count - 1
|
||||
AudioServer.set_bus_name(bus_index, FUTURE_BUS_NAME)
|
||||
var lowpass := AudioEffectLowPassFilter.new()
|
||||
lowpass.cutoff_hz = future_lowpass_cutoff_hz
|
||||
AudioServer.add_bus_effect(bus_index, lowpass)
|
||||
_route_bus_to_music(bus_index)
|
||||
|
||||
|
||||
func _route_bus_to_music(bus_index: int) -> void:
|
||||
if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1:
|
||||
AudioServer.set_bus_send(bus_index, MUSIC_BUS_NAME)
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _rhythm_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("RhythmManager")
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3agddgh7fn4a
|
||||
@@ -0,0 +1,139 @@
|
||||
class_name Character
|
||||
extends CharacterBody2D
|
||||
|
||||
const GRAVITY := 1200.0
|
||||
|
||||
@export var speed := 180.0
|
||||
@export var jump_intensity := 304.056
|
||||
@export var attack_duration := 0.4
|
||||
@export var attack_lunge_duration := 0.18
|
||||
@export var attack_lunge_speed := 220.0
|
||||
@export var air_attack_duration := 0.45
|
||||
@export var air_attack_lunge_duration := 0.22
|
||||
@export var air_attack_lunge_speed := 260.0
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
@onready var character_sprite: Sprite2D = $Visual/CharacterSprite
|
||||
|
||||
const PRESENTATION_IDLE := &"idle"
|
||||
const PRESENTATION_WALK := &"walk"
|
||||
const PRESENTATION_JUMP := &"jump"
|
||||
const PRESENTATION_LAND := &"land"
|
||||
const PRESENTATION_ATTACK := &"attack"
|
||||
const PRESENTATION_AIR_ATTACK := &"air_attack"
|
||||
|
||||
var anim_map := {
|
||||
PRESENTATION_IDLE: "idle",
|
||||
PRESENTATION_WALK: "idle",
|
||||
PRESENTATION_JUMP: "rising_slash",
|
||||
PRESENTATION_LAND: "idle",
|
||||
PRESENTATION_ATTACK: "atk_ground_1",
|
||||
PRESENTATION_AIR_ATTACK: "atk_air",
|
||||
}
|
||||
var attack_direction := Vector2.LEFT
|
||||
var attack_lunge_time_left := 0.0
|
||||
var attack_time_left := 0.0
|
||||
var heading := Vector2.LEFT
|
||||
var height := 0.0
|
||||
var height_speed := 0.0
|
||||
var state := PRESENTATION_IDLE
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
handle_input()
|
||||
handle_air_time(delta)
|
||||
handle_attack_time(delta)
|
||||
handle_movement()
|
||||
handle_animations()
|
||||
set_sprite_height_position()
|
||||
set_heading()
|
||||
flip_sprites()
|
||||
move_and_slide()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
if state != PRESENTATION_JUMP:
|
||||
return
|
||||
height += height_speed * delta
|
||||
if height <= 0.0 and height_speed < 0.0:
|
||||
height = 0.0
|
||||
height_speed = 0.0
|
||||
state = PRESENTATION_LAND
|
||||
else:
|
||||
height_speed -= GRAVITY * delta
|
||||
|
||||
|
||||
func handle_attack_time(delta: float) -> void:
|
||||
if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
velocity.y = 0.0
|
||||
attack_time_left -= delta
|
||||
if attack_time_left <= 0.0:
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
attack_lunge_time_left -= delta
|
||||
if attack_lunge_time_left <= 0.0:
|
||||
velocity.x = 0.0
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
if state == PRESENTATION_LAND:
|
||||
state = PRESENTATION_IDLE
|
||||
elif absf(velocity.x) > 0.0:
|
||||
state = PRESENTATION_WALK
|
||||
else:
|
||||
state = PRESENTATION_IDLE
|
||||
|
||||
|
||||
func handle_animations() -> void:
|
||||
var animation_name: String = anim_map.get(state, "idle")
|
||||
if animation_player.has_animation(animation_name) and animation_player.current_animation != animation_name:
|
||||
animation_player.play(animation_name)
|
||||
|
||||
|
||||
func set_sprite_height_position() -> void:
|
||||
if visual != null:
|
||||
visual.position = Vector2.UP * height
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func flip_sprites() -> void:
|
||||
if visual == null:
|
||||
return
|
||||
var base_scale := absf(visual.scale.x)
|
||||
visual.scale.x = base_scale if heading == Vector2.LEFT else -base_scale
|
||||
|
||||
|
||||
func can_jump() -> bool:
|
||||
return state == PRESENTATION_IDLE or state == PRESENTATION_WALK
|
||||
|
||||
|
||||
func start_jump() -> void:
|
||||
state = PRESENTATION_JUMP
|
||||
height_speed = jump_intensity
|
||||
|
||||
|
||||
func begin_attack_motion(duration: float, next_velocity: Vector2) -> void:
|
||||
attack_lunge_time_left = maxf(0.0, duration)
|
||||
velocity = next_velocity
|
||||
|
||||
|
||||
func stop_attack_motion() -> void:
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfrt3ewrqd07i
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcpjibn5dex88
|
||||
@@ -0,0 +1,532 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/characters/player.gd" id="1_player_script"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/16_idle/idle_loop.png" id="2_idle"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/air_prep.png" id="3_atk1_prep"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/air_swing.png" id="4_atk1_swing"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png" id="5_atk1_fx"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/air_prep.png" id="6_atk2_prep"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/air_swing.png" id="7_atk2_swing"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png" id="8_atk2_fx"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/prep.png" id="9_rise_prep"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/swing.png" id="10_rise_swing"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/slash_overlay_fx.png" id="11_rise_fx"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/17_turn_around/turn.png" id="12_turn"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/characters/player/18_block/air_block_start.png" id="25_block_start"]
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="13_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/input_component.gd" id="14_input_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="15_movement_motor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="16_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="17_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="18_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="19_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="20_motion_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/burst_component.gd" id="21_burst_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/charge_component.gd" id="22_charge_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/energy_component.gd" id="23_energy_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="24_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="26_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="27_damage_receiver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="28_health_component"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/contact_fx_spawner.gd" id="31_contact_fx"]
|
||||
[ext_resource type="Script" path="res://scenes/components/streak_counter.gd" id="32_streak_counter"]
|
||||
[ext_resource type="Script" path="res://scenes/components/attack_buff_component.gd" id="33_attack_buff"]
|
||||
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="34_frame_collision"]
|
||||
[ext_resource type="Script" path="res://scenes/components/attack_buff_visual.gd" id="35_attack_buff_visual"]
|
||||
[ext_resource type="Script" path="res://scenes/components/overhead_charge_segments.gd" id="36_overhead_charge_segments"]
|
||||
[ext_resource type="Resource" path="res://resources/effects/effect_on_perfect_damage_reward.tres" id="29_perfect_reward"]
|
||||
[ext_resource type="Resource" path="res://resources/effects/effect_on_landed_haste.tres" id="30_landed_haste"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"]
|
||||
size = Vector2(18, 48)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(48, 40)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
|
||||
size = Vector2(22, 52)
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle"]
|
||||
resource_name = "idle"
|
||||
length = 0.4
|
||||
loop_mode = 1
|
||||
step = 0.05
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("2_idle")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [4]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4, 5, 6, 7]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_atk_ground_1"]
|
||||
resource_name = "atk_ground_1"
|
||||
length = 0.35
|
||||
step = 0.05
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0, 0.15), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("3_atk1_prep"), ExtResource("4_atk1_swing")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:texture")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("5_atk1_fx")]}
|
||||
tracks/5/type = "value"
|
||||
tracks/5/imported = false
|
||||
tracks/5/enabled = true
|
||||
tracks/5/path = NodePath("Visual/FxOverlay:hframes")
|
||||
tracks/5/interp = 1
|
||||
tracks/5/loop_wrap = true
|
||||
tracks/5/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]}
|
||||
tracks/6/type = "value"
|
||||
tracks/6/imported = false
|
||||
tracks/6/enabled = true
|
||||
tracks/6/path = NodePath("Visual/FxOverlay:vframes")
|
||||
tracks/6/interp = 1
|
||||
tracks/6/loop_wrap = true
|
||||
tracks/6/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]}
|
||||
tracks/7/type = "value"
|
||||
tracks/7/imported = false
|
||||
tracks/7/enabled = true
|
||||
tracks/7/path = NodePath("Visual/FxOverlay:frame")
|
||||
tracks/7/interp = 1
|
||||
tracks/7/loop_wrap = true
|
||||
tracks/7/keys = {"times": PackedFloat32Array(0.15, 0.2, 0.25, 0.3), "transitions": PackedFloat32Array(1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4]}
|
||||
tracks/8/type = "value"
|
||||
tracks/8/imported = false
|
||||
tracks/8/enabled = true
|
||||
tracks/8/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/8/interp = 1
|
||||
tracks/8/loop_wrap = true
|
||||
tracks/8/keys = {"times": PackedFloat32Array(0, 0.15, 0.35), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]}
|
||||
tracks/9/type = "value"
|
||||
tracks/9/imported = false
|
||||
tracks/9/enabled = true
|
||||
tracks/9/path = NodePath("Visual/FxOverlay:offset")
|
||||
tracks/9/interp = 1
|
||||
tracks/9/loop_wrap = true
|
||||
tracks/9/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-147, -191)]}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_atk_ground_2"]
|
||||
resource_name = "atk_ground_2"
|
||||
length = 0.35
|
||||
step = 0.05
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0, 0.15), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("6_atk2_prep"), ExtResource("7_atk2_swing")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:texture")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("8_atk2_fx")]}
|
||||
tracks/5/type = "value"
|
||||
tracks/5/imported = false
|
||||
tracks/5/enabled = true
|
||||
tracks/5/path = NodePath("Visual/FxOverlay:hframes")
|
||||
tracks/5/interp = 1
|
||||
tracks/5/loop_wrap = true
|
||||
tracks/5/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]}
|
||||
tracks/6/type = "value"
|
||||
tracks/6/imported = false
|
||||
tracks/6/enabled = true
|
||||
tracks/6/path = NodePath("Visual/FxOverlay:vframes")
|
||||
tracks/6/interp = 1
|
||||
tracks/6/loop_wrap = true
|
||||
tracks/6/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]}
|
||||
tracks/7/type = "value"
|
||||
tracks/7/imported = false
|
||||
tracks/7/enabled = true
|
||||
tracks/7/path = NodePath("Visual/FxOverlay:frame")
|
||||
tracks/7/interp = 1
|
||||
tracks/7/loop_wrap = true
|
||||
tracks/7/keys = {"times": PackedFloat32Array(0.15, 0.2, 0.25, 0.3), "transitions": PackedFloat32Array(1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4]}
|
||||
tracks/8/type = "value"
|
||||
tracks/8/imported = false
|
||||
tracks/8/enabled = true
|
||||
tracks/8/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/8/interp = 1
|
||||
tracks/8/loop_wrap = true
|
||||
tracks/8/keys = {"times": PackedFloat32Array(0, 0.15, 0.35), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]}
|
||||
tracks/9/type = "value"
|
||||
tracks/9/imported = false
|
||||
tracks/9/enabled = true
|
||||
tracks/9/path = NodePath("Visual/FxOverlay:offset")
|
||||
tracks/9/interp = 1
|
||||
tracks/9/loop_wrap = true
|
||||
tracks/9/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-147, -191)]}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_rising_slash"]
|
||||
resource_name = "rising_slash"
|
||||
length = 0.42
|
||||
step = 0.03
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("9_rise_prep"), ExtResource("10_rise_swing")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [3, 5]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [1, 2]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.03, 0.06, 0.09, 0.12, 0.15, 0.18, 0.21, 0.24, 0.27, 0.3, 0.33, 0.36), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:texture")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("11_rise_fx")]}
|
||||
tracks/5/type = "value"
|
||||
tracks/5/imported = false
|
||||
tracks/5/enabled = true
|
||||
tracks/5/path = NodePath("Visual/FxOverlay:hframes")
|
||||
tracks/5/interp = 1
|
||||
tracks/5/loop_wrap = true
|
||||
tracks/5/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]}
|
||||
tracks/6/type = "value"
|
||||
tracks/6/imported = false
|
||||
tracks/6/enabled = true
|
||||
tracks/6/path = NodePath("Visual/FxOverlay:vframes")
|
||||
tracks/6/interp = 1
|
||||
tracks/6/loop_wrap = true
|
||||
tracks/6/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]}
|
||||
tracks/7/type = "value"
|
||||
tracks/7/imported = false
|
||||
tracks/7/enabled = true
|
||||
tracks/7/path = NodePath("Visual/FxOverlay:frame")
|
||||
tracks/7/interp = 1
|
||||
tracks/7/loop_wrap = true
|
||||
tracks/7/keys = {"times": PackedFloat32Array(0.09, 0.12, 0.15, 0.18, 0.21, 0.24, 0.27, 0.3, 0.33), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||||
tracks/8/type = "value"
|
||||
tracks/8/imported = false
|
||||
tracks/8/enabled = true
|
||||
tracks/8/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/8/interp = 1
|
||||
tracks/8/loop_wrap = true
|
||||
tracks/8/keys = {"times": PackedFloat32Array(0, 0.09, 0.42), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]}
|
||||
tracks/9/type = "value"
|
||||
tracks/9/imported = false
|
||||
tracks/9/enabled = true
|
||||
tracks/9/path = NodePath("Visual/FxOverlay:offset")
|
||||
tracks/9/interp = 1
|
||||
tracks/9/loop_wrap = true
|
||||
tracks/9/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-111, -156)]}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_block_start"]
|
||||
resource_name = "block_start"
|
||||
length = 0.25
|
||||
step = 0.05
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("25_block_start")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2), "transitions": PackedFloat32Array(1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_turn"]
|
||||
resource_name = "turn"
|
||||
length = 0.15
|
||||
step = 0.025
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Visual/CharacterSprite:texture")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("12_turn")]}
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("Visual/CharacterSprite:hframes")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]}
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("Visual/CharacterSprite:vframes")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]}
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("Visual/CharacterSprite:frame")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {"times": PackedFloat32Array(0, 0.025, 0.05, 0.075, 0.1, 0.125), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4, 5]}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("Visual/FxOverlay:visible")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_player"]
|
||||
_data = {
|
||||
&"atk_ground_1": SubResource("Animation_atk_ground_1"),
|
||||
&"atk_ground_2": SubResource("Animation_atk_ground_2"),
|
||||
&"block_start": SubResource("Animation_block_start"),
|
||||
&"idle": SubResource("Animation_idle"),
|
||||
&"rising_slash": SubResource("Animation_rising_slash"),
|
||||
&"turn": SubResource("Animation_turn")
|
||||
}
|
||||
|
||||
[node name="Player" type="CharacterBody2D"]
|
||||
collision_layer = 32
|
||||
collision_mask = 65
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
script = ExtResource("1_player_script")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="CharacterSprite" type="Sprite2D" parent="Visual"]
|
||||
texture_filter = 2
|
||||
texture = ExtResource("2_idle")
|
||||
centered = false
|
||||
offset = Vector2(-64, -128)
|
||||
hframes = 4
|
||||
vframes = 2
|
||||
|
||||
[node name="FxOverlay" type="Sprite2D" parent="Visual"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
texture_filter = 2
|
||||
centered = false
|
||||
|
||||
[node name="AttackBuffVisual" type="Node2D" parent="Visual"]
|
||||
z_index = 1
|
||||
script = ExtResource("35_attack_buff_visual")
|
||||
|
||||
[node name="FxAnimationPlayer" type="AnimationPlayer" parent="Visual"]
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, -72)
|
||||
shape = SubResource("RectangleShape2D_player")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="."]
|
||||
collision_layer = 8
|
||||
collision_mask = 4
|
||||
monitoring = false
|
||||
script = ExtResource("26_damage_emitter")
|
||||
damage = 100
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"]
|
||||
position = Vector2(0, -72)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="DamageReceiver" type="Area2D" parent="."]
|
||||
collision_layer = 2
|
||||
collision_mask = 16
|
||||
script = ExtResource("27_damage_receiver")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"]
|
||||
position = Vector2(0, -72)
|
||||
shape = SubResource("RectangleShape2D_hurtbox")
|
||||
|
||||
[node name="OverheadChargeSegments" type="Node2D" parent="."]
|
||||
visible = false
|
||||
position = Vector2(0, -18)
|
||||
z_index = 96
|
||||
script = ExtResource("36_overhead_charge_segments")
|
||||
segment_count = 3
|
||||
segment_size = Vector2(42, 12)
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries/ = SubResource("AnimationLibrary_player")
|
||||
autoplay = &"idle"
|
||||
|
||||
[node name="ContactFxSpawner" type="Node" parent="."]
|
||||
script = ExtResource("31_contact_fx")
|
||||
|
||||
[node name="StateMachine" type="Node" parent="."]
|
||||
script = ExtResource("13_state_machine")
|
||||
|
||||
[node name="InputComponent" type="Node" parent="."]
|
||||
script = ExtResource("14_input_component")
|
||||
|
||||
[node name="MovementMotor" type="Node" parent="."]
|
||||
script = ExtResource("15_movement_motor")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="."]
|
||||
script = ExtResource("16_combo_window")
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="."]
|
||||
script = ExtResource("17_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("18_action_executor")
|
||||
energy_component_path = NodePath("../EnergyComponent")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="."]
|
||||
script = ExtResource("19_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
burst_component_path = NodePath("../BurstComponent")
|
||||
|
||||
[node name="MotionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("20_motion_executor")
|
||||
|
||||
[node name="BurstComponent" type="Node" parent="."]
|
||||
script = ExtResource("21_burst_component")
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="."]
|
||||
script = ExtResource("24_effect_container")
|
||||
initial_effects = Array[Resource]([ExtResource("29_perfect_reward"), ExtResource("30_landed_haste")])
|
||||
|
||||
[node name="HealthComponent" type="Node" parent="."]
|
||||
script = ExtResource("28_health_component")
|
||||
maximum = 1000
|
||||
current = 1000
|
||||
hitstun_seconds = 0.4
|
||||
|
||||
[node name="ChargeComponent" type="Node" parent="."]
|
||||
script = ExtResource("22_charge_component")
|
||||
animation_player_path = NodePath("../AnimationPlayer")
|
||||
effect_sprite_path = NodePath("../Visual/FxOverlay")
|
||||
|
||||
[node name="EnergyComponent" type="Node" parent="."]
|
||||
script = ExtResource("23_energy_component")
|
||||
|
||||
[node name="StreakCounter" type="Node" parent="."]
|
||||
script = ExtResource("32_streak_counter")
|
||||
|
||||
[node name="AttackBuffComponent" type="Node" parent="."]
|
||||
script = ExtResource("33_attack_buff")
|
||||
|
||||
[node name="FrameCollisionDriver" type="Node" parent="."]
|
||||
script = ExtResource("34_frame_collision")
|
||||
@@ -0,0 +1,185 @@
|
||||
class_name ChartRunner
|
||||
extends Node
|
||||
|
||||
signal chart_event_upcoming(event: Resource, time_to_event: float)
|
||||
signal chart_event_triggered(event: Resource)
|
||||
signal chart_reset(chart_id: StringName)
|
||||
signal chart_finished(chart_id: StringName)
|
||||
|
||||
const UPCOMING_TIME_EPSILON := 0.02
|
||||
|
||||
@export var chart: Resource
|
||||
@export var rhythm_manager_path: NodePath
|
||||
@export var actors_container_path: NodePath
|
||||
@export var beat_time_override := 0.0
|
||||
@export var auto_run := true
|
||||
|
||||
var running := true
|
||||
var _upcoming_keys: Dictionary = {}
|
||||
var _triggered_keys: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
running = auto_run
|
||||
_apply_initial_time_phase()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if not running or chart == null:
|
||||
return
|
||||
var rhythm := _rhythm_manager()
|
||||
if rhythm == null or not rhythm.has_method("song_position"):
|
||||
return
|
||||
update_for_song_time(float(rhythm.call("song_position")))
|
||||
|
||||
|
||||
func set_chart(next_chart: Resource) -> void:
|
||||
chart = next_chart
|
||||
reset()
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
_upcoming_keys.clear()
|
||||
_triggered_keys.clear()
|
||||
var chart_id := &""
|
||||
if chart != null:
|
||||
chart_id = StringName(str(chart.get("chart_id")))
|
||||
chart_reset.emit(chart_id)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null:
|
||||
bus.emit_signal("chart_reset", chart_id)
|
||||
_apply_initial_time_phase()
|
||||
|
||||
|
||||
func update_for_song_time(song_time: float) -> void:
|
||||
if chart == null:
|
||||
return
|
||||
var beat_time := _beat_time()
|
||||
for event: Resource in chart.call("all_events"):
|
||||
var event_time := float(event.call("time_seconds", beat_time))
|
||||
var time_to_event := event_time - song_time
|
||||
var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time
|
||||
var event_key: StringName = event.call("key")
|
||||
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON:
|
||||
if _time_phase_allows(event):
|
||||
_upcoming_keys[event_key] = true
|
||||
_emit_upcoming(event, time_to_event)
|
||||
if not _triggered_keys.has(event_key) and song_time >= event_time:
|
||||
_triggered_keys[event_key] = true
|
||||
if _time_phase_allows(event):
|
||||
_emit_triggered(event)
|
||||
|
||||
|
||||
func pause() -> void:
|
||||
running = false
|
||||
|
||||
|
||||
func resume() -> void:
|
||||
running = true
|
||||
|
||||
|
||||
func _emit_upcoming(event: Resource, time_to_event: float) -> void:
|
||||
chart_event_upcoming.emit(event, time_to_event)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null:
|
||||
bus.emit_signal("chart_event_upcoming", event, time_to_event)
|
||||
|
||||
|
||||
func _emit_triggered(event: Resource) -> void:
|
||||
_dispatch_to_target_actor(event)
|
||||
chart_event_triggered.emit(event)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null:
|
||||
bus.emit_signal("chart_event_triggered", event)
|
||||
|
||||
|
||||
func _dispatch_to_target_actor(event: Resource) -> void:
|
||||
if event == null:
|
||||
return
|
||||
var action_id := StringName(str(event.get("action_id")))
|
||||
if action_id.is_empty():
|
||||
return
|
||||
var target_id := StringName(str(event.get("target_id")))
|
||||
if target_id.is_empty():
|
||||
return
|
||||
var actors := _actors_container()
|
||||
if actors == null:
|
||||
return
|
||||
var target := actors.get_node_or_null(NodePath(str(target_id)))
|
||||
if target == null:
|
||||
return
|
||||
var driver := target.get_node_or_null("EnemyActionDriver")
|
||||
if driver != null and driver.has_method("handle_chart_event"):
|
||||
driver.call("handle_chart_event", event)
|
||||
|
||||
|
||||
func _time_phase_allows(event: Resource) -> bool:
|
||||
if event == null:
|
||||
return false
|
||||
if event.has_method("matches_time_phase"):
|
||||
var manager := _time_phase_manager()
|
||||
if manager == null:
|
||||
return true
|
||||
return bool(event.call("matches_time_phase", StringName(str(manager.get("current_time_phase")))))
|
||||
return true
|
||||
|
||||
|
||||
func _apply_initial_time_phase() -> void:
|
||||
if chart == null:
|
||||
return
|
||||
var initial = chart.get("initial_time_phase")
|
||||
if initial == null:
|
||||
return
|
||||
var manager := _time_phase_manager()
|
||||
if manager != null and manager.has_method("reset_to_initial"):
|
||||
manager.call("reset_to_initial", StringName(str(initial)))
|
||||
|
||||
|
||||
func _time_phase_manager() -> Node:
|
||||
# Reverse root scan (matching TimeAnchorSystem): a test-added manager wins
|
||||
# over the autoload, since name collisions get auto-renamed on add_child.
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return _last_root_child_matching(func(child: Node) -> bool:
|
||||
return child.has_method("set_time_phase") and child.get("current_time_phase") != null
|
||||
)
|
||||
|
||||
|
||||
func _beat_time() -> float:
|
||||
if beat_time_override > 0.0:
|
||||
return beat_time_override
|
||||
var rhythm := _rhythm_manager()
|
||||
if rhythm != null:
|
||||
return float(rhythm.get("beat_time"))
|
||||
return 0.5
|
||||
|
||||
|
||||
func _rhythm_manager() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
if not rhythm_manager_path.is_empty():
|
||||
return get_node_or_null(rhythm_manager_path)
|
||||
return get_tree().root.get_node_or_null("RhythmManager")
|
||||
|
||||
|
||||
func _actors_container() -> Node:
|
||||
if not is_inside_tree() or actors_container_path.is_empty():
|
||||
return null
|
||||
return get_node_or_null(actors_container_path)
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return _last_root_child_matching(func(child: Node) -> bool:
|
||||
return child != self and child.has_signal("chart_event_triggered") and child.has_signal("chart_reset") and child.has_signal("judgement_made")
|
||||
)
|
||||
|
||||
|
||||
func _last_root_child_matching(predicate: Callable) -> Node:
|
||||
var children := get_tree().root.get_children()
|
||||
for index: int in range(children.size() - 1, -1, -1):
|
||||
var child: Node = children[index]
|
||||
if bool(predicate.call(child)):
|
||||
return child
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://vea0brdgpneb
|
||||
@@ -0,0 +1,197 @@
|
||||
class_name ActionResolver
|
||||
extends Node
|
||||
|
||||
const ACTION_DIR := "res://resources/actions"
|
||||
const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd")
|
||||
|
||||
const BASIC_TRAILING_SUFFIXES := ["W", "A", "D", "S"]
|
||||
|
||||
static var _loaded := false
|
||||
static var _actions_by_pattern: Dictionary = {}
|
||||
static var _actions_by_id: Dictionary = {}
|
||||
static var _space_priority_labels: Array[StringName] = [
|
||||
&"burst",
|
||||
&"counter_projectile",
|
||||
&"blade_chain",
|
||||
&"state_specific",
|
||||
&"exact_pattern",
|
||||
&"fallback",
|
||||
]
|
||||
|
||||
|
||||
func resolve_window(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||
return resolve(window, state_machine, context)
|
||||
|
||||
|
||||
func resolve_text_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||
return resolve_pattern(pattern, state_machine, context)
|
||||
|
||||
|
||||
static func resolve(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||
return resolve_pattern(window.get_contiguous_pattern(), state_machine, context)
|
||||
|
||||
|
||||
static func resolve_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}, allow_basic_trailing_fallback := true) -> Resource:
|
||||
_ensure_loaded()
|
||||
var key := _normalize_pattern(pattern)
|
||||
var resolver_context := _resolver_context(state_machine, context)
|
||||
if key.ends_with("SP"):
|
||||
var priority_result := _resolve_space_priority(key, resolver_context)
|
||||
if priority_result != null:
|
||||
return priority_result
|
||||
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||
for action: Resource in candidates:
|
||||
if _can_execute(action, resolver_context):
|
||||
return action
|
||||
var trailing_basic := _resolve_trailing_basic_suffix(key, resolver_context, allow_basic_trailing_fallback)
|
||||
if trailing_basic != null:
|
||||
return trailing_basic
|
||||
return null
|
||||
|
||||
|
||||
static func get_action(action_id: StringName) -> Resource:
|
||||
_ensure_loaded()
|
||||
return _actions_by_id.get(action_id, null) as Resource
|
||||
|
||||
|
||||
static func space_priority_labels() -> Array[StringName]:
|
||||
return _space_priority_labels.duplicate()
|
||||
|
||||
|
||||
static func reload(action_dir := ACTION_DIR) -> void:
|
||||
_loaded = true
|
||||
_actions_by_pattern.clear()
|
||||
_actions_by_id.clear()
|
||||
_load_dir(action_dir)
|
||||
|
||||
|
||||
static func clear_cache() -> void:
|
||||
_actions_by_pattern.clear()
|
||||
_actions_by_id.clear()
|
||||
_loaded = false
|
||||
|
||||
|
||||
static func _ensure_loaded() -> void:
|
||||
if not _loaded:
|
||||
reload()
|
||||
|
||||
|
||||
static func _load_dir(action_dir: String) -> void:
|
||||
var dir := DirAccess.open(action_dir)
|
||||
if dir == null:
|
||||
return
|
||||
var subdirs := dir.get_directories()
|
||||
subdirs.sort()
|
||||
for subdir: String in subdirs:
|
||||
_load_dir("%s/%s" % [action_dir, subdir])
|
||||
# 导出包(pck)内的目录列表把资源显示为 xxx.tres.remap,直接按 .tres
|
||||
# 过滤会一个都匹配不上,导致动作库为空(玩家出招和敌人攻击全部失效)。
|
||||
# 还原原始文件名再 load(),remap 表会命中 pck 内的实际资源。
|
||||
var seen := {}
|
||||
var files := dir.get_files()
|
||||
files.sort()
|
||||
for file_name: String in files:
|
||||
var resource_name := file_name.trim_suffix(".remap")
|
||||
if not resource_name.ends_with(".tres") or seen.has(resource_name):
|
||||
continue
|
||||
seen[resource_name] = true
|
||||
var action: Resource = load("%s/%s" % [action_dir, resource_name])
|
||||
if action == null or not action.get("input_pattern") is Array:
|
||||
continue
|
||||
_register_action(action)
|
||||
|
||||
|
||||
static func _register_action(action: Resource) -> void:
|
||||
var id := StringName(str(action.get("id")))
|
||||
if not id.is_empty():
|
||||
_actions_by_id[id] = action
|
||||
var key := _pattern_key(action.get("input_pattern"))
|
||||
if key.is_empty():
|
||||
return
|
||||
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||
candidates.append(action)
|
||||
_actions_by_pattern[key] = candidates
|
||||
|
||||
|
||||
static func _resolve_space_priority(key: String, context: Dictionary) -> Resource:
|
||||
for action_id_key: String in [
|
||||
"burst_action_id",
|
||||
"counter_action_id",
|
||||
"blade_chain_action_id",
|
||||
]:
|
||||
if not context.has(action_id_key):
|
||||
continue
|
||||
if action_id_key == "counter_action_id" and not bool(context.get("counter_ready", false)):
|
||||
continue
|
||||
if action_id_key == "blade_chain_action_id" and not bool(context.get("blade_chain_active", false)):
|
||||
continue
|
||||
var explicit_action := get_action(StringName(str(context[action_id_key])))
|
||||
if explicit_action != null and _can_execute(explicit_action, context):
|
||||
return explicit_action
|
||||
|
||||
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||
for action: Resource in candidates:
|
||||
if _can_execute(action, context):
|
||||
return action
|
||||
var suffix_action := _resolve_trailing_space_suffix(key, context)
|
||||
if suffix_action != null:
|
||||
return suffix_action
|
||||
return null
|
||||
|
||||
|
||||
static func _resolve_trailing_space_suffix(key: String, context: Dictionary) -> Resource:
|
||||
var best_action: Resource = null
|
||||
var best_length := 0
|
||||
for candidate_key: String in _actions_by_pattern.keys():
|
||||
if candidate_key == key:
|
||||
continue
|
||||
if not candidate_key.ends_with("SP"):
|
||||
continue
|
||||
if candidate_key.length() <= best_length:
|
||||
continue
|
||||
if not key.ends_with(candidate_key):
|
||||
continue
|
||||
for action: Resource in _actions_by_pattern.get(candidate_key, []):
|
||||
if _can_execute(action, context):
|
||||
best_action = action
|
||||
best_length = candidate_key.length()
|
||||
break
|
||||
return best_action
|
||||
|
||||
|
||||
static func _resolve_trailing_basic_suffix(key: String, context: Dictionary, allow_basic_trailing_fallback: bool) -> Resource:
|
||||
if not allow_basic_trailing_fallback:
|
||||
return null
|
||||
for candidate_key: String in BASIC_TRAILING_SUFFIXES:
|
||||
if key == candidate_key:
|
||||
continue
|
||||
if not key.ends_with(candidate_key):
|
||||
continue
|
||||
for action: Resource in _actions_by_pattern.get(candidate_key, []):
|
||||
if _can_execute(action, context):
|
||||
return action
|
||||
return null
|
||||
|
||||
|
||||
static func _pattern_key(pattern: Array[StringName]) -> String:
|
||||
var key := ""
|
||||
for symbol: StringName in pattern:
|
||||
key += str(symbol)
|
||||
return _normalize_pattern(key)
|
||||
|
||||
|
||||
static func _normalize_pattern(pattern: String) -> String:
|
||||
return pattern.replace(" ", "").to_upper()
|
||||
|
||||
|
||||
static func _resolver_context(state_machine: Variant, context: Dictionary) -> Dictionary:
|
||||
var result := context.duplicate()
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
for key: Variant in state_machine.call("build_context").keys():
|
||||
if not result.has(key):
|
||||
result[key] = state_machine.call("build_context")[key]
|
||||
return result
|
||||
|
||||
|
||||
static func _can_execute(action: Resource, context: Dictionary) -> bool:
|
||||
return ActionRuleResolverScript.can_execute(context, action)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnc3bixik3wxn
|
||||
@@ -0,0 +1,74 @@
|
||||
class_name ContactFxSpawner
|
||||
extends Node
|
||||
|
||||
const OneShotFxScript := preload("res://scenes/combat/one_shot_fx.gd")
|
||||
|
||||
const CONTACT_SPECS := {
|
||||
&"basic_1": {"path": "res://assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0},
|
||||
&"basic_2": {"path": "res://assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0},
|
||||
&"basic_3": {"path": "res://assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0},
|
||||
&"finisher": {"path": "res://assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0},
|
||||
&"projectile": {"path": "res://assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png", "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 20.0, "scale": 0.7},
|
||||
}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed):
|
||||
bus.connect("hit_confirmed", _on_hit_confirmed)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("hit_confirmed") and bus.is_connected("hit_confirmed", _on_hit_confirmed):
|
||||
bus.disconnect("hit_confirmed", _on_hit_confirmed)
|
||||
|
||||
|
||||
func _on_hit_confirmed(result: Dictionary) -> void:
|
||||
if int(result.get("damage", 0)) <= 0:
|
||||
return
|
||||
var emitter := result.get("emitter", null) as Area2D
|
||||
var receiver := result.get("receiver", null) as Area2D
|
||||
if emitter == null or receiver == null or not is_instance_valid(emitter) or not is_instance_valid(receiver):
|
||||
return
|
||||
if not _is_our_hit(emitter):
|
||||
return
|
||||
var target := receiver.get_parent() as Node2D
|
||||
if target == null:
|
||||
return
|
||||
var spec: Dictionary = CONTACT_SPECS.get(_spec_key_for(result), {})
|
||||
if spec.is_empty():
|
||||
return
|
||||
var config := spec.duplicate()
|
||||
var from := result.get("from", target.global_position) as Vector2
|
||||
config["flip_h"] = from.x > target.global_position.x
|
||||
OneShotFxScript.spawn(target, target.global_position + Vector2(0.0, -70.0), config)
|
||||
|
||||
|
||||
func _is_our_hit(emitter: Area2D) -> bool:
|
||||
var actor := get_parent()
|
||||
if actor != null and emitter.get_parent() == actor:
|
||||
return true
|
||||
return emitter.is_in_group("player_projectiles")
|
||||
|
||||
|
||||
func _spec_key_for(result: Dictionary) -> StringName:
|
||||
if StringName(str(result.get("hit_type", &""))) == &"projectile":
|
||||
return &"projectile"
|
||||
var action: Resource = result.get("action", null) as Resource
|
||||
if action == null:
|
||||
return &"basic_1"
|
||||
var action_id := str(action.get("id"))
|
||||
if action_id.begins_with("ground_attack") and action_id.ends_with("_2"):
|
||||
return &"basic_2"
|
||||
if action_id.begins_with("ground_attack") and action_id.ends_with("_3"):
|
||||
return &"basic_3"
|
||||
if action_id.begins_with("combo_finisher") or action_id.begins_with("ground_smash"):
|
||||
return &"finisher"
|
||||
return &"basic_1"
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl4cd5x3uc0bl
|
||||
@@ -0,0 +1,81 @@
|
||||
class_name OneShotFx
|
||||
extends Node2D
|
||||
|
||||
var texture: Texture2D
|
||||
var hframes := 1
|
||||
var vframes := 1
|
||||
var first_frame := 0
|
||||
var last_frame := 0
|
||||
var fps := 20.0
|
||||
var delay := 0.0
|
||||
var duration_override := 0.0
|
||||
var velocity := Vector2.ZERO
|
||||
var flip_h := false
|
||||
var sprite_scale := Vector2.ONE
|
||||
var sprite_offset := Vector2.ZERO
|
||||
|
||||
var _sprite: Sprite2D
|
||||
var _elapsed := 0.0
|
||||
|
||||
|
||||
static func spawn(parent: Node, spawn_global_position: Vector2, config: Dictionary) -> Node2D:
|
||||
if parent == null:
|
||||
return null
|
||||
var script: Script = load("res://scenes/combat/one_shot_fx.gd")
|
||||
var fx: Node2D = script.new()
|
||||
var texture_path := str(config.get("path", ""))
|
||||
if ResourceLoader.exists(texture_path):
|
||||
fx.set("texture", load(texture_path))
|
||||
fx.set("hframes", maxi(1, int(config.get("hframes", 1))))
|
||||
fx.set("vframes", maxi(1, int(config.get("vframes", 1))))
|
||||
fx.set("first_frame", maxi(0, int(config.get("first", 0))))
|
||||
fx.set("last_frame", maxi(0, int(config.get("last", 0))))
|
||||
fx.set("fps", maxf(1.0, float(config.get("fps", 20.0))))
|
||||
fx.set("delay", maxf(0.0, float(config.get("delay", 0.0))))
|
||||
fx.set("duration_override", maxf(0.0, float(config.get("duration", 0.0))))
|
||||
fx.set("velocity", config.get("velocity", Vector2.ZERO))
|
||||
fx.set("flip_h", bool(config.get("flip_h", false)))
|
||||
var scale_value := float(config.get("scale", 1.0))
|
||||
fx.set("sprite_scale", Vector2(scale_value, scale_value))
|
||||
fx.set("sprite_offset", config.get("offset", Vector2.ZERO))
|
||||
fx.z_index = int(config.get("z_index", 3))
|
||||
parent.add_child(fx)
|
||||
fx.global_position = spawn_global_position
|
||||
return fx
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if texture == null:
|
||||
queue_free()
|
||||
return
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.texture = texture
|
||||
_sprite.hframes = hframes
|
||||
_sprite.vframes = vframes
|
||||
_sprite.centered = true
|
||||
_sprite.scale = sprite_scale
|
||||
_sprite.offset = sprite_offset
|
||||
_sprite.flip_h = flip_h
|
||||
_sprite.frame = clampi(first_frame, 0, hframes * vframes - 1)
|
||||
_sprite.visible = delay <= 0.0
|
||||
add_child(_sprite)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_elapsed += delta
|
||||
if _elapsed < delay:
|
||||
return
|
||||
if _sprite == null:
|
||||
queue_free()
|
||||
return
|
||||
_sprite.visible = true
|
||||
var play_time := _elapsed - delay
|
||||
position += velocity * delta
|
||||
var lifetime := duration_override
|
||||
if lifetime <= 0.0:
|
||||
lifetime = float(last_frame - first_frame + 1) / fps
|
||||
if play_time >= lifetime:
|
||||
queue_free()
|
||||
return
|
||||
var frame_index := first_frame + int(play_time * fps)
|
||||
_sprite.frame = clampi(frame_index, first_frame, mini(last_frame, hframes * vframes - 1))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bi8tnfsrabjok
|
||||
@@ -0,0 +1,190 @@
|
||||
class_name PlayerProjectile
|
||||
extends Area2D
|
||||
|
||||
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
|
||||
# effect_sheet.png is a 6x2 grid of 32x32 cells; row one holds four flight frames
|
||||
# (cells four and five are empty), row two holds the impact burst.
|
||||
const SHEET_HFRAMES := 6
|
||||
const SHEET_VFRAMES := 2
|
||||
const FLIGHT_FRAME_COUNT := 4
|
||||
const FLIGHT_FPS := 16.0
|
||||
const VANISH_FRAME := 4
|
||||
const HIT_LEAD_DISTANCE := 18.0
|
||||
const DEFAULT_MAX_RANGE := 460.0
|
||||
|
||||
# The player blade wave uses the author's PROJECTILE sheet (5 frames, 33fps loop).
|
||||
const WAVE_TEXTURE_PATH := "res://assets/art/characters/player/11_blade_wave/projectile.png"
|
||||
# collision_radius keeps the hit circle inside the visible art: the effect_sheet
|
||||
# flight frames show a ~32x18px bolt at scale 2 (radius 9 = half its height),
|
||||
# the wave sheet's smallest frame is ~66x65px at scale 0.55 (radius 32 fits).
|
||||
const VISUAL_PROFILES := {
|
||||
&"bullet": {"hframes": SHEET_HFRAMES, "vframes": SHEET_VFRAMES, "first": 0, "last": FLIGHT_FRAME_COUNT - 1, "fps": FLIGHT_FPS, "scale": 2.0, "vanish_frame": VANISH_FRAME, "collision_radius": 9.0},
|
||||
&"wave": {"path": WAVE_TEXTURE_PATH, "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 33.0, "scale": 0.55, "vanish_frame": -1, "collision_radius": 32.0},
|
||||
}
|
||||
|
||||
@export var damage := 100
|
||||
@export var hit_type: StringName = &"projectile"
|
||||
@export var base_knockback := Vector2.ZERO
|
||||
|
||||
## Snapshotted when fired: weak-state enemy projectiles deal damage but do not
|
||||
## interrupt, while Boss/player projectiles keep full authority.
|
||||
var attacker_interrupts := true
|
||||
var source_actor: Node = null
|
||||
var action_context: Resource
|
||||
var judgement_context: Dictionary = {"label": "perfect"}
|
||||
var direction := Vector2.RIGHT
|
||||
var speed := 520.0
|
||||
var max_range := DEFAULT_MAX_RANGE
|
||||
var visual_profile: StringName = &"bullet"
|
||||
var _travelled := 0.0
|
||||
var _age := 0.0
|
||||
var _sprite: Sprite2D
|
||||
var _hit_confirmed := false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_create_sprite()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if _sprite == null:
|
||||
_create_sprite()
|
||||
_apply_visual_profile()
|
||||
if not area_entered.is_connected(_on_area_entered):
|
||||
area_entered.connect(_on_area_entered)
|
||||
|
||||
|
||||
func _apply_visual_profile() -> void:
|
||||
if _sprite == null:
|
||||
return
|
||||
var profile := _visual()
|
||||
var texture_path := str(profile.get("path", ""))
|
||||
if not texture_path.is_empty() and ResourceLoader.exists(texture_path):
|
||||
_sprite.texture = load(texture_path)
|
||||
else:
|
||||
_sprite.texture = EFFECT_TEXTURE
|
||||
_sprite.hframes = maxi(1, int(profile.get("hframes", 1)))
|
||||
_sprite.vframes = maxi(1, int(profile.get("vframes", 1)))
|
||||
var profile_scale := float(profile.get("scale", 1.0))
|
||||
_sprite.scale = Vector2(profile_scale, profile_scale)
|
||||
_sprite.frame = clampi(int(profile.get("first", 0)), 0, _sprite.hframes * _sprite.vframes - 1)
|
||||
_apply_collision_profile(profile)
|
||||
|
||||
|
||||
func _apply_collision_profile(profile: Dictionary) -> void:
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node == null or not (shape_node.shape is CircleShape2D):
|
||||
return
|
||||
# The scene's CircleShape2D is a shared subresource: duplicate before sizing
|
||||
# per profile so bullet and wave instances never overwrite each other.
|
||||
var circle := (shape_node.shape as CircleShape2D).duplicate() as CircleShape2D
|
||||
circle.radius = maxf(1.0, float(profile.get("collision_radius", circle.radius)))
|
||||
shape_node.shape = circle
|
||||
|
||||
|
||||
func _visual() -> Dictionary:
|
||||
return VISUAL_PROFILES.get(visual_profile, VISUAL_PROFILES[&"bullet"])
|
||||
|
||||
|
||||
func _create_sprite() -> void:
|
||||
if _sprite != null and is_instance_valid(_sprite):
|
||||
return
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.name = "Sprite"
|
||||
_sprite.texture = EFFECT_TEXTURE
|
||||
_sprite.hframes = SHEET_HFRAMES
|
||||
_sprite.vframes = SHEET_VFRAMES
|
||||
_sprite.centered = true
|
||||
_sprite.scale = Vector2(2.0, 2.0)
|
||||
add_child(_sprite)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_age += delta
|
||||
if not _hit_confirmed:
|
||||
var profile := _visual()
|
||||
var first := int(profile.get("first", 0))
|
||||
var span := maxi(1, int(profile.get("last", first)) - first + 1)
|
||||
_sprite.frame = first + int(_age * float(profile.get("fps", FLIGHT_FPS))) % span
|
||||
_sprite.rotation = direction.angle()
|
||||
_sprite.flip_h = false
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _hit_confirmed:
|
||||
return
|
||||
var movement := direction.normalized() * speed * delta
|
||||
var receiver := _swept_receiver(movement)
|
||||
if receiver != null:
|
||||
_on_area_entered(receiver)
|
||||
return
|
||||
position += movement
|
||||
_travelled += movement.length()
|
||||
if _travelled >= max_travel_distance():
|
||||
queue_free()
|
||||
|
||||
|
||||
func _on_area_entered(receiver: Area2D) -> void:
|
||||
if _hit_confirmed:
|
||||
return
|
||||
if receiver.is_in_group("enemy_projectiles") and is_in_group("player_projectiles"):
|
||||
receiver.queue_free()
|
||||
return
|
||||
if not receiver.is_in_group("damage_receivers"):
|
||||
return
|
||||
_hit_confirmed = true
|
||||
_vanish_after_hit()
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_hit"):
|
||||
combat.call("resolve_hit", self, receiver)
|
||||
queue_free()
|
||||
|
||||
|
||||
func _combat_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("CombatManager")
|
||||
|
||||
|
||||
func max_travel_distance() -> float:
|
||||
return max_range if max_range > 0.0 else DEFAULT_MAX_RANGE
|
||||
|
||||
|
||||
func _vanish_after_hit() -> void:
|
||||
set_deferred("monitoring", false)
|
||||
set_deferred("monitorable", false)
|
||||
visible = false
|
||||
var vanish_frame := int(_visual().get("vanish_frame", -1))
|
||||
if _sprite != null and vanish_frame >= 0:
|
||||
_sprite.frame = vanish_frame
|
||||
|
||||
|
||||
func _swept_receiver(movement: Vector2) -> Area2D:
|
||||
if movement == Vector2.ZERO or not is_inside_tree():
|
||||
return null
|
||||
var travel_direction := movement.normalized()
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape != null:
|
||||
var shape_query := PhysicsShapeQueryParameters2D.new()
|
||||
shape_query.shape = shape_node.shape
|
||||
shape_query.transform = shape_node.global_transform.translated(travel_direction * HIT_LEAD_DISTANCE + movement)
|
||||
shape_query.collision_mask = collision_mask
|
||||
shape_query.collide_with_areas = true
|
||||
shape_query.collide_with_bodies = false
|
||||
shape_query.exclude = [get_rid()]
|
||||
for hit: Dictionary in get_world_2d().direct_space_state.intersect_shape(shape_query, 8):
|
||||
var shape_collider = hit.get("collider", null)
|
||||
if shape_collider is Area2D and shape_collider.is_in_group("damage_receivers"):
|
||||
return shape_collider
|
||||
var query := PhysicsRayQueryParameters2D.create(
|
||||
global_position + travel_direction * HIT_LEAD_DISTANCE,
|
||||
global_position + travel_direction * HIT_LEAD_DISTANCE + movement,
|
||||
collision_mask
|
||||
)
|
||||
query.collide_with_areas = true
|
||||
query.collide_with_bodies = false
|
||||
var hit := get_world_2d().direct_space_state.intersect_ray(query)
|
||||
var collider = hit.get("collider", null)
|
||||
if collider is Area2D and collider.is_in_group("damage_receivers"):
|
||||
return collider
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8ek2cytdwsmp
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/combat/player_projectile.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="CircleShape2D_projectile"]
|
||||
radius = 32.0
|
||||
|
||||
[node name="PlayerProjectile" type="Area2D"]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("CircleShape2D_projectile")
|
||||
@@ -0,0 +1,865 @@
|
||||
class_name ActionController
|
||||
extends Node
|
||||
|
||||
const ActionResolverScript := preload("res://scenes/combat/action_resolver.gd")
|
||||
const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd")
|
||||
const DEFAULT_PLAYER_ACTION_BINDINGS_PATH := "res://resources/player_action_bindings.tres"
|
||||
|
||||
signal action_started(action: Resource, intent)
|
||||
signal action_active_started(action: Resource, intent)
|
||||
signal action_active_finished(action: Resource)
|
||||
signal action_finished(action: Resource)
|
||||
signal action_cancelled(action: Resource, reason: StringName)
|
||||
signal action_rejected(intent, reason: StringName)
|
||||
|
||||
enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY, CHARGING }
|
||||
|
||||
const MAX_CHARGE_LEVEL := 3
|
||||
|
||||
@export var combo_window_path: NodePath
|
||||
@export var action_resolver_path: NodePath
|
||||
@export var action_executor_path: NodePath
|
||||
@export var state_machine_path: NodePath
|
||||
@export var burst_component_path: NodePath
|
||||
@export var beat_anchor_policy: StringName = &"ANCHOR_ACTIVE"
|
||||
@export var anchor_grid := 0.25
|
||||
@export var hold_threshold_beats := 0.25
|
||||
@export var charge_level_beats := 1.0
|
||||
@export var player_action_bindings: Resource
|
||||
|
||||
@onready var combo_window: Node = get_node_or_null(combo_window_path)
|
||||
@onready var action_resolver: Node = get_node_or_null(action_resolver_path)
|
||||
@onready var action_executor: Node = get_node_or_null(action_executor_path)
|
||||
@onready var state_machine: Node = get_node_or_null(state_machine_path)
|
||||
@onready var burst_component: Node = get_node_or_null(burst_component_path)
|
||||
@onready var energy_component: Node = get_node_or_null("../EnergyComponent")
|
||||
@onready var effect_container: Node = get_node_or_null("../EffectContainer")
|
||||
|
||||
var phase := Phase.IDLE
|
||||
var current_action: Resource
|
||||
var current_intent
|
||||
var pending_intent
|
||||
var phase_elapsed := 0.0
|
||||
var phase_duration := 0.0
|
||||
var startup_stretch_seconds := 0.0
|
||||
var _cost_snapshot := 0.0
|
||||
var _action_snapshot: Dictionary = {}
|
||||
var _charge_hold_intent
|
||||
var _active_charge_entry: Dictionary = {}
|
||||
var _charge_hold_elapsed := 0.0
|
||||
var _charge_hold_ready := false
|
||||
var _charging_elapsed := 0.0
|
||||
|
||||
|
||||
func submit_intent(intent) -> void:
|
||||
if intent == null:
|
||||
return
|
||||
var judged_intent = _ensure_judged(intent)
|
||||
# new1: 锁定期间的按键完全无效——不进判定流程、不触发动作、不留任何记录。
|
||||
if judged_intent.is_pressed() and _live_input_locked(judged_intent):
|
||||
action_rejected.emit(judged_intent, &"input_locked")
|
||||
return
|
||||
if _is_direct_charge_release(judged_intent):
|
||||
_cancel_charge_hold()
|
||||
return
|
||||
if _is_charge_secondary_cast(judged_intent):
|
||||
_finish_secondary_charge_gate(judged_intent)
|
||||
return
|
||||
if _is_secondary_charge_cancel_release(judged_intent):
|
||||
_cancel_secondary_charge_gate()
|
||||
return
|
||||
if _is_charge_hold_broken_by_derive(judged_intent):
|
||||
_break_charge_hold_for_derive()
|
||||
if _is_charge_hold_release(judged_intent):
|
||||
_finish_charge_hold_gate(judged_intent)
|
||||
return
|
||||
if _should_begin_direct_charge_gate(judged_intent):
|
||||
_begin_direct_charge_gate(judged_intent)
|
||||
return
|
||||
if judged_intent.is_released():
|
||||
action_rejected.emit(judged_intent, &"release_not_action")
|
||||
return
|
||||
match _gate_live_input(judged_intent):
|
||||
&"ignored":
|
||||
action_rejected.emit(judged_intent, &"input_ignored")
|
||||
return
|
||||
&"miss":
|
||||
judged_intent = judged_intent.with_judgement(_miss_judgement_override(judged_intent.judgement))
|
||||
_emit_judgement_feedback(judged_intent)
|
||||
_dispatch_judgement_effect_events(judged_intent)
|
||||
if _judgement_label(judged_intent) == &"miss":
|
||||
_record_miss(judged_intent)
|
||||
action_rejected.emit(judged_intent, &"miss")
|
||||
return
|
||||
var should_track_charge_hold := _should_begin_charge_hold_gate(judged_intent)
|
||||
if phase == Phase.IDLE:
|
||||
_consume_intent(judged_intent)
|
||||
if should_track_charge_hold:
|
||||
_begin_charge_hold_gate_if_started(judged_intent)
|
||||
return
|
||||
if _can_cancel_now():
|
||||
cancel_current(&"chain")
|
||||
_consume_intent(judged_intent)
|
||||
if should_track_charge_hold:
|
||||
_begin_charge_hold_gate_if_started(judged_intent)
|
||||
return
|
||||
_store_pending_intent(judged_intent)
|
||||
|
||||
|
||||
func submit_ai_intent(intent) -> void:
|
||||
if intent == null:
|
||||
return
|
||||
if phase != Phase.IDLE:
|
||||
action_rejected.emit(intent, &"busy")
|
||||
return
|
||||
var action: Resource = ActionResolverScript.get_action(StringName(str(intent.action_id)))
|
||||
if action == null:
|
||||
action_rejected.emit(intent, &"no_executable_action")
|
||||
return
|
||||
if not ActionRuleResolverScript.can_execute(_resolver_context(), action):
|
||||
action_rejected.emit(intent, &"no_executable_action")
|
||||
return
|
||||
if not _commit_action_cost(action, intent):
|
||||
action_rejected.emit(intent, &"insufficient_energy")
|
||||
return
|
||||
current_action = action
|
||||
current_intent = intent
|
||||
_dispatch_action_effect_event(&"on_action_start", action, intent)
|
||||
_enter_phase(Phase.STARTUP)
|
||||
action_started.emit(action, intent)
|
||||
_emit_action_started_fact(action, intent)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_tick_charge_hold(delta)
|
||||
if phase == Phase.CHARGING:
|
||||
return
|
||||
if phase == Phase.IDLE:
|
||||
if pending_intent != null and not _window_is_showing_pending_clear():
|
||||
var idle_intent = pending_intent
|
||||
pending_intent = null
|
||||
_consume_intent(idle_intent)
|
||||
_arm_charge_hold_for_consumed_pending(idle_intent)
|
||||
return
|
||||
phase_elapsed += delta
|
||||
if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now():
|
||||
var next_intent = pending_intent
|
||||
pending_intent = null
|
||||
cancel_current(&"chain")
|
||||
_consume_intent(next_intent)
|
||||
_arm_charge_hold_for_consumed_pending(next_intent)
|
||||
return
|
||||
if phase_elapsed < phase_duration:
|
||||
return
|
||||
var carryover := maxf(0.0, phase_elapsed - phase_duration)
|
||||
match phase:
|
||||
Phase.STARTUP:
|
||||
_enter_phase(Phase.ACTIVE)
|
||||
phase_elapsed = carryover
|
||||
if not _activate_current_action():
|
||||
return
|
||||
Phase.ACTIVE:
|
||||
_finish_action_execution()
|
||||
action_active_finished.emit(current_action)
|
||||
_enter_phase(Phase.RECOVERY)
|
||||
phase_elapsed = carryover
|
||||
Phase.RECOVERY:
|
||||
var finished_action := current_action
|
||||
var next_intent = pending_intent
|
||||
pending_intent = null
|
||||
var should_enter_charge := _charge_hold_ready and _charge_hold_intent != null and next_intent == null
|
||||
if next_intent != null:
|
||||
_clear_charge_hold()
|
||||
_reset_to_idle(not should_enter_charge)
|
||||
_clear_window_after_action(finished_action)
|
||||
action_finished.emit(finished_action)
|
||||
if should_enter_charge and _enter_charge_phase():
|
||||
return
|
||||
if next_intent != null:
|
||||
_consume_intent(next_intent)
|
||||
_arm_charge_hold_for_consumed_pending(next_intent)
|
||||
|
||||
|
||||
func _consume_intent(intent) -> void:
|
||||
_start_action(intent)
|
||||
|
||||
|
||||
func _start_action(intent) -> void:
|
||||
if combo_window == null or action_resolver == null:
|
||||
action_rejected.emit(intent, &"missing_component")
|
||||
return
|
||||
_record_intent_symbol(intent)
|
||||
var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context())
|
||||
if action == null:
|
||||
if not _window_is_showing_pending_clear():
|
||||
_rollback_intent_symbol(intent)
|
||||
action_rejected.emit(intent, &"no_executable_action")
|
||||
return
|
||||
if not _commit_action_cost(action, intent):
|
||||
_rollback_intent_symbol(intent)
|
||||
action_rejected.emit(intent, &"insufficient_energy")
|
||||
return
|
||||
current_action = action
|
||||
current_intent = intent
|
||||
_dispatch_action_effect_event(&"on_action_start", action, intent)
|
||||
_enter_phase(Phase.STARTUP)
|
||||
action_started.emit(action, intent)
|
||||
_emit_action_started_fact(action, intent)
|
||||
|
||||
|
||||
func _start_action_resource(action: Resource, intent) -> void:
|
||||
if action == null:
|
||||
action_rejected.emit(intent, &"no_executable_action")
|
||||
_reset_to_idle()
|
||||
return
|
||||
if not ActionRuleResolverScript.can_execute(_resolver_context(), action):
|
||||
action_rejected.emit(intent, &"no_executable_action")
|
||||
_reset_to_idle()
|
||||
return
|
||||
if not _commit_action_cost(action, intent):
|
||||
action_rejected.emit(intent, &"insufficient_energy")
|
||||
_reset_to_idle()
|
||||
return
|
||||
current_action = action
|
||||
current_intent = intent
|
||||
_dispatch_action_effect_event(&"on_action_start", action, intent)
|
||||
_enter_phase(Phase.STARTUP)
|
||||
action_started.emit(action, intent)
|
||||
_emit_action_started_fact(action, intent)
|
||||
|
||||
|
||||
func _activate_current_action() -> bool:
|
||||
if current_action == null or current_intent == null:
|
||||
_reset_to_idle()
|
||||
return false
|
||||
if action_executor == null:
|
||||
action_rejected.emit(current_intent, &"missing_component")
|
||||
_reset_to_idle()
|
||||
return false
|
||||
if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), effect_container):
|
||||
var reason := _action_executor_failure_reason()
|
||||
combo_window.flush_pending_clear()
|
||||
combo_window.clear(reason)
|
||||
action_rejected.emit(current_intent, reason)
|
||||
_reset_to_idle()
|
||||
return false
|
||||
action_active_started.emit(current_action, current_intent)
|
||||
return true
|
||||
|
||||
|
||||
func _record_intent_symbol(intent) -> void:
|
||||
if combo_window.has_pending_clear():
|
||||
combo_window.flush_pending_clear()
|
||||
combo_window.record(intent.symbol)
|
||||
|
||||
|
||||
func _rollback_intent_symbol(intent) -> void:
|
||||
if combo_window != null and combo_window.has_method("rollback_last"):
|
||||
combo_window.call("rollback_last", intent.symbol)
|
||||
|
||||
|
||||
func _record_miss(_intent) -> void:
|
||||
if combo_window != null:
|
||||
if combo_window.has_pending_clear():
|
||||
combo_window.flush_pending_clear()
|
||||
combo_window.record(&"Ø")
|
||||
|
||||
|
||||
func _clear_window_after_action(action: Resource) -> void:
|
||||
if combo_window == null or action == null:
|
||||
return
|
||||
if bool(action.get("clear_window")):
|
||||
combo_window.clear(StringName("skill:%s" % action.get("id")))
|
||||
|
||||
|
||||
func _store_pending_intent(intent) -> void:
|
||||
if pending_intent != null:
|
||||
_emit_intent_replaced_fact(pending_intent, intent)
|
||||
action_rejected.emit(pending_intent, &"replaced")
|
||||
pending_intent = intent
|
||||
|
||||
|
||||
func _enter_phase(next_phase: Phase) -> void:
|
||||
phase = next_phase
|
||||
phase_elapsed = 0.0
|
||||
phase_duration = _phase_duration_seconds(next_phase)
|
||||
_mirror_action_phase()
|
||||
_mirror_defense_state()
|
||||
|
||||
|
||||
func _phase_duration_seconds(next_phase: Phase) -> float:
|
||||
if current_action == null:
|
||||
return 0.0
|
||||
var beat_time := _beat_time()
|
||||
match next_phase:
|
||||
Phase.STARTUP:
|
||||
var minimum_duration := maxf(0.01, _snapshot_float("startup_beats", float(current_action.get("startup_beats"))) * beat_time)
|
||||
if beat_anchor_policy != &"ANCHOR_ACTIVE":
|
||||
startup_stretch_seconds = 0.0
|
||||
return minimum_duration
|
||||
var anchored_duration := _anchored_startup_duration(minimum_duration, beat_time)
|
||||
startup_stretch_seconds = maxf(0.0, anchored_duration - minimum_duration)
|
||||
return anchored_duration
|
||||
Phase.ACTIVE:
|
||||
return maxf(0.01, _snapshot_float("active_beats", float(current_action.get("active_beats"))) * beat_time)
|
||||
Phase.RECOVERY:
|
||||
return maxf(0.01, _snapshot_float("recovery_beats", float(current_action.get("recovery_beats"))) * beat_time)
|
||||
Phase.CHARGING:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
func _can_cancel_now() -> bool:
|
||||
if phase != Phase.RECOVERY or current_action == null:
|
||||
return false
|
||||
if bool(current_action.get("clear_window")) and not bool(current_action.get("can_chain")):
|
||||
return false
|
||||
var duration := maxf(0.01, phase_duration)
|
||||
var progress := clampf(phase_elapsed / duration, 0.0, 1.0)
|
||||
return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0)
|
||||
|
||||
|
||||
func _reset_to_idle(clear_charge_hold := true) -> void:
|
||||
phase = Phase.IDLE
|
||||
current_action = null
|
||||
current_intent = null
|
||||
phase_elapsed = 0.0
|
||||
phase_duration = 0.0
|
||||
startup_stretch_seconds = 0.0
|
||||
_cost_snapshot = 0.0
|
||||
_action_snapshot = {}
|
||||
if clear_charge_hold:
|
||||
_clear_charge_hold()
|
||||
_mirror_action_phase()
|
||||
_mirror_defense_state()
|
||||
|
||||
|
||||
func cancel_current(reason: StringName) -> void:
|
||||
var cancelled_action := current_action
|
||||
_finish_action_execution()
|
||||
pending_intent = null if reason != &"chain" else pending_intent
|
||||
_reset_to_idle()
|
||||
if reason == &"interrupt" or reason == &"death":
|
||||
if combo_window != null:
|
||||
combo_window.clear(reason)
|
||||
action_cancelled.emit(cancelled_action, reason)
|
||||
_emit_action_cancelled_fact(cancelled_action, reason)
|
||||
|
||||
|
||||
func _window_is_showing_pending_clear() -> bool:
|
||||
return combo_window != null and combo_window.has_pending_clear()
|
||||
|
||||
|
||||
func _ensure_judged(intent):
|
||||
if not intent.judgement.is_empty():
|
||||
return intent.with_judgement(_judgement_with_defaults(intent.judgement))
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null and rhythm.has_method("judge"):
|
||||
var rating: Dictionary = rhythm.call("judge", intent.timestamp_ms)
|
||||
# 只有实时判定的输入走 new1 的消耗/锁定门控;预置判定(测试/AI)保持权威。
|
||||
rating["live"] = true
|
||||
return intent.with_judgement(_judgement_with_defaults(rating))
|
||||
return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}))
|
||||
|
||||
|
||||
func _live_input_locked(intent) -> bool:
|
||||
if not bool(intent.judgement.get("live", false)):
|
||||
return false
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
return rhythm != null and rhythm.has_method("is_input_locked") and bool(rhythm.call("is_input_locked"))
|
||||
|
||||
|
||||
## new1 门控:返回 &"ok" / &"ignored" / &"miss"(见 RhythmManager.gate_judged_input)。
|
||||
func _gate_live_input(intent) -> StringName:
|
||||
if not bool(intent.judgement.get("live", false)):
|
||||
return &"ok"
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm == null or not rhythm.has_method("gate_judged_input"):
|
||||
return &"ok"
|
||||
return StringName(str(rhythm.call("gate_judged_input", intent.judgement)))
|
||||
|
||||
|
||||
func _miss_judgement_override(judgement: Dictionary) -> Dictionary:
|
||||
var rating := judgement.duplicate()
|
||||
rating["label"] = "miss"
|
||||
rating["color"] = _judgement_color(&"miss")
|
||||
return rating
|
||||
|
||||
|
||||
func _judgement_label(intent) -> StringName:
|
||||
return StringName(str(intent.judgement.get("label", "miss")))
|
||||
|
||||
|
||||
func _emit_judgement_feedback(intent) -> void:
|
||||
var rating := _judgement_with_defaults(intent.judgement)
|
||||
var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol
|
||||
rating["action"] = action_name
|
||||
var label := StringName(str(rating.get("label", "miss")))
|
||||
var diff_ms := float(rating.get("diff", INF)) * 1000.0
|
||||
var nearest_beat := int(rating.get("nearest_beat", 0))
|
||||
var bus := _event_bus_or_null()
|
||||
if bus == null:
|
||||
return
|
||||
bus.emit_signal("judgement_made", label, diff_ms, nearest_beat)
|
||||
|
||||
|
||||
func _judgement_with_defaults(judgement: Dictionary) -> Dictionary:
|
||||
var rating := judgement.duplicate()
|
||||
var label := StringName(str(rating.get("label", "miss")))
|
||||
rating["label"] = str(label)
|
||||
if not rating.has("diff"):
|
||||
rating["diff"] = 0.0
|
||||
if not rating.has("abs_diff"):
|
||||
rating["abs_diff"] = absf(float(rating.get("diff", 0.0)))
|
||||
if not rating.has("nearest_beat"):
|
||||
rating["nearest_beat"] = 0
|
||||
if not rating.has("color"):
|
||||
rating["color"] = _judgement_color(label)
|
||||
return rating
|
||||
|
||||
|
||||
func _judgement_color(label: StringName) -> Color:
|
||||
match label:
|
||||
&"perfect":
|
||||
return Color("00f2ff")
|
||||
&"good":
|
||||
return Color("ffffff")
|
||||
&"bad":
|
||||
return Color("ffaa00")
|
||||
return Color("ff0055")
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
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 _emit_intent_replaced_fact(previous_intent, next_intent) -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("intent_replaced"):
|
||||
bus.emit_signal("intent_replaced", previous_intent, next_intent)
|
||||
|
||||
|
||||
func _emit_action_started_fact(action: Resource, intent) -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("action_started"):
|
||||
bus.emit_signal("action_started", action, intent)
|
||||
|
||||
|
||||
func _emit_action_cancelled_fact(action: Resource, reason: StringName) -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("action_cancelled"):
|
||||
bus.emit_signal("action_cancelled", action, reason)
|
||||
|
||||
|
||||
func _dispatch_judgement_effect_events(intent) -> void:
|
||||
if effect_container == null or not effect_container.has_method("dispatch_event"):
|
||||
return
|
||||
if _judgement_label(intent) == &"perfect":
|
||||
effect_container.call("dispatch_event", &"on_perfect", {"intent": intent})
|
||||
|
||||
|
||||
func _dispatch_action_effect_event(event_name: StringName, action: Resource, intent) -> void:
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("dispatch_event"):
|
||||
effect_container.call("dispatch_event", event_name, {"action": action, "intent": intent})
|
||||
|
||||
|
||||
func _resolver_context() -> Dictionary:
|
||||
var context := {}
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
context = state_machine.call("build_context")
|
||||
else:
|
||||
context = {
|
||||
"ground_state": &"Grounded",
|
||||
"action_phase": &"Neutral",
|
||||
"defense_state": &"Vulnerable",
|
||||
"life_state": &"Alive",
|
||||
"tags": [&"Grounded", &"Neutral", &"Vulnerable", &"Alive"],
|
||||
}
|
||||
context.merge({
|
||||
"burst_action_id": _burst_action_id(),
|
||||
"counter_action_id": _counter_action_id(),
|
||||
"counter_ready": _counter_ready(),
|
||||
"blade_chain_action_id": _blade_chain_action_id(),
|
||||
"blade_chain_active": _blade_chain_active(),
|
||||
}, true)
|
||||
return context
|
||||
|
||||
|
||||
func _burst_action_id() -> StringName:
|
||||
if burst_component != null and bool(burst_component.get("burst_ready")):
|
||||
return _binding_action_id("burst_action_id")
|
||||
return &""
|
||||
|
||||
|
||||
func _counter_action_id() -> StringName:
|
||||
return _binding_action_id("counter_action_id")
|
||||
|
||||
|
||||
func _counter_ready() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func _blade_chain_action_id() -> StringName:
|
||||
if _blade_chain_active():
|
||||
return _binding_action_id("blade_chain_action_id")
|
||||
return &""
|
||||
|
||||
|
||||
func _blade_chain_active() -> bool:
|
||||
if current_action == null:
|
||||
return false
|
||||
return bool(current_action.get("can_chain"))
|
||||
|
||||
|
||||
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 _commit_action_cost(action: Resource, intent) -> bool:
|
||||
_action_snapshot = _resolve_action_snapshot(action, intent)
|
||||
_cost_snapshot = float(_action_snapshot.get("cost", 0.0))
|
||||
if energy_component == null:
|
||||
return true
|
||||
return energy_component.spend(_cost_snapshot)
|
||||
|
||||
|
||||
func _resolve_cost(action: Resource, judgement: Dictionary = {}) -> float:
|
||||
var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null
|
||||
if combat != null and combat.has_method("resolve_cost"):
|
||||
return float(combat.call("resolve_cost", action, judgement, effect_container))
|
||||
return float(action.get("base_cost"))
|
||||
|
||||
|
||||
func _resolve_action_snapshot(action: Resource, intent) -> Dictionary:
|
||||
var judgement := {}
|
||||
if intent != null:
|
||||
var intent_judgement = intent.get("judgement")
|
||||
if intent_judgement is Dictionary:
|
||||
judgement = intent_judgement
|
||||
var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null
|
||||
if combat != null and combat.has_method("resolve_action_snapshot"):
|
||||
return combat.call("resolve_action_snapshot", action, judgement, effect_container)
|
||||
return {
|
||||
"cost": _resolve_cost(action, judgement),
|
||||
"startup_beats": float(action.get("startup_beats")),
|
||||
"active_beats": float(action.get("active_beats")),
|
||||
"recovery_beats": float(action.get("recovery_beats")),
|
||||
}
|
||||
|
||||
|
||||
func _snapshot_float(key: String, fallback: float) -> float:
|
||||
if _action_snapshot.has(key):
|
||||
return float(_action_snapshot[key])
|
||||
return fallback
|
||||
|
||||
|
||||
func _anchored_startup_duration(minimum_duration: float, beat_time: float) -> float:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
var now := 0.0
|
||||
if rhythm != null and rhythm.has_method("song_position"):
|
||||
now = float(rhythm.call("song_position"))
|
||||
var grid_seconds := maxf(0.01, anchor_grid * beat_time)
|
||||
var desired_active := now + minimum_duration
|
||||
var anchored_active := ceili(desired_active / grid_seconds) * grid_seconds
|
||||
return maxf(minimum_duration, anchored_active - now)
|
||||
|
||||
|
||||
func _mirror_action_phase() -> void:
|
||||
if state_machine == null or not state_machine.has_method("set_action_phase"):
|
||||
return
|
||||
match phase:
|
||||
Phase.STARTUP:
|
||||
state_machine.call("set_action_phase", &"Startup")
|
||||
Phase.ACTIVE:
|
||||
state_machine.call("set_action_phase", &"Active")
|
||||
Phase.RECOVERY:
|
||||
state_machine.call("set_action_phase", &"Recovery")
|
||||
Phase.CHARGING:
|
||||
state_machine.call("set_action_phase", &"Charging")
|
||||
_:
|
||||
state_machine.call("set_action_phase", &"Neutral")
|
||||
|
||||
|
||||
func _mirror_defense_state() -> void:
|
||||
if state_machine == null or not state_machine.has_method("set_defense_state"):
|
||||
return
|
||||
if phase != Phase.ACTIVE or current_action == null:
|
||||
state_machine.call("set_defense_state", &"Vulnerable")
|
||||
return
|
||||
var defense_tags: Array = current_action.get("defense_tags")
|
||||
if defense_tags.has(&"invincible"):
|
||||
state_machine.call("set_defense_state", &"Invincible")
|
||||
elif defense_tags.has(&"parry") or defense_tags.has(&"parrying"):
|
||||
state_machine.call("set_defense_state", &"Parrying")
|
||||
elif defense_tags.has(&"super_armor"):
|
||||
state_machine.call("set_defense_state", &"SuperArmor")
|
||||
else:
|
||||
state_machine.call("set_defense_state", &"Vulnerable")
|
||||
|
||||
|
||||
func _should_begin_charge_hold_gate(intent) -> bool:
|
||||
if not intent.is_pressed():
|
||||
return false
|
||||
var entry := _charge_entry_for_symbol(intent.symbol)
|
||||
return StringName(str(entry.get("entry_mode", &""))) == &"after_tap"
|
||||
|
||||
|
||||
func _begin_charge_hold_gate_if_started(intent) -> void:
|
||||
if current_intent != intent or current_action == null:
|
||||
return
|
||||
_active_charge_entry = _charge_entry_for_symbol(intent.symbol)
|
||||
_charge_hold_intent = intent
|
||||
_charge_hold_elapsed = 0.0
|
||||
_charge_hold_ready = false
|
||||
|
||||
|
||||
func _arm_charge_hold_for_consumed_pending(intent) -> void:
|
||||
if intent == null or not _should_begin_charge_hold_gate(intent):
|
||||
return
|
||||
if not _symbol_input_still_pressed(intent.symbol):
|
||||
return
|
||||
_begin_charge_hold_gate_if_started(intent)
|
||||
|
||||
|
||||
func _symbol_input_still_pressed(symbol: StringName) -> bool:
|
||||
var action_name: StringName
|
||||
match symbol:
|
||||
&"A":
|
||||
action_name = &"combo_a"
|
||||
&"D":
|
||||
action_name = &"combo_d"
|
||||
&"W":
|
||||
action_name = &"combo_w"
|
||||
&"S":
|
||||
action_name = &"combo_s"
|
||||
&"SP":
|
||||
action_name = &"combo_space"
|
||||
_:
|
||||
return false
|
||||
return InputMap.has_action(action_name) and Input.is_action_pressed(action_name)
|
||||
|
||||
|
||||
func _is_charge_hold_release(intent) -> bool:
|
||||
return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_release"
|
||||
|
||||
|
||||
func _finish_charge_hold_gate(release_intent) -> void:
|
||||
var held_long_enough := phase == Phase.CHARGING
|
||||
var action_id := _charge_cast_action_id(_active_charge_entry, _charge_level())
|
||||
_clear_charge_hold()
|
||||
if held_long_enough:
|
||||
match _gate_live_input(release_intent):
|
||||
&"ignored":
|
||||
_reset_to_idle()
|
||||
action_rejected.emit(release_intent, &"input_ignored")
|
||||
return
|
||||
&"miss":
|
||||
release_intent = release_intent.with_judgement(_miss_judgement_override(release_intent.judgement))
|
||||
_emit_judgement_feedback(release_intent)
|
||||
if _judgement_label(release_intent) == &"miss":
|
||||
_reset_to_idle()
|
||||
_record_miss(release_intent)
|
||||
action_rejected.emit(release_intent, &"miss")
|
||||
return
|
||||
_start_action_resource(ActionResolverScript.get_action(action_id), release_intent)
|
||||
|
||||
|
||||
func _tick_charge_hold(delta: float) -> void:
|
||||
if _charge_hold_intent == null:
|
||||
return
|
||||
_charge_hold_elapsed += delta
|
||||
if phase == Phase.CHARGING:
|
||||
_charging_elapsed += delta
|
||||
return
|
||||
if _charge_hold_elapsed < hold_threshold_beats * _beat_time():
|
||||
return
|
||||
_charge_hold_ready = true
|
||||
if phase == Phase.IDLE:
|
||||
_enter_charge_phase()
|
||||
|
||||
|
||||
func _enter_charge_phase() -> bool:
|
||||
if _charge_hold_intent == null:
|
||||
return false
|
||||
if _active_charge_entry.is_empty():
|
||||
_clear_charge_hold()
|
||||
return false
|
||||
current_intent = _charge_hold_intent
|
||||
current_action = null
|
||||
_charging_elapsed = 0.0
|
||||
_enter_phase(Phase.CHARGING)
|
||||
return true
|
||||
|
||||
|
||||
func _clear_charge_hold() -> void:
|
||||
_charge_hold_intent = null
|
||||
_active_charge_entry = {}
|
||||
_charge_hold_elapsed = 0.0
|
||||
_charge_hold_ready = false
|
||||
_charging_elapsed = 0.0
|
||||
|
||||
|
||||
func _cancel_charge_hold() -> void:
|
||||
_clear_charge_hold()
|
||||
_reset_to_idle()
|
||||
|
||||
|
||||
func _is_charge_hold_broken_by_derive(intent) -> bool:
|
||||
if not intent.is_pressed() or _charge_hold_intent == null:
|
||||
return false
|
||||
if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_release":
|
||||
return false
|
||||
return intent.symbol == &"SP"
|
||||
|
||||
|
||||
func _break_charge_hold_for_derive() -> void:
|
||||
# SP while an A/D hold is armed or charging: the four-slot derivation wins
|
||||
# ([A][sp] dash, [A][A][sp] finisher...), so abort the charge and let the
|
||||
# press fall through to the normal resolution path.
|
||||
var was_charging := phase == Phase.CHARGING
|
||||
_clear_charge_hold()
|
||||
if was_charging:
|
||||
_reset_to_idle()
|
||||
|
||||
|
||||
func _should_begin_direct_charge_gate(intent) -> bool:
|
||||
if not intent.is_pressed() or phase != Phase.IDLE:
|
||||
return false
|
||||
var entry := _charge_entry_for_symbol(intent.symbol)
|
||||
return StringName(str(entry.get("entry_mode", &""))) == &"direct"
|
||||
|
||||
|
||||
func _begin_direct_charge_gate(intent) -> void:
|
||||
_active_charge_entry = _charge_entry_for_symbol(intent.symbol)
|
||||
_charge_hold_intent = intent
|
||||
_charge_hold_elapsed = 0.0
|
||||
_charge_hold_ready = true
|
||||
_charging_elapsed = 0.0
|
||||
current_intent = intent
|
||||
current_action = null
|
||||
_enter_phase(Phase.CHARGING)
|
||||
|
||||
|
||||
func _is_direct_charge_release(intent) -> bool:
|
||||
return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("entry_mode", &""))) == &"direct"
|
||||
|
||||
|
||||
func _is_charge_secondary_cast(intent) -> bool:
|
||||
if not intent.is_pressed() or phase != Phase.CHARGING or _active_charge_entry.is_empty():
|
||||
return false
|
||||
if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_secondary_key":
|
||||
return false
|
||||
return intent.symbol == StringName(str(_active_charge_entry.get("cast_key", &"")))
|
||||
|
||||
|
||||
func _is_secondary_charge_cancel_release(intent) -> bool:
|
||||
if not intent.is_released() or _charge_hold_intent == null:
|
||||
return false
|
||||
if intent.symbol != _charge_hold_intent.symbol:
|
||||
return false
|
||||
return StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_secondary_key"
|
||||
|
||||
|
||||
func _cancel_secondary_charge_gate() -> void:
|
||||
_clear_charge_hold()
|
||||
if phase == Phase.CHARGING:
|
||||
_reset_to_idle()
|
||||
|
||||
|
||||
func _finish_secondary_charge_gate(cast_intent) -> void:
|
||||
var entry := _active_charge_entry.duplicate(true)
|
||||
var action_id := _charge_cast_action_id(entry, _charge_level())
|
||||
_clear_charge_hold()
|
||||
match _gate_live_input(cast_intent):
|
||||
&"ignored":
|
||||
_reset_to_idle()
|
||||
action_rejected.emit(cast_intent, &"input_ignored")
|
||||
return
|
||||
&"miss":
|
||||
cast_intent = cast_intent.with_judgement(_miss_judgement_override(cast_intent.judgement))
|
||||
_emit_judgement_feedback(cast_intent)
|
||||
if _judgement_label(cast_intent) == &"miss":
|
||||
_reset_to_idle()
|
||||
action_rejected.emit(cast_intent, &"miss")
|
||||
return
|
||||
_start_action_resource(ActionResolverScript.get_action(action_id), cast_intent)
|
||||
|
||||
|
||||
func _charge_entry_for_symbol(symbol: StringName) -> Dictionary:
|
||||
var bindings := _player_action_bindings()
|
||||
if bindings != null and bindings.has_method("charge_entry_for_symbol"):
|
||||
return bindings.call("charge_entry_for_symbol", symbol)
|
||||
return {}
|
||||
|
||||
|
||||
func _charge_cast_action_id(entry: Dictionary, level: int) -> StringName:
|
||||
if entry.is_empty():
|
||||
return &""
|
||||
var bindings := _player_action_bindings()
|
||||
if bindings != null and bindings.has_method("cast_action_id"):
|
||||
return bindings.call("cast_action_id", entry, level)
|
||||
var cast_ids: Dictionary = entry.get("cast_action_ids", {})
|
||||
if cast_ids.has(level):
|
||||
return StringName(str(cast_ids[level]))
|
||||
if cast_ids.has(str(level)):
|
||||
return StringName(str(cast_ids[str(level)]))
|
||||
return &""
|
||||
|
||||
|
||||
func _charge_level() -> int:
|
||||
var level_seconds := maxf(0.001, charge_level_beats * _beat_time())
|
||||
return clampi(1 + int(floor(_charging_elapsed / level_seconds)), 1, MAX_CHARGE_LEVEL)
|
||||
|
||||
|
||||
func charge_state() -> Dictionary:
|
||||
# Single source of truth for the charge gauge: the cast level is decided
|
||||
# here, so presentation reads the same clock instead of keeping its own.
|
||||
var charging := phase == Phase.CHARGING
|
||||
var level_seconds := maxf(0.001, charge_level_beats * _beat_time())
|
||||
return {
|
||||
"charging": charging,
|
||||
"level": _charge_level() if charging else 0,
|
||||
"max_level": MAX_CHARGE_LEVEL,
|
||||
"progress_units": clampf(_charging_elapsed / level_seconds, 0.0, float(MAX_CHARGE_LEVEL - 1)) if charging else 0.0,
|
||||
}
|
||||
|
||||
|
||||
func _binding_action_id(property_name: StringName) -> StringName:
|
||||
var bindings := _player_action_bindings()
|
||||
if bindings == null:
|
||||
return &""
|
||||
return StringName(str(bindings.get(property_name)))
|
||||
|
||||
|
||||
func _player_action_bindings() -> Resource:
|
||||
if player_action_bindings != null:
|
||||
return player_action_bindings
|
||||
if ResourceLoader.exists(DEFAULT_PLAYER_ACTION_BINDINGS_PATH):
|
||||
player_action_bindings = load(DEFAULT_PLAYER_ACTION_BINDINGS_PATH)
|
||||
return player_action_bindings
|
||||
|
||||
|
||||
func _action_executor_failure_reason() -> StringName:
|
||||
if action_executor != null and "last_failure_reason" in action_executor:
|
||||
var reason := StringName(str(action_executor.get("last_failure_reason")))
|
||||
if not reason.is_empty():
|
||||
return reason
|
||||
return &"execution_failed"
|
||||
|
||||
|
||||
func _finish_action_execution() -> void:
|
||||
if action_executor != null and action_executor.has_method("finish_action"):
|
||||
action_executor.call("finish_action")
|
||||
@@ -0,0 +1 @@
|
||||
uid://0dw8poe3o53a
|
||||
@@ -0,0 +1,149 @@
|
||||
class_name ActionExecutor
|
||||
extends Node
|
||||
|
||||
const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd")
|
||||
|
||||
signal action_executed(action: Resource, judgement: StringName)
|
||||
signal action_failed(action: Resource, reason: StringName)
|
||||
|
||||
@export var energy_component_path: NodePath
|
||||
@export var damage_emitter_path: NodePath
|
||||
|
||||
@onready var _energy_component: Node = get_node_or_null(energy_component_path)
|
||||
@onready var _damage_emitter: Node = get_node_or_null(damage_emitter_path)
|
||||
|
||||
var last_failure_reason: StringName = &""
|
||||
|
||||
func execute(action: Resource, judgement: StringName, _stat_provider: Variant = null) -> bool:
|
||||
last_failure_reason = &""
|
||||
if action == null:
|
||||
_fail(action, &"missing_action")
|
||||
return false
|
||||
if _action_requires_damage_emitter(action) and _damage_emitter == null:
|
||||
_fail(action, &"missing_damage_emitter")
|
||||
return false
|
||||
if _action_requires_damage_emitter(action) and _damage_emitter != null and _damage_emitter.has_method("configure_hit"):
|
||||
_damage_emitter.configure_hit(action, {"label": str(judgement)})
|
||||
elif _damage_emitter != null and _damage_emitter.has_method("clear_hit"):
|
||||
_damage_emitter.clear_hit()
|
||||
if _action_spawns_projectile(action):
|
||||
_request_projectile(action, judgement)
|
||||
var reward := int(round(_resolve_reward(action, {"label": str(judgement)}, _stat_provider)))
|
||||
if reward != 0 and _energy_component != null:
|
||||
_energy_component.change(reward)
|
||||
action_executed.emit(action, judgement)
|
||||
return true
|
||||
|
||||
|
||||
func finish_action() -> void:
|
||||
if _damage_emitter != null and _damage_emitter.has_method("clear_hit"):
|
||||
_damage_emitter.clear_hit()
|
||||
elif _damage_emitter != null:
|
||||
_damage_emitter.monitoring = false
|
||||
|
||||
|
||||
func _fail(action: Resource, reason: StringName) -> void:
|
||||
last_failure_reason = reason
|
||||
action_failed.emit(action, reason)
|
||||
|
||||
|
||||
func _action_requires_damage_emitter(action: Resource) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
return StringName(str(action.get("hit_type"))) == &"melee"
|
||||
|
||||
|
||||
func _action_spawns_projectile(action: Resource) -> bool:
|
||||
return action != null and StringName(str(action.get("hit_type"))) == &"projectile"
|
||||
|
||||
|
||||
func _resolve_cost(action: Resource, stat_provider: Variant) -> float:
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_cost"):
|
||||
return float(combat.call("resolve_cost", action, {}, stat_provider))
|
||||
return float(action.get("base_cost"))
|
||||
|
||||
|
||||
func _resolve_reward(action: Resource, judgement: Dictionary, stat_provider: Variant) -> float:
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_reward"):
|
||||
return float(combat.call("resolve_reward", action, judgement, stat_provider))
|
||||
return StatResolverScript.resolve_reward(action, judgement, stat_provider)
|
||||
|
||||
|
||||
func _request_projectile(action: Resource, judgement: StringName = &"perfect") -> void:
|
||||
var owner := get_parent()
|
||||
var context := {
|
||||
"team": _projectile_team(action),
|
||||
"action": action,
|
||||
"judgement": {"label": str(judgement)},
|
||||
"range": float(action.get("range")) if action != null else 0.0,
|
||||
"attacker_interrupts": _owner_interrupt_authority(owner),
|
||||
"source_actor": owner,
|
||||
}
|
||||
# Projectile base damage belongs to the shooter (player/minion/boss), not
|
||||
# the projectile scene default.
|
||||
if _damage_emitter != null:
|
||||
context["base_damage"] = int(_damage_emitter.get("damage"))
|
||||
if owner != null and owner.has_method("projectile_requests_for_action"):
|
||||
var requests = owner.call("projectile_requests_for_action", action)
|
||||
if requests is Array and not requests.is_empty():
|
||||
for request: Variant in requests:
|
||||
if request is Dictionary:
|
||||
_emit_projectile_request(
|
||||
request.get("projectile_scene", null) as PackedScene,
|
||||
request.get("spawn_position", Vector2.ZERO) as Vector2,
|
||||
request.get("direction", Vector2.RIGHT) as Vector2,
|
||||
context
|
||||
)
|
||||
return
|
||||
var spawn_position := Vector2.ZERO
|
||||
var direction := Vector2.RIGHT
|
||||
if owner is Node2D:
|
||||
spawn_position = (owner as Node2D).global_position
|
||||
var heading = owner.get("heading")
|
||||
if heading is Vector2 and heading != Vector2.ZERO:
|
||||
direction = heading
|
||||
if owner != null and owner.has_method("projectile_spawn_position"):
|
||||
spawn_position = owner.call("projectile_spawn_position", action)
|
||||
if owner != null and owner.has_method("projectile_direction"):
|
||||
direction = owner.call("projectile_direction", action)
|
||||
_emit_projectile_request(null, spawn_position, direction, context)
|
||||
|
||||
|
||||
func _owner_interrupt_authority(owner: Node) -> bool:
|
||||
if owner != null and owner.has_method("is_strong_in_current_time_phase"):
|
||||
return bool(owner.call("is_strong_in_current_time_phase"))
|
||||
return true
|
||||
|
||||
|
||||
func _projectile_team(action: Resource) -> StringName:
|
||||
if action != null and action.get("action_tags") is Array:
|
||||
for tag: Variant in action.get("action_tags"):
|
||||
if StringName(str(tag)) == &"enemy":
|
||||
return &"enemy"
|
||||
return &"player"
|
||||
|
||||
|
||||
func _emit_projectile_request(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("projectile_requested"):
|
||||
bus.emit_signal("projectile_requested", projectile_scene, spawn_position, direction, context)
|
||||
|
||||
|
||||
func _combat_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("CombatManager")
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://deahmqoulk8me
|
||||
@@ -0,0 +1,18 @@
|
||||
class_name AIIntent
|
||||
extends RefCounted
|
||||
|
||||
var action_id: StringName
|
||||
var timestamp_ms := 0.0
|
||||
var judgement: Dictionary = {
|
||||
"label": "perfect",
|
||||
"diff": 0.0,
|
||||
"abs_diff": 0.0,
|
||||
}
|
||||
|
||||
|
||||
static func create(next_action_id: StringName, next_timestamp_ms: float) -> RefCounted:
|
||||
var script: Script = load("res://scenes/components/ai_intent.gd")
|
||||
var intent: RefCounted = script.new()
|
||||
intent.action_id = next_action_id
|
||||
intent.timestamp_ms = next_timestamp_ms
|
||||
return intent
|
||||
@@ -0,0 +1 @@
|
||||
uid://coyppy6e3f8ax
|
||||
@@ -0,0 +1,170 @@
|
||||
class_name AttackBuffComponent
|
||||
extends Node
|
||||
|
||||
## Sole writer of the time-anchor attack buff stacks. The stack storage itself
|
||||
## is the Effect instance inside EffectContainer; damage injection rides the
|
||||
## existing buffs multiplier slot in resolve_damage (no resolver changes).
|
||||
|
||||
const BUFF_EFFECT_PATH := "res://resources/effects/time_phase/effect_time_anchor_attack_buff.tres"
|
||||
const BUFF_EFFECT_ID := &"time_anchor_attack_buff"
|
||||
const MAX_STACKS := 7
|
||||
const HEAL_WINDOW_BEATS := 4
|
||||
const HEAL_BY_JUDGEMENT := {
|
||||
&"perfect": 30,
|
||||
&"good": 20,
|
||||
&"bad": 10,
|
||||
}
|
||||
|
||||
## 受伤规则:进入受伤状态掉一半 Buff 层数(向下取整),
|
||||
## 每掉 1 层换 0.5 秒霸体(SuperArmor:伤害照吃、不打断、无击退)。
|
||||
const HURT_ARMOR_EFFECT_PATH := "res://resources/effects/effect_hurt_super_armor.tres"
|
||||
const HURT_ARMOR_EFFECT_ID := &"hurt_super_armor"
|
||||
const HURT_ARMOR_SECONDS_PER_STACK := 0.5
|
||||
|
||||
@export var effect_container_path := NodePath("../EffectContainer")
|
||||
@export var health_component_path := NodePath("../HealthComponent")
|
||||
@export var state_machine_path := NodePath("../StateMachine")
|
||||
|
||||
@onready var effect_container: Node = get_node_or_null(effect_container_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var state_machine: Node = get_node_or_null(state_machine_path)
|
||||
|
||||
var buff_definition: Resource
|
||||
var hurt_armor_definition: Resource
|
||||
var _last_healed_window := -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if buff_definition == null and ResourceLoader.exists(BUFF_EFFECT_PATH):
|
||||
buff_definition = load(BUFF_EFFECT_PATH)
|
||||
if hurt_armor_definition == null and ResourceLoader.exists(HURT_ARMOR_EFFECT_PATH):
|
||||
hurt_armor_definition = load(HURT_ARMOR_EFFECT_PATH)
|
||||
if state_machine != null and state_machine.has_signal("axis_changed") and not state_machine.is_connected("axis_changed", _on_state_axis_changed):
|
||||
state_machine.connect("axis_changed", _on_state_axis_changed)
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved):
|
||||
bus.connect("time_anchor_resolved", _on_time_anchor_resolved)
|
||||
if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset):
|
||||
bus.connect("chart_reset", _on_chart_reset)
|
||||
|
||||
|
||||
func attack_buff_stacks() -> int:
|
||||
if effect_container == null or not effect_container.has_method("effect_stacks"):
|
||||
return 0
|
||||
return int(effect_container.call("effect_stacks", BUFF_EFFECT_ID))
|
||||
|
||||
|
||||
func _on_time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) -> void:
|
||||
if not held:
|
||||
return
|
||||
_apply_time_anchor_heal(anchor, judgement)
|
||||
var label := _judgement_label(judgement)
|
||||
if label == &"perfect" or label == &"good":
|
||||
if effect_container != null and buff_definition != null:
|
||||
effect_container.call("add_effect", buff_definition, &"time_anchor")
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var reduced := maxi(0, attack_buff_stacks() - 2)
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, reduced)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_state_axis_changed(axis: StringName, _previous: StringName, current: StringName) -> void:
|
||||
if axis == &"life_state" and current == &"Hitstun":
|
||||
_drop_stacks_for_hurt()
|
||||
|
||||
|
||||
## 受伤惩罚与补偿一体:掉 floor(层数 / 2) 层,掉几层就换几段霸体时间。
|
||||
## 霸体期间后续攻击不再触发受伤状态,因此不会连锁掉层。
|
||||
func _drop_stacks_for_hurt() -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var stacks := attack_buff_stacks()
|
||||
var dropped := floori(stacks * 0.5)
|
||||
if dropped <= 0:
|
||||
return
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, stacks - dropped)
|
||||
_apply_hurt_super_armor(dropped)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _apply_hurt_super_armor(dropped_stacks: int) -> void:
|
||||
if hurt_armor_definition == null or not effect_container.has_method("add_effect"):
|
||||
return
|
||||
var armor := hurt_armor_definition.duplicate() as Resource
|
||||
armor.set("duration", HURT_ARMOR_SECONDS_PER_STACK * float(dropped_stacks))
|
||||
effect_container.call("add_effect", armor, &"hurt_buff_drop")
|
||||
|
||||
|
||||
func cap_stacks(max_kept: int) -> void:
|
||||
if effect_container == null or not effect_container.has_method("set_effect_stacks"):
|
||||
return
|
||||
var capped := clampi(attack_buff_stacks(), 0, maxi(0, max_kept))
|
||||
effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, capped)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_chart_reset(_chart_id: StringName) -> void:
|
||||
_last_healed_window = -1
|
||||
if effect_container != null and effect_container.has_method("remove_effect"):
|
||||
effect_container.call("remove_effect", BUFF_EFFECT_ID)
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _apply_time_anchor_heal(anchor: Dictionary, judgement: Dictionary) -> void:
|
||||
var beat_value: Variant = anchor.get("beat", null)
|
||||
if beat_value == null:
|
||||
return
|
||||
var label: StringName = _judgement_label(judgement)
|
||||
if not HEAL_BY_JUDGEMENT.has(label):
|
||||
return
|
||||
var window_index: int = int(floor(float(int(beat_value)) / float(HEAL_WINDOW_BEATS)))
|
||||
if window_index == _last_healed_window:
|
||||
return
|
||||
var health: Node = _health_component_or_null()
|
||||
if health == null or not health.has_method("heal"):
|
||||
return
|
||||
var amount: int = int(HEAL_BY_JUDGEMENT[label])
|
||||
var maximum: int = maxi(1, int(health.get("maximum")))
|
||||
var current: int = int(health.get("current"))
|
||||
if current < int(ceil(float(maximum) * 0.5)):
|
||||
amount *= 2
|
||||
health.call("heal", amount)
|
||||
_last_healed_window = window_index
|
||||
|
||||
|
||||
func _judgement_label(judgement: Dictionary) -> StringName:
|
||||
if judgement.has("label"):
|
||||
return StringName(str(judgement["label"]).to_lower())
|
||||
if judgement.has(&"label"):
|
||||
return StringName(str(judgement[&"label"]).to_lower())
|
||||
return &""
|
||||
|
||||
|
||||
func _health_component_or_null() -> Node:
|
||||
if health_component != null and is_instance_valid(health_component):
|
||||
return health_component
|
||||
health_component = get_node_or_null(health_component_path)
|
||||
return health_component
|
||||
|
||||
|
||||
func _broadcast() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("attack_buff_changed"):
|
||||
bus.emit_signal("attack_buff_changed", attack_buff_stacks(), MAX_STACKS)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("time_anchor_resolved") and child.has_signal("attack_buff_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
@@ -0,0 +1 @@
|
||||
uid://l2m6txa53ylx
|
||||
@@ -0,0 +1,132 @@
|
||||
class_name AttackBuffVisual
|
||||
extends Node2D
|
||||
|
||||
const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/buff_effect"
|
||||
const DEFAULT_MAX_STACKS := 7
|
||||
|
||||
@export var frame_directory := DEFAULT_FRAME_DIRECTORY
|
||||
@export var frames_per_second := 18.0
|
||||
@export var center_offset := Vector2(0.0, -86.0)
|
||||
@export var minimum_scale := 0.85
|
||||
@export var maximum_scale := 1.85
|
||||
@export var minimum_alpha := 0.28
|
||||
@export var maximum_alpha := 0.95
|
||||
|
||||
var _stacks := 0
|
||||
var _max_stacks := DEFAULT_MAX_STACKS
|
||||
var _frame_index := 0
|
||||
var _frame_elapsed := 0.0
|
||||
var _pulse_elapsed := 0.0
|
||||
var _frames: Array[Texture2D] = []
|
||||
var _sprite: Sprite2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 1
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.name = "BuffEffect"
|
||||
_sprite.centered = true
|
||||
_sprite.position = center_offset
|
||||
_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_sprite.visible = false
|
||||
add_child(_sprite)
|
||||
_load_frames()
|
||||
_connect_event_buses()
|
||||
call_deferred("_connect_event_buses")
|
||||
call_deferred("_sync_from_attack_buff_component")
|
||||
set_attack_buff(0, DEFAULT_MAX_STACKS)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _stacks <= 0 or _frames.is_empty():
|
||||
return
|
||||
var intensity := _intensity()
|
||||
_pulse_elapsed = fmod(_pulse_elapsed + delta * lerpf(1.3, 2.8, intensity), TAU)
|
||||
_frame_elapsed += delta
|
||||
var frame_duration := 1.0 / maxf(1.0, frames_per_second * lerpf(0.85, 1.35, intensity))
|
||||
while _frame_elapsed >= frame_duration:
|
||||
_frame_elapsed -= frame_duration
|
||||
_frame_index = (_frame_index + 1) % _frames.size()
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
_apply_effect_state()
|
||||
|
||||
|
||||
func set_attack_buff(stacks: int, max_stacks: int) -> void:
|
||||
_max_stacks = maxi(1, max_stacks)
|
||||
_stacks = clampi(stacks, 0, _max_stacks)
|
||||
set_process(_stacks > 0 and not _frames.is_empty())
|
||||
visible = _stacks > 0 and not _frames.is_empty()
|
||||
if _sprite != null:
|
||||
_sprite.visible = visible
|
||||
if visible and _sprite.texture == null and not _frames.is_empty():
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
_apply_effect_state()
|
||||
|
||||
|
||||
func frame_count() -> int:
|
||||
return _frames.size()
|
||||
|
||||
|
||||
func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void:
|
||||
set_attack_buff(stacks, max_stacks)
|
||||
|
||||
|
||||
func _load_frames() -> void:
|
||||
_frames.clear()
|
||||
var dir := DirAccess.open(frame_directory)
|
||||
if dir == null:
|
||||
return
|
||||
# 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap;
|
||||
# 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。
|
||||
var files := PackedStringArray()
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not dir.current_is_dir():
|
||||
var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap")
|
||||
if resource_name.begins_with("frame") and resource_name.ends_with(".png") and not files.has(resource_name):
|
||||
files.append(resource_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
files.sort()
|
||||
for path_name: String in files:
|
||||
var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D
|
||||
if texture != null:
|
||||
_frames.append(texture)
|
||||
if not _frames.is_empty() and _sprite != null:
|
||||
_sprite.texture = _frames[0]
|
||||
|
||||
|
||||
func _apply_effect_state() -> void:
|
||||
if _sprite == null:
|
||||
return
|
||||
if _stacks <= 0 or _frames.is_empty():
|
||||
_sprite.visible = false
|
||||
return
|
||||
var intensity := _intensity()
|
||||
var pulse := 0.5 + 0.5 * sin(_pulse_elapsed)
|
||||
_sprite.position = center_offset
|
||||
_sprite.scale = Vector2.ONE * (lerpf(minimum_scale, maximum_scale, intensity) + pulse * lerpf(0.03, 0.18, intensity))
|
||||
_sprite.modulate = Color(1.0, 1.0, 1.0, lerpf(minimum_alpha, maximum_alpha, intensity) * lerpf(0.78, 1.0, pulse))
|
||||
_sprite.visible = true
|
||||
|
||||
|
||||
func _sync_from_attack_buff_component() -> void:
|
||||
var actor := get_parent()
|
||||
if actor != null:
|
||||
actor = actor.get_parent()
|
||||
var component := actor.get_node_or_null("AttackBuffComponent") if actor != null else null
|
||||
if component != null and component.has_method("attack_buff_stacks"):
|
||||
set_attack_buff(int(component.call("attack_buff_stacks")), DEFAULT_MAX_STACKS)
|
||||
|
||||
|
||||
func _connect_event_buses() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("attack_buff_changed") and not child.is_connected("attack_buff_changed", _on_attack_buff_changed):
|
||||
child.connect("attack_buff_changed", _on_attack_buff_changed)
|
||||
|
||||
|
||||
func _intensity() -> float:
|
||||
return clampf(float(_stacks) / float(maxi(1, _max_stacks)), 0.0, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2ejqfwu2b6t5
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name BurstComponent
|
||||
extends Node
|
||||
|
||||
signal burst_changed(burst_ready: bool, active: bool, cooldown: int)
|
||||
|
||||
const BURST_EFFECT_PATH := "res://resources/effects/effect_burst_power.tres"
|
||||
|
||||
@export var active_beats := 16
|
||||
@export var cooldown_beats := 4
|
||||
|
||||
var burst_ready := false
|
||||
var active := false
|
||||
var cooldown := 0
|
||||
var _beats_left := 0
|
||||
@onready var effect_container: Node = get_node_or_null("../EffectContainer")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
|
||||
if rhythm != null and not rhythm.is_connected("beat_ticked", _on_beat_ticked):
|
||||
rhythm.connect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func set_ready(value: bool) -> void:
|
||||
if active or cooldown > 0:
|
||||
burst_ready = false
|
||||
else:
|
||||
burst_ready = value
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
|
||||
|
||||
func activate() -> bool:
|
||||
if not burst_ready or active or cooldown > 0:
|
||||
return false
|
||||
burst_ready = false
|
||||
active = true
|
||||
_beats_left = active_beats
|
||||
_apply_burst_effect()
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
return true
|
||||
|
||||
|
||||
func damage_mult(_action: Resource = null) -> float:
|
||||
return _effect_stat_multiplier(&"damage_mult")
|
||||
|
||||
|
||||
func cost_mult(_action: Resource = null) -> float:
|
||||
return _effect_stat_multiplier(&"cost_mult")
|
||||
|
||||
|
||||
func move_mult(_action: Resource = null) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
func _on_beat_ticked(_beat_index: int) -> void:
|
||||
if active:
|
||||
_beats_left -= 1
|
||||
if _beats_left <= 0:
|
||||
active = false
|
||||
cooldown = cooldown_beats
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
elif cooldown > 0:
|
||||
cooldown -= 1
|
||||
burst_changed.emit(burst_ready, active, cooldown)
|
||||
|
||||
|
||||
func _apply_burst_effect() -> void:
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("add_effect"):
|
||||
var burst_effect: Resource = load(BURST_EFFECT_PATH) if ResourceLoader.exists(BURST_EFFECT_PATH) else null
|
||||
if burst_effect != null:
|
||||
effect_container.call("add_effect", burst_effect, &"burst")
|
||||
|
||||
|
||||
func _effect_stat_multiplier(stat: StringName) -> float:
|
||||
if not active:
|
||||
return 1.0
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("stat_multiplier"):
|
||||
return float(effect_container.call("stat_multiplier", stat, null))
|
||||
return 1.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnofufbs2yvx5
|
||||
@@ -0,0 +1,198 @@
|
||||
class_name ChargeComponent
|
||||
extends Node
|
||||
|
||||
signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool)
|
||||
|
||||
# wave: CHARGING UP OVERLAY loops while holding S (author spec 50fps).
|
||||
# blade_rain: SWORD RAIN PREP OVERLAY plays frames 1-16 (ring forms) and holds;
|
||||
# frames 17-19 (ring launches) belong to the blade_rain_cast release animation.
|
||||
const OVERLAY_PROFILES := {
|
||||
&"wave": {
|
||||
"path": "res://assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png",
|
||||
"hframes": 5, "vframes": 5, "first": 0, "last": 24, "fps": 50.0,
|
||||
"offset": Vector2(-92.0, -140.0), "loop": true,
|
||||
},
|
||||
&"blade_rain": {
|
||||
"path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png",
|
||||
"hframes": 10, "vframes": 2, "first": 1, "last": 16, "fps": 25.0,
|
||||
"offset": Vector2(-64.0, -305.0), "loop": false,
|
||||
},
|
||||
}
|
||||
|
||||
# Fallback ramp when no ActionController drives the gauge (standalone use).
|
||||
@export var charge_duration := 1.1
|
||||
@export var animation_player_path: NodePath
|
||||
@export var effect_sprite_path: NodePath
|
||||
|
||||
var value := 0.0
|
||||
var charge_ready := false
|
||||
var active := false
|
||||
var overlay_profile: StringName = &"wave"
|
||||
|
||||
var _effect_time := 0.0
|
||||
var _animation_time := 0.0
|
||||
var _maximum := 1.1
|
||||
var _last_level := 0
|
||||
|
||||
@onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer
|
||||
@onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D
|
||||
@onready var _action_controller: Node = get_node_or_null("../ActionController")
|
||||
|
||||
|
||||
func tick(delta: float, is_charging: bool) -> void:
|
||||
if not is_charging:
|
||||
if active or value > 0.0 or charge_ready:
|
||||
cancel()
|
||||
return
|
||||
if not active:
|
||||
_start()
|
||||
if not active:
|
||||
return
|
||||
_update_charge_animation(delta)
|
||||
var charge_state := _controller_charge_state()
|
||||
if charge_state.is_empty():
|
||||
_maximum = charge_duration
|
||||
value = minf(charge_duration, value + delta)
|
||||
charge_ready = value >= charge_duration
|
||||
else:
|
||||
# The gauge is level-space: 0 units = level 1 fresh, max units = top
|
||||
# level. The cast level itself is owned by ActionController.
|
||||
var max_level := maxi(2, int(charge_state.get("max_level", 3)))
|
||||
var level := int(charge_state.get("level", 1))
|
||||
_maximum = float(max_level - 1)
|
||||
value = clampf(float(charge_state.get("progress_units", 0.0)), 0.0, _maximum)
|
||||
charge_ready = level >= max_level
|
||||
if level > _last_level and _last_level > 0:
|
||||
_pulse_level_up()
|
||||
_last_level = level
|
||||
_update_charge_effect(delta)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func cancel() -> void:
|
||||
active = false
|
||||
value = 0.0
|
||||
charge_ready = false
|
||||
_animation_time = 0.0
|
||||
_last_level = 0
|
||||
_set_effect_visible(false)
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color.WHITE
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return active
|
||||
|
||||
|
||||
func is_ready() -> bool:
|
||||
return charge_ready
|
||||
|
||||
|
||||
func maximum() -> float:
|
||||
return _maximum
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
active = true
|
||||
value = 0.0
|
||||
charge_ready = false
|
||||
_effect_time = 0.0
|
||||
_animation_time = 0.0
|
||||
_last_level = 1
|
||||
_setup_charge_overlay()
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color.WHITE
|
||||
_update_charge_effect(0.0)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _controller_charge_state() -> Dictionary:
|
||||
if _action_controller == null:
|
||||
_action_controller = get_node_or_null("../ActionController")
|
||||
if _action_controller == null or not _action_controller.has_method("charge_state"):
|
||||
return {}
|
||||
var charge_state = _action_controller.call("charge_state")
|
||||
if charge_state is Dictionary and bool((charge_state as Dictionary).get("charging", false)):
|
||||
return charge_state
|
||||
return {}
|
||||
|
||||
|
||||
func _pulse_level_up() -> void:
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.modulate = Color(1.7, 1.7, 1.7)
|
||||
|
||||
|
||||
func set_overlay_profile(profile: StringName) -> void:
|
||||
if profile == overlay_profile or not OVERLAY_PROFILES.has(profile):
|
||||
return
|
||||
overlay_profile = profile
|
||||
if active:
|
||||
_effect_time = 0.0
|
||||
_setup_charge_overlay()
|
||||
|
||||
|
||||
func _current_overlay() -> Dictionary:
|
||||
return OVERLAY_PROFILES.get(overlay_profile, OVERLAY_PROFILES[&"wave"])
|
||||
|
||||
|
||||
func _setup_charge_overlay() -> void:
|
||||
if _effect_sprite == null:
|
||||
return
|
||||
var overlay := _current_overlay()
|
||||
var texture_path := str(overlay.get("path", ""))
|
||||
var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null
|
||||
if texture == null:
|
||||
return
|
||||
_effect_sprite.texture = texture
|
||||
_effect_sprite.hframes = maxi(1, int(overlay.get("hframes", 1)))
|
||||
_effect_sprite.vframes = maxi(1, int(overlay.get("vframes", 1)))
|
||||
_effect_sprite.offset = overlay.get("offset", Vector2.ZERO)
|
||||
_effect_sprite.frame = clampi(int(overlay.get("first", 0)), 0, _effect_sprite.hframes * _effect_sprite.vframes - 1)
|
||||
|
||||
|
||||
func _update_charge_effect(delta: float) -> void:
|
||||
if _effect_sprite == null:
|
||||
return
|
||||
_effect_sprite.visible = active
|
||||
if not active:
|
||||
return
|
||||
_effect_time += delta
|
||||
if _effect_sprite.modulate != Color.WHITE:
|
||||
_effect_sprite.modulate = _effect_sprite.modulate.lerp(Color.WHITE, minf(1.0, delta * 6.0))
|
||||
var overlay := _current_overlay()
|
||||
var first := int(overlay.get("first", 0))
|
||||
var last := int(overlay.get("last", first))
|
||||
var span := maxi(1, last - first + 1)
|
||||
var step := int(_effect_time * float(overlay.get("fps", 25.0)))
|
||||
var frame_index := first + (step % span if bool(overlay.get("loop", true)) else mini(step, span - 1))
|
||||
_effect_sprite.frame = clampi(frame_index, 0, _effect_sprite.hframes * _effect_sprite.vframes - 1)
|
||||
|
||||
|
||||
func _update_charge_animation(delta: float) -> void:
|
||||
_animation_time += delta
|
||||
var intro_length := _animation_length(&"warrior_charge_intro")
|
||||
if _animation_time < intro_length:
|
||||
_play_charge_animation(&"warrior_charge_intro")
|
||||
else:
|
||||
_play_charge_animation(&"warrior_charge_loop")
|
||||
|
||||
|
||||
func _play_charge_animation(animation_name: StringName) -> void:
|
||||
if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name:
|
||||
_animation_player.play(animation_name)
|
||||
|
||||
|
||||
func _animation_length(animation_name: StringName) -> float:
|
||||
if _animation_player != null and _animation_player.has_animation(animation_name):
|
||||
return maxf(0.1, _animation_player.get_animation(animation_name).length)
|
||||
return 0.1
|
||||
|
||||
|
||||
func _set_effect_visible(is_visible: bool) -> void:
|
||||
if _effect_sprite != null:
|
||||
_effect_sprite.visible = is_visible
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
charge_changed.emit(value, _maximum, charge_ready, active)
|
||||
@@ -0,0 +1 @@
|
||||
uid://doo6xoscxjpt2
|
||||
@@ -0,0 +1,122 @@
|
||||
class_name ComboWindow
|
||||
extends Node
|
||||
|
||||
signal combo_updated(inputs: Array[StringName])
|
||||
signal combo_cleared(reason: StringName)
|
||||
|
||||
@export var size := 4
|
||||
@export var clear_display_time := 0.35
|
||||
@export var broadcast_to_bus := true
|
||||
|
||||
var slots: Array[StringName] = []
|
||||
var pending_clear_reason: StringName = &""
|
||||
var _timer: Timer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_timer = Timer.new()
|
||||
_timer.one_shot = true
|
||||
_timer.timeout.connect(flush_pending_clear)
|
||||
add_child(_timer)
|
||||
|
||||
|
||||
func record(input: StringName) -> void:
|
||||
if input.is_empty():
|
||||
return
|
||||
slots.append(input)
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
if slots.size() >= size:
|
||||
queue_clear(&"full")
|
||||
|
||||
|
||||
func rollback_last(expected_input: StringName = &"") -> bool:
|
||||
if slots.is_empty():
|
||||
return false
|
||||
var last := slots[slots.size() - 1]
|
||||
if not expected_input.is_empty() and last != expected_input:
|
||||
return false
|
||||
slots.pop_back()
|
||||
pending_clear_reason = &""
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
return true
|
||||
|
||||
|
||||
func get_slots() -> Array[StringName]:
|
||||
return slots.duplicate()
|
||||
|
||||
|
||||
func has_pending_clear() -> bool:
|
||||
return not pending_clear_reason.is_empty()
|
||||
|
||||
|
||||
func consume_pending_clear_reason() -> StringName:
|
||||
var reason := pending_clear_reason
|
||||
pending_clear_reason = &""
|
||||
return reason
|
||||
|
||||
|
||||
func get_pattern() -> String:
|
||||
var pattern := ""
|
||||
for slot: StringName in slots:
|
||||
if slot != &"Ø":
|
||||
pattern += str(slot)
|
||||
return pattern
|
||||
|
||||
|
||||
func get_contiguous_pattern() -> String:
|
||||
var pattern := ""
|
||||
for index: int in range(slots.size() - 1, -1, -1):
|
||||
var slot := slots[index]
|
||||
if slot == &"Ø":
|
||||
break
|
||||
pattern = str(slot) + pattern
|
||||
return pattern
|
||||
|
||||
|
||||
func queue_clear(reason: StringName, delay := -1.0) -> void:
|
||||
pending_clear_reason = reason
|
||||
if _timer == null:
|
||||
return
|
||||
_timer.stop()
|
||||
_timer.wait_time = clear_display_time if delay < 0.0 else delay
|
||||
_timer.start()
|
||||
|
||||
|
||||
func cancel_pending_clear() -> void:
|
||||
pending_clear_reason = &""
|
||||
if _timer != null:
|
||||
_timer.stop()
|
||||
|
||||
|
||||
func flush_pending_clear() -> void:
|
||||
var reason := consume_pending_clear_reason()
|
||||
if reason.is_empty():
|
||||
return
|
||||
if _timer != null:
|
||||
_timer.stop()
|
||||
clear(reason)
|
||||
|
||||
|
||||
func clear(reason: StringName = &"") -> void:
|
||||
slots.clear()
|
||||
pending_clear_reason = &""
|
||||
combo_cleared.emit(reason)
|
||||
_emit_bus_signal("combo_cleared", [reason])
|
||||
combo_updated.emit(get_slots())
|
||||
_emit_bus_signal("combo_updated", [get_slots()])
|
||||
|
||||
|
||||
func _emit_bus_signal(signal_name: StringName, args: Array) -> void:
|
||||
if not broadcast_to_bus or not is_inside_tree():
|
||||
return
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null:
|
||||
bus.emit_signal(signal_name, args[0])
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd66pkuusgqck
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name DamageEmitter
|
||||
extends Area2D
|
||||
|
||||
@export var damage := 10
|
||||
@export var hit_type: StringName = &"normal"
|
||||
@export var base_knockback := Vector2(120.0, 304.056)
|
||||
|
||||
var action_context: Resource
|
||||
var judgement_context: Dictionary = {}
|
||||
## Weak-state minion attacks only deal damage; interrupt authority snapshots at
|
||||
## action start so phase changes mid-swing do not rewrite the hit.
|
||||
var attacker_interrupts := true
|
||||
var _default_position := Vector2.ZERO
|
||||
var _default_shape_size := Vector2.ZERO
|
||||
# With per-frame hit windows (FrameCollisionDriver) the same receiver can
|
||||
# enter the area more than once within one swing; each swing hits once.
|
||||
var _already_hit_ids: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_default_position = position
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
_default_shape_size = (shape_node.shape as RectangleShape2D).size
|
||||
area_entered.connect(_on_area_entered)
|
||||
|
||||
|
||||
func configure_hit(action: Resource, judgement: Dictionary) -> void:
|
||||
action_context = action
|
||||
judgement_context = judgement.duplicate()
|
||||
attacker_interrupts = _owner_interrupt_authority()
|
||||
_already_hit_ids.clear()
|
||||
if action != null:
|
||||
hit_type = StringName(str(action.get("hit_type")))
|
||||
_update_hitbox_geometry(action)
|
||||
monitoring = true
|
||||
|
||||
|
||||
func clear_hit() -> void:
|
||||
monitoring = false
|
||||
action_context = null
|
||||
judgement_context = {}
|
||||
attacker_interrupts = true
|
||||
_already_hit_ids.clear()
|
||||
|
||||
|
||||
func _owner_interrupt_authority() -> bool:
|
||||
var owner := get_parent()
|
||||
if owner != null and owner.has_method("is_strong_in_current_time_phase"):
|
||||
return bool(owner.call("is_strong_in_current_time_phase"))
|
||||
return true
|
||||
|
||||
|
||||
func has_already_hit(receiver: Node) -> bool:
|
||||
return receiver != null and _already_hit_ids.has(receiver.get_instance_id())
|
||||
|
||||
|
||||
func _update_hitbox_geometry(action: Resource) -> void:
|
||||
if action == null:
|
||||
position = _default_position
|
||||
return
|
||||
var range := float(action.get("range"))
|
||||
if range <= 0.0:
|
||||
position = _default_position
|
||||
return
|
||||
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
var rectangle := shape_node.shape as RectangleShape2D
|
||||
var height := _default_shape_size.y if _default_shape_size != Vector2.ZERO else rectangle.size.y
|
||||
rectangle.size = Vector2(maxf(8.0, range), maxf(8.0, height))
|
||||
var owner := get_parent()
|
||||
var direction := 1.0
|
||||
if owner != null:
|
||||
var heading = owner.get("heading")
|
||||
if heading is Vector2 and absf((heading as Vector2).x) > 0.0:
|
||||
direction = -1.0 if (heading as Vector2).x < 0.0 else 1.0
|
||||
var base_y := _default_position.y
|
||||
position = Vector2(direction * (range * 0.5 + 10.0), base_y)
|
||||
|
||||
|
||||
func _on_area_entered(receiver: Area2D) -> void:
|
||||
if receiver.is_in_group("damage_receivers"):
|
||||
if _already_hit_ids.has(receiver.get_instance_id()):
|
||||
return
|
||||
_already_hit_ids[receiver.get_instance_id()] = true
|
||||
var combat := _combat_manager_or_null()
|
||||
if combat != null and combat.has_method("resolve_hit"):
|
||||
var result: Dictionary = combat.call("resolve_hit", self, receiver)
|
||||
_event_bus().emit_signal("damage_dealt", receiver, int(result.get("damage", 0)), hit_type)
|
||||
|
||||
|
||||
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 _combat_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("CombatManager")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddays88jc3oh3
|
||||
@@ -0,0 +1,30 @@
|
||||
class_name DamageReceiver
|
||||
extends Area2D
|
||||
|
||||
signal damage_received(amount: int, hit_type: StringName, from: Vector2)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("damage_receivers")
|
||||
|
||||
|
||||
func take_damage(amount: int, hit_type: StringName, from: Vector2) -> void:
|
||||
damage_received.emit(amount, hit_type, from)
|
||||
_event_bus().emit_signal("damage_dealt", self, amount, hit_type)
|
||||
|
||||
|
||||
func receive_hit(result: Dictionary) -> void:
|
||||
var amount := int(result.get("damage", 0))
|
||||
var resolved_hit_type := StringName(str(result.get("hit_type", &"normal")))
|
||||
var from := result.get("from", Vector2.ZERO) as Vector2
|
||||
damage_received.emit(amount, resolved_hit_type, from)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://be6nwtel2w0kp
|
||||
@@ -0,0 +1,213 @@
|
||||
class_name EffectContainer
|
||||
extends Node
|
||||
|
||||
signal effect_added(effect_id: StringName)
|
||||
signal effect_removed(effect_id: StringName)
|
||||
|
||||
const EffectInstanceScript := preload("res://resources/effects/effect_instance.gd")
|
||||
|
||||
@export var beats_per_measure := 4
|
||||
@export var initial_effects: Array[Resource] = []
|
||||
|
||||
var effects: Array[RefCounted] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
for definition: Resource in initial_effects:
|
||||
add_effect(definition, &"loadout")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
tick_time(delta)
|
||||
|
||||
|
||||
func add_effect(definition: Resource, source: Variant = &"-") -> void:
|
||||
if definition == null:
|
||||
return
|
||||
var effect_id := StringName(str(definition.get("id")))
|
||||
var max_stacks := int(definition.get("max_stacks"))
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
effect.stacks = mini(effect.stacks + 1, max(1, max_stacks))
|
||||
effect.remaining = float(definition.get("duration"))
|
||||
effect.source = source
|
||||
effect_added.emit(effect_id)
|
||||
return
|
||||
effects.append(EffectInstanceScript.create(definition, source))
|
||||
effect_added.emit(effect_id)
|
||||
|
||||
|
||||
func set_effect_stacks(effect_id: StringName, stacks: int) -> void:
|
||||
if stacks <= 0:
|
||||
remove_effect(effect_id)
|
||||
return
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
var max_stacks := maxi(1, int(effect.definition.get("max_stacks")))
|
||||
effect.stacks = mini(stacks, max_stacks)
|
||||
return
|
||||
|
||||
|
||||
func effect_stacks(effect_id: StringName) -> int:
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
return int(effect.stacks)
|
||||
return 0
|
||||
|
||||
|
||||
func remove_effect(effect_id: StringName) -> void:
|
||||
for index: int in range(effects.size() - 1, -1, -1):
|
||||
var effect: RefCounted = effects[index]
|
||||
if StringName(str(effect.definition.get("id"))) == effect_id:
|
||||
effects.remove_at(index)
|
||||
effect_removed.emit(effect_id)
|
||||
|
||||
|
||||
func tick_time(delta: float) -> void:
|
||||
for effect: RefCounted in effects:
|
||||
effect.tick_time(delta)
|
||||
_prune_expired()
|
||||
|
||||
|
||||
func tick_beats(beats: float) -> void:
|
||||
for effect: RefCounted in effects:
|
||||
effect.tick_beats(beats)
|
||||
_prune_expired()
|
||||
|
||||
|
||||
func active_count() -> int:
|
||||
return effects.size()
|
||||
|
||||
|
||||
func active_effect_ids() -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for effect: RefCounted in effects:
|
||||
ids.append(StringName(str(effect.definition.get("id"))))
|
||||
return ids
|
||||
|
||||
|
||||
func active_effect_summaries() -> Array[Dictionary]:
|
||||
var summaries: Array[Dictionary] = []
|
||||
for effect: RefCounted in effects:
|
||||
summaries.append({
|
||||
"id": StringName(str(effect.definition.get("id"))),
|
||||
"duration_type": StringName(str(effect.definition.get("duration_type"))),
|
||||
"remaining": float(effect.remaining),
|
||||
"stacks": int(effect.stacks),
|
||||
"source": _source_label(effect.source),
|
||||
})
|
||||
return summaries
|
||||
|
||||
|
||||
func dispatch_event(event_name: StringName, context: Dictionary = {}) -> void:
|
||||
var triggered: Array[Resource] = []
|
||||
for effect: RefCounted in effects:
|
||||
if StringName(str(effect.definition.get("trigger_event"))) != event_name:
|
||||
continue
|
||||
if effect.has_method("matches_event_context") and not bool(effect.call("matches_event_context", context)):
|
||||
continue
|
||||
var next_effects = effect.definition.get("trigger_effects")
|
||||
if not next_effects is Array:
|
||||
continue
|
||||
for definition: Resource in next_effects:
|
||||
if definition != null:
|
||||
triggered.append(definition)
|
||||
for effect: RefCounted in effects:
|
||||
if effect.has_method("tick_event"):
|
||||
effect.call("tick_event", event_name, context)
|
||||
_prune_expired()
|
||||
for definition: Resource in triggered:
|
||||
add_effect(definition)
|
||||
|
||||
|
||||
func damage_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"damage_mult", action)
|
||||
|
||||
|
||||
func cost_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"cost_mult", action)
|
||||
|
||||
|
||||
func move_mult(action: Resource = null) -> float:
|
||||
return stat_multiplier(&"move_mult", action)
|
||||
|
||||
|
||||
func defense_modifiers() -> Array[Resource]:
|
||||
return _active_modifiers("defense_modifiers")
|
||||
|
||||
|
||||
func action_rule_modifiers() -> Array[Resource]:
|
||||
return _active_modifiers("action_rule_modifiers")
|
||||
|
||||
|
||||
func stat_multiplier(stat: StringName, _action: Resource = null) -> float:
|
||||
var multiplier := 1.0
|
||||
for effect: RefCounted in effects:
|
||||
for modifier: Resource in _stat_modifiers(effect):
|
||||
if StringName(str(modifier.get("stat"))) != stat:
|
||||
continue
|
||||
if str(modifier.get("operation")) == "add":
|
||||
multiplier += float(modifier.get("value")) * maxi(1, int(effect.stacks))
|
||||
else:
|
||||
multiplier *= float(modifier.get("value"))
|
||||
return multiplier
|
||||
|
||||
|
||||
func _stat_modifiers(effect: RefCounted) -> Array:
|
||||
var modifiers = effect.definition.get("stat_modifiers")
|
||||
return modifiers if modifiers is Array else []
|
||||
|
||||
|
||||
func _active_modifiers(property_name: String) -> Array[Resource]:
|
||||
var result: Array[Resource] = []
|
||||
for effect: RefCounted in effects:
|
||||
var modifiers = effect.definition.get(property_name)
|
||||
if not modifiers is Array:
|
||||
continue
|
||||
for modifier: Resource in modifiers:
|
||||
if modifier != null:
|
||||
result.append(modifier)
|
||||
return result
|
||||
|
||||
|
||||
func _source_label(source: Variant) -> StringName:
|
||||
if source == null:
|
||||
return &"-"
|
||||
if source is StringName:
|
||||
return source
|
||||
if source is String:
|
||||
return StringName(source)
|
||||
if source is Node:
|
||||
return StringName((source as Node).name)
|
||||
return StringName(str(source))
|
||||
|
||||
|
||||
func _prune_expired() -> void:
|
||||
for index: int in range(effects.size() - 1, -1, -1):
|
||||
var effect: RefCounted = effects[index]
|
||||
if effect.is_expired():
|
||||
var effect_id := StringName(str(effect.definition.get("id")))
|
||||
effects.remove_at(index)
|
||||
effect_removed.emit(effect_id)
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
tick_beats(1.0)
|
||||
if beats_per_measure > 0 and beat_index % beats_per_measure == 0:
|
||||
dispatch_event(&"on_measure_start", {"beat_index": beat_index})
|
||||
dispatch_event(&"on_beat", {"beat_index": beat_index})
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://27i1pv5fu1ae
|
||||
@@ -0,0 +1,54 @@
|
||||
class_name EnergyComponent
|
||||
extends Node
|
||||
|
||||
signal energy_changed(current: int, maximum: int)
|
||||
|
||||
@export var maximum := 100
|
||||
@export var current := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func set_values(next_current: int, next_maximum: int) -> void:
|
||||
maximum = max(1, next_maximum)
|
||||
current = clampi(next_current, 0, maximum)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func set_current(next_current: int) -> void:
|
||||
var clamped := clampi(next_current, 0, maximum)
|
||||
if clamped == current:
|
||||
return
|
||||
current = clamped
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func change(delta: int) -> void:
|
||||
set_current(current + delta)
|
||||
|
||||
|
||||
func spend(cost: float) -> bool:
|
||||
var int_cost := int(ceil(cost))
|
||||
if int_cost <= 0:
|
||||
return true
|
||||
if current < int_cost:
|
||||
return false
|
||||
set_current(current - int_cost)
|
||||
return true
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
energy_changed.emit(current, maximum)
|
||||
_event_bus().emit_signal("player_energy_changed", float(current), float(maximum))
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce44s5ldp64p1
|
||||
@@ -0,0 +1,317 @@
|
||||
class_name FrameCollisionDriver
|
||||
extends Node
|
||||
|
||||
## Drives the three collision matrices from the CURRENT animation frame, every
|
||||
## physics tick, for both the player and the boss:
|
||||
## body - CharacterBody2D collision_layer/mask (composed with the
|
||||
## MotionExecutor dash-through ghost bits so nothing goes stale),
|
||||
## hurtbox - DamageReceiver shape follows the opaque pixel bounds of the
|
||||
## frame being displayed (pose-accurate damage-receive matrix),
|
||||
## hitbox - DamageEmitter only monitors on ACTIVE-phase frames whose art
|
||||
## reaches forward past HIT_REACH_GATE of the sheet's own maximum
|
||||
## (best-effort per sheet; action range stays the damage floor).
|
||||
## If no frame of a swing ever passes the gate, the back half of
|
||||
## the ACTIVE phase falls back to the plain range window so a
|
||||
## beat-anchored swing can never whiff purely from art timing.
|
||||
## Facing is derived per tick from heading x visual mirroring, so native-left
|
||||
## sheets (player) and native-right sheets (boss) both measure true forward
|
||||
## reach. Frame bounds are computed once per sheet in a single byte-level pass
|
||||
## and cached statically; per-tick work is table lookups plus rect transforms.
|
||||
|
||||
const ActionControllerScript := preload("res://scenes/components/action_controller.gd")
|
||||
|
||||
const MIN_HURT_SIZE := Vector2(12.0, 16.0)
|
||||
const HIT_REACH_GATE := 0.72
|
||||
const HIT_MIN_FORWARD_PX := 18.0
|
||||
const HIT_HEIGHT_FACTOR := 0.8
|
||||
const HIT_START_OFFSET_PX := 6.0
|
||||
|
||||
@export var sprite_path: NodePath = ^"../Visual/CharacterSprite"
|
||||
@export var damage_emitter_path: NodePath = ^"../DamageEmitter"
|
||||
@export var damage_receiver_path: NodePath = ^"../DamageReceiver"
|
||||
@export var action_controller_path: NodePath = ^"../ActionController"
|
||||
@export var motion_executor_path: NodePath = ^"../MotionExecutor"
|
||||
@export var body_collision_enabled := true
|
||||
@export var damage_receiver_enabled := true
|
||||
@export var damage_emitter_enabled := true
|
||||
@export var hurtbox_enabled := true
|
||||
@export var hitbox_enabled := true
|
||||
## Actor-space pixels trimmed from the opaque bounds (total per axis) so
|
||||
## clothing fringes and glow pixels do not count as body.
|
||||
@export var hurt_margin := Vector2(12.0, 8.0)
|
||||
|
||||
var actor: CharacterBody2D
|
||||
var sprite: Sprite2D
|
||||
var emitter: Area2D
|
||||
var receiver: Area2D
|
||||
var controller: Node
|
||||
var motion_executor: Node
|
||||
|
||||
var _base_body_layer := 0
|
||||
var _base_body_mask := 0
|
||||
var _base_emitter_layer := 0
|
||||
var _base_emitter_mask := 0
|
||||
var _base_receiver_layer := 0
|
||||
var _base_receiver_mask := 0
|
||||
var _swing_action: Resource
|
||||
var _swing_had_hit_window := false
|
||||
|
||||
static var _sheet_bounds_cache: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Run after ActionController/ActionExecutor state changes within the tick,
|
||||
# so this component owns the final matrix values for the physics step.
|
||||
process_physics_priority = 20
|
||||
actor = get_parent() as CharacterBody2D
|
||||
sprite = get_node_or_null(sprite_path) as Sprite2D
|
||||
emitter = get_node_or_null(damage_emitter_path) as Area2D
|
||||
receiver = get_node_or_null(damage_receiver_path) as Area2D
|
||||
controller = get_node_or_null(action_controller_path)
|
||||
motion_executor = get_node_or_null(motion_executor_path)
|
||||
if actor != null:
|
||||
_base_body_layer = actor.collision_layer
|
||||
_base_body_mask = actor.collision_mask
|
||||
if emitter != null:
|
||||
_base_emitter_layer = emitter.collision_layer
|
||||
_base_emitter_mask = emitter.collision_mask
|
||||
_make_shape_unique(emitter)
|
||||
if receiver != null:
|
||||
_base_receiver_layer = receiver.collision_layer
|
||||
_base_receiver_mask = receiver.collision_mask
|
||||
_make_shape_unique(receiver)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
refresh_now()
|
||||
|
||||
|
||||
func refresh_now() -> void:
|
||||
_apply_body_matrix()
|
||||
var frame_rect := _current_frame_actor_rect()
|
||||
if hurtbox_enabled:
|
||||
_apply_hurtbox(frame_rect)
|
||||
if hitbox_enabled:
|
||||
_apply_hitbox(frame_rect)
|
||||
|
||||
|
||||
func _apply_body_matrix() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
var layer := _base_body_layer
|
||||
var mask := _base_body_mask
|
||||
if not body_collision_enabled:
|
||||
layer &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
mask &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
if motion_executor != null and motion_executor.has_method("is_ghosting") and motion_executor.is_ghosting():
|
||||
layer &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
mask &= ~MotionExecutor.BODY_GHOST_BITS
|
||||
actor.collision_layer = layer
|
||||
actor.collision_mask = mask
|
||||
|
||||
|
||||
func _apply_hurtbox(frame_rect: Rect2) -> void:
|
||||
if receiver == null:
|
||||
return
|
||||
receiver.collision_layer = _base_receiver_layer
|
||||
receiver.collision_mask = _base_receiver_mask
|
||||
if not damage_receiver_enabled:
|
||||
receiver.collision_layer = 0
|
||||
receiver.collision_mask = 0
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
return
|
||||
if _actor_is_dead():
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
return
|
||||
receiver.monitoring = true
|
||||
receiver.monitorable = true
|
||||
if frame_rect.size == Vector2.ZERO:
|
||||
return
|
||||
var shape_node := receiver.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node == null or not (shape_node.shape is RectangleShape2D):
|
||||
return
|
||||
receiver.position = Vector2.ZERO
|
||||
var size := frame_rect.size - hurt_margin
|
||||
size = Vector2(maxf(MIN_HURT_SIZE.x, size.x), maxf(MIN_HURT_SIZE.y, size.y))
|
||||
(shape_node.shape as RectangleShape2D).size = size
|
||||
shape_node.position = frame_rect.get_center()
|
||||
|
||||
|
||||
func _apply_hitbox(frame_rect: Rect2) -> void:
|
||||
if emitter == null:
|
||||
return
|
||||
emitter.collision_layer = _base_emitter_layer
|
||||
emitter.collision_mask = _base_emitter_mask
|
||||
if not damage_emitter_enabled or _actor_is_dead():
|
||||
emitter.collision_layer = 0 if not damage_emitter_enabled else _base_emitter_layer
|
||||
emitter.collision_mask = 0 if not damage_emitter_enabled else _base_emitter_mask
|
||||
emitter.monitoring = false
|
||||
return
|
||||
if controller == null or actor == null or sprite == null or sprite.texture == null:
|
||||
# Without a phase source the emitter keeps its own configure/clear
|
||||
# window behaviour (projectiles, tests, detached components).
|
||||
return
|
||||
emitter.position = Vector2.ZERO
|
||||
var action: Resource = emitter.get("action_context") as Resource
|
||||
var in_active: bool = int(controller.get("phase")) == ActionControllerScript.Phase.ACTIVE
|
||||
var is_melee := action != null and StringName(str(action.get("hit_type"))) == &"melee"
|
||||
if not in_active or not is_melee or frame_rect.size == Vector2.ZERO:
|
||||
emitter.monitoring = false
|
||||
_swing_action = null
|
||||
_swing_had_hit_window = false
|
||||
return
|
||||
if action != _swing_action:
|
||||
_swing_action = action
|
||||
_swing_had_hit_window = false
|
||||
var heading_x := 1.0
|
||||
var heading: Variant = actor.get("heading")
|
||||
if heading is Vector2 and absf((heading as Vector2).x) > 0.0:
|
||||
heading_x = signf((heading as Vector2).x)
|
||||
var hframes := maxi(1, sprite.hframes)
|
||||
var vframes := maxi(1, sprite.vframes)
|
||||
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
|
||||
var anchor_px := _actor_anchor_in_sheet_pixels()
|
||||
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
emitter.monitoring = false
|
||||
return
|
||||
# Which native side of the sheet is "forward" depends on both the heading
|
||||
# and the Visual mirror: sprite-native +x maps to actor sign(basis_x).
|
||||
var basis_x := (actor.global_transform.affine_inverse() * sprite.global_transform).x.x
|
||||
var forward_is_native_left := (basis_x * heading_x) < 0.0
|
||||
var native_forward := (anchor_px.x - bounds.position.x) if forward_is_native_left else (bounds.end.x - anchor_px.x)
|
||||
var sheet_max := _sheet_max_native_extent(sprite.texture, hframes, vframes, anchor_px.x, forward_is_native_left)
|
||||
var pixel_scale := frame_rect.size.x / maxf(1.0, bounds.size.x)
|
||||
var forward_px := native_forward * pixel_scale
|
||||
var reaches := sheet_max > 0.0 and native_forward >= HIT_REACH_GATE * sheet_max and forward_px >= HIT_MIN_FORWARD_PX
|
||||
if not reaches:
|
||||
if _swing_had_hit_window or not _in_late_active_fallback():
|
||||
emitter.monitoring = false
|
||||
return
|
||||
# Fallback: no frame of this swing has reached yet and ACTIVE is half
|
||||
# over — guarantee the plain range window (old behaviour floor) so a
|
||||
# beat-anchored swing cannot whiff purely from art timing.
|
||||
forward_px = 0.0
|
||||
else:
|
||||
_swing_had_hit_window = true
|
||||
var reach := maxf(forward_px, float(action.get("range")))
|
||||
if reach <= HIT_START_OFFSET_PX:
|
||||
emitter.monitoring = false
|
||||
return
|
||||
var height := clampf(frame_rect.size.y * HIT_HEIGHT_FACTOR, 24.0, 140.0)
|
||||
var shape_node := emitter.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape is RectangleShape2D:
|
||||
var width := maxf(8.0, reach - HIT_START_OFFSET_PX)
|
||||
(shape_node.shape as RectangleShape2D).size = Vector2(width, height)
|
||||
shape_node.position = Vector2(heading_x * (HIT_START_OFFSET_PX + width * 0.5), frame_rect.get_center().y)
|
||||
emitter.monitoring = true
|
||||
|
||||
|
||||
func _actor_is_dead() -> bool:
|
||||
if actor == null:
|
||||
return false
|
||||
var state_machine := actor.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
var health := actor.get_node_or_null("HealthComponent")
|
||||
return health != null and int(health.get("current")) <= 0
|
||||
|
||||
|
||||
func _in_late_active_fallback() -> bool:
|
||||
var duration_value: Variant = controller.get("phase_duration")
|
||||
var elapsed_value: Variant = controller.get("phase_elapsed")
|
||||
if not (duration_value is float) or not (elapsed_value is float):
|
||||
return false
|
||||
var duration := duration_value as float
|
||||
return duration > 0.0 and (elapsed_value as float) >= duration * 0.5
|
||||
|
||||
|
||||
func _current_frame_actor_rect() -> Rect2:
|
||||
if sprite == null or sprite.texture == null or actor == null or not sprite.is_inside_tree():
|
||||
return Rect2()
|
||||
var hframes := maxi(1, sprite.hframes)
|
||||
var vframes := maxi(1, sprite.vframes)
|
||||
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
|
||||
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
return Rect2()
|
||||
var local_origin := _sprite_pixel_origin() + bounds.position
|
||||
var to_actor := actor.global_transform.affine_inverse() * sprite.global_transform
|
||||
var corner_a := to_actor * local_origin
|
||||
var corner_b := to_actor * (local_origin + bounds.size)
|
||||
return Rect2(
|
||||
Vector2(minf(corner_a.x, corner_b.x), minf(corner_a.y, corner_b.y)),
|
||||
(corner_b - corner_a).abs()
|
||||
)
|
||||
|
||||
|
||||
func _sprite_pixel_origin() -> Vector2:
|
||||
# Sprite-local coordinate of the sheet frame's top-left drawn pixel.
|
||||
if sprite.centered:
|
||||
return sprite.offset - _frame_size() * 0.5
|
||||
return sprite.offset
|
||||
|
||||
|
||||
func _actor_anchor_in_sheet_pixels() -> Vector2:
|
||||
# The actor origin expressed in drawn-pixel coordinates of the frame.
|
||||
var to_sprite := sprite.global_transform.affine_inverse() * actor.global_transform
|
||||
return (to_sprite * Vector2.ZERO) - _sprite_pixel_origin()
|
||||
|
||||
|
||||
func _frame_size() -> Vector2:
|
||||
return Vector2(
|
||||
float(sprite.texture.get_width()) / float(maxi(1, sprite.hframes)),
|
||||
float(sprite.texture.get_height()) / float(maxi(1, sprite.vframes))
|
||||
)
|
||||
|
||||
|
||||
func _make_shape_unique(area: Area2D) -> void:
|
||||
var shape_node := area.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node != null and shape_node.shape != null:
|
||||
shape_node.shape = shape_node.shape.duplicate()
|
||||
|
||||
|
||||
static func _frame_opaque_bounds(texture: Texture2D, hframes: int, vframes: int, frame: int) -> Rect2:
|
||||
var all_bounds := _sheet_frame_bounds(texture, hframes, vframes)
|
||||
if frame < 0 or frame >= all_bounds.size():
|
||||
return Rect2()
|
||||
return all_bounds[frame]
|
||||
|
||||
|
||||
static func _sheet_max_native_extent(texture: Texture2D, hframes: int, vframes: int, anchor_x: float, left_side: bool) -> float:
|
||||
var best := 0.0
|
||||
for bounds: Rect2 in _sheet_frame_bounds(texture, hframes, vframes):
|
||||
if bounds.size == Vector2.ZERO:
|
||||
continue
|
||||
var extent := (anchor_x - bounds.position.x) if left_side else (bounds.end.x - anchor_x)
|
||||
best = maxf(best, extent)
|
||||
return best
|
||||
|
||||
|
||||
static func _sheet_frame_bounds(texture: Texture2D, hframes: int, vframes: int) -> Array:
|
||||
# All frames of a sheet are measured once and cached. The per-frame crop +
|
||||
# used-rect run on the C++ side, so even large sheets cost well under a
|
||||
# millisecond and there is no first-swing hitch.
|
||||
var key := "%d#%d#%d" % [texture.get_rid().get_id(), hframes, vframes]
|
||||
if _sheet_bounds_cache.has(key):
|
||||
return _sheet_bounds_cache[key]
|
||||
var result: Array = []
|
||||
var image := texture.get_image()
|
||||
if image != null and not image.is_empty():
|
||||
if image.is_compressed():
|
||||
image.decompress()
|
||||
var frame_width := maxi(1, image.get_width() / hframes)
|
||||
var frame_height := maxi(1, image.get_height() / vframes)
|
||||
for index: int in range(hframes * vframes):
|
||||
var column := index % hframes
|
||||
var row := int(float(index) / float(hframes))
|
||||
var region := image.get_region(Rect2i(column * frame_width, row * frame_height, frame_width, frame_height))
|
||||
var used := region.get_used_rect()
|
||||
if used.size.x <= 0 or used.size.y <= 0:
|
||||
result.append(Rect2())
|
||||
else:
|
||||
result.append(Rect2(used))
|
||||
_sheet_bounds_cache[key] = result
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdl8602o30i87
|
||||
@@ -0,0 +1,87 @@
|
||||
class_name HealthComponent
|
||||
extends Node
|
||||
|
||||
signal health_changed(current: int, maximum: int)
|
||||
signal depleted
|
||||
|
||||
@export var maximum := 100
|
||||
@export var current := 100
|
||||
@export var hitstun_seconds := 0.4
|
||||
|
||||
var _hitstun_time_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _hitstun_time_left <= 0.0:
|
||||
return
|
||||
_hitstun_time_left = maxf(0.0, _hitstun_time_left - delta)
|
||||
if _hitstun_time_left <= 0.0 and current > 0:
|
||||
var state_machine := _state_machine_or_null()
|
||||
if state_machine != null and state_machine.has_method("set_life_state"):
|
||||
state_machine.call("set_life_state", &"Alive")
|
||||
|
||||
|
||||
func set_values(next_current: int, next_maximum: int) -> void:
|
||||
maximum = max(1, next_maximum)
|
||||
current = clampi(next_current, 0, maximum)
|
||||
if current > 0:
|
||||
_hitstun_time_left = 0.0
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func apply_damage(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
current = clampi(current - amount, 0, maximum)
|
||||
_emit_changed()
|
||||
if current == 0:
|
||||
depleted.emit()
|
||||
|
||||
|
||||
func receive_hit(result: Dictionary) -> void:
|
||||
var amount := int(result.get("damage", result.get("amount", 0)))
|
||||
apply_damage(amount)
|
||||
var state_machine := _state_machine_or_null()
|
||||
if state_machine == null or not state_machine.has_method("set_life_state"):
|
||||
return
|
||||
if current <= 0:
|
||||
_hitstun_time_left = 0.0
|
||||
state_machine.call("set_life_state", &"Dead")
|
||||
elif amount > 0 and bool(result.get("interrupts", true)):
|
||||
_hitstun_time_left = maxf(0.0, hitstun_seconds)
|
||||
state_machine.call("set_life_state", &"Hitstun")
|
||||
|
||||
|
||||
func heal(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
current = clampi(current + amount, 0, maximum)
|
||||
_emit_changed()
|
||||
|
||||
|
||||
func _emit_changed() -> void:
|
||||
health_changed.emit(current, maximum)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and _is_player_health_component():
|
||||
bus.emit_signal("player_health_changed", current, maximum)
|
||||
|
||||
|
||||
func _is_player_health_component() -> bool:
|
||||
var actor := get_parent()
|
||||
return actor != null and actor.name == "Player"
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
|
||||
|
||||
func _state_machine_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_node_or_null("../StateMachine")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk0nbsdn77rb4
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name InputComponent
|
||||
extends Node
|
||||
|
||||
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
|
||||
|
||||
signal intent_created(intent)
|
||||
signal combo_pressed(symbol: StringName, rhythm_action: StringName)
|
||||
signal combo_released(symbol: StringName)
|
||||
|
||||
const COMBO_ACTIONS: Dictionary = {
|
||||
&"combo_w": [&"W", &"w"],
|
||||
&"combo_a": [&"A", &"a"],
|
||||
&"combo_d": [&"D", &"d"],
|
||||
&"combo_s": [&"S", &"s"],
|
||||
&"combo_space": [&"SP", &"space"],
|
||||
}
|
||||
|
||||
const COMBO_ACTION_ORDER: Array[StringName] = [
|
||||
&"combo_w",
|
||||
&"combo_a",
|
||||
&"combo_d",
|
||||
&"combo_s",
|
||||
&"combo_space",
|
||||
]
|
||||
|
||||
|
||||
func handle_input_event(event: InputEvent) -> bool:
|
||||
var key_event := event as InputEventKey
|
||||
if key_event != null and key_event.echo:
|
||||
return false
|
||||
for action_name: StringName in COMBO_ACTION_ORDER:
|
||||
if event.is_action_pressed(action_name, false, true):
|
||||
var data: Array = COMBO_ACTIONS[action_name]
|
||||
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec()))
|
||||
intent_created.emit(intent)
|
||||
combo_pressed.emit(data[0], data[1])
|
||||
return true
|
||||
if event.is_action_released(action_name, true):
|
||||
var data: Array = COMBO_ACTIONS[action_name]
|
||||
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"released", float(Time.get_ticks_msec()))
|
||||
intent_created.emit(intent)
|
||||
combo_released.emit(data[0])
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxwomhlyicdep
|
||||
@@ -0,0 +1,32 @@
|
||||
class_name InputIntent
|
||||
extends RefCounted
|
||||
|
||||
var symbol: StringName
|
||||
var rhythm_action: StringName
|
||||
var event_type: StringName
|
||||
var timestamp_ms := 0.0
|
||||
var judgement: Dictionary = {}
|
||||
|
||||
|
||||
static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> RefCounted:
|
||||
var script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = script.new()
|
||||
intent.symbol = next_symbol
|
||||
intent.rhythm_action = next_rhythm_action
|
||||
intent.event_type = next_event_type
|
||||
intent.timestamp_ms = next_timestamp_ms
|
||||
return intent
|
||||
|
||||
|
||||
func is_pressed() -> bool:
|
||||
return event_type == &"pressed"
|
||||
|
||||
|
||||
func is_released() -> bool:
|
||||
return event_type == &"released"
|
||||
|
||||
|
||||
func with_judgement(next_judgement: Dictionary) -> RefCounted:
|
||||
var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms)
|
||||
copy.judgement = next_judgement.duplicate()
|
||||
return copy
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgrjwtje4wfni
|
||||
@@ -0,0 +1,156 @@
|
||||
class_name MotionExecutor
|
||||
extends Node
|
||||
|
||||
signal motion_started(action: Resource)
|
||||
signal motion_finished(action: Resource)
|
||||
|
||||
# player_body (layer 6) | enemy_body (layer 7): the pair that body-blocks.
|
||||
const BODY_GHOST_BITS := (1 << 5) | (1 << 6)
|
||||
# 幽灵态收尾嵌入时的每帧排斥步长(240px/s,快于小怪追击 120px/s,保证能拉开)。
|
||||
const OVERLAP_NUDGE_PER_FRAME := 4.0
|
||||
|
||||
@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D
|
||||
|
||||
var current_action: Resource
|
||||
var velocity := Vector2.ZERO
|
||||
var duration := 0.0
|
||||
var elapsed := 0.0
|
||||
var active := false
|
||||
var _saved_collision_mask := -1
|
||||
var _saved_collision_layer := -1
|
||||
var _restore_pending := false
|
||||
var _ghost_exit_sign := 1.0
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if _restore_pending:
|
||||
_try_restore_collision()
|
||||
|
||||
|
||||
func execute(action: Resource, direction: Vector2, beat_time: float, speed := 220.0) -> void:
|
||||
if active:
|
||||
cancel()
|
||||
current_action = action
|
||||
duration = maxf(0.01, float(action.get("action_beats")) * maxf(0.01, beat_time))
|
||||
elapsed = 0.0
|
||||
active = true
|
||||
# Vertical motion is owned by the fake-height system (height/height_speed);
|
||||
# feeding move_mult_y into the body velocity leaves the CharacterBody drifting off the ground plane.
|
||||
var move_x := float(action.get("move_mult_x"))
|
||||
var horizontal := direction.x if direction.x != 0.0 else signf(move_x)
|
||||
velocity = Vector2(signf(horizontal) * speed, 0.0) if horizontal != 0.0 else Vector2.ZERO
|
||||
if _has_action_tag(action, &"dash_through"):
|
||||
_begin_dash_through()
|
||||
motion_started.emit(action)
|
||||
|
||||
|
||||
func tick(delta: float) -> Vector2:
|
||||
if not active:
|
||||
return Vector2.ZERO
|
||||
elapsed += delta
|
||||
if elapsed >= duration:
|
||||
active = false
|
||||
velocity = Vector2.ZERO
|
||||
_request_restore_collision()
|
||||
motion_finished.emit(current_action)
|
||||
return velocity
|
||||
|
||||
|
||||
func cancel() -> void:
|
||||
active = false
|
||||
velocity = Vector2.ZERO
|
||||
_request_restore_collision()
|
||||
current_action = null
|
||||
|
||||
|
||||
func is_ghosting() -> bool:
|
||||
return _saved_collision_mask != -1
|
||||
|
||||
|
||||
func _has_action_tag(action: Resource, tag: StringName) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
var tags: Array = action.get("action_tags")
|
||||
for item: Variant in tags:
|
||||
if StringName(str(item)) == tag:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _begin_dash_through() -> void:
|
||||
if actor == null:
|
||||
actor = get_parent() as CharacterBody2D
|
||||
if actor == null:
|
||||
return
|
||||
# A new dash always cancels any deferred restore from the previous one, so
|
||||
# the ghost state cannot snap back mid-dash.
|
||||
_restore_pending = false
|
||||
# 记录冲刺方向:收尾嵌入时沿该方向推出(保留"从远侧穿出"的手感)。
|
||||
if velocity.x != 0.0:
|
||||
_ghost_exit_sign = signf(velocity.x)
|
||||
if _saved_collision_mask != -1:
|
||||
return
|
||||
# Ghost both directions: stop colliding with enemy/player bodies AND stop
|
||||
# being collidable by them, so neither side's move_and_slide recovery can
|
||||
# shove anyone while the dash overlaps a body.
|
||||
_saved_collision_mask = actor.collision_mask
|
||||
_saved_collision_layer = actor.collision_layer
|
||||
actor.collision_mask = actor.collision_mask & ~BODY_GHOST_BITS
|
||||
actor.collision_layer = actor.collision_layer & ~BODY_GHOST_BITS
|
||||
|
||||
|
||||
func _request_restore_collision() -> void:
|
||||
if _saved_collision_mask == -1:
|
||||
return
|
||||
# Never snap collision back while still inside another body: restoring
|
||||
# mid-overlap lets depenetration shove the actor back out the entry side,
|
||||
# which reads as "the dash did not pierce". Retry every physics frame.
|
||||
_restore_pending = true
|
||||
_try_restore_collision()
|
||||
|
||||
|
||||
func _try_restore_collision() -> void:
|
||||
if actor == null or _saved_collision_mask == -1:
|
||||
_restore_pending = false
|
||||
return
|
||||
if Engine.is_in_physics_frame() and _overlaps_ghosted_bodies():
|
||||
# 2026-07-06 策划:除冲刺穿人过程外不得与敌人重叠。嵌入时不再无限期
|
||||
# 干等分离,而是沿冲刺方向温和推出,推清后下一帧恢复碰撞。
|
||||
_nudge_out_of_overlap()
|
||||
return
|
||||
actor.collision_mask = _saved_collision_mask
|
||||
actor.collision_layer = _saved_collision_layer
|
||||
_saved_collision_mask = -1
|
||||
_saved_collision_layer = -1
|
||||
_restore_pending = false
|
||||
|
||||
|
||||
## 幽灵态结束但仍嵌在敌人体内:每物理帧沿冲刺方向推 4px(幽灵掩码只剩
|
||||
## world 位,绝不会被推进地形);顶到边界墙推不动时反向从入口侧退出。
|
||||
func _nudge_out_of_overlap() -> void:
|
||||
var motion := Vector2(_ghost_exit_sign * OVERLAP_NUDGE_PER_FRAME, 0.0)
|
||||
var collision := actor.move_and_collide(motion)
|
||||
if collision != null and collision.get_travel().length() < OVERLAP_NUDGE_PER_FRAME * 0.5:
|
||||
_ghost_exit_sign = -_ghost_exit_sign
|
||||
|
||||
|
||||
func _overlaps_ghosted_bodies() -> bool:
|
||||
if actor == null or not actor.is_inside_tree():
|
||||
return false
|
||||
var query_mask := _saved_collision_mask & BODY_GHOST_BITS
|
||||
if query_mask == 0:
|
||||
return false
|
||||
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
return false
|
||||
var params := PhysicsShapeQueryParameters2D.new()
|
||||
params.shape = shape_node.shape
|
||||
params.transform = shape_node.global_transform
|
||||
params.collision_mask = query_mask
|
||||
params.collide_with_bodies = true
|
||||
params.collide_with_areas = false
|
||||
params.exclude = [actor.get_rid()]
|
||||
var space := actor.get_world_2d().direct_space_state
|
||||
if space == null:
|
||||
return false
|
||||
return not space.intersect_shape(params, 1).is_empty()
|
||||
@@ -0,0 +1 @@
|
||||
uid://21xrm1ubabdn
|
||||
@@ -0,0 +1,237 @@
|
||||
class_name MovementMotor
|
||||
extends Node
|
||||
|
||||
const GRAVITY := 1200.0
|
||||
const KNOCKBACK_FRICTION := 480.0
|
||||
const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd")
|
||||
|
||||
@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D
|
||||
@onready var state_machine: Node = get_node_or_null("../StateMachine")
|
||||
@onready var effect_container: Node = get_node_or_null("../EffectContainer")
|
||||
|
||||
# 击退的水平残速还没衰减完;期间 set_heading 不得改写朝向(受击不转身)。
|
||||
var _knockback_stray_active := false
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
if actor == null or not can_move_freely():
|
||||
return
|
||||
# 自主移动接管 velocity.x,击退残速语义随之结束。
|
||||
_knockback_stray_active = false
|
||||
var direction := get_horizontal_axis()
|
||||
actor.velocity.x = direction * float(actor.get("speed")) * _move_speed_multiplier()
|
||||
if direction < 0.0:
|
||||
actor.set("heading", Vector2.LEFT)
|
||||
elif direction > 0.0:
|
||||
actor.set("heading", Vector2.RIGHT)
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
if actor == null:
|
||||
return
|
||||
_decay_stray_velocity(delta)
|
||||
if _presentation_state() != Character.PRESENTATION_JUMP and _ground_state() != &"Airborne":
|
||||
return
|
||||
var height := float(actor.get("height"))
|
||||
var height_speed := float(actor.get("height_speed"))
|
||||
height += height_speed * delta
|
||||
if height <= 0.0 and height_speed < 0.0:
|
||||
actor.set("height", 0.0)
|
||||
actor.set("height_speed", 0.0)
|
||||
actor.set("state", Character.PRESENTATION_LAND)
|
||||
actor.velocity.y = 0.0
|
||||
_set_ground_state(&"Grounded")
|
||||
_reset_air_actions()
|
||||
_dispatch_effect_event(&"on_landed")
|
||||
else:
|
||||
actor.set("height", height)
|
||||
actor.set("height_speed", height_speed - GRAVITY * delta)
|
||||
_set_ground_state(&"Airborne")
|
||||
|
||||
|
||||
func _decay_stray_velocity(delta: float) -> void:
|
||||
var state := _presentation_state()
|
||||
if state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
actor.velocity.x = move_toward(actor.velocity.x, 0.0, KNOCKBACK_FRICTION * delta)
|
||||
actor.velocity.y = 0.0
|
||||
if actor.velocity.x == 0.0:
|
||||
_knockback_stray_active = false
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
var state := _presentation_state()
|
||||
if state == Character.PRESENTATION_JUMP or state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
if _ground_state() == &"Airborne" or float(actor.get("height")) > 0.0 or not is_zero_approx(float(actor.get("height_speed"))):
|
||||
_set_ground_state(&"Airborne")
|
||||
return
|
||||
if state == Character.PRESENTATION_LAND:
|
||||
actor.set("state", Character.PRESENTATION_IDLE)
|
||||
elif absf(actor.velocity.x) > 0.0:
|
||||
actor.set("state", Character.PRESENTATION_WALK)
|
||||
else:
|
||||
actor.set("state", Character.PRESENTATION_IDLE)
|
||||
_set_ground_state(&"Grounded")
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
if actor == null:
|
||||
return
|
||||
# 受击不转身(2026-07-05 定案):Hitstun/击退残速期间保持受击前朝向。
|
||||
if _knockback_stray_active or _life_state() != &"Alive":
|
||||
return
|
||||
if actor.velocity.x > 0.0:
|
||||
actor.set("heading", Vector2.RIGHT)
|
||||
elif actor.velocity.x < 0.0:
|
||||
actor.set("heading", Vector2.LEFT)
|
||||
|
||||
|
||||
func start_jump() -> bool:
|
||||
if actor == null or not can_jump():
|
||||
return false
|
||||
actor.set("state", Character.PRESENTATION_JUMP)
|
||||
actor.set("height_speed", float(actor.get("jump_intensity")))
|
||||
_set_ground_state(&"Airborne")
|
||||
return true
|
||||
|
||||
|
||||
func can_jump() -> bool:
|
||||
if actor == null:
|
||||
return false
|
||||
var state := _presentation_state()
|
||||
return state == Character.PRESENTATION_IDLE or state == Character.PRESENTATION_WALK
|
||||
|
||||
|
||||
func can_move_freely() -> bool:
|
||||
if not _free_movement_inputs_bound():
|
||||
return false
|
||||
if _movement_blocked_by_effect():
|
||||
return false
|
||||
var context := _movement_context()
|
||||
if bool(ActionRuleResolverScript.can_move_freely(context)):
|
||||
return true
|
||||
return _charging_movement_allowed_by_effect()
|
||||
|
||||
|
||||
func get_horizontal_axis() -> float:
|
||||
var axis := 0.0
|
||||
if Input.is_action_pressed(&"move_left"):
|
||||
axis -= 1.0
|
||||
if Input.is_action_pressed(&"move_right"):
|
||||
axis += 1.0
|
||||
return axis
|
||||
|
||||
|
||||
func apply_knockback(knockback: Vector2) -> void:
|
||||
if actor == null:
|
||||
return
|
||||
actor.velocity.x = knockback.x
|
||||
_knockback_stray_active = knockback.x != 0.0
|
||||
if knockback.y > 0.0:
|
||||
actor.set("height", maxf(float(actor.get("height")), 0.1))
|
||||
actor.set("height_speed", knockback.y)
|
||||
_set_ground_state(&"Airborne")
|
||||
elif knockback.y < 0.0:
|
||||
actor.set("height_speed", knockback.y)
|
||||
else:
|
||||
actor.velocity.y = 0.0
|
||||
|
||||
|
||||
## 供覆写了 handle_movement 的宿主(Boss/小怪 AI)查询:击退残速未衰减完时
|
||||
## 不得清零 velocity.x,否则击退位移活不过一帧。
|
||||
func has_knockback_stray() -> bool:
|
||||
return _knockback_stray_active
|
||||
|
||||
|
||||
## 强制结束击退滑行语义(Boss 受击链强制撤退等"压倒击退"的路径专用)。
|
||||
func clear_knockback_stray() -> void:
|
||||
_knockback_stray_active = false
|
||||
|
||||
|
||||
func _ground_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded")))
|
||||
return &"Grounded"
|
||||
|
||||
|
||||
func _movement_context() -> Dictionary:
|
||||
var context := {
|
||||
"life_state": &"Alive",
|
||||
"action_phase": &"Neutral",
|
||||
"ground_state": &"Grounded",
|
||||
}
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
context = state_machine.call("build_context")
|
||||
context["effect_container"] = effect_container
|
||||
return context
|
||||
|
||||
|
||||
func _presentation_state() -> StringName:
|
||||
if actor == null:
|
||||
return Character.PRESENTATION_IDLE
|
||||
return StringName(str(actor.get("state")))
|
||||
|
||||
|
||||
func _action_phase() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("action_phase", &"Neutral")))
|
||||
return &"Neutral"
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _charging_movement_allowed_by_effect() -> bool:
|
||||
if _action_phase() != &"Charging":
|
||||
return false
|
||||
if effect_container == null or not effect_container.has_method("action_rule_modifiers"):
|
||||
return false
|
||||
for modifier: Resource in effect_container.call("action_rule_modifiers"):
|
||||
if bool(modifier.get("allow_movement_while_charging")):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _movement_blocked_by_effect() -> bool:
|
||||
if effect_container == null or not effect_container.has_method("action_rule_modifiers"):
|
||||
return false
|
||||
for modifier: Resource in effect_container.call("action_rule_modifiers"):
|
||||
if bool(modifier.get("block_movement")):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _move_speed_multiplier() -> float:
|
||||
if effect_container == null or not effect_container.has_method("stat_multiplier"):
|
||||
return 1.0
|
||||
return float(effect_container.call("stat_multiplier", &"move_speed", null))
|
||||
|
||||
|
||||
func _free_movement_inputs_bound() -> bool:
|
||||
for action_name: StringName in [&"move_left", &"move_right"]:
|
||||
if InputMap.has_action(action_name) and not InputMap.action_get_events(action_name).is_empty():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _set_ground_state(next_state: StringName) -> void:
|
||||
if state_machine != null and state_machine.has_method("set_ground_state"):
|
||||
state_machine.call("set_ground_state", next_state)
|
||||
|
||||
|
||||
func _reset_air_actions() -> void:
|
||||
if state_machine != null and "air_action_count" in state_machine:
|
||||
state_machine.set("air_action_count", 0)
|
||||
|
||||
|
||||
func _dispatch_effect_event(event_name: StringName) -> void:
|
||||
if effect_container == null:
|
||||
effect_container = get_node_or_null("../EffectContainer")
|
||||
if effect_container != null and effect_container.has_method("dispatch_event"):
|
||||
effect_container.call("dispatch_event", event_name, {"actor": actor})
|
||||
@@ -0,0 +1 @@
|
||||
uid://cc8bjx2eu05cf
|
||||
@@ -0,0 +1,96 @@
|
||||
class_name OverheadChargeSegments
|
||||
extends Node2D
|
||||
|
||||
@export var charge_component_path: NodePath = ^"../ChargeComponent"
|
||||
@export var segment_count := 3
|
||||
@export var segment_size := Vector2(42.0, 12.0)
|
||||
@export var segment_gap := 5.0
|
||||
@export var border_width := 2.0
|
||||
@export var background_color := Color(0.04, 0.055, 0.07, 0.86)
|
||||
@export var fill_color := Color(0.25, 0.88, 1.0, 0.96)
|
||||
@export var ready_fill_color := Color(1.0, 0.72, 0.18, 1.0)
|
||||
@export var border_color := Color(0.88, 0.96, 1.0, 0.9)
|
||||
@export var inactive_linger_seconds := 0.22
|
||||
|
||||
var _current := 0.0
|
||||
var _maximum := 1.0
|
||||
var _is_charge_ready := false
|
||||
var _active := false
|
||||
var _visual_linger_remaining := 0.0
|
||||
|
||||
@onready var _charge_component: Node = get_node_or_null(charge_component_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
segment_count = maxi(1, segment_count)
|
||||
z_index = max(z_index, 96)
|
||||
visible = false
|
||||
if _charge_component != null and _charge_component.has_signal("charge_changed"):
|
||||
if not _charge_component.is_connected("charge_changed", _on_charge_changed):
|
||||
_charge_component.connect("charge_changed", _on_charge_changed)
|
||||
|
||||
|
||||
func active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func current_progress_segments() -> float:
|
||||
if not _active and _visual_linger_remaining <= 0.0:
|
||||
return 0.0
|
||||
if _is_charge_ready:
|
||||
return float(segment_count)
|
||||
if _maximum <= float(segment_count - 1) + 0.1:
|
||||
return clampf(_current + 1.0, 0.0, float(segment_count))
|
||||
return clampf((_current / maxf(0.001, _maximum)) * float(segment_count), 0.0, float(segment_count))
|
||||
|
||||
|
||||
func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void:
|
||||
var was_showing := _active or _visual_linger_remaining > 0.0
|
||||
if active:
|
||||
_current = maxf(0.0, current)
|
||||
_maximum = maxf(0.001, maximum)
|
||||
_is_charge_ready = ready
|
||||
_visual_linger_remaining = 0.0
|
||||
modulate.a = 1.0
|
||||
visible = true
|
||||
else:
|
||||
if was_showing and inactive_linger_seconds > 0.0:
|
||||
_visual_linger_remaining = inactive_linger_seconds
|
||||
visible = true
|
||||
else:
|
||||
_visual_linger_remaining = 0.0
|
||||
modulate.a = 1.0
|
||||
visible = false
|
||||
_active = active
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _visual_linger_remaining <= 0.0:
|
||||
return
|
||||
_visual_linger_remaining = maxf(0.0, _visual_linger_remaining - delta)
|
||||
modulate.a = clampf(_visual_linger_remaining / maxf(0.001, inactive_linger_seconds), 0.0, 1.0)
|
||||
if _visual_linger_remaining <= 0.0:
|
||||
visible = false
|
||||
modulate.a = 1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _active and _visual_linger_remaining <= 0.0:
|
||||
return
|
||||
var safe_count := maxi(1, segment_count)
|
||||
var total_width := segment_size.x * float(safe_count) + segment_gap * float(maxi(0, safe_count - 1))
|
||||
var origin := Vector2(-total_width * 0.5, 0.0)
|
||||
var progress := current_progress_segments()
|
||||
var plate_rect := Rect2(origin - Vector2(5.0, 4.0), Vector2(total_width + 10.0, segment_size.y + 8.0))
|
||||
draw_rect(plate_rect, Color(0.0, 0.0, 0.0, 0.68), true)
|
||||
draw_rect(plate_rect, Color(0.9, 0.96, 1.0, 0.42), false, 1.0)
|
||||
for index: int in range(safe_count):
|
||||
var rect := Rect2(origin + Vector2(float(index) * (segment_size.x + segment_gap), 0.0), segment_size)
|
||||
draw_rect(rect, background_color, true)
|
||||
var fill_ratio := clampf(progress - float(index), 0.0, 1.0)
|
||||
if fill_ratio > 0.0:
|
||||
var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (segment_size.x - border_width * 2.0) * fill_ratio), maxf(0.0, segment_size.y - border_width * 2.0)))
|
||||
draw_rect(fill_rect, ready_fill_color if _is_charge_ready else fill_color, true)
|
||||
draw_rect(rect, border_color, false, border_width)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdkmtj1tgh5sf
|
||||
@@ -0,0 +1,48 @@
|
||||
class_name OverheadHealthBar
|
||||
extends Node2D
|
||||
|
||||
@export var health_component_path: NodePath = ^"../HealthComponent"
|
||||
@export var bar_size := Vector2(58.0, 7.0)
|
||||
@export var border_width := 1.0
|
||||
@export var background_color := Color(0.05, 0.04, 0.045, 0.82)
|
||||
@export var fill_color := Color(0.95, 0.16, 0.13, 0.95)
|
||||
@export var low_health_color := Color(1.0, 0.62, 0.15, 0.98)
|
||||
@export var border_color := Color(0.96, 0.9, 0.78, 0.88)
|
||||
@export var hide_when_depleted := true
|
||||
|
||||
var _current := 1
|
||||
var _maximum := 1
|
||||
|
||||
@onready var _health_component: Node = get_node_or_null(health_component_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = max(z_index, 30)
|
||||
if _health_component != null:
|
||||
if _health_component.has_signal("health_changed") and not _health_component.is_connected("health_changed", _on_health_changed):
|
||||
_health_component.connect("health_changed", _on_health_changed)
|
||||
_on_health_changed(int(_health_component.get("current")), int(_health_component.get("maximum")))
|
||||
else:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func health_ratio() -> float:
|
||||
return clampf(float(_current) / float(maxi(1, _maximum)), 0.0, 1.0)
|
||||
|
||||
|
||||
func _on_health_changed(current: int, maximum: int) -> void:
|
||||
_current = clampi(current, 0, maxi(1, maximum))
|
||||
_maximum = maxi(1, maximum)
|
||||
visible = _current > 0 or not hide_when_depleted
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if hide_when_depleted and _current <= 0:
|
||||
return
|
||||
var rect := Rect2(-bar_size * 0.5, bar_size)
|
||||
draw_rect(rect, background_color, true)
|
||||
var ratio := health_ratio()
|
||||
var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (bar_size.x - border_width * 2.0) * ratio), maxf(0.0, bar_size.y - border_width * 2.0)))
|
||||
draw_rect(fill_rect, low_health_color if ratio <= 0.32 else fill_color, true)
|
||||
draw_rect(rect, border_color, false, border_width)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c277u75ut0vwy
|
||||
@@ -0,0 +1,88 @@
|
||||
class_name StateMachine
|
||||
extends Node
|
||||
|
||||
signal axis_changed(axis: StringName, previous: StringName, current: StringName)
|
||||
|
||||
enum GroundState { GROUNDED, AIRBORNE }
|
||||
enum ActionPhaseState { NEUTRAL, STARTUP, ACTIVE, RECOVERY, CHARGING }
|
||||
enum LifeState { ALIVE, HITSTUN, DEAD }
|
||||
enum DefenseState { VULNERABLE, PARRYING, SUPER_ARMOR, INVINCIBLE }
|
||||
|
||||
const GROUND_STATE_NAMES: Array[StringName] = [&"Grounded", &"Airborne"]
|
||||
const ACTION_PHASE_NAMES: Array[StringName] = [&"Neutral", &"Startup", &"Active", &"Recovery", &"Charging"]
|
||||
const LIFE_STATE_NAMES: Array[StringName] = [&"Alive", &"Hitstun", &"Dead"]
|
||||
const DEFENSE_STATE_NAMES: Array[StringName] = [&"Vulnerable", &"Parrying", &"SuperArmor", &"Invincible"]
|
||||
|
||||
var ground_state := GroundState.GROUNDED
|
||||
var action_phase := ActionPhaseState.NEUTRAL
|
||||
var life_state := LifeState.ALIVE
|
||||
var defense_state := DefenseState.VULNERABLE
|
||||
var air_action_count := 0
|
||||
var max_air_action_count := 1
|
||||
|
||||
|
||||
func build_context() -> Dictionary:
|
||||
var tags: Array[StringName] = [
|
||||
get_ground_state(),
|
||||
get_action_phase(),
|
||||
get_defense_state(),
|
||||
get_life_state(),
|
||||
]
|
||||
return {
|
||||
"ground_state": get_ground_state(),
|
||||
"action_phase": get_action_phase(),
|
||||
"defense_state": get_defense_state(),
|
||||
"life_state": get_life_state(),
|
||||
"air_action_count": air_action_count,
|
||||
"max_air_action_count": max_air_action_count,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
func get_context() -> Dictionary:
|
||||
return build_context()
|
||||
|
||||
|
||||
func has_tag(tag: StringName) -> bool:
|
||||
return build_context()["tags"].has(tag)
|
||||
|
||||
|
||||
func get_ground_state() -> StringName:
|
||||
return GROUND_STATE_NAMES[ground_state]
|
||||
|
||||
|
||||
func get_action_phase() -> StringName:
|
||||
return ACTION_PHASE_NAMES[action_phase]
|
||||
|
||||
|
||||
func get_life_state() -> StringName:
|
||||
return LIFE_STATE_NAMES[life_state]
|
||||
|
||||
|
||||
func get_defense_state() -> StringName:
|
||||
return DEFENSE_STATE_NAMES[defense_state]
|
||||
|
||||
|
||||
func set_ground_state(next_state: StringName) -> void:
|
||||
ground_state = _set_axis(&"ground_state", GROUND_STATE_NAMES, ground_state, next_state)
|
||||
|
||||
|
||||
func set_action_phase(next_phase: StringName) -> void:
|
||||
action_phase = _set_axis(&"action_phase", ACTION_PHASE_NAMES, action_phase, next_phase)
|
||||
|
||||
|
||||
func set_life_state(next_state: StringName) -> void:
|
||||
life_state = _set_axis(&"life_state", LIFE_STATE_NAMES, life_state, next_state)
|
||||
|
||||
|
||||
func set_defense_state(next_state: StringName) -> void:
|
||||
defense_state = _set_axis(&"defense_state", DEFENSE_STATE_NAMES, defense_state, next_state)
|
||||
|
||||
|
||||
func _set_axis(axis: StringName, names: Array[StringName], current: int, next_name: StringName) -> int:
|
||||
var next_index := names.find(next_name)
|
||||
if next_index == -1 or next_index == current:
|
||||
return current
|
||||
var previous := names[current]
|
||||
axis_changed.emit(axis, previous, names[next_index])
|
||||
return next_index
|
||||
@@ -0,0 +1 @@
|
||||
uid://1cwt1fphdnkr
|
||||
@@ -0,0 +1,63 @@
|
||||
class_name StreakCounter
|
||||
extends Node
|
||||
|
||||
## Sole writer of the streak count (AnchorV1.0 "ComboCounter").
|
||||
## Pure fact subscriber: +1 on skill_executed, reset on miss / chart_reset.
|
||||
|
||||
var streak := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("skill_executed") and not bus.is_connected("skill_executed", _on_skill_executed):
|
||||
bus.connect("skill_executed", _on_skill_executed)
|
||||
if bus.has_signal("judgement_made") and not bus.is_connected("judgement_made", _on_judgement_made):
|
||||
bus.connect("judgement_made", _on_judgement_made)
|
||||
if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved):
|
||||
bus.connect("time_anchor_resolved", _on_time_anchor_resolved)
|
||||
if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset):
|
||||
bus.connect("chart_reset", _on_chart_reset)
|
||||
|
||||
|
||||
func _on_skill_executed(_skill: Resource, _judgement: StringName) -> void:
|
||||
streak += 1
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void:
|
||||
if quality != &"miss":
|
||||
return
|
||||
reset()
|
||||
|
||||
|
||||
func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void:
|
||||
if held:
|
||||
return
|
||||
reset()
|
||||
|
||||
|
||||
func _on_chart_reset(_chart_id: StringName) -> void:
|
||||
reset()
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
if streak == 0:
|
||||
return
|
||||
streak = 0
|
||||
_broadcast()
|
||||
|
||||
|
||||
func _broadcast() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("streak_changed"):
|
||||
bus.emit_signal("streak_changed", streak)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("skill_executed") and child.has_signal("streak_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
@@ -0,0 +1 @@
|
||||
uid://djvi3n0qxs2wl
|
||||
@@ -0,0 +1,89 @@
|
||||
class_name StrongBuffVisual
|
||||
extends Node2D
|
||||
|
||||
const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/strong_buff"
|
||||
|
||||
@export var frame_directory := DEFAULT_FRAME_DIRECTORY
|
||||
@export var frames_per_second := 20.0
|
||||
@export var center_offset := Vector2(0.0, -38.0)
|
||||
@export var effect_scale := 0.18
|
||||
|
||||
var _active := false
|
||||
var _frame_index := 0
|
||||
var _frame_elapsed := 0.0
|
||||
var _frames: Array[Texture2D] = []
|
||||
var _sprite: Sprite2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 1
|
||||
_sprite = Sprite2D.new()
|
||||
_sprite.name = "StrongBuffEffect"
|
||||
_sprite.centered = true
|
||||
_sprite.position = center_offset
|
||||
_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_sprite.visible = false
|
||||
add_child(_sprite)
|
||||
_load_frames()
|
||||
set_active(false)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _active or _frames.is_empty():
|
||||
return
|
||||
_frame_elapsed += delta
|
||||
var frame_duration := 1.0 / maxf(1.0, frames_per_second)
|
||||
while _frame_elapsed >= frame_duration:
|
||||
_frame_elapsed -= frame_duration
|
||||
_frame_index = (_frame_index + 1) % _frames.size()
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
|
||||
|
||||
func set_active(active: bool) -> void:
|
||||
_active = active and not _frames.is_empty()
|
||||
visible = _active
|
||||
set_process(_active)
|
||||
if _sprite != null:
|
||||
_sprite.visible = _active
|
||||
_sprite.position = center_offset
|
||||
_sprite.scale = Vector2.ONE * effect_scale
|
||||
if _active and _sprite.texture == null and not _frames.is_empty():
|
||||
_sprite.texture = _frames[_frame_index]
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func frame_count() -> int:
|
||||
return _frames.size()
|
||||
|
||||
|
||||
func current_effect_scale() -> float:
|
||||
return effect_scale
|
||||
|
||||
|
||||
func _load_frames() -> void:
|
||||
_frames.clear()
|
||||
var dir := DirAccess.open(frame_directory)
|
||||
if dir == null:
|
||||
return
|
||||
# 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap;
|
||||
# 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。
|
||||
var files := PackedStringArray()
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not dir.current_is_dir():
|
||||
var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap")
|
||||
if resource_name.ends_with(".png") and not files.has(resource_name):
|
||||
files.append(resource_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
files.sort()
|
||||
for path_name: String in files:
|
||||
var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D
|
||||
if texture != null:
|
||||
_frames.append(texture)
|
||||
if not _frames.is_empty() and _sprite != null:
|
||||
_sprite.texture = _frames[0]
|
||||
@@ -0,0 +1 @@
|
||||
uid://hr56iy0nnfao
|
||||
@@ -0,0 +1,86 @@
|
||||
class_name TimePhaseAdapter
|
||||
extends Node
|
||||
|
||||
## Swaps this actor's time-phase Effects when the world phase changes.
|
||||
## Only ever talks to EffectContainer.add_effect / remove_effect; never
|
||||
## touches stats, state axes or animations directly. Actors without a
|
||||
## profile are simply unaffected by phase switches.
|
||||
|
||||
@export var profile: Resource
|
||||
@export var effect_container_path := NodePath("../EffectContainer")
|
||||
|
||||
@onready var effect_container: Node = get_node_or_null(effect_container_path)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for bus: Node in _event_buses():
|
||||
if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
apply_time_phase(_current_time_phase())
|
||||
|
||||
|
||||
func apply_time_phase(time_phase: StringName) -> void:
|
||||
if profile == null or effect_container == null:
|
||||
return
|
||||
for definition: Resource in _profile_effects(&"past") + _profile_effects(&"future"):
|
||||
if definition != null and effect_container.has_method("remove_effect"):
|
||||
effect_container.call("remove_effect", StringName(str(definition.get("id"))))
|
||||
for definition: Resource in _profile_effects(time_phase):
|
||||
if definition != null and effect_container.has_method("add_effect"):
|
||||
effect_container.call("add_effect", definition, &"time_phase")
|
||||
|
||||
|
||||
func visual_key() -> StringName:
|
||||
if profile != null and profile.has_method("visual_key_for_time_phase"):
|
||||
return profile.call("visual_key_for_time_phase", _current_time_phase())
|
||||
return _current_time_phase()
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
apply_time_phase(current)
|
||||
|
||||
|
||||
func _profile_effects(time_phase: StringName) -> Array[Resource]:
|
||||
var result: Array[Resource] = []
|
||||
if profile == null:
|
||||
return result
|
||||
var effects = profile.call("effects_for_time_phase", time_phase) if profile.has_method("effects_for_time_phase") else null
|
||||
if effects is Array:
|
||||
for definition: Variant in effects:
|
||||
if definition is Resource:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := _time_phase_manager_or_null()
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _time_phase_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return _last_root_child_matching(func(child: Node) -> bool:
|
||||
return child.has_method("set_time_phase") and child.get("current_time_phase") != null
|
||||
)
|
||||
|
||||
|
||||
func _event_buses() -> Array[Node]:
|
||||
var buses: Array[Node] = []
|
||||
if not is_inside_tree():
|
||||
return buses
|
||||
for child: Node in get_tree().root.get_children():
|
||||
if child.has_signal("time_phase_changed"):
|
||||
buses.append(child)
|
||||
return buses
|
||||
|
||||
|
||||
func _last_root_child_matching(predicate: Callable) -> Node:
|
||||
var children := get_tree().root.get_children()
|
||||
for index: int in range(children.size() - 1, -1, -1):
|
||||
var child: Node = children[index]
|
||||
if bool(predicate.call(child)):
|
||||
return child
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://byxap76rqsb4j
|
||||
@@ -0,0 +1,607 @@
|
||||
class_name Boss
|
||||
extends Character
|
||||
|
||||
const DEFAULT_HEADING := Vector2.LEFT
|
||||
const VISUAL_SCALE := 2.0
|
||||
const DEFAULT_ANIMATION_FPS := 12.0
|
||||
|
||||
const BOSS_ANIMATION_SPECS := {
|
||||
&"boss_idle": {"path": "res://assets/art/characters/boss/boss_idle.png", "frames": 1, "fps": 4.0, "loop": true},
|
||||
&"boss_run": {"path": "res://assets/art/characters/boss/boss_run.png", "frames": 8, "fps": 12.0, "loop": true},
|
||||
&"boss_dash": {"path": "res://assets/art/characters/boss/boss_dash.png", "frames": 6, "fps": 14.0, "loop": false},
|
||||
&"boss_jump_fall": {"path": "res://assets/art/characters/boss/boss_jump_fall.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_take_hit": {"path": "res://assets/art/characters/boss/boss_take_hit.png", "frames": 1, "fps": 8.0, "loop": false},
|
||||
&"boss_die": {"path": "res://assets/art/characters/boss/boss_die.png", "frames": 4, "fps": 8.0, "loop": false},
|
||||
&"boss_ground_combo_1": {"path": "res://assets/art/characters/boss/boss_ground_combo_1.png", "frames": 8, "fps": 14.0, "loop": false},
|
||||
&"boss_ground_combo_2": {"path": "res://assets/art/characters/boss/boss_ground_combo_2.png", "frames": 10, "fps": 14.0, "loop": false},
|
||||
&"boss_ground_combo_3": {"path": "res://assets/art/characters/boss/boss_ground_combo_3.png", "frames": 14, "fps": 14.0, "loop": false},
|
||||
&"boss_lunging_stab": {"path": "res://assets/art/characters/boss/boss_lunging_stab.png", "frames": 15, "fps": 15.0, "loop": false},
|
||||
&"boss_shoot_1": {"path": "res://assets/art/characters/boss/boss_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_shoot_2": {"path": "res://assets/art/characters/boss/boss_shoot_2.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_2way_shoot": {"path": "res://assets/art/characters/boss/boss_2way_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
}
|
||||
|
||||
@export var max_health := 28000
|
||||
@export var current_health := 28000
|
||||
## Training-dummy mode: the boss still attacks (BT + chart events) but never
|
||||
## displaces — no walk/dash/retreat/lunge motion and no hit-chain escape.
|
||||
@export var stationary := false
|
||||
@export var combat_enabled := true:
|
||||
set(value):
|
||||
combat_enabled = value
|
||||
_sync_combat_collision()
|
||||
@export var visual_ground_offset := -40.0
|
||||
@export var boss_lunge_speed := 150.0
|
||||
@export var projectile_spawn_forward := 70.0
|
||||
@export var projectile_spawn_height := 74.0
|
||||
@export var escape_after_consecutive_hits := 4
|
||||
@export var hit_chain_window_seconds := 1.2
|
||||
@export var escape_cooldown_seconds := 4.0
|
||||
|
||||
@onready var state_machine: Node = get_node_or_null("StateMachine")
|
||||
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
|
||||
@onready var action_controller: Node = get_node_or_null("ActionController")
|
||||
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
|
||||
@onready var health_component: Node = get_node_or_null("HealthComponent")
|
||||
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
|
||||
@onready var frame_collision_driver: Node = get_node_or_null("FrameCollisionDriver")
|
||||
|
||||
var _current_action_animation: StringName = &""
|
||||
var _frame_delta := 0.0
|
||||
var _dead_state_entered := false
|
||||
var _death_animation_started := false
|
||||
var _hit_chain_count := 0
|
||||
var _hit_chain_time_left := 0.0
|
||||
var _escape_cooldown_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
heading = DEFAULT_HEADING
|
||||
anim_map = {
|
||||
PRESENTATION_IDLE: "boss_idle",
|
||||
PRESENTATION_WALK: "boss_run",
|
||||
PRESENTATION_JUMP: "boss_jump_fall",
|
||||
PRESENTATION_LAND: "boss_idle",
|
||||
PRESENTATION_ATTACK: "boss_ground_combo_1",
|
||||
PRESENTATION_AIR_ATTACK: "boss_ground_combo_1",
|
||||
}
|
||||
_install_boss_animations()
|
||||
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
|
||||
_wire_action_controller()
|
||||
_wire_damage_receiver()
|
||||
if health_component != null and health_component.has_method("set_values"):
|
||||
health_component.call("set_values", current_health, max_health)
|
||||
_sync_combat_collision()
|
||||
state = PRESENTATION_IDLE
|
||||
_play_animation(&"boss_idle")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_frame_delta = delta
|
||||
_tick_hit_chain(delta)
|
||||
super._physics_process(delta)
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## 2026-07-05 定案:Boss 对不消耗能量的攻击恒霸体——伤害照常结算,但不被
|
||||
## 打断、不被击退;只有耗能技能(base_cost > 0)能压制 Boss。
|
||||
func shrugs_off_hit(action: Resource) -> bool:
|
||||
if action == null:
|
||||
return true
|
||||
return float(action.get("base_cost")) <= 0.0
|
||||
|
||||
|
||||
## 2026-07-05 策划两轮调参:×3 后仍嫌短,再 ×3 → 距离 ×9(初速 ×3)。
|
||||
## 只放大水平滑行,击飞高度不变;换算由 CombatManager 负责(距离 ∝ 初速²)。
|
||||
func knockback_distance_taken_mult() -> float:
|
||||
return 9.0
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
if movement_motor != null and movement_motor.has_method("handle_air_time"):
|
||||
movement_motor.call("handle_air_time", delta)
|
||||
else:
|
||||
super.handle_air_time(delta)
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if motion_executor != null and bool(motion_executor.get("active")):
|
||||
velocity = motion_executor.call("tick", _frame_delta)
|
||||
return
|
||||
if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
# 击退残速期不清零,交给 MovementMotor 的摩擦衰减自然滑行——否则击退
|
||||
# 初速活不过一帧,Boss 的水平击退位移恒为 0。
|
||||
if not (movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray"))):
|
||||
velocity.x = 0.0
|
||||
if movement_motor != null and movement_motor.has_method("handle_movement"):
|
||||
movement_motor.call("handle_movement")
|
||||
else:
|
||||
super.handle_movement()
|
||||
|
||||
|
||||
func handle_animations() -> void:
|
||||
var life_state := _life_state()
|
||||
if life_state == &"Dead":
|
||||
_enter_dead_state()
|
||||
_play_death_animation_once()
|
||||
_queue_free_if_death_animation_finished()
|
||||
return
|
||||
_dead_state_entered = false
|
||||
_death_animation_started = false
|
||||
if life_state == &"Hitstun":
|
||||
_play_animation(&"boss_take_hit")
|
||||
return
|
||||
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
|
||||
_play_animation(_current_action_animation)
|
||||
return
|
||||
super.handle_animations()
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_sprite_height_position() -> void:
|
||||
# Keep the editor-authored ground offset at runtime (WYSIWYG): without this
|
||||
# override the Character base resets Visual to (0, -height) every physics
|
||||
# tick and the boss art jumps 32px above its authored position.
|
||||
if visual != null:
|
||||
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
|
||||
|
||||
|
||||
func flip_sprites() -> void:
|
||||
if visual == null:
|
||||
return
|
||||
visual.scale.x = -VISUAL_SCALE if heading == Vector2.LEFT else VISUAL_SCALE
|
||||
visual.scale.y = VISUAL_SCALE
|
||||
|
||||
|
||||
func look_at_target(target: Node2D) -> void:
|
||||
if target == null:
|
||||
return
|
||||
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
|
||||
|
||||
|
||||
func projectile_spawn_position(_action: Resource) -> Vector2:
|
||||
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -projectile_spawn_height)
|
||||
|
||||
|
||||
func projectile_direction(_action: Resource) -> Vector2:
|
||||
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
|
||||
|
||||
|
||||
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
|
||||
if action != null and StringName(str(action.get("id"))) == &"boss_2way_shoot":
|
||||
return [
|
||||
{
|
||||
"spawn_position": global_position + Vector2(-projectile_spawn_forward, -projectile_spawn_height),
|
||||
"direction": Vector2.LEFT,
|
||||
},
|
||||
{
|
||||
"spawn_position": global_position + Vector2(projectile_spawn_forward, -projectile_spawn_height),
|
||||
"direction": Vector2.RIGHT,
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
"spawn_position": projectile_spawn_position(action),
|
||||
"direction": projectile_direction(action),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func is_strong_in_current_time_phase() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func can_start_enemy_action(action_id: StringName) -> bool:
|
||||
if not combat_enabled:
|
||||
return false
|
||||
# 击退滑行(趔趄)期间不得起手新动作:动作自带的突进位移会立刻接管
|
||||
# velocity 并冲回玩家,把击退效果整个吃掉(实测净位移为负)。
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
return false
|
||||
var behavior := get_node_or_null("BossBehaviorTree")
|
||||
if behavior != null and behavior.has_method("phase_allows_action"):
|
||||
return bool(behavior.call("phase_allows_action", action_id))
|
||||
return true
|
||||
|
||||
|
||||
func _sync_combat_collision() -> void:
|
||||
var driver := frame_collision_driver
|
||||
if driver == null:
|
||||
driver = get_node_or_null("FrameCollisionDriver")
|
||||
if driver != null:
|
||||
driver.set("body_collision_enabled", combat_enabled)
|
||||
driver.set("damage_receiver_enabled", combat_enabled)
|
||||
driver.set("damage_emitter_enabled", combat_enabled)
|
||||
if driver.has_method("refresh_now"):
|
||||
driver.call("refresh_now")
|
||||
if combat_enabled:
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
if tree.has_method("reset_for_combat_entry"):
|
||||
tree.call("reset_for_combat_entry")
|
||||
else:
|
||||
tree.set("enabled", true)
|
||||
|
||||
|
||||
func _install_boss_animations() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
var library: AnimationLibrary
|
||||
if animation_player.has_animation_library(""):
|
||||
library = animation_player.get_animation_library("")
|
||||
else:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
var base_sprite_offset := character_sprite.offset if character_sprite != null else Vector2(-40.0, -47.0)
|
||||
var idle_anchor := _visible_bottom_anchor_for_spec(BOSS_ANIMATION_SPECS.get(&"boss_idle", {}), base_sprite_offset)
|
||||
for animation_name: StringName in BOSS_ANIMATION_SPECS:
|
||||
if library.has_animation(animation_name):
|
||||
continue
|
||||
var spec: Dictionary = BOSS_ANIMATION_SPECS[animation_name]
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
continue
|
||||
var align_anchor := idle_anchor if animation_name == &"boss_die" else INF
|
||||
library.add_animation(animation_name, _make_sheet_animation(texture, int(spec.get("frames", 1)), float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), align_anchor, base_sprite_offset))
|
||||
|
||||
|
||||
func _make_sheet_animation(texture: Texture2D, frame_count: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
|
||||
var safe_frame_count := maxi(1, frame_count)
|
||||
var safe_fps := maxf(1.0, fps)
|
||||
var frame_time := 1.0 / safe_fps
|
||||
var animation := Animation.new()
|
||||
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
|
||||
animation.step = frame_time
|
||||
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
|
||||
var texture_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
|
||||
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(texture_track, 0.0, texture)
|
||||
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
|
||||
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(hframes_track, 0.0, safe_frame_count)
|
||||
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
|
||||
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(vframes_track, 0.0, 1)
|
||||
var frame_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
|
||||
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
|
||||
if visible_bottom_anchor < INF:
|
||||
var offset_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
|
||||
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
var offset := sprite_offset
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, safe_frame_count, 1, frame_index)
|
||||
if bottom != INF:
|
||||
offset.y = visible_bottom_anchor - bottom
|
||||
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
|
||||
var fx_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(fx_track, NodePath("Visual/FxOverlay:visible"))
|
||||
animation.value_track_set_update_mode(fx_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(fx_track, 0.0, false)
|
||||
return animation
|
||||
|
||||
|
||||
func _wire_action_controller() -> void:
|
||||
if action_controller == null:
|
||||
return
|
||||
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
|
||||
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
|
||||
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
|
||||
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
|
||||
|
||||
|
||||
func _wire_damage_receiver() -> void:
|
||||
if damage_receiver == null:
|
||||
return
|
||||
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
|
||||
|
||||
|
||||
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||
if not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_face_target_if_available()
|
||||
_current_action_animation = StringName(str(action.get("animation")))
|
||||
state = PRESENTATION_ATTACK
|
||||
attack_time_left = _action_duration_seconds(action) + 0.05
|
||||
attack_lunge_time_left = attack_time_left
|
||||
_align_action_animation_speed(action)
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
_play_animation(_current_action_animation)
|
||||
|
||||
|
||||
func _on_action_active_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
if stationary:
|
||||
return
|
||||
if motion_executor == null:
|
||||
return
|
||||
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
|
||||
return
|
||||
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
|
||||
motion_executor.call("execute", action, _motion_direction_for_action(action), _beat_time(), boss_lunge_speed * speed_scale)
|
||||
|
||||
|
||||
func _on_action_finished(_action: Resource) -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
|
||||
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
|
||||
func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if amount <= 0 or _life_state() == &"Dead":
|
||||
return
|
||||
# 2026-07-05 定案:霸体下的普攻不打断 Boss,但照样计入受击链——
|
||||
# "被连打 4 次后撤逃脱"仍是防贴脸无脑磨血的兜底。
|
||||
if _record_consecutive_hit_and_should_escape() and _can_escape_now():
|
||||
if _trigger_hit_chain_escape():
|
||||
return
|
||||
if _life_state() == &"Hitstun":
|
||||
_play_animation(&"boss_take_hit")
|
||||
|
||||
|
||||
func _action_duration_seconds(action: Resource) -> float:
|
||||
if action == null:
|
||||
return attack_duration
|
||||
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
|
||||
|
||||
|
||||
func _align_action_animation_speed(action: Resource) -> void:
|
||||
if animation_player == null or action == null or _current_action_animation.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(_current_action_animation):
|
||||
return
|
||||
var natural_length := animation_player.get_animation(_current_action_animation).length
|
||||
var action_seconds := _action_duration_seconds(action)
|
||||
if natural_length <= 0.0 or action_seconds <= 0.0:
|
||||
return
|
||||
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
|
||||
|
||||
|
||||
func _reset_animation_speed() -> void:
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
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 _face_target_if_available() -> void:
|
||||
var target := _target_or_null()
|
||||
if target != null:
|
||||
look_at_target(target)
|
||||
|
||||
|
||||
func _target_or_null() -> Node2D:
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
var tree_target = tree.get("target")
|
||||
if tree_target is Node2D and is_instance_valid(tree_target):
|
||||
return tree_target
|
||||
var container := get_parent()
|
||||
if container != null:
|
||||
var sibling := container.get_node_or_null("Player") as Node2D
|
||||
if sibling != null:
|
||||
return sibling
|
||||
return null
|
||||
|
||||
|
||||
func _tick_hit_chain(delta: float) -> void:
|
||||
_escape_cooldown_left = maxf(0.0, _escape_cooldown_left - delta)
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
return
|
||||
_hit_chain_time_left = maxf(0.0, _hit_chain_time_left - delta)
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
_hit_chain_count = 0
|
||||
|
||||
|
||||
func _can_escape_now() -> bool:
|
||||
if stationary:
|
||||
return false
|
||||
if _escape_cooldown_left > 0.0:
|
||||
return false
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
if StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded"))) != &"Grounded":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _record_consecutive_hit_and_should_escape() -> bool:
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_count += 1
|
||||
_hit_chain_time_left = maxf(0.01, hit_chain_window_seconds)
|
||||
return _hit_chain_count >= maxi(1, escape_after_consecutive_hits)
|
||||
|
||||
|
||||
func _trigger_hit_chain_escape() -> bool:
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_time_left = 0.0
|
||||
_escape_cooldown_left = maxf(0.0, escape_cooldown_seconds)
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
# 强制撤退压倒击退滑行:清掉残速标志,否则 can_start_enemy_action 的
|
||||
# 趔趄门禁会拦下 boss_retreat_dash,受击链逃脱失效。
|
||||
if movement_motor != null and movement_motor.has_method("clear_knockback_stray"):
|
||||
movement_motor.call("clear_knockback_stray")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if action_controller != null:
|
||||
if action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"hit_escape")
|
||||
elif action_controller.has_method("_reset_to_idle"):
|
||||
action_controller.call("_reset_to_idle")
|
||||
if state_machine != null:
|
||||
if state_machine.has_method("set_life_state"):
|
||||
state_machine.call("set_life_state", &"Alive")
|
||||
if state_machine.has_method("set_ground_state"):
|
||||
state_machine.call("set_ground_state", &"Grounded")
|
||||
if state_machine.has_method("set_action_phase"):
|
||||
state_machine.call("set_action_phase", &"Neutral")
|
||||
_face_target_if_available()
|
||||
var driver := get_node_or_null("EnemyActionDriver")
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", &"boss_retreat_dash")
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _heading_sign() -> float:
|
||||
return -1.0 if heading.x < 0.0 else 1.0
|
||||
|
||||
|
||||
func _enter_dead_state() -> void:
|
||||
if _dead_state_entered:
|
||||
return
|
||||
_dead_state_entered = true
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_time_left = 0.0
|
||||
_reset_animation_speed()
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"death")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
var damage_emitter := get_node_or_null("DamageEmitter")
|
||||
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
|
||||
damage_emitter.call("clear_hit")
|
||||
var receiver := get_node_or_null("DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
tree.set("enabled", false)
|
||||
|
||||
|
||||
func _play_death_animation_once() -> void:
|
||||
if _death_animation_started:
|
||||
return
|
||||
_death_animation_started = true
|
||||
if animation_player != null and animation_player.has_animation(&"boss_die"):
|
||||
animation_player.play(&"boss_die")
|
||||
|
||||
|
||||
func _on_animation_finished(animation_name: StringName) -> void:
|
||||
if _death_animation_started and animation_name == &"boss_die":
|
||||
queue_free()
|
||||
|
||||
|
||||
func _queue_free_if_death_animation_finished() -> void:
|
||||
if not _death_animation_started or is_queued_for_deletion():
|
||||
return
|
||||
if animation_player == null:
|
||||
queue_free()
|
||||
return
|
||||
if not animation_player.is_playing():
|
||||
queue_free()
|
||||
|
||||
|
||||
func _visible_bottom_anchor_for_spec(spec: Dictionary, sprite_offset: Vector2) -> float:
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
return INF
|
||||
var frame_count := int(spec.get("frames", 1))
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, frame_count), 1, 0)
|
||||
if bottom == INF:
|
||||
return INF
|
||||
return sprite_offset.y + bottom
|
||||
|
||||
|
||||
func _visible_frame_bottom_in_texture(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 _motion_direction_for_action(action: Resource) -> Vector2:
|
||||
if _has_action_tag(action, &"retreat"):
|
||||
return -heading
|
||||
return heading
|
||||
|
||||
|
||||
func _has_action_tag(action: Resource, tag: StringName) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
var tags: Array = action.get("action_tags")
|
||||
for item: Variant in tags:
|
||||
if StringName(str(item)) == tag:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _play_animation(animation_name: StringName) -> void:
|
||||
if animation_player == null or animation_name.is_empty():
|
||||
return
|
||||
if animation_player.has_animation(animation_name) and animation_player.current_animation != String(animation_name):
|
||||
animation_player.play(animation_name)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ijm7vma4w324
|
||||
@@ -0,0 +1,124 @@
|
||||
[gd_scene format=3 uid="uid://bnoju71qm1xh7"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/enemies/boss.gd" id="1_boss"]
|
||||
[ext_resource type="Texture2D" uid="uid://b2x7g4eslsmbd" path="res://assets/art/characters/boss/boss_idle.png" id="2_idle"]
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="3_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="4_movement_motor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="5_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="6_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="7_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="8_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="9_motion_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="10_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="11_health_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="12_damage_receiver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="13_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="14_enemy_action_driver"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/boss_behavior_tree.gd" id="15_boss_behavior_tree"]
|
||||
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(24, 58)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
|
||||
size = Vector2(28, 62)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(80, 42)
|
||||
|
||||
[node name="Boss" type="CharacterBody2D" unique_id=11104068 groups=["bosses"]]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
script = ExtResource("1_boss")
|
||||
speed = 120.0
|
||||
attack_lunge_speed = 150.0
|
||||
|
||||
[node name="Visual" type="Node2D" parent="." unique_id=1328075337]
|
||||
scale = Vector2(2, 2)
|
||||
|
||||
[node name="CharacterSprite" type="Sprite2D" parent="Visual" unique_id=1061052547]
|
||||
texture_filter = 2
|
||||
texture = ExtResource("2_idle")
|
||||
centered = false
|
||||
offset = Vector2(-40, -47)
|
||||
|
||||
[node name="FxOverlay" type="Sprite2D" parent="Visual" unique_id=1946927846]
|
||||
visible = false
|
||||
texture_filter = 2
|
||||
z_index = 2
|
||||
centered = false
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1147475571]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1741182662]
|
||||
|
||||
[node name="StateMachine" type="Node" parent="." unique_id=1244013726]
|
||||
script = ExtResource("3_state_machine")
|
||||
|
||||
[node name="MovementMotor" type="Node" parent="." unique_id=1623369341]
|
||||
script = ExtResource("4_movement_motor")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="." unique_id=689578719]
|
||||
script = ExtResource("5_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="." unique_id=275892237]
|
||||
script = ExtResource("6_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="." unique_id=1902183099]
|
||||
script = ExtResource("7_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="." unique_id=163263912]
|
||||
script = ExtResource("8_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
|
||||
[node name="MotionExecutor" type="Node" parent="." unique_id=1554747471]
|
||||
script = ExtResource("9_motion_executor")
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="." unique_id=214753755]
|
||||
script = ExtResource("10_effect_container")
|
||||
|
||||
[node name="HealthComponent" type="Node" parent="." unique_id=363838105]
|
||||
script = ExtResource("11_health_component")
|
||||
maximum = 28000
|
||||
current = 28000
|
||||
|
||||
[node name="DamageReceiver" type="Area2D" parent="." unique_id=1741852259]
|
||||
collision_layer = 4
|
||||
collision_mask = 8
|
||||
script = ExtResource("12_damage_receiver")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver" unique_id=459196080]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_hurtbox")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="." unique_id=1856891186]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("13_damage_emitter")
|
||||
damage = 30
|
||||
base_knockback = Vector2(360, 304.056)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter" unique_id=2047760774]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="." unique_id=1172727978]
|
||||
script = ExtResource("14_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
|
||||
[node name="BossBehaviorTree" type="Node" parent="." unique_id=1218241436]
|
||||
script = ExtResource("15_boss_behavior_tree")
|
||||
target_path = NodePath("../../Player")
|
||||
|
||||
[node name="FrameCollisionDriver" type="Node" parent="."]
|
||||
script = ExtResource("18_frame_collision")
|
||||
@@ -0,0 +1,408 @@
|
||||
class_name BossBehaviorTree
|
||||
extends Node
|
||||
|
||||
@export var enabled := true
|
||||
@export var target_path: NodePath
|
||||
@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver")
|
||||
@export var action_controller_path: NodePath = NodePath("../ActionController")
|
||||
@export var health_component_path: NodePath = NodePath("../HealthComponent")
|
||||
## new3 §6.2:近战相位攻击距离 180。
|
||||
@export var melee_distance := 180.0
|
||||
@export var ranged_distance := 360.0
|
||||
@export var dash_distance := 460.0
|
||||
## new3 §6.7:远程相位下玩家进入该距离触发后撤。
|
||||
@export var retreat_trigger_distance := 240.0
|
||||
@export var decision_interval_beats := 2.0
|
||||
## new3 §6.4:相位切换后短暂停顿(跳过的决策拍数,1 拍 ≈ 0.46s)。
|
||||
@export var phase_switch_pause_beats := 1
|
||||
## §18.1 Boss 相位差异:Past 出招慢(重击),Future 出招快(轻击、频率高)。
|
||||
## 只缩放决策节拍,不改 HP/防御等核心数值。
|
||||
@export var past_decision_interval_scale := 1.0
|
||||
@export var future_decision_interval_scale := 0.5
|
||||
## §18.2: preparing a special attack raises a conditional TimeAnchorEvent.
|
||||
@export var special_attack_anchor_lead_beats := 2
|
||||
@export var conditional_anchor_actions: Array[StringName] = [&"boss_lunging_stab", &"boss_2way_shoot"]
|
||||
@export var melee_combo_cooldown_beats := 4.0
|
||||
@export var retreat_cooldown_beats := 8.0
|
||||
@export var stab_cooldown_beats := 16.0
|
||||
@export var two_way_cooldown_beats := 8.0
|
||||
@export var dash_cooldown_beats := 4.0
|
||||
@export var low_health_ratio := 0.35
|
||||
@export var restrict_actions_by_time_phase := true
|
||||
|
||||
@onready var driver: Node = get_node_or_null(enemy_action_driver_path)
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var target: Node2D = get_node_or_null(target_path) as Node2D
|
||||
|
||||
var _beat_cursor := 0.0
|
||||
var _next_decision_beat := 0
|
||||
var _last_seen_beat := 0
|
||||
var _combo_index := 0
|
||||
var _ranged_index := 0
|
||||
var _force_ranged_after_retreat := false
|
||||
var _last_melee_beat := -999.0
|
||||
var _last_retreat_beat := -999.0
|
||||
var _last_stab_beat := -999.0
|
||||
var _last_two_way_beat := -999.0
|
||||
var _last_dash_beat := -999.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_connect_beat_bus()
|
||||
_connect_chart_bus()
|
||||
_connect_time_phase_bus()
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
func _connect_time_phase_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
|
||||
|
||||
## new3 §6.4:相位切换时不立刻攻击——先停顿、调整距离,再进入新相位循环。
|
||||
## 以总线上最后看到的拍号为基准(而非全局时钟),测试的合成拍号同样适用。
|
||||
func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void:
|
||||
_next_decision_beat = maxi(_next_decision_beat, _last_seen_beat + 1 + maxi(0, phase_switch_pause_beats))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus == null:
|
||||
return
|
||||
if bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
if bus.has_signal("chart_event_upcoming") and bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
|
||||
bus.disconnect("chart_event_upcoming", _on_chart_event_upcoming)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if not enabled or _is_dead():
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
_last_seen_beat = maxi(_last_seen_beat, beat_index)
|
||||
if not enabled or _is_dead() or not _owner_combat_enabled():
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
if beat_index < _next_decision_beat:
|
||||
return
|
||||
_beat_cursor = float(beat_index)
|
||||
var action_id := _choose_action()
|
||||
if action_id.is_empty():
|
||||
return
|
||||
if _owner_stationary() and (action_id == &"boss_dash" or action_id == &"boss_retreat_dash"):
|
||||
action_id = _phase_fallback_action()
|
||||
if not phase_allows_action(action_id):
|
||||
action_id = _phase_fallback_action()
|
||||
if action_id.is_empty() or not phase_allows_action(action_id):
|
||||
return
|
||||
_next_decision_beat = beat_index + _decision_beat_step()
|
||||
if conditional_anchor_actions.has(action_id):
|
||||
_schedule_conditional_anchor(beat_index)
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", action_id)
|
||||
|
||||
|
||||
func reset_for_combat_entry() -> void:
|
||||
enabled = true
|
||||
_next_decision_beat = 0
|
||||
_beat_cursor = 0.0
|
||||
_force_ranged_after_retreat = false
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
## Conditional TimeAnchorEvent (AnchorV1.0 §18.2): the boss preparing a special
|
||||
## attack marks an upcoming beat as a time anchor — miss it and the phase flips.
|
||||
func _schedule_conditional_anchor(beat_index: int) -> void:
|
||||
var anchor_system := _time_anchor_system_or_null()
|
||||
if anchor_system == null or not anchor_system.has_method("schedule_time_anchor"):
|
||||
return
|
||||
anchor_system.call("schedule_time_anchor", beat_index + maxi(1, special_attack_anchor_lead_beats), &"boss_condition")
|
||||
|
||||
|
||||
func _time_anchor_system_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("TimeAnchorSystem")
|
||||
|
||||
|
||||
func _choose_action() -> StringName:
|
||||
if _is_dead():
|
||||
return &""
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
if target == null:
|
||||
return &""
|
||||
var distance := _distance_to_target()
|
||||
var low_health := _health_ratio() <= low_health_ratio
|
||||
if restrict_actions_by_time_phase:
|
||||
var phase := _current_time_phase()
|
||||
if phase == &"future":
|
||||
return _choose_future_phase_action(distance)
|
||||
if phase == &"past":
|
||||
return _choose_past_phase_action(distance)
|
||||
if distance <= melee_distance:
|
||||
return _choose_melee_range_action(low_health)
|
||||
if distance > dash_distance:
|
||||
if _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
return _next_ranged_action()
|
||||
return _choose_mid_range_action(low_health, distance)
|
||||
|
||||
|
||||
func _choose_past_phase_action(distance: float) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
if distance > dash_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
if distance > melee_distance and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
return &""
|
||||
|
||||
|
||||
func _choose_future_phase_action(distance: float) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
# new3 §6.7:远程相位被近身时优先后撤拉开距离(后撤本身不造成伤害)。
|
||||
if distance <= retreat_trigger_distance and not _owner_stationary() and _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
|
||||
_last_retreat_beat = _beat_cursor
|
||||
_force_ranged_after_retreat = true
|
||||
return &"boss_retreat_dash"
|
||||
if distance <= melee_distance and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
return _next_ranged_action()
|
||||
|
||||
|
||||
func _choose_melee_range_action(low_health: bool) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
if not low_health and _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
if _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
|
||||
_last_retreat_beat = _beat_cursor
|
||||
_force_ranged_after_retreat = true
|
||||
return &"boss_retreat_dash"
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
return &""
|
||||
|
||||
|
||||
func _choose_mid_range_action(low_health: bool, distance: float) -> StringName:
|
||||
if _force_ranged_after_retreat:
|
||||
_force_ranged_after_retreat = false
|
||||
return _next_ranged_action()
|
||||
if not low_health and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
if low_health and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
if distance > ranged_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
return _next_ranged_action()
|
||||
|
||||
|
||||
func _next_combo_action() -> StringName:
|
||||
var actions: Array[StringName] = [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3"]
|
||||
var action_id := actions[_combo_index % actions.size()]
|
||||
_combo_index += 1
|
||||
return action_id
|
||||
|
||||
|
||||
func _next_ranged_action() -> StringName:
|
||||
var actions: Array[StringName] = [&"boss_shoot_1", &"boss_shoot_2"]
|
||||
var action_id := actions[_ranged_index % actions.size()]
|
||||
_ranged_index += 1
|
||||
return action_id
|
||||
|
||||
|
||||
func phase_allows_action(action_id: StringName) -> bool:
|
||||
if not restrict_actions_by_time_phase or action_id.is_empty():
|
||||
return true
|
||||
var action := ActionResolver.get_action(action_id)
|
||||
if action == null:
|
||||
return true
|
||||
var tags: Array = action.get("action_tags")
|
||||
var hit_type := StringName(str(action.get("hit_type")))
|
||||
var is_melee := hit_type == &"melee" or tags.has(&"melee")
|
||||
var is_ranged := hit_type == &"projectile" or hit_type == &"ranged" or tags.has(&"ranged")
|
||||
var phase := _current_time_phase()
|
||||
if phase == &"past":
|
||||
return not is_ranged
|
||||
if phase == &"future":
|
||||
return not is_melee
|
||||
return true
|
||||
|
||||
|
||||
func _phase_fallback_action() -> StringName:
|
||||
if not restrict_actions_by_time_phase:
|
||||
return _next_ranged_action()
|
||||
if _current_time_phase() == &"future":
|
||||
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
return _next_ranged_action()
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
if _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
return &""
|
||||
|
||||
|
||||
func _refresh_target() -> void:
|
||||
if target != null and is_instance_valid(target):
|
||||
return
|
||||
target = get_node_or_null(target_path) as Node2D
|
||||
if target != null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or owner.get_parent() == null:
|
||||
return
|
||||
target = owner.get_parent().get_node_or_null("Player") as Node2D
|
||||
|
||||
|
||||
func _face_target() -> void:
|
||||
var owner := get_parent()
|
||||
if target != null and owner != null and owner.has_method("look_at_target"):
|
||||
owner.call("look_at_target", target)
|
||||
|
||||
|
||||
func _distance_to_target() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null:
|
||||
return melee_distance
|
||||
return absf(target.global_position.x - owner.global_position.x)
|
||||
|
||||
|
||||
func _health_ratio() -> float:
|
||||
if health_component == null:
|
||||
return 1.0
|
||||
var maximum := maxf(1.0, float(health_component.get("maximum")))
|
||||
return clampf(float(health_component.get("current")) / maximum, 0.0, 1.0)
|
||||
|
||||
|
||||
func _owner_combat_enabled() -> bool:
|
||||
# Boss-room lock: while the door is open the boss neither acts nor raises
|
||||
# conditional time anchors.
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return true
|
||||
var value = owner.get("combat_enabled")
|
||||
if value is bool:
|
||||
return value
|
||||
return true
|
||||
|
||||
|
||||
func _owner_stationary() -> bool:
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var value = owner.get("stationary")
|
||||
return value is bool and value
|
||||
|
||||
|
||||
func _is_dead() -> bool:
|
||||
if health_component != null and int(health_component.get("current")) <= 0:
|
||||
return true
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var state_machine := owner.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
return false
|
||||
|
||||
|
||||
func _cooldown_ready(last_beat: float, cooldown_beats: float) -> bool:
|
||||
return _beat_cursor - last_beat >= cooldown_beats
|
||||
|
||||
|
||||
func _decision_beat_step() -> int:
|
||||
# Boss 相位补充说明: the boss has no strong/weak phase — HP, defense and
|
||||
# its core numbers stay stable across Past/Future. Only move SELECTION
|
||||
# (melee vs ranged) and decision CADENCE (§18.1 slow/heavy vs fast/light)
|
||||
# shift with the phase.
|
||||
var interval_scale := 1.0
|
||||
if restrict_actions_by_time_phase:
|
||||
interval_scale = future_decision_interval_scale if _current_time_phase() == &"future" else past_decision_interval_scale
|
||||
return maxi(1, int(round(decision_interval_beats * interval_scale)))
|
||||
|
||||
|
||||
func _connect_beat_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _connect_chart_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("chart_event_upcoming") and not bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
|
||||
bus.connect("chart_event_upcoming", _on_chart_event_upcoming)
|
||||
|
||||
|
||||
func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void:
|
||||
if event == null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or StringName(str(event.get("target_id"))) != StringName(owner.name):
|
||||
return
|
||||
var event_beat := int(floor(float(event.call("beat_position"))))
|
||||
_next_decision_beat = maxi(_next_decision_beat, event_beat + 2)
|
||||
|
||||
|
||||
func _event_bus() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
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 _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
if not is_inside_tree():
|
||||
return &"past"
|
||||
var children := get_tree().root.get_children()
|
||||
for index: int in range(children.size() - 1, -1, -1):
|
||||
var child: Node = children[index]
|
||||
if child.has_method("set_time_phase") and child.get("current_time_phase") != null:
|
||||
return StringName(str(child.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c57cb6ivmypad
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=12 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="1_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="2_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="3_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="4_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="5_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="6_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="7_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="8_enemy_action_driver"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(18, 48)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(64, 36)
|
||||
|
||||
[node name="Enemy" type="CharacterBody2D"]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, -32)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="StateMachine" type="Node" parent="."]
|
||||
script = ExtResource("1_state_machine")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="."]
|
||||
script = ExtResource("2_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="."]
|
||||
script = ExtResource("3_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("4_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="."]
|
||||
script = ExtResource("5_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
beat_anchor_policy = &"ANCHOR_ACTIVE"
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="."]
|
||||
script = ExtResource("6_effect_container")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="."]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("7_damage_emitter")
|
||||
damage = 100
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"]
|
||||
position = Vector2(0, -32)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="."]
|
||||
script = ExtResource("8_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
@@ -0,0 +1,39 @@
|
||||
class_name EnemyActionDriver
|
||||
extends Node
|
||||
|
||||
const AIIntentScript := preload("res://scenes/components/ai_intent.gd")
|
||||
|
||||
@export var action_controller_path: NodePath
|
||||
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
|
||||
|
||||
func start_action(action_id: StringName) -> void:
|
||||
if action_controller == null or not action_controller.has_method("submit_ai_intent"):
|
||||
return
|
||||
if not _owner_allows_action(action_id):
|
||||
return
|
||||
var intent: RefCounted = AIIntentScript.create(action_id, float(Time.get_ticks_msec()))
|
||||
action_controller.call("submit_ai_intent", intent)
|
||||
|
||||
|
||||
func handle_chart_event(event: Resource) -> void:
|
||||
if event == null:
|
||||
return
|
||||
var action_id := StringName(str(event.get("action_id")))
|
||||
if action_id.is_empty() and event.get("payload") is Dictionary:
|
||||
action_id = StringName(str(event.get("payload").get("action_id", &"")))
|
||||
if action_id.is_empty():
|
||||
return
|
||||
if not _owner_allows_action(action_id):
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0 and action_controller.has_method("cancel_current"):
|
||||
action_controller.call("cancel_current", &"chart_override")
|
||||
start_action(action_id)
|
||||
|
||||
|
||||
func _owner_allows_action(action_id: StringName) -> bool:
|
||||
var actor := get_parent()
|
||||
if actor != null and actor.has_method("can_start_enemy_action"):
|
||||
return bool(actor.call("can_start_enemy_action", action_id))
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://hpgeq4fpf078
|
||||
@@ -0,0 +1,665 @@
|
||||
class_name Minion
|
||||
extends Character
|
||||
|
||||
const DEFAULT_HEADING := Vector2.LEFT
|
||||
const DEFAULT_ANIMATION_FPS := 12.0
|
||||
const VISIBLE_FEET_ANCHOR_Y := -1.0
|
||||
const PHASE_SWAP_MOVEMENT_HOLD_FRAMES := 2
|
||||
|
||||
const FORM_SPECS := {
|
||||
&"jin_zhan_1": {
|
||||
"native_facing": Vector2.LEFT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_1/idle.png", "frames": 6, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/jin_zhan_1/death.png", "frames": 6, "fps": 10.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"jin_zhan_1_attack", "path": "res://assets/art/characters/minions/jin_zhan_1/attack.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_jin_zhan_1_attack"],
|
||||
"sprite_offset": Vector2(-28.0, -48.0),
|
||||
"projectile_height": 42.0,
|
||||
},
|
||||
&"jin_zhan_3": {
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_3/idle.png", "frames": 7, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/jin_zhan_3/death.png", "frames": 12, "fps": 12.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_3/dash.png", "frames": 5, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"jin_zhan_3_attack_1", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
{"name": &"jin_zhan_3_attack_2", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
|
||||
{"name": &"jin_zhan_3_attack_3", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_3.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_jin_zhan_3_attack_1", &"minion_jin_zhan_3_attack_2", &"minion_jin_zhan_3_attack_3"],
|
||||
"sprite_offset": Vector2(-47.0, -62.0),
|
||||
"projectile_height": 44.0,
|
||||
},
|
||||
&"yuan_cheng_1": {
|
||||
# 素材原生朝右(眼睛与挥击弧线都在右侧);朝左时再做镜像。
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_1/idle.png", "frames": 8, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_1/death.png", "frames": 7, "fps": 10.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"yuan_cheng_1_attack_1", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
{"name": &"yuan_cheng_1_attack_2", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_yuan_cheng_1_attack_1", &"minion_yuan_cheng_1_attack_2"],
|
||||
"sprite_offset": Vector2(-39.0, -48.0),
|
||||
# 可见脚底在 actor 原点上方约 40px,出手点再高约 46px(甩击释放高度),
|
||||
# 两个远程形态共用同一条弹道,切相位不跳变。
|
||||
"projectile_height": 86.0,
|
||||
},
|
||||
&"yuan_cheng_2": {
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"visual_scale": 1.12,
|
||||
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_2/idle.png", "frames": 10, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_2/death.png", "frames": 18, "fps": 14.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_2/dash.png", "frames": 3, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"yuan_cheng_2_attack", "path": "res://assets/art/characters/minions/yuan_cheng_2/attack.png", "frames": 13, "fps": 14.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_yuan_cheng_2_attack"],
|
||||
"sprite_offset": Vector2(-74.0, -97.0),
|
||||
# 对齐法杖顶端法球中心(脚底上方约 49px),并与小女孩共用同一条弹道。
|
||||
"projectile_height": 86.0,
|
||||
},
|
||||
}
|
||||
|
||||
@export var max_health := 650
|
||||
@export var current_health := 650
|
||||
@export var past_form_id: StringName = &"jin_zhan_3"
|
||||
@export var future_form_id: StringName = &"jin_zhan_1"
|
||||
@export var strength_profile: StringName = &"past_strong"
|
||||
@export var time_phase_profile: Resource
|
||||
@export var visual_scale := 2.0
|
||||
@export var visual_ground_offset := -38.0
|
||||
@export var projectile_spawn_forward := 44.0
|
||||
@export var minion_lunge_speed := 100.0
|
||||
@export var approach_speed := 120.0
|
||||
|
||||
## Set by MinionBehavior each physics frame: signed walk intent whose magnitude
|
||||
## scales approach_speed (patrol strolls at ~0.45, a ranged retreat hops at ~1.7).
|
||||
var approach_direction := 0.0
|
||||
|
||||
@onready var state_machine: Node = get_node_or_null("StateMachine")
|
||||
@onready var action_controller: Node = get_node_or_null("ActionController")
|
||||
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
|
||||
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
|
||||
@onready var health_component: Node = get_node_or_null("HealthComponent")
|
||||
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
|
||||
@onready var strong_buff_visual: Node = get_node_or_null("Visual/StrongBuffVisual")
|
||||
@onready var overhead_health_bar: Node2D = get_node_or_null("OverheadHealthBar") as Node2D
|
||||
|
||||
var form_id: StringName = &""
|
||||
var _pending_form_id: StringName = &""
|
||||
var _current_action_animation: StringName = &""
|
||||
var _frame_delta := 0.0
|
||||
var _dead_state_entered := false
|
||||
var _death_animation_started := false
|
||||
var _phase_swap_hold_frames := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
heading = DEFAULT_HEADING
|
||||
anim_map = {
|
||||
PRESENTATION_IDLE: "idle",
|
||||
PRESENTATION_WALK: "idle",
|
||||
PRESENTATION_JUMP: "idle",
|
||||
PRESENTATION_LAND: "idle",
|
||||
PRESENTATION_ATTACK: "idle",
|
||||
PRESENTATION_AIR_ATTACK: "idle",
|
||||
}
|
||||
_install_minion_animations()
|
||||
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
|
||||
_wire_action_controller()
|
||||
_wire_damage_receiver()
|
||||
_connect_time_phase_bus()
|
||||
_apply_time_phase_profile()
|
||||
if health_component != null and health_component.has_method("set_values"):
|
||||
var health_multiplier := _difficulty_health_multiplier()
|
||||
health_component.call("set_values", int(round(current_health * health_multiplier)), int(round(max_health * health_multiplier)))
|
||||
apply_time_phase(_current_time_phase())
|
||||
state = PRESENTATION_IDLE
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_frame_delta = delta
|
||||
super._physics_process(delta)
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## 击退滑行(趔趄)期间不得起手新动作(EnemyActionDriver 钩子,boss 同款):
|
||||
## 否则动作突进会立刻接管 velocity,把击退位移吃掉。
|
||||
func can_start_enemy_action(_action_id: StringName) -> bool:
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
# 委托 MovementMotor(boss 同款):击退残速的摩擦衰减、假高度轴积分、
|
||||
# 落地复位 Grounded 全在里面。旧版只在 JUMP 态积分,导致垂直击退后
|
||||
# ground_state 永久卡 Airborne,小怪从此不能出招(allowed_ground_states)。
|
||||
if movement_motor != null and movement_motor.has_method("handle_air_time"):
|
||||
movement_motor.call("handle_air_time", delta)
|
||||
elif state == PRESENTATION_JUMP:
|
||||
super.handle_air_time(delta)
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if motion_executor != null and bool(motion_executor.get("active")):
|
||||
velocity = motion_executor.call("tick", _frame_delta)
|
||||
return
|
||||
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
if _phase_swap_hold_frames > 0:
|
||||
_phase_swap_hold_frames -= 1
|
||||
approach_direction = 0.0
|
||||
velocity.x = 0.0
|
||||
state = PRESENTATION_IDLE
|
||||
return
|
||||
# 击退残速期:滑行交给 MovementMotor 的摩擦衰减,AI 不接管 velocity.x
|
||||
# (否则击退初速活不过一帧,小怪击退位移恒为 0)。必须放在相位切换
|
||||
# hold 之后——换相位定身优先,hold 清零速度后 stray 标志随衰减自清。
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
state = PRESENTATION_IDLE
|
||||
return
|
||||
if absf(approach_direction) > 0.05 and _life_state() != &"Dead":
|
||||
# 上限 4.0 给受击脱离的跳跃速度(escape_speed_scale 3.2)留出空间。
|
||||
velocity.x = clampf(approach_direction, -4.0, 4.0) * approach_speed
|
||||
state = PRESENTATION_WALK
|
||||
return
|
||||
velocity.x = 0.0
|
||||
state = PRESENTATION_IDLE
|
||||
|
||||
|
||||
func handle_animations() -> void:
|
||||
var life_state := _life_state()
|
||||
if life_state == &"Dead":
|
||||
_enter_dead_state()
|
||||
_play_death_animation_once()
|
||||
_queue_free_if_death_animation_finished()
|
||||
return
|
||||
_dead_state_entered = false
|
||||
_death_animation_started = false
|
||||
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
|
||||
_play_animation(_current_action_animation)
|
||||
return
|
||||
if state == PRESENTATION_WALK:
|
||||
_play_animation(_dash_animation_name(form_id))
|
||||
return
|
||||
_play_idle()
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_sprite_height_position() -> void:
|
||||
if visual != null:
|
||||
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
|
||||
_refresh_overhead_health_bar_position()
|
||||
|
||||
|
||||
func flip_sprites() -> void:
|
||||
_apply_form_visual_scale()
|
||||
|
||||
|
||||
func apply_time_phase(time_phase: StringName) -> void:
|
||||
_refresh_strong_buff_visual(time_phase)
|
||||
var next_form := future_form_id if time_phase == &"future" else past_form_id
|
||||
if next_form == form_id:
|
||||
_pending_form_id = &""
|
||||
if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK and _life_state() != &"Dead":
|
||||
_apply_sprite_offset()
|
||||
_play_idle(true)
|
||||
return
|
||||
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
_pending_form_id = next_form
|
||||
return
|
||||
_hold_phase_swap_position()
|
||||
form_id = next_form
|
||||
_pending_form_id = &""
|
||||
_apply_sprite_offset()
|
||||
if _life_state() != &"Dead":
|
||||
_play_idle(true)
|
||||
|
||||
|
||||
func current_action_ids() -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
var actions: Array = _form_spec().get("actions", [])
|
||||
for action_id: Variant in actions:
|
||||
result.append(StringName(str(action_id)))
|
||||
return result
|
||||
|
||||
|
||||
func current_attack_interval_beats() -> int:
|
||||
return 2 if is_strong_in_current_time_phase() else 4
|
||||
|
||||
|
||||
func is_strong_in_current_time_phase() -> bool:
|
||||
return _is_strong_in_time_phase(_current_time_phase())
|
||||
|
||||
|
||||
func _is_strong_in_time_phase(time_phase: StringName) -> bool:
|
||||
return (strength_profile == &"past_strong" and time_phase == &"past") or (strength_profile == &"future_strong" and time_phase == &"future")
|
||||
|
||||
|
||||
func look_at_target(target: Node2D) -> void:
|
||||
if target == null:
|
||||
return
|
||||
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
|
||||
|
||||
|
||||
func projectile_spawn_position(_action: Resource) -> Vector2:
|
||||
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -_projectile_height())
|
||||
|
||||
|
||||
func projectile_direction(_action: Resource) -> Vector2:
|
||||
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
|
||||
|
||||
|
||||
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
|
||||
return [{
|
||||
"spawn_position": projectile_spawn_position(action),
|
||||
"direction": projectile_direction(action),
|
||||
}]
|
||||
|
||||
|
||||
func _install_minion_animations() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
var library: AnimationLibrary
|
||||
if animation_player.has_animation_library(""):
|
||||
library = animation_player.get_animation_library("")
|
||||
else:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
for next_form: StringName in FORM_SPECS.keys():
|
||||
var spec: Dictionary = FORM_SPECS[next_form]
|
||||
var sprite_offset: Vector2 = spec.get("sprite_offset", Vector2(-32.0, -64.0))
|
||||
_add_sheet_animation(library, _idle_animation_name(next_form), spec.get("idle", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
_add_sheet_animation(library, _death_animation_name(next_form), spec.get("death", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
_add_sheet_animation(library, _dash_animation_name(next_form), spec.get("dash", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
for attack: Variant in spec.get("attacks", []):
|
||||
if attack is Dictionary:
|
||||
_add_sheet_animation(library, StringName(str((attack as Dictionary).get("name", &""))), attack, VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
|
||||
|
||||
func _add_sheet_animation(library: AnimationLibrary, animation_name: StringName, spec: Dictionary, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> void:
|
||||
if animation_name.is_empty() or spec.is_empty() or library.has_animation(animation_name):
|
||||
return
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
return
|
||||
var frame_count: int = int(spec.get("frames", 1))
|
||||
var hframes: int = int(spec.get("hframes", frame_count))
|
||||
library.add_animation(animation_name, _make_sheet_animation(texture, frame_count, hframes, float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), visible_bottom_anchor, sprite_offset))
|
||||
|
||||
|
||||
func _make_sheet_animation(texture: Texture2D, frame_count: int, hframes: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
|
||||
var safe_frame_count := maxi(1, frame_count)
|
||||
var safe_hframes := maxi(safe_frame_count, hframes)
|
||||
var safe_fps := maxf(1.0, fps)
|
||||
var frame_time := 1.0 / safe_fps
|
||||
var animation := Animation.new()
|
||||
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
|
||||
animation.step = frame_time
|
||||
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
|
||||
var texture_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
|
||||
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(texture_track, 0.0, texture)
|
||||
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
|
||||
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(hframes_track, 0.0, safe_hframes)
|
||||
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
|
||||
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(vframes_track, 0.0, 1)
|
||||
var frame_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
|
||||
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
|
||||
if visible_bottom_anchor < INF:
|
||||
var offset_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
|
||||
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
var offset := sprite_offset
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, safe_hframes, 1, frame_index)
|
||||
if bottom != INF:
|
||||
offset.y = visible_bottom_anchor - bottom
|
||||
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
|
||||
return animation
|
||||
|
||||
|
||||
func _wire_action_controller() -> void:
|
||||
if action_controller == null:
|
||||
return
|
||||
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
|
||||
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
|
||||
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
|
||||
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
|
||||
|
||||
|
||||
func _wire_damage_receiver() -> void:
|
||||
if damage_receiver != null:
|
||||
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
|
||||
|
||||
|
||||
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||
if not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
|
||||
|
||||
func _connect_time_phase_bus() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
|
||||
|
||||
func _apply_time_phase_profile() -> void:
|
||||
var adapter := get_node_or_null("TimePhaseAdapter")
|
||||
if adapter == null:
|
||||
return
|
||||
adapter.set("profile", time_phase_profile)
|
||||
if adapter.has_method("apply_time_phase"):
|
||||
adapter.call("apply_time_phase", _current_time_phase())
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
apply_time_phase(current)
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_face_target_if_available()
|
||||
_current_action_animation = StringName(str(action.get("animation")))
|
||||
state = PRESENTATION_ATTACK
|
||||
attack_time_left = _action_duration_seconds(action) + 0.05
|
||||
attack_lunge_time_left = attack_time_left
|
||||
_align_action_animation_speed(action)
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
_play_animation(_current_action_animation)
|
||||
|
||||
|
||||
func _on_action_active_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead" or motion_executor == null:
|
||||
return
|
||||
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
|
||||
return
|
||||
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
|
||||
motion_executor.call("execute", action, heading, _beat_time(), minion_lunge_speed * speed_scale)
|
||||
|
||||
|
||||
func _on_action_finished(_action: Resource) -> void:
|
||||
_finish_action_presentation()
|
||||
|
||||
|
||||
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
|
||||
_finish_action_presentation()
|
||||
|
||||
|
||||
func _finish_action_presentation() -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
if not _pending_form_id.is_empty():
|
||||
apply_time_phase(_current_time_phase())
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _enter_dead_state() -> void:
|
||||
if _dead_state_entered:
|
||||
return
|
||||
_dead_state_entered = true
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
_reset_animation_speed()
|
||||
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"death")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
var damage_emitter := get_node_or_null("DamageEmitter")
|
||||
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
|
||||
damage_emitter.call("clear_hit")
|
||||
var receiver := get_node_or_null("DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
var behavior := get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
behavior.set("enabled", false)
|
||||
_refresh_strong_buff_visual(_current_time_phase())
|
||||
|
||||
|
||||
func _play_death_animation_once() -> void:
|
||||
if _death_animation_started:
|
||||
return
|
||||
_death_animation_started = true
|
||||
_play_animation(_death_animation_name(form_id))
|
||||
|
||||
|
||||
func _on_animation_finished(animation_name: StringName) -> void:
|
||||
if _death_animation_started and animation_name == _death_animation_name(form_id):
|
||||
queue_free()
|
||||
|
||||
|
||||
func _queue_free_if_death_animation_finished() -> void:
|
||||
if not _death_animation_started or is_queued_for_deletion():
|
||||
return
|
||||
if animation_player == null:
|
||||
queue_free()
|
||||
return
|
||||
if not animation_player.is_playing():
|
||||
queue_free()
|
||||
|
||||
|
||||
func _play_idle(force_refresh := false) -> void:
|
||||
_play_animation(_idle_animation_name(form_id), force_refresh)
|
||||
|
||||
|
||||
func _play_animation(animation_name: StringName, force_refresh := false) -> void:
|
||||
if animation_player == null or animation_name.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(animation_name):
|
||||
return
|
||||
if force_refresh or animation_player.current_animation != String(animation_name):
|
||||
animation_player.play(animation_name)
|
||||
if force_refresh:
|
||||
animation_player.seek(0.0, true)
|
||||
|
||||
|
||||
func _align_action_animation_speed(action: Resource) -> void:
|
||||
if animation_player == null or action == null or _current_action_animation.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(_current_action_animation):
|
||||
return
|
||||
var natural_length := animation_player.get_animation(_current_action_animation).length
|
||||
var action_seconds := _action_duration_seconds(action)
|
||||
if natural_length <= 0.0 or action_seconds <= 0.0:
|
||||
return
|
||||
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
|
||||
|
||||
|
||||
func _reset_animation_speed() -> void:
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
func _action_duration_seconds(action: Resource) -> float:
|
||||
if action == null:
|
||||
return attack_duration
|
||||
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
|
||||
|
||||
|
||||
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 _face_target_if_available() -> void:
|
||||
var target := _target_or_null()
|
||||
if target != null:
|
||||
look_at_target(target)
|
||||
|
||||
|
||||
func _target_or_null() -> Node2D:
|
||||
var behavior := get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
var behavior_target = behavior.get("target")
|
||||
if behavior_target is Node2D and is_instance_valid(behavior_target):
|
||||
return behavior_target
|
||||
var container := get_parent()
|
||||
if container != null:
|
||||
var sibling := container.get_node_or_null("Player") as Node2D
|
||||
if sibling != null:
|
||||
return sibling
|
||||
return null
|
||||
|
||||
|
||||
func _form_spec() -> Dictionary:
|
||||
return FORM_SPECS.get(form_id, FORM_SPECS.get(past_form_id, {}))
|
||||
|
||||
|
||||
func _form_visual_scale() -> float:
|
||||
return float(_form_spec().get("visual_scale", visual_scale))
|
||||
|
||||
|
||||
func _native_facing() -> Vector2:
|
||||
return _form_spec().get("native_facing", Vector2.RIGHT)
|
||||
|
||||
|
||||
func _apply_sprite_offset() -> void:
|
||||
if character_sprite != null:
|
||||
var sprite_offset: Vector2 = _form_spec().get("sprite_offset", Vector2(-32.0, -64.0))
|
||||
var idle_spec: Dictionary = _form_spec().get("idle", {})
|
||||
var texture := load(str(idle_spec.get("path", ""))) as Texture2D
|
||||
var hframes := int(idle_spec.get("hframes", idle_spec.get("frames", 1)))
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, hframes), 1, 0)
|
||||
sprite_offset.y = VISIBLE_FEET_ANCHOR_Y - bottom if bottom < INF else sprite_offset.y
|
||||
character_sprite.offset = sprite_offset
|
||||
_apply_form_visual_scale()
|
||||
_refresh_overhead_health_bar_position()
|
||||
|
||||
|
||||
func _apply_form_visual_scale() -> void:
|
||||
if visual == null:
|
||||
return
|
||||
var scale_value := _form_visual_scale()
|
||||
visual.scale.x = scale_value if heading == _native_facing() else -scale_value
|
||||
visual.scale.y = scale_value
|
||||
|
||||
|
||||
func _hold_phase_swap_position() -> void:
|
||||
_phase_swap_hold_frames = PHASE_SWAP_MOVEMENT_HOLD_FRAMES
|
||||
approach_direction = 0.0
|
||||
velocity.x = 0.0
|
||||
|
||||
|
||||
func _refresh_overhead_health_bar_position() -> void:
|
||||
if overhead_health_bar == null or visual == null or character_sprite == null:
|
||||
return
|
||||
overhead_health_bar.position = Vector2(0.0, visual.position.y + character_sprite.offset.y * absf(visual.scale.y) - 10.0)
|
||||
|
||||
|
||||
func _visible_frame_bottom_in_texture(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 _projectile_height() -> float:
|
||||
return float(_form_spec().get("projectile_height", 42.0))
|
||||
|
||||
|
||||
func _idle_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_idle" % str(next_form))
|
||||
|
||||
|
||||
func _death_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_death" % str(next_form))
|
||||
|
||||
|
||||
func _dash_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_dash" % str(next_form))
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _heading_sign() -> float:
|
||||
return -1.0 if heading.x < 0.0 else 1.0
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _difficulty_health_multiplier() -> float:
|
||||
var settings := get_tree().root.get_node_or_null("GameSettings") if is_inside_tree() else null
|
||||
if settings != null and settings.has_method("minion_health_multiplier"):
|
||||
return maxf(0.01, float(settings.call("minion_health_multiplier")))
|
||||
return 1.0
|
||||
|
||||
|
||||
func _refresh_strong_buff_visual(time_phase: StringName = &"") -> void:
|
||||
if strong_buff_visual == null or not strong_buff_visual.has_method("set_active"):
|
||||
return
|
||||
var phase := _current_time_phase() if time_phase.is_empty() else time_phase
|
||||
strong_buff_visual.call("set_active", _is_strong_in_time_phase(phase) and _life_state() != &"Dead")
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqbxyv604lcgx
|
||||
@@ -0,0 +1,136 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/enemies/minion.gd" id="1_minion"]
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="2_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="3_movement_motor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="4_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="5_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="6_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="7_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="8_motion_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="9_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="10_health_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="11_damage_receiver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="12_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="13_enemy_action_driver"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/minion_behavior.gd" id="14_minion_behavior"]
|
||||
[ext_resource type="Script" path="res://scenes/components/time_phase_adapter.gd" id="15_time_phase_adapter"]
|
||||
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="16_frame_collision"]
|
||||
[ext_resource type="Script" path="res://scenes/components/strong_buff_visual.gd" id="17_strong_buff_visual"]
|
||||
[ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(20, 46)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
|
||||
size = Vector2(24, 50)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(54, 34)
|
||||
|
||||
[node name="Minion" type="CharacterBody2D" groups=["enemies"]]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
script = ExtResource("1_minion")
|
||||
speed = 90.0
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
scale = Vector2(2, 2)
|
||||
|
||||
[node name="CharacterSprite" type="Sprite2D" parent="Visual"]
|
||||
texture_filter = 2
|
||||
centered = false
|
||||
offset = Vector2(-48, -84)
|
||||
|
||||
[node name="FxOverlay" type="Sprite2D" parent="Visual"]
|
||||
visible = false
|
||||
texture_filter = 2
|
||||
z_index = 2
|
||||
centered = false
|
||||
|
||||
[node name="StrongBuffVisual" type="Node2D" parent="Visual"]
|
||||
z_index = 1
|
||||
script = ExtResource("17_strong_buff_visual")
|
||||
|
||||
[node name="OverheadHealthBar" type="Node2D" parent="."]
|
||||
position = Vector2(0, -118)
|
||||
z_index = 30
|
||||
script = ExtResource("18_overhead_health_bar")
|
||||
bar_size = Vector2(58, 7)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
|
||||
[node name="StateMachine" type="Node" parent="."]
|
||||
script = ExtResource("2_state_machine")
|
||||
|
||||
[node name="MovementMotor" type="Node" parent="."]
|
||||
script = ExtResource("3_movement_motor")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="."]
|
||||
script = ExtResource("4_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="."]
|
||||
script = ExtResource("5_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("6_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="."]
|
||||
script = ExtResource("7_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
|
||||
[node name="MotionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("8_motion_executor")
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="."]
|
||||
script = ExtResource("9_effect_container")
|
||||
|
||||
[node name="HealthComponent" type="Node" parent="."]
|
||||
script = ExtResource("10_health_component")
|
||||
maximum = 650
|
||||
current = 650
|
||||
|
||||
[node name="DamageReceiver" type="Area2D" parent="."]
|
||||
collision_layer = 4
|
||||
collision_mask = 8
|
||||
script = ExtResource("11_damage_receiver")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_hurtbox")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="."]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("12_damage_emitter")
|
||||
damage = 25
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="."]
|
||||
script = ExtResource("13_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
|
||||
[node name="MinionBehavior" type="Node" parent="."]
|
||||
script = ExtResource("14_minion_behavior")
|
||||
target_path = NodePath("../../Player")
|
||||
|
||||
[node name="TimePhaseAdapter" type="Node" parent="."]
|
||||
script = ExtResource("15_time_phase_adapter")
|
||||
|
||||
[node name="FrameCollisionDriver" type="Node" parent="."]
|
||||
script = ExtResource("16_frame_collision")
|
||||
@@ -0,0 +1,431 @@
|
||||
class_name MinionBehavior
|
||||
extends Node
|
||||
|
||||
## 小怪行为状态机(关卡设计指南 §7.4 / AnchorV1.0 §17.5)。
|
||||
## 近战怪:入场 → 巡逻 → (警戒后在下一个节拍点)追击 → 攻击。
|
||||
## 远程怪:入场 → 移动到站位固定 → 距离判断攻击;被近身或连续受击时后撤。
|
||||
## 决策与攻击都对齐节拍;移动逐帧执行。
|
||||
|
||||
const ROLE_MELEE := &"melee"
|
||||
const ROLE_RANGED := &"ranged"
|
||||
|
||||
const STATE_ENTRY := &"Entry"
|
||||
const STATE_PATROL := &"Patrol"
|
||||
const STATE_CHASE := &"Chase"
|
||||
const STATE_HOLD := &"Hold"
|
||||
const STATE_RETREAT := &"Retreat"
|
||||
|
||||
@export var enabled := true
|
||||
## 近战怪会巡逻并追击;远程怪固定站位、会后撤。
|
||||
@export var role: StringName = ROLE_MELEE
|
||||
@export var target_path: NodePath
|
||||
@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver")
|
||||
@export var action_controller_path: NodePath = NodePath("../ActionController")
|
||||
@export var health_component_path: NodePath = NodePath("../HealthComponent")
|
||||
## Attacks only start inside this horizontal distance; further away the minion
|
||||
## repositions instead (near for melee, the whole firing lane for ranged).
|
||||
## new3 §4.2:近战攻击距离 120。
|
||||
@export var attack_range := 120.0
|
||||
## Stop approaching a little inside the attack range so the minion does not
|
||||
## rub against the player collider. new3 §4.2:追踪停止距离 120。
|
||||
@export var approach_stop_range := 120.0
|
||||
## 警戒范围:玩家进入后近战怪转入追击(下一个节拍点生效)。new3 §4.2:360。
|
||||
@export var alert_range := 360.0
|
||||
## 近战怪巡逻半径与速度(占 approach_speed 的比例)。
|
||||
@export var patrol_radius := 110.0
|
||||
@export var patrol_speed_scale := 0.45
|
||||
## 远程怪后撤参数(new3 §5.4):玩家进入危险距离时向后退开。
|
||||
@export var retreat_distance := 220.0
|
||||
@export var retreat_speed_scale := 1.7
|
||||
@export var too_close_range := 220.0
|
||||
@export var too_close_seconds := 0.6
|
||||
## 受击脱离(2026-07-05 定案):连续受击 4 次触发短暂闪烁无敌 + 跳跃形式脱离。
|
||||
@export var retreat_after_hits := 4
|
||||
@export var hit_chain_window_seconds := 2.0
|
||||
@export var retreat_cooldown_seconds := 3.0
|
||||
@export var escape_invuln_seconds := 0.4
|
||||
@export var escape_speed_scale := 3.2
|
||||
## 后撤/脱离结束后的停顿(按节拍对齐,new3 §5.4 推荐 0.4s ≈ 1 拍)。
|
||||
@export var post_retreat_pause_beats := 1
|
||||
## 可活动区间:巡逻 / 后撤永远不出这段地面。
|
||||
@export var arena_min_x := 1120.0
|
||||
@export var arena_max_x := 2890.0
|
||||
## 增援入场目标(NAN = 原地即战斗区域,直接进入巡逻/站位)。
|
||||
@export var entry_target_x := NAN
|
||||
|
||||
@onready var driver: Node = get_node_or_null(enemy_action_driver_path)
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var target: Node2D = get_node_or_null(target_path) as Node2D
|
||||
|
||||
var state: StringName = STATE_ENTRY
|
||||
var _next_decision_beat := 0
|
||||
var _action_index := 0
|
||||
var _patrol_anchor_x := 0.0
|
||||
var _patrol_direction := 1.0
|
||||
var _chase_pending := false
|
||||
var _retreat_target_x := 0.0
|
||||
var _too_close_time := 0.0
|
||||
var _recent_hits := 0
|
||||
var _hit_window_left := 0.0
|
||||
var _retreat_cooldown_left := 0.0
|
||||
var _escaping := false
|
||||
var _invuln_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
var receiver := get_node_or_null("../DamageReceiver")
|
||||
if receiver != null and receiver.has_signal("damage_received") and not receiver.is_connected("damage_received", _on_damage_received):
|
||||
receiver.connect("damage_received", _on_damage_received)
|
||||
_patrol_anchor_x = _owner_x()
|
||||
_refresh_target()
|
||||
if _has_entry_move():
|
||||
state = STATE_ENTRY
|
||||
else:
|
||||
state = STATE_PATROL if role == ROLE_MELEE else STATE_HOLD
|
||||
_face_target()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_tick_escape_invulnerability(delta)
|
||||
if not enabled or _is_dead():
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
# 攻击动作进行中:位移交给动作本身。
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
_refresh_target()
|
||||
_tick_retreat_triggers(delta)
|
||||
_update_state()
|
||||
_update_movement()
|
||||
|
||||
|
||||
## ------------------------------------------------------- state transitions
|
||||
|
||||
|
||||
func _update_state() -> void:
|
||||
var distance := _distance_to_target()
|
||||
match state:
|
||||
STATE_ENTRY:
|
||||
# 近战怪的入场可以被警戒打断(追击等下一拍);远程怪坚持走到站位。
|
||||
# 每帧重算:玩家在下一拍前离开警戒范围就不再追。
|
||||
if role == ROLE_MELEE:
|
||||
_chase_pending = distance <= alert_range
|
||||
if _entry_reached():
|
||||
_arrive_at_battle_area()
|
||||
STATE_PATROL:
|
||||
_chase_pending = distance <= alert_range
|
||||
STATE_CHASE:
|
||||
if distance > alert_range * 1.25:
|
||||
# 玩家甩开警戒范围:回到当前位置附近小范围巡逻。
|
||||
state = STATE_PATROL
|
||||
_patrol_anchor_x = _owner_x()
|
||||
_chase_pending = false
|
||||
STATE_HOLD:
|
||||
if _should_escape():
|
||||
_begin_escape()
|
||||
elif _should_retreat(distance):
|
||||
_begin_retreat()
|
||||
STATE_RETREAT:
|
||||
if absf(_owner_x() - _retreat_target_x) <= 10.0:
|
||||
_finish_retreat()
|
||||
|
||||
|
||||
func _arrive_at_battle_area() -> void:
|
||||
entry_target_x = NAN
|
||||
if role == ROLE_MELEE:
|
||||
state = STATE_PATROL
|
||||
_patrol_anchor_x = _owner_x()
|
||||
else:
|
||||
# 到达站位后固定下来,开始距离判断。
|
||||
state = STATE_HOLD
|
||||
|
||||
|
||||
func _begin_retreat(escape := false) -> void:
|
||||
var own_x := _owner_x()
|
||||
var away := 1.0
|
||||
if target != null and is_instance_valid(target):
|
||||
away = 1.0 if own_x >= target.global_position.x else -1.0
|
||||
var candidate := clampf(own_x + away * retreat_distance, arena_min_x, arena_max_x)
|
||||
if absf(candidate - own_x) < 60.0:
|
||||
# 贴墙没有退路:跳向另一侧(穿过玩家身位)。
|
||||
candidate = clampf(own_x - away * retreat_distance, arena_min_x, arena_max_x)
|
||||
_retreat_target_x = candidate
|
||||
_too_close_time = 0.0
|
||||
_recent_hits = 0
|
||||
_retreat_cooldown_left = retreat_cooldown_seconds
|
||||
_escaping = escape
|
||||
if escape:
|
||||
_start_escape_invulnerability()
|
||||
state = STATE_RETREAT
|
||||
|
||||
|
||||
## 受击脱离(2026-07-05 定案):短暂闪烁无敌 + 跳跃形式快速脱离。
|
||||
func _begin_escape() -> void:
|
||||
_begin_retreat(true)
|
||||
|
||||
|
||||
func _finish_retreat() -> void:
|
||||
state = STATE_HOLD
|
||||
_escaping = false
|
||||
# 后撤/脱离后的短暂停顿:按节拍推迟下一次攻击决策(new3 §5.4)。
|
||||
_next_decision_beat = maxi(_next_decision_beat, _current_beat_index() + maxi(0, post_retreat_pause_beats) + 1)
|
||||
|
||||
|
||||
func _should_retreat(distance: float) -> bool:
|
||||
if role != ROLE_RANGED or _retreat_cooldown_left > 0.0:
|
||||
return false
|
||||
return _too_close_time >= too_close_seconds
|
||||
|
||||
|
||||
func _should_escape() -> bool:
|
||||
if role != ROLE_RANGED or _retreat_cooldown_left > 0.0:
|
||||
return false
|
||||
return _recent_hits >= maxi(1, retreat_after_hits)
|
||||
|
||||
|
||||
func _start_escape_invulnerability() -> void:
|
||||
_invuln_left = maxf(0.0, escape_invuln_seconds)
|
||||
_set_damage_receiver_enabled(false)
|
||||
|
||||
|
||||
func _tick_escape_invulnerability(delta: float) -> void:
|
||||
if _invuln_left <= 0.0:
|
||||
return
|
||||
_invuln_left = maxf(0.0, _invuln_left - delta)
|
||||
var visual := _owner_visual()
|
||||
if _invuln_left <= 0.0:
|
||||
if visual != null:
|
||||
visual.modulate.a = 1.0
|
||||
if not _is_dead():
|
||||
_set_damage_receiver_enabled(true)
|
||||
return
|
||||
if visual != null:
|
||||
# 闪烁表现:无敌期间快速明暗交替。
|
||||
visual.modulate.a = 0.35 if fmod(_invuln_left, 0.16) > 0.08 else 1.0
|
||||
|
||||
|
||||
## FrameCollisionDriver 每物理帧都会重申受击矩阵,所以无敌必须通过它的
|
||||
## damage_receiver_enabled 开关实现,而不是直接改 Area2D 的 monitoring。
|
||||
func _set_damage_receiver_enabled(enabled: bool) -> void:
|
||||
var collision_driver := get_node_or_null("../FrameCollisionDriver")
|
||||
if collision_driver != null and "damage_receiver_enabled" in collision_driver:
|
||||
collision_driver.set("damage_receiver_enabled", enabled)
|
||||
var receiver := get_node_or_null("../DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.set_deferred("monitoring", enabled)
|
||||
receiver.set_deferred("monitorable", enabled)
|
||||
|
||||
|
||||
func _owner_visual() -> Node2D:
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return null
|
||||
return owner.get_node_or_null("Visual") as Node2D
|
||||
|
||||
|
||||
func _current_beat_index() -> int:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null:
|
||||
return int(rhythm.get("beat_index"))
|
||||
return 0
|
||||
|
||||
|
||||
func _tick_retreat_triggers(delta: float) -> void:
|
||||
_retreat_cooldown_left = maxf(0.0, _retreat_cooldown_left - delta)
|
||||
if _hit_window_left > 0.0:
|
||||
_hit_window_left = maxf(0.0, _hit_window_left - delta)
|
||||
if _hit_window_left <= 0.0:
|
||||
_recent_hits = 0
|
||||
if role == ROLE_RANGED and state == STATE_HOLD and _distance_to_target() <= too_close_range:
|
||||
_too_close_time += delta
|
||||
else:
|
||||
_too_close_time = maxf(0.0, _too_close_time - delta * 2.0)
|
||||
|
||||
|
||||
func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if role != ROLE_RANGED:
|
||||
return
|
||||
if _hit_window_left <= 0.0:
|
||||
_recent_hits = 0
|
||||
_recent_hits += 1
|
||||
_hit_window_left = hit_chain_window_seconds
|
||||
|
||||
|
||||
## --------------------------------------------------------------- movement
|
||||
|
||||
|
||||
func _update_movement() -> void:
|
||||
match state:
|
||||
STATE_ENTRY:
|
||||
_move_toward_x(entry_target_x, 1.0)
|
||||
STATE_PATROL:
|
||||
_patrol_step()
|
||||
STATE_CHASE:
|
||||
_chase_step()
|
||||
STATE_HOLD:
|
||||
_set_approach_direction(0.0)
|
||||
_face_target()
|
||||
STATE_RETREAT:
|
||||
_move_toward_x(_retreat_target_x, escape_speed_scale if _escaping else retreat_speed_scale)
|
||||
_face_target()
|
||||
|
||||
|
||||
func _patrol_step() -> void:
|
||||
var own_x := _owner_x()
|
||||
var half := maxf(20.0, patrol_radius)
|
||||
var low := clampf(_patrol_anchor_x - half, arena_min_x, arena_max_x)
|
||||
var high := clampf(_patrol_anchor_x + half, arena_min_x, arena_max_x)
|
||||
if own_x <= low:
|
||||
_patrol_direction = 1.0
|
||||
elif own_x >= high:
|
||||
_patrol_direction = -1.0
|
||||
_set_approach_direction(_patrol_direction * patrol_speed_scale)
|
||||
_face_walk_direction(_patrol_direction)
|
||||
|
||||
|
||||
func _chase_step() -> void:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null or not is_instance_valid(target):
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
var delta_x := target.global_position.x - owner.global_position.x
|
||||
if absf(delta_x) <= maxf(8.0, approach_stop_range):
|
||||
_set_approach_direction(0.0)
|
||||
else:
|
||||
_set_approach_direction(signf(delta_x))
|
||||
_face_target()
|
||||
|
||||
|
||||
func _move_toward_x(target_x: float, speed_scale: float) -> void:
|
||||
if is_nan(target_x):
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
var delta_x := target_x - _owner_x()
|
||||
if absf(delta_x) <= 8.0:
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
_set_approach_direction(signf(delta_x) * speed_scale)
|
||||
if state == STATE_ENTRY:
|
||||
_face_walk_direction(signf(delta_x))
|
||||
|
||||
|
||||
func _entry_reached() -> bool:
|
||||
return not _has_entry_move() or absf(entry_target_x - _owner_x()) <= 10.0
|
||||
|
||||
|
||||
func _has_entry_move() -> bool:
|
||||
return not is_nan(entry_target_x)
|
||||
|
||||
|
||||
func _set_approach_direction(direction: float) -> void:
|
||||
var owner := get_parent()
|
||||
if owner != null and "approach_direction" in owner:
|
||||
owner.set("approach_direction", direction)
|
||||
|
||||
|
||||
## ------------------------------------------------------------ beat driven
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
if not enabled or _is_dead():
|
||||
return
|
||||
if _chase_pending and role == ROLE_MELEE and state != STATE_CHASE:
|
||||
# 指南 §7.4:警戒后在下一个节拍点进入追击状态。
|
||||
_chase_pending = false
|
||||
state = STATE_CHASE
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
if beat_index < _next_decision_beat:
|
||||
return
|
||||
if state == STATE_ENTRY or state == STATE_RETREAT:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or not owner.has_method("current_action_ids"):
|
||||
return
|
||||
_refresh_target()
|
||||
if _distance_to_target() > attack_range:
|
||||
# Out of reach: keep repositioning (handled per-frame); re-check next beat.
|
||||
_next_decision_beat = beat_index + 1
|
||||
return
|
||||
_face_target()
|
||||
var action_ids: Array = owner.call("current_action_ids")
|
||||
if action_ids.is_empty():
|
||||
return
|
||||
var action_id := StringName(str(action_ids[_action_index % action_ids.size()]))
|
||||
_action_index += 1
|
||||
var interval := 1
|
||||
if owner.has_method("current_attack_interval_beats"):
|
||||
interval = maxi(1, int(owner.call("current_attack_interval_beats")))
|
||||
_next_decision_beat = beat_index + interval
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", action_id)
|
||||
|
||||
|
||||
## ----------------------------------------------------------------- lookup
|
||||
|
||||
|
||||
func _distance_to_target() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null or not is_instance_valid(target):
|
||||
return INF
|
||||
return absf(target.global_position.x - owner.global_position.x)
|
||||
|
||||
|
||||
func _owner_x() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
return owner.global_position.x if owner != null else 0.0
|
||||
|
||||
|
||||
func _refresh_target() -> void:
|
||||
if target != null and is_instance_valid(target):
|
||||
return
|
||||
target = get_node_or_null(target_path) as Node2D
|
||||
if target != null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or owner.get_parent() == null:
|
||||
return
|
||||
target = owner.get_parent().get_node_or_null("Player") as Node2D
|
||||
|
||||
|
||||
func _face_target() -> void:
|
||||
var owner := get_parent()
|
||||
if target != null and owner != null and owner.has_method("look_at_target"):
|
||||
owner.call("look_at_target", target)
|
||||
|
||||
|
||||
func _face_walk_direction(direction: float) -> void:
|
||||
var owner := get_parent()
|
||||
if owner == null or is_zero_approx(direction):
|
||||
return
|
||||
if "heading" in owner:
|
||||
owner.set("heading", Vector2.LEFT if direction < 0.0 else Vector2.RIGHT)
|
||||
|
||||
|
||||
func _is_dead() -> bool:
|
||||
if health_component != null and int(health_component.get("current")) <= 0:
|
||||
return true
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var state_machine := owner.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
return false
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3aqe2ksj244o
|
||||
@@ -0,0 +1,55 @@
|
||||
[gd_scene format=3 uid="uid://cs0rhloanh2u4"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://df1i0jo40oefn" path="res://assets/art/ground/ground.png" id="1_au3k8"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_au3k8"]
|
||||
size = Vector2(4105, 1)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_rrkwn"]
|
||||
resource_name = "RectangleShape2D_left_boundary"
|
||||
size = Vector2(32, 900)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_xmv3o"]
|
||||
resource_name = "RectangleShape2D_right_boundary"
|
||||
size = Vector2(32, 900)
|
||||
|
||||
[node name="ground" type="Node2D" unique_id=656914049]
|
||||
|
||||
[node name="StreetUnderfill" type="Polygon2D" parent="." unique_id=2126258380]
|
||||
visible = false
|
||||
color = Color(0.078, 0.065, 0.09, 1)
|
||||
polygon = PackedVector2Array(-22, 540, 4116, 540, 4116, 920, -22, 920)
|
||||
|
||||
[node name="GroundAnchor" type="Marker2D" parent="." unique_id=1875450741]
|
||||
position = Vector2(2047, 366)
|
||||
|
||||
[node name="GroundBody" type="StaticBody2D" parent="." unique_id=1583776637]
|
||||
position = Vector2(2047, 366)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="GroundBody" unique_id=376657383]
|
||||
texture_filter = 2
|
||||
scale = Vector2(1.7979, 1.7979)
|
||||
texture = ExtResource("1_au3k8")
|
||||
centered = false
|
||||
offset = Vector2(-1150.5, -480)
|
||||
region_enabled = true
|
||||
region_rect = Rect2(0, 90, 2301, 593)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="GroundBody" unique_id=629550857]
|
||||
position = Vector2(7, -133.5)
|
||||
shape = SubResource("RectangleShape2D_au3k8")
|
||||
|
||||
[node name="LeftBoundaryBody" type="StaticBody2D" parent="." unique_id=1153049543]
|
||||
position = Vector2(-21.5, -80)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="LeftBoundaryBody" unique_id=595374678]
|
||||
shape = SubResource("RectangleShape2D_rrkwn")
|
||||
|
||||
[node name="RightBoundaryBody" type="StaticBody2D" parent="." unique_id=1425356485]
|
||||
position = Vector2(4115.5, -80)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="RightBoundaryBody" unique_id=2009094553]
|
||||
shape = SubResource("RectangleShape2D_xmv3o")
|
||||
@@ -0,0 +1,59 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ground/ground_background.gd" id="1_ground_background"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/ground/past/past.png" id="2_past"]
|
||||
[ext_resource type="Texture2D" path="res://assets/art/ground/future/future.png" id="3_future"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"]
|
||||
size = Vector2(2301, 8)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_boundary"]
|
||||
resource_name = "RectangleShape2D_left_boundary"
|
||||
size = Vector2(32, 1100)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_boundary"]
|
||||
resource_name = "RectangleShape2D_right_boundary"
|
||||
size = Vector2(32, 1100)
|
||||
|
||||
[node name="ground1" type="Node2D"]
|
||||
script = ExtResource("1_ground_background")
|
||||
|
||||
[node name="GroundAnchor" type="Marker2D" parent="."]
|
||||
position = Vector2(2047, 560)
|
||||
|
||||
[node name="ArtLayer" type="Node2D" parent="."]
|
||||
position = Vector2(896.5, 0)
|
||||
z_index = -10
|
||||
|
||||
[node name="PastBackground" type="Sprite2D" parent="ArtLayer"]
|
||||
texture_filter = 2
|
||||
texture = ExtResource("2_past")
|
||||
centered = false
|
||||
|
||||
[node name="FutureBackground" type="Sprite2D" parent="ArtLayer"]
|
||||
visible = false
|
||||
texture_filter = 2
|
||||
texture = ExtResource("3_future")
|
||||
centered = false
|
||||
|
||||
[node name="GroundBody" type="StaticBody2D" parent="."]
|
||||
position = Vector2(2047, 560)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="GroundBody"]
|
||||
position = Vector2(0, 4)
|
||||
shape = SubResource("RectangleShape2D_ground")
|
||||
|
||||
[node name="LeftBoundaryBody" type="StaticBody2D" parent="."]
|
||||
position = Vector2(880.5, 150)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="LeftBoundaryBody"]
|
||||
shape = SubResource("RectangleShape2D_left_boundary")
|
||||
|
||||
[node name="RightBoundaryBody" type="StaticBody2D" parent="."]
|
||||
position = Vector2(3213.5, 150)
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="RightBoundaryBody"]
|
||||
shape = SubResource("RectangleShape2D_right_boundary")
|
||||
@@ -0,0 +1,66 @@
|
||||
extends Node2D
|
||||
|
||||
## 2026-07-06 视差定案(策划:远景夕阳要时刻在画面内):背景是"远景天幕",
|
||||
## 仅以 0.2 倍速随世界滚动(即 80% 跟随相机)——此前 0.92/0.8 的"近景层"
|
||||
## 参数方向反了,背景几乎钉在世界上,所以一直"看不出视差"。
|
||||
## 锚点 1734.5 = 让夕阳(原画 x≈760,世界 x≈1656)在出生点镜头(可见中心
|
||||
## 1340.8)恰好居中;相机全程 [1340.8, 2753.2] 内夕阳屏幕位置从中心缓移到
|
||||
## 左 282px,始终在画面内(已验算含光晕不出屏)。两端背景覆盖也已验算:
|
||||
## 极左窗 [880,1801.6]⊂[581.5,2882.5]、极右窗 [2292.4,3214]⊂[1711.5,4012.5]。
|
||||
## 已知取舍:步道砖烙在原画里会明显随镜头走(角色脚下滑动感),单张烙死图
|
||||
## 无解;美术把"近景步道"拆成独立 1:1 层后即可消除。勿用 Parallax2D
|
||||
## (历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。
|
||||
const PARALLAX_SCROLL_SCALE := 0.2
|
||||
const PARALLAX_ANCHOR_X := 1734.5
|
||||
|
||||
@onready var past_background: CanvasItem = $ArtLayer/PastBackground
|
||||
@onready var future_background: CanvasItem = $ArtLayer/FutureBackground
|
||||
@onready var _art_layer: Node2D = $ArtLayer
|
||||
|
||||
var _art_base_x := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_art_base_x = _art_layer.position.x
|
||||
_set_time_phase(_current_time_phase())
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var camera := get_viewport().get_camera_2d()
|
||||
if camera == null:
|
||||
# headless 测试/无相机场景:背景保持原位。
|
||||
_art_layer.position.x = _art_base_x
|
||||
return
|
||||
# get_screen_center_position 含 limit 夹取,玩家贴墙时背景不会继续滑。
|
||||
var center_x := camera.get_screen_center_position().x
|
||||
_art_layer.position.x = _art_base_x + (center_x - PARALLAX_ANCHOR_X) * (1.0 - PARALLAX_SCROLL_SCALE)
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
_set_time_phase(current)
|
||||
|
||||
|
||||
func _set_time_phase(time_phase: StringName) -> void:
|
||||
var use_future := time_phase == &"future"
|
||||
if past_background != null:
|
||||
past_background.visible = not use_future
|
||||
if future_background != null:
|
||||
future_background.visible = use_future
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
if not is_inside_tree():
|
||||
return &"past"
|
||||
var manager := get_tree().root.get_node_or_null("TimePhaseManager")
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ba7fyvg1ukb8e
|
||||
@@ -0,0 +1,40 @@
|
||||
extends Node
|
||||
|
||||
@export var sample_limit := 20
|
||||
|
||||
var _samples := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_process_input(true)
|
||||
var rhythm := _rhythm_manager()
|
||||
if rhythm != null and rhythm.has_method("start"):
|
||||
rhythm.call("start")
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
var key := event as InputEventKey
|
||||
if key == null or not key.pressed or key.echo:
|
||||
return
|
||||
_print_clock_sample(Time.get_ticks_msec())
|
||||
|
||||
|
||||
func _print_clock_sample(timestamp_ms: float) -> void:
|
||||
var rhythm := _rhythm_manager()
|
||||
if rhythm == null:
|
||||
push_warning("ClockDebug requires the RhythmManager autoload.")
|
||||
return
|
||||
|
||||
var song_time := float(rhythm.call("input_to_song_time", timestamp_ms))
|
||||
var rating: Dictionary = rhythm.call("get_rating_for_time", song_time)
|
||||
var nearest_beat := int(rating.get("nearest_beat", -1))
|
||||
var diff_ms := float(rating.get("diff", 0.0)) * 1000.0
|
||||
_samples += 1
|
||||
print("clock_debug sample=%d song_time=%.3f beat=%d diff_ms=%.2f" % [_samples, song_time, nearest_beat, diff_ms])
|
||||
|
||||
if sample_limit > 0 and _samples >= sample_limit:
|
||||
print("clock_debug sample limit reached")
|
||||
|
||||
|
||||
func _rhythm_manager() -> Node:
|
||||
return get_node_or_null("/root/RhythmManager")
|
||||
@@ -0,0 +1 @@
|
||||
uid://s13x8nbmfsc8
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/main/clock_debug.gd" id="1_clock"]
|
||||
|
||||
[node name="ClockDebug" type="Node"]
|
||||
script = ExtResource("1_clock")
|
||||
@@ -0,0 +1,19 @@
|
||||
extends Node2D
|
||||
|
||||
@onready var stage: Node = $Stage
|
||||
@onready var ui: Node = $UILayer/UI
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if ui != null and ui.has_method("bind_debug_actor"):
|
||||
ui.call("bind_debug_actor", get_player())
|
||||
if ui != null and ui.has_method("bind_boss_actor"):
|
||||
ui.call("bind_boss_actor", get_boss())
|
||||
|
||||
|
||||
func get_player() -> Node:
|
||||
return stage.get_node("ActorsContainer/Player")
|
||||
|
||||
|
||||
func get_boss() -> Node:
|
||||
return stage.get_node("ActorsContainer/Boss")
|
||||
@@ -0,0 +1 @@
|
||||
uid://q1v3lbqlej3y
|
||||
@@ -0,0 +1,28 @@
|
||||
[gd_scene format=3 uid="uid://dimsne1iyxg20"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://q1v3lbqlej3y" path="res://scenes/main/main.gd" id="1_main"]
|
||||
[ext_resource type="PackedScene" uid="uid://dimgym86pdqmd" path="res://scenes/stage/stage.tscn" id="2_stage"]
|
||||
[ext_resource type="Script" uid="uid://vea0brdgpneb" path="res://scenes/chart/chart_runner.gd" id="3_chart_runner"]
|
||||
[ext_resource type="Resource" uid="uid://3xrcwnvo4ih3" path="res://resources/charts/stage9_boss_duel.tres" id="4_stage7_chart"]
|
||||
[ext_resource type="PackedScene" uid="uid://d4fldy342eji2" path="res://scenes/ui/main_ui.tscn" id="5_ui"]
|
||||
[ext_resource type="Script" uid="uid://c3agddgh7fn4a" path="res://scenes/audio/audio_layer_controller.gd" id="6_audio_layers"]
|
||||
|
||||
[node name="Main" type="Node2D" unique_id=28024565]
|
||||
script = ExtResource("1_main")
|
||||
|
||||
[node name="Stage" parent="." unique_id=442949244 instance=ExtResource("2_stage")]
|
||||
|
||||
[node name="UILayer" type="CanvasLayer" parent="." unique_id=166482417]
|
||||
layer = 10
|
||||
|
||||
[node name="UI" parent="UILayer" unique_id=299590534 instance=ExtResource("5_ui")]
|
||||
|
||||
[node name="ChartRunner" type="Node" parent="." unique_id=110188885]
|
||||
script = ExtResource("3_chart_runner")
|
||||
chart = ExtResource("4_stage7_chart")
|
||||
actors_container_path = NodePath("../Stage/ActorsContainer")
|
||||
|
||||
[node name="AudioLayerController" type="Node" parent="." unique_id=1902714634]
|
||||
script = ExtResource("6_audio_layers")
|
||||
|
||||
[editable path="Stage"]
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://scenes/ground/ground.tscn" id="1_ground"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="2_player"]
|
||||
|
||||
[node name="Stage4Playtest" type="Node2D"]
|
||||
|
||||
[node name="Ground" parent="." instance=ExtResource("1_ground")]
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||
position = Vector2(2047, 366)
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position = Vector2(2047, 230)
|
||||
zoom = Vector2(2.5, 2.5)
|
||||
enabled = true
|
||||
@@ -0,0 +1,73 @@
|
||||
class_name ActorsContainer
|
||||
extends Node2D
|
||||
|
||||
const DEFAULT_PROJECTILE_SCENE := preload("res://scenes/combat/player_projectile.tscn")
|
||||
# 敌弹减速为玩家弹默认速度 520 的 80%(2026-07-05 定案);玩家弹保持脚本默认值。
|
||||
const ENEMY_PROJECTILE_SPEED := 416.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_event_bus().connect("projectile_requested", _on_projectile_requested)
|
||||
|
||||
|
||||
func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void:
|
||||
var scene := projectile_scene if projectile_scene != null else DEFAULT_PROJECTILE_SCENE
|
||||
var projectile := scene.instantiate()
|
||||
projectile.global_position = spawn_position
|
||||
projectile.set("direction", direction)
|
||||
var team := StringName(str(context.get("team", &"player")))
|
||||
if team == &"player":
|
||||
projectile.set("visual_profile", &"wave")
|
||||
# 耗能技能一律带击退(2026-07-05 定案):玩家弹丸使用与近战相同的
|
||||
# 基准击退,实际力度由动作的 knockback_mult 决定。
|
||||
projectile.set("base_knockback", Vector2(120.0, 304.056))
|
||||
else:
|
||||
projectile.set("speed", ENEMY_PROJECTILE_SPEED)
|
||||
# 2026-07-06:敌方弹丸此前基准击退为 (0,0),未来相位 Boss 只用远程
|
||||
# → 对玩家的击退数学上恒为零。补上基准(x 与 Boss 近战一致,弹丸
|
||||
# 不击飞),实际力度仍由各动作 .tres 的 knockback_mult 决定(0.25~0.5)。
|
||||
projectile.set("base_knockback", Vector2(360.0, 0.0))
|
||||
_configure_projectile_team(projectile, team)
|
||||
var action = context.get("action", null)
|
||||
if action is Resource:
|
||||
projectile.set("action_context", action)
|
||||
var judgement = context.get("judgement", null)
|
||||
if judgement is Dictionary and not judgement.is_empty():
|
||||
projectile.set("judgement_context", judgement)
|
||||
var interrupt_authority = context.get("attacker_interrupts", null)
|
||||
if interrupt_authority is bool:
|
||||
projectile.set("attacker_interrupts", interrupt_authority)
|
||||
var source_actor = context.get("source_actor", null)
|
||||
if source_actor is Node:
|
||||
projectile.set("source_actor", source_actor)
|
||||
var base_damage = context.get("base_damage", null)
|
||||
if base_damage is int or base_damage is float:
|
||||
projectile.set("damage", int(base_damage))
|
||||
var travel_range := float(context.get("range", 0.0))
|
||||
if travel_range > 0.0:
|
||||
projectile.set("max_range", travel_range)
|
||||
add_child(projectile)
|
||||
|
||||
|
||||
func _configure_projectile_team(projectile: Node, team: StringName) -> void:
|
||||
var area := projectile as Area2D
|
||||
if area == null:
|
||||
return
|
||||
if team == &"enemy":
|
||||
area.collision_layer = 1 << 4
|
||||
area.collision_mask = 1 << 1
|
||||
area.add_to_group("enemy_projectiles")
|
||||
else:
|
||||
area.collision_layer = 1 << 3
|
||||
area.collision_mask = (1 << 2) | (1 << 4)
|
||||
area.add_to_group("player_projectiles")
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs318qe54putv
|
||||
@@ -0,0 +1,148 @@
|
||||
class_name BossRoomGate
|
||||
extends Node2D
|
||||
|
||||
## Front-area → boss-room door (AnchorV1.0 §4.5 + 关卡设计指南 §8). The door
|
||||
## starts SEALED: the boss room only opens after all six front-area minions
|
||||
## are defeated (LevelDirector calls unseal()). Once the player crosses the
|
||||
## threshold it locks behind them (no going back to farm the front area) and
|
||||
## GameFlowManager applies the boss-entry rules.
|
||||
|
||||
signal boss_room_entered
|
||||
signal unsealed
|
||||
|
||||
@export var gate_x := 2960.0
|
||||
@export var floor_y := 231.0
|
||||
@export var wall_height := 900.0
|
||||
@export var wall_thickness := 48.0
|
||||
## 通关条件门闩:默认封锁,直到导演宣布小怪清场。
|
||||
@export var sealed := true
|
||||
|
||||
var locked := false
|
||||
|
||||
var _wall_body: StaticBody2D
|
||||
var _wall_shape: CollisionShape2D
|
||||
var _door_visual: Polygon2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_wall()
|
||||
_build_visual()
|
||||
_refresh_visual()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if locked or sealed:
|
||||
return
|
||||
var player := _player_or_null()
|
||||
if player == null:
|
||||
return
|
||||
# 跨过门线的瞬间就开战:Boss 立刻解锁、面板立刻显示。旧阈值
|
||||
# gate_x + wall_thickness 距 Boss 锚点仅 12px,等于要贴脸才触发。
|
||||
if player.global_position.x >= gate_x:
|
||||
lock()
|
||||
|
||||
|
||||
## 小怪清场后开放前往 Boss 房的路径(指南 §8)。
|
||||
func unseal() -> void:
|
||||
if not sealed or locked:
|
||||
return
|
||||
sealed = false
|
||||
_disable_wall()
|
||||
_refresh_visual()
|
||||
unsealed.emit()
|
||||
|
||||
|
||||
func lock() -> void:
|
||||
if locked:
|
||||
return
|
||||
locked = true
|
||||
_disable_wall()
|
||||
_refresh_visual()
|
||||
var boss := _activate_boss_actor()
|
||||
var flow := _flow_manager_or_null()
|
||||
if flow != null and flow.has_method("enter_boss_room"):
|
||||
flow.call("enter_boss_room")
|
||||
_bind_boss_panel(boss)
|
||||
boss_room_entered.emit()
|
||||
|
||||
|
||||
func _build_wall() -> void:
|
||||
_wall_body = StaticBody2D.new()
|
||||
_wall_body.name = "GateWall"
|
||||
_wall_body.position = Vector2(gate_x, floor_y - wall_height * 0.5)
|
||||
_wall_body.collision_layer = 1
|
||||
_wall_body.collision_mask = 0
|
||||
add_child(_wall_body)
|
||||
_wall_shape = CollisionShape2D.new()
|
||||
var shape := RectangleShape2D.new()
|
||||
shape.size = Vector2(wall_thickness, wall_height)
|
||||
_wall_shape.shape = shape
|
||||
_wall_shape.disabled = not sealed
|
||||
_wall_body.add_child(_wall_shape)
|
||||
|
||||
|
||||
func _build_visual() -> void:
|
||||
_door_visual = Polygon2D.new()
|
||||
_door_visual.name = "DoorVisual"
|
||||
_door_visual.visible = false
|
||||
var half := wall_thickness * 0.5
|
||||
_door_visual.polygon = PackedVector2Array([
|
||||
Vector2(gate_x - half, floor_y),
|
||||
Vector2(gate_x + half, floor_y),
|
||||
Vector2(gate_x + half, floor_y - wall_height),
|
||||
Vector2(gate_x - half, floor_y - wall_height),
|
||||
])
|
||||
add_child(_door_visual)
|
||||
|
||||
|
||||
func _refresh_visual() -> void:
|
||||
if _door_visual == null:
|
||||
return
|
||||
_door_visual.visible = false
|
||||
_door_visual.color = Color(1.0, 1.0, 1.0, 0.0)
|
||||
|
||||
|
||||
func _player_or_null() -> Node2D:
|
||||
var container := get_parent()
|
||||
if container == null:
|
||||
return null
|
||||
var stage := container
|
||||
var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D
|
||||
return player
|
||||
|
||||
|
||||
func _disable_wall() -> void:
|
||||
if _wall_shape != null:
|
||||
_wall_shape.set_deferred("disabled", true)
|
||||
if _wall_body != null:
|
||||
_wall_body.collision_layer = 0
|
||||
_wall_body.collision_mask = 0
|
||||
_wall_body.visible = false
|
||||
|
||||
|
||||
func _activate_boss_actor() -> Node:
|
||||
var stage := get_parent()
|
||||
if stage == null:
|
||||
return null
|
||||
var boss := stage.get_node_or_null("ActorsContainer/Boss")
|
||||
if boss == null:
|
||||
return null
|
||||
boss.set("combat_enabled", true)
|
||||
boss.set("stationary", false)
|
||||
return boss
|
||||
|
||||
|
||||
func _bind_boss_panel(boss: Node) -> void:
|
||||
if boss == null or not is_instance_valid(boss):
|
||||
return
|
||||
var stage := get_parent()
|
||||
var main := stage.get_parent() if stage != null else null
|
||||
var ui := main.get_node_or_null("UILayer/UI") if main != null else null
|
||||
if ui != null and ui.has_method("bind_boss_actor"):
|
||||
ui.call("bind_boss_actor", boss)
|
||||
|
||||
|
||||
func _flow_manager_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("GameFlowManager")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c34aqq8fscdrv
|
||||
@@ -0,0 +1,245 @@
|
||||
class_name LevelDirector
|
||||
extends Node
|
||||
|
||||
## 第一关出怪导演(关卡设计指南 §7 / §8)。
|
||||
## 阶段一:预置近战怪(编辑器摆放,无刷新动画)。
|
||||
## 阶段二:击杀后在玩家附近刷 1 只远程怪(可感知、不贴脸)。
|
||||
## 阶段三:再击杀后左右两侧每 2 秒依次增援 近战L→远程R→近战R→远程L。
|
||||
## 全部 6 只被击败 → 解封 Boss 房路径并广播 front_area_cleared。
|
||||
|
||||
signal wave_phase_changed(phase: int)
|
||||
signal front_area_cleared
|
||||
|
||||
const MINION_SCENE := preload("res://scenes/enemies/minion.tscn")
|
||||
const PAST_STRONG_PROFILE := preload("res://resources/time_phase/profile_past_strong_enemy.tres")
|
||||
const FUTURE_STRONG_PROFILE := preload("res://resources/time_phase/profile_future_strong_enemy.tres")
|
||||
|
||||
## 阶段三增援表:延迟秒数 / 职责 / 入场方向(指南 §7.3 推荐顺序)。
|
||||
const REINFORCEMENTS := [
|
||||
{"delay": 0.0, "role": &"melee", "side": &"left"},
|
||||
{"delay": 2.0, "role": &"ranged", "side": &"right"},
|
||||
{"delay": 4.0, "role": &"melee", "side": &"right"},
|
||||
{"delay": 6.0, "role": &"ranged", "side": &"left"},
|
||||
]
|
||||
|
||||
@export var actors_container_path: NodePath = NodePath("../ActorsContainer")
|
||||
@export var gate_path: NodePath = NodePath("../BossRoomGate")
|
||||
## 阶段三刷新间隔的缩放(测试将其调小以加速)。
|
||||
@export var reinforcement_time_scale := 1.0
|
||||
## 阶段二远程怪与玩家的刷新距离(可感知范围内、不贴脸)。
|
||||
@export var ranged_spawn_distance := 330.0
|
||||
## 画面边缘刷新点(摄像机外侧)与入场后的战斗区域。
|
||||
@export var left_edge_x := 1150.0
|
||||
@export var right_edge_x := 2905.0
|
||||
@export var battle_center_x := 2100.0
|
||||
@export var arena_min_x := 1120.0
|
||||
@export var arena_max_x := 2890.0
|
||||
@export var ground_y := 231.0
|
||||
## Keep all front-area enemies comfortably left of the boss-room air wall so
|
||||
## the pre-boss camera can frame their full sprites.
|
||||
@export var boss_gate_minion_clearance := 220.0
|
||||
## 远程怪站位到战斗中心的距离;近战怪入场目标到中心的距离。
|
||||
@export var ranged_station_offset := 380.0
|
||||
@export var melee_entry_offset := 170.0
|
||||
## 2026-07-05 定案:近战小怪基础攻击天生是远程小怪的两倍(原基础 25 翻倍)。
|
||||
@export var melee_base_attack := 50
|
||||
@export var ranged_base_attack := 25
|
||||
## 远程怪最大开火距离必须在镜头可视范围内(约 460px 半宽):刚进画面即可开火,
|
||||
## 但绝不允许从屏幕外攻击玩家。
|
||||
@export var ranged_attack_range := 450.0
|
||||
|
||||
var phase := 1
|
||||
var kills := 0
|
||||
var total_spawned := 0
|
||||
var cleared := false
|
||||
|
||||
## 通关需要的总击杀数:预置怪 + 阶段二远程怪 1 只 + 阶段三增援(指南 §8 = 6)。
|
||||
var _expected_kills := 6
|
||||
var _watched: Dictionary = {}
|
||||
var _spawn_serial := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred("_register_preplaced_minions")
|
||||
|
||||
|
||||
func _register_preplaced_minions() -> void:
|
||||
var container := _actors_container()
|
||||
if container == null:
|
||||
return
|
||||
var preplaced := 0
|
||||
for child: Node in container.get_children():
|
||||
if child.is_in_group("enemies") and child.get_node_or_null("HealthComponent") != null:
|
||||
_configure_front_area_minion(child)
|
||||
_watch_minion(child)
|
||||
preplaced += 1
|
||||
total_spawned += preplaced
|
||||
_expected_kills = preplaced + 1 + REINFORCEMENTS.size()
|
||||
|
||||
|
||||
## ---------------------------------------------------------------- spawning
|
||||
|
||||
|
||||
func spawn_minion(role: StringName, spawn_position: Vector2, entry_target_x: float = NAN) -> Node:
|
||||
var container := _actors_container()
|
||||
if container == null:
|
||||
return null
|
||||
var minion: Node2D = MINION_SCENE.instantiate()
|
||||
_spawn_serial += 1
|
||||
minion.name = "%sMinion%d" % [("JinZhan" if role == &"melee" else "YuanCheng"), _spawn_serial]
|
||||
var behavior := minion.get_node_or_null("MinionBehavior")
|
||||
if role == &"ranged":
|
||||
minion.set("past_form_id", &"yuan_cheng_1")
|
||||
minion.set("future_form_id", &"yuan_cheng_2")
|
||||
minion.set("strength_profile", &"future_strong")
|
||||
minion.set("time_phase_profile", FUTURE_STRONG_PROFILE)
|
||||
if behavior != null:
|
||||
behavior.set("role", &"ranged")
|
||||
behavior.set("attack_range", ranged_attack_range)
|
||||
behavior.set("approach_stop_range", 340.0)
|
||||
behavior.set("alert_range", 540.0)
|
||||
else:
|
||||
minion.set("past_form_id", &"jin_zhan_3")
|
||||
minion.set("future_form_id", &"jin_zhan_1")
|
||||
minion.set("strength_profile", &"past_strong")
|
||||
minion.set("time_phase_profile", PAST_STRONG_PROFILE)
|
||||
if behavior != null:
|
||||
behavior.set("role", &"melee")
|
||||
if behavior != null:
|
||||
behavior.set("entry_target_x", entry_target_x)
|
||||
_configure_front_area_minion(minion)
|
||||
minion.position = _front_area_spawn_position(spawn_position)
|
||||
container.add_child(minion)
|
||||
total_spawned += 1
|
||||
_watch_minion(minion)
|
||||
return minion
|
||||
|
||||
|
||||
func _watch_minion(minion: Node) -> void:
|
||||
var key := minion.get_instance_id()
|
||||
if _watched.has(key):
|
||||
return
|
||||
var health := minion.get_node_or_null("HealthComponent")
|
||||
if health == null or not health.has_signal("depleted"):
|
||||
return
|
||||
_watched[key] = minion
|
||||
health.connect("depleted", Callable(self, "_on_minion_died").bind(key))
|
||||
|
||||
|
||||
func _on_minion_died(key: int) -> void:
|
||||
if not _watched.has(key):
|
||||
return
|
||||
_watched.erase(key)
|
||||
kills += 1
|
||||
match kills:
|
||||
1:
|
||||
_start_phase_two()
|
||||
2:
|
||||
_start_phase_three()
|
||||
if kills >= _expected_kills and not cleared:
|
||||
_clear_front_area()
|
||||
|
||||
|
||||
func _start_phase_two() -> void:
|
||||
if phase >= 2:
|
||||
return
|
||||
phase = 2
|
||||
wave_phase_changed.emit(phase)
|
||||
# 指南 §7.2:在玩家附近可感知处刷新,不贴脸;往地形余量更大的一侧放。
|
||||
var player := _player_or_null()
|
||||
var player_x := battle_center_x if player == null else player.global_position.x
|
||||
var front_max_x := _front_area_max_x()
|
||||
var side := 1.0 if (front_max_x - player_x) >= (player_x - arena_min_x) else -1.0
|
||||
var spawn_x := clampf(player_x + side * ranged_spawn_distance, arena_min_x + 60.0, front_max_x)
|
||||
spawn_minion(&"ranged", Vector2(spawn_x, ground_y))
|
||||
|
||||
|
||||
func _start_phase_three() -> void:
|
||||
if phase >= 3:
|
||||
return
|
||||
phase = 3
|
||||
wave_phase_changed.emit(phase)
|
||||
for entry: Dictionary in REINFORCEMENTS:
|
||||
var delay := float(entry.get("delay", 0.0)) * maxf(0.01, reinforcement_time_scale)
|
||||
var role := StringName(str(entry.get("role", &"melee")))
|
||||
var side := StringName(str(entry.get("side", &"left")))
|
||||
if delay <= 0.0:
|
||||
_spawn_reinforcement(role, side)
|
||||
else:
|
||||
var timer := get_tree().create_timer(delay, false)
|
||||
timer.timeout.connect(Callable(self, "_spawn_reinforcement").bind(role, side))
|
||||
|
||||
|
||||
func _spawn_reinforcement(role: StringName, side: StringName) -> void:
|
||||
if cleared or not is_inside_tree():
|
||||
return
|
||||
var from_left := side == &"left"
|
||||
var spawn_x := left_edge_x if from_left else right_edge_x
|
||||
var direction := 1.0 if from_left else -1.0
|
||||
# 入场目标在自己一侧:左侧入场停在中心左侧,右侧入场停在中心右侧,
|
||||
# 保证玩家不会被瞬间包围(指南 §7.3)。
|
||||
var offset := ranged_station_offset if role == &"ranged" else melee_entry_offset
|
||||
var entry_x := battle_center_x - direction * offset
|
||||
entry_x = clampf(entry_x, arena_min_x + 40.0, _front_area_max_x())
|
||||
spawn_minion(role, Vector2(spawn_x, ground_y), entry_x)
|
||||
|
||||
|
||||
## ------------------------------------------------------------------- clear
|
||||
|
||||
|
||||
func _clear_front_area() -> void:
|
||||
cleared = true
|
||||
var gate := get_node_or_null(gate_path)
|
||||
if gate != null and gate.has_method("unseal"):
|
||||
gate.call("unseal")
|
||||
front_area_cleared.emit()
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("front_area_cleared"):
|
||||
bus.emit_signal("front_area_cleared")
|
||||
|
||||
|
||||
## ------------------------------------------------------------------ lookup
|
||||
|
||||
|
||||
func _actors_container() -> Node:
|
||||
return get_node_or_null(actors_container_path)
|
||||
|
||||
|
||||
func _player_or_null() -> Node2D:
|
||||
var container := _actors_container()
|
||||
if container == null:
|
||||
return null
|
||||
return container.get_node_or_null("Player") as Node2D
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
|
||||
|
||||
func _front_area_spawn_position(spawn_position: Vector2) -> Vector2:
|
||||
return Vector2(clampf(spawn_position.x, arena_min_x + 40.0, _front_area_max_x()), spawn_position.y)
|
||||
|
||||
|
||||
func _configure_front_area_minion(minion: Node) -> void:
|
||||
var behavior := minion.get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
behavior.set("arena_min_x", arena_min_x)
|
||||
behavior.set("arena_max_x", _front_area_max_x())
|
||||
# 按职责区分基础攻击力(近战 50 / 远程 25,2026-07-05 定案)。
|
||||
var emitter := minion.get_node_or_null("DamageEmitter")
|
||||
if emitter != null:
|
||||
var is_melee := StringName(str(behavior.get("role"))) == &"melee"
|
||||
emitter.set("damage", melee_base_attack if is_melee else ranged_base_attack)
|
||||
if minion is Node2D:
|
||||
var actor := minion as Node2D
|
||||
actor.global_position = _front_area_spawn_position(actor.global_position)
|
||||
|
||||
|
||||
func _front_area_max_x() -> float:
|
||||
var max_x := arena_max_x
|
||||
var gate := get_node_or_null(gate_path)
|
||||
if gate != null:
|
||||
max_x = minf(max_x, float(gate.get("gate_x")) - boss_gate_minion_clearance)
|
||||
return maxf(arena_min_x + 80.0, max_x)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b834k71c0wvyu
|
||||
@@ -0,0 +1,183 @@
|
||||
class_name Stage
|
||||
extends Node2D
|
||||
|
||||
@export var use_authored_camera_view := false
|
||||
@export var camera_follow_offset := Vector2(0.0, -165.0)
|
||||
@export var camera_zoom := Vector2(1.25, 1.25)
|
||||
@export var past_world_tint := Color(1.0, 0.94, 0.82)
|
||||
@export var future_world_tint := Color(0.55, 0.72, 1.0)
|
||||
@export var time_phase_wipe_seconds := 0.5
|
||||
@export var time_phase_wipe_softness_px := 70.0
|
||||
|
||||
# blend_mul: inside the expanding circle the framebuffer stays as-is (the new
|
||||
# scene tint already applied world-wide); outside it is multiplied by
|
||||
# old_tint/new_tint, which reproduces the previous scene exactly. The rim
|
||||
# brightens so the old scene visibly "dissolves" outward from the player.
|
||||
const TIME_PHASE_WIPE_SHADER := """
|
||||
shader_type canvas_item;
|
||||
render_mode blend_mul;
|
||||
uniform vec2 center_px = vec2(640.0, 360.0);
|
||||
uniform float radius_px = 999999.0;
|
||||
uniform float softness_px = 70.0;
|
||||
uniform vec4 outside_tint = vec4(1.0);
|
||||
void fragment() {
|
||||
float dist = distance(FRAGCOORD.xy, center_px);
|
||||
float outside = smoothstep(radius_px - softness_px, radius_px + softness_px, dist);
|
||||
float rim = clamp(1.0 - abs(dist - radius_px) / max(softness_px * 1.6, 1.0), 0.0, 1.0);
|
||||
vec3 tint = mix(vec3(1.0), outside_tint.rgb, outside);
|
||||
COLOR = vec4(tint * (1.0 + rim * 0.55), 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
@onready var actors_container: Node2D = $ActorsContainer
|
||||
@onready var player: Node2D = $ActorsContainer/Player
|
||||
@onready var camera: Camera2D = $Camera2D
|
||||
@onready var boss_room_gate: Node = $BossRoomGate
|
||||
|
||||
var _world_modulate: CanvasModulate
|
||||
var _wipe_rect: ColorRect
|
||||
var _wipe_material: ShaderMaterial
|
||||
var _wipe_tween: Tween
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_priority = 100
|
||||
_configure_camera()
|
||||
_setup_time_phase_visuals()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
call_deferred("_update_camera_follow")
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
call_deferred("_update_camera_follow")
|
||||
if _wipe_rect != null and _wipe_rect.visible:
|
||||
_update_wipe_center()
|
||||
|
||||
|
||||
func get_player() -> Node:
|
||||
return player
|
||||
|
||||
|
||||
func _configure_camera() -> void:
|
||||
if camera == null:
|
||||
return
|
||||
camera.enabled = true
|
||||
if use_authored_camera_view:
|
||||
# WYSIWYG: the editor-authored position and zoom are the runtime view.
|
||||
camera.make_current()
|
||||
return
|
||||
camera.zoom = camera_zoom
|
||||
camera.make_current()
|
||||
_update_camera_follow()
|
||||
|
||||
|
||||
func _update_camera_follow() -> void:
|
||||
if use_authored_camera_view:
|
||||
return
|
||||
if camera == null or player == null:
|
||||
return
|
||||
var target_position := player.global_position + camera_follow_offset
|
||||
if _should_clamp_camera_before_boss_room():
|
||||
target_position.x = minf(target_position.x, _sealed_boss_room_camera_max_x())
|
||||
camera.global_position = target_position
|
||||
|
||||
|
||||
func _should_clamp_camera_before_boss_room() -> bool:
|
||||
return boss_room_gate != null and bool(boss_room_gate.get("sealed"))
|
||||
|
||||
|
||||
func _sealed_boss_room_camera_max_x() -> float:
|
||||
if boss_room_gate == null or camera == null:
|
||||
return INF
|
||||
var viewport_width := get_viewport_rect().size.x
|
||||
if viewport_width <= 0.0 or camera.zoom.x <= 0.0:
|
||||
return INF
|
||||
var boss_entry_x := float(boss_room_gate.get("gate_x")) + float(boss_room_gate.get("wall_thickness"))
|
||||
var half_view_width := viewport_width / (2.0 * camera.zoom.x)
|
||||
return boss_entry_x - half_view_width
|
||||
|
||||
|
||||
func _setup_time_phase_visuals() -> void:
|
||||
_world_modulate = get_node_or_null("TimePhaseWorldModulate") as CanvasModulate
|
||||
if _world_modulate == null:
|
||||
_world_modulate = CanvasModulate.new()
|
||||
_world_modulate.name = "TimePhaseWorldModulate"
|
||||
add_child(_world_modulate)
|
||||
var wipe_layer := CanvasLayer.new()
|
||||
wipe_layer.name = "TimePhaseWipeLayer"
|
||||
wipe_layer.layer = 1
|
||||
add_child(wipe_layer)
|
||||
var shader := Shader.new()
|
||||
shader.code = TIME_PHASE_WIPE_SHADER
|
||||
_wipe_material = ShaderMaterial.new()
|
||||
_wipe_material.shader = shader
|
||||
_wipe_rect = ColorRect.new()
|
||||
_wipe_rect.name = "TimePhaseWipe"
|
||||
_wipe_rect.color = Color.WHITE
|
||||
_wipe_rect.material = _wipe_material
|
||||
_wipe_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_wipe_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_wipe_rect.visible = false
|
||||
wipe_layer.add_child(_wipe_rect)
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
_world_modulate.color = _time_phase_tint(_current_time_phase())
|
||||
|
||||
|
||||
func _on_time_phase_changed(previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
var old_tint := _time_phase_tint(previous)
|
||||
var new_tint := _time_phase_tint(current)
|
||||
if _world_modulate != null:
|
||||
_world_modulate.color = new_tint
|
||||
_start_time_phase_wipe(old_tint, new_tint)
|
||||
|
||||
|
||||
func _time_phase_tint(time_phase: StringName) -> Color:
|
||||
return future_world_tint if time_phase == &"future" else past_world_tint
|
||||
|
||||
|
||||
func _start_time_phase_wipe(old_tint: Color, new_tint: Color) -> void:
|
||||
if _wipe_rect == null or _wipe_material == null:
|
||||
return
|
||||
if _wipe_tween != null and _wipe_tween.is_valid():
|
||||
_wipe_tween.kill()
|
||||
var ratio := Color(
|
||||
clampf(old_tint.r / maxf(0.05, new_tint.r), 0.05, 3.0),
|
||||
clampf(old_tint.g / maxf(0.05, new_tint.g), 0.05, 3.0),
|
||||
clampf(old_tint.b / maxf(0.05, new_tint.b), 0.05, 3.0),
|
||||
1.0
|
||||
)
|
||||
_wipe_material.set_shader_parameter("outside_tint", ratio)
|
||||
_wipe_material.set_shader_parameter("softness_px", time_phase_wipe_softness_px)
|
||||
_wipe_material.set_shader_parameter("radius_px", 0.0)
|
||||
_update_wipe_center()
|
||||
_wipe_rect.visible = true
|
||||
var max_radius := get_viewport_rect().size.length() + time_phase_wipe_softness_px * 2.0
|
||||
_wipe_tween = create_tween()
|
||||
_wipe_tween.tween_property(_wipe_material, "shader_parameter/radius_px", max_radius, maxf(0.1, time_phase_wipe_seconds))
|
||||
_wipe_tween.tween_callback(func() -> void:
|
||||
_wipe_rect.visible = false
|
||||
)
|
||||
|
||||
|
||||
func _update_wipe_center() -> void:
|
||||
if _wipe_material == null or player == null or not is_instance_valid(player):
|
||||
return
|
||||
var focus := player.get_global_transform_with_canvas().origin + Vector2(0.0, -60.0)
|
||||
_wipe_material.set_shader_parameter("center_px", focus)
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8xnk16036wyv
|
||||
@@ -0,0 +1,59 @@
|
||||
[gd_scene load_steps=11 format=3 uid="uid://dimgym86pdqmd"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c8xnk16036wyv" path="res://scenes/stage/stage.gd" id="1_stage"]
|
||||
[ext_resource type="Script" uid="uid://bs318qe54putv" path="res://scenes/stage/actors_container.gd" id="2_actors"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ground/ground1.tscn" id="3_ground"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="4_player"]
|
||||
[ext_resource type="PackedScene" uid="uid://bnoju71qm1xh7" path="res://scenes/enemies/boss.tscn" id="5_boss"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/enemies/minion.tscn" id="8_minion"]
|
||||
[ext_resource type="Resource" path="res://resources/time_phase/profile_past_strong_enemy.tres" id="9_past_strong"]
|
||||
[ext_resource type="Script" path="res://scenes/stage/boss_room_gate.gd" id="11_gate"]
|
||||
[ext_resource type="Script" path="res://scenes/stage/level_director.gd" id="12_director"]
|
||||
|
||||
[node name="Stage" type="Node2D" unique_id=1175276098]
|
||||
script = ExtResource("1_stage")
|
||||
camera_follow_offset = Vector2(0, -165)
|
||||
camera_zoom = Vector2(1.25, 1.25)
|
||||
future_world_tint = Color(0.72, 0.84, 1, 1)
|
||||
|
||||
[node name="TimePhaseWorldModulate" type="CanvasModulate" parent="." unique_id=57917128]
|
||||
color = Color(1, 0.94, 0.82, 1)
|
||||
|
||||
[node name="Ground" parent="." unique_id=819592526 instance=ExtResource("3_ground")]
|
||||
|
||||
[node name="BossRoomGate" type="Node2D" parent="."]
|
||||
script = ExtResource("11_gate")
|
||||
gate_x = 2460.0
|
||||
floor_y = 560.0
|
||||
|
||||
[node name="LevelDirector" type="Node" parent="."]
|
||||
script = ExtResource("12_director")
|
||||
ground_y = 560.0
|
||||
left_edge_x = 1420.0
|
||||
right_edge_x = 2240.0
|
||||
battle_center_x = 2047.0
|
||||
arena_min_x = 1120.0
|
||||
arena_max_x = 2240.0
|
||||
|
||||
[node name="ActorsContainer" type="Node2D" parent="." unique_id=833422275]
|
||||
script = ExtResource("2_actors")
|
||||
|
||||
[node name="Player" parent="ActorsContainer" unique_id=16275280 instance=ExtResource("4_player")]
|
||||
position = Vector2(1180, 560)
|
||||
|
||||
[node name="JinZhanMinion" parent="ActorsContainer" unique_id=1373552042 instance=ExtResource("8_minion")]
|
||||
position = Vector2(2200, 560)
|
||||
time_phase_profile = ExtResource("9_past_strong")
|
||||
|
||||
[node name="Boss" parent="ActorsContainer" unique_id=319185885 instance=ExtResource("5_boss")]
|
||||
position = Vector2(2520, 560)
|
||||
stationary = true
|
||||
combat_enabled = false
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="." unique_id=539412778]
|
||||
position = Vector2(1180, 395)
|
||||
zoom = Vector2(1.25, 1.25)
|
||||
limit_left = 880
|
||||
limit_top = -420
|
||||
limit_right = 3214
|
||||
limit_bottom = 860
|
||||
@@ -0,0 +1,132 @@
|
||||
class_name ComboWindowHud
|
||||
extends Control
|
||||
|
||||
@export var slot_count := 4
|
||||
|
||||
const FRAME_TEXTURE := preload("res://assets/ui/combo/four_slot_judgement_frame.png")
|
||||
const FRAME_SIZE := Vector2(255.0, 51.0)
|
||||
const SLOT_SIZE := Vector2(50.0, 51.0)
|
||||
const SLOT_X := [0.0, 68.0, 137.0, 205.0]
|
||||
|
||||
var frame: TextureRect
|
||||
var panels: Array[PanelContainer] = []
|
||||
var labels: Array[Label] = []
|
||||
var clear_tween: Tween
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = FRAME_SIZE
|
||||
size = FRAME_SIZE
|
||||
_build_slots()
|
||||
var bus := _event_bus()
|
||||
bus.connect("combo_updated", refresh)
|
||||
bus.connect("combo_cleared", _on_combo_cleared)
|
||||
|
||||
|
||||
func refresh(inputs: Array) -> void:
|
||||
if labels.is_empty():
|
||||
_build_slots()
|
||||
for index: int in range(labels.size()):
|
||||
var filled := index < inputs.size()
|
||||
labels[index].text = _slot_text(inputs[index]) if filled else "∅"
|
||||
labels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.32)
|
||||
panels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.72)
|
||||
if filled:
|
||||
_pulse_slot(panels[index])
|
||||
|
||||
|
||||
func _on_combo_cleared(_reason: StringName) -> void:
|
||||
refresh([])
|
||||
_flash_clear()
|
||||
|
||||
|
||||
func _build_slots() -> void:
|
||||
if not labels.is_empty():
|
||||
return
|
||||
frame = TextureRect.new()
|
||||
frame.name = "SlotFrame"
|
||||
frame.texture = FRAME_TEXTURE
|
||||
frame.modulate = Color(1.0, 1.0, 1.0, 0.7)
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
frame.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
frame.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
frame.position = Vector2.ZERO
|
||||
frame.size = FRAME_SIZE
|
||||
add_child(frame)
|
||||
for index: int in range(slot_count):
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Slot%d" % (index + 1)
|
||||
panel.position = Vector2(float(SLOT_X[index]), 0.0)
|
||||
panel.size = SLOT_SIZE
|
||||
panel.custom_minimum_size = SLOT_SIZE
|
||||
panel.pivot_offset = SLOT_SIZE * 0.5
|
||||
panel.modulate = Color(1.0, 1.0, 1.0, 0.72)
|
||||
panel.add_theme_stylebox_override("panel", _make_slot_style())
|
||||
var label := Label.new()
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.text = "∅"
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_color_override("font_color", Color(0.94, 0.98, 1.0, 1.0))
|
||||
label.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.9))
|
||||
label.add_theme_constant_override("shadow_offset_x", 2)
|
||||
label.add_theme_constant_override("shadow_offset_y", 2)
|
||||
label.add_theme_font_size_override("font_size", 25)
|
||||
panel.add_child(label)
|
||||
add_child(panel)
|
||||
panels.append(panel)
|
||||
labels.append(label)
|
||||
|
||||
|
||||
func _slot_text(value: Variant) -> String:
|
||||
var symbol := StringName(str(value))
|
||||
match symbol:
|
||||
&"SP":
|
||||
return "sp"
|
||||
&"Ø":
|
||||
return "∅"
|
||||
return str(symbol)
|
||||
|
||||
|
||||
func _pulse_slot(panel: PanelContainer) -> void:
|
||||
var tween := create_tween()
|
||||
panel.scale = Vector2(1.08, 1.08)
|
||||
tween.tween_property(panel, "scale", Vector2.ONE, 0.09)
|
||||
|
||||
|
||||
func _flash_clear() -> void:
|
||||
if clear_tween != null and clear_tween.is_valid():
|
||||
clear_tween.kill()
|
||||
clear_tween = create_tween()
|
||||
clear_tween.set_parallel(true)
|
||||
for panel: PanelContainer in panels:
|
||||
panel.scale = Vector2(1.16, 1.16)
|
||||
panel.modulate = Color(1.0, 1.0, 1.0, 1.0)
|
||||
clear_tween.tween_property(panel, "scale", Vector2.ONE, 0.20)
|
||||
clear_tween.tween_property(panel, "modulate", Color(1.0, 1.0, 1.0, 0.72), 0.20)
|
||||
|
||||
|
||||
func _make_slot_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.content_margin_left = 4.0
|
||||
style.content_margin_top = 2.0
|
||||
style.content_margin_right = 4.0
|
||||
style.content_margin_bottom = 2.0
|
||||
style.bg_color = Color.TRANSPARENT
|
||||
style.border_width_left = 0
|
||||
style.border_width_top = 0
|
||||
style.border_width_right = 0
|
||||
style.border_width_bottom = 0
|
||||
return style
|
||||
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user