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:
44
src/App.tsx
44
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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 환영 카드
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user