Files
tinyshop/src/gfx/furniture3d.js
T
deepseek 0e7c07533e 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)
2026-08-23 06:58:24 +00:00

430 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}