Compare commits

...

10 Commits

Author SHA1 Message Date
64d555d641 docs: add project CLAUDE.md (continuous-develop operating mode)
Confirms project is in continuous-develop / live-ops mode with no fixed
launch date — prior spec dates (2026-04-30 Paperclip target, etc.) are
per-task milestones, not project-wide release windows. PWA build is
deployable at any time; ad SDK swaps in via platform adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:40:47 +09:00
4ce2ce2614 docs: sync plan with A-5 polish actually shipped (partiallyKnown filter, opacity split)
Final reviewer flagged that the plan still showed the pre-polish RecipeHint
shape and tierGroups pipeline. Update the plan code blocks to match HEAD
so future implementers see the shipped pattern (partiallyKnown field,
.filter(...) drop, undiscoveredDimStyle opacity wrapper, module-scope
MAX_UNDISCOVERED_PER_TIER).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 04:25:25 +09:00
559ca8d934 fix: tighten codex hint visibility and constants (A-5 polish)
- Hoist MAX_UNDISCOVERED_PER_TIER to module scope (was a local const that
  expressed game-design intent, not runtime state).
- Filter undiscovered slots to those with at least one known ingredient
  so a fresh player never sees useless `? + ?` cards on high tiers.
- Move card-level opacity off the wrapper so the recipe-hint span can
  stay full-color (especially the craftable-now blue) while sprite +
  name remain dimmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 04:18:32 +09:00
