Add guard behavior for opening defenders
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user