diff --git a/README.md b/README.md index 4341e02..3216524 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Grid movement. - Unit selection. - Move, attack, wait, end turn. -- Enemy AI with movement, attacks, multiple MP tactic choices, and scenario target priorities. +- Enemy AI with movement, damage-aware physical attacks, multiple MP tactic choices, and scenario target priorities. - Hover tile and unit info. - Enemy threat range overlay with tile-level threat source hints. - Damage forecast for selected-unit attacks. diff --git a/data/scenarios/005_puyang_raid.json b/data/scenarios/005_puyang_raid.json index 337d99f..1b54491 100644 --- a/data/scenarios/005_puyang_raid.json +++ b/data/scenarios/005_puyang_raid.json @@ -97,7 +97,8 @@ "team": "enemy", "level": 5, "pos": [10, 4], - "base": { "hp": 48, "atk": 16, "def": 8, "agi": 10 } + "base": { "hp": 48, "atk": 12, "def": 8, "agi": 10 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "gao_shun_guard", @@ -106,7 +107,8 @@ "team": "enemy", "level": 5, "pos": [9, 3], - "base": { "hp": 44, "atk": 14, "def": 9 } + "base": { "hp": 44, "atk": 10, "def": 9 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "puyang_cavalry_1", @@ -115,7 +117,8 @@ "team": "enemy", "level": 5, "pos": [11, 3], - "base": { "hp": 38, "atk": 13, "def": 7 } + "base": { "hp": 38, "atk": 9, "def": 7 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "puyang_cavalry_2", @@ -124,7 +127,8 @@ "team": "enemy", "level": 5, "pos": [11, 5], - "base": { "hp": 38, "atk": 13, "def": 7 } + "base": { "hp": 38, "atk": 9, "def": 7 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "puyang_archer", @@ -133,7 +137,8 @@ "team": "enemy", "level": 5, "pos": [10, 2], - "base": { "hp": 30, "atk": 12, "def": 5 } + "base": { "hp": 30, "atk": 9, "def": 5 }, + "equipment": { "weapon": "short_bow" } } ], "events": [ @@ -203,7 +208,8 @@ "team": "enemy", "level": 6, "pos": [13, 4], - "base": { "hp": 54, "atk": 18, "def": 9, "agi": 12 } + "base": { "hp": 54, "atk": 14, "def": 9, "agi": 12 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "lu_bu_rider_north", @@ -212,7 +218,8 @@ "team": "enemy", "level": 5, "pos": [12, 2], - "base": { "hp": 38, "atk": 14, "def": 7 } + "base": { "hp": 38, "atk": 10, "def": 7 }, + "equipment": { "weapon": "training_spear" } }, { "unit_id": "lu_bu_rider_south", @@ -221,7 +228,8 @@ "team": "enemy", "level": 5, "pos": [12, 6], - "base": { "hp": 38, "atk": 14, "def": 7 } + "base": { "hp": 38, "atk": 10, "def": 7 }, + "equipment": { "weapon": "training_spear" } } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ef90f40..5625733 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,7 +40,7 @@ 6. Briefing completion dispatches battle-begin events, including opening dialogue. 7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay, and floating combat text for core action and UI feedback. 8. Player ends turn. -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. +9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock tactics through the same combat and skill resolvers. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. 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`. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 49d29d6..be3a7ba 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -355,7 +355,7 @@ Scenario `formation.cells` controls the highlighted starting cells available dur Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, visible objective text, battle conditions, and victory rewards. -Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. +Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `equipment`; the validator checks those items against class equipment slots, and `DataCatalog` applies their stat bonuses and weapon ranges when hydrating the unit. ```json { @@ -544,7 +544,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, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock 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`, equipment, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support buffs, enemy debuffs, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock statuses, EXP, level-ups, weapon-aware physical attack AI, 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. @@ -560,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 area shapes, skill status/action-lock effect shapes, item effect names and cure statuses, equipment slot compatibility including accessory types and weapon effectiveness fields, 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, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, 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 e6db763..7c297d7 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -57,7 +57,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Disarm Tactic applies disarm, prevents physical attacks and counterattacks, and still leaves movement, tactics, items, and wait available. - Support, debuff, poison, seal, snare, and disarm status pips appear on affected board units and disappear when those effects expire or are cured. - Antidote can cure poison, Panacea can cure poison, seal, snare, or disarm, and neither consumes stock when used on a target without a matching status. -- Enemy turn advances and AI acts. +- Enemy turn advances and AI acts, including damage-aware physical target selection. - Victory and defeat panels still appear. - Post-battle dialogue and post-battle choices still block progression until a choice is saved. - Next battle loads with saved roster, inventory, joined officers, and campaign flags. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c0eafec..9d25982 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,7 +16,7 @@ - Enemy threat range overlay with tile-level threat source hints. - Show basic damage forecast. - Move, attack, wait, and end turn. -- Enemy units take AI turns with movement, attacks, and basic tactic use. +- Enemy units take AI turns with movement, damage-aware physical attacks, and basic tactic use. - Victory and defeat states are visible. - Scenario-defined victory and defeat conditions exist. - Destination victory conditions and objective map markers exist. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 910f5b3..eb67542 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -2302,21 +2302,26 @@ func _enemy_take_action(enemy: Dictionary) -> void: if _try_enemy_skill_action(enemy): return - var target := _find_nearest_target(enemy, TEAM_PLAYER) - if target.is_empty(): - return - - if _is_in_attack_range(enemy, target["pos"]): - _resolve_combat(enemy, target) + var attack_action := _best_ai_physical_attack_action(enemy, [enemy["pos"]]) + if not attack_action.is_empty(): + _resolve_combat(enemy, attack_action["target"]) return var best_cell: Vector2i = enemy["pos"] - var best_distance := _manhattan(best_cell, target["pos"]) - for cell in _movement_range_for_unit(enemy): - var distance := _manhattan(cell, target["pos"]) - if distance < best_distance: - best_distance = distance - best_cell = cell + var move_attack_action := _best_ai_physical_attack_action(enemy, _movement_range_for_unit(enemy)) + if not move_attack_action.is_empty(): + best_cell = move_attack_action["origin"] + else: + var target := _find_nearest_target(enemy, TEAM_PLAYER) + if target.is_empty(): + return + + var best_distance := _manhattan(best_cell, target["pos"]) + for cell in _movement_range_for_unit(enemy): + var distance := _manhattan(cell, target["pos"]) + if distance < best_distance: + best_distance = distance + best_cell = cell if best_cell != enemy["pos"]: var from_cell: Vector2i = enemy["pos"] @@ -2331,9 +2336,45 @@ func _enemy_take_action(enemy: Dictionary) -> void: if _try_enemy_skill_action(enemy): return - target = _find_nearest_target(enemy, TEAM_PLAYER) - if not target.is_empty() and _is_in_attack_range(enemy, target["pos"]): - _resolve_combat(enemy, target) + attack_action = _best_ai_physical_attack_action(enemy, [enemy["pos"]]) + if not attack_action.is_empty(): + _resolve_combat(enemy, attack_action["target"]) + + +func _best_ai_physical_attack_action(attacker: Dictionary, origins: Array[Vector2i]) -> Dictionary: + var best_action := {} + var best_score := -999999 + var target_team := _opposing_team(str(attacker.get("team", ""))) + if target_team.is_empty() or _unit_has_action_lock(attacker, "attack"): + return {} + + for origin in origins: + if not is_inside(origin): + continue + for target in get_living_units(target_team): + if not _attack_cells_from(attacker, origin).has(target["pos"]): + continue + var damage := calculate_damage(attacker, target) + var score := _physical_attack_ai_score(attacker, target, damage) + score -= _manhattan(attacker["pos"], origin) * 2 + if score > best_score: + best_score = score + best_action = { + "origin": origin, + "target": target, + "damage": damage, + "score": score + } + return best_action + + +func _physical_attack_ai_score(attacker: Dictionary, target: Dictionary, damage: int) -> int: + var score := damage * 10 + score += _weapon_effectiveness_bonus(attacker, target) * 6 + score += _ai_target_priority_score(target) + if int(target.get("hp", 0)) - damage <= 0: + score += 1000 + return score func _try_enemy_skill_action(enemy: Dictionary) -> bool: diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index ac2c480..f697bb9 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -455,7 +455,9 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H if (-not $classIds.Contains($classId)) { Fail "Scenario $ScenarioId deployment $unitId references unknown class: $classId" } + $classDef = $classes.$classId Check-Skill-Refs (Get-Prop $Deployment "skills" @()) "Scenario $ScenarioId deployment $unitId" + Check-Equipment-Loadout (Get-Prop $Deployment "equipment" $null) $classDef "Scenario $ScenarioId deployment $unitId" $pos = Get-Prop $Deployment "pos" @() if ($pos.Count -lt 2) { @@ -468,7 +470,6 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H } $terrainKey = Terrain-At $Rows $x $y - $classDef = $classes.$classId $moveType = [string](Get-Prop $Deployment "move_type" (Get-Prop $classDef "move_type" "foot")) $moveCost = $terrain.$terrainKey.move_cost.$moveType if ($null -eq $moveCost) { @@ -629,58 +630,66 @@ function Check-Class-Promotions() { } } +function Check-Equipment-Loadout($Equipment, $ClassDef, [string]$Context) { + $slots = Get-Prop $classDef "equipment_slots" $null + if ($null -eq $Equipment) { + return + } + if ($Equipment -is [string] -or $Equipment -is [System.Array]) { + Fail "$Context equipment must be an object." + } + + foreach ($slot in $Equipment.PSObject.Properties) { + $slotName = [string]$slot.Name + $itemId = [string]$slot.Value + if ([string]::IsNullOrWhiteSpace($itemId)) { + continue + } + if (-not @("weapon", "armor", "accessory").Contains($slotName)) { + Fail "$Context has unknown equipment slot: $slotName" + } + + $item = Find-Item-Def $itemId + if ($null -eq $item) { + Fail "$Context references unknown equipment item: $itemId" + } + + $kind = [string](Get-Prop $item "kind" "") + if ($slotName -eq "weapon") { + if ($kind -ne "weapon") { + Fail "$Context has non-weapon item $itemId in weapon slot." + } + $weaponType = [string](Get-Prop $item "weapon_type" "") + if ($null -ne $slots -and (-not @((Get-Prop $slots "weapon" @())).Contains($weaponType))) { + Fail "$Context cannot equip weapon type $weaponType." + } + } elseif ($slotName -eq "armor") { + if ($kind -ne "armor") { + Fail "$Context has non-armor item $itemId in armor slot." + } + $armorType = [string](Get-Prop $item "armor_type" "") + if ($null -ne $slots -and (-not @((Get-Prop $slots "armor" @())).Contains($armorType))) { + Fail "$Context cannot equip armor type $armorType." + } + } elseif ($slotName -eq "accessory") { + if ($kind -ne "accessory") { + Fail "$Context has non-accessory item $itemId in accessory slot." + } + $accessoryType = [string](Get-Prop $item "accessory_type" "accessory") + if ($null -ne $slots -and (-not @((Get-Prop $slots "accessory" @())).Contains($accessoryType))) { + Fail "$Context cannot equip accessory type $accessoryType." + } + } + } +} + function Check-Officer-Equipment([string]$OfficerId, $Officer) { $classId = [string](Get-Prop $Officer "class_id" "") if ([string]::IsNullOrWhiteSpace($classId) -or (-not $classIds.Contains($classId))) { Fail "Officer $OfficerId references unknown class: $classId" } - $classDef = $classes.$classId - $slots = Get-Prop $classDef "equipment_slots" $null - $equipment = Get-Prop $Officer "equipment" $null - if ($null -eq $equipment) { - return - } - - foreach ($slot in $equipment.PSObject.Properties) { - $slotName = [string]$slot.Name - $itemId = [string]$slot.Value - if ([string]::IsNullOrWhiteSpace($itemId)) { - continue - } - - $item = Find-Item-Def $itemId - if ($null -eq $item) { - Fail "Officer $OfficerId references unknown equipment item: $itemId" - } - - $kind = [string](Get-Prop $item "kind" "") - if ($slotName -eq "weapon") { - if ($kind -ne "weapon") { - Fail "Officer $OfficerId has non-weapon item $itemId in weapon slot." - } - $weaponType = [string](Get-Prop $item "weapon_type" "") - if ($null -ne $slots -and (-not @((Get-Prop $slots "weapon" @())).Contains($weaponType))) { - Fail "Officer $OfficerId cannot equip weapon type $weaponType." - } - } elseif ($slotName -eq "armor") { - if ($kind -ne "armor") { - Fail "Officer $OfficerId has non-armor item $itemId in armor slot." - } - $armorType = [string](Get-Prop $item "armor_type" "") - if ($null -ne $slots -and (-not @((Get-Prop $slots "armor" @())).Contains($armorType))) { - Fail "Officer $OfficerId cannot equip armor type $armorType." - } - } elseif ($slotName -eq "accessory") { - if ($kind -ne "accessory") { - Fail "Officer $OfficerId has non-accessory item $itemId in accessory slot." - } - $accessoryType = [string](Get-Prop $item "accessory_type" "accessory") - if ($null -ne $slots -and (-not @((Get-Prop $slots "accessory" @())).Contains($accessoryType))) { - Fail "Officer $OfficerId cannot equip accessory type $accessoryType." - } - } - } + Check-Equipment-Loadout (Get-Prop $Officer "equipment" $null) $classes.$classId "Officer $OfficerId" } function Check-Item-Effects() {