서비스
교육 철학

06. 무기 4종 — 투사체와 이펙트도 엔티티다

5장이 끝났을 때 게임은 꽤 그럴듯해졌습니다. 적이 몰려오고, 맞으면 죽습니다. 다만 죽는 쪽이 항상 플레이어입니다. 이 장에서 반격 수단을 만듭니다.
무기는 넷입니다. 일부러 성격이 전혀 다른 넷을 골랐습니다.
무기
쏘는 방식
효과
쿨다운
새로 배우는 것
⚡ Lightning (번개)
누르고 있는 동안. 캐릭터 정면으로 즉시 명중
피해 50
1초
광선 판정, 일회성 이펙트 엔티티
❄️ Frost (냉기)
누르고 있는 동안 부채꼴로 계속 분사
1초 쬐면 얼어서 멈춘다. 피해 없음
없음
범위 판정, 적에게 걸리는 상태 이상
💨 Stink (악취)
뗄 때 커서 위치로 포물선 투척
주변의 적이 4초간 달아난다. 피해 없음
5초
날아가는 투사체 엔티티, 조준 표시
🟢 Slime (슬라임)
뗄 때 찍은 적에게 유도탄
0.5초마다 20씩 여섯 번. 그동안 공격 불가
3.5초
대상을 쫓는 투사체, 지속 피해
넷이 이렇게 다른데도 플레이어 쪽 코드는 "지금 든 무기에게 버튼 상태를 알려 준다" 한 가지만 합니다. 무기가 누를 때 나가는지 뗄 때 나가는지, 광선인지 투사체인지 플레이어는 모릅니다. 그렇게 만드는 것이 이 장의 목표입니다.
이 장에서 처음 만드는 무기, 번개. 캐릭터 정면의 적에게 즉시 명중하고 그 자리에 착탄 이펙트가 터진다
🎯
이 장에서 하는 일
1.
무기를 플레이어에게 붙는 엔티티로 만든다 — 공통 틀(WeaponBase)과 무기 묶음(WeaponLoadout)
2.
번개 — 광선으로 맞히고, 착탄 이펙트를 엔티티로 띄운다
3.
냉기 — 범위 안의 적에게 상태 이상을 건다
4.
악취 — 투사체 엔티티를 날리고, 조준 표시(레티클)를 그린다
5.
슬라임 — 같은 틀 위의 네 번째 변형을 읽어 본다
이 책에서 가장 긴 장입니다. 번개 하나만 끝내도 게임은 할 만해지니, 6.6 절까지 한 뒤 쉬었다가 이어 가도 됩니다.
🧰
시작하기 전에 확인할 것 — 예제 프로젝트에는 이미 되어 있습니다.
•
Assets/Art/Prefabs/Attacks/ 의 프리팹 아홉 개가 Addressables 에 파일 이름과 같은 주소로 등록돼 있다. 무기 넷(LightningAttack, FrostAttack, StinkAttack, SlimeAttack)과 무기가 쏘아 보내는 것 다섯(LightningHit, StinkProjectile, StinkHit, SlimeProjectile, SlimeHit)
•
1장에서 Entity 컴포넌트에 Weapon, Projectile, Effect 그룹을 만들었다
•
1장에서 레이어를 정했다: 9 = Shootable(적), 10 = Blocking, 14 = Environment
•
Girl / Boy 프리팹에 Antenna 라는 빈 자식이 있다. 무기가 붙는 자리(발사점)다. 무기의 모습은 캐릭터 프리팹 안에 없고, 위의 무기 프리팹 넷에 따로 들어 있다

6.1 무기는 무엇으로 만들 것인가

지금까지 만든 것을 종류별로 놓아 봅니다.
종류
예
언제 쓰나
엔티티
Player, Enemy
화면에 나타났다 사라지는 것. 프리팹이 있다
프레임워크 컴포넌트
PlayerCameraFollow
판이 바뀌어도 계속 살아 있는 전역 시스템
평범한 C# 객체
SurvivalGame, EnemySpawner
화면에 모습이 없고, 주인이 들고 굴리는 것
무기는 어디에 속할까요? 무기에는 모습이 있습니다. 총구의 반짝임, 번개 줄기, 냉기 분사, 조준 고리가 전부 무기마다 따로 있는 프리팹입니다(LightningAttack, FrostAttack, StinkAttack, SlimeAttack). 그리고 플레이어가 나타날 때 같이 나타나고, 사라질 때 같이 사라집니다. 프리팹이 있고, 나타났다 사라집니다. 표의 첫째 줄, 엔티티입니다.
다만 무기는 혼자 서지 않습니다. 플레이어의 안테나 끝에 붙어서 같이 움직입니다. 프레임워크에는 이것을 위한 기능이 있습니다. AttachEntity 는 엔티티를 다른 엔티티의 자식으로 붙입니다. 붙은 엔티티는 부모를 따라 움직이고, 부모가 회수되면 프레임워크가 자식을 먼저 회수합니다. 플레이어가 사라질 때 무기를 치우는 코드를 우리가 쓸 필요가 없습니다.
무기 넷을 들고 "지금 어느 것을 쥐었는가", "쿨다운이 끝났는가"를 관리하는 무기 묶음에는 모습이 없습니다. 이쪽은 표의 셋째 줄, 플레이어가 들고 굴리는 평범한 C# 객체입니다. 5장에서 SurvivalGame 이 스포너를 들고 굴리던 것과 같은 모양이 한 층 더 내려갑니다.
graph TD
    P["Player<br>(엔티티)"] -->|"들고 있다"| L["WeaponLoadout<br>무기 묶음. 입력을 읽고 쿨다운을 센다"]
    L -.->|"ShowEntity"| W1["LightningWeapon<br>(엔티티)"]
    L -.->|"ShowEntity"| W2["FrostWeapon<br>(엔티티)"]
    L -.->|"ShowEntity"| W3["StinkWeapon<br>(엔티티)"]
    L -.->|"ShowEntity"| W4["SlimeWeapon<br>(엔티티)"]
    W1 ==>|"AttachEntity<br>넷 다 플레이어의 Antenna 에 붙는다"| P
    W1 -.->|"ShowEntity"| E1["HitEffect<br>(엔티티)"]
    W3 -.->|"ShowEntity"| E2["StinkProjectile<br>(엔티티)"]
    W4 -.->|"ShowEntity"| E3["SlimeProjectile<br>(엔티티)"]
Mermaid
복사
무기가 쏘아 보내는 것(투사체, 착탄 이펙트)도 나타났다 사라지므로 엔티티입니다. 이쪽은 어디에도 붙지 않고 혼자 날아가 혼자 사라집니다.
무기 프리팹 넷은 Assets/Art/Prefabs/Attacks 에 있습니다.
Assets/Art/Prefabs/Attacks. ① 무기 프리팹 넷 — 나머지는 무기가 쏘아 보내는 투사체·이펙트와 원본 샘플에서 남은 프리팹
하나를 골라 Inspector 를 보면 3장의 캐릭터 프리팹과 같은 규칙을 따르고 있습니다. 주소는 파일 이름 그대로이고, 로직 스크립트는 붙어 있지 않습니다. 어떤 로직을 붙일지는 ShowEntity 를 부르는 쪽이 정합니다.
LightningAttack 프리팹. ① Addressables 주소 = 프리팹 이름 ② Transform 과 파티클뿐, 로직 스크립트가 없다
💡
왜 캐릭터 프리팹 안에 넣어 두지 않는가. Unity 에 익숙하면 무기 오브젝트를 캐릭터 프리팹의 자식으로 넣고 스크립트를 붙여 두는 쪽이 먼저 떠오릅니다. 그렇게 하지 않는 이유는 셋입니다. ① 캐릭터와 무기가 한 프리팹에 묶입니다. 캐릭터가 둘이면 같은 무기 넷을 두 번 넣어야 하고, 무기를 하나 늘릴 때마다 캐릭터 프리팹을 전부 고쳐야 합니다. ② 3장, 4장에서 본 대로 캐릭터 인스턴스는 풀로 돌아갔다가 선택 화면의 캐릭터로 다시 나옵니다. 무기가 캐릭터 안에서 켜고 끈 것을 전부 손으로 되돌려 놓아야 합니다. ③ 무기에 붙인 스크립트의 Update 와 플레이어의 OnUpdate 중 어느 쪽이 먼저 불릴지 보장되지 않습니다. "플레이어가 이번 프레임의 조준점을 구한 뒤에 쏜다"가 성립하지 않습니다. 무기를 엔티티로 띄우면 ①과 ②는 프레임워크가 해결해 줍니다. ③은 이 장에서 무기가 스스로 쏘지 않게 만들어 해결합니다. 발사 신호는 언제나 플레이어 쪽에서 내려옵니다.

두 가지 규칙

무기 넷이 공유하는 규칙이 둘 있습니다.
① 쏘는 순간이 둘이다. 번개와 냉기는 버튼을 누르고 있는 동안 나갑니다. 악취와 슬라임은 버튼을 떼는 순간 나갑니다(누르는 동안에는 조준만 합니다). 그래서 무기에게 주는 신호가 셋입니다.
신호
언제
반응하는 무기
TryFireHeld()
누르고 있는 동안 매 프레임
번개, 냉기
TryFireReleased()
뗀 프레임에 한 번
악취, 슬라임
StopFiring()
뗀 프레임에 한 번 (쿨다운과 상관없이 항상)
냉기 — 분사를 끈다
② 쿨다운은 무기마다가 아니라 전체에 하나다. 악취(5초)를 던진 직후에 번개로 바꿔도 5초 동안은 아무것도 못 쏩니다. 무기를 바꿔 가며 쿨다운을 피하는 것을 막기 위해서입니다. 그래서 쿨다운 타이머는 무기가 아니라 무기 묶음이 하나만 들고 있고, 각 무기는 "나를 쏘고 나면 몇 초를 잠가야 하는지"라는 값만 알려 줍니다.

6.2 숫자와 도구

Assets/GameMain/Weapon 폴더를 만듭니다. 5장에서 적의 숫자를 표에 모았듯이, 무기의 숫자도 한 파일에 모읍니다.
// Assets/GameMain/Weapon/WeaponTable.cs namespace ToyBoxNightmare { /// <summary> /// 무기 관련 상수. Addressables 주소는 여기 문자열과 Groups 창의 Address 가 /// 정확히 같아야 한다 — 다르면 ShowEntity 가 조용히 실패한다. /// /// <b>쿨다운은 전역이다.</b> 무기가 자기 타이머를 돌리지 않는다. 여기 값은 /// "이 무기로 쏘고 나면 <b>모든</b> 무기가 몇 초 잠기는가" 이고, 그 타이머는 /// <see cref="WeaponLoadout"/> 이 한 쌍만 들고 있다. /// </summary> public static class WeaponTable { // ─── 엔티티 그룹 ─── public const string WeaponGroup = "Weapon"; public const string ProjectileGroup = "Projectile"; public const string EffectGroup = "Effect"; // ─── Addressables 주소 ─── // 무기 엔티티 프리팹. 플레이어의 발사점(WeaponBase.AttachPath)에 붙는다. public const string LightningWeaponAsset = "LightningAttack"; public const string FrostWeaponAsset = "FrostAttack"; public const string StinkWeaponAsset = "StinkAttack"; public const string SlimeWeaponAsset = "SlimeAttack"; public const string LightningHitAsset = "LightningHit"; public const string StinkProjectileAsset = "StinkProjectile"; public const string StinkHitAsset = "StinkHit"; public const string SlimeProjectileAsset = "SlimeProjectile"; public const string SlimeHitAsset = "SlimeHit"; // ─── Lightning ─── // 누르고 있는 동안 발사. 쿨다운 1초. 레티클 없음. public const float LightningRange = 10f; public const int LightningDamage = 50; public const float LightningCooldown = 1f; /// <summary>착탄 이펙트 수명. 파티클이 0.5~0.75초라 여유를 둔 값.</summary> public const float LightningHitLifetime = 1f; // ─── Frost (데미지 0. Lightning 과 병행 장착이 전제다) ─── // 누르면 콘 ON, 떼면 콘 OFF. 레티클 없음. 쿨다운 없음. public const float FrostCooldown = 0f; /// <summary>콘 안의 적을 다시 훑는 주기.</summary> public const float FrostRetickInterval = 0.2f; public const float FrostConeRadius = 6f; public const float FrostConeHalfAngle = 30f; // 개구각 60° // ─── Stink ─── // **떼는 프레임에** 마우스 지면 지점으로 투척. 쿨다운 5초. 레티클 있음. public const float StinkCooldown = 5f; /// <summary> /// 투척 사거리. 플레이어와 마우스 지면 지점 사이 거리가 이 값 이내여야 던진다. /// 밖이면 레티클이 빨강이고 발사도 안 된다. /// </summary> public const float StinkThrowRange = 10f; public const float StinkSpeed = 10f; public const float StinkBlastRadius = 4f; public const float StinkFleeDuration = 4f; public const float StinkHitLifetime = 4f; // ─── Slime ─── // **떼는 프레임에** 발사. 쿨다운 3.5초. 레티클 있음. // 대상은 마우스 지면 지점에서 위로 쏜 짧은 레이에 걸린 적이다. 대상이 없으면 // 발사 자체가 실패하고 쿨다운도 걸리지 않는다. public const float SlimeCooldown = 3.5f; /// <summary>대상 판정 레이 길이. 마우스 지면 지점에서 Vector3.up 으로 이만큼 쏜다.</summary> public const float SlimeTargetRayLength = 2f; public const float SlimeSpeed = 20f; public const float SlimeHitRadius = 1f; public const float SlimeHitLifetime = 1.2f; // DoT public const int SlimeTicks = 6; public const float SlimeTickInterval = 0.5f; public const int SlimeTickDamage = 20; } }
C#
복사
이 파일은 이 장 맨 앞의 표를 코드로 옮긴 것입니다. 무기 클래스 안에는 숫자를 직접 쓰지 않고 전부 여기서 가져다 씁니다. 밸런스를 만질 때 여는 파일이 언제나 하나가 됩니다.
다음은 무기들이 함께 쓰는 도구 상자입니다.
// Assets/GameMain/Weapon/WeaponUtil.cs using System.Collections.Generic; using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 레티클 3색이 뜻하는 것. "지금 이 지점에 쏠 수 있나" 를 알린다. /// </summary> public enum TargetState { /// <summary>조준 지점이 무효다. 빨강. (Stink: 사거리 밖 / Slime: 대상 없음)</summary> Invalid, /// <summary>조준은 유효한데 전역 쿨다운이 안 끝났다. 노랑.</summary> NotReady, /// <summary>지금 쏠 수 있다. 초록.</summary> Ready, } /// <summary>무기와 착탄 이펙트가 함께 쓰는 적 탐색 유틸.</summary> public static class WeaponUtil { // ─── 레티클 색 ─── public static readonly Color ColorInvalid = new Color(1f, 0f, 0f, 1f); public static readonly Color ColorNotReady = new Color(1f, 0.922f, 0.016f, 1f); public static readonly Color ColorReady = new Color(0f, 1f, 0f, 1f); public static Color GetTargetColor(TargetState state) { switch (state) { case TargetState.Ready: return ColorReady; case TargetState.NotReady: return ColorNotReady; case TargetState.Invalid: return ColorInvalid; default: // TargetState 에 값을 추가하고 여기를 안 고치면 조용히 빨강이 된다. GameAssert.Unreachable("GetTargetColor: 처리되지 않은 TargetState"); return ColorInvalid; } } // ─── 거리 ─── /// <summary> /// XZ 평면 거리. 무기 사거리는 전부 이걸로 잰다 — /// 캐릭터 콜라이더 중심이 Y 로 올라가 있어 3D 거리를 쓰면 사거리가 높이차에 흔들린다. /// </summary> public static float PlanarDistance(Vector3 a, Vector3 b) { Vector3 delta = a - b; delta.y = 0f; return delta.magnitude; } // ─── 적 탐색 ─── /// <summary>Shootable(9). 적 탐색용.</summary> public const int ShootableMask = 1 << 9; /// <summary>Shootable(9) | Blocking(10) | Environment(14) = 17920. Lightning 마스크.</summary> public const int HitscanMask = (1 << 9) | (1 << 10) | (1 << 14); /// <summary> /// OverlapSphere 결과 스크래치의 길이. /// 적 1기당 콜라이더가 2개(CapsuleCollider + Shootable 트리거 구)라 <b>실효 상한은 절반</b>인 128기다. /// </summary> private const int OverlapBufferSize = 256; /// <summary>OverlapSphere 결과 스크래치. 프레임 할당을 없앤다.</summary> private static readonly Collider[] sOverlapBuffer = new Collider[OverlapBufferSize]; /// <summary>버퍼 포화를 <b>한 번만</b> 알리기 위한 플래그.</summary> private static bool sOverlapOverflowReported = false; /// <summary> /// 구 안의 살아있는 적을 중복 없이 채운다. 반환값은 개수. /// /// 적 프리팹은 루트에 SphereCollider(trigger)와 CapsuleCollider 가 둘 다 붙어 있고 /// 둘 다 레이어 9다. OverlapSphere 가 적 1기당 원소를 2개 돌려준다 — /// 중복 제거를 빼면 디버프가 2회 적용된다. /// </summary> public static int FindEnemiesInSphere(Vector3 origin, float radius, List<Enemy> result) { GameAssert.IsTrue(result != null, "FindEnemiesInSphere: 결과 버퍼가 null 이다."); if (result == null) return 0; result.Clear(); int hitCount = Physics.OverlapSphereNonAlloc(origin, radius, sOverlapBuffer, ShootableMask); WarnOnBufferSaturation(hitCount); for (int i = 0; i < hitCount; i++) { Enemy enemy = GetAttackableEnemy(sOverlapBuffer[i]); if (enemy == null) continue; // OverlapSphere 는 **콜라이더 겹침**으로 잡는다. 적의 트리거 구가 커서 // 중심이 반경 밖에 있어도 걸린다. 최종 집합을 중심 거리 기준으로 맞춘다. if (PlanarDistance(enemy.CachedTransform.position, origin) > radius) continue; // 적 1기가 콜라이더 2개로 잡히므로 중복 제거가 필수다. if (result.Contains(enemy)) continue; result.Add(enemy); } return result.Count; } /// <summary> /// 버퍼가 가득 찼으면 <b>딱 한 번</b> 알린다. /// OverlapSphereNonAlloc 은 초과분을 조용히 버린다. /// </summary> private static void WarnOnBufferSaturation(int hitCount) { if (hitCount < sOverlapBuffer.Length || sOverlapOverflowReported) return; sOverlapOverflowReported = true; Log.Warning("WeaponUtil: 적 탐색 버퍼({0})가 가득 찼다. 초과분이 잘려 반경 안의 적이 빠질 수 있다. " + "OverlapBufferSize 를 키울 것. (이 경고는 한 번만 나온다)", OverlapBufferSize); } /// <summary> /// 콜라이더 하나에서 "지금 때릴 수 있는 적" 을 꺼낸다. 아니면 null. /// 바닥·환경 콜라이더, 시체, 회수된 엔티티가 전부 여기서 걸러진다. /// </summary> private static Enemy GetAttackableEnemy(Collider collider) { // 같은 프레임에 파괴된 콜라이더가 버퍼에 남아 있을 수 있다. if (collider == null) return null; Entity entity = collider.GetComponentInParent<Entity>(); if (entity == null) return null; Enemy enemy = entity.Logic as Enemy; // Available 을 IsDead 보다 먼저 본다. IsDead 는 스폰 데이터를 읽는데, // 회수된 엔티티의 데이터는 이미 풀로 반납된 뒤라 답을 신뢰할 수 없다. if (enemy == null || !enemy.Available || enemy.IsDead) return null; return enemy; } // ─── 이펙트 스폰 ─── /// <summary> /// 일회성 이펙트 엔티티를 띄운다. /// </summary> public static void SpawnEffect(System.Type logicType, string assetName, Vector3 position, Quaternion rotation, float lifetime) { // 주소가 비면 ShowEntity 가 조용히 실패한다. if (logicType == null || string.IsNullOrEmpty(assetName)) { Log.Error("SpawnEffect: 이펙트 종류나 주소가 비어 있다. (logicType={0}, assetName={1})", logicType, assetName); return; } GameAssert.IsTrue(lifetime > 0f, "SpawnEffect: lifetime 이 0 이하면 이펙트가 보이기 전에 회수된다."); EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) { Log.Error("SpawnEffect: EntityComponent 가 없다. GameFramework 프리팹 구성을 확인할 것."); return; } int id = EntitySerialId.Next(); entityComponent.ShowEntity( id, logicType, assetName, WeaponTable.EffectGroup, EffectData.Create(id, 1, position, rotation, lifetime)); } } }
C#
복사

