Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
L
ldefense
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Packages
Packages
Container Registry
Analytics
CI / CD Analytics
Repository Analytics
Value Stream Analytics
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Gavin An
ldefense
Commits
dbf019c8
Commit
dbf019c8
authored
Jul 16, 2026
by
Gavin An
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
유닛 격자에 고정되도록 변경
parent
c675f866
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
281 additions
and
73 deletions
+281
-73
docs/README.md
docs/README.md
+1
-1
src/entities/units/base_unit.gd
src/entities/units/base_unit.gd
+35
-3
src/ui/hud.gd
src/ui/hud.gd
+245
-69
No files found.
docs/README.md
View file @
dbf019c8
...
...
@@ -26,7 +26,7 @@ L-Defense 게임 개요
-
유닛 개념
*
유닛 생성시 마다 골드가 소모되며, 모두 무작위 확률로 생성
*
생성된 유닛은 스테이지의 격자에 각각 배치 가능. 고정되어 있으며 자동 공격함.
*
생성된 유닛은 스테이지의 격자
(Tile)
에 각각 배치 가능. 고정되어 있으며 자동 공격함.
*
체력 / 공격력 / 공격속도 / 이동속도
*
유닛의 등급은 일반 / 고급 / 희귀 / 전설 / 신화 / 유일 등급
*
일반 ~ 희귀까지는 고유 스킬이 없고 일반 공격만 가능(근거리 / 원거리 구분 존재)
...
...
src/entities/units/base_unit.gd
View file @
dbf019c8
...
...
@@ -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
# 이동 명령 시 강제 공격 해제
...
...
src/ui/hud.gd
View file @
dbf019c8
...
...
@@ -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,11 +147,15 @@ 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
DataManager
.
unit_templates
.
is_empty
():
return
if
GameManager
.
player_gold
<
GameManager
.
unit_spawn_cost
:
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
]
...
...
@@ -151,22 +179,177 @@ func _on_spawn_pressed() -> void:
if
mage_scene
:
target_scene
=
mage_scene
var
new_unit
:
Node
=
target_scene
.
instantiate
()
var
parent_scene
:
Node
=
get_tree
()
.
current_scene
_begin_unit_placement
(
target_scene
,
chosen_template
,
GameManager
.
unit_spawn_cost
)
# 맵 중앙 스폰 존(0, 0) 기준 분산 스폰 좌표 지정
var
spawn_offset
:
Vector3
=
Vector3
(
randf_range
(
-
2.0
,
2.0
),
1
,
randf_range
(
-
2.0
,
2.0
)
)
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
()
new_unit
.
set
(
"unit_data"
,
chosen_template
)
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
var
tile_cell
:
Vector3i
=
tile_info
[
"cell"
]
if
_is_tile_occupied
(
tile_cell
):
return
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
)
# 씬 트리에 추가된 이후에 global_position을 주입해야 월드 좌표계 연산이 올바르게 반영됩니다.
if
new_unit
is
Node3D
:
(
new_unit
as
Node3D
)
.
global_position
=
spawn_offset
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,39 +502,32 @@ 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
not
GameManager
.
spend_gold
(
HERO_SPAWN_COST
):
return
# 골드 소모 검증
if
GameManager
.
spend_gold
(
HERO_SPAWN_COST
):
# 현재 영웅 템플릿 중 첫 번째(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
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
)
# 월드 좌표 할당
new_hero
.
global_position
=
spawn_pos
new_hero
.
global_position
=
Vector3
(
0.0
,
1.0
,
0.0
)
_active_hero
=
new_hero
# 영웅 좌측 상단 HUD 상태창 구성
# 영웅은 타일 점유 배치가 아닌 맵 중앙 생성 규칙을 따릅니다.
_setup_hero_ui
(
new_hero
)
# 영웅 전용 좌측 상단 HUD 구성
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment