Add area tactic effects

This commit is contained in:
2026-06-18 11:09:24 +09:00
parent 65e2508257
commit 47d02e1537
9 changed files with 314 additions and 94 deletions

View File

@@ -623,28 +623,36 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool:
if not _skill_cells_from(caster, skill).has(target_cell):
return false
var target := get_unit_at(target_cell)
if not _skill_target_valid(caster, target, skill):
return false
var skill_kind := str(skill.get("kind", "damage"))
if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0:
_emit_log("%s is already at full HP." % target["name"])
var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell)
if targets.is_empty():
if skill_kind == "heal":
_emit_log("%s has no injured targets in range." % skill.get("name", "Skill"))
else:
_emit_log("%s has no valid targets." % skill.get("name", "Skill"))
return false
if skill_kind == "support" and not _skill_has_support_effects(skill):
return false
caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
if skill_kind == "heal":
var healed := _resolve_skill_heal(caster, target, skill)
if healed > 0:
var total_healed := 0
for target in targets:
total_healed += _resolve_skill_heal(caster, target, skill)
if total_healed > 0:
_grant_experience(caster, EXP_HEAL)
elif skill_kind == "support":
if _resolve_skill_support(caster, target, skill_id, skill):
var applied_support := false
for target in targets:
applied_support = _resolve_skill_support(caster, target, skill_id, skill) or applied_support
if applied_support:
_grant_experience(caster, EXP_SUPPORT)
else:
var defeated := _resolve_skill_damage(caster, target, skill)
_grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0))
var defeated_count := 0
for target in targets:
if _resolve_skill_damage(caster, target, skill):
defeated_count += 1
_grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS)
caster["acted"] = true
caster["moved"] = true
@@ -741,6 +749,9 @@ 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_action_lock(caster, "skill")
var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell)
var area_cells := _skill_area_cells(target_cell, skill)
var has_area := _skill_has_area(skill)
var preview := {
"skill_id": skill_id,
"skill_name": str(skill.get("name", skill_id)),
@@ -748,25 +759,42 @@ func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2
"mp_cost": int(skill.get("mp_cost", 0)),
"has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)),
"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
"valid_target": not skill_locked and not targets.is_empty(),
"skill_locked": skill_locked,
"has_area": has_area,
"area_cells": area_cells,
"target_count": targets.size()
}
if target.is_empty():
if targets.is_empty():
return preview
preview["target_id"] = target.get("id", "")
var primary_target: Dictionary = target if not target.is_empty() and _skill_target_valid(caster, target, skill) else targets[0]
preview["target_id"] = primary_target.get("id", "")
if skill_kind == "heal":
var heal_amount := calculate_skill_heal(caster, target, skill)
var heal_amount := calculate_skill_heal(caster, primary_target, skill)
var total_heal := 0
for area_target in targets:
total_heal += calculate_skill_heal(caster, area_target, skill)
preview["heal"] = heal_amount
preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + heal_amount)
preview["total_heal"] = total_heal
preview["target_hp_after"] = min(int(primary_target.get("max_hp", 1)), int(primary_target.get("hp", 0)) + heal_amount)
elif skill_kind == "support":
preview["effect_text"] = _format_support_effects(skill)
preview["already_active"] = _has_status_effect_from_skill(target, skill_id)
preview["already_active"] = _has_status_effect_from_skill(primary_target, skill_id)
else:
var damage := calculate_skill_damage(caster, target, skill)
var damage := calculate_skill_damage(caster, primary_target, skill)
var total_damage := 0
var defeat_count := 0
for area_target in targets:
var area_damage := calculate_skill_damage(caster, area_target, skill)
total_damage += area_damage
if int(area_target.get("hp", 0)) - area_damage <= 0:
defeat_count += 1
preview["damage"] = damage
preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage)
preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0
preview["total_damage"] = total_damage
preview["defeat_count"] = defeat_count
preview["target_hp_after"] = max(0, int(primary_target.get("hp", 0)) - damage)
preview["would_defeat"] = int(primary_target.get("hp", 0)) - damage <= 0
return preview
@@ -1209,6 +1237,18 @@ func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]:
return _skill_cells_from(unit, skill)
func get_skill_area_cells(unit_id: String, skill_id: String, target_cell: Vector2i) -> Array[Vector2i]:
var unit := get_unit(unit_id)
var skill := get_skill_def(skill_id)
if unit.is_empty() or skill.is_empty() or not is_inside(target_cell):
return []
if not _unit_has_skill(unit, skill_id):
return []
if not _skill_cells_from(unit, skill).has(target_cell):
return []
return _skill_area_cells(target_cell, skill)
func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]:
var unit := get_unit(unit_id)
var item := get_item_def(item_id)
@@ -1762,9 +1802,10 @@ func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]:
if not _skill_threatens_opponents(skill):
continue
for origin in _unit_threat_origins(unit):
for cell in _skill_cells_from_origin(unit, skill, origin):
if not result.has(cell):
result.append(cell)
for center_cell in _skill_cells_from_origin(unit, skill, origin):
for cell in _skill_area_cells(center_cell, skill):
if not result.has(cell):
result.append(cell)
return result
@@ -1835,6 +1876,57 @@ func _skill_cells_from_origin(_unit: Dictionary, skill: Dictionary, origin: Vect
return result
func _skill_area_cells(center: Vector2i, skill: Dictionary) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if not is_inside(center):
return result
var radius: int = _skill_area_radius(skill)
var shape := _skill_area_shape(skill)
if radius <= 0 or shape == "single":
result.append(center)
return result
for y in range(center.y - radius, center.y + radius + 1):
for x in range(center.x - radius, center.x + radius + 1):
var cell := Vector2i(x, y)
if not is_inside(cell):
continue
var distance := _manhattan(center, cell)
if shape == "diamond" and distance <= radius:
result.append(cell)
return result
func _skill_has_area(skill: Dictionary) -> bool:
return _skill_area_radius(skill) > 0 and _skill_area_shape(skill) != "single"
func _skill_area_shape(skill: Dictionary) -> String:
var shape := str(skill.get("area_shape", "single")).strip_edges().to_lower()
if shape == "diamond":
return shape
return "single"
func _skill_area_radius(skill: Dictionary) -> int:
return maxi(0, int(skill.get("area_radius", 0)))
func _skill_targets_for_cast(caster: Dictionary, skill_id: String, skill: Dictionary, target_cell: Vector2i, skip_existing_support := false) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var skill_kind := str(skill.get("kind", "damage"))
for cell in _skill_area_cells(target_cell, skill):
var target := get_unit_at(cell)
if not _skill_target_valid(caster, target, skill):
continue
if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0:
continue
if skill_kind == "support" and skip_existing_support and _has_status_effect_from_skill(target, skill_id):
continue
result.append(target)
return result
func _skill_range_pair(skill: Dictionary) -> Dictionary:
var value = skill.get("range", 1)
if typeof(value) == TYPE_ARRAY:
@@ -2230,25 +2322,31 @@ func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary:
continue
if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
continue
for ally in get_living_units(str(caster.get("team", ""))):
if not _skill_cells_from(caster, skill).has(ally["pos"]):
for target_cell in _skill_cells_from(caster, skill):
var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell)
if targets.is_empty():
continue
if not _skill_target_valid(caster, ally, skill):
var total_heal := 0
var total_missing_hp := 0
var lowest_hp_ratio := 1.0
for ally in targets:
var heal_amount := calculate_skill_heal(caster, ally, skill)
var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0))
var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1))))
total_heal += heal_amount
total_missing_hp += missing_hp
lowest_hp_ratio = minf(lowest_hp_ratio, hp_ratio)
if lowest_hp_ratio > 0.5 and total_missing_hp < 10:
continue
var heal_amount := calculate_skill_heal(caster, ally, skill)
if heal_amount <= 0:
continue
var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0))
var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1))))
if hp_ratio > 0.5 and missing_hp < 10:
continue
var score := heal_amount * 10 + missing_hp
var score := total_heal * 10 + total_missing_hp + targets.size() * 4
score -= _manhattan(caster["pos"], target_cell)
if score > best_score:
best_score = score
best_action = {
"skill_id": normalized_skill_id,
"skill": skill,
"target": ally
"target": targets[0],
"target_cell": target_cell
}
return best_action
@@ -2265,23 +2363,24 @@ func _best_ai_support_skill_action(caster: Dictionary) -> Dictionary:
continue
if not _skill_has_support_effects(skill):
continue
for target in _support_ai_targets_for_skill(caster, skill):
if not _skill_cells_from(caster, skill).has(target["pos"]):
for target_cell in _skill_cells_from(caster, skill):
var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell, true)
if targets.is_empty():
continue
if not _skill_target_valid(caster, target, skill):
continue
if _has_status_effect_from_skill(target, normalized_skill_id):
continue
var score := _support_ai_score(caster, target, skill)
var score := 0
for target in targets:
score += _support_ai_score(caster, target, skill)
if score <= 0:
continue
score -= _manhattan(caster["pos"], target["pos"])
score += targets.size() * 3
score -= _manhattan(caster["pos"], target_cell)
if score > best_score:
best_score = score
best_action = {
"skill_id": normalized_skill_id,
"skill": skill,
"target": target
"target": targets[0],
"target_cell": target_cell
}
return best_action
@@ -2404,25 +2503,32 @@ func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -
continue
if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
continue
for target in get_living_units(target_team):
if not _skill_cells_from(caster, skill).has(target["pos"]):
for target_cell in _skill_cells_from(caster, skill):
var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell)
if targets.is_empty():
continue
if not _skill_target_valid(caster, target, skill):
var total_damage := 0
var defeat_count := 0
var priority_score := 0
for target in targets:
var damage := calculate_skill_damage(caster, target, skill)
total_damage += damage
priority_score += _ai_target_priority_score(target)
if int(target.get("hp", 0)) - damage <= 0:
defeat_count += 1
if defeating_only and defeat_count <= 0:
continue
var damage := calculate_skill_damage(caster, target, skill)
var would_defeat := int(target.get("hp", 0)) - damage <= 0
if defeating_only and not would_defeat:
continue
var score := damage + _ai_target_priority_score(target)
if would_defeat:
score += 1000
score -= _manhattan(caster["pos"], target["pos"])
var score := total_damage + priority_score + targets.size() * 5
if defeat_count > 0:
score += 1000 * defeat_count
score -= _manhattan(caster["pos"], target_cell)
if score > best_score:
best_score = score
best_action = {
"skill_id": normalized_skill_id,
"skill": skill,
"target": target
"target": targets[0],
"target_cell": target_cell
}
return best_action
@@ -2431,7 +2537,7 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool:
var skill_id := str(action.get("skill_id", ""))
var skill: Dictionary = action.get("skill", {})
var target: Dictionary = action.get("target", {})
if skill_id.is_empty() or skill.is_empty() or target.is_empty():
if skill_id.is_empty() or skill.is_empty():
return false
if not _unit_has_skill(caster, skill_id):
return false
@@ -2439,29 +2545,38 @@ func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool:
return false
if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)):
return false
if not target.get("alive", false):
return false
if not _skill_cells_from(caster, skill).has(target["pos"]):
return false
if not _skill_target_valid(caster, target, skill):
var target_cell: Vector2i = action.get("target_cell", target.get("pos", Vector2i(-999, -999)))
if not is_inside(target_cell):
return false
var skill_kind := str(skill.get("kind", "damage"))
if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0:
var skip_existing_support := skill_kind == "support"
var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell, skip_existing_support)
if targets.is_empty():
return false
if skill_kind == "support" and (not _skill_has_support_effects(skill) or _has_status_effect_from_skill(target, skill_id)):
if not _skill_cells_from(caster, skill).has(target_cell):
return false
if skill_kind == "support" and not _skill_has_support_effects(skill):
return false
caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
if skill_kind == "heal":
var healed := _resolve_skill_heal(caster, target, skill)
if healed > 0:
var total_healed := 0
for area_target in targets:
total_healed += _resolve_skill_heal(caster, area_target, skill)
if total_healed > 0:
_grant_experience(caster, EXP_HEAL)
elif skill_kind == "support":
if _resolve_skill_support(caster, target, skill_id, skill):
var applied_support := false
for area_target in targets:
applied_support = _resolve_skill_support(caster, area_target, skill_id, skill) or applied_support
if applied_support:
_grant_experience(caster, EXP_SUPPORT)
else:
var defeated := _resolve_skill_damage(caster, target, skill)
_grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0))
var defeated_count := 0
for area_target in targets:
if _resolve_skill_damage(caster, area_target, skill):
defeated_count += 1
_grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS)
_emit_log("%s spends %d MP." % [caster["name"], int(skill.get("mp_cost", 0))])
return true

View File

@@ -10,6 +10,7 @@ const PLAYER_COLOR := Color(0.20, 0.42, 0.82)
const ENEMY_COLOR := Color(0.72, 0.18, 0.16)
const GRID_COLOR := Color(0.05, 0.06, 0.07, 0.45)
const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28)
const SKILL_AREA_OVERLAY_COLOR := Color(0.72, 0.46, 1.0, 0.30)
const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30)
const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34)
const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30)
@@ -959,6 +960,14 @@ func _draw_overlays() -> void:
for cell in skill_cells:
draw_rect(_rect_for_cell(cell), SKILL_OVERLAY_COLOR)
if not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
var area_selected := state.get_selected_unit()
if not area_selected.is_empty():
for cell in state.get_skill_area_cells(area_selected["id"], selected_skill_id, hover_cell):
var area_rect := _rect_for_cell(cell)
draw_rect(area_rect, SKILL_AREA_OVERLAY_COLOR)
draw_rect(area_rect.grow(-7.0), Color(0.84, 0.62, 1.0, 0.62), false, 1.5)
for cell in item_cells:
draw_rect(_rect_for_cell(cell), ITEM_OVERLAY_COLOR)
@@ -1223,26 +1232,37 @@ func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Di
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)):
return _make_target_preview_badge("OUT", "invalid")
if not bool(preview.get("has_mp", false)):
return _make_target_preview_badge("NO MP", "invalid")
if not bool(preview.get("valid_target", false)):
return _make_target_preview_badge("NO TGT" if bool(preview.get("has_area", false)) else "INVALID", "invalid")
if target.is_empty() and not bool(preview.get("has_area", false)):
return {}
var kind := str(preview.get("kind", "damage"))
var target_count := int(preview.get("target_count", 1))
if kind == "heal":
var heal := int(preview.get("heal", 0))
if heal <= 0:
var total_heal := int(preview.get("total_heal", heal))
if total_heal <= 0:
return _make_target_preview_badge("FULL", "invalid")
if bool(preview.get("has_area", false)):
return _make_target_preview_badge("+%d x%d" % [total_heal, target_count], "heal")
return _make_target_preview_badge("+%d HP" % heal, "heal")
if kind == "support":
var effect_text := str(preview.get("effect_text", "Support"))
var badge_kind := "disarm" if effect_text.contains("Disarm") else ("snare" if effect_text.contains("Snare") else ("seal" if effect_text.contains("Seal") else ("poison" if effect_text.contains("Poison") else ("debuff" if effect_text.contains("-") else "support"))))
if bool(preview.get("has_area", false)):
return _make_target_preview_badge("%s x%d" % [_compact_support_preview_text(effect_text), target_count], badge_kind)
return _make_target_preview_badge(_compact_support_preview_text(effect_text), badge_kind)
if bool(preview.get("has_area", false)):
var defeat_count := int(preview.get("defeat_count", 0))
if defeat_count > 0:
return _make_target_preview_badge("KO x%d" % defeat_count, "damage")
return _make_target_preview_badge("-%d x%d" % [int(preview.get("total_damage", 0)), target_count], "damage")
var result_text := "KO" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
return _make_target_preview_badge(result_text, "damage")
@@ -1542,7 +1562,6 @@ func _update_skill_forecast(selected: Dictionary) -> void:
forecast_label.text = "Forecast: -"
return
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)):
@@ -1551,7 +1570,7 @@ func _update_skill_forecast(selected: Dictionary) -> void:
preview["mp_cost"]
]
return
if target.is_empty() or not preview["valid_target"]:
if not preview["valid_target"]:
forecast_label.text = "%s: %s, %d MP.%s\nNo valid target." % [
preview["skill_name"],
range_text,
@@ -1560,36 +1579,71 @@ func _update_skill_forecast(selected: Dictionary) -> void:
]
return
var target := state.get_unit(str(preview.get("target_id", "")))
var target_max_hp := int(target.get("max_hp", 1)) if not target.is_empty() else 1
var target_count := int(preview.get("target_count", 1))
var has_area := bool(preview.get("has_area", false))
var area_text := " Area targets: %d." % target_count if has_area else ""
if preview["kind"] == "heal":
forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [
var total_heal := int(preview.get("total_heal", preview.get("heal", 0)))
if not has_area:
forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["heal"],
preview["target_hp_after"],
target_max_hp
]
return
forecast_label.text = "%s: %s, %d MP.%s%s\nRestore %d HP total, focus HP %d/%d." % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["heal"],
area_text,
total_heal,
preview["target_hp_after"],
target["max_hp"]
target_max_hp
]
elif preview["kind"] == "support":
var refresh_text := " Refresh." if bool(preview.get("already_active", false)) else ""
forecast_label.text = "%s: %s, %d MP.%s\n%s.%s" % [
forecast_label.text = "%s: %s, %d MP.%s%s\n%s.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
area_text,
preview.get("effect_text", "Support effect"),
refresh_text
]
else:
var defeat_text := " Defeat" if preview["would_defeat"] else ""
forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [
var defeat_count := int(preview.get("defeat_count", 0))
var defeat_text := " Defeat x%d" % defeat_count if defeat_count > 0 else ""
var total_damage := int(preview.get("total_damage", preview.get("damage", 0)))
if not has_area:
var single_defeat_text := " Defeat" if bool(preview.get("would_defeat", false)) else ""
forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["damage"],
preview["target_hp_after"],
target_max_hp,
single_defeat_text
]
return
forecast_label.text = "%s: %s, %d MP.%s%s\n%d total damage, focus HP %d/%d.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["damage"],
area_text,
total_damage,
preview["target_hp_after"],
target["max_hp"],
target_max_hp,
defeat_text
]
@@ -3421,6 +3475,7 @@ func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: D
func _format_tactic_effect_text(skill: Dictionary) -> String:
var area_text := _format_tactic_area_text(skill)
if str(skill.get("kind", "")) == "support":
var parts := []
for effect in skill.get("effects", []):
@@ -3442,8 +3497,18 @@ func _format_tactic_effect_text(skill: Dictionary) -> String:
_action_lock_effect_label(str(effect.get("action", "")))
])
if not parts.is_empty():
return _join_strings(parts, ", ")
return "P%d" % int(skill.get("power", 0))
var support_text := _join_strings(parts, ", ")
return "%s, %s" % [support_text, area_text] if not area_text.is_empty() else support_text
var power_text := "P%d" % int(skill.get("power", 0))
return "%s, %s" % [power_text, area_text] if not area_text.is_empty() else power_text
func _format_tactic_area_text(skill: Dictionary) -> String:
var shape := str(skill.get("area_shape", "single"))
var radius := int(skill.get("area_radius", 0))
if shape == "diamond" and radius > 0:
return "Area %d" % radius
return ""
func _format_tactic_range_text(skill: Dictionary) -> String: