From ca51680b1c97a7cf2703c86589a755b5f2548a4e Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 11:43:44 +0900 Subject: [PATCH] Show hostile tactic threat hints --- README.md | 2 +- docs/ARCHITECTURE.md | 4 +- docs/GODOT_4_6_MIGRATION.md | 2 +- docs/ROADMAP.md | 4 +- scripts/core/battle_state.gd | 157 +++++++++++++++++++++++++++++++++ scripts/scenes/battle_scene.gd | 41 +++++++++ 6 files changed, 204 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 52a710a..ee07a92 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Move, attack, wait, end turn. - Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities. - Hover tile and unit info. -- Enemy threat range overlay with tile-level threat source and physical damage hints. +- Enemy threat range overlay with tile-level threat source, physical damage hints, and hostile tactic hints. - Damage forecast for selected-unit attacks. - Terrain defense and avoid affect physical attack damage and hit chance. - Min/max attack ranges and counterattack forecasts. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4db4f22..b145bec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ 4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, 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, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage hints, 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, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, 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. @@ -58,6 +58,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. 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. 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. 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. 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. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item pickups, and reinforcements from saved campaign choices. Consumable use can restore HP or MP or cure matching poison/seal/snare/disarm statuses, and in-battle item use, event pickup grants, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items, 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, 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 and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources and estimates physical damage against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, 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 and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, 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, and item pickup effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, 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/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index d98452f..8d77a87 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -47,7 +47,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Manual checkpoint save/load restores the expected current briefing without script errors. - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. -- The Threat toggle or `Y` key shows enemy physical and hostile tactic reach, hovered threatened tiles name the threatening units, and occupied player tiles show physical damage/hit estimates. +- The Threat toggle or `Y` key shows enemy physical and hostile tactic reach, hovered threatened tiles name the threatening units, and occupied player tiles show physical damage/hit estimates plus hostile tactic damage or status hints. - Area tactics such as Blaze, Great Mend, and Poison Mist highlight affected cells, apply only to valid targets in the area, and spend MP once per cast. - March Order and Hamper Order adjust movement range while active and expire with the normal support/debuff timing. - Focus Order and Feint Order adjust physical hit chance through AGI bonuses or penalties and update attack forecasts while active. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 90e8d09..86ba743 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,7 +13,7 @@ - Select player units. - Show move and attack ranges. - Show hover tile and unit information. -- Enemy threat range overlay with tile-level threat source and physical damage hints. +- 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. - Enemy units take AI turns with movement, damage-aware physical attacks, and basic tactic use. @@ -134,7 +134,7 @@ - Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, and missing art falls back to speaker initials. - Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings plus small status pips for active support, debuff, poison, seal, snare, and disarm effects. -- Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item pickups, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items, and the board can overlay enemy threat ranges with physical damage hints. +- Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item pickups, 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 and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview, and dialogue lines can choose left or right portrait placement. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index d316f24..dda3cb7 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -1370,6 +1370,45 @@ func get_physical_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> return result +func get_skill_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if not is_inside(cell): + return result + var target := get_unit_at(cell) + if target.is_empty() or target.get("team", "") == source_team: + return result + + for unit in get_living_units(source_team): + if _unit_has_action_lock(unit, "skill"): + continue + if typeof(unit.get("skills", [])) != TYPE_ARRAY: + continue + for skill_id_value in unit.get("skills", []): + var skill_id := str(skill_id_value) + var skill := get_skill_def(skill_id) + if skill.is_empty(): + continue + if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): + continue + if not _skill_threatens_opponents(skill): + continue + if not _skill_target_valid(unit, target, skill): + continue + var preview := _best_skill_threat_preview_for_target(unit, skill_id, skill, target, cell) + if not preview.is_empty(): + result.append(preview) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_score := _skill_threat_preview_sort_score(a) + var b_score := _skill_threat_preview_sort_score(b) + if a_score != b_score: + return a_score > b_score + if str(a.get("source_name", "")) != str(b.get("source_name", "")): + return str(a.get("source_name", "")) < str(b.get("source_name", "")) + return str(a.get("skill_name", "")) < str(b.get("skill_name", "")) + ) + return result + + func get_objective_cells() -> Array[Vector2i]: var result: Array[Vector2i] = [] _collect_objective_cells(battle_conditions.get("victory", {}), result) @@ -1894,6 +1933,124 @@ func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]: return result +func _best_skill_threat_preview_for_target(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, target_cell: Vector2i) -> Dictionary: + var best_preview := {} + var best_score := -999999 + for origin in _unit_threat_origins(caster): + for center_cell in _skill_cells_from_origin(caster, skill, origin): + if not _skill_area_cells(center_cell, skill).has(target_cell): + continue + var targets := _skill_targets_for_cast(caster, skill_id, skill, center_cell) + if targets.is_empty(): + continue + var preview := _build_skill_threat_preview(caster, skill_id, skill, target, origin, center_cell, targets) + var score := _skill_threat_preview_sort_score(preview) + if score > best_score: + best_score = score + best_preview = preview + return best_preview + + +func _build_skill_threat_preview(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, origin: Vector2i, center_cell: Vector2i, targets: Array[Dictionary]) -> Dictionary: + var skill_kind := str(skill.get("kind", "damage")) + var preview := { + "source_id": str(caster.get("id", "")), + "source_name": str(caster.get("name", caster.get("id", "Unit"))), + "skill_id": skill_id, + "skill_name": str(skill.get("name", skill_id)), + "kind": skill_kind, + "mp_cost": int(skill.get("mp_cost", 0)), + "origin_cell": origin, + "cast_cell": center_cell, + "requires_move": origin != caster.get("pos", origin), + "has_area": _skill_has_area(skill), + "area_shape": _skill_area_shape(skill), + "area_radius": _skill_area_radius(skill), + "target_count": targets.size() + } + if skill_kind == "support": + preview["effect_text"] = _format_skill_threat_effect_summary(skill) + preview["already_active"] = _has_status_effect_from_skill(target, skill_id) + else: + var damage := calculate_skill_damage(caster, target, skill) + var total_damage := 0 + var defeat_count := 0 + for area_target in targets: + var area_damage := calculate_skill_damage(caster, area_target, skill) + total_damage += area_damage + if int(area_target.get("hp", 0)) - area_damage <= 0: + defeat_count += 1 + preview["damage"] = damage + preview["total_damage"] = total_damage + preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage) + preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0 + preview["defeat_count"] = defeat_count + return preview + + +func _skill_threat_preview_sort_score(preview: Dictionary) -> int: + var score := 0 + if str(preview.get("kind", "")) == "damage": + score += int(preview.get("damage", 0)) * 10 + score += int(preview.get("defeat_count", 0)) * 2000 + if bool(preview.get("would_defeat", false)): + score += 10000 + else: + score += _skill_threat_effect_score(preview) + if bool(preview.get("already_active", false)): + score -= 250 + score += int(preview.get("target_count", 0)) * 20 + if bool(preview.get("has_area", false)): + score += 5 + if not bool(preview.get("requires_move", false)): + score += 2 + return score + + +func _skill_threat_effect_score(preview: Dictionary) -> int: + var skill := get_skill_def(str(preview.get("skill_id", ""))) + if skill.is_empty(): + return 0 + var score := 400 + for effect in skill.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + var effect_type := str(effect.get("type", "")) + if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty(): + score += 350 + elif effect_type == "damage_over_time": + score += 250 + max(0, int(effect.get("amount", 0))) * 20 + elif effect_type == "stat_bonus": + score += 150 + abs(int(effect.get("amount", 0))) * 25 + return score + + +func _format_skill_threat_effect_summary(skill: Dictionary) -> String: + var parts := [] + for effect in skill.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + var effect_type := str(effect.get("type", "")) + if effect_type == "stat_bonus": + var stat := str(effect.get("stat", "")) + var amount := int(effect.get("amount", 0)) + if not _is_supported_status_stat(stat) or amount == 0: + continue + var sign := "+" if amount > 0 else "" + parts.append("%s %s%d" % [stat.to_upper(), sign, amount]) + elif effect_type == "damage_over_time": + var damage := int(effect.get("amount", 0)) + if damage <= 0: + continue + parts.append("%s -%d HP" % [_status_display_name(str(effect.get("status", "poison"))), damage]) + elif effect_type == "action_lock": + var status_name := _status_display_name(str(effect.get("status", "status"))) + parts.append("%s %s" % [status_name, _action_lock_display_name(str(effect.get("action", "")))]) + if parts.is_empty(): + return _format_support_effects(skill) + return _join_strings(parts, ", ") + + func _skill_threatens_opponents(skill: Dictionary) -> bool: var target_rule := str(skill.get("target", "enemy")) if target_rule != "enemy" and target_rule != "any": diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index be342b2..18ff715 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1496,6 +1496,9 @@ func _update_cell_info() -> void: var physical_threats := state.get_physical_threat_previews(cell, BattleState.TEAM_ENEMY) if not physical_threats.is_empty(): text += "\nPhysical threat: %s" % _format_physical_threat_preview_text(physical_threats, 3) + var skill_threats := state.get_skill_threat_previews(cell, BattleState.TEAM_ENEMY) + if not skill_threats.is_empty(): + text += "\nSkill threat: %s" % _format_skill_threat_preview_text(skill_threats, 3) cell_info_label.text = text @@ -1524,6 +1527,44 @@ func _format_physical_threat_preview_text(previews: Array[Dictionary], limit: in return _join_strings(parts, "; ") +func _format_skill_threat_preview_text(previews: Array[Dictionary], limit: int) -> String: + var parts := [] + var safe_limit: int = maxi(1, limit) + for index in range(mini(previews.size(), safe_limit)): + var preview: Dictionary = previews[index] + var source_text := str(preview.get("source_name", "Unit")) + var skill_text := str(preview.get("skill_name", "Skill")) + var area_text := _format_skill_threat_area_text(preview) + if str(preview.get("kind", "")) == "damage": + var defeat_text := " KO" if bool(preview.get("would_defeat", false)) else "" + parts.append("%s %s %d dmg%s%s" % [ + source_text, + skill_text, + int(preview.get("damage", 0)), + area_text, + defeat_text + ]) + else: + var active_text := " active" if bool(preview.get("already_active", false)) else "" + parts.append("%s %s %s%s%s" % [ + source_text, + skill_text, + str(preview.get("effect_text", "Effect")), + area_text, + active_text + ]) + if previews.size() > safe_limit: + parts.append("+%d" % (previews.size() - safe_limit)) + return _join_strings(parts, "; ") + + +func _format_skill_threat_area_text(preview: Dictionary) -> String: + var target_count := int(preview.get("target_count", 0)) + if target_count <= 1: + return "" + return " x%d" % target_count + + func _compact_name_list(names: Array[String], limit: int) -> String: var visible_names: Array[String] = [] var safe_limit: int = maxi(1, limit)