Improve tactical attack flow

This commit is contained in:
2026-06-19 17:44:58 +09:00
parent 7db09930e7
commit 71e6abb986
5 changed files with 606 additions and 66 deletions

View File

@@ -27,6 +27,11 @@ const EXP_DEFEAT_BONUS := 30
const EXP_LEVEL := 100
const BASE_HIT_CHANCE := 90
const MIN_HIT_CHANCE := 25
const DIRECTIONAL_FRONT_ATTACK := "front"
const DIRECTIONAL_SIDE_ATTACK := "side"
const DIRECTIONAL_BACK_ATTACK := "back"
const DIRECTIONAL_SIDE_DAMAGE_BONUS := 2
const DIRECTIONAL_BACK_DAMAGE_BONUS := 5
const CARDINAL_DIRECTIONS := [
Vector2i(1, 0),
@@ -569,6 +574,7 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary:
unit["agi"] = int(unit.get("agi", 0))
unit["move"] = int(unit.get("move", 3))
unit["move_type"] = str(unit.get("move_type", "foot"))
unit["facing_x"] = _normalize_facing_x(unit.get("facing_x", _default_unit_facing_x(unit)))
if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY:
unit["skills"] = []
else:
@@ -695,13 +701,16 @@ func try_move_selected(cell: Vector2i, defer_reach_events := false) -> bool:
return false
var from_cell: Vector2i = unit["pos"]
unit["_pending_move_facing_x"] = unit_facing_x(unit)
unit["pos"] = cell
unit["moved"] = true
_face_unit_from_to(unit, from_cell, cell)
unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell)
_emit_log("%s, %s으로 진군." % [unit["name"], _format_cell(cell)])
if not defer_reach_events:
_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
_check_battle_status()
unit.erase("_pending_move_facing_x")
_notify_changed()
return true
@@ -710,6 +719,7 @@ func commit_pending_move_events(unit_id: String) -> bool:
var unit := get_unit(unit_id)
if unit.is_empty() or battle_status != STATUS_ACTIVE:
return false
unit.erase("_pending_move_facing_x")
_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
_check_battle_status()
_notify_changed()
@@ -730,6 +740,8 @@ func cancel_pending_move(unit_id: String, from_cell: Vector2i, to_cell: Vector2i
unit["pos"] = from_cell
unit["moved"] = false
unit["facing_x"] = _normalize_facing_x(unit.get("_pending_move_facing_x", unit.get("facing_x", _default_unit_facing_x(unit))))
unit.erase("_pending_move_facing_x")
unit_motion_requested.emit(unit_id, to_cell, from_cell)
_emit_log("%s의 행군을 거두었다." % unit["name"])
_notify_changed()
@@ -793,6 +805,7 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool:
if skill_kind == "support" and not _skill_has_support_effects(skill):
return false
_face_unit_toward_cell(caster, target_cell)
_emit_skill_motion(caster, target_cell, skill_kind)
caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
if skill_kind == "heal":
@@ -847,6 +860,7 @@ func try_use_selected_item_on_cell(item_id: String, target_cell: Vector2i) -> bo
if not applied:
return false
_face_unit_toward_cell(user, target_cell)
battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1)
user["acted"] = true
user["moved"] = true
@@ -868,21 +882,32 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary:
return {}
var attack_locked := _unit_has_action_lock(attacker, "attack")
var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(attacker, target)
var damage := 0 if attack_locked else calculate_damage(attacker, target)
var preview_attacker := attacker.duplicate(true)
var preview_target := target.duplicate(true)
if not attack_locked:
_face_unit_toward_cell(preview_attacker, preview_target["pos"])
var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(preview_attacker, preview_target)
var directional_info := {} if attack_locked else get_directional_attack_info(preview_attacker, preview_target)
var directional_bonus := int(directional_info.get("bonus", 0))
var damage := 0 if attack_locked else calculate_damage(preview_attacker, preview_target)
var target_hp_after: int = maxi(0, int(target["hp"]) - damage)
var counter_damage := 0
var attacker_hp_after := int(attacker["hp"])
var hit_chance := 0 if attack_locked else calculate_hit_chance(attacker, target)
var hit_chance := 0 if attack_locked else calculate_hit_chance(preview_attacker, preview_target)
var counter_hit_chance := 0
var counter_effective_bonus := 0
var counter_directional_info := {}
var counter_directional_bonus := 0
var counter_in_range := false
if not attack_locked and (target_hp_after > 0 or hit_chance < 100):
counter_in_range = _is_in_attack_range(target, attacker["pos"])
counter_in_range = _is_in_attack_range(preview_target, preview_attacker["pos"])
if counter_in_range:
counter_effective_bonus = _weapon_effectiveness_bonus(target, attacker)
counter_damage = calculate_damage(target, attacker)
counter_hit_chance = calculate_hit_chance(target, attacker)
_face_unit_toward_cell(preview_target, preview_attacker["pos"])
counter_effective_bonus = _weapon_effectiveness_bonus(preview_target, preview_attacker)
counter_directional_info = get_directional_attack_info(preview_target, preview_attacker)
counter_directional_bonus = int(counter_directional_info.get("bonus", 0))
counter_damage = calculate_damage(preview_target, preview_attacker)
counter_hit_chance = calculate_hit_chance(preview_target, preview_attacker)
attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage)
return {
"attacker_id": attacker_id,
@@ -890,6 +915,9 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary:
"damage": damage,
"effective_bonus": effective_bonus,
"effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "",
"directional_attack": str(directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"directional_bonus": directional_bonus,
"directional_label": str(directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK))),
"hit_chance": hit_chance,
"target_hp_after": target_hp_after,
"would_defeat": int(target["hp"]) - damage <= 0,
@@ -899,6 +927,9 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary:
"counter_damage": counter_damage,
"counter_effective_bonus": counter_effective_bonus,
"counter_effective_target_type": _weapon_effectiveness_target_type(attacker) if counter_effective_bonus > 0 else "",
"counter_directional_attack": str(counter_directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"counter_directional_bonus": counter_directional_bonus,
"counter_directional_label": str(counter_directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK))),
"counter_hit_chance": counter_hit_chance,
"attacker_hp_after": attacker_hp_after,
"counter_would_defeat": attacker_hp_after <= 0
@@ -997,7 +1028,87 @@ func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) -
func calculate_damage(attacker: Dictionary, target: Dictionary) -> int:
var terrain_defense := get_terrain_defense(target["pos"])
var effective_bonus := _weapon_effectiveness_bonus(attacker, target)
return max(1, _effective_stat(attacker, "atk") + effective_bonus - _effective_stat(target, "def") - terrain_defense)
var directional_bonus := _directional_damage_bonus(attacker, target)
return max(1, _effective_stat(attacker, "atk") + effective_bonus + directional_bonus - _effective_stat(target, "def") - terrain_defense)
func get_directional_attack_info(attacker: Dictionary, target: Dictionary) -> Dictionary:
var attack_kind := _directional_attack_kind(attacker, target)
return {
"kind": attack_kind,
"bonus": _directional_damage_bonus_for_kind(attack_kind),
"label": _directional_attack_label(attack_kind)
}
func unit_facing_x(unit: Dictionary) -> int:
return _normalize_facing_x(unit.get("facing_x", _default_unit_facing_x(unit)))
func _default_unit_facing_x(unit: Dictionary) -> int:
return -1 if str(unit.get("team", "")) == TEAM_ENEMY else 1
func _normalize_facing_x(value) -> int:
if typeof(value) == TYPE_VECTOR2I:
var vector_i: Vector2i = value
return -1 if vector_i.x < 0 else 1
if typeof(value) == TYPE_VECTOR2:
var vector: Vector2 = value
return -1 if vector.x < 0.0 else 1
if typeof(value) == TYPE_STRING:
var text := str(value).strip_edges().to_lower()
if text == "left" or text == "west" or text == "w" or text == "-1":
return -1
if text == "right" or text == "east" or text == "e" or text == "1":
return 1
return -1 if int(value) < 0 else 1
func _face_unit_from_to(unit: Dictionary, from_cell: Vector2i, to_cell: Vector2i) -> void:
if unit.is_empty() or from_cell.x == to_cell.x:
return
unit["facing_x"] = -1 if to_cell.x < from_cell.x else 1
func _face_unit_toward_cell(unit: Dictionary, target_cell: Vector2i) -> void:
if unit.is_empty() or not unit.has("pos"):
return
_face_unit_from_to(unit, unit.get("pos", target_cell), target_cell)
func _directional_attack_kind(attacker: Dictionary, target: Dictionary) -> String:
if attacker.is_empty() or target.is_empty():
return DIRECTIONAL_FRONT_ATTACK
var attacker_cell: Vector2i = attacker.get("pos", Vector2i.ZERO)
var target_cell: Vector2i = target.get("pos", Vector2i.ZERO)
var delta_x := attacker_cell.x - target_cell.x
if delta_x == 0:
return DIRECTIONAL_SIDE_ATTACK
var approach_x := -1 if delta_x < 0 else 1
if approach_x == unit_facing_x(target):
return DIRECTIONAL_FRONT_ATTACK
return DIRECTIONAL_BACK_ATTACK
func _directional_damage_bonus(attacker: Dictionary, target: Dictionary) -> int:
return _directional_damage_bonus_for_kind(_directional_attack_kind(attacker, target))
func _directional_damage_bonus_for_kind(attack_kind: String) -> int:
if attack_kind == DIRECTIONAL_BACK_ATTACK:
return DIRECTIONAL_BACK_DAMAGE_BONUS
if attack_kind == DIRECTIONAL_SIDE_ATTACK:
return DIRECTIONAL_SIDE_DAMAGE_BONUS
return 0
func _directional_attack_label(attack_kind: String) -> String:
if attack_kind == DIRECTIONAL_BACK_ATTACK:
return "후방"
if attack_kind == DIRECTIONAL_SIDE_ATTACK:
return "측면"
return "정면"
func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int:
@@ -1553,6 +1664,7 @@ func get_physical_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) ->
var damage := calculate_damage(unit, target)
var hit_chance := calculate_hit_chance(unit, target)
var effective_bonus := _weapon_effectiveness_bonus(unit, target)
var directional_info := get_directional_attack_info(unit, target)
result.append({
"source_id": str(unit.get("id", "")),
"source_name": str(unit.get("name", unit.get("id", "Unit"))),
@@ -1561,7 +1673,10 @@ func get_physical_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) ->
"target_hp_after": max(0, int(target.get("hp", 0)) - damage),
"would_defeat": int(target.get("hp", 0)) - damage <= 0,
"effective_bonus": effective_bonus,
"effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else ""
"effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "",
"directional_attack": str(directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"directional_bonus": int(directional_info.get("bonus", 0)),
"directional_label": str(directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK)))
})
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if bool(a.get("would_defeat", false)) != bool(b.get("would_defeat", false)):
@@ -2958,6 +3073,7 @@ func _enemy_take_action(enemy: Dictionary) -> void:
var from_cell: Vector2i = enemy["pos"]
enemy["pos"] = best_cell
enemy["moved"] = true
_face_unit_from_to(enemy, from_cell, best_cell)
unit_motion_requested.emit(str(enemy.get("id", "")), from_cell, best_cell)
_emit_log("%s, %s으로 압박." % [enemy["name"], _format_cell(best_cell)])
_run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy)
@@ -2985,7 +3101,12 @@ func _best_ai_physical_attack_action(attacker: Dictionary, origins: Array[Vector
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 scoring_attacker := attacker
if attacker.get("pos", Vector2i(-1, -1)) != origin:
scoring_attacker = attacker.duplicate(true)
scoring_attacker["pos"] = origin
_face_unit_from_to(scoring_attacker, attacker.get("pos", origin), origin)
var damage := calculate_damage(scoring_attacker, target)
var score := _physical_attack_ai_score(attacker, target, damage)
score -= _manhattan(attacker["pos"], origin) * 2
if score > best_score:
@@ -3346,6 +3467,7 @@ func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void:
func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary:
_face_unit_toward_cell(attacker, target.get("pos", attacker.get("pos", Vector2i.ZERO)))
var hit_chance := calculate_hit_chance(attacker, target)
var verb := "반격" if is_counter else "공격"
unit_action_motion_requested.emit(
@@ -3358,6 +3480,7 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal
_emit_log("%s %s %s, 헛쳤다. 명중 %d%%." % [attacker["name"], verb, target["name"], hit_chance])
_emit_combat_feedback(target, "헛침", "miss")
return {"hit": false, "defeated": false}
var directional_info := get_directional_attack_info(attacker, target)
var damage := calculate_damage(attacker, target)
target["hp"] = max(0, int(target["hp"]) - damage)
var effective_bonus := _weapon_effectiveness_bonus(attacker, target)
@@ -3365,6 +3488,9 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal
if effective_bonus > 0:
effective_text = " %s 특효 +%d." % [_format_move_type(_weapon_effectiveness_target_type(target)), effective_bonus]
_emit_log("%s %s %s, %d 피해. 명중 %d%%.%s" % [attacker["name"], verb, target["name"], damage, hit_chance, effective_text])
var directional_bonus := int(directional_info.get("bonus", 0))
if directional_bonus > 0:
_emit_log("%s %s +%d." % [target["name"], str(directional_info.get("label", "")), directional_bonus])
_emit_combat_feedback(target, "-%d" % damage, "damage")
if int(target["hp"]) <= 0: