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
45c18ca1
Commit
45c18ca1
authored
Jul 20, 2026
by
Gavin An
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
유닛 생성 시 미리보기 추가
parent
46fd46de
Changes
19
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1558 additions
and
692 deletions
+1558
-692
src/core/main.gd
src/core/main.gd
+90
-0
src/core/projectile_pool.gd
src/core/projectile_pool.gd
+157
-0
src/core/projectile_pool.gd.uid
src/core/projectile_pool.gd.uid
+1
-0
src/entities/summoner/summoner.gd
src/entities/summoner/summoner.gd
+28
-16
src/entities/units/base/base_unit.gd
src/entities/units/base/base_unit.gd
+10
-6
src/entities/units/base/projectile.gd
src/entities/units/base/projectile.gd
+93
-5
src/entities/units/mage/mage_unit.gd
src/entities/units/mage/mage_unit.gd
+16
-0
src/entities/units/ranger/ranger_unit.gd
src/entities/units/ranger/ranger_unit.gd
+16
-0
src/map/background/desert.tscn
src/map/background/desert.tscn
+1
-1
src/ui/hero_panel/hud_hero_panel.gd
src/ui/hero_panel/hud_hero_panel.gd
+328
-0
src/ui/hero_panel/hud_hero_panel.gd.uid
src/ui/hero_panel/hud_hero_panel.gd.uid
+1
-0
src/ui/hud.gd
src/ui/hud.gd
+115
-664
src/ui/summoner_panel/hud_summoner_panel.gd
src/ui/summoner_panel/hud_summoner_panel.gd
+161
-0
src/ui/summoner_panel/hud_summoner_panel.gd.uid
src/ui/summoner_panel/hud_summoner_panel.gd.uid
+1
-0
src/ui/unit_placement/hud_unit_placement.gd
src/ui/unit_placement/hud_unit_placement.gd
+490
-0
src/ui/unit_placement/hud_unit_placement.gd.uid
src/ui/unit_placement/hud_unit_placement.gd.uid
+1
-0
src/ui/unit_placement/placement_tile_overlay.gd
src/ui/unit_placement/placement_tile_overlay.gd
+36
-0
src/ui/unit_placement/placement_tile_overlay.gd.uid
src/ui/unit_placement/placement_tile_overlay.gd.uid
+1
-0
src/ui/unit_placement/placement_tile_overlay.tscn
src/ui/unit_placement/placement_tile_overlay.tscn
+12
-0
No files found.
src/core/main.gd
View file @
45c18ca1
extends
Node3D
extends
Node3D
const
ProjectilePoolScript
:
GDScript
=
preload
(
"res://src/core/projectile_pool.gd"
)
## 메인 전장 월드 씬 루트 스크립트 (Main)
## 메인 전장 월드 씬 루트 스크립트 (Main)
## 스테이지 선택에 따른 맵 데이터를 동적으로 읽어 전장에 탑재합니다.
## 스테이지 선택에 따른 맵 데이터를 동적으로 읽어 전장에 탑재합니다.
## 1스테이지에서 사용할 기본 태양광 밝기입니다.
## 값이 클수록 DirectionalLight3D가 전장을 더 강하게 비춥니다.
@
export
var
stage_1_light_energy
:
float
=
1.2
## 마지막 스테이지에서 사용할 태양광 밝기입니다.
## 스테이지가 진행될수록 이 값에 가까워져 후반부 분위기가 어두워집니다.
@
export
var
final_stage_light_energy
:
float
=
0.35
## 스테이지 변경 시 조명 밝기가 목표값까지 전환되는 시간입니다.
## 0 이하로 설정하면 보간 없이 즉시 밝기가 바뀝니다.
@
export
var
stage_light_transition_time
:
float
=
0.6
## main.tscn 루트에 배치된 주 광원입니다.
## 스테이지별 분위기 연출을 위해 light_energy만 런타임에 조정합니다.
@
onready
var
_directional_light
:
DirectionalLight3D
=
$
DirectionalLight3D
## 스테이지가 빠르게 바뀌는 경우 이전 밝기 전환을 중단하기 위한 Tween 참조입니다.
var
_light_tween
:
Tween
## 런타임에 생성되는 발사체 풀입니다.
## main.tscn 원본을 직접 수정하지 않고 코드에서 노드를 붙여 프로젝트 지침을 지킵니다.
var
_projectile_pool
:
Node
=
null
func
_enter_tree
()
->
void
:
func
_enter_tree
()
->
void
:
var
map_path
:
String
=
"res://src/map/stages/stage_1_map.tscn"
# 기본 맵
var
map_path
:
String
=
"res://src/map/stages/stage_1_map.tscn"
# 기본 맵
if
GameManager
.
selected_stage_map_path
!=
""
:
if
GameManager
.
selected_stage_map_path
!=
""
:
...
@@ -11,6 +36,21 @@ func _enter_tree() -> void:
...
@@ -11,6 +36,21 @@ func _enter_tree() -> void:
_load_stage_background
(
map_path
)
_load_stage_background
(
map_path
)
_load_stage_map
(
map_path
)
_load_stage_map
(
map_path
)
func
_ready
()
->
void
:
_setup_projectile_pool
()
# GameManager의 current_stage setter가 stage_changed를 발행하므로,
# Main은 이 신호만 구독해 스테이지 진행과 조명 연출을 느슨하게 연결합니다.
GameManager
.
stage_changed
.
connect
(
_on_stage_changed
)
# 이미 선택된 현재 스테이지 밝기를 씬 시작 시 즉시 반영합니다.
# 초기 밝기는 전환 애니메이션 없이 맞춰야 첫 프레임부터 의도한 분위기가 나옵니다.
_apply_stage_light_energy
(
GameManager
.
current_stage
,
true
)
# 전장 첫 진입 직후 투사체 이펙트를 미리 준비합니다.
# 실제 전투가 시작되기 전에 GLB, 셰이더, 파티클 리소스가 한 번 구성되도록 유도합니다.
_warmup_projectile_effects_deferred
()
func
_load_stage_map
(
map_path
:
String
)
->
void
:
func
_load_stage_map
(
map_path
:
String
)
->
void
:
var
map_scene
:
PackedScene
=
load
(
map_path
)
as
PackedScene
var
map_scene
:
PackedScene
=
load
(
map_path
)
as
PackedScene
if
map_scene
:
if
map_scene
:
...
@@ -47,3 +87,53 @@ func _get_background_path_for_map(map_path: String) -> String:
...
@@ -47,3 +87,53 @@ func _get_background_path_for_map(map_path: String) -> String:
return
"res://src/map/background/swamp.tscn"
return
"res://src/map/background/swamp.tscn"
_
:
_
:
return
""
return
""
## 발사체 풀 노드를 런타임에 생성해 Main의 자식으로 배치합니다.
## 원거리 유닛은 projectile_pool 그룹을 통해 이 노드를 찾아 공통으로 재사용합니다.
func
_setup_projectile_pool
()
->
void
:
_projectile_pool
=
ProjectilePoolScript
.
new
()
as
Node
_projectile_pool
.
name
=
"ProjectilePool"
add_child
(
_projectile_pool
)
if
_projectile_pool
.
has_method
(
"initialize_pool"
):
_projectile_pool
.
call
(
"initialize_pool"
)
## 첫 전투 중 끊김을 줄이기 위해 투사체 이펙트를 지연 워밍업합니다.
## 한 프레임 뒤에 실행하면 Main과 카메라/월드 노드가 준비된 뒤 리소스 초기화가 진행됩니다.
func
_warmup_projectile_effects_deferred
()
->
void
:
await
get_tree
()
.
process_frame
if
_projectile_pool
and
_projectile_pool
.
has_method
(
"warmup_projectile_effects"
):
_projectile_pool
.
call
(
"warmup_projectile_effects"
)
func
_on_stage_changed
(
stage
:
int
)
->
void
:
# 스테이지 숫자가 바뀔 때마다 해당 진행도에 맞는 광량으로 전환합니다.
_apply_stage_light_energy
(
stage
)
## 스테이지 진행도에 따라 DirectionalLight3D의 밝기를 계산하고 적용합니다.
## - 1스테이지는 stage_1_light_energy를 사용합니다.
## - 마지막 스테이지는 final_stage_light_energy를 사용합니다.
## - 중간 스테이지는 두 값 사이를 선형 보간합니다.
func
_apply_stage_light_energy
(
stage
:
int
,
instant
:
bool
=
false
)
->
void
:
if
not
_directional_light
:
return
# stage_progress는 0.0(첫 스테이지)부터 1.0(마지막 스테이지)까지의 정규화된 진행도입니다.
# clampf로 범위를 고정해 디버그 중 비정상 스테이지 값이 들어와도 광량이 과하게 튀지 않게 합니다.
var
stage_progress
:
float
=
0.0
if
GameManager
.
MAX_STAGE
>
1
:
stage_progress
=
clampf
(
float
(
stage
-
1
)
/
float
(
GameManager
.
MAX_STAGE
-
1
),
0.0
,
1.0
)
# 진행도에 따라 밝은 초반 광량에서 어두운 후반 광량으로 자연스럽게 이동합니다.
var
target_energy
:
float
=
lerpf
(
stage_1_light_energy
,
final_stage_light_energy
,
stage_progress
)
# 이전 스테이지 전환 애니메이션이 남아 있다면 중복 적용을 막기 위해 중단합니다.
if
_light_tween
:
_light_tween
.
kill
()
# 초기 적용 또는 전환 시간이 없는 설정에서는 Tween 없이 즉시 목표 밝기를 반영합니다.
if
instant
or
stage_light_transition_time
<=
0.0
:
_directional_light
.
light_energy
=
target_energy
return
# 실제 플레이 중 스테이지가 넘어갈 때는 짧은 보간으로 밝기를 부드럽게 바꿉니다.
_light_tween
=
create_tween
()
_light_tween
.
tween_property
(
_directional_light
,
"light_energy"
,
target_energy
,
stage_light_transition_time
)
src/core/projectile_pool.gd
0 → 100644
View file @
45c18ca1
class_name
ProjectilePool
extends
Node3D
## 원거리 발사체와 투사체 비주얼 이펙트를 미리 만들어 재사용하는 런타임 풀입니다.
## - 첫 공격 순간에 PackedScene 인스턴싱, GLB 업로드, 셰이더/파티클 준비 비용이 몰리는 현상을 줄입니다.
## - 전투 중에는 queue_free() 대신 비활성화 후 재사용하여 잦은 생성/삭제로 인한 프레임 끊김을 줄입니다.
const
ProjectileScript
:
GDScript
=
preload
(
"res://src/entities/units/base/projectile.gd"
)
const
FIREBALL_EFFECT_SCENE
:
PackedScene
=
preload
(
"res://src/entities/projectiles/fireball/fireball_effect.tscn"
)
const
ARROW_EFFECT_SCENE
:
PackedScene
=
preload
(
"res://src/entities/projectiles/arrow/arrow_effect.tscn"
)
const
PROJECTILE_KEY_FIREBALL
:
String
=
"fireball"
const
PROJECTILE_KEY_ARROW
:
String
=
"arrow"
const
PROJECTILE_KEY_SPHERE
:
String
=
"sphere"
## 전투 시작 전 미리 준비할 화염구 발사체 수입니다.
## 마법사 수가 늘어 동시에 여러 발이 나가도 런타임 인스턴싱이 적게 발생하도록 여유분을 둡니다.
@
export
var
fireball_pool_size
:
int
=
16
## 전투 시작 전 미리 준비할 화살 발사체 수입니다.
## 레인저는 공격 속도가 빠른 편이므로 화염구보다 조금 더 넉넉히 준비합니다.
@
export
var
arrow_pool_size
:
int
=
24
## 커스텀 비주얼을 쓰지 않는 기본 구체 발사체 예비 수입니다.
## 현재는 fallback 용도지만, 다른 원거리 유닛이 추가될 때 재사용할 수 있습니다.
@
export
var
sphere_pool_size
:
int
=
8
## 워밍업 인스턴스가 잠시 배치될 위치입니다.
## 카메라 밖/전장 밖에 두어 플레이어에게 보이지 않게 하면서 리소스 초기화를 유도합니다.
@
export
var
warmup_position
:
Vector3
=
Vector3
(
0.0
,
-
100.0
,
0.0
)
var
_available_projectiles
:
Dictionary
=
{}
var
_visual_scenes
:
Dictionary
=
{}
var
_is_initialized
:
bool
=
false
func
_ready
()
->
void
:
initialize_pool
()
## 풀을 한 번만 초기화합니다.
## Main에서 명시적으로 호출해도 되고, 노드가 씬 트리에 들어올 때 _ready()를 통해 자동 실행되어도 안전합니다.
func
initialize_pool
()
->
void
:
if
_is_initialized
:
return
_is_initialized
=
true
add_to_group
(
"projectile_pool"
)
_visual_scenes
=
{
PROJECTILE_KEY_FIREBALL
:
FIREBALL_EFFECT_SCENE
,
PROJECTILE_KEY_ARROW
:
ARROW_EFFECT_SCENE
,
PROJECTILE_KEY_SPHERE
:
null
}
_available_projectiles
.
clear
()
_available_projectiles
[
PROJECTILE_KEY_FIREBALL
]
=
[]
_available_projectiles
[
PROJECTILE_KEY_ARROW
]
=
[]
_available_projectiles
[
PROJECTILE_KEY_SPHERE
]
=
[]
_create_projectiles
(
PROJECTILE_KEY_FIREBALL
,
fireball_pool_size
)
_create_projectiles
(
PROJECTILE_KEY_ARROW
,
arrow_pool_size
)
_create_projectiles
(
PROJECTILE_KEY_SPHERE
,
sphere_pool_size
)
## 지정된 종류의 발사체를 풀에 미리 생성합니다.
## 생성 직후에는 비활성화 상태로 보관하며, 실제 공격 시 activate_from_pool()로 켭니다.
func
_create_projectiles
(
projectile_key
:
String
,
count
:
int
)
->
void
:
var
safe_count
:
int
=
maxi
(
0
,
count
)
for
index
:
int
in
range
(
safe_count
):
var
projectile
:
Projectile
=
_create_projectile
(
projectile_key
)
_store_projectile
(
projectile_key
,
projectile
)
## 발사체 노드 하나를 생성하고 해당 종류의 비주얼 씬을 장착합니다.
## 이 시점에 씬 트리에 붙기 때문에 _ready()에서 비주얼 인스턴스도 함께 구성됩니다.
func
_create_projectile
(
projectile_key
:
String
)
->
Projectile
:
var
projectile
:
Projectile
=
ProjectileScript
.
new
()
as
Projectile
projectile
.
name
=
"PooledProjectile_"
+
projectile_key
projectile
.
custom_visual_scene
=
_visual_scenes
.
get
(
projectile_key
)
as
PackedScene
projectile
.
projectile_pool
=
self
projectile
.
pool_key
=
projectile_key
add_child
(
projectile
)
projectile
.
global_position
=
warmup_position
projectile
.
deactivate_for_pool
()
return
projectile
## 외부 유닛이 공격할 때 호출하는 발사체 대여 함수입니다.
## 풀이 비어 있으면 같은 경로로 새 발사체를 확장 생성하여 공격 누락을 막습니다.
func
spawn_projectile
(
projectile_key
:
String
,
fire_position
:
Vector3
,
target
:
Node3D
,
damage
:
float
,
speed
:
float
,
color
:
Color
)
->
Projectile
:
initialize_pool
()
var
projectile
:
Projectile
=
_take_projectile
(
projectile_key
)
projectile
.
activate_from_pool
(
self
,
projectile_key
,
fire_position
,
target
,
damage
,
speed
,
color
)
return
projectile
## 발사체가 충돌/소멸 조건에 도달했을 때 다시 풀로 반환됩니다.
## Projectile 쪽에서 직접 호출하므로 이름과 시그니처를 안정적으로 유지합니다.
func
release_projectile
(
projectile
:
Projectile
)
->
void
:
if
not
is_instance_valid
(
projectile
):
return
var
projectile_key
:
String
=
projectile
.
pool_key
if
not
_available_projectiles
.
has
(
projectile_key
):
projectile
.
queue_free
()
return
projectile
.
deactivate_for_pool
()
_store_projectile
(
projectile_key
,
projectile
)
## 워밍업용으로 미리 만들어 둔 발사체를 잠깐 켰다가 다시 끕니다.
## 실제 첫 공격 전에 비주얼 노드, 머티리얼, 파티클 리소스가 한 번 씬 트리에 올라오도록 유도합니다.
func
warmup_projectile_effects
()
->
void
:
initialize_pool
()
var
warmup_keys
:
Array
[
String
]
=
[
PROJECTILE_KEY_FIREBALL
,
PROJECTILE_KEY_ARROW
,
PROJECTILE_KEY_SPHERE
]
var
warmup_projectiles
:
Array
[
Projectile
]
=
[]
for
projectile_key
:
String
in
warmup_keys
:
var
projectile
:
Projectile
=
_take_projectile
(
projectile_key
)
projectile
.
show_for_warmup
(
warmup_position
)
warmup_projectiles
.
append
(
projectile
)
# 한 렌더 프레임 이상 비주얼을 씬 트리에 켜 두어 첫 사용 리소스 준비가 진행될 시간을 줍니다.
await
get_tree
()
.
process_frame
await
get_tree
()
.
process_frame
for
projectile
:
Projectile
in
warmup_projectiles
:
projectile
.
deactivate_for_pool
()
_store_projectile
(
projectile
.
pool_key
,
projectile
)
## 풀에서 하나를 꺼내거나, 부족하면 새로 만들어 반환합니다.
func
_take_projectile
(
projectile_key
:
String
)
->
Projectile
:
if
not
_available_projectiles
.
has
(
projectile_key
):
projectile_key
=
PROJECTILE_KEY_SPHERE
var
available
:
Array
=
_available_projectiles
[
projectile_key
]
if
available
.
is_empty
():
return
_create_projectile
(
projectile_key
)
return
available
.
pop_back
()
as
Projectile
## 비활성 발사체를 해당 종류의 대기열에 넣습니다.
func
_store_projectile
(
projectile_key
:
String
,
projectile
:
Projectile
)
->
void
:
if
not
_available_projectiles
.
has
(
projectile_key
):
_available_projectiles
[
projectile_key
]
=
[]
var
available
:
Array
=
_available_projectiles
[
projectile_key
]
if
not
available
.
has
(
projectile
):
available
.
append
(
projectile
)
src/core/projectile_pool.gd.uid
0 → 100644
View file @
45c18ca1
uid://yb0vk5n52gji
src/entities/summoner/summoner.gd
View file @
45c18ca1
...
@@ -26,9 +26,18 @@ var spell_mana_costs: Dictionary = {
...
@@ -26,9 +26,18 @@ var spell_mana_costs: Dictionary = {
var
is_initialized
:
bool
=
false
var
is_initialized
:
bool
=
false
## 강타(Smite) 번개 기둥에 재사용할 메쉬 리소스입니다.
## 첫 시전 순간 CylinderMesh를 생성하지 않도록 소환사 노드 준비 시 미리 구성합니다.
var
_lightning_mesh
:
CylinderMesh
=
null
## 강타(Smite) 번개 기둥에 재사용할 발광 머티리얼입니다.
## StandardMaterial3D 생성과 emission 설정 비용을 첫 스킬 사용 전에 끝내기 위한 캐시입니다.
var
_lightning_material
:
StandardMaterial3D
=
null
func
_ready
()
->
void
:
func
_ready
()
->
void
:
# setup_summoner()를 통해 외부에서 데이터를 직접 바인딩할 때까지 대기
# setup_summoner()를 통해 외부에서 데이터를 직접 바인딩할 때까지 대기합니다.
pass
# 비주얼 이펙트 리소스는 스킬 첫 사용 끊김을 줄이기 위해 노드 준비 시점에 미리 만듭니다.
_prepare_lightning_effect_resources
()
## 소환사 데이터를 주입받아 초기 셋업을 완료하는 메서드
## 소환사 데이터를 주입받아 초기 셋업을 완료하는 메서드
func
setup_summoner
(
data
:
SummonerData
)
->
void
:
func
setup_summoner
(
data
:
SummonerData
)
->
void
:
...
@@ -177,20 +186,9 @@ func _create_lightning_strike_effect(strike_pos: Vector3) -> void:
...
@@ -177,20 +186,9 @@ func _create_lightning_strike_effect(strike_pos: Vector3) -> void:
return
return
# 1. 번개 기둥 역할을 할 실린더 메쉬 인스턴스 생성
# 1. 번개 기둥 역할을 할 실린더 메쉬 인스턴스 생성
var
mesh_inst
=
MeshInstance3D
.
new
()
var
mesh_inst
:
MeshInstance3D
=
MeshInstance3D
.
new
()
var
cyl_mesh
=
CylinderMesh
.
new
()
mesh_inst
.
mesh
=
_lightning_mesh
cyl_mesh
.
top_radius
=
0.05
mesh_inst
.
material_override
=
_lightning_material
cyl_mesh
.
bottom_radius
=
0.15
cyl_mesh
.
height
=
10.0
var
mat
=
StandardMaterial3D
.
new
()
mat
.
albedo_color
=
Color
(
0.1
,
0.6
,
1.0
)
# 강렬한 하늘색
mat
.
emission_enabled
=
true
mat
.
emission
=
Color
(
0.2
,
0.7
,
1.0
)
mat
.
emission_energy_multiplier
=
4.0
mesh_inst
.
mesh
=
cyl_mesh
mesh_inst
.
material_override
=
mat
root
.
add_child
(
mesh_inst
)
root
.
add_child
(
mesh_inst
)
# 번개 기둥 위치 정렬: 하늘에서 내리치는 연출이므로 y 오프셋
# 번개 기둥 위치 정렬: 하늘에서 내리치는 연출이므로 y 오프셋
mesh_inst
.
global_position
=
strike_pos
+
Vector3
(
0.0
,
5.0
,
0.0
)
mesh_inst
.
global_position
=
strike_pos
+
Vector3
(
0.0
,
5.0
,
0.0
)
...
@@ -210,3 +208,17 @@ func _create_lightning_strike_effect(strike_pos: Vector3) -> void:
...
@@ -210,3 +208,17 @@ func _create_lightning_strike_effect(strike_pos: Vector3) -> void:
if
is_instance_valid
(
flash_light
):
if
is_instance_valid
(
flash_light
):
flash_light
.
queue_free
()
flash_light
.
queue_free
()
)
)
## 강타 번개 이펙트에서 반복 사용하는 리소스를 미리 준비합니다.
## 실제 시전 시에는 MeshInstance3D와 OmniLight3D 노드만 짧게 만들고, 메쉬/머티리얼은 여기서 만든 것을 공유합니다.
func
_prepare_lightning_effect_resources
()
->
void
:
_lightning_mesh
=
CylinderMesh
.
new
()
_lightning_mesh
.
top_radius
=
0.05
_lightning_mesh
.
bottom_radius
=
0.15
_lightning_mesh
.
height
=
10.0
_lightning_material
=
StandardMaterial3D
.
new
()
_lightning_material
.
albedo_color
=
Color
(
0.1
,
0.6
,
1.0
)
# 강렬한 하늘색
_lightning_material
.
emission_enabled
=
true
_lightning_material
.
emission
=
Color
(
0.2
,
0.7
,
1.0
)
_lightning_material
.
emission_energy_multiplier
=
4.0
src/entities/units/base/base_unit.gd
View file @
45c18ca1
...
@@ -437,12 +437,6 @@ func _show_attack_visual() -> void:
...
@@ -437,12 +437,6 @@ func _show_attack_visual() -> void:
## 모델별 기본 공격 애니메이션 이름을 반환합니다.
## 모델별 기본 공격 애니메이션 이름을 반환합니다.
func
_get_attack_animation_name
(
model_name
:
String
)
->
String
:
func
_get_attack_animation_name
(
model_name
:
String
)
->
String
:
return
""
return
""
# var attack_key: String = "Slash"
# if model_name == "ranger":
# attack_key = "Draw"
# elif model_name == "mage":
# attack_key = "Casting"
# return model_name + "/" + attack_key
## 상태(대기, 이동, 공격)별 3D 애니메이션 트랙 제어 및 크로스페이드 블렌딩
## 상태(대기, 이동, 공격)별 3D 애니메이션 트랙 제어 및 크로스페이드 블렌딩
## 상태(대기, 이동, 공격)별 3D 애니메이션 트랙 제어 (상속 클래스에서 재정의)
## 상태(대기, 이동, 공격)별 3D 애니메이션 트랙 제어 (상속 클래스에서 재정의)
...
@@ -456,3 +450,13 @@ func _setup_unit_visuals() -> void:
...
@@ -456,3 +450,13 @@ func _setup_unit_visuals() -> void:
## 원거리 유닛의 발사체 생성을 처리하는 가상 함수
## 원거리 유닛의 발사체 생성을 처리하는 가상 함수
func
_spawn_projectile
(
_fire_position
:
Vector3
)
->
void
:
func
_spawn_projectile
(
_fire_position
:
Vector3
)
->
void
:
pass
pass
## 현재 씬에서 런타임 발사체 풀을 찾아 반환합니다.
## - Main이 ProjectilePool을 생성해 group에 등록하면 원거리 유닛은 이 경로로 풀을 공유합니다.
## - 풀을 찾지 못하는 테스트 씬에서는 null을 반환하여 기존 직접 생성 fallback을 사용할 수 있게 합니다.
func
_get_projectile_pool
()
->
Node
:
var
pools
:
Array
[
Node
]
=
get_tree
()
.
get_nodes_in_group
(
"projectile_pool"
)
if
pools
.
is_empty
():
return
null
return
pools
[
0
]
src/entities/units/base/projectile.gd
View file @
45c18ca1
...
@@ -13,6 +13,20 @@ var color: Color = Color(1.0, 0.9, 0.2, 1.0) # 기본 노란색 발사체
...
@@ -13,6 +13,20 @@ var color: Color = Color(1.0, 0.9, 0.2, 1.0) # 기본 노란색 발사체
# 커스텀 비주얼 씬 (마법사 불덩이, 레인저 화살 등 외부 씬 연동 지원)
# 커스텀 비주얼 씬 (마법사 불덩이, 레인저 화살 등 외부 씬 연동 지원)
var
custom_visual_scene
:
PackedScene
=
null
var
custom_visual_scene
:
PackedScene
=
null
## ProjectilePool에서 관리되는 경우 풀 노드 참조를 보관합니다.
## 값이 없으면 기존 방식처럼 충돌/소멸 시 queue_free()를 사용합니다.
var
projectile_pool
:
Node
=
null
## 풀 내부에서 발사체 종류를 구분하기 위한 키입니다.
## 예: "fireball", "arrow", "sphere"
var
pool_key
:
String
=
""
## 풀에 보관 중인 비활성 발사체가 물리 처리와 충돌 판정을 하지 않도록 막는 플래그입니다.
var
_is_active
:
bool
=
true
## 커스텀 비주얼 인스턴스를 한 번만 생성해 재사용하기 위한 캐시입니다.
var
_visual_inst
:
Node3D
=
null
func
_ready
()
->
void
:
func
_ready
()
->
void
:
if
custom_visual_scene
:
if
custom_visual_scene
:
_setup_custom_visual
()
_setup_custom_visual
()
...
@@ -21,8 +35,8 @@ func _ready() -> void:
...
@@ -21,8 +35,8 @@ func _ready() -> void:
## 커스텀 비주얼 씬 인스턴싱하여 자식으로 장착
## 커스텀 비주얼 씬 인스턴싱하여 자식으로 장착
func
_setup_custom_visual
()
->
void
:
func
_setup_custom_visual
()
->
void
:
var
visual_inst
:
Node3D
=
custom_visual_scene
.
instantiate
()
as
Node3D
_visual_inst
=
custom_visual_scene
.
instantiate
()
as
Node3D
add_child
(
visual_inst
)
add_child
(
_
visual_inst
)
## 구체 빛 구슬 비주얼 설정 (Ranger 이외 유닛 또는 arrow 로드 실패 시 Fallback)
## 구체 빛 구슬 비주얼 설정 (Ranger 이외 유닛 또는 arrow 로드 실패 시 Fallback)
...
@@ -41,15 +55,62 @@ func _setup_sphere_visual() -> void:
...
@@ -41,15 +55,62 @@ func _setup_sphere_visual() -> void:
mesh_instance
.
mesh
=
sphere_mesh
mesh_instance
.
mesh
=
sphere_mesh
add_child
(
mesh_instance
)
add_child
(
mesh_instance
)
_visual_inst
=
mesh_instance
## 풀에서 꺼낸 발사체에 이번 공격에 필요한 런타임 값을 주입하고 활성화합니다.
## 같은 발사체 노드를 재사용하므로 이전 타겟/데미지/색상 상태가 남지 않도록 여기에서 모두 덮어씁니다.
func
activate_from_pool
(
pool
:
Node
,
projectile_key
:
String
,
fire_position
:
Vector3
,
new_target
:
Node3D
,
new_damage
:
float
,
new_speed
:
float
,
new_color
:
Color
)
->
void
:
projectile_pool
=
pool
pool_key
=
projectile_key
target
=
new_target
damage
=
new_damage
speed
=
new_speed
color
=
new_color
global_position
=
fire_position
visible
=
true
_is_active
=
true
set_physics_process
(
true
)
_restart_particle_children
(
self
)
## 풀에 반환될 때 발사체를 완전히 비활성화합니다.
## 노드는 씬 트리에 남겨 두되 보이지 않고, 물리 프레임도 소모하지 않게 만듭니다.
func
deactivate_for_pool
()
->
void
:
_is_active
=
false
target
=
null
visible
=
false
global_position
=
Vector3
(
0.0
,
-
100.0
,
0.0
)
set_physics_process
(
false
)
_stop_particle_children
(
self
)
## 이펙트 워밍업 전용 활성화입니다.
## 타겟 추적이나 충돌 처리는 하지 않고, 비주얼 자식만 잠깐 켜서 리소스 준비를 유도합니다.
func
show_for_warmup
(
warmup_position
:
Vector3
)
->
void
:
_is_active
=
false
target
=
null
global_position
=
warmup_position
visible
=
true
set_physics_process
(
false
)
_restart_particle_children
(
self
)
func
_physics_process
(
delta
:
float
)
->
void
:
func
_physics_process
(
delta
:
float
)
->
void
:
if
not
_is_active
:
return
if
GameManager
.
is_game_over
:
if
GameManager
.
is_game_over
:
queue_free
()
_despawn
()
return
return
# 타겟이 도중에 사라지거나 사망 시 소멸
# 타겟이 도중에 사라지거나 사망 시 소멸
if
not
is_instance_valid
(
target
)
or
target
.
get
(
"is_dead"
)
==
true
:
if
not
is_instance_valid
(
target
)
or
target
.
get
(
"is_dead"
)
==
true
:
queue_free
()
_despawn
()
return
return
# 타겟 높이 오프셋 보정 (몬스터 중심부 y ~= 0.5를 향하도록)
# 타겟 높이 오프셋 보정 (몬스터 중심부 y ~= 0.5를 향하도록)
...
@@ -79,4 +140,31 @@ func _impact() -> void:
...
@@ -79,4 +140,31 @@ func _impact() -> void:
target
.
call
(
"take_damage"
,
damage
)
target
.
call
(
"take_damage"
,
damage
)
# 충돌 이펙트 (필요 시 연출)
# 충돌 이펙트 (필요 시 연출)
_despawn
()
## 발사체 수명이 끝났을 때 풀로 반환하거나, 풀이 없으면 기존처럼 삭제합니다.
func
_despawn
()
->
void
:
if
projectile_pool
and
projectile_pool
.
has_method
(
"release_projectile"
):
projectile_pool
.
call
(
"release_projectile"
,
self
)
else
:
queue_free
()
queue_free
()
## 재사용 발사체가 다시 발사될 때 GPUParticles3D 자식들의 방출을 재시작합니다.
## 파티클이 중간 상태로 남아 있지 않게 emitting을 껐다 켜는 방식으로 초기화합니다.
func
_restart_particle_children
(
root
:
Node
)
->
void
:
for
child
:
Node
in
root
.
get_children
():
if
child
is
GPUParticles3D
:
var
particles
:
GPUParticles3D
=
child
as
GPUParticles3D
particles
.
emitting
=
false
particles
.
restart
()
particles
.
emitting
=
true
_restart_particle_children
(
child
)
## 풀에 들어간 비활성 발사체의 파티클 방출을 멈춥니다.
## 보이지 않는 발사체가 백그라운드에서 파티클 시뮬레이션 비용을 쓰지 않게 합니다.
func
_stop_particle_children
(
root
:
Node
)
->
void
:
for
child
:
Node
in
root
.
get_children
():
if
child
is
GPUParticles3D
:
var
particles
:
GPUParticles3D
=
child
as
GPUParticles3D
particles
.
emitting
=
false
_stop_particle_children
(
child
)
src/entities/units/mage/mage_unit.gd
View file @
45c18ca1
...
@@ -112,6 +112,22 @@ func _update_animations() -> void:
...
@@ -112,6 +112,22 @@ func _update_animations() -> void:
## 마법사(Mage) 전용 화염 불덩이(Fireball) 발사체를 월드에 스폰하는 함수
## 마법사(Mage) 전용 화염 불덩이(Fireball) 발사체를 월드에 스폰하는 함수
func
_spawn_projectile
(
fire_position
:
Vector3
)
->
void
:
func
_spawn_projectile
(
fire_position
:
Vector3
)
->
void
:
# Main에서 준비한 ProjectilePool이 있으면 미리 생성된 화염구 발사체를 재사용합니다.
# 이 경로가 전투 중 반복 instantiate/free를 줄여 첫 발사와 연속 발사 끊김을 완화합니다.
var
projectile_pool
:
Node
=
_get_projectile_pool
()
if
projectile_pool
and
projectile_pool
.
has_method
(
"spawn_projectile"
):
projectile_pool
.
call
(
"spawn_projectile"
,
"fireball"
,
fire_position
,
_target_monster
,
unit_data
.
damage
,
18.0
,
Color
(
1.0
,
0.35
,
0.1
,
1.0
)
)
return
# ProjectilePool이 없는 독립 테스트 씬에서는 기존 방식으로 발사체를 직접 생성합니다.
# 공통 Projectile 스크립트를 로드하여 발사체 인스턴스를 동적 생성합니다.
# 공통 Projectile 스크립트를 로드하여 발사체 인스턴스를 동적 생성합니다.
var
projectile_script
:
=
preload
(
"res://src/entities/units/base/projectile.gd"
)
var
projectile_script
:
=
preload
(
"res://src/entities/units/base/projectile.gd"
)
var
proj
:
Node3D
=
projectile_script
.
new
()
as
Node3D
var
proj
:
Node3D
=
projectile_script
.
new
()
as
Node3D
...
...
src/entities/units/ranger/ranger_unit.gd
View file @
45c18ca1
...
@@ -92,6 +92,22 @@ func _update_animations() -> void:
...
@@ -92,6 +92,22 @@ func _update_animations() -> void:
## 레인저 전용 발사체 생성 및 스폰
## 레인저 전용 발사체 생성 및 스폰
func
_spawn_projectile
(
fire_position
:
Vector3
)
->
void
:
func
_spawn_projectile
(
fire_position
:
Vector3
)
->
void
:
# Main에서 준비한 ProjectilePool이 있으면 미리 생성된 화살 발사체를 재사용합니다.
# 빠르게 반복 발사되는 레인저 특성상 풀링 효과가 크며, 전투 중 생성/삭제 비용을 줄입니다.
var
projectile_pool
:
Node
=
_get_projectile_pool
()
if
projectile_pool
and
projectile_pool
.
has_method
(
"spawn_projectile"
):
projectile_pool
.
call
(
"spawn_projectile"
,
"arrow"
,
fire_position
,
_target_monster
,
unit_data
.
damage
,
28.0
,
unit_data
.
unit_color
)
return
# ProjectilePool이 없는 독립 테스트 씬에서는 기존 방식으로 발사체를 직접 생성합니다.
var
projectile_script
=
preload
(
"res://src/entities/units/base/projectile.gd"
)
var
projectile_script
=
preload
(
"res://src/entities/units/base/projectile.gd"
)
var
proj
:
Node3D
=
projectile_script
.
new
()
as
Node3D
var
proj
:
Node3D
=
projectile_script
.
new
()
as
Node3D
...
...
src/map/background/desert.tscn
View file @
45c18ca1
...
@@ -115,4 +115,4 @@ waterDefinition = SubResource("WaterResource_gbjga")
...
@@ -115,4 +115,4 @@ waterDefinition = SubResource("WaterResource_gbjga")
terrainZones = SubResource("ZonesResource_aj3fk")
terrainZones = SubResource("ZonesResource_aj3fk")
physics_interpolation_mode = 2
physics_interpolation_mode = 2
top_level = true
top_level = true
metadata/_edit_lock_ =
tru
e
metadata/_edit_lock_ =
fals
e
src/ui/hero_panel/hud_hero_panel.gd
0 → 100644
View file @
45c18ca1
class_name
HudHeroPanel
extends
RefCounted
## HUD의 영웅 소환 버튼과 영웅 상태 패널을 담당하는 보조 클래스입니다.
## - 영웅 소환 비용/버튼 상태, 영웅 생성, 경험치/레벨/스킬 쿨다운 UI를 한곳에 모읍니다.
## - HUD 본문은 골드 변경 시 update_gold()만 호출하고, 세부 UI 갱신은 이 클래스가 처리합니다.
const
HERO_SPAWN_COST
:
int
=
500
const
DWARF_HERO_SCENE
:
PackedScene
=
preload
(
"res://src/entities/units/hero/dwarf/dwarf_hero.tscn"
)
const
DWARF_PORTRAIT_PATH
:
String
=
"res://assets/ui/hero/dwarf_portrait.jpg"
var
_hud
:
Control
=
null
var
_placement_manager
:
Object
=
null
var
hero_spawn_button
:
Button
=
null
var
hero_panel
:
Panel
=
null
var
_active_hero
:
BaseHero
=
null
var
_skill_cooldown_overlays
:
Dictionary
=
{}
var
_skill_cooldown_labels
:
Dictionary
=
{}
## HUD와 유닛 배치 매니저를 주입받아 영웅 버튼을 BottomBar에 추가합니다.
## placement_manager는 일반 유닛 배치 중 영웅 소환이 겹치지 않게 상태 확인에 사용합니다.
func
setup
(
hud
:
Control
,
bottom_bar
:
Control
,
placement_manager
:
Object
)
->
void
:
_hud
=
hud
_placement_manager
=
placement_manager
hero_spawn_button
=
_create_hero_spawn_button
()
bottom_bar
.
add_child
(
hero_spawn_button
)
hero_spawn_button
.
pressed
.
connect
(
_on_summon_hero_pressed
)
## 골드 변화에 따라 영웅 소환 버튼 활성 상태를 갱신합니다.
## HUD의 _on_gold_changed()가 호출될 때 함께 호출됩니다.
func
update_gold
(
gold
:
int
)
->
void
:
if
hero_spawn_button
:
hero_spawn_button
.
disabled
=
gold
<
HERO_SPAWN_COST
## 영웅 소환 버튼을 생성하고 시각 스타일을 적용합니다.
## 버튼은 BottomBar에 동적으로 들어가므로 hud.tscn 원본을 수정하지 않습니다.
func
_create_hero_spawn_button
()
->
Button
:
var
button
:
Button
=
Button
.
new
()
button
.
name
=
"SummonHeroButton"
button
.
text
=
"Summon Hero (500 G)"
var
style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
style
.
bg_color
=
Color
(
0.35
,
0.1
,
0.45
,
0.9
)
style
.
set_corner_radius_all
(
4
)
button
.
add_theme_stylebox_override
(
"normal"
,
style
)
return
button
## 영웅 소환 버튼 클릭 처리입니다.
## 유닛 배치 대기 중이거나 게임 종료 상태라면 소환을 막아 입력 흐름이 섞이지 않게 합니다.
func
_on_summon_hero_pressed
()
->
void
:
if
GameManager
.
is_game_over
or
_has_pending_unit_placement
():
return
if
not
GameManager
.
spend_gold
(
HERO_SPAWN_COST
):
return
if
DataManager
.
hero_templates
.
is_empty
():
GameManager
.
add_gold
(
HERO_SPAWN_COST
)
return
var
chosen_template
:
UnitData
=
DataManager
.
hero_templates
[
0
]
var
new_hero
:
BaseHero
=
DWARF_HERO_SCENE
.
instantiate
()
as
BaseHero
var
parent_scene
:
Node
=
_hud
.
get_tree
()
.
current_scene
if
not
parent_scene
or
not
new_hero
:
GameManager
.
add_gold
(
HERO_SPAWN_COST
)
return
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
)
## 일반 유닛 배치 대기 상태인지 placement_manager에 위임해 확인합니다.
func
_has_pending_unit_placement
()
->
bool
:
if
_placement_manager
and
_placement_manager
.
has_method
(
"has_pending_unit_placement"
):
return
_placement_manager
.
call
(
"has_pending_unit_placement"
)
return
false
## 영웅 전용 좌측 상단 HUD를 새로 구성합니다.
## 기존 패널이 있으면 제거한 뒤, 새 영웅 인스턴스의 신호와 연결된 UI를 생성합니다.
func
_setup_hero_ui
(
hero
:
BaseHero
)
->
void
:
if
hero_panel
and
is_instance_valid
(
hero_panel
):
hero_panel
.
queue_free
()
_skill_cooldown_overlays
.
clear
()
_skill_cooldown_labels
.
clear
()
hero_panel
=
_create_hero_panel
()
_hud
.
add_child
(
hero_panel
)
var
hbox
:
HBoxContainer
=
_create_panel_hbox
()
hero_panel
.
add_child
(
hbox
)
hbox
.
add_child
(
_create_portrait_panel
())
var
detail_box
:
VBoxContainer
=
_create_detail_box
()
hbox
.
add_child
(
detail_box
)
var
name_label
:
Label
=
_create_name_label
(
hero
)
var
xp_bar
:
ProgressBar
=
_create_xp_bar
()
var
xp_label
:
Label
=
_create_xp_label
(
hero
)
var
xp_group
:
HBoxContainer
=
_create_xp_group
(
xp_bar
,
xp_label
)
var
skill_group
:
HBoxContainer
=
_create_skill_group
(
hero
)
detail_box
.
add_child
(
name_label
)
detail_box
.
add_child
(
xp_group
)
detail_box
.
add_child
(
skill_group
)
_connect_hero_signals
(
hero
,
name_label
,
xp_bar
,
xp_label
)
hero
.
xp_changed
.
emit
(
hero
.
xp
,
hero
.
xp_to_next_level
)
hero
.
level_changed
.
emit
(
hero
.
level
)
## 영웅 상태 패널의 외곽 Panel을 생성합니다.
func
_create_hero_panel
()
->
Panel
:
var
panel
:
Panel
=
Panel
.
new
()
panel
.
name
=
"HeroStatusPanel"
panel
.
custom_minimum_size
=
Vector2
(
290
,
120
)
panel
.
position
=
Vector2
(
20
,
120
)
var
style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
style
.
bg_color
=
Color
(
0.12
,
0.08
,
0.16
,
0.85
)
style
.
border_width_left
=
2
style
.
border_width_top
=
2
style
.
border_width_right
=
2
style
.
border_width_bottom
=
2
style
.
border_color
=
Color
(
0.85
,
0.12
,
0.85
,
0.9
)
style
.
set_corner_radius_all
(
6
)
panel
.
add_theme_stylebox_override
(
"panel"
,
style
)
return
panel
## 초상화와 상세 정보를 좌우로 나누는 HBoxContainer를 생성합니다.
func
_create_panel_hbox
()
->
HBoxContainer
:
var
hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
hbox
.
name
=
"HBox"
hbox
.
anchor_right
=
1.0
hbox
.
anchor_bottom
=
1.0
hbox
.
offset_left
=
8
hbox
.
offset_top
=
8
hbox
.
offset_right
=
-
8
hbox
.
offset_bottom
=
-
8
hbox
.
add_theme_constant_override
(
"separation"
,
12
)
return
hbox
## 영웅 초상화 패널을 생성합니다.
## 초상화 이미지가 없더라도 검은 배경 패널이 남아 레이아웃이 유지됩니다.
func
_create_portrait_panel
()
->
Panel
:
var
portrait_panel
:
Panel
=
Panel
.
new
()
portrait_panel
.
custom_minimum_size
=
Vector2
(
75
,
75
)
var
panel_style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
panel_style
.
bg_color
=
Color
(
0.05
,
0.05
,
0.05
,
1.0
)
panel_style
.
border_width_left
=
1
panel_style
.
border_width_top
=
1
panel_style
.
border_width_right
=
1
panel_style
.
border_width_bottom
=
1
panel_style
.
border_color
=
Color
(
1.0
,
0.85
,
0.0
,
0.8
)
panel_style
.
set_corner_radius_all
(
4
)
portrait_panel
.
add_theme_stylebox_override
(
"panel"
,
panel_style
)
var
portrait
:
TextureRect
=
TextureRect
.
new
()
portrait
.
name
=
"Portrait"
portrait
.
anchor_right
=
1.0
portrait
.
anchor_bottom
=
1.0
portrait
.
offset_left
=
2
portrait
.
offset_top
=
2
portrait
.
offset_right
=
-
2
portrait
.
offset_bottom
=
-
2
portrait
.
expand_mode
=
TextureRect
.
EXPAND_IGNORE_SIZE
portrait
.
stretch_mode
=
TextureRect
.
STRETCH_KEEP_ASPECT_COVERED
if
ResourceLoader
.
exists
(
DWARF_PORTRAIT_PATH
):
portrait
.
texture
=
load
(
DWARF_PORTRAIT_PATH
)
as
Texture2D
portrait_panel
.
add_child
(
portrait
)
return
portrait_panel
## 이름, 경험치, 스킬 슬롯을 세로로 쌓는 상세 정보 박스를 생성합니다.
func
_create_detail_box
()
->
VBoxContainer
:
var
vbox
:
VBoxContainer
=
VBoxContainer
.
new
()
vbox
.
name
=
"VBox"
vbox
.
size_flags_horizontal
=
Control
.
SIZE_EXPAND_FILL
vbox
.
add_theme_constant_override
(
"separation"
,
4
)
return
vbox
## 영웅 이름과 레벨 라벨을 생성합니다.
func
_create_name_label
(
hero
:
BaseHero
)
->
Label
:
var
name_label
:
Label
=
Label
.
new
()
name_label
.
name
=
"NameLabel"
name_label
.
text
=
"Lv.
%d
%s
"
%
[
hero
.
level
,
hero
.
unit_data
.
unit_name
]
name_label
.
add_theme_font_size_override
(
"font_size"
,
13
)
name_label
.
add_theme_color_override
(
"font_color"
,
Color
(
1.0
,
0.9
,
0.3
,
1.0
))
return
name_label
## 경험치 ProgressBar를 생성하고 스타일을 적용합니다.
func
_create_xp_bar
()
->
ProgressBar
:
var
xp_bar
:
ProgressBar
=
ProgressBar
.
new
()
xp_bar
.
name
=
"XPBar"
xp_bar
.
show_percentage
=
false
xp_bar
.
custom_minimum_size
=
Vector2
(
110
,
10
)
xp_bar
.
size_flags_vertical
=
Control
.
SIZE_SHRINK_CENTER
var
background_style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
background_style
.
bg_color
=
Color
(
0.1
,
0.1
,
0.1
,
1.0
)
background_style
.
set_corner_radius_all
(
2
)
var
fill_style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
fill_style
.
bg_color
=
Color
(
0.7
,
0.15
,
0.85
,
1.0
)
fill_style
.
set_corner_radius_all
(
2
)
xp_bar
.
add_theme_stylebox_override
(
"background"
,
background_style
)
xp_bar
.
add_theme_stylebox_override
(
"fill"
,
fill_style
)
return
xp_bar
## 경험치 숫자 라벨을 생성합니다.
func
_create_xp_label
(
hero
:
BaseHero
)
->
Label
:
var
xp_label
:
Label
=
Label
.
new
()
xp_label
.
name
=
"XPLabel"
xp_label
.
text
=
"
%d
/
%d
"
%
[
hero
.
xp
,
hero
.
xp_to_next_level
]
xp_label
.
add_theme_font_size_override
(
"font_size"
,
9
)
return
xp_label
## 경험치 바와 숫자를 같은 줄에 배치합니다.
func
_create_xp_group
(
xp_bar
:
ProgressBar
,
xp_label
:
Label
)
->
HBoxContainer
:
var
xp_hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
xp_hbox
.
add_theme_constant_override
(
"separation"
,
6
)
xp_hbox
.
add_child
(
xp_bar
)
xp_hbox
.
add_child
(
xp_label
)
return
xp_hbox
## Q/W/E/R 스킬 버튼과 쿨다운 오버레이를 생성합니다.
func
_create_skill_group
(
hero
:
BaseHero
)
->
HBoxContainer
:
var
skill_hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
skill_hbox
.
name
=
"SkillGroup"
skill_hbox
.
add_theme_constant_override
(
"separation"
,
6
)
var
slots
:
Array
[
String
]
=
[
"Q"
,
"W"
,
"E"
,
"R"
]
var
skill_names
:
Dictionary
=
{
"Q"
:
"Strike"
,
"W"
:
"Haste"
,
"E"
:
"Clap"
,
"R"
:
"Avatar"
}
for
slot
:
String
in
slots
:
skill_hbox
.
add_child
(
_create_skill_button
(
hero
,
slot
,
skill_names
[
slot
]))
return
skill_hbox
## 스킬 슬롯 버튼 하나를 만들고 쿨다운 오버레이/라벨을 자식으로 붙입니다.
func
_create_skill_button
(
hero
:
BaseHero
,
slot
:
String
,
skill_name
:
String
)
->
Button
:
var
button
:
Button
=
Button
.
new
()
button
.
name
=
"Skill_"
+
slot
button
.
text
=
slot
button
.
custom_minimum_size
=
Vector2
(
32
,
32
)
button
.
add_theme_font_size_override
(
"font_size"
,
10
)
button
.
add_theme_stylebox_override
(
"normal"
,
_create_skill_button_style
())
button
.
tooltip_text
=
"
%s
Skill:
%s
\n
Cooldown:
%.1f
s"
%
[
slot
,
skill_name
,
hero
.
skill_max_cooldowns
[
slot
]]
button
.
pressed
.
connect
(
func
()
->
void
:
if
is_instance_valid
(
hero
):
hero
.
use_skill
(
slot
)
)
var
overlay
:
ColorRect
=
ColorRect
.
new
()
overlay
.
name
=
"CooldownOverlay"
overlay
.
color
=
Color
(
0.0
,
0.0
,
0.0
,
0.6
)
overlay
.
anchor_right
=
1.0
overlay
.
anchor_bottom
=
1.0
overlay
.
mouse_filter
=
Control
.
MOUSE_FILTER_IGNORE
overlay
.
visible
=
false
button
.
add_child
(
overlay
)
_skill_cooldown_overlays
[
slot
]
=
overlay
var
label
:
Label
=
Label
.
new
()
label
.
name
=
"CooldownLabel"
label
.
horizontal_alignment
=
HORIZONTAL_ALIGNMENT_CENTER
label
.
vertical_alignment
=
VERTICAL_ALIGNMENT_CENTER
label
.
anchor_right
=
1.0
label
.
anchor_bottom
=
1.0
label
.
add_theme_font_size_override
(
"font_size"
,
9
)
label
.
add_theme_color_override
(
"font_color"
,
Color
(
1.0
,
1.0
,
1.0
,
1.0
))
label
.
visible
=
false
button
.
add_child
(
label
)
_skill_cooldown_labels
[
slot
]
=
label
return
button
## 스킬 버튼 공통 배경 스타일을 생성합니다.
func
_create_skill_button_style
()
->
StyleBoxFlat
:
var
style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
style
.
bg_color
=
Color
(
0.2
,
0.15
,
0.3
,
1.0
)
style
.
border_width_left
=
1
style
.
border_width_top
=
1
style
.
border_width_right
=
1
style
.
border_width_bottom
=
1
style
.
border_color
=
Color
(
0.6
,
0.4
,
0.7
,
0.8
)
style
.
set_corner_radius_all
(
3
)
return
style
## 영웅 데이터 신호와 UI 갱신 함수를 연결합니다.
func
_connect_hero_signals
(
hero
:
BaseHero
,
name_label
:
Label
,
xp_bar
:
ProgressBar
,
xp_label
:
Label
)
->
void
:
hero
.
level_changed
.
connect
(
func
(
new_level
:
int
)
->
void
:
if
is_instance_valid
(
name_label
)
and
is_instance_valid
(
hero
):
name_label
.
text
=
"Lv.
%d
%s
"
%
[
new_level
,
hero
.
unit_data
.
unit_name
]
)
hero
.
xp_changed
.
connect
(
func
(
current_xp
:
int
,
need_xp
:
int
)
->
void
:
if
is_instance_valid
(
xp_bar
)
and
is_instance_valid
(
xp_label
):
xp_bar
.
max_value
=
need_xp
xp_bar
.
value
=
current_xp
xp_label
.
text
=
"
%d
/
%d
"
%
[
current_xp
,
need_xp
]
)
hero
.
skill_cooldown_changed
.
connect
(
_on_hero_skill_cooldown_changed
)
## 스킬 쿨다운 신호를 받아 오버레이와 남은 시간 라벨을 표시/숨김 처리합니다.
func
_on_hero_skill_cooldown_changed
(
slot_name
:
String
,
remaining
:
float
,
_duration
:
float
)
->
void
:
var
overlay
:
ColorRect
=
_skill_cooldown_overlays
.
get
(
slot_name
)
var
label
:
Label
=
_skill_cooldown_labels
.
get
(
slot_name
)
if
is_instance_valid
(
overlay
)
and
is_instance_valid
(
label
):
if
remaining
>
0.0
:
overlay
.
visible
=
true
label
.
visible
=
true
label
.
text
=
"
%.1f
"
%
remaining
else
:
overlay
.
visible
=
false
label
.
visible
=
false
src/ui/hero_panel/hud_hero_panel.gd.uid
0 → 100644
View file @
45c18ca1
uid://bkx7mv8uksflg
src/ui/hud.gd
View file @
45c18ca1
extends
Control
extends
Control
## 메인 HUD UI 제어 스크립트 (HUD)
## 메인 HUD UI 제어 스크립트 (HUD)
## 플레이어 자원 현황, 게임 결과 팝업, 유닛 소환 메커니즘을 총괄합니다.
## 플레이어 자원/스테이지/몬스터 표시와 게임 흐름 버튼을 조율합니다.
## 세부 기능은 아래 보조 클래스로 분리해 HUD가 지나치게 커지지 않도록 유지합니다.
# 유닛 씬 및 데이터 프리로드
## - HudUnitPlacement: 일반 유닛 무작위 소환 및 타일 배치
## - HudSummonerPanel: 소환사 마나/주문 UI
## - HudHeroPanel: 영웅 소환 버튼 및 영웅 상태 UI
const
HudUnitPlacementScript
:
GDScript
=
preload
(
"res://src/ui/unit_placement/hud_unit_placement.gd"
)
const
HudSummonerPanelScript
:
GDScript
=
preload
(
"res://src/ui/summoner_panel/hud_summoner_panel.gd"
)
const
HudHeroPanelScript
:
GDScript
=
preload
(
"res://src/ui/hero_panel/hud_hero_panel.gd"
)
## 기본 유닛 fallback 씬입니다.
## 유닛 ID에 맞는 전용 씬을 찾지 못하면 HudUnitPlacement가 이 씬을 사용합니다.
@
export
var
base_unit_scene
:
PackedScene
=
preload
(
"res://src/entities/units/base/base_unit.tscn"
)
@
export
var
base_unit_scene
:
PackedScene
=
preload
(
"res://src/entities/units/base/base_unit.tscn"
)
const
INVALID_TILE_CELL
:
Vector3i
=
Vector3i
(
2147483647
,
2147483647
,
2147483647
)
# UI 노드 자동 캐싱
## 상단 상태 표시 UI 노드 캐시입니다.
## hud.tscn에 이미 배치된 노드들이므로 @onready로 안전하게 참조합니다.
@
onready
var
gold_label
:
Label
=
$
TopBar
/
GoldLabel
@
onready
var
gold_label
:
Label
=
$
TopBar
/
GoldLabel
@
onready
var
stage_label
:
Label
=
$
TopBar
/
StageLabel
@
onready
var
stage_label
:
Label
=
$
TopBar
/
StageLabel
@
onready
var
monster_label
:
Label
=
$
TopBar
/
MonsterLabel
@
onready
var
monster_label
:
Label
=
$
TopBar
/
MonsterLabel
@
onready
var
spawn_button
:
Button
=
$
BottomBar
/
SpawnButton
## 하단 기본 버튼과 결과 팝업 노드 캐시입니다.
@
onready
var
unit_spawn_button
:
Button
=
$
BottomBar
/
SpawnButton
@
onready
var
result_popup
:
Panel
=
$
ResultPopup
@
onready
var
result_popup
:
Panel
=
$
ResultPopup
@
onready
var
result_title
:
Label
=
$
ResultPopup
/
ResultTitle
@
onready
var
result_title
:
Label
=
$
ResultPopup
/
ResultTitle
@
onready
var
restart_button
:
Button
=
$
ResultPopup
/
RestartButton
@
onready
var
restart_button
:
Button
=
$
ResultPopup
/
RestartButton
# 몬스터 일시정지 버튼 런타임 저장 변수
var
_pause_button
:
Button
=
null
var
_pause_button
:
Button
=
null
var
_summoner
:
Summoner
=
null
var
_summoner
:
Summoner
=
null
# 영웅 관리용 상태 및 UI 노드 캐시
## 기능별 보조 객체입니다.
var
hero_spawn_button
:
Button
=
null
## 새 파일을 추가한 직후 Godot class_name 캐시 상태에 덜 의존하도록 Object 타입으로 보관합니다.
var
hero_panel
:
Panel
=
null
var
_unit_placement
:
Object
=
null
var
_active_hero
:
BaseHero
=
null
var
_summoner_panel
:
Object
=
null
const
HERO_SPAWN_COST
:
int
=
500
var
_hero_panel
:
Object
=
null
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
func
_ready
()
->
void
:
_connect_game_manager_signals
()
_setup_unit_placement
()
_bind_static_buttons
()
_initialize_status_labels
()
_setup_summoner_flow
()
_create_runtime_bottom_buttons
()
_on_gold_changed
(
GameManager
.
player_gold
)
## 마우스/터치 입력 중 유닛 배치 확정에 해당하는 입력을 HudUnitPlacement에 위임합니다.
## 배치 입력이 처리된 경우 viewport에 handled를 표시해 같은 클릭이 다른 게임 입력으로 중복 전달되지 않게 합니다.
func
_input
(
event
:
InputEvent
)
->
void
:
if
_unit_placement
and
_unit_placement
.
has_method
(
"handle_input"
):
var
handled
:
bool
=
_unit_placement
.
call
(
"handle_input"
,
event
)
if
handled
:
get_viewport
()
.
set_input_as_handled
()
func
_ready
()
->
void
:
## GameManager의 전역 상태 신호를 HUD 갱신 함수에 연결합니다.
# GameManager 시그널 핸들러 등록
## UI는 GameManager 상태를 직접 폴링하지 않고 신호를 통해 변경 시점에만 갱신합니다.
func
_connect_game_manager_signals
()
->
void
:
GameManager
.
gold_changed
.
connect
(
_on_gold_changed
)
GameManager
.
gold_changed
.
connect
(
_on_gold_changed
)
GameManager
.
stage_changed
.
connect
(
_on_stage_changed
)
GameManager
.
stage_changed
.
connect
(
_on_stage_changed
)
GameManager
.
active_monster_count_changed
.
connect
(
_on_monster_changed
)
GameManager
.
active_monster_count_changed
.
connect
(
_on_monster_changed
)
GameManager
.
game_lost
.
connect
(
_on_game_lost
)
GameManager
.
game_lost
.
connect
(
_on_game_lost
)
GameManager
.
game_victory
.
connect
(
_on_game_victory
)
GameManager
.
game_victory
.
connect
(
_on_game_victory
)
# UI 버튼 바인딩
## 유닛 배치 전담 객체를 생성하고 HUD/버튼/기본 씬 참조를 주입합니다.
_spawn_button_default_text
=
spawn_button
.
text
func
_setup_unit_placement
()
->
void
:
spawn_button
.
pressed
.
connect
(
_on_spawn_pressed
)
_unit_placement
=
HudUnitPlacementScript
.
new
()
if
_unit_placement
.
has_method
(
"setup"
):
_unit_placement
.
call
(
"setup"
,
self
,
unit_spawn_button
,
base_unit_scene
)
if
_unit_placement
.
has_method
(
"warmup_overlay_pool_deferred"
):
_unit_placement
.
call
(
"warmup_overlay_pool_deferred"
)
## hud.tscn에 이미 존재하는 정적 버튼들의 클릭 신호를 연결합니다.
func
_bind_static_buttons
()
->
void
:
unit_spawn_button
.
pressed
.
connect
(
_on_spawn_pressed
)
restart_button
.
pressed
.
connect
(
_on_restart_pressed
)
restart_button
.
pressed
.
connect
(
_on_restart_pressed
)
# 초기 화면 레이아웃 설정
## 씬 진입 직후 현재 GameManager 상태를 상단 HUD와 결과 팝업에 반영합니다.
func
_initialize_status_labels
()
->
void
:
result_popup
.
visible
=
false
result_popup
.
visible
=
false
_on_gold_changed
(
GameManager
.
player_gold
)
_on_gold_changed
(
GameManager
.
player_gold
)
_on_stage_changed
(
GameManager
.
current_stage
)
_on_stage_changed
(
GameManager
.
current_stage
)
_on_monster_changed
(
GameManager
.
active_monster_count
)
_on_monster_changed
(
GameManager
.
active_monster_count
)
# 소환사 노드 동적 생성 및 바인딩 (선택 전 데이터 주입 보류)
## 소환사 노드를 생성하고 선택 데이터가 없으면 디버그 기본값 또는 선택 씬으로 보냅니다.
## 선택 데이터가 준비되면 소환사 UI를 구성하고 StageManager 시작 신호까지 이어집니다.
func
_setup_summoner_flow
()
->
void
:
var
summoner_script
:
GDScript
=
preload
(
"res://src/entities/summoner/summoner.gd"
)
var
summoner_script
:
GDScript
=
preload
(
"res://src/entities/summoner/summoner.gd"
)
_summoner
=
summoner_script
.
new
()
as
Summoner
_summoner
=
summoner_script
.
new
()
as
Summoner
_summoner
.
name
=
"ActiveSummoner"
_summoner
.
name
=
"ActiveSummoner"
add_child
(
_summoner
)
add_child
(
_summoner
)
# 선택된 소환사 데이터가 존재하는지 검증 (디버그 모드 대안 포함)
if
not
GameManager
.
selected_summoner_data
:
if
not
GameManager
.
selected_summoner_data
:
if
GameManager
.
should_skip_selection_flow
():
if
GameManager
.
should_skip_selection_flow
():
GameManager
.
apply_debug_selection_defaults
()
GameManager
.
apply_debug_selection_defaults
()
...
@@ -75,12 +101,15 @@ func _ready() -> void:
...
@@ -75,12 +101,15 @@ func _ready() -> void:
SceneChanger
.
change_scene_to_file
(
"res://src/ui/summoner_selection/summoner_selection.tscn"
)
SceneChanger
.
change_scene_to_file
(
"res://src/ui/summoner_selection/summoner_selection.tscn"
)
return
return
# 소환사 데이터 초기 셋업 가동
_on_summoner_selected
(
GameManager
.
selected_summoner_data
)
_on_summoner_selected
(
GameManager
.
selected_summoner_data
)
# 몬스터 생성 일시 정지, 게임 재시작 및 모든 유닛 삭제 버튼 런타임 생성
## BottomBar에 런타임 전용 버튼들을 추가합니다.
## Pause/Restart/Clear는 HUD가 직접 처리하고, 영웅 버튼과 패널은 HudHeroPanel에 위임합니다.
func
_create_runtime_bottom_buttons
()
->
void
:
var
bottom_bar
:
Control
=
get_node_or_null
(
"BottomBar"
)
as
Control
var
bottom_bar
:
Control
=
get_node_or_null
(
"BottomBar"
)
as
Control
if
bottom_bar
:
if
not
bottom_bar
:
return
_pause_button
=
Button
.
new
()
_pause_button
=
Button
.
new
()
_pause_button
.
name
=
"PauseSpawningButton"
_pause_button
.
name
=
"PauseSpawningButton"
_pause_button
.
text
=
"Pause Spawning"
_pause_button
.
text
=
"Pause Spawning"
...
@@ -99,652 +128,74 @@ func _ready() -> void:
...
@@ -99,652 +128,74 @@ func _ready() -> void:
bottom_bar
.
add_child
(
clear_units_button
)
bottom_bar
.
add_child
(
clear_units_button
)
clear_units_button
.
pressed
.
connect
(
_on_clear_units_pressed
)
clear_units_button
.
pressed
.
connect
(
_on_clear_units_pressed
)
# 영웅 소환 버튼 동적 생성
_hero_panel
=
HudHeroPanelScript
.
new
()
hero_spawn_button
=
Button
.
new
()
if
_hero_panel
.
has_method
(
"setup"
):
hero_spawn_button
.
name
=
"SummonHeroButton"
_hero_panel
.
call
(
"setup"
,
self
,
bottom_bar
,
_unit_placement
)
hero_spawn_button
.
text
=
"Summon Hero (500 G)"
# 멋진 보라색 영웅 테두리/에너지 느낌의 테마 스타일 적용
var
sb
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
sb
.
bg_color
=
Color
(
0.35
,
0.1
,
0.45
,
0.9
)
sb
.
set_corner_radius_all
(
4
)
hero_spawn_button
.
add_theme_stylebox_override
(
"normal"
,
sb
)
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
:
## 골드 UI 갱신 및 골드 기반 버튼 활성 상태를 반영합니다.
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
:
func
_on_gold_changed
(
gold
:
int
)
->
void
:
gold_label
.
text
=
"GOLD: "
+
str
(
gold
)
+
" G"
gold_label
.
text
=
"GOLD: "
+
str
(
gold
)
+
" G"
# 골드가 부족하면 유닛 소환 버튼 비활성화
unit_spawn_button
.
disabled
=
gold
<
GameManager
.
unit_spawn_cost
spawn_button
.
disabled
=
(
gold
<
GameManager
.
unit_spawn_cost
)
if
_hero_panel
and
_hero_panel
.
has_method
(
"update_gold"
):
if
hero_spawn_button
:
_hero_panel
.
call
(
"update_gold"
,
gold
)
hero_spawn_button
.
disabled
=
(
gold
<
HERO_SPAWN_COST
)
#
스테이지 UI 갱신
#
# 현재 스테이지 텍스트를 갱신합니다.
func
_on_stage_changed
(
stage
:
int
)
->
void
:
func
_on_stage_changed
(
stage
:
int
)
->
void
:
stage_label
.
text
=
"STAGE: "
+
str
(
stage
)
+
" / "
+
str
(
GameManager
.
MAX_STAGE
)
stage_label
.
text
=
"STAGE: "
+
str
(
stage
)
+
" / "
+
str
(
GameManager
.
MAX_STAGE
)
#
필드 몬스터 개수 UI 갱신
#
# 필드에 남아 있는 몬스터 수를 갱신합니다.
func
_on_monster_changed
(
count
:
int
)
->
void
:
func
_on_monster_changed
(
count
:
int
)
->
void
:
monster_label
.
text
=
"MONSTERS: "
+
str
(
count
)
+
" / "
+
str
(
GameManager
.
DEFEAT_MONSTER_LIMIT
)
monster_label
.
text
=
"MONSTERS: "
+
str
(
count
)
+
" / "
+
str
(
GameManager
.
DEFEAT_MONSTER_LIMIT
)
#
게임 오버 시 팝업 현시
#
# 게임 패배 신호를 받으면 결과 팝업을 표시합니다.
func
_on_game_lost
()
->
void
:
func
_on_game_lost
()
->
void
:
result_title
.
text
=
"GAME OVER
\n
Monster Limit Exceeded!"
result_title
.
text
=
"GAME OVER
\n
Monster Limit Exceeded!"
result_popup
.
visible
=
true
result_popup
.
visible
=
true
#
게임 승리 시 팝업 현시
#
# 게임 승리 신호를 받으면 결과 팝업을 표시합니다.
func
_on_game_victory
()
->
void
:
func
_on_game_victory
()
->
void
:
result_title
.
text
=
"VICTORY!
\n
You Survived All Stages!"
result_title
.
text
=
"VICTORY!
\n
You Survived All Stages!"
result_popup
.
visible
=
true
result_popup
.
visible
=
true
# 유닛 무작위 소환 수행
## 일반 유닛 소환 버튼 클릭 처리입니다.
## 실제 유닛 선정과 타일 배치 대기 전환은 HudUnitPlacement가 담당합니다.
func
_on_spawn_pressed
()
->
void
:
func
_on_spawn_pressed
()
->
void
:
if
GameManager
.
is_game_over
or
_has_pending_unit_placement
():
if
_unit_placement
and
_unit_placement
.
has_method
(
"begin_random_unit_placement"
):
return
_unit_placement
.
call
(
"begin_random_unit_placement"
)
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
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
)
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
# 상시 노출되는 게임 재시작 버튼 클릭 처리
func
_on_restart_game_pressed
()
->
void
:
func
_on_restart_game_pressed
()
->
void
:
GameManager
.
restart_game
()
GameManager
.
restart_game
()
#
모든 유닛 삭제 버튼 클릭 처리
#
# 모든 유닛 삭제 버튼 클릭 처리입니다.
func
_on_clear_units_pressed
()
->
void
:
func
_on_clear_units_pressed
()
->
void
:
GameManager
.
clear_all_units
()
GameManager
.
clear_all_units
()
#
게임 결과 팝업 내 재시작 버튼 클릭 처리
#
# 결과 팝업 내부 재시작 버튼 클릭 처리입니다.
func
_on_restart_pressed
()
->
void
:
func
_on_restart_pressed
()
->
void
:
GameManager
.
restart_game
()
GameManager
.
restart_game
()
# 몬스터 스폰 일시정지 버튼 클릭 처리
## 몬스터 스폰 일시정지 버튼 클릭 처리입니다.
## StageManager와는 직접 강결합하지 않고, 메서드 존재 여부를 확인한 뒤 호출합니다.
func
_on_pause_spawning_pressed
()
->
void
:
func
_on_pause_spawning_pressed
()
->
void
:
if
not
_pause_button
:
if
not
_pause_button
:
return
return
var
stage_mgr
:
Node
=
get_node_or_null
(
"../StageManager"
)
if
stage_mgr
and
stage_mgr
.
has_method
(
"toggle_spawning_pause"
):
var
is_paused
:
bool
=
stage_mgr
.
call
(
"toggle_spawning_pause"
)
if
is_paused
:
_pause_button
.
text
=
"Resume Spawning"
else
:
_pause_button
.
text
=
"Pause Spawning"
var
stage_manager
:
Node
=
get_node_or_null
(
"../StageManager"
)
if
stage_manager
and
stage_manager
.
has_method
(
"toggle_spawning_pause"
):
var
is_paused
:
bool
=
stage_manager
.
call
(
"toggle_spawning_pause"
)
_pause_button
.
text
=
"Resume Spawning"
if
is_paused
else
"Pause Spawning"
## 소환사 선택 완료 후
UI 구성 및 스테이지 타이머 시작
## 소환사 선택 완료 후
소환사 데이터 셋업, 소환사 UI 구성, 스테이지 시작을 순서대로 처리합니다.
func
_on_summoner_selected
(
data
:
SummonerData
)
->
void
:
func
_on_summoner_selected
(
data
:
SummonerData
)
->
void
:
# 1. 소환사 데이터 실 셋업 가동
_summoner
.
setup_summoner
(
data
)
_summoner
.
setup_summoner
(
data
)
# 2. 인게임 소환사 UI 런타임 조립하여 BottomBar에 배치
var
bottom_bar
:
Control
=
get_node_or_null
(
"BottomBar"
)
as
Control
var
bottom_bar
:
Control
=
get_node_or_null
(
"BottomBar"
)
as
Control
if
bottom_bar
:
if
bottom_bar
:
var
summoner_ui
:
HBoxContainer
=
HBoxContainer
.
new
()
_summoner_panel
=
HudSummonerPanelScript
.
new
()
summoner_ui
.
name
=
"SummonerUI"
if
_summoner_panel
.
has_method
(
"build"
):
summoner_ui
.
add_theme_constant_override
(
"separation"
,
10
)
_summoner_panel
.
call
(
"build"
,
bottom_bar
,
_summoner
,
data
)
# 소환사 초상화
var
portrait
:
TextureRect
=
TextureRect
.
new
()
portrait
.
name
=
"SummonerPortrait"
portrait
.
custom_minimum_size
=
Vector2
(
48
,
48
)
portrait
.
expand_mode
=
TextureRect
.
EXPAND_IGNORE_SIZE
portrait
.
stretch_mode
=
TextureRect
.
STRETCH_KEEP_ASPECT_COVERED
if
data
.
portrait_texture
:
portrait
.
texture
=
data
.
portrait_texture
summoner_ui
.
add_child
(
portrait
)
# 마나 정보 그룹
var
info_group
:
VBoxContainer
=
VBoxContainer
.
new
()
info_group
.
name
=
"InfoGroup"
info_group
.
custom_minimum_size
=
Vector2
(
110
,
48
)
var
mana_bar
:
ProgressBar
=
ProgressBar
.
new
()
mana_bar
.
name
=
"ManaBar"
mana_bar
.
show_percentage
=
false
mana_bar
.
custom_minimum_size
=
Vector2
(
110
,
14
)
var
sb_bg
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
sb_bg
.
bg_color
=
Color
(
0.12
,
0.12
,
0.12
,
0.8
)
sb_bg
.
set_corner_radius_all
(
2
)
var
sb_fg
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
sb_fg
.
bg_color
=
Color
(
0.15
,
0.45
,
0.85
,
1.0
)
sb_fg
.
set_corner_radius_all
(
2
)
mana_bar
.
add_theme_stylebox_override
(
"background"
,
sb_bg
)
mana_bar
.
add_theme_stylebox_override
(
"fill"
,
sb_fg
)
info_group
.
add_child
(
mana_bar
)
var
mana_label
:
Label
=
Label
.
new
()
mana_label
.
name
=
"ManaLabel"
mana_label
.
text
=
"MP:
%.1f
/
%.1f
"
%
[
data
.
max_mana
,
data
.
max_mana
]
mana_label
.
add_theme_font_size_override
(
"font_size"
,
10
)
info_group
.
add_child
(
mana_label
)
summoner_ui
.
add_child
(
info_group
)
# 스펠 주문 버튼 그룹
var
spell_group
:
HBoxContainer
=
HBoxContainer
.
new
()
spell_group
.
name
=
"SpellGroup"
spell_group
.
add_theme_constant_override
(
"separation"
,
5
)
var
btn_haste
:
Button
=
Button
.
new
()
btn_haste
.
name
=
"SpellHaste"
btn_haste
.
text
=
"Haste (40 MP)"
var
haste_icon_path
:
String
=
"res://assets/ui/summoner/spells/spell_haste.png"
if
ResourceLoader
.
exists
(
haste_icon_path
):
btn_haste
.
icon
=
load
(
haste_icon_path
)
as
Texture2D
btn_haste
.
expand_icon
=
true
btn_haste
.
custom_minimum_size
=
Vector2
(
90
,
45
)
else
:
btn_haste
.
custom_minimum_size
=
Vector2
(
90
,
30
)
btn_haste
.
pressed
.
connect
(
func
()
->
void
:
_summoner
.
cast_spell
(
"haste"
))
spell_group
.
add_child
(
btn_haste
)
var
btn_smite
:
Button
=
Button
.
new
()
btn_smite
.
name
=
"SpellSmite"
btn_smite
.
text
=
"Smite (50 MP)"
var
smite_icon_path
:
String
=
"res://assets/ui/summoner/spells/spell_smite.png"
if
ResourceLoader
.
exists
(
smite_icon_path
):
btn_smite
.
icon
=
load
(
smite_icon_path
)
as
Texture2D
btn_smite
.
expand_icon
=
true
btn_smite
.
custom_minimum_size
=
Vector2
(
90
,
45
)
else
:
btn_smite
.
custom_minimum_size
=
Vector2
(
90
,
30
)
btn_smite
.
pressed
.
connect
(
func
()
->
void
:
_summoner
.
cast_spell
(
"smite"
))
spell_group
.
add_child
(
btn_smite
)
summoner_ui
.
add_child
(
spell_group
)
# UI 조립
bottom_bar
.
add_child
(
summoner_ui
)
bottom_bar
.
move_child
(
summoner_ui
,
0
)
# 신호 연결
_summoner
.
mana_changed
.
connect
(
func
(
current
:
float
,
max_v
:
float
)
->
void
:
mana_bar
.
max_value
=
max_v
mana_bar
.
value
=
current
mana_label
.
text
=
"MP:
%.1f
/
%.1f
"
%
[
current
,
max_v
]
btn_haste
.
disabled
=
(
current
<
40.0
or
_summoner
.
spell_cooldowns
.
get
(
"haste"
,
0.0
)
>
0.0
)
btn_smite
.
disabled
=
(
current
<
50.0
or
_summoner
.
spell_cooldowns
.
get
(
"smite"
,
0.0
)
>
0.0
)
)
_summoner
.
spell_cooldown_changed
.
connect
(
func
(
spell_name
:
String
,
remaining
:
float
,
duration
:
float
)
->
void
:
var
btn
:
Button
=
btn_haste
if
spell_name
==
"haste"
else
btn_smite
var
label_text
:
String
=
"Haste"
if
spell_name
==
"haste"
else
"Smite"
var
cost
:
float
=
40.0
if
spell_name
==
"haste"
else
50.0
if
remaining
>
0.0
:
btn
.
text
=
"
%s
\n
(
%.1f
s)"
%
[
label_text
,
remaining
]
btn
.
disabled
=
true
else
:
btn
.
text
=
"
%s
(
%d
MP)"
%
[
label_text
,
cost
]
if
_summoner
.
current_mana
>=
cost
:
btn
.
disabled
=
false
)
# 강제 GUI 갱신용으로 신호 1회 수동 트리거
_summoner
.
mana_changed
.
emit
(
_summoner
.
current_mana
,
_summoner
.
max_mana
)
# 3. StageManager를 찾아 수동 게임 시작 기동
var
stage_mgr
:
Node
=
get_node_or_null
(
"../StageManager"
)
if
stage_mgr
and
stage_mgr
.
has_method
(
"start_game"
):
stage_mgr
.
call
(
"start_game"
)
# 영웅 소환 버튼 클릭 처리
func
_on_summon_hero_pressed
()
->
void
:
if
GameManager
.
is_game_over
or
_has_pending_unit_placement
():
return
if
not
GameManager
.
spend_gold
(
HERO_SPAWN_COST
):
return
# 현재 영웅 템플릿 중 첫 번째(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
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
:
# 기존 영웅 UI 패널이 존재하면 완전 제거
if
hero_panel
and
is_instance_valid
(
hero_panel
):
hero_panel
.
queue_free
()
_skill_cooldown_overlays
.
clear
()
_skill_cooldown_labels
.
clear
()
# 1. 메인 패널 생성 및 스타일 적용
hero_panel
=
Panel
.
new
()
hero_panel
.
name
=
"HeroStatusPanel"
hero_panel
.
custom_minimum_size
=
Vector2
(
290
,
120
)
# 화면 좌측 상단에 알맞은 마진 할당
hero_panel
.
position
=
Vector2
(
20
,
120
)
var
sb
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
sb
.
bg_color
=
Color
(
0.12
,
0.08
,
0.16
,
0.85
)
# 어두운 보랏빛 판타지 배경
sb
.
border_width_left
=
2
sb
.
border_width_top
=
2
sb
.
border_width_right
=
2
sb
.
border_width_bottom
=
2
sb
.
border_color
=
Color
(
0.85
,
0.12
,
0.85
,
0.9
)
# 화려한 퍼플 테두리
sb
.
set_corner_radius_all
(
6
)
hero_panel
.
add_theme_stylebox_override
(
"panel"
,
sb
)
add_child
(
hero_panel
)
# 2. HBox 컨테이너 배치 (초상화 | 상세정보)
var
hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
hbox
.
name
=
"HBox"
hbox
.
anchor_right
=
1.0
hbox
.
anchor_bottom
=
1.0
hbox
.
offset_left
=
8
hbox
.
offset_top
=
8
hbox
.
offset_right
=
-
8
hbox
.
offset_bottom
=
-
8
hbox
.
add_theme_constant_override
(
"separation"
,
12
)
hero_panel
.
add_child
(
hbox
)
# 3. 초상화 구성
var
portrait_panel
:
Panel
=
Panel
.
new
()
portrait_panel
.
custom_minimum_size
=
Vector2
(
75
,
75
)
var
port_sb
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
port_sb
.
bg_color
=
Color
(
0.05
,
0.05
,
0.05
,
1.0
)
port_sb
.
border_width_left
=
1
port_sb
.
border_width_top
=
1
port_sb
.
border_width_right
=
1
port_sb
.
border_width_bottom
=
1
port_sb
.
border_color
=
Color
(
1.0
,
0.85
,
0.0
,
0.8
)
# 황금빛 초상화 테두리
port_sb
.
set_corner_radius_all
(
4
)
portrait_panel
.
add_theme_stylebox_override
(
"panel"
,
port_sb
)
var
portrait
:
TextureRect
=
TextureRect
.
new
()
portrait
.
name
=
"Portrait"
portrait
.
anchor_right
=
1.0
portrait
.
anchor_bottom
=
1.0
portrait
.
offset_left
=
2
portrait
.
offset_top
=
2
portrait
.
offset_right
=
-
2
portrait
.
offset_bottom
=
-
2
portrait
.
expand_mode
=
TextureRect
.
EXPAND_IGNORE_SIZE
portrait
.
stretch_mode
=
TextureRect
.
STRETCH_KEEP_ASPECT_COVERED
var
p_path
:
String
=
"res://assets/ui/hero/dwarf_portrait.jpg"
if
ResourceLoader
.
exists
(
p_path
):
portrait
.
texture
=
load
(
p_path
)
as
Texture2D
portrait_panel
.
add_child
(
portrait
)
hbox
.
add_child
(
portrait_panel
)
# 4. 상세 정보 레이아웃 (VBox)
var
vbox
:
VBoxContainer
=
VBoxContainer
.
new
()
vbox
.
name
=
"VBox"
vbox
.
size_flags_horizontal
=
Control
.
SIZE_EXPAND_FILL
vbox
.
add_theme_constant_override
(
"separation"
,
4
)
hbox
.
add_child
(
vbox
)
# (1) 영웅 이름 및 레벨 표시 라벨
var
name_label
:
Label
=
Label
.
new
()
name_label
.
name
=
"NameLabel"
name_label
.
text
=
"Lv.
%d
%s
"
%
[
hero
.
level
,
hero
.
unit_data
.
unit_name
]
name_label
.
add_theme_font_size_override
(
"font_size"
,
13
)
name_label
.
add_theme_color_override
(
"font_color"
,
Color
(
1.0
,
0.9
,
0.3
,
1.0
))
# 연황금색
vbox
.
add_child
(
name_label
)
# (2) 경험치 바 그룹 (HBox)
var
xp_hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
xp_hbox
.
add_theme_constant_override
(
"separation"
,
6
)
var
xp_bar
:
ProgressBar
=
ProgressBar
.
new
()
xp_bar
.
name
=
"XPBar"
xp_bar
.
show_percentage
=
false
xp_bar
.
custom_minimum_size
=
Vector2
(
110
,
10
)
xp_bar
.
size_flags_vertical
=
Control
.
SIZE_SHRINK_CENTER
var
xp_bg
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
xp_bg
.
bg_color
=
Color
(
0.1
,
0.1
,
0.1
,
1.0
)
xp_bg
.
set_corner_radius_all
(
2
)
var
xp_fg
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
xp_fg
.
bg_color
=
Color
(
0.7
,
0.15
,
0.85
,
1.0
)
# 강렬한 마젠타/보라색 게이지
xp_fg
.
set_corner_radius_all
(
2
)
xp_bar
.
add_theme_stylebox_override
(
"background"
,
xp_bg
)
xp_bar
.
add_theme_stylebox_override
(
"fill"
,
xp_fg
)
xp_hbox
.
add_child
(
xp_bar
)
var
xp_label
:
Label
=
Label
.
new
()
xp_label
.
name
=
"XPLabel"
xp_label
.
text
=
"
%d
/
%d
"
%
[
hero
.
xp
,
hero
.
xp_to_next_level
]
xp_label
.
add_theme_font_size_override
(
"font_size"
,
9
)
xp_hbox
.
add_child
(
xp_label
)
vbox
.
add_child
(
xp_hbox
)
# (3) 스킬 단축키 그룹 (HBox)
var
skill_hbox
:
HBoxContainer
=
HBoxContainer
.
new
()
skill_hbox
.
name
=
"SkillGroup"
skill_hbox
.
add_theme_constant_override
(
"separation"
,
6
)
vbox
.
add_child
(
skill_hbox
)
# 4개 스킬 슬롯 배치 (Q, W, E, R)
var
slots
:
Array
[
String
]
=
[
"Q"
,
"W"
,
"E"
,
"R"
]
var
skill_names
:
Dictionary
=
{
"Q"
:
"Strike"
,
"W"
:
"Haste"
,
"E"
:
"Clap"
,
"R"
:
"Avatar"
}
for
slot
:
String
in
slots
:
var
btn
:
Button
=
Button
.
new
()
btn
.
name
=
"Skill_"
+
slot
btn
.
text
=
slot
btn
.
custom_minimum_size
=
Vector2
(
32
,
32
)
btn
.
add_theme_font_size_override
(
"font_size"
,
10
)
# 스킬 슬롯 배경 스타일링
var
btn_sb
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
btn_sb
.
bg_color
=
Color
(
0.2
,
0.15
,
0.3
,
1.0
)
btn_sb
.
border_width_left
=
1
btn_sb
.
border_width_top
=
1
btn_sb
.
border_width_right
=
1
btn_sb
.
border_width_bottom
=
1
btn_sb
.
border_color
=
Color
(
0.6
,
0.4
,
0.7
,
0.8
)
btn_sb
.
set_corner_radius_all
(
3
)
btn
.
add_theme_stylebox_override
(
"normal"
,
btn_sb
)
# 툴팁 힌트 세팅
btn
.
tooltip_text
=
"
%s
Skill:
%s
\n
Cooldown:
%.1f
s"
%
[
slot
,
skill_names
[
slot
],
hero
.
skill_max_cooldowns
[
slot
]]
# 클릭 시 스킬 사용 바인딩
btn
.
pressed
.
connect
(
func
()
->
void
:
if
is_instance_valid
(
hero
):
hero
.
use_skill
(
slot
)
)
# 자식 쿨다운 오버레이 컬러렉트
var
overlay
:
ColorRect
=
ColorRect
.
new
()
overlay
.
name
=
"CooldownOverlay"
overlay
.
color
=
Color
(
0.0
,
0.0
,
0.0
,
0.6
)
overlay
.
anchor_right
=
1.0
overlay
.
anchor_bottom
=
1.0
overlay
.
mouse_filter
=
Control
.
MOUSE_FILTER_IGNORE
overlay
.
visible
=
false
btn
.
add_child
(
overlay
)
_skill_cooldown_overlays
[
slot
]
=
overlay
# 자식 쿨다운 숫자 라벨
var
label
:
Label
=
Label
.
new
()
label
.
name
=
"CooldownLabel"
label
.
horizontal_alignment
=
HORIZONTAL_ALIGNMENT_CENTER
label
.
vertical_alignment
=
VERTICAL_ALIGNMENT_CENTER
label
.
anchor_right
=
1.0
label
.
anchor_bottom
=
1.0
label
.
add_theme_font_size_override
(
"font_size"
,
9
)
label
.
add_theme_color_override
(
"font_color"
,
Color
(
1.0
,
1.0
,
1.0
,
1.0
))
label
.
visible
=
false
btn
.
add_child
(
label
)
_skill_cooldown_labels
[
slot
]
=
label
skill_hbox
.
add_child
(
btn
)
# 5. 영웅 시그널 연결 (실시간 UI 데이터 주입)
hero
.
level_changed
.
connect
(
func
(
new_lvl
:
int
)
->
void
:
if
is_instance_valid
(
name_label
)
and
is_instance_valid
(
hero
):
name_label
.
text
=
"Lv.
%d
%s
"
%
[
new_lvl
,
hero
.
unit_data
.
unit_name
]
)
hero
.
xp_changed
.
connect
(
func
(
curr_xp
:
int
,
need_xp
:
int
)
->
void
:
if
is_instance_valid
(
xp_bar
)
and
is_instance_valid
(
xp_label
):
xp_bar
.
max_value
=
need_xp
xp_bar
.
value
=
curr_xp
xp_label
.
text
=
"
%d
/
%d
"
%
[
curr_xp
,
need_xp
]
)
hero
.
skill_cooldown_changed
.
connect
(
func
(
slot_name
:
String
,
remaining
:
float
,
duration
:
float
)
->
void
:
var
overlay
:
ColorRect
=
_skill_cooldown_overlays
.
get
(
slot_name
)
var
label
:
Label
=
_skill_cooldown_labels
.
get
(
slot_name
)
if
is_instance_valid
(
overlay
)
and
is_instance_valid
(
label
):
if
remaining
>
0.0
:
overlay
.
visible
=
true
label
.
visible
=
true
label
.
text
=
"
%.1f
"
%
remaining
else
:
overlay
.
visible
=
false
label
.
visible
=
false
)
# 초기 1회성 데이터 동기화 강제 발행
var
stage_manager
:
Node
=
get_node_or_null
(
"../StageManager"
)
hero
.
xp_changed
.
emit
(
hero
.
xp
,
hero
.
xp_to_next_level
)
if
stage_manager
and
stage_manager
.
has_method
(
"start_game"
):
hero
.
level_changed
.
emit
(
hero
.
level
)
stage_manager
.
call
(
"start_game"
)
src/ui/summoner_panel/hud_summoner_panel.gd
0 → 100644
View file @
45c18ca1
class_name
HudSummonerPanel
extends
RefCounted
## HUD 하단의 소환사 상태/주문 UI를 생성하고 갱신하는 보조 클래스입니다.
## - Summoner 노드의 mana_changed, spell_cooldown_changed 신호를 받아 버튼 상태를 갱신합니다.
## - HUD 본문은 소환사 노드 생성과 게임 시작 호출만 담당하고, UI 세부 조립은 이 클래스가 맡습니다.
const
HASTE_SPELL_NAME
:
String
=
"haste"
const
SMITE_SPELL_NAME
:
String
=
"smite"
const
HASTE_MANA_COST
:
float
=
40.0
const
SMITE_MANA_COST
:
float
=
50.0
const
HASTE_ICON_PATH
:
String
=
"res://assets/ui/summoner/spells/spell_haste.png"
const
SMITE_ICON_PATH
:
String
=
"res://assets/ui/summoner/spells/spell_smite.png"
var
_summoner
:
Summoner
=
null
var
_mana_bar
:
ProgressBar
=
null
var
_mana_label
:
Label
=
null
var
_haste_button
:
Button
=
null
var
_smite_button
:
Button
=
null
## 소환사 UI를 BottomBar 앞쪽에 조립하고 신호 연결까지 완료합니다.
## data는 이미 GameManager에서 선택/디버그 기본값 보정이 끝난 SummonerData여야 합니다.
func
build
(
bottom_bar
:
Control
,
summoner
:
Summoner
,
data
:
SummonerData
)
->
void
:
_summoner
=
summoner
var
summoner_ui
:
HBoxContainer
=
HBoxContainer
.
new
()
summoner_ui
.
name
=
"SummonerUI"
summoner_ui
.
add_theme_constant_override
(
"separation"
,
10
)
summoner_ui
.
add_child
(
_create_portrait
(
data
))
summoner_ui
.
add_child
(
_create_mana_info_group
(
data
))
summoner_ui
.
add_child
(
_create_spell_group
())
bottom_bar
.
add_child
(
summoner_ui
)
bottom_bar
.
move_child
(
summoner_ui
,
0
)
_connect_summoner_signals
()
# UI 생성 직후 현재 마나 값을 한 번 강제 반영합니다.
# 신호 기반 UI이므로 초기 동기화도 같은 경로를 쓰면 이후 갱신과 표시 규칙이 일관됩니다.
_summoner
.
mana_changed
.
emit
(
_summoner
.
current_mana
,
_summoner
.
max_mana
)
## 소환사 초상화 TextureRect를 생성합니다.
## 선택 데이터에 portrait_texture가 없으면 빈 영역을 유지해 레이아웃 크기만 안정적으로 잡습니다.
func
_create_portrait
(
data
:
SummonerData
)
->
TextureRect
:
var
portrait
:
TextureRect
=
TextureRect
.
new
()
portrait
.
name
=
"SummonerPortrait"
portrait
.
custom_minimum_size
=
Vector2
(
48
,
48
)
portrait
.
expand_mode
=
TextureRect
.
EXPAND_IGNORE_SIZE
portrait
.
stretch_mode
=
TextureRect
.
STRETCH_KEEP_ASPECT_COVERED
if
data
.
portrait_texture
:
portrait
.
texture
=
data
.
portrait_texture
return
portrait
## 마나 바와 숫자 라벨을 담는 세로 그룹을 생성합니다.
## ProgressBar 스타일도 여기에서 함께 설정해 HUD 본문에 시각 세부사항이 새지 않게 합니다.
func
_create_mana_info_group
(
data
:
SummonerData
)
->
VBoxContainer
:
var
info_group
:
VBoxContainer
=
VBoxContainer
.
new
()
info_group
.
name
=
"InfoGroup"
info_group
.
custom_minimum_size
=
Vector2
(
110
,
48
)
_mana_bar
=
ProgressBar
.
new
()
_mana_bar
.
name
=
"ManaBar"
_mana_bar
.
show_percentage
=
false
_mana_bar
.
custom_minimum_size
=
Vector2
(
110
,
14
)
_mana_bar
.
max_value
=
data
.
max_mana
_mana_bar
.
value
=
data
.
max_mana
var
background_style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
background_style
.
bg_color
=
Color
(
0.12
,
0.12
,
0.12
,
0.8
)
background_style
.
set_corner_radius_all
(
2
)
var
fill_style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
fill_style
.
bg_color
=
Color
(
0.15
,
0.45
,
0.85
,
1.0
)
fill_style
.
set_corner_radius_all
(
2
)
_mana_bar
.
add_theme_stylebox_override
(
"background"
,
background_style
)
_mana_bar
.
add_theme_stylebox_override
(
"fill"
,
fill_style
)
info_group
.
add_child
(
_mana_bar
)
_mana_label
=
Label
.
new
()
_mana_label
.
name
=
"ManaLabel"
_mana_label
.
text
=
"MP:
%.1f
/
%.1f
"
%
[
data
.
max_mana
,
data
.
max_mana
]
_mana_label
.
add_theme_font_size_override
(
"font_size"
,
10
)
info_group
.
add_child
(
_mana_label
)
return
info_group
## 소환사 주문 버튼 그룹을 생성합니다.
## 각 버튼은 Summoner.cast_spell()만 호출하고, 사용 가능 여부는 신호 갱신에서 계산합니다.
func
_create_spell_group
()
->
HBoxContainer
:
var
spell_group
:
HBoxContainer
=
HBoxContainer
.
new
()
spell_group
.
name
=
"SpellGroup"
spell_group
.
add_theme_constant_override
(
"separation"
,
5
)
_haste_button
=
_create_spell_button
(
"SpellHaste"
,
"Haste (40 MP)"
,
HASTE_ICON_PATH
)
_haste_button
.
pressed
.
connect
(
func
()
->
void
:
_summoner
.
cast_spell
(
HASTE_SPELL_NAME
)
)
spell_group
.
add_child
(
_haste_button
)
_smite_button
=
_create_spell_button
(
"SpellSmite"
,
"Smite (50 MP)"
,
SMITE_ICON_PATH
)
_smite_button
.
pressed
.
connect
(
func
()
->
void
:
_summoner
.
cast_spell
(
SMITE_SPELL_NAME
)
)
spell_group
.
add_child
(
_smite_button
)
return
spell_group
## 주문 버튼 하나를 생성하고 아이콘이 있으면 함께 적용합니다.
## 아이콘 파일이 없어도 버튼 크기를 유지해 UI가 갑자기 줄어들지 않게 합니다.
func
_create_spell_button
(
button_name
:
String
,
text
:
String
,
icon_path
:
String
)
->
Button
:
var
button
:
Button
=
Button
.
new
()
button
.
name
=
button_name
button
.
text
=
text
if
ResourceLoader
.
exists
(
icon_path
):
button
.
icon
=
load
(
icon_path
)
as
Texture2D
button
.
expand_icon
=
true
button
.
custom_minimum_size
=
Vector2
(
90
,
45
)
else
:
button
.
custom_minimum_size
=
Vector2
(
90
,
30
)
return
button
## 소환사 신호를 UI 갱신 함수에 연결합니다.
## 문자열 기반 연결 대신 Callable 연결을 사용해 Godot 4 스타일을 유지합니다.
func
_connect_summoner_signals
()
->
void
:
_summoner
.
mana_changed
.
connect
(
_on_mana_changed
)
_summoner
.
spell_cooldown_changed
.
connect
(
_on_spell_cooldown_changed
)
## 현재 마나와 쿨다운 상태를 함께 보고 주문 버튼 활성/비활성을 갱신합니다.
func
_on_mana_changed
(
current
:
float
,
max_value
:
float
)
->
void
:
if
not
is_instance_valid
(
_mana_bar
)
or
not
is_instance_valid
(
_mana_label
):
return
_mana_bar
.
max_value
=
max_value
_mana_bar
.
value
=
current
_mana_label
.
text
=
"MP:
%.1f
/
%.1f
"
%
[
current
,
max_value
]
_update_spell_button_enabled
(
_haste_button
,
HASTE_SPELL_NAME
,
HASTE_MANA_COST
)
_update_spell_button_enabled
(
_smite_button
,
SMITE_SPELL_NAME
,
SMITE_MANA_COST
)
## 쿨다운 숫자를 버튼 텍스트에 표시하고, 쿨다운 종료 시 마나 조건을 다시 검사합니다.
func
_on_spell_cooldown_changed
(
spell_name
:
String
,
remaining
:
float
,
_duration
:
float
)
->
void
:
var
button
:
Button
=
_haste_button
if
spell_name
==
HASTE_SPELL_NAME
else
_smite_button
var
label_text
:
String
=
"Haste"
if
spell_name
==
HASTE_SPELL_NAME
else
"Smite"
var
cost
:
float
=
HASTE_MANA_COST
if
spell_name
==
HASTE_SPELL_NAME
else
SMITE_MANA_COST
if
remaining
>
0.0
:
button
.
text
=
"
%s
\n
(
%.1f
s)"
%
[
label_text
,
remaining
]
button
.
disabled
=
true
else
:
button
.
text
=
"
%s
(
%d
MP)"
%
[
label_text
,
cost
]
_update_spell_button_enabled
(
button
,
spell_name
,
cost
)
## 마나 부족 또는 쿨다운 중이면 주문 버튼을 비활성화합니다.
func
_update_spell_button_enabled
(
button
:
Button
,
spell_name
:
String
,
mana_cost
:
float
)
->
void
:
if
not
is_instance_valid
(
button
)
or
not
is_instance_valid
(
_summoner
):
return
var
cooldown_remaining
:
float
=
_summoner
.
spell_cooldowns
.
get
(
spell_name
,
0.0
)
button
.
disabled
=
_summoner
.
current_mana
<
mana_cost
or
cooldown_remaining
>
0.0
src/ui/summoner_panel/hud_summoner_panel.gd.uid
0 → 100644
View file @
45c18ca1
uid://xv3ptedjdn2o
src/ui/unit_placement/hud_unit_placement.gd
0 → 100644
View file @
45c18ca1
extends
RefCounted
## HUD의 유닛 소환/타일 배치 흐름만 담당하는 보조 클래스입니다.
## - HUD 본문이 입력, 타일 레이캐스트, 배치 오버레이 생성까지 모두 떠안지 않도록 책임을 분리합니다.
## - 실제 노드는 hud.get_tree()를 통해 현재 전장 씬에 배치하며, hud.tscn 원본은 수정하지 않습니다.
## - 배치 대기 상태는 이 클래스 내부에만 보관하여 HUD는 "배치 중인지"만 물어보면 됩니다.
const
INVALID_TILE_CELL
:
Vector3i
=
Vector3i
(
2147483647
,
2147483647
,
2147483647
)
const
STRIKER_SCENE
:
PackedScene
=
preload
(
"res://src/entities/units/striker/striker_unit.tscn"
)
const
GUARDIAN_SCENE
:
PackedScene
=
preload
(
"res://src/entities/units/guardian/guardian_unit.tscn"
)
const
RANGER_SCENE
:
PackedScene
=
preload
(
"res://src/entities/units/ranger/ranger_unit.tscn"
)
const
MAGE_SCENE
:
PackedScene
=
preload
(
"res://src/entities/units/mage/mage_unit.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
var
_hud
:
Control
=
null
var
_unit_spawn_button
:
Button
=
null
var
_base_unit_scene
:
PackedScene
=
null
var
_spawn_button_default_text
:
String
=
""
var
_pending_unit_scene
:
PackedScene
=
null
var
_pending_unit_data
:
UnitData
=
null
var
_pending_spawn_cost
:
int
=
0
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
_is_overlay_pool_warming_up
:
bool
=
false
var
_pending_preview_panel
:
Panel
=
null
var
_pending_preview_name_label
:
Label
=
null
var
_pending_preview_role_label
:
Label
=
null
var
_pending_preview_damage_label
:
Label
=
null
var
_pending_preview_cooldown_label
:
Label
=
null
var
_pending_preview_range_label
:
Label
=
null
var
_pending_preview_cost_label
:
Label
=
null
var
_pending_preview_color_swatch
:
ColorRect
=
null
## HUD에서 필요한 참조를 주입받습니다.
## RefCounted는 씬 트리에 직접 붙지 않으므로, viewport/tree 접근은 HUD 노드를 통해 수행합니다.
func
setup
(
hud
:
Control
,
unit_spawn_button
:
Button
,
base_unit_scene
:
PackedScene
)
->
void
:
_hud
=
hud
_unit_spawn_button
=
unit_spawn_button
_base_unit_scene
=
base_unit_scene
_spawn_button_default_text
=
_unit_spawn_button
.
text
## 전장 진입 직후 타일 오버레이 풀을 미리 준비합니다.
## HUD _ready()에서 호출되며, 한 프레임 뒤 StageMap/GridMap이 준비된 시점에 사용 타일 수만큼 씬 인스턴스를 만들어 둡니다.
func
warmup_overlay_pool_deferred
()
->
void
:
if
not
_hud
or
_is_overlay_pool_warming_up
:
return
_is_overlay_pool_warming_up
=
true
await
_hud
.
get_tree
()
.
process_frame
var
grid_map
:
GridMap
=
_get_stage_grid_map
()
var
parent_scene
:
Node
=
_hud
.
get_tree
()
.
current_scene
if
not
grid_map
or
not
parent_scene
:
_is_overlay_pool_warming_up
=
false
return
var
used_cells
:
Array
=
grid_map
.
get_used_cells
()
await
_warmup_overlay_pool_in_batches
(
parent_scene
,
used_cells
.
size
())
_is_overlay_pool_warming_up
=
false
## 현재 유닛 배치 위치를 기다리는 중인지 반환합니다.
## HUD는 이 값으로 새 소환, 영웅 소환, 마우스 입력 처리 가능 여부를 판단합니다.
func
has_pending_unit_placement
()
->
bool
:
return
_pending_unit_scene
!=
null
and
_pending_unit_data
!=
null
## 마우스/터치 입력을 받아 배치 대기 중인 유닛을 선택된 타일에 놓습니다.
## 배치 상태가 아닐 때는 false를 반환해 HUD의 다른 입력 흐름을 방해하지 않습니다.
func
handle_input
(
event
:
InputEvent
)
->
bool
:
if
not
has_pending_unit_placement
():
return
false
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
)
return
true
elif
event
is
InputEventScreenTouch
:
var
touch_event
:
InputEventScreenTouch
=
event
as
InputEventScreenTouch
if
touch_event
.
pressed
:
_try_place_pending_unit
(
touch_event
.
position
)
return
true
return
false
## 현재 보유한 유닛 템플릿 중 하나를 무작위로 골라 타일 선택 대기 상태로 전환합니다.
## 실제 골드 차감은 타일 배치가 확정되는 순간 처리하여, 잘못 누른 소환 버튼이 즉시 비용을 쓰지 않게 합니다.
func
begin_random_unit_placement
()
->
void
:
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
var
random_index
:
int
=
randi
()
%
DataManager
.
unit_templates
.
size
()
var
chosen_template
:
UnitData
=
DataManager
.
unit_templates
[
random_index
]
var
target_scene
:
PackedScene
=
_get_scene_for_unit
(
chosen_template
)
_begin_unit_placement
(
target_scene
,
chosen_template
,
GameManager
.
unit_spawn_cost
)
## 유닛 ID에 대응하는 전용 씬을 반환합니다.
## 새 유닛 타입이 추가되면 이 match에만 씬 매핑을 늘리면 배치 흐름은 그대로 재사용됩니다.
func
_get_scene_for_unit
(
unit_data
:
UnitData
)
->
PackedScene
:
match
unit_data
.
unit_id
:
"Unit_Striker"
:
return
STRIKER_SCENE
"Unit_Guardian"
:
return
GUARDIAN_SCENE
"Unit_Ranger"
:
return
RANGER_SCENE
"Unit_Mage"
:
return
MAGE_SCENE
_
:
return
_base_unit_scene
## 소환 버튼을 "타일 선택" 모드로 바꾸고, 배치 가능한 모든 타일에 오버레이를 표시합니다.
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
_unit_spawn_button
.
text
=
"Select Tile"
_show_pending_unit_preview
(
unit_data
,
spawn_cost
)
_show_placement_tile_overlays
()
## 배치 대기 상태와 타일 오버레이를 함께 정리합니다.
## 골드 부족, 성공 배치, 씬 전환 등의 경로에서 동일한 복구 로직을 사용합니다.
func
clear_pending_unit_placement
()
->
void
:
_pending_unit_scene
=
null
_pending_unit_data
=
null
_pending_spawn_cost
=
0
_unit_spawn_button
.
text
=
_spawn_button_default_text
_hide_pending_unit_preview
()
_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
=
_hud
.
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
()
## 화면 좌표에 대응하는 GridMap 셀과 배치 중심 좌표를 계산합니다.
## 카메라에서 월드로 레이를 쏜 뒤, 맞은 지점 주변에서 실제 타일 셀을 찾습니다.
func
_get_tile_info_from_screen
(
screen_pos
:
Vector2
)
->
Dictionary
:
var
camera
:
Camera3D
=
_hud
.
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
}
## 레이 충돌 위치와 가장 가까운 유효 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을 찾습니다.
## 맵 씬이 NavigationRegion3D 아래에 동적으로 붙으므로 고정 NodePath 대신 재귀 탐색을 사용합니다.
func
_get_stage_grid_map
()
->
GridMap
:
var
current_scene
:
Node
=
_hud
.
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
## 이미 타일에 고정 배치된 유닛이 해당 셀을 점유 중인지 확인합니다.
## BaseUnit.place_on_tile()에서 기록한 occupied_tile_cell을 기준으로 중복 배치를 막습니다.
func
_is_tile_occupied
(
tile_cell
:
Vector3i
)
->
bool
:
var
units
:
Array
[
Node
]
=
_hud
.
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
=
_hud
.
get_tree
()
.
current_scene
if
not
grid_map
or
not
parent_scene
:
return
var
used_cells
:
Array
=
grid_map
.
get_used_cells
()
_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
()
## 타일 하나에 대응하는 오버레이 씬을 풀에서 꺼내 위치/크기/재질을 설정합니다.
## 새 MeshInstance3D나 PlaneMesh를 만들지 않고 placement_tile_overlay.tscn 인스턴스를 재사용합니다.
func
_add_placement_tile_overlay
(
grid_map
:
GridMap
,
tile_cell
:
Vector3i
)
->
void
:
var
is_occupied
:
bool
=
_is_tile_occupied
(
tile_cell
)
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
))
tile_center
.
y
+=
1.03
var
tile_size
:
Vector2
=
Vector2
(
grid_map
.
cell_size
.
x
*
0.92
,
grid_map
.
cell_size
.
z
*
0.92
)
if
overlay
.
has_method
(
"configure"
):
overlay
.
call
(
"configure"
,
tile_center
,
tile_size
,
_get_placement_material
(
is_occupied
),
is_occupied
)
_active_overlays
.
append
(
overlay
)
## 오버레이 풀이 필요한 수량만큼 준비되어 있는지 확인합니다.
## 부족한 수량만 placement_tile_overlay.tscn을 instantiate하고, 이후 배치 모드에서는 재사용합니다.
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
)
## 전장 시작 직후 오버레이 풀을 여러 프레임에 나누어 채웁니다.
## 타일 수가 많아도 한 프레임에 instantiate 비용이 몰리지 않게 하여 초기 로딩 끊김을 줄입니다.
func
_warmup_overlay_pool_in_batches
(
parent_scene
:
Node
,
required_count
:
int
)
->
void
:
_reset_overlay_pool_if_parent_changed
(
parent_scene
)
while
_overlay_pool
.
size
()
+
_active_overlays
.
size
()
<
required_count
:
var
batch_count
:
int
=
mini
(
OVERLAY_POOL_WARMUP_BATCH_SIZE
,
required_count
-
(
_overlay_pool
.
size
()
+
_active_overlays
.
size
())
)
for
index
:
int
in
range
(
batch_count
):
_add_overlay_instance_to_pool
(
parent_scene
)
await
_hud
.
get_tree
()
.
process_frame
## placement_tile_overlay.tscn 인스턴스 하나를 생성해 현재 씬에 붙이고 비활성 풀에 보관합니다.
## 런타임 생성 위치를 이 함수로 모아, 배치 모드 표시 로직에서는 instantiate를 직접 호출하지 않게 합니다.
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
)
## 현재 씬이 바뀐 경우 이전 씬에 붙어 있던 풀 참조를 버립니다.
## restart_game()처럼 씬 리로드가 일어나면 기존 노드는 함께 사라지므로 새 씬 기준으로 다시 워밍업합니다.
func
_reset_overlay_pool_if_parent_changed
(
parent_scene
:
Node
)
->
void
:
if
_overlay_pool_parent
==
parent_scene
:
return
_overlay_pool
.
clear
()
_active_overlays
.
clear
()
_overlay_pool_parent
=
parent_scene
## 비활성 오버레이 하나를 풀에서 꺼냅니다.
## 예외적으로 풀이 비어 있으면 빈 값을 반환하고, 사전에 warmup/ensure가 실패했음을 조용히 허용합니다.
func
_take_overlay_from_pool
()
->
Node
:
if
_overlay_pool
.
is_empty
():
return
null
return
_overlay_pool
.
pop_back
()
## 배치 가능/점유 상태에 맞는 오버레이 머티리얼을 반환합니다.
## 머티리얼은 최초 1회만 생성하고 이후 타일들이 공유합니다.
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
## 3D 타일 위에 얹을 반투명/비조명 머티리얼을 생성합니다.
## no_depth_test를 켜서 타일 표면과 겹칠 때 깜빡이는 z-fighting을 줄입니다.
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
## pending 상태의 유닛 정보를 우측 상단 미리보기 패널에 표시합니다.
## 실제 유닛 씬을 미리보기로 instantiate하면 BaseUnit._ready()가 실행되어 units 그룹/AI 상태에 섞일 수 있으므로,
## HUD 전용 카드에 UnitData 기반 정보만 표시해 전투 로직과 완전히 분리합니다.
func
_show_pending_unit_preview
(
unit_data
:
UnitData
,
spawn_cost
:
int
)
->
void
:
_ensure_pending_preview_panel
()
_pending_preview_color_swatch
.
color
=
unit_data
.
unit_color
_pending_preview_name_label
.
text
=
unit_data
.
unit_name
_pending_preview_role_label
.
text
=
_get_unit_role_text
(
unit_data
)
_pending_preview_damage_label
.
text
=
"DMG
%.1f
"
%
unit_data
.
damage
_pending_preview_cooldown_label
.
text
=
"CD
%.1f
s"
%
unit_data
.
cooldown
_pending_preview_range_label
.
text
=
"RNG
%.1f
m"
%
unit_data
.
attack_range
_pending_preview_cost_label
.
text
=
"COST
%d
G"
%
spawn_cost
_pending_preview_panel
.
visible
=
true
## pending 배치가 끝났거나 취소될 때 미리보기 패널을 숨깁니다.
## 패널 노드는 삭제하지 않고 보관해 다음 소환 대기 때 라벨 값만 교체합니다.
func
_hide_pending_unit_preview
()
->
void
:
if
_pending_preview_panel
and
is_instance_valid
(
_pending_preview_panel
):
_pending_preview_panel
.
visible
=
false
## 우측 상단 pending 미리보기 패널을 최초 1회 생성합니다.
## hud.tscn 원본을 직접 수정하지 않고 런타임 노드로 붙이며, 이후에는 같은 패널을 재사용합니다.
func
_ensure_pending_preview_panel
()
->
void
:
if
_pending_preview_panel
and
is_instance_valid
(
_pending_preview_panel
):
return
_pending_preview_panel
=
Panel
.
new
()
_pending_preview_panel
.
name
=
"PendingUnitPreviewPanel"
_pending_preview_panel
.
anchor_left
=
1.0
_pending_preview_panel
.
anchor_right
=
1.0
_pending_preview_panel
.
anchor_top
=
0.0
_pending_preview_panel
.
anchor_bottom
=
0.0
_pending_preview_panel
.
offset_left
=
-
260.0
_pending_preview_panel
.
offset_top
=
84.0
_pending_preview_panel
.
offset_right
=
-
20.0
_pending_preview_panel
.
offset_bottom
=
210.0
_pending_preview_panel
.
visible
=
false
_pending_preview_panel
.
mouse_filter
=
Control
.
MOUSE_FILTER_IGNORE
_pending_preview_panel
.
add_theme_stylebox_override
(
"panel"
,
_create_pending_preview_panel_style
())
_hud
.
add_child
(
_pending_preview_panel
)
var
margin
:
MarginContainer
=
MarginContainer
.
new
()
margin
.
anchor_right
=
1.0
margin
.
anchor_bottom
=
1.0
margin
.
add_theme_constant_override
(
"margin_left"
,
12
)
margin
.
add_theme_constant_override
(
"margin_top"
,
10
)
margin
.
add_theme_constant_override
(
"margin_right"
,
12
)
margin
.
add_theme_constant_override
(
"margin_bottom"
,
10
)
_pending_preview_panel
.
add_child
(
margin
)
var
root_box
:
VBoxContainer
=
VBoxContainer
.
new
()
root_box
.
add_theme_constant_override
(
"separation"
,
6
)
margin
.
add_child
(
root_box
)
var
header_row
:
HBoxContainer
=
HBoxContainer
.
new
()
header_row
.
add_theme_constant_override
(
"separation"
,
8
)
root_box
.
add_child
(
header_row
)
_pending_preview_color_swatch
=
ColorRect
.
new
()
_pending_preview_color_swatch
.
name
=
"UnitColorSwatch"
_pending_preview_color_swatch
.
custom_minimum_size
=
Vector2
(
18
,
18
)
_pending_preview_color_swatch
.
mouse_filter
=
Control
.
MOUSE_FILTER_IGNORE
header_row
.
add_child
(
_pending_preview_color_swatch
)
var
title_box
:
VBoxContainer
=
VBoxContainer
.
new
()
title_box
.
size_flags_horizontal
=
Control
.
SIZE_EXPAND_FILL
title_box
.
add_theme_constant_override
(
"separation"
,
1
)
header_row
.
add_child
(
title_box
)
_pending_preview_name_label
=
Label
.
new
()
_pending_preview_name_label
.
name
=
"UnitNameLabel"
_pending_preview_name_label
.
add_theme_font_size_override
(
"font_size"
,
15
)
_pending_preview_name_label
.
add_theme_color_override
(
"font_color"
,
Color
(
1.0
,
0.95
,
0.78
,
1.0
))
title_box
.
add_child
(
_pending_preview_name_label
)
_pending_preview_role_label
=
Label
.
new
()
_pending_preview_role_label
.
name
=
"UnitRoleLabel"
_pending_preview_role_label
.
add_theme_font_size_override
(
"font_size"
,
10
)
_pending_preview_role_label
.
add_theme_color_override
(
"font_color"
,
Color
(
0.74
,
0.84
,
0.92
,
1.0
))
title_box
.
add_child
(
_pending_preview_role_label
)
var
separator
:
HSeparator
=
HSeparator
.
new
()
root_box
.
add_child
(
separator
)
var
stats_grid
:
GridContainer
=
GridContainer
.
new
()
stats_grid
.
columns
=
2
stats_grid
.
add_theme_constant_override
(
"h_separation"
,
10
)
stats_grid
.
add_theme_constant_override
(
"v_separation"
,
4
)
root_box
.
add_child
(
stats_grid
)
_pending_preview_damage_label
=
_create_pending_preview_stat_label
()
_pending_preview_cooldown_label
=
_create_pending_preview_stat_label
()
_pending_preview_range_label
=
_create_pending_preview_stat_label
()
_pending_preview_cost_label
=
_create_pending_preview_stat_label
()
stats_grid
.
add_child
(
_pending_preview_damage_label
)
stats_grid
.
add_child
(
_pending_preview_cooldown_label
)
stats_grid
.
add_child
(
_pending_preview_range_label
)
stats_grid
.
add_child
(
_pending_preview_cost_label
)
## pending 미리보기 패널 배경 스타일을 생성합니다.
## 어두운 반투명 배경과 얇은 테두리로 전투 화면을 가리지 않으면서도 현재 대기 유닛을 식별하게 합니다.
func
_create_pending_preview_panel_style
()
->
StyleBoxFlat
:
var
style
:
StyleBoxFlat
=
StyleBoxFlat
.
new
()
style
.
bg_color
=
Color
(
0.05
,
0.08
,
0.11
,
0.86
)
style
.
border_color
=
Color
(
0.34
,
0.72
,
0.95
,
0.85
)
style
.
border_width_left
=
1
style
.
border_width_top
=
1
style
.
border_width_right
=
1
style
.
border_width_bottom
=
1
style
.
set_corner_radius_all
(
6
)
return
style
## 미리보기 스탯 라벨 공통 스타일을 생성합니다.
func
_create_pending_preview_stat_label
()
->
Label
:
var
label
:
Label
=
Label
.
new
()
label
.
add_theme_font_size_override
(
"font_size"
,
11
)
label
.
add_theme_color_override
(
"font_color"
,
Color
(
0.9
,
0.94
,
0.96
,
1.0
))
return
label
## UnitData의 원거리 여부를 플레이어가 바로 알아볼 수 있는 짧은 역할 문자열로 변환합니다.
func
_get_unit_role_text
(
unit_data
:
UnitData
)
->
String
:
if
unit_data
.
is_ranged
:
return
"Ranged Unit"
return
"Melee Unit"
src/ui/unit_placement/hud_unit_placement.gd.uid
0 → 100644
View file @
45c18ca1
uid://cuuq64au1mfit
src/ui/unit_placement/placement_tile_overlay.gd
0 → 100644
View file @
45c18ca1
extends
MeshInstance3D
## 유닛 배치 모드에서 타일 위에 표시되는 3D 오버레이 노드입니다.
## - 이 노드는 placement_tile_overlay.tscn의 루트로 사용됩니다.
## - HudUnitPlacement는 이 씬을 풀로 미리 만들어 두고, 배치 모드마다 configure()로 재사용합니다.
## - PlaneMesh 자체는 씬에 포함된 1x1 크기를 사용하고, 실제 타일 크기는 scale로 맞춰 런타임 Mesh 생성을 피합니다.
const
HIDDEN_POSITION
:
Vector3
=
Vector3
(
0.0
,
-
100.0
,
0.0
)
## 풀에서 대기 중인지 여부입니다.
## 디버깅 중 씬 트리에서 오버레이 상태를 확인할 때 쓸 수 있도록 명시적으로 보관합니다.
var
is_available_in_pool
:
bool
=
true
func
_ready
()
->
void
:
# 오버레이는 순수 표시용이므로 그림자와 입력 충돌 비용을 만들지 않습니다.
cast_shadow
=
GeometryInstance3D
.
SHADOW_CASTING_SETTING_OFF
visible
=
false
global_position
=
HIDDEN_POSITION
## 풀에서 꺼낸 오버레이를 실제 타일 위치/크기/재질로 설정하고 표시합니다.
## PlaneMesh는 기본 1x1 크기이므로 scale.x/z로 GridMap 셀 크기에 맞춥니다.
func
configure
(
tile_center
:
Vector3
,
tile_size
:
Vector2
,
material
:
Material
,
is_occupied
:
bool
)
->
void
:
is_available_in_pool
=
false
name
=
"OccupiedTileOverlay"
if
is_occupied
else
"AvailableTileOverlay"
global_position
=
tile_center
scale
=
Vector3
(
tile_size
.
x
,
1.0
,
tile_size
.
y
)
material_override
=
material
visible
=
true
## 배치 모드가 끝나면 삭제하지 않고 풀에 반환될 수 있도록 숨깁니다.
## 씬 트리에는 남아 있으므로 다음 배치 모드에서는 instantiate 없이 바로 재사용됩니다.
func
release_to_pool
()
->
void
:
is_available_in_pool
=
true
visible
=
false
global_position
=
HIDDEN_POSITION
material_override
=
null
src/ui/unit_placement/placement_tile_overlay.gd.uid
0 → 100644
View file @
45c18ca1
uid://bd00wvrr48mfi
src/ui/unit_placement/placement_tile_overlay.tscn
0 → 100644
View file @
45c18ca1
[gd_scene format=3]
[ext_resource type="Script" path="res://src/ui/unit_placement/placement_tile_overlay.gd" id="1_67clr"]
[sub_resource type="PlaneMesh" id="PlaneMesh_y7kwe"]
size = Vector2(1, 1)
[node name="PlacementTileOverlay" type="MeshInstance3D" unique_id=1113555824]
visible = false
cast_shadow = 0
mesh = SubResource("PlaneMesh_y7kwe")
script = ExtResource("1_67clr")
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