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:

View File

@@ -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, "행군 전에 무기, 갑옷, 장신구를 바꿉니다.")