Commit 68ca2daf authored by Gavin An's avatar Gavin An

ldefense init

parents
root = true
[*]
charset = utf-8
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
# Godot 4+ specific ignores
.godot/
/android/
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# Rider에서 무시된 파일
/contentModel.xml
/.idea.ldefense.iml
/modules.xml
/projectSettingsUpdater.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/
# 쿼리 파일을 포함한 무시된 디폴트 폴더
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
</code_scheme>
</component>
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
\ No newline at end of file
# Godot 개발 에이전트 지침서 (agents.md)
이 파일은 AI 코딩 에이전트가 본 Godot 프로젝트를 분석하고 코드를 작성하기 위한 필수 규칙을 정의합니다.
## 1. 프로젝트 및 기술 스택 정보
- **엔진 버전:** Godot 4.x (GDScript)
- **주력 언어:** GDScript
- **주요 아키텍처:** 싱글톤(AutoLoad), 노드 기반 트리 구조, 신호(Signal) 시스템 활용
- **핵심 폴더 구조:**
- `res://scenes/`: 게임 씬 (.tscn) 파일
- `res://scripts/`: GDScript (.gd) 파일
- `res://assets/`: 스프라이트, 오디오, 폰트 등 리소스
- `res://addons/`: 플러그인
## 2. GDScript 코딩 스타일 및 규칙
- **정적 타이핑:** 모든 함수 매개변수와 반환 타입, 변수에 타입을 명시하십시오. (예: `var player_health: int = 100`)
- **이름 짓기 규칙:**
- 클래스 및 노드 이름: PascalCase (`class_name PlayerCharacter`)
- 변수 및 함수 이름: snake_case (`func take_damage()`)
- 상수: UPPER_SNAKE_CASE (`const MAX_SPEED: int = 400`)
- **노드 참조:** `$` 기호 대신 `@onready` 키워드를 사용하여 노드를 안전하게 캐싱하십시오.
- **신호 연결:** 문자열 기반 연결 대신 콜러블(Callable)을 사용하십시오. (예: `button.pressed.connect(_on_button_pressed)`)
## 3. 에이전트 작업 지침
1. **절대 금지:** `.tscn` (씬) 파일의 원시 JSON/텍스트 데이터를 직접 수정하지 마십시오. 노드 생성 및 설정은 Godot 에디터를 통하거나 적절한 GDScript 코드로만 수행해야 합니다.
2. **참조 확인:** 코드를 수정하기 전에 관련된 씬 파일(`.tscn`)이나 기존 스크립트의 상속 구조를 먼저 분석하십시오.
3. **3D 노드 사용:** 3D 디펜스이므로 2D 노드(`Node2D`)가 아닌 3D 노드(`Node3D`, `CharacterBody3D`, `Path3D`, `PathFollow3D`)를 사용할 것.
4. **자원 관리:** `preload()`를 적극 활용하고, 복잡한 데이터는 커스텀 `Resource`를 만들어 관리할 것.
5. **독립성 유지:** 노드는 상위 노드를 직접 참조(`get_parent()`)하지 말고, 반드시 **시그널(Signal)**을 통해 통신할 것.
## 4. 커스텀 CLI 명령 (에이전트 전용)
에이전트는 아래의 명령을 수행하여 프로젝트를 관리할 수 있습니다.
- `run game`: `godot --main-pack project.pck` 또는 에디터 실행을 통해 게임을 시작합니다.
- `lint`: GDScript 내장 linter를 실행하여 코드 오류를 점검합니다.
- `clean`: `.godot/` 임시 캐시 폴더를 비웁니다.
\ No newline at end of file
# 프로젝트 아키텍처 및 노드 구조 설계서 (architecture.md)
## 1. 프로젝트 폴더 구조 (Directory Structure)
모든 자산과 코드는 다음과 같은 구조로 분류하여 관리합니다.
ldefense/
├── .godot/
├── docs/ # 개발 문서 폴더 (.md)
│ ├── agents.md
│ ├── game_design.md
│ └── architecture.md
├── src/ # 실제 게임 소스 코드 폴더
│ ├── autoload/ # 싱글톤 (AutoLoad) 스크립트
│ ├── core/ # 메인 게임 루프 및 관리자 스크립트
│ ├── entities/ # 게임 내 객체 (유닛, 몬스터)
│ │ ├── monsters/ # 몬스터 관련 씬 및 스크립트
│ │ └── units/ # 플레이어 유닛 관련 씬 및 스크립트
│ ├── ui/ # UI 관련 씬 및 스크립트
│ └── utils/ # 기타 유틸리티 함수
└── project.godot
\ No newline at end of file
# 게임 기획서: 3D 무작위 유닛 디펜스 (game_design.md)
## 1. 핵심 컨셉 (Core Concept)
- **장르:** 3D 실시간 전략 유닛 디펜스 (3D RTS-Style Unit Defense)
- **플레이 방식:** 고정된 타워를 짓는 것이 아니라, 골드를 소모해 무작위로 유닛을 뽑고, 생성된 유닛들을 플레이어가 직접 마우스로 드래그/선택하여 컨트롤하는 디펜스 게임입니다.
- **핵심 루프:**
1. 골드를 소모하여 버튼을 눌러 5가지 유닛 중 하나를 무작위로 소환한다.
2. 소환된 유닛들을 컨트롤하여 가장자리를 무한히 도는 몬스터들을 공격한다.
3. 몬스터를 처치해 골드를 획득하고, 더 많은 유닛을 확보한다.
4. 라운드가 지날수록 강해지는 몬스터를 막아내며 총 20스테이지를 생존하면 승리한다.
---
## 2. 글로벌 플레이어 상태 및 패배 조건 (Global Player State & Defeat Condition)
이 데이터들은 전역 스크립트(`GameManager.gd`)에서 실시간으로 추적 및 관리됩니다.
| 변수명 (Variable Name) | 데이터 타입 | 초기값 | 설명 |
| :--- | :---: | :---: | :--- |
| `player_gold` | `int` | `300` | 유닛 소환 버튼을 누를 때 소모되는 재화. |
| `unit_spawn_cost` | `int` | `100` | 유닛 1마리를 무작위 소환하는 데 필요한 비용. |
| `current_stage` | `int` | `1` | 현재 진행 중인 스테이지 (1 ~ 20). |
| `active_monster_count` | `int` | `0` | 현재 필드에 존재하는 살아있는 몬스터의 총 개수. |
- **게임 패배 조건:** 필드에 생성된 몬스터의 수(`active_monster_count`)가 **200마리를 초과하는 즉시** 게임 패배 (`GAMEOVER`).
- **게임 승리 조건:** 20스테이지의 모든 몬스터를 처치하고 필드의 몬스터 수가 0이 되면 게임 승리 (`VICTORY`).
---
## 3. 환경 및 경로 시스템 (Environment & Path Loop)
- **무한 루프 경로:** 맵 지형의 가장자리를 크게 감싸는 사각형태의 `Path3D`가 배치됩니다.
- **몬스터 이동:** 몬스터들은 `PathFollow3D`를 따라 이동하며, 경로의 끝(Progress 비율 1.0)에 도달하면 사라지지 않고 **다시 시작 지점(Progress 비율 0.0)으로 리셋되어 무한하게 회전**합니다.
---
## 4. 스테이지 및 몬스터 성장 규칙 (Stage & Monster Scaling)
게임은 총 20단계의 스테이지로 구성되며, 몬스터의 체력은 선형적으로 증가합니다. 몬스터는 스테이지 시작 시 **1초 간격으로 계속해서 스폰**됩니다.
- **몬스터 체력 공식:** $HP = 100 + (Stage - 1) \times 50$
- **몬스터 스폰 간격:** 스테이지 진행 여부와 상관없이 매 1.0초마다 1마리씩 스폰.
### 스테이지별 몬스터 제원 예시
| 스테이지 (Stage) | 몬스터 체력 (HP) | 스폰 간격 (Interval) | 처치 보상 골드 (Gold) |
| :---: | :---: | :---: | :---: |
| **Stage 1** | `100` | `1.0초` | `15` |
| **Stage 2** | `150` | `1.0초` | `18` |
| **Stage 5** | `300` | `1.0초` | `25` |
| **Stage 10** | `550` | `1.0초` | `40` |
| **Stage 15** | `800` | `1.0초` | `60` |
| **Stage 20** | `1050` | `1.0초` | `100` |
---
## 5. 플레이어 유닛 레지스트리 (Player Unit Registry)
플레이어가 버튼을 누르면 아래 5가지 유닛 중 하나가 **20%의 동일한 확률(무작위)**로 생성됩니다. 스테이지별 몬스터 체력 증가량(100~1050)을 고려하여 공격력(Damage)을 다양하게 분배했습니다.
| 유닛 ID (Unit ID) | 소환 확률 | 공격력 (`float`) | 공격 속도 (Cooldown) | 이동 속도 (`float`) | 특징 (Trait) |
| :--- | :---: | :---: | :---: | :---: | :--- |
| `Unit_Striker` | `20%` | `12.0` | `0.5초` | `6.0` | 대미지는 낮지만 매우 빠른 연사형 근접 유닛 |
| `Unit_Guardian` | `20%` | `25.0` | `1.0초` | `4.0` | 가장 표준적이고 균형 잡힌 밸런스형 근접 유닛 |
| `Unit_Ranger` | `20%` | `18.0` | `0.8초` | `4.5` | 원거리에서 경로 위의 몬스터를 안전하게 타격 |
| `Unit_Slayer` | `20%` | `55.0` | `1.8초` | `3.5` | 공격 속도는 느리지만 한 방이 강력한 대형 유닛 |
| `Unit_Archmage` | `20%` | `110.0` | `3.0초` | `3.0` | 극단적으로 느린 쿨타임을 가진 고화력 마법 유닛 |
---
## 6. 조작 및 UI 시스템 (Controls & UI)
- **유닛 소환 버튼 (UI):** 화면 하단에 존재하며, 클릭 시 `player_gold``unit_spawn_cost`만큼 차감하고 맵 중앙의 소환 구역에 5종의 유닛 중 하나를 무작위 인스턴스로 생성합니다.
- **RTS식 유닛 컨트롤:**
- **선택:** 마우스 왼쪽 버튼 클릭으로 단일 유닛 선택, 또는 드래그(Box Selection)로 여러 유닛을 동시에 선택할 수 있습니다.
- **이동:** 유닛을 선택한 상태에서 맵의 바닥을 마우스 오른쪽 버튼으로 클릭하면 해당 위치로 이동합니다.
- **공격:** 유닛을 선택한 상태에서 무한 루프 경로를 돌고 있는 몬스터를 마우스 오른쪽 버튼으로 클릭하면, 해당 몬스터를 추격하며 사거리 내에 들어올 시 자동으로 공격을 시작합니다.
\ No newline at end of file
# 프로젝트 개발 체크리스트 (todo.md)
이 문서는 본 3D 무작위 유닛 디펜스 게임의 전체 개발 로드맵과 각 단계별 진척 상황을 관리합니다.
## [ ] 1단계: 기본 맵과 3D 카메라 셋업
- [ ] 프로젝트 기본 폴더 구조 생성 (`src/core`, `src/autoload`, `src/entities`, `src/ui`, `src/utils` 등)
- [ ] 메인 게임 씬 (`src/core/main.tscn`) 생성
- [ ] 3D 기본 지형 구축 (지상 바닥 및 조명, 환경 설정)
- [ ] 몬스터 이동 경로 (`Path3D`) 배치 (맵 가장자리를 도는 루프 형태)
- [ ] RTS 스타일 3D 카메라 구현 (`src/core/rts_camera.gd`)
- WASD / 방향키를 통한 화면 이동
- 마우스 휠을 통한 줌인/줌아웃
- 화면 가장자리에 마우스 위치 시 카메라 스크롤 (선택 사항)
## [ ] 2단계: 글로벌 게임 매니저와 스테이지 시스템
- [ ] 글로벌 게임 매니저 싱글톤 (`src/autoload/game_manager.gd`) 구현
- `player_gold`, `unit_spawn_cost`, `current_stage`, `active_monster_count` 등 상태 변수 관리
- 게임오버(200마리 초과) 및 승리 조건(20스테이지 클리어) 체크 루프
- [ ] 스테이지 제어 로직 구현 (`src/core/stage_manager.gd`)
- 1초 간격 몬스터 스폰 타이머 설정
- 스테이지별 몬스터 제원 (HP, 골드 보상 등) 스케일링 공식 적용
## [ ] 3단계: 몬스터 시스템 구현
- [ ] 기본 몬스터 씬 (`src/entities/monsters/base_monster.tscn`) 및 스크립트 작성
- [ ] 몬스터가 `PathFollow3D`를 따라 이동하게 하고, 끝에 도달하면 시작 지점으로 다시 루프하도록 설정
- [ ] 몬스터 피격, 체력 소진 시 소멸 및 골드 보상 획득 로직 구현
## [ ] 4단계: RTS식 유닛 선택 및 이동 (Navigation)
- [ ] 네비게이션 메시 (`NavigationRegion3D`) 맵에 추가 및 구워내기 (Bake)
- [ ] 마우스 클릭 및 드래그 박스(`Area3D` 또는 스크린 투 월드 투영 기법)를 활용한 유닛 멀티 선택 기능 구현
- [ ] 선택된 유닛이 맵 바닥 우클릭 시 네비게이션 에이전트(`NavigationAgent3D`)를 활용하여 목적지로 이동하도록 구현
## [ ] 5단계: 유닛 클래스 정의 및 전투 구현
- [ ] 유닛 기본 스키마 (`src/entities/units/base_unit.gd`) 작성 (정적 타이핑 적용)
- [ ] 5종의 유닛(Striker, Guardian, Ranger, Slayer, Archmage) 스펙 정의 (커스텀 리소스 사용 또는 스크립트 분기)
- [ ] 적 감지 범위 (`Area3D`) 내의 몬스터를 자동 검색 및 추격, 사거리 진입 시 쿨타임에 따른 자동 공격 구현
- [ ] 투사체(Ranger, Archmage용) 또는 근접 공격 이펙트 처리
## [ ] 6단계: UI 및 소환 시스템
- [ ] 메인 HUD UI 씬 (`src/ui/hud.tscn`) 구성
- 상단: 스테이지 정보, 몬스터 마릿수(0/200), 현재 골드 상태
- 하단: 유닛 소환 버튼 (100골드 소모) 및 선택된 유닛 정보 창
- [ ] 소환 버튼 클릭 시 맵 중앙의 소환 구역(Spawn Zone)에 20% 확률로 5종 유닛 중 무작위 소환
- [ ] 게임 오버 및 게임 승리 결과 팝업 UI 구현
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dg78em6cnybdi"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="ldefense"
run/main_scene="uid://bj2p5iv0c10v6"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
[autoload]
GameManager="*res://src/autoload/game_manager.gd"
[physics]
3d/physics_engine="Jolt Physics"
[rendering]
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
extends Node
## 글로벌 게임 상태 및 규칙 관리 매니저 (GameManager 싱글톤)
# 시그널 정의
signal gold_changed(new_gold: int)
signal stage_changed(new_stage: int)
signal active_monster_count_changed(new_count: int)
signal game_lost
signal game_victory
# 글로벌 플레이어 상태 변수 (정적 타이핑 적용)
var player_gold: int = 300:
set(value):
player_gold = value
gold_changed.emit(player_gold)
var unit_spawn_cost: int = 100
var current_stage: int = 1:
set(value):
current_stage = value
stage_changed.emit(current_stage)
var active_monster_count: int = 0:
set(value):
active_monster_count = value
active_monster_count_changed.emit(active_monster_count)
_check_game_over_conditions()
# 게임 종료 조건 상태
var is_game_over: bool = false
# 게임 상수 정의
const MAX_STAGE: int = 20
const DEFEAT_MONSTER_LIMIT: int = 200
func _ready() -> void:
# 초기화 시그널 전송
gold_changed.emit(player_gold)
stage_changed.emit(current_stage)
active_monster_count_changed.emit(active_monster_count)
## 골드 획득
func add_gold(amount: int) -> void:
if is_game_over:
return
player_gold += amount
## 골드 소모 (성공 시 true 반환)
func spend_gold(amount: int) -> bool:
if is_game_over:
return false
if player_gold >= amount:
player_gold -= amount
return true
return false
## 몬스터 마릿수 증가
func increment_monster_count() -> void:
active_monster_count += 1
## 몬스터 마릿수 감소
func decrement_monster_count() -> void:
active_monster_count = max(0, active_monster_count - 1)
_check_victory_condition()
## 게임오버 및 승리 판정 루프
func _check_game_over_conditions() -> void:
if is_game_over:
return
# 몬스터 개수가 200마리 초과 시 패배
if active_monster_count > DEFEAT_MONSTER_LIMIT:
is_game_over = true
game_lost.emit()
print("GAME OVER: Monster limit exceeded!")
func _check_victory_condition() -> void:
if is_game_over:
return
# 20스테이지이고, 스폰할 몬스터도 없으며 필드의 몬스터가 0이 되었을 때 승리
# (StageManager에서 20스테이지의 스폰이 끝났음을 확인한 후, 필드에 몬스터가 0이 되면 승리 처리)
# 이 판정의 트리거 보조를 위해 StageManager가 승리 조건 판단을 개시하도록 하거나
# GameManager의 변수 flag를 활용할 수 있습니다.
pass
## 게임 리셋 기능 (테스트 및 재시작용)
func reset_game() -> void:
is_game_over = false
player_gold = 300
unit_spawn_cost = 100
current_stage = 1
active_monster_count = 0
[gd_scene format=3 uid="uid://bj2p5iv0c10v6"]
[ext_resource type="Script" uid="uid://dd1xgi6hnpuvs" path="res://src/core/rts_camera.gd" id="1_rts_cam"]
[ext_resource type="Script" uid="uid://cq7r51whh6po" path="res://src/core/stage_manager.gd" id="2_stage_mgr"]
[ext_resource type="PackedScene" path="res://src/entities/monsters/base_monster.tscn" id="3_base_monster"]
[ext_resource type="Script" uid="uid://dkplkt6q8e3b5" path="res://src/core/unit_controller.gd" id="4_unit_ctrl"]
[ext_resource type="PackedScene" path="res://src/ui/hud.tscn" id="5_hud"]
[sub_resource type="NavigationMesh" id="NavigationMesh_map"]
agent_height = 1.0
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(50, 1, 50)
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
size = Vector3(50, 1, 50)
[sub_resource type="Curve3D" id="Curve3D_path"]
_data = {
"points": PackedVector3Array(0, 0, 0, 0, 0, 0, -22, 0, -22, 0, 0, 0, 0, 0, 0, 22, 0, -22, 0, 0, 0, 0, 0, 0, 22, 0, 22, 0, 0, 0, 0, 0, 0, -22, 0, 22, 0, 0, 0, 0, 0, 0, -22, 0, -22),
"tilts": PackedFloat32Array(0, 0, 0, 0, 0)
}
point_count = 5
[node name="Main" type="Node3D" unique_id=953576256]
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=873926246]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=808151930]
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 15, 0)
shadow_enabled = true
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="." unique_id=161846999]
navigation_mesh = SubResource("NavigationMesh_map")
[node name="Ground" type="StaticBody3D" parent="NavigationRegion3D" unique_id=341047909]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="NavigationRegion3D/Ground" unique_id=1875970619]
shape = SubResource("BoxShape3D_ground")
[node name="MeshInstance3D" type="MeshInstance3D" parent="NavigationRegion3D/Ground" unique_id=1715390279]
mesh = SubResource("BoxMesh_ground")
[node name="MonsterPath" type="Path3D" parent="." unique_id=432631624]
curve = SubResource("Curve3D_path")
[node name="RTSCamera" type="Camera3D" parent="." unique_id=1379842143]
transform = Transform3D(1, 0, 0, 0, 0.5, 0.866025, 0, -0.866025, 0.5, 0, 15, 10)
script = ExtResource("1_rts_cam")
[node name="StageManager" type="Node" parent="." unique_id=132524356]
script = ExtResource("2_stage_mgr")
monster_scene = ExtResource("3_base_monster")
[node name="UnitController" type="Control" parent="." unique_id=552898295]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("4_unit_ctrl")
[node name="HUD" parent="." unique_id=218723993 instance=ExtResource("5_hud")]
class_name RTSCamera
extends Camera3D
## RTS 스타일 탑다운 3D 카메라 컨트롤러
## 키보드(WASD) 및 마우스 경계 스크롤로 평면을 이동하고 마우스 휠로 줌 조작이 가능합니다.
# 이동 및 줌 속도 설정
@export var move_speed: float = 25.0
@export var zoom_speed: float = 10.0
@export var zoom_step: float = 2.0
# 줌 범위 설정
@export var min_zoom: float = 8.0
@export var max_zoom: float = 35.0
# 화면 가장자리 마우스 감지 여백 (픽셀 단위)
@export var edge_margin: float = 15.0
@export var enable_edge_scroll: bool = true
# 맵 카메라 이동 한계 설정 (바닥 크기 50x50 기준)
@export var limit_min: Vector2 = Vector2(-22.0, -22.0)
@export var limit_max: Vector2 = Vector2(22.0, 22.0)
# 현재 카메라가 바라보고 있는 바닥의 포커스 좌표 (이곳을 중심으로 회전 및 줌이 일어남)
var _target_focus: Vector3 = Vector3.ZERO
var _current_zoom: float = 20.0
var _target_zoom: float = 20.0
func _ready() -> void:
# 초기 카메라 위치를 기준으로 포커스 좌표 역계산
# pitch 각도를 60도로 설정 (main.tscn 기본값)
var angle_rad: float = deg_to_rad(60.0)
_current_zoom = global_position.y
_target_zoom = _current_zoom
# Z 오프셋 = Y / tan(60) 이므로, 포커스 타겟 Z = 카메라 Z - Z 오프셋
var z_offset: float = _current_zoom / tan(angle_rad)
_target_focus = Vector3(global_position.x, 0.0, global_position.z - z_offset)
# 초기 셋업 적용
_update_camera_transform(0.0)
func _physics_process(delta: float) -> void:
# 1. 입력 방향 계산
var direction: Vector3 = _get_input_direction()
# 2. 포커스 타겟 이동
if direction != Vector3.ZERO:
_target_focus += direction * move_speed * delta
_target_focus.x = clampf(_target_focus.x, limit_min.x, limit_max.x)
_target_focus.z = clampf(_target_focus.z, limit_min.y, limit_max.y)
# 3. 줌 레벨 선형 보간
if not is_equal_approx(_current_zoom, _target_zoom):
_current_zoom = lerpf(_current_zoom, _target_zoom, delta * zoom_speed)
# 4. 카메라 위치 및 회전 업데이트
_update_camera_transform(delta)
func _unhandled_input(event: InputEvent) -> void:
# 마우스 휠을 통한 줌 조작
if event is InputEventMouseButton:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
if mouse_event.is_pressed():
if mouse_event.button_index == MOUSE_BUTTON_WHEEL_UP:
_target_zoom = clampf(_target_zoom - zoom_step, min_zoom, max_zoom)
elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_target_zoom = clampf(_target_zoom + zoom_step, min_zoom, max_zoom)
## 입력 방향 계산 (키보드 WASD/방향키 + 마우스 경계 스크롤)
func _get_input_direction() -> Vector3:
var dir: Vector3 = Vector3.ZERO
# 키보드 입력 체크
if Input.is_key_pressed(KEY_A) or Input.is_action_pressed("ui_left"):
dir.x -= 1.0
if Input.is_key_pressed(KEY_D) or Input.is_action_pressed("ui_right"):
dir.x += 1.0
if Input.is_key_pressed(KEY_W) or Input.is_action_pressed("ui_up"):
dir.z -= 1.0
if Input.is_key_pressed(KEY_S) or Input.is_action_pressed("ui_down"):
dir.z += 1.0
# 마우스 경계 스크롤 체크
if enable_edge_scroll:
var viewport: Viewport = get_viewport()
if viewport:
var mouse_pos: Vector2 = viewport.get_mouse_position()
var viewport_size: Vector2 = viewport.get_visible_rect().size
# 마우스 포인터가 화면 크기 안에 있을 때만 스크롤 수행 (창 밖 이탈 방지)
if mouse_pos.x >= 0.0 and mouse_pos.y >= 0.0 and mouse_pos.x <= viewport_size.x and mouse_pos.y <= viewport_size.y:
if mouse_pos.x < edge_margin:
dir.x -= 1.0
elif mouse_pos.x > viewport_size.x - edge_margin:
dir.x += 1.0
if mouse_pos.y < edge_margin:
dir.z -= 1.0
elif mouse_pos.y > viewport_size.y - edge_margin:
dir.z += 1.0
return dir.normalized()
## 카메라의 Position과 Rotation을 업데이트
func _update_camera_transform(_delta: float) -> void:
# 피치 각도 60도 고정
var angle_rad: float = deg_to_rad(60.0)
var z_offset: float = _current_zoom / tan(angle_rad)
# 카메라 글로벌 좌표 설정
global_position.x = _target_focus.x
global_position.y = _current_zoom
global_position.z = _target_focus.z + z_offset
# 포커스 좌표를 바라보도록 설정
look_at(_target_focus, Vector3.UP)
uid://dd1xgi6hnpuvs
extends Node
## 스테이지 관리 및 몬스터 스폰 시스템 (StageManager)
signal stage_start_cooldown_started(duration: float)
signal stage_started(stage: int)
@export var monster_scene: PackedScene
@export var spawn_interval: float = 1.0
@export var monsters_per_stage: int = 15 # 빠른 테스트를 위해 기획보다 약간 낮추거나 15~30마리 수준 설정
# Path3D 노드 경로
@export var path_node_path: NodePath = NodePath("../MonsterPath")
var _path_node: Path3D
var _spawn_timer: Timer
var _cooldown_timer: Timer
var _monsters_spawned_this_stage: int = 0
var _is_spawning: bool = false
var _is_waiting_for_next_stage: bool = false
func _ready() -> void:
# 노드 참조 획득
if has_node(path_node_path):
_path_node = get_node(path_node_path) as Path3D
else:
push_error("StageManager: Path3D node not found at path: " + str(path_node_path))
# 스폰 타이머 설정
_spawn_timer = Timer.new()
_spawn_timer.wait_time = spawn_interval
_spawn_timer.one_shot = false
_spawn_timer.timeout.connect(_on_spawn_timer_timeout)
add_child(_spawn_timer)
# 대기 시간(쿨다운) 타이머 설정
_cooldown_timer = Timer.new()
_cooldown_timer.wait_time = 5.0
_cooldown_timer.one_shot = true
_cooldown_timer.timeout.connect(_start_stage_spawning)
add_child(_cooldown_timer)
# GameManager 시그널 구독 (스테이지 변경 및 몬스터 수 체크)
GameManager.active_monster_count_changed.connect(_on_active_monster_count_changed)
# 3초 뒤에 첫 번째 스테이지 대기 시간 가동
_start_next_stage_cooldown(3.0)
## 다음 스테이지 준비 대기 시간 시작
func _start_next_stage_cooldown(duration: float) -> void:
_is_waiting_for_next_stage = true
_cooldown_timer.wait_time = duration
_cooldown_timer.start()
stage_start_cooldown_started.emit(duration)
print("Stage Manager: Cooldown started for stage ", GameManager.current_stage, " - Duration: ", duration)
## 실제 스테이지 몬스터 스폰 가동
func _start_stage_spawning() -> void:
if GameManager.is_game_over:
return
_is_waiting_for_next_stage = false
_is_spawning = true
_monsters_spawned_this_stage = 0
stage_started.emit(GameManager.current_stage)
print("Stage Manager: Stage ", GameManager.current_stage, " Started!")
_spawn_timer.start()
## 타이머 주기마다 몬스터 스폰
func _on_spawn_timer_timeout() -> void:
if GameManager.is_game_over or not _is_spawning:
_spawn_timer.stop()
return
if not _path_node or not monster_scene:
push_error("StageManager: Path3D node or Monster scene is missing.")
_spawn_timer.stop()
return
# 몬스터 생성 및 경로 배치
var monster_instance: Node = monster_scene.instantiate()
_path_node.add_child(monster_instance)
# 몬스터 초기화 (HP, 보상 골드 설정)
var stage: int = GameManager.current_stage
var monster_hp: float = 100.0 + (stage - 1) * 50.0
var monster_gold: int = _get_monster_gold_reward(stage)
if monster_instance.has_method("initialize_monster"):
monster_instance.call("initialize_monster", monster_hp, monster_gold)
GameManager.increment_monster_count()
_monsters_spawned_this_stage += 1
# 정해진 마릿수 다 스폰하면 타이머 중지
if _monsters_spawned_this_stage >= monsters_per_stage:
_is_spawning = false
_spawn_timer.stop()
print("Stage Manager: All monsters spawned for stage ", GameManager.current_stage)
## 몬스터 잔여 수 변동 시 다음 스테이지 이동 체크
func _on_active_monster_count_changed(count: int) -> void:
if GameManager.is_game_over:
return
# 스폰이 끝났고 필드의 몬스터가 0일 때
if not _is_spawning and count == 0 and _monsters_spawned_this_stage >= monsters_per_stage and not _is_waiting_for_next_stage:
# 승리 조건 검증 (20스테이지 클리어 시 승리)
if GameManager.current_stage >= GameManager.MAX_STAGE:
GameManager.is_game_over = true
GameManager.game_victory.emit()
print("VICTORY: All stages cleared!")
else:
# 다음 스테이지로 상태 전이
GameManager.current_stage += 1
_start_next_stage_cooldown(5.0)
## 스테이지별 몬스터 처치 보상 골드 테이블 (game_design.md 참고)
func _get_monster_gold_reward(stage: int) -> int:
if stage >= 20: return 100
elif stage >= 15: return 60 + (stage - 15) * 8
elif stage >= 10: return 40 + (stage - 10) * 4
elif stage >= 5: return 25 + (stage - 5) * 3
elif stage >= 2: return 18 + (stage - 2) * 2
else: return 15
extends Control
## RTS 유닛 선택 및 이동을 통제하는 컨트롤러 (UnitController)
## 화면 전체를 덮는 Control 노드로 작동하여 드래그 박스를 렌더링하고 마우스 입력을 처리합니다.
# 마우스 및 드래그 상태 관리
var _is_dragging: bool = false
var _drag_start: Vector2 = Vector2.ZERO
var _drag_end: Vector2 = Vector2.ZERO
# 현재 선택된 유닛 리스트
var selected_units: Array[Node] = []
# 화면 드래그 박스 렌더링 색상
@export var box_color: Color = Color(0.0, 1.0, 0.0, 0.2) # 연녹색 반투명
@export var border_color: Color = Color(0.0, 1.0, 0.0, 0.8) # 녹색 테두리
@export var border_width: float = 1.5
func _ready() -> void:
# 화면 전체를 덮도록 설정
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = MOUSE_FILTER_PASS # 마우스 입력을 통과시켜 카메라 등으로 전달 가능하게 함
# 런타임에 NavigationRegion3D를 찾아 맵을 베이크시킴 (4.2 구현 보완)
var nav_region: NavigationRegion3D = get_node_or_null("../NavigationRegion3D") as NavigationRegion3D
if nav_region:
nav_region.bake_navigation_mesh()
print("UnitController: Navigation mesh baked successfully on start.")
func _gui_input(event: InputEvent) -> void:
if GameManager.is_game_over:
return
var camera: Camera3D = get_viewport().get_camera_3d()
if not camera:
return
# 마우스 좌클릭 (선택/드래그 시작)
if event is InputEventMouseButton:
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
# 마우스 왼쪽 버튼 클릭
if mouse_event.button_index == MOUSE_BUTTON_LEFT:
if mouse_event.is_pressed():
_is_dragging = true
_drag_start = mouse_event.position
_drag_end = _drag_start
else:
# 클릭 해제 시 선택 처리
_is_dragging = false
_perform_selection(_drag_start, mouse_event.position)
queue_redraw()
# 마우스 오른쪽 버튼 클릭 (이동 및 공격 지시)
elif mouse_event.button_index == MOUSE_BUTTON_RIGHT and mouse_event.is_pressed():
_perform_action(mouse_event.position)
# 마우스 드래그 중인 상태
elif event is InputEventMouseMotion and _is_dragging:
var motion_event: InputEventMouseMotion = event as InputEventMouseMotion
_drag_end = motion_event.position
queue_redraw()
func _draw() -> void:
# 드래그 영역 상자 그리기
if _is_dragging and _drag_start.distance_to(_drag_end) > 5.0:
var rect: Rect2 = Rect2(_drag_start, _drag_end - _drag_start).abs()
draw_rect(rect, box_color, true)
draw_rect(rect, border_color, false, border_width)
## 다중 및 단일 선택 수행
func _perform_selection(start: Vector2, end: Vector2) -> void:
var camera: Camera3D = get_viewport().get_camera_3d()
if not camera:
return
# 이전 선택 유닛 비활성화
for unit: Node in selected_units:
if is_instance_valid(unit) and unit.has_method("deselect"):
unit.call("deselect")
selected_units.clear()
var drag_dist: float = start.distance_to(end)
# 1. 단일 선택 처리 (Raycast 이용)
if drag_dist <= 5.0:
var ray_result: Dictionary = _raycast_from_mouse(start)
if ray_result.has("collider"):
var hit_collider: Node = ray_result["collider"] as Node
var unit_node: Node = _get_unit_node(hit_collider)
if unit_node and unit_node.is_in_group("units"):
selected_units.append(unit_node)
if unit_node.has_method("select"):
unit_node.call("select")
print("UnitController: Selected single unit: ", unit_node.name)
# 2. 드래그 다중 선택 처리 (Unproject 투영 이용)
else:
var selection_rect: Rect2 = Rect2(start, end - start).abs()
var all_units: Array[Node] = get_tree().get_nodes_in_group("units")
for unit: Node in all_units:
if is_instance_valid(unit) and unit is Node3D:
var unit_3d: Node3D = unit as Node3D
var screen_pos: Vector2 = camera.unproject_position(unit_3d.global_position)
# 화면 뷰포트 영역 내부 및 드래그 사각형 안에 포함되는지 판정
if selection_rect.has_point(screen_pos):
selected_units.append(unit)
if unit.has_method("select"):
unit.call("select")
print("UnitController: Selected ", selected_units.size(), " units via drag.")
## 우클릭 액션 (이동 목적지 지정 또는 적 공격 타겟팅)
func _perform_action(mouse_pos: Vector2) -> void:
if selected_units.is_empty():
return
# Raycast를 쏘아 바닥 또는 몬스터 감지
var ray_result: Dictionary = _raycast_from_mouse(mouse_pos)
if ray_result.is_empty():
return
var hit_pos: Vector3 = ray_result["position"]
var hit_collider: Node = ray_result["collider"] as Node
# 충돌 대상이 몬스터인지 검사
var monster_node: Node = _get_monster_node(hit_collider)
if monster_node and monster_node.is_in_group("monsters"):
# 선택된 유닛들에게 공격 타겟 명령 하달
for unit: Node in selected_units:
if is_instance_valid(unit) and unit.has_method("attack_target"):
unit.call("attack_target", monster_node)
print("UnitController: Ordered units to attack monster: ", monster_node.name)
else:
# 바닥 또는 일반 지형인 경우 해당 좌표로 이동 명령 하달
# 다중 유닛 이동 시 겹침 방지를 위해 약간의 분산(Offset)을 주면 좋습니다.
var unit_count: int = selected_units.size()
var spacing: float = 1.5
var rows: int = int(ceil(sqrt(unit_count)))
for i: int in range(unit_count):
var unit: Node = selected_units[i]
if is_instance_valid(unit) and unit.has_method("move_to"):
# 격자 정렬 오프셋 계산
var row: int = i / rows
var col: int = i % rows
var offset: Vector3 = Vector3(
(col - (rows - 1) / 2.0) * spacing,
0.0,
(row - (rows - 1) / 2.0) * spacing
)
var target_destination: Vector3 = hit_pos + offset
unit.call("move_to", target_destination)
print("UnitController: Ordered ", unit_count, " units to move to ", hit_pos)
## 마우스 포인터 방향으로 3D Raycast 실행
func _raycast_from_mouse(mouse_pos: Vector2) -> Dictionary:
var camera: Camera3D = get_viewport().get_camera_3d()
if not camera:
return {}
var from: Vector3 = camera.project_ray_origin(mouse_pos)
var to: Vector3 = from + camera.project_ray_normal(mouse_pos) * 1000.0
var space_state: PhysicsDirectSpaceState3D = camera.get_world_3d().direct_space_state
var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = true
query.collide_with_bodies = true
return space_state.intersect_ray(query)
## 충돌체로부터 유닛 노드를 식별하여 반환
func _get_unit_node(collider: Node) -> Node:
if not collider:
return null
# 유닛의 충돌체(Area3D)가 자식일 경우 부모 노드를 추적
if collider.is_in_group("units"):
return collider
var parent: Node = collider.get_parent()
if parent and parent.is_in_group("units"):
return parent
# 한 단계 더 위를 체크 (CharacterBody3D의 CollisionShape 구조 등)
var grandparent: Node = parent.get_parent() if parent else null
if grandparent and grandparent.is_in_group("units"):
return grandparent
return null
## 충돌체로부터 몬스터 노드를 식별하여 반환
func _get_monster_node(collider: Node) -> Node:
if not collider:
return null
if collider.is_in_group("monsters"):
return collider
var parent: Node = collider.get_parent()
if parent and parent.is_in_group("monsters"):
return parent
var grandparent: Node = parent.get_parent() if parent else null
if grandparent and grandparent.is_in_group("monsters"):
return grandparent
return null
class_name BaseMonster
extends PathFollow3D
## 기본 몬스터 클래스 (Path3D 상하위에서 경로를 따라 회전하며 피격/사망 처리됨)
# 몬스터 능력치 설정
@export var speed: float = 6.0
var max_hp: float = 100.0
var current_hp: float = 100.0
var gold_reward: int = 15
var is_dead: bool = false
func _ready() -> void:
# PathFollow3D 루프 설정 활성화 (경로 끝 도달 시 처음으로 돌아감)
loop = true
func _physics_process(delta: float) -> void:
if GameManager.is_game_over or is_dead:
return
# 경로 이동 거리 갱신
progress += speed * delta
## 몬스터 초기화 (StageManager에 의해 스폰 시 호출됨)
func initialize_monster(hp: float, reward: int) -> void:
max_hp = hp
current_hp = hp
gold_reward = reward
is_dead = false
## 피해 가하기
func take_damage(amount: float) -> void:
if is_dead:
return
current_hp = maxf(0.0, current_hp - amount)
print("Monster ", name, " took ", amount, " damage. HP: ", current_hp, "/", max_hp)
if current_hp <= 0.0:
_die()
## 사망 처리
func _die() -> void:
is_dead = true
# 골드 보상 획득
GameManager.add_gold(gold_reward)
# 필드 활성 몬스터 수 감소
GameManager.decrement_monster_count()
print("Monster ", name, " died! Rewarded ", gold_reward, " gold.")
# 씬에서 제거
queue_free()
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://src/entities/monsters/base_monster.gd" id="1_base_monster"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_red"]
albedo_color = Color(0.901961, 0.098039, 0.098039, 1)
roughness = 0.5
[sub_resource type="SphereMesh" id="SphereMesh_monster"]
material = SubResource("StandardMaterial3D_red")
radius = 0.5
height = 1.0
[sub_resource type="SphereShape3D" id="SphereShape3D_collision"]
radius = 0.6
[node name="BaseMonster" type="PathFollow3D"]
script = ExtResource("1_base_monster")
[node name="VisualMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
mesh = SubResource("SphereMesh_monster")
[node name="MonsterArea" type="Area3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="MonsterArea"]
shape = SubResource("SphereShape3D_collision")
class_name BaseUnit
extends CharacterBody3D
## 플레이어 유닛 기본 클래스 (RTS 식 이동, 몬스터 자동 감지 및 공격 수행)
@export var unit_data: UnitData
var is_selected: bool = false
# 전투 관련 상태
var _target_monster: Node3D = null
var _attack_timer: float = 0.0
# 자동 탐색 범위
@export var detection_range: float = 16.0
# 노드 캐싱
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
@onready var _selection_ring: MeshInstance3D = $SelectionRing
@onready var _visual_mesh: MeshInstance3D = $VisualMesh
func _ready() -> void:
# 유닛 그룹에 등록
add_to_group("units")
# 네비게이션 파라미터 조율
_nav_agent.path_desired_distance = 0.5
_nav_agent.target_desired_distance = 0.5
# 선택 상태에 맞춤 설정
deselect()
# 유닛 능력치에 따른 외형(색상) 적용
if unit_data:
_apply_unit_style()
func _physics_process(delta: float) -> void:
if GameManager.is_game_over:
velocity = Vector3.ZERO
return
# 공격 타이머 차감
if _attack_timer > 0.0:
_attack_timer -= delta
# 1. 공격 대상이 유효한지 검증 (사망했거나 삭제되었는지 체크)
if _target_monster and (not is_instance_valid(_target_monster) or _target_monster.get("is_dead")):
_target_monster = null
# 2. 상태에 따른 AI 동작 분기
if _target_monster:
_process_attack_logic(delta)
else:
_process_normal_logic(delta)
## 1. 공격 타겟이 존재할 때의 동작 로직
func _process_attack_logic(_delta: float) -> void:
if not _target_monster:
return
var distance_to_target: float = global_position.distance_to(_target_monster.global_position)
# 사거리 밖인 경우 추격 이동
if distance_to_target > unit_data.attack_range:
_nav_agent.target_position = _target_monster.global_position
_navigate_to_target(_delta)
# 사거리 이내인 경우 정지 및 공격 수행
else:
velocity = Vector3.ZERO
# 타겟 몬스터를 바라보도록 회전
_look_at_target(_target_monster.global_position)
_try_attack()
## 2. 일반 대기 및 이동 상태의 로직
func _process_normal_logic(_delta: float) -> void:
# 이동 명령을 수행 중인 경우
if not _nav_agent.is_navigation_finished():
_navigate_to_target(_delta)
else:
velocity = Vector3.ZERO
# 정지 상태에서 주변 몬스터 탐지
_find_nearest_target()
## 네비게이션 타겟 방향으로 캐릭터 이동
func _navigate_to_target(_delta: float) -> void:
if _nav_agent.is_navigation_finished():
velocity = Vector3.ZERO
return
var next_path_pos: Vector3 = _nav_agent.get_next_path_position()
var current_pos: Vector3 = global_position
var new_velocity: Vector3 = (next_path_pos - current_pos).normalized() * unit_data.move_speed
# Y축 속도는 고정하고 평면 속도만 적용
velocity = Vector3(new_velocity.x, 0.0, new_velocity.z)
move_and_slide()
# 이동 방향 바라보도록 회전
if current_pos.distance_to(next_path_pos) > 0.2:
_look_at_target(next_path_pos)
## 타겟을 부드럽게(또는 수평 기준) 바라보게 회전
func _look_at_target(target_pos: Vector3) -> void:
var look_target: Vector3 = Vector3(target_pos.x, global_position.y, target_pos.z)
if global_position.distance_to(look_target) > 0.1:
look_at(look_target, Vector3.UP)
## 범위 내 가장 가까운 살아있는 몬스터 탐색
func _find_nearest_target() -> void:
var monsters: Array[Node] = get_tree().get_nodes_in_group("monsters")
var nearest_monster: Node3D = null
var min_distance: float = detection_range
for monster: Node in monsters:
if is_instance_valid(monster) and monster is Node3D:
var m_3d: Node3D = monster as Node3D
# 몬스터의 사망 여부 검사
if m_3d.get("is_dead") == true:
continue
var dist: float = global_position.distance_to(m_3d.global_position)
if dist < min_distance:
min_distance = dist
nearest_monster = m_3d
if nearest_monster:
_target_monster = nearest_monster
## 타겟에게 피격 가함
func _try_attack() -> void:
if _attack_timer > 0.0 or not _target_monster:
return
# 공격 데미지 적용
if _target_monster.has_method("take_damage"):
_target_monster.call("take_damage", unit_data.damage)
# 쿨타임 타이머 작동
_attack_timer = unit_data.cooldown
# 공격 이펙트 처리 (원거리용 레이 트레이스 혹은 무작위 시각 반응)
_show_attack_visual()
## 공격 관련 비주얼 이펙트 연출
func _show_attack_visual() -> void:
# 원거리 타격 시 빔 또는 스파크 이펙트 임시 렌더
if unit_data.is_ranged and _target_monster:
print("Unit ", name, " fired a projectile/spell at ", _target_monster.name)
else:
print("Unit ", name, " strikes ", _target_monster.name)
## 외부에서 호출하는 수동 이동 명령
func move_to(target_pos: Vector3) -> void:
_target_monster = null # 이동 명령 시 강제 공격 해제
_nav_agent.target_position = target_pos
## 외부에서 호출하는 특정 몬스터 강제 공격 지정
func attack_target(monster: Node3D) -> void:
if is_instance_valid(monster) and not monster.get("is_dead"):
_target_monster = monster
_nav_agent.target_position = monster.global_position
## 유닛 데이터 스킨 색상 적용
func _apply_unit_style() -> void:
# VisualMesh의 material에 접근하여 색상 덧칠
if _visual_mesh and _visual_mesh.mesh:
# Material override가 있는지 확인하고 동적 생성
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = unit_data.unit_color
mat.roughness = 0.4
_visual_mesh.material_override = mat
## 유닛 선택 시 하이라이팅
func select() -> void:
is_selected = true
if _selection_ring:
_selection_ring.visible = true
## 유닛 선택 해제
func deselect() -> void:
is_selected = false
if _selection_ring:
_selection_ring.visible = false
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://src/entities/units/base_unit.gd" id="1_base_unit"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.4
height = 1.2
[sub_resource type="CapsuleMesh" id="CapsuleMesh_unit"]
radius = 0.4
height = 1.2
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ring"]
albedo_color = Color(0.098039, 0.901961, 0.098039, 1)
emission_enabled = true
emission = Color(0.098039, 0.901961, 0.098039, 1)
emission_energy_multiplier = 2.0
[sub_resource type="TorusMesh" id="TorusMesh_selection"]
material = SubResource("StandardMaterial3D_ring")
inner_radius = 0.65
outer_radius = 0.7
[node name="BaseUnit" type="CharacterBody3D"]
script = ExtResource("1_base_unit")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="VisualMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
mesh = SubResource("CapsuleMesh_unit")
[node name="SelectionRing" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.02, 0)
mesh = SubResource("TorusMesh_selection")
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
class_name UnitData
extends Resource
## 유닛의 기획 능력치를 정의하는 커스텀 리소스 (UnitData)
@export var unit_id: String = ""
@export var unit_name: String = ""
@export var damage: float = 10.0
@export var cooldown: float = 1.0
@export var move_speed: float = 4.0
@export var attack_range: float = 1.5
@export var is_ranged: bool = false
@export var unit_color: Color = Color.WHITE
extends Control
## 메인 HUD UI 제어 스크립트 (HUD)
## 플레이어 자원 현황, 게임 결과 팝업, 유닛 소환 메커니즘을 총괄합니다.
# 유닛 씬 및 데이터 프리로드
@export var base_unit_scene: PackedScene = preload("res://src/entities/units/base_unit.tscn")
# UI 노드 자동 캐싱
@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 result_popup: Panel = $ResultPopup
@onready var result_title: Label = $ResultPopup/ResultTitle
@onready var restart_button: Button = $ResultPopup/RestartButton
# 5가지 유닛 능력치 리소스 템플릿 배열
var _unit_templates: Array[UnitData] = []
func _ready() -> void:
# 5종 유닛 리소스 템플릿 빌드
_initialize_unit_templates()
# GameManager 시그널 핸들러 등록
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.pressed.connect(_on_spawn_pressed)
restart_button.pressed.connect(_on_restart_pressed)
# 초기 화면 레이아웃 설정
result_popup.visible = false
_on_gold_changed(GameManager.player_gold)
_on_stage_changed(GameManager.current_stage)
_on_monster_changed(GameManager.active_monster_count)
## 5종의 유닛 능력치 데이터 초기 셋업
func _initialize_unit_templates() -> void:
# 1. Striker (연사형 근접 유닛)
var striker: UnitData = UnitData.new()
striker.unit_id = "Unit_Striker"
striker.unit_name = "Striker"
striker.damage = 12.0
striker.cooldown = 0.5
striker.move_speed = 6.0
striker.attack_range = 1.5
striker.is_ranged = false
striker.unit_color = Color(0.12, 0.45, 0.85, 1.0) # 청색
_unit_templates.append(striker)
# 2. Guardian (밸런스형 근접 유닛)
var guardian: UnitData = UnitData.new()
guardian.unit_id = "Unit_Guardian"
guardian.unit_name = "Guardian"
guardian.damage = 25.0
guardian.cooldown = 1.0
guardian.move_speed = 4.0
guardian.attack_range = 1.5
guardian.is_ranged = false
guardian.unit_color = Color(0.85, 0.65, 0.12, 1.0) # 황금색
_unit_templates.append(guardian)
# 3. Ranger (원거리 유닛)
var ranger: UnitData = UnitData.new()
ranger.unit_id = "Unit_Ranger"
ranger.unit_name = "Ranger"
ranger.damage = 18.0
ranger.cooldown = 0.8
ranger.move_speed = 4.5
ranger.attack_range = 10.0
ranger.is_ranged = true
ranger.unit_color = Color(0.12, 0.75, 0.25, 1.0) # 녹색
_unit_templates.append(ranger)
# 4. Slayer (고화력 대형 유닛)
var slayer: UnitData = UnitData.new()
slayer.unit_id = "Unit_Slayer"
slayer.unit_name = "Slayer"
slayer.damage = 55.0
slayer.cooldown = 1.8
slayer.move_speed = 3.5
slayer.attack_range = 1.6
slayer.is_ranged = false
slayer.unit_color = Color(0.85, 0.12, 0.12, 1.0) # 진적색
_unit_templates.append(slayer)
# 5. Archmage (극단적 극딜 마법 유닛)
var archmage: UnitData = UnitData.new()
archmage.unit_id = "Unit_Archmage"
archmage.unit_name = "Archmage"
archmage.damage = 110.0
archmage.cooldown = 3.0
archmage.move_speed = 3.0
archmage.attack_range = 12.0
archmage.is_ranged = true
archmage.unit_color = Color(0.55, 0.12, 0.85, 1.0) # 보라색
_unit_templates.append(archmage)
# 골드 UI 갱신 및 소환 한도 검증
func _on_gold_changed(gold: int) -> void:
gold_label.text = "GOLD: " + str(gold) + " G"
# 골드가 부족하면 유닛 소환 버튼 비활성화
spawn_button.disabled = (gold < GameManager.unit_spawn_cost)
# 스테이지 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
# 유닛 무작위 소환 수행
func _on_spawn_pressed() -> void:
if GameManager.is_game_over:
return
# 골드 비용 소모 시도
if GameManager.spend_gold(GameManager.unit_spawn_cost):
# 20% 동일 확률로 5종 유닛 중 무작위 1개 선정
var random_index: int = randi() % _unit_templates.size()
var chosen_template: UnitData = _unit_templates[random_index]
# 유닛 객체 스폰
var new_unit: Node = base_unit_scene.instantiate()
var parent_scene: Node = get_tree().current_scene
# 맵 중앙 스폰 존(0, 0) 기준 분산 스폰 좌표 지정
var spawn_offset: Vector3 = Vector3(
randf_range(-2.0, 2.0),
0.1,
randf_range(-2.0, 2.0)
)
# 스폰 위치 설정 및 능력치 주입
if new_unit is Node3D:
(new_unit as Node3D).global_position = spawn_offset
new_unit.set("unit_data", chosen_template)
parent_scene.add_child(new_unit)
print("Spawned random unit: ", chosen_template.unit_name, " at position ", spawn_offset)
# 게임 재시작 기능
func _on_restart_pressed() -> void:
# 필드 위 모든 유닛 제거
var units: Array[Node] = get_tree().get_nodes_in_group("units")
for unit: Node in units:
if is_instance_valid(unit):
unit.queue_free()
# 필드 위 모든 몬스터 제거
var monsters: Array[Node] = get_tree().get_nodes_in_group("monsters")
for monster: Node in monsters:
if is_instance_valid(monster):
monster.queue_free()
# GameManager 상태 초기화
GameManager.reset_game()
result_popup.visible = false
# 스테이지 재베이킹 및 스폰 리셋을 위해 씬 리로드 우회 처리 가능하나
# 여기서는 간단히 GameManager reset 이후 StageManager가 몬스터를 처음부터 다시 배치하도록 대응
var stage_mgr: Node = get_node_or_null("../StageManager")
if stage_mgr:
stage_mgr.call("_ready")
uid://c4o1mw6qth7x4
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="1_hud"]
[node name="HUD" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_hud")
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_top = 15.0
offset_bottom = 65.0
grow_horizontal = 2
theme_override_constants/separation = 50
alignment = 1
[node name="GoldLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.94902, 0.792157, 0.098039, 1)
theme_override_font_sizes/font_size = 22
text = "GOLD: 300 G"
horizontal_alignment = 1
vertical_alignment = 1
[node name="StageLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "STAGE: 1 / 20"
horizontal_alignment = 1
vertical_alignment = 1
[node name="MonsterLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.901961, 0.098039, 0.098039, 1)
theme_override_font_sizes/font_size = 22
text = "MONSTERS: 0 / 200"
horizontal_alignment = 1
vertical_alignment = 1
[node name="BottomBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -150.0
offset_top = -90.0
offset_right = 150.0
offset_bottom = -40.0
grow_horizontal = 2
grow_vertical = 0
alignment = 1
[node name="SpawnButton" type="Button" parent="BottomBar"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 20
text = "SPAWN UNIT (100G)"
[node name="ResultPopup" type="Panel" parent="."]
visible = false
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -120.0
offset_right = 200.0
offset_bottom = 120.0
grow_horizontal = 2
grow_vertical = 2
[node name="ResultTitle" type="Label" parent="ResultPopup"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_top = 20.0
offset_bottom = 120.0
grow_horizontal = 2
theme_override_font_sizes/font_size = 22
text = "VICTORY!"
horizontal_alignment = 1
vertical_alignment = 1
[node name="RestartButton" type="Button" parent="ResultPopup"]
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -90.0
offset_top = -70.0
offset_right = 90.0
offset_bottom = -25.0
grow_horizontal = 2
grow_vertical = 0
theme_override_font_sizes/font_size = 18
text = "RESTART"
# 프로젝트 개발 체크리스트 (todo.md)
이 문서는 본 3D 무작위 유닛 디펜스 게임의 전체 개발 로드맵과 각 단계별 진척 상황을 관리합니다.
## [x] 1단계: 기본 맵과 3D 카메라 셋업
- [x] 프로젝트 기본 폴더 구조 생성 (`src/core`, `src/autoload`, `src/entities`, `src/ui`, `src/utils` 등)
- [x] 메인 게임 씬 (`src/core/main.tscn`) 생성
- [x] 3D 기본 지형 구축 (지상 바닥 및 조명, 환경 설정)
- [x] 몬스터 이동 경로 (`Path3D`) 배치 (맵 가장자리를 도는 루프 형태)
- [x] RTS 스타일 3D 카메라 구현 (`src/core/rts_camera.gd`)
- WASD / 방향키를 통한 화면 이동
- 마우스 휠을 통한 줌인/줌아웃
- 화면 가장자리에 마우스 위치 시 카메라 스크롤 (선택 사항)
## [x] 2단계: 글로벌 게임 매니저와 스테이지 시스템
- [x] 글로벌 게임 매니저 싱글톤 (`src/autoload/game_manager.gd`) 구현
- `player_gold`, `unit_spawn_cost`, `current_stage`, `active_monster_count` 등 상태 변수 관리
- 게임오버(200마리 초과) 및 승리 조건(20스테이지 클리어) 체크 루프
- [x] 스테이지 제어 로직 구현 (`src/core/stage_manager.gd`)
- 1초 간격 몬스터 스폰 타이머 설정
- 스테이지별 몬스터 제원 (HP, 골드 보상 등) 스케일링 공식 적용
## [x] 3단계: 몬스터 시스템 구현
- [x] 기본 몬스터 씬 (`src/entities/monsters/base_monster.tscn`) 및 스크립트 작성
- [x] 몬스터가 `PathFollow3D`를 따라 이동하게 하고, 끝에 도달하면 시작 지점으로 다시 루프하도록 설정
- [x] 몬스터 피격, 체력 소진 시 소멸 및 골드 보상 획득 로직 구현
## [x] 4단계: RTS식 유닛 선택 및 이동 (Navigation)
- [x] 네비게이션 메시 (`NavigationRegion3D`) 맵에 추가 및 구워내기 (Bake)
- [x] 마우스 클릭 및 드래그 박스(`Area3D` 또는 스크린 투 월드 투영 기법)를 활용한 유닛 멀티 선택 기능 구현
- [x] 선택된 유닛이 맵 바닥 우클릭 시 네비게이션 에이전트(`NavigationAgent3D`)를 활용하여 목적지로 이동하도록 구현
## [x] 5단계: 유닛 클래스 정의 및 전투 구현
- [x] 유닛 기본 스키마 (`src/entities/units/base_unit.gd`) 작성 (정적 타이핑 적용)
- [x] 5종의 유닛(Striker, Guardian, Ranger, Slayer, Archmage) 스펙 정의 (커스텀 리소스 사용 또는 스크립트 분기)
- [x] 적 감지 범위 (`Area3D`) 내의 몬스터를 자동 검색 및 추격, 사거리 진입 시 쿨타임에 따른 자동 공격 구현
- [x] 투사체(Ranger, Archmage용) 또는 근접 공격 이펙트 처리
## [x] 6단계: UI 및 소환 시스템
- [x] 메인 HUD UI 씬 (`src/ui/hud.tscn`) 구성
- 상단: 스테이지 정보, 몬스터 마릿수(0/200), 현재 골드 상태
- 하단: 유닛 소환 버튼 (100골드 소모) 및 선택된 유닛 정보 창
- [x] 소환 버튼 클릭 시 맵 중앙의 소환 구역(Spawn Zone)에 20% 확률로 5종 유닛 중 무작위 소환
- [x] 게임 오버 및 게임 승리 결과 팝업 UI 구현
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