diff --git a/docs/superpowers/plans/2026-05-01-phase0-fun-patch.md b/docs/superpowers/plans/2026-05-01-phase0-fun-patch.md new file mode 100644 index 0000000..776e714 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-phase0-fun-patch.md @@ -0,0 +1,1547 @@ +# Phase 0 — Fun & Engagement Hotfix 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:** 출시 직후(또는 직전) v1.0 핫픽스로, 코어 시스템은 그대로 둔 채 첫 5분 후크·능동 합성 보상·감각 연출·광고 슬롯을 추가해 리텐션과 ARPU 기반을 마련한다. + +**Architecture:** +- 신규 유틸 2종(`src/lib/haptic.ts`, `src/lib/sfx.ts`), 신규 컴포넌트 3종(`DiscoveryHero`, `WelcomeGiftModal`, `AdBanner`)을 추가하고, `useGameStore`에 4개 상태(`sfxEnabled`, `hapticEnabled`, `comboCount`, `comboExpiresAt`, `welcomeGiftClaimed`)를 추가한다. +- 발견·튜토리얼 보상 트리거는 `useGameStore` 액션에서 `window.dispatchEvent` 패턴(이미 `newAchievement`로 사용 중)을 따라 컴포넌트가 listen. +- 광고 SDK 연동은 본 Phase에서 하지 않음 — `AdBanner`는 placeholder 슬롯만 확보해 Phase 1에서 SDK 어댑터를 끼울 수 있게 한다. + +**Tech Stack:** Vite 5, React 18, TypeScript 5, Zustand 5 (persist middleware), @emotion/react. 테스트 인프라 미설치 — 본 Plan은 `npm run typecheck` + 브라우저 수동 스모크 테스트로 검증한다 (테스트 프레임워크 도입은 별도 spec). + +**상위 spec:** `docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md` Phase 0 섹션. + +--- + +## File Structure + +| 파일 | 역할 | 새로 생성 / 수정 | +|---|---|---| +| `src/lib/haptic.ts` | `navigator.vibrate` 어댑터 + 패턴 상수 | **Create** | +| `src/lib/sfx.ts` | Web Audio API 합성 SFX | **Create** | +| `src/components/DiscoveryHero.tsx` | 첫 발견 풀스크린 카드 | **Create** | +| `src/components/WelcomeGiftModal.tsx` | 튜토리얼 완료 보상 모달 | **Create** | +| `src/components/AdBanner.tsx` | 광고 영역 placeholder 슬롯 | **Create** | +| `src/store/useGameStore.ts` | sfx/haptic 토글, 콤보, 럭키, 발견 이벤트, 환영 선물 | **Modify** | +| `src/components/screens/FusionScreen.tsx` | 콤보 바, 럭키 배지, SFX/햅틱/셰이크 호출 | **Modify** | +| `src/App.tsx` | 셰이크 listener, DiscoveryHero/WelcomeGiftModal/AdBanner 마운트 | **Modify** | + +--- + +## Task 0: Pre-flight — 미커밋 변경 점검 및 베이스라인 커밋 + +**Files:** +- Inspect: `src/store/useGameStore.ts`, `src/components/screens/SettingsScreen.tsx`, `src/components/screens/FusionScreen.tsx`, `src/components/screens/ShopScreen.tsx`, `src/components/screens/ElementsScreen.tsx`, `src/components/CharacterSprite.tsx` + +**컨텍스트:** 기재 시점 `git status`에 5개 파일 modified. `SettingsScreen.tsx`가 이미 `sfxEnabled`/`hapticEnabled` 토글 UI를 참조 중이지만 `useGameStore`에 해당 필드가 없어 typecheck가 깨진 상태일 수 있음. + +- [ ] **Step 1: 변경 현황 검토** + +```bash +git status +git diff src/store/useGameStore.ts +git diff src/components/screens/SettingsScreen.tsx +git diff src/components/screens/ShopScreen.tsx +git diff src/components/screens/ElementsScreen.tsx +git diff src/components/screens/FusionScreen.tsx +git diff src/components/CharacterSprite.tsx +``` + +- [ ] **Step 2: typecheck 결과 확인** + +```bash +npm run typecheck +``` + +만약 `sfxEnabled`/`hapticEnabled` 관련 에러가 발생하면 → Task 1 ~ 2에서 store 필드를 추가하면서 자연스럽게 해결됨. Step 3에서는 일단 현재 변경을 그대로 베이스라인으로 잡지 않고, 작업 트리를 그대로 두고 Task 1로 진행한다. + +만약 typecheck가 통과하면 → 미커밋 변경은 일관된 작업 단위. Step 3에서 단일 베이스라인 커밋. + +- [ ] **Step 3a (typecheck 통과 시): 베이스라인 단일 커밋** + +```bash +git add src/store/useGameStore.ts src/components/screens/SettingsScreen.tsx src/components/screens/FusionScreen.tsx src/components/screens/ShopScreen.tsx src/components/screens/ElementsScreen.tsx src/components/CharacterSprite.tsx +git commit -m "chore: baseline pre-Phase-0 in-progress changes" +``` + +- [ ] **Step 3b (typecheck 실패 시): Task 1로 진행** + +미커밋 변경을 그대로 두고 다음 Task에서 store에 누락 필드를 추가하면서 함께 커밋한다. + +- [ ] **Step 4: 베이스라인 검증** + +```bash +git status +``` + +작업 트리가 깨끗하거나(3a 경로), 또는 의도된 작업 진행 중(3b 경로)임을 확인. + +--- + +## Task 1: F-8 햅틱 진동 라이브러리 + +**Files:** +- Create: `src/lib/haptic.ts` +- Modify: `src/store/useGameStore.ts` (필드 추가, 토글 액션 추가) +- Modify: `src/components/screens/FusionScreen.tsx` (호출) + +- [ ] **Step 1: 햅틱 유틸 생성** + +새 파일 `src/lib/haptic.ts`: + +```ts +let _enabled = true; + +export function setHapticEnabled(enabled: boolean): void { + _enabled = enabled; +} + +function safeVibrate(pattern: number | number[]): void { + if (!_enabled) return; + if (typeof navigator === 'undefined') return; + const vibrate = navigator.vibrate?.bind(navigator); + if (!vibrate) return; + try { + vibrate(pattern); + } catch { + // Some browsers throw if vibrate is called from non-user-gesture context — ignore + } +} + +export const HAPTIC = { + fuse: (): void => safeVibrate(50), + fuseLucky: (): void => safeVibrate([30, 20, 30]), + legendary: (): void => safeVibrate([80, 40, 80]), + prestige: (): void => safeVibrate(200), + achievement: (): void => safeVibrate([60, 30, 60]), +}; +``` + +- [ ] **Step 2: store에 hapticEnabled 필드 + 액션 + persist 추가** + +`src/store/useGameStore.ts` 변경 — 다음 위치에 각각 한 줄/블록 추가: + +(a) 파일 상단에 import 추가 (기존 import 블록 끝): + +```ts +import { setHapticEnabled as setHapticEnabledLib } from '../lib/haptic'; +``` + +(b) `interface GameState` 안 — `bgmEnabled: boolean;` 다음 줄에 추가: + +```ts + sfxEnabled: boolean; + hapticEnabled: boolean; + setSfxEnabled: (enabled: boolean) => void; + setHapticEnabled: (enabled: boolean) => void; +``` + +(c) `type PersistedState = Pick<...>` 의 union에 추가 (기존 `'bgmEnabled'` 다음 줄): + +```ts + | 'sfxEnabled' + | 'hapticEnabled' +``` + +(d) store 본문에서 `bgmEnabled: false,` 다음 줄들에 추가: + +```ts + sfxEnabled: true, + hapticEnabled: true, + setSfxEnabled: (enabled) => set({ sfxEnabled: enabled }), + setHapticEnabled: (enabled) => { + setHapticEnabledLib(enabled); + set({ hapticEnabled: enabled }); + }, +``` + +(e) `partialize` 안에 두 줄 추가 (기존 `bgmEnabled: state.bgmEnabled,` 다음): + +```ts + sfxEnabled: state.sfxEnabled, + hapticEnabled: state.hapticEnabled, +``` + +(f) `onRehydrateStorage` 안에 — `state.activeBoosts = validBoosts;` 다음에 추가: + +```ts + if (typeof state.sfxEnabled !== 'boolean') state.sfxEnabled = true; + if (typeof state.hapticEnabled !== 'boolean') state.hapticEnabled = true; + setHapticEnabledLib(state.hapticEnabled); +``` + +- [ ] **Step 3: FusionScreen에서 합성 시 햅틱 호출** + +`src/components/screens/FusionScreen.tsx`의 import 블록에 추가: + +```ts +import { HAPTIC } from '../../lib/haptic'; +``` + +`handleFuse` 함수의 `if (result.success && result.resultId) {` 블록 안, `setIsFusing(true);` 직후에 추가: + +```ts + if (rarity === 'legendary' || rarity === 'epic') { + HAPTIC.legendary(); + } else { + HAPTIC.fuse(); + } +``` + +- [ ] **Step 4: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. (Task 0 Step 3b 경로였다면 SettingsScreen이 이제 정상 컴파일됨.) + +- [ ] **Step 5: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. 합성 화면에서 합성 1회 실행 → 모바일 디바이스에서 50ms 진동 (데스크톱 크롬은 지원 안 함, 무에러) +2. 설정 → "햅틱 진동" 토글 OFF → 다시 합성 → 진동 없음 +3. 새로고침 후 토글 상태 유지 + +- [ ] **Step 6: 커밋** + +```bash +git add src/lib/haptic.ts src/store/useGameStore.ts src/components/screens/FusionScreen.tsx +git commit -m "feat: add haptic vibration on fusion (F-8)" +``` + +(Task 0이 3b 경로였다면 미커밋 SettingsScreen·ShopScreen·ElementsScreen·CharacterSprite도 함께 staging — `git status`로 확인 후 의도된 변경만 add.) + +--- + +## Task 2: F-1 효과음 (Web Audio 합성) + +**Files:** +- Create: `src/lib/sfx.ts` +- Modify: `src/store/useGameStore.ts` (sfxEnabled lib 동기화) +- Modify: `src/components/screens/FusionScreen.tsx` (호출) + +- [ ] **Step 1: SFX 유틸 생성** + +새 파일 `src/lib/sfx.ts`: + +```ts +let _enabled = true; +let _ctx: AudioContext | null = null; + +export function setSfxEnabled(enabled: boolean): void { + _enabled = enabled; +} + +function getCtx(): AudioContext | null { + if (!_enabled) return null; + if (typeof window === 'undefined') return null; + if (_ctx === null) { + const AnyWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }; + const Ctor = window.AudioContext ?? AnyWindow.webkitAudioContext; + if (!Ctor) return null; + _ctx = new Ctor(); + } + return _ctx; +} + +interface ToneSpec { + freq: number; + durationMs: number; + type?: OscillatorType; + attackMs?: number; + releaseMs?: number; + gain?: number; +} + +function playTone(spec: ToneSpec, delayMs = 0): void { + const ctx = getCtx(); + if (!ctx) return; + const start = ctx.currentTime + delayMs / 1000; + const dur = spec.durationMs / 1000; + const attack = (spec.attackMs ?? 5) / 1000; + const release = (spec.releaseMs ?? 80) / 1000; + const peak = spec.gain ?? 0.18; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = spec.type ?? 'sine'; + osc.frequency.value = spec.freq; + gain.gain.setValueAtTime(0, start); + gain.gain.linearRampToValueAtTime(peak, start + attack); + gain.gain.linearRampToValueAtTime(0, start + dur + release); + osc.connect(gain).connect(ctx.destination); + osc.start(start); + osc.stop(start + dur + release + 0.05); +} + +function playChord(specs: ToneSpec[], spreadMs = 0): void { + specs.forEach((s, i) => playTone(s, i * spreadMs)); +} + +const SFX = { + common: (): void => playTone({ freq: 540, durationMs: 80, type: 'triangle', gain: 0.12 }), + uncommon: (): void => + playChord( + [ + { freq: 660, durationMs: 100, type: 'triangle' }, + { freq: 880, durationMs: 100, type: 'triangle' }, + ], + 35 + ), + rare: (): void => + playChord( + [ + { freq: 660, durationMs: 220, type: 'sine' }, + { freq: 990, durationMs: 220, type: 'sine' }, + ], + 40 + ), + epic: (): void => + playChord( + [ + { freq: 523, durationMs: 320, type: 'sine' }, + { freq: 659, durationMs: 320, type: 'sine' }, + { freq: 784, durationMs: 320, type: 'sine' }, + ], + 60 + ), + legendary: (): void => + playChord( + [ + { freq: 440, durationMs: 480, type: 'sine', gain: 0.22 }, + { freq: 554, durationMs: 480, type: 'sine', gain: 0.2 }, + { freq: 659, durationMs: 480, type: 'sine', gain: 0.18 }, + { freq: 880, durationMs: 480, type: 'sine', gain: 0.16 }, + ], + 70 + ), + lucky: (): void => + playChord( + [ + { freq: 988, durationMs: 120, type: 'triangle' }, + { freq: 1318, durationMs: 120, type: 'triangle' }, + ], + 50 + ), +}; + +export type RarityKey = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +export function playRaritySfx(rarity: string): void { + const fn = SFX[rarity as RarityKey]; + if (fn) fn(); +} + +export function playLuckySfx(): void { + SFX.lucky(); +} +``` + +- [ ] **Step 2: store에서 sfxEnabled lib 동기화** + +`src/store/useGameStore.ts` 상단 import에 추가 (Task 1의 haptic import 옆): + +```ts +import { setSfxEnabled as setSfxEnabledLib } from '../lib/sfx'; +``` + +기존 `setSfxEnabled` 액션을 다음으로 교체 (Task 1 Step 2(d)에서 추가한 라인): + +```ts + setSfxEnabled: (enabled) => { + setSfxEnabledLib(enabled); + set({ sfxEnabled: enabled }); + }, +``` + +`onRehydrateStorage` 안 — `setHapticEnabledLib(...)` 다음 줄에 추가: + +```ts + setSfxEnabledLib(state.sfxEnabled); +``` + +- [ ] **Step 3: FusionScreen에서 rarity별 SFX 호출** + +`src/components/screens/FusionScreen.tsx` import 블록에 추가: + +```ts +import { playRaritySfx } from '../../lib/sfx'; +``` + +`handleFuse` 함수, `setIsFusing(true);` 직후 (Task 1에서 추가한 HAPTIC 호출 옆에) 추가: + +```ts + playRaritySfx(rarity); +``` + +- [ ] **Step 4: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 5: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. 합성 (common 결과) → 짧은 "톡" 사운드 +2. 합성 (rare 결과 — 예: 증기 같은 Tier 2 결과 위쪽 합성) → 종 비슷한 화음 +3. 설정 → 효과음 OFF → 다시 합성 → 무음 +4. 새로고침 후 토글 유지 + +브라우저 첫 사용자 제스처 후에만 AudioContext가 동작 — 합성 버튼 클릭이 그 트리거 역할. 첫 클릭에서 무음일 수 있음 → 두 번째부터 정상. + +- [ ] **Step 6: 커밋** + +```bash +git add src/lib/sfx.ts src/store/useGameStore.ts src/components/screens/FusionScreen.tsx +git commit -m "feat: add rarity-based Web Audio SFX on fusion (F-1)" +``` + +--- + +## Task 3: F-2 레전더리 화면 셰이크 + +**Files:** +- Modify: `src/App.tsx` (셰이크 keyframes + state + listener) +- Modify: `src/components/screens/FusionScreen.tsx` (legendary 시 dispatch) + +- [ ] **Step 1: App.tsx에 셰이크 keyframes·상태·리스너 추가** + +`src/App.tsx` import 블록을 다음으로 교체: + +```tsx +import { css, keyframes } from '@emotion/react'; +import { useEffect, useState } from 'react'; +``` + +기존 `rootStyle` 정의 다음에 추가: + +```tsx +const screenShakeKeyframes = keyframes` + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-3px, 2px); } + 40% { transform: translate(3px, -2px); } + 60% { transform: translate(-2px, -3px); } + 80% { transform: translate(2px, 3px); } +`; + +const shakingStyle = css` + animation: ${screenShakeKeyframes} 0.4s ease-out; +`; + +const legendaryGlowKeyframes = keyframes` + 0%, 100% { box-shadow: inset 0 0 0 rgba(247, 193, 42, 0); } + 50% { box-shadow: inset 0 0 60px rgba(247, 193, 42, 0.45); } +`; + +const legendaryGlowStyle = css` + position: fixed; + inset: 0; + pointer-events: none; + z-index: 999; + animation: ${legendaryGlowKeyframes} 0.4s ease-out; +`; +``` + +`App` 함수 상단 (`const { activeTab, ... } = useGameStore();` 다음) 추가: + +```tsx + const [isShaking, setIsShaking] = useState(false); + const [isGlowing, setIsGlowing] = useState(false); + + useEffect(() => { + const handler = () => { + setIsShaking(true); + setIsGlowing(true); + window.setTimeout(() => { + setIsShaking(false); + setIsGlowing(false); + }, 400); + }; + window.addEventListener('legendaryShake', handler); + return () => window.removeEventListener('legendaryShake', handler); + }, []); +``` + +JSX의 루트 `
`을 다음으로 교체: + +```tsx +
+ {isGlowing &&
} +``` + +(닫는 `
`는 그대로) + +- [ ] **Step 2: FusionScreen에서 legendary일 때 dispatch** + +`src/components/screens/FusionScreen.tsx` `handleFuse` 안, `playRaritySfx(rarity);` 다음에 추가: + +```ts + if (rarity === 'legendary') { + window.dispatchEvent(new CustomEvent('legendaryShake')); + } +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 4: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. legendary rarity 결과를 만드는 합성 (`elements.json`의 `rarity === 'legendary'` 항목 — 예: spirit, creation 같은 최상위) 실행 +2. 화면이 0.4초간 미세하게 흔들림 + 황금색 가장자리 글로우 +3. 일반 합성 (common/rare)에서는 아무 일 없음 +4. 햅틱 + SFX + 셰이크가 동시에 트리거되는지 확인 + +테스트가 어렵다면: 임시로 FusionScreen에 디버그 버튼 추가 → `window.dispatchEvent(new CustomEvent('legendaryShake'))` 호출 → 검증 후 제거 (커밋 X). + +- [ ] **Step 5: 커밋** + +```bash +git add src/App.tsx src/components/screens/FusionScreen.tsx +git commit -m "feat: add legendary screen shake and edge glow (F-2)" +``` + +--- + +## Task 4: B-5 럭키 합성 (1% 확률 상위 티어 승급) + +**Files:** +- Modify: `src/store/useGameStore.ts` (`fuse()` 분기 + 반환 시그니처) +- Modify: `src/components/screens/FusionScreen.tsx` (LUCKY 배지 + 사운드) + +- [ ] **Step 1: store에 럭키 분기 추가** + +`src/store/useGameStore.ts` 상단 상수 영역에 추가 (`const TIER_SPAWN_RATE_MULTIPLIER` 근처): + +```ts +const LUCKY_PROC_RATE = 0.01; +const LUCKY_GOLD_MULTIPLIER = 3; +``` + +`FuseResult` 인터페이스에 `lucky?: boolean` 추가: + +```ts +export interface FuseResult { + success: boolean; + resultId?: string; + goldGained?: number; + lucky?: boolean; + error?: 'no_recipe' | 'insufficient_elements'; +} +``` + +`useGameStore` 본문 — `fuse: (slot1Id, slot2Id) => {` 함수 안의 기존 코드를 다음과 같이 수정: + +```ts + const baseResultId = recipe.result; + const baseTier = + (elementsData as Array<{ id: string; tier: number }>).find( + (e) => e.id === baseResultId + )?.tier ?? 0; + + const isLucky = Math.random() < LUCKY_PROC_RATE; + let finalResultId = baseResultId; + let goldGained = recipe.tier * 10; + + if (isLucky) { + const upgradeCandidates = (elementsData as Array<{ id: string; tier: number }>).filter( + (e) => e.tier === baseTier + 1 + ); + if (upgradeCandidates.length > 0) { + const pick = upgradeCandidates[Math.floor(Math.random() * upgradeCandidates.length)]; + finalResultId = pick.id; + goldGained *= LUCKY_GOLD_MULTIPLIER; + } + } + + const resultTier = + (elementsData as Array<{ id: string; tier: number }>).find( + (e) => e.id === finalResultId + )?.tier ?? 0; +``` + +(기존 `const goldGained = recipe.tier * 10;` 와 `const resultTier = ...` 줄을 제거 — 위 코드로 대체) + +`set((state) => { ... })` 블록 안에서 `next[recipe.result] = (next[recipe.result] ?? 0) + 1;` 줄을 다음으로 변경: + +```ts + next[finalResultId] = (next[finalResultId] ?? 0) + 1; +``` + +`getDiscoveredElementIds` 호출도 변경: + +```ts + const discoveredElements = getDiscoveredElementIds( + [...state.discoveredElements, finalResultId], + next + ); +``` + +함수 마지막 return 변경: + +```ts + return { success: true, resultId: finalResultId, goldGained, lucky: isLucky }; +``` + +- [ ] **Step 2: FusionScreen에서 LUCKY 배지 + 사운드** + +`src/components/screens/FusionScreen.tsx` import 블록에서 sfx import 확장: + +```ts +import { playRaritySfx, playLuckySfx } from '../../lib/sfx'; +``` + +`useState`에 `lastResult` 타입 확장 (기존 useState 줄): + +```ts + const [lastResult, setLastResult] = useState<{ + type: 'success' | 'error'; + resultId?: string; + goldGained?: number; + lucky?: boolean; + message?: string; + } | null>(null); +``` + +`handleFuse` 안, `setLastResult({ type: 'success', ... })` 부분을 다음으로 교체: + +```ts + setLastResult({ + type: 'success', + resultId: result.resultId, + goldGained: result.goldGained, + lucky: result.lucky, + }); +``` + +`playRaritySfx(rarity);` 다음에 추가: + +```ts + if (result.lucky) { + playLuckySfx(); + } +``` + +result banner JSX (`{lastResult?.type === 'success' && lastResult.resultId && (` 블록 안) 의 `NEW` 줄을 다음으로 교체: + +```tsx + {lastResult.lucky ? '🍀 LUCKY' : 'NEW'} +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 4: 브라우저 수동 검증 (확률 검증을 위해 임시 디버그)** + +확률 1%는 수동 검증이 어려움. 다음 중 하나로 검증: + +(A) `LUCKY_PROC_RATE`를 임시로 `1.0`으로 변경 → 합성 1회 → "🍀 LUCKY" 배지 + 골드 3배 + 결과가 한 단계 위 원소(예: 불+물 → 보통 증기지만 럭키면 Tier 3 결과) 확인 → `0.01`로 복원, 임시 변경 커밋 X. + +(B) 브라우저 콘솔에서 `for (let i=0; i<200; i++) useGameStore.getState().fuse('fire', 'water')` 실행 → 인벤토리에서 Tier 3 원소가 의도치 않게 늘어났는지 확인. + +```bash +npm run dev +``` + +확인: +1. (A) 방식 럭키 강제 → 배지 표시·골드 3배·상위 티어 결과 +2. (A) 후 복원 → 다시 일반 합성에서 "NEW" 배지 + +- [ ] **Step 5: 커밋** + +```bash +git add src/store/useGameStore.ts src/components/screens/FusionScreen.tsx +git commit -m "feat: add 1% lucky fusion proc with tier upgrade (B-5)" +``` + +--- + +## Task 5: B-1 합성 콤보 미터 + +**Files:** +- Modify: `src/store/useGameStore.ts` (콤보 상태 + `fuse()` 적용) +- Modify: `src/components/screens/FusionScreen.tsx` (콤보 바 UI) + +**규칙 (spec):** 직전 합성 후 8초 이내 재합성 시 콤보 누적. 3콤보 x1.5 / 6콤보 x2 / 10콤보 x3 (cap). 8초 무합성 시 리셋. + +- [ ] **Step 1: store에 콤보 상태 + 적용** + +`src/store/useGameStore.ts` 상단 상수 영역에 추가: + +```ts +const COMBO_WINDOW_MS = 8_000; + +function comboMultiplier(count: number): number { + if (count >= 10) return 3; + if (count >= 6) return 2; + if (count >= 3) return 1.5; + return 1; +} +``` + +`interface GameState` 안 (`fuse: (...) => FuseResult;` 근처)에 추가: + +```ts + comboCount: number; + comboExpiresAt: number; +``` + +`type PersistedState = Pick<...>` union에 추가: + +```ts + | 'comboCount' + | 'comboExpiresAt' +``` + +store 본문 초기값 추가 (zustand `(set, get) => ({ ... })` 본문에 다른 초기값들과 함께): + +```ts + comboCount: 0, + comboExpiresAt: 0, +``` + +`partialize` 안에 두 줄 추가: + +```ts + comboCount: state.comboCount, + comboExpiresAt: state.comboExpiresAt, +``` + +`onRehydrateStorage` 안에 추가 (다른 보정 코드 옆): + +```ts + if (typeof state.comboCount !== 'number') state.comboCount = 0; + if (typeof state.comboExpiresAt !== 'number') state.comboExpiresAt = 0; + if (state.comboExpiresAt < Date.now()) { + state.comboCount = 0; + state.comboExpiresAt = 0; + } +``` + +`fuse` 함수 안 — Task 4에서 만든 `let goldGained = recipe.tier * 10;` 가 있는 위치 직후, 럭키 적용 직전에 추가: + +```ts + const now = Date.now(); + const previousCombo = get().comboCount; + const previousExpiresAt = get().comboExpiresAt; + const continuingCombo = now < previousExpiresAt; + const newComboCount = continuingCombo ? Math.min(previousCombo + 1, 99) : 1; + const comboMult = comboMultiplier(newComboCount); + goldGained = Math.floor(goldGained * comboMult); +``` + +`set((state) => { ... return { ... }; })` 의 return 객체에 다음 두 줄 추가 (다른 키들과 함께): + +```ts + comboCount: newComboCount, + comboExpiresAt: now + COMBO_WINDOW_MS, +``` + +- [ ] **Step 2: FusionScreen에 콤보 바 UI** + +`src/components/screens/FusionScreen.tsx` 의 `useGameStore()` destructure를 수정: + +```ts + const { elements, fuse, comboCount, comboExpiresAt } = useGameStore(); +``` + +`useState` 영역에 추가 (다른 useState 옆): + +```ts + const [, forceTick] = useState(0); +``` + +`useFloatingItems` 호출 다음에 추가: + +```ts + useEffect(() => { + if (comboExpiresAt < Date.now()) return; + const id = window.setInterval(() => forceTick((t) => t + 1), 100); + return () => window.clearInterval(id); + }, [comboExpiresAt]); +``` + +`useEffect` import 추가 (`useState` 옆): + +```ts +import { useEffect, useState } from 'react'; +``` + +스타일 영역에 추가 (예: `errorBannerStyle` 다음): + +```ts +const comboBarStyle = css` + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #ff6b35, #f7c12a); + border-radius: 10px; + padding: 7px 12px; + margin-bottom: 10px; + color: #fff; + font-weight: 800; + font-size: 13px; +`; + +const comboCountdownStyle = css` + margin-left: auto; + font-size: 11px; + opacity: 0.85; +`; + +const comboTrackStyle = css` + height: 4px; + background: rgba(255, 255, 255, 0.25); + border-radius: 999px; + overflow: hidden; + margin-bottom: 10px; +`; + +const comboFillStyle = (ratio: number) => css` + height: 100%; + width: ${(Math.max(0, Math.min(1, ratio)) * 100).toFixed(1)}%; + background: #ffffff; + border-radius: 999px; + transition: width 0.1s linear; +`; +``` + +`return` JSX 안 — 합성 패널의 `previewBannerStyle` 영역 위 (또는 `slotsRowStyle` 다음)에 다음 추가: + +```tsx + {comboCount >= 2 && comboExpiresAt > Date.now() && (() => { + const remaining = Math.max(0, comboExpiresAt - Date.now()); + const ratio = remaining / 8000; + const mult = comboCount >= 10 ? 3 : comboCount >= 6 ? 2 : comboCount >= 3 ? 1.5 : 1; + return ( + <> +
+ 🔥 {comboCount} COMBO + 골드 x{mult} + {(remaining / 1000).toFixed(1)}s +
+
+
+
+ + ); + })()} +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 4: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. 합성 화면에서 8초 내에 연속 합성 3번 → 콤보 바 표시 + "🔥 3 COMBO 골드 x1.5" +2. 6번째 합성 → x2, 10번째 → x3 +3. 8초 대기 후 합성 → 콤보 1로 리셋 +4. 합성 사이에 `+골드` 플로팅이 콤보 배율로 늘어나는지 (육안) +5. 페이지 새로고침 후 콤보 만료됐다면 0으로 초기화 + +- [ ] **Step 5: 커밋** + +```bash +git add src/store/useGameStore.ts src/components/screens/FusionScreen.tsx +git commit -m "feat: add fusion combo meter with x1.5/x2/x3 gold multiplier (B-1)" +``` + +--- + +## Task 6: A-3 첫 발견 풀스크린 카드 + +**Files:** +- Create: `src/components/DiscoveryHero.tsx` +- Modify: `src/store/useGameStore.ts` (newDiscovery 이벤트 dispatch) +- Modify: `src/App.tsx` (마운트) + +- [ ] **Step 1: DiscoveryHero 컴포넌트 생성** + +새 파일 `src/components/DiscoveryHero.tsx`: + +```tsx +import { css, keyframes } from '@emotion/react'; +import { useEffect, useState } from 'react'; +import elementsData from '../data/elements.json'; +import { CharacterSprite } from './CharacterSprite'; + +type ElementData = (typeof elementsData)[number]; + +const HERO_DURATION_MS = 1200; + +const fadeIn = keyframes` + 0% { opacity: 0; } + 20% { opacity: 1; } + 80% { opacity: 1; } + 100% { opacity: 0; } +`; + +const popIn = keyframes` + 0% { transform: scale(0.4); opacity: 0; } + 40% { transform: scale(1.2); opacity: 1; } + 60% { transform: scale(0.95); } + 100% { transform: scale(1); opacity: 1; } +`; + +const overlayStyle = css` + position: fixed; + inset: 0; + background: rgba(8, 12, 24, 0.78); + backdrop-filter: blur(6px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 500; + animation: ${fadeIn} ${HERO_DURATION_MS}ms ease-out forwards; + cursor: pointer; +`; + +const cardStyle = css` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + animation: ${popIn} 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +`; + +const spriteWrapStyle = css` + width: 120px; + height: 120px; +`; + +const eyebrowStyle = css` + font-size: 12px; + font-weight: 700; + color: #f7c12a; + letter-spacing: 4px; + text-transform: uppercase; +`; + +const nameStyle = css` + font-size: 32px; + font-weight: 900; + color: #ffffff; + text-shadow: 0 2px 12px rgba(247, 193, 42, 0.5); +`; + +const subtitleStyle = css` + font-size: 13px; + color: rgba(255, 255, 255, 0.75); + margin-top: 4px; +`; + +interface DiscoveryDetail { + elementId: string; +} + +export function DiscoveryHero(): JSX.Element | null { + const [active, setActive] = useState(null); + + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + const el = elementsData.find((x) => x.id === detail.elementId); + if (!el) return; + setActive(el); + window.setTimeout(() => setActive(null), HERO_DURATION_MS); + }; + window.addEventListener('newDiscovery', handler); + return () => window.removeEventListener('newDiscovery', handler); + }, []); + + if (!active) return null; + + return ( +
setActive(null)}> +
+ NEW DISCOVERY +
+ +
+
{active.name}
+
창조자여, 새로운 빛을 발견했군요
+
+
+ ); +} +``` + +- [ ] **Step 2: store에서 newDiscovery 이벤트 dispatch** + +`src/store/useGameStore.ts`의 헬퍼 함수 영역 (예: `getDiscoveredFromElements` 다음)에 추가: + +```ts +function dispatchNewDiscoveries(prevDiscovered: string[], nextDiscovered: string[]): void { + if (typeof window === 'undefined') return; + const prevSet = new Set(prevDiscovered); + for (const id of nextDiscovered) { + if (!prevSet.has(id)) { + window.dispatchEvent( + new CustomEvent('newDiscovery', { detail: { elementId: id } }) + ); + } + } +} +``` + +다음 액션들에서 `discoveredElements` 가 갱신되는 직후 dispatch 호출: + +(a) `addElement` 안 — `const discoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가: + +```ts + dispatchNewDiscoveries(state.discoveredElements, discoveredElements); +``` + +(b) `fuse` 안 — `const discoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가: + +```ts + dispatchNewDiscoveries(state.discoveredElements, discoveredElements); +``` + +(c) `claimOfflineReward` 안 — `const newDiscoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가: + +```ts + dispatchNewDiscoveries(state.discoveredElements, newDiscoveredElements); +``` + +(d) `tickIdle` 안 — `const newDiscoveredElements = getDiscoveredElementIds(...)` 다음 줄에 추가: + +```ts + dispatchNewDiscoveries(state.discoveredElements, newDiscoveredElements); +``` + +- [ ] **Step 3: App.tsx에 DiscoveryHero 마운트** + +`src/App.tsx` import 추가: + +```tsx +import { DiscoveryHero } from './components/DiscoveryHero'; +``` + +JSX에서 `` 다음 줄에 추가: + +```tsx + +``` + +- [ ] **Step 4: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 5: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. 새로 합성으로 처음 발견하는 원소(예: 새 게임 시작 후 fire+water=steam) → 풀스크린 카드 1.2초간 표시 +2. 같은 원소 재합성 → 카드 미표시 (이미 발견) +3. 카드 클릭하면 즉시 닫힘 +4. 자동생산으로 새 원소 첫 발견 시에도 동일 (idle tick에서) + +- [ ] **Step 6: 커밋** + +```bash +git add src/components/DiscoveryHero.tsx src/store/useGameStore.ts src/App.tsx +git commit -m "feat: add full-screen discovery hero card on first element find (A-3)" +``` + +--- + +## Task 7: A-4 튜토리얼 완료 보상 모달 + +**Files:** +- Create: `src/components/WelcomeGiftModal.tsx` +- Modify: `src/store/useGameStore.ts` (`welcomeGiftClaimed`, `claimWelcomeGift`) +- Modify: `src/App.tsx` (조건부 마운트) + +- [ ] **Step 1: store에 환영 선물 상태 + 액션** + +`src/store/useGameStore.ts`: + +`interface GameState` 안 — 튜토리얼 영역 근처에 추가: + +```ts + welcomeGiftClaimed: boolean; + welcomeGiftElementId: string | null; + claimWelcomeGift: () => void; +``` + +`type PersistedState = Pick<...>` union에 추가: + +```ts + | 'welcomeGiftClaimed' + | 'welcomeGiftElementId' +``` + +store 본문 초기값: + +```ts + welcomeGiftClaimed: false, + welcomeGiftElementId: null, + + claimWelcomeGift: () => { + set((state) => { + if (state.welcomeGiftClaimed) return state; + const id = state.welcomeGiftElementId; + if (!id) return state; + const newElements = { + ...state.elements, + [id]: (state.elements[id] ?? 0) + 1, + }; + const newDiscovered = getDiscoveredElementIds( + [...state.discoveredElements, id], + newElements + ); + dispatchNewDiscoveries(state.discoveredElements, newDiscovered); + return { + welcomeGiftClaimed: true, + elements: newElements, + discoveredElements: newDiscovered, + }; + }); + }, +``` + +`partialize` 안 추가: + +```ts + welcomeGiftClaimed: state.welcomeGiftClaimed, + welcomeGiftElementId: state.welcomeGiftElementId, +``` + +`onRehydrateStorage` 안 추가: + +```ts + if (typeof state.welcomeGiftClaimed !== 'boolean') state.welcomeGiftClaimed = false; + if (typeof state.welcomeGiftElementId !== 'string' && state.welcomeGiftElementId !== null) { + state.welcomeGiftElementId = null; + } +``` + +- [ ] **Step 2: WelcomeGiftModal 생성** + +새 파일 `src/components/WelcomeGiftModal.tsx`: + +```tsx +import { css, keyframes } from '@emotion/react'; +import { useGameStore } from '../store/useGameStore'; +import elementsData from '../data/elements.json'; +import { CharacterSprite } from './CharacterSprite'; + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +`; + +const slideUp = keyframes` + from { transform: translateY(40px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +`; + +const overlayStyle = css` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 450; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: ${fadeIn} 0.2s ease-out; +`; + +const cardStyle = css` + background: linear-gradient(160deg, #ffffff, #fff8ed); + border-radius: 24px; + padding: 28px 24px; + width: 100%; + max-width: 360px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); + animation: ${slideUp} 0.32s cubic-bezier(0.34, 1.56, 0.64, 1); +`; + +const eyebrowStyle = css` + font-size: 11px; + font-weight: 800; + color: #ff6b35; + letter-spacing: 3px; +`; + +const titleStyle = css` + font-size: 22px; + font-weight: 800; + color: #191919; + text-align: center; +`; + +const descStyle = css` + font-size: 13px; + color: #6b7684; + text-align: center; + line-height: 1.5; +`; + +const spriteWrapStyle = css` + width: 96px; + height: 96px; +`; + +const giftNameStyle = css` + font-size: 18px; + font-weight: 800; + color: #191919; +`; + +const claimButtonStyle = css` + width: 100%; + margin-top: 8px; + padding: 14px; + border-radius: 14px; + border: none; + background: linear-gradient(135deg, #ff6b35, #f7c12a); + color: #ffffff; + font-size: 15px; + font-weight: 800; + cursor: pointer; +`; + +export function WelcomeGiftModal(): JSX.Element | null { + const tutorialCompleted = useGameStore((s) => s.tutorialCompleted); + const welcomeGiftClaimed = useGameStore((s) => s.welcomeGiftClaimed); + const welcomeGiftElementId = useGameStore((s) => s.welcomeGiftElementId); + const claimWelcomeGift = useGameStore((s) => s.claimWelcomeGift); + + if (!tutorialCompleted) return null; + if (welcomeGiftClaimed) return null; + if (!welcomeGiftElementId) return null; + + const el = elementsData.find((x) => x.id === welcomeGiftElementId); + if (!el) return null; + + return ( +
+
+ WELCOME GIFT +
환영 선물이 도착했어요
+
+ 첫 여정을 축하합니다.{'\n'}창조의 다음 단계로 도와줄 원소 한 개를 받으세요. +
+
+ +
+
{el.emoji} {el.name} ×1
+ +
+
+ ); +} +``` + +- [ ] **Step 3: store의 advanceTutorial이 마지막 단계 도달 시 prepareWelcomeGift 호출** + +`src/store/useGameStore.ts` 의 `advanceTutorial` 액션을 다음으로 교체: + +```ts + advanceTutorial: () => + set((state) => { + const nextStep = state.tutorialStep + 1; + const completed = nextStep >= 5; + if (completed && !state.welcomeGiftClaimed && !state.welcomeGiftElementId) { + const tier2 = (elementsData as Array<{ id: string; tier: number }>).filter( + (e) => e.tier === 2 + ); + if (tier2.length > 0) { + const pick = tier2[Math.floor(Math.random() * tier2.length)]; + return { + tutorialStep: nextStep, + tutorialCompleted: completed, + welcomeGiftElementId: pick.id, + }; + } + } + return { + tutorialStep: nextStep, + tutorialCompleted: completed, + }; + }), +``` + +`skipTutorial`도 마찬가지로: + +```ts + skipTutorial: () => + set((state) => { + if (state.welcomeGiftClaimed || state.welcomeGiftElementId) { + return { tutorialStep: 5, tutorialCompleted: true }; + } + const tier2 = (elementsData as Array<{ id: string; tier: number }>).filter( + (e) => e.tier === 2 + ); + if (tier2.length === 0) { + return { tutorialStep: 5, tutorialCompleted: true }; + } + const pick = tier2[Math.floor(Math.random() * tier2.length)]; + return { + tutorialStep: 5, + tutorialCompleted: true, + welcomeGiftElementId: pick.id, + }; + }), +``` + +- [ ] **Step 4: App.tsx에 마운트** + +`src/App.tsx` import: + +```tsx +import { WelcomeGiftModal } from './components/WelcomeGiftModal'; +``` + +JSX 안, `` 다음에: + +```tsx + +``` + +- [ ] **Step 5: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 6: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인 (게임 데이터 초기화 권장 — 설정 화면에서 리셋): +1. 새 게임 → 튜토리얼 5단계 완주 → "환영 선물" 모달 등장 +2. 받기 클릭 → 인벤토리에 Tier 2 원소 1개 추가 + DiscoveryHero 카드 등장 (Task 6과 연동) +3. 새로고침 → 모달 다시 안 뜸 +4. 튜토리얼 스킵 → 동일하게 작동 + +- [ ] **Step 7: 커밋** + +```bash +git add src/components/WelcomeGiftModal.tsx src/store/useGameStore.ts src/App.tsx +git commit -m "feat: add welcome gift modal after tutorial completion (A-4)" +``` + +--- + +## Task 8: E-7 광고 배너 영역 슬롯 + +**Files:** +- Create: `src/components/AdBanner.tsx` +- Modify: `src/App.tsx` (마운트 + 레이아웃 보정) + +**참고:** `BottomTabBar`는 `position: fixed; bottom: 0`. AdBanner도 동일한 방식으로 BottomTabBar 위에 고정 + content padding-bottom 증가. + +- [ ] **Step 1: AdBanner placeholder 생성** + +새 파일 `src/components/AdBanner.tsx`: + +```tsx +import { css } from '@emotion/react'; +import { adaptive } from '../styles/adaptive'; + +export const AD_BANNER_HEIGHT = 56; + +const containerStyle = css` + position: fixed; + left: 0; + right: 0; + bottom: calc(72px + env(safe-area-inset-bottom, 0px)); + height: ${AD_BANNER_HEIGHT}px; + background: ${adaptive.greyBackground}; + border-top: 1px solid ${adaptive.grey200}; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: ${adaptive.grey400}; + letter-spacing: 1px; + z-index: 90; +`; + +export function AdBanner(): JSX.Element { + // Phase 0: 광고 SDK 미연동, placeholder 슬롯만 확보. + // Phase 1에서 src/platform/ads.ts 어댑터 + 실제 SDK로 교체 예정. + return
광고 영역
; +} +``` + +- [ ] **Step 2: App.tsx에 마운트 + content padding 보정** + +`src/App.tsx` import 추가: + +```tsx +import { AdBanner, AD_BANNER_HEIGHT } from './components/AdBanner'; +``` + +기존 `contentStyle` 상수를 다음으로 교체: + +```tsx +const contentStyle = css` + flex: 1; + overflow-y: auto; + padding-bottom: calc(72px + ${AD_BANNER_HEIGHT}px); +`; +``` + +JSX에서 `` 직전에 추가: + +```tsx + +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 4: 브라우저 수동 검증** + +```bash +npm run dev +``` + +확인: +1. 화면 하단에 "광고 영역" placeholder가 BottomTabBar 위 56px 슬롯으로 표시 +2. 콘텐츠 영역(원소·합성 화면) 스크롤이 placeholder에 가려지지 않음 (가장 아래 카드까지 스크롤로 도달) +3. 모바일 노치 디바이스에서 safe-area-inset-bottom 적용 (Pretendard iOS 시뮬레이터 가능 시) + +- [ ] **Step 5: 커밋** + +```bash +git add src/components/AdBanner.tsx src/App.tsx +git commit -m "feat: reserve ad banner slot above bottom tab bar (E-7)" +``` + +--- + +## Final Verification + +- [ ] **Step 1: 통합 typecheck** + +```bash +npm run typecheck +``` + +기대: PASS. + +- [ ] **Step 2: 통합 빌드** + +```bash +npm run build +``` + +기대: 성공. 산출물 `dist/`. + +- [ ] **Step 3: 통합 스모크 테스트 (브라우저)** + +```bash +npm run dev +``` + +체크리스트: +- [ ] 새 게임 시작 → 튜토리얼 진행 → 완료 시 환영 선물 모달 등장 +- [ ] 환영 선물 받기 → DiscoveryHero 카드 표시 +- [ ] 합성 → SFX 들림 + 모바일에서 진동 +- [ ] 30초 내 합성 3회 → 콤보 바 표시 + x1.5 골드 +- [ ] 신규 원소 합성 → DiscoveryHero 카드 +- [ ] (강제 럭키 트리거하여) LUCKY 배지 + 사운드 +- [ ] 레전더리 합성 → 화면 셰이크 + 황금 글로우 +- [ ] 광고 영역 placeholder가 BottomTabBar 위에 보임 +- [ ] 설정 → SFX/햅틱 토글 OFF/ON 정상 + +- [ ] **Step 4: 커밋 로그 확인** + +```bash +git log --oneline -10 +``` + +기대: 8~9개의 새 커밋 (Task 0 베이스라인 1 + Task 1~8). + +--- + +## Self-Review Notes + +본 plan에서 의도적으로 다루지 않은 항목 (Phase 1+로 미룸): +- 광고 SDK 실제 연동 (E-1, E-2 등) — `AdBanner`는 슬롯만 +- 사운드 파일 외부 자산 도입 — Web Audio 합성으로 충분 (Phase 1에서 mp3 교체) +- 콤보 도달 SFX — 단순 골드 배율만 적용 (필요 시 후속) +- 럭키 합성의 시각 이펙트 강화 — 배지·SFX만 +- 원소 도감(Codex), 일일 미션 등 다른 Phase 0 외 항목 + +본 plan에서 도입하는 새 의존성: 없음. 기존 React/Zustand/Emotion 만 사용. diff --git a/docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md b/docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md new file mode 100644 index 0000000..1a9aca3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md @@ -0,0 +1,332 @@ +# Archetype-FirstSpark — 재미·리텐션·수익화 전면 개선안 + +- **작성일:** 2026-04-28 +- **출시 타겟:** 2026-04-30 (Paperclip JSA-2) +- **선결 조건:** 현재 미커밋 변경 (`CharacterSprite.tsx`, `ElementsScreen.tsx`, `FusionScreen.tsx`, `ShopScreen.tsx`, `useGameStore.ts`) 정리·커밋 후 작업 시작 +- **상위 spec:** `2026-04-27-web-engine-pivot-design.md` (웹 PWA 전환 + 광고 수익화) + +--- + +## 1. 문제 정의 + +코어 시스템(합성, 자동생산, 강화, 공명, 프레스티지, 업적, 부스트, 오프라인 보상, 튜토리얼)은 모두 구현돼 있지만, **각 시스템이 플레이어의 "감정 곡선"과 강하게 연결돼 있지 않다.** 다음 세 가지 갭이 핵심: + +| 구간 | 증상 | 원인 | +|---|---|---| +| 첫 5분 | 튜토리얼이 끝나면 "그래서 뭐?" 이탈 위험 | 첫 합성·발견 모먼트 연출 약함, 자동생산이 너무 빨리 손을 대신함 | +| Tier 3~4 중반 | 자동생산만 보고 있게 됨 | 능동 행동에 보상이 없음, 일일 챌린지/이벤트 부재 | +| 프레스티지 후 | "다음 목표가 뭐지?" | 콜렉션·도감·계보·시즌 등 메타게임 깊이 부재 | + +추가로 **광고 수익화 동선이 0**이고, **연출(SFX·햅틱·이펙트)이 시스템 임팩트보다 약하다.** + +--- + +## 2. 개선 목표 + +1. **첫 세션 리텐션** — 튜토리얼 5분 완주 → 다음 날 재방문 +2. **세션당 능동 행동** — 자동생산 외 능동 인터랙션 분당 횟수 ↑ +3. **광고 노출 자연스러움** — 강제 인터스티셜 0, 리워드형만, 일일 광고 시청률 1+ +4. **장기 후크 1개 이상** — 프레스티지 후에도 "그 다음" 명확 + +--- + +## 3. 카테고리별 개선안 + +표기: 💡 출시 전 끼울 수 있음 (수 시간) / 🔧 v1.1 (수일) / 🏔 v1.2+ (큰 그림) + +--- + +### A. 첫 5분의 후크 + +#### A-1. 콜드오픈 시네마틱 1.5초 🔧 +- **무엇:** 검은 화면 → 4원소 차례로 등장 → "당신이 세상의 첫 불꽃입니다" 한 줄 → 게임 진입 +- **왜:** 무드 설정. 단순 idle 게임이 아니라 "창조자 서사"라는 톤 알림 +- **어디:** `App.tsx` 진입점 + 새 컴포넌트 `IntroSplash.tsx`. 첫 1회만 (`tutorialStep === 0` 플래그 사용) + +#### A-2. 첫 합성 강제 페이싱 🔧 +- **무엇:** 첫 60초간 자동생산 OFF, 4원소 중 손으로 1번 합성하도록 유도 +- **왜:** 손이 시스템을 학습. 자동생산이 학습을 빼앗는 것 방지 +- **어디:** `useGameStore.tickIdle` — `tutorialCompleted=false && tutorialStep<=2`일 때 spawn skip. 튜토리얼 step에 합성 1회 강제 추가 + +#### A-3. 첫 발견 풀스크린 카드 💡 +- **무엇:** 새 원소 첫 발견 시 0.8초 풀스크린 카드 (캐릭터 + 이름 + "Ngth 발견" 가짜 카운터) +- **왜:** 도파민 임펙트. 합성 결과 배너만으로는 약함 +- **어디:** `FusionScreen.tsx` 결과 처리부에 `discoveredElements`에 새로 추가됐는지 확인 후 모달 트리거. 새 컴포넌트 `DiscoveryHero.tsx` + +#### A-4. 튜토리얼 완료 보상 🎁 💡 +- **무엇:** 5단계 튜토리얼 끝에 "환영 선물" 모션 + Tier 2 원소 1개 랜덤 지급 +- **왜:** 첫 5분 끝에 명확한 "받았다" 감각 +- **어디:** `useGameStore.advanceTutorial`에서 `nextStep === 5` 분기. 새 액션 `claimWelcomeGift` + +#### A-5. 미발견 도감 슬롯 강화 💡 +- **무엇:** 미발견 원소 카드를 "?"가 아닌 실루엣 + 잠금 해제 힌트 ("불 + ? 조합으로 발견 가능") +- **왜:** 빈 슬롯이 시각적 갈증을 만들어야 함 +- **어디:** `ElementsScreen.tsx` `undiscoveredCardStyle` + `CharacterSprite` `state="undiscovered"` 분기. 힌트는 `recipes.json`을 거꾸로 매칭 + +#### A-6. 데일리 보너스 시드 🔧 +- **무엇:** 첫 접속 시 다음 날 "어제 못 받은 보상이 있습니다" 알림 만들기 — `lastLoginAt` 추가 +- **왜:** D+1 리텐션 직접 자극 +- **어디:** `useGameStore` 상태에 `lastLoginAt`, `dailyStreak` 추가. `OfflineRewardModal`을 `DailyBonusModal`로 확장 + +--- + +### B. 중반 정체기 해소 + +#### B-1. 합성 콤보 미터 💡 +- **무엇:** 직전 합성 후 8초 이내 재합성 시 콤보 누적. 임계: 3콤보 x1.5 / 6콤보 x2 / 10콤보 x3 (이후 캡). 8초 무합성 시 리셋 +- **왜:** 능동 합성을 보상. "한 번 더" 도파민 +- **어디:** `useGameStore`에 `comboCount`, `comboExpiresAt` 추가. `fuse()`에서 갱신, `goldGained` 곱셈. `FusionScreen` 상단에 콤보 바 + 남은 시간 카운트다운 + +#### B-2. 탭-투-수확 💡 +- **무엇:** 자동생산 progress 바가 100%일 때 탭하면 즉시 수확 + 1.5x 보너스 +- **왜:** 정체기에 손을 다시 게임에 묶음. 진행바는 이미 있음 +- **어디:** `ElementsScreen.tsx` `producerLaneStyle` 클릭 핸들러. progress가 `>= 0.95`일 때만 활성화 + +#### B-3. 일일 미션 3종 🔧 +- **무엇:** 매일 0시 갱신. "합성 30번 / 새 원소 3종 / 골드 10K". 모두 완료 시 부스트 1시간 +- **왜:** 매일 접속 동기. 출시 직후 D+1~D+7 리텐션의 80% +- **어디:** 새 데이터 `daily-missions.json`. `useGameStore`에 `dailyMissions`, `dailyMissionsResetAt`. 새 화면 슬롯 또는 BottomTab 신설 + +#### B-4. Element Storm 이벤트 🔧 +- **무엇:** 6시간마다 5분간 랜덤 원소 1종 스폰 x5 +- **왜:** "지금 들어와야 한다" 시드. 푸시 알림 없이도 작동 +- **어디:** `useGameStore`에 `activeStorm: {elementId, expiresAt}`. `tickIdle`에서 해당 원소 스폰 배율. 상단 배너 컴포넌트 + +#### B-5. 럭키 합성 💡 +- **무엇:** 1% 확률로 같은 재료에서 한 단계 위 원소 등장 (합성 결과의 result.tier+1) +- **왜:** 임계 임팩트. "이번엔 뭐가 나올까" 기대치 +- **어디:** `useGameStore.fuse()` 내부, recipe 매칭 후 1% 분기. 결과 화면에 "🍀 LUCKY" 배지. SFX 별도 + +#### B-6. 빌드 분기 슬롯 🏔 +- **무엇:** Tier 4 도달 시 "골드형 / 수집형 / 공명형" 영구 1개 선택 (각 +20% 보너스) +- **왜:** 의식 + 정체성. 프레스티지마다 빌드 바꿀 수도 있게 +- **어디:** `useGameStore`에 `selectedBuild`. 새 모달 `BuildChoiceModal.tsx`. 적용은 `tickIdle` 배수에 합성 + +#### B-7. 결투 미니게임 🔧 +- **무엇:** 두 원소 카드를 부딪히면 1탭 가위바위보식 단판 → 승자 흡수, 골드 배수 보상 +- **왜:** 1분 미만 액티브 미니게임. 손맛 채움 +- **어디:** 새 화면 `DuelScreen.tsx`. BottomTab 또는 Shop 내 메뉴. `useGameStore.duel(a, b)` 액션 + +--- + +### C. 장기 메타게임 깊이 + +#### C-1. 원소 도감 (Codex) v0 💡 +- **무엇:** 원소별 한 줄 플레이버 텍스트 + "수집 합계 / 강화 합계 / 첫 발견 시각" 통계 +- **왜:** 컬렉션 동기 부여. 출시 직전에도 가능 +- **어디:** `elements.json`에 `lore: string` 필드 추가. `AchievementsScreen` 또는 `ElementsScreen` 상세 모달에 탭 추가 + +#### C-2. 캐릭터 진화 비주얼 🏔 +- **무엇:** 같은 원소를 N번 강화/합성 시 SVG 변형 잠금해제 (수염, 후광, 파편 추가) +- **왜:** 컬렉션 + 시간 투자 시각화 +- **어디:** `CharacterSprite.tsx`에 `evolutionStage` prop 추가. `useGameStore`에 원소별 `usageCount` 카운터 + +#### C-3. Tier 6 신화급 🔧 +- **무엇:** 프레스티지 3회 후 추가 4종 등장. 합성 트리 끝단 +- **왜:** 출시 후 첫 콘텐츠 패치 후크 +- **어디:** `elements.json` + `recipes.json`에 데이터 추가. `unlockCondition`에 `prestige:N` 형식 추가 + +#### C-4. 시즌 한정 원소 🏔 +- **무엇:** 매월 1종 한정. 못 얻으면 영원히 못 얻음 +- **왜:** 소장 가치. 장기 유저 retention +- **어디:** `elements.json`에 `availableFrom`, `availableTo` ISO 필드. 게임 시작 시 시즌 체크 + +#### C-5. 무한 챌린지 모드 🏔 +- **무엇:** 프레스티지 N회 이상 시 "심연" 잠금해제. 합성 비용 누진, 무한 깊이 +- **왜:** 엔드게임 콘텐츠 + +#### C-6. 부모-자식 계보 트리 🔧 +- **무엇:** "이 원소는 ◯+◯에서 태어났습니다 → 이 원소는 ◯+◯의 재료가 됩니다" 시각화 +- **왜:** 합성 트리 자체가 컨텐츠. 다음 합성 동기 +- **어디:** 새 화면 또는 도감 모달 내 탭. `recipes.json` 양방향 매핑 한 번만 + +--- + +### D. 소셜·바이럴 훅 + +#### D-1. 합성 결과 공유 이미지 🔧 +- **무엇:** 캔버스로 "Ember + Mist = Steam 발견!" 카드 자동 생성 → 시스템 공유 시트 +- **왜:** "내 발견을 자랑". 유기적 유입 +- **어디:** 새 유틸 `src/lib/shareCard.ts` (canvas 2D). `navigator.share()` 우선, 실패 시 다운로드 + +#### D-2. 스크린샷 명예의 전당 🔧 +- **무엇:** 프레스티지/레전더리 발견 시 자동 캡처 → 갤러리 탭 +- **왜:** "내 여정" 자료 축적 +- **어디:** `useGameStore`에 `screenshots: string[]` (base64 또는 IndexedDB). 새 화면 `GalleryScreen.tsx` + +#### D-3. 친구 코드 🔧 +- **무엇:** 6자리 코드 입력 시 양쪽 1회 부스트. 서버 없이 URL 파라미터로 +- **왜:** 친구 초대 = 가장 강력한 수익성 채널 +- **어디:** `?ref=ABC123` URL 파라미터 → `useGameStore.applyReferral()`. 코드는 hashed `userId` + +#### D-4. 도감 공유 URL 💡 +- **무엇:** 내 진행도를 정적 URL로. 받는 쪽은 view-only +- **왜:** 자랑 동기 + 입소문 +- **어디:** `localStorage` 직렬화 → base64 → URL 파라미터. 100KB 미만이라 가능 + +#### D-5. 데일리 글로벌 시드 🏔 +- **무엇:** 모든 유저가 동일한 "오늘의 레시피 챌린지" → 익명 leaderboard +- **왜:** 같이 했다는 감각. v2에서 서버 갖췄을 때 + +#### D-6. 타이틀 OG 이미지 🔧 +- **무엇:** 프레스티지 칭호를 OG meta로 카톡 미리보기에 노출 +- **왜:** 공유 시 "프로필 카드"가 자동으로 보임 +- **어디:** `index.html` `og:image`를 동적 URL로. 출시 직후엔 정적 1장도 OK + +--- + +### E. 광고 수익화 친화 동선 + +> 정책 결정: **인터스티셜 0, 모두 리워드형.** 강제 광고 없음. + +#### E-1. 오프라인 보상 2배 광고 💡 +- **무엇:** `OfflineRewardModal`에 "광고 보고 2배" 버튼 1개 추가 +- **왜:** 가장 자연스러운 첫 광고 동선. 사용자가 이미 "보상" 모드라 친화적 +- **어디:** `OfflineRewardModal.tsx` + 광고 SDK 어댑터 (`src/platform/ads.ts`). `claimOfflineReward(multiplier)` 시그니처 확장 + +#### E-2. 즉시 30분 부스트 (광고) 💡 +- **무엇:** Shop에 "FREE" 슬롯 신설. 광고 시청 → 30분 글로벌 x2. 일 3회 한도 +- **어디:** `ShopScreen.tsx` 상단에 무료 섹션. `useGameStore`에 `freeBoostUsedToday` + +#### E-3. 합성 실패 → 재시도 광고 💡 +- **무엇:** "알 수 없는 조합" 에러에 "광고 보고 한 번 더" — 사실상 도파민 회수 +- **어디:** `FusionScreen` errorBanner 내 버튼 + +#### E-4. 일일 무료 가챠 1회 🔧 +- **무엇:** 광고 시청 → 보유 원소 1단계 위 랜덤 1개 +- **어디:** 새 컴포넌트 `DailyGachaCard.tsx`. `useGameStore.gacha()` 액션 + +#### E-5. 골드 펀딩 💡 +- **무엇:** 강화 비용 부족 시 "절반 광고로 메우기" 버튼 +- **어디:** `ElementsScreen` `DetailPanel` 강화 섹션 + +#### E-6. 광고 방패 🔧 +- **무엇:** 광고 1개 시청 → 30분간 "광고 OFF" (실제로는 광고 없는 상태이므로 의미는 신뢰 시그널) +- **왜:** "광고가 강요되지 않는다"는 정책을 가시화. 사용자 신뢰 + +#### E-7. 배너 영역 분리 💡 +- **무엇:** 하단 탭바 위 1줄 배너 영역. 게임 영역 침범 금지 +- **어디:** `App.tsx` 레이아웃에 `` 슬롯. 광고 미연동 시 빈 영역 + +#### E-8. 광고 SDK 어댑터 💡 *(선결)* +- **무엇:** 광고 SDK를 `src/platform/ads.ts` 뒤로 격리. 인터페이스: `showRewardedAd(): Promise<{ rewarded: boolean }>` +- **왜:** 어떤 광고망을 쓰든 비즈 로직은 변경 없음. analytics 어댑터와 같은 패턴 + +--- + +### F. 감정·연출 강화 + +#### F-1. rarity별 SFX 💡 +- **무엇:** common 톡 / uncommon 띵 / rare 종 / epic 차임벨 / legendary 크리스탈 굉음 +- **소스 정책:** Phase 0은 **Web Audio API로 즉시 합성** (사인파 + 엔벨로프, 외부 파일 0). Phase 1에서 freesound.org CC0 mp3로 교체 +- **어디:** 새 유틸 `src/lib/sfx.ts`. `bgmEnabled` 외 `sfxEnabled` 별도 토글 추가 (Settings 화면 + `useGameStore`) + +#### F-2. 레전더리 발견 카메라 셰이크 💡 +- **무엇:** 0.4초 화면 정지 + transform 진동 + 가장자리 글로우 +- **왜:** 시스템 임팩트가 시각 임팩트보다 강할 때, 진동을 추가해 균형 +- **어디:** `App.tsx` 루트에 `transform` 클래스 토글. `RARITY_GLOW`는 이미 `FusionScreen`에 있음 + +#### F-3. BGM 스템 시스템 🏔 +- **무엇:** Tier 해금 단계마다 새 악기 레이어 추가 (Crashlands 스타일) +- **어디:** 5개 mp3 stem + Web Audio mixer. `bgmEnabled` 토글 유지 + +#### F-4. 캐릭터 미세 인터랙션 💡 +- **무엇:** 30초 이상 한 화면 머무를 때 캐릭터 하품/깜빡임 애니메이션 +- **어디:** `CharacterSprite.tsx`에 `idleVariant` prop. `ElementsScreen`에서 30초 타이머 + +#### F-5. 시간대 톤 변화 💡 +- **무엇:** 디바이스 시계 기준 아침/낮/저녁/밤 4단계 배경 그라디언트 +- **어디:** `App.tsx` 루트 `rootStyle` 동적. `getTimeOfDay()` 유틸 + +#### F-6. 첫 발견 칭찬 한 줄 💡 +- **무엇:** "창조자여, 새로운 빛을 발견했군요" 풀스크린 1초 (A-3과 같이 처리) +- **어디:** `DiscoveryHero.tsx` (A-3와 통합) + +#### F-7. 키 일러스트 활용 🔧 +- **무엇:** `Ember_Origin.jpg` 톤의 키 일러스트를 발견 모달 배경 / 프레스티지 / 로딩 스플래시에 +- **왜:** 톤 통일. 현재는 SVG 캐릭터만 있어서 "느낌"이 부족 + +#### F-8. 햅틱 진동 💡 +- **무엇:** `navigator.vibrate()` — 합성 50ms / 프레스티지 200ms / 레전더리 [80, 40, 80] +- **어디:** 각 액션 후 `vibrate()` 호출. 설정에서 `hapticEnabled` 토글 + +--- + +## 4. 출시 D-2 (4-30) 단기 패치 — Phase 0 + +> **목표:** 4-30 출시 빌드에 끼울 수 있는, 임팩트 큰 6시간 묶음. +> 모두 `core changes only` — 새 화면/새 데이터 추가 없음. + +| ID | 항목 | 부담 | 임팩트 | +|---|---|---|---| +| **A-3** | 첫 발견 풀스크린 카드 | 1.5h | 첫 5분 후크 | +| **A-4** | 튜토리얼 완료 보상 | 0.5h | 첫 5분 마무리 | +| **B-1** | 합성 콤보 미터 | 1.5h | 능동 합성 보상 | +| **B-5** | 럭키 합성 (1%) | 0.3h | 도파민 임펙트 | +| **F-1** | rarity별 SFX | 1.0h | 감각 자체가 변함 | +| **F-2** | 레전더리 셰이크 | 0.5h | F-1과 합쳐 강함 | +| **F-8** | 햅틱 진동 | 0.3h | 모바일 체감 | +| **E-7** | 배너 영역 분리 | 0.4h | 광고 준비만 | + +**합계 약 6시간.** 모두 단일 PR로 가능. 광고 SDK 연동(E-1, E-2)은 SDK 선정 시간이 변수라 별도 패치. + +--- + +## 5. v1.1 (출시 후 1~2주) — Phase 1 + +| 카테고리 | 항목 | +|---|---| +| 후크 | A-1 콜드오픈 / A-2 첫 합성 강제 / A-5 도감 슬롯 / A-6 데일리 보너스 | +| 정체기 | B-2 탭-투-수확 / B-3 일일 미션 / B-4 Element Storm | +| 깊이 | C-1 도감 v0 / C-3 Tier 6 신화급 / C-6 계보 트리 | +| 소셜 | D-1 공유 이미지 / D-4 도감 URL / D-6 OG 이미지 | +| 수익화 | E-1 오프라인 2배 / E-2 30분 부스트 / E-3 재시도 광고 / E-5 골드 펀딩 / E-8 광고 어댑터 | +| 연출 | F-4 미세 인터랙션 / F-5 시간대 톤 / F-7 키 일러스트 | + +**우선순위 권장:** E-1 ~ E-8 (수익화 동선) → A-1 ~ A-6 (리텐션) → 기타. + +--- + +## 6. v1.2+ (장기) — Phase 2 + +| 카테고리 | 항목 | +|---|---| +| 깊이 | B-6 빌드 분기 / C-2 진화 비주얼 / C-4 시즌 한정 / C-5 무한 모드 | +| 소셜 | D-3 친구 코드 / D-5 글로벌 시드 | +| 정체기 | B-7 결투 미니게임 | +| 연출 | F-3 BGM 스템 | +| 수익화 | E-4 일일 가챠 / E-6 광고 방패 | + +--- + +## 7. 측정 지표 (KPI) + +각 단계에서 다음을 `trackGameEvent`로 추적: + +| 지표 | 측정 | +|---|---| +| **D+1 리텐션** | 첫 접속 24h 후 재접속 비율 | +| **튜토리얼 완주율** | `tutorialCompleted=true` / 첫 접속 | +| **세션당 합성 횟수** | `fusion_completed` 이벤트 수 | +| **콤보 도달률** | 콤보 x2 이상 도달 세션 비율 | +| **광고 시청률** | 일일 활성 유저 중 광고 1회 이상 시청 | +| **프레스티지 도달률** | `prestige_count >= 1` 비율 | + +--- + +## 8. 의도적 제외 (YAGNI) + +- 서버 의존 기능 (전역 leaderboard, 실시간 멀티) — v2 이전엔 안 함 +- 결제(IAP) — 광고 수익화 우선, IAP는 시장 반응 본 후 +- PvP / 길드 / 채팅 — 게임 성격에 맞지 않음 +- 강제 인터스티셜 광고 — 정책으로 영구 금지 +- 푸시 알림 — Web PWA로 출시, native 푸시는 나중 + +--- + +## 9. 다음 단계 + +1. **사용자 검토** — 이 문서의 우선순위·범위에 동의하는지 +2. **Phase 0 (출시 D-2)** 항목들에 대한 구현 계획 작성 — `writing-plans` 스킬로 `docs/superpowers/plans/2026-04-28-phase0-fun-patch.md` 만들기 +3. **광고 SDK 선정** — Phase 1 진입 전제 조건 (AdSense vs AdMob Web vs Carrot 등) +4. **Phase 1 실행** — Phase 0 출시 후 D+3 ~ D+14