@rydr/game-sdk 8.11.0 → 8.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -2
- package/dist/client/PlatformClient.d.ts.map +1 -1
- package/dist/client/PlatformClient.js +14 -2
- package/dist/client/PlatformClient.js.map +1 -1
- package/dist/client/Room.d.ts +9 -4
- package/dist/client/Room.d.ts.map +1 -1
- package/dist/client/Room.js +8 -5
- package/dist/client/Room.js.map +1 -1
- package/dist/host/PlatformHost.d.ts +4 -2
- package/dist/host/PlatformHost.d.ts.map +1 -1
- package/dist/host/PlatformHost.js +5 -3
- package/dist/host/PlatformHost.js.map +1 -1
- package/dist/protocol/messages.d.ts +11 -4
- package/dist/protocol/messages.d.ts.map +1 -1
- package/dist/protocol/room.d.ts +26 -0
- package/dist/protocol/room.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +2 -2
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +23 -2
- package/dist/protocol/version.js.map +1 -1
- package/dist/three/README.md +111 -0
- package/dist/three/character/index.d.ts +14 -0
- package/dist/three/character/index.d.ts.map +1 -0
- package/dist/three/character/index.js +14 -0
- package/dist/three/character/index.js.map +1 -0
- package/dist/three/character/kit.d.ts +74 -0
- package/dist/three/character/kit.d.ts.map +1 -0
- package/dist/three/character/kit.js +254 -0
- package/dist/three/character/kit.js.map +1 -0
- package/dist/three/character/riders.d.ts +20 -0
- package/dist/three/character/riders.d.ts.map +1 -0
- package/dist/three/character/riders.js +383 -0
- package/dist/three/character/riders.js.map +1 -0
- package/dist/three/character/skeleton.d.ts +105 -0
- package/dist/three/character/skeleton.d.ts.map +1 -0
- package/dist/three/character/skeleton.js +57 -0
- package/dist/three/character/skeleton.js.map +1 -0
- package/dist/three/controller/glyph-texture.d.ts +29 -0
- package/dist/three/controller/glyph-texture.d.ts.map +1 -0
- package/dist/three/controller/glyph-texture.js +68 -0
- package/dist/three/controller/glyph-texture.js.map +1 -0
- package/dist/three/controller/index.d.ts +12 -0
- package/dist/three/controller/index.d.ts.map +1 -0
- package/dist/three/controller/index.js +12 -0
- package/dist/three/controller/index.js.map +1 -0
- package/dist/three/controller/joycon-designs.d.ts +49 -0
- package/dist/three/controller/joycon-designs.d.ts.map +1 -0
- package/dist/three/controller/joycon-designs.js +742 -0
- package/dist/three/controller/joycon-designs.js.map +1 -0
- package/dist/three/controller/joycon-shoulder-mesh.d.ts +41 -0
- package/dist/three/controller/joycon-shoulder-mesh.d.ts.map +1 -0
- package/dist/three/controller/joycon-shoulder-mesh.js +527 -0
- package/dist/three/controller/joycon-shoulder-mesh.js.map +1 -0
- package/dist/three/controller/joycon-spec.d.ts +233 -0
- package/dist/three/controller/joycon-spec.d.ts.map +1 -0
- package/dist/three/controller/joycon-spec.js +285 -0
- package/dist/three/controller/joycon-spec.js.map +1 -0
- package/dist/three/controller/three-pad-renderer.d.ts +81 -0
- package/dist/three/controller/three-pad-renderer.d.ts.map +1 -0
- package/dist/three/controller/three-pad-renderer.js +433 -0
- package/dist/three/controller/three-pad-renderer.js.map +1 -0
- package/dist/three/index.d.ts +2 -0
- package/dist/three/index.d.ts.map +1 -1
- package/dist/three/index.js +2 -0
- package/dist/three/index.js.map +1 -1
- package/dist/three/rider/cyclist-pose.d.ts +62 -0
- package/dist/three/rider/cyclist-pose.d.ts.map +1 -0
- package/dist/three/rider/cyclist-pose.js +173 -0
- package/dist/three/rider/cyclist-pose.js.map +1 -0
- package/dist/three/rider/index.d.ts +8 -0
- package/dist/three/rider/index.d.ts.map +1 -0
- package/dist/three/rider/index.js +8 -0
- package/dist/three/rider/index.js.map +1 -0
- package/dist/three/rider/three-rider-rig.d.ts +80 -0
- package/dist/three/rider/three-rider-rig.d.ts.map +1 -0
- package/dist/three/rider/three-rider-rig.js +418 -0
- package/dist/three/rider/three-rider-rig.js.map +1 -0
- package/dist/ui/README.md +153 -1
- package/dist/ui/controller/combo-demo.d.ts +9 -0
- package/dist/ui/controller/combo-demo.d.ts.map +1 -1
- package/dist/ui/controller/combo-demo.js +1 -0
- package/dist/ui/controller/combo-demo.js.map +1 -1
- package/dist/ui/controller/control-spotlight.d.ts +10 -1
- package/dist/ui/controller/control-spotlight.d.ts.map +1 -1
- package/dist/ui/controller/control-spotlight.js +42 -42
- package/dist/ui/controller/control-spotlight.js.map +1 -1
- package/dist/ui/controller/control-trainer.d.ts +14 -0
- package/dist/ui/controller/control-trainer.d.ts.map +1 -1
- package/dist/ui/controller/control-trainer.js +6 -0
- package/dist/ui/controller/control-trainer.js.map +1 -1
- package/dist/ui/controller/controller-map.d.ts +9 -0
- package/dist/ui/controller/controller-map.d.ts.map +1 -1
- package/dist/ui/controller/controller-map.js +8 -6
- package/dist/ui/controller/controller-map.js.map +1 -1
- package/dist/ui/controller/controller-pad.d.ts +35 -2
- package/dist/ui/controller/controller-pad.d.ts.map +1 -1
- package/dist/ui/controller/controller-pad.js +47 -7
- package/dist/ui/controller/controller-pad.js.map +1 -1
- package/dist/ui/controller/index.d.ts +1 -0
- package/dist/ui/controller/index.d.ts.map +1 -1
- package/dist/ui/controller/index.js +1 -0
- package/dist/ui/controller/index.js.map +1 -1
- package/dist/ui/controller/renderer.d.ts +118 -0
- package/dist/ui/controller/renderer.d.ts.map +1 -0
- package/dist/ui/controller/renderer.js +93 -0
- package/dist/ui/controller/renderer.js.map +1 -0
- package/dist/ui/controller/styles.js +30 -0
- package/dist/ui/controller/styles.js.map +1 -1
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +6 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/rider-rig.d.ts +315 -0
- package/dist/ui/rider-rig.d.ts.map +1 -0
- package/dist/ui/rider-rig.js +1000 -0
- package/dist/ui/rider-rig.js.map +1 -0
- package/dist/ui/showcase/index.d.ts +11 -0
- package/dist/ui/showcase/index.d.ts.map +1 -1
- package/dist/ui/showcase/index.js +109 -1
- package/dist/ui/showcase/index.js.map +1 -1
- package/package.json +8 -4
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THREE ART DIRECTIONS for the 3D Joy-Con, to be chosen between by eye.
|
|
3
|
+
*
|
|
4
|
+
* All three are built from the same {@link joycon-spec} numbers — real hardware proportions, real
|
|
5
|
+
* control placement — so what differs between them is **only** the art direction: shape language,
|
|
6
|
+
* materials, lighting response, level of detail. Same model, three ways of drawing it.
|
|
7
|
+
*
|
|
8
|
+
* {@link buildProductJoycon} "Product" — what the hardware looks like. Neon red / neon blue,
|
|
9
|
+
* domed buttons, dished stick caps, visible rail. Most faithful.
|
|
10
|
+
* {@link buildToyJoycon} "Toy" — chunkier, flatter colour, oversized controls, matte. Built to
|
|
11
|
+
* be read from across a room, which is the actual use case.
|
|
12
|
+
* {@link buildHoloJoycon} "Holo" — translucent shell with lit edges and emissive controls, in
|
|
13
|
+
* the SDK's own accent language. Reads as UI rather than as an object.
|
|
14
|
+
*
|
|
15
|
+
* Each returns a `JoyconBuild`: the group to add to a scene, plus the per-control nodes a driver would
|
|
16
|
+
* later need, plus everything to dispose. None of them is wired to input yet — that comes once one is
|
|
17
|
+
* picked.
|
|
18
|
+
*/
|
|
19
|
+
import * as THREE from "three";
|
|
20
|
+
import { TUNING, D, SILHOUETTE, DEPTH_TOP, depthAtV, faceToLocal, GAP, H, LEFT_FACE, RIGHT_FACE, SHELL_RADIUS, SHOULDER, SIZES, W, } from "./joycon-spec.js";
|
|
21
|
+
import { glyphTexture } from "./glyph-texture.js";
|
|
22
|
+
import { JOYCON_LAYOUT } from "../../ui/controller/layouts/joycon.js";
|
|
23
|
+
// ── shared geometry helpers ───────────────────────────────────────────────────────────────────────
|
|
24
|
+
/** A rounded rectangle shape, centred on the origin. */
|
|
25
|
+
function roundedRect(w, h, r) {
|
|
26
|
+
const s = new THREE.Shape();
|
|
27
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
28
|
+
const x = -w / 2;
|
|
29
|
+
const y = -h / 2;
|
|
30
|
+
s.moveTo(x + rr, y);
|
|
31
|
+
s.lineTo(x + w - rr, y);
|
|
32
|
+
s.quadraticCurveTo(x + w, y, x + w, y + rr);
|
|
33
|
+
s.lineTo(x + w, y + h - rr);
|
|
34
|
+
s.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
|
|
35
|
+
s.lineTo(x + rr, y + h);
|
|
36
|
+
s.quadraticCurveTo(x, y + h, x, y + h - rr);
|
|
37
|
+
s.lineTo(x, y + rr);
|
|
38
|
+
s.quadraticCurveTo(x, y, x + rr, y);
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A box with genuinely rounded edges on all three axes.
|
|
43
|
+
*
|
|
44
|
+
* `ExtrudeGeometry`'s bevel is what rounds the front and back faces; making the bevel a real fraction
|
|
45
|
+
* of the depth is what stops the result reading as a flat plate with soft corners — which is exactly
|
|
46
|
+
* how the first attempt went wrong.
|
|
47
|
+
*/
|
|
48
|
+
function roundedBox(w, h, d, r, bevel = d * 0.22) {
|
|
49
|
+
// ⚠️ A bevel GROWS the shape: `bevelSize` is pushed outwards from the profile on every side, and
|
|
50
|
+
// `bevelThickness` is added to each end of the depth. So the profile has to be shrunk by it first,
|
|
51
|
+
// or the result is bigger than asked for on all three axes. Getting this wrong is what made the two
|
|
52
|
+
// shells overlap — they were built 0.70 wide and came out 0.95.
|
|
53
|
+
const b = Math.min(bevel, w / 2 - 1e-4, h / 2 - 1e-4, d / 2 - 1e-4);
|
|
54
|
+
const geo = new THREE.ExtrudeGeometry(roundedRect(w - b * 2, h - b * 2, Math.max(1e-4, r - b)), {
|
|
55
|
+
depth: d - b * 2,
|
|
56
|
+
bevelEnabled: true,
|
|
57
|
+
bevelThickness: b,
|
|
58
|
+
bevelSize: b,
|
|
59
|
+
bevelSegments: 5,
|
|
60
|
+
curveSegments: 16,
|
|
61
|
+
});
|
|
62
|
+
geo.translate(0, 0, -(d - b * 2) / 2);
|
|
63
|
+
return geo;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* THE SHELL — a tapered wedge, not a slab.
|
|
67
|
+
*
|
|
68
|
+
* A Joy-Con is markedly thicker at the top than at the bottom: measured on the reference, the bottom is
|
|
69
|
+
* 58% of the top's depth. That bulge is what makes room for the triggers to sit behind the shoulders,
|
|
70
|
+
* so a constant-thickness box (which is what the first pass built) both looks wrong and leaves the
|
|
71
|
+
* triggers nowhere to live.
|
|
72
|
+
*
|
|
73
|
+
* Two things the profile has to get right, both measured:
|
|
74
|
+
* - the taper is entirely on the BACK. The front face — where every button sits — is dead flat from
|
|
75
|
+
* top to bottom, which is why `frontZ()` is a constant everywhere else in this file.
|
|
76
|
+
* - it is a soft shoulder around v≈0.27, not a step.
|
|
77
|
+
*
|
|
78
|
+
* Built as a loft: horizontal contours stacked down the height, each a rounded rectangle in plan whose
|
|
79
|
+
* depth comes from {@link depthAtV}, with the front edge pinned. `ExtrudeGeometry` can't do this — its
|
|
80
|
+
* depth is a single number — which is exactly why the shell needed its own builder.
|
|
81
|
+
*/
|
|
82
|
+
function shellGeometry(w, h, frontZ, innerSign) {
|
|
83
|
+
const LEVELS = 26;
|
|
84
|
+
/**
|
|
85
|
+
* How far an edge is pulled IN at height `v`, following a corner of radius `rTop` / `rBottom`.
|
|
86
|
+
*
|
|
87
|
+
* Called separately for the two edges, which is what makes the silhouette asymmetric: the inner edge
|
|
88
|
+
* gets tiny radii (it is straight), the outer edge gets big ones.
|
|
89
|
+
*/
|
|
90
|
+
const inset = (v, rTop, rBottom) => {
|
|
91
|
+
const y = h / 2 - v * h;
|
|
92
|
+
const top = h / 2 - rTop;
|
|
93
|
+
const bot = -h / 2 + rBottom;
|
|
94
|
+
if (y > top && rTop > 0) {
|
|
95
|
+
const t = Math.min(1, (y - top) / rTop);
|
|
96
|
+
return rTop * (1 - Math.sqrt(Math.max(0, 1 - t * t)));
|
|
97
|
+
}
|
|
98
|
+
if (y < bot && rBottom > 0) {
|
|
99
|
+
const t = Math.min(1, (bot - y) / rBottom);
|
|
100
|
+
return rBottom * (1 - Math.sqrt(Math.max(0, 1 - t * t)));
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* One horizontal contour: a true ROUNDED RECTANGLE in the XZ plane, front edge pinned at `frontZ`.
|
|
106
|
+
*
|
|
107
|
+
* Built from four corner arcs rather than a superellipse. The superellipse this replaced bulged every
|
|
108
|
+
* face outwards, which turned the shell into a tube — and worse, it curved the FRONT, the one face
|
|
109
|
+
* that must stay dead flat because every button sits on it.
|
|
110
|
+
*/
|
|
111
|
+
const CORNER = 6;
|
|
112
|
+
const ring = (v) => {
|
|
113
|
+
// Two edges, computed independently — the inner one barely moves, the outer one sweeps.
|
|
114
|
+
const xInner = innerSign * (w / 2 - inset(v, SILHOUETTE.innerTopR, SILHOUETTE.innerBottomR));
|
|
115
|
+
const xOuter = -innerSign * (w / 2 - inset(v, SILHOUETTE.outerTopR, SILHOUETTE.outerBottomR));
|
|
116
|
+
const xHi = Math.max(xInner, xOuter);
|
|
117
|
+
const xLo = Math.min(xInner, xOuter);
|
|
118
|
+
const d = depthAtV(v);
|
|
119
|
+
const zFront = frontZ;
|
|
120
|
+
const zBack = frontZ - d;
|
|
121
|
+
const r = Math.min((xHi - xLo) * 0.3, d * 0.34);
|
|
122
|
+
const y = h / 2 - v * h;
|
|
123
|
+
const corners = [
|
|
124
|
+
[xHi - r, zFront - r, 0],
|
|
125
|
+
[xLo + r, zFront - r, Math.PI / 2],
|
|
126
|
+
[xLo + r, zBack + r, Math.PI],
|
|
127
|
+
[xHi - r, zBack + r, (3 * Math.PI) / 2],
|
|
128
|
+
];
|
|
129
|
+
const pts = [];
|
|
130
|
+
for (const [ccx, ccz, a0] of corners) {
|
|
131
|
+
for (let i = 0; i < CORNER; i++) {
|
|
132
|
+
const a = a0 + (i / (CORNER - 1)) * (Math.PI / 2);
|
|
133
|
+
pts.push(new THREE.Vector3(ccx + Math.cos(a) * r, y, ccz + Math.sin(a) * r));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return pts;
|
|
137
|
+
};
|
|
138
|
+
const RING = CORNER * 4;
|
|
139
|
+
const rings = [];
|
|
140
|
+
for (let i = 0; i <= LEVELS; i++)
|
|
141
|
+
rings.push(ring(i / LEVELS));
|
|
142
|
+
const pos = [];
|
|
143
|
+
/**
|
|
144
|
+
* Emit one triangle, oriented OUTWARD explicitly rather than trusting the ring's winding.
|
|
145
|
+
*
|
|
146
|
+
* Deriving the winding from the traversal order is what broke here: flipping it globally just swapped
|
|
147
|
+
* which 398 of the 1296 triangles faced the wrong way, because the top and bottom caps need the
|
|
148
|
+
* opposite sense from the side walls. Three.js culls back faces, so any triangle that ends up inward
|
|
149
|
+
* is a hole — and a shell full of holes reads as a transparent shell.
|
|
150
|
+
*
|
|
151
|
+
* `ref` is a point known to be INSIDE the solid near this triangle: the shell's own axis at that
|
|
152
|
+
* height for the walls, the centroid for the caps. If the normal points back towards it, swap two
|
|
153
|
+
* vertices.
|
|
154
|
+
*/
|
|
155
|
+
const tri = (a, b, c, ref) => {
|
|
156
|
+
const n = new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a));
|
|
157
|
+
const mid = new THREE.Vector3().addVectors(a, b).add(c).multiplyScalar(1 / 3).sub(ref);
|
|
158
|
+
const [p, q, r] = n.dot(mid) >= 0 ? [a, b, c] : [a, c, b];
|
|
159
|
+
pos.push(p.x, p.y, p.z, q.x, q.y, q.z, r.x, r.y, r.z);
|
|
160
|
+
};
|
|
161
|
+
const axisAt = (v) => new THREE.Vector3(0, h / 2 - v * h, frontZ - depthAtV(v) / 2);
|
|
162
|
+
for (let i = 0; i < LEVELS; i++) {
|
|
163
|
+
const A = rings[i];
|
|
164
|
+
const B = rings[i + 1];
|
|
165
|
+
// The axis midway between the two rings — inside the solid, whatever the taper is doing here.
|
|
166
|
+
const ref = axisAt((i + 0.5) / LEVELS);
|
|
167
|
+
for (let j = 0; j < RING; j++) {
|
|
168
|
+
const k = (j + 1) % RING;
|
|
169
|
+
tri(A[j], B[j], B[k], ref);
|
|
170
|
+
tri(A[j], B[k], A[k], ref);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Caps: reference a point just INSIDE the shell along the axis, so the fan faces out of the end.
|
|
174
|
+
for (const [rIdx, inward] of [[0, 1], [LEVELS, -1]]) {
|
|
175
|
+
const R = rings[rIdx];
|
|
176
|
+
const c = new THREE.Vector3();
|
|
177
|
+
for (const q of R)
|
|
178
|
+
c.add(q);
|
|
179
|
+
c.multiplyScalar(1 / R.length);
|
|
180
|
+
const ref = c.clone();
|
|
181
|
+
ref.y -= inward * h * 0.02;
|
|
182
|
+
for (let j = 0; j < RING; j++)
|
|
183
|
+
tri(c, R[j], R[(j + 1) % RING], ref);
|
|
184
|
+
}
|
|
185
|
+
const geo = new THREE.BufferGeometry();
|
|
186
|
+
geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
|
|
187
|
+
geo.computeVertexNormals();
|
|
188
|
+
return geo;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A button: a CYLINDER with a flat top and a filleted edge.
|
|
192
|
+
*
|
|
193
|
+
* Not a dome. On the hardware, A/B/X/Y and the four direction keys are short cylinders whose top is
|
|
194
|
+
* flat — which is what lets a letter be printed on them; on a dome the character would distort. The
|
|
195
|
+
* fillet rounds the top edge so the piece reads as moulded rather than cut out, and leaves a visible
|
|
196
|
+
* straight FLANK, which is what says "this stands off the face". The previous shape put 45% of its
|
|
197
|
+
* height into a spherical cap, so the flank was too short to see and every key read as a bulge.
|
|
198
|
+
*
|
|
199
|
+
* Built as a lathe: base → up the flank → around the fillet → in across the flat top.
|
|
200
|
+
*/
|
|
201
|
+
function domedButton(r, rise, segments = 24) {
|
|
202
|
+
// Fillet is capped by both dimensions so a very flat or very narrow key stays valid.
|
|
203
|
+
const fillet = Math.min(r * 0.34, rise * 0.55);
|
|
204
|
+
const pts = [];
|
|
205
|
+
pts.push(new THREE.Vector2(0, 0)); // centre of the base
|
|
206
|
+
pts.push(new THREE.Vector2(r, 0)); // out to the rim
|
|
207
|
+
pts.push(new THREE.Vector2(r, rise - fillet)); // straight flank — the part that reads as height
|
|
208
|
+
const STEPS = 5;
|
|
209
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
210
|
+
const a = (i / STEPS) * (Math.PI / 2);
|
|
211
|
+
pts.push(new THREE.Vector2(r - fillet + Math.cos(a) * fillet, rise - fillet + Math.sin(a) * fillet));
|
|
212
|
+
}
|
|
213
|
+
pts.push(new THREE.Vector2(0, rise)); // flat top, in to the centre
|
|
214
|
+
const geo = new THREE.LatheGeometry(pts, segments);
|
|
215
|
+
// Lathe spins around +y; these keys face the viewer, so lay the axis down onto +z.
|
|
216
|
+
geo.rotateX(Math.PI / 2);
|
|
217
|
+
return geo;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Minimal geometry merge — enough for the handful of two-part shapes here.
|
|
221
|
+
*
|
|
222
|
+
* Written out rather than importing `three/examples/jsm/utils/BufferGeometryUtils.js`: the SDK's own
|
|
223
|
+
* `merge.ts` avoids that dependency too, and pulling an examples module into a published package is a
|
|
224
|
+
* resolution problem waiting to happen for consumers.
|
|
225
|
+
*/
|
|
226
|
+
function mergeGeometries(list) {
|
|
227
|
+
const out = new THREE.BufferGeometry();
|
|
228
|
+
const attrs = ["position", "normal"];
|
|
229
|
+
for (const name of attrs) {
|
|
230
|
+
const arrays = [];
|
|
231
|
+
for (const g of list) {
|
|
232
|
+
const src = g.getAttribute(name);
|
|
233
|
+
const idx = g.getIndex();
|
|
234
|
+
if (!src)
|
|
235
|
+
continue;
|
|
236
|
+
// Expand indexed geometry so the concatenation needs no index remapping.
|
|
237
|
+
if (idx) {
|
|
238
|
+
for (let i = 0; i < idx.count; i++) {
|
|
239
|
+
const k = idx.getX(i);
|
|
240
|
+
arrays.push(src.getX(k), src.getY(k), src.getZ(k));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
for (let i = 0; i < src.count; i++)
|
|
245
|
+
arrays.push(src.getX(i), src.getY(i), src.getZ(i));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
out.setAttribute(name, new THREE.Float32BufferAttribute(arrays, 3));
|
|
249
|
+
}
|
|
250
|
+
for (const g of list)
|
|
251
|
+
g.dispose();
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The `−` and `+` keys, drawn as their SYMBOLS rather than as round pips.
|
|
256
|
+
*
|
|
257
|
+
* On the hardware these are not domed buttons: `−` is a flat bar and `+` a flat cross, both barely
|
|
258
|
+
* proud of the shell (measured at 0.005 against a 0.104-wide shell — essentially flush). Drawing them
|
|
259
|
+
* as generic round buttons, which is what the first pass did, loses the one cue that says which is
|
|
260
|
+
* which without any lettering.
|
|
261
|
+
*/
|
|
262
|
+
function symbolMinus(size, rise) {
|
|
263
|
+
const g = new THREE.BoxGeometry(size, size * 0.26, rise);
|
|
264
|
+
g.translate(0, 0, rise / 2);
|
|
265
|
+
return g;
|
|
266
|
+
}
|
|
267
|
+
function symbolPlus(size, rise) {
|
|
268
|
+
const bar = size * 0.26;
|
|
269
|
+
const a = new THREE.BoxGeometry(size, bar, rise);
|
|
270
|
+
const b = new THREE.BoxGeometry(bar, size, rise);
|
|
271
|
+
const merged = mergeGeometries([a, b]);
|
|
272
|
+
merged.translate(0, 0, rise / 2);
|
|
273
|
+
return merged;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A letter lying on a key's face.
|
|
277
|
+
*
|
|
278
|
+
* Drawn as a canvas texture on a small plane rather than as 3D text: this SDK ships no non-TS files,
|
|
279
|
+
* and real text geometry needs a font — a typeface JSON or an SDF atlas — which would be the first
|
|
280
|
+
* binary in the package. See `glyph-texture.ts`.
|
|
281
|
+
*
|
|
282
|
+
* `depthWrite: false` and a small offset keep it from z-fighting the dome it sits on.
|
|
283
|
+
*/
|
|
284
|
+
function glyphPlane(text, size, ink, track) {
|
|
285
|
+
const geo = track(new THREE.PlaneGeometry(size, size));
|
|
286
|
+
const mat = track(new THREE.MeshBasicMaterial({ map: glyphTexture(text, ink), transparent: true, depthWrite: false }));
|
|
287
|
+
return new THREE.Mesh(geo, mat);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* `ZL` / `ZR` — generated, not copied, and deliberately so.
|
|
291
|
+
*
|
|
292
|
+
* The other keys come from the reference model verbatim. This one cannot: on that model the rear
|
|
293
|
+
* trigger is not a part at all, it is a region of the SHELL in a second colour. Every attempt to cut it
|
|
294
|
+
* out inherited the shell's own topology — filtering by material gave a scattered set of faces, by
|
|
295
|
+
* normal it lost the sideways ones and bit a notch out, and hole-filling the result still left open
|
|
296
|
+
* edges. All three showed as a hole.
|
|
297
|
+
*
|
|
298
|
+
* So it is built here as a closed slab that follows the crown, sized to the footprint MEASURED on the
|
|
299
|
+
* reference: 79% of the shell's width, from 44% to 100% of its depth — i.e. running back to the rear
|
|
300
|
+
* edge.
|
|
301
|
+
*/
|
|
302
|
+
function crownPadGeometry(widthFrac, depthFrom, depthTo, rise, crownY,
|
|
303
|
+
/**
|
|
304
|
+
* How far the piece will be RAISED after it is built.
|
|
305
|
+
*
|
|
306
|
+
* The underside has to reach below the crown by at least this much, or lifting the key detaches it
|
|
307
|
+
* from the body and it floats. The skirt was a fixed 90% of the key's own height, which the raise
|
|
308
|
+
* then more than cancelled: the bumper ended up 0.024 clear of the shell, visibly hovering.
|
|
309
|
+
*/
|
|
310
|
+
raise = 0) {
|
|
311
|
+
const SEG = 24;
|
|
312
|
+
const w = W * widthFrac;
|
|
313
|
+
const zFront = frontZ() - depthFrom * DEPTH_TOP;
|
|
314
|
+
const zBack = frontZ() - depthTo * DEPTH_TOP;
|
|
315
|
+
/** Taper to nothing at the two ends so the slab has rounded tips rather than sheared ones. */
|
|
316
|
+
const cap = 0.14;
|
|
317
|
+
const taper = (t) => {
|
|
318
|
+
const e = t < cap ? t / cap : t > 1 - cap ? (1 - t) / cap : 1;
|
|
319
|
+
return Math.max(0.05, Math.sqrt(Math.max(0, 1 - (1 - e) * (1 - e))));
|
|
320
|
+
};
|
|
321
|
+
// Each section is a closed quad in the (z, y) plane: top face domed slightly, bottom flat on the
|
|
322
|
+
// crown. Four points per ring keeps it simple and guarantees a closed solid.
|
|
323
|
+
const rings = [];
|
|
324
|
+
for (let i = 0; i <= SEG; i++) {
|
|
325
|
+
const t = i / SEG;
|
|
326
|
+
const k = taper(t);
|
|
327
|
+
const x = (t - 0.5) * w;
|
|
328
|
+
const base = crownY(x);
|
|
329
|
+
const zf = zFront * k + ((zFront + zBack) / 2) * (1 - k);
|
|
330
|
+
const zb = zBack * k + ((zFront + zBack) / 2) * (1 - k);
|
|
331
|
+
const h = rise * k;
|
|
332
|
+
// Underside: deep enough to stay buried in the shell once the piece is raised, with a margin so
|
|
333
|
+
// it bites rather than merely touching.
|
|
334
|
+
const under = h * 0.9 + raise * 1.6;
|
|
335
|
+
rings.push([
|
|
336
|
+
new THREE.Vector3(x, base + h, zf),
|
|
337
|
+
new THREE.Vector3(x, base + h * 0.75, zb),
|
|
338
|
+
new THREE.Vector3(x, base - under, zb),
|
|
339
|
+
new THREE.Vector3(x, base - under, zf),
|
|
340
|
+
]);
|
|
341
|
+
}
|
|
342
|
+
const pos = [];
|
|
343
|
+
const centreOf = (R) => new THREE.Vector3().add(R[0]).add(R[1]).add(R[2]).add(R[3]).multiplyScalar(0.25);
|
|
344
|
+
const tri = (a, b, c, ref) => {
|
|
345
|
+
const n = new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a));
|
|
346
|
+
const mid = new THREE.Vector3().addVectors(a, b).add(c).multiplyScalar(1 / 3).sub(ref);
|
|
347
|
+
const [p, q, r] = n.dot(mid) >= 0 ? [a, b, c] : [a, c, b];
|
|
348
|
+
pos.push(p.x, p.y, p.z, q.x, q.y, q.z, r.x, r.y, r.z);
|
|
349
|
+
};
|
|
350
|
+
for (let i = 0; i < SEG; i++) {
|
|
351
|
+
const A = rings[i];
|
|
352
|
+
const B = rings[i + 1];
|
|
353
|
+
const ref = centreOf(A).add(centreOf(B)).multiplyScalar(0.5);
|
|
354
|
+
for (let j = 0; j < 4; j++) {
|
|
355
|
+
const k2 = (j + 1) % 4;
|
|
356
|
+
tri(A[j], B[j], B[k2], ref);
|
|
357
|
+
tri(A[j], B[k2], A[k2], ref);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const idx of [0, SEG]) {
|
|
361
|
+
const R = rings[idx];
|
|
362
|
+
const c = centreOf(R);
|
|
363
|
+
const ref = c.clone();
|
|
364
|
+
ref.x += idx === 0 ? w * 0.05 : -w * 0.05;
|
|
365
|
+
for (let j = 0; j < 4; j++)
|
|
366
|
+
tri(c, R[j], R[(j + 1) % 4], ref);
|
|
367
|
+
}
|
|
368
|
+
const geo = new THREE.BufferGeometry();
|
|
369
|
+
geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
|
|
370
|
+
geo.computeVertexNormals();
|
|
371
|
+
return geo;
|
|
372
|
+
}
|
|
373
|
+
/** A stick: a tapered post carrying a dished cap, on a pivot that leans. */
|
|
374
|
+
function stickAssembly(capR, height, mat, capMat, track) {
|
|
375
|
+
const pivot = new THREE.Group();
|
|
376
|
+
const postGeo = track(new THREE.CylinderGeometry(capR * 0.44, capR * 0.6, height, 20));
|
|
377
|
+
postGeo.rotateX(Math.PI / 2);
|
|
378
|
+
postGeo.translate(0, 0, height / 2);
|
|
379
|
+
pivot.add(new THREE.Mesh(postGeo, mat));
|
|
380
|
+
// The cap reuses `domedButton`: a skirt with a domed top.
|
|
381
|
+
//
|
|
382
|
+
// A lathe with a dished top was tried first and read as a CRATER — from the usual viewing angle you
|
|
383
|
+
// see into the dish and the piece looks sunk into the shell rather than standing on it. The real cap
|
|
384
|
+
// is dished, but at this size that detail costs more than it buys: what has to read is that the
|
|
385
|
+
// stick is a body sitting proud of the face, and a convex cap says that unambiguously.
|
|
386
|
+
const capGeo = track(domedButton(capR, capR * TUNING.stickCapDepth, 28));
|
|
387
|
+
capGeo.translate(0, 0, height);
|
|
388
|
+
const cap = new THREE.Mesh(capGeo, capMat);
|
|
389
|
+
pivot.add(cap);
|
|
390
|
+
// The CAP is handed back alongside the pivot: the pivot is what leans, but the cap is what a driver
|
|
391
|
+
// paints and sinks. Returning only the pivot left the stick-click unpaintable — it is a Group, and a
|
|
392
|
+
// Group has no material.
|
|
393
|
+
return { pivot, cap };
|
|
394
|
+
}
|
|
395
|
+
/** Where a control sits in world space on a shell whose centre is `shellX`. */
|
|
396
|
+
function place(node, shellX, p, z) {
|
|
397
|
+
const { x, y } = faceToLocal(p);
|
|
398
|
+
node.position.set(shellX + x, y, z);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The front face's z.
|
|
402
|
+
*
|
|
403
|
+
* The shell tapers on the back only, so the plane every button sits on never moves. Read through a
|
|
404
|
+
* function rather than a module constant, or it freezes at load and stops following `TUNING`.
|
|
405
|
+
*/
|
|
406
|
+
function frontZ() {
|
|
407
|
+
return DEPTH_TOP / 2;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Assemble a pair from a skin.
|
|
411
|
+
*
|
|
412
|
+
* The three designs differ ONLY in their skin, which is the point: if a direction wins, the arrangement
|
|
413
|
+
* and proportions it won with are the ones the other two were judged against.
|
|
414
|
+
*/
|
|
415
|
+
function buildPair(skin) {
|
|
416
|
+
const group = new THREE.Group();
|
|
417
|
+
const controls = new Map();
|
|
418
|
+
const sticks = {};
|
|
419
|
+
const disposables = [];
|
|
420
|
+
const track = (x) => {
|
|
421
|
+
disposables.push(x);
|
|
422
|
+
return x;
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* A material for ONE drivable part.
|
|
426
|
+
*
|
|
427
|
+
* Every control needs its own instance. The skins hand back a shared material per family — `const sh
|
|
428
|
+
* = flat(...)` then `shoulder: () => sh` — which is right for the shell and the decor but fatal for
|
|
429
|
+
* anything the driver paints: lighting `ZL` would recolour every shoulder on both halves at once,
|
|
430
|
+
* which is exactly what it did.
|
|
431
|
+
*/
|
|
432
|
+
const own = (m) => track(m.clone());
|
|
433
|
+
const rise = SIZES.buttonRise * (skin.riseScale ?? 1);
|
|
434
|
+
const sz = skin.sizeScale ?? 1;
|
|
435
|
+
const halfX = (W + GAP) / 2;
|
|
436
|
+
for (const side of ["l", "r"]) {
|
|
437
|
+
const shellX = side === "l" ? -halfX : halfX;
|
|
438
|
+
// The shell — a tapered wedge: thick at the top for the triggers, slimmer at the bottom.
|
|
439
|
+
// Inner edge faces the OTHER half: +x for the left shell, -x for the right.
|
|
440
|
+
const shellGeo = track(shellGeometry(W, H, frontZ(), side === "l" ? 1 : -1));
|
|
441
|
+
const shell = new THREE.Mesh(shellGeo, skin.shell(side));
|
|
442
|
+
shell.position.x = shellX;
|
|
443
|
+
group.add(shell);
|
|
444
|
+
// Split cleanly per side rather than narrowing a union: the two halves genuinely carry different
|
|
445
|
+
// controls (four directions vs a face diamond), so one shape for both was always going to fight.
|
|
446
|
+
/** Look a control up on its half's face map. They are mutable now (the showcase can drag them). */
|
|
447
|
+
const face = (k) => (side === "l" ? LEFT_FACE : RIGHT_FACE)[k] ?? { u: 0.5, v: 0.5 };
|
|
448
|
+
const stickPos = face("stick");
|
|
449
|
+
const round = side === "l"
|
|
450
|
+
? [
|
|
451
|
+
["dpadUp", face("dpadUp"), SIZES.dpadR],
|
|
452
|
+
["dpadDown", face("dpadDown"), SIZES.dpadR],
|
|
453
|
+
["dpadLeft", face("dpadLeft"), SIZES.dpadR],
|
|
454
|
+
["dpadRight", face("dpadRight"), SIZES.dpadR],
|
|
455
|
+
]
|
|
456
|
+
: [
|
|
457
|
+
["faceTop", face("faceTop"), SIZES.faceR],
|
|
458
|
+
["faceRight", face("faceRight"), SIZES.faceR],
|
|
459
|
+
["faceBottom", face("faceBottom"), SIZES.faceR],
|
|
460
|
+
["faceLeft", face("faceLeft"), SIZES.faceR],
|
|
461
|
+
];
|
|
462
|
+
/** What is printed on each key, straight from the flat layout so the two never disagree. */
|
|
463
|
+
const FACE_GLYPH = {
|
|
464
|
+
faceTop: JOYCON_LAYOUT.face.top.glyph,
|
|
465
|
+
faceRight: JOYCON_LAYOUT.face.right.glyph,
|
|
466
|
+
faceBottom: JOYCON_LAYOUT.face.bottom.glyph,
|
|
467
|
+
faceLeft: JOYCON_LAYOUT.face.left.glyph,
|
|
468
|
+
};
|
|
469
|
+
for (const [key, pos, r] of round) {
|
|
470
|
+
const geo = track(domedButton(r * sz, rise));
|
|
471
|
+
const mesh = new THREE.Mesh(geo, own(skin.button()));
|
|
472
|
+
place(mesh, shellX, pos, frontZ());
|
|
473
|
+
group.add(mesh);
|
|
474
|
+
controls.set(key, mesh);
|
|
475
|
+
// The d-pad carries no lettering — the arms are too small at this scale and the arrows are the
|
|
476
|
+
// same on every device. Same rule the flat pad follows.
|
|
477
|
+
const g = FACE_GLYPH[key];
|
|
478
|
+
if (g) {
|
|
479
|
+
const label = glyphPlane(g, r * sz * 1.9, skin.ink ?? "#eaf2ff", track);
|
|
480
|
+
label.position.set(mesh.position.x, mesh.position.y, mesh.position.z + rise + 0.004);
|
|
481
|
+
group.add(label);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// System buttons: `−` / `+` and Home. Flatter and smaller — they are on the hardware too.
|
|
485
|
+
// `−` and `+` are drawn as their symbols; Home stays a round pip, which is what it is.
|
|
486
|
+
const sys = side === "l"
|
|
487
|
+
? [["select", face("select"), "minus"]]
|
|
488
|
+
: [["start", face("start"), "plus"], ["home", face("home"), "pip"]];
|
|
489
|
+
for (const [key, pos, kind] of sys) {
|
|
490
|
+
const symRise = SIZES.systemRise * (skin.riseScale ?? 1);
|
|
491
|
+
const symSize = SIZES.systemR * 2 * sz;
|
|
492
|
+
const geo = track(kind === "minus"
|
|
493
|
+
? symbolMinus(symSize, symRise)
|
|
494
|
+
: kind === "plus"
|
|
495
|
+
? symbolPlus(symSize, symRise)
|
|
496
|
+
// Home gets the FULL button relief, not a fraction of it.
|
|
497
|
+
//
|
|
498
|
+
// `−` and `+` are nearly flush on the hardware — they are printed marks, and the flat
|
|
499
|
+
// symbols above match that. Home is not: it is a real key you press, and halving its
|
|
500
|
+
// relief left it barely readable next to its neighbours. It stays smaller in radius,
|
|
501
|
+
// which is true to the hardware, but it now stands as proud as any other button.
|
|
502
|
+
: domedButton(SIZES.systemR * sz * 1.12, rise));
|
|
503
|
+
const mesh = new THREE.Mesh(geo, own(skin.system()));
|
|
504
|
+
place(mesh, shellX, pos, frontZ());
|
|
505
|
+
group.add(mesh);
|
|
506
|
+
controls.set(key, mesh);
|
|
507
|
+
}
|
|
508
|
+
// The stick, in its recess.
|
|
509
|
+
const wellGeo = track(new THREE.CylinderGeometry(SIZES.stickWellR, SIZES.stickWellR * 0.92, D * 0.06, 28));
|
|
510
|
+
wellGeo.rotateX(Math.PI / 2);
|
|
511
|
+
const well = new THREE.Mesh(wellGeo, skin.stickPost());
|
|
512
|
+
place(well, shellX, stickPos, frontZ() - D * 0.01);
|
|
513
|
+
group.add(well);
|
|
514
|
+
const { pivot, cap } = stickAssembly(SIZES.stickCapR * sz, SIZES.stickHeight, skin.stickPost(), own(skin.stickCap()), track);
|
|
515
|
+
place(pivot, shellX, stickPos, frontZ());
|
|
516
|
+
group.add(pivot);
|
|
517
|
+
sticks[side] = pivot;
|
|
518
|
+
controls.set(side === "l" ? "lstickPress" : "rstickPress", cap);
|
|
519
|
+
/**
|
|
520
|
+
* Height of the shell's TOP EDGE at horizontal position `x` — the curve the rear pad sits on.
|
|
521
|
+
*
|
|
522
|
+
* Same corner radii as the silhouette, so the two cannot disagree: it is the inverse of `inset`,
|
|
523
|
+
* answering "how high is the crown at this offset" instead of "how far in is the edge at this
|
|
524
|
+
* height". The outer corner is far larger than the inner one, so the crown is markedly asymmetric.
|
|
525
|
+
*/
|
|
526
|
+
const crownY = (x) => {
|
|
527
|
+
const half = W / 2;
|
|
528
|
+
const arc = (d, r) => {
|
|
529
|
+
if (r <= 0 || d >= r)
|
|
530
|
+
return H / 2;
|
|
531
|
+
return H / 2 - r + Math.sqrt(Math.max(0, r * r - (r - d) * (r - d)));
|
|
532
|
+
};
|
|
533
|
+
const innerX = (side === "l" ? 1 : -1) * half;
|
|
534
|
+
return Math.min(arc(Math.abs(x - -innerX), SILHOUETTE.outerTopR), arc(Math.abs(x - innerX), SILHOUETTE.innerTopR));
|
|
535
|
+
};
|
|
536
|
+
// ── the shoulder row ─────────────────────────────────────────────────────────────────────────
|
|
537
|
+
// Verbatim geometry, so there is nothing to place: the triangles already carry their position on
|
|
538
|
+
// the shell. Only the mirror for the right-hand half is applied.
|
|
539
|
+
// L / R — generated, like ZL/ZR, to the footprint measured on the reference: 96% of the width,
|
|
540
|
+
// from 13% to 32% of the depth (a thin band across the front of the crown).
|
|
541
|
+
//
|
|
542
|
+
// It WAS the reference's own triangles. That part is closed and consistently wound, yet it rendered
|
|
543
|
+
// with thin dark slits: read off a decimated copy of the model, its surfaces had been pinched
|
|
544
|
+
// together by the decimation. Re-reading the undecimated original was worse — 418 open edges, its
|
|
545
|
+
// vertices are split. Generating it sidesteps a source mesh that is simply not watertight enough to
|
|
546
|
+
// reuse, and makes both shoulder keys consistent with each other.
|
|
547
|
+
{
|
|
548
|
+
const raise = W * (TUNING.shoulderRaise + TUNING.shoulderLift);
|
|
549
|
+
const geo = track(crownPadGeometry(0.9, 0.13, 0.32, W * 0.03 * TUNING.shoulderScale, crownY, raise));
|
|
550
|
+
const mesh = new THREE.Mesh(geo, own(skin.shoulder()));
|
|
551
|
+
mesh.position.set(shellX, W * (TUNING.shoulderRaise + TUNING.shoulderLift), W * TUNING.shoulderLift * 0.4);
|
|
552
|
+
group.add(mesh);
|
|
553
|
+
controls.set(side === "l" ? "lb" : "rb", mesh);
|
|
554
|
+
}
|
|
555
|
+
// ZL / ZR: generated to the measured footprint — see crownPadGeometry for why it is not copied.
|
|
556
|
+
{
|
|
557
|
+
const raise = W * (TUNING.shoulderRaise + TUNING.shoulderLift);
|
|
558
|
+
const geo = track(crownPadGeometry(0.79, 0.44, 1.0, W * 0.055 * TUNING.shoulderScale, crownY, raise));
|
|
559
|
+
const mesh = new THREE.Mesh(geo, own(skin.shoulder()));
|
|
560
|
+
// `shoulderLift` applies here too. At 0 the pad sits exactly on the crown and the two surfaces
|
|
561
|
+
// fight for the same plane — z-fighting, which reads as a ragged hole in the key rather than as
|
|
562
|
+
// the coincident surfaces it is.
|
|
563
|
+
mesh.position.set(shellX, W * (TUNING.shoulderRaise + TUNING.shoulderLift), W * TUNING.shoulderLift * 0.4);
|
|
564
|
+
group.add(mesh);
|
|
565
|
+
controls.set(side === "l" ? "lt" : "rt", mesh);
|
|
566
|
+
}
|
|
567
|
+
// Shoulder lettering: L / R in front, ZL / ZR behind. Laid flat on the crown, facing up, since
|
|
568
|
+
// that is the face you actually see once the pad is tilted.
|
|
569
|
+
for (const [text, depthFrac, riseAt] of [
|
|
570
|
+
[side === "l" ? "L" : "R", 0.3, W * 0.03],
|
|
571
|
+
[side === "l" ? JOYCON_LAYOUT.triggers.l.glyph : JOYCON_LAYOUT.triggers.r.glyph, 0.7, W * 0.075],
|
|
572
|
+
]) {
|
|
573
|
+
if (!text)
|
|
574
|
+
continue;
|
|
575
|
+
const label = glyphPlane(text, W * 0.2, skin.ink ?? "#eaf2ff", track);
|
|
576
|
+
label.rotation.x = -Math.PI / 2;
|
|
577
|
+
label.position.set(shellX, crownY(0) + riseAt + W * TUNING.shoulderRaise, frontZ() - depthFrac * DEPTH_TOP);
|
|
578
|
+
group.add(label);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
skin.decorate?.(group, track);
|
|
582
|
+
return { group, controls, sticks, disposables };
|
|
583
|
+
}
|
|
584
|
+
// ── design 1 · "Product" ──────────────────────────────────────────────────────────────────────────
|
|
585
|
+
const NEON_L = 0xff3c28;
|
|
586
|
+
const NEON_R = 0x0ab9e6;
|
|
587
|
+
export function buildProductJoycon() {
|
|
588
|
+
const shellL = new THREE.MeshStandardMaterial({ color: NEON_L, roughness: 0.42, metalness: 0.02 });
|
|
589
|
+
const shellR = new THREE.MeshStandardMaterial({ color: NEON_R, roughness: 0.42, metalness: 0.02 });
|
|
590
|
+
const dark = new THREE.MeshStandardMaterial({ color: 0x1b1d21, roughness: 0.5, metalness: 0.05 });
|
|
591
|
+
const btn = new THREE.MeshStandardMaterial({ color: 0x2b2e34, roughness: 0.36, metalness: 0.08 });
|
|
592
|
+
const build = buildPair({
|
|
593
|
+
shell: (s) => (s === "l" ? shellL : shellR),
|
|
594
|
+
button: () => btn,
|
|
595
|
+
system: () => dark,
|
|
596
|
+
stickPost: () => dark,
|
|
597
|
+
stickCap: () => new THREE.MeshStandardMaterial({ color: 0x25282d, roughness: 0.55 }),
|
|
598
|
+
shoulder: () => new THREE.MeshStandardMaterial({ color: 0x23262b, roughness: 0.55 }),
|
|
599
|
+
ink: "#e8eef8",
|
|
600
|
+
});
|
|
601
|
+
build.disposables.push(shellL, shellR, dark, btn);
|
|
602
|
+
return build;
|
|
603
|
+
}
|
|
604
|
+
// ── design 2 · "Toy" ──────────────────────────────────────────────────────────────────────────────
|
|
605
|
+
export function buildToyJoycon() {
|
|
606
|
+
const flat = (c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.92, metalness: 0 });
|
|
607
|
+
const shellL = flat(0xef4a5a);
|
|
608
|
+
const shellR = flat(0x3aa9e0);
|
|
609
|
+
const btn = flat(0xf3f6fb);
|
|
610
|
+
const sys = flat(0xc9d3e2);
|
|
611
|
+
const post = flat(0x39414f);
|
|
612
|
+
const cap = flat(0xf3f6fb);
|
|
613
|
+
const sh = flat(0xdbe3ee);
|
|
614
|
+
const build = buildPair({
|
|
615
|
+
shell: (s) => (s === "l" ? shellL : shellR),
|
|
616
|
+
button: () => btn,
|
|
617
|
+
system: () => sys,
|
|
618
|
+
stickPost: () => post,
|
|
619
|
+
stickCap: () => cap,
|
|
620
|
+
shoulder: () => sh,
|
|
621
|
+
// Oversized, high-relief controls: this direction trades fidelity for being readable at distance,
|
|
622
|
+
// which is the actual job — a rider reads this while pedalling, metres from the screen.
|
|
623
|
+
riseScale: 2.4,
|
|
624
|
+
sizeScale: 1.28,
|
|
625
|
+
// Dark ink: the toy direction's keys are near-white, so light lettering would vanish.
|
|
626
|
+
ink: "#2b3444",
|
|
627
|
+
});
|
|
628
|
+
build.disposables.push(shellL, shellR, btn, sys, post, cap, sh);
|
|
629
|
+
return build;
|
|
630
|
+
}
|
|
631
|
+
// ── design 3 · "Holo" ─────────────────────────────────────────────────────────────────────────────
|
|
632
|
+
const ACCENT = 0x38bdf8;
|
|
633
|
+
export function buildHoloJoycon() {
|
|
634
|
+
const shell = new THREE.MeshStandardMaterial({
|
|
635
|
+
color: 0x0e1a26,
|
|
636
|
+
roughness: 0.28,
|
|
637
|
+
metalness: 0.5,
|
|
638
|
+
transparent: true,
|
|
639
|
+
opacity: 0.58,
|
|
640
|
+
});
|
|
641
|
+
const lit = new THREE.MeshStandardMaterial({
|
|
642
|
+
color: ACCENT,
|
|
643
|
+
emissive: ACCENT,
|
|
644
|
+
emissiveIntensity: 0.75,
|
|
645
|
+
roughness: 0.3,
|
|
646
|
+
});
|
|
647
|
+
const dim = new THREE.MeshStandardMaterial({
|
|
648
|
+
color: 0x1a2c3c,
|
|
649
|
+
emissive: ACCENT,
|
|
650
|
+
emissiveIntensity: 0.16,
|
|
651
|
+
roughness: 0.4,
|
|
652
|
+
});
|
|
653
|
+
const build = buildPair({
|
|
654
|
+
shell: () => shell,
|
|
655
|
+
button: () => lit,
|
|
656
|
+
system: () => dim,
|
|
657
|
+
stickPost: () => dim,
|
|
658
|
+
stickCap: () => lit,
|
|
659
|
+
shoulder: () => dim,
|
|
660
|
+
ink: "#04121c",
|
|
661
|
+
decorate(group, track) {
|
|
662
|
+
// Lit edges on the shells — what makes this read as UI rather than as an object. Only the two
|
|
663
|
+
// shells get them; edging every control would turn the pad into a wireframe mess.
|
|
664
|
+
const edgeMat = track(new THREE.LineBasicMaterial({ color: ACCENT, transparent: true, opacity: 0.85 }));
|
|
665
|
+
for (const child of [...group.children]) {
|
|
666
|
+
const mesh = child;
|
|
667
|
+
if (!(mesh instanceof THREE.Mesh) || mesh.material !== shell)
|
|
668
|
+
continue;
|
|
669
|
+
const edges = track(new THREE.EdgesGeometry(mesh.geometry, 28));
|
|
670
|
+
const line = new THREE.LineSegments(edges, edgeMat);
|
|
671
|
+
line.position.copy(mesh.position);
|
|
672
|
+
group.add(line);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
build.disposables.push(shell, lit, dim);
|
|
677
|
+
return build;
|
|
678
|
+
}
|
|
679
|
+
// ── the catalogue ─────────────────────────────────────────────────────────────────────────────────
|
|
680
|
+
export const JOYCON_DESIGNS = [
|
|
681
|
+
{
|
|
682
|
+
id: "product",
|
|
683
|
+
name: "Product",
|
|
684
|
+
note: "Le plus fidèle au matériel : néon rouge / néon bleu, boutons bombés, capuchons de stick creusés, " +
|
|
685
|
+
"rail de glissière. Ce que le rider reconnaît comme « sa » manette.",
|
|
686
|
+
backdrop: "radial-gradient(circle at 50% 35%, #223047 0%, #0a0f18 70%)",
|
|
687
|
+
build: buildProductJoycon,
|
|
688
|
+
light(scene) {
|
|
689
|
+
// Studio three-point: a warm key high-left, a cool fill low-right, a rim behind to peel the
|
|
690
|
+
// silhouette off the backdrop.
|
|
691
|
+
// Total intensity kept near 1.6. The first pass ran at 4.5, which drove every mid-grey part —
|
|
692
|
+
// the buttons and the whole shoulder row — to pure white, flattening the volumes that are the
|
|
693
|
+
// entire reason for rendering in 3D.
|
|
694
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.3));
|
|
695
|
+
const k = new THREE.DirectionalLight(0xfff4e6, 0.85);
|
|
696
|
+
k.position.set(-2.4, 3.2, 3.4);
|
|
697
|
+
scene.add(k);
|
|
698
|
+
const f = new THREE.DirectionalLight(0xbcd6ff, 0.3);
|
|
699
|
+
f.position.set(2.6, -1.6, 2.2);
|
|
700
|
+
scene.add(f);
|
|
701
|
+
const r = new THREE.DirectionalLight(0xffffff, 0.35);
|
|
702
|
+
r.position.set(0.4, 1.4, -3);
|
|
703
|
+
scene.add(r);
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
id: "toy",
|
|
708
|
+
name: "Toy",
|
|
709
|
+
note: "Volumes épaissis, couleurs plates, commandes surdimensionnées et très en relief. Sacrifie la " +
|
|
710
|
+
"fidélité à la lisibilité — pensé pour être lu à deux mètres, en pédalant.",
|
|
711
|
+
backdrop: "radial-gradient(circle at 50% 35%, #2b3550 0%, #0b1020 70%)",
|
|
712
|
+
build: buildToyJoycon,
|
|
713
|
+
light(scene) {
|
|
714
|
+
// Flat and generous: a toy render wants shape, not drama. High ambient keeps every face legible.
|
|
715
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
|
716
|
+
const k = new THREE.DirectionalLight(0xffffff, 0.75);
|
|
717
|
+
k.position.set(-1.4, 2.8, 3.2);
|
|
718
|
+
scene.add(k);
|
|
719
|
+
const f = new THREE.HemisphereLight(0xdfe9ff, 0x384056, 0.4);
|
|
720
|
+
scene.add(f);
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
id: "holo",
|
|
725
|
+
name: "Holo",
|
|
726
|
+
note: "Coque translucide, arêtes lumineuses, commandes émissives dans le bleu d'accent du SDK. Se lit " +
|
|
727
|
+
"comme un élément d'interface plutôt que comme un objet — cohérent avec le reste du kit.",
|
|
728
|
+
backdrop: "radial-gradient(circle at 50% 40%, #06202e 0%, #04070d 70%)",
|
|
729
|
+
build: buildHoloJoycon,
|
|
730
|
+
light(scene) {
|
|
731
|
+
// Low ambient so the emissive materials carry the image; two cool directionals for form.
|
|
732
|
+
scene.add(new THREE.AmbientLight(0x6fa8c8, 0.24));
|
|
733
|
+
const k = new THREE.DirectionalLight(0x9fd8ff, 0.7);
|
|
734
|
+
k.position.set(-2, 2.4, 3);
|
|
735
|
+
scene.add(k);
|
|
736
|
+
const r = new THREE.DirectionalLight(0x38bdf8, 0.45);
|
|
737
|
+
r.position.set(1.8, -1.2, -2.4);
|
|
738
|
+
scene.add(r);
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
];
|
|
742
|
+
//# sourceMappingURL=joycon-designs.js.map
|