From 4948ed60843b4e831c0c0cbd525ff81880c6169b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 20 Jun 2026 18:59:18 +0900 Subject: [PATCH] Add readability frame capture tool --- README.md | 2 + scripts/scenes/battle_scene.gd | 17 ++- tools/capture_readability_frames.gd | 192 ++++++++++++++++++++++++++++ tools/smoke_visual_assets.gd | 12 ++ 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 tools/capture_readability_frames.gd diff --git a/README.md b/README.md index 5bfa76a..be2b042 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. S Data files and item icon cutouts can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`. +Readable title/opening/briefing/dialogue/battle reference frames can be captured with `Godot_v4.6.3-stable_win64_console.exe --path . --script res://tools/capture_readability_frames.gd`; run this without `--headless` so Godot can render the viewport. The script prints the generated `user://readability_captures` folder path and restores existing saves/settings after capture. + Godot 4.6 migration readiness can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readiness.ps1`. The migration checklist lives in `docs/GODOT_4_6_MIGRATION.md`. ## Direction diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index de2549c..ef25172 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -298,6 +298,7 @@ const UI_TOOLTIP_FONT_SIZE := 16 const UI_TOOLTIP_OUTLINE_SIZE := 3 const MAP_SCROLL_FRAME_PADDING := 12.0 const SPEAKER_DISPLAY_NAMES := { + "Narrator": "이야기", "Cao Cao": "조조", "Xiahou Dun": "하후돈", "Xiahou Yuan": "하후연", @@ -348,6 +349,8 @@ const SPEAKER_DISPLAY_NAMES := { "Scout": "척후" } const SPEAKER_CANONICAL_NAMES := { + "이야기": "Narrator", + "기록": "Narrator", "조조": "Cao Cao", "하후돈": "Xiahou Dun", "하후연": "Xiahou Yuan", @@ -12595,9 +12598,7 @@ func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void: return var texture := _load_portrait_texture(portrait_path) var has_portrait := texture != null - var placeholder_text := _dialogue_portrait_initials(speaker) - if placeholder_text.is_empty(): - placeholder_text = "기록" + var placeholder_text := _dialogue_portrait_placeholder_text(speaker) dialogue_portrait_panel.visible = true dialogue_portrait_texture.texture = texture dialogue_portrait_texture.visible = has_portrait @@ -12650,6 +12651,16 @@ func _load_portrait_texture(portrait_path: String) -> Texture2D: return raw_texture +func _dialogue_portrait_placeholder_text(speaker: String) -> String: + var normalized_speaker := speaker.strip_edges() + if normalized_speaker.is_empty(): + return "기록" + if _canonical_speaker_name(normalized_speaker) == "Narrator": + return "기록" + var initials := _dialogue_portrait_initials(normalized_speaker) + return "기록" if initials.is_empty() else initials + + func _dialogue_portrait_initials(speaker: String) -> String: var normalized_speaker := speaker.strip_edges() if normalized_speaker.is_empty(): diff --git a/tools/capture_readability_frames.gd b/tools/capture_readability_frames.gd new file mode 100644 index 0000000..bf2d217 --- /dev/null +++ b/tools/capture_readability_frames.gd @@ -0,0 +1,192 @@ +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: + scene.objective_notice_panel.visible = false + scene._update_threat_button() + scene._on_command_hint_button_mouse_entered(scene.threat_button) + await _settle() + await _capture_frame(scene, output_dir, label, "06_battle_threat_hint", captures, failures) + + scene.queue_free() + await _settle() + + +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 diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index b8da657..475ba90 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3846,6 +3846,18 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: if portrait_index_after_narration != portrait_index_before_narration: failures.append("speakerless narration should not move the portrait slot") scene._hide_dialogue_panel() + var explicit_narrator_lines := scene._normalized_dialogue_lines([{"speaker": "Narrator", "display_speaker": "이야기", "text": "천하가 어지러워졌다."}]) + if explicit_narrator_lines.is_empty(): + failures.append("dialogue normalization should keep explicit narrator lines") + else: + scene.active_dialogue_lines = explicit_narrator_lines + scene.active_dialogue_index = 0 + scene._render_dialogue_line() + if scene.dialogue_speaker_label == null or scene.dialogue_speaker_label.text != "이야기": + failures.append("explicit narrator should keep the story speaker label: %s" % ("" if scene.dialogue_speaker_label == null else scene.dialogue_speaker_label.text)) + if scene.dialogue_portrait_label == null or scene.dialogue_portrait_label.text != "기록": + failures.append("explicit narrator should use the record placeholder instead of a single initial: %s" % ("" if scene.dialogue_portrait_label == null else scene.dialogue_portrait_label.text)) + scene._hide_dialogue_panel() var enemy_lines := scene._normalized_dialogue_lines([{"speaker": "Wen Chou", "text": "전진!"}]) if enemy_lines.is_empty() or str((enemy_lines[0] as Dictionary).get("speaker", "")) != "문추": failures.append("dialogue normalization should localize enemy speaker names")