튜토리얼 하이라이트: 눌러야 할 곳을 노란 점선 펄스로 안내

- DOM 버튼: 입양·상점·구매(부두/벌목장/부엌)·배치·증축하기·입양 선택
  버튼에 점선 아웃라인이 커졌다 작아졌다 (tut-pulse 애니메이션)
- 캔버스 건물: 부두·벌목장·회관 풋프린트 둘레에 행진하는 점선 다이아몬드
  (크기 펄스 + 골드 글로우)
- 단계·패널 상태에 따라 타깃 자동 전환 (상점 열면 구매 버튼으로,
  건물 패널 열면 배치 버튼으로 이동)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-13 18:06:12 +09:00
co-authored by Claude Fable 5
부모 6225403290
커밋 a4c6764731
3개의 변경된 파일과 126개의 추가작업 그리고 8개의 파일을 삭제
+18
파일 보기
@@ -212,3 +212,21 @@ body {
border-radius: 999px; border-radius: 999px;
background: linear-gradient(90deg, #d66a4a, #ee9367); background: linear-gradient(90deg, #d66a4a, #ee9367);
} }
/* ── 튜토리얼 하이라이트 (노란 점선 펄스) ── */
@keyframes tut-pulse {
0%,
100% {
outline-offset: 3px;
box-shadow: 0 0 10px rgba(250, 204, 21, 0.55);
}
50% {
outline-offset: 8px;
box-shadow: 0 0 22px rgba(250, 204, 21, 0.85);
}
}
.tut-highlight {
outline: 3px dashed #facc15;
animation: tut-pulse 1.1s ease-in-out infinite;
border-radius: 14px;
}
+68 -8
파일 보기
@@ -167,6 +167,38 @@ export default function Game() {
? state.buildings.find((b) => b.id === panel.id) ? state.buildings.find((b) => b.id === panel.id)
: undefined; : undefined;
// 튜토리얼: 지금 눌러야 할 곳 하이라이트 타깃
const tutStep = state.tutorialStep ?? TUTORIAL_DONE;
const tutTarget: string | null = (() => {
if (tutStep >= TUTORIAL_STEPS.length) return null;
const dock = state.buildings.find((b) => b.type === "dock");
const lumber = state.buildings.find((b) => b.type === "lumberyard");
switch (tutStep) {
case 1:
case 4:
return candidates ? "adopt-pick" : "adopt";
case 2:
return panel?.type === "shop" ? "shop-dock" : placing ? null : "shop";
case 3:
if (!dock) return null;
if (panel?.type === "building" && panel.id === dock.id) return "assign";
return `canvas:${dock.id}`;
case 5:
return panel?.type === "shop" ? "shop-lumberyard" : placing ? null : "shop";
case 6:
if (!lumber) return null;
if (panel?.type === "building" && panel.id === lumber.id) return "assign";
return `canvas:${lumber.id}`;
case 7:
return panel?.type === "shop" ? "shop-kitchen" : placing ? null : "shop";
case 8:
return panel?.type === "hall" ? "hall-upgrade" : "canvas:hall";
default:
return null;
}
})();
const tut = (key: string) => (tutTarget === key ? " tut-highlight" : "");
return ( return (
<div className="fixed inset-0 overflow-hidden"> <div className="fixed inset-0 overflow-hidden">
<VillageView <VillageView
@@ -188,6 +220,9 @@ export default function Game() {
if (!error) setPlacing(null); if (!error) setPlacing(null);
}} }}
onCancelPlace={() => setPlacing(null)} onCancelPlace={() => setPlacing(null)}
tutHighlight={
tutTarget?.startsWith("canvas:") ? tutTarget.slice(7) : null
}
/> />
{/* ── 배치 모드 힌트 ── */} {/* ── 배치 모드 힌트 ── */}
@@ -247,7 +282,7 @@ export default function Game() {
onClick={() => onClick={() =>
setPanel(panel?.type === "shop" ? null : { type: "shop" }) setPanel(panel?.type === "shop" ? null : { type: "shop" })
} }
className="wood-pill font-jua flex items-center gap-1.5 px-4 py-2 text-[15px] hover:brightness-125" className={"wood-pill font-jua flex items-center gap-1.5 px-4 py-2 text-[15px] hover:brightness-125" + tut("shop")}
> >
🛒 상점 🛒 상점
</button> </button>
@@ -273,7 +308,7 @@ export default function Game() {
) )
} }
disabled={state.cats.length >= state.hall.capacity} disabled={state.cats.length >= state.hall.capacity}
className="nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50" className={"nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50" + tut("adopt")}
> >
😻 입양 😻 입양
</button> </button>
@@ -336,13 +371,20 @@ export default function Game() {
{/* ── 우측 패널 ── */} {/* ── 우측 패널 ── */}
{panel?.type === "hall" && ( {panel?.type === "hall" && (
<SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}> <SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}>
<HallPanel state={state} onUpgrade={() => run(() => engine.upgradeHall())} /> <HallPanel
state={state}
onUpgrade={() => run(() => engine.upgradeHall())}
highlightUpgrade={tutTarget === "hall-upgrade"}
/>
</SidePanel> </SidePanel>
)} )}
{panel?.type === "shop" && ( {panel?.type === "shop" && (
<SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}> <SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}>
<ShopPanel <ShopPanel
state={state} state={state}
highlightType={
tutTarget?.startsWith("shop-") ? tutTarget.slice(5) : null
}
onBuy={(def) => { onBuy={(def) => {
setPanel(null); setPanel(null);
setPlacing({ type: def.type, aptitude: def.aptitude }); setPlacing({ type: def.type, aptitude: def.aptitude });
@@ -371,6 +413,7 @@ export default function Game() {
onUnassign={(catId) => run(() => engine.unassign(catId))} onUnassign={(catId) => run(() => engine.unassign(catId))}
onCollect={() => run(() => engine.collect(openedBuilding.id))} onCollect={() => run(() => engine.collect(openedBuilding.id))}
onUpgradeKitchen={() => run(() => engine.upgradeKitchen())} onUpgradeKitchen={() => run(() => engine.upgradeKitchen())}
highlightAssign={tutTarget === "assign"}
onMove={() => { onMove={() => {
setPanel(null); setPanel(null);
setPlacing({ setPlacing({
@@ -424,6 +467,7 @@ export default function Game() {
{candidates && ( {candidates && (
<AdoptModal <AdoptModal
candidates={candidates} candidates={candidates}
highlightPick={tutTarget === "adopt-pick"}
onPick={(cat) => { onPick={(cat) => {
run(() => engine.adoptCandidate(cat)); run(() => engine.adoptCandidate(cat));
setCandidates(null); setCandidates(null);
@@ -480,7 +524,15 @@ function SidePanel({
/* ──────────────── 회관 패널 ──────────────── */ /* ──────────────── 회관 패널 ──────────────── */
function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => void }) { function HallPanel({
state,
onUpgrade,
highlightUpgrade,
}: {
state: GameState;
onUpgrade: () => void;
highlightUpgrade?: boolean;
}) {
const next = HALL_LEVELS[state.hall.level + 1]; const next = HALL_LEVELS[state.hall.level + 1];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -542,7 +594,7 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
) : ( ) : (
<button <button
onClick={onUpgrade} onClick={onUpgrade}
className="nature-btn w-full py-2 text-sm" className={"nature-btn w-full py-2 text-sm" + (highlightUpgrade ? " tut-highlight" : "")}
> >
🏗️ 증축하기 🏗️ 증축하기
</button> </button>
@@ -561,10 +613,12 @@ function ShopPanel({
state, state,
onBuy, onBuy,
onBuyDeco, onBuyDeco,
highlightType,
}: { }: {
state: GameState; state: GameState;
onBuy: (def: BuildDef) => void; onBuy: (def: BuildDef) => void;
onBuyDeco: (key: string) => void; onBuyDeco: (key: string) => void;
highlightType?: string | null;
}) { }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -617,7 +671,7 @@ function ShopPanel({
<button <button
onClick={() => onBuy(def)} onClick={() => onBuy(def)}
disabled={!hallOk || !affordable} disabled={!hallOk || !affordable}
className="amber-btn shrink-0 px-3.5 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-40" className={"amber-btn shrink-0 px-3.5 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-40" + (highlightType === def.type ? " tut-highlight" : "")}
> >
{!hallOk ? `회관 Lv${def.reqHallLv}` : !affordable ? "자원 부족" : "구매"} {!hallOk ? `회관 Lv${def.reqHallLv}` : !affordable ? "자원 부족" : "구매"}
</button> </button>
@@ -666,6 +720,7 @@ function BuildingPanel({
onCollect, onCollect,
onUpgradeKitchen, onUpgradeKitchen,
onMove, onMove,
highlightAssign,
}: { }: {
building: Building; building: Building;
state: GameState; state: GameState;
@@ -674,6 +729,7 @@ function BuildingPanel({
onCollect: () => void; onCollect: () => void;
onUpgradeKitchen: () => void; onUpgradeKitchen: () => void;
onMove: () => void; onMove: () => void;
highlightAssign?: boolean;
}) { }) {
const isKitchen = building.type === "kitchen"; const isKitchen = building.type === "kitchen";
const recipe = activeRecipe(state); const recipe = activeRecipe(state);
@@ -896,6 +952,7 @@ function BuildingPanel({
label: "배치", label: "배치",
style: "primary", style: "primary",
disabled: slotsLeft <= 0, disabled: slotsLeft <= 0,
highlight: highlightAssign,
onClick: () => onAssign(cat.id), onClick: () => onAssign(cat.id),
}} }}
/> />
@@ -926,6 +983,7 @@ function CatRow({
label: string; label: string;
style: "primary" | "danger"; style: "primary" | "danger";
disabled?: boolean; disabled?: boolean;
highlight?: boolean;
onClick: () => void; onClick: () => void;
}; };
}) { }) {
@@ -959,7 +1017,7 @@ function CatRow({
action.style === "primary" action.style === "primary"
? "nature-btn" ? "nature-btn"
: "wood-plank hover:brightness-110" : "wood-plank hover:brightness-110"
}`} }${action.highlight ? " tut-highlight" : ""}`}
> >
{action.label} {action.label}
</button> </button>
@@ -1133,10 +1191,12 @@ function AdoptModal({
candidates, candidates,
onPick, onPick,
onClose, onClose,
highlightPick,
}: { }: {
candidates: Cat[]; candidates: Cat[];
onPick: (cat: Cat) => void; onPick: (cat: Cat) => void;
onClose: () => void; onClose: () => void;
highlightPick?: boolean;
}) { }) {
return ( return (
<div <div
@@ -1214,7 +1274,7 @@ function AdoptModal({
<button <button
onClick={() => onPick(cat)} onClick={() => onPick(cat)}
className="nature-btn mt-auto py-2 text-sm" className={"nature-btn mt-auto py-2 text-sm" + (highlightPick ? " tut-highlight" : "")}
> >
이 아이로 할래요 이 아이로 할래요
</button> </button>
+40
파일 보기
@@ -137,11 +137,13 @@ export default function VillageView({
placing, placing,
onPlace, onPlace,
onCancelPlace, onCancelPlace,
tutHighlight,
}: { }: {
onBuildingClick?: (id: string) => void; onBuildingClick?: (id: string) => void;
placing?: PlacingState | null; placing?: PlacingState | null;
onPlace?: (i: number, j: number) => void; onPlace?: (i: number, j: number) => void;
onCancelPlace?: () => void; onCancelPlace?: () => void;
tutHighlight?: string | null; // 건물 id 또는 "hall" — 노란 점선 펄스
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
@@ -165,6 +167,8 @@ export default function VillageView({
const onCancelPlaceRef = useRef(onCancelPlace); const onCancelPlaceRef = useRef(onCancelPlace);
onCancelPlaceRef.current = onCancelPlace; onCancelPlaceRef.current = onCancelPlace;
const hoverTileRef = useRef<{ i: number; j: number } | null>(null); const hoverTileRef = useRef<{ i: number; j: number } | null>(null);
const tutHighlightRef = useRef<string | null | undefined>(tutHighlight);
tutHighlightRef.current = tutHighlight;
// 전체화면 스케일 (컨테이너 크기에 맞춤) // 전체화면 스케일 (컨테이너 크기에 맞춤)
useEffect(() => { useEffect(() => {
@@ -754,6 +758,42 @@ export default function VillageView({
sprites.sort((a, b) => a.y - b.y); sprites.sort((a, b) => a.y - b.y);
for (const s of sprites) s.draw(); for (const s of sprites) s.draw();
// --- 튜토리얼 하이라이트: 대상 건물 둘레 노란 점선 펄스 ---
const tut = tutHighlightRef.current;
if (tut) {
const targetPos =
tut === "hall"
? HALL_TILE
: state.buildings.find((b) => b.id === tut)?.pos;
const targetType =
tut === "hall"
? "hall"
: state.buildings.find((b) => b.id === tut)?.type;
if (targetPos && targetType) {
const fp = footprintOf(targetType);
const grow = 6 + Math.sin(now / 300) * 5; // 커졌다 작아졌다
const a = vertex(targetPos.i, targetPos.j);
const b = vertex(targetPos.i + fp.w, targetPos.j);
const c = vertex(targetPos.i + fp.w, targetPos.j + fp.h);
const d = vertex(targetPos.i, targetPos.j + fp.h);
ctx.save();
ctx.beginPath();
ctx.moveTo(a.x, a.y - grow);
ctx.lineTo(b.x + grow, b.y);
ctx.lineTo(c.x, c.y + grow);
ctx.lineTo(d.x - grow, d.y);
ctx.closePath();
ctx.setLineDash([10, 7]);
ctx.lineDashOffset = -now / 40; // 행진하는 점선
ctx.strokeStyle = "#facc15";
ctx.lineWidth = 3.5;
ctx.shadowColor = "rgba(250,204,21,0.8)";
ctx.shadowBlur = 10 + Math.sin(now / 300) * 5;
ctx.stroke();
ctx.restore();
}
}
// --- 배치 모드 오버레이 (고스트 + 유효/무효 하이라이트) --- // --- 배치 모드 오버레이 (고스트 + 유효/무효 하이라이트) ---
if (placing) { if (placing) {
// 배치 가능한 앵커 타일 은은하게 표시 // 배치 가능한 앵커 타일 은은하게 표시