내러티브 태그 시스템 개선
요청하신 내러티브 태그 시스템과 시각화 작업을 모두 완료했습니다! 이전에 멈췄던 개발 서버( 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:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user