기획 문서에서 미구현한 시스템 구현

다음과 같은 주요 시스템들이 클라이언트 단에 추가되었습니다:
  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:
Kyoung5seo
2026-06-17 23:14:15 +09:00
parent e2557ebee8
commit c0921548ec
13 changed files with 720 additions and 52 deletions

View File

@@ -0,0 +1,168 @@
import React, { useState, useEffect } from 'react';
import './CouncilScreen.css';
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, onResolve }) {
const [members, setMembers] = useState([]);
const [agenda, setAgenda] = useState('');
const [impeachment, setImpeachment] = useState(false);
const [actionTaken, setActionTaken] = useState(false);
const [result, setResult] = useState(null);
useEffect(() => {
// Initialize 13 members (8 board, 5 global)
// Their vote depends on risk parameter and randomness
const newMembers = Array.from({ length: 13 }).map((_, i) => {
const type = i < 8 ? 'board' : 'global';
// High risk = more likely to vote against
const rand = Math.random() * 100;
let vote = '미정';
if (rand < params.risk * 20) {
vote = '반대';
} else if (rand > 50) {
vote = '찬성';
}
return { id: i, type, vote };
});
setMembers(newMembers);
const againstCount = newMembers.filter(m => m.vote === '반대').length;
if (againstCount >= 9) {
setAgenda('정치적 탄핵안 상정');
setImpeachment(true);
} else {
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
setAgenda(randAgenda);
}
}, [params.risk]);
const handleAction = (type) => {
if (actionTaken) return;
const newMembers = [...members];
if (type === 'bribe' && tokens.budget >= 30) {
setTokens(prev => ({ ...prev, budget: prev.budget - 30 }));
setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) }));
// Force 2 against to support
let changed = 0;
for (let m of newMembers) {
if (m.vote === '반대' && changed < 2) {
m.vote = '찬성';
changed++;
}
}
} else if (type === 'manipulate' && tokens.info >= 30) {
setTokens(prev => ({ ...prev, info: prev.info - 30 }));
setParams(prev => ({ ...prev, entropy: Math.min(5, prev.entropy + 1) }));
// Delay agenda
setResult('법안 상정 이월 (장부 조작 성공)');
setActionTaken(true);
return;
} else if (type === 'audit' && tokens.personnel >= 30) {
setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 }));
setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) }));
// Force 3 against to abstain
let changed = 0;
for (let m of newMembers) {
if (m.vote === '반대' && changed < 3) {
m.vote = '미정';
changed++;
}
}
} else {
return; // Not enough tokens
}
setMembers(newMembers);
setActionTaken(true);
};
const finalize = () => {
if (result) {
onResolve({ impeachment: false });
return;
}
const againstCount = members.filter(m => m.vote === '반대').length;
if (impeachment) {
if (againstCount >= 9) {
onResolve({ impeachment: true });
} else {
onResolve({ impeachment: false });
}
} else {
const supportCount = members.filter(m => m.vote === '찬성').length;
if (supportCount > againstCount) {
if (agenda.includes('이득')) {
setTags(prev => ({ ...prev, wealth: prev.wealth + 1 }));
} else {
setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) }));
}
}
onResolve({ impeachment: false });
}
};
return (
<div className="council-screen">
<div className="council-header">
<h2>[📜 이번 상정: {agenda}]</h2>
<div className="risk-bar-container">
<span>위험 수용 계수:</span>
<div className="risk-bar">
<div className="risk-fill" style={{ width: `${(params.risk / 5) * 100}%` }}></div>
</div>
</div>
{impeachment && <div className="warning-blink">[🚨 정치적 탄핵안 상정!]</div>}
</div>
<div className="members-grid">
{members.map(m => (
<div key={m.id} className={`member-icon ${m.type}`}>
<span className="member-avatar">👤</span>
<span className="member-badge">
{m.vote === '찬성' ? '✅' : m.vote === '반대' ? '❌' : '❓'}
</span>
</div>
))}
</div>
{result ? (
<div className="council-result">
<h3>결과: {result}</h3>
<button className="fat-btn" onClick={finalize}>의회 종료</button>
</div>
) : (
<div className="council-actions">
<button
className={`fat-btn ${tokens.budget < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('bribe')}
disabled={actionTaken || tokens.budget < 30}
>
💰 매수 (예산 30 소모 / 임원 찬성)
</button>
<button
className={`fat-btn ${tokens.info < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('manipulate')}
disabled={actionTaken || tokens.info < 30}
>
👁 장부 조작 (정보 30 소모 / 안건 이월)
</button>
<button
className={`fat-btn ${tokens.personnel < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('audit')}
disabled={actionTaken || tokens.personnel < 30}
>
🩸 내부 감사 (인력 30 소모 / 기권 유도)
</button>
{actionTaken && (
<button className="fat-btn action-proceed" onClick={finalize}>표결 진행</button>
)}
{!actionTaken && (
<button className="fat-btn action-proceed" onClick={finalize}>개입 없이 표결 진행</button>
)}
</div>
)}
</div>
);
}