docs: add micro fun patch plan (A-5, F-4, F-5)
3-item ~3h plan covering codex slot hints with recipe ingredients (A-5), idle-mood character yawn animation after 30s of inactivity (F-4), and device-time-of-day root background palette (F-5). Wrapper-based approach keeps CharacterSprite untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
638
docs/superpowers/plans/2026-05-04-micro-fun-patch.md
Normal file
638
docs/superpowers/plans/2026-05-04-micro-fun-patch.md
Normal file
@@ -0,0 +1,638 @@
|
||||
# Micro Fun Patch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** spec의 짧은 💡 항목 3개를 묶어 도감 인지 / 캐릭터 살아있음 / 환경 톤을 한 번에 끌어올린다.
|
||||
|
||||
**Architecture:**
|
||||
- 데이터 변경·새 의존성 없음. 기존 컴포넌트(`ElementsScreen`, `CharacterSprite`, `App`)에 작은 헬퍼와 prop을 추가.
|
||||
- 시간대 컬러는 `App.tsx`에서 단일 source of truth로 계산해 root background에 적용. CSS-in-JS 동적 스타일.
|
||||
- 캐릭터 idle mood는 ElementsScreen 안에서 입력 비활성 시간을 추적하다가 일정 시간 후 sprite에 `mood="idle"` prop 전달. CharacterSprite가 추가 keyframes를 합성.
|
||||
- 미발견 도감은 기존 `tierGroups`를 확장해 미발견 원소도 포함시키고, 새 헬퍼 `getRecipeHintForElement`로 재료 emoji 힌트를 주입.
|
||||
|
||||
**Tech Stack:** Vite 5, React 18, TypeScript 5, @emotion/react. 검증은 `npm run typecheck` + 브라우저 수동 스모크.
|
||||
|
||||
**상위 spec:** `docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md` 의 A-5 / F-4 / F-5 항목.
|
||||
|
||||
---
|
||||
|
||||
## Sanity Check Before You Start
|
||||
|
||||
본 plan 작성 시점에 다음이 사실로 확인됨. 실제 구현 시 어긋나면 멈추고 보고 (BLOCKED):
|
||||
|
||||
- `src/components/screens/ElementsScreen.tsx`의 `undiscoveredCardStyle`/`undiscoveredNameStyle`은 단순 `???` 만 출력 (line ~1270)
|
||||
- `tierGroups`는 `discoveredElementList`만 필터링해서 미발견 카드는 화면에 안 보임 (line ~1465)
|
||||
- `CharacterSprite`는 ambient 애니메이션(`fireFlicker`, `rasterFloat` 등)만 있고 idle/yawn 같은 mood 분기 없음
|
||||
- `App.tsx`의 `rootStyle`은 `background-color: #f7f8fa` 고정값. 시간대 처리 없음
|
||||
- B-2 탭-투-수확은 이미 구현됨 (`harvestReady = progress >= 0.75`, line ~1627). 본 plan에서는 다루지 않음
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 파일 | 역할 | 변경 종류 |
|
||||
|---|---|---|
|
||||
| `src/lib/timeOfDay.ts` | 시간대 → 팔레트 매핑 + React hook | **Create** |
|
||||
| `src/lib/useIdleMood.ts` | 사용자 비활성 추적 hook | **Create** |
|
||||
| `src/components/screens/ElementsScreen.tsx` | tierGroups 확장 + 미발견 카드 힌트 + idle 시 sprite wrapper | **Modify** |
|
||||
| `src/App.tsx` | `useTimeOfDay()` 호출 → rootStyle 동적 배경 | **Modify** |
|
||||
|
||||
> **CharacterSprite는 건드리지 않습니다.** idle 모션은 ElementsScreen에서 sprite를 감싸는 wrapper div에 CSS animation을 거는 방식 — sprite 내부 구조에 의존하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: F-5 시간대 톤 변화
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/timeOfDay.ts`
|
||||
- Modify: `src/App.tsx` (rootStyle 동적화)
|
||||
|
||||
**규칙:**
|
||||
- 시간대 4단계 (디바이스 로컬 시간 기준):
|
||||
- 05:00 ~ 08:59 — `dawn` (따뜻한 크림)
|
||||
- 09:00 ~ 16:59 — `day` (현재 톤 유지: 푸른 회백)
|
||||
- 17:00 ~ 19:59 — `dusk` (피치 톤)
|
||||
- 20:00 ~ 04:59 — `night` (딥 네이비)
|
||||
- 5분마다 재계산해서 톤 전환
|
||||
|
||||
- [ ] **Step 1: timeOfDay 유틸 생성**
|
||||
|
||||
새 파일 `src/lib/timeOfDay.ts`:
|
||||
|
||||
```ts
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type TimeOfDay = 'dawn' | 'day' | 'dusk' | 'night';
|
||||
|
||||
export interface TimeOfDayPalette {
|
||||
background: string;
|
||||
contentTint: string;
|
||||
}
|
||||
|
||||
const PALETTES: Record<TimeOfDay, TimeOfDayPalette> = {
|
||||
dawn: { background: '#fff6ec', contentTint: 'rgba(255, 196, 138, 0.05)' },
|
||||
day: { background: '#f7f8fa', contentTint: 'rgba(255, 255, 255, 0)' },
|
||||
dusk: { background: '#fff0e6', contentTint: 'rgba(255, 138, 92, 0.06)' },
|
||||
night: { background: '#101728', contentTint: 'rgba(124, 138, 255, 0.04)' },
|
||||
};
|
||||
|
||||
export function getTimeOfDay(date: Date = new Date()): TimeOfDay {
|
||||
const h = date.getHours();
|
||||
if (h >= 5 && h < 9) return 'dawn';
|
||||
if (h >= 9 && h < 17) return 'day';
|
||||
if (h >= 17 && h < 20) return 'dusk';
|
||||
return 'night';
|
||||
}
|
||||
|
||||
export function getPalette(tod: TimeOfDay): TimeOfDayPalette {
|
||||
return PALETTES[tod];
|
||||
}
|
||||
|
||||
const TICK_MS = 5 * 60 * 1000;
|
||||
|
||||
export function useTimeOfDay(): TimeOfDay {
|
||||
const [tod, setTod] = useState<TimeOfDay>(() => getTimeOfDay());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setTod((prev) => {
|
||||
const next = getTimeOfDay();
|
||||
return next === prev ? prev : next;
|
||||
});
|
||||
}, TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return tod;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: App.tsx의 rootStyle을 팔레트로 분기**
|
||||
|
||||
`src/App.tsx` import 블록에 추가:
|
||||
|
||||
```tsx
|
||||
import { useTimeOfDay, getPalette } from './lib/timeOfDay';
|
||||
```
|
||||
|
||||
기존 `rootStyle` 함수를 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
const rootStyle = (shaking: boolean, background: string) => css`
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: ${background};
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
'Pretendard',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
animation: ${shaking ? css`${legendaryShake} 0.42s ease-out` : 'none'};
|
||||
transition: background-color 1.5s ease-in-out;
|
||||
`;
|
||||
```
|
||||
|
||||
`App` 함수 본문에서 `useIdleTick();` 다음에 추가:
|
||||
|
||||
```tsx
|
||||
const timeOfDay = useTimeOfDay();
|
||||
const palette = getPalette(timeOfDay);
|
||||
```
|
||||
|
||||
JSX 루트 div 변경:
|
||||
|
||||
```tsx
|
||||
<div css={rootStyle(shaking, palette.background)}>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
기대: PASS.
|
||||
|
||||
- [ ] **Step 4: 브라우저 수동 검증**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
확인:
|
||||
1. 일반 접속 → 현재 시간대 톤 적용 (예: 14시 = day = 기존 #f7f8fa)
|
||||
2. 디바이스 시간을 변경하거나, 콘솔에서 `localStorage.setItem('debug-hour', '22')` 등 디버그 트릭 사용 — 본 plan 외 코드 변경 없이 직접 검증이 어려우면, 임시로 `useTimeOfDay`의 초기값을 `'night'` 등으로 하드코딩해 모든 4톤을 눈으로 확인 후 원복.
|
||||
3. 톤 전환 시 1.5초 fade transition
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/lib/timeOfDay.ts src/App.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: tint app background by time of day (F-5)
|
||||
|
||||
Adds dawn/day/dusk/night palette derived from device-local hour. Root
|
||||
background fades over 1.5s on transitions; recalculated every 5 minutes.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: F-4 캐릭터 미세 인터랙션 (idle mood)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/useIdleMood.ts`
|
||||
- Modify: `src/components/screens/ElementsScreen.tsx` (sprite wrapper에 idle 애니메이션 부착)
|
||||
|
||||
**규칙:**
|
||||
- 사용자 입력(pointerdown/touchstart/keydown/scroll) 기준 30초 비활성 시 mood='idle'
|
||||
- idle 시 sprite를 감싸는 wrapper div가 8초 주기 yawn 애니메이션 (살짝 위로 떴다 가라앉음)
|
||||
- 입력 발생하면 즉시 mood='awake' 복귀, 애니메이션 정지
|
||||
- CharacterSprite 내부 코드는 변경하지 않음. wrapper 방식이라 sprite 구조에 영향 없음
|
||||
|
||||
- [ ] **Step 1: useIdleMood hook 생성**
|
||||
|
||||
새 파일 `src/lib/useIdleMood.ts`:
|
||||
|
||||
```ts
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const IDLE_AFTER_MS = 30_000;
|
||||
const TICK_MS = 2_000;
|
||||
|
||||
export type Mood = 'awake' | 'idle';
|
||||
|
||||
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||
'pointerdown',
|
||||
'touchstart',
|
||||
'keydown',
|
||||
'scroll',
|
||||
];
|
||||
|
||||
export function useIdleMood(idleAfterMs: number = IDLE_AFTER_MS): Mood {
|
||||
const [mood, setMood] = useState<Mood>('awake');
|
||||
|
||||
useEffect(() => {
|
||||
let lastActivity = Date.now();
|
||||
const onActivity = () => {
|
||||
lastActivity = Date.now();
|
||||
setMood((prev) => (prev === 'awake' ? prev : 'awake'));
|
||||
};
|
||||
ACTIVITY_EVENTS.forEach((evt) =>
|
||||
window.addEventListener(evt, onActivity, { passive: true })
|
||||
);
|
||||
const tick = window.setInterval(() => {
|
||||
if (Date.now() - lastActivity >= idleAfterMs) {
|
||||
setMood((prev) => (prev === 'idle' ? prev : 'idle'));
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => {
|
||||
ACTIVITY_EVENTS.forEach((evt) => window.removeEventListener(evt, onActivity));
|
||||
window.clearInterval(tick);
|
||||
};
|
||||
}, [idleAfterMs]);
|
||||
|
||||
return mood;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ElementsScreen에 idle yawn wrapper 적용**
|
||||
|
||||
`src/components/screens/ElementsScreen.tsx` import 블록에 추가:
|
||||
|
||||
```ts
|
||||
import { useIdleMood, type Mood } from '../../lib/useIdleMood';
|
||||
```
|
||||
|
||||
스타일 영역(예: `spriteWrapStyle` 정의 직후)에 추가:
|
||||
|
||||
```ts
|
||||
const idleYawnKeyframes = keyframes`
|
||||
0%, 70%, 100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
78% { transform: translateY(-1.5px) scale(1.04, 0.96); opacity: 0.94; }
|
||||
88% { transform: translateY(-2.5px) scale(1.06, 0.94); opacity: 0.88; }
|
||||
96% { transform: translateY(-0.5px) scale(1.02, 0.99); opacity: 0.97; }
|
||||
`;
|
||||
|
||||
const idleSpriteWrapperStyle = (mood: Mood) => css`
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
${mood === 'idle'
|
||||
? `animation: ${idleYawnKeyframes.toString()} 8s ease-in-out infinite;`
|
||||
: ''}
|
||||
`;
|
||||
```
|
||||
|
||||
> 만약 `keyframes`가 import되어 있지 않다면 `@emotion/react`의 import에 `keyframes` 추가:
|
||||
> `import { css, keyframes } from '@emotion/react';`
|
||||
> (이미 keyframes를 사용하고 있으면 import는 그대로 둠.)
|
||||
|
||||
`ElementsScreen` 함수 본문 상단(`const gold = useGameStore(...)` 영역 부근)에 추가:
|
||||
|
||||
```ts
|
||||
const idleMood = useIdleMood();
|
||||
```
|
||||
|
||||
`activeProducers.map(...)` JSX의 `<div css={producerSpriteStyle(active)}>`을 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
<div css={producerSpriteStyle(active)}>
|
||||
<div css={idleSpriteWrapperStyle(idleMood)}>
|
||||
<CharacterSprite
|
||||
elementId={el.id}
|
||||
elementColor={el.color}
|
||||
tier={el.tier}
|
||||
size={42}
|
||||
state={(elements[el.id] ?? 0) > 0 ? 'obtained' : 'locked'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
> Task 3에서는 ElementCard 안에서도 동일한 wrapper를 적용. ElementCard에 mood prop을 추가하는 부분이 Task 3 Step 2에 포함됨.
|
||||
|
||||
- [ ] **Step 3: typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
기대: PASS.
|
||||
|
||||
- [ ] **Step 4: 브라우저 수동 검증**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
확인:
|
||||
1. Elements 탭 진입 → 30초 동안 어떤 입력도 안 함 → 작업장 producer sprite들이 천천히 yawn (위로 살짝 떴다 가라앉기, 8초 주기)
|
||||
2. 화면 탭/스크롤 → 즉시 awake (yawn 멈춤)
|
||||
3. 다시 30초 대기 → idle 진입 반복
|
||||
|
||||
(테스트 단축을 위해 호출을 임시 `useIdleMood(5000)`으로 낮추고 검증 후 원복.)
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/lib/useIdleMood.ts src/components/screens/ElementsScreen.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: add idle yawn animation when user is inactive (F-4)
|
||||
|
||||
After 30s without user input on Elements tab, producer sprites cycle a
|
||||
subtle yawn (translateY + scale) every 8s. Resets immediately on any
|
||||
pointer/touch/key/scroll event. Implemented via wrapper div so
|
||||
CharacterSprite internals are untouched.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: A-5 미발견 도감 슬롯 힌트
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/screens/ElementsScreen.tsx` (tierGroups 확장 + 힌트 헬퍼 + 미발견 카드 렌더)
|
||||
|
||||
**규칙:**
|
||||
- 각 tier 섹션에서 발견된 원소 다음에, 해당 tier의 미발견 원소를 합성 가능 우선 + 최대 6개까지 노출
|
||||
- 미발견 카드는 기존 `undiscoveredCardStyle` + 실루엣 sprite + 재료 힌트 ("🔥 + 💧" 또는 "🔥 + ?") 표시
|
||||
- 재료 emoji는 재료 자체가 발견됐을 때만 노출, 아니면 "?" 폴백
|
||||
|
||||
- [ ] **Step 1: 헬퍼 함수 추가**
|
||||
|
||||
`src/components/screens/ElementsScreen.tsx`의 `elementMap` 상수 정의 부근(또는 파일 상단 헬퍼 영역)에 추가:
|
||||
|
||||
```ts
|
||||
const elementMap = Object.fromEntries(elementsData.map((el) => [el.id, el]));
|
||||
|
||||
interface RecipeHint {
|
||||
display: string;
|
||||
craftableNow: boolean;
|
||||
}
|
||||
|
||||
function getRecipeHintForElement(
|
||||
elementId: string,
|
||||
discoveredIds: string[],
|
||||
ownedCounts: Record<string, number>
|
||||
): RecipeHint | null {
|
||||
const recipe = recipesData.find((r) => r.result === elementId);
|
||||
if (!recipe) return null;
|
||||
const [aId, bId] = recipe.ingredients;
|
||||
const aDiscovered = discoveredIds.includes(aId);
|
||||
const bDiscovered = discoveredIds.includes(bId);
|
||||
const aSym = aDiscovered ? (elementMap[aId]?.emoji ?? '?') : '?';
|
||||
const bSym = bDiscovered ? (elementMap[bId]?.emoji ?? '?') : '?';
|
||||
const sameElement = aId === bId;
|
||||
const hasA = (ownedCounts[aId] ?? 0) >= (sameElement ? 2 : 1);
|
||||
const hasB = sameElement ? hasA : (ownedCounts[bId] ?? 0) >= 1;
|
||||
return {
|
||||
display: `${aSym} + ${bSym}`,
|
||||
craftableNow: aDiscovered && bDiscovered && hasA && hasB,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
(`elementMap` 선언이 이미 있으면 중복 추가하지 말 것)
|
||||
|
||||
미발견 카드 스타일에 작은 힌트용 스타일 추가 (스타일 영역, `undiscoveredNameStyle` 옆):
|
||||
|
||||
```ts
|
||||
const undiscoveredHintStyle = (craftable: boolean) => css`
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
color: ${craftable ? adaptive.blue500 : adaptive.grey400};
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ElementCard에 미발견 힌트 + idle wrapper 렌더링 추가**
|
||||
|
||||
`ElementCard` 컴포넌트 props에 `mood?: Mood; hint?: RecipeHint | null;` 추가:
|
||||
|
||||
```tsx
|
||||
interface ElementCardProps {
|
||||
el: ElementData;
|
||||
state: ElementState;
|
||||
count: number;
|
||||
level: number;
|
||||
onSelect: (el: ElementData) => void;
|
||||
isAutomated?: boolean;
|
||||
isTutorialTarget?: boolean;
|
||||
mood?: Mood; // Task 2에서 정의
|
||||
hint?: RecipeHint | null; // 본 Task에서 추가
|
||||
}
|
||||
```
|
||||
|
||||
(`Mood` import 확인: `import { useIdleMood, type Mood } from '../../lib/useIdleMood';` — Task 2에서 이미 추가됨)
|
||||
|
||||
`state === 'undiscovered'` 분기를 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
if (state === 'undiscovered') {
|
||||
return (
|
||||
<div css={undiscoveredCardStyle}>
|
||||
<div css={spriteWrapStyle}>
|
||||
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
|
||||
<CharacterSprite
|
||||
elementId={el.id}
|
||||
elementColor={el.color}
|
||||
tier={el.tier}
|
||||
size={56}
|
||||
state="undiscovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span css={undiscoveredNameStyle}>???</span>
|
||||
{hint && (
|
||||
<span css={undiscoveredHintStyle(hint.craftableNow)}>{hint.display}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`state === 'locked'` 분기도 일관성 위해 동일하게 sprite wrapper 적용:
|
||||
|
||||
```tsx
|
||||
if (state === 'locked') {
|
||||
return (
|
||||
<div css={lockedCardStyle}>
|
||||
<span css={lockIconStyle}>🔒</span>
|
||||
<div css={spriteWrapStyle}>
|
||||
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
|
||||
<CharacterSprite
|
||||
elementId={el.id}
|
||||
elementColor={el.color}
|
||||
tier={el.tier}
|
||||
size={56}
|
||||
state="locked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span css={elementNameStyle}>{el.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`state === 'obtained'` (default) 분기의 `<div css={spriteWrapStyle}>` 안 CharacterSprite도 동일하게 wrapper로 감쌈:
|
||||
|
||||
```tsx
|
||||
<div css={spriteWrapStyle}>
|
||||
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
|
||||
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="obtained" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: tierGroups를 확장해 미발견 원소 포함**
|
||||
|
||||
`ElementsScreen` 본문에서 기존 `tierGroups` 정의를 다음으로 교체:
|
||||
|
||||
```ts
|
||||
const MAX_UNDISCOVERED_PER_TIER = 6;
|
||||
|
||||
const tierGroups = [1, 2, 3, 4, 5].map((tier) => {
|
||||
const tierElements = elementsData.filter((el) => el.tier === tier);
|
||||
const owned = tierElements.filter((el) => discoveredElementIds.includes(el.id));
|
||||
const undiscovered = tierElements.filter((el) => !discoveredElementIds.includes(el.id));
|
||||
|
||||
const undiscoveredWithHints = undiscovered
|
||||
.map((el) => ({ el, hint: getRecipeHintForElement(el.id, discoveredElementIds, elements) }))
|
||||
.sort((a, b) => {
|
||||
const aCraftable = a.hint?.craftableNow ? 1 : 0;
|
||||
const bCraftable = b.hint?.craftableNow ? 1 : 0;
|
||||
if (aCraftable !== bCraftable) return bCraftable - aCraftable;
|
||||
const aHasHint = a.hint ? 1 : 0;
|
||||
const bHasHint = b.hint ? 1 : 0;
|
||||
return bHasHint - aHasHint;
|
||||
})
|
||||
.slice(0, MAX_UNDISCOVERED_PER_TIER);
|
||||
|
||||
return {
|
||||
tier,
|
||||
owned,
|
||||
undiscovered: undiscoveredWithHints,
|
||||
};
|
||||
}).filter(({ owned, undiscovered }) => owned.length > 0 || undiscovered.length > 0);
|
||||
```
|
||||
|
||||
기존 tierGroups 렌더링 JSX를 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
{tierGroups.map(({ tier, owned, undiscovered }) => (
|
||||
<div key={tier} css={tierSectionStyle}>
|
||||
<div css={tierLabelStyle}>{TIER_LABELS[tier]}</div>
|
||||
<div css={elementGridStyle}>
|
||||
{owned.map((el) => (
|
||||
<ElementCard
|
||||
key={el.id}
|
||||
el={el}
|
||||
state="obtained"
|
||||
count={elements[el.id] ?? 0}
|
||||
level={elementLevels[el.id] ?? 0}
|
||||
onSelect={setSelectedEl}
|
||||
isAutomated={el.tier <= tierAutomation.unlockedTier}
|
||||
isTutorialTarget={el.id === firstObtainedId}
|
||||
mood={idleMood}
|
||||
/>
|
||||
))}
|
||||
{undiscovered.map(({ el, hint }) => (
|
||||
<ElementCard
|
||||
key={el.id}
|
||||
el={el}
|
||||
state="undiscovered"
|
||||
count={0}
|
||||
level={0}
|
||||
onSelect={setSelectedEl}
|
||||
hint={hint}
|
||||
mood={idleMood}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
> 만약 첫 1티어부터 모든 카드가 발견된 신규 유저가 아니라면 (이미 진행한 게임 데이터 보유), 미발견 미니카드가 갑자기 등장하면서 시각적 변화가 큼 — 정상. 콜렉션 갈증을 만드는 의도.
|
||||
|
||||
- [ ] **Step 4: typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
기대: PASS.
|
||||
|
||||
- [ ] **Step 5: 브라우저 수동 검증**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
확인:
|
||||
1. 새 게임 또는 기존 진행 → Elements 화면 하단 tier 섹션
|
||||
2. 각 tier에서 발견 카드 다음에 실루엣 카드들이 보임
|
||||
3. 실루엣 카드 아래 "🔥 + 💧" 같은 재료 emoji (양쪽 다 발견됐고 보유량 있으면 파란색, 아니면 회색)
|
||||
4. 미발견 재료가 있으면 "🔥 + ?" 형식으로 표시
|
||||
5. 발견 후 → 해당 카드는 owned 섹션으로 자연스럽게 이동
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add src/components/screens/ElementsScreen.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: surface recipe hints on undiscovered codex slots (A-5)
|
||||
|
||||
Each tier section now shows up to 6 undiscovered silhouettes after the owned
|
||||
cards, sorted by craftable-now > known-ingredients > unknown. Hint is the
|
||||
two-ingredient emoji pair, with "?" when an ingredient has not been found.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] **Step 1: 통합 typecheck**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
기대: PASS.
|
||||
|
||||
- [ ] **Step 2: 통합 빌드**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
기대: 성공. 산출물 `dist/`.
|
||||
|
||||
- [ ] **Step 3: 통합 스모크 테스트**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
체크리스트:
|
||||
- [ ] 시간대 톤이 현재 시간에 맞게 적용 (낮 시간대면 기존 흰빛, 밤이면 네이비)
|
||||
- [ ] 30초 입력 안 하면 캐릭터 sprite가 yawn + 깜빡임
|
||||
- [ ] 입력하면 즉시 awake로 복귀
|
||||
- [ ] Elements 화면 tier 섹션에 미발견 실루엣 카드 + 재료 힌트
|
||||
- [ ] 새 원소를 합성·발견 → 카드가 owned 섹션으로 이동, hint 카드는 줄어듦
|
||||
- [ ] 모든 미발견 카드 클릭 시 기존 DetailPanel은 안 열려도 OK (state="undiscovered" 분기에 onSelect 호출 없음)
|
||||
- [ ] typecheck/build 모두 PASS
|
||||
|
||||
- [ ] **Step 4: 커밋 로그 확인**
|
||||
|
||||
```bash
|
||||
git log --oneline -6
|
||||
```
|
||||
|
||||
기대: 본 plan의 3개 커밋이 차례로 보임.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
본 plan에서 의도적으로 다루지 않은 항목:
|
||||
- D-4 도감 URL 공유 — 별도 plan
|
||||
- 도감 카드 클릭 시 DetailPanel 변형 (미발견 전용 UI) — A-5 v0에는 부재
|
||||
- night 톤일 때 contentTint를 실제로 어디에 합성할지 — Task 1에서는 background만 사용, 향후 폴리시에서 contentTint 활용 가능
|
||||
- idle mood가 FusionScreen/EvolutionScreen 등 다른 화면에도 전파되는지 — 본 plan은 ElementsScreen만 (가장 sprite 노출 많은 화면)
|
||||
|
||||
본 plan에서 도입하는 새 의존성: 없음.
|
||||
Reference in New Issue
Block a user