- public/assets/grass.jpg (1200px JPEG 최적화) - 잔디 타일 영역 클리핑 채움, 격자 힌트는 미세한 화이트 오버레이로 유지 - 로드 전 단색 폴백 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
472 라인
16 KiB
TypeScript
472 라인
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { engine, catRate } from "@/lib/game/engine";
|
|
import { TOOL_IMG } from "@/lib/game/data";
|
|
import type { AptitudeKey } from "@/lib/game/types";
|
|
|
|
// ---------- 아이소메트릭 그리드 ----------
|
|
const COLS = 11;
|
|
const ROWS = 11;
|
|
const TW = 64; // 타일 가로
|
|
const TH = 32; // 타일 세로
|
|
const PAD_Y = 56;
|
|
const CANVAS_W = ((COLS + ROWS) * TW) / 2 + 16;
|
|
const CANVAS_H = ((COLS + ROWS) * TH) / 2 + PAD_Y + 48;
|
|
const ORIGIN_X = (ROWS * TW) / 2 + 8;
|
|
|
|
const isoX = (i: number, j: number) => ORIGIN_X + ((i - j) * TW) / 2;
|
|
const isoY = (i: number, j: number) => PAD_Y + ((i + j) * TH) / 2;
|
|
|
|
// 물 (동쪽 끝 열), 부두는 물가에 붙임
|
|
const isWater = (i: number, j: number) => i >= COLS - 1;
|
|
|
|
// 건물 위치 (타일 좌표)
|
|
const BUILDING_TILES: Record<string, { i: number; j: number }> = {
|
|
hall: { i: 5, j: 3 },
|
|
dock_1: { i: 9, j: 5 },
|
|
lumberyard_1: { i: 2, j: 8 },
|
|
};
|
|
|
|
const TREES: Array<{ i: number; j: number }> = [
|
|
{ i: 0, j: 7 }, { i: 1, j: 9 }, { i: 3, j: 9 }, { i: 0, j: 9 },
|
|
{ i: 1, j: 6 }, { i: 3, j: 7 },
|
|
];
|
|
const DECOR: Array<{ i: number; j: number; emoji: string }> = [
|
|
{ i: 7, j: 1, emoji: "🌼" }, { i: 3, j: 2, emoji: "🌷" },
|
|
{ i: 8, j: 8, emoji: "🌾" }, { i: 6, j: 9, emoji: "🍄" },
|
|
{ i: 1, j: 3, emoji: "🪨" },
|
|
];
|
|
|
|
const isBuildingTile = (i: number, j: number) =>
|
|
Object.values(BUILDING_TILES).some((t) => t.i === i && t.j === j);
|
|
const isWalkable = (i: number, j: number) =>
|
|
i >= 0 && i < COLS - 1 && j >= 0 && j < ROWS &&
|
|
!isBuildingTile(i, j) &&
|
|
!TREES.some((t) => t.i === i && t.j === j);
|
|
|
|
// ---------- 고양이 이동 상태 (렌더 전용, 저장 안 함) ----------
|
|
interface CatAnim {
|
|
x: number;
|
|
y: number;
|
|
tx: number;
|
|
ty: number;
|
|
mode: "wander" | "toWork" | "working";
|
|
waitUntil: number;
|
|
flip: boolean;
|
|
}
|
|
interface Bubble {
|
|
x: number;
|
|
y: number;
|
|
born: number;
|
|
text: string;
|
|
}
|
|
|
|
const WALK_SPEED = 42; // px/초
|
|
|
|
function randomWalkTarget(): { x: number; y: number } {
|
|
for (let tries = 0; tries < 30; tries++) {
|
|
const i = Math.floor(Math.random() * (COLS - 1));
|
|
const j = Math.floor(Math.random() * ROWS);
|
|
if (isWalkable(i, j)) return { x: isoX(i, j), y: isoY(i, j) + TH / 2 };
|
|
}
|
|
return { x: isoX(5, 5), y: isoY(5, 5) + TH / 2 };
|
|
}
|
|
|
|
function workSpot(buildingId: string, slotIndex: number) {
|
|
const tile = BUILDING_TILES[buildingId] ?? BUILDING_TILES.hall;
|
|
// 건물 이름 라벨(정남쪽)을 피해서 좌우·아래대각에 배치
|
|
const angle = (slotIndex * 2 * Math.PI) / 3 + Math.PI / 6;
|
|
return {
|
|
x: isoX(tile.i, tile.j) + Math.cos(angle) * 42,
|
|
y: isoY(tile.i, tile.j) + TH / 2 + Math.sin(angle) * 16 + 8,
|
|
};
|
|
}
|
|
|
|
export default function VillageView() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const animsRef = useRef<Map<string, CatAnim>>(new Map());
|
|
const bubblesRef = useRef<Bubble[]>([]);
|
|
const toolImagesRef = useRef<Map<AptitudeKey, HTMLImageElement>>(new Map());
|
|
const waterImgRef = useRef<HTMLImageElement | null>(null);
|
|
const grassImgRef = useRef<HTMLImageElement | null>(null);
|
|
const hitRectsRef = useRef<
|
|
Array<{ id: string; x: number; y: number; w: number; h: number }>
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
canvas.width = CANVAS_W * dpr;
|
|
canvas.height = CANVAS_H * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// 도구 일러스트 프리로드 (로드 전에는 이모지로 폴백)
|
|
for (const [key, src] of Object.entries(TOOL_IMG)) {
|
|
const img = new Image();
|
|
img.src = src;
|
|
img.onload = () => toolImagesRef.current.set(key as AptitudeKey, img);
|
|
}
|
|
// 강물·잔디 텍스처 (로드 전에는 단색 폴백)
|
|
{
|
|
const img = new Image();
|
|
img.src = "/assets/water.jpg";
|
|
img.onload = () => (waterImgRef.current = img);
|
|
const grass = new Image();
|
|
grass.src = "/assets/grass.jpg";
|
|
grass.onload = () => (grassImgRef.current = grass);
|
|
}
|
|
|
|
let raf = 0;
|
|
let last = performance.now();
|
|
|
|
const draw = (now: number) => {
|
|
const dt = Math.min((now - last) / 1000, 0.1);
|
|
last = now;
|
|
const state = engine.peek();
|
|
if (!state) {
|
|
raf = requestAnimationFrame(draw);
|
|
return;
|
|
}
|
|
|
|
// --- 고양이 이동 갱신 ---
|
|
const anims = animsRef.current;
|
|
const alive = new Set<string>();
|
|
for (const cat of state.cats) {
|
|
alive.add(cat.id);
|
|
let anim = anims.get(cat.id);
|
|
if (!anim) {
|
|
const start = randomWalkTarget();
|
|
anim = { ...start, tx: start.x, ty: start.y, mode: "wander", waitUntil: 0, flip: false };
|
|
anims.set(cat.id, anim);
|
|
}
|
|
|
|
if (cat.assignedTo) {
|
|
const building = state.buildings.find((b) => b.id === cat.assignedTo);
|
|
const slot = building ? building.assigned.indexOf(cat.id) : 0;
|
|
const spot = workSpot(cat.assignedTo, Math.max(0, slot));
|
|
if (anim.mode !== "working" && (anim.tx !== spot.x || anim.ty !== spot.y)) {
|
|
anim.tx = spot.x;
|
|
anim.ty = spot.y;
|
|
anim.mode = "toWork";
|
|
}
|
|
} else if (anim.mode !== "wander") {
|
|
anim.mode = "wander";
|
|
const t = randomWalkTarget();
|
|
anim.tx = t.x;
|
|
anim.ty = t.y;
|
|
}
|
|
|
|
const dx = anim.tx - anim.x;
|
|
const dy = anim.ty - anim.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > 2) {
|
|
const step = Math.min(WALK_SPEED * dt, dist);
|
|
anim.x += (dx / dist) * step;
|
|
anim.y += (dy / dist) * step;
|
|
anim.flip = dx < 0;
|
|
} else if (anim.mode === "toWork") {
|
|
anim.mode = "working";
|
|
} else if (anim.mode === "working") {
|
|
// 일하는 중 — 생산 말풍선 (약 2.5초에 한 번)
|
|
if (Math.random() < dt / 2.5 && cat.assignedTo) {
|
|
const b = state.buildings.find((x) => x.id === cat.assignedTo);
|
|
if (b && catRate(cat, b.aptitude) > 0) {
|
|
bubblesRef.current.push({
|
|
x: anim.x, y: anim.y - 34, born: now,
|
|
text: b.resource === "fish" ? "🐟" : "🪵",
|
|
});
|
|
}
|
|
}
|
|
} else if (anim.mode === "wander") {
|
|
if (anim.waitUntil === 0) {
|
|
anim.waitUntil = now + 800 + Math.random() * 2600;
|
|
} else if (now >= anim.waitUntil) {
|
|
anim.waitUntil = 0;
|
|
const t = randomWalkTarget();
|
|
anim.tx = t.x;
|
|
anim.ty = t.y;
|
|
}
|
|
}
|
|
}
|
|
for (const id of anims.keys()) if (!alive.has(id)) anims.delete(id);
|
|
|
|
// --- 그리기 ---
|
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// 하늘 배경 없음(투명) — 타일부터
|
|
const tilePath = (i: number, j: number) => {
|
|
const x = isoX(i, j);
|
|
const y = isoY(i, j);
|
|
ctx.moveTo(x, y);
|
|
ctx.lineTo(x + TW / 2, y + TH / 2);
|
|
ctx.lineTo(x, y + TH);
|
|
ctx.lineTo(x - TW / 2, y + TH / 2);
|
|
ctx.closePath();
|
|
};
|
|
|
|
// 잔디 — 일러스트 텍스처를 잔디 타일 영역에 클리핑해서 채움
|
|
{
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
for (let j = 0; j < ROWS; j++)
|
|
for (let i = 0; i < COLS; i++) if (!isWater(i, j)) tilePath(i, j);
|
|
ctx.clip();
|
|
|
|
const grass = grassImgRef.current;
|
|
if (grass) {
|
|
const scale = Math.max(CANVAS_W / grass.width, CANVAS_H / grass.height);
|
|
ctx.drawImage(grass, 0, 0, grass.width * scale, grass.height * scale);
|
|
} else {
|
|
ctx.fillStyle = "#82c368";
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
}
|
|
|
|
// 타일 격자 힌트 (배치 가독성 유지)
|
|
for (let j = 0; j < ROWS; j++) {
|
|
for (let i = 0; i < COLS; i++) {
|
|
if (isWater(i, j)) continue;
|
|
ctx.beginPath();
|
|
tilePath(i, j);
|
|
if ((i + j) % 2 === 0) {
|
|
ctx.fillStyle = "rgba(255,255,255,0.045)";
|
|
ctx.fill();
|
|
}
|
|
ctx.strokeStyle = "rgba(255,255,255,0.08)";
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// 강물 — 일러스트 텍스처를 물 타일 영역에 클리핑해서 채움
|
|
{
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
for (let j = 0; j < ROWS; j++)
|
|
for (let i = 0; i < COLS; i++) if (isWater(i, j)) tilePath(i, j);
|
|
ctx.clip();
|
|
|
|
const water = waterImgRef.current;
|
|
if (water) {
|
|
// 물 영역 바운딩 박스를 덮도록 cover 배치 + 흐르는 방향으로 느린 드리프트
|
|
const left = isoX(COLS - 1, ROWS - 1) - TW / 2;
|
|
const right = isoX(COLS - 1, 0) + TW / 2;
|
|
const top = isoY(COLS - 1, 0);
|
|
const bottom = isoY(COLS - 1, ROWS - 1) + TH;
|
|
const boxW = right - left;
|
|
const boxH = bottom - top;
|
|
const scale = Math.max(boxW / water.width, boxH / water.height) * 1.15;
|
|
const w = water.width * scale;
|
|
const h = water.height * scale;
|
|
// 강 줄기 방향(좌하향)으로 왕복 드리프트
|
|
const drift = Math.sin(now / 4000) * 14;
|
|
ctx.drawImage(
|
|
water,
|
|
left + (boxW - w) / 2 - drift,
|
|
top + (boxH - h) / 2 + drift / 2,
|
|
w,
|
|
h,
|
|
);
|
|
} else {
|
|
ctx.fillStyle = "#60a5fa";
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
}
|
|
|
|
// 은은한 반짝임 오버레이
|
|
for (let j = 0; j < ROWS; j++) {
|
|
const glint = 0.05 + 0.05 * Math.sin(now / 700 + j);
|
|
ctx.beginPath();
|
|
tilePath(COLS - 1, j);
|
|
ctx.fillStyle = `rgba(255,255,255,${glint})`;
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// 그릴 것들을 y 순으로 정렬해 겹침 처리
|
|
type Sprite = { y: number; draw: () => void };
|
|
const sprites: Sprite[] = [];
|
|
|
|
const drawEmoji = (emoji: string, x: number, y: number, size: number, flip = false) => {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
if (flip) ctx.scale(-1, 1);
|
|
ctx.font = `${size}px serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "alphabetic";
|
|
// 컬러 이모지에도 fillStyle의 알파가 적용되므로 반드시 불투명으로 리셋
|
|
ctx.fillStyle = "#111";
|
|
ctx.fillText(emoji, 0, 0);
|
|
ctx.restore();
|
|
};
|
|
const shadow = (x: number, y: number, w: number) => {
|
|
ctx.beginPath();
|
|
ctx.ellipse(x, y, w, w * 0.35, 0, 0, Math.PI * 2);
|
|
ctx.fillStyle = "rgba(0,0,0,0.15)";
|
|
ctx.fill();
|
|
};
|
|
|
|
for (const t of TREES) {
|
|
const x = isoX(t.i, t.j);
|
|
const y = isoY(t.i, t.j) + TH / 2;
|
|
sprites.push({ y, draw: () => { shadow(x, y + 4, 14); drawEmoji("🌲", x, y + 6, 34); } });
|
|
}
|
|
for (const d of DECOR) {
|
|
const x = isoX(d.i, d.j);
|
|
const y = isoY(d.i, d.j) + TH / 2;
|
|
sprites.push({ y: y - 6, draw: () => drawEmoji(d.emoji, x, y + 4, 18) });
|
|
}
|
|
|
|
// 회관
|
|
{
|
|
const t = BUILDING_TILES.hall;
|
|
const x = isoX(t.i, t.j);
|
|
const y = isoY(t.i, t.j) + TH / 2;
|
|
sprites.push({
|
|
y,
|
|
draw: () => {
|
|
shadow(x, y + 6, 26);
|
|
drawEmoji("🏛️", x, y + 8, 48);
|
|
ctx.font = "bold 12px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = "#374151";
|
|
ctx.fillText(`마을 회관 Lv${state.hall.level}`, x, y + 24);
|
|
},
|
|
});
|
|
}
|
|
|
|
// 생산 건물 + 회수 배지
|
|
hitRectsRef.current = [];
|
|
for (const b of state.buildings) {
|
|
const t = BUILDING_TILES[b.id];
|
|
if (!t) continue;
|
|
const x = isoX(t.i, t.j);
|
|
const y = isoY(t.i, t.j) + TH / 2;
|
|
const stored = Math.floor(b.stored);
|
|
hitRectsRef.current.push({ id: b.id, x: x - 30, y: y - 44, w: 60, h: 72 });
|
|
sprites.push({
|
|
y,
|
|
draw: () => {
|
|
shadow(x, y + 6, 24);
|
|
const img = toolImagesRef.current.get(b.aptitude);
|
|
if (img) {
|
|
// 세로가 긴 일러스트 — 높이 56px 기준으로 비율 유지
|
|
const h = 56;
|
|
const w = (img.width / img.height) * h;
|
|
ctx.drawImage(img, x - w / 2, y + 8 - h, w, h);
|
|
} else {
|
|
drawEmoji(b.emoji, x, y + 8, 42);
|
|
}
|
|
ctx.font = "bold 12px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = "#374151";
|
|
ctx.fillText(b.name, x, y + 24);
|
|
if (stored > 0) {
|
|
const label = `${b.resource === "fish" ? "🐟" : "🪵"}${stored}`;
|
|
ctx.font = "bold 12px sans-serif";
|
|
const w = ctx.measureText(label).width + 12;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x - w / 2, y - 58, w, 20, 10);
|
|
ctx.fillStyle = "#f97316";
|
|
ctx.fill();
|
|
ctx.fillStyle = "#fff";
|
|
ctx.fillText(label, x, y - 43);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// 고양이
|
|
for (const cat of state.cats) {
|
|
const anim = anims.get(cat.id);
|
|
if (!anim) continue;
|
|
const working = anim.mode === "working";
|
|
const moving = Math.hypot(anim.tx - anim.x, anim.ty - anim.y) > 2;
|
|
const bob = working
|
|
? Math.sin(now / 130) * 3
|
|
: moving
|
|
? Math.abs(Math.sin(now / 110)) * -3
|
|
: 0;
|
|
sprites.push({
|
|
y: anim.y,
|
|
draw: () => {
|
|
shadow(anim.x, anim.y + 2, 10);
|
|
drawEmoji(cat.emoji, anim.x, anim.y + bob, 30, anim.flip);
|
|
ctx.font = "10px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = "rgba(55,65,81,0.85)";
|
|
ctx.fillText(cat.name, anim.x, anim.y + 12);
|
|
if (working) drawEmoji("💦", anim.x + 12, anim.y - 20 + bob, 10);
|
|
},
|
|
});
|
|
}
|
|
|
|
sprites.sort((a, b) => a.y - b.y);
|
|
for (const s of sprites) s.draw();
|
|
|
|
// 생산 말풍선
|
|
const bubbles = bubblesRef.current;
|
|
for (let k = bubbles.length - 1; k >= 0; k--) {
|
|
const bub = bubbles[k];
|
|
const age = (now - bub.born) / 1200;
|
|
if (age >= 1) {
|
|
bubbles.splice(k, 1);
|
|
continue;
|
|
}
|
|
ctx.globalAlpha = 1 - age;
|
|
drawEmoji(bub.text, bub.x, bub.y - age * 22, 14);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
raf = requestAnimationFrame(draw);
|
|
};
|
|
|
|
raf = requestAnimationFrame(draw);
|
|
|
|
const onClick = (e: MouseEvent) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * CANVAS_W;
|
|
const y = ((e.clientY - rect.top) / rect.height) * CANVAS_H;
|
|
for (const r of hitRectsRef.current) {
|
|
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
|
|
const state = engine.peek();
|
|
const b = state?.buildings.find((bb) => bb.id === r.id);
|
|
const amount = b ? Math.floor(b.stored) : 0;
|
|
if (b && amount > 0) {
|
|
engine.collect(r.id);
|
|
engine.save();
|
|
bubblesRef.current.push({
|
|
x: r.x + r.w / 2, y: r.y, born: performance.now(),
|
|
text: `+${amount} ${b.resource === "fish" ? "🐟" : "🪵"}`,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
canvas.addEventListener("click", onClick);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
canvas.removeEventListener("click", onClick);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-linear-to-b from-sky-100 to-emerald-50 dark:border-gray-800 dark:from-sky-950 dark:to-emerald-950">
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{ width: CANVAS_W, height: CANVAS_H, display: "block", margin: "0 auto", cursor: "pointer" }}
|
|
/>
|
|
<p className="pb-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
|
건물 위 주황 배지를 클릭하면 산출물을 회수합니다
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|