서사형 덱빌딩 게임 시스템으로 리뉴얼

This commit is contained in:
Kyoung5seo
2026-06-09 22:54:07 +09:00
parent 4d3e55a89b
commit e1310bc872
27 changed files with 2604 additions and 13 deletions

View File

@@ -0,0 +1,156 @@
- 개요
- 서사형 덱빌딩 게임
- 출시: 모바일&스팀
- 코어 루프
- 시스템 요약: 로그라이크 루프
1. **시작:** 무작위 사건(내러티브)과 무작위 지부장이 배치된 채 국장 취임.
2. **결재 (덱 빌딩):** 스와이프를 통해 토큰을 관리하며, 덱에 [오염 카드]를 밀어 넣거나 [자산 카드]를 챙김.
3. **위기 (드로우):** 내가 넣은 오염 카드가 튀어나와 목을 조를 때, 어떻게든 [자원 태그] 엔진과 남은 토큰으로 방어.
4. **엔딩 (결과):** 덱의 상태, 파벌 우호도, 붕괴 게이지의 조합에 따라 수십 가지의 파생 엔딩 중 하나를 수집하고 다음 회차로 루프.
- 시스템 (플레이어 행동 중심 시스템 정리)
- 게임 목표: 모든 엔딩 보기
- 기본 붕괴 엔딩
- 사임 (내부 붕괴): [내부 저항] 5단계 도달 시. (수많은 딜레마에서 조직원들을 갈아 넣고 장악력을 잃은 결과)
- 세계 멸망 (외부 붕괴): [엔트로피] 5단계 도달 시. (내러티브 태그 방치 및 오염 카드에서 세상의 타락을 방관한 결과)
- 정치적 탄핵 (윗선 붕괴): 주간 의회 단계에서 적대적 파벌 임원이 2/3(9명 이상)를 넘어 탄핵안이 상정되었으나, 방어 자원(정치적 개입)이 없어 가결될 시.
- 변주된 붕괴 엔딩
- 기본 붕괴 엔딩 + 특정 자원 태그
- 기본 붕괴 엔딩 + 특정 토큰
- 극단적 파라미터 엔딩
- 특정 파라미터를 극단적으로 비정상적인 상태로 만들었을 때 즉시 발동하는 강제 종료 엔딩 (조기 엔딩)
- 내러티브 연쇄 엔딩
- 특정 내러티브 엔딩을 순서대로 수집
- 의회 정치 엔딩 (의회의 수장이 원하는 바를 이룸)
- 탄핵 + 의회의 수장이 보유한 성향에 맞는 조건 달성
- 자원
- 토큰: 소모성 자원
- 예산 토큰
- 인력 토큰
- 정보 토큰
- 자원 태그: 누적 자원, 영구적인 1코스트 할인 효과
- 재력/자본 태그: 건물 아이콘
- 무력/조직 태그: 방패 아이콘
- 감시/언론 태그: 눈동자 아이콘
- 핵심 속성 파라미터 (명확한 수치화)
- [내부 저항 (Internal Resistance)]: (0~100%) 국장에 대한 조직 내부의 반발력.
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [엔트로피 (Entropy)]: (0~100%) 세계가 미쳐가는 종말 카운트다운.
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [위험 수용 계수 (Risk Tolerance)]: (0~100%) 위원회가 국장의 행보를 보고 느끼는 위기감의 척도. (높을수록 위원회가 보수적/강압적으로 변함)
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [내러티브 태그]: 결재로 얻는 세계의 훼손 상태 (스택형).
- [성향 태그]: 결재로 얻는 국장의 통치 사상 (스택형).
- [임원 우호도]: 13명의 위원이 국장에게 가지는 개별 호감도 (-100 ~ +100).
- 결재 단계: 스와이프 결정
- 딜레마 선택
- 일상 딜레마: 어떤 토큰과 어떤 오염 카드를 선택할 것인가?
- 좌: 토큰 획득 + 덱오염
- 우: 토큰 획득 + 덱오염
- 위기 딜레마: 덱오염의 대가로 토큰을 얻을 것인가? 토큰을 쓰고 위기를 넘길것인가?
- 좌: 토큰 획득 + 덱오염
- 우: 토큰 소모
- 작전: 무료 획득이지만 덱오염을 감수할 것인가? 토큰을 쓰고 더 많은 자원 태그를 얻을 것인가?
- 좌: 자원 태그 획득 + 덱오염
- 우: 토큰 소모 + 더 많은 자원 태그 획득
- 극비 프로젝트: 토큰을 소모해 어떤 보상 (자원 태그 or 자산)을 받을 것인가?
- 좌: 토큰 소모 + '자원 태그 획득 or 자산 획득'
- 우: 토큰 소모 + '자원 태그 획득 or 자산 획득'
- 위로: 버리기
- 위기 강타(오염 카드 - 신화, 조직저항, 이사회의 압박)
- 좌: 토큰 차감 or 자원 태그 차감
- 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승
- 뉴스 이벤트
- 좌: 보유 '자산' 중 1개 제거
- 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승
- 공통 획득
- 내러티브 태그: 스토리 전달 + 자원 태그 스택
- 연대기 시스템 (Chronicle)
- 누적된 내러티브 태그를 터치하면, 플레이어가 지금까지 세상을 어떻게 망치고 구해왔는지 연대기 형태의 히스토리/텍스트로 확인 가능. (플레이어의 목적의식과 내러티브 몰입도 강화)
- 성향 태그: 파벌과의 우호도 변화
- 덱 오염 (토큰을 마이너스)
- 엔트로피 1~5단계: 신화 카드 추가
- 적색 보고서: 뉴스 이벤트
- 내부 저항 1~5단계: 조직 저항 카드 추가
- 위험 수용 계수 1~5단계: 이사회의 압박 카드 추가
- 덱압축: 오염카드 제거
- 자산
- 성질 변환 (Transformation): 독을 약으로 바꾸기
- 오염카드를 영구 변환
- 시한부 격리 (Quarantine): 하이 리스크 하이 리턴 압축
- 여러장의 카드를 격리하지만 조건에 따라 덱으로 복귀
- 물리적 영구 소각 (Purge): 정통 덱 압축
- 조건 없이 깔끔하게 덱의 불순물을 제거하는 가장 기본적이고 강력한 수단
- 결재권 강제 행사 (Mulligan / Skip): 회피 기동
- 덱에서 카드가 사라지지는 않지만, 뽑혔을 때 뼈아픈 페널티를 무효화하는 패시브 방어막
- 자산 슬롯 (무제한 인벤토리)
- 파이프라인 연쇄 반응
- 연쇄 노드를 구축하면 오염카드 자동처리
- 입력
- 변환
- 출력
- 주간 의회 단계: 의회의 압박
- '그림자 위원회' 임원 구성 (총 13명):
- 상임 이사회 (8명): 고정된 이념을 가짐. 국장의 '성향 태그'에 직접적으로 우호도가 변함.
- 글로벌 지부장 (5명): 수많은 현장 책임자 중 5명만 선발되어 합류(게임을 시작할때마다 변경). 이념보다 실리(자원 태그, 자산, 특정 내러티브 태그)에 따라 우호도가 변동함.
- 법안 상정 및 투표
- 법안 상정
- 동맹: 이득 규약 법안 상정 (자원 태그 플러스)
- 파국: 표적 규약 법안 상정 (자원 태그 마이너스)
- 예상 표결
- 동맹: 이득 규약에 투표 - 국장의 개입 불가
- 우호: 이득 규약에 투표
- 중립: 알 수 없음
- 적대: 표적 규약에 투표
- 파국: 표적 규약에 투표 - 국장의 개입 불가
- 국장의 개입
- 매수 액션:
- 소모 비용: [예산 토큰] 대량 소모 (또는 재력 자원 태그 스택 희생)
- ​개입 효과: '중립' 또는 '적대' 성향의 임원 1~2명을 이번 투표에 한하여 '동맹(찬성)'으로 강제 전환합니다.
- ​치명적 리스크: 돈으로 입을 막은 대가로 이사회 내부에 국장의 부패 스캔들이 돌기 시작하며 [위험 수용 계수]가 즉시 1단계(20%) 상승합니다.
- 장부 조작 액션:
- 소모 비용: [정보 토큰] 대량 소모 (또는 감시 자원 태그 스택 희생)
- ​개입 효과: 발의된 법안의 핵심 근거 서류를 위조하여 법안 상정 자체를 무효화하고 다음 주 의회로 이월(Delay)시킵니다. 시간을 벌 수 있는 유일한 수단입니다.
- ​치명적 리스크: 은폐해야 할 외부의 미스터리 관련 기밀 정보를 정치판에 흘린 대가로 [엔트로피]가 즉시 1단계(20%) 상승합니다.
- 내부 감사 액션:
- 소모 비용: [인력 토큰] 대량 소모 (또는 무력 자원 태그 스택 희생)
- ​개입 효과: 가장 강력한 '적대/파국' 임원의 비리를 무력으로 털어, 해당 임원과 그 측근들을 이번 의회에서 '기권(투표권 박탈)' 처리합니다. 탄핵 정족수를 무너뜨릴 때 유용합니다.
- ​치명적 리스크: 요원들을 사적인 정치 싸움에 갈아 넣은 결과로 현장의 불만이 폭발하여 [내부 저항]이 즉시 1단계(20%) 상승합니다.
- 투표 결과 및 법안 통과: 국장의 개입이 반영된 투표 결과 출력 및 법안 통과
- 탄핵 투표
- 예상 표결:
- 동맹: 탄핵 반대 - 국장의 개입 불가
- 우호: 탄핵 반대
- 중립: 알 수 없음
- 적대: 탄핵 찬성
- 파국: 탄핵 찬성 - 국장의 개입 불가
- 국장의 개입: 매수 액션, 장부 조작 액션, 내부 감사 액션
- 투표 결과 및 탄핵 심판: 국장의 개입이 반영된 투표 결과 출력 및 탄핵 결과 적용
- 토큰 경제 (내러티브 태그 누적치에 따라 페이즈 전환)
- 페이즈1: 기본 루프 (어떤 독을 마실 것인가)
- 토큰과 오염 카드 선택
- 주요 등장 딜레마: 일상 딜레마 + 위기 딜레마
- 페이즈2: 기회 루프 (드디어 턴이 왔다)
- 토큰 소비와 스택 보상 선택(오염 카드 해결 + 자산, 자원 태그 빌드업)
- 주요 등장 딜레마: 작전 + 극비 프로젝트 + 오염 카드
- 페이즈3: 압박 루프 (쌓아둔 모래성 무너뜨리기)
- 엔트로피, 내부 저항, 위험 수용 계수는 거의 게임오버 한계에 도달
- 오염 카드, 뉴스 속보, 표적 규약이 극에 달하는 시기로 토큰이 항상 부족
- 주요 등장 딜레마: 작전 + 극비 프로젝트 + 오염 카드
- 스토리 기반 페이징
- **작동 방식:** 플레이어가 획득한 [내러티브 태그]의 총개수에 따라 파트가 넘어갑니다.
- 태그 0~3개: 페이즈1 (사태 파악 및 은폐의 시작)
- 태그 4~7개: 페이즈2 (프로젝트 가동 및 이사회의 개입 본격화)
- 태그 8개 이상: 페이즈3 (통제 불능의 사태 및 종말의 카운트다운)

