From c928bb64b7b152f438c82b534aac5f556f72ef2d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 17:26:57 +0900 Subject: [PATCH] Improve unit focus HUD details --- README.md | 1 + docs/ARCHITECTURE.md | 4 +- docs/ROADMAP.md | 3 +- scripts/scenes/battle_scene.gd | 125 +++++++++++++++++++++------------ tools/smoke_visual_assets.gd | 31 ++++++++ 5 files changed, 118 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 4d864eb..5389aa0 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Initial AI-generated photorealistic officer portraits for Cao Cao, Xiahou Dun, Xiahou Yuan, Cao Ren, Dian Wei, Guo Jia, and Zhang He. - Initial AI-generated battlefield, class unit, and item icon assets live under `art/backgrounds`, `art/units`, and `art/items`. - Battle HUD portrait thumbnails show the selected unit, or a hovered unit when nothing is selected. +- Selected and hovered unit focus text shows combat role, movement type, current terrain move cost, terrain bonuses, core stats, and status effects. - Dialogue lines can place the portrait on the left or right side of an expanded visual-novel-style dialogue panel with progress and previous-line controls. - Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX. - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dcc4cbe..9703d63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ 4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, selectable camp Talk conversations, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. 5. Player selects a unit. 6. Briefing completion dispatches battle-begin events, including opening dialogue. -7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, a short attack motion, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, hover unit class/movement/equipment details, and floating combat text for core action and UI feedback. +7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, a short attack motion, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, selected/hover unit role, movement, terrain, status, and equipment details, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. @@ -59,6 +59,6 @@ Joined officers gate whether `requires_joined` player deployments are loaded at Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards, can expose `briefing.camp_conversations` through a selectable Talk menu with `briefing.camp_dialogue` as a fallback, and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps, and `shop.merchant` lines are presentation-only camp flavor. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. -The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit, board maps can draw a scenario background texture under translucent terrain overlays and terrain detail patterns, board units render class sprite tokens, team rings, class badges, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` asks the scene to render a short attack motion, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. +The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit and formats focus text with role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, and gear, board maps can draw a scenario background texture under translucent terrain overlays and terrain detail patterns, board units render class sprite tokens, team rings, class badges, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` asks the scene to render a short attack motion, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2bc3f08..4bfb6da 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,7 @@ - Scenario briefing before battle with a campaign chapter header, battle header, objective summary, and reward preview. - Select player units. - Show move and attack ranges. -- Show hover tile and unit information, including class, movement type, AGI, movement, range, and equipped gear. +- Show selected/hover unit information, including class role, movement type, current terrain move cost/bonuses, core stats, status effects, and equipped gear. - Enemy threat range overlay with tile-level threat source, physical damage hints, and hostile tactic hints. - Show basic damage forecast. - Move, attack, wait, and end turn. @@ -137,6 +137,7 @@ - Portrait and art pipeline. Officer definitions can provide default portrait paths, classes can provide board `sprite` paths, items can provide `icon` paths, scenarios can provide map `background` paths, and the first generated officer portraits, unit sprites, item icons, and battlefield background are in `art/`. - Unit sprites and animations. Board movement now has a short slide animation, attacks request a short strike animation, and board units show class sprite tokens, team rings, class badges, low-HP warning rings, and small status pips for active support, debuff, poison, seal, snare, and disarm effects, with HUD summaries showing remaining phases. - Battlefield presentation. Maps can draw a high-resolution background under translucent terrain overlays, with first terrain-specific detail patterns for plains, forest, hill, road, water, and castle cells. +- Battle HUD presentation. Selected and hovered units now summarize battlefield role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, and gear so class identity reads alongside the visual tokens. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item and gold pickups, AI target-focus changes, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items, and the board can overlay enemy threat ranges with physical damage and hostile tactic hints. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. - Chapter UI, objective presentation, and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview plus objective/progress/risk tracker, active battle HUD shows the same live objective panel, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index a575098..1dc99e9 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -293,7 +293,7 @@ func _create_hud() -> void: side_panel.add_child(side_column) var selected_row := HBoxContainer.new() - selected_row.custom_minimum_size = Vector2(420, 104) + selected_row.custom_minimum_size = Vector2(420, 126) selected_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL selected_row.add_theme_constant_override("separation", 8) side_column.add_child(selected_row) @@ -321,7 +321,7 @@ func _create_hud() -> void: selected_label = Label.new() selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - selected_label.custom_minimum_size = Vector2(320, 104) + selected_label.custom_minimum_size = Vector2(320, 126) selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL selected_row.add_child(selected_label) @@ -337,7 +337,7 @@ func _create_hud() -> void: cell_info_label = Label.new() cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - cell_info_label.custom_minimum_size = Vector2(420, 90) + cell_info_label.custom_minimum_size = Vector2(420, 78) side_column.add_child(cell_info_label) forecast_label = Label.new() @@ -1744,16 +1744,7 @@ func _update_hud() -> void: if hovered_unit.is_empty(): selected_label.text = "No unit selected." else: - selected_label.text = "Hover: %s\n%s Lv.%d %s\nHP %d/%d MP %d/%d" % [ - str(hovered_unit.get("name", "Unit")), - str(hovered_unit.get("team", "")).capitalize(), - int(hovered_unit.get("level", 1)), - str(hovered_unit.get("class", "Unit")), - int(hovered_unit.get("hp", 0)), - int(hovered_unit.get("max_hp", 1)), - int(hovered_unit.get("mp", 0)), - int(hovered_unit.get("max_mp", 0)) - ] + selected_label.text = _format_unit_focus_text(hovered_unit, "Hover") wait_button.disabled = true _update_tactic_button({}) _hide_tactic_menu() @@ -1763,27 +1754,7 @@ func _update_hud() -> void: _hide_equip_menu() else: _update_hud_unit_portrait(selected) - var roster_mark := " *" if selected.get("loaded_from_roster", false) else "" - selected_label.text = "%s%s Lv.%d %s EXP %d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d\nMOV %d RNG %d-%d" % [ - selected["name"], - roster_mark, - selected["level"], - selected["class"], - selected.get("exp", 0), - selected["hp"], - selected["max_hp"], - selected.get("mp", 0), - selected.get("max_mp", 0), - selected["atk"], - selected["def"], - selected.get("int", 0), - selected["move"], - selected.get("min_range", 1), - selected["range"] - ] - var selected_effects := state.get_status_effect_summary(selected["id"]) - if not selected_effects.is_empty(): - selected_label.text += "\nEffects: %s" % selected_effects + selected_label.text = _format_unit_focus_text(selected, "Selected") wait_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked() _update_tactic_button(selected) _update_item_button(selected) @@ -1809,6 +1780,81 @@ func _format_objective_hud_text() -> String: return "Objective: %s" % text +func _format_unit_focus_text(unit: Dictionary, prefix: String) -> String: + if unit.is_empty(): + return "No unit selected." + var roster_mark := " *" if unit.get("loaded_from_roster", false) else "" + var control_label := " Protected" if not bool(unit.get("controllable", true)) else "" + var lines := [] + lines.append("%s: %s%s%s" % [ + prefix, + str(unit.get("name", "Unit")), + roster_mark, + control_label + ]) + lines.append("%s Lv.%d %s %s" % [ + str(unit.get("team", "")).capitalize(), + int(unit.get("level", 1)), + str(unit.get("class", "Unit")), + _unit_role_text(unit) + ]) + lines.append("HP %d/%d MP %d/%d EXP %d" % [ + int(unit.get("hp", 0)), + int(unit.get("max_hp", 1)), + int(unit.get("mp", 0)), + int(unit.get("max_mp", 0)), + int(unit.get("exp", 0)) + ]) + lines.append("ATK %d DEF %d INT %d AGI %d" % [ + int(unit.get("atk", 0)), + int(unit.get("def", 0)), + int(unit.get("int", 0)), + int(unit.get("agi", 0)) + ]) + lines.append("MOV %d %s RNG %d-%d" % [ + int(unit.get("move", 0)), + _format_move_type(str(unit.get("move_type", "foot"))), + int(unit.get("min_range", 1)), + int(unit.get("range", 1)) + ]) + var terrain_text := _unit_current_terrain_text(unit) + if not terrain_text.is_empty(): + lines.append(terrain_text) + var status_effects := state.get_status_effect_summary(str(unit.get("id", ""))) + if not status_effects.is_empty(): + lines.append("Effects: %s" % status_effects) + return _join_strings(lines, "\n") + + +func _unit_role_text(unit: Dictionary) -> String: + var class_id := str(unit.get("class_id", "")) + if class_id.contains("cavalry"): + return "Shock cavalry" + if class_id.contains("archer") or class_id.contains("marksman"): + return "Ranged pressure" + if class_id.contains("strategist") or class_id.contains("advisor"): + return "Tactics support" + if class_id.contains("hero") or class_id.contains("commander"): + return "Command tactics" + if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"): + return "Assault fighter" + return "Line infantry" + + +func _unit_current_terrain_text(unit: Dictionary) -> String: + var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) + if not state.is_inside(cell): + return "" + return "Tile %d,%d %s Cost %d DEF +%d AVO +%d%%" % [ + cell.x + 1, + cell.y + 1, + state.get_terrain_name(cell), + state.get_move_cost(cell, str(unit.get("move_type", "foot"))), + state.get_terrain_defense(cell), + state.get_terrain_avoid(cell) + ] + + func _update_mission_panel() -> void: if mission_detail_label == null: return @@ -1887,24 +1933,17 @@ func _update_cell_info() -> void: var unit: Dictionary = summary["unit"] if not unit.is_empty(): var control_label := " Protected" if not bool(unit.get("controllable", true)) else "" - text += "\n%s%s %s Lv.%d %s (%s)\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d AGI %d\nMOV %d RNG %d-%d\nGear: %s" % [ + text += "\nUnit: %s%s %s Lv.%d\n%s %s HP %d/%d MP %d/%d\nGear: %s" % [ unit["name"], control_label, str(unit["team"]).capitalize(), unit["level"], str(unit.get("class", "Unit")), - _format_move_type(str(unit.get("move_type", "foot"))), + _unit_role_text(unit), unit["hp"], unit["max_hp"], unit.get("mp", 0), unit.get("max_mp", 0), - unit["atk"], - unit["def"], - unit.get("int", 0), - unit.get("agi", 0), - unit.get("move", 0), - unit.get("min_range", 1), - unit["range"], _format_hover_unit_equipment(unit) ] var status_effects := state.get_status_effect_summary(unit["id"]) diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 0da25d1..2a9fa9c 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -8,6 +8,7 @@ func _init() -> void: var failures: Array[String] = [] _check_battle_visual_data(failures) _check_scene_texture_loading(failures) + _check_hud_focus_text(failures) _check_attack_motion_signal(failures) if failures.is_empty(): @@ -88,6 +89,36 @@ func _check_scene_texture_loading(failures: Array[String]) -> void: scene.free() +func _check_hud_focus_text(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load battle scene state for HUD focus text") + scene.free() + return + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + var cao_text := scene._format_unit_focus_text(cao_cao, "Selected") + if not cao_text.contains("Command tactics"): + failures.append("Cao Cao HUD focus should describe command role: %s" % cao_text) + if not cao_text.contains("Tile 2,4"): + failures.append("Cao Cao HUD focus should include current tile: %s" % cao_text) + if not cao_text.contains("Cost 1"): + failures.append("Cao Cao HUD focus should include terrain move cost: %s" % cao_text) + if not cao_text.contains("DEF +0"): + failures.append("Cao Cao HUD focus should include terrain defense: %s" % cao_text) + var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun") + xiahou_dun["pos"] = Vector2i(3, 1) + var forest_text := scene._unit_current_terrain_text(xiahou_dun) + if not forest_text.contains("Forest") or not forest_text.contains("Cost 3"): + failures.append("Cavalry movement should use mounted move_type cost on forest: %s" % forest_text) + var archer: Dictionary = scene.state.get_unit("yellow_turban_3") + var archer_text := scene._format_unit_focus_text(archer, "Hover") + if not archer_text.contains("Ranged pressure"): + failures.append("Archer HUD focus should describe ranged role: %s" % archer_text) + if not scene._unit_current_terrain_text(archer).contains("Plain"): + failures.append("Archer terrain text should reflect its current plain tile") + scene.free() + + func _check_attack_motion_signal(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):