Add guard behavior for opening defenders

This commit is contained in:
2026-06-19 19:06:50 +09:00
parent f13f20939f
commit 8f1b6fb398
6 changed files with 232 additions and 3 deletions

View File

@@ -323,6 +323,12 @@
6,
5
],
"ai_behavior": "guard",
"ai_guard_anchor": [
6,
5
],
"ai_guard_radius": 2,
"base": {
"hp": 38,
"atk": 9,
@@ -371,6 +377,12 @@
19,
2
],
"ai_behavior": "guard",
"ai_guard_anchor": [
19,
2
],
"ai_guard_radius": 3,
"base": {
"hp": 40,
"atk": 10,
@@ -467,6 +479,12 @@
7,
6
],
"ai_behavior": "guard",
"ai_guard_anchor": [
7,
6
],
"ai_guard_radius": 2,
"base": {
"hp": 36,
"atk": 10,
@@ -515,6 +533,12 @@
21,
2
],
"ai_behavior": "guard",
"ai_guard_anchor": [
21,
2
],
"ai_guard_radius": 3,
"base": {
"hp": 38,
"atk": 10,

View File

@@ -355,6 +355,8 @@ Scenario `roster.max_units`, `roster.required_officers`, and `roster.required_un
Scenario-only protected units can use `team: "player"`, `controllable: false`, `persist_progression: false`, and an optional `ai_target_priority`. They remain valid targets for enemies, healing, and defeat conditions, but the player cannot select, move, attack, change equipment, or place them in Formation, and they are omitted from campaign roster progression snapshots. `ai_target_priority` is a non-negative scenario hint that makes enemy movement and damage-skill AI treat that unit as a more attractive target. Events can later change the same value with `set_ai_target_priority`.
Enemy deployments may set `ai_behavior: "guard"` with optional `ai_guard_anchor: [x, y]` and `ai_guard_radius` to hold villages, gates, castles, or other defensive terrain. Guard units still attack and pursue targets inside their assigned radius, but they do not abandon the guard zone to chase distant player units and will step back toward the anchor if displaced outside it.
Scenario `formation.cells` controls the highlighted starting cells available during pre-battle Formation. These choices only change the current battle's unit positions; they are not saved to campaign state. If a scenario omits `formation`, player deployment positions become the default formation cells.
## Scenarios

View File

@@ -606,6 +606,16 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary:
unit["controllable"] = bool(unit.get("controllable", true))
unit["persist_progression"] = bool(unit.get("persist_progression", true))
unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0)))
unit["ai_behavior"] = str(unit.get("ai_behavior", "")).strip_edges().to_lower()
if unit["ai_behavior"] != "guard":
unit["ai_behavior"] = ""
if unit["ai_behavior"] == "guard" and not unit.has("ai_guard_radius"):
unit["ai_guard_radius"] = 2
unit["ai_guard_radius"] = max(0, int(unit.get("ai_guard_radius", 0)))
var guard_anchor := _condition_cell(unit.get("ai_guard_anchor", [unit["pos"].x, unit["pos"].y]))
if not is_inside(guard_anchor):
guard_anchor = unit["pos"]
unit["ai_guard_anchor"] = guard_anchor
unit["status_effects"] = []
unit["acted"] = false
unit["moved"] = false
@@ -3045,6 +3055,15 @@ func _run_enemy_turn() -> void:
func _enemy_take_action(enemy: Dictionary) -> void:
var movement_cells := _movement_range_for_unit(enemy)
if _is_ai_guard(enemy):
movement_cells = _ai_guard_movement_cells(enemy, movement_cells)
if not _ai_guard_is_engaged(enemy, movement_cells):
var return_cell := _best_ai_guard_return_cell(enemy, movement_cells)
if return_cell != enemy["pos"]:
_move_ai_unit(enemy, return_cell)
return
if _try_enemy_skill_action(enemy):
return
@@ -3054,16 +3073,16 @@ func _enemy_take_action(enemy: Dictionary) -> void:
return
var best_cell: Vector2i = enemy["pos"]
var move_attack_action := _best_ai_physical_attack_action(enemy, _movement_range_for_unit(enemy))
var move_attack_action := _best_ai_physical_attack_action(enemy, movement_cells)
if not move_attack_action.is_empty():
best_cell = move_attack_action["origin"]
else:
var target := _find_nearest_target(enemy, TEAM_PLAYER)
var target := _find_nearest_ai_guard_target(enemy, TEAM_PLAYER) if _is_ai_guard(enemy) else _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):
for cell in movement_cells:
var distance := _manhattan(cell, target["pos"])
if distance < best_distance:
best_distance = distance
@@ -3088,6 +3107,97 @@ func _enemy_take_action(enemy: Dictionary) -> void:
_resolve_combat(enemy, attack_action["target"])
func _move_ai_unit(unit: Dictionary, target_cell: Vector2i) -> bool:
if target_cell == unit["pos"]:
return true
var from_cell: Vector2i = unit["pos"]
unit["pos"] = target_cell
unit["moved"] = true
_face_unit_from_to(unit, from_cell, target_cell)
unit_motion_requested.emit(str(unit.get("id", "")), from_cell, target_cell)
_emit_log("%s, %s?쇰줈 ?뺣컯." % [unit["name"], _format_cell(target_cell)])
_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
return battle_status == STATUS_ACTIVE
func _is_ai_guard(unit: Dictionary) -> bool:
return str(unit.get("ai_behavior", "")).strip_edges().to_lower() == "guard"
func _ai_guard_anchor(unit: Dictionary) -> Vector2i:
var anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1)))
if not is_inside(anchor):
return unit.get("pos", Vector2i(-1, -1))
return anchor
func _ai_guard_radius(unit: Dictionary) -> int:
return max(0, int(unit.get("ai_guard_radius", 0)))
func _ai_guard_contains_cell(unit: Dictionary, cell: Vector2i) -> bool:
return _manhattan(_ai_guard_anchor(unit), cell) <= _ai_guard_radius(unit)
func _ai_guard_movement_cells(unit: Dictionary, movement_cells: Array[Vector2i]) -> Array[Vector2i]:
var filtered: Array[Vector2i] = []
for cell in movement_cells:
if _ai_guard_contains_cell(unit, cell):
filtered.append(cell)
if not filtered.is_empty():
return filtered
if not _ai_guard_contains_cell(unit, unit.get("pos", Vector2i(-1, -1))):
return movement_cells
return filtered
func _ai_guard_attack_origins(unit: Dictionary, movement_cells: Array[Vector2i]) -> Array[Vector2i]:
var origins: Array[Vector2i] = []
var current: Vector2i = unit.get("pos", Vector2i(-1, -1))
if is_inside(current):
origins.append(current)
for cell in movement_cells:
if not origins.has(cell):
origins.append(cell)
return origins
func _ai_guard_is_engaged(unit: Dictionary, movement_cells: Array[Vector2i]) -> bool:
var target_team := _opposing_team(str(unit.get("team", "")))
if target_team.is_empty():
return false
for target in get_living_units(target_team):
if _ai_guard_contains_cell(unit, target.get("pos", Vector2i(-1, -1))):
return true
return not _best_ai_physical_attack_action(unit, _ai_guard_attack_origins(unit, movement_cells)).is_empty()
func _best_ai_guard_return_cell(unit: Dictionary, movement_cells: Array[Vector2i]) -> Vector2i:
var best_cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
var anchor := _ai_guard_anchor(unit)
var best_distance := _manhattan(best_cell, anchor)
for cell in movement_cells:
var distance := _manhattan(cell, anchor)
if distance < best_distance:
best_distance = distance
best_cell = cell
return best_cell
func _find_nearest_ai_guard_target(unit: Dictionary, target_team: String) -> Dictionary:
var best_target := {}
var best_score := 9999
for target in get_living_units(target_team):
if not _ai_guard_contains_cell(unit, target.get("pos", Vector2i(-1, -1))):
continue
var distance := _manhattan(unit["pos"], target["pos"])
var score := distance - int(target.get("ai_target_priority", 0))
if score < best_score:
best_score = score
best_target = target
return best_target
func _best_ai_physical_attack_action(attacker: Dictionary, origins: Array[Vector2i]) -> Dictionary:
var best_action := {}
var best_score := -999999

View File

@@ -54,6 +54,15 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary:
unit["controllable"] = bool(deployment.get("controllable", true))
unit["persist_progression"] = bool(deployment.get("persist_progression", true))
unit["ai_target_priority"] = int(deployment.get("ai_target_priority", 0))
unit["ai_behavior"] = str(deployment.get("ai_behavior", "")).strip_edges().to_lower()
if deployment.has("ai_guard_anchor"):
unit["ai_guard_anchor"] = deployment["ai_guard_anchor"]
elif unit["ai_behavior"] == "guard":
unit["ai_guard_anchor"] = deployment.get("pos", [0, 0])
if deployment.has("ai_guard_radius"):
unit["ai_guard_radius"] = int(deployment.get("ai_guard_radius", 0))
elif unit["ai_behavior"] == "guard":
unit["ai_guard_radius"] = 2
unit["level"] = int(deployment.get("level", officer.get("level", 1)))
unit["exp"] = int(deployment.get("exp", officer.get("exp", 0)))
unit["max_hp"] = int(stats.get("hp", stats.get("max_hp", 1)))

View File

@@ -14,6 +14,7 @@ func _init() -> void:
_check_priority_action(state, failures)
_check_priority_changes_enemy_targeting(state, failures)
_check_reinforcement_priority_events(failures)
_check_guard_ai_behavior(failures)
if failures.is_empty():
print("event ai priority smoke ok")
@@ -90,6 +91,45 @@ func _check_reinforcement_priority_events(failures: Array[String]) -> void:
failures.append("Turn 7 reinforcement priority feedback missing")
func _check_guard_ai_behavior(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("Could not load stage 1 guard AI scenario")
return
var guard: Dictionary = state.get_unit("yellow_turban_7")
if str(guard.get("ai_behavior", "")) != "guard":
failures.append("castle defender should load guard AI behavior")
if guard.get("ai_guard_anchor", Vector2i(-1, -1)) != Vector2i(19, 2):
failures.append("castle defender should keep its guard anchor: %s" % str(guard.get("ai_guard_anchor", null)))
if int(guard.get("ai_guard_radius", 0)) != 3:
failures.append("castle defender should keep its guard radius")
var movement_cells: Array[Vector2i] = state._ai_guard_movement_cells(guard, state._movement_range_for_unit(guard))
if state._ai_guard_is_engaged(guard, movement_cells):
failures.append("guard should not engage distant opening player positions")
state._enemy_take_action(guard)
if guard.get("pos", Vector2i.ZERO) != Vector2i(19, 2):
failures.append("guard should hold position instead of chasing distant targets: %s" % str(guard.get("pos", null)))
guard["pos"] = Vector2i(15, 2)
var displaced_return := state._best_ai_guard_return_cell(guard, state._ai_guard_movement_cells(guard, state._movement_range_for_unit(guard)))
if state._manhattan(displaced_return, Vector2i(19, 2)) >= state._manhattan(guard["pos"], Vector2i(19, 2)):
failures.append("displaced guard should choose a cell closer to its anchor: %s" % str(displaced_return))
state._enemy_take_action(guard)
if state._manhattan(guard.get("pos", Vector2i.ZERO), Vector2i(19, 2)) >= 4:
failures.append("displaced guard should step back toward its anchor: %s" % str(guard.get("pos", null)))
guard["pos"] = Vector2i(19, 2)
state.get_unit("cao_cao")["pos"] = Vector2i(18, 2)
movement_cells = state._ai_guard_movement_cells(guard, state._movement_range_for_unit(guard))
if not state._ai_guard_is_engaged(guard, movement_cells):
failures.append("guard should engage a player unit inside its guard radius")
var target: Dictionary = state._find_nearest_ai_guard_target(guard, "player")
if str(target.get("id", "")) != "cao_cao":
failures.append("guard target search should prefer targets inside the guard zone: %s" % str(target.get("id", "")))
func _feedback_has(feedback: Array[Dictionary], expected_unit_id: String, expected_text: String, expected_kind: String) -> bool:
for entry in feedback:
if (

View File

@@ -534,6 +534,31 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H
Fail "Scenario $ScenarioId deployment $unitId ai_target_priority must be between 0 and 20."
}
}
$aiBehavior = [string](Get-Prop $Deployment "ai_behavior" "")
if (-not [string]::IsNullOrWhiteSpace($aiBehavior)) {
if ($aiBehavior -ne "guard") {
Fail "Scenario $ScenarioId deployment $unitId has unknown ai_behavior: $aiBehavior"
}
if ($team -ne "enemy") {
Fail "Scenario $ScenarioId deployment $unitId ai_behavior guard is only valid for enemy deployments."
}
}
if ((Has-Prop $Deployment "ai_guard_anchor") -and $aiBehavior -ne "guard") {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_anchor needs ai_behavior guard."
}
if ((Has-Prop $Deployment "ai_guard_radius") -and $aiBehavior -ne "guard") {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_radius needs ai_behavior guard."
}
if (Has-Prop $Deployment "ai_guard_radius") {
$guardRadiusValue = Get-Prop $Deployment "ai_guard_radius" 0
if (-not ($guardRadiusValue -is [int] -or $guardRadiusValue -is [long])) {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_radius must be an integer."
}
$guardRadius = [int]$guardRadiusValue
if ($guardRadius -lt 0 -or $guardRadius -gt 12) {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_radius must be between 0 and 12."
}
}
$classId = Resolve-Class-Id $Deployment $ScenarioId
if (-not $classIds.Contains($classId)) {
@@ -562,6 +587,25 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H
if ([int]$moveCost -ge 99) {
Fail "Scenario $ScenarioId deployment $unitId starts on impassable terrain."
}
if (Has-Prop $Deployment "ai_guard_anchor") {
$anchor = Get-Prop $Deployment "ai_guard_anchor" @()
if ($anchor.Count -lt 2) {
Fail "Scenario $ScenarioId deployment $unitId has malformed ai_guard_anchor."
}
$anchorX = [int]$anchor[0]
$anchorY = [int]$anchor[1]
if ($anchorX -lt 0 -or $anchorY -lt 0 -or $anchorX -ge $Width -or $anchorY -ge $Height) {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_anchor is outside map at [$anchorX,$anchorY]."
}
$anchorTerrainKey = Terrain-At $Rows $anchorX $anchorY
$anchorMoveCost = $terrain.$anchorTerrainKey.move_cost.$moveType
if ($null -eq $anchorMoveCost) {
$anchorMoveCost = $terrain.$anchorTerrainKey.move_cost.foot
}
if ([int]$anchorMoveCost -ge 99) {
Fail "Scenario $ScenarioId deployment $unitId ai_guard_anchor is on impassable terrain."
}
}
}
function Add-Deployment-Unit-Id($Deployment, $Result) {