내러티브 태그 시스템 개선

요청하신 내러티브 태그 시스템과 시각화 작업을 모두 완료했습니다! 이전에 멈췄던 개발 서버( npm run dev )도 다시 실행해
  두었으니, 새로고침 후 바로 확인해 보실 수 있습니다.
  구현된 시스템의 주요 변경점은 다음과 같습니다.
  ### 1. 내러티브 데이터 생성 및 연동 ( narrative_tags.json ,  cards.csv )

  • 기획서의 세계관을 바탕으로 피도 눈물도 없는 진압, 진실의 장막, 금지된 지식(이계 조우), 사이비의 그림자 등 서사
  몰입을 돕는 내러티브 태그 데이터를 생성했습니다.
  • 매 결재마다 무조건 얻는 것이 아니라, 특정 카드(예: 민간인 구역 폭격, 유물 연구 등)에서 특정한 선택을 했을 때만
  불규칙적으로 획득하도록  cards.csv  데이터를 스크립트로 자동 매핑했습니다.

  ### 2. 결재 시 스와이프 보상 시각화 ( SwipeCard.jsx ,  SwipeCard.css )
  • 내러티브 태그가 걸려 있는 선택지 쪽으로 스와이프(미리보기)를 하면, 텍스트 상단에 노란색 바탕의 뱃지( 💡 진실의 장막
  )가 심장 박동처럼 깜빡이는 애니메이션과 함께 아주 눈에 띄게 표시됩니다. 플레이어가 "아, 이 선택을 하면 스토리에 큰
  영향을 주는구나"를 직관적으로 알 수 있습니다.
  ### 3. 태그 획득 알림 팝업 ( GameScreen.jsx )

  • 결재를 완료하여 내러티브 태그를 얻는 순간, 화면 중앙 상단에  [새로운 내러티브 획득! - 금지된 지식]  형태의 눈에
  띄는 팝업 알림이 내려왔다가 3초 뒤 자연스럽게 사라지도록 구현했습니다.

  ### 4. 연대기 창 서사 부각 개편 ( ChroniclePopup.jsx ,  ChroniclePopup.css )

  • 단순히 텍스트만 나열되던 방식을 타임라인(Timeline) 형식으로 전면 재설계했습니다.
  • 과거의 기록에는  [위기] 상황 키워드 와 함께 내가 어떤 서사를 마주했고 어떤 결정을 내렸는지 명확히 표시됩니다.
  • 태그 획득 시각화: 특히 내러티브 태그를 얻은 턴은 배경이 금빛으로 하이라이트되며, 획득한 태그의 **자세한 설명(스토리
  서사)**이 커다란 아이콘과 함께 표시됩니다. 이를 통해 유저가 왜 이런 태그를 얻었는지 서사를 깊게 이해할 수 있습니다.
This commit is contained in:
Kyoung5seo
2026-06-25 22:48:38 +09:00
parent 56f494f331
commit 4c44f942c5
9 changed files with 346 additions and 82 deletions
+40 -8
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';
@@ -12,7 +13,7 @@ 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 [acquiredTags, setAcquiredTags] = useState([]);
const [phase, setPhase] = useState(1);
const [gameOver, setGameOver] = useState(false);
const [cards, setCards] = useState([]);
@@ -31,6 +32,7 @@ export function GameScreen() {
const [bankruptcyCount, setBankruptcyCount] = useState(0);
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
const [bankrupted, setBankrupted] = useState(false);
const [newTagNotification, setNewTagNotification] = useState(null);
// CSV Load Effect
useEffect(() => {
@@ -45,6 +47,7 @@ export function GameScreen() {
flavor: row.flavor,
left: {
text: row.left_text,
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
stats: {
res: row.left_res || 0,
ent: row.left_ent || 0,
@@ -78,6 +81,7 @@ export function GameScreen() {
},
right: {
text: row.right_text,
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
stats: {
res: row.right_res || 0,
ent: row.right_ent || 0,
@@ -118,15 +122,16 @@ export function GameScreen() {
if (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5 || params.risk >= 5) {
setGameOver(true);
}
if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2);
else if (narrativeTags >= 8) setPhase(3);
}, [params, narrativeTags]);
if (acquiredTags.length >= 4 && acquiredTags.length < 8) setPhase(2);
else if (acquiredTags.length >= 8) setPhase(3);
}, [params, acquiredTags]);
const handleSwipe = (direction) => {
if (cards.length === 0) return;
const card = cards[currentCardIndex % cards.length];
let outcomeText = '';
let gainedTag = null;
let newBudget = tokens.budget;
let newPersonnel = tokens.personnel;
@@ -138,12 +143,14 @@ export function GameScreen() {
newInfo += (card.left.stats?.inf || 0);
card.left.effect();
outcomeText = card.left.text;
gainedTag = card.left.tag;
} else if (direction === 'right' && card.right) {
newBudget += (card.right.stats?.bud || 0);
newPersonnel += (card.right.stats?.per || 0);
newInfo += (card.right.stats?.inf || 0);
card.right.effect();
outcomeText = card.right.text;
gainedTag = card.right.tag;
} else if (direction === 'up') {
outcomeText = '보류 / 버리기';
}
@@ -153,7 +160,22 @@ export function GameScreen() {
}
if (outcomeText) {
setChronicle(prev => [...prev, { turn: turns + 1, text: `[${card.type}] ${card.text} -> 결정: ${outcomeText}` }]);
let isNewTag = false;
if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) {
isNewTag = true;
setAcquiredTags(prev => [...prev, gainedTag]);
setNewTagNotification(gainedTag);
setTimeout(() => setNewTagNotification(null), 3000);
}
setChronicle(prev => [...prev, {
turn: turns + 1,
cardType: card.type,
keyword: card.keyword,
text: card.text,
decision: outcomeText,
tag: isNewTag ? gainedTag : null
}]);
}
// Add random asset for '극비' success (just for demonstration)
@@ -163,7 +185,6 @@ export function GameScreen() {
}
}
setNarrativeTags(n => n + 1);
setCurrentCardIndex(idx => idx + 1);
setTurns(turns + 1);
@@ -225,7 +246,7 @@ export function GameScreen() {
impeachment={impeachmentPassed}
termExpired={termExpired}
bankrupted={bankrupted}
narrativeTags={narrativeTags}
narrativeTags={acquiredTags.length}
onRestart={() => window.location.reload()}
/>
);
@@ -256,7 +277,7 @@ export function GameScreen() {
{/* 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>{acquiredTags.length}/8</span>
</div>
<div className="round-info">
제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건
@@ -311,9 +332,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}
acquiredTags={acquiredTags}
onClose={() => setShowChronicle(false)}
/>
)}