인트로 화면: 타이틀 일러스트 + 구름 걷힘 연출

- 접속 시 intro.png 타이틀 화면, 중앙 하단 둥실거리는 시작하기 버튼
- 시작하기 클릭: 인트로가 1초간 투명해지며 아래 깔린 구름 레이어가
  드러나고, 구름 두 장이 양옆으로 1.9초간 걷히며 마을 진입
- 구름은 CSS로 구성 (블러 처리한 본체 + 안쪽 가장자리 뭉게 퍼프 7개)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 16:56:43 +09:00
co-authored by Claude Fable 5
부모 e7c47b0a12
커밋 9a37cc5e6c
4개의 변경된 파일과 119개의 추가작업 그리고 0개의 파일을 삭제
+34
파일 보기
@@ -230,3 +230,37 @@ body {
animation: tut-pulse 1.1s ease-in-out infinite;
border-radius: 14px;
}
/* ── 인트로: 구름 걷힘 연출 ── */
.intro-cloud {
position: absolute;
top: -14%;
bottom: -14%;
width: 68%;
transition: transform 1.9s cubic-bezier(0.65, 0.03, 0.35, 1) 0.25s;
}
.intro-cloud-left {
left: -6%;
}
.intro-cloud-right {
right: -6%;
}
.intro-leaving .intro-cloud-left {
transform: translateX(-120%);
}
.intro-leaving .intro-cloud-right {
transform: translateX(120%);
}
.cloud-puff {
position: absolute;
border-radius: 9999px;
background: #fff;
filter: blur(16px);
}
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-7px); }
}
.animate-bounce-slow {
animation: bounce-slow 1.6s ease-in-out infinite;
}
+4
파일 보기
@@ -7,6 +7,7 @@ import DbStatus from "@/components/DbStatus";
import NicknameModal from "@/components/game/NicknameModal";
import ChatTicker from "@/components/game/ChatTicker";
import ChatPanel from "@/components/game/ChatPanel";
import IntroScreen from "@/components/game/IntroScreen";
import {
engine,
effectiveRate,
@@ -119,6 +120,7 @@ type Panel =
export default function Game() {
const { state, dispatch } = useGame();
const [panel, setPanel] = useState<Panel>(null);
const [showIntro, setShowIntro] = useState(true);
const [candidates, setCandidates] = useState<Cat[] | null>(null);
const [placing, setPlacing] = useState<PlacingState | null>(null);
const [message, setMessage] = useState<string | null>(null);
@@ -211,6 +213,8 @@ export default function Game() {
return (
<div className="fixed inset-0 overflow-hidden">
{/* ── 인트로 (시작하기 → 구름 걷힘) ── */}
{showIntro && <IntroScreen onDone={() => setShowIntro(false)} />}
<VillageView
onBuildingClick={(id) => {
if (id === "hall") setPanel({ type: "hall" });
+81
파일 보기
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
const LEAVE_MS = 2300; // 연출 총 길이 (인트로 페이드 + 구름 걷힘)
// 구름 안쪽 가장자리의 뭉게 퍼프 (top%, size(vh), edgeOffset(%))
const PUFFS: Array<[number, number, number]> = [
[-4, 26, -8],
[12, 34, -3],
[30, 28, -10],
[46, 38, -2],
[63, 30, -9],
[78, 36, -4],
[92, 27, -8],
];
function CloudHalf({ side }: { side: "left" | "right" }) {
return (
<div className={`intro-cloud intro-cloud-${side}`}>
{/* 구름 본체 */}
<div className="absolute inset-0 bg-white" style={{ filter: "blur(10px)" }} />
{/* 안쪽 가장자리 뭉게뭉게 */}
{PUFFS.map(([top, size, off], i) => (
<div
key={i}
className="cloud-puff"
style={{
top: `${top}%`,
[side === "left" ? "right" : "left"]: `${off}%`,
width: `${size}vh`,
height: `${size * 0.72}vh`,
}}
/>
))}
</div>
);
}
/** 시작 화면: 타이틀 일러스트 + 시작하기 → 구름이 걷히며 마을 진입 */
export default function IntroScreen({ onDone }: { onDone: () => void }) {
const [leaving, setLeaving] = useState(false);
const start = () => {
if (leaving) return;
setLeaving(true);
setTimeout(onDone, LEAVE_MS);
};
return (
<div
className={`fixed inset-0 z-[100] overflow-hidden ${leaving ? "intro-leaving pointer-events-none" : ""}`}
>
{/* 구름 레이어 (인트로 아래) — 인트로가 투명해지면 드러나고, 양옆으로 걷힘 */}
<CloudHalf side="left" />
<CloudHalf side="right" />
{/* 타이틀 일러스트 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/assets/intro.png"
alt="고양이 마을"
className="absolute inset-0 h-full w-full object-cover transition-opacity duration-1000 ease-out"
style={{ opacity: leaving ? 0 : 1 }}
/>
{/* 시작하기 버튼 */}
<div
className="absolute inset-x-0 bottom-[16%] flex justify-center transition-opacity duration-500"
style={{ opacity: leaving ? 0 : 1 }}
>
<button
onClick={start}
className="nature-btn animate-bounce-slow px-12 py-4 text-2xl shadow-2xl"
>
🐾 시작하기
</button>
</div>
</div>
);
}