Commit dbf019c8 authored by Gavin An's avatar Gavin An

유닛 격자에 고정되도록 변경

parent c675f866
......@@ -26,7 +26,7 @@ L-Defense 게임 개요
- 유닛 개념
* 유닛 생성시 마다 골드가 소모되며, 모두 무작위 확률로 생성
* 생성된 유닛은 스테이지의 격자에 각각 배치 가능. 고정되어 있으며 자동 공격함.
* 생성된 유닛은 스테이지의 격자(Tile)에 각각 배치 가능. 고정되어 있으며 자동 공격함.
* 체력 / 공격력 / 공격속도 / 이동속도
* 유닛의 등급은 일반 / 고급 / 희귀 / 전설 / 신화 / 유일 등급
* 일반 ~ 희귀까지는 고유 스킬이 없고 일반 공격만 가능(근거리 / 원거리 구분 존재)
......
......@@ -3,12 +3,15 @@ extends CharacterBody3D
const UnitHelper = preload("res://src/entities/units/unit_helper.gd")
const MELEE_HIT_CONFIRM_PADDING: float = 0.05
const INVALID_PLACEMENT_CELL: Vector3i = Vector3i(2147483647, 2147483647, 2147483647)
## 플레이어 유닛 기본 클래스 (RTS 식 이동, 몬스터 자동 감지 및 공격 수행)
@export var unit_data: UnitData
var is_selected: bool = false
var is_tile_bound: bool = false
var occupied_tile_cell: Vector3i = INVALID_PLACEMENT_CELL
# 전투 관련 상태
var _target_monster: Node3D = null
......@@ -160,6 +163,12 @@ func _process_attack_logic(_delta: float) -> void:
_try_attack()
else:
_is_currently_attacking = false
if is_tile_bound:
# 타일에 배치된 유닛은 자리를 이탈하지 않고, 사거리 밖 타겟만 놓습니다.
_target_monster = null
velocity.x = 0.0
velocity.z = 0.0
return
# 사거리 밖인 경우 추격 이동
_nav_agent.target_position = _target_monster.global_position
_navigate_to_target(_delta)
......@@ -167,7 +176,7 @@ func _process_attack_logic(_delta: float) -> void:
## 2. 일반 대기 및 이동 상태의 로직
func _process_normal_logic(_delta: float) -> void:
# 홀드 상태이거나 이동 명령이 끝난 경우 정지
if _is_holding or _nav_agent.is_navigation_finished():
if is_tile_bound or _is_holding or _nav_agent.is_navigation_finished():
velocity.x = 0.0
velocity.z = 0.0
else:
......@@ -213,8 +222,8 @@ func _look_at_target(target_pos: Vector3) -> void:
func _find_nearest_target() -> void:
var monsters: Array[Node] = get_tree().get_nodes_in_group("monsters")
var nearest_monster: Node3D = null
# 홀드 상태 시에는 자신의 무기 사거리 내만 탐색, 일반 상태 시에는 자동 탐색 범위(15m) 적용
var min_distance: float = unit_data.attack_range if _is_holding else detection_range
# 타일 배치/홀드 상태 시에는 자신의 무기 사거리 내만 탐색하여 제자리 방어 규칙을 유지합니다.
var min_distance: float = unit_data.attack_range if is_tile_bound or _is_holding else detection_range
for monster: Node in monsters:
if is_instance_valid(monster) and monster is Node3D:
......@@ -322,8 +331,28 @@ func hold_position() -> void:
_nav_agent.target_position = global_position
velocity = Vector3.ZERO
## 맵 타일 하나를 점유하며 해당 타일 중앙에 유닛을 고정 배치합니다.
## 배치된 유닛은 이동/추격 명령으로 자리를 벗어나지 않고, 사거리 안 적만 공격합니다.
func place_on_tile(tile_center: Vector3, tile_cell: Vector3i) -> void:
is_tile_bound = true
occupied_tile_cell = tile_cell
_is_holding = true
_is_moving = false
_target_monster = null
_is_currently_attacking = false
_attack_lock_timer = 0.0
_melee_move_lock_timer = 0.0
_has_queued_move_after_attack = false
_auto_target_cooldown = 0.0
global_position = tile_center
_nav_agent.target_position = tile_center
velocity = Vector3.ZERO
## 외부에서 호출하는 수동 이동 명령
func move_to(target_pos: Vector3) -> void:
if is_tile_bound:
return
# 근접 유닛은 타격 확정 전 이동 명령을 잠시 보관했다가, 판정 직후 바로 이동합니다.
# 원거리 유닛이나 근접 후딜 구간은 즉시 공격을 끊고 이동해 조작 반응성을 유지합니다.
if _is_melee_attack_locked():
......@@ -340,6 +369,9 @@ func move_to(target_pos: Vector3) -> void:
## 실제 수동 이동 상태 전환을 한곳에서 처리합니다.
## - 근접 공격의 후딜 캔슬과 원거리 카이팅 모두 이 경로를 공유합니다.
func _apply_move_command(target_pos: Vector3) -> void:
if is_tile_bound:
return
_is_moving = true
_is_holding = false # 수동 조작 시 홀드 해제
_target_monster = null # 이동 명령 시 강제 공격 해제
......
......@@ -5,6 +5,7 @@ extends Control
# 유닛 씬 및 데이터 프리로드
@export var base_unit_scene: PackedScene = preload("res://src/entities/units/base_unit.tscn")
const INVALID_TILE_CELL: Vector3i = Vector3i(2147483647, 2147483647, 2147483647)
# UI 노드 자동 캐싱
@onready var gold_label: Label = $TopBar/GoldLabel
......@@ -26,6 +27,13 @@ var _active_hero: BaseHero = null
const HERO_SPAWN_COST: int = 500
var _skill_cooldown_overlays: Dictionary = {} # {"Q": ColorRect, "W": ColorRect, ...}
var _skill_cooldown_labels: Dictionary = {} # {"Q": Label, "W": Label, ...}
var _pending_unit_scene: PackedScene = null
var _pending_unit_data: UnitData = null
var _pending_spawn_cost: int = 0
var _spawn_button_default_text: String = ""
var _placement_overlay_root: Node3D = null
var _placement_available_material: StandardMaterial3D = null
var _placement_occupied_material: StandardMaterial3D = null
......@@ -39,6 +47,7 @@ func _ready() -> void:
GameManager.game_victory.connect(_on_game_victory)
# UI 버튼 바인딩
_spawn_button_default_text = spawn_button.text
spawn_button.pressed.connect(_on_spawn_pressed)
restart_button.pressed.connect(_on_restart_pressed)
......@@ -95,6 +104,21 @@ func _ready() -> void:
bottom_bar.add_child(hero_spawn_button)
hero_spawn_button.pressed.connect(_on_summon_hero_pressed)
func _input(event: InputEvent) -> void:
if not _has_pending_unit_placement():
return
if event is InputEventMouseButton:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.pressed:
_try_place_pending_unit(mouse_event.position)
get_viewport().set_input_as_handled()
elif event is InputEventScreenTouch:
var touch_event: InputEventScreenTouch = event as InputEventScreenTouch
if touch_event.pressed:
_try_place_pending_unit(touch_event.position)
get_viewport().set_input_as_handled()
# 골드 UI 갱신 및 소환 한도 검증
func _on_gold_changed(gold: int) -> void:
gold_label.text = "GOLD: " + str(gold) + " G"
......@@ -123,50 +147,209 @@ func _on_game_victory() -> void:
# 유닛 무작위 소환 수행
func _on_spawn_pressed() -> void:
if GameManager.is_game_over:
if GameManager.is_game_over or _has_pending_unit_placement():
return
# 골드 비용 소모 시도
if GameManager.spend_gold(GameManager.unit_spawn_cost):
# 20% 동일 확률로 5종 유닛 중 무작위 1개 선정
var random_index: int = randi() % DataManager.unit_templates.size()
var chosen_template: UnitData = DataManager.unit_templates[random_index]
# 유닛 객체 스폰 (Striker, Guardian, Ranger 전용 씬 분기 분할)
var target_scene: PackedScene = base_unit_scene
if chosen_template.unit_id == "Unit_Striker":
var striker_scene: PackedScene = load("res://src/entities/units/striker/striker_unit.tscn") as PackedScene
if striker_scene:
target_scene = striker_scene
elif chosen_template.unit_id == "Unit_Guardian":
var guardian_scene: PackedScene = load("res://src/entities/units/guardian/guardian_unit.tscn") as PackedScene
if guardian_scene:
target_scene = guardian_scene
elif chosen_template.unit_id == "Unit_Ranger":
var ranger_scene: PackedScene = load("res://src/entities/units/ranger/ranger_unit.tscn") as PackedScene
if ranger_scene:
target_scene = ranger_scene
elif chosen_template.unit_id == "Unit_Mage":
var mage_scene: PackedScene = load("res://src/entities/units/mage/mage_unit.tscn") as PackedScene
if mage_scene:
target_scene = mage_scene
var new_unit: Node = target_scene.instantiate()
var parent_scene: Node = get_tree().current_scene
# 맵 중앙 스폰 존(0, 0) 기준 분산 스폰 좌표 지정
var spawn_offset: Vector3 = Vector3(
randf_range(-2.0, 2.0),
1,
randf_range(-2.0, 2.0)
)
if DataManager.unit_templates.is_empty():
return
if GameManager.player_gold < GameManager.unit_spawn_cost:
return
# 20% 동일 확률로 5종 유닛 중 무작위 1개 선정
var random_index: int = randi() % DataManager.unit_templates.size()
var chosen_template: UnitData = DataManager.unit_templates[random_index]
# 유닛 객체 스폰 (Striker, Guardian, Ranger 전용 씬 분기 분할)
var target_scene: PackedScene = base_unit_scene
if chosen_template.unit_id == "Unit_Striker":
var striker_scene: PackedScene = load("res://src/entities/units/striker/striker_unit.tscn") as PackedScene
if striker_scene:
target_scene = striker_scene
elif chosen_template.unit_id == "Unit_Guardian":
var guardian_scene: PackedScene = load("res://src/entities/units/guardian/guardian_unit.tscn") as PackedScene
if guardian_scene:
target_scene = guardian_scene
elif chosen_template.unit_id == "Unit_Ranger":
var ranger_scene: PackedScene = load("res://src/entities/units/ranger/ranger_unit.tscn") as PackedScene
if ranger_scene:
target_scene = ranger_scene
elif chosen_template.unit_id == "Unit_Mage":
var mage_scene: PackedScene = load("res://src/entities/units/mage/mage_unit.tscn") as PackedScene
if mage_scene:
target_scene = mage_scene
_begin_unit_placement(target_scene, chosen_template, GameManager.unit_spawn_cost)
func _has_pending_unit_placement() -> bool:
return _pending_unit_scene != null and _pending_unit_data != null
func _begin_unit_placement(unit_scene: PackedScene, unit_data: UnitData, spawn_cost: int) -> void:
_pending_unit_scene = unit_scene
_pending_unit_data = unit_data
_pending_spawn_cost = spawn_cost
spawn_button.text = "Select Tile"
_show_placement_tile_overlays()
func _clear_pending_unit_placement() -> void:
_pending_unit_scene = null
_pending_unit_data = null
_pending_spawn_cost = 0
spawn_button.text = _spawn_button_default_text
_clear_placement_tile_overlays()
func _try_place_pending_unit(screen_pos: Vector2) -> void:
var tile_info: Dictionary = _get_tile_info_from_screen(screen_pos)
if tile_info.is_empty():
return
new_unit.set("unit_data", chosen_template)
parent_scene.add_child(new_unit)
var tile_cell: Vector3i = tile_info["cell"]
if _is_tile_occupied(tile_cell):
return
# 씬 트리에 추가된 이후에 global_position을 주입해야 월드 좌표계 연산이 올바르게 반영됩니다.
if new_unit is Node3D:
(new_unit as Node3D).global_position = spawn_offset
if not GameManager.spend_gold(_pending_spawn_cost):
_clear_pending_unit_placement()
return
var new_unit: Node = _pending_unit_scene.instantiate()
var parent_scene: Node = get_tree().current_scene
var tile_center: Vector3 = tile_info["center"]
new_unit.set("unit_data", _pending_unit_data)
parent_scene.add_child(new_unit)
if new_unit is BaseUnit:
(new_unit as BaseUnit).place_on_tile(tile_center, tile_cell)
elif new_unit is Node3D:
(new_unit as Node3D).global_position = tile_center
_clear_pending_unit_placement()
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))
tile_center.y += 1.0
return {
"cell": tile_cell,
"center": tile_center
}
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
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)
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
func _is_tile_occupied(tile_cell: Vector3i) -> bool:
var units: Array[Node] = get_tree().get_nodes_in_group("units")
for unit_node: Node in units:
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 _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
_placement_overlay_root = Node3D.new()
_placement_overlay_root.name = "UnitPlacementTileOverlays"
parent_scene.add_child(_placement_overlay_root)
var used_cells: Array = grid_map.get_used_cells()
for cell: Vector3i in used_cells:
_add_placement_tile_overlay(grid_map, cell)
func _clear_placement_tile_overlays() -> void:
if _placement_overlay_root and is_instance_valid(_placement_overlay_root):
_placement_overlay_root.queue_free()
_placement_overlay_root = null
func _add_placement_tile_overlay(grid_map: GridMap, tile_cell: Vector3i) -> void:
if not _placement_overlay_root:
return
var overlay_mesh: PlaneMesh = PlaneMesh.new()
overlay_mesh.size = Vector2(grid_map.cell_size.x * 0.92, grid_map.cell_size.z * 0.92)
var overlay: MeshInstance3D = MeshInstance3D.new()
overlay.name = "OccupiedTileOverlay" if _is_tile_occupied(tile_cell) else "AvailableTileOverlay"
overlay.mesh = overlay_mesh
overlay.material_override = _get_placement_material(_is_tile_occupied(tile_cell))
overlay.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var tile_center: Vector3 = grid_map.to_global(grid_map.map_to_local(tile_cell))
tile_center.y += 1.03
overlay.global_position = tile_center
_placement_overlay_root.add_child(overlay)
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
# 상시 노출되는 게임 재시작 버튼 클릭 처리
......@@ -319,40 +502,33 @@ func _on_summoner_selected(data: SummonerData) -> void:
# 영웅 소환 버튼 클릭 처리
func _on_summon_hero_pressed() -> void:
if GameManager.is_game_over:
if GameManager.is_game_over or _has_pending_unit_placement():
return
# 골드 소모 검증
if GameManager.spend_gold(HERO_SPAWN_COST):
# 현재 영웅 템플릿 중 첫 번째(Dwarf Hero) 선정
if DataManager.hero_templates.is_empty():
return
var chosen_template: UnitData = DataManager.hero_templates[0]
var hero_scene: PackedScene = load("res://src/entities/units/hero/dwarf/dwarf_hero.tscn") as PackedScene
if not hero_scene:
return
var new_hero: BaseHero = hero_scene.instantiate() as BaseHero
var parent_scene: Node = get_tree().current_scene
# 스폰 좌표 설정 (맵 중앙 0, 0, 0 근처)
var spawn_pos: Vector3 = Vector3(
randf_range(-1.0, 1.0),
1.0,
randf_range(-1.0, 1.0)
)
new_hero.set("unit_data", chosen_template)
parent_scene.add_child(new_hero)
if not GameManager.spend_gold(HERO_SPAWN_COST):
return
# 월드 좌표 할당
new_hero.global_position = spawn_pos
_active_hero = new_hero
# 현재 영웅 템플릿 중 첫 번째(Dwarf Hero) 선정
if DataManager.hero_templates.is_empty():
GameManager.add_gold(HERO_SPAWN_COST)
return
var chosen_template: UnitData = DataManager.hero_templates[0]
var hero_scene: PackedScene = load("res://src/entities/units/hero/dwarf/dwarf_hero.tscn") as PackedScene
if not hero_scene:
GameManager.add_gold(HERO_SPAWN_COST)
return
# 영웅 좌측 상단 HUD 상태창 구성
_setup_hero_ui(new_hero)
var new_hero: BaseHero = hero_scene.instantiate() as BaseHero
var parent_scene: Node = get_tree().current_scene
new_hero.set("unit_data", chosen_template)
parent_scene.add_child(new_hero)
new_hero.global_position = Vector3(0.0, 1.0, 0.0)
_active_hero = new_hero
# 영웅은 타일 점유 배치가 아닌 맵 중앙 생성 규칙을 따릅니다.
_setup_hero_ui(new_hero)
# 영웅 전용 좌측 상단 HUD 구성
func _setup_hero_ui(hero: BaseHero) -> void:
......
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