Compare commits
10 Commits
90ba98fbf7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d555d641 | |||
| 4ce2ce2614 | |||
| 559ca8d934 | |||
| 56664b56d3 | |||
| 744ccbf434 | |||
| 2b752e9e1f | |||
| c638679502 | |||
| b1f7dbf216 | |||
| 91393daeec | |||
| 3676d8b12c |
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal 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)
|
||||
@@ -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 기반을 마련한다.
|
||||
|
||||
646
docs/superpowers/plans/2026-05-04-micro-fun-patch.md
Normal file
646
docs/superpowers/plans/2026-05-04-micro-fun-patch.md
Normal 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에서 도입하는 새 의존성: 없음.
|
||||
50
src/App.tsx
50
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
43
src/lib/timeOfDay.ts
Normal 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
40
src/lib/useIdleMood.ts
Normal 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
Reference in New Issue
Block a user