구현 우선 순위 1차 진행 완료

- 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능)
  1. **기호 체계 + 책상 상태 상시 노출 UI** (§3)
  2. **책상 적재 + 누진 패시브 페널티 A·B** (§4)
  3. **표준 족보 판정 및 합본 실행** (§5)
  4. **합본 기반 토큰 산출** (§8.2)

 게임 플레이 후 피드백 해결
 - 내용은 아래 문서 참고
 - docs/decisions/2026-09-05_core-graybox-ux-feedback.md
This commit is contained in:
kyoung5seo
2026-09-05 01:06:41 +09:00
parent 54e2fa8132
commit 83060f3e5b
21 changed files with 1526 additions and 8 deletions
@@ -0,0 +1,133 @@
import React, { useMemo, useState } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../../data/cards.csv?raw';
import DeskBar from './DeskBar';
import CoreSwipeCard from './CoreSwipeCard';
import CombinePanel from './CombinePanel';
import HandGuidePopup from './HandGuidePopup';
import { pickPollutionEntry } from '../engine/pollutionPool';
import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk } from '../engine/desk';
import './CoreGameScreen.css';
// 코어 루프 그레이박스 (§2·§17 1차) — 기호 체계 + 책상 적재/누진 페널티(A·B) + 표준 족보 판정/합본 +
// 합본 기반 토큰 산출까지만 구현한다. 상점·규약·의회·자산·전례·엔딩은 2~4차 범위라 아직 없다.
// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다 — 코어시스템
// 기획서 자체가 "기존 코드는 참조용, 신규 개발" 원칙을 명시하고 있다.
const buildCard = (row) => ({
id: row.id,
type: row.type,
keyword: row.keyword,
question: row.question,
text: row.text,
flavor: row.flavor,
left: {
text: row.left_text,
axis: row.left_axis || null,
grade: row.left_grade ? Number(row.left_grade) : null,
tag: row.left_tag || null,
},
right: {
text: row.right_text,
axis: row.right_axis || null,
grade: row.right_grade ? Number(row.right_grade) : null,
tag: row.right_tag || null,
},
});
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
const INITIAL_TOKENS = { budget: 0, personnel: 0, info: 0 };
export default function CoreGameScreen() {
const deck = useMemo(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
return parsed.data.map(buildCard);
}, []);
const [cardIndex, setCardIndex] = useState(0);
const [deskCards, setDeskCards] = useState([]);
const [gauges, setGauges] = useState(INITIAL_GAUGES);
const [tokens, setTokens] = useState(INITIAL_TOKENS);
const [score, setScore] = useState(0);
const [justifications, setJustifications] = useState([]);
const [showCombine, setShowCombine] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [log, setLog] = useState([]);
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
const handleSwipe = (direction) => {
if (!currentCard) return;
const side = direction === 'right' ? currentCard.right : currentCard.left;
// A. 게이지 상시 상승 — 이번 결재 시점 책상 상태 기준으로 축별 누진율만큼 올린다 (§4.2-A).
setGauges((prev) => applyPassiveGaugeRise(prev, deskCards));
// 선택한 쪽에 축이 지정돼 있으면 오염 카드 1장을 책상에 적재한다.
// pollution_pool.csv에서 실제 이름·사연을 붙여서, "이 선택이 어떤 서류를 남겼는지"가
// 축 라벨이 아니라 구체적인 사건으로 보이게 한다 (플레이테스트 피드백).
if (side.axis) {
const grade = side.grade || 1;
const entry = pickPollutionEntry(side.axis, grade);
const newCard = makeDeskCard(side.axis, grade, entry || {});
setDeskCards((prev) => addToDesk(prev, [newCard]));
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』 접수`);
} else {
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
}
setCardIndex((i) => (i + 1) % deck.length);
};
const handleCombineConfirm = (result) => {
setDeskCards((prev) => removeFromDesk(prev, result.removeIds));
setTokens((prev) => {
const next = { ...prev };
Object.entries(result.tokens).forEach(([k, v]) => { next[k] = (next[k] || 0) + v; });
return next;
});
setJustifications((prev) => [...prev, result.justification]);
setScore((prev) => prev + result.performanceScore);
pushLog(`합본 성립: 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 명분(${result.justification.type}) +${result.justification.amount}`);
setShowCombine(false);
};
if (!currentCard) {
return <div className="core-loading">카드를 불러오는 중...</div>;
}
return (
<div className="core-game-screen">
<DeskBar
deskCards={deskCards}
gauges={gauges}
onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)}
/>
<div className="core-status-row">
<span>실적 {score}</span>
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
<span>명분 {justifications.length}개 보유</span>
</div>
<CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />
<div className="core-log">
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
</div>
{showCombine && (
<CombinePanel
deskCards={deskCards}
onConfirm={handleCombineConfirm}
onClose={() => setShowCombine(false)}
/>
)}
{showGuide && <HandGuidePopup onClose={() => setShowGuide(false)} />}
</div>
);
}