diff options
| -rw-r--r-- | public/bluebey-studio/src/hats.js | 116 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 52 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 14 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 2 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 37 |
5 files changed, 221 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js new file mode 100644 index 0000000..c639548 --- /dev/null +++ b/public/bluebey-studio/src/hats.js @@ -0,0 +1,116 @@ +import * as THREE from 'three'; + +/** + * Hats for ぶるべー. Each hat is a small group of primitives whose base sits at + * y = 0, so the app can drop it straight onto the top of the head (see + * `hatMount` in src/main.js). The character's front is +z, so a brim or a badge + * faces +z. + * + * These are deliberately not textured: like the props, they are built from a few + * rounded primitives so they take a clean outline and read in every render style. + */ + +const material = (color, opts = {}) => new THREE.MeshStandardMaterial({ + color, + roughness: 0.72, + metalness: 0, + ...opts, +}); + +function add(parent, geometry, mat, x = 0, y = 0, z = 0) { + const mesh = new THREE.Mesh(geometry, mat); + mesh.position.set(x, y, z); + mesh.castShadow = true; + mesh.receiveShadow = false; + parent.add(mesh); + return mesh; +} + +const cylinder = (rt, rb, h, seg = 24) => new THREE.CylinderGeometry(rt, rb, h, seg); +const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI) => ( + new THREE.SphereGeometry(r, w, h, phiStart, phiLength, thetaStart, thetaLength) +); +const box = (w, h, d) => new THREE.BoxGeometry(w, h, d); + +/** シルクハット: a tall black crown, a narrow band and a flat brim. */ +function buildSilk() { + const group = new THREE.Group(); + const black = material('#1b1722'); + const band = material('#3a2f4a'); + add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0); + add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0); + add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0); + return group; +} + +/** キャップ: a baseball cap - a low dome, a rim and a bill at the front. */ +function buildCap() { + const group = new THREE.Group(); + const blue = material('#3f6fd0'); + const dark = material('#2f54a4'); + // The crown: a hemisphere whose flat face sits on y = 0. + const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0); + crown.scale.set(1, 0.92, 1); + add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0); + // The bill, tipped down a little at the front. + const bill = add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92); + bill.scale.set(1, 1, 1.25); + bill.rotation.x = -0.12; + add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0); + return group; +} + +/** コック帽: a tall white band under a puffy crown. */ +function buildChef() { + const group = new THREE.Group(); + const white = material('#f7f6f2'); + const shade = material('#e6e3da'); + add(group, cylinder(0.94, 0.98, 0.62, 28), white, 0, 0.31, 0); + const puff = add(group, sphere(1.22, 24, 18), white, 0, 0.86, 0); + puff.scale.set(1, 0.82, 1); + for (let i = 0; i < 6; i += 1) { + const a = (i / 6) * Math.PI * 2; + const lobe = add(group, sphere(0.44, 14, 12), shade, Math.cos(a) * 0.86, 1.22, Math.sin(a) * 0.86); + lobe.scale.set(1, 0.85, 1); + } + return group; +} + +/** 消防士の帽子: a red helmet with a brim, a top ridge and a small front badge. */ +function buildFire() { + const group = new THREE.Group(); + const red = material('#c5342f'); + const dark = material('#8f241f'); + const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 }); + const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0); + dome.scale.set(1, 0.95, 1); + const brim = add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18); + brim.scale.set(1, 1, 1.1); + // The ridge along the top, front to back. + const crest = add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0); + crest.rotation.x = -0.05; + add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02); + return group; +} + +const BUILDERS = { + silk: buildSilk, + cap: buildCap, + chef: buildChef, + fire: buildFire, +}; + +/** The hats offered in the panel: `none` clears it. */ +export const HAT_LIBRARY = [ + { id: 'none', label: 'なし' }, + { id: 'silk', label: 'シルクハット' }, + { id: 'cap', label: 'キャップ' }, + { id: 'chef', label: 'コック帽' }, + { id: 'fire', label: '消防士' }, +]; + +/** Builds the named hat, or `null` for `none` / an unknown name. */ +export function buildHat(id) { + const builder = BUILDERS[id]; + return builder ? builder() : null; +} diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 60507eb..01ee85a 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -12,6 +12,7 @@ import { toast } from './ui.js'; import { History } from './history.js'; import { createLook } from './look.js'; import { createClipper } from './clip.js'; +import { buildHat } from './hats.js'; import { createBackdrop, backdropStyle } from './background.js'; import { drawCaption, layoutCaption } from './caption.js'; import { loadFont, captionFontStack } from './textOutlines.js'; @@ -360,6 +361,53 @@ async function init() { const snotMeshes = snotBubble ? [...snotBubble.children] : []; applySnotBubble(snotBubble, state.face.eyes.snot); + // The hat rides the `master` bone like the snot bubble does: it is parented + // there, placed at the top of the head in that bone's local frame, and turned + // so it is world-aligned at rest (the bone's own rest rotation cancelled once). + // The character's front is +z, so the hats are modelled facing +z. + const hatMount = (() => { + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!master) return null; + model.root.updateMatrixWorld(true); + const bone = master.bone; + const group = new THREE.Group(); + group.name = 'hat'; + group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0))); + group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert()); + bone.add(group); + return group; + })(); + + let hatKindCurrent = null; + let hatObject = null; + const hatMeshes = []; + /** Set once the outline pass exists, so a hat change can refresh the exclude set. */ + let refreshOutlineExclusion = null; + function applyHat(kind) { + if (kind === hatKindCurrent) return; + hatKindCurrent = kind; + if (!hatMount) return; + if (hatObject) { + hatMount.remove(hatObject); + hatObject.traverse((o) => { + o.geometry?.dispose(); + if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); + else o.material?.dispose(); + }); + hatObject = null; + } + hatMeshes.length = 0; + const built = buildHat(kind); + if (built) { + hatMount.add(built); + hatObject = built; + built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); }); + } + refreshOutlineExclusion?.(); + app.needsRender = true; + } + applyHat(state.face.hat?.kind ?? 'none'); + const halfHeight = model.size.y * 0.5; view.frame(model.size, halfHeight); @@ -1186,10 +1234,12 @@ async function init() { model.parts.mouthMesh, ...(model.parts.hairMesh ? [model.parts.hairMesh] : []), ...snotMeshes, + ...hatMeshes, ...styles.outlineMeshes, ]; } outline.exclude(outlineExclusion()); + refreshOutlineExclusion = () => outline.exclude(outlineExclusion()); // The wall is *not* excluded: it is handed to the pass as an occluder, so the // labels behind it are culled as well. Excluded, the leaves and the nose // buried in the wall kept their outlines, because the labels are read before @@ -1383,6 +1433,8 @@ async function init() { look.contact.position.x = container.position.x; look.contact.position.z = container.position.z; + applyHat(state.face.hat?.kind ?? 'none'); + if (snotBubble) { const snot = state.face.eyes.snot; const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 723d806..67022aa 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -11,6 +11,7 @@ import { EYE_VARIANTS } from './model.js'; import { ENVIRONMENTS } from './look.js'; import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js'; import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js'; +import { HAT_LIBRARY } from './hats.js'; import { GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH, } from './gion.js'; @@ -1615,6 +1616,19 @@ export function buildPanel(app, root) { }], })); + /* ------------------------------------------------------------- 帽子 */ + + const hatSection = section(tabView, '帽子'); + hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。もう一度「なし」を選ぶと外れます。')); + const hatSegment = segmented({ + label: '帽子', + options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })), + value: 'none', + onChange: (value) => { state.face.hat.kind = value; app.actions.refresh('all'); }, + }); + hatSection.add(hatSegment.el); + syncers.push(() => hatSegment.set(state.face.hat?.kind ?? 'none')); + /* ------------------------------------------------------------- body colour */ const colourSection = section(tabView, '体の色'); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index e12fe9a..43fc12f 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -193,6 +193,8 @@ export function defaultState() { speed: 1, }, face: { + // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js. + hat: { kind: 'none' }, eyes: { source: 'parametric', // parametric | opened | closed | ... (hand-drawn) linked: true, // move both eyes together diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js index ba682e4..3f09651 100644 --- a/public/bluebey-studio/src/props.js +++ b/public/bluebey-studio/src/props.js @@ -310,6 +310,39 @@ const buildCan = prop((group, material) => { group.rotation.z = 0.05; }); +/** + * ベッド. A wooden frame on four legs with a mattress, a headboard at the far end + * and a pillow, sized so the character can be posed lying on it. + */ +const buildBed = prop((group, material) => { + // Sized to take the character lying down: the body is a sphere of radius ~1.6, + // so the frame is a little over 3 units across and 4 units long. + for (const x of [-1.55, 1.55]) { + for (const z of [-1.85, 1.85]) { + addBox(group, material('wood'), [0.22, 0.5, 0.22], [x, 0.25, z]); + } + } + addBox(group, material('wood'), [3.5, 0.26, 4.3], [0, 0.63, 0]); + // The headboard sits at the far (-z) end, away from the camera. + addBox(group, material('wood'), [3.5, 1.5, 0.2], [0, 1.25, -2.15]); + addBox(group, material('paper'), [3.3, 0.46, 4.1], [0, 0.99, 0]); + + // The pillow, slightly tipped up against the headboard. + const pillow = addBox(group, material('paper', 0.9), [2.2, 0.34, 0.9], [0, 1.39, -1.5]); + pillow.rotation.x = -0.08; +}); + +/** + * 布団. A mattress laid on the floor, a pillow and a quilt folded over the lower + * end. + */ +const buildFuton = prop((group, material) => { + addBox(group, material('paper'), [3.6, 0.3, 4.4], [0, 0.15, 0]); + const pillow = addBox(group, material('paper', 0.9), [2.2, 0.3, 0.9], [0, 0.45, -1.6]); + pillow.rotation.x = -0.06; + addBox(group, material('accent', 0.8), [3.6, 0.24, 2.7], [0, 0.42, 0.85]); +}); + // --------------------------------------------------------------------------- // The library // --------------------------------------------------------------------------- @@ -328,6 +361,8 @@ export const PROP_LIBRARY = [ { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant }, { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox }, { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan }, + { id: 'bed', label: 'ベッド', height: 2.0, build: buildBed }, + { id: 'futon', label: '布団', height: 0.65, build: buildFuton }, ]; /** @@ -348,6 +383,8 @@ export const PROP_DEFAULTS = { 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 }, + bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 }, + futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 }, }; /** The palette entry each part reads, derived from the app's colours. */ |
