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)