튜토리얼 하이라이트: 눌러야 할 곳을 노란 점선 펄스로 안내
- DOM 버튼: 입양·상점·구매(부두/벌목장/부엌)·배치·증축하기·입양 선택 버튼에 점선 아웃라인이 커졌다 작아졌다 (tut-pulse 애니메이션) - 캔버스 건물: 부두·벌목장·회관 풋프린트 둘레에 행진하는 점선 다이아몬드 (크기 펄스 + 골드 글로우) - 단계·패널 상태에 따라 타깃 자동 전환 (상점 열면 구매 버튼으로, 건물 패널 열면 배치 버튼으로 이동) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<VillageView
|
||||
@@ -188,6 +220,9 @@ export default function Game() {
|
||||
if (!error) setPlacing(null);
|
||||
}}
|
||||
onCancelPlace={() => 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")}
|
||||
>
|
||||
🛒 상점
|
||||
</button>
|
||||
@@ -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")}
|
||||
>
|
||||
😻 입양
|
||||
</button>
|
||||
@@ -336,13 +371,20 @@ export default function Game() {
|
||||
{/* ── 우측 패널 ── */}
|
||||
{panel?.type === "hall" && (
|
||||
<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>
|
||||
)}
|
||||
{panel?.type === "shop" && (
|
||||
<SidePanel title="🛒 건물 상점" onClose={() => setPanel(null)}>
|
||||
<ShopPanel
|
||||
state={state}
|
||||
highlightType={
|
||||
tutTarget?.startsWith("shop-") ? tutTarget.slice(5) : null
|
||||
}
|
||||
onBuy={(def) => {
|
||||
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 && (
|
||||
<AdoptModal
|
||||
candidates={candidates}
|
||||
highlightPick={tutTarget === "adopt-pick"}
|
||||
onPick={(cat) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
@@ -542,7 +594,7 @@ function HallPanel({ state, onUpgrade }: { state: GameState; onUpgrade: () => vo
|
||||
) : (
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="nature-btn w-full py-2 text-sm"
|
||||
className={"nature-btn w-full py-2 text-sm" + (highlightUpgrade ? " tut-highlight" : "")}
|
||||
>
|
||||
🏗️ 증축하기
|
||||
</button>
|
||||
@@ -561,10 +613,12 @@ function ShopPanel({
|
||||
state,
|
||||
onBuy,
|
||||
onBuyDeco,
|
||||
highlightType,
|
||||
}: {
|
||||
state: GameState;
|
||||
onBuy: (def: BuildDef) => void;
|
||||
onBuyDeco: (key: string) => void;
|
||||
highlightType?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -617,7 +671,7 @@ function ShopPanel({
|
||||
<button
|
||||
onClick={() => onBuy(def)}
|
||||
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 ? "자원 부족" : "구매"}
|
||||
</button>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -1133,10 +1191,12 @@ function AdoptModal({
|
||||
candidates,
|
||||
onPick,
|
||||
onClose,
|
||||
highlightPick,
|
||||
}: {
|
||||
candidates: Cat[];
|
||||
onPick: (cat: Cat) => void;
|
||||
onClose: () => void;
|
||||
highlightPick?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -1214,7 +1274,7 @@ function AdoptModal({
|
||||
|
||||
<button
|
||||
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>
|
||||
|
||||
@@ -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<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(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<string | null | undefined>(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) {
|
||||
// 배치 가능한 앵커 타일 은은하게 표시
|
||||
|
||||
새 이슈에서 참조
사용자 차단