feat(core): NerdNavis.Framework 코어코드를 조직 레포에 통합 (PD님 직접 지시)
단일 clone으로 전체 조직 자산 접근 가능하도록 코어코드를 NerdNavisAi 레포 내 배치. - 코어코드/NerdNavis.Framework/ 에 전체 소스 복사 (30파일, .git 제외) - 원본 별도 레포는 유지 (C+H1 배포 방식의 원격 SOT) - 배포 방식: C+H1 (UPM Git URL + 로컬 오버라이드) PD님 승인 완료 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db47786c82
commit
7187ac68b2
|
|
@ -31,6 +31,7 @@ C3·C13 위반에 해당. **즉시 자진 보고 후 소급 등록**.
|
||||||
|
|
||||||
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|
||||||
|---|------|----------|----------|-----------|----------|----------|
|
|---|------|----------|----------|-----------|----------|----------|
|
||||||
|
| 27 | 2026-04-16 | NerdNavis.Framework 코어코드를 NerdNavisAi 조직 레포에 통합 — `코어코드/NerdNavis.Framework/`에 복사, git 커밋·푸시, 대화로그·지시로그 기록 | 진행중 | - | - | - |
|
||||||
| 26 | 2026-04-16 | NerdNavis.Framework git 통합 관리 조치 — 저장소 상태 점검(git remote·원격 연결·코드 구조 정합성), 구현 완료/미완료 모듈 목록 정리, 설계 문서와 실제 코드 정합성 확인 후 보고서 작성 | 완료 | `공유/소통/개발팀→PM/2026-04-16_코어코드_git통합_점검_개발팀.md`, `공유/대화로그/코어프레임워크/2026-04-16.md` | - | - |
|
| 26 | 2026-04-16 | NerdNavis.Framework git 통합 관리 조치 — 저장소 상태 점검(git remote·원격 연결·코드 구조 정합성), 구현 완료/미완료 모듈 목록 정리, 설계 문서와 실제 코드 정합성 확인 후 보고서 작성 | 완료 | `공유/소통/개발팀→PM/2026-04-16_코어코드_git통합_점검_개발팀.md`, `공유/대화로그/코어프레임워크/2026-04-16.md` | - | - |
|
||||||
| 1 | 2026-04-14 | NerdNavisCore 타 회사 소유 전환·담당자 퇴사 사실 통보, 자체 범용 코어 신규 제작 결정 | 진행중 | `개발팀/프로젝트_숙지/수상한잡화점/06_신규코어_설계안_v1.md` (초안), `개발팀/코어_설계/01_아키텍처_개요_v1.md` (v1.2→§4-9 ServiceLocator 신설 추가), `개발팀/코어_설계/02_수상한잡화점_추출대상_v1.md` (13+ 파일 분류표), `개발팀/코어_설계/_skeleton/` (UPM 패키지 스켈레톤), **`D:/NerdNavis/NerdNavis.Framework/` 구현체 — Tier 1 기반 Core 4종 완료 (Log·CoroutineRunner·MonoSingleton·ServiceLocator + 테스트 28건) Gitea push 완료** | - | OI-1(네임스페이스 NerdNavis.*) PD님 확정 반영 완료. **OI-2·3·4·5는 여전히 PD님 판단 대기**. Tier 1+2 MVP 범위 PD님 확정 반영. **Tier 1 잔여 9종(EnumToInt/EnumEx/FormatEx/SafeAreaBorder 등) 대기** |
|
| 1 | 2026-04-14 | NerdNavisCore 타 회사 소유 전환·담당자 퇴사 사실 통보, 자체 범용 코어 신규 제작 결정 | 진행중 | `개발팀/프로젝트_숙지/수상한잡화점/06_신규코어_설계안_v1.md` (초안), `개발팀/코어_설계/01_아키텍처_개요_v1.md` (v1.2→§4-9 ServiceLocator 신설 추가), `개발팀/코어_설계/02_수상한잡화점_추출대상_v1.md` (13+ 파일 분류표), `개발팀/코어_설계/_skeleton/` (UPM 패키지 스켈레톤), **`D:/NerdNavis/NerdNavis.Framework/` 구현체 — Tier 1 기반 Core 4종 완료 (Log·CoroutineRunner·MonoSingleton·ServiceLocator + 테스트 28건) Gitea push 완료** | - | OI-1(네임스페이스 NerdNavis.*) PD님 확정 반영 완료. **OI-2·3·4·5는 여전히 PD님 판단 대기**. Tier 1+2 MVP 범위 PD님 확정 반영. **Tier 1 잔여 9종(EnumToInt/EnumEx/FormatEx/SafeAreaBorder 등) 대기** |
|
||||||
| 2 | 2026-04-14 | 서버 Critical 보안 3건 보류 | 보류 | `개발팀/프로젝트_숙지/수상한잡화점/05_서버연동_현황_v1.md` | 서버 파트 정비 미완료 (PD님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 |
|
| 2 | 2026-04-14 | 서버 Critical 보안 3건 보류 | 보류 | `개발팀/프로젝트_숙지/수상한잡화점/05_서버연동_현황_v1.md` | 서버 파트 정비 미완료 (PD님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# 기본 텍스트 처리: 자동 개행 정규화
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows 배치 파일만 CRLF 유지
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# C# / Unity 텍스트
|
||||||
|
*.cs text diff=csharp
|
||||||
|
*.asmdef text
|
||||||
|
*.json text
|
||||||
|
*.md text
|
||||||
|
*.txt text
|
||||||
|
*.xml text
|
||||||
|
*.yml text
|
||||||
|
*.yaml text
|
||||||
|
|
||||||
|
# Unity 에셋 텍스트 포맷 (Force Text 모드 가정)
|
||||||
|
*.unity text merge=unityyamlmerge
|
||||||
|
*.prefab text merge=unityyamlmerge
|
||||||
|
*.asset text merge=unityyamlmerge
|
||||||
|
*.mat text merge=unityyamlmerge
|
||||||
|
*.anim text merge=unityyamlmerge
|
||||||
|
*.controller text merge=unityyamlmerge
|
||||||
|
*.meta text merge=unityyamlmerge
|
||||||
|
|
||||||
|
# 바이너리
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.psd binary
|
||||||
|
*.tga binary
|
||||||
|
*.tif binary
|
||||||
|
*.tiff binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.wav binary
|
||||||
|
*.ogg binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.fbx binary
|
||||||
|
*.obj binary
|
||||||
|
*.blend binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.dll binary
|
||||||
|
*.so binary
|
||||||
|
*.dylib binary
|
||||||
|
|
||||||
|
# Git LFS 후보 (초기엔 비활성, 대용량 에셋 추가 시 활성화)
|
||||||
|
# *.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
# *.fbx filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Unity 생성물 (패키지에는 포함되지 않음)
|
||||||
|
[Ll]ibrary/
|
||||||
|
[Tt]emp/
|
||||||
|
[Oo]bj/
|
||||||
|
[Bb]uild/
|
||||||
|
[Bb]uilds/
|
||||||
|
[Ll]ogs/
|
||||||
|
[Mm]emoryCaptures/
|
||||||
|
|
||||||
|
# Unity 메타 캐시
|
||||||
|
*.pidb.meta
|
||||||
|
*.pdb.meta
|
||||||
|
*.mdb.meta
|
||||||
|
|
||||||
|
# Visual Studio / Rider
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.csproj
|
||||||
|
*.sln
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userprefs
|
||||||
|
*.pidb
|
||||||
|
*.booproj
|
||||||
|
*.svd
|
||||||
|
*.pdb
|
||||||
|
*.mdb
|
||||||
|
*.opendb
|
||||||
|
*.VC.db
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 빌드 산출물
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
*.unitypackage
|
||||||
|
|
||||||
|
# 임시 / 로그
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
이 프로젝트의 모든 주요 변경 사항은 이 파일에 기록한다.
|
||||||
|
|
||||||
|
포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르고, 버저닝은 [Semantic Versioning](https://semver.org/lang/ko/)을 따른다.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 패키지 스켈레톤 (폴더 구조, asmdef, package.json)
|
||||||
|
|
||||||
|
## [0.1.0] - TBD
|
||||||
|
|
||||||
|
최초 릴리즈 예정.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "NerdNavis.Framework.Editor",
|
||||||
|
"rootNamespace": "NerdNavis.Editor",
|
||||||
|
"references": [
|
||||||
|
"NerdNavis.Framework"
|
||||||
|
],
|
||||||
|
"includePlatforms": [
|
||||||
|
"Editor"
|
||||||
|
],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# NerdNavis.Framework
|
||||||
|
|
||||||
|
너드나비스 자체 범용 Unity 프레임워크.
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
기존 외부 의존 코어(`NerdNavisCore`)가 이전·퇴사로 사용 불가해짐에 따라, 너드나비스가 자체적으로 보유·유지하는 범용 코어를 새로 구축한다. 수상한 잡화점 등 사내 프로젝트에서 반복되는 패턴을 Tier 단위로 흡수하여 차기 프로젝트부터 바로 활용 가능한 형태로 제공한다.
|
||||||
|
|
||||||
|
## 설치 (Unity Package Manager)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://burning.i234.me/NerdNavis/NerdNavis.Framework.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Unity 에디터 → Package Manager → `+` → **Add package from git URL...** → 위 URL 입력.
|
||||||
|
|
||||||
|
특정 버전 고정:
|
||||||
|
```
|
||||||
|
https://burning.i234.me/NerdNavis/NerdNavis.Framework.git#v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
Runtime/
|
||||||
|
├── Core/
|
||||||
|
│ ├── Patterns/ # MonoSingleton 등
|
||||||
|
│ ├── Coroutine/ # CoroutineRunner
|
||||||
|
│ └── Util/ # ValidationEx, ObjectEx, FormatEx, EnumEx, EnumToInt, Log
|
||||||
|
├── UI/
|
||||||
|
│ ├── UGUI/ # InfiniteScrollView, SpriteAtlasRegistry, BackKeyHandler
|
||||||
|
│ └── Components/ # SafeAreaBorder
|
||||||
|
├── Addressable/ # AddressableHandle, AutoReleaseComponent (Tier 2)
|
||||||
|
└── Security/ # CryptoUtil, ICryptoProvider (Tier 3)
|
||||||
|
|
||||||
|
Editor/ # 에디터 전용 유틸
|
||||||
|
Tests/ # Runtime/Editor 테스트
|
||||||
|
Documentation~/ # Unity 임포트 제외 (~ 접두)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 개발 원칙
|
||||||
|
|
||||||
|
- **네이밍**: `My*`·`u*` 접두 금지, PascalCase 준수, `FilGoodBandits` → `NerdNavis.*`
|
||||||
|
- **의존성 단절**: 프로젝트 특수 enum/테이블 참조 제거
|
||||||
|
- **제네릭 우선**: 하드코딩 메서드는 제네릭 팩토리로 재설계
|
||||||
|
- **싱글톤 최소화**: 필요 최소 외 DI/이벤트 기반으로 전환
|
||||||
|
|
||||||
|
자세한 내용은 `개발실/코어_설계/01_아키텍처_개요_v1.md`, `02_수상한잡화점_추출대상_v1.md` 참조.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
사내 사용. 외부 배포 금지.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// AssemblyInfo.cs — 테스트 어셈블리에 internal 멤버 노출
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("NerdNavis.Framework.Tests")]
|
||||||
|
[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor")]
|
||||||
|
[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor.Tests")]
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// CoroutineHandle.cs — CoroutineRunner 반환 핸들
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Coroutine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="CoroutineRunner"/>에서 발급하는 코루틴 핸들.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ID 기반 참조값이며 값 타입이다. <see cref="None"/>은 유효하지 않은 핸들을 의미한다.
|
||||||
|
/// 핸들을 보관한 쪽에서 중단·일시정지·재개 등을 요청할 때 사용한다.
|
||||||
|
/// </remarks>
|
||||||
|
public readonly struct CoroutineHandle : IEquatable<CoroutineHandle>
|
||||||
|
{
|
||||||
|
/// <summary>유효하지 않은 핸들.</summary>
|
||||||
|
public static readonly CoroutineHandle None = new CoroutineHandle(0);
|
||||||
|
|
||||||
|
/// <summary>내부 식별 ID. 0은 유효하지 않음.</summary>
|
||||||
|
public readonly ulong Id;
|
||||||
|
|
||||||
|
internal CoroutineHandle(ulong id) { Id = id; }
|
||||||
|
|
||||||
|
/// <summary>핸들이 유효한지 여부(ID가 0이 아닌지).</summary>
|
||||||
|
public bool IsValid => Id != 0;
|
||||||
|
|
||||||
|
public bool Equals(CoroutineHandle other) => Id == other.Id;
|
||||||
|
public override bool Equals(object obj) => obj is CoroutineHandle h && Equals(h);
|
||||||
|
public override int GetHashCode() => Id.GetHashCode();
|
||||||
|
public static bool operator ==(CoroutineHandle a, CoroutineHandle b) => a.Id == b.Id;
|
||||||
|
public static bool operator !=(CoroutineHandle a, CoroutineHandle b) => a.Id != b.Id;
|
||||||
|
public override string ToString() => IsValid ? $"Coroutine#{Id}" : "Coroutine#None";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// CoroutineRunner.cs — 일시정지·재개·키 중복방지를 지원하는 전역 코루틴 러너
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using NerdNavis.Core.Util.Log;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Coroutine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 전역에서 사용 가능한 코루틴 러너.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>기존 NerdNavisCore의 <c>CoroutineHandler</c>와 <c>CoroutineRunner</c> 2종을 하나로 통합했다.
|
||||||
|
/// 수상한 잡화점의 <c>MyCoroutine</c>도 이 러너로 흡수된다.</para>
|
||||||
|
/// <para>호스트는 <see cref="CoroutineHost"/> 내부 <see cref="MonoBehaviour"/>로,
|
||||||
|
/// <c>DontDestroyOnLoad</c> 처리된다. 첫 호출 시점에 지연 생성되며, 씬 전환과 무관하게 유지된다.</para>
|
||||||
|
/// <para>주요 기능:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>핸들 기반 중단 / 일시정지 / 재개</description></item>
|
||||||
|
/// <item><description>문자열 키 기반 중복 정책 (Replace/Ignore/Allow)</description></item>
|
||||||
|
/// <item><description>전체 중단 <see cref="StopAll"/></description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CoroutineRunner
|
||||||
|
{
|
||||||
|
private const string LogCategory = "Coroutine";
|
||||||
|
|
||||||
|
private static CoroutineHost _host;
|
||||||
|
private static ulong _nextId = 1;
|
||||||
|
private static readonly Dictionary<ulong, Entry> _entries = new Dictionary<ulong, Entry>();
|
||||||
|
private static readonly Dictionary<string, ulong> _keyToId = new Dictionary<string, ulong>();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 공개 API — 단순 시작
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>코루틴을 시작한다.</summary>
|
||||||
|
/// <param name="routine">실행할 <see cref="IEnumerator"/>.</param>
|
||||||
|
/// <returns>중단·제어에 사용하는 핸들. <paramref name="routine"/>이 null이면 <see cref="CoroutineHandle.None"/>.</returns>
|
||||||
|
public static CoroutineHandle Start(IEnumerator routine)
|
||||||
|
=> StartInternal(routine, key: null);
|
||||||
|
|
||||||
|
/// <summary>문자열 키로 코루틴을 시작한다. 동일 키 실행 중이면 <paramref name="policy"/>에 따라 처리.</summary>
|
||||||
|
public static CoroutineHandle Start(string key, IEnumerator routine,
|
||||||
|
DuplicatePolicy policy = DuplicatePolicy.Replace)
|
||||||
|
{
|
||||||
|
if (routine == null) return CoroutineHandle.None;
|
||||||
|
if (string.IsNullOrEmpty(key)) return StartInternal(routine, key: null);
|
||||||
|
|
||||||
|
if (_keyToId.TryGetValue(key, out var existingId))
|
||||||
|
{
|
||||||
|
switch (policy)
|
||||||
|
{
|
||||||
|
case DuplicatePolicy.Ignore:
|
||||||
|
return new CoroutineHandle(existingId);
|
||||||
|
case DuplicatePolicy.Replace:
|
||||||
|
StopInternal(existingId);
|
||||||
|
break;
|
||||||
|
case DuplicatePolicy.Allow:
|
||||||
|
// 기존 유지, 새 항목은 키 등록하지 않음.
|
||||||
|
return StartInternal(routine, key: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StartInternal(routine, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 공개 API — 제어
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>핸들로 실행 중인 코루틴을 중단한다.</summary>
|
||||||
|
public static void Stop(CoroutineHandle handle)
|
||||||
|
{
|
||||||
|
if (!handle.IsValid) return;
|
||||||
|
StopInternal(handle.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>키로 실행 중인 코루틴을 중단한다.</summary>
|
||||||
|
public static void StopByKey(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key)) return;
|
||||||
|
if (_keyToId.TryGetValue(key, out var id)) StopInternal(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>실행 중인 모든 코루틴을 중단한다.</summary>
|
||||||
|
public static void StopAll()
|
||||||
|
{
|
||||||
|
if (_host == null) return;
|
||||||
|
_host.StopAllCoroutines();
|
||||||
|
_entries.Clear();
|
||||||
|
_keyToId.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>핸들로 일시정지. 재개 전까지 <c>yield return</c> 이 소비되지 않는다.</summary>
|
||||||
|
public static void Pause(CoroutineHandle handle)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>핸들로 재개.</summary>
|
||||||
|
public static void Resume(CoroutineHandle handle)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>핸들이 현재 실행 중인지 조회.</summary>
|
||||||
|
public static bool IsRunning(CoroutineHandle handle)
|
||||||
|
=> handle.IsValid && _entries.ContainsKey(handle.Id);
|
||||||
|
|
||||||
|
/// <summary>키가 현재 실행 중인지 조회.</summary>
|
||||||
|
public static bool IsRunningByKey(string key)
|
||||||
|
=> !string.IsNullOrEmpty(key) && _keyToId.ContainsKey(key);
|
||||||
|
|
||||||
|
/// <summary>핸들에 해당하는 코루틴의 일시정지 여부.</summary>
|
||||||
|
public static bool IsPaused(CoroutineHandle handle)
|
||||||
|
=> _entries.TryGetValue(handle.Id, out var e) && e.IsPaused;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 내부
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private static CoroutineHandle StartInternal(IEnumerator routine, string key)
|
||||||
|
{
|
||||||
|
if (routine == null) return CoroutineHandle.None;
|
||||||
|
|
||||||
|
EnsureHost();
|
||||||
|
|
||||||
|
var id = _nextId++;
|
||||||
|
if (_nextId == 0) _nextId = 1; // 0은 None 예약
|
||||||
|
|
||||||
|
var entry = new Entry { Id = id, Source = routine, Key = key, IsPaused = false };
|
||||||
|
_entries[id] = entry;
|
||||||
|
if (!string.IsNullOrEmpty(key)) _keyToId[key] = id;
|
||||||
|
|
||||||
|
entry.UnityCoroutine = _host.StartCoroutine(Wrap(entry));
|
||||||
|
return new CoroutineHandle(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StopInternal(ulong id)
|
||||||
|
{
|
||||||
|
if (!_entries.TryGetValue(id, out var entry)) return;
|
||||||
|
if (_host != null && entry.UnityCoroutine != null)
|
||||||
|
_host.StopCoroutine(entry.UnityCoroutine);
|
||||||
|
|
||||||
|
_entries.Remove(id);
|
||||||
|
if (entry.Key != null) _keyToId.Remove(entry.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerator Wrap(Entry entry)
|
||||||
|
{
|
||||||
|
var source = entry.Source;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// 일시정지면 프레임 단위로 대기
|
||||||
|
while (entry.IsPaused) yield return null;
|
||||||
|
|
||||||
|
bool moved;
|
||||||
|
try { moved = source.MoveNext(); }
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(LogCategory, $"coroutine threw (id={entry.Id}, key={entry.Key ?? "<none>"})", ex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
yield return source.Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정상 종료·예외 종료 공통 정리
|
||||||
|
_entries.Remove(entry.Id);
|
||||||
|
if (entry.Key != null && _keyToId.TryGetValue(entry.Key, out var kid) && kid == entry.Id)
|
||||||
|
_keyToId.Remove(entry.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureHost()
|
||||||
|
{
|
||||||
|
if (_host != null) return;
|
||||||
|
var go = new GameObject("[NerdNavis.CoroutineHost]");
|
||||||
|
Object.DontDestroyOnLoad(go);
|
||||||
|
go.hideFlags = HideFlags.HideAndDontSave;
|
||||||
|
_host = go.AddComponent<CoroutineHost>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>테스트 전용: 내부 상태 초기화.</summary>
|
||||||
|
internal static void ResetForTests()
|
||||||
|
{
|
||||||
|
if (_host != null)
|
||||||
|
{
|
||||||
|
_host.StopAllCoroutines();
|
||||||
|
Object.Destroy(_host.gameObject);
|
||||||
|
_host = null;
|
||||||
|
}
|
||||||
|
_entries.Clear();
|
||||||
|
_keyToId.Clear();
|
||||||
|
_nextId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부 엔트리 — 클래스로 선언하여 Wrap 람다와 dictionary가 같은 참조를 공유하도록 함
|
||||||
|
private sealed class Entry
|
||||||
|
{
|
||||||
|
public ulong Id;
|
||||||
|
public IEnumerator Source;
|
||||||
|
public string Key;
|
||||||
|
public bool IsPaused;
|
||||||
|
public UnityEngine.Coroutine UnityCoroutine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>전역 코루틴 호스트.</summary>
|
||||||
|
private sealed class CoroutineHost : MonoBehaviour { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// DuplicatePolicy.cs — 키 기반 코루틴 중복 정책
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
namespace NerdNavis.Core.Coroutine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 동일 키로 <see cref="CoroutineRunner.Start(string, System.Collections.IEnumerator, DuplicatePolicy)"/>
|
||||||
|
/// 가 호출됐을 때 기존 실행 중인 코루틴을 어떻게 처리할지 정의한다.
|
||||||
|
/// </summary>
|
||||||
|
public enum DuplicatePolicy
|
||||||
|
{
|
||||||
|
/// <summary>기존 코루틴을 중단하고 새로 시작한다(기본값).</summary>
|
||||||
|
Replace = 0,
|
||||||
|
|
||||||
|
/// <summary>기존 코루틴을 유지하고 새 요청은 무시한다.</summary>
|
||||||
|
Ignore = 1,
|
||||||
|
|
||||||
|
/// <summary>기존 코루틴을 유지하고 중복 시작을 허용한다(키 공유, 핸들만 다름).</summary>
|
||||||
|
Allow = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// InitMode.cs — MonoSingleton 초기화 모드
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
namespace NerdNavis.Core.Patterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MonoSingleton{T}"/>의 초기화 모드.
|
||||||
|
/// </summary>
|
||||||
|
public enum InitMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MonoSingleton{T}.OnInitialized"/> 가 <c>Awake</c>에서 즉시 실행되고
|
||||||
|
/// <see cref="MonoSingleton{T}.IsReady"/>는 그 직후 <c>true</c>가 된다(기본값).
|
||||||
|
/// </summary>
|
||||||
|
Sync = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MonoSingleton{T}.OnInitializeAsync"/> 가 코루틴으로 실행되며
|
||||||
|
/// 완료 시 <see cref="MonoSingleton{T}.IsReady"/>가 <c>true</c>로 전환된다.
|
||||||
|
/// </summary>
|
||||||
|
Async = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 구현체가 <see cref="MonoSingleton{T}.MarkReady"/> 를 직접 호출할 때까지
|
||||||
|
/// <see cref="MonoSingleton{T}.IsReady"/>는 <c>false</c>를 유지한다.
|
||||||
|
/// </summary>
|
||||||
|
ManualReady = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// MonoSingleton.cs — 4종 통합(Sync/Async/Inner/Ready) MonoBehaviour 싱글톤
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System.Collections;
|
||||||
|
using UnityEngine;
|
||||||
|
using NerdNavis.Core.Coroutine;
|
||||||
|
using NerdNavis.Core.Util.Log;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Patterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MonoBehaviour"/> 단일 인스턴스 베이스 클래스.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>기존 NerdNavisCore의 4종(<c>Singleton</c>, <c>AsyncSingleton</c>, <c>InnerSingleton</c>,
|
||||||
|
/// <c>ReadySingleton</c>)을 통합했다(설계 문서 §4-1). 옵션은 <see cref="Attribute"/> 방식이 아닌
|
||||||
|
/// 가상 프로퍼티로 노출하여 런타임 리플렉션 비용을 제거했다.</para>
|
||||||
|
/// <para>동작 규칙:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="Instance"/>는 씬에 있으면 찾고, 없으면 <see cref="AutoCreate"/> 판단 후 생성.</description></item>
|
||||||
|
/// <item><description><see cref="Persistent"/>이면 <c>DontDestroyOnLoad</c> 적용.</description></item>
|
||||||
|
/// <item><description><see cref="InitMode"/>에 따라 <see cref="OnInitialized"/> / <see cref="OnInitializeAsync"/>
|
||||||
|
/// / <see cref="MarkReady"/> 중 하나로 준비 상태가 결정된다.</description></item>
|
||||||
|
/// <item><description>중복 인스턴스가 발견되면 나중 것이 파괴된다(<see cref="OnDuplicateDestroyed"/> 훅).</description></item>
|
||||||
|
/// <item><description>애플리케이션 종료 후 <see cref="Instance"/> 접근은 <c>null</c>을 반환한다(Unity destroyed object 회피).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>주의: Unity 메인 스레드에서만 접근한다. 다른 스레드 호출은 지원하지 않는다.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
|
||||||
|
{
|
||||||
|
private const string LogCategory = "Singleton";
|
||||||
|
|
||||||
|
private static T _instance;
|
||||||
|
private static bool _applicationIsQuitting;
|
||||||
|
private static readonly object _gate = new object();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 싱글톤 인스턴스. 없으면 <see cref="AutoCreate"/> 규칙으로 자동 생성 시도.
|
||||||
|
/// 애플리케이션 종료 중이면 <c>null</c>을 반환한다.
|
||||||
|
/// </summary>
|
||||||
|
public static T Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_applicationIsQuitting) return null;
|
||||||
|
if (_instance != null) return _instance;
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_instance != null) return _instance;
|
||||||
|
|
||||||
|
// 씬 검색
|
||||||
|
var found = FindAnyObjectByType<T>(FindObjectsInactive.Include);
|
||||||
|
if (found != null)
|
||||||
|
{
|
||||||
|
_instance = found;
|
||||||
|
_instance.ApplyPersistence();
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 생성 판정 — 프로토타입 인스턴스로 AutoCreate 값만 읽음
|
||||||
|
// AutoCreate=false 이면 외부가 GameObject에 붙여야 한다(InnerSingleton 대응)
|
||||||
|
var probe = new GameObject(typeof(T).Name + "_probe").AddComponent<T>();
|
||||||
|
bool autoCreate = probe.AutoCreate;
|
||||||
|
if (!autoCreate)
|
||||||
|
{
|
||||||
|
Destroy(probe.gameObject);
|
||||||
|
Log.Warn(LogCategory,
|
||||||
|
$"{typeof(T).Name}: AutoCreate=false, 씬에 인스턴스가 없어 null 반환");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe 가 이미 Awake를 통해 _instance 에 등록되었을 것
|
||||||
|
probe.gameObject.name = typeof(T).Name;
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>싱글톤이 이미 존재하거나 조회 가능한지 확인(자동 생성 트리거 없음).</summary>
|
||||||
|
public static bool Exists => !_applicationIsQuitting && _instance != null;
|
||||||
|
|
||||||
|
/// <summary>초기화 완료 여부. <see cref="InitMode"/>에 따라 결정된다.</summary>
|
||||||
|
public static bool IsReady { get; private set; }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 옵션 (파생에서 override)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>씬 전환에도 파괴되지 않을지 여부. 기본 <c>true</c>.</summary>
|
||||||
|
protected virtual bool Persistent => true;
|
||||||
|
|
||||||
|
/// <summary>인스턴스 부재 시 자동 생성 여부. 기본 <c>true</c>.</summary>
|
||||||
|
protected virtual bool AutoCreate => true;
|
||||||
|
|
||||||
|
/// <summary>초기화 모드. 기본 <see cref="Core.Patterns.InitMode.Sync"/>.</summary>
|
||||||
|
protected virtual InitMode InitMode => InitMode.Sync;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 라이프사이클 훅 (파생에서 override)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary><see cref="InitMode.Sync"/>에서 <c>Awake</c> 시 호출된다.</summary>
|
||||||
|
protected virtual void OnInitialized() { }
|
||||||
|
|
||||||
|
/// <summary><see cref="InitMode.Async"/>에서 코루틴으로 실행된다. 종료 시점에 <see cref="IsReady"/>가 <c>true</c>.</summary>
|
||||||
|
protected virtual IEnumerator OnInitializeAsync() { yield break; }
|
||||||
|
|
||||||
|
/// <summary>중복 인스턴스가 파괴될 때 마지막 호출. 리소스 정리 훅.</summary>
|
||||||
|
protected virtual void OnDuplicateDestroyed() { }
|
||||||
|
|
||||||
|
/// <summary><see cref="InitMode.ManualReady"/>에서 구현체가 호출하여 준비 상태로 전환.</summary>
|
||||||
|
protected void MarkReady() { IsReady = true; }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Unity 라이프사이클
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
protected virtual void Awake()
|
||||||
|
{
|
||||||
|
if (_instance != null && _instance != this)
|
||||||
|
{
|
||||||
|
Log.Warn(LogCategory,
|
||||||
|
$"{typeof(T).Name}: 중복 인스턴스 감지, 이번 GameObject 파괴");
|
||||||
|
OnDuplicateDestroyed();
|
||||||
|
Destroy(gameObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_instance = (T)this;
|
||||||
|
ApplyPersistence();
|
||||||
|
|
||||||
|
switch (InitMode)
|
||||||
|
{
|
||||||
|
case InitMode.Sync:
|
||||||
|
OnInitialized();
|
||||||
|
IsReady = true;
|
||||||
|
break;
|
||||||
|
case InitMode.Async:
|
||||||
|
CoroutineRunner.Start(InitializeAsyncWrapper());
|
||||||
|
break;
|
||||||
|
case InitMode.ManualReady:
|
||||||
|
// 구현체가 MarkReady 호출할 때까지 대기
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator InitializeAsyncWrapper()
|
||||||
|
{
|
||||||
|
yield return OnInitializeAsync();
|
||||||
|
IsReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void OnDestroy()
|
||||||
|
{
|
||||||
|
if (_instance == this)
|
||||||
|
{
|
||||||
|
_instance = null;
|
||||||
|
IsReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void OnApplicationQuit()
|
||||||
|
{
|
||||||
|
_applicationIsQuitting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 공용 유틸
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private void ApplyPersistence()
|
||||||
|
{
|
||||||
|
if (!Persistent) return;
|
||||||
|
if (transform.parent != null) transform.SetParent(null, true); // DontDestroyOnLoad는 루트 객체에만 적용
|
||||||
|
DontDestroyOnLoad(gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>테스트 전용: 정적 상태 초기화.</summary>
|
||||||
|
internal static void ResetForTests()
|
||||||
|
{
|
||||||
|
if (_instance != null && _instance.gameObject != null)
|
||||||
|
Object.DestroyImmediate(_instance.gameObject);
|
||||||
|
_instance = null;
|
||||||
|
IsReady = false;
|
||||||
|
_applicationIsQuitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// ServiceLocator.cs — 경량 서비스 레지스트리 (순수 C#, MonoBehaviour 비의존)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NerdNavis.Core.Util.Log;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Patterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 타입 키 기반 경량 서비스 레지스트리.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>설계 문서 §4-9 (v1.2 신설)에 따른 구현. MonoBehaviour와 무관한 순수 C# 서비스
|
||||||
|
/// 의 중앙 레지스트리로, MonoSingleton(씬 생명주기 동반)과 EventBus(이벤트 분기)와 역할이
|
||||||
|
/// 분리된 3축의 한 축을 담당한다.</para>
|
||||||
|
/// <para>용도:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>인터페이스 기반 느슨한 결합 — <c>ISaveProvider</c> 등으로 등록 후 구현 교체 용이</description></item>
|
||||||
|
/// <item><description>테스트 시 Mock 주입 — <see cref="Clear"/>로 전역 초기화, Mock <see cref="Register{T}(T)"/></description></item>
|
||||||
|
/// <item><description>Lazy 생성 — <see cref="Register{T}(Func{T})"/> 로 첫 <see cref="Resolve{T}"/> 시 생성</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>주의 규칙 (§4-9):</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>인터페이스 타입 등록 권장, 구체 타입 강결합 회피</description></item>
|
||||||
|
/// <item><description>글로벌 서비스만 등록. 씬 전환 시 스코프가 필요하면 MonoSingleton 사용</description></item>
|
||||||
|
/// <item><description><see cref="Resolve{T}"/> 실패 시 <see cref="ServiceNotRegisteredException"/> — silent null 금지</description></item>
|
||||||
|
/// <item><description>코어 자체는 ServiceLocator에 의존하지 않음(순환 방지)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ServiceLocator
|
||||||
|
{
|
||||||
|
private const string LogCategory = "ServiceLocator";
|
||||||
|
|
||||||
|
private static readonly object _gate = new object();
|
||||||
|
private static readonly Dictionary<Type, object> _instances = new Dictionary<Type, object>();
|
||||||
|
private static readonly Dictionary<Type, Func<object>> _factories = new Dictionary<Type, Func<object>>();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 등록
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 서비스 인스턴스를 등록한다. 동일 타입 재등록은 덮어쓴다(경고 로그).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="service"/>가 <c>null</c>.</exception>
|
||||||
|
public static void Register<T>(T service) where T : class
|
||||||
|
{
|
||||||
|
if (service == null) throw new ArgumentNullException(nameof(service));
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
|
||||||
|
Log.Warn(LogCategory, $"Register<{type.Name}>: 기존 바인딩을 덮어쓴다");
|
||||||
|
_instances[type] = service;
|
||||||
|
_factories.Remove(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazy 팩토리를 등록한다. 첫 <see cref="Resolve{T}"/> 호출 시점에 팩토리가 실행되고,
|
||||||
|
/// 결과 인스턴스가 캐싱된다(이후 호출은 캐시 반환).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="factory"/>가 <c>null</c>.</exception>
|
||||||
|
public static void Register<T>(Func<T> factory) where T : class
|
||||||
|
{
|
||||||
|
if (factory == null) throw new ArgumentNullException(nameof(factory));
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
|
||||||
|
Log.Warn(LogCategory, $"Register<{type.Name}>(factory): 기존 바인딩을 덮어쓴다");
|
||||||
|
_instances.Remove(type);
|
||||||
|
_factories[type] = () => factory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 조회
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>등록된 서비스를 조회한다. 미등록이면 예외.</summary>
|
||||||
|
/// <exception cref="ServiceNotRegisteredException">해당 타입이 등록되지 않음.</exception>
|
||||||
|
public static T Resolve<T>() where T : class
|
||||||
|
{
|
||||||
|
if (TryResolve<T>(out var service)) return service;
|
||||||
|
throw new ServiceNotRegisteredException(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>등록된 서비스를 조회한다. 미등록이면 <c>false</c>.</summary>
|
||||||
|
public static bool TryResolve<T>(out T service) where T : class
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_instances.TryGetValue(type, out var cached))
|
||||||
|
{
|
||||||
|
service = cached as T;
|
||||||
|
return service != null;
|
||||||
|
}
|
||||||
|
if (_factories.TryGetValue(type, out var factory))
|
||||||
|
{
|
||||||
|
object created;
|
||||||
|
try { created = factory(); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(LogCategory, $"factory for {type.Name} threw", ex);
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (created is T typed)
|
||||||
|
{
|
||||||
|
_instances[type] = typed; // 캐시
|
||||||
|
_factories.Remove(type);
|
||||||
|
service = typed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Log.Error(LogCategory,
|
||||||
|
$"factory for {type.Name} returned incompatible type {created?.GetType().Name ?? "<null>"}");
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>해당 타입이 등록되어 있는지 조회(팩토리 포함).</summary>
|
||||||
|
public static bool IsRegistered<T>() where T : class
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_gate) { return _instances.ContainsKey(type) || _factories.ContainsKey(type); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 해제
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>해당 타입의 서비스 등록을 해제한다.</summary>
|
||||||
|
public static void Unregister<T>() where T : class
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_instances.Remove(type);
|
||||||
|
_factories.Remove(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모든 등록을 해제한다(테스트 용도).</summary>
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_instances.Clear();
|
||||||
|
_factories.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// ServiceNotRegisteredException.cs — ServiceLocator 조회 실패 예외
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Patterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ServiceLocator.Resolve{T}"/> 호출 시 해당 타입이 등록되지 않은 경우 발생.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// silent null 반환을 금지하는 설계 원칙(§4-9 규칙 3)에 따라 명시적으로 예외를 던진다.
|
||||||
|
/// 실패를 허용하는 호출부는 <see cref="ServiceLocator.TryResolve{T}"/>를 사용한다.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ServiceNotRegisteredException : Exception
|
||||||
|
{
|
||||||
|
public Type ServiceType { get; }
|
||||||
|
|
||||||
|
public ServiceNotRegisteredException(Type serviceType)
|
||||||
|
: base($"Service '{serviceType?.FullName ?? "<null>"}' is not registered in ServiceLocator.")
|
||||||
|
{
|
||||||
|
ServiceType = serviceType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// ILogSink.cs — 외부 로그 수신자 인터페이스
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Util.Log
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 로그 이벤트를 외부로 전달할 때 사용하는 sink 인터페이스.
|
||||||
|
/// 크래시 리포터·파일 로거·원격 서버 전송 등을 구현체로 등록할 수 있다.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 기존 <c>ErrorLogHookManager</c>의 역할을 인터페이스로 추상화한 형태다.
|
||||||
|
/// <see cref="Log"/>는 등록된 모든 sink에 순차적으로 로그를 전달한다.
|
||||||
|
/// sink 구현은 예외를 내부에서 삼켜야 하며(Log 본체로 재귀하지 않도록),
|
||||||
|
/// 무거운 I/O는 비동기로 처리하도록 권장한다.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ILogSink
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 로그 이벤트가 발생했을 때 호출된다.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="level">로그 레벨.</param>
|
||||||
|
/// <param name="category">로그 카테고리(프로젝트가 자유 정의).</param>
|
||||||
|
/// <param name="message">포맷팅이 끝난 최종 메시지.</param>
|
||||||
|
/// <param name="exception">예외 객체(없으면 <c>null</c>).</param>
|
||||||
|
void Emit(LogLevel level, string category, string message, Exception exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// Log.cs — 카테고리·레벨 필터 지원 중앙 로거
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityDebug = UnityEngine.Debug;
|
||||||
|
|
||||||
|
namespace NerdNavis.Core.Util.Log
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 너드나비스 프레임워크의 중앙 로거.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>특징:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>레벨 필터: <see cref="MinLevel"/> 미만은 출력하지 않는다.</description></item>
|
||||||
|
/// <item><description>카테고리 필터: <see cref="EnableCategory"/>/<see cref="DisableCategory"/> 로 제어.</description></item>
|
||||||
|
/// <item><description>외부 sink: <see cref="AddSink"/>로 파일·원격·크래시리포터 연동.</description></item>
|
||||||
|
/// <item><description>릴리즈 스트리핑: <see cref="Verbose"/>/<see cref="Info"/> 는
|
||||||
|
/// <c>UNITY_EDITOR</c>·<c>DEVELOPMENT_BUILD</c>·<c>NERDNAVIS_LOG_VERBOSE</c> 중
|
||||||
|
/// 하나라도 정의돼야 호출부에 남는다(<see cref="ConditionalAttribute"/>).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>설계 원칙(C11): 카테고리 문자열은 프로젝트 쪽에서 자유 정의하며
|
||||||
|
/// 코어에는 특정 게임의 카테고리를 하드코딩하지 않는다.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Log
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 상태
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private static readonly object _gate = new object();
|
||||||
|
private static readonly List<ILogSink> _sinks = new List<ILogSink>();
|
||||||
|
private static readonly HashSet<string> _disabledCategories =
|
||||||
|
new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 출력되는 최소 레벨. 이 값 미만의 로그는 차단된다.
|
||||||
|
/// 기본값은 에디터/개발빌드에서는 <see cref="LogLevel.Verbose"/>,
|
||||||
|
/// 릴리즈에서는 <see cref="LogLevel.Warn"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static LogLevel MinLevel { get; set; }
|
||||||
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
|
= LogLevel.Verbose;
|
||||||
|
#else
|
||||||
|
= LogLevel.Warn;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 메시지 앞에 카테고리 태그를 붙일지 여부.
|
||||||
|
/// </summary>
|
||||||
|
public static bool PrefixCategory { get; set; } = true;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Sink 등록
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>외부 sink를 등록한다.</summary>
|
||||||
|
public static void AddSink(ILogSink sink)
|
||||||
|
{
|
||||||
|
if (sink == null) return;
|
||||||
|
lock (_gate) { if (!_sinks.Contains(sink)) _sinks.Add(sink); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>등록된 외부 sink를 제거한다.</summary>
|
||||||
|
public static void RemoveSink(ILogSink sink)
|
||||||
|
{
|
||||||
|
if (sink == null) return;
|
||||||
|
lock (_gate) { _sinks.Remove(sink); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모든 외부 sink를 제거한다(테스트 용도).</summary>
|
||||||
|
public static void ClearSinks()
|
||||||
|
{
|
||||||
|
lock (_gate) { _sinks.Clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 카테고리 필터
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>특정 카테고리 로그 출력을 차단한다.</summary>
|
||||||
|
public static void DisableCategory(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(category)) return;
|
||||||
|
lock (_gate) { _disabledCategories.Add(category); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>특정 카테고리 로그 출력을 허용한다(기본값).</summary>
|
||||||
|
public static void EnableCategory(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(category)) return;
|
||||||
|
lock (_gate) { _disabledCategories.Remove(category); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모든 카테고리 필터를 초기화한다.</summary>
|
||||||
|
public static void ResetCategoryFilter()
|
||||||
|
{
|
||||||
|
lock (_gate) { _disabledCategories.Clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 공용 API — Conditional 스트리핑 대상
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
[Conditional("UNITY_EDITOR")]
|
||||||
|
[Conditional("DEVELOPMENT_BUILD")]
|
||||||
|
[Conditional("NERDNAVIS_LOG_VERBOSE")]
|
||||||
|
public static void Verbose(string category, string message)
|
||||||
|
=> Emit(LogLevel.Verbose, category, message, null);
|
||||||
|
|
||||||
|
[Conditional("UNITY_EDITOR")]
|
||||||
|
[Conditional("DEVELOPMENT_BUILD")]
|
||||||
|
[Conditional("NERDNAVIS_LOG_VERBOSE")]
|
||||||
|
public static void Info(string category, string message)
|
||||||
|
=> Emit(LogLevel.Info, category, message, null);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 공용 API — 릴리즈에서도 살아남는 레벨
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
public static void Warn(string category, string message)
|
||||||
|
=> Emit(LogLevel.Warn, category, message, null);
|
||||||
|
|
||||||
|
public static void Error(string category, string message, Exception exception = null)
|
||||||
|
=> Emit(LogLevel.Error, category, message, exception);
|
||||||
|
|
||||||
|
public static void Critical(string category, string message, Exception exception = null)
|
||||||
|
=> Emit(LogLevel.Critical, category, message, exception);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 내부 공통 처리
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void Emit(LogLevel level, string category, string message, Exception exception)
|
||||||
|
{
|
||||||
|
// 레벨·카테고리 필터
|
||||||
|
if (level < MinLevel) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (category != null && _disabledCategories.Contains(category)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatted = Format(category, message);
|
||||||
|
|
||||||
|
// Unity Console 출력
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.Verbose:
|
||||||
|
case LogLevel.Info:
|
||||||
|
UnityDebug.Log(formatted);
|
||||||
|
break;
|
||||||
|
case LogLevel.Warn:
|
||||||
|
UnityDebug.LogWarning(formatted);
|
||||||
|
break;
|
||||||
|
case LogLevel.Error:
|
||||||
|
case LogLevel.Critical:
|
||||||
|
if (exception != null) UnityDebug.LogException(exception);
|
||||||
|
UnityDebug.LogError(formatted);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 sink 전달 (등록된 sink 수만큼 스냅샷 순회)
|
||||||
|
ILogSink[] snapshot;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_sinks.Count == 0) return;
|
||||||
|
snapshot = _sinks.ToArray();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < snapshot.Length; i++)
|
||||||
|
{
|
||||||
|
try { snapshot[i].Emit(level, category, formatted, exception); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// sink 내부 예외는 무시한다(재귀 방지).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Format(string category, string message)
|
||||||
|
{
|
||||||
|
if (!PrefixCategory || string.IsNullOrEmpty(category)) return message ?? string.Empty;
|
||||||
|
var sb = new StringBuilder(
|
||||||
|
(category?.Length ?? 0) + (message?.Length ?? 0) + 4);
|
||||||
|
sb.Append('[').Append(category).Append("] ").Append(message);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework
|
||||||
|
// LogLevel.cs — 로그 레벨 정의
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
namespace NerdNavis.Core.Util.Log
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 로그 심각도 레벨. 숫자가 클수록 심각하다.
|
||||||
|
/// </summary>
|
||||||
|
public enum LogLevel
|
||||||
|
{
|
||||||
|
/// <summary>상세 디버그 정보. 릴리즈에서는 stripping 대상.</summary>
|
||||||
|
Verbose = 0,
|
||||||
|
|
||||||
|
/// <summary>일반 정보. 릴리즈에서는 stripping 대상.</summary>
|
||||||
|
Info = 1,
|
||||||
|
|
||||||
|
/// <summary>경고. 동작은 지속되나 주의 필요.</summary>
|
||||||
|
Warn = 2,
|
||||||
|
|
||||||
|
/// <summary>오류. 동작이 실패했으나 복구 가능.</summary>
|
||||||
|
Error = 3,
|
||||||
|
|
||||||
|
/// <summary>치명적 오류. 애플리케이션 상태가 불안정할 수 있음.</summary>
|
||||||
|
Critical = 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "NerdNavis.Framework",
|
||||||
|
"rootNamespace": "NerdNavis",
|
||||||
|
"references": [],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "NerdNavis.Framework.Editor.Tests",
|
||||||
|
"rootNamespace": "NerdNavis.Editor.Tests",
|
||||||
|
"references": [
|
||||||
|
"NerdNavis.Framework",
|
||||||
|
"NerdNavis.Framework.Editor",
|
||||||
|
"UnityEngine.TestRunner",
|
||||||
|
"UnityEditor.TestRunner"
|
||||||
|
],
|
||||||
|
"includePlatforms": [
|
||||||
|
"Editor"
|
||||||
|
],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": true,
|
||||||
|
"precompiledReferences": [
|
||||||
|
"nunit.framework.dll"
|
||||||
|
],
|
||||||
|
"autoReferenced": false,
|
||||||
|
"defineConstraints": [
|
||||||
|
"UNITY_INCLUDE_TESTS"
|
||||||
|
],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework.Tests
|
||||||
|
// CoroutineRunnerTests.cs — CoroutineRunner PlayMode 테스트
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System.Collections;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
using NerdNavis.Core.Coroutine;
|
||||||
|
|
||||||
|
namespace NerdNavis.Tests.Core.Coroutine
|
||||||
|
{
|
||||||
|
public class CoroutineRunnerTests
|
||||||
|
{
|
||||||
|
[SetUp] public void SetUp() => CoroutineRunner.ResetForTests();
|
||||||
|
[TearDown] public void TearDown() => CoroutineRunner.ResetForTests();
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Start_Runs_To_Completion()
|
||||||
|
{
|
||||||
|
int counter = 0;
|
||||||
|
var handle = CoroutineRunner.Start(CountRoutine(3, () => counter++));
|
||||||
|
Assert.IsTrue(handle.IsValid);
|
||||||
|
|
||||||
|
while (CoroutineRunner.IsRunning(handle)) yield return null;
|
||||||
|
Assert.AreEqual(3, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Stop_Prevents_Completion()
|
||||||
|
{
|
||||||
|
int counter = 0;
|
||||||
|
var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
|
||||||
|
yield return null; yield return null;
|
||||||
|
CoroutineRunner.Stop(handle);
|
||||||
|
|
||||||
|
int snapshot = counter;
|
||||||
|
for (int i = 0; i < 5; i++) yield return null;
|
||||||
|
|
||||||
|
Assert.IsFalse(CoroutineRunner.IsRunning(handle));
|
||||||
|
Assert.AreEqual(snapshot, counter, "중단 후에는 counter가 증가하지 않아야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Pause_Then_Resume_Continues()
|
||||||
|
{
|
||||||
|
int counter = 0;
|
||||||
|
var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
|
||||||
|
yield return null; yield return null;
|
||||||
|
|
||||||
|
CoroutineRunner.Pause(handle);
|
||||||
|
int paused = counter;
|
||||||
|
for (int i = 0; i < 5; i++) yield return null;
|
||||||
|
Assert.AreEqual(paused, counter, "일시정지 중에는 진행이 없어야 한다");
|
||||||
|
|
||||||
|
CoroutineRunner.Resume(handle);
|
||||||
|
while (CoroutineRunner.IsRunning(handle)) yield return null;
|
||||||
|
Assert.AreEqual(10, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Duplicate_Replace_Stops_Previous()
|
||||||
|
{
|
||||||
|
int a = 0, b = 0;
|
||||||
|
var h1 = CoroutineRunner.Start("k", CountRoutine(20, () => a++));
|
||||||
|
yield return null;
|
||||||
|
var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Replace);
|
||||||
|
|
||||||
|
Assert.IsFalse(CoroutineRunner.IsRunning(h1), "첫 핸들은 중단되어야 한다");
|
||||||
|
while (CoroutineRunner.IsRunning(h2)) yield return null;
|
||||||
|
Assert.AreEqual(5, b);
|
||||||
|
Assert.Less(a, 20, "첫 루틴은 20회를 채우지 못했어야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Duplicate_Ignore_Keeps_Original()
|
||||||
|
{
|
||||||
|
int a = 0, b = 0;
|
||||||
|
var h1 = CoroutineRunner.Start("k", CountRoutine(5, () => a++));
|
||||||
|
var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Ignore);
|
||||||
|
|
||||||
|
Assert.AreEqual(h1.Id, h2.Id, "Ignore는 기존 핸들을 반환해야 한다");
|
||||||
|
while (CoroutineRunner.IsRunning(h1)) yield return null;
|
||||||
|
Assert.AreEqual(5, a);
|
||||||
|
Assert.AreEqual(0, b, "Ignore된 루틴은 실행되지 않아야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator StopByKey_Works()
|
||||||
|
{
|
||||||
|
int counter = 0;
|
||||||
|
CoroutineRunner.Start("kill-me", CountRoutine(20, () => counter++));
|
||||||
|
yield return null; yield return null;
|
||||||
|
Assert.IsTrue(CoroutineRunner.IsRunningByKey("kill-me"));
|
||||||
|
|
||||||
|
CoroutineRunner.StopByKey("kill-me");
|
||||||
|
Assert.IsFalse(CoroutineRunner.IsRunningByKey("kill-me"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Exception_In_Routine_Is_Captured()
|
||||||
|
{
|
||||||
|
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex(@"\[Coroutine\] coroutine threw"));
|
||||||
|
LogAssert.Expect(LogType.Exception, new System.Text.RegularExpressions.Regex("boom"));
|
||||||
|
|
||||||
|
var handle = CoroutineRunner.Start(ThrowingRoutine());
|
||||||
|
while (CoroutineRunner.IsRunning(handle)) yield return null;
|
||||||
|
|
||||||
|
// 완료(예외 종료) 후 내부 정리가 되어 IsRunning이 false여야 한다
|
||||||
|
Assert.IsFalse(CoroutineRunner.IsRunning(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- helper routines ---------
|
||||||
|
|
||||||
|
private static IEnumerator CountRoutine(int times, System.Action onTick)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < times; i++)
|
||||||
|
{
|
||||||
|
onTick?.Invoke();
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerator ThrowingRoutine()
|
||||||
|
{
|
||||||
|
yield return null;
|
||||||
|
throw new System.InvalidOperationException("boom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework.Tests
|
||||||
|
// MonoSingletonTests.cs — MonoSingleton<T> PlayMode 테스트
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System.Collections;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
using NerdNavis.Core.Patterns;
|
||||||
|
|
||||||
|
namespace NerdNavis.Tests.Core.Patterns
|
||||||
|
{
|
||||||
|
public class MonoSingletonTests
|
||||||
|
{
|
||||||
|
// 각 테스트 고유의 파생 타입을 사용해 static 공유 이슈를 회피한다.
|
||||||
|
|
||||||
|
// --- SyncCase ---
|
||||||
|
private class SyncCase : MonoSingleton<SyncCase>
|
||||||
|
{
|
||||||
|
public int InitCount;
|
||||||
|
protected override void OnInitialized() => InitCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NoAutoCreateCase ---
|
||||||
|
private class NoAutoCreateCase : MonoSingleton<NoAutoCreateCase>
|
||||||
|
{
|
||||||
|
protected override bool AutoCreate => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NonPersistentCase ---
|
||||||
|
private class NonPersistentCase : MonoSingleton<NonPersistentCase>
|
||||||
|
{
|
||||||
|
protected override bool Persistent => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DuplicateCase ---
|
||||||
|
private class DuplicateCase : MonoSingleton<DuplicateCase>
|
||||||
|
{
|
||||||
|
public static int DestroyedCount;
|
||||||
|
protected override void OnDuplicateDestroyed() => DestroyedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ManualReadyCase ---
|
||||||
|
private class ManualReadyCase : MonoSingleton<ManualReadyCase>
|
||||||
|
{
|
||||||
|
protected override InitMode InitMode => InitMode.ManualReady;
|
||||||
|
public void Ready() => MarkReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
MonoSingleton<SyncCase>.ResetForTests();
|
||||||
|
MonoSingleton<NoAutoCreateCase>.ResetForTests();
|
||||||
|
MonoSingleton<NonPersistentCase>.ResetForTests();
|
||||||
|
MonoSingleton<DuplicateCase>.ResetForTests();
|
||||||
|
MonoSingleton<ManualReadyCase>.ResetForTests();
|
||||||
|
DuplicateCase.DestroyedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator AutoCreate_Instance_On_First_Access()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(MonoSingleton<SyncCase>.Exists);
|
||||||
|
var inst = SyncCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
Assert.IsNotNull(inst);
|
||||||
|
Assert.IsTrue(MonoSingleton<SyncCase>.Exists);
|
||||||
|
Assert.IsTrue(MonoSingleton<SyncCase>.IsReady);
|
||||||
|
Assert.AreEqual(1, inst.InitCount, "OnInitialized는 1회 호출");
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator NoAutoCreate_Returns_Null_And_Warns()
|
||||||
|
{
|
||||||
|
LogAssert.Expect(LogType.Warning,
|
||||||
|
new System.Text.RegularExpressions.Regex(@"AutoCreate=false"));
|
||||||
|
|
||||||
|
var inst = NoAutoCreateCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
Assert.IsNull(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator NonPersistent_Does_Not_DontDestroyOnLoad()
|
||||||
|
{
|
||||||
|
var inst = NonPersistentCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
// DontDestroyOnLoad 씬의 scene.buildIndex == -1 임을 역으로 이용
|
||||||
|
Assert.AreNotEqual(-1, inst.gameObject.scene.buildIndex,
|
||||||
|
"Persistent=false인데 DontDestroyOnLoad 씬에 들어갔다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Persistent_Goes_To_DontDestroyOnLoad()
|
||||||
|
{
|
||||||
|
var inst = SyncCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
Assert.AreEqual("DontDestroyOnLoad", inst.gameObject.scene.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Duplicate_Instance_Is_Destroyed()
|
||||||
|
{
|
||||||
|
LogAssert.Expect(LogType.Warning,
|
||||||
|
new System.Text.RegularExpressions.Regex(@"중복 인스턴스"));
|
||||||
|
|
||||||
|
var first = DuplicateCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
var extra = new GameObject("extra").AddComponent<DuplicateCase>();
|
||||||
|
yield return null;
|
||||||
|
// 파괴 완료 대기
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
Assert.IsTrue(extra == null, "중복 인스턴스는 Destroy 되어야 한다");
|
||||||
|
Assert.AreEqual(1, DuplicateCase.DestroyedCount);
|
||||||
|
Assert.AreSame(first, MonoSingleton<DuplicateCase>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator ManualReady_Stays_Not_Ready_Until_Marked()
|
||||||
|
{
|
||||||
|
var inst = ManualReadyCase.Instance;
|
||||||
|
yield return null;
|
||||||
|
Assert.IsNotNull(inst);
|
||||||
|
Assert.IsFalse(MonoSingleton<ManualReadyCase>.IsReady);
|
||||||
|
|
||||||
|
inst.Ready();
|
||||||
|
Assert.IsTrue(MonoSingleton<ManualReadyCase>.IsReady);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework.Tests
|
||||||
|
// ServiceLocatorTests.cs — ServiceLocator 단위 테스트
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NerdNavis.Core.Patterns;
|
||||||
|
|
||||||
|
namespace NerdNavis.Tests.Core.Patterns
|
||||||
|
{
|
||||||
|
public class ServiceLocatorTests
|
||||||
|
{
|
||||||
|
private interface IGreeter { string Hello(); }
|
||||||
|
private sealed class EnGreeter : IGreeter { public string Hello() => "Hi"; }
|
||||||
|
private sealed class KoGreeter : IGreeter { public string Hello() => "안녕"; }
|
||||||
|
|
||||||
|
private interface ICounter { int Count { get; } }
|
||||||
|
private sealed class Counter : ICounter
|
||||||
|
{
|
||||||
|
public int Count { get; private set; }
|
||||||
|
public Counter() { Count = 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUp] public void SetUp() => ServiceLocator.Clear();
|
||||||
|
[TearDown] public void TearDown() => ServiceLocator.Clear();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Register_Instance_And_Resolve()
|
||||||
|
{
|
||||||
|
var svc = new EnGreeter();
|
||||||
|
ServiceLocator.Register<IGreeter>(svc);
|
||||||
|
|
||||||
|
Assert.IsTrue(ServiceLocator.IsRegistered<IGreeter>());
|
||||||
|
Assert.AreSame(svc, ServiceLocator.Resolve<IGreeter>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Register_Null_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => ServiceLocator.Register<IGreeter>((IGreeter)null));
|
||||||
|
Assert.Throws<ArgumentNullException>(() => ServiceLocator.Register<IGreeter>((Func<IGreeter>)null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_Missing_Throws_ServiceNotRegistered()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ServiceNotRegisteredException>(() => ServiceLocator.Resolve<IGreeter>());
|
||||||
|
Assert.AreEqual(typeof(IGreeter), ex.ServiceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryResolve_Missing_Returns_False()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(ServiceLocator.TryResolve<IGreeter>(out var svc));
|
||||||
|
Assert.IsNull(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Factory_Is_Invoked_Once_And_Cached()
|
||||||
|
{
|
||||||
|
int factoryCalls = 0;
|
||||||
|
ServiceLocator.Register<ICounter>(() => { factoryCalls++; return new Counter(); });
|
||||||
|
|
||||||
|
Assert.AreEqual(0, factoryCalls, "등록 시점에 factory는 호출되지 않는다");
|
||||||
|
|
||||||
|
var a = ServiceLocator.Resolve<ICounter>();
|
||||||
|
var b = ServiceLocator.Resolve<ICounter>();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, factoryCalls, "factory는 첫 Resolve에서만 1회 호출");
|
||||||
|
Assert.AreSame(a, b, "이후 Resolve는 캐시된 인스턴스를 반환");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Register_Overwrites_Existing_Binding()
|
||||||
|
{
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning,
|
||||||
|
new System.Text.RegularExpressions.Regex(@"\[ServiceLocator\] Register<IGreeter>"));
|
||||||
|
|
||||||
|
ServiceLocator.Register<IGreeter>(new EnGreeter());
|
||||||
|
ServiceLocator.Register<IGreeter>(new KoGreeter());
|
||||||
|
|
||||||
|
Assert.AreEqual("안녕", ServiceLocator.Resolve<IGreeter>().Hello());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Unregister_Removes_Binding()
|
||||||
|
{
|
||||||
|
ServiceLocator.Register<IGreeter>(new EnGreeter());
|
||||||
|
ServiceLocator.Unregister<IGreeter>();
|
||||||
|
|
||||||
|
Assert.IsFalse(ServiceLocator.IsRegistered<IGreeter>());
|
||||||
|
Assert.Throws<ServiceNotRegisteredException>(() => ServiceLocator.Resolve<IGreeter>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Clear_Removes_All()
|
||||||
|
{
|
||||||
|
ServiceLocator.Register<IGreeter>(new EnGreeter());
|
||||||
|
ServiceLocator.Register<ICounter>(() => new Counter());
|
||||||
|
ServiceLocator.Clear();
|
||||||
|
|
||||||
|
Assert.IsFalse(ServiceLocator.IsRegistered<IGreeter>());
|
||||||
|
Assert.IsFalse(ServiceLocator.IsRegistered<ICounter>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Factory_Exception_Is_Captured_As_False()
|
||||||
|
{
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error,
|
||||||
|
new System.Text.RegularExpressions.Regex(@"factory for IGreeter threw"));
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception,
|
||||||
|
new System.Text.RegularExpressions.Regex("boom"));
|
||||||
|
|
||||||
|
ServiceLocator.Register<IGreeter>(() => throw new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.IsFalse(ServiceLocator.TryResolve<IGreeter>(out var svc));
|
||||||
|
Assert.IsNull(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NerdNavis.Framework.Tests
|
||||||
|
// LogTests.cs — Log 중앙 로거 단위 테스트
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NerdNavis.Core.Util.Log;
|
||||||
|
|
||||||
|
namespace NerdNavis.Tests.Core.Util.Log
|
||||||
|
{
|
||||||
|
public class LogTests
|
||||||
|
{
|
||||||
|
private sealed class RecordingSink : ILogSink
|
||||||
|
{
|
||||||
|
public readonly List<(LogLevel level, string category, string message, Exception exception)> Events
|
||||||
|
= new List<(LogLevel, string, string, Exception)>();
|
||||||
|
|
||||||
|
public void Emit(LogLevel level, string category, string message, Exception exception)
|
||||||
|
=> Events.Add((level, category, message, exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecordingSink _sink;
|
||||||
|
private LogLevel _savedMinLevel;
|
||||||
|
private bool _savedPrefix;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_savedMinLevel = global::NerdNavis.Core.Util.Log.Log.MinLevel;
|
||||||
|
_savedPrefix = global::NerdNavis.Core.Util.Log.Log.PrefixCategory;
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.ClearSinks();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Verbose;
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = true;
|
||||||
|
|
||||||
|
_sink = new RecordingSink();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.AddSink(_sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.ClearSinks();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.MinLevel = _savedMinLevel;
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = _savedPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Warn_Sink_Receives_Formatted_Message()
|
||||||
|
{
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] timeout");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "timeout");
|
||||||
|
|
||||||
|
Assert.AreEqual(1, _sink.Events.Count);
|
||||||
|
Assert.AreEqual(LogLevel.Warn, _sink.Events[0].level);
|
||||||
|
Assert.AreEqual("Net", _sink.Events[0].category);
|
||||||
|
Assert.AreEqual("[Net] timeout", _sink.Events[0].message);
|
||||||
|
Assert.IsNull(_sink.Events[0].exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MinLevel_Filters_Below()
|
||||||
|
{
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Error;
|
||||||
|
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "below");
|
||||||
|
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] above");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Error("Net", "above");
|
||||||
|
|
||||||
|
Assert.AreEqual(1, _sink.Events.Count);
|
||||||
|
Assert.AreEqual(LogLevel.Error, _sink.Events[0].level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Disabled_Category_Is_Skipped()
|
||||||
|
{
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.DisableCategory("Net");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "blocked");
|
||||||
|
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[UI] kept");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("UI", "kept");
|
||||||
|
|
||||||
|
Assert.AreEqual(1, _sink.Events.Count);
|
||||||
|
Assert.AreEqual("UI", _sink.Events[0].category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PrefixCategory_Off_Omits_Tag()
|
||||||
|
{
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = false;
|
||||||
|
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "no-tag");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "no-tag");
|
||||||
|
|
||||||
|
Assert.AreEqual("no-tag", _sink.Events[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Error_With_Exception_Is_Forwarded()
|
||||||
|
{
|
||||||
|
var ex = new InvalidOperationException("boom");
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception, "InvalidOperationException: boom");
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] failed");
|
||||||
|
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Error("Net", "failed", ex);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, _sink.Events.Count);
|
||||||
|
Assert.AreSame(ex, _sink.Events[0].exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Sink_Exception_Does_Not_Break_Others()
|
||||||
|
{
|
||||||
|
var bad = new ThrowingSink();
|
||||||
|
var good = new RecordingSink();
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.AddSink(bad);
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.AddSink(good);
|
||||||
|
|
||||||
|
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] ping");
|
||||||
|
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "ping");
|
||||||
|
|
||||||
|
Assert.AreEqual(1, good.Events.Count, "정상 sink는 수신해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingSink : ILogSink
|
||||||
|
{
|
||||||
|
public void Emit(LogLevel level, string category, string message, Exception exception)
|
||||||
|
=> throw new InvalidOperationException("sink failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "NerdNavis.Framework.Tests",
|
||||||
|
"rootNamespace": "NerdNavis.Tests",
|
||||||
|
"references": [
|
||||||
|
"NerdNavis.Framework",
|
||||||
|
"UnityEngine.TestRunner",
|
||||||
|
"UnityEditor.TestRunner"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": true,
|
||||||
|
"precompiledReferences": [
|
||||||
|
"nunit.framework.dll"
|
||||||
|
],
|
||||||
|
"autoReferenced": false,
|
||||||
|
"defineConstraints": [
|
||||||
|
"UNITY_INCLUDE_TESTS"
|
||||||
|
],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "com.nerdnavis.framework",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"displayName": "NerdNavis Framework",
|
||||||
|
"description": "너드나비스 내부 프로젝트용 범용 Unity 프레임워크. 코루틴·싱글톤·UGUI·Addressable·보안 등 재사용 모듈 모음.",
|
||||||
|
"unity": "2022.3",
|
||||||
|
"author": {
|
||||||
|
"name": "NerdNavis",
|
||||||
|
"url": "https://nerdnavis.com"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"framework",
|
||||||
|
"utility",
|
||||||
|
"ugui",
|
||||||
|
"addressable"
|
||||||
|
],
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue