From b6a58c2327516297861ebc16d2e02224b92fa628 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 16:18:10 +0900 Subject: [PATCH] Improve Korean UI text fitting --- scripts/scenes/battle_scene.gd | 107 ++++++++++++++++++++++++++++-- tools/smoke_chapter_one_polish.gd | 41 ++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 56419e9..0044fc9 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -70,6 +70,16 @@ const DIALOGUE_PANEL_SIZE := Vector2(1200, 356) const DIALOGUE_PORTRAIT_SIZE := Vector2(220, 310) const DIALOGUE_COLUMN_SIZE := Vector2(850, 318) const DIALOGUE_TEXT_SIZE := Vector2(830, 164) +const OBJECTIVE_HUD_FONT_SIZE := 13 +const OBJECTIVE_HUD_MIN_FONT_SIZE := 11 +const OBJECTIVE_NOTICE_FONT_SIZE := 15 +const OBJECTIVE_NOTICE_MIN_FONT_SIZE := 12 +const BRIEFING_OBJECTIVE_FONT_SIZE := 15 +const BRIEFING_OBJECTIVE_MIN_FONT_SIZE := 12 +const DIALOGUE_SPEAKER_FONT_SIZE := 18 +const DIALOGUE_SPEAKER_MIN_FONT_SIZE := 15 +const DIALOGUE_TEXT_FONT_SIZE := 17 +const DIALOGUE_TEXT_MIN_FONT_SIZE := 14 const BRIEFING_CAMP_THUMBNAIL_SIZE := Vector2(188, 86) const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58) const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50) @@ -735,6 +745,63 @@ func _apply_label_style(label: Label, font_color: Color, font_size: int = 0) -> _apply_control_font(label, font_size >= 16) +func _fit_label_font_size_to_text(label: Label, text: String, base_size: int, min_size: int, available_size: Vector2 = Vector2.ZERO) -> int: + if label == null: + return base_size + var fit_size := available_size + if fit_size.x <= 0.0: + fit_size.x = label.custom_minimum_size.x + if fit_size.y <= 0.0: + fit_size.y = label.custom_minimum_size.y + var font_size := _fit_font_size_for_text(text, base_size, min_size, fit_size) + label.add_theme_font_size_override("font_size", font_size) + return font_size + + +func _fit_font_size_for_text(text: String, base_size: int, min_size: int, available_size: Vector2) -> int: + if text.strip_edges().is_empty(): + return base_size + var safe_base: int = maxi(base_size, min_size) + var safe_min: int = maxi(1, mini(base_size, min_size)) + for font_size in range(safe_base, safe_min - 1, -1): + if _estimated_wrapped_text_height(text, available_size.x, font_size) <= available_size.y: + return font_size + return safe_min + + +func _estimated_wrapped_text_height(text: String, width: float, font_size: int) -> float: + var line_count := _estimated_wrapped_line_count(text, width, font_size) + return float(line_count) * _estimated_line_height(font_size) + + +func _estimated_wrapped_line_count(text: String, width: float, font_size: int) -> int: + var capacity := maxf(1.0, floorf(width / maxf(1.0, float(font_size) * 0.92))) + var count := 0 + for paragraph in text.split("\n"): + var weighted_length := _weighted_text_length(str(paragraph)) + count += maxi(1, int(ceil(weighted_length / capacity))) + return maxi(1, count) + + +func _estimated_line_height(font_size: int) -> float: + return ceilf(float(font_size) * 1.36) + + +func _weighted_text_length(text: String) -> float: + var total := 0.0 + for index in range(text.length()): + var code := text.unicode_at(index) + if code == 10: + continue + if code == 32: + total += 0.35 + elif code < 128: + total += 0.56 + else: + total += 1.0 + return total + + func _make_separator(width: float = 0.0, height: float = 2.0) -> ColorRect: var separator := ColorRect.new() separator.color = UI_OLD_BRONZE @@ -1133,7 +1200,7 @@ func _create_hud() -> void: objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART objective_label.custom_minimum_size = Vector2(382, 36) objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _apply_label_style(objective_label, UI_AGED_INK, 13) + _apply_label_style(objective_label, UI_AGED_INK, OBJECTIVE_HUD_FONT_SIZE) objective_top_row.add_child(objective_label) objective_top_row.add_child(_make_bamboo_gutter(14, 36)) @@ -1171,7 +1238,7 @@ func _create_hud() -> void: objective_notice_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER objective_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART objective_notice_label.custom_minimum_size = Vector2(548, 80) - _apply_label_style(objective_notice_label, UI_AGED_INK, 15) + _apply_label_style(objective_notice_label, UI_AGED_INK, OBJECTIVE_NOTICE_FONT_SIZE) objective_notice_column.add_child(objective_notice_label) objective_notice_column.add_child(_make_scroll_rod(548, 3)) @@ -1593,7 +1660,7 @@ func _create_hud() -> void: briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART briefing_objective_label.custom_minimum_size = Vector2(500, 104) briefing_objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _apply_label_style(briefing_objective_label, UI_AGED_INK, 15) + _apply_label_style(briefing_objective_label, UI_AGED_INK, BRIEFING_OBJECTIVE_FONT_SIZE) briefing_objective_row.add_child(briefing_objective_label) briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104)) briefing_objective_row.add_child(_make_bamboo_gutter(18, 104)) @@ -1930,7 +1997,7 @@ func _create_hud() -> void: dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 88, 24) dialogue_speaker_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL dialogue_speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _apply_label_style(dialogue_speaker_label, UI_PARCHMENT_TEXT, 18) + _apply_label_style(dialogue_speaker_label, UI_PARCHMENT_TEXT, DIALOGUE_SPEAKER_FONT_SIZE) dialogue_speaker_row.add_child(dialogue_speaker_label) dialogue_speaker_row.add_child(_make_seal_tile("", 24)) @@ -1951,7 +2018,7 @@ func _create_hud() -> void: dialogue_text_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 164, DIALOGUE_TEXT_SIZE.y - 20) dialogue_text_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL dialogue_text_label.size_flags_vertical = Control.SIZE_EXPAND_FILL - _apply_label_style(dialogue_text_label, UI_AGED_INK, 17) + _apply_label_style(dialogue_text_label, UI_AGED_INK, DIALOGUE_TEXT_FONT_SIZE) dialogue_text_row.add_child(dialogue_text_label) dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20)) dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20)) @@ -4419,6 +4486,12 @@ func _refresh_ranges() -> void: func _update_hud() -> void: status_label.text = state.get_status_text() objective_label.text = _format_objective_hud_text() + _fit_label_font_size_to_text( + objective_label, + objective_label.text, + OBJECTIVE_HUD_FONT_SIZE, + OBJECTIVE_HUD_MIN_FONT_SIZE + ) campaign_status_label.text = campaign_state.get_progress_text() _update_mission_panel() _update_cell_info() @@ -5166,6 +5239,12 @@ func _on_objective_updated(victory: String, defeat: String) -> void: notice_lines.append("주의: %s" % defeat) if objective_notice_label != null: objective_notice_label.text = "\n".join(notice_lines) + _fit_label_font_size_to_text( + objective_notice_label, + objective_notice_label.text, + OBJECTIVE_NOTICE_FONT_SIZE, + OBJECTIVE_NOTICE_MIN_FONT_SIZE + ) if objective_notice_panel != null: objective_notice_panel.visible = true objective_notice_timer = OBJECTIVE_NOTICE_DURATION @@ -5513,7 +5592,19 @@ func _render_dialogue_line() -> void: _apply_dialogue_side(side) dialogue_speaker_label.text = display_speaker if not display_speaker.is_empty() else "" dialogue_speaker_label.visible = not display_speaker.is_empty() + _fit_label_font_size_to_text( + dialogue_speaker_label, + dialogue_speaker_label.text, + DIALOGUE_SPEAKER_FONT_SIZE, + DIALOGUE_SPEAKER_MIN_FONT_SIZE + ) dialogue_text_label.text = _format_dialogue_body_text(str(line.get("text", ""))) + _fit_label_font_size_to_text( + dialogue_text_label, + dialogue_text_label.text, + DIALOGUE_TEXT_FONT_SIZE, + DIALOGUE_TEXT_MIN_FONT_SIZE + ) _update_dialogue_portrait(display_speaker, str(line.get("portrait", ""))) _update_dialogue_controls() dialogue_panel.visible = true @@ -6160,6 +6251,12 @@ func _show_briefing() -> void: briefing_location_label.visible = not briefing_location_label.text.is_empty() briefing_objective_collapsed = false briefing_objective_label.text = _format_briefing_objectives() + _fit_label_font_size_to_text( + briefing_objective_label, + briefing_objective_label.text, + BRIEFING_OBJECTIVE_FONT_SIZE, + BRIEFING_OBJECTIVE_MIN_FONT_SIZE + ) briefing_objective_label.visible = not briefing_objective_label.text.is_empty() _update_briefing_objective_visibility() briefing_label.text = body diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index b36c628..d810393 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -166,9 +166,50 @@ func _check_readability_contract(failures: Array[String]) -> void: failures.append("briefing objective section should have a close/open button") elif scene.briefing_objective_toggle_button.text != "닫기": failures.append("briefing objective button should start with a clear close label") + _check_text_fit_contract(scene, failures) scene.free() +func _check_text_fit_contract(scene, failures: Array[String]) -> void: + for method_name in [ + "_fit_label_font_size_to_text", + "_fit_font_size_for_text", + "_estimated_wrapped_text_height", + "_estimated_wrapped_line_count", + "_weighted_text_length" + ]: + if not scene.has_method(method_name): + failures.append("missing text fit helper: %s" % method_name) + + var dialogue_area := Vector2(666.0, 144.0) + var short_dialogue_size := int(scene._fit_font_size_for_text("군령을 전하라.", 17, 14, dialogue_area)) + if short_dialogue_size != 17: + failures.append("short dialogue text should keep the base font size, got %d" % short_dialogue_size) + var long_dialogue := "「" \ + + "서영의 매복은 아직 끝나지 않았다. 숲 뒤 봉쇄병이 동쪽 길목을 닫기 전에 전열을 유지하고, 부상병을 마을로 물려 회복시킨 뒤 다시 밀어붙여라. " \ + + "하후돈은 비탈 궁병을 묶고, 하후연은 길목을 살펴라. 조인은 뒤따르는 병력을 모아 조조의 깃발이 끊기지 않게 하라. " \ + + "마지막 봉쇄병이 모습을 드러내면 조급히 흩어지지 말고, 마을과 성채의 회복지를 번갈아 쓰며 전열을 앞으로 밀어라. " \ + + "적이 다시 숲을 흔들어도 군령은 하나다. 길목을 열고 남은 병력을 정리한 뒤 추격로를 확보하라. " \ + + "북쪽 비탈과 남쪽 수풀을 동시에 살피고, 어느 한 장수가 고립되지 않도록 서로의 이동 거리를 맞추어라.」" + var fitted_dialogue_size := int(scene._fit_font_size_for_text(long_dialogue, 17, 14, dialogue_area)) + if fitted_dialogue_size >= 17: + failures.append("long dialogue text should reduce font size to avoid clipping") + if fitted_dialogue_size < 14: + failures.append("long dialogue text should not shrink below its readable minimum") + var fitted_dialogue_height := float(scene._estimated_wrapped_text_height(long_dialogue, dialogue_area.x, fitted_dialogue_size)) + if fitted_dialogue_height > dialogue_area.y: + failures.append("fitted long dialogue should stay within the dialogue text area") + + var objective_area := Vector2(500.0, 104.0) + var long_objective := "승리: 서영의 후군을 꺾었다. 동쪽 길목을 장악하고 뒤따르는 봉쇄병까지 정리하라.\n패배: 조조가 퇴각하거나 제17턴이 시작되면 패한다.\n보상: 군자금 700냥, 콩, 술, 철검" + var fitted_objective_size := int(scene._fit_font_size_for_text(long_objective, 15, 12, objective_area)) + if fitted_objective_size < 12 or fitted_objective_size > 15: + failures.append("briefing objective font fit should stay inside its configured bounds") + var fitted_objective_height := float(scene._estimated_wrapped_text_height(long_objective, objective_area.x, fitted_objective_size)) + if fitted_objective_height > objective_area.y: + failures.append("fitted briefing objective text should stay within its panel") + + func _check_dialogue_localization_and_side(failures: Array[String]) -> void: var scene = BattleSceneScript.new() var lines := scene._normalized_dialogue_lines([