커맨드 레지스트리

개발자 콘솔의 데이터 코어 — 커맨드 테이블, 토크나이저, 실행, 히스토리, 자동완성. UI는 직접 만듭니다.

화면만 빼고 개발자 콘솔에 필요한 전부 — 커맨드, 파싱, 히스토리, 자동완성.

인터페이스ICommandRegistry
끄기 스위치NullCommandRegistry
어셈블리CommonGameSystem.Core
시작부팅 시 자동 등록 (23개 서비스 중 하나) — 별도 설정 불필요

하는 일

커맨드 레지스트리는 개발자 콘솔, 치트 메뉴, 자동화 스크립트가 꽂히는 엔진입니다. 데이터를 소유합니다: 커맨드 테이블(이름에서 핸들러로), 원시 입력 줄을 위한 토크나이저(give sword 3give, sword, 3으로 나누는 부분), 크기가 제한된 커맨드 히스토리, 그리고 접두사 자동완성. 화면에는 아무것도 그리지 않습니다 — 콘솔 UI(렌더링, 입력 캡처, 토글 키)는 여러분이 만드는 몫이고, 아래 콘솔 직접 만들기 섹션이 그 방법을 보여 줍니다.

런타임 문제는 호출자를 절대 크래시시키지 않습니다. 알 수 없는 커맨드, 잘못된 인자, throw하는 핸들러는 격리되고 로그되며, 실패한 CommandResult로 반환됩니다. 프로그래밍 오류만 throw합니다: null이거나 빈 이름의 등록, null 핸들러, dispose된 후의 사용, 워커 스레드에서의 호출.

빠른 시작

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyCheats : MonoBehaviour
{
    private ICommandRegistry _commands;

    private void Awake()
    {
        _commands = ServiceLocator.Resolve<ICommandRegistry>();   // registered automatically at startup

        _commands.Register("give", args =>
        {
            if (args.Count < 2) return CommandResult.Fail("usage: give <id> <count>");
            return CommandResult.Ok($"gave {args[1]}x {args[0]}");
        }, new CommandMetadata("Grant an item", "give <id> <count>"));

        CommandResult r = _commands.TryExecute("give sword 3");   // tokenizes, dispatches, records history
        Debug.Log(r.Message);
    }
}

보여 준 대로 Awake()에서 서비스를 한 번 resolve해 캐시하세요. 프레임워크가 시작된 뒤에 실행되도록 클래스에 [DefaultExecutionOrder(100)]를 붙이세요.

API 레퍼런스

등록

멤버설명
void Register(string name, CommandHandler handler, CommandMetadata metadata = default)이름을 핸들러에 매핑합니다. 기존 이름을 다시 등록하면 생성 시 선택한 중복 정책을 따릅니다: 경고와 함께 교체, 조용히 교체, 또는 throw. null/공백 이름이나 null 핸들러는 throw합니다.
bool Unregister(string name)이름을 제거합니다. 있었다면 true, 알 수 없는 이름·null·공백이면 false. 절대 throw하지 않습니다.
bool IsRegistered(string name)이름이 등록되어 있는지. null이나 공백이면 false를 반환합니다.
bool TryGetMetadata(string name, out CommandMetadata metadata)true와 해당 항목의 메타데이터, 또는 falsedefault.
IReadOnlyList<string> RegisteredNames { get; }등록된 모든 이름의 읽기 전용 정렬 스냅샷. 스냅샷은 수정할 수 없습니다.

실행

멤버설명
CommandResult TryExecute(string rawLine)줄을 토크나이즈해 첫 토큰을 커맨드 이름으로, 나머지를 인자로 취급하고, 그 줄을 히스토리에 기록한 뒤 핸들러의 결과를 반환합니다. 런타임 입력에 대해서는 절대 throw하지 않습니다.
CommandResult TryExecute(string name, IReadOnlyList<string> args)미리 파싱된 이름과 인자를 직접 디스패치합니다. 토크나이즈를 건너뛰고, 히스토리를 기록하지 않습니다.
IReadOnlyList<string> Tokenize(string rawLine)단일 패스 토크나이저: 공백 분리, 큰따옴표 그룹핑, 백슬래시 이스케이프. 셸 확장은 없습니다. 절대 throw하지 않습니다.

자동완성

멤버설명
IReadOnlyList<string> Lookup(string prefix)prefix로 시작하는 등록된 이름들(정렬됨). 비어 있거나 null인 접두사는 모든 이름을 반환합니다. 절대 throw하지 않습니다.

히스토리 (데이터만 — UI 없음)

멤버설명
string Previous()커서를 더 오래된 항목 쪽으로 한 칸 옮기고 그 줄을 반환합니다. 가장 오래된 항목에서 멈추며(순환 없음), 히스토리가 비어 있으면 null을 반환합니다.
string Next()더 새로운 항목 쪽으로 이동합니다. 가장 새로운 항목을 지나면(빈 입력 상태) null을 반환합니다.
void ResetHistoryCursor()커서를 가장 새로운 위치로 되돌립니다.
IReadOnlyList<string> History { get; }시간순 스냅샷(오래된 것부터 새것까지). 호출할 때마다 새 리스트입니다.

