From a4c6764731c892f5e4e73e985606a006caa470f7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 13 Aug 2026 18:06:12 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8A=9C=ED=86=A0=EB=A6=AC=EC=96=BC=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8:=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=EC=95=BC=20=ED=95=A0=20=EA=B3=B3=EC=9D=84=20=EB=85=B8?= =?UTF-8?q?=EB=9E=80=20=EC=A0=90=EC=84=A0=20=ED=8E=84=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DOM 버튼: 입양·상점·구매(부두/벌목장/부엌)·배치·증축하기·입양 선택 버튼에 점선 아웃라인이 커졌다 작아졌다 (tut-pulse 애니메이션) - 캔버스 건물: 부두·벌목장·회관 풋프린트 둘레에 행진하는 점선 다이아몬드 (크기 펄스 + 골드 글로우) - 단계·패널 상태에 따라 타깃 자동 전환 (상점 열면 구매 버튼으로, 건물 패널 열면 배치 버튼으로 이동) Co-Authored-By: Claude Fable 5 --- src/app/globals.css | 18 +++++++ src/components/game/Game.tsx | 76 ++++++++++++++++++++++++++--- src/components/game/VillageView.tsx | 40 +++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 098b4b9..a1db609 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -212,3 +212,21 @@ body { border-radius: 999px; 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; +} diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index e53d437..4a9ab66 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -167,6 +167,38 @@ export default function Game() { ? state.buildings.find((b) => b.id === panel.id) : 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 (
setPlacing(null)} + tutHighlight={ + tutTarget?.startsWith("canvas:") ? tutTarget.slice(7) : null + } /> {/* ── 배치 모드 힌트 ── */} @@ -247,7 +282,7 @@ export default function Game() { onClick={() => 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")} > 🛒 상점 @@ -273,7 +308,7 @@ export default function Game() { ) } 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")} > 😻 입양 @@ -336,13 +371,20 @@ export default function Game() { {/* ── 우측 패널 ── */} {panel?.type === "hall" && ( setPanel(null)}> - run(() => engine.upgradeHall())} /> + run(() => engine.upgradeHall())} + highlightUpgrade={tutTarget === "hall-upgrade"} + /> )} {panel?.type === "shop" && ( setPanel(null)}> { setPanel(null); setPlacing({ type: def.type, aptitude: def.aptitude }); @@ -371,6 +413,7 @@ export default function Game() { onUnassign={(catId) => run(() => engine.unassign(catId))} onCollect={() => run(() => engine.collect(openedBuilding.id))} onUpgradeKitchen={() => run(() => engine.upgradeKitchen())} + highlightAssign={tutTarget === "assign"} onMove={() => { setPanel(null); setPlacing({ @@ -424,6 +467,7 @@ export default function Game() { {candidates && ( { run(() => engine.adoptCandidate(cat)); 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]; return (
@@ -542,7 +594,7 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo ) : ( @@ -561,10 +613,12 @@ function ShopPanel({ state, onBuy, onBuyDeco, + highlightType, }: { state: GameState; onBuy: (def: BuildDef) => void; onBuyDeco: (key: string) => void; + highlightType?: string | null; }) { return (
@@ -617,7 +671,7 @@ function ShopPanel({ @@ -666,6 +720,7 @@ function BuildingPanel({ onCollect, onUpgradeKitchen, onMove, + highlightAssign, }: { building: Building; state: GameState; @@ -674,6 +729,7 @@ function BuildingPanel({ onCollect: () => void; onUpgradeKitchen: () => void; onMove: () => void; + highlightAssign?: boolean; }) { const isKitchen = building.type === "kitchen"; const recipe = activeRecipe(state); @@ -896,6 +952,7 @@ function BuildingPanel({ label: "배치", style: "primary", disabled: slotsLeft <= 0, + highlight: highlightAssign, onClick: () => onAssign(cat.id), }} /> @@ -926,6 +983,7 @@ function CatRow({ label: string; style: "primary" | "danger"; disabled?: boolean; + highlight?: boolean; onClick: () => void; }; }) { @@ -959,7 +1017,7 @@ function CatRow({ action.style === "primary" ? "nature-btn" : "wood-plank hover:brightness-110" - }`} + }${action.highlight ? " tut-highlight" : ""}`} > {action.label} @@ -1133,10 +1191,12 @@ function AdoptModal({ candidates, onPick, onClose, + highlightPick, }: { candidates: Cat[]; onPick: (cat: Cat) => void; onClose: () => void; + highlightPick?: boolean; }) { return (
onPick(cat)} - className="nature-btn mt-auto py-2 text-sm" + className={"nature-btn mt-auto py-2 text-sm" + (highlightPick ? " tut-highlight" : "")} > 이 아이로 할래요 diff --git a/src/components/game/VillageView.tsx b/src/components/game/VillageView.tsx index 27d9acb..6f59b05 100644 --- a/src/components/game/VillageView.tsx +++ b/src/components/game/VillageView.tsx @@ -137,11 +137,13 @@ export default function VillageView({ placing, onPlace, onCancelPlace, + tutHighlight, }: { onBuildingClick?: (id: string) => void; placing?: PlacingState | null; onPlace?: (i: number, j: number) => void; onCancelPlace?: () => void; + tutHighlight?: string | null; // 건물 id 또는 "hall" — 노란 점선 펄스 }) { const canvasRef = useRef(null); const wrapRef = useRef(null); @@ -165,6 +167,8 @@ export default function VillageView({ const onCancelPlaceRef = useRef(onCancelPlace); onCancelPlaceRef.current = onCancelPlace; const hoverTileRef = useRef<{ i: number; j: number } | null>(null); + const tutHighlightRef = useRef(tutHighlight); + tutHighlightRef.current = tutHighlight; // 전체화면 스케일 (컨테이너 크기에 맞춤) useEffect(() => { @@ -754,6 +758,42 @@ export default function VillageView({ sprites.sort((a, b) => a.y - b.y); 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) { // 배치 가능한 앵커 타일 은은하게 표시