Commit d8eba5c2 authored by Gavin An's avatar Gavin An

작업중

parent 24121fe1
...@@ -3,6 +3,8 @@ extends Control ...@@ -3,6 +3,8 @@ extends Control
## RTS 유닛 선택 및 이동을 통제하는 컨트롤러 (UnitController) ## RTS 유닛 선택 및 이동을 통제하는 컨트롤러 (UnitController)
## 화면 전체를 덮는 Control 노드로 작동하여 마우스 입력과 선택 유닛 액션 버튼을 처리합니다. ## 화면 전체를 덮는 Control 노드로 작동하여 마우스 입력과 선택 유닛 액션 버튼을 처리합니다.
const PLACEMENT_TILE_OVERLAY_SCENE: PackedScene = preload("res://src/ui/unit_placement/placement_tile_overlay.tscn")
const INVALID_TILE_CELL: Vector3i = Vector3i(2147483647, 2147483647, 2147483647)
const UNIT_SELECTION_SCREEN_RADIUS: float = 54.0 const UNIT_SELECTION_SCREEN_RADIUS: float = 54.0
const UNIT_ACTION_BUTTON_SIZE: Vector2 = Vector2(86.0, 34.0) const UNIT_ACTION_BUTTON_SIZE: Vector2 = Vector2(86.0, 34.0)
const UNIT_ACTION_BUTTON_GAP: float = 8.0 const UNIT_ACTION_BUTTON_GAP: float = 8.0
...@@ -10,16 +12,29 @@ const UNIT_ACTION_BUTTON_WORLD_HEIGHT: float = 1.55 ...@@ -10,16 +12,29 @@ const UNIT_ACTION_BUTTON_WORLD_HEIGHT: float = 1.55
const UNIT_SELL_REFUND_RATIO: float = 0.5 const UNIT_SELL_REFUND_RATIO: float = 0.5
const UNIT_UPGRADE_COST: int = 75 const UNIT_UPGRADE_COST: int = 75
## MonsterPath와 가까운 타일을 "몬스터 길"로 판정할 때 추가로 더해 주는 여유 폭입니다.
## 실제 판정 거리는 `타일 반폭 + 타일 크기 * 이 비율`입니다.
## 예를 들어 GridMap 셀 크기가 2m이고 값이 0.15라면, 타일 중심이 Path3D에서 1.3m 이내일 때 길 타일로 취급합니다.
## Path3D가 타일 정중앙을 지나지 않거나 모델/그리드가 조금 어긋나도 몬스터 길에 유닛이 재배치되지 않게 막는 안전 마진입니다.
const MONSTER_PATH_TILE_EXTRA_MARGIN_RATIO: float = 0.15
## 현재 선택된 유닛 리스트입니다. ## 현재 선택된 유닛 리스트입니다.
## 예전 다중 선택 API를 참조하는 코드가 있을 수 있어 배열 형태는 유지하지만, 실제로는 항상 0개 또는 1개만 담습니다. ## 예전 다중 선택 API를 참조하는 코드가 있을 수 있어 배열 형태는 유지하지만, 실제로는 항상 0개 또는 1개만 담습니다.
var selected_units: Array[Node] = [] var selected_units: Array[Node] = []
var _selected_unit: Node3D = null var _selected_unit: Node3D = null
var _is_waiting_for_move_destination: bool = false var _is_waiting_for_tile_move_destination: bool = false
var _action_button_container: HBoxContainer = null var _action_button_container: HBoxContainer = null
var _sell_button: Button = null var _sell_button: Button = null
var _move_button: Button = null var _move_button: Button = null
var _upgrade_button: Button = null var _upgrade_button: Button = null
var _placement_available_material: StandardMaterial3D = null
var _placement_occupied_material: StandardMaterial3D = null
var _overlay_pool: Array[Node] = []
var _active_overlays: Array[Node] = []
var _overlay_pool_parent: Node = null
var _monster_path_tile_cache_grid_map: GridMap = null
var _monster_path_tile_cache: Dictionary = {}
func _ready() -> void: func _ready() -> void:
# 화면 전체를 덮도록 설정 # 화면 전체를 덮도록 설정
...@@ -48,8 +63,8 @@ func _unhandled_input(event: InputEvent) -> void: ...@@ -48,8 +63,8 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton var mouse_event: InputEventMouseButton = event as InputEventMouseButton
if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.is_pressed(): if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.is_pressed():
if _is_waiting_for_move_destination: if _is_waiting_for_tile_move_destination:
_perform_selected_unit_move(mouse_event.position) _try_move_selected_unit_to_tile(mouse_event.position)
else: else:
_perform_selection(mouse_event.position) _perform_selection(mouse_event.position)
...@@ -121,7 +136,7 @@ func _select_single_unit(unit_node: Node3D) -> void: ...@@ -121,7 +136,7 @@ func _select_single_unit(unit_node: Node3D) -> void:
_selected_unit = unit_node _selected_unit = unit_node
selected_units.clear() selected_units.clear()
selected_units.append(unit_node) selected_units.append(unit_node)
_is_waiting_for_move_destination = false _cancel_tile_move_mode()
if unit_node.has_method("select"): if unit_node.has_method("select"):
unit_node.call("select") unit_node.call("select")
...@@ -133,13 +148,15 @@ func _clear_selected_unit() -> void: ...@@ -133,13 +148,15 @@ func _clear_selected_unit() -> void:
_selected_unit.call("deselect") _selected_unit.call("deselect")
_selected_unit = null _selected_unit = null
selected_units.clear() selected_units.clear()
_is_waiting_for_move_destination = false _cancel_tile_move_mode()
_hide_unit_action_buttons() _hide_unit_action_buttons()
## 우클릭 액션 (이동 목적지 지정 또는 적 공격 타겟팅) ## 우클릭 액션 (이동 목적지 지정 또는 적 공격 타겟팅)
func _perform_action(mouse_pos: Vector2) -> void: func _perform_action(mouse_pos: Vector2) -> void:
if selected_units.is_empty(): if selected_units.is_empty():
return return
if _is_waiting_for_tile_move_destination:
return
# Raycast를 쏘아 바닥 또는 몬스터 감지 # Raycast를 쏘아 바닥 또는 몬스터 감지
var ray_result: Dictionary = _raycast_from_mouse(mouse_pos) var ray_result: Dictionary = _raycast_from_mouse(mouse_pos)
...@@ -354,29 +371,54 @@ func _on_sell_selected_unit_pressed() -> void: ...@@ -354,29 +371,54 @@ func _on_sell_selected_unit_pressed() -> void:
unit_to_sell.queue_free() unit_to_sell.queue_free()
## 이동 버튼 처리입니다. ## 이동 버튼 처리입니다.
## 버튼을 누른 뒤 다음 좌클릭 위치를 선택 유닛의 이동 목적지로 사용합니다. ## 버튼을 누르면 일반 바닥 클릭 이동이 아니라, 유닛 생성 배치와 같은 타일 선택 모드로 들어갑니다.
## 배치 가능한 타일에는 초록 오버레이가 뜨고, 이미 유닛이 있는 타일은 빨간 오버레이로 표시됩니다.
func _on_move_selected_unit_pressed() -> void: func _on_move_selected_unit_pressed() -> void:
if not _selected_unit or not is_instance_valid(_selected_unit): if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit() _clear_selected_unit()
return return
_is_waiting_for_move_destination = true _enter_tile_move_mode()
## 이동 버튼 후속 좌클릭 처리입니다. ## 선택 유닛의 타일 이동 모드에 진입합니다.
## 바닥을 찍으면 선택 유닛에 move_to()를 호출하고, 클릭 인디케이터를 표시한 뒤 이동 대기 상태를 해제합니다. ## 액션 버튼은 타일 선택을 가리지 않도록 잠시 숨기고, 선택 가능한 전체 타일 오버레이를 표시합니다.
func _perform_selected_unit_move(mouse_pos: Vector2) -> void: func _enter_tile_move_mode() -> void:
if not _selected_unit or not is_instance_valid(_selected_unit): if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit() _clear_selected_unit()
return return
var ray_result: Dictionary = _raycast_from_mouse(mouse_pos) _is_waiting_for_tile_move_destination = true
if ray_result.is_empty() or not ray_result.has("position"): _hide_unit_action_buttons()
_show_placement_tile_overlays()
## 타일 이동 모드를 종료하고 표시 중인 타일 오버레이를 풀로 반환합니다.
## 선택이 유지되는 경우에는 액션 버튼을 다시 띄울 수 있도록 별도 show 호출은 호출자가 결정합니다.
func _cancel_tile_move_mode() -> void:
_is_waiting_for_tile_move_destination = false
_clear_placement_tile_overlays()
## 타일 이동 모드에서 클릭한 화면 좌표를 GridMap 셀로 변환하고, 배치 가능하면 선택 유닛을 그 타일로 이동시킵니다.
## 유닛 생성 배치와 같은 판정을 써서 몬스터 길/점유 타일에는 이동 명령이 들어가지 않습니다.
func _try_move_selected_unit_to_tile(screen_pos: Vector2) -> void:
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return return
var hit_pos: Vector3 = ray_result["position"] var tile_info: Dictionary = _get_tile_info_from_screen(screen_pos)
if _selected_unit.has_method("move_to"): if tile_info.is_empty():
_selected_unit.call("move_to", hit_pos) return
_spawn_click_indicator(hit_pos, false)
_is_waiting_for_move_destination = false var tile_cell: Vector3i = tile_info["cell"]
if _is_tile_occupied(tile_cell, _selected_unit):
return
var tile_center: Vector3 = tile_info["center"]
if _selected_unit.has_method("move_to_tile"):
_selected_unit.call("move_to_tile", tile_center, tile_cell)
elif _selected_unit.has_method("move_to"):
_selected_unit.call("move_to", tile_center)
_spawn_click_indicator(tile_center, false)
_cancel_tile_move_mode()
_show_unit_action_buttons() _show_unit_action_buttons()
## 업그레이드 버튼 처리입니다. ## 업그레이드 버튼 처리입니다.
...@@ -399,4 +441,272 @@ func _on_upgrade_selected_unit_pressed() -> void: ...@@ -399,4 +441,272 @@ func _on_upgrade_selected_unit_pressed() -> void:
selected_base_unit.unit_data.attack_range *= 1.04 selected_base_unit.unit_data.attack_range *= 1.04
selected_base_unit.unit_data.cooldown = maxf(0.15, selected_base_unit.unit_data.cooldown * 0.94) selected_base_unit.unit_data.cooldown = maxf(0.15, selected_base_unit.unit_data.cooldown * 0.94)
selected_base_unit.unit_data.move_speed *= 1.04 selected_base_unit.unit_data.move_speed *= 1.04
## 화면 좌표에 대응하는 GridMap 셀과 타일 중심 좌표를 계산합니다.
## 유닛 생성 배치와 동일하게, 카메라 레이캐스트 후 충돌 지점 주변의 유효 GridMap 셀을 찾습니다.
func _get_tile_info_from_screen(screen_pos: Vector2) -> Dictionary:
var camera: Camera3D = get_viewport().get_camera_3d()
var grid_map: GridMap = _get_stage_grid_map()
if not camera or not grid_map:
return {}
var ray_origin: Vector3 = camera.project_ray_origin(screen_pos)
var ray_end: Vector3 = ray_origin + camera.project_ray_normal(screen_pos) * 1000.0
var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
var result: Dictionary = camera.get_world_3d().direct_space_state.intersect_ray(query)
if result.is_empty() or not result.has("position"):
return {}
var hit_position: Vector3 = result["position"]
var tile_cell: Vector3i = _get_valid_tile_cell(grid_map, hit_position)
if tile_cell == INVALID_TILE_CELL:
return {}
var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell))
return {
"cell": tile_cell,
"center": tile_center
}
## 레이 충돌 위치와 가장 가까운 유효 GridMap 셀을 반환합니다.
## 타일 메시 높이나 클릭 위치가 조금 어긋나도 같은 x/z 주변 y 셀을 확인해 타일 선택이 쉽게 실패하지 않게 합니다.
func _get_valid_tile_cell(grid_map: GridMap, world_position: Vector3) -> Vector3i:
var local_position: Vector3 = grid_map.to_local(world_position)
var tile_cell: Vector3i = grid_map.local_to_map(local_position)
if grid_map.get_cell_item(tile_cell) != -1:
return tile_cell
var ground_cell: Vector3i = Vector3i(tile_cell.x, 0, tile_cell.z)
if grid_map.get_cell_item(ground_cell) != -1:
return ground_cell
for y: int in range(tile_cell.y - 4, tile_cell.y + 5):
var nearby_cell: Vector3i = Vector3i(tile_cell.x, y, tile_cell.z)
if grid_map.get_cell_item(nearby_cell) != -1:
return nearby_cell
return INVALID_TILE_CELL
## 현재 전장에 동적으로 붙은 StageMap GridMap을 찾습니다.
## main.tscn에서는 StageMap이 NavigationRegion3D 아래에 붙으므로, 고정 경로 대신 재귀 탐색을 사용합니다.
func _get_stage_grid_map() -> GridMap:
var current_scene: Node = get_tree().current_scene
if not current_scene:
return null
return _find_grid_map(current_scene)
## 자식 노드를 재귀 순회하며 첫 번째 GridMap을 반환합니다.
func _find_grid_map(node: Node) -> GridMap:
if node is GridMap:
return node as GridMap
for child: Node in node.get_children():
var found_grid: GridMap = _find_grid_map(child)
if found_grid:
return found_grid
return null
## 해당 타일이 이동 목적지로 막혀 있는지 확인합니다.
## selected_unit은 현재 움직이려는 유닛이므로, 자기 자신이 점유한 기존 타일 때문에 재배치가 막히지 않도록 제외합니다.
func _is_tile_occupied(tile_cell: Vector3i, selected_unit: Node = null) -> bool:
var grid_map: GridMap = _get_stage_grid_map()
if grid_map and _is_monster_path_tile(grid_map, tile_cell):
return true
var units: Array[Node] = get_tree().get_nodes_in_group("units")
for unit_node: Node in units:
if unit_node == selected_unit:
continue
if unit_node is BaseUnit:
var unit: BaseUnit = unit_node as BaseUnit
if unit.is_tile_bound and unit.occupied_tile_cell == tile_cell:
return true
return false
## 몬스터 이동 경로로 판단되는 타일인지 확인합니다.
## 길 타일은 선택 오버레이에서 아예 제외하고, 실제 클릭 이동 목적지로도 허용하지 않습니다.
func _is_monster_path_tile(grid_map: GridMap, tile_cell: Vector3i) -> bool:
_ensure_monster_path_tile_cache(grid_map)
return _monster_path_tile_cache.has(tile_cell)
## 현재 GridMap 기준으로 몬스터 길 타일 캐시를 준비합니다.
## 전체 타일 오버레이를 만들 때 매 셀마다 Path3D 거리 계산을 반복하지 않도록 맵 단위로 한 번만 계산합니다.
func _ensure_monster_path_tile_cache(grid_map: GridMap) -> void:
if _monster_path_tile_cache_grid_map == grid_map:
return
_monster_path_tile_cache_grid_map = grid_map
_monster_path_tile_cache.clear()
var used_cells: Array = grid_map.get_used_cells()
if used_cells.is_empty():
return
var cell_bounds: Dictionary = _get_used_cell_xz_bounds(used_cells)
var monster_path: Path3D = _get_monster_path()
for cell: Vector3i in used_cells:
if _is_outer_edge_cell(cell, cell_bounds) or _is_cell_close_to_monster_path(grid_map, monster_path, cell):
_monster_path_tile_cache[cell] = true
## GridMap 사용 셀의 x/z 최소/최대 범위를 계산합니다.
## 현재 맵은 몬스터 길이 외곽을 따라 돌기 때문에 외곽 셀을 빠르게 차단하는 데 사용합니다.
func _get_used_cell_xz_bounds(used_cells: Array) -> Dictionary:
var first_cell: Vector3i = used_cells[0] as Vector3i
var min_x: int = first_cell.x
var max_x: int = first_cell.x
var min_z: int = first_cell.z
var max_z: int = first_cell.z
for cell: Vector3i in used_cells:
min_x = mini(min_x, cell.x)
max_x = maxi(max_x, cell.x)
min_z = mini(min_z, cell.z)
max_z = maxi(max_z, cell.z)
return {
"min_x": min_x,
"max_x": max_x,
"min_z": min_z,
"max_z": max_z
}
## 사용 셀 범위의 가장자리 타일인지 확인합니다.
func _is_outer_edge_cell(tile_cell: Vector3i, cell_bounds: Dictionary) -> bool:
return (
tile_cell.x == cell_bounds["min_x"]
or tile_cell.x == cell_bounds["max_x"]
or tile_cell.z == cell_bounds["min_z"]
or tile_cell.z == cell_bounds["max_z"]
)
## 타일 중심이 MonsterPath에 가까우면 몬스터 길로 간주합니다.
## 외곽 길이 아닌 스테이지가 추가되어도 Path3D를 따라 재배치 금지 영역이 자동으로 생기도록 보강합니다.
func _is_cell_close_to_monster_path(grid_map: GridMap, monster_path: Path3D, tile_cell: Vector3i) -> bool:
if not monster_path or not monster_path.curve:
return false
var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell))
var local_tile_center: Vector3 = monster_path.to_local(tile_center)
var closest_local_point: Vector3 = monster_path.curve.get_closest_point(local_tile_center)
var closest_world_point: Vector3 = monster_path.to_global(closest_local_point)
# 재배치 판정은 바닥 평면 기준이므로 y 높이 차이는 무시합니다.
# half_tile_width는 타일 중심에서 타일 가장자리까지의 거리이고,
# path_margin은 MonsterPath가 타일 중앙선에서 조금 벗어난 경우까지 길 타일로 묶기 위한 추가 여유입니다.
tile_center.y = 0.0
closest_world_point.y = 0.0
var half_tile_width: float = maxf(grid_map.cell_size.x, grid_map.cell_size.z) * 0.5
var path_margin: float = maxf(grid_map.cell_size.x, grid_map.cell_size.z) * MONSTER_PATH_TILE_EXTRA_MARGIN_RATIO
return tile_center.distance_to(closest_world_point) <= half_tile_width + path_margin
## 현재 전장 씬의 MonsterPath를 찾습니다.
func _get_monster_path() -> Path3D:
var current_scene: Node = get_tree().current_scene
if not current_scene:
return null
var path_node: Node = current_scene.find_child("MonsterPath", true, false)
if path_node is Path3D:
return path_node as Path3D
return null
## 선택 유닛 재배치 모드에 필요한 타일 오버레이를 표시합니다.
## 유닛 생성 배치 화면처럼 가능한 타일과 이미 점유된 타일을 구분하고, 몬스터 길 타일은 투명하게 남깁니다.
func _show_placement_tile_overlays() -> void:
_clear_placement_tile_overlays()
var grid_map: GridMap = _get_stage_grid_map()
var parent_scene: Node = get_tree().current_scene
if not grid_map or not parent_scene:
return
var used_cells: Array = grid_map.get_used_cells()
_ensure_monster_path_tile_cache(grid_map)
_ensure_overlay_pool_capacity(parent_scene, used_cells.size())
for cell: Vector3i in used_cells:
_add_placement_tile_overlay(grid_map, cell)
## 표시 중인 타일 오버레이를 삭제하지 않고 풀로 반환합니다.
func _clear_placement_tile_overlays() -> void:
for overlay: Node in _active_overlays:
if is_instance_valid(overlay):
if overlay.has_method("release_to_pool"):
overlay.call("release_to_pool")
_overlay_pool.append(overlay)
_active_overlays.clear()
## 타일 하나에 대응하는 오버레이 씬을 풀에서 꺼내 위치/크기/재질을 설정합니다.
func _add_placement_tile_overlay(grid_map: GridMap, tile_cell: Vector3i) -> void:
if _is_monster_path_tile(grid_map, tile_cell):
return
var is_occupied: bool = _is_tile_occupied(tile_cell, _selected_unit)
var overlay: Node = _take_overlay_from_pool()
if not overlay:
return
var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell))
var tile_size: Vector2 = Vector2(grid_map.cell_size.x, grid_map.cell_size.z)
if overlay.has_method("configure"):
overlay.call("configure", tile_center, tile_size, _get_placement_material(is_occupied), is_occupied)
_active_overlays.append(overlay)
## 오버레이 풀이 필요한 수량만큼 준비되어 있는지 확인합니다.
func _ensure_overlay_pool_capacity(parent_scene: Node, required_count: int) -> void:
_reset_overlay_pool_if_parent_changed(parent_scene)
var total_count: int = _overlay_pool.size() + _active_overlays.size()
var missing_count: int = maxi(0, required_count - total_count)
for index: int in range(missing_count):
_add_overlay_instance_to_pool(parent_scene)
## placement_tile_overlay.tscn 인스턴스 하나를 생성해 현재 씬에 붙이고 비활성 풀에 보관합니다.
func _add_overlay_instance_to_pool(parent_scene: Node) -> void:
var overlay: Node = PLACEMENT_TILE_OVERLAY_SCENE.instantiate()
if not overlay:
return
parent_scene.add_child(overlay)
if overlay.has_method("release_to_pool"):
overlay.call("release_to_pool")
_overlay_pool.append(overlay)
## 현재 씬이 바뀌면 이전 씬에 붙어 있던 풀 참조와 길 타일 캐시를 버립니다.
func _reset_overlay_pool_if_parent_changed(parent_scene: Node) -> void:
if _overlay_pool_parent == parent_scene:
return
_overlay_pool.clear()
_active_overlays.clear()
_monster_path_tile_cache_grid_map = null
_monster_path_tile_cache.clear()
_overlay_pool_parent = parent_scene
## 비활성 오버레이 하나를 풀에서 꺼냅니다.
func _take_overlay_from_pool() -> Node:
if _overlay_pool.is_empty():
return null
return _overlay_pool.pop_back()
## 배치 가능/점유 상태에 맞는 오버레이 머티리얼을 반환합니다.
## 현재 오버레이 씬은 내부에서 파츠별 색을 처리하지만, 기존 configure() 시그니처 호환을 위해 머티리얼을 전달합니다.
func _get_placement_material(is_occupied: bool) -> StandardMaterial3D:
if is_occupied:
if not _placement_occupied_material:
_placement_occupied_material = _create_placement_material(Color(1.0, 0.05, 0.02, 0.38))
return _placement_occupied_material
if not _placement_available_material:
_placement_available_material = _create_placement_material(Color(0.05, 1.0, 0.2, 0.32))
return _placement_available_material
## 타일 오버레이 호환용 반투명 머티리얼을 생성합니다.
func _create_placement_material(color: Color) -> StandardMaterial3D:
var material: StandardMaterial3D = StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = color
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.no_depth_test = true
return material
...@@ -12,6 +12,9 @@ const INVALID_PLACEMENT_CELL: Vector3i = Vector3i(2147483647, 2147483647, 214748 ...@@ -12,6 +12,9 @@ const INVALID_PLACEMENT_CELL: Vector3i = Vector3i(2147483647, 2147483647, 214748
var is_selected: bool = false var is_selected: bool = false
var is_tile_bound: bool = false var is_tile_bound: bool = false
var occupied_tile_cell: Vector3i = INVALID_PLACEMENT_CELL var occupied_tile_cell: Vector3i = INVALID_PLACEMENT_CELL
var _has_pending_tile_rebind: bool = false
var _pending_tile_center: Vector3 = Vector3.ZERO
var _pending_tile_cell: Vector3i = INVALID_PLACEMENT_CELL
# 전투 관련 상태 # 전투 관련 상태
var _target_monster: Node3D = null var _target_monster: Node3D = null
...@@ -95,6 +98,8 @@ func _physics_process(delta: float) -> void: ...@@ -95,6 +98,8 @@ func _physics_process(delta: float) -> void:
if _is_moving and _has_reached_manual_move_target(): if _is_moving and _has_reached_manual_move_target():
_is_moving = false _is_moving = false
if _has_pending_tile_rebind:
_complete_pending_tile_rebind()
# 2. 적 자동 탐색 (타겟이 없고 수동 강제 이동 방지 쿨다운이 완료, 공격 쿨다운이 끝난 경우) # 2. 적 자동 탐색 (타겟이 없고 수동 강제 이동 방지 쿨다운이 완료, 공격 쿨다운이 끝난 경우)
if not _is_moving and not _target_monster and _auto_target_cooldown <= 0.0 and _attack_lock_timer <= 0.0: if not _is_moving and not _target_monster and _auto_target_cooldown <= 0.0 and _attack_lock_timer <= 0.0:
...@@ -336,6 +341,9 @@ func hold_position() -> void: ...@@ -336,6 +341,9 @@ func hold_position() -> void:
func place_on_tile(tile_center: Vector3, tile_cell: Vector3i) -> void: func place_on_tile(tile_center: Vector3, tile_cell: Vector3i) -> void:
is_tile_bound = true is_tile_bound = true
occupied_tile_cell = tile_cell occupied_tile_cell = tile_cell
_has_pending_tile_rebind = false
_pending_tile_center = Vector3.ZERO
_pending_tile_cell = INVALID_PLACEMENT_CELL
_is_holding = true _is_holding = true
_is_moving = false _is_moving = false
_target_monster = null _target_monster = null
...@@ -348,6 +356,17 @@ func place_on_tile(tile_center: Vector3, tile_cell: Vector3i) -> void: ...@@ -348,6 +356,17 @@ func place_on_tile(tile_center: Vector3, tile_cell: Vector3i) -> void:
_nav_agent.target_position = tile_center _nav_agent.target_position = tile_center
velocity = Vector3.ZERO velocity = Vector3.ZERO
## 타일에 고정된 유닛을 다른 배치 가능 타일로 이동시킵니다.
## 일반 move_to()는 타일 고정 유닛을 움직이지 않게 막으므로, 유닛 재배치 버튼 전용 경로를 따로 둡니다.
## 이동 중에는 현재 타일 점유를 해제하고, 목적지에 도착하면 place_on_tile()을 다시 호출해 새 타일에 고정합니다.
func move_to_tile(tile_center: Vector3, tile_cell: Vector3i) -> void:
_has_pending_tile_rebind = true
_pending_tile_center = tile_center
_pending_tile_cell = tile_cell
is_tile_bound = false
occupied_tile_cell = INVALID_PLACEMENT_CELL
_apply_move_command(tile_center)
## 외부에서 호출하는 수동 이동 명령 ## 외부에서 호출하는 수동 이동 명령
func move_to(target_pos: Vector3) -> void: func move_to(target_pos: Vector3) -> void:
if is_tile_bound: if is_tile_bound:
...@@ -382,6 +401,15 @@ func _apply_move_command(target_pos: Vector3) -> void: ...@@ -382,6 +401,15 @@ func _apply_move_command(target_pos: Vector3) -> void:
_auto_target_cooldown = 0.5 # 수동 강제 이동 시 0.5초 동안 자동 공격 타겟 지정을 비활성화하여 피신을 허용 _auto_target_cooldown = 0.5 # 수동 강제 이동 시 0.5초 동안 자동 공격 타겟 지정을 비활성화하여 피신을 허용
_nav_agent.target_position = target_pos _nav_agent.target_position = target_pos
## move_to_tile()로 시작한 재배치 이동이 끝났을 때 새 타일에 유닛을 고정합니다.
## NavigationAgent 도착 판정은 약간의 허용 거리를 갖기 때문에, 마지막에는 타일 중심으로 스냅해 배치 그리드와 정확히 맞춥니다.
func _complete_pending_tile_rebind() -> void:
if _pending_tile_cell == INVALID_PLACEMENT_CELL:
_has_pending_tile_rebind = false
return
place_on_tile(_pending_tile_center, _pending_tile_cell)
## 근접 공격의 타격 확정 전 구간인지 확인합니다. ## 근접 공격의 타격 확정 전 구간인지 확인합니다.
## - 원거리 유닛은 공격 모션 타이머가 있어도 이동 가능해야 하므로 false를 반환합니다. ## - 원거리 유닛은 공격 모션 타이머가 있어도 이동 가능해야 하므로 false를 반환합니다.
func _is_melee_attack_locked() -> bool: func _is_melee_attack_locked() -> bool:
......
...@@ -12,6 +12,11 @@ const MAGE_SCENE: PackedScene = preload("res://src/entities/units/mage/mage_unit ...@@ -12,6 +12,11 @@ const MAGE_SCENE: PackedScene = preload("res://src/entities/units/mage/mage_unit
const WARRIOR_LV1_SCENE: PackedScene = preload("res://src/entities/units/warrior/lv1/warrior_unit.tscn") const WARRIOR_LV1_SCENE: PackedScene = preload("res://src/entities/units/warrior/lv1/warrior_unit.tscn")
const PLACEMENT_TILE_OVERLAY_SCENE: PackedScene = preload("res://src/ui/unit_placement/placement_tile_overlay.tscn") const PLACEMENT_TILE_OVERLAY_SCENE: PackedScene = preload("res://src/ui/unit_placement/placement_tile_overlay.tscn")
const OVERLAY_POOL_WARMUP_BATCH_SIZE: int = 16 const OVERLAY_POOL_WARMUP_BATCH_SIZE: int = 16
## MonsterPath와 가까운 타일을 "몬스터 길"로 판정할 때 추가로 더해 주는 여유 폭입니다.
## 실제 판정 거리는 `타일 반폭 + 타일 크기 * 이 비율`입니다.
## 예를 들어 GridMap 셀 크기가 2m이고 값이 0.15라면, 타일 중심이 Path3D에서 1.3m 이내일 때 길 타일로 취급합니다.
## Path3D가 타일 정중앙을 지나지 않거나 모델/그리드가 조금 어긋나도 몬스터 길에 유닛이 배치되지 않게 막는 안전 마진입니다.
const MONSTER_PATH_TILE_EXTRA_MARGIN_RATIO: float = 0.15 const MONSTER_PATH_TILE_EXTRA_MARGIN_RATIO: float = 0.15
var _hud: Control = null var _hud: Control = null
...@@ -276,7 +281,6 @@ func _get_tile_info_from_screen(screen_pos: Vector2) -> Dictionary: ...@@ -276,7 +281,6 @@ func _get_tile_info_from_screen(screen_pos: Vector2) -> Dictionary:
return {} return {}
var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell)) var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell))
tile_center.y += 1.0
return { return {
"cell": tile_cell, "cell": tile_cell,
"center": tile_center "center": tile_center
...@@ -406,7 +410,8 @@ func _is_cell_close_to_monster_path(grid_map: GridMap, monster_path: Path3D, til ...@@ -406,7 +410,8 @@ func _is_cell_close_to_monster_path(grid_map: GridMap, monster_path: Path3D, til
var closest_world_point: Vector3 = monster_path.to_global(closest_local_point) var closest_world_point: Vector3 = monster_path.to_global(closest_local_point)
# 배치 판정은 바닥 평면 기준이므로 y 높이 차이는 무시합니다. # 배치 판정은 바닥 평면 기준이므로 y 높이 차이는 무시합니다.
# half_tile_width에 작은 여유를 더해 경로가 타일 중앙에서 조금 벗어나 있어도 해당 길 타일이 잠기도록 합니다. # half_tile_width는 타일 중심에서 타일 가장자리까지의 거리이고,
# path_margin은 MonsterPath가 타일 중앙선에서 조금 벗어난 경우까지 길 타일로 묶기 위한 추가 여유입니다.
tile_center.y = 0.0 tile_center.y = 0.0
closest_world_point.y = 0.0 closest_world_point.y = 0.0
var half_tile_width: float = maxf(grid_map.cell_size.x, grid_map.cell_size.z) * 0.5 var half_tile_width: float = maxf(grid_map.cell_size.x, grid_map.cell_size.z) * 0.5
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment