This commit is contained in:
kyoung5seo
2026-07-09 23:20:31 +09:00
20 changed files with 781 additions and 271 deletions
+93 -46
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../data/cards.csv?raw';
import narrativeTagsData from '../data/narrative_tags.json';
import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
import SwipeCard from './SwipeCard';
import CouncilScreen from './CouncilScreen';
@@ -38,9 +39,10 @@ const pickGrade = (level) => {
return 'C';
};
// 내러티브 키워드 누적치에 따른 페이즈 전환 문턱 (기존 4/8은 실제 플레이에서 너무 길게 느껴져 하향 조정)
const PHASE2_TAG_THRESHOLD = 2;
const PHASE3_TAG_THRESHOLD = 4;
// 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
// 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
const PHASE2_TAG_THRESHOLD = 3;
const PHASE3_TAG_THRESHOLD = 6;
const ASSET_META = {
transform: { icon: '🧪', label: '성질 변환' },
@@ -58,7 +60,7 @@ const GLOBAL_FACTIONS = [
{ id: 'spindoctor', name: '여론 통제국', computeAffinity: (c) => c.tags.surveillance * 18 - 30 },
{ id: 'union', name: '현장 요원 노조', computeAffinity: (c) => (2 - c.params.resistance) * 30 },
{ id: 'risk', name: '리스크 관리반', computeAffinity: (c) => (2 - c.params.risk) * 30 },
{ id: 'occult', name: '오컬트 수집가 협회', computeAffinity: (c) => c.mythicCount * 35 + c.narrativeTags * 3 - 20 },
{ id: 'occult', name: '오컬트 수집가 협회', computeAffinity: (c) => c.mythicCount * 35 + c.tagCount * 3 - 20 },
{ id: 'blackops', name: '극비 프로젝트 연구소', computeAffinity: (c) => c.assetsAcquiredCount * 25 - 30 },
{ id: 'scavenger', name: '잔해 청소부 길드', computeAffinity: (c) => c.purgeCount * 35 - 20 },
{ id: 'entropy', name: '엔트로피 관측소', computeAffinity: (c) => (2 - c.params.entropy) * 30 },
@@ -78,13 +80,15 @@ const TIER_IMMUNE = new Set(['동맹', '파국']);
const buildCardFromRow = (row) => ({
id: row.id,
type: row.type,
keyword: row.keyword,
question: row.question,
text: row.text,
flavor: row.flavor,
grade: row.grade || null,
contaminated: CONTAM_TYPES.has(row.type),
left: {
text: row.left_text,
narrativeTag: row.left_narrative || null,
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
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, inf: row.left_inf || 0,
@@ -93,7 +97,7 @@ const buildCardFromRow = (row) => ({
},
right: {
text: row.right_text,
narrativeTag: row.right_narrative || null,
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
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, inf: row.right_inf || 0,
@@ -117,8 +121,8 @@ export function GameScreen() {
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);
const [keywords, setKeywords] = useState([]);
const [acquiredTags, setAcquiredTags] = useState([]);
const [newTagNotification, setNewTagNotification] = useState(null);
const [phase, setPhase] = useState(1);
const [gameOver, setGameOver] = useState(false);
const [cards, setCards] = useState([]);
@@ -133,9 +137,13 @@ export function GameScreen() {
const [showChronicle, setShowChronicle] = useState(false);
const [assets, setAssets] = useState([]);
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
const [roundCount, setRoundCount] = useState(1);
const [approvalsRemaining, setApprovalsRemaining] = useState(10);
// 파산 문책 시스템
const [bankruptcyCounter, setBankruptcyCounter] = useState(0);
const [bankruptcyWarned, setBankruptcyWarned] = useState(false);
const [bankruptcyCount, setBankruptcyCount] = useState(0);
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
// 자산 효과 상태
const [mulliganShields, setMulliganShields] = useState(0);
@@ -179,9 +187,9 @@ export function GameScreen() {
setChairmanAxis(CHAIRMAN_AXES[Math.floor(Math.random() * CHAIRMAN_AXES.length)]);
}, []);
// 내러티브 키워드가 PHASE2_TAG_THRESHOLD개 도달 시 페이즈2 카드 풀(작전/극비) 개방 — 스토리 페이징 카드 풀 전환
// 내러티브 태그가 PHASE2_TAG_THRESHOLD종 도달 시 페이즈2 카드 풀(작전/극비) 개방
useEffect(() => {
if (phase2Unlocked || phase2Pool.length === 0 || narrativeTags < PHASE2_TAG_THRESHOLD) return;
if (phase2Unlocked || phase2Pool.length === 0 || acquiredTags.length < PHASE2_TAG_THRESHOLD) return;
setPhase2Unlocked(true);
setCards(prev => {
const shuffledPool = [...phase2Pool].sort(() => Math.random() - 0.5);
@@ -190,12 +198,12 @@ export function GameScreen() {
next.splice(insertAt, 0, ...shuffledPool);
return next;
});
setChronicle(prev => [...prev, { turn: turns, text: '[페이징] 페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.' }]);
}, [narrativeTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
setChronicle(prev => [...prev, { turn: turns, cardType: '페이징', keyword: null, text: '페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.', decision: '', tag: null }]);
}, [acquiredTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
useEffect(() => {
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) {
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
const ending = resolveEnding({
trigger: 'collapse', params, tags, tokens,
lastDelta: lastParamDelta, narrativeChainUnlocked,
@@ -203,9 +211,9 @@ export function GameScreen() {
setFinalEnding(ending);
setGameOver(true);
}
if (narrativeTags >= PHASE2_TAG_THRESHOLD && narrativeTags < PHASE3_TAG_THRESHOLD) setPhase(2);
else if (narrativeTags >= PHASE3_TAG_THRESHOLD) setPhase(3);
}, [params, narrativeTags]);
if (acquiredTags.length >= PHASE2_TAG_THRESHOLD && acquiredTags.length < PHASE3_TAG_THRESHOLD) setPhase(2);
else if (acquiredTags.length >= PHASE3_TAG_THRESHOLD) setPhase(3);
}, [params, acquiredTags]);
// 시한부 격리된 오염 카드 복귀
useEffect(() => {
@@ -214,7 +222,7 @@ export function GameScreen() {
if (ready.length > 0) {
setCards(prev => [...prev, ...ready.map(({ returnTurn, ...rest }) => rest)]);
setQuarantineZone(prev => prev.filter(c => c.returnTurn > turns));
setChronicle(prev => [...prev, { turn: turns, text: `[자산] 격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.` }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.`, decision: '', tag: null }]);
}
}, [turns]);
@@ -244,6 +252,8 @@ export function GameScreen() {
let outcomeText = '';
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
let isNewTag = false;
let gainedTag = null;
if (side) {
const shouldNullify = card.contaminated && mulliganShields > 0;
@@ -255,7 +265,7 @@ export function GameScreen() {
const nextPersonnel = tokens.personnel + side.stats.per;
const nextInfo = tokens.info + side.stats.inf;
if (nextBudget < 0 || nextPersonnel < 0 || nextInfo < 0) {
setBankruptcyCounter(c => c + 1);
setBankruptcyCount(c => c + 1);
}
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
@@ -268,10 +278,13 @@ export function GameScreen() {
if (maxParam) setLastParamDelta({ param: maxParam, amount: maxVal });
}
// 내러티브 키워드: 모든 카드가 아니라 선택된 일부 카드/선택지만 키워드를 부여한다
if (side.narrativeTag) {
setKeywords(prev => [...prev, { turn: turns + 1, word: side.narrativeTag }]);
setNarrativeTags(n => n + 1);
// 내러티브 태그: 재사용 가능한 태그 풀에서 처음 획득하는 태그일 때만 진행도에 반영
gainedTag = side.tag;
if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) {
isNewTag = true;
setAcquiredTags(prev => [...prev, gainedTag]);
setNewTagNotification(gainedTag);
setTimeout(() => setNewTagNotification(null), 3000);
}
// 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적
@@ -280,7 +293,6 @@ export function GameScreen() {
setContaminationSequence(nextSequence);
if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) {
setNarrativeChainUnlocked(true);
setChronicle(prev => [...prev, { turn: turns + 1, text: '[내러티브] 사건들의 연쇄가 완성되었습니다 - 진실에 가까워졌습니다.' }]);
}
}
@@ -293,7 +305,10 @@ export function GameScreen() {
}
if (outcomeText) {
setChronicle(prev => [...prev, { turn: turns + 1, text: `[${card.type}] ${card.text} -> 결정: ${outcomeText}` }]);
setChronicle(prev => [...prev, {
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
decision: outcomeText, tag: isNewTag ? gainedTag : null,
}]);
}
// 극비 프로젝트 성공 시 자산 획득 (4종 중 랜덤)
@@ -307,11 +322,11 @@ export function GameScreen() {
}
setCurrentCardIndex(idx => idx + 1);
setTurns(t => t + 1);
const newTurns = turns + 1;
setTurns(newTurns);
if (newTurns > 0 && newTurns % 10 === 0) {
const newRemaining = approvalsRemaining - 1;
setApprovalsRemaining(newRemaining);
if (newRemaining <= 0) {
setShowCouncil(true);
}
};
@@ -336,21 +351,19 @@ export function GameScreen() {
next.splice(insertAt, 0, ...newCards);
return next;
});
setChronicle(prev => [...prev, { turn: turns, text: `[오염] 의회 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.` }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
}
};
const handleCouncilResolve = (result) => {
setShowCouncil(false);
if (result.bankruptcy) {
if (result.bankrupted) {
setFinalEnding(getEndingById('bankruptcy'));
setGameOver(true);
return;
}
setBankruptcyWarned(result.bankruptcyWarning);
if (result.impeachment) {
const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked });
setFinalEnding(ending);
@@ -358,7 +371,28 @@ export function GameScreen() {
return;
}
if (typeof result.bankruptcyWarning === 'boolean') {
setBankruptcyWarning(result.bankruptcyWarning);
}
injectContaminationCards();
// 라운드 진행: 라운드가 증가할수록 다음 정례회의까지의 결재 건수가 줄어든다
const nextRound = roundCount + 1;
let nextApprovals;
if (roundCount === 1) nextApprovals = 8;
else if (roundCount === 2) nextApprovals = 6;
else if (roundCount === 3) nextApprovals = 4;
else if (roundCount === 4) nextApprovals = 2;
else nextApprovals = 0;
setRoundCount(nextRound);
setApprovalsRemaining(nextApprovals);
if (nextApprovals <= 0) {
setFinalEnding(getEndingById('term_expired'));
setGameOver(true);
}
};
// 자산 효과 4종 발동
@@ -366,7 +400,7 @@ export function GameScreen() {
if (asset.type === 'mulligan') {
setMulliganShields(s => s + 1);
setAssets(prev => prev.filter(a => a.id !== asset.id));
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.' }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.', decision: '', tag: null }]);
return;
}
@@ -374,7 +408,7 @@ export function GameScreen() {
cards.forEach((c, i) => { if (c.contaminated) contaminatedIdx.push(i); });
if (contaminatedIdx.length === 0) {
setChronicle(prev => [...prev, { turn: turns, text: `[자산] ${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.` }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.`, decision: '', tag: null }]);
setAssets(prev => prev.filter(a => a.id !== asset.id));
return;
}
@@ -383,17 +417,17 @@ export function GameScreen() {
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
setCards(prev => prev.filter((_, i) => i !== target));
setPurgeCount(c => c + 1);
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 오염 카드를 물리적으로 영구 소각했습니다.' }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드를 물리적으로 영구 소각했습니다.', decision: '', tag: null }]);
} else if (asset.type === 'quarantine') {
const targetSet = new Set(contaminatedIdx.slice(0, 2));
const quarantined = cards.filter((c, i) => targetSet.has(i));
setCards(prev => prev.filter((_, i) => !targetSet.has(i)));
setQuarantineZone(prev => [...prev, ...quarantined.map(c => ({ ...c, returnTurn: turns + 5 }))]);
setChronicle(prev => [...prev, { turn: turns, text: `[자산] 오염 카드 ${quarantined.length}건을 시한부 격리했습니다. (5턴 후 덱으로 복귀)` }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `오염 카드 ${quarantined.length}건을 시한부 격리했습니다. (5턴 후 덱으로 복귀)`, decision: '', tag: null }]);
} else if (asset.type === 'transform') {
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
setCards(prev => prev.map((c, i) => (i === target ? transformCard(c) : c)));
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)' }]);
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)', decision: '', tag: null }]);
}
setAssets(prev => prev.filter(a => a.id !== asset.id));
@@ -409,8 +443,8 @@ export function GameScreen() {
return (
<EndingScreen
ending={finalEnding}
narrativeTags={narrativeTags}
keywords={keywords}
narrativeTags={acquiredTags.length}
keywords={acquiredTags.map(t => ({ word: t.name }))}
onRestart={() => window.location.reload()}
/>
);
@@ -422,7 +456,7 @@ export function GameScreen() {
tags, tokens, params,
assetsCount: assets.length,
mythicCount: cards.filter(c => c.type === '신화').length,
narrativeTags, assetsAcquiredCount, purgeCount, bribeCount,
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
};
const globalFactions = factionRoster.map(f => {
const score = f.computeAffinity(affinityCtx);
@@ -435,8 +469,8 @@ export function GameScreen() {
params={params} setParams={setParams}
tokens={tokens} setTokens={setTokens}
tags={tags} setTags={setTags}
bankruptcyCounter={bankruptcyCounter}
bankruptcyWarned={bankruptcyWarned}
bankruptcyCount={bankruptcyCount}
bankruptcyWarning={bankruptcyWarning}
globalFactions={globalFactions}
onBribe={() => setBribeCount(c => c + 1)}
onResolve={handleCouncilResolve}
@@ -456,7 +490,10 @@ export function GameScreen() {
{/* Top Bar */}
<div className="top-bar">
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
<BookOpen size={16} /> <span>{narrativeTags}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
<BookOpen size={16} /> <span>{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
</div>
<div className="round-info">
제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건
</div>
<div className="parameters">
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
@@ -518,10 +555,20 @@ export function GameScreen() {
</div>
</div>
{newTagNotification && (
<div className="tag-notification">
<div className="tag-noti-icon">{newTagNotification.icon}</div>
<div className="tag-noti-info">
<h4>새로운 내러티브 획득!</h4>
<p>[{newTagNotification.name}]</p>
</div>
</div>
)}
{showChronicle && (
<ChroniclePopup
chronicle={chronicle}
keywords={keywords}
acquiredTags={acquiredTags}
onClose={() => setShowChronicle(false)}
/>
)}