지원 타입

타입설명
delegate CommandResult CommandHandler(IReadOnlyList<string> args)파싱된 문자열 인자를 받는 여러분의 핸들러. 커맨드 이름은 제외되며, 리스트는 절대 null이 아닙니다. 타입 있는 값의 파싱은 핸들러의 몫입니다 — 레지스트리는 타입 변환을 하지 않으며, 그것이 IL2CPP/AOT 안전성을 지킵니다.
readonly struct CommandResultbool Successstring Message(절대 null 아님). CommandResult.Ok(msg)CommandResult.Fail(msg)로 만드세요. default는 실패로 칩니다.
readonly struct CommandMetadata(string description, string usage)DescriptionUsage 도움말 문자열(둘 다 절대 null 아님).
readonly struct CommandRegistryOptions생성 정책. 생성자나 .Default로: HistoryCapacity(64, 8–1024로 클램프), CaseSensitiveNames(true), DuplicateRegistrationPolicy, MaxArgumentCount(0 = 무제한), WarnOnUnknownCommand, WarnOnDuplicateRegistration, WarnOnMalformedLine, InitialCommandCapacity.
enum CommandDuplicatePolicyReplaceWithWarning(기본) / ReplaceSilently / Throw.

콘솔 직접 만들기

레지스트리는 콘솔에 필요한 모든 것을 화면만 빼고 제공합니다: TryExecute가 출력 텍스트를 만들고, Previous/Next가 위/아래 히스토리 recall을 구동하며, Lookup이 자동완성을 뒷받침합니다. 이 셋을 원하는 UI에 바인딩하면 동작하는 인게임 콘솔이 완성됩니다.

밑바닥부터 시작할 필요는 없습니다. Command Console 샘플 — 패키지와 함께 제공되는 여섯 샘플 중 하나이자 실행 가능한 세 샘플 중 하나 — 은 이 서비스 위에 만들어진 완전히 동작하는 콘솔입니다: 출력 뷰가 있는 어두운 UGUI 패널, 흐릿한 자동완성 힌트 줄, 입력 필드, 데모 커맨드 다섯 개(help, echo, add, timescale, clear), 그리고 위/아래 히스토리. **Tools → Common Game System → Welcome → Import "Command Console Sample"**로 임포트한 뒤, 임포트된 CommandConsole.unity를 열고 Play를 누르세요. 권장 출발점입니다: 프로젝트에 복사해 커맨드 목록을 확장하세요.

와이어링 패턴의 핵심만 추리면:

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

[DefaultExecutionOrder(100)]
public class MyConsole : MonoBehaviour
{
    [SerializeField] private InputField _inputField;  // your input row
    [SerializeField] private Text _outputText;        // your output view

    private ICommandRegistry _commands;
    private bool _fieldFocusedLastFrame;

    private void Awake() => _commands = ServiceLocator.Resolve<ICommandRegistry>();

    private void Update()
    {
        Keyboard kb = Keyboard.current;
        if (kb == null) return;

        // History recall while the field is focused.
        if (_inputField.isFocused)
        {
            if (kb.upArrowKey.wasPressedThisFrame) Recall(_commands.Previous());
            else if (kb.downArrowKey.wasPressedThisFrame) Recall(_commands.Next() ?? string.Empty);
        }

        // Submit on Enter — detected here in Update (see the tip below).
        bool enter = kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame;
        if (enter && (_inputField.isFocused || _fieldFocusedLastFrame))
            Submit(_inputField.text);

        _fieldFocusedLastFrame = _inputField.isFocused;
    }

    private void Submit(string line)
    {
        if (string.IsNullOrWhiteSpace(line)) return;
        CommandResult result = _commands.TryExecute(line);   // tokenize + dispatch + history
        _outputText.text += $"\n> {line}\n{result.Message}";
        _commands.ResetHistoryCursor();                      // next Up starts from the newest entry
        _inputField.text = string.Empty;
        _inputField.ActivateInputField();                    // refocus for the next command
    }

    private void Recall(string line)
    {
        if (line == null) return;                            // null only when history is empty
        _inputField.text = line;
        _inputField.caretPosition = line.Length;
        _inputField.ActivateInputField();
    }

    // Autocomplete: call from the field's onValueChanged and render the matches yourself.
    public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}

