From 23642a14e1757fc08bbe84f3815d2d9231793520 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 09:55:54 +0900 Subject: [PATCH] Add tactic seal action lock --- README.md | 6 +-- data/defs/classes.json | 4 +- data/defs/skills.json | 12 +++++ docs/ARCHITECTURE.md | 8 +-- docs/DATA_MODEL.md | 16 +++--- docs/GODOT_4_6_MIGRATION.md | 3 +- docs/ROADMAP.md | 6 +-- scripts/core/battle_state.gd | 91 +++++++++++++++++++++++++++++++++- scripts/scenes/battle_scene.gd | 49 ++++++++++++++++-- tools/validate_data.ps1 | 14 +++++- 10 files changed, 181 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7f1c499..0b7d11b 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, ally support buffs, enemy debuffs, poison damage-over-time, a skill list menu, previews, and target overlays. +- MP tactics with low/high damage options, healing options, ally support buffs, enemy debuffs, poison damage-over-time, tactic seal, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery and status-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. @@ -111,10 +111,10 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Dialogue lines can place the portrait on the left or right side of the dialogue panel. - 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, level-ups, and promotions. +- Floating combat text for damage, recovery, misses, support effects, poison ticks, tactic seal, level-ups, and promotions. - Short unit slide animation for board movement. - Hover target preview badges for attacks, tactics, and items. -- Low-HP board unit warning rings, HP bar color states, and board status pips for support, debuff, and poison effects. +- Low-HP board unit warning rings, HP bar color states, and board status pips for support, debuff, poison, and seal effects. - Data-driven scenario setup through JSON definitions and deployments. ## Run diff --git a/data/defs/classes.json b/data/defs/classes.json index b1bdaee..16559c0 100644 --- a/data/defs/classes.json +++ b/data/defs/classes.json @@ -160,7 +160,7 @@ "move_type": "foot", "move": 4, "attack_range": [1, 1], - "skills": ["spark", "fire_tactic", "mend", "guard_order", "rally_order", "disrupt_order", "poison_mist"], + "skills": ["spark", "fire_tactic", "mend", "guard_order", "rally_order", "disrupt_order", "poison_mist", "seal_tactic"], "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", "rally_order", "disrupt_order", "poison_mist"], + "skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order", "rally_order", "disrupt_order", "poison_mist", "seal_tactic"], "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 d82d135..e439c83 100644 --- a/data/defs/skills.json +++ b/data/defs/skills.json @@ -91,5 +91,17 @@ "effects": [ { "type": "damage_over_time", "status": "poison", "amount": 4, "duration_turns": 2 } ] + }, + "seal_tactic": { + "name": "Seal Tactic", + "kind": "support", + "target": "enemy", + "mp_cost": 6, + "range": [1, 3], + "power": 0, + "stat": "int", + "effects": [ + { "type": "action_lock", "status": "seal", "action": "skill", "duration_turns": 2 } + ] } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e74b848..c1ba624 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,7 @@ - `data/defs/classes.json`: Class stats, movement types, skill access. - `data/defs/officers.json`: Named officer base stats, growth, and default portrait paths. - `data/defs/items.json`: Weapons, armor, accessories, and consumables. -- `data/defs/skills.json`: MP tactic definitions for damage, healing, target rules, and range. +- `data/defs/skills.json`: MP tactic definitions for damage, healing, support, status effects, target rules, and range. - `data/defs/terrain.json`: Terrain colors, defense, avoid, and movement costs. - `data/campaign/campaign.json`: Chapter ranges, scenario sequence, titles, and resource paths. - `data/scenarios/*.json`: Dialogue, deployments, rewards, and branching flags. @@ -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, or damage-over-time 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, debuff, 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, 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, or damage-over-time tactics through the same combat and skill resolvers. +9. Enemy AI moves, attacks, and can cast damage, healing, support, debuff, damage-over-time, or skill-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 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 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 or cure matching damage-over-time statuses, 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, 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 status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support, debuff, and poison 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 status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support, debuff, poison, and seal 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 8db7a1f..c3b9ec9 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -226,7 +226,7 @@ Classes provide movement, min/max attack range, skill access, growth grades, cla ## Skills -Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Skill `kind` can be `damage`, `heal`, or `support`. Enemy AI can use damage, healing, support, debuff, and damage-over-time skills when it has enough MP. +Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Skill `kind` can be `damage`, `heal`, or `support`. Enemy AI can use damage, healing, support, debuff, damage-over-time, and action-lock skills when it has enough MP. ```json { @@ -242,12 +242,12 @@ 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`, 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. `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. +Support skills use an `effects` array. `stat_bonus` uses `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. `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`, and positive `duration_turns`; it blocks the affected unit from selecting or casting tactics while active. ```json { - "poison_mist": { - "name": "Poison Mist", + "seal_tactic": { + "name": "Seal Tactic", "kind": "support", "target": "enemy", "mp_cost": 6, @@ -255,13 +255,13 @@ Support skills use an `effects` array. `stat_bonus` uses `stat` in `atk`, `def`, "power": 0, "stat": "int", "effects": [ - { "type": "damage_over_time", "status": "poison", "amount": 4, "duration_turns": 2 } + { "type": "action_lock", "status": "seal", "action": "skill", "duration_turns": 2 } ] } } ``` -The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, `guard_order`, `rally_order`, `disrupt_order`, and `poison_mist`. Hero classes get low-cost Spark alongside Fire/Mend and ally orders, strategist classes also get Poison Mist, 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`, `disrupt_order`, `poison_mist`, and `seal_tactic`. Hero classes get low-cost Spark alongside Fire/Mend and ally orders, strategist classes also get Poison Mist and Seal 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 @@ -506,7 +506,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 buffs, enemy debuffs, damage-over-time statuses, 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, damage-over-time statuses, action-lock statuses, 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, per-scenario shop purchase counts, 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, branch decisions, or finite shop purchases. 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. @@ -522,4 +522,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 effect shapes, item effect names and cure statuses, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, 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 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, 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 460827e..d886dee 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -47,7 +47,8 @@ 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. - 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. -- Support, debuff, and poison status pips appear on affected board units and disappear when those effects expire or are cured. +- Seal Tactic applies seal, prevents the affected unit from using tactics during its next action phase, and disables the tactic button with a sealed forecast/badge. +- Support, debuff, poison, and seal status pips appear on affected board units and disappear when those effects expire or are cured. - Antidote can cure poison and does not consume stock when used on a target without a matching status. - Enemy turn advances and AI acts. - Victory and defeat panels still appear. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3c21d6..cc14424 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, ally support buffs, enemy debuffs, poison damage-over-time, 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, poison damage-over-time, tactic seal, 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, item menu, preview, and target overlay exist. -- Counterattacks exist. Battle-only support, debuff, and first damage-over-time status effects exist; richer ailment/status families are still planned. +- 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. ## Milestone 3: Scenario Layer @@ -132,7 +132,7 @@ ## Milestone 5: Presentation - 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, and poison effects. +- 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, and seal effects. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items. - 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 4c1ac0b..d3508d3 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -606,6 +606,9 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool: return false if caster.get("acted", false): return false + if _unit_has_skill_lock(caster): + _emit_log("%s cannot use tactics while sealed." % caster["name"]) + return false if not _unit_has_skill(caster, skill_id): return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): @@ -729,14 +732,16 @@ 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_skill_lock(caster) var preview := { "skill_id": skill_id, "skill_name": str(skill.get("name", skill_id)), "kind": skill_kind, "mp_cost": int(skill.get("mp_cost", 0)), "has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)), - "in_range": _skill_cells_from(caster, skill).has(target_cell), - "valid_target": _skill_target_valid(caster, target, skill) + "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 } if target.is_empty(): return preview @@ -817,6 +822,13 @@ func get_status_effect_summary(unit_id: String) -> String: return _format_active_status_effects(unit) +func is_unit_skill_sealed(unit_id: String) -> bool: + var unit := get_unit(unit_id) + if unit.is_empty(): + return false + return _unit_has_skill_lock(unit) + + func _effective_stat(unit: Dictionary, stat: String) -> int: var base_value := int(unit.get(stat, 0)) for effect in _active_status_effects(unit): @@ -850,6 +862,8 @@ func _skill_has_support_effects(skill: Dictionary) -> bool: return true if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0: return true + if effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill" and not str(effect.get("status", "seal")).strip_edges().is_empty(): + return true return false @@ -887,6 +901,18 @@ func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: St "amount": damage, "remaining_phases": max(1, int(effect.get("duration_turns", 1))) }) + elif effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + var status := str(effect.get("status", "seal")) + if status.strip_edges().is_empty(): + continue + status_effects.append({ + "source_skill": skill_id, + "name": str(skill.get("name", skill_id)), + "type": effect_type, + "status": status, + "action": "skill", + "remaining_phases": max(1, int(effect.get("duration_turns", 1))) + }) target["status_effects"] = status_effects _emit_log("%s casts %s on %s. %s" % [ caster["name"], @@ -935,6 +961,9 @@ func _format_support_effects(skill: Dictionary) -> String: continue var status_name := _status_display_name(str(effect.get("status", "poison"))) parts.append("%s -%d HP for %s" % [status_name, damage, duration_text]) + elif effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + var status_name := _status_display_name(str(effect.get("status", "seal"))) + parts.append("%s tactics for %s" % [status_name, duration_text]) if parts.is_empty(): return "No support effect." return _join_strings(parts, ", ") @@ -957,12 +986,19 @@ func _format_support_popup(skill: Dictionary) -> String: var damage := int(effect.get("amount", 0)) if damage > 0: parts.append(_status_display_name(str(effect.get("status", "poison"))).to_upper()) + elif effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + parts.append(_status_display_name(str(effect.get("status", "seal"))).to_upper()) if parts.is_empty(): return "Support" 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", "")) == "action_lock" and str(effect.get("action", "skill")) == "skill": + return str(effect.get("status", "seal")) for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue @@ -985,6 +1021,9 @@ func _format_active_status_effects(unit: Dictionary) -> String: if amount > 0: parts.append("%s -%d HP" % [_status_display_name(str(effect.get("status", "poison"))), amount]) continue + if effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + parts.append("%s tactics" % _status_display_name(str(effect.get("status", "seal")))) + continue var stat := str(effect.get("stat", "")) if not _is_supported_status_stat(stat) or amount == 0: continue @@ -1004,6 +1043,13 @@ func _is_supported_status_stat(stat: String) -> bool: return stat == "atk" or stat == "def" or stat == "int" or stat == "agi" +func _unit_has_skill_lock(unit: Dictionary) -> bool: + for effect in _active_status_effects(unit): + if str(effect.get("type", "")) == "action_lock" and str(effect.get("action", "skill")) == "skill": + return true + return false + + func try_wait_selected() -> bool: var unit := get_selected_unit() if unit.is_empty() or battle_status != STATUS_ACTIVE: @@ -1125,6 +1171,8 @@ func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]: return [] if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return [] + if _unit_has_skill_lock(unit): + return [] if not _unit_has_skill(unit, skill_id): return [] if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): @@ -1180,6 +1228,8 @@ func get_first_usable_skill_id(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: return "" + if _unit_has_skill_lock(unit): + return "" for skill_id in unit.get("skills", []): var normalized := str(skill_id) var skill := get_skill_def(normalized) @@ -1197,6 +1247,8 @@ func get_usable_skill_ids(unit_id: String) -> Array: return result if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return result + if _unit_has_skill_lock(unit): + return result for skill_id in unit.get("skills", []): var normalized := str(skill_id) var skill := get_skill_def(normalized) @@ -2005,6 +2057,8 @@ func _try_enemy_skill_action(enemy: Dictionary) -> bool: return false if typeof(enemy.get("skills", [])) != TYPE_ARRAY: return false + if _unit_has_skill_lock(enemy): + return false var finishing_action := _best_ai_damage_skill_action(enemy, true) if not finishing_action.is_empty() and _execute_ai_skill_action(enemy, finishing_action): @@ -2133,6 +2187,14 @@ func _support_ai_score(caster: Dictionary, target: Dictionary, skill: Dictionary var duration: int = max(1, int(effect.get("duration_turns", 1))) score += damage * duration * 5 score += _ai_target_priority_score(target) + elif effect_type == "action_lock" and not target_is_ally and str(effect.get("action", "skill")) == "skill": + if _unit_has_skill_lock(target): + continue + var threat_score := _unit_skill_threat_score(target) + if threat_score <= 0: + continue + score += 18 + threat_score + score += _ai_target_priority_score(target) return score @@ -2148,6 +2210,29 @@ func _support_ai_stat_weight(stat: String) -> int: return 1 +func _unit_skill_threat_score(unit: Dictionary) -> int: + if typeof(unit.get("skills", [])) != TYPE_ARRAY: + return 0 + if int(unit.get("mp", 0)) <= 0: + return 0 + + var score := 0 + for skill_id in unit.get("skills", []): + var skill := get_skill_def(str(skill_id)) + if skill.is_empty(): + continue + if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): + continue + var kind := str(skill.get("kind", "damage")) + if kind == "damage": + score += 12 + elif kind == "heal": + score += 9 + elif kind == "support": + score += 7 + return score + + func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -> Dictionary: var best_action := {} var best_score := -1 @@ -2193,6 +2278,8 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: return false if not _unit_has_skill(caster, skill_id): return false + if _unit_has_skill_lock(caster): + return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): return false if not target.get("alive", false): diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index a074666..ce52433 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1039,6 +1039,8 @@ func _unit_status_marker_kinds(unit: Dictionary) -> Array[String]: func _unit_status_marker_kind(effect: Dictionary) -> String: var effect_type := str(effect.get("type", "stat_bonus")) + if effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + return str(effect.get("status", "seal")) if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0: return str(effect.get("status", "poison")) if effect_type == "stat_bonus": @@ -1057,6 +1059,8 @@ func _unit_status_marker_color(marker_kind: String) -> Color: return Color(0.78, 0.55, 1.0) if marker_kind == "poison": return Color(0.54, 0.95, 0.36) + if marker_kind == "seal": + return Color(0.44, 0.86, 1.0) return Color(0.72, 0.86, 1.0) @@ -1113,6 +1117,8 @@ func _floating_text_color(kind: String) -> Color: return Color(0.80, 0.52, 1.0) if kind == "poison": return Color(0.54, 0.95, 0.36) + if kind == "seal": + return Color(0.44, 0.86, 1.0) if kind == "miss" or kind == "fade": return Color(0.82, 0.84, 0.90) if kind == "defeat": @@ -1182,9 +1188,13 @@ func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> D func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary: var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell) - if preview.is_empty() or target.is_empty(): + if preview.is_empty(): return {} + 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)): @@ -1200,7 +1210,7 @@ func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Di return _make_target_preview_badge("+%d HP" % heal, "heal") if kind == "support": var effect_text := str(preview.get("effect_text", "Support")) - var badge_kind := "poison" if effect_text.contains("Poison") else ("debuff" if effect_text.contains("-") else "support") + var badge_kind := "seal" if effect_text.contains("Seal") else ("poison" if effect_text.contains("Poison") else ("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)) @@ -1275,6 +1285,8 @@ func _target_preview_badge_color(kind: String) -> Color: return Color(0.78, 0.55, 1.0) if kind == "poison": return Color(0.54, 0.95, 0.36) + if kind == "seal": + return Color(0.44, 0.86, 1.0) return Color(0.78, 0.80, 0.86) @@ -1475,6 +1487,12 @@ func _update_skill_forecast(selected: Dictionary) -> void: 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)): + forecast_label.text = "%s: sealed, %d MP.\nCannot use tactics." % [ + preview["skill_name"], + preview["mp_cost"] + ] + return if target.is_empty() or not preview["valid_target"]: forecast_label.text = "%s: %s, %d MP.%s\nNo valid target." % [ preview["skill_name"], @@ -1867,6 +1885,14 @@ func _on_tactic_pressed() -> void: var selected := state.get_selected_unit() if selected.is_empty(): return + if state.is_unit_skill_sealed(selected["id"]): + _play_ui_cancel() + selected_skill_id = "" + _hide_tactic_menu() + _refresh_ranges() + _update_hud() + queue_redraw() + return _play_ui_click() if tactic_menu != null and tactic_menu.visible: _hide_tactic_menu() @@ -3244,6 +3270,10 @@ func _update_tactic_button(selected: Dictionary) -> void: tactic_button.text = "Tactic" tactic_button.disabled = true return + if state.is_unit_skill_sealed(selected["id"]): + tactic_button.text = "Tactic - Sealed" + tactic_button.disabled = true + return if not selected_skill_id.is_empty(): var skill := state.get_skill_def(selected_skill_id) @@ -3280,12 +3310,17 @@ func _rebuild_tactic_menu(selected: Dictionary) -> void: empty_label.text = "No tactics" tactic_list.add_child(empty_label) return + var skill_sealed := state.is_unit_skill_sealed(selected["id"]) + if skill_sealed: + var sealed_label := Label.new() + sealed_label.text = "Tactics sealed" + tactic_list.add_child(sealed_label) for skill_id in skill_ids: var skill := state.get_skill_def(str(skill_id)) var skill_button := Button.new() skill_button.text = _format_tactic_button_text(str(skill_id), skill, selected) - skill_button.disabled = selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)) + skill_button.disabled = skill_sealed or selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)) skill_button.pressed.connect(_on_tactic_skill_pressed.bind(str(skill_id))) tactic_list.add_child(skill_button) @@ -3308,7 +3343,11 @@ func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: D var kind := str(skill.get("kind", "skill")).capitalize() var range_text := _format_tactic_range_text(skill) var effect_text := _format_tactic_effect_text(skill) - var disabled_text := " - MP" if int(selected.get("mp", 0)) < mp_cost else "" + var disabled_text := "" + if state.is_unit_skill_sealed(selected["id"]): + disabled_text = " - Sealed" + elif int(selected.get("mp", 0)) < mp_cost: + disabled_text = " - MP" return "%s%s %dMP %s R%s %s%s" % [marker, skill_name, mp_cost, kind, range_text, effect_text, disabled_text] @@ -3328,6 +3367,8 @@ func _format_tactic_effect_text(skill: Dictionary) -> String: elif effect_type == "damage_over_time" and amount > 0: var status_name := str(effect.get("status", "status")).replace("_", " ").capitalize() parts.append("%s -%d" % [status_name, amount]) + elif effect_type == "action_lock" and str(effect.get("action", "skill")) == "skill": + parts.append("%s tactics" % str(effect.get("status", "seal")).replace("_", " ").capitalize()) if not parts.is_empty(): return _join_strings(parts, ", ") return "P%d" % int(skill.get("power", 0)) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index caa0e3a..12ee57b 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -100,7 +100,7 @@ $validBonusStats = @("hp", "mp", "atk", "def", "int", "agi") $validSkillKinds = @("damage", "heal", "support") $validSkillTargets = @("enemy", "ally", "self", "any") $validSkillStats = @("atk", "def", "int", "agi") -$validSkillEffectTypes = @("stat_bonus", "damage_over_time") +$validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock") $validDialogueSides = @("left", "right") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $knownFlagIds = New-Object System.Collections.Generic.HashSet[string] @@ -792,6 +792,18 @@ function Check-Skill-Definitions() { if ([int](Get-Prop $effect "duration_turns" 1) -le 0) { Fail "Skill $skillId damage_over_time effect must have positive duration_turns." } + } elseif ($effectType -eq "action_lock") { + $status = [string](Get-Prop $effect "status" "") + if ([string]::IsNullOrWhiteSpace($status) -or -not ($status -match '^[a-z0-9_]+$')) { + Fail "Skill $skillId action_lock effect must have a stable status." + } + $action = [string](Get-Prop $effect "action" "") + if ($action -ne "skill") { + Fail "Skill $skillId action_lock effect currently supports only action=skill." + } + if ([int](Get-Prop $effect "duration_turns" 1) -le 0) { + Fail "Skill $skillId action_lock effect must have positive duration_turns." + } } } }