Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 { 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>
|
||||
</div>
|
||||
<div class="image-row">
|
||||
<label>Billboard image (GIF / PNG)</label>
|
||||
<label>Billboard (GIF / PNG / WebP, or MP4 / WebM video)</label>
|
||||
<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="" />
|
||||
<video id="gm-preview-vid" class="gm-preview hidden" muted loop playsinline></video>
|
||||
</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 class="modal-actions">
|
||||
<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); }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.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; }
|
||||
.set-card { border: 1px solid var(--line); border-radius: var(--rad); padding: 16px; background: var(--panel); }
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
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 = `
|
||||
precision mediump float;
|
||||
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);
|
||||
}
|
||||
`;
|
||||
|
||||
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: radius shrinks toward the tail (top in UV space = head)
|
||||
const geo = new THREE.CylinderGeometry(0.0, 0.5, 1.2, 18, 8, true);
|
||||
geo.translate(0, 0.1, 0);
|
||||
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: wisp body + gradient material ---
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uTop: { value: hexToRGB(THREE, grad.top) },
|
||||
uBottom: { value: hexToRGB(THREE, grad.bottom) },
|
||||
uAlpha: { value: 1.0 },
|
||||
};
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: WISP_VERT,
|
||||
fragmentShader: WISP_FRAG,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.NormalBlending,
|
||||
});
|
||||
const body = new THREE.Mesh(buildWispGeometry(THREE), mat);
|
||||
group.add(body);
|
||||
|
||||
// --- 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;
|
||||
if (textures.glow) addFxBillboard(textures.glow, 1.6, 0.1, 0.5);
|
||||
if (textures.trail) addFxBillboard(textures.trail, 1.2, -0.4, 0.35);
|
||||
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;
|
||||
// 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);
|
||||
fxSprites.forEach(s => s.material.color.set(g2.top));
|
||||
},
|
||||
dispose() {
|
||||
body.geometry?.dispose?.(); mat.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,332 @@
|
||||
<!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</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; } }
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 { createGhostVisual } from '/ghost-visual.js';
|
||||
import { createHunt } from '/hunt.js';
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
let roster = [];
|
||||
try {
|
||||
roster = await (await fetch('/ghosts.enriched.json')).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));
|
||||
|
||||
addEventListener('resize', () => {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
const hunt = createHunt(THREE, scene, {
|
||||
roster, fx: true,
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'capture') toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`);
|
||||
if (e.type === 'flee') toast(`<span class="nm">${e.ghost.name}</span> slipped away`);
|
||||
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
|
||||
score = scoreFromCaptures();
|
||||
$('#caught').textContent = hunt.captured.length;
|
||||
$('#score').textContent = score;
|
||||
}
|
||||
}
|
||||
});
|
||||
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){}
|
||||
}
|
||||
gyroQuat = new THREE.Quaternion();
|
||||
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)=>{
|
||||
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);
|
||||
beginLoop();
|
||||
}
|
||||
function hideStart(){ $('#start').style.display='none'; }
|
||||
|
||||
$('#go-ar').addEventListener('click', startAR);
|
||||
$('#go-gyro').addEventListener('click', startGyro);
|
||||
|
||||
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 && gyroQuat) camera.quaternion.copy(gyroQuat);
|
||||
hunt.update(dt, t);
|
||||
$('#reticle').classList.toggle('locked', !!currentTarget());
|
||||
paintHaunt();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
function beginLoop(){ renderer.setAnimationLoop(frame); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 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 placeInArc(obj) {
|
||||
const half = (spawnArcDeg * Math.PI / 180) / 2;
|
||||
const yaw = (Math.random() * 2 - 1) * half; // forward-facing arc
|
||||
const pitch = (Math.random() * 0.5 - 0.15); // slightly varied height
|
||||
const dist = 2.2 + Math.random() * 1.8; // 2.2-4m out
|
||||
obj.position.set(
|
||||
Math.sin(yaw) * dist,
|
||||
0.2 + Math.sin(pitch) * 1.2,
|
||||
-Math.cos(yaw) * dist
|
||||
);
|
||||
}
|
||||
|
||||
function spawn() {
|
||||
if (active.size >= maxActive) return;
|
||||
const g = pickGhost();
|
||||
const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false });
|
||||
placeInArc(visual.object3d);
|
||||
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
|
||||
active.set(id, {
|
||||
ghost: g, visual, hp, maxHp: hp,
|
||||
state: 'idle',
|
||||
fleeAt: performance.now() + (fleeMinMs + Math.random() * (fleeMaxMs - fleeMinMs)), // escapes if ignored
|
||||
});
|
||||
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) {
|
||||
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) {
|
||||
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}` : '—';
|
||||
return `<tr>
|
||||
<td class="thumb-cell">${img}</td>
|
||||
@@ -134,12 +138,45 @@ function openGhost(id) {
|
||||
$('#gm-boss').checked = !!g?.is_boss;
|
||||
$('#gm-enabled').checked = g ? !!g.enabled : true;
|
||||
$('#gm-file').value = '';
|
||||
const prev = $('#gm-preview');
|
||||
if (g?.image_path) { prev.src = `/uploads/${g.image_path}`; prev.classList.remove('hidden'); }
|
||||
else prev.classList.add('hidden');
|
||||
// Show the existing stored media: prefer the WebM video, else a still image
|
||||
// (image_path is the GIF/PNG, or the WebP thumbnail for converted ghosts).
|
||||
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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
async function saveGhost() {
|
||||
|
||||
@@ -118,6 +118,17 @@ async function resolveCode(code) {
|
||||
============================================================ */
|
||||
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 = {
|
||||
running: false,
|
||||
scene: null, camera: null, renderer: null, raf: null,
|
||||
@@ -289,7 +300,46 @@ const hunt = {
|
||||
|
||||
let mesh;
|
||||
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
|
||||
const img = document.createElement('img');
|
||||
img.crossOrigin = 'anonymous';
|
||||
@@ -444,6 +494,7 @@ const hunt = {
|
||||
captureTarget() {
|
||||
const g = this.target;
|
||||
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.ghosts = this.ghosts.filter((x) => x !== g);
|
||||
this.target = null;
|
||||
@@ -543,6 +594,9 @@ const hunt = {
|
||||
removeEventListener('mouseup', this._blastEls.endBlast);
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 db from '../db/index.js';
|
||||
import { requireAuth } from './auth-middleware.js';
|
||||
import { convertGhostMp4 } from '../lib/ghost-media.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
|
||||
@@ -14,7 +15,8 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const router = Router();
|
||||
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({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
@@ -24,13 +26,20 @@ const storage = multer.diskStorage({
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
limits: { fileSize: 64 * 1024 * 1024 }, // 64MB — source MP4s are larger than GIFs
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
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);
|
||||
|
||||
/* ---------------- Ghosts ---------------- */
|
||||
@@ -87,26 +96,71 @@ router.patch('/ghosts/:id', (req, res) => {
|
||||
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);
|
||||
if (!ghost) return res.status(404).json({ error: 'not found' });
|
||||
if (!req.file) return res.status(400).json({ error: 'no file' });
|
||||
// remove old image file if present
|
||||
if (ghost.image_path) {
|
||||
const old = join(UPLOAD_DIR, ghost.image_path);
|
||||
if (existsSync(old)) try { unlinkSync(old); } catch { /* ignore */ }
|
||||
if (!ghost) {
|
||||
if (req.file) removeUpload(req.file.filename);
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
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}` });
|
||||
});
|
||||
|
||||
router.delete('/ghosts/:id', (req, res) => {
|
||||
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.image_path) {
|
||||
const p = join(UPLOAD_DIR, ghost.image_path);
|
||||
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
|
||||
}
|
||||
removeUpload(ghost.image_path);
|
||||
removeUpload(ghost.webm_path);
|
||||
removeUpload(ghost.webp_path);
|
||||
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ function rowToGhost(g) {
|
||||
setNumber: g.set_number,
|
||||
setName: g.set_name,
|
||||
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) => {
|
||||
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
|
||||
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 = [];
|
||||
if (type && ['red', 'yellow', 'blue'].includes(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'));
|
||||
});
|
||||
|
||||
// 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).
|
||||
|
||||
// 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) => {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ error: err.message || 'server error' });
|
||||
|
||||