feat: add Phase 0 fun-pack scaffolding (intro, discovery, ad slot, sfx/haptic, sprite art)
- src/lib/sfx.ts: Web Audio rarity SFX synthesis + navigator.vibrate wrapper - src/components/IntroSplash.tsx: 1.5s "당신이 세상의 첫 불꽃입니다" cold open - src/components/DiscoveryHero.tsx: full-screen new-element reveal card - src/components/AdBanner.tsx: rewarded-ad slot placeholder above tab bar - src/platform/ads.ts: rewarded ad adapter (stub for Phase 1 SDK swap) - src/assets/elements/: 4 base-element sprites + 8-frame animations + manifest Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BIN
src/assets/elements/animated/earth/frame-00.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
src/assets/elements/animated/earth/frame-01.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
src/assets/elements/animated/earth/frame-02.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
src/assets/elements/animated/earth/frame-03.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
src/assets/elements/animated/earth/frame-04.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
src/assets/elements/animated/earth/frame-05.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
src/assets/elements/animated/earth/frame-06.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
src/assets/elements/animated/earth/frame-07.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
src/assets/elements/animated/earth/sheet.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/assets/elements/animated/fire/frame-00.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
src/assets/elements/animated/fire/frame-01.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
src/assets/elements/animated/fire/frame-02.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
src/assets/elements/animated/fire/frame-03.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
src/assets/elements/animated/fire/frame-04.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/assets/elements/animated/fire/frame-05.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
src/assets/elements/animated/fire/frame-06.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
src/assets/elements/animated/fire/frame-07.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
src/assets/elements/animated/fire/sheet.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
63
src/assets/elements/animated/manifest.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"frameSize": [
|
||||
512,
|
||||
512
|
||||
],
|
||||
"frameCount": 8,
|
||||
"layout": "horizontal",
|
||||
"fps": 12,
|
||||
"elements": {
|
||||
"fire": {
|
||||
"sheet": "src/assets/elements/animated/fire/sheet.png",
|
||||
"frames": [
|
||||
"src/assets/elements/animated/fire/frame-00.png",
|
||||
"src/assets/elements/animated/fire/frame-01.png",
|
||||
"src/assets/elements/animated/fire/frame-02.png",
|
||||
"src/assets/elements/animated/fire/frame-03.png",
|
||||
"src/assets/elements/animated/fire/frame-04.png",
|
||||
"src/assets/elements/animated/fire/frame-05.png",
|
||||
"src/assets/elements/animated/fire/frame-06.png",
|
||||
"src/assets/elements/animated/fire/frame-07.png"
|
||||
]
|
||||
},
|
||||
"water": {
|
||||
"sheet": "src/assets/elements/animated/water/sheet.png",
|
||||
"frames": [
|
||||
"src/assets/elements/animated/water/frame-00.png",
|
||||
"src/assets/elements/animated/water/frame-01.png",
|
||||
"src/assets/elements/animated/water/frame-02.png",
|
||||
"src/assets/elements/animated/water/frame-03.png",
|
||||
"src/assets/elements/animated/water/frame-04.png",
|
||||
"src/assets/elements/animated/water/frame-05.png",
|
||||
"src/assets/elements/animated/water/frame-06.png",
|
||||
"src/assets/elements/animated/water/frame-07.png"
|
||||
]
|
||||
},
|
||||
"wind": {
|
||||
"sheet": "src/assets/elements/animated/wind/sheet.png",
|
||||
"frames": [
|
||||
"src/assets/elements/animated/wind/frame-00.png",
|
||||
"src/assets/elements/animated/wind/frame-01.png",
|
||||
"src/assets/elements/animated/wind/frame-02.png",
|
||||
"src/assets/elements/animated/wind/frame-03.png",
|
||||
"src/assets/elements/animated/wind/frame-04.png",
|
||||
"src/assets/elements/animated/wind/frame-05.png",
|
||||
"src/assets/elements/animated/wind/frame-06.png",
|
||||
"src/assets/elements/animated/wind/frame-07.png"
|
||||
]
|
||||
},
|
||||
"earth": {
|
||||
"sheet": "src/assets/elements/animated/earth/sheet.png",
|
||||
"frames": [
|
||||
"src/assets/elements/animated/earth/frame-00.png",
|
||||
"src/assets/elements/animated/earth/frame-01.png",
|
||||
"src/assets/elements/animated/earth/frame-02.png",
|
||||
"src/assets/elements/animated/earth/frame-03.png",
|
||||
"src/assets/elements/animated/earth/frame-04.png",
|
||||
"src/assets/elements/animated/earth/frame-05.png",
|
||||
"src/assets/elements/animated/earth/frame-06.png",
|
||||
"src/assets/elements/animated/earth/frame-07.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/assets/elements/animated/preview-contact-sheet.png
Normal file
|
After Width: | Height: | Size: 447 KiB |
BIN
src/assets/elements/animated/water/frame-00.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/elements/animated/water/frame-01.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/assets/elements/animated/water/frame-02.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/elements/animated/water/frame-03.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/assets/elements/animated/water/frame-04.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/elements/animated/water/frame-05.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/elements/animated/water/frame-06.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/assets/elements/animated/water/frame-07.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
src/assets/elements/animated/water/sheet.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
src/assets/elements/animated/wind/frame-00.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
src/assets/elements/animated/wind/frame-01.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
src/assets/elements/animated/wind/frame-02.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
src/assets/elements/animated/wind/frame-03.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
src/assets/elements/animated/wind/frame-04.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
src/assets/elements/animated/wind/frame-05.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
src/assets/elements/animated/wind/frame-06.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
src/assets/elements/animated/wind/frame-07.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
src/assets/elements/animated/wind/sheet.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
src/assets/elements/base-elements-sheet.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
src/assets/elements/earth.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
src/assets/elements/fire.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
src/assets/elements/water.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
src/assets/elements/wind.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
24
src/components/AdBanner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
const bannerStyle = css`
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(58px + env(safe-area-inset-bottom, 0px));
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-top: 1px solid #eef0f3;
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
z-index: 90;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
export function AdBanner() {
|
||||
return <div css={bannerStyle}>Rewarded Ads Ready</div>;
|
||||
}
|
||||
|
||||
137
src/components/DiscoveryHero.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { CharacterSprite } from './CharacterSprite';
|
||||
|
||||
interface DiscoveryHeroProps {
|
||||
elementId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
tier: number;
|
||||
rarity: string;
|
||||
discoveredCount: number;
|
||||
isLucky?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const enter = keyframes`
|
||||
from { opacity: 0; transform: scale(1.08); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
`;
|
||||
|
||||
const pop = keyframes`
|
||||
0% { transform: translateY(18px) scale(0.82); opacity: 0; }
|
||||
60% { transform: translateY(-4px) scale(1.08); opacity: 1; }
|
||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
`;
|
||||
|
||||
const overlayStyle = (color: string) => css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 36%, ${color}66, transparent 28%),
|
||||
linear-gradient(160deg, #090b14, #151b2e 52%, #090b14);
|
||||
animation: ${enter} 0.18s ease-out;
|
||||
`;
|
||||
|
||||
const cardStyle = css`
|
||||
width: min(360px, 100%);
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
animation: ${pop} 0.42s ease-out;
|
||||
`;
|
||||
|
||||
const labelStyle = css`
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const spriteStyle = css`
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 0 auto 14px;
|
||||
filter: drop-shadow(0 18px 34px rgba(0, 0, 0, 0.35));
|
||||
`;
|
||||
|
||||
const nameStyle = css`
|
||||
font-size: 30px;
|
||||
font-weight: 1000;
|
||||
line-height: 1.08;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const metaStyle = css`
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const copyStyle = css`
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const buttonStyle = css`
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 15px;
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export function DiscoveryHero({
|
||||
elementId,
|
||||
name,
|
||||
color,
|
||||
tier,
|
||||
rarity,
|
||||
discoveredCount,
|
||||
isLucky = false,
|
||||
onClose,
|
||||
}: DiscoveryHeroProps) {
|
||||
return (
|
||||
<div css={overlayStyle(color)} onClick={onClose}>
|
||||
<div css={cardStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div css={labelStyle}>{isLucky ? 'LUCKY DISCOVERY' : 'NEW DISCOVERY'}</div>
|
||||
<div css={spriteStyle}>
|
||||
<CharacterSprite elementId={elementId} elementColor={color} tier={tier} size={168} />
|
||||
</div>
|
||||
<div css={nameStyle}>{name}</div>
|
||||
<div css={metaStyle}>
|
||||
<span>Tier {tier}</span>
|
||||
<span>{rarity}</span>
|
||||
<span>{discoveredCount}번째 발견</span>
|
||||
</div>
|
||||
<div css={copyStyle}>
|
||||
창조자여, 새로운 빛을 발견했습니다.
|
||||
<br />
|
||||
이 원소는 이제 당신의 세계에 기록됩니다.
|
||||
</div>
|
||||
<button css={buttonStyle} onClick={onClose}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
88
src/components/IntroSplash.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface IntroSplashProps {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
const fade = keyframes`
|
||||
0% { opacity: 0; }
|
||||
14%, 82% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
`;
|
||||
|
||||
const rise = keyframes`
|
||||
from { transform: translateY(16px) scale(0.9); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
`;
|
||||
|
||||
const overlayStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 50% 38%, #27364f, #05070d 62%);
|
||||
color: white;
|
||||
animation: ${fade} 1.55s ease-in-out forwards;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const contentStyle = css`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const iconsStyle = css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
font-size: 34px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
animation: ${rise} 0.36s ease-out forwards;
|
||||
}
|
||||
|
||||
span:nth-of-type(2) { animation-delay: 0.16s; }
|
||||
span:nth-of-type(3) { animation-delay: 0.32s; }
|
||||
span:nth-of-type(4) { animation-delay: 0.48s; }
|
||||
`;
|
||||
|
||||
const lineStyle = css`
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
font-weight: 800;
|
||||
opacity: 0;
|
||||
animation: ${rise} 0.36s 0.68s ease-out forwards;
|
||||
`;
|
||||
|
||||
export function IntroSplash({ onDone }: IntroSplashProps) {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(onDone, 1550);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={overlayStyle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onDone}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') onDone();
|
||||
}}
|
||||
>
|
||||
<div css={contentStyle}>
|
||||
<div css={iconsStyle}>
|
||||
<span>🔥</span>
|
||||
<span>💧</span>
|
||||
<span>🌪️</span>
|
||||
<span>🌱</span>
|
||||
</div>
|
||||
<div css={lineStyle}>당신이 세상의 첫 불꽃입니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/lib/sfx.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
type Rarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
const FREQS: Record<Rarity, number[]> = {
|
||||
common: [420],
|
||||
uncommon: [520, 680],
|
||||
rare: [620, 820, 1040],
|
||||
epic: [440, 660, 880, 1320],
|
||||
legendary: [330, 660, 990, 1320, 1760],
|
||||
};
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
const AudioCtx =
|
||||
window.AudioContext ||
|
||||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return null;
|
||||
return new AudioCtx();
|
||||
}
|
||||
|
||||
export function playRaritySfx(rarity: string, enabled: boolean): void {
|
||||
if (!enabled) return;
|
||||
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const normalized = (['common', 'uncommon', 'rare', 'epic', 'legendary'].includes(rarity)
|
||||
? rarity
|
||||
: 'common') as Rarity;
|
||||
const freqs = FREQS[normalized];
|
||||
const now = ctx.currentTime;
|
||||
const master = ctx.createGain();
|
||||
master.gain.setValueAtTime(normalized === 'legendary' ? 0.12 : 0.08, now);
|
||||
master.connect(ctx.destination);
|
||||
|
||||
freqs.forEach((freq, index) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const start = now + index * 0.055;
|
||||
const duration = normalized === 'legendary' ? 0.34 : normalized === 'epic' ? 0.24 : 0.16;
|
||||
|
||||
osc.type = normalized === 'common' ? 'sine' : normalized === 'legendary' ? 'triangle' : 'square';
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.65, start + 0.018);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(master);
|
||||
osc.start(start);
|
||||
osc.stop(start + duration + 0.03);
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
void ctx.close().catch(() => undefined);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
export function vibrate(pattern: number | number[], enabled: boolean): void {
|
||||
if (!enabled || !navigator.vibrate) return;
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
9
src/platform/ads.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface RewardedAdResult {
|
||||
rewarded: boolean;
|
||||
}
|
||||
|
||||
export async function showRewardedAd(): Promise<RewardedAdResult> {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 450));
|
||||
return { rewarded: true };
|
||||
}
|
||||
|
||||