extends Node ## Per-run battle statistics for the result screen (AnchorV1.0 §19.6 / §21.10). ## Pure fact subscriber: judgement facts, anchor resolutions and phase shifts ## flow in from the EventBus; the rank formula reads only this node. signal stats_changed const RANK_THRESHOLDS := [ {"rank": "S", "min": 90.0}, {"rank": "A", "min": 80.0}, {"rank": "B", "min": 65.0}, {"rank": "C", "min": 50.0}, {"rank": "D", "min": 0.0}, ] const COMBO_TARGET := 80.0 const ACCURACY_WEIGHT := 65.0 const ANCHOR_WEIGHT := 20.0 const COMBO_WEIGHT := 15.0 var perfect_count := 0 var good_count := 0 var bad_count := 0 var miss_count := 0 var max_combo := 0 var anchor_success_count := 0 var phase_shift_count := 0 var _tracking := false func _ready() -> void: var bus := _event_bus_or_null() if bus == null: return _connect_once(bus, "judgement_made", _on_judgement_made) _connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved) _connect_once(bus, "time_phase_changed", _on_time_phase_changed) _connect_once(bus, "streak_changed", _on_streak_changed) func start_run() -> void: reset() _tracking = true func stop_tracking() -> void: _tracking = false func reset() -> void: perfect_count = 0 good_count = 0 bad_count = 0 miss_count = 0 max_combo = 0 anchor_success_count = 0 phase_shift_count = 0 stats_changed.emit() func summary() -> Dictionary: return { "perfect": perfect_count, "good": good_count, "bad": bad_count, "miss": miss_count, "max_combo": max_combo, "anchor_success": anchor_success_count, "phase_shift_count": phase_shift_count, } ## §19.6: 总分 = 节奏准确分 65 + 锚点控制分 20 + 连击稳定分 15. func total_score() -> float: return accuracy_score() + anchor_score() + combo_score() func accuracy_score() -> float: var total := perfect_count + good_count + bad_count + miss_count if total <= 0: return 0.0 var accuracy_raw := (float(perfect_count) * 1.00 + float(good_count) * 0.85 + float(bad_count) * 0.55) / float(total) return accuracy_raw * ACCURACY_WEIGHT func anchor_score() -> float: var anchor_total := anchor_success_count + phase_shift_count if anchor_total <= 0: return ANCHOR_WEIGHT return float(anchor_success_count) / float(anchor_total) * ANCHOR_WEIGHT func combo_score() -> float: return minf(float(max_combo) / COMBO_TARGET, 1.0) * COMBO_WEIGHT func rank_for_score(score: float) -> String: for entry: Dictionary in RANK_THRESHOLDS: if score >= float(entry.get("min", 0.0)): return str(entry.get("rank", "D")) return "D" func current_rank() -> String: return rank_for_score(total_score()) func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void: if not _tracking: return match quality: &"perfect": perfect_count += 1 &"good": good_count += 1 &"bad": bad_count += 1 &"miss": miss_count += 1 stats_changed.emit() func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void: if not _tracking: return if held: anchor_success_count += 1 stats_changed.emit() func _on_time_phase_changed(_previous: StringName, _current: StringName, reason: StringName) -> void: if not _tracking: return # Chart-driven initialisation is not a player-caused shift. if reason == &"chart_init": return phase_shift_count += 1 stats_changed.emit() func _on_streak_changed(count: int) -> void: if not _tracking: return if count > max_combo: max_combo = count stats_changed.emit() func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void: if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback): bus.connect(signal_name, callback) func _event_bus_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("EventBus")