TINY SHOP — cozy 3D shop-management game (Three.js vertical slice)

- Procedural 3D world: dollhouse shop, town, day/night, weather, seasons
- Customer AI with personalities (story NPCs, thieves, weekly regulars)
- Economy: suppliers, negotiation, pricing psychology, daily accounting
- Staff with traits/loyalty, 8 expansion levels, furniture & decoration
- Events with choices, quests, achievements, 4 difficulties, rival shop
- Animated daily report, analytics, save/load (3 slots + autosave)
- Procedural music & SFX (WebAudio), zero external assets
- Test harnesses: simtest (node), verify/check/e2e (headless browser)
This commit is contained in:
2026-08-23 06:58:24 +00:00
commit 0e7c07533e
40 changed files with 10130 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
import * as THREE from 'three';
import { scene, mat, cyl, sph } from './engine.js';
import { buildProductMesh } from './products3d.js';
import { FURN_BY_ID } from '../data/furniture.js';
import G from '../sim/state.js';
// ============================================================
// FURNITURE 3D — meshes for every catalog item + shelf stocking
// ============================================================
export const furnGroups = new Map(); // furnitureId -> {group, sig, slots:[]}
export const WOOD = { dark: 0x6b4a2c, mid: 0x8a5a33, light: 0xa9713d };
export function cellToWorld(cx, cz) {
const [W, H] = G.gridSize();
return [cx - W / 2 + 0.5, cz - H / 2 + 0.5];
}
// Rebuild all furniture visuals (on load / expand / layout change)
export function refreshAllFurniture() {
if (!G.data) return;
const seen = new Set();
for (const f of G.data.furniture) {
seen.add(f.id);
let rec = furnGroups.get(f.id);
if (!rec) {
const group = buildFurnMesh(f.type);
scene.add(group);
rec = { group, sig: '' };
furnGroups.set(f.id, rec);
}
placeFurn(rec.group, f);
// shelf stock display
if (['shelf', 'table', 'pedestal'].includes(f.type)) {
const stock = G.data.shelfStock[f.id] || {};
const sig = JSON.stringify(stock);
if (sig !== rec.sig) {
rec.sig = sig;
restockShelfVisual(f, rec);
}
}
}
for (const [id, rec] of [...furnGroups]) {
if (!seen.has(id)) {
scene.remove(rec.group);
disposeDeep(rec.group);
furnGroups.delete(id);
}
}
}
export function getFurnGroup(id) { return furnGroups.get(id)?.group || null; }
function disposeDeep(obj) {
obj.traverse(o => { o.geometry?.dispose(); });
}
function placeFurn(group, f) {
const def = FURN_BY_ID[f.type];
const rot = f.rot % 2;
const w = rot ? def.size[1] : def.size[0];
const d = rot ? def.size[0] : def.size[1];
const [wx, wz] = cellToWorld(f.cx + w / 2 - 0.5, f.cz + d / 2 - 0.5);
group.position.set(wx, 0, wz);
group.rotation.y = -f.rot * Math.PI / 2;
}
// ---------- individual builders ----------
export function buildFurnMesh(type) {
switch (type) {
case 'shelf': return buildShelf();
case 'counter': return buildCounter();
case 'table': return buildTable();
case 'crate': return buildCrate();
case 'rug': return buildRug();
case 'plant': return buildPlant();
case 'lamp': return buildLampFurn();
case 'pedestal': return buildPedestal();
case 'painting': return buildPainting();
case 'banner': return buildBanner();
case 'fountain': return buildMiniFountain();
case 'catbed': return buildCatBed();
default: return new THREE.Group();
}
}
function shadowify(g) { g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); return g; }
function buildShelf() {
const g = new THREE.Group();
const sideM = mat(WOOD.mid, { rough: .85 });
const boardM = mat(WOOD.light, { rough: .85 });
// vertical sides
for (const sgn of [-1, 1]) {
const side = new THREE.Mesh(new THREE.BoxGeometry(0.08, 1.7, 0.42), sideM);
side.position.set(sgn * 0.96, 0.85, 0);
g.add(side);
}
// boards
const levels = [0.12, 0.66, 1.2];
for (const y of levels) {
const board = new THREE.Mesh(new THREE.BoxGeometry(1.92, 0.06, 0.44), boardM);
board.position.y = y;
g.add(board);
}
// top crown
const crown = new THREE.Mesh(new THREE.BoxGeometry(2.04, 0.09, 0.48), mat(WOOD.dark));
crown.position.y = 1.72;
g.add(crown);
// back panel
const back = new THREE.Mesh(new THREE.BoxGeometry(1.92, 1.68, 0.04), mat(0x9a6b3f, { rough: .95 }));
back.position.set(0, 0.9, -0.21);
g.add(back);
// little heart carved on top
const h = new THREE.Mesh(sph(0.05, 8), mat(0xef6461));
h.position.set(0, 1.82, 0);
g.add(h);
return shadowify(g);
}
function shelfSlots() {
// 4 per level × 3 levels, world-local positions
const slots = [];
for (const y of [0.24, 0.78, 1.32]) {
for (let i = 0; i < 4; i++) {
slots.push(new THREE.Vector3(-0.69 + i * 0.46, y, 0));
}
}
return slots;
}
function buildCounter() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(1.95, 0.95, 0.75), mat(WOOD.dark, { rough: .85 }));
body.position.y = 0.48;
g.add(body);
const top = new THREE.Mesh(new THREE.BoxGeometry(2.08, 0.09, 0.88), mat(WOOD.light, { rough: .7 }));
top.position.y = 1.0;
g.add(top);
// till / register
const till = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.3, 0.3), mat(0x4a4038, { rough: .6 }));
till.position.set(-0.6, 1.2, 0);
g.add(till);
const keys = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.03, 0.22), mat(0xd9c9a8));
keys.position.set(-0.6, 1.36, 0.02);
g.add(keys);
// bell
const bell = new THREE.Mesh(sph(0.06, 10), mat(0xffd166, { metal: .8, rough: .25 }));
bell.scale.y = 0.85;
bell.position.set(0.15, 1.14, 0.15);
g.add(bell);
const btn = new THREE.Mesh(sph(0.02, 6), mat(0x4a4038));
btn.position.set(0.15, 1.23, 0.15);
g.add(btn);
// jar of lollipops for charm
const jar = new THREE.Mesh(cyl(0.09, 0.09, 0.16, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: .35, roughness: .2 }));
jar.position.set(0.62, 1.13, 0);
g.add(jar);
for (let i = 0; i < 4; i++) {
const pop = new THREE.Mesh(sph(0.045, 8), mat([0xef6461, 0x58c98b, 0x5aa9e6, 0xffd166][i]));
pop.position.set(0.56 + (i % 2) * 0.11, 1.16 + Math.floor(i / 2) * 0.07, (i % 2 ? 0.03 : -0.03));
g.add(pop);
}
// front panel trim
const trim = new THREE.Mesh(new THREE.BoxGeometry(1.98, 0.1, 0.78), mat(WOOD.mid));
trim.position.y = 0.12;
g.add(trim);
return shadowify(g);
}
function buildTable() {
const g = new THREE.Group();
const top = new THREE.Mesh(cyl(0.95, 0.95, 0.09, 18), mat(WOOD.light, { rough: .75 }));
top.position.y = 0.72;
g.add(top);
const cloth = new THREE.Mesh(cyl(0.99, 0.99, 0.03, 18), mat(0xf6ead2, { rough: .95 }));
cloth.position.y = 0.77;
g.add(cloth);
const leg = new THREE.Mesh(cyl(0.12, 0.17, 0.7, 10), mat(WOOD.mid));
leg.position.y = 0.36;
g.add(leg);
const base = new THREE.Mesh(cyl(0.42, 0.46, 0.08, 14), mat(WOOD.dark));
base.position.y = 0.04;
g.add(base);
return shadowify(g);
}
function tableSlots() {
const s = [];
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2 + 0.5;
s.push(new THREE.Vector3(Math.cos(ang) * 0.52, 0.83, Math.sin(ang) * 0.52));
}
return s;
}
function buildCrate() {
const g = new THREE.Group();
const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.62, 0.8), mat(WOOD.mid, { rough: .95 }));
box.position.y = 0.31;
g.add(box);
const edge = mat(WOOD.dark, { rough: .95 });
for (const [rx, ry] of [[0, 0], [0, 0.62]]) {
const band = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.07, 0.84), edge);
band.position.y = ry === 0 ? 0.05 : 0.57;
g.add(band);
}
const cross = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.1, 0.1), edge);
cross.rotation.x = Math.PI / 4;
cross.position.y = 0.31;
cross.scale.z = 6;
g.add(cross);
return shadowify(g);
}
function buildRug() {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#c95f4a'; ctx.fillRect(0, 0, 128, 128);
ctx.strokeStyle = '#f6ead2'; ctx.lineWidth = 6;
ctx.strokeRect(10, 10, 108, 108);
ctx.strokeStyle = '#ffd166'; ctx.lineWidth = 3;
ctx.strokeRect(20, 20, 88, 88);
ctx.fillStyle = '#f6ead2';
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.arc(38 + i * 18, 64, 5, 0, 7);
ctx.fill();
}
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const m = new THREE.Mesh(
new THREE.BoxGeometry(2.9, 0.03, 1.9),
new THREE.MeshStandardMaterial({ map: tex, roughness: 1 })
);
m.position.y = 0.02;
m.receiveShadow = true;
return m;
}
function buildPlant() {
const g = new THREE.Group();
const pot = new THREE.Mesh(cyl(0.19, 0.14, 0.3, 10), mat(0xc96f52, { rough: .9 }));
pot.position.y = 0.15;
g.add(pot);
const rim = new THREE.Mesh(cyl(0.21, 0.21, 0.06, 10), mat(0xb85f43));
rim.position.y = 0.3;
g.add(rim);
const stem = new THREE.Mesh(cyl(0.025, 0.03, 0.35, 6), mat(0x4f8a44));
stem.position.y = 0.5;
g.add(stem);
const leafM = mat(0x5da24b, { rough: .95 });
for (let i = 0; i < 5; i++) {
const leaf = new THREE.Mesh(sph(rand(.14, .22), 8), leafM);
leaf.scale.set(1, 0.55, 1);
const ang = (i / 5) * Math.PI * 2;
leaf.position.set(Math.cos(ang) * 0.16, 0.62 + (i % 2) * 0.14, Math.sin(ang) * 0.16);
g.add(leaf);
}
function rand(a = 0.8, b) { return b === undefined ? Math.random() * a : a + Math.random() * (b - a); }
return shadowify(g);
}
let lampLightCount = 0;
function buildLampFurn() {
const g = new THREE.Group();
const pole = new THREE.Mesh(cyl(0.035, 0.05, 1.5, 8), mat(0x4a4038, { metal: .3, rough: .5 }));
pole.position.y = 0.75;
g.add(pole);
const base = new THREE.Mesh(cyl(0.2, 0.24, 0.06, 12), mat(0x4a4038));
base.position.y = 0.03;
g.add(base);
const shade = new THREE.Mesh(cone(0.28, 0.32),
new THREE.MeshStandardMaterial({ color: 0xffd9a0, emissive: 0xffb45e, emissiveIntensity: 0.9, roughness: .8 }));
shade.position.y = 1.62;
g.add(shade);
const bulb = new THREE.Mesh(sph(0.07, 8), mat(0xfff3cf, { emissive: 0xffdf9e, emissiveIntensity: 2.2 }));
bulb.position.y = 1.52;
g.add(bulb);
// real light only for first few lamps (perf)
if (lampLightCount < 4) {
const pl = new THREE.PointLight(0xffc46b, 6, 6.5, 1.8);
pl.position.y = 1.5;
g.add(pl);
lampLightCount++;
}
return shadowify(g);
}
function cone(r, h) { return new THREE.ConeGeometry(r, h, 12); }
function buildPedestal() {
const g = new THREE.Group();
const marble = mat(0xe8e2d5, { rough: .5 });
const base = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.12, 0.55), marble);
base.position.y = 0.06;
g.add(base);
const column = new THREE.Mesh(cyl(0.16, 0.2, 0.9, 10), marble);
column.position.y = 0.55;
g.add(column);
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.08, 0.45), marble);
cap.position.y = 1.03;
g.add(cap);
// glass dome
const dome = new THREE.Mesh(sph(0.26, 14), new THREE.MeshStandardMaterial({ color: 0xcfe8f5, transparent: true, opacity: 0.18, roughness: .1, metalness: .1 }));
dome.scale.y = 1.25;
dome.position.y = 1.32;
g.add(dome);
return shadowify(g);
}
function pedestalSlot() { return new THREE.Vector3(0, 1.1, 0); }
function buildPainting() {
const g = new THREE.Group();
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.75, 0.08), mat(0xb98a3d, { rough: .6, metal: .3 }));
frame.position.y = 1.9;
g.add(frame);
// tiny procedural landscape
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 96;
const ctx = cv.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 96);
grad.addColorStop(0, '#8ecdea'); grad.addColorStop(1, '#ffe3ae');
ctx.fillStyle = grad; ctx.fillRect(0, 0, 128, 96);
ctx.fillStyle = '#7cb95a'; ctx.beginPath(); ctx.moveTo(0, 70); ctx.lineTo(40, 40); ctx.lineTo(80, 74); ctx.lineTo(128, 50); ctx.lineTo(128, 96); ctx.lineTo(0, 96); ctx.fill();
ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.arc(96, 26, 10, 0, 7); ctx.fill();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const art = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 0.6), new THREE.MeshBasicMaterial({ map: tex }));
art.position.set(0, 1.9, 0.045);
g.add(art);
g.userData.wallArt = true;
return g;
}
function buildBanner() {
const g = new THREE.Group();
const pole = new THREE.Mesh(cyl(0.035, 0.035, 2.4, 6), mat(0x6b4a2c));
pole.position.y = 1.2;
g.add(pole);
const clothColors = [0xef6461, 0xffd166, 0x5aa9e6];
for (let i = 0; i < 3; i++) {
const flag = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.36, 4),
new THREE.MeshStandardMaterial({ color: clothColors[i], roughness: .9, side: THREE.DoubleSide }));
flag.rotation.z = Math.PI;
flag.position.set(0.001, 2.1 - i * 0.42, 0);
g.add(flag);
}
return shadowify(g);
}
function buildMiniFountain() {
const g = new THREE.Group();
const stone = mat(0xa8adb5, { rough: .85 });
const basin = new THREE.Mesh(cyl(0.85, 0.95, 0.4, 16), stone);
basin.position.y = 0.2;
g.add(basin);
const water = new THREE.Mesh(cyl(0.76, 0.76, 0.1, 16), mat(0x7fd4ea, { rough: .15 }));
water.position.y = 0.42;
water.name = 'water';
g.add(water);
const pillar = new THREE.Mesh(cyl(0.12, 0.16, 0.6, 10), stone);
pillar.position.y = 0.65;
g.add(pillar);
const bowl = new THREE.Mesh(cyl(0.3, 0.38, 0.12, 12), stone);
bowl.position.y = 0.95;
g.add(bowl);
const water2 = new THREE.Mesh(cyl(0.26, 0.26, 0.06, 12), mat(0x7fd4ea, { rough: .15 }));
water2.position.y = 1.02;
g.add(water2);
return shadowify(g);
}
function buildCatBed() {
const g = new THREE.Group();
const bed = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.12, 8, 16), mat(0xef8fb5, { rough: 1 }));
bed.rotation.x = Math.PI / 2;
bed.scale.y = 0.6;
bed.position.y = 0.1;
g.add(bed);
const cushion = new THREE.Mesh(cyl(0.3, 0.3, 0.08, 16), mat(0xf6d8e0, { rough: 1 }));
cushion.position.y = 0.06;
g.add(cushion);
// sleeping cat (if adopted)
if (G.data?.flags.cat) {
const body = new THREE.Mesh(sph(0.2, 10), mat(0xe8a25c, { rough: 1 }));
body.scale.set(1.25, 0.75, 1);
body.position.y = 0.18;
g.add(body);
const head = new THREE.Mesh(sph(0.13, 10), mat(0xe8a25c, { rough: 1 }));
head.position.set(0.24, 0.24, 0);
g.add(head);
for (const sgn of [-1, 1]) {
const ear = new THREE.Mesh(cone(0.05, 0.09, 4), mat(0xd98f47));
ear.position.set(0.24 + sgn * 0.07, 0.37, 0);
g.add(ear);
}
const tail = new THREE.Mesh(new THREE.TorusGeometry(0.12, 0.035, 6, 12, 4), mat(0xd98f47));
tail.position.set(-0.22, 0.12, 0.1);
tail.rotation.x = 1.2;
g.add(tail);
}
return shadowify(g);
}
// ---------- shelf stock visualization ----------
function slotsFor(type) {
if (type === 'shelf') return shelfSlots();
if (type === 'table') return tableSlots();
return [pedestalSlot()];
}
function restockShelfVisual(f, rec) {
// remove old product meshes
if (rec.items) for (const it of rec.items) it.removeFromParent();
rec.items = [];
const stock = G.data.shelfStock[f.id] || {};
const slots = slotsFor(f.type);
let slotIdx = 0;
for (const [pid, qty] of Object.entries(stock)) {
const n = Math.min(qty, f.type === 'pedestal' ? 1 : Math.ceil(qty / 3) + 1, slots.length - slotIdx);
for (let i = 0; i < n && slotIdx < slots.length; i++, slotIdx++) {
const m = buildProductMesh(pid, f.type === 'pedestal' ? 1.5 : 1);
const s = slots[slotIdx];
m.position.copy(s).add(new THREE.Vector3((Math.random() - 0.5) * 0.06, 0, (Math.random() - 0.5) * 0.08));
m.rotation.y = Math.random() * Math.PI * 2;
rec.group.add(m);
rec.items.push(m);
}
}
}