코드 읽기

지금 눈여겨볼 것은 레이어 마스크 두 줄입니다.
public const int ShootableMask = 1 << 9; public const int HitscanMask = (1 << 9) | (1 << 10) | (1 << 14);
C#
복사
Unity 의 물리 검사(광선, 범위)는 "어떤 레이어만 볼 것인가"를 숫자 하나로 받습니다. 1 << 9 는 "9번째 비트만 켠 수", 곧 9번 레이어만이라는 뜻입니다. | 로 이으면 여러 레이어가 됩니다.
마스크
보는 레이어
쓰는 곳
ShootableMask
적(9)만
"이 범위 안의 적을 찾아라" — 냉기, 악취
HitscanMask
적(9) + 막는 것(10) + 가구(14)
"이 방향으로 처음 닿는 것" — 번개. 가구 뒤의 적은 맞지 않아야 한다
바닥(8)은 어느 마스크에도 없습니다. 번개가 바닥에 맞아 끊기면 안 되기 때문입니다.
FindEnemiesInSphere 와 SpawnEffect 는 쓰는 자리에서 다시 읽습니다. 아직 EffectData 가 없어서 컴파일되지 않는데, 6.6 절에서 만듭니다. 이 장은 서로 물린 파일이 많아서 6.6 절 끝에서 처음으로 컴파일이 맞춰집니다. 그때까지는 빨간 줄을 두고 계속 진행합니다.

6.3 무기의 공통 틀 — WeaponBase

무기도 엔티티이므로 주문서부터 만듭니다. 3장부터 해 온 그대로입니다.
// Assets/GameMain/Entity/EntityData/WeaponData.cs using GameFramework; namespace ToyBoxNightmare { /// <summary> /// 무기 엔티티의 스폰 데이터. /// /// 무기는 혼자 서지 않는다. <see cref="OwnerId"/> 의 엔티티(플레이어)에 붙어서 쓰이고, /// 소유자가 회수되면 프레임워크가 함께 회수한다. /// </summary> public class WeaponData : EntityData { public static WeaponData Create(int entityId, int typeId, int ownerId, int slot, float cooldown) { WeaponData data = ReferencePool.Acquire<WeaponData>(); data.Fill(entityId, typeId); data.OwnerId = ownerId; data.Slot = slot; data.Cooldown = cooldown; return data; } /// <summary>이 무기를 드는 엔티티의 Id. 무기는 뜨자마자 여기에 붙는다.</summary> public int OwnerId { get; private set; } /// <summary> /// 로드아웃에서의 자리(0부터). 무기는 비동기로 로드되어 도착 순서가 요청 순서와 /// 다를 수 있으므로, Tab 순환 순서는 도착 순서가 아니라 이 값으로 정한다. /// </summary> public int Slot { get; private set; } /// <summary> /// 이 무기로 발사에 성공했을 때 걸리는 <b>전역</b> 쿨다운(초). 쿨다운이 없으면 0. /// 무기는 값만 알려 주고 타이머는 <see cref="WeaponLoadout"/> 이 하나만 돌린다. /// </summary> public float Cooldown { get; private set; } public override void Clear() { base.Clear(); // 0 으로 되돌리지 않으면 다음에 이 데이터를 받은 무기가 이전 소유자를 찾아간다. OwnerId = 0; Slot = 0; Cooldown = 0f; } } }
C#
복사
주문서에 실리는 것은 셋입니다.
OwnerId 는 누구에게 붙을지입니다. 플레이어를 참조로 싣지 않고 엔티티 번호로 싣습니다. 무기 프리팹은 비동기로 로드되므로, 요청한 순간과 무기가 나타나는 순간 사이에 주인이 사라졌을 수도 있기 때문입니다. 번호로 들고 있으면 "그 엔티티가 아직 있는가"를 프레임워크에 물을 수 있습니다(6.9 절에서 다시 다룹니다).
Slot 은 무기 묶음의 몇 번째 자리인지입니다. 무기 넷을 한꺼번에 요청해도 도착하는 순서는 정해져 있지 않습니다. Tab 으로 도는 순서가 판마다 달라지면 안 되므로 자리 번호를 주문서에 적어 보냅니다.
Cooldown 은 6.1 절의 둘째 규칙에서 말한, 무기가 알려 주는 값입니다.
이제 무기 넷이 공통으로 하는 일을 한 클래스에 모읍니다. 양이 많아 보이지만 하는 일은 넷입니다.
1.
수명 — 나타나자마자 주인에게 붙고(OnShow), 손에 들리고 놓이고(SetActive), 주인과 함께 회수된다(OnHide)
2.
발사 신호 — 6.1 절의 세 신호를 받아 자식에게 넘긴다
3.
총구 — 어디에서 쏘는가
4.
보이기 — 손에 든 무기만 보인다. 무기 프리팹이 통째로 켜지고 꺼진다
// Assets/GameMain/Weapon/WeaponBase.cs using System.Collections.Generic; using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 무기 베이스. <b>무기도 엔티티다</b> — <see cref="WeaponLoadout"/> 이 <c>ShowEntity</c> 로 띄우고, /// 뜨자마자 <see cref="WeaponData.OwnerId"/> 의 플레이어에게 <c>AttachEntity</c> 로 붙는다. /// /// <b>무기는 스스로 발사하지 않는다.</b> 발사 시점은 <see cref="WeaponLoadout"/> 이 입력에서 뽑아 내려 주고, /// 그 호출은 <see cref="Player"/> 의 갱신 안에서 일어난다. 그래서 "플레이어가 조준점을 계산한 /// 뒤에 무기가 쏜다" 는 순서가 엔티티 갱신 순서와 무관하게 보장된다. /// /// <b>수동 발사 모델이다.</b> 조준은 플레이어가 마우스로 하고(<c>Player.AimPoint</c>), /// 발사 시점은 <see cref="WeaponLoadout"/> 이 입력에서 뽑아 /// <see cref="TryFireHeld"/>/<see cref="TryFireReleased"/>/<see cref="StopFiring"/> 로 내려 준다. /// /// <b>쿨다운은 무기가 아니라 로드아웃이 전역 하나로 관리한다.</b> /// 무기는 자기 쿨다운 <b>값</b>(<see cref="Cooldown"/>)만 알려 줄 뿐 타이머를 돌리지 않는다. /// </summary> public abstract class WeaponBase : EntityLogicBase { /// <summary> /// 소유자 프리팹에서 무기가 붙는 자리(소유자 루트 기준 경로). /// Girl/Boy 프리팹의 발사점이다. 무기 4종이 전부 여기서 쏜다. /// </summary> public const string AttachPath = "Antenna"; /// <summary>Shootable(9). 적 탐색용.</summary> protected const int ShootableMask = WeaponUtil.ShootableMask; /// <summary>Shootable(9) | Blocking(10) | Environment(14) = 17920.</summary> protected const int HitscanMask = WeaponUtil.HitscanMask; /// <summary>파생 무기가 재사용하는 스크래치 버퍼. 프레임 할당을 없앤다.</summary> protected readonly List<Enemy> Candidates = new List<Enemy>(32); private WeaponData mWeaponData = null; /// <summary>이 무기를 든 플레이어. 붙기 전과 떨어진 뒤에는 null 이다.</summary> protected Player Owner { get; private set; } /// <summary>지금 이 무기가 손에 들려 있는지. <see cref="SetActive"/> 로만 바뀐다.</summary> public bool Active { get; private set; } private bool mActiveApplied = false; /// <summary>로드아웃에서의 자리. <see cref="WeaponData.Slot"/> 그대로다.</summary> public int Slot { get { return mWeaponData != null ? mWeaponData.Slot : 0; } } /// <summary> /// 이 무기가 발사에 성공했을 때 로드아웃이 걸 전역 쿨다운(초). 쿨다운이 없으면 0. /// 값은 스폰 데이터(<see cref="WeaponData.Cooldown"/>)에서 온다. /// </summary> public float Cooldown { get { return mWeaponData != null ? mWeaponData.Cooldown : 0f; } } // ─── 생명주기 ─── protected internal override void OnInit(object userData) { base.OnInit(userData); // 인스턴스가 처음 만들어질 때 한 번. 자식 트랜스폼은 풀 재사용 사이에 바뀌지 않으므로 // 여기서 찾아 두면 스폰마다 다시 찾지 않아도 된다. OnWeaponInit(); } /// <summary>파생 무기가 자기 프리팹 안의 자식을 찾아 둘 훅. 인스턴스당 한 번 불린다.</summary> protected virtual void OnWeaponInit() { } protected internal override void OnShow(object userData) { base.OnShow(userData); // 풀에서 꺼낸 인스턴스는 이전 판의 상태를 물고 있다. 데이터 검사보다 먼저 되돌린다. Owner = null; Active = false; mActiveApplied = false; // 손에 들리기 전에는 보이지 않는다. 로드아웃이 SetActive 로 켠다. // (base.OnShow 가 방금 켰으므로 여기서 다시 꺼야 한다.) Visible = false; OnWeaponShow(); mWeaponData = userData as WeaponData; if (mWeaponData == null) { Log.Error("{0}: Weapon data is invalid.", GetType().Name); return; } AttachToOwner(); } /// <summary>파생 무기가 자기 상태를 리셋할 훅. 스폰(재사용)마다 불린다.</summary> protected virtual void OnWeaponShow() { } /// <summary> /// 소유자에게 붙는다. 무기는 비동기로 로드되므로, 뜨는 사이에 소유자가 이미 회수됐을 수 있다. /// 그때는 붙지 않고 그대로 둔다 — 다음 <see cref="OnUpdate"/> 가 주인 없는 무기를 회수한다. /// </summary> private void AttachToOwner() { EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) return; // 없는 부모에 붙이면 코어가 예외를 던진다. 먼저 확인한다. if (!entityComponent.HasEntity(mWeaponData.OwnerId)) return; entityComponent.AttachEntity(Entity, mWeaponData.OwnerId, AttachPath); } protected internal override void OnAttachTo(EntityLogic parentEntity, Transform parentTransform, object userData) { base.OnAttachTo(parentEntity, parentTransform, userData); // base 는 부모만 바꾼다. 풀 루트에 있던 월드 좌표가 로컬로 남지 않게 붙는 자리에 맞춘다. CachedTransform.localPosition = Vector3.zero; CachedTransform.localRotation = Quaternion.identity; CachedTransform.localScale = Vector3.one; Owner = parentEntity as Player; if (Owner == null) { Log.Warning("{0}: 소유자가 Player 가 아니다. 이 무기는 발사되지 않는다.", GetType().Name); } } protected internal override void OnDetachFrom(EntityLogic parentEntity, object userData) { Owner = null; base.OnDetachFrom(parentEntity, userData); } protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); // 주인 없는 무기는 쓸 수 없다. 소유자가 먼저 사라진 뒤에 로드가 끝난 경우다. if (Owner == null) { SafeHide(); } } protected internal override void OnHide(bool isShutdown, object userData) { // 켜 둔 연출을 끊고 프리팹 기본 상태로 되돌린다. 이 인스턴스는 풀에 돌아가 // 다음 판에 그대로 재사용되므로, 여기서 안 끄면 Frost 콘이 켜진 채로 다시 나온다. OnWeaponDisabled(); OnStopFiring(); Active = false; mActiveApplied = false; Owner = null; // 데이터는 base 체인 끝(EntityLogicBase)에서 ReferencePool 로 반납된다. // 반납된 것을 계속 들고 있으면 다음 주인의 값을 읽게 되므로 참조를 놓는다. mWeaponData = null; base.OnHide(isShutdown, userData); } /// <summary> /// 무기를 손에 들거나 내려놓는다. 내려놓으면 무기 엔티티가 통째로 보이지 않게 되어 /// 총구 VFX 가 함께 꺼진다 — 이걸 빠뜨리면 Tab 으로 바꿔도 이전 무기의 연출이 남는다. /// /// 보이지 않을 뿐 엔티티는 살아 있다. 회수는 소유자가 회수될 때 프레임워크가 한다. /// </summary> public void SetActive(bool active) { if (mActiveApplied && Active == active) return; mActiveApplied = true; Active = active; if (active) { Visible = true; OnWeaponEnabled(); } else { // 정리를 숨기기보다 **먼저** 한다. 먼저 숨기면 그 아래 컴포넌트가 // 비활성 상태가 되어, 스스로 멈춰야 하는 연출이 정리 기회를 잃는다. OnWeaponDisabled(); // 지속 연출(Frost 콘 등)도 여기서 확실히 끊는다. 버튼을 누른 채로 Tab 을 치면 // 이 무기의 StopFiring 이 영영 안 불리기 때문이다. OnStopFiring(); Visible = false; } } protected virtual void OnWeaponEnabled() { } protected virtual void OnWeaponDisabled() { } /// <summary> /// 무기를 굴릴 수 있는 상태인가. 소유자가 회수됐거나 죽었으면 거짓이다. /// </summary> private bool IsOwnerAlive => Owner != null && Owner.Available && !Owner.IsDead; /// <summary>손에 들려 있고 소유자도 멀쩡한가. 발사 훅의 공통 전제다.</summary> private bool CanOperate => Active && IsOwnerAlive; // ─── 발사 ─── /// <summary> /// 발사 버튼을 누르고 있는 동안 매 프레임 불린다. /// 쿨다운을 소모하는 발사를 했으면 true — 로드아웃이 그때만 전역 쿨다운을 건다. /// </summary> public bool TryFireHeld() { if (!CanOperate) return false; return OnFireHeld(); } /// <summary>기본은 "이 타이밍에 쏘지 않는다".</summary> protected virtual bool OnFireHeld() { return false; } /// <summary> /// 발사 버튼을 뗀 프레임에 불린다. /// 쿨다운을 소모하는 발사를 했으면 true — 로드아웃이 그때만 전역 쿨다운을 건다. /// </summary> public bool TryFireReleased() { if (!CanOperate) return false; return OnFireReleased(); } /// <summary>기본은 "이 타이밍에 쏘지 않는다".</summary> protected virtual bool OnFireReleased() { return false; } /// <summary> /// 발사 버튼을 뗐을 때 <b>쿨다운과 무관하게</b> 항상 불린다. 지속 연출을 끊는 용도다. /// /// 일부러 <see cref="CanOperate"/> 로 막지 않는다. 소유자가 그 프레임에 죽었더라도 /// 켜 둔 연출(Frost 콘·루프 사운드)은 반드시 꺼야 한다. /// </summary> public void StopFiring() { OnStopFiring(); } /// <summary>파생 무기가 지속 연출을 끊을 훅.</summary> protected virtual void OnStopFiring() { } // ─── 조준 ─── /// <summary> /// 활성 무기에 대해 매 프레임 불린다. /// isReady 는 <b>전역</b> 쿨다운이 끝났는지다(이 무기만의 상태가 아니다). /// </summary> public void UpdateAim(bool isReady) { if (!Active) return; if (!IsOwnerAlive) { // 소유자가 죽으면 스스로 내려놓는다. SetActive(false); return; } OnUpdateAim(isReady); } /// <summary>파생 무기가 매 프레임 할 일을 넣을 훅. 기본은 아무것도 하지 않는다.</summary> protected virtual void OnUpdateAim(bool isReady) { } // ─── 총구(발사 원점) ─── /// <summary> /// 발사 원점. 무기 엔티티가 소유자의 발사점(<see cref="AttachPath"/>)에 붙어 있으므로 /// 무기 자신의 위치가 곧 총구다. /// </summary> protected Vector3 MuzzleOrigin { get { return CachedTransform.position; } } } }
C#
복사
📝
예제 프로젝트의 WeaponBase.cs 에는 이 아래에 레티클(조준 표시) 부분이 더 있습니다. 번개와 냉기는 레티클을 쓰지 않으므로 6.8 절에서 악취를 만들 때 붙입니다.

코드 읽기

공개 메서드와 훅이 짝을 이룹니다. 이 클래스에서 가장 중요한 모양입니다.
밖에서 부르는 것 (자식은 건드리지 않는다)
자식이 채우는 것 (protected virtual)
OnInit — 프레임워크가 부른다. 인스턴스당 한 번
OnWeaponInit()
OnShow — 프레임워크가 부른다. 나타날 때마다
OnWeaponShow()
SetActive(bool)
OnWeaponEnabled() / OnWeaponDisabled()
TryFireHeld()
OnFireHeld()
TryFireReleased()
OnFireReleased()
StopFiring()
OnStopFiring()
UpdateAim(isReady)
OnUpdateAim(isReady)
왼쪽은 공통으로 해야 하는 검사와 정리를 하고 나서 오른쪽을 부릅니다. TryFireHeld() 를 보면, 무기가 손에 들려 있지 않거나 주인이 죽었으면(CanOperate) 자식의 OnFireHeld() 까지 가지도 않습니다. 자식은 "쏴도 되는 상황에서만 불린다"고 믿고 쏘는 일만 쓰면 됩니다.
4장의 TargetableObject.ApplyDamage 가 OnDamaged / OnDead 를 부르던 것, 5장의 EnemyStateBase.OnUpdate 가 OnStateUpdate 를 부르던 것과 같은 모양입니다. 부모가 "언제"와 "해도 되는가"를 쥐고, 자식은 "무엇을"만 채웁니다. 이 책에서 가장 자주 쓰는 설계입니다.
기본 훅이 "아무것도 안 한다"입니다. OnFireHeld() 와 OnFireReleased() 는 둘 다 기본이 return false; 입니다. 번개는 앞의 것만, 악취는 뒤의 것만 덮어씁니다. 무기 묶음은 무기가 어느 쪽에 반응하는지 묻지 않고 두 신호를 다 보냅니다. 관심 없는 신호는 무기가 알아서 흘려보냅니다.
발사 훅이 bool 을 돌려줍니다. "쿨다운을 걸어야 하는 발사를 했는가"입니다. 슬라임은 찍은 적이 없으면 쏘지 않고 false 를 돌려줘서 쿨다운이 걸리지 않습니다. 냉기는 분사를 켜고도 false 를 돌려줍니다. 쿨다운이 없는 무기이기 때문입니다.
StopFiring() 만 CanOperate 를 보지 않습니다. 주석에 이유가 있습니다. 켜 놓은 것을 끄는 일은 어떤 상황에서도 되어야 합니다. 주인이 방금 죽었다고 냉기 분사를 못 끄면 시체가 계속 냉기를 뿜습니다. "시작하는 쪽은 조건을 따지고, 끝내는 쪽은 무조건"이라는 비대칭을 기억해 두세요.
무기는 나타나자마자 스스로 주인에게 붙습니다. OnShow 의 마지막 줄 AttachToOwner() 가 주문서의 OwnerId 로 AttachEntity(Entity, 주인 번호, "Antenna") 를 부릅니다. 셋째 인자는 주인 프리팹 안에서 어느 자식 밑에 붙을지의 경로입니다. 프레임워크는 무기를 그 트랜스폼의 자식으로 옮긴 뒤 양쪽에 알립니다. 무기 쪽에는 OnAttachTo 가, 주인 쪽에는 OnAttached 가 불립니다(주인 쪽은 6.5 절에서 씁니다). 그래서 총구를 찾는 코드가 없습니다. 무기가 안테나에 붙어 있으니 MuzzleOrigin 은 그냥 자기 위치입니다.
붙기 전에 주인이 아직 있는지 확인합니다. 무기 프리팹은 비동기로 로드됩니다. 요청하고 나타나기까지 몇 프레임이 걸릴 수 있고, 그 사이에 주인이 사라졌을 수도 있습니다. 없는 엔티티에 붙이려 하면 프레임워크가 예외를 던지므로 HasEntity 로 먼저 묻습니다. 붙지 못한 무기는 Owner 가 null 인 채로 남고, 다음 OnUpdate 가 SafeHide() 로 스스로 돌아갑니다. 요청과 도착 사이에 세상이 바뀌었을 수 있다는 것은 비동기로 뜨는 모든 엔티티가 따져야 하는 일입니다.
손에 든 무기만 보입니다. Visible 은 EntityLogic 이 가진 속성으로, 엔티티의 GameObject 를 켜고 끕니다. 무기 넷이 전부 안테나에 붙어 있지만 SetActive(true) 를 받은 하나만 보입니다. 꺼져 있어도 엔티티로서는 살아 있어서 OnUpdate 는 계속 불립니다. 프레임워크의 OnUpdate 는 Unity 의 Update 가 아니라, 프레임워크가 떠 있는 엔티티를 하나씩 돌며 직접 부르는 것이기 때문입니다.
치우는 코드가 없습니다. 이 클래스 어디에도 "주인이 사라지면 나도 사라진다"는 코드가 없습니다. 6.1 절에서 말한 대로 프레임워크가 합니다. 플레이어가 회수될 때 프레임워크는 붙어 있는 자식들을 먼저 떼고(OnDetachFrom) 회수한(OnHide) 다음에 플레이어의 OnHide 를 부릅니다. 우리가 할 일은 무기의 OnHide 에서 켜 둔 것을 끄고 참조를 놓는 것뿐입니다. 3장부터 해 온 그대로입니다. 풀에 돌려보내는 것은 받았을 때의 모습으로.

