BT12-MVP-A Phase 2-A: 경험치·레벨업 시스템 코드 + JSON 테이블

PD 직접 지시 2026-05-08 — (b) 채택 + JSON 테이블 영역 관리.

신규 영역 (8 파일):
- Assets/Resources/Progression/level_xp_table.json — Lv 1~30 EXP 테이블 (balance-designer SOT)
- Assets/Scripts/Progression/LevelXPTableLoader.cs — Resources.Load + JsonUtility 캐시
- Assets/Scripts/Progression/PlayerProgression.cs — Level·EXP 진행도 (BT12-Dev v1 PlayerStats와 직무 분리)
- Assets/Scripts/Progression/ExperienceSystem.cs — EXP 발급 정적 게이트웨이
- Assets/Scripts/Progression/SkillCardPlaceholder.cs — placeholder ScriptableObject
- Assets/Scripts/Progression/SkillCardPlaceholderPool.cs — 카드 풀·Draw3Random
- Assets/Scripts/Progression/LevelUpManager.cs — 레벨업 발화·일시정지·UI placeholder (Phase 2-B 통합)

기존 파일 수정 (2 파일):
- EnemyDeath.cs Execute 마지막 영역 ExperienceSystem.OnEnemyKilled 호출
- PlayerController.cs Awake PlayerProgression 자동 부착

회귀 위험:
- BT5-Dev 발판/몬스터 영역 영향 X (EnemyDeath 호출 마지막·PlayerController 자동 부착)
- BT7-Dev VS 순수형 영향 X (Schedule 영역 변경 X)
- BT12-Dev v1 영역 충돌 X (PlayerStats 분리·신규 namespace EerieVillage.Progression)

Phase 2-A 영역 검증:
- 적 처치 → EXP 누적 → Lv 임계점 → Console [LevelUpManager] 영역 출력 확증
- Phase 2-B 영역 = SkillSelectionUI prefab + 5 placeholder asset + Scene 통합
This commit is contained in:
깃 관리자 2026-05-08 17:53:39 +09:00
parent 69b1b9197b
commit 047661cc49
9 changed files with 375 additions and 0 deletions

View File

@ -0,0 +1,38 @@
{
"version": "0.1",
"description": "BT12-MVP-A 레벨별 다음 레벨 도달 EXP 테이블. balance-designer SOT. 코드 산식 X·JSON 직접 편집.",
"fallback_formula": "level <= 0 시 100 / table 미정의 시 last_level + 20 × (level - max)",
"max_level_in_table": 30,
"table": [
{ "level": 1, "xp_to_next": 100 },
{ "level": 2, "xp_to_next": 120 },
{ "level": 3, "xp_to_next": 140 },
{ "level": 4, "xp_to_next": 160 },
{ "level": 5, "xp_to_next": 180 },
{ "level": 6, "xp_to_next": 200 },
{ "level": 7, "xp_to_next": 220 },
{ "level": 8, "xp_to_next": 240 },
{ "level": 9, "xp_to_next": 260 },
{ "level": 10, "xp_to_next": 280 },
{ "level": 11, "xp_to_next": 320 },
{ "level": 12, "xp_to_next": 360 },
{ "level": 13, "xp_to_next": 400 },
{ "level": 14, "xp_to_next": 440 },
{ "level": 15, "xp_to_next": 480 },
{ "level": 16, "xp_to_next": 520 },
{ "level": 17, "xp_to_next": 560 },
{ "level": 18, "xp_to_next": 600 },
{ "level": 19, "xp_to_next": 640 },
{ "level": 20, "xp_to_next": 680 },
{ "level": 21, "xp_to_next": 760 },
{ "level": 22, "xp_to_next": 840 },
{ "level": 23, "xp_to_next": 920 },
{ "level": 24, "xp_to_next": 1000 },
{ "level": 25, "xp_to_next": 1080 },
{ "level": 26, "xp_to_next": 1160 },
{ "level": 27, "xp_to_next": 1240 },
{ "level": 28, "xp_to_next": 1320 },
{ "level": 29, "xp_to_next": 1400 },
{ "level": 30, "xp_to_next": 1480 }
]
}

View File

