608 lines
23 KiB
GDScript
608 lines
23 KiB
GDScript
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)
|