diff --git a/README.md b/README.md index b124a05..022d370 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Unit selection. - Move, attack, wait, end turn. - Automatic end-turn prompt appears after all controllable allies finish acting, using a styled command-complete confirmation. -- Ally and enemy phase changes raise compact command notices in the active battle HUD. +- Ally, enemy, and objective phase changes raise compact command notices with generated visual icon chips in the active battle HUD. - Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities that events can retune mid-battle. - Hover tile and unit info with class, movement type, AGI, movement, range, and equipped gear. - Active battle unit list opens from the top toolbar with ally/enemy tabs, portrait or sprite rows, compact HP/status/zone badges, and hover combat details. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 8839227..ed54881 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -144,6 +144,8 @@ const BRIEFING_FORCE_PREVIEW_MAX_ALLIES := 2 const BRIEFING_FORCE_PREVIEW_MAX_ENEMY_TYPES := 4 const OPENING_STORY_BEAT_CARD_SIZE := Vector2(122, 66) const OPENING_STORY_BEAT_IMAGE_SIZE := Vector2(112, 42) +const COMMAND_NOTICE_ICON_SIZE := Vector2(32, 32) +const COMMAND_NOTICE_MAX_ICONS := 4 const CHAPTER_SEAL_BADGE_SIZE := Vector2(54, 54) const CHAPTER_SCENARIO_THUMBNAIL_SIZE := Vector2(72, 50) const CHAPTER_INFO_BADGE_SIZE := Vector2(86, 22) @@ -382,6 +384,7 @@ var mission_toggle_button: Button var mission_detail_panel: PanelContainer var mission_detail_label: Label var objective_notice_panel: PanelContainer +var objective_notice_icon_strip: HBoxContainer var objective_notice_label: Label var ui_root_control: Control var screen_backdrop: ColorRect @@ -2570,7 +2573,7 @@ func _create_hud() -> void: objective_notice_panel = PanelContainer.new() objective_notice_panel.visible = false objective_notice_panel.position = Vector2(338, 88) - objective_notice_panel.size = Vector2(604, 154) + objective_notice_panel.size = Vector2(604, 170) objective_notice_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _apply_panel_style(objective_notice_panel, "notice_edict") root.add_child(objective_notice_panel) @@ -2581,11 +2584,19 @@ func _create_hud() -> void: objective_notice_column.add_child(_make_scroll_rod(548, 4)) objective_notice_column.add_child(_make_seal_ribbon(548, 14)) + objective_notice_icon_strip = HBoxContainer.new() + objective_notice_icon_strip.name = "CommandNoticeIconStrip" + objective_notice_icon_strip.alignment = BoxContainer.ALIGNMENT_CENTER + objective_notice_icon_strip.custom_minimum_size = Vector2(548, 32) + objective_notice_icon_strip.mouse_filter = Control.MOUSE_FILTER_PASS + objective_notice_icon_strip.add_theme_constant_override("separation", 8) + objective_notice_column.add_child(objective_notice_icon_strip) + objective_notice_label = Label.new() objective_notice_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER 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) + objective_notice_label.custom_minimum_size = Vector2(548, 62) _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)) @@ -9457,13 +9468,15 @@ func _show_command_notice(title: String, body: String) -> void: var normalized_body := body.strip_edges() if not normalized_body.is_empty(): notice_lines.append(normalized_body) + _rebuild_command_notice_icon_strip(title, normalized_body) 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 + OBJECTIVE_NOTICE_MIN_FONT_SIZE, + objective_notice_label.custom_minimum_size ) if objective_notice_panel != null: objective_notice_panel.visible = true @@ -9473,6 +9486,94 @@ func _show_command_notice(title: String, body: String) -> void: queue_redraw() +func _rebuild_command_notice_icon_strip(title: String, body: String) -> void: + if objective_notice_icon_strip == null: + return + for child in objective_notice_icon_strip.get_children(): + objective_notice_icon_strip.remove_child(child) + child.queue_free() + var entries := _command_notice_icon_entries(title, body) + for entry in entries: + objective_notice_icon_strip.add_child(_make_command_notice_icon_chip(entry)) + objective_notice_icon_strip.visible = not entries.is_empty() + + +func _command_notice_icon_entries(title: String, body: String) -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + var seen := {} + var combined_text := "%s\n%s" % [title, body] + if combined_text.contains("아군"): + _append_command_notice_icon_entry(entries, seen, "toolbar", "move", "행군") + _append_command_notice_icon_entry(entries, seen, "toolbar", "attack", "타격") + _append_command_notice_icon_entry(entries, seen, "toolbar", "wait", "대기") + elif combined_text.contains("적군"): + _append_command_notice_icon_entry(entries, seen, "toolbar", "threat", "적세") + _append_command_notice_icon_entry(entries, seen, "toolbar", "forecast", "예측") + if combined_text.contains("목표") or combined_text.contains("성채") or combined_text.contains("깃발"): + _append_command_notice_icon_entry(entries, seen, "badge", "objective", "목표") + if combined_text.contains("유인"): + _append_command_notice_icon_entry(entries, seen, "badge", "tactic", "유인") + if combined_text.contains("보급") or combined_text.contains("마을"): + _append_command_notice_icon_entry(entries, seen, "badge", "supply", "보급") + if combined_text.contains("회복"): + _append_command_notice_icon_entry(entries, seen, "badge", "recover", "회복") + if combined_text.contains("군자금"): + _append_command_notice_icon_entry(entries, seen, "badge", "gold", "군자금") + if combined_text.contains("퇴각") or combined_text.contains("주의"): + _append_command_notice_icon_entry(entries, seen, "toolbar", "status", "주의") + if entries.is_empty(): + _append_command_notice_icon_entry(entries, seen, "badge", "event", "군령") + return entries + + +func _append_command_notice_icon_entry(entries: Array[Dictionary], seen: Dictionary, source: String, key: String, label: String) -> void: + if entries.size() >= COMMAND_NOTICE_MAX_ICONS: + return + var entry_key := "%s/%s" % [source, key] + if seen.has(entry_key): + return + seen[entry_key] = true + entries.append({ + "source": source, + "key": key, + "label": label + }) + + +func _make_command_notice_icon_chip(entry: Dictionary) -> PanelContainer: + var label_text := str(entry.get("label", "군령")) + var chip := PanelContainer.new() + chip.name = "CommandNoticeIconChip" + chip.custom_minimum_size = COMMAND_NOTICE_ICON_SIZE + chip.mouse_filter = Control.MOUSE_FILTER_PASS + _apply_panel_style(chip, "caption") + var chip_style := chip.get_theme_stylebox("panel") + if chip_style != null: + chip_style.content_margin_left = 2 + chip_style.content_margin_right = 2 + chip_style.content_margin_top = 2 + chip_style.content_margin_bottom = 2 + + var icon := TextureRect.new() + icon.name = "CommandNoticeIconImage" + icon.custom_minimum_size = COMMAND_NOTICE_ICON_SIZE + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.mouse_filter = Control.MOUSE_FILTER_PASS + icon.texture = _command_notice_icon_texture(entry) + chip.add_child(icon) + _set_control_tree_tooltip(chip, label_text) + return chip + + +func _command_notice_icon_texture(entry: Dictionary) -> Texture2D: + var source := str(entry.get("source", "toolbar")) + var key := str(entry.get("key", "status")) + if source == "badge": + return _load_map_badge_texture(key) + return _make_toolbar_icon(key) + + func _on_dialogue_requested(lines: Array) -> void: var normalized_lines := _normalized_dialogue_lines(lines) if normalized_lines.is_empty(): diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 97c2ea3..a7ac04d 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -3469,9 +3469,17 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: failures.append("enemy turn log should show the command notice panel") elif scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("적군 군령") or not scene.objective_notice_label.text.contains("적군 차례"): failures.append("enemy turn notice should use compact command wording: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text)) + if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 2: + failures.append("enemy turn notice should show generated visual command icons") + elif not _has_descendant_texture(scene.objective_notice_icon_strip): + failures.append("enemy turn notice icons should use generated textures") scene._on_log_added("아군 차례가 열렸습니다.") if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("아군 군령") or not scene.objective_notice_label.text.contains("아군 차례"): failures.append("player turn notice should use compact command wording: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text)) + if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 3: + failures.append("player turn notice should show generated visual command icons") + elif not _has_descendant_texture(scene.objective_notice_icon_strip): + failures.append("player turn notice icons should use generated textures") var localized_lines := scene._normalized_dialogue_lines([{"speaker": "Cao Cao", "display_speaker": "조조", "text": "군령을 전하라."}]) if localized_lines.is_empty() or str((localized_lines[0] as Dictionary).get("speaker", "")) != "조조": failures.append("dialogue normalization should preserve display_speaker for localized names") @@ -3593,6 +3601,10 @@ func _check_ancient_ui_theme(failures: Array[String]) -> void: scene._on_objective_updated("동문에 깃발을 세워라.", "조조가 퇴각하면 패전이다.") if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("목표:") or not scene.objective_notice_label.text.contains("주의:"): failures.append("objective update notice should use Korean annotation wording") + if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 2: + failures.append("objective update notice should show objective and warning icons") + elif not _has_descendant_texture(scene.objective_notice_icon_strip): + failures.append("objective update notice icons should use generated textures") scene._update_briefing_camp_overview(scene.state.get_briefing(), false) if scene.briefing_camp_overview_fallback_label == null or scene.briefing_camp_overview_fallback_label.text != "비단 전장도": failures.append("briefing map fallback should read as an old campaign map")