aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 23:39:45 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 23:39:45 +0900
commitfa3411f87c72f249168b00e6338027512ce7e894 (patch)
tree7e8a6d5a8cfdfb2d7a2d6128f5e7bb5e0220d1b7 /public/bluebey-studio
parenteed94d378af577171e24fe78b4b159be5ca50317 (diff)
bluebey-studio: 小物にベッドと布団、帽子の着脱機能を追加
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/src/hats.js116
-rw-r--r--public/bluebey-studio/src/main.js52
-rw-r--r--public/bluebey-studio/src/panel.js14
-rw-r--r--public/bluebey-studio/src/presets.js2
-rw-r--r--public/bluebey-studio/src/props.js37
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. */