From 01c7851b1d32dd41c5af02ed470c441a87d261b9 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 04:27:05 +0900 Subject: [PATCH] Add support and debuff tactics --- README.md | 2 +- data/defs/classes.json | 8 +-- data/defs/skills.json | 24 ++++++++ docs/ARCHITECTURE.md | 4 +- docs/DATA_MODEL.md | 6 +- docs/ROADMAP.md | 4 +- scripts/core/battle_state.gd | 108 ++++++++++++++++++++++++++++++++- scripts/scenes/battle_scene.gd | 8 ++- 8 files changed, 148 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 53dbd25..1d8890b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Damage forecast for selected-unit attacks. - Terrain defense and avoid affect physical attack damage and hit chance. - Min/max attack ranges and counterattack forecasts. -- MP tactics with low/high damage options, healing options, a first support buff, a skill list menu, previews, and target overlays. +- MP tactics with low/high damage options, healing options, ally support buffs, enemy debuffs, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery preview, and target overlay. - Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. - Scenario-specific pre-battle shop purchases and 50% sell-back save immediately and sync into the upcoming battle inventory. diff --git a/data/defs/classes.json b/data/defs/classes.json index 52e4a55..f436e4b 100644 --- a/data/defs/classes.json +++ b/data/defs/classes.json @@ -5,7 +5,7 @@ "move_type": "foot", "move": 4, "attack_range": [1, 1], - "skills": ["spark", "fire_tactic", "mend", "guard_order"], + "skills": ["spark", "fire_tactic", "mend", "guard_order", "rally_order", "disrupt_order"], "growth": { "hp": "B", "mp": "B", "atk": "B", "def": "B", "int": "A", "agi": "B" }, "base_bonus": { "hp": 0, "atk": 0, "def": 0 }, "equipment_slots": { @@ -21,7 +21,7 @@ "move_type": "foot", "move": 5, "attack_range": [1, 1], - "skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order"], + "skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order", "rally_order", "disrupt_order"], "growth": { "hp": "A", "mp": "B", "atk": "A", "def": "B", "int": "A", "agi": "B" }, "base_bonus": { "hp": 4, "atk": 1, "def": 1, "int": 1 }, "equipment_slots": { @@ -160,7 +160,7 @@ "move_type": "foot", "move": 4, "attack_range": [1, 1], - "skills": ["spark", "fire_tactic", "mend", "guard_order"], + "skills": ["spark", "fire_tactic", "mend", "guard_order", "rally_order", "disrupt_order"], "growth": { "hp": "C", "mp": "A", "atk": "E", "def": "D", "int": "A", "agi": "C" }, "base_bonus": { "hp": -2, "mp": 8, "atk": -2, "def": -1, "int": 2 }, "equipment_slots": { @@ -176,7 +176,7 @@ "move_type": "foot", "move": 4, "attack_range": [1, 1], - "skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order"], + "skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order", "rally_order", "disrupt_order"], "growth": { "hp": "B", "mp": "A", "atk": "E", "def": "C", "int": "A", "agi": "C" }, "base_bonus": { "hp": 2, "mp": 6, "atk": -2, "def": 1, "int": 2 }, "equipment_slots": { diff --git a/data/defs/skills.json b/data/defs/skills.json index 59a734e..756e256 100644 --- a/data/defs/skills.json +++ b/data/defs/skills.json @@ -55,5 +55,29 @@ "effects": [ { "type": "stat_bonus", "stat": "def", "amount": 4, "duration_turns": 1 } ] + }, + "rally_order": { + "name": "Rally Order", + "kind": "support", + "target": "ally", + "mp_cost": 5, + "range": [0, 2], + "power": 0, + "stat": "int", + "effects": [ + { "type": "stat_bonus", "stat": "atk", "amount": 3, "duration_turns": 1 } + ] + }, + "disrupt_order": { + "name": "Disrupt Order", + "kind": "support", + "target": "enemy", + "mp_cost": 5, + "range": [1, 3], + "power": 0, + "stat": "int", + "effects": [ + { "type": "stat_bonus", "stat": "atk", "amount": -3, "duration_turns": 2 } + ] } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 012b4d1..6b5812e 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, and power or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, 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, and power, support, or debuff effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, attacks, and can cast damage or healing 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. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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 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, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved 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, or current-scenario advancement; 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 and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, `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 and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support and debuff 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/DATA_MODEL.md b/docs/DATA_MODEL.md index 6c204f3..bb0832d 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -242,9 +242,9 @@ Skills define MP tactics. A class, officer, or scenario deployment can add skill } ``` -Support skills use an `effects` array. The first supported effect is `stat_bonus`, with `stat` in `atk`, `def`, `int`, or `agi`, a non-zero `amount`, and positive `duration_turns`. These effects are battle-only and expire when the target's team begins enough future phases. +Support skills use an `effects` array. The first supported effect is `stat_bonus`, with `stat` in `atk`, `def`, `int`, or `agi`, a non-zero `amount`, and positive `duration_turns`. Positive amounts are buffs and negative amounts are debuffs; both are battle-only and expire when the target's team begins enough future phases. -The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, and `guard_order`. Hero and strategist classes get low-cost Spark alongside Fire/Mend and Guard Order, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class. +The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, `guard_order`, `rally_order`, and `disrupt_order`. Hero and strategist classes get low-cost Spark alongside Fire/Mend, ally orders, and the first enemy debuff, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class. ## Terrain @@ -473,7 +473,7 @@ Event-spawned units use the same compact deployment shape as scenario starting u ## Current Implementation -`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support effects, EXP, level-ups, and enemy tactic AI without changing scenario files. +`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support buffs, enemy debuffs, EXP, level-ups, and enemy tactic AI without changing scenario files. `scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, or branch decisions. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d52c3a6..3a1add5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,9 +32,9 @@ - Weapon, armor, and accessory slots. Basic stat bonuses exist. - Equipment rewards can be stored, displayed, and swapped from the battle HUD. - Terrain movement, defense, and avoid modifiers affect combat. -- Skills, tactics, and MP. Multiple single-target damage/heal tactics, a first support buff, a skill list menu with range/effect hints, and enemy tactic AI exist. +- Skills, tactics, and MP. Multiple single-target damage/heal tactics, ally support buffs, enemy debuffs, a skill list menu with range/effect hints, and enemy tactic AI exist. - Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, item menu, preview, and target overlay exist. -- Counterattacks exist. A first battle-only support status effect exists; richer support and debuff effects are still planned. +- Counterattacks exist. Battle-only support and debuff status effects exist; richer ailment/status families are still planned. - Battle EXP, level-ups, class promotion, and campaign roster persistence exist. ## Milestone 3: Scenario Layer diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index afc4ede..53d074a 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -852,7 +852,7 @@ func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: St target["name"], _format_support_effects(skill) ]) - _emit_combat_feedback(target, _format_support_popup(skill), "support") + _emit_combat_feedback(target, _format_support_popup(skill), _support_feedback_kind(skill)) return true @@ -910,6 +910,15 @@ func _format_support_popup(skill: Dictionary) -> String: return _join_strings(parts, ", ") +func _support_feedback_kind(skill: Dictionary) -> String: + for effect in skill.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) == "stat_bonus" and int(effect.get("amount", 0)) < 0: + return "debuff" + return "support" + + func _format_active_status_effects(unit: Dictionary) -> String: var parts := [] for effect in _active_status_effects(unit): @@ -1871,6 +1880,10 @@ func _try_enemy_skill_action(enemy: Dictionary) -> bool: if not heal_action.is_empty() and _execute_ai_skill_action(enemy, heal_action): return true + var support_action := _best_ai_support_skill_action(enemy) + if not support_action.is_empty() and _execute_ai_skill_action(enemy, support_action): + return true + var damage_action := _best_ai_damage_skill_action(enemy, false) if not damage_action.is_empty() and _execute_ai_skill_action(enemy, damage_action): return true @@ -1911,6 +1924,89 @@ func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary: return best_action +func _best_ai_support_skill_action(caster: Dictionary) -> Dictionary: + var best_action := {} + var best_score := -1 + for skill_id in caster.get("skills", []): + var normalized_skill_id := str(skill_id) + var skill := get_skill_def(normalized_skill_id) + if skill.is_empty() or str(skill.get("kind", "")) != "support": + continue + if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): + continue + if not _skill_has_support_effects(skill): + continue + for target in _support_ai_targets_for_skill(caster, skill): + if not _skill_cells_from(caster, skill).has(target["pos"]): + continue + if not _skill_target_valid(caster, target, skill): + continue + if _has_status_effect_from_skill(target, normalized_skill_id): + continue + var score := _support_ai_score(caster, target, skill) + if score <= 0: + continue + score -= _manhattan(caster["pos"], target["pos"]) + if score > best_score: + best_score = score + best_action = { + "skill_id": normalized_skill_id, + "skill": skill, + "target": target + } + return best_action + + +func _support_ai_targets_for_skill(caster: Dictionary, skill: Dictionary) -> Array[Dictionary]: + var target_rule := str(skill.get("target", "ally")) + if target_rule == "self": + return [caster] + if target_rule == "enemy": + var target_team: String = _opposing_team(str(caster.get("team", ""))) + return get_living_units(target_team) if not target_team.is_empty() else [] + if target_rule == "any": + var targets: Array[Dictionary] = get_living_units(str(caster.get("team", ""))) + var opposing_team: String = _opposing_team(str(caster.get("team", ""))) + if not opposing_team.is_empty(): + targets.append_array(get_living_units(opposing_team)) + return targets + return get_living_units(str(caster.get("team", ""))) + + +func _support_ai_score(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: + var score: int = 0 + var target_is_ally: bool = target.get("team", "") == caster.get("team", "") + for effect in skill.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) != "stat_bonus": + continue + var stat := str(effect.get("stat", "")) + var amount := int(effect.get("amount", 0)) + if not _is_supported_status_stat(stat) or amount == 0: + continue + if amount > 0 and target_is_ally: + score += amount * _support_ai_stat_weight(stat) + if target.get("id", "") == caster.get("id", ""): + score += 3 + elif amount < 0 and not target_is_ally: + score += absi(amount) * _support_ai_stat_weight(stat) + score += _ai_target_priority_score(target) + return score + + +func _support_ai_stat_weight(stat: String) -> int: + if stat == "atk": + return 9 + if stat == "def": + return 7 + if stat == "int": + return 6 + if stat == "agi": + return 4 + return 1 + + func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -> Dictionary: var best_action := {} var best_score := -1 @@ -1964,14 +2060,20 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: return false if not _skill_target_valid(caster, target, skill): return false - if str(skill.get("kind", "")) == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + var skill_kind := str(skill.get("kind", "damage")) + if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + return false + if skill_kind == "support" and (not _skill_has_support_effects(skill) or _has_status_effect_from_skill(target, skill_id)): return false caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) - if str(skill.get("kind", "")) == "heal": + if skill_kind == "heal": var healed := _resolve_skill_heal(caster, target, skill) if healed > 0: _grant_experience(caster, EXP_HEAL) + elif skill_kind == "support": + if _resolve_skill_support(caster, target, skill_id, skill): + _grant_experience(caster, EXP_SUPPORT) else: var defeated := _resolve_skill_damage(caster, target, skill) _grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0)) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 15397ef..f4a4e52 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1049,6 +1049,8 @@ func _floating_text_color(kind: String) -> Color: return Color(0.48, 0.74, 1.0) if kind == "support": return Color(1.0, 0.88, 0.28) + if kind == "debuff": + return Color(0.80, 0.52, 1.0) if kind == "miss" or kind == "fade": return Color(0.82, 0.84, 0.90) if kind == "defeat": @@ -1135,7 +1137,9 @@ func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Di return _make_target_preview_badge("FULL", "invalid") return _make_target_preview_badge("+%d HP" % heal, "heal") if kind == "support": - return _make_target_preview_badge(_compact_support_preview_text(str(preview.get("effect_text", "Support"))), "support") + var effect_text := str(preview.get("effect_text", "Support")) + var badge_kind := "debuff" if effect_text.contains("-") else "support" + return _make_target_preview_badge(_compact_support_preview_text(effect_text), badge_kind) var result_text := "KO" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0)) return _make_target_preview_badge(result_text, "damage") @@ -1199,6 +1203,8 @@ func _target_preview_badge_color(kind: String) -> Color: return Color(0.54, 0.78, 1.0) if kind == "support": return Color(1.0, 0.88, 0.30) + if kind == "debuff": + return Color(0.78, 0.55, 1.0) return Color(0.78, 0.80, 0.86)