/** * The studio backdrop, as a DOM layer that sits behind the WebGL canvas. * * The renderer is created with `alpha: true`, so whenever it is cleared to * alpha 0 the page shows through. Painting the backdrop into a sibling layer * placed *under* `#view` therefore gives the character a scene without touching * the scene graph: the CC0 photo, the user's own picture and the phone's camera * feed are all just CSS on one element, and no texture has to be uploaded per * frame. * * `backdropStyle()` is deliberately pure. It is the only place that turns the * `view.background*` slice into a CSS descriptor, so the behaviour `apply()` * ships with is exactly the behaviour the unit tests cover. */ /** The library already downloaded into `assets/backgrounds/` (see CREDITS.json). * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes * with a floor, a horizon and perspective, which is what gives a picture depth * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the * exceptions: original illustrations drawn for this studio by the author, not * CC0 photos. */ export const BACKGROUND_PRESETS = [ { name: 'empty_warehouse_01', label: '倉庫' }, { name: 'ballroom', label: '広間' }, { name: 'kloppenheim_06_puresky', label: '空と雲' }, { name: 'autumn_park', label: '秋の公園' }, // The places a councillor explains things in, or stands in to make a point. // Poly Haven has no true classroom, desert or 社長室, so these are the closest // real places it does have. { name: 'newman_cafeteria', label: '学校' }, { name: 'wooden_lounge', label: '社長室(木の部屋)' }, { name: 'minedump_flats', label: '砂漠' }, { name: 'spiaggia_di_mondello', label: '海岸' }, // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。 { name: 'mutoujima', label: '無人島' }, { name: 'dokan', label: '土管のある空き地' }, { name: 'uchu', label: '宇宙船の中' }, // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。 { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' }, { name: 'basement_boxing_ring', label: '地下室のリング' }, { name: 'autoshop_01', label: '自動車工場' }, { name: 'urban_alley_01', label: '路地' }, ]; const DEFAULT_PRESET = 'autumn_park'; /** * Manga effect-line backdrops, drawn *procedurally* (no file, no network). * * They are the classic stress marks: a burst of lines from behind the character * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and * short lines ringing a wide ellipse so the empty middle reads as isolation * (`ellipse`). * Each is a canvas painted black on white; the same generator runs for the * preview, the PNG and the standalone build. */ export const EFFECT_PRESETS = [ { name: 'focus', label: '集中線' }, { name: 'fall', label: '落ち込み線' }, { name: 'speed', label: '疾走線' }, { name: 'ellipse', label: '楕円集中線' }, ]; const DEFAULT_EFFECT = 'focus'; /** The canvas every effect is painted on, so preview and export agree. */ export const EFFECT_SIZE = { width: 1200, height: 800 }; /** * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks * the same on every run, in every build, and in the tests. */ function makeRandom(seedText) { let seed = 2166136261; for (let i = 0; i < seedText.length; i++) { seed ^= seedText.charCodeAt(i); seed = Math.imul(seed, 16777619); } return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the * same name and size it always issues the same black-on-white strokes, so the * unit tests can pin the look down and the cache below stays sound. * * @param {CanvasRenderingContext2D} ctx * @param {'focus'|'fall'|'speed'|'ellipse'} name * @param {{width:number,height:number}} [size] */ export function drawEffectLines(ctx, name, size = EFFECT_SIZE) { const width = positiveOrOne(size?.width); const height = positiveOrOne(size?.height); const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; const random = makeRandom(kind); ctx.save(); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); ctx.strokeStyle = '#111111'; ctx.lineCap = 'butt'; if (kind === 'focus') { // A burst: lines radiate from a point behind the head, and stop short of it // so the character's face is not crossed by ink. const cx = width * 0.5; const cy = height * 0.42; const reach = Math.hypot(width, height) * 1.1; const near = Math.min(width, height); for (let i = 0; i < 150; i++) { const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03; const inner = near * (0.12 + random() * 0.5); ctx.lineWidth = 1 + random() * 4; ctx.beginPath(); ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner); ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach); ctx.stroke(); } } else if (kind === 'fall') { // Lines raining down from the top edge, of uneven length. for (let i = 0; i < 90; i++) { const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8)); const length = height * (0.25 + random() * 0.7); ctx.lineWidth = 1 + random() * 3; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, length); ctx.stroke(); } } else if (kind === 'speed') { // Lines streaming in from one side or the other. for (let i = 0; i < 70; i++) { const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6)); const length = width * (0.35 + random() * 0.65); const x = random() < 0.5 ? 0 : width - length; ctx.lineWidth = 1 + random() * 3.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + length, y); ctx.stroke(); } } else if (kind === 'ellipse') { // A dense ring of short lines *outside* a wide ellipse: the blank inside is // the point (the 「ひとり」 panel), so nothing is drawn there. The start // points are spaced by arc length, not by angle - an equal angular step // bunches the lines at the two ends of a wide ellipse and leaves gaps along // the flat top and bottom, which is what made the old ring look uneven. Each // line then leaves along the ellipse's outward normal, so the inner edge // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and // every line is anchored to the ring, so none reads as a stray scratch. const cx = width * 0.5; const cy = height * 0.5; const rx = Math.min(width * 0.42, height * 0.9); const ry = rx * 0.55; const lines = 200; const reach = Math.min(width, height) * 0.24; // Cumulative arc length around the ring, so the lines can be spread evenly // around it rather than evenly by angle. const samples = 512; const arc = [0]; for (let s = 1; s <= samples; s++) { const prev = ((s - 1) / samples) * Math.PI * 2; const next = (s / samples) * Math.PI * 2; const dx = (Math.cos(next) - Math.cos(prev)) * rx; const dy = (Math.sin(next) - Math.sin(prev)) * ry; arc.push(arc[s - 1] + Math.hypot(dx, dy)); } const total = arc[samples]; let s = 1; for (let i = 0; i < lines; i++) { const target = ((i + 0.5) / lines) * total; while (s < samples && arc[s] < target) s++; const span = arc[s] - arc[s - 1] || 1; const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2; const cos = Math.cos(t); const sin = Math.sin(t); // The outward normal of an ellipse points along (cos/rx, sin/ry), not along // the radius from the centre, so the lines meet the ring at a right angle. const nx = cos / rx; const ny = sin / ry; const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1); ctx.lineWidth = 1 + random() * 3.5; const startX = cx + cos * rx; const startY = cy + sin * ry; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(startX + nx * length, startY + ny * length); ctx.stroke(); } } ctx.restore(); } /** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */ const FIT_STYLES = { cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' }, contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' }, stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' }, tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' }, }; const DEFAULT_FIT = 'cover'; /** A live frame is a replaced element, so the same fit maps to `object-fit`. */ const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' }; const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']); /** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */ const GYRO_INTERVAL_MS = 33; /** Coerce to a finite number, or `null` when the value is not one. */ function finiteOrNull(value) { const n = Number(value); return Number.isFinite(n) ? n : null; } function normalizeMode(mode) { return MODES.has(mode) ? mode : 'solid'; } function clamp01(value) { const n = finiteOrNull(value); if (n == null) return 0; return Math.min(1, Math.max(0, n)); } function nonNegative(value) { const n = finiteOrNull(value); return n == null ? 0 : Math.max(0, n); } function positiveOrOne(value) { const n = finiteOrNull(value); return n == null || n <= 0 ? 1 : n; } /** The shortest signed distance between two angles, in degrees. */ function wrapDeg(degrees) { return ((degrees + 540) % 360) - 180; } /** * Turn the `view` slice into the plain descriptor `apply()` renders from. * Every field is optional, so a half-written state still yields a usable look; * an unknown `background` falls back to `solid` and an unknown `backgroundFit` * to `cover`. * * `offset` is in fractions of the layer size, and `visible` is false only for * `transparent` (where the page background is meant to show through). */ export function backdropStyle(viewState) { const state = viewState && typeof viewState === 'object' ? viewState : {}; const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT]; const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object' ? state.backgroundOffset : {}; return { backgroundSize: fit.backgroundSize, backgroundRepeat: fit.backgroundRepeat, mirror: state.cameraMirror === true, darken: clamp01(state.backgroundDarken), blur: nonNegative(state.backgroundBlur), offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 }, scale: positiveOrOne(state.backgroundScale), visible: normalizeMode(state.background) !== 'transparent', }; } /** `assets/backgrounds/.webp` - what the multi-file build serves. */ function defaultResolveUrl(name) { return `assets/backgrounds/${name}.webp`; } /** * Build the backdrop layer for a `#stage` element. * * `resolveUrl(name)` maps a preset name to an image URL; the single-file build * passes one that reads an inlined map, while the default points at * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so * the host can repaint. * * The returned object is the only way in: the layer never reads or writes the * studio state itself, `apply(viewState)` is the single entry point. */ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { if (!stage || typeof stage.prepend !== 'function') { throw new Error('createBackdrop: ステージ要素(#stage)が必要です'); } const doc = stage.ownerDocument ?? globalThis.document; const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl; const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {}; const el = doc.createElement('div'); el.className = 'backdrop'; el.setAttribute('aria-hidden', 'true'); Object.assign(el.style, { position: 'absolute', inset: '0', zIndex: '0', overflow: 'hidden', pointerEvents: 'none', transformOrigin: 'center', }); // The image surface is a plain div so blur/scale/tint stay pure CSS. const media = doc.createElement('div'); Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' }); // The camera feed is a muted, inline, autoplaying video for the AR mode. const video = doc.createElement('video'); video.muted = true; video.playsInline = true; video.autoplay = true; video.setAttribute('muted', ''); video.setAttribute('playsinline', ''); video.setAttribute('aria-hidden', 'true'); Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' }); // The dark veil sits on top of whichever surface is showing, so the character // keeps its contrast against a busy photo. const veil = doc.createElement('div'); Object.assign(veil.style, { position: 'absolute', inset: '0' }); el.append(media, video, veil); // Prepended, i.e. before the canvas; the canvas is lifted back above it. stage.prepend(el); const canvas = stage.querySelector('canvas'); if (canvas) { canvas.style.position = 'relative'; canvas.style.zIndex = '1'; } /** The most recent state slice, so the imperative helpers keep its styling. */ let last = {}; let stream = null; let gyroEnabled = false; let gyroAttached = false; let gyroBase = null; let gyroCallback = null; let lastGyroAt = 0; const effectUrls = new Map(); /** The data URL for an effect-line backdrop, generated once and cached. */ function effectUrl(name) { const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; if (effectUrls.has(key)) return effectUrls.get(key); const surface = doc.createElement('canvas'); surface.width = EFFECT_SIZE.width; surface.height = EFFECT_SIZE.height; const surfaceCtx = surface.getContext('2d'); if (!surfaceCtx) return null; drawEffectLines(surfaceCtx, key, EFFECT_SIZE); const url = surface.toDataURL('image/png'); effectUrls.set(key, url); return url; } function imageUrlOf(mode, viewState) { if (mode === 'image') { return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage ? viewState.backgroundImage : null; } if (mode === 'effect') { const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect ? viewState.backgroundEffect : DEFAULT_EFFECT; return effectUrl(name); } if (mode !== 'preset') return null; const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset ? viewState.backgroundPreset : DEFAULT_PRESET; try { const url = resolve(name); if (typeof url === 'string' && url) return url; } catch { // Fall through to the file path: an unknown preset is not worth throwing. } return defaultResolveUrl(name); } function colorOr(value) { return typeof value === 'string' && value ? value : '#ffffff'; } /** * Fit, mirror, blur and the blur bleeding past the edges (the negative inset * keeps `filter: blur()` from fading the border to transparent). */ function styleSurface(node, style) { node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0'; node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none'; node.style.transform = style.mirror ? 'scaleX(-1)' : 'none'; if (node === media) { node.style.backgroundSize = style.backgroundSize; node.style.backgroundRepeat = style.backgroundRepeat; } else { node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover'; } } function apply(viewState) { last = viewState && typeof viewState === 'object' ? viewState : {}; const style = backdropStyle(last); const wanted = normalizeMode(last.background); const url = imageUrlOf(wanted, last); // A preset that cannot be resolved is shown as a plain colour, never blank. const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect'; const mode = drawable && !url ? 'solid' : wanted; el.style.display = style.visible ? 'block' : 'none'; el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent'; el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0 ? 'none' : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`; veil.style.background = `rgba(0, 0, 0, ${style.darken})`; veil.style.display = style.darken > 0 ? 'block' : 'none'; const showCamera = style.visible && mode === 'camera'; const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect'); // Leaving the AR mode must release the camera, not just hide the video. if (!showCamera && stream) stopCamera(); styleSurface(media, style); styleSurface(video, style); media.style.display = showImage ? 'block' : 'none'; video.style.display = showCamera ? 'block' : 'none'; media.style.backgroundImage = showImage ? `url("${url}")` : 'none'; needsRender(); } /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */ function loadPreset(name) { const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET; apply({ ...last, background: 'preset', backgroundPreset: preset }); } /** Apply a stored data URL (as kept in `view.backgroundImage`). */ function applyImage(dataUrl) { if (typeof dataUrl !== 'string' || !dataUrl) { apply({ ...last, background: 'solid', backgroundImage: null }); return; } apply({ ...last, background: 'image', backgroundImage: dataUrl }); } /** Read a user File into a data URL, show it, and hand the URL back to save. */ async function loadImageFile(file) { const dataUrl = await new Promise((resolveUrl, reject) => { const reader = new FileReader(); reader.onload = () => resolveUrl(String(reader.result)); reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした')); reader.readAsDataURL(file); }); applyImage(dataUrl); return dataUrl; } /* Shown whenever the page is not a secure context. This is a fact about the * web platform, not about any one browser, so the wording must not point a * finger at the browser the user happens to be holding. */ const INSECURE_CAMERA_REASON = 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。'; /** * Japanese, actionable text for the ways `getUserMedia` usually fails. On a * phone this toast is all the user has to go on, so each case names the exact * thing to tap instead of just stating that something went wrong. */ function cameraFailureReason(error) { switch (error?.name) { case 'NotAllowedError': case 'PermissionDeniedError': return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。'; case 'NotFoundError': case 'DevicesNotFoundError': return '使えるカメラが見つかりませんでした'; case 'NotReadableError': case 'TrackStartError': return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)'; case 'OverconstrainedError': case 'ConstraintNotSatisfiedError': return '指定したカメラを使用できません'; case 'SecurityError': // No `mediaDevices` at all is the usual symptom, but this covers the // same cause when a sandboxed frame raises the error instead. return INSECURE_CAMERA_REASON; default: return 'カメラを起動できませんでした'; } } async function startCamera(facing = 'environment') { const mediaDevices = globalThis.navigator?.mediaDevices; if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { // An insecure page has no `mediaDevices` at all. Say so plainly rather // than leaving the user to think their camera or browser is broken. return { ok: false, reason: globalThis.isSecureContext === false ? INSECURE_CAMERA_REASON : 'カメラを使うには https か localhost が必要です', }; } stopCamera(); try { const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } }); stream = next; video.srcObject = next; try { await video.play(); } catch { // A blocked autoplay still resolves to a painting stream in practice. } apply({ ...last, background: 'camera', cameraFacing: facing }); return { ok: true }; } catch (error) { stopCamera(); return { ok: false, reason: cameraFailureReason(error) }; } } function stopCamera() { const active = stream; stream = null; if (active) { try { for (const track of active.getTracks()) track.stop(); } catch { // A track that cannot be stopped is already gone as far as we care. } } if (video) { try { video.pause(); } catch { // Pausing a video without a source is expected to throw. } video.srcObject = null; video.style.display = 'none'; } } function emitGyro(yaw, pitch, roll) { gyroCallback?.({ yaw, pitch, roll }); } /** * Attach the orientation listener and take the current pose as zero, so the * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`. */ function enableGyro() { gyroEnabled = true; gyroBase = null; if (!gyroAttached && typeof globalThis.addEventListener === 'function') { globalThis.addEventListener('deviceorientation', handleOrientation); gyroAttached = true; } emitGyro(0, 0, 0); } /** * Degrees of turn from the pose gyro started at. `yaw` grows clockwise * (turning the device to the right); `pitch` and `roll` follow the raw * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz. */ function handleOrientation(event) { if (!gyroEnabled) return; const alpha = typeof event?.alpha === 'number' ? event.alpha : null; const beta = typeof event?.beta === 'number' ? event.beta : null; const gamma = typeof event?.gamma === 'number' ? event.gamma : null; if (alpha == null && beta == null && gamma == null) return; if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 }; const now = Date.now(); if (now - lastGyroAt < GYRO_INTERVAL_MS) return; lastGyroAt = now; emitGyro( alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha), beta == null ? 0 : wrapDeg(beta - gyroBase.beta), gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma), ); } /** Ask for orientation permission (iOS) and start listening. */ async function requestGyro() { const OrientationEvent = globalThis.DeviceOrientationEvent; if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false; if (typeof OrientationEvent.requestPermission === 'function') { let granted = false; try { granted = (await OrientationEvent.requestPermission()) === 'granted'; } catch { granted = false; } if (!granted) return false; } enableGyro(); return true; } /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */ function onGyro(callback) { gyroCallback = typeof callback === 'function' ? callback : null; if (!gyroEnabled) enableGyro(); return () => { if (gyroCallback === callback) gyroCallback = null; }; } function dispose() { stopCamera(); if (gyroAttached) { globalThis.removeEventListener?.('deviceorientation', handleOrientation); gyroAttached = false; } gyroEnabled = false; gyroCallback = null; gyroBase = null; el.remove(); } return { el, apply, loadPreset, loadImageFile, applyImage, startCamera, stopCamera, /** The data URL of a drawn effect line, so exports can composite the same * bitmap the preview shows. */ effectUrl, get cameraActive() { return stream !== null; }, requestGyro, onGyro, snapshot() {}, dispose, }; }