From b0d3dba09230e61233b82be7458682deb4a38b66 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 20 Jun 2026 00:10:17 +0900 Subject: [PATCH] Add portrait roster rows --- README.md | 2 +- scripts/scenes/battle_scene.gd | 139 +++++++++++++++++++++++++++++---- tools/smoke_roster_choices.gd | 89 +++++++++++++++++++++ 3 files changed, 214 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dbc3f0f..ac2ca16 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Pre-battle shop rows emphasize item artwork, compact price/status badges, and hover details instead of dense inline text. - Pre-battle Talk can list scenario camp conversations by officer or topic, branch them from saved campaign choices, grant one-time pre-battle supplies, and shop stock can include merchant flavor lines before a battle. - Pre-battle Armory equipment changes, including unequipping gear back into stock, save immediately and sync roster equipment plus inventory stock. -- Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. +- Pre-battle Roster can move optional officers between sortie and reserve within scenario limits, using portrait rows, compact status badges, and hover details. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. - Pre-battle briefing shows the campaign chapter, chapter battle number, location, victory/defeat summary, and first-clear reward preview. - Pre-battle Chapters overview shows story-arc progress and can open completed or current battles. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 3de6410..e23afaf 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -11211,17 +11211,12 @@ func _rebuild_roster_menu() -> void: return for unit in units: - var unit_id := str(unit.get("id", "")) - var roster_unit_button := Button.new() - roster_unit_button.text = _format_roster_unit_button_text(unit) - _apply_button_style(roster_unit_button) var deployed := bool(unit.get("deployed", true)) - roster_unit_button.disabled = ( + var disabled := ( bool(unit.get("required_deployment", false)) or (not deployed and deployed_count >= max_units) ) - roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id)) - roster_list.add_child(roster_unit_button) + _add_roster_unit_row(unit, disabled) func _clear_roster_list() -> void: @@ -11231,17 +11226,131 @@ func _clear_roster_list() -> void: func _format_roster_unit_button_text(unit: Dictionary) -> String: + return str(unit.get("name", "장수")) + + +func _add_roster_unit_row(unit: Dictionary, disabled: bool) -> void: + var unit_id := str(unit.get("id", "")) + var tooltip_text := _format_roster_unit_tooltip(unit, disabled) + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(580, 62) + row.add_theme_constant_override("separation", 8) + row.tooltip_text = tooltip_text + roster_list.add_child(row) + + row.add_child(_make_roster_unit_portrait(unit, tooltip_text)) + + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.add_theme_constant_override("separation", 4) + column.tooltip_text = tooltip_text + row.add_child(column) + + var roster_unit_button := Button.new() + roster_unit_button.text = _format_roster_unit_button_text(unit) + roster_unit_button.custom_minimum_size = Vector2(500, 30) + roster_unit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + roster_unit_button.disabled = disabled + roster_unit_button.tooltip_text = tooltip_text + _apply_button_style(roster_unit_button) + roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id)) + column.add_child(roster_unit_button) + + var badge_row := HBoxContainer.new() + badge_row.custom_minimum_size = Vector2(500, 22) + badge_row.add_theme_constant_override("separation", 5) + badge_row.tooltip_text = tooltip_text + column.add_child(badge_row) + + for badge_text in _format_roster_unit_badges(unit): + var text := str(badge_text).strip_edges() + if text.is_empty(): + continue + badge_row.add_child(_make_roster_unit_badge(text, tooltip_text)) + + +func _make_roster_unit_portrait(unit: Dictionary, tooltip_text: String) -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE + panel.tooltip_text = tooltip_text + _apply_panel_style(panel, "portrait") + + var stack := Control.new() + stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE + stack.tooltip_text = tooltip_text + panel.add_child(stack) + + var portrait := TextureRect.new() + portrait.set_anchors_preset(Control.PRESET_FULL_RECT) + portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + portrait.texture = _load_art_texture(str(unit.get("portrait", ""))) + portrait.visible = portrait.texture != null + portrait.tooltip_text = tooltip_text + stack.add_child(portrait) + + 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.text = _dialogue_portrait_initials(str(unit.get("name", ""))) + initials_label.visible = portrait.texture == null + initials_label.tooltip_text = tooltip_text + _apply_label_style(initials_label, UI_PARCHMENT_TEXT, 16) + stack.add_child(initials_label) + return panel + + +func _format_roster_unit_badges(unit: Dictionary) -> Array: var state_text := "출진" if bool(unit.get("deployed", true)) else "예비" - var required_text := " 필수" if bool(unit.get("required_deployment", false)) else "" - var protected_text := " 호위" if not bool(unit.get("controllable", true)) else "" - return "%s%s 품계 %d %s %s%s" % [ - str(unit.get("name", "장수")), - protected_text, - int(unit.get("level", 1)), - _unit_class_display_name(unit), + var badges := [ state_text, - required_text + "품계 %d" % int(unit.get("level", 1)), + _unit_class_display_name(unit) ] + if bool(unit.get("required_deployment", false)): + badges.append("필수") + if not bool(unit.get("controllable", true)): + badges.append("호위") + return badges + + +func _format_roster_unit_tooltip(unit: Dictionary, disabled: bool) -> String: + var lines := [ + "%s · 품계 %d · %s" % [ + str(unit.get("name", "장수")), + int(unit.get("level", 1)), + _unit_class_display_name(unit) + ], + "상태: %s" % ("출진" if bool(unit.get("deployed", true)) else "예비"), + "병장: %s" % _format_unit_equipment(unit) + ] + if bool(unit.get("required_deployment", false)): + lines.append("필수 출진 장수입니다.") + if not bool(unit.get("controllable", true)): + lines.append("호위 대상이라 직접 명령할 수 없습니다.") + if not disabled: + lines.append("클릭하면 출진과 예비를 바꿉니다.") + elif not bool(unit.get("required_deployment", false)) and not bool(unit.get("deployed", true)): + lines.append("출진 명부가 가득 찼습니다.") + return _join_strings(lines, "\n") + + +func _make_roster_unit_badge(text: String, tooltip_text: String) -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(82, 22) + panel.tooltip_text = tooltip_text + _apply_panel_style(panel, "caption") + + 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.text = text + label.tooltip_text = tooltip_text + _apply_label_style(label, UI_PARCHMENT_TEXT, 11) + panel.add_child(label) + return panel func _on_roster_unit_pressed(unit_id: String) -> void: diff --git a/tools/smoke_roster_choices.gd b/tools/smoke_roster_choices.gd index 73e631b..c54e9cf 100644 --- a/tools/smoke_roster_choices.gd +++ b/tools/smoke_roster_choices.gd @@ -9,6 +9,7 @@ func _init() -> void: _check_required_only_roster(failures) _check_optional_roster(failures) _check_roster_button_guard(failures) + _check_roster_visual_rows(failures) if failures.is_empty(): print("roster choices smoke ok") @@ -54,9 +55,97 @@ func _check_roster_button_guard(failures: Array[String]) -> void: scene.free() +func _check_roster_visual_rows(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + if not scene.state.load_battle( + "res://data/scenarios/007_xian_emperor_escort.json", + {}, + {}, + {}, + ["dian_wei", "xiahou_yuan", "cao_ren"] + ): + failures.append("007 could not load for roster visual rows") + scene.free() + return + scene._rebuild_roster_menu() + if scene.roster_list == null or scene.roster_list.get_child_count() < 5: + failures.append("roster visual rows should list required and optional officers") + else: + var visible_text := _collect_child_text(scene.roster_list) + var tooltip_text := _collect_child_tooltips(scene.roster_list) + if not visible_text.contains("조조") or not visible_text.contains("품계") or not visible_text.contains("출진"): + failures.append("roster rows should show compact name/status badges: %s" % visible_text) + if visible_text.contains("병장:") or visible_text.contains("청동검") or visible_text.contains("피갑"): + failures.append("roster rows should leave equipment detail to tooltips: %s" % visible_text) + if not tooltip_text.contains("병장:") or not tooltip_text.contains("필수 출진") or not tooltip_text.contains("호위 대상"): + failures.append("roster row tooltips should preserve equipment and lock detail: %s" % tooltip_text) + if not tooltip_text.contains("클릭하면 출진과 예비"): + failures.append("roster row tooltips should explain optional toggle behavior: %s" % tooltip_text) + if not _has_descendant_texture(scene.roster_list): + failures.append("roster rows should reuse officer portrait artwork") + var cao_cao: Dictionary = scene.state.get_unit("cao_cao_ch7") + if scene._format_roster_unit_button_text(cao_cao).contains("품계"): + failures.append("roster row button text should stay compact") + scene.free() + + func _loaded_state(failures: Array[String], label: String, path: String): var state = BattleStateScript.new() if not state.load_battle(path): failures.append("%s could not load scenario: %s" % [label, path]) return null return state + + +func _collect_child_text(root: Node) -> String: + if root == null: + return "" + var parts: Array[String] = [] + _collect_child_text_into(root, parts) + return "\n".join(parts) + + +func _collect_child_text_into(root: Node, parts: Array[String]) -> void: + if root == null: + return + if root is Button: + var button_text := str((root as Button).text).strip_edges() + if not button_text.is_empty(): + parts.append(button_text) + elif root is Label: + var label_text := str((root as Label).text).strip_edges() + if not label_text.is_empty(): + parts.append(label_text) + for child in root.get_children(): + _collect_child_text_into(child, parts) + + +func _collect_child_tooltips(root: Node) -> String: + if root == null: + return "" + var parts: Array[String] = [] + _collect_child_tooltips_into(root, parts) + return "\n".join(parts) + + +func _collect_child_tooltips_into(root: Node, parts: Array[String]) -> void: + if root == null: + return + if root is Control: + var tooltip := str((root as Control).tooltip_text).strip_edges() + if not tooltip.is_empty(): + parts.append(tooltip) + for child in root.get_children(): + _collect_child_tooltips_into(child, parts) + + +func _has_descendant_texture(root: Node) -> bool: + if root == null: + return false + if root is TextureRect and (root as TextureRect).texture != null: + return true + for child in root.get_children(): + if _has_descendant_texture(child): + return true + return false