aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/hats.js
blob: 7a54c465f5a55ad6d9b746a1b23e47643636e677 (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
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 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);
  tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34);
  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);
  tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30);
  // The bill, tipped down a little at the front.
  const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30);
  bill.scale.set(1, 1, 1.25);
  bill.rotation.x = -0.12;
  tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30);
  return group;
}

/** コック帽: a tall pleated toque - a cylindrical band under a big puffy crown. */
function buildChef() {
  const group = new THREE.Group();
  const white = material('#f7f6f2');
  const shade = material('#e6e3da');
  // The band: a tall cylinder that sits on the head.
  add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0);
  tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26);
  // The crown: a big squashed sphere ringed with lobes, so the top reads as the
  // classic pleated toque rather than a plain dome.
  const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0);
  puff.scale.set(1, 0.82, 1);
  for (let i = 0; i < 8; i += 1) {
    const a = (i / 8) * Math.PI * 2;
    const lobe = add(group, sphere(0.5, 14, 12), white, Math.cos(a) * 0.92, 1.52, Math.sin(a) * 0.92);
    lobe.scale.set(1, 0.9, 1);
  }
  const top = add(group, sphere(0.62, 16, 12), white, 0, 1.92, 0);
  top.scale.set(1, 0.78, 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 = 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;
}