Improve Korean UI text fitting

This commit is contained in:
2026-06-19 16:18:10 +09:00
parent ee6e82e5eb
commit b6a58c2327
2 changed files with 143 additions and 5 deletions

View File

@@ -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

View File

@@ -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([