diff --git a/public/admin/admin.js b/public/admin/admin.js index 4207204..8c13533 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -39,7 +39,7 @@ export const styles = ` export function nav(active) { return `

Newbury Exhibit — Admin

- ${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p => + ${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p => `${p}`).join('')} viewer ↗
`; diff --git a/public/admin/landing.html b/public/admin/landing.html new file mode 100644 index 0000000..96ca8a5 --- /dev/null +++ b/public/admin/landing.html @@ -0,0 +1,181 @@ + + +Landing Page — Newbury Exhibit + +
+ + diff --git a/public/index.html b/public/index.html index af7815f..2295305 100644 --- a/public/index.html +++ b/public/index.html @@ -3,29 +3,52 @@ -Newbury Exhibit +Newbury Nights @@ -34,17 +57,29 @@
-

NEWBURY EXHIBIT

-
Point your phone at the Newbury Crests to reveal the hidden side of the town.
- -
Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. - LEGO® and Hidden Side™ are trademarks of the LEGO Group.
+ +

+

+ + +
+
+ + + +
diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 47cd63b..1e5e451 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -10,6 +10,7 @@ import { WorldFuser } from './ar/fuse.js'; import { buildGhost } from './ghosts/loader.js'; import { ghostTransform } from './ghosts/behavior.js'; import { ExhibitNet, installErrorReporter } from './net.js'; +import { renderMarkdown } from './md.js'; const $ = (s) => document.querySelector(s); let info = { mode: 'dev' }; @@ -26,17 +27,54 @@ async function boot() { info = await (await fetch('/api/info')).json(); installErrorReporter(info.mode === 'dev'); + await applyLanding(); + const g = await (await fetch('/api/ghosts')).json(); gradients = g.gradients.gradients || g.gradients; modelManifest = await (await fetch('/api/models')).json(); characters = await (await fetch('/api/characters')).json(); $('#start').addEventListener('click', start, { once: true }); + $('#detailsBtn').addEventListener('click', () => $('#details').classList.remove('hidden')); + $('#backBtn').addEventListener('click', () => $('#details').classList.add('hidden')); +} + +/* Paint the operator-authored landing page + details page. */ +async function applyLanding() { + let L; + try { L = await (await fetch('/api/landing')).json(); } catch { return; } + const set = (sel, txt) => { const el = $(sel); if (el) el.textContent = txt || ''; }; + + document.documentElement.style.setProperty('--accent', L.accent || '#51eaf1'); + document.documentElement.style.setProperty('--text', L.textColor || '#e8ecff'); + document.documentElement.style.setProperty('--ov', L.overlay != null ? L.overlay : 0.55); + + if (L.backgroundUrl) $('#startScreen').style.backgroundImage = `url("${L.backgroundUrl}")`; + if (L.logoUrl) { + const img = $('#logo'); + img.src = L.logoUrl; img.alt = L.title || 'logo'; + img.classList.remove('hidden'); + $('#title').classList.add('hidden'); // logo art replaces the text title + } else { + set('#title', L.title); + } + set('#subtitle', L.subtitle); + set('#start', L.startButton || 'Start'); + set('#detailsBtn', L.detailsButton || 'About'); + set('#detailsTitle', L.detailsTitle); + $('#detailsBody').innerHTML = renderMarkdown(L.detailsMarkdown); + // disclaimer is always rendered on both screens + set('#disc1', L.disclaimer); + set('#disc2', L.disclaimer); + if (!L.detailsMarkdown) $('#detailsBtn').classList.add('hidden'); + document.title = L.title || 'Newbury Nights'; } async function start() { $('#startScreen').classList.add('hidden'); $('#hud').classList.remove('hidden'); + $('#shutter').classList.remove('hidden'); + $('#shutter').addEventListener('click', capturePhoto); // camera video = $('#cam'); @@ -52,7 +90,7 @@ async function start() { ctx2d = canvas2d.getContext('2d', { willReadFrequently: true }); // three.js - renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true }); + renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); scene3 = new THREE.Scene(); camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000); @@ -195,6 +233,50 @@ function loop(t) { renderer.render(scene3, camera3); } +/* Shutter: composite the live camera frame with the rendered ghosts and save a PNG. + * The WebGL canvas is drawn on top of a video frame at full video resolution. */ +async function capturePhoto() { + try { + const flash = $('#flash'); + flash.classList.add('on'); + setTimeout(() => flash.classList.remove('on'), 60); + + const vw = video.videoWidth, vh = video.videoHeight; + const out = document.createElement('canvas'); + out.width = vw; out.height = vh; + const c = out.getContext('2d'); + c.drawImage(video, 0, 0, vw, vh); + + // renderer canvas is CSS-sized to the viewport with object-fit:cover on the video, + // so replicate that cover mapping when overlaying the 3D layer + const gl = $('#gl'); + const scale = Math.max(vw / gl.width, vh / gl.height); + const dw = gl.width * scale, dh = gl.height * scale; + renderer.render(scene3, camera3); // ensure the buffer is current + c.drawImage(gl, (vw - dw) / 2, (vh - dh) / 2, dw, dh); + + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const blob = await new Promise(r => out.toBlob(r, 'image/png')); + const file = new File([blob], `newbury-${stamp}.png`, { type: 'image/png' }); + + // native share sheet on mobile (lets users save to camera roll), download elsewhere + if (navigator.canShare && navigator.canShare({ files: [file] })) { + await navigator.share({ files: [file], title: 'Newbury Nights' }); + toast('Photo ready to share'); + } else { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = file.name; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + toast('Photo saved'); + } + } catch (e) { + if (e && e.name === 'AbortError') return; // user dismissed the share sheet + toast('Could not save photo'); + console.warn('capture failed', e); + } +} + let toastTimer = null; function toast(msg) { const el = $('#toast'); diff --git a/push-characters.bat b/push-characters.bat index 5199eed..de9f3be 100644 --- a/push-characters.bat +++ b/push-characters.bat @@ -1,17 +1,8 @@ -@echo off -REM push-characters.bat - commit and push the character manager changes to Gitea. -REM Run from anywhere; it always works on K:\newbury-exhibit-v2 -cd /d K:\newbury-exhibit-v2 - -if not exist .git ( - echo No git repo here - initialising and linking to Gitea... - git init -b main - git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git - git fetch origin - git reset --soft origin/main -) - +@echo off +REM push-characters.bat — commit and push the character manager changes to Gitea. +REM Extract the zip over Y:\newbury-exhibit-v2 first, then run this. +cd /d Y:\newbury-exhibit-v2 git add -A git commit -m "Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals" git push origin main -pause \ No newline at end of file +pause diff --git a/push-landing.bat b/push-landing.bat new file mode 100644 index 0000000..c13fbce --- /dev/null +++ b/push-landing.bat @@ -0,0 +1,16 @@ +@echo off +REM push-landing.bat - commit and push the landing page editor + shutter button. +cd /d K:\newbury-exhibit-v2 + +if not exist .git ( + echo No git repo here - initialising and linking to Gitea... + git init -b main + git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git + git fetch origin + git reset --soft origin/main +) + +git add -A +git commit -m "Landing page editor: logo/background/subtitle, markdown details page, editable disclaimer, photo shutter" +git push origin main +pause diff --git a/server/server.js b/server/server.js index 42cce51..f64b986 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,13 @@ app.delete('/api/assets/:id', guard, (req, res) => { catch (e) { res.status(400).json({ error: String(e.message || e) }); } }); +// ---------- landing page ---------- +app.get('/api/landing', (req, res) => res.json(state.getLanding())); +app.put('/api/landing', guard, (req, res) => { + try { res.json(state.putLanding(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); + // ---------- character overrides ---------- app.get('/api/characters', (req, res) => res.json(state.getCharacters())); app.put('/api/characters', guard, (req, res) => { diff --git a/server/state.js b/server/state.js index 7cde8ee..6c96463 100644 --- a/server/state.js +++ b/server/state.js @@ -13,6 +13,7 @@ const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json'); const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json'); const MODELS_SEED = path.join(DATA, 'models.json'); const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json'); +const LANDING_LIVE = path.join(LIVE_DIR, 'landing.live.json'); fs.mkdirSync(LIVE_DIR, { recursive: true }); @@ -32,6 +33,24 @@ let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] } * faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */ let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} }; +/* landing: end-user facing start screen + details page. All text is editable except + * that the disclaimer is ALWAYS rendered (wording editable, presence is not). */ +const LANDING_DEFAULTS = { + title: 'NEWBURY NIGHTS', + subtitle: 'Point your phone at the Newbury Crests to reveal the hidden side of the town.', + logoUrl: '', + backgroundUrl: '', + startButton: 'Start Ghost Watching', + detailsButton: 'About this build', + detailsTitle: 'About Newbury Nights', + detailsMarkdown: 'Write about your build here.\n\nUse **bold**, *italic*, and blank lines for paragraphs.', + disclaimer: 'Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. LEGO® and Hidden Side™ are trademarks of the LEGO Group.', + accent: '#51eaf1', + textColor: '#e8ecff', + overlay: 0.55, +}; +let landing = Object.assign({}, LANDING_DEFAULTS, readJSON(LANDING_LIVE, null) || {}); + const listeners = []; function validateScene(s) { @@ -76,6 +95,17 @@ module.exports = { return scene; }, getGhosts: () => ({ ghosts, gradients }), + getLanding: () => landing, + putLanding(l) { + if (!l || typeof l !== 'object') throw new Error('landing must be object'); + const next = Object.assign({}, LANDING_DEFAULTS, l); + // disclaimer may be reworded but never emptied — it protects the project + if (!String(next.disclaimer || '').trim()) next.disclaimer = LANDING_DEFAULTS.disclaimer; + landing = next; + writeJSON(LANDING_LIVE, landing); + listeners.forEach(f => f()); + return landing; + }, getCharacters: () => characters, putCharacters(c) { if (!c || typeof c !== 'object') throw new Error('characters must be object');