text-adv-engine
v1.0.0
Published
Choice-based text adventure engine with TypeScript API and validation.
Maintainers
Readme
텍스트 어드벤처 엔진 — TypeScript Core
선택지 기반 텍스트 어드벤처 게임을 구동하기 위한 순수 로직 엔진입니다. UI 프레임워크와 완전히 분리되어 있으며, JSON으로 정의된 스토리를 안전하게 검증하고 상태 머신을 통해 씬과 엔딩을 전이시킵니다. 프로젝트 계획으로 제공된 초기 기획서의 범위를 충실히 구현하여 아래와 같은 기능을 제공합니다.
주요 기능
- ⚙️ 엄격한 스토리 검증 — 최대 5개의 선택지, 유효한 전이, 중복 ID 탐지, 고급 그래프 점검(옵션) 등을 지원합니다.
- 🎯 간결한 엔진 API —
load,start,choose,back,save,restore등 필수 메서드를 제공합니다. - 💾 자동/수동 저장 — 추상화된
StorageAdapter인터페이스와 메모리 기반 기본 구현을 내장합니다. - 🧭 플래그·히스토리 기반 분기 —
and/or/not으로 조합 가능한 조건식을 통해 선택지 활성화/비활성화를 제어할 수 있습니다. - 🎒 인벤토리 기반 상호작용 — 선택지 실행 시 아이템을 획득/소비하고, 보유 여부로 조건부 선택지를 제어할 수 있습니다. 최대 보유 수량을 지정하면 한도를 채운 선택지는 자동으로 숨겨집니다.
- 🧮 성향 점수 & 결과 추천 — 선택지에 점수 가중치를 부여하고
@results전이를 사용하면 누적 점수에 기반한 자동 엔딩 추천과 요약을 구현할 수 있습니다. - 📊 진행도 뷰 모델 — 씬에 진행 정보를 선언하면 엔진이 현재 단계·총 단계·백분율을 뷰 모델에 포함해 UI에서 손쉽게 표현할 수 있습니다.
- 🔌 이벤트 훅 — 전이/저장/복원 시점에 커스텀 동작을 삽입할 수 있습니다.
- 🖼️ 프리로드 힌트 — 씬/엔딩과 연관된 이미지 경로를 UI로 전달하여 사전 로드를 도와줍니다.
설치
npm install타 프로젝트에서 재사용하려면 패키지를 빌드한 후 dist/ 산출물을 배포하거나 npm pack으로 번들링할 수 있습니다.
빌드 & 스크립트
| 명령어 | 설명 |
|-----------------|-----------------------------------|
| npm run build | TypeScript 소스 코드를 dist/로 컴파일합니다. |
| npm run test | Vitest 기반 단위 테스트를 실행합니다. |
| npm run lint | ESLint + JSDoc 규칙을 실행합니다. |
| npm run clean | 빌드 산출물을 제거합니다. |
빠른 시작
import { createEngine } from 'text-adv-engine';
import story from './story.json';
const engine = createEngine({ autoSaveSlot: 'autosave' });
await engine.load(story);
await engine.start();
let view = engine.getView();
if (view?.kind === 'scene') {
for (const choice of view.choices) {
console.log(choice.label, choice.disabled ? '(비활성)' : '');
}
}
await engine.choose('choice_id');
view = engine.getView();UI 레이어는 engine.getView()가 반환하는 씬/엔딩 뷰 모델을 그대로 렌더하면 됩니다. 이미지는 preloadHints 배열을 참조하여 사전 로딩 전략을 구성할 수 있습니다.
스토리 스키마 요약
interface ThemePalette {
background: string;
text: string;
accent: string;
}
interface StoryTheme {
light: ThemePalette;
dark?: ThemePalette;
}
interface StoryMeta {
title: string;
subtitle?: string;
version?: string;
lang?: string;
description?: string;
heroImage?: string;
thumbnail?: string;
estimatedDuration?: string;
category?: string;
tags?: string[];
theme?: StoryTheme;
custom?: Record<string, unknown>;
scores?: Record<string, { label?: string; description?: string }>;
results?: Array<{ ending: string; matcher?: ResultMatcher }>;
}
interface Story {
meta: StoryMeta;
start: string;
scenes?: Record<string, Scene>;
endings?: Record<string, Ending>;
}
interface Scene {
id: string;
title?: string;
text: string;
image?: string;
choices: Choice[]; // 최대 5개
progress?: { current: number; total: number; label?: string };
}
interface Choice {
id: string;
label: string;
image?: string;
next: string; // scene_* 또는 ending_*
when?: Condition;
inventory?: {
add?: Array<string | { id: string; max?: number }>;
remove?: Array<string | { id: string }>;
};
scores?: Record<string, number>;
}
type Condition =
| { hasSeen: string }
| { flag: string; eq?: boolean }
| { inventory: string; has?: boolean }
| { and: Condition[] }
| { or: Condition[] }
| { not: Condition };
interface Ending {
id: string;
title?: string;
text: string;
image?: string;
type?: 'good' | 'bad' | 'neutral' | string;
}참고:
inventory.add에 문자열을 사용하면 기본 최대 보유 수량은 1이며, 한도에 도달한 선택지는 씬 뷰에서 자동으로 사라집니다. 여러 개를 수집해야 한다면{ id: 'item-id', max: 3 }와 같이 명시적으로 한도를 설정하세요.
유효성 규칙
- 씬/엔딩의
id는 중복될 수 없습니다. Choice.next는 반드시 존재하는 씬 또는 엔딩을 가리켜야 합니다.- 각 씬의 선택지는 0~5개 범위여야 합니다.
hasSeen·flag조건은 올바른 참조/이름을 사용해야 합니다.- 논리 연산자는 최소 1개의 하위 조건을 가져야 하며,
hasSeen참조는 실제 씬/엔딩 ID여야 합니다. - 인벤토리 조건(
inventory)은 비어 있지 않은 문자열을 참조해야 하며, 아이템 추가/삭제 목록은 문자열 또는{ id, max? }객체 배열이어야 합니다. progress.current는 0 이상,progress.total은 1 이상이어야 하며 현재값은 전체값을 초과할 수 없습니다.- (옵션)
advancedGraphChecks활성화 시 도달 불가능 노드 및 단순 순환을 경고로 보고합니다.
TypeScript로 스토리 작성하기
- JSON 파일 대신 TypeScript 모듈에서 스토리를 선언하고 싶다면
defineStory헬퍼를 사용하세요.start와Choice.next가 존재하는 ID를 가리키는지 타입 수준에서 검증하며, 메타데이터 역시 동일한 구조를 재사용합니다.
import { defineStory } from 'text-adv-engine';
export default defineStory({
meta: {
title: '예시 스토리',
subtitle: '타입 안전한 선언',
category: 'adventure',
tags: ['typescript'],
},
start: 'intro',
scenes: {
intro: {
id: 'intro',
text: '타입이 보장된 첫 장면입니다.',
choices: [{ id: 'finish', label: '끝내기', next: 'ending_success' }],
},
},
endings: {
ending_success: { id: 'ending_success', text: '모든 것이 잘 작동했습니다.' },
},
});성향 점수 및 결과 규칙
Choice.scores에{ empathy: 2, introvert: 1 }과 같이 가중치를 선언하면 선택 시 해당 점수가 누적됩니다.meta.scores에 점수 키와 라벨을 정의하면 엔딩 뷰에서 사람이 읽기 쉬운 요약을 생성할 수 있습니다.meta.results배열에{ matcher: { highest: ['cat'] }, ending: 'ending_cat' }처럼 규칙을 선언하고, 질문 흐름의 마지막 선택지를@results로 전이시키면 엔진이 자동으로 가장 알맞은 엔딩을 고릅니다.- 규칙은
highest,scoreAtLeast,scoreBetween,exact중 하나의 매처를 사용할 수 있으며, 조건이 없는 항목은 기본(fallback) 엔딩으로 동작합니다. @results전이를 통해 도달한 엔딩의 뷰에는summary필드가 포함되어 UI에서 누적 점수를 쉽게 시각화할 수 있습니다.
씬 진행도 (Progress)
- 씬 정의에
progress: { current, total, label? }를 선언하면 엔진이SceneView.progress로 동일한 값을 돌려주고,percentage(반올림된 백분율)도 함께 제공합니다. progress.label은 UI에 노출할 맞춤 텍스트입니다. 생략하면 UI 레이어가 기본 라벨을 사용하면 됩니다.- 유효한 진행도는 총 단계가 1 이상이어야 하고 현재 단계는 0 이상, 총 단계를 초과할 수 없습니다. 소수점 값을 사용하면 설문이나 미세한 진행 상황도 표현할 수 있으며, 엔진이 자동으로 값을 보정해 안전한 백분율을 계산합니다.
엔진 API
const engine = createEngine(options);| 메서드 | 설명 |
|--------|------|
| load(story) | 스토리를 검증 후 메모리에 적재합니다. Story 객체 또는 JSON 문자열을 받을 수 있으며 실패 시 예외를 던집니다. |
| start(options?) | 새 게임을 시작하거나 저장 슬롯/스냅샷을 복원합니다. |
| choose(choiceId) | 현재 씬의 선택지를 실행합니다. 조건 미충족 시 예외가 발생합니다. |
| back() | 히스토리를 따라 이전 씬으로 돌아갑니다. |
| save(slot?) | 현재 상태를 지정 슬롯에 저장합니다. |
| restore(slot?) | 지정 슬롯에서 상태를 불러옵니다. |
| getState() | 엔진 내부 상태의 사본을 반환합니다. |
| getStoryMeta() | 로드된 스토리의 메타데이터를 반환합니다. |
| getView() | 렌더링 준비가 된 씬 또는 엔딩 뷰를 반환합니다. |
| setFlag(name, value) | 분기용 플래그를 갱신합니다. |
| clearFlags() | 모든 플래그를 초기화합니다. |
| addItem(id, amount?, options?) | 인벤토리에 아이템을 추가합니다. options.max로 최대 보유 수량을 제한할 수 있습니다. |
| removeItem(id, amount?) | 인벤토리에서 아이템을 제거합니다. |
| clearInventory() | 인벤토리를 비웁니다. |
옵션
interface EngineOptions {
validator?: (story: Story, options?: ValidatorOptions) => ValidationResult;
storage?: StorageAdapter; // 기본값: MemoryStorage
hooks?: EngineHooks;
autoSaveSlot?: string;
enableFlags?: boolean; // false일 경우 플래그 조건 무시
endingResolver?: (context: EngineContext) => string | Promise<string> | null;
}훅 인터페이스
beforeTransition(context & { nextId })afterTransition(context)afterSave(context & { slot })afterRestore(context & { slot })
훅은 동기/비동기 함수를 모두 지원합니다.
저장소 커스터마이징
엔진은 StorageAdapter 인터페이스를 통해 저장 방식을 추상화합니다. 다음은 로컬 스토리지를 사용하는 간단한 예시입니다.
class LocalStorageAdapter implements StorageAdapter {
async load(slot = 'default') {
const raw = window.localStorage.getItem(slot);
return raw ? JSON.parse(raw) : null;
}
async save(state: EngineState, slot = 'default') {
window.localStorage.setItem(slot, JSON.stringify(state));
}
async clear(slot = 'default') {
window.localStorage.removeItem(slot);
}
}테스트
본 저장소는 Vitest 기반 단위 테스트를 포함합니다. 구현한 기능이 의도대로 작동하는지 확인하려면 다음 명령을 실행하세요.
npm run test샘플 프로젝트 — Adventure React
samples/adventure-react 디렉터리에는 Adventure React 멀티 스토리 React UI 샘플이 포함되어 있습니다. 포스트 아포칼립스 "Seoul Afterglow", 좀비 스릴러 "캠퍼스 아웃브레이크", 그리고 성향 테스트 "에겐남 vs 테토남"을 앱스토어형 홈 화면에서 선택하고, 스토리별 테마 컬러를 적용한 상태로 플레이할 수 있습니다.
실행 방법
루트 엔진을 먼저 빌드하거나
npm install을 통해 의존성을 설치합니다.샘플 디렉터리로 이동 후 필요한 패키지를 설치합니다.
cd samples/adventure-react npm install npm run devVite 개발 서버가 기동되면
http://localhost:5173에서 Adventure React 체험판을 확인할 수 있습니다.
라이선스
MIT License
로드맵 메모
- 확률 분기, 엔딩 도감 메타, 그래프 시각화 등은 향후 확장 포인트로 남겨두었습니다.
조건식 예시
조건식은 단일 비교식뿐 아니라 논리 연산자를 중첩하여 사용할 수 있습니다. 아래 예시는 플레이어가 레버를 당겼고(flag), 단서를 확인했거나 키를 보유했으며(or), 저주받은 인장을 갖고 있지 않을 때(not)만 선택지가 활성화되는 조건입니다.
{
"when": {
"and": [
{ "flag": "lever-armed" },
{ "or": [{ "hasSeen": "scene_clue" }, { "inventory": "master-key" }] },
{ "not": { "inventory": "cursed-sigil" } }
]
}
}