Files
tinyshop/src/gfx/engine.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

178 lines
6.7 KiB
JavaScript

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);
}