한 번의 전투를 여러 선택이 이어지는 런으로 바꾸기
13일차까지는 한 번의 전투를 만드는 데 집중했다. 문제는 전투에서 이기면 그대로 끝난다는 점이었다. 14일차에는 전투 → 보상 선택 → 다음 경로 선택 → 더 어려운 전투가 이어지는 로그라이크 런을 만들었다. 여기서 런은 게임을 시작해 성공하거나 실패할 때까지 이어지는 한 번의 도전을 뜻한다.
이 글은 클래스 목록이 아니라 세 질문을 따라간다. 게임 진행 단계를 어떻게 제한했는가, 같은 시드에서 같은 맵을 어떻게 만들었는가, 런 모드에서만 필요한 객체를 어떻게 등록했는가다. R3는 값이 바뀌면 구독자에게 알려 주는 C# 라이브러리이고, VContainer는 객체 사이의 의존성을 연결하는 도구다.
잘못된 순서로 화면이 넘어가지 않게 하기
RunManager는 순수 C# 클래스다. MonoBehaviour가 아닌 IDisposable을 구현하고, R3의 ReactiveProperty로 상태를 관리한다.
// RunManager.cs 실제 코드
public class RunManager : IDisposable
{
private readonly ReactiveProperty<RunState> _reactiveState = new(RunState.NotStarted);
private RunProgressData _progress;
private readonly Subject<(RunState Previous, RunState Current)> _onRunStateChanged = new();
private readonly Subject<int> _onStageCompleted = new();
private readonly Subject<bool> _onRunEnded = new();
// 상태 전이 규칙을 Dictionary로 정적 정의
private static readonly Dictionary<RunState, HashSet<RunState>> ValidTransitions = new()
{
{ RunState.NotStarted, new HashSet<RunState> { RunState.MapSelect } },
{ RunState.MapSelect, new HashSet<RunState> { RunState.StageIntro } },
{ RunState.StageIntro, new HashSet<RunState> { RunState.Combat, RunState.MapSelect } },
{ RunState.Combat, new HashSet<RunState> { RunState.CombatResult } },
{ RunState.CombatResult, new HashSet<RunState> { RunState.RewardSelect } },
{ RunState.RewardSelect, new HashSet<RunState> { RunState.MapSelect, RunState.StageTransition } },
{ RunState.StageTransition, new HashSet<RunState> { RunState.StageIntro, RunState.WorldClear } },
{ RunState.WorldClear, new HashSet<RunState> { RunState.MapSelect, RunState.RunComplete } },
{ RunState.RunComplete, new HashSet<RunState>() },
{ RunState.RunFailed, new HashSet<RunState>() },
};
public ReadOnlyReactiveProperty<RunState> ReactiveState => _reactiveState;
public Observable<(RunState Previous, RunState Current)> OnRunStateChanged => _onRunStateChanged;
public Observable<bool> OnRunEnded => _onRunEnded;
}
상태 전이 규칙을 Dictionary<RunState, HashSet<RunState>>로 선언한 부분이 핵심이다. NotStarted에서는 MapSelect로만 갈 수 있고, Combat에서는 CombatResult로만 갈 수 있다. 유효하지 않은 전이를 코드 레벨에서 차단한다. RunFailed는 어느 활성 상태에서든 강제 전이 가능하도록 별도 처리했다.
R3의 Subject로 OnRunStateChanged, OnStageCompleted, OnRunEnded 세 개의 이벤트 스트림을 발행한다. StageFlowController와 RoguelikeBootstrap이 이 스트림을 구독해서 전투 시작, 보상 UI 표시, 맵 복귀 등을 처리한다. RunManager 자체는 “다음에 무엇을 해야 하는지” 모른다. 상태만 바꾸고 이벤트를 쏜다.
RunProgressData는 킬 수, 패링 횟수, Perfect 비율, 경과 시간, 골드, 획득 아티팩트 등 런 통계를 struct로 추적한다.
같은 시드로 같은 선택 지도를 다시 만들기
맵 생성기도 순수 C# 클래스다. System.Random(seed)을 받아서 결정론적 맵을 생성한다.
// RunMapGenerator.cs 실제 코드
public class RunMapGenerator
{
private const int LAYERS_PER_WORLD = 6;
private const int START_NODES_MIN = 2;
private const int START_NODES_MAX_EXCL = 4; // 시작층 2~3개
private const int ELITE_NODES_MIN = 1;
private const int ELITE_NODES_MAX_EXCL = 3; // 엘리트 1~2개
private const int BOSS_NODE_COUNT = 1; // 보스 항상 1개
public List<List<StageNodeData>> GenerateMap(RunConfigSO config, int seed)
{
var random = new Random(seed);
var allLayers = new List<List<StageNodeData>>();
int nodeIdCounter = 0;
for (int world = 0; world < config.WorldCount; world++)
{
var worldLayers = GenerateWorld(random, world, ref nodeIdCounter);
allLayers.AddRange(worldLayers);
}
return allLayers;
}
}
6층 구조에서 각 층의 노드 수를 랜덤으로 결정하되, 상한/하한을 const로 고정했다. Layer 0~2는 Combat(2~3개), Layer 3은 Elite(1~2개), Layer 4는 Utility(Shop/Event/Rest 중 랜덤), Layer 5는 Boss(1개). nodeIdCounter를 ref로 넘겨서 다중 월드에서도 노드 ID가 겹치지 않는다.
연결 생성과 고립 방지
// RunMapGenerator.cs — BuildConnections
private Dictionary<int, List<int>> BuildConnections(Random random, int[][] layerNodeIds)
{
var connections = new Dictionary<int, List<int>>();
// 모든 노드에 빈 연결 리스트 초기화
for (int layer = 0; layer < LAYERS_PER_WORLD; layer++)
foreach (int nodeId in layerNodeIds[layer])
connections[nodeId] = new List<int>();
// 각 층 → 다음 층 1~2개 연결
for (int layer = 0; layer < LAYERS_PER_WORLD - 1; layer++)
{
var currentIds = layerNodeIds[layer];
var nextIds = layerNodeIds[layer + 1];
foreach (int currentId in currentIds)
{
int maxConnections = Math.Min(
random.Next(MIN_CONNECTIONS, MAX_CONNECTIONS_EXCL), nextIds.Length);
var selectedIndices = PickRandomIndices(random, nextIds.Length, maxConnections);
foreach (int idx in selectedIndices)
connections[currentId].Add(nextIds[idx]);
}
// 고립 노드 방지
EnsureNoIsolation(random, currentIds, nextIds, connections);
}
return connections;
}
PickRandomIndices가 Fisher-Yates 부분 셔플이다. 전체 인덱스 배열을 만들고, 앞에서 count개만 셔플해서 선택한다.
private List<int> PickRandomIndices(Random random, int total, int count)
{
var indices = new List<int>(total);
for (int i = 0; i < total; i++) indices.Add(i);
// Fisher-Yates 부분 셔플
for (int i = 0; i < count; i++)
{
int j = random.Next(i, indices.Count);
(indices[i], indices[j]) = (indices[j], indices[i]);
}
return indices.GetRange(0, count);
}
EnsureNoIsolation은 다음 층에서 인바운드 연결이 0인 노드를 찾아 현재 층의 랜덤 노드에서 연결을 추가한다. 이 조합 덕분에 같은 시드는 같은 맵을 생성하면서, 어떤 시드에서도 고립 노드가 생기지 않는다.
노드 데이터는 StageNodeData struct로, NodeId, NodeType, ConnectedNodeIds[], WorldIndex, StageIndex를 담고, WithCompleted() 같은 immutable 패턴 메서드를 제공한다.
체력이 줄면 공격 패턴이 바뀌는 적
엘리트 적은 기존의 EnemyPatternSequencer를 재활용한다. 핵심은 두 개의 EnemyPatternSO를 _sequencer.SetNextPattern()으로 전환하는 구조다.
// FillInBerserkerAI.cs 실제 코드
public class FillInBerserkerAI : MonoBehaviour
{
[SerializeField] private EnemyPatternSO _normalPattern; // ● _ _ _ ● _ _ _
[SerializeField] private EnemyPatternSO _burstPattern; // ● _ _ _ ● ● ● ●
[SerializeField] private int _normalMeasuresBeforeFillIn = 3;
[SerializeField] private float _berserkHpThreshold = 0.5f;
[SerializeField] private float _berserkSpeedMultiplier = 2.0f;
[SerializeField] private float _berserkTelegraphMultiplier = 0.8f;
[SerializeField] private EnemyPatternSequencer _sequencer;
[SerializeField] private EnemyMovement _movement;
[SerializeField] private EnemyRhythmAttack _rhythmAttack;
[SerializeField] private HealthComponent _healthComponent;
private bool _isBerserk;
private int _normalLoopCount;
private DisposableBag _disposables;
public event Action OnBerserkActivated;
public event Action<bool> OnPatternSwitched; // true=필인, false=기본
}
_normalPattern은 느린 4비트(● _ _ _ ● _ _ _, 2마디), _burstPattern은 연타(● _ _ _ ● ● ● ●, 2마디)다. 시퀀서가 패턴 루프를 완료할 때마다 _normalLoopCount를 증가시키고, _normalMeasuresBeforeFillIn(기본 3)에 도달하면 _burstPattern으로 전환한다.
광폭화 로직은 R3의 DisposableBag으로 관리한다. HealthComponent.OnDamagedStream을 구독해서 HP 비율이 임계값 이하로 떨어지면 발동한다.
// 광폭화 시 변경되는 수치
_movement.SetMoveSpeed(_baseSpeed * _berserkSpeedMultiplier); // 3.0 → 6.0 m/s
_rhythmAttack.TelegraphMultiplier = _berserkTelegraphMultiplier; // 0.8배 (20% 감소)
// 광폭화 후에는 매 루프마다 필인 발동 (_normalMeasuresBeforeFillIn 무시)
테스트는 35개. 패턴 SO 검증(노트 수, 비트 위치, 빛남 색상), 광폭화 전환(플래그, 이벤트, 속도 2배, 텔레그래프 0.8배), 패턴 사이클(기본 N회 → 필인 → 반복), HP 임계값 판정 등을 커버한다.
전투 도중 BPM을 두 배로 바꾸는 보스
보스 AI의 기술적 핵심은 RhythmManager에 런타임으로 BPM을 주입하는 구조다.
// MetronomeKnightAI.cs 실제 코드
public class MetronomeKnightAI : MonoBehaviour
{
private const float DEFAULT_P1_BPM = 120f;
private const float DEFAULT_P2_BPM = 240f;
private const float DEFAULT_PHASE_TRANSITION_PAUSE = 1.0f;
private const int DEFAULT_BOSS_HP = 500;
[SerializeField] private EnemyPatternSO _phase1Pattern; // ● _ _ _ ● _ _ _ (Red)
[SerializeField] private EnemyPatternSO _phase2Pattern; // ● _ ● _ ● _ ● _ (Orange)
[SerializeField] private float _phase1BPM = DEFAULT_P1_BPM;
[SerializeField] private float _phase2BPM = DEFAULT_P2_BPM;
[SerializeField] private float _bpmTransitionDuration = 1.0f;
[SerializeField] private RhythmManager _rhythmManager;
public event Action OnPhase2Activated;
public event Action<float, float> OnBPMTransitionRequested; // (targetBPM, duration)
public event Action OnBGMTransitionRequested;
public event Action<bool> OnPatternSwitched; // true=P2
}
P1은 BPM 120, 패턴 ● _ _ _ ● _ _ _(2마디, Red 빛남, 대미지 20). P2는 BPM 240, 패턴 ● _ ● _ ● _ ● _(더블타임, Orange 빛남, 대미지 15). HP 500.
페이즈 전환 시 1초 정지(_phaseTransitionPause) 후 OnBPMTransitionRequested 이벤트를 발행한다. RoguelikeBootstrap이 이 이벤트를 받아서 RhythmManager.SetBPM()을 호출하고, AudioLayerManager가 BGM 레이어를 Base → Boss로 전환한다. 보스 AI 자체는 RhythmManager를 직접 호출하지 않고 이벤트로 위임한다.
_phase2Speed(4.5 m/s)는 P1 기본 속도(3.0 m/s)의 1.5배. BPM이 2배로 올라가면서 이동 속도는 1.5배만 올리는 이유는, 2배로 하면 플레이어가 따라잡을 수 없기 때문이다. 대신 대미지를 20 → 15로 낮춰서, “빠르지만 한 발 한 발이 약한” 체감을 만든다.
로그라이크 모드에서만 필요한 객체 등록하기
VContainer DI의 진입점이다. RunConfigSO 할당 여부로 로그라이크 서비스 등록을 분기한다.
// GameLifetimeScope.cs — 실제 등록 구조
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private CombatTimingConfigSO _combatTimingConfig;
[SerializeField] private RunConfigSO _runConfig; // null이면 로그라이크 비활성
protected override void Configure(IContainerBuilder builder)
{
// 항상 등록: 전투 코어
builder.RegisterInstance(_combatTimingConfig);
builder.RegisterComponentInHierarchy<RhythmManager>();
builder.RegisterComponentInHierarchy<BeatJudge>();
builder.Register<DamageCalculator>(Lifetime.Singleton);
// RunConfigSO가 있을 때만: 로그라이크 서비스
if (_runConfig != null)
{
builder.RegisterInstance(_runConfig);
builder.Register<RunManager>(Lifetime.Singleton);
builder.Register<RunMapGenerator>(Lifetime.Singleton);
builder.Register<RewardGenerator>(Lifetime.Singleton);
builder.Register<DifficultyScaler>(Lifetime.Singleton);
builder.Register<ArtifactManager>(Lifetime.Singleton);
builder.Register<StageFlowController>(Lifetime.Singleton);
}
}
}
PrototypeBootstrap(DefaultExecutionOrder(-100))은 _roguelikeMode bool 플래그로 분기한다. false면 기존 즉시 전투 모드로 적 스폰/전투 초기화를 직접 수행한다. true면 적/전투 초기화를 스킵하고, RoguelikeBootstrap(DefaultExecutionOrder(-90))이 런 루프를 제어한다.
RoguelikeBootstrap의 런 루프는 다음 흐름이다:
- 런 시작 →
RunMapGenerator.GenerateMap(config, seed)→ 맵 UI 표시 - 노드 선택 → 전투 노드면
StageFlowController.EnterStage()→RhythmManager.SetBPM()→EnemySpawner.SpawnFirstWave()→PrototypeBootstrap.WireNewCombatEnemies()호출 - 전투 승리 →
RewardGenerator가 시드 기반(seed ^ (nodeId × 31 + 7))으로 보상 3개 생성 → RewardSelectionUI 표시 - 보상 선택 →
ArtifactManager.AddArtifact()또는 골드/HP 적용 → 맵 복귀
DifficultyScaler는 월드 인덱스에 따라 적 HP(× (1 + worldIndex × 0.15)), 공격력(× (1 + worldIndex × 0.1)), 텔레그래프 시간(× max(0.5, 1 - worldIndex × 0.05))을 스케일링한다.
정리
| 항목 | 구현 |
|---|---|
| RunManager | R3 ReactiveProperty 상태 머신 + ValidTransitions Dictionary로 전이 규칙 정적 정의 |
| RunMapGenerator | System.Random(seed) + Fisher-Yates 부분 셔플 + EnsureNoIsolation. 순수 C# |
| FillInBerserkerAI | EnemyPatternSO 2종 전환 + R3 DisposableBag HP 구독 + 광폭화(속도×2, 텔레그래프×0.8) |
| HemiolaMageAI | 2박↔3박 패턴 교대 + 투사체 발사(12 m/s). 루프 완료마다 SetNextPattern |
| MetronomeKnightAI | P1 BPM 120 → P2 BPM 240 + 이벤트 위임(OnBPMTransitionRequested) |
| GameLifetimeScope | RunConfigSO null 체크로 로그라이크 DI 조건부 등록 |
| Bootstrap 분리 | DefaultExecutionOrder(-100/-90) + _roguelikeMode 플래그 |
| 테스트 | FillInBerserkerTests 35개, HemiolaMageTests 40개, MetronomeKnightTests 200+ 개 |
다음 단계는 보상 시스템(리듬 유물 6종, 스킬 변형 3종)과 이벤트 노드 콘텐츠다.
14일차에는 전투 코드를 늘리기보다 전투 사이의 순서, 재현 가능한 지도, 모드별 객체 경계를 만들어 한 번의 도전이 끝까지 이어지게 했다.
댓글