diff --git a/actors/bosses/town/emperor_boss.gd b/actors/bosses/town/emperor_boss.gd index ecc365c..7936e01 100644 --- a/actors/bosses/town/emperor_boss.gd +++ b/actors/bosses/town/emperor_boss.gd @@ -12,22 +12,41 @@ const MELEE_EFFECT_TEXTURE_PATH := "res://assets/effects/vfx/magic_circle.webp" @export var max_hp: float = 5000.0 @export var defense_value: float = 260.0 -@export var move_speed: float = 165.0 +@export var move_speed: float = 150.0 @export var attack_damage: float = 42.0 @export var attack_range: float = 96.0 @export var attack_arc_degrees: float = 120.0 -@export var attack_interval: float = 1.05 +@export var attack_interval: float = 2.1 @export var phase_two_threshold: float = 2000.0 @export var phase_transition_duration: float = 0.9 -@export var skill_interval_phase_one: float = 5.0 -@export var skill_interval_phase_two: float = 3.0 -@export var shadow_step_duration: float = 0.55 -@export var shadow_step_slash_damage: float = 70.0 -@export var charge_skill_damage: float = 88.0 -@export var charge_distance: float = 280.0 -@export var arcane_burst_damage: float = 66.0 -@export var arcane_burst_radius: float = 130.0 + +@export var skill_interval: float = 5.8 + +@export var dash_charge_duration: float = 0.85 +@export var dash_distance: float = 420.0 +@export var dash_duration: float = 0.34 +@export var dash_damage: float = 78.0 +@export var dash_hit_radius: float = 82.0 +@export var dash_lane_width: float = 34.0 + +@export var shock_charge_duration: float = 1.25 +@export var shock_radius: float = 260.0 +@export var shock_damage: float = 82.0 +@export var shock_slow_duration: float = 3.0 +@export var shock_slow_multiplier: float = 0.74 +@export var shock_outer_ring_radius: float = 430.0 +@export var shock_outer_ring_damage: float = 56.0 + +@export var volley_shot_count_phase_one: int = 18 +@export var volley_shot_count_phase_two: int = 30 +@export var volley_interval_phase_one: float = 0.18 +@export var volley_interval_phase_two: float = 0.13 +@export var volley_center_radius_phase_one: float = 150.0 +@export var volley_center_radius_phase_two: float = 200.0 @export var volley_damage: float = 24.0 +@export var volley_bullet_speed: float = 390.0 +@export var volley_prediction_time: float = 0.34 +@export var volley_ring_shots: int = 8 @onready var body: Polygon2D = $Body @onready var weapon: Node2D = $Weapon @@ -42,25 +61,35 @@ var hp: float = 0.0 var state: StringName = &"idle" var state_time: float = 0.0 var attack_cooldown: float = 0.0 -var skill_cooldown: float = skill_interval_phase_one +var skill_cooldown: float = skill_interval var current_skill: StringName = &"" var line_direction: Vector2 = Vector2.RIGHT var action_committed: bool = false var phase_two: bool = false var invulnerable: bool = false -var charge_start_position: Vector2 = Vector2.ZERO -var charge_direction: Vector2 = Vector2.RIGHT -var burst_center: Vector2 = Vector2.ZERO -var shadow_reposition_point: Vector2 = Vector2.ZERO var body_sprite: Sprite2D = null var weapon_sprite: Sprite2D = null var weapon_angle_offset: float = 0.0 var melee_texture: Texture2D = null var visual_last_position: Vector2 = Vector2.ZERO var visual_bob_time: float = 0.0 +var skill_cycle_index: int = 0 +var dash_step_index: int = 0 +var dash_total_steps: int = 1 +var dash_start_position: Vector2 = Vector2.ZERO +var dash_target_position: Vector2 = Vector2.ZERO +var dash_active_damage: float = 0.0 +var shock_payload: Dictionary = {} +var volley_shots_remaining: int = 0 +var volley_next_fire_time: float = 0.0 +var volley_center_position: Vector2 = Vector2.ZERO +var volley_rng := RandomNumberGenerator.new() +var target_last_position: Vector2 = Vector2.ZERO +var target_velocity_estimate: Vector2 = Vector2.ZERO func _ready() -> void: add_to_group("damageable") + volley_rng.randomize() health_component.setup(max_hp, defense_value) health_component.damaged.connect(_on_damaged) health_component.died.connect(_on_died) @@ -75,21 +104,24 @@ func _ready() -> void: func bind_player(player: Node2D) -> void: target = player + if target != null and is_instance_valid(target): + target_last_position = target.global_position func get_status_title() -> String: return "Emperor" func get_status_text() -> String: - return "HP %d / %d\nSkill cadence %.1fs\nState: %s" % [ + return "HP %d / %d\nSkill %.1fs\nState: %s" % [ int(round(hp)), int(round(max_hp)), - skill_interval_phase_two if phase_two else skill_interval_phase_one, + skill_interval, String(state) ] func _physics_process(delta: float) -> void: if state == &"dead": return + _update_target_tracking(delta) if target == null or not _is_targetable_player(target): _find_target() attack_cooldown = maxf(attack_cooldown - delta, 0.0) @@ -108,10 +140,21 @@ func receive_hit(payload: Dictionary) -> void: if final_damage > 0.0: _spawn_damage_number(final_damage, bool(result.get("is_critical", false))) +func _update_target_tracking(delta: float) -> void: + if target == null or not is_instance_valid(target): + target_velocity_estimate = Vector2.ZERO + return + var current_position := target.global_position + if delta > 0.0: + target_velocity_estimate = (current_position - target_last_position) / delta + target_last_position = current_position + func _find_target() -> void: for player in get_tree().get_nodes_in_group("player"): if _is_targetable_player(player): target = player + if target != null and is_instance_valid(target): + target_last_position = target.global_position return target = null @@ -132,12 +175,14 @@ func _update_state(delta: float) -> void: _process_idle(delta) &"basic_attack": _process_basic_attack() - &"skill_shadow": - _process_skill_shadow() - &"skill_charge": - _process_skill_charge(delta) - &"skill_burst": - _process_skill_burst() + &"skill_dash_charge": + _process_skill_dash_charge() + &"skill_dash": + _process_skill_dash(delta) + &"skill_shock_charge": + _process_skill_shock_charge() + &"skill_shock_release": + _process_skill_shock_release() &"skill_volley": _process_skill_volley() &"phase_transition": @@ -156,7 +201,7 @@ func _process_idle(delta: float) -> void: if attack_cooldown <= 0.0 and distance <= attack_range: _start_basic_attack() return - if distance > attack_range * 0.86: + if distance > attack_range * 0.9: global_position += line_direction * move_speed * delta func _start_basic_attack() -> void: @@ -164,117 +209,164 @@ func _start_basic_attack() -> void: state_time = 0.0 action_committed = false attack_cooldown = attack_interval + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), global_position, 0.92) _animate_weapon_swing(-42.0, 20.0, 0.26) func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.24: + if not action_committed and state_time >= 1.0: action_committed = true _hit_target_in_arc(attack_range, attack_damage, attack_arc_degrees) _spawn_slash_effect(attack_range, Color(1.0, 0.84, 0.58, 0.92)) - if state_time >= 0.58: + if state_time >= 1.34: _return_to_idle() func _start_skill_cast() -> void: current_skill = _pick_skill() action_committed = false state_time = 0.0 - skill_cooldown = skill_interval_phase_two if phase_two else skill_interval_phase_one + skill_cooldown = skill_interval match current_skill: - &"shadow": - state = &"skill_shadow" - shadow_reposition_point = global_position - if target != null and is_instance_valid(target): - var side := Vector2(64.0, 0.0) - side.x *= -1.0 if int(Time.get_ticks_msec() / 100) % 2 == 0 else 1.0 - shadow_reposition_point = target.global_position + side - &"charge": - state = &"skill_charge" - charge_start_position = global_position - charge_direction = (target.global_position - global_position).normalized() if target != null else line_direction - if charge_direction == Vector2.ZERO: - charge_direction = Vector2.RIGHT - _animate_weapon_swing(-46.0, 12.0, 0.32) - &"burst": - state = &"skill_burst" - burst_center = target.global_position if target != null else global_position - burst_ring.visible = true - burst_ring.global_position = burst_center - burst_ring.scale = Vector2.ONE * 0.4 - burst_ring.modulate = Color(0.82, 0.9, 1.0, 0.92) + &"dash": + _start_dash_skill() + &"shock": + _start_shock_skill() &"volley": - state = &"skill_volley" - _animate_weapon_swing(-28.0, 12.0, 0.24) + _start_volley_skill() func _pick_skill() -> StringName: - var pool: Array[StringName] = [&"shadow", &"charge", &"burst", &"volley"] - return pool[randi() % pool.size()] + var pool: Array[StringName] = [&"dash", &"shock", &"volley"] + var picked := pool[skill_cycle_index % pool.size()] + skill_cycle_index += 1 + return picked -func _process_skill_shadow() -> void: - invulnerable = true - body.modulate = Color(0.72, 0.8, 1.0, 0.34) - if state_time >= shadow_step_duration * 0.6 and not action_committed: - action_committed = true - global_position = shadow_reposition_point - if target != null and is_instance_valid(target): - line_direction = (target.global_position - global_position).normalized() - if line_direction == Vector2.ZERO: - line_direction = Vector2.RIGHT - _animate_weapon_swing(-44.0, 18.0, 0.18) - _hit_target_in_arc(112.0, shadow_step_slash_damage, 145.0) - _spawn_slash_effect(112.0, Color(0.8, 0.9, 1.0, 0.95)) - if state_time >= shadow_step_duration: - invulnerable = false - _return_to_idle() +func _start_dash_skill() -> void: + state = &"skill_dash_charge" + state_time = 0.0 + dash_step_index = 0 + dash_total_steps = 2 if phase_two else 1 + dash_active_damage = dash_damage + _prepare_dash_target(true) + _show_line_marker(dash_start_position, dash_target_position, dash_lane_width * 2.0, Color(1.0, 0.78, 0.42, 0.82)) + _show_intent_text("Dash Slash", Color(1.0, 0.76, 0.54, 1.0), global_position, 0.9) + _animate_weapon_swing(-50.0, 10.0, dash_charge_duration) + +func _prepare_dash_target(use_current_position: bool) -> void: + dash_start_position = global_position + var aim_position := global_position + line_direction * dash_distance + if target != null and is_instance_valid(target): + var player_position := target.global_position if use_current_position else (target.global_position + target_velocity_estimate * 0.12) + var to_target := player_position - global_position + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() + aim_position = global_position + line_direction * dash_distance + dash_target_position = aim_position -func _process_skill_charge(delta: float) -> void: - if state_time < 0.26: +func _process_skill_dash_charge() -> void: + _show_line_marker(dash_start_position, dash_target_position, dash_lane_width * 2.0, Color(1.0, 0.78, 0.42, 0.82)) + if state_time < dash_charge_duration: return - global_position += charge_direction * (charge_distance / 0.26) * delta + burst_ring.visible = false + state = &"skill_dash" + state_time = 0.0 + action_committed = false + +func _process_skill_dash(delta: float) -> void: + var dash_time := dash_duration * (0.7 if phase_two else 1.0) + var progress := clampf(state_time / maxf(dash_time, 0.001), 0.0, 1.0) + global_position = dash_start_position.lerp(dash_target_position, progress) if not action_committed: action_committed = true - _hit_target_in_arc(130.0, charge_skill_damage, 95.0) - _spawn_slash_effect(130.0, Color(1.0, 0.78, 0.56, 0.95)) - if state_time >= 0.56: + _hit_targets_in_lane(dash_start_position, dash_target_position, dash_lane_width, dash_active_damage) + _spawn_slash_effect(dash_hit_radius, Color(1.0, 0.78, 0.56, 0.95)) + if progress >= 1.0: + global_position = dash_target_position + if phase_two and dash_step_index == 0: + dash_step_index = 1 + dash_active_damage = dash_damage * 0.5 + dash_start_position = global_position + dash_target_position = dash_start_position - line_direction * dash_distance + _show_line_marker(dash_start_position, dash_target_position, dash_lane_width * 2.0, Color(1.0, 0.72, 0.42, 0.78)) + state_time = 0.0 + action_committed = false + return _return_to_idle() -func _process_skill_burst() -> void: - if burst_ring.visible: - burst_ring.scale = Vector2.ONE * (0.4 + minf(state_time * 1.5, 0.75)) - if not action_committed and state_time >= 0.42: +func _start_shock_skill() -> void: + state = &"skill_shock_charge" + state_time = 0.0 + action_committed = false + burst_ring.visible = true + burst_ring.global_position = global_position + burst_ring.points = _build_ring_points(shock_radius, 24) + burst_ring.scale = Vector2.ONE * 0.2 + burst_ring.modulate = Color(1.0, 0.82, 0.58, 0.9) + shock_payload = { + "slow_duration": shock_slow_duration, + "slow_multiplier": shock_slow_multiplier + } + _show_intent_text("Shockwave", Color(1.0, 0.82, 0.58, 1.0), global_position, 0.9) + +func _process_skill_shock_charge() -> void: + burst_ring.global_position = global_position + burst_ring.scale = Vector2.ONE * (0.2 + minf(state_time / maxf(shock_charge_duration, 0.001), 1.0) * 0.95) + if state_time < shock_charge_duration: + return + state = &"skill_shock_release" + state_time = 0.0 + action_committed = false + +func _process_skill_shock_release() -> void: + if not action_committed: action_committed = true - _apply_burst_damage() - if state_time >= 0.58: + _hit_targets_in_radius(global_position, shock_radius, shock_damage, shock_payload) + _hit_targets_in_radius(global_position, shock_outer_ring_radius, shock_outer_ring_damage, {}) + _spawn_burst_effect(global_position, shock_radius, Color(1.0, 0.80, 0.56, 0.9)) + _spawn_burst_effect(global_position, shock_outer_ring_radius, Color(1.0, 0.64, 0.38, 0.72)) burst_ring.visible = false + if state_time >= 0.26: _return_to_idle() -func _apply_burst_damage() -> void: - _spawn_burst_effect() - if target == null or not is_instance_valid(target): - return - if burst_center.distance_to(target.global_position) > arcane_burst_radius: - return - target.receive_hit({ - "source": self, - "damage": arcane_burst_damage, - "crit_rate": 0.0 - }) +func _start_volley_skill() -> void: + state = &"skill_volley" + state_time = 0.0 + action_committed = false + volley_shots_remaining = volley_shot_count_phase_two if phase_two else volley_shot_count_phase_one + volley_next_fire_time = 0.0 + volley_center_position = target.global_position if target != null and is_instance_valid(target) else global_position + _show_intent_text("Royal Volley", Color(1.0, 0.84, 0.58, 1.0), volley_center_position, 0.88) func _process_skill_volley() -> void: - if not action_committed and state_time >= 0.24: - action_committed = true - _fire_volley() - if state_time >= 0.52: + var interval := volley_interval_phase_two if phase_two else volley_interval_phase_one + if target != null and is_instance_valid(target): + volley_center_position = target.global_position + while volley_shots_remaining > 0 and state_time >= volley_next_fire_time: + _fire_single_volley_bolt() + volley_shots_remaining -= 1 + volley_next_fire_time += interval + if volley_shots_remaining <= 0 and state_time >= volley_next_fire_time + 0.18: _return_to_idle() -func _fire_volley() -> void: +func _fire_single_volley_bolt() -> void: if target == null or not is_instance_valid(target): return - var base_direction := (target.global_position - projectile_spawner.global_position).normalized() - for angle_offset in [-10.0, 0.0, 10.0]: - var bolt := ROYAL_BOLT_SCENE.instantiate() - bolt.global_position = projectile_spawner.global_position - get_tree().current_scene.add_child(bolt) - bolt.setup(self, base_direction.rotated(deg_to_rad(angle_offset)), volley_damage) + var spawn_position := global_position + var predicted_target := target.global_position + target_velocity_estimate * volley_prediction_time + var aim_direction := (predicted_target - spawn_position).normalized() + if aim_direction == Vector2.ZERO: + aim_direction = (target.global_position - spawn_position).normalized() + if aim_direction == Vector2.ZERO: + aim_direction = Vector2.RIGHT + var scene_root := get_tree().current_scene + if scene_root == null: + return + var bolt := ROYAL_BOLT_SCENE.instantiate() + bolt.global_position = spawn_position + scene_root.add_child(bolt) + bolt.setup(self, aim_direction, volley_damage) + if bolt.has_method("set"): + bolt.set("speed", volley_bullet_speed) + if phase_two or volley_shots_remaining % 3 == 0: + _spawn_volley_ring_burst() func _start_phase_transition() -> void: phase_two = true @@ -284,6 +376,7 @@ func _start_phase_transition() -> void: action_committed = false phase_ring.visible = true phase_ring.scale = Vector2.ONE * 0.6 + _show_intent_text("Phase Two", Color(1.0, 0.88, 0.62, 1.0), global_position, 0.94) func _process_phase_transition() -> void: phase_ring.rotation += 0.16 @@ -291,7 +384,7 @@ func _process_phase_transition() -> void: if state_time >= phase_transition_duration: invulnerable = false phase_ring.visible = false - skill_cooldown = 0.4 + skill_cooldown = 0.5 _return_to_idle() func _return_to_idle() -> void: @@ -311,6 +404,49 @@ func _hit_target_in_arc(radius: float, damage: float, arc_degrees: float) -> voi "crit_rate": 0.0 }) +func _hit_targets_in_radius(center: Vector2, radius: float, damage: float, payload: Dictionary = {}) -> void: + for candidate in get_tree().get_nodes_in_group("player"): + if not _is_targetable_player(candidate): + continue + var actor := candidate as Node2D + if actor.global_position.distance_to(center) > radius: + continue + var hit_payload := { + "source": self, + "damage": damage, + "crit_rate": 0.0 + } + for key in payload.keys(): + hit_payload[key] = payload[key] + actor.receive_hit(hit_payload) + +func _hit_targets_in_lane(start_position: Vector2, end_position: Vector2, width: float, damage: float) -> void: + for candidate in get_tree().get_nodes_in_group("player"): + if not _is_targetable_player(candidate): + continue + var actor := candidate as Node2D + if _distance_to_segment(actor.global_position, start_position, end_position) > width: + continue + actor.receive_hit({ + "source": self, + "damage": damage, + "crit_rate": 0.0 + }) + +func _spawn_volley_ring_burst() -> void: + var scene_root := get_tree().current_scene + if scene_root == null: + return + var base_angle := volley_rng.randf_range(0.0, TAU) + for index in range(volley_ring_shots): + var direction := Vector2.RIGHT.rotated(base_angle + TAU * float(index) / float(volley_ring_shots)) + var bolt := ROYAL_BOLT_SCENE.instantiate() + bolt.global_position = global_position + direction * 34.0 + scene_root.add_child(bolt) + bolt.setup(self, direction, volley_damage * 0.75) + if bolt.has_method("set"): + bolt.set("speed", volley_bullet_speed * 0.74) + func _spawn_slash_effect(radius: float, color: Color) -> void: if melee_texture != null: var texture_slash := Sprite2D.new() @@ -344,19 +480,51 @@ func _spawn_slash_effect(radius: float, color: Color) -> void: tween.parallel().tween_property(slash, "scale", Vector2.ONE * 1.1, 0.16) tween.finished.connect(slash.queue_free) -func _spawn_burst_effect() -> void: +func _spawn_burst_effect(center: Vector2, radius: float, color: Color) -> void: var ring := Line2D.new() ring.width = 4.0 ring.closed = true - ring.default_color = Color(0.82, 0.92, 1.0, 0.9) - ring.points = _build_ring_points(arcane_burst_radius, 20) - ring.global_position = burst_center + ring.default_color = color + ring.points = _build_ring_points(radius, 24) + ring.global_position = center get_tree().current_scene.add_child(ring) var tween := create_tween() - tween.tween_property(ring, "scale", Vector2.ONE * 1.18, 0.16) + tween.tween_property(ring, "scale", Vector2.ONE * 1.12, 0.16) tween.parallel().tween_property(ring, "modulate:a", 0.0, 0.16) tween.finished.connect(ring.queue_free) +func _show_line_marker(start_position: Vector2, end_position: Vector2, width: float, color: Color) -> void: + var direction := end_position - start_position + var length := direction.length() + if length <= 0.001: + return + burst_ring.visible = true + burst_ring.closed = false + burst_ring.width = width + burst_ring.default_color = color + burst_ring.global_position = start_position + burst_ring.rotation = direction.angle() + burst_ring.points = PackedVector2Array([Vector2.ZERO, Vector2(length, 0.0)]) + +func _distance_to_segment(point: Vector2, start_position: Vector2, end_position: Vector2) -> float: + var segment := end_position - start_position + var segment_length_squared := segment.length_squared() + if segment_length_squared <= 0.0001: + return point.distance_to(start_position) + var weight := clampf((point - start_position).dot(segment) / segment_length_squared, 0.0, 1.0) + var closest_point := start_position + segment * weight + return point.distance_to(closest_point) + +func _show_intent_text(label_text: String, color_value: Color, world_position: Vector2, scale_value: float = 0.84) -> void: + var popup := DAMAGE_NUMBER_SCENE.instantiate() + popup.position = to_local(world_position) + Vector2(-44.0, -104.0) + if popup.has_method("setup_text"): + popup.setup_text(label_text, color_value, scale_value) + if popup is CanvasItem: + (popup as CanvasItem).z_index = 60 + popup.lifetime = 0.8 + effects_layer.add_child(popup) + func _build_ring_points(radius: float, steps: int) -> PackedVector2Array: var points := PackedVector2Array() for index in range(steps): @@ -399,13 +567,10 @@ func _update_visuals() -> void: if to_target != Vector2.ZERO: line_direction = to_target.normalized() _set_body_tint(Color(1.0, 0.94, 0.88, 1.0) if phase_two else Color.WHITE) - if invulnerable: - body.modulate = Color(1.0, 1.0, 1.0, 0.8) - else: - body.modulate = Color.WHITE + body.modulate = Color(1.0, 1.0, 1.0, 0.8) if invulnerable else Color.WHITE _apply_body_motion(0.9, 0.025, 0.012) phase_ring.default_color = Color(1.0, 0.84, 0.52, 0.88) - weapon.position = line_direction * 22.0 + Vector2(0.0, 0.0) + weapon.position = line_direction * 22.0 weapon.rotation = _weapon_guard_rotation(line_direction, -48.0) + weapon_angle_offset projectile_spawner.position = line_direction * 24.0 visual_last_position = global_position diff --git a/actors/bosses/town/guard_unit.gd b/actors/bosses/town/guard_unit.gd deleted file mode 100644 index 219513d..0000000 --- a/actors/bosses/town/guard_unit.gd +++ /dev/null @@ -1,287 +0,0 @@ -extends Node2D - -signal defeated - -const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") - -@export var max_hp: float = 800.0 -@export var defense_value: float = 120.0 -@export var move_speed: float = 92.0 -@export var attack_damage: float = 20.0 -@export var attack_interval: float = 2.0 -@export var attack_range: float = 82.0 -@export var skill1_damage: float = 10.0 -@export var skill1_cooldown: float = 6.0 -@export var skill2_damage: float = 20.0 -@export var skill2_cooldown: float = 8.0 - -@onready var body: Polygon2D = $Body -@onready var sword: Polygon2D = $Sword -@onready var aura_ring: Line2D = $AuraRing -@onready var health_component: Node = $HealthComponent -@onready var effects_layer: Node2D = $EffectsLayer - -var target: Node2D = null -var hp: float = 0.0 -var mobile_guard: bool = false -var immune: bool = true -var lane_origin: Vector2 = Vector2.ZERO -var lane_axis: Vector2 = Vector2.UP -var lane_extent: float = 48.0 -var lane_offset: float = 0.0 -var lane_direction: float = 1.0 -var state: StringName = &"idle" -var state_time: float = 0.0 -var attack_cooldown: float = 0.0 -var skill1_cooldown_remaining: float = 0.0 -var skill2_cooldown_remaining: float = 0.0 -var recover_duration: float = 0.0 -var leap_start_position: Vector2 = Vector2.ZERO -var leap_target_position: Vector2 = Vector2.ZERO -var action_committed: bool = false -var silenced_time_remaining: float = 0.0 -var root_time_remaining: float = 0.0 -var slow_time_remaining: float = 0.0 -var slow_factor: float = 1.0 - -func _ready() -> void: - add_to_group("damageable") - health_component.setup(max_hp, defense_value) - health_component.damaged.connect(_on_damaged) - health_component.died.connect(_on_died) - hp = max_hp - lane_origin = global_position - _update_visuals() - -func bind_player(player: Node2D) -> void: - target = player - -func setup_lane(is_mobile_guard: bool, origin: Vector2, axis: Vector2) -> void: - mobile_guard = is_mobile_guard - lane_origin = origin - lane_axis = axis.normalized() if axis != Vector2.ZERO else Vector2.UP - global_position = lane_origin - -func set_lane_extent(value: float) -> void: - lane_extent = value - -func set_immune(active: bool) -> void: - immune = active - aura_ring.visible = active - -func _physics_process(delta: float) -> void: - if hp <= 0.0: - return - attack_cooldown = maxf(attack_cooldown - delta, 0.0) - skill1_cooldown_remaining = maxf(skill1_cooldown_remaining - delta, 0.0) - skill2_cooldown_remaining = maxf(skill2_cooldown_remaining - delta, 0.0) - _update_status_timers(delta) - state_time += delta - if target == null or not is_instance_valid(target): - _find_target() - _update_state(delta) - _update_visuals() - -func receive_hit(payload: Dictionary) -> void: - if immune or hp <= 0.0: - aura_ring.visible = true - aura_ring.default_color = Color(0.72, 0.9, 1.0, 0.95) - var timer := get_tree().create_timer(0.1) - timer.timeout.connect(func() -> void: - if is_instance_valid(self): - _update_visuals() - ) - return - var result: Dictionary = health_component.receive_hit(payload) - var final_damage := float(result.get("damage", 0.0)) - if final_damage > 0.0: - apply_control_effects(payload) - _spawn_damage_number(final_damage, bool(result.get("is_critical", false))) - -func apply_control_effects(payload: Dictionary) -> void: - if payload.has("silence_duration"): - silenced_time_remaining = maxf(silenced_time_remaining, float(payload["silence_duration"])) - if payload.has("root_duration"): - root_time_remaining = maxf(root_time_remaining, float(payload["root_duration"])) - if payload.has("slow_duration"): - slow_time_remaining = maxf(slow_time_remaining, float(payload["slow_duration"])) - slow_factor = minf(slow_factor, float(payload.get("slow_multiplier", 1.0))) - -func _find_target() -> void: - var players := get_tree().get_nodes_in_group("player") - target = players[0] if not players.is_empty() else null - -func _update_status_timers(delta: float) -> void: - silenced_time_remaining = maxf(silenced_time_remaining - delta, 0.0) - root_time_remaining = maxf(root_time_remaining - delta, 0.0) - if slow_time_remaining > 0.0: - slow_time_remaining = maxf(slow_time_remaining - delta, 0.0) - else: - slow_factor = 1.0 - -func _update_state(delta: float) -> void: - match state: - &"idle": - _process_idle(delta) - &"basic_attack": - _process_basic_attack() - &"skill_1_jump": - _process_jump_attack() - &"skill_2_sweep": - _process_sweep_attack() - &"recover": - _process_recover() - &"dead": - return - -func _process_idle(delta: float) -> void: - if target == null or not is_instance_valid(target): - return - var to_target := target.global_position - global_position - var distance := to_target.length() - if distance > 0.0: - sword.rotation = _sword_visual_rotation(to_target) - if _can_use_skills() and skill2_cooldown_remaining <= 0.0 and distance <= 190.0: - _start_sweep() - return - if _can_use_skills() and skill1_cooldown_remaining <= 0.0 and distance <= 160.0: - _start_jump() - return - if attack_cooldown <= 0.0 and distance <= attack_range: - _start_basic_attack() - return - if mobile_guard and root_time_remaining <= 0.0: - lane_offset += lane_direction * move_speed * slow_factor * delta - if absf(lane_offset) >= lane_extent: - lane_offset = clampf(lane_offset, -lane_extent, lane_extent) - lane_direction *= -1.0 - global_position = lane_origin + lane_axis * lane_offset - -func _start_basic_attack() -> void: - state = &"basic_attack" - state_time = 0.0 - action_committed = false - attack_cooldown = attack_interval - if Sfx != null: - Sfx.play_event(&"enemy_swordsman_attack", global_position, -2.0) - -func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.35: - action_committed = true - _hit_target(attack_range, attack_damage) - if state_time >= 0.72: - _enter_recover(0.28) - -func _start_jump() -> void: - state = &"skill_1_jump" - state_time = 0.0 - action_committed = false - skill1_cooldown_remaining = skill1_cooldown - leap_start_position = global_position - leap_target_position = target.global_position if target != null else global_position - if Sfx != null: - Sfx.play_event(&"enemy_hunter_dash", global_position, -1.0) - -func _process_jump_attack() -> void: - var progress := clampf(state_time / 0.38, 0.0, 1.0) - global_position = leap_start_position.lerp(leap_target_position, progress) + Vector2(0.0, -sin(progress * PI) * 72.0) - if not action_committed and progress >= 1.0: - action_committed = true - global_position = leap_target_position - _hit_target(86.0, skill1_damage) - if state_time >= 0.48: - _enter_recover(2.0) - -func _start_sweep() -> void: - state = &"skill_2_sweep" - state_time = 0.0 - action_committed = false - skill2_cooldown_remaining = skill2_cooldown - if Sfx != null: - Sfx.play_event(&"enemy_shield_bash", global_position, -1.5) - -func _process_sweep_attack() -> void: - if not action_committed and state_time >= 2.0: - action_committed = true - _hit_target(106.0, skill2_damage) - if state_time >= 2.18: - _enter_recover(2.0) - -func _enter_recover(duration: float) -> void: - state = &"recover" - state_time = 0.0 - recover_duration = duration - -func _process_recover() -> void: - if state_time >= recover_duration: - state = &"idle" - state_time = 0.0 - -func _hit_target(radius: float, damage: float) -> void: - if target == null or not is_instance_valid(target): - return - if global_position.distance_to(target.global_position) > radius: - return - target.receive_hit({ - "source": self, - "damage": damage, - "crit_rate": 0.0 - }) - -func _can_use_skills() -> bool: - return silenced_time_remaining <= 0.0 - -func _update_visuals() -> void: - var base_color := Color(0.56, 0.66, 0.78, 1.0) if mobile_guard else Color(0.48, 0.58, 0.7, 1.0) - if state == &"skill_2_sweep": - base_color = Color(0.88, 0.72, 0.48, 1.0) - elif silenced_time_remaining > 0.0: - base_color = Color(0.72, 0.64, 0.92, 1.0) - body.color = base_color - aura_ring.visible = immune - aura_ring.default_color = Color(0.8, 0.92, 1.0, 0.75) if immune else Color(1.0, 0.84, 0.5, 0.0) - if target != null and is_instance_valid(target): - var to_target := target.global_position - global_position - if to_target.length_squared() > 0.0001: - sword.rotation = _sword_visual_rotation(to_target) - -func _sword_visual_rotation(direction: Vector2) -> float: - var facing := direction.normalized() if direction.length_squared() > 0.0001 else Vector2.RIGHT - var side_sign := -1.0 if facing.x < -0.05 else 1.0 - var guard_degrees := -44.0 - if state == &"basic_attack": - var attack_progress := clampf(state_time / 0.42, 0.0, 1.0) - guard_degrees = lerpf(-52.0, 18.0, attack_progress) - elif state == &"skill_2_sweep": - var sweep_progress := clampf(state_time / 2.0, 0.0, 1.0) - guard_degrees = lerpf(-60.0, 24.0, sweep_progress) - return facing.angle() + deg_to_rad(guard_degrees * side_sign) - -func _spawn_damage_number(amount: float, is_critical: bool) -> void: - var damage_number := DAMAGE_NUMBER_SCENE.instantiate() - damage_number.position = Vector2(0.0, -34.0) - damage_number.setup(amount, is_critical) - effects_layer.add_child(damage_number) - -func _on_damaged(_amount: float, remaining_hp: float, _source: Node) -> void: - hp = remaining_hp - if Sfx != null: - Sfx.play_event(&"enemy_generic_hit", global_position, -5.0) - body.color = Color(1.0, 0.58, 0.58, 1.0) - var timer := get_tree().create_timer(0.1) - timer.timeout.connect(func() -> void: - if is_instance_valid(self) and hp > 0.0: - _update_visuals() - ) - -func _on_died() -> void: - hp = 0.0 - state = &"dead" - if Sfx != null: - Sfx.play_event(&"enemy_generic_dead", global_position, -3.0) - var timer := get_tree().create_timer(0.35) - timer.timeout.connect(func() -> void: - if is_instance_valid(self): - defeated.emit() - queue_free() - ) diff --git a/actors/bosses/town/guard_unit.gd.uid b/actors/bosses/town/guard_unit.gd.uid deleted file mode 100644 index 3b3bf65..0000000 --- a/actors/bosses/town/guard_unit.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cu4uhp5eoup20 diff --git a/actors/bosses/town/guard_unit.tscn b/actors/bosses/town/guard_unit.tscn deleted file mode 100644 index e0a4b44..0000000 --- a/actors/bosses/town/guard_unit.tscn +++ /dev/null @@ -1,41 +0,0 @@ -[gd_scene load_steps=5 format=3] - -[ext_resource type="Script" path="res://actors/bosses/town/guard_unit.gd" id="1"] -[ext_resource type="Script" path="res://combat/health_component.gd" id="2"] - -[sub_resource type="CircleShape2D" id="1"] -radius = 20.0 - -[node name="GuardUnit" type="Node2D"] -script = ExtResource("1") - -[node name="Body" type="Polygon2D" parent="."] -color = Color(0.48, 0.58, 0.7, 1) -polygon = PackedVector2Array(-16, 24, 16, 24, 20, 4, 12, -20, 0, -30, -12, -20, -20, 4) - -[node name="Sword" type="Polygon2D" parent="."] -color = Color(0.9, 0.82, 0.68, 1) -position = Vector2(10, -2) -polygon = PackedVector2Array(-3, 6, 5, 6, 6, -20, 1, -34, -4, -20) - -[node name="AuraRing" type="Line2D" parent="."] -visible = false -default_color = Color(0.8, 0.92, 1, 0.75) -width = 2.0 -closed = true -points = PackedVector2Array(42, 0, 30, 30, 0, 42, -30, 30, -42, 0, -30, -30, 0, -42, 30, -30) - -[node name="Hurtbox" type="Area2D" parent="."] -collision_layer = 1 -collision_mask = 0 -monitoring = true -monitorable = true - -[node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"] -shape = SubResource("1") - -[node name="HealthComponent" type="Node" parent="."] -script = ExtResource("2") - -[node name="EffectsLayer" type="Node2D" parent="."] - diff --git a/actors/bosses/town/judicator_boss.gd b/actors/bosses/town/judicator_boss.gd index b81cba5..0e98042 100644 --- a/actors/bosses/town/judicator_boss.gd +++ b/actors/bosses/town/judicator_boss.gd @@ -13,7 +13,7 @@ const JUDICATOR_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_w @export var move_speed: float = 96.0 @export var attack_damage: float = 50.0 @export var attack_range: float = 92.0 -@export var attack_interval: float = 2.0 +@export var attack_interval: float = 2.6 @export var skill1_damage: float = 30.0 @export var skill1_cooldown: float = 6.0 @export var skill1_jump_start_duration: float = 0.45 @@ -92,13 +92,13 @@ func bind_player(player: Node2D) -> void: target = player func get_status_title() -> String: - return _locale_text("Judicator", "审判官", "審判官") + return _locale_text("Judicator", "审判者", "審判者") func get_status_text() -> String: return _locale_text("HP %d / %d%s\nState: %s", "生命 %d / %d%s\n状态:%s", "生命 %d / %d%s\n狀態:%s") % [ int(round(hp)), int(round(max_hp)), - _locale_text(" ENRAGED", " 暴怒", " 暴怒") if enraged else "", + _locale_text(" ENRAGED", " 狂暴", " 狂暴") if enraged else "", _localized_state_name(String(state)) ] @@ -120,19 +120,19 @@ func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) - func _localized_state_name(state_name: String) -> String: match state_name: "idle": - return _locale_text("Idle", "待机", "待機") + return _locale_text("Idle", "寰呮満", "寰呮") "basic_attack": - return _locale_text("Basic Attack", "普攻", "普攻") + return _locale_text("Basic Attack", "鏅敾", "鏅敾") "skill_1_jump_start": - return _locale_text("Leap Windup", "跃击蓄势", "躍擊蓄勢") + return _locale_text("Leap Windup", "璺冨嚮钃勫娍", "韬嶆搳钃勫嫝") "skill_1_slam": - return _locale_text("Leap Slam", "跃击重砸", "躍擊重砸") + return _locale_text("Leap Slam", "璺冨嚮閲嶇牳", "韬嶆搳閲嶇牳") "skill_2_charge": - return _locale_text("Line Verdict", "裁决冲锋", "裁決衝鋒") + return _locale_text("Line Verdict", "瑁佸喅鍐查攱", "瑁佹焙琛濋嫆") "recover": - return _locale_text("Recover", "恢复", "恢復") + return _locale_text("Recover", "鎭㈠", "鎭㈠京") "dead": - return _locale_text("Defeated", "倒下", "倒下") + return _locale_text("Defeated", "鍊掍笅", "鍊掍笅") _: return state_name.capitalize() @@ -232,10 +232,10 @@ func _start_basic_attack() -> void: Sfx.play_event(&"boss_judicator_attack", global_position) func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.45: + if not action_committed and state_time >= 1.0: action_committed = true _hit_target_in_radius(attack_range, attack_damage, false) - if state_time >= 0.9: + if state_time >= 1.45: _enter_recover(0.45) func _start_skill1() -> void: @@ -539,9 +539,11 @@ func _spawn_damage_number(amount: float, is_critical: bool) -> void: func _show_intent_text(label_text: String, color_value: Color, world_position: Vector2, scale_value: float = 0.86) -> void: var popup := DAMAGE_NUMBER_SCENE.instantiate() - popup.position = to_local(world_position) + Vector2(-42.0, -54.0) + popup.position = to_local(world_position) + Vector2(-42.0, -96.0) if popup.has_method("setup_text"): popup.setup_text(label_text, color_value, scale_value) + if popup is CanvasItem: + (popup as CanvasItem).z_index = 60 effects_layer.add_child(popup) func _on_damaged(_amount: float, remaining_hp: float, _source: Node) -> void: diff --git a/actors/bosses/town/mage_boss.gd b/actors/bosses/town/mage_boss.gd index b7b80da..f685f85 100644 --- a/actors/bosses/town/mage_boss.gd +++ b/actors/bosses/town/mage_boss.gd @@ -4,34 +4,57 @@ signal defeated const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") const ARCANE_BOLT_SCENE := preload("res://effects/projectiles/arcane_bolt.tscn") +const MAGE_RED_BOLT_SCENE := preload("res://effects/projectiles/mage_boss_red_bolt.tscn") const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") const MAGE_BODY_TEXTURE_PATH := "res://actors/bosses/textures/mage_boss.png" const MAGE_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon_mage_staff.png" +const MAGE_SKILL1_BULLET_TEXTURE_PATH := "res://assets/effects/projectiles/boss_bullet_red.webp" +const MAGE_LASER_BODY_TEXTURE_PATH := "res://assets/effects/projectiles/mage_boss_laser_body.webp" +const MAGE_LASER_CORE_TEXTURE_PATH := "res://assets/effects/projectiles/mage_boss_laser_core.png" @export var max_hp: float = 3300.0 @export var defense_value: float = 205.0 -@export var move_speed: float = 180.0 -@export var attack_damage: float = 44.0 -@export var attack_interval: float = 1.0 -@export var attack_targeting_range: float = 560.0 -@export var skill1_damage: float = 28.0 -@export var skill1_cooldown: float = 8.2 -@export var blade_duration: float = 7.0 -@export var blade_radius: float = 96.0 -@export var blade_tick_interval: float = 0.8 -@export var skill1_shield_value: float = 130.0 -@export var skill2_damage: float = 92.0 -@export var skill2_cooldown: float = 7.0 -@export var skill2_cast_duration: float = 0.42 -@export var burst_radius: float = 128.0 -@export var burst_targeting_range: float = 520.0 -@export var chain_burst_damage: float = 56.0 -@export var skill3_cooldown: float = 6.2 -@export var skill3_cast_duration: float = 0.26 -@export var silence_duration: float = 2.2 -@export var slow_duration: float = 3.5 -@export var slow_multiplier: float = 0.55 -@export var root_duration: float = 1.0 +@export var move_speed: float = 170.0 + +@export var skill_cooldown_between_casts: float = 3.0 + +@export var skill1_charge_duration: float = 0.85 +@export var skill1_radius_from_boss: float = 92.0 +@export var skill1_wave_count: int = 6 +@export var skill1_wave_interval: float = 0.34 +@export var skill1_projectiles_per_ring: int = 18 +@export var skill1_spiral_offset_degrees: float = 13.0 +@export var skill1_aimed_bullets_per_wave: int = 3 +@export var skill1_aimed_spread_degrees: float = 18.0 +@export var skill1_damage: float = 30.0 +@export var skill1_ring_projectile_speed: float = 235.0 +@export var skill1_aimed_projectile_speed: float = 380.0 + +@export var skill2_marker_radius: float = 30.0 +@export var skill2_expand_speed: float = 230.0 +@export var skill2_expand_duration: float = 1.35 +@export var skill2_damage: float = 88.0 + +@export var skill3_charge_duration: float = 1.0 +@export var skill3_laser_damage: float = 80.0 +@export var skill3_rotate_speed_degrees: float = 35.0 +@export var skill3_rotate_duration: float = 2.8 +@export var skill3_hold_duration: float = 0.55 +@export var skill3_laser_length: float = 1040.0 +@export var skill3_laser_width: float = 20.0 + +@export var skill4_burst_spawn_radius: float = 300.0 +@export var skill4_burst_radius: float = 56.0 +@export var skill4_warning_duration: float = 1.0 +@export var skill4_damage: float = 50.0 +@export var skill4_burst_count: int = 9 + +@export var skill5_projectiles_per_wave: int = 28 +@export var skill5_wave_count: int = 6 +@export var skill5_wave_interval: float = 0.38 +@export var skill5_angle_offset_degrees: float = 9.0 +@export var skill5_damage: float = 30.0 +@export var skill5_projectile_speed: float = 300.0 @onready var body: Polygon2D = $Body @onready var focus_ring: Line2D = $FocusRing @@ -45,51 +68,58 @@ const MAGE_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon var target: Node2D = null var hp: float = 0.0 -var shield: float = 0.0 var state: StringName = &"idle" var state_time: float = 0.0 var recover_duration: float = 0.0 -var attack_cooldown: float = 0.0 -var skill1_cooldown_remaining: float = 1.2 -var skill2_cooldown_remaining: float = 3.0 -var skill3_cooldown_remaining: float = 4.8 +var skill_cooldown_remaining: float = 1.5 var line_direction: Vector2 = Vector2.RIGHT var action_committed: bool = false -var blades_active: bool = false -var blade_time_remaining: float = 0.0 -var blade_hit_cooldowns: Dictionary = {} -var blade_nodes: Array[Polygon2D] = [] -var enchant_active: bool = false -var current_skill_target: Node2D = null -var silenced_time_remaining: float = 0.0 -var root_time_remaining: float = 0.0 -var slow_time_remaining: float = 0.0 -var slow_factor: float = 1.0 +var current_skill_target: Vector2 = Vector2.ZERO +var current_skill_id: int = -1 +var last_skill_id: int = -1 var body_sprite: Sprite2D = null var weapon_sprite: Sprite2D = null +var laser_body_sprite: Sprite2D = null +var laser_core_sprite: Sprite2D = null +var laser_line: Line2D = null +var skill1_bullet_texture: Texture2D = null +var skill3_laser_body_texture: Texture2D = null +var skill3_laser_core_texture: Texture2D = null var weapon_angle_offset: float = deg_to_rad(98.0) -var orbit_direction: float = 1.0 var visual_last_position: Vector2 = Vector2.ZERO var visual_bob_time: float = 0.0 +var skill_rng := RandomNumberGenerator.new() +var skill1_current_wave: int = 0 +var skill1_next_wave_time: float = 0.0 +var skill1_base_angle: float = 0.0 +var skill1_spin_direction: float = 1.0 +var skill2_ring_radius: float = 0.0 +var skill3_target_angle: float = 0.0 +var skill3_start_angle: float = 0.0 +var skill3_current_angle: float = 0.0 +var skill4_markers: Array[Dictionary] = [] +var skill5_current_wave: int = 0 +var skill5_next_wave_time: float = 0.0 func _ready() -> void: add_to_group("damageable") + skill_rng.randomize() + skill1_bullet_texture = TEXTURE_LOADER.load_texture(MAGE_SKILL1_BULLET_TEXTURE_PATH) + skill3_laser_body_texture = TEXTURE_LOADER.load_texture(MAGE_LASER_BODY_TEXTURE_PATH) + skill3_laser_core_texture = TEXTURE_LOADER.load_texture(MAGE_LASER_CORE_TEXTURE_PATH) health_component.setup(max_hp, defense_value) health_component.damaged.connect(_on_damaged) - health_component.healed.connect(_on_healed) - health_component.shield_changed.connect(_on_shield_changed) health_component.died.connect(_on_died) hp = max_hp visual_last_position = global_position _setup_body_visual() _setup_weapon_visual() - for child in blade_orbit.get_children(): - if child is Polygon2D: - blade_nodes.append(child) + _setup_laser_visuals() focus_ring.visible = false burst_ring.visible = false enchant_sigil.visible = false blade_orbit.visible = false + _clear_unused_visual_nodes() _update_visuals() func bind_player(player: Node2D) -> void: @@ -99,11 +129,9 @@ func get_status_title() -> String: return "Grand Arcanist" func get_status_text() -> String: - var shield_text := " | Shield %d" % int(round(shield)) if shield > 0.0 else "" - return "HP %d / %d%s\nState: %s" % [ + return "HP %d / %d\nState: %s" % [ int(round(hp)), int(round(max_hp)), - shield_text, String(state) ] @@ -112,13 +140,8 @@ func _physics_process(delta: float) -> void: return if target == null or not _is_targetable_player(target): _find_target() - _update_status_timers(delta) - attack_cooldown = maxf(attack_cooldown - delta, 0.0) - skill1_cooldown_remaining = maxf(skill1_cooldown_remaining - delta, 0.0) - skill2_cooldown_remaining = maxf(skill2_cooldown_remaining - delta, 0.0) - skill3_cooldown_remaining = maxf(skill3_cooldown_remaining - delta, 0.0) + skill_cooldown_remaining = maxf(skill_cooldown_remaining - delta, 0.0) state_time += delta - _update_blades(delta) _update_state(delta) _update_visuals() @@ -128,18 +151,8 @@ func receive_hit(payload: Dictionary) -> void: var result: Dictionary = health_component.receive_hit(payload) var final_damage := float(result.get("damage", 0.0)) if final_damage > 0.0: - apply_control_effects(payload) _spawn_damage_number(final_damage, bool(result.get("is_critical", false))) -func apply_control_effects(payload: Dictionary) -> void: - if payload.has("silence_duration"): - silenced_time_remaining = maxf(silenced_time_remaining, float(payload["silence_duration"])) - if payload.has("root_duration"): - root_time_remaining = maxf(root_time_remaining, float(payload["root_duration"])) - if payload.has("slow_duration"): - slow_time_remaining = maxf(slow_time_remaining, float(payload["slow_duration"])) - slow_factor = minf(slow_factor, float(payload.get("slow_multiplier", 1.0))) - func _find_target() -> void: for player in get_tree().get_nodes_in_group("player"): if _is_targetable_player(player): @@ -158,26 +171,28 @@ func _is_targetable_player(candidate: Variant) -> bool: var hp_value: Variant = actor.get("hp") return hp_value == null or float(hp_value) > 0.0 -func _update_status_timers(delta: float) -> void: - silenced_time_remaining = maxf(silenced_time_remaining - delta, 0.0) - root_time_remaining = maxf(root_time_remaining - delta, 0.0) - if slow_time_remaining > 0.0: - slow_time_remaining = maxf(slow_time_remaining - delta, 0.0) - else: - slow_factor = 1.0 - func _update_state(delta: float) -> void: match state: &"idle": _process_idle(delta) - &"basic_attack": - _process_basic_attack() - &"skill1_cast": - _process_skill1_cast() - &"skill2_cast": - _process_skill2_cast() - &"skill3_cast": - _process_skill3_cast() + &"skill1_charge": + _process_skill1_charge() + &"skill1_release": + _process_skill1_release() + &"skill2_charge": + _process_skill2_charge(delta) + &"skill2_resolve": + _process_skill2_resolve() + &"skill3_charge": + _process_skill3_charge() + &"skill3_rotate": + _process_skill3_rotate() + &"skill3_hold": + _process_skill3_hold() + &"skill4_cast": + _process_skill4_cast() + &"skill5_cast": + _process_skill5_cast() &"recover": _process_recover() @@ -185,223 +200,308 @@ func _process_idle(delta: float) -> void: if target == null or not is_instance_valid(target): return var to_target := target.global_position - global_position - var distance := to_target.length() if to_target != Vector2.ZERO: line_direction = to_target.normalized() - if _can_use_skills() and not enchant_active and skill3_cooldown_remaining <= 0.0: - _start_skill3_cast() + if skill_cooldown_remaining <= 0.0: + _start_random_skill() return - if _can_use_skills() and skill2_cooldown_remaining <= 0.0 and distance <= burst_targeting_range: - _start_skill2_cast() + var orbit := Vector2(-line_direction.y, line_direction.x) + global_position += orbit.normalized() * move_speed * 0.45 * delta + +func _start_random_skill() -> void: + var candidates := [1, 2, 3, 4, 5] + candidates.erase(last_skill_id) + current_skill_id = candidates[skill_rng.randi_range(0, candidates.size() - 1)] + last_skill_id = current_skill_id + skill_cooldown_remaining = skill_cooldown_between_casts + match current_skill_id: + 1: + _start_skill1() + 2: + _start_skill2() + 3: + _start_skill3() + 4: + _start_skill4() + 5: + _start_skill5() + +func _start_skill1() -> void: + state = &"skill1_charge" + state_time = 0.0 + action_committed = false + skill1_current_wave = 0 + skill1_next_wave_time = 0.0 + skill1_base_angle = skill_rng.randf_range(0.0, TAU) + skill1_spin_direction = 1.0 if skill_rng.randi_range(0, 1) == 0 else -1.0 + burst_ring.visible = true + burst_ring.global_position = global_position + burst_ring.default_color = Color(1.0, 0.22, 0.12, 0.72) + burst_ring.points = _build_ring_points(skill1_radius_from_boss, 28) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), global_position, 0.92) + +func _process_skill1_charge() -> void: + if state_time < skill1_charge_duration: + burst_ring.visible = true + burst_ring.global_position = global_position + burst_ring.points = _build_ring_points(skill1_radius_from_boss + sin(state_time * 18.0) * 6.0, 28) return - if _can_use_skills() and not blades_active and skill1_cooldown_remaining <= 0.0 and distance <= 240.0: - _start_skill1_cast() + state = &"skill1_release" + state_time = 0.0 + burst_ring.visible = false + +func _process_skill1_release() -> void: + while skill1_current_wave < skill1_wave_count and state_time >= skill1_next_wave_time: + _fire_skill1_wave() + skill1_current_wave += 1 + skill1_next_wave_time += skill1_wave_interval + if skill1_current_wave >= skill1_wave_count and state_time >= skill1_next_wave_time + 0.2: + _enter_recover(0.12) + +func _fire_skill1_wave() -> void: + var ring_offset := deg_to_rad(skill1_spiral_offset_degrees * float(skill1_current_wave)) * skill1_spin_direction + for index in range(skill1_projectiles_per_ring): + var angle := skill1_base_angle + TAU * float(index) / float(skill1_projectiles_per_ring) + ring_offset + var direction := Vector2.RIGHT.rotated(angle) + var spawn_position := global_position + direction * skill1_radius_from_boss + _spawn_red_bolt(spawn_position, direction, skill1_damage, skill1_ring_projectile_speed) + if target == null or not is_instance_valid(target): return - if attack_cooldown <= 0.0 and distance <= attack_targeting_range: - _start_basic_attack() + var to_target := target.global_position - global_position + if to_target == Vector2.ZERO: return - if root_time_remaining > 0.0: + var aimed_center := to_target.normalized().angle() + var spread_start := -deg_to_rad(skill1_aimed_spread_degrees) * 0.5 + var spread_step := deg_to_rad(skill1_aimed_spread_degrees) / maxf(float(skill1_aimed_bullets_per_wave - 1), 1.0) + for index in range(skill1_aimed_bullets_per_wave): + var aimed_angle := aimed_center + spread_start + spread_step * float(index) + var aimed_direction := Vector2.RIGHT.rotated(aimed_angle) + var spawn_position := global_position + aimed_direction * skill1_radius_from_boss + _spawn_red_bolt(spawn_position, aimed_direction, skill1_damage, skill1_aimed_projectile_speed) + +func _start_skill2() -> void: + state = &"skill2_charge" + state_time = 0.0 + action_committed = false + current_skill_target = target.global_position if target != null and is_instance_valid(target) else global_position + skill2_ring_radius = skill2_marker_radius + burst_ring.visible = true + burst_ring.global_position = current_skill_target + burst_ring.points = _build_ring_points(skill2_ring_radius, 24) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), current_skill_target, 0.92) + +func _process_skill2_charge(delta: float) -> void: + skill2_ring_radius = skill2_marker_radius + skill2_expand_speed * minf(state_time, skill2_expand_duration) + burst_ring.visible = true + burst_ring.global_position = current_skill_target + burst_ring.points = _build_ring_points(skill2_ring_radius, 28) + if state_time < skill2_expand_duration: return - var tangent := Vector2(-line_direction.y, line_direction.x) * orbit_direction - var speed := move_speed * slow_factor - if distance < 180.0: - global_position -= line_direction * speed * 0.9 * delta - elif distance > 340.0: - global_position += line_direction * speed * 0.75 * delta - else: - if state_time >= 0.56: - state_time = 0.0 - orbit_direction *= -1.0 - tangent = Vector2(-line_direction.y, line_direction.x) * orbit_direction - global_position += tangent.normalized() * speed * 0.5 * delta - -func _start_basic_attack() -> void: - state = &"basic_attack" + state = &"skill2_resolve" state_time = 0.0 action_committed = false - attack_cooldown = attack_interval - focus_ring.visible = true - _animate_weapon_swing(-56.0, 18.0, 0.24) - Sfx.play_event(&"mage_attack", global_position) -func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.24: +func _process_skill2_resolve() -> void: + if not action_committed: action_committed = true - _fire_arcane_bolt(attack_damage) - if state_time >= 0.48: - _enter_recover(0.16) + _apply_radius_damage(current_skill_target, skill2_ring_radius, skill2_damage) + _show_burst_effect(current_skill_target, skill2_ring_radius, Color(0.78, 0.90, 1.0, 0.9)) + burst_ring.visible = false + if state_time >= 0.18: + _enter_recover(0.12) -func _start_skill1_cast() -> void: - state = &"skill1_cast" +func _start_skill3() -> void: + state = &"skill3_charge" state_time = 0.0 action_committed = false - skill1_cooldown_remaining = skill1_cooldown - _animate_weapon_swing(-34.0, 14.0, 0.28) - Sfx.play_event(&"mage_skill1_blades", global_position) - -func _process_skill1_cast() -> void: - if not action_committed and state_time >= 0.28: - action_committed = true - _activate_arcane_blades() - if state_time >= 0.46: - _enter_recover(0.16) - -func _activate_arcane_blades() -> void: - blades_active = true - blade_time_remaining = blade_duration - blade_hit_cooldowns.clear() - blade_orbit.visible = true - health_component.set_shield(skill1_shield_value) - -func _start_skill2_cast() -> void: - state = &"skill2_cast" + var target_direction := line_direction + if target != null and is_instance_valid(target): + var to_target := target.global_position - global_position + if to_target != Vector2.ZERO: + target_direction = to_target.normalized() + skill3_target_angle = target_direction.angle() + skill3_start_angle = skill_rng.randf_range(-PI, PI) + skill3_current_angle = skill3_start_angle + _show_laser_effect() + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), global_position, 0.92) + +func _process_skill3_charge() -> void: + skill3_current_angle = skill3_start_angle + _show_laser_effect() + if state_time < skill3_charge_duration: + return + state = &"skill3_rotate" state_time = 0.0 action_committed = false - skill2_cooldown_remaining = skill2_cooldown - current_skill_target = target - burst_ring.visible = true - burst_ring.scale = Vector2.ONE * 0.4 - _animate_weapon_swing(-26.0, 12.0, skill2_cast_duration) - Sfx.play_event(&"mage_skill2_burst", global_position) - -func _process_skill2_cast() -> void: - if current_skill_target != null and is_instance_valid(current_skill_target): - burst_ring.global_position = current_skill_target.global_position - if burst_ring.visible: - burst_ring.scale = Vector2.ONE * (0.4 + minf(state_time * 1.35, 0.72)) - if not action_committed and state_time >= skill2_cast_duration * 0.62: + +func _process_skill3_rotate() -> void: + if not action_committed: action_committed = true - var center := current_skill_target.global_position if current_skill_target != null and is_instance_valid(current_skill_target) else global_position + line_direction * 110.0 - _release_arcane_burst(center) - if state_time >= skill2_cast_duration + 0.2: - burst_ring.visible = false - current_skill_target = null - _enter_recover(0.18) - -func _release_arcane_burst(center: Vector2) -> void: - var extra_payload := _consume_enchant_payload() - _apply_burst_damage(center, burst_radius, skill2_damage, extra_payload) - _show_burst_effect(center, burst_radius) - if hp <= max_hp * 0.5: - var timer := get_tree().create_timer(0.18) - timer.timeout.connect(func() -> void: - if is_instance_valid(self) and state != &"dead": - _apply_burst_damage(center, burst_radius * 0.72, chain_burst_damage, {}) - _show_burst_effect(center, burst_radius * 0.72) - ) - -func _start_skill3_cast() -> void: - state = &"skill3_cast" + _update_skill3_laser_angles(state_time) + _show_laser_effect() + if state_time > 0.18: + _apply_laser_damage() + if state_time >= skill3_rotate_duration: + state = &"skill3_hold" + state_time = 0.0 + +func _process_skill3_hold() -> void: + _update_skill3_laser_angles(skill3_rotate_duration) + _show_laser_effect() + _apply_laser_damage() + if state_time >= skill3_hold_duration: + _set_laser_visuals_visible(false) + _enter_recover(0.15) + +func _start_skill4() -> void: + state = &"skill4_cast" state_time = 0.0 action_committed = false - skill3_cooldown_remaining = skill3_cooldown - _animate_weapon_swing(-42.0, 10.0, skill3_cast_duration) - Sfx.play_event(&"mage_skill3_enchant", global_position) + _clear_skill4_markers() + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), target.global_position if target != null and is_instance_valid(target) else global_position, 0.92) + var center := target.global_position if target != null and is_instance_valid(target) else global_position + for _index in range(skill4_burst_count): + var angle := skill_rng.randf_range(0.0, TAU) + var marker_position := center + Vector2.RIGHT.rotated(angle) * skill_rng.randf_range(0.0, skill4_burst_spawn_radius) + var marker := Line2D.new() + marker.width = 3.0 + marker.closed = true + marker.default_color = Color(1.0, 0.72, 0.52, 0.92) + marker.points = _build_ring_points(skill4_burst_radius, 18) + marker.global_position = marker_position + get_tree().current_scene.add_child(marker) + skill4_markers.append({ + "node": marker, + "position": marker_position + }) -func _process_skill3_cast() -> void: - if not action_committed and state_time >= skill3_cast_duration * 0.55: +func _process_skill4_cast() -> void: + if state_time < skill4_warning_duration: + return + if not action_committed: action_committed = true - enchant_active = true - enchant_sigil.visible = true - if state_time >= skill3_cast_duration + 0.12: + for marker_data in skill4_markers: + var marker_position := marker_data.get("position", Vector2.ZERO) as Vector2 + _apply_radius_damage(marker_position, skill4_burst_radius, skill4_damage) + _show_burst_effect(marker_position, skill4_burst_radius, Color(1.0, 0.70, 0.50, 0.9)) + _clear_skill4_markers() + if state_time >= skill4_warning_duration + 0.18: _enter_recover(0.12) +func _start_skill5() -> void: + state = &"skill5_cast" + state_time = 0.0 + action_committed = false + skill5_current_wave = 0 + skill5_next_wave_time = 0.0 + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), global_position, 0.92) + +func _process_skill5_cast() -> void: + while skill5_current_wave < skill5_wave_count and state_time >= skill5_next_wave_time: + _fire_skill5_wave(skill5_current_wave) + skill5_current_wave += 1 + skill5_next_wave_time += skill5_wave_interval + if skill5_current_wave >= skill5_wave_count and state_time >= skill5_next_wave_time + 0.18: + _enter_recover(0.12) + +func _fire_skill5_wave(wave_index: int) -> void: + var angle_offset := deg_to_rad(skill5_angle_offset_degrees * float(wave_index)) + for index in range(skill5_projectiles_per_wave): + var angle := TAU * float(index) / float(skill5_projectiles_per_wave) + angle_offset + var direction := Vector2.RIGHT.rotated(angle) + _spawn_red_bolt(global_position, direction, skill5_damage, skill5_projectile_speed) + func _enter_recover(duration: float) -> void: state = &"recover" state_time = 0.0 recover_duration = duration - focus_ring.visible = false func _process_recover() -> void: if state_time >= recover_duration: state = &"idle" state_time = 0.0 -func _can_use_skills() -> bool: - return silenced_time_remaining <= 0.0 - -func _fire_arcane_bolt(damage: float) -> void: - var direction := line_direction if line_direction != Vector2.ZERO else Vector2.RIGHT +func _spawn_arcane_bolt(spawn_position: Vector2, direction: Vector2, damage: float) -> void: var bolt := ARCANE_BOLT_SCENE.instantiate() - bolt.global_position = projectile_spawner.global_position + bolt.global_position = spawn_position get_tree().current_scene.add_child(bolt) - bolt.setup(self, direction, damage, 0.0, &"attack", _consume_enchant_payload()) + bolt.setup(self, direction, damage, 0.0, &"skill") -func _consume_enchant_payload() -> Dictionary: - if not enchant_active: - return {} - enchant_active = false - enchant_sigil.visible = false - return _build_control_payload() +func _spawn_skill1_bolt(spawn_position: Vector2, direction: Vector2, damage: float) -> void: + _spawn_red_bolt(spawn_position, direction, damage, skill1_ring_projectile_speed) + +func _spawn_red_bolt(spawn_position: Vector2, direction: Vector2, damage: float, speed: float) -> void: + var bolt: Area2D = MAGE_RED_BOLT_SCENE.instantiate() + bolt.global_position = spawn_position + get_tree().current_scene.add_child(bolt) + bolt.setup(self, direction, damage, speed) -func _apply_burst_damage(center: Vector2, radius: float, damage: float, extra_payload: Dictionary) -> void: +func _apply_radius_damage(center: Vector2, radius: float, damage: float) -> void: for player in get_tree().get_nodes_in_group("player"): if not _is_targetable_player(player): continue var node_2d: Node2D = player if center.distance_to(node_2d.global_position) > radius: continue - var payload := { + node_2d.receive_hit({ "source": self, "damage": damage, "crit_rate": 0.0 - } - for key in extra_payload.keys(): - payload[key] = extra_payload[key] - node_2d.receive_hit(payload) - -func _build_control_payload() -> Dictionary: - return { - "silence_duration": silence_duration, - "slow_duration": slow_duration, - "slow_multiplier": slow_multiplier, - "root_duration": root_duration - } - -func _update_blades(delta: float) -> void: - if not blades_active: - return - blade_time_remaining = maxf(blade_time_remaining - delta, 0.0) - blade_orbit.visible = true - blade_orbit.rotation += delta * 2.8 - for key in blade_hit_cooldowns.keys(): - blade_hit_cooldowns[key] = maxf(float(blade_hit_cooldowns[key]) - delta, 0.0) - for index in range(blade_nodes.size()): - var angle := blade_orbit.rotation + TAU * float(index) / float(max(blade_nodes.size(), 1)) - blade_nodes[index].position = Vector2.RIGHT.rotated(angle) * 34.0 + }) + +func _apply_laser_damage() -> void: + var direction := Vector2.RIGHT.rotated(skill3_current_angle) for player in get_tree().get_nodes_in_group("player"): if not _is_targetable_player(player): continue - var node_2d: Node2D = player - if global_position.distance_to(node_2d.global_position) > blade_radius: - continue - var key := node_2d.get_instance_id() - if float(blade_hit_cooldowns.get(key, 0.0)) > 0.0: - continue - blade_hit_cooldowns[key] = blade_tick_interval - node_2d.receive_hit({ - "source": self, - "damage": skill1_damage, - "crit_rate": 0.0 - }) - if blade_time_remaining <= 0.0: - blades_active = false - blade_orbit.visible = false - blade_hit_cooldowns.clear() + var node_2d := player as Node2D + if _distance_to_segment(node_2d.global_position, global_position, global_position + direction * skill3_laser_length) <= skill3_laser_width: + node_2d.receive_hit({ + "source": self, + "damage": skill3_laser_damage, + "crit_rate": 0.0 + }) + +func _show_laser_effect() -> void: + focus_ring.visible = false + enchant_sigil.visible = false + _update_laser_sprite_pair( + laser_body_sprite, + laser_core_sprite, + laser_line, + skill3_current_angle + ) + +func _update_skill3_laser_angles(elapsed: float) -> void: + var rotate_amount := deg_to_rad(skill3_rotate_speed_degrees) * elapsed + skill3_current_angle = _move_angle_toward(skill3_start_angle, skill3_target_angle, rotate_amount) -func _show_burst_effect(center: Vector2, radius: float) -> void: +func _show_burst_effect(center: Vector2, radius: float, color: Color) -> void: var ring := Line2D.new() ring.width = 4.0 ring.closed = true - ring.default_color = Color(0.78, 0.9, 1.0, 0.88) - ring.points = _build_ring_points(radius, 18) + ring.default_color = color + ring.points = _build_ring_points(radius, 20) ring.global_position = center get_tree().current_scene.add_child(ring) var tween := create_tween() - tween.tween_property(ring, "scale", Vector2.ONE * 1.18, 0.16) + tween.tween_property(ring, "scale", Vector2.ONE * 1.16, 0.16) tween.parallel().tween_property(ring, "modulate:a", 0.0, 0.16) tween.finished.connect(ring.queue_free) +func _clear_skill4_markers() -> void: + for marker_data in skill4_markers: + var marker: Variant = marker_data.get("node", null) + if marker is Node and is_instance_valid(marker): + (marker as Node).queue_free() + skill4_markers.clear() + +func _clear_unused_visual_nodes() -> void: + enchant_sigil.visible = false + focus_ring.visible = false + blade_orbit.visible = false + _set_laser_visuals_visible(false) + func _setup_body_visual() -> void: body_sprite = Sprite2D.new() body_sprite.texture = TEXTURE_LOADER.load_texture(MAGE_BODY_TEXTURE_PATH) @@ -426,33 +526,83 @@ func _setup_weapon_visual() -> void: weapon_sprite.position = Vector2(-40.0, 4.0) weapon.add_child(weapon_sprite) -func _animate_weapon_swing(start_degrees: float, end_degrees: float, duration: float) -> void: - weapon_angle_offset = deg_to_rad(start_degrees) - var tween := create_tween() - tween.tween_property(self, "weapon_angle_offset", deg_to_rad(end_degrees), maxf(duration, 0.01)) +func _setup_laser_visuals() -> void: + laser_body_sprite = _create_laser_body_sprite("LaserBody") + laser_core_sprite = _create_laser_core_sprite("LaserCore") + laser_line = _create_laser_line("LaserLine") + +func _create_laser_body_sprite(node_name: String) -> Sprite2D: + var sprite := Sprite2D.new() + sprite.name = node_name + sprite.texture = skill3_laser_body_texture + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sprite.centered = true + sprite.visible = false + sprite.modulate = Color(1.0, 1.0, 1.0, 0.94) + sprite.z_index = 80 + effects_layer.add_child(sprite) + return sprite + +func _create_laser_core_sprite(node_name: String) -> Sprite2D: + var sprite := Sprite2D.new() + sprite.name = node_name + sprite.texture = skill3_laser_core_texture + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sprite.centered = true + sprite.visible = false + sprite.modulate = Color(1.0, 1.0, 1.0, 0.96) + sprite.z_index = 81 + effects_layer.add_child(sprite) + return sprite + +func _create_laser_line(node_name: String) -> Line2D: + var line := Line2D.new() + line.name = node_name + line.width = skill3_laser_width * 1.35 + line.default_color = Color(1.0, 0.82, 0.42, 0.78) + line.visible = false + line.z_index = 82 + line.closed = false + effects_layer.add_child(line) + return line + +func _update_laser_sprite_pair(body_sprite_ref: Sprite2D, core_sprite_ref: Sprite2D, line_ref: Line2D, angle: float) -> void: + if body_sprite_ref == null or core_sprite_ref == null: + return + var direction := Vector2.RIGHT.rotated(angle) + if line_ref != null: + line_ref.visible = true + line_ref.global_position = global_position + line_ref.points = PackedVector2Array([ + Vector2.ZERO, + direction * skill3_laser_length + ]) + if skill3_laser_body_texture != null: + var body_size := skill3_laser_body_texture.get_size() + var body_scale_x := skill3_laser_length / maxf(body_size.x, 1.0) + var body_scale_y := maxf((skill3_laser_width * 2.2) / maxf(body_size.y, 1.0), 0.01) + body_sprite_ref.visible = true + body_sprite_ref.global_position = global_position + direction * (skill3_laser_length * 0.5) + body_sprite_ref.rotation = angle + body_sprite_ref.scale = Vector2(body_scale_x, body_scale_y) + if skill3_laser_core_texture != null: + core_sprite_ref.visible = true + core_sprite_ref.global_position = global_position + core_sprite_ref.scale = Vector2.ONE * 1.1 + core_sprite_ref.rotation = 0.0 + +func _set_laser_visuals_visible(visible_value: bool) -> void: + for item in [laser_body_sprite, laser_core_sprite, laser_line]: + if item != null: + item.visible = visible_value func _update_visuals() -> void: if target != null and is_instance_valid(target): var to_target := target.global_position - global_position if to_target != Vector2.ZERO: line_direction = to_target.normalized() - var body_tint := Color.WHITE - if silenced_time_remaining > 0.0: - body_tint = Color(0.9, 0.82, 1.0, 1.0) - elif enchant_active: - body_tint = Color(1.0, 0.95, 0.86, 1.0) - elif blades_active: - body_tint = Color(0.9, 0.96, 1.0, 1.0) - _set_body_tint(body_tint) - focus_ring.visible = state == &"basic_attack" - if focus_ring.visible: - focus_ring.rotation = line_direction.angle() - focus_ring.scale = Vector2.ONE * (0.9 + 0.08 * sin(Time.get_ticks_msec() * 0.008)) + _set_body_tint(Color.WHITE) burst_ring.default_color = Color(0.78, 0.9, 1.0, 0.9) - enchant_sigil.visible = enchant_active - if enchant_sigil.visible: - enchant_sigil.rotation += 0.08 - enchant_sigil.scale = Vector2.ONE * (0.96 + 0.08 * sin(Time.get_ticks_msec() * 0.008)) _apply_float_body_motion(1.45, 0.055) weapon.position = line_direction * 18.0 + Vector2(0.0, -4.0) weapon.rotation = line_direction.angle() + weapon_angle_offset @@ -478,6 +628,31 @@ func _build_ring_points(radius: float, steps: int = 16) -> PackedVector2Array: points.append(Vector2.RIGHT.rotated(angle) * radius) return points +func _distance_to_segment(point: Vector2, segment_start: Vector2, segment_end: Vector2) -> float: + var segment := segment_end - segment_start + var length_sq := segment.length_squared() + if length_sq <= 0.0001: + return point.distance_to(segment_start) + var weight := clampf((point - segment_start).dot(segment) / length_sq, 0.0, 1.0) + var closest := segment_start + segment * weight + return point.distance_to(closest) + +func _move_angle_toward(from_angle: float, to_angle: float, max_delta: float) -> float: + var difference := angle_difference(from_angle, to_angle) + if absf(difference) <= max_delta: + return to_angle + return from_angle + signf(difference) * max_delta + +func _show_intent_text(label_text: String, color_value: Color, world_position: Vector2, scale_value: float = 0.84) -> void: + var popup := DAMAGE_NUMBER_SCENE.instantiate() + popup.position = to_local(world_position) + Vector2(-42.0, -100.0) + if popup.has_method("setup_text"): + popup.setup_text(label_text, color_value, scale_value) + if popup is CanvasItem: + (popup as CanvasItem).z_index = 60 + popup.lifetime = 0.8 + effects_layer.add_child(popup) + func _spawn_damage_number(amount: float, is_critical: bool) -> void: var damage_number := DAMAGE_NUMBER_SCENE.instantiate() damage_number.position = Vector2(0.0, -40.0) @@ -493,12 +668,6 @@ func _on_damaged(_amount: float, remaining_hp: float, _source: Node) -> void: _update_visuals() ) -func _on_healed(_amount: float, current_hp: float) -> void: - hp = current_hp - -func _on_shield_changed(current_shield: float) -> void: - shield = current_shield - func _on_died() -> void: if state == &"dead": return @@ -507,8 +676,10 @@ func _on_died() -> void: focus_ring.visible = false burst_ring.visible = false enchant_sigil.visible = false + _set_laser_visuals_visible(false) blade_orbit.visible = false weapon.visible = false + _clear_skill4_markers() Sfx.play_event(&"boss_generic_dead", global_position) var timer := get_tree().create_timer(0.55) timer.timeout.connect(func() -> void: diff --git a/actors/bosses/town/ranger_boss.gd b/actors/bosses/town/ranger_boss.gd index 3801cf4..e94e979 100644 --- a/actors/bosses/town/ranger_boss.gd +++ b/actors/bosses/town/ranger_boss.gd @@ -16,27 +16,41 @@ const MELEE_EFFECT_TEXTURE_PATH := "res://assets/effects/vfx/magic_circle.webp" @export var attack_damage: float = 42.0 @export var attack_range: float = 92.0 @export var attack_arc_degrees: float = 112.0 -@export var attack_interval: float = 0.82 -@export var skill1_damage: float = 68.0 -@export var skill1_cooldown: float = 5.8 -@export var skill1_cast_duration: float = 0.26 -@export var shadow_step_duration: float = 1.18 +@export var attack_interval: float = 1.35 +@export var skill_cycle_interval: float = 7.0 +@export var skill_cycle_initial_delay: float = 3.0 + +@export var cloud_arrow_damage: float = 60.0 +@export var cloud_arrow_charge_duration: float = 0.75 +@export var cloud_arrow_spread_degrees: float = 11.0 +@export var cloud_arrow_wave_delay: float = 0.22 +@export var cloud_arrow_fan_count: int = 5 +@export var cloud_arrow_speed: float = 560.0 + +@export var shadow_step_duration: float = 2.8 +@export var shadow_step_damage_reduction: float = 0.9 +@export var shadow_step_orbit_duration: float = 2.2 @export var shadow_step_speed: float = 370.0 -@export var shadow_step_slash_damage: float = 58.0 -@export var shadow_step_heal: float = 120.0 -@export var shadow_step_attack_speed_gain: float = 0.28 -@export var shadow_step_speed_gain: float = 0.22 -@export var shadow_step_buff_duration: float = 5.0 -@export var skill2_cooldown: float = 8.4 -@export var assassination_range: float = 340.0 -@export var assassination_dash_speed: float = 980.0 -@export var assassination_stop_distance: float = 48.0 -@export var assassination_damage: float = 96.0 -@export var assassination_strike_duration: float = 0.26 -@export var bleed_damage_per_second: float = 18.0 -@export var bleed_duration: float = 4.0 -@export var execute_health_threshold: float = 0.12 -@export var skill3_cooldown: float = 9.6 +@export var shadow_step_orbit_radius: float = 300.0 +@export var shadow_shockwave_charge_duration: float = 0.85 +@export var shadow_shockwave_radius: float = 170.0 +@export var shadow_shockwave_damage: float = 50.0 +@export var shadow_burst_interval: float = 0.48 +@export var shadow_burst_projectiles: int = 8 +@export var shadow_burst_damage: float = 26.0 +@export var shadow_burst_speed: float = 330.0 + +@export var assassination_damage: float = 46.0 +@export var assassination_first_charge_duration: float = 1.0 +@export var assassination_followup_charge_duration: float = 0.3 +@export var assassination_first_lock_time: float = 0.55 +@export var assassination_dash_speed: float = 1120.0 +@export var assassination_dash_width: float = 38.0 +@export var assassination_dash_overshoot: float = 150.0 +@export var assassination_reappear_distance: float = 200.0 +@export var assassination_pentagram_charge_duration: float = 0.8 +@export var assassination_pentagram_radius: float = 150.0 +@export var assassination_pentagram_dash_total_duration: float = 0.8 @onready var body: Polygon2D = $Body @onready var weapon: Node2D = $Weapon @@ -52,19 +66,15 @@ var state: StringName = &"idle" var state_time: float = 0.0 var recover_duration: float = 0.0 var attack_cooldown: float = 0.0 -var skill1_cooldown_remaining: float = 1.8 -var skill2_cooldown_remaining: float = 4.0 -var skill3_cooldown_remaining: float = 6.0 +var skill_cycle_remaining: float = 0.0 var action_committed: bool = false var invulnerable: bool = false var line_direction: Vector2 = Vector2.RIGHT var shadow_orbit_direction: float = 1.0 var shadow_afterimage_timer: float = 0.0 -var shadow_step_buff_time_remaining: float = 0.0 -var current_attack_speed_bonus: float = 0.0 -var current_speed_bonus: float = 0.0 +var shadow_burst_next_time: float = 0.0 +var shadow_damage_reduction_time_remaining: float = 0.0 var active_skill_target: Node2D = null -var damage_applied: bool = false var silenced_time_remaining: float = 0.0 var root_time_remaining: float = 0.0 var slow_time_remaining: float = 0.0 @@ -75,20 +85,45 @@ var weapon_angle_offset: float = 0.0 var melee_texture: Texture2D = null var visual_last_position: Vector2 = Vector2.ZERO var visual_bob_time: float = 0.0 +var skill_rng: RandomNumberGenerator = RandomNumberGenerator.new() + +var cloud_arrow_wave_count: int = 0 +var cloud_arrow_waves_fired: int = 0 +var cloud_arrow_next_wave_time: float = 0.0 +var cloud_arrow_locked_direction: Vector2 = Vector2.RIGHT +var cloud_arrow_left_marker: Line2D = null +var cloud_arrow_right_marker: Line2D = null +var cloud_arrow_pending_relock: bool = false + +var assassination_anchor_position: Vector2 = Vector2.ZERO +var assassination_locked_position: Vector2 = Vector2.ZERO +var assassination_lock_captured: bool = false +var assassination_dash_index: int = 0 +var assassination_dash_total: int = 0 +var assassination_dash_start: Vector2 = Vector2.ZERO +var assassination_dash_end: Vector2 = Vector2.ZERO +var assassination_dash_duration: float = 0.0 +var assassination_dash_hit_targets: Array[Node] = [] +var assassination_path_points: Array[Vector2] = [] +var assassination_followup_target_position: Vector2 = Vector2.ZERO +var assassination_pentagram_repeat_index: int = 0 +var assassination_pentagram_repeat_total: int = 3 func _ready() -> void: add_to_group("damageable") + skill_rng.randomize() health_component.setup(max_hp, defense_value) health_component.damaged.connect(_on_damaged) health_component.healed.connect(_on_healed) health_component.died.connect(_on_died) hp = max_hp + skill_cycle_remaining = skill_cycle_initial_delay visual_last_position = global_position melee_texture = TEXTURE_LOADER.load_texture(MELEE_EFFECT_TEXTURE_PATH) _setup_body_visual() _setup_weapon_visual() - aim_ring.visible = false - assassination_mark.visible = false + _setup_cloud_arrow_markers() + _hide_skill_markers() _update_visuals() func bind_player(player: Node2D) -> void: @@ -98,12 +133,12 @@ func get_status_title() -> String: return "Shadow Huntress" func get_status_text() -> String: - var buff_text := " | Buffed" if shadow_step_buff_time_remaining > 0.0 else "" - return "HP %d / %d\nATK %.2fs%s\nState: %s" % [ + var shadow_text := " | Shadow DR" if shadow_damage_reduction_time_remaining > 0.0 else "" + return "HP %d / %d\nSkill %.1fs%s\nState: %s" % [ int(round(hp)), int(round(max_hp)), - _get_attack_interval(), - buff_text, + skill_cycle_remaining, + shadow_text, String(state) ] @@ -113,15 +148,9 @@ func _physics_process(delta: float) -> void: if target == null or not _is_targetable_player(target): _find_target() _update_status_timers(delta) + _update_shadow_damage_reduction(delta) attack_cooldown = maxf(attack_cooldown - delta, 0.0) - skill1_cooldown_remaining = maxf(skill1_cooldown_remaining - delta, 0.0) - skill2_cooldown_remaining = maxf(skill2_cooldown_remaining - delta, 0.0) - skill3_cooldown_remaining = maxf(skill3_cooldown_remaining - delta, 0.0) - if shadow_step_buff_time_remaining > 0.0: - shadow_step_buff_time_remaining = maxf(shadow_step_buff_time_remaining - delta, 0.0) - if shadow_step_buff_time_remaining <= 0.0: - current_attack_speed_bonus = 0.0 - current_speed_bonus = 0.0 + skill_cycle_remaining = maxf(skill_cycle_remaining - delta, 0.0) state_time += delta _update_state(delta) _update_visuals() @@ -170,20 +199,35 @@ func _update_status_timers(delta: float) -> void: else: slow_factor = 1.0 +func _update_shadow_damage_reduction(delta: float) -> void: + if shadow_damage_reduction_time_remaining <= 0.0: + return + shadow_damage_reduction_time_remaining = maxf(shadow_damage_reduction_time_remaining - delta, 0.0) + if shadow_damage_reduction_time_remaining <= 0.0: + health_component.set_damage_reduction(0.0) + func _update_state(delta: float) -> void: match state: &"idle": _process_idle(delta) &"basic_attack": _process_basic_attack() - &"skill1_cast": - _process_skill1_cast() + &"skill1_charge": + _process_skill1_charge() + &"skill1_fire": + _process_skill1_fire() &"skill2_shadow": _process_skill2_shadow(delta) + &"skill2_shockwave_charge": + _process_skill2_shockwave_charge() + &"skill3_charge": + _process_skill3_charge() &"skill3_dash": - _process_skill3_dash(delta) - &"skill3_strike": - _process_skill3_strike() + _process_skill3_dash() + &"skill3_pentagram_charge": + _process_skill3_pentagram_charge() + &"skill3_pentagram_dash": + _process_skill3_pentagram_dash() &"recover": _process_recover() @@ -194,21 +238,15 @@ func _process_idle(delta: float) -> void: var distance := to_target.length() if to_target != Vector2.ZERO: line_direction = to_target.normalized() - if _can_use_skills() and skill3_cooldown_remaining <= 0.0 and distance <= assassination_range: - _start_skill3() - return - if _can_use_skills() and skill2_cooldown_remaining <= 0.0 and distance <= 220.0: - _start_skill2() - return - if _can_use_skills() and skill1_cooldown_remaining <= 0.0 and distance >= 110.0: - _start_skill1() + if _can_use_skills() and skill_cycle_remaining <= 0.0: + _start_random_skill() return if attack_cooldown <= 0.0 and distance <= attack_range: _start_basic_attack() return if root_time_remaining > 0.0: return - var speed := move_speed * (1.0 + current_speed_bonus) * slow_factor + var speed := move_speed * slow_factor var tangent := Vector2(-line_direction.y, line_direction.x) * shadow_orbit_direction if distance > attack_range * 0.86: global_position += (line_direction + tangent * 0.22).normalized() * speed * delta @@ -230,161 +268,287 @@ func _start_basic_attack() -> void: Sfx.play_event(&"ranger_attack", global_position) func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.18: + if not action_committed and state_time >= 1.0: action_committed = true _hit_target_in_arc(attack_range, attack_damage, attack_arc_degrees) _spawn_slash_effect(attack_range, Color(0.88, 1.0, 0.76, 0.92)) - if state_time >= 0.42: + if state_time >= 1.24: _enter_recover(0.16) +func _start_random_skill() -> void: + skill_cycle_remaining = skill_cycle_interval + match skill_rng.randi_range(0, 2): + 0: + _start_skill1() + 1: + _start_skill2() + _: + _start_skill3() func _start_skill1() -> void: - state = &"skill1_cast" + state = &"skill1_charge" state_time = 0.0 action_committed = false - skill1_cooldown_remaining = skill1_cooldown - aim_ring.visible = true - aim_ring.scale = Vector2.ONE * 0.42 - aim_ring.modulate = Color(0.72, 1.0, 0.86, 0.88) - _animate_weapon_swing(-28.0, 10.0, skill1_cast_duration) + cloud_arrow_wave_count = 3 if _is_half_health() else 1 + cloud_arrow_waves_fired = 0 + cloud_arrow_next_wave_time = 0.0 + cloud_arrow_pending_relock = false + _update_line_direction_to_target() + cloud_arrow_locked_direction = line_direction if line_direction.length_squared() > 0.0001 else Vector2.RIGHT + _show_cloud_arrow_markers(cloud_arrow_locked_direction) + _animate_weapon_swing(-28.0, 10.0, cloud_arrow_charge_duration) + +func _process_skill1_charge() -> void: + _show_cloud_arrow_markers(cloud_arrow_locked_direction) + if state_time < cloud_arrow_charge_duration: + return + state = &"skill1_fire" + state_time = 0.0 + cloud_arrow_waves_fired = 0 + cloud_arrow_next_wave_time = 0.0 + _fire_cloud_arrow_wave() + cloud_arrow_waves_fired += 1 + cloud_arrow_next_wave_time += cloud_arrow_wave_delay + +func _process_skill1_fire() -> void: + while cloud_arrow_waves_fired < cloud_arrow_wave_count and state_time >= cloud_arrow_next_wave_time: + if cloud_arrow_pending_relock: + _relock_cloud_arrow_direction_from_target() + cloud_arrow_pending_relock = false + _fire_cloud_arrow_wave() + cloud_arrow_waves_fired += 1 + cloud_arrow_next_wave_time += cloud_arrow_wave_delay + cloud_arrow_pending_relock = cloud_arrow_waves_fired < cloud_arrow_wave_count + var final_wave_time := maxf(float(cloud_arrow_wave_count - 1) * cloud_arrow_wave_delay, 0.0) + if cloud_arrow_waves_fired >= cloud_arrow_wave_count and state_time >= final_wave_time + 0.16: + _hide_cloud_arrow_markers() + _enter_recover(0.16) -func _process_skill1_cast() -> void: - if not action_committed and state_time >= skill1_cast_duration * 0.65: - action_committed = true - _fire_piercing_arrow_combo() - if state_time >= skill1_cast_duration + 0.18: - aim_ring.visible = false - _enter_recover(0.18) - -func _fire_piercing_arrow_combo() -> void: - var facing_direction := line_direction if line_direction != Vector2.ZERO else Vector2.RIGHT - _spawn_piercing_arrow(facing_direction, skill1_damage) - if hp <= max_hp * 0.6: - _spawn_piercing_arrow(facing_direction.rotated(deg_to_rad(-14.0)), skill1_damage * 0.6) - _spawn_piercing_arrow(facing_direction.rotated(deg_to_rad(14.0)), skill1_damage * 0.6) - if hp <= max_hp * 0.3: - var timer := get_tree().create_timer(0.12) - timer.timeout.connect(func() -> void: - if is_instance_valid(self) and state != &"dead": - _spawn_piercing_arrow(facing_direction, skill1_damage * 0.85) - ) +func _fire_cloud_arrow_wave() -> void: + var base_direction := cloud_arrow_locked_direction if cloud_arrow_locked_direction != Vector2.ZERO else Vector2.RIGHT + var spread_step := deg_to_rad(cloud_arrow_spread_degrees) + var center_index := float(cloud_arrow_fan_count - 1) * 0.5 + for index in range(cloud_arrow_fan_count): + var offset := (float(index) - center_index) * spread_step + _spawn_piercing_arrow(base_direction.rotated(offset), cloud_arrow_damage) + Sfx.play_event(&"ranger_skill1_arrow", projectile_spawner.global_position) + +func _relock_cloud_arrow_direction_from_target() -> void: + if target == null or not is_instance_valid(target): + return + var to_target := target.global_position - global_position + if to_target.length_squared() <= 0.0001: + return + cloud_arrow_locked_direction = to_target.normalized() + _show_cloud_arrow_markers(cloud_arrow_locked_direction) func _start_skill2() -> void: state = &"skill2_shadow" state_time = 0.0 action_committed = false - invulnerable = true - shadow_afterimage_timer = 0.0 - skill2_cooldown_remaining = skill2_cooldown shadow_orbit_direction *= -1.0 + shadow_afterimage_timer = 0.0 + shadow_burst_next_time = 0.22 + shadow_damage_reduction_time_remaining = shadow_step_duration + health_component.set_damage_reduction(shadow_step_damage_reduction) + _hide_skill_markers() Sfx.play_event(&"ranger_skill2_roll", global_position) func _process_skill2_shadow(delta: float) -> void: - if target == null or not is_instance_valid(target): - invulnerable = false - _enter_recover(0.12) - return - var progress := clampf(state_time / maxf(shadow_step_duration, 0.001), 0.0, 1.0) - var angle := lerpf(-1.1, 1.1, progress) * shadow_orbit_direction - var desired_position := target.global_position + Vector2.RIGHT.rotated(angle) * 126.0 - global_position = global_position.move_toward(desired_position, shadow_step_speed * delta) - line_direction = (target.global_position - global_position).normalized() - if line_direction == Vector2.ZERO: - line_direction = Vector2.RIGHT + if target != null and is_instance_valid(target): + var elapsed := minf(state_time, shadow_step_orbit_duration) + var target_offset_angle := elapsed * 3.1 * shadow_orbit_direction + var desired_position := target.global_position + Vector2.RIGHT.rotated(target_offset_angle) * shadow_step_orbit_radius + global_position = global_position.move_toward(desired_position, shadow_step_speed * slow_factor * delta) + _update_line_direction_to_target() shadow_afterimage_timer -= delta if shadow_afterimage_timer <= 0.0: - shadow_afterimage_timer = 0.05 + shadow_afterimage_timer = 0.055 _spawn_afterimage() - if not action_committed and state_time >= shadow_step_duration * 0.72: - action_committed = true - _hit_target_in_arc(114.0, shadow_step_slash_damage, 142.0) - _spawn_slash_effect(114.0, Color(0.76, 0.94, 1.0, 0.95)) + while state_time >= shadow_burst_next_time: + _fire_shadow_burst() + shadow_burst_next_time += shadow_burst_interval if state_time >= shadow_step_duration: - invulnerable = false - current_attack_speed_bonus = shadow_step_attack_speed_gain - current_speed_bonus = shadow_step_speed_gain - shadow_step_buff_time_remaining = shadow_step_buff_duration - health_component.heal(shadow_step_heal) - _enter_recover(0.12) + _start_shadow_shockwave_charge() -func _start_skill3() -> void: - state = &"skill3_dash" +func _start_shadow_shockwave_charge() -> void: + state = &"skill2_shockwave_charge" state_time = 0.0 action_committed = false - damage_applied = false + _show_ring_marker(global_position, shadow_shockwave_radius, Color(0.76, 0.94, 1.0, 0.84)) + +func _process_skill2_shockwave_charge() -> void: + _show_ring_marker(global_position, shadow_shockwave_radius, Color(0.76, 0.94, 1.0, 0.84)) + if state_time < shadow_shockwave_charge_duration: + return + if not action_committed: + action_committed = true + assassination_mark.visible = false + _hit_targets_in_radius(global_position, shadow_shockwave_radius, shadow_shockwave_damage) + _spawn_radius_burst(global_position, shadow_shockwave_radius, Color(0.72, 0.92, 1.0, 0.9)) + Sfx.play_event(&"ranger_skill2_roll", global_position) + _enter_recover(0.2) + +func _start_skill3() -> void: active_skill_target = target - skill3_cooldown_remaining = skill3_cooldown - assassination_mark.visible = true - assassination_mark.scale = Vector2.ONE * 0.76 + assassination_anchor_position = target.global_position if target != null and is_instance_valid(target) else global_position + line_direction * 160.0 + assassination_followup_target_position = assassination_anchor_position + assassination_dash_index = 0 + assassination_dash_hit_targets.clear() + _hide_skill_markers() _animate_weapon_swing(-34.0, 12.0, 0.18) Sfx.play_event(&"ranger_skill3_assassinate", global_position) + if _is_half_health(): + _start_pentagram_assassination() + else: + assassination_dash_total = 3 + assassination_lock_captured = false + assassination_locked_position = assassination_anchor_position + _start_assassination_charge() -func _process_skill3_dash(delta: float) -> void: - if active_skill_target == null or not is_instance_valid(active_skill_target): - assassination_mark.visible = false - _enter_recover(0.12) - return - var to_target := active_skill_target.global_position - global_position - var distance := to_target.length() - if distance <= assassination_stop_distance or state_time >= 0.42: - state = &"skill3_strike" - state_time = 0.0 - damage_applied = false +func _start_assassination_charge() -> void: + state = &"skill3_charge" + state_time = 0.0 + action_committed = false + assassination_dash_hit_targets.clear() + if assassination_dash_index <= 0: + assassination_lock_captured = false + _update_line_direction_to_target() + _show_line_marker(global_position, global_position + line_direction * 260.0, 5.0, Color(1.0, 0.48, 0.42, 0.84)) + else: + _prepare_followup_assassination_dash() + +func _process_skill3_charge() -> void: + var charge_duration := _current_assassination_charge_duration() + if assassination_dash_index <= 0: + if not assassination_lock_captured and state_time >= assassination_first_lock_time: + _capture_first_assassination_target() + elif not assassination_lock_captured: + _update_line_direction_to_target() + _show_line_marker(global_position, global_position + line_direction * 260.0, 5.0, Color(1.0, 0.48, 0.42, 0.84)) + if state_time < charge_duration: return - line_direction = to_target.normalized() - if line_direction == Vector2.ZERO: - line_direction = Vector2.RIGHT - global_position += line_direction * assassination_dash_speed * delta + if assassination_dash_index <= 0 and not assassination_lock_captured: + _capture_first_assassination_target() + _begin_assassination_dash() + +func _capture_first_assassination_target() -> void: + assassination_locked_position = target.global_position if target != null and is_instance_valid(target) else assassination_anchor_position + assassination_anchor_position = assassination_locked_position + _prepare_first_assassination_dash(assassination_locked_position) + assassination_lock_captured = true + +func _prepare_first_assassination_dash(lock_position: Vector2) -> void: + var direction := lock_position - global_position + line_direction = direction.normalized() if direction.length_squared() > 0.0001 else Vector2.RIGHT + assassination_dash_start = global_position + assassination_dash_end = lock_position + line_direction * assassination_dash_overshoot + assassination_dash_duration = _dash_duration_between(assassination_dash_start, assassination_dash_end) + _show_line_marker(assassination_dash_start, assassination_dash_end, 5.0, Color(1.0, 0.48, 0.42, 0.9)) + +func _prepare_followup_assassination_dash() -> void: + var old_position := global_position + var lock_position := assassination_followup_target_position + if lock_position == Vector2.ZERO: + lock_position = assassination_anchor_position + var spawn_direction := Vector2.RIGHT.rotated(TAU * skill_rng.randf()) + global_position = lock_position + spawn_direction * assassination_reappear_distance + _spawn_afterimage_at(old_position) _spawn_afterimage() - -func _process_skill3_strike() -> void: - if not damage_applied and state_time >= assassination_strike_duration * 0.42: - damage_applied = true - _apply_assassination_damage() - _spawn_slash_effect(124.0, Color(1.0, 0.74, 0.68, 0.96)) - if state_time >= assassination_strike_duration: - active_skill_target = null - assassination_mark.visible = false - _enter_recover(0.18) - -func _apply_assassination_damage() -> void: - if active_skill_target == null or not is_instance_valid(active_skill_target): + var facing := (lock_position - global_position).normalized() if lock_position.distance_squared_to(global_position) > 0.0001 else Vector2.RIGHT + line_direction = facing + assassination_dash_start = global_position + assassination_dash_end = lock_position + facing * assassination_dash_overshoot + assassination_dash_duration = _dash_duration_between(assassination_dash_start, assassination_dash_end) + _show_line_marker(assassination_dash_start, assassination_dash_end, 5.0, Color(1.0, 0.48, 0.42, 0.9)) + +func _begin_assassination_dash() -> void: + state = &"skill3_dash" + state_time = 0.0 + action_committed = false + assassination_dash_hit_targets.clear() + aim_ring.visible = false + _spawn_dash_impact(assassination_dash_start, assassination_dash_end, assassination_dash_width, Color(1.0, 0.54, 0.44, 0.88)) + +func _process_skill3_dash() -> void: + var previous_position := global_position + var progress := clampf(state_time / maxf(assassination_dash_duration, 0.01), 0.0, 1.0) + global_position = assassination_dash_start.lerp(assassination_dash_end, progress) + line_direction = (assassination_dash_end - assassination_dash_start).normalized() + _hit_targets_between(previous_position, global_position, assassination_dash_width, assassination_damage) + if progress >= 1.0: + assassination_followup_target_position = target.global_position if target != null and is_instance_valid(target) else assassination_followup_target_position + assassination_dash_index += 1 + if assassination_dash_index < assassination_dash_total: + _start_assassination_charge() + else: + _enter_recover(0.24) + +func _start_pentagram_assassination() -> void: + state = &"skill3_pentagram_charge" + state_time = 0.0 + action_committed = false + assassination_dash_total = 5 + assassination_dash_index = 0 + assassination_pentagram_repeat_index = 0 + assassination_followup_target_position = assassination_anchor_position + _build_pentagram_path(assassination_anchor_position, assassination_pentagram_radius * 1.5) + _show_pentagram_marker(assassination_anchor_position) + +func _process_skill3_pentagram_charge() -> void: + _show_pentagram_marker(assassination_anchor_position) + if state_time < assassination_pentagram_charge_duration: return - var damage := assassination_damage - if _can_execute_target(active_skill_target): - damage = 999999.0 - active_skill_target.receive_hit({ - "source": self, - "damage": damage, - "crit_rate": 0.18 - }) - if damage < 999999.0: - _apply_bleed(active_skill_target) - -func _apply_bleed(target_node: Node) -> void: - var ticks := int(floor(bleed_duration)) - for tick in range(ticks): - var timer := get_tree().create_timer(float(tick + 1)) - timer.timeout.connect(func() -> void: - if is_instance_valid(self) and is_instance_valid(target_node) and target_node.has_method("receive_hit"): - target_node.receive_hit({ - "source": self, - "damage": bleed_damage_per_second, - "crit_rate": 0.0 - }) - ) - -func _can_execute_target(target_node: Node) -> bool: - if target_node == null: - return false - var target_hp := float(target_node.get("hp")) - var target_max_hp := float(target_node.get("max_hp")) - return target_max_hp > 0.0 and target_hp <= target_max_hp * execute_health_threshold + assassination_mark.visible = false + _start_pentagram_dash_segment() +func _start_pentagram_dash_segment() -> void: + if assassination_dash_index >= assassination_dash_total or assassination_path_points.size() < assassination_dash_index + 2: + _enter_recover(0.28) + return + var old_position := global_position + assassination_dash_start = assassination_path_points[assassination_dash_index] + assassination_dash_end = assassination_path_points[assassination_dash_index + 1] + var direction := assassination_dash_end - assassination_dash_start + line_direction = direction.normalized() if direction.length_squared() > 0.0001 else Vector2.RIGHT + _spawn_afterimage_at(old_position) + global_position = assassination_dash_start + _spawn_afterimage() + assassination_dash_duration = assassination_pentagram_dash_total_duration / 5.0 + assassination_dash_hit_targets.clear() + state = &"skill3_pentagram_dash" + state_time = 0.0 + action_committed = false + _spawn_dash_impact(assassination_dash_start, assassination_dash_end, assassination_dash_width, Color(1.0, 0.58, 0.48, 0.9)) + +func _process_skill3_pentagram_dash() -> void: + var previous_position := global_position + var progress := clampf(state_time / maxf(assassination_dash_duration, 0.01), 0.0, 1.0) + global_position = assassination_dash_start.lerp(assassination_dash_end, progress) + line_direction = (assassination_dash_end - assassination_dash_start).normalized() + _hit_targets_between(previous_position, global_position, assassination_dash_width, assassination_damage) + if progress >= 1.0: + assassination_dash_index += 1 + if assassination_dash_index < assassination_dash_total: + _start_pentagram_dash_segment() + else: + assassination_pentagram_repeat_index += 1 + if assassination_pentagram_repeat_index < assassination_pentagram_repeat_total: + assassination_dash_index = 0 + assassination_anchor_position = target.global_position if target != null and is_instance_valid(target) else assassination_anchor_position + assassination_followup_target_position = assassination_anchor_position + _build_pentagram_path(assassination_anchor_position, assassination_pentagram_radius * 1.5) + state = &"skill3_pentagram_charge" + state_time = 0.0 + action_committed = false + _show_pentagram_marker(assassination_anchor_position) + else: + _enter_recover(0.16) func _enter_recover(duration: float) -> void: state = &"recover" state_time = 0.0 recover_duration = duration - aim_ring.visible = false + active_skill_target = null + _hide_skill_markers() func _process_recover() -> void: if state_time >= recover_duration: @@ -395,7 +559,24 @@ func _can_use_skills() -> bool: return silenced_time_remaining <= 0.0 func _get_attack_interval() -> float: - return attack_interval / (1.0 + current_attack_speed_bonus) + return attack_interval + +func _is_half_health() -> bool: + return hp > 0.0 and hp <= max_hp * 0.5 + +func _current_assassination_charge_duration() -> float: + if assassination_dash_index <= 0: + return assassination_first_charge_duration + if _is_half_health(): + return assassination_pentagram_charge_duration + return assassination_followup_charge_duration + +func _update_line_direction_to_target() -> void: + if target == null or not is_instance_valid(target): + return + var to_target := target.global_position - global_position + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() func _hit_target_in_arc(radius: float, damage: float, arc_degrees: float) -> void: if target == null or not is_instance_valid(target): @@ -408,11 +589,156 @@ func _hit_target_in_arc(radius: float, damage: float, arc_degrees: float) -> voi "crit_rate": 0.12 }) +func _hit_targets_in_radius(center: Vector2, radius: float, damage: float) -> void: + for candidate in get_tree().get_nodes_in_group("player"): + if candidate == self or not (candidate is Node2D): + continue + if not candidate.has_method("receive_hit"): + continue + var node_2d: Node2D = candidate + if node_2d.global_position.distance_to(center) > radius: + continue + candidate.receive_hit({ + "source": self, + "damage": damage, + "crit_rate": 0.0 + }) + +func _hit_targets_between(start_position: Vector2, end_position: Vector2, width: float, damage: float) -> void: + if start_position == end_position: + return + for candidate in get_tree().get_nodes_in_group("player"): + if candidate == self or not (candidate is Node2D): + continue + if assassination_dash_hit_targets.has(candidate): + continue + if not candidate.has_method("receive_hit"): + continue + var node_2d: Node2D = candidate + if _distance_to_segment(node_2d.global_position, start_position, end_position) > width: + continue + assassination_dash_hit_targets.append(candidate) + candidate.receive_hit({ + "source": self, + "damage": damage, + "crit_rate": 0.0 + }) + func _spawn_piercing_arrow(direction: Vector2, damage: float) -> void: var arrow := PIERCING_ARROW_SCENE.instantiate() arrow.global_position = projectile_spawner.global_position get_tree().current_scene.add_child(arrow) - arrow.setup(self, direction, damage, 0.1) + arrow.setup(self, direction, damage, 0.0) + if "speed" in arrow: + arrow.speed = cloud_arrow_speed + +func _fire_shadow_burst() -> void: + for index in range(shadow_burst_projectiles): + var angle := TAU * float(index) / float(shadow_burst_projectiles) + state_time * 0.7 * shadow_orbit_direction + var direction := Vector2.RIGHT.rotated(angle) + var arrow := PIERCING_ARROW_SCENE.instantiate() + arrow.global_position = global_position + direction * 22.0 + get_tree().current_scene.add_child(arrow) + arrow.setup(self, direction, shadow_burst_damage, 0.0) + if "speed" in arrow: + arrow.speed = shadow_burst_speed + +func _show_line_marker(start_position: Vector2, end_position: Vector2, width: float, color: Color) -> void: + var direction := end_position - start_position + var length := direction.length() + if length <= 0.001: + return + aim_ring.visible = true + aim_ring.closed = false + aim_ring.global_position = start_position + aim_ring.rotation = direction.angle() + aim_ring.width = width + aim_ring.default_color = color + aim_ring.points = PackedVector2Array([Vector2.ZERO, Vector2(length, 0.0)]) + +func _setup_cloud_arrow_markers() -> void: + cloud_arrow_left_marker = _create_cloud_arrow_marker(Color(0.68, 1.0, 0.82, 0.7)) + cloud_arrow_right_marker = _create_cloud_arrow_marker(Color(0.68, 1.0, 0.82, 0.7)) + effects_layer.add_child(cloud_arrow_left_marker) + effects_layer.add_child(cloud_arrow_right_marker) + +func _create_cloud_arrow_marker(color: Color) -> Line2D: + var marker := Line2D.new() + marker.visible = false + marker.width = 3.0 + marker.closed = false + marker.default_color = color + return marker + +func _show_cloud_arrow_markers(direction: Vector2) -> void: + var base_direction := direction.normalized() if direction.length_squared() > 0.0001 else Vector2.RIGHT + var spread := deg_to_rad(cloud_arrow_spread_degrees) + _show_line_marker(global_position, global_position + base_direction * 320.0, 4.0, Color(0.72, 1.0, 0.86, 0.82)) + if cloud_arrow_left_marker != null: + cloud_arrow_left_marker.visible = true + cloud_arrow_left_marker.global_position = global_position + cloud_arrow_left_marker.rotation = (base_direction.rotated(-spread)).angle() + cloud_arrow_left_marker.points = PackedVector2Array([Vector2.ZERO, Vector2(320.0, 0.0)]) + if cloud_arrow_right_marker != null: + cloud_arrow_right_marker.visible = true + cloud_arrow_right_marker.global_position = global_position + cloud_arrow_right_marker.rotation = (base_direction.rotated(spread)).angle() + cloud_arrow_right_marker.points = PackedVector2Array([Vector2.ZERO, Vector2(320.0, 0.0)]) + +func _hide_cloud_arrow_markers() -> void: + aim_ring.visible = false + if cloud_arrow_left_marker != null: + cloud_arrow_left_marker.visible = false + if cloud_arrow_right_marker != null: + cloud_arrow_right_marker.visible = false + +func _show_ring_marker(center: Vector2, radius: float, color: Color) -> void: + assassination_mark.visible = true + assassination_mark.closed = true + assassination_mark.global_position = center + assassination_mark.rotation = 0.0 + assassination_mark.width = 4.0 + assassination_mark.default_color = color + assassination_mark.points = _build_ring_points(radius, 24) + +func _show_pentagram_marker(center: Vector2) -> void: + if assassination_path_points.is_empty(): + return + var points := PackedVector2Array() + for point in assassination_path_points: + points.append(point - center) + assassination_mark.visible = true + assassination_mark.closed = false + assassination_mark.global_position = center + assassination_mark.rotation = 0.0 + assassination_mark.width = 4.0 + assassination_mark.default_color = Color(1.0, 0.5, 0.42, 0.9) + assassination_mark.points = points + +func _hide_skill_markers() -> void: + _hide_cloud_arrow_markers() + assassination_mark.visible = false + +func _build_pentagram_path(center: Vector2, radius: float) -> void: + assassination_path_points.clear() + var outer_points: Array[Vector2] = [] + for index in range(5): + var angle := -PI * 0.5 + TAU * float(index) / 5.0 + outer_points.append(center + Vector2.RIGHT.rotated(angle) * radius) + for point_index in [0, 2, 4, 1, 3, 0]: + assassination_path_points.append(outer_points[int(point_index)]) + +func _dash_duration_between(start_position: Vector2, end_position: Vector2) -> float: + return maxf(start_position.distance_to(end_position) / maxf(assassination_dash_speed, 1.0), 0.08) + +func _distance_to_segment(point: Vector2, start_position: Vector2, end_position: Vector2) -> float: + var segment := end_position - start_position + var segment_length_squared := segment.length_squared() + if segment_length_squared <= 0.0001: + return point.distance_to(start_position) + var weight := clampf((point - start_position).dot(segment) / segment_length_squared, 0.0, 1.0) + var closest_point := start_position + segment * weight + return point.distance_to(closest_point) func _spawn_slash_effect(radius: float, color: Color) -> void: if melee_texture != null: @@ -448,15 +774,68 @@ func _spawn_slash_effect(radius: float, color: Color) -> void: tween.parallel().tween_property(slash, "modulate:a", 0.0, 0.12) tween.finished.connect(slash.queue_free) +func _spawn_radius_burst(center: Vector2, radius: float, color: Color) -> void: + var scene_root := get_tree().current_scene + if scene_root == null: + return + var ring := Line2D.new() + ring.width = 6.0 + ring.closed = true + ring.default_color = color + ring.points = _build_ring_points(radius, 24) + ring.global_position = center + scene_root.add_child(ring) + var tween := ring.create_tween() + tween.tween_property(ring, "scale", Vector2.ONE * 1.18, 0.18) + tween.parallel().tween_property(ring, "modulate:a", 0.0, 0.18) + tween.finished.connect(ring.queue_free) + +func _spawn_dash_impact(start_position: Vector2, end_position: Vector2, width: float, color: Color) -> void: + var scene_root := get_tree().current_scene + if scene_root == null: + return + var direction := end_position - start_position + var length := direction.length() + if length <= 0.001: + return + var slash := Polygon2D.new() + slash.color = color + slash.global_position = start_position + slash.rotation = direction.angle() + slash.polygon = PackedVector2Array([ + Vector2(0.0, -width * 0.5), + Vector2(length, -width * 0.5), + Vector2(length, width * 0.5), + Vector2(0.0, width * 0.5) + ]) + scene_root.add_child(slash) + var tween := slash.create_tween() + tween.tween_property(slash, "modulate:a", 0.0, 0.16) + tween.parallel().tween_property(slash, "scale", Vector2(1.08, 1.04), 0.16) + tween.finished.connect(slash.queue_free) + +func _build_ring_points(radius: float, steps: int = 16) -> PackedVector2Array: + var points := PackedVector2Array() + for index in range(steps): + var angle := TAU * float(index) / float(steps) + points.append(Vector2.RIGHT.rotated(angle) * radius) + return points + func _spawn_afterimage() -> void: + _spawn_afterimage_at(global_position) + +func _spawn_afterimage_at(world_position: Vector2) -> void: + var scene_root := get_tree().current_scene + if scene_root == null: + return var ghost := Polygon2D.new() ghost.polygon = body.polygon ghost.color = Color(0.64, 1.0, 0.86, 0.18) - ghost.global_position = global_position + ghost.global_position = world_position ghost.rotation = body.rotation ghost.scale = body.scale - get_tree().current_scene.add_child(ghost) - var tween := create_tween() + scene_root.add_child(ghost) + var tween := ghost.create_tween() tween.tween_property(ghost, "modulate:a", 0.0, 0.18) tween.parallel().tween_property(ghost, "scale", body.scale * 0.92, 0.18) tween.finished.connect(ghost.queue_free) @@ -491,33 +870,42 @@ func _animate_weapon_swing(start_degrees: float, end_degrees: float, duration: f tween.tween_property(self, "weapon_angle_offset", deg_to_rad(end_degrees), maxf(duration, 0.01)) func _update_visuals() -> void: - if target != null and is_instance_valid(target): + if _should_face_target() and target != null and is_instance_valid(target): var to_target := target.global_position - global_position if to_target != Vector2.ZERO: line_direction = to_target.normalized() var body_tint := Color.WHITE if silenced_time_remaining > 0.0: body_tint = Color(0.9, 0.82, 1.0, 1.0) - elif shadow_step_buff_time_remaining > 0.0: - body_tint = Color(1.0, 0.94, 0.84, 1.0) + elif shadow_damage_reduction_time_remaining > 0.0: + body_tint = Color(0.76, 0.94, 1.0, 1.0) _set_body_tint(body_tint) body.modulate = Color(1.0, 1.0, 1.0, 0.36) if invulnerable else Color.WHITE - aim_ring.visible = state == &"skill1_cast" if aim_ring.visible: - aim_ring.rotation = line_direction.angle() - aim_ring.scale = Vector2.ONE * (0.82 + 0.18 * minf(state_time / maxf(skill1_cast_duration, 0.01), 1.0)) - aim_ring.modulate = Color(0.72, 1.0, 0.86, 0.78) - assassination_mark.visible = active_skill_target != null and is_instance_valid(active_skill_target) and state != &"dead" + var aim_pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.018) + aim_ring.width = 4.0 + aim_pulse * 1.4 + aim_ring.modulate = Color(1.0, 1.0, 1.0, 0.78 + 0.12 * aim_pulse) if assassination_mark.visible: - assassination_mark.global_position = active_skill_target.global_position - assassination_mark.rotation += 0.16 - assassination_mark.modulate = Color(1.0, 0.48, 0.42, 0.9) + var mark_pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.014) + assassination_mark.width = 3.6 + mark_pulse * 1.2 + assassination_mark.modulate = Color(1.0, 1.0, 1.0, 0.76 + 0.14 * mark_pulse) weapon.position = line_direction * 18.0 + Vector2(0.0, -2.0) weapon.rotation = _weapon_guard_rotation(line_direction, -38.0) + weapon_angle_offset projectile_spawner.position = line_direction * 28.0 _apply_agile_body_motion() visual_last_position = global_position +func _should_face_target() -> bool: + return state in [ + &"idle", + &"basic_attack", + &"skill1_charge", + &"skill1_fire", + &"skill2_shadow", + &"skill2_shockwave_charge", + &"recover" + ] + func _weapon_guard_rotation(direction: Vector2, guard_degrees: float) -> float: var facing := direction.normalized() if direction.length_squared() > 0.0001 else Vector2.RIGHT var side_sign := -1.0 if facing.x < -0.05 else 1.0 @@ -558,8 +946,9 @@ func _on_died() -> void: hp = 0.0 invulnerable = true state = &"dead" - aim_ring.visible = false - assassination_mark.visible = false + shadow_damage_reduction_time_remaining = 0.0 + health_component.set_damage_reduction(0.0) + _hide_skill_markers() weapon.visible = false Sfx.play_event(&"boss_generic_dead", global_position) var timer := get_tree().create_timer(0.5) diff --git a/actors/bosses/town/royal_guard_formation.gd b/actors/bosses/town/royal_guard_formation.gd deleted file mode 100644 index 014dd27..0000000 --- a/actors/bosses/town/royal_guard_formation.gd +++ /dev/null @@ -1,157 +0,0 @@ -extends Node2D - -signal defeated - -const GUARD_SCENE := preload("res://actors/bosses/town/guard_unit.tscn") - -@onready var line_layer: Node2D = $LineLayer -@onready var unit_layer: Node2D = $UnitLayer - -var target: Node2D = null -var fixed_guards: Array[Node] = [] -var mobile_guards: Array[Node] = [] -var all_guards: Array[Node] = [] -var link_lines: Array[Line2D] = [] -var left_lane_line: Line2D -var right_lane_line: Line2D -var coverage_progress: float = 0.0 -var encounter_finished: bool = false -var immune_phase_active: bool = true - -func _ready() -> void: - _build_lines() - _spawn_guards() - -func bind_player(player: Node2D) -> void: - target = player - for guard in all_guards: - if is_instance_valid(guard): - guard.bind_player(player) - -func get_status_title() -> String: - return _locale_text("Royal Guard Formation", "王家近卫阵列", "王家近衛陣列") - -func get_status_text() -> String: - if encounter_finished: - return _locale_text("Formation collapsed.", "阵列已崩解。", "陣列已崩解。") - var immune_text := _locale_text("Immune", "免疫", "免疫") if coverage_progress < 1.0 else _locale_text("Vulnerable", "可破", "可破") - return _locale_text("%s\nCoverage %d%% | Guards remaining %d", "%s\n覆盖进度 %d%% | 近卫剩余 %d", "%s\n覆蓋進度 %d%% | 近衛剩餘 %d") % [ - immune_text, - int(round(coverage_progress * 100.0)), - all_guards.size() - ] - -func _current_locale() -> String: - var ui_settings := get_node_or_null("/root/UISettings") - if ui_settings != null and ui_settings.has_method("get_locale"): - return String(ui_settings.get_locale()) - return "en" - -func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) -> String: - match _current_locale(): - "zh_Hant": - return zh_hant_text - "zh_Hans": - return zh_hans_text - _: - return en_text - -func _physics_process(delta: float) -> void: - if encounter_finished: - return - coverage_progress = minf(coverage_progress + delta / 12.0, 1.0) - var lane_extent := lerpf(50.0, 220.0, coverage_progress) - for guard in mobile_guards: - if is_instance_valid(guard): - guard.set_lane_extent(lane_extent) - var formation_immune := coverage_progress < 1.0 - if immune_phase_active and not formation_immune: - immune_phase_active = false - Sfx.play_event(&"boss_guard_immune_break", global_position) - for guard in all_guards: - if is_instance_valid(guard): - guard.set_immune(formation_immune) - _update_line_visuals(lane_extent, formation_immune) - if all_guards.is_empty() and not encounter_finished: - encounter_finished = true - defeated.emit() - queue_free() - -func _spawn_guards() -> void: - var positions := [ - Vector2(-220.0, -150.0), - Vector2(220.0, -150.0), - Vector2(-220.0, 150.0), - Vector2(220.0, 150.0), - Vector2(0.0, 0.0) - ] - for guard_position in positions: - var guard := GUARD_SCENE.instantiate() - unit_layer.add_child(guard) - guard.position = guard_position - guard.bind_player(target) - guard.defeated.connect(_on_guard_defeated.bind(guard)) - fixed_guards.append(guard) - all_guards.append(guard) - var left_mobile := GUARD_SCENE.instantiate() - unit_layer.add_child(left_mobile) - left_mobile.setup_lane(true, Vector2(-90.0, 0.0), Vector2.UP) - left_mobile.position = Vector2(-90.0, 0.0) - left_mobile.bind_player(target) - left_mobile.defeated.connect(_on_guard_defeated.bind(left_mobile)) - mobile_guards.append(left_mobile) - all_guards.append(left_mobile) - var right_mobile := GUARD_SCENE.instantiate() - unit_layer.add_child(right_mobile) - right_mobile.setup_lane(true, Vector2(90.0, 0.0), Vector2.UP) - right_mobile.position = Vector2(90.0, 0.0) - right_mobile.bind_player(target) - right_mobile.defeated.connect(_on_guard_defeated.bind(right_mobile)) - mobile_guards.append(right_mobile) - all_guards.append(right_mobile) - -func _build_lines() -> void: - var square_points := [ - Vector2(-220.0, -150.0), - Vector2(220.0, -150.0), - Vector2(220.0, 150.0), - Vector2(-220.0, 150.0) - ] - for index in range(square_points.size()): - var next_index := (index + 1) % square_points.size() - link_lines.append(_add_line([square_points[index], square_points[next_index]], 2.0)) - for point in square_points: - link_lines.append(_add_line([Vector2.ZERO, point], 2.0)) - left_lane_line = _add_line([Vector2(-90.0, -50.0), Vector2(-90.0, 50.0)], 3.0) - right_lane_line = _add_line([Vector2(90.0, -50.0), Vector2(90.0, 50.0)], 3.0) - -func _add_line(points: Array, width: float) -> Line2D: - var line := Line2D.new() - line.width = width - line.default_color = Color(0.7, 0.88, 1.0, 0.52) - var packed := PackedVector2Array() - for point in points: - packed.append(point) - line.points = packed - line_layer.add_child(line) - return line - -func _update_line_visuals(lane_extent: float, immune: bool) -> void: - var alpha := 0.45 + 0.18 * sin(Time.get_ticks_msec() * 0.008) - for line in link_lines: - line.default_color = Color(0.7, 0.88, 1.0, alpha) if immune else Color(1.0, 0.82, 0.52, 0.55) - left_lane_line.points = PackedVector2Array([ - Vector2(-90.0, -lane_extent), - Vector2(-90.0, lane_extent) - ]) - right_lane_line.points = PackedVector2Array([ - Vector2(90.0, -lane_extent), - Vector2(90.0, lane_extent) - ]) - left_lane_line.default_color = Color(0.74, 0.9, 1.0, 0.82) - right_lane_line.default_color = Color(0.74, 0.9, 1.0, 0.82) - -func _on_guard_defeated(guard: Node) -> void: - all_guards.erase(guard) - fixed_guards.erase(guard) - mobile_guards.erase(guard) diff --git a/actors/bosses/town/royal_guard_formation.gd.uid b/actors/bosses/town/royal_guard_formation.gd.uid deleted file mode 100644 index 96cbe9d..0000000 --- a/actors/bosses/town/royal_guard_formation.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://o23wywy7m73v diff --git a/actors/bosses/town/royal_guard_formation.tscn b/actors/bosses/town/royal_guard_formation.tscn deleted file mode 100644 index 4a26f29..0000000 --- a/actors/bosses/town/royal_guard_formation.tscn +++ /dev/null @@ -1,11 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://actors/bosses/town/royal_guard_formation.gd" id="1"] - -[node name="RoyalGuardFormation" type="Node2D"] -script = ExtResource("1") - -[node name="LineLayer" type="Node2D" parent="."] - -[node name="UnitLayer" type="Node2D" parent="."] - diff --git a/actors/bosses/town/twin_princes_boss.gd b/actors/bosses/town/twin_princes_boss.gd index 0567124..c8a1e14 100644 --- a/actors/bosses/town/twin_princes_boss.gd +++ b/actors/bosses/town/twin_princes_boss.gd @@ -1,4 +1,4 @@ -extends Node2D +extends Node2D signal defeated @@ -23,7 +23,7 @@ const BARRAGE_WAVE_OFFSETS := [ @export var defense_value: float = 220.0 @export var move_speed: float = 235.0 @export var basic_attack_damage: float = 50.0 -@export var basic_attack_interval: float = 2.0 +@export var basic_attack_interval: float = 2.8 @export var basic_attack_recover: float = 1.0 @export var basic_attack_range: float = 90.0 @export var teleport_interval: float = 5.0 @@ -238,15 +238,15 @@ func _start_basic_attack() -> void: state_time = 0.0 action_committed = false attack_cooldown = basic_attack_interval - _show_intent_text("Slash", Color(1.0, 0.84, 0.62, 1.0), global_position, 0.8) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), global_position, 0.92) func _process_basic_attack() -> void: - if not action_committed and state_time >= 0.24: + if not action_committed and state_time >= 1.0: action_committed = true _hit_target_in_arc(basic_attack_range, 110.0, basic_attack_damage) _spawn_melee_slash_effect(basic_attack_range, Color(1.0, 0.84, 0.62, 0.92)) normal_attack_counter += 1 - if state_time >= basic_attack_recover: + if state_time >= 1.0 + basic_attack_recover: _enter_recover(0.18) func _start_teleport_attack() -> void: @@ -597,9 +597,11 @@ func _spawn_damage_number(amount: float, is_critical: bool) -> void: func _show_intent_text(label_text: String, color_value: Color, world_position: Vector2, scale_value: float = 0.84) -> void: var popup := DAMAGE_NUMBER_SCENE.instantiate() - popup.position = to_local(world_position) + Vector2(-40.0, -56.0) + popup.position = to_local(world_position) + Vector2(-40.0, -100.0) if popup.has_method("setup_text"): popup.setup_text(label_text, color_value, scale_value) + if popup is CanvasItem: + (popup as CanvasItem).z_index = 60 effects_layer.add_child(popup) func _spawn_melee_slash_effect(radius: float, color: Color) -> void: @@ -709,3 +711,5 @@ func _on_died() -> void: defeated.emit() queue_free() ) + + diff --git a/actors/enemy/swordsman_enemy.gd b/actors/enemy/swordsman_enemy.gd index bdda873..c812b68 100644 --- a/actors/enemy/swordsman_enemy.gd +++ b/actors/enemy/swordsman_enemy.gd @@ -1,4 +1,4 @@ -extends CharacterBody2D +extends CharacterBody2D signal defeated @@ -7,7 +7,7 @@ signal defeated @export var move_speed: float = 110.0 @export var attack_damage: float = 22.0 @export var attack_range: float = 62.0 -@export var attack_interval: float = 1.3 +@export var attack_interval: float = 1.9 @export var detection_range: float = 320.0 @onready var body: Polygon2D = $Body @@ -15,12 +15,16 @@ signal defeated @onready var effects_layer: Node2D = $EffectsLayer const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") +const BASIC_ATTACK_WARNING_DELAY := 1.0 +const BASIC_ATTACK_WARNING_VISIBLE_TIME := 0.8 var target: Node2D = null var attack_cooldown: float = 0.0 var base_position: Vector2 = Vector2.ZERO var knock_up_remaining: float = 0.0 var knock_up_total: float = 0.0 +var attack_windup_remaining: float = 0.0 +var attack_warning_spawned: bool = false func _ready() -> void: base_position = position @@ -44,10 +48,20 @@ func _physics_process(delta: float) -> void: var distance := global_position.distance_to(target.global_position) if distance <= attack_range: velocity = Vector2.ZERO - if attack_cooldown <= 0.0: + if attack_windup_remaining > 0.0: + if not attack_warning_spawned: + _show_attack_warning() + attack_warning_spawned = true + attack_windup_remaining = maxf(attack_windup_remaining - delta, 0.0) + if attack_windup_remaining <= 0.0: + attack_target() + elif attack_cooldown <= 0.0: attack_cooldown = attack_interval - attack_target() + attack_windup_remaining = BASIC_ATTACK_WARNING_DELAY + attack_warning_spawned = false else: + attack_windup_remaining = 0.0 + attack_warning_spawned = false if distance <= detection_range: var direction := (target.global_position - global_position).normalized() velocity = direction * move_speed @@ -117,3 +131,11 @@ func _spawn_damage_number(amount: float, is_critical: bool) -> void: damage_number.position = Vector2(0.0, -34.0) damage_number.setup(amount, is_critical) effects_layer.add_child(damage_number) + +func _show_attack_warning() -> void: + var popup := DAMAGE_NUMBER_SCENE.instantiate() + popup.position = Vector2(-8.0, -52.0) + if popup.has_method("setup_text"): + popup.setup_text("!", Color(1.0, 0.95, 0.56, 1.0), 0.92) + popup.lifetime = BASIC_ATTACK_WARNING_VISIBLE_TIME + effects_layer.add_child(popup) diff --git a/actors/enemy/town_enemy.gd b/actors/enemy/town_enemy.gd index 6f210d7..1f32764 100644 --- a/actors/enemy/town_enemy.gd +++ b/actors/enemy/town_enemy.gd @@ -1,4 +1,4 @@ -extends CharacterBody2D +extends CharacterBody2D signal defeated @@ -250,11 +250,11 @@ func _update_swordsman(delta: float) -> void: var distance := _refresh_target_direction() if state == &"attack_slash": velocity = Vector2.ZERO - if not action_committed and state_time >= 0.45: + if not action_committed and state_time >= 1.0: action_committed = true _spawn_melee_attack_effect(attack_range + 20.0, Color(1.0, 0.78, 0.5, 0.95), 24.0, 12.0) _hit_target_radius(attack_range, attack_damage) - if state_time >= 0.9: + if state_time >= 1.45: _enter_recover(0.3) return if state == &"recover": @@ -277,11 +277,11 @@ func _update_shield(delta: float) -> void: var distance := _refresh_target_direction() if state == &"shield_bash": velocity = Vector2.ZERO - if not action_committed and state_time >= 0.55: + if not action_committed and state_time >= 1.0: action_committed = true _spawn_melee_attack_effect(attack_range + 26.0, Color(0.92, 0.84, 0.64, 0.95), 30.0, 14.0, true) _hit_target_radius(attack_range + 8.0, attack_damage) - if state_time >= 0.95: + if state_time >= 1.4: _enter_recover(0.55) return if state == &"guard_hold": @@ -298,7 +298,7 @@ func _update_shield(delta: float) -> void: state_time = 0.0 action_committed = false attack_cooldown = attack_interval - _show_intent_text("Shield Bash", Color(0.98, 0.86, 0.66, 1.0), 0.86) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), 0.92) Sfx.play_event(&"enemy_shield_bash", global_position) return if skill_cooldown <= 0.0 and distance <= attack_range + 40.0: @@ -319,10 +319,10 @@ func _update_archer(delta: float) -> void: line_direction = to_target.normalized() if state == &"draw_bow": velocity = Vector2.ZERO - if not action_committed and state_time >= 0.55: + if not action_committed and state_time >= 1.0: action_committed = true _fire_projectile(attack_damage, Color(0.8, 0.92, 0.66, 1.0), 540.0) - if state_time >= 0.82: + if state_time >= 1.35: _enter_recover(0.28) return if state == &"recover": @@ -333,7 +333,7 @@ func _update_archer(delta: float) -> void: state_time = 0.0 action_committed = false attack_cooldown = attack_interval - _show_intent_text("Arrow Shot", Color(0.82, 0.96, 0.72, 1.0), 0.8) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), 0.92) return state = &"reposition" _set_ranged_spacing_velocity(distance, 150.0, attack_range + 120.0, 0.7, 0.7) @@ -375,7 +375,7 @@ func _update_hunter(delta: float) -> void: state = &"dash_in" state_time = 0.0 skill_cooldown = 3.0 - _show_intent_text("Pounce", Color(1.0, 0.74, 0.70, 1.0), 0.84) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), 0.92) Sfx.play_event(&"enemy_hunter_dash", global_position) return state = &"stalk" @@ -390,10 +390,10 @@ func _update_apprentice(delta: float) -> void: line_direction = to_target.normalized() if state == &"cast_bolt": velocity = Vector2.ZERO - if not action_committed and state_time >= 0.55: + if not action_committed and state_time >= 1.0: action_committed = true _fire_projectile(attack_damage, Color(0.72, 0.84, 1.0, 1.0), 420.0) - if state_time >= 0.9: + if state_time >= 1.45: _enter_recover(0.45) return if state == &"recover": @@ -404,7 +404,7 @@ func _update_apprentice(delta: float) -> void: state_time = 0.0 action_committed = false attack_cooldown = attack_interval - _show_intent_text("Frost Bolt", Color(0.78, 0.88, 1.0, 1.0), 0.8) + _show_intent_text("!", Color(1.0, 0.95, 0.56, 1.0), 0.92) return state = &"move" _set_ranged_spacing_velocity(distance, 180.0, 280.0, 0.62, 0.54) @@ -418,11 +418,11 @@ func _update_arcanist(delta: float) -> void: line_direction = to_target.normalized() if state == &"basic_cast": velocity = Vector2.ZERO - if not action_committed and state_time >= 0.48: + if not action_committed and state_time >= 1.0: action_committed = true _fire_projectile(attack_damage, Color(1.0, 0.78, 0.48, 1.0), 500.0) basic_attack_counter += 1 - if state_time >= 0.78: + if state_time >= 1.3: _enter_recover(0.22) return if state == &"skill_mark": @@ -632,9 +632,12 @@ func _show_intent_text(label_text: String, color_value: Color, scale_value: floa if effects_layer == null: return var popup := DAMAGE_NUMBER_SCENE.instantiate() - popup.position = Vector2(-38.0, -56.0) + popup.position = Vector2(-38.0, -96.0) if popup.has_method("setup_text"): popup.setup_text(label_text, color_value, scale_value) + if popup is CanvasItem: + (popup as CanvasItem).z_index = 60 + popup.lifetime = 0.8 effects_layer.add_child(popup) func _hit_target_radius(radius: float, damage: float) -> void: @@ -971,3 +974,4 @@ func _spawn_death_burst() -> void: tween.parallel().tween_property(shard, "rotation", direction.angle(), 0.18) tween.parallel().tween_property(shard, "modulate:a", 0.0, 0.18) tween.finished.connect(shard.queue_free) + diff --git a/assets/effects/projectiles/mage_boss_laser_body.webp b/assets/effects/projectiles/mage_boss_laser_body.webp new file mode 100644 index 0000000..c88ced3 Binary files /dev/null and b/assets/effects/projectiles/mage_boss_laser_body.webp differ diff --git a/assets/effects/projectiles/mage_boss_laser_body.webp.import b/assets/effects/projectiles/mage_boss_laser_body.webp.import new file mode 100644 index 0000000..65d3c0c --- /dev/null +++ b/assets/effects/projectiles/mage_boss_laser_body.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5oyc434hhqpx" +path="res://.godot/imported/mage_boss_laser_body.webp-65e9140198424b6f33103b67449b59b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/mage_boss_laser_body.webp" +dest_files=["res://.godot/imported/mage_boss_laser_body.webp-65e9140198424b6f33103b67449b59b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/effects/projectiles/mage_boss_laser_core.png b/assets/effects/projectiles/mage_boss_laser_core.png new file mode 100644 index 0000000..d41894d Binary files /dev/null and b/assets/effects/projectiles/mage_boss_laser_core.png differ diff --git a/assets/effects/projectiles/mage_boss_laser_core.png.import b/assets/effects/projectiles/mage_boss_laser_core.png.import new file mode 100644 index 0000000..c6d0308 --- /dev/null +++ b/assets/effects/projectiles/mage_boss_laser_core.png.import @@ -0,0 +1,36 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://fch1qiawfu2i" +valid=false + +[deps] + +source_file="res://assets/effects/projectiles/mage_boss_laser_core.png" + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/audio/sfx/player_knight_attack.wav b/audio/sfx/player_knight_attack.wav new file mode 100644 index 0000000..0f7ff74 Binary files /dev/null and b/audio/sfx/player_knight_attack.wav differ diff --git a/audio/sfx/player_knight_attack.wav.import b/audio/sfx/player_knight_attack.wav.import new file mode 100644 index 0000000..b300ef6 --- /dev/null +++ b/audio/sfx/player_knight_attack.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b688nj6ofkr3h" +path="res://.godot/imported/player_knight_attack.wav-253c98aa036bb968540b472dcde8a4e8.sample" + +[deps] + +source_file="res://audio/sfx/player_knight_attack.wav" +dest_files=["res://.godot/imported/player_knight_attack.wav-253c98aa036bb968540b472dcde8a4e8.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_knight_skill1_dash.wav b/audio/sfx/player_knight_skill1_dash.wav new file mode 100644 index 0000000..208e38f Binary files /dev/null and b/audio/sfx/player_knight_skill1_dash.wav differ diff --git a/audio/sfx/player_knight_skill1_dash.wav.import b/audio/sfx/player_knight_skill1_dash.wav.import new file mode 100644 index 0000000..2cb4df9 --- /dev/null +++ b/audio/sfx/player_knight_skill1_dash.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://2omoaaq75mav" +path="res://.godot/imported/player_knight_skill1_dash.wav-ad3797c90c3da492007f2216984608e1.sample" + +[deps] + +source_file="res://audio/sfx/player_knight_skill1_dash.wav" +dest_files=["res://.godot/imported/player_knight_skill1_dash.wav-ad3797c90c3da492007f2216984608e1.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_knight_skill2_shockwave.wav b/audio/sfx/player_knight_skill2_shockwave.wav new file mode 100644 index 0000000..843d8f5 Binary files /dev/null and b/audio/sfx/player_knight_skill2_shockwave.wav differ diff --git a/audio/sfx/player_knight_skill2_shockwave.wav.import b/audio/sfx/player_knight_skill2_shockwave.wav.import new file mode 100644 index 0000000..53e41be --- /dev/null +++ b/audio/sfx/player_knight_skill2_shockwave.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dcvc24oyhrtmg" +path="res://.godot/imported/player_knight_skill2_shockwave.wav-bb92954a241317a38f09b38088b62505.sample" + +[deps] + +source_file="res://audio/sfx/player_knight_skill2_shockwave.wav" +dest_files=["res://.godot/imported/player_knight_skill2_shockwave.wav-bb92954a241317a38f09b38088b62505.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_knight_skill3_sanctuary.wav b/audio/sfx/player_knight_skill3_sanctuary.wav new file mode 100644 index 0000000..2d36c05 Binary files /dev/null and b/audio/sfx/player_knight_skill3_sanctuary.wav differ diff --git a/audio/sfx/player_knight_skill3_sanctuary.wav.import b/audio/sfx/player_knight_skill3_sanctuary.wav.import new file mode 100644 index 0000000..b013f61 --- /dev/null +++ b/audio/sfx/player_knight_skill3_sanctuary.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c370cw6ogpd4p" +path="res://.godot/imported/player_knight_skill3_sanctuary.wav-40ba5e034f26be1508b40771aecc4086.sample" + +[deps] + +source_file="res://audio/sfx/player_knight_skill3_sanctuary.wav" +dest_files=["res://.godot/imported/player_knight_skill3_sanctuary.wav-40ba5e034f26be1508b40771aecc4086.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_mage_attack.wav b/audio/sfx/player_mage_attack.wav new file mode 100644 index 0000000..ee9244b Binary files /dev/null and b/audio/sfx/player_mage_attack.wav differ diff --git a/audio/sfx/player_mage_attack.wav.import b/audio/sfx/player_mage_attack.wav.import new file mode 100644 index 0000000..0a8aaee --- /dev/null +++ b/audio/sfx/player_mage_attack.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://btit1w6q5w7rv" +path="res://.godot/imported/player_mage_attack.wav-84018b59faeb92fbdfb00d1c041d1572.sample" + +[deps] + +source_file="res://audio/sfx/player_mage_attack.wav" +dest_files=["res://.godot/imported/player_mage_attack.wav-84018b59faeb92fbdfb00d1c041d1572.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_mage_skill1_blades.wav b/audio/sfx/player_mage_skill1_blades.wav new file mode 100644 index 0000000..786da61 Binary files /dev/null and b/audio/sfx/player_mage_skill1_blades.wav differ diff --git a/audio/sfx/player_mage_skill1_blades.wav.import b/audio/sfx/player_mage_skill1_blades.wav.import new file mode 100644 index 0000000..162f00f --- /dev/null +++ b/audio/sfx/player_mage_skill1_blades.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bkcvvia87li1b" +path="res://.godot/imported/player_mage_skill1_blades.wav-45d1e9bb278cf3159f6ef9024ff70934.sample" + +[deps] + +source_file="res://audio/sfx/player_mage_skill1_blades.wav" +dest_files=["res://.godot/imported/player_mage_skill1_blades.wav-45d1e9bb278cf3159f6ef9024ff70934.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_mage_skill2_burst.wav b/audio/sfx/player_mage_skill2_burst.wav new file mode 100644 index 0000000..1fec695 Binary files /dev/null and b/audio/sfx/player_mage_skill2_burst.wav differ diff --git a/audio/sfx/player_mage_skill2_burst.wav.import b/audio/sfx/player_mage_skill2_burst.wav.import new file mode 100644 index 0000000..9931cae --- /dev/null +++ b/audio/sfx/player_mage_skill2_burst.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://brj782n0jhvoy" +path="res://.godot/imported/player_mage_skill2_burst.wav-cb695cd8ce01d5856825e6ab6fe2459e.sample" + +[deps] + +source_file="res://audio/sfx/player_mage_skill2_burst.wav" +dest_files=["res://.godot/imported/player_mage_skill2_burst.wav-cb695cd8ce01d5856825e6ab6fe2459e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_mage_skill3_thunder.wav b/audio/sfx/player_mage_skill3_thunder.wav new file mode 100644 index 0000000..2bf453e Binary files /dev/null and b/audio/sfx/player_mage_skill3_thunder.wav differ diff --git a/audio/sfx/player_mage_skill3_thunder.wav.import b/audio/sfx/player_mage_skill3_thunder.wav.import new file mode 100644 index 0000000..bf28410 --- /dev/null +++ b/audio/sfx/player_mage_skill3_thunder.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cny0xjfk6n61a" +path="res://.godot/imported/player_mage_skill3_thunder.wav-e8da62fc8736bcd7770a22cd9db1f7ea.sample" + +[deps] + +source_file="res://audio/sfx/player_mage_skill3_thunder.wav" +dest_files=["res://.godot/imported/player_mage_skill3_thunder.wav-e8da62fc8736bcd7770a22cd9db1f7ea.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_ranger_attack.wav b/audio/sfx/player_ranger_attack.wav new file mode 100644 index 0000000..eb71f9f Binary files /dev/null and b/audio/sfx/player_ranger_attack.wav differ diff --git a/audio/sfx/player_ranger_attack.wav.import b/audio/sfx/player_ranger_attack.wav.import new file mode 100644 index 0000000..c62da0d --- /dev/null +++ b/audio/sfx/player_ranger_attack.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bssn8wr2s0dct" +path="res://.godot/imported/player_ranger_attack.wav-f956a6a71298d7390d6d39929055b278.sample" + +[deps] + +source_file="res://audio/sfx/player_ranger_attack.wav" +dest_files=["res://.godot/imported/player_ranger_attack.wav-f956a6a71298d7390d6d39929055b278.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_ranger_skill1_arrow.wav b/audio/sfx/player_ranger_skill1_arrow.wav new file mode 100644 index 0000000..ae5ff09 Binary files /dev/null and b/audio/sfx/player_ranger_skill1_arrow.wav differ diff --git a/audio/sfx/player_ranger_skill1_arrow.wav.import b/audio/sfx/player_ranger_skill1_arrow.wav.import new file mode 100644 index 0000000..ed30390 --- /dev/null +++ b/audio/sfx/player_ranger_skill1_arrow.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://itfvbtgdlm30" +path="res://.godot/imported/player_ranger_skill1_arrow.wav-f88e7ade861e47fe716758136b0e1447.sample" + +[deps] + +source_file="res://audio/sfx/player_ranger_skill1_arrow.wav" +dest_files=["res://.godot/imported/player_ranger_skill1_arrow.wav-f88e7ade861e47fe716758136b0e1447.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_ranger_skill2_shadow.wav b/audio/sfx/player_ranger_skill2_shadow.wav new file mode 100644 index 0000000..641f88f Binary files /dev/null and b/audio/sfx/player_ranger_skill2_shadow.wav differ diff --git a/audio/sfx/player_ranger_skill2_shadow.wav.import b/audio/sfx/player_ranger_skill2_shadow.wav.import new file mode 100644 index 0000000..edb11f9 --- /dev/null +++ b/audio/sfx/player_ranger_skill2_shadow.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://koaeeeqqu344" +path="res://.godot/imported/player_ranger_skill2_shadow.wav-42d971efc62c07f2bfcd4062c8422589.sample" + +[deps] + +source_file="res://audio/sfx/player_ranger_skill2_shadow.wav" +dest_files=["res://.godot/imported/player_ranger_skill2_shadow.wav-42d971efc62c07f2bfcd4062c8422589.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/player_ranger_skill3_assassinate.wav b/audio/sfx/player_ranger_skill3_assassinate.wav new file mode 100644 index 0000000..96d25ae Binary files /dev/null and b/audio/sfx/player_ranger_skill3_assassinate.wav differ diff --git a/audio/sfx/player_ranger_skill3_assassinate.wav.import b/audio/sfx/player_ranger_skill3_assassinate.wav.import new file mode 100644 index 0000000..7780d76 --- /dev/null +++ b/audio/sfx/player_ranger_skill3_assassinate.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c4sexr66p2lw5" +path="res://.godot/imported/player_ranger_skill3_assassinate.wav-35176dcc297b39bc23a2d1178e3438fd.sample" + +[deps] + +source_file="res://audio/sfx/player_ranger_skill3_assassinate.wav" +dest_files=["res://.godot/imported/player_ranger_skill3_assassinate.wav-35176dcc297b39bc23a2d1178e3438fd.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx_manager.gd b/audio/sfx_manager.gd index 63505b8..3248656 100644 --- a/audio/sfx_manager.gd +++ b/audio/sfx_manager.gd @@ -73,6 +73,78 @@ const EVENT_PROFILES := { "duck_music_db": -2.5, "duck_hold": 0.14 }, + "player_knight_attack": { + "volume_db": -2.0, + "pitch_min": 0.96, + "pitch_max": 1.03 + }, + "player_knight_skill1_dash": { + "volume_db": -1.0, + "pitch_min": 0.97, + "pitch_max": 1.02 + }, + "player_knight_skill2_shockwave": { + "volume_db": -3.5, + "pitch_min": 0.96, + "pitch_max": 1.02, + "duck_music_db": -4.5, + "duck_hold": 0.2 + }, + "player_knight_skill3_sanctuary": { + "volume_db": -4.5, + "pitch_min": 0.98, + "pitch_max": 1.02, + "duck_music_db": -3.0, + "duck_hold": 0.18 + }, + "player_ranger_attack": { + "volume_db": -3.0, + "pitch_min": 1.0, + "pitch_max": 1.06 + }, + "player_ranger_skill1_arrow": { + "volume_db": -2.0, + "pitch_min": 0.98, + "pitch_max": 1.04 + }, + "player_ranger_skill2_shadow": { + "volume_db": -5.0, + "pitch_min": 0.98, + "pitch_max": 1.03, + "duck_music_db": -2.4, + "duck_hold": 0.12 + }, + "player_ranger_skill3_assassinate": { + "volume_db": -1.5, + "pitch_min": 0.98, + "pitch_max": 1.03, + "duck_music_db": -3.0, + "duck_hold": 0.16 + }, + "player_mage_attack": { + "volume_db": -7.0, + "pitch_min": 0.99, + "pitch_max": 1.04 + }, + "player_mage_skill1_blades": { + "volume_db": -2.5, + "pitch_min": 0.98, + "pitch_max": 1.04 + }, + "player_mage_skill2_burst": { + "volume_db": -5.0, + "pitch_min": 0.96, + "pitch_max": 1.02, + "duck_music_db": -4.5, + "duck_hold": 0.18 + }, + "player_mage_skill3_thunder": { + "volume_db": -4.0, + "pitch_min": 0.96, + "pitch_max": 1.02, + "duck_music_db": -3.2, + "duck_hold": 0.16 + }, "boss_judicator_attack": { "duck_music_db": -3.2, "duck_hold": 0.16 diff --git a/characters/knight/knight.gd b/characters/knight/knight.gd index 458f87c..bb82b66 100644 --- a/characters/knight/knight.gd +++ b/characters/knight/knight.gd @@ -408,6 +408,7 @@ func start_attack() -> void: current_attack_targets.clear() current_attack_name = &"attack" attack_started.emit(current_attack_name) + Sfx.play_event(&"player_knight_attack", global_position) slash_arc.visible = true slash_arc.position = _attack_visual_offset() slash_arc.rotation = _attack_facing().angle() @@ -452,6 +453,7 @@ func start_dash_from_skill() -> void: if dash_direction == Vector2.ZERO: dash_direction = facing if facing != Vector2.ZERO else Vector2.RIGHT dash_direction = dash_direction.normalized() + Sfx.play_event(&"player_knight_skill1_dash", global_position) play_animation(&"dash") _animate_weapon_swing(-36.0, 16.0, 0.08, 0.12) @@ -555,6 +557,7 @@ func finish_dodge() -> void: func trigger_shockwave() -> void: var shockwave_targets: Array[Node] = [] attack_started.emit(&"skill2") + Sfx.play_event(&"player_knight_skill2_shockwave", global_position) _show_shockwave() var payload := {} if skill2_knock_up_upgrade: @@ -601,6 +604,7 @@ func apply_sanctuary() -> void: sanctuary_time_remaining = buff_duration sanctuary_heal_tick_elapsed = 0.0 attack_started.emit(&"skill3") + Sfx.play_event(&"player_knight_skill3_sanctuary", global_position) _show_sanctuary_activation() attack_finished.emit(&"skill3") diff --git a/characters/mage/mage.gd b/characters/mage/mage.gd index 73c5bd2..b634c92 100644 --- a/characters/mage/mage.gd +++ b/characters/mage/mage.gd @@ -1,4 +1,4 @@ -extends CharacterBody2D +extends CharacterBody2D signal attack_started(attack_name: StringName) signal attack_hit(attack_name: StringName, target: Node) @@ -21,59 +21,63 @@ const MAGE_DEATH_TEXTURE_PATH := "res://art/final_materials/deaths/player_mage_d const BODY_BASE_SCALE := Vector2(0.85, 0.85) @export_group("Core Stats") -@export var max_hp: float = 70.0 -@export var max_inspiration: float = 80.0 +@export var max_hp: float = 60.0 +@export var max_inspiration: float = 30.0 @export var defense: float = 60.0 @export var max_defense: float = 60.0 @export var move_speed: float = 232.0 -@export var attack_damage: float = 90.0 -@export var attack_interval: float = 0.98 +@export var attack_move_speed: float = 126.0 +@export var attack_damage: float = 40.0 +@export var attack_interval: float = 0.8 @export_range(0.0, 1.0, 0.01) var crit_rate: float = 0.3 -@export var inspiration_gain_on_attack_hit: float = 8.0 +@export var inspiration_gain_on_attack_hit: float = 6.0 @export_group("Normal Attack Timing") @export var attack_windup: float = 0.27 @export var attack_hit_frame: float = 0.08 @export var attack_recovery: float = 0.36 -@export var attack_targeting_range: float = 560.0 +@export var attack_targeting_range: float = 500.0 +@export var normal_attack_projectile_damage: float = 60.0 +@export var normal_attack_projectile_speed: float = 720.0 +@export var normal_attack_projectile_lifetime: float = 1.5 +@export var normal_attack_projectile_hit_radius: float = 20.0 @export_group("Skill 1: Arcane Blades") -@export var skill1_cost: float = 35.0 -@export var skill1_cooldown: float = 12.0 -@export var skill1_damage: float = 50.0 +@export var skill1_cost: float = 10.0 +@export var skill1_cooldown: float = 10.0 +@export var skill1_damage: float = 100.0 @export var skill1_cast_duration: float = 0.35 @export var blade_duration: float = 10.0 @export var blade_radius: float = 90.0 @export var blade_tick_interval: float = 1.0 -@export var skill1_shield_upgrade: bool = false -@export var skill1_shield_value: float = 50.0 +@export var skill1_defense_upgrade: bool = false +@export var skill1_defense_restore: float = 60.0 @export var skill1_attack_blades_upgrade: bool = false -@export var skill1_attack_blades_damage: float = 50.0 +@export var skill1_attack_blades_damage: float = 100.0 @export var skill1_attack_blades_hits: int = 3 @export_group("Skill 2: Arcane Burst") -@export var skill2_cost: float = 40.0 -@export var skill2_cooldown: float = 0.0 -@export var skill2_damage: float = 100.0 +@export var skill2_cost: float = 15.0 +@export var skill2_cooldown: float = 0.5 +@export var skill2_damage: float = 200.0 @export var skill2_cast_duration: float = 0.38 @export var burst_radius: float = 120.0 -@export var burst_targeting_range: float = 540.0 +@export var burst_targeting_range: float = 640.0 @export var skill2_range_upgrade: bool = false @export var skill2_extra_damage_upgrade: bool = false -@export var skill2_bonus_damage: float = 20.0 +@export var skill2_bonus_damage: float = 100.0 @export var skill2_chain_burst_upgrade: bool = false -@export var skill2_chain_damage: float = 60.0 +@export var skill2_chain_damage: float = 180.0 -@export_group("Skill 3: Silence Decree") -@export var skill3_cost: float = 25.0 +@export_group("Skill 3: Thunder Empowerment") +@export var skill3_cost: float = 20.0 @export var skill3_cooldown: float = 8.0 @export var skill3_cast_duration: float = 0.28 -@export var silence_duration: float = 3.0 -@export var skill3_slow_upgrade: bool = false -@export var slow_duration: float = 8.0 -@export var slow_multiplier: float = 0.5 -@export var skill3_root_upgrade: bool = false -@export var root_duration: float = 3.0 +@export var skill3_duration: float = 20.0 +@export var skill3_echo_radius: float = 90.0 +@export var skill3_echo_damage_ratio: float = 0.5 +@export var skill3_damage_upgrade_small: bool = false +@export var skill3_damage_upgrade_large: bool = false @export_group("Dodge") @export var dodge_cost: float = 5.0 @@ -118,7 +122,8 @@ var blades_active: bool = false var blade_time_remaining: float = 0.0 var blade_hit_cooldowns: Dictionary = {} var attack_blade_bonus_hits_remaining: int = 0 -var enchant_active: bool = false +var thunder_empowerment_active: bool = false +var thunder_empowerment_remaining: float = 0.0 var current_skill_target: Node2D = null var blade_nodes: Array[Polygon2D] = [] var dodge_direction: Vector2 = Vector2.RIGHT @@ -174,6 +179,7 @@ func _physics_process(delta: float) -> void: func _process(delta: float) -> void: _update_control_effects(delta) _update_blades(delta) + _update_thunder_empowerment(delta) _update_targeting_visuals(delta) func emit_stat_signals() -> void: @@ -190,15 +196,15 @@ func get_upgrade_sections() -> Array: "title": "Skill 1: 法刃回旋", "upgrades": [ { - "id": "skill1_shield", - "label": "护体", - "description": "生成临时护盾。", - "fields": ["skill1_shield_upgrade"] + "id": "skill1_defense", + "label": "固甲", + "description": "释放时回复 60 防御。", + "fields": ["skill1_defense_upgrade"] }, { "id": "skill1_attack_blades", "label": "刃环", - "description": "前三次攻击附带法刃伤害。", + "description": "前三次普攻额外附加 100 法刃伤害。", "fields": ["skill1_attack_blades_upgrade"] } ] @@ -209,31 +215,31 @@ func get_upgrade_sections() -> Array: { "id": "skill2_amp", "label": "扩能", - "description": "同时提高爆炸范围与伤害。", + "description": "爆炸半径 +32,伤害 +100。", "fields": ["skill2_range_upgrade", "skill2_extra_damage_upgrade"] }, { "id": "skill2_chain", "label": "连爆", - "description": "第一次爆炸后追加一次小爆炸。", + "description": "0.18 秒后追加一次 70% 半径、180 伤害的小爆炸。", "fields": ["skill2_chain_burst_upgrade"] } ] }, { - "title": "Skill 3: 沉默诏令", + "title": "Skill 3: 雷霆之力", "upgrades": [ { - "id": "skill3_slow", - "label": "迟滞", - "description": "附加减速效果。", - "fields": ["skill3_slow_upgrade"] + "id": "skill3_damage_small", + "label": "雷涌", + "description": "技能生效期间造成的伤害提高 20%。", + "fields": ["skill3_damage_upgrade_small"] }, { - "id": "skill3_root", - "label": "禁制", - "description": "附加禁锢效果。", - "fields": ["skill3_root_upgrade"] + "id": "skill3_damage_large", + "label": "天威", + "description": "技能生效期间造成的伤害提高 40%。", + "fields": ["skill3_damage_upgrade_large"] } ] } @@ -283,17 +289,17 @@ func gain_inspiration(amount: float) -> void: func on_attack_landed(attack_name: StringName, target: Node) -> void: gain_inspiration(inspiration_gain_on_attack_hit) - if enchant_active and target != null and target.has_method("apply_control_effects"): - target.apply_control_effects(_build_control_payload()) - clear_skill3_enchant() if skill1_attack_blades_upgrade and attack_blade_bonus_hits_remaining > 0 and target != null and target.has_method("receive_hit"): attack_blade_bonus_hits_remaining -= 1 target.receive_hit(AccessoryManager.build_hit_payload( self, &"skill1_bonus", - skill1_attack_blades_damage, - crit_rate + _get_scaled_outgoing_damage(skill1_attack_blades_damage), + _get_current_crit_rate() )) + _emit_thunder_echo(target, skill1_attack_blades_damage) + if attack_name != &"skill3_echo": + _emit_thunder_echo(target, _infer_attack_damage_for_echo(attack_name)) AccessoryManager.apply_on_hit_effects(self, attack_name, target) func sync_visuals() -> void: @@ -312,7 +318,7 @@ func sync_visuals() -> void: modulate = Color(1.0, 0.7, 0.7, 1.0) elif silenced_time_remaining > 0.0: modulate = Color(0.84, 0.76, 1.0, 1.0) - elif enchant_active: + elif thunder_empowerment_active: modulate = Color(1.0, 0.92, 0.72, 1.0) elif blades_active: modulate = Color(0.78, 0.88, 1.0, 1.0) @@ -425,7 +431,16 @@ func finish_dodge() -> void: func trigger_normal_attack_hit() -> void: var direction := _get_attack_direction() - _spawn_arcane_bolt(direction, attack_damage, _get_current_crit_rate(), &"attack") + Sfx.play_event(&"player_mage_attack", global_position) + _spawn_arcane_bolt( + direction, + _get_scaled_outgoing_damage(normal_attack_projectile_damage), + _get_current_crit_rate(), + &"attack", + normal_attack_projectile_speed, + normal_attack_projectile_lifetime, + normal_attack_projectile_hit_radius + ) func finish_attack() -> void: if current_attack_name != &"": @@ -452,14 +467,19 @@ func start_skill1_cast() -> void: _animate_weapon_swing(-10.0, 7.0, skill1_cast_duration) func cast_skill1_blades() -> void: - if skill1_shield_upgrade: - health_component.set_shield(skill1_shield_value) + if skill1_defense_upgrade: + var restored_defense := minf(defense + skill1_defense_restore, max_defense) + if health_component != null: + health_component.defense = restored_defense + health_component.defense_changed.emit(restored_defense, max_defense) + defense = restored_defense + defense_changed.emit(defense, max_defense) attack_started.emit(&"skill1") + Sfx.play_event(&"player_mage_skill1_blades", global_position) blades_active = true blade_time_remaining = blade_duration blade_hit_cooldowns.clear() - if skill1_attack_blades_upgrade: - attack_blade_bonus_hits_remaining = skill1_attack_blades_hits + attack_blade_bonus_hits_remaining = skill1_attack_blades_hits if skill1_attack_blades_upgrade else 0 blade_orbit.visible = true attack_finished.emit(&"skill1") @@ -472,6 +492,7 @@ func start_skill2_cast() -> void: func release_skill2_burst() -> void: attack_started.emit(&"skill2") + Sfx.play_event(&"player_mage_skill2_burst", global_position) var radius := burst_radius + (32.0 if skill2_range_upgrade else 0.0) var damage := skill2_damage + (skill2_bonus_damage if skill2_extra_damage_upgrade else 0.0) var burst_center := global_position + facing * 120.0 @@ -484,7 +505,7 @@ func release_skill2_burst() -> void: timer.timeout.connect(func() -> void: if is_instance_valid(self): _show_burst_ring(burst_center, radius * 0.7) - _apply_area_damage(burst_center, radius * 0.7, skill2_chain_damage, &"skill2") + _apply_area_damage(burst_center, radius * 0.7, skill2_chain_damage, &"skill2_chain") ) attack_finished.emit(&"skill2") @@ -496,7 +517,9 @@ func start_skill3_cast() -> void: func apply_skill3_enchant() -> void: attack_started.emit(&"skill3") - enchant_active = true + Sfx.play_event(&"player_mage_skill3_thunder", global_position) + thunder_empowerment_active = true + thunder_empowerment_remaining = skill3_duration enchant_sigil.visible = true enchant_sigil.rotation = 0.0 enchant_sigil.scale = Vector2.ONE @@ -515,7 +538,8 @@ func clear_arcane_blades() -> void: blade_orbit.visible = false func clear_skill3_enchant() -> void: - enchant_active = false + thunder_empowerment_active = false + thunder_empowerment_remaining = 0.0 enchant_sigil.visible = false func receive_hit(payload: Dictionary) -> void: @@ -556,6 +580,8 @@ func heal(amount: float) -> void: health_component.heal(amount) func get_current_move_speed() -> float: + if current_attack_name == &"attack" or state_machine.get_state_name() == &"Attack": + return attack_move_speed return move_speed * slow_factor func get_effective_move_speed() -> float: @@ -602,8 +628,19 @@ func _get_attack_direction() -> Vector2: func _get_current_crit_rate() -> float: return crit_rate -func _spawn_arcane_bolt(direction: Vector2, damage: float, hit_crit_rate: float, attack_label: StringName) -> void: +func _spawn_arcane_bolt( + direction: Vector2, + damage: float, + hit_crit_rate: float, + attack_label: StringName, + projectile_speed: float = normal_attack_projectile_speed, + projectile_lifetime: float = normal_attack_projectile_lifetime, + projectile_hit_radius: float = normal_attack_projectile_hit_radius +) -> void: var bolt := ARCANE_BOLT_SCENE.instantiate() + bolt.speed = projectile_speed + bolt.lifetime = projectile_lifetime + bolt.hit_radius = projectile_hit_radius bolt.global_position = projectile_spawner.global_position get_tree().current_scene.add_child(bolt) bolt.setup(self, direction, damage, hit_crit_rate, attack_label) @@ -617,26 +654,16 @@ func _apply_area_damage(center: Vector2, radius: float, damage: float, attack_na continue if not target.has_method("receive_hit"): continue + var final_damage := _get_scaled_outgoing_damage(damage) target.receive_hit(AccessoryManager.build_hit_payload( self, attack_name, - damage, + final_damage, _get_current_crit_rate() )) on_attack_landed(attack_name, target) attack_hit.emit(attack_name, target) -func _build_control_payload() -> Dictionary: - var payload := { - "silence_duration": silence_duration - } - if skill3_slow_upgrade: - payload["slow_duration"] = slow_duration - payload["slow_multiplier"] = slow_multiplier - if skill3_root_upgrade: - payload["root_duration"] = root_duration - return payload - func apply_control_effects(payload: Dictionary) -> void: if payload.has("silence_duration"): silenced_time_remaining = maxf(silenced_time_remaining, float(payload["silence_duration"])) @@ -679,10 +706,11 @@ func _update_blades(delta: float) -> void: if float(blade_hit_cooldowns.get(key, 0.0)) > 0.0: continue blade_hit_cooldowns[key] = blade_tick_interval + var blade_damage := _get_scaled_outgoing_damage(skill1_damage) target.receive_hit(AccessoryManager.build_hit_payload( self, &"skill1", - skill1_damage, + blade_damage, _get_current_crit_rate() )) on_attack_landed(&"skill1", target) @@ -690,6 +718,13 @@ func _update_blades(delta: float) -> void: if blade_time_remaining <= 0.0: clear_arcane_blades() +func _update_thunder_empowerment(delta: float) -> void: + if not thunder_empowerment_active: + return + thunder_empowerment_remaining = maxf(thunder_empowerment_remaining - delta, 0.0) + if thunder_empowerment_remaining <= 0.0: + clear_skill3_enchant() + func _update_targeting_visuals(delta: float) -> void: var pulse := 0.75 + 0.25 * sin(Time.get_ticks_msec() * 0.008) if state_machine.get_state_name() == &"Attack": @@ -699,7 +734,7 @@ func _update_targeting_visuals(delta: float) -> void: focus_ring.modulate = Color(0.74, 0.88, 1.0, 0.45 + 0.25 * pulse) else: focus_ring.visible = false - if enchant_active: + if thunder_empowerment_active: enchant_sigil.visible = true enchant_sigil.rotation += delta * 2.4 enchant_sigil.scale = Vector2.ONE * (0.94 + 0.08 * pulse) @@ -725,6 +760,56 @@ func spawn_damage_number(amount: float, is_critical: bool, world_position: Vecto damage_number.setup(amount, is_critical) effects_layer.add_child(damage_number) +func _get_scaled_outgoing_damage(base_damage: float) -> float: + var multiplier := 1.0 + if thunder_empowerment_active: + if skill3_damage_upgrade_small: + multiplier += 0.20 + if skill3_damage_upgrade_large: + multiplier += 0.40 + return base_damage * multiplier + +func _infer_attack_damage_for_echo(attack_name: StringName) -> float: + match attack_name: + &"attack": + return normal_attack_projectile_damage + &"skill1": + return skill1_damage + &"skill1_bonus": + return skill1_attack_blades_damage + &"skill2": + return skill2_damage + (skill2_bonus_damage if skill2_extra_damage_upgrade else 0.0) + &"skill2_chain": + return skill2_chain_damage + _: + return 0.0 + +func _emit_thunder_echo(primary_target: Node, base_damage: float) -> void: + if not thunder_empowerment_active: + return + if primary_target == null or not (primary_target is Node2D): + return + var echo_damage := _get_scaled_outgoing_damage(base_damage) * skill3_echo_damage_ratio + if echo_damage <= 0.0: + return + var center := (primary_target as Node2D).global_position + for candidate in get_tree().get_nodes_in_group("damageable"): + if candidate == self or candidate == primary_target or not (candidate is Node2D): + continue + if not candidate.has_method("receive_hit"): + continue + var node_2d: Node2D = candidate + if center.distance_to(node_2d.global_position) > skill3_echo_radius: + continue + candidate.receive_hit(AccessoryManager.build_hit_payload( + self, + &"skill3_echo", + echo_damage, + _get_current_crit_rate() + )) + attack_hit.emit(&"skill3_echo", candidate) + + func _on_hurtbox_area_entered(area: Area2D) -> void: if area != null and area.has_method("build_damage_payload"): receive_hit(area.build_damage_payload()) @@ -910,3 +995,6 @@ func _spawn_death_burst() -> void: tween.tween_property(glyph, "position", direction * 24.0 + Vector2(0.0, -8.0), 0.22) tween.parallel().tween_property(glyph, "modulate:a", 0.0, 0.22) tween.finished.connect(glyph.queue_free) + + + diff --git a/characters/ranger/ranger.gd b/characters/ranger/ranger.gd index e1e6026..31b0cc3 100644 --- a/characters/ranger/ranger.gd +++ b/characters/ranger/ranger.gd @@ -392,6 +392,7 @@ func start_attack() -> void: current_attack_targets.clear() current_attack_name = &"attack" attack_started.emit(current_attack_name) + Sfx.play_event(&"player_ranger_attack", global_position) slash_arc.visible = true slash_arc.position = _attack_visual_offset() slash_arc.rotation = _attack_facing().angle() @@ -426,6 +427,7 @@ func trigger_normal_attack_hit() -> void: func fire_piercing_arrow() -> void: attack_started.emit(&"skill1") + Sfx.play_event(&"player_ranger_skill1_arrow", global_position) _show_piercing_charge_burst() _spawn_arrow(facing, skill1_damage) if skill1_split_shot_upgrade: @@ -479,6 +481,7 @@ func start_shadow_step() -> void: shadow_step_marked_targets.clear() roll_afterimage_timer = 0.0 attack_started.emit(&"skill2") + Sfx.play_event(&"player_ranger_skill2_shadow", global_position) play_animation(&"skill2") if weapon != null: weapon.visible = false @@ -530,6 +533,7 @@ func start_assassination_dash() -> void: skill3_strike_elapsed = 0.0 active_skill_target = queued_skill_payload.get("target", null) _show_assassination_mark() + Sfx.play_event(&"player_ranger_skill3_assassinate", global_position) play_animation(&"skill3_dash") _animate_weapon_swing(-42.0, 14.0, 0.18) diff --git a/combat/health_component.gd b/combat/health_component.gd index e7d41a1..00ee59a 100644 --- a/combat/health_component.gd +++ b/combat/health_component.gd @@ -76,7 +76,9 @@ func heal(amount: float) -> void: func receive_hit(payload: Dictionary) -> Dictionary: var source_value: Variant = payload.get("source", null) - var source: Node = source_value if source_value is Node and is_instance_valid(source_value) else null + var source: Node = null + if is_instance_valid(source_value) and source_value is Node: + source = source_value if _is_owner_cheat_protected(): return { "damage": 0.0, diff --git a/docs/MAP_INTEGRATION_NOTES.md b/docs/MAP_INTEGRATION_NOTES.md index dec9528..a52e36a 100644 --- a/docs/MAP_INTEGRATION_NOTES.md +++ b/docs/MAP_INTEGRATION_NOTES.md @@ -58,11 +58,11 @@ room_03_street_battle_2 -> town mob encounter room_04_central_plaza -> town mob encounter room_09_elite_zone -> town mob encounter room_10_palace_hall -> Judicator boss -room_11_palace_corridor -> Royal Guard Formation +room_11_palace_corridor -> town mob encounter room_12_king_gate -> Twin Princes boss ``` -The first five rooms are treated as outside/approach rooms and use the six soldier enemy types. The first palace room uses the first boss. The king gate / throne-front room uses the final twin boss. +Approach and corridor rooms use the six soldier enemy types. The first palace boss room uses Judicator. The king gate / throne-front room uses the lineage final boss. The same demo also places six enemy material previews on top of the stitched route: diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 1535165..56e727c 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -7,7 +7,7 @@ Infinity Kingdom is a Godot 4 action prototype centered on a short town boss-rus 1. `world.tscn` loads the title UI, audio managers, HUD, character select, and accessory choice UI. 2. The player selects Knight, Ranger, or Mage. 3. `AccessoryManager` resets the run and opens the first relic choice. -4. Encounters advance in order: a variable town enemy sweep, Judicator, Royal Guard Formation, Twin Princes. +4. Encounters advance through variable town enemy sweeps, Judicator, and a lineage final boss. 5. `RunDirector` awards gold, then deals a short event deck of shop plus shuffled rest/training/pact/attunement branches. ## Main Folders diff --git a/effects/projectiles/arcane_bolt.gd b/effects/projectiles/arcane_bolt.gd index 49f0e02..ac72fca 100644 --- a/effects/projectiles/arcane_bolt.gd +++ b/effects/projectiles/arcane_bolt.gd @@ -5,7 +5,7 @@ const PLAYER_ORB_TEXTURE_PATH := "res://assets/effects/projectiles/player_staff_ @export var speed: float = 620.0 @export var lifetime: float = 1.5 -@export var hit_radius: float = 18.0 +@export var hit_radius: float = 10.0 @onready var bolt: Sprite2D = $Bolt @onready var collision_shape: CollisionShape2D = $CollisionShape2D diff --git a/effects/projectiles/mage_boss_red_bolt.gd b/effects/projectiles/mage_boss_red_bolt.gd new file mode 100644 index 0000000..39649e7 --- /dev/null +++ b/effects/projectiles/mage_boss_red_bolt.gd @@ -0,0 +1,132 @@ +extends Area2D + +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const BOLT_TEXTURE_PATH := "res://assets/effects/projectiles/boss_bullet_red.webp" + +@export var speed: float = 300.0 +@export var lifetime: float = 3.0 + +@onready var bolt: Sprite2D = $Bolt +@onready var collision_shape: CollisionShape2D = $CollisionShape2D + +var direction: Vector2 = Vector2.RIGHT +var damage: float = 30.0 +var source: Node = null +var expired: bool = false + +func _ready() -> void: + add_to_group("enemy_projectile") + z_index = 90 + _setup_visual() + _setup_collision_shape() + body_entered.connect(_on_body_entered) + area_entered.connect(_on_area_entered) + var timer := get_tree().create_timer(lifetime) + timer.timeout.connect(queue_free) + +func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, new_speed: float = speed) -> void: + source = owner_actor + direction = travel_direction.normalized() if travel_direction != Vector2.ZERO else Vector2.RIGHT + damage = hit_damage + speed = new_speed + rotation = direction.angle() + +func _physics_process(delta: float) -> void: + var previous_position := global_position + global_position += direction * speed * delta + if _expire_if_blocked_between(previous_position, global_position): + return + +func _on_body_entered(body: Node) -> void: + _try_hit(body) + +func _on_area_entered(area: Area2D) -> void: + if area == null: + return + _try_hit(area.get_parent()) + +func _try_hit(target: Variant) -> void: + if expired: + return + if target is Node and (target as Node).is_in_group("projectile_blocker"): + _expire_on_blocker() + return + target = _resolve_damage_target(target) + if source != null and not is_instance_valid(source): + source = null + if target == null or target == source: + return + if not target.has_method("receive_hit"): + return + expired = true + target.receive_hit({ + "source": source, + "damage": damage, + "crit_rate": 0.0 + }) + _spawn_hit_flash() + queue_free() + +func _expire_on_blocker() -> void: + if expired: + return + expired = true + _spawn_hit_flash() + queue_free() + +func _expire_if_blocked_between(from_position: Vector2, to_position: Vector2) -> bool: + if expired or from_position == to_position: + return false + var query := PhysicsRayQueryParameters2D.create(from_position, to_position) + query.collision_mask = 1 + query.exclude = [self] + var hit := get_world_2d().direct_space_state.intersect_ray(query) + var collider: Variant = hit.get("collider", null) + if collider is Node and (collider as Node).is_in_group("projectile_blocker"): + _expire_on_blocker() + return true + return false + +func _resolve_damage_target(target: Variant) -> Node: + if target == null or not (target is Node): + return null + var node: Node = target + if node.has_method("receive_hit"): + return node + if node.get_parent() != null and node.get_parent().has_method("receive_hit"): + return node.get_parent() + return null + +func _spawn_hit_flash() -> void: + var scene_root := get_tree().current_scene + if scene_root == null: + return + var flash := Sprite2D.new() + flash.texture = bolt.texture + flash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + flash.centered = true + flash.scale = Vector2.ONE * 1.15 + flash.modulate = Color(1.0, 0.42, 0.3, 0.9) + flash.global_position = global_position + flash.rotation = rotation + scene_root.add_child(flash) + var tween := flash.create_tween() + tween.tween_property(flash, "scale", Vector2.ONE * 1.55, 0.1) + tween.parallel().tween_property(flash, "modulate:a", 0.0, 0.1) + tween.finished.connect(flash.queue_free) + +func _setup_visual() -> void: + bolt.texture = TEXTURE_LOADER.load_texture(BOLT_TEXTURE_PATH) + bolt.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + bolt.centered = true + bolt.scale = Vector2.ONE * 0.78 + bolt.z_index = 91 + +func _setup_collision_shape() -> void: + if collision_shape == null: + return + var shape := CapsuleShape2D.new() + shape.radius = 7.0 + shape.height = 32.0 + collision_shape.rotation = PI * 0.5 + collision_shape.shape = shape diff --git a/effects/projectiles/mage_boss_red_bolt.gd.uid b/effects/projectiles/mage_boss_red_bolt.gd.uid new file mode 100644 index 0000000..6f6e7f6 --- /dev/null +++ b/effects/projectiles/mage_boss_red_bolt.gd.uid @@ -0,0 +1 @@ +uid://r5l4b5galgeu diff --git a/effects/projectiles/mage_boss_red_bolt.tscn b/effects/projectiles/mage_boss_red_bolt.tscn new file mode 100644 index 0000000..be0d4d7 --- /dev/null +++ b/effects/projectiles/mage_boss_red_bolt.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://effects/projectiles/mage_boss_red_bolt.gd" id="1"] + +[sub_resource type="CapsuleShape2D" id="1"] +radius = 7.0 +height = 32.0 + +[node name="MageBossRedBolt" type="Area2D"] +script = ExtResource("1") +collision_layer = 0 +collision_mask = 1 +monitoring = true +monitorable = false + +[node name="Bolt" type="Sprite2D" parent="."] +texture_filter = 1 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +rotation = 1.5708 +shape = SubResource("1") diff --git a/effects/projectiles/piercing_arrow.gd b/effects/projectiles/piercing_arrow.gd index 83c9c18..95f5a5b 100644 --- a/effects/projectiles/piercing_arrow.gd +++ b/effects/projectiles/piercing_arrow.gd @@ -61,16 +61,17 @@ func _try_hit(target: Variant) -> void: _expire_on_blocker() return target = _resolve_damage_target(target) - if target == null or target == source: + var safe_source: Node = source if source != null and is_instance_valid(source) else null + if target == null or target == safe_source: return if hit_targets.has(target): return if not target.has_method("receive_hit"): return hit_targets.append(target) - target.receive_hit(AccessoryManager.build_hit_payload(source, attack_name, damage, crit_rate)) - if source != null and source.has_method("on_attack_landed"): - source.on_attack_landed(attack_name, target) + target.receive_hit(AccessoryManager.build_hit_payload(safe_source, attack_name, damage, crit_rate)) + if safe_source != null and safe_source.has_method("on_attack_landed"): + safe_source.on_attack_landed(attack_name, target) _spawn_hit_flash() func _expire_on_blocker() -> void: diff --git a/explain_code/folders/implementation_folders.md b/explain_code/folders/implementation_folders.md index 1ab54a3..3750f97 100644 --- a/explain_code/folders/implementation_folders.md +++ b/explain_code/folders/implementation_folders.md @@ -122,7 +122,6 @@ - `judicator_boss.gd`:审判者 Boss,跳劈、直线斩、弹幕,低血狂暴。 - `emperor_boss.gd`:皇帝 Boss,二阶段、影步、冲锋、爆裂、齐射。 - `twin_princes_boss.gd`:双王子 Boss,半血换相、传送、直线斩、弹幕、冲刺失误自眩。 -- `royal_guard_formation.gd` / `guard_unit.gd`:守卫阵型和守卫单位。 ## `ui/` diff --git a/explain_code/inventory/code_inventory.md b/explain_code/inventory/code_inventory.md index 8cef17f..dab90cf 100644 --- a/explain_code/inventory/code_inventory.md +++ b/explain_code/inventory/code_inventory.md @@ -72,11 +72,9 @@ - `actors/encounters/empty_encounter.gd` - `actors/encounters/town_mob_encounter.gd` - `actors/bosses/town/emperor_boss.gd` -- `actors/bosses/town/guard_unit.gd` - `actors/bosses/town/judicator_boss.gd` - `actors/bosses/town/mage_boss.gd` - `actors/bosses/town/ranger_boss.gd` -- `actors/bosses/town/royal_guard_formation.gd` - `actors/bosses/town/twin_princes_boss.gd` ## 效果与投射物 diff --git a/systems/run/run_director.gd b/systems/run/run_director.gd index 3cb91f1..9092988 100644 --- a/systems/run/run_director.gd +++ b/systems/run/run_director.gd @@ -84,6 +84,8 @@ var reward_history: Array[int] = [] var hero_level: int = 1 var hero_xp: int = 0 var hero_xp_to_next: int = 45 +var skill_points: int = 0 +var max_skill_points: int = 6 var total_kills: int = 0 var town_service_consumed: bool = false var rng := RandomNumberGenerator.new() @@ -106,6 +108,7 @@ func reset_run() -> void: hero_level = 1 hero_xp = 0 hero_xp_to_next = _xp_needed_for_level(hero_level) + skill_points = 0 total_kills = 0 town_service_consumed = false _emit_state() @@ -150,6 +153,7 @@ func grant_experience(amount: int) -> Dictionary: "previous_level": previous_level, "current_level": hero_level, "levels_gained": 0, + "skill_points_gained": 0, "xp": hero_xp, "xp_to_next": hero_xp_to_next } @@ -160,16 +164,39 @@ func grant_experience(amount: int) -> Dictionary: hero_level += 1 levels_gained += 1 hero_xp_to_next = _xp_needed_for_level(hero_level) + var skill_points_gained := grant_skill_points(levels_gained) _emit_state() return { "granted": granted, "previous_level": previous_level, "current_level": hero_level, "levels_gained": levels_gained, + "skill_points_gained": skill_points_gained, "xp": hero_xp, "xp_to_next": hero_xp_to_next } +func grant_skill_points(amount: int) -> int: + var granted := maxi(amount, 0) + if granted <= 0: + return 0 + var previous_points := skill_points + skill_points = mini(skill_points + granted, max_skill_points) + return skill_points - previous_points + +func spend_skill_point(amount: int = 1) -> bool: + var cost := maxi(amount, 0) + if cost <= 0: + return true + if skill_points < cost: + return false + skill_points -= cost + _emit_state() + return true + +func can_spend_skill_point(amount: int = 1) -> bool: + return skill_points >= maxi(amount, 0) + func record_kill(amount: int = 1) -> void: var granted := maxi(amount, 0) if granted <= 0: @@ -331,6 +358,8 @@ func get_state() -> Dictionary: "hero_level": hero_level, "hero_xp": hero_xp, "hero_xp_to_next": hero_xp_to_next, + "skill_points": skill_points, + "max_skill_points": max_skill_points, "total_kills": total_kills, "town_service_consumed": town_service_consumed } diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" index dec9528..a52e36a 100644 --- "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" @@ -58,11 +58,11 @@ room_03_street_battle_2 -> town mob encounter room_04_central_plaza -> town mob encounter room_09_elite_zone -> town mob encounter room_10_palace_hall -> Judicator boss -room_11_palace_corridor -> Royal Guard Formation +room_11_palace_corridor -> town mob encounter room_12_king_gate -> Twin Princes boss ``` -The first five rooms are treated as outside/approach rooms and use the six soldier enemy types. The first palace room uses the first boss. The king gate / throne-front room uses the final twin boss. +Approach and corridor rooms use the six soldier enemy types. The first palace boss room uses Judicator. The king gate / throne-front room uses the lineage final boss. The same demo also places six enemy material previews on top of the stitched route: diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" index 1535165..56e727c 100644 --- "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" @@ -7,7 +7,7 @@ Infinity Kingdom is a Godot 4 action prototype centered on a short town boss-rus 1. `world.tscn` loads the title UI, audio managers, HUD, character select, and accessory choice UI. 2. The player selects Knight, Ranger, or Mage. 3. `AccessoryManager` resets the run and opens the first relic choice. -4. Encounters advance in order: a variable town enemy sweep, Judicator, Royal Guard Formation, Twin Princes. +4. Encounters advance through variable town enemy sweeps, Judicator, and a lineage final boss. 5. `RunDirector` awards gold, then deals a short event deck of shop plus shuffled rest/training/pact/attunement branches. ## Main Folders diff --git a/tools/character_debug_world.gd b/tools/character_debug_world.gd index 9b2e29d..6cdfaa8 100644 --- a/tools/character_debug_world.gd +++ b/tools/character_debug_world.gd @@ -57,6 +57,8 @@ var active_enemy_elite: bool = false func _ready() -> void: if AccessoryManager != null: AccessoryManager.reset_run() + if RunDirector != null: + RunDirector.reset_run() if character_select != null: character_select.character_selected.connect(_on_character_selected) if character_select.has_signal("quit_requested"): @@ -95,6 +97,7 @@ func _build_debug_overlay_controls() -> void: var upgrade_button := Button.new() upgrade_button.text = "升级" upgrade_button.custom_minimum_size = Vector2(120.0, 34.0) + upgrade_button.focus_mode = Control.FOCUS_NONE upgrade_button.tooltip_text = "打开升级面板" upgrade_button.pressed.connect(_on_upgrade_requested) control_row.add_child(upgrade_button) @@ -142,6 +145,7 @@ func _on_character_selected(character_id: StringName) -> void: enemy_select.open() if debug_status != null and debug_status.has_method("bind_character"): debug_status.bind_character(player_character) + debug_status.visible = true _spawn_debug_target(active_enemy_id, active_enemy_elite) _refresh_help_text() @@ -223,6 +227,8 @@ func _return_to_character_select() -> void: enemy_select.close() if debug_status != null and debug_status.has_method("clear"): debug_status.clear() + elif debug_status != null: + debug_status.visible = false if character_select != null: character_select.visible = true _refresh_help_text() diff --git a/tools/character_debug_world.tscn b/tools/character_debug_world.tscn index 408e224..2705fcf 100644 --- a/tools/character_debug_world.tscn +++ b/tools/character_debug_world.tscn @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://tools/character_debug_world.gd" id="1"] [ext_resource type="PackedScene" path="res://ui/character_select.tscn" id="2"] [ext_resource type="PackedScene" path="res://ui/debug_enemy_select.tscn" id="3"] -[ext_resource type="PackedScene" path="res://ui/character_debug_status.tscn" id="4"] +[ext_resource type="PackedScene" path="res://ui/knight_hud.tscn" id="4"] [sub_resource type="RectangleShape2D" id="1"] size = Vector2(1360, 20) diff --git a/ui/character_select.gd b/ui/character_select.gd index 6097a65..8cae41d 100644 --- a/ui/character_select.gd +++ b/ui/character_select.gd @@ -1368,40 +1368,6 @@ func _gallery_entries() -> Array[Dictionary]: "zh_Hant": "這個首領最強的地方不是數值,而是逼你在很短的窗口裡慌張處理。看到落點圈就提前讓位,給直線衝鋒預留空間,殘血階段一定要防它的狂暴追擊。" } }, - { - "id": "gallery_boss_guards", - "icon": "res://assets/ui/icon/ui_church.png", - "title": { - "en": "Royal Guard Formation", - "zh_Hans": "王庭卫阵", - "zh_Hant": "王庭衛陣" - }, - "subtitle": { - "en": "Boss Intel", - "zh_Hans": "首领情报", - "zh_Hant": "首領情報" - }, - "meta": { - "en": "Immune shell, lane pressure, collapse condition.", - "zh_Hans": "免疫外壳、边线压迫、拆阵条件。", - "zh_Hant": "免疫外殼、邊線壓迫、拆陣條件。" - }, - "detail_title": { - "en": "Royal Guard Formation", - "zh_Hans": "王庭卫阵", - "zh_Hant": "王庭衛陣" - }, - "detail_role": { - "en": "Boss Intel | Formation encounter", - "zh_Hans": "首领情报 | 阵型关卡", - "zh_Hant": "首領情報 | 陣型關卡" - }, - "detail_desc": { - "en": "The formation starts immune, then opens once coverage fully unfolds. The fight asks you to read the whole board, not only the nearest guard. Clear pressure without losing the center.", - "zh_Hans": "这场战斗的重点是读整体阵型,不是只盯最近的小兵。护阵阶段先处理边线压力,等覆盖完全展开以后再抓住中场空档,一口气把阵型拆穿。", - "zh_Hant": "這場戰鬥的重點是讀整體陣型,不是只盯最近的小兵。護陣階段先處理邊線壓力,等覆蓋完全展開以後再抓住中場空檔,一口氣把陣型拆穿。" - } - }, { "id": "gallery_boss_princes", "icon": "res://assets/ui/icon/ui_ember_seed.png", diff --git a/ui/debug_enemy_select.gd b/ui/debug_enemy_select.gd index d1050af..8153371 100644 --- a/ui/debug_enemy_select.gd +++ b/ui/debug_enemy_select.gd @@ -69,6 +69,7 @@ func _build_ui() -> void: content.add_child(hint) elite_check = CheckBox.new() + elite_check.focus_mode = Control.FOCUS_NONE elite_check.text = "小怪使用精英版" content.add_child(elite_check) @@ -76,6 +77,7 @@ func _build_ui() -> void: var button := Button.new() button.text = String(option["label"]) button.custom_minimum_size = Vector2(260.0, 36.0) + button.focus_mode = Control.FOCUS_NONE var enemy_id: StringName = option["id"] button.pressed.connect(func() -> void: enemy_selected.emit(enemy_id, elite_check.button_pressed) diff --git a/ui/knight_hud.gd b/ui/knight_hud.gd index b65f890..d08a62a 100644 --- a/ui/knight_hud.gd +++ b/ui/knight_hud.gd @@ -1,5 +1,7 @@ extends CanvasLayer +signal skill_tree_requested + const RunEffects := preload("res://systems/run/run_effects.gd") const UISkin := preload("res://ui/ui_skin.gd") const CooldownSkillIcon := preload("res://ui/cooldown_skill_icon.gd") @@ -52,6 +54,8 @@ var shield_label: Label var state_label: Label var level_label: Label var xp_label: Label +var skill_points_label: Label +var skill_tree_button: Button var status_grid: GridContainer var control_label: Label var combat_feed_label: Label @@ -230,12 +234,24 @@ func _build_ui() -> void: state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL level_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL xp_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + skill_points_label = _make_label(_locale_text("Points 0 / 6", "技能点 0 / 6", "技能點 0 / 6"), 13, Color(0.92, 0.80, 1.0)) + skill_points_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + skill_tree_button = Button.new() + skill_tree_button.text = _locale_text("Skills", "技能树", "技能樹") + skill_tree_button.custom_minimum_size = Vector2(0.0, 30.0) + skill_tree_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UISkin.button_styles(skill_tree_button, "thin") + skill_tree_button.pressed.connect(func() -> void: + skill_tree_requested.emit() + ) state_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT xp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT status_grid.add_child(shield_label) status_grid.add_child(state_label) status_grid.add_child(level_label) status_grid.add_child(xp_label) + status_grid.add_child(skill_points_label) + status_grid.add_child(skill_tree_button) status_panel_root = PanelContainer.new() status_panel_root.add_theme_stylebox_override("panel", _hud_panel_style(Color(0.84, 0.90, 0.98))) @@ -575,7 +591,10 @@ func _refresh_section_emphasis() -> void: if not current_run_state.is_empty(): var reward_flat_bonus := int(current_run_state.get("reward_flat_bonus", 0)) var reward_multiplier := float(current_run_state.get("reward_multiplier", 1.0)) - var pending_prep := current_run_state.get("pending_encounter_prep", {}) as Dictionary + var pending_prep: Dictionary = {} + var pending_prep_value: Variant = current_run_state.get("pending_encounter_prep", {}) + if pending_prep_value is Dictionary: + pending_prep = pending_prep_value if reward_flat_bonus > 0 or reward_multiplier > 1.001: run_emphasis = true run_accent = Color(0.98, 0.86, 0.58) @@ -696,7 +715,10 @@ func _refresh_run_state_label(state: Dictionary) -> void: var next_label := RunDirector.describe_event_kind(next_kind) if RunDirector != null and not next_kind.is_empty() else _locale_text("Victory", "胜利结算", "勝利結算") var reward_flat_bonus := int(state.get("reward_flat_bonus", 0)) var reward_multiplier := float(state.get("reward_multiplier", 1.0)) - var pending_prep := state.get("pending_encounter_prep", {}) as Dictionary + var pending_prep: Dictionary = {} + var pending_prep_value: Variant = state.get("pending_encounter_prep", {}) + if pending_prep_value is Dictionary: + pending_prep = pending_prep_value var hero_level := int(state.get("hero_level", 1)) var hero_xp := int(state.get("hero_xp", 0)) var hero_xp_to_next := int(state.get("hero_xp_to_next", 45)) @@ -820,7 +842,9 @@ func _skill_total_cooldown(key: String) -> float: func _prep_run_text(current_scene: Node, pending_prep: Dictionary) -> String: var active_prep: Dictionary = {} if current_scene != null: - active_prep = current_scene.get("active_encounter_prep") as Dictionary + var active_prep_value: Variant = current_scene.get("active_encounter_prep") + if active_prep_value is Dictionary: + active_prep = active_prep_value if not active_prep.is_empty(): return _locale_text(" | Prep %s", " | 备战 %s", " | 備戰 %s") % RunEffects.prep_title(active_prep) if not pending_prep.is_empty(): diff --git a/ui/result_screen.gd b/ui/result_screen.gd index d3e59e2..52c91a6 100644 --- a/ui/result_screen.gd +++ b/ui/result_screen.gd @@ -127,11 +127,15 @@ func _unhandled_input(event: InputEvent) -> void: match event.keycode: KEY_ESCAPE, KEY_ENTER, KEY_KP_ENTER, KEY_SPACE: _close_result() - get_viewport().set_input_as_handled() + var viewport := get_viewport() + if viewport != null: + viewport.set_input_as_handled() KEY_Q: get_tree().paused = false quit_requested.emit() - get_viewport().set_input_as_handled() + var viewport := get_viewport() + if viewport != null: + viewport.set_input_as_handled() func _queue_layout_refresh() -> void: call_deferred("_refresh_layout") diff --git a/ui/skill_tree_panel.gd b/ui/skill_tree_panel.gd new file mode 100644 index 0000000..6f9530c --- /dev/null +++ b/ui/skill_tree_panel.gd @@ -0,0 +1,279 @@ +extends CanvasLayer + +signal upgrade_purchased(upgrade_id: String) +signal pause_requested + +const UISkin := preload("res://ui/ui_skin.gd") + +var backdrop: ColorRect +var panel: PanelContainer +var title_label: Label +var detail_label: Label +var points_label: Label +var open_hint_label: Label +var scroll: ScrollContainer +var list_root: VBoxContainer + +var active_actor: Node = null +var upgrade_buttons: Array[Button] = [] + +func _ready() -> void: + layer = 19 + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _build_ui() + if RunDirector != null and not RunDirector.state_changed.is_connected(_on_run_state_changed): + RunDirector.state_changed.connect(_on_run_state_changed) + +func bind_actor(actor: Node) -> void: + if active_actor != null and is_instance_valid(active_actor) and active_actor.has_signal("upgrades_changed"): + var refresh_callable := Callable(self, "_refresh_content") + if active_actor.is_connected("upgrades_changed", refresh_callable): + active_actor.disconnect("upgrades_changed", refresh_callable) + active_actor = actor + if active_actor != null and is_instance_valid(active_actor) and active_actor.has_signal("upgrades_changed"): + var refresh_callable := Callable(self, "_refresh_content") + if not active_actor.is_connected("upgrades_changed", refresh_callable): + active_actor.connect("upgrades_changed", refresh_callable) + _refresh_content() + +func open() -> void: + _refresh_content() + visible = true + get_tree().paused = true + +func close() -> void: + visible = false + get_tree().paused = false + +func toggle() -> void: + if visible: + close() + else: + open() + +func has_available_upgrades() -> bool: + if active_actor == null or not is_instance_valid(active_actor): + return false + if not active_actor.has_method("get_upgrade_sections") or not active_actor.has_method("is_upgrade_enabled"): + return false + var points := int(RunDirector.get_state().get("skill_points", 0)) if RunDirector != null else 0 + if points <= 0: + return false + for section_variant in active_actor.get_upgrade_sections(): + var section: Dictionary = section_variant + for upgrade_variant in section.get("upgrades", []): + var upgrade: Dictionary = upgrade_variant + var upgrade_id := String(upgrade.get("id", "")) + if upgrade_id.is_empty(): + continue + if not bool(active_actor.is_upgrade_enabled(upgrade_id)): + return true + return false + +func _build_ui() -> void: + backdrop = ColorRect.new() + backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.color = Color(0.012, 0.014, 0.020, 0.72) + add_child(backdrop) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.add_child(center) + + panel = PanelContainer.new() + panel.custom_minimum_size = Vector2(920, 560) + panel.add_theme_stylebox_override("panel", UISkin.menu_panel_style()) + center.add_child(panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 28) + margin.add_theme_constant_override("margin_top", 24) + margin.add_theme_constant_override("margin_right", 28) + margin.add_theme_constant_override("margin_bottom", 24) + panel.add_child(margin) + + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + margin.add_child(column) + + title_label = Label.new() + title_label.text = _locale_text("Skill Tree", "技能加点", "技能加點") + UISkin.label(title_label, 28, Color(0.98, 0.90, 0.66)) + column.add_child(title_label) + + detail_label = Label.new() + detail_label.text = _locale_text( + "Each level grants 1 point. Spend points to unlock upgrades. Esc opens the menu.", + "每次升级获得 1 点技能点,最多储存 6 点。点击词条即可解锁。Esc 打开菜单。", + "每次升級獲得 1 點技能點,最多儲存 6 點。點擊詞條即可解鎖。Esc 打開選單。" + ) + detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UISkin.label(detail_label, 14, Color(0.78, 0.84, 0.92)) + column.add_child(detail_label) + + var top_row := HBoxContainer.new() + top_row.add_theme_constant_override("separation", 12) + column.add_child(top_row) + + points_label = Label.new() + points_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UISkin.label(points_label, 16, Color(0.92, 0.88, 0.64)) + top_row.add_child(points_label) + + open_hint_label = Label.new() + open_hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + UISkin.label(open_hint_label, 13, Color(0.72, 0.80, 0.90)) + top_row.add_child(open_hint_label) + + scroll = ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + column.add_child(scroll) + + list_root = VBoxContainer.new() + list_root.add_theme_constant_override("separation", 10) + list_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(list_root) + +func _refresh_content() -> void: + _refresh_header() + _clear_entries() + if list_root == null: + return + if active_actor == null or not is_instance_valid(active_actor): + var label := Label.new() + label.text = _locale_text("No active hero.", "当前没有可加点角色。", "當前沒有可加點角色。") + UISkin.label(label, 14, Color(0.82, 0.90, 0.98)) + list_root.add_child(label) + return + if not active_actor.has_method("get_upgrade_sections") or not active_actor.has_method("is_upgrade_enabled") or not active_actor.has_method("set_upgrade_enabled"): + var label := Label.new() + label.text = _locale_text("This hero has no skill tree data.", "该角色暂时没有技能树数据。", "該角色暫時沒有技能樹資料。") + UISkin.label(label, 14, Color(0.82, 0.90, 0.98)) + list_root.add_child(label) + return + var sections: Array = active_actor.get_upgrade_sections() + for section_variant in sections: + var section: Dictionary = section_variant + _build_section(section) + +func _build_section(section: Dictionary) -> void: + var section_panel := PanelContainer.new() + section_panel.add_theme_stylebox_override("panel", UISkin.choice_panel_style()) + list_root.add_child(section_panel) + + var section_margin := MarginContainer.new() + section_margin.add_theme_constant_override("margin_left", 16) + section_margin.add_theme_constant_override("margin_top", 14) + section_margin.add_theme_constant_override("margin_right", 16) + section_margin.add_theme_constant_override("margin_bottom", 14) + section_panel.add_child(section_margin) + + var section_column := VBoxContainer.new() + section_column.add_theme_constant_override("separation", 8) + section_margin.add_child(section_column) + + var section_title := Label.new() + section_title.text = String(section.get("title", _locale_text("Skill", "技能", "技能"))) + UISkin.label(section_title, 18, Color(0.90, 0.92, 0.98)) + section_column.add_child(section_title) + + for upgrade_variant in section.get("upgrades", []): + var upgrade: Dictionary = upgrade_variant + var entry := _build_upgrade_entry(upgrade) + section_column.add_child(entry) + +func _build_upgrade_entry(upgrade: Dictionary) -> Control: + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 4) + + var upgrade_id := String(upgrade.get("id", "")) + var upgrade_label := String(upgrade.get("label", upgrade_id)) + var upgrade_description := String(upgrade.get("description", "")) + var learned := active_actor != null and is_instance_valid(active_actor) and active_actor.has_method("is_upgrade_enabled") and bool(active_actor.is_upgrade_enabled(upgrade_id)) + var points := int(RunDirector.get_state().get("skill_points", 0)) if RunDirector != null else 0 + + var button := Button.new() + button.text = "%s%s" % [upgrade_label, _locale_text(" [Learned]", " [已解锁]", " [已解鎖]") if learned else ""] + button.tooltip_text = upgrade_description + button.disabled = learned or points <= 0 + button.custom_minimum_size = Vector2(0.0, 42.0) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UISkin.button_styles(button, "thin") + button.pressed.connect(_on_upgrade_pressed.bind(upgrade_id)) + row.add_child(button) + upgrade_buttons.append(button) + + var description_label := Label.new() + description_label.text = upgrade_description + description_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UISkin.label(description_label, 12, Color(0.78, 0.84, 0.92)) + row.add_child(description_label) + + return row + +func _clear_entries() -> void: + upgrade_buttons.clear() + if list_root == null: + return + for child in list_root.get_children(): + child.queue_free() + +func _refresh_header() -> void: + if points_label == null: + return + var state := RunDirector.get_state() if RunDirector != null else {} + var points := int(state.get("skill_points", 0)) + var max_points := int(state.get("max_skill_points", 6)) + points_label.text = _locale_text( + "Skill Points: %d / %d", + "技能点:%d / %d", + "技能點:%d / %d" + ) % [points, max_points] + if open_hint_label != null: + open_hint_label.text = _locale_text( + "Hotkey: U", + "快捷键:U", + "快捷鍵:U" + ) + +func _on_upgrade_pressed(upgrade_id: String) -> void: + if upgrade_id.is_empty(): + return + if active_actor == null or not is_instance_valid(active_actor): + return + if not active_actor.has_method("is_upgrade_enabled") or not active_actor.has_method("set_upgrade_enabled"): + return + if bool(active_actor.is_upgrade_enabled(upgrade_id)): + return + if RunDirector == null or not RunDirector.can_spend_skill_point(1): + return + active_actor.set_upgrade_enabled(upgrade_id, true) + if not RunDirector.spend_skill_point(1): + active_actor.set_upgrade_enabled(upgrade_id, false) + return + upgrade_purchased.emit(upgrade_id) + _refresh_content() + +func _on_run_state_changed(_state: Dictionary) -> void: + _refresh_header() + if visible: + _refresh_content() + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ESCAPE: + pause_requested.emit() + get_viewport().set_input_as_handled() + +func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) -> String: + if UISettings != null and UISettings.has_method("get_locale"): + match String(UISettings.get_locale()): + "zh_Hant": + return zh_hant_text + "zh_Hans": + return zh_hans_text + return en_text diff --git a/ui/skill_tree_panel.gd.uid b/ui/skill_tree_panel.gd.uid new file mode 100644 index 0000000..707c578 --- /dev/null +++ b/ui/skill_tree_panel.gd.uid @@ -0,0 +1 @@ +uid://dofopmy7vk72d diff --git a/world.gd b/world.gd index 1036c06..808c605 100644 --- a/world.gd +++ b/world.gd @@ -12,6 +12,7 @@ const WORLD_HEALTH_BAR_SCRIPT := preload("res://ui/world_health_bar.gd") const INVENTORY_PANEL_SCRIPT := preload("res://ui/inventory_panel.gd") const CONSUMABLE_BAR_SCRIPT := preload("res://ui/consumable_bar.gd") const STAGE_REWARD_PANEL_SCRIPT := preload("res://ui/stage_reward_panel.gd") +const SKILL_TREE_PANEL_SCRIPT := preload("res://ui/skill_tree_panel.gd") const LINEAGE_HUD_SCRIPT := preload("res://ui/lineage_hud.gd") const HEIR_SELECT_PANEL_SCRIPT := preload("res://ui/heir_select_panel.gd") const CROWN_DROP_TEXTURE_PATH := "res://assets/effects/pickups/crown_drop_cutout.png" @@ -106,6 +107,7 @@ var door_transition_backdrop: ColorRect = null var inventory_panel: CanvasLayer = null var consumable_bar: CanvasLayer = null var stage_reward_panel: CanvasLayer = null +var skill_tree_panel: CanvasLayer = null var lineage_hud: CanvasLayer = null var heir_select_panel: CanvasLayer = null var pending_lineage_respawn: Dictionary = {} @@ -142,6 +144,7 @@ func _ready() -> void: _build_inventory_panel() _build_consumable_bar() _build_stage_reward_panel() + _build_skill_tree_panel() _build_lineage_hud() _build_heir_select_panel() if get_tree() != null and not get_tree().node_added.is_connected(_on_tree_node_added): @@ -165,6 +168,8 @@ func _ready() -> void: stage_reward_panel.reward_chosen.connect(_on_stage_reward_chosen) if stage_reward_panel.has_signal("pause_requested"): stage_reward_panel.pause_requested.connect(_on_stage_reward_pause_requested) + if skill_tree_panel != null and skill_tree_panel.has_signal("pause_requested"): + skill_tree_panel.pause_requested.connect(_on_skill_tree_pause_requested) if pause_menu != null: if pause_menu.has_method("bind_world"): pause_menu.bind_world(self) @@ -350,6 +355,11 @@ func _build_stage_reward_panel() -> void: stage_reward_panel.name = "StageRewardPanel" add_child(stage_reward_panel) +func _build_skill_tree_panel() -> void: + skill_tree_panel = SKILL_TREE_PANEL_SCRIPT.new() + skill_tree_panel.name = "SkillTreePanel" + add_child(skill_tree_panel) + func _build_lineage_hud() -> void: lineage_hud = LINEAGE_HUD_SCRIPT.new() lineage_hud.name = "LineageHud" @@ -491,6 +501,10 @@ func _unhandled_input(event: InputEvent) -> void: _toggle_inventory_panel() get_viewport().set_input_as_handled() return + if event.keycode == KEY_U: + _toggle_skill_tree_panel() + get_viewport().set_input_as_handled() + return if event.keycode >= KEY_1 and event.keycode < KEY_1 + (ConsumableManager.MAX_SLOTS if ConsumableManager != null else 4): var used := ConsumableManager.use_slot(event.keycode - KEY_1, player_character) if ConsumableManager != null else false if used: @@ -559,6 +573,10 @@ func _on_character_selected(character_id: StringName) -> void: _update_screen_layers() if character_hud != null and character_hud.has_method("bind_character"): character_hud.bind_character(player_character) + if character_hud != null and character_hud.has_signal("skill_tree_requested") and not character_hud.skill_tree_requested.is_connected(_on_skill_tree_requested): + character_hud.skill_tree_requested.connect(_on_skill_tree_requested) + if skill_tree_panel != null and skill_tree_panel.has_method("bind_actor"): + skill_tree_panel.bind_actor(player_character) AccessoryManager.apply_to_actor(player_character) RunEffects.refresh_persistent_modifiers(player_character) LineageDirector.apply_aptitude_to_actor(player_character) @@ -1282,6 +1300,7 @@ func _begin_crown_choice(ending_kind: String) -> void: if character_select != null: character_select.visible = false _update_screen_layers() + _show_victory_result(ending_kind) func _process_crown_choice(delta: float) -> void: if not crown_awaiting_choice: @@ -1541,6 +1560,7 @@ func _on_encounter_actor_defeated(actor: Node) -> void: var levels_gained := int(xp_result.get("levels_gained", 0)) if levels_gained > 0: _apply_level_up_rewards(player_character, levels_gained) + _update_skill_tree_panel_binding() var level_value := int(xp_result.get("current_level", 1)) _spawn_world_text( player_character.global_position + Vector2(0.0, -54.0), @@ -1553,6 +1573,8 @@ func _on_encounter_actor_defeated(actor: Node) -> void: LEVEL_UP_FLASH_COLOR, 1.08 ) + if xp_amount > 0 and int(RunDirector.get_state().get("skill_points", 0)) > 0: + _open_skill_tree_if_available() _spawn_reward_pickups(reward_position, reward.get("drops", []) as Array) func _build_defeat_rewards(actor: Node) -> Dictionary: @@ -2267,30 +2289,6 @@ func _encounter_threat_hint(encounter: Node) -> String: "盯住他的技能冷却,把位移留给跃击和直线裁决。", "盯住他的技能冷卻,把位移留給躍擊和直線裁決。" ) - if script_path.ends_with("royal_guard_formation.gd"): - var coverage := float(encounter.get("coverage_progress")) if _has_property(encounter, "coverage_progress") else 0.0 - var guard_count := 0 - if _has_property(encounter, "all_guards"): - var guards_value: Variant = encounter.get("all_guards") - if guards_value is Array: - guard_count = (guards_value as Array).size() - if coverage < 1.0: - return _ui_text( - "The formation stays immune until coverage fills. Survive and isolate exposed guards.", - "覆盖条填满前阵列都处于免疫,先活下来并单抓露头的近卫。", - "覆蓋條填滿前陣列都處於免疫,先活下來並單抓露頭的近衛。" - ) - if guard_count > 2: - return _ui_text( - "Coverage is broken. Collapse the mobile guards before the crossfire settles.", - "覆盖已被打破,在交叉火力重新站稳前先收掉机动近卫。", - "覆蓋已被打破,在交叉火力重新站穩前先收掉機動近衛。" - ) - return _ui_text( - "The formation is vulnerable. Clean up the remaining guards quickly.", - "阵列已经可破,尽快清掉剩余近卫。", - "陣列已經可破,盡快清掉剩餘近衛。" - ) if script_path.ends_with("twin_princes_boss.gd"): var phase := int(encounter.get("current_phase")) if _has_property(encounter, "current_phase") else 1 var state_name := String(encounter.get("state")) if _has_property(encounter, "state") else "" @@ -2520,6 +2518,31 @@ func _on_battle_status_debug_requested() -> void: return debug_panel.toggle() +func _on_skill_tree_pause_requested() -> void: + if skill_tree_panel != null and skill_tree_panel.has_method("close") and bool(skill_tree_panel.visible): + skill_tree_panel.close() + +func _on_skill_tree_requested() -> void: + _toggle_skill_tree_panel() + +func _update_skill_tree_panel_binding() -> void: + if skill_tree_panel == null or not skill_tree_panel.has_method("bind_actor"): + return + skill_tree_panel.bind_actor(player_character) + +func _toggle_skill_tree_panel() -> void: + if skill_tree_panel == null or not skill_tree_panel.has_method("toggle"): + return + _update_skill_tree_panel_binding() + skill_tree_panel.toggle() + +func _open_skill_tree_if_available() -> void: + if skill_tree_panel == null or not skill_tree_panel.has_method("has_available_upgrades"): + return + _update_skill_tree_panel_binding() + if bool(skill_tree_panel.has_available_upgrades()): + skill_tree_panel.open() + func _on_audio_settings_panel_closed() -> void: if return_pause_after_audio_panel: return_pause_after_audio_panel = false @@ -2581,6 +2604,10 @@ func _respawn_lineage_heir() -> void: _update_screen_layers() if character_hud != null and character_hud.has_method("bind_character"): character_hud.bind_character(player_character) + if character_hud != null and character_hud.has_signal("skill_tree_requested") and not character_hud.skill_tree_requested.is_connected(_on_skill_tree_requested): + character_hud.skill_tree_requested.connect(_on_skill_tree_requested) + if skill_tree_panel != null and skill_tree_panel.has_method("bind_actor"): + skill_tree_panel.bind_actor(player_character) AccessoryManager.reset_run() if ConsumableManager != null: ConsumableManager.reset_run() @@ -2614,6 +2641,8 @@ func _reset_to_character_select() -> void: if player_character != null and is_instance_valid(player_character): player_character.queue_free() player_character = null + if skill_tree_panel != null and skill_tree_panel.has_method("bind_actor"): + skill_tree_panel.bind_actor(null) encounter_index = -1 waiting_for_accessory_choice = false active_accessory_reason = ""