6.4 무기 묶음 — WeaponLoadout

무기를 요청하고, 도착한 무기를 자리에 넣고, 입력을 읽어 신호로 바꾸고, 하나뿐인 쿨다운을 셉니다. 무기는 엔티티지만 이 묶음은 엔티티가 아닙니다. 모습이 없고 플레이어 하나에게 딸린 규칙이라, 플레이어가 들고 굴리는 평범한 C# 객체로 둡니다.
// Assets/GameMain/Weapon/WeaponLoadout.cs using System; using UnityEngine; using UnityEngine.InputSystem; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 플레이어가 장착한 무기 묶음. 장착 요청·전환·발사 입력·전역 쿨다운을 한곳에서 맡는다. /// /// <b>무기는 엔티티다</b>(<see cref="WeaponBase"/>). 이 로드아웃은 무기를 만들지 않는다 — /// <see cref="Equip"/> 이 <c>ShowEntity</c> 로 요청하면 무기가 스스로 플레이어에게 붙고, /// 플레이어가 <c>OnAttached</c>/<c>OnDetached</c> 에서 <see cref="Attach"/>/<see cref="Detach"/> 로 /// 알려 준다. 회수도 하지 않는다. 플레이어가 회수되면 코어가 붙어 있는 무기를 먼저 회수한다. /// /// 로드아웃 자체는 엔티티가 아니다. 소유 <see cref="Player"/> 가 들고 직접 굴린다 — /// "조준점 계산 → 무기" 순서가 엔티티 갱신 순서에 좌우되지 않게 하기 위해서다. /// 활성 무기는 항상 하나다. /// </summary> public class WeaponLoadout { /// <summary>시작 무기 한 종의 명세. 어떤 로직을 어떤 프리팹에 붙여 띄울지와 쿨다운.</summary> private struct WeaponSpec { public Type LogicType; public string AssetName; public float Cooldown; public WeaponSpec(Type logicType, string assetName, float cooldown) { LogicType = logicType; AssetName = assetName; Cooldown = cooldown; } } /// <summary> /// 이 캐릭터가 들고 시작하는 무기. 배열 순서가 곧 슬롯 번호이자 Tab 순환 순서다. /// </summary> private static readonly WeaponSpec[] sStartingWeapons = { new WeaponSpec(typeof(LightningWeapon), WeaponTable.LightningWeaponAsset, WeaponTable.LightningCooldown), }; /// <summary>무기 엔티티의 TypeId. 이 프로젝트는 TypeId 로 구분하지 않아 전부 1 이다.</summary> private const int WeaponTypeId = 1; /// <summary>이 로드아웃의 소유자. 무기는 이 엔티티에 붙는다.</summary> private readonly Player mOwner = null; /// <summary> /// 슬롯별 무기. 무기는 비동기로 로드되므로 <b>아직 도착하지 않은 슬롯은 null 이다.</b> /// 이 중 <see cref="mActiveSlot"/> 하나만 활성이다. /// </summary> private readonly WeaponBase[] mSlots = new WeaponBase[sStartingWeapons.Length]; private int mActiveSlot = 0; /// <summary> /// 무기를 전부 내려놓은 상태인가(사망). 이 뒤에 늦게 도착한 무기를 켜지 않기 위해 둔다. /// </summary> private bool mShutdown = false; // ─── 전역 쿨다운 ─── /// <summary> /// 쿨다운은 <b>무기별이 아니라 전역 하나</b>다. 마지막으로 발사에 성공한 /// 무기의 쿨다운 값을 여기 덮어쓴다. /// 무기를 바꿔도 리셋하지 않는다 — 리셋하면 Tab 연타로 쿨다운을 지울 수 있다. /// </summary> private float mAttackCooldown = 0f; /// <summary>마지막 발사 시각. <see cref="Time.time"/> 기준이다.</summary> private float mTimeOfLastAttack = 0f; /// <summary>지금 발사할 수 있는지.</summary> public bool IsReadyToAttack { get { return Time.time >= mTimeOfLastAttack + mAttackCooldown; } } public WeaponLoadout(Player owner) { mOwner = owner; } /// <summary>지금 들고 있는 무기. 그 슬롯의 무기가 아직 도착 전이면 null.</summary> private WeaponBase ActiveWeapon { get { GameAssert.InRange(mActiveSlot, mSlots.Length, "mActiveSlot"); return mSlots[mActiveSlot]; } } // ─── 장착 ─── /// <summary> /// 시작 무기를 요청한다. 플레이어가 스폰(재스폰)될 때마다 한 번 부른다. /// /// 여기서는 <b>요청만</b> 한다. 무기는 로드가 끝나면 스스로 플레이어에게 붙고, /// 그때 <see cref="Attach"/> 가 불려 슬롯에 들어온다. /// </summary> public void Equip() { // 재스폰 때마다 첫 무기부터, 쿨다운 없이 시작한다. mShutdown = false; mActiveSlot = 0; ClearCooldown(); // 계약: 무기는 소유자 엔티티의 Id 로 붙는다. Entity 가 없으면 붙을 곳이 없다. GameAssert.IsTrue(mOwner != null && mOwner.Entity != null, "WeaponLoadout.Equip: 소유자 엔티티가 없다."); if (mOwner == null || mOwner.Entity == null) return; EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) { Log.Error("WeaponLoadout: EntityComponent 를 찾지 못했다. 무기를 띄울 수 없다."); return; } int ownerId = mOwner.Entity.Id; for (int slot = 0; slot < sStartingWeapons.Length; slot++) { WeaponSpec spec = sStartingWeapons[slot]; int id = EntitySerialId.Next(); entityComponent.ShowEntity( id, spec.LogicType, spec.AssetName, WeaponTable.WeaponGroup, WeaponData.Create(id, WeaponTypeId, ownerId, slot, spec.Cooldown)); } Log.Info("무기 {0}종 요청. 마우스 좌클릭 발사 / Tab 으로 전환.", sStartingWeapons.Length); } /// <summary> /// 무기가 플레이어에게 붙었다. <see cref="Player.OnAttached"/> 가 부른다. /// 도착 순서와 무관하게 자기 슬롯에 들어간다. /// </summary> public void Attach(WeaponBase weapon) { if (weapon == null) return; int slot = weapon.Slot; if (slot < 0 || slot >= mSlots.Length) { Log.Error("WeaponLoadout: 슬롯 {0} 은 범위 밖이다({1}). 이 무기는 쓰이지 않는다.", slot, weapon.GetType().Name); return; } mSlots[slot] = weapon; ApplySelection(); } /// <summary>무기가 플레이어에게서 떨어졌다(회수 직전). <see cref="Player.OnDetached"/> 가 부른다.</summary> public void Detach(WeaponBase weapon) { // 슬롯 번호를 무기에게 다시 묻지 않고 참조로 찾는다. 떨어지는 시점에는 // 무기 쪽 데이터가 이미 정리 중일 수 있다. for (int i = 0; i < mSlots.Length; i++) { if (mSlots[i] == weapon) { mSlots[i] = null; } } } /// <summary> /// 활성 무기만 켠다. 꺼진 무기는 조준 갱신도 발사 입력도 받지 못하고 /// 총구 VFX 도 함께 꺼진다. /// </summary> private void ApplySelection() { for (int i = 0; i < mSlots.Length; i++) { if (mSlots[i] == null) continue; mSlots[i].SetActive(!mShutdown && i == mActiveSlot); } } // ─── 갱신 ─── /// <summary> /// 활성 무기의 조준을 갱신하고 발사 입력을 읽는다. /// 플레이어가 조준점 계산 뒤에 부르므로 조준이 한 프레임 밀리지 않는다. /// </summary> public void OnUpdate() { WeaponBase weapon = ActiveWeapon; if (weapon == null) return; // 쿨다운 판정은 프레임당 한 번만 해서 조준 표시(레티클 색)와 실제 발사 가부가 // 어긋나지 않게 한다. bool isReady = IsReadyToAttack; weapon.UpdateAim(isReady); ReadFireInput(weapon, isReady); } /// <summary> /// 누르는 동안과 떼는 프레임이 나뉘어 있고 /// 무기마다 어느 쪽에서 나가는지가 다르다(Lightning·Frost=누름 / Stink·Slime=뗌). /// </summary> private void ReadFireInput(WeaponBase weapon, bool isReady) { Mouse mouse = Mouse.current; if (mouse == null) { // 마우스가 사라져도(무선 절전, USB 분리) 지속 발사는 끊어야 한다. // 여기서 그냥 return 하면 "뗀 프레임" 이 영영 오지 않아 Frost 콘이 켜진 채 굳는다. weapon.StopFiring(); return; } if (mouse.leftButton.wasReleasedThisFrame) { // StopFiring 은 쿨다운 검사 **밖**에서 항상 부른다. weapon.StopFiring(); if (isReady && weapon.TryFireReleased()) { BeginCooldown(weapon.Cooldown); } return; } if (!mouse.leftButton.isPressed) return; if (isReady && weapon.TryFireHeld()) { BeginCooldown(weapon.Cooldown); } } /// <summary>발사에 성공한 무기의 쿨다운을 전역 타이머에 싣는다.</summary> private void BeginCooldown(float cooldown) { // 쿨다운이 없는 무기(Frost)는 0 을 준다. 음수를 실으면 다음 발사가 // 과거로 밀려 다른 무기의 쿨다운까지 건너뛴다. mAttackCooldown = Mathf.Max(0f, cooldown); mTimeOfLastAttack = Time.time; } /// <summary>전역 쿨다운을 지워 즉시 쏠 수 있게 한다. 장착(재스폰) 시점에만 쓴다.</summary> private void ClearCooldown() { mAttackCooldown = 0f; mTimeOfLastAttack = 0f; } /// <summary> /// 무기를 전부 내려놓는다. 총구 VFX·Frost 콘이 여기서 꺼진다. /// 사망 시 부른다 — 죽으면 갱신이 멈춰 무기 스스로는 못 끈다. /// /// 내려놓을 뿐 회수하지 않는다. 무기 엔티티는 플레이어가 회수될 때 함께 회수된다. /// </summary> public void Shutdown() { mShutdown = true; ApplySelection(); } /// <summary>Tab 무기 순환.</summary> public void ReadSwitchInput() { Keyboard keyboard = Keyboard.current; if (keyboard == null || !keyboard.tabKey.wasPressedThisFrame) return; int nextSlot = FindNextEquippedSlot(mActiveSlot); if (nextSlot == mActiveSlot) return; // 전환 전에 이전 무기의 지속 발사를 반드시 끊는다. 버튼을 누른 채 Tab 을 치면 // 그 무기는 "뗀 프레임" 을 영영 못 받는다 — Frost 콘이 켜진 채로 남는다. WeaponBase previous = ActiveWeapon; if (previous != null) { previous.StopFiring(); } mActiveSlot = nextSlot; ApplySelection(); Log.Info("무기 전환 → {0} ({1}/{2})", mSlots[mActiveSlot].GetType().Name, mActiveSlot + 1, mSlots.Length); } /// <summary> /// from 다음부터 돌면서 무기가 도착해 있는 첫 슬롯. 아직 로드 중인 슬롯은 건너뛴다. /// 다른 무기가 하나도 없으면 from 을 그대로 돌려준다. /// </summary> private int FindNextEquippedSlot(int from) { for (int step = 1; step < mSlots.Length; step++) { int slot = (from + step) % mSlots.Length; if (mSlots[slot] != null) return slot; } return from; } } }
C#
복사

코드 읽기

ReadFireInput 이 6.1 절의 표를 그대로 코드로 옮긴 것입니다.
graph TD
    A{"이번 프레임에<br>버튼을 뗐나"} -->|"예"| B["StopFiring()<br>(항상)"]
    B --> C{"쿨다운이 끝났고<br>TryFireReleased() 가 true"}
    C -->|"예"| D["BeginCooldown"]
    A -->|"아니오"| E{"누르고 있나"}
    E -->|"예"| F{"쿨다운이 끝났고<br>TryFireHeld() 가 true"}
    F -->|"예"| D
Mermaid
복사
if (isReady && weapon.TryFireHeld()) 에서 && 의 순서가 중요합니다. C# 은 왼쪽이 false 면 오른쪽을 실행하지 않습니다. 쿨다운 중에는 TryFireHeld() 가 불리지조차 않으므로, 무기는 "불렸다 = 쿨다운이 끝났다"고 믿어도 됩니다.
쿨다운은 타이머를 깎지 않고 시각을 비교합니다. mAttackCooldown -= elapseSeconds 로 매 프레임 줄이는 대신, "마지막으로 쏜 시각"과 "잠글 길이"를 적어 두고 Time.time >= mTimeOfLastAttack + mAttackCooldown 으로 묻습니다. 매 프레임 할 일이 없고, 누가 언제 물어도 같은 답이 나옵니다.
isReady 를 한 번만 계산해 두 곳에 넘깁니다. UpdateAim(isReady) 와 ReadFireInput(weapon, isReady) 가 같은 값을 받습니다. 각자 IsReadyToAttack 을 따로 읽으면 아주 드물게 둘의 답이 달라져 "조준 표시는 초록인데 안 나간다"가 됩니다. 4장에서 조준점을 한 번만 구해 모두가 읽게 한 것과 같은 원칙입니다.
(from + step) % mSlots.Length — % 는 나머지입니다. 자리가 넷이면 0 → 1 → 2 → 3 → 0 으로 돕니다. 목록을 빙글빙글 도는 가장 흔한 표현입니다. FindNextEquippedSlot 은 여기에 한 가지를 더합니다. 비어 있는 자리는 건너뜁니다. 무기가 아직 도착하지 않은 자리로 넘어가면 손에 든 것이 없는 상태가 되기 때문입니다.
Equip() 은 무기를 만들지 않습니다. 요청만 하고 돌아옵니다. 3장부터 써 온 ShowEntity 다섯 인자입니다. 번호, 로직 타입, 프리팹 주소, 그룹, 주문서. 무기는 몇 프레임 뒤에 나타나 스스로 플레이어에게 붙고, 그때 플레이어가 Attach(weapon) 으로 알려 줍니다(6.5 절). 그래서 mSlots 는 한동안 비어 있을 수 있는 배열입니다. ActiveWeapon 이 null 을 돌려줄 수 있고, OnUpdate 의 첫 줄이 그것을 받아 줍니다. 무기가 도착하기 전의 몇 프레임 동안은 버튼을 눌러도 에러 없이 아무 일도 일어나지 않습니다.
무기 묶음은 무기를 치우지도 않습니다. Shutdown() 은 전부 내려놓을 뿐입니다. 회수는 플레이어가 회수될 때 프레임워크가 합니다. mShutdown 깃발은 드문 순서 하나를 막습니다. 플레이어가 죽은 뒤에 늦게 도착한 무기가 Attach → ApplySelection 을 타고 켜지는 경우입니다. 깃발이 서 있으면 어떤 무기도 켜지지 않습니다. 그리고 4장에서 말한 대로, 깃발을 만들었으면 되돌리는 줄(Equip 의 첫 줄)도 같이 만듭니다.
지금 sStartingWeapons 에는 번개 하나만 있습니다. 무기를 하나 만들 때마다 여기에 한 줄씩 추가합니다. 배열의 길이가 곧 자리 수이므로 다른 곳은 고칠 필요가 없습니다.

6.5 플레이어에게 쥐여 주기

Player.cs 를 엽니다. 다섯 군데를 고칩니다.
① 필드를 추가합니다.
/// <summary>장착 무기 묶음. 수명은 이 로직 인스턴스와 같다 — OnInit 에서 만든다.</summary> private WeaponLoadout mWeaponLoadout = null;
C#
복사
② OnInit 의 base.OnInit(userData); 바로 아래에서 만듭니다.
// 필드 초기화식에서는 this 를 넘길 수 없어 여기서 만든다. mWeaponLoadout = new WeaponLoadout(this);
C#
복사
③ OnShow 에서 조준을 잡은 뒤, 상태 기계를 만들기 전에 장착합니다.
ResetAim(); mWeaponLoadout.Equip(); // 상태 기계는 **맨 마지막**에 세운다. 상태가 OnEnter 에서 소유자 값을 읽기 때문이다. CreateStateMachine();
C#
복사
④ 무기가 붙고 떨어질 때 프레임워크가 불러 주는 훅 둘을 덮어씁니다. OnHide 아래에 추가합니다. OnHide 자체는 고치지 않습니다.
/// <summary> /// 자식 엔티티가 붙었다. 지금 붙는 것은 무기뿐이다 — /// <see cref="WeaponLoadout.Equip"/> 이 요청한 무기가 로드를 마치고 스스로 붙어 온다. /// </summary> protected internal override void OnAttached(EntityLogic childEntity, Transform parentTransform, object userData) { base.OnAttached(childEntity, parentTransform, userData); WeaponBase weapon = childEntity as WeaponBase; if (weapon != null) { mWeaponLoadout.Attach(weapon); } } /// <summary>자식 엔티티가 떨어졌다. 무기가 회수되기 직전에 온다.</summary> protected internal override void OnDetached(EntityLogic childEntity, object userData) { WeaponBase weapon = childEntity as WeaponBase; if (weapon != null) { mWeaponLoadout.Detach(weapon); } base.OnDetached(childEntity, userData); }
C#
복사
⑤ TickAliveInput 과 OnDead 입니다.
/// <summary>살아있는 동안의 한 프레임. 조준점 → 이동 입력 → 무기 순서다.</summary> internal void TickAliveInput() { // 무기가 이번 프레임의 조준점을 그대로 쓰므로 순서를 바꾸지 말 것. ReadInput(); mWeaponLoadout.ReadSwitchInput(); mWeaponLoadout.OnUpdate(); }
C#
복사
mDying = true; mDeathTimer = 0f; // 사망 후에는 OnUpdate 가 무기를 굴리지 않으므로 여기서 확실히 끈다. // 안 그러면 총구 VFX 와 타겟 마커가 시체 위에 남는다. mWeaponLoadout.Shutdown(); StopMovementAndCollision(); PlayDeathAnimation();
C#
복사

코드 읽기