실용 팁 — Enter는 onEndEdit이 아니라 Update에서 감지하세요. 입력 필드의 onEndEdit 이벤트에서 제출하고 싶어지지만, UI가 onEndEdit을 올릴 때쯤이면 키보드의 enterKey.wasPressedThisFrame은 이미 false로 돌아가 있습니다(Unity 6.3 LTS에서 측정) — onEndEdit 안에서 그 플래그로 제출을 게이트하는 콘솔은 모든 제출을 조용히 삼킵니다. 위 스니펫처럼 Update에서 키보드를 폴링하세요. 미묘한 점이 하나 더 있습니다: 같은 Enter 입력이 프레임의 더 이른 시점에 입력 필드를 비활성화할 수 있으므로, 필드가 지금 포커스되어 있거나 직전 프레임에 포커스되어 있었다면 그 입력을 받아들이세요 — _fieldFocusedLastFrame이 바로 그 용도입니다.

샘플에서 따라 할 만한 습관 두 가지 더:

  • 공유 레지스트리에 등록하고, 자기 이름만 정리하세요. OnDestroy에서 여러분이 추가한 커맨드만 정확히 Unregister하세요. 레지스트리를 절대 Dispose()하지 마세요 — Bootstrap이 소유한 공유 서비스입니다.
  • 타이핑이나 Enter에 아무 반응이 없다면, 프로젝트가 아직 Unity의 구 입력 백엔드에 있을 가능성이 큽니다. Tools → Common Game System → Welcome을 열어 Enable the new Input System을 클릭하고(에디터 재시작 한 번), 다시 Play 하세요.

예제

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class DebugConsoleInput : MonoBehaviour
{
    private ICommandRegistry _commands;

    private void Awake()
    {
        _commands = ServiceLocator.Resolve<ICommandRegistry>();
        _commands.Register("god", _ => CommandResult.Ok("god mode toggled"),
            new CommandMetadata("Toggle invulnerability", "god"));
    }

    // Called by your own UI when the player submits a console line.
    public string Submit(string line)
    {
        CommandResult r = _commands.TryExecute(line);   // recorded in history automatically
        return r.Message;                               // your UI prints it
    }

    // Up/Down arrow recall — pure data; you bind the keys.
    public string RecallPrevious() => _commands.Previous();
    public string RecallNext() => _commands.Next();

    // Tab autocomplete — you render the candidate list.
    public IReadOnlyList<string> Complete(string prefix) => _commands.Lookup(prefix);
}

끄는 방법

ServiceLocator.Replace<ICommandRegistry>(new NullCommandRegistry());

이렇게 하면 모든 커맨드가 무음 처리됩니다. Register/Unregister는 아무것도 하지 않고, TryExecute는 실패한 CommandResult를 반환하며, 모든 조회는 빈 컬렉션이나 null을 반환합니다. 실제 레지스트리에 연결된 게임은 치트와 커맨드가 조용히 비활성화된 채 계속 돌아갑니다. 교체 구현이 생성될 때 경고가 한 번 로그되므로 교체가 조용히 지나가는 일은 없습니다. 프로그래밍 오류는 여전히 throw합니다 — null/공백 이름, null 핸들러, dispose 후 사용 — 프레임워크의 모든 null 구현이 따르는 규칙 그대로입니다.

흔한 함정

  • 콘솔 UI는 여러분의 몫입니다. 레지스트리는 데이터 코어만 제공합니다 — print, render, 키 토글 API는 없습니다. TryExecute(출력), Previous/Next(히스토리 recall), Lookup(자동완성)에 자체 UI를 바인딩하세요. 레지스트리가 만들어 내는 텍스트는 CommandResult.Message뿐입니다.
  • 핸들러가 인자를 직접 파싱합니다. 인자는 커맨드 이름이 제외된 IReadOnlyList<string>으로 도착합니다. 레지스트리는 타입 변환도, 리플렉션 바인딩도 하지 않습니다 — int.Parse 등은 직접 호출하세요. 이것이 IL2CPP/AOT 안전성을 지키는 방식입니다.
  • TryExecute 오버로드는 히스토리를 다르게 다룹니다. string rawLine 오버로드는 토크나이즈하고 히스토리도 기록합니다. 미리 파싱된 (name, args) 오버로드는 둘 다 건너뜁니다. 콘솔 입력에는 raw-line 형식을, 프로그래밍 방식 디스패치에는 파싱된 형식을 쓰세요.
  • default(CommandRegistryOptions).Default가 아닙니다.new CommandRegistryOptions()(또는 default)는 전부 0인 값 묶음입니다: 히스토리 용량 0, 대소문자 구분 없는 이름. 레지스트리는 그 0들에서 합리적인 값을 복구하지만, 표준 정책을 원하면 CommandRegistryOptions.Default를 명시적으로 전달하세요.
  • 메인 스레드 전용입니다. 대부분의 프레임워크 서비스처럼 레지스트리는 메인 스레드를 assert합니다. 워커 스레드에서의 호출은 프로그래밍 오류입니다.

관련 페이지

  • 로거 — 레지스트리가 로그를 보내는 진단 싱크
  • 서비스 로케이터 — 서비스 resolve와 null 구현으로의 교체
  • 부트스트랩 — 모든 서비스가 자동으로 등록되고 종료되는 방식
  • 타임 — 샘플의 timescale 커맨드가 구동하는 대상