Merge branch 'main' of https://git.mint-grape.duckdns.org/kyoung5/ProjectSS
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user