무기는 PlayerAliveState 에서만 돕니다. TickAliveInput 은 4장에서 만든 Alive 상태가 부르는 메서드입니다. 무기를 굴리는 코드를 여기 넣었으니, 죽는 중이거나 일시정지 중에는 저절로 무기가 멈춥니다. "죽었을 때 총이 나가면 안 된다"는 if 를 무기 쪽에 쓸 필요가 없습니다. 상태로 나눠 둔 덕을 여기서 봅니다.
순서가 곧 규칙입니다. ReadInput() 이 조준점을 구한 뒤에 mWeaponLoadout.OnUpdate() 가 그것을 읽습니다. 6.1 절의 셋째 이유가 이것이었습니다. 무기는 엔티티지만 스스로 쏘지 않습니다. 무기의 OnUpdate 에는 발사 코드가 없고, 발사는 언제나 이 줄에서 내려갑니다. 주인이 직접 부르면 순서가 코드에 적힌 그대로입니다.
OnInit 에서 만들고 OnShow 에서 장착합니다. 무기 묶음 객체는 플레이어 로직과 수명이 같으므로 한 번 만들어 두고, 나타날 때마다 Equip() 으로 처음 상태로 되돌립니다. 3장부터 반복되는 "한 번 만들 것은 OnInit, 나타날 때마다 되돌릴 것은 OnShow"입니다.
OnAttached 와 OnDetached 는 AttachEntity 의 주인 쪽 절반입니다. 6.3 절에서 무기 쪽에는 OnAttachTo 가 불린다고 했습니다. 같은 순간 주인에게는 OnAttached 가 옵니다. 플레이어는 붙은 것이 무기인지 as 로 확인해 무기 묶음의 자리에 넣습니다. 무기 묶음이 무기를 찾아다니지 않고, 프레임워크가 알려 줄 때 받아 적습니다. 떨어질 때는 OnDetached 가 와서 자리에서 뺍니다.
OnHide 는 고치지 않았습니다. 플레이어가 회수될 때 프레임워크는 붙어 있는 무기를 먼저 전부 떼고 회수한 뒤에야 플레이어의 OnHide 를 부릅니다. 무기를 치우는 줄을 한 줄도 쓰지 않았는데 무기가 남지 않습니다.

6.6 첫 무기 — 번개

번개는 가장 단순합니다. 캐릭터 정면으로 보이지 않는 직선을 긋고, 처음 닿은 것이 적이면 피해를 줍니다. 이런 방식을 히트스캔이라고 합니다. 총알이 날아가는 과정 없이 쏘는 순간 맞습니다.
맞은 자리에는 불꽃이 튀어야 합니다. 이 착탄 이펙트가 이 책의 세 번째 종류의 엔티티입니다.

스스로 사라지는 엔티티 — HitEffect

플레이어와 적은 누군가 죽여야 사라졌습니다. 착탄 이펙트는 정해진 시간이 지나면 혼자 사라집니다. 먼저 주문서입니다.
// Assets/GameMain/Entity/EntityData/EffectData.cs using GameFramework; using UnityEngine; namespace ToyBoxNightmare { /// <summary>수명이 지나면 스스로 회수되는 일회성 이펙트 엔티티용.</summary> public class EffectData : EntityData { /// <summary>호출부가 수명을 지정하지 않았을 때 쓸 값.</summary> private const float DefaultLifetime = 2f; public static EffectData Create(int entityId, int typeId, Vector3 position, Quaternion rotation, float lifetime) { EffectData data = ReferencePool.Acquire<EffectData>(); data.Fill(entityId, typeId); data.Position = position; data.Rotation = rotation; data.Lifetime = lifetime; return data; } /// <summary>이 시간이 지나면 회수된다.</summary> public float Lifetime { get; set; } = DefaultLifetime; public override void Clear() { base.Clear(); // 필드 초기화식은 첫 new 때만 돈다. 기본값 복원도 여기서 해야 한다. Lifetime = DefaultLifetime; } } }
C#
복사
로직입니다. 체력이 없으니 TargetableObject 가 아니라 3장의 EntityLogicBase 를 바로 상속합니다.
// Assets/GameMain/Entity/EntityLogic/HitEffect.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 일회성 착탄 이펙트. 수명이 지나면 스스로 회수된다. /// </summary> public class HitEffect : EntityLogicBase { /// <summary>EffectData 가 오지 않았을 때 쓸 수명.</summary> private const float FallbackLifetime = 2f; private ParticleSystem[] mParticles = null; private float mLifetime = FallbackLifetime; private float mElapsed = 0f; protected internal override void OnInit(object userData) { base.OnInit(userData); mParticles = GetComponentsInChildren<ParticleSystem>(true); // 착탄 이펙트인데 재생할 것이 하나도 없으면 프리팹 구성이 잘못된 것이다. if (mParticles.Length == 0) { Log.Warning("HitEffect '{0}' 에 ParticleSystem 이 없다. 프리팹 구성을 확인할 것.", Name); } } protected internal override void OnShow(object userData) { base.OnShow(userData); mElapsed = 0f; ApplyEffectData(userData as EffectData); // 배치가 끝난 뒤에 재생한다. 프리팹에 따라 PlayOnAwake 가 꺼진 것도 있어 // 명시 호출이 반드시 필요하다. RestartEffects(); OnEffectStarted(); } /// <summary> /// 수명과 배치를 데이터에서 가져온다. /// 데이터가 없으면 지금 자리에서 기본 수명으로 튼다 — 이펙트를 통째로 씹는 것보다 낫다. /// </summary> private void ApplyEffectData(EffectData data) { if (data == null) { Log.Warning("HitEffect '{0}' 에 EffectData 가 오지 않았다. 배치를 건너뛰고 기본 수명 {1}초로 재생한다.", Name, FallbackLifetime); mLifetime = FallbackLifetime; return; } // 0 이하면 첫 OnUpdate 에서 곧바로 회수되어 화면에 아무것도 안 남는다. GameAssert.IsTrue(data.Lifetime > 0f, "EffectData.Lifetime 이 0 이하다. 이펙트가 즉시 회수된다."); mLifetime = data.Lifetime; CachedTransform.position = data.Position; CachedTransform.rotation = data.Rotation; } /// <summary>파생 클래스가 착탄 판정 같은 1회성 처리를 여기에 넣는다.</summary> protected virtual void OnEffectStarted() { } protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); if (IsHiding) return; mElapsed += elapseSeconds; if (mElapsed >= mLifetime) { SafeHide(); } } private void RestartEffects() { RestartParticles(); } /// <summary>풀에서 재사용되므로 이전 재생분을 지우고 처음부터 다시 튼다.</summary> private void RestartParticles() { if (mParticles == null) return; foreach (var ps in mParticles) { if (ps == null) continue; ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(true); // 인자 true 가 자식까지 재생한다. 빼면 스파크가 안 나온다 } } } }
C#
복사

코드 읽기

엔티티의 틀이 가장 작게 드러난 클래스입니다. OnInit 에서 부품(파티클)을 찾고, OnShow 에서 되돌리고 → 주문서를 읽어 자리를 잡고 → 재생하고, OnUpdate 에서 시간이 차면 SafeHide(). 상태가 하나뿐이라 상태 기계도 없습니다. 상태 기계는 상태가 둘 이상일 때 쓰는 도구이지 모든 엔티티의 의무가 아닙니다.
ps.Stop(…, StopEmittingAndClear) 뒤에 ps.Play(true) — 풀에서 다시 나온 파티클은 지난번에 재생하다 만 상태일 수 있습니다. 먼저 깨끗이 지우고 처음부터 틉니다. 이펙트를 풀로 돌릴 때 반드시 필요한 한 쌍입니다.
이펙트를 왜 엔티티로 만드는가. 캐릭터 프리팹 안에 불꽃 오브젝트를 하나 넣어 두고, 맞을 때마다 그 자리로 옮겨 재생해도 됩니다. 하지만 그러면 동시에 하나만 터질 수 있습니다. 번개는 1초에 한 번이니 괜찮아 보이지만, 여러 발이 겹치는 무기를 만드는 순간 앞의 이펙트가 끊깁니다. 엔티티로 띄우면 필요한 만큼 생기고, 풀이 재사용해 줍니다. 6.2 절의 WeaponUtil.SpawnEffect 가 그 일을 합니다. 이제 다시 읽어 보면 3장부터 써 온 ShowEntity 다섯 인자입니다.

번개 줄기 — LightningBoltVfx

총구에서 착탄점까지 지지직거리는 번개 줄기를 그리는 스크립트입니다. 순수한 그림 그리기 코드이고 길이가 280줄쯤 됩니다. 게임의 구조와는 상관이 없으므로 예제 프로젝트의 Assets/GameMain/Weapon/LightningBoltVfx.cs 를 그대로 복사해 옵니다. 무기 쪽에서는 생성자(new LightningBoltVfx(번개 줄기 트랜스폼))와 메서드 셋만 씁니다.
메서드
하는 일
Play(Vector3 endPoint)
자기 위치에서 endPoint 까지 번개를 0.75초 동안 그린다
StopImmediate()
즉시 지운다
OnUpdate(float elapseSeconds)
남은 시간을 줄이고 선의 모양을 바꾼다. 주인이 매 프레임 불러 줘야 한다
이것은 MonoBehaviour 가 아닙니다. 무기 프리팹 안의 LightningBolt 오브젝트(선을 그리는 LineRenderer 가 붙어 있습니다)를 넘겨받아 다루는 평범한 C# 객체입니다. Unity 의 Update 로 혼자 돌지 않고, 무기 엔티티가 자기 OnUpdate 에서 불러 줄 때만 한 프레임씩 움직입니다. 5장의 스포너, 이 장의 무기 묶음과 같은 모양입니다. 이렇게 해 두면 게임 안에서 매 프레임 도는 것이 전부 프레임워크의 갱신에서 내려오는 한 줄기가 됩니다. 무기가 회수되면 번개도 같이 멈춥니다.

무기 — LightningWeapon

// Assets/GameMain/Weapon/LightningWeapon.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 즉발 히트스캔 무기. 발사 버튼을 누르고 있는 동안 쿨다운마다 <b>캐릭터 전방</b>으로 빔을 쏜다. /// /// 조준은 무기가 아니라 캐릭터가 한다 — 플레이어가 마우스 지면 지점을 바라보므로 /// 전방이 곧 마우스 방향이다. 그래서 레티클도 없다. /// </summary> public class LightningWeapon : WeaponBase { /// <summary>빔 연출 오브젝트. 무기 프리팹(LightningAttack) 루트 기준 경로다.</summary> private const string BoltPath = "LightningBolt"; private LightningBoltVfx mBolt = null; /// <summary>빔 연출은 무기 프리팹의 자식이라 인스턴스당 한 번만 찾으면 된다.</summary> protected override void OnWeaponInit() { ResolveBoltVfx(); } /// <summary>풀에서 다시 나올 때 이전 판의 빔이 남아 있지 않게 한다.</summary> protected override void OnWeaponShow() { if (mBolt != null) { mBolt.StopImmediate(); } } private void ResolveBoltVfx() { Transform boltTransform = CachedTransform.Find(BoltPath); if (boltTransform == null) { Log.Warning("LightningWeapon: '{0}' 을 찾지 못했다. 빔 연출 없이 동작한다.", BoltPath); return; } // 프리팹에서 비활성으로 저장돼 있다. 연출 컴포넌트가 돌아야 하므로 켜 두고, // 실제 표시 여부는 LineRenderer/Light 를 껐다 켜서 제어한다. boltTransform.gameObject.SetActive(true); mBolt = new LightningBoltVfx(boltTransform); } /// <summary> /// 빔 연출은 스스로 돌지 않는다. 이 무기가 떠 있는 동안 여기서 굴린다 — /// 손에 들려 있지 않아도 엔티티의 OnUpdate 는 불리므로, 내려놓은 뒤의 잔여 시간도 여기서 소진된다. /// </summary> protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); if (mBolt != null) { mBolt.OnUpdate(elapseSeconds); } } /// <summary> /// 발사 버튼을 누르고 있는 동안. 로드아웃이 쿨다운을 통과시킨 프레임에만 불린다. /// 자동조준도 시야 검사도 없다 — 벽에 막히면 벽에 맞는 것이 정상 동작이다. /// </summary> protected override bool OnFireHeld() { // 발사 원점은 무기 자신(소유자의 안테나에 붙어 있다). Vector3 origin = MuzzleOrigin; // Transform.forward 는 이미 단위 벡터라 정규화가 필요 없다. Vector3 direction = Owner.CachedTransform.forward; // 아무것도 안 맞으면(사이에 콜라이더가 없다) 최대 사거리까지 빔만 그린다. Vector3 beamEndPoint = origin + direction * WeaponTable.LightningRange; RaycastHit hit; if (Physics.Raycast(origin, direction, out hit, WeaponTable.LightningRange, WeaponUtil.HitscanMask)) { beamEndPoint = hit.point; ApplyHitscanHit(hit); } PlayBolt(beamEndPoint); // 빗나가도 쿨다운은 소모한다. return true; } /// <summary>레이가 맞은 것을 처리한다 — 적이면 피해를 주고, 무엇에 맞았든 착탄 이펙트를 띄운다.</summary> private void ApplyHitscanHit(RaycastHit hit) { Entity entity = hit.collider.GetComponentInParent<Entity>(); Enemy hitEnemy = entity != null ? entity.Logic as Enemy : null; if (hitEnemy != null && !hitEnemy.IsDead) { hitEnemy.ApplyDamage(Owner.Entity, WeaponTable.LightningDamage); } WeaponUtil.SpawnEffect(typeof(HitEffect), WeaponTable.LightningHitAsset, hit.point, Quaternion.identity, WeaponTable.LightningHitLifetime); } /// <summary>빔 연출은 없어도 게임플레이는 돌아간다. 프리팹에서 못 찾았으면 조용히 넘어간다.</summary> private void PlayBolt(Vector3 endPoint) { if (mBolt != null) { mBolt.Play(endPoint); } } /// <summary> /// 무기를 내려놓으면 빔 GameObject 가 무기와 함께 보이지 않게 된다. 남은 시간이 흐르기를 /// 기다리지 않고 여기서 바로 끊는다. /// </summary> protected override void OnWeaponDisabled() { if (mBolt != null) { mBolt.StopImmediate(); } } } }
C#
복사

코드 읽기

6.3 절에서 틀을 다 만들어 둔 덕에, 무기 자체는 훅 넷만 채웁니다. OnWeaponInit(번개 줄기 오브젝트를 찾는다), OnWeaponShow(지난 판의 번개를 지운다), OnFireHeld(쏜다), OnWeaponDisabled(끈다). 쿨다운을 세는 코드도, "죽었으면 쏘지 않는다"는 코드도, 총구를 찾는 코드도 없습니다. 경로도 짧습니다. 번개 줄기는 무기 프리팹 안에 있으므로 "LightningBolt" 한 단계입니다. 그리고 OnUpdate 에는 번개 줄기를 굴리는 한 줄뿐입니다. 발사는 거기에 없습니다.
핵심은 OnFireHeld 의 이 호출입니다.
Physics.Raycast(origin, direction, out hit, WeaponTable.LightningRange, WeaponUtil.HitscanMask)
C#
복사
"origin 에서 direction 방향으로 LightningRange 만큼 선을 그어, HitscanMask 에 속한 것 중 처음 닿는 것을 hit 에 담아라." 닿은 것이 있으면 true 입니다.
3장의 클릭 판정에서도 Physics.Raycast 를 썼습니다. 그때는 카메라에서 커서 방향으로 쐈고, 이번에는 안테나에서 캐릭터 정면으로 쏩니다. 같은 도구입니다.
맞은 콜라이더에서 적을 찾아 올라갑니다. hit.collider 는 콜라이더일 뿐입니다. 그것이 적인지는 GetComponentInParent<Entity>() 로 엔티티를 찾고, 그 로직이 Enemy 인지 as 로 확인해 알아냅니다. 가구에 맞았다면 entity 가 null 이라 hitEnemy 도 null 이 되고, 피해 없이 착탄 이펙트만 터집니다.
hitEnemy.ApplyDamage(Owner.Entity, …) — 5장에서는 적이 플레이어의 ApplyDamage 를 불렀고, 이번에는 플레이어의 무기가 적의 것을 부릅니다. 4장에서 만든 그 한 메서드가 양쪽 방향의 전투를 전부 받습니다.
조준하는 코드가 없습니다. 방향은 Owner.CachedTransform.forward, 캐릭터가 보는 쪽입니다. 캐릭터는 4장에서 이미 마우스를 바라보게 만들었으니, 정면으로 쏘면 그것이 곧 조준입니다.

적이 맞았을 때 솜이 튀게 하기

적 프리팹에는 맞았을 때 흰 솜뭉치가 튀는 HitParticles 라는 자식 파티클이 들어 있습니다. 4장에서 TargetableObject 에 비워 둔 훅 OnDamaged 가 여기서 처음 쓰입니다. Enemy.cs 에 추가합니다.
// 프리팹 구조 계약. 자식 이름이 바뀌면 조용히 null 이 되므로 한곳에 모아 둔다. private const string HitParticlesChildName = "HitParticles"; // 프리팹의 HitParticles 자식(흰 솜뭉치). private ParticleSystem mHitParticles = null;
C#
복사
OnInit 에서 WarnOnMissingPrefabParts(); 다음 줄에 CacheVfxReferences(); 를 넣고 메서드를 씁니다.
private void CacheVfxReferences() { // 이름으로 찾는다. GetComponentInChildren<ParticleSystem> 로 잡으면 // 디버프 VFX 자식을 추가하는 순간 계층 순서에 따라 엉뚱한 것이 잡힌다. Transform hit = CachedTransform.Find(HitParticlesChildName); mHitParticles = hit != null ? hit.GetComponent<ParticleSystem>() : null; if (mHitParticles == null) { mHitParticles = GetComponentInChildren<ParticleSystem>(true); } }
C#
복사
ResetPresentation 의 RestoreChildLayers(); 다음에 풀 재사용 대비를 넣습니다.
if (mHitParticles != null) { mHitParticles.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); }
C#
복사
그리고 훅을 덮어씁니다.
// ─── 피격 / 통지 ─── /// <summary>피격 파편.</summary> protected override void OnDamaged(Entity attacker, int damageHitPoints) { if (mHitParticles != null) { mHitParticles.Play(); } }
C#
복사

실행해 보기

이제 컴파일이 맞춰집니다. Play → 캐릭터를 고르면 콘솔에 한 줄이 더 나옵니다.
Character selected: Girl 무기 1종 요청. 마우스 좌클릭 발사 / Tab 으로 전환. Player spawned: Girl
Plain Text
복사
적이 다가오면 그쪽으로 마우스를 두고 왼쪽 버튼을 누르고 있습니다.
번개가 명중한 순간. 맞은 자리에 착탄 이펙트(HitEffect 엔티티)가 터지고 적에게서 솜이 튄다
1초마다 번개가 나갑니다. 토끼와 곰(체력 100)은 두 발, 코끼리(체력 200)는 네 발에 쓰러집니다. 5장에서 K 키로 보던 쓰러지고 가라앉는 연출이 이제 정당하게 나옵니다.
확인할 것들입니다.
•
적을 가구 뒤에 두고 쏘면 가구에 맞고 적은 멀쩡합니다. HitscanMask 에 가구 레이어가 들어 있기 때문입니다.
•
쓰러지는 중인 적 뒤의 적을 쏘면 뒤의 적이 맞습니다. 5장에서 죽은 적의 콜라이더를 전부 꺼 둔 덕입니다.
•
Hierarchy 의 Entity Group - Effect 아래에 LightningHit(Clone) 이 나타났다가 1초 뒤 회색이 됩니다. 연달아 쏘면 그 하나가 계속 재사용됩니다.
•
Hierarchy 에서 플레이어 Girl(Clone) 을 펼치면 Antenna 아래에 LightningAttack(Clone) 이 들어 있습니다. 무기 엔티티가 플레이어에게 붙은 모습입니다. 플레이어가 죽어 사라지면 이 오브젝트는 Entity Group - Weapon 아래로 돌아가 회색이 됩니다.
•
적에게 둘러싸여 죽으면 총구의 반짝임이 꺼집니다. OnDead 의 mWeaponLoadout.Shutdown() 입니다.
이제 게임이 됩니다. 쫓아오는 적을 피해 다니며 번개로 하나씩 쓰러뜨릴 수 있습니다.
실제 플레이 — 번개. 누르고 있는 동안 1초마다 나가고, 맞은 자리에 착탄 이펙트가 터진다
무기가 어디에 붙어 있는지는 에디터에서 보면 분명합니다. 아래는 완성된 예제(무기 넷)를 멈추고 찍은 것입니다.
무기를 쥐고 있는 플레이어. ① Antenna 아래의 무기 넷 ② 비어 있는 Entity Group - Weapon ③ LightningAttack(Clone) 의 Inspector ④ 화면 속 플레이어의 안테나
① 무기 넷이 플레이어의 Antenna 아래에 들어 있습니다. 지금 쥐고 있는 번개만 흰색이고 나머지 셋은 꺼져 있습니다(6.3 절의 "손에 든 무기만 보인다"). ② 무기를 "Weapon" 그룹으로 띄웠는데 그 그룹은 비어 있습니다. AttachEntity 가 무기를 주인의 자식으로 옮기기 때문입니다. 플레이어가 회수되면 무기는 이 그룹 아래로 돌아와 회색이 됩니다. ③ 프리팹에는 없던 Entity 와 Lightning Weapon 이 붙어 있습니다.

