aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/assets/backgrounds/CREDITS.json26
-rw-r--r--public/bluebey-studio/assets/backgrounds/dokan.webpbin0 -> 33248 bytes
-rw-r--r--public/bluebey-studio/assets/backgrounds/uchu.webpbin0 -> 33900 bytes
-rw-r--r--public/bluebey-studio/index.html2
-rw-r--r--public/bluebey-studio/src/animation.js12
-rw-r--r--public/bluebey-studio/src/background.js34
-rw-r--r--public/bluebey-studio/src/faceArt.js245
-rw-r--r--public/bluebey-studio/src/main.js11
-rw-r--r--public/bluebey-studio/src/panel.js141
-rw-r--r--public/bluebey-studio/src/presets.js57
-rw-r--r--public/bluebey-studio/src/props.js63
-rw-r--r--public/bluebey-studio/src/style.css129
-rw-r--r--public/bluebey-studio/src/ui.js17
-rw-r--r--public/bluebey/images/bluebey-scene.webpbin99758 -> 185162 bytes
14 files changed, 540 insertions, 197 deletions
diff --git a/public/bluebey-studio/assets/backgrounds/CREDITS.json b/public/bluebey-studio/assets/backgrounds/CREDITS.json
index e615c7e..d4114c7 100644
--- a/public/bluebey-studio/assets/backgrounds/CREDITS.json
+++ b/public/bluebey-studio/assets/backgrounds/CREDITS.json
@@ -1,5 +1,5 @@
{
- "note": "背景素材は Poly Haven (CC0-1.0)。tools/fetch-backgrounds.py で取得。ただし mutoujima.webp は作者(安竹洋平)によるオリジナルの描き起こし。",
+ "note": "背景素材は Poly Haven (CC0-1.0)。tools/fetch-backgrounds.py で取得。ただし mutoujima.webp は作者(安竹洋平)によるオリジナルの描き起こし。dokan.webp と uchu.webp も作者のオリジナル(blender-scenes でレンダリング)。",
"items": [
{
"file": "empty_warehouse_01.webp",
@@ -158,6 +158,30 @@
"license": "original (not CC0)",
"kind": "illustration",
"source": "作者(安竹洋平)が bluebey-studio のために描き起こしたオリジナル作品"
+ },
+ {
+ "file": "dokan.webp",
+ "label": "土管のある空き地",
+ "yaw": 0,
+ "name": "Dokan",
+ "authors": {
+ "安竹洋平": "All"
+ },
+ "license": "original (not CC0)",
+ "kind": "illustration",
+ "source": "作者(安竹洋平)が blender-scenes でレンダリングした bluebey-studio 用のオリジナル作品"
+ },
+ {
+ "file": "uchu.webp",
+ "label": "宇宙船の中",
+ "yaw": 0,
+ "name": "Uchu",
+ "authors": {
+ "安竹洋平": "All"
+ },
+ "license": "original (not CC0)",
+ "kind": "illustration",
+ "source": "作者(安竹洋平)が blender-scenes でレンダリングした bluebey-studio 用のオリジナル作品"
}
]
} \ No newline at end of file
diff --git a/public/bluebey-studio/assets/backgrounds/dokan.webp b/public/bluebey-studio/assets/backgrounds/dokan.webp
new file mode 100644
index 0000000..7d408d3
--- /dev/null
+++ b/public/bluebey-studio/assets/backgrounds/dokan.webp
Binary files differ
diff --git a/public/bluebey-studio/assets/backgrounds/uchu.webp b/public/bluebey-studio/assets/backgrounds/uchu.webp
new file mode 100644
index 0000000..3ab794d
--- /dev/null
+++ b/public/bluebey-studio/assets/backgrounds/uchu.webp
Binary files differ
diff --git a/public/bluebey-studio/index.html b/public/bluebey-studio/index.html
index 906dd6b..0711549 100644
--- a/public/bluebey-studio/index.html
+++ b/public/bluebey-studio/index.html
@@ -186,7 +186,7 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa
<p>次のような使い方はご遠慮ください。</p>
<ul>
<li>公序良俗に反する目的での利用</li>
- <li>ぶるべーのイメージを損なうような攻撃的・差別的・性的・過激な利用</li>
+ <li>ぶるべーのイメージを損なうような利用(たとえば、攻撃的・差別的・性的・過激な利用、誹謗中傷など)</li>
<li>反社会的勢力や違法行為に関わる利用</li>
<li>政治的意図があるように見える利用(例:特定の政治家や政党をぶるべーが応援しているような表現)</li>
<li>特定の宗教・宗派への勧誘や布教と誤解される利用</li>
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js
index 4446878..47f1cfb 100644
--- a/public/bluebey-studio/src/animation.js
+++ b/public/bluebey-studio/src/animation.js
@@ -111,6 +111,18 @@ export class Animator {
return { bones, root };
}
+ if (mode === 'wave') {
+ // One flipper is held up and sweeps back and forth, with the hand lagging a
+ // little behind it; the body and the other flipper rock along gently. The
+ // +84 X holds the arm up on its own, so the wave reads from a plain stance.
+ const wave = Math.sin(t * 3.2);
+ add('arm.l', 84 + wave * 14, 0, wave * 12);
+ add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 22);
+ add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4);
+ add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3);
+ return { bones, root };
+ }
+
// Default: gentle breathing, as if standing there alive.
add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6);
add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5);
diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js
index 72bc396..adc7f94 100644
--- a/public/bluebey-studio/src/background.js
+++ b/public/bluebey-studio/src/background.js
@@ -16,8 +16,9 @@
/** 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. */
+ * 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: '広間' },
@@ -32,6 +33,8 @@ export const BACKGROUND_PRESETS = [
{ 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: '地下室のリング' },
@@ -471,12 +474,22 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
return dataUrl;
}
- /** Japanese, actionable text for the ways `getUserMedia` usually fails. */
+ /* 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 'カメラの使用が許可されていません';
+ return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。';
case 'NotFoundError':
case 'DevicesNotFoundError':
return '使えるカメラが見つかりませんでした';
@@ -487,7 +500,9 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
case 'ConstraintNotSatisfiedError':
return '指定したカメラを使用できません';
case 'SecurityError':
- return 'カメラを使うには https か localhost が必要です';
+ // 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 'カメラを起動できませんでした';
}
@@ -496,7 +511,14 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
async function startCamera(facing = 'environment') {
const mediaDevices = globalThis.navigator?.mediaDevices;
if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
- return { ok: false, reason: 'カメラを使うには https か localhost が必要です' };
+ // 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();
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 65205fa..61bfa0d 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -147,17 +147,34 @@ export const CHEEK_LAYOUT = {
drop: 64, // how far below the eye centre the patch sits (artwork px)
outward: 66, // how far outwards, away from the midline, it sits
radius: 50, // half-width of the patch
- squash: 0.62, // half-height, as a fraction of the half-width
- lines: 4, // hatch strokes across the patch
- lineWidth: 5,
- slant: 58, // hatching angle, degrees
+ squash: 0.55, // half-height, as a fraction of the half-width (a flat ellipse)
+ // The blush is *always* four strokes - the reference artwork has four - so
+ // there is no line-count control any more. The strokes are horizontal and
+ // hand-drawn: each wobbles a little and tapers to a point at both ends.
+ lines: 4,
+ lineWidth: 10, // the stroke thickness at its widest (artwork px)
+ spread: 0.62, // how far the outermost stroke sits from the centre (x half-height)
+ margin: 0.18, // clear gap left between a stroke's end and the patch outline
+ wobble: 0.32, // sideways wander, as a fraction of the stroke thickness
};
+/**
+ * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol.
+ *
+ * The face plate is a shell of the *whole* front half of the body, so the plate's
+ * own edge is the head's front silhouette. Filling the plate from its top edge
+ * down to an adjustable boundary therefore makes the covering's outline follow
+ * the silhouette for free - there is no separate ellipse to keep in step.
+ *
+ * It is a generic device: a colour cap, a fringe, a side part, or a left/right
+ * two-tone split. It is deliberately *not* a copy of any one character's hair.
+ * It starts off.
+ */
export const HEAD_MARK_LAYOUT = {
- cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline
- cy: 92, // up on the forehead, above the brows
- size: 46, // nominal radius of a shape
- lineWidth: 9,
+ // Where the boundary sits by default: on the forehead, just above the brows
+ // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it).
+ cy: 120,
+ reach: 190, // artwork px the boundary moves per unit of `size`
};
/**
@@ -212,8 +229,9 @@ const SUNGLASSES_LAYOUT = {
bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height)
templeLength: 1.15, // a short stub, like the round pair's but a little shorter
templeRise: 24,
- frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke
- highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width
+ // A heavy rim. It used to be slimmer than the round pair's stroke, which read
+ // as a thin pair of shades; the reference look is a chunky frame.
+ frameFactor: 1.6,
};
/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole
@@ -666,31 +684,50 @@ function drawCheek(ctx, cx, cy, spec) {
ctx.ellipse(cx, cy, r, ry, 0, 0, TAU);
ctx.fillStyle = spec.color ?? '#f6a6b8';
ctx.fill();
- // Clip the hatching to the patch, so the strokes keep a clean edge.
+ // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge.
ctx.clip();
- const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines));
- if (lines > 0) {
- const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180;
- const dx = Math.cos(angle);
- const dy = -Math.sin(angle);
- const reach = r * 1.3;
- ctx.strokeStyle = spec.hatchColor ?? '#e0708f';
- ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size);
- ctx.lineCap = 'round';
- for (let i = 0; i < lines; i += 1) {
- // Spread the strokes evenly across the patch.
- const t = lines === 1 ? 0 : i / (lines - 1) - 0.5;
- const px = cx + t * r * 1.2;
- ctx.beginPath();
- ctx.moveTo(px - dx * reach, cy - dy * reach);
- ctx.lineTo(px + dx * reach, cy + dy * reach);
- ctx.stroke();
- }
+ // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a
+ // point at both ends and wanders a little. Each one stops short of the patch
+ // outline - the margin is measured against the patch's own width at that
+ // height, so a stroke on the narrow top or bottom cannot reach the edge.
+ const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size);
+ ctx.fillStyle = spec.hatchColor ?? '#e0708f';
+ for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) {
+ const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1
+ const dy = t * ry * CHEEK_LAYOUT.spread;
+ const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2));
+ const half = chord - r * CHEEK_LAYOUT.margin;
+ if (half < 4) continue;
+ traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9);
}
ctx.restore();
}
+/**
+ * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both
+ * ends, with a gentle wobble so it reads as drawn rather than printed.
+ */
+function traceHandHatch(ctx, cx, cy, half, thickness, seed) {
+ const steps = 12;
+ const top = [];
+ const bottom = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const u = i / steps;
+ const x = cx + (u - 0.5) * 2 * half;
+ const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble;
+ const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends
+ top.push({ x, y: cy + wobble - w });
+ bottom.push({ x, y: cy + wobble + w });
+ }
+ ctx.beginPath();
+ ctx.moveTo(top[0].x, top[0].y);
+ for (const point of top.slice(1)) ctx.lineTo(point.x, point.y);
+ for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y);
+ ctx.closePath();
+ ctx.fill();
+}
+
/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */
function drawCheeks(ctx, spec) {
if (!spec?.enabled) return;
@@ -704,60 +741,97 @@ function drawCheeks(ctx, spec) {
}
/**
- * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of
- * shapes on purpose - the point is a generic manga device, not a particular
- * character's artwork - and it starts off.
+ * 頭の模様: a hair-like covering over the whole head.
+ *
+ * The face plate is a shell of the front half of the body, so its own edge is
+ * the head's silhouette. This fills the plate from its top edge down to an
+ * adjustable boundary, which is what makes the covering follow the head outline
+ * without knowing the head's shape ahead of time. The boundary style, colour and
+ * size are all adjustable, and one style (`split`) divides the head into two
+ * colours along a vertical curve - a generic "two-tone head" device. It is not a
+ * copy of any particular character's hair.
*/
-function drawHeadMark(ctx, spec) {
- const shape = spec.shape ?? 'off';
+function drawHeadCover(ctx, spec, limits) {
+ const shape = spec?.shape ?? 'off';
if (shape === 'off' || shape === 'none') return;
+
const size = clamp(spec.size ?? 1, 0.2, 3);
- const s = HEAD_MARK_LAYOUT.size * size;
- const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400);
- const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400);
+ const left = limits.left;
+ const right = limits.right;
+ const top = limits.top;
+ const bottom = limits.bottom;
+ const width = right - left;
+ const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600);
+ const color = spec.color ?? '#8a4fe0';
+ const color2 = spec.color2 ?? '#ffffff';
ctx.save();
- ctx.strokeStyle = spec.color ?? '#8a4fe0';
- ctx.fillStyle = spec.color ?? '#8a4fe0';
- ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size);
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
+ // Stay on the plate: nothing may spill past the artwork's real edges.
+ ctx.beginPath();
+ ctx.rect(left, top, width, bottom - top);
+ ctx.clip();
- if (shape === 'hook') {
- // A short curved hook, like a comma.
- ctx.beginPath();
- ctx.moveTo(cx - s * 1.05, cy + s * 0.45);
- ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15);
- ctx.stroke();
- } else if (shape === 'spiral') {
- const steps = 60;
- ctx.beginPath();
- for (let i = 0; i <= steps; i += 1) {
- const t = i / steps;
- const angle = t * TAU * 2.2;
- const radius = s * (0.14 + 0.86 * t);
- const x = cx + Math.cos(angle) * radius;
- const y = cy + Math.sin(angle) * radius;
- if (i === 0) ctx.moveTo(x, y);
- else ctx.lineTo(x, y);
- }
- ctx.stroke();
- } else if (shape === 'strokes') {
- // Three short strokes standing side by side.
- for (let i = -1; i <= 1; i += 1) {
+ if (shape === 'split') {
+ // Two-tone head: a vertical curve divides the head into two colours. `size`
+ // bows the seam, `offsetY` bows it the other way, `offsetX` slides it.
+ const bow = (size - 1) * 220 + clamp(spec.offsetY ?? 0, -600, 600);
+ const seam = (y) => cx + Math.sin(clamp((y - top) / (bottom - top), 0, 1) * Math.PI) * bow;
+ const steps = 32;
+ const trace = (dir) => {
ctx.beginPath();
- ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6);
- ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6);
- ctx.stroke();
+ ctx.moveTo(seam(top), top);
+ for (let i = 1; i <= steps; i += 1) {
+ const y = top + ((bottom - top) * i) / steps;
+ ctx.lineTo(seam(y), y);
+ }
+ ctx.lineTo(dir > 0 ? right : left, bottom);
+ ctx.lineTo(dir > 0 ? right : left, top);
+ ctx.closePath();
+ };
+ trace(-1);
+ ctx.fillStyle = color;
+ ctx.fill();
+ trace(1);
+ ctx.fillStyle = color2;
+ ctx.fill();
+ ctx.restore();
+ return;
+ }
+
+ // The boundary curve. `t` runs -1..1 across the plate, so each style is a
+ // small shape function; the covering is everything above it.
+ const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach
+ + clamp(spec.offsetY ?? 0, -600, 600);
+ const halfSpan = width / 2;
+ const boundary = (x) => {
+ const t = clamp((x - cx) / halfSpan, -1, 1);
+ if (shape === 'fringe') {
+ // Bangs: a soft point dipping in the middle, with side lobes.
+ return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI));
}
- } else if (shape === 'dots') {
- const radius = Math.max(1.5, ctx.lineWidth * 0.8);
- for (let i = -1; i <= 1; i += 1) {
- ctx.beginPath();
- ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU);
- ctx.fill();
+ if (shape === 'wave') {
+ // A single smooth sweep across the forehead.
+ return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5);
+ }
+ if (shape === 'side') {
+ // A side part: high on one side, low on the other.
+ return base + 260 * t;
}
+ // `cap`: a rounded helmet, lowest in the middle.
+ return base - 100 * (1 - t * t);
+ };
+
+ const steps = 48;
+ ctx.beginPath();
+ ctx.moveTo(left, top);
+ ctx.lineTo(right, top);
+ for (let i = steps; i >= 0; i -= 1) {
+ const x = left + (width * i) / steps;
+ ctx.lineTo(x, boundary(x));
}
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
ctx.restore();
}
@@ -767,7 +841,8 @@ function drawHeadMark(ctx, spec) {
*/
function drawFaceExtras(ctx, style) {
drawCheeks(ctx, style.cheeks);
- drawHeadMark(ctx, style.headMark);
+ // The head covering fills out to the plate's real edges, so it needs `limits`.
+ drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
}
/**
@@ -861,18 +936,6 @@ function drawGlasses(ctx, eye, line, style, limits) {
ctx.strokeStyle = colour;
ctx.lineWidth = frame;
ctx.stroke();
- // A light streak inside the glass, clipped to the lens so it can never spill
- // over the frame. Shaded drawings only: 線画's lens is empty paper.
- if (!line) {
- ctx.save();
- traceRoundedPolygon(ctx, lens, ry * L.corner);
- ctx.clip();
- strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], {
- color: 'rgba(255, 255, 255, 0.5)',
- width: Math.max(2, rx * L.highlightWidth),
- });
- ctx.restore();
- }
} else {
ctx.beginPath();
ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU);
@@ -915,13 +978,13 @@ function drawGlasses(ctx, eye, line, style, limits) {
// --- temple ----------------------------------------------------------
// A stub outwards from the outer edge of the lens. It stays short on purpose:
// the plate only reaches so far, and a temple that ran off it would smear. The
- // round pair's outer edge is a vertical line at the lens centre height; the
- // shades' outer end sits low, so their stub starts down there instead of
- // floating below the lens.
+ // round pair's outer edge is a vertical line at the lens centre height. The
+ // shades' outer end drops low, so their arm leaves from the *top* edge instead
+ // - from the pointed outer corner - which is where a cat-eye's arm attaches.
const outerX = shades
- ? lensCx - eye.towardNose * rx * L.outerEndX
+ ? lensCx - eye.towardNose * rx * L.tipX
: lensCx - eye.towardNose * rx;
- const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : lensCy;
+ const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy;
const reach = clamp(
outerX - eye.towardNose * r * L.templeLength,
limits.left + EDGE_MARGIN,
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index a9d27f8..9c0b98d 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -2076,6 +2076,17 @@ async function init() {
resizeViewport();
}
+ // The panel is a side column on a wide screen and a bottom sheet on a small
+ // one, where it starts closed so the 3D view fills the screen (see the
+ // small-screen rules in src/style.css). When the window is resized across that
+ // breakpoint, match the layout the other side expects instead of leaving the
+ // panel stuck in the previous state.
+ const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ narrowScreenLayout.addEventListener('change', (event) => {
+ document.body.classList.toggle('panel-hidden', event.matches);
+ resizeViewport();
+ });
+
function installKeys() {
window.addEventListener('keydown', (event) => {
// Undo/redo are the only shortcuts that need a modifier, so they are dealt
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 28ea8f9..babeb59 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -115,9 +115,10 @@ export function buildPanel(app, root) {
root.replaceChildren();
- // Four tabs instead of one long scroll: the panel used to be a wall of
- // controls, and everything is easier to find this way. Each tab now carries a
- // small stroke icon so the row can be scanned at a glance.
+ // Tabs instead of one long scroll: the panel used to be a wall of controls,
+ // and everything is easier to find this way. Each tab is a small stroke icon
+ // (its name is the tooltip and the accessible label) sitting on the top edge
+ // of the content, so the row reads as tabs rather than buttons.
const tabBar = tabs(root, [
{
id: 'pose',
@@ -127,7 +128,7 @@ export function buildPanel(app, root) {
},
{
id: 'face',
- label: '表情',
+ label: '顔',
// A smiley face: an outline, two eyes and a smile.
icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
},
@@ -138,6 +139,12 @@ export function buildPanel(app, root) {
icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01',
},
{
+ id: 'scene',
+ label: 'シーン',
+ // A framed picture: background, stage and light all sit inside the frame.
+ icon: 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ },
+ {
id: 'export',
label: '書き出し',
// A download arrow dropping into a tray.
@@ -147,6 +154,7 @@ export function buildPanel(app, root) {
const tabPose = tabBar.panels.pose;
const tabFace = tabBar.panels.face;
const tabView = tabBar.panels.view;
+ const tabScene = tabBar.panels.scene;
const tabExport = tabBar.panels.export;
/* ------------------------------------------------------------------ pose */
@@ -197,8 +205,12 @@ export function buildPanel(app, root) {
// こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the
// main way in, so they stay folded away inside the bone section.
- const rootSliders = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({
- label, min: -2, max: 2, step: 0.01, value: 0,
+ const rootSliders = [
+ { label: 'よこ移動', min: -5, max: 5 },
+ { label: 'たて移動', min: -2, max: 6 },
+ { label: 'おくゆき', min: -5, max: 5 },
+ ].map(({ label, min, max }, index) => slider({
+ label, min, max, step: 0.01, value: 0,
format: (v) => v.toFixed(2),
onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; },
}));
@@ -705,24 +717,43 @@ export function buildPanel(app, root) {
faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ '線画のときはレンズを塗らず、フレームの線だけになります。'));
- const glassesToggle = check({
- label: '眼鏡をかける',
- value: false,
- onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); },
- });
- faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
-
- // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの
- // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、
- // 作りこんだフレームは種類を変えても失われない。
+ // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明
+ // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の
+ // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を
+ // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても
+ // 失われない。
const GLASSES_KINDS = {
- glasses: { lensColor: '#2b2433', lensOpacity: 0.22 },
- sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 },
+ glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 },
+ sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 },
+ };
+ const wearSunglasses = () => {
+ state.face.eyes.glasses.lensOpacity = 1;
+ state.face.eyes.left.open = 0;
+ state.face.eyes.right.open = 0;
};
const setGlassesKind = (kind) => {
Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] });
+ // サングラス hides the eye behind an opaque lens, so both eyes shut with it;
+ // 眼鏡's lens is clear, so switching back opens them again.
+ const open = kind === 'sunglasses' ? 0 : 1;
+ state.face.eyes.left.open = open;
+ state.face.eyes.right.open = open;
};
+ const glassesToggle = check({
+ label: '眼鏡をかける',
+ value: false,
+ onChange: (value) => {
+ state.face.eyes.glasses.enabled = value;
+ // A loaded state can already be on サングラス, so enabling the shades also
+ // shuts the eyes rather than showing them through the opaque lens.
+ if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses();
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
+
const glassesKindSegment = segmented({
label: '種類',
options: [
@@ -833,34 +864,29 @@ export function buildPanel(app, root) {
label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); },
}),
- lines: slider({
- label: '線の数', min: 0, max: 7, step: 1, value: 4,
- format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`),
- onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); },
- }),
};
faceSection.add(cheekSliders.size.el);
- const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)');
+ const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)');
cheekBox.add(cheekSliders.offsetY.el);
cheekBox.add(cheekSliders.spacing.el);
- cheekBox.add(cheekSliders.lines.el);
const cheekColor = colorField({
label: 'ほっぺの色', value: '#f6a6b8',
swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'],
onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); },
});
cheekBox.add(cheekColor.el);
- cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。'
- + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。'));
+ cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、'
+ + '「たかさ」は目からの下がり具合です。'));
const headMarkShape = segmented({
label: '頭の模様',
options: [
{ value: 'off', label: 'なし' },
- { value: 'hook', label: 'かぎ' },
- { value: 'spiral', label: 'うずまき' },
- { value: 'strokes', label: '三本線' },
- { value: 'dots', label: '点' },
+ { value: 'cap', label: 'かぶせ' },
+ { value: 'fringe', label: 'まえがみ' },
+ { value: 'wave', label: 'なみ' },
+ { value: 'side', label: 'わけめ' },
+ { value: 'split', label: 'ツートン' },
],
value: 'off',
onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); },
@@ -877,27 +903,29 @@ export function buildPanel(app, root) {
onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); },
}),
offsetY: slider({
- label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); },
}),
- lineWidth: slider({
- label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
- onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); },
- }),
};
faceSection.add(headMarkSliders.size.el);
- const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)');
+ const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)');
headMarkBox.add(headMarkSliders.offsetX.el);
headMarkBox.add(headMarkSliders.offsetY.el);
- headMarkBox.add(headMarkSliders.lineWidth.el);
const headMarkColor = colorField({
label: '模様の色', value: '#8a4fe0',
swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
});
+ const headMarkColor2 = colorField({
+ label: '2色目', value: '#ffffff',
+ swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'],
+ onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); },
+ });
headMarkBox.add(headMarkColor.el);
- headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、'
- + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。'));
+ headMarkBox.add(headMarkColor2.el);
+ headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。'
+ + '「ツートン」は頭を左右2色に分けます(2色目を使います)。'
+ + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。'));
// --- mouth
faceSection = section(tabFace, '口');
@@ -987,7 +1015,7 @@ export function buildPanel(app, root) {
/* ---------------------------------------------------------------- caption */
- const captionSection = section(tabFace, 'セリフ(吹き出し)');
+ const captionSection = section(tabScene, 'セリフ(吹き出し)');
// Two bubbles share this one set of controls: the selector says which bubble
// they are editing, and every handler below reads the active one when it runs,
@@ -1148,7 +1176,7 @@ export function buildPanel(app, root) {
};
for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
- + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+ + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
/* ------------------------------------------------------ movement (pose tab) */
@@ -1167,6 +1195,7 @@ export function buildPanel(app, root) {
{ value: 'off', label: 'とまる' },
{ value: 'idle', label: 'ゆれる' },
{ value: 'walk', label: '歩く' },
+ { value: 'wave', label: 'てをふる' },
],
value: 'off',
onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; },
@@ -1364,7 +1393,7 @@ export function buildPanel(app, root) {
/* -------------------------------------------------------------- background */
- const backdropSection = section(tabView, '背景');
+ const backdropSection = section(tabScene, '背景');
// 素材 is one button for two state values (a photo or an effect line). This
// remembers which of the two was picked last, so leaving 素材 and coming back
// returns to it instead of always jumping to the photos.
@@ -1540,7 +1569,7 @@ export function buildPanel(app, root) {
},
});
backdropSection.add(controlRow(null, gyroToggle.el, { wide: true }));
- backdropSection.add(hint('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。'));
+ backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。'));
backdropSection.add(buttons({
items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }],
}));
@@ -1549,7 +1578,7 @@ export function buildPanel(app, root) {
// 舞台: the things you put the character *among* - the props and the wall - in
// one place, since they are set up together.
- const stageSection = section(tabView, '舞台');
+ const stageSection = section(tabScene, '舞台');
const propSection = details(stageSection.body, '小物');
propSection.add(buttons({
label: '追加',
@@ -1601,10 +1630,10 @@ export function buildPanel(app, root) {
// The numbers are only a fallback for the drag, so they stay folded away.
const numbers = details(block, '数値で調整');
for (const widget of Object.values(sliders)) numbers.add(widget.el);
- // 看板 only: the writing surface can be enlarged on its own, so the frame
- // and posts stay put. The app rebuilds the props from the state and carries
- // only the whole-prop scale, so the props syncer re-applies this to the
- // fresh mesh (see `applyPropFaceScale`).
+ // 看板 only: the writing area (face *and* frame) can be enlarged on its
+ // own, so the posts and base stay put. The app rebuilds the props from the
+ // state and carries only the whole-prop scale, so the props syncer
+ // re-applies this to the fresh mesh (see `applyPropFaceScale`).
let faceScale = null;
if (item.kind === 'sign') {
faceScale = slider({
@@ -1946,7 +1975,7 @@ export function buildPanel(app, root) {
// 画面・光: the light rig and the camera, which together decide how the picture
// is lit and framed.
- const screenLightSection = section(tabView, '画面・光');
+ const screenLightSection = section(tabScene, '画面・光');
const lightSection = details(screenLightSection.body, 'ひかり');
const lightPad = xyPad({
value: { x: -0.2, y: 0.45 },
@@ -2246,11 +2275,20 @@ export function buildPanel(app, root) {
// A tiny read-out of what the renderer is actually doing: if the picture ever
// looks wrong or never appears, this says why (buffer size, quality, fps).
- const infoSection = section(tabView, '画面の情報');
+ const infoSection = section(tabScene, '画面の情報');
const statsLine = h('p', { class: 'hint', text: '計測中…' });
infoSection.add(statsLine);
infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
+ // シーン: the scene controls are built wherever they belong in the code (the
+ // speech bubble sits with the face controls), so put them on the tab in the
+ // order the user meets them: what is behind the character, what is beside it,
+ // the speech bubble, then the light and the read-out.
+ tabScene.append(
+ backdropSection.el, stageSection.el, captionSection.el,
+ screenLightSection.el, infoSection.el,
+ );
+
boneSyncers.push(() => {
const name = rig.selected;
for (const [key, button] of boneButtons) button.classList.toggle('active', key === name);
@@ -2316,6 +2354,7 @@ export function buildPanel(app, root) {
headMarkShape.set(state.face.eyes.headMark.shape ?? 'off');
for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
headMarkColor.set(state.face.eyes.headMark.color);
+ headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff');
});
syncers.push(() => {
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 3146e0b..b14a6e4 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -186,25 +186,28 @@ export function defaultState() {
// The tears live on each eye (see `eyes.left.tear`), so one eye can cry
// on its own; only the colour is shared.
tearColor: '#8fd8ff',
- // ほっぺ (a manga blush) and 頭の模様 (a mark on the head) are drawn into
- // the same plate as the eyes and brows. Both are generic manga devices,
- // not a copy of any one character, and both start off. The mark's shape
- // is one of `off`, `hook`, `spiral`, `strokes`, `dots`.
+ // ほっぺ (a manga blush) and 頭の模様 (a hair-like head covering) are drawn
+ // into the same plate as the eyes and brows. Both are generic devices,
+ // not a copy of any one character, and both start off. The blush is always
+ // four horizontal strokes, so there is no line-count field.
cheeks: {
enabled: false,
size: 1,
spacing: 0, // extra gap outwards from the eyes
offsetY: 0, // extra drop below the eyes
- lines: 4, // hatch strokes; 0 leaves a plain patch
color: '#f6a6b8',
},
+ // The head covering's `shape` is one of `off`, `cap`, `fringe`, `wave`,
+ // `side` (the boundary style) or `split` (a two-tone head divided by a
+ // vertical curve). `color` is the covering / left half; `color2` is the
+ // second colour of the two-tone split.
headMark: {
shape: 'off',
size: 1,
offsetX: 0,
offsetY: 0,
- lineWidth: 1,
color: '#8a4fe0',
+ color2: '#ffffff',
},
// Eyebrows are drawn into the eye texture. Off by default, because the
// original character has no eyebrows.
@@ -488,9 +491,43 @@ export const FACE_PRESETS = [
*/
export const POSE_PRESETS = [
{ id: 'stand', label: '立ち', pose: { bones: {} } },
- { id: 'bow', label: 'おじぎ', pose: { bones: { master: [22, 0, 0] } } },
- { id: 'deep-bow', label: 'ふかくおじぎ', pose: { bones: { master: [42, 0, 0] } } },
- { id: 'tilt', label: 'くびかしげ', pose: { bones: { master: [0, 0, 16] } } },
+ {
+ id: 'attention',
+ label: 'きおつけ',
+ pose: {
+ bones: {
+ // Both flippers dropped to the sides. A negative X lowers the arm, and
+ // the value is the same on both sides (only Z mirrors between them).
+ 'arm.l': [-62, 0, 0],
+ 'arm.r': [-62, 0, 0],
+ },
+ },
+ },
+ {
+ id: 'bow',
+ label: 'おじぎ',
+ pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'deep-bow',
+ label: 'ふかくおじぎ',
+ pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'tilt',
+ label: 'くびかしげ',
+ pose: {
+ bones: {
+ master: [0, 0, 16],
+ // The roll turns the whole body about its base, which swings the feet up
+ // and down, so the legs take the difference. The right-hand leg (on the
+ // left of the screen) is the one that is planted: it opens outwards a
+ // little and keeps the sole down. The left-hand leg bends back behind it.
+ 'legsupport.r': [8, 0, 0],
+ 'legsupport.l': [-6, 0, 14],
+ },
+ },
+ },
{
id: 'wave',
label: 'てをふる',
@@ -546,7 +583,7 @@ export const POSE_PRESETS = [
},
{
id: 'leap',
- label: 'とびはねる',
+ label: '大ジャンプ',
pose: {
bones: {
master: [-18, 0, 0],
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
index 5ad958a..a5fcbcc 100644
--- a/public/bluebey-studio/src/props.js
+++ b/public/bluebey-studio/src/props.js
@@ -192,19 +192,28 @@ const buildMic = prop((group, material) => {
});
/**
- * 看板. A post, a frame and a blank face. The face is a separate, named child
- * tagged `userData.canvasTexture`, so a later feature can paint a CanvasTexture
- * onto it without touching the frame - and `applyPropFaceScale` can grow just
- * that surface, about its own centre, leaving the frame and posts alone. It is a
- * thin box rather than a plane: the outline pass offsets geometry along its
- * normals, and a zero-thickness plane gets a visibly doubled rim.
+ * 看板. A post, a frame and a blank face. The face is a named child tagged
+ * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it
+ * without hunting for the writable mesh. The face *and* its frame are grouped,
+ * so `applyPropFaceScale` can grow the whole board about the face's centre while
+ * the post and base stay put. Both are thin boxes rather than planes: the outline
+ * pass offsets geometry along its normals, and a zero-thickness plane gets a
+ * visibly doubled rim.
*/
const buildSign = prop((group, material) => {
addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]);
addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]);
- addBox(group, material('accent'), [1.42, 0.98, 0.12], [0, 2.21, 0]);
- const face = addBox(group, material('paper'), [1.18, 0.76, 0.08], [0, 2.21, 0.06]);
+ // The board is one group so the face and its frame scale together, about the
+ // board's own centre - which is also the face's centre.
+ const board = new THREE.Group();
+ board.name = 'sign-board';
+ board.userData.faceScaleGroup = true;
+ board.position.set(0, 2.21, 0);
+ group.add(board);
+
+ addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]);
+ const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]);
face.name = 'sign-face';
face.userData.canvasTexture = true;
});
@@ -284,14 +293,19 @@ const buildCan = prop((group, material) => {
addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16);
// The one lid, peeled back on a hinge at the back of the mouth so its
- // underside shows. It is twisted slightly sideways so it does not read as a
- // knob sitting straight up on the can.
+ // underside shows. The hinge sits *on* the can's back rim and the disc is
+ // placed so its own rim passes through that same point (its centre is one
+ // radius in front of the hinge), so opening the hinge rotates the lid about
+ // its edge - the two rims stay joined instead of the lid floating in mid-air.
+ // It is twisted slightly sideways so it does not read as a knob sitting
+ // straight up on the can.
+ const lidRadius = 0.132;
const hinge = new THREE.Group();
- hinge.position.set(0, 0.42, -0.135);
+ hinge.position.set(0, 0.42, -lidRadius);
hinge.rotation.x = -1.05;
hinge.rotation.z = 0.4;
group.add(hinge);
- addCylinder(hinge, material('metal', 0.9), 0.132, 0.132, 0.018, [0, 0.12, 0], 18);
+ addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18);
group.rotation.z = 0.05;
});
@@ -327,8 +341,9 @@ export const PROP_DEFAULTS = {
podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 },
desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 },
mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 },
- // `faceScale` only means something for a 看板: it grows the writing surface
- // alone, so the frame and posts stay put. The panel owns that control.
+ // `faceScale` only means something for a 看板: it grows the writing area (the
+ // face and its frame) together, so the post and base stay put. The panel owns
+ // that control.
sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1 },
plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 },
box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 },
@@ -419,21 +434,27 @@ export function propFace(group) {
}
/**
- * Scale the writing surface about its own centre, so only the 看板's face grows
- * and its frame and posts stay where they are. `addBox` puts the mesh's origin at
- * the box centre, which is what makes this a scale about the centre rather than
- * about the prop's base.
+ * Scale the writing area - the 看板's face *and* the frame around it - about the
+ * board's own centre, so the post and base stay where they are. A prop that tags
+ * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the
+ * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the
+ * 看板's board group sits at the face centre, which is what makes this a scale
+ * about the centre rather than about the prop's base.
*
* WHY this is applied from the panel: the app rebuilds every prop from the state
* (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale,
* so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards.
*/
export function applyPropFaceScale(group, faceScale) {
- const face = propFace(group);
- if (!face) return;
+ let target = null;
+ group?.traverse?.((object) => {
+ if (!target && object.userData?.faceScaleGroup) target = object;
+ });
+ target ??= propFace(group);
+ if (!target) return;
const value = Number(faceScale);
const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
- face.scale.setScalar(scale);
+ target.scale.setScalar(scale);
}
/** The world-space box the prop occupies; the app drops its contact shadow from it. */
diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css
index 2ca7148..e5287a2 100644
--- a/public/bluebey-studio/src/style.css
+++ b/public/bluebey-studio/src/style.css
@@ -15,6 +15,10 @@
html, body {
margin: 0;
height: 100%;
+ height: 100vh;
+ /* `dvh` tracks the space mobile browser chrome leaves; the lines above are
+ the fallbacks for browsers that do not know it yet. */
+ height: 100dvh;
overflow: hidden;
}
@@ -91,7 +95,8 @@ body:not(.panel-hidden) #stage {
#topbar button:active { transform: translateY(0); }
#topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
-#viewport-hint {
+/* Shared by the mouse hint and the touch hint (see #viewport-hint-touch). */
+.viewport-hint {
position: fixed;
left: 18px;
bottom: 14px;
@@ -102,6 +107,10 @@ body:not(.panel-hidden) #stage {
pointer-events: none;
}
+/* Phones get a touch-specific hint instead of this mouse one; swapped in the
+ small-screen media query at the end of the viewport/panel section below. */
+#viewport-hint-touch { display: none; }
+
/* -------------------------------------------------------------------- panel */
#panel {
@@ -132,6 +141,92 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
#topbar { max-width: calc(100vw - 60px); }
}
+/*
+ * Phones and other short touch screens. The panel becomes a bottom sheet over
+ * the 3D view instead of a side column: the canvas keeps the full screen behind
+ * it, so opening the sheet does not resize the render (and the ≡ button, which
+ * already calls resizeViewport, is all the control needed).
+ */
+@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) {
+ /* Full-screen canvas: the sheet floats over it, so its height never feeds
+ back into the renderer. */
+ #stage,
+ body:not(.panel-hidden) #stage {
+ inset: 0;
+ right: 0;
+ }
+
+ #topbar {
+ top: calc(8px + env(safe-area-inset-top));
+ left: calc(10px + env(safe-area-inset-left));
+ right: calc(10px + env(safe-area-inset-right));
+ max-width: none;
+ gap: 8px;
+ }
+
+ /* Let the title give way first (it can ellipsize); the buttons keep their
+ labels and stay on one line. */
+ #topbar h1 {
+ font-size: 13px;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .topbar-actions {
+ flex: 1;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+ gap: 6px;
+ }
+
+ /* Comfortable tap targets (>= 40px) in a row that still fits 360px. */
+ #topbar button {
+ font-size: 11px;
+ padding: 9px 10px;
+ min-height: 40px;
+ }
+
+ #btn-panel { min-width: 44px; }
+
+ #viewport-hint { display: none; }
+ #viewport-hint-touch {
+ display: block;
+ bottom: calc(14px + env(safe-area-inset-bottom));
+ }
+
+ #panel {
+ top: auto;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ max-height: 65dvh;
+ padding: 20px 14px calc(20px + env(safe-area-inset-bottom));
+ border-left: 0;
+ border-top: 1px solid var(--border);
+ border-radius: 18px 18px 0 0;
+ box-shadow: 0 -6px 24px rgba(40, 20, 80, 0.18);
+ }
+
+ /* A grab handle so the sheet reads as an edge that opens and closes; it
+ sticks to the top of the scroll area as the sheet is scrolled. */
+ #panel::before {
+ content: "";
+ position: sticky;
+ top: 0;
+ display: block;
+ width: 44px;
+ height: 5px;
+ margin: 0 auto 12px;
+ border-radius: 999px;
+ background: #cfc6e4;
+ }
+
+ body.panel-hidden #topbar { max-width: none; }
+}
+
/* ----------------------------------------------------------------- sections */
.tabs {
@@ -139,27 +234,41 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
top: 0;
z-index: 3;
display: flex;
- gap: 4px;
- padding: 2px 0 10px;
- margin: -2px 0 4px;
+ align-items: flex-end;
+ gap: 3px;
+ padding: 2px 0 0;
+ margin: -2px 0 8px;
+ border-bottom: 2px solid var(--border);
background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0));
}
+/* Each tab is a rounded-top tab sitting on the strip's baseline; only the icon
+ shows, and the active one fills with the accent and covers the baseline. */
.tab {
flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
font: inherit;
- font-size: 12px;
- padding: 8px 2px;
+ padding: 9px 2px 7px;
border: 1px solid var(--border);
- border-radius: 9px;
- background: #fff;
+ border-bottom: none;
+ border-radius: 9px 9px 0 0;
+ background: var(--accent-soft);
color: var(--muted);
cursor: pointer;
white-space: nowrap;
}
-.tab:hover { border-color: var(--accent-2); }
-.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
+.tab svg { display: block; width: 20px; height: 20px; }
+.tab:hover { color: var(--accent-2); border-color: var(--accent-2); }
+.tab.active {
+ margin-bottom: -2px;
+ padding-bottom: 9px;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
.tab-panel[hidden] { display: none; }
.details {
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js
index 71f79b4..e184f5e 100644
--- a/public/bluebey-studio/src/ui.js
+++ b/public/bluebey-studio/src/ui.js
@@ -84,8 +84,9 @@ function tabIcon(d) {
}
/**
- * Tabbed container: returns the per-tab panels to fill in. Each definition may
- * carry an `icon` (an SVG path `d`); the label stays visible next to it.
+ * Tabbed container: returns the per-tab panels to fill in. When a definition
+ * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the
+ * label becomes its tooltip and accessible name; without one the label shows.
*/
export function tabs(parent, definitions) {
const bar = h('div', { class: 'tabs' });
@@ -101,10 +102,14 @@ export function tabs(parent, definitions) {
};
for (const definition of definitions) {
- const button = h('button', { type: 'button', class: 'tab' },
- definition.icon ? tabIcon(definition.icon) : null,
- definition.icon ? ' ' : null,
- h('span', { class: 'tab-label', text: definition.label }));
+ const button = h('button', { type: 'button', class: 'tab' });
+ if (definition.icon) {
+ button.append(tabIcon(definition.icon));
+ button.title = definition.label;
+ button.setAttribute('aria-label', definition.label);
+ } else {
+ button.append(h('span', { class: 'tab-label', text: definition.label }));
+ }
button.addEventListener('click', () => select(definition.id));
buttons.set(definition.id, button);
bar.append(button);
diff --git a/public/bluebey/images/bluebey-scene.webp b/public/bluebey/images/bluebey-scene.webp
index d308b9d..9287a85 100644
--- a/public/bluebey/images/bluebey-scene.webp
+++ b/public/bluebey/images/bluebey-scene.webp
Binary files differ