Phase 2~4 그레이박스 시스템 완성 및 내러티브 태그 재설계
1. Phase 2 완료 — 자원 경제 & 덱빌딩
- 파산 문책 시스템: 토큰 마이너스 상태로 결재 시 파산 카운터 증가,
의회에서 단계별 경고 → 미개선 시 다음 의회에서 해임 게임오버
- 오염 카드 등급 확률: 엔트로피/내부저항/대중공황/위험계수 단계에 비례한
S/A/B/C 가중치로 의회 종료 시 오염 카드 자동 주입
(신화/조직저항/사회공황/이사회압박 16종 카드 신규 추가)
- 자산 효과 4종 구현: 성질 변환·시한부 격리·물리적 영구 소각·결재권 강제 행사
(자산 슬롯 클릭으로 발동)
2. Phase 3 완료 — 정치 시스템 & 스토리 페이징
- 글로벌 지부장 12개 파벌이 각자 다른 공식으로 우호도를 계산하는 엔진 추가.
게임 시작 시 5명 무작위 선발, 동맹/파국 성향 임원은 국장의 개입(매수/감사)에 면역
- 내러티브 태그 누적치에 따라 페이즈1(일상/위기) → 페이즈2(작전/극비) 카드 풀이
실시간으로 전환되도록 구현
- 버그 수정: 위험 수용 계수(risk)가 직접 게임오버를 발동시키지만 EndingScreen에
대응 엔딩이 없어 "미확인 종말" 빈 폴백으로 빠지던 문제 (risk는 탄핵 기준치로만 사용하도록 수정)
- 버그 수정: CouncilScreen의 임원 초기화 effect가 매수 액션의 risk 상승으로
재실행되며 방금 강제 전환한 투표 결과를 덮어쓰던 문제 (마운트 시 1회만 초기화하도록 수정)
3. Phase 4 완료 — 멀티 엔딩 & 연대기
- 우선순위 기반 엔딩 판별기(endings.js) 신규: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 >
변주된 붕괴 엔딩(자원 태그 3종 + 토큰 3종) > 기본 붕괴 엔딩, 총 15종 엔딩 카탈로그
- 극단적 파라미터 조기 엔딩: 단일 결재로 파라미터가 급격히 5단계에 도달하면
"돌이킬 수 없는 순간" 즉시 발동
- 엔딩 도감(EndingCodex) 신규 컴포넌트: 해금/미해금(???) 표시,
localStorage(ss_endings_v2)에 엔딩 고유 id 기준으로 영구 저장
- 연대기 팝업에 타임라인(세로선 + 턴별 점) 시각화 추가
4. 내러티브 태그 시스템 재설계
- 기존에는 결재 카드 한 장당 무조건 +1 되던 카운터였으나, "항상 얻는 보상이 아니다"라는
기획 의도에 맞춰 선별된 카드(22/63장)의 좌/우 선택지에만 짧은 키워드를 부여하도록 변경
(cards.csv에 left_narrative/right_narrative 컬럼 추가)
- 연대기 팝업 상단과 엔딩 화면에 수집한 키워드를 칩(chip) 형태로 표시
- 위 재설계로 태그 획득이 희소해진 만큼, 페이즈 전환 문턱을 4/8 → 2/4로 하향 조정
5. 기타
- 로컬 Node.js LTS 설치 및 Playwright 기반 실기 플레이 검증 체계 확립
- ROADMAP.md/CLAUDE.md Phase 상태 갱신 (Phase 0~4 그레이박스 구간 완료,
다음은 외부 플레이어 그레이박스 테스트)
This commit is contained in:
@@ -6,125 +6,288 @@ import SwipeCard from './SwipeCard';
|
||||
import CouncilScreen from './CouncilScreen';
|
||||
import EndingScreen from './EndingScreen';
|
||||
import ChroniclePopup from './ChroniclePopup';
|
||||
import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings';
|
||||
import './GameScreen.css';
|
||||
|
||||
// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
|
||||
const CONTAM_TYPE_BY_PARAM = {
|
||||
entropy: '신화',
|
||||
resistance: '조직저항',
|
||||
panic: '사회공황',
|
||||
risk: '이사회압박',
|
||||
};
|
||||
const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
|
||||
|
||||
// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
|
||||
const GRADE_WEIGHTS = {
|
||||
1: { C: 70, B: 22, A: 6, S: 2 },
|
||||
2: { C: 50, B: 28, A: 15, S: 7 },
|
||||
3: { C: 32, B: 30, A: 23, S: 15 },
|
||||
4: { C: 18, B: 24, A: 32, S: 26 },
|
||||
5: { C: 8, B: 17, A: 33, S: 42 },
|
||||
};
|
||||
|
||||
const pickGrade = (level) => {
|
||||
const weights = GRADE_WEIGHTS[Math.min(5, Math.max(1, level))];
|
||||
const total = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (const [grade, w] of Object.entries(weights)) {
|
||||
if (r < w) return grade;
|
||||
r -= w;
|
||||
}
|
||||
return 'C';
|
||||
};
|
||||
|
||||
// 내러티브 키워드 누적치에 따른 페이즈 전환 문턱 (기존 4/8은 실제 플레이에서 너무 길게 느껴져 하향 조정)
|
||||
const PHASE2_TAG_THRESHOLD = 2;
|
||||
const PHASE3_TAG_THRESHOLD = 4;
|
||||
|
||||
const ASSET_META = {
|
||||
transform: { icon: '🧪', label: '성질 변환' },
|
||||
quarantine: { icon: '⏳', label: '시한부 격리' },
|
||||
purge: { icon: '🔥', label: '물리적 영구 소각' },
|
||||
mulligan: { icon: '🛡️', label: '결재권 강제 행사' },
|
||||
};
|
||||
|
||||
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
|
||||
const GLOBAL_FACTIONS = [
|
||||
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
|
||||
{ id: 'blackmarket', name: '블랙마켓 카르텔', computeAffinity: (c) => c.tokens.budget * 0.4 + c.assetsCount * 20 - 40 },
|
||||
{ id: 'databroker', name: '정보 브로커 연맹', computeAffinity: (c) => c.tokens.info * 0.4 + c.tags.surveillance * 15 - 40 },
|
||||
{ id: 'infra', name: '인프라 재건 위원회', computeAffinity: (c) => c.tags.wealth * 18 - 30 },
|
||||
{ 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: '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 },
|
||||
{ id: 'corrupt', name: '부패 관료 연합', computeAffinity: (c) => c.bribeCount * 40 - 10 },
|
||||
];
|
||||
|
||||
const getFactionTier = (score) => {
|
||||
if (score >= 60) return '동맹';
|
||||
if (score >= 20) return '우호';
|
||||
if (score > -20) return '중립';
|
||||
if (score > -60) return '적대';
|
||||
return '파국';
|
||||
};
|
||||
const TIER_VOTE = { 동맹: '찬성', 우호: '찬성', 중립: '미정', 적대: '반대', 파국: '반대' };
|
||||
const TIER_IMMUNE = new Set(['동맹', '파국']);
|
||||
|
||||
const buildCardFromRow = (row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
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,
|
||||
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,
|
||||
wea: row.left_wea || 0, for: row.left_for || 0, sur: row.left_sur || 0,
|
||||
},
|
||||
},
|
||||
right: {
|
||||
text: row.right_text,
|
||||
narrativeTag: row.right_narrative || 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,
|
||||
wea: row.right_wea || 0, for: row.right_for || 0, sur: row.right_sur || 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformCard = (card) => {
|
||||
const flip = (stats) => ({ ...stats, res: -stats.res, ent: -stats.ent, pan: -stats.pan, rsk: -stats.rsk });
|
||||
return {
|
||||
...card,
|
||||
transformed: true,
|
||||
text: card.transformed ? card.text : `${card.text} (순화됨)`,
|
||||
left: { ...card.left, stats: flip(card.left.stats) },
|
||||
right: { ...card.right, stats: flip(card.right.stats) },
|
||||
};
|
||||
};
|
||||
|
||||
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 [phase, setPhase] = useState(1);
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [contaminationTemplates, setContaminationTemplates] = useState([]);
|
||||
const [phase2Pool, setPhase2Pool] = useState([]);
|
||||
const [phase2Unlocked, setPhase2Unlocked] = useState(false);
|
||||
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
|
||||
// 파산 문책 시스템
|
||||
const [bankruptcyCounter, setBankruptcyCounter] = useState(0);
|
||||
const [bankruptcyWarned, setBankruptcyWarned] = useState(false);
|
||||
|
||||
// 자산 효과 상태
|
||||
const [mulliganShields, setMulliganShields] = useState(0);
|
||||
const [quarantineZone, setQuarantineZone] = useState([]);
|
||||
const [assetsAcquiredCount, setAssetsAcquiredCount] = useState(0);
|
||||
const [purgeCount, setPurgeCount] = useState(0);
|
||||
|
||||
// 글로벌 지부장 파벌 (게임 시작 시 12개 중 5개 무작위 선발)
|
||||
const [factionRoster, setFactionRoster] = useState([]);
|
||||
const [bribeCount, setBribeCount] = useState(0);
|
||||
|
||||
// 멀티 엔딩 판별기 상태
|
||||
const [finalEnding, setFinalEnding] = useState(null);
|
||||
const [chairmanAxis, setChairmanAxis] = useState(null);
|
||||
const [lastParamDelta, setLastParamDelta] = useState(null);
|
||||
const [contaminationSequence, setContaminationSequence] = useState([]);
|
||||
const [narrativeChainUnlocked, setNarrativeChainUnlocked] = useState(false);
|
||||
|
||||
// CSV Load Effect — 페이즈1(일상/위기) / 페이즈2(작전/극비) 카드 풀 분리
|
||||
useEffect(() => {
|
||||
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
|
||||
|
||||
const formattedCards = parsed.data.map(row => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
text: row.text,
|
||||
flavor: row.flavor,
|
||||
left: {
|
||||
text: row.left_text,
|
||||
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,
|
||||
wea: row.left_wea || 0,
|
||||
for: row.left_for || 0,
|
||||
sur: row.left_sur || 0,
|
||||
},
|
||||
effect: () => {
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (row.left_res || 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)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
budget: t.budget + (row.left_bud || 0),
|
||||
personnel: t.personnel + (row.left_per || 0),
|
||||
info: t.info + (row.left_inf || 0),
|
||||
}));
|
||||
setTags(tg => ({
|
||||
wealth: tg.wealth + (row.left_wea || 0),
|
||||
force: tg.force + (row.left_for || 0),
|
||||
surveillance: tg.surveillance + (row.left_sur || 0),
|
||||
}));
|
||||
}
|
||||
},
|
||||
right: {
|
||||
text: row.right_text,
|
||||
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,
|
||||
wea: row.right_wea || 0,
|
||||
for: row.right_for || 0,
|
||||
sur: row.right_sur || 0,
|
||||
},
|
||||
effect: () => {
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (row.right_res || 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)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
budget: t.budget + (row.right_bud || 0),
|
||||
personnel: t.personnel + (row.right_per || 0),
|
||||
info: t.info + (row.right_inf || 0),
|
||||
}));
|
||||
setTags(tg => ({
|
||||
wealth: tg.wealth + (row.right_wea || 0),
|
||||
force: tg.force + (row.right_for || 0),
|
||||
surveillance: tg.surveillance + (row.right_sur || 0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
setCards(formattedCards.sort(() => Math.random() - 0.5));
|
||||
const rows = parsed.data.filter(row => row.id);
|
||||
|
||||
const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기');
|
||||
const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비');
|
||||
const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow);
|
||||
|
||||
setCards(phase1Rows.map(buildCardFromRow).sort(() => Math.random() - 0.5));
|
||||
setPhase2Pool(phase2Rows.map(buildCardFromRow));
|
||||
setContaminationTemplates(templates);
|
||||
}, []);
|
||||
|
||||
// 글로벌 지부장 5명 선발 (게임 시작 시 1회)
|
||||
useEffect(() => {
|
||||
if (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5 || params.risk >= 5) {
|
||||
const shuffled = [...GLOBAL_FACTIONS].sort(() => Math.random() - 0.5);
|
||||
setFactionRoster(shuffled.slice(0, 5));
|
||||
}, []);
|
||||
|
||||
// 의회 의장 성향 축 선정 (게임 시작 시 1회) — 의회 정치 엔딩 판별에 사용
|
||||
useEffect(() => {
|
||||
setChairmanAxis(CHAIRMAN_AXES[Math.floor(Math.random() * CHAIRMAN_AXES.length)]);
|
||||
}, []);
|
||||
|
||||
// 내러티브 키워드가 PHASE2_TAG_THRESHOLD개 도달 시 페이즈2 카드 풀(작전/극비) 개방 — 스토리 페이징 카드 풀 전환
|
||||
useEffect(() => {
|
||||
if (phase2Unlocked || phase2Pool.length === 0 || narrativeTags < PHASE2_TAG_THRESHOLD) return;
|
||||
setPhase2Unlocked(true);
|
||||
setCards(prev => {
|
||||
const shuffledPool = [...phase2Pool].sort(() => Math.random() - 0.5);
|
||||
const insertAt = Math.min(prev.length, currentCardIndex + 1);
|
||||
const next = [...prev];
|
||||
next.splice(insertAt, 0, ...shuffledPool);
|
||||
return next;
|
||||
});
|
||||
setChronicle(prev => [...prev, { turn: turns, text: '[페이징] 페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.' }]);
|
||||
}, [narrativeTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
|
||||
if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) {
|
||||
const ending = resolveEnding({
|
||||
trigger: 'collapse', params, tags, tokens,
|
||||
lastDelta: lastParamDelta, narrativeChainUnlocked,
|
||||
});
|
||||
setFinalEnding(ending);
|
||||
setGameOver(true);
|
||||
}
|
||||
if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2);
|
||||
else if (narrativeTags >= 8) setPhase(3);
|
||||
if (narrativeTags >= PHASE2_TAG_THRESHOLD && narrativeTags < PHASE3_TAG_THRESHOLD) setPhase(2);
|
||||
else if (narrativeTags >= PHASE3_TAG_THRESHOLD) setPhase(3);
|
||||
}, [params, narrativeTags]);
|
||||
|
||||
// 시한부 격리된 오염 카드 복귀
|
||||
useEffect(() => {
|
||||
if (quarantineZone.length === 0) return;
|
||||
const ready = quarantineZone.filter(c => c.returnTurn <= turns);
|
||||
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}건이 덱으로 복귀했습니다.` }]);
|
||||
}
|
||||
}, [turns]);
|
||||
|
||||
const applySide = (side, nullifyDanger) => {
|
||||
const s = side.stats;
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (nullifyDanger ? 0 : s.res)),
|
||||
entropy: Math.max(0, p.entropy + (nullifyDanger ? 0 : s.ent)),
|
||||
panic: Math.max(0, p.panic + (nullifyDanger ? 0 : s.pan)),
|
||||
risk: Math.max(0, p.risk + (nullifyDanger ? 0 : s.rsk)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
budget: t.budget + s.bud,
|
||||
personnel: t.personnel + s.per,
|
||||
info: t.info + s.inf,
|
||||
}));
|
||||
setTags(tg => ({
|
||||
wealth: tg.wealth + s.wea,
|
||||
force: tg.force + s.for,
|
||||
surveillance: tg.surveillance + s.sur,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSwipe = (direction) => {
|
||||
if (cards.length === 0) return;
|
||||
const card = cards[currentCardIndex % cards.length];
|
||||
|
||||
let outcomeText = '';
|
||||
|
||||
if (direction === 'left' && card.left) {
|
||||
card.left.effect();
|
||||
outcomeText = card.left.text;
|
||||
} else if (direction === 'right' && card.right) {
|
||||
card.right.effect();
|
||||
outcomeText = card.right.text;
|
||||
let outcomeText = '';
|
||||
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
|
||||
|
||||
if (side) {
|
||||
const shouldNullify = card.contaminated && mulliganShields > 0;
|
||||
applySide(side, shouldNullify);
|
||||
outcomeText = side.text;
|
||||
|
||||
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
|
||||
const nextBudget = tokens.budget + side.stats.bud;
|
||||
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);
|
||||
}
|
||||
|
||||
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
|
||||
if (!shouldNullify) {
|
||||
const dangerDeltas = { resistance: side.stats.res, entropy: side.stats.ent, panic: side.stats.pan };
|
||||
let maxParam = null, maxVal = 0;
|
||||
Object.entries(dangerDeltas).forEach(([p, v]) => {
|
||||
if (v > maxVal) { maxVal = v; maxParam = p; }
|
||||
});
|
||||
if (maxParam) setLastParamDelta({ param: maxParam, amount: maxVal });
|
||||
}
|
||||
|
||||
// 내러티브 키워드: 모든 카드가 아니라 선택된 일부 카드/선택지만 키워드를 부여한다
|
||||
if (side.narrativeTag) {
|
||||
setKeywords(prev => [...prev, { turn: turns + 1, word: side.narrativeTag }]);
|
||||
setNarrativeTags(n => n + 1);
|
||||
}
|
||||
|
||||
// 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적
|
||||
if (card.contaminated) {
|
||||
const nextSequence = [...contaminationSequence, card.type];
|
||||
setContaminationSequence(nextSequence);
|
||||
if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) {
|
||||
setNarrativeChainUnlocked(true);
|
||||
setChronicle(prev => [...prev, { turn: turns + 1, text: '[내러티브] 사건들의 연쇄가 완성되었습니다 - 진실에 가까워졌습니다.' }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldNullify) {
|
||||
setMulliganShields(s => s - 1);
|
||||
outcomeText += ' (방어막으로 페널티 무효화)';
|
||||
}
|
||||
} else if (direction === 'up') {
|
||||
outcomeText = '보류 / 버리기';
|
||||
}
|
||||
@@ -132,31 +295,108 @@ export function GameScreen() {
|
||||
if (outcomeText) {
|
||||
setChronicle(prev => [...prev, { turn: turns + 1, text: `[${card.type}] ${card.text} -> 결정: ${outcomeText}` }]);
|
||||
}
|
||||
|
||||
// Add random asset for '극비' success (just for demonstration)
|
||||
|
||||
// 극비 프로젝트 성공 시 자산 획득 (4종 중 랜덤)
|
||||
if (card.type === '극비' && direction !== 'up') {
|
||||
if (assets.length < 4) {
|
||||
setAssets(prev => [...prev, { id: Date.now(), icon: ['📦','💎','🧪','💾'][Math.floor(Math.random()*4)] }]);
|
||||
const types = Object.keys(ASSET_META);
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
setAssets(prev => [...prev, { id: Date.now(), type, ...ASSET_META[type] }]);
|
||||
setAssetsAcquiredCount(c => c + 1);
|
||||
}
|
||||
}
|
||||
|
||||
setNarrativeTags(n => n + 1);
|
||||
setCurrentCardIndex(idx => idx + 1);
|
||||
|
||||
|
||||
const newTurns = turns + 1;
|
||||
setTurns(newTurns);
|
||||
|
||||
|
||||
if (newTurns > 0 && newTurns % 10 === 0) {
|
||||
setShowCouncil(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급 가중치로 오염 카드 주입
|
||||
const injectContaminationCards = () => {
|
||||
const newCards = [];
|
||||
Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([paramKey, cardType]) => {
|
||||
const level = params[paramKey];
|
||||
if (level < 1) return;
|
||||
const grade = pickGrade(level);
|
||||
const pool = contaminationTemplates.filter(t => t.type === cardType && t.grade === grade);
|
||||
if (pool.length === 0) return;
|
||||
const template = pool[Math.floor(Math.random() * pool.length)];
|
||||
newCards.push({ ...template, id: `${template.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` });
|
||||
});
|
||||
|
||||
if (newCards.length > 0) {
|
||||
setCards(prev => {
|
||||
const insertAt = Math.min(prev.length, currentCardIndex + 1);
|
||||
const next = [...prev];
|
||||
next.splice(insertAt, 0, ...newCards);
|
||||
return next;
|
||||
});
|
||||
setChronicle(prev => [...prev, { turn: turns, text: `[오염] 의회 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.` }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCouncilResolve = (result) => {
|
||||
setShowCouncil(false);
|
||||
if (result.impeachment) {
|
||||
setImpeachmentPassed(true);
|
||||
|
||||
if (result.bankruptcy) {
|
||||
setFinalEnding(getEndingById('bankruptcy'));
|
||||
setGameOver(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBankruptcyWarned(result.bankruptcyWarning);
|
||||
|
||||
if (result.impeachment) {
|
||||
const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked });
|
||||
setFinalEnding(ending);
|
||||
setGameOver(true);
|
||||
return;
|
||||
}
|
||||
|
||||
injectContaminationCards();
|
||||
};
|
||||
|
||||
// 자산 효과 4종 발동
|
||||
const activateAsset = (asset) => {
|
||||
if (asset.type === 'mulligan') {
|
||||
setMulliganShields(s => s + 1);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
const contaminatedIdx = [];
|
||||
cards.forEach((c, i) => { if (c.contaminated) contaminatedIdx.push(i); });
|
||||
|
||||
if (contaminatedIdx.length === 0) {
|
||||
setChronicle(prev => [...prev, { turn: turns, text: `[자산] ${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.` }]);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.type === 'purge') {
|
||||
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: '[자산] 오염 카드를 물리적으로 영구 소각했습니다.' }]);
|
||||
} 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턴 후 덱으로 복귀)` }]);
|
||||
} 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: '[자산] 오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)' }]);
|
||||
}
|
||||
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
};
|
||||
|
||||
const renderDots = (value, max = 5) => {
|
||||
@@ -167,21 +407,38 @@ export function GameScreen() {
|
||||
|
||||
if (gameOver) {
|
||||
return (
|
||||
<EndingScreen
|
||||
params={params}
|
||||
impeachment={impeachmentPassed}
|
||||
narrativeTags={narrativeTags}
|
||||
onRestart={() => window.location.reload()}
|
||||
<EndingScreen
|
||||
ending={finalEnding}
|
||||
narrativeTags={narrativeTags}
|
||||
keywords={keywords}
|
||||
onRestart={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCouncil) {
|
||||
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
|
||||
const affinityCtx = {
|
||||
tags, tokens, params,
|
||||
assetsCount: assets.length,
|
||||
mythicCount: cards.filter(c => c.type === '신화').length,
|
||||
narrativeTags, assetsAcquiredCount, purgeCount, bribeCount,
|
||||
};
|
||||
const globalFactions = factionRoster.map(f => {
|
||||
const score = f.computeAffinity(affinityCtx);
|
||||
const tier = getFactionTier(score);
|
||||
return { id: f.id, name: f.name, tier, vote: TIER_VOTE[tier], immune: TIER_IMMUNE.has(tier) };
|
||||
});
|
||||
|
||||
return (
|
||||
<CouncilScreen
|
||||
<CouncilScreen
|
||||
params={params} setParams={setParams}
|
||||
tokens={tokens} setTokens={setTokens}
|
||||
tags={tags} setTags={setTags}
|
||||
bankruptcyCounter={bankruptcyCounter}
|
||||
bankruptcyWarned={bankruptcyWarned}
|
||||
globalFactions={globalFactions}
|
||||
onBribe={() => setBribeCount(c => c + 1)}
|
||||
onResolve={handleCouncilResolve}
|
||||
/>
|
||||
);
|
||||
@@ -192,13 +449,14 @@ export function GameScreen() {
|
||||
}
|
||||
|
||||
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
|
||||
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
|
||||
|
||||
return (
|
||||
<div className={`game-screen ${phaseClass}`}>
|
||||
{/* Top Bar */}
|
||||
<div className="top-bar">
|
||||
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
|
||||
<BookOpen size={16} /> <span>{narrativeTags}/8</span>
|
||||
<BookOpen size={16} /> <span>{narrativeTags}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
|
||||
</div>
|
||||
<div className="parameters">
|
||||
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
|
||||
@@ -222,8 +480,8 @@ export function GameScreen() {
|
||||
|
||||
{/* Card Area */}
|
||||
<div className="card-area">
|
||||
<SwipeCard
|
||||
card={cards[currentCardIndex % cards.length]}
|
||||
<SwipeCard
|
||||
card={cards[currentCardIndex % cards.length]}
|
||||
onSwipe={handleSwipe}
|
||||
phase={phase}
|
||||
/>
|
||||
@@ -232,9 +490,10 @@ export function GameScreen() {
|
||||
{/* Bottom Inventory */}
|
||||
<div className="bottom-bar">
|
||||
<div className="tokens-row">
|
||||
<div className="token"><HandCoins size={16} color="var(--color-token-budget)" /> <span>{tokens.budget}</span></div>
|
||||
<div className="token"><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</span></div>
|
||||
<div className="token"><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</span></div>
|
||||
<div className={`token ${tokens.budget < 0 ? 'negative' : ''}`}><HandCoins size={16} color="var(--color-token-budget)" /> <span>{tokens.budget}</span></div>
|
||||
<div className={`token ${tokens.personnel < 0 ? 'negative' : ''}`}><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</span></div>
|
||||
<div className={`token ${tokens.info < 0 ? 'negative' : ''}`}><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</span></div>
|
||||
{mulliganShields > 0 && <div className="token shield-badge">🛡️ <span>x{mulliganShields}</span></div>}
|
||||
</div>
|
||||
<div className="tags-row">
|
||||
<div className="tag"><Building2 size={14} /> <span>{tags.wealth}</span></div>
|
||||
@@ -242,18 +501,28 @@ export function GameScreen() {
|
||||
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
|
||||
</div>
|
||||
<div className="asset-slots">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} className="asset-slot">
|
||||
{assets[i] ? <span className="asset-icon">{assets[i].icon}</span> : ''}
|
||||
</div>
|
||||
))}
|
||||
{[0, 1, 2, 3].map(i => {
|
||||
const asset = assets[i];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={`asset-slot ${asset ? 'filled' : ''}`}
|
||||
onClick={() => asset && activateAsset(asset)}
|
||||
disabled={!asset}
|
||||
title={asset ? asset.label : '빈 슬롯'}
|
||||
>
|
||||
{asset ? <span className="asset-icon">{asset.icon}</span> : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showChronicle && (
|
||||
<ChroniclePopup
|
||||
chronicle={chronicle}
|
||||
onClose={() => setShowChronicle(false)}
|
||||
<ChroniclePopup
|
||||
chronicle={chronicle}
|
||||
keywords={keywords}
|
||||
onClose={() => setShowChronicle(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user