6.7 둘째 무기 — 냉기와 상태 이상

번개는 "맞으면 체력이 깎인다"로 끝났습니다. 냉기는 피해가 0 입니다. 대신 적을 얼립니다. 얼어 있는 동안 적은 움직이지 못합니다.
여기서 새로운 질문이 생깁니다. "얼어 있다"는 상태는 누가 들고 있어야 할까요?
무기가 "내가 얼린 적 목록"을 들고 있다가 시간이 되면 풀어 주는 방법이 먼저 떠오릅니다. 하지만 그 사이에 무기를 바꾸면? 플레이어가 죽으면? 얼어 있던 적이 다른 이유로 죽어 풀로 돌아갔다가 다른 적으로 다시 나오면? 무기가 든 목록은 이 모든 경우에 낡은 정보가 됩니다.
그래서 상태는 당하는 쪽이 듭니다. 무기는 적에게 "너 지금 냉기에 닿았어"라고 알려 주기만 하고, 얼지 말지, 언제 녹을지는 적이 스스로 정합니다. 적이 사라지면 상태도 같이 사라지니 낡을 것이 없습니다.
graph LR
    W["FrostWeapon<br>부채꼴 안의 적을 찾는다"] -->|"RefreshFrostContact()<br>닿아 있는 동안 매 프레임"| D["EnemyDebuffs<br>(적 한 마리가 하나씩 든다)"]
    D -->|"1초 연속으로 닿으면"| F["얼음<br>속도 0, 애니메이션 정지"]
    F -->|"닿지 않게 된 뒤 2초"| N["해동"]
Mermaid
복사

적의 상태 이상 — EnemyDebuffs

세 무기(냉기, 악취, 슬라임)가 거는 상태 이상을 한 클래스에 모읍니다. 이것도 엔티티가 아니라 적이 하나씩 들고 굴리는 평범한 C# 객체입니다. Assets/GameMain/Entity 폴더에 만듭니다.
// Assets/GameMain/Entity/EnemyDebuffs.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 적에게 걸리는 디버프 3종 — Frost(빙결) / Stink(도주) / Slime(공격봉인 DoT) — 의 /// 상태와 전이. 추격·공격·사망 연출은 <see cref="Enemy"/> 쪽에 남아 있다. /// /// Enemy 내부 상태로 관리한다. 엔티티가 회수되면 상태도 함께 /// 사라지므로 "주인 잃은 디버프" 가 원천적으로 생기지 않는다. /// /// <b>MonoBehaviour 가 아니다.</b> 소유 <see cref="Enemy"/> 가 인스턴스를 만들어 들고 직접 굴린다. /// /// 이 객체 자체는 엔티티 풀 재사용 사이에 살아남는다. 그래서 <c>Enemy.OnShow</c> 가 /// <see cref="ClearAll"/> 로 이전 판의 상태를 되돌린다. /// </summary> public class EnemyDebuffs { // ─── 튜닝 상수 ─── private const float FrostContactGrace = 0.35f; private const float FrostFreezeDelay = 1f; private const float FrostThawDuration = 2f; /// <summary> /// "한 번도 Frost 콘에 닿은 적 없음" 을 뜻하는 접촉 시각. /// Time.time 이 0 에 가까운 스폰 직후에도 grace 밖으로 판정되도록 충분히 과거여야 한다. /// </summary> private const float NoFrostContactTime = -999f; /// <summary>슬라임 틱 간격의 하한. 0 이하가 들어오면 매 프레임 틱이 나가 버린다.</summary> private const float MinSlimeTickInterval = 0.05f; /// <summary> /// "공격자를 모른다" 를 뜻하는 엔티티 Id. EntitySerialId 의 첫 발급이 1 이라 /// 0 은 어떤 엔티티도 갖지 않는다. /// </summary> private const int NoAttackerId = 0; /// <summary>디버프가 걸리는 대상. 생성 시점에 정해지고 바뀌지 않는다.</summary> private readonly Enemy mOwner = null; // ─── 디버프 상태 ─── // Frost private bool mFrozen = false; private float mFrostTimer = 0f; private float mFrostRefreshTime = NoFrostContactTime; // 해동 대기가 시작됐는지. 콘에서 한 번 벗어나면 재진입해도 해동을 취소하지 않는다. private bool mFrostThawing = false; // Stink private float mFleeTimer = 0f; // Slime private bool mAttackSuppressed = false; private int mSlimeTicksLeft = 0; private float mSlimeTickTimer = 0f; private float mSlimeTickInterval = 0.5f; private int mSlimeTickDamage = 20; /// <summary> /// DoT 를 넣은 공격자를 <b>참조가 아니라 Id 로</b> 들고 있다. DoT 는 최대 3초를 끄는데 /// 그 사이 발사체 엔티티는 회수되고 같은 인스턴스가 다른 발사체로 재사용될 수 있다. /// </summary> private int mSlimeAttackerId = NoAttackerId; public EnemyDebuffs(Enemy owner) { GameAssert.IsTrue(owner != null, "EnemyDebuffs 는 소유 Enemy 없이 만들 수 없다."); mOwner = owner; } // ─── 상태 질의 ─── /// <summary> /// 무력화 상태 — 얼었거나, 도주 중이거나, 공격이 봉인됐다. /// </summary> public bool IsNeutralized => mFrozen || IsFleeing || mAttackSuppressed; /// <summary>Stink 를 맞고 달아나는 중인지.</summary> public bool IsFleeing => mFleeTimer > 0f; /// <summary>Slime 에 걸려 공격이 봉인됐는지. 이동과 타이머는 그대로 돈다.</summary> public bool IsAttackSuppressed => mAttackSuppressed; /// <summary>지금 Frost 콘 안에 있는지. 콘이 grace 안에 접촉을 갱신해 주는 동안만 참이다.</summary> private bool IsInFrostCone => (Time.time - mFrostRefreshTime) <= FrostContactGrace; // ─── 디버프 수신 API ─── /// <summary>Frost 콘 안에 있는 동안 매 재판정 틱마다 호출한다.</summary> public void RefreshFrostContact() { mFrostRefreshTime = Time.time; } /// <summary>Stink 착탄. 도주 지속시간(초). 중첩 시 더 긴 쪽으로 갱신한다.</summary> public void ApplyFlee(float duration) { mFleeTimer = Mathf.Max(mFleeTimer, duration); } /// <summary> /// Slime 명중. 스택하지 않고 리셋한다. /// 첫 틱은 부착 즉시 나간다. /// </summary> public void ApplySlime(int ticks, float tickInterval, int damagePerTick, Entity attacker) { if (ticks <= 0) return; GameAssert.IsTrue(tickInterval > 0f, "Slime 의 tickInterval 은 양수여야 한다."); GameAssert.IsTrue(damagePerTick >= 0, "Slime 의 damagePerTick 이 음수면 적을 회복시킨다."); mSlimeTicksLeft = ticks; mSlimeTickInterval = Mathf.Max(MinSlimeTickInterval, tickInterval); mSlimeTickDamage = damagePerTick; mSlimeTickTimer = 0f; mAttackSuppressed = true; // 참조가 아니라 Id 만 남긴다. mSlimeAttackerId = IsUsableAttacker(attacker) ? attacker.Id : NoAttackerId; mOwner.SetSlimeFxActive(true); } /// <summary> /// 회수된 엔티티를 들고 있지 않는다. Available 은 Entity 가 아니라 Logic 쪽에 있다. /// </summary> private static bool IsUsableAttacker(Entity attacker) { return attacker != null && attacker.Logic != null && attacker.Logic.Available; } /// <summary> /// 틱 시점의 공격자. Id 로 다시 조회하므로 그 사이 회수된 엔티티를 넘기지 않는다. /// 못 찾으면 null 이다 — <b>데미지는 그대로 들어간다</b>. /// </summary> private Entity ResolveSlimeAttacker() { if (mSlimeAttackerId == NoAttackerId) return null; EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null || !entityComponent.HasEntity(mSlimeAttackerId)) return null; Entity attacker = entityComponent.GetEntity(mSlimeAttackerId); return IsUsableAttacker(attacker) ? attacker : null; } // ─── 갱신 ─── /// <summary> /// 소유 Enemy 가 매 프레임 부른다. /// </summary> public void OnUpdate(float elapseSeconds) { if (mOwner.IsDying) { ClearAll(); return; } UpdateFrost(elapseSeconds); UpdateFlee(elapseSeconds); UpdateSlime(elapseSeconds); } private void UpdateFrost(float elapseSeconds) { bool inCone = IsInFrostCone; if (!mFrozen) { UpdateFreezeCharge(inCone, elapseSeconds); return; } UpdateThaw(inCone, elapseSeconds); } /// <summary>빙결 전. 콘 안에 <b>연속으로</b> 머문 시간이 freezeDelay 를 넘어야 언다.</summary> private void UpdateFreezeCharge(bool inCone, float elapseSeconds) { if (!inCone) { // 벗어나면 누적이 0 으로 돌아간다 — 끊긴 노출은 합산하지 않는다. mFrostTimer = 0f; return; } mFrostTimer += elapseSeconds; if (mFrostTimer >= FrostFreezeDelay) { Freeze(); } } /// <summary> /// 빙결 후. 콘 안에 머무는 동안은 계속 얼어 있고, 한 번 벗어나면 해동이 확정된다. /// 해동이 시작된 뒤로는 inCone 을 보지 않는다 — 재진입해도 예정대로 진행된다. /// </summary> private void UpdateThaw(bool inCone, float elapseSeconds) { if (!mFrostThawing) { // 콘 안에 머무는 동안은 계속 얼어 있다. if (inCone) return; // 벗어나는 순간 해동이 확정된다. mFrostThawing = true; mFrostTimer = 0f; } mFrostTimer += elapseSeconds; if (mFrostTimer >= FrostThawDuration) { Unfreeze(); } } private void Freeze() { mFrozen = true; mFrostThawing = false; mFrostTimer = 0f; mOwner.SetSpeedMultiplier(0f); mOwner.SetAnimatorEnabled(false); mOwner.SetFreezeFxActive(true); } private void Unfreeze() { mFrozen = false; mFrostThawing = false; mFrostTimer = 0f; mOwner.SetSpeedMultiplier(1f); mOwner.SetAnimatorEnabled(true); mOwner.SetFreezeFxActive(false); } /// <summary>Stink 도주 지속시간 소진. 0 이하로 내려가면 IsFleeing 이 자연히 꺼진다.</summary> private void UpdateFlee(float elapseSeconds) { if (!IsFleeing) return; mFleeTimer -= elapseSeconds; } private void UpdateSlime(float elapseSeconds) { if (mSlimeTicksLeft <= 0) return; mSlimeTickTimer -= elapseSeconds; if (mSlimeTickTimer > 0f) return; mSlimeTicksLeft--; mSlimeTickTimer = mSlimeTickInterval; // 이미 죽었으면 TargetableObject.ApplyDamage 의 IsDead 가드가 무시한다. // 이 한 방이 치명타면 OnDead → ClearAll → ClearSlime 으로 재진입해 // mSlimeTicksLeft 를 0 으로 만든다. 아래 마무리는 그래도 안전하도록 멱등이다. mOwner.ApplyDamage(ResolveSlimeAttacker(), mSlimeTickDamage); if (mSlimeTicksLeft <= 0) { ClearSlime(); } } private void ClearSlime() { mSlimeTicksLeft = 0; mSlimeTickTimer = 0f; mSlimeAttackerId = NoAttackerId; // 이걸 빼먹으면 한 번 걸린 적이 죽을 때까지 공격 불능이 된다. mAttackSuppressed = false; mOwner.SetSlimeFxActive(false); } /// <summary> /// 디버프 전량 해제. 풀 재사용(<c>Enemy.OnShow</c>)과 사망(<c>Enemy.OnDead</c>) 양쪽에서 부른다. /// </summary> public void ClearAll() { mFrozen = false; mFrostThawing = false; mFrostTimer = 0f; mFrostRefreshTime = NoFrostContactTime; mFleeTimer = 0f; mOwner.SetSpeedMultiplier(1f); mOwner.SetAnimatorEnabled(true); mOwner.SetFreezeFxActive(false); ClearSlime(); } } }
C#
복사

코드 읽기 (냉기 부분)

세 가지가 한 파일에 있지만 지금은 Frost 라고 적힌 부분만 봅니다. 악취와 슬라임은 해당 절에서 다시 읽습니다.
무기가 하는 일은 한 줄입니다. RefreshFrostContact() 는 "방금 냉기에 닿았다"는 시각을 적어 둘 뿐입니다. 나머지는 전부 UpdateFrost 가 매 프레임 스스로 판단합니다.
"지금 닿아 있는가"를 시각으로 판단합니다. IsInFrostCone 은 "마지막으로 닿은 지 0.35초가 안 지났는가"입니다. 무기가 매 프레임 갱신해 주는 동안은 참이고, 무기가 멈추거나 적이 부채꼴을 벗어나면 0.35초 뒤 저절로 거짓이 됩니다. "벗어났다"고 알려 줄 필요가 없습니다. 무기를 갑자기 바꾸든, 플레이어가 죽든, 갱신이 끊기면 알아서 풀립니다. 6.4 절에서 쿨다운을 시각 비교로 만든 것과 같은 요령입니다.
얼고 녹는 과정은 타이머 하나(mFrostTimer)를 번갈아 씁니다.
상황
타이머가 세는 것
끝나면
얼기 전 + 닿아 있다
연속으로 닿은 시간
1초 → Freeze()
얼기 전 + 벗어났다
0 으로 되돌린다 (끊긴 노출은 합치지 않는다)
얼었다 + 닿아 있다
세지 않는다. 계속 얼어 있다
얼었다 + 한 번이라도 벗어났다
녹기까지 남은 시간
2초 → Unfreeze()
Freeze() 는 적의 내부를 직접 건드리지 않습니다. mOwner.SetSpeedMultiplier(0f), mOwner.SetAnimatorEnabled(false) 처럼 적이 열어 둔 창구만 씁니다. 상태 클래스가 주인의 창구를 쓰던 것과 같은 관계입니다.
OnUpdate 첫 줄의 if (mOwner.IsDying) — 죽는 중에는 모든 상태 이상을 풀고 아무것도 하지 않습니다. 얼어서 애니메이터가 꺼진 채로 죽으면 쓰러지는 애니메이션이 나오지 않기 때문입니다.

Enemy 에 연결하기

