Teach enemy AI weapon-aware attacks

This commit is contained in:
2026-06-18 11:30:37 +09:00
parent 6ca276082e
commit a4330481a6
8 changed files with 135 additions and 77 deletions

View File

@@ -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.

View File

@@ -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" }
}
]
}

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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() {