105
docs/architecture_design.md Normal file
View File

@@ -0,0 +1,105 @@
# Project SS - 파일 및 컴포넌트 구조 설계
본 문서는 **"서사형 덱빌딩 게임 시스템 기획서"** 및 **"스토리 바이블"**을 바탕으로, 기존 단일 HTML 파일을 모던 웹 프레임워크(React + Vite + Tailwind CSS + Zustand) 기반으로 마이그레이션 및 고도화하기 위한 디렉토리 및 컴포넌트 구조를 정의합니다.
## 📂 1. 전체 디렉토리 구조 (Directory Structure)
```text
/src
├── assets/ # 이미지, 폰트, 아이콘 등 정적 파일
├── components/ # UI 및 도메인 컴포넌트
│ ├── common/ # 공통 UI 요소 (버튼, 모달, 툴팁 등)
│ ├── dashboard/ # 상단 상태창 및 파라미터 표시
│ ├── gameplay/ # 핵심 게임 루프 (카드 스와이프, 자산 관리)
│ └── council/ # 주간 의회 시스템 (투표, 법안, 파벌)
├── constants/ # 게임 내 상수 (파벌 정보, 테마 컬러, 엔딩 조건 등)
├── data/ # 초기 데이터 및 시나리오 (CSV/JSON 형식)
├── hooks/ # 커스텀 훅 (ex. useSwipe, useVoting)
├── store/ # 전역 상태 관리 (Zustand)
├── types/ # TypeScript 타입 정의 (Card, Faction, State 등)
├── utils/ # 헬퍼 함수 (엔트로피 계산, 전투/결과 연산 등)
├── App.tsx # 루트 컴포넌트 (페이지 라우팅 및 전역 상태 주입)
└── main.tsx # 진입점
```
---
## 🧩 2. 상세 컴포넌트 구조 (Components Breakdown)
### 2.1 공통 UI (`/components/common`)
프리미엄한 관료주의 다크/레트로 UI를 구성하는 기본 요소들입니다.
- `Button.tsx`: 액션 버튼 (결재, 서명 등 텍스처 효과 포함)
- `Modal.tsx`: 미스터리 보드, 법안 확인 등을 위한 공통 팝업
- `ProgressBar.tsx`: 게이지 바 (엔트로피, 내부 저항 등)
- `AlertToast.tsx`: 패시브 효과 연쇄나 비상 사태를 알리는 시스템 메시지 (우측 하단/상단 노출)
### 2.2 대시보드 (`/components/dashboard`)
플레이어의 현재 상태(국장실 요약)를 보여줍니다.
- `StatusHeader.tsx`: 현재 주차(Week), 일차(Day), 주간 아젠다 노출
- `ParameterBoard.tsx`: 3대 핵심 파라미터 게이지 (내부 저항, 엔트로피, 위험 수용 계수)
- `ResourcePanel.tsx`: 3대 토큰(예산, 인력, 정보) 및 자원 태그(재력, 무력, 감시) 현황
- `FactionDonut.tsx`: 8대 파벌의 의회 지분율 및 우호도를 시각화하는 도넛 차트
### 2.3 게임플레이 (`/components/gameplay`)
일일 업무(결재 단계)를 처리하는 핵심 루프 컴포넌트입니다.
- `DeskView.tsx`: 국장실 책상 배경 및 전체 레이아웃 래퍼
- `CardSwiper.tsx`: 틴더 스타일의 스와이프 인터페이스 (Tinder-like Swipe)
- 좌/우 스와이프에 따른 예측 결과(예상 토큰/파라미터 증감)를 오버레이로 표시
- `DilemmaCard.tsx`: 개별 딜레마/위기 서류. (제목, 내용, 플레이버 텍스트, 결재/반려 스탬프 이펙트)
- `AssetManager.tsx`: 보유 중인 자산(파이프라인, 성질 변환 등 덱 압축 장치) 관리 모달
### 2.4 의회 시스템 (`/components/council`)
매주 5일차 종료 후 진행되는 정치 시스템 컴포넌트입니다.
- `CouncilPhase.tsx`: 주간 의회 화면 메인 컨테이너
- `BillDisplay.tsx`: 상정된 법안(이득 규약 vs 표적 규약) 비교 UI
- `VotingBoard.tsx`: 13명의 임원(상임 이사 8명 + 글로벌 지부장 5명)의 예상 투표 현황 및 게이지 바
- `InterventionPanel.tsx`: 국장의 개입 액션 (매수, 장부 조작, 내부 감사) 버튼 및 리스크 안내 패널
### 2.5 뷰 / 씬 (Views/Scenes)
- `LoadingScreen.tsx`: 초기 데이터 파싱 및 게임 세팅 화면
- `GameScreen.tsx`: 인게임 전체 메인 화면 (대시보드 + 게임플레이 결합)
- `EndingScreen.tsx`: 멀티 엔딩 매트릭스에 따른 최종 결과 출력 (위대한 침묵, 신인류의 탄생 등)
---
## 💾 3. 전역 상태 구조 (Global State - Zustand 제안)
관리가 복잡한 게임 데이터를 효율적으로 다루기 위해 상태를 나눕니다.
```typescript
interface GameState {
// 시간/페이즈 관리
week: number;
day: number;
phase: 'DAY_WORK' | 'COUNCIL' | 'ENDING';
// 핵심 파라미터
entropy: number; // 0~100%
internalResistance: number; // 0~100%
riskTolerance: number; // 0~100%
// 자원
tokens: { budget: number; personnel: number; info: number; };
tags: { finance: number; force: number; surveillance: number; };
// 덱 및 내러티브
narrativeTags: string[];
ideologyTags: string[];
deck: Card[]; // 섞인 오염카드 및 이벤트 풀
assets: Asset[]; // 덱 압축 파이프라인
// 파벌 및 의회 (13명의 임원 정보 포함)
factions: FactionState[];
currentBills: Bill[];
}
```
---
## 🚀 4. 추천 기술 스택
- **Framework**: `React` (컴포넌트 분리를 통한 복잡한 UI 관리)
- **Build Tool**: `Vite` (빠른 HMR 및 빌드 속도)
- **Styling**: `Tailwind CSS` (유틸리티 클래스로 빠른 UI 구현) + CSS Animations (스와이프 액션, 팝업 등)
- **State Management**: `Zustand` (가볍고 직관적인 전역 상태 관리)
- **Interactions**: `Framer Motion` 또는 `react-spring` (부드러운 카드 스와이프 액션 및 몰입감 있는 애니메이션)
- **Type Checking**: `TypeScript` (복잡한 자원 계산과 이벤트 분기에 대한 안정성 보장)

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<title>Project SS - Live DB Edition</title>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Nanum+Myeongjo:wght@700;800&family=Black+Han+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1672
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,9 @@
"description": "Project SS - Steam App", "description": "Project SS - Steam App",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"start": "electron .", "start": "electron .",
"build:win": "electron-builder --win --x64" "build:win": "electron-builder --win --x64"
}, },
@@ -31,7 +34,29 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.2",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.8.1" "electron-builder": "^26.8.1",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.17.0",
"papaparse": "^5.5.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
} }
} }