EnemyDebuffs 가 부르는 창구를 Enemy.cs 에 엽니다. 고칠 곳이 많으니 덩어리별로 나눕니다.
① 상수와 필드
private const float FleeDistance = 10f; /// <summary>도주 목적지를 NavMesh 폴리곤 위로 스냅할 때 허용하는 탐색 반경.</summary> private const float FleeSampleRadius = 4f; /// <summary>도주 방향을 정규화하기 전에 요구하는 최소 제곱 길이.</summary> private const float MinFleeDirectionSqrMagnitude = 0.0001f; private const string FreezeFxChildName = "FreezeFx"; private const string SlimeFxChildName = "SlimeFx";
C#
복사
// 디버프 VFX. 적 프리팹에 비활성 자식으로 구워 두면 자동으로 잡힌다(없어도 동작한다). private GameObject mFreezeFx = null; private GameObject mSlimeFx = null; private float mSpeedMultiplier = 1f; /// <summary> /// Frost/Stink/Slime 상태 전부. OnInit 에서 만들어 이 인스턴스와 수명을 같이한다. /// 순수 C# 객체라 엔티티 풀 재사용 사이에 살아남는다 — 이전 판의 상태는 /// ResetCombatState 가 ClearAll 로 되돌린다. /// </summary> private EnemyDebuffs mDebuffs = null;
C#
복사
② 만들기와 되돌리기 — OnInit 의 base.OnInit(userData); 바로 아래,
// 인스턴스당 1회. OnShow/OnUpdate 보다 반드시 먼저 도므로 이후 경로에서는 항상 유효하다. mDebuffs = new EnemyDebuffs(this);
C#
복사
CacheVfxReferences 의 끝,
// 프리팹에 구워 두면 잡히고, 없으면 null 인 채로 게임플레이만 동작한다. mFreezeFx = FindChildObject(FreezeFxChildName); mSlimeFx = FindChildObject(SlimeFxChildName); } private GameObject FindChildObject(string childName) { Transform child = CachedTransform.Find(childName); return child != null ? child.gameObject : null; }
C#
복사
ResetCombatState 에 추가합니다.
mAttackTimer = 0f; mSpeedMultiplier = 1f; mDeathRequested = false; mSinkRequested = false; mDeathTimer = 0f; mPlayerDeadNotified = false; // 디버프 전량 리셋. 하나라도 빠지면 재사용된 적이 얼어붙은 채 또는 // 공격 불능인 채로 부활한다. mDebuffs.ClearAll();
C#
복사
③ 창구 — 무기가 부르는 것과 EnemyDebuffs 가 부르는 것입니다.
/// <summary> /// 무력화 상태 — 얼었거나, 도주 중이거나, 공격이 봉인됐다. /// </summary> public bool IsNeutralized => mDebuffs.IsNeutralized; /// <summary>이동 속도 배율. 빙결이 0 으로 만든다.</summary> public void SetSpeedMultiplier(float multiplier) { mSpeedMultiplier = Mathf.Clamp(multiplier, 0f, 1f); } // ─── 디버프 수신 API ─── // 무기 쪽(FrostWeapon / StinkHit / SlimeProjectile)이 부르는 창구다. // 상태와 판정은 전부 EnemyDebuffs 에 있고 여기서는 위임만 한다. /// <summary>Frost 콘 안에 있는 동안 매 재판정 틱마다 호출한다.</summary> public void RefreshFrostContact() { mDebuffs.RefreshFrostContact(); } /// <summary>Stink 착탄. 도주 지속시간(초). 중첩 시 더 긴 쪽으로 갱신한다.</summary> public void ApplyFlee(float duration) { mDebuffs.ApplyFlee(duration); } /// <summary>Slime 명중.</summary> public void ApplySlime(int ticks, float tickInterval, int damagePerTick, Entity attacker) { mDebuffs.ApplySlime(ticks, tickInterval, damagePerTick, attacker); } // ─── 디버프 지원 (EnemyDebuffs 전용) ─── // 디버프가 몸에 반영해야 하는 것만 연다. 상태 필드 자체는 열지 않는다. /// <summary>Animator 를 켜고 끈다. 빙결이 끄고 해동이 되돌린다.</summary> internal void SetAnimatorEnabled(bool isEnabled) { if (mAnimator == null) return; mAnimator.enabled = isEnabled; } /// <summary>빙결 VFX 토글. 프리팹에 FreezeFx 자식이 없으면 조용히 무시된다.</summary> internal void SetFreezeFxActive(bool active) { SetFxActive(mFreezeFx, active); } /// <summary>슬라임 VFX 토글. 프리팹에 SlimeFx 자식이 없으면 조용히 무시된다.</summary> internal void SetSlimeFxActive(bool active) { SetFxActive(mSlimeFx, active); } private static void SetFxActive(GameObject fx, bool active) { if (fx != null && fx.activeSelf != active) { fx.SetActive(active); } } /// <summary> /// 디버프 타이머. <b>모든 상태에서 먼저 돌아야 한다</b> — /// 사망 연출 중에도, 플레이어가 죽은 뒤에도. /// </summary> internal void TickDebuffs(float elapseSeconds) { mDebuffs.OnUpdate(elapseSeconds); }
C#
복사
④ 상태 이상이 행동에 끼어드는 곳 — TickChase, TickAttack, OnDead 를 고칩니다.
internal void TickChase(Player player, float elapseSeconds) { if (!IsAgentReady) return; mAgent.isStopped = false; mAgent.speed = mEnemyData.Stats.MoveSpeed * mSpeedMultiplier; mRetargetTimer += elapseSeconds; if (mRetargetTimer < EnemyTable.RetargetInterval) return; mRetargetTimer = 0f; // 추적 목적지는 발밑(트랜스폼 원점)이다. 공격 판정만 콜라이더 중심을 쓴다. // 둘째 인자는 "쫓을 좌표" 다. 지금은 플레이어 위치 그대로이고, // 아군이 생기는 뒤의 장에서 달라진다. Vector3 playerPosition = player.CachedTransform.position; mAgent.SetDestination(GetDestination(playerPosition, playerPosition)); } /// <summary> /// 추적 목적지. 도주 중이면 플레이어 반대 방향으로 달아난다. /// </summary> private Vector3 GetDestination(Vector3 playerPosition, Vector3 chasePosition) { // 도주는 언제나 플레이어 기준이다. 냄새를 뿌린 것이 플레이어이기 때문이다. if (!mDebuffs.IsFleeing) return chasePosition; return GetFleeDestination(playerPosition); } /// <summary>플레이어 반대편으로 FleeDistance 만큼 떨어진, NavMesh 위의 지점.</summary> private Vector3 GetFleeDestination(Vector3 playerPosition) { Vector3 away = CachedTransform.position - playerPosition; away.y = 0f; // 플레이어와 거의 겹쳐 있으면 방향이 없다. 그럴 때는 지금 보는 방향으로 달아난다. if (away.sqrMagnitude < MinFleeDirectionSqrMagnitude) { away = CachedTransform.forward; } Vector3 destination = CachedTransform.position + away.normalized * FleeDistance; NavMeshHit navHit; if (NavMesh.SamplePosition(destination, out navHit, FleeSampleRadius, NavMesh.AllAreas)) { return navHit.position; } // NavMesh 밖이면 도주를 포기한다. 아무 데도 못 가는 것보다 낫다. return playerPosition; }
C#
복사
TickAttack 에서는 타이머를 되감은 뒤, 사거리를 보기 전에 한 줄을 넣습니다.
mAttackTimer -= stats.TimeBetweenAttacks; // 슬라임에 걸리면 데미지만 막는다. 타이머는 계속 돌려야 한다 — // 멈추면 봉인 해제 직후 확정 타격이 나가 더 가혹해진다. if (mDebuffs.IsAttackSuppressed) return;
C#
복사
OnDead 에서는 콜라이더를 끈 다음, 애니메이션을 걸기 전에 넣습니다.
SetCollidersEnabled(false); // 빙결 중에 죽으면 Animator 가 꺼져 있어 사망 애니메이션이 통째로 안 나온다. mDebuffs.ClearAll();
C#
복사
⑤ 모든 상태에서 굴리기 — 마지막으로 EnemyStateBase.OnUpdate 에 한 줄을 넣습니다.
// 데이터 없이 떴거나 이미 회수 요청이 나간 엔티티는 아무것도 하지 않는다. if (!owner.CanRunStates) return; owner.TickDebuffs(elapseSeconds); OnStateUpdate(fsm, owner, elapseSeconds);
C#
복사

코드 읽기

⑤가 5장에서 OnUpdate 를 sealed 로 잠가 둔 이유입니다. 상태 이상의 타이머는 적이 쫓는 중이든, 서 있든, 죽는 중이든 언제나 돌아야 합니다. 만약 각 상태가 알아서 TickDebuffs 를 부르게 했다면, 네 상태 중 하나에서 빠뜨리는 순간 "플레이어가 죽자 얼어 있던 적이 영원히 얼어 있는" 버그가 됩니다(플레이어가 죽으면 적은 Idle 상태로 가니까요). 부모의 잠긴 OnUpdate 에 한 줄을 넣으면 자식 넷이 전부 따릅니다. 주석으로 "꼭 부를 것"이라고 부탁하는 대신 빠뜨릴 수 없는 자리에 둔 것입니다.
상태 이상은 행동을 "대신"하지 않고 "끼어듭니다." 얼음은 mSpeedMultiplier 를 0 으로 만들 뿐이고, 적은 여전히 Chase 상태에서 TickChase 를 부릅니다. 속도가 0 이라 못 움직일 뿐입니다. 도주는 GetDestination 이 돌려주는 좌표만 바꿉니다. 슬라임은 TickAttack 의 중간에서 피해만 막습니다. "Frozen 상태", "Fleeing 상태"를 FSM 에 추가하지 않았습니다. 그렇게 하면 "얼어 있으면서 슬라임에 걸린" 조합마다 상태가 필요해져 2장에서 본 bool 조합 폭발이 다시 일어납니다. 동시에 여러 개가 걸릴 수 있는 것은 상태가 아니라 값으로 둡니다.
세 군데에서 ClearAll() 을 부릅니다. 나타날 때(ResetCombatState), 죽을 때(OnDead), 그리고 죽는 중 매 프레임(EnemyDebuffs.OnUpdate 의 첫 줄). EnemyDebuffs 객체는 적 인스턴스와 함께 풀에서 살아남으므로, 되돌리지 않으면 지난번에 얼어 죽은 적이 얼어 있는 채로 다시 나옵니다.

무기 — FrostWeapon

이제 무기입니다. 부채꼴 안의 적을 찾아 RefreshFrostContact() 를 불러 주는 것이 전부입니다.
// Assets/GameMain/Weapon/FrostWeapon.cs using System.Collections.Generic; using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 빙결. 발사 버튼을 누르는 동안 콘을 켜고 그 안의 적을 얼린다. <b>데미지는 0이다.</b> /// Lightning 같은 딜링 무기와 병행 장착하는 것이 전제다. /// /// <b>쿨다운이 없다.</b> 콘 방향은 캐릭터 전방 고정이고, 캐릭터가 마우스를 /// 바라보므로 결과적으로 마우스 방향이 된다. /// /// 매 프레임 "지금 콘 안에 있는 집합"을 다시 계산한다. /// </summary> public class FrostWeapon : WeaponBase { // 무기 프리팹(FrostAttack) 루트 기준 경로다. private const string FrostConePath = "FrostCone"; private const string FrostArcPath = "FrostArc"; /// <summary>콘 꼭짓점의 FrostAttack 로컬 좌표.</summary> private static readonly Vector3 ApexLocal = new Vector3(-0.015296f, 0.044137f, 0.342790f); /// <summary>방향 벡터로 쓰기엔 너무 짧다고 볼 제곱 길이.</summary> private const float MinDirectionSqrMagnitude = 0.0001f; /// <summary> /// 콘이 지금 어디에 어떻게 놓여 있는지 — 꼭짓점과 바라보는 축. /// 이 둘은 항상 같이 구해서 같이 쓰이므로 한 덩어리로 묶었다. /// </summary> private struct ConePose { public Vector3 Apex; public Vector3 Axis; } private Transform mFrostAttack = null; private GameObject mFrostCone = null; /// <summary>발사 버튼을 누르고 있는가.</summary> private bool mFiring = false; /// <summary>이번 프레임에 얼릴 적들. 매 프레임 재사용해 할당을 없앤다.</summary> private readonly List<Enemy> mConeTargets = new List<Enemy>(16); /// <summary>반경 안 후보 스크래치.</summary> private readonly List<Enemy> mCandidates = new List<Enemy>(16); /// <summary>콘과 판정 기준 트랜스폼은 무기 프리팹 안에 있어 인스턴스당 한 번만 찾으면 된다.</summary> protected override void OnWeaponInit() { // 무기 프리팹의 루트가 곧 FrostAttack 이다. 콘 꼭짓점과 축을 여기서 읽는다. mFrostAttack = CachedTransform; Transform cone = CachedTransform.Find(FrostConePath); if (cone == null) { Log.Warning("FrostWeapon: '{0}' 을 찾지 못했다. 콘 연출 없이 동작한다.", FrostConePath); } mFrostCone = cone != null ? cone.gameObject : null; DisableLegacyArcCollider(); } /// <summary>풀에서 다시 나올 때 콘이 켜진 채로 등장하지 않게 한다.</summary> protected override void OnWeaponShow() { mFiring = false; SetConeActive(false); } /// <summary>코드 판정으로 갈았으므로 원본 판정용 콜라이더는 꺼 둔다(이중 판정 방지).</summary> private void DisableLegacyArcCollider() { Transform arc = CachedTransform.Find(FrostArcPath); if (arc == null) return; MeshCollider meshCollider = arc.GetComponent<MeshCollider>(); if (meshCollider != null) { meshCollider.enabled = false; } } /// <summary> /// 누르는 동안 콘을 켠다. <b>false 를 돌려준다</b> — 쿨다운을 소모하지 않는 발사라 /// 로드아웃이 전역 쿨다운을 걸면 안 된다. /// </summary> protected override bool OnFireHeld() { mFiring = true; SetConeActive(true); return false; } /// <summary> /// 버튼을 뗀 프레임. 로드아웃이 <b>쿨다운과 무관하게</b> 불러 주므로 콘이 남지 않는다. /// </summary> protected override void OnStopFiring() { mFiring = false; SetConeActive(false); } /// <summary> /// 콘이 켜져 있는 동안의 재판정. 활성 무기에 대해 매 프레임 불리는 /// 훅이 이것뿐이라 여기에 얹었다. /// </summary> protected override void OnUpdateAim(bool isReady) { if (!mFiring) return; int frozenCount = CollectCone(mConeTargets); for (int i = 0; i < frozenCount; i++) { mConeTargets[i].RefreshFrostContact(); } } /// <summary>지금 실제로 얼릴 적들 — 반경과 부채꼴 각도를 둘 다 통과한 집합이다.</summary> private int CollectCone(List<Enemy> results) { results.Clear(); ConePose cone; if (!TryGetCone(out cone)) return 0; int candidateCount = WeaponUtil.FindEnemiesInSphere( cone.Apex, WeaponTable.FrostConeRadius, mCandidates); for (int i = 0; i < candidateCount; i++) { if (IsInCone(mCandidates[i], cone)) { results.Add(mCandidates[i]); } } return results.Count; } /// <summary> /// 콘의 현재 자세. 축은 <b>캐릭터 전방</b>이다. 판정을 눈에 보이는 콘과 같은 /// 트랜스폼에서 뽑아야 둘이 어긋나지 않는다. /// </summary> private bool TryGetCone(out ConePose cone) { cone = new ConePose { Apex = Vector3.zero, Axis = Vector3.forward }; if (mFrostAttack == null || Owner == null) return false; cone.Apex = mFrostAttack.TransformPoint(ApexLocal); Vector3 axis = mFrostAttack.forward; axis.y = 0f; // 안테나가 정확히 수직을 보고 있으면 수평 성분이 사라진다. 그때만 캐릭터 정면으로 대신한다. if (axis.sqrMagnitude < MinDirectionSqrMagnitude) { axis = Owner.CachedTransform.forward; axis.y = 0f; } // 축은 반드시 수평이어야 한다. IsInCone 은 적까지의 상대 벡터만 눕히므로, // 축에 y 가 남으면 두 벡터 사이 각이 부풀려져 부채꼴이 실제보다 좁아진다. if (axis.sqrMagnitude < MinDirectionSqrMagnitude) { // 수평 방향을 만들 수 없으니 이번 판정은 포기한다. return false; } cone.Axis = axis; return true; } private static bool IsInCone(Enemy enemy, ConePose cone) { Vector3 toEnemy = enemy.CachedTransform.position - cone.Apex; toEnemy.y = 0f; return Vector3.Angle(cone.Axis, toEnemy) <= WeaponTable.FrostConeHalfAngle; } /// <summary>다른 무기로 전환되면 콘과 루프 사운드를 반드시 끈다.</summary> protected override void OnWeaponDisabled() { mFiring = false; SetConeActive(false); } private void SetConeActive(bool active) { if (mFrostCone != null && mFrostCone.activeSelf != active) { mFrostCone.SetActive(active); } } } }
C#
복사
WeaponLoadout 의 sStartingWeapons 에 한 줄을 추가합니다. 냉기는 쿨다운이 없으므로 FrostCooldown 은 0 입니다.
new WeaponSpec(typeof(LightningWeapon), WeaponTable.LightningWeaponAsset, WeaponTable.LightningCooldown), new WeaponSpec(typeof(FrostWeapon), WeaponTable.FrostWeaponAsset, WeaponTable.FrostCooldown),
C#
복사

코드 읽기

번개와 훅을 다르게 씁니다. 번개는 OnFireHeld 에서 한 번 쏘고 true 를 돌려줬습니다. 냉기는 OnFireHeld 에서 깃발을 세우고 분사를 켠 뒤 false 를 돌려주고, 실제 판정은 매 프레임 불리는 OnUpdateAim 에서 합니다. 끌 때는 OnStopFiring. 6.1 절에서 StopFiring 을 "쿨다운과 상관없이 항상 불린다"고 따로 둔 이유가 이 무기입니다.
부채꼴 판정은 두 단계입니다. CollectCone 을 보세요.
graph LR
    A["FindEnemiesInSphere<br>반지름 6 안의 적 전부"] --> B["IsInCone<br>그중 정면 ±30° 안에 있는 적만"]
    B --> C["RefreshFrostContact"]
Mermaid
복사
먼저 물리 엔진에게 "이 원 안의 적"을 묻고(싸고 빠릅니다), 그 결과를 "정면 방향과 이루는 각도"로 한 번 더 거릅니다. Vector3.Angle(축, 적 방향) 이 30° 이하면 부채꼴 안입니다. 원과 각도를 합치면 부채꼴이 됩니다. 복잡한 모양의 판정은 대개 이렇게 싼 검사로 후보를 줄이고 정밀한 검사로 거르는 두 단계로 만듭니다.
여기서 6.2 절의 FindEnemiesInSphere 를 다시 읽어 보세요. 주석에 적힌 함정 둘이 이 무기에서 실제 문제가 됩니다. 적 한 마리에 콜라이더가 둘이라 같은 적이 두 번 잡히고(중복 제거), 큰 트리거 구 때문에 중심이 원 밖인 적도 잡힙니다(거리 재검사).
out 인자를 받는 TryGetCone — 4장의 TryGetGroundPoint, 5장의 TryGetStats 와 같은 모양입니다. "실패할 수 있고 실패가 에러는 아닌" 일에 계속 같은 모양을 씁니다.

실행해 보기

Play → 캐릭터를 고르고, Tab 을 누릅니다.
무기 2종 요청. 마우스 좌클릭 발사 / Tab 으로 전환. 무기 전환 → FrostWeapon (2/2)
Plain Text
복사
적 쪽으로 마우스를 두고 버튼을 누르고 있습니다.
냉기를 뿜는 중. 부채꼴 안에 1초 이상 머문 적은 얼어서 멈춘다
•
냉기 속으로 걸어 들어온 적이 1초 뒤 그 자리에서 굳습니다. 애니메이션도 멈춥니다.
•
버튼을 떼거나 방향을 돌리면 2초 뒤 다시 움직입니다.
•
얼려 놓고 Tab 으로 번개로 바꿔 쏩니다. 이 게임의 기본 전법입니다.
•
버튼을 누른 채로 Tab 을 눌러 봅니다. 분사가 꺼집니다. ReadSwitchInput 의 previous.StopFiring() 입니다.
•
번개를 쏜 직후(쿨다운 1초 중)에 Tab 으로 바꿔 냉기를 누르면 쿨다운이 끝난 뒤에 켜집니다. 쿨다운이 전체에 하나라는 뜻입니다.
실제 플레이 — 냉기. 부채꼴 안에 머문 적이 얼어서 멈춘다

6.8 셋째 무기 — 악취와 투사체

악취는 앞의 둘과 두 가지가 다릅니다. 쏘는 순간과 맞는 순간 사이에 날아가는 시간이 있고, 어디에 떨어질지 미리 보여 주는 조준 표시가 있습니다.
sequenceDiagram
    participant W as StinkWeapon
    participant P as StinkProjectile (엔티티)
    participant H as StinkHit (엔티티)
    participant E as 주변의 Enemy 들
    Note over W: 누르는 동안: 레티클만 그린다
    W->>P: 뗀 순간 ShowEntity (착탄점을 실어서)
    Note over P: 포물선으로 날아간다
    P->>H: 도착하면 ShowEntity (그 자리에)
    P->>P: SafeHide
    H->>E: 나타나는 순간 반경 4 안의 적에게 ApplyFlee(4초)
    Note over H: 가스 구름. 4초 뒤 스스로 사라진다
Mermaid
복사
엔티티가 엔티티를 낳습니다. 무기가 투사체를 띄우고, 투사체가 착탄 이펙트를 띄우고, 착탄 이펙트가 적에게 영향을 줍니다. 각자 자기 할 일만 하고 사라집니다.

조준 표시 — WeaponBase 에 레티클 붙이기

6.3 절에서 미뤄 둔 부분입니다. 무기 프리팹 StinkAttack 안에는 TargetRing 이라는 고리 모양 오브젝트가 들어 있습니다. 이것을 커서가 가리키는 바닥에 놓고, 상태에 따라 색을 바꿉니다.
색
TargetState
뜻
🔴 빨강
Invalid
거기에는 쏠 수 없다
🟡 노랑
NotReady
쏠 수 있는 곳이지만 쿨다운 중이다
🟢 초록
Ready
지금 떼면 나간다
WeaponBase.cs 클래스의 끝에 다음을 추가합니다.
// ─── 레티클 ─── /// <summary> /// 조준 지점에 깔 레티클의 경로(무기 프리팹 루트 기준). null 이면 레티클이 없는 무기다. /// /// 레티클은 무기 프리팹의 자식이라 무기를 내려놓으면 같이 꺼진다. /// </summary> protected virtual string ReticlePath => null; /// <summary>지면과 겹쳐 z-fighting 나지 않게 살짝 띄운다.</summary> private const float ReticleGroundOffset = 0.06f; /// <summary> /// 부모(캐릭터·안테나)가 회전하므로 월드 회전을 고정해 항상 지면에 눕힌다. /// </summary> private static readonly Quaternion ReticleGroundRotation = Quaternion.Euler(-90f, 0f, 0f); /// <summary>레거시 파티클 셰이더의 색 프로퍼티.</summary> private const string LegacyTintColorProperty = "_TintColor"; /// <summary>URP Lit/Unlit 의 색 프로퍼티.</summary> private const string UrpBaseColorProperty = "_BaseColor"; /// <summary> /// 색 적용용 공유 블록. 매 프레임 도는 경로라 인스턴스마다 만들면 그대로 GC 압력이 된다. /// </summary> private static MaterialPropertyBlock sReticleBlock = null; private Transform mReticle = null; private Renderer mReticleRenderer = null; /// <summary> /// 레티클을 조준 지점에 놓고 상태에 맞는 색을 입힌다. /// 레티클이 없는 무기가 불러도 조용히 무시된다. /// </summary> protected void ShowReticle(Vector3 worldPosition, TargetState state) { if (mReticle == null) return; if (!mReticle.gameObject.activeSelf) { mReticle.gameObject.SetActive(true); } worldPosition.y += ReticleGroundOffset; mReticle.position = worldPosition; mReticle.rotation = ReticleGroundRotation; ApplyReticleColor(WeaponUtil.GetTargetColor(state)); } /// <summary>레티클을 숨긴다. 없는 무기가 불러도 조용히 무시된다.</summary> protected void HideReticle() { if (mReticle == null || !mReticle.gameObject.activeSelf) return; mReticle.gameObject.SetActive(false); } /// <summary>레티클과 그 렌더러를 찾아 둔다. OnInit 에서 한 번 부른다.</summary> private void ResolveReticle() { string path = ReticlePath; // 레티클을 쓰지 않는 무기다. 정상 경로이므로 로그를 남기지 않는다. if (string.IsNullOrEmpty(path)) return; mReticle = CachedTransform.Find(path); if (mReticle == null) { Log.Warning("{0}: 레티클 '{1}' 을 찾지 못했다.", GetType().Name, path); return; } // 렌더러도 지금 잡아 둔다. 색 적용은 매 프레임이라 GetComponent 를 반복할 수 없다. mReticleRenderer = mReticle.GetComponent<Renderer>(); if (mReticleRenderer == null) { mReticleRenderer = mReticle.GetComponentInChildren<Renderer>(true); } if (mReticleRenderer == null) { Log.Warning("{0}: 레티클 '{1}' 에 Renderer 가 없다. 위치만 움직이고 색은 바뀌지 않는다.", GetType().Name, path); } } /// <summary> /// 레티클 색을 바꾼다. <c>renderer.material</c> 을 직접 건드리면 Renderer 마다 /// 머티리얼 사본이 생겨 샌다 — 반드시 MaterialPropertyBlock 으로 간다. /// </summary> private void ApplyReticleColor(Color color) { if (mReticleRenderer == null) return; if (sReticleBlock == null) { sReticleBlock = new MaterialPropertyBlock(); } mReticleRenderer.GetPropertyBlock(sReticleBlock); sReticleBlock.SetColor(LegacyTintColorProperty, color); sReticleBlock.SetColor(UrpBaseColorProperty, color); mReticleRenderer.SetPropertyBlock(sReticleBlock); }
C#
복사
그리고 앞에서 만든 메서드 넷에 한 줄씩 넣습니다. OnInit 에서 한 번 찾아 두고,
ResolveReticle(); OnWeaponInit();
C#
복사
OnShow 에서 숨기고,
Visible = false; HideReticle(); OnWeaponShow();
C#
복사
SetActive 의 끄는 쪽,
OnStopFiring(); HideReticle(); Visible = false;
C#
복사
마지막으로 OnHide 입니다.
OnWeaponDisabled(); OnStopFiring(); HideReticle();
C#
복사
번개 줄기와 같은 틀입니다. 무기 프리팹 안의 자식을 OnInit 에서 한 번 찾아 기억하고, 나타날 때와 돌아갈 때 꺼 둡니다. 레티클은 무기의 자식이지만 위치는 월드 좌표로 놓습니다. 무기는 안테나에 붙어 캐릭터와 함께 돌고 있으므로, 그대로 두면 고리가 캐릭터를 따라 빙빙 돕니다. 그래서 ShowReticle 이 매 프레임 position 과 rotation 을 직접 써넣습니다. 새로운 것은 색을 바꾸는 방법 하나입니다.
MaterialPropertyBlock — 오브젝트의 색을 바꾸는 가장 쉬운 방법은 renderer.material.color = … 입니다. 그런데 renderer.material 을 읽는 순간 Unity 는 그 오브젝트만을 위한 머티리얼 복사본을 만듭니다. 복사본은 우리가 직접 지우지 않으면 남습니다. MaterialPropertyBlock 은 머티리얼을 복사하지 않고 "이 렌더러만 이 값을 다르게 써라"고 덧씌웁니다. 매 프레임 색을 바꾸는 곳에서는 반드시 이쪽을 씁니다.

투사체의 공통 부모

악취와 슬라임은 둘 다 날아가는 엔티티를 씁니다. 공통 부분부터 만듭니다.
// Assets/GameMain/Entity/EntityLogic/ProjectileLogicBase.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 투사체 공통 베이스. 수명 상한과 착탄 이펙트 스폰만 공유한다 — /// 궤적(포물선/유도)은 파생 클래스가 전적으로 결정한다. /// </summary> public abstract class ProjectileLogicBase : EntityLogicBase { /// <summary>파생 클래스가 따로 잡지 않았을 때 쓰는 수명 상한.</summary> private const float DefaultMaxLifetime = 3f; /// <summary> /// 이 시간이 지나면 무조건 회수된다. 유실 투사체 방지. /// OnShow 에서 매번 기본값으로 되돌린다 — /// 빼면 이전 인스턴스가 마지막으로 잡은 수명을 물고 나온다. /// </summary> protected float MaxLifetime = DefaultMaxLifetime; private float mElapsed = 0f; /// <summary>이 투사체가 들고 있는 파티클. 풀 재사용 시 Stop → 배치 → Play 순서가 중요하다.</summary> private ParticleSystem[] mParticles = null; protected internal override void OnInit(object userData) { base.OnInit(userData); mParticles = GetComponentsInChildren<ParticleSystem>(true); } protected internal override void OnShow(object userData) { base.OnShow(userData); mElapsed = 0f; // 파생 클래스는 base.OnShow 를 먼저 부르고 그 뒤에 자기 수명을 // 덮어쓰므로, 여기서 기본값으로 되돌려도 파생 값이 그대로 살아 있다. MaxLifetime = DefaultMaxLifetime; // 배치 전에 반드시 멈춘다. 순서를 지키지 않으면 재사용 시 // 이전 위치에서 현재 위치까지 연기 줄기가 그어진다. StopParticles(); } /// <summary>위치를 다 잡은 뒤 파생 클래스가 호출한다.</summary> protected void PlayEffects() { PlayParticles(); } private void PlayParticles() { if (mParticles == null) return; foreach (var ps in mParticles) { if (ps != null) { ps.Play(true); // 인자 true 가 자식까지 재생한다 } } } protected void StopParticles() { if (mParticles == null) return; foreach (var ps in mParticles) { if (ps != null) { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } } } protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); if (IsHiding) return; mElapsed += elapseSeconds; if (mElapsed >= MaxLifetime) { SafeHide(); return; } OnFly(elapseSeconds); } /// <summary>매 프레임 궤적을 진행시킨다.</summary> protected abstract void OnFly(float elapseSeconds); /// <summary>착탄 이펙트 엔티티를 띄운다.</summary> protected static void SpawnEffect(System.Type logicType, string assetName, Vector3 position, Quaternion rotation, float lifetime) { if (logicType == null || string.IsNullOrEmpty(assetName)) { Log.Error("착탄 이펙트 스폰 인자가 잘못됐다. logicType={0}, assetName='{1}' — WeaponTable 을 확인할 것.", logicType, assetName); return; } WeaponUtil.SpawnEffect(logicType, assetName, position, rotation, lifetime); } } }
C#
복사
HitEffect 와 닮았습니다. 시간이 차면 스스로 사라집니다. 다른 점은 OnFly 라는 빈칸을 자식에게 남겨 둔 것입니다. 부모는 "언제까지 살 것인가" 를 책임지고, 자식은 "어떻게 날 것인가" 만 씁니다.
MaxLifetime 은 안전장치입니다. 투사체가 어떤 이유로든 목표에 도착하지 못하면(목표가 사라지거나, 계산이 어긋나거나) 영원히 날아다닙니다. 그런 투사체는 눈에 띄지 않게 쌓여서 게임을 느리게 만듭니다. "아무리 길어도 이 시간이 지나면 치운다"는 상한을 부모에 두면 어떤 자식도 이 보장에서 벗어날 수 없습니다.
멈추고 → 옮기고 → 재생한다. OnShow 에서 StopParticles() 로 지난번 파티클을 지우고, 자식이 위치를 잡은 뒤에 PlayEffects() 를 부릅니다. 순서가 바뀌면 주석대로 지난번 위치에서 이번 위치까지 연기 줄기가 한 줄 그어집니다. 풀에서 꺼낸 파티클을 순간이동시킬 때 흔히 겪는 일입니다.

포물선 투사체 — StinkProjectile

주문서입니다.
// Assets/GameMain/Entity/EntityData/ArcProjectileData.cs using GameFramework; using UnityEngine; namespace ToyBoxNightmare { /// <summary>포물선 투사체(Stink)용. 착탄점을 발사 시점에 확정한다.</summary> public class ArcProjectileData : EntityData { public static ArcProjectileData Create(int entityId, int typeId, Vector3 position, Vector3 impactPoint, float speed) { ArcProjectileData data = ReferencePool.Acquire<ArcProjectileData>(); data.Fill(entityId, typeId); data.Position = position; data.ImpactPoint = impactPoint; data.Speed = speed; return data; } /// <summary>지면 착탄점. 발사 순간에 고정된다 — 매 프레임 재조준하면 아치가 깨진다.</summary> public Vector3 ImpactPoint { get; set; } /// <summary>수평 진행 속도(초당 거리).</summary> public float Speed { get; set; } = WeaponTable.StinkSpeed; public override void Clear() { base.Clear(); ImpactPoint = Vector3.zero; // 필드 초기화식은 첫 new 때만 돈다. 기본값 복원도 여기서 해야 한다. Speed = WeaponTable.StinkSpeed; } } }
C#
복사
로직입니다.
// Assets/GameMain/Entity/EntityLogic/StinkProjectile.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 악취 투사체. 물리를 쓰지 않는다 — 직선 등속으로 나아가면서 /// 진행률에 따른 커브 값을 Y 에 더해 포물선처럼 보이게 한다. /// </summary> public class StinkProjectile : ProjectileLogicBase { /// <summary>높이 커브. 최고점 1.0 유닛 @ 진행률 0.80.</summary> private static readonly AnimationCurve Arc = new AnimationCurve( new Keyframe(0.00000095f, 0.0000138f, 1.250883f, 1.250883f), new Keyframe(0.7994252f, 1f, -0.060324907f, -0.060324907f), new Keyframe(0.8967965f, 0.85f, -4.88826f, -4.88826f), new Keyframe(0.9999976f, 0.0000331f, -8.236026f, -8.236026f)); /// <summary>속도가 0 이하로 들어오면 영원히 도착하지 못한다.</summary> private const float MinSpeed = 0.1f; /// <summary> /// 이보다 짧으면 방향 정규화도 진행률 계산도 의미가 없다(0 나눗셈). 즉시 착탄시킨다. /// </summary> private const float MinFlightDistance = 0.01f; /// <summary>비행 예정 시간에 얹는 여유. 이 시간이 지나면 베이스가 강제로 회수한다.</summary> private const float LifetimeMargin = 0.5f; /// <summary> /// 가스 구름 프리팹의 루트가 X -90° 로 저작돼 있다. identity 로 두면 구름이 눕는다. /// </summary> private static readonly Quaternion HitRotation = Quaternion.Euler(-90f, 0f, 0f); private Vector3 mStart = Vector3.zero; private Vector3 mPath = Vector3.zero; /// <summary>mPath 의 단위 벡터. 발사 후 변하지 않으므로 매 프레임 정규화하지 않는다.</summary> private Vector3 mDirection = Vector3.forward; private float mTotal = 0f; private float mTraveled = 0f; private float mSpeed = WeaponTable.StinkSpeed; protected internal override void OnShow(object userData) { base.OnShow(userData); var data = userData as ArcProjectileData; if (data == null) { Log.Error("StinkProjectile data is invalid."); SafeHide(); return; } mStart = data.Position; mSpeed = Mathf.Max(MinSpeed, data.Speed); mPath = data.ImpactPoint - mStart; mTotal = mPath.magnitude; mDirection = Vector3.forward; mTraveled = 0f; // 아래 계산이 전부 mTotal 로 나누므로, 0 나눗셈이 될 거리는 여기서 걸러낸다. if (mTotal < MinFlightDistance) { Explode(); return; } mDirection = mPath / mTotal; MaxLifetime = mTotal / mSpeed + LifetimeMargin; CachedTransform.position = mStart; CachedTransform.rotation = Quaternion.identity; PlayEffects(); } protected override void OnFly(float elapseSeconds) { mTraveled += mSpeed * elapseSeconds; CachedTransform.position = GetArcPosition(mTraveled); bool hasArrived = mTraveled >= mTotal; if (hasArrived) { Explode(); } } /// <summary>진행 거리에 대응하는 궤적 위의 한 점 — 직선 위치에 진행률 커브 높이를 얹은 것.</summary> private Vector3 GetArcPosition(float traveled) { float progress = Mathf.Clamp01(traveled / mTotal); Vector3 position = mStart + mDirection * traveled; position.y += Arc.Evaluate(progress); return position; } private void Explode() { if (IsHiding) return; SpawnEffect(typeof(StinkHit), WeaponTable.StinkHitAsset, GetImpactPoint(), HitRotation, WeaponTable.StinkHitLifetime); SafeHide(); } /// <summary>가스 구름이 설 지면 좌표. 발사 시점에 확정된 착탄점을 Y=0 으로 눌러 쓴다.</summary> private Vector3 GetImpactPoint() { Vector3 impact = mStart + mPath; impact.y = 0f; return impact; } } }
C#
복사

코드 읽기

물리를 쓰지 않습니다. Rigidbody 에 힘을 줘서 던지면 진짜 포물선이 되지만, "정확히 저 지점에 떨어지게" 하려면 초기 속도를 역산해야 하고 다른 물체에 부딪혀 튕기기도 합니다. 여기서는 훨씬 단순하게 합니다. 바닥에서는 직선으로 등속 이동하고, 얼마나 왔는지(0~1)에 따라 높이만 곡선에서 읽어 더합니다.
Vector3 position = mStart + mDirection * traveled; // 직선 위의 위치 position.y += Arc.Evaluate(progress); // 진행률에 따른 높이
C#
복사
AnimationCurve 는 "0~1 사이의 값을 넣으면 곡선 위의 높이를 돌려주는" 도구입니다. 반드시 정해진 곳에 떨어지고, 궤적의 모양은 곡선의 키 값을 고쳐 마음대로 바꿀 수 있습니다. 게임의 "포물선"은 대개 이렇게 그럴듯해 보이게 꾸민 직선입니다.
OnShow 에서 나눗셈에 쓸 값을 미리 걸러 냅니다. mTotal(날아갈 거리)이 0 에 가까우면 mPath / mTotal 도, traveled / mTotal 도 0 으로 나누기가 됩니다. 발밑에 던진 경우입니다. 그럴 때는 날지 않고 그 자리에서 바로 터뜨립니다. 계산이 불가능한 입력을 에러로 만들지 않고 의미 있는 동작으로 바꿨습니다.
MaxLifetime = mTotal / mSpeed + LifetimeMargin — 부모가 준 기본 상한(3초)을 "이 거리를 이 속도로 날면 걸릴 시간 + 0.5초"로 좁힙니다. 정상이라면 도착해서 터지고, 무언가 잘못되어도 0.5초 뒤에는 부모가 치웁니다.
Explode() 는 자기 일을 다음 엔티티에게 넘기고 사라집니다. 가스 구름을 띄우고 SafeHide(). 투사체는 "날아가는 것"만 책임집니다. 첫 줄의 if (IsHiding) return; 은 도착과 수명 만료가 같은 프레임에 겹쳐도 두 번 터지지 않게 합니다.

가스 구름 — StinkHit

// Assets/GameMain/Entity/EntityLogic/StinkHit.cs using System.Collections.Generic; using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 악취 가스 구름. 착탄 순간 한 번만 범위 판정을 하고, 맞은 적을 도주시킨다. /// 도주 타이머는 Enemy 쪽에 있다. /// </summary> public class StinkHit : HitEffect { /// <summary> /// 판정 결과를 담을 공용 버퍼. 매 착탄마다 List 를 새로 만들면 GC 가 돈다. /// static 이어도 안전한 이유는 이 버퍼를 쓰는 구간이 /// 다른 StinkHit 을 스폰하지도, 재진입하지도 않기 때문이다. /// </summary> private static readonly List<Enemy> sBuffer = new List<Enemy>(32); protected override void OnEffectStarted() { base.OnEffectStarted(); int fleeingCount = WeaponUtil.FindEnemiesInSphere( CachedTransform.position, WeaponTable.StinkBlastRadius, sBuffer); GameAssert.IsTrue(fleeingCount <= sBuffer.Count, "FindEnemiesInSphere 가 채운 개수보다 큰 값을 돌려줬다."); MakeEnemiesFlee(fleeingCount); Log.Info("StinkHit: {0}마리 도주", fleeingCount); } private static void MakeEnemiesFlee(int count) { for (int i = 0; i < count; i++) { Enemy enemy = sBuffer[i]; if (enemy == null) continue; enemy.ApplyFlee(WeaponTable.StinkFleeDuration); } } } }
C#
복사
6.6 절의 HitEffect 에 비워 둔 훅 OnEffectStarted() 가 여기서 쓰입니다. 번개의 착탄 이펙트는 보여 주기만 했지만, 이것은 나타나는 순간 일을 합니다. 파티클 재생과 4초 뒤 사라지는 것은 부모가 이미 다 해 줍니다.
enemy.ApplyFlee(4초) 한 줄이 적에게 하는 일의 전부입니다. 6.7 절에서 읽은 대로, 적은 이 숫자를 mFleeTimer 에 적어 두고 그동안 GetDestination 이 플레이어의 반대편을 돌려줍니다. 가스 구름은 4초 뒤 누구에게 무엇을 했는지 기억하지 않은 채로 사라집니다. 되돌릴 것이 없으니 기억할 것도 없습니다.

무기 — StinkWeapon

// Assets/GameMain/Weapon/StinkWeapon.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 악취. 마우스가 가리키는 지면으로 포물선을 던져 착탄 지점 주변의 적을 도주시킨다. /// <b>데미지는 0이다</b>. /// /// <b>버튼을 뗄 때</b> 발사하고, 레티클이 마우스 지점을 따라다니며 /// 3색으로 상태를 알린다. 사거리 밖이면 던지지 않는다(쿨다운도 걸리지 않는다). /// </summary> public class StinkWeapon : WeaponBase { /// <summary>마우스 지면 지점을 가리키는 레티클. 무기 프리팹(StinkAttack) 루트 기준이다.</summary> protected override string ReticlePath => "TargetRing"; /// <summary> /// 레티클을 마우스 지점에 두고 색을 정한다 — /// 사거리를 먼저 보고, 사거리 안일 때만 쿨다운을 본다. /// </summary> protected override void OnUpdateAim(bool isReady) { Vector3 aimPoint = Owner.AimPoint; TargetState state; if (!CanReach(aimPoint)) { state = TargetState.Invalid; // 사거리 밖 — 빨강 } else if (!isReady) { state = TargetState.NotReady; // 쿨다운 중 — 노랑 } else { state = TargetState.Ready; // 지금 던질 수 있다 — 초록 } ShowReticle(aimPoint, state); } /// <summary> /// 버튼을 뗀 프레임에 던진다. 사거리 밖이면 <b>false</b> — 아무것도 안 나갔으니 /// 쿨다운도 걸리지 않는다. /// </summary> protected override bool OnFireReleased() { Vector3 aimPoint = Owner.AimPoint; if (!CanReach(aimPoint)) return false; EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) { Log.Error("StinkWeapon: EntityComponent 를 찾지 못했다. 투사체를 띄울 수 없다."); return false; } Vector3 origin = MuzzleOrigin; // 착탄점은 발사 시점에 확정한다. 매 프레임 재조준하면 아치가 깨진다. Vector3 impact = aimPoint; impact.y = 0f; int id = EntitySerialId.Next(); entityComponent.ShowEntity( id, typeof(StinkProjectile), WeaponTable.StinkProjectileAsset, WeaponTable.ProjectileGroup, ArcProjectileData.Create(id, 1, origin, impact, WeaponTable.StinkSpeed)); return true; } /// <summary> /// 던질 수 있는 지점인가. /// 지면 조준이 실패한 프레임은 착탄점 자체가 대체값(캐릭터 전방 한 칸)이라 무효로 본다. /// </summary> private bool CanReach(Vector3 aimPoint) { if (!Owner.HasAimPoint) return false; // 높이차에 흔들리지 않도록 평면 거리. float distance = WeaponUtil.PlanarDistance(aimPoint, Owner.CachedTransform.position); return distance <= WeaponTable.StinkThrowRange; } } }
C#
복사
sStartingWeapons 에 new WeaponSpec(typeof(StinkWeapon), WeaponTable.StinkWeaponAsset, WeaponTable.StinkCooldown), 한 줄을 추가합니다. 쿨다운 값이 무기 클래스가 아니라 여기에 있다는 점을 봐 두세요. 무기는 주문서로 받은 값을 그대로 돌려줄 뿐입니다.

코드 읽기

앞의 두 무기와 다른 훅을 씁니다. OnFireHeld 는 덮어쓰지 않았고(누르는 동안에는 쏘지 않습니다), OnUpdateAim 에서 레티클을 그리고, OnFireReleased 에서 던집니다.
4장에서 만들어 둔 것을 씁니다. Owner.AimPoint 와 Owner.HasAimPoint 는 4장에서 플레이어가 마우스 쪽을 바라보게 만들며 구해 둔 값입니다. 그때 "6장의 무기도 이 점을 조준에 쓴다"고 public 으로 열어 두었습니다. 무기는 광선을 다시 쏘지 않고 플레이어가 이번 프레임에 이미 구한 점을 읽습니다.
같은 판단을 두 군데서 씁니다. CanReach(aimPoint) 는 레티클 색을 정할 때 한 번, 실제로 던질 때 한 번 불립니다. 판단을 한 메서드에 두었으니 "빨간데 나간다" 거나 "초록인데 안 나간다" 는 어긋남이 생길 수 없습니다. 화면에 보여 주는 것과 실제로 일어나는 것은 반드시 같은 코드에서 나와야 합니다.
착탄점은 던지는 순간 정해집니다. ArcProjectileData.Create(…, origin, impact, …) 에 실어 보낸 뒤로는 무기도 플레이어도 그 투사체에 관여하지 않습니다. 투사체는 OnShow 에서 받은 주문서만 보고 끝까지 갑니다. 3장에서 "주문서는 요청 시점의 값을 실어 나른다"고 한 것이 여기서 그대로 설계가 됩니다.

실행해 보기

Play → Tab 을 두 번 눌러 StinkWeapon 으로 바꿉니다. 커서를 따라 바닥에 고리가 따라다닙니다. 멀리 가져가면 빨강, 가까이 오면 초록입니다. 적들이 모인 곳에 초록 고리를 두고 버튼을 눌렀다 뗍니다.
악취를 던진 뒤. 가스 구름 주변의 적이 전부 달아났다가 4초 뒤 다시 몰려온다
무기 전환 → StinkWeapon (3/3) StinkHit: 9마리 도주
Plain Text
복사
•
가스가 터진 자리 주변의 적이 플레이어 반대쪽으로 흩어집니다. 4초 뒤 다시 돌아옵니다.
•
던진 직후 고리가 노랑이 됩니다. 5초 뒤 초록으로 돌아옵니다.
•
노랑인 동안 Tab 으로 번개로 바꿔 쏴 봅니다. 안 나갑니다. 쿨다운은 전체에 하나입니다.
•
고리가 빨강일 때 떼면 아무 일도 없고, 고리도 노랑이 되지 않습니다. OnFireReleased 가 false 를 돌려줘 쿨다운이 걸리지 않았습니다.
•
Hierarchy 의 Entity Group - Projectile 과 Entity Group - Effect 에 StinkProjectile(Clone), StinkHit(Clone) 이 차례로 나타났다 회색이 됩니다.
실제 플레이 — 악취. 고리를 적들 사이에 두고 떼면 투사체가 포물선을 그리며 날아가 가스가 터지고, 적들이 흩어진다

6.9 넷째 무기 — 슬라임 읽어 보기

마지막 무기는 따라 치지 않고 읽습니다. 새로운 틀은 없고, 지금까지 만든 것들의 조합이기 때문입니다. 예제 프로젝트에서 세 파일을 복사해 오고, sStartingWeapons 에 new WeaponSpec(typeof(SlimeWeapon), WeaponTable.SlimeWeaponAsset, WeaponTable.SlimeCooldown), 한 줄을 추가합니다.
파일
악취의 어느 것에 해당하나
Entity/EntityData/HomingProjectileData.cs
ArcProjectileData — 주문서
Entity/EntityLogic/SlimeProjectile.cs
StinkProjectile — 날아가는 엔티티
Weapon/SlimeWeapon.cs
StinkWeapon — 무기
착탄 이펙트는 따로 만들지 않고 번개처럼 HitEffect 를 그대로 씁니다(주소만 SlimeHit). 적에게 걸리는 효과는 6.7 절에서 이미 EnemyDebuffs 에 넣어 두었습니다.
슬라임. 찍은 적에게 날아가 붙고, 0.5초마다 피해를 준다. 붙어 있는 동안 그 적은 공격하지 못한다
세 파일을 열어 놓고 다음 다섯 가지를 찾아보세요. 악취와 무엇이 다른지가 이 무기의 전부입니다.
① 조준 대상이 "지점"이 아니라 "적"이다. SlimeWeapon.FindTargetUnderCursor() 를 봅니다.
RaycastHit hit; if (!Physics.Raycast(Owner.AimPoint, Vector3.up, out hit, WeaponTable.SlimeTargetRayLength, WeaponUtil.ShootableMask)) { return current; }
C#
복사
커서가 가리키는 바닥 지점에서 위로 짧은 광선(길이 2)을 쏩니다. 그 위에 적이 서 있으면 걸립니다. 화면에서 적을 "찍는다"는 것을 이렇게 구현했습니다. 마스크가 ShootableMask 라 적만 봅니다. 못 찾으면 current(직전에 찍어 둔 적)를 그대로 돌려주는데, 커서가 적에서 살짝 벗어나도 조준이 풀리지 않게 하려는 것입니다.
② 레티클이 적을 따라다닌다. OnUpdateAim 에서 레티클 위치가 mTarget != null ? mTarget.CachedTransform.position : Owner.AimPoint 입니다. 찍은 적이 있으면 그 적의 발밑에, 없으면 커서에 붙습니다. 색을 정하는 세 갈래는 악취와 같은데 첫 조건만 "사거리 밖"에서 "찍은 적이 없다"로 바뀌었습니다.
③ 주문서에 적을 "참조"가 아니라 "번호"로 싣는다. HomingProjectileData 에는 Enemy 필드가 없고 int TargetEntityId 가 있습니다. 그리고 투사체는 매 프레임 그 번호로 적을 다시 찾습니다.
private Enemy ResolveTarget() { var entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null || !entityComponent.HasEntity(mTargetId)) return null; Entity entity = entityComponent.GetEntity(mTargetId); if (entity == null) return null; Enemy enemy = entity.Logic as Enemy; if (enemy == null || !enemy.Available || enemy.IsDead) return null; return enemy; }
C#
복사
이 책에서 여러 번 말한 "풀에서 재사용되므로 참조를 믿지 않는다"의 가장 엄격한 형태입니다. 투사체가 날아가는 동안 그 적이 번개에 맞아 죽고, 풀로 돌아갔다가, 새 적으로 다시 나올 수 있습니다. Enemy 참조를 들고 있었다면 투사체는 방금 태어난 엉뚱한 적을 쫓아갑니다. 엔티티 번호는 스폰될 때마다 새로 발급되므로(3장의 EntitySerialId), 번호로 찾으면 "내가 쏜 바로 그 적이 아직 있는가"를 정확히 알 수 있습니다. 없으면 투사체는 조용히 사라집니다.
💡
참조로 들 것인가, 번호로 들 것인가. 같은 프레임 안에서 쓰고 버릴 것은 참조로 충분합니다(번개의 hitEnemy). 여러 프레임에 걸쳐 들고 있어야 한다면, 그 사이에 대상이 풀을 다녀올 수 있는지 생각합니다. 다녀올 수 있다면 번호로 들고 쓸 때마다 다시 찾습니다. SurvivalGame.Player 속성이 Available 을 매번 확인하던 것은 그 중간쯤의 방법입니다.
④ 궤적이 매 프레임 다시 계산된다. SlimeProjectile.OnFly 는 세 줄입니다. 목표 쪽으로 돌고(TurnToward), 앞으로 가고(Translate(0, 0, 속도 × 시간)), 가까워졌으면 터집니다. 악취가 처음 정한 길을 끝까지 갔다면, 슬라임은 매 프레임 "지금 적이 어디 있지?"를 다시 봅니다. 그래서 도망치는 적도 맞힙니다. 부모의 MaxLifetime(3초)이 여기서 진짜 안전장치 노릇을 합니다. 적이 투사체보다 빨리 달아나면 영원히 못 따라잡으니까요.
⑤ 지속 피해는 적이 스스로 센다. 투사체가 맞는 순간 하는 일은 target.ApplySlime(6, 0.5f, 20, 공격자) 한 줄입니다. 그 뒤 3초 동안 0.5초마다 20씩 깎는 일은 6.7 절의 EnemyDebuffs.UpdateSlime 이 합니다. 다시 열어 읽어 보세요. ClearSlime() 의 주석 "이걸 빼먹으면 한 번 걸린 적이 죽을 때까지 공격 불능이 된다"가 가리키는 줄이 mAttackSuppressed = false; 입니다. 건 것은 반드시 푼다. 그리고 EnemyDebuffs 도 공격자를 mSlimeAttackerId, 번호로 들고 있습니다. ③과 같은 이유입니다.

