entangled-worlds-orbiters-shared 4.2.0 → 5.1.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 +3 -2
- package/dist/shortcuts/catalogue.d.ts +72 -0
- package/dist/shortcuts/catalogue.js +118 -0
- package/dist/shortcuts/chord.d.ts +24 -2
- package/dist/shortcuts/chord.js +86 -8
- package/dist/shortcuts/format.d.ts +7 -4
- package/dist/shortcuts/format.js +40 -14
- package/dist/shortcuts/index.d.ts +4 -8
- package/dist/shortcuts/index.js +8 -11
- package/dist/shortcuts/match.d.ts +14 -6
- package/dist/shortcuts/match.js +14 -8
- package/dist/shortcuts/source.d.ts +62 -0
- package/dist/shortcuts/source.js +139 -0
- package/dist/sky/geometry.d.ts +31 -0
- package/dist/sky/geometry.js +84 -0
- package/dist/sky/index.d.ts +6 -0
- package/dist/sky/index.js +6 -0
- package/dist/sky/params.d.ts +44 -0
- package/dist/sky/params.js +52 -0
- package/dist/sky/shaders.d.ts +14 -0
- package/dist/sky/shaders.js +149 -0
- package/dist/sky/sky.d.ts +24 -0
- package/dist/sky/sky.js +268 -0
- package/dist/sky/worldSkyFile.d.ts +16 -0
- package/dist/sky/worldSkyFile.js +51 -0
- package/package.json +8 -2
- package/src/shortcuts/catalogue.ts +172 -0
- package/src/shortcuts/chord.ts +96 -6
- package/src/shortcuts/format.ts +44 -17
- package/src/shortcuts/index.ts +31 -28
- package/src/shortcuts/match.ts +25 -13
- package/src/shortcuts/source.ts +204 -0
- package/src/sky/geometry.ts +99 -0
- package/src/sky/index.ts +16 -0
- package/src/sky/params.ts +89 -0
- package/src/sky/shaders.ts +159 -0
- package/src/sky/sky.ts +330 -0
- package/src/sky/worldSkyFile.ts +60 -0
- package/dist/shortcuts/entangledWorlds.d.ts +0 -21
- package/dist/shortcuts/entangledWorlds.js +0 -40
- package/dist/shortcuts/labels.d.ts +0 -10
- package/dist/shortcuts/labels.js +0 -130
- package/dist/shortcuts/orbiters.d.ts +0 -55
- package/dist/shortcuts/orbiters.js +0 -109
- package/dist/shortcuts/overrides.d.ts +0 -30
- package/dist/shortcuts/overrides.js +0 -81
- package/dist/shortcuts/sections.d.ts +0 -23
- package/dist/shortcuts/sections.js +0 -19
- package/dist/shortcuts/site.d.ts +0 -9
- package/dist/shortcuts/site.js +0 -25
- package/src/shortcuts/entangledWorlds.ts +0 -51
- package/src/shortcuts/labels.ts +0 -139
- package/src/shortcuts/orbiters.ts +0 -132
- package/src/shortcuts/overrides.ts +0 -110
- package/src/shortcuts/sections.ts +0 -36
- package/src/shortcuts/site.ts +0 -33
package/dist/sky/sky.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The sky behind a world: stars (one Points draw), an optional nebula + Milky Way whose shape is
|
|
2
|
+
// baked into a small cube target shared by every sky on the renderer with the same shape (one
|
|
3
|
+
// textured box per frame, coloured live), and, under a world sky, the constellation figures (one
|
|
4
|
+
// LineSegments). All three draw after the opaque bodies with depth test
|
|
5
|
+
// on, so sky behind a planet costs nothing.
|
|
6
|
+
//
|
|
7
|
+
// Cost, measured against the six-face painted skycube it replaces (bench/sky): stars alone about
|
|
8
|
+
// 40% of its frame time; with the nebula about the same frame time, at 1/5–1/15 of its GPU memory
|
|
9
|
+
// and with nothing to download.
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import { DEFAULT_SKY_PARAMS, SKY_LOOK_KEYS, normalizeSkyParams } from './params.js';
|
|
12
|
+
import { buildLineGeometry, buildStarGeometry, galacticBandFrame, seededBandFrame } from './geometry.js';
|
|
13
|
+
import { createLcg } from '../star/layout.js';
|
|
14
|
+
import { BAKE_FRAGMENT, BAKE_VERTEX, GLOW_SEEDED, GLOW_WORLD, LINE_FRAGMENT, LINE_VERTEX, NEBULA_FRAGMENT, NEBULA_VERTEX, STAR_FRAGMENT, STAR_VERTEX, } from './shaders.js';
|
|
15
|
+
const RENDER_ORDER = 1000;
|
|
16
|
+
const kits = new WeakMap();
|
|
17
|
+
function kitFor(renderer) {
|
|
18
|
+
let kit = kits.get(renderer);
|
|
19
|
+
if (!kit) {
|
|
20
|
+
const uniforms = {
|
|
21
|
+
uSeed: { value: 0 },
|
|
22
|
+
uBand: { value: 0 },
|
|
23
|
+
uToBand: { value: new THREE.Matrix3() },
|
|
24
|
+
uCoreDir: { value: new THREE.Vector3(1, 0, 0) },
|
|
25
|
+
};
|
|
26
|
+
const geometry = new THREE.BoxGeometry(2, 2, 2);
|
|
27
|
+
const material = new THREE.ShaderMaterial({ uniforms, vertexShader: BAKE_VERTEX, fragmentShader: BAKE_FRAGMENT, side: THREE.BackSide, depthTest: false, depthWrite: false });
|
|
28
|
+
const scene = new THREE.Scene();
|
|
29
|
+
scene.add(new THREE.Mesh(geometry, material));
|
|
30
|
+
kit = { scene, geometry, material, fields: new Map() };
|
|
31
|
+
kits.set(renderer, kit);
|
|
32
|
+
}
|
|
33
|
+
return kit;
|
|
34
|
+
}
|
|
35
|
+
function acquireField(renderer, size, seed, band, frame, frameKey) {
|
|
36
|
+
const kit = kitFor(renderer);
|
|
37
|
+
const key = `${size}|${seed}|${band}|${frameKey}`;
|
|
38
|
+
let field = kit.fields.get(key);
|
|
39
|
+
if (!field) {
|
|
40
|
+
// Half-float where the GPU can render to it: the glow fades into the black over a handful of
|
|
41
|
+
// 8-bit levels, which show as hard stair-stepped contours. No depth: the bake tests none.
|
|
42
|
+
const ext = renderer.extensions;
|
|
43
|
+
const halfFloat = Boolean(ext?.has('EXT_color_buffer_half_float') || ext?.has('EXT_color_buffer_float'));
|
|
44
|
+
const target = new THREE.WebGLCubeRenderTarget(size, {
|
|
45
|
+
generateMipmaps: false,
|
|
46
|
+
depthBuffer: false,
|
|
47
|
+
minFilter: THREE.LinearFilter,
|
|
48
|
+
magFilter: THREE.LinearFilter,
|
|
49
|
+
type: halfFloat ? THREE.HalfFloatType : THREE.UnsignedByteType,
|
|
50
|
+
});
|
|
51
|
+
field = { target, bytes: 6 * size * size * (halfFloat ? 8 : 4), refs: 0, baked: false, seed, band, frame };
|
|
52
|
+
kit.fields.set(key, field);
|
|
53
|
+
}
|
|
54
|
+
field.refs++;
|
|
55
|
+
return { key, field };
|
|
56
|
+
}
|
|
57
|
+
function releaseField(renderer, key) {
|
|
58
|
+
const kit = kits.get(renderer);
|
|
59
|
+
const field = kit?.fields.get(key);
|
|
60
|
+
if (!kit || !field || --field.refs > 0)
|
|
61
|
+
return;
|
|
62
|
+
field.target.dispose();
|
|
63
|
+
kit.fields.delete(key);
|
|
64
|
+
// The last field gone: the bake's own box and shader go too, until a glow is wanted again.
|
|
65
|
+
if (kit.fields.size === 0) {
|
|
66
|
+
kit.geometry.dispose();
|
|
67
|
+
kit.material.dispose();
|
|
68
|
+
kits.delete(renderer);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function bakeField(renderer, field) {
|
|
72
|
+
const kit = kitFor(renderer);
|
|
73
|
+
const u = kit.material.uniforms;
|
|
74
|
+
// The shader offsets noise by the seed in float32: a 31-bit seed would erase its fraction, so
|
|
75
|
+
// the shader gets a small value derived from it.
|
|
76
|
+
u.uSeed.value = createLcg(field.seed)() * 1000;
|
|
77
|
+
u.uBand.value = field.band;
|
|
78
|
+
u.uToBand.value.setFromMatrix4(field.frame.basis).transpose();
|
|
79
|
+
u.uCoreDir.value.copy(field.frame.core);
|
|
80
|
+
new THREE.CubeCamera(0.1, 10, field.target).update(renderer, kit.scene);
|
|
81
|
+
field.baked = true;
|
|
82
|
+
}
|
|
83
|
+
export function createSky(renderer, initial = {}, options = {}) {
|
|
84
|
+
let p = normalizeSkyParams({ ...DEFAULT_SKY_PARAMS, ...initial });
|
|
85
|
+
let world = options.world ?? null;
|
|
86
|
+
const nebulaSize = options.nebulaSize ?? 512;
|
|
87
|
+
const usesWorld = () => p.source === 'world' && world !== null;
|
|
88
|
+
const frame = () => (usesWorld() ? galacticBandFrame() : seededBandFrame(p.seed, p.bandTilt));
|
|
89
|
+
const frameKey = () => (usesWorld() ? 'galactic' : `seeded:${p.bandTilt}`);
|
|
90
|
+
const farUniform = { value: 1000 };
|
|
91
|
+
const starUniforms = {
|
|
92
|
+
uTime: { value: 0 },
|
|
93
|
+
uBrightness: { value: p.brightness },
|
|
94
|
+
uSize: { value: p.size },
|
|
95
|
+
uColour: { value: p.colour },
|
|
96
|
+
uTwinkle: { value: p.twinkle },
|
|
97
|
+
uPixelScale: { value: 1 },
|
|
98
|
+
uGlowAt: { value: GLOW_SEEDED },
|
|
99
|
+
};
|
|
100
|
+
const starMat = new THREE.ShaderMaterial({
|
|
101
|
+
uniforms: starUniforms,
|
|
102
|
+
vertexShader: STAR_VERTEX,
|
|
103
|
+
fragmentShader: STAR_FRAGMENT,
|
|
104
|
+
blending: THREE.AdditiveBlending,
|
|
105
|
+
depthWrite: false,
|
|
106
|
+
transparent: true,
|
|
107
|
+
});
|
|
108
|
+
// The glow: a box at infinity sampling the shared field, coloured by these uniforms.
|
|
109
|
+
const nebulaUniforms = {
|
|
110
|
+
uNebula: { value: null },
|
|
111
|
+
uFar: farUniform,
|
|
112
|
+
uAmount: { value: p.nebula },
|
|
113
|
+
uHue: { value: p.hue },
|
|
114
|
+
uSpread: { value: p.hueSpread },
|
|
115
|
+
};
|
|
116
|
+
const nebulaMat = new THREE.ShaderMaterial({
|
|
117
|
+
uniforms: nebulaUniforms,
|
|
118
|
+
vertexShader: NEBULA_VERTEX,
|
|
119
|
+
fragmentShader: NEBULA_FRAGMENT,
|
|
120
|
+
side: THREE.BackSide,
|
|
121
|
+
depthWrite: false,
|
|
122
|
+
});
|
|
123
|
+
const nebula = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), nebulaMat);
|
|
124
|
+
nebula.name = 'SkyNebula';
|
|
125
|
+
nebula.frustumCulled = false;
|
|
126
|
+
// Opaque, but drawn after every opaque body: the depth test skips the pixels a planet covers.
|
|
127
|
+
nebula.renderOrder = RENDER_ORDER;
|
|
128
|
+
// Every layer reads the camera's far plane: whichever draws first sets it for the frame.
|
|
129
|
+
const trackFar = (_r, _s, cam) => {
|
|
130
|
+
farUniform.value = cam.far;
|
|
131
|
+
};
|
|
132
|
+
nebula.onBeforeRender = trackFar;
|
|
133
|
+
const lineUniforms = { uLines: { value: p.constellations }, uFar: farUniform };
|
|
134
|
+
const lineMat = new THREE.ShaderMaterial({ uniforms: lineUniforms, vertexShader: LINE_VERTEX, fragmentShader: LINE_FRAGMENT, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true });
|
|
135
|
+
const object = new THREE.Group();
|
|
136
|
+
object.name = 'Sky';
|
|
137
|
+
object.add(nebula);
|
|
138
|
+
let stars = null;
|
|
139
|
+
let lines = null;
|
|
140
|
+
let linesFor = null;
|
|
141
|
+
// The glow field this sky holds (none while the glow is off: its memory goes back).
|
|
142
|
+
let held = null;
|
|
143
|
+
const syncGlow = () => {
|
|
144
|
+
const wanted = p.nebula > 0 ? `${nebulaSize}|${p.seed}|${p.band}|${frameKey()}` : null;
|
|
145
|
+
if (held?.key === wanted)
|
|
146
|
+
return;
|
|
147
|
+
if (held)
|
|
148
|
+
releaseField(renderer, held.key);
|
|
149
|
+
held = wanted ? acquireField(renderer, nebulaSize, p.seed, p.band, frame(), frameKey()) : null;
|
|
150
|
+
nebulaUniforms.uNebula.value = held ? held.field.target.texture : null;
|
|
151
|
+
nebula.visible = held !== null;
|
|
152
|
+
};
|
|
153
|
+
const buildStars = () => {
|
|
154
|
+
if (stars) {
|
|
155
|
+
object.remove(stars);
|
|
156
|
+
stars.geometry.dispose();
|
|
157
|
+
}
|
|
158
|
+
const w = usesWorld() ? world : null;
|
|
159
|
+
stars = new THREE.Points(buildStarGeometry({ seed: p.seed, count: p.count, band: p.band, frame: frame(), world: w }), starMat);
|
|
160
|
+
stars.name = 'SkyStars';
|
|
161
|
+
stars.frustumCulled = false;
|
|
162
|
+
stars.renderOrder = RENDER_ORDER + 1;
|
|
163
|
+
stars.onBeforeRender = trackFar;
|
|
164
|
+
object.add(stars);
|
|
165
|
+
starUniforms.uGlowAt.value = w ? GLOW_WORLD : GLOW_SEEDED;
|
|
166
|
+
};
|
|
167
|
+
// The figures depend on the world sky alone: kept across every other change.
|
|
168
|
+
const syncLines = () => {
|
|
169
|
+
const w = usesWorld() ? world : null;
|
|
170
|
+
if (w === linesFor)
|
|
171
|
+
return;
|
|
172
|
+
linesFor = w;
|
|
173
|
+
if (lines) {
|
|
174
|
+
object.remove(lines);
|
|
175
|
+
lines.geometry.dispose();
|
|
176
|
+
lines = null;
|
|
177
|
+
}
|
|
178
|
+
if (w && w.lines.length > 0) {
|
|
179
|
+
lines = new THREE.LineSegments(buildLineGeometry(w), lineMat);
|
|
180
|
+
lines.name = 'SkyConstellations';
|
|
181
|
+
lines.frustumCulled = false;
|
|
182
|
+
lines.renderOrder = RENDER_ORDER + 2;
|
|
183
|
+
lines.onBeforeRender = trackFar;
|
|
184
|
+
object.add(lines);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const applyLook = () => {
|
|
188
|
+
starUniforms.uBrightness.value = p.brightness;
|
|
189
|
+
starUniforms.uSize.value = p.size;
|
|
190
|
+
starUniforms.uColour.value = p.colour;
|
|
191
|
+
starUniforms.uTwinkle.value = p.twinkle;
|
|
192
|
+
lineUniforms.uLines.value = p.constellations;
|
|
193
|
+
if (lines)
|
|
194
|
+
lines.visible = p.constellations > 0;
|
|
195
|
+
nebulaUniforms.uAmount.value = p.nebula;
|
|
196
|
+
nebulaUniforms.uHue.value = p.hue;
|
|
197
|
+
nebulaUniforms.uSpread.value = p.hueSpread;
|
|
198
|
+
};
|
|
199
|
+
// A rebuild runs at most once a frame, from `update`: a density drag that moves many times
|
|
200
|
+
// between frames builds once.
|
|
201
|
+
let rebuildPending = false;
|
|
202
|
+
const rebuild = () => {
|
|
203
|
+
buildStars();
|
|
204
|
+
syncLines();
|
|
205
|
+
syncGlow();
|
|
206
|
+
applyLook();
|
|
207
|
+
};
|
|
208
|
+
rebuild();
|
|
209
|
+
const bytes = (g) => {
|
|
210
|
+
let n = 0;
|
|
211
|
+
for (const a of Object.values(g.attributes))
|
|
212
|
+
n += a.array.byteLength;
|
|
213
|
+
return n;
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
object,
|
|
217
|
+
setParams(next) {
|
|
218
|
+
const prev = p;
|
|
219
|
+
p = normalizeSkyParams({ ...p, ...next });
|
|
220
|
+
// A world sky's band is the galactic plane: its tilt changes nothing there.
|
|
221
|
+
const changed = Object.keys(next).filter((k) => prev[k] !== p[k] && !(k === 'bandTilt' && usesWorld()));
|
|
222
|
+
if (changed.length === 0)
|
|
223
|
+
return;
|
|
224
|
+
applyLook();
|
|
225
|
+
// Glow on or off: hold or let go of its field now (no star work).
|
|
226
|
+
syncGlow();
|
|
227
|
+
if (!changed.every((k) => SKY_LOOK_KEYS.includes(k)))
|
|
228
|
+
rebuildPending = true;
|
|
229
|
+
},
|
|
230
|
+
setWorld(next) {
|
|
231
|
+
if (next === world)
|
|
232
|
+
return;
|
|
233
|
+
world = next;
|
|
234
|
+
if (p.source === 'world')
|
|
235
|
+
rebuildPending = true;
|
|
236
|
+
},
|
|
237
|
+
update(timeSec, drawingBufferHeight) {
|
|
238
|
+
if (rebuildPending) {
|
|
239
|
+
rebuildPending = false;
|
|
240
|
+
rebuild();
|
|
241
|
+
}
|
|
242
|
+
if (held && !held.field.baked)
|
|
243
|
+
bakeField(renderer, held.field);
|
|
244
|
+
starUniforms.uTime.value = timeSec;
|
|
245
|
+
starUniforms.uPixelScale.value = drawingBufferHeight / 1080;
|
|
246
|
+
},
|
|
247
|
+
gpuBytes() {
|
|
248
|
+
let n = stars ? bytes(stars.geometry) : 0;
|
|
249
|
+
if (lines)
|
|
250
|
+
n += bytes(lines.geometry);
|
|
251
|
+
// The glow field is shared: each sky holding it counts its share.
|
|
252
|
+
if (held)
|
|
253
|
+
n += held.field.bytes / held.field.refs;
|
|
254
|
+
return n;
|
|
255
|
+
},
|
|
256
|
+
dispose() {
|
|
257
|
+
stars?.geometry.dispose();
|
|
258
|
+
lines?.geometry.dispose();
|
|
259
|
+
starMat.dispose();
|
|
260
|
+
lineMat.dispose();
|
|
261
|
+
nebula.geometry.dispose();
|
|
262
|
+
nebulaMat.dispose();
|
|
263
|
+
if (held)
|
|
264
|
+
releaseField(renderer, held.key);
|
|
265
|
+
held = null;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const WORLD_SKY_MAGIC = "WSKY";
|
|
2
|
+
export declare const WORLD_SKY_FORMAT = 1;
|
|
3
|
+
export interface WorldSky {
|
|
4
|
+
datasetVersion: number;
|
|
5
|
+
starCount: number;
|
|
6
|
+
/** Star directions, `Int16 × 3` per star; a view into the file. */
|
|
7
|
+
directions: Int16Array;
|
|
8
|
+
/** Star colour + brightness, `Uint8 × 4` per star; a view into the file. */
|
|
9
|
+
colourBrightness: Uint8Array;
|
|
10
|
+
/** Constellation line vertices, `Int16 × 3` each, in segment pairs; a view into the file. */
|
|
11
|
+
lines: Int16Array;
|
|
12
|
+
/** Seeded stars to add so a far host's sky still reads Earth-like. */
|
|
13
|
+
standIns: number;
|
|
14
|
+
}
|
|
15
|
+
/** Wraps a world-sky file. Throws on anything that is not a complete format-1 file. */
|
|
16
|
+
export declare function parseWorldSky(buffer: ArrayBuffer): WorldSky;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The world-sky file: a host star's night sky, worked out once by the backend's catalogue sync and
|
|
2
|
+
// read here without a copy or any star maths. Format 1, little-endian (N stars, L line vertices):
|
|
3
|
+
//
|
|
4
|
+
// offset size type field
|
|
5
|
+
// 0 4 ASCII magic "WSKY"
|
|
6
|
+
// 4 2 uint16 format version (1)
|
|
7
|
+
// 6 2 uint16 header size in bytes; stars start here
|
|
8
|
+
// 8 4 uint32 dataset version
|
|
9
|
+
// 12 4 uint32 N, star count
|
|
10
|
+
// 16 4 uint32 L, line-vertex count (even: vertices 2k, 2k+1 are one segment)
|
|
11
|
+
// 20 4 uint32 stand-in count: seeded stars to add for an Earth-like sky
|
|
12
|
+
// 24 8 — reserved
|
|
13
|
+
// H 6·N int16 × 3 star directions (unit vector × 32767), scene frame
|
|
14
|
+
// H + 6N 4·N uint8 × 4 star colour (r g b) + apparent brightness (a: mag −1 → 255, 6.5 → 0)
|
|
15
|
+
// H + 10N 6·L int16 × 3 constellation line vertices (arcs already split)
|
|
16
|
+
//
|
|
17
|
+
// Scene frame: y toward celestial north; x = cos(dec)·cos(ra), z = −cos(dec)·sin(ra).
|
|
18
|
+
export const WORLD_SKY_MAGIC = 'WSKY';
|
|
19
|
+
export const WORLD_SKY_FORMAT = 1;
|
|
20
|
+
/** Wraps a world-sky file. Throws on anything that is not a complete format-1 file. */
|
|
21
|
+
export function parseWorldSky(buffer) {
|
|
22
|
+
if (buffer.byteLength < 24)
|
|
23
|
+
throw new Error('world sky: file too short');
|
|
24
|
+
const view = new DataView(buffer);
|
|
25
|
+
const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
|
|
26
|
+
if (magic !== WORLD_SKY_MAGIC)
|
|
27
|
+
throw new Error('world sky: not a WSKY file');
|
|
28
|
+
const format = view.getUint16(4, true);
|
|
29
|
+
if (format !== WORLD_SKY_FORMAT)
|
|
30
|
+
throw new Error(`world sky: unsupported format ${format}`);
|
|
31
|
+
const header = view.getUint16(6, true);
|
|
32
|
+
const datasetVersion = view.getUint32(8, true);
|
|
33
|
+
const starCount = view.getUint32(12, true);
|
|
34
|
+
const lineVertices = view.getUint32(16, true);
|
|
35
|
+
const standIns = view.getUint32(20, true);
|
|
36
|
+
if (header < 24 || header % 2 !== 0)
|
|
37
|
+
throw new Error('world sky: bad header size');
|
|
38
|
+
if (lineVertices % 2 !== 0)
|
|
39
|
+
throw new Error('world sky: odd line-vertex count');
|
|
40
|
+
const size = header + 10 * starCount + 6 * lineVertices;
|
|
41
|
+
if (buffer.byteLength < size)
|
|
42
|
+
throw new Error('world sky: file truncated');
|
|
43
|
+
return {
|
|
44
|
+
datasetVersion,
|
|
45
|
+
starCount,
|
|
46
|
+
directions: new Int16Array(buffer, header, 3 * starCount),
|
|
47
|
+
colourBrightness: new Uint8Array(buffer, header + 6 * starCount, 4 * starCount),
|
|
48
|
+
lines: new Int16Array(buffer, header + 10 * starCount, 3 * lineVertices),
|
|
49
|
+
standIns,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -83,6 +83,10 @@
|
|
|
83
83
|
"types": "./dist/star/index.d.ts",
|
|
84
84
|
"import": "./dist/star/index.js"
|
|
85
85
|
},
|
|
86
|
+
"./sky": {
|
|
87
|
+
"types": "./dist/sky/index.d.ts",
|
|
88
|
+
"import": "./dist/sky/index.js"
|
|
89
|
+
},
|
|
86
90
|
"./audio": {
|
|
87
91
|
"types": "./dist/audio/index.d.ts",
|
|
88
92
|
"import": "./dist/audio/index.js"
|
|
@@ -154,6 +158,8 @@
|
|
|
154
158
|
"baseline:release": "tsx scripts/api-baseline.ts release",
|
|
155
159
|
"check:exports": "tsx scripts/check-exports.ts",
|
|
156
160
|
"bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
|
|
157
|
-
"bench:star": "npm run bench:star:build && tsx bench/star/run.ts"
|
|
161
|
+
"bench:star": "npm run bench:star:build && tsx bench/star/run.ts",
|
|
162
|
+
"bench:sky:build": "esbuild bench/sky/main.ts --bundle --format=esm --target=es2022 --outfile=bench/sky/build/main.js --log-level=warning",
|
|
163
|
+
"bench:sky": "npm run bench:sky:build && tsx bench/sky/run.ts"
|
|
158
164
|
}
|
|
159
165
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The shortcut catalogue as the apps read it: every app's sections of actions, their default keys and
|
|
2
|
+
// names, as the backend serves them (from its config, so a key or a name changes without a library
|
|
3
|
+
// release). This file only reads what was served; it holds no actions of its own.
|
|
4
|
+
|
|
5
|
+
import type { ShortcutAction, ShortcutNames } from './match.js'
|
|
6
|
+
|
|
7
|
+
/** Something with a name: English `label`, other languages in `names`. */
|
|
8
|
+
export interface ShortcutNamed {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly label: string
|
|
11
|
+
readonly names?: ShortcutNames
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A row of a keys sheet (`?`): one line for one or more actions whose keys show together. */
|
|
15
|
+
export interface ShortcutSheetRow extends ShortcutNamed {
|
|
16
|
+
readonly actions: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ShortcutSheetColumn extends ShortcutNamed {
|
|
20
|
+
readonly rows: readonly ShortcutSheetRow[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One surface's keys (`orbiters-play`, `ew-studio`, `site`). Two actions clash only inside a section. */
|
|
24
|
+
export interface ShortcutSection {
|
|
25
|
+
readonly id: string
|
|
26
|
+
/** The app it belongs to (`orbiters`) and that app's name. */
|
|
27
|
+
readonly app: string
|
|
28
|
+
readonly appLabel: string
|
|
29
|
+
readonly appNames?: ShortcutNames
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly names?: ShortcutNames
|
|
32
|
+
/** Its name inside the app (`Play`, `Studio`); none when the app has only this section. */
|
|
33
|
+
readonly part?: string
|
|
34
|
+
readonly partNames?: ShortcutNames
|
|
35
|
+
/** How a command menu groups the actions, in its order. */
|
|
36
|
+
readonly groups: readonly ShortcutNamed[]
|
|
37
|
+
/** The keys sheet, where the app shows one in columns. */
|
|
38
|
+
readonly sheet?: readonly ShortcutSheetColumn[]
|
|
39
|
+
readonly actions: readonly ShortcutAction[]
|
|
40
|
+
/**
|
|
41
|
+
* Letters a surface of this section plays while it is open (`event.key`, lower-case; the Orbiters
|
|
42
|
+
* on-screen keyboard): a shortcut on one of them waits until it closes.
|
|
43
|
+
*/
|
|
44
|
+
readonly heldKeys?: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ShortcutCatalogue {
|
|
48
|
+
/** Changes whenever the catalogue is edited. */
|
|
49
|
+
readonly version: string
|
|
50
|
+
readonly sections: readonly ShortcutSection[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Before the catalogue is read: no sections, so no key does anything yet. */
|
|
54
|
+
export const EMPTY_CATALOGUE: ShortcutCatalogue = Object.freeze({ version: '', sections: Object.freeze([]) })
|
|
55
|
+
|
|
56
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
57
|
+
!!value && typeof value === 'object' && !Array.isArray(value)
|
|
58
|
+
const isString = (value: unknown): value is string => typeof value === 'string' && value.length > 0
|
|
59
|
+
|
|
60
|
+
function names(value: unknown): ShortcutNames | undefined {
|
|
61
|
+
if (!isRecord(value)) return undefined
|
|
62
|
+
const out: Record<string, string> = {}
|
|
63
|
+
for (const [locale, name] of Object.entries(value)) if (isString(name)) out[locale] = name
|
|
64
|
+
return Object.freeze(out)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function named(value: unknown): ShortcutNamed | null {
|
|
68
|
+
if (!isRecord(value) || !isString(value.id) || !isString(value.label)) return null
|
|
69
|
+
return { id: value.id, label: value.label, names: names(value.names) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function action(value: unknown): ShortcutAction | null {
|
|
73
|
+
const base = named(value)
|
|
74
|
+
if (!base || !isRecord(value) || !isString(value.scope) || !Array.isArray(value.defaultKeys)) return null
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
...base,
|
|
77
|
+
defaultKeys: Object.freeze(value.defaultKeys.filter(isString)),
|
|
78
|
+
scope: value.scope,
|
|
79
|
+
...(isString(value.group) ? { group: value.group } : {}),
|
|
80
|
+
...(value.command === false ? { command: false } : {}),
|
|
81
|
+
...(isString(value.href) ? { href: value.href } : {}),
|
|
82
|
+
...(value.signedIn === true ? { signedIn: true } : {}),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const list = <T>(value: unknown, read: (item: unknown) => T | null): readonly T[] =>
|
|
87
|
+
Object.freeze(Array.isArray(value) ? value.map(read).filter((item): item is T => item !== null) : [])
|
|
88
|
+
|
|
89
|
+
function section(value: unknown): ShortcutSection | null {
|
|
90
|
+
const base = named(value)
|
|
91
|
+
if (!base || !isRecord(value) || !isString(value.app) || !isString(value.appLabel)) return null
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
...base,
|
|
94
|
+
app: value.app,
|
|
95
|
+
appLabel: value.appLabel,
|
|
96
|
+
appNames: names(value.appNames),
|
|
97
|
+
...(isString(value.part) ? { part: value.part, partNames: names(value.partNames) } : {}),
|
|
98
|
+
groups: list(value.groups, named),
|
|
99
|
+
...(Array.isArray(value.sheet)
|
|
100
|
+
? {
|
|
101
|
+
sheet: list(value.sheet, (column) => {
|
|
102
|
+
const head = named(column)
|
|
103
|
+
if (!head || !isRecord(column)) return null
|
|
104
|
+
return Object.freeze({
|
|
105
|
+
...head,
|
|
106
|
+
rows: list(column.rows, (row) => {
|
|
107
|
+
const line = named(row)
|
|
108
|
+
return line && isRecord(row) ? Object.freeze({ ...line, actions: list(row.actions, (id) => (isString(id) ? id : null)) }) : null
|
|
109
|
+
}),
|
|
110
|
+
})
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
actions: list(value.actions, action),
|
|
115
|
+
...(Array.isArray(value.heldKeys) ? { heldKeys: list(value.heldKeys, (key) => (isString(key) ? key : null)) } : {}),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The catalogue a response holds (`{ version, sections }`), or `null` when it holds none. A malformed
|
|
121
|
+
* section or action is left out rather than failing the whole read.
|
|
122
|
+
*/
|
|
123
|
+
export function parseCatalogue(value: unknown): ShortcutCatalogue | null {
|
|
124
|
+
if (!isRecord(value) || !Array.isArray(value.sections)) return null
|
|
125
|
+
return Object.freeze({ version: typeof value.version === 'string' ? value.version : '', sections: list(value.sections, section) })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A name in a language (`es`, `es-AR`), the English when there is none in it. */
|
|
129
|
+
export function localName(item: { readonly label: string; readonly names?: ShortcutNames }, locale?: string): string {
|
|
130
|
+
return item.names?.[(locale ?? '').slice(0, 2).toLowerCase()] ?? item.label
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A section's app name in a language. */
|
|
134
|
+
export function localAppName(section: Pick<ShortcutSection, 'appLabel' | 'appNames'>, locale?: string): string {
|
|
135
|
+
return localName({ label: section.appLabel, names: section.appNames }, locale)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A section's part name (`Play`, `Studio`) in a language; `undefined` when it has none. */
|
|
139
|
+
export function localPartName(section: Pick<ShortcutSection, 'part' | 'partNames'>, locale?: string): string | undefined {
|
|
140
|
+
return section.part === undefined ? undefined : localName({ label: section.part, names: section.partNames }, locale)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The apps the catalogue has, in its order. */
|
|
144
|
+
export function shortcutApps(catalogue: ShortcutCatalogue): ReadonlyArray<{ id: string; label: string; names?: ShortcutNames }> {
|
|
145
|
+
const seen = new Map<string, { id: string; label: string; names?: ShortcutNames }>()
|
|
146
|
+
for (const s of catalogue.sections) if (!seen.has(s.app)) seen.set(s.app, { id: s.app, label: s.appLabel, names: s.appNames })
|
|
147
|
+
return [...seen.values()]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const actionLists = new WeakMap<ShortcutCatalogue, Map<string, readonly ShortcutAction[]>>()
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The actions of these sections, in the order named: where two sections hold one key, the first
|
|
154
|
+
* named wins (the Orbiter Studio lies over play). The same list object for as long as the catalogue
|
|
155
|
+
* is the same, so a cache keyed by it holds.
|
|
156
|
+
*/
|
|
157
|
+
export function sectionActions(catalogue: ShortcutCatalogue, ...sectionIds: string[]): readonly ShortcutAction[] {
|
|
158
|
+
let lists = actionLists.get(catalogue)
|
|
159
|
+
if (!lists) actionLists.set(catalogue, (lists = new Map()))
|
|
160
|
+
const key = sectionIds.join('\n')
|
|
161
|
+
let found = lists.get(key)
|
|
162
|
+
if (!found) {
|
|
163
|
+
found = Object.freeze(sectionIds.flatMap((id) => catalogue.sections.find((s) => s.id === id)?.actions ?? []))
|
|
164
|
+
lists.set(key, found)
|
|
165
|
+
}
|
|
166
|
+
return found
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One section, if the catalogue has it. */
|
|
170
|
+
export function findSection(catalogue: ShortcutCatalogue, sectionId: string): ShortcutSection | undefined {
|
|
171
|
+
return catalogue.sections.find((s) => s.id === sectionId)
|
|
172
|
+
}
|
package/src/shortcuts/chord.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// One key with its modifiers, written as a string: `Shift+KeyR`, `Mod+KeyZ`, `Shift?+Digit1`.
|
|
2
2
|
//
|
|
3
|
-
// The last part
|
|
4
|
-
//
|
|
3
|
+
// The last part names the key the way a US keyboard does (`KeyboardEvent.code`: `KeyQ`, `Slash`,
|
|
4
|
+
// `Digit1`). A press matches it by the CHARACTER it types wherever it types one, so `/` is the key
|
|
5
|
+
// that prints `/` on this person's keyboard (Shift+7 on a Spanish one), and `KeyQ` is the key that
|
|
6
|
+
// prints Q (top left on a French one). Only where the key types no character the US keyboard has
|
|
7
|
+
// (a Greek or Cyrillic letter, ⌥ on a Mac changing it, a dead key, no letter on a letter key) is the
|
|
8
|
+
// key matched by its place.
|
|
9
|
+
// Digits match by character first, then by place: a French keyboard types `&` there.
|
|
10
|
+
// Before the key, the modifiers:
|
|
5
11
|
//
|
|
6
12
|
// Shift, Alt, Ctrl, Meta held. One left out must NOT be held.
|
|
7
13
|
// Mod Ctrl or ⌘ (either, or both), the command key of every platform.
|
|
@@ -72,15 +78,99 @@ function parse(keys: string): Chord | null {
|
|
|
72
78
|
|
|
73
79
|
const fits = (rule: ModifierRule, down: boolean) => rule === 'any' || (rule === 'held') === Boolean(down)
|
|
74
80
|
|
|
75
|
-
/**
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
/** A press as a chord reads it: the modifiers, the key's place and, when the browser says, the character it typed. */
|
|
82
|
+
export interface ChordPress extends ModifierState {
|
|
83
|
+
readonly code: string
|
|
84
|
+
readonly key?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The punctuation keys a chord may name, and what a US keyboard types there without and with Shift:
|
|
89
|
+
* what the chord means on every keyboard. With these, Shift is part of the character (`?` is
|
|
90
|
+
* `Shift+Slash`), so a keyboard that needs Shift, or AltGr, for a `/` still types the `/`.
|
|
91
|
+
*/
|
|
92
|
+
const PUNCTUATION: Readonly<Record<string, readonly [string, string]>> = {
|
|
93
|
+
Minus: ['-', '_'], Equal: ['=', '+'], BracketLeft: ['[', '{'], BracketRight: [']', '}'], Backslash: ['\\', '|'],
|
|
94
|
+
Semicolon: [';', ':'], Quote: ["'", '"'], Backquote: ['`', '~'], Comma: [',', '<'], Period: ['.', '>'], Slash: ['/', '?'],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The code a US keyboard types each punctuation character at, and whether it takes Shift. */
|
|
98
|
+
const PUNCTUATION_PLACE = new Map<string, readonly [code: string, shift: boolean]>(
|
|
99
|
+
Object.entries(PUNCTUATION).flatMap(([code, [plain, shifted]]): Array<[string, readonly [string, boolean]]> => [[plain, [code, false]], [shifted, [code, true]]]),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/** The character a press typed, when it is one a US keyboard has (printable ASCII); else `null`. */
|
|
103
|
+
function typed(key: string | undefined): string | null {
|
|
104
|
+
return typeof key === 'string' && key.length === 1 && key >= '!' && key <= '~' ? key : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How a press is this chord: by the character it typed, by the key's place, or not at all. */
|
|
108
|
+
export type ChordMatch = 'character' | 'place' | null
|
|
109
|
+
|
|
110
|
+
function commandFits(chord: Chord, state: ModifierState): boolean {
|
|
79
111
|
if (chord.mod === 'held') return Boolean(state.ctrlKey || state.metaKey)
|
|
80
112
|
if (chord.mod === 'any') return true
|
|
81
113
|
return fits(chord.ctrl, state.ctrlKey) && fits(chord.meta, state.metaKey)
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
function placeMatch(chord: Chord, code: string, state: ModifierState): boolean {
|
|
117
|
+
return chord.code === code && fits(chord.shift, state.shiftKey) && fits(chord.alt, state.altKey) && commandFits(chord, state)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How a press is this chord. `character` beats `place`: on a Spanish keyboard Shift+7 types `/`, so it
|
|
122
|
+
* is the `/` key before it is the `7` key. A press with no `key` (an old caller) matches by place only.
|
|
123
|
+
*/
|
|
124
|
+
export function chordMatch(chord: Chord, press: ChordPress): ChordMatch {
|
|
125
|
+
const char = typed(press.key)
|
|
126
|
+
if (char === null) return placeMatch(chord, press.code, press) ? 'place' : null
|
|
127
|
+
const punctuation = PUNCTUATION[chord.code]
|
|
128
|
+
if (punctuation) {
|
|
129
|
+
// Shift is in the character (a Spanish `/` is Shift+7); with ⌘ some browsers report the key
|
|
130
|
+
// unshifted, so `⇧⌘,` is also a `,` with Shift held. Alt only when the chord asks for it (AltGr
|
|
131
|
+
// and ⌥ type characters).
|
|
132
|
+
const [plain, shifted] = punctuation
|
|
133
|
+
const asShifted = char === shifted || (isCommandChord(chord) && char === plain && press.shiftKey)
|
|
134
|
+
const ok = chord.shift === 'held' ? asShifted : chord.shift === 'any' ? char === plain || asShifted : char === plain
|
|
135
|
+
// A command (⇧⌘,) where this keyboard types another character there: the key's place, as menus do.
|
|
136
|
+
if (!ok) return isCommandChord(chord) && placeMatch(chord, press.code, press) ? 'place' : null
|
|
137
|
+
if (chord.alt === 'held' && !press.altKey) return null
|
|
138
|
+
// AltGr arrives as Ctrl+Alt on Windows: that Ctrl typed the character, it is no command.
|
|
139
|
+
const altGr = press.altKey && press.ctrlKey && !press.metaKey
|
|
140
|
+
return commandFits(chord, { ctrlKey: press.ctrlKey && !altGr, metaKey: press.metaKey, shiftKey: press.shiftKey, altKey: press.altKey }) ? 'character' : null
|
|
141
|
+
}
|
|
142
|
+
const letter = /^Key([A-Z])$/.exec(chord.code)?.[1]
|
|
143
|
+
if (letter) {
|
|
144
|
+
// A key that types no letter (`;` where Q is on a Greek keyboard) is read by its place.
|
|
145
|
+
if (!/^[a-zA-Z]$/.test(char)) return placeMatch(chord, press.code, press) ? 'place' : null
|
|
146
|
+
if (char.toUpperCase() !== letter) return null
|
|
147
|
+
return fits(chord.shift, press.shiftKey) && fits(chord.alt, press.altKey) && commandFits(chord, press) ? 'character' : null
|
|
148
|
+
}
|
|
149
|
+
const digit = /^Digit(\d)$/.exec(chord.code)?.[1]
|
|
150
|
+
// A digit typed; where the keyboard types something else there (`&` on a French one), its place.
|
|
151
|
+
if (digit && char === digit && fits(chord.shift, press.shiftKey) && fits(chord.alt, press.altKey) && commandFits(chord, press)) return 'character'
|
|
152
|
+
return placeMatch(chord, press.code, press) ? 'place' : null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether a press of `code` with these modifiers (and, when given, the character it typed) is this chord. */
|
|
156
|
+
export function chordMatches(chord: Chord, code: string, state: ModifierState & { readonly key?: string }): boolean {
|
|
157
|
+
// Field by field: a browser's KeyboardEvent keeps them on its prototype, where a spread does not reach.
|
|
158
|
+
const { shiftKey, altKey, ctrlKey, metaKey, key } = state
|
|
159
|
+
return chordMatch(chord, { code, key, shiftKey, altKey, ctrlKey, metaKey }) !== null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The key string a typed character names (`/` → `Slash`, `?` → `Shift+Slash`, `q` → `KeyQ`), for
|
|
164
|
+
* someone setting a key; `null` for a character a US keyboard does not have, or none.
|
|
165
|
+
*/
|
|
166
|
+
export function typedKey(key: string | undefined): { readonly code: string; readonly shift?: boolean } | null {
|
|
167
|
+
const char = typed(key)
|
|
168
|
+
if (char === null) return null
|
|
169
|
+
if (/^[a-zA-Z]$/.test(char)) return { code: `Key${char.toUpperCase()}` }
|
|
170
|
+
const place = PUNCTUATION_PLACE.get(char)
|
|
171
|
+
return place ? { code: place[0], shift: place[1] } : null
|
|
172
|
+
}
|
|
173
|
+
|
|
84
174
|
/** A chord that needs Ctrl or ⌘: a command, never a character typed. */
|
|
85
175
|
export function isCommandChord(chord: Chord): boolean {
|
|
86
176
|
return chord.mod === 'held' || chord.ctrl === 'held' || chord.meta === 'held'
|