Commit 24121fe1 authored by Gavin An's avatar Gavin An

유닛 선택 시, context 메뉴 뜨도록 하기

parent 9123dec3
...@@ -26,7 +26,8 @@ L-Defense 게임 개요 ...@@ -26,7 +26,8 @@ L-Defense 게임 개요
- 유닛 개념 - 유닛 개념
* 유닛 생성시 마다 골드가 소모되며, 모두 무작위 확률로 생성 * 유닛 생성시 마다 골드가 소모되며, 모두 무작위 확률로 생성
* 생성된 유닛은 스테이지의 격자(Tile)에 각각 배치 가능. 고정되어 있으며 자동 공격함. * 생성된 유닛은 스테이지의 격자(Tile)에 각각 배치 가능.
* 유닛을 선택하면, 이동 / 팔기 / 업그레이드(유닛 3개로 선택하면) 선택 가능
* 체력 / 공격력 / 공격속도 / 이동속도 * 체력 / 공격력 / 공격속도 / 이동속도
* 유닛의 등급은 일반 / 고급 / 희귀 / 전설 / 신화 / 유일 등급 * 유닛의 등급은 일반 / 고급 / 희귀 / 전설 / 신화 / 유일 등급
* 일반 ~ 희귀까지는 고유 스킬이 없고 일반 공격만 가능(근거리 / 원거리 구분 존재) * 일반 ~ 희귀까지는 고유 스킬이 없고 일반 공격만 가능(근거리 / 원거리 구분 존재)
......
...@@ -7,9 +7,7 @@ ...@@ -7,9 +7,7 @@
- **주력 언어:** GDScript - **주력 언어:** GDScript
- **주요 아키텍처:** 싱글톤(AutoLoad), 노드 기반 트리 구조, 신호(Signal) 시스템 활용 - **주요 아키텍처:** 싱글톤(AutoLoad), 노드 기반 트리 구조, 신호(Signal) 시스템 활용
- **핵심 폴더 구조:** - **핵심 폴더 구조:**
- `res://scenes/`: 게임 씬 (.tscn) 파일 - `res://assets/`: 스프라이트, 텍스쳐, 오디오, 폰트 등 리소스
- `res://scripts/`: GDScript (.gd) 파일
- `res://assets/`: 스프라이트, 오디오, 폰트 등 리소스
- `res://addons/`: 플러그인 - `res://addons/`: 플러그인
## 2. GDScript 코딩 스타일 및 규칙 ## 2. GDScript 코딩 스타일 및 규칙
......
...@@ -39,6 +39,7 @@ shadow_enabled = true ...@@ -39,6 +39,7 @@ shadow_enabled = true
shadow_blur = 5.0 shadow_blur = 5.0
directional_shadow_mode = 0 directional_shadow_mode = 0
directional_shadow_max_distance = 30.0 directional_shadow_max_distance = 30.0
directional_shadow_pancake_size = 15.0
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="." unique_id=161846999] [node name="NavigationRegion3D" type="NavigationRegion3D" parent="." unique_id=161846999]
navigation_mesh = SubResource("NavigationMesh_map") navigation_mesh = SubResource("NavigationMesh_map")
......
extends Control extends Control
## RTS 유닛 선택 및 이동을 통제하는 컨트롤러 (UnitController) ## RTS 유닛 선택 및 이동을 통제하는 컨트롤러 (UnitController)
## 화면 전체를 덮는 Control 노드로 작동하여 드래그 박스를 렌더링하고 마우스 입력을 처리합니다. ## 화면 전체를 덮는 Control 노드로 작동하여 마우스 입력과 선택 유닛 액션 버튼을 처리합니다.
# 마우스 및 드래그 상태 관리 const UNIT_SELECTION_SCREEN_RADIUS: float = 54.0
var _is_dragging: bool = false const UNIT_ACTION_BUTTON_SIZE: Vector2 = Vector2(86.0, 34.0)
var _drag_start: Vector2 = Vector2.ZERO const UNIT_ACTION_BUTTON_GAP: float = 8.0
var _drag_end: Vector2 = Vector2.ZERO const UNIT_ACTION_BUTTON_WORLD_HEIGHT: float = 1.55
const UNIT_SELL_REFUND_RATIO: float = 0.5
const UNIT_UPGRADE_COST: int = 75
# 현재 선택된 유닛 리스트 ## 현재 선택된 유닛 리스트입니다.
## 예전 다중 선택 API를 참조하는 코드가 있을 수 있어 배열 형태는 유지하지만, 실제로는 항상 0개 또는 1개만 담습니다.
var selected_units: Array[Node] = [] var selected_units: Array[Node] = []
# 화면 드래그 박스 렌더링 색상 var _selected_unit: Node3D = null
@export var box_color: Color = Color(0.0, 1.0, 0.0, 0.2) # 연녹색 반투명 var _is_waiting_for_move_destination: bool = false
@export var border_color: Color = Color(0.0, 1.0, 0.0, 0.8) # 녹색 테두리 var _action_button_container: HBoxContainer = null
@export var border_width: float = 1.5 var _sell_button: Button = null
var _move_button: Button = null
var _upgrade_button: Button = null
func _ready() -> void: func _ready() -> void:
# 화면 전체를 덮도록 설정 # 화면 전체를 덮도록 설정
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
mouse_filter = MOUSE_FILTER_IGNORE # UI가 아닌 unhandled input으로 동작하게 통과 처리 mouse_filter = MOUSE_FILTER_IGNORE # UI가 아닌 unhandled input으로 동작하게 통과 처리
_create_unit_action_buttons()
# 런타임에 NavigationRegion3D를 찾아 맵을 베이크시킴 (4.2 구현 보완) # 런타임에 NavigationRegion3D를 찾아 맵을 베이크시킴 (4.2 구현 보완)
var nav_region: NavigationRegion3D = get_node_or_null("../NavigationRegion3D") as NavigationRegion3D var nav_region: NavigationRegion3D = get_node_or_null("../NavigationRegion3D") as NavigationRegion3D
if nav_region: if nav_region:
nav_region.bake_navigation_mesh() nav_region.bake_navigation_mesh()
func _process(_delta: float) -> void:
_update_unit_action_buttons_position()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if GameManager.is_game_over: if GameManager.is_game_over:
return return
...@@ -35,21 +44,14 @@ func _unhandled_input(event: InputEvent) -> void: ...@@ -35,21 +44,14 @@ func _unhandled_input(event: InputEvent) -> void:
if not camera: if not camera:
return return
# 마우스 좌클릭 (선택/드래그 시작)
if event is InputEventMouseButton: if event is InputEventMouseButton:
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: if _is_waiting_for_move_destination:
if mouse_event.is_pressed(): _perform_selected_unit_move(mouse_event.position)
_is_dragging = true
_drag_start = mouse_event.position
_drag_end = _drag_start
else: else:
# 클릭 해제 시 선택 처리 _perform_selection(mouse_event.position)
_is_dragging = false
_perform_selection(_drag_start, mouse_event.position)
queue_redraw()
# 마우스 오른쪽 버튼 클릭 (이동 및 공격 지시) # 마우스 오른쪽 버튼 클릭 (이동 및 공격 지시)
elif mouse_event.button_index == MOUSE_BUTTON_RIGHT and mouse_event.is_pressed(): elif mouse_event.button_index == MOUSE_BUTTON_RIGHT and mouse_event.is_pressed():
...@@ -61,69 +63,78 @@ func _unhandled_input(event: InputEvent) -> void: ...@@ -61,69 +63,78 @@ func _unhandled_input(event: InputEvent) -> void:
if key_event.is_pressed() and not key_event.is_echo() and key_event.keycode == KEY_H: if key_event.is_pressed() and not key_event.is_echo() and key_event.keycode == KEY_H:
_perform_hold_position() _perform_hold_position()
# 마우스 드래그 중인 상태 ## 단일 유닛 선택을 수행합니다.
elif event is InputEventMouseMotion and _is_dragging: ## 드래그 선택과 Shift 누적 선택은 제거하고, 새 유닛을 선택할 때마다 기존 선택은 항상 해제합니다.
var motion_event: InputEventMouseMotion = event as InputEventMouseMotion func _perform_selection(mouse_pos: Vector2) -> void:
_drag_end = motion_event.position var unit_node: Node3D = _find_selectable_unit_from_mouse(mouse_pos)
queue_redraw() if unit_node:
_select_single_unit(unit_node)
func _draw() -> void: else:
# 드래그 영역 상자 그리기 _clear_selected_unit()
if _is_dragging and _drag_start.distance_to(_drag_end) > 5.0:
var rect: Rect2 = Rect2(_drag_start, _drag_end - _drag_start).abs() ## 마우스 위치 주변에서 선택 가능한 유닛을 찾습니다.
draw_rect(rect, box_color, true) ## 먼저 물리 Raycast로 직접 맞은 유닛을 찾고, 실패하면 화면상 가까운 유닛을 찾아 선택 반경을 넓게 느끼도록 합니다.
draw_rect(rect, border_color, false, border_width) func _find_selectable_unit_from_mouse(mouse_pos: Vector2) -> Node3D:
var ray_result: Dictionary = _raycast_from_mouse(mouse_pos)
## 다중 및 단일 선택 수행 if ray_result.has("collider"):
func _perform_selection(start: Vector2, end: Vector2) -> void: var hit_collider: Node = ray_result["collider"] as Node
var raycast_unit: Node = _get_unit_node(hit_collider)
if raycast_unit and raycast_unit.is_in_group("units") and raycast_unit is Node3D:
return raycast_unit as Node3D
return _find_nearest_unit_on_screen(mouse_pos)
## 화면 좌표 기준으로 마우스 근처의 가장 가까운 유닛을 반환합니다.
## 작은 모델이나 콜라이더가 빗나가도 선택이 되도록, 유닛 월드 위치를 카메라 화면 좌표로 투영해 거리 판정을 보강합니다.
func _find_nearest_unit_on_screen(mouse_pos: Vector2) -> Node3D:
var camera: Camera3D = get_viewport().get_camera_3d() var camera: Camera3D = get_viewport().get_camera_3d()
if not camera: if not camera:
return return null
# Shift 키 입력 체크 var nearest_unit: Node3D = null
var is_shift: bool = Input.is_key_pressed(KEY_SHIFT) var nearest_screen_distance: float = UNIT_SELECTION_SCREEN_RADIUS
var units: Array[Node] = get_tree().get_nodes_in_group("units")
for unit: Node in units:
if not is_instance_valid(unit) or not unit is Node3D:
continue
# Shift가 눌려있지 않은 경우에만 기존 선택 유닛들 초기화 var unit_3d: Node3D = unit as Node3D
if not is_shift: if camera.is_position_behind(unit_3d.global_position):
for unit: Node in selected_units: continue
if is_instance_valid(unit) and unit.has_method("deselect"):
unit.call("deselect")
selected_units.clear()
var drag_dist: float = start.distance_to(end) var screen_pos: Vector2 = camera.unproject_position(unit_3d.global_position)
var screen_distance: float = screen_pos.distance_to(mouse_pos)
if screen_distance <= nearest_screen_distance:
nearest_screen_distance = screen_distance
nearest_unit = unit_3d
return nearest_unit
## 선택 유닛을 하나로 고정합니다.
## 기존 선택 유닛이 있다면 먼저 deselect()를 호출해 선택 링과 상태를 확실히 정리합니다.
func _select_single_unit(unit_node: Node3D) -> void:
if _selected_unit == unit_node:
_show_unit_action_buttons()
return
# 1. 단일 선택 처리 (Raycast 이용) _clear_selected_unit()
if drag_dist <= 5.0: _selected_unit = unit_node
var ray_result: Dictionary = _raycast_from_mouse(start) selected_units.clear()
if ray_result.has("collider"):
var hit_collider: Node = ray_result["collider"] as Node
var unit_node: Node = _get_unit_node(hit_collider)
if unit_node and unit_node.is_in_group("units"):
# Shift 추가 선택 모드 시 이미 선택 리스트에 없는 경우에만 누적
if not selected_units.has(unit_node):
selected_units.append(unit_node) selected_units.append(unit_node)
_is_waiting_for_move_destination = false
if unit_node.has_method("select"): if unit_node.has_method("select"):
unit_node.call("select") unit_node.call("select")
_show_unit_action_buttons()
# 2. 드래그 다중 선택 처리 (Unproject 투영 이용) ## 현재 선택을 해제하고 유닛 액션 버튼도 숨깁니다.
else: func _clear_selected_unit() -> void:
var selection_rect: Rect2 = Rect2(start, end - start).abs() if _selected_unit and is_instance_valid(_selected_unit) and _selected_unit.has_method("deselect"):
var all_units: Array[Node] = get_tree().get_nodes_in_group("units") _selected_unit.call("deselect")
var newly_selected_count: int = 0 _selected_unit = null
selected_units.clear()
for unit: Node in all_units: _is_waiting_for_move_destination = false
if is_instance_valid(unit) and unit is Node3D: _hide_unit_action_buttons()
var unit_3d: Node3D = unit as Node3D
var screen_pos: Vector2 = camera.unproject_position(unit_3d.global_position)
# 화면 뷰포트 영역 내부 및 드래그 사각형 안에 포함되는지 판정
if selection_rect.has_point(screen_pos):
# 중복 선택 추가 방지
if not selected_units.has(unit):
selected_units.append(unit)
if unit.has_method("select"):
unit.call("select")
newly_selected_count += 1
## 우클릭 액션 (이동 목적지 지정 또는 적 공격 타겟팅) ## 우클릭 액션 (이동 목적지 지정 또는 적 공격 타겟팅)
func _perform_action(mouse_pos: Vector2) -> void: func _perform_action(mouse_pos: Vector2) -> void:
...@@ -142,7 +153,7 @@ func _perform_action(mouse_pos: Vector2) -> void: ...@@ -142,7 +153,7 @@ func _perform_action(mouse_pos: Vector2) -> void:
var monster_node: Node = _get_monster_node(hit_collider) var monster_node: Node = _get_monster_node(hit_collider)
if monster_node and monster_node.is_in_group("monsters"): if monster_node and monster_node.is_in_group("monsters"):
# 선택된 유닛에게 공격 타겟 명령 하달 # 선택된 유닛에게 공격 타겟 명령 하달
for unit: Node in selected_units: for unit: Node in selected_units:
if is_instance_valid(unit) and unit.has_method("attack_target"): if is_instance_valid(unit) and unit.has_method("attack_target"):
unit.call("attack_target", monster_node) unit.call("attack_target", monster_node)
...@@ -150,26 +161,9 @@ func _perform_action(mouse_pos: Vector2) -> void: ...@@ -150,26 +161,9 @@ func _perform_action(mouse_pos: Vector2) -> void:
# 공격 지시 클릭 표시 (빨간색) 스폰 # 공격 지시 클릭 표시 (빨간색) 스폰
_spawn_click_indicator(monster_node.global_position, true) _spawn_click_indicator(monster_node.global_position, true)
else: else:
# 바닥 또는 일반 지형인 경우 해당 좌표로 이동 명령 하달 var selected_unit: Node = selected_units[0]
# 다중 유닛 이동 시 겹침 방지를 위해 약간의 분산(Offset)을 주면 좋습니다. if is_instance_valid(selected_unit) and selected_unit.has_method("move_to"):
var unit_count: int = selected_units.size() selected_unit.call("move_to", hit_pos)
var spacing: float = 1.5
var rows: int = int(ceil(sqrt(unit_count)))
for i: int in range(unit_count):
var unit: Node = selected_units[i]
if is_instance_valid(unit) and unit.has_method("move_to"):
# 격자 정렬 오프셋 계산
var row: int = i / rows
var col: int = i % rows
var offset: Vector3 = Vector3(
(col - (rows - 1) / 2.0) * spacing,
0.0,
(row - (rows - 1) / 2.0) * spacing
)
var target_destination: Vector3 = hit_pos + offset
unit.call("move_to", target_destination)
# 지면 이동 클릭 표시 (초록색) 스폰 # 지면 이동 클릭 표시 (초록색) 스폰
_spawn_click_indicator(hit_pos, false) _spawn_click_indicator(hit_pos, false)
...@@ -255,3 +249,154 @@ func _perform_hold_position() -> void: ...@@ -255,3 +249,154 @@ func _perform_hold_position() -> void:
if is_instance_valid(unit) and unit.has_method("hold_position"): if is_instance_valid(unit) and unit.has_method("hold_position"):
unit.call("hold_position") unit.call("hold_position")
## 선택된 유닛 주변에 표시할 3개 액션 버튼을 생성합니다.
## 버튼은 UnitController 자식 Control로 두어 매 프레임 선택 유닛의 월드 위치를 화면 좌표로 투영해 따라다니게 합니다.
func _create_unit_action_buttons() -> void:
_action_button_container = HBoxContainer.new()
_action_button_container.name = "SelectedUnitActionButtons"
_action_button_container.visible = false
_action_button_container.mouse_filter = Control.MOUSE_FILTER_STOP
_action_button_container.add_theme_constant_override("separation", int(UNIT_ACTION_BUTTON_GAP))
add_child(_action_button_container)
_sell_button = _create_unit_action_button("SellUnitButton", "팔기", Color(0.48, 0.08, 0.08, 0.94), Color(1.0, 0.32, 0.22, 0.86))
_move_button = _create_unit_action_button("MoveUnitButton", "이동", Color(0.08, 0.22, 0.42, 0.94), Color(0.35, 0.72, 1.0, 0.86))
_upgrade_button = _create_unit_action_button("UpgradeUnitButton", "업그레이드", Color(0.18, 0.20, 0.08, 0.94), Color(1.0, 0.82, 0.24, 0.86))
_sell_button.pressed.connect(_on_sell_selected_unit_pressed)
_move_button.pressed.connect(_on_move_selected_unit_pressed)
_upgrade_button.pressed.connect(_on_upgrade_selected_unit_pressed)
## 유닛 액션 버튼 하나를 생성합니다.
## HUD 버튼처럼 보이되 월드 선택 메뉴로 읽히도록 크기를 작게 고정하고, 입력은 반드시 버튼이 먼저 소비하게 합니다.
func _create_unit_action_button(button_name: String, button_text: String, bg_color: Color, border_color: Color) -> Button:
var button: Button = Button.new()
button.name = button_name
button.text = button_text
button.custom_minimum_size = UNIT_ACTION_BUTTON_SIZE
button.mouse_filter = Control.MOUSE_FILTER_STOP
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 13)
button.add_theme_stylebox_override("normal", _create_action_button_style(bg_color, border_color))
button.add_theme_stylebox_override("hover", _create_action_button_style(bg_color.lightened(0.10), border_color.lightened(0.12)))
button.add_theme_stylebox_override("pressed", _create_action_button_style(bg_color.darkened(0.08), border_color))
_action_button_container.add_child(button)
return button
## 선택 액션 버튼의 공통 카드형 배경을 생성합니다.
## 둥근 정도는 과하지 않게 두어 기존 HUD와 어울리게 하고, 테두리 색으로 버튼 성격을 구분합니다.
func _create_action_button_style(bg_color: Color, border_color: Color) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = bg_color
style.border_color = border_color
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 8.0
style.content_margin_right = 8.0
style.content_margin_top = 4.0
style.content_margin_bottom = 4.0
return style
## 선택 유닛이 살아 있다면 액션 버튼을 표시합니다.
func _show_unit_action_buttons() -> void:
if _action_button_container:
_action_button_container.visible = _selected_unit != null and is_instance_valid(_selected_unit)
_update_unit_action_buttons_position()
## 선택이 없거나 유닛이 사라진 경우 액션 버튼을 숨깁니다.
func _hide_unit_action_buttons() -> void:
if _action_button_container:
_action_button_container.visible = false
## 선택 유닛의 머리 위 위치를 화면 좌표로 변환해 버튼 묶음을 따라다니게 합니다.
## 카메라 뒤로 사라지거나 유닛이 삭제되면 선택 상태도 정리해 잘못된 버튼 클릭을 막습니다.
func _update_unit_action_buttons_position() -> void:
if not _action_button_container or not _action_button_container.visible:
return
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return
var camera: Camera3D = get_viewport().get_camera_3d()
if not camera:
_hide_unit_action_buttons()
return
var anchor_world_position: Vector3 = _selected_unit.global_position + Vector3(0.0, UNIT_ACTION_BUTTON_WORLD_HEIGHT, 0.0)
if camera.is_position_behind(anchor_world_position):
_action_button_container.visible = false
return
var screen_position: Vector2 = camera.unproject_position(anchor_world_position)
var container_size: Vector2 = _action_button_container.get_combined_minimum_size()
_action_button_container.size = container_size
_action_button_container.position = screen_position - Vector2(container_size.x * 0.5, container_size.y + 10.0)
## 팔기 버튼 처리입니다.
## 아직 개별 유닛 구매가를 저장하지 않으므로, 현재 기본 소환 비용의 50%를 환불 기준으로 사용합니다.
func _on_sell_selected_unit_pressed() -> void:
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return
var refund_amount: int = int(float(GameManager.unit_spawn_cost) * UNIT_SELL_REFUND_RATIO)
if refund_amount > 0:
GameManager.add_gold(refund_amount)
var unit_to_sell: Node3D = _selected_unit
_clear_selected_unit()
unit_to_sell.queue_free()
## 이동 버튼 처리입니다.
## 버튼을 누른 뒤 다음 좌클릭 위치를 선택 유닛의 이동 목적지로 사용합니다.
func _on_move_selected_unit_pressed() -> void:
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return
_is_waiting_for_move_destination = true
## 이동 버튼 후속 좌클릭 처리입니다.
## 바닥을 찍으면 선택 유닛에 move_to()를 호출하고, 클릭 인디케이터를 표시한 뒤 이동 대기 상태를 해제합니다.
func _perform_selected_unit_move(mouse_pos: Vector2) -> void:
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return
var ray_result: Dictionary = _raycast_from_mouse(mouse_pos)
if ray_result.is_empty() or not ray_result.has("position"):
return
var hit_pos: Vector3 = ray_result["position"]
if _selected_unit.has_method("move_to"):
_selected_unit.call("move_to", hit_pos)
_spawn_click_indicator(hit_pos, false)
_is_waiting_for_move_destination = false
_show_unit_action_buttons()
## 업그레이드 버튼 처리입니다.
## UnitData가 템플릿 Resource로 공유될 수 있으므로 선택 유닛에만 복제본을 할당한 뒤 능력치를 올립니다.
func _on_upgrade_selected_unit_pressed() -> void:
if not _selected_unit or not is_instance_valid(_selected_unit):
_clear_selected_unit()
return
if not _selected_unit is BaseUnit:
return
var selected_base_unit: BaseUnit = _selected_unit as BaseUnit
if not selected_base_unit.unit_data:
return
if not GameManager.spend_gold(UNIT_UPGRADE_COST):
return
selected_base_unit.unit_data = selected_base_unit.unit_data.duplicate(true) as UnitData
selected_base_unit.unit_data.damage *= 1.18
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.move_speed *= 1.04
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