건설 시간 시스템: 배치 후 공사장 표시 → 시간 경과 시 완공
- BuildDef.buildTime 추가 (부엌 60초, 채석장 120초), 상점에 ⏱ 표시 - 배치 확정 시 constructionUntil 타임스탬프 설정 (오프라인에도 진행) - 건설 중: 공사장 일러스트 + 🔨 카운트다운 판자 배지 + "건설 중" 라벨, 생산·배치 불가, 회관 증축 요건에도 미인정 - 패널: 공사장 이미지 + 진행 바 + 남은 시간 표시 - 완공 시 tick에서 자동 전환 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
바이너리 파일은 표시되지 않음.
|
이후 너비: | 높이: | 크기: 1.4 MiB |
바이너리 파일은 표시되지 않음.
|
이후 너비: | 높이: | 크기: 155 KiB |
@@ -9,6 +9,7 @@ import {
|
|||||||
effectiveRate,
|
effectiveRate,
|
||||||
activeRecipe,
|
activeRecipe,
|
||||||
fullnessPenalty,
|
fullnessPenalty,
|
||||||
|
isUnderConstruction,
|
||||||
} from "@/lib/game/engine";
|
} from "@/lib/game/engine";
|
||||||
import {
|
import {
|
||||||
APTITUDE_LABEL,
|
APTITUDE_LABEL,
|
||||||
@@ -408,6 +409,7 @@ function ShopPanel({
|
|||||||
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
<div className="font-jua text-[15px] text-[#3f2c14]">{def.name}</div>
|
||||||
<div className="font-num text-xs text-[#8a6316]">
|
<div className="font-num text-xs text-[#8a6316]">
|
||||||
{costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")}
|
{costEntries.map(([k, v]) => `${RES_EMOJI[k]} ${v}`).join(" + ")}
|
||||||
|
{` · ⏱ ${def.buildTime}초`}
|
||||||
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
|
{def.reqHallLv > 1 && ` · 회관 Lv${def.reqHallLv}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-[11px] text-[#8a7551]">{def.desc}</div>
|
<div className="mt-0.5 line-clamp-2 text-[11px] text-[#8a7551]">{def.desc}</div>
|
||||||
@@ -470,6 +472,33 @@ function BuildingPanel({
|
|||||||
effectiveRate(b, building.aptitude) - effectiveRate(a, building.aptitude),
|
effectiveRate(b, building.aptitude) - effectiveRate(a, building.aptitude),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isUnderConstruction(building)) {
|
||||||
|
const def = BUILD_DEFS.find((d) => d.type === building.type);
|
||||||
|
const total = (def?.buildTime ?? 60) * 1000;
|
||||||
|
const remainMs = Math.max(0, (building.constructionUntil ?? 0) - Date.now());
|
||||||
|
const pct = Math.min(100, ((total - remainMs) / total) * 100);
|
||||||
|
const remainSec = Math.ceil(remainMs / 1000);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={BUILDING_IMG.construction}
|
||||||
|
alt="건설 중"
|
||||||
|
className="mx-auto h-28 object-contain"
|
||||||
|
/>
|
||||||
|
<p className="text-center text-sm text-[#6f5c39]">
|
||||||
|
🔨 열심히 짓는 중이에요…
|
||||||
|
</p>
|
||||||
|
<div className="bar-track h-3 w-full">
|
||||||
|
<div className="bar-fill-amber" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="font-num text-center text-sm text-[#8a6316]">
|
||||||
|
남은 시간 {Math.floor(remainSec / 60)}:{String(remainSec % 60).padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* 정보 */}
|
{/* 정보 */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { engine, catRate, canPlaceAt } from "@/lib/game/engine";
|
import { engine, catRate, canPlaceAt, isUnderConstruction } from "@/lib/game/engine";
|
||||||
import {
|
import {
|
||||||
BOARD_COLS,
|
BOARD_COLS,
|
||||||
BOARD_ROWS,
|
BOARD_ROWS,
|
||||||
@@ -565,17 +565,40 @@ export default function VillageView({
|
|||||||
const baseY = y + (fp.h * TH) / 2;
|
const baseY = y + (fp.h * TH) / 2;
|
||||||
const stored = Math.floor(b.stored);
|
const stored = Math.floor(b.stored);
|
||||||
const isKitchen = b.type === "kitchen";
|
const isKitchen = b.type === "kitchen";
|
||||||
|
const constructing = isUnderConstruction(b);
|
||||||
// 건물 본체 → 정보 패널
|
// 건물 본체 → 정보 패널
|
||||||
hitRectsRef.current.push({
|
hitRectsRef.current.push({
|
||||||
id: b.id, kind: "open",
|
id: b.id, kind: "open",
|
||||||
x: x - imgW / 2, y: y - imgW * 0.6, w: imgW, h: imgW * 0.6 + (fp.h * TH) / 2,
|
x: x - imgW / 2, y: y - imgW * 0.6, w: imgW, h: imgW * 0.6 + (fp.h * TH) / 2,
|
||||||
});
|
});
|
||||||
// 배지 → 빠른 회수 (생산 건물만)
|
// 배지 → 빠른 회수 (생산 건물만)
|
||||||
if (!isKitchen && stored > 0)
|
if (!constructing && !isKitchen && stored > 0)
|
||||||
hitRectsRef.current.push({ id: b.id, kind: "collect", x: x - 46, y: y - imgW * 0.72, w: 92, h: 26 });
|
hitRectsRef.current.push({ id: b.id, kind: "collect", x: x - 46, y: y - imgW * 0.72, w: 92, h: 26 });
|
||||||
sprites.push({
|
sprites.push({
|
||||||
y,
|
y,
|
||||||
draw: () => {
|
draw: () => {
|
||||||
|
if (constructing) {
|
||||||
|
const cImg = getImage(BUILDING_IMG.construction);
|
||||||
|
if (cImg) {
|
||||||
|
const h = (cImg.height / cImg.width) * imgW;
|
||||||
|
ctx.drawImage(cImg, x - imgW / 2, baseY + 4 - h, imgW, h);
|
||||||
|
} else {
|
||||||
|
drawEmoji("🏗️", x, y + 8, 42);
|
||||||
|
}
|
||||||
|
const remain = Math.max(0, Math.ceil(((b.constructionUntil ?? 0) - Date.now()) / 1000));
|
||||||
|
const mm = Math.floor(remain / 60);
|
||||||
|
const ss = remain % 60;
|
||||||
|
drawPlankBadge(x, y - imgW * 0.5, `🔨 ${mm}:${String(ss).padStart(2, "0")}`);
|
||||||
|
ctx.font = `13px ${juaFamily}`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.strokeStyle = "rgba(255,252,238,0.75)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeText(`${b.name} 건설 중`, x, baseY + 17);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.fillStyle = "#4a3418";
|
||||||
|
ctx.fillText(`${b.name} 건설 중`, x, baseY + 17);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bImg = getImage(BUILDING_IMG[b.type]);
|
const bImg = getImage(BUILDING_IMG[b.type]);
|
||||||
if (bImg) {
|
if (bImg) {
|
||||||
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
|
// 건물 일러스트 (자체 잔디 베이스 포함 — 그림자 불필요)
|
||||||
|
|||||||
+4
-2
@@ -25,6 +25,7 @@ export function catSprite(breed: string, frame: CatFrame): string {
|
|||||||
|
|
||||||
// 건물 일러스트 (public/assets/buildings/)
|
// 건물 일러스트 (public/assets/buildings/)
|
||||||
export const BUILDING_IMG: Record<string, string> = {
|
export const BUILDING_IMG: Record<string, string> = {
|
||||||
|
construction: "/assets/buildings/construction.png",
|
||||||
hall: "/assets/buildings/hall.png",
|
hall: "/assets/buildings/hall.png",
|
||||||
dock: "/assets/buildings/dock.png",
|
dock: "/assets/buildings/dock.png",
|
||||||
lumberyard: "/assets/buildings/lumberyard.png",
|
lumberyard: "/assets/buildings/lumberyard.png",
|
||||||
@@ -151,18 +152,19 @@ export interface BuildDef {
|
|||||||
maxSlots: number;
|
maxSlots: number;
|
||||||
cost: Partial<Record<"fish" | "wood" | "stone", number>>;
|
cost: Partial<Record<"fish" | "wood" | "stone", number>>;
|
||||||
reqHallLv: number;
|
reqHallLv: number;
|
||||||
|
buildTime: number; // 건설 소요 시간 (초)
|
||||||
desc: string;
|
desc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILD_DEFS: BuildDef[] = [
|
export const BUILD_DEFS: BuildDef[] = [
|
||||||
{
|
{
|
||||||
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
|
id: "kitchen_1", type: "kitchen", name: "부엌", emoji: "🍳",
|
||||||
aptitude: "cooking", maxSlots: 2, cost: { wood: 60 }, reqHallLv: 1,
|
aptitude: "cooking", maxSlots: 2, cost: { wood: 60 }, reqHallLv: 1, buildTime: 60,
|
||||||
desc: "요리 적성 고양이가 생선으로 요리를 만들고, 배고픈 고양이들이 알아서 먹으러 옵니다.",
|
desc: "요리 적성 고양이가 생선으로 요리를 만들고, 배고픈 고양이들이 알아서 먹으러 옵니다.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "quarry_1", type: "quarry", name: "채석장", emoji: "⛏️",
|
id: "quarry_1", type: "quarry", name: "채석장", emoji: "⛏️",
|
||||||
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2,
|
aptitude: "mining", resource: "stone", maxSlots: 3, cost: { wood: 120 }, reqHallLv: 2, buildTime: 120,
|
||||||
desc: "채굴 적성 고양이가 돌을 캡니다. 돌은 회관 증축과 중급 건물에 필요합니다.",
|
desc: "채굴 적성 고양이가 돌을 캡니다. 돌은 회관 증축과 중급 건물에 필요합니다.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+18
-1
@@ -162,6 +162,11 @@ function passiveFoodMul(cat: Cat): number {
|
|||||||
return mul;
|
return mul;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 건설 중인가 */
|
||||||
|
export function isUnderConstruction(b: Building): boolean {
|
||||||
|
return b.constructionUntil !== undefined && Date.now() < b.constructionUntil;
|
||||||
|
}
|
||||||
|
|
||||||
/** 사각 풋프린트 겹침 검사 */
|
/** 사각 풋프린트 겹침 검사 */
|
||||||
function rectsOverlap(
|
function rectsOverlap(
|
||||||
a: { i: number; j: number; w: number; h: number },
|
a: { i: number; j: number; w: number; h: number },
|
||||||
@@ -256,6 +261,14 @@ export const engine = {
|
|||||||
tick(dt: number) {
|
tick(dt: number) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
for (const building of state.buildings) {
|
for (const building of state.buildings) {
|
||||||
|
// 건설 완료 체크
|
||||||
|
if (building.constructionUntil !== undefined) {
|
||||||
|
if (Date.now() >= building.constructionUntil) {
|
||||||
|
building.constructionUntil = undefined;
|
||||||
|
} else {
|
||||||
|
continue; // 건설 중 — 생산 없음
|
||||||
|
}
|
||||||
|
}
|
||||||
let produced = 0;
|
let produced = 0;
|
||||||
for (const catId of building.assigned) {
|
for (const catId of building.assigned) {
|
||||||
const cat = state.cats.find((c) => c.id === catId);
|
const cat = state.cats.find((c) => c.id === catId);
|
||||||
@@ -354,6 +367,7 @@ export const engine = {
|
|||||||
pos: { i: pos.i, j: pos.j },
|
pos: { i: pos.i, j: pos.j },
|
||||||
level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
|
level: 1, maxSlots: def.maxSlots, aptitude: def.aptitude,
|
||||||
resource: def.resource, assigned: [], stored: 0, progress: 0,
|
resource: def.resource, assigned: [], stored: 0, progress: 0,
|
||||||
|
constructionUntil: Date.now() + def.buildTime * 1000,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -415,7 +429,8 @@ export const engine = {
|
|||||||
}
|
}
|
||||||
for (const req of next.reqBuildings ?? []) {
|
for (const req of next.reqBuildings ?? []) {
|
||||||
const b = state.buildings.find((x) => x.type === req.type);
|
const b = state.buildings.find((x) => x.type === req.type);
|
||||||
if (!b || b.level < (req.level ?? 1)) missing.push(req.label);
|
if (!b || isUnderConstruction(b) || b.level < (req.level ?? 1))
|
||||||
|
missing.push(req.label);
|
||||||
}
|
}
|
||||||
if (next.reqCat) {
|
if (next.reqCat) {
|
||||||
const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length;
|
const count = state.cats.filter((c) => c.level >= next.reqCat!.level).length;
|
||||||
@@ -434,6 +449,8 @@ export const engine = {
|
|||||||
const cat = state.cats.find((c) => c.id === catId);
|
const cat = state.cats.find((c) => c.id === catId);
|
||||||
const building = state.buildings.find((b) => b.id === buildingId);
|
const building = state.buildings.find((b) => b.id === buildingId);
|
||||||
if (!cat || !building) return null;
|
if (!cat || !building) return null;
|
||||||
|
if (isUnderConstruction(building))
|
||||||
|
return `${building.name}은(는) 아직 건설 중입니다`;
|
||||||
if (cat.aptitude[building.aptitude] === 0)
|
if (cat.aptitude[building.aptitude] === 0)
|
||||||
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
||||||
if (building.assigned.length >= building.maxSlots)
|
if (building.assigned.length >= building.maxSlots)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface Building {
|
|||||||
assigned: string[]; // cat id
|
assigned: string[]; // cat id
|
||||||
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
stored: number; // 생산 건물: 회수 대기 산출물 / 부엌: 보관 중인 요리 수
|
||||||
progress?: number; // 부엌: 현재 요리 진행 작업량
|
progress?: number; // 부엌: 현재 요리 진행 작업량
|
||||||
|
constructionUntil?: number; // 건설 완료 예정 timestamp (없으면 완공 상태)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
|
|||||||
새 이슈에서 참조
사용자 차단