Improve tactical attack flow
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -91,6 +91,9 @@ const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50)
|
||||
const SHOP_ITEM_ICON_SIZE := Vector2(48, 48)
|
||||
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
|
||||
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
|
||||
const HUD_COMMAND_GRID_SIZE := Vector2(420, 70)
|
||||
const HUD_COMMAND_BUTTON_SIZE := Vector2(132, 30)
|
||||
const HUD_COMMAND_GRID_COLUMNS := 3
|
||||
const POST_MOVE_MENU_SIZE := Vector2(240, 146)
|
||||
const POST_MOVE_TITLE_SIZE := Vector2(196, 22)
|
||||
const POST_MOVE_PICKER_PANEL_SIZE := Vector2(336, 236)
|
||||
@@ -746,6 +749,14 @@ func _apply_button_style(button: Button, important: bool = false) -> void:
|
||||
_apply_control_font(button, important)
|
||||
|
||||
|
||||
func _configure_hud_command_button(button: Button) -> void:
|
||||
if button == null:
|
||||
return
|
||||
button.custom_minimum_size = HUD_COMMAND_BUTTON_SIZE
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
|
||||
|
||||
func _apply_label_style(label: Label, font_color: Color, font_size: int = 0) -> void:
|
||||
if label == null:
|
||||
return
|
||||
@@ -1364,38 +1375,48 @@ func _create_hud() -> void:
|
||||
_apply_label_style(inventory_label, UI_PARCHMENT_TEXT)
|
||||
side_column.add_child(inventory_label)
|
||||
|
||||
var button_row := HBoxContainer.new()
|
||||
button_row.add_theme_constant_override("separation", 8)
|
||||
var button_row := GridContainer.new()
|
||||
button_row.name = "HudCommandGrid"
|
||||
button_row.columns = HUD_COMMAND_GRID_COLUMNS
|
||||
button_row.custom_minimum_size = HUD_COMMAND_GRID_SIZE
|
||||
button_row.add_theme_constant_override("h_separation", 6)
|
||||
button_row.add_theme_constant_override("v_separation", 5)
|
||||
side_column.add_child(button_row)
|
||||
|
||||
wait_button = Button.new()
|
||||
wait_button.text = "대기령"
|
||||
wait_button.text = "대기"
|
||||
_configure_hud_command_button(wait_button)
|
||||
wait_button.pressed.connect(_on_wait_pressed)
|
||||
button_row.add_child(wait_button)
|
||||
|
||||
tactic_button = Button.new()
|
||||
tactic_button.text = "책략첩"
|
||||
tactic_button.text = "책략"
|
||||
_configure_hud_command_button(tactic_button)
|
||||
tactic_button.pressed.connect(_on_tactic_pressed)
|
||||
button_row.add_child(tactic_button)
|
||||
|
||||
item_button = Button.new()
|
||||
item_button.text = "보급첩"
|
||||
item_button.text = "도구"
|
||||
_configure_hud_command_button(item_button)
|
||||
item_button.pressed.connect(_on_item_pressed)
|
||||
button_row.add_child(item_button)
|
||||
|
||||
equip_button = Button.new()
|
||||
equip_button.text = "병장"
|
||||
_configure_hud_command_button(equip_button)
|
||||
equip_button.pressed.connect(_on_equip_pressed)
|
||||
button_row.add_child(equip_button)
|
||||
|
||||
threat_button = Button.new()
|
||||
threat_button.text = "적진도"
|
||||
threat_button.text = "적정"
|
||||
_configure_hud_command_button(threat_button)
|
||||
threat_button.toggle_mode = true
|
||||
threat_button.pressed.connect(_on_threat_pressed)
|
||||
button_row.add_child(threat_button)
|
||||
|
||||
end_turn_button = Button.new()
|
||||
end_turn_button.text = "군령 봉함"
|
||||
end_turn_button.text = "차례 종료"
|
||||
_configure_hud_command_button(end_turn_button)
|
||||
end_turn_button.pressed.connect(_on_end_turn_pressed)
|
||||
button_row.add_child(end_turn_button)
|
||||
|
||||
@@ -2378,6 +2399,8 @@ func _handle_board_click(screen_position: Vector2) -> void:
|
||||
basic_attack_targeting = false
|
||||
_clear_pending_move_state()
|
||||
return
|
||||
if not _has_pending_move() and not basic_attack_targeting and _try_auto_move_attack(selected_unit, clicked_unit):
|
||||
return
|
||||
|
||||
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""):
|
||||
if _has_pending_move():
|
||||
@@ -3135,7 +3158,7 @@ func _draw_units() -> void:
|
||||
|
||||
draw_circle(center + Vector2(0, 17), 20, Color(0.0, 0.0, 0.0, 0.30))
|
||||
_draw_unit_class_emblem(rect, center, unit, team_color)
|
||||
_draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate)
|
||||
_draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate, _unit_pixel_facing(unit))
|
||||
_draw_unit_identity_marks(rect, center, unit, team_color)
|
||||
_draw_unit_class_badge(rect, unit, team_color)
|
||||
|
||||
@@ -3186,12 +3209,14 @@ func _target_selection_marker_entries() -> Array[Dictionary]:
|
||||
return _skill_target_marker_entries()
|
||||
if not selected_item_id.is_empty():
|
||||
return _item_target_marker_entries()
|
||||
if not state.get_selected_unit().is_empty() and not _has_pending_move():
|
||||
return _attack_target_marker_entries()
|
||||
return []
|
||||
|
||||
|
||||
func _attack_target_marker_entries() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
if not basic_attack_targeting:
|
||||
if not basic_attack_targeting and _has_pending_move():
|
||||
return result
|
||||
var selected := state.get_selected_unit()
|
||||
if selected.is_empty():
|
||||
@@ -3270,6 +3295,96 @@ func _has_basic_attack_target(unit_id: String) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func _try_auto_move_attack(selected: Dictionary, target: Dictionary) -> bool:
|
||||
if selected.is_empty() or target.is_empty():
|
||||
return false
|
||||
if bool(selected.get("moved", false)) or bool(selected.get("acted", false)):
|
||||
return false
|
||||
if selected.get("team", "") == target.get("team", ""):
|
||||
return false
|
||||
var unit_id := str(selected.get("id", ""))
|
||||
var target_id := str(target.get("id", ""))
|
||||
if unit_id.is_empty() or target_id.is_empty():
|
||||
return false
|
||||
var attack_origin := _best_auto_attack_origin(selected, target)
|
||||
if attack_origin == Vector2i(-1, -1):
|
||||
return false
|
||||
|
||||
_play_ui_confirm()
|
||||
selected_skill_id = ""
|
||||
selected_item_id = ""
|
||||
basic_attack_targeting = false
|
||||
_hide_tactic_menu()
|
||||
_hide_item_menu()
|
||||
_hide_equip_menu()
|
||||
_hide_post_move_menu()
|
||||
_hide_post_move_picker()
|
||||
_hide_targeting_hint_panel()
|
||||
if selected.get("pos", Vector2i(-1, -1)) != attack_origin:
|
||||
if not state.try_move_selected(attack_origin, true):
|
||||
_refresh_ranges()
|
||||
_update_hud()
|
||||
queue_redraw()
|
||||
return false
|
||||
if not state.commit_pending_move_events(unit_id):
|
||||
_refresh_ranges()
|
||||
_update_hud()
|
||||
queue_redraw()
|
||||
return true
|
||||
if state.try_attack_selected(target_id):
|
||||
_clear_pending_move_state()
|
||||
_refresh_ranges()
|
||||
_update_hud()
|
||||
queue_redraw()
|
||||
return true
|
||||
_refresh_ranges()
|
||||
_update_hud()
|
||||
queue_redraw()
|
||||
return false
|
||||
|
||||
|
||||
func _best_auto_attack_origin(selected: Dictionary, target: Dictionary) -> Vector2i:
|
||||
var unit_id := str(selected.get("id", ""))
|
||||
if unit_id.is_empty():
|
||||
return Vector2i(-1, -1)
|
||||
var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1))
|
||||
var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1))
|
||||
if not state.is_inside(current_cell) or not state.is_inside(target_cell):
|
||||
return Vector2i(-1, -1)
|
||||
var best_cell := Vector2i(-1, -1)
|
||||
var best_score := -999999
|
||||
for cell in state.get_movement_range(unit_id):
|
||||
if not state.is_inside(cell):
|
||||
continue
|
||||
if cell == target_cell:
|
||||
continue
|
||||
var blocker := state.get_unit_at(cell)
|
||||
if not blocker.is_empty() and str(blocker.get("id", "")) != unit_id:
|
||||
continue
|
||||
if not _can_attack_target_from(selected, cell, target_cell):
|
||||
continue
|
||||
var candidate_attacker := selected.duplicate(true)
|
||||
candidate_attacker["pos"] = cell
|
||||
var damage: int = state.calculate_damage(candidate_attacker, target)
|
||||
var move_distance: int = _manhattan(current_cell, cell)
|
||||
var score: int = damage * 20 - move_distance
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_cell = cell
|
||||
return best_cell
|
||||
|
||||
|
||||
func _can_attack_target_from(unit: Dictionary, origin: Vector2i, target_cell: Vector2i) -> bool:
|
||||
var attack_range: int = maxi(1, int(unit.get("range", 1)))
|
||||
var min_range: int = clampi(int(unit.get("min_range", mini(1, attack_range))), 1, attack_range)
|
||||
var distance: int = _manhattan(origin, target_cell)
|
||||
return distance >= min_range and distance <= attack_range
|
||||
|
||||
|
||||
func _manhattan(a: Vector2i, b: Vector2i) -> int:
|
||||
return absi(a.x - b.x) + absi(a.y - b.y)
|
||||
|
||||
|
||||
func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2:
|
||||
var texture_size := texture.get_size()
|
||||
if texture_size.x <= 0.0 or texture_size.y <= 0.0:
|
||||
@@ -3289,13 +3404,14 @@ func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color:
|
||||
return Color.WHITE
|
||||
|
||||
|
||||
func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color) -> void:
|
||||
func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color, facing: int = 1) -> void:
|
||||
var profile := _pixel_unit_sprite_profile(unit)
|
||||
var family := str(profile.get("family", _unit_class_family(unit)))
|
||||
var phase := _unit_idle_phase(unit)
|
||||
var bob := _unit_idle_bob(family, phase)
|
||||
var step := _unit_idle_step(family, phase)
|
||||
var pixel := float(profile.get("pixel", 3.0))
|
||||
var safe_facing := -1 if facing < 0 else 1
|
||||
var origin := Vector2(roundf(rect.position.x + 12.0), roundf(rect.position.y + 5.0 + bob))
|
||||
var outline := _unit_pixel_color(Color(0.035, 0.026, 0.018, 1.0), modulate)
|
||||
var skin := _unit_pixel_color(Color(0.86, 0.62, 0.38, 1.0), modulate)
|
||||
@@ -3304,6 +3420,8 @@ func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, m
|
||||
var class_color := _unit_pixel_color(_unit_class_badge_color(unit, team_color), modulate)
|
||||
var accent := _unit_pixel_color(Color(0.98, 0.76, 0.28, 1.0) if not str(unit.get("officer_id", "")).is_empty() else class_color.lightened(0.25), modulate)
|
||||
|
||||
if safe_facing < 0:
|
||||
draw_set_transform(Vector2(rect.position.x * 2.0 + TILE_SIZE, 0.0), 0.0, Vector2(-1.0, 1.0))
|
||||
_draw_pixel_rect(origin, 2, 11, 8, 2, pixel, Color(0.0, 0.0, 0.0, 0.22 * modulate.a))
|
||||
if family == "cavalry":
|
||||
_draw_pixel_cavalry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||||
@@ -3315,6 +3433,25 @@ func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, m
|
||||
_draw_pixel_heavy(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||||
else:
|
||||
_draw_pixel_infantry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||||
if safe_facing < 0:
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
func _unit_pixel_facing(unit: Dictionary) -> int:
|
||||
var unit_id := str(unit.get("id", ""))
|
||||
if not unit_id.is_empty() and unit_action_motion_by_unit.has(unit_id):
|
||||
var action_motion: Dictionary = unit_action_motion_by_unit[unit_id]
|
||||
var from_cell: Vector2i = action_motion.get("from", unit.get("pos", Vector2i.ZERO))
|
||||
var to_cell: Vector2i = action_motion.get("to", from_cell)
|
||||
if to_cell.x < from_cell.x:
|
||||
return -1
|
||||
if to_cell.x > from_cell.x:
|
||||
return 1
|
||||
if unit.has("facing_x"):
|
||||
return state.unit_facing_x(unit)
|
||||
if unit.get("team", "") == BattleState.TEAM_ENEMY:
|
||||
return -1
|
||||
return 1
|
||||
|
||||
|
||||
func _unit_idle_phase(unit: Dictionary) -> float:
|
||||
@@ -4887,11 +5024,13 @@ func _format_physical_threat_preview_text(previews: Array[Dictionary], limit: in
|
||||
var effective_type := _format_move_type(str(preview.get("effective_target_type", "")))
|
||||
var effective_target_text := "" if effective_type.is_empty() else " 대 %s" % effective_type
|
||||
effective_text = " 특효 +%d%s" % [effective_bonus, effective_target_text]
|
||||
parts.append("%s 피해 %d/명중 %d%%%s%s" % [
|
||||
var directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label")
|
||||
parts.append("%s 피해 %d/명중 %d%%%s%s%s" % [
|
||||
str(preview.get("source_name", "부대")),
|
||||
int(preview.get("damage", 0)),
|
||||
int(preview.get("hit_chance", 0)),
|
||||
effective_text,
|
||||
directional_text,
|
||||
defeat_text
|
||||
])
|
||||
if previews.size() > safe_limit:
|
||||
@@ -4977,13 +5116,16 @@ func _update_forecast() -> void:
|
||||
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
|
||||
var defeat_text := " 퇴각" if preview["would_defeat"] else ""
|
||||
var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type")
|
||||
var directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label")
|
||||
var counter_text := ""
|
||||
if preview["counter_in_range"]:
|
||||
var counter_defeat := " 퇴각" if preview["counter_would_defeat"] else ""
|
||||
var counter_effective_text := _format_effective_forecast_text(preview, "counter_effective_bonus", "counter_effective_target_type")
|
||||
counter_text = "\n반격 %d%s, 명중 %d%%, 아군 병력 %d/%d.%s" % [
|
||||
var counter_directional_text := _format_directional_forecast_text(preview, "counter_directional_bonus", "counter_directional_label")
|
||||
counter_text = "\n반격 %d%s%s, 명중 %d%%, 아군 병력 %d/%d.%s" % [
|
||||
preview["counter_damage"],
|
||||
counter_effective_text,
|
||||
counter_directional_text,
|
||||
preview.get("counter_hit_chance", 0),
|
||||
preview["attacker_hp_after"],
|
||||
selected["max_hp"],
|
||||
@@ -4992,7 +5134,7 @@ func _update_forecast() -> void:
|
||||
forecast_label.text = "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [
|
||||
range_text,
|
||||
preview["damage"],
|
||||
effective_text,
|
||||
effective_text + directional_text,
|
||||
preview.get("hit_chance", 100),
|
||||
preview["target_hp_after"],
|
||||
target["max_hp"],
|
||||
@@ -5011,6 +5153,16 @@ func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, typ
|
||||
return " (특효 +%d 대 %s)" % [bonus, move_type]
|
||||
|
||||
|
||||
func _format_directional_forecast_text(preview: Dictionary, bonus_key: String, label_key: String) -> String:
|
||||
var bonus := int(preview.get(bonus_key, 0))
|
||||
if bonus <= 0:
|
||||
return ""
|
||||
var label := str(preview.get(label_key, "방향")).strip_edges()
|
||||
if label.is_empty():
|
||||
label = "방향"
|
||||
return " (%s +%d)" % [label, bonus]
|
||||
|
||||
|
||||
func _update_skill_forecast(selected: Dictionary) -> void:
|
||||
var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell)
|
||||
if preview.is_empty():
|
||||
@@ -8736,6 +8888,9 @@ func _is_targeting_mode() -> bool:
|
||||
func _show_targeting_hint_panel() -> void:
|
||||
if targeting_hint_panel == null:
|
||||
return
|
||||
if basic_attack_targeting:
|
||||
_hide_targeting_hint_panel()
|
||||
return
|
||||
_update_targeting_hint_panel()
|
||||
if targeting_hint_panel != null and _is_targeting_mode():
|
||||
targeting_hint_panel.visible = true
|
||||
@@ -8751,7 +8906,7 @@ func _hide_targeting_hint_panel() -> void:
|
||||
func _update_targeting_hint_panel() -> void:
|
||||
if targeting_hint_panel == null:
|
||||
return
|
||||
if not _is_targeting_mode():
|
||||
if basic_attack_targeting or not _is_targeting_mode():
|
||||
_hide_targeting_hint_panel()
|
||||
return
|
||||
var selected := state.get_selected_unit()
|
||||
@@ -8854,7 +9009,11 @@ func _set_action_button_state(button: Button, label: String, disabled: bool, too
|
||||
|
||||
|
||||
func _set_action_button_blocked(button: Button, base_label: String, reason_label: String, tooltip: String) -> void:
|
||||
_set_action_button_state(button, "%s|%s" % [base_label, reason_label], true, tooltip)
|
||||
var reason := reason_label.strip_edges()
|
||||
var blocked_tooltip := tooltip
|
||||
if not reason.is_empty():
|
||||
blocked_tooltip = "%s: %s" % [reason, tooltip] if not tooltip.strip_edges().is_empty() else reason
|
||||
_set_action_button_state(button, base_label, true, blocked_tooltip)
|
||||
|
||||
|
||||
func _action_phase_block_reason() -> Dictionary:
|
||||
@@ -8890,16 +9049,16 @@ func _update_wait_button(selected: Dictionary) -> void:
|
||||
if wait_button == null:
|
||||
return
|
||||
if selected.is_empty():
|
||||
_set_action_button_blocked(wait_button, "대기령", "부대 미지정", "명령할 부대를 먼저 고릅니다.")
|
||||
_set_action_button_blocked(wait_button, "대기", "부대 미지정", "명령할 부대를 먼저 고릅니다.")
|
||||
return
|
||||
if bool(selected.get("acted", false)):
|
||||
_set_action_button_blocked(wait_button, "대기령", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
_set_action_button_blocked(wait_button, "대기", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
return
|
||||
var phase_block := _action_phase_block_reason()
|
||||
if not phase_block.is_empty():
|
||||
_set_action_button_blocked(wait_button, "대기령", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
_set_action_button_blocked(wait_button, "대기", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
return
|
||||
_set_action_button_state(wait_button, "대기령", false, "이 부대의 군령을 봉인합니다.")
|
||||
_set_action_button_state(wait_button, "대기", false, "이 부대의 행동을 마칩니다.")
|
||||
|
||||
|
||||
func _update_end_turn_button() -> void:
|
||||
@@ -8907,9 +9066,9 @@ func _update_end_turn_button() -> void:
|
||||
return
|
||||
var phase_block := _action_phase_block_reason()
|
||||
if not phase_block.is_empty():
|
||||
_set_action_button_blocked(end_turn_button, "군령 봉함", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
_set_action_button_blocked(end_turn_button, "차례 종료", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
return
|
||||
_set_action_button_state(end_turn_button, "군령 봉함", false, "아군 군령을 거두고 적군의 차례로 넘깁니다.")
|
||||
_set_action_button_state(end_turn_button, "차례 종료", false, "아군 차례를 마치고 적군에게 넘깁니다.")
|
||||
|
||||
|
||||
func _update_threat_button() -> void:
|
||||
@@ -8917,46 +9076,46 @@ func _update_threat_button() -> void:
|
||||
return
|
||||
threat_button.button_pressed = show_threat_overlay
|
||||
if campaign_complete_screen:
|
||||
_set_action_button_blocked(threat_button, "적진도", "전기 봉인", "전기가 완결되었습니다.")
|
||||
_set_action_button_blocked(threat_button, "적정", "전기 봉인", "전기가 완결되었습니다.")
|
||||
return
|
||||
if not battle_started:
|
||||
_set_action_button_blocked(threat_button, "적진도", "전장도 미개봉", "전장도를 펼친 뒤 적진을 살필 수 있습니다.")
|
||||
_set_action_button_blocked(threat_button, "적정", "전장도 미개봉", "전장도를 펼친 뒤 적진을 살필 수 있습니다.")
|
||||
return
|
||||
if state.battle_status != BattleState.STATUS_ACTIVE:
|
||||
_set_action_button_blocked(threat_button, "적진도", "전장 종결", "전투 중에만 적진을 살필 수 있습니다.")
|
||||
_set_action_button_blocked(threat_button, "적정", "전장 종결", "전투 중에만 적진을 살필 수 있습니다.")
|
||||
return
|
||||
_set_action_button_state(threat_button, "적진도", false, "적의 무기와 책략 사거리를 살핍니다.")
|
||||
_set_action_button_state(threat_button, "적정", false, "적의 무기와 책략 사거리를 살핍니다.")
|
||||
|
||||
|
||||
func _update_tactic_button(selected: Dictionary) -> void:
|
||||
if tactic_button == null:
|
||||
return
|
||||
if selected.is_empty():
|
||||
_set_action_button_blocked(tactic_button, "책략첩", "부대 미지정", "책략을 쓸 부대를 먼저 고릅니다.")
|
||||
_set_action_button_blocked(tactic_button, "책략", "부대 미지정", "책략을 쓸 부대를 먼저 고릅니다.")
|
||||
return
|
||||
|
||||
var skill_ids := state.get_skill_ids(selected["id"])
|
||||
if skill_ids.is_empty():
|
||||
_set_action_button_blocked(tactic_button, "책략첩", "없음", "이 부대는 사용할 책략이 없습니다.")
|
||||
_set_action_button_blocked(tactic_button, "책략", "없음", "이 부대는 사용할 책략이 없습니다.")
|
||||
return
|
||||
if state.is_unit_skill_sealed(selected["id"]):
|
||||
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 책략을 쓸 수 없습니다.")
|
||||
_set_action_button_blocked(tactic_button, "책략", "봉인됨", "이 부대는 책략을 쓸 수 없습니다.")
|
||||
return
|
||||
if bool(selected.get("acted", false)):
|
||||
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
_set_action_button_blocked(tactic_button, "책략", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
return
|
||||
var phase_block := _action_phase_block_reason()
|
||||
if not phase_block.is_empty():
|
||||
_set_action_button_blocked(tactic_button, "책략첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
_set_action_button_blocked(tactic_button, "책략", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
return
|
||||
|
||||
if not selected_skill_id.is_empty():
|
||||
var skill := state.get_skill_def(selected_skill_id)
|
||||
_set_action_button_state(tactic_button, "책략첩|%s" % str(skill.get("name", selected_skill_id)), false, "전장에 책략 대상을 지목합니다.")
|
||||
_set_action_button_state(tactic_button, "책략 선택", false, "%s 대상 지목." % str(skill.get("name", selected_skill_id)))
|
||||
elif tactic_menu != null and tactic_menu.visible:
|
||||
_set_action_button_state(tactic_button, "책략첩 봉함", false, "책략 목록을 닫습니다.")
|
||||
_set_action_button_state(tactic_button, "닫기", false, "책략 목록을 닫습니다.")
|
||||
else:
|
||||
_set_action_button_state(tactic_button, "책략첩", false, "책략 목록을 펼칩니다. %d개." % skill_ids.size())
|
||||
_set_action_button_state(tactic_button, "책략", false, "책략 목록을 펼칩니다. %d개." % skill_ids.size())
|
||||
|
||||
|
||||
func _show_tactic_menu(selected: Dictionary) -> void:
|
||||
@@ -9168,28 +9327,28 @@ func _update_item_button(selected: Dictionary) -> void:
|
||||
if item_button == null:
|
||||
return
|
||||
if selected.is_empty():
|
||||
_set_action_button_blocked(item_button, "보급첩", "부대 미지정", "도구를 쓸 부대를 먼저 고릅니다.")
|
||||
_set_action_button_blocked(item_button, "도구", "부대 미지정", "도구를 쓸 부대를 먼저 고릅니다.")
|
||||
return
|
||||
|
||||
if bool(selected.get("acted", false)):
|
||||
_set_action_button_blocked(item_button, "보급첩", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
_set_action_button_blocked(item_button, "도구", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||||
return
|
||||
var phase_block := _action_phase_block_reason()
|
||||
if not phase_block.is_empty():
|
||||
_set_action_button_blocked(item_button, "보급첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
_set_action_button_blocked(item_button, "도구", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
return
|
||||
var item_ids := state.get_usable_item_ids()
|
||||
if item_ids.is_empty():
|
||||
_set_action_button_blocked(item_button, "보급첩", "비어 있음", "사용할 도구가 없습니다.")
|
||||
_set_action_button_blocked(item_button, "도구", "비어 있음", "사용할 도구가 없습니다.")
|
||||
return
|
||||
|
||||
if not selected_item_id.is_empty():
|
||||
var item := state.get_item_def(selected_item_id)
|
||||
_set_action_button_state(item_button, "보급첩|%s" % str(item.get("name", selected_item_id)), false, "전장에서 도구 대상을 지목합니다.")
|
||||
_set_action_button_state(item_button, "도구 선택", false, "%s 대상 지목." % str(item.get("name", selected_item_id)))
|
||||
elif item_menu != null and item_menu.visible:
|
||||
_set_action_button_state(item_button, "보급첩 봉함", false, "도구 목록을 닫습니다.")
|
||||
_set_action_button_state(item_button, "닫기", false, "도구 목록을 닫습니다.")
|
||||
else:
|
||||
_set_action_button_state(item_button, "보급첩", false, "도구 목록을 펼칩니다. %d종." % item_ids.size())
|
||||
_set_action_button_state(item_button, "도구", false, "도구 목록을 펼칩니다. %d종." % item_ids.size())
|
||||
|
||||
|
||||
func _show_item_menu(selected: Dictionary) -> void:
|
||||
@@ -9313,7 +9472,7 @@ func _update_equip_button(selected: Dictionary) -> void:
|
||||
_set_action_button_blocked(equip_button, "병장", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||||
return
|
||||
if equip_menu != null and equip_menu.visible:
|
||||
_set_action_button_state(equip_button, "병장 닫기", false, "병장 장부를 닫습니다.")
|
||||
_set_action_button_state(equip_button, "닫기", false, "병장 장부를 닫습니다.")
|
||||
else:
|
||||
_set_action_button_state(equip_button, "병장", false, "행군 전에 무기, 갑옷, 장신구를 바꿉니다.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user