튜토리얼 하이라이트: 눌러야 할 곳을 노란 점선 펄스로 안내
- DOM 버튼: 입양·상점·구매(부두/벌목장/부엌)·배치·증축하기·입양 선택 버튼에 점선 아웃라인이 커졌다 작아졌다 (tut-pulse 애니메이션) - 캔버스 건물: 부두·벌목장·회관 풋프린트 둘레에 행진하는 점선 다이아몬드 (크기 펄스 + 골드 글로우) - 단계·패널 상태에 따라 타깃 자동 전환 (상점 열면 구매 버튼으로, 건물 패널 열면 배치 버튼으로 이동) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
// 배치 가능한 앵커 타일 은은하게 표시
|
// 배치 가능한 앵커 타일 은은하게 표시
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단