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:
@@ -0,0 +1,177 @@
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
|
||||
import { clamp, lerp, easeInOut } from '../util.js';
|
||||
|
||||
// ============================================================
|
||||
// RENDER ENGINE — renderer, scene, camera rig, post fx
|
||||
// ============================================================
|
||||
export const scene = new THREE.Scene();
|
||||
export let renderer = null;
|
||||
export let composer = null;
|
||||
let bloomPass = null;
|
||||
|
||||
export const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 300);
|
||||
|
||||
// --- camera orbit rig ---
|
||||
const rig = {
|
||||
target: new THREE.Vector3(0, 0.6, 0),
|
||||
tTarget: new THREE.Vector3(0, 0.6, 0),
|
||||
radius: 16, tRadius: 16,
|
||||
azimuth: Math.PI * 0.08, tAzimuth: Math.PI * 0.08,
|
||||
polar: 0.98, tPolar: 0.98,
|
||||
vRadius: 0,
|
||||
};
|
||||
const FOCUS_STACK = [];
|
||||
|
||||
export function initEngine(container) {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.12;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.38, 0.55, 0.86);
|
||||
composer.addPass(bloomPass);
|
||||
composer.addPass(new OutputPass());
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
return renderer;
|
||||
}
|
||||
|
||||
export function applyQuality(q) {
|
||||
if (!renderer) return;
|
||||
const high = q !== 'low';
|
||||
renderer.shadowMap.enabled = high;
|
||||
bloomPass.enabled = high;
|
||||
renderer.setPixelRatio(high ? Math.min(window.devicePixelRatio, 2) : 1);
|
||||
scene.traverse(o => { if (o.isMesh && o.material) o.material.needsUpdate = true; });
|
||||
}
|
||||
|
||||
// ---------- camera control ----------
|
||||
export function orbitRotate(dx, dy) {
|
||||
rig.tAzimuth -= dx * 0.005;
|
||||
rig.tPolar = clamp(rig.tPolar - dy * 0.004, 0.32, 1.35);
|
||||
}
|
||||
export function orbitZoom(delta) {
|
||||
rig.tRadius = clamp(rig.tRadius * (1 + delta * 0.0012), 6, 46);
|
||||
}
|
||||
export function orbitPan(dx, dz) {
|
||||
// pan in camera space
|
||||
const s = rig.radius * 0.0016;
|
||||
const cos = Math.cos(rig.azimuth), sin = Math.sin(rig.azimuth);
|
||||
rig.tTarget.x += (-dx * cos - dz * sin) * s * -1;
|
||||
rig.tTarget.z += (dx * sin - dz * cos) * s * -1;
|
||||
clampTarget();
|
||||
}
|
||||
export function setOrbit(target, radius, azimuth, polar, instant = false) {
|
||||
rig.tTarget.copy(target);
|
||||
rig.tRadius = radius;
|
||||
if (azimuth !== undefined) rig.tAzimuth = azimuth;
|
||||
if (polar !== undefined) rig.tPolar = polar;
|
||||
if (instant) {
|
||||
rig.target.copy(rig.tTarget); rig.radius = rig.tRadius;
|
||||
rig.azimuth = rig.tAzimuth; rig.polar = rig.tPolar;
|
||||
}
|
||||
}
|
||||
export function pushFocus(name) { if (!FOCUS_STACK.includes(name)) FOCUS_STACK.push(name); }
|
||||
export function popFocus(name) { const i = FOCUS_STACK.indexOf(name); if (i >= 0) FOCUS_STACK.splice(i, 1); }
|
||||
|
||||
function clampTarget() {
|
||||
const lim = 26;
|
||||
rig.tTarget.x = clamp(rig.tTarget.x, -lim, lim);
|
||||
rig.tTarget.z = clamp(rig.tTarget.z, -lim, lim);
|
||||
}
|
||||
|
||||
export function updateCamera(dt) {
|
||||
const k = 1 - Math.pow(0.0001, dt); // smooth exponential approach
|
||||
rig.target.lerp(rig.tTarget, k * 0.9);
|
||||
rig.radius = lerp(rig.radius, rig.tRadius, k * 0.9);
|
||||
rig.azimuth = lerp(rig.azimuth, rig.tAzimuth, k * 0.85);
|
||||
rig.polar = lerp(rig.polar, rig.tPolar, k * 0.85);
|
||||
|
||||
const sinP = Math.sin(rig.polar), cosP = Math.cos(rig.polar);
|
||||
camera.position.set(
|
||||
rig.target.x + rig.radius * sinP * Math.sin(rig.azimuth),
|
||||
rig.target.y + rig.radius * cosP,
|
||||
rig.target.z + rig.radius * sinP * Math.cos(rig.azimuth)
|
||||
);
|
||||
camera.lookAt(rig.target);
|
||||
camera.userData.lookTarget = rig.target;
|
||||
return { polar: rig.polar, azimuth: rig.azimuth };
|
||||
}
|
||||
|
||||
export function renderFrame() {
|
||||
if (composer) composer.render();
|
||||
}
|
||||
|
||||
// ---------- raycasting helper ----------
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const ndc = new THREE.Vector2();
|
||||
export function pickAt(clientX, clientY, objects, recursive = true) {
|
||||
ndc.x = (clientX / window.innerWidth) * 2 - 1;
|
||||
ndc.y = -(clientY / window.innerHeight) * 2 + 1;
|
||||
raycaster.setFromCamera(ndc, camera);
|
||||
const hits = raycaster.intersectObjects(objects, recursive);
|
||||
return hits.length ? hits[0] : null;
|
||||
}
|
||||
export function screenRay(clientX, clientY) {
|
||||
ndc.x = (clientX / window.innerWidth) * 2 - 1;
|
||||
ndc.y = -(clientY / window.innerHeight) * 2 + 1;
|
||||
raycaster.setFromCamera(ndc, camera);
|
||||
return raycaster.ray;
|
||||
}
|
||||
|
||||
// ---------- shared material cache ----------
|
||||
const matCache = new Map();
|
||||
export function mat(color, opts = {}) {
|
||||
const key = `${color}|${opts.rough ?? .8}|${opts.metal ?? 0}|${opts.flat ?? 1}|${opts.emissive ?? 0}|${opts.emissiveIntensity ?? 1}|${opts.transparent ?? 0}|${opts.opacity ?? 1}`;
|
||||
if (!matCache.has(key)) {
|
||||
const m = new THREE.MeshStandardMaterial({
|
||||
color, roughness: opts.rough ?? 0.8, metalness: opts.metal ?? 0,
|
||||
flatShading: opts.flat ?? true,
|
||||
emissive: opts.emissive || 0x000000,
|
||||
emissiveIntensity: opts.emissiveIntensity ?? 1,
|
||||
transparent: !!opts.transparent, opacity: opts.opacity ?? 1,
|
||||
});
|
||||
matCache.set(key, m);
|
||||
}
|
||||
return matCache.get(key);
|
||||
}
|
||||
export function basicMat(color, opacity = 1) {
|
||||
const key = `b|${color}|${opacity}`;
|
||||
if (!matCache.has(key)) {
|
||||
matCache.set(key, new THREE.MeshBasicMaterial({ color, transparent: opacity < 1, opacity }));
|
||||
}
|
||||
return matCache.get(key);
|
||||
}
|
||||
|
||||
// ---------- geometry helpers ----------
|
||||
export function roundedBox(w, h, d, r = 0.06, seg = 2) {
|
||||
// lightweight rounded box via BoxGeometry + bevel illusion (cheap): we just use box here
|
||||
// (RoundedBoxGeometry import kept out to save bundle; visual difference minor at this scale)
|
||||
return new THREE.BoxGeometry(w, h, d, 1, 1, 1);
|
||||
}
|
||||
export function cyl(rt, rb, h, seg = 12) { return new THREE.CylinderGeometry(rt, rb, h, seg); }
|
||||
export function sph(r, seg = 14) { return new THREE.SphereGeometry(r, seg, Math.max(8, seg / 2)); }
|
||||
|
||||
export function disposeObject(obj) {
|
||||
obj.traverse(o => {
|
||||
if (o.geometry) o.geometry.dispose();
|
||||
// materials are cached/shared — don't dispose
|
||||
});
|
||||
obj.parent?.remove(obj);
|
||||
}
|
||||
Reference in New Issue
Block a user