@ -35,6 +35,10 @@ namespace Platformer.Gameplay
// 1초 후 GameObject 영역 Destroy (사망 애니메이션 시간)
Object.Destroy(enemy.gameObject, 1f);
// BT12-MVP-A 영역 신규 (2026-05-08) — 적 처치 시 EXP 발급
var player = Object.FindFirstObjectByType<PlayerController>();
EerieVillage.Progression.ExperienceSystem.OnEnemyKilled(enemy, player);
}
}
}

View File

@ -92,6 +92,10 @@ namespace Platformer.Mechanics
if (GetComponent<PlayerInvulnerabilityFlash>() == null) gameObject.AddComponent<PlayerInvulnerabilityFlash>();
if (GetComponent<Platformer.UI.ResurrectPromptUI>() == null) gameObject.AddComponent<Platformer.UI.ResurrectPromptUI>();
// BT12-MVP-A 영역 신규 (2026-05-08) — PlayerProgression 자동 부착 (레벨업 영역)
if (GetComponent<EerieVillage.Progression.PlayerProgression>() == null)
gameObject.AddComponent<EerieVillage.Progression.PlayerProgression>();
// 사망 시 입력 차단 / 부활 시 입력 복원
if (health != null)
{

View File

@ -0,0 +1,30 @@
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Progression
{
/// <summary>
/// EXP 발급 정적 영역. EnemyDeath 영역 단일 호출 통로.
/// 차기 BT12-Dev 영역 P19 XPMultiplier 영역 적용 hook.
/// </summary>
public static class ExperienceSystem
{
const int DEFAULT_XP_REWARD = 5;
/// <summary>적 처치 시 호출 — Player 영역 PlayerProgression 갱신.</summary>
public static void OnEnemyKilled(EnemyController enemy, PlayerController player)
{
if (player == null) return;
var prog = player.GetComponent<PlayerProgression>();
if (prog == null) return;
int xp = ComputeXPReward(enemy);
prog.GainXP(xp);
}
/// <summary>placeholder — 적 종류·등급별 XP 영역 차기 BT12-Dev 영역 (enemy_xp_reward.json 분리).</summary>
static int ComputeXPReward(EnemyController enemy)
{
return DEFAULT_XP_REWARD;
}
}
}

View File

@ -0,0 +1,100 @@
using System.Collections.Generic;
using UnityEngine;
using Platformer.Mechanics;
namespace EerieVillage.Progression
{
/// <summary>
/// 레벨업 발화 시 일시정지 + UI 호출 + 카드 선택 결과 수령.
/// PlayerProgression.OnLevelUp 구독.
///
/// Phase 2-A 영역 — UI 호출 placeholder (Debug.Log).
/// Phase 2-B 영역 — SkillSelectionUI 영역 통합.
/// </summary>
public class LevelUpManager : MonoBehaviour
{
public static LevelUpManager Instance { get; private set; }
[SerializeField] SkillCardPlaceholderPool _pool;
// Phase 2-B 영역 — SkillSelectionUI 영역 부착
// [SerializeField] SkillSelectionUI _ui;
PlayerController _player;
PlayerProgression _progression;
bool _isLevelUpActive = false;
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
void Start()
{
_player = Object.FindFirstObjectByType<PlayerController>();
if (_player == null)
{
Debug.LogWarning("[LevelUpManager] PlayerController 영역 부재 — Start 시점 참조 X");
return;
}
_progression = _player.GetComponent<PlayerProgression>();
if (_progression == null)
{
_progression = _player.gameObject.AddComponent<PlayerProgression>();
}
_progression.OnLevelUp += HandleLevelUp;
if (_pool == null) _pool = GetComponent<SkillCardPlaceholderPool>();
}
void OnDestroy()
{
if (_progression != null) _progression.OnLevelUp -= HandleLevelUp;
if (Instance == this) Instance = null;
}
void HandleLevelUp(int newLevel)
{
if (_isLevelUpActive) return;
_isLevelUpActive = true;
// 일시정지 + 입력 차단
Time.timeScale = 0f;
if (_player != null) _player.controlEnabled = false;
// 카드 3장 무작위 추출
List<SkillCardPlaceholder> cards = _pool != null
? _pool.Draw3Random()
: new List<SkillCardPlaceholder>();
// Phase 2-A 영역 placeholder — UI 호출 영역 Phase 2-B 영역 통합
Debug.Log($"[LevelUpManager] LevelUp Lv.{newLevel} — 카드 {cards.Count}장 영역 (UI placeholder·Phase 2-B 통합 예정)");
for (int i = 0; i < cards.Count; i++)
{
var c = cards[i];
Debug.Log($" [{i + 1}] {c.displayName} ({c.rarity}·Lv.{c.currentLevel}{(c.IsMaxLevel ? "·" : "")})");
}
// Phase 2-A 영역 임시 — 즉시 첫 카드 자동 확인 + 게임 재개
// Phase 2-B 영역 = SkillSelectionUI.Show + 사용자 클릭·확인 후 콜백
HandleCardConfirmed(cards.Count > 0 ? cards[0] : null);
}
void HandleCardConfirmed(SkillCardPlaceholder selected)
{
// 차기 BT12-Dev 영역 = PlayerSkillInventory.AddSkillByCardId(selected.id)
// BT12-MVP-A 영역 = UI 닫기·게임 재개만
Debug.Log($"[LevelUpManager] 카드 확정 영역 — {(selected != null ? selected.displayName : "NONE")} (효과 적용 X·BT12-Dev 본격 영역)");
Time.timeScale = 1f;
if (_player != null) _player.controlEnabled = true;
_isLevelUpActive = false;
}
}
}

View File

@ -0,0 +1,97 @@
using System.Collections.Generic;
using UnityEngine;
namespace EerieVillage.Progression
{
/// <summary>
/// JSON 영역 레벨업 EXP 테이블 로더. Resources.Load + JsonUtility 활용.
/// 정적 캐시 — 첫 호출 시점 1회 로드 (게임 영역 1회).
/// SOT: Assets/Resources/Progression/level_xp_table.json (balance-designer SOT).
/// PD 직접 지시 2026-05-08 — 코드 산식 폐기·JSON 테이블 영역 관리.
/// </summary>
public static class LevelXPTableLoader
{
[System.Serializable]
public class LevelXPEntry
{
public int level;
public int xp_to_next;
}
[System.Serializable]
public class LevelXPTable
{
public string version;
public string description;
public string fallback_formula;
public int max_level_in_table;
public LevelXPEntry[] table;
}
const string RESOURCE_PATH = "Progression/level_xp_table";
const int FALLBACK_BASE = 100;
const int FALLBACK_INCREMENT = 20;
static Dictionary<int, int> _cache;
static int _maxLevelInTable;
static int _lastTableXP;
public static void EnsureLoaded()
{
if (_cache != null) return;
_cache = new Dictionary<int, int>();
_maxLevelInTable = 0;
_lastTableXP = FALLBACK_BASE;
var ta = Resources.Load<TextAsset>(RESOURCE_PATH);
if (ta == null)
{
Debug.LogWarning($"[LevelXPTableLoader] Resources/{RESOURCE_PATH}.json 부재 — fallback 활성");
return;
}
try
{
var data = JsonUtility.FromJson<LevelXPTable>(ta.text);
if (data == null || data.table == null)
{
Debug.LogWarning("[LevelXPTableLoader] JSON 파싱 실패 — fallback 활성");
return;
}
_maxLevelInTable = data.max_level_in_table;
foreach (var entry in data.table)
{
_cache[entry.level] = entry.xp_to_next;
if (entry.level == _maxLevelInTable) _lastTableXP = entry.xp_to_next;
}
}
catch (System.Exception e)
{
Debug.LogError($"[LevelXPTableLoader] 파싱 예외 — {e.Message}");
}
}
/// <summary>
/// 지정 level → 다음 레벨 도달 EXP. 테이블 미정의 영역 = fallback (last_xp + 20 × overflow).
/// </summary>
public static int GetXPToNextLevel(int level)
{
EnsureLoaded();
if (level <= 0) return FALLBACK_BASE;
if (_cache.TryGetValue(level, out int xp)) return xp;
if (_maxLevelInTable > 0 && level > _maxLevelInTable)
{
return _lastTableXP + FALLBACK_INCREMENT * (level - _maxLevelInTable);
}
return FALLBACK_BASE + level * FALLBACK_INCREMENT;
}
/// <summary>테이블 재로드 (Editor 영역 hot-reload 용).</summary>
public static void Reload()
{
_cache = null;
EnsureLoaded();
}
}
}

View File

@ -0,0 +1,38 @@
using UnityEngine;
namespace EerieVillage.Progression
{
/// <summary>
/// 플레이어 레벨업 진행도. BT12-Dev v1 PlayerStats(패시브 보정)와 직무 분리 (Single Responsibility).
/// EXP는 적 처치 시 누적. 임계점 도달 시 OnLevelUp 발화.
/// EXP 곡선 = LevelXPTableLoader 영역 JSON SOT.
/// </summary>
public class PlayerProgression : MonoBehaviour
{
public int Level { get; private set; } = 1;
public int CurrentXP { get; private set; } = 0;
public int XPToNextLevel { get; private set; } = 100;
/// <summary>레벨업 발화 — int = new Level. LevelUpManager 구독 hook.</summary>
public event System.Action<int> OnLevelUp;
void Awake()
{
XPToNextLevel = LevelXPTableLoader.GetXPToNextLevel(Level);
}
/// <summary>적 처치 시 호출 — XP 획득. amount <= 0 시 무시.</summary>
public void GainXP(int amount)
{
if (amount <= 0) return;
CurrentXP += amount;
while (CurrentXP >= XPToNextLevel)
{
CurrentXP -= XPToNextLevel;
Level++;
XPToNextLevel = LevelXPTableLoader.GetXPToNextLevel(Level);
OnLevelUp?.Invoke(Level);
}
}
}
}

View File

@ -0,0 +1,33 @@
using UnityEngine;
namespace EerieVillage.Progression
{
public enum CardRarity { Common, Rare, Max }
/// <summary>
/// BT12-MVP-A 영역 placeholder 카드 데이터 ScriptableObject.
/// BT12-Dev v1 영역 ActiveSkillData·PassiveSkillData·AwakeningSkillData 영역과 별도 (효과 정의 X·표시 영역만).
/// 차기 BT12-Dev 본격 영역 = 본 ScriptableObject 영역 deprecate + 60종 카드 ScriptableObject 활용.
/// </summary>
[CreateAssetMenu(menuName = "EerieVillage/SkillCardPlaceholder")]
public class SkillCardPlaceholder : ScriptableObject
{
[Header("식별 영역")]
public string id; // 고유 ID (예: "A01_jineonbu")
public string displayName; // 한글 카드명 (예: "진언부")
[Header("표시 영역 (PD 예시 정합)")]
public Sprite icon; // 원형 아이콘
public CardRarity rarity = CardRarity.Common;
[Range(1, 5)]
public int currentLevel = 1; // PD 예시 영역 "레벨 N"
[Range(1, 5)]
public int maxLevel = 5; // PD 예시 영역 "최대" 표시
[TextArea(2, 4)]
public string description; // 효과 설명 (3~4 라인 placeholder)
public bool IsMaxLevel => currentLevel >= maxLevel;
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using UnityEngine;
namespace EerieVillage.Progression
{
/// <summary>
/// BT12-MVP-A 영역 placeholder 카드 풀. Inspector 영역 SkillCardPlaceholder 5~8장 등록.
/// 차기 BT12-Dev 본격 영역 = SkillRuntimeFactory 영역 활용.
/// </summary>
public class SkillCardPlaceholderPool : MonoBehaviour
{
[SerializeField] List<SkillCardPlaceholder> _allCards = new List<SkillCardPlaceholder>();
public IReadOnlyList<SkillCardPlaceholder> AllCards => _allCards;
/// <summary>5~8장에서 무작위 3장 추출 (중복 X). 카드 영역 부족 시 가능 영역만.</summary>
public List<SkillCardPlaceholder> Draw3Random()
{
var copy = new List<SkillCardPlaceholder>(_allCards);
var result = new List<SkillCardPlaceholder>();
int draw = Mathf.Min(3, copy.Count);
for (int i = 0; i < draw; i++)
{
int idx = Random.Range(0, copy.Count);
result.Add(copy[idx]);
copy.RemoveAt(idx);
}
return result;
}
}
}