실행해 보기

Play → Tab 을 세 번 눌러 SlimeWeapon 으로 바꿉니다. 커서를 적 위에 올리면 고리가 그 적의 발밑에 붙고 초록이 됩니다. 눌렀다 떼면 초록 덩어리가 날아가 붙습니다.
•
맞은 적의 체력이 0.5초마다 20씩 줄어, 토끼(체력 100)는 다섯 번째 틱에 혼자 쓰러집니다.
•
슬라임이 붙은 적은 플레이어 옆에 와서도 때리지 못합니다.
•
커서를 빈 바닥에 두면 고리가 빨강이고, 떼어도 아무것도 나가지 않습니다.
•
슬라임을 쏜 직후 그 적을 번개로 먼저 죽이면, 날아가던 투사체가 중간에 사라집니다. ResolveTarget() 이 null 을 돌려준 것입니다.
실제 플레이 — 슬라임. 커서를 올린 적에게 날아가 붙고, 붙어 있는 동안 피해를 준다

6.10 잘 안 될 때

증상
원인과 해결
무기가 보이지 않고 발사도 안 된다. Show entity failure: … (LightningAttack)
무기 프리팹이 Addressables 에 없거나 주소가 다릅니다. Entity group 'Weapon' is not exist. 라면 1장의 엔티티 그룹에 Weapon 이 없습니다
에러는 없는데 무기가 보이지 않고 발사도 안 된다
Player.OnAttached 에서 mWeaponLoadout.Attach(weapon) 을 부르지 않았습니다. 무기는 플레이어에게 붙었지만 무기 묶음의 자리가 비어 있어 아무도 켜 주지 않습니다
Can not find transform path 'Antenna' from parent entity … 경고. 무기가 발밑에서 나간다
캐릭터 프리팹에 Antenna 자식이 없거나 이름이 다릅니다. 그럴 때 무기는 캐릭터 루트에 붙습니다
… 'LightningBolt' 을 찾지 못했다 / 레티클 '…' 을 찾지 못했다
무기 프리팹의 자식 이름이 코드의 문자열과 다릅니다. 프리팹을 열어 이름을 확인합니다. 경로는 무기 프리팹 루트 기준입니다
번개가 나가는데 적이 안 맞는다
적 프리팹의 레이어가 9 (Shootable) 인지 확인합니다. 마스크에 없는 레이어는 광선이 통과합니다
번개가 바로 앞에서 끊긴다
플레이어 자신이나 바닥이 HitscanMask 의 레이어(9, 10, 14)에 들어 있습니다
착탄 이펙트가 안 보인다. Show entity failure: … (LightningHit)
이펙트 프리팹이 Addressables 에 없거나 주소가 다릅니다. Entity group 'Effect' is not exist. 라면 엔티티 그룹이 없습니다
무기를 바꿨는데 이전 무기의 총구 반짝임이 남는다
WeaponBase.SetActive 의 끄는 쪽에서 Visible = false; 를 빠뜨렸습니다
죽은 뒤에도 냉기가 계속 나온다
Player.OnDead 에서 mWeaponLoadout.Shutdown() 을 부르지 않았습니다
다음 판에 냉기를 들자마자 분사가 켜져 있다
WeaponBase.OnHide 에서 OnStopFiring() 을 부르지 않았거나 FrostWeapon.OnWeaponShow 에서 콘을 끄지 않았습니다. 무기가 켜 둔 채로 풀에 돌아갔습니다
냉기를 쬐어도 얼지 않는다
① EnemyStateBase.OnUpdate 에 owner.TickDebuffs(…) 가 있는가 ② FrostWeapon.OnFireHeld 에서 mFiring = true 를 했는가
한 번 언 적이 영원히 얼어 있다
EnemyDebuffs.ClearAll() 을 Enemy.ResetCombatState 에서 부르지 않았거나, TickDebuffs 를 일부 상태에서만 부르고 있습니다
풀에서 다시 나온 적이 T 자 포즈로 굳어 있다
얼어서 애니메이터가 꺼진 채 회수됐습니다. ResetPresentation 의 mAnimator.enabled = true; 가 Rebind() 앞에 있는지 확인합니다
악취 투사체가 지난번 자리에서 이번 자리까지 연기 줄을 긋는다
ProjectileLogicBase.OnShow 의 StopParticles() 가 빠졌거나, PlayEffects() 를 위치를 잡기 전에 불렀습니다
고리 색이 바뀌지 않는다. 레티클 '…' 에 Renderer 가 없다
레티클 오브젝트나 그 자식에 Renderer 가 없습니다
슬라임에 걸렸던 적이 그 뒤로 영영 공격하지 않는다
ClearSlime() 에서 mAttackSuppressed = false; 를 빠뜨렸습니다

