diff --git a/docs/superpowers/plans/2026-05-04-micro-fun-patch.md b/docs/superpowers/plans/2026-05-04-micro-fun-patch.md new file mode 100644 index 0000000..1712069 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-micro-fun-patch.md @@ -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 = { + 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(() => 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 +
+``` + +- [ ] **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) +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 = [ + 'pointerdown', + 'touchstart', + 'keydown', + 'scroll', +]; + +export function useIdleMood(idleAfterMs: number = IDLE_AFTER_MS): Mood { + const [mood, setMood] = useState('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์˜ `
`์„ ๋‹ค์Œ์œผ๋กœ ๊ต์ฒด: + +```tsx +
+
+ 0 ? 'obtained' : 'locked'} + /> +
+
+``` + +> 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) +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 +): 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 ( +
+
+
+ +
+
+ ??? + {hint && ( + {hint.display} + )} +
+ ); + } +``` + +`state === 'locked'` ๋ถ„๊ธฐ๋„ ์ผ๊ด€์„ฑ ์œ„ํ•ด ๋™์ผํ•˜๊ฒŒ sprite wrapper ์ ์šฉ: + +```tsx + if (state === 'locked') { + return ( +
+ ๐Ÿ”’ +
+
+ +
+
+ {el.name} +
+ ); + } +``` + +`state === 'obtained'` (default) ๋ถ„๊ธฐ์˜ `
` ์•ˆ CharacterSprite๋„ ๋™์ผํ•˜๊ฒŒ wrapper๋กœ ๊ฐ์Œˆ: + +```tsx +
+
+ +
+
+``` + +- [ ] **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 }) => ( +
+
{TIER_LABELS[tier]}
+
+ {owned.map((el) => ( + + ))} + {undiscovered.map(({ el, hint }) => ( + + ))} +
+
+ ))} +``` + +> ๋งŒ์•ฝ ์ฒซ 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) +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์—์„œ ๋„์ž…ํ•˜๋Š” ์ƒˆ ์˜์กด์„ฑ: ์—†์Œ.