diff --git a/actors/bosses/town/emperor_boss.gd b/actors/bosses/town/emperor_boss.gd index 3043b35..ecc365c 100644 --- a/actors/bosses/town/emperor_boss.gd +++ b/actors/bosses/town/emperor_boss.gd @@ -8,6 +8,7 @@ const MELEE_UTILS := preload("res://combat/melee_utils.gd") const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") const EMPEROR_BODY_TEXTURE_PATH := "res://actors/bosses/textures/emperor_boss.png" const EMPEROR_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon_emperor_sword.png" +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 @@ -54,6 +55,7 @@ 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 @@ -64,6 +66,7 @@ func _ready() -> void: health_component.died.connect(_on_died) hp = max_hp visual_last_position = global_position + melee_texture = TEXTURE_LOADER.load_texture(MELEE_EFFECT_TEXTURE_PATH) _setup_body_visual() _setup_weapon_visual() burst_ring.visible = false @@ -309,6 +312,20 @@ func _hit_target_in_arc(radius: float, damage: float, arc_degrees: float) -> voi }) func _spawn_slash_effect(radius: float, color: Color) -> void: + if melee_texture != null: + var texture_slash := Sprite2D.new() + texture_slash.texture = melee_texture + texture_slash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture_slash.centered = true + texture_slash.modulate = color + texture_slash.position = line_direction * (radius * 0.42) + texture_slash.rotation = line_direction.angle() + texture_slash.scale = Vector2(0.24, 0.10) + effects_layer.add_child(texture_slash) + var texture_tween := texture_slash.create_tween() + texture_tween.tween_property(texture_slash, "scale", Vector2(0.34, 0.16), 0.14) + texture_tween.parallel().tween_property(texture_slash, "modulate:a", 0.0, 0.14) + texture_tween.finished.connect(texture_slash.queue_free) var slash := Line2D.new() slash.width = 12.0 slash.default_color = color diff --git a/actors/bosses/town/ranger_boss.gd b/actors/bosses/town/ranger_boss.gd index dceef0e..3801cf4 100644 --- a/actors/bosses/town/ranger_boss.gd +++ b/actors/bosses/town/ranger_boss.gd @@ -8,6 +8,7 @@ const MELEE_UTILS := preload("res://combat/melee_utils.gd") const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") const RANGER_BODY_TEXTURE_PATH := "res://actors/bosses/textures/ranger_boss.png" const RANGER_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon_ranger_blade.png" +const MELEE_EFFECT_TEXTURE_PATH := "res://assets/effects/vfx/magic_circle.webp" @export var max_hp: float = 3600.0 @export var defense_value: float = 190.0 @@ -71,6 +72,7 @@ var slow_factor: float = 1.0 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 @@ -82,6 +84,7 @@ func _ready() -> void: health_component.died.connect(_on_died) hp = max_hp 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 @@ -412,6 +415,20 @@ func _spawn_piercing_arrow(direction: Vector2, damage: float) -> void: arrow.setup(self, direction, damage, 0.1) func _spawn_slash_effect(radius: float, color: Color) -> void: + if melee_texture != null: + var texture_slash := Sprite2D.new() + texture_slash.texture = melee_texture + texture_slash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture_slash.centered = true + texture_slash.modulate = color + texture_slash.position = line_direction * (radius * 0.42) + texture_slash.rotation = line_direction.angle() + texture_slash.scale = Vector2(0.22, 0.10) + effects_layer.add_child(texture_slash) + var texture_tween := texture_slash.create_tween() + texture_tween.tween_property(texture_slash, "scale", Vector2(0.32, 0.16), 0.12) + texture_tween.parallel().tween_property(texture_slash, "modulate:a", 0.0, 0.12) + texture_tween.finished.connect(texture_slash.queue_free) var slash := Line2D.new() slash.width = 12.0 slash.antialiased = true diff --git a/actors/bosses/town/twin_princes_boss.gd b/actors/bosses/town/twin_princes_boss.gd index 577de29..0567124 100644 --- a/actors/bosses/town/twin_princes_boss.gd +++ b/actors/bosses/town/twin_princes_boss.gd @@ -5,22 +5,50 @@ signal defeated const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") const ROYAL_BOLT_SCENE := preload("res://effects/projectiles/royal_bolt.tscn") const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const MELEE_UTILS := preload("res://combat/melee_utils.gd") const TWIN_PHASE1_BODY_TEXTURE_PATH := "res://actors/bosses/textures/twin_first_boss.png" const TWIN_PHASE2_BODY_TEXTURE_PATH := "res://actors/bosses/textures/twin_second_boss.png" const TWIN_PHASE1_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon_twin_first_sword.png" const TWIN_PHASE2_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss_weapon_twin_second_sword.png" - -@export var phase1_hp: float = 4000.0 -@export var phase2_hp: float = 4000.0 +const MELEE_EFFECT_TEXTURE_PATH := "res://assets/effects/vfx/magic_circle.webp" +const BARRAGE_WAVE_TIMINGS := [0.18, 0.42, 0.68, 0.96] +const BARRAGE_WAVE_OFFSETS := [ + [-10.0, 0.0, 10.0], + [-18.0, -9.0, 0.0, 9.0, 18.0], + [-28.0, -18.0, -8.0, 0.0, 8.0, 18.0, 28.0], + [-36.0, -28.0, -20.0, -12.0, -4.0, 4.0, 12.0, 20.0, 28.0, 36.0] +] + +@export var max_hp_value: float = 5000.0 @export var defense_value: float = 220.0 -@export var move_speed: float = 170.0 -@export var teleport_slash_damage: float = 30.0 -@export var teleport_charge_duration: float = 4.0 -@export var teleport_shock_damage: float = 30.0 -@export var teleport_shock_radius: float = 116.0 -@export var spear_charge_damage: float = 50.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_recover: float = 1.0 +@export var basic_attack_range: float = 90.0 +@export var teleport_interval: float = 5.0 +@export var teleport_charge_duration: float = 1.5 +@export var teleport_landing_radius: float = 132.0 +@export var teleport_landing_damage: float = 50.0 +@export var line_slash_damage: float = 72.0 +@export var line_slash_length: float = 392.0 +@export var line_slash_width: float = 84.0 +@export var line_slash_charge_duration: float = 1.0 @export var barrage_damage: float = 18.0 -@export_range(0.1, 0.9, 0.05) var desperation_threshold_ratio: float = 0.35 +@export var barrage_interval: float = 5.0 +@export var barrage_recover: float = 0.8 +@export var dash_charge_duration: float = 1.5 +@export var dash_duration: float = 0.68 +@export var dash_speed: float = 980.0 +@export var dash_hit_width: float = 44.0 +@export var dash_damage: float = 20.0 +@export var dash_player_root_duration: float = 3.0 +@export var dash_hit_lockout_duration: float = 5.0 +@export var dash_miss_stun_duration: float = 4.0 +@export var dash_miss_damage_multiplier: float = 1.5 +@export var dash_miss_burst_damage: float = 24.0 +@export var dash_miss_burst_radius: float = 120.0 +@export var dash_miss_teleport_delay: float = 1.0 @onready var body: Polygon2D = $Body @onready var spear: Polygon2D = $Spear @@ -33,37 +61,43 @@ const TWIN_PHASE2_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/boss var target: Node2D = null var hp: float = 0.0 -var max_hp: float = 0.0 var current_phase: int = 1 var state: StringName = &"intro" var state_time: float = 0.0 +var recover_duration: float = 0.0 var action_committed: bool = false var invulnerable: bool = false -var teleport_cooldown: float = 0.8 -var spear_cooldown: float = 3.0 -var barrage_cooldown: float = 6.0 +var teleport_cooldown: float = 1.2 +var barrage_cooldown: float = 2.4 +var attack_cooldown: float = 0.8 +var phase_threshold_hp: float = 2500.0 +var normal_attack_counter: int = 0 +var barrage_counter: int = 0 +var barrage_wave_index: int = 0 +var pending_post_stun_teleport: bool = false +var dash_connected: bool = false +var line_direction: Vector2 = Vector2.RIGHT var teleport_target_position: Vector2 = Vector2.ZERO -var charge_direction: Vector2 = Vector2.RIGHT -var charge_start_position: Vector2 = Vector2.ZERO -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 desperation_active: bool = false +var dash_direction: Vector2 = Vector2.RIGHT var body_sprite: Sprite2D = null var spear_sprite: Sprite2D = null -var spear_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 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") - max_hp = phase1_hp - hp = max_hp - health_component.setup(max_hp, defense_value) + hp = max_hp_value + phase_threshold_hp = max_hp_value * 0.5 + health_component.setup(max_hp_value, defense_value) health_component.damaged.connect(_on_damaged) health_component.died.connect(_on_died) visual_last_position = global_position + melee_texture = TEXTURE_LOADER.load_texture(MELEE_EFFECT_TEXTURE_PATH) _setup_body_visual() _setup_weapon_visual() _refresh_phase_visuals() @@ -76,77 +110,40 @@ func bind_player(player: Node2D) -> void: target = player func get_status_title() -> String: - return _locale_text("Grand Prince", "大王子", "大王子") if current_phase == 1 else _locale_text("Saint Prince Yage", "圣裔王子雅格", "聖裔王子雅格") + return "Twin Princes" func get_status_text() -> String: - return _locale_text("Phase %d%s\nHP %d / %d\nState: %s", "阶段 %d%s\n生命 %d / %d\n状态:%s", "階段 %d%s\n生命 %d / %d\n狀態:%s") % [ + return "Phase %d\nHP %d / %d\nState: %s" % [ current_phase, - _locale_text(" DESPERATE", " 殊死", " 殊死") if desperation_active else "", int(round(hp)), - int(round(max_hp)), - _localized_state_name(String(state)) + int(round(max_hp_value)), + String(state) ] -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 _localized_state_name(state_name: String) -> String: - match state_name: - "intro": - return _locale_text("Intro", "登场", "登場") - "idle": - return _locale_text("Idle", "待机", "待機") - "teleport_mark": - return _locale_text("Teleport Mark", "跃迁标记", "躍遷標記") - "teleport_slash": - return _locale_text("Teleport Slash", "跃迁斩", "躍遷斬") - "spear_charge": - return _locale_text("Spear Charge", "枪阵突袭", "槍陣突襲") - "barrage_cast": - return _locale_text("Royal Barrage", "王室弹幕", "王室彈幕") - "phase_change": - return _locale_text("Phase Change", "转阶段", "轉階段") - "recover": - return _locale_text("Recover", "恢复", "恢復") - "dead": - return _locale_text("Defeated", "倒下", "倒下") - _: - return state_name.capitalize() - func _physics_process(delta: float) -> void: if state == &"dead": return if target == null or not is_instance_valid(target): _find_target() _update_status_timers(delta) + attack_cooldown = maxf(attack_cooldown - delta, 0.0) teleport_cooldown = maxf(teleport_cooldown - delta, 0.0) - spear_cooldown = maxf(spear_cooldown - delta, 0.0) barrage_cooldown = maxf(barrage_cooldown - delta, 0.0) state_time += delta - _update_desperation_state() _update_state(delta) _update_visuals() func receive_hit(payload: Dictionary) -> void: if invulnerable or state == &"dead": return - var result: Dictionary = health_component.receive_hit(payload) + var hit_payload := payload.duplicate() + if state == &"self_stunned": + hit_payload["damage"] = float(hit_payload.get("damage", 0.0)) * dash_miss_damage_multiplier + var result: Dictionary = health_component.receive_hit(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))) + _spawn_damage_number(float(result.get("total_damage", final_damage)), bool(result.get("is_critical", false))) func apply_control_effects(payload: Dictionary) -> void: if payload.has("silence_duration"): @@ -158,8 +155,11 @@ func apply_control_effects(payload: Dictionary) -> void: 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 + for candidate in get_tree().get_nodes_in_group("player"): + if candidate is Node2D and is_instance_valid(candidate): + target = candidate as Node2D + return + target = null func _update_status_timers(delta: float) -> void: silenced_time_remaining = maxf(silenced_time_remaining - delta, 0.0) @@ -172,168 +172,259 @@ func _update_status_timers(delta: float) -> void: func _update_state(delta: float) -> void: match state: &"intro": - _process_intro() + if state_time >= 0.8: + state = &"idle" + state_time = 0.0 &"idle": _process_idle(delta) + &"basic_attack": + _process_basic_attack() &"teleport_mark": _process_teleport_mark() - &"teleport_slash": - _process_teleport_slash() - &"spear_charge": - _process_spear_charge(delta) + &"teleport_attack": + _process_teleport_attack() + &"line_slash_charge": + _process_line_slash_charge() + &"line_slash": + _process_line_slash() &"barrage_cast": _process_barrage_cast() + &"dash_charge": + _process_dash_charge() + &"dash_attack": + _process_dash_attack(delta) + &"self_stunned": + _process_self_stunned() &"recover": _process_recover() &"phase_change": _process_phase_change() -func _process_intro() -> void: - if state_time >= 1.0: - _start_teleport_attack() - 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: - spear.rotation = _weapon_guard_rotation(to_target, -42.0) - if current_phase == 2 and _can_use_skills() and barrage_cooldown <= 0.0 and distance >= (120.0 if desperation_active else 150.0): + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() + if normal_attack_counter >= 4 and _can_use_skills(): + _start_line_slash() + return + if current_phase == 2 and barrage_counter >= 3 and _can_use_skills(): + _start_dash_charge() + return + if current_phase == 2 and barrage_cooldown <= 0.0 and _can_use_skills(): _start_barrage() return - if _can_use_skills() and teleport_cooldown <= 0.0: + if teleport_cooldown <= 0.0 and _can_use_skills(): _start_teleport_attack() return - if _can_use_skills() and spear_cooldown <= 0.0 and distance >= 110.0: - _start_spear_charge() + if distance <= basic_attack_range and attack_cooldown <= 0.0: + _start_basic_attack() return - if root_time_remaining <= 0.0 and distance > 80.0: - var direction := to_target.normalized() - var orbit := Vector2(-direction.y, direction.x) * (0.35 if current_phase == 1 else -0.35) - global_position += (direction + orbit).normalized() * move_speed * slow_factor * delta + if root_time_remaining > 0.0: + return + var preferred_distance := basic_attack_range * (1.1 if current_phase == 1 else 1.26) + if distance > preferred_distance: + global_position += line_direction * move_speed * slow_factor * delta + else: + var orbit := Vector2(-line_direction.y, line_direction.x) + if orbit.length_squared() > 0.0001: + var orbit_sign := 1.0 if int(Time.get_ticks_msec() / 260) % 2 == 0 else -1.0 + global_position += orbit.normalized() * move_speed * 0.16 * orbit_sign * slow_factor * delta + +func _start_basic_attack() -> void: + state = &"basic_attack" + 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) + +func _process_basic_attack() -> void: + if not action_committed and state_time >= 0.24: + 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: + _enter_recover(0.18) func _start_teleport_attack() -> void: state = &"teleport_mark" state_time = 0.0 action_committed = false - teleport_cooldown = teleport_charge_duration + (5.0 if current_phase == 1 else (2.45 if desperation_active else 3.2)) - if target != null and is_instance_valid(target): - var side := Vector2(52.0, 0.0) - side.x *= -1.0 if int(Time.get_ticks_msec() / 100) % 2 == 0 else 1.0 - teleport_target_position = target.global_position + side - else: - teleport_target_position = global_position + teleport_cooldown = teleport_interval + teleport_target_position = target.global_position if target != null and is_instance_valid(target) else global_position teleport_marker.visible = true teleport_marker.global_position = teleport_target_position teleport_marker.rotation = 0.0 - _show_intent_text("Blink Slash", Color(1.0, 0.82, 0.68, 1.0), teleport_target_position, 0.86) Sfx.play_event(&"boss_twin_teleport", global_position) + _show_intent_text("Teleport Slash", Color(1.0, 0.78, 0.62, 1.0), teleport_target_position, 0.86) func _process_teleport_mark() -> void: - teleport_marker.rotation += 0.18 - if state_time >= teleport_charge_duration: - state = &"teleport_slash" - state_time = 0.0 - action_committed = false - global_position = teleport_target_position - teleport_marker.visible = false - _spawn_teleport_shockwave(teleport_target_position, teleport_shock_radius) - _hit_teleport_landing_targets() + teleport_marker.rotation += 0.14 + if state_time < teleport_charge_duration: + return + state = &"teleport_attack" + state_time = 0.0 + action_committed = false + global_position = teleport_target_position + teleport_marker.visible = false + if target != null and is_instance_valid(target): + var to_target := target.global_position - global_position + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() -func _process_teleport_slash() -> void: +func _process_teleport_attack() -> void: if not action_committed and state_time >= 0.08: action_committed = true - if state_time >= 0.28: - _enter_recover(0.4 if current_phase == 1 else (0.16 if desperation_active else 0.24)) - -func _start_spear_charge() -> void: - state = &"spear_charge" + _hit_targets_in_radius(global_position, teleport_landing_radius, teleport_landing_damage) + _spawn_radius_burst(teleport_landing_radius, Color(1.0, 0.78, 0.58, 0.86)) + _spawn_melee_slash_effect(basic_attack_range + 14.0, Color(1.0, 0.72, 0.56, 0.94)) + normal_attack_counter += 1 + if state_time >= 0.38: + _enter_recover(0.18) + +func _start_line_slash() -> void: + state = &"line_slash_charge" state_time = 0.0 action_committed = false - spear_cooldown = 4.8 if desperation_active else 6.0 - charge_start_position = global_position - charge_direction = (target.global_position - global_position).normalized() if target != null else Vector2.RIGHT - if charge_direction == Vector2.ZERO: - charge_direction = Vector2.RIGHT + normal_attack_counter = 0 + if target != null and is_instance_valid(target): + var to_target := target.global_position - global_position + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() charge_line.visible = true charge_line.global_position = global_position - charge_line.rotation = charge_direction.angle() - _show_intent_text("Spear Charge", Color(1.0, 0.76, 0.58, 1.0), global_position, 0.88) + charge_line.rotation = line_direction.angle() + charge_line.points = PackedVector2Array([Vector2.ZERO, Vector2(line_slash_length, 0.0)]) Sfx.play_event(&"boss_twin_charge", global_position) + _show_intent_text("Heavy Cleave", Color(1.0, 0.74, 0.54, 1.0), global_position, 0.9) -func _process_spear_charge(delta: float) -> void: - if state_time < 1.0: - charge_line.visible = true - charge_line.global_position = global_position +func _process_line_slash_charge() -> void: + charge_line.visible = true + charge_line.global_position = global_position + charge_line.rotation = line_direction.angle() + if state_time < line_slash_charge_duration: return - charge_line.visible = false - if state_time < 1.35: - global_position += charge_direction * 480.0 * slow_factor * delta - if not action_committed: - action_committed = true - _hit_target_in_line(spear_charge_damage, 220.0, 38.0) - else: + state = &"line_slash" + state_time = 0.0 + action_committed = false + +func _process_line_slash() -> void: + if not action_committed: + action_committed = true charge_line.visible = false - _enter_recover(2.0) + _hit_target_in_line(line_slash_damage, line_slash_length, line_slash_width) + _spawn_line_impact(line_slash_length, line_slash_width) + if state_time >= 0.36: + _enter_recover(0.34) func _start_barrage() -> void: state = &"barrage_cast" state_time = 0.0 action_committed = false - barrage_cooldown = 5.8 if desperation_active else 8.0 - _show_intent_text("Royal Barrage", Color(1.0, 0.86, 0.62, 1.0), global_position, 0.9) + barrage_wave_index = 0 + barrage_cooldown = barrage_interval + _show_intent_text("Barrage", Color(1.0, 0.84, 0.58, 1.0), global_position, 0.82) Sfx.play_event(&"boss_twin_barrage", global_position) func _process_barrage_cast() -> void: - if not action_committed and state_time >= 0.35: + while barrage_wave_index < BARRAGE_WAVE_TIMINGS.size() and state_time >= float(BARRAGE_WAVE_TIMINGS[barrage_wave_index]): action_committed = true - _fire_barrage() - if state_time >= 0.82: - _enter_recover(0.42 if desperation_active else 0.7) + _fire_barrage_wave(barrage_wave_index) + barrage_wave_index += 1 + if barrage_wave_index >= BARRAGE_WAVE_TIMINGS.size() and state_time >= barrage_recover: + barrage_counter += 1 + _enter_recover(0.16) + +func _start_dash_charge() -> void: + state = &"dash_charge" + state_time = 0.0 + action_committed = false + barrage_counter = 0 + charge_line.visible = true + charge_line.global_position = global_position + charge_line.points = PackedVector2Array([Vector2.ZERO, Vector2(240.0, 0.0)]) + _show_intent_text("Twin Lunge", Color(1.0, 0.7, 0.52, 1.0), global_position, 0.92) + Sfx.play_event(&"boss_twin_charge", global_position, 1.0) + +func _process_dash_charge() -> void: + if target != null and is_instance_valid(target): + var to_target := target.global_position - global_position + if to_target.length_squared() > 0.0001: + line_direction = to_target.normalized() + charge_line.visible = true + charge_line.global_position = global_position + charge_line.rotation = line_direction.angle() + if state_time < dash_charge_duration: + return + dash_direction = line_direction if line_direction.length_squared() > 0.0001 else Vector2.RIGHT + dash_connected = false + charge_line.visible = false + state = &"dash_attack" + state_time = 0.0 + action_committed = false + +func _process_dash_attack(delta: float) -> void: + var start_position := global_position + global_position += dash_direction * dash_speed * slow_factor * delta + if not dash_connected and _player_intersects_segment(start_position, global_position, dash_hit_width): + dash_connected = true + _apply_dash_hit() + _enter_recover(dash_hit_lockout_duration) + return + if state_time >= dash_duration: + _start_self_stun() + +func _start_self_stun() -> void: + state = &"self_stunned" + state_time = 0.0 + action_committed = false + pending_post_stun_teleport = true + _show_intent_text("Staggered", Color(1.0, 0.66, 0.58, 1.0), global_position, 0.82) + +func _process_self_stunned() -> void: + if state_time < dash_miss_stun_duration: + return + if not action_committed: + action_committed = true + _spawn_radius_burst(dash_miss_burst_radius, Color(1.0, 0.72, 0.54, 0.88)) + _hit_target_in_radius(dash_miss_burst_radius, dash_miss_burst_damage) + state_time = 0.0 + return + if state_time >= dash_miss_teleport_delay: + pending_post_stun_teleport = false + _start_teleport_attack() func _process_recover() -> void: - if state_time >= 0.7: + if state_time >= maxf(recover_duration, 0.1): state = &"idle" state_time = 0.0 func _enter_recover(duration: float) -> void: state = &"recover" state_time = 0.0 - if duration > 0.0: - barrage_cooldown = maxf(barrage_cooldown, duration) + recover_duration = duration charge_line.visible = false teleport_marker.visible = false func _process_phase_change() -> void: phase_ring.visible = true phase_ring.rotation += 0.12 - if state_time >= 1.2: + if state_time >= 1.0: invulnerable = false phase_ring.visible = false state = &"idle" state_time = 0.0 + teleport_cooldown = 1.2 + barrage_cooldown = 1.5 + attack_cooldown = 0.8 -func _fire_barrage() -> void: - if target == null or not is_instance_valid(target): - return - var base_direction := (target.global_position - global_position).normalized() - var first_wave := [-18.0, -9.0, 0.0, 9.0, 18.0] - if desperation_active: - first_wave = [-24.0, -16.0, -8.0, 0.0, 8.0, 16.0, 24.0] - _spawn_barrage_wave(base_direction, first_wave, barrage_damage) - if desperation_active: - var timer := get_tree().create_timer(0.18) - timer.timeout.connect(func() -> void: - if is_instance_valid(self) and target != null and is_instance_valid(target): - var second_direction := (target.global_position - global_position).normalized() - _spawn_barrage_wave(second_direction, [-12.0, 0.0, 12.0], barrage_damage * 0.8) - ) - -func _hit_target_in_radius(radius: float, damage: float) -> void: +func _hit_target_in_arc(radius: float, arc_degrees: float, damage: float) -> void: if target == null or not is_instance_valid(target): return - if global_position.distance_to(target.global_position) > radius: + if not MELEE_UTILS.is_point_in_arc(global_position, line_direction, target.global_position, radius, arc_degrees): return target.receive_hit({ "source": self, @@ -345,7 +436,7 @@ func _hit_target_in_line(damage: float, length: float, width: float) -> void: if target == null or not is_instance_valid(target): return var start_position := global_position - var end_position := global_position + charge_direction * length + var end_position := global_position + line_direction * length if _distance_to_segment(target.global_position, start_position, end_position) > width: return target.receive_hit({ @@ -354,23 +445,46 @@ func _hit_target_in_line(damage: float, length: float, width: float) -> void: "crit_rate": 0.0 }) -func _hit_teleport_landing_targets() -> void: +func _hit_target_in_radius(radius: float, damage: float) -> void: + _hit_targets_in_radius(global_position, radius, damage) + +func _hit_targets_in_radius(center: Vector2, radius: float, damage: float) -> void: + for candidate in get_tree().get_nodes_in_group("damageable"): + 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 _player_intersects_segment(start_position: Vector2, end_position: Vector2, width: float) -> bool: + if target == null or not is_instance_valid(target): + return false + return _distance_to_segment(target.global_position, start_position, end_position) <= width + +func _apply_dash_hit() -> void: if target == null or not is_instance_valid(target): return - var distance := global_position.distance_to(target.global_position) - var damage := 0.0 - if distance <= teleport_shock_radius: - damage += teleport_shock_damage - if distance <= 84.0: - damage += teleport_slash_damage - if damage <= 0.0: - return - action_committed = true - target.receive_hit({ - "source": self, - "damage": damage, - "crit_rate": 0.0 - }) + if target.has_node("HealthComponent"): + var target_health: Node = target.get_node("HealthComponent") + if target_health != null: + if target_health.has_method("clear_shield"): + target_health.clear_shield() + target_health.set("hp", 1.0) + target.set("hp", 1.0) + if target.has_signal("hp_changed"): + target.hp_changed.emit(1.0, float(target.get("max_hp"))) + if target.has_method("apply_control_effects"): + target.apply_control_effects({ + "root_duration": dash_player_root_duration, + "silence_duration": dash_player_root_duration + }) func _distance_to_segment(point: Vector2, start_position: Vector2, end_position: Vector2) -> float: var segment := end_position - start_position @@ -384,18 +498,25 @@ func _distance_to_segment(point: Vector2, start_position: Vector2, end_position: func _can_use_skills() -> bool: return silenced_time_remaining <= 0.0 -func _update_desperation_state() -> void: - if desperation_active or current_phase != 2 or hp > max_hp * desperation_threshold_ratio: +func _fire_barrage_wave(wave_index: int) -> void: + if target == null or not is_instance_valid(target): return - desperation_active = true - move_speed *= 1.08 - teleport_cooldown = minf(teleport_cooldown, 1.0) - spear_cooldown = minf(spear_cooldown, 1.5) - barrage_cooldown = minf(barrage_cooldown, 1.6) - phase_ring.visible = true - phase_ring.rotation = 0.0 - _show_intent_text("Desperate", Color(1.0, 0.66, 0.50, 1.0), global_position, 0.94) - Sfx.play_event(&"boss_twin_barrage", global_position, 2.0) + var base_direction := (target.global_position - projectile_spawner.global_position).normalized() + if base_direction == Vector2.ZERO: + base_direction = Vector2.RIGHT + var wave_offsets: Array = BARRAGE_WAVE_OFFSETS[min(wave_index, BARRAGE_WAVE_OFFSETS.size() - 1)] + _spawn_barrage_wave(base_direction, wave_offsets, barrage_damage * (1.0 + 0.08 * float(wave_index))) + +func _spawn_barrage_wave(base_direction: Vector2, angles: Array, damage: float) -> void: + var payload := { + "slow_duration": 0.9, + "slow_multiplier": 0.78 + } + for angle_offset in angles: + 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(float(angle_offset))), damage, payload) func _setup_body_visual() -> void: body_sprite = Sprite2D.new() @@ -405,12 +526,6 @@ func _setup_body_visual() -> void: body.add_child(body_sprite) body.color = Color(1.0, 1.0, 1.0, 0.0) -func _set_body_tint(color: Color) -> void: - if body_sprite != null: - body_sprite.self_modulate = color - else: - body.color = color - func _setup_weapon_visual() -> void: spear_sprite = Sprite2D.new() spear_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST @@ -427,33 +542,30 @@ func _refresh_phase_visuals() -> void: spear_sprite.texture = TEXTURE_LOADER.load_texture(TWIN_PHASE1_WEAPON_TEXTURE_PATH if current_phase == 1 else TWIN_PHASE2_WEAPON_TEXTURE_PATH) func _update_visuals() -> void: - var base_color := Color(0.84, 0.82, 0.88, 1.0) if current_phase == 1 else Color(0.95, 0.76, 0.58, 1.0) - if silenced_time_remaining > 0.0: - base_color = Color(0.72, 0.64, 0.92, 1.0) - elif desperation_active: - base_color = Color(1.0, 0.62, 0.46, 1.0) + var base_color := Color(0.84, 0.82, 0.88, 1.0) if current_phase == 1 else Color(0.96, 0.76, 0.58, 1.0) + if state == &"self_stunned": + base_color = Color(1.0, 0.62, 0.62, 1.0) + elif silenced_time_remaining > 0.0: + base_color = Color(0.74, 0.66, 0.92, 1.0) _set_body_tint(base_color) - phase_ring.default_color = Color(1.0, 0.64, 0.42, 0.9) if desperation_active else (Color(1.0, 0.82, 0.56, 0.85) if current_phase == 2 else Color(0.82, 0.86, 1.0, 0.8)) - var aim_direction := charge_direction if charge_direction != Vector2.ZERO else Vector2.RIGHT + var aim_direction := line_direction if line_direction.length_squared() > 0.0001 else Vector2.RIGHT if target != null and is_instance_valid(target): var to_target := target.global_position - global_position - if to_target != Vector2.ZERO: + if to_target.length_squared() > 0.0001 and state != &"dash_attack": aim_direction = to_target.normalized() spear.position = aim_direction * 20.0 + Vector2(0.0, 4.0) - spear.rotation = _weapon_guard_rotation(aim_direction, -42.0) + spear_angle_offset + spear.rotation = _weapon_guard_rotation(aim_direction, -42.0) _apply_prince_body_motion(aim_direction) var pulse := 0.82 + 0.18 * sin(Time.get_ticks_msec() * 0.01) if teleport_marker.visible: teleport_marker.scale = Vector2.ONE * (0.92 + 0.08 * pulse) teleport_marker.width = 3.0 + 0.8 * pulse - teleport_marker.modulate = Color(1.0, 1.0, 1.0, 0.76 + 0.14 * pulse) if charge_line.visible: - charge_line.width = 3.6 + 1.0 * pulse - charge_line.modulate = Color(1.0, 1.0, 1.0, 0.74 + 0.16 * pulse) + charge_line.width = 5.0 + 1.2 * pulse + charge_line.modulate = Color(1.0, 1.0, 1.0, 0.74 + 0.12 * pulse) if phase_ring.visible: phase_ring.scale = Vector2.ONE * (0.94 + 0.08 * pulse) phase_ring.width = 3.4 + 1.0 * pulse - phase_ring.modulate = Color(1.0, 1.0, 1.0, 0.74 + 0.16 * pulse) visual_last_position = global_position func _weapon_guard_rotation(direction: Vector2, guard_degrees: float) -> float: @@ -464,13 +576,19 @@ func _weapon_guard_rotation(direction: Vector2, guard_degrees: float) -> float: func _apply_prince_body_motion(aim_direction: Vector2) -> void: var movement := global_position - visual_last_position var motion_ratio := clampf(movement.length() / maxf(move_speed * get_physics_process_delta_time(), 1.0), 0.0, 1.0) - visual_bob_time += 0.09 + motion_ratio * 0.13 + visual_bob_time += 0.08 + motion_ratio * 0.14 var facing := -1.0 if aim_direction.x < -0.05 else 1.0 if body_sprite != null: body_sprite.flip_h = facing < 0.0 - body.position = Vector2(0.0, sin(visual_bob_time) * (1.0 + motion_ratio * 2.6)) + body.position = Vector2(0.0, sin(visual_bob_time) * (1.0 + motion_ratio * 2.5)) body.rotation = sin(visual_bob_time * 0.72) * (0.018 + motion_ratio * 0.04) * facing +func _set_body_tint(color: Color) -> void: + if body_sprite != null: + body_sprite.self_modulate = color + else: + body.color = color + func _spawn_damage_number(amount: float, is_critical: bool) -> void: var damage_number := DAMAGE_NUMBER_SCENE.instantiate() damage_number.position = Vector2(0.0, -44.0) @@ -479,24 +597,83 @@ 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(-42.0, -56.0) + popup.position = to_local(world_position) + Vector2(-40.0, -56.0) if popup.has_method("setup_text"): popup.setup_text(label_text, color_value, scale_value) effects_layer.add_child(popup) -func _spawn_teleport_shockwave(center: Vector2, radius: float) -> void: +func _spawn_melee_slash_effect(radius: float, color: Color) -> void: + if effects_layer == null: + return + if melee_texture != null: + var texture_slash := Sprite2D.new() + texture_slash.texture = melee_texture + texture_slash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture_slash.centered = true + texture_slash.modulate = color + texture_slash.position = line_direction * (radius * 0.42) + texture_slash.rotation = line_direction.angle() + texture_slash.scale = Vector2(0.22, 0.1) + effects_layer.add_child(texture_slash) + var texture_tween := texture_slash.create_tween() + texture_tween.tween_property(texture_slash, "scale", Vector2(0.32, 0.16), 0.14) + texture_tween.parallel().tween_property(texture_slash, "modulate:a", 0.0, 0.14) + texture_tween.finished.connect(texture_slash.queue_free) + var slash := Line2D.new() + slash.width = 12.0 + slash.default_color = color + slash.position = line_direction * 14.0 + slash.rotation = line_direction.angle() + slash.points = PackedVector2Array([ + Vector2(-8.0, -22.0), + Vector2(radius * 0.3, -radius * 0.16), + Vector2(radius * 0.78, 0.0), + Vector2(radius * 0.3, radius * 0.16), + Vector2(-8.0, 22.0) + ]) + effects_layer.add_child(slash) + var tween := create_tween() + tween.tween_property(slash, "modulate:a", 0.0, 0.16) + tween.parallel().tween_property(slash, "scale", Vector2.ONE * 1.1, 0.16) + tween.finished.connect(slash.queue_free) + +func _spawn_line_impact(length: float, width: float) -> void: + var slash := Polygon2D.new() + slash.color = Color(1.0, 0.78, 0.54, 0.9) + slash.position = line_direction * (length * 0.18) + slash.rotation = line_direction.angle() + slash.polygon = PackedVector2Array([ + Vector2(-10.0, -width * 0.5), + Vector2(length, -width * 0.5), + Vector2(length, width * 0.5), + Vector2(-10.0, width * 0.5) + ]) + effects_layer.add_child(slash) + var tween := create_tween() + tween.tween_property(slash, "modulate:a", 0.0, 0.18) + tween.parallel().tween_property(slash, "scale", Vector2(1.08, 1.04), 0.18) + tween.finished.connect(slash.queue_free) + +func _spawn_radius_burst(radius: float, color: Color) -> void: var ring := Line2D.new() - ring.width = 5.0 + ring.width = 6.0 ring.closed = true - ring.default_color = Color(1.0, 0.74, 0.48, 0.9) - ring.points = _build_ring_points(radius, 20) - ring.global_position = center + ring.default_color = color + ring.points = _build_ring_points(radius, 24) + ring.global_position = global_position get_tree().current_scene.add_child(ring) var tween := create_tween() - tween.tween_property(ring, "scale", Vector2.ONE * 1.16, 0.18) + 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 _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 _on_damaged(_amount: float, remaining_hp: float, _source: Node) -> void: hp = remaining_hp _set_body_tint(Color(1.0, 0.8, 0.8, 1.0)) @@ -505,23 +682,21 @@ func _on_damaged(_amount: float, remaining_hp: float, _source: Node) -> void: if is_instance_valid(self) and state != &"dead": _update_visuals() ) - -func _on_died() -> void: - if current_phase == 1: - _show_intent_text("Phase Two", Color(1.0, 0.88, 0.62, 1.0), global_position, 0.94) + if current_phase == 1 and hp > 0.0 and hp <= phase_threshold_hp: current_phase = 2 - desperation_active = false - max_hp = phase2_hp - hp = max_hp - _refresh_phase_visuals() - invulnerable = true - teleport_cooldown = 1.0 - spear_cooldown = 2.4 - barrage_cooldown = 3.2 state = &"phase_change" state_time = 0.0 - health_component.setup(max_hp, defense_value) - return + action_committed = false + invulnerable = true + normal_attack_counter = 0 + barrage_counter = 0 + teleport_marker.visible = false + charge_line.visible = false + phase_ring.visible = true + _refresh_phase_visuals() + _show_intent_text("Phase Two", Color(1.0, 0.88, 0.62, 1.0), global_position, 0.94) + +func _on_died() -> void: state = &"dead" invulnerable = true teleport_marker.visible = false @@ -534,21 +709,3 @@ func _on_died() -> void: defeated.emit() 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_barrage_wave(base_direction: Vector2, angles: Array, damage: float) -> void: - var payload := { - "slow_duration": 0.9 if not desperation_active else 1.15, - "slow_multiplier": 0.78 if not desperation_active else 0.72 - } - for angle_offset in angles: - 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(float(angle_offset))), damage, payload) diff --git a/actors/enemy/town_enemy.gd b/actors/enemy/town_enemy.gd index 92c1d82..6f210d7 100644 --- a/actors/enemy/town_enemy.gd +++ b/actors/enemy/town_enemy.gd @@ -4,6 +4,10 @@ signal defeated const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") const ENEMY_BOLT_SCENE := preload("res://effects/projectiles/enemy_bolt.tscn") +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const LASER_BODY_TEXTURE_PATH := "res://assets/effects/vfx/laser_body.webp" +const LASER_CORE_TEXTURE_PATH := "res://assets/effects/vfx/laser_core.webp" +const SLASH_TEXTURE_PATH := "res://assets/effects/vfx/magic_circle.webp" const ENEMY_WEAPON_TEXTURE_PATHS := [ [ "res://art/final_materials/weapons/enemy_sword_guard_sword_normal.png", @@ -107,6 +111,9 @@ var base_weapon_position: Vector2 = Vector2.ZERO var base_projectile_spawner_position: Vector2 = Vector2.ZERO var visual_bob_time: float = 0.0 var death_sequence_started: bool = false +var laser_effect_root: Node2D = null +var laser_body_sprite: Sprite2D = null +var laser_core_sprite: Sprite2D = null func _ready() -> void: movement_rng.randomize() @@ -125,6 +132,7 @@ func _ready() -> void: hp = max_hp telegraph_ring.visible = false telegraph_line.visible = false + _setup_effect_sprites() _find_target() _update_visuals() @@ -545,6 +553,8 @@ func _enter_recover(duration: float) -> void: recover_duration = duration velocity = Vector2.ZERO telegraph_ring.visible = false + telegraph_line.visible = false + _set_laser_visual_visible(false) func _process_recover() -> void: velocity = Vector2.ZERO @@ -558,6 +568,16 @@ func _can_use_skills() -> bool: func _spawn_melee_attack_effect(radius: float, color: Color, spread: float, forward_offset: float, heavy: bool = false) -> void: if effects_layer == null: return + var slash_sprite := Sprite2D.new() + slash_sprite.texture = TEXTURE_LOADER.load_texture(SLASH_TEXTURE_PATH) + slash_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + slash_sprite.centered = true + slash_sprite.modulate = color + slash_sprite.position = line_direction * (forward_offset + radius * 0.42) + slash_sprite.rotation = line_direction.angle() + slash_sprite.scale = Vector2(0.24 if heavy else 0.18, 0.10 if heavy else 0.08) + effects_layer.add_child(slash_sprite) + var slash := Line2D.new() slash.antialiased = false slash.width = 12.0 if heavy else 8.0 @@ -603,6 +623,11 @@ func _spawn_melee_attack_effect(radius: float, color: Color, spread: float, forw spark_tween.parallel().tween_property(spark, "modulate:a", 0.0, 0.12) spark_tween.finished.connect(spark.queue_free) + var slash_sprite_tween := slash_sprite.create_tween() + slash_sprite_tween.tween_property(slash_sprite, "scale", slash_sprite.scale * Vector2(1.3, 1.6), 0.14) + slash_sprite_tween.parallel().tween_property(slash_sprite, "modulate:a", 0.0, 0.14) + slash_sprite_tween.finished.connect(slash_sprite.queue_free) + func _show_intent_text(label_text: String, color_value: Color, scale_value: float = 0.8) -> void: if effects_layer == null: return @@ -643,6 +668,7 @@ func _update_laser_telegraph(length: float = ARCANIST_LASER_LENGTH) -> void: telegraph_line.global_position = global_position telegraph_line.rotation = line_direction.angle() telegraph_line.points = PackedVector2Array([Vector2.ZERO, Vector2(visible_length, 0.0)]) + _update_laser_visuals(visible_length) func _laser_blocked_length(length: float) -> float: var world := get_world_2d() @@ -845,6 +871,7 @@ func _on_died() -> void: set_physics_process(false) telegraph_ring.visible = false telegraph_line.visible = false + _set_laser_visual_visible(false) if has_node("CollisionShape2D"): var collision_shape := $CollisionShape2D as CollisionShape2D if collision_shape != null: @@ -875,6 +902,55 @@ func _on_died() -> void: queue_free() ) +func _setup_effect_sprites() -> void: + if effects_layer == null: + return + laser_effect_root = Node2D.new() + laser_effect_root.name = "LaserEffectRoot" + laser_effect_root.visible = false + effects_layer.add_child(laser_effect_root) + laser_body_sprite = Sprite2D.new() + laser_body_sprite.texture = TEXTURE_LOADER.load_texture(LASER_BODY_TEXTURE_PATH) + laser_body_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + laser_body_sprite.centered = false + laser_body_sprite.visible = false + laser_body_sprite.modulate = Color(1.0, 1.0, 1.0, 0.92) + laser_effect_root.add_child(laser_body_sprite) + laser_core_sprite = Sprite2D.new() + laser_core_sprite.texture = TEXTURE_LOADER.load_texture(LASER_CORE_TEXTURE_PATH) + laser_core_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + laser_core_sprite.centered = true + laser_core_sprite.visible = false + laser_effect_root.add_child(laser_core_sprite) + +func _update_laser_visuals(length: float) -> void: + if laser_body_sprite == null or laser_core_sprite == null: + return + var show_laser := telegraph_line.visible and length > 0.0 + _set_laser_visual_visible(show_laser) + if not show_laser: + return + var texture_size := laser_body_sprite.texture.get_size() if laser_body_sprite.texture != null else Vector2(84.0, 24.0) + var body_scale_x := maxf(length / maxf(texture_size.x, 1.0), 0.01) + var body_scale_y := (1.08 if state == &"skill_laser" else 0.8) * (1.08 if elite else 1.0) + laser_body_sprite.position = Vector2.ZERO + laser_body_sprite.rotation = 0.0 + laser_body_sprite.scale = Vector2(body_scale_x, body_scale_y) + laser_body_sprite.modulate = Color(1.0, 0.8, 0.62, 0.92) if state == &"skill_laser" else Color(1.0, 0.9, 0.78, 0.62) + laser_core_sprite.position = Vector2(0.0, 0.0) + laser_core_sprite.scale = Vector2.ONE * (0.78 if state == &"skill_laser" else 0.62) * (1.12 if elite else 1.0) + laser_core_sprite.modulate = Color(1.0, 0.88, 0.6, 0.94) if state == &"skill_laser" else Color(1.0, 0.94, 0.8, 0.72) + laser_effect_root.position = Vector2.ZERO + laser_effect_root.rotation = line_direction.angle() + +func _set_laser_visual_visible(visible_value: bool) -> void: + if laser_effect_root != null: + laser_effect_root.visible = visible_value + if laser_body_sprite != null: + laser_body_sprite.visible = visible_value + if laser_core_sprite != null: + laser_core_sprite.visible = visible_value + func _spawn_death_burst() -> void: if effects_layer == null: return diff --git a/assets/effects/pickups/experience.webp b/assets/effects/pickups/experience.webp new file mode 100644 index 0000000..d2f2fdb Binary files /dev/null and b/assets/effects/pickups/experience.webp differ diff --git a/assets/effects/pickups/experience.webp.import b/assets/effects/pickups/experience.webp.import new file mode 100644 index 0000000..ed87c08 --- /dev/null +++ b/assets/effects/pickups/experience.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwsjw8ih2y6x0" +path="res://.godot/imported/experience.webp-ab1d9feaed0534f8f19daf8698b94cfa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/pickups/experience.webp" +dest_files=["res://.godot/imported/experience.webp-ab1d9feaed0534f8f19daf8698b94cfa.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/pickups/gold.webp b/assets/effects/pickups/gold.webp new file mode 100644 index 0000000..ac4ed9d Binary files /dev/null and b/assets/effects/pickups/gold.webp differ diff --git a/assets/effects/pickups/gold.webp.import b/assets/effects/pickups/gold.webp.import new file mode 100644 index 0000000..e3328fc --- /dev/null +++ b/assets/effects/pickups/gold.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3tecj2sg51p2" +path="res://.godot/imported/gold.webp-46c1d9a79b97ec4897765369e21f37d5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/pickups/gold.webp" +dest_files=["res://.godot/imported/gold.webp-46c1d9a79b97ec4897765369e21f37d5.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/apprentice_orb.webp b/assets/effects/projectiles/apprentice_orb.webp new file mode 100644 index 0000000..519da41 Binary files /dev/null and b/assets/effects/projectiles/apprentice_orb.webp differ diff --git a/assets/effects/projectiles/apprentice_orb.webp.import b/assets/effects/projectiles/apprentice_orb.webp.import new file mode 100644 index 0000000..755ea6b --- /dev/null +++ b/assets/effects/projectiles/apprentice_orb.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djie3lyfe3tda" +path="res://.godot/imported/apprentice_orb.webp-93af867cbf120263f905a5943f4eb37a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/apprentice_orb.webp" +dest_files=["res://.godot/imported/apprentice_orb.webp-93af867cbf120263f905a5943f4eb37a.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/arcanist_missile.webp b/assets/effects/projectiles/arcanist_missile.webp new file mode 100644 index 0000000..7946b70 Binary files /dev/null and b/assets/effects/projectiles/arcanist_missile.webp differ diff --git a/assets/effects/projectiles/arcanist_missile.webp.import b/assets/effects/projectiles/arcanist_missile.webp.import new file mode 100644 index 0000000..3ae8013 --- /dev/null +++ b/assets/effects/projectiles/arcanist_missile.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxqja4tluqkl0" +path="res://.godot/imported/arcanist_missile.webp-3bbde0c4a71b299c413b339d5050ef96.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/arcanist_missile.webp" +dest_files=["res://.godot/imported/arcanist_missile.webp-3bbde0c4a71b299c413b339d5050ef96.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/arrow.webp b/assets/effects/projectiles/arrow.webp new file mode 100644 index 0000000..636213f Binary files /dev/null and b/assets/effects/projectiles/arrow.webp differ diff --git a/assets/effects/projectiles/arrow.webp.import b/assets/effects/projectiles/arrow.webp.import new file mode 100644 index 0000000..07b6129 --- /dev/null +++ b/assets/effects/projectiles/arrow.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cdx3stshq8bu3" +path="res://.godot/imported/arrow.webp-9242dd6011b1d83a693f34863960dad3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/arrow.webp" +dest_files=["res://.godot/imported/arrow.webp-9242dd6011b1d83a693f34863960dad3.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/basic_bullet.webp b/assets/effects/projectiles/basic_bullet.webp new file mode 100644 index 0000000..3ada7b2 Binary files /dev/null and b/assets/effects/projectiles/basic_bullet.webp differ diff --git a/assets/effects/projectiles/basic_bullet.webp.import b/assets/effects/projectiles/basic_bullet.webp.import new file mode 100644 index 0000000..7fadbea --- /dev/null +++ b/assets/effects/projectiles/basic_bullet.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxjpvyc4cvgsl" +path="res://.godot/imported/basic_bullet.webp-1ec75e1ff79dfea252f5918b811c08a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/basic_bullet.webp" +dest_files=["res://.godot/imported/basic_bullet.webp-1ec75e1ff79dfea252f5918b811c08a1.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/boss_bullet_red.webp b/assets/effects/projectiles/boss_bullet_red.webp new file mode 100644 index 0000000..7fe3f17 Binary files /dev/null and b/assets/effects/projectiles/boss_bullet_red.webp differ diff --git a/assets/effects/projectiles/boss_bullet_red.webp.import b/assets/effects/projectiles/boss_bullet_red.webp.import new file mode 100644 index 0000000..d27b6bd --- /dev/null +++ b/assets/effects/projectiles/boss_bullet_red.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cniu2whspxect" +path="res://.godot/imported/boss_bullet_red.webp-dd0dd7b74af03331baad435d41a9ca73.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/boss_bullet_red.webp" +dest_files=["res://.godot/imported/boss_bullet_red.webp-dd0dd7b74af03331baad435d41a9ca73.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/boss_bullet_yellow.webp b/assets/effects/projectiles/boss_bullet_yellow.webp new file mode 100644 index 0000000..b148364 Binary files /dev/null and b/assets/effects/projectiles/boss_bullet_yellow.webp differ diff --git a/assets/effects/projectiles/boss_bullet_yellow.webp.import b/assets/effects/projectiles/boss_bullet_yellow.webp.import new file mode 100644 index 0000000..0be5dac --- /dev/null +++ b/assets/effects/projectiles/boss_bullet_yellow.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c7rali64y1xjd" +path="res://.godot/imported/boss_bullet_yellow.webp-55cbf61618aeda6aca6abb5cf5fe1df5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/boss_bullet_yellow.webp" +dest_files=["res://.godot/imported/boss_bullet_yellow.webp-55cbf61618aeda6aca6abb5cf5fe1df5.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/player_staff_orb.webp b/assets/effects/projectiles/player_staff_orb.webp new file mode 100644 index 0000000..00394e4 Binary files /dev/null and b/assets/effects/projectiles/player_staff_orb.webp differ diff --git a/assets/effects/projectiles/player_staff_orb.webp.import b/assets/effects/projectiles/player_staff_orb.webp.import new file mode 100644 index 0000000..40be831 --- /dev/null +++ b/assets/effects/projectiles/player_staff_orb.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3xkqnwb6kmsd" +path="res://.godot/imported/player_staff_orb.webp-e3edc7a5a403d6e7a7810ae960b25300.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/player_staff_orb.webp" +dest_files=["res://.godot/imported/player_staff_orb.webp-e3edc7a5a403d6e7a7810ae960b25300.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/staff_orb.webp b/assets/effects/projectiles/staff_orb.webp new file mode 100644 index 0000000..6f0f5fb Binary files /dev/null and b/assets/effects/projectiles/staff_orb.webp differ diff --git a/assets/effects/projectiles/staff_orb.webp.import b/assets/effects/projectiles/staff_orb.webp.import new file mode 100644 index 0000000..9f24ef9 --- /dev/null +++ b/assets/effects/projectiles/staff_orb.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgefm2og8yotn" +path="res://.godot/imported/staff_orb.webp-68de30a3e15c0087a324f5b809a66e64.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/projectiles/staff_orb.webp" +dest_files=["res://.godot/imported/staff_orb.webp-68de30a3e15c0087a324f5b809a66e64.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/vfx/laser_body.webp b/assets/effects/vfx/laser_body.webp new file mode 100644 index 0000000..c88ced3 Binary files /dev/null and b/assets/effects/vfx/laser_body.webp differ diff --git a/assets/effects/vfx/laser_body.webp.import b/assets/effects/vfx/laser_body.webp.import new file mode 100644 index 0000000..1e414ab --- /dev/null +++ b/assets/effects/vfx/laser_body.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://i0oat3dopdxg" +path="res://.godot/imported/laser_body.webp-a24d4c8e3f176fa4750f52357edb0e9d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/vfx/laser_body.webp" +dest_files=["res://.godot/imported/laser_body.webp-a24d4c8e3f176fa4750f52357edb0e9d.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/vfx/laser_core.webp b/assets/effects/vfx/laser_core.webp new file mode 100644 index 0000000..d41894d Binary files /dev/null and b/assets/effects/vfx/laser_core.webp differ diff --git a/assets/effects/vfx/laser_core.webp.import b/assets/effects/vfx/laser_core.webp.import new file mode 100644 index 0000000..1516c28 --- /dev/null +++ b/assets/effects/vfx/laser_core.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0iusoujp21aq" +path="res://.godot/imported/laser_core.webp-7f57b5ebdb9a25408a4cb260c897237f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/vfx/laser_core.webp" +dest_files=["res://.godot/imported/laser_core.webp-7f57b5ebdb9a25408a4cb260c897237f.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/vfx/magic_circle.webp b/assets/effects/vfx/magic_circle.webp new file mode 100644 index 0000000..d455533 Binary files /dev/null and b/assets/effects/vfx/magic_circle.webp differ diff --git a/assets/effects/vfx/magic_circle.webp.import b/assets/effects/vfx/magic_circle.webp.import new file mode 100644 index 0000000..0f47456 --- /dev/null +++ b/assets/effects/vfx/magic_circle.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cebvlsivm5h6m" +path="res://.godot/imported/magic_circle.webp-c99152b8a67d6d5b194a7653601c4d5f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/vfx/magic_circle.webp" +dest_files=["res://.godot/imported/magic_circle.webp-c99152b8a67d6d5b194a7653601c4d5f.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/characters/knight/knight.gd b/characters/knight/knight.gd index 7a8a095..7c73173 100644 --- a/characters/knight/knight.gd +++ b/characters/knight/knight.gd @@ -15,14 +15,14 @@ signal died @export_group("Core Stats") @export var max_hp: float = 100.0 -@export var max_inspiration: float = 20.0 +@export var max_inspiration: float = 30.0 @export var defense: float = 100.0 @export var max_defense: float = 100.0 @export var move_speed: float = 226.0 @export var attack_damage: float = 100.0 @export var attack_interval: float = 0.68 @export_range(0.0, 1.0, 0.01) var crit_rate: float = 0.2 -@export var inspiration_gain_on_attack_hit: float = 2.0 +@export var inspiration_gain_on_attack_hit: float = 5.0 @export_group("Normal Attack Timing") @export var attack_windup: float = 0.24 @@ -37,6 +37,8 @@ signal died @export var dash_duration: float = 0.3 @export var dash_speed: float = 750.0 @export var dash_hit_radius: float = 48.0 +@export var skill1_post_dash_radius: float = 78.0 +@export var skill1_post_dash_damage: float = 90.0 @export var skill1_instant_dash_upgrade: bool = false @export var skill1_armor_break_upgrade: bool = false @export var armor_break_multiplier: float = 1.25 @@ -57,6 +59,9 @@ signal died @export var skill3_cost: float = 20.0 @export var skill3_cooldown: float = 20.0 @export var skill3_duration: float = 0.4 +@export var skill3_move_speed_bonus: float = 0.2 +@export var skill3_attack_speed_bonus: float = 1.0 +@export var skill3_attack_damage_bonus: float = 1.0 @export var buff_duration: float = 15.0 @export var sanctuary_damage_multiplier: float = 1.1 @export var sanctuary_damage_reduction: float = 0.2 @@ -93,6 +98,7 @@ const MELEE_UTILS := preload("res://combat/melee_utils.gd") const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") const KNIGHT_WEAPON_TEXTURE_PATH := "res://art/final_materials/weapons/player_knight_sword_lv3.png" const KNIGHT_DEATH_TEXTURE_PATH := "res://art/final_materials/deaths/player_knight_dead.png" +const SANCTUARY_BACK_TEXTURE_PATH := "C:/Users/Lee/Desktop/kindom/弹幕/技能特效法阵.png" const BASE_SPRITE_SCALE := Vector2(0.85, 0.85) var hp: float = 0.0 @@ -109,10 +115,13 @@ var cooldowns := { } var active_damage_multiplier: float = 1.0 var active_damage_reduction: float = 0.0 +var active_attack_speed_multiplier: float = 0.0 +var active_move_speed_bonus: float = 0.0 var guard_active: bool = false var buff_active: bool = false var guard_time_remaining: float = 0.0 var sanctuary_time_remaining: float = 0.0 +var sanctuary_heal_tick_elapsed: float = 0.0 var queued_skill: StringName = &"" var queued_skill_payload: Dictionary = {} var dash_direction: Vector2 = Vector2.RIGHT @@ -126,6 +135,7 @@ var dodge_active: bool = false var dodge_invincible: bool = false var weapon: Node2D = null var weapon_sprite: Sprite2D = null +var sanctuary_back_sprite: Sprite2D = null var weapon_swing_rotation: float = 0.0 var weapon_swing_tween: Tween = null var silenced_time_remaining: float = 0.0 @@ -148,6 +158,7 @@ func _ready() -> void: add_to_group("player") hurtbox.area_entered.connect(_on_hurtbox_area_entered) _setup_weapon_visual() + _setup_sanctuary_back_visual() _setup_visual_shapes() _build_animations() slash_arc.visible = false @@ -274,6 +285,8 @@ func _find_upgrade_definition(upgrade_id: String) -> Dictionary: func on_attack_landed(attack_name: StringName, target: Node) -> void: gain_inspiration(inspiration_gain_on_attack_hit) + if buff_active and skill3_restore_defense_upgrade: + restore_defense(max_defense * 0.1) AccessoryManager.apply_on_hit_effects(self, attack_name, target) func sync_visuals() -> void: @@ -283,6 +296,8 @@ func sync_visuals() -> void: slash_arc.position = _attack_visual_offset() if slash_arc.visible: slash_arc.rotation = _attack_facing().angle() + if sanctuary_back_sprite != null: + sanctuary_back_sprite.visible = buff_active and hp > 0.0 if hp <= 0.0: modulate = Color(0.35, 0.35, 0.35, 1.0) elif dodge_invincible: @@ -387,7 +402,9 @@ func consume_queued_skill() -> StringName: return skill_name func start_attack() -> void: - cooldowns["attack"] = attack_interval + var attack_speed := get_attack_speed_multiplier() + animation_player.speed_scale = attack_speed + cooldowns["attack"] = attack_interval / attack_speed current_attack_targets.clear() current_attack_name = &"attack" attack_started.emit(current_attack_name) @@ -395,9 +412,10 @@ func start_attack() -> void: slash_arc.position = _attack_visual_offset() slash_arc.rotation = _attack_facing().angle() play_animation(&"attack") - _animate_weapon_swing(-34.0, 20.0, 0.12, 0.18) + _animate_weapon_swing(-34.0, 20.0, get_attack_windup_duration(), get_attack_recovery_duration()) func finish_attack() -> void: + animation_player.speed_scale = 1.0 if current_attack_name != &"": attack_finished.emit(current_attack_name) slash_arc.visible = false @@ -462,6 +480,8 @@ func try_hit_dash_targets_along_segment(start_position: Vector2, end_position: V func finish_dash() -> void: velocity = Vector2.ZERO + var burst_targets: Array[Node] = [] + apply_damage_to_targets(get_scaled_damage(skill1_post_dash_damage), skill1_post_dash_radius, &"skill1_burst", burst_targets) if current_attack_name != &"": attack_finished.emit(current_attack_name) current_attack_name = &"" @@ -500,7 +520,7 @@ func _distance_to_segment(point: Vector2, start_position: Vector2, end_position: return point.distance_to(closest_point) func trigger_normal_attack_hit() -> void: - apply_damage_to_targets_in_arc(attack_damage, attack_range, attack_arc_degrees, &"attack") + apply_damage_to_targets_in_arc(get_scaled_damage(attack_damage), attack_range, attack_arc_degrees, &"attack") _show_melee_slash_effect(attack_range, attack_arc_degrees) _flash_melee_impact() @@ -539,7 +559,7 @@ func trigger_shockwave() -> void: var payload := {} if skill2_knock_up_upgrade: payload["knock_up_duration"] = knock_up_duration - apply_damage_to_targets(skill2_damage, shockwave_radius, &"skill2", shockwave_targets, payload) + apply_damage_to_targets(get_scaled_damage(skill2_damage + 150.0), shockwave_radius, &"skill2", shockwave_targets, payload) if skill2_shield_upgrade: activate_guard() attack_finished.emit(&"skill2") @@ -565,19 +585,21 @@ func end_guard() -> void: shield_changed.emit(shield) func apply_sanctuary() -> void: - if skill3_heal_upgrade: - heal((max_hp - hp) * 0.1) + heal(max_hp * 0.5) if skill3_restore_defense_upgrade: if health_component.has_method("restore_defense_full"): health_component.restore_defense_full() else: health_component.defense = health_component.max_defense health_component.defense_changed.emit(health_component.defense, health_component.max_defense) - active_damage_multiplier = sanctuary_damage_multiplier + active_damage_multiplier = 1.0 + skill3_attack_damage_bonus + active_attack_speed_multiplier = skill3_attack_speed_bonus + active_move_speed_bonus = skill3_move_speed_bonus active_damage_reduction = sanctuary_damage_reduction health_component.set_damage_reduction(active_damage_reduction) buff_active = true sanctuary_time_remaining = buff_duration + sanctuary_heal_tick_elapsed = 0.0 attack_started.emit(&"skill3") _show_sanctuary_activation() attack_finished.emit(&"skill3") @@ -586,10 +608,15 @@ func clear_sanctuary() -> void: if not buff_active and sanctuary_time_remaining <= 0.0: return active_damage_multiplier = 1.0 + active_attack_speed_multiplier = 0.0 + active_move_speed_bonus = 0.0 active_damage_reduction = 0.0 + sanctuary_heal_tick_elapsed = 0.0 health_component.set_damage_reduction(0.0) buff_active = false sanctuary_time_remaining = 0.0 + if sanctuary_back_sprite != null: + sanctuary_back_sprite.visible = false sanctuary_ring.visible = false sanctuary_ring.rotation = 0.0 sanctuary_ring.scale = Vector2.ONE @@ -602,11 +629,32 @@ func cast_skill3() -> void: func heal(amount: float) -> void: health_component.heal(amount) +func restore_defense(amount: float) -> void: + if amount <= 0.0 or hp <= 0.0: + return + var previous_defense: float = float(health_component.defense) + health_component.defense = minf(health_component.defense + amount, health_component.max_defense) + health_component.defense_regen_timer = 0.0 + if not is_equal_approx(previous_defense, health_component.defense): + health_component.defense_changed.emit(health_component.defense, health_component.max_defense) + func get_scaled_damage(base_damage: float) -> float: return base_damage * active_damage_multiplier +func get_attack_speed_multiplier() -> float: + return 1.0 + active_attack_speed_multiplier + +func get_attack_windup_duration() -> float: + return attack_windup / get_attack_speed_multiplier() + +func get_attack_hit_frame_duration() -> float: + return attack_hit_frame / get_attack_speed_multiplier() + +func get_attack_recovery_duration() -> float: + return attack_recovery / get_attack_speed_multiplier() + func get_current_move_speed() -> float: - return move_speed * slow_factor + return move_speed * (1.0 + active_move_speed_bonus) * slow_factor func get_effective_move_speed() -> float: return get_current_move_speed() @@ -781,6 +829,19 @@ func _setup_weapon_visual() -> void: weapon_sprite.position = Vector2(27.0, 0.0) weapon.add_child(weapon_sprite) +func _setup_sanctuary_back_visual() -> void: + sanctuary_back_sprite = Sprite2D.new() + sanctuary_back_sprite.texture = TEXTURE_LOADER.load_texture(SANCTUARY_BACK_TEXTURE_PATH) + sanctuary_back_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sanctuary_back_sprite.centered = true + sanctuary_back_sprite.position = Vector2(0.0, -18.0) + sanctuary_back_sprite.scale = Vector2.ONE * 0.34 + sanctuary_back_sprite.modulate = Color(1.0, 1.0, 1.0, 0.8) + sanctuary_back_sprite.visible = false + sanctuary_back_sprite.z_index = -1 + sanctuary_back_sprite.show_behind_parent = true + add_child(sanctuary_back_sprite) + func _sync_weapon_visual() -> void: if weapon == null: return @@ -915,6 +976,11 @@ func _update_guard(delta: float) -> void: func _update_sanctuary(delta: float) -> void: if not buff_active: return + if skill3_heal_upgrade: + sanctuary_heal_tick_elapsed += delta + while sanctuary_heal_tick_elapsed >= 1.0: + sanctuary_heal_tick_elapsed -= 1.0 + heal(max_hp * 0.05) sanctuary_time_remaining = maxf(sanctuary_time_remaining - delta, 0.0) _spawn_sanctuary_visual(delta) if sanctuary_time_remaining <= 0.0: @@ -922,6 +988,8 @@ func _update_sanctuary(delta: float) -> void: func _spawn_sanctuary_visual(delta: float) -> void: sanctuary_ring.rotation += delta * 2.5 + if sanctuary_back_sprite != null: + sanctuary_back_sprite.rotation += delta * 0.7 func spawn_damage_number(amount: float, is_critical: bool, world_position: Vector2) -> void: var damage_number := DAMAGE_NUMBER_SCENE.instantiate() diff --git a/characters/knight/states/buff_state.gd b/characters/knight/states/buff_state.gd index be68778..8f50f6d 100644 --- a/characters/knight/states/buff_state.gd +++ b/characters/knight/states/buff_state.gd @@ -18,7 +18,8 @@ func enter() -> void: func physics_update(delta: float) -> void: elapsed += delta - actor.velocity = actor.velocity.move_toward(Vector2.ZERO, actor.move_speed * delta * 10.0) + var move_speed: float = float(actor.get_current_move_speed()) if actor.has_method("get_current_move_speed") else float(actor.move_speed) + actor.velocity = actor.velocity.move_toward(Vector2.ZERO, move_speed * delta * 10.0) func evaluate_transitions() -> void: if elapsed >= actor.buff_duration: diff --git a/characters/knight/states/charge_state.gd b/characters/knight/states/charge_state.gd index 3fed2ac..f9705da 100644 --- a/characters/knight/states/charge_state.gd +++ b/characters/knight/states/charge_state.gd @@ -18,7 +18,8 @@ func enter() -> void: func physics_update(delta: float) -> void: elapsed += delta - actor.velocity = actor.velocity.move_toward(Vector2.ZERO, actor.move_speed * delta * 8.0) + var move_speed: float = float(actor.get_current_move_speed()) if actor.has_method("get_current_move_speed") else float(actor.move_speed) + actor.velocity = actor.velocity.move_toward(Vector2.ZERO, move_speed * delta * 8.0) func evaluate_transitions() -> void: if elapsed >= actor.charge_duration: diff --git a/characters/knight/states/guard_state.gd b/characters/knight/states/guard_state.gd index f6061eb..2e011b5 100644 --- a/characters/knight/states/guard_state.gd +++ b/characters/knight/states/guard_state.gd @@ -18,7 +18,8 @@ func enter() -> void: func physics_update(delta: float) -> void: elapsed += delta - actor.velocity = actor.velocity.move_toward(Vector2.ZERO, actor.move_speed * delta * 10.0) + var move_speed: float = float(actor.get_current_move_speed()) if actor.has_method("get_current_move_speed") else float(actor.move_speed) + actor.velocity = actor.velocity.move_toward(Vector2.ZERO, move_speed * delta * 10.0) func evaluate_transitions() -> void: if elapsed >= actor.guard_duration or actor.shield <= 0.0: diff --git a/characters/knight/states/hit_state.gd b/characters/knight/states/hit_state.gd index 0cf814d..b58ddd9 100644 --- a/characters/knight/states/hit_state.gd +++ b/characters/knight/states/hit_state.gd @@ -19,7 +19,8 @@ func enter() -> void: func physics_update(delta: float) -> void: elapsed += delta - actor.velocity = actor.velocity.move_toward(Vector2.ZERO, actor.move_speed * delta * 14.0) + var move_speed: float = float(actor.get_current_move_speed()) if actor.has_method("get_current_move_speed") else float(actor.move_speed) + actor.velocity = actor.velocity.move_toward(Vector2.ZERO, move_speed * delta * 14.0) func evaluate_transitions() -> void: if elapsed >= actor.hit_stun_duration: diff --git a/characters/knight/states/skill_state.gd b/characters/knight/states/skill_state.gd index d921ec1..7d7fbba 100644 --- a/characters/knight/states/skill_state.gd +++ b/characters/knight/states/skill_state.gd @@ -25,7 +25,8 @@ func enter() -> void: func physics_update(delta: float) -> void: elapsed += delta - actor.velocity = actor.velocity.move_toward(Vector2.ZERO, actor.move_speed * delta * 10.0) + var move_speed: float = float(actor.get_current_move_speed()) if actor.has_method("get_current_move_speed") else float(actor.move_speed) + actor.velocity = actor.velocity.move_toward(Vector2.ZERO, move_speed * delta * 10.0) func evaluate_transitions() -> void: state_machine.transition_to(&"Idle") diff --git a/effects/projectiles/arcane_bolt.gd b/effects/projectiles/arcane_bolt.gd index 03f0a1a..7c857fa 100644 --- a/effects/projectiles/arcane_bolt.gd +++ b/effects/projectiles/arcane_bolt.gd @@ -1,10 +1,13 @@ extends Area2D +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const PLAYER_ORB_TEXTURE_PATH := "res://assets/effects/projectiles/player_staff_orb.webp" + @export var speed: float = 620.0 @export var lifetime: float = 1.5 @export var hit_radius: float = 18.0 -@onready var bolt: Polygon2D = $Bolt +@onready var bolt: Sprite2D = $Bolt @onready var collision_shape: CollisionShape2D = $CollisionShape2D var direction: Vector2 = Vector2.RIGHT @@ -19,6 +22,8 @@ var trail_timer: float = 0.0 func _ready() -> void: add_to_group("arcane_bolt_test") + _setup_texture_visual() + _setup_collision_shape() body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) @@ -29,7 +34,6 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, hit_ crit_rate = hit_crit_rate attack_name = attack_label extra_payload = hit_extra_payload.duplicate(true) - bolt.polygon = _pixel_bolt_polygon() rotation = direction.angle() var timer := get_tree().create_timer(lifetime) timer.timeout.connect(queue_free) @@ -37,7 +41,7 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, hit_ func _physics_process(delta: float) -> void: global_position += direction * speed * delta pulse_time += delta - bolt.scale = Vector2.ONE * (1.06 if int(pulse_time * 16.0) % 2 == 0 else 0.94) + bolt.scale = Vector2.ONE * (0.72 if int(pulse_time * 16.0) % 2 == 0 else 0.66) trail_timer -= delta if trail_timer <= 0.0: trail_timer = 0.045 @@ -115,18 +119,12 @@ func _consume_bolt() -> void: call_deferred("queue_free") func _spawn_hit_flash() -> void: - var flash := Polygon2D.new() - flash.polygon = PackedVector2Array([ - Vector2(-14.0, -4.0), - Vector2(-4.0, -14.0), - Vector2(4.0, -14.0), - Vector2(14.0, -4.0), - Vector2(14.0, 4.0), - Vector2(4.0, 14.0), - Vector2(-4.0, 14.0), - Vector2(-14.0, 4.0) - ]) - flash.color = Color(0.82, 0.92, 1.0, 0.92) + var flash := Sprite2D.new() + flash.texture = TEXTURE_LOADER.load_texture(PLAYER_ORB_TEXTURE_PATH) + flash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + flash.centered = true + flash.scale = Vector2.ONE * 0.82 + flash.modulate = Color(0.82, 0.92, 1.0, 0.92) flash.global_position = global_position flash.rotation = rotation get_tree().current_scene.add_child(flash) @@ -135,37 +133,32 @@ func _spawn_hit_flash() -> void: tween.parallel().tween_property(flash, "modulate:a", 0.0, 0.1) tween.finished.connect(flash.queue_free) -func _pixel_bolt_polygon() -> PackedVector2Array: - return PackedVector2Array([ - Vector2(-16.0, -4.0), - Vector2(-6.0, -10.0), - Vector2(4.0, -10.0), - Vector2(12.0, -4.0), - Vector2(18.0, 0.0), - Vector2(12.0, 4.0), - Vector2(4.0, 10.0), - Vector2(-6.0, 10.0), - Vector2(-16.0, 4.0), - Vector2(-10.0, 0.0) - ]) - func _spawn_pixel_trail() -> void: var scene_root := get_tree().current_scene if scene_root == null: return - var rune := Polygon2D.new() - rune.color = Color(0.72, 0.88, 1.0, 0.34) - rune.polygon = PackedVector2Array([ - Vector2(-3.0, -5.0), - Vector2(3.0, -5.0), - Vector2(5.0, 0.0), - Vector2(3.0, 5.0), - Vector2(-3.0, 5.0), - Vector2(-5.0, 0.0) - ]) + var rune := Sprite2D.new() + rune.texture = TEXTURE_LOADER.load_texture(PLAYER_ORB_TEXTURE_PATH) + rune.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + rune.centered = true + rune.scale = Vector2.ONE * 0.32 + rune.modulate = Color(0.72, 0.88, 1.0, 0.34) rune.global_position = global_position - direction * 10.0 scene_root.add_child(rune) var tween := rune.create_tween() tween.tween_property(rune, "scale", Vector2.ONE * 0.7, 0.12) tween.parallel().tween_property(rune, "modulate:a", 0.0, 0.12) tween.finished.connect(rune.queue_free) + +func _setup_texture_visual() -> void: + bolt.texture = TEXTURE_LOADER.load_texture(PLAYER_ORB_TEXTURE_PATH) + bolt.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + bolt.centered = true + bolt.scale = Vector2.ONE * 0.68 + +func _setup_collision_shape() -> void: + if collision_shape == null: + return + var shape := CircleShape2D.new() + shape.radius = hit_radius + collision_shape.shape = shape diff --git a/effects/projectiles/arcane_bolt.tscn b/effects/projectiles/arcane_bolt.tscn index 921a4d4..e7fa990 100644 --- a/effects/projectiles/arcane_bolt.tscn +++ b/effects/projectiles/arcane_bolt.tscn @@ -2,9 +2,8 @@ [ext_resource type="Script" path="res://effects/projectiles/arcane_bolt.gd" id="1"] -[sub_resource type="CapsuleShape2D" id="1"] -radius = 6.0 -height = 28.0 +[sub_resource type="CircleShape2D" id="1"] +radius = 18.0 [node name="ArcaneBolt" type="Area2D"] script = ExtResource("1") @@ -13,9 +12,8 @@ collision_mask = 1 monitoring = true monitorable = false -[node name="Bolt" type="Polygon2D" parent="."] -color = Color(0.68, 0.86, 1, 1) -polygon = PackedVector2Array(-14, -4, 10, -6, 18, 0, 10, 6, -14, 4, -6, 0) +[node name="Bolt" type="Sprite2D" parent="."] +texture_filter = 1 [node name="CollisionShape2D" type="CollisionShape2D" parent="."] shape = SubResource("1") diff --git a/effects/projectiles/enemy_bolt.gd b/effects/projectiles/enemy_bolt.gd index 7f3981f..df5a421 100644 --- a/effects/projectiles/enemy_bolt.gd +++ b/effects/projectiles/enemy_bolt.gd @@ -1,10 +1,16 @@ extends Area2D +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const BASIC_BULLET_TEXTURE_PATH := "res://assets/effects/projectiles/basic_bullet.webp" +const APPRENTICE_ORB_TEXTURE_PATH := "res://assets/effects/projectiles/apprentice_orb.webp" +const ARCANIST_MISSILE_TEXTURE_PATH := "res://assets/effects/projectiles/arcanist_missile.webp" + @export var speed: float = 420.0 @export var lifetime: float = 2.4 @export var hit_radius: float = 18.0 -@onready var bolt: Polygon2D = $Bolt +@onready var bolt: Sprite2D = $Bolt +@onready var collision_shape: CollisionShape2D = $CollisionShape2D var direction: Vector2 = Vector2.RIGHT var damage: float = 10.0 @@ -15,6 +21,8 @@ var pulse_time: float = 0.0 var trail_timer: float = 0.0 func _ready() -> void: + _setup_texture_visual(BASIC_BULLET_TEXTURE_PATH) + _setup_collision_shape() body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) @@ -23,8 +31,8 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, colo direction = travel_direction.normalized() if travel_direction != Vector2.ZERO else Vector2.RIGHT damage = hit_damage speed = new_speed - bolt.color = color - bolt.polygon = _pixel_bolt_polygon() + bolt.modulate = color + _setup_texture_visual(_texture_for_color(color)) extra_payload = payload.duplicate(true) rotation = direction.angle() var timer := get_tree().create_timer(lifetime) @@ -34,7 +42,7 @@ func _physics_process(delta: float) -> void: global_position += direction * speed * delta pulse_time += delta var pixel_pulse := 1.08 if int(pulse_time * 18.0) % 2 == 0 else 0.96 - bolt.scale = Vector2.ONE * pixel_pulse + bolt.scale = Vector2.ONE * (0.72 * pixel_pulse) trail_timer -= delta if trail_timer <= 0.0: trail_timer = 0.055 @@ -103,18 +111,12 @@ func _resolve_damage_target(target: Variant) -> Node: return null func _spawn_hit_flash() -> void: - var flash := Polygon2D.new() - flash.polygon = PackedVector2Array([ - Vector2(-12, -4), - Vector2(-4, -12), - Vector2(4, -12), - Vector2(12, -4), - Vector2(12, 4), - Vector2(4, 12), - Vector2(-4, 12), - Vector2(-12, 4) - ]) - flash.color = bolt.color + var flash := Sprite2D.new() + flash.texture = bolt.texture + flash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + flash.centered = true + flash.scale = Vector2.ONE * 0.82 + flash.modulate = bolt.modulate flash.global_position = global_position flash.rotation = rotation get_tree().current_scene.add_child(flash) @@ -123,29 +125,16 @@ func _spawn_hit_flash() -> void: tween.parallel().tween_property(flash, "modulate:a", 0.0, 0.1) tween.finished.connect(flash.queue_free) -func _pixel_bolt_polygon() -> PackedVector2Array: - return PackedVector2Array([ - Vector2(-14.0, -4.0), - Vector2(-6.0, -8.0), - Vector2(10.0, -8.0), - Vector2(18.0, 0.0), - Vector2(10.0, 8.0), - Vector2(-6.0, 8.0), - Vector2(-14.0, 4.0) - ]) - func _spawn_pixel_trail() -> void: var scene_root := get_tree().current_scene if scene_root == null: return - var chip := Polygon2D.new() - chip.color = Color(bolt.color.r, bolt.color.g, bolt.color.b, 0.46) - chip.polygon = PackedVector2Array([ - Vector2(-3.0, -3.0), - Vector2(3.0, -3.0), - Vector2(3.0, 3.0), - Vector2(-3.0, 3.0) - ]) + var chip := Sprite2D.new() + chip.texture = bolt.texture + chip.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + chip.centered = true + chip.scale = Vector2.ONE * 0.26 + chip.modulate = Color(bolt.modulate.r, bolt.modulate.g, bolt.modulate.b, 0.46) chip.global_position = global_position - direction * 12.0 chip.rotation = rotation scene_root.add_child(chip) @@ -153,3 +142,24 @@ func _spawn_pixel_trail() -> void: tween.tween_property(chip, "global_position", chip.global_position - direction * 10.0, 0.12) tween.parallel().tween_property(chip, "modulate:a", 0.0, 0.12) tween.finished.connect(chip.queue_free) + +func _texture_for_color(color: Color) -> String: + if color.r > 0.9 and color.g < 0.72: + return ARCANIST_MISSILE_TEXTURE_PATH + if color.b > color.r and color.b > color.g: + return APPRENTICE_ORB_TEXTURE_PATH + return BASIC_BULLET_TEXTURE_PATH + +func _setup_texture_visual(texture_path: String) -> void: + bolt.texture = TEXTURE_LOADER.load_texture(texture_path) + bolt.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + bolt.centered = true + bolt.scale = Vector2.ONE * 0.72 + +func _setup_collision_shape() -> void: + if collision_shape == null: + return + var shape := CapsuleShape2D.new() + shape.radius = 7.5 + shape.height = 34.0 + collision_shape.shape = shape diff --git a/effects/projectiles/enemy_bolt.tscn b/effects/projectiles/enemy_bolt.tscn index 2bcb5b6..dcfa5b1 100644 --- a/effects/projectiles/enemy_bolt.tscn +++ b/effects/projectiles/enemy_bolt.tscn @@ -2,8 +2,9 @@ [ext_resource type="Script" path="res://effects/projectiles/enemy_bolt.gd" id="1"] -[sub_resource type="CircleShape2D" id="1"] -radius = 7.0 +[sub_resource type="CapsuleShape2D" id="1"] +radius = 7.5 +height = 34.0 [node name="EnemyBolt" type="Area2D"] script = ExtResource("1") @@ -12,9 +13,8 @@ collision_mask = 1 monitoring = true monitorable = false -[node name="Bolt" type="Polygon2D" parent="."] -color = Color(0.95, 0.88, 0.6, 1) -polygon = PackedVector2Array(-12, -6, 8, -8, 16, 0, 8, 8, -12, 6, -4, 0) +[node name="Bolt" type="Sprite2D" parent="."] +texture_filter = 1 [node name="CollisionShape2D" type="CollisionShape2D" parent="."] shape = SubResource("1") diff --git a/effects/projectiles/piercing_arrow.gd b/effects/projectiles/piercing_arrow.gd index 2f9c6f5..8bddd42 100644 --- a/effects/projectiles/piercing_arrow.gd +++ b/effects/projectiles/piercing_arrow.gd @@ -1,9 +1,12 @@ extends Area2D +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const ARROW_TEXTURE_PATH := "res://assets/effects/projectiles/arrow.webp" + @export var speed: float = 700.0 @export var lifetime: float = 1.4 -@onready var trail: Polygon2D = $Trail +@onready var trail: Sprite2D = $Trail var direction: Vector2 = Vector2.RIGHT var damage: float = 100.0 @@ -16,6 +19,7 @@ var expired: bool = false var trail_timer: float = 0.0 func _ready() -> void: + _setup_texture_visual() body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) @@ -25,7 +29,6 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, hit_ damage = hit_damage crit_rate = hit_crit_rate attack_name = attack_label - trail.polygon = _pixel_arrow_polygon() rotation = direction.angle() var timer := get_tree().create_timer(lifetime) timer.timeout.connect(queue_free) @@ -33,8 +36,8 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, hit_ func _physics_process(delta: float) -> void: global_position += direction * speed * delta pulse_time += delta - trail.scale = Vector2(1.08 if int(pulse_time * 20.0) % 2 == 0 else 0.98, 1.0) - trail.color = Color(1.0, 0.96, 0.72, 1.0) + trail.scale = Vector2.ONE * (0.82 if int(pulse_time * 20.0) % 2 == 0 else 0.76) + trail.modulate = Color(1.0, 0.96, 0.72, 1.0) trail_timer -= delta if trail_timer <= 0.0: trail_timer = 0.035 @@ -85,16 +88,12 @@ func _resolve_damage_target(target: Variant) -> Node: return null func _spawn_hit_flash() -> void: - var flash := Polygon2D.new() - flash.polygon = PackedVector2Array([ - Vector2(-10.0, -4.0), - Vector2(6.0, -4.0), - Vector2(14.0, 0.0), - Vector2(6.0, 4.0), - Vector2(-10.0, 4.0), - Vector2(-4.0, 0.0) - ]) - flash.color = Color(1.0, 1.0, 0.8, 0.9) + var flash := Sprite2D.new() + flash.texture = TEXTURE_LOADER.load_texture(ARROW_TEXTURE_PATH) + flash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + flash.centered = true + flash.scale = Vector2.ONE * 0.92 + flash.modulate = Color(1.0, 1.0, 0.8, 0.9) flash.global_position = global_position flash.rotation = rotation get_tree().current_scene.add_child(flash) @@ -103,18 +102,6 @@ func _spawn_hit_flash() -> void: tween.parallel().tween_property(flash, "modulate:a", 0.0, 0.08) tween.finished.connect(flash.queue_free) -func _pixel_arrow_polygon() -> PackedVector2Array: - return PackedVector2Array([ - Vector2(-20.0, -3.0), - Vector2(4.0, -3.0), - Vector2(4.0, -7.0), - Vector2(20.0, 0.0), - Vector2(4.0, 7.0), - Vector2(4.0, 3.0), - Vector2(-20.0, 3.0), - Vector2(-12.0, 0.0) - ]) - func _spawn_pixel_trail() -> void: var scene_root := get_tree().current_scene if scene_root == null: @@ -134,3 +121,9 @@ func _spawn_pixel_trail() -> void: tween.tween_property(chip, "global_position", chip.global_position - direction * 12.0, 0.1) tween.parallel().tween_property(chip, "modulate:a", 0.0, 0.1) tween.finished.connect(chip.queue_free) + +func _setup_texture_visual() -> void: + trail.texture = TEXTURE_LOADER.load_texture(ARROW_TEXTURE_PATH) + trail.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + trail.centered = true + trail.scale = Vector2.ONE * 0.78 diff --git a/effects/projectiles/piercing_arrow.tscn b/effects/projectiles/piercing_arrow.tscn index 10a1de8..1b3576e 100644 --- a/effects/projectiles/piercing_arrow.tscn +++ b/effects/projectiles/piercing_arrow.tscn @@ -3,8 +3,8 @@ [ext_resource type="Script" path="res://effects/projectiles/piercing_arrow.gd" id="1"] [sub_resource type="CapsuleShape2D" id="1"] -radius = 6.0 -height = 34.0 +radius = 5.0 +height = 38.0 [node name="PiercingArrow" type="Area2D"] script = ExtResource("1") @@ -13,9 +13,8 @@ collision_mask = 1 monitoring = true monitorable = false -[node name="Trail" type="Polygon2D" parent="."] -color = Color(0.96, 0.92, 0.58, 1) -polygon = PackedVector2Array(-18, -4, 18, 0, -18, 4, -6, 0) +[node name="Trail" type="Sprite2D" parent="."] +texture_filter = 1 [node name="CollisionShape2D" type="CollisionShape2D" parent="."] shape = SubResource("1") diff --git a/effects/projectiles/royal_bolt.gd b/effects/projectiles/royal_bolt.gd index f6e29c9..b952d1f 100644 --- a/effects/projectiles/royal_bolt.gd +++ b/effects/projectiles/royal_bolt.gd @@ -1,9 +1,14 @@ extends Area2D +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const BOSS_RED_TEXTURE_PATH := "res://assets/effects/projectiles/boss_bullet_red.webp" +const BOSS_YELLOW_TEXTURE_PATH := "res://assets/effects/projectiles/boss_bullet_yellow.webp" + @export var speed: float = 440.0 @export var lifetime: float = 3.0 -@onready var bolt: Polygon2D = $Bolt +@onready var bolt: Sprite2D = $Bolt +@onready var collision_shape: CollisionShape2D = $CollisionShape2D var direction: Vector2 = Vector2.RIGHT var damage: float = 18.0 @@ -14,6 +19,8 @@ var pulse_time: float = 0.0 var trail_timer: float = 0.0 func _ready() -> void: + _setup_texture_visual(BOSS_YELLOW_TEXTURE_PATH) + _setup_collision_shape() body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) @@ -22,7 +29,7 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, payl direction = travel_direction.normalized() if travel_direction != Vector2.ZERO else Vector2.RIGHT damage = hit_damage extra_payload = payload.duplicate(true) - bolt.polygon = _pixel_bolt_polygon() + _setup_texture_visual(BOSS_RED_TEXTURE_PATH if hit_damage > 22.0 else BOSS_YELLOW_TEXTURE_PATH) rotation = direction.angle() var timer := get_tree().create_timer(lifetime) timer.timeout.connect(queue_free) @@ -30,7 +37,7 @@ func setup(owner_actor: Node, travel_direction: Vector2, hit_damage: float, payl func _physics_process(delta: float) -> void: global_position += direction * speed * delta pulse_time += delta - bolt.scale = Vector2.ONE * (1.08 if int(pulse_time * 18.0) % 2 == 0 else 0.96) + bolt.scale = Vector2.ONE * (0.9 if int(pulse_time * 18.0) % 2 == 0 else 0.82) trail_timer -= delta if trail_timer <= 0.0: trail_timer = 0.05 @@ -85,18 +92,12 @@ func _resolve_damage_target(target: Variant) -> Node: return null func _spawn_hit_flash() -> void: - var flash := Polygon2D.new() - flash.polygon = PackedVector2Array([ - Vector2(-12.0, -4.0), - Vector2(-4.0, -12.0), - Vector2(4.0, -12.0), - Vector2(12.0, -4.0), - Vector2(12.0, 4.0), - Vector2(4.0, 12.0), - Vector2(-4.0, 12.0), - Vector2(-12.0, 4.0) - ]) - flash.color = Color(1.0, 0.84, 0.52, 0.9) + var flash := Sprite2D.new() + flash.texture = bolt.texture + flash.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + flash.centered = true + flash.scale = Vector2.ONE + flash.modulate = Color(1.0, 0.84, 0.52, 0.9) flash.global_position = global_position flash.rotation = rotation get_tree().current_scene.add_child(flash) @@ -105,17 +106,6 @@ func _spawn_hit_flash() -> void: tween.parallel().tween_property(flash, "modulate:a", 0.0, 0.1) tween.finished.connect(flash.queue_free) -func _pixel_bolt_polygon() -> PackedVector2Array: - return PackedVector2Array([ - Vector2(-12.0, -4.0), - Vector2(-4.0, -9.0), - Vector2(6.0, -9.0), - Vector2(18.0, 0.0), - Vector2(6.0, 9.0), - Vector2(-4.0, 9.0), - Vector2(-12.0, 4.0) - ]) - func _spawn_pixel_trail() -> void: var scene_root := get_tree().current_scene if scene_root == null: @@ -135,3 +125,17 @@ func _spawn_pixel_trail() -> void: tween.tween_property(chip, "global_position", chip.global_position - direction * 8.0, 0.12) tween.parallel().tween_property(chip, "modulate:a", 0.0, 0.12) tween.finished.connect(chip.queue_free) + +func _setup_texture_visual(texture_path: String) -> void: + bolt.texture = TEXTURE_LOADER.load_texture(texture_path) + bolt.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + bolt.centered = true + bolt.scale = Vector2.ONE * 0.84 + +func _setup_collision_shape() -> void: + if collision_shape == null: + return + var shape := CapsuleShape2D.new() + shape.radius = 8.0 + shape.height = 34.0 + collision_shape.shape = shape diff --git a/effects/projectiles/royal_bolt.tscn b/effects/projectiles/royal_bolt.tscn index 37baf70..ca3466d 100644 --- a/effects/projectiles/royal_bolt.tscn +++ b/effects/projectiles/royal_bolt.tscn @@ -2,8 +2,9 @@ [ext_resource type="Script" path="res://effects/projectiles/royal_bolt.gd" id="1"] -[sub_resource type="CircleShape2D" id="1"] +[sub_resource type="CapsuleShape2D" id="1"] radius = 8.0 +height = 34.0 [node name="RoyalBolt" type="Area2D"] script = ExtResource("1") @@ -12,9 +13,8 @@ collision_mask = 1 monitoring = true monitorable = false -[node name="Bolt" type="Polygon2D" parent="."] -color = Color(1, 0.82, 0.48, 1) -polygon = PackedVector2Array(-12, -6, 8, -8, 16, 0, 8, 8, -12, 6, -4, 0) +[node name="Bolt" type="Sprite2D" parent="."] +texture_filter = 1 [node name="CollisionShape2D" type="CollisionShape2D" parent="."] shape = SubResource("1") diff --git a/systems/pickups/run_pickup.gd b/systems/pickups/run_pickup.gd index d7e8076..bca65e6 100644 --- a/systems/pickups/run_pickup.gd +++ b/systems/pickups/run_pickup.gd @@ -2,6 +2,10 @@ extends Node2D signal collected(kind: String, amount: float, world_position: Vector2) +const TEXTURE_LOADER := preload("res://combat/runtime_texture_loader.gd") +const GOLD_TEXTURE_PATH := "res://assets/effects/pickups/gold.webp" +const EXPERIENCE_TEXTURE_PATH := "res://assets/effects/pickups/experience.webp" + var kind: String = "gold" var amount: float = 0.0 var tint: Color = Color(1.0, 0.88, 0.42, 1.0) @@ -18,7 +22,7 @@ func setup(pickup_kind: String, pickup_amount: float, options: Dictionary = {}) amount = maxf(pickup_amount, 0.0) tint = options.get("tint", _default_tint(kind)) var icon_path := String(options.get("icon", "")) - icon_texture = load(icon_path) as Texture2D if not icon_path.is_empty() else null + icon_texture = TEXTURE_LOADER.load_texture(icon_path) if not icon_path.is_empty() else _default_texture(kind) var launch_speed := float(options.get("launch_speed", randf_range(34.0, 62.0))) var launch_angle := float(options.get("launch_angle", randf() * TAU)) velocity = Vector2.RIGHT.rotated(launch_angle) * launch_speed @@ -64,7 +68,11 @@ func _draw() -> void: draw_circle(center, outer_radius + 2.0, Color(0.02, 0.03, 0.05, 0.52)) if icon_texture != null: var icon_size := Vector2(28.0, 28.0) - if kind == "accessory": + if kind == "gold": + icon_size = Vector2(20.0, 26.0) + elif kind == "inspiration": + icon_size = Vector2(26.0, 26.0) + elif kind == "accessory": icon_size = Vector2(26.0, 26.0) elif kind == "consumable": icon_size = Vector2(30.0, 30.0) @@ -131,3 +139,12 @@ func _default_tint(pickup_kind: String) -> Color: return Color(1.0, 0.88, 0.52, 1.0) _: return Color(1.0, 0.88, 0.42, 1.0) + +func _default_texture(pickup_kind: String) -> Texture2D: + match pickup_kind: + "gold": + return TEXTURE_LOADER.load_texture(GOLD_TEXTURE_PATH) + "inspiration": + return TEXTURE_LOADER.load_texture(EXPERIENCE_TEXTURE_PATH) + _: + return null diff --git a/tools/character_debug_world.gd b/tools/character_debug_world.gd index 8cab0e7..9b2e29d 100644 --- a/tools/character_debug_world.gd +++ b/tools/character_debug_world.gd @@ -15,6 +15,7 @@ const TWIN_PRINCES_BOSS_SCENE := preload("res://actors/bosses/town/twin_princes_ const RANGER_BOSS_SCENE := preload("res://actors/bosses/town/ranger_boss.tscn") const MAGE_BOSS_SCENE := preload("res://actors/bosses/town/mage_boss.tscn") const EMPEROR_BOSS_SCENE := preload("res://actors/bosses/town/emperor_boss.tscn") +const DEBUG_PANEL_SCENE := preload("res://ui/debug_panel.tscn") const PLAYER_SCENES := { &"knight": KNIGHT_SCENE, @@ -44,9 +45,11 @@ const ENEMY_SCENES := { @onready var debug_status: CanvasLayer = $CharacterDebugStatus @onready var help_label: Label = $DebugOverlay/Panel/Margin/HelpLabel @onready var camera: Camera2D = $Camera2D +var debug_panel: CanvasLayer = null var player_character: Node2D = null var active_target: Node2D = null +var current_encounter: Node = null var active_enemy_id: StringName = &"dummy" var active_enemy_elite: bool = false @@ -63,6 +66,8 @@ func _ready() -> void: enemy_select.visible = false if debug_status != null: debug_status.visible = false + _build_debug_overlay_controls() + _build_debug_panel() _refresh_help_text() if Music != null: Music.play_profile(&"title", true) @@ -73,6 +78,48 @@ func _ready() -> void: call_deferred("_on_character_selected", StringName(pending["character_id"])) +func _build_debug_overlay_controls() -> void: + if help_label == null: + return + var margin := help_label.get_parent() + if margin == null: + return + var control_row := HBoxContainer.new() + control_row.add_theme_constant_override("separation", 12) + control_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + margin.remove_child(help_label) + margin.add_child(control_row) + control_row.add_child(help_label) + help_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + help_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var upgrade_button := Button.new() + upgrade_button.text = "升级" + upgrade_button.custom_minimum_size = Vector2(120.0, 34.0) + upgrade_button.tooltip_text = "打开升级面板" + upgrade_button.pressed.connect(_on_upgrade_requested) + control_row.add_child(upgrade_button) + + +func _build_debug_panel() -> void: + if DEBUG_PANEL_SCENE == null: + return + debug_panel = DEBUG_PANEL_SCENE.instantiate() as CanvasLayer + if debug_panel == null: + return + debug_panel.name = "DebugPanel" + add_child(debug_panel) + if debug_panel.has_method("bind_world"): + debug_panel.bind_world(self) + debug_panel.visible = false + + +func _on_upgrade_requested() -> void: + if debug_panel == null or not is_instance_valid(debug_panel): + return + if debug_panel.has_method("toggle"): + debug_panel.toggle() + else: + debug_panel.visible = not debug_panel.visible func _process(_delta: float) -> void: _refresh_help_text() @@ -123,8 +170,10 @@ func _spawn_debug_target(enemy_id: StringName, elite: bool) -> void: if active_target != null and is_instance_valid(active_target): active_target.queue_free() active_target = null + current_encounter = null var scene: PackedScene = ENEMY_SCENES.get(enemy_id, TRAINING_DUMMY_SCENE) active_target = scene.instantiate() as Node2D + current_encounter = active_target active_target.position = target_spawn.position active_target.z_index = 3 if active_target.get("elite") != null: @@ -138,6 +187,7 @@ func _spawn_debug_target(enemy_id: StringName, elite: bool) -> void: func _on_debug_target_defeated() -> void: active_target = null + current_encounter = null var timer := get_tree().create_timer(0.45) timer.timeout.connect(func() -> void: if is_instance_valid(self): @@ -168,6 +218,7 @@ func _return_to_character_select() -> void: if active_target != null and is_instance_valid(active_target): active_target.queue_free() active_target = null + current_encounter = null if enemy_select != null and enemy_select.has_method("close"): enemy_select.close() if debug_status != null and debug_status.has_method("clear"): diff --git a/ui/battle_status.gd b/ui/battle_status.gd index 3cbbd3f..93e164e 100644 --- a/ui/battle_status.gd +++ b/ui/battle_status.gd @@ -1,5 +1,7 @@ extends CanvasLayer +signal debug_requested + const RunEffects := preload("res://systems/run/run_effects.gd") const UISkin := preload("res://ui/ui_skin.gd") @@ -33,6 +35,7 @@ var metric_caption_labels: Array[Label] = [] var layout_size_override: Vector2 = Vector2.ZERO var context_state: Dictionary = {} var run_panel_root: PanelContainer +var debug_button: Button func _ready() -> void: context_state = _default_context_state() @@ -95,6 +98,13 @@ func _build_ui() -> void: UISkin.label(title_label, 24, Color(0.98, 0.90, 0.66)) content.add_child(title_label) + debug_button = Button.new() + debug_button.text = "Debug" + debug_button.tooltip_text = "Open the test panel" + UISkin.button_styles(debug_button, "thin") + debug_button.pressed.connect(_on_debug_pressed) + content.add_child(debug_button) + subtitle_label = Label.new() subtitle_label.text = _locale_text( "Pick a champion, then build toward the bosses instead of only surviving the next room.", @@ -417,6 +427,9 @@ func _default_context_state() -> Dictionary: ) } +func _on_debug_pressed() -> void: + debug_requested.emit() + func _refresh_context() -> void: if objective_value_label == null: return diff --git a/ui/debug_panel.gd b/ui/debug_panel.gd index a42db43..731074b 100644 --- a/ui/debug_panel.gd +++ b/ui/debug_panel.gd @@ -8,13 +8,20 @@ const UISkin := preload("res://ui/ui_skin.gd") var target_world: Node = null var layout_size_override: Vector2 = Vector2.ZERO +var content_stack: VBoxContainer = null +var upgrade_title_label: Label = null +var upgrade_action_row: HBoxContainer = null +var clear_upgrades_button: Button = null +var upgrade_scroll: ScrollContainer = null +var upgrade_list: VBoxContainer = null +var active_upgrade_target: Node = null +var upgrade_buttons: Array[Button] = [] func _ready() -> void: layer = 40 visible = false panel.add_theme_stylebox_override("panel", UISkin.content_panel_style()) - UISkin.label(label, 12, Color(0.82, 0.90, 0.98)) - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _build_upgrade_ui() if get_viewport() != null and not get_viewport().size_changed.is_connected(_queue_layout_refresh): get_viewport().size_changed.connect(_queue_layout_refresh) _queue_layout_refresh() @@ -29,6 +36,8 @@ func _process(_delta: float) -> void: if not visible or target_world == null or not is_instance_valid(target_world): return var player: Node = target_world.player_character + if player != active_upgrade_target: + _rebuild_upgrade_controls(player) var encounter: Node = target_world.current_encounter var lines := PackedStringArray() lines.append("Debug") @@ -67,7 +76,123 @@ func _process(_delta: float) -> void: float(player.cooldowns.get("skill3", 0.0)) ]) label.text = "\n".join(lines) + _sync_upgrade_button_states() + +func _build_upgrade_ui() -> void: + if content_stack != null: + return + var content_root := label.get_parent() + if content_root == null: + return + content_stack = VBoxContainer.new() + content_stack.name = "DebugStack" + content_stack.add_theme_constant_override("separation", 8) + content_root.remove_child(label) + content_stack.add_child(label) + content_root.add_child(content_stack) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + + upgrade_title_label = Label.new() + upgrade_title_label.text = "Upgrades" + upgrade_title_label.add_theme_font_size_override("font_size", 14) + upgrade_title_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.64)) + content_stack.add_child(upgrade_title_label) + + upgrade_action_row = HBoxContainer.new() + upgrade_action_row.add_theme_constant_override("separation", 8) + content_stack.add_child(upgrade_action_row) + + clear_upgrades_button = Button.new() + clear_upgrades_button.text = "Clear Upgrades" + clear_upgrades_button.toggle_mode = false + clear_upgrades_button.custom_minimum_size = Vector2(0.0, 34.0) + UISkin.button_styles(clear_upgrades_button, "thin") + clear_upgrades_button.pressed.connect(_on_clear_upgrades_pressed) + upgrade_action_row.add_child(clear_upgrades_button) + + upgrade_scroll = ScrollContainer.new() + upgrade_scroll.custom_minimum_size = Vector2(0.0, 168.0) + upgrade_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + upgrade_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + content_stack.add_child(upgrade_scroll) + + upgrade_list = VBoxContainer.new() + upgrade_list.add_theme_constant_override("separation", 6) + upgrade_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + upgrade_scroll.add_child(upgrade_list) +func _rebuild_upgrade_controls(player: Node) -> void: + active_upgrade_target = player + _clear_upgrade_entries() + if clear_upgrades_button != null: + clear_upgrades_button.disabled = player == null or not is_instance_valid(player) or not player.has_method("clear_all_upgrades") + if player == null or not is_instance_valid(player): + return + if not player.has_method("get_upgrade_sections") or not player.has_method("is_upgrade_enabled") or not player.has_method("set_upgrade_enabled"): + return + var sections: Array = player.get_upgrade_sections() + for section_variant in sections: + var section: Dictionary = section_variant + var section_title := Label.new() + section_title.text = String(section.get("title", "Upgrades")) + section_title.add_theme_font_size_override("font_size", 12) + section_title.add_theme_color_override("font_color", Color(0.86, 0.91, 0.98)) + upgrade_list.add_child(section_title) + for upgrade_variant in section.get("upgrades", []): + var upgrade: Dictionary = upgrade_variant + var upgrade_id := String(upgrade.get("id", "")) + var upgrade_label := String(upgrade.get("label", upgrade_id)) + var upgrade_description := String(upgrade.get("description", "")) + var button := Button.new() + button.toggle_mode = true + button.text = upgrade_label + button.tooltip_text = upgrade_description + button.custom_minimum_size = Vector2(0.0, 34.0) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UISkin.button_styles(button, "thin") + button.set_meta("upgrade_id", upgrade_id) + button.button_pressed = bool(player.is_upgrade_enabled(upgrade_id)) + button.toggled.connect(func(pressed: bool) -> void: + if active_upgrade_target == null or not is_instance_valid(active_upgrade_target): + return + if not active_upgrade_target.has_method("set_upgrade_enabled"): + return + active_upgrade_target.set_upgrade_enabled(upgrade_id, pressed) + ) + upgrade_list.add_child(button) + upgrade_buttons.append(button) + _sync_upgrade_button_states() + +func _sync_upgrade_button_states() -> void: + if active_upgrade_target == null or not is_instance_valid(active_upgrade_target): + return + if not active_upgrade_target.has_method("is_upgrade_enabled"): + return + for button in upgrade_buttons: + if button == null or not is_instance_valid(button): + continue + if not button.has_meta("upgrade_id"): + continue + var upgrade_id := String(button.get_meta("upgrade_id")) + var should_be_pressed := bool(active_upgrade_target.is_upgrade_enabled(upgrade_id)) + if button.button_pressed != should_be_pressed: + button.button_pressed = should_be_pressed + +func _clear_upgrade_entries() -> void: + upgrade_buttons.clear() + if upgrade_list == null: + return + for child in upgrade_list.get_children(): + child.queue_free() + +func _on_clear_upgrades_pressed() -> void: + if active_upgrade_target == null or not is_instance_valid(active_upgrade_target): + return + if not active_upgrade_target.has_method("clear_all_upgrades"): + return + active_upgrade_target.clear_all_upgrades() + _sync_upgrade_button_states() func _queue_layout_refresh() -> void: call_deferred("_refresh_layout") diff --git a/world.gd b/world.gd index e177cdd..1404e7f 100644 --- a/world.gd +++ b/world.gd @@ -1,4 +1,4 @@ -extends Node2D +extends Node2D const KNIGHT_SCENE := preload("res://characters/knight/knight.tscn") const RANGER_SCENE := preload("res://characters/ranger/ranger.tscn") @@ -142,6 +142,8 @@ func _ready() -> void: settings_panel.closed.connect(_on_settings_panel_closed) if debug_panel != null and debug_panel.has_method("bind_world"): debug_panel.bind_world(self) + if battle_status != null and battle_status.has_signal("debug_requested") and not battle_status.debug_requested.is_connected(_on_battle_status_debug_requested): + battle_status.debug_requested.connect(_on_battle_status_debug_requested) _refresh_battle_status( _ui_text("Town Boss Trial", "城镇王战试炼", "城鎮王戰試煉"), _ui_text("Pick a champion, then claim relics between encounters.", "先选择角色,再在战斗间隙领取饰品。", "先選擇角色,再在戰鬥間隙領取飾品。"), @@ -2140,6 +2142,11 @@ func _on_title_settings_requested() -> void: settings_panel.open() _refresh_battle_status() +func _on_battle_status_debug_requested() -> void: + if debug_panel == null or not debug_panel.has_method("toggle"): + return + debug_panel.toggle() + func _on_audio_settings_panel_closed() -> void: if return_pause_after_audio_panel: return_pause_after_audio_panel = false