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
+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