Initial project sync

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