Files
rthythm_archor_game/scenes/enemies/minion.gd
T
2026-07-06 22:59:47 -07:00

666 lines
26 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name Minion
extends Character
const DEFAULT_HEADING := Vector2.LEFT
const DEFAULT_ANIMATION_FPS := 12.0
const VISIBLE_FEET_ANCHOR_Y := -1.0
const PHASE_SWAP_MOVEMENT_HOLD_FRAMES := 2
const FORM_SPECS := {
&"jin_zhan_1": {
"native_facing": Vector2.LEFT,
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_1/idle.png", "frames": 6, "fps": 8.0, "loop": true},
"death": {"path": "res://assets/art/characters/minions/jin_zhan_1/death.png", "frames": 6, "fps": 10.0, "loop": false},
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
"attacks": [
{"name": &"jin_zhan_1_attack", "path": "res://assets/art/characters/minions/jin_zhan_1/attack.png", "frames": 6, "fps": 12.0, "loop": false},
],
"actions": [&"minion_jin_zhan_1_attack"],
"sprite_offset": Vector2(-28.0, -48.0),
"projectile_height": 42.0,
},
&"jin_zhan_3": {
"native_facing": Vector2.RIGHT,
"idle": {"path": "res://assets/art/characters/minions/jin_zhan_3/idle.png", "frames": 7, "fps": 8.0, "loop": true},
"death": {"path": "res://assets/art/characters/minions/jin_zhan_3/death.png", "frames": 12, "fps": 12.0, "loop": false},
"dash": {"path": "res://assets/art/characters/minions/jin_zhan_3/dash.png", "frames": 5, "fps": 10.0, "loop": true},
"attacks": [
{"name": &"jin_zhan_3_attack_1", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
{"name": &"jin_zhan_3_attack_2", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
{"name": &"jin_zhan_3_attack_3", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_3.png", "frames": 6, "fps": 12.0, "loop": false},
],
"actions": [&"minion_jin_zhan_3_attack_1", &"minion_jin_zhan_3_attack_2", &"minion_jin_zhan_3_attack_3"],
"sprite_offset": Vector2(-47.0, -62.0),
"projectile_height": 44.0,
},
&"yuan_cheng_1": {
# 素材原生朝右(眼睛与挥击弧线都在右侧);朝左时再做镜像。
"native_facing": Vector2.RIGHT,
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_1/idle.png", "frames": 8, "fps": 8.0, "loop": true},
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_1/death.png", "frames": 7, "fps": 10.0, "loop": false},
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_1/dash.png", "frames": 6, "fps": 10.0, "loop": true},
"attacks": [
{"name": &"yuan_cheng_1_attack_1", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_1.png", "frames": 6, "fps": 12.0, "loop": false},
{"name": &"yuan_cheng_1_attack_2", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_2.png", "frames": 5, "fps": 12.0, "loop": false},
],
"actions": [&"minion_yuan_cheng_1_attack_1", &"minion_yuan_cheng_1_attack_2"],
"sprite_offset": Vector2(-39.0, -48.0),
# 可见脚底在 actor 原点上方约 40px,出手点再高约 46px(甩击释放高度),
# 两个远程形态共用同一条弹道,切相位不跳变。
"projectile_height": 86.0,
},
&"yuan_cheng_2": {
"native_facing": Vector2.RIGHT,
"visual_scale": 1.12,
"idle": {"path": "res://assets/art/characters/minions/yuan_cheng_2/idle.png", "frames": 10, "fps": 8.0, "loop": true},
"death": {"path": "res://assets/art/characters/minions/yuan_cheng_2/death.png", "frames": 18, "fps": 14.0, "loop": false},
"dash": {"path": "res://assets/art/characters/minions/yuan_cheng_2/dash.png", "frames": 3, "fps": 10.0, "loop": true},
"attacks": [
{"name": &"yuan_cheng_2_attack", "path": "res://assets/art/characters/minions/yuan_cheng_2/attack.png", "frames": 13, "fps": 14.0, "loop": false},
],
"actions": [&"minion_yuan_cheng_2_attack"],
"sprite_offset": Vector2(-74.0, -97.0),
# 对齐法杖顶端法球中心(脚底上方约 49px),并与小女孩共用同一条弹道。
"projectile_height": 86.0,
},
}
@export var max_health := 650
@export var current_health := 650
@export var past_form_id: StringName = &"jin_zhan_3"
@export var future_form_id: StringName = &"jin_zhan_1"
@export var strength_profile: StringName = &"past_strong"
@export var time_phase_profile: Resource
@export var visual_scale := 2.0
@export var visual_ground_offset := -38.0
@export var projectile_spawn_forward := 44.0
@export var minion_lunge_speed := 100.0
@export var approach_speed := 120.0
## Set by MinionBehavior each physics frame: signed walk intent whose magnitude
## scales approach_speed (patrol strolls at ~0.45, a ranged retreat hops at ~1.7).
var approach_direction := 0.0
@onready var state_machine: Node = get_node_or_null("StateMachine")
@onready var action_controller: Node = get_node_or_null("ActionController")
@onready var motion_executor: Node = get_node_or_null("MotionExecutor")
@onready var movement_motor: Node = get_node_or_null("MovementMotor")
@onready var health_component: Node = get_node_or_null("HealthComponent")
@onready var damage_receiver: Node = get_node_or_null("DamageReceiver")
@onready var strong_buff_visual: Node = get_node_or_null("Visual/StrongBuffVisual")
@onready var overhead_health_bar: Node2D = get_node_or_null("OverheadHealthBar") as Node2D
var form_id: StringName = &""
var _pending_form_id: StringName = &""
var _current_action_animation: StringName = &""
var _frame_delta := 0.0
var _dead_state_entered := false
var _death_animation_started := false
var _phase_swap_hold_frames := 0
func _ready() -> void:
heading = DEFAULT_HEADING
anim_map = {
PRESENTATION_IDLE: "idle",
PRESENTATION_WALK: "idle",
PRESENTATION_JUMP: "idle",
PRESENTATION_LAND: "idle",
PRESENTATION_ATTACK: "idle",
PRESENTATION_AIR_ATTACK: "idle",
}
_install_minion_animations()
_connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished"))
_wire_action_controller()
_wire_damage_receiver()
_connect_time_phase_bus()
_apply_time_phase_profile()
if health_component != null and health_component.has_method("set_values"):
var health_multiplier := _difficulty_health_multiplier()
health_component.call("set_values", int(round(current_health * health_multiplier)), int(round(max_health * health_multiplier)))
apply_time_phase(_current_time_phase())
state = PRESENTATION_IDLE
_play_idle()
func _physics_process(delta: float) -> void:
_frame_delta = delta
super._physics_process(delta)
func handle_input() -> void:
pass
## 击退滑行(趔趄)期间不得起手新动作(EnemyActionDriver 钩子,boss 同款):
## 否则动作突进会立刻接管 velocity,把击退位移吃掉。
func can_start_enemy_action(_action_id: StringName) -> bool:
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
return false
return true
func handle_air_time(delta: float) -> void:
# 委托 MovementMotorboss 同款):击退残速的摩擦衰减、假高度轴积分、
# 落地复位 Grounded 全在里面。旧版只在 JUMP 态积分,导致垂直击退后
# ground_state 永久卡 Airborne,小怪从此不能出招(allowed_ground_states)。
if movement_motor != null and movement_motor.has_method("handle_air_time"):
movement_motor.call("handle_air_time", delta)
elif state == PRESENTATION_JUMP:
super.handle_air_time(delta)
func handle_movement() -> void:
if motion_executor != null and bool(motion_executor.get("active")):
velocity = motion_executor.call("tick", _frame_delta)
return
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
return
if _phase_swap_hold_frames > 0:
_phase_swap_hold_frames -= 1
approach_direction = 0.0
velocity.x = 0.0
state = PRESENTATION_IDLE
return
# 击退残速期:滑行交给 MovementMotor 的摩擦衰减,AI 不接管 velocity.x
# (否则击退初速活不过一帧,小怪击退位移恒为 0)。必须放在相位切换
# hold 之后——换相位定身优先,hold 清零速度后 stray 标志随衰减自清。
if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")):
state = PRESENTATION_IDLE
return
if absf(approach_direction) > 0.05 and _life_state() != &"Dead":
# 上限 4.0 给受击脱离的跳跃速度(escape_speed_scale 3.2)留出空间。
velocity.x = clampf(approach_direction, -4.0, 4.0) * approach_speed
state = PRESENTATION_WALK
return
velocity.x = 0.0
state = PRESENTATION_IDLE
func handle_animations() -> void:
var life_state := _life_state()
if life_state == &"Dead":
_enter_dead_state()
_play_death_animation_once()
_queue_free_if_death_animation_finished()
return
_dead_state_entered = false
_death_animation_started = false
if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty():
_play_animation(_current_action_animation)
return
if state == PRESENTATION_WALK:
_play_animation(_dash_animation_name(form_id))
return
_play_idle()
func set_heading() -> void:
pass
func set_sprite_height_position() -> void:
if visual != null:
visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height
_refresh_overhead_health_bar_position()
func flip_sprites() -> void:
_apply_form_visual_scale()
func apply_time_phase(time_phase: StringName) -> void:
_refresh_strong_buff_visual(time_phase)
var next_form := future_form_id if time_phase == &"future" else past_form_id
if next_form == form_id:
_pending_form_id = &""
if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK and _life_state() != &"Dead":
_apply_sprite_offset()
_play_idle(true)
return
if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK:
_pending_form_id = next_form
return
_hold_phase_swap_position()
form_id = next_form
_pending_form_id = &""
_apply_sprite_offset()
if _life_state() != &"Dead":
_play_idle(true)
func current_action_ids() -> Array[StringName]:
var result: Array[StringName] = []
var actions: Array = _form_spec().get("actions", [])
for action_id: Variant in actions:
result.append(StringName(str(action_id)))
return result
func current_attack_interval_beats() -> int:
return 2 if is_strong_in_current_time_phase() else 4
func is_strong_in_current_time_phase() -> bool:
return _is_strong_in_time_phase(_current_time_phase())
func _is_strong_in_time_phase(time_phase: StringName) -> bool:
return (strength_profile == &"past_strong" and time_phase == &"past") or (strength_profile == &"future_strong" and time_phase == &"future")
func look_at_target(target: Node2D) -> void:
if target == null:
return
heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT
func projectile_spawn_position(_action: Resource) -> Vector2:
return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -_projectile_height())
func projectile_direction(_action: Resource) -> Vector2:
return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING
func projectile_requests_for_action(action: Resource) -> Array[Dictionary]:
return [{
"spawn_position": projectile_spawn_position(action),
"direction": projectile_direction(action),
}]
func _install_minion_animations() -> void:
if animation_player == null:
return
var library: AnimationLibrary
if animation_player.has_animation_library(""):
library = animation_player.get_animation_library("")
else:
library = AnimationLibrary.new()
animation_player.add_animation_library("", library)
for next_form: StringName in FORM_SPECS.keys():
var spec: Dictionary = FORM_SPECS[next_form]
var sprite_offset: Vector2 = spec.get("sprite_offset", Vector2(-32.0, -64.0))
_add_sheet_animation(library, _idle_animation_name(next_form), spec.get("idle", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
_add_sheet_animation(library, _death_animation_name(next_form), spec.get("death", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
_add_sheet_animation(library, _dash_animation_name(next_form), spec.get("dash", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset)
for attack: Variant in spec.get("attacks", []):
if attack is Dictionary:
_add_sheet_animation(library, StringName(str((attack as Dictionary).get("name", &""))), attack, VISIBLE_FEET_ANCHOR_Y, sprite_offset)
func _add_sheet_animation(library: AnimationLibrary, animation_name: StringName, spec: Dictionary, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> void:
if animation_name.is_empty() or spec.is_empty() or library.has_animation(animation_name):
return
var texture := load(str(spec.get("path", ""))) as Texture2D
if texture == null:
return
var frame_count: int = int(spec.get("frames", 1))
var hframes: int = int(spec.get("hframes", frame_count))
library.add_animation(animation_name, _make_sheet_animation(texture, frame_count, hframes, float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), visible_bottom_anchor, sprite_offset))
func _make_sheet_animation(texture: Texture2D, frame_count: int, hframes: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation:
var safe_frame_count := maxi(1, frame_count)
var safe_hframes := maxi(safe_frame_count, hframes)
var safe_fps := maxf(1.0, fps)
var frame_time := 1.0 / safe_fps
var animation := Animation.new()
animation.length = maxf(frame_time, float(safe_frame_count) * frame_time)
animation.step = frame_time
animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
var texture_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture"))
animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(texture_track, 0.0, texture)
var hframes_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes"))
animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(hframes_track, 0.0, safe_hframes)
var vframes_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes"))
animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE)
animation.track_insert_key(vframes_track, 0.0, 1)
var frame_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame"))
animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE)
for frame_index: int in range(safe_frame_count):
animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index)
if visible_bottom_anchor < INF:
var offset_track := animation.add_track(Animation.TYPE_VALUE)
animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset"))
animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE)
for frame_index: int in range(safe_frame_count):
var offset := sprite_offset
var bottom := _visible_frame_bottom_in_texture(texture, safe_hframes, 1, frame_index)
if bottom != INF:
offset.y = visible_bottom_anchor - bottom
animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset)
return animation
func _wire_action_controller() -> void:
if action_controller == null:
return
_connect_once(action_controller, &"action_started", Callable(self, "_on_action_started"))
_connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started"))
_connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished"))
_connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled"))
func _wire_damage_receiver() -> void:
if damage_receiver != null:
_connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received"))
func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void:
if not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
func _connect_time_phase_bus() -> void:
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed):
bus.connect("time_phase_changed", _on_time_phase_changed)
func _apply_time_phase_profile() -> void:
var adapter := get_node_or_null("TimePhaseAdapter")
if adapter == null:
return
adapter.set("profile", time_phase_profile)
if adapter.has_method("apply_time_phase"):
adapter.call("apply_time_phase", _current_time_phase())
func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void:
apply_time_phase(current)
func _on_action_started(action: Resource, _intent) -> void:
if _life_state() == &"Dead":
return
_face_target_if_available()
_current_action_animation = StringName(str(action.get("animation")))
state = PRESENTATION_ATTACK
attack_time_left = _action_duration_seconds(action) + 0.05
attack_lunge_time_left = attack_time_left
_align_action_animation_speed(action)
if animation_player != null:
animation_player.stop()
_play_animation(_current_action_animation)
func _on_action_active_started(action: Resource, _intent) -> void:
if _life_state() == &"Dead" or motion_executor == null:
return
if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0:
return
var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x"))))
motion_executor.call("execute", action, heading, _beat_time(), minion_lunge_speed * speed_scale)
func _on_action_finished(_action: Resource) -> void:
_finish_action_presentation()
func _on_action_cancelled(_action: Resource, _reason: StringName) -> void:
_finish_action_presentation()
func _finish_action_presentation() -> void:
_current_action_animation = &""
_reset_animation_speed()
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
if _life_state() == &"Dead":
velocity = Vector2.ZERO
return
state = PRESENTATION_IDLE
velocity = Vector2.ZERO
if not _pending_form_id.is_empty():
apply_time_phase(_current_time_phase())
_play_idle()
func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
if _life_state() == &"Dead":
return
_play_idle()
func _enter_dead_state() -> void:
if _dead_state_entered:
return
_dead_state_entered = true
_current_action_animation = &""
attack_time_left = 0.0
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO
_reset_animation_speed()
if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0:
action_controller.call("cancel_current", &"death")
if motion_executor != null and motion_executor.has_method("cancel"):
motion_executor.call("cancel")
var damage_emitter := get_node_or_null("DamageEmitter")
if damage_emitter != null and damage_emitter.has_method("clear_hit"):
damage_emitter.call("clear_hit")
var receiver := get_node_or_null("DamageReceiver") as Area2D
if receiver != null:
receiver.monitoring = false
receiver.monitorable = false
var behavior := get_node_or_null("MinionBehavior")
if behavior != null:
behavior.set("enabled", false)
_refresh_strong_buff_visual(_current_time_phase())
func _play_death_animation_once() -> void:
if _death_animation_started:
return
_death_animation_started = true
_play_animation(_death_animation_name(form_id))
func _on_animation_finished(animation_name: StringName) -> void:
if _death_animation_started and animation_name == _death_animation_name(form_id):
queue_free()
func _queue_free_if_death_animation_finished() -> void:
if not _death_animation_started or is_queued_for_deletion():
return
if animation_player == null:
queue_free()
return
if not animation_player.is_playing():
queue_free()
func _play_idle(force_refresh := false) -> void:
_play_animation(_idle_animation_name(form_id), force_refresh)
func _play_animation(animation_name: StringName, force_refresh := false) -> void:
if animation_player == null or animation_name.is_empty():
return
if not animation_player.has_animation(animation_name):
return
if force_refresh or animation_player.current_animation != String(animation_name):
animation_player.play(animation_name)
if force_refresh:
animation_player.seek(0.0, true)
func _align_action_animation_speed(action: Resource) -> void:
if animation_player == null or action == null or _current_action_animation.is_empty():
return
if not animation_player.has_animation(_current_action_animation):
return
var natural_length := animation_player.get_animation(_current_action_animation).length
var action_seconds := _action_duration_seconds(action)
if natural_length <= 0.0 or action_seconds <= 0.0:
return
animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0)
func _reset_animation_speed() -> void:
if animation_player != null:
animation_player.speed_scale = 1.0
func _action_duration_seconds(action: Resource) -> float:
if action == null:
return attack_duration
return maxf(0.05, float(action.get("action_beats")) * _beat_time())
func _beat_time() -> float:
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5
func _face_target_if_available() -> void:
var target := _target_or_null()
if target != null:
look_at_target(target)
func _target_or_null() -> Node2D:
var behavior := get_node_or_null("MinionBehavior")
if behavior != null:
var behavior_target = behavior.get("target")
if behavior_target is Node2D and is_instance_valid(behavior_target):
return behavior_target
var container := get_parent()
if container != null:
var sibling := container.get_node_or_null("Player") as Node2D
if sibling != null:
return sibling
return null
func _form_spec() -> Dictionary:
return FORM_SPECS.get(form_id, FORM_SPECS.get(past_form_id, {}))
func _form_visual_scale() -> float:
return float(_form_spec().get("visual_scale", visual_scale))
func _native_facing() -> Vector2:
return _form_spec().get("native_facing", Vector2.RIGHT)
func _apply_sprite_offset() -> void:
if character_sprite != null:
var sprite_offset: Vector2 = _form_spec().get("sprite_offset", Vector2(-32.0, -64.0))
var idle_spec: Dictionary = _form_spec().get("idle", {})
var texture := load(str(idle_spec.get("path", ""))) as Texture2D
var hframes := int(idle_spec.get("hframes", idle_spec.get("frames", 1)))
var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, hframes), 1, 0)
sprite_offset.y = VISIBLE_FEET_ANCHOR_Y - bottom if bottom < INF else sprite_offset.y
character_sprite.offset = sprite_offset
_apply_form_visual_scale()
_refresh_overhead_health_bar_position()
func _apply_form_visual_scale() -> void:
if visual == null:
return
var scale_value := _form_visual_scale()
visual.scale.x = scale_value if heading == _native_facing() else -scale_value
visual.scale.y = scale_value
func _hold_phase_swap_position() -> void:
_phase_swap_hold_frames = PHASE_SWAP_MOVEMENT_HOLD_FRAMES
approach_direction = 0.0
velocity.x = 0.0
func _refresh_overhead_health_bar_position() -> void:
if overhead_health_bar == null or visual == null or character_sprite == null:
return
overhead_health_bar.position = Vector2(0.0, visual.position.y + character_sprite.offset.y * absf(visual.scale.y) - 10.0)
func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float:
if texture == null:
return INF
var image := texture.get_image()
if image == null or image.is_empty():
return INF
var safe_hframes := maxi(1, hframes)
var safe_vframes := maxi(1, vframes)
var frame_width := image.get_width() / safe_hframes
var frame_height := image.get_height() / safe_vframes
var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1)
var column := safe_frame % safe_hframes
var row := int(safe_frame / safe_hframes)
var bottom := -1
for y: int in range(frame_height):
for x: int in range(frame_width):
var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y)
if pixel.a > 0.01:
bottom = y
if bottom < 0:
return INF
return float(bottom)
func _projectile_height() -> float:
return float(_form_spec().get("projectile_height", 42.0))
func _idle_animation_name(next_form: StringName) -> StringName:
return StringName("%s_idle" % str(next_form))
func _death_animation_name(next_form: StringName) -> StringName:
return StringName("%s_death" % str(next_form))
func _dash_animation_name(next_form: StringName) -> StringName:
return StringName("%s_dash" % str(next_form))
func _life_state() -> StringName:
if state_machine != null and state_machine.has_method("build_context"):
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive")))
return &"Alive"
func _heading_sign() -> float:
return -1.0 if heading.x < 0.0 else 1.0
func _current_time_phase() -> StringName:
var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null
if manager != null:
return StringName(str(manager.get("current_time_phase")))
return &"past"
func _difficulty_health_multiplier() -> float:
var settings := get_tree().root.get_node_or_null("GameSettings") if is_inside_tree() else null
if settings != null and settings.has_method("minion_health_multiplier"):
return maxf(0.01, float(settings.call("minion_health_multiplier")))
return 1.0
func _refresh_strong_buff_visual(time_phase: StringName = &"") -> void:
if strong_buff_visual == null or not strong_buff_visual.has_method("set_active"):
return
var phase := _current_time_phase() if time_phase.is_empty() else time_phase
strong_buff_visual.call("set_active", _is_strong_in_time_phase(phase) and _life_state() != &"Dead")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")