diff options
Diffstat (limited to 'public/bluebey-studio')
| -rw-r--r-- | public/bluebey-studio/src/clip.js | 18 | ||||
| -rw-r--r-- | public/bluebey-studio/src/look.js | 6 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 56 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 52 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 3 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 63 |
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); |
