From 47d02e15377aed140e164b1fcafef8b486715e1c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 11:09:24 +0900 Subject: [PATCH] Add area tactic effects --- README.md | 2 +- data/defs/skills.json | 6 + docs/ARCHITECTURE.md | 6 +- docs/DATA_MODEL.md | 22 ++- docs/GODOT_4_6_MIGRATION.md | 1 + docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 255 ++++++++++++++++++++++++--------- scripts/scenes/battle_scene.gd | 99 ++++++++++--- tools/validate_data.ps1 | 15 ++ 9 files changed, 314 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 22c511e..cbb1b4a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,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, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu, previews, and target overlays. +- MP tactics with low/high damage options, area damage/healing/status options, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery, poison/seal/snare/disarm cure previews, and target overlay. - Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. diff --git a/data/defs/skills.json b/data/defs/skills.json index 4fce814..66f1a59 100644 --- a/data/defs/skills.json +++ b/data/defs/skills.json @@ -23,6 +23,8 @@ "target": "enemy", "mp_cost": 7, "range": [1, 3], + "area_shape": "diamond", + "area_radius": 1, "power": 13, "stat": "int" }, @@ -41,6 +43,8 @@ "target": "ally", "mp_cost": 7, "range": [0, 2], + "area_shape": "diamond", + "area_radius": 1, "power": 20, "stat": "int" }, @@ -146,6 +150,8 @@ "target": "enemy", "mp_cost": 6, "range": [1, 3], + "area_shape": "diamond", + "area_radius": 1, "power": 0, "stat": "int", "effects": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8516a64..2b2dbfc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,9 +38,9 @@ 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, 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; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, an enemy threat overlay, 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; 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, and floating combat text for core action and UI feedback. 8. Player ends turn. -9. Enemy AI moves, attacks, and can cast damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. +9. Enemy AI moves, attacks, 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. 11. Turn-start and movement-triggered scenario events run as phases change or units enter marked cells, and may update objectives, grant inventory pickups, or deploy reinforcements. 12. On first-time victory, `CampaignState` snapshots player progression and the battle inventory, applies rewards once, advances `current_scenario_id`, and writes `user://campaign_save.json`. @@ -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, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, 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, 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/DATA_MODEL.md b/docs/DATA_MODEL.md index 880438f..62e27dc 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -244,6 +244,8 @@ Skills define MP tactics. A class, officer, or scenario deployment can add skill Support skills use an `effects` array. `stat_bonus` uses `stat` in `atk`, `def`, `int`, `agi`, or `move`, 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. Move bonuses alter the affected unit's pathfinding range while active, and AGI bonuses or penalties alter physical hit and avoid forecasts. `damage_over_time` uses a stable `status`, positive `amount`, and positive `duration_turns`; it ticks at the start of the target team's phase before expiration and can defeat units without awarding delayed defeat EXP. `action_lock` uses a stable `status`, an `action` of `skill`, `move`, or `attack`, and positive `duration_turns`; it blocks the affected unit from selecting or casting tactics, moving, or making physical attacks and counterattacks while active. +Skills may define an area of effect with `area_shape` and `area_radius`. Omitting these fields is the same as `area_shape: "single"` and `area_radius: 0`. The first supported non-single shape is `diamond`, where `range` selects the center cell and `area_radius` applies the skill to valid targets within Manhattan distance from that center. Area skills spend MP once, grant one base skill/heal/support EXP award per cast, apply defeat bonuses per defeated target, and ignore units that do not match the skill target rule. Healing areas can be centered on an empty cell as long as at least one valid injured ally is inside the area. + ```json { "disarm_tactic": { @@ -261,7 +263,23 @@ Support skills use an `effects` array. `stat_bonus` uses `stat` in `atk`, `def`, } ``` -The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, `guard_order`, `rally_order`, `march_order`, `focus_order`, `disrupt_order`, `hamper_order`, `feint_order`, `disarm_tactic`, `poison_mist`, `seal_tactic`, and `snare_tactic`. Hero classes get low-cost Spark alongside Fire/Mend and ally and enemy orders, strategist classes also get Disarm Tactic, Poison Mist, Seal Tactic, and Snare Tactic, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class. +```json +{ + "blaze": { + "name": "Blaze", + "kind": "damage", + "target": "enemy", + "mp_cost": 7, + "range": [1, 3], + "area_shape": "diamond", + "area_radius": 1, + "power": 13, + "stat": "int" + } +} +``` + +The current tactic set includes `spark`, `fire_tactic`, area `blaze`, `mend`, area `great_mend`, `guard_order`, `rally_order`, `march_order`, `focus_order`, `disrupt_order`, `hamper_order`, `feint_order`, `disarm_tactic`, area `poison_mist`, `seal_tactic`, and `snare_tactic`. Hero classes get low-cost Spark alongside Fire/Mend and ally and enemy orders, strategist classes also get Disarm Tactic, Poison Mist, Seal Tactic, and Snare Tactic, 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 @@ -542,4 +560,4 @@ Before a battle starts, `BattleScene` can buy priced items through `CampaignStat When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill status/action-lock effect shapes, item effect names and cure statuses, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. +Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index 46066d1..6fa4bb8 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -48,6 +48,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear. - 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, and hovered threatened tiles name the threatening units. +- 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. - Poison Mist applies poison, poison ticks at the target team's phase start, and poison defeat immediately ends the battle when it satisfies a victory or defeat condition. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e3a3f61..16bc77a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -33,7 +33,7 @@ - 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, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu with range/effect hints, and enemy tactic AI exist. +- Skills, tactics, and MP. Multiple damage/heal tactics, first area damage/healing/status tactics, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, 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, Antidote poison cure, Panacea poison/seal/snare/disarm cure, item menu, preview, and target overlay exist. - Counterattacks exist. Battle-only support, debuff, damage-over-time, and first action-lock status effects exist; richer ailment/status families are still planned. - Battle EXP, level-ups, class promotion, and campaign roster persistence exist. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 4df8fd0..bc7df3a 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -623,28 +623,36 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool: if not _skill_cells_from(caster, skill).has(target_cell): return false - var target := get_unit_at(target_cell) - if not _skill_target_valid(caster, target, skill): - return false - var skill_kind := str(skill.get("kind", "damage")) - if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: - _emit_log("%s is already at full HP." % target["name"]) + var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell) + if targets.is_empty(): + if skill_kind == "heal": + _emit_log("%s has no injured targets in range." % skill.get("name", "Skill")) + else: + _emit_log("%s has no valid targets." % skill.get("name", "Skill")) return false if skill_kind == "support" and not _skill_has_support_effects(skill): return false caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": - var healed := _resolve_skill_heal(caster, target, skill) - if healed > 0: + var total_healed := 0 + for target in targets: + total_healed += _resolve_skill_heal(caster, target, skill) + if total_healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": - if _resolve_skill_support(caster, target, skill_id, skill): + var applied_support := false + for target in targets: + applied_support = _resolve_skill_support(caster, target, skill_id, skill) or applied_support + if applied_support: _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)) + var defeated_count := 0 + for target in targets: + if _resolve_skill_damage(caster, target, skill): + defeated_count += 1 + _grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS) caster["acted"] = true caster["moved"] = true @@ -741,6 +749,9 @@ func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2 var target := get_unit_at(target_cell) var skill_kind := str(skill.get("kind", "damage")) var skill_locked := _unit_has_action_lock(caster, "skill") + var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell) + var area_cells := _skill_area_cells(target_cell, skill) + var has_area := _skill_has_area(skill) var preview := { "skill_id": skill_id, "skill_name": str(skill.get("name", skill_id)), @@ -748,25 +759,42 @@ func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2 "mp_cost": int(skill.get("mp_cost", 0)), "has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)), "in_range": not skill_locked and _skill_cells_from(caster, skill).has(target_cell), - "valid_target": not skill_locked and _skill_target_valid(caster, target, skill), - "skill_locked": skill_locked + "valid_target": not skill_locked and not targets.is_empty(), + "skill_locked": skill_locked, + "has_area": has_area, + "area_cells": area_cells, + "target_count": targets.size() } - if target.is_empty(): + if targets.is_empty(): return preview - preview["target_id"] = target.get("id", "") + var primary_target: Dictionary = target if not target.is_empty() and _skill_target_valid(caster, target, skill) else targets[0] + preview["target_id"] = primary_target.get("id", "") if skill_kind == "heal": - var heal_amount := calculate_skill_heal(caster, target, skill) + var heal_amount := calculate_skill_heal(caster, primary_target, skill) + var total_heal := 0 + for area_target in targets: + total_heal += calculate_skill_heal(caster, area_target, skill) preview["heal"] = heal_amount - preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + heal_amount) + preview["total_heal"] = total_heal + preview["target_hp_after"] = min(int(primary_target.get("max_hp", 1)), int(primary_target.get("hp", 0)) + heal_amount) elif skill_kind == "support": preview["effect_text"] = _format_support_effects(skill) - preview["already_active"] = _has_status_effect_from_skill(target, skill_id) + preview["already_active"] = _has_status_effect_from_skill(primary_target, skill_id) else: - var damage := calculate_skill_damage(caster, target, skill) + var damage := calculate_skill_damage(caster, primary_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["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage) - preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0 + preview["total_damage"] = total_damage + preview["defeat_count"] = defeat_count + preview["target_hp_after"] = max(0, int(primary_target.get("hp", 0)) - damage) + preview["would_defeat"] = int(primary_target.get("hp", 0)) - damage <= 0 return preview @@ -1209,6 +1237,18 @@ func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]: return _skill_cells_from(unit, skill) +func get_skill_area_cells(unit_id: String, skill_id: String, target_cell: Vector2i) -> Array[Vector2i]: + var unit := get_unit(unit_id) + var skill := get_skill_def(skill_id) + if unit.is_empty() or skill.is_empty() or not is_inside(target_cell): + return [] + if not _unit_has_skill(unit, skill_id): + return [] + if not _skill_cells_from(unit, skill).has(target_cell): + return [] + return _skill_area_cells(target_cell, skill) + + func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]: var unit := get_unit(unit_id) var item := get_item_def(item_id) @@ -1762,9 +1802,10 @@ func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]: if not _skill_threatens_opponents(skill): continue for origin in _unit_threat_origins(unit): - for cell in _skill_cells_from_origin(unit, skill, origin): - if not result.has(cell): - result.append(cell) + for center_cell in _skill_cells_from_origin(unit, skill, origin): + for cell in _skill_area_cells(center_cell, skill): + if not result.has(cell): + result.append(cell) return result @@ -1835,6 +1876,57 @@ func _skill_cells_from_origin(_unit: Dictionary, skill: Dictionary, origin: Vect return result +func _skill_area_cells(center: Vector2i, skill: Dictionary) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + if not is_inside(center): + return result + var radius: int = _skill_area_radius(skill) + var shape := _skill_area_shape(skill) + if radius <= 0 or shape == "single": + result.append(center) + return result + + for y in range(center.y - radius, center.y + radius + 1): + for x in range(center.x - radius, center.x + radius + 1): + var cell := Vector2i(x, y) + if not is_inside(cell): + continue + var distance := _manhattan(center, cell) + if shape == "diamond" and distance <= radius: + result.append(cell) + return result + + +func _skill_has_area(skill: Dictionary) -> bool: + return _skill_area_radius(skill) > 0 and _skill_area_shape(skill) != "single" + + +func _skill_area_shape(skill: Dictionary) -> String: + var shape := str(skill.get("area_shape", "single")).strip_edges().to_lower() + if shape == "diamond": + return shape + return "single" + + +func _skill_area_radius(skill: Dictionary) -> int: + return maxi(0, int(skill.get("area_radius", 0))) + + +func _skill_targets_for_cast(caster: Dictionary, skill_id: String, skill: Dictionary, target_cell: Vector2i, skip_existing_support := false) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var skill_kind := str(skill.get("kind", "damage")) + for cell in _skill_area_cells(target_cell, skill): + var target := get_unit_at(cell) + if not _skill_target_valid(caster, target, skill): + continue + if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + continue + if skill_kind == "support" and skip_existing_support and _has_status_effect_from_skill(target, skill_id): + continue + result.append(target) + return result + + func _skill_range_pair(skill: Dictionary) -> Dictionary: var value = skill.get("range", 1) if typeof(value) == TYPE_ARRAY: @@ -2230,25 +2322,31 @@ func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary: continue if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue - for ally in get_living_units(str(caster.get("team", ""))): - if not _skill_cells_from(caster, skill).has(ally["pos"]): + for target_cell in _skill_cells_from(caster, skill): + var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell) + if targets.is_empty(): continue - if not _skill_target_valid(caster, ally, skill): + var total_heal := 0 + var total_missing_hp := 0 + var lowest_hp_ratio := 1.0 + for ally in targets: + var heal_amount := calculate_skill_heal(caster, ally, skill) + var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0)) + var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1)))) + total_heal += heal_amount + total_missing_hp += missing_hp + lowest_hp_ratio = minf(lowest_hp_ratio, hp_ratio) + if lowest_hp_ratio > 0.5 and total_missing_hp < 10: continue - var heal_amount := calculate_skill_heal(caster, ally, skill) - if heal_amount <= 0: - continue - var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0)) - var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1)))) - if hp_ratio > 0.5 and missing_hp < 10: - continue - var score := heal_amount * 10 + missing_hp + var score := total_heal * 10 + total_missing_hp + targets.size() * 4 + score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, - "target": ally + "target": targets[0], + "target_cell": target_cell } return best_action @@ -2265,23 +2363,24 @@ func _best_ai_support_skill_action(caster: Dictionary) -> Dictionary: 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"]): + for target_cell in _skill_cells_from(caster, skill): + var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell, true) + if targets.is_empty(): 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) + var score := 0 + for target in targets: + score += _support_ai_score(caster, target, skill) if score <= 0: continue - score -= _manhattan(caster["pos"], target["pos"]) + score += targets.size() * 3 + score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, - "target": target + "target": targets[0], + "target_cell": target_cell } return best_action @@ -2404,25 +2503,32 @@ func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) - continue if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue - for target in get_living_units(target_team): - if not _skill_cells_from(caster, skill).has(target["pos"]): + for target_cell in _skill_cells_from(caster, skill): + var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell) + if targets.is_empty(): continue - if not _skill_target_valid(caster, target, skill): + var total_damage := 0 + var defeat_count := 0 + var priority_score := 0 + for target in targets: + var damage := calculate_skill_damage(caster, target, skill) + total_damage += damage + priority_score += _ai_target_priority_score(target) + if int(target.get("hp", 0)) - damage <= 0: + defeat_count += 1 + if defeating_only and defeat_count <= 0: continue - var damage := calculate_skill_damage(caster, target, skill) - var would_defeat := int(target.get("hp", 0)) - damage <= 0 - if defeating_only and not would_defeat: - continue - var score := damage + _ai_target_priority_score(target) - if would_defeat: - score += 1000 - score -= _manhattan(caster["pos"], target["pos"]) + var score := total_damage + priority_score + targets.size() * 5 + if defeat_count > 0: + score += 1000 * defeat_count + score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, - "target": target + "target": targets[0], + "target_cell": target_cell } return best_action @@ -2431,7 +2537,7 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: var skill_id := str(action.get("skill_id", "")) var skill: Dictionary = action.get("skill", {}) var target: Dictionary = action.get("target", {}) - if skill_id.is_empty() or skill.is_empty() or target.is_empty(): + if skill_id.is_empty() or skill.is_empty(): return false if not _unit_has_skill(caster, skill_id): return false @@ -2439,29 +2545,38 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): return false - if not target.get("alive", false): - return false - if not _skill_cells_from(caster, skill).has(target["pos"]): - return false - if not _skill_target_valid(caster, target, skill): + var target_cell: Vector2i = action.get("target_cell", target.get("pos", Vector2i(-999, -999))) + if not is_inside(target_cell): return false var skill_kind := str(skill.get("kind", "damage")) - if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: + var skip_existing_support := skill_kind == "support" + var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell, skip_existing_support) + if targets.is_empty(): return false - if skill_kind == "support" and (not _skill_has_support_effects(skill) or _has_status_effect_from_skill(target, skill_id)): + if not _skill_cells_from(caster, skill).has(target_cell): + return false + if skill_kind == "support" and not _skill_has_support_effects(skill): return false caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": - var healed := _resolve_skill_heal(caster, target, skill) - if healed > 0: + var total_healed := 0 + for area_target in targets: + total_healed += _resolve_skill_heal(caster, area_target, skill) + if total_healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": - if _resolve_skill_support(caster, target, skill_id, skill): + var applied_support := false + for area_target in targets: + applied_support = _resolve_skill_support(caster, area_target, skill_id, skill) or applied_support + if applied_support: _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)) + var defeated_count := 0 + for area_target in targets: + if _resolve_skill_damage(caster, area_target, skill): + defeated_count += 1 + _grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS) _emit_log("%s spends %d MP." % [caster["name"], int(skill.get("mp_cost", 0))]) return true diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index f376ec6..7f05f62 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -10,6 +10,7 @@ const PLAYER_COLOR := Color(0.20, 0.42, 0.82) const ENEMY_COLOR := Color(0.72, 0.18, 0.16) const GRID_COLOR := Color(0.05, 0.06, 0.07, 0.45) const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28) +const SKILL_AREA_OVERLAY_COLOR := Color(0.72, 0.46, 1.0, 0.30) const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30) const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34) const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30) @@ -959,6 +960,14 @@ func _draw_overlays() -> void: for cell in skill_cells: draw_rect(_rect_for_cell(cell), SKILL_OVERLAY_COLOR) + if not selected_skill_id.is_empty() and skill_cells.has(hover_cell): + var area_selected := state.get_selected_unit() + if not area_selected.is_empty(): + for cell in state.get_skill_area_cells(area_selected["id"], selected_skill_id, hover_cell): + var area_rect := _rect_for_cell(cell) + draw_rect(area_rect, SKILL_AREA_OVERLAY_COLOR) + draw_rect(area_rect.grow(-7.0), Color(0.84, 0.62, 1.0, 0.62), false, 1.5) + for cell in item_cells: draw_rect(_rect_for_cell(cell), ITEM_OVERLAY_COLOR) @@ -1223,26 +1232,37 @@ func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Di if bool(preview.get("skill_locked", false)): return _make_target_preview_badge("SEALED", "seal") - if target.is_empty(): - return {} - if not bool(preview.get("valid_target", false)): - return _make_target_preview_badge("INVALID", "invalid") if not bool(preview.get("in_range", false)): return _make_target_preview_badge("OUT", "invalid") if not bool(preview.get("has_mp", false)): return _make_target_preview_badge("NO MP", "invalid") + if not bool(preview.get("valid_target", false)): + return _make_target_preview_badge("NO TGT" if bool(preview.get("has_area", false)) else "INVALID", "invalid") + if target.is_empty() and not bool(preview.get("has_area", false)): + return {} var kind := str(preview.get("kind", "damage")) + var target_count := int(preview.get("target_count", 1)) if kind == "heal": var heal := int(preview.get("heal", 0)) - if heal <= 0: + var total_heal := int(preview.get("total_heal", heal)) + if total_heal <= 0: return _make_target_preview_badge("FULL", "invalid") + if bool(preview.get("has_area", false)): + return _make_target_preview_badge("+%d x%d" % [total_heal, target_count], "heal") return _make_target_preview_badge("+%d HP" % heal, "heal") if kind == "support": var effect_text := str(preview.get("effect_text", "Support")) var badge_kind := "disarm" if effect_text.contains("Disarm") else ("snare" if effect_text.contains("Snare") else ("seal" if effect_text.contains("Seal") else ("poison" if effect_text.contains("Poison") else ("debuff" if effect_text.contains("-") else "support")))) + if bool(preview.get("has_area", false)): + return _make_target_preview_badge("%s x%d" % [_compact_support_preview_text(effect_text), target_count], badge_kind) return _make_target_preview_badge(_compact_support_preview_text(effect_text), badge_kind) + if bool(preview.get("has_area", false)): + var defeat_count := int(preview.get("defeat_count", 0)) + if defeat_count > 0: + return _make_target_preview_badge("KO x%d" % defeat_count, "damage") + return _make_target_preview_badge("-%d x%d" % [int(preview.get("total_damage", 0)), target_count], "damage") 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") @@ -1542,7 +1562,6 @@ func _update_skill_forecast(selected: Dictionary) -> void: forecast_label.text = "Forecast: -" return - var target := state.get_unit_at(hover_cell) var range_text := "ready" if preview["in_range"] else "out of range" var mp_text := "" if preview["has_mp"] else " Not enough MP." if bool(preview.get("skill_locked", false)): @@ -1551,7 +1570,7 @@ func _update_skill_forecast(selected: Dictionary) -> void: preview["mp_cost"] ] return - if target.is_empty() or not preview["valid_target"]: + if not preview["valid_target"]: forecast_label.text = "%s: %s, %d MP.%s\nNo valid target." % [ preview["skill_name"], range_text, @@ -1560,36 +1579,71 @@ func _update_skill_forecast(selected: Dictionary) -> void: ] return + var target := state.get_unit(str(preview.get("target_id", ""))) + var target_max_hp := int(target.get("max_hp", 1)) if not target.is_empty() else 1 + var target_count := int(preview.get("target_count", 1)) + var has_area := bool(preview.get("has_area", false)) + var area_text := " Area targets: %d." % target_count if has_area else "" if preview["kind"] == "heal": - forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [ + var total_heal := int(preview.get("total_heal", preview.get("heal", 0))) + if not has_area: + forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [ + preview["skill_name"], + range_text, + preview["mp_cost"], + mp_text, + preview["heal"], + preview["target_hp_after"], + target_max_hp + ] + return + forecast_label.text = "%s: %s, %d MP.%s%s\nRestore %d HP total, focus HP %d/%d." % [ preview["skill_name"], range_text, preview["mp_cost"], mp_text, - preview["heal"], + area_text, + total_heal, preview["target_hp_after"], - target["max_hp"] + target_max_hp ] elif preview["kind"] == "support": var refresh_text := " Refresh." if bool(preview.get("already_active", false)) else "" - forecast_label.text = "%s: %s, %d MP.%s\n%s.%s" % [ + forecast_label.text = "%s: %s, %d MP.%s%s\n%s.%s" % [ preview["skill_name"], range_text, preview["mp_cost"], mp_text, + area_text, preview.get("effect_text", "Support effect"), refresh_text ] else: - var defeat_text := " Defeat" if preview["would_defeat"] else "" - forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [ + var defeat_count := int(preview.get("defeat_count", 0)) + var defeat_text := " Defeat x%d" % defeat_count if defeat_count > 0 else "" + var total_damage := int(preview.get("total_damage", preview.get("damage", 0))) + if not has_area: + var single_defeat_text := " Defeat" if bool(preview.get("would_defeat", false)) else "" + forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [ + preview["skill_name"], + range_text, + preview["mp_cost"], + mp_text, + preview["damage"], + preview["target_hp_after"], + target_max_hp, + single_defeat_text + ] + return + forecast_label.text = "%s: %s, %d MP.%s%s\n%d total damage, focus HP %d/%d.%s" % [ preview["skill_name"], range_text, preview["mp_cost"], mp_text, - preview["damage"], + area_text, + total_damage, preview["target_hp_after"], - target["max_hp"], + target_max_hp, defeat_text ] @@ -3421,6 +3475,7 @@ func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: D func _format_tactic_effect_text(skill: Dictionary) -> String: + var area_text := _format_tactic_area_text(skill) if str(skill.get("kind", "")) == "support": var parts := [] for effect in skill.get("effects", []): @@ -3442,8 +3497,18 @@ func _format_tactic_effect_text(skill: Dictionary) -> String: _action_lock_effect_label(str(effect.get("action", ""))) ]) if not parts.is_empty(): - return _join_strings(parts, ", ") - return "P%d" % int(skill.get("power", 0)) + var support_text := _join_strings(parts, ", ") + return "%s, %s" % [support_text, area_text] if not area_text.is_empty() else support_text + var power_text := "P%d" % int(skill.get("power", 0)) + return "%s, %s" % [power_text, area_text] if not area_text.is_empty() else power_text + + +func _format_tactic_area_text(skill: Dictionary) -> String: + var shape := str(skill.get("area_shape", "single")) + var radius := int(skill.get("area_radius", 0)) + if shape == "diamond" and radius > 0: + return "Area %d" % radius + return "" func _format_tactic_range_text(skill: Dictionary) -> String: diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 3c5bc1f..b33e2df 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -102,6 +102,7 @@ $validSkillTargets = @("enemy", "ally", "self", "any") $validSkillStats = @("atk", "def", "int", "agi") $validStatusStats = @("atk", "def", "int", "agi", "move") $validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock") +$validSkillAreaShapes = @("single", "diamond") $validDialogueSides = @("left", "right") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $knownFlagIds = New-Object System.Collections.Generic.HashSet[string] @@ -774,6 +775,20 @@ function Check-Skill-Definitions() { Fail "Skill $skillId has unknown target: $target" } Check-Skill-Range (Get-Prop $skill "range" 1) "Skill $skillId" + $areaShape = [string](Get-Prop $skill "area_shape" "single") + if (-not $validSkillAreaShapes.Contains($areaShape)) { + Fail "Skill $skillId has unsupported area_shape: $areaShape" + } + $areaRadius = [int](Get-Prop $skill "area_radius" 0) + if ($areaRadius -lt 0) { + Fail "Skill $skillId has invalid area_radius." + } + if ($areaShape -eq "single" -and $areaRadius -gt 0) { + Fail "Skill $skillId area_radius requires a non-single area_shape." + } + if ($areaShape -ne "single" -and $areaRadius -le 0) { + Fail "Skill $skillId area_shape requires a positive area_radius." + } if ([int](Get-Prop $skill "mp_cost" 0) -lt 0) { Fail "Skill $skillId has negative mp_cost." }