@celestia-island/hikari 0.55.261 → 0.55.262
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/package.json +1 -1
- package/src/components/HkMenu.rail.test.tsx +72 -3
- package/src/components/HkMenu.scss +7 -7
- package/src/components/HkSidebar.scss +14 -10
- package/src/components/HkSidebar.styles.test.ts +55 -10
- package/src/components/HkSidebar.tsx +6 -5
- package/src/scene3d/holographicFleetBody.test.ts +214 -90
- package/src/scene3d/holographicFleetBody.ts +327 -231
- package/src/scene3d/holographicFleetScene.test.ts +156 -106
- package/src/scene3d/holographicFleetScene.ts +47 -64
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* holographicFleetBody — one node's
|
|
3
|
-
*
|
|
2
|
+
* holographicFleetBody — one node's hologram: a spherical shell built from
|
|
3
|
+
* six overlapping face dials plus a fat-centre ring spindle, with ONE
|
|
4
|
+
* detailed camera-facing dial at the front carrying the real-data ticker.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* picking; the centre
|
|
6
|
+
* User direction (2026-10-31): the body must read as a ball of circles
|
|
7
|
+
* from every side — no polyhedron, no solid sphere, no shared wireframe,
|
|
8
|
+
* and no visible cube edges. Main cores (role "star") are larger with
|
|
9
|
+
* denser dial content; node cores (role "planet") are sparser; satellites
|
|
10
|
+
* get a miniature crossed-dial shell with no text. Every decorative line
|
|
11
|
+
* stays out of raycast picking; the invisible centre proxy remains the
|
|
12
|
+
* node's pointer target.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import * as THREE from "three";
|
|
14
16
|
|
|
15
|
-
import {
|
|
16
|
-
HOLOGRAM_RING_SCALES,
|
|
17
|
-
type FleetBodySpec,
|
|
18
|
-
} from "./holographicFleetSpec";
|
|
17
|
+
import type { FleetBodySpec } from "./holographicFleetSpec";
|
|
19
18
|
|
|
20
19
|
export type FleetHologramInput = Pick<
|
|
21
20
|
FleetBodySpec,
|
|
22
21
|
"id" | "label" | "status" | "detail" | "role" | "radius"
|
|
23
22
|
>;
|
|
24
23
|
|
|
25
|
-
export interface FleetHologramRing {
|
|
26
|
-
pivot: THREE.Group;
|
|
27
|
-
baseY: number;
|
|
28
|
-
speed: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
export interface FleetHologram {
|
|
32
25
|
group: THREE.Group;
|
|
26
|
+
/** Invisible centre proxy; the node's pointer target (never rendered). */
|
|
33
27
|
mesh: THREE.Mesh;
|
|
34
|
-
|
|
28
|
+
/** Six faint overlapping face dials: the spherical shell. */
|
|
29
|
+
shell: THREE.Group;
|
|
30
|
+
/** The detailed camera-facing dial that carries the real-data ticker. */
|
|
31
|
+
frontDial: THREE.Group;
|
|
32
|
+
/** Sweeping layer inside the front dial (ticks and arcs rotate here). */
|
|
33
|
+
frontSpinner: THREE.Group;
|
|
34
|
+
/** Circular focus mark, shown while the body is selected. */
|
|
35
|
+
selectionRing: THREE.Group;
|
|
36
|
+
/** The real-data ticker plane at the front dial's centre. */
|
|
37
|
+
hudPanel: THREE.Mesh | null;
|
|
35
38
|
/** Null only when the host cannot create a 2D canvas (SSR/test DOM). */
|
|
36
39
|
hudTexture: THREE.CanvasTexture | null;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
/** The single source of truth for the
|
|
42
|
+
/** The single source of truth for the dial ticker's content. */
|
|
40
43
|
export function hologramHudText(input: FleetHologramInput): string {
|
|
41
44
|
return [input.label, input.status, input.detail]
|
|
42
45
|
.map((part) => part.trim())
|
|
@@ -44,11 +47,17 @@ export function hologramHudText(input: FleetHologramInput): string {
|
|
|
44
47
|
.join(" · ") || input.id;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
/** Decorative geometry must not become a broad, invisible pointer target. */
|
|
51
|
+
function unpickable<T extends THREE.Object3D>(object: T): T {
|
|
52
|
+
object.raycast = () => undefined;
|
|
53
|
+
return object;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function xyCircleGeometry(radius: number, segments: number): THREE.BufferGeometry {
|
|
48
57
|
const points: number[] = [];
|
|
49
58
|
for (let i = 0; i < segments; i += 1) {
|
|
50
59
|
const angle = (i / segments) * Math.PI * 2;
|
|
51
|
-
points.push(Math.cos(angle) * radius,
|
|
60
|
+
points.push(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
|
|
52
61
|
}
|
|
53
62
|
return new THREE.BufferGeometry().setAttribute(
|
|
54
63
|
"position",
|
|
@@ -56,14 +65,16 @@ function circleGeometry(radius: number, segments: number): THREE.BufferGeometry
|
|
|
56
65
|
);
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
function
|
|
68
|
+
function xyArcGeometry(
|
|
69
|
+
radius: number,
|
|
70
|
+
start: number,
|
|
71
|
+
span: number,
|
|
72
|
+
segments = 48,
|
|
73
|
+
): THREE.BufferGeometry {
|
|
60
74
|
const points: number[] = [];
|
|
61
|
-
const segments = 32;
|
|
62
|
-
const start = -0.22;
|
|
63
|
-
const span = Math.PI * 0.48;
|
|
64
75
|
for (let i = 0; i <= segments; i += 1) {
|
|
65
76
|
const angle = start + (i / segments) * span;
|
|
66
|
-
points.push(Math.cos(angle) * radius,
|
|
77
|
+
points.push(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
|
|
67
78
|
}
|
|
68
79
|
return new THREE.BufferGeometry().setAttribute(
|
|
69
80
|
"position",
|
|
@@ -71,199 +82,210 @@ function arcGeometry(radius: number): THREE.BufferGeometry {
|
|
|
71
82
|
);
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
function lineMaterial(color: THREE.Color, opacity: number): THREE.LineBasicMaterial {
|
|
86
|
+
return new THREE.LineBasicMaterial({
|
|
87
|
+
color,
|
|
88
|
+
transparent: true,
|
|
89
|
+
opacity,
|
|
90
|
+
depthWrite: false,
|
|
91
|
+
blending: THREE.AdditiveBlending,
|
|
92
|
+
});
|
|
78
93
|
}
|
|
79
94
|
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
/** How much content a dial tier draws (user direction: main cores carry
|
|
96
|
+
* MORE content than node cores — rings, ticks, arcs — never identical). */
|
|
97
|
+
interface DialTier {
|
|
98
|
+
rings: ReadonlyArray<{ scale: number; opacity: number }>;
|
|
99
|
+
tickCount: number;
|
|
100
|
+
arcs: ReadonlyArray<{ scale: number; start: number; span: number; opacity: number }>;
|
|
101
|
+
core: boolean;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const triangleCenter = a.clone().add(b).add(c).multiplyScalar(1 / 3);
|
|
102
|
-
if (normal.dot(triangleCenter) < 0) normal.negate();
|
|
103
|
-
let group = groups.find((candidate) => candidate.normal.dot(normal) > 0.999);
|
|
104
|
-
if (!group) {
|
|
105
|
-
group = { normal, vertices: new Map() };
|
|
106
|
-
groups.push(group);
|
|
107
|
-
}
|
|
108
|
-
for (const vertex of [a, b, c]) {
|
|
109
|
-
const vertexKey = [vertex.x, vertex.y, vertex.z]
|
|
110
|
-
.map((value) => value.toFixed(5))
|
|
111
|
-
.join(":");
|
|
112
|
-
group.vertices.set(vertexKey, vertex);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
104
|
+
const FOCUS_STAR: DialTier = {
|
|
105
|
+
rings: [
|
|
106
|
+
{ scale: 1, opacity: 0.72 },
|
|
107
|
+
{ scale: 0.64, opacity: 0.3 },
|
|
108
|
+
{ scale: 0.38, opacity: 0.22 },
|
|
109
|
+
],
|
|
110
|
+
tickCount: 48,
|
|
111
|
+
arcs: [
|
|
112
|
+
{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 0.94 },
|
|
113
|
+
{ scale: 0.64, start: Math.PI * 0.85, span: Math.PI * 0.2, opacity: 0.62 },
|
|
114
|
+
],
|
|
115
|
+
core: true,
|
|
116
|
+
};
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
};
|
|
128
|
-
});
|
|
129
|
-
}
|
|
118
|
+
const FOCUS_PLANET: DialTier = {
|
|
119
|
+
rings: [
|
|
120
|
+
{ scale: 1, opacity: 0.66 },
|
|
121
|
+
{ scale: 0.52, opacity: 0.26 },
|
|
122
|
+
],
|
|
123
|
+
tickCount: 28,
|
|
124
|
+
arcs: [
|
|
125
|
+
{ scale: 1, start: -0.25, span: Math.PI * 0.5, opacity: 0.9 },
|
|
126
|
+
],
|
|
127
|
+
core: true,
|
|
128
|
+
};
|
|
130
129
|
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return angle(a) - angle(b);
|
|
143
|
-
});
|
|
144
|
-
let inradius = Number.POSITIVE_INFINITY;
|
|
145
|
-
for (let i = 0; i < ordered.length; i += 1) {
|
|
146
|
-
const a = ordered[i]!;
|
|
147
|
-
const b = ordered[(i + 1) % ordered.length]!;
|
|
148
|
-
const edge = b.clone().sub(a);
|
|
149
|
-
const area2 = edge.clone().cross(face.center.clone().sub(a)).length();
|
|
150
|
-
if (edge.lengthSq() > 1e-10) inradius = Math.min(inradius, area2 / edge.length());
|
|
151
|
-
}
|
|
152
|
-
return Number.isFinite(inradius) ? inradius : 0;
|
|
130
|
+
/** The five non-facing shell faces: an outline and a faint arc only, so
|
|
131
|
+
* the sphere stays legible without competing with the front dial. */
|
|
132
|
+
const SUPPORT: DialTier = {
|
|
133
|
+
rings: [{ scale: 1, opacity: 0.28 }],
|
|
134
|
+
tickCount: 0,
|
|
135
|
+
arcs: [{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 0.34 }],
|
|
136
|
+
core: false,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function focusTierFor(role: "star" | "planet"): DialTier {
|
|
140
|
+
return role === "star" ? FOCUS_STAR : FOCUS_PLANET;
|
|
153
141
|
}
|
|
154
142
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
geometry: THREE.BufferGeometry,
|
|
159
|
-
bodyRadius: number,
|
|
143
|
+
/** One circular dial in the XY plane, drawn to a content tier. */
|
|
144
|
+
function makeFaceDial(
|
|
145
|
+
radius: number,
|
|
160
146
|
color: THREE.Color,
|
|
161
147
|
offline: boolean,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
148
|
+
tier: DialTier,
|
|
149
|
+
): THREE.Group {
|
|
150
|
+
const dial = new THREE.Group();
|
|
151
|
+
dial.name = "fleet-hologram-face-dial";
|
|
152
|
+
|
|
153
|
+
for (const ring of tier.rings) {
|
|
154
|
+
const line = unpickable(new THREE.LineLoop(
|
|
155
|
+
xyCircleGeometry(radius * ring.scale, 160),
|
|
156
|
+
lineMaterial(color, offline ? ring.opacity * 0.24 : ring.opacity),
|
|
157
|
+
));
|
|
158
|
+
line.name = "fleet-hologram-face-dial-ring";
|
|
159
|
+
dial.add(line);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (tier.tickCount > 0) {
|
|
163
|
+
const ticks: number[] = [];
|
|
164
|
+
for (let i = 0; i < tier.tickCount; i += 1) {
|
|
165
|
+
const angle = (i / tier.tickCount) * Math.PI * 2;
|
|
166
|
+
const major = i % 4 === 0;
|
|
167
|
+
const inner = radius - radius * (major ? 0.13 : 0.06);
|
|
168
|
+
const outer = radius + radius * (major ? 0.05 : 0.025);
|
|
169
|
+
ticks.push(Math.cos(angle) * inner, Math.sin(angle) * inner, 0);
|
|
170
|
+
ticks.push(Math.cos(angle) * outer, Math.sin(angle) * outer, 0);
|
|
179
171
|
}
|
|
180
|
-
const
|
|
172
|
+
const tickMarks = unpickable(new THREE.LineSegments(
|
|
181
173
|
new THREE.BufferGeometry().setAttribute(
|
|
182
174
|
"position",
|
|
183
|
-
new THREE.Float32BufferAttribute(
|
|
175
|
+
new THREE.Float32BufferAttribute(ticks, 3),
|
|
184
176
|
),
|
|
185
|
-
|
|
177
|
+
lineMaterial(color, offline ? 0.12 : 0.58),
|
|
178
|
+
));
|
|
179
|
+
tickMarks.name = "fleet-hologram-face-dial-ticks";
|
|
180
|
+
dial.add(tickMarks);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const arc of tier.arcs) {
|
|
184
|
+
const line = unpickable(new THREE.Line(
|
|
185
|
+
xyArcGeometry(radius * arc.scale, arc.start, arc.span),
|
|
186
|
+
lineMaterial(color, offline ? arc.opacity * 0.22 : arc.opacity),
|
|
187
|
+
));
|
|
188
|
+
line.name = "fleet-hologram-face-dial-arc";
|
|
189
|
+
dial.add(line);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (tier.core) {
|
|
193
|
+
const core = unpickable(new THREE.Mesh(
|
|
194
|
+
new THREE.CircleGeometry(radius * 0.16, 32),
|
|
195
|
+
new THREE.MeshBasicMaterial({
|
|
186
196
|
color,
|
|
187
197
|
transparent: true,
|
|
188
|
-
opacity: offline ? 0.
|
|
198
|
+
opacity: offline ? 0.08 : 0.26,
|
|
199
|
+
side: THREE.DoubleSide,
|
|
189
200
|
depthWrite: false,
|
|
190
201
|
blending: THREE.AdditiveBlending,
|
|
191
202
|
}),
|
|
192
203
|
));
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
core.name = "fleet-hologram-face-dial-core";
|
|
205
|
+
dial.add(core);
|
|
195
206
|
}
|
|
196
|
-
return
|
|
207
|
+
return dial;
|
|
197
208
|
}
|
|
198
209
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
210
|
+
/** Six overlapping support dials on the cube's face axes. Their rims
|
|
211
|
+
* merge into one rounded envelope — the box itself must stay invisible
|
|
212
|
+
* (user direction 2026-10-31: fusing the two structures is not allowed
|
|
213
|
+
* to leave a readable cube behind). */
|
|
214
|
+
function makeDiscShell(
|
|
215
|
+
bodyRadius: number,
|
|
202
216
|
color: THREE.Color,
|
|
203
217
|
offline: boolean,
|
|
204
|
-
):
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// One bright arc makes the ring's slow rotation visible without filling
|
|
226
|
-
// the scene with dashed lines, random glyphs or particle noise.
|
|
227
|
-
const arc = unpickable(new THREE.Line(
|
|
228
|
-
arcGeometry(radius),
|
|
229
|
-
new THREE.LineBasicMaterial({
|
|
230
|
-
color,
|
|
231
|
-
transparent: true,
|
|
232
|
-
opacity: offline ? 0.2 : 0.86,
|
|
233
|
-
depthWrite: false,
|
|
234
|
-
blending: THREE.AdditiveBlending,
|
|
235
|
-
}),
|
|
236
|
-
));
|
|
237
|
-
arc.name = "fleet-hologram-ring-arc";
|
|
238
|
-
pivot.add(arc);
|
|
218
|
+
): THREE.Group {
|
|
219
|
+
const shell = new THREE.Group();
|
|
220
|
+
shell.name = "fleet-hologram-disc-shell";
|
|
221
|
+
const faceOffset = bodyRadius * 0.4;
|
|
222
|
+
const dialRadius = bodyRadius * 0.8;
|
|
223
|
+
const faces: Array<{ position: [number, number, number]; rotation: [number, number, number] }> = [
|
|
224
|
+
{ position: [0, 0, faceOffset], rotation: [0, 0, 0] },
|
|
225
|
+
{ position: [0, 0, -faceOffset], rotation: [0, Math.PI, 0] },
|
|
226
|
+
{ position: [faceOffset, 0, 0], rotation: [0, Math.PI / 2, 0] },
|
|
227
|
+
{ position: [-faceOffset, 0, 0], rotation: [0, -Math.PI / 2, 0] },
|
|
228
|
+
{ position: [0, faceOffset, 0], rotation: [-Math.PI / 2, 0, 0] },
|
|
229
|
+
{ position: [0, -faceOffset, 0], rotation: [Math.PI / 2, 0, 0] },
|
|
230
|
+
];
|
|
231
|
+
for (const face of faces) {
|
|
232
|
+
const dial = makeFaceDial(dialRadius, color, offline, SUPPORT);
|
|
233
|
+
dial.position.set(...face.position);
|
|
234
|
+
dial.rotation.set(...face.rotation);
|
|
235
|
+
shell.add(dial);
|
|
236
|
+
}
|
|
237
|
+
return shell;
|
|
238
|
+
}
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
240
|
+
/** A stack of concentric circles along the Z axis: fat at the equator,
|
|
241
|
+
* tapering to thin rings at the poles (user direction 2026-10-31 — this
|
|
242
|
+
* is the second of the two fused structures; it rounds the shell into a
|
|
243
|
+
* ball and keeps every side view a set of nested circles). */
|
|
244
|
+
function makeRingSpindle(
|
|
245
|
+
bodyRadius: number,
|
|
246
|
+
color: THREE.Color,
|
|
247
|
+
offline: boolean,
|
|
248
|
+
count: number,
|
|
249
|
+
): THREE.Group {
|
|
250
|
+
const spindle = new THREE.Group();
|
|
251
|
+
spindle.name = "fleet-hologram-ring-spindle";
|
|
252
|
+
for (let i = 0; i < count; i += 1) {
|
|
253
|
+
const t = count === 1 ? 0 : -0.97 + (1.94 * i) / (count - 1);
|
|
254
|
+
const radius = bodyRadius * Math.sqrt(Math.max(0, 1 - t * t)) * 0.98;
|
|
255
|
+
const loop = unpickable(new THREE.LineLoop(
|
|
256
|
+
xyCircleGeometry(radius, 160),
|
|
257
|
+
lineMaterial(color, offline ? 0.04 : 0.08 + (1 - Math.abs(t)) * 0.12),
|
|
258
|
+
));
|
|
259
|
+
loop.name = "fleet-hologram-ring-spindle-circle";
|
|
260
|
+
loop.position.z = bodyRadius * t;
|
|
261
|
+
spindle.add(loop);
|
|
262
|
+
}
|
|
263
|
+
return spindle;
|
|
247
264
|
}
|
|
248
265
|
|
|
249
266
|
function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
|
|
250
267
|
const canvas = document.createElement("canvas");
|
|
251
268
|
canvas.width = 1024;
|
|
252
|
-
canvas.height =
|
|
269
|
+
canvas.height = 96;
|
|
253
270
|
const ctx = canvas.getContext("2d");
|
|
254
271
|
if (!ctx) return null;
|
|
255
272
|
|
|
256
273
|
const line = hologramHudText(input);
|
|
257
274
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
258
|
-
|
|
275
|
+
// 64px glyphs on the 96px strip: the plane's aspect is locked to the
|
|
276
|
+
// canvas's below, so on-disc text height is fontPx/canvasW × planeW —
|
|
277
|
+
// 64/1024 of the band, readable at fleet zoom.
|
|
278
|
+
ctx.font = "600 64px ui-monospace, SFMono-Regular, Consolas, monospace";
|
|
259
279
|
ctx.textBaseline = "middle";
|
|
260
280
|
ctx.fillStyle = "#ffffff";
|
|
261
281
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
282
|
+
// A clipped horizontal ticker for the dial's centre (user direction
|
|
283
|
+
// 2026-10-31: the text belongs to the disc's middle, not a wrapping
|
|
284
|
+
// ribbon). Long host labels are condensed by canvas rather than
|
|
285
|
+
// replaced with fabricated data.
|
|
286
|
+
const maxItemWidth = 610;
|
|
265
287
|
const measured = ctx.measureText(line).width;
|
|
266
|
-
const itemWidth = Math.min(maxItemWidth, Math.max(
|
|
288
|
+
const itemWidth = Math.min(maxItemWidth, Math.max(360, measured + 64));
|
|
267
289
|
for (let x = 24; x < canvas.width; x += itemWidth) {
|
|
268
290
|
ctx.fillText(line, x, canvas.height / 2, maxItemWidth - 48);
|
|
269
291
|
}
|
|
@@ -275,30 +297,52 @@ function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
|
|
|
275
297
|
return texture;
|
|
276
298
|
}
|
|
277
299
|
|
|
278
|
-
|
|
279
|
-
|
|
300
|
+
/** The real-data ticker plane, pinned to the front dial's centre. The
|
|
301
|
+
* plane's aspect is locked to the texture's (1024:96) — a mismatched
|
|
302
|
+
* aspect would compress the glyphs horizontally. */
|
|
303
|
+
function makeTickerPlane(
|
|
304
|
+
bodyRadius: number,
|
|
280
305
|
color: THREE.Color,
|
|
281
306
|
offline: boolean,
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
const height = Math.max(0.18, input.radius * 0.23);
|
|
287
|
-
const material = new THREE.MeshBasicMaterial({
|
|
288
|
-
map: texture,
|
|
289
|
-
color,
|
|
290
|
-
transparent: true,
|
|
291
|
-
opacity: offline ? 0.2 : 0.68,
|
|
292
|
-
side: THREE.DoubleSide,
|
|
293
|
-
depthWrite: false,
|
|
294
|
-
blending: THREE.AdditiveBlending,
|
|
295
|
-
});
|
|
307
|
+
texture: THREE.CanvasTexture,
|
|
308
|
+
): THREE.Mesh {
|
|
309
|
+
const width = bodyRadius * 1.14;
|
|
310
|
+
const height = width * (96 / 1024);
|
|
296
311
|
const mesh = unpickable(new THREE.Mesh(
|
|
297
|
-
new THREE.
|
|
298
|
-
|
|
312
|
+
new THREE.PlaneGeometry(width, height),
|
|
313
|
+
new THREE.MeshBasicMaterial({
|
|
314
|
+
map: texture,
|
|
315
|
+
color,
|
|
316
|
+
transparent: true,
|
|
317
|
+
opacity: offline ? 0.18 : 0.86,
|
|
318
|
+
side: THREE.DoubleSide,
|
|
319
|
+
depthWrite: false,
|
|
320
|
+
blending: THREE.AdditiveBlending,
|
|
321
|
+
}),
|
|
299
322
|
));
|
|
300
|
-
mesh.name = "fleet-hologram-hud-
|
|
301
|
-
return
|
|
323
|
+
mesh.name = "fleet-hologram-hud-panel";
|
|
324
|
+
return mesh;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The circular focus mark that replaces the board's cube cursor while a
|
|
328
|
+
* body is selected (straight corners would fight the ring silhouette). */
|
|
329
|
+
function makeSelectionRing(
|
|
330
|
+
bodyRadius: number,
|
|
331
|
+
color: THREE.Color,
|
|
332
|
+
offline: boolean,
|
|
333
|
+
): THREE.Group {
|
|
334
|
+
const ring = new THREE.Group();
|
|
335
|
+
ring.name = "fleet-hologram-selection-ring";
|
|
336
|
+
ring.visible = false;
|
|
337
|
+
for (const [scale, opacity] of [[1.02, 0.8], [1.08, 0.24]] as const) {
|
|
338
|
+
const line = unpickable(new THREE.LineLoop(
|
|
339
|
+
xyCircleGeometry(bodyRadius * scale, 160),
|
|
340
|
+
lineMaterial(color, offline ? opacity * 0.2 : opacity),
|
|
341
|
+
));
|
|
342
|
+
line.name = "fleet-hologram-selection-ring-line";
|
|
343
|
+
ring.add(line);
|
|
344
|
+
}
|
|
345
|
+
return ring;
|
|
302
346
|
}
|
|
303
347
|
|
|
304
348
|
export function makeFleetHologram(
|
|
@@ -308,58 +352,110 @@ export function makeFleetHologram(
|
|
|
308
352
|
): FleetHologram {
|
|
309
353
|
const group = new THREE.Group();
|
|
310
354
|
group.name = "fleet-hologram";
|
|
311
|
-
const color = baseColor.clone();
|
|
312
|
-
|
|
313
|
-
if (offline) {
|
|
314
|
-
color.multiplyScalar(0.3);
|
|
315
|
-
edgeColor.multiplyScalar(0.3);
|
|
316
|
-
}
|
|
355
|
+
const color = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.18);
|
|
356
|
+
if (offline) color.multiplyScalar(0.3);
|
|
317
357
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
358
|
+
// The visible object is the disc shell plus its spindle; this invisible
|
|
359
|
+
// proxy is only the pointer target the board measures for picking. It
|
|
360
|
+
// reaches 82% of the body radius — the old polyhedron's pick reach —
|
|
361
|
+
// so most of the visible silhouette still answers a click.
|
|
321
362
|
const mesh = new THREE.Mesh(
|
|
322
|
-
|
|
363
|
+
new THREE.IcosahedronGeometry(input.radius * 0.82, 1),
|
|
323
364
|
new THREE.MeshBasicMaterial({
|
|
324
365
|
color,
|
|
325
366
|
transparent: true,
|
|
326
|
-
opacity:
|
|
327
|
-
side: THREE.DoubleSide,
|
|
367
|
+
opacity: 0,
|
|
328
368
|
depthWrite: false,
|
|
329
|
-
blending: THREE.AdditiveBlending,
|
|
330
369
|
}),
|
|
331
370
|
);
|
|
332
|
-
mesh.name = "fleet-hologram-
|
|
333
|
-
|
|
371
|
+
mesh.name = "fleet-hologram-core";
|
|
372
|
+
|
|
373
|
+
const shell = makeDiscShell(input.radius, color, offline);
|
|
374
|
+
shell.add(makeRingSpindle(
|
|
375
|
+
input.radius,
|
|
376
|
+
color,
|
|
377
|
+
offline,
|
|
378
|
+
input.role === "star" ? 9 : 6,
|
|
379
|
+
));
|
|
380
|
+
group.add(shell);
|
|
381
|
+
|
|
382
|
+
const frontDial = new THREE.Group();
|
|
383
|
+
frontDial.name = "fleet-hologram-front-dial";
|
|
384
|
+
const frontSpinner = new THREE.Group();
|
|
385
|
+
frontSpinner.name = "fleet-hologram-front-spinner";
|
|
386
|
+
frontSpinner.add(makeFaceDial(input.radius * 0.8, color, offline, focusTierFor(input.role)));
|
|
387
|
+
frontDial.add(frontSpinner);
|
|
334
388
|
|
|
335
|
-
|
|
336
|
-
|
|
389
|
+
const hudTexture = makeHudTexture(input);
|
|
390
|
+
let hudPanel: THREE.Mesh | null = null;
|
|
391
|
+
if (hudTexture) {
|
|
392
|
+
hudPanel = makeTickerPlane(input.radius, color, offline, hudTexture);
|
|
393
|
+
hudPanel.position.z = input.radius * 0.02;
|
|
394
|
+
frontDial.add(hudPanel);
|
|
337
395
|
}
|
|
396
|
+
group.add(frontDial);
|
|
397
|
+
|
|
398
|
+
const selectionRing = makeSelectionRing(input.radius, color, offline);
|
|
399
|
+
group.add(selectionRing);
|
|
338
400
|
group.add(mesh);
|
|
339
401
|
|
|
340
|
-
|
|
341
|
-
const ring = makeRing(input.radius * scale, index, edgeColor, offline);
|
|
342
|
-
group.add(ring.pivot);
|
|
343
|
-
return ring;
|
|
344
|
-
});
|
|
402
|
+
group.userData.fleetOwnedTextures = hudTexture ? [hudTexture] : [];
|
|
345
403
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
404
|
+
return {
|
|
405
|
+
group,
|
|
406
|
+
mesh,
|
|
407
|
+
shell,
|
|
408
|
+
frontDial,
|
|
409
|
+
frontSpinner,
|
|
410
|
+
selectionRing,
|
|
411
|
+
hudPanel,
|
|
412
|
+
hudTexture,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
349
415
|
|
|
350
|
-
|
|
416
|
+
/** A satellite's miniature hologram: three crossed support dials and a
|
|
417
|
+
* three-ring spindle — the same visual language as its gateway, at
|
|
418
|
+
* endpoint scale and with no text (user direction 2026-10-31: EVERY
|
|
419
|
+
* solid sphere in the scene is replaced, hierarchy carried by size and
|
|
420
|
+
* content density). */
|
|
421
|
+
export function makeSatelliteHologram(
|
|
422
|
+
radius: number,
|
|
423
|
+
baseColor: THREE.Color,
|
|
424
|
+
offline: boolean,
|
|
425
|
+
): THREE.Group {
|
|
426
|
+
const group = new THREE.Group();
|
|
427
|
+
group.name = "fleet-hologram-satellite";
|
|
428
|
+
const color = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.18);
|
|
429
|
+
if (offline) color.multiplyScalar(0.3);
|
|
430
|
+
const orientations: Array<[number, number, number]> = [
|
|
431
|
+
[0, 0, 0],
|
|
432
|
+
[0, Math.PI / 3, Math.PI / 4],
|
|
433
|
+
[Math.PI / 3, 0, -Math.PI / 5],
|
|
434
|
+
];
|
|
435
|
+
for (const [rx, ry, rz] of orientations) {
|
|
436
|
+
const dial = makeFaceDial(radius, color, offline, SUPPORT);
|
|
437
|
+
dial.rotation.set(rx, ry, rz);
|
|
438
|
+
group.add(dial);
|
|
439
|
+
}
|
|
440
|
+
group.add(makeRingSpindle(radius, color, offline, 3));
|
|
441
|
+
return group;
|
|
351
442
|
}
|
|
352
443
|
|
|
353
|
-
/** Independent motion: the
|
|
354
|
-
*
|
|
444
|
+
/** Independent motion: the shell turns slowly, the front dial and the
|
|
445
|
+
* selection ring face the camera, the dial's ticks sweep, and the
|
|
446
|
+
* real-data ticker scrolls across the disc's centre. */
|
|
355
447
|
export function poseFleetHologram(
|
|
356
448
|
hologram: FleetHologram,
|
|
357
449
|
elapsed: number,
|
|
358
450
|
spinPeriodSec: number,
|
|
451
|
+
cameraQuaternion?: THREE.Quaternion,
|
|
359
452
|
): void {
|
|
360
|
-
hologram.
|
|
361
|
-
|
|
362
|
-
|
|
453
|
+
hologram.shell.rotation.y = (elapsed / spinPeriodSec) * Math.PI * 2;
|
|
454
|
+
hologram.shell.rotation.x = Math.sin(elapsed * 0.08) * 0.06;
|
|
455
|
+
hologram.frontSpinner.rotation.z = elapsed * 0.11;
|
|
456
|
+
if (cameraQuaternion) {
|
|
457
|
+
hologram.frontDial.quaternion.copy(cameraQuaternion);
|
|
458
|
+
hologram.selectionRing.quaternion.copy(cameraQuaternion);
|
|
363
459
|
}
|
|
364
460
|
if (hologram.hudTexture) {
|
|
365
461
|
hologram.hudTexture.offset.x = (elapsed * 0.018) % 1;
|