기획 문서에서 미구현한 시스템 구현
다음과 같은 주요 시스템들이 클라이언트 단에 추가되었습니다:
1. 대중 공황 (Mass Panic) 시스템
• 기존의 저항, 엔트로피, 위험 수용 계수 외에 '대중 공황' 파라미터를 추가했습니다.
• cards.csv 파일에 left_pan , right_pan 컬럼을 동적으로 업데이트하고, 일부 카드에 패널티/효과를 부여했습니다.
• 게임 메인 화면 상단에 😱 아이콘과 1~5단계 점(Dot) 위젯을 추가했습니다.
2. 주간 의회 (Weekly Council) 시스템
• 10턴 주기로 발동하는 CouncilScreen 컴포넌트를 신규 제작했습니다.
• 13명(상임 8명, 지부장 5명)의 위원회를 시각적으로 구현하고 위험 수용 계수에 따라 반대 확률이 증가하도록 로직을
작성했습니다.
• 3가지 개입 액션(매수, 장부 조작, 내부 감사)을 예산, 정보, 인력 토큰 소모를 통해 실행할 수 있도록 했습니다.
• 탄핵안 발의 및 법안(이득/표적) 상정 기능이 정상 작동합니다.
3. 무제한 인벤토리 및 자산 (Assets) 시스템
• 극비 카드를 완료했을 때 랜덤한 자산을 얻어 하단 인벤토리 슬롯에 시각적으로 누적되는 기초 로직을 붙였습니다.
• 스와이프 카드의 드래그를 양방향(XY)으로 허용하여, 극비 프로젝트의 경우 '위로 스와이프'하여 보류/버리기가
가능하도록 했습니다.
4. 연대기 (Chronicle) 시스템
• 좌상단 내러티브 태그 배지를 클릭하면 그동안 플레이어가 결재한 스토리와 선택(좌/우/위)을 기록한 ChroniclePopup
이 노출되도록 구현했습니다.
5. 멀티 엔딩 및 도감 시스템
• 단순한 게임 오버 화면을 대체하는 EndingScreen 컴포넌트를 제작했습니다.
• 4대 붕괴 조건(사임, 세계 멸망, 문명 붕괴, 정치적 탄핵) 중 어느 것을 달성했는지 판별하여 원인과 결과를 표시해
줍니다.
• 브라우저의 localStorage 를 이용해 수집된 엔딩이 도감에 영구적으로 기록되도록 처리했습니다.
This commit is contained in:
@@ -3,10 +3,13 @@ import Papa from 'papaparse';
|
||||
import cardsCsvRaw from '../data/cards.csv?raw';
|
||||
import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
|
||||
import SwipeCard from './SwipeCard';
|
||||
import CouncilScreen from './CouncilScreen';
|
||||
import EndingScreen from './EndingScreen';
|
||||
import ChroniclePopup from './ChroniclePopup';
|
||||
import './GameScreen.css';
|
||||
|
||||
export function GameScreen() {
|
||||
const [params, setParams] = useState({ resistance: 0, entropy: 0, risk: 0 });
|
||||
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
|
||||
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
|
||||
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
|
||||
const [narrativeTags, setNarrativeTags] = useState(0);
|
||||
@@ -14,6 +17,13 @@ export function GameScreen() {
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
|
||||
const [turns, setTurns] = useState(0);
|
||||
const [showCouncil, setShowCouncil] = useState(false);
|
||||
const [impeachmentPassed, setImpeachmentPassed] = useState(false);
|
||||
const [chronicle, setChronicle] = useState([]);
|
||||
const [showChronicle, setShowChronicle] = useState(false);
|
||||
const [assets, setAssets] = useState([]);
|
||||
|
||||
// CSV Load Effect
|
||||
useEffect(() => {
|
||||
@@ -29,6 +39,7 @@ export function GameScreen() {
|
||||
stats: {
|
||||
res: row.left_res || 0,
|
||||
ent: row.left_ent || 0,
|
||||
pan: row.left_pan || 0,
|
||||
rsk: row.left_rsk || 0,
|
||||
bud: row.left_bud || 0,
|
||||
per: row.left_per || 0,
|
||||
@@ -41,6 +52,7 @@ export function GameScreen() {
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (row.left_res || 0)),
|
||||
entropy: Math.max(0, p.entropy + (row.left_ent || 0)),
|
||||
panic: Math.max(0, p.panic + (row.left_pan || 0)),
|
||||
risk: Math.max(0, p.risk + (row.left_rsk || 0)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
@@ -60,6 +72,7 @@ export function GameScreen() {
|
||||
stats: {
|
||||
res: row.right_res || 0,
|
||||
ent: row.right_ent || 0,
|
||||
pan: row.right_pan || 0,
|
||||
rsk: row.right_rsk || 0,
|
||||
bud: row.right_bud || 0,
|
||||
per: row.right_per || 0,
|
||||
@@ -72,6 +85,7 @@ export function GameScreen() {
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (row.right_res || 0)),
|
||||
entropy: Math.max(0, p.entropy + (row.right_ent || 0)),
|
||||
panic: Math.max(0, p.panic + (row.right_pan || 0)),
|
||||
risk: Math.max(0, p.risk + (row.right_rsk || 0)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
@@ -92,7 +106,7 @@ export function GameScreen() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.resistance >= 5 || params.entropy >= 5 || params.risk >= 5) {
|
||||
if (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5 || params.risk >= 5) {
|
||||
setGameOver(true);
|
||||
}
|
||||
if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2);
|
||||
@@ -102,13 +116,47 @@ export function GameScreen() {
|
||||
const handleSwipe = (direction) => {
|
||||
if (cards.length === 0) return;
|
||||
const card = cards[currentCardIndex % cards.length];
|
||||
|
||||
let outcomeText = '';
|
||||
|
||||
if (direction === 'left' && card.left) {
|
||||
card.left.effect();
|
||||
outcomeText = card.left.text;
|
||||
} else if (direction === 'right' && card.right) {
|
||||
card.right.effect();
|
||||
outcomeText = card.right.text;
|
||||
} else if (direction === 'up') {
|
||||
outcomeText = '보류 / 버리기';
|
||||
}
|
||||
|
||||
if (outcomeText) {
|
||||
setChronicle(prev => [...prev, { turn: turns + 1, text: `[${card.type}] ${card.text} -> 결정: ${outcomeText}` }]);
|
||||
}
|
||||
|
||||
// Add random asset for '극비' success (just for demonstration)
|
||||
if (card.type === '극비' && direction !== 'up') {
|
||||
if (assets.length < 4) {
|
||||
setAssets(prev => [...prev, { id: Date.now(), icon: ['📦','💎','🧪','💾'][Math.floor(Math.random()*4)] }]);
|
||||
}
|
||||
}
|
||||
|
||||
setNarrativeTags(n => n + 1);
|
||||
setCurrentCardIndex(idx => idx + 1);
|
||||
|
||||
const newTurns = turns + 1;
|
||||
setTurns(newTurns);
|
||||
|
||||
if (newTurns > 0 && newTurns % 10 === 0) {
|
||||
setShowCouncil(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCouncilResolve = (result) => {
|
||||
setShowCouncil(false);
|
||||
if (result.impeachment) {
|
||||
setImpeachmentPassed(true);
|
||||
setGameOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDots = (value, max = 5) => {
|
||||
@@ -119,11 +167,23 @@ export function GameScreen() {
|
||||
|
||||
if (gameOver) {
|
||||
return (
|
||||
<div className="game-over">
|
||||
<h1 className="glitch" data-text="시스템 붕괴">시스템 붕괴</h1>
|
||||
<p>프로젝트가 파멸을 맞이했습니다. 임무 실패.</p>
|
||||
<button className="restart-btn" onClick={() => window.location.reload()}>다음 회차 시작</button>
|
||||
</div>
|
||||
<EndingScreen
|
||||
params={params}
|
||||
impeachment={impeachmentPassed}
|
||||
narrativeTags={narrativeTags}
|
||||
onRestart={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCouncil) {
|
||||
return (
|
||||
<CouncilScreen
|
||||
params={params} setParams={setParams}
|
||||
tokens={tokens} setTokens={setTokens}
|
||||
tags={tags} setTags={setTags}
|
||||
onResolve={handleCouncilResolve}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +197,7 @@ export function GameScreen() {
|
||||
<div className={`game-screen ${phaseClass}`}>
|
||||
{/* Top Bar */}
|
||||
<div className="top-bar">
|
||||
<div className="narrative-badge">
|
||||
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
|
||||
<BookOpen size={16} /> <span>{narrativeTags}/8</span>
|
||||
</div>
|
||||
<div className="parameters">
|
||||
@@ -149,6 +209,10 @@ export function GameScreen() {
|
||||
<span className="icon">🌀</span>
|
||||
<div className="dots">{renderDots(params.entropy)}</div>
|
||||
</div>
|
||||
<div className={`param ${params.panic >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-panic-critical)'}}>
|
||||
<span className="icon">😱</span>
|
||||
<div className="dots">{renderDots(params.panic)}</div>
|
||||
</div>
|
||||
<div className={`param ${params.risk >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-risk-critical)'}}>
|
||||
<span className="icon">⚠️</span>
|
||||
<div className="dots">{renderDots(params.risk)}</div>
|
||||
@@ -178,12 +242,20 @@ export function GameScreen() {
|
||||
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
|
||||
</div>
|
||||
<div className="asset-slots">
|
||||
<div className="asset-slot"></div>
|
||||
<div className="asset-slot"></div>
|
||||
<div className="asset-slot"></div>
|
||||
<div className="asset-slot"></div>
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} className="asset-slot">
|
||||
{assets[i] ? <span className="asset-icon">{assets[i].icon}</span> : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showChronicle && (
|
||||
<ChroniclePopup
|
||||
chronicle={chronicle}
|
||||
onClose={() => setShowChronicle(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user