Files
heros/tools/smoke_title_menu.gd

638 lines
40 KiB
GDScript

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"
func _init() -> 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] = []
_clear_user_file(SAVE_PATH)
_clear_user_file(MANUAL_SAVE_PATH)
_clear_user_file(SETTINGS_PATH)
_check_title_menu_start_load_and_settings(failures)
if not _restore_user_file(SAVE_PATH, save_backup):
failures.append("could not restore campaign save after title menu smoke")
if not _restore_user_file(MANUAL_SAVE_PATH, manual_backup):
failures.append("could not restore manual campaign save after title menu smoke")
if not _restore_user_file(SETTINGS_PATH, settings_backup):
failures.append("could not restore title settings after title menu smoke")
if failures.is_empty():
print("title menu smoke ok")
quit(0)
return
for failure in failures:
push_error(failure)
quit(1)
func _check_title_menu_start_load_and_settings(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._ready()
if scene.title_screen_root == null or not scene.title_screen_root.visible:
failures.append("title screen should be visible on startup")
if scene.bgm_player == null or scene.bgm_player.stream == null or scene.current_bgm_key != "menu":
failures.append("title screen should start with an audible menu BGM stream")
elif scene.bgm_player.volume_db < -12.0:
failures.append("title menu BGM should be loud enough to hear by default: %.1fdb" % scene.bgm_player.volume_db)
if BattleSceneScript.BGM_MENU_PATH.contains("placeholder") or BattleSceneScript.BGM_BATTLE_PATH.contains("placeholder"):
failures.append("menu and battle BGM should use named mood tracks instead of placeholder assets")
if not BattleSceneScript.BGM_MENU_PATH.ends_with("menu_camp_dawn_theme.wav"):
failures.append("title menu should use the camp dawn theme, got %s" % BattleSceneScript.BGM_MENU_PATH)
if not BattleSceneScript.BGM_BATTLE_PATH.ends_with("battle_yingchuan_march.wav"):
failures.append("battle start should use the Yingchuan march theme, got %s" % BattleSceneScript.BGM_BATTLE_PATH)
_check_bgm_stream_quality(failures, scene._bgm_stream_for("menu"), "menu", 20.0)
_check_bgm_stream_quality(failures, scene._bgm_stream_for("battle"), "battle", 16.0)
for sfx_key in [
"ui_click",
"menu_confirm",
"menu_cancel",
"footstep",
"slash",
"bow_release",
"hit_heavy",
"guard_block",
"skill_cast",
"fire_skill",
"item_pickup",
"victory_sting",
"defeat_sting"
]:
if scene._sfx_stream_for(sfx_key) == null:
failures.append("SFX stream should load: %s" % sfx_key)
if scene.title_background_texture_rect == null or scene.title_background_texture_rect.texture == null:
failures.append("title screen should show a high-resolution battlefield backdrop")
elif BattleSceneScript.TITLE_BACKGROUND_PATH == BattleSceneScript.DEFAULT_BATTLE_BACKGROUND_PATH:
failures.append("title screen should use a dedicated cinematic background, not the first battle map backdrop")
for button_spec in [
{"button": scene.top_settings_button, "context": "top settings"},
{"button": scene.end_turn_button, "context": "top end turn"},
{"button": scene.wait_button, "context": "HUD wait"},
{"button": scene.briefing_start_button, "context": "briefing start"}
]:
var sampled_button := button_spec["button"] as Button
if sampled_button != null:
_check_button_texture_slice_fit(failures, sampled_button, str(button_spec["context"]))
if scene.top_settings_button == null or not str(scene.top_settings_button.tooltip_text).contains("전체화면"):
failures.append("top settings tooltip should mention fullscreen display control")
if scene.title_portrait_texture_rect == null or scene.title_portrait_texture_rect.texture == null:
failures.append("title screen should show Cao Cao portrait art")
elif scene.title_portrait_texture_rect.material == null or not bool(scene.title_portrait_texture_rect.material.get_meta("title_portrait_feather_material", false)):
failures.append("title Cao Cao portrait should feather into the background instead of reading as a hard rectangle")
var portrait_shadow := scene.title_screen_root.find_child("TitlePortraitLacquerField", true, false) as ColorRect if scene.title_screen_root != null else null
if portrait_shadow == null:
failures.append("title screen should keep a subtle portrait grounding field")
elif portrait_shadow.color.a > 0.08:
failures.append("title portrait grounding field should not read as a black rectangle: %.2f alpha" % portrait_shadow.color.a)
if scene.title_panel == null or scene.title_panel.position.x > 100.0:
failures.append("title menu panel should sit on the left side of the cinematic title screen")
elif scene.title_portrait_texture_rect != null and scene.title_panel.position.x + scene.title_panel.size.x >= scene.title_portrait_texture_rect.position.x:
failures.append("title menu commands should stay clearly to the left of Cao Cao portrait")
if scene.title_menu_surface_texture_rect == null or scene.title_menu_surface_texture_rect.texture == null:
failures.append("title screen should use a generated command-board surface behind the menu")
else:
if not bool(scene.title_menu_surface_texture_rect.get_meta("generated_title_menu_surface", false)):
failures.append("title command board should carry generated surface metadata")
if scene.title_menu_surface_texture_rect.texture.get_width() < 1024 or scene.title_menu_surface_texture_rect.texture.get_height() < 1024:
failures.append("title command board should keep high-resolution generated pixels")
if scene.title_menu_surface_texture_rect.custom_minimum_size.x > 620.0 or scene.title_menu_surface_texture_rect.custom_minimum_size.y > 660.0:
failures.append("title command board should stay compact so the three commands are not clipped: %s" % str(scene.title_menu_surface_texture_rect.custom_minimum_size))
if scene.title_panel != null and str(scene.title_panel.get_meta("panel_style_variant", "")) != "transparent_overlay":
failures.append("title menu panel should be a transparent content layer over generated art")
if scene.title_caption_label == null or scene.title_caption_label.text != "조조전":
failures.append("title screen should present the campaign title in Korean")
else:
_check_title_text_contrast(failures, scene.title_caption_label, "title caption", 0.88, 3, 34)
if scene.title_subtitle_label != null:
_check_title_text_contrast(failures, scene.title_subtitle_label, "title subtitle", 0.66, 3, 17)
var title_story_feature: Node = null
if scene.title_screen_root != null:
title_story_feature = scene.title_screen_root.find_child("TitleStoryFeaturePanel", true, false)
if title_story_feature != null:
failures.append("title screen should not crowd the first menu with opening story feature artwork")
var title_story_strip: Node = null
if scene.title_screen_root != null:
title_story_strip = scene.title_screen_root.find_child("TitleStoryPreviewStrip", true, false)
if title_story_strip != null:
failures.append("title screen should keep opening story previews inside the prologue, not the first menu")
if scene.briefing_panel == null or scene.briefing_panel.visible:
failures.append("briefing should stay hidden behind the title screen")
if scene.title_start_button == null or scene.title_start_button.text != "처음부터":
failures.append("title screen should expose `처음부터`")
else:
if not scene.title_start_button.visible:
failures.append("title start command should be visible on first screen")
if scene.title_start_button.custom_minimum_size.y < 54.0:
failures.append("title start command should be tall enough to read clearly")
_check_title_command_fit(failures, scene.title_start_button, "title start")
_check_title_button_text_contrast(failures, scene.title_start_button, "title start")
_check_title_menu_button_art(failures, scene.title_start_button, "new", "title start")
if scene.title_load_button == null or scene.title_load_button.text != "로드하기":
failures.append("title screen should expose `로드하기`")
elif not scene.title_load_button.disabled:
failures.append("Load should be disabled when no save exists")
else:
if not scene.title_load_button.visible:
failures.append("title load command should be visible on first screen")
if scene.title_load_button.custom_minimum_size.y < 54.0:
failures.append("title load command should be tall enough to read clearly")
_check_title_command_fit(failures, scene.title_load_button, "title load")
_check_title_button_text_contrast(failures, scene.title_load_button, "title load")
_check_title_menu_button_art(failures, scene.title_load_button, "load", "title load")
if scene.title_load_panel == null:
failures.append("title screen should create a load-slot selection panel")
elif scene.title_load_panel.visible:
failures.append("load-slot selection panel should stay hidden until Game Load is pressed")
if scene.title_settings_button == null or scene.title_settings_button.text != "환경설정":
failures.append("title screen should expose `환경설정`")
else:
if not scene.title_settings_button.visible:
failures.append("title settings command should be visible on first screen")
if scene.title_settings_button.custom_minimum_size.y < 54.0:
failures.append("title settings command should be tall enough to read clearly")
_check_title_command_fit(failures, scene.title_settings_button, "title settings")
_check_title_button_text_contrast(failures, scene.title_settings_button, "title settings")
_check_title_menu_button_art(failures, scene.title_settings_button, "settings", "title settings")
if not str(scene.title_settings_button.tooltip_text).contains("화면"):
failures.append("title settings tooltip should mention display settings")
if scene.title_settings_back_button != null and scene.title_settings_back_button.visible:
failures.append("title settings back command should stay hidden until settings are open")
scene._on_title_settings_pressed()
if scene.title_settings_panel == null or not scene.title_settings_panel.visible:
failures.append("title settings panel should open from the title screen")
if scene.title_start_button != null and scene.title_start_button.visible:
failures.append("title settings should replace the main title commands while open")
if scene.title_bgm_toggle == null or scene.title_sfx_toggle == null or scene.title_edge_scroll_toggle == null or scene.title_fullscreen_toggle == null:
failures.append("title settings should expose BGM, SFX, edge-scroll, and fullscreen toggles")
else:
if not scene.title_bgm_toggle.text.contains("군막의 새벽"):
failures.append("title BGM toggle should name the active menu theme")
if not scene.title_sfx_toggle.text.contains("클릭") or not scene.title_sfx_toggle.text.contains("발소리") or not scene.title_sfx_toggle.text.contains("전투"):
failures.append("title SFX toggle should clarify menu, movement, and battle effects")
if not scene.title_fullscreen_toggle.text.contains("전체화면"):
failures.append("title fullscreen toggle should be clearly named")
if scene.title_bgm_volume_slider == null or scene.title_sfx_volume_slider == null:
failures.append("title settings should expose BGM and SFX volume sliders")
scene._on_title_bgm_toggled(false)
if scene.title_bgm_enabled:
failures.append("BGM toggle should disable background music")
scene._on_title_bgm_volume_changed(45.0)
if scene.title_bgm_volume_percent != 45:
failures.append("BGM volume slider should update the saved BGM volume percent")
scene._on_title_sfx_toggled(false)
if scene.title_sfx_enabled:
failures.append("SFX toggle should disable sound effects")
scene._on_title_sfx_volume_changed(35.0)
if scene.title_sfx_volume_percent != 35:
failures.append("SFX volume slider should update the saved SFX volume percent")
scene._on_title_edge_scroll_toggled(false)
if scene.title_edge_scroll_enabled:
failures.append("edge-scroll toggle should disable edge scrolling")
if scene._update_edge_scroll(0.25):
failures.append("edge scrolling should not move while disabled in settings")
scene._on_title_fullscreen_toggled(true)
if not scene.title_fullscreen_enabled:
failures.append("fullscreen toggle should enable fullscreen state")
if scene._window_mode_for_fullscreen(false) != DisplayServer.WINDOW_MODE_WINDOWED:
failures.append("windowed display mode should map to Godot's windowed mode")
if scene._window_mode_for_fullscreen(true) != DisplayServer.WINDOW_MODE_FULLSCREEN:
failures.append("fullscreen display mode should map to Godot's fullscreen mode")
var persisted_scene = BattleSceneScript.new()
persisted_scene._ready()
if persisted_scene.title_bgm_enabled or persisted_scene.title_sfx_enabled or persisted_scene.title_edge_scroll_enabled:
failures.append("title settings toggles should persist into a new scene instance")
if not persisted_scene.title_fullscreen_enabled:
failures.append("fullscreen setting should persist into a new scene instance")
if persisted_scene.title_bgm_volume_percent != 45 or persisted_scene.title_sfx_volume_percent != 35:
failures.append("title settings volume values should persist into a new scene instance")
persisted_scene.free()
if scene.title_audio_reset_button == null or scene.title_audio_reset_button.text != "기본값 듣기":
failures.append("title settings should expose a clear audio defaults preview button")
else:
_check_title_menu_button_art(failures, scene.title_audio_reset_button, "status", "title audio reset")
if not scene.title_audio_reset_button.tooltip_text.contains("확인음") or not scene.title_audio_reset_button.tooltip_text.contains("발소리") or not scene.title_audio_reset_button.tooltip_text.contains("타격음"):
failures.append("title audio preview should explain the confirmation, movement, and strike samples")
scene.recent_sfx_keys.clear()
scene._on_title_audio_reset_pressed()
if not scene.title_bgm_enabled or not scene.title_sfx_enabled:
failures.append("title audio recovery should re-enable BGM and SFX")
if scene.title_bgm_volume_percent != BattleSceneScript.DEFAULT_AUDIO_VOLUME_PERCENT or scene.title_sfx_volume_percent != BattleSceneScript.DEFAULT_AUDIO_VOLUME_PERCENT:
failures.append("title audio recovery should restore audible volume defaults")
if scene.bgm_player == null or scene.bgm_player.stream == null or scene.current_bgm_key.is_empty():
failures.append("title audio recovery should leave a playable BGM stream selected")
if not scene.recent_sfx_keys.has("menu_confirm"):
failures.append("title audio preview should play an audible confirmation SFX")
if not scene.recent_sfx_keys.has("footstep"):
failures.append("title audio preview should play a movement footstep SFX")
if not scene.recent_sfx_keys.has("slash"):
failures.append("title audio preview should play a strike SFX")
scene._on_title_settings_back_pressed()
if scene.title_settings_panel != null and scene.title_settings_panel.visible:
failures.append("settings back should hide the title settings panel")
scene._on_title_start_pressed()
if scene.title_screen_root != null and scene.title_screen_root.visible:
failures.append("starting a new game should hide the title screen")
if not scene._is_opening_prologue_visible():
failures.append("starting a new campaign should show the opening prologue before briefing")
if scene.briefing_panel != null and scene.briefing_panel.visible:
failures.append("opening prologue should hold back the first briefing until advanced")
if scene.opening_prologue_title_label == null or scene.opening_prologue_body_label == null:
failures.append("opening prologue should expose title and story text")
elif not scene.opening_prologue_body_label.text.contains("조조") or not scene.opening_prologue_body_label.text.contains("황건의 난"):
failures.append("opening prologue should first explain Cao Cao before the Yellow Turban battle")
if scene.opening_prologue_step_label == null or scene.opening_prologue_step_label.text != "첫 기록":
failures.append("opening prologue should describe the current record without bare numeric counters")
if scene.opening_prologue_root == null or scene.opening_prologue_root.find_child("OpeningChapterSeal", true, false) == null:
failures.append("opening prologue should anchor the generated story art with a chapter seal")
var opening_story_strip: Node = null
if scene.opening_prologue_root != null:
opening_story_strip = scene.opening_prologue_root.find_child("OpeningStoryLedgerStrip", true, false)
if opening_story_strip == null:
failures.append("opening prologue should show a compact story ledger in the upper-right corner")
else:
var progress_caption := opening_story_strip.find_child("OpeningStoryProgressCaption", true, false) as Label
if progress_caption == null or progress_caption.text != "영천으로":
failures.append("opening story ledger should use a natural Korean chapter caption")
var progress_scene := opening_story_strip.find_child("OpeningStoryProgressScene", true, false) as Label
if progress_scene == null or not progress_scene.text.contains("첫 기록") or not progress_scene.text.contains("황건의 난"):
failures.append("opening story ledger should name the current record and scene")
var opening_story_cards: Array = opening_story_strip.find_children("OpeningStoryBeatCard*", "", true, false)
if not opening_story_cards.is_empty():
failures.append("opening story ledger should not expose old numbered-card style nodes")
var opening_story_ticks: Array = opening_story_strip.find_children("OpeningStoryLedgerTick*", "", true, false)
if opening_story_ticks.size() != 4:
failures.append("opening story ledger should expose four quiet progress ticks: %d" % opening_story_ticks.size())
var current_count := 0
var current_index := 0
for opening_story_tick in opening_story_ticks:
var tick_node := opening_story_tick as Node
if not bool(tick_node.get_meta("story_progress_tick", false)):
failures.append("opening story tick should be marked as a quiet progress tick")
if bool(tick_node.get_meta("current_story_beat", false)):
current_count += 1
current_index = opening_story_ticks.find(opening_story_tick) + 1
if current_count != 1 or current_index != 1:
failures.append("opening story ledger should highlight the first story tick on entry")
var numeric_labels := opening_story_strip.find_children("*", "Label", true, false)
for numeric_label in numeric_labels:
if str((numeric_label as Label).text).strip_edges() in ["1", "2", "3", "4", "1/4", "2/4", "3/4", "4/4"]:
failures.append("opening story ledger should not expose bare numeric labels")
if opening_story_strip.custom_minimum_size.x > 380.0 or opening_story_strip.custom_minimum_size.y > 62.0:
failures.append("opening story ledger should stay compact in the upper-right corner: %s" % str(opening_story_strip.custom_minimum_size))
if scene.opening_prologue_next_button == null or scene.opening_prologue_next_button.text != "전기 넘기기":
failures.append("opening prologue next button should read as a story action")
else:
_check_opening_story_button_art(failures, scene.opening_prologue_next_button, "opening next")
if scene.opening_prologue_skip_button == null or scene.opening_prologue_skip_button.text != "전기 접기":
failures.append("opening prologue skip button should read as a contextual camp shortcut")
else:
_check_opening_story_button_art(failures, scene.opening_prologue_skip_button, "opening skip")
if scene.opening_prologue_scene_texture_rect == null or scene.opening_prologue_scene_texture_rect.texture == null:
failures.append("opening prologue should show generated story artwork")
elif scene.opening_prologue_scene_texture_rect.custom_minimum_size.x <= scene.opening_prologue_scene_texture_rect.custom_minimum_size.y:
failures.append("opening prologue story artwork should use a wide cinematic frame")
elif scene.opening_prologue_scene_texture_rect.custom_minimum_size.x < 1200 or scene.opening_prologue_scene_texture_rect.custom_minimum_size.y < 700:
failures.append("opening prologue story artwork should fill the first screen instead of sitting in a small frame")
if scene.opening_prologue_body_label != null and scene.opening_prologue_body_label.custom_minimum_size.x < 730:
failures.append("opening prologue body text should have enough caption width for Korean copy")
if scene.opening_prologue_title_label != null:
var opening_title_color: Color = scene.opening_prologue_title_label.get_theme_color("font_color")
if opening_title_color.get_luminance() < 0.78:
failures.append("opening prologue title should use bright caption text over the artwork")
if scene.opening_prologue_title_label.get_theme_constant("outline_size") < 5:
failures.append("opening prologue title should have a strong outline over the artwork")
if scene.opening_prologue_title_label.modulate.a < 0.99 or scene.opening_prologue_title_label.self_modulate.a < 0.99:
failures.append("opening prologue title should render at full opacity")
if scene.opening_prologue_body_label != null:
var opening_body_color: Color = scene.opening_prologue_body_label.get_theme_color("font_color")
if opening_body_color.get_luminance() < 0.86:
failures.append("opening prologue body should use bright readable text over the artwork")
if scene.opening_prologue_body_label.get_theme_constant("outline_size") < 5:
failures.append("opening prologue body should keep a strong outline over generated art")
if scene.opening_prologue_body_label.get_theme_color("font_outline_color").a < 0.95:
failures.append("opening prologue body should use an opaque ink outline")
if scene.opening_prologue_body_label.get_theme_color("font_shadow_color").a < 0.90:
failures.append("opening prologue body should keep a strong shadow over bright artwork")
if scene.opening_prologue_body_label.modulate.a < 0.99 or scene.opening_prologue_body_label.self_modulate.a < 0.99:
failures.append("opening prologue body should render at full opacity")
var opening_caption: PanelContainer = null
if scene.opening_prologue_root != null:
opening_caption = scene.opening_prologue_root.find_child("OpeningCaptionScroll", true, false) as PanelContainer
var opening_caption_backing: ColorRect = null
if scene.opening_prologue_root != null:
opening_caption_backing = scene.opening_prologue_root.find_child("OpeningCaptionTextBacking", true, false) as ColorRect
if opening_caption == null:
failures.append("opening prologue should keep a bottom scroll caption over the artwork")
elif opening_caption.custom_minimum_size.y < 160.0 or opening_caption.custom_minimum_size.y > 188.0 or opening_caption.position.y < 492.0:
failures.append("opening prologue caption should be large enough for readable Korean while leaving the image dominant: %s at %s" % [str(opening_caption.custom_minimum_size), str(opening_caption.position)])
elif str(opening_caption.get_meta("panel_style_variant", "")) != "cinematic_caption":
failures.append("opening prologue caption should use a dark cinematic panel so text remains visible")
elif bool(opening_caption.get_meta("generated_panel_texture", true)):
failures.append("opening prologue caption should use a flat high-contrast panel instead of a busy texture")
elif opening_caption.z_index < 60:
failures.append("opening prologue caption should render above the generated story image")
if opening_caption_backing == null:
failures.append("opening prologue should keep a dedicated dark backing behind story text")
elif opening_caption_backing.color.a < 0.55:
failures.append("opening prologue backing should be dark enough for readable Korean text")
elif opening_caption != null and opening_caption_backing.z_index >= opening_caption.z_index:
failures.append("opening prologue backing should sit behind the caption panel")
if scene.opening_prologue_body_label != null and opening_caption != null:
if scene.opening_prologue_body_label.z_as_relative:
failures.append("opening prologue body should use an absolute text layer over the cinematic art")
if scene.opening_prologue_body_label.z_index <= opening_caption.z_index:
failures.append("opening prologue body text should render above the caption panel")
var first_story_texture: Texture2D = null
if scene.opening_prologue_scene_texture_rect != null:
first_story_texture = scene.opening_prologue_scene_texture_rect.texture
scene._on_opening_prologue_next_pressed()
if scene.opening_prologue_scene_texture_rect == null or scene.opening_prologue_scene_texture_rect.texture == first_story_texture:
failures.append("opening prologue should change story artwork per page")
var second_story_card: Node = null
if scene.opening_prologue_root != null:
second_story_card = scene.opening_prologue_root.find_child("OpeningStoryLedgerTick2", true, false)
if second_story_card == null or not bool(second_story_card.get_meta("current_story_beat", false)):
failures.append("opening story ledger should highlight the second tick after advancing")
if scene.opening_prologue_title_label == null or scene.opening_prologue_title_label.text != "맹덕의 군령":
failures.append("opening prologue should move from Cao Cao background to his decision")
elif not scene.opening_prologue_body_label.text.contains("황건") or not scene.opening_prologue_body_label.text.contains("작은 군세"):
failures.append("opening prologue decision page should explain why Cao Cao raises troops")
scene._on_opening_prologue_next_pressed()
if scene.opening_prologue_title_label == null or scene.opening_prologue_title_label.text != "하후돈의 맹세":
failures.append("opening prologue should then show Xiahou Dun joining Cao Cao")
elif not scene.opening_prologue_body_label.text.contains("하후돈") or not scene.opening_prologue_body_label.text.contains("첫 군령"):
failures.append("opening prologue Xiahou Dun page should link the relationship to the first command")
scene._on_opening_prologue_next_pressed()
if scene.opening_prologue_title_label == null or scene.opening_prologue_title_label.text != "영천의 적진":
failures.append("opening prologue should end by pointing toward Yingchuan")
var prologue_guard := 0
while scene._is_opening_prologue_visible() and prologue_guard < 8:
scene._on_opening_prologue_next_pressed()
prologue_guard += 1
if scene._is_opening_prologue_visible():
failures.append("opening prologue should close after advancing through its pages")
if scene.briefing_panel == null or not scene.briefing_panel.visible:
failures.append("finishing the opening prologue should enter the first briefing")
var audio_scene = BattleSceneScript.new()
audio_scene._ready()
audio_scene._enter_campaign_from_title()
audio_scene._start_battle_from_briefing()
if audio_scene.current_bgm_key != "battle" or audio_scene.bgm_player == null or audio_scene.bgm_player.stream == null:
failures.append("battle start should switch from menu music to an audible battle BGM stream")
audio_scene._show_title_menu()
if audio_scene.current_bgm_key != "menu" or audio_scene.bgm_player == null or audio_scene.bgm_player.stream == null:
failures.append("returning to title should restore the menu BGM stream")
audio_scene.free()
if not scene.campaign_state.has_save():
failures.append("starting a new game should create the main campaign save")
if not scene.campaign_state.save_manual_game():
failures.append("manual checkpoint should be writable for title load selection")
scene._show_title_menu()
scene._update_title_menu_state()
if scene.title_load_button == null or scene.title_load_button.disabled:
failures.append("Game Load should become available after a campaign save exists")
scene._on_title_load_pressed()
if scene.title_load_panel == null or not scene.title_load_panel.visible:
failures.append("Game Load should open a load-slot selection panel instead of loading immediately")
if scene.title_start_button != null and scene.title_start_button.visible:
failures.append("load-slot selection should replace the main title commands while open")
if scene.title_load_auto_button == null or scene.title_load_auto_button.disabled:
failures.append("load-slot selection should enable the automatic campaign save")
elif not scene.title_load_auto_button.text.contains("자동 기록"):
failures.append("automatic load slot should be labeled in Korean")
else:
_check_title_menu_button_art(failures, scene.title_load_auto_button, "campaign", "title automatic load slot")
if scene.title_load_manual_button == null or scene.title_load_manual_button.disabled:
failures.append("load-slot selection should enable the manual checkpoint save")
elif not scene.title_load_manual_button.text.contains("수동 봉인"):
failures.append("manual load slot should be labeled in Korean")
else:
_check_title_menu_button_art(failures, scene.title_load_manual_button, "save", "title manual load slot")
scene._show_title_menu()
if scene.title_load_panel != null and scene.title_load_panel.visible:
failures.append("showing the title menu again should reset the load-slot panel")
scene._on_title_load_pressed()
scene._on_title_load_back_pressed()
if scene.title_load_panel != null and scene.title_load_panel.visible:
failures.append("load-slot back should close the title load panel")
scene._on_title_load_pressed()
scene._on_title_load_auto_pressed()
if scene.title_screen_root != null and scene.title_screen_root.visible:
failures.append("loading a saved game should hide the title screen")
if scene._is_opening_prologue_visible():
failures.append("loading a saved game should skip the new-campaign opening prologue")
if scene.briefing_panel == null or not scene.briefing_panel.visible:
failures.append("loading a saved game should enter the current campaign briefing")
if scene.top_settings_button == null:
failures.append("top toolbar should expose an in-game Settings icon")
scene._on_settings_pressed()
if scene.settings_panel == null or not scene.settings_panel.visible:
failures.append("top Settings icon should open the in-game settings panel")
if scene.settings_bgm_toggle == null or scene.settings_sfx_toggle == null or scene.settings_edge_scroll_toggle == null or scene.settings_fullscreen_toggle == null:
failures.append("in-game settings panel should expose BGM, SFX, edge-scroll, and fullscreen toggles")
else:
if not scene.settings_bgm_toggle.text.contains("군막") or not scene.settings_bgm_toggle.text.contains("전투"):
failures.append("in-game BGM toggle should clarify menu and battle music")
if not scene.settings_sfx_toggle.text.contains("선택") or not scene.settings_sfx_toggle.text.contains("발소리") or not scene.settings_sfx_toggle.text.contains("전투"):
failures.append("in-game SFX toggle should clarify menu, movement, and battle effects")
if not scene.settings_fullscreen_toggle.text.contains("전체화면"):
failures.append("in-game fullscreen toggle should be clearly named")
if scene.settings_bgm_volume_slider == null or scene.settings_sfx_volume_slider == null:
failures.append("in-game settings panel should expose BGM and SFX volume sliders")
scene._on_settings_bgm_toggled(true)
scene._on_settings_sfx_toggled(true)
scene._on_settings_edge_scroll_toggled(true)
scene._on_settings_fullscreen_toggled(true)
scene._on_settings_bgm_volume_changed(70.0)
scene._on_settings_sfx_volume_changed(60.0)
if not scene.title_bgm_enabled or not scene.title_sfx_enabled or not scene.title_edge_scroll_enabled or not scene.title_fullscreen_enabled:
failures.append("in-game settings toggles should update the shared settings state")
if scene.title_bgm_volume_percent != 70 or scene.title_sfx_volume_percent != 60:
failures.append("in-game settings sliders should update the shared volume state")
if scene.settings_audio_reset_button == null or scene.settings_audio_reset_button.text != "기본값 듣기":
failures.append("in-game settings should expose a clear audio defaults preview button")
elif not scene.settings_audio_reset_button.tooltip_text.contains("현재 음악"):
failures.append("in-game audio preview should explain that it plays the current music")
scene._on_settings_bgm_toggled(false)
scene._on_settings_sfx_toggled(false)
scene._on_settings_bgm_volume_changed(20.0)
scene._on_settings_sfx_volume_changed(15.0)
scene.recent_sfx_keys.clear()
scene._on_settings_audio_reset_pressed()
if not scene.title_bgm_enabled or not scene.title_sfx_enabled:
failures.append("in-game audio recovery should re-enable BGM and SFX")
if scene.title_bgm_volume_percent != BattleSceneScript.DEFAULT_AUDIO_VOLUME_PERCENT or scene.title_sfx_volume_percent != BattleSceneScript.DEFAULT_AUDIO_VOLUME_PERCENT:
failures.append("in-game audio recovery should restore audible volume defaults")
if not scene.recent_sfx_keys.has("menu_confirm"):
failures.append("in-game audio preview should play an audible confirmation SFX")
if not scene.recent_sfx_keys.has("footstep"):
failures.append("in-game audio preview should play a movement footstep SFX")
if not scene.recent_sfx_keys.has("slash"):
failures.append("in-game audio preview should play a strike SFX")
scene._hide_settings_panel()
scene._on_new_campaign_pressed()
if not scene._is_opening_prologue_visible():
failures.append("top New Campaign should also show the opening prologue")
scene._on_opening_prologue_skip_pressed()
if scene.briefing_panel == null or not scene.briefing_panel.visible:
failures.append("skipping the top New Campaign prologue should enter the first briefing")
scene.free()
func _check_title_menu_button_art(failures: Array[String], button: Button, expected_icon: String, context: String) -> void:
if button == null:
failures.append("%s button missing" % context)
return
if not bool(button.get_meta("title_menu_button", false)):
failures.append("%s should be configured as a generated title menu button" % context)
if str(button.get_meta("command_icon", "")) != expected_icon:
failures.append("%s should keep command icon metadata `%s`, got `%s`" % [context, expected_icon, str(button.get_meta("command_icon", ""))])
if button.icon == null:
failures.append("%s should expose generated icon art" % context)
elif button.icon.get_width() > BattleSceneScript.TITLE_MENU_ICON_MAX_WIDTH or button.icon.get_height() > BattleSceneScript.TITLE_MENU_ICON_MAX_WIDTH:
failures.append("%s icon should render at title-menu size, got %dx%d" % [context, button.icon.get_width(), button.icon.get_height()])
var stylebox := button.get_theme_stylebox("normal")
if not (stylebox is StyleBoxTexture):
failures.append("%s should use a generated button texture surface" % context)
return
var style := stylebox as StyleBoxTexture
if style.texture == null:
failures.append("%s generated button surface is missing its texture" % context)
elif style.texture.get_width() < 512 or style.texture.get_height() < 192:
failures.append("%s generated button texture should keep high-resolution source pixels: %dx%d" % [context, style.texture.get_width(), style.texture.get_height()])
if style.texture_margin_top > 24 or style.texture_margin_bottom > 24:
failures.append("%s generated button slice should stay shallow enough for the 720p title menu: %d/%d" % [context, style.texture_margin_top, style.texture_margin_bottom])
_check_button_texture_slice_fit(failures, button, context)
if str(button.tooltip_text).strip_edges().is_empty():
failures.append("%s should describe its command through a tooltip" % context)
func _check_opening_story_button_art(failures: Array[String], button: Button, context: String) -> void:
if button == null:
failures.append("%s button missing" % context)
return
if not bool(button.get_meta("opening_story_button", false)):
failures.append("%s should use the compact opening-story button profile" % context)
if button.expand_icon:
failures.append("%s icon should not stretch in QHD fullscreen" % context)
if button.get_theme_constant("icon_max_width") > 22:
failures.append("%s icon should stay small beside Korean text" % context)
var stylebox := button.get_theme_stylebox("normal")
if not (stylebox is StyleBoxTexture):
failures.append("%s should keep a generated button texture" % context)
return
_check_button_texture_slice_fit(failures, button, context)
if button.get_theme_font_size("font_size") > 17:
failures.append("%s should stay visually quiet in the story caption" % context)
func _check_button_texture_slice_fit(failures: Array[String], button: Button, context: String) -> void:
if button == null:
return
var stylebox := button.get_theme_stylebox("normal")
if not (stylebox is StyleBoxTexture):
return
var style := stylebox as StyleBoxTexture
var size := button.custom_minimum_size
if size.x <= 0.0 or size.y <= 0.0:
size = button.get_combined_minimum_size()
var max_x := maxi(1, int(floor(size.x * 0.5)) - 2)
var max_y := maxi(1, int(floor(size.y * 0.5)) - 2)
if style.texture_margin_left > max_x or style.texture_margin_right > max_x:
failures.append("%s generated button horizontal slices should fit the control width: margins %d/%d size %.1f" % [context, style.texture_margin_left, style.texture_margin_right, size.x])
if style.texture_margin_top > max_y or style.texture_margin_bottom > max_y:
failures.append("%s generated button vertical slices should fit the control height: margins %d/%d size %.1f" % [context, style.texture_margin_top, style.texture_margin_bottom, size.y])
func _check_title_command_fit(failures: Array[String], button: Button, context: String) -> void:
if button == null:
return
if button.expand_icon:
failures.append("%s icon should not expand into an oversized title command" % context)
var icon_limit := button.get_theme_constant("icon_max_width")
if icon_limit <= 0 or icon_limit > BattleSceneScript.TITLE_MENU_ICON_MAX_WIDTH:
failures.append("%s icon should be capped at title-menu size, got %d" % [context, icon_limit])
var combined := button.get_combined_minimum_size()
if combined.y > 76.0:
failures.append("%s combined height should fit inside the title menu, got %.1f" % [context, combined.y])
if combined.x > 420.0:
failures.append("%s combined width should fit inside the title menu, got %.1f" % [context, combined.x])
func _check_title_button_text_contrast(failures: Array[String], button: Button, context: String) -> void:
if button == null:
return
_check_title_text_contrast(failures, button, "%s text" % context, 0.88, 3, 18)
var disabled_color: Color = button.get_theme_color("font_disabled_color")
if disabled_color.get_luminance() < 0.70:
failures.append("%s disabled text should remain readable, luminance %.2f" % [context, disabled_color.get_luminance()])
func _check_title_text_contrast(
failures: Array[String],
control: Control,
context: String,
min_luminance: float,
min_outline: int,
min_font_size: int
) -> void:
if control == null:
failures.append("%s missing" % context)
return
var font_color: Color = control.get_theme_color("font_color")
if font_color.get_luminance() < min_luminance:
failures.append("%s should use bright readable text, luminance %.2f" % [context, font_color.get_luminance()])
var outline_size := control.get_theme_constant("outline_size")
if outline_size < min_outline:
failures.append("%s should keep a strong outline, got %d" % [context, outline_size])
var font_size := control.get_theme_font_size("font_size")
if font_size < min_font_size:
failures.append("%s font should be large enough to read, got %d" % [context, font_size])
func _check_bgm_stream_quality(failures: Array[String], stream: AudioStream, label: String, minimum_length: float) -> void:
if stream == null:
failures.append("%s BGM stream should load before playback" % label)
return
var length := stream.get_length()
if length < minimum_length:
failures.append("%s BGM should be a substantial loop, got %.2fs expected %.2fs+" % [label, length, minimum_length])
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