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); /** * The 線画 fill a part takes, as how far it steps from the paper towards the ink * (see `addMesh` in src/styles.js). Every hat part already gets the light default * tone, but two parts of the *same* hat then come out the same shade - so a part * that sits flush on another (a band on a crown) loses its seam. Tagging it with * a darker tone puts that seam back, without touching リアル / フラット, where the * part's own colour already draws it. */ function tone(mesh, strength) { mesh.userData.tone = strength; return mesh; } /** シルクハット: a tall felt crown that widens a little at the top, a rolled brim * and a ribbon with a flat bow. */ function buildSilk() { const group = new THREE.Group(); const felt = material('#17131e', { roughness: 0.55 }); const ribbon = material('#352a45'); // Brim: a shallow disc with a rolled edge, so it has a thickness in line art. add(group, cylinder(1.80, 1.66, 0.10, 40), felt, 0, 0.05, 0); const rim = add(group, new THREE.TorusGeometry(1.785, 0.075, 10, 48), felt, 0, 0.095, 0); rim.rotation.x = Math.PI / 2; tone(rim, 0.22); // Crown: a lathe, very slightly wider at the top - the classic top-hat flare. const profile = [ [1.00, 0.00], [0.985, 0.10], [0.975, 0.88], [0.99, 1.32], [1.02, 1.49], [1.03, 1.57], [0.95, 1.64], [0.62, 1.67], [0.0, 1.68], ].map(([x, y]) => new THREE.Vector2(x, y)); add(group, new THREE.LatheGeometry(profile, 40), felt, 0, 0.09, 0); // Ribbon: a band around the base, with a flat bow on the left side. tone(add(group, cylinder(1.015, 1.02, 0.20, 40), ribbon, 0, 0.20, 0), 0.30); const bow = tone(add(group, box(0.07, 0.20, 0.30), ribbon, -0.99, 0.20, 0), 0.14); bow.rotation.y = 0.16; return group; } /** キャップ: a low six-panel crown with a button, a band and a long curved bill. */ function buildCap() { const group = new THREE.Group(); const cloth = material('#3f6fd0', { roughness: 0.82 }); const dark = material('#2c4d96', { roughness: 0.86 }); const button = material('#243c74', { roughness: 0.7 }); // The crown: a hemisphere whose flat face sits on y = 0, squashed and leaning // a touch forward so it reads as fabric, not a bowl. const crown = add(group, sphere(1.05, 32, 20, 0, Math.PI * 2, 0, Math.PI / 2), cloth, 0, 0.02, 0.02); crown.scale.set(1, 0.88, 1.03); // The band around the rim, and the button on top. tone(add(group, cylinder(1.075, 1.075, 0.14, 32), dark, 0, 0.07, 0.02), 0.22); tone(add(group, sphere(0.10, 14, 10), button, 0, 0.925, 0.02), 0.18); // The bill: an oval that is long at the front and tucked under the crown at // the back, tipped down a little. const bill = tone(add(group, cylinder(1.12, 1.12, 0.075, 32), material('#5b86e0', { roughness: 0.82 }), 0, 0.075, 0.75), 0.26); bill.scale.set(1, 1, 1.9); bill.rotation.x = -0.28; return group; } /** コック帽: a low band under a mushroom-shaped puff with vertical pleats. */ function buildChef() { const group = new THREE.Group(); const cloth = material('#f8f7f3', { roughness: 0.9 }); const shade = material('#e4e1d7', { roughness: 0.9 }); // The band that sits on the head. add(group, cylinder(0.86, 0.90, 0.62, 32), cloth, 0, 0.31, 0); tone(add(group, cylinder(0.885, 0.885, 0.06, 32), shade, 0, 0.59, 0), 0.24); // The puff: a lathe profile that swells out past the band and closes over the // top, which gives the silhouette a toque has (a ball just looks like a head). const profile = [ [0.86, 0.60], [1.10, 0.68], [1.28, 0.85], [1.35, 1.10], [1.32, 1.35], [1.18, 1.56], [0.86, 1.68], [0.44, 1.73], [0.0, 1.74], ].map(([x, y]) => new THREE.Vector2(x, y)); add(group, new THREE.LatheGeometry(profile, 36), cloth, 0, 0, 0); // Pleats: soft vertical lobes around the swell, thin across and deep along the // radius, ten of them as a real toque has. for (let i = 0; i < 10; i += 1) { const a = (i / 10) * Math.PI * 2; const lobe = add(group, sphere(0.5, 12, 10), cloth, Math.cos(a) * 1.16, 1.18, Math.sin(a) * 1.16); lobe.scale.set(0.40, 0.85, 0.55); lobe.rotation.y = -(a + Math.PI / 2); } // A rolled seam where the band meets the puff. const roll = add(group, new THREE.TorusGeometry(1.00, 0.07, 10, 36), shade, 0, 0.64, 0); roll.rotation.x = Math.PI / 2; tone(roll, 0.16); 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 = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28); brim.scale.set(1, 1, 1.1); // The ridge along the top, front to back. const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28); crest.rotation.x = -0.05; tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22); 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; }