class_name MainUI extends Control const DefenseResolverScript := preload("res://scripts/resolvers/defense_resolver.gd") const ActionPatternExporterScript := preload("res://tools/export_action_patterns.gd") @onready var rhythm_track: Control = $RhythmTrack @onready var status_bars: Control = $StatusBars @onready var boss_status: Control = $BossStatus @onready var combo_window: Control = $ComboWindow @onready var time_phase_hud: Control = $TimePhaseHud @onready var debug_panel: Control = $DebugPanel @onready var health_bar: ProgressBar = $StatusBars/HealthBar @onready var charge_bar: ProgressBar = $StatusBars/ChargeBar @onready var charge_level_label: Label = $StatusBars/ChargeLevelLabel @onready var boss_health_bar: ProgressBar = $BossStatus/BossHealthBar @onready var boss_name_label: Label = $BossStatus/BossNameLabel @onready var combo_skill_label: Label = $ComboSkillLabel @onready var state_axes_label: Label = $DebugPanel/StateAxesLabel @onready var action_label: Label = $DebugPanel/ActionLabel @onready var anchor_label: Label = $DebugPanel/AnchorLabel @onready var effects_label: Label = $DebugPanel/EffectsLabel @onready var defense_label: Label = $DebugPanel/DefenseLabel @onready var patterns_label: Label = $DebugPanel/PatternsLabel @onready var hit_log_label: Label = $DebugPanel/HitLogLabel @onready var calibration_label: Label = $DebugPanel/CalibrationLabel @onready var time_phase_label: Label = $TimePhaseHud/TimePhaseLabel @onready var attack_buff_label: Label = $TimePhaseHud/AttackBuffLabel @onready var streak_label: Label = $TimePhaseHud/StreakLabel @onready var time_phase_debug_label: Label = $DebugPanel/TimePhaseDebugLabel @onready var time_anchor_debug_label: Label = $DebugPanel/TimeAnchorDebugLabel const DESIGN_SIZE := Vector2(1280.0, 720.0) const HUD_MIN_SCALE := 0.72 const HUD_MAX_SCALE := 1.15 const TIME_PHASE_PAST_COLOR := Color(1.0, 0.84, 0.4) const TIME_PHASE_FUTURE_COLOR := Color(0.45, 0.85, 1.0) const COMBO_SKILL_IDLE_SCALE := Vector2.ONE const COMBO_SKILL_POP_SCALE := Vector2(1.12, 1.12) const MOVE_LIST_ACTION := &"toggle_move_list" const MOVE_LIST_PANEL_SIZE := Vector2(430.0, 430.0) const MOVE_LIST_SECTIONS := [ { "title": "基础", "rows": [ ["A / D", "左右地面三段斩"], ["W", "上挑"], ["S", "格挡 / 空中下劈"], ["空中 A / D", "空中斩"], ], }, { "title": "SP 派生", "rows": [ ["A/D + SP", "突进斩"], ["A/A 或 D/D + SP", "连段终结"], ["A/A/A 或 D/D/D + SP", "震地击"], ], }, { "title": "蓄力", "rows": [ ["A/D 按住后松开", "剑雨 Lv1-3"], ["S 按住 + SP", "斩波 Lv1-3"], ], }, ] var charge_bar_ready := false var charge_flash := 0.0 var debug_actor: Node var boss_actor: Node var _boss_health_component: Node var _patterns_debug_text := "Patterns -" var _hit_log_entries: Array[String] = [] var _last_diff_ms := INF var _current_time_phase: StringName = &"past" var _streak_count := 0 var _attack_buff_stacks := 0 var _attack_buff_max := 20 var _last_viewport_size := Vector2.ZERO var _boss_room_active := false var move_list_panel: PanelContainer func _ready() -> void: _ensure_move_list_input_action() _build_move_list_panel() _apply_responsive_layout() var viewport := get_viewport() var resize_callback := Callable(self, "_apply_responsive_layout") if viewport != null and not viewport.size_changed.is_connected(resize_callback): viewport.size_changed.connect(resize_callback) _apply_bar_styles() _patterns_debug_text = _debug_patterns() var bus := _event_bus() bus.connect("player_health_changed", _on_health_changed) bus.connect("player_charge_changed", _on_charge_changed) bus.connect("skill_executed", _on_skill_executed) if bus.has_signal("judgement_made") and not bus.is_connected("judgement_made", _on_judgement_made): bus.connect("judgement_made", _on_judgement_made) if bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed): bus.connect("hit_confirmed", _on_hit_confirmed) if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): bus.connect("time_phase_changed", _on_time_phase_changed) if bus.has_signal("attack_buff_changed") and not bus.is_connected("attack_buff_changed", _on_attack_buff_changed): bus.connect("attack_buff_changed", _on_attack_buff_changed) if bus.has_signal("streak_changed") and not bus.is_connected("streak_changed", _on_streak_changed): bus.connect("streak_changed", _on_streak_changed) if bus.has_signal("flow_state_changed") and not bus.is_connected("flow_state_changed", _on_flow_state_changed): bus.connect("flow_state_changed", _on_flow_state_changed) var time_phase_manager := get_tree().root.get_node_or_null("TimePhaseManager") if time_phase_manager != null: _current_time_phase = StringName(str(time_phase_manager.get("current_time_phase"))) _boss_room_active = _current_flow_state() == &"Gameplay_BossRoom" _update_boss_status_visibility() _refresh_time_phase_hud() func _unhandled_input(event: InputEvent) -> void: var key_event := event as InputEventKey if key_event != null and key_event.echo: return var pressed := event.is_action_pressed(MOVE_LIST_ACTION) if not pressed and key_event != null and key_event.pressed: pressed = key_event.keycode == KEY_E or key_event.physical_keycode == KEY_E if not pressed: return toggle_move_list() var viewport := get_viewport() if viewport != null: viewport.set_input_as_handled() func _process(delta: float) -> void: var viewport_size := get_viewport_rect().size if viewport_size != _last_viewport_size: _apply_responsive_layout() _update_charge_bar_flash(delta) refresh_debug_panel() func _apply_responsive_layout() -> void: var viewport_size := get_viewport_rect().size if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: return _last_viewport_size = viewport_size set_anchors_preset(Control.PRESET_FULL_RECT) offset_left = 0.0 offset_top = 0.0 offset_right = 0.0 offset_bottom = 0.0 var s := _hud_scale(viewport_size) if status_bars != null: status_bars.set_anchors_preset(Control.PRESET_TOP_LEFT, true) status_bars.pivot_offset = Vector2.ZERO status_bars.size = Vector2(261.0, 111.0) status_bars.position = Vector2(34.0, 28.0) * s status_bars.scale = Vector2.ONE * (1.25 * s) if boss_status != null: var boss_scale := 1.18 * s var boss_x := maxf(16.0 * s, viewport_size.x - 298.0 * boss_scale - 28.0 * s) boss_status.set_anchors_preset(Control.PRESET_TOP_LEFT, true) boss_status.pivot_offset = Vector2.ZERO boss_status.size = Vector2(298.0, 79.0) boss_status.position = Vector2(boss_x, 34.0 * s) boss_status.scale = Vector2.ONE * boss_scale if rhythm_track != null: rhythm_track.set_anchors_preset(Control.PRESET_FULL_RECT) rhythm_track.offset_left = 0.0 rhythm_track.offset_top = 0.0 rhythm_track.offset_right = 0.0 rhythm_track.offset_bottom = 0.0 rhythm_track.scale = Vector2.ONE _layout_combo_window(viewport_size, s) if combo_skill_label != null: combo_skill_label.visible = false combo_skill_label.text = "" if time_phase_hud != null: time_phase_hud.visible = false if debug_panel != null: debug_panel.visible = false debug_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT, true) debug_panel.offset_left = -368.0 * s debug_panel.offset_top = 172.0 * s debug_panel.offset_right = -24.0 * s debug_panel.offset_bottom = 348.0 * s _layout_move_list_panel(viewport_size, s) func _layout_combo_window(viewport_size: Vector2, s: float) -> void: if combo_window == null: return var combo_scale := 1.28 * s var combo_size := Vector2(255.0, 51.0) var x := 42.0 * s var y := viewport_size.y - 310.0 * s x = clampf(x, 24.0 * s, maxf(24.0 * s, viewport_size.x - combo_size.x * combo_scale - 24.0 * s)) y = clampf(y, 170.0 * s, maxf(170.0 * s, viewport_size.y - combo_size.y * combo_scale - 150.0 * s)) combo_window.set_anchors_preset(Control.PRESET_TOP_LEFT, true) combo_window.pivot_offset = Vector2.ZERO combo_window.size = combo_size combo_window.scale = Vector2.ONE * combo_scale combo_window.position = Vector2(x, y) func _hud_scale(viewport_size: Vector2) -> float: return clampf(minf(viewport_size.x / DESIGN_SIZE.x, viewport_size.y / DESIGN_SIZE.y), HUD_MIN_SCALE, HUD_MAX_SCALE) func toggle_move_list() -> void: set_move_list_visible(move_list_panel == null or not move_list_panel.visible) func set_move_list_visible(visible: bool) -> void: if move_list_panel == null: _build_move_list_panel() move_list_panel.visible = visible func _ensure_move_list_input_action() -> void: if not InputMap.has_action(MOVE_LIST_ACTION): InputMap.add_action(MOVE_LIST_ACTION) if _input_action_has_key(MOVE_LIST_ACTION, KEY_E): return var event := InputEventKey.new() event.keycode = KEY_E event.physical_keycode = KEY_E InputMap.action_add_event(MOVE_LIST_ACTION, event) func _input_action_has_key(action_name: StringName, keycode: Key) -> bool: if not InputMap.has_action(action_name): return false for event: InputEvent in InputMap.action_get_events(action_name): var key_event := event as InputEventKey if key_event != null and (key_event.keycode == keycode or key_event.physical_keycode == keycode): return true return false func _build_move_list_panel() -> void: if move_list_panel != null: return move_list_panel = PanelContainer.new() move_list_panel.name = "MoveListPanel" move_list_panel.visible = false move_list_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE move_list_panel.z_index = 40 move_list_panel.custom_minimum_size = MOVE_LIST_PANEL_SIZE move_list_panel.add_theme_stylebox_override( "panel", _make_style(Color(0.045, 0.055, 0.07, 0.88), Color(0.7, 0.95, 1.0, 0.55)) ) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 16) margin.add_theme_constant_override("margin_right", 18) margin.add_theme_constant_override("margin_bottom", 16) move_list_panel.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 9) margin.add_child(content) var title := _make_move_list_label("招式表", 27, Color(0.92, 0.98, 1.0), HORIZONTAL_ALIGNMENT_CENTER) title.custom_minimum_size = Vector2(0.0, 34.0) content.add_child(title) for section: Dictionary in MOVE_LIST_SECTIONS: _add_move_list_section(content, section) add_child(move_list_panel) func _add_move_list_section(content: VBoxContainer, section: Dictionary) -> void: var section_title := _make_move_list_label(str(section.get("title", "")), 16, Color(0.98, 0.78, 0.36), HORIZONTAL_ALIGNMENT_LEFT) section_title.custom_minimum_size = Vector2(0.0, 22.0) content.add_child(section_title) var grid := GridContainer.new() grid.columns = 2 grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL grid.add_theme_constant_override("h_separation", 12) grid.add_theme_constant_override("v_separation", 5) content.add_child(grid) for row: Array in section.get("rows", []): _add_move_list_row(grid, str(row[0]), str(row[1])) func _add_move_list_row(grid: GridContainer, key_text: String, move_text: String) -> void: var key_cell := PanelContainer.new() key_cell.custom_minimum_size = Vector2(145.0, 27.0) key_cell.add_theme_stylebox_override( "panel", _make_style(Color(0.1, 0.13, 0.16, 0.95), Color(0.35, 0.72, 0.92, 0.65)) ) var key_label := _make_move_list_label(key_text, 14, Color(0.88, 0.96, 1.0), HORIZONTAL_ALIGNMENT_CENTER) key_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER key_cell.add_child(key_label) grid.add_child(key_cell) var move_label := _make_move_list_label(move_text, 15, Color(0.86, 0.9, 0.92), HORIZONTAL_ALIGNMENT_LEFT) move_label.custom_minimum_size = Vector2(220.0, 27.0) move_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER move_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART move_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL grid.add_child(move_label) func _make_move_list_label(text: String, font_size: int, color: Color, alignment: HorizontalAlignment) -> Label: var label := Label.new() label.text = text label.horizontal_alignment = alignment label.add_theme_font_size_override("font_size", font_size) label.add_theme_color_override("font_color", color) return label func _layout_move_list_panel(viewport_size: Vector2, s: float) -> void: if move_list_panel == null: return var panel_scale := 0.96 * s var margin := 30.0 * s var min_y := 126.0 * s var desired_y := maxf(min_y, viewport_size.y * 0.2) var max_y := maxf(min_y, viewport_size.y - MOVE_LIST_PANEL_SIZE.y * panel_scale - 52.0 * s) move_list_panel.set_anchors_preset(Control.PRESET_TOP_LEFT, true) move_list_panel.pivot_offset = Vector2.ZERO move_list_panel.size = MOVE_LIST_PANEL_SIZE move_list_panel.scale = Vector2.ONE * panel_scale move_list_panel.position = Vector2( maxf(18.0 * s, viewport_size.x - MOVE_LIST_PANEL_SIZE.x * panel_scale - margin), clampf(desired_y, min_y, max_y) ) func _on_health_changed(current: int, maximum: int) -> void: health_bar.max_value = max(1, maximum) health_bar.value = clampi(current, 0, maximum) func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void: charge_bar.max_value = maxf(0.01, maximum) charge_bar.value = clampf(current, 0.0, maximum) charge_bar_ready = ready and active _update_charge_level_display(current, maximum, ready, active) if charge_bar_ready: return charge_bar.modulate = Color(1.0, 1.0, 1.0, 1.0 if active or ready else 0.45) func _update_charge_level_display(current: float, maximum: float, ready: bool, active: bool) -> void: if charge_level_label == null: return if not active: charge_level_label.text = "" return var max_level := maxi(2, int(round(maximum)) + 1) var level := clampi(1 + int(floor(current + 0.0001)), 1, max_level) charge_level_label.text = "CHARGE LV%d / %d%s" % [level, max_level, " MAX" if ready else ""] var level_color := _charge_level_color(level) charge_level_label.add_theme_color_override("font_color", level_color) charge_bar.add_theme_stylebox_override( "fill", _make_style(level_color, Color.TRANSPARENT, false) ) func _charge_level_color(level: int) -> Color: match level: 1: return Color(0.92, 0.72, 0.25) 2: return Color(1.0, 0.55, 0.2) return Color(1.0, 0.32, 0.28) func _on_skill_executed(skill: Resource, _judgement: StringName) -> void: if combo_skill_label == null or not combo_skill_label.visible: return var display_name := str(skill.get("display_name")) if display_name.is_empty(): display_name = str(skill.get("id")).to_upper() combo_skill_label.text = display_name var tween := create_tween() combo_skill_label.scale = COMBO_SKILL_POP_SCALE tween.tween_property(combo_skill_label, "scale", COMBO_SKILL_IDLE_SCALE, 0.12) func bind_debug_actor(actor: Node) -> void: debug_actor = actor _apply_responsive_layout() refresh_debug_panel() func bind_boss_actor(actor: Node) -> void: boss_actor = actor _refresh_boss_name_label() _bind_boss_health_component() _update_boss_status_visibility() func _on_flow_state_changed(_previous: StringName, current: StringName) -> void: _boss_room_active = current == &"Gameplay_BossRoom" _update_boss_status_visibility() func _update_boss_status_visibility() -> void: if boss_status == null: return boss_status.visible = _boss_room_active and boss_actor != null and is_instance_valid(boss_actor) func _current_flow_state() -> StringName: var flow := get_tree().root.get_node_or_null("GameFlowManager") if flow == null: return &"" return StringName(str(flow.get("state"))) func _refresh_boss_name_label() -> void: if boss_name_label == null: return if boss_actor == null or not is_instance_valid(boss_actor): return var display_name: Variant = boss_actor.get("display_name") if display_name != null and not str(display_name).is_empty(): boss_name_label.text = str(display_name) func refresh_debug_panel() -> void: if debug_actor == null or not is_instance_valid(debug_actor): _write_debug_defaults() return var state_machine := debug_actor.get_node_or_null("StateMachine") var context := {} if state_machine != null and state_machine.has_method("build_context"): context = state_machine.call("build_context") state_axes_label.text = "State %s / %s / %s / %s" % [ context.get("life_state", &"Alive"), context.get("ground_state", &"Grounded"), context.get("action_phase", &"Neutral"), context.get("defense_state", &"Vulnerable"), ] var action_controller := debug_actor.get_node_or_null("ActionController") action_label.text = "Action %s" % _debug_action_id(action_controller) anchor_label.text = "Anchor %s stretch %.3f" % [ _debug_anchor_grid(action_controller), _debug_startup_stretch(action_controller), ] var effect_container := debug_actor.get_node_or_null("EffectContainer") effects_label.text = "Effects %s" % _debug_effects(effect_container) defense_label.text = _debug_baseline_defense(context, effect_container) patterns_label.text = _patterns_debug_text calibration_label.text = _debug_calibration() _write_hit_log_label() _write_time_phase_debug() func _write_debug_defaults() -> void: state_axes_label.text = "State Alive / Grounded / Neutral / Vulnerable" action_label.text = "Action -" anchor_label.text = "Anchor - stretch 0.000" effects_label.text = "Effects -" defense_label.text = "Baseline Vulnerable damage 1.00 interrupt true" patterns_label.text = _patterns_debug_text calibration_label.text = _debug_calibration() _write_hit_log_label() _write_time_phase_debug() func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: _current_time_phase = current _refresh_time_phase_hud() func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void: _attack_buff_stacks = stacks _attack_buff_max = max_stacks _refresh_time_phase_hud() func _on_streak_changed(count: int) -> void: _streak_count = count _refresh_time_phase_hud() func _refresh_time_phase_hud() -> void: if time_phase_label == null: return var phase_color := TIME_PHASE_FUTURE_COLOR if _current_time_phase == &"future" else TIME_PHASE_PAST_COLOR time_phase_label.text = str(_current_time_phase).to_upper() + _next_time_anchor_hint() time_phase_label.add_theme_color_override("font_color", phase_color) attack_buff_label.text = "ATK BUFF %d/%d (+%d%%)" % [_attack_buff_stacks, _attack_buff_max, _attack_buff_stacks * 10] streak_label.text = "STREAK %d" % _streak_count func _next_time_anchor_hint() -> String: var time_anchor_system := get_tree().root.get_node_or_null("TimeAnchorSystem") var rhythm := get_tree().root.get_node_or_null("RhythmManager") if time_anchor_system == null or rhythm == null or not time_anchor_system.has_method("armed_anchor_summaries"): return "" var summaries: Array = time_anchor_system.call("armed_anchor_summaries") if summaries.is_empty(): return "" var beat_time := maxf(0.001, float(rhythm.get("beat_time"))) var current_beat := (float(rhythm.call("song_position")) + float(rhythm.get("beat_offset"))) / beat_time var next_beat := int((summaries[0] as Dictionary).get("beat", 0)) var beats_left := maxf(0.0, float(next_beat) - current_beat) return " ANCHOR %db" % int(ceil(beats_left)) func _write_time_phase_debug() -> void: if time_phase_debug_label == null: return time_phase_debug_label.text = "TimePhase %s streak %d buff %d/%d" % [ _current_time_phase, _streak_count, _attack_buff_stacks, _attack_buff_max, ] time_anchor_debug_label.text = "TimeAnchor %s" % _debug_time_anchor_summary() _refresh_time_phase_hud() func _debug_time_anchor_summary() -> String: var time_anchor_system := get_tree().root.get_node_or_null("TimeAnchorSystem") if time_anchor_system == null or not time_anchor_system.has_method("armed_anchor_summaries"): return "-" var parts: Array[String] = [] var summaries: Array = time_anchor_system.call("armed_anchor_summaries") for summary: Variant in summaries: if summary is Dictionary: parts.append("b%d(%s)" % [int((summary as Dictionary).get("beat", 0)), (summary as Dictionary).get("source", &"-")]) var last: Dictionary = time_anchor_system.call("last_resolved_summary") if time_anchor_system.has_method("last_resolved_summary") else {} var last_text := "-" if not last.is_empty(): last_text = "b%d %s" % [int(last.get("beat", 0)), "held" if bool(last.get("held", false)) else "broken"] if parts.is_empty(): return "armed - last %s" % last_text return "armed %s last %s" % [" ".join(parts), last_text] func _on_hit_confirmed(result: Dictionary) -> void: _hit_log_entries.push_front(_format_hit_result(result)) _write_hit_log_label() func _on_judgement_made(_quality: StringName, offset_ms: float, _beat_index: int) -> void: _last_diff_ms = offset_ms calibration_label.text = _debug_calibration() func get_hit_log_entries() -> Array[String]: return _hit_log_entries.duplicate() func export_hit_log() -> String: return "\n".join(_hit_log_entries) func _debug_action_id(action_controller: Node) -> String: if action_controller == null: return "-" var action: Resource = action_controller.get("current_action") as Resource if action == null: return "-" var action_id := str(action.get("id")) return action_id if not action_id.is_empty() else "-" func _debug_anchor_grid(action_controller: Node) -> String: if action_controller == null: return "-" return str(action_controller.get("anchor_grid")) func _debug_startup_stretch(action_controller: Node) -> float: if action_controller == null: return 0.0 return float(action_controller.get("startup_stretch_seconds")) func _debug_effects(effect_container: Node) -> String: if effect_container == null: return "-" if effect_container.has_method("active_effect_summaries"): var summaries: Array = effect_container.call("active_effect_summaries") if summaries.is_empty(): return "-" var parts: Array[String] = [] for summary: Variant in summaries: if summary is Dictionary: parts.append(_format_effect_summary(summary)) return ", ".join(parts) if not parts.is_empty() else "-" if effect_container.has_method("active_effect_ids"): var ids: Array = effect_container.call("active_effect_ids") return "-" if ids.is_empty() else _join_string_names(ids) return "-" func _debug_baseline_defense(context: Dictionary, effect_container: Node) -> String: var defense_context := context.duplicate() if effect_container != null: defense_context["effect_container"] = effect_container var result: Dictionary = DefenseResolverScript.resolve_effective_defense(defense_context, []) return "Baseline %s damage %.2f interrupt %s" % [ result.get("defense_state", &"Vulnerable"), float(result.get("damage_mult", 1.0)), str(bool(result.get("interrupts", true))).to_lower(), ] func _debug_patterns() -> String: var patterns: Dictionary = ActionPatternExporterScript.export_patterns() if patterns.is_empty(): return "Patterns -" var highlighted := [] for key: String in ["A", "D", "S", "W"]: if patterns.has(key): highlighted.append("%s->%s" % [key, patterns[key]]) return "Patterns %d total %s" % [ patterns.size(), ", ".join(highlighted), ] func _format_hit_result(result: Dictionary) -> String: var action: Resource = result.get("action", null) as Resource var action_id := "-" if action != null: action_id = str(action.get("id")) var defense = result.get("defense", {}) var defense_state := &"Vulnerable" if defense is Dictionary: defense_state = StringName(str(defense.get("defense_state", &"Vulnerable"))) return "%s dmg %d %s interrupt %s" % [ action_id, int(result.get("damage", 0)), defense_state, str(bool(result.get("interrupts", true))).to_lower(), ] func _write_hit_log_label() -> void: if _hit_log_entries.is_empty(): hit_log_label.text = "Hits -" return var compact := _hit_log_entries.duplicate() if compact.size() > 3: compact.resize(3) hit_log_label.text = "Hits %s" % " | ".join(compact) func _format_effect_summary(summary: Dictionary) -> String: return "%s %s x%d src:%s" % [ summary.get("id", &"-"), _format_effect_remaining(StringName(str(summary.get("duration_type", &"infinite"))), float(summary.get("remaining", 0.0))), int(summary.get("stacks", 1)), summary.get("source", &"-"), ] func _format_effect_remaining(duration_type: StringName, remaining: float) -> String: match duration_type: &"seconds": return "%.1fs" % remaining &"beats": return "%.1fb" % remaining &"actions": return "%da" % int(round(remaining)) &"hits": return "%dhit" % int(round(remaining)) &"hurts": return "%dhurt" % int(round(remaining)) &"measures": return "%dmeasure" % int(round(remaining)) &"until_event": return "until" return "inf" func _debug_calibration() -> String: if is_inf(_last_diff_ms): return "Diff -- ms" return "Diff %+.0f ms" % _last_diff_ms func _join_string_names(values: Array) -> String: var parts: Array[String] = [] for value: Variant in values: parts.append(str(value)) return ", ".join(parts) func _update_charge_bar_flash(delta: float) -> void: if not charge_bar_ready: charge_flash = 0.0 return charge_flash = fmod(charge_flash + delta * 7.0, TAU) var alpha := 0.62 + 0.38 * absf(sin(charge_flash)) charge_bar.modulate = Color(1.0, 1.0, 1.0, alpha) func _apply_bar_styles() -> void: # Health and boss health bars keep their editor-authored StyleBoxTexture # skins (playerui / boss_ui panel wells) so the editor view matches the # running game; only the flat charge bar is styled at runtime. charge_bar.add_theme_stylebox_override( "background", _make_style(Color(0.08, 0.07, 0.12, 0.86), Color(0.42, 0.36, 0.75, 0.9)) ) charge_bar.add_theme_stylebox_override( "fill", _make_style(Color(0.92, 0.72, 0.25, 1.0), Color.TRANSPARENT, false) ) func _make_style(bg_color: Color, border_color: Color, has_border := true) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = bg_color if has_border: style.border_width_left = 1 style.border_width_top = 1 style.border_width_right = 1 style.border_width_bottom = 1 style.border_color = border_color return style func _bind_boss_health_component() -> void: if _boss_health_component != null and is_instance_valid(_boss_health_component): var old_callback := Callable(self, "_on_boss_health_changed") if _boss_health_component.is_connected("health_changed", old_callback): _boss_health_component.disconnect("health_changed", old_callback) _boss_health_component = null if boss_actor == null or not is_instance_valid(boss_actor): boss_health_bar.max_value = 1.0 boss_health_bar.value = 0.0 return _boss_health_component = boss_actor.get_node_or_null("HealthComponent") if _boss_health_component == null: boss_health_bar.max_value = 1.0 boss_health_bar.value = 0.0 return var callback := Callable(self, "_on_boss_health_changed") if not _boss_health_component.is_connected("health_changed", callback): _boss_health_component.connect("health_changed", callback) _on_boss_health_changed(int(_boss_health_component.get("current")), int(_boss_health_component.get("maximum"))) func _on_boss_health_changed(current: int, maximum: int) -> void: boss_health_bar.max_value = max(1, maximum) boss_health_bar.value = clampi(current, 0, maximum) func _event_bus() -> Node: var root := get_tree().root var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus