Commit 45c18ca1 authored by Gavin An's avatar Gavin An

유닛 생성 시 미리보기 추가

parent 46fd46de
extends Node3D
const ProjectilePoolScript: GDScript = preload("res://src/core/projectile_pool.gd")
## 메인 전장 월드 씬 루트 스크립트 (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:
var map_path: String = "res://src/map/stages/stage_1_map.tscn" # 기본 맵
if GameManager.selected_stage_map_path != "":
......@@ -11,6 +36,21 @@ func _enter_tree() -> void:
_load_stage_background(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:
var map_scene: PackedScene = load(map_path) as PackedScene
if map_scene:
......@@ -47,3 +87,53 @@ func _get_background_path_for_map(map_path: String) -> String:
return "res://src/map/background/swamp.tscn"
_:
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)
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)
......@@ -26,9 +26,18 @@ var spell_mana_costs: Dictionary = {
var is_initialized: bool = false
## 강타(Smite) 번개 기둥에 재사용할 메쉬 리소스입니다.
## 첫 시전 순간 CylinderMesh를 생성하지 않도록 소환사 노드 준비 시 미리 구성합니다.
var _lightning_mesh: CylinderMesh = null
## 강타(Smite) 번개 기둥에 재사용할 발광 머티리얼입니다.
## StandardMaterial3D 생성과 emission 설정 비용을 첫 스킬 사용 전에 끝내기 위한 캐시입니다.
var _lightning_material: StandardMaterial3D = null
func _ready() -> void:
# setup_summoner()를 통해 외부에서 데이터를 직접 바인딩할 때까지 대기
pass
# setup_summoner()를 통해 외부에서 데이터를 직접 바인딩할 때까지 대기합니다.
# 비주얼 이펙트 리소스는 스킬 첫 사용 끊김을 줄이기 위해 노드 준비 시점에 미리 만듭니다.
_prepare_lightning_effect_resources()
## 소환사 데이터를 주입받아 초기 셋업을 완료하는 메서드
func setup_summoner(data: SummonerData) -> void:
......@@ -177,20 +186,9 @@ func _create_lightning_strike_effect(strike_pos: Vector3) -> void:
return
# 1. 번개 기둥 역할을 할 실린더 메쉬 인스턴스 생성
var mesh_inst = MeshInstance3D.new()
var cyl_mesh = CylinderMesh.new()
cyl_mesh.top_radius = 0.05
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
var mesh_inst: MeshInstance3D = MeshInstance3D.new()
mesh_inst.mesh = _lightning_mesh
mesh_inst.material_override = _lightning_material
root.add_child(mesh_inst)
# 번개 기둥 위치 정렬: 하늘에서 내리치는 연출이므로 y 오프셋
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:
if is_instance_valid(flash_light):
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
......@@ -437,12 +437,6 @@ func _show_attack_visual() -> void:
## 모델별 기본 공격 애니메이션 이름을 반환합니다.
func _get_attack_animation_name(model_name: String) -> String:
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 애니메이션 트랙 제어 (상속 클래스에서 재정의)
......@@ -456,3 +450,13 @@ func _setup_unit_visuals() -> void:
## 원거리 유닛의 발사체 생성을 처리하는 가상 함수
func _spawn_projectile(_fire_position: Vector3) -> void:
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]
......@@ -13,6 +13,20 @@ var color: Color = Color(1.0, 0.9, 0.2, 1.0) # 기본 노란색 발사체
# 커스텀 비주얼 씬 (마법사 불덩이, 레인저 화살 등 외부 씬 연동 지원)
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:
if custom_visual_scene:
_setup_custom_visual()
......@@ -21,8 +35,8 @@ func _ready() -> void:
## 커스텀 비주얼 씬 인스턴싱하여 자식으로 장착
func _setup_custom_visual() -> void:
var visual_inst: Node3D = custom_visual_scene.instantiate() as Node3D
add_child(visual_inst)
_visual_inst = custom_visual_scene.instantiate() as Node3D
add_child(_visual_inst)
## 구체 빛 구슬 비주얼 설정 (Ranger 이외 유닛 또는 arrow 로드 실패 시 Fallback)
......@@ -41,15 +55,62 @@ func _setup_sphere_visual() -> void:
mesh_instance.mesh = sphere_mesh
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:
if not _is_active:
return
if GameManager.is_game_over:
queue_free()
_despawn()
return
# 타겟이 도중에 사라지거나 사망 시 소멸
if not is_instance_valid(target) or target.get("is_dead") == true:
queue_free()
_despawn()
return
# 타겟 높이 오프셋 보정 (몬스터 중심부 y ~= 0.5를 향하도록)
......@@ -79,4 +140,31 @@ func _impact() -> void:
target.call("take_damage", damage)
# 충돌 이펙트 (필요 시 연출)
queue_free()
_despawn()
## 발사체 수명이 끝났을 때 풀로 반환하거나, 풀이 없으면 기존처럼 삭제합니다.
func _despawn() -> void:
if projectile_pool and projectile_pool.has_method("release_projectile"):
projectile_pool.call("release_projectile", self)
else:
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)
......@@ -112,6 +112,22 @@ func _update_animations() -> void:
## 마법사(Mage) 전용 화염 불덩이(Fireball) 발사체를 월드에 스폰하는 함수
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 스크립트를 로드하여 발사체 인스턴스를 동적 생성합니다.
var projectile_script: = preload("res://src/entities/units/base/projectile.gd")
var proj: Node3D = projectile_script.new() as Node3D
......
......@@ -92,6 +92,22 @@ func _update_animations() -> 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 proj: Node3D = projectile_script.new() as Node3D
......
......@@ -115,4 +115,4 @@ waterDefinition = SubResource("WaterResource_gbjga")
terrainZones = SubResource("ZonesResource_aj3fk")
physics_interpolation_mode = 2
top_level = true
metadata/_edit_lock_ = true
metadata/_edit_lock_ = false
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\nCooldown: %.1fs" % [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
extends Control
## 메인 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")
const INVALID_TILE_CELL: Vector3i = Vector3i(2147483647, 2147483647, 2147483647)
# UI 노드 자동 캐싱
## 상단 상태 표시 UI 노드 캐시입니다.
## hud.tscn에 이미 배치된 노드들이므로 @onready로 안전하게 참조합니다.
@onready var gold_label: Label = $TopBar/GoldLabel
@onready var stage_label: Label = $TopBar/StageLabel
@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_title: Label = $ResultPopup/ResultTitle
@onready var restart_button: Button = $ResultPopup/RestartButton
# 몬스터 일시정지 버튼 런타임 저장 변수
var _pause_button: Button = null
var _summoner: Summoner = null
# 영웅 관리용 상태 및 UI 노드 캐시
var hero_spawn_button: Button = null
var hero_panel: Panel = null
var _active_hero: BaseHero = null
const HERO_SPAWN_COST: int = 500
var _skill_cooldown_overlays: Dictionary = {} # {"Q": ColorRect, "W": ColorRect, ...}
var _skill_cooldown_labels: Dictionary = {} # {"Q": Label, "W": Label, ...}
var _pending_unit_scene: PackedScene = null
var _pending_unit_data: UnitData = null
var _pending_spawn_cost: int = 0
var _spawn_button_default_text: String = ""
var _placement_overlay_root: Node3D = null
var _placement_available_material: StandardMaterial3D = null
var _placement_occupied_material: StandardMaterial3D = null
## 기능별 보조 객체입니다.
## 새 파일을 추가한 직후 Godot class_name 캐시 상태에 덜 의존하도록 Object 타입으로 보관합니다.
var _unit_placement: Object = null
var _summoner_panel: Object = null
var _hero_panel: Object = 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 시그널 핸들러 등록
## GameManager의 전역 상태 신호를 HUD 갱신 함수에 연결합니다.
## UI는 GameManager 상태를 직접 폴링하지 않고 신호를 통해 변경 시점에만 갱신합니다.
func _connect_game_manager_signals() -> void:
GameManager.gold_changed.connect(_on_gold_changed)
GameManager.stage_changed.connect(_on_stage_changed)
GameManager.active_monster_count_changed.connect(_on_monster_changed)
GameManager.game_lost.connect(_on_game_lost)
GameManager.game_victory.connect(_on_game_victory)
# UI 버튼 바인딩
_spawn_button_default_text = spawn_button.text
spawn_button.pressed.connect(_on_spawn_pressed)
## 유닛 배치 전담 객체를 생성하고 HUD/버튼/기본 씬 참조를 주입합니다.
func _setup_unit_placement() -> void:
_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)
# 초기 화면 레이아웃 설정
## 씬 진입 직후 현재 GameManager 상태를 상단 HUD와 결과 팝업에 반영합니다.
func _initialize_status_labels() -> void:
result_popup.visible = false
_on_gold_changed(GameManager.player_gold)
_on_stage_changed(GameManager.current_stage)
_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")
_summoner = summoner_script.new() as Summoner
_summoner.name = "ActiveSummoner"
add_child(_summoner)
# 선택된 소환사 데이터가 존재하는지 검증 (디버그 모드 대안 포함)
if not GameManager.selected_summoner_data:
if GameManager.should_skip_selection_flow():
GameManager.apply_debug_selection_defaults()
......@@ -75,676 +101,101 @@ func _ready() -> void:
SceneChanger.change_scene_to_file("res://src/ui/summoner_selection/summoner_selection.tscn")
return
# 소환사 데이터 초기 셋업 가동
_on_summoner_selected(GameManager.selected_summoner_data)
# 몬스터 생성 일시 정지, 게임 재시작 및 모든 유닛 삭제 버튼 런타임 생성
var bottom_bar: Control = get_node_or_null("BottomBar") as Control
if bottom_bar:
_pause_button = Button.new()
_pause_button.name = "PauseSpawningButton"
_pause_button.text = "Pause Spawning"
bottom_bar.add_child(_pause_button)
_pause_button.pressed.connect(_on_pause_spawning_pressed)
var restart_game_button: Button = Button.new()
restart_game_button.name = "RestartGameButton"
restart_game_button.text = "Restart Game"
bottom_bar.add_child(restart_game_button)
restart_game_button.pressed.connect(_on_restart_game_pressed)
var clear_units_button: Button = Button.new()
clear_units_button.name = "ClearUnitsButton"
clear_units_button.text = "Clear All Units"
bottom_bar.add_child(clear_units_button)
clear_units_button.pressed.connect(_on_clear_units_pressed)
# 영웅 소환 버튼 동적 생성
hero_spawn_button = Button.new()
hero_spawn_button.name = "SummonHeroButton"
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():
## BottomBar에 런타임 전용 버튼들을 추가합니다.
## Pause/Restart/Clear는 HUD가 직접 처리하고, 영웅 버튼과 패널은 HudHeroPanel에 위임합니다.
func _create_runtime_bottom_buttons() -> void:
var bottom_bar: Control = get_node_or_null("BottomBar") as Control
if not bottom_bar:
return
if event is InputEventMouseButton:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.pressed:
_try_place_pending_unit(mouse_event.position)
get_viewport().set_input_as_handled()
elif event is InputEventScreenTouch:
var touch_event: InputEventScreenTouch = event as InputEventScreenTouch
if touch_event.pressed:
_try_place_pending_unit(touch_event.position)
get_viewport().set_input_as_handled()
# 골드 UI 갱신 및 소환 한도 검증
_pause_button = Button.new()
_pause_button.name = "PauseSpawningButton"
_pause_button.text = "Pause Spawning"
bottom_bar.add_child(_pause_button)
_pause_button.pressed.connect(_on_pause_spawning_pressed)
var restart_game_button: Button = Button.new()
restart_game_button.name = "RestartGameButton"
restart_game_button.text = "Restart Game"
bottom_bar.add_child(restart_game_button)
restart_game_button.pressed.connect(_on_restart_game_pressed)
var clear_units_button: Button = Button.new()
clear_units_button.name = "ClearUnitsButton"
clear_units_button.text = "Clear All Units"
bottom_bar.add_child(clear_units_button)
clear_units_button.pressed.connect(_on_clear_units_pressed)
_hero_panel = HudHeroPanelScript.new()
if _hero_panel.has_method("setup"):
_hero_panel.call("setup", self, bottom_bar, _unit_placement)
## 골드 UI 갱신 및 골드 기반 버튼 활성 상태를 반영합니다.
func _on_gold_changed(gold: int) -> void:
gold_label.text = "GOLD: " + str(gold) + " G"
# 골드가 부족하면 유닛 소환 버튼 비활성화
spawn_button.disabled = (gold < GameManager.unit_spawn_cost)
if hero_spawn_button:
hero_spawn_button.disabled = (gold < HERO_SPAWN_COST)
unit_spawn_button.disabled = gold < GameManager.unit_spawn_cost
if _hero_panel and _hero_panel.has_method("update_gold"):
_hero_panel.call("update_gold", gold)
# 스테이지 UI 갱신
## 현재 스테이지 텍스트를 갱신합니다.
func _on_stage_changed(stage: int) -> void:
stage_label.text = "STAGE: " + str(stage) + " / " + str(GameManager.MAX_STAGE)
# 필드 몬스터 개수 UI 갱신
## 필드에 남아 있는 몬스터 수를 갱신합니다.
func _on_monster_changed(count: int) -> void:
monster_label.text = "MONSTERS: " + str(count) + " / " + str(GameManager.DEFEAT_MONSTER_LIMIT)
# 게임 오버 시 팝업 현시
## 게임 패배 신호를 받으면 결과 팝업을 표시합니다.
func _on_game_lost() -> void:
result_title.text = "GAME OVER\nMonster Limit Exceeded!"
result_popup.visible = true
# 게임 승리 시 팝업 현시
## 게임 승리 신호를 받으면 결과 팝업을 표시합니다.
func _on_game_victory() -> void:
result_title.text = "VICTORY!\nYou Survived All Stages!"
result_popup.visible = true
# 유닛 무작위 소환 수행
## 일반 유닛 소환 버튼 클릭 처리입니다.
## 실제 유닛 선정과 타일 배치 대기 전환은 HudUnitPlacement가 담당합니다.
func _on_spawn_pressed() -> 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
# 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)
if _unit_placement and _unit_placement.has_method("begin_random_unit_placement"):
_unit_placement.call("begin_random_unit_placement")
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:
GameManager.restart_game()
# 모든 유닛 삭제 버튼 클릭 처리
## 모든 유닛 삭제 버튼 클릭 처리입니다.
func _on_clear_units_pressed() -> void:
GameManager.clear_all_units()
# 게임 결과 팝업 내 재시작 버튼 클릭 처리
## 결과 팝업 내부 재시작 버튼 클릭 처리입니다.
func _on_restart_pressed() -> void:
GameManager.restart_game()
# 몬스터 스폰 일시정지 버튼 클릭 처리
## 몬스터 스폰 일시정지 버튼 클릭 처리입니다.
## StageManager와는 직접 강결합하지 않고, 메서드 존재 여부를 확인한 뒤 호출합니다.
func _on_pause_spawning_pressed() -> void:
if not _pause_button:
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:
# 1. 소환사 데이터 실 셋업 가동
_summoner.setup_summoner(data)
# 2. 인게임 소환사 UI 런타임 조립하여 BottomBar에 배치
var bottom_bar: Control = get_node_or_null("BottomBar") as Control
if bottom_bar:
var summoner_ui: HBoxContainer = HBoxContainer.new()
summoner_ui.name = "SummonerUI"
summoner_ui.add_theme_constant_override("separation", 10)
# 소환사 초상화
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(%.1fs)" % [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\nCooldown: %.1fs" % [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
)
_summoner_panel = HudSummonerPanelScript.new()
if _summoner_panel.has_method("build"):
_summoner_panel.call("build", bottom_bar, _summoner, data)
# 초기 1회성 데이터 동기화 강제 발행
hero.xp_changed.emit(hero.xp, hero.xp_to_next_level)
hero.level_changed.emit(hero.level)
var stage_manager: Node = get_node_or_null("../StageManager")
if stage_manager and stage_manager.has_method("start_game"):
stage_manager.call("start_game")
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(%.1fs)" % [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
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 %.1fs" % unit_data.cooldown
_pending_preview_range_label.text = "RNG %.1fm" % 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"
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
[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")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment