서사형 덱빌딩 게임 시스템으로 리뉴얼
This commit is contained in:
156
docs/Project SS 서사형 덱빌딩 게임 시스템 기획서.md
Normal file
156
docs/Project SS 서사형 덱빌딩 게임 시스템 기획서.md
Normal 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
105
docs/architecture_design.md
Normal 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
15
index.html
Normal 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
1672
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -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
58
src/App.tsx
Normal 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;
|
||||||
44
src/components/dashboard/Dashboard.tsx
Normal file
44
src/components/dashboard/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
src/components/gameplay/CardView.tsx
Normal file
100
src/components/gameplay/CardView.tsx
Normal 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
31
src/index.css
Normal 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
10
src/main.tsx
Normal 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
139
src/store/gameState.ts
Normal 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
84
src/types/index.ts
Normal 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
117
src/utils/dataLoader.ts
Normal 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
29
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
20
vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user