기획 문서에서 미구현한 시스템 구현
다음과 같은 주요 시스템들이 클라이언트 단에 추가되었습니다:
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:
168
client/src/components/CouncilScreen.jsx
Normal file
168
client/src/components/CouncilScreen.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user