Improve camp talk and merchant previews

This commit is contained in:
2026-06-18 22:51:06 +09:00
parent 3cdadf8599
commit 65d6dff0f0
2 changed files with 185 additions and 12 deletions

View File

@@ -58,6 +58,8 @@ const DIALOGUE_PANEL_SIZE := Vector2(1040, 210)
const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186) const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186)
const DIALOGUE_COLUMN_SIZE := Vector2(820, 186) const DIALOGUE_COLUMN_SIZE := Vector2(820, 186)
const DIALOGUE_TEXT_SIZE := Vector2(800, 104) const DIALOGUE_TEXT_SIZE := Vector2(800, 104)
const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58)
const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50)
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
const POST_MOVE_MENU_SIZE := Vector2(228, 120) const POST_MOVE_MENU_SIZE := Vector2(228, 120)
@@ -4230,11 +4232,66 @@ func _rebuild_talk_menu() -> void:
talk_list.add_child(empty_label) talk_list.add_child(empty_label)
return return
for conversation in conversations: for conversation in conversations:
var conversation_id := str(conversation.get("id", "")) _add_camp_conversation_row(conversation)
var button := Button.new()
button.text = _format_camp_conversation_button_text(conversation)
button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id)) func _add_camp_conversation_row(conversation: Dictionary) -> void:
talk_list.add_child(button) var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(580, 64)
row.add_theme_constant_override("separation", 8)
talk_list.add_child(row)
row.add_child(_make_camp_conversation_portrait(conversation))
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_theme_constant_override("separation", 3)
row.add_child(column)
var conversation_id := str(conversation.get("id", ""))
var button := Button.new()
button.text = _format_camp_conversation_row_title(conversation)
button.custom_minimum_size = Vector2(500, 32)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id))
column.add_child(button)
var preview_label := Label.new()
preview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
preview_label.custom_minimum_size = Vector2(500, 24)
preview_label.text = _format_camp_conversation_preview_text(conversation)
column.add_child(preview_label)
func _make_camp_conversation_portrait(conversation: Dictionary) -> PanelContainer:
var portrait_panel := PanelContainer.new()
portrait_panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
var stack := Control.new()
stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
portrait_panel.add_child(stack)
var texture_rect := TextureRect.new()
texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
stack.add_child(texture_rect)
var initials_label := Label.new()
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
initials_label.add_theme_font_size_override("font_size", 18)
stack.add_child(initials_label)
var speaker := _camp_conversation_preview_speaker(conversation)
var texture := _load_art_texture(_camp_conversation_preview_portrait_path(conversation))
var has_texture := texture != null
texture_rect.texture = texture
texture_rect.visible = has_texture
initials_label.text = _dialogue_portrait_initials(speaker)
initials_label.visible = not has_texture
return portrait_panel
func _format_talk_status_text(conversations: Array) -> String: func _format_talk_status_text(conversations: Array) -> String:
@@ -4307,6 +4364,83 @@ func _format_camp_conversation_button_text(conversation: Dictionary) -> String:
return _join_strings(parts, " - ") return _join_strings(parts, " - ")
func _format_camp_conversation_row_title(conversation: Dictionary) -> String:
var label := str(conversation.get("label", "Camp Talk"))
var speaker := _camp_conversation_preview_speaker(conversation)
var group := str(conversation.get("group", "topic")).capitalize()
var parts := [group, label]
if not speaker.is_empty():
parts.append(speaker)
var effects_text := _format_camp_conversation_effects_text(conversation)
if not effects_text.is_empty():
parts.append(effects_text)
return _join_strings(parts, " - ")
func _format_camp_conversation_preview_text(conversation: Dictionary) -> String:
var parts := []
var summary := str(conversation.get("summary", "")).strip_edges()
if not summary.is_empty():
parts.append(summary)
var first_text := _camp_conversation_preview_line(conversation)
if not first_text.is_empty():
parts.append(_short_preview_text(first_text, 96))
if parts.is_empty():
return "No preview text"
return _join_strings(parts, " | ")
func _camp_conversation_preview_speaker(conversation: Dictionary) -> String:
var speaker := str(conversation.get("speaker", "")).strip_edges()
if not speaker.is_empty():
return speaker
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
speaker = str(line.get("speaker", "")).strip_edges()
if not speaker.is_empty():
return speaker
return ""
func _camp_conversation_preview_line(conversation: Dictionary) -> String:
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
var text := str(line.get("text", "")).strip_edges()
if not text.is_empty():
return text
return ""
func _camp_conversation_preview_portrait_path(conversation: Dictionary) -> String:
var portrait := str(conversation.get("portrait", "")).strip_edges()
if not portrait.is_empty():
return portrait
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
portrait = str(line.get("portrait", "")).strip_edges()
if not portrait.is_empty():
return portrait
var officer_id := str(conversation.get("officer_id", "")).strip_edges()
if not officer_id.is_empty():
portrait = state.data_catalog.get_portrait_for_officer(officer_id)
if not portrait.is_empty():
return portrait
var speaker := _camp_conversation_preview_speaker(conversation)
if not speaker.is_empty():
return state.data_catalog.get_portrait_for_speaker(speaker)
return ""
func _short_preview_text(text: String, max_length: int) -> String:
var normalized := text.strip_edges()
if max_length <= 3 or normalized.length() <= max_length:
return normalized
return "%s..." % normalized.substr(0, max_length - 3).strip_edges()
func _on_camp_conversation_pressed(conversation_id: String) -> void: func _on_camp_conversation_pressed(conversation_id: String) -> void:
var conversation := _camp_conversation_by_id(conversation_id) var conversation := _camp_conversation_by_id(conversation_id)
if conversation.is_empty(): if conversation.is_empty():
@@ -4978,23 +5112,51 @@ func _clear_shop_list() -> void:
func _add_shop_merchant_notice() -> void: func _add_shop_merchant_notice() -> void:
var merchant := state.get_shop_merchant() var merchant := state.get_shop_merchant()
if merchant.is_empty(): var notice_text := _format_shop_merchant_notice_text(merchant)
if notice_text.is_empty():
return return
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(580, 58)
row.add_theme_constant_override("separation", 8)
shop_list.add_child(row)
row.add_child(_make_initials_badge(str(merchant.get("name", "Merchant")), SHOP_MERCHANT_BADGE_SIZE, 18))
var merchant_label := Label.new()
merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
merchant_label.custom_minimum_size = Vector2(500, 0)
merchant_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
merchant_label.text = notice_text
row.add_child(merchant_label)
func _format_shop_merchant_notice_text(merchant: Dictionary) -> String:
if merchant.is_empty():
return ""
var lines := [] var lines := []
for line in merchant.get("lines", []): for line in merchant.get("lines", []):
var text := str(line).strip_edges() var text := str(line).strip_edges()
if not text.is_empty(): if not text.is_empty():
lines.append(text) lines.append(text)
if lines.is_empty(): if lines.is_empty():
return return ""
var merchant_label := Label.new() return "%s: %s" % [
merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
merchant_label.custom_minimum_size = Vector2(560, 0)
merchant_label.text = "%s: %s" % [
str(merchant.get("name", "Merchant")), str(merchant.get("name", "Merchant")),
_join_strings(lines, " ") _join_strings(lines, " ")
] ]
shop_list.add_child(merchant_label)
func _make_initials_badge(label_text: String, badge_size: Vector2, font_size: int) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = badge_size
var label := Label.new()
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", font_size)
label.text = _dialogue_portrait_initials(label_text)
panel.add_child(label)
return panel
func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String: func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String:

