From b2a00f214c6050db2888f7748f53e59b2ba1b6ad Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 20 Jun 2026 18:12:34 +0900 Subject: [PATCH] Improve readable story text wrapping --- scripts/scenes/battle_scene.gd | 86 +++++++++++++++++++++++++------ tools/smoke_chapter_one_polish.gd | 25 +++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 82ece3e..70cc722 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -164,6 +164,10 @@ const DIALOGUE_SPEAKER_FONT_SIZE := 20 const DIALOGUE_SPEAKER_MIN_FONT_SIZE := 16 const DIALOGUE_TEXT_FONT_SIZE := 19 const DIALOGUE_TEXT_MIN_FONT_SIZE := 16 +const DIALOGUE_VISIBLE_BODY_LINES := 5 +const DIALOGUE_VISIBLE_BODY_CHARS := 44 +const OPENING_VISIBLE_BODY_LINES := 3 +const OPENING_VISIBLE_BODY_CHARS := 36 const LOCAL_COMMAND_TITLE_FONT_SIZE := 14 const LOCAL_COMMAND_TITLE_MIN_FONT_SIZE := 10 const LOCAL_COMMAND_DETAIL_FONT_SIZE := 12 @@ -2437,27 +2441,62 @@ func _compact_visible_text(text: String, max_lines: int, max_chars_per_line: int var normalized := text.strip_edges() if normalized.is_empty(): return "" + var safe_max_lines: int = maxi(1, max_lines) + var safe_max_chars: int = maxi(4, max_chars_per_line) var visible_lines: Array[String] = [] - var source_lines := normalized.split("\n") + var source_lines: Array[String] = [] + for raw_source_line in normalized.split("\n"): + var source_line := str(raw_source_line).strip_edges() + if not source_line.is_empty(): + source_lines.append(source_line) var truncated := false - for raw_line in source_lines: - var line := str(raw_line).strip_edges() + if source_lines.size() > 1: + for source_index in range(source_lines.size()): + if visible_lines.size() >= safe_max_lines: + truncated = true + break + visible_lines.append(_short_preview_text(source_lines[source_index], safe_max_chars)) + if source_index < source_lines.size() - 1 and visible_lines.size() >= safe_max_lines: + truncated = true + break + if truncated and not visible_lines.is_empty(): + var last_index := visible_lines.size() - 1 + var last_line := visible_lines[last_index] + if not last_line.ends_with("..."): + var trimmed_line := last_line + if trimmed_line.length() > safe_max_chars - 3: + trimmed_line = trimmed_line.substr(0, safe_max_chars - 3).strip_edges() + visible_lines[last_index] = "%s..." % trimmed_line + return _join_strings(visible_lines, "\n") + for source_index in range(source_lines.size()): + var line := source_lines[source_index] if line.is_empty(): continue - if visible_lines.size() >= max_lines: + var remaining := line + while not remaining.is_empty(): + if visible_lines.size() >= safe_max_lines: + truncated = true + break + var split_index := _preview_split_index(remaining, safe_max_chars) + var preview := remaining.substr(0, split_index).strip_edges() + if preview.is_empty(): + preview = remaining.substr(0, mini(remaining.length(), safe_max_chars)).strip_edges() + visible_lines.append(preview) + remaining = remaining.substr(split_index).strip_edges() + if remaining.is_empty(): + break + if truncated: + break + if source_index < source_lines.size() - 1 and visible_lines.size() >= safe_max_lines: truncated = true break - var preview := _short_preview_text(line, max_chars_per_line) - if preview != line: - truncated = true - visible_lines.append(preview) if visible_lines.is_empty(): return "" if truncated: var last_index := visible_lines.size() - 1 var last_line := visible_lines[last_index] if not last_line.ends_with("..."): - var trimmed_limit: int = maxi(4, max_chars_per_line) + var trimmed_limit: int = safe_max_chars var trimmed_line := last_line if trimmed_line.length() > trimmed_limit - 3: trimmed_line = trimmed_line.substr(0, trimmed_limit - 3).strip_edges() @@ -2465,6 +2504,19 @@ func _compact_visible_text(text: String, max_lines: int, max_chars_per_line: int return _join_strings(visible_lines, "\n") +func _preview_split_index(text: String, max_chars_per_line: int) -> int: + var normalized := text.strip_edges() + if normalized.length() <= max_chars_per_line: + return normalized.length() + var split_at: int = max_chars_per_line + for index in range(max_chars_per_line, maxi(0, max_chars_per_line - 12), -1): + var code := normalized.unicode_at(index - 1) + if code == 32 or code == 44 or code == 46 or code == 183 or code == 12289 or code == 12290: + split_at = index + break + return maxi(1, split_at) + + 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 @@ -12427,12 +12479,14 @@ func _render_dialogue_line() -> void: 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( + _set_readable_preview_label_text( dialogue_text_label, - dialogue_text_label.text, + _format_dialogue_body_text(str(line.get("text", ""))), DIALOGUE_TEXT_FONT_SIZE, - DIALOGUE_TEXT_MIN_FONT_SIZE + DIALOGUE_TEXT_MIN_FONT_SIZE, + dialogue_text_label.custom_minimum_size, + DIALOGUE_VISIBLE_BODY_LINES, + DIALOGUE_VISIBLE_BODY_CHARS ) _update_dialogue_portrait(display_speaker, str(line.get("portrait", ""))) _update_dialogue_controls() @@ -15019,12 +15073,14 @@ func _update_opening_prologue_page() -> void: ) _apply_opening_caption_text_style(opening_prologue_title_label, UI_OPENING_CAPTION_TITLE) if opening_prologue_body_label != null: - _set_fitted_label_text( + _set_readable_preview_label_text( opening_prologue_body_label, str(page.get("body", "")), 22, 18, - opening_prologue_body_label.custom_minimum_size + opening_prologue_body_label.custom_minimum_size, + OPENING_VISIBLE_BODY_LINES, + OPENING_VISIBLE_BODY_CHARS ) _apply_opening_caption_text_style(opening_prologue_body_label, UI_OPENING_CAPTION_BODY) if opening_prologue_step_label != null: diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index e1b78e3..8be124b 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -740,6 +740,16 @@ func _check_text_fit_contract(scene, failures: Array[String]) -> void: )).split("\n") if compact_lines.size() > 2: failures.append("compact visible text should respect the requested line budget: %s" % str(compact_lines)) + var compact_single_line := str(scene._compact_visible_text( + "영천 들판 너머의 황건 진영이 마을과 성채를 등지고 길게 늘어서 있으니 조급히 들이치지 말고 전열을 유지하라.", + 3, + 18 + )) + var compact_single_lines := compact_single_line.split("\n") + if compact_single_lines.size() < 2: + failures.append("long single-line text should wrap into multiple readable preview lines: %s" % compact_single_line) + if compact_single_lines.size() > 3: + failures.append("long single-line text should still respect the line budget: %s" % compact_single_line) func _check_command_notice_readable_preview(failures: Array[String]) -> void: @@ -753,6 +763,8 @@ func _check_command_notice_readable_preview(failures: Array[String]) -> void: notice_lines = str(scene.objective_notice_label.text).split("\n") if notice_lines.size() > 3: failures.append("long command notice should keep only title plus two readable body lines: %s" % str(notice_lines)) + if scene.objective_notice_label != null and not scene.objective_notice_label.text.contains("주의:"): + failures.append("long command notice should keep the warning line visible beside the goal: %s" % scene.objective_notice_label.text) if scene.objective_notice_label != null and not scene.objective_notice_label.tooltip_text.contains("마지막 황건 잔당"): failures.append("long command notice should preserve the full text in its tooltip") scene.free() @@ -802,6 +814,19 @@ func _check_first_battle_rendered_text_fit(failures: Array[String]) -> void: scene._render_dialogue_line() _check_label_fits_visible_area(scene, failures, scene.dialogue_speaker_label, "opening dialogue speaker %d" % (index + 1)) _check_label_fits_visible_area(scene, failures, scene.dialogue_text_label, "opening dialogue body %d" % (index + 1)) + var long_dialogue := "동쪽 성채의 깃발을 먼저 꺾되 북쪽 숲의 보급고를 지나치지 마라. 적이 마을을 등지고 버티면 하후돈으로 앞줄을 묶고 조조는 뒤로 돌아 마지막 황건 잔당을 끊어라." + scene.active_dialogue_lines = scene._normalized_dialogue_lines([{"speaker": "Cao Cao", "display_speaker": "조조", "text": long_dialogue}]) + scene.active_dialogue_index = 0 + scene._render_dialogue_line() + _check_label_fits_visible_area(scene, failures, scene.dialogue_text_label, "long tactical dialogue body") + if scene.dialogue_text_label != null: + var visible_dialogue_lines := str(scene.dialogue_text_label.text).split("\n") + if visible_dialogue_lines.size() < 2: + failures.append("long tactical dialogue should show more than a single clipped line: %s" % scene.dialogue_text_label.text) + if visible_dialogue_lines.size() > BattleSceneScript.DIALOGUE_VISIBLE_BODY_LINES: + failures.append("long tactical dialogue should respect visible line budget: %s" % scene.dialogue_text_label.text) + if not scene.dialogue_text_label.tooltip_text.contains("마지막 황건 잔당"): + failures.append("long tactical dialogue should preserve the full copy in tooltip: %s" % scene.dialogue_text_label.tooltip_text) scene.free()