고양이 마을 1단계: 개체 생성·배치·틱 생산·수동 회수
- 품종 8종·패시브·랜덤 개체 생성 (기획서 4.2, 5장) - 부두/벌목장 배치, 적성 0 배치 불가, 적성 비선형 작업량 (4.4) - rAF+델타 타임 엔진(모듈 싱글턴), UI 300ms 동기화 (기술 메모) - 수동 회수(운반 수레 전), localStorage 저장(version 필드) - 회관 Lv1 수용 3마리, 입양 버튼 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -0,0 +1,215 @@
|
||||
import type { AptitudeKey, Building, Cat, GameState } from "./types";
|
||||
import {
|
||||
APTITUDE_RATE,
|
||||
BASE_RATE,
|
||||
BREEDS,
|
||||
CAT_NAMES,
|
||||
PASSIVES,
|
||||
type BreedDef,
|
||||
} from "./data";
|
||||
|
||||
const SAVE_KEY = "cat-village-save";
|
||||
const APTITUDE_KEYS: AptitudeKey[] = [
|
||||
"fishing", "logging", "mining", "foraging", "cooking", "crafting", "grooming",
|
||||
];
|
||||
|
||||
const rand = (min: number, max: number) =>
|
||||
Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
const pickWeighted = <T extends { weight: number }>(items: T[]): T => {
|
||||
const total = items.reduce((s, i) => s + i.weight, 0);
|
||||
let roll = Math.random() * total;
|
||||
for (const item of items) {
|
||||
roll -= item.weight;
|
||||
if (roll <= 0) return item;
|
||||
}
|
||||
return items[items.length - 1];
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const nextId = (prefix: string) =>
|
||||
`${prefix}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
||||
|
||||
// ---------- 고양이 생성 ----------
|
||||
|
||||
function generateCat(forceAptitude?: AptitudeKey): Cat {
|
||||
const breed: BreedDef = pickWeighted(BREEDS);
|
||||
|
||||
const aptitude = {} as Record<AptitudeKey, number>;
|
||||
for (const key of APTITUDE_KEYS) {
|
||||
let value = rand(0, 2);
|
||||
if (breed.bias.includes(key)) value += rand(1, 3);
|
||||
aptitude[key] = Math.min(value, breed.aptitudeCap);
|
||||
}
|
||||
if (forceAptitude && aptitude[forceAptitude] < 2) {
|
||||
aptitude[forceAptitude] = rand(2, 3);
|
||||
}
|
||||
|
||||
const passives: string[] = [];
|
||||
const pool = [...PASSIVES];
|
||||
const passiveCount = rand(0, 2);
|
||||
for (let i = 0; i < passiveCount; i++) {
|
||||
const idx = rand(0, pool.length - 1);
|
||||
passives.push(pool.splice(idx, 1)[0].key);
|
||||
}
|
||||
|
||||
return {
|
||||
id: nextId("cat"),
|
||||
name: CAT_NAMES[rand(0, CAT_NAMES.length - 1)],
|
||||
breed: breed.key,
|
||||
breedName: breed.name,
|
||||
emoji: breed.emoji,
|
||||
level: 1,
|
||||
exp: 0,
|
||||
aptitude,
|
||||
stats: {
|
||||
diligence: rand(50, 150),
|
||||
stamina: rand(50, 150),
|
||||
appetite: Math.min(5, Math.max(1, rand(2, 4) + breed.appetiteMod)),
|
||||
},
|
||||
status: { fullness: 100, happiness: 80 },
|
||||
passives,
|
||||
assignedTo: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 초기 상태 ----------
|
||||
|
||||
function createBuildings(): Building[] {
|
||||
return [
|
||||
{
|
||||
id: "dock_1", type: "dock", name: "부두", emoji: "⚓",
|
||||
level: 1, maxSlots: 3, aptitude: "fishing", resource: "fish",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
{
|
||||
id: "lumberyard_1", type: "lumberyard", name: "벌목장", emoji: "🪓",
|
||||
level: 1, maxSlots: 3, aptitude: "logging", resource: "wood",
|
||||
assigned: [], stored: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
// 시작 고양이 2마리는 각각 낚시·벌목이 가능하도록 보정 (초반 막힘 방지)
|
||||
return {
|
||||
version: 1,
|
||||
resources: { fish: 0, wood: 0 },
|
||||
cats: [generateCat("fishing"), generateCat("logging")],
|
||||
buildings: createBuildings(),
|
||||
hall: { level: 1, capacity: 3 },
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 엔진 (모듈 싱글턴, React 밖에서 틱 계산) ----------
|
||||
|
||||
let state: GameState | null = null;
|
||||
|
||||
function passiveSpeedMul(cat: Cat): number {
|
||||
return cat.passives.reduce((mul, key) => {
|
||||
const def = PASSIVES.find((p) => p.key === key);
|
||||
return mul * (def?.speedMul ?? 1);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
/** 초당 산출량 = 적성 테이블 × 근면 배율 × 패시브 배율 × BASE_RATE */
|
||||
export function catRate(cat: Cat, aptitudeKey: AptitudeKey): number {
|
||||
const apt = APTITUDE_RATE[cat.aptitude[aptitudeKey]] ?? 0;
|
||||
return apt * (cat.stats.diligence / 100) * passiveSpeedMul(cat) * BASE_RATE;
|
||||
}
|
||||
|
||||
export const engine = {
|
||||
init(): GameState {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as GameState;
|
||||
if (saved.version === 1) state = saved;
|
||||
}
|
||||
} catch {
|
||||
// 저장 데이터가 깨졌으면 새로 시작
|
||||
}
|
||||
if (!state) state = createInitialState();
|
||||
return state;
|
||||
},
|
||||
|
||||
tick(dt: number) {
|
||||
if (!state) return;
|
||||
for (const building of state.buildings) {
|
||||
let produced = 0;
|
||||
for (const catId of building.assigned) {
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
if (!cat) continue;
|
||||
produced += catRate(cat, building.aptitude) * dt;
|
||||
// 배치 중 경험치: 초당 1
|
||||
cat.exp += dt;
|
||||
const needed = cat.level * 60;
|
||||
if (cat.exp >= needed) {
|
||||
cat.exp -= needed;
|
||||
cat.level += 1;
|
||||
cat.stats.diligence = Math.min(150, cat.stats.diligence + rand(1, 3));
|
||||
}
|
||||
}
|
||||
building.stored += produced;
|
||||
}
|
||||
state.updatedAt = Date.now();
|
||||
},
|
||||
|
||||
adopt(): string | null {
|
||||
if (!state) return null;
|
||||
if (state.cats.length >= state.hall.capacity) return "수용 인원이 가득 찼습니다 (회관 증축 필요)";
|
||||
state.cats.push(generateCat());
|
||||
return null;
|
||||
},
|
||||
|
||||
assign(catId: string, buildingId: string): string | null {
|
||||
if (!state) return null;
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
const building = state.buildings.find((b) => b.id === buildingId);
|
||||
if (!cat || !building) return null;
|
||||
if (cat.aptitude[building.aptitude] === 0)
|
||||
return `${cat.name}은(는) ${building.name} 적성이 없습니다`;
|
||||
if (building.assigned.length >= building.maxSlots)
|
||||
return `${building.name} 자리가 가득 찼습니다`;
|
||||
// 기존 배치 해제 후 이동
|
||||
this.unassign(catId);
|
||||
building.assigned.push(catId);
|
||||
cat.assignedTo = building.id;
|
||||
return null;
|
||||
},
|
||||
|
||||
unassign(catId: string) {
|
||||
if (!state) return;
|
||||
const cat = state.cats.find((c) => c.id === catId);
|
||||
if (!cat) return;
|
||||
for (const b of state.buildings) {
|
||||
b.assigned = b.assigned.filter((id) => id !== catId);
|
||||
}
|
||||
cat.assignedTo = null;
|
||||
},
|
||||
|
||||
collect(buildingId: string) {
|
||||
if (!state) return;
|
||||
const building = state.buildings.find((b) => b.id === buildingId);
|
||||
if (!building) return;
|
||||
const amount = Math.floor(building.stored);
|
||||
if (amount <= 0) return;
|
||||
building.stored -= amount;
|
||||
state.resources[building.resource] += amount;
|
||||
},
|
||||
|
||||
save() {
|
||||
if (!state) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// 저장 실패는 치명적이지 않음
|
||||
}
|
||||
},
|
||||
|
||||
snapshot(): GameState | null {
|
||||
return state ? (JSON.parse(JSON.stringify(state)) as GameState) : null;
|
||||
},
|
||||
};
|
||||
새 이슈에서 참조
사용자 차단