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>
This commit is contained in:
2026-05-02 07:33:56 +09:00
parent ca6d6f15f8
commit 90ba98fbf7
48 changed files with 381 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View 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"
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View 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>;
}

View 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>
);
}

View 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
View 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
View 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 };
}