56664b56d3 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>
2026-05-04 04:06:54 +09:00
744ccbf434 fix: register idleYawn keyframes via direct css interpolation + cover wheel scroll (F-4)
The idleSpriteWrapperStyle was using `keyframes.toString()` inside a JS
template literal, so Emotion's serializer hit the string-handling branch
and never injected the @keyframes block — the animation silently did
nothing. Switch to direct Keyframes-object interpolation inside css\`...\`
so Emotion registers the rule and returns the animation name.

Also add 'wheel' to ACTIVITY_EVENTS so desktop mouse-wheel scrolling on
the inner scrollable content area resets the idle timer (the existing
'scroll' listener on window only catches mobile/touch scroll).

Update the source plan doc to reflect the corrected idiom so future
implementers don't repeat the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 04:01:47 +09:00
2b752e9e1f 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>
2026-05-04 03:53:20 +09:00
c638679502 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>
2026-05-04 03:46:23 +09:00
b1f7dbf216 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>
2026-05-04 03:44:58 +09:00
91393daeec docs: mark Phase 0 plan as superseded after actual implementation
Phase 0의 8개 항목 모두 다른 패턴/위치로 이미 구현되어 commits 90ba98f
및 3676d8b에 반영됨. plan은 스펙 추적을 위해 보존하되, 실제 코드와의
차이를 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:35:47 +09:00
3676d8b12c feat: integrate Phase 0 fun systems across screens and game store
Wires the new scaffolding (intro, discovery, ad slot, sfx/haptic, sprite art)
into existing systems and adds combo + lucky proc to the fusion loop.

- useGameStore: add combo meter (8s window, x1.5/x2/x3 caps), lucky proc
  (1% tier-up + gold bonus), sfx/haptic enabled toggles, welcome-gift
  selection, screen polish state for new visual systems.
- App.tsx: mount IntroSplash + AdBanner; listen for legendaryImpact event
  and apply 0.42s screen-shake keyframes.
- FusionScreen: render combo bar + lucky badge + DiscoveryHero overlay;
  trigger SFX, haptic, legendary impact event on fuse results.
- SettingsScreen: add SFX and haptic toggles wired to store flags.
- TutorialOverlay: integrate welcome-gift step at tutorial completion.
- CharacterSprite, ElementsScreen, EvolutionScreen, ShopScreen,
  OfflineRewardModal: adopt new sprite art and apply visual polish to
  match the discovery / scene aesthetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:34:10 +09:00
15 changed files with 4108 additions and 166 deletions

35
CLAUDE.md Normal file
View File

@@ -0,0 +1,35 @@
# Archetype-FirstSpark — CLAUDE.md
> Vite + React + TypeScript 기반 idle/incremental 게임. 박재오 개인 프로젝트.
## 운영 모드 (2026-05-11 확정)
**고정 출시일 없음. 지속 개발(continuous develop) 프로젝트.**
이전 spec 문서들(`docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md`, `2026-04-28-fun-engagement-overhaul-design.md`)에 적힌 "2026-05-25 출시" / "2026-04-30 Paperclip JSA-2" 등의 일자는 해당 spec 작성 당시의 작업 단위 타겟이며, **프로젝트 전체의 정식 출시일이 아니다.**
- 단발성 출시 이벤트 X
- 마이크로 패치 + 기능 추가를 누적하는 라이브 운영 스타일
- 광고 SDK·도메인은 준비되는 대로 어댑터 교체 (`src/platform/` 격리 단일 규칙 유지)
- PWA 빌드 자체는 언제든 배포 가능 상태
## 기술 스택 / 아키텍처
- Vite + React + TypeScript
- zustand + localStorage (게임 상태)
- emotion css
- PWA (`vite-plugin-pwa`)
- 모든 vendor는 `src/platform/`을 통해서만 접근 (광고/푸시/분석/PWA 어댑터 격리)
자세한 설계는 `docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md` 참고.
## 진행 중 작업 (2026-04~05)
- `2026-04-27` web-engine-pivot — Toss(Granite/TDS) 제거, Vite 단독 + PWA + 광고 어댑터 (DummyAdapter)
- `2026-04-28` fun-engagement-overhaul (spec) — 첫 5분 후크 / Tier 3~4 능동 행동 / 프레스티지 후 메타게임 / 광고 동선
- `2026-05-01` phase0-fun-patch (plan) — 콜드오픈 시네마틱 + 첫 합성 강제 페이싱
- `2026-05-04` micro-fun-patch — 마이크로 단위 개선
## 위키 참조
이 프로젝트의 위키 인덱스: [[프로젝트-archetype-firstspark]] (Obsidian Vault)

View File

@@ -1,5 +1,19 @@
# Phase 0 — Fun & Engagement Hotfix Implementation Plan
> **⚠️ SUPERSEDED (2026-05-02):** Phase 0의 8개 항목 모두 다른 패턴/위치로 이미 구현되어 커밋됨 (`90ba98f`, `3676d8b`). 본 plan은 스펙 추적용으로 보존하되, 실제 코드와 차이가 있으니 신규 작업 가이드로 사용하지 말 것.
>
> 실제 구현 vs plan 매핑:
> - F-8 햅틱: `src/lib/sfx.ts::vibrate()` (plan은 별도 `haptic.ts`)
> - F-1 SFX: `src/lib/sfx.ts::playRaritySfx(rarity, enabled)` (plan은 module 상태)
> - F-2 셰이크: `legendaryImpact` 이벤트 (plan은 `legendaryShake`)
> - A-3 발견 카드: `DiscoveryHero` props 컨트롤드 (plan은 event listener)
> - A-4 환영 선물: `TutorialOverlay` 인라인 (plan은 별도 `WelcomeGiftModal`)
> - 그 외 B-1, B-5, E-7는 plan과 동등한 형태로 구현됨
>
> 후속 작업은 Phase 1 plan(`docs/superpowers/plans/` 하위, 작성 예정) 참고.
---
> **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 기반을 마련한다.

View File

@@ -0,0 +1,646 @@
# 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',
'wheel',
];
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%;
animation: ${mood === 'idle' ? css`${idleYawnKeyframes} 8s ease-in-out infinite` : 'none'};
`;
```
> 만약 `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;
partiallyKnown: 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,
partiallyKnown: aDiscovered || bDiscovered,
};
}
```
(`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` 정의를 다음으로 교체:
> `MAX_UNDISCOVERED_PER_TIER`는 module 상수 (예: `TIER_LABELS` 옆)에 두는 편을 권장. 게임 디자인 캡이지 런타임 상태가 아님.
```ts
// module scope
const MAX_UNDISCOVERED_PER_TIER = 6;
// ElementsScreen 함수 본문
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) }))
.filter(({ hint }) => hint?.partiallyKnown ?? false) // `? + ?` 카드 차단
.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);
```
> 미발견 카드의 opacity 처리: 카드 wrapper 전체에 `opacity: 0.4`를 걸면 craftable hint의 파란색이 묻힘. `undiscoveredCardStyle`에서 opacity를 빼고, sprite + `???` name만 별도 wrapper(`undiscoveredDimStyle`, opacity 0.45 + flex-column 정렬)로 dim 처리. 힌트 span은 dim wrapper 바깥에 두어 full-color 유지.
기존 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에서 도입하는 새 의존성: 없음.

View File

@@ -1,8 +1,10 @@
import { css } from '@emotion/react';
import { useEffect } from 'react';
import { css, keyframes } from '@emotion/react';
import { useCallback, useEffect, useState } from 'react';
import { BottomTabBar } from './components/BottomTabBar';
import { OfflineRewardModal } from './components/OfflineRewardModal';
import { AchievementToast } from './components/AchievementToast';
import { AdBanner } from './components/AdBanner';
import { IntroSplash } from './components/IntroSplash';
import { ElementsScreen } from './components/screens/ElementsScreen';
import { EvolutionScreen } from './components/screens/EvolutionScreen';
import { FusionScreen } from './components/screens/FusionScreen';
@@ -13,30 +15,51 @@ import { TutorialOverlay } from './components/tutorial/TutorialOverlay';
import { useIdleTick } from './hooks/useIdleTick';
import { useGameStore } from './store/useGameStore';
import { trackGameEvent } from './platform/analytics';
import { useTimeOfDay, getPalette } from './lib/timeOfDay';
const rootStyle = css`
const legendaryShake = keyframes`
0%, 100% { transform: translate3d(0, 0, 0); filter: none; }
15% { transform: translate3d(-6px, 2px, 0); filter: drop-shadow(0 0 18px rgba(255, 215, 0, 0.7)); }
30% { transform: translate3d(5px, -3px, 0); }
45% { transform: translate3d(-4px, -2px, 0); }
60% { transform: translate3d(4px, 3px, 0); }
75% { transform: translate3d(-2px, 1px, 0); }
`;
const rootStyle = (shaking: boolean, background: string) => css`
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f7f8fa;
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;
`;
const contentStyle = css`
flex: 1;
overflow-y: auto;
padding-bottom: 72px;
padding-bottom: 106px;
`;
export function App() {
const { activeTab, setActiveTab, elements, gold, elementLevels } = useGameStore();
const [shaking, setShaking] = useState(false);
const [showIntro, setShowIntro] = useState(() => localStorage.getItem('firstspark-intro-seen') !== '1');
useIdleTick();
const timeOfDay = useTimeOfDay();
const palette = getPalette(timeOfDay);
const handleIntroDone = useCallback(() => {
localStorage.setItem('firstspark-intro-seen', '1');
setShowIntro(false);
}, []);
useEffect(() => {
const ownedCount = Object.values(elements).filter((c) => c > 0).length;
@@ -50,8 +73,22 @@ export function App() {
});
}, []);
useEffect(() => {
const handler = () => {
setShaking(true);
window.setTimeout(() => setShaking(false), 440);
};
window.addEventListener('legendaryImpact', handler);
return () => window.removeEventListener('legendaryImpact', handler);
}, []);
return (
<div css={rootStyle}>
<div css={rootStyle(shaking, palette.background)}>
{showIntro && (
<IntroSplash
onDone={handleIntroDone}
/>
)}
<OfflineRewardModal />
<AchievementToast />
<div css={contentStyle}>
@@ -62,6 +99,7 @@ export function App() {
{activeTab === 'achievements' && <AchievementsScreen />}
{activeTab === 'settings' && <SettingsScreen />}
</div>
<AdBanner />
<BottomTabBar activeTab={activeTab} onTabChange={setActiveTab} />
<TutorialOverlay />
</div>

View File

@@ -1,10 +1,11 @@
/**
* CharacterSprite
*
* docs/character-design-guide.md 에 정의된 카와이 치비 스타일을 SVG로 구현.
* 각 원소의 색상·Tier·파티클 유형에 따라 고유한 캐릭터를 생성합니다.
* 원소별 속성 문양과 티어 프레임을 가진 SVG 스프라이트.
* 같은 둥근 캐릭터처럼 보이지 않도록 색, 문양, 실루엣, 오라가 함께 달라집니다.
*/
import { Fragment, useMemo, type ReactElement } from 'react';
import { css, keyframes } from '@emotion/react';
import { Fragment, useId, useMemo, type ReactElement } from 'react';
import {
CHARACTER_VISUAL,
DEFAULT_VISUAL,
@@ -12,6 +13,112 @@ import {
type ParticleType,
type PatternType,
} from '../data/characterVisual';
import earthAsset from '../assets/elements/earth.png';
import fireAsset from '../assets/elements/fire.png';
import waterAsset from '../assets/elements/water.png';
import windAsset from '../assets/elements/wind.png';
type RasterMotion = 'fire' | 'water' | 'wind' | 'earth';
const RASTER_SPRITES: Record<string, { src: string; motion: RasterMotion }> = {
fire: { src: fireAsset, motion: 'fire' },
water: { src: waterAsset, motion: 'water' },
wind: { src: windAsset, motion: 'wind' },
earth: { src: earthAsset, motion: 'earth' },
};
const fireFlicker = keyframes`
0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1) saturate(1.04); }
18% { transform: translateY(-2px) scale(1.035, 0.985) rotate(-1.4deg); filter: brightness(1.12) saturate(1.18); }
36% { transform: translateY(1px) scale(0.985, 1.035) rotate(1.1deg); filter: brightness(0.96) saturate(1.08); }
62% { transform: translateY(-3px) scale(1.045, 0.975) rotate(0.8deg); filter: brightness(1.18) saturate(1.22); }
82% { transform: translateY(-1px) scale(1.01, 1.02) rotate(-0.6deg); filter: brightness(1.04) saturate(1.12); }
`;
const waterRipple = keyframes`
0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1); }
30% { transform: translateY(-2px) scale(1.02, 0.99) rotate(0.8deg); filter: brightness(1.08); }
58% { transform: translateY(1px) scale(0.99, 1.02) rotate(-0.7deg); filter: brightness(0.98); }
78% { transform: translateY(-1px) scale(1.015, 1) rotate(0.4deg); filter: brightness(1.04); }
`;
const windFlow = keyframes`
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 0.96; }
25% { transform: translate(2px, -2px) scale(1.02) rotate(1.6deg); opacity: 1; }
52% { transform: translate(-2px, 1px) scale(0.99, 1.02) rotate(-1.2deg); opacity: 0.92; }
76% { transform: translate(1px, -1px) scale(1.01) rotate(0.8deg); opacity: 0.98; }
`;
const earthGrow = keyframes`
0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1); }
38% { transform: translateY(-1px) scale(1.01, 1.015) rotate(-0.4deg); filter: brightness(1.05); }
72% { transform: translateY(1px) scale(0.995, 1.005) rotate(0.5deg); filter: brightness(0.98); }
`;
const rasterFloat = keyframes`
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.02); }
`;
const glowPulse = keyframes`
0%, 100% { opacity: 0.08; transform: scale(0.94); }
50% { opacity: 0.18; transform: scale(1.04); }
`;
const rasterWrapStyle = (size: number, color: string, tier: number) => css`
position: relative;
width: ${size}px;
height: ${size}px;
display: inline-block;
border-radius: 999px;
filter: drop-shadow(0 7px 10px rgba(15, 23, 42, 0.16));
animation: ${rasterFloat} 2.2s ease-in-out infinite;
&::before {
content: '';
position: absolute;
inset: ${Math.max(1, size * 0.04)}px;
border-radius: 999px;
background: ${color};
opacity: ${tier >= 3 ? 0.16 : 0.08};
filter: blur(${Math.max(6, size * 0.14)}px);
animation: ${glowPulse} 2.3s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
inset: ${Math.max(1, size * 0.03)}px;
border-radius: 999px;
border: ${Math.max(1, size * 0.025)}px solid rgba(255, 255, 255, 0.6);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
pointer-events: none;
}
`;
function getRasterMotionStyle(motion: RasterMotion) {
switch (motion) {
case 'fire':
return css`${fireFlicker} 0.82s ease-in-out infinite`;
case 'water':
return css`${waterRipple} 1.7s ease-in-out infinite`;
case 'wind':
return css`${windFlow} 1.35s ease-in-out infinite`;
case 'earth':
return css`${earthGrow} 2.2s ease-in-out infinite`;
}
}
const rasterImageStyle = (size: number, motion: RasterMotion) => css`
position: absolute;
inset: 0;
width: ${size}px;
height: ${size}px;
object-fit: contain;
transform-origin: 50% 62%;
animation: ${getRasterMotionStyle(motion)};
will-change: transform, filter, opacity;
`;
// ─── 색상 유틸 ───────────────────────────────────────────────
@@ -33,16 +140,44 @@ function rgbToHex(r: number, g: number, b: number): string {
);
}
function lighten(hex: string, amt: number): string {
const [r, g, b] = hexToRgb(hex);
return rgbToHex(r + amt, g + amt, b + amt);
}
function darken(hex: string, amt: number): string {
const [r, g, b] = hexToRgb(hex);
return rgbToHex(r - amt, g - amt, b - amt);
}
function mix(hexA: string, hexB: string, ratio: number): string {
const [ar, ag, ab] = hexToRgb(hexA);
const [br, bg, bb] = hexToRgb(hexB);
return rgbToHex(
ar + (br - ar) * ratio,
ag + (bg - ag) * ratio,
ab + (bb - ab) * ratio
);
}
function luminance(hex: string): number {
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function readableBase(hex: string): string {
if (luminance(hex) > 0.82) return mix(hex, '#3182F6', 0.28);
if (luminance(hex) < 0.08) return mix(hex, '#7C4DFF', 0.32);
return hex;
}
const TIER_FRAME: Record<number, { stroke: string; accent: string; aura: number; dash?: string }> = {
1: { stroke: '#94A3B8', accent: '#CBD5E1', aura: 0.08 },
2: { stroke: '#22C55E', accent: '#86EFAC', aura: 0.12, dash: '4 5' },
3: { stroke: '#3B82F6', accent: '#93C5FD', aura: 0.18, dash: '7 4' },
4: { stroke: '#A855F7', accent: '#D8B4FE', aura: 0.24 },
5: { stroke: '#F59E0B', accent: '#FDE68A', aura: 0.32 },
};
function getTierFrame(tier: number) {
return TIER_FRAME[Math.min(5, Math.max(1, tier))] ?? TIER_FRAME[1];
}
// ─── 파티클 위치 ──────────────────────────────────────────────
interface ParticlePos {
@@ -309,6 +444,141 @@ function renderBodyPattern(
}
}
function renderElementCrest(
particleType: ParticleType,
color: string,
accentColor: string,
cx: number,
cy: number,
tier: number
): ReactElement {
const crestY = cy + 5;
const opacity = tier >= 4 ? 0.28 : 0.22;
switch (particleType) {
case 'flame':
return (
<path
d={`M${cx},${crestY - 18} C${cx - 13},${crestY - 4} ${cx - 9},${crestY + 12} ${cx},${crestY + 16} C${cx + 12},${crestY + 10} ${cx + 12},${crestY - 2} ${cx + 4},${crestY - 9} C${cx + 2},${crestY - 12} ${cx + 2},${crestY - 15} ${cx},${crestY - 18} Z`}
fill={accentColor}
opacity={opacity}
/>
);
case 'water':
return (
<path
d={`M${cx},${crestY - 18} C${cx - 12},${crestY - 3} ${cx - 13},${crestY + 8} ${cx},${crestY + 15} C${cx + 13},${crestY + 8} ${cx + 12},${crestY - 3} ${cx},${crestY - 18} Z`}
fill={accentColor}
opacity={opacity}
/>
);
case 'wind':
return (
<Fragment>
<path
d={`M${cx - 18},${crestY - 5} C${cx - 7},${crestY - 14} ${cx + 14},${crestY - 12} ${cx + 18},${crestY - 2}`}
fill="none"
stroke={accentColor}
strokeWidth="4"
strokeLinecap="round"
opacity={opacity + 0.06}
/>
<path
d={`M${cx - 14},${crestY + 7} C${cx - 2},${crestY + 14} ${cx + 11},${crestY + 9} ${cx + 15},${crestY + 1}`}
fill="none"
stroke={accentColor}
strokeWidth="3"
strokeLinecap="round"
opacity={opacity}
/>
</Fragment>
);
case 'earth':
case 'leaf':
return (
<Fragment>
<ellipse
cx={cx - 6}
cy={crestY + 3}
rx="9"
ry="15"
fill={accentColor}
opacity={opacity}
transform={`rotate(-32 ${cx - 6} ${crestY + 3})`}
/>
<ellipse
cx={cx + 7}
cy={crestY + 4}
rx="8"
ry="13"
fill={color}
opacity={opacity * 0.85}
transform={`rotate(34 ${cx + 7} ${crestY + 4})`}
/>
</Fragment>
);
case 'crystal':
return (
<polygon
points={`${cx},${crestY - 19} ${cx + 15},${crestY - 2} ${cx + 7},${crestY + 17} ${cx - 9},${crestY + 16} ${cx - 16},${crestY - 2}`}
fill={accentColor}
opacity={opacity}
/>
);
case 'spark':
return (
<path
d={`M${cx + 1},${crestY - 20} L${cx + 8},${crestY - 2} L${cx + 1},${crestY - 2} L${cx + 10},${crestY + 18} L${cx - 10},${crestY - 6} L${cx - 2},${crestY - 6} Z`}
fill={accentColor}
opacity={opacity + 0.08}
/>
);
case 'smoke':
return (
<Fragment>
<circle cx={cx - 10} cy={crestY + 1} r="10" fill={accentColor} opacity={opacity} />
<circle cx={cx + 2} cy={crestY - 7} r="12" fill={accentColor} opacity={opacity * 0.88} />
<circle cx={cx + 13} cy={crestY + 3} r="8" fill={accentColor} opacity={opacity * 0.74} />
</Fragment>
);
case 'rainbow':
return (
<Fragment>
<path
d={`M${cx - 18},${crestY + 10} A18,18 0 0 1 ${cx + 18},${crestY + 10}`}
fill="none"
stroke="#FF4D6D"
strokeWidth="4"
opacity={opacity + 0.1}
/>
<path
d={`M${cx - 13},${crestY + 10} A13,13 0 0 1 ${cx + 13},${crestY + 10}`}
fill="none"
stroke="#4ADE80"
strokeWidth="4"
opacity={opacity + 0.1}
/>
<path
d={`M${cx - 8},${crestY + 10} A8,8 0 0 1 ${cx + 8},${crestY + 10}`}
fill="none"
stroke="#60A5FA"
strokeWidth="4"
opacity={opacity + 0.1}
/>
</Fragment>
);
case 'star':
default:
return (
<polygon
points={`${cx},${crestY - 19} ${cx + 5},${crestY - 5} ${cx + 19},${crestY - 5} ${cx + 8},${crestY + 4} ${cx + 12},${crestY + 18} ${cx},${crestY + 9} ${cx - 12},${crestY + 18} ${cx - 8},${crestY + 4} ${cx - 19},${crestY - 5} ${cx - 5},${crestY - 5}`}
fill={accentColor}
opacity={opacity}
/>
);
}
}
// ─── 눈 렌더링 ────────────────────────────────────────────────
function renderEyes(
@@ -382,29 +652,36 @@ export function CharacterSprite({
size = 120,
state = 'obtained',
}: CharacterSpriteProps) {
const instanceId = useId().replace(/:/g, '');
const config = CHARACTER_VISUAL[elementId] ?? DEFAULT_VISUAL;
const [bodyRx, bodyRy] = config.bodyShape ?? [33, 31];
const frame = getTierFrame(tier);
const colors = useMemo(() => {
if (state === 'undiscovered') {
return {
body1: '#C8C8C8',
body2: '#A0A0A0',
body3: '#808080',
outline: '#606060',
pattern: '#888888',
glow: '#AAAAAA',
body1: '#E5E7EB',
body2: '#B8C0CC',
body3: '#7C8798',
outline: '#667085',
pattern: '#98A2B3',
glow: '#CBD5E1',
crest: '#F8FAFC',
shine: '#FFFFFF',
};
}
const isLocked = state === 'locked';
const base = isLocked ? '#9E9E9E' : elementColor;
const base = isLocked ? '#9E9E9E' : readableBase(elementColor);
const glowBase = isLocked ? '#CCCCCC' : readableBase(config.glowColor);
return {
body1: lighten(base, 45),
body2: base,
body3: darken(base, 20),
outline: darken(base, 35),
pattern: darken(base, 15),
glow: isLocked ? '#CCCCCC' : config.glowColor,
body1: mix(base, '#FFFFFF', 0.58),
body2: mix(base, '#FFFFFF', 0.12),
body3: mix(base, '#111827', 0.22),
outline: mix(base, '#0F172A', 0.48),
pattern: mix(base, '#FFFFFF', 0.18),
glow: glowBase,
crest: mix(base, '#FFFFFF', 0.66),
shine: mix(base, '#FFFFFF', 0.82),
};
}, [elementColor, state, config.glowColor]);
@@ -414,9 +691,21 @@ export function CharacterSprite({
const showRing = showDetails && tier >= 4;
const showRainbow = showDetails && tier >= 5;
const uid = `cs-${elementId}`;
const uid = `cs-${elementId}-${instanceId}`;
const CX = 60;
const CY = 64;
const coreScale = tier >= 5 ? 1.06 : tier >= 4 ? 1.03 : 1;
const rx = bodyRx * coreScale;
const ry = bodyRy * coreScale;
const rasterSprite = RASTER_SPRITES[elementId];
if (state === 'obtained' && rasterSprite) {
return (
<span css={rasterWrapStyle(size, elementColor, tier)} aria-hidden="true">
<img src={rasterSprite.src} css={rasterImageStyle(size, rasterSprite.motion)} alt="" />
</span>
);
}
return (
<svg
@@ -427,6 +716,10 @@ export function CharacterSprite({
overflow="visible"
>
<defs>
<filter id={`${uid}-shadow`} x="-35%" y="-35%" width="170%" height="180%">
<feDropShadow dx="0" dy="7" stdDeviation="5" floodColor="#101828" floodOpacity="0.18" />
</filter>
{/* 몸체 그라디언트 */}
<radialGradient id={`${uid}-grad`} cx="38%" cy="32%" r="65%">
<stop offset="0%" stopColor={colors.body1} />
@@ -434,6 +727,12 @@ export function CharacterSprite({
<stop offset="100%" stopColor={colors.body3} />
</radialGradient>
<linearGradient id={`${uid}-frame`} x1="18" y1="20" x2="102" y2="102">
<stop offset="0%" stopColor={frame.accent} />
<stop offset="46%" stopColor={frame.stroke} />
<stop offset="100%" stopColor={colors.outline} />
</linearGradient>
{/* 글로우 필터 (T3+) */}
{showGlow && (
<filter id={`${uid}-glow`} x="-25%" y="-25%" width="150%" height="150%">
@@ -465,16 +764,25 @@ export function CharacterSprite({
)}
</defs>
{/* ① 배경 글로우 헤일로 (T3+) */}
{showGlow && (
<ellipse cx={CX} cy="101" rx={rx * 0.78} ry="8" fill="#101828" opacity="0.12" />
{/* ① 배경 오라 */}
{showDetails && (
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 12}
ry={bodyRy + 12}
rx={rx + 16 + tier * 1.5}
ry={ry + 16 + tier * 1.5}
fill={colors.glow}
opacity={tier >= 5 ? 0.2 : tier >= 4 ? 0.15 : 0.1}
/>
opacity={frame.aura}
>
<animate
attributeName="opacity"
values={`${frame.aura * 0.7};${frame.aura};${frame.aura * 0.7}`}
dur={`${2.8 - Math.min(tier, 5) * 0.16}s`}
repeatCount="indefinite"
/>
</ellipse>
)}
{/* ② 에너지 링 (T4) */}
@@ -482,22 +790,23 @@ export function CharacterSprite({
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 14}
ry={bodyRy + 14}
rx={rx + 14}
ry={ry + 14}
fill="none"
stroke={elementColor}
strokeWidth="1.5"
opacity="0.4"
stroke={`url(#${uid}-frame)`}
strokeWidth="2.2"
strokeDasharray={frame.dash}
opacity="0.55"
>
<animate
attributeName="rx"
values={`${bodyRx + 12};${bodyRx + 16};${bodyRx + 12}`}
values={`${rx + 12};${rx + 17};${rx + 12}`}
dur="2.4s"
repeatCount="indefinite"
/>
<animate
attributeName="ry"
values={`${bodyRy + 12};${bodyRy + 16};${bodyRy + 12}`}
values={`${ry + 12};${ry + 17};${ry + 12}`}
dur="2.4s"
repeatCount="indefinite"
/>
@@ -510,11 +819,11 @@ export function CharacterSprite({
<ellipse
cx={CX}
cy={CY}
rx={bodyRx + 14}
ry={bodyRy + 14}
rx={rx + 17}
ry={ry + 17}
fill="none"
stroke={`url(#${uid}-rainbow)`}
strokeWidth="2.5"
strokeWidth="3"
opacity="0.75"
>
<animateTransform
@@ -531,12 +840,28 @@ export function CharacterSprite({
{showDetails &&
renderParticles(config.particleType, config.particleColors, tier, CX, CY)}
{/* ⑤ 몸체 */}
{/* ⑤ 티어 프레임 */}
{state !== 'undiscovered' && (
<ellipse
cx={CX}
cy={CY}
rx={rx + 6}
ry={ry + 6}
fill="rgba(255,255,255,0.18)"
stroke={`url(#${uid}-frame)`}
strokeWidth={tier >= 4 ? 3 : tier >= 2 ? 2.4 : 1.8}
strokeDasharray={tier < 4 ? frame.dash : undefined}
opacity={state === 'locked' ? 0.52 : 0.9}
filter={`url(#${uid}-shadow)`}
/>
)}
{/* ⑥ 몸체 */}
<ellipse
cx={CX}
cy={CY}
rx={bodyRx}
ry={bodyRy}
rx={rx}
ry={ry}
fill={`url(#${uid}-grad)`}
stroke={colors.outline}
strokeWidth={showGlow ? 2.5 : 2}
@@ -554,33 +879,45 @@ export function CharacterSprite({
)}
</ellipse>
{/* 몸체 패턴 */}
{/* ⑦ 속성 문양과 몸체 패턴 */}
{showDetails &&
renderElementCrest(config.particleType, colors.pattern, colors.crest, CX, CY, tier)}
{showDetails &&
renderBodyPattern(config.patternType, colors.pattern, CX, CY)}
{/* ⑦ 눈 */}
<ellipse
cx={CX - rx * 0.28}
cy={CY - ry * 0.42}
rx={Math.max(7, rx * 0.22)}
ry={Math.max(4, ry * 0.12)}
fill={colors.shine}
opacity={state === 'obtained' ? 0.38 : 0.16}
transform={`rotate(-22 ${CX - rx * 0.28} ${CY - ry * 0.42})`}
/>
{/* ⑧ 눈 */}
{state !== 'undiscovered' &&
renderEyes(
showDetails ? config.eyeStyle : 'steady',
CX,
CY,
bodyRy,
ry,
colors.outline,
)}
{/* 잠금 눈 가리개 */}
{/* 잠금 눈 가리개 */}
{state === 'locked' && (
<path
d={`M${CX - 14},${CY - bodyRy * 0.18 - 2} Q${CX},${CY - bodyRy * 0.18 + 6} ${CX + 14},${CY - bodyRy * 0.18 - 2}`}
d={`M${CX - 14},${CY - ry * 0.18 - 2} Q${CX},${CY - ry * 0.18 + 6} ${CX + 14},${CY - ry * 0.18 - 2}`}
fill={darken(colors.body2, 25)}
opacity="0.5"
/>
)}
{/* 미소 */}
{/* 미소 */}
{state !== 'undiscovered' && (
<path
d={`M${CX - 8},${CY + bodyRy * 0.22} Q${CX},${CY + bodyRy * 0.22 + 6} ${CX + 8},${CY + bodyRy * 0.22}`}
d={`M${CX - 8},${CY + ry * 0.22} Q${CX},${CY + ry * 0.22 + 6} ${CX + 8},${CY + ry * 0.22}`}
fill="none"
stroke={colors.outline}
strokeWidth="1.5"
@@ -588,7 +925,16 @@ export function CharacterSprite({
/>
)}
{/* ⑩ 미발견 ? */}
{/* ⑪ 고티어 장식 */}
{showDetails && tier >= 4 && (
<Fragment>
<circle cx={CX - rx - 6} cy={CY - ry * 0.5} r={tier >= 5 ? 3.2 : 2.4} fill={frame.accent} opacity="0.9" />
<circle cx={CX + rx + 6} cy={CY - ry * 0.35} r={tier >= 5 ? 3.2 : 2.4} fill={frame.accent} opacity="0.9" />
<circle cx={CX} cy={CY - ry - 9} r={tier >= 5 ? 3.5 : 2.6} fill={frame.accent} opacity="0.86" />
</Fragment>
)}
{/* ⑫ 미발견 ? */}
{state === 'undiscovered' && (
<text
x={CX}

View File

@@ -1,8 +1,11 @@
import { css, keyframes } from '@emotion/react';
import { useState } from 'react';
import { adaptive } from '../styles/adaptive';
import elementsData from '../data/elements.json';
import { useGameStore } from '../store/useGameStore';
import { trackGameEvent } from '../platform/analytics';
import { showRewardedAd } from '../platform/ads';
import type { OfflineReward } from '../store/useGameStore';
function formatDuration(seconds: number): string {
if (seconds >= 3600) {
@@ -33,7 +36,7 @@ const overlayStyle = css`
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 2000;
padding: 20px;
`;
@@ -192,27 +195,88 @@ const claimButtonStyle = css`
transparent
);
animation: ${shimmer} 2.8s ease-in-out infinite;
pointer-events: none;
}
&:active {
transform: scale(0.97);
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
`;
const doubleButtonStyle = css`
width: 100%;
padding: 13px;
background: #101828;
color: white;
border: none;
border-radius: 14px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
margin-bottom: 10px;
&:active {
transform: scale(0.97);
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
`;
export function OfflineRewardModal() {
const pendingOfflineReward = useGameStore((s) => s.pendingOfflineReward);
const claimOfflineReward = useGameStore((s) => s.claimOfflineReward);
const set = useGameStore.setState;
const [isClaiming, setIsClaiming] = useState(false);
if (!pendingOfflineReward) return null;
const handleClaim = () => {
trackGameEvent('offline_reward_claimed', {
offline_sec: pendingOfflineReward.offlineSec,
gold_reward: pendingOfflineReward.gold,
element_types_count: Object.values(pendingOfflineReward.elements).filter((c) => c > 0).length,
});
claimOfflineReward();
const trackClaim = (reward: OfflineReward, multiplier: number) => {
try {
trackGameEvent('offline_reward_claimed', {
offline_sec: reward.offlineSec,
gold_reward: reward.gold * multiplier,
multiplier,
element_types_count: Object.values(reward.elements).filter((c) => c > 0).length,
});
} catch (error) {
console.warn('Failed to track offline reward claim', error);
}
};
const claimReward = (reward: OfflineReward, multiplier = 1) => {
claimOfflineReward(multiplier);
trackClaim(reward, multiplier);
};
const handleClaim = (multiplier = 1) => {
if (isClaiming) return;
setIsClaiming(true);
claimReward(pendingOfflineReward, multiplier);
};
const handleDoubleClaim = async () => {
if (isClaiming) return;
setIsClaiming(true);
try {
const result = await showRewardedAd();
if (result.rewarded) {
claimReward(pendingOfflineReward, 2);
return;
}
} catch (error) {
console.warn('Failed to show rewarded ad', error);
}
setIsClaiming(false);
};
const elementRewards = Object.entries(pendingOfflineReward.elements)
@@ -260,8 +324,13 @@ export function OfflineRewardModal() {
<p css={subtitleStyle}> .</p>
)}
<button css={claimButtonStyle} onClick={handleClaim}>
{hasRewards && (
<button css={doubleButtonStyle} onClick={handleDoubleClaim} disabled={isClaiming}>
{isClaiming ? '처리 중...' : '광고 보고 2배 수령'}
</button>
)}
<button css={claimButtonStyle} onClick={() => handleClaim()} disabled={isClaiming}>
{isClaiming ? '처리 중...' : '수령하기'}
</button>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
} from '../../store/useGameStore';
import { FloatingOverlay } from '../FloatingOverlay';
import { trackGameEvent } from '../../platform/analytics';
import { showRewardedAd } from '../../platform/ads';
import { useFloatingItems } from '../../hooks/useFloatingItems';
const containerStyle = css`
@@ -144,6 +145,27 @@ const enhanceButtonStyle = (canEnhance: boolean) => css`
}
`;
const fundingButtonStyle = (enabled: boolean) => css`
width: 100%;
padding: 9px;
margin-top: 8px;
background: ${enabled ? '#101828' : '#f0f0f0'};
color: ${enabled ? '#ffffff' : '#a0a0a0'};
border: none;
border-radius: 10px;
font-size: 12px;
font-weight: 800;
cursor: ${enabled ? 'pointer' : 'default'};
`;
const fundingLimitStyle = css`
margin-top: 6px;
font-size: 10px;
font-weight: 700;
color: #8b8b8b;
text-align: center;
`;
const emptyStyle = css`
text-align: center;
padding: 60px 20px;
@@ -153,8 +175,16 @@ const emptyStyle = css`
const LEVEL_LABELS = ['기본', '강화 I', '강화 II', '강화 III', '강화 IV', '강화 V (최대)'];
export function EvolutionScreen() {
const { gold, elements, elementLevels, enhance } = useGameStore();
const {
gold,
elements,
elementLevels,
enhance,
adRewardProgress,
claimAdGoldFunding,
} = useGameStore();
const [flashedId, setFlashedId] = useState<string | null>(null);
const [fundingId, setFundingId] = useState<string | null>(null);
const { items: floatItems, add: addFloat } = useFloatingItems(1000);
const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0);
@@ -191,6 +221,33 @@ export function EvolutionScreen() {
}
};
const handleGoldFunding = async (elementId: string, cost: number) => {
if (fundingId || adRewardProgress.goldFundingUses >= 5) return;
const missingGold = Math.max(0, cost - useGameStore.getState().gold);
if (missingGold <= 0) return;
setFundingId(elementId);
try {
const adResult = await showRewardedAd();
if (adResult.rewarded && claimAdGoldFunding(missingGold)) {
addFloat({
text: `+${missingGold} 💰`,
x: window.innerWidth / 2 - 28,
y: window.innerHeight * 0.38,
color: '#F7C12A',
fontSize: 15,
});
trackGameEvent('rewarded_ad_claimed', {
placement: 'enhance_gold_funding',
reward_gold: missingGold,
element_id: elementId,
});
}
} finally {
setFundingId(null);
}
};
if (ownedElements.length === 0) {
return (
<div css={containerStyle}>
@@ -260,6 +317,24 @@ export function EvolutionScreen() {
? `🔒 골드 부족 (${cost - gold}G 필요)`
: `🔒 강화 불가`}
</button>
{!isMax && !canEnhance && gold < cost && (
<>
<button
css={fundingButtonStyle(!fundingId && adRewardProgress.goldFundingUses < 5)}
onClick={() => handleGoldFunding(el.id, cost)}
disabled={Boolean(fundingId) || adRewardProgress.goldFundingUses >= 5}
>
{fundingId === el.id
? '광고 확인 중...'
: adRewardProgress.goldFundingUses >= 5
? '오늘 보충 한도 완료'
: `광고 보고 ${cost - gold}G 보충`}
</button>
<div css={fundingLimitStyle}>
{adRewardProgress.goldFundingUses}/5
</div>
</>
)}
</div>
</div>
);

View File

@@ -6,6 +6,9 @@ import { useGameStore } from '../../store/useGameStore';
import { FloatingOverlay } from '../FloatingOverlay';
import { useFloatingItems } from '../../hooks/useFloatingItems';
import { trackGameEvent } from '../../platform/analytics';
import { showRewardedAd } from '../../platform/ads';
import { DiscoveryHero } from '../DiscoveryHero';
import { playRaritySfx, vibrate } from '../../lib/sfx';
// TDS 색상 팔레트
const tds = {
@@ -189,6 +192,23 @@ const previewBannerStyle = css`
color: ${tds.blue};
`;
const comboBarStyle = (active: boolean) => css`
background: ${active ? 'linear-gradient(135deg, #101828, #27364f)' : tds.gray100};
color: ${active ? '#ffffff' : tds.gray500};
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
font-weight: 800;
`;
const comboValueStyle = css`
color: #ffd166;
`;
const fuseButtonStyle = (canFuse: boolean) => css`
width: 100%;
padding: 14px;
@@ -271,6 +291,30 @@ const errorBannerStyle = css`
margin-bottom: 10px;
`;
const assistBannerStyle = css`
background: ${tds.goldLight};
border: 1px solid ${tds.gold};
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
color: ${tds.gray900};
font-weight: 800;
text-align: center;
margin-bottom: 10px;
`;
const assistButtonStyle = (enabled: boolean) => css`
margin-top: 8px;
border: none;
border-radius: 9px;
background: ${enabled ? '#101828' : tds.gray200};
color: ${enabled ? tds.white : tds.gray500};
padding: 8px 10px;
font-size: 12px;
font-weight: 900;
cursor: ${enabled ? 'pointer' : 'default'};
`;
// ─── 인벤토리 섹션 ──────────────────────────────────────────────────────────
const inventorySectionStyle = css`
@@ -389,7 +433,17 @@ const shineBadgeStyle = css`
// ─── 컴포넌트 ────────────────────────────────────────────────────────────────
export function FusionScreen() {
const { elements, fuse } = useGameStore();
const {
elements,
discoveredElements,
comboCount,
comboExpiresAt,
sfxEnabled,
hapticEnabled,
adRewardProgress,
claimAdFusionAssist,
fuse,
} = useGameStore();
const [slot1, setSlot1] = useState<string | null>(null);
const [slot2, setSlot2] = useState<string | null>(null);
const [selectingSlot, setSelectingSlot] = useState<1 | 2 | null>(null);
@@ -400,8 +454,22 @@ export function FusionScreen() {
message?: string;
} | null>(null);
const [isFusing, setIsFusing] = useState(false);
const [assistLoading, setAssistLoading] = useState(false);
const [assistMessage, setAssistMessage] = useState<string | null>(null);
const [fuseRarity, setFuseRarity] = useState<string>('common');
const [hero, setHero] = useState<{
elementId: string;
name: string;
color: string;
tier: number;
rarity: string;
discoveredCount: number;
isLucky: boolean;
} | null>(null);
const { items: floatItems, add: addFloat } = useFloatingItems(1100);
const comboRemainingSec = Math.max(0, Math.ceil((comboExpiresAt - Date.now()) / 1000));
const comboMultiplier = comboCount >= 10 ? 3 : comboCount >= 6 ? 2 : comboCount >= 3 ? 1.5 : 1;
const comboActive = comboRemainingSec > 0 && comboCount > 0;
// 보유 중인 원소 목록 (보유량 > 0)
const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0);
@@ -416,10 +484,49 @@ export function FusionScreen() {
};
const matchingRecipe = getMatchingRecipe();
const sameSelectedElement = slot1 !== null && slot1 === slot2;
const canFuse =
!!matchingRecipe &&
(elements[slot1 ?? ''] ?? 0) > 0 &&
(elements[slot2 ?? ''] ?? 0) > 0;
(sameSelectedElement
? (elements[slot1 ?? ''] ?? 0) >= 2
: (elements[slot1 ?? ''] ?? 0) > 0 && (elements[slot2 ?? ''] ?? 0) > 0);
const findAssistRecipe = () =>
recipesData.find((recipe) => {
const [first, second] = recipe.ingredients;
const same = first === second;
if (same) return (elements[first] ?? 0) >= 2;
return (elements[first] ?? 0) > 0 && (elements[second] ?? 0) > 0;
});
const handleFusionAssist = async () => {
if (assistLoading || adRewardProgress.fusionAssistUses >= 5) return;
const recipe = findAssistRecipe();
if (!recipe) {
setAssistMessage('추천할 수 있는 조합이 아직 없습니다. 원소를 더 모아보세요.');
return;
}
setAssistLoading(true);
try {
const adResult = await showRewardedAd();
if (adResult.rewarded && claimAdFusionAssist()) {
setSlot1(recipe.ingredients[0]);
setSlot2(recipe.ingredients[1]);
setSelectingSlot(null);
const resultEl = elementMap[recipe.result];
setAssistMessage(`${resultEl?.emoji ?? '✨'} ${resultEl?.name ?? '새 원소'} 조합을 슬롯에 넣었습니다.`);
setLastResult(null);
trackGameEvent('rewarded_ad_claimed', {
placement: 'fusion_assist',
recipe_id: recipe.id,
result_id: recipe.result,
});
}
} finally {
setAssistLoading(false);
}
};
const handleFuse = () => {
if (!slot1 || !slot2) return;
@@ -430,21 +537,59 @@ export function FusionScreen() {
setFuseRarity(rarity);
setIsFusing(true);
setTimeout(() => setIsFusing(false), 750);
playRaritySfx(rarity, sfxEnabled);
vibrate(rarity === 'legendary' ? [80, 40, 80] : 50, hapticEnabled);
if (rarity === 'legendary') {
window.dispatchEvent(new CustomEvent('legendaryImpact'));
}
// 골드 획득 플로팅 텍스트
const cx = window.innerWidth / 2 - 30;
const cy = window.innerHeight * 0.45;
addFloat({ text: `+${result.goldGained} 💰`, x: cx + (Math.random() - 0.5) * 60, y: cy, color: tds.gold, fontSize: 14 });
if (result.comboMultiplier && result.comboMultiplier > 1) {
addFloat({
text: `${result.comboCount} COMBO x${result.comboMultiplier}`,
x: cx + (Math.random() - 0.5) * 80,
y: cy - 28,
color: '#FFD166',
fontSize: 15,
});
}
if (result.isLucky) {
addFloat({
text: '🍀 LUCKY!',
x: cx + (Math.random() - 0.5) * 80,
y: cy - 54,
color: '#22C55E',
fontSize: 16,
});
}
trackGameEvent('fusion_completed', {
result_id: result.resultId,
result_name: resultEl?.name ?? '',
result_tier: rarity,
gold_gained: result.goldGained ?? 0,
combo_count: result.comboCount ?? 1,
combo_multiplier: result.comboMultiplier ?? 1,
lucky: result.isLucky ?? false,
ingredient_1: slot1,
ingredient_2: slot2,
});
if (result.isNewDiscovery && resultEl) {
setHero({
elementId: result.resultId,
name: resultEl.name,
color: resultEl.color,
tier: resultEl.tier,
rarity,
discoveredCount: discoveredElements.length + 1,
isLucky: result.isLucky ?? false,
});
}
setLastResult({
type: 'success',
resultId: result.resultId,
@@ -461,6 +606,7 @@ export function FusionScreen() {
? '⚠️ 알 수 없는 조합입니다'
: '⚠️ 원소가 부족합니다',
});
vibrate(35, hapticEnabled);
}
// 3초 후 피드백 초기화
setTimeout(() => setLastResult(null), 3000);
@@ -497,6 +643,12 @@ export function FusionScreen() {
{/* ── 합성 패널 ── */}
<div css={fusionPanelStyle}>
<div css={comboBarStyle(comboActive)}>
<span> </span>
<span css={comboValueStyle}>
{comboActive ? `${comboCount} COMBO · x${comboMultiplier} · ${comboRemainingSec}s` : '8초 안에 연속 합성'}
</span>
</div>
{/* 슬롯 */}
<div css={slotsRowStyle}>
<div
@@ -556,16 +708,32 @@ export function FusionScreen() {
</span>
<div css={resultTextStyle}>
<div css={resultNameStyle}>
{elementMap[lastResult.resultId]?.name} !
{lastResult.resultId && elementMap[lastResult.resultId]?.name} !
</div>
<div css={resultGoldStyle}>+{lastResult.goldGained} Gold </div>
</div>
<span css={shineBadgeStyle}>NEW</span>
<span css={shineBadgeStyle}>
{lastResult.resultId && elementMap[lastResult.resultId]?.rarity === 'legendary' ? 'LEGEND' : 'NEW'}
</span>
</div>
)}
{lastResult?.type === 'error' && (
<div css={errorBannerStyle}>{lastResult.message}</div>
<div css={errorBannerStyle}>
<div>{lastResult.message}</div>
<button
css={assistButtonStyle(!assistLoading && adRewardProgress.fusionAssistUses < 5)}
onClick={handleFusionAssist}
disabled={assistLoading || adRewardProgress.fusionAssistUses >= 5}
>
{assistLoading
? '광고 확인 중...'
: adRewardProgress.fusionAssistUses >= 5
? '오늘 힌트 한도 완료'
: '광고 보고 추천 조합'}
</button>
</div>
)}
{assistMessage && <div css={assistBannerStyle}>{assistMessage}</div>}
{/* 합성 버튼 + 파티클 버스트 */}
<div css={fuseButtonWrapStyle}>
@@ -657,6 +825,19 @@ export function FusionScreen() {
</div>
)}
</div>
{hero && (
<DiscoveryHero
elementId={hero.elementId}
name={hero.name}
color={hero.color}
tier={hero.tier}
rarity={hero.rarity}
discoveredCount={hero.discoveredCount}
isLucky={hero.isLucky}
onClose={() => setHero(null)}
/>
)}
</div>
);
}

View File

@@ -215,7 +215,18 @@ function Toggle({ enabled, onToggle }: { enabled: boolean; onToggle: () => void
type ResetStep = 'idle' | 'confirm1' | 'confirm2';
export function SettingsScreen() {
const { language, bgmEnabled, lastTickAt, setLanguage, setBgmEnabled, resetGame } = useGameStore();
const {
language,
bgmEnabled,
sfxEnabled,
hapticEnabled,
lastTickAt,
setLanguage,
setBgmEnabled,
setSfxEnabled,
setHapticEnabled,
resetGame,
} = useGameStore();
const [resetStep, setResetStep] = useState<ResetStep>('idle');
const lastSavedDate = new Date(lastTickAt);
@@ -262,12 +273,20 @@ export function SettingsScreen() {
{/* 사운드 설정 */}
<div css={sectionStyle}>
<p css={sectionTitleStyle}></p>
<p css={sectionTitleStyle}> </p>
<div css={cardStyle}>
<div css={rowStyle}>
<span css={rowLabelStyle}>BGM</span>
<Toggle enabled={bgmEnabled} onToggle={() => setBgmEnabled(!bgmEnabled)} />
</div>
<div css={rowStyle}>
<span css={rowLabelStyle}></span>
<Toggle enabled={sfxEnabled} onToggle={() => setSfxEnabled(!sfxEnabled)} />
</div>
<div css={rowStyle}>
<span css={rowLabelStyle}> </span>
<Toggle enabled={hapticEnabled} onToggle={() => setHapticEnabled(!hapticEnabled)} />
</div>
</div>
</div>

View File

@@ -4,9 +4,12 @@ import { adaptive } from '../../styles/adaptive';
import { rarityGradient } from '../../styles/gameColors';
import { useGameStore, isElementUnlocked } from '../../store/useGameStore';
import { trackGameEvent } from '../../platform/analytics';
import { showRewardedAd } from '../../platform/ads';
import elementsData from '../../data/elements.json';
import recipesData from '../../data/recipes.json';
const BOOST_DURATION_SEC = 30;
const ADVANCED_BOOST_DURATION_SEC = 45;
const containerStyle = css`
padding: 24px 20px;
@@ -34,6 +37,88 @@ const goldRowStyle = css`
color: #5a3200;
`;
const rewardSectionStyle = css`
background: #101828;
color: #ffffff;
border-radius: 18px;
padding: 16px;
margin-bottom: 18px;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.18);
`;
const rewardHeaderStyle = css`
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
`;
const rewardTitleStyle = css`
font-size: 15px;
font-weight: 900;
`;
const rewardLimitStyle = css`
font-size: 11px;
font-weight: 800;
color: rgba(255, 255, 255, 0.58);
white-space: nowrap;
`;
const rewardGridStyle = css`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
`;
const rewardCardStyle = css`
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 12px;
`;
const rewardNameStyle = css`
font-size: 13px;
font-weight: 900;
margin-bottom: 4px;
`;
const rewardDescStyle = css`
font-size: 11px;
line-height: 1.4;
font-weight: 700;
color: rgba(255, 255, 255, 0.64);
margin-bottom: 10px;
`;
const rewardButtonStyle = (enabled: boolean) => css`
width: 100%;
border: none;
border-radius: 10px;
background: ${enabled ? '#ffffff' : 'rgba(255, 255, 255, 0.14)'};
color: ${enabled ? '#101828' : 'rgba(255, 255, 255, 0.52)'};
padding: 10px 12px;
font-size: 12px;
font-weight: 900;
cursor: ${enabled ? 'pointer' : 'default'};
`;
const rewardResultStyle = css`
margin-top: 10px;
border-radius: 10px;
background: rgba(34, 197, 94, 0.15);
color: #bbf7d0;
padding: 8px 10px;
font-size: 11px;
font-weight: 900;
`;
const shopGridStyle = css`
display: flex;
flex-direction: column;
@@ -179,28 +264,95 @@ const SHOP_ITEMS = [
price: 50,
rarity: 'uncommon',
},
{
id: 'wind_boost',
name: '바람 강화석',
desc: `바람 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`,
icon: '🌪️',
price: 50,
rarity: 'uncommon',
},
{
id: 'earth_boost',
name: '대지 강화석',
desc: `흙 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`,
icon: '🌱',
price: 50,
rarity: 'uncommon',
},
{
id: 'starter_bundle',
name: '기본 원소 묶음',
desc: 'Tier 1 원소를 각각 +3 획득',
icon: '🧪',
price: 90,
rarity: 'uncommon',
},
{
id: 'fusion_scroll',
name: '합성 두루마리',
desc: '랜덤 원소 합성 시도',
desc: '현재 만들 수 있는 미발견 원소 1개 획득',
icon: '📜',
price: 80,
price: 160,
rarity: 'rare',
},
{
id: 'global_spawn_boost',
name: '정령 가속 코어',
desc: `전체 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`,
icon: '⚙️',
price: 180,
rarity: 'rare',
},
{
id: 'gold_boost',
name: '황금 향로',
desc: `골드 수입 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`,
icon: '🏺',
price: 200,
rarity: 'rare',
},
{
id: 'tier_2_boost',
name: '2티어 공명석',
desc: `Tier 2 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`,
icon: '🔷',
price: 260,
rarity: 'epic',
},
{
id: 'tier_3_boost',
name: '3티어 공명석',
desc: `Tier 3 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`,
icon: '💠',
price: 520,
rarity: 'epic',
},
{
id: 'gold_bag',
name: '골드 주머니',
desc: '+50 골드 즉시 획득',
desc: '+120 골드 즉시 획득',
icon: '👝',
price: 100,
price: 90,
rarity: 'epic',
},
];
export function ShopScreen() {
const { gold, addGold, addElement, activeBoosts, activateBoost } = useGameStore();
const {
gold,
addGold,
addElement,
activeBoosts,
activateBoost,
adRewardProgress,
claimAdFreeBoost,
claimAdDailyGacha,
} = useGameStore();
const [boughtId, setBoughtId] = useState<string | null>(null);
const [noGoldId, setNoGoldId] = useState<string | null>(null);
const [rewardLoadingId, setRewardLoadingId] = useState<string | null>(null);
const [rewardResult, setRewardResult] = useState<string | null>(null);
const [, setTick] = useState(0);
// 버프 타이머 갱신 (1초마다 re-render)
@@ -215,6 +367,44 @@ export function ShopScreen() {
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
};
const handleAdFreeBoost = async () => {
if (rewardLoadingId || adRewardProgress.freeBoostUses >= 3) return;
setRewardLoadingId('free_boost');
try {
const result = await showRewardedAd();
if (result.rewarded && claimAdFreeBoost()) {
setRewardResult('전체 생산 x2 부스트 30분이 적용되었습니다.');
trackGameEvent('rewarded_ad_claimed', {
placement: 'shop_free_boost',
reward: 'global_spawn_boost_30m',
});
}
} finally {
setRewardLoadingId(null);
}
};
const handleAdDailyGacha = async () => {
if (rewardLoadingId || adRewardProgress.dailyGachaClaimed) return;
setRewardLoadingId('daily_gacha');
try {
const adResult = await showRewardedAd();
if (adResult.rewarded) {
const gachaResult = claimAdDailyGacha();
if (gachaResult.success && gachaResult.elementId) {
const el = elementsData.find((item) => item.id === gachaResult.elementId);
setRewardResult(`${el?.emoji ?? '✨'} ${el?.name ?? gachaResult.elementId} 원소를 획득했습니다.`);
trackGameEvent('rewarded_ad_claimed', {
placement: 'shop_daily_gacha',
reward: gachaResult.elementId,
});
}
}
} finally {
setRewardLoadingId(null);
}
};
const handleBuy = (item: (typeof SHOP_ITEMS)[0]) => {
// 스토어에서 최신 골드 값을 직접 읽어 스테일 클로저로 인한 음수 골드 버그 방지
const currentGold = useGameStore.getState().gold;
@@ -226,19 +416,43 @@ export function ShopScreen() {
addGold(-item.price);
if (item.id === 'gold_bag') {
addGold(50);
addGold(120);
} else if (item.id === 'starter_bundle') {
addElement('fire', 3);
addElement('water', 3);
addElement('wind', 3);
addElement('earth', 3);
} else if (item.id === 'fusion_scroll') {
// 미보유 원소 우선 제공, 없으면 보유 중 랜덤
// 현재 재료로 만들 수 있는 미발견 원소 우선 제공
const currentElements = useGameStore.getState().elements;
const unlockedIds = elementsData
.filter((el) => isElementUnlocked(el.id, currentElements))
const discoveredElements = useGameStore.getState().discoveredElements;
const craftableIds = recipesData
.filter(
(recipe) =>
!discoveredElements.includes(recipe.result) &&
isElementUnlocked(recipe.result, currentElements)
)
.map((recipe) => recipe.result);
const fallbackIds = elementsData
.filter((el) => discoveredElements.includes(el.id))
.map((el) => el.id);
const unownedIds = unlockedIds.filter((id) => (currentElements[id] ?? 0) === 0);
const pool = unownedIds.length > 0 ? unownedIds : unlockedIds;
const pool = craftableIds.length > 0 ? craftableIds : fallbackIds;
if (pool.length > 0) {
addElement(pool[Math.floor(Math.random() * pool.length)]);
}
} else if (item.id === 'fire_boost' || item.id === 'water_boost') {
} else if (
item.id === 'global_spawn_boost' ||
item.id === 'gold_boost' ||
item.id === 'tier_2_boost' ||
item.id === 'tier_3_boost'
) {
activateBoost(item.id, ADVANCED_BOOST_DURATION_SEC);
} else if (
item.id === 'fire_boost' ||
item.id === 'water_boost' ||
item.id === 'wind_boost' ||
item.id === 'earth_boost'
) {
activateBoost(item.id, BOOST_DURATION_SEC);
}
@@ -260,11 +474,65 @@ export function ShopScreen() {
<span>💰</span>
<span> : {gold.toLocaleString()}</span>
</div>
<section css={rewardSectionStyle}>
<div css={rewardHeaderStyle}>
<div css={rewardTitleStyle}> </div>
<div css={rewardLimitStyle}>
{adRewardProgress.freeBoostUses}/3 · {adRewardProgress.dailyGachaClaimed ? '완료' : '가능'}
</div>
</div>
<div css={rewardGridStyle}>
<div css={rewardCardStyle}>
<div css={rewardNameStyle}> 30 </div>
<div css={rewardDescStyle}>
30 2 .
</div>
<button
css={rewardButtonStyle(!rewardLoadingId && adRewardProgress.freeBoostUses < 3)}
onClick={handleAdFreeBoost}
disabled={Boolean(rewardLoadingId) || adRewardProgress.freeBoostUses >= 3}
>
{rewardLoadingId === 'free_boost'
? '광고 확인 중...'
: adRewardProgress.freeBoostUses >= 3
? '오늘 한도 완료'
: '광고 보고 받기'}
</button>
</div>
<div css={rewardCardStyle}>
<div css={rewardNameStyle}>🎁 </div>
<div css={rewardDescStyle}>
1 .
</div>
<button
css={rewardButtonStyle(!rewardLoadingId && !adRewardProgress.dailyGachaClaimed)}
onClick={handleAdDailyGacha}
disabled={Boolean(rewardLoadingId) || adRewardProgress.dailyGachaClaimed}
>
{rewardLoadingId === 'daily_gacha'
? '광고 확인 중...'
: adRewardProgress.dailyGachaClaimed
? '오늘 수령 완료'
: '광고 보고 뽑기'}
</button>
</div>
</div>
{rewardResult && <div css={rewardResultStyle}>{rewardResult}</div>}
</section>
<div css={shopGridStyle}>
{SHOP_ITEMS.map((item) => {
const remaining = getRemainingSeconds(item.id);
const isActive = remaining > 0;
const progress = (remaining / BOOST_DURATION_SEC) * 100;
const duration =
item.id === 'global_spawn_boost' ||
item.id === 'gold_boost' ||
item.id === 'tier_2_boost' ||
item.id === 'tier_3_boost'
? ADVANCED_BOOST_DURATION_SEC
: BOOST_DURATION_SEC;
const progress = (remaining / duration) * 100;
return (
<div key={item.id} css={noGoldId === item.id ? shopItemNoGoldStyle : isActive ? shopItemActiveStyle : shopItemStyle}>

View File

@@ -112,8 +112,14 @@ const completionBadgeStyle = css`
`;
export function TutorialOverlay() {
const { tutorialStep, tutorialCompleted, advanceTutorial, skipTutorial, setActiveTab } =
useGameStore();
const {
tutorialStep,
tutorialCompleted,
pendingOfflineReward,
advanceTutorial,
skipTutorial,
setActiveTab,
} = useGameStore();
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
const [showCompletionBadge, setShowCompletionBadge] = useState(false);
@@ -170,8 +176,9 @@ export function TutorialOverlay() {
// 완전히 완료된 경우 렌더링하지 않음
if (tutorialCompleted && !showCompletionBadge) return null;
if (pendingOfflineReward) return null;
if (tutorialCompleted && showCompletionBadge) {
return <div css={completionBadgeStyle}>🎉 !</div>;
return <div css={completionBadgeStyle}>🎁 ! T2 + 120G</div>;
}
// Step 0: 환영 카드

43
src/lib/timeOfDay.ts Normal file
View File

@@ -0,0 +1,43 @@
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;
}

40
src/lib/useIdleMood.ts Normal file
View File

@@ -0,0 +1,40 @@
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',
'wheel',
];
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;
}

File diff suppressed because it is too large Load Diff