Phase 3 update: orbit ghosts, parallax, FX, shader fix [build v8]
This commit is contained in:
+80
-11
@@ -27,6 +27,15 @@ export const GHOST_GRADIENTS = {
|
|||||||
Blue: { top: '#529EFF', bottom: '#51EAF1' }, // GhostType_Sad
|
Blue: { top: '#529EFF', bottom: '#51EAF1' }, // GhostType_Sad
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared ghost mesh (Ghost_Bat) loaded once and cloned per-ghost. Set via
|
||||||
|
// setSharedMesh() before spawning; if null, ghosts fall back to the lathe wisp.
|
||||||
|
let SHARED_MESH = null;
|
||||||
|
export function setSharedMesh(mesh) { SHARED_MESH = mesh || null; }
|
||||||
|
|
||||||
|
// Y-range of the shared mesh, for mapping the vertical gradient onto it.
|
||||||
|
let SHARED_MESH_Y = { min: -1.83, max: 1.83 };
|
||||||
|
export function setSharedMeshYRange(min, max) { SHARED_MESH_Y = { min, max }; }
|
||||||
|
|
||||||
const WISP_VERT = `
|
const WISP_VERT = `
|
||||||
varying float vY;
|
varying float vY;
|
||||||
varying vec3 vPos;
|
varying vec3 vPos;
|
||||||
@@ -58,6 +67,34 @@ const WISP_FRAG = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Gradient shader for the real mesh: maps the vertical gradient onto the mesh's
|
||||||
|
// object-space Y range (passed as uYMin/uYMax) and keeps it solid + glowy.
|
||||||
|
const MESH_VERT = `
|
||||||
|
varying float vT;
|
||||||
|
uniform float uYMin;
|
||||||
|
uniform float uYMax;
|
||||||
|
uniform float uTime;
|
||||||
|
void main() {
|
||||||
|
vT = clamp((position.y - uYMin) / max(0.001, (uYMax - uYMin)), 0.0, 1.0);
|
||||||
|
vec3 p = position;
|
||||||
|
// subtle billow so the ghost feels alive
|
||||||
|
p.x += sin(uTime * 1.5 + position.y * 2.0) * 0.03;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const MESH_FRAG = `
|
||||||
|
varying float vT;
|
||||||
|
uniform vec3 uTop;
|
||||||
|
uniform vec3 uBottom;
|
||||||
|
uniform float uAlpha;
|
||||||
|
uniform float uTime;
|
||||||
|
void main() {
|
||||||
|
vec3 col = mix(uBottom, uTop, vT);
|
||||||
|
float shimmer = 0.85 + 0.15 * sin(uTime * 2.0 + vT * 6.2831);
|
||||||
|
gl_FragColor = vec4(col * shimmer, uAlpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function hexToRGB(THREE, hex) { return new THREE.Color(hex); }
|
function hexToRGB(THREE, hex) { return new THREE.Color(hex); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,23 +125,55 @@ export function createGhostVisual(THREE, opts = {}) {
|
|||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = `ghost-${color}`;
|
group.name = `ghost-${color}`;
|
||||||
|
|
||||||
// --- Tier 0: wisp body + gradient material ---
|
// --- Tier 0: ghost body. Prefer the real shared mesh (Ghost_Bat), recoloured
|
||||||
|
// by the gradient; fall back to the procedural lathe wisp if no mesh. ---
|
||||||
const uniforms = {
|
const uniforms = {
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uTop: { value: hexToRGB(THREE, grad.top) },
|
uTop: { value: hexToRGB(THREE, grad.top) },
|
||||||
uBottom: { value: hexToRGB(THREE, grad.bottom) },
|
uBottom: { value: hexToRGB(THREE, grad.bottom) },
|
||||||
uAlpha: { value: 1.0 },
|
uAlpha: { value: 1.0 },
|
||||||
};
|
};
|
||||||
const mat = new THREE.ShaderMaterial({
|
|
||||||
vertexShader: WISP_VERT,
|
let body, mat;
|
||||||
fragmentShader: WISP_FRAG,
|
if (SHARED_MESH) {
|
||||||
uniforms,
|
// clone the shared mesh geometry and give it the mesh gradient material
|
||||||
transparent: true,
|
mat = new THREE.ShaderMaterial({
|
||||||
depthWrite: false,
|
vertexShader: MESH_VERT,
|
||||||
side: THREE.DoubleSide,
|
fragmentShader: MESH_FRAG,
|
||||||
blending: THREE.NormalBlending,
|
uniforms: {
|
||||||
});
|
...uniforms,
|
||||||
const body = new THREE.Mesh(buildWispGeometry(THREE), mat);
|
uYMin: { value: SHARED_MESH_Y.min },
|
||||||
|
uYMax: { value: SHARED_MESH_Y.max },
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
blending: THREE.NormalBlending,
|
||||||
|
});
|
||||||
|
// SHARED_MESH may be a Group (from OBJLoader) or a Mesh; find the first mesh geometry
|
||||||
|
let srcGeo = null;
|
||||||
|
SHARED_MESH.traverse?.(o => { if (!srcGeo && o.isMesh) srcGeo = o.geometry; });
|
||||||
|
if (!srcGeo && SHARED_MESH.isMesh) srcGeo = SHARED_MESH.geometry;
|
||||||
|
body = new THREE.Mesh(srcGeo, mat);
|
||||||
|
// normalise scale: mesh is ~3.6 units tall; scale to ~1.2 like the wisp
|
||||||
|
const targetH = 1.4;
|
||||||
|
const meshH = (SHARED_MESH_Y.max - SHARED_MESH_Y.min) || 3.66;
|
||||||
|
body.scale.setScalar(targetH / meshH);
|
||||||
|
// keep the alpha-driven uniforms reachable for animation
|
||||||
|
uniforms.uTime = mat.uniforms.uTime;
|
||||||
|
uniforms.uAlpha = mat.uniforms.uAlpha;
|
||||||
|
} else {
|
||||||
|
mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: WISP_VERT,
|
||||||
|
fragmentShader: WISP_FRAG,
|
||||||
|
uniforms,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
blending: THREE.NormalBlending,
|
||||||
|
});
|
||||||
|
body = new THREE.Mesh(buildWispGeometry(THREE), mat);
|
||||||
|
}
|
||||||
group.add(body);
|
group.add(body);
|
||||||
|
|
||||||
// --- always-on soft glow halo (procedural, no texture needed) so the ghost
|
// --- always-on soft glow halo (procedural, no texture needed) so the ghost
|
||||||
|
|||||||
+24
-8
@@ -203,7 +203,7 @@
|
|||||||
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
|
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="note">Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</div>
|
<div id="note">Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</div>
|
||||||
<div id="ver" style="position:fixed;right:6px;bottom:6px;z-index:11;font-size:9px;color:#2e3340;letter-spacing:0.1em;">build v8</div>
|
<div id="ver" style="position:fixed;right:6px;bottom:6px;z-index:11;font-size:9px;color:#2e3340;letter-spacing:0.1em;">build v9</div>
|
||||||
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{ "imports": {
|
{ "imports": {
|
||||||
@@ -213,14 +213,15 @@
|
|||||||
</script>
|
</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createGhostVisual } from './ghost-visual.js?v=8';
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
import { createHunt } from './hunt.js?v=8';
|
import { createGhostVisual, setSharedMesh, setSharedMeshYRange } from './ghost-visual.js?v=9';
|
||||||
console.log('%cNewbury Nights hunt — build v8 loaded (orbit ghosts + parallax)', 'color:#51EAF1');
|
import { createHunt } from './hunt.js?v=9';
|
||||||
|
console.log('%cNewbury Nights hunt — build v9 loaded (real ghost mesh)', 'color:#51EAF1');
|
||||||
|
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
let roster = [];
|
let roster = [];
|
||||||
try {
|
try {
|
||||||
roster = await (await fetch('./ghosts.enriched.json?v=8')).json();
|
roster = await (await fetch('./ghosts.enriched.json?v=9')).json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
roster = [
|
roster = [
|
||||||
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
||||||
@@ -244,12 +245,27 @@ function loadTex(path){
|
|||||||
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
|
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
|
||||||
}
|
}
|
||||||
const fxTextures = {
|
const fxTextures = {
|
||||||
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=8'),
|
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=9'),
|
||||||
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=8'),
|
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=9'),
|
||||||
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=8'),
|
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=9'),
|
||||||
};
|
};
|
||||||
console.log('FX textures loaded:', Object.fromEntries(Object.entries(fxTextures).map(([k,v])=>[k, !!v])));
|
console.log('FX textures loaded:', Object.fromEntries(Object.entries(fxTextures).map(([k,v])=>[k, !!v])));
|
||||||
|
|
||||||
|
// ---- load the real ghost mesh (Ghost_Bat) used as the body for all ghosts ----
|
||||||
|
// Recoloured per-ghost by the gradient. Falls back to procedural wisp if it fails.
|
||||||
|
try {
|
||||||
|
const objLoader = new OBJLoader();
|
||||||
|
const meshObj = await new Promise((res, rej) =>
|
||||||
|
objLoader.load('./ghosts/mesh/Ghost_Bat.obj?v=9', res, undefined, rej));
|
||||||
|
// compute the mesh's Y range so the gradient maps head->tail correctly
|
||||||
|
const box = new THREE.Box3().setFromObject(meshObj);
|
||||||
|
setSharedMeshYRange(box.min.y, box.max.y);
|
||||||
|
setSharedMesh(meshObj);
|
||||||
|
console.log('Ghost mesh loaded: Ghost_Bat Y[' + box.min.y.toFixed(2) + ',' + box.max.y.toFixed(2) + ']');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Ghost mesh failed to load, using procedural wisp:', err?.message || err);
|
||||||
|
}
|
||||||
|
|
||||||
addEventListener('resize', () => {
|
addEventListener('resize', () => {
|
||||||
renderer.setSize(innerWidth, innerHeight);
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
|
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||||
|
|||||||
Reference in New Issue
Block a user