From e332019acb312ec64893c26cf3d797d5ce472f26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:51:34 +0900 Subject: bluebey: ぶるべー スタジオのページを公開 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/background.js | 644 ++++++++++++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 public/bluebey-studio/src/background.js (limited to 'public/bluebey-studio/src/background.js') diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js new file mode 100644 index 0000000..72bc396 --- /dev/null +++ b/public/bluebey-studio/src/background.js @@ -0,0 +1,644 @@ +/** + * 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` is the exception: an original + * illustration drawn for this studio by the author, not a third-party CC0 photo. */ +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: '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; + } + + /** Japanese, actionable text for the ways `getUserMedia` usually fails. */ + function cameraFailureReason(error) { + switch (error?.name) { + case 'NotAllowedError': + case 'PermissionDeniedError': + return 'カメラの使用が許可されていません'; + case 'NotFoundError': + case 'DevicesNotFoundError': + return '使えるカメラが見つかりませんでした'; + case 'NotReadableError': + case 'TrackStartError': + return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)'; + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + return '指定したカメラを使用できません'; + case 'SecurityError': + return 'カメラを使うには https か localhost が必要です'; + default: + return 'カメラを起動できませんでした'; + } + } + + async function startCamera(facing = 'environment') { + const mediaDevices = globalThis.navigator?.mediaDevices; + if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { + return { ok: false, 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, + }; +} -- cgit v1.3.1