Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce35712014 | |||
| 802ae7fab9 | |||
| 70dc130647 | |||
| 6aae22b648 | |||
| a6c45437c3 | |||
| 6a5fb9d8c6 | |||
| efadc85195 | |||
| 653980a52e | |||
| 25832dd58f | |||
| 4cf8ec07a4 | |||
| 793a17dbdf | |||
| 6f8b67b583 | |||
| b2a863bd80 | |||
| 2d66d809d4 | |||
| e81f779ea5 | |||
| a8552592c7 | |||
| 458c66a2c0 | |||
| 327b37babb | |||
| 3fa50c4c2f | |||
| 8e8e259b4c | |||
| 2fe4d67518 | |||
| 0c5123e3a6 | |||
| dc5e032b3a | |||
| 22983caa18 | |||
| 9866e44445 | |||
| ec4442d4ce |
@@ -70,5 +70,19 @@ CREATE TABLE IF NOT EXISTS set_ghosts (
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
/* ---- Migrations (idempotent) ----
|
||||||
|
* Add transparent-video sprite columns to the existing ghosts table without
|
||||||
|
* dropping data. webm_path is the VP9+alpha billboard; webp_path is the
|
||||||
|
* animated-WebP fallback used where VP9 alpha isn't supported (e.g. iOS).
|
||||||
|
*/
|
||||||
|
function addColumnIfMissing(table, column, decl) {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
if (!cols.some((c) => c.name === column)) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addColumnIfMissing('ghosts', 'webm_path', 'TEXT'); // VP9+alpha billboard (nullable)
|
||||||
|
addColumnIfMissing('ghosts', 'webp_path', 'TEXT'); // animated-WebP fallback (nullable)
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
export { DB_PATH };
|
export { DB_PATH };
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { basename, extname, join } from 'node:path';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ghost-media.js — server-side ghost sprite conversion.
|
||||||
|
*
|
||||||
|
* Turns an uploaded black-background ghost MP4 into a transparent animated
|
||||||
|
* WebM (VP9 + alpha) plus an animated WebP fallback, using luma keying so the
|
||||||
|
* black becomes transparent and the glow keeps soft semi-transparent edges.
|
||||||
|
*
|
||||||
|
* Shells out to the system `ffmpeg` binary (no npm dependency). If ffmpeg is
|
||||||
|
* not installed, conversion fails gracefully and the caller keeps the original.
|
||||||
|
*
|
||||||
|
* This mirrors the standalone ghostify.sh pipeline so local and server output
|
||||||
|
* match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||||
|
|
||||||
|
// Tunables (env-overridable) — kept in sync with ghostify.sh defaults.
|
||||||
|
const THRESH = process.env.GHOST_KEY_THRESHOLD || '0.06';
|
||||||
|
const TOL = process.env.GHOST_KEY_TOLERANCE || '0.10';
|
||||||
|
const SOFT = process.env.GHOST_KEY_SOFTNESS || '0.15';
|
||||||
|
const FPS = process.env.GHOST_FPS || '15';
|
||||||
|
const MAXW = process.env.GHOST_MAXW || '512';
|
||||||
|
const WEBP_Q = process.env.GHOST_WEBP_QUALITY || '80';
|
||||||
|
const VP9_CRF = process.env.GHOST_VP9_CRF || '30';
|
||||||
|
|
||||||
|
// The shared luma-key + downscale filter chain.
|
||||||
|
function keyChain() {
|
||||||
|
const key = `format=rgba,lumakey=threshold=${THRESH}:tolerance=${TOL}:softness=${SOFT}`;
|
||||||
|
const scale = `scale='min(iw,${MAXW})':-1:flags=lanczos`;
|
||||||
|
return `${key},fps=${FPS},${scale}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||||
|
proc.on('error', reject); // e.g. ffmpeg not found (ENOENT)
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if a usable ffmpeg binary is on PATH. */
|
||||||
|
export function ffmpegAvailable() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn(FFMPEG, ['-version'], { stdio: 'ignore' });
|
||||||
|
proc.on('error', () => resolve(false));
|
||||||
|
proc.on('close', (code) => resolve(code === 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function outName(srcFilename, ext) {
|
||||||
|
// wraith-1718.mp4 -> wraith-1718.webm / .webp, written alongside the source.
|
||||||
|
const stem = basename(srcFilename, extname(srcFilename));
|
||||||
|
return `${stem}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a source MP4 (already saved in uploadDir) into transparent webm+webp.
|
||||||
|
*
|
||||||
|
* @param {string} uploadDir absolute uploads directory
|
||||||
|
* @param {string} srcFilename the multer filename of the uploaded mp4
|
||||||
|
* @returns {Promise<{ webm: string|null, webp: string|null }>} new filenames
|
||||||
|
* (not full paths). Either may be null if that encode failed.
|
||||||
|
*/
|
||||||
|
export async function convertGhostMp4(uploadDir, srcFilename) {
|
||||||
|
const srcPath = join(uploadDir, srcFilename);
|
||||||
|
if (!existsSync(srcPath)) throw new Error(`source not found: ${srcPath}`);
|
||||||
|
|
||||||
|
const vf = keyChain();
|
||||||
|
const result = { webm: null, webp: null };
|
||||||
|
|
||||||
|
// --- WebM (VP9 + alpha): the primary, browser-decoded sprite ---
|
||||||
|
const webmName = outName(srcFilename, 'webm');
|
||||||
|
try {
|
||||||
|
await run([
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
|
||||||
|
'-vf', vf, '-an',
|
||||||
|
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p',
|
||||||
|
'-b:v', '0', '-crf', VP9_CRF, '-auto-alt-ref', '0',
|
||||||
|
join(uploadDir, webmName),
|
||||||
|
]);
|
||||||
|
if (statSync(join(uploadDir, webmName)).size > 0) result.webm = webmName;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ghost-media] webm encode failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebP fallback: for iOS Safari / no-VP9-alpha browsers ---
|
||||||
|
const webpName = outName(srcFilename, 'webp');
|
||||||
|
try {
|
||||||
|
await run([
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
|
||||||
|
'-vf', vf, '-loop', '0', '-an',
|
||||||
|
'-c:v', 'libwebp_anim', '-lossless', '0', '-q:v', WEBP_Q,
|
||||||
|
'-preset', 'drawing', '-compression_level', '6',
|
||||||
|
join(uploadDir, webpName),
|
||||||
|
]);
|
||||||
|
if (statSync(join(uploadDir, webpName)).size > 0) result.webp = webpName;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ghost-media] webp encode failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -100,12 +100,13 @@
|
|||||||
<label class="chk"><input type="checkbox" id="gm-enabled" checked /> Enabled</label>
|
<label class="chk"><input type="checkbox" id="gm-enabled" checked /> Enabled</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-row">
|
<div class="image-row">
|
||||||
<label>Billboard image (GIF / PNG)</label>
|
<label>Billboard (GIF / PNG / WebP, or MP4 / WebM video)</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="gm-file" type="file" accept=".gif,.png,.jpg,.jpeg,.webp" />
|
<input id="gm-file" type="file" accept=".gif,.png,.jpg,.jpeg,.webp,.webm,.mp4" />
|
||||||
<img id="gm-preview" class="gm-preview hidden" alt="" />
|
<img id="gm-preview" class="gm-preview hidden" alt="" />
|
||||||
|
<video id="gm-preview-vid" class="gm-preview hidden" muted loop playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="font-size:11px;margin-top:4px">Upload happens after you save the ghost.</div>
|
<div class="muted" style="font-size:11px;margin-top:4px">Upload happens after you save the ghost. MP4 files are converted to a transparent WebM (with a WebP fallback) on the server.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button data-close>Cancel</button>
|
<button data-close>Cancel</button>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ td .sub { color: var(--text-dim); font-size: 12px; }
|
|||||||
.toggle.on { color: var(--gloom); } .toggle.off { color: var(--text-dim); }
|
.toggle.on { color: var(--gloom); } .toggle.off { color: var(--text-dim); }
|
||||||
.row-actions { display: flex; gap: 6px; }
|
.row-actions { display: flex; gap: 6px; }
|
||||||
.row-actions button { padding: 5px 9px; font-size: 11px; }
|
.row-actions button { padding: 5px 9px; font-size: 11px; }
|
||||||
.thumb-cell img { width: 34px; height: 34px; object-fit: contain; background: #000a; border-radius: 6px; }
|
.thumb-cell img, .thumb-cell video { width: 34px; height: 34px; object-fit: contain; background: #000a; border-radius: 6px; }
|
||||||
|
|
||||||
.sets-list { display: grid; gap: 14px; }
|
.sets-list { display: grid; gap: 14px; }
|
||||||
.set-card { border: 1px solid var(--line); border-radius: var(--rad); padding: 16px; background: var(--panel); }
|
.set-card { border: 1px solid var(--line); border-radius: var(--rad); padding: 16px; background: var(--panel); }
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* ghost-visual.js — Newbury Nights shared ghost visual (Phase 2)
|
||||||
|
*
|
||||||
|
* Replicates the original's architecture: ONE shared base body, recoloured by a
|
||||||
|
* per-colour gradient (recovered from GhostType_* assets). No per-ghost models.
|
||||||
|
*
|
||||||
|
* Tier 0 (always): procedural wisp mesh + vertical gradient material.
|
||||||
|
* Tier 1 (progressive enhancement): FX billboards (trail / glow / smoke)
|
||||||
|
* layered on top when textures + GPU budget allow.
|
||||||
|
* Special: two roster ghosts can use real meshes (Ghost_Baseball/Bat) via
|
||||||
|
* the `meshLoader` hook.
|
||||||
|
*
|
||||||
|
* Engine-agnostic about your spawn/capture logic — this only produces the
|
||||||
|
* Object3D you place in the scene. Drop into your Three.js r160 app.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { createGhostVisual, GHOST_GRADIENTS } from './ghost-visual.js';
|
||||||
|
* const g = createGhostVisual(THREE, { color: 'Red', fx: true });
|
||||||
|
* scene.add(g.object3d);
|
||||||
|
* // each frame:
|
||||||
|
* g.update(dt, elapsed);
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GHOST_GRADIENTS = {
|
||||||
|
Red: { top: '#F65151', bottom: '#FF2678' }, // GhostType_Angry
|
||||||
|
Yellow: { top: '#B57F0B', bottom: '#FFF35D' }, // GhostType_Crazy
|
||||||
|
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 = `
|
||||||
|
varying float vY;
|
||||||
|
varying vec3 vPos;
|
||||||
|
uniform float uTime;
|
||||||
|
void main() {
|
||||||
|
vec3 p = position;
|
||||||
|
// gentle billow so the wisp breathes
|
||||||
|
float w = sin(uTime * 1.6 + position.y * 3.0) * 0.04;
|
||||||
|
p.x += w * (0.5 + uv.y);
|
||||||
|
vY = uv.y;
|
||||||
|
vPos = position; // object-space, for radial falloff in frag
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WISP_FRAG = `
|
||||||
|
varying float vY;
|
||||||
|
varying vec3 vPos;
|
||||||
|
uniform vec3 uTop;
|
||||||
|
uniform vec3 uBottom;
|
||||||
|
uniform float uAlpha;
|
||||||
|
uniform float uTime;
|
||||||
|
void main() {
|
||||||
|
vec3 col = mix(uBottom, uTop, vY); // vertical gradient (head bright)
|
||||||
|
// breathing shimmer, never lets the body vanish
|
||||||
|
float shimmer = 0.78 + 0.22 * sin(uTime * 2.0 + vY * 6.2831);
|
||||||
|
float a = uAlpha * mix(0.55, 1.0, vY) * shimmer;
|
||||||
|
gl_FragColor = vec4(col, a);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shared wisp body (a tapered, double-sided plane-ish blob).
|
||||||
|
* Kept cheap: a subdivided cone/teardrop so the gradient + billow read well.
|
||||||
|
*/
|
||||||
|
function buildWispGeometry(THREE) {
|
||||||
|
// teardrop-ish body: rounded head (sphere-like top) tapering to a wispy tail.
|
||||||
|
// Lathe a profile curve so it reads as a ghost rather than a hard cone.
|
||||||
|
const pts = [];
|
||||||
|
const N = 12;
|
||||||
|
for (let i = 0; i <= N; i++) {
|
||||||
|
const t = i / N; // 0 = tail bottom, 1 = head top
|
||||||
|
const y = -0.6 + t * 1.2; // height -0.6 .. 0.6
|
||||||
|
// radius: thin pointed tail, bulging rounded head
|
||||||
|
const r = Math.sin(t * Math.PI * 0.92) * 0.42 + t * 0.06;
|
||||||
|
pts.push(new THREE.Vector2(Math.max(0.001, r), y));
|
||||||
|
}
|
||||||
|
const geo = new THREE.LatheGeometry(pts, 20);
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGhostVisual(THREE, opts = {}) {
|
||||||
|
const color = opts.color && GHOST_GRADIENTS[opts.color] ? opts.color : 'Blue';
|
||||||
|
const grad = GHOST_GRADIENTS[color];
|
||||||
|
const wantFx = opts.fx !== false;
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = `ghost-${color}`;
|
||||||
|
|
||||||
|
// --- 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 = {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uTop: { value: hexToRGB(THREE, grad.top) },
|
||||||
|
uBottom: { value: hexToRGB(THREE, grad.bottom) },
|
||||||
|
uAlpha: { value: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let body, mat;
|
||||||
|
if (SHARED_MESH) {
|
||||||
|
// clone the shared mesh geometry and give it the mesh gradient material
|
||||||
|
mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: MESH_VERT,
|
||||||
|
fragmentShader: MESH_FRAG,
|
||||||
|
uniforms: {
|
||||||
|
...uniforms,
|
||||||
|
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);
|
||||||
|
|
||||||
|
// --- always-on soft glow halo (procedural, no texture needed) so the ghost
|
||||||
|
// never looks like a bare cone even before FX textures attach ---
|
||||||
|
const haloMat = new THREE.SpriteMaterial({
|
||||||
|
color: hexToRGB(THREE, grad.bottom),
|
||||||
|
transparent: true, opacity: 0.35, depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
const halo = new THREE.Sprite(haloMat);
|
||||||
|
halo.scale.setScalar(1.5);
|
||||||
|
halo.position.y = 0.1;
|
||||||
|
group.add(halo);
|
||||||
|
|
||||||
|
// --- Tier 1: FX billboards (progressive enhancement) ---
|
||||||
|
const fxSprites = [];
|
||||||
|
function addFxBillboard(texture, scale, yOffset, opacity, blend) {
|
||||||
|
const m = new THREE.SpriteMaterial({
|
||||||
|
map: texture,
|
||||||
|
color: new THREE.Color(grad.top),
|
||||||
|
transparent: true,
|
||||||
|
opacity,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: blend ?? THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
const s = new THREE.Sprite(m);
|
||||||
|
s.scale.setScalar(scale);
|
||||||
|
s.position.y = yOffset;
|
||||||
|
group.add(s);
|
||||||
|
fxSprites.push(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// caller passes a loaded-texture map when fx enabled; see attachFx()
|
||||||
|
let fxAttached = false;
|
||||||
|
function attachFx(textures) {
|
||||||
|
if (!wantFx || fxAttached || !textures) return;
|
||||||
|
// give the procedural halo a real soft-glow texture if provided
|
||||||
|
if (textures.glow) { haloMat.map = textures.glow; haloMat.opacity = 0.55; haloMat.needsUpdate = true; }
|
||||||
|
if (textures.trail) addFxBillboard(textures.trail, 1.0, -0.45, 0.4);
|
||||||
|
if (textures.smoke) addFxBillboard(textures.smoke, 0.8, 0.3, 0.25);
|
||||||
|
fxAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- optional special mesh override (Ghost_Baseball / Ghost_Bat) ---
|
||||||
|
function useSpecialMesh(mesh) {
|
||||||
|
if (!mesh) return;
|
||||||
|
body.visible = false;
|
||||||
|
mesh.traverse?.(o => {
|
||||||
|
if (o.isMesh && o.material) {
|
||||||
|
o.material.color = new THREE.Color(grad.top);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- spawn / capture animation helpers ---
|
||||||
|
let state = 'idle';
|
||||||
|
let stateT = 0;
|
||||||
|
function setState(s) { state = s; stateT = 0; }
|
||||||
|
|
||||||
|
function update(dt, elapsed) {
|
||||||
|
uniforms.uTime.value = elapsed;
|
||||||
|
stateT += dt;
|
||||||
|
// bob: offset the BODY around the group, so the group's spawn position
|
||||||
|
// stays fixed (previously this accumulated onto group.y and the ghost
|
||||||
|
// drifted up out of view every frame).
|
||||||
|
body.position.y = Math.sin(elapsed * 1.8) * 0.08;
|
||||||
|
halo.position.y = 0.1 + Math.sin(elapsed * 1.8) * 0.08; // halo bobs with body
|
||||||
|
// billboard FX face camera handled by Sprite automatically
|
||||||
|
if (state === 'spawn') {
|
||||||
|
const k = Math.min(stateT / 0.6, 1);
|
||||||
|
group.scale.setScalar(k);
|
||||||
|
uniforms.uAlpha.value = k;
|
||||||
|
if (k >= 1) setState('idle');
|
||||||
|
} else if (state === 'capture') {
|
||||||
|
const k = Math.min(stateT / 0.5, 1);
|
||||||
|
group.scale.setScalar(1 - k);
|
||||||
|
uniforms.uAlpha.value = 1 - k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start hidden, play spawn
|
||||||
|
group.scale.setScalar(0);
|
||||||
|
setState('spawn');
|
||||||
|
|
||||||
|
return {
|
||||||
|
object3d: group,
|
||||||
|
color,
|
||||||
|
update,
|
||||||
|
setState, // 'idle' | 'spawn' | 'capture'
|
||||||
|
attachFx, // call with {glow, trail, smoke} THREE.Textures
|
||||||
|
useSpecialMesh, // call with a loaded Ghost_Baseball/Bat mesh
|
||||||
|
setColor(next) {
|
||||||
|
const g2 = GHOST_GRADIENTS[next]; if (!g2) return;
|
||||||
|
uniforms.uTop.value.set(g2.top);
|
||||||
|
uniforms.uBottom.value.set(g2.bottom);
|
||||||
|
haloMat.color.set(g2.bottom);
|
||||||
|
fxSprites.forEach(s => s.material.color.set(g2.top));
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
body.geometry?.dispose?.(); mat.dispose?.(); haloMat.dispose?.();
|
||||||
|
fxSprites.forEach(s => s.material?.map?.dispose?.());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,767 @@
|
|||||||
|
g Ghost_Bat
|
||||||
|
v 0.23093598 1.6888598 9.536743E-09
|
||||||
|
v 0.16428131 1.7850729 9.536743E-09
|
||||||
|
v 0.14227325 1.7850726 -0.08214095
|
||||||
|
v 0.19999602 1.6888598 -0.115468964
|
||||||
|
v 0.23055358 0.080041505 9.536743E-09
|
||||||
|
v 0.0821405 1.7850729 -0.14227232
|
||||||
|
v 0.19966583 0.080041505 -0.11527636
|
||||||
|
v 0.21243744 -0.14647491 9.536743E-09
|
||||||
|
v 0.11546661 1.6888598 -0.19999824
|
||||||
|
v -0 1.7850729 -0.16428192
|
||||||
|
v 0.18397796 -0.14647491 -0.10621962
|
||||||
|
v 0.14700073 -0.6485732 9.536743E-09
|
||||||
|
v 3.0517577E-07 1.6888598 -0.23093818
|
||||||
|
v -0.0821405 1.7850729 -0.14227232
|
||||||
|
v 0.11527374 0.080041505 -0.19966449
|
||||||
|
v 0.12730224 -0.6485732 -0.07349921
|
||||||
|
v 0.116545714 -1.1276284 9.536743E-09
|
||||||
|
v -0.115467526 1.6888598 -0.19999824
|
||||||
|
v -0.14227447 1.7850726 -0.08214095
|
||||||
|
v 0.106221005 -0.14647491 -0.18397777
|
||||||
|
v 3.0517577E-07 0.080041505 -0.2305527
|
||||||
|
v 0.100928344 -1.1276284 -0.058271825
|
||||||
|
v 0.138067 -1.6819524 9.536743E-09
|
||||||
|
v 0.11956604 -1.6819524 -0.06903267
|
||||||
|
v -0.19999634 1.6888598 -0.115468964
|
||||||
|
v -0.16428192 1.7850729 9.536743E-09
|
||||||
|
v -0.23093536 1.6888598 9.536743E-09
|
||||||
|
v 0.073499754 -0.64857316 -0.12730438
|
||||||
|
v 0.058272704 -1.1276284 -0.100929715
|
||||||
|
v 0.06903198 -1.6819524 -0.119568124
|
||||||
|
v 3.0517577E-07 -0.14647491 -0.21243922
|
||||||
|
v -0.11527435 0.080041505 -0.19966449
|
||||||
|
v -0.19966644 0.080041505 -0.11527636
|
||||||
|
v -0.23055267 0.080041505 9.536743E-09
|
||||||
|
v 3.0517577E-07 -1.1276284 -0.11654369
|
||||||
|
v 3.0517577E-07 -1.6819524 -0.13806534
|
||||||
|
v 3.0517577E-07 -0.6485732 -0.14699839
|
||||||
|
v -0.10622131 -0.14647491 -0.18397777
|
||||||
|
v -0.18397857 -0.14647491 -0.10621962
|
||||||
|
v -0.21243712 -0.14647491 9.536743E-09
|
||||||
|
v -0.073500365 -0.64857316 -0.12730438
|
||||||
|
v -0.058272704 -1.1276284 -0.100929715
|
||||||
|
v -0.06903137 -1.6819524 -0.119568124
|
||||||
|
v -0.12730254 -0.6485732 -0.07349921
|
||||||
|
v -0.14700073 -0.6485732 9.536743E-09
|
||||||
|
v -0.100928344 -1.1276284 -0.058271825
|
||||||
|
v -0.11956573 -1.6819524 -0.06903267
|
||||||
|
v -0.116545714 -1.1276284 9.536743E-09
|
||||||
|
v -0.13806671 -1.6819524 9.536743E-09
|
||||||
|
v -0.13806671 -1.6819524 9.536743E-09
|
||||||
|
v -0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v -0.20557372 -1.7254586 -0.11868828
|
||||||
|
v -0.11956573 -1.6819524 -0.06903267
|
||||||
|
v -0.1186911 -1.7254586 -0.20557404
|
||||||
|
v -0.06903137 -1.6819524 -0.119568124
|
||||||
|
v 3.0517577E-07 -1.7254586 -0.23737656
|
||||||
|
v 3.0517577E-07 -1.6819524 -0.13806534
|
||||||
|
v 0.11868805 -1.7254586 -0.20557404
|
||||||
|
v 0.06903198 -1.6819524 -0.119568124
|
||||||
|
v 0.20557372 -1.7254586 -0.118688464
|
||||||
|
v 0.11956604 -1.6819524 -0.06903267
|
||||||
|
v 0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v 0.138067 -1.6819524 9.536743E-09
|
||||||
|
v -0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v -0.23737335 -1.8253057 9.536743E-09
|
||||||
|
v -0.20557372 -1.8253057 -0.11868828
|
||||||
|
v -0.20557372 -1.7254586 -0.11868828
|
||||||
|
v -0.1186911 -1.8253057 -0.20557404
|
||||||
|
v -0.1186911 -1.7254586 -0.20557404
|
||||||
|
v 3.0517577E-07 -1.8253057 -0.23737656
|
||||||
|
v 3.0517577E-07 -1.7254586 -0.23737656
|
||||||
|
v 0.11868805 -1.8253057 -0.20557404
|
||||||
|
v 0.11868805 -1.7254586 -0.20557404
|
||||||
|
v 0.20557372 -1.8253057 -0.118688464
|
||||||
|
v 0.20557372 -1.7254586 -0.118688464
|
||||||
|
v 0.23737335 -1.8253057 9.536743E-09
|
||||||
|
v 0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v -0.14227447 1.7850726 -0.08214095
|
||||||
|
v 3.0517577E-07 1.825306 9.536743E-09
|
||||||
|
v -0.16428192 1.7850729 9.536743E-09
|
||||||
|
v -0.0821405 1.7850729 -0.14227232
|
||||||
|
v -0 1.7850729 -0.16428192
|
||||||
|
v 0.0821405 1.7850729 -0.14227232
|
||||||
|
v 0.14227325 1.7850726 -0.08214095
|
||||||
|
v 0.16428131 1.7850729 9.536743E-09
|
||||||
|
v 0.14227325 1.7850729 0.08214097
|
||||||
|
v 0.16428131 1.7850729 9.536743E-09
|
||||||
|
v 0.23093598 1.6888598 9.536743E-09
|
||||||
|
v 0.19999602 1.6888598 0.11546906
|
||||||
|
v 0.0821405 1.7850726 0.14227243
|
||||||
|
v 0.23055358 0.080041505 9.536743E-09
|
||||||
|
v 0.11546661 1.6888598 0.19999824
|
||||||
|
v -0 1.7850726 0.164282
|
||||||
|
v 0.19966583 0.080041505 0.115276344
|
||||||
|
v 0.21243744 -0.14647491 9.536743E-09
|
||||||
|
v 3.0517577E-07 1.6888598 0.2309381
|
||||||
|
v -0.0821405 1.7850726 0.14227243
|
||||||
|
v 0.18397796 -0.14647476 0.10621964
|
||||||
|
v 0.14700073 -0.6485732 9.536743E-09
|
||||||
|
v 0.11527374 0.080041505 0.19966446
|
||||||
|
v -0.115467526 1.6888598 0.19999824
|
||||||
|
v -0.14227447 1.7850729 0.08214097
|
||||||
|
v 0.12730224 -0.6485732 0.07349923
|
||||||
|
v 0.116545714 -1.1276284 9.536743E-09
|
||||||
|
v 0.106221005 -0.14647491 0.18397777
|
||||||
|
v 3.0517577E-07 0.080041505 0.23055267
|
||||||
|
v -0.19999634 1.6888598 0.11546906
|
||||||
|
v -0.16428192 1.7850729 9.536743E-09
|
||||||
|
v -0.23093536 1.6888598 9.536743E-09
|
||||||
|
v 0.100928344 -1.1276284 0.05827185
|
||||||
|
v 0.138067 -1.6819524 9.536743E-09
|
||||||
|
v 0.11956604 -1.6819524 0.069032684
|
||||||
|
v -0.11527435 0.080041505 0.19966446
|
||||||
|
v -0.19966644 0.080041505 0.115276344
|
||||||
|
v -0.23055267 0.080041505 9.536743E-09
|
||||||
|
v 3.0517577E-07 -0.14647491 0.21243927
|
||||||
|
v 0.073499754 -0.6485732 0.12730438
|
||||||
|
v 0.058272704 -1.1276284 0.10092972
|
||||||
|
v 0.06903198 -1.6819524 0.11956815
|
||||||
|
v -0.10622131 -0.14647491 0.18397777
|
||||||
|
v -0.18397857 -0.14647476 0.10621964
|
||||||
|
v -0.21243712 -0.14647491 9.536743E-09
|
||||||
|
v 3.0517577E-07 -0.6485732 0.14699844
|
||||||
|
v 3.0517577E-07 -1.1276283 0.11654372
|
||||||
|
v 3.0517577E-07 -1.6819524 0.13806537
|
||||||
|
v -0.073500365 -0.6485732 0.12730438
|
||||||
|
v -0.12730254 -0.6485732 0.07349923
|
||||||
|
v -0.14700073 -0.6485732 9.536743E-09
|
||||||
|
v -0.058272704 -1.1276284 0.10092972
|
||||||
|
v -0.06903137 -1.6819524 0.11956815
|
||||||
|
v -0.100928344 -1.1276284 0.05827185
|
||||||
|
v -0.116545714 -1.1276284 9.536743E-09
|
||||||
|
v -0.11956573 -1.6819524 0.069032684
|
||||||
|
v -0.13806671 -1.6819524 9.536743E-09
|
||||||
|
v -0.20557372 -1.7254586 0.11868829
|
||||||
|
v -0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v -0.13806671 -1.6819524 9.536743E-09
|
||||||
|
v -0.11956573 -1.6819524 0.069032684
|
||||||
|
v -0.1186911 -1.7254586 0.20557404
|
||||||
|
v -0.06903137 -1.6819524 0.11956815
|
||||||
|
v 3.0517577E-07 -1.7254586 0.23737656
|
||||||
|
v 3.0517577E-07 -1.6819524 0.13806537
|
||||||
|
v 0.11868805 -1.7254586 0.20557404
|
||||||
|
v 0.06903198 -1.6819524 0.11956815
|
||||||
|
v 0.20557372 -1.7254586 0.11868849
|
||||||
|
v 0.11956604 -1.6819524 0.069032684
|
||||||
|
v 0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v 0.138067 -1.6819524 9.536743E-09
|
||||||
|
v -0.20557372 -1.8253057 0.118688285
|
||||||
|
v -0.23737335 -1.8253057 9.536743E-09
|
||||||
|
v -0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v -0.20557372 -1.7254586 0.11868829
|
||||||
|
v -0.1186911 -1.8253057 0.20557404
|
||||||
|
v -0.1186911 -1.7254586 0.20557404
|
||||||
|
v 3.0517577E-07 -1.8253057 0.23737656
|
||||||
|
v 3.0517577E-07 -1.7254586 0.23737656
|
||||||
|
v 0.11868805 -1.8253057 0.20557404
|
||||||
|
v 0.11868805 -1.7254586 0.20557404
|
||||||
|
v 0.20557372 -1.8253057 0.118688494
|
||||||
|
v 0.20557372 -1.7254586 0.11868849
|
||||||
|
v 0.23737335 -1.8253057 9.536743E-09
|
||||||
|
v 0.23737335 -1.7254586 9.536743E-09
|
||||||
|
v 3.0517577E-07 1.825306 9.536743E-09
|
||||||
|
v -0.14227447 1.7850729 0.08214097
|
||||||
|
v -0.16428192 1.7850729 9.536743E-09
|
||||||
|
v -0.0821405 1.7850726 0.14227243
|
||||||
|
v -0 1.7850726 0.164282
|
||||||
|
v 0.0821405 1.7850726 0.14227243
|
||||||
|
v 0.14227325 1.7850729 0.08214097
|
||||||
|
v 0.16428131 1.7850729 9.536743E-09
|
||||||
|
v -9.155273E-07 -1.825306 9.536743E-09
|
||||||
|
v 0.20557372 -1.8253057 0.118688494
|
||||||
|
v 0.23737335 -1.8253057 9.536743E-09
|
||||||
|
v 0.20557372 -1.8253057 -0.118688464
|
||||||
|
v 0.11868805 -1.8253057 0.20557404
|
||||||
|
v 0.11868805 -1.8253057 -0.20557404
|
||||||
|
v 3.0517577E-07 -1.8253057 0.23737656
|
||||||
|
v 3.0517577E-07 -1.8253057 -0.23737656
|
||||||
|
v -0.1186911 -1.8253057 0.20557404
|
||||||
|
v -0.1186911 -1.8253057 -0.20557404
|
||||||
|
v -0.20557372 -1.8253057 0.118688285
|
||||||
|
v -0.20557372 -1.8253057 -0.11868828
|
||||||
|
v -0.23737335 -1.8253057 9.536743E-09
|
||||||
|
vt 0.033712 0.545644
|
||||||
|
vt 0.020241 0.536712
|
||||||
|
vt 0.020241 0.521679
|
||||||
|
vt 0.033712 0.526721
|
||||||
|
vt 0.292339 0.546247
|
||||||
|
vt 0.020241 0.505521
|
||||||
|
vt 0.292339 0.527201
|
||||||
|
vt 0.331103 0.541357
|
||||||
|
vt 0.033712 0.507875
|
||||||
|
vt 0.020241 0.489027
|
||||||
|
vt 0.331103 0.52395
|
||||||
|
vt 0.41101 0.518695
|
||||||
|
vt 0.033712 0.489051
|
||||||
|
vt 0.020241 0.472557
|
||||||
|
vt 0.292339 0.508151
|
||||||
|
vt 0.41101 0.508839
|
||||||
|
vt 0.48663 0.510622
|
||||||
|
vt 0.033712 0.470215
|
||||||
|
vt 0.020241 0.456432
|
||||||
|
vt 0.331103 0.506483
|
||||||
|
vt 0.292339 0.489101
|
||||||
|
vt 0.48663 0.503249
|
||||||
|
vt 0.577956 0.515712
|
||||||
|
vt 0.577956 0.506448
|
||||||
|
vt 0.033712 0.451342
|
||||||
|
vt 0.020241 0.440988
|
||||||
|
vt 0.033712 0.432426
|
||||||
|
vt 0.41101 0.498874
|
||||||
|
vt 0.48663 0.495866
|
||||||
|
vt 0.577956 0.49719
|
||||||
|
vt 0.331103 0.489005
|
||||||
|
vt 0.292339 0.47005
|
||||||
|
vt 0.292339 0.450989
|
||||||
|
vt 0.292339 0.431904
|
||||||
|
vt 0.48663 0.488476
|
||||||
|
vt 0.577956 0.487934
|
||||||
|
vt 0.41101 0.488858
|
||||||
|
vt 0.331103 0.471548
|
||||||
|
vt 0.331103 0.454152
|
||||||
|
vt 0.331103 0.436904
|
||||||
|
vt 0.41101 0.478845
|
||||||
|
vt 0.48663 0.481084
|
||||||
|
vt 0.577956 0.478676
|
||||||
|
vt 0.41101 0.468891
|
||||||
|
vt 0.41101 0.459045
|
||||||
|
vt 0.48663 0.473692
|
||||||
|
vt 0.577956 0.469409
|
||||||
|
vt 0.48663 0.466304
|
||||||
|
vt 0.577956 0.460131
|
||||||
|
vt 0.321383 0.559963
|
||||||
|
vt 0.32882 0.574769
|
||||||
|
vt 0.312915 0.574769
|
||||||
|
vt 0.309785 0.559963
|
||||||
|
vt 0.297487 0.574769
|
||||||
|
vt 0.296334 0.559963
|
||||||
|
vt 0.282349 0.574769
|
||||||
|
vt 0.282115 0.559963
|
||||||
|
vt 0.267131 0.574769
|
||||||
|
vt 0.267854 0.559963
|
||||||
|
vt 0.251542 0.574769
|
||||||
|
vt 0.254327 0.559962
|
||||||
|
vt 0.235757 0.574769
|
||||||
|
vt 0.243116 0.559963
|
||||||
|
vt 0.32882 0.574769
|
||||||
|
vt 0.32882 0.589843
|
||||||
|
vt 0.312671 0.589843
|
||||||
|
vt 0.312915 0.574769
|
||||||
|
vt 0.297284 0.589843
|
||||||
|
vt 0.297487 0.574769
|
||||||
|
vt 0.282726 0.589843
|
||||||
|
vt 0.282349 0.574769
|
||||||
|
vt 0.26791 0.589843
|
||||||
|
vt 0.267131 0.574769
|
||||||
|
vt 0.252166 0.589843
|
||||||
|
vt 0.251542 0.574769
|
||||||
|
vt 0.235757 0.589843
|
||||||
|
vt 0.235757 0.574769
|
||||||
|
vt 0.037127 0.260495
|
||||||
|
vt 0.011751 0.297588
|
||||||
|
vt 0.011751 0.253695
|
||||||
|
vt 0.05252 0.277233
|
||||||
|
vt 0.057481 0.299426
|
||||||
|
vt 0.050682 0.321125
|
||||||
|
vt 0.033943 0.336518
|
||||||
|
vt 0.011751 0.34148
|
||||||
|
vt 0.020241 0.521679
|
||||||
|
vt 0.020241 0.536712
|
||||||
|
vt 0.033712 0.545644
|
||||||
|
vt 0.033712 0.526721
|
||||||
|
vt 0.020241 0.505521
|
||||||
|
vt 0.292339 0.546247
|
||||||
|
vt 0.033712 0.507875
|
||||||
|
vt 0.020241 0.489027
|
||||||
|
vt 0.292339 0.527201
|
||||||
|
vt 0.331103 0.541357
|
||||||
|
vt 0.033712 0.489051
|
||||||
|
vt 0.020241 0.472557
|
||||||
|
vt 0.331103 0.52395
|
||||||
|
vt 0.41101 0.518695
|
||||||
|
vt 0.292339 0.508151
|
||||||
|
vt 0.033712 0.470215
|
||||||
|
vt 0.020241 0.456432
|
||||||
|
vt 0.41101 0.508839
|
||||||
|
vt 0.48663 0.510622
|
||||||
|
vt 0.331103 0.506483
|
||||||
|
vt 0.292339 0.489101
|
||||||
|
vt 0.033712 0.451342
|
||||||
|
vt 0.020241 0.440988
|
||||||
|
vt 0.033712 0.432426
|
||||||
|
vt 0.48663 0.503249
|
||||||
|
vt 0.577956 0.515712
|
||||||
|
vt 0.577956 0.506448
|
||||||
|
vt 0.292339 0.47005
|
||||||
|
vt 0.292339 0.450989
|
||||||
|
vt 0.292339 0.431904
|
||||||
|
vt 0.331103 0.489005
|
||||||
|
vt 0.41101 0.498874
|
||||||
|
vt 0.48663 0.495866
|
||||||
|
vt 0.577956 0.49719
|
||||||
|
vt 0.331103 0.471548
|
||||||
|
vt 0.331103 0.454152
|
||||||
|
vt 0.331103 0.436904
|
||||||
|
vt 0.41101 0.488858
|
||||||
|
vt 0.48663 0.488476
|
||||||
|
vt 0.577956 0.487934
|
||||||
|
vt 0.41101 0.478845
|
||||||
|
vt 0.41101 0.468891
|
||||||
|
vt 0.41101 0.459045
|
||||||
|
vt 0.48663 0.481084
|
||||||
|
vt 0.577956 0.478676
|
||||||
|
vt 0.48663 0.473692
|
||||||
|
vt 0.48663 0.466304
|
||||||
|
vt 0.577956 0.469409
|
||||||
|
vt 0.577956 0.460131
|
||||||
|
vt 0.312913 0.574766
|
||||||
|
vt 0.328818 0.574766
|
||||||
|
vt 0.321381 0.559959
|
||||||
|
vt 0.309783 0.559959
|
||||||
|
vt 0.297486 0.574766
|
||||||
|
vt 0.296332 0.559959
|
||||||
|
vt 0.282347 0.574766
|
||||||
|
vt 0.282113 0.559959
|
||||||
|
vt 0.267129 0.574766
|
||||||
|
vt 0.267852 0.559959
|
||||||
|
vt 0.25154 0.574766
|
||||||
|
vt 0.254325 0.559959
|
||||||
|
vt 0.235755 0.574766
|
||||||
|
vt 0.243114 0.559959
|
||||||
|
vt 0.312669 0.589839
|
||||||
|
vt 0.328818 0.589839
|
||||||
|
vt 0.328818 0.574766
|
||||||
|
vt 0.312913 0.574766
|
||||||
|
vt 0.297282 0.589839
|
||||||
|
vt 0.297486 0.574766
|
||||||
|
vt 0.282724 0.589839
|
||||||
|
vt 0.282347 0.574766
|
||||||
|
vt 0.267908 0.589839
|
||||||
|
vt 0.267129 0.574766
|
||||||
|
vt 0.252164 0.58984
|
||||||
|
vt 0.25154 0.574766
|
||||||
|
vt 0.235755 0.589839
|
||||||
|
vt 0.235755 0.574766
|
||||||
|
vt 0.011751 0.297588
|
||||||
|
vt 0.037127 0.260495
|
||||||
|
vt 0.011751 0.253695
|
||||||
|
vt 0.05252 0.277233
|
||||||
|
vt 0.057481 0.299426
|
||||||
|
vt 0.050682 0.321125
|
||||||
|
vt 0.033943 0.336518
|
||||||
|
vt 0.011751 0.34148
|
||||||
|
vt 0.649191 0.419149
|
||||||
|
vt 0.665469 0.390954
|
||||||
|
vt 0.649191 0.386592
|
||||||
|
vt 0.632912 0.390954
|
||||||
|
vt 0.677386 0.402871
|
||||||
|
vt 0.620995 0.402871
|
||||||
|
vt 0.681748 0.419149
|
||||||
|
vt 0.616634 0.419149
|
||||||
|
vt 0.677386 0.435428
|
||||||
|
vt 0.620995 0.435428
|
||||||
|
vt 0.665469 0.447344
|
||||||
|
vt 0.632912 0.447344
|
||||||
|
vt 0.649191 0.451706
|
||||||
|
vn 0.9585068 0.28506985 0
|
||||||
|
vn 0.6041495 0.79687095 0
|
||||||
|
vn 0.5232121 0.79686904 -0.30207404
|
||||||
|
vn 0.8300891 0.28507143 -0.479256
|
||||||
|
vn 0.99920964 -0.0397502 0
|
||||||
|
vn 0.30207363 0.79687333 -0.52320594
|
||||||
|
vn 0.8653401 -0.039747465 -0.49960646
|
||||||
|
vn 0.9945681 -0.10408779 0
|
||||||
|
vn 0.4792509 0.2850737 -0.83009124
|
||||||
|
vn 4.3355183E-07 0.79687506 -0.6041442
|
||||||
|
vn 0.8613218 -0.10408532 -0.4972835
|
||||||
|
vn 0.99529195 -0.09692217 0
|
||||||
|
vn 1.2513581E-06 0.2850765 -0.95850474
|
||||||
|
vn -0.30207178 0.7968739 -0.5232061
|
||||||
|
vn 0.49960172 -0.039745323 -0.865343
|
||||||
|
vn 0.8619444 -0.09692138 -0.4976526
|
||||||
|
vn 0.9999225 -0.012446101 0
|
||||||
|
vn -0.47925013 0.28507364 -0.8300918
|
||||||
|
vn -0.5232138 0.7968682 -0.30207342
|
||||||
|
vn 0.49728736 -0.1040834 -0.8613199
|
||||||
|
vn 1.2316204E-06 -0.03974624 -0.99920976
|
||||||
|
vn 0.86595005 -0.012445819 -0.49997568
|
||||||
|
vn 0.9992472 0.038795333 0
|
||||||
|
vn 0.86536473 0.038794216 -0.4996387
|
||||||
|
vn -0.83009106 0.28507048 -0.47925323
|
||||||
|
vn -0.6041511 0.79686975 0
|
||||||
|
vn -0.95850754 0.2850673 0
|
||||||
|
vn 0.49765417 -0.09692319 -0.8619433
|
||||||
|
vn 0.49997118 -0.012445979 -0.86595255
|
||||||
|
vn 0.49963182 0.03879506 -0.86536866
|
||||||
|
vn 1.2029865E-06 -0.10408348 -0.9945686
|
||||||
|
vn -0.49960047 -0.03974562 -0.8653437
|
||||||
|
vn -0.86534244 -0.039747626 -0.49960238
|
||||||
|
vn -0.99920964 -0.039749898 0
|
||||||
|
vn 1.1310963E-06 -0.012445888 -0.9999225
|
||||||
|
vn 5.5348755E-07 0.03879548 -0.9992472
|
||||||
|
vn 1.514343E-06 -0.09692247 -0.99529195
|
||||||
|
vn -0.4972856 -0.10408365 -0.86132085
|
||||||
|
vn -0.8613239 -0.104085565 -0.49727997
|
||||||
|
vn -0.9945682 -0.10408735 0
|
||||||
|
vn -0.49765322 -0.09692343 -0.86194384
|
||||||
|
vn -0.4999705 -0.012446378 -0.865953
|
||||||
|
vn -0.49963075 0.038794797 -0.8653693
|
||||||
|
vn -0.86194605 -0.09692179 -0.4976497
|
||||||
|
vn -0.99529195 -0.096922286 0
|
||||||
|
vn -0.8659506 -0.012446293 -0.49997464
|
||||||
|
vn -0.8653645 0.03879403 -0.4996392
|
||||||
|
vn -0.9999225 -0.012446395 0
|
||||||
|
vn -0.9992472 0.038795333 0
|
||||||
|
vn -0.40126997 0.9159598 0
|
||||||
|
vn -0.40126997 0.9159598 0
|
||||||
|
vn -0.34750712 0.915962 -0.20063005
|
||||||
|
vn -0.3475068 0.915962 -0.20063078
|
||||||
|
vn -0.20063345 0.91596407 -0.3474998
|
||||||
|
vn -0.2006302 0.91596407 -0.34750158
|
||||||
|
vn 8.6672554E-07 0.9159622 -0.40126446
|
||||||
|
vn 1.1384262E-06 0.9159622 -0.4012645
|
||||||
|
vn 0.20063318 0.9159625 -0.34750405
|
||||||
|
vn 0.2006331 0.9159625 -0.34750408
|
||||||
|
vn 0.34750792 0.91596097 -0.20063308
|
||||||
|
vn 0.3475075 0.91596097 -0.20063396
|
||||||
|
vn 0.4012704 0.9159596 0
|
||||||
|
vn 0.4012704 0.9159596 0
|
||||||
|
vn -1 0 0
|
||||||
|
vn -1 0 0
|
||||||
|
vn -0.8660355 0 -0.49998257
|
||||||
|
vn -0.8660355 0 -0.49998257
|
||||||
|
vn -0.5000049 0 -0.86602265
|
||||||
|
vn -0.5000049 0 -0.86602265
|
||||||
|
vn 3.6730592E-06 0 -1
|
||||||
|
vn 3.6730592E-06 0 -1
|
||||||
|
vn 0.5000007 0 -0.866025
|
||||||
|
vn 0.5000007 0 -0.866025
|
||||||
|
vn 0.86603063 0 -0.49999094
|
||||||
|
vn 0.86603063 0 -0.49999094
|
||||||
|
vn 1 0 0
|
||||||
|
vn 1 0 0
|
||||||
|
vn -0.5232138 0.7968682 -0.30207342
|
||||||
|
vn 9.2884443E-07 1 0
|
||||||
|
vn -0.6041511 0.79686975 0
|
||||||
|
vn -0.30207178 0.7968739 -0.5232061
|
||||||
|
vn 4.3355183E-07 0.79687506 -0.6041442
|
||||||
|
vn 0.30207363 0.79687333 -0.52320594
|
||||||
|
vn 0.5232121 0.79686904 -0.30207404
|
||||||
|
vn 0.6041495 0.79687095 0
|
||||||
|
vn 0.52321213 0.79686916 0.30207407
|
||||||
|
vn 0.6041495 0.79687095 0
|
||||||
|
vn 0.9585068 0.28506985 0
|
||||||
|
vn 0.8300891 0.28507143 0.47925606
|
||||||
|
vn 0.3020736 0.7968734 0.52320594
|
||||||
|
vn 0.99920964 -0.0397502 0
|
||||||
|
vn 0.4792509 0.28507373 0.8300913
|
||||||
|
vn 4.3222195E-07 0.79687506 0.6041442
|
||||||
|
vn 0.8653401 -0.03974747 0.49960646
|
||||||
|
vn 0.9945681 -0.10408779 0
|
||||||
|
vn 1.2406169E-06 0.2850765 0.95850474
|
||||||
|
vn -0.30207178 0.7968739 0.5232061
|
||||||
|
vn 0.8613218 -0.10408533 0.4972835
|
||||||
|
vn 0.99529195 -0.09692217 0
|
||||||
|
vn 0.49960172 -0.039745323 0.865343
|
||||||
|
vn -0.47925013 0.28507364 0.8300918
|
||||||
|
vn -0.5232138 0.7968682 0.30207345
|
||||||
|
vn 0.8619444 -0.09692139 0.49765265
|
||||||
|
vn 0.9999225 -0.012446101 0
|
||||||
|
vn 0.49728736 -0.1040834 0.8613199
|
||||||
|
vn 1.2217279E-06 -0.03974624 0.99920976
|
||||||
|
vn -0.83009106 0.28507048 0.47925323
|
||||||
|
vn -0.6041511 0.79686975 0
|
||||||
|
vn -0.95850754 0.2850673 0
|
||||||
|
vn 0.86595005 -0.012445819 0.49997568
|
||||||
|
vn 0.9992472 0.038795333 0
|
||||||
|
vn 0.8653647 0.038794212 0.49963874
|
||||||
|
vn -0.49960047 -0.03974562 0.8653437
|
||||||
|
vn -0.86534244 -0.039747626 0.49960238
|
||||||
|
vn -0.99920964 -0.039749898 0
|
||||||
|
vn 1.2029865E-06 -0.10408348 0.9945686
|
||||||
|
vn 0.49765417 -0.09692319 0.8619433
|
||||||
|
vn 0.49997118 -0.012445979 0.86595255
|
||||||
|
vn 0.49963182 0.03879506 0.86536866
|
||||||
|
vn -0.4972856 -0.10408365 0.86132085
|
||||||
|
vn -0.8613239 -0.104085565 0.49727997
|
||||||
|
vn -0.9945682 -0.10408735 0
|
||||||
|
vn 1.5045731E-06 -0.09692247 0.99529195
|
||||||
|
vn 1.1213455E-06 -0.012445888 0.9999225
|
||||||
|
vn 5.5348755E-07 0.03879548 0.9992472
|
||||||
|
vn -0.49765322 -0.09692343 0.86194384
|
||||||
|
vn -0.86194605 -0.09692179 0.4976497
|
||||||
|
vn -0.99529195 -0.096922286 0
|
||||||
|
vn -0.4999705 -0.0124463765 0.865953
|
||||||
|
vn -0.49963075 0.038794797 0.8653693
|
||||||
|
vn -0.8659506 -0.012446293 0.4999747
|
||||||
|
vn -0.9999225 -0.012446395 0
|
||||||
|
vn -0.8653645 0.038794033 0.4996392
|
||||||
|
vn -0.9992472 0.038795333 0
|
||||||
|
vn -0.34750712 0.915962 0.20063005
|
||||||
|
vn -0.40126997 0.9159598 0
|
||||||
|
vn -0.40126997 0.9159598 0
|
||||||
|
vn -0.3475068 0.915962 0.20063078
|
||||||
|
vn -0.20063345 0.91596407 0.3474998
|
||||||
|
vn -0.2006302 0.91596407 0.34750158
|
||||||
|
vn 8.7235355E-07 0.9159622 0.4012645
|
||||||
|
vn 1.1425659E-06 0.9159622 0.4012645
|
||||||
|
vn 0.20063315 0.9159625 0.34750405
|
||||||
|
vn 0.2006331 0.9159625 0.34750414
|
||||||
|
vn 0.34750792 0.91596097 0.20063308
|
||||||
|
vn 0.34750748 0.9159611 0.20063396
|
||||||
|
vn 0.4012704 0.9159596 0
|
||||||
|
vn 0.4012704 0.9159596 0
|
||||||
|
vn -0.8660355 0 0.49998257
|
||||||
|
vn -1 0 0
|
||||||
|
vn -1 0 0
|
||||||
|
vn -0.8660355 0 0.49998257
|
||||||
|
vn -0.5000049 0 0.86602265
|
||||||
|
vn -0.5000049 0 0.86602265
|
||||||
|
vn 3.6730592E-06 0 1
|
||||||
|
vn 3.6730592E-06 0 1
|
||||||
|
vn 0.5000007 0 0.866025
|
||||||
|
vn 0.5000007 0 0.866025
|
||||||
|
vn 0.86603063 0 0.49999094
|
||||||
|
vn 0.86603063 0 0.49999094
|
||||||
|
vn 1 0 0
|
||||||
|
vn 1 0 0
|
||||||
|
vn 9.2884443E-07 1 0
|
||||||
|
vn -0.5232138 0.7968682 0.30207345
|
||||||
|
vn -0.6041511 0.79686975 0
|
||||||
|
vn -0.30207178 0.7968739 0.5232061
|
||||||
|
vn 4.3222195E-07 0.79687506 0.6041442
|
||||||
|
vn 0.3020736 0.7968734 0.52320594
|
||||||
|
vn 0.52321213 0.79686916 0.30207407
|
||||||
|
vn 0.6041495 0.79687095 0
|
||||||
|
vn -0 -1 0
|
||||||
|
vn 1.2372466E-06 -1 5.2292353E-07
|
||||||
|
vn 1.4286578E-06 -1 0
|
||||||
|
vn 1.2372466E-06 -1 -5.2292353E-07
|
||||||
|
vn 5.2291966E-07 -1 1.2372409E-06
|
||||||
|
vn 5.2291966E-07 -1 -1.2372409E-06
|
||||||
|
vn -0 -1 1.4286439E-06
|
||||||
|
vn -0 -1 -1.4286439E-06
|
||||||
|
vn -6.1863193E-07 -1 1.0714709E-06
|
||||||
|
vn -6.1863193E-07 -1 -1.0714709E-06
|
||||||
|
vn -1.2372628E-06 -1 7.142983E-07
|
||||||
|
vn -1.2372628E-06 -1 -7.142983E-07
|
||||||
|
vn -1.2372628E-06 -1 0
|
||||||
|
g Ghost_Bat_0
|
||||||
|
f 3/3/3 2/2/2 1/1/1
|
||||||
|
f 4/4/4 3/3/3 1/1/1
|
||||||
|
f 4/4/4 1/1/1 5/5/5
|
||||||
|
f 6/6/6 3/3/3 4/4/4
|
||||||
|
f 7/7/7 4/4/4 5/5/5
|
||||||
|
f 7/7/7 5/5/5 8/8/8
|
||||||
|
f 9/9/9 6/6/6 4/4/4
|
||||||
|
f 9/9/9 4/4/4 7/7/7
|
||||||
|
f 10/10/10 6/6/6 9/9/9
|
||||||
|
f 11/11/11 7/7/7 8/8/8
|
||||||
|
f 11/11/11 8/8/8 12/12/12
|
||||||
|
f 13/13/13 10/10/10 9/9/9
|
||||||
|
f 14/14/14 10/10/10 13/13/13
|
||||||
|
f 15/15/15 9/9/9 7/7/7
|
||||||
|
f 15/15/15 7/7/7 11/11/11
|
||||||
|
f 13/13/13 9/9/9 15/15/15
|
||||||
|
f 16/16/16 11/11/11 12/12/12
|
||||||
|
f 16/16/16 12/12/12 17/17/17
|
||||||
|
f 18/18/18 14/14/14 13/13/13
|
||||||
|
f 19/19/19 14/14/14 18/18/18
|
||||||
|
f 20/20/20 15/15/15 11/11/11
|
||||||
|
f 20/20/20 11/11/11 16/16/16
|
||||||
|
f 21/21/21 13/13/13 15/15/15
|
||||||
|
f 18/18/18 13/13/13 21/21/21
|
||||||
|
f 21/21/21 15/15/15 20/20/20
|
||||||
|
f 22/22/22 16/16/16 17/17/17
|
||||||
|
f 22/22/22 17/17/17 23/23/23
|
||||||
|
f 24/24/24 22/22/22 23/23/23
|
||||||
|
f 25/25/25 19/19/19 18/18/18
|
||||||
|
f 26/26/26 19/19/19 25/25/25
|
||||||
|
f 27/27/27 26/26/26 25/25/25
|
||||||
|
f 28/28/28 20/20/20 16/16/16
|
||||||
|
f 28/28/28 16/16/16 22/22/22
|
||||||
|
f 29/29/29 22/22/22 24/24/24
|
||||||
|
f 29/29/29 28/28/28 22/22/22
|
||||||
|
f 30/30/30 29/29/29 24/24/24
|
||||||
|
f 31/31/31 21/21/21 20/20/20
|
||||||
|
f 31/31/31 20/20/20 28/28/28
|
||||||
|
f 32/32/32 18/18/18 21/21/21
|
||||||
|
f 25/25/25 18/18/18 32/32/32
|
||||||
|
f 32/32/32 21/21/21 31/31/31
|
||||||
|
f 27/27/27 25/25/25 33/33/33
|
||||||
|
f 33/33/33 25/25/25 32/32/32
|
||||||
|
f 34/34/34 27/27/27 33/33/33
|
||||||
|
f 35/35/35 29/29/29 30/30/30
|
||||||
|
f 36/36/36 35/35/35 30/30/30
|
||||||
|
f 37/37/37 28/28/28 29/29/29
|
||||||
|
f 37/37/37 31/31/31 28/28/28
|
||||||
|
f 35/35/35 37/37/37 29/29/29
|
||||||
|
f 38/38/38 32/32/32 31/31/31
|
||||||
|
f 33/33/33 32/32/32 38/38/38
|
||||||
|
f 38/38/38 31/31/31 37/37/37
|
||||||
|
f 34/34/34 33/33/33 39/39/39
|
||||||
|
f 39/39/39 33/33/33 38/38/38
|
||||||
|
f 40/40/40 34/34/34 39/39/39
|
||||||
|
f 41/41/41 38/38/38 37/37/37
|
||||||
|
f 41/41/41 37/37/37 35/35/35
|
||||||
|
f 39/39/39 38/38/38 41/41/41
|
||||||
|
f 42/42/42 35/35/35 36/36/36
|
||||||
|
f 42/42/42 41/41/41 35/35/35
|
||||||
|
f 43/43/43 42/42/42 36/36/36
|
||||||
|
f 40/40/40 39/39/39 44/44/44
|
||||||
|
f 44/44/44 39/39/39 41/41/41
|
||||||
|
f 44/44/44 41/41/41 42/42/42
|
||||||
|
f 45/45/45 40/40/40 44/44/44
|
||||||
|
f 46/46/46 42/42/42 43/43/43
|
||||||
|
f 46/46/46 44/44/44 42/42/42
|
||||||
|
f 45/45/45 44/44/44 46/46/46
|
||||||
|
f 47/47/47 46/46/46 43/43/43
|
||||||
|
f 48/48/48 45/45/45 46/46/46
|
||||||
|
f 48/48/48 46/46/46 47/47/47
|
||||||
|
f 49/49/49 48/48/48 47/47/47
|
||||||
|
f 52/52/52 51/51/51 50/50/50
|
||||||
|
f 53/53/53 52/52/52 50/50/50
|
||||||
|
f 54/54/54 52/52/52 53/53/53
|
||||||
|
f 55/55/55 54/54/54 53/53/53
|
||||||
|
f 56/56/56 54/54/54 55/55/55
|
||||||
|
f 57/57/57 56/56/56 55/55/55
|
||||||
|
f 58/58/58 56/56/56 57/57/57
|
||||||
|
f 59/59/59 58/58/58 57/57/57
|
||||||
|
f 60/60/60 58/58/58 59/59/59
|
||||||
|
f 61/61/61 60/60/60 59/59/59
|
||||||
|
f 62/62/62 60/60/60 61/61/61
|
||||||
|
f 63/63/63 62/62/62 61/61/61
|
||||||
|
f 66/66/66 65/65/65 64/64/64
|
||||||
|
f 67/67/67 66/66/66 64/64/64
|
||||||
|
f 68/68/68 66/66/66 67/67/67
|
||||||
|
f 69/69/69 68/68/68 67/67/67
|
||||||
|
f 70/70/70 68/68/68 69/69/69
|
||||||
|
f 71/71/71 70/70/70 69/69/69
|
||||||
|
f 72/72/72 70/70/70 71/71/71
|
||||||
|
f 73/73/73 72/72/72 71/71/71
|
||||||
|
f 74/74/74 72/72/72 73/73/73
|
||||||
|
f 75/75/75 74/74/74 73/73/73
|
||||||
|
f 76/76/76 74/74/74 75/75/75
|
||||||
|
f 77/77/77 76/76/76 75/75/75
|
||||||
|
f 80/80/80 79/79/79 78/78/78
|
||||||
|
f 79/79/79 81/81/81 78/78/78
|
||||||
|
f 81/81/81 79/79/79 82/82/82
|
||||||
|
f 79/79/79 83/83/83 82/82/82
|
||||||
|
f 83/83/83 79/79/79 84/84/84
|
||||||
|
f 79/79/79 85/85/85 84/84/84
|
||||||
|
f 88/88/88 87/87/87 86/86/86
|
||||||
|
f 89/89/89 88/88/88 86/86/86
|
||||||
|
f 89/89/89 86/86/86 90/90/90
|
||||||
|
f 91/91/91 88/88/88 89/89/89
|
||||||
|
f 92/92/92 89/89/89 90/90/90
|
||||||
|
f 92/92/92 90/90/90 93/93/93
|
||||||
|
f 94/94/94 91/91/91 89/89/89
|
||||||
|
f 94/94/94 89/89/89 92/92/92
|
||||||
|
f 95/95/95 91/91/91 94/94/94
|
||||||
|
f 96/96/96 92/92/92 93/93/93
|
||||||
|
f 96/96/96 93/93/93 97/97/97
|
||||||
|
f 98/98/98 95/95/95 94/94/94
|
||||||
|
f 99/99/99 95/95/95 98/98/98
|
||||||
|
f 100/100/100 94/94/94 92/92/92
|
||||||
|
f 100/100/100 92/92/92 96/96/96
|
||||||
|
f 98/98/98 94/94/94 100/100/100
|
||||||
|
f 101/101/101 96/96/96 97/97/97
|
||||||
|
f 101/101/101 97/97/97 102/102/102
|
||||||
|
f 103/103/103 99/99/99 98/98/98
|
||||||
|
f 104/104/104 99/99/99 103/103/103
|
||||||
|
f 105/105/105 98/98/98 100/100/100
|
||||||
|
f 103/103/103 98/98/98 105/105/105
|
||||||
|
f 106/106/106 100/100/100 96/96/96
|
||||||
|
f 106/106/106 96/96/96 101/101/101
|
||||||
|
f 105/105/105 100/100/100 106/106/106
|
||||||
|
f 107/107/107 101/101/101 102/102/102
|
||||||
|
f 107/107/107 102/102/102 108/108/108
|
||||||
|
f 109/109/109 107/107/107 108/108/108
|
||||||
|
f 110/110/110 104/104/104 103/103/103
|
||||||
|
f 111/111/111 104/104/104 110/110/110
|
||||||
|
f 112/112/112 111/111/111 110/110/110
|
||||||
|
f 113/113/113 106/106/106 101/101/101
|
||||||
|
f 113/113/113 101/101/101 107/107/107
|
||||||
|
f 114/114/114 107/107/107 109/109/109
|
||||||
|
f 114/114/114 113/113/113 107/107/107
|
||||||
|
f 115/115/115 114/114/114 109/109/109
|
||||||
|
f 116/116/116 105/105/105 106/106/106
|
||||||
|
f 116/116/116 106/106/106 113/113/113
|
||||||
|
f 117/117/117 103/103/103 105/105/105
|
||||||
|
f 110/110/110 103/103/103 117/117/117
|
||||||
|
f 117/117/117 105/105/105 116/116/116
|
||||||
|
f 112/112/112 110/110/110 118/118/118
|
||||||
|
f 118/118/118 110/110/110 117/117/117
|
||||||
|
f 119/119/119 112/112/112 118/118/118
|
||||||
|
f 120/120/120 113/113/113 114/114/114
|
||||||
|
f 120/120/120 116/116/116 113/113/113
|
||||||
|
f 121/121/121 114/114/114 115/115/115
|
||||||
|
f 121/121/121 120/120/120 114/114/114
|
||||||
|
f 122/122/122 121/121/121 115/115/115
|
||||||
|
f 123/123/123 117/117/117 116/116/116
|
||||||
|
f 118/118/118 117/117/117 123/123/123
|
||||||
|
f 123/123/123 116/116/116 120/120/120
|
||||||
|
f 119/119/119 118/118/118 124/124/124
|
||||||
|
f 124/124/124 118/118/118 123/123/123
|
||||||
|
f 125/125/125 119/119/119 124/124/124
|
||||||
|
f 126/126/126 123/123/123 120/120/120
|
||||||
|
f 126/126/126 120/120/120 121/121/121
|
||||||
|
f 124/124/124 123/123/123 126/126/126
|
||||||
|
f 127/127/127 121/121/121 122/122/122
|
||||||
|
f 127/127/127 126/126/126 121/121/121
|
||||||
|
f 128/128/128 127/127/127 122/122/122
|
||||||
|
f 125/125/125 124/124/124 129/129/129
|
||||||
|
f 129/129/129 124/124/124 126/126/126
|
||||||
|
f 129/129/129 126/126/126 127/127/127
|
||||||
|
f 130/130/130 125/125/125 129/129/129
|
||||||
|
f 131/131/131 127/127/127 128/128/128
|
||||||
|
f 131/131/131 129/129/129 127/127/127
|
||||||
|
f 130/130/130 129/129/129 131/131/131
|
||||||
|
f 132/132/132 131/131/131 128/128/128
|
||||||
|
f 133/133/133 130/130/130 131/131/131
|
||||||
|
f 133/133/133 131/131/131 132/132/132
|
||||||
|
f 134/134/134 133/133/133 132/132/132
|
||||||
|
f 137/137/137 136/136/136 135/135/135
|
||||||
|
f 138/138/138 137/137/137 135/135/135
|
||||||
|
f 138/138/138 135/135/135 139/139/139
|
||||||
|
f 140/140/140 138/138/138 139/139/139
|
||||||
|
f 140/140/140 139/139/139 141/141/141
|
||||||
|
f 142/142/142 140/140/140 141/141/141
|
||||||
|
f 142/142/142 141/141/141 143/143/143
|
||||||
|
f 144/144/144 142/142/142 143/143/143
|
||||||
|
f 144/144/144 143/143/143 145/145/145
|
||||||
|
f 146/146/146 144/144/144 145/145/145
|
||||||
|
f 146/146/146 145/145/145 147/147/147
|
||||||
|
f 148/148/148 146/146/146 147/147/147
|
||||||
|
f 151/151/151 150/150/150 149/149/149
|
||||||
|
f 152/152/152 151/151/151 149/149/149
|
||||||
|
f 152/152/152 149/149/149 153/153/153
|
||||||
|
f 154/154/154 152/152/152 153/153/153
|
||||||
|
f 154/154/154 153/153/153 155/155/155
|
||||||
|
f 156/156/156 154/154/154 155/155/155
|
||||||
|
f 156/156/156 155/155/155 157/157/157
|
||||||
|
f 158/158/158 156/156/156 157/157/157
|
||||||
|
f 158/158/158 157/157/157 159/159/159
|
||||||
|
f 160/160/160 158/158/158 159/159/159
|
||||||
|
f 160/160/160 159/159/159 161/161/161
|
||||||
|
f 162/162/162 160/160/160 161/161/161
|
||||||
|
f 165/165/165 164/164/164 163/163/163
|
||||||
|
f 164/164/164 166/166/166 163/163/163
|
||||||
|
f 166/166/166 167/167/167 163/163/163
|
||||||
|
f 167/167/167 168/168/168 163/163/163
|
||||||
|
f 168/168/168 169/169/169 163/163/163
|
||||||
|
f 169/169/169 170/170/170 163/163/163
|
||||||
|
f 173/173/173 172/172/172 171/171/171
|
||||||
|
f 174/174/174 173/173/173 171/171/171
|
||||||
|
f 175/175/175 171/171/171 172/172/172
|
||||||
|
f 171/171/171 176/176/176 174/174/174
|
||||||
|
f 171/171/171 175/175/175 177/177/177
|
||||||
|
f 178/178/178 176/176/176 171/171/171
|
||||||
|
f 179/179/179 171/171/171 177/177/177
|
||||||
|
f 180/180/180 178/178/178 171/171/171
|
||||||
|
f 171/171/171 179/179/179 181/181/181
|
||||||
|
f 182/182/182 180/180/180 171/171/171
|
||||||
|
f 183/183/183 171/171/171 181/181/181
|
||||||
|
f 183/183/183 182/182/182 171/171/171
|
||||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,621 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<title>Newbury Nights - Hunt Demo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #0a0c14;
|
||||||
|
--ink-2: #11131f;
|
||||||
|
--mist: #c7ccd9;
|
||||||
|
--mist-dim: #6b7184;
|
||||||
|
--red: #FF2678;
|
||||||
|
--red-top: #F65151;
|
||||||
|
--yellow: #FFF35D;
|
||||||
|
--yellow-top: #B57F0B;
|
||||||
|
--blue: #51EAF1;
|
||||||
|
--blue-top: #529EFF;
|
||||||
|
--haze: rgba(199,204,217,0.08);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; height: 100%; overflow: hidden;
|
||||||
|
background: var(--ink); color: var(--mist);
|
||||||
|
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#scene, #cam { position: fixed; inset: 0; width: 100%; height: 100%; }
|
||||||
|
#cam { object-fit: cover; z-index: 0; background: #000; }
|
||||||
|
#scene { z-index: 1; }
|
||||||
|
canvas { display: block; }
|
||||||
|
|
||||||
|
#hud { position: fixed; inset: 0; z-index: 3; pointer-events: none; }
|
||||||
|
.pointer { pointer-events: auto; }
|
||||||
|
|
||||||
|
#haunt {
|
||||||
|
position: fixed; left: 0; top: 0; bottom: 0; width: 8px; z-index: 4;
|
||||||
|
}
|
||||||
|
#haunt-fill {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; height: 0%;
|
||||||
|
background: linear-gradient(0deg, var(--blue), #fff2);
|
||||||
|
box-shadow: 0 0 18px var(--blue); transition: height 0.4s ease, background 0.6s, box-shadow 0.6s;
|
||||||
|
}
|
||||||
|
#haunt-label {
|
||||||
|
position: fixed; left: 14px; top: 14px; z-index: 5;
|
||||||
|
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--mist-dim);
|
||||||
|
}
|
||||||
|
#haunt-pct { color: var(--mist); font-size: 13px; }
|
||||||
|
|
||||||
|
#readout {
|
||||||
|
position: fixed; right: 14px; top: 14px; z-index: 5; text-align: right;
|
||||||
|
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mist-dim);
|
||||||
|
}
|
||||||
|
#readout b { color: var(--mist); font-size: 15px; letter-spacing: 0; }
|
||||||
|
|
||||||
|
#reticle {
|
||||||
|
position: fixed; left: 50%; top: 50%; width: 64px; height: 64px;
|
||||||
|
transform: translate(-50%, -50%); z-index: 4; pointer-events: none;
|
||||||
|
}
|
||||||
|
#reticle .ring {
|
||||||
|
position: absolute; inset: 0; border: 1.5px solid var(--haze); border-radius: 50%;
|
||||||
|
}
|
||||||
|
#reticle .charge {
|
||||||
|
position: absolute; inset: 4px; border-radius: 50%;
|
||||||
|
background: conic-gradient(var(--blue) 0deg, transparent 0deg);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
#reticle .dot { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px;
|
||||||
|
transform: translate(-50%,-50%); background: var(--mist); border-radius: 50%; }
|
||||||
|
#reticle.locked .ring { border-color: var(--mist); }
|
||||||
|
|
||||||
|
#wheel {
|
||||||
|
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
|
||||||
|
z-index: 5; display: flex; gap: 14px; pointer-events: auto;
|
||||||
|
}
|
||||||
|
.lure {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.14);
|
||||||
|
background: var(--c); position: relative; cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.2s, border-color 0.2s; opacity: 0.55;
|
||||||
|
}
|
||||||
|
.lure[data-on="1"] { opacity: 1; transform: scale(1.12);
|
||||||
|
border-color: #fff; box-shadow: 0 0 22px var(--c); }
|
||||||
|
.lure.red { --c: var(--red); }
|
||||||
|
.lure.blue { --c: var(--blue); }
|
||||||
|
.lure.yellow { --c: var(--yellow); }
|
||||||
|
#wheel-label { position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
|
||||||
|
z-index: 5; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||||
|
color: var(--mist-dim); }
|
||||||
|
|
||||||
|
#fire {
|
||||||
|
position: fixed; right: 22px; bottom: 30px; z-index: 6;
|
||||||
|
width: 84px; height: 84px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 50% 40%, var(--ink-2), #05060c);
|
||||||
|
border: 2px solid rgba(255,255,255,0.16); color: var(--mist);
|
||||||
|
font: inherit; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
pointer-events: auto; cursor: pointer; touch-action: none;
|
||||||
|
}
|
||||||
|
#fire:active { border-color: #fff; }
|
||||||
|
|
||||||
|
#toasts { position: fixed; left: 50%; top: 64px; transform: translateX(-50%);
|
||||||
|
z-index: 6; display: flex; flex-direction: column; gap: 6px; align-items: center; }
|
||||||
|
.toast { font-size: 12px; letter-spacing: 0.06em; padding: 6px 12px;
|
||||||
|
background: rgba(10,12,20,0.7); border: 1px solid var(--haze); border-radius: 2px;
|
||||||
|
color: var(--mist); animation: rise 2.4s forwards; }
|
||||||
|
.toast .nm { color: #fff; }
|
||||||
|
@keyframes rise { 0% { opacity: 0; transform: translateY(6px);} 12%{opacity:1;transform:none;}
|
||||||
|
80%{opacity:1;} 100%{opacity:0;} }
|
||||||
|
|
||||||
|
#start { position: fixed; inset: 0; z-index: 10; background:
|
||||||
|
radial-gradient(120% 80% at 50% 0%, #14182a 0%, var(--ink) 60%);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 24px; }
|
||||||
|
#start h1 { font-size: clamp(30px, 9vw, 52px); margin: 0; letter-spacing: -0.01em;
|
||||||
|
font-weight: 600; color: #fff; line-height: 0.98; }
|
||||||
|
#start h1 .n2 { display: block; color: var(--blue); font-style: italic; }
|
||||||
|
#start p { max-width: 30ch; color: var(--mist-dim); font-size: 13px; line-height: 1.6;
|
||||||
|
letter-spacing: 0.02em; }
|
||||||
|
#start .sub { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||||
|
color: var(--mist-dim); margin-bottom: 18px; }
|
||||||
|
.btn { margin-top: 22px; pointer-events: auto; cursor: pointer; font: inherit;
|
||||||
|
font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
padding: 14px 28px; color: var(--ink); background: var(--mist);
|
||||||
|
border: none; border-radius: 2px; }
|
||||||
|
.btn.ghost { background: transparent; color: var(--mist); border: 1px solid var(--haze);
|
||||||
|
margin-top: 10px; }
|
||||||
|
#note { position: fixed; bottom: 8px; left: 0; right: 0; text-align: center; z-index: 10;
|
||||||
|
font-size: 9px; letter-spacing: 0.14em; color: #3a3f4f; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .toast { animation-duration: 0.1s; } }
|
||||||
|
|
||||||
|
/* ---- debug overlay ---- */
|
||||||
|
#dbg-toggle { position: fixed; left: 14px; bottom: 14px; z-index: 7; pointer-events: auto;
|
||||||
|
font: inherit; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
background: rgba(10,12,20,0.7); color: var(--mist-dim); border: 1px solid var(--haze);
|
||||||
|
border-radius: 2px; padding: 8px 10px; cursor: pointer; }
|
||||||
|
#dbg { position: fixed; left: 14px; bottom: 52px; z-index: 7; width: min(320px, 86vw);
|
||||||
|
max-height: 46vh; background: rgba(8,10,16,0.92); border: 1px solid var(--haze);
|
||||||
|
border-radius: 4px; padding: 10px; display: none; pointer-events: auto;
|
||||||
|
font-size: 10px; line-height: 1.5; color: var(--mist); }
|
||||||
|
#dbg.open { display: block; }
|
||||||
|
#dbg .row { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;
|
||||||
|
color: var(--mist-dim); }
|
||||||
|
#dbg .row b { color: var(--mist); }
|
||||||
|
#dbg .ctl { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||||
|
#dbg .ctl button { font: inherit; font-size: 12px; width: 24px; height: 24px;
|
||||||
|
background: var(--ink-2); color: var(--mist); border: 1px solid var(--haze);
|
||||||
|
border-radius: 2px; cursor: pointer; }
|
||||||
|
#dbg-log { max-height: 28vh; overflow-y: auto; border-top: 1px solid var(--haze);
|
||||||
|
padding-top: 6px; font-family: ui-monospace, monospace; }
|
||||||
|
#dbg-log div { white-space: nowrap; }
|
||||||
|
#dbg-log .spawn { color: var(--yellow); }
|
||||||
|
#dbg-log .detect { color: var(--blue); }
|
||||||
|
#dbg-log .capture { color: #7CFFA0; }
|
||||||
|
#dbg-log .flee { color: var(--red); }
|
||||||
|
|
||||||
|
/* off-screen direction arrow */
|
||||||
|
#arrow { position: fixed; z-index: 6; width: 0; height: 0; pointer-events: none;
|
||||||
|
display: none; filter: drop-shadow(0 0 6px var(--c, var(--blue))); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video id="cam" autoplay muted playsinline></video>
|
||||||
|
<div id="scene"></div>
|
||||||
|
|
||||||
|
<div id="hud">
|
||||||
|
<div id="haunt"><div id="haunt-fill"></div></div>
|
||||||
|
<div id="haunt-label">Haunt <span id="haunt-pct">0%</span></div>
|
||||||
|
<div id="readout"><b id="caught">0</b><br>caught · <span id="score">0</span> pts</div>
|
||||||
|
|
||||||
|
<div id="reticle"><div class="ring"></div><div class="charge"></div><div class="dot"></div></div>
|
||||||
|
|
||||||
|
<div id="wheel-label">Lure</div>
|
||||||
|
<div id="wheel" class="pointer">
|
||||||
|
<div class="lure red" data-color="Red"></div>
|
||||||
|
<div class="lure blue" data-color="Blue"></div>
|
||||||
|
<div class="lure yellow" data-color="Yellow"></div>
|
||||||
|
</div>
|
||||||
|
<button id="fire">Hold<br>to charge</button>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- off-screen ghost direction arrow -->
|
||||||
|
<svg id="arrow" width="40" height="40" viewBox="0 0 40 40"><path d="M20 4 L34 32 L20 24 L6 32 Z" fill="var(--c, #51EAF1)"/></svg>
|
||||||
|
|
||||||
|
<!-- debug overlay -->
|
||||||
|
<button id="dbg-toggle">Debug</button>
|
||||||
|
<div id="dbg">
|
||||||
|
<div class="row"><span>active / max</span><b><span id="dbg-active">0</span> / <span id="dbg-max">4</span></b></div>
|
||||||
|
<div class="ctl"><span style="color:var(--mist-dim)">max active</span>
|
||||||
|
<button id="dbg-minus">-</button><b id="dbg-maxval">4</b><button id="dbg-plus">+</button>
|
||||||
|
<label style="margin-left:auto;color:var(--mist-dim)"><input type="checkbox" id="dbg-beacon" checked> beacons</label>
|
||||||
|
</div>
|
||||||
|
<div class="row"><span>haunt</span><b id="dbg-haunt">0%</b></div>
|
||||||
|
<div class="row"><span>server log</span><b id="dbg-srv">off</b></div>
|
||||||
|
<div id="dbg-log"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="start">
|
||||||
|
<div class="sub">A spiritual successor - fan tribute</div>
|
||||||
|
<h1>Newbury<span class="n2">Nights</span></h1>
|
||||||
|
<p>Raise your phone to the room. Spirits gather in the dark - pick a lure colour,
|
||||||
|
aim, and charge your shot to catch them before the haunt overtakes the night.</p>
|
||||||
|
<button class="btn" id="go-ar">Begin hunt (AR)</button>
|
||||||
|
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
|
||||||
|
</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 v9</div>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
|
import { createGhostVisual, setSharedMesh, setSharedMeshYRange } from './ghost-visual.js?v=9';
|
||||||
|
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);
|
||||||
|
let roster = [];
|
||||||
|
try {
|
||||||
|
roster = await (await fetch('./ghosts.enriched.json?v=9')).json();
|
||||||
|
} catch (e) {
|
||||||
|
roster = [
|
||||||
|
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
||||||
|
{ id:'basic-b', name:'Wisp', color:'Blue', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
||||||
|
{ id:'basic-y', name:'Wisp', color:'Yellow', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
$('#scene').appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 50);
|
||||||
|
scene.add(new THREE.HemisphereLight(0x8899ff, 0x111122, 1.1));
|
||||||
|
|
||||||
|
// ---- load FX textures (glow/trail/smoke) so ghosts look wispy, not like cones ----
|
||||||
|
const texLoader = new THREE.TextureLoader();
|
||||||
|
function loadTex(path){
|
||||||
|
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
|
||||||
|
}
|
||||||
|
const fxTextures = {
|
||||||
|
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=9'),
|
||||||
|
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=9'),
|
||||||
|
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=9'),
|
||||||
|
};
|
||||||
|
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', () => {
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hunt = createHunt(THREE, scene, {
|
||||||
|
roster, fx: true, fxTextures,
|
||||||
|
onEvent: (e) => {
|
||||||
|
if (e.type === 'spawn') { spawnBeacon(e); dbgLog('spawn', e); }
|
||||||
|
if (e.type === 'capture') { toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`); dbgLog('capture', e); }
|
||||||
|
if (e.type === 'flee') { toast(`<span class="nm">${e.ghost.name}</span> slipped away`); dbgLog('flee', e); }
|
||||||
|
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
|
||||||
|
score = scoreFromCaptures();
|
||||||
|
$('#caught').textContent = hunt.captured.length;
|
||||||
|
$('#score').textContent = score;
|
||||||
|
}
|
||||||
|
logToServer(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- debug + telemetry ----
|
||||||
|
const SESSION = Math.random().toString(36).slice(2, 8);
|
||||||
|
let serverLog = false; // toggled on if the endpoint answers
|
||||||
|
let serverLogDisabled = false; // turns off after the first failure (e.g. static file server)
|
||||||
|
let beaconsOn = true;
|
||||||
|
const seenInView = new Set(); // ids already "detected" (entered the frustum)
|
||||||
|
const beacons = new Map(); // id -> beacon mesh
|
||||||
|
|
||||||
|
// spawn beacon: a bright, always-visible marker at the spawn point so you can
|
||||||
|
// see WHERE a ghost is even if the wisp itself fails to render.
|
||||||
|
function spawnBeacon(e){
|
||||||
|
if (!beaconsOn) return;
|
||||||
|
// find the matching active ghost's object3d to read its live position
|
||||||
|
const a = hunt.listActive().find(x => x.id === e.id);
|
||||||
|
const pos = a ? a.object3d.position : new THREE.Vector3(e.pos?.x||0, e.pos?.y||0, e.pos?.z||0);
|
||||||
|
const colMap = { Red:0xFF2678, Blue:0x51EAF1, Yellow:0xFFF35D };
|
||||||
|
const col = colMap[e.ghost?.color] || 0xffffff;
|
||||||
|
const grp = new THREE.Group();
|
||||||
|
// bright core
|
||||||
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.12, 12, 12),
|
||||||
|
new THREE.MeshBasicMaterial({ color: col })
|
||||||
|
);
|
||||||
|
// halo ring billboard
|
||||||
|
const ring = new THREE.Sprite(new THREE.SpriteMaterial({ color: col, transparent:true, opacity:0.6 }));
|
||||||
|
ring.scale.setScalar(0.9);
|
||||||
|
grp.add(core); grp.add(ring);
|
||||||
|
grp.position.copy(pos);
|
||||||
|
grp.userData.born = performance.now();
|
||||||
|
scene.add(grp);
|
||||||
|
beacons.set(e.id, grp);
|
||||||
|
}
|
||||||
|
function updateBeacons(){
|
||||||
|
const now = performance.now();
|
||||||
|
for (const [id, g] of beacons) {
|
||||||
|
const age = (now - g.userData.born) / 1000;
|
||||||
|
const pulse = 0.7 + 0.3 * Math.sin(age * 6);
|
||||||
|
g.children[0].scale.setScalar(pulse);
|
||||||
|
g.children[1].material.opacity = 0.5 * pulse;
|
||||||
|
// beacon fades after 3s (ghost should be found by then)
|
||||||
|
if (age > 3) { scene.remove(g); beacons.delete(id); }
|
||||||
|
// also drop the beacon once the ghost is gone
|
||||||
|
if (!hunt.listActive().some(a => a.id === id) && age > 0.5) { scene.remove(g); beacons.delete(id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detection: fire once when a ghost first enters the camera view
|
||||||
|
function checkDetections(){
|
||||||
|
for (const a of hunt.listActive()) {
|
||||||
|
const p = a.object3d.position.clone().project(camera);
|
||||||
|
const inView = p.z < 1 && Math.abs(p.x) < 1 && Math.abs(p.y) < 1;
|
||||||
|
if (inView && !seenInView.has(a.id)) {
|
||||||
|
seenInView.add(a.id);
|
||||||
|
const e = { type:'detect', id:a.id, ghost:a.ghost };
|
||||||
|
dbgLog('detect', e); logToServer(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbgLogEl = () => $('#dbg-log');
|
||||||
|
function dbgLog(kind, e){
|
||||||
|
const el = dbgLogEl(); if (!el) return;
|
||||||
|
const ts = new Date().toLocaleTimeString().slice(0,8);
|
||||||
|
const nm = e.ghost?.name || '';
|
||||||
|
const extra = e.pos ? ` @${e.pos.x},${e.pos.y},${e.pos.z}` : (e.score ? ` +${e.score}` : '');
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = kind;
|
||||||
|
line.textContent = `${ts} ${kind.toUpperCase().padEnd(7)} ${nm}${extra}`;
|
||||||
|
el.prepend(line);
|
||||||
|
while (el.childNodes.length > 80) el.removeChild(el.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logToServer(e){
|
||||||
|
if (serverLogDisabled) return; // don't spam a server that can't receive POSTs
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/hunt-log', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ ...e, ghost: e.ghost?.name, color: e.ghost?.color,
|
||||||
|
rarity: e.ghost?.rarity, session: SESSION })
|
||||||
|
});
|
||||||
|
if (r.ok) { if (!serverLog) { serverLog = true; $('#dbg-srv').textContent = 'on'; } }
|
||||||
|
else { serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a (static)'; }
|
||||||
|
} catch(_) {
|
||||||
|
serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a'; // no backend (static demo) — stop trying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const RARITY_SCORE = { Common:10, Rare:25, Epic:60, Legendary:150 };
|
||||||
|
let score = 0;
|
||||||
|
function scoreFromCaptures() {
|
||||||
|
const byId = Object.fromEntries(roster.map(g => [g.id, g]));
|
||||||
|
return hunt.captured.reduce((s,id)=> s + (RARITY_SCORE[byId[id]?.rarity]||10), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lure').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const on = el.dataset.on === '1';
|
||||||
|
document.querySelectorAll('.lure').forEach(x => x.dataset.on='0');
|
||||||
|
if (!on) { el.dataset.on='1'; hunt.setLure(el.dataset.color); reticleColor(el.dataset.color); }
|
||||||
|
else { hunt.setLure(null); reticleColor(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function reticleColor(c){
|
||||||
|
const map = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
|
||||||
|
document.documentElement.style.setProperty('--blue', map[c] || '#51EAF1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTarget() {
|
||||||
|
const list = hunt.listActive();
|
||||||
|
let best=null, bestD=Infinity;
|
||||||
|
for (const a of list) {
|
||||||
|
const p = a.object3d.position.clone().project(camera);
|
||||||
|
if (p.z > 1) continue;
|
||||||
|
const d = Math.hypot(p.x, p.y);
|
||||||
|
if (d < bestD) { bestD = d; best = a; }
|
||||||
|
}
|
||||||
|
return bestD < 0.35 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let charging=false, charge=0;
|
||||||
|
const fireBtn = $('#fire');
|
||||||
|
const chargeEl = $('#reticle .charge');
|
||||||
|
function setCharge(v){
|
||||||
|
charge = Math.max(0, Math.min(1, v));
|
||||||
|
chargeEl.style.background = `conic-gradient(var(--blue) ${charge*360}deg, transparent ${charge*360}deg)`;
|
||||||
|
}
|
||||||
|
const press = (e)=>{ e.preventDefault(); charging=true; };
|
||||||
|
const release = (e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
if (!charging) return; charging=false;
|
||||||
|
const t = currentTarget();
|
||||||
|
if (t) hunt.fireAt(t.id, charge);
|
||||||
|
setCharge(0);
|
||||||
|
};
|
||||||
|
fireBtn.addEventListener('pointerdown', press);
|
||||||
|
fireBtn.addEventListener('pointerup', release);
|
||||||
|
fireBtn.addEventListener('pointercancel', release);
|
||||||
|
|
||||||
|
function toast(html){
|
||||||
|
const d = document.createElement('div'); d.className='toast'; d.innerHTML=html;
|
||||||
|
$('#toasts').appendChild(d); setTimeout(()=>d.remove(), 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintHaunt(){
|
||||||
|
const h = hunt.haunt;
|
||||||
|
$('#haunt-fill').style.height = h + '%';
|
||||||
|
$('#haunt-pct').textContent = Math.round(h) + '%';
|
||||||
|
const col = h < 50 ? 'var(--blue)' : h < 80 ? 'var(--yellow)' : 'var(--red)';
|
||||||
|
$('#haunt-fill').style.background = `linear-gradient(0deg, ${col}, #fff2)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usingXR = false, xrSession = null, gyroQuat = null;
|
||||||
|
async function startAR(){
|
||||||
|
if (navigator.xr && await navigator.xr.isSessionSupported?.('immersive-ar')) {
|
||||||
|
try {
|
||||||
|
xrSession = await navigator.xr.requestSession('immersive-ar', { optionalFeatures:['local-floor','hit-test'] });
|
||||||
|
renderer.xr.enabled = true;
|
||||||
|
await renderer.xr.setSession(xrSession);
|
||||||
|
usingXR = true; beginLoop(); hideStart(); return;
|
||||||
|
} catch(e){ /* fall through to gyro */ }
|
||||||
|
}
|
||||||
|
startGyro();
|
||||||
|
}
|
||||||
|
async function startGyro(){
|
||||||
|
hideStart();
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:'environment' }, audio:false });
|
||||||
|
$('#cam').srcObject = stream;
|
||||||
|
} catch(e){ /* no camera - black bg is fine */ }
|
||||||
|
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
|
||||||
|
try { await DeviceOrientationEvent.requestPermission(); } catch(e){}
|
||||||
|
}
|
||||||
|
// iOS requires a separate permission for motion (accelerometer) used by parallax
|
||||||
|
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
|
||||||
|
try { await DeviceMotionEvent.requestPermission(); } catch(e){}
|
||||||
|
}
|
||||||
|
gyroQuat = new THREE.Quaternion();
|
||||||
|
let gotGyro = false;
|
||||||
|
const euler = new THREE.Euler();
|
||||||
|
const zee = new THREE.Vector3(0,0,1);
|
||||||
|
const q0 = new THREE.Quaternion();
|
||||||
|
const q1 = new THREE.Quaternion(-Math.sqrt(0.5),0,0,Math.sqrt(0.5));
|
||||||
|
addEventListener('deviceorientation', (ev)=>{
|
||||||
|
if (ev.alpha == null && ev.beta == null && ev.gamma == null) return;
|
||||||
|
gotGyro = true;
|
||||||
|
const a = THREE.MathUtils.degToRad(ev.alpha||0);
|
||||||
|
const b = THREE.MathUtils.degToRad(ev.beta||0);
|
||||||
|
const g = THREE.MathUtils.degToRad(ev.gamma||0);
|
||||||
|
const orient = THREE.MathUtils.degToRad(screen.orientation?.angle||0);
|
||||||
|
euler.set(b, a, -g, 'YXZ');
|
||||||
|
gyroQuat.setFromEuler(euler);
|
||||||
|
gyroQuat.multiply(q1);
|
||||||
|
gyroQuat.multiply(q0.setFromAxisAngle(zee, -orient));
|
||||||
|
}, true);
|
||||||
|
// if no gyro data arrives within 1s (desktop / no sensor), enable drag-look
|
||||||
|
setTimeout(() => { if (!gotGyro) enableDragLook(); }, 1000);
|
||||||
|
enableParallax();
|
||||||
|
beginLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fake parallax: a subtle camera sway from device motion / tilt ----
|
||||||
|
// Cannot give true positional tracking (iOS Safari has no WebXR), but reading
|
||||||
|
// devicemotion lets us add a small depth-giving sway when you tilt or step.
|
||||||
|
const parallax = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||||
|
function enableParallax(){
|
||||||
|
// tilt-based offset (works from deviceorientation beta/gamma we already get)
|
||||||
|
addEventListener('deviceorientation', (ev) => {
|
||||||
|
if (ev.beta == null && ev.gamma == null) return;
|
||||||
|
// map tilt to a tiny lateral/vertical target offset (clamped, metres)
|
||||||
|
parallax.tx = Math.max(-0.12, Math.min(0.12, (ev.gamma || 0) / 90 * 0.12));
|
||||||
|
parallax.ty = Math.max(-0.08, Math.min(0.08, ((ev.beta || 0) - 80) / 90 * 0.08));
|
||||||
|
}, true);
|
||||||
|
// motion-based micro-impulse (a step/jolt nudges the sway)
|
||||||
|
if (typeof DeviceMotionEvent !== 'undefined') {
|
||||||
|
addEventListener('devicemotion', (ev) => {
|
||||||
|
const a = ev.accelerationIncludingGravity || ev.acceleration;
|
||||||
|
if (!a) return;
|
||||||
|
parallax.tx += Math.max(-0.02, Math.min(0.02, (a.x || 0) * 0.002));
|
||||||
|
parallax.ty += Math.max(-0.02, Math.min(0.02, (a.y || 0) * 0.002));
|
||||||
|
parallax.tx = Math.max(-0.15, Math.min(0.15, parallax.tx));
|
||||||
|
parallax.ty = Math.max(-0.12, Math.min(0.12, parallax.ty));
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideStart(){ $('#start').style.display='none'; }
|
||||||
|
|
||||||
|
$('#go-ar').addEventListener('click', startAR);
|
||||||
|
$('#go-gyro').addEventListener('click', startGyro);
|
||||||
|
|
||||||
|
// ---- desktop fallback: drag to look around (no gyro on desktop) ----
|
||||||
|
let dragYaw = 0, dragPitch = 0, dragging = false, lastX = 0, lastY = 0;
|
||||||
|
let usingDrag = false;
|
||||||
|
function enableDragLook(){
|
||||||
|
usingDrag = true;
|
||||||
|
const el = renderer.domElement;
|
||||||
|
const down = (e) => { dragging = true; lastX = e.clientX ?? e.touches?.[0]?.clientX; lastY = e.clientY ?? e.touches?.[0]?.clientY; };
|
||||||
|
const move = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const x = e.clientX ?? e.touches?.[0]?.clientX, y = e.clientY ?? e.touches?.[0]?.clientY;
|
||||||
|
dragYaw -= (x - lastX) * 0.005;
|
||||||
|
dragPitch -= (y - lastY) * 0.005;
|
||||||
|
dragPitch = Math.max(-1.2, Math.min(1.2, dragPitch));
|
||||||
|
lastX = x; lastY = y;
|
||||||
|
};
|
||||||
|
const up = () => { dragging = false; };
|
||||||
|
el.addEventListener('mousedown', down); addEventListener('mousemove', move); addEventListener('mouseup', up);
|
||||||
|
el.addEventListener('touchstart', down, {passive:true}); addEventListener('touchmove', move, {passive:true}); addEventListener('touchend', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- debug panel controls ----
|
||||||
|
$('#dbg-toggle').addEventListener('click', () => $('#dbg').classList.toggle('open'));
|
||||||
|
function setMax(n){
|
||||||
|
hunt.setMaxActive(n);
|
||||||
|
$('#dbg-maxval').textContent = hunt.maxActive;
|
||||||
|
$('#dbg-max').textContent = hunt.maxActive;
|
||||||
|
}
|
||||||
|
$('#dbg-minus').addEventListener('click', () => setMax(hunt.maxActive - 1));
|
||||||
|
$('#dbg-plus').addEventListener('click', () => setMax(hunt.maxActive + 1));
|
||||||
|
$('#dbg-beacon').addEventListener('change', (e) => { beaconsOn = e.target.checked; });
|
||||||
|
setMax(hunt.maxActive);
|
||||||
|
|
||||||
|
// ---- off-screen direction arrow: points to the nearest off-screen ghost ----
|
||||||
|
const arrowEl = $('#arrow');
|
||||||
|
function updateArrow(){
|
||||||
|
const list = hunt.listActive();
|
||||||
|
let target = null, bestZ = Infinity;
|
||||||
|
for (const a of list) {
|
||||||
|
const p = a.object3d.position.clone().project(camera);
|
||||||
|
const onScreen = p.z < 1 && Math.abs(p.x) < 0.95 && Math.abs(p.y) < 0.95;
|
||||||
|
if (onScreen) continue;
|
||||||
|
if (p.z < bestZ) { bestZ = p.z; target = { a, p }; }
|
||||||
|
}
|
||||||
|
if (!target) { arrowEl.style.display = 'none'; return; }
|
||||||
|
// direction from screen centre toward the (clamped) projected point.
|
||||||
|
// behind-camera points (z>1) get flipped so the arrow still makes sense.
|
||||||
|
let { x, y, z } = target.p;
|
||||||
|
if (z > 1) { x = -x; y = -y; }
|
||||||
|
const ang = Math.atan2(y, x); // screen angle (y up)
|
||||||
|
const r = Math.min(innerWidth, innerHeight) * 0.36;
|
||||||
|
const cx = innerWidth/2 + Math.cos(ang) * r;
|
||||||
|
const cy = innerHeight/2 - Math.sin(ang) * r; // CSS y is down
|
||||||
|
const colMap = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
|
||||||
|
arrowEl.style.setProperty('--c', colMap[target.a.ghost.color] || '#51EAF1');
|
||||||
|
arrowEl.querySelector('path').setAttribute('fill', colMap[target.a.ghost.color] || '#51EAF1');
|
||||||
|
arrowEl.style.display = 'block';
|
||||||
|
arrowEl.style.left = (cx - 20) + 'px';
|
||||||
|
arrowEl.style.top = (cy - 20) + 'px';
|
||||||
|
arrowEl.style.transform = `rotate(${-ang + Math.PI/2}rad)`; // point outward
|
||||||
|
}
|
||||||
|
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
function frame(){
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
const t = clock.elapsedTime;
|
||||||
|
if (charging) setCharge(charge + dt * 0.9);
|
||||||
|
if (usingXR) {
|
||||||
|
// In AR, the device pose drives renderer.xr.getCamera(). Sync our logic
|
||||||
|
// camera (used for targeting/detection/arrows) from it so position AND
|
||||||
|
// rotation both track as you physically move the phone.
|
||||||
|
const xrCam = renderer.xr.getCamera();
|
||||||
|
xrCam.getWorldPosition(camera.position);
|
||||||
|
xrCam.getWorldQuaternion(camera.quaternion);
|
||||||
|
camera.updateMatrixWorld();
|
||||||
|
} else if (usingDrag) {
|
||||||
|
const e = new THREE.Euler(dragPitch, dragYaw, 0, 'YXZ');
|
||||||
|
camera.quaternion.setFromEuler(e);
|
||||||
|
} else if (gyroQuat) {
|
||||||
|
camera.quaternion.copy(gyroQuat);
|
||||||
|
}
|
||||||
|
// fake parallax sway (non-XR only): ease the camera a touch toward the
|
||||||
|
// tilt/motion target, applied in view space so it reads as gentle depth.
|
||||||
|
if (!usingXR) {
|
||||||
|
parallax.x += (parallax.tx - parallax.x) * Math.min(1, dt * 4);
|
||||||
|
parallax.y += (parallax.ty - parallax.y) * Math.min(1, dt * 4);
|
||||||
|
// decay the motion impulse back toward the tilt baseline
|
||||||
|
parallax.tx *= 0.92; parallax.ty *= 0.92;
|
||||||
|
const right = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
|
||||||
|
const up = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
|
||||||
|
camera.position.copy(right.multiplyScalar(parallax.x).add(up.multiplyScalar(parallax.y)));
|
||||||
|
}
|
||||||
|
hunt.update(dt, t);
|
||||||
|
updateBeacons();
|
||||||
|
checkDetections();
|
||||||
|
updateArrow();
|
||||||
|
$('#dbg-active').textContent = hunt.listActive().length;
|
||||||
|
$('#dbg-haunt').textContent = Math.round(hunt.haunt) + '%';
|
||||||
|
$('#reticle').classList.toggle('locked', !!currentTarget());
|
||||||
|
paintHaunt();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
function beginLoop(){ renderer.setAnimationLoop(frame); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* hunt.js — Newbury Nights hunt loop (Phase 3, standalone demo)
|
||||||
|
*
|
||||||
|
* Core v1 mechanics:
|
||||||
|
* - Spawn: rarity-weighted draw from the roster, placed in a forward arc.
|
||||||
|
* - Colour lure: the wheel sets the "lured" colour; matching ghosts spawn more
|
||||||
|
* often and are worth more, mirroring the original's GhostColor mechanic.
|
||||||
|
* - Capture: aim the reticle, hold to charge, release to fire. Charge time
|
||||||
|
* scales with the ghost's chargePips; misses let it flee (escape timing).
|
||||||
|
* - Hauntometer: every active/escaped ghost raises the meter; captures lower
|
||||||
|
* it. As the meter climbs, spawn rate and rarity bias escalate — the session
|
||||||
|
* gets harder the longer the haunt goes unchecked.
|
||||||
|
*
|
||||||
|
* Engine-agnostic: takes a THREE instance + a scene + the createGhostVisual
|
||||||
|
* factory. No AR/session code here — the page wires camera/anchor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createGhostVisual } from './ghost-visual.js';
|
||||||
|
|
||||||
|
const RARITY_BASE_WEIGHT = { Common: 50, Rare: 30, Epic: 15, Legendary: 5 };
|
||||||
|
const RARITY_SCORE = { Common: 10, Rare: 25, Epic: 60, Legendary: 150 };
|
||||||
|
const COLORS = ['Red', 'Blue', 'Yellow'];
|
||||||
|
|
||||||
|
export function createHunt(THREE, scene, opts = {}) {
|
||||||
|
const roster = opts.roster || [];
|
||||||
|
let maxActive = opts.maxActive ?? 4;
|
||||||
|
const spawnArcDeg = opts.spawnArcDeg ?? 90; // ±45° — tighter so ghosts land in front
|
||||||
|
const fleeMinMs = opts.fleeMinMs ?? 12000; // longer discovery window
|
||||||
|
const fleeMaxMs = opts.fleeMaxMs ?? 18000;
|
||||||
|
const fxTextures = opts.fxTextures || null; // {glow, trail, smoke} THREE.Textures
|
||||||
|
const onEvent = opts.onEvent || (() => {});
|
||||||
|
|
||||||
|
let luredColor = null; // set by the colour wheel
|
||||||
|
let haunt = 0; // 0..100 hauntometer
|
||||||
|
let captured = []; // captured ghost ids this session
|
||||||
|
const active = new Map(); // id -> { ghost, visual, hp, state, fleeAt }
|
||||||
|
let nextId = 1;
|
||||||
|
let spawnTimer = 0;
|
||||||
|
|
||||||
|
// --- weighted roster draw, biased by lure + haunt escalation ---
|
||||||
|
function pickGhost() {
|
||||||
|
// haunt escalation: higher haunt tilts the draw toward rarer ghosts
|
||||||
|
const escal = 1 + (haunt / 100) * 1.5; // up to 2.5x rare bias
|
||||||
|
const pool = [];
|
||||||
|
for (const g of roster) {
|
||||||
|
let w = RARITY_BASE_WEIGHT[g.rarity] || 1;
|
||||||
|
// rarer ghosts get heavier as haunt climbs
|
||||||
|
if (g.rarity !== 'Common') w *= escal;
|
||||||
|
// lure: matching colour spawns ~2x more often
|
||||||
|
if (luredColor && g.color === luredColor) w *= 2;
|
||||||
|
pool.push([g, w]);
|
||||||
|
}
|
||||||
|
const total = pool.reduce((s, [, w]) => s + w, 0);
|
||||||
|
let r = Math.random() * total;
|
||||||
|
for (const [g, w] of pool) { r -= w; if (r <= 0) return g; }
|
||||||
|
return pool[pool.length - 1][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeOnRing(a) {
|
||||||
|
// Full 360° ring around the stationary player at roughly eye level.
|
||||||
|
// Each ghost gets orbit params so it slowly circles + bobs; the player
|
||||||
|
// stands still and pans to track them (works on iOS, no walking needed).
|
||||||
|
a.azimuth = Math.random() * Math.PI * 2; // start anywhere around
|
||||||
|
a.radius = 2.6 + Math.random() * 1.6; // 2.6–4.2m out
|
||||||
|
a.height = -0.3 + Math.random() * 1.0; // around eye level
|
||||||
|
a.orbitSpeed = (Math.random() < 0.5 ? -1 : 1) * (0.05 + Math.random() * 0.12); // rad/s, either direction
|
||||||
|
a.bobPhase = Math.random() * Math.PI * 2;
|
||||||
|
a.bobAmp = 0.1 + Math.random() * 0.2;
|
||||||
|
a.radiusPhase = Math.random() * Math.PI * 2;
|
||||||
|
a.radiusAmp = 0.2 + Math.random() * 0.4; // gentle in/out drift
|
||||||
|
positionFromOrbit(a, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionFromOrbit(a, elapsed) {
|
||||||
|
const az = a.azimuth + a.orbitSpeed * elapsed;
|
||||||
|
const r = a.radius + Math.sin(elapsed * 0.5 + a.radiusPhase) * a.radiusAmp;
|
||||||
|
const y = a.height + Math.sin(elapsed * 1.2 + a.bobPhase) * a.bobAmp;
|
||||||
|
a.visual.object3d.position.set(
|
||||||
|
Math.sin(az) * r,
|
||||||
|
y,
|
||||||
|
-Math.cos(az) * r
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn() {
|
||||||
|
if (active.size >= maxActive) return;
|
||||||
|
const g = pickGhost();
|
||||||
|
const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false });
|
||||||
|
if (fxTextures) visual.attachFx(fxTextures);
|
||||||
|
scene.add(visual.object3d);
|
||||||
|
const id = nextId++;
|
||||||
|
// hp derived from health pips/stat; charge to capture scales with chargePips
|
||||||
|
const hp = (g.healthMax || 300) / 100; // ~2.6-4.5
|
||||||
|
const a = {
|
||||||
|
ghost: g, visual, hp, maxHp: hp,
|
||||||
|
state: 'idle',
|
||||||
|
fleeAt: performance.now() + (fleeMinMs + Math.random() * (fleeMaxMs - fleeMinMs)), // escapes if ignored
|
||||||
|
};
|
||||||
|
placeOnRing(a); // assign orbit + initial position
|
||||||
|
active.set(id, a);
|
||||||
|
haunt = Math.min(100, haunt + 4); // presence raises the meter
|
||||||
|
const p = visual.object3d.position;
|
||||||
|
onEvent({ type: 'spawn', id, ghost: g, haunt, pos: { x: +p.x.toFixed(2), y: +p.y.toFixed(2), z: +p.z.toFixed(2) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- capture attempt: called by the page with a charge level 0..1 ---
|
||||||
|
function fireAt(id, charge) {
|
||||||
|
const a = active.get(id);
|
||||||
|
if (!a) return { hit: false };
|
||||||
|
// damage = charge, but the ghost needs enough charge relative to chargePips
|
||||||
|
const needed = 0.4 + (a.ghost.chargePips || 2) * 0.12; // 0.5-1.0
|
||||||
|
const dmg = charge >= needed ? charge * 1.6 : charge * 0.4;
|
||||||
|
a.hp -= dmg;
|
||||||
|
if (a.hp <= 0) return capture(id);
|
||||||
|
a.state = 'hit';
|
||||||
|
onEvent({ type: 'hit', id, ghost: a.ghost, hpFrac: Math.max(0, a.hp / a.maxHp) });
|
||||||
|
return { hit: true, captured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(id) {
|
||||||
|
const a = active.get(id);
|
||||||
|
if (!a) return { hit: false };
|
||||||
|
a.visual.setState('capture');
|
||||||
|
const colorBonus = luredColor && a.ghost.color === luredColor ? 1.5 : 1;
|
||||||
|
const score = Math.round((RARITY_SCORE[a.ghost.rarity] || 10) * colorBonus);
|
||||||
|
captured.push(a.ghost.id);
|
||||||
|
haunt = Math.max(0, haunt - 10); // captures calm the haunt
|
||||||
|
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 500);
|
||||||
|
active.delete(id);
|
||||||
|
onEvent({ type: 'capture', id, ghost: a.ghost, score, captured: captured.length, haunt });
|
||||||
|
return { hit: true, captured: true, score };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flee(id) {
|
||||||
|
const a = active.get(id);
|
||||||
|
if (!a) return;
|
||||||
|
a.visual.setState('capture'); // reuse shrink-out
|
||||||
|
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 400);
|
||||||
|
active.delete(id);
|
||||||
|
haunt = Math.min(100, haunt + 8); // an escape feeds the haunt
|
||||||
|
onEvent({ type: 'flee', id, ghost: a.ghost, haunt });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLure(color) {
|
||||||
|
luredColor = COLORS.includes(color) ? color : null;
|
||||||
|
onEvent({ type: 'lure', color: luredColor });
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(dt, elapsed) {
|
||||||
|
// spawn cadence accelerates with haunt
|
||||||
|
const interval = Math.max(1.2, 4.5 - (haunt / 100) * 3.0);
|
||||||
|
spawnTimer += dt;
|
||||||
|
if (spawnTimer >= interval) { spawnTimer = 0; spawn(); }
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
for (const [id, a] of active) {
|
||||||
|
// orbit-and-drift around the stationary player
|
||||||
|
if (a.state !== 'capture') positionFromOrbit(a, elapsed);
|
||||||
|
a.visual.update(dt, elapsed);
|
||||||
|
if (now >= a.fleeAt) flee(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listActive() {
|
||||||
|
return [...active.entries()].map(([id, a]) => ({
|
||||||
|
id, ghost: a.ghost, object3d: a.visual.object3d,
|
||||||
|
hpFrac: Math.max(0, a.hp / a.maxHp),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update, spawn, fireAt, capture, flee, setLure, listActive,
|
||||||
|
setMaxActive(n) { maxActive = Math.max(1, n | 0); },
|
||||||
|
get maxActive() { return maxActive; },
|
||||||
|
get haunt() { return haunt; },
|
||||||
|
get captured() { return captured.slice(); },
|
||||||
|
get luredColor() { return luredColor; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -79,7 +79,11 @@ function renderGhosts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ghostRow(g) {
|
function ghostRow(g) {
|
||||||
const img = g.image_path ? `<img src="/uploads/${g.image_path}" alt="">` : '';
|
const img = g.image_path
|
||||||
|
? `<img src="/uploads/${g.image_path}" alt="">`
|
||||||
|
: (g.webm_path
|
||||||
|
? `<video src="/uploads/${g.webm_path}" muted loop autoplay playsinline></video>`
|
||||||
|
: '');
|
||||||
const setRef = g.set_number ? `${g.set_number}` : '—';
|
const setRef = g.set_number ? `${g.set_number}` : '—';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="thumb-cell">${img}</td>
|
<td class="thumb-cell">${img}</td>
|
||||||
@@ -134,12 +138,45 @@ function openGhost(id) {
|
|||||||
$('#gm-boss').checked = !!g?.is_boss;
|
$('#gm-boss').checked = !!g?.is_boss;
|
||||||
$('#gm-enabled').checked = g ? !!g.enabled : true;
|
$('#gm-enabled').checked = g ? !!g.enabled : true;
|
||||||
$('#gm-file').value = '';
|
$('#gm-file').value = '';
|
||||||
const prev = $('#gm-preview');
|
// Show the existing stored media: prefer the WebM video, else a still image
|
||||||
if (g?.image_path) { prev.src = `/uploads/${g.image_path}`; prev.classList.remove('hidden'); }
|
// (image_path is the GIF/PNG, or the WebP thumbnail for converted ghosts).
|
||||||
else prev.classList.add('hidden');
|
if (g?.webm_path) showPreview(`/uploads/${g.webm_path}`, 'video');
|
||||||
|
else if (g?.image_path) showPreview(`/uploads/${g.image_path}`, 'image');
|
||||||
|
else if (g?.webp_path) showPreview(`/uploads/${g.webp_path}`, 'image');
|
||||||
|
else hidePreview();
|
||||||
$('#ghost-modal').classList.remove('hidden');
|
$('#ghost-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swap the modal preview between an <img> and a <video> depending on media kind.
|
||||||
|
function showPreview(src, kind) {
|
||||||
|
const img = $('#gm-preview');
|
||||||
|
const vid = $('#gm-preview-vid');
|
||||||
|
if (kind === 'video') {
|
||||||
|
img.classList.add('hidden'); img.removeAttribute('src');
|
||||||
|
vid.src = src; vid.classList.remove('hidden');
|
||||||
|
vid.play?.().catch(() => {});
|
||||||
|
} else {
|
||||||
|
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
|
||||||
|
img.src = src; img.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePreview() {
|
||||||
|
const img = $('#gm-preview');
|
||||||
|
const vid = $('#gm-preview-vid');
|
||||||
|
img.classList.add('hidden'); img.removeAttribute('src');
|
||||||
|
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live local preview when a file is chosen (before upload).
|
||||||
|
$('#gm-file').addEventListener('change', () => {
|
||||||
|
const file = $('#gm-file').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const isVideo = /\.(mp4|webm)$/i.test(file.name) || file.type.startsWith('video/');
|
||||||
|
showPreview(url, isVideo ? 'video' : 'image');
|
||||||
|
});
|
||||||
|
|
||||||
$('#gm-save').addEventListener('click', saveGhost);
|
$('#gm-save').addEventListener('click', saveGhost);
|
||||||
|
|
||||||
async function saveGhost() {
|
async function saveGhost() {
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ async function resolveCode(code) {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
|
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
|
||||||
|
|
||||||
|
// Detect VP9-with-alpha WebM support once. iOS Safari historically lacks
|
||||||
|
// reliable VP9-alpha, so those devices fall back to the GIF/WebP <img> path.
|
||||||
|
const SUPPORTS_WEBM_ALPHA = (() => {
|
||||||
|
try {
|
||||||
|
const v = document.createElement('video');
|
||||||
|
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const hunt = {
|
const hunt = {
|
||||||
running: false,
|
running: false,
|
||||||
scene: null, camera: null, renderer: null, raf: null,
|
scene: null, camera: null, renderer: null, raf: null,
|
||||||
@@ -289,7 +300,46 @@ const hunt = {
|
|||||||
|
|
||||||
let mesh;
|
let mesh;
|
||||||
let texture = null;
|
let texture = null;
|
||||||
if (data.image) {
|
if (data.webm && SUPPORTS_WEBM_ALPHA) {
|
||||||
|
// WebM (VP9+alpha) billboard via VideoTexture. The browser decodes and
|
||||||
|
// updates the texture itself — no per-frame needsUpdate pumping needed.
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.crossOrigin = 'anonymous';
|
||||||
|
vid.muted = true; // required for autoplay
|
||||||
|
vid.loop = true;
|
||||||
|
vid.playsInline = true; // iOS: stay inline, don't fullscreen
|
||||||
|
vid.autoplay = true;
|
||||||
|
vid.preload = 'auto';
|
||||||
|
vid.src = data.webm;
|
||||||
|
texture = new THREE.VideoTexture(vid);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
texture.generateMipmaps = false;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false });
|
||||||
|
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
|
||||||
|
// If the video errors (e.g. alpha unsupported despite canPlayType), swap
|
||||||
|
// to the GIF/image billboard if we have one.
|
||||||
|
vid.onerror = () => {
|
||||||
|
if (group.userData.videoFellBack) return;
|
||||||
|
group.userData.videoFellBack = true;
|
||||||
|
if (data.image) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = data.image;
|
||||||
|
const t2 = new THREE.Texture(img);
|
||||||
|
img.onload = () => { t2.needsUpdate = true; };
|
||||||
|
mesh.material.map = t2;
|
||||||
|
mesh.material.needsUpdate = true;
|
||||||
|
group.userData.gifImg = img;
|
||||||
|
group.userData.gifTex = t2;
|
||||||
|
group.userData.vidEl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pr = vid.play();
|
||||||
|
if (pr && pr.catch) pr.catch(() => {});
|
||||||
|
group.userData.vidEl = vid;
|
||||||
|
} else if (data.image) {
|
||||||
// animated GIF billboard — texture.needsUpdate pumped each frame
|
// animated GIF billboard — texture.needsUpdate pumped each frame
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
@@ -444,6 +494,7 @@ const hunt = {
|
|||||||
captureTarget() {
|
captureTarget() {
|
||||||
const g = this.target;
|
const g = this.target;
|
||||||
const d = g.userData.data;
|
const d = g.userData.data;
|
||||||
|
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
|
||||||
this.scene.remove(g);
|
this.scene.remove(g);
|
||||||
this.ghosts = this.ghosts.filter((x) => x !== g);
|
this.ghosts = this.ghosts.filter((x) => x !== g);
|
||||||
this.target = null;
|
this.target = null;
|
||||||
@@ -543,6 +594,9 @@ const hunt = {
|
|||||||
removeEventListener('mouseup', this._blastEls.endBlast);
|
removeEventListener('mouseup', this._blastEls.endBlast);
|
||||||
}
|
}
|
||||||
if (this.renderer) { this.renderer.dispose?.(); }
|
if (this.renderer) { this.renderer.dispose?.(); }
|
||||||
|
for (const g of this.ghosts) {
|
||||||
|
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
|
||||||
|
}
|
||||||
this.ghosts = []; this.target = null;
|
this.ghosts = []; this.target = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Newbury Nights</title>
|
||||||
|
<style>
|
||||||
|
:root{ --ink:#0a0c14; --mist:#c7ccd9; --mist-dim:#6b7184; --blue:#51EAF1;
|
||||||
|
--red:#FF2678; --yellow:#FFF35D; --haze:rgba(199,204,217,0.08); }
|
||||||
|
*{ box-sizing:border-box; }
|
||||||
|
html,body{ margin:0; min-height:100%; background:var(--ink); color:var(--mist);
|
||||||
|
font-family:ui-monospace,"SF Mono","Cascadia Mono",Menlo,monospace; }
|
||||||
|
body{ display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||||
|
min-height:100vh; text-align:center; padding:24px;
|
||||||
|
background:radial-gradient(120% 80% at 50% 0%, #14182a 0%, var(--ink) 60%); }
|
||||||
|
.sub{ font-size:10px; letter-spacing:0.24em; text-transform:uppercase; color:var(--mist-dim);
|
||||||
|
margin-bottom:18px; }
|
||||||
|
h1{ font-size:clamp(34px,11vw,68px); margin:0; font-weight:600; color:#fff; line-height:0.95;
|
||||||
|
letter-spacing:-0.01em; }
|
||||||
|
h1 .n2{ display:block; font-style:italic; color:var(--blue); }
|
||||||
|
.lures{ display:flex; gap:10px; justify-content:center; margin:22px 0 6px; }
|
||||||
|
.lures i{ width:14px; height:14px; border-radius:50%; display:block; }
|
||||||
|
.lures .r{ background:var(--red); box-shadow:0 0 12px var(--red); }
|
||||||
|
.lures .b{ background:var(--blue); box-shadow:0 0 12px var(--blue); }
|
||||||
|
.lures .y{ background:var(--yellow); box-shadow:0 0 12px var(--yellow); }
|
||||||
|
p{ max-width:34ch; color:var(--mist-dim); font-size:13px; line-height:1.6; }
|
||||||
|
.actions{ display:flex; flex-direction:column; gap:10px; margin-top:24px; width:min(280px,80vw); }
|
||||||
|
a.btn{ text-decoration:none; font-size:13px; letter-spacing:0.16em; text-transform:uppercase;
|
||||||
|
padding:15px 24px; border-radius:2px; }
|
||||||
|
a.primary{ background:var(--mist); color:var(--ink); }
|
||||||
|
a.ghost{ background:transparent; color:var(--mist); border:1px solid var(--haze); }
|
||||||
|
footer{ margin-top:36px; font-size:9px; letter-spacing:0.14em; color:#3a3f4f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sub">A spiritual successor · fan tribute</div>
|
||||||
|
<h1>Newbury<span class="n2">Nights</span></h1>
|
||||||
|
<div class="lures"><i class="r"></i><i class="b"></i><i class="y"></i></div>
|
||||||
|
<p>The town doesn't sleep after dark. Raise your phone, lure the spirits by colour,
|
||||||
|
and catch them before the haunt takes hold.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn primary" href="/hunt">Begin the hunt</a>
|
||||||
|
<a class="btn ghost" href="/#about">About this tribute</a>
|
||||||
|
</div>
|
||||||
|
<footer>Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
|
||||||
|
<title>Newbury Nights — Ghost Preview</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
#pv-stage { position: fixed; inset: 0; overflow: hidden; background: #07090f; }
|
||||||
|
#pv-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
#pv-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.pv-bar {
|
||||||
|
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
padding: 10px 14px; background: rgba(7,9,15,.82); backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
.pv-bar .brand { font-weight: 700; letter-spacing: .04em; text-transform: uppercase; font-size: 14px; }
|
||||||
|
.pv-bar .brand span { color: var(--blue, #3bb6ff); }
|
||||||
|
.pv-bar select, .pv-bar button { font-size: 13px; }
|
||||||
|
.pv-bar .grow { flex: 1; }
|
||||||
|
.pv-hint { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
|
||||||
|
text-align: center; padding: 8px; font-family: var(--mono, monospace); font-size: 11px;
|
||||||
|
color: rgba(255,255,255,.7); background: linear-gradient(transparent, rgba(7,9,15,.75)); }
|
||||||
|
.pv-controls { position: fixed; right: 14px; bottom: 36px; z-index: 10;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
|
||||||
|
.pv-controls label { font-family: var(--mono, monospace); font-size: 10px; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: rgba(255,255,255,.65); }
|
||||||
|
.pv-controls input[type=range] { width: 150px; }
|
||||||
|
/* Login gate */
|
||||||
|
#pv-login { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center;
|
||||||
|
padding: 24px; background: #07090f; }
|
||||||
|
.pv-login-card { background: var(--panel, #11151f); border: 1px solid rgba(255,255,255,.1);
|
||||||
|
border-radius: 14px; padding: 30px; width: 100%; max-width: 360px; }
|
||||||
|
.pv-login-card h1 { font-size: 30px; line-height: .9; margin: 0 0 4px; }
|
||||||
|
.pv-login-card h1 span { color: var(--blue, #3bb6ff); }
|
||||||
|
.pv-login-card .sub { font-size: 11px; letter-spacing: .3em; color: rgba(255,255,255,.55);
|
||||||
|
margin: 6px 0 22px; font-family: var(--mono, monospace); }
|
||||||
|
.pv-login-card label { display: block; margin-top: 12px; font-size: 13px; }
|
||||||
|
.pv-login-card input { width: 100%; box-sizing: border-box; }
|
||||||
|
.pv-err { color: var(--danger, #ff3b5c); font-size: 13px; margin-top: 12px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login gate (same client-side flow as /admin) -->
|
||||||
|
<div id="pv-login">
|
||||||
|
<div class="pv-login-card">
|
||||||
|
<h1>Newbury<span>Nights</span></h1>
|
||||||
|
<div class="sub">GHOST PREVIEW</div>
|
||||||
|
<label for="pv-user">Username</label>
|
||||||
|
<input id="pv-user" autocomplete="username" />
|
||||||
|
<label for="pv-pass">Password</label>
|
||||||
|
<input id="pv-pass" type="password" autocomplete="current-password" />
|
||||||
|
<button class="primary" id="pv-go" style="width:100%;margin-top:14px">Sign in</button>
|
||||||
|
<div id="pv-login-err" class="pv-err hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview stage -->
|
||||||
|
<div id="pv-stage" class="hidden">
|
||||||
|
<video id="pv-video" playsinline muted></video>
|
||||||
|
<canvas id="pv-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pv-app" class="hidden">
|
||||||
|
<div class="pv-bar">
|
||||||
|
<div class="brand">Newbury<span>Nights</span> · PREVIEW</div>
|
||||||
|
<select id="pv-select"><option value="">Loading ghosts…</option></select>
|
||||||
|
<button id="pv-camera-toggle" class="small">Camera: on</button>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<a href="/admin" class="small" style="text-decoration:none"><button class="small">← Admin</button></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pv-controls">
|
||||||
|
<label>Distance <span id="pv-dist-val">3.0</span></label>
|
||||||
|
<input id="pv-dist" type="range" min="1.5" max="8" step="0.1" value="3" />
|
||||||
|
<label>Size <span id="pv-size-val">1.2</span></label>
|
||||||
|
<input id="pv-size" type="range" min="0.4" max="3" step="0.1" value="1.2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pv-hint" id="pv-hint">Select a ghost to preview it over the camera feed.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
|
||||||
|
|
||||||
|
// VP9-alpha WebM transparency works on desktop Chromium/Gecko but NOT on
|
||||||
|
// WebKit: all iOS browsers (Safari, Chrome, Firefox) and desktop Safari play
|
||||||
|
// the WebM but render its alpha as opaque black. Those devices must fall
|
||||||
|
// through to the animated WebP (which IS transparent), so exclude WebKit here.
|
||||||
|
const SUPPORTS_WEBM_ALPHA = (() => {
|
||||||
|
try {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(ua) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ poses as Mac
|
||||||
|
const isSafari = /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg|OPR/.test(ua);
|
||||||
|
if (isIOS || isSafari) return false;
|
||||||
|
const v = document.createElement('video');
|
||||||
|
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
|
||||||
|
} catch { return false; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
let TOKEN = null;
|
||||||
|
let GHOSTS = [];
|
||||||
|
|
||||||
|
/* ---------- login ---------- */
|
||||||
|
$('#pv-go').addEventListener('click', login);
|
||||||
|
$('#pv-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const username = $('#pv-user').value.trim();
|
||||||
|
const password = $('#pv-pass').value;
|
||||||
|
const err = $('#pv-login-err');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('bad login');
|
||||||
|
TOKEN = (await res.json()).token;
|
||||||
|
} catch {
|
||||||
|
err.textContent = 'Invalid username or password.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#pv-login').classList.add('hidden');
|
||||||
|
$('#pv-stage').classList.remove('hidden');
|
||||||
|
$('#pv-app').classList.remove('hidden');
|
||||||
|
await loadGhosts();
|
||||||
|
initScene();
|
||||||
|
await initCamera();
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGhosts() {
|
||||||
|
const res = await fetch('/api/admin/ghosts', { headers: { Authorization: `Bearer ${TOKEN}` } });
|
||||||
|
GHOSTS = (await res.json()).ghosts;
|
||||||
|
const sel = $('#pv-select');
|
||||||
|
sel.innerHTML = '<option value="">— pick a ghost —</option>' +
|
||||||
|
GHOSTS.map((g) => `<option value="${g.id}">${esc(g.name)}${g.is_boss ? ' (boss)' : ''} ${'★'.repeat(g.rarity)}</option>`).join('');
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const g = GHOSTS.find((x) => x.id === +sel.value);
|
||||||
|
if (g) showGhost(g);
|
||||||
|
else clearGhost();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- camera ---------- */
|
||||||
|
let stream = null;
|
||||||
|
let cameraOn = true;
|
||||||
|
async function initCamera() {
|
||||||
|
const video = $('#pv-video');
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play();
|
||||||
|
} catch {
|
||||||
|
$('#pv-hint').textContent = 'Camera unavailable — ghost shown over a dark backdrop.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#pv-camera-toggle').addEventListener('click', () => {
|
||||||
|
cameraOn = !cameraOn;
|
||||||
|
$('#pv-video').style.visibility = cameraOn ? 'visible' : 'hidden';
|
||||||
|
$('#pv-camera-toggle').textContent = `Camera: ${cameraOn ? 'on' : 'off'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- three.js scene ---------- */
|
||||||
|
let scene, camera, renderer, current = null, raf = null;
|
||||||
|
let distance = 3, size = 1.2;
|
||||||
|
|
||||||
|
function initScene() {
|
||||||
|
const canvas = $('#pv-canvas');
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);
|
||||||
|
camera.position.set(0, 0, 0);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.85));
|
||||||
|
const p = new THREE.PointLight(0x88aaff, 1.2, 50);
|
||||||
|
p.position.set(0, 2, 2);
|
||||||
|
scene.add(p);
|
||||||
|
|
||||||
|
addEventListener('resize', () => {
|
||||||
|
camera.aspect = innerWidth / innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a ghost group using the SAME render path as the hunt:
|
||||||
|
// WebM (VP9+alpha) VideoTexture -> GIF/image Texture -> procedural wisp.
|
||||||
|
function buildGhost(data) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const color = TYPE_COLORS[data.type] ?? 0xffffff;
|
||||||
|
let mesh;
|
||||||
|
|
||||||
|
// The admin endpoint (/api/admin/ghosts) returns raw DB rows with
|
||||||
|
// snake_case *_path fields holding bare filenames. Map them to /uploads
|
||||||
|
// URLs. (Also tolerate the public API shape: webm / webp / image.)
|
||||||
|
const up = (f) => (f ? (f.startsWith('/') || f.startsWith('http') ? f : `/uploads/${f}`) : null);
|
||||||
|
const webm = up(data.webm_path || data.webm);
|
||||||
|
const image = up(data.image_path || data.image || data.webp_path || data.webp);
|
||||||
|
|
||||||
|
if (webm && SUPPORTS_WEBM_ALPHA) {
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.crossOrigin = 'anonymous';
|
||||||
|
vid.muted = true; vid.loop = true; vid.playsInline = true; vid.autoplay = true; vid.preload = 'auto';
|
||||||
|
vid.src = webm;
|
||||||
|
const tex = new THREE.VideoTexture(vid);
|
||||||
|
// Desktop Chromium/Gecko only (WebKit excluded above). RGBAFormat keeps
|
||||||
|
// the alpha channel on frame upload; no SRGB override (it can crush the
|
||||||
|
// alpha); premultipliedAlpha off so transparent regions stay see-through.
|
||||||
|
tex.format = THREE.RGBAFormat;
|
||||||
|
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: tex, transparent: true, side: THREE.DoubleSide,
|
||||||
|
depthWrite: false, premultipliedAlpha: false,
|
||||||
|
});
|
||||||
|
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
|
||||||
|
vid.onerror = () => {
|
||||||
|
if (group.userData.fell || !image) return;
|
||||||
|
group.userData.fell = true;
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.crossOrigin = 'anonymous'; img.src = image;
|
||||||
|
const t2 = new THREE.Texture(img);
|
||||||
|
img.onload = () => { t2.needsUpdate = true; };
|
||||||
|
mesh.material.map = t2; mesh.material.needsUpdate = true;
|
||||||
|
group.userData.gifTex = t2; group.userData.gifImg = img; group.userData.vidEl = null;
|
||||||
|
};
|
||||||
|
const pr = vid.play(); if (pr && pr.catch) pr.catch(() => {});
|
||||||
|
group.userData.vidEl = vid;
|
||||||
|
} else if (image) {
|
||||||
|
// Animated WebP (the iOS/WebKit fallback). Three.js can't advance an
|
||||||
|
// animated WebP in a WebGL texture on iOS, so we render it as a real DOM
|
||||||
|
// <img> overlay (which animates natively) and project an invisible 3D
|
||||||
|
// anchor's screen position onto it each frame. The ghost still tracks
|
||||||
|
// distance/size and bobs, and transparency is native to the WebP.
|
||||||
|
const ovl = document.createElement('img');
|
||||||
|
ovl.crossOrigin = 'anonymous'; ovl.src = image;
|
||||||
|
ovl.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:5;transform:translate(-50%,-50%);will-change:left,top,width;';
|
||||||
|
document.body.appendChild(ovl);
|
||||||
|
// invisible anchor mesh so the existing bob/lookAt/position math applies
|
||||||
|
mesh = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(1, 1),
|
||||||
|
new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
);
|
||||||
|
group.userData.overlayImg = ovl;
|
||||||
|
group.userData.overlayAspect = 1;
|
||||||
|
ovl.onload = () => {
|
||||||
|
group.userData.overlayAspect = (ovl.naturalWidth || 1) / (ovl.naturalHeight || 1);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const geo = new THREE.SphereGeometry(0.4, 24, 24);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color, emissive: color, emissiveIntensity: 1.4, roughness: 0.4, transparent: true, opacity: 0.92,
|
||||||
|
});
|
||||||
|
mesh = new THREE.Mesh(geo, mat);
|
||||||
|
const halo = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.55, 24, 24),
|
||||||
|
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.16, side: THREE.BackSide })
|
||||||
|
);
|
||||||
|
group.add(halo);
|
||||||
|
}
|
||||||
|
group.add(mesh);
|
||||||
|
group.userData.mesh = mesh;
|
||||||
|
group.userData.bobPhase = Math.random() * Math.PI * 2;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGhost(data) {
|
||||||
|
clearGhost();
|
||||||
|
current = buildGhost(data);
|
||||||
|
applyTransform();
|
||||||
|
scene.add(current);
|
||||||
|
const hasWebm = !!(data.webm_path || data.webm);
|
||||||
|
const hasImg = !!(data.image_path || data.image || data.webp_path || data.webp);
|
||||||
|
const kind = hasWebm
|
||||||
|
? (SUPPORTS_WEBM_ALPHA ? ' · WebM' : ' · WebP (WebKit fallback)')
|
||||||
|
: hasImg ? ' · image' : ' · procedural wisp';
|
||||||
|
$('#pv-hint').textContent = `${data.name}${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGhost() {
|
||||||
|
if (!current) return;
|
||||||
|
if (current.userData.vidEl) { try { current.userData.vidEl.pause(); current.userData.vidEl.src = ''; } catch {} }
|
||||||
|
if (current.userData.overlayImg) { try { current.userData.overlayImg.remove(); } catch {} }
|
||||||
|
scene.remove(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
if (!current) return;
|
||||||
|
current.position.set(0, 0, -distance);
|
||||||
|
const m = current.userData.mesh;
|
||||||
|
if (m) m.scale.set(size, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#pv-dist').addEventListener('input', (e) => {
|
||||||
|
distance = +e.target.value; $('#pv-dist-val').textContent = distance.toFixed(1); applyTransform();
|
||||||
|
});
|
||||||
|
$('#pv-size').addEventListener('input', (e) => {
|
||||||
|
size = +e.target.value; $('#pv-size-val').textContent = size.toFixed(1); applyTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
const _v = new THREE.Vector3();
|
||||||
|
function loop() {
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
const t = performance.now() / 1000;
|
||||||
|
if (current) {
|
||||||
|
current.position.y = Math.sin(t * 1.5 + current.userData.bobPhase) * 0.06;
|
||||||
|
current.lookAt(camera.position);
|
||||||
|
// Desktop WebM-onerror image fallback (rare): refresh its texture.
|
||||||
|
if (current.userData.gifTex && current.userData.gifImg?.complete) current.userData.gifTex.needsUpdate = true;
|
||||||
|
// iOS WebP overlay: project the invisible anchor to screen space and
|
||||||
|
// position/scale the DOM <img> so it tracks the 3D ghost (and animates).
|
||||||
|
const ud = current.userData;
|
||||||
|
if (ud.overlayImg) {
|
||||||
|
_v.copy(current.position);
|
||||||
|
_v.project(camera);
|
||||||
|
const onScreen = _v.z < 1;
|
||||||
|
if (onScreen) {
|
||||||
|
const sx = (_v.x * 0.5 + 0.5) * innerWidth;
|
||||||
|
const sy = (-_v.y * 0.5 + 0.5) * innerHeight;
|
||||||
|
// size in px: shrink with distance. fov-based scale at the anchor depth.
|
||||||
|
const depth = Math.abs(distance);
|
||||||
|
const worldH = size; // plane is 1 unit * size
|
||||||
|
const vFov = (camera.fov * Math.PI) / 180;
|
||||||
|
const viewH = 2 * Math.tan(vFov / 2) * depth;
|
||||||
|
const px = (worldH / viewH) * innerHeight;
|
||||||
|
ud.overlayImg.style.display = '';
|
||||||
|
ud.overlayImg.style.left = sx + 'px';
|
||||||
|
ud.overlayImg.style.top = sy + 'px';
|
||||||
|
ud.overlayImg.style.height = px + 'px';
|
||||||
|
ud.overlayImg.style.width = px * ud.overlayAspect + 'px';
|
||||||
|
} else {
|
||||||
|
ud.overlayImg.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@ import { dirname, extname, join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { requireAuth } from './auth-middleware.js';
|
import { requireAuth } from './auth-middleware.js';
|
||||||
|
import { convertGhostMp4 } from '../lib/ghost-media.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
|
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
|
||||||
@@ -14,7 +15,8 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth); // everything here requires a valid JWT
|
router.use(requireAuth); // everything here requires a valid JWT
|
||||||
|
|
||||||
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp']);
|
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp', '.webm', '.mp4']);
|
||||||
|
const VIDEO_EXTS = new Set(['.mp4', '.webm']);
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
@@ -24,13 +26,20 @@ const storage = multer.diskStorage({
|
|||||||
});
|
});
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 8 * 1024 * 1024 },
|
limits: { fileSize: 64 * 1024 * 1024 }, // 64MB — source MP4s are larger than GIFs
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const ext = extname(file.originalname).toLowerCase();
|
const ext = extname(file.originalname).toLowerCase();
|
||||||
cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext));
|
cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove an uploaded file by bare filename, ignoring errors.
|
||||||
|
function removeUpload(filename) {
|
||||||
|
if (!filename) return;
|
||||||
|
const p = join(UPLOAD_DIR, filename);
|
||||||
|
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d);
|
const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d);
|
||||||
|
|
||||||
/* ---------------- Ghosts ---------------- */
|
/* ---------------- Ghosts ---------------- */
|
||||||
@@ -87,26 +96,71 @@ router.patch('/ghosts/:id', (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/ghosts/:id/image', upload.single('image'), (req, res) => {
|
router.post('/ghosts/:id/image', upload.single('image'), async (req, res) => {
|
||||||
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
||||||
if (!ghost) return res.status(404).json({ error: 'not found' });
|
if (!ghost) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'no file' });
|
if (req.file) removeUpload(req.file.filename);
|
||||||
// remove old image file if present
|
return res.status(404).json({ error: 'not found' });
|
||||||
if (ghost.image_path) {
|
|
||||||
const old = join(UPLOAD_DIR, ghost.image_path);
|
|
||||||
if (existsSync(old)) try { unlinkSync(old); } catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
db.prepare('UPDATE ghosts SET image_path = ? WHERE id = ?').run(req.file.filename, ghost.id);
|
if (!req.file) return res.status(400).json({ error: 'no file' });
|
||||||
|
|
||||||
|
const ext = extname(req.file.filename).toLowerCase();
|
||||||
|
|
||||||
|
// Clear any previous media (image + video sprites) before recording the new set.
|
||||||
|
const cleanupOld = () => {
|
||||||
|
removeUpload(ghost.image_path);
|
||||||
|
removeUpload(ghost.webm_path);
|
||||||
|
removeUpload(ghost.webp_path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ext === '.mp4') {
|
||||||
|
// Convert the source MP4 to a transparent WebM (VP9+alpha) plus a WebP
|
||||||
|
// fallback via luma keying. The original MP4 is removed afterwards.
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = await convertGhostMp4(UPLOAD_DIR, req.file.filename);
|
||||||
|
} catch (e) {
|
||||||
|
removeUpload(req.file.filename);
|
||||||
|
return res.status(500).json({ error: 'conversion failed', detail: e.message });
|
||||||
|
}
|
||||||
|
removeUpload(req.file.filename); // discard the raw mp4
|
||||||
|
if (!out.webm && !out.webp) {
|
||||||
|
return res.status(500).json({ error: 'conversion produced no output (is ffmpeg installed?)' });
|
||||||
|
}
|
||||||
|
cleanupOld();
|
||||||
|
// webp doubles as the still/thumbnail image where present.
|
||||||
|
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = ?, image_path = ? WHERE id = ?')
|
||||||
|
.run(out.webm, out.webp, out.webp, ghost.id);
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
webm: out.webm ? `/uploads/${out.webm}` : null,
|
||||||
|
webp: out.webp ? `/uploads/${out.webp}` : null,
|
||||||
|
image: out.webp ? `/uploads/${out.webp}` : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext === '.webm') {
|
||||||
|
// Pre-made transparent WebM uploaded directly — store as-is.
|
||||||
|
cleanupOld();
|
||||||
|
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = NULL, image_path = NULL WHERE id = ?')
|
||||||
|
.run(req.file.filename, ghost.id);
|
||||||
|
return res.json({ ok: true, webm: `/uploads/${req.file.filename}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain image (gif/png/jpg/webp) — the original billboard path. Clear any
|
||||||
|
// previous video sprites so the ghost falls back cleanly to the image.
|
||||||
|
cleanupOld();
|
||||||
|
db.prepare('UPDATE ghosts SET image_path = ?, webm_path = NULL, webp_path = NULL WHERE id = ?')
|
||||||
|
.run(req.file.filename, ghost.id);
|
||||||
res.json({ ok: true, image: `/uploads/${req.file.filename}` });
|
res.json({ ok: true, image: `/uploads/${req.file.filename}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/ghosts/:id', (req, res) => {
|
router.delete('/ghosts/:id', (req, res) => {
|
||||||
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
||||||
if (!ghost) return res.status(404).json({ error: 'not found' });
|
if (!ghost) return res.status(404).json({ error: 'not found' });
|
||||||
if (ghost.image_path) {
|
removeUpload(ghost.image_path);
|
||||||
const p = join(UPLOAD_DIR, ghost.image_path);
|
removeUpload(ghost.webm_path);
|
||||||
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
|
removeUpload(ghost.webp_path);
|
||||||
}
|
|
||||||
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
|
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function rowToGhost(g) {
|
|||||||
setNumber: g.set_number,
|
setNumber: g.set_number,
|
||||||
setName: g.set_name,
|
setName: g.set_name,
|
||||||
image: g.image_path ? `/uploads/${g.image_path}` : null,
|
image: g.image_path ? `/uploads/${g.image_path}` : null,
|
||||||
|
webm: g.webm_path ? `/uploads/${g.webm_path}` : null,
|
||||||
|
webp: g.webp_path ? `/uploads/${g.webp_path}` : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,9 @@ router.get('/scan/:code', (req, res) => {
|
|||||||
router.get('/freehunt', (req, res) => {
|
router.get('/freehunt', (req, res) => {
|
||||||
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
|
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
|
||||||
const type = req.query.type; // optional red|yellow|blue filter
|
const type = req.query.type; // optional red|yellow|blue filter
|
||||||
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0';
|
// Free hunt only spawns ghosts with an uploaded video (webm); the webp/image
|
||||||
|
// fallbacks are derived from it, so these always render on every platform.
|
||||||
|
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0 AND webm_path IS NOT NULL';
|
||||||
const params = [];
|
const params = [];
|
||||||
if (type && ['red', 'yellow', 'blue'].includes(type)) {
|
if (type && ['red', 'yellow', 'blue'].includes(type)) {
|
||||||
q += ' AND type = ?';
|
q += ' AND type = ?';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* routes/hunt-log.js — receives hunt telemetry from the client and appends it
|
||||||
|
* to a daily log file on the server. Debug aid: confirms spawns / detections /
|
||||||
|
* captures are firing server-side even when something looks wrong on-device.
|
||||||
|
*
|
||||||
|
* Wire in server.js:
|
||||||
|
* import huntLogRoutes from './routes/hunt-log.js';
|
||||||
|
* app.use('/api/hunt-log', huntLogRoutes);
|
||||||
|
*
|
||||||
|
* Log file: logs/hunt-YYYY-MM-DD.log (one JSON object per line)
|
||||||
|
*/
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { appendFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LOG_DIR = join(__dirname, '..', 'logs');
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Accept a single event or a batch. Keep it small + permissive.
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const events = Array.isArray(body.events) ? body.events : [body];
|
||||||
|
const day = new Date().toISOString().slice(0, 10);
|
||||||
|
const file = join(LOG_DIR, `hunt-${day}.log`);
|
||||||
|
|
||||||
|
await mkdir(LOG_DIR, { recursive: true });
|
||||||
|
const lines = events.map(e => JSON.stringify({
|
||||||
|
t: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
type: e.type || 'unknown',
|
||||||
|
ghost: e.ghost || null,
|
||||||
|
color: e.color || null,
|
||||||
|
rarity: e.rarity || null,
|
||||||
|
haunt: typeof e.haunt === 'number' ? Math.round(e.haunt) : null,
|
||||||
|
pos: e.pos || null, // {x,y,z} spawn position when present
|
||||||
|
session: e.session || null, // client session id
|
||||||
|
})).join('\n') + '\n';
|
||||||
|
|
||||||
|
await appendFile(file, lines, 'utf8');
|
||||||
|
res.json({ ok: true, logged: events.length });
|
||||||
|
} catch (err) {
|
||||||
|
// never let logging break the hunt
|
||||||
|
console.error('hunt-log error:', err.message);
|
||||||
|
res.status(200).json({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -35,7 +35,23 @@ app.get('/admin', (_req, res) => {
|
|||||||
res.sendFile(join(__dirname, 'public', 'admin.html'));
|
res.sendFile(join(__dirname, 'public', 'admin.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ghost preview page (admin-gated client-side; ghost list requires a JWT).
|
||||||
|
app.get('/preview', (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, 'public', 'preview.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// JSON error handler (multer + thrown errors).
|
// JSON error handler (multer + thrown errors).
|
||||||
|
|
||||||
|
// Hunt page (Phase 3 AR ghost hunt; standalone, no auth).
|
||||||
|
app.get('/hunt', (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, 'public', 'hunt.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landing / launch page.
|
||||||
|
app.get('/play', (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, 'public', 'play.html'));
|
||||||
|
});
|
||||||
|
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(err.status || 500).json({ error: err.message || 'server error' });
|
res.status(err.status || 500).json({ error: err.message || 'server error' });
|
||||||
|
|||||||