Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+607
View File
@@ -0,0 +1,607 @@
class_name Boss
extends Character
const DEFAULT_HEADING := Vector2.LEFT
const VISUAL_SCALE := 2.0
const DEFAULT_ANIMATION_FPS := 12.0
const BOSS_ANIMATION_SPECS := {
&"boss_idle": {"path": "res://assets/art/characters/boss/boss_idle.png", "frames": 1, "fps": 4.0, "loop": true},
&"boss_run": {"path": "res://assets/art/characters/boss/boss_run.png", "frames": 8, "fps": 12.0, "loop": true},
&"boss_dash": {"path": "res://assets/art/characters/boss/boss_dash.png", "frames": 6, "fps": 14.0, "loop": false},
&"boss_jump_fall": {"path": "res://assets/art/characters/boss/boss_jump_fall.png", "frames": 2, "fps": 8.0, "loop": false},
&"boss_take_hit": {"path": "res://assets/art/characters/boss/boss_take_hit.png", "frames": 1, "fps": 8.0, "loop": false},
&"boss_die": {"path": "res://assets/art/characters/boss/boss_die.png", "frames": 4, "fps": 8.0, "loop": false},
&"boss_ground_combo_1": {"path": "res://assets/art/characters/boss/boss_ground_combo_1.png", "frames": 8, "fps": 14.0, "loop": false},
&"boss_ground_combo_2": {"path": "res://assets/art/characters/boss/boss_ground_combo_2.png", "frames": 10, "fps": 14.0, "loop": false},
&"boss_ground_combo_3": {"path": "res://assets/art/characters/boss/boss_ground_combo_3.png", "frames": 14, "fps": 14.0, "loop": false},
&"boss_lunging_stab": {"path": "res://assets/art/characters/boss/boss_lunging_stab.png", "frames": 15, "fps": 15.0, "loop": false},
&"boss_shoot_1": {"path": "res://assets/art/characters/boss/boss_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
&"boss_shoot_2": {"path": "res://assets/art/characters/boss/boss_shoot_2.png", "frames": 2, "fps": 8.0, "loop": false},
&"boss_2way_shoot": {"path": "res://assets/art/characters/boss/boss_2way_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false},
}
@export var max_health := 28000
@export var current_health := 28000
## Training-dummy mode: the boss still attacks (BT + chart events) but never
## displaces — no walk/dash/retreat/lunge motion and no hit-chain escape.
@export var stationary := false
@export var combat_enabled := true:
set(value):
combat_enabled = value
_sync_combat_collision()
@export var visual_ground_offset := -40.0
@export var boss_lunge_speed := 150.0
@export var projectile_spawn_forward := 70.0
@export var projectile_spawn_height := 74.0
@export var escape_after_consecutive_hits := 4
@export var hit_chain_window_seconds := 1.2
@export var escape_cooldown_seconds := 4.0
@onready var state_machine: Node = get_node_or_null("StateMachine")
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
@onready var action_controller: Node = get_node_or_null("ActionController")
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
@onready var health_component: Node = get_node_or_null("HealthComponent")
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
@onready var frame_collision_driver: Node = get_node_or_null("FrameCollisionDriver")
var _current_action_animation: StringName = &""
var _frame_delta := 0.0
var _dead_state_entered := false
var _death_animation_started := false
var _hit_chain_count := 0
var _hit_chain_time_left := 0.0
var _escape_cooldown_left := 0.0
func _ready() -> void:
heading = DEFAULT_HEADING
anim_map = {
PRESENTATION_IDLE: "boss_idle",
PRESENTATION_WALK: "boss_run",
PRESENTATION_JUMP: "boss_jump_fall",
PRESENTATION_LAND: "boss_idle",
PRESENTATION_ATTACK: "boss_ground_combo_1",
PRESENTATION_AIR_ATTACK: "boss_ground_combo_1",
}
_install_boss_animations()
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
_wire_action_controller()
_wire_damage_receiver()
if health_component != null and health_component.has_method("set_values"):
health_component.call("set_values", current_health, max_health)
_sync_combat_collision()
state = PRESENTATION_IDLE
_play_animation(&"boss_idle")
func _physics_process(delta: float) -> void:
_frame_delta = delta
_tick_hit_chain(delta)
super._physics_process(delta)
func handle_input() -> void:
pass
## 2026-07-05 定案:Boss 对不消耗能量的攻击恒霸体——伤害照常结算,但不被
## 打断、不被击退;只有耗能技能(base_cost > 0)能压制 Boss。
func shrugs_off_hit(action: Resource) -> bool:
if action == null:
return true
return float(action.get("base_cost")) <= 0.0
## 2026-07-05 策划两轮调参:×3 后仍嫌短,再 ×3 → 距离 ×9(初速 ×3)。
## 只放大水平滑行,击飞高度不变;换算由 CombatManager 负责(距离 ∝ 初速²)。
func knockback_distance_taken_mult() -> float:
return 9.0
func handle_air_time(delta: float) -> void:
if movement_motor != null and movement_motor.has_method("handle_air_time"):
movement_motor.call("handle_air_time", delta)
else:
super.handle_air_time(delta)
func handle_movement() -> void:
if motion_executor != null and bool(motion_executor.get("active")):
velocity = motion_executor.call("tick", _frame_delta)
return
if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
return
# 击退残速期不清零,交给 MovementMotor 的摩擦衰减自然滑行——否则击退
# 初速活不过一帧,Boss 的水平击退位移恒为 0。
if not (movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray"))):
velocity.x = 0.0
if movement_motor != null and movement_motor.has_method("handle_movement"):
movement_motor.call("handle_movement")
else:
super.handle_movement()
func handle_animations() -> void:
var life_state := _life_state()
if life_state == &"Dead":
_enter_dead_state()
_play_death_animation_once()
_queue_free_if_death_animation_finished()
return
_dead_state_entered = false
_death_animation_started = false
if life_state == &"Hitstun":
_play_animation(&"boss_take_hit")
return
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
_play_animation(_current_action_animation)
return
super.handle_animations()
func set_heading() -> void:
pass
func set_sprite_height_position() -> void:
# Keep the editor-authored ground offset at runtime (WYSIWYG): without this
# override the Character base resets Visual to (0, -height) every physics
# tick and the boss art jumps 32px above its authored position.
if visual != null:
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
func flip_sprites() -> void:
if visual == null:
return
visual.scale.x = -VISUAL_SCALE if heading == Vector2.LEFT else VISUAL_SCALE
visual.scale.y = VISUAL_SCALE
func look_at_target(target: Node2D) -> void:
if target == null:
return
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
func projectile_spawn_position(_action: Resource) -> Vector2:
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -projectile_spawn_height)
func projectile_direction(_action: Resource) -> Vector2:
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
if action != null and StringName(str(action.get("id"))) == &"boss_2way_shoot":
return [
{
"spawn_position": global_position + Vector2(-projectile_spawn_forward, -projectile_spawn_height),
"direction": Vector2.LEFT,
},
{
"spawn_position": global_position + Vector2(projectile_spawn_forward, -projectile_spawn_height),
"direction": Vector2.RIGHT,
},
]
return [
{
"spawn_position": projectile_spawn_position(action),
"direction": projectile_direction(action),
},
]
func is_strong_in_current_time_phase() -> bool:
return true
func can_start_enemy_action(action_id: StringName) -> bool:
if not combat_enabled:
return false
# 击退滑行(趔趄)期间不得起手新动作:动作自带的突进位移会立刻接管
# velocity 并冲回玩家,把击退效果整个吃掉(实测净位移为负)。
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
return false
var behavior := get_node_or_null("BossBehaviorTree")
if behavior != null and behavior.has_method("phase_allows_action"):
return bool(behavior.call("phase_allows_action", action_id))
return true
func _sync_combat_collision() -> void:
var driver := frame_collision_driver
if driver == null:
driver = get_node_or_null("FrameCollisionDriver")
if driver != null:
driver.set("body_collision_enabled", combat_enabled)
driver.set("damage_receiver_enabled", combat_enabled)
driver.set("damage_emitter_enabled", combat_enabled)
if driver.has_method("refresh_now"):
driver.call("refresh_now")
if combat_enabled:
var tree := get_node_or_null("BossBehaviorTree")
if tree != null:
if tree.has_method("reset_for_combat_entry"):
tree.call("reset_for_combat_entry")
else:
tree.set("enabled", true)
func _install_boss_animations() -> void:
if animation_player == null:
return
var library: AnimationLibrary
if animation_player.has_animation_library(""):
library = animation_player.get_animation_library("")
else:
library = AnimationLibrary.new()
animation_player.add_animation_library("", library)
var base_sprite_offset := character_sprite.offset if character_sprite != null else Vector2(-40.0, -47.0)
var idle_anchor := _visible_bottom_anchor_for_spec(BOSS_ANIMATION_SPECS.get(&"boss_idle", {}), base_sprite_offset)
for animation_name: StringName in BOSS_ANIMATION_SPECS:
if library.has_animation(animation_name):
continue
var spec: Dictionary = BOSS_ANIMATION_SPECS[animation_name]
var texture := load(str(spec.get("path", ""))) as Texture2D
if texture == null:
continue
var align_anchor := idle_anchor if animation_name == &"boss_die" else INF
library.add_animation(animation_name, _make_sheet_animation(texture, int(spec.get("frames", 1)), float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), align_anchor, base_sprite_offset))
func _make_sheet_animation(texture: Texture2D, frame_count: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
var safe_frame_count := maxi(1, frame_count)
var safe_fps := maxf(1.0, fps)
var frame_time := 1.0 / safe_fps
var animation := Animation.new()
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
animation.step = frame_time
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
var texture_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(texture_track, 0.0, texture)
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(hframes_track, 0.0, safe_frame_count)
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(vframes_track, 0.0, 1)
var frame_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
for frame_index: int in range(safe_frame_count):
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
if visible_bottom_anchor < INF:
var offset_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
for frame_index: int in range(safe_frame_count):
var offset := sprite_offset
var bottom := _visible_frame_bottom_in_texture(texture, safe_frame_count, 1, frame_index)
if bottom != INF:
offset.y = visible_bottom_anchor - bottom
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
var fx_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(fx_track, NodePath("Visual/FxOverlay:visible"))
animation.value_track_set_update_mode(fx_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(fx_track, 0.0, false)
return animation
func _wire_action_controller() -> void:
if action_controller == null:
return
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
func _wire_damage_receiver() -> void:
if damage_receiver == null:
return
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
if not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
func _on_action_started(action: Resource, _intent) -> void:
if _life_state() == &"Dead":
return
_face_target_if_available()
_current_action_animation = StringName(str(action.get("animation")))
state = PRESENTATION_ATTACK
attack_time_left = _action_duration_seconds(action) + 0.05
attack_lunge_time_left = attack_time_left
_align_action_animation_speed(action)
if animation_player != null:
animation_player.stop()
_play_animation(_current_action_animation)
func _on_action_active_started(action: Resource, _intent) -> void:
if _life_state() == &"Dead":
return
if stationary:
return
if motion_executor == null:
return
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
return
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
motion_executor.call("execute", action, _motion_direction_for_action(action), _beat_time(), boss_lunge_speed * speed_scale)
func _on_action_finished(_action: Resource) -> void:
_current_action_animation = &""
_reset_animation_speed()
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
if _life_state() == &"Dead":
velocity = Vector2.ZERO
return
state = PRESENTATION_IDLE
velocity = Vector2.ZERO
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
_current_action_animation = &""
_reset_animation_speed()
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
if _life_state() == &"Dead":
velocity = Vector2.ZERO
return
state = PRESENTATION_IDLE
velocity = Vector2.ZERO
func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void:
if amount <= 0 or _life_state() == &"Dead":
return
# 2026-07-05 定案:霸体下的普攻不打断 Boss,但照样计入受击链——
# "被连打 4 次后撤逃脱"仍是防贴脸无脑磨血的兜底。
if _record_consecutive_hit_and_should_escape() and _can_escape_now():
if _trigger_hit_chain_escape():
return
if _life_state() == &"Hitstun":
_play_animation(&"boss_take_hit")
func _action_duration_seconds(action: Resource) -> float:
if action == null:
return attack_duration
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
func _align_action_animation_speed(action: Resource) -> void:
if animation_player == null or action == null or _current_action_animation.is_empty():
return
if not animation_player.has_animation(_current_action_animation):
return
var natural_length := animation_player.get_animation(_current_action_animation).length
var action_seconds := _action_duration_seconds(action)
if natural_length <= 0.0 or action_seconds <= 0.0:
return
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
func _reset_animation_speed() -> void:
if animation_player != null:
animation_player.speed_scale = 1.0
func _beat_time() -> float:
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5
func _face_target_if_available() -> void:
var target := _target_or_null()
if target != null:
look_at_target(target)
func _target_or_null() -> Node2D:
var tree := get_node_or_null("BossBehaviorTree")
if tree != null:
var tree_target = tree.get("target")
if tree_target is Node2D and is_instance_valid(tree_target):
return tree_target
var container := get_parent()
if container != null:
var sibling := container.get_node_or_null("Player") as Node2D
if sibling != null:
return sibling
return null
func _tick_hit_chain(delta: float) -> void:
_escape_cooldown_left = maxf(0.0, _escape_cooldown_left - delta)
if _hit_chain_time_left <= 0.0:
return
_hit_chain_time_left = maxf(0.0, _hit_chain_time_left - delta)
if _hit_chain_time_left <= 0.0:
_hit_chain_count = 0
func _can_escape_now() -> bool:
if stationary:
return false
if _escape_cooldown_left > 0.0:
return false
if state_machine != null and state_machine.has_method("build_context"):
if StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded"))) != &"Grounded":
return false
return true
func _record_consecutive_hit_and_should_escape() -> bool:
if _hit_chain_time_left <= 0.0:
_hit_chain_count = 0
_hit_chain_count += 1
_hit_chain_time_left = maxf(0.01, hit_chain_window_seconds)
return _hit_chain_count >= maxi(1, escape_after_consecutive_hits)
func _trigger_hit_chain_escape() -> bool:
_hit_chain_count = 0
_hit_chain_time_left = 0.0
_escape_cooldown_left = maxf(0.0, escape_cooldown_seconds)
_current_action_animation = &""
attack_time_left = 0.0
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO
# 强制撤退压倒击退滑行:清掉残速标志,否则 can_start_enemy_action 的
# 趔趄门禁会拦下 boss_retreat_dash,受击链逃脱失效。
if movement_motor != null and movement_motor.has_method("clear_knockback_stray"):
movement_motor.call("clear_knockback_stray")
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
if action_controller != null:
if action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
action_controller.call("cancel_current", &"hit_escape")
elif action_controller.has_method("_reset_to_idle"):
action_controller.call("_reset_to_idle")
if state_machine != null:
if state_machine.has_method("set_life_state"):
state_machine.call("set_life_state", &"Alive")
if state_machine.has_method("set_ground_state"):
state_machine.call("set_ground_state", &"Grounded")
if state_machine.has_method("set_action_phase"):
state_machine.call("set_action_phase", &"Neutral")
_face_target_if_available()
var driver := get_node_or_null("EnemyActionDriver")
if driver != null and driver.has_method("start_action"):
driver.call("start_action", &"boss_retreat_dash")
return true
return false
func _heading_sign() -> float:
return -1.0 if heading.x < 0.0 else 1.0
func _enter_dead_state() -> void:
if _dead_state_entered:
return
_dead_state_entered = true
_hit_chain_count = 0
_hit_chain_time_left = 0.0
_reset_animation_speed()
_current_action_animation = &""
attack_time_left = 0.0
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
action_controller.call("cancel_current", &"death")
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
var damage_emitter := get_node_or_null("DamageEmitter")
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
damage_emitter.call("clear_hit")
var receiver := get_node_or_null("DamageReceiver") as Area2D
if receiver != null:
receiver.monitoring = false
receiver.monitorable = false
var tree := get_node_or_null("BossBehaviorTree")
if tree != null:
tree.set("enabled", false)
func _play_death_animation_once() -> void:
if _death_animation_started:
return
_death_animation_started = true
if animation_player != null and animation_player.has_animation(&"boss_die"):
animation_player.play(&"boss_die")
func _on_animation_finished(animation_name: StringName) -> void:
if _death_animation_started and animation_name == &"boss_die":
queue_free()
func _queue_free_if_death_animation_finished() -> void:
if not _death_animation_started or is_queued_for_deletion():
return
if animation_player == null:
queue_free()
return
if not animation_player.is_playing():
queue_free()
func _visible_bottom_anchor_for_spec(spec: Dictionary, sprite_offset: Vector2) -> float:
var texture := load(str(spec.get("path", ""))) as Texture2D
if texture == null:
return INF
var frame_count := int(spec.get("frames", 1))
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, frame_count), 1, 0)
if bottom == INF:
return INF
return sprite_offset.y + bottom
func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float:
if texture == null:
return INF
var image := texture.get_image()
if image == null or image.is_empty():
return INF
var safe_hframes := maxi(1, hframes)
var safe_vframes := maxi(1, vframes)
var frame_width := image.get_width() / safe_hframes
var frame_height := image.get_height() / safe_vframes
var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1)
var column := safe_frame % safe_hframes
var row := int(safe_frame / safe_hframes)
var bottom := -1
for y: int in range(frame_height):
for x: int in range(frame_width):
var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y)
if pixel.a > 0.01:
bottom = y
if bottom < 0:
return INF
return float(bottom)
func _motion_direction_for_action(action: Resource) -> Vector2:
if _has_action_tag(action, &"retreat"):
return -heading
return heading
func _has_action_tag(action: Resource, tag: StringName) -> bool:
if action == null:
return false
var tags: Array = action.get("action_tags")
for item: Variant in tags:
if StringName(str(item)) == tag:
return true
return false
func _life_state() -> StringName:
if state_machine != null and state_machine.has_method("build_context"):
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
return &"Alive"
func _play_animation(animation_name: StringName) -> void:
if animation_player == null or animation_name.is_empty():
return
if animation_player.has_animation(animation_name) and animation_player.current_animation != String(animation_name):
animation_player.play(animation_name)