diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 85a1957..fd3c626 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -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: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 2bfaf72..dd567d4 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -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, "행군 전에 무기, 갑옷, 장신구를 바꿉니다.") diff --git a/tools/smoke_directional_combat.gd b/tools/smoke_directional_combat.gd new file mode 100644 index 0000000..e839c40 --- /dev/null +++ b/tools/smoke_directional_combat.gd @@ -0,0 +1,174 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_directional_damage_bonuses(failures) + _check_attack_updates_facing(failures) + _check_move_cancel_restores_facing(failures) + _check_scene_pixel_facing(failures) + + if failures.is_empty(): + print("directional combat smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_directional_damage_bonuses(failures: Array[String]) -> void: + var state = _loaded_state(failures, "directional damage") + if state == null: + return + var attacker: Dictionary = state.get_unit("cao_cao") + var target: Dictionary = state.get_unit("yellow_turban_1") + if attacker.is_empty() or target.is_empty(): + failures.append("missing units for directional damage") + return + + _configure_duel_units(attacker, target) + target["pos"] = Vector2i(5, 9) + target["facing_x"] = 1 + + attacker["pos"] = Vector2i(6, 9) + var front_damage: int = state.calculate_damage(attacker, target) + var front_info: Dictionary = state.get_directional_attack_info(attacker, target) + attacker["pos"] = Vector2i(5, 8) + var side_damage: int = state.calculate_damage(attacker, target) + var side_info: Dictionary = state.get_directional_attack_info(attacker, target) + attacker["pos"] = Vector2i(4, 9) + var back_damage: int = state.calculate_damage(attacker, target) + var back_info: Dictionary = state.get_directional_attack_info(attacker, target) + + if str(front_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_FRONT_ATTACK: + failures.append("front attack should be classified as front: %s" % str(front_info)) + if str(side_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_SIDE_ATTACK: + failures.append("same-file attack should be classified as side: %s" % str(side_info)) + if str(back_info.get("kind", "")) != BattleStateScript.DIRECTIONAL_BACK_ATTACK: + failures.append("rear attack should be classified as back: %s" % str(back_info)) + if side_damage != front_damage + BattleStateScript.DIRECTIONAL_SIDE_DAMAGE_BONUS: + failures.append("side damage should add side bonus: front %d side %d" % [front_damage, side_damage]) + if back_damage != front_damage + BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS: + failures.append("back damage should add back bonus: front %d back %d" % [front_damage, back_damage]) + + var preview: Dictionary = state.get_damage_preview("cao_cao", "yellow_turban_1") + if str(preview.get("directional_attack", "")) != BattleStateScript.DIRECTIONAL_BACK_ATTACK: + failures.append("damage preview should expose rear attack: %s" % str(preview)) + if int(preview.get("directional_bonus", 0)) != BattleStateScript.DIRECTIONAL_BACK_DAMAGE_BONUS: + failures.append("damage preview should expose rear bonus: %s" % str(preview)) + + +func _check_attack_updates_facing(failures: Array[String]) -> void: + var state = _loaded_state(failures, "attack facing") + if state == null: + return + var attacker: Dictionary = state.get_unit("cao_cao") + var target: Dictionary = state.get_unit("yellow_turban_1") + if attacker.is_empty() or target.is_empty(): + failures.append("missing units for attack facing") + return + + _configure_duel_units(attacker, target) + attacker["pos"] = Vector2i(4, 9) + attacker["facing_x"] = -1 + target["pos"] = Vector2i(5, 9) + target["facing_x"] = 1 + target["controllable"] = false + var expected_damage: int = state.calculate_damage(attacker, target) + var start_hp: int = int(target.get("hp", 0)) + + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for attack facing") + return + if not state.try_attack_selected("yellow_turban_1"): + failures.append("rear attack should execute") + return + + if int(target.get("hp", 0)) != start_hp - expected_damage: + failures.append("rear attack damage mismatch: expected %d got %d" % [expected_damage, start_hp - int(target.get("hp", 0))]) + if state.unit_facing_x(attacker) != 1: + failures.append("attacker should face target after attack") + + +func _check_move_cancel_restores_facing(failures: Array[String]) -> void: + var state = _loaded_state(failures, "move cancel facing") + if state == null: + return + var unit: Dictionary = state.get_unit("cao_cao") + if unit.is_empty(): + failures.append("missing Cao Cao for move cancel facing") + return + + unit["facing_x"] = -1 + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for move cancel facing") + return + if not state.try_move_selected(Vector2i(2, 3), true): + failures.append("pending move should succeed for facing cancel") + return + if state.unit_facing_x(unit) != 1: + failures.append("pending move to the east should face right") + if not state.cancel_pending_move("cao_cao", Vector2i(1, 3), Vector2i(2, 3)): + failures.append("pending move cancel should succeed for facing restore") + return + if state.unit_facing_x(unit) != -1: + failures.append("canceling a pending move should restore previous facing") + + +func _check_scene_pixel_facing(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for pixel facing") + scene.free() + return + + var player: Dictionary = scene.state.get_unit("cao_cao") + var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") + player["facing_x"] = -1 + enemy["facing_x"] = 1 + if scene._unit_pixel_facing(player) != -1: + failures.append("pixel facing should use player facing_x") + if scene._unit_pixel_facing(enemy) != 1: + failures.append("pixel facing should use enemy facing_x") + + scene.unit_action_motion_by_unit["cao_cao"] = { + "from": Vector2i(4, 3), + "to": Vector2i(3, 3) + } + if scene._unit_pixel_facing(player) != -1: + failures.append("pixel facing should prefer active action motion") + scene.free() + + +func _configure_duel_units(attacker: Dictionary, target: Dictionary) -> void: + attacker["atk"] = 30 + attacker["agi"] = 50 + attacker["hp"] = 80 + attacker["max_hp"] = 80 + attacker["range"] = 1 + attacker["min_range"] = 1 + attacker["acted"] = false + attacker["moved"] = false + target["def"] = 10 + target["atk"] = 1 + target["agi"] = 0 + target["hp"] = 80 + target["max_hp"] = 80 + target["range"] = 1 + target["min_range"] = 1 + target["alive"] = true + target["acted"] = false + target["moved"] = false + + +func _loaded_state(failures: Array[String], label: String): + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("%s could not load opening battle" % label) + return null + return state diff --git a/tools/smoke_post_move_action_flow.gd b/tools/smoke_post_move_action_flow.gd index ce7684c..deaf494 100644 --- a/tools/smoke_post_move_action_flow.gd +++ b/tools/smoke_post_move_action_flow.gd @@ -12,6 +12,7 @@ func _init() -> void: _check_scene_post_move_text_fit(failures) _check_scene_post_move_tactic_picker_flow(failures) _check_scene_post_move_item_picker_flow(failures) + _check_scene_enemy_click_auto_attack(failures) if failures.is_empty(): print("post move action flow smoke ok") @@ -278,8 +279,8 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void: failures.append("Attack should enter target selection mode") if scene.post_move_menu != null and scene.post_move_menu.visible: failures.append("post-move menu should hide while selecting an attack target") - if scene.targeting_hint_panel == null or not scene.targeting_hint_panel.visible: - failures.append("targeting hint panel should be visible during attack targeting") + if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible: + failures.append("attack targeting should not open a separate hint panel") if not scene.attack_cells.has(Vector2i(3, 3)): failures.append("attack targeting should highlight the adjacent enemy cell") var markers := scene._attack_target_marker_entries() @@ -312,6 +313,56 @@ func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void: scene.free() +func _check_scene_enemy_click_auto_attack(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for enemy click auto attack") + scene.free() + return + scene.battle_started = true + scene.campaign_complete_screen = false + scene.briefing_panel.visible = false + scene.result_panel.visible = false + scene._clear_pending_move_state() + + var cao_cao: Dictionary = scene.state.get_unit("cao_cao") + var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") + cao_cao["pos"] = Vector2i(1, 3) + cao_cao["atk"] = 60 + cao_cao["agi"] = 999 + cao_cao["moved"] = false + cao_cao["acted"] = false + enemy["pos"] = Vector2i(4, 3) + enemy["hp"] = 24 + enemy["max_hp"] = 24 + enemy["def"] = 1 + enemy["alive"] = true + scene.state.rng.seed = 1 + + if not scene.state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for enemy click auto attack") + scene.free() + return + scene._refresh_ranges() + if scene.attack_cells.has(Vector2i(4, 3)): + failures.append("auto attack setup enemy should start outside current attack range") + scene._handle_board_click(_screen_for_cell(Vector2i(4, 3))) + cao_cao = scene.state.get_unit("cao_cao") + enemy = scene.state.get_unit("yellow_turban_1") + var auto_origin: Vector2i = cao_cao.get("pos", Vector2i.ZERO) + if absi(auto_origin.x - Vector2i(4, 3).x) + absi(auto_origin.y - Vector2i(4, 3).y) != 1: + failures.append("enemy click should auto-move to an attack origin, got %s" % str(auto_origin)) + if not bool(cao_cao.get("acted", false)) or not bool(cao_cao.get("moved", false)): + failures.append("enemy click auto attack should spend the unit action") + if int(enemy.get("hp", 0)) >= 24 and bool(enemy.get("alive", true)): + failures.append("enemy click auto attack should damage or defeat the target") + if scene._has_pending_move() or scene.basic_attack_targeting: + failures.append("enemy click auto attack should not leave pending or targeting UI state") + + scene.free() + + func _check_scene_post_move_tactic_picker_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 9317e8e..f921439 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -96,6 +96,7 @@ func _init() -> void: _check_ancient_ui_theme(failures) _check_hover_intent_badges(failures) _check_counter_attack_badge_variants(failures) + _check_hud_command_grid_layout(failures) _check_action_button_disabled_reasons(failures) _check_terrain_and_unit_presentation(failures) _check_objective_and_status_marker_helpers(failures) @@ -3419,6 +3420,35 @@ func _find_descendant_by_name(root: Node, node_name: String) -> Node: return null +func _check_hud_command_grid_layout(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + var command_grid := scene.end_turn_button.get_parent() as GridContainer + if command_grid == null: + failures.append("HUD command buttons should live in a fixed grid") + else: + if command_grid.columns != BattleSceneScript.HUD_COMMAND_GRID_COLUMNS: + failures.append("HUD command grid should use %d columns, got %d" % [BattleSceneScript.HUD_COMMAND_GRID_COLUMNS, command_grid.columns]) + if command_grid.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_GRID_SIZE.x or command_grid.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_GRID_SIZE.y: + failures.append("HUD command grid should reserve stable space: %s" % str(command_grid.custom_minimum_size)) + for button in [ + scene.wait_button, + scene.tactic_button, + scene.item_button, + scene.equip_button, + scene.threat_button, + scene.end_turn_button + ]: + if button == null: + failures.append("HUD command button missing") + continue + if button.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.y: + failures.append("HUD command button should reserve fixed Korean label space: %s" % str(button.custom_minimum_size)) + if scene.end_turn_button.text != "차례 종료": + failures.append("end turn command should use a short visible Korean label: %s" % scene.end_turn_button.text) + scene.free() + + func _check_action_button_disabled_reasons(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene.wait_button = Button.new() @@ -3441,7 +3471,7 @@ func _check_action_button_disabled_reasons(failures: Array[String]) -> void: "remaining_phases": 2 }] scene._update_tactic_button(cao_cao) - if scene.tactic_button.text != "책략첩|봉인됨" or not scene.tactic_button.tooltip_text.contains("책략"): + if scene.tactic_button.text != "책략" or not scene.tactic_button.tooltip_text.contains("봉인됨") or not scene.tactic_button.tooltip_text.contains("책략"): failures.append("sealed unit should explain disabled tactic button: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text]) cao_cao["status_effects"] = [] @@ -3450,36 +3480,36 @@ func _check_action_button_disabled_reasons(failures: Array[String]) -> void: scene._update_tactic_button(cao_cao) scene._update_item_button(cao_cao) scene._update_equip_button(cao_cao) - if not scene.wait_button.text.contains("봉인됨"): - failures.append("acted unit hold button should show Sealed: %s" % scene.wait_button.text) - if not scene.tactic_button.text.contains("봉인됨"): - failures.append("acted unit stratagem button should show Sealed: %s" % scene.tactic_button.text) - if not scene.item_button.text.contains("봉인됨"): - failures.append("acted unit supply button should show Sealed: %s" % scene.item_button.text) - if not scene.equip_button.text.contains("봉인됨"): - failures.append("acted unit arms button should show Sealed: %s" % scene.equip_button.text) + if scene.wait_button.text != "대기" or not scene.wait_button.tooltip_text.contains("이미 행동"): + failures.append("acted unit hold button should keep short text and explain in tooltip: %s | %s" % [scene.wait_button.text, scene.wait_button.tooltip_text]) + if scene.tactic_button.text != "책략" or not scene.tactic_button.tooltip_text.contains("이미 행동"): + failures.append("acted unit stratagem button should keep short text and explain in tooltip: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text]) + if scene.item_button.text != "도구" or not scene.item_button.tooltip_text.contains("이미 행동"): + failures.append("acted unit supply button should keep short text and explain in tooltip: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text]) + if scene.equip_button.text != "병장" or not scene.equip_button.tooltip_text.contains("이미 행동"): + failures.append("acted unit arms button should keep short text and explain in tooltip: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text]) cao_cao["acted"] = false cao_cao["moved"] = true scene._update_equip_button(cao_cao) - if scene.equip_button.text != "병장|행군 후" or not scene.equip_button.tooltip_text.contains("행군 전"): + if scene.equip_button.text != "병장" or not scene.equip_button.tooltip_text.contains("행군 후") or not scene.equip_button.tooltip_text.contains("행군 전"): failures.append("moved unit should explain disabled equip button: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text]) cao_cao["moved"] = false scene.state.set_inventory_snapshot({}) scene._update_item_button(cao_cao) - if scene.item_button.text != "보급첩|비어 있음" or not scene.item_button.tooltip_text.contains("도구"): + if scene.item_button.text != "도구" or not scene.item_button.tooltip_text.contains("비어 있음") or not scene.item_button.tooltip_text.contains("도구"): failures.append("empty inventory should explain disabled item button: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text]) scene.state.current_team = BattleState.TEAM_ENEMY scene._update_end_turn_button() - if scene.end_turn_button.text != "군령 봉함|적군 차례" or not scene.end_turn_button.tooltip_text.contains("아군 군령"): + if scene.end_turn_button.text != "차례 종료" or not scene.end_turn_button.tooltip_text.contains("적군 차례") or not scene.end_turn_button.tooltip_text.contains("아군 군령"): failures.append("enemy phase should explain disabled end turn button: %s | %s" % [scene.end_turn_button.text, scene.end_turn_button.tooltip_text]) scene.state.current_team = BattleState.TEAM_PLAYER scene.battle_started = false scene._update_threat_button() - if scene.threat_button.text != "적진도|전장도 미개봉" or not scene.threat_button.tooltip_text.contains("전장도"): + if scene.threat_button.text != "적정" or not scene.threat_button.tooltip_text.contains("전장도 미개봉") or not scene.threat_button.tooltip_text.contains("전장도"): failures.append("inactive battle should explain disabled threat button: %s | %s" % [scene.threat_button.text, scene.threat_button.tooltip_text]) _free_action_button_test_scene(scene)