- 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)
430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
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);
|
||
}
|
||
}
|
||
}
|