이 장에서 만든 것

파일
내용
Weapon/WeaponTable.cs
무기의 숫자 전부
Weapon/WeaponUtil.cs
레이어 마스크, 범위 안의 적 찾기, 이펙트 띄우기
Weapon/WeaponBase.cs
무기 엔티티의 공통 틀 — 주인에게 붙기, 발사 신호, 총구, 레티클
Weapon/WeaponLoadout.cs
무기 묶음 — 무기 요청, 입력, 전환, 전역 쿨다운
Weapon/LightningWeapon.cs, LightningBoltVfx.cs
번개 (히트스캔)
Weapon/FrostWeapon.cs
냉기 (부채꼴 범위, 지속)
Weapon/StinkWeapon.cs
악취 (포물선 투척)
Weapon/SlimeWeapon.cs
슬라임 (유도탄)
Entity/EnemyDebuffs.cs
적의 상태 이상 — 빙결, 도주, 지속 피해
Entity/EntityData/WeaponData.cs, EffectData.cs, ArcProjectileData.cs, HomingProjectileData.cs
무기·이펙트·투사체 주문서
Entity/EntityLogic/HitEffect.cs, StinkHit.cs
스스로 사라지는 착탄 이펙트
Entity/EntityLogic/ProjectileLogicBase.cs, StinkProjectile.cs, SlimeProjectile.cs
투사체
Entity/EntityLogic/Enemy.cs, States/EnemyStateBase.cs (수정)
피격 파편, 상태 이상 연결
Entity/EntityLogic/Player.cs (수정)
무기 묶음 소유·구동, 무기가 붙고 떨어질 때 받아 적기

정리

•
프리팹이 있고 나타났다 사라지는 것은 엔티티다. 다른 엔티티에 딸린 것은 AttachEntity 로 붙인다. 주인이 회수되면 프레임워크가 함께 회수한다
•
비동기로 뜨는 엔티티는 요청과 도착 사이에 세상이 바뀌었을 수 있다. 도착했을 때 주인이 아직 있는지 확인하고, 도착 순서에 기대지 않는다
•
화면에 모습이 없고 한 주인에게 딸린 규칙(무기 묶음)은 주인이 들고 굴리는 평범한 C# 객체로 만든다. 주인이 직접 부르면 순서가 코드에 적힌 그대로다
•
부모는 공개 메서드에서 "해도 되는가"를 검사하고 훅을 부른다. 자식은 훅에 "무엇을"만 쓴다. 기본 훅은 "아무것도 안 한다"
•
시작하는 쪽은 조건을 따지고, 끝내는 쪽은 무조건 된다
•
화면에 보여 주는 판단(레티클 색)과 실제 동작(발사 가부)은 같은 코드에서 나오게 한다
•
일회성 이펙트도 엔티티로 띄우면 겹쳐도 끊기지 않고 풀이 재사용한다
•
상태 이상은 당하는 쪽이 든다. 거는 쪽은 알려 주기만 한다. 대상이 사라지면 상태도 같이 사라진다
•
"아직 닿아 있는가"는 마지막으로 닿은 시각으로 판단하면 "벗어났다"를 알려 줄 필요가 없다
•
동시에 여럿 걸릴 수 있는 것은 FSM 의 상태가 아니라 값으로 두고, 행동에 끼어들게 한다
•
모든 상태가 반드시 해야 하는 일은 잠긴 부모 메서드에 둔다
•
여러 프레임에 걸쳐 가리켜야 하는 엔티티는 참조가 아니라 번호로 들고, 쓸 때마다 다시 찾는다
•
투사체에는 수명 상한을 둔다. 풀에서 꺼낸 파티클은 멈추고 → 옮기고 → 재생한다
•
무기가 켜 둔 것(분사, 번개 줄기, 조준 고리)은 받았을 때의 모습으로 되돌려 풀에 보낸다

직접 해 보기

1.
WeaponTable 에서 LightningCooldown 을 0.2f 로, LightningDamage 를 10 으로 바꿔 기관총처럼 만들어 보세요. 무기 클래스는 열지 않습니다.
2.
FrostConeHalfAngle 을 90f 로 바꾸면 냉기가 어떤 모양이 될까요? 해 보기 전에 예상해 보세요. 눈에 보이는 분사와 실제 판정이 어긋나는 것도 확인합니다(그림은 프리팹에, 판정은 코드에 있기 때문입니다).
3.
StinkProjectile 의 Arc 에서 둘째 키의 높이 1f 를 4f 로 바꿔 보세요. 궤적만 달라지고 떨어지는 지점은 그대로인지 확인합니다.
4.
ProjectileLogicBase.OnShow 에서 StopParticles(); 를 지우고 악취를 여러 번, 서로 다른 곳에 던져 보세요. 두 번째부터 무엇이 보이나요?
5.
LightningWeapon.ApplyHitscanHit 에서 WeaponUtil.SpawnEffect(…) 호출을 반복문으로 세 번 부르게 해 보세요(위치를 조금씩 다르게). Hierarchy 의 Entity Group - Effect 에 LightningHit(Clone) 이 몇 개 생기고, 그 뒤로 더 늘어나는지 지켜봅니다.
6.
(어려움) 다섯 번째 무기를 만들어 보세요. Shotgun — 누르고 있는 동안, 쿨다운 1.5초, 정면 ±20° 반경 5 안의 모든 적에게 피해 30. FrostWeapon 의 CollectCone 과 LightningWeapon 의 ApplyDamage 를 합치면 됩니다. WeaponBase 를 상속하고 훅 하나(OnFireHeld)만 채운 뒤 sStartingWeapons 에 한 줄을 추가합니다. 무기 프리팹은 LightningAttack 을 그대로 써도 됩니다(같은 프리팹에 다른 로직을 입히는 것은 4장에서 해 봤습니다). Player 는 고치지 않습니다.
더 읽기 — ,
다음 장 — : 체력이 얼마나 남았는지, 점수가 몇 점인지, 쿨다운이 언제 끝나는지. 화면에 숫자와 게이지를 올립니다.