109 lines
4.4 KiB
GDScript
109 lines
4.4 KiB
GDScript
## CrashReporter — 全局崩溃/异常日志 Autoload
|
||
##
|
||
## 职责:
|
||
## 1. 捕获 NOTIFICATION_CRASH(引擎级崩溃信号)并写入 user://crash_log.txt
|
||
## 2. 捕获 NOTIFICATION_WM_CLOSE_REQUEST(正常退出),记录最后一帧状态
|
||
## 3. 游戏启动时检查上次是否有未完成的崩溃日志并上报 EventBus(供 HUD 展示提示)
|
||
##
|
||
## 注意:此 Autoload 必须排在所有其他 Autoload 之前注册(project.godot [autoload] 顺序),
|
||
## 确保其他系统崩溃时本 Reporter 已初始化。
|
||
##
|
||
## 使用方式:
|
||
## CrashReporter.log_error("模块名", "错误描述") → 写入 crash_log.txt(不崩溃)
|
||
## CrashReporter.log_fatal("模块名", "致命错误") → 写入后 crash(开发模式下)
|
||
|
||
extends Node
|
||
|
||
const LOG_PATH: String = "user://crash_log.txt"
|
||
const MAX_LOG_BYTES: int = 512 * 1024 # 512KB 上限,防止无限增长
|
||
|
||
var _session_start_time: float = 0.0
|
||
var _last_wave: int = 0 # 由 WaveManager 每波更新,崩溃时记录当前波次
|
||
|
||
# ── 生命周期 ────────────────────────────────────────────────────────────────
|
||
|
||
func _ready() -> void:
|
||
_session_start_time = Time.get_unix_time_from_system()
|
||
_rotate_log_if_oversized()
|
||
_check_previous_crash()
|
||
|
||
func _notification(what: int) -> void:
|
||
match what:
|
||
NOTIFICATION_CRASH:
|
||
_write_crash_entry("CRASH", "引擎级崩溃(NOTIFICATION_CRASH)")
|
||
NOTIFICATION_WM_CLOSE_REQUEST:
|
||
_write_crash_entry("SHUTDOWN", "正常退出")
|
||
get_tree().quit()
|
||
|
||
# ── 公共接口 ────────────────────────────────────────────────────────────────
|
||
|
||
## 记录非致命错误(push_error 级别,不终止运行)
|
||
func log_error(module: String, message: String) -> void:
|
||
push_error("[%s] %s" % [module, message])
|
||
_append_log("ERROR", module, message)
|
||
|
||
## 记录致命错误(开发模式下触发断言失败,发布模式下仅记录)
|
||
func log_fatal(module: String, message: String) -> void:
|
||
_write_crash_entry("FATAL", "[%s] %s" % [module, message])
|
||
assert(false, "[CrashReporter] Fatal: %s — %s" % [module, message])
|
||
|
||
## 由 WaveManager 调用,更新崩溃时记录的波次信息
|
||
func set_current_wave(wave: int) -> void:
|
||
_last_wave = wave
|
||
|
||
# ── 内部实现 ─────────────────────────────────────────────────────────────────
|
||
|
||
func _write_crash_entry(level: String, reason: String) -> void:
|
||
var elapsed: float = Time.get_unix_time_from_system() - _session_start_time
|
||
var entry: String = (
|
||
"\n=== %s @ %s (session +%.1fs, wave=%d) ===\n%s\n" % [
|
||
level,
|
||
Time.get_datetime_string_from_system(),
|
||
elapsed,
|
||
_last_wave,
|
||
reason
|
||
]
|
||
)
|
||
_append_log_raw(entry)
|
||
|
||
func _append_log(level: String, module: String, message: String) -> void:
|
||
var line: String = "[%s][%s] %s — %s\n" % [
|
||
Time.get_time_string_from_system(),
|
||
level, module, message
|
||
]
|
||
_append_log_raw(line)
|
||
|
||
func _append_log_raw(text: String) -> void:
|
||
var f := FileAccess.open(LOG_PATH, FileAccess.READ_WRITE)
|
||
if f == null:
|
||
f = FileAccess.open(LOG_PATH, FileAccess.WRITE)
|
||
if f == null:
|
||
push_warning("CrashReporter: 无法打开 %s" % LOG_PATH)
|
||
return
|
||
f.seek_end(0)
|
||
f.store_string(text)
|
||
|
||
func _rotate_log_if_oversized() -> void:
|
||
if not FileAccess.file_exists(LOG_PATH):
|
||
return
|
||
if FileAccess.get_file_as_bytes(LOG_PATH).size() > MAX_LOG_BYTES:
|
||
var archived: String = LOG_PATH.replace(".txt", "_prev.txt")
|
||
DirAccess.rename_absolute(
|
||
ProjectSettings.globalize_path(LOG_PATH),
|
||
ProjectSettings.globalize_path(archived)
|
||
)
|
||
|
||
func _check_previous_crash() -> void:
|
||
## 检查上次是否有 CRASH 或 FATAL 记录,若有则在下次启动时通过 EventBus 通知
|
||
if not FileAccess.file_exists(LOG_PATH):
|
||
return
|
||
var content: String = FileAccess.get_file_as_string(LOG_PATH)
|
||
if "=== CRASH" in content or "=== FATAL" in content:
|
||
# 延迟到第一帧,确保 EventBus 已初始化
|
||
call_deferred("_emit_crash_detected")
|
||
|
||
func _emit_crash_detected() -> void:
|
||
if Engine.has_singleton("EventBus"):
|
||
# EventID 暂用 0(特殊保留 ID),HUD 订阅后展示"上次游戏异常退出"提示
|
||
EventBus.emit(0, {"source": "crash_reporter", "log_path": LOG_PATH})
|