View File

@@ -179,11 +179,22 @@ func _check_scene_texture_loading(failures: Array[String]) -> void:
failures.append("battle scene should find Cao Cao camp conversation by id") failures.append("battle scene should find Cao Cao camp conversation by id")
elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"): elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"):
failures.append("camp conversation button text should include label/speaker") failures.append("camp conversation button text should include label/speaker")
elif scene._camp_conversation_preview_portrait_path(strategy).is_empty():
failures.append("camp conversation preview should resolve Cao Cao portrait")
elif scene._load_art_texture(scene._camp_conversation_preview_portrait_path(strategy)) == null:
failures.append("camp conversation preview portrait should load")
elif not scene._format_camp_conversation_row_title(strategy).contains("Officer"):
failures.append("camp conversation row title should include group")
elif not scene._format_camp_conversation_preview_text(strategy).contains("Yingchuan"):
failures.append("camp conversation preview should include first dialogue text")
var cache := scene._camp_conversation_by_id("northern_woods_cache") var cache := scene._camp_conversation_by_id("northern_woods_cache")
if cache.is_empty(): if cache.is_empty():
failures.append("battle scene should find cache camp conversation by id") failures.append("battle scene should find cache camp conversation by id")
elif not scene._format_camp_conversation_button_text(cache).contains("Supply Bean"): elif not scene._format_camp_conversation_button_text(cache).contains("Supply Bean"):
failures.append("camp conversation button text should preview supply effect") failures.append("camp conversation button text should preview supply effect")
var merchant_notice := scene._format_shop_merchant_notice_text(scene.state.get_shop_merchant())
if not merchant_notice.contains("Camp Merchant") or not merchant_notice.contains("Roadside stores"):
failures.append("shop merchant notice should include name and flavor lines")
scene.free() scene.free()
_check_scenario_background_texture_loading(failures) _check_scenario_background_texture_loading(failures)