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

유닛 생성 시 미리보기 추가

parent 46fd46de
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)
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 = { ...@@ -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
...@@ -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]
...@@ -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)
...@@ -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
......
...@@ -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
......
...@@ -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_ = true metadata/_edit_lock_ = false
This diff is collapsed.
This diff is collapsed.
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
This diff is collapsed.
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