aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/hats.js
blob: 571b136ce8d9b8f3622539bbcef7c411c029cb44 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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 six-panel crown, a band, a button and a bill that slopes down. */
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 dome big enough to sit over the top of the head.
  const crown = add(group, sphere(1.25, 30, 20, 0, Math.PI * 2, 0, Math.PI / 2), cloth, 0, 0.0, 0.02);
  crown.scale.set(1, 0.82, 1.04);

  // Six panel seams. A quarter torus per seam lies on the dome and carries the
  // eye from the button down to the rim, which is what makes it read as a cap.
  for (let i = 0; i < 6; i += 1) {
    const a = (i / 6) * Math.PI * 2;
    const seam = add(group, new THREE.TorusGeometry(1.245, 0.018, 5, 14, Math.PI / 2), dark, 0, 0.0, 0.02);
    seam.scale.set(1, 0.82, 1.04);
    seam.rotation.y = -a;
  }

  // The band around the rim, and the button on top.
  tone(add(group, cylinder(1.27, 1.27, 0.15, 32), dark, 0, 0.075, 0.02), 0.22);
  tone(add(group, sphere(0.11, 14, 10), button, 0, 1.03, 0.02), 0.18);

  // The bill: the front half of a disc, tilted down at the front (`rotation.x`
  // positive lowers +z). It sits a little above the rim because the hat is worn
  // sunk into the round head; at y = 0 the head hides the whole bill.
  const bill = tone(add(group, new THREE.CylinderGeometry(1.24, 1.24, 0.08, 32, 1, false, -Math.PI / 2, Math.PI), cloth, 0, 0.45, 0.06), 0.26);
  bill.scale.set(1, 1, 1.85);
  bill.rotation.x = 0.30;
  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 wide brim that runs long at the back, a
 *  ridge along the top and a gold front shield. */
function buildFire() {
  const group = new THREE.Group();
  const red = material('#c5342f', { roughness: 0.58 });
  const dark = material('#8f241f', { roughness: 0.62 });
  const gold = material('#f0c24a', { metalness: 0.35, roughness: 0.35 });
  const cream = material('#f7efdc', { roughness: 0.55 });

  // The dome, wide enough to cover the top of the head.
  const dome = add(group, sphere(1.42, 32, 20, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0);
  dome.scale.set(1, 0.90, 1.05);

  // The brim: an oval, pushed back so it protects the neck, with a rolled edge.
  const brim = add(group, cylinder(1.80, 1.70, 0.13, 36), dark, 0, 0.02, -0.16);
  brim.scale.set(1, 1, 1.50);
  const rim = add(group, new THREE.TorusGeometry(1.775, 0.085, 10, 48), dark, 0, 0.085, -0.16);
  rim.scale.set(1, 1.50, 1);
  rim.rotation.x = Math.PI / 2;
  tone(rim, 0.26);

  // The ridge along the top, front to back.
  const crest = tone(add(group, sphere(0.5, 18, 14), dark, 0, 1.14, -0.05), 0.26);
  crest.scale.set(0.34, 0.70, 2.05);

  // The front shield, lying on the dome's face.
  const shield = tone(add(group, sphere(0.52, 20, 16), gold, 0, 0.50, 1.30), 0.20);
  shield.scale.set(0.76, 1.0, 0.20);
  shield.rotation.x = -0.30;
  const badge = tone(add(group, sphere(0.38, 18, 14), cream, 0, 0.50, 1.44), 0.14);
  badge.scale.set(0.64, 0.94, 0.18);
  badge.rotation.x = -0.30;
  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;
}