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');