58
src/App.tsx Normal file
View File

@@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import { initDatabase } from './utils/dataLoader';
import { useGameStore } from './store/gameState';
import { Dashboard } from './components/dashboard/Dashboard';
import { CardView } from './components/gameplay/CardView';
function App() {
const { isLoaded, initGame, phase, nextDay } = useGameStore();
useEffect(() => {
async function loadData() {
const db = await initDatabase();
initGame(db);
nextDay();
}
loadData();
}, []);
if (!isLoaded) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-white">
Loading Data...
</div>
);
}
return (
<div className="flex h-[100dvh] w-screen items-center justify-center bg-black font-sans text-white overflow-hidden">
{/* Mobile/Steam Container Setup */}
<div className="relative flex h-full w-full max-w-[450px] flex-col overflow-hidden bg-gray-900 sm:h-[95vh] sm:max-h-[900px] sm:rounded-xl sm:border sm:border-gray-700 shadow-2xl">
<Dashboard />
{phase === 'DAY_WORK' && <CardView />}
{phase === 'COUNCIL' && (
<div className="flex-1 flex flex-col items-center justify-center p-4">
<h2 className="text-xl font-bold text-yellow-500 mb-4">Council Phase</h2>
<button className="bg-gray-700 px-4 py-2 rounded mt-4" onClick={() => { /* reset for now */ window.location.reload() }}>
Next Week (Not Implemented)
</button>
</div>
)}
{phase === 'ENDING' && (
<div className="flex-1 flex flex-col items-center justify-center p-4">
<h2 className="text-2xl font-bold text-red-500 mb-4">GAME OVER</h2>
<p>Entropy reached 100%.</p>
<button className="bg-gray-700 px-4 py-2 rounded mt-4" onClick={() => window.location.reload()}>
Restart
</button>
</div>
)}
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { useGameStore } from '../../store/gameState';
export const Dashboard: React.FC = () => {
const { week, day, anomaly, political, trust, entropy } = useGameStore();
return (
<div className="w-full bg-gray-800 border-b border-gray-600 p-3 flex flex-col gap-2 shrink-0">
<div className="flex justify-between items-center text-sm font-bold border-b border-gray-700 pb-1">
<span>Week {week}</span>
<span>Day {day} / 5</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-1">
<div className="bg-gray-700 p-2 rounded flex flex-col items-center">
<span className="text-xs text-gray-400">Data (Anomaly)</span>
<span className="text-lg font-mono text-blue-300">{anomaly}</span>
</div>
<div className="bg-gray-700 p-2 rounded flex flex-col items-center">
<span className="text-xs text-gray-400">Pol. Capital</span>
<span className="text-lg font-mono text-yellow-400">{political}</span>
</div>
</div>
<div className="flex flex-col gap-1 mt-1">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-400">Trust</span>
<span>{trust}%</span>
</div>
<div className="w-full h-2 bg-gray-900 rounded overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${trust}%` }}></div>
</div>
<div className="flex justify-between items-center text-xs mt-1">
<span className="text-gray-400">Entropy</span>
<span>{entropy}%</span>
</div>
<div className="w-full h-2 bg-gray-900 rounded overflow-hidden">
<div className="h-full bg-red-500" style={{ width: `${entropy}%` }}></div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { useGameStore } from '../../store/gameState';
export const CardView: React.FC = () => {
const { currentCard, addStat, nextDay, phase } = useGameStore();
const [hoverDirection, setHoverDirection] = useState<'left' | 'right' | null>(null);
if (phase !== 'DAY_WORK' || !currentCard) {
return <div className="flex-1 flex items-center justify-center">No active card.</div>;
}
const handleDecision = (direction: 'left' | 'right') => {
// Process the decision
if (direction === 'left') {
addStat('anomaly', currentCard.yes_a);
addStat('entropy', currentCard.yes_e);
// TODO: Add narrative tags or specific costs based on 'yes' logic
} else {
addStat('political', currentCard.no_p);
addStat('entropy', currentCard.no_e);
// TODO: Add costs for 'no' logic
}
// Proceed to next day
nextDay();
setHoverDirection(null);
};
return (
<div className="flex-1 w-full flex flex-col items-center justify-center p-4 bg-gray-900 relative">
{/* Grey-box Card container */}
<div className="w-full max-w-[300px] bg-gray-200 text-black border-2 border-gray-400 p-4 rounded shadow-lg flex flex-col gap-3 min-h-[400px] z-10">
<div className="flex justify-between items-center text-xs text-gray-600 border-b border-gray-400 pb-2">
<span className="font-bold bg-gray-400 text-white px-2 py-1 rounded">{currentCard.dept || 'Department'}</span>
<span>{currentCard.faction || 'Neutral'}</span>
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold leading-tight">{currentCard.title || 'Untitled'}</h2>
<div className="flex gap-1 flex-wrap mt-1">
{currentCard.tags?.map((t, idx) => (
<span key={idx} className="bg-gray-800 text-white text-[9px] px-1.5 py-0.5 rounded">{t}</span>
))}
</div>
</div>
<div className="flex-1 text-sm leading-relaxed overflow-y-auto my-2 border-y border-gray-300 py-2 whitespace-pre-wrap">
{currentCard.body || 'No content.'}
</div>
<div className="text-xs italic text-gray-600 border-l-4 border-gray-400 pl-2">
"{currentCard.flavor || 'No flavor text'}"
</div>
</div>
{/* Decision Buttons (Grey-box alternative to swiping) */}
<div className="flex w-full justify-between mt-6 px-4 gap-4 z-10">
<button
onMouseEnter={() => setHoverDirection('left')}
onMouseLeave={() => setHoverDirection(null)}
onClick={() => handleDecision('left')}
className="flex-1 bg-red-900 hover:bg-red-800 text-white p-3 rounded text-sm font-bold border border-red-700 transition-colors"
>
Approve (Left)
</button>
<button
onMouseEnter={() => setHoverDirection('right')}
onMouseLeave={() => setHoverDirection(null)}
onClick={() => handleDecision('right')}
className="flex-1 bg-blue-900 hover:bg-blue-800 text-white p-3 rounded text-sm font-bold border border-blue-700 transition-colors"
>
Reject (Right)
</button>
</div>
{/* Prediction Overlay for Greybox Testing */}
{hoverDirection && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black text-white p-3 rounded border border-gray-600 text-xs w-3/4 shadow-2xl z-20">
<div className="font-bold border-b border-gray-700 pb-1 mb-2 text-center text-yellow-400">
Expected Result: {hoverDirection === 'left' ? 'Approve' : 'Reject'}
</div>
<div className="flex justify-between">
<span>Data (Anomaly):</span>
<span className={hoverDirection === 'left' && currentCard.yes_a > 0 ? 'text-green-400' : hoverDirection === 'right' && 0 > 0 ? 'text-green-400' : ''}>
{hoverDirection === 'left' ? (currentCard.yes_a > 0 ? `+${currentCard.yes_a}` : currentCard.yes_a) : '0'}
</span>
</div>
<div className="flex justify-between mt-1">
<span>Political Capital:</span>
<span>{hoverDirection === 'right' ? (currentCard.no_p > 0 ? `+${currentCard.no_p}` : currentCard.no_p) : '0'}</span>
</div>
<div className="flex justify-between mt-1">
<span>Entropy:</span>
<span>{hoverDirection === 'left' ? (currentCard.yes_e > 0 ? `+${currentCard.yes_e}` : currentCard.yes_e) : (currentCard.no_e > 0 ? `+${currentCard.no_e}` : currentCard.no_e)}</span>
</div>
</div>
)}
</div>
);
};

31
src/index.css Normal file
View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
@theme {
--color-bgDark: #1a1a1a;
--color-paper: #e6dec5;
--color-ink: #2b2b2b;
--color-redSeal: #c92a2a;
--color-blueSeal: #1c4f82;
--color-uiBorder: #444;
--font-courier: "Courier Prime", monospace;
--font-nanum: "Nanum Myeongjo", serif;
--font-blackhan: "Black Han Sans", sans-serif;
}
@layer base {
:root {
--safe-top: env(safe-area-inset-top, 30px);
}
body {
@apply m-0 p-0 overflow-hidden select-none touch-none;
}
}
@layer utilities {
.desk-bg {
background: #2d1b14;
background-image: repeating-linear-gradient(45deg, #281812 25%, transparent 25%, transparent 75%, #281812 75%, #281812);
background-size: 20px 20px;
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

139
src/store/gameState.ts Normal file
View File

@@ -0,0 +1,139 @@
import { create } from 'zustand';
import { Database, CardData, Agenda, Quest, Asset, Mystery, Faction, Bill } from '../types';
interface GameState {
isLoaded: boolean;
db: Database | null;
week: number;
day: number;
phase: 'DAY_WORK' | 'COUNCIL' | 'ENDING';
// 파라미터 (0 ~ 100)
anomaly: number; // 데이터
political: number; // 정치 자본
trust: number; // 신임도
entropy: number; // 종말 시계
// 상태
assets: Asset[];
builtTags: Record<string, number>;
activeLaws: Bill[];
activeMysteries: Mystery[];
currentAgenda: Agenda | null;
currentQuest: Quest | null;
questProgress: number;
currentCard: CardData | null;
lockState: 'none' | 'approve' | 'reject';
council: {
billA: Bill | null;
billB: Bill | null;
votesA: number;
votesB: number;
supportersA: Faction[];
supportersB: Faction[];
};
// Actions
initGame: (db: Database) => void;
nextDay: () => void;
processSwipe: (direction: 'left' | 'right') => void;
addStat: (key: 'anomaly' | 'political' | 'trust' | 'entropy', val: number) => void;
}
export const useGameStore = create<GameState>((set, get) => ({
isLoaded: false,
db: null,
week: 1,
day: 1,
phase: 'DAY_WORK',
anomaly: 20,
political: 20,
trust: 80,
entropy: 10,
assets: [],
builtTags: {},
activeLaws: [],
activeMysteries: [],
currentAgenda: null,
currentQuest: null,
questProgress: 0,
currentCard: null,
lockState: 'none',
council: {
billA: null,
billB: null,
votesA: 50,
votesB: 50,
supportersA: [],
supportersB: []
},
initGame: (db) => set({
isLoaded: true,
db,
week: 1,
day: 1,
phase: 'DAY_WORK',
anomaly: 20,
political: 20,
trust: 80,
entropy: 10,
assets: [],
builtTags: {},
activeLaws: [],
activeMysteries: [...db.mysteries],
currentAgenda: db.agendas.length > 0 ? db.agendas[Math.floor(Math.random() * db.agendas.length)] : null,
currentQuest: db.quests.length > 0 ? db.quests[Math.floor(Math.random() * db.quests.length)] : null,
}),
addStat: (key, val) => set((state) => ({
[key]: Math.max(0, Math.min(100, state[key] + val)) // Ensure between 0 and 100 for percentage stats, though anomaly/political can go higher
})),
nextDay: () => {
const state = get();
if (!state.db) return;
if (state.day > 5) {
set({ phase: 'COUNCIL' });
return;
}
if (state.entropy >= 100) {
set({ phase: 'ENDING' });
return;
}
// 파라미터 패널티 적용 등 (기존 로직 이식)
let newEntropy = state.entropy;
state.activeMysteries.forEach(m => {
if (m.penalty_entropy) newEntropy += m.penalty_entropy;
});
let pool = state.db.scenarios.filter(s => s.act === state.week);
if (pool.length === 0) pool = state.db.scenarios;
const nextCard = pool[Math.floor(Math.random() * pool.length)];
set({
day: state.day + 1,
entropy: Math.min(100, newEntropy),
currentCard: nextCard,
lockState: 'none' // 추후 활성 법안에 따라 계산
});
},
processSwipe: (direction) => {
// 스와이프 처리 로직 (기존 로직 이식)
get().nextDay();
}
}));

84
src/types/index.ts Normal file
View File

@@ -0,0 +1,84 @@
export interface Faction {
id: string;
name: string;
share: number;
like_tag: string;
hate_tag: string;
is_blacklisted?: boolean;
color?: string;
}
export interface CardData {
id: string | number;
dept: string;
color?: string;
title: string;
body: string;
flavor: string;
faction: string;
tags: string[];
yes_a: number; // Anomaly (Data) cost/gain
no_p: number; // Political capital cost/gain
yes_e: number; // Entropy change on yes
no_e: number; // Entropy change on no
act?: number;
}
export interface Agenda {
id: string;
title: string;
desc: string;
}
export interface Quest {
id: string;
title: string;
reward: string;
penalty: string;
}
export interface Bill {
id: string;
name: string;
desc: string;
type: string; // 'lock_approve', 'lock_reject', 'buff_tag', 'nullify_tag', etc.
tag: string;
is_buff?: boolean;
target_tag?: string;
buff_tag?: string | null;
nullify_tag?: string | null;
}
export interface Asset {
id: string;
name: string;
tags: string[];
passive_effect?: { anomaly?: number; political?: number; entropy?: number };
holding_risk?: { entropy?: number };
combo_req?: number;
trigger?: string;
effect_type?: string;
effect_target?: 'anomaly' | 'political' | 'trust' | 'entropy';
effect_val?: number;
combo_effect_key?: 'anomaly' | 'political' | 'trust' | 'entropy';
combo_effect_val?: number;
}
export interface Mystery {
id: string;
title: string;
desc: string;
req_tags: Record<string, number>;
cost_anomaly: number;
penalty_entropy: number;
}
export interface Database {
factions: Faction[];
agendas: Agenda[];
quests: Quest[];
bills: Bill[];
assets: Asset[];
scenarios: CardData[];
mysteries: Mystery[];
}

117
src/utils/dataLoader.ts Normal file
View File

@@ -0,0 +1,117 @@
import Papa from 'papaparse';
import { Database } from '../types';
const SHEET_URLS = {
factions: "/data/factions.csv",
agendas: "/data/agendas.csv",
quests: "/data/quests.csv",
bills: "/data/bills.csv",
assets: "/data/assets.csv",
scenarios: "/data/scenarios.csv",
mysteries: "/data/mysteries.csv"
};
function normalizeKey(str: string) {
return String(str).replace(/^\uFEFF/, '').trim().toLowerCase();
}
function normalizeValue(val: any) {
return typeof val === 'string' ? val.trim() : val;
}
function parseCSV<T>(url: string): Promise<T[]> {
return new Promise((resolve) => {
Papa.parse(url, {
header: true,
skipEmptyLines: true,
download: true,
complete: function(results) {
const data = results.data.map((row: any) => {
let r: any = {};
for(let k in row) r[normalizeKey(k)] = normalizeValue(row[k]);
return r;
});
resolve(data as T[]);
},
error: function() {
resolve([]);
}
});
});
}
export async function initDatabase(): Promise<Database> {
const [factionsData, agendasData, questsData, billsData, assetsData, scenariosData, mysteriesData] = await Promise.all([
parseCSV<any>(SHEET_URLS.factions),
parseCSV<any>(SHEET_URLS.agendas),
parseCSV<any>(SHEET_URLS.quests),
parseCSV<any>(SHEET_URLS.bills),
parseCSV<any>(SHEET_URLS.assets),
parseCSV<any>(SHEET_URLS.scenarios),
parseCSV<any>(SHEET_URLS.mysteries)
]);
const factions = factionsData.map(f => ({
...f,
share: parseInt(f.share) || 10,
like_tag: f.love_tag || f.like_tag || '',
hate_tag: f.hate_tag || ''
}));
const bills = billsData.map(b => {
let is_buff = b.type === 'lock_reject' || b.type === 'buff_tag';
return {
...b,
is_buff,
target_tag: b.tag,
buff_tag: is_buff ? b.tag : null,
nullify_tag: !is_buff ? b.tag : null
};
});
const assets = assetsData.map(a => ({
...a,
tags: a.tags ? a.tags.split(',').map((t: string) => t.trim()) : [],
passive_effect: { anomaly: parseInt(a.passive_eff_val) || 0 },
holding_risk: { entropy: parseInt(a.risk_eff_val) || 0 },
combo_req: parseInt(a.combo_req) || 0,
effect_val: parseInt(a.effect_val) || 0,
combo_effect_val: parseInt(a.combo_effect_val) || 0
}));
const scenarios = scenariosData.map(s => ({
...s,
act: parseInt(s.act) || 1,
tags: s.tags ? s.tags.split(',').map((t: string) => t.trim().replace(/^"|"$/g, '')) : [],
yes_a: parseInt(s.cost) || 0,
no_p: parseInt(s.conflict) || 0,
yes_e: parseInt(s.yes_e) || 0,
no_e: parseInt(s.no_e) || 0,
}));
const mysteries = mysteriesData.map(m => {
let req_tags: Record<string, number> = {};
if (m.req_tags) {
m.req_tags.split(',').forEach((rt: string) => {
let parts = rt.split(':');
if (parts.length === 2) req_tags[parts[0].trim()] = parseInt(parts[1]);
});
}
return {
...m,
req_tags,
cost_anomaly: parseInt(m.cost_anomaly) || 0,
penalty_entropy: parseInt(m.penalty_entropy) || 0
};
});
return {
factions,
agendas: agendasData,
quests: questsData,
bills,
assets,
scenarios,
mysteries
};
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [
tailwindcss(),
react()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
base: './',
server: {
port: 5173
}
});