Compare commits

25 Commits

Author SHA1 Message Date
jessikitty 802ae7fab9 Phase 3 update: orbit ghosts, parallax, FX, shader fix [build v8] 2026-06-23 14:53:36 +10:00
jessikitty 70dc130647 Phase 3: AR hunt /hunt + launch page /play - shared-visual ghosts, colour lure, hauntometer 2026-06-23 12:06:36 +10:00
jessikitty 6aae22b648 Phase 3: AR hunt /hunt + launch page /play - shared-visual ghosts, colour lure, hauntometer 2026-06-23 11:33:00 +10:00
jessikitty a6c45437c3 Phase 3: AR hunt /hunt + launch page /play - shared-visual ghosts, colour lure, hauntometer 2026-06-23 11:19:47 +10:00
jessikitty 6a5fb9d8c6 Phase 3: AR hunt /hunt + launch page /play - shared-visual ghosts, colour lure, hauntometer 2026-06-23 11:12:44 +10:00
jessikitty efadc85195 Free hunt: only spawn ghosts that have an uploaded video
Add `AND webm_path IS NOT NULL` to the /api/freehunt pool so free-hunt
mode only selects ghosts with real media (and their derived webp/image
fallbacks), never bare procedural-wisp ghosts.
2026-06-22 10:37:52 +10:00
jessikitty 653980a52e iOS WebP: render as tracked DOM img overlay instead of WebGL texture
iOS won't animate a WebP inside a WebGL texture in any form. Render the
iOS/WebKit fallback as a real DOM <img> (animates natively) and project an
invisible 3D anchor's screen position onto it each frame, so it still tracks
distance/size and bobs. Flat 2D rather than in-scene, but animated + transparent.
2026-06-22 09:59:35 +10:00
jessikitty 25832dd58f Keep WebP animating on iOS by attaching the source img to the DOM
iOS pauses animation on a detached <img>, so the CanvasTexture sampled a
frozen frame. Attach the img hidden off-screen (opacity 0.01) so WebKit
keeps advancing the WebP; remove it on clear.
2026-06-22 09:53:36 +10:00
jessikitty 4cf8ec07a4 Animate WebP fallback on iOS via per-frame CanvasTexture
Three.js doesn't advance an animated-image Texture on iOS, so the WebP
showed as a static frame. Draw the animating <img> into an offscreen
canvas each frame and sample it through a CanvasTexture, keeping the ghost
in 3D (bob/scale/lookAt) and animated, with transparency preserved.
2026-06-22 09:48:17 +10:00
jessikitty 793a17dbdf Route iOS/WebKit to transparent WebP instead of opaque VP9 WebM
WebKit (all iOS browsers + desktop Safari) plays VP9 WebM but renders its
alpha as opaque black. SUPPORTS_WEBM_ALPHA now excludes iOS/Safari so those
devices fall through to the animated WebP fallback, which IS transparent.
Desktop Chromium/Gecko keep the WebM path.
2026-06-19 22:38:53 +10:00
jessikitty 6f8b67b583 Fix VP9-alpha transparency: drop sRGB on video texture, premultipliedAlpha off
The WebM carries real alpha (alpha_mode=1) but rendered opaque because the
VideoTexture was forced to SRGBColorSpace and the material assumed
premultiplied alpha, crushing transparent regions to black. Removing the
colorspace override and setting premultipliedAlpha:false keys the black out.
2026-06-19 22:00:03 +10:00
jessikitty b2a863bd80 Fix preview: map admin raw-row *_path fields to /uploads URLs
The /api/admin/ghosts endpoint returns raw DB rows (webm_path,
webp_path, image_path as bare filenames), not the public API's
camelCase URL shape. buildGhost now reads those and prefixes /uploads/,
so WebM video renders in the preview instead of falling through to the
procedural wisp.
2026-06-19 21:49:58 +10:00
jessikitty 2d66d809d4 Add /preview route serving the ghost preview page 2026-06-19 15:26:14 +10:00
jessikitty e81f779ea5 Fix preview.html encoding (store raw HTML, not base64) 2026-06-19 15:22:33 +10:00
jessikitty a8552592c7 Add admin-gated ghost preview page (/preview)
Camera-background single-ghost preview reusing the hunt's render path
(WebM VP9+alpha VideoTexture -> WebP/GIF -> procedural wisp). Login gate
matches admin; ghost list via JWT-protected /api/admin/ghosts. Dropdown
to pick a ghost, sliders for distance/size, camera toggle.
2026-06-19 15:20:17 +10:00
jessikitty 458c66a2c0 Fix: decode base64-corrupted source files (html/css/js + backend) 2026-06-19 05:06:43 +00:00
jessikitty 327b37babb Fix: decode base64-corrupted admin html/css/js 2026-06-19 01:21:26 +00:00
jessikitty 3fa50c4c2f Admin UI: style video row thumbnails like image thumbnails
Add .thumb-cell video to the existing .thumb-cell img rule so WebM-only
ghost thumbnails render at the same 34px size.
2026-06-19 09:54:25 +10:00
jessikitty 8e8e259b4c Admin UI: video-aware preview + table thumbnails
- openGhost() previews stored media, preferring WebM video over still image
- showPreview/hidePreview swap between <img> and <video> elements
- Live local preview on file pick (handles mp4/webm)
- Ghost-table row thumbnail renders <video> when only a WebM exists
2026-06-19 09:52:24 +10:00
jessikitty 2fe4d67518 Admin UI: allow MP4/WebM in billboard upload + video preview element
- Widen file input accept to include .webm/.mp4
- Add a <video> preview element alongside the <img> preview
- Update label and help text to mention server-side MP4 conversion
2026-06-19 09:46:29 +10:00
jessikitty 0c5123e3a6 Admin: accept mp4/webm uploads and auto-convert mp4 to transparent webm+webp
- Allow .mp4/.webm in addition to image types; raise upload limit to 64MB
- MP4 uploads are luma-keyed to a VP9+alpha WebM plus an animated WebP
  fallback via lib/ghost-media.js; the raw MP4 is discarded
