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>
This commit is contained in:
2026-05-02 07:34:10 +09:00
parent 90ba98fbf7
commit 3676d8b12c
10 changed files with 3189 additions and 154 deletions

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';
@@ -14,7 +16,16 @@ import { useIdleTick } from './hooks/useIdleTick';
import { useGameStore } from './store/useGameStore';
import { trackGameEvent } from './platform/analytics';
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) => css`
width: 100%;
height: 100vh;
display: flex;
@@ -26,18 +37,26 @@ const rootStyle = css`
-apple-system,
BlinkMacSystemFont,
sans-serif;
animation: ${shaking ? css`${legendaryShake} 0.42s ease-out` : 'none'};
`;
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 handleIntroDone = useCallback(() => {
localStorage.setItem('firstspark-intro-seen', '1');
setShowIntro(false);
}, []);
useEffect(() => {
const ownedCount = Object.values(elements).filter((c) => c > 0).length;
const totalLevel = Object.values(elementLevels).reduce((sum, lv) => sum + lv, 0);
@@ -50,8 +69,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)}>
{showIntro && (
<IntroSplash
onDone={handleIntroDone}
/>
)}
<OfflineRewardModal />
<AchievementToast />
<div css={contentStyle}>
@@ -62,6 +95,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: 환영 카드

File diff suppressed because it is too large Load Diff