Initial project sync
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
class_name Boss
|
||||
extends Character
|
||||
|
||||
const DEFAULT_HEADING := Vector2.LEFT
|
||||
const VISUAL_SCALE := 2.0
|
||||
const DEFAULT_ANIMATION_FPS := 12.0
|
||||
|
||||
const BOSS_ANIMATION_SPECS := {
|
||||
&"boss_idle": {"path": "res://assets/art/characters/boss/boss_idle.png", "frames": 1, "fps": 4.0, "loop": true},
|
||||
&"boss_run": {"path": "res://assets/art/characters/boss/boss_run.png", "frames": 8, "fps": 12.0, "loop": true},
|
||||
&"boss_dash": {"path": "res://assets/art/characters/boss/boss_dash.png", "frames": 6, "fps": 14.0, "loop": false},
|
||||
&"boss_jump_fall": {"path": "res://assets/art/characters/boss/boss_jump_fall.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_take_hit": {"path": "res://assets/art/characters/boss/boss_take_hit.png", "frames": 1, "fps": 8.0, "loop": false},
|
||||
&"boss_die": {"path": "res://assets/art/characters/boss/boss_die.png", "frames": 4, "fps": 8.0, "loop": false},
|
||||
&"boss_ground_combo_1": {"path": "res://assets/art/characters/boss/boss_ground_combo_1.png", "frames": 8, "fps": 14.0, "loop": false},
|
||||
&"boss_ground_combo_2": {"path": "res://assets/art/characters/boss/boss_ground_combo_2.png", "frames": 10, "fps": 14.0, "loop": false},
|
||||
&"boss_ground_combo_3": {"path": "res://assets/art/characters/boss/boss_ground_combo_3.png", "frames": 14, "fps": 14.0, "loop": false},
|
||||
&"boss_lunging_stab": {"path": "res://assets/art/characters/boss/boss_lunging_stab.png", "frames": 15, "fps": 15.0, "loop": false},
|
||||
&"boss_shoot_1": {"path": "res://assets/art/characters/boss/boss_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_shoot_2": {"path": "res://assets/art/characters/boss/boss_shoot_2.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
&"boss_2way_shoot": {"path": "res://assets/art/characters/boss/boss_2way_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
|
||||
}
|
||||
|
||||
@export var max_health := 28000
|
||||
@export var current_health := 28000
|
||||
## Training-dummy mode: the boss still attacks (BT + chart events) but never
|
||||
## displaces — no walk/dash/retreat/lunge motion and no hit-chain escape.
|
||||
@export var stationary := false
|
||||
@export var combat_enabled := true:
|
||||
set(value):
|
||||
combat_enabled = value
|
||||
_sync_combat_collision()
|
||||
@export var visual_ground_offset := -40.0
|
||||
@export var boss_lunge_speed := 150.0
|
||||
@export var projectile_spawn_forward := 70.0
|
||||
@export var projectile_spawn_height := 74.0
|
||||
@export var escape_after_consecutive_hits := 4
|
||||
@export var hit_chain_window_seconds := 1.2
|
||||
@export var escape_cooldown_seconds := 4.0
|
||||
|
||||
@onready var state_machine: Node = get_node_or_null("StateMachine")
|
||||
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
|
||||
@onready var action_controller: Node = get_node_or_null("ActionController")
|
||||
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
|
||||
@onready var health_component: Node = get_node_or_null("HealthComponent")
|
||||
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
|
||||
@onready var frame_collision_driver: Node = get_node_or_null("FrameCollisionDriver")
|
||||
|
||||
var _current_action_animation: StringName = &""
|
||||
var _frame_delta := 0.0
|
||||
var _dead_state_entered := false
|
||||
var _death_animation_started := false
|
||||
var _hit_chain_count := 0
|
||||
var _hit_chain_time_left := 0.0
|
||||
var _escape_cooldown_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
heading = DEFAULT_HEADING
|
||||
anim_map = {
|
||||
PRESENTATION_IDLE: "boss_idle",
|
||||
PRESENTATION_WALK: "boss_run",
|
||||
PRESENTATION_JUMP: "boss_jump_fall",
|
||||
PRESENTATION_LAND: "boss_idle",
|
||||
PRESENTATION_ATTACK: "boss_ground_combo_1",
|
||||
PRESENTATION_AIR_ATTACK: "boss_ground_combo_1",
|
||||
}
|
||||
_install_boss_animations()
|
||||
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
|
||||
_wire_action_controller()
|
||||
_wire_damage_receiver()
|
||||
if health_component != null and health_component.has_method("set_values"):
|
||||
health_component.call("set_values", current_health, max_health)
|
||||
_sync_combat_collision()
|
||||
state = PRESENTATION_IDLE
|
||||
_play_animation(&"boss_idle")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_frame_delta = delta
|
||||
_tick_hit_chain(delta)
|
||||
super._physics_process(delta)
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## 2026-07-05 定案:Boss 对不消耗能量的攻击恒霸体——伤害照常结算,但不被
|
||||
## 打断、不被击退;只有耗能技能(base_cost > 0)能压制 Boss。
|
||||
func shrugs_off_hit(action: Resource) -> bool:
|
||||
if action == null:
|
||||
return true
|
||||
return float(action.get("base_cost")) <= 0.0
|
||||
|
||||
|
||||
## 2026-07-05 策划两轮调参:×3 后仍嫌短,再 ×3 → 距离 ×9(初速 ×3)。
|
||||
## 只放大水平滑行,击飞高度不变;换算由 CombatManager 负责(距离 ∝ 初速²)。
|
||||
func knockback_distance_taken_mult() -> float:
|
||||
return 9.0
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
if movement_motor != null and movement_motor.has_method("handle_air_time"):
|
||||
movement_motor.call("handle_air_time", delta)
|
||||
else:
|
||||
super.handle_air_time(delta)
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if motion_executor != null and bool(motion_executor.get("active")):
|
||||
velocity = motion_executor.call("tick", _frame_delta)
|
||||
return
|
||||
if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
# 击退残速期不清零,交给 MovementMotor 的摩擦衰减自然滑行——否则击退
|
||||
# 初速活不过一帧,Boss 的水平击退位移恒为 0。
|
||||
if not (movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray"))):
|
||||
velocity.x = 0.0
|
||||
if movement_motor != null and movement_motor.has_method("handle_movement"):
|
||||
movement_motor.call("handle_movement")
|
||||
else:
|
||||
super.handle_movement()
|
||||
|
||||
|
||||
func handle_animations() -> void:
|
||||
var life_state := _life_state()
|
||||
if life_state == &"Dead":
|
||||
_enter_dead_state()
|
||||
_play_death_animation_once()
|
||||
_queue_free_if_death_animation_finished()
|
||||
return
|
||||
_dead_state_entered = false
|
||||
_death_animation_started = false
|
||||
if life_state == &"Hitstun":
|
||||
_play_animation(&"boss_take_hit")
|
||||
return
|
||||
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
|
||||
_play_animation(_current_action_animation)
|
||||
return
|
||||
super.handle_animations()
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_sprite_height_position() -> void:
|
||||
# Keep the editor-authored ground offset at runtime (WYSIWYG): without this
|
||||
# override the Character base resets Visual to (0, -height) every physics
|
||||
# tick and the boss art jumps 32px above its authored position.
|
||||
if visual != null:
|
||||
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
|
||||
|
||||
|
||||
func flip_sprites() -> void:
|
||||
if visual == null:
|
||||
return
|
||||
visual.scale.x = -VISUAL_SCALE if heading == Vector2.LEFT else VISUAL_SCALE
|
||||
visual.scale.y = VISUAL_SCALE
|
||||
|
||||
|
||||
func look_at_target(target: Node2D) -> void:
|
||||
if target == null:
|
||||
return
|
||||
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
|
||||
|
||||
|
||||
func projectile_spawn_position(_action: Resource) -> Vector2:
|
||||
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -projectile_spawn_height)
|
||||
|
||||
|
||||
func projectile_direction(_action: Resource) -> Vector2:
|
||||
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
|
||||
|
||||
|
||||
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
|
||||
if action != null and StringName(str(action.get("id"))) == &"boss_2way_shoot":
|
||||
return [
|
||||
{
|
||||
"spawn_position": global_position + Vector2(-projectile_spawn_forward, -projectile_spawn_height),
|
||||
"direction": Vector2.LEFT,
|
||||
},
|
||||
{
|
||||
"spawn_position": global_position + Vector2(projectile_spawn_forward, -projectile_spawn_height),
|
||||
"direction": Vector2.RIGHT,
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
"spawn_position": projectile_spawn_position(action),
|
||||
"direction": projectile_direction(action),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func is_strong_in_current_time_phase() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func can_start_enemy_action(action_id: StringName) -> bool:
|
||||
if not combat_enabled:
|
||||
return false
|
||||
# 击退滑行(趔趄)期间不得起手新动作:动作自带的突进位移会立刻接管
|
||||
# velocity 并冲回玩家,把击退效果整个吃掉(实测净位移为负)。
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
return false
|
||||
var behavior := get_node_or_null("BossBehaviorTree")
|
||||
if behavior != null and behavior.has_method("phase_allows_action"):
|
||||
return bool(behavior.call("phase_allows_action", action_id))
|
||||
return true
|
||||
|
||||
|
||||
func _sync_combat_collision() -> void:
|
||||
var driver := frame_collision_driver
|
||||
if driver == null:
|
||||
driver = get_node_or_null("FrameCollisionDriver")
|
||||
if driver != null:
|
||||
driver.set("body_collision_enabled", combat_enabled)
|
||||
driver.set("damage_receiver_enabled", combat_enabled)
|
||||
driver.set("damage_emitter_enabled", combat_enabled)
|
||||
if driver.has_method("refresh_now"):
|
||||
driver.call("refresh_now")
|
||||
if combat_enabled:
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
if tree.has_method("reset_for_combat_entry"):
|
||||
tree.call("reset_for_combat_entry")
|
||||
else:
|
||||
tree.set("enabled", true)
|
||||
|
||||
|
||||
func _install_boss_animations() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
var library: AnimationLibrary
|
||||
if animation_player.has_animation_library(""):
|
||||
library = animation_player.get_animation_library("")
|
||||
else:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
var base_sprite_offset := character_sprite.offset if character_sprite != null else Vector2(-40.0, -47.0)
|
||||
var idle_anchor := _visible_bottom_anchor_for_spec(BOSS_ANIMATION_SPECS.get(&"boss_idle", {}), base_sprite_offset)
|
||||
for animation_name: StringName in BOSS_ANIMATION_SPECS:
|
||||
if library.has_animation(animation_name):
|
||||
continue
|
||||
var spec: Dictionary = BOSS_ANIMATION_SPECS[animation_name]
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
continue
|
||||
var align_anchor := idle_anchor if animation_name == &"boss_die" else INF
|
||||
library.add_animation(animation_name, _make_sheet_animation(texture, int(spec.get("frames", 1)), float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), align_anchor, base_sprite_offset))
|
||||
|
||||
|
||||
func _make_sheet_animation(texture: Texture2D, frame_count: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
|
||||
var safe_frame_count := maxi(1, frame_count)
|
||||
var safe_fps := maxf(1.0, fps)
|
||||
var frame_time := 1.0 / safe_fps
|
||||
var animation := Animation.new()
|
||||
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
|
||||
animation.step = frame_time
|
||||
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
|
||||
var texture_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
|
||||
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(texture_track, 0.0, texture)
|
||||
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
|
||||
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(hframes_track, 0.0, safe_frame_count)
|
||||
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
|
||||
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(vframes_track, 0.0, 1)
|
||||
var frame_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
|
||||
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
|
||||
if visible_bottom_anchor < INF:
|
||||
var offset_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
|
||||
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
var offset := sprite_offset
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, safe_frame_count, 1, frame_index)
|
||||
if bottom != INF:
|
||||
offset.y = visible_bottom_anchor - bottom
|
||||
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
|
||||
var fx_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(fx_track, NodePath("Visual/FxOverlay:visible"))
|
||||
animation.value_track_set_update_mode(fx_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(fx_track, 0.0, false)
|
||||
return animation
|
||||
|
||||
|
||||
func _wire_action_controller() -> void:
|
||||
if action_controller == null:
|
||||
return
|
||||
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
|
||||
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
|
||||
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
|
||||
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
|
||||
|
||||
|
||||
func _wire_damage_receiver() -> void:
|
||||
if damage_receiver == null:
|
||||
return
|
||||
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
|
||||
|
||||
|
||||
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||
if not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_face_target_if_available()
|
||||
_current_action_animation = StringName(str(action.get("animation")))
|
||||
state = PRESENTATION_ATTACK
|
||||
attack_time_left = _action_duration_seconds(action) + 0.05
|
||||
attack_lunge_time_left = attack_time_left
|
||||
_align_action_animation_speed(action)
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
_play_animation(_current_action_animation)
|
||||
|
||||
|
||||
func _on_action_active_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
if stationary:
|
||||
return
|
||||
if motion_executor == null:
|
||||
return
|
||||
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
|
||||
return
|
||||
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
|
||||
motion_executor.call("execute", action, _motion_direction_for_action(action), _beat_time(), boss_lunge_speed * speed_scale)
|
||||
|
||||
|
||||
func _on_action_finished(_action: Resource) -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
|
||||
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
|
||||
func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if amount <= 0 or _life_state() == &"Dead":
|
||||
return
|
||||
# 2026-07-05 定案:霸体下的普攻不打断 Boss,但照样计入受击链——
|
||||
# "被连打 4 次后撤逃脱"仍是防贴脸无脑磨血的兜底。
|
||||
if _record_consecutive_hit_and_should_escape() and _can_escape_now():
|
||||
if _trigger_hit_chain_escape():
|
||||
return
|
||||
if _life_state() == &"Hitstun":
|
||||
_play_animation(&"boss_take_hit")
|
||||
|
||||
|
||||
func _action_duration_seconds(action: Resource) -> float:
|
||||
if action == null:
|
||||
return attack_duration
|
||||
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
|
||||
|
||||
|
||||
func _align_action_animation_speed(action: Resource) -> void:
|
||||
if animation_player == null or action == null or _current_action_animation.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(_current_action_animation):
|
||||
return
|
||||
var natural_length := animation_player.get_animation(_current_action_animation).length
|
||||
var action_seconds := _action_duration_seconds(action)
|
||||
if natural_length <= 0.0 or action_seconds <= 0.0:
|
||||
return
|
||||
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
|
||||
|
||||
|
||||
func _reset_animation_speed() -> void:
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
func _beat_time() -> float:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null:
|
||||
return float(rhythm.get("beat_time"))
|
||||
return 0.5
|
||||
|
||||
|
||||
func _face_target_if_available() -> void:
|
||||
var target := _target_or_null()
|
||||
if target != null:
|
||||
look_at_target(target)
|
||||
|
||||
|
||||
func _target_or_null() -> Node2D:
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
var tree_target = tree.get("target")
|
||||
if tree_target is Node2D and is_instance_valid(tree_target):
|
||||
return tree_target
|
||||
var container := get_parent()
|
||||
if container != null:
|
||||
var sibling := container.get_node_or_null("Player") as Node2D
|
||||
if sibling != null:
|
||||
return sibling
|
||||
return null
|
||||
|
||||
|
||||
func _tick_hit_chain(delta: float) -> void:
|
||||
_escape_cooldown_left = maxf(0.0, _escape_cooldown_left - delta)
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
return
|
||||
_hit_chain_time_left = maxf(0.0, _hit_chain_time_left - delta)
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
_hit_chain_count = 0
|
||||
|
||||
|
||||
func _can_escape_now() -> bool:
|
||||
if stationary:
|
||||
return false
|
||||
if _escape_cooldown_left > 0.0:
|
||||
return false
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
if StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded"))) != &"Grounded":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _record_consecutive_hit_and_should_escape() -> bool:
|
||||
if _hit_chain_time_left <= 0.0:
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_count += 1
|
||||
_hit_chain_time_left = maxf(0.01, hit_chain_window_seconds)
|
||||
return _hit_chain_count >= maxi(1, escape_after_consecutive_hits)
|
||||
|
||||
|
||||
func _trigger_hit_chain_escape() -> bool:
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_time_left = 0.0
|
||||
_escape_cooldown_left = maxf(0.0, escape_cooldown_seconds)
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
# 强制撤退压倒击退滑行:清掉残速标志,否则 can_start_enemy_action 的
|
||||
# 趔趄门禁会拦下 boss_retreat_dash,受击链逃脱失效。
|
||||
if movement_motor != null and movement_motor.has_method("clear_knockback_stray"):
|
||||
movement_motor.call("clear_knockback_stray")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if action_controller != null:
|
||||
if action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"hit_escape")
|
||||
elif action_controller.has_method("_reset_to_idle"):
|
||||
action_controller.call("_reset_to_idle")
|
||||
if state_machine != null:
|
||||
if state_machine.has_method("set_life_state"):
|
||||
state_machine.call("set_life_state", &"Alive")
|
||||
if state_machine.has_method("set_ground_state"):
|
||||
state_machine.call("set_ground_state", &"Grounded")
|
||||
if state_machine.has_method("set_action_phase"):
|
||||
state_machine.call("set_action_phase", &"Neutral")
|
||||
_face_target_if_available()
|
||||
var driver := get_node_or_null("EnemyActionDriver")
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", &"boss_retreat_dash")
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _heading_sign() -> float:
|
||||
return -1.0 if heading.x < 0.0 else 1.0
|
||||
|
||||
|
||||
func _enter_dead_state() -> void:
|
||||
if _dead_state_entered:
|
||||
return
|
||||
_dead_state_entered = true
|
||||
_hit_chain_count = 0
|
||||
_hit_chain_time_left = 0.0
|
||||
_reset_animation_speed()
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"death")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
var damage_emitter := get_node_or_null("DamageEmitter")
|
||||
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
|
||||
damage_emitter.call("clear_hit")
|
||||
var receiver := get_node_or_null("DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
var tree := get_node_or_null("BossBehaviorTree")
|
||||
if tree != null:
|
||||
tree.set("enabled", false)
|
||||
|
||||
|
||||
func _play_death_animation_once() -> void:
|
||||
if _death_animation_started:
|
||||
return
|
||||
_death_animation_started = true
|
||||
if animation_player != null and animation_player.has_animation(&"boss_die"):
|
||||
animation_player.play(&"boss_die")
|
||||
|
||||
|
||||
func _on_animation_finished(animation_name: StringName) -> void:
|
||||
if _death_animation_started and animation_name == &"boss_die":
|
||||
queue_free()
|
||||
|
||||
|
||||
func _queue_free_if_death_animation_finished() -> void:
|
||||
if not _death_animation_started or is_queued_for_deletion():
|
||||
return
|
||||
if animation_player == null:
|
||||
queue_free()
|
||||
return
|
||||
if not animation_player.is_playing():
|
||||
queue_free()
|
||||
|
||||
|
||||
func _visible_bottom_anchor_for_spec(spec: Dictionary, sprite_offset: Vector2) -> float:
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
return INF
|
||||
var frame_count := int(spec.get("frames", 1))
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, frame_count), 1, 0)
|
||||
if bottom == INF:
|
||||
return INF
|
||||
return sprite_offset.y + bottom
|
||||
|
||||
|
||||
func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float:
|
||||
if texture == null:
|
||||
return INF
|
||||
var image := texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
return INF
|
||||
var safe_hframes := maxi(1, hframes)
|
||||
var safe_vframes := maxi(1, vframes)
|
||||
var frame_width := image.get_width() / safe_hframes
|
||||
var frame_height := image.get_height() / safe_vframes
|
||||
var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1)
|
||||
var column := safe_frame % safe_hframes
|
||||
var row := int(safe_frame / safe_hframes)
|
||||
var bottom := -1
|
||||
for y: int in range(frame_height):
|
||||
for x: int in range(frame_width):
|
||||
var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y)
|
||||
if pixel.a > 0.01:
|
||||
bottom = y
|
||||
if bottom < 0:
|
||||
return INF
|
||||
return float(bottom)
|
||||
|
||||
|
||||
func _motion_direction_for_action(action: Resource) -> Vector2:
|
||||
if _has_action_tag(action, &"retreat"):
|
||||
return -heading
|
||||
return heading
|
||||
|
||||
|
||||
func _has_action_tag(action: Resource, tag: StringName) -> bool:
|
||||
if action == null:
|
||||
return false
|
||||
var tags: Array = action.get("action_tags")
|
||||
for item: Variant in tags:
|
||||
if StringName(str(item)) == tag:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _play_animation(animation_name: StringName) -> void:
|
||||
if animation_player == null or animation_name.is_empty():
|
||||
return
|
||||
if animation_player.has_animation(animation_name) and animation_player.current_animation != String(animation_name):
|
||||
animation_player.play(animation_name)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ijm7vma4w324
|
||||
@@ -0,0 +1,124 @@
|
||||
[gd_scene format=3 uid="uid://bnoju71qm1xh7"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/enemies/boss.gd" id="1_boss"]
|
||||
[ext_resource type="Texture2D" uid="uid://b2x7g4eslsmbd" path="res://assets/art/characters/boss/boss_idle.png" id="2_idle"]
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="3_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="4_movement_motor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="5_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="6_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="7_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="8_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="9_motion_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="10_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="11_health_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="12_damage_receiver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="13_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="14_enemy_action_driver"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/boss_behavior_tree.gd" id="15_boss_behavior_tree"]
|
||||
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(24, 58)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
|
||||
size = Vector2(28, 62)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(80, 42)
|
||||
|
||||
[node name="Boss" type="CharacterBody2D" unique_id=11104068 groups=["bosses"]]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
script = ExtResource("1_boss")
|
||||
speed = 120.0
|
||||
attack_lunge_speed = 150.0
|
||||
|
||||
[node name="Visual" type="Node2D" parent="." unique_id=1328075337]
|
||||
scale = Vector2(2, 2)
|
||||
|
||||
[node name="CharacterSprite" type="Sprite2D" parent="Visual" unique_id=1061052547]
|
||||
texture_filter = 2
|
||||
texture = ExtResource("2_idle")
|
||||
centered = false
|
||||
offset = Vector2(-40, -47)
|
||||
|
||||
[node name="FxOverlay" type="Sprite2D" parent="Visual" unique_id=1946927846]
|
||||
visible = false
|
||||
texture_filter = 2
|
||||
z_index = 2
|
||||
centered = false
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1147475571]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1741182662]
|
||||
|
||||
[node name="StateMachine" type="Node" parent="." unique_id=1244013726]
|
||||
script = ExtResource("3_state_machine")
|
||||
|
||||
[node name="MovementMotor" type="Node" parent="." unique_id=1623369341]
|
||||
script = ExtResource("4_movement_motor")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="." unique_id=689578719]
|
||||
script = ExtResource("5_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="." unique_id=275892237]
|
||||
script = ExtResource("6_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="." unique_id=1902183099]
|
||||
script = ExtResource("7_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="." unique_id=163263912]
|
||||
script = ExtResource("8_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
|
||||
[node name="MotionExecutor" type="Node" parent="." unique_id=1554747471]
|
||||
script = ExtResource("9_motion_executor")
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="." unique_id=214753755]
|
||||
script = ExtResource("10_effect_container")
|
||||
|
||||
[node name="HealthComponent" type="Node" parent="." unique_id=363838105]
|
||||
script = ExtResource("11_health_component")
|
||||
maximum = 28000
|
||||
current = 28000
|
||||
|
||||
[node name="DamageReceiver" type="Area2D" parent="." unique_id=1741852259]
|
||||
collision_layer = 4
|
||||
collision_mask = 8
|
||||
script = ExtResource("12_damage_receiver")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver" unique_id=459196080]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_hurtbox")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="." unique_id=1856891186]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("13_damage_emitter")
|
||||
damage = 30
|
||||
base_knockback = Vector2(360, 304.056)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter" unique_id=2047760774]
|
||||
position = Vector2(0, -70)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="." unique_id=1172727978]
|
||||
script = ExtResource("14_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
|
||||
[node name="BossBehaviorTree" type="Node" parent="." unique_id=1218241436]
|
||||
script = ExtResource("15_boss_behavior_tree")
|
||||
target_path = NodePath("../../Player")
|
||||
|
||||
[node name="FrameCollisionDriver" type="Node" parent="."]
|
||||
script = ExtResource("18_frame_collision")
|
||||
@@ -0,0 +1,408 @@
|
||||
class_name BossBehaviorTree
|
||||
extends Node
|
||||
|
||||
@export var enabled := true
|
||||
@export var target_path: NodePath
|
||||
@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver")
|
||||
@export var action_controller_path: NodePath = NodePath("../ActionController")
|
||||
@export var health_component_path: NodePath = NodePath("../HealthComponent")
|
||||
## new3 §6.2:近战相位攻击距离 180。
|
||||
@export var melee_distance := 180.0
|
||||
@export var ranged_distance := 360.0
|
||||
@export var dash_distance := 460.0
|
||||
## new3 §6.7:远程相位下玩家进入该距离触发后撤。
|
||||
@export var retreat_trigger_distance := 240.0
|
||||
@export var decision_interval_beats := 2.0
|
||||
## new3 §6.4:相位切换后短暂停顿(跳过的决策拍数,1 拍 ≈ 0.46s)。
|
||||
@export var phase_switch_pause_beats := 1
|
||||
## §18.1 Boss 相位差异:Past 出招慢(重击),Future 出招快(轻击、频率高)。
|
||||
## 只缩放决策节拍,不改 HP/防御等核心数值。
|
||||
@export var past_decision_interval_scale := 1.0
|
||||
@export var future_decision_interval_scale := 0.5
|
||||
## §18.2: preparing a special attack raises a conditional TimeAnchorEvent.
|
||||
@export var special_attack_anchor_lead_beats := 2
|
||||
@export var conditional_anchor_actions: Array[StringName] = [&"boss_lunging_stab", &"boss_2way_shoot"]
|
||||
@export var melee_combo_cooldown_beats := 4.0
|
||||
@export var retreat_cooldown_beats := 8.0
|
||||
@export var stab_cooldown_beats := 16.0
|
||||
@export var two_way_cooldown_beats := 8.0
|
||||
@export var dash_cooldown_beats := 4.0
|
||||
@export var low_health_ratio := 0.35
|
||||
@export var restrict_actions_by_time_phase := true
|
||||
|
||||
@onready var driver: Node = get_node_or_null(enemy_action_driver_path)
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var target: Node2D = get_node_or_null(target_path) as Node2D
|
||||
|
||||
var _beat_cursor := 0.0
|
||||
var _next_decision_beat := 0
|
||||
var _last_seen_beat := 0
|
||||
var _combo_index := 0
|
||||
var _ranged_index := 0
|
||||
var _force_ranged_after_retreat := false
|
||||
var _last_melee_beat := -999.0
|
||||
var _last_retreat_beat := -999.0
|
||||
var _last_stab_beat := -999.0
|
||||
var _last_two_way_beat := -999.0
|
||||
var _last_dash_beat := -999.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_connect_beat_bus()
|
||||
_connect_chart_bus()
|
||||
_connect_time_phase_bus()
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
func _connect_time_phase_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
|
||||
|
||||
## new3 §6.4:相位切换时不立刻攻击——先停顿、调整距离,再进入新相位循环。
|
||||
## 以总线上最后看到的拍号为基准(而非全局时钟),测试的合成拍号同样适用。
|
||||
func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void:
|
||||
_next_decision_beat = maxi(_next_decision_beat, _last_seen_beat + 1 + maxi(0, phase_switch_pause_beats))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus == null:
|
||||
return
|
||||
if bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
if bus.has_signal("chart_event_upcoming") and bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
|
||||
bus.disconnect("chart_event_upcoming", _on_chart_event_upcoming)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if not enabled or _is_dead():
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
_last_seen_beat = maxi(_last_seen_beat, beat_index)
|
||||
if not enabled or _is_dead() or not _owner_combat_enabled():
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
if beat_index < _next_decision_beat:
|
||||
return
|
||||
_beat_cursor = float(beat_index)
|
||||
var action_id := _choose_action()
|
||||
if action_id.is_empty():
|
||||
return
|
||||
if _owner_stationary() and (action_id == &"boss_dash" or action_id == &"boss_retreat_dash"):
|
||||
action_id = _phase_fallback_action()
|
||||
if not phase_allows_action(action_id):
|
||||
action_id = _phase_fallback_action()
|
||||
if action_id.is_empty() or not phase_allows_action(action_id):
|
||||
return
|
||||
_next_decision_beat = beat_index + _decision_beat_step()
|
||||
if conditional_anchor_actions.has(action_id):
|
||||
_schedule_conditional_anchor(beat_index)
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", action_id)
|
||||
|
||||
|
||||
func reset_for_combat_entry() -> void:
|
||||
enabled = true
|
||||
_next_decision_beat = 0
|
||||
_beat_cursor = 0.0
|
||||
_force_ranged_after_retreat = false
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
|
||||
|
||||
## Conditional TimeAnchorEvent (AnchorV1.0 §18.2): the boss preparing a special
|
||||
## attack marks an upcoming beat as a time anchor — miss it and the phase flips.
|
||||
func _schedule_conditional_anchor(beat_index: int) -> void:
|
||||
var anchor_system := _time_anchor_system_or_null()
|
||||
if anchor_system == null or not anchor_system.has_method("schedule_time_anchor"):
|
||||
return
|
||||
anchor_system.call("schedule_time_anchor", beat_index + maxi(1, special_attack_anchor_lead_beats), &"boss_condition")
|
||||
|
||||
|
||||
func _time_anchor_system_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("TimeAnchorSystem")
|
||||
|
||||
|
||||
func _choose_action() -> StringName:
|
||||
if _is_dead():
|
||||
return &""
|
||||
_refresh_target()
|
||||
_face_target()
|
||||
if target == null:
|
||||
return &""
|
||||
var distance := _distance_to_target()
|
||||
var low_health := _health_ratio() <= low_health_ratio
|
||||
if restrict_actions_by_time_phase:
|
||||
var phase := _current_time_phase()
|
||||
if phase == &"future":
|
||||
return _choose_future_phase_action(distance)
|
||||
if phase == &"past":
|
||||
return _choose_past_phase_action(distance)
|
||||
if distance <= melee_distance:
|
||||
return _choose_melee_range_action(low_health)
|
||||
if distance > dash_distance:
|
||||
if _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
return _next_ranged_action()
|
||||
return _choose_mid_range_action(low_health, distance)
|
||||
|
||||
|
||||
func _choose_past_phase_action(distance: float) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
if distance > dash_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
if distance > melee_distance and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
return &""
|
||||
|
||||
|
||||
func _choose_future_phase_action(distance: float) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
# new3 §6.7:远程相位被近身时优先后撤拉开距离(后撤本身不造成伤害)。
|
||||
if distance <= retreat_trigger_distance and not _owner_stationary() and _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
|
||||
_last_retreat_beat = _beat_cursor
|
||||
_force_ranged_after_retreat = true
|
||||
return &"boss_retreat_dash"
|
||||
if distance <= melee_distance and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
return _next_ranged_action()
|
||||
|
||||
|
||||
func _choose_melee_range_action(low_health: bool) -> StringName:
|
||||
_force_ranged_after_retreat = false
|
||||
if not low_health and _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
if _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats):
|
||||
_last_retreat_beat = _beat_cursor
|
||||
_force_ranged_after_retreat = true
|
||||
return &"boss_retreat_dash"
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
return &""
|
||||
|
||||
|
||||
func _choose_mid_range_action(low_health: bool, distance: float) -> StringName:
|
||||
if _force_ranged_after_retreat:
|
||||
_force_ranged_after_retreat = false
|
||||
return _next_ranged_action()
|
||||
if not low_health and _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
if low_health and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
if distance > ranged_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats):
|
||||
_last_dash_beat = _beat_cursor
|
||||
return &"boss_dash"
|
||||
return _next_ranged_action()
|
||||
|
||||
|
||||
func _next_combo_action() -> StringName:
|
||||
var actions: Array[StringName] = [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3"]
|
||||
var action_id := actions[_combo_index % actions.size()]
|
||||
_combo_index += 1
|
||||
return action_id
|
||||
|
||||
|
||||
func _next_ranged_action() -> StringName:
|
||||
var actions: Array[StringName] = [&"boss_shoot_1", &"boss_shoot_2"]
|
||||
var action_id := actions[_ranged_index % actions.size()]
|
||||
_ranged_index += 1
|
||||
return action_id
|
||||
|
||||
|
||||
func phase_allows_action(action_id: StringName) -> bool:
|
||||
if not restrict_actions_by_time_phase or action_id.is_empty():
|
||||
return true
|
||||
var action := ActionResolver.get_action(action_id)
|
||||
if action == null:
|
||||
return true
|
||||
var tags: Array = action.get("action_tags")
|
||||
var hit_type := StringName(str(action.get("hit_type")))
|
||||
var is_melee := hit_type == &"melee" or tags.has(&"melee")
|
||||
var is_ranged := hit_type == &"projectile" or hit_type == &"ranged" or tags.has(&"ranged")
|
||||
var phase := _current_time_phase()
|
||||
if phase == &"past":
|
||||
return not is_ranged
|
||||
if phase == &"future":
|
||||
return not is_melee
|
||||
return true
|
||||
|
||||
|
||||
func _phase_fallback_action() -> StringName:
|
||||
if not restrict_actions_by_time_phase:
|
||||
return _next_ranged_action()
|
||||
if _current_time_phase() == &"future":
|
||||
if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats):
|
||||
_last_two_way_beat = _beat_cursor
|
||||
return &"boss_2way_shoot"
|
||||
return _next_ranged_action()
|
||||
if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats):
|
||||
_last_melee_beat = _beat_cursor
|
||||
return _next_combo_action()
|
||||
if _cooldown_ready(_last_stab_beat, stab_cooldown_beats):
|
||||
_last_stab_beat = _beat_cursor
|
||||
return &"boss_lunging_stab"
|
||||
return &""
|
||||
|
||||
|
||||
func _refresh_target() -> void:
|
||||
if target != null and is_instance_valid(target):
|
||||
return
|
||||
target = get_node_or_null(target_path) as Node2D
|
||||
if target != null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or owner.get_parent() == null:
|
||||
return
|
||||
target = owner.get_parent().get_node_or_null("Player") as Node2D
|
||||
|
||||
|
||||
func _face_target() -> void:
|
||||
var owner := get_parent()
|
||||
if target != null and owner != null and owner.has_method("look_at_target"):
|
||||
owner.call("look_at_target", target)
|
||||
|
||||
|
||||
func _distance_to_target() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null:
|
||||
return melee_distance
|
||||
return absf(target.global_position.x - owner.global_position.x)
|
||||
|
||||
|
||||
func _health_ratio() -> float:
|
||||
if health_component == null:
|
||||
return 1.0
|
||||
var maximum := maxf(1.0, float(health_component.get("maximum")))
|
||||
return clampf(float(health_component.get("current")) / maximum, 0.0, 1.0)
|
||||
|
||||
|
||||
func _owner_combat_enabled() -> bool:
|
||||
# Boss-room lock: while the door is open the boss neither acts nor raises
|
||||
# conditional time anchors.
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return true
|
||||
var value = owner.get("combat_enabled")
|
||||
if value is bool:
|
||||
return value
|
||||
return true
|
||||
|
||||
|
||||
func _owner_stationary() -> bool:
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var value = owner.get("stationary")
|
||||
return value is bool and value
|
||||
|
||||
|
||||
func _is_dead() -> bool:
|
||||
if health_component != null and int(health_component.get("current")) <= 0:
|
||||
return true
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var state_machine := owner.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
return false
|
||||
|
||||
|
||||
func _cooldown_ready(last_beat: float, cooldown_beats: float) -> bool:
|
||||
return _beat_cursor - last_beat >= cooldown_beats
|
||||
|
||||
|
||||
func _decision_beat_step() -> int:
|
||||
# Boss 相位补充说明: the boss has no strong/weak phase — HP, defense and
|
||||
# its core numbers stay stable across Past/Future. Only move SELECTION
|
||||
# (melee vs ranged) and decision CADENCE (§18.1 slow/heavy vs fast/light)
|
||||
# shift with the phase.
|
||||
var interval_scale := 1.0
|
||||
if restrict_actions_by_time_phase:
|
||||
interval_scale = future_decision_interval_scale if _current_time_phase() == &"future" else past_decision_interval_scale
|
||||
return maxi(1, int(round(decision_interval_beats * interval_scale)))
|
||||
|
||||
|
||||
func _connect_beat_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _connect_chart_bus() -> void:
|
||||
var bus := _event_bus()
|
||||
if bus != null and bus.has_signal("chart_event_upcoming") and not bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming):
|
||||
bus.connect("chart_event_upcoming", _on_chart_event_upcoming)
|
||||
|
||||
|
||||
func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void:
|
||||
if event == null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or StringName(str(event.get("target_id"))) != StringName(owner.name):
|
||||
return
|
||||
var event_beat := int(floor(float(event.call("beat_position"))))
|
||||
_next_decision_beat = maxi(_next_decision_beat, event_beat + 2)
|
||||
|
||||
|
||||
func _event_bus() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
var root := get_tree().root
|
||||
var bus := root.get_node_or_null("EventBus")
|
||||
if bus == null:
|
||||
bus = load("res://autoload/event_bus.gd").new()
|
||||
bus.name = "EventBus"
|
||||
root.add_child(bus)
|
||||
return bus
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
if not is_inside_tree():
|
||||
return &"past"
|
||||
var children := get_tree().root.get_children()
|
||||
for index: int in range(children.size() - 1, -1, -1):
|
||||
var child: Node = children[index]
|
||||
if child.has_method("set_time_phase") and child.get("current_time_phase") != null:
|
||||
return StringName(str(child.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _beat_time() -> float:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null:
|
||||
return float(rhythm.get("beat_time"))
|
||||
return 0.5
|
||||
@@ -0,0 +1 @@
|
||||
uid://c57cb6ivmypad
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=12 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="1_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="2_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="3_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="4_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="5_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="6_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="7_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="8_enemy_action_driver"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(18, 48)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(64, 36)
|
||||
|
||||
[node name="Enemy" type="CharacterBody2D"]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, -32)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="StateMachine" type="Node" parent="."]
|
||||
script = ExtResource("1_state_machine")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="."]
|
||||
script = ExtResource("2_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="."]
|
||||
script = ExtResource("3_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("4_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="."]
|
||||
script = ExtResource("5_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
beat_anchor_policy = &"ANCHOR_ACTIVE"
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="."]
|
||||
script = ExtResource("6_effect_container")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="."]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("7_damage_emitter")
|
||||
damage = 100
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"]
|
||||
position = Vector2(0, -32)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="."]
|
||||
script = ExtResource("8_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
@@ -0,0 +1,39 @@
|
||||
class_name EnemyActionDriver
|
||||
extends Node
|
||||
|
||||
const AIIntentScript := preload("res://scenes/components/ai_intent.gd")
|
||||
|
||||
@export var action_controller_path: NodePath
|
||||
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
|
||||
|
||||
func start_action(action_id: StringName) -> void:
|
||||
if action_controller == null or not action_controller.has_method("submit_ai_intent"):
|
||||
return
|
||||
if not _owner_allows_action(action_id):
|
||||
return
|
||||
var intent: RefCounted = AIIntentScript.create(action_id, float(Time.get_ticks_msec()))
|
||||
action_controller.call("submit_ai_intent", intent)
|
||||
|
||||
|
||||
func handle_chart_event(event: Resource) -> void:
|
||||
if event == null:
|
||||
return
|
||||
var action_id := StringName(str(event.get("action_id")))
|
||||
if action_id.is_empty() and event.get("payload") is Dictionary:
|
||||
action_id = StringName(str(event.get("payload").get("action_id", &"")))
|
||||
if action_id.is_empty():
|
||||
return
|
||||
if not _owner_allows_action(action_id):
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0 and action_controller.has_method("cancel_current"):
|
||||
action_controller.call("cancel_current", &"chart_override")
|
||||
start_action(action_id)
|
||||
|
||||
|
||||
func _owner_allows_action(action_id: StringName) -> bool:
|
||||
var actor := get_parent()
|
||||
if actor != null and actor.has_method("can_start_enemy_action"):
|
||||
return bool(actor.call("can_start_enemy_action", action_id))
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://hpgeq4fpf078
|
||||
@@ -0,0 +1,665 @@
|
||||
class_name Minion
|
||||
extends Character
|
||||
|
||||
const DEFAULT_HEADING := Vector2.LEFT
|
||||
const DEFAULT_ANIMATION_FPS := 12.0
|
||||
const VISIBLE_FEET_ANCHOR_Y := -1.0
|
||||
const PHASE_SWAP_MOVEMENT_HOLD_FRAMES := 2
|
||||
|
||||
const FORM_SPECS := {
|
||||
&"jin_zhan_1": {
|
||||
"native_facing": Vector2.LEFT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_1/idle.png", "frames": 6, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/jin_zhan_1/death.png", "frames": 6, "fps": 10.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"jin_zhan_1_attack", "path": "res://assets/art/characters/minions/jin_zhan_1/attack.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_jin_zhan_1_attack"],
|
||||
"sprite_offset": Vector2(-28.0, -48.0),
|
||||
"projectile_height": 42.0,
|
||||
},
|
||||
&"jin_zhan_3": {
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_3/idle.png", "frames": 7, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/jin_zhan_3/death.png", "frames": 12, "fps": 12.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_3/dash.png", "frames": 5, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"jin_zhan_3_attack_1", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
{"name": &"jin_zhan_3_attack_2", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
|
||||
{"name": &"jin_zhan_3_attack_3", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_3.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_jin_zhan_3_attack_1", &"minion_jin_zhan_3_attack_2", &"minion_jin_zhan_3_attack_3"],
|
||||
"sprite_offset": Vector2(-47.0, -62.0),
|
||||
"projectile_height": 44.0,
|
||||
},
|
||||
&"yuan_cheng_1": {
|
||||
# 素材原生朝右(眼睛与挥击弧线都在右侧);朝左时再做镜像。
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_1/idle.png", "frames": 8, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_1/death.png", "frames": 7, "fps": 10.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"yuan_cheng_1_attack_1", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
|
||||
{"name": &"yuan_cheng_1_attack_2", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_yuan_cheng_1_attack_1", &"minion_yuan_cheng_1_attack_2"],
|
||||
"sprite_offset": Vector2(-39.0, -48.0),
|
||||
# 可见脚底在 actor 原点上方约 40px,出手点再高约 46px(甩击释放高度),
|
||||
# 两个远程形态共用同一条弹道,切相位不跳变。
|
||||
"projectile_height": 86.0,
|
||||
},
|
||||
&"yuan_cheng_2": {
|
||||
"native_facing": Vector2.RIGHT,
|
||||
"visual_scale": 1.12,
|
||||
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_2/idle.png", "frames": 10, "fps": 8.0, "loop": true},
|
||||
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_2/death.png", "frames": 18, "fps": 14.0, "loop": false},
|
||||
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_2/dash.png", "frames": 3, "fps": 10.0, "loop": true},
|
||||
"attacks": [
|
||||
{"name": &"yuan_cheng_2_attack", "path": "res://assets/art/characters/minions/yuan_cheng_2/attack.png", "frames": 13, "fps": 14.0, "loop": false},
|
||||
],
|
||||
"actions": [&"minion_yuan_cheng_2_attack"],
|
||||
"sprite_offset": Vector2(-74.0, -97.0),
|
||||
# 对齐法杖顶端法球中心(脚底上方约 49px),并与小女孩共用同一条弹道。
|
||||
"projectile_height": 86.0,
|
||||
},
|
||||
}
|
||||
|
||||
@export var max_health := 650
|
||||
@export var current_health := 650
|
||||
@export var past_form_id: StringName = &"jin_zhan_3"
|
||||
@export var future_form_id: StringName = &"jin_zhan_1"
|
||||
@export var strength_profile: StringName = &"past_strong"
|
||||
@export var time_phase_profile: Resource
|
||||
@export var visual_scale := 2.0
|
||||
@export var visual_ground_offset := -38.0
|
||||
@export var projectile_spawn_forward := 44.0
|
||||
@export var minion_lunge_speed := 100.0
|
||||
@export var approach_speed := 120.0
|
||||
|
||||
## Set by MinionBehavior each physics frame: signed walk intent whose magnitude
|
||||
## scales approach_speed (patrol strolls at ~0.45, a ranged retreat hops at ~1.7).
|
||||
var approach_direction := 0.0
|
||||
|
||||
@onready var state_machine: Node = get_node_or_null("StateMachine")
|
||||
@onready var action_controller: Node = get_node_or_null("ActionController")
|
||||
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
|
||||
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
|
||||
@onready var health_component: Node = get_node_or_null("HealthComponent")
|
||||
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
|
||||
@onready var strong_buff_visual: Node = get_node_or_null("Visual/StrongBuffVisual")
|
||||
@onready var overhead_health_bar: Node2D = get_node_or_null("OverheadHealthBar") as Node2D
|
||||
|
||||
var form_id: StringName = &""
|
||||
var _pending_form_id: StringName = &""
|
||||
var _current_action_animation: StringName = &""
|
||||
var _frame_delta := 0.0
|
||||
var _dead_state_entered := false
|
||||
var _death_animation_started := false
|
||||
var _phase_swap_hold_frames := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
heading = DEFAULT_HEADING
|
||||
anim_map = {
|
||||
PRESENTATION_IDLE: "idle",
|
||||
PRESENTATION_WALK: "idle",
|
||||
PRESENTATION_JUMP: "idle",
|
||||
PRESENTATION_LAND: "idle",
|
||||
PRESENTATION_ATTACK: "idle",
|
||||
PRESENTATION_AIR_ATTACK: "idle",
|
||||
}
|
||||
_install_minion_animations()
|
||||
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
|
||||
_wire_action_controller()
|
||||
_wire_damage_receiver()
|
||||
_connect_time_phase_bus()
|
||||
_apply_time_phase_profile()
|
||||
if health_component != null and health_component.has_method("set_values"):
|
||||
var health_multiplier := _difficulty_health_multiplier()
|
||||
health_component.call("set_values", int(round(current_health * health_multiplier)), int(round(max_health * health_multiplier)))
|
||||
apply_time_phase(_current_time_phase())
|
||||
state = PRESENTATION_IDLE
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_frame_delta = delta
|
||||
super._physics_process(delta)
|
||||
|
||||
|
||||
func handle_input() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## 击退滑行(趔趄)期间不得起手新动作(EnemyActionDriver 钩子,boss 同款):
|
||||
## 否则动作突进会立刻接管 velocity,把击退位移吃掉。
|
||||
func can_start_enemy_action(_action_id: StringName) -> bool:
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func handle_air_time(delta: float) -> void:
|
||||
# 委托 MovementMotor(boss 同款):击退残速的摩擦衰减、假高度轴积分、
|
||||
# 落地复位 Grounded 全在里面。旧版只在 JUMP 态积分,导致垂直击退后
|
||||
# ground_state 永久卡 Airborne,小怪从此不能出招(allowed_ground_states)。
|
||||
if movement_motor != null and movement_motor.has_method("handle_air_time"):
|
||||
movement_motor.call("handle_air_time", delta)
|
||||
elif state == PRESENTATION_JUMP:
|
||||
super.handle_air_time(delta)
|
||||
|
||||
|
||||
func handle_movement() -> void:
|
||||
if motion_executor != null and bool(motion_executor.get("active")):
|
||||
velocity = motion_executor.call("tick", _frame_delta)
|
||||
return
|
||||
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
return
|
||||
if _phase_swap_hold_frames > 0:
|
||||
_phase_swap_hold_frames -= 1
|
||||
approach_direction = 0.0
|
||||
velocity.x = 0.0
|
||||
state = PRESENTATION_IDLE
|
||||
return
|
||||
# 击退残速期:滑行交给 MovementMotor 的摩擦衰减,AI 不接管 velocity.x
|
||||
# (否则击退初速活不过一帧,小怪击退位移恒为 0)。必须放在相位切换
|
||||
# hold 之后——换相位定身优先,hold 清零速度后 stray 标志随衰减自清。
|
||||
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
|
||||
state = PRESENTATION_IDLE
|
||||
return
|
||||
if absf(approach_direction) > 0.05 and _life_state() != &"Dead":
|
||||
# 上限 4.0 给受击脱离的跳跃速度(escape_speed_scale 3.2)留出空间。
|
||||
velocity.x = clampf(approach_direction, -4.0, 4.0) * approach_speed
|
||||
state = PRESENTATION_WALK
|
||||
return
|
||||
velocity.x = 0.0
|
||||
state = PRESENTATION_IDLE
|
||||
|
||||
|
||||
func handle_animations() -> void:
|
||||
var life_state := _life_state()
|
||||
if life_state == &"Dead":
|
||||
_enter_dead_state()
|
||||
_play_death_animation_once()
|
||||
_queue_free_if_death_animation_finished()
|
||||
return
|
||||
_dead_state_entered = false
|
||||
_death_animation_started = false
|
||||
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
|
||||
_play_animation(_current_action_animation)
|
||||
return
|
||||
if state == PRESENTATION_WALK:
|
||||
_play_animation(_dash_animation_name(form_id))
|
||||
return
|
||||
_play_idle()
|
||||
|
||||
|
||||
func set_heading() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func set_sprite_height_position() -> void:
|
||||
if visual != null:
|
||||
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
|
||||
_refresh_overhead_health_bar_position()
|
||||
|
||||
|
||||
func flip_sprites() -> void:
|
||||
_apply_form_visual_scale()
|
||||
|
||||
|
||||
func apply_time_phase(time_phase: StringName) -> void:
|
||||
_refresh_strong_buff_visual(time_phase)
|
||||
var next_form := future_form_id if time_phase == &"future" else past_form_id
|
||||
if next_form == form_id:
|
||||
_pending_form_id = &""
|
||||
if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK and _life_state() != &"Dead":
|
||||
_apply_sprite_offset()
|
||||
_play_idle(true)
|
||||
return
|
||||
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
|
||||
_pending_form_id = next_form
|
||||
return
|
||||
_hold_phase_swap_position()
|
||||
form_id = next_form
|
||||
_pending_form_id = &""
|
||||
_apply_sprite_offset()
|
||||
if _life_state() != &"Dead":
|
||||
_play_idle(true)
|
||||
|
||||
|
||||
func current_action_ids() -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
var actions: Array = _form_spec().get("actions", [])
|
||||
for action_id: Variant in actions:
|
||||
result.append(StringName(str(action_id)))
|
||||
return result
|
||||
|
||||
|
||||
func current_attack_interval_beats() -> int:
|
||||
return 2 if is_strong_in_current_time_phase() else 4
|
||||
|
||||
|
||||
func is_strong_in_current_time_phase() -> bool:
|
||||
return _is_strong_in_time_phase(_current_time_phase())
|
||||
|
||||
|
||||
func _is_strong_in_time_phase(time_phase: StringName) -> bool:
|
||||
return (strength_profile == &"past_strong" and time_phase == &"past") or (strength_profile == &"future_strong" and time_phase == &"future")
|
||||
|
||||
|
||||
func look_at_target(target: Node2D) -> void:
|
||||
if target == null:
|
||||
return
|
||||
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
|
||||
|
||||
|
||||
func projectile_spawn_position(_action: Resource) -> Vector2:
|
||||
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -_projectile_height())
|
||||
|
||||
|
||||
func projectile_direction(_action: Resource) -> Vector2:
|
||||
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
|
||||
|
||||
|
||||
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
|
||||
return [{
|
||||
"spawn_position": projectile_spawn_position(action),
|
||||
"direction": projectile_direction(action),
|
||||
}]
|
||||
|
||||
|
||||
func _install_minion_animations() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
var library: AnimationLibrary
|
||||
if animation_player.has_animation_library(""):
|
||||
library = animation_player.get_animation_library("")
|
||||
else:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
for next_form: StringName in FORM_SPECS.keys():
|
||||
var spec: Dictionary = FORM_SPECS[next_form]
|
||||
var sprite_offset: Vector2 = spec.get("sprite_offset", Vector2(-32.0, -64.0))
|
||||
_add_sheet_animation(library, _idle_animation_name(next_form), spec.get("idle", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
_add_sheet_animation(library, _death_animation_name(next_form), spec.get("death", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
_add_sheet_animation(library, _dash_animation_name(next_form), spec.get("dash", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
for attack: Variant in spec.get("attacks", []):
|
||||
if attack is Dictionary:
|
||||
_add_sheet_animation(library, StringName(str((attack as Dictionary).get("name", &""))), attack, VISIBLE_FEET_ANCHOR_Y, sprite_offset)
|
||||
|
||||
|
||||
func _add_sheet_animation(library: AnimationLibrary, animation_name: StringName, spec: Dictionary, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> void:
|
||||
if animation_name.is_empty() or spec.is_empty() or library.has_animation(animation_name):
|
||||
return
|
||||
var texture := load(str(spec.get("path", ""))) as Texture2D
|
||||
if texture == null:
|
||||
return
|
||||
var frame_count: int = int(spec.get("frames", 1))
|
||||
var hframes: int = int(spec.get("hframes", frame_count))
|
||||
library.add_animation(animation_name, _make_sheet_animation(texture, frame_count, hframes, float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), visible_bottom_anchor, sprite_offset))
|
||||
|
||||
|
||||
func _make_sheet_animation(texture: Texture2D, frame_count: int, hframes: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
|
||||
var safe_frame_count := maxi(1, frame_count)
|
||||
var safe_hframes := maxi(safe_frame_count, hframes)
|
||||
var safe_fps := maxf(1.0, fps)
|
||||
var frame_time := 1.0 / safe_fps
|
||||
var animation := Animation.new()
|
||||
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
|
||||
animation.step = frame_time
|
||||
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
|
||||
var texture_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
|
||||
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(texture_track, 0.0, texture)
|
||||
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
|
||||
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(hframes_track, 0.0, safe_hframes)
|
||||
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
|
||||
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
|
||||
animation.track_insert_key(vframes_track, 0.0, 1)
|
||||
var frame_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
|
||||
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
|
||||
if visible_bottom_anchor < INF:
|
||||
var offset_track := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
|
||||
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
|
||||
for frame_index: int in range(safe_frame_count):
|
||||
var offset := sprite_offset
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, safe_hframes, 1, frame_index)
|
||||
if bottom != INF:
|
||||
offset.y = visible_bottom_anchor - bottom
|
||||
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
|
||||
return animation
|
||||
|
||||
|
||||
func _wire_action_controller() -> void:
|
||||
if action_controller == null:
|
||||
return
|
||||
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
|
||||
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
|
||||
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
|
||||
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
|
||||
|
||||
|
||||
func _wire_damage_receiver() -> void:
|
||||
if damage_receiver != null:
|
||||
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
|
||||
|
||||
|
||||
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||
if not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
|
||||
|
||||
func _connect_time_phase_bus() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
|
||||
bus.connect("time_phase_changed", _on_time_phase_changed)
|
||||
|
||||
|
||||
func _apply_time_phase_profile() -> void:
|
||||
var adapter := get_node_or_null("TimePhaseAdapter")
|
||||
if adapter == null:
|
||||
return
|
||||
adapter.set("profile", time_phase_profile)
|
||||
if adapter.has_method("apply_time_phase"):
|
||||
adapter.call("apply_time_phase", _current_time_phase())
|
||||
|
||||
|
||||
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
|
||||
apply_time_phase(current)
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_face_target_if_available()
|
||||
_current_action_animation = StringName(str(action.get("animation")))
|
||||
state = PRESENTATION_ATTACK
|
||||
attack_time_left = _action_duration_seconds(action) + 0.05
|
||||
attack_lunge_time_left = attack_time_left
|
||||
_align_action_animation_speed(action)
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
_play_animation(_current_action_animation)
|
||||
|
||||
|
||||
func _on_action_active_started(action: Resource, _intent) -> void:
|
||||
if _life_state() == &"Dead" or motion_executor == null:
|
||||
return
|
||||
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
|
||||
return
|
||||
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
|
||||
motion_executor.call("execute", action, heading, _beat_time(), minion_lunge_speed * speed_scale)
|
||||
|
||||
|
||||
func _on_action_finished(_action: Resource) -> void:
|
||||
_finish_action_presentation()
|
||||
|
||||
|
||||
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
|
||||
_finish_action_presentation()
|
||||
|
||||
|
||||
func _finish_action_presentation() -> void:
|
||||
_current_action_animation = &""
|
||||
_reset_animation_speed()
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
if _life_state() == &"Dead":
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
state = PRESENTATION_IDLE
|
||||
velocity = Vector2.ZERO
|
||||
if not _pending_form_id.is_empty():
|
||||
apply_time_phase(_current_time_phase())
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if _life_state() == &"Dead":
|
||||
return
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _enter_dead_state() -> void:
|
||||
if _dead_state_entered:
|
||||
return
|
||||
_dead_state_entered = true
|
||||
_current_action_animation = &""
|
||||
attack_time_left = 0.0
|
||||
attack_lunge_time_left = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
_reset_animation_speed()
|
||||
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
|
||||
action_controller.call("cancel_current", &"death")
|
||||
if motion_executor != null and motion_executor.has_method("cancel"):
|
||||
motion_executor.call("cancel")
|
||||
var damage_emitter := get_node_or_null("DamageEmitter")
|
||||
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
|
||||
damage_emitter.call("clear_hit")
|
||||
var receiver := get_node_or_null("DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.monitoring = false
|
||||
receiver.monitorable = false
|
||||
var behavior := get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
behavior.set("enabled", false)
|
||||
_refresh_strong_buff_visual(_current_time_phase())
|
||||
|
||||
|
||||
func _play_death_animation_once() -> void:
|
||||
if _death_animation_started:
|
||||
return
|
||||
_death_animation_started = true
|
||||
_play_animation(_death_animation_name(form_id))
|
||||
|
||||
|
||||
func _on_animation_finished(animation_name: StringName) -> void:
|
||||
if _death_animation_started and animation_name == _death_animation_name(form_id):
|
||||
queue_free()
|
||||
|
||||
|
||||
func _queue_free_if_death_animation_finished() -> void:
|
||||
if not _death_animation_started or is_queued_for_deletion():
|
||||
return
|
||||
if animation_player == null:
|
||||
queue_free()
|
||||
return
|
||||
if not animation_player.is_playing():
|
||||
queue_free()
|
||||
|
||||
|
||||
func _play_idle(force_refresh := false) -> void:
|
||||
_play_animation(_idle_animation_name(form_id), force_refresh)
|
||||
|
||||
|
||||
func _play_animation(animation_name: StringName, force_refresh := false) -> void:
|
||||
if animation_player == null or animation_name.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(animation_name):
|
||||
return
|
||||
if force_refresh or animation_player.current_animation != String(animation_name):
|
||||
animation_player.play(animation_name)
|
||||
if force_refresh:
|
||||
animation_player.seek(0.0, true)
|
||||
|
||||
|
||||
func _align_action_animation_speed(action: Resource) -> void:
|
||||
if animation_player == null or action == null or _current_action_animation.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(_current_action_animation):
|
||||
return
|
||||
var natural_length := animation_player.get_animation(_current_action_animation).length
|
||||
var action_seconds := _action_duration_seconds(action)
|
||||
if natural_length <= 0.0 or action_seconds <= 0.0:
|
||||
return
|
||||
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
|
||||
|
||||
|
||||
func _reset_animation_speed() -> void:
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
func _action_duration_seconds(action: Resource) -> float:
|
||||
if action == null:
|
||||
return attack_duration
|
||||
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
|
||||
|
||||
|
||||
func _beat_time() -> float:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null:
|
||||
return float(rhythm.get("beat_time"))
|
||||
return 0.5
|
||||
|
||||
|
||||
func _face_target_if_available() -> void:
|
||||
var target := _target_or_null()
|
||||
if target != null:
|
||||
look_at_target(target)
|
||||
|
||||
|
||||
func _target_or_null() -> Node2D:
|
||||
var behavior := get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
var behavior_target = behavior.get("target")
|
||||
if behavior_target is Node2D and is_instance_valid(behavior_target):
|
||||
return behavior_target
|
||||
var container := get_parent()
|
||||
if container != null:
|
||||
var sibling := container.get_node_or_null("Player") as Node2D
|
||||
if sibling != null:
|
||||
return sibling
|
||||
return null
|
||||
|
||||
|
||||
func _form_spec() -> Dictionary:
|
||||
return FORM_SPECS.get(form_id, FORM_SPECS.get(past_form_id, {}))
|
||||
|
||||
|
||||
func _form_visual_scale() -> float:
|
||||
return float(_form_spec().get("visual_scale", visual_scale))
|
||||
|
||||
|
||||
func _native_facing() -> Vector2:
|
||||
return _form_spec().get("native_facing", Vector2.RIGHT)
|
||||
|
||||
|
||||
func _apply_sprite_offset() -> void:
|
||||
if character_sprite != null:
|
||||
var sprite_offset: Vector2 = _form_spec().get("sprite_offset", Vector2(-32.0, -64.0))
|
||||
var idle_spec: Dictionary = _form_spec().get("idle", {})
|
||||
var texture := load(str(idle_spec.get("path", ""))) as Texture2D
|
||||
var hframes := int(idle_spec.get("hframes", idle_spec.get("frames", 1)))
|
||||
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, hframes), 1, 0)
|
||||
sprite_offset.y = VISIBLE_FEET_ANCHOR_Y - bottom if bottom < INF else sprite_offset.y
|
||||
character_sprite.offset = sprite_offset
|
||||
_apply_form_visual_scale()
|
||||
_refresh_overhead_health_bar_position()
|
||||
|
||||
|
||||
func _apply_form_visual_scale() -> void:
|
||||
if visual == null:
|
||||
return
|
||||
var scale_value := _form_visual_scale()
|
||||
visual.scale.x = scale_value if heading == _native_facing() else -scale_value
|
||||
visual.scale.y = scale_value
|
||||
|
||||
|
||||
func _hold_phase_swap_position() -> void:
|
||||
_phase_swap_hold_frames = PHASE_SWAP_MOVEMENT_HOLD_FRAMES
|
||||
approach_direction = 0.0
|
||||
velocity.x = 0.0
|
||||
|
||||
|
||||
func _refresh_overhead_health_bar_position() -> void:
|
||||
if overhead_health_bar == null or visual == null or character_sprite == null:
|
||||
return
|
||||
overhead_health_bar.position = Vector2(0.0, visual.position.y + character_sprite.offset.y * absf(visual.scale.y) - 10.0)
|
||||
|
||||
|
||||
func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float:
|
||||
if texture == null:
|
||||
return INF
|
||||
var image := texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
return INF
|
||||
var safe_hframes := maxi(1, hframes)
|
||||
var safe_vframes := maxi(1, vframes)
|
||||
var frame_width := image.get_width() / safe_hframes
|
||||
var frame_height := image.get_height() / safe_vframes
|
||||
var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1)
|
||||
var column := safe_frame % safe_hframes
|
||||
var row := int(safe_frame / safe_hframes)
|
||||
var bottom := -1
|
||||
for y: int in range(frame_height):
|
||||
for x: int in range(frame_width):
|
||||
var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y)
|
||||
if pixel.a > 0.01:
|
||||
bottom = y
|
||||
if bottom < 0:
|
||||
return INF
|
||||
return float(bottom)
|
||||
|
||||
|
||||
func _projectile_height() -> float:
|
||||
return float(_form_spec().get("projectile_height", 42.0))
|
||||
|
||||
|
||||
func _idle_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_idle" % str(next_form))
|
||||
|
||||
|
||||
func _death_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_death" % str(next_form))
|
||||
|
||||
|
||||
func _dash_animation_name(next_form: StringName) -> StringName:
|
||||
return StringName("%s_dash" % str(next_form))
|
||||
|
||||
|
||||
func _life_state() -> StringName:
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
|
||||
return &"Alive"
|
||||
|
||||
|
||||
func _heading_sign() -> float:
|
||||
return -1.0 if heading.x < 0.0 else 1.0
|
||||
|
||||
|
||||
func _current_time_phase() -> StringName:
|
||||
var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null
|
||||
if manager != null:
|
||||
return StringName(str(manager.get("current_time_phase")))
|
||||
return &"past"
|
||||
|
||||
|
||||
func _difficulty_health_multiplier() -> float:
|
||||
var settings := get_tree().root.get_node_or_null("GameSettings") if is_inside_tree() else null
|
||||
if settings != null and settings.has_method("minion_health_multiplier"):
|
||||
return maxf(0.01, float(settings.call("minion_health_multiplier")))
|
||||
return 1.0
|
||||
|
||||
|
||||
func _refresh_strong_buff_visual(time_phase: StringName = &"") -> void:
|
||||
if strong_buff_visual == null or not strong_buff_visual.has_method("set_active"):
|
||||
return
|
||||
var phase := _current_time_phase() if time_phase.is_empty() else time_phase
|
||||
strong_buff_visual.call("set_active", _is_strong_in_time_phase(phase) and _life_state() != &"Dead")
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqbxyv604lcgx
|
||||
@@ -0,0 +1,136 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/enemies/minion.gd" id="1_minion"]
|
||||
[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="2_state_machine"]
|
||||
[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="3_movement_motor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="4_combo_window"]
|
||||
[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="5_action_resolver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="6_action_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="7_action_controller"]
|
||||
[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="8_motion_executor"]
|
||||
[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="9_effect_container"]
|
||||
[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="10_health_component"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="11_damage_receiver"]
|
||||
[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="12_damage_emitter"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="13_enemy_action_driver"]
|
||||
[ext_resource type="Script" path="res://scenes/enemies/minion_behavior.gd" id="14_minion_behavior"]
|
||||
[ext_resource type="Script" path="res://scenes/components/time_phase_adapter.gd" id="15_time_phase_adapter"]
|
||||
[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="16_frame_collision"]
|
||||
[ext_resource type="Script" path="res://scenes/components/strong_buff_visual.gd" id="17_strong_buff_visual"]
|
||||
[ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
|
||||
size = Vector2(20, 46)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"]
|
||||
size = Vector2(24, 50)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"]
|
||||
size = Vector2(54, 34)
|
||||
|
||||
[node name="Minion" type="CharacterBody2D" groups=["enemies"]]
|
||||
collision_layer = 64
|
||||
collision_mask = 33
|
||||
floor_snap_length = 0.0
|
||||
safe_margin = 0.001
|
||||
script = ExtResource("1_minion")
|
||||
speed = 90.0
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
scale = Vector2(2, 2)
|
||||
|
||||
[node name="CharacterSprite" type="Sprite2D" parent="Visual"]
|
||||
texture_filter = 2
|
||||
centered = false
|
||||
offset = Vector2(-48, -84)
|
||||
|
||||
[node name="FxOverlay" type="Sprite2D" parent="Visual"]
|
||||
visible = false
|
||||
texture_filter = 2
|
||||
z_index = 2
|
||||
centered = false
|
||||
|
||||
[node name="StrongBuffVisual" type="Node2D" parent="Visual"]
|
||||
z_index = 1
|
||||
script = ExtResource("17_strong_buff_visual")
|
||||
|
||||
[node name="OverheadHealthBar" type="Node2D" parent="."]
|
||||
position = Vector2(0, -118)
|
||||
z_index = 30
|
||||
script = ExtResource("18_overhead_health_bar")
|
||||
bar_size = Vector2(58, 7)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_body")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
|
||||
[node name="StateMachine" type="Node" parent="."]
|
||||
script = ExtResource("2_state_machine")
|
||||
|
||||
[node name="MovementMotor" type="Node" parent="."]
|
||||
script = ExtResource("3_movement_motor")
|
||||
|
||||
[node name="ComboWindow" type="Node" parent="."]
|
||||
script = ExtResource("4_combo_window")
|
||||
broadcast_to_bus = false
|
||||
|
||||
[node name="ActionResolver" type="Node" parent="."]
|
||||
script = ExtResource("5_action_resolver")
|
||||
|
||||
[node name="ActionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("6_action_executor")
|
||||
damage_emitter_path = NodePath("../DamageEmitter")
|
||||
|
||||
[node name="ActionController" type="Node" parent="."]
|
||||
script = ExtResource("7_action_controller")
|
||||
combo_window_path = NodePath("../ComboWindow")
|
||||
action_resolver_path = NodePath("../ActionResolver")
|
||||
action_executor_path = NodePath("../ActionExecutor")
|
||||
state_machine_path = NodePath("../StateMachine")
|
||||
|
||||
[node name="MotionExecutor" type="Node" parent="."]
|
||||
script = ExtResource("8_motion_executor")
|
||||
|
||||
[node name="EffectContainer" type="Node" parent="."]
|
||||
script = ExtResource("9_effect_container")
|
||||
|
||||
[node name="HealthComponent" type="Node" parent="."]
|
||||
script = ExtResource("10_health_component")
|
||||
maximum = 650
|
||||
current = 650
|
||||
|
||||
[node name="DamageReceiver" type="Area2D" parent="."]
|
||||
collision_layer = 4
|
||||
collision_mask = 8
|
||||
script = ExtResource("11_damage_receiver")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_hurtbox")
|
||||
|
||||
[node name="DamageEmitter" type="Area2D" parent="."]
|
||||
collision_layer = 16
|
||||
collision_mask = 2
|
||||
monitoring = false
|
||||
script = ExtResource("12_damage_emitter")
|
||||
damage = 25
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"]
|
||||
position = Vector2(0, -54)
|
||||
shape = SubResource("RectangleShape2D_hitbox")
|
||||
|
||||
[node name="EnemyActionDriver" type="Node" parent="."]
|
||||
script = ExtResource("13_enemy_action_driver")
|
||||
action_controller_path = NodePath("../ActionController")
|
||||
|
||||
[node name="MinionBehavior" type="Node" parent="."]
|
||||
script = ExtResource("14_minion_behavior")
|
||||
target_path = NodePath("../../Player")
|
||||
|
||||
[node name="TimePhaseAdapter" type="Node" parent="."]
|
||||
script = ExtResource("15_time_phase_adapter")
|
||||
|
||||
[node name="FrameCollisionDriver" type="Node" parent="."]
|
||||
script = ExtResource("16_frame_collision")
|
||||
@@ -0,0 +1,431 @@
|
||||
class_name MinionBehavior
|
||||
extends Node
|
||||
|
||||
## 小怪行为状态机(关卡设计指南 §7.4 / AnchorV1.0 §17.5)。
|
||||
## 近战怪:入场 → 巡逻 → (警戒后在下一个节拍点)追击 → 攻击。
|
||||
## 远程怪:入场 → 移动到站位固定 → 距离判断攻击;被近身或连续受击时后撤。
|
||||
## 决策与攻击都对齐节拍;移动逐帧执行。
|
||||
|
||||
const ROLE_MELEE := &"melee"
|
||||
const ROLE_RANGED := &"ranged"
|
||||
|
||||
const STATE_ENTRY := &"Entry"
|
||||
const STATE_PATROL := &"Patrol"
|
||||
const STATE_CHASE := &"Chase"
|
||||
const STATE_HOLD := &"Hold"
|
||||
const STATE_RETREAT := &"Retreat"
|
||||
|
||||
@export var enabled := true
|
||||
## 近战怪会巡逻并追击;远程怪固定站位、会后撤。
|
||||
@export var role: StringName = ROLE_MELEE
|
||||
@export var target_path: NodePath
|
||||
@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver")
|
||||
@export var action_controller_path: NodePath = NodePath("../ActionController")
|
||||
@export var health_component_path: NodePath = NodePath("../HealthComponent")
|
||||
## Attacks only start inside this horizontal distance; further away the minion
|
||||
## repositions instead (near for melee, the whole firing lane for ranged).
|
||||
## new3 §4.2:近战攻击距离 120。
|
||||
@export var attack_range := 120.0
|
||||
## Stop approaching a little inside the attack range so the minion does not
|
||||
## rub against the player collider. new3 §4.2:追踪停止距离 120。
|
||||
@export var approach_stop_range := 120.0
|
||||
## 警戒范围:玩家进入后近战怪转入追击(下一个节拍点生效)。new3 §4.2:360。
|
||||
@export var alert_range := 360.0
|
||||
## 近战怪巡逻半径与速度(占 approach_speed 的比例)。
|
||||
@export var patrol_radius := 110.0
|
||||
@export var patrol_speed_scale := 0.45
|
||||
## 远程怪后撤参数(new3 §5.4):玩家进入危险距离时向后退开。
|
||||
@export var retreat_distance := 220.0
|
||||
@export var retreat_speed_scale := 1.7
|
||||
@export var too_close_range := 220.0
|
||||
@export var too_close_seconds := 0.6
|
||||
## 受击脱离(2026-07-05 定案):连续受击 4 次触发短暂闪烁无敌 + 跳跃形式脱离。
|
||||
@export var retreat_after_hits := 4
|
||||
@export var hit_chain_window_seconds := 2.0
|
||||
@export var retreat_cooldown_seconds := 3.0
|
||||
@export var escape_invuln_seconds := 0.4
|
||||
@export var escape_speed_scale := 3.2
|
||||
## 后撤/脱离结束后的停顿(按节拍对齐,new3 §5.4 推荐 0.4s ≈ 1 拍)。
|
||||
@export var post_retreat_pause_beats := 1
|
||||
## 可活动区间:巡逻 / 后撤永远不出这段地面。
|
||||
@export var arena_min_x := 1120.0
|
||||
@export var arena_max_x := 2890.0
|
||||
## 增援入场目标(NAN = 原地即战斗区域,直接进入巡逻/站位)。
|
||||
@export var entry_target_x := NAN
|
||||
|
||||
@onready var driver: Node = get_node_or_null(enemy_action_driver_path)
|
||||
@onready var action_controller: Node = get_node_or_null(action_controller_path)
|
||||
@onready var health_component: Node = get_node_or_null(health_component_path)
|
||||
@onready var target: Node2D = get_node_or_null(target_path) as Node2D
|
||||
|
||||
var state: StringName = STATE_ENTRY
|
||||
var _next_decision_beat := 0
|
||||
var _action_index := 0
|
||||
var _patrol_anchor_x := 0.0
|
||||
var _patrol_direction := 1.0
|
||||
var _chase_pending := false
|
||||
var _retreat_target_x := 0.0
|
||||
var _too_close_time := 0.0
|
||||
var _recent_hits := 0
|
||||
var _hit_window_left := 0.0
|
||||
var _retreat_cooldown_left := 0.0
|
||||
var _escaping := false
|
||||
var _invuln_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.connect("beat_ticked", _on_beat_ticked)
|
||||
var receiver := get_node_or_null("../DamageReceiver")
|
||||
if receiver != null and receiver.has_signal("damage_received") and not receiver.is_connected("damage_received", _on_damage_received):
|
||||
receiver.connect("damage_received", _on_damage_received)
|
||||
_patrol_anchor_x = _owner_x()
|
||||
_refresh_target()
|
||||
if _has_entry_move():
|
||||
state = STATE_ENTRY
|
||||
else:
|
||||
state = STATE_PATROL if role == ROLE_MELEE else STATE_HOLD
|
||||
_face_target()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var bus := _event_bus_or_null()
|
||||
if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked):
|
||||
bus.disconnect("beat_ticked", _on_beat_ticked)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_tick_escape_invulnerability(delta)
|
||||
if not enabled or _is_dead():
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
# 攻击动作进行中:位移交给动作本身。
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
_refresh_target()
|
||||
_tick_retreat_triggers(delta)
|
||||
_update_state()
|
||||
_update_movement()
|
||||
|
||||
|
||||
## ------------------------------------------------------- state transitions
|
||||
|
||||
|
||||
func _update_state() -> void:
|
||||
var distance := _distance_to_target()
|
||||
match state:
|
||||
STATE_ENTRY:
|
||||
# 近战怪的入场可以被警戒打断(追击等下一拍);远程怪坚持走到站位。
|
||||
# 每帧重算:玩家在下一拍前离开警戒范围就不再追。
|
||||
if role == ROLE_MELEE:
|
||||
_chase_pending = distance <= alert_range
|
||||
if _entry_reached():
|
||||
_arrive_at_battle_area()
|
||||
STATE_PATROL:
|
||||
_chase_pending = distance <= alert_range
|
||||
STATE_CHASE:
|
||||
if distance > alert_range * 1.25:
|
||||
# 玩家甩开警戒范围:回到当前位置附近小范围巡逻。
|
||||
state = STATE_PATROL
|
||||
_patrol_anchor_x = _owner_x()
|
||||
_chase_pending = false
|
||||
STATE_HOLD:
|
||||
if _should_escape():
|
||||
_begin_escape()
|
||||
elif _should_retreat(distance):
|
||||
_begin_retreat()
|
||||
STATE_RETREAT:
|
||||
if absf(_owner_x() - _retreat_target_x) <= 10.0:
|
||||
_finish_retreat()
|
||||
|
||||
|
||||
func _arrive_at_battle_area() -> void:
|
||||
entry_target_x = NAN
|
||||
if role == ROLE_MELEE:
|
||||
state = STATE_PATROL
|
||||
_patrol_anchor_x = _owner_x()
|
||||
else:
|
||||
# 到达站位后固定下来,开始距离判断。
|
||||
state = STATE_HOLD
|
||||
|
||||
|
||||
func _begin_retreat(escape := false) -> void:
|
||||
var own_x := _owner_x()
|
||||
var away := 1.0
|
||||
if target != null and is_instance_valid(target):
|
||||
away = 1.0 if own_x >= target.global_position.x else -1.0
|
||||
var candidate := clampf(own_x + away * retreat_distance, arena_min_x, arena_max_x)
|
||||
if absf(candidate - own_x) < 60.0:
|
||||
# 贴墙没有退路:跳向另一侧(穿过玩家身位)。
|
||||
candidate = clampf(own_x - away * retreat_distance, arena_min_x, arena_max_x)
|
||||
_retreat_target_x = candidate
|
||||
_too_close_time = 0.0
|
||||
_recent_hits = 0
|
||||
_retreat_cooldown_left = retreat_cooldown_seconds
|
||||
_escaping = escape
|
||||
if escape:
|
||||
_start_escape_invulnerability()
|
||||
state = STATE_RETREAT
|
||||
|
||||
|
||||
## 受击脱离(2026-07-05 定案):短暂闪烁无敌 + 跳跃形式快速脱离。
|
||||
func _begin_escape() -> void:
|
||||
_begin_retreat(true)
|
||||
|
||||
|
||||
func _finish_retreat() -> void:
|
||||
state = STATE_HOLD
|
||||
_escaping = false
|
||||
# 后撤/脱离后的短暂停顿:按节拍推迟下一次攻击决策(new3 §5.4)。
|
||||
_next_decision_beat = maxi(_next_decision_beat, _current_beat_index() + maxi(0, post_retreat_pause_beats) + 1)
|
||||
|
||||
|
||||
func _should_retreat(distance: float) -> bool:
|
||||
if role != ROLE_RANGED or _retreat_cooldown_left > 0.0:
|
||||
return false
|
||||
return _too_close_time >= too_close_seconds
|
||||
|
||||
|
||||
func _should_escape() -> bool:
|
||||
if role != ROLE_RANGED or _retreat_cooldown_left > 0.0:
|
||||
return false
|
||||
return _recent_hits >= maxi(1, retreat_after_hits)
|
||||
|
||||
|
||||
func _start_escape_invulnerability() -> void:
|
||||
_invuln_left = maxf(0.0, escape_invuln_seconds)
|
||||
_set_damage_receiver_enabled(false)
|
||||
|
||||
|
||||
func _tick_escape_invulnerability(delta: float) -> void:
|
||||
if _invuln_left <= 0.0:
|
||||
return
|
||||
_invuln_left = maxf(0.0, _invuln_left - delta)
|
||||
var visual := _owner_visual()
|
||||
if _invuln_left <= 0.0:
|
||||
if visual != null:
|
||||
visual.modulate.a = 1.0
|
||||
if not _is_dead():
|
||||
_set_damage_receiver_enabled(true)
|
||||
return
|
||||
if visual != null:
|
||||
# 闪烁表现:无敌期间快速明暗交替。
|
||||
visual.modulate.a = 0.35 if fmod(_invuln_left, 0.16) > 0.08 else 1.0
|
||||
|
||||
|
||||
## FrameCollisionDriver 每物理帧都会重申受击矩阵,所以无敌必须通过它的
|
||||
## damage_receiver_enabled 开关实现,而不是直接改 Area2D 的 monitoring。
|
||||
func _set_damage_receiver_enabled(enabled: bool) -> void:
|
||||
var collision_driver := get_node_or_null("../FrameCollisionDriver")
|
||||
if collision_driver != null and "damage_receiver_enabled" in collision_driver:
|
||||
collision_driver.set("damage_receiver_enabled", enabled)
|
||||
var receiver := get_node_or_null("../DamageReceiver") as Area2D
|
||||
if receiver != null:
|
||||
receiver.set_deferred("monitoring", enabled)
|
||||
receiver.set_deferred("monitorable", enabled)
|
||||
|
||||
|
||||
func _owner_visual() -> Node2D:
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return null
|
||||
return owner.get_node_or_null("Visual") as Node2D
|
||||
|
||||
|
||||
func _current_beat_index() -> int:
|
||||
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||
if rhythm != null:
|
||||
return int(rhythm.get("beat_index"))
|
||||
return 0
|
||||
|
||||
|
||||
func _tick_retreat_triggers(delta: float) -> void:
|
||||
_retreat_cooldown_left = maxf(0.0, _retreat_cooldown_left - delta)
|
||||
if _hit_window_left > 0.0:
|
||||
_hit_window_left = maxf(0.0, _hit_window_left - delta)
|
||||
if _hit_window_left <= 0.0:
|
||||
_recent_hits = 0
|
||||
if role == ROLE_RANGED and state == STATE_HOLD and _distance_to_target() <= too_close_range:
|
||||
_too_close_time += delta
|
||||
else:
|
||||
_too_close_time = maxf(0.0, _too_close_time - delta * 2.0)
|
||||
|
||||
|
||||
func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
if role != ROLE_RANGED:
|
||||
return
|
||||
if _hit_window_left <= 0.0:
|
||||
_recent_hits = 0
|
||||
_recent_hits += 1
|
||||
_hit_window_left = hit_chain_window_seconds
|
||||
|
||||
|
||||
## --------------------------------------------------------------- movement
|
||||
|
||||
|
||||
func _update_movement() -> void:
|
||||
match state:
|
||||
STATE_ENTRY:
|
||||
_move_toward_x(entry_target_x, 1.0)
|
||||
STATE_PATROL:
|
||||
_patrol_step()
|
||||
STATE_CHASE:
|
||||
_chase_step()
|
||||
STATE_HOLD:
|
||||
_set_approach_direction(0.0)
|
||||
_face_target()
|
||||
STATE_RETREAT:
|
||||
_move_toward_x(_retreat_target_x, escape_speed_scale if _escaping else retreat_speed_scale)
|
||||
_face_target()
|
||||
|
||||
|
||||
func _patrol_step() -> void:
|
||||
var own_x := _owner_x()
|
||||
var half := maxf(20.0, patrol_radius)
|
||||
var low := clampf(_patrol_anchor_x - half, arena_min_x, arena_max_x)
|
||||
var high := clampf(_patrol_anchor_x + half, arena_min_x, arena_max_x)
|
||||
if own_x <= low:
|
||||
_patrol_direction = 1.0
|
||||
elif own_x >= high:
|
||||
_patrol_direction = -1.0
|
||||
_set_approach_direction(_patrol_direction * patrol_speed_scale)
|
||||
_face_walk_direction(_patrol_direction)
|
||||
|
||||
|
||||
func _chase_step() -> void:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null or not is_instance_valid(target):
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
var delta_x := target.global_position.x - owner.global_position.x
|
||||
if absf(delta_x) <= maxf(8.0, approach_stop_range):
|
||||
_set_approach_direction(0.0)
|
||||
else:
|
||||
_set_approach_direction(signf(delta_x))
|
||||
_face_target()
|
||||
|
||||
|
||||
func _move_toward_x(target_x: float, speed_scale: float) -> void:
|
||||
if is_nan(target_x):
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
var delta_x := target_x - _owner_x()
|
||||
if absf(delta_x) <= 8.0:
|
||||
_set_approach_direction(0.0)
|
||||
return
|
||||
_set_approach_direction(signf(delta_x) * speed_scale)
|
||||
if state == STATE_ENTRY:
|
||||
_face_walk_direction(signf(delta_x))
|
||||
|
||||
|
||||
func _entry_reached() -> bool:
|
||||
return not _has_entry_move() or absf(entry_target_x - _owner_x()) <= 10.0
|
||||
|
||||
|
||||
func _has_entry_move() -> bool:
|
||||
return not is_nan(entry_target_x)
|
||||
|
||||
|
||||
func _set_approach_direction(direction: float) -> void:
|
||||
var owner := get_parent()
|
||||
if owner != null and "approach_direction" in owner:
|
||||
owner.set("approach_direction", direction)
|
||||
|
||||
|
||||
## ------------------------------------------------------------ beat driven
|
||||
|
||||
|
||||
func _on_beat_ticked(beat_index: int) -> void:
|
||||
if not enabled or _is_dead():
|
||||
return
|
||||
if _chase_pending and role == ROLE_MELEE and state != STATE_CHASE:
|
||||
# 指南 §7.4:警戒后在下一个节拍点进入追击状态。
|
||||
_chase_pending = false
|
||||
state = STATE_CHASE
|
||||
if action_controller != null and int(action_controller.get("phase")) != 0:
|
||||
return
|
||||
if beat_index < _next_decision_beat:
|
||||
return
|
||||
if state == STATE_ENTRY or state == STATE_RETREAT:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or not owner.has_method("current_action_ids"):
|
||||
return
|
||||
_refresh_target()
|
||||
if _distance_to_target() > attack_range:
|
||||
# Out of reach: keep repositioning (handled per-frame); re-check next beat.
|
||||
_next_decision_beat = beat_index + 1
|
||||
return
|
||||
_face_target()
|
||||
var action_ids: Array = owner.call("current_action_ids")
|
||||
if action_ids.is_empty():
|
||||
return
|
||||
var action_id := StringName(str(action_ids[_action_index % action_ids.size()]))
|
||||
_action_index += 1
|
||||
var interval := 1
|
||||
if owner.has_method("current_attack_interval_beats"):
|
||||
interval = maxi(1, int(owner.call("current_attack_interval_beats")))
|
||||
_next_decision_beat = beat_index + interval
|
||||
if driver != null and driver.has_method("start_action"):
|
||||
driver.call("start_action", action_id)
|
||||
|
||||
|
||||
## ----------------------------------------------------------------- lookup
|
||||
|
||||
|
||||
func _distance_to_target() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
if owner == null or target == null or not is_instance_valid(target):
|
||||
return INF
|
||||
return absf(target.global_position.x - owner.global_position.x)
|
||||
|
||||
|
||||
func _owner_x() -> float:
|
||||
var owner := get_parent() as Node2D
|
||||
return owner.global_position.x if owner != null else 0.0
|
||||
|
||||
|
||||
func _refresh_target() -> void:
|
||||
if target != null and is_instance_valid(target):
|
||||
return
|
||||
target = get_node_or_null(target_path) as Node2D
|
||||
if target != null:
|
||||
return
|
||||
var owner := get_parent()
|
||||
if owner == null or owner.get_parent() == null:
|
||||
return
|
||||
target = owner.get_parent().get_node_or_null("Player") as Node2D
|
||||
|
||||
|
||||
func _face_target() -> void:
|
||||
var owner := get_parent()
|
||||
if target != null and owner != null and owner.has_method("look_at_target"):
|
||||
owner.call("look_at_target", target)
|
||||
|
||||
|
||||
func _face_walk_direction(direction: float) -> void:
|
||||
var owner := get_parent()
|
||||
if owner == null or is_zero_approx(direction):
|
||||
return
|
||||
if "heading" in owner:
|
||||
owner.set("heading", Vector2.LEFT if direction < 0.0 else Vector2.RIGHT)
|
||||
|
||||
|
||||
func _is_dead() -> bool:
|
||||
if health_component != null and int(health_component.get("current")) <= 0:
|
||||
return true
|
||||
var owner := get_parent()
|
||||
if owner == null:
|
||||
return false
|
||||
var state_machine := owner.get_node_or_null("StateMachine")
|
||||
if state_machine != null and state_machine.has_method("build_context"):
|
||||
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
|
||||
return false
|
||||
|
||||
|
||||
func _event_bus_or_null() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().root.get_node_or_null("EventBus")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3aqe2ksj244o
|
||||
Reference in New Issue
Block a user