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

다음과 같은 주요 시스템들이 클라이언트 단에 추가되었습니다:
  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,93 @@
.chronicle-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.chronicle-popup {
background: var(--color-surface);
width: 90%;
max-width: 400px;
max-height: 80%;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
}
.chronicle-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
background: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.chronicle-header h2 {
margin: 0;
font-family: var(--font-doc-title);
font-size: 20px;
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 28px;
cursor: pointer;
}
.chronicle-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.empty-text {
text-align: center;
color: #888;
font-style: italic;
}
.chronicle-list {
list-style: none;
padding: 0;
margin: 0;
}
.chronicle-item {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
}
.chronicle-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.turn-number {
font-size: 12px;
color: var(--color-token-info);
font-weight: bold;
display: block;
margin-bottom: 5px;
}
.chronicle-item p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #ddd;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import './ChroniclePopup.css';
export default function ChroniclePopup({ chronicle, onClose }) {
return (
<div className="chronicle-overlay" onClick={onClose}>
<div className="chronicle-popup" onClick={e => e.stopPropagation()}>
<div className="chronicle-header">
<h2>국장의 연대기</h2>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="chronicle-content">
{chronicle.length === 0 ? (
<p className="empty-text">아직 기록된 역사가 없습니다.</p>
) : (
<ul className="chronicle-list">
{chronicle.map((entry, idx) => (
<li key={idx} className="chronicle-item">
<span className="turn-number">Turn {entry.turn}</span>
<p>{entry.text}</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
.council-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 100;
display: flex;
flex-direction: column;
padding: 40px 20px;
color: #fff;
box-sizing: border-box;
}
.council-header {
text-align: center;
margin-bottom: 30px;
}
.council-header h2 {
font-family: var(--font-doc-title);
color: var(--color-risk-critical);
margin-bottom: 10px;
}
.risk-bar-container {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--font-ui);
}
.risk-bar {
width: 200px;
height: 10px;
background: #333;
border-radius: 5px;
overflow: hidden;
}
.risk-fill {
height: 100%;
background: var(--color-risk-critical);
transition: width 0.3s;
}
.warning-blink {
color: #f87171;
font-weight: bold;
margin-top: 10px;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.members-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
justify-items: center;
margin: 0 auto 40px auto;
max-width: 320px;
}
.member-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 24px;
}
.member-icon.board {
border: 2px solid var(--color-risk-base);
}
.member-icon.global {
border: 2px solid var(--color-token-info);
}
.member-badge {
position: absolute;
bottom: -5px;
right: -5px;
font-size: 16px;
background: #000;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.council-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
.fat-btn {
width: 100%;
padding: 15px;
border: none;
border-radius: 12px;
font-family: var(--font-ui);
font-size: 16px;
font-weight: bold;
background: #333;
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.fat-btn:hover:not(.disabled) {
background: #444;
}
.fat-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-proceed {
background: var(--color-swipe-right);
margin-top: 20px;
}
.action-proceed:hover {
background: #2e7d32;
}
.council-result {
text-align: center;
margin-top: auto;
}
.council-result h3 {
margin-bottom: 20px;
}

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>
);
}

View File

@@ -0,0 +1,44 @@
.ending-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
color: var(--color-text);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
text-align: center;
z-index: 200;
}
.ending-content {
margin: 40px 0;
background: rgba(255, 255, 255, 0.05);
padding: 30px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 400px;
}
.ending-desc {
font-size: 18px;
line-height: 1.6;
margin-bottom: 20px;
font-family: var(--font-doc-title);
}
.ending-reason {
font-size: 14px;
color: #f87171;
margin-bottom: 10px;
}
.ending-stats {
font-size: 14px;
color: #aaa;
}

View File

@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import './EndingScreen.css';
export default function EndingScreen({ params, impeachment, narrativeTags, onRestart }) {
const [endingInfo, setEndingInfo] = useState({ title: '', desc: '', reason: '' });
useEffect(() => {
// Determine ending
let title = '미확인 종말';
let desc = '기록되지 않은 파멸입니다.';
let reason = '알 수 없는 원인';
if (impeachment) {
title = '정치적 탄핵 (윗선 붕괴)';
desc = '그림자 위원회가 당신의 권한을 박탈했습니다. 당신의 모든 업적은 지워질 것입니다.';
reason = '주간 의회에서 적대적 파벌 임원 9명 이상 찬성';
} else if (params.panic >= 5) {
title = '문명 붕괴 (사회 붕괴)';
desc = '대중은 광기에 휩싸였고, 문명은 스스로를 파괴했습니다. 통제 불능의 시대가 도래했습니다.';
reason = '대중 공황 파라미터 5단계 도달';
} else if (params.entropy >= 5) {
title = '세계 멸망 (외부 붕괴)';
desc = '장막이 찢어지고 이계의 존재들이 쏟아져 들어옵니다. 우리가 알던 세계는 끝났습니다.';
reason = '엔트로피 파라미터 5단계 도달';
} else if (params.resistance >= 5) {
title = '사임 (내부 붕괴)';
desc = '조직원들의 불만이 폭발하여 폭동이 일어났습니다. 당신은 자리에서 끌려 내려왔습니다.';
reason = '내부 저항 파라미터 5단계 도달';
}
setEndingInfo({ title, desc, reason });
// Save to collection
const collection = JSON.parse(localStorage.getItem('ss_endings') || '[]');
if (!collection.includes(title)) {
collection.push(title);
localStorage.setItem('ss_endings', JSON.stringify(collection));
}
}, [params, impeachment]);
return (
<div className="ending-screen">
<h1 className="glitch" data-text={endingInfo.title}>{endingInfo.title}</h1>
<div className="ending-content">
<p className="ending-desc">{endingInfo.desc}</p>
<div className="ending-reason">
<strong>발동 원인:</strong> {endingInfo.reason}
</div>
<div className="ending-stats">
<span>획득한 내러티브 태그: {narrativeTags}</span>
</div>
</div>
<button className="restart-btn" onClick={onRestart}>도감에 기록하기 & 다음 회차 시작</button>
</div>
);
}

View File

@@ -12,7 +12,12 @@
} }
.game-screen.phase-2 { .game-screen.phase-2 {
box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.8); justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.asset-icon {
font-size: 20px;
} }
.game-screen.phase-3 { .game-screen.phase-3 {
@@ -147,6 +152,9 @@
border: 1px dashed rgba(255, 255, 255, 0.2); border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
display: flex;
align-items: center;
justify-content: center;
} }
/* Game Over */ /* Game Over */

View File

@@ -3,10 +3,13 @@ import Papa from 'papaparse';
import cardsCsvRaw from '../data/cards.csv?raw'; import cardsCsvRaw from '../data/cards.csv?raw';
import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react'; import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
import SwipeCard from './SwipeCard'; import SwipeCard from './SwipeCard';
import CouncilScreen from './CouncilScreen';
import EndingScreen from './EndingScreen';
import ChroniclePopup from './ChroniclePopup';
import './GameScreen.css'; import './GameScreen.css';
export function GameScreen() { 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 [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 }); const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
const [narrativeTags, setNarrativeTags] = useState(0); const [narrativeTags, setNarrativeTags] = useState(0);
@@ -15,6 +18,13 @@ export function GameScreen() {
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [currentCardIndex, setCurrentCardIndex] = useState(0); 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 // CSV Load Effect
useEffect(() => { useEffect(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true }); const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
@@ -29,6 +39,7 @@ export function GameScreen() {
stats: { stats: {
res: row.left_res || 0, res: row.left_res || 0,
ent: row.left_ent || 0, ent: row.left_ent || 0,
pan: row.left_pan || 0,
rsk: row.left_rsk || 0, rsk: row.left_rsk || 0,
bud: row.left_bud || 0, bud: row.left_bud || 0,
per: row.left_per || 0, per: row.left_per || 0,
@@ -41,6 +52,7 @@ export function GameScreen() {
setParams(p => ({ setParams(p => ({
resistance: Math.max(0, p.resistance + (row.left_res || 0)), resistance: Math.max(0, p.resistance + (row.left_res || 0)),
entropy: Math.max(0, p.entropy + (row.left_ent || 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)), risk: Math.max(0, p.risk + (row.left_rsk || 0)),
})); }));
setTokens(t => ({ setTokens(t => ({
@@ -60,6 +72,7 @@ export function GameScreen() {
stats: { stats: {
res: row.right_res || 0, res: row.right_res || 0,
ent: row.right_ent || 0, ent: row.right_ent || 0,
pan: row.right_pan || 0,
rsk: row.right_rsk || 0, rsk: row.right_rsk || 0,
bud: row.right_bud || 0, bud: row.right_bud || 0,
per: row.right_per || 0, per: row.right_per || 0,
@@ -72,6 +85,7 @@ export function GameScreen() {
setParams(p => ({ setParams(p => ({
resistance: Math.max(0, p.resistance + (row.right_res || 0)), resistance: Math.max(0, p.resistance + (row.right_res || 0)),
entropy: Math.max(0, p.entropy + (row.right_ent || 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)), risk: Math.max(0, p.risk + (row.right_rsk || 0)),
})); }));
setTokens(t => ({ setTokens(t => ({
@@ -92,7 +106,7 @@ export function GameScreen() {
}, []); }, []);
useEffect(() => { 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); setGameOver(true);
} }
if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2); if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2);
@@ -102,13 +116,47 @@ export function GameScreen() {
const handleSwipe = (direction) => { const handleSwipe = (direction) => {
if (cards.length === 0) return; if (cards.length === 0) return;
const card = cards[currentCardIndex % cards.length]; const card = cards[currentCardIndex % cards.length];
let outcomeText = '';
if (direction === 'left' && card.left) { if (direction === 'left' && card.left) {
card.left.effect(); card.left.effect();
outcomeText = card.left.text;
} else if (direction === 'right' && card.right) { } else if (direction === 'right' && card.right) {
card.right.effect(); 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); setNarrativeTags(n => n + 1);
setCurrentCardIndex(idx => idx + 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) => { const renderDots = (value, max = 5) => {
@@ -119,11 +167,23 @@ export function GameScreen() {
if (gameOver) { if (gameOver) {
return ( return (
<div className="game-over"> <EndingScreen
<h1 className="glitch" data-text="시스템 붕괴">시스템 붕괴</h1> params={params}
<p>프로젝트가 파멸을 맞이했습니다. 임무 실패.</p> impeachment={impeachmentPassed}
<button className="restart-btn" onClick={() => window.location.reload()}>다음 회차 시작</button> narrativeTags={narrativeTags}
</div> 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}`}> <div className={`game-screen ${phaseClass}`}>
{/* Top Bar */} {/* Top Bar */}
<div className="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> <BookOpen size={16} /> <span>{narrativeTags}/8</span>
</div> </div>
<div className="parameters"> <div className="parameters">
@@ -149,6 +209,10 @@ export function GameScreen() {
<span className="icon">🌀</span> <span className="icon">🌀</span>
<div className="dots">{renderDots(params.entropy)}</div> <div className="dots">{renderDots(params.entropy)}</div>
</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)'}}> <div className={`param ${params.risk >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-risk-critical)'}}>
<span className="icon"></span> <span className="icon"></span>
<div className="dots">{renderDots(params.risk)}</div> <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 className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
</div> </div>
<div className="asset-slots"> <div className="asset-slots">
<div className="asset-slot"></div> {[0, 1, 2, 3].map(i => (
<div className="asset-slot"></div> <div key={i} className="asset-slot">
<div className="asset-slot"></div> {assets[i] ? <span className="asset-icon">{assets[i].icon}</span> : ''}
<div className="asset-slot"></div> </div>
))}
</div> </div>
</div> </div>
{showChronicle && (
<ChroniclePopup
chronicle={chronicle}
onClose={() => setShowChronicle(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -97,6 +97,13 @@
right: -20px; right: -20px;
} }
.up-intent {
color: var(--color-swipe-up);
top: -40px;
left: 50%;
transform: translateX(-50%);
}
.swipe-intent .intent-title { .swipe-intent .intent-title {
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
padding: 8px 16px; padding: 8px 16px;

View File

@@ -9,6 +9,7 @@ const renderStats = (stats) => {
if (stats.res !== 0) items.push({ icon: '✊', label: '저항', val: stats.res, isDanger: true }); if (stats.res !== 0) items.push({ icon: '✊', label: '저항', val: stats.res, isDanger: true });
if (stats.ent !== 0) items.push({ icon: '🌀', label: '엔트로피', val: stats.ent, isDanger: true }); if (stats.ent !== 0) items.push({ icon: '🌀', label: '엔트로피', val: stats.ent, isDanger: true });
if (stats.pan !== 0) items.push({ icon: '😱', label: '공황', val: stats.pan, isDanger: true });
if (stats.rsk !== 0) items.push({ icon: '⚠️', label: '위험', val: stats.rsk, isDanger: true }); if (stats.rsk !== 0) items.push({ icon: '⚠️', label: '위험', val: stats.rsk, isDanger: true });
if (stats.bud !== 0) items.push({ icon: <HandCoins size={14} />, label: '예산', val: stats.bud }); if (stats.bud !== 0) items.push({ icon: <HandCoins size={14} />, label: '예산', val: stats.bud });
@@ -41,6 +42,7 @@ const renderStats = (stats) => {
export default function SwipeCard({ card, onSwipe, phase }) { export default function SwipeCard({ card, onSwipe, phase }) {
const x = useMotionValue(0); const x = useMotionValue(0);
const y = useMotionValue(0);
const controls = useAnimation(); const controls = useAnimation();
// Dynamic friction based on phase // Dynamic friction based on phase
@@ -50,6 +52,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
const rotate = useTransform(x, [-200, 200], [-15 * friction, 15 * friction]); const rotate = useTransform(x, [-200, 200], [-15 * friction, 15 * friction]);
const leftOpacity = useTransform(x, [-100, -50, 0], [1, 0.5, 0]); const leftOpacity = useTransform(x, [-100, -50, 0], [1, 0.5, 0]);
const rightOpacity = useTransform(x, [0, 50, 100], [0, 0.5, 1]); const rightOpacity = useTransform(x, [0, 50, 100], [0, 0.5, 1]);
const upOpacity = useTransform(y, [-100, -50, 0], [1, 0.5, 0]);
const [exitX, setExitX] = useState(0); const [exitX, setExitX] = useState(0);
@@ -57,19 +60,21 @@ export default function SwipeCard({ card, onSwipe, phase }) {
useEffect(() => { useEffect(() => {
setExitX(0); setExitX(0);
x.set(0); x.set(0);
controls.start({ x: 0, opacity: 1, rotate: 0, transition: { duration: 0 } }); y.set(0);
}, [card, controls, x]); controls.start({ x: 0, y: 0, opacity: 1, rotate: 0, transition: { duration: 0 } });
}, [card, controls, x, y]);
const handleDragEnd = async (event, info) => { const handleDragEnd = async (event, info) => {
const threshold = 100; const threshold = 100;
if (info.offset.x > threshold) { if (info.offset.x > threshold) {
setExitX(300);
await controls.start({ x: 300, opacity: 0, transition: { duration: 0.3 } }); await controls.start({ x: 300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('right'); onSwipe('right');
} else if (info.offset.x < -threshold) { } else if (info.offset.x < -threshold) {
setExitX(-300);
await controls.start({ x: -300, opacity: 0, transition: { duration: 0.3 } }); await controls.start({ x: -300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('left'); onSwipe('left');
} else if (info.offset.y < -threshold && card.type === '극비') {
await controls.start({ y: -300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('up');
} else { } else {
// Snap back // Snap back
controls.start({ x: 0, y: 0, transition: { type: 'spring', stiffness: 300, damping: 20 } }); controls.start({ x: 0, y: 0, transition: { type: 'spring', stiffness: 300, damping: 20 } });
@@ -80,9 +85,9 @@ export default function SwipeCard({ card, onSwipe, phase }) {
<div className="card-container"> <div className="card-container">
<motion.div <motion.div
className="swipe-card" className="swipe-card"
drag="x" drag
dragConstraints={{ left: 0, right: 0 }} dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
style={{ x, rotate }} style={{ x, y, rotate }}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
animate={controls} animate={controls}
whileTap={{ scale: 0.98, cursor: 'grabbing' }} whileTap={{ scale: 0.98, cursor: 'grabbing' }}
@@ -110,6 +115,11 @@ export default function SwipeCard({ card, onSwipe, phase }) {
<span className="intent-title">{card.right?.text}</span> <span className="intent-title">{card.right?.text}</span>
{renderStats(card.right?.stats)} {renderStats(card.right?.stats)}
</motion.div> </motion.div>
{card.type === '극비' && (
<motion.div className="swipe-intent up-intent" style={{ opacity: upOpacity }}>
<span className="intent-title">보류 / 버리기</span>
</motion.div>
)}
</motion.div> </motion.div>
</div> </div>
); );

View File

@@ -1,32 +1,33 @@
id,type,text,flavor,left_text,left_res,left_ent,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_res,right_ent,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur id,type,text,flavor,left_text,left_res,left_ent,left_pan,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_res,right_ent,right_pan,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur
1,일상,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,1,0,0,0,-10,0,0,0,0,요구 수용,0,0,0,-20,0,0,0,0,0 1,일상,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,1,0,0,0,0,-10,0,0,0,0,요구 수용,0,0,0,0,-20,0,0,0,0,0
2,위기,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,0,0,0,0,-30,0,0,0,0,조사팀 파견,0,1,1,0,0,0,0,0,0 2,위기,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,0,0,0,0,0,-30,0,0,0,0,조사팀 파견,0,1,0,1,0,0,0,0,0,0
3,일상,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,0,0,1,0,0,-10,0,0,0,규정대로 반려,1,1,0,0,0,0,0,0,0 3,일상,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,0,0,0,1,0,0,-10,0,0,0,규정대로 반려,1,1,0,0,0,0,0,0,0,0
4,극비,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,0,2,0,40,40,0,0,0,0,제안 거절,1,0,0,0,0,0,0,0,0 4,극비,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,0,2,0,0,40,40,0,0,0,0,제안 거절,1,0,0,0,0,0,0,0,0,0
5,작전,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,0,0,2,0,0,0,0,2,0,작전 기각,1,0,0,0,0,0,0,0,0 5,작전,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,0,0,0,2,0,0,0,0,2,0,작전 기각,1,0,0,0,0,0,0,0,0,0
6,일상,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,0,0,1,30,0,0,0,0,0,원칙대로 소각,0,0,0,-10,0,0,0,0,0 6,일상,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,0,0,0,1,30,0,0,0,0,0,원칙대로 소각,0,0,0,0,-10,0,0,0,0,0
7,작전,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,0,0,0,0,0,-40,0,0,2,프로젝트 보류,1,0,0,0,0,0,0,0,0 7,작전,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,0,0,0,0,0,0,-40,0,0,2,프로젝트 보류,1,0,0,0,0,0,0,0,0,0
8,극비,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,1,0,1,0,0,0,0,0,0 8,극비,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,0,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,1,0,0,1,0,0,0,0,0,0
9,위기,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,1,0,0,0,-30,0,0,0,0,군사력으로 제압,0,0,1,0,0,-20,0,0,0 9,위기,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,1,0,0,0,0,-30,0,0,0,0,군사력으로 제압,0,0,0,1,0,0,-20,0,0,0
10,일상,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,0,0,-1,0,0,-20,0,0,0,영상만 삭제,0,1,0,0,0,0,0,0,0 10,일상,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,0,0,0,-1,0,0,-20,0,0,0,영상만 삭제,0,1,0,0,0,0,0,0,0,0
11,위기,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,1,0,0,0,-40,0,0,0,0,의식 방관,0,2,0,0,0,0,0,0,0 11,위기,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,1,0,1,0,0,-40,0,0,0,0,의식 방관,0,2,0,0,0,0,0,0,0,0
12,위기,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,0,0,2,0,0,0,0,0,0 12,위기,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,0,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,0,0,0,2,0,0,0,0,0,0
13,일상,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,-1,0,0,-30,0,0,0,0,0,요구 전면 묵살,1,0,0,0,0,0,0,0,0 13,일상,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,-1,0,0,0,-30,0,0,0,0,0,요구 전면 묵살,1,0,0,0,0,0,0,0,0,0
14,작전,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,0,1,0,50,0,0,0,0,0,폐쇄 상태 유지,1,0,0,0,0,0,0,0,0 14,작전,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,0,1,0,0,50,0,0,0,0,0,폐쇄 상태 유지,1,0,0,0,0,0,0,0,0,0
15,극비,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,0,0,0,20,-30,0,1,0,0,영구 봉인,0,0,0,0,0,0,0,0,0 15,극비,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,0,0,0,0,20,-30,0,1,0,0,영구 봉인,0,0,0,0,0,0,0,0,0,0
16,작전,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,0,0,0,0,0,-30,2,0,0,작전 취소,1,0,0,0,0,0,0,0,0 16,작전,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,0,0,0,0,0,0,-30,2,0,0,작전 취소,1,0,0,0,0,0,0,0,0,0
17,위기,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,0,-1,2,0,0,0,0,0,0,결사 방어 명령,0,0,0,-40,-40,0,0,0,0 17,위기,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,0,-1,0,2,0,0,0,0,0,0,결사 방어 명령,0,0,0,0,-40,-40,0,0,0,0
18,일상,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,0,0,1,-20,0,0,0,0,0,파업 강경 대응,1,0,0,0,0,0,0,0,0 18,일상,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,0,0,0,1,-20,0,0,0,0,0,파업 강경 대응,1,0,0,0,0,0,0,0,0,0
19,극비,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,0,1,0,0,0,20,0,1,0,즉각 폐기 처분,1,0,0,-10,0,0,0,0,0 19,극비,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,0,1,0,0,0,0,20,0,1,0,즉각 폐기 처분,1,0,0,0,-10,0,0,0,0,0
20,일상,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,2,0,0,0,0,30,0,0,0,증거 부족으로 덮음,0,0,1,0,0,0,0,0,0 20,일상,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,2,0,0,0,0,0,30,0,0,0,증거 부족으로 덮음,0,0,0,1,0,0,0,0,0,0
21,작전,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,1,0,1,0,0,0,0,0,0 21,작전,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,1,0,0,1,0,0,0,0,0,0
22,극비,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,0,1,0,0,0,50,0,0,0,유물 파괴 명령,1,0,1,0,0,0,0,0,0 22,극비,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,0,1,0,0,0,0,50,0,0,0,유물 파괴 명령,1,0,0,1,0,0,0,0,0,0
23,위기,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,2,0,0,0,0,0,0,0,2,진정제 무상 배포,0,0,0,-50,0,0,0,0,0 23,위기,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,2,0,1,0,0,0,0,0,0,2,진정제 무상 배포,0,0,0,0,-50,0,0,0,0,0
24,일상,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,0,1,1,0,0,0,0,0,0 24,일상,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,0,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,0,1,0,1,0,0,0,0,0,0
25,극비,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,0,0,2,0,0,0,0,0,0 25,극비,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,0,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,0,0,0,2,0,0,0,0,0,0
26,위기,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,0,2,0,0,0,0,0,0,0 26,위기,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,0,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,0,2,0,0,0,0,0,0,0,0
27,작전,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,1,0,0,0,0,0,0,0,0 27,작전,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,0,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,1,0,0,0,0,0,0,0,0,0
28,작전,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,0,1,0,-50,0,0,0,0,0,윤리적 이유로 기각,0,0,1,0,0,0,0,0,0 28,작전,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,0,1,0,0,-50,0,0,0,0,0,윤리적 이유로 기각,0,0,0,1,0,0,0,0,0,0
29,극비,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,0,1,1,0,0,0,0,0,0 29,극비,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,0,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,0,1,0,1,0,0,0,0,0,0
30,일상,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,-1,0,0,-10,0,0,0,0,0,헛소리 금지령,1,0,0,0,0,0,0,0,0 30,일상,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,-1,0,0,0,-10,0,0,0,0,0,헛소리 금지령,1,0,0,0,0,0,0,0,0,0
31,위기,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,0,2,0,0,0,-50,0,0,0,모든 예비병력 투입,0,0,1,0,-60,0,0,0,0 31,위기,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,0,0,0,1,0,-60,0,0,0,0
,,,,,,,,,,,,,,,,,,,,,,,,,
1 id type text flavor left_text left_res left_ent left_pan left_rsk left_bud left_per left_inf left_wea left_for left_sur right_text right_res right_ent right_pan right_rsk right_bud right_per right_inf right_wea right_for right_sur
2 1 일상 B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다. 우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다! 무력 진압 1 0 0 0 0 -10 0 0 0 0 요구 수용 0 0 0 0 -20 0 0 0 0 0
3 2 위기 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 네이팜 소각 강행 0 0 0 0 0 -30 0 0 0 0 조사팀 파견 0 1 0 1 0 0 0 0 0 0
4 3 일상 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 내역 위조 후 출동 0 0 0 1 0 0 -10 0 0 0 규정대로 반려 1 1 0 0 0 0 0 0 0 0
5 4 극비 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 실험 승인 0 2 0 0 40 40 0 0 0 0 제안 거절 1 0 0 0 0 0 0 0 0 0
6 5 작전 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 궤도 폭격 승인 0 0 0 2 0 0 0 0 2 0 작전 기각 1 0 0 0 0 0 0 0 0 0
7 6 일상 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 매각 허가 0 0 0 1 30 0 0 0 0 0 원칙대로 소각 0 0 0 0 -10 0 0 0 0 0
8 7 작전 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 감시망 구축 0 0 0 0 0 0 -40 0 0 2 프로젝트 보류 1 0 0 0 0 0 0 0 0 0
9 8 극비 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 예산 배정 0 0 0 0 -40 0 40 0 0 0 예산 전액 삭감 1 0 0 1 0 0 0 0 0 0
10 9 위기 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 적당히 묵인한다 1 0 0 0 0 -30 0 0 0 0 군사력으로 제압 0 0 0 1 0 0 -20 0 0 0
11 10 일상 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 제거 절차 승인 0 0 0 -1 0 0 -20 0 0 0 영상만 삭제 0 1 0 0 0 0 0 0 0 0
12 11 위기 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 군사 개입 지시 1 0 1 0 0 -40 0 0 0 0 의식 방관 0 2 0 0 0 0 0 0 0 0
13 12 위기 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 은폐 공작 실행 0 0 0 1 0 0 -40 0 0 0 횡령 사실 인정 0 0 0 2 0 0 0 0 0 0
14 13 일상 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 특별 보너스 지급 -1 0 0 0 -30 0 0 0 0 0 요구 전면 묵살 1 0 0 0 0 0 0 0 0 0
15 14 작전 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 게이트 강제 개방 0 1 0 0 50 0 0 0 0 0 폐쇄 상태 유지 1 0 0 0 0 0 0 0 0 0
16 15 극비 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 강제 개방 지시 0 0 0 0 20 -30 0 1 0 0 영구 봉인 0 0 0 0 0 0 0 0 0 0
17 16 작전 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 작전 실행 0 0 0 0 0 0 -30 2 0 0 작전 취소 1 0 0 0 0 0 0 0 0 0
18 17 위기 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 시민 포기 및 폭격 0 -1 0 2 0 0 0 0 0 0 결사 방어 명령 0 0 0 0 -40 -40 0 0 0 0
19 18 일상 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 예산 전용으로 무마 0 0 0 1 -20 0 0 0 0 0 파업 강경 대응 1 0 0 0 0 0 0 0 0 0
20 19 극비 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 실전 즉각 배치 0 1 0 0 0 0 20 0 1 0 즉각 폐기 처분 1 0 0 0 -10 0 0 0 0 0
21 20 일상 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 대규모 사찰 시작 2 0 0 0 0 0 30 0 0 0 증거 부족으로 덮음 0 0 0 1 0 0 0 0 0 0
22 21 작전 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 수용소 건립 승인 0 0 0 0 -40 0 0 0 1 0 제안 단호히 거절 1 0 0 1 0 0 0 0 0 0
23 22 극비 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 유물 집중 연구 0 1 0 0 0 0 50 0 0 0 유물 파괴 명령 1 0 0 1 0 0 0 0 0 0
24 23 위기 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 인터넷 전면 차단 2 0 1 0 0 0 0 0 0 2 진정제 무상 배포 0 0 0 0 -50 0 0 0 0 0
25 24 일상 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 정신과 치료 지원 0 0 0 0 -20 10 0 0 0 0 트라우마 기억 소거 0 1 0 1 0 0 0 0 0 0
26 25 극비 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 사고사 위장 지시 0 0 0 -1 0 0 -30 0 0 0 폭로전 정면 돌파 0 0 0 2 0 0 0 0 0 0
27 26 위기 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 특수 해체팀 투입 0 0 0 0 0 -40 0 0 0 0 폭발 방관 및 대피 0 2 0 0 0 0 0 0 0 0
28 27 작전 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 결탁 및 흡수 합병 0 0 0 0 -40 0 0 0 2 0 독자적 노선 유지 1 0 0 0 0 0 0 0 0 0
29 28 작전 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 돔 건설 강행 0 1 0 0 -50 0 0 0 0 0 윤리적 이유로 기각 0 0 0 1 0 0 0 0 0 0
30 29 극비 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 거래 즉시 성사 0 0 0 0 0 -40 0 0 2 0 사절단 전원 사살 0 1 0 1 0 0 0 0 0 0
31 30 일상 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 최고급 원두 지원 -1 0 0 0 -10 0 0 0 0 0 헛소리 금지령 1 0 0 0 0 0 0 0 0 0
32 31 위기 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 현실 조작기 가동 0 2 0 0 0 0 -50 0 0 0 모든 예비병력 투입 0 0 0 1 0 -60 0 0 0 0
33

View File

@@ -8,6 +8,9 @@
--color-entropy-base: #5C3A21; --color-entropy-base: #5C3A21;
--color-entropy-critical: #8B0000; --color-entropy-critical: #8B0000;
--color-panic-base: #4A148C;
--color-panic-critical: #9C27B0;
--color-resistance-base: #2F4F4F; --color-resistance-base: #2F4F4F;
--color-resistance-critical: #00FFCC; --color-resistance-critical: #00FFCC;

27
client/update_csv.js Normal file
View File

@@ -0,0 +1,27 @@
import fs from 'fs';
import Papa from 'papaparse';
const csv = fs.readFileSync('./src/data/cards.csv', 'utf-8');
const parsed = Papa.parse(csv, { header: true });
parsed.data.forEach(row => {
if (!row.id) return;
row.left_pan = 0;
row.right_pan = 0;
// Let's add panic to some rows for testing
if (row.id == 23 || row.id == 11) {
row.left_pan = 1;
row.right_pan = 0;
}
});
const newCsv = Papa.unparse(parsed.data, {
columns: [
'id','type','text','flavor',
'left_text','left_res','left_ent','left_pan','left_rsk','left_bud','left_per','left_inf','left_wea','left_for','left_sur',
'right_text','right_res','right_ent','right_pan','right_rsk','right_bud','right_per','right_inf','right_wea','right_for','right_sur'
]
});
fs.writeFileSync('./src/data/cards.csv', newCsv);
console.log('CSV updated');