- Pre-made .webm uploads are stored directly
- All prior media (image/webm/webp) is cleaned up on replace and on delete
- WebP doubles as the still thumbnail for converted ghosts
2026-06-18 14:27:50 +10:00
jessikitty dc5e032b3a Emit webm and webp URLs in public ghost objects
rowToGhost now returns webm/webp alongside image, so the client renderer can
select the VP9+alpha video billboard (with WebP/GIF fallback).
2026-06-18 14:22:50 +10:00
jessikitty 22983caa18 Add webm_path and webp_path columns to ghosts (idempotent migration)
ALTER TABLE guarded by a column-existence check so existing production
databases gain the columns without data loss.
2026-06-18 14:20:50 +10:00
jessikitty 9866e44445 Add server-side ghost media converter (mp4 -> transparent webm + webp)
Shells out to system ffmpeg with the same luma-key pipeline as ghostify.sh.
Produces a VP9+alpha WebM (browser-decoded primary) and an animated WebP
fallback. No new npm dependency; fails gracefully if ffmpeg is absent.
2026-06-18 14:18:50 +10:00
jessikitty ec4442d4ce Add WebM (VP9+alpha) VideoTexture ghost billboards with GIF/WebP fallback
- Detect VP9-alpha WebM support once at load; iOS Safari falls back to <img>
- addGhost prefers data.webm via THREE.VideoTexture (browser-decoded, no
  per-frame needsUpdate pump) when supported
- On video error, gracefully swap the billboard to the GIF/image texture
- Pause + release ghost <video> elements on capture and on hunt teardown to
  avoid leaking decoders
