aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 15:44:38 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 15:44:38 +0900
commitfbaf2cc671fed23b555e218499301af0f50327b0 (patch)
treedbdbf5bca92004085feebec920019800472672e5 /public
parent4cf9c37ecd568dab470b5714bff4ff9f860a5377 (diff)
public/bluebey-studio: 移動範囲と影・壁の追従、看板の文字
移動スライダーの範囲を広げ、キーライトと影カメラがぶるべーに追従するようにした(遠くへ動かしても影が切れない)。地面も広げた。 足もとの丸い影は既定で非表示にし、「足もとの丸い影」で切り替えられるようにした。原点に取り残されないよう、ぶるべーに追従させる。 見えない壁は体の向き(master のY回転と見る先の体向き)にも追従するようにした。 「舞台」をやめ、「小物」「擬音」「見えない壁」をそれぞれ独立したメニューにした。看板に文字を書けるようにした(複数行可)。
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/src/clip.js18
-rw-r--r--public/bluebey-studio/src/look.js6
-rw-r--r--public/bluebey-studio/src/main.js56
-rw-r--r--public/bluebey-studio/src/panel.js52
-rw-r--r--public/bluebey-studio/src/presets.js3
-rw-r--r--public/bluebey-studio/src/props.js63
6 files changed, 169 insertions, 29 deletions
diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js
index b5b03f4..4fdcfd9 100644
--- a/public/bluebey-studio/src/clip.js
+++ b/public/bluebey-studio/src/clip.js
@@ -138,16 +138,22 @@ export function createClipper({ scene, renderer, model }) {
* @param {{x?:number,y?:number,z?:number}} [offset] the character's own
* translation. A wall's position is stored *relative to the character*, so
* adding this makes the walls travel with the body when it is moved.
+ * @param {number} [yaw] the character's own turn about Y, in radians. The stored
+ * position is rotated by it (and added to the wall's own `yaw`), so a wall
+ * stays glued to the body when the character is turned.
*
* The guide is drawn only when `wall.guide` is on, which is also when the wall
* can be grabbed in the viewport. Off, the wall is invisible: the cut still
* applies, so you can see the character half-hidden with nothing in the way.
*/
- function apply(walls, offset) {
+ function apply(walls, offset, yaw = 0) {
const list = Array.isArray(walls) ? walls : [walls];
const ox = offset?.x ?? 0;
const oy = offset?.y ?? 0;
const oz = offset?.z ?? 0;
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const yawDeg = (yaw * 180) / Math.PI;
slots.forEach((slot, index) => {
const wall = list[index];
if (!wall || wall.on !== true) {
@@ -155,10 +161,14 @@ export function createClipper({ scene, renderer, model }) {
slot.guide.visible = false;
return;
}
- const x = (wall.x ?? 0) + ox;
+ // The stored position is relative to the character, so it turns with the
+ // body: rotate (x, z) about Y by the character's yaw, then add the offset.
+ const lx = wall.x ?? 0;
+ const lz = wall.z ?? 0;
+ const x = cos * lx + sin * lz + ox;
const y = (wall.y ?? 0) + oy;
- const z = (wall.z ?? 0) + oz;
- const { normal } = wallPlane({ yaw: wall.yaw, tilt: wall.tilt, x, y, z });
+ const z = -sin * lx + cos * lz + oz;
+ const { normal } = wallPlane({ yaw: (wall.yaw ?? 0) + yawDeg, tilt: wall.tilt, x, y, z });
slot.normal.set(normal[0], normal[1], normal[2]);
const span = guideSpan * Math.max(0.05, wall.size ?? 1);
// The wall and its guide are the same rectangle, turned to face along the
diff --git a/public/bluebey-studio/src/look.js b/public/bluebey-studio/src/look.js
index f68b602..d755c3b 100644
--- a/public/bluebey-studio/src/look.js
+++ b/public/bluebey-studio/src/look.js
@@ -130,7 +130,11 @@ export function createLook({ renderer, scene, styles, model, ground, key, charac
// where the body hides its own shadow right at the feet, which otherwise
// reads as "the shadow is cut off". It is the only thing keeping the
// character looking like it is standing on the floor rather than above it.
- contact.visible = wantsShadow;
+ // The blob sits at the feet. It used to be drawn whenever the shadow was on,
+ // which left a faint ring behind at the origin once the character was moved;
+ // it is now opt-in (`render.contactBlob`, default off). It is always shown in
+ // the 「接地影だけ」 mode, where it is the only shadow there is.
+ contact.visible = wantsShadow && (blobOnly || render.contactBlob === true);
contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6));
const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6));
contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25));
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index f2434b9..051554d 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -15,7 +15,7 @@ import { createClipper } from './clip.js';
import { createBackdrop, backdropStyle } from './background.js';
import { drawCaption, layoutCaption } from './caption.js';
import { loadFont, captionFontStack } from './textOutlines.js';
-import { buildProp, PROP_DEFAULTS, disposeProp } from './props.js';
+import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
import { rollAll, decodeSeed } from './gacha.js';
import { encodeState, decodeState } from './urlState.js';
import { createMouthFlap, levelToMouth } from './mouthFlap.js';
@@ -132,7 +132,7 @@ async function init() {
// ever hiding the character, so moving ぶるべー below it no longer makes the body
// (or its textures) vanish - the shadow still falls on it, but it never occludes.
const ground = new THREE.Mesh(
- new THREE.PlaneGeometry(60, 60),
+ new THREE.PlaneGeometry(200, 200),
new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }),
);
ground.rotation.x = -Math.PI / 2;
@@ -367,6 +367,15 @@ async function init() {
shadowCamera.updateProjectionMatrix();
key.target.position.set(0, halfHeight, 0);
+ // The light's offset from the character, so the key light (and therefore its
+ // shadow camera) can follow the character around: moving far from the origin
+ // used to leave the shadow behind and cut it off at the shadow camera's edge.
+ const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3);
+ function updateKeyLight() {
+ key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z);
+ key.position.copy(key.target.position).add(keyOffset);
+ }
+
// ------------------------------------------------------------- controllers
const styles = new Styles({ meshes: model.parts.body });
const face = new Face({
@@ -399,7 +408,18 @@ async function init() {
const root = state.pose?.root;
return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 };
};
- const applyWalls = () => clip.apply([state.render.wall, state.render.wall2], characterOffset());
+ const applyWalls = () => clip.apply(
+ [state.render.wall, state.render.wall2],
+ characterOffset(),
+ wallYaw(),
+ );
+ // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any
+ // body yaw the 見る先 mode added. The walls take this too, so turning the
+ // character with Shift+drag no longer slides the hidden region around.
+ const wallYaw = () => {
+ const masterY = state.pose?.bones?.master?.[1] ?? 0;
+ return (masterY + (container.rotation.y * 180) / Math.PI) * DEG;
+ };
// --------------------------------------------------------------- backdrop
const backdrop = createBackdrop({
@@ -744,6 +764,9 @@ async function init() {
group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0);
group.rotation.y = ((item.rotY ?? def.rotY ?? 0) * DEG);
propRoot.add(group);
+ // 看板 only: paint the saved text onto the freshly built face (no-op for
+ // every other prop, which has no writing surface).
+ applyPropText(group, item.text);
}
app.needsRender = true;
}
@@ -854,14 +877,19 @@ async function init() {
propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point);
const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]);
const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = onWall.point.x - offset.x;
+ const pz = onWall.point.z - offset.z;
wallDrag.pointerId = event.pointerId;
wallDrag.key = onWall.key;
// Keep the grab point's offset *in the wall's own (character-relative) frame*,
// so grabbing a corner does not make the wall jump.
wallDrag.offset.set(
- (wall.x ?? 0) - (onWall.point.x - offset.x),
+ (wall.x ?? 0) - (cos * px - sin * pz),
(wall.y ?? 0) - (onWall.point.y - offset.y),
- (wall.z ?? 0) - (onWall.point.z - offset.z),
+ (wall.z ?? 0) - (sin * px + cos * pz),
);
stage.style.cursor = 'grabbing';
app.needsRender = true;
@@ -877,13 +905,18 @@ async function init() {
propRaycaster.setFromCamera(pointerNdc(event), view.camera);
if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = propPoint.x - offset.x;
+ const pz = propPoint.z - offset.z;
const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]);
// Back into the wall's character-relative frame, then the same ranges the
// panel's sliders offer, so a drag and a number always describe the same
// place.
- wall.x = round2(clampNumber(propPoint.x - offset.x + wallDrag.offset.x, -4, 4));
+ wall.x = round2(clampNumber(cos * px - sin * pz + wallDrag.offset.x, -4, 4));
wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4));
- wall.z = round2(clampNumber(propPoint.z - offset.z + wallDrag.offset.z, -4, 4));
+ wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4));
applyWalls();
app.needsRender = true;
return;
@@ -1281,6 +1314,10 @@ async function init() {
const posed = animator.pose(state.pose);
if (!rig.controls.dragging) rig.applyPose(posed);
container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0);
+ updateKeyLight();
+ // The contact blob rides under the character, so it never lingers at the origin.
+ look.contact.position.x = container.position.x;
+ look.contact.position.z = container.position.z;
if (snotBubble) {
const snot = state.face.eyes.snot;
@@ -1493,11 +1530,12 @@ async function init() {
const azimuth = state.render.lightAzimuth * DEG;
const elevation = state.render.lightElevation * DEG;
const distance = model.size.y * 3;
- key.position.set(
+ keyOffset.set(
Math.cos(elevation) * Math.sin(azimuth) * distance,
- Math.sin(elevation) * distance + halfHeight,
+ Math.sin(elevation) * distance,
Math.cos(elevation) * Math.cos(azimuth) * distance,
);
+ updateKeyLight();
key.intensity = state.render.lightIntensity;
ambient.intensity = state.render.ambient;
renderer.toneMappingExposure = state.render.exposure;
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index e9764c1..4b2a4c0 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -10,7 +10,7 @@ import { STYLE_DEFS } from './styles.js';
import { EYE_VARIANTS } from './model.js';
import { ENVIRONMENTS } from './look.js';
import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
-import { PROP_LIBRARY, applyPropFaceScale } from './props.js';
+import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js';
import {
GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
} from './gion.js';
@@ -244,9 +244,9 @@ 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 = [
- { label: 'よこ移動', min: -5, max: 5 },
- { label: 'たて移動', min: -2, max: 6 },
- { label: 'おくゆき', min: -5, max: 5 },
+ { label: 'よこ移動', min: -20, max: 20 },
+ { label: 'たて移動', min: -10, max: 20 },
+ { label: 'おくゆき', min: -20, max: 20 },
].map(({ label, min, max }, index) => slider({
label, min, max, step: 0.01, value: 0,
format: (v) => v.toFixed(2),
@@ -1526,7 +1526,15 @@ export function buildPanel(app, root) {
onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); },
});
lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true }));
- lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。「接地影だけ」は足もとの丸い影だけにします。'));
+ const contactBlobToggle = check({
+ label: '足もとの丸い影',
+ value: false,
+ onChange: (value) => { state.render.contactBlob = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactBlobToggle.el, { wide: true }));
+ lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。'
+ + '「接地影だけ」は足もとの丸い影だけにします。'
+ + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。'));
const environmentSegment = segmented({
label: '照明の種類',
@@ -1721,10 +1729,10 @@ export function buildPanel(app, root) {
/* ------------------------------------------------------------------- props */
- // 舞台: the things you put the character *among* - the props and the wall - in
- // one place, since they are set up together.
- const stageSection = section(tabScene, '舞台');
- const propSection = details(stageSection.body, '小物');
+ // 小物: the things you put the character *among*. Each stage feature gets its
+ // own top-level section rather than one shared 舞台 drawer, so they are easier
+ // to find and open independently.
+ const propSection = section(tabScene, '小物');
propSection.add(buttons({
label: '追加',
items: PROP_LIBRARY.map((entry) => ({
@@ -1780,7 +1788,21 @@ export function buildPanel(app, root) {
// state and carries only the whole-prop scale, so the props syncer
// re-applies this to the fresh mesh (see `applyPropFaceScale`).
let faceScale = null;
+ let signText = null;
if (item.kind === 'sign') {
+ signText = textArea({
+ label: '看板の文字(改行できます)',
+ value: '',
+ onChange: (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target.text = value;
+ // Paint straight onto the live face rather than rebuilding every prop
+ // on each keystroke; a later rebuild re-applies it (see main.js).
+ applyPropText(placedPropGroups(app)[index], value);
+ app.needsRender = true;
+ },
+ });
+ block.insertBefore(signText.el, numbers.el);
faceScale = slider({
label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1,
format: (v) => v.toFixed(2), onInput: patch('faceScale'),
@@ -1801,7 +1823,7 @@ export function buildPanel(app, root) {
}],
}).el);
propListBox.append(block);
- propWidgets.push({ index, sliders, faceScale });
+ propWidgets.push({ index, sliders, faceScale, signText });
});
};
renderProps();
@@ -1812,7 +1834,7 @@ export function buildPanel(app, root) {
// so the user picks the crop with a marquee instead of the app slicing them
// (see src/gion.js for why). Only the sheet name and the crop rectangle live in
// the state - never the bitmap - so a shared link stays small.
- const gionSection = details(stageSection.body, '擬音(マンガのオノマトペ)');
+ const gionSection = section(tabScene, '擬音(マンガのオノマトペ)');
gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。'
+ '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。'
+ '貼ったあとはビューポートでドラッグして動かせます。'));
@@ -2069,7 +2091,7 @@ export function buildPanel(app, root) {
// 見えない壁: place up to two finite "boards" and bury the character in them, so
// only the part in front still shows. A wall's position is kept *relative to the
// character*, so moving ぶるべー carries its walls along (see src/clip.js).
- const wallSection = details(stageSection.body, '見えない壁');
+ const wallSection = section(tabScene, '見えない壁');
wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。'
@@ -2443,8 +2465,8 @@ export function buildPanel(app, root) {
// 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,
+ backdropSection.el, propSection.el, gionSection.el, wallSection.el,
+ captionSection.el, screenLightSection.el, infoSection.el,
);
boneSyncers.push(() => {
@@ -2584,6 +2606,7 @@ export function buildPanel(app, root) {
shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22);
shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6);
contactShadowToggle.set(state.render?.contactShadow === true);
+ contactBlobToggle.set(state.render?.contactBlob === true);
environmentSegment.set(state.render?.environment ?? 'gradient');
});
@@ -2618,6 +2641,7 @@ export function buildPanel(app, root) {
entry.sliders.scale.set(item.scale ?? 1);
entry.faceScale?.set(item.faceScale ?? 1);
applyPropFaceScale(groups[entry.index], item.faceScale);
+ entry.signText?.set(item.text ?? '');
}
});
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 12b14fd..aee3a5f 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -105,6 +105,9 @@ export function defaultState() {
outlineColor: '#2a1e33',
paper: '#ffffff',
shadow: true,
+ // The soft round blob at the character's feet. Off by default: it sat at the
+ // origin and stayed behind when the character was moved. See look.js.
+ contactBlob: false,
pngScale: 2,
pngTransparent: false,
svgWidth: 2048,
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
index a5fcbcc..ba682e4 100644
--- a/public/bluebey-studio/src/props.js
+++ b/public/bluebey-studio/src/props.js
@@ -344,7 +344,7 @@ export const PROP_DEFAULTS = {
// `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 },
+ sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1, text: '' },
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 },
can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 },
@@ -457,6 +457,67 @@ export function applyPropFaceScale(group, faceScale) {
target.scale.setScalar(scale);
}
+/**
+ * Paint text onto a prop's writing surface (the 看板's face), or clear it.
+ *
+ * The face's material gets a `CanvasTexture`, drawn here with a system font stack
+ * (no font file is needed, and Japanese falls back to whatever the device has).
+ * Long lines and long blocks are shrunk to fit the board, and explicit newlines
+ * are honoured. The material is per-prop (see `materialCache`), so the map never
+ * leaks to another sign.
+ */
+export function applyPropText(group, text) {
+ if (typeof document === 'undefined') return; // Node (tests): no canvas
+ const face = propFace(group);
+ const material = face?.material;
+ if (!material) return;
+ const value = typeof text === 'string' ? text : '';
+
+ if (!value.trim()) {
+ if (material.map) {
+ material.map.dispose();
+ material.map = null;
+ material.needsUpdate = true;
+ }
+ material.userData.signText = '';
+ return;
+ }
+ if (material.userData.signText === value) return; // nothing changed
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 330;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#f4efe2';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#241a30';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ const family = '"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif';
+ const lines = value.split(/\r\n|\r|\n/);
+ const maxWidth = canvas.width * 0.86;
+ let size = 150;
+ const fits = () => {
+ ctx.font = `700 ${size}px ${family}`;
+ return lines.every((line) => ctx.measureText(line).width <= maxWidth)
+ && lines.length * size * 1.15 <= canvas.height * 0.86;
+ };
+ while (size > 18 && !fits()) size -= 2;
+ ctx.font = `700 ${size}px ${family}`;
+ const step = size * 1.15;
+ const startY = canvas.height / 2 - ((lines.length - 1) * step) / 2;
+ lines.forEach((line, index) => ctx.fillText(line, canvas.width / 2, startY + index * step));
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 4;
+ if (material.map) material.map.dispose();
+ material.map = texture;
+ material.userData.signText = value;
+ material.needsUpdate = true;
+}
+
/** The world-space box the prop occupies; the app drops its contact shadow from it. */
export function propBounds(group) {
group.updateMatrixWorld(true);