@fluritheme/moon-explorer 0.0.0-stage → 0.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/dist/index.js ADDED
@@ -0,0 +1,428 @@
1
+ import { Expedition, clamp } from "./model.js";
2
+ import { paint } from "./render.js";
3
+ const validMissions = [
4
+ "explore",
5
+ "sample",
6
+ "flag",
7
+ "rover",
8
+ "observe",
9
+ "wave",
10
+ "jump",
11
+ "rest",
12
+ ];
13
+ function finite(n, fallback, min, max) {
14
+ return typeof n === "number" && Number.isFinite(n)
15
+ ? clamp(n, min, max)
16
+ : fallback;
17
+ }
18
+ /** Create a self-contained lunar expedition. Importing the module is SSR-safe. */
19
+ export function createMoonExplorer(input = {}) {
20
+ const browser = typeof window !== "undefined" && typeof document !== "undefined";
21
+ let opts = {
22
+ size: finite(input.size, 1, 0.55, 1.8),
23
+ pace: finite(input.pace, 1, 0.3, 2.5),
24
+ autoExplore: input.autoExplore ?? true,
25
+ interactive: input.interactive ?? true,
26
+ rover: input.rover ?? true,
27
+ lander: input.lander ?? true,
28
+ shootingStars: input.shootingStars ?? true,
29
+ maxFlags: Math.round(finite(input.maxFlags, 5, 0, 12)),
30
+ accent: typeof input.accent === "string" && /^#[0-9a-f]{3,8}$/i.test(input.accent)
31
+ ? input.accent
32
+ : "#ef9459",
33
+ maxFPS: finite(input.maxFPS, 40, 12, 60),
34
+ maxDPR: finite(input.maxDPR, 1.5, 1, 2.5),
35
+ zIndex: Math.round(finite(input.zIndex, 30, -2147483647, 2147483647)),
36
+ motion: input.motion ?? "respect",
37
+ };
38
+ let state = browser ? "running" : "idle";
39
+ let reduced = false, destroyed = false, manualPause = false, hidden = false;
40
+ const listeners = new Map();
41
+ const emptyWorld = {
42
+ bounds: { x: 0, y: 0, width: 800, height: 600 },
43
+ view: { x: 0, y: 0, width: 800, height: 600 },
44
+ surfaces: [{ id: "floor", x: 40, y: 480, width: 720 }],
45
+ };
46
+ let model = new Expedition(emptyWorld, opts.size, opts.autoExplore, opts.rover, opts.maxFlags, input.seed ?? 42);
47
+ model.onEvent = (event, data) => {
48
+ for (const f of listeners.get(event) ?? [])
49
+ try {
50
+ f({ ...data });
51
+ }
52
+ catch {
53
+ /* A listener cannot interrupt the animation loop. */
54
+ }
55
+ };
56
+ let canvas = null, context = null, root = null, frame = 0, last = 0, drawLast = 0, resize = null, mutation = null, media = null;
57
+ let refreshFrame = 0;
58
+ const pointer = { x: 0, y: 0, active: false };
59
+ const elements = new Map();
60
+ let ids = 0;
61
+ const rootIsPage = () => root === document.body || root === document.documentElement;
62
+ function measure() {
63
+ if (!root)
64
+ return emptyWorld;
65
+ const page = rootIsPage();
66
+ const rect = root.getBoundingClientRect();
67
+ const sx = window.scrollX, sy = window.scrollY;
68
+ const bounds = page
69
+ ? {
70
+ x: 0,
71
+ y: 0,
72
+ width: Math.max(innerWidth, document.documentElement.scrollWidth),
73
+ height: Math.max(innerHeight, document.documentElement.scrollHeight),
74
+ }
75
+ : {
76
+ x: rect.left + sx,
77
+ y: rect.top + sy,
78
+ width: rect.width,
79
+ height: rect.height,
80
+ };
81
+ const view = { x: sx, y: sy, width: innerWidth, height: innerHeight };
82
+ let targets = [];
83
+ try {
84
+ targets = [
85
+ ...root.querySelectorAll(input.surfaces ??
86
+ "[data-moon-surface], button, h1, h2, article, .card"),
87
+ ].slice(0, 180);
88
+ }
89
+ catch {
90
+ targets = [];
91
+ }
92
+ const surfaces = [];
93
+ for (const el of targets) {
94
+ if (el === canvas || el.contains(canvas))
95
+ continue;
96
+ const r = el.getBoundingClientRect();
97
+ if (r.width < 110 * opts.size ||
98
+ r.height < 2 ||
99
+ r.top + sy < bounds.y ||
100
+ r.top + sy > bounds.y + bounds.height)
101
+ continue;
102
+ const css = getComputedStyle(el);
103
+ if (css.display === "none" ||
104
+ css.visibility === "hidden" ||
105
+ css.position === "fixed")
106
+ continue;
107
+ if (!elements.has(el))
108
+ elements.set(el, "surface-" + ++ids);
109
+ surfaces.push({
110
+ id: elements.get(el),
111
+ x: r.left + sx + 5,
112
+ y: r.top + sy,
113
+ width: r.width - 10,
114
+ });
115
+ }
116
+ surfaces.push({
117
+ id: "floor",
118
+ x: bounds.x + 30,
119
+ y: bounds.y + bounds.height - 30,
120
+ width: Math.max(120, bounds.width - 60),
121
+ });
122
+ return { bounds, view, surfaces };
123
+ }
124
+ function refresh() {
125
+ if (destroyed || !browser)
126
+ return;
127
+ model.size = opts.size;
128
+ model.auto = opts.autoExplore;
129
+ model.rover = opts.rover;
130
+ model.maxFlags = opts.maxFlags;
131
+ model.refresh(measure());
132
+ draw();
133
+ }
134
+ function queueRefresh() {
135
+ if (!refreshFrame)
136
+ refreshFrame = requestAnimationFrame(() => {
137
+ refreshFrame = 0;
138
+ refresh();
139
+ });
140
+ }
141
+ function draw() {
142
+ if (!canvas || !context || !root)
143
+ return;
144
+ const dpr = Math.min(devicePixelRatio || 1, opts.maxDPR);
145
+ const w = innerWidth, h = innerHeight;
146
+ if (canvas.width !== Math.round(w * dpr) ||
147
+ canvas.height !== Math.round(h * dpr)) {
148
+ canvas.width = Math.round(w * dpr);
149
+ canvas.height = Math.round(h * dpr);
150
+ canvas.style.width = w + "px";
151
+ canvas.style.height = h + "px";
152
+ }
153
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
154
+ context.clearRect(0, 0, w, h);
155
+ context.save();
156
+ if (!rootIsPage()) {
157
+ const r = root.getBoundingClientRect();
158
+ context.beginPath();
159
+ context.rect(r.left, r.top, r.width, r.height);
160
+ context.clip();
161
+ }
162
+ context.translate(-scrollX, -scrollY);
163
+ paint(context, model, {
164
+ accent: opts.accent,
165
+ lander: opts.lander,
166
+ rover: opts.rover,
167
+ pointer,
168
+ reduced,
169
+ });
170
+ context.restore();
171
+ }
172
+ function tick(now) {
173
+ frame = 0;
174
+ if (destroyed || manualPause || hidden || reduced)
175
+ return;
176
+ const delta = last ? Math.min(0.06, (now - last) / 1000) : 0;
177
+ last = now;
178
+ model.step(delta * opts.pace);
179
+ if (opts.shootingStars && model.rng() < delta * 0.012)
180
+ model.shootingStar();
181
+ if (now - drawLast >= 1000 / opts.maxFPS) {
182
+ drawLast = now;
183
+ draw();
184
+ }
185
+ frame = requestAnimationFrame(tick);
186
+ }
187
+ function sync() {
188
+ if (!browser || destroyed)
189
+ return;
190
+ cancelAnimationFrame(frame);
191
+ frame = 0;
192
+ last = 0;
193
+ state = manualPause || hidden ? "paused" : "running";
194
+ if (reduced) {
195
+ model.staticPose();
196
+ draw();
197
+ }
198
+ else if (!manualPause && !hidden)
199
+ frame = requestAnimationFrame(tick);
200
+ }
201
+ function motionChange() {
202
+ reduced = opts.motion === "static" || Boolean(media?.matches);
203
+ sync();
204
+ }
205
+ function visibility() {
206
+ hidden = document.hidden;
207
+ sync();
208
+ }
209
+ function scroll() {
210
+ model.world.view = {
211
+ x: scrollX,
212
+ y: scrollY,
213
+ width: innerWidth,
214
+ height: innerHeight,
215
+ };
216
+ if (!rootIsPage()) {
217
+ const r = root.getBoundingClientRect();
218
+ const nextHidden = document.hidden || r.bottom < 0 || r.top > innerHeight;
219
+ if (nextHidden !== hidden) {
220
+ hidden = nextHidden;
221
+ sync();
222
+ }
223
+ }
224
+ draw();
225
+ }
226
+ function move(e) {
227
+ pointer.x = e.clientX + scrollX;
228
+ pointer.y = e.clientY + scrollY;
229
+ pointer.active = e.pointerType !== "touch";
230
+ }
231
+ function click(e) {
232
+ if (!opts.interactive || reduced || manualPause)
233
+ return;
234
+ if (Math.hypot(e.clientX + scrollX - model.x, e.clientY + scrollY - (model.y - 45 * opts.size)) <
235
+ 70 * opts.size)
236
+ model.enqueue("wave");
237
+ }
238
+ function leave() {
239
+ pointer.active = false;
240
+ }
241
+ function resolve(selector) {
242
+ if (!selector || !root)
243
+ return undefined;
244
+ try {
245
+ const el = root.matches(selector) ? root : root.querySelector(selector);
246
+ if (!el)
247
+ return undefined;
248
+ const r = el.getBoundingClientRect();
249
+ if (r.width < 100)
250
+ return undefined;
251
+ if (!elements.has(el))
252
+ elements.set(el, "surface-" + ++ids);
253
+ return {
254
+ id: elements.get(el),
255
+ x: r.left + scrollX + 5,
256
+ y: r.top + scrollY,
257
+ width: r.width - 10,
258
+ };
259
+ }
260
+ catch {
261
+ return undefined;
262
+ }
263
+ }
264
+ function mission(kind, selector) {
265
+ if (destroyed || !validMissions.includes(kind) || !browser)
266
+ return false;
267
+ const target = resolve(selector);
268
+ if (selector && !target)
269
+ return false;
270
+ if (reduced)
271
+ return false;
272
+ return model.enqueue(kind, target);
273
+ }
274
+ if (browser) {
275
+ root = input.root ?? document.body;
276
+ canvas = document.createElement("canvas");
277
+ canvas.dataset.moonExplorer = "";
278
+ canvas.setAttribute("aria-hidden", "true");
279
+ canvas.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${opts.zIndex};contain:strict;`;
280
+ document.body.append(canvas);
281
+ context = canvas.getContext("2d");
282
+ media = matchMedia("(prefers-reduced-motion: reduce)");
283
+ reduced = opts.motion === "static" || media.matches;
284
+ model.world = measure();
285
+ model.land();
286
+ if (reduced)
287
+ model.staticPose();
288
+ window.addEventListener("scroll", scroll, { passive: true });
289
+ window.addEventListener("resize", queueRefresh, { passive: true });
290
+ window.addEventListener("pointermove", move, { passive: true });
291
+ window.addEventListener("pointerdown", click, { passive: true });
292
+ window.addEventListener("pointerleave", leave, { passive: true });
293
+ document.addEventListener("visibilitychange", visibility);
294
+ media.addEventListener("change", motionChange);
295
+ if (typeof ResizeObserver !== "undefined") {
296
+ resize = new ResizeObserver(queueRefresh);
297
+ resize.observe(root);
298
+ }
299
+ if (typeof MutationObserver !== "undefined") {
300
+ mutation = new MutationObserver((records) => {
301
+ if (records.some((r) => [...r.addedNodes, ...r.removedNodes].some((n) => n !== canvas)))
302
+ queueRefresh();
303
+ });
304
+ mutation.observe(root, { childList: true, subtree: true });
305
+ }
306
+ draw();
307
+ sync();
308
+ }
309
+ return {
310
+ mission,
311
+ explore: (selector) => mission("explore", selector),
312
+ collect: () => mission("sample"),
313
+ plantFlag: () => mission("flag"),
314
+ ride: () => mission("rover"),
315
+ observe: () => mission("observe"),
316
+ jump: () => mission("jump"),
317
+ wave: () => mission("wave"),
318
+ rest: () => mission("rest"),
319
+ shootingStar() {
320
+ if (!destroyed && !reduced) {
321
+ model.shootingStar();
322
+ draw();
323
+ }
324
+ },
325
+ land(selector) {
326
+ if (destroyed || !browser || reduced)
327
+ return false;
328
+ const target = resolve(selector);
329
+ if (selector && !target)
330
+ return false;
331
+ model.land(target);
332
+ return true;
333
+ },
334
+ pause() {
335
+ if (destroyed)
336
+ return;
337
+ manualPause = true;
338
+ sync();
339
+ if (!browser)
340
+ state = "paused";
341
+ },
342
+ resume() {
343
+ if (destroyed)
344
+ return;
345
+ manualPause = false;
346
+ sync();
347
+ },
348
+ refresh,
349
+ update(options) {
350
+ if (destroyed)
351
+ return;
352
+ opts = {
353
+ ...opts,
354
+ size: finite(options.size, opts.size, 0.55, 1.8),
355
+ pace: finite(options.pace, opts.pace, 0.3, 2.5),
356
+ maxFPS: finite(options.maxFPS, opts.maxFPS, 12, 60),
357
+ maxDPR: finite(options.maxDPR, opts.maxDPR, 1, 2.5),
358
+ maxFlags: Math.round(finite(options.maxFlags, opts.maxFlags, 0, 12)),
359
+ autoExplore: options.autoExplore ?? opts.autoExplore,
360
+ interactive: options.interactive ?? opts.interactive,
361
+ rover: options.rover ?? opts.rover,
362
+ lander: options.lander ?? opts.lander,
363
+ shootingStars: options.shootingStars ?? opts.shootingStars,
364
+ motion: options.motion ?? opts.motion,
365
+ accent: typeof options.accent === "string" &&
366
+ /^#[0-9a-f]{3,8}$/i.test(options.accent)
367
+ ? options.accent
368
+ : opts.accent,
369
+ zIndex: Math.round(finite(options.zIndex, opts.zIndex, -2147483647, 2147483647)),
370
+ };
371
+ if (canvas)
372
+ canvas.style.zIndex = String(opts.zIndex);
373
+ refresh();
374
+ motionChange();
375
+ },
376
+ destroy() {
377
+ if (destroyed)
378
+ return;
379
+ destroyed = true;
380
+ state = "destroyed";
381
+ if (browser) {
382
+ cancelAnimationFrame(frame);
383
+ cancelAnimationFrame(refreshFrame);
384
+ resize?.disconnect();
385
+ mutation?.disconnect();
386
+ media?.removeEventListener("change", motionChange);
387
+ window.removeEventListener("scroll", scroll);
388
+ window.removeEventListener("resize", queueRefresh);
389
+ window.removeEventListener("pointermove", move);
390
+ window.removeEventListener("pointerdown", click);
391
+ window.removeEventListener("pointerleave", leave);
392
+ document.removeEventListener("visibilitychange", visibility);
393
+ canvas?.remove();
394
+ }
395
+ listeners.clear();
396
+ elements.clear();
397
+ },
398
+ getState() {
399
+ return Object.freeze({
400
+ state,
401
+ phase: model.phase,
402
+ mission: model.mission,
403
+ x: model.x,
404
+ y: model.y,
405
+ facing: model.facing,
406
+ surface: model.surface.id,
407
+ samples: model.samples,
408
+ flags: model.flags.length,
409
+ distance: Math.round(model.distance),
410
+ roverDistance: Math.round(model.roverDistance),
411
+ missionsCompleted: model.missionsCompleted,
412
+ elapsed: model.elapsed,
413
+ reducedMotion: reduced,
414
+ });
415
+ },
416
+ on(event, handler) {
417
+ if (destroyed)
418
+ return () => { };
419
+ let set = listeners.get(event);
420
+ if (!set) {
421
+ set = new Set();
422
+ listeners.set(event, set);
423
+ }
424
+ set.add(handler);
425
+ return () => set.delete(handler);
426
+ },
427
+ };
428
+ }
@@ -0,0 +1,100 @@
1
+ import type { ExplorerPhase, Mission, MoonWorld, LunarSurface, ExplorerEvent, ExplorerEventData } from "./types.js";
2
+ export declare const clamp: (n: number, a: number, b: number) => number;
3
+ export declare const lerp: (a: number, b: number, t: number) => number;
4
+ export declare const ease: (t: number) => number;
5
+ export declare function random(seed: number): () => number;
6
+ export interface Dust {
7
+ x: number;
8
+ y: number;
9
+ vx: number;
10
+ vy: number;
11
+ life: number;
12
+ ttl: number;
13
+ radius: number;
14
+ }
15
+ export interface Footprint {
16
+ x: number;
17
+ y: number;
18
+ facing: number;
19
+ side: number;
20
+ life: number;
21
+ }
22
+ export interface Flag {
23
+ x: number;
24
+ y: number;
25
+ phase: number;
26
+ surface: string;
27
+ }
28
+ export interface Rock {
29
+ x: number;
30
+ y: number;
31
+ size: number;
32
+ collected: boolean;
33
+ surface: string;
34
+ angle: number;
35
+ }
36
+ export interface ShootingStar {
37
+ x: number;
38
+ y: number;
39
+ vx: number;
40
+ vy: number;
41
+ life: number;
42
+ ttl: number;
43
+ }
44
+ export declare class Expedition {
45
+ world: MoonWorld;
46
+ size: number;
47
+ auto: boolean;
48
+ rover: boolean;
49
+ maxFlags: number;
50
+ phase: ExplorerPhase;
51
+ mission: Mission | null;
52
+ phaseTime: number;
53
+ elapsed: number;
54
+ x: number;
55
+ y: number;
56
+ startX: number;
57
+ startY: number;
58
+ targetX: number;
59
+ targetY: number;
60
+ facing: 1 | -1;
61
+ surface: LunarSurface;
62
+ distance: number;
63
+ roverDistance: number;
64
+ samples: number;
65
+ missionsCompleted: number;
66
+ dust: Dust[];
67
+ footprints: Footprint[];
68
+ flags: Flag[];
69
+ rocks: Rock[];
70
+ stars: ShootingStar[];
71
+ queue: {
72
+ kind: Mission;
73
+ target?: LunarSurface;
74
+ }[];
75
+ roverX: number;
76
+ roverY: number;
77
+ landerX: number;
78
+ landerY: number;
79
+ landingStartY: number;
80
+ sampled: boolean;
81
+ flagged: boolean;
82
+ lastStep: number;
83
+ nextAuto: number;
84
+ rng: () => number;
85
+ onEvent: (event: ExplorerEvent, data: ExplorerEventData) => void;
86
+ constructor(world: MoonWorld, size?: number, auto?: boolean, rover?: boolean, maxFlags?: number, seed?: number);
87
+ event(event: ExplorerEvent): void;
88
+ choose(target?: LunarSurface): LunarSurface;
89
+ setPhase(phase: ExplorerPhase): void;
90
+ land(target?: LunarSurface): void;
91
+ populateRocks(): void;
92
+ enqueue(kind: Mission, target?: LunarSurface): boolean;
93
+ startMission(kind: Mission, target?: LunarSurface): void;
94
+ finish(): void;
95
+ burst(x?: number, y?: number, amount?: number, power?: number): void;
96
+ shootingStar(): void;
97
+ staticPose(): void;
98
+ refresh(world: MoonWorld): void;
99
+ step(dt: number): void;
100
+ }