2026-06-18 13:15:54 +10:00
65 changed files with 12169 additions and 24 deletions
+14
View File
@@ -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 };
+111
View File
@@ -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;
}
+1496
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -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>
+1 -1
View File
@@ -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); }
+213
View File
@@ -0,0 +1,213 @@
/**
* 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 = `
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-ish body: rounded head (sphere-like top) tapering to a wispy tail.
// Lathe a profile curve so it reads as a ghost rather than a hard cone.
const pts = [];
const N = 12;
for (let i = 0; i <= N; i++) {
const t = i / N; // 0 = tail bottom, 1 = head top
const y = -0.6 + t * 1.2; // height -0.6 .. 0.6
// radius: thin pointed tail, bulging rounded head
const r = Math.sin(t * Math.PI * 0.92) * 0.42 + t * 0.06;
pts.push(new THREE.Vector2(Math.max(0.001, r), y));
}
const geo = new THREE.LatheGeometry(pts, 20);
return geo;
}
export function createGhostVisual(THREE, opts = {}) {
const color = opts.color && GHOST_GRADIENTS[opts.color] ? opts.color : 'Blue';
const grad = GHOST_GRADIENTS[color];
const wantFx = opts.fx !== false;
const group = new THREE.Group();
group.name = `ghost-${color}`;
// --- Tier 0: 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);
// --- always-on soft glow halo (procedural, no texture needed) so the ghost
// never looks like a bare cone even before FX textures attach ---
const haloMat = new THREE.SpriteMaterial({
color: hexToRGB(THREE, grad.bottom),
transparent: true, opacity: 0.35, depthWrite: false,
blending: THREE.AdditiveBlending,
});
const halo = new THREE.Sprite(haloMat);
halo.scale.setScalar(1.5);
halo.position.y = 0.1;
group.add(halo);
// --- Tier 1: FX billboards (progressive enhancement) ---
const fxSprites = [];
function addFxBillboard(texture, scale, yOffset, opacity, blend) {
const m = new THREE.SpriteMaterial({
map: texture,
color: new THREE.Color(grad.top),
transparent: true,
opacity,
depthWrite: false,
blending: blend ?? THREE.AdditiveBlending,
});
const s = new THREE.Sprite(m);
s.scale.setScalar(scale);
s.position.y = yOffset;
group.add(s);
fxSprites.push(s);
return s;
}
// caller passes a loaded-texture map when fx enabled; see attachFx()
let fxAttached = false;
function attachFx(textures) {
if (!wantFx || fxAttached || !textures) return;
// give the procedural halo a real soft-glow texture if provided
if (textures.glow) { haloMat.map = textures.glow; haloMat.opacity = 0.55; haloMat.needsUpdate = true; }
if (textures.trail) addFxBillboard(textures.trail, 1.0, -0.45, 0.4);
if (textures.smoke) addFxBillboard(textures.smoke, 0.8, 0.3, 0.25);
fxAttached = true;
}
// --- optional special mesh override (Ghost_Baseball / Ghost_Bat) ---
function useSpecialMesh(mesh) {
if (!mesh) return;
body.visible = false;
mesh.traverse?.(o => {
if (o.isMesh && o.material) {
o.material.color = new THREE.Color(grad.top);
}
});
group.add(mesh);
}
// --- spawn / capture animation helpers ---
let state = 'idle';
let stateT = 0;
function setState(s) { state = s; stateT = 0; }
function update(dt, elapsed) {
uniforms.uTime.value = elapsed;
stateT += dt;
// bob: offset the BODY around the group, so the group's spawn position
// stays fixed (previously this accumulated onto group.y and the ghost
// drifted up out of view every frame).
body.position.y = Math.sin(elapsed * 1.8) * 0.08;
halo.position.y = 0.1 + Math.sin(elapsed * 1.8) * 0.08; // halo bobs with body
// billboard FX face camera handled by Sprite automatically
if (state === 'spawn') {
const k = Math.min(stateT / 0.6, 1);
group.scale.setScalar(k);
uniforms.uAlpha.value = k;
if (k >= 1) setState('idle');
} else if (state === 'capture') {
const k = Math.min(stateT / 0.5, 1);
group.scale.setScalar(1 - k);
uniforms.uAlpha.value = 1 - k;
}
}
// start hidden, play spawn
group.scale.setScalar(0);
setState('spawn');
return {
object3d: group,
color,
update,
setState, // 'idle' | 'spawn' | 'capture'
attachFx, // call with {glow, trail, smoke} THREE.Textures
useSpecialMesh, // call with a loaded Ghost_Baseball/Bat mesh
setColor(next) {
const g2 = GHOST_GRADIENTS[next]; if (!g2) return;
uniforms.uTop.value.set(g2.top);
uniforms.uBottom.value.set(g2.bottom);
haloMat.color.set(g2.bottom);
fxSprites.forEach(s => s.material.color.set(g2.top));
},
dispose() {
body.geometry?.dispose?.(); mat.dispose?.(); haloMat.dispose?.();
fxSprites.forEach(s => s.material?.map?.dispose?.());
},
};
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because it is too large Load Diff
+767
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+605
View File
@@ -0,0 +1,605 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Newbury Nights - Hunt Demo</title>
<style>
:root {
--ink: #0a0c14;
--ink-2: #11131f;
--mist: #c7ccd9;
--mist-dim: #6b7184;
--red: #FF2678;
--red-top: #F65151;
--yellow: #FFF35D;
--yellow-top: #B57F0B;
--blue: #51EAF1;
--blue-top: #529EFF;
--haze: rgba(199,204,217,0.08);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: var(--ink); color: var(--mist);
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
user-select: none;
}
#scene, #cam { position: fixed; inset: 0; width: 100%; height: 100%; }
#cam { object-fit: cover; z-index: 0; background: #000; }
#scene { z-index: 1; }
canvas { display: block; }
#hud { position: fixed; inset: 0; z-index: 3; pointer-events: none; }
.pointer { pointer-events: auto; }
#haunt {
position: fixed; left: 0; top: 0; bottom: 0; width: 8px; z-index: 4;
}
#haunt-fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0%;
background: linear-gradient(0deg, var(--blue), #fff2);
box-shadow: 0 0 18px var(--blue); transition: height 0.4s ease, background 0.6s, box-shadow 0.6s;
}
#haunt-label {
position: fixed; left: 14px; top: 14px; z-index: 5;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--mist-dim);
}
#haunt-pct { color: var(--mist); font-size: 13px; }
#readout {
position: fixed; right: 14px; top: 14px; z-index: 5; text-align: right;
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mist-dim);
}
#readout b { color: var(--mist); font-size: 15px; letter-spacing: 0; }
#reticle {
position: fixed; left: 50%; top: 50%; width: 64px; height: 64px;
transform: translate(-50%, -50%); z-index: 4; pointer-events: none;
}
#reticle .ring {
position: absolute; inset: 0; border: 1.5px solid var(--haze); border-radius: 50%;
}
#reticle .charge {
position: absolute; inset: 4px; border-radius: 50%;
background: conic-gradient(var(--blue) 0deg, transparent 0deg);
opacity: 0.85;
}
#reticle .dot { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px;
transform: translate(-50%,-50%); background: var(--mist); border-radius: 50%; }
#reticle.locked .ring { border-color: var(--mist); }
#wheel {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
z-index: 5; display: flex; gap: 14px; pointer-events: auto;
}
.lure {
width: 52px; height: 52px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.14);
background: var(--c); position: relative; cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s, border-color 0.2s; opacity: 0.55;
}
.lure[data-on="1"] { opacity: 1; transform: scale(1.12);
border-color: #fff; box-shadow: 0 0 22px var(--c); }
.lure.red { --c: var(--red); }
.lure.blue { --c: var(--blue); }
.lure.yellow { --c: var(--yellow); }
#wheel-label { position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
z-index: 5; font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--mist-dim); }
#fire {
position: fixed; right: 22px; bottom: 30px; z-index: 6;
width: 84px; height: 84px; border-radius: 50%;
background: radial-gradient(circle at 50% 40%, var(--ink-2), #05060c);
border: 2px solid rgba(255,255,255,0.16); color: var(--mist);
font: inherit; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
pointer-events: auto; cursor: pointer; touch-action: none;
}
#fire:active { border-color: #fff; }
#toasts { position: fixed; left: 50%; top: 64px; transform: translateX(-50%);
z-index: 6; display: flex; flex-direction: column; gap: 6px; align-items: center; }
.toast { font-size: 12px; letter-spacing: 0.06em; padding: 6px 12px;
background: rgba(10,12,20,0.7); border: 1px solid var(--haze); border-radius: 2px;
color: var(--mist); animation: rise 2.4s forwards; }
.toast .nm { color: #fff; }
@keyframes rise { 0% { opacity: 0; transform: translateY(6px);} 12%{opacity:1;transform:none;}
80%{opacity:1;} 100%{opacity:0;} }
#start { position: fixed; inset: 0; z-index: 10; background:
radial-gradient(120% 80% at 50% 0%, #14182a 0%, var(--ink) 60%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px; }
#start h1 { font-size: clamp(30px, 9vw, 52px); margin: 0; letter-spacing: -0.01em;
font-weight: 600; color: #fff; line-height: 0.98; }
#start h1 .n2 { display: block; color: var(--blue); font-style: italic; }
#start p { max-width: 30ch; color: var(--mist-dim); font-size: 13px; line-height: 1.6;
letter-spacing: 0.02em; }
#start .sub { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--mist-dim); margin-bottom: 18px; }
.btn { margin-top: 22px; pointer-events: auto; cursor: pointer; font: inherit;
font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase;
padding: 14px 28px; color: var(--ink); background: var(--mist);
border: none; border-radius: 2px; }
.btn.ghost { background: transparent; color: var(--mist); border: 1px solid var(--haze);
margin-top: 10px; }
#note { position: fixed; bottom: 8px; left: 0; right: 0; text-align: center; z-index: 10;
font-size: 9px; letter-spacing: 0.14em; color: #3a3f4f; }
@media (prefers-reduced-motion: reduce) { .toast { animation-duration: 0.1s; } }
/* ---- debug overlay ---- */
#dbg-toggle { position: fixed; left: 14px; bottom: 14px; z-index: 7; pointer-events: auto;
font: inherit; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
background: rgba(10,12,20,0.7); color: var(--mist-dim); border: 1px solid var(--haze);
border-radius: 2px; padding: 8px 10px; cursor: pointer; }
#dbg { position: fixed; left: 14px; bottom: 52px; z-index: 7; width: min(320px, 86vw);
max-height: 46vh; background: rgba(8,10,16,0.92); border: 1px solid var(--haze);
border-radius: 4px; padding: 10px; display: none; pointer-events: auto;
font-size: 10px; line-height: 1.5; color: var(--mist); }
#dbg.open { display: block; }
#dbg .row { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 6px;
color: var(--mist-dim); }
#dbg .row b { color: var(--mist); }
#dbg .ctl { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
#dbg .ctl button { font: inherit; font-size: 12px; width: 24px; height: 24px;
background: var(--ink-2); color: var(--mist); border: 1px solid var(--haze);
border-radius: 2px; cursor: pointer; }
#dbg-log { max-height: 28vh; overflow-y: auto; border-top: 1px solid var(--haze);
padding-top: 6px; font-family: ui-monospace, monospace; }
#dbg-log div { white-space: nowrap; }
#dbg-log .spawn { color: var(--yellow); }
#dbg-log .detect { color: var(--blue); }
#dbg-log .capture { color: #7CFFA0; }
#dbg-log .flee { color: var(--red); }
/* off-screen direction arrow */
#arrow { position: fixed; z-index: 6; width: 0; height: 0; pointer-events: none;
display: none; filter: drop-shadow(0 0 6px var(--c, var(--blue))); }
</style>
</head>
<body>
<video id="cam" autoplay muted playsinline></video>
<div id="scene"></div>
<div id="hud">
<div id="haunt"><div id="haunt-fill"></div></div>
<div id="haunt-label">Haunt <span id="haunt-pct">0%</span></div>
<div id="readout"><b id="caught">0</b><br>caught &middot; <span id="score">0</span> pts</div>
<div id="reticle"><div class="ring"></div><div class="charge"></div><div class="dot"></div></div>
<div id="wheel-label">Lure</div>
<div id="wheel" class="pointer">
<div class="lure red" data-color="Red"></div>
<div class="lure blue" data-color="Blue"></div>
<div class="lure yellow" data-color="Yellow"></div>
</div>
<button id="fire">Hold<br>to charge</button>
<div id="toasts"></div>
</div>
<!-- off-screen ghost direction arrow -->
<svg id="arrow" width="40" height="40" viewBox="0 0 40 40"><path d="M20 4 L34 32 L20 24 L6 32 Z" fill="var(--c, #51EAF1)"/></svg>
<!-- debug overlay -->
<button id="dbg-toggle">Debug</button>
<div id="dbg">
<div class="row"><span>active / max</span><b><span id="dbg-active">0</span> / <span id="dbg-max">4</span></b></div>
<div class="ctl"><span style="color:var(--mist-dim)">max active</span>
<button id="dbg-minus">-</button><b id="dbg-maxval">4</b><button id="dbg-plus">+</button>
<label style="margin-left:auto;color:var(--mist-dim)"><input type="checkbox" id="dbg-beacon" checked> beacons</label>
</div>
<div class="row"><span>haunt</span><b id="dbg-haunt">0%</b></div>
<div class="row"><span>server log</span><b id="dbg-srv">off</b></div>
<div id="dbg-log"></div>
</div>
<div id="start">
<div class="sub">A spiritual successor - fan tribute</div>
<h1>Newbury<span class="n2">Nights</span></h1>
<p>Raise your phone to the room. Spirits gather in the dark - pick a lure colour,
aim, and charge your shot to catch them before the haunt overtakes the night.</p>
<button class="btn" id="go-ar">Begin hunt (AR)</button>
<button class="btn ghost" id="go-gyro">No AR - use motion</button>
</div>
<div id="note">Fan-made tribute. Not affiliated with or endorsed by the LEGO Group.</div>
<div id="ver" style="position:fixed;right:6px;bottom:6px;z-index:11;font-size:9px;color:#2e3340;letter-spacing:0.1em;">build v8</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?v=8';
import { createHunt } from './hunt.js?v=8';
console.log('%cNewbury Nights hunt — build v8 loaded (orbit ghosts + parallax)', 'color:#51EAF1');
const $ = s => document.querySelector(s);
let roster = [];
try {
roster = await (await fetch('./ghosts.enriched.json?v=8')).json();
} catch (e) {
roster = [
{ id:'basic-r', name:'Wisp', color:'Red', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-b', name:'Wisp', color:'Blue', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
{ id:'basic-y', name:'Wisp', color:'Yellow', rarity:'Common', rarityTier:1, chargePips:2, healthMax:263 },
];
}
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
$('#scene').appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 50);
scene.add(new THREE.HemisphereLight(0x8899ff, 0x111122, 1.1));
// ---- load FX textures (glow/trail/smoke) so ghosts look wispy, not like cones ----
const texLoader = new THREE.TextureLoader();
function loadTex(path){
return new Promise(res => texLoader.load(path, t => res(t), undefined, () => res(null)));
}
const fxTextures = {
glow: await loadTex('./ghosts/fx/FX_Glow.png?v=8'),
trail: await loadTex('./ghosts/fx/FX_WispyTrail2.png?v=8'),
smoke: await loadTex('./ghosts/fx/smokepuff1.png?v=8'),
};
console.log('FX textures loaded:', Object.fromEntries(Object.entries(fxTextures).map(([k,v])=>[k, !!v])));
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
});
const hunt = createHunt(THREE, scene, {
roster, fx: true, fxTextures,
onEvent: (e) => {
if (e.type === 'spawn') { spawnBeacon(e); dbgLog('spawn', e); }
if (e.type === 'capture') { toast(`Caught <span class="nm">${e.ghost.name}</span> +${e.score}`); dbgLog('capture', e); }
if (e.type === 'flee') { toast(`<span class="nm">${e.ghost.name}</span> slipped away`); dbgLog('flee', e); }
if (e.type === 'capture' || e.type === 'spawn' || e.type === 'flee') {
score = scoreFromCaptures();
$('#caught').textContent = hunt.captured.length;
$('#score').textContent = score;
}
logToServer(e);
}
});
// ---- debug + telemetry ----
const SESSION = Math.random().toString(36).slice(2, 8);
let serverLog = false; // toggled on if the endpoint answers
let serverLogDisabled = false; // turns off after the first failure (e.g. static file server)
let beaconsOn = true;
const seenInView = new Set(); // ids already "detected" (entered the frustum)
const beacons = new Map(); // id -> beacon mesh
// spawn beacon: a bright, always-visible marker at the spawn point so you can
// see WHERE a ghost is even if the wisp itself fails to render.
function spawnBeacon(e){
if (!beaconsOn) return;
// find the matching active ghost's object3d to read its live position
const a = hunt.listActive().find(x => x.id === e.id);
const pos = a ? a.object3d.position : new THREE.Vector3(e.pos?.x||0, e.pos?.y||0, e.pos?.z||0);
const colMap = { Red:0xFF2678, Blue:0x51EAF1, Yellow:0xFFF35D };
const col = colMap[e.ghost?.color] || 0xffffff;
const grp = new THREE.Group();
// bright core
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 12, 12),
new THREE.MeshBasicMaterial({ color: col })
);
// halo ring billboard
const ring = new THREE.Sprite(new THREE.SpriteMaterial({ color: col, transparent:true, opacity:0.6 }));
ring.scale.setScalar(0.9);
grp.add(core); grp.add(ring);
grp.position.copy(pos);
grp.userData.born = performance.now();
scene.add(grp);
beacons.set(e.id, grp);
}
function updateBeacons(){
const now = performance.now();
for (const [id, g] of beacons) {
const age = (now - g.userData.born) / 1000;
const pulse = 0.7 + 0.3 * Math.sin(age * 6);
g.children[0].scale.setScalar(pulse);
g.children[1].material.opacity = 0.5 * pulse;
// beacon fades after 3s (ghost should be found by then)
if (age > 3) { scene.remove(g); beacons.delete(id); }
// also drop the beacon once the ghost is gone
if (!hunt.listActive().some(a => a.id === id) && age > 0.5) { scene.remove(g); beacons.delete(id); }
}
}
// detection: fire once when a ghost first enters the camera view
function checkDetections(){
for (const a of hunt.listActive()) {
const p = a.object3d.position.clone().project(camera);
const inView = p.z < 1 && Math.abs(p.x) < 1 && Math.abs(p.y) < 1;
if (inView && !seenInView.has(a.id)) {
seenInView.add(a.id);
const e = { type:'detect', id:a.id, ghost:a.ghost };
dbgLog('detect', e); logToServer(e);
}
}
}
const dbgLogEl = () => $('#dbg-log');
function dbgLog(kind, e){
const el = dbgLogEl(); if (!el) return;
const ts = new Date().toLocaleTimeString().slice(0,8);
const nm = e.ghost?.name || '';
const extra = e.pos ? ` @${e.pos.x},${e.pos.y},${e.pos.z}` : (e.score ? ` +${e.score}` : '');
const line = document.createElement('div');
line.className = kind;
line.textContent = `${ts} ${kind.toUpperCase().padEnd(7)} ${nm}${extra}`;
el.prepend(line);
while (el.childNodes.length > 80) el.removeChild(el.lastChild);
}
async function logToServer(e){
if (serverLogDisabled) return; // don't spam a server that can't receive POSTs
try {
const r = await fetch('/api/hunt-log', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ ...e, ghost: e.ghost?.name, color: e.ghost?.color,
rarity: e.ghost?.rarity, session: SESSION })
});
if (r.ok) { if (!serverLog) { serverLog = true; $('#dbg-srv').textContent = 'on'; } }
else { serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a (static)'; }
} catch(_) {
serverLogDisabled = true; $('#dbg-srv').textContent = 'n/a'; // no backend (static demo) — stop trying
}
}
const RARITY_SCORE = { Common:10, Rare:25, Epic:60, Legendary:150 };
let score = 0;
function scoreFromCaptures() {
const byId = Object.fromEntries(roster.map(g => [g.id, g]));
return hunt.captured.reduce((s,id)=> s + (RARITY_SCORE[byId[id]?.rarity]||10), 0);
}
document.querySelectorAll('.lure').forEach(el => {
el.addEventListener('click', () => {
const on = el.dataset.on === '1';
document.querySelectorAll('.lure').forEach(x => x.dataset.on='0');
if (!on) { el.dataset.on='1'; hunt.setLure(el.dataset.color); reticleColor(el.dataset.color); }
else { hunt.setLure(null); reticleColor(null); }
});
});
function reticleColor(c){
const map = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
document.documentElement.style.setProperty('--blue', map[c] || '#51EAF1');
}
function currentTarget() {
const list = hunt.listActive();
let best=null, bestD=Infinity;
for (const a of list) {
const p = a.object3d.position.clone().project(camera);
if (p.z > 1) continue;
const d = Math.hypot(p.x, p.y);
if (d < bestD) { bestD = d; best = a; }
}
return bestD < 0.35 ? best : null;
}
let charging=false, charge=0;
const fireBtn = $('#fire');
const chargeEl = $('#reticle .charge');
function setCharge(v){
charge = Math.max(0, Math.min(1, v));
chargeEl.style.background = `conic-gradient(var(--blue) ${charge*360}deg, transparent ${charge*360}deg)`;
}
const press = (e)=>{ e.preventDefault(); charging=true; };
const release = (e)=>{
e.preventDefault();
if (!charging) return; charging=false;
const t = currentTarget();
if (t) hunt.fireAt(t.id, charge);
setCharge(0);
};
fireBtn.addEventListener('pointerdown', press);
fireBtn.addEventListener('pointerup', release);
fireBtn.addEventListener('pointercancel', release);
function toast(html){
const d = document.createElement('div'); d.className='toast'; d.innerHTML=html;
$('#toasts').appendChild(d); setTimeout(()=>d.remove(), 2400);
}
function paintHaunt(){
const h = hunt.haunt;
$('#haunt-fill').style.height = h + '%';
$('#haunt-pct').textContent = Math.round(h) + '%';
const col = h < 50 ? 'var(--blue)' : h < 80 ? 'var(--yellow)' : 'var(--red)';
$('#haunt-fill').style.background = `linear-gradient(0deg, ${col}, #fff2)`;
}
let usingXR = false, xrSession = null, gyroQuat = null;
async function startAR(){
if (navigator.xr && await navigator.xr.isSessionSupported?.('immersive-ar')) {
try {
xrSession = await navigator.xr.requestSession('immersive-ar', { optionalFeatures:['local-floor','hit-test'] });
renderer.xr.enabled = true;
await renderer.xr.setSession(xrSession);
usingXR = true; beginLoop(); hideStart(); return;
} catch(e){ /* fall through to gyro */ }
}
startGyro();
}
async function startGyro(){
hideStart();
try {
const stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:'environment' }, audio:false });
$('#cam').srcObject = stream;
} catch(e){ /* no camera - black bg is fine */ }
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
try { await DeviceOrientationEvent.requestPermission(); } catch(e){}
}
// iOS requires a separate permission for motion (accelerometer) used by parallax
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
try { await DeviceMotionEvent.requestPermission(); } catch(e){}
}
gyroQuat = new THREE.Quaternion();
let gotGyro = false;
const euler = new THREE.Euler();
const zee = new THREE.Vector3(0,0,1);
const q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.sqrt(0.5),0,0,Math.sqrt(0.5));
addEventListener('deviceorientation', (ev)=>{
if (ev.alpha == null && ev.beta == null && ev.gamma == null) return;
gotGyro = true;
const a = THREE.MathUtils.degToRad(ev.alpha||0);
const b = THREE.MathUtils.degToRad(ev.beta||0);
const g = THREE.MathUtils.degToRad(ev.gamma||0);
const orient = THREE.MathUtils.degToRad(screen.orientation?.angle||0);
euler.set(b, a, -g, 'YXZ');
gyroQuat.setFromEuler(euler);
gyroQuat.multiply(q1);
gyroQuat.multiply(q0.setFromAxisAngle(zee, -orient));
}, true);
// if no gyro data arrives within 1s (desktop / no sensor), enable drag-look
setTimeout(() => { if (!gotGyro) enableDragLook(); }, 1000);
enableParallax();
beginLoop();
}
// ---- fake parallax: a subtle camera sway from device motion / tilt ----
// Cannot give true positional tracking (iOS Safari has no WebXR), but reading
// devicemotion lets us add a small depth-giving sway when you tilt or step.
const parallax = { x: 0, y: 0, tx: 0, ty: 0 };
function enableParallax(){
// tilt-based offset (works from deviceorientation beta/gamma we already get)
addEventListener('deviceorientation', (ev) => {
if (ev.beta == null && ev.gamma == null) return;
// map tilt to a tiny lateral/vertical target offset (clamped, metres)
parallax.tx = Math.max(-0.12, Math.min(0.12, (ev.gamma || 0) / 90 * 0.12));
parallax.ty = Math.max(-0.08, Math.min(0.08, ((ev.beta || 0) - 80) / 90 * 0.08));
}, true);
// motion-based micro-impulse (a step/jolt nudges the sway)
if (typeof DeviceMotionEvent !== 'undefined') {
addEventListener('devicemotion', (ev) => {
const a = ev.accelerationIncludingGravity || ev.acceleration;
if (!a) return;
parallax.tx += Math.max(-0.02, Math.min(0.02, (a.x || 0) * 0.002));
parallax.ty += Math.max(-0.02, Math.min(0.02, (a.y || 0) * 0.002));
parallax.tx = Math.max(-0.15, Math.min(0.15, parallax.tx));
parallax.ty = Math.max(-0.12, Math.min(0.12, parallax.ty));
}, true);
}
}
function hideStart(){ $('#start').style.display='none'; }
$('#go-ar').addEventListener('click', startAR);
$('#go-gyro').addEventListener('click', startGyro);
// ---- desktop fallback: drag to look around (no gyro on desktop) ----
let dragYaw = 0, dragPitch = 0, dragging = false, lastX = 0, lastY = 0;
let usingDrag = false;
function enableDragLook(){
usingDrag = true;
const el = renderer.domElement;
const down = (e) => { dragging = true; lastX = e.clientX ?? e.touches?.[0]?.clientX; lastY = e.clientY ?? e.touches?.[0]?.clientY; };
const move = (e) => {
if (!dragging) return;
const x = e.clientX ?? e.touches?.[0]?.clientX, y = e.clientY ?? e.touches?.[0]?.clientY;
dragYaw -= (x - lastX) * 0.005;
dragPitch -= (y - lastY) * 0.005;
dragPitch = Math.max(-1.2, Math.min(1.2, dragPitch));
lastX = x; lastY = y;
};
const up = () => { dragging = false; };
el.addEventListener('mousedown', down); addEventListener('mousemove', move); addEventListener('mouseup', up);
el.addEventListener('touchstart', down, {passive:true}); addEventListener('touchmove', move, {passive:true}); addEventListener('touchend', up);
}
// ---- debug panel controls ----
$('#dbg-toggle').addEventListener('click', () => $('#dbg').classList.toggle('open'));
function setMax(n){
hunt.setMaxActive(n);
$('#dbg-maxval').textContent = hunt.maxActive;
$('#dbg-max').textContent = hunt.maxActive;
}
$('#dbg-minus').addEventListener('click', () => setMax(hunt.maxActive - 1));
$('#dbg-plus').addEventListener('click', () => setMax(hunt.maxActive + 1));
$('#dbg-beacon').addEventListener('change', (e) => { beaconsOn = e.target.checked; });
setMax(hunt.maxActive);
// ---- off-screen direction arrow: points to the nearest off-screen ghost ----
const arrowEl = $('#arrow');
function updateArrow(){
const list = hunt.listActive();
let target = null, bestZ = Infinity;
for (const a of list) {
const p = a.object3d.position.clone().project(camera);
const onScreen = p.z < 1 && Math.abs(p.x) < 0.95 && Math.abs(p.y) < 0.95;
if (onScreen) continue;
if (p.z < bestZ) { bestZ = p.z; target = { a, p }; }
}
if (!target) { arrowEl.style.display = 'none'; return; }
// direction from screen centre toward the (clamped) projected point.
// behind-camera points (z>1) get flipped so the arrow still makes sense.
let { x, y, z } = target.p;
if (z > 1) { x = -x; y = -y; }
const ang = Math.atan2(y, x); // screen angle (y up)
const r = Math.min(innerWidth, innerHeight) * 0.36;
const cx = innerWidth/2 + Math.cos(ang) * r;
const cy = innerHeight/2 - Math.sin(ang) * r; // CSS y is down
const colMap = { Red:'#FF2678', Blue:'#51EAF1', Yellow:'#FFF35D' };
arrowEl.style.setProperty('--c', colMap[target.a.ghost.color] || '#51EAF1');
arrowEl.querySelector('path').setAttribute('fill', colMap[target.a.ghost.color] || '#51EAF1');
arrowEl.style.display = 'block';
arrowEl.style.left = (cx - 20) + 'px';
arrowEl.style.top = (cy - 20) + 'px';
arrowEl.style.transform = `rotate(${-ang + Math.PI/2}rad)`; // point outward
}
const clock = new THREE.Clock();
function frame(){
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
if (charging) setCharge(charge + dt * 0.9);
if (usingXR) {
// In AR, the device pose drives renderer.xr.getCamera(). Sync our logic
// camera (used for targeting/detection/arrows) from it so position AND
// rotation both track as you physically move the phone.
const xrCam = renderer.xr.getCamera();
xrCam.getWorldPosition(camera.position);
xrCam.getWorldQuaternion(camera.quaternion);
camera.updateMatrixWorld();
} else if (usingDrag) {
const e = new THREE.Euler(dragPitch, dragYaw, 0, 'YXZ');
camera.quaternion.setFromEuler(e);
} else if (gyroQuat) {
camera.quaternion.copy(gyroQuat);
}
// fake parallax sway (non-XR only): ease the camera a touch toward the
// tilt/motion target, applied in view space so it reads as gentle depth.
if (!usingXR) {
parallax.x += (parallax.tx - parallax.x) * Math.min(1, dt * 4);
parallax.y += (parallax.ty - parallax.y) * Math.min(1, dt * 4);
// decay the motion impulse back toward the tilt baseline
parallax.tx *= 0.92; parallax.ty *= 0.92;
const right = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
camera.position.copy(right.multiplyScalar(parallax.x).add(up.multiplyScalar(parallax.y)));
}
hunt.update(dt, t);
updateBeacons();
checkDetections();
updateArrow();
$('#dbg-active').textContent = hunt.listActive().length;
$('#dbg-haunt').textContent = Math.round(hunt.haunt) + '%';
$('#reticle').classList.toggle('locked', !!currentTarget());
paintHaunt();
renderer.render(scene, camera);
}
function beginLoop(){ renderer.setAnimationLoop(frame); }
</script>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
/**
* hunt.js — Newbury Nights hunt loop (Phase 3, standalone demo)
*
* Core v1 mechanics:
* - Spawn: rarity-weighted draw from the roster, placed in a forward arc.
* - Colour lure: the wheel sets the "lured" colour; matching ghosts spawn more
* often and are worth more, mirroring the original's GhostColor mechanic.
* - Capture: aim the reticle, hold to charge, release to fire. Charge time
* scales with the ghost's chargePips; misses let it flee (escape timing).
* - Hauntometer: every active/escaped ghost raises the meter; captures lower
* it. As the meter climbs, spawn rate and rarity bias escalate — the session
* gets harder the longer the haunt goes unchecked.
*
* Engine-agnostic: takes a THREE instance + a scene + the createGhostVisual
* factory. No AR/session code here — the page wires camera/anchor.
*/
import { createGhostVisual } from './ghost-visual.js';
const RARITY_BASE_WEIGHT = { Common: 50, Rare: 30, Epic: 15, Legendary: 5 };
const RARITY_SCORE = { Common: 10, Rare: 25, Epic: 60, Legendary: 150 };
const COLORS = ['Red', 'Blue', 'Yellow'];
export function createHunt(THREE, scene, opts = {}) {
const roster = opts.roster || [];
let maxActive = opts.maxActive ?? 4;
const spawnArcDeg = opts.spawnArcDeg ?? 90; // ±45° — tighter so ghosts land in front
const fleeMinMs = opts.fleeMinMs ?? 12000; // longer discovery window
const fleeMaxMs = opts.fleeMaxMs ?? 18000;
const fxTextures = opts.fxTextures || null; // {glow, trail, smoke} THREE.Textures
const onEvent = opts.onEvent || (() => {});
let luredColor = null; // set by the colour wheel
let haunt = 0; // 0..100 hauntometer
let captured = []; // captured ghost ids this session
const active = new Map(); // id -> { ghost, visual, hp, state, fleeAt }
let nextId = 1;
let spawnTimer = 0;
// --- weighted roster draw, biased by lure + haunt escalation ---
function pickGhost() {
// haunt escalation: higher haunt tilts the draw toward rarer ghosts
const escal = 1 + (haunt / 100) * 1.5; // up to 2.5x rare bias
const pool = [];
for (const g of roster) {
let w = RARITY_BASE_WEIGHT[g.rarity] || 1;
// rarer ghosts get heavier as haunt climbs
if (g.rarity !== 'Common') w *= escal;
// lure: matching colour spawns ~2x more often
if (luredColor && g.color === luredColor) w *= 2;
pool.push([g, w]);
}
const total = pool.reduce((s, [, w]) => s + w, 0);
let r = Math.random() * total;
for (const [g, w] of pool) { r -= w; if (r <= 0) return g; }
return pool[pool.length - 1][0];
}
function placeOnRing(a) {
// Full 360° ring around the stationary player at roughly eye level.
// Each ghost gets orbit params so it slowly circles + bobs; the player
// stands still and pans to track them (works on iOS, no walking needed).
a.azimuth = Math.random() * Math.PI * 2; // start anywhere around
a.radius = 2.6 + Math.random() * 1.6; // 2.64.2m out
a.height = -0.3 + Math.random() * 1.0; // around eye level
a.orbitSpeed = (Math.random() < 0.5 ? -1 : 1) * (0.05 + Math.random() * 0.12); // rad/s, either direction
a.bobPhase = Math.random() * Math.PI * 2;
a.bobAmp = 0.1 + Math.random() * 0.2;
a.radiusPhase = Math.random() * Math.PI * 2;
a.radiusAmp = 0.2 + Math.random() * 0.4; // gentle in/out drift
positionFromOrbit(a, 0);
}
function positionFromOrbit(a, elapsed) {
const az = a.azimuth + a.orbitSpeed * elapsed;
const r = a.radius + Math.sin(elapsed * 0.5 + a.radiusPhase) * a.radiusAmp;
const y = a.height + Math.sin(elapsed * 1.2 + a.bobPhase) * a.bobAmp;
a.visual.object3d.position.set(
Math.sin(az) * r,
y,
-Math.cos(az) * r
);
}
function spawn() {
if (active.size >= maxActive) return;
const g = pickGhost();
const visual = createGhostVisual(THREE, { color: g.color, fx: opts.fx !== false });
if (fxTextures) visual.attachFx(fxTextures);
scene.add(visual.object3d);
const id = nextId++;
// hp derived from health pips/stat; charge to capture scales with chargePips
const hp = (g.healthMax || 300) / 100; // ~2.6-4.5
const a = {
ghost: g, visual, hp, maxHp: hp,
state: 'idle',
fleeAt: performance.now() + (fleeMinMs + Math.random() * (fleeMaxMs - fleeMinMs)), // escapes if ignored
};
placeOnRing(a); // assign orbit + initial position
active.set(id, a);
haunt = Math.min(100, haunt + 4); // presence raises the meter
const p = visual.object3d.position;
onEvent({ type: 'spawn', id, ghost: g, haunt, pos: { x: +p.x.toFixed(2), y: +p.y.toFixed(2), z: +p.z.toFixed(2) } });
}
// --- capture attempt: called by the page with a charge level 0..1 ---
function fireAt(id, charge) {
const a = active.get(id);
if (!a) return { hit: false };
// damage = charge, but the ghost needs enough charge relative to chargePips
const needed = 0.4 + (a.ghost.chargePips || 2) * 0.12; // 0.5-1.0
const dmg = charge >= needed ? charge * 1.6 : charge * 0.4;
a.hp -= dmg;
if (a.hp <= 0) return capture(id);
a.state = 'hit';
onEvent({ type: 'hit', id, ghost: a.ghost, hpFrac: Math.max(0, a.hp / a.maxHp) });
return { hit: true, captured: false };
}
function capture(id) {
const a = active.get(id);
if (!a) return { hit: false };
a.visual.setState('capture');
const colorBonus = luredColor && a.ghost.color === luredColor ? 1.5 : 1;
const score = Math.round((RARITY_SCORE[a.ghost.rarity] || 10) * colorBonus);
captured.push(a.ghost.id);
haunt = Math.max(0, haunt - 10); // captures calm the haunt
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 500);
active.delete(id);
onEvent({ type: 'capture', id, ghost: a.ghost, score, captured: captured.length, haunt });
return { hit: true, captured: true, score };
}
function flee(id) {
const a = active.get(id);
if (!a) return;
a.visual.setState('capture'); // reuse shrink-out
setTimeout(() => { scene.remove(a.visual.object3d); a.visual.dispose(); }, 400);
active.delete(id);
haunt = Math.min(100, haunt + 8); // an escape feeds the haunt
onEvent({ type: 'flee', id, ghost: a.ghost, haunt });
}
function setLure(color) {
luredColor = COLORS.includes(color) ? color : null;
onEvent({ type: 'lure', color: luredColor });
}
function update(dt, elapsed) {
// spawn cadence accelerates with haunt
const interval = Math.max(1.2, 4.5 - (haunt / 100) * 3.0);
spawnTimer += dt;
if (spawnTimer >= interval) { spawnTimer = 0; spawn(); }
const now = performance.now();
for (const [id, a] of active) {
// orbit-and-drift around the stationary player
if (a.state !== 'capture') positionFromOrbit(a, elapsed);
a.visual.update(dt, elapsed);
if (now >= a.fleeAt) flee(id);
}
}
function listActive() {
return [...active.entries()].map(([id, a]) => ({
id, ghost: a.ghost, object3d: a.visual.object3d,
hpFrac: Math.max(0, a.hp / a.maxHp),
}));
}
return {
update, spawn, fireAt, capture, flee, setLure, listActive,
setMaxActive(n) { maxActive = Math.max(1, n | 0); },
get maxActive() { return maxActive; },
get haunt() { return haunt; },
get captured() { return captured.slice(); },
get luredColor() { return luredColor; },
};
}
+41 -4
View File
@@ -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() {
+55 -1
View File
@@ -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;
},
};
+47
View File
@@ -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 &middot; 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>
+365
View File
@@ -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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
</script>
</body>
</html>
+68 -14
View File
@@ -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 });
});
+5 -1
View File
@@ -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 = ?';
+52
View File
@@ -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;
+16
View File
@@ -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' });