고양이 마을 1단계: 개체 생성·배치·틱 생산·수동 회수

- 품종 8종·패시브·랜덤 개체 생성 (기획서 4.2, 5장)
- 부두/벌목장 배치, 적성 0 배치 불가, 적성 비선형 작업량 (4.4)
- rAF+델타 타임 엔진(모듈 싱글턴), UI 300ms 동기화 (기술 메모)
- 수동 회수(운반 수레 전), localStorage 저장(version 필드)
- 회관 Lv1 수용 3마리, 입양 버튼

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-12 17:44:59 +09:00
co-authored by Claude Fable 5
부모 98e1b0f5a3
커밋 f5ae61a879
7개의 변경된 파일과 657개의 추가작업 그리고 35개의 파일을 삭제
+63
파일 보기
@@ -0,0 +1,63 @@
import type { AptitudeKey } from "./types";
// 적성 레벨별 초당 작업량 (기획서 4.4) — BASE_RATE로 전체 속도 스케일 조정
export const APTITUDE_RATE = [0, 1.0, 2.2, 4.8, 10.0, 21.0, 45.0];
export const BASE_RATE = 0.2;
export const APTITUDE_LABEL: Record<AptitudeKey, string> = {
fishing: "낚시",
logging: "벌목",
mining: "채굴",
foraging: "채집",
cooking: "요리",
crafting: "제작",
grooming: "손질",
};
export interface BreedDef {
key: string;
name: string;
emoji: string;
rarity: number; // 별 개수
weight: number; // 추첨 가중치
bias: AptitudeKey[]; // 적성 편향
aptitudeCap: number;
appetiteMod: number;
}
// 기획서 4.2 품종표
export const BREEDS: BreedDef[] = [
{ key: "korean_shorthair", name: "코리안 숏헤어", emoji: "🐱", rarity: 1, weight: 35, bias: [], aptitudeCap: 5, appetiteMod: -1 },
{ key: "munchkin", name: "먼치킨", emoji: "😺", rarity: 1, weight: 25, bias: ["foraging", "grooming"], aptitudeCap: 5, appetiteMod: 0 },
{ key: "norwegian_forest", name: "노르웨이 숲", emoji: "🐈", rarity: 2, weight: 15, bias: ["logging", "mining"], aptitudeCap: 5, appetiteMod: 1 },
{ key: "bengal", name: "뱅갈", emoji: "🐆", rarity: 2, weight: 10, bias: ["fishing", "foraging"], aptitudeCap: 5, appetiteMod: 0 },
{ key: "russian_blue", name: "러시안 블루", emoji: "🐈‍⬛", rarity: 2, weight: 8, bias: ["crafting", "cooking"], aptitudeCap: 5, appetiteMod: 0 },
{ key: "persian", name: "페르시안", emoji: "😸", rarity: 3, weight: 4, bias: ["cooking", "grooming"], aptitudeCap: 5, appetiteMod: 0 },
{ key: "sphynx", name: "스핑크스", emoji: "🐯", rarity: 3, weight: 2, bias: ["crafting", "mining"], aptitudeCap: 5, appetiteMod: 0 },
{ key: "maine_coon", name: "메인쿤", emoji: "🦁", rarity: 4, weight: 1, bias: ["logging", "mining"], aptitudeCap: 6, appetiteMod: 2 },
];
export interface PassiveDef {
key: string;
name: string;
kind: "positive" | "negative" | "tradeoff";
desc: string;
speedMul: number; // 1단계에서는 작업 속도 효과만 적용
}
// 기획서 5장 중 1단계에서 의미 있는 패시브 위주
export const PASSIVES: PassiveDef[] = [
{ key: "diligent", name: "부지런함", kind: "positive", desc: "작업 속도 +25%", speedMul: 1.25 },
{ key: "sturdy", name: "튼튼함", kind: "positive", desc: "체력 소모 -30%", speedMul: 1 },
{ key: "small_eater", name: "소식가", kind: "positive", desc: "식량 소모 -30%", speedMul: 1 },
{ key: "lucky", name: "복덩이", kind: "positive", desc: "확률적으로 냥코인 획득", speedMul: 1 },
{ key: "lazy", name: "게으름뱅이", kind: "negative", desc: "작업 속도 -30%", speedMul: 0.7 },
{ key: "big_eater", name: "대식가", kind: "negative", desc: "식량 소모 +50%", speedMul: 1 },
{ key: "workaholic", name: "일중독", kind: "tradeoff", desc: "작업 속도 +60%, 행복도 감소 +40%", speedMul: 1.6 },
];
export const CAT_NAMES = [
"치즈", "나비", "콩이", "보리", "두부", "마루", "모찌", "호두",
"감자", "앙꼬", "밤이", "구름", "초코", "우유", "깨비", "솜이",
"달이", "별이", "쿠키", "만두", "생강", "참깨", "누룽지", "조약돌",
];
+215
파일 보기
@@ -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;
},
};
+56
파일 보기
@@ -0,0 +1,56 @@
export type AptitudeKey =
| "fishing"
| "logging"
| "mining"
| "foraging"
| "cooking"
| "crafting"
| "grooming";
export type ResourceKey = "fish" | "wood";
export type BuildingType = "dock" | "lumberyard";
export interface Cat {
id: string;
name: string;
breed: string;
breedName: string;
emoji: string;
level: number;
exp: number;
aptitude: Record<AptitudeKey, number>;
stats: {
diligence: number; // 50~150, 작업 속도 배율
stamina: number; // 50~150 (1단계에서는 표시만)
appetite: number; // 1~5 (1단계에서는 표시만)
};
status: {
fullness: number;
happiness: number;
};
passives: string[];
assignedTo: string | null;
}
export interface Building {
id: string;
type: BuildingType;
name: string;
emoji: string;
level: number;
maxSlots: number;
aptitude: AptitudeKey; // 필요 적성
resource: ResourceKey; // 산출 자원
assigned: string[]; // cat id
stored: number; // 회수 대기 산출물
}
export interface GameState {
version: 1;
resources: Record<ResourceKey, number>;
cats: Cat[];
buildings: Building[];
hall: { level: number; capacity: number };
updatedAt: number;
}