Import v2 base (unchanged files)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
|
||||
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
|
||||
* randomness, so phones stay in sync without streaming positions.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
function hashNoise(seed, k) {
|
||||
// cheap deterministic pseudo-noise in [-1, 1]
|
||||
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
|
||||
return (x - Math.floor(x)) * 2 - 1;
|
||||
}
|
||||
|
||||
export function ghostTransform(rec, nowMs, out) {
|
||||
const t = (nowMs - rec.spawnedAt) / 1000;
|
||||
const base = new THREE.Vector3(...rec.pos);
|
||||
const b = rec.behavior || { type: 'static' };
|
||||
|
||||
if (b.type === 'path') {
|
||||
pathTransform(b, t, out);
|
||||
} else if (b.type === 'wander') {
|
||||
const s = b.seed || 1;
|
||||
const R = b.radius ?? 60; // cm
|
||||
const v = b.speed ?? 0.15;
|
||||
// smooth pseudo-random orbit-drift: two incommensurate sines per axis
|
||||
const ph = t * v;
|
||||
out.position.set(
|
||||
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
|
||||
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
|
||||
base.y + (b.vertical ?? 10) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28)
|
||||
+ 3 * Math.sin(t * 1.7 + hashNoise(s, 6) * 6.28),
|
||||
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
|
||||
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
|
||||
);
|
||||
// face travel direction (finite difference)
|
||||
const eps = 0.05;
|
||||
const ahead = (t2) => new THREE.Vector3(
|
||||
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
|
||||
0,
|
||||
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
|
||||
const dir = ahead(t + eps).sub(ahead(t));
|
||||
out.rotationY = Math.atan2(dir.x, dir.z);
|
||||
} else {
|
||||
const amp = b.bobAmp ?? 6; // cm
|
||||
const hz = b.bobHz ?? 0.4;
|
||||
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z);
|
||||
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
|
||||
}
|
||||
|
||||
// crossfade opacity: fade in on spawn; permanent residents (until == null) never fade out
|
||||
const fade = (rec.crossfade || 3) * 1000;
|
||||
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
|
||||
const outA = rec.until == null ? 1 : Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
|
||||
out.opacity = Math.min(inA, outA);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Walk a waypoint path at constant speed (cm/s), turning to face travel direction.
|
||||
* Deterministic: position is a pure function of elapsed time, so all viewers agree.
|
||||
* b: { points:[[x,y,z],...], mode:'loop'|'pingpong', speed, phase, seed } */
|
||||
function pathTransform(b, t, out) {
|
||||
const raw = b.points || [];
|
||||
if (raw.length < 2) { out.position.set(...(raw[0] || [0, 0, 0])); out.rotationY = 0; return; }
|
||||
const pts = b.mode === 'loop' ? [...raw, raw[0]] : raw;
|
||||
|
||||
// cumulative segment lengths
|
||||
const cum = [0];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1], dz = pts[i][2] - pts[i - 1][2];
|
||||
cum.push(cum[i - 1] + Math.hypot(dx, dy, dz));
|
||||
}
|
||||
const total = cum[cum.length - 1] || 1;
|
||||
|
||||
const wrap = (d) => {
|
||||
if (b.mode === 'pingpong') { const m = ((d % (2 * total)) + 2 * total) % (2 * total); return m < total ? m : 2 * total - m; }
|
||||
return ((d % total) + total) % total;
|
||||
};
|
||||
const sample = (d, v) => {
|
||||
let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
|
||||
const f = (d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6);
|
||||
v.set(
|
||||
pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * f,
|
||||
pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * f,
|
||||
pts[i - 1][2] + (pts[i][2] - pts[i - 1][2]) * f);
|
||||
return v;
|
||||
};
|
||||
|
||||
const d = wrap(t * (b.speed ?? 8) + (b.phase || 0));
|
||||
sample(d, out.position);
|
||||
// face where we're heading: sample a few cm ahead so corners turn smoothly
|
||||
const ahead = sample(wrap(t * (b.speed ?? 8) + (b.phase || 0) + 6), _look);
|
||||
const dx = ahead.x - out.position.x, dz = ahead.z - out.position.z;
|
||||
if (dx * dx + dz * dz > 1e-4) out.rotationY = Math.atan2(dx, dz);
|
||||
// gentle float so walking still reads ghostly
|
||||
out.position.y += 2 * Math.sin(t * 1.9 + (b.seed || 0));
|
||||
}
|
||||
const _look = new THREE.Vector3();
|
||||
@@ -0,0 +1,280 @@
|
||||
/* loader.js — ghost visuals.
|
||||
*
|
||||
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
|
||||
* rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor:
|
||||
* Red / Yellow / Blue). Procedural wisp fallback until models are uploaded.
|
||||
*
|
||||
* Per-character overrides come from /api/characters (managed in /admin/characters.html):
|
||||
* { modelId, opacity, topColor, bottomColor, heightCm, scale,
|
||||
* faceTextureUrl, torsoTextureUrl,
|
||||
* partOverrides: { <partKey>: { offset:[x,y,z], rotation:[x,y,z], scale } } }
|
||||
*
|
||||
* Part placement: each part in a model may be a bare URL string, or an object
|
||||
* { url, offset:[x,y,z], rotation:[x,y,z](degrees), scale }
|
||||
* The model manifest holds the shared default placement; a character's
|
||||
* partOverrides[key] is merged on top so an individual ghost can be nudged
|
||||
* without disturbing every other ghost that shares the same model.
|
||||
*
|
||||
* Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
|
||||
* so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
|
||||
* centre/size, blended over the gradient — a simple decal without needing UV-mapped models.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
/* Canonical part order (also the assembly/z-order). Shared with the editor. */
|
||||
export const PART_KEYS = ['legs', 'wisp', 'torso', 'head', 'headpiece'];
|
||||
|
||||
const objLoader = new OBJLoader();
|
||||
const objCache = new Map();
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
const texCache = new Map();
|
||||
|
||||
/* A sampler2D uniform must ALWAYS be bound to a real texture: leaving it null makes
|
||||
* the shader program fail to link on many GPUs, which would break every untextured
|
||||
* ghost. This 1x1 fully-transparent pixel is the safe no-op binding. */
|
||||
const EMPTY_TEX = (() => {
|
||||
const t = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat);
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
})();
|
||||
|
||||
function loadTexture(url) {
|
||||
if (!url) return null;
|
||||
if (!texCache.has(url)) {
|
||||
const t = texLoader.load(url, undefined, undefined,
|
||||
() => console.warn('texture failed to load:', url)); // keeps the ghost alive
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
texCache.set(url, t);
|
||||
}
|
||||
return texCache.get(url);
|
||||
}
|
||||
|
||||
function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
topColor: { value: new THREE.Color(top) },
|
||||
bottomColor: { value: new THREE.Color(bottom) },
|
||||
opacity: { value: opacity },
|
||||
uMinY: { value: 0 },
|
||||
uMaxY: { value: 1 },
|
||||
uToGroup: { value: new THREE.Matrix4() }, // mesh-local -> group-local (bakes part transform)
|
||||
uTime: { value: 0 },
|
||||
faceMap: { value: faceTex || EMPTY_TEX },
|
||||
torsoMap: { value: torsoTex || EMPTY_TEX },
|
||||
hasFace: { value: faceTex ? 1 : 0 },
|
||||
hasTorso: { value: torsoTex ? 1 : 0 },
|
||||
// [centreY, halfHeight, halfWidth] in normalized model space
|
||||
faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) },
|
||||
torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) },
|
||||
},
|
||||
vertexShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vLocal;
|
||||
uniform float uMinY, uMaxY;
|
||||
uniform mat4 uToGroup;
|
||||
void main() {
|
||||
// Position within the assembled group (accounts for per-part offset/rotation/scale),
|
||||
// so the gradient + decal bands span the whole figure, not each part's raw origin.
|
||||
vec3 gp = (uToGroup * vec4(position, 1.0)).xyz;
|
||||
float h = max(uMaxY - uMinY, 0.001);
|
||||
vY = clamp((gp.y - uMinY) / h, 0.0, 1.0);
|
||||
vLocal = vec3(gp.x / h, vY, gp.z / h);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vLocal;
|
||||
uniform vec3 topColor, bottomColor;
|
||||
uniform float opacity, uTime;
|
||||
uniform sampler2D faceMap, torsoMap;
|
||||
uniform int hasFace, hasTorso;
|
||||
uniform vec3 faceBand, torsoBand;
|
||||
|
||||
// planar-front decal: map local X/Y into the band's UV box
|
||||
vec4 decal(sampler2D map, vec3 band) {
|
||||
vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5,
|
||||
((vLocal.y - band.x) / band.y) * 0.5 + 0.5);
|
||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0);
|
||||
return texture2D(map, uv);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 c = mix(bottomColor, topColor, vY);
|
||||
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
|
||||
c += rim * 0.35;
|
||||
|
||||
// only decal the front-facing side so images don't mirror onto the back
|
||||
float front = smoothstep(0.0, 0.35, vNormal.z);
|
||||
if (hasTorso == 1) {
|
||||
vec4 t = decal(torsoMap, torsoBand);
|
||||
c = mix(c, t.rgb, t.a * front);
|
||||
}
|
||||
if (hasFace == 1) {
|
||||
vec4 f = decal(faceMap, faceBand);
|
||||
c = mix(c, f.rgb, f.a * front);
|
||||
}
|
||||
|
||||
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
|
||||
gl_FragColor = vec4(c, opacity * pulse);
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOBJ(url) {
|
||||
if (objCache.has(url)) return objCache.get(url).clone();
|
||||
const obj = await objLoader.loadAsync(url);
|
||||
objCache.set(url, obj);
|
||||
return obj.clone();
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/* A part entry may be a bare URL string or { url, offset, rotation, scale }.
|
||||
* Return a normalized { url, offset:[x,y,z], rotation:[x,y,z], scale } or null. */
|
||||
export function normalizePart(entry) {
|
||||
if (!entry) return null;
|
||||
if (typeof entry === 'string') {
|
||||
return { url: entry, offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1 };
|
||||
}
|
||||
if (!entry.url) return null;
|
||||
const o = Array.isArray(entry.offset) ? entry.offset : [0, 0, 0];
|
||||
const r = Array.isArray(entry.rotation) ? entry.rotation : [0, 0, 0];
|
||||
return {
|
||||
url: entry.url,
|
||||
offset: [+o[0] || 0, +o[1] || 0, +o[2] || 0],
|
||||
rotation: [+r[0] || 0, +r[1] || 0, +r[2] || 0],
|
||||
scale: entry.scale != null ? (+entry.scale || 1) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/* Merge a per-ghost override on top of the model's default placement.
|
||||
* Only fields present in the override replace the base; url always wins from base. */
|
||||
export function mergePart(base, override) {
|
||||
const b = normalizePart(base);
|
||||
if (!b) return null;
|
||||
if (!override) return b;
|
||||
const ov = override;
|
||||
return {
|
||||
url: b.url,
|
||||
offset: Array.isArray(ov.offset) ? ov.offset.map((v, i) => (v != null ? +v : b.offset[i])) : b.offset,
|
||||
rotation: Array.isArray(ov.rotation) ? ov.rotation.map((v, i) => (v != null ? +v : b.rotation[i])) : b.rotation,
|
||||
scale: ov.scale != null ? +ov.scale : b.scale,
|
||||
};
|
||||
}
|
||||
|
||||
/* Apply a normalized transform to a freshly-loaded part group. */
|
||||
function applyPartTransform(obj, p) {
|
||||
obj.position.set(p.offset[0], p.offset[1], p.offset[2]);
|
||||
obj.rotation.set(p.rotation[0] * DEG, p.rotation[1] * DEG, p.rotation[2] * DEG);
|
||||
obj.scale.setScalar(p.scale);
|
||||
obj.userData.partKey = p.key;
|
||||
return obj;
|
||||
}
|
||||
|
||||
function proceduralWisp() {
|
||||
const g = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16));
|
||||
body.scale.set(1, 1.35, 1);
|
||||
body.position.y = 14;
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16));
|
||||
tail.rotation.x = Math.PI;
|
||||
tail.position.y = 0;
|
||||
g.add(body, tail);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* Resolve the override chain: character-specific -> defaults -> built-in. */
|
||||
function resolveOverrides(ghost, characters) {
|
||||
const d = (characters && characters.defaults) || {};
|
||||
const c = (characters && characters.byId && characters.byId[ghost.id]) || {};
|
||||
return { ...d, ...c };
|
||||
}
|
||||
|
||||
function pickModel(manifest, ov) {
|
||||
const models = (manifest && manifest.models) || [];
|
||||
if (!models.length) return null;
|
||||
if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0];
|
||||
return models[0];
|
||||
}
|
||||
|
||||
export async function buildGhost(ghost, gradients, manifest, characters) {
|
||||
const ov = resolveOverrides(ghost, characters);
|
||||
const grad = (gradients && (gradients[ghost.color] || gradients.Blue))
|
||||
|| { top: '#51eaf1', bottom: '#529eff' }; // survive a missing/!oddly-shaped gradient payload
|
||||
const top = ov.topColor || grad.top;
|
||||
const bottom = ov.bottomColor || grad.bottom;
|
||||
const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92;
|
||||
const bands = {
|
||||
faceY: ov.faceY != null ? Number(ov.faceY) : 0.82,
|
||||
faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16,
|
||||
torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52,
|
||||
torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22,
|
||||
};
|
||||
const mat = gradientMaterial(top, bottom, opacity,
|
||||
loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands);
|
||||
|
||||
let group;
|
||||
const model = pickModel(manifest, ov);
|
||||
const partOv = ov.partOverrides || {};
|
||||
if (model && model.parts) {
|
||||
group = new THREE.Group();
|
||||
try {
|
||||
for (const key of PART_KEYS) {
|
||||
const merged = mergePart(model.parts[key], partOv[key]);
|
||||
if (!merged) continue;
|
||||
merged.key = key;
|
||||
const partObj = await loadOBJ(merged.url);
|
||||
applyPartTransform(partObj, merged);
|
||||
group.add(partObj);
|
||||
}
|
||||
if (!group.children.length) group = proceduralWisp();
|
||||
} catch (e) {
|
||||
console.warn('ghost model load failed, using wisp fallback', e);
|
||||
group = proceduralWisp();
|
||||
}
|
||||
} else {
|
||||
group = proceduralWisp();
|
||||
}
|
||||
|
||||
// gradient/decal mapping needs the model's Y range (group-local, post part-transform)
|
||||
group.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const groupInv = new THREE.Matrix4().copy(group.matrixWorld).invert();
|
||||
const mats = [];
|
||||
group.traverse(o => {
|
||||
if (o.isMesh) {
|
||||
// Each mesh needs its own material instance to carry a per-mesh group-local matrix,
|
||||
// but they all share the same tunable uniforms via the tick/opacity setters below.
|
||||
const m = mat.clone();
|
||||
m.uniforms.uMinY.value = box.min.y;
|
||||
m.uniforms.uMaxY.value = box.max.y;
|
||||
m.uniforms.uToGroup.value = new THREE.Matrix4().multiplyMatrices(groupInv, o.matrixWorld);
|
||||
o.material = m;
|
||||
mats.push(m);
|
||||
}
|
||||
});
|
||||
if (!mats.length) mats.push(mat);
|
||||
|
||||
// Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
|
||||
const outer = new THREE.Group();
|
||||
const inner = new THREE.Group();
|
||||
inner.position.y = -box.min.y;
|
||||
inner.add(group);
|
||||
outer.add(inner);
|
||||
const rawH = Math.max(box.max.y - box.min.y, 1e-6);
|
||||
const modelScale = (ov.scale || model?.scale || 1);
|
||||
outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
|
||||
outer.userData.setHeight(4);
|
||||
|
||||
outer.userData.material = mats[0];
|
||||
outer.userData.materials = mats;
|
||||
outer.userData.setOpacity = (v) => { for (const m of mats) m.uniforms.opacity.value = v * opacity; };
|
||||
outer.userData.tick = (t) => { for (const m of mats) m.uniforms.uTime.value = t; };
|
||||
return outer;
|
||||
}
|
||||
Reference in New Issue
Block a user