extends SceneTree const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") const SAVE_PATH := "user://campaign_save.json" const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" const SETTINGS_PATH := "user://heros_settings.json" const CAPTURE_BASE_DIR := "user://readability_captures" const SETTLE_FRAMES := 3 const VIEWPORT_SPECS := [ {"label": "desktop", "size": Vector2i(1280, 720)}, {"label": "qhd", "size": Vector2i(2560, 1440)} ] func _init() -> void: call_deferred("_run") func _run() -> void: var save_backup := _backup_user_file(SAVE_PATH) var manual_backup := _backup_user_file(MANUAL_SAVE_PATH) var settings_backup := _backup_user_file(SETTINGS_PATH) var failures: Array[String] = [] var captures: Array[Dictionary] = [] var output_dir := "" _clear_user_file(SAVE_PATH) _clear_user_file(MANUAL_SAVE_PATH) _clear_user_file(SETTINGS_PATH) if _is_headless_display(): failures.append("readability frame capture needs a rendering display; run without --headless") else: output_dir = _make_output_dir(failures) if not output_dir.is_empty(): for spec in VIEWPORT_SPECS: await _capture_viewport_set(spec, output_dir, captures, failures) _write_manifest(output_dir, captures) if not _restore_user_file(SAVE_PATH, save_backup): failures.append("could not restore campaign save after readability capture") if not _restore_user_file(MANUAL_SAVE_PATH, manual_backup): failures.append("could not restore manual campaign save after readability capture") if not _restore_user_file(SETTINGS_PATH, settings_backup): failures.append("could not restore settings after readability capture") if failures.is_empty(): print("readability frame capture ok") print("capture directory: %s" % ProjectSettings.globalize_path(output_dir)) quit(0) return for failure in failures: push_error(failure) quit(1) func _is_headless_display() -> bool: return DisplayServer.get_name().to_lower().contains("headless") func _capture_viewport_set(spec: Dictionary, output_dir: String, captures: Array[Dictionary], failures: Array[String]) -> void: var label := str(spec.get("label", "viewport")) var size: Vector2i = spec.get("size", Vector2i(1280, 720)) _set_viewport_size(size) var scene = BattleSceneScript.new() get_root().add_child(scene) await _settle() await _capture_frame(scene, output_dir, label, "01_title", captures, failures) scene._show_opening_prologue() await _settle() await _capture_frame(scene, output_dir, label, "02_opening_rebellion", captures, failures) scene.opening_prologue_index = BattleSceneScript.OPENING_PROLOGUE_PAGES.size() - 1 scene._update_opening_prologue_page() await _settle() await _capture_frame(scene, output_dir, label, "03_opening_contact", captures, failures) scene._hide_opening_prologue() scene._hide_title_menu() scene._load_scenario(scene.campaign_state.get_start_scenario_id()) scene._show_briefing() await _settle() await _capture_frame(scene, output_dir, label, "04_briefing", captures, failures) scene._start_battle_from_briefing() await _settle() await _capture_frame(scene, output_dir, label, "05_battle_dialogue", captures, failures) var guard := 0 while scene._is_dialogue_visible() and guard < 16: scene._advance_dialogue() guard += 1 await _settle() if scene.objective_notice_panel != null and scene.objective_notice_panel.visible: await _capture_frame(scene, output_dir, label, "06_battle_start_notice", captures, failures) if scene.objective_notice_panel != null: scene.objective_notice_panel.visible = false scene._update_hud() await _settle() await _capture_frame(scene, output_dir, label, "07_battle_idle", captures, failures) _prepare_auto_attack_hint_frame(scene) await _settle() await _capture_frame(scene, output_dir, label, "08_battle_auto_attack_hint", captures, failures) scene._update_threat_button() scene._on_command_hint_button_mouse_entered(scene.threat_button) await _settle() await _capture_frame(scene, output_dir, label, "09_battle_threat_hint", captures, failures) _prepare_post_move_action_frame(scene, "") await _settle() await _capture_frame(scene, output_dir, label, "10_post_move_actions", captures, failures) _prepare_post_move_action_frame(scene, "tactic") await _settle() await _capture_frame(scene, output_dir, label, "11_post_move_tactic_picker", captures, failures) _prepare_post_move_action_frame(scene, "item") await _settle() await _capture_frame(scene, output_dir, label, "12_post_move_item_picker", captures, failures) scene.queue_free() await _settle() func _prepare_auto_attack_hint_frame(scene) -> void: scene._hide_post_move_menu() scene._hide_post_move_picker() scene._hide_tactic_menu() scene._hide_item_menu() scene._hide_equip_menu() scene._clear_pending_move_state() scene.selected_skill_id = "" scene.selected_item_id = "" scene.basic_attack_targeting = false var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") if cao_cao.is_empty() or enemy.is_empty(): return cao_cao["pos"] = Vector2i(1, 3) cao_cao["moved"] = false cao_cao["acted"] = false enemy["pos"] = Vector2i(4, 3) enemy["alive"] = true enemy["hp"] = maxi(1, int(enemy.get("hp", 24))) scene.state.select_unit("cao_cao") scene.hover_cell = Vector2i(4, 3) scene._refresh_ranges() scene._focus_board_on_cell(Vector2i(4, 3)) scene._update_hud() scene.queue_redraw() func _prepare_post_move_action_frame(scene, picker_mode: String) -> void: scene._hide_command_hint() scene._hide_post_move_menu() scene._hide_post_move_picker() scene._hide_tactic_menu() scene._hide_item_menu() scene._hide_equip_menu() scene._clear_pending_move_state() scene.selected_skill_id = "" scene.selected_item_id = "" scene.basic_attack_targeting = false scene.state.set_inventory_snapshot({"bean": 2}) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun") if cao_cao.is_empty() or enemy.is_empty() or xiahou_dun.is_empty(): return cao_cao["pos"] = Vector2i(1, 3) cao_cao["moved"] = false cao_cao["acted"] = false cao_cao["mp"] = maxi(10, int(cao_cao.get("mp", 0))) enemy["pos"] = Vector2i(3, 3) enemy["alive"] = true enemy["hp"] = maxi(18, int(enemy.get("hp", 0))) xiahou_dun["pos"] = Vector2i(2, 4) xiahou_dun["alive"] = true xiahou_dun["hp"] = 12 scene.state.select_unit("cao_cao") scene._refresh_ranges() scene._focus_board_on_cell(Vector2i(2, 3)) scene._handle_board_click(_screen_for_cell(scene, Vector2i(2, 3))) if picker_mode == "tactic": scene._on_post_move_tactic_pressed() elif picker_mode == "item": scene._on_post_move_item_pressed() scene._update_hud() scene.queue_redraw() func _screen_for_cell(scene, cell: Vector2i) -> Vector2: var rect: Rect2 = scene._rect_for_cell(cell) return rect.position + rect.size * 0.5 func _capture_frame(_scene, output_dir: String, viewport_label: String, frame_label: String, captures: Array[Dictionary], failures: Array[String]) -> void: var texture := get_root().get_texture() if texture == null: failures.append("viewport texture missing for %s/%s" % [viewport_label, frame_label]) return var image := texture.get_image() if image == null or image.is_empty(): failures.append("viewport image missing for %s/%s" % [viewport_label, frame_label]) return var path := "%s/%s_%s.png" % [output_dir, viewport_label, frame_label] var result := image.save_png(path) if result != OK: failures.append("could not save capture %s: %s" % [path, str(result)]) return captures.append({ "viewport": viewport_label, "frame": frame_label, "path": ProjectSettings.globalize_path(path), "size": [image.get_width(), image.get_height()] }) func _settle() -> void: for _index in range(SETTLE_FRAMES): await process_frame func _set_viewport_size(size: Vector2i) -> void: get_root().size = size DisplayServer.window_set_size(size) func _make_output_dir(failures: Array[String]) -> String: var stamp := _safe_timestamp() var output_dir := "%s/%s" % [CAPTURE_BASE_DIR, stamp] var absolute_dir := ProjectSettings.globalize_path(output_dir) var result := DirAccess.make_dir_recursive_absolute(absolute_dir) if result != OK: failures.append("could not create capture directory %s: %s" % [absolute_dir, str(result)]) return "" return output_dir func _safe_timestamp() -> String: var stamp := Time.get_datetime_string_from_system(false, true) for token in [":", "-", "T", " "]: stamp = stamp.replace(token, "") return stamp func _write_manifest(output_dir: String, captures: Array[Dictionary]) -> void: var manifest := { "generated_at": Time.get_datetime_string_from_system(false, true), "project": "Heroes of Three Kingdoms", "purpose": "readability QA reference frames", "captures": captures } var file := FileAccess.open("%s/manifest.json" % output_dir, FileAccess.WRITE) if file != null: file.store_string(JSON.stringify(manifest, "\t")) func _backup_user_file(path: String) -> Dictionary: if not FileAccess.file_exists(path): return {"exists": false, "text": ""} return { "exists": true, "text": FileAccess.get_file_as_string(path) } func _restore_user_file(path: String, backup: Dictionary) -> bool: if bool(backup.get("exists", false)): var file := FileAccess.open(path, FileAccess.WRITE) if file == null: return false file.store_string(str(backup.get("text", ""))) return true return _clear_user_file(path) func _clear_user_file(path: String) -> bool: if not FileAccess.file_exists(path): return true return DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK