PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / game-inkfall.js

game-inkfall.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at assets/js/game-inkfall.js

1,417 lines 42.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 const TEXT_DOMAIN = "desktop-mode";
4 function i18n() {
5 return window.wp?.i18n;
6 }
7 function __(text, domain = TEXT_DOMAIN) {
8 return i18n()?.__(text, domain) ?? text;
9 }
10 function sprintf(format, ...args) {
11 const impl = i18n()?.sprintf;
12 if (impl) {
13 return impl(format, ...args);
14 }
15 let i = 0;
16 return format.replace(/%(?:(\d+)\$)?[sd]/g, (_match, pos) => {
17 const idx = pos ? Number.parseInt(pos, 10) - 1 : i++;
18 return String(args[idx] ?? "");
19 });
20 }
21 function desktopGlobal() {
22 return window.wp?.desktop ?? {};
23 }
24 const SOUND_STORAGE_KEY = "desktop-mode/inkfall-sound";
25 const MASTER_LEVEL = 0.16;
26 const PLUCK_LEVEL = 0.5;
27 const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
28 const BASE_FREQUENCY = 196;
29 function letterFrequency(ch) {
30 const letter = ch.toLowerCase();
31 if (letter.length !== 1 || letter < "a" || letter > "z") {
32 return 0;
33 }
34 const index = letter.charCodeAt(0) - 97;
35 const semitones = 12 * Math.floor(index / MAJOR_SCALE.length) + MAJOR_SCALE[index % MAJOR_SCALE.length];
36 return BASE_FREQUENCY * Math.pow(2, semitones / 12);
37 }
38 function readStoredEnabled() {
39 try {
40 return window.localStorage.getItem(SOUND_STORAGE_KEY) !== "0";
41 } catch {
42 return true;
43 }
44 }
45 function storeEnabled(enabled) {
46 try {
47 window.localStorage.setItem(SOUND_STORAGE_KEY, enabled ? "1" : "0");
48 } catch {
49 }
50 }
51 function createGameAudio() {
52 let ctx = null;
53 let master = null;
54 let enabled = readStoredEnabled();
55 let disposed = false;
56 const ensureContext = () => {
57 if (disposed) {
58 return null;
59 }
60 if (ctx) {
61 if ("suspended" === ctx.state) {
62 void ctx.resume().catch(() => void 0);
63 }
64 return ctx;
65 }
66 const Ctor = window.AudioContext ?? window.webkitAudioContext;
67 if (!Ctor) {
68 return null;
69 }
70 try {
71 ctx = new Ctor();
72 } catch {
73 return null;
74 }
75 master = ctx.createGain();
76 master.gain.value = MASTER_LEVEL;
77 master.connect(ctx.destination);
78 return ctx;
79 };
80 const pluck = (frequency, opts = {}) => {
81 if (!enabled || frequency <= 0) {
82 return;
83 }
84 const context = ensureContext();
85 if (!context || !master) {
86 return;
87 }
88 const { type = "sine", delay = 0, duration = 0.22, level: level2 = PLUCK_LEVEL } = opts;
89 const start = context.currentTime + delay;
90 const osc = context.createOscillator();
91 const gain = context.createGain();
92 osc.type = type;
93 osc.frequency.value = frequency;
94 gain.gain.setValueAtTime(1e-4, start);
95 gain.gain.exponentialRampToValueAtTime(level2, start + 8e-3);
96 gain.gain.exponentialRampToValueAtTime(1e-4, start + duration);
97 osc.connect(gain);
98 gain.connect(master);
99 osc.start(start);
100 osc.stop(start + duration + 0.05);
101 };
102 return {
103 letter(ch) {
104 pluck(letterFrequency(ch));
105 },
106 typo() {
107 pluck(98, { type: "triangle", duration: 0.15, level: 0.35 });
108 pluck(103, { type: "triangle", duration: 0.12, level: 0.2 });
109 },
110 wordBurst(lastLetter) {
111 const root = letterFrequency(lastLetter) || BASE_FREQUENCY;
112 pluck(root, { duration: 0.3 });
113 pluck(root * 1.25, { delay: 0.06, duration: 0.3 });
114 pluck(root * 1.5, { delay: 0.12, duration: 0.35 });
115 pluck(root * 2, { delay: 0.18, duration: 0.4, level: 0.4 });
116 },
117 miss() {
118 pluck(165, { type: "triangle", duration: 0.3, level: 0.4 });
119 pluck(123, { type: "triangle", delay: 0.12, duration: 0.4, level: 0.4 });
120 },
121 setEnabled(next) {
122 enabled = next;
123 storeEnabled(next);
124 },
125 isEnabled() {
126 return enabled;
127 },
128 dispose() {
129 disposed = true;
130 if (ctx) {
131 void ctx.close().catch(() => void 0);
132 ctx = null;
133 master = null;
134 }
135 }
136 };
137 }
138 const NONCE_HEADER = "X-WP-Nonce";
139 function injectRestNonce(input, init) {
140 const nonce = readRestNonce();
141 if (!nonce) {
142 return init;
143 }
144 const url = resolveUrl(input);
145 if (!url || !isSameOriginRestUrl(url)) {
146 return init;
147 }
148 const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
149 const headers = new Headers(baseHeaders ?? {});
150 if (headers.has(NONCE_HEADER)) {
151 return init;
152 }
153 headers.set(NONCE_HEADER, nonce);
154 return { ...init ?? {}, headers };
155 }
156 function readRestNonce() {
157 if (typeof window === "undefined") {
158 return void 0;
159 }
160 const cfg = window.desktopModeConfig;
161 const value = cfg?.restNonce;
162 return typeof value === "string" && value.length > 0 ? value : void 0;
163 }
164 function resolveUrl(input) {
165 try {
166 const base = typeof window !== "undefined" && window.location ? window.location.href : void 0;
167 if (typeof input === "string") {
168 return new URL(input, base);
169 }
170 if (input instanceof URL) {
171 return input;
172 }
173 if (typeof Request !== "undefined" && input instanceof Request) {
174 return new URL(input.url, base);
175 }
176 return null;
177 } catch {
178 return null;
179 }
180 }
181 function isSameOriginRestUrl(url) {
182 if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) {
183 return false;
184 }
185 if (url.pathname.includes("/wp-json/")) {
186 return true;
187 }
188 if (url.searchParams.has("rest_route")) {
189 return true;
190 }
191 return false;
192 }
193 function trackedFetch(input, init, opts = {}) {
194 const fn = window.wp?.desktop?.fetch;
195 if (typeof fn === "function") {
196 return fn(input, init, opts);
197 }
198 const finalInit = injectRestNonce(input, init);
199 return fetch(input, finalInit);
200 }
201 function parseDictionary(raw) {
202 const words = [];
203 for (const line of raw.split("\n")) {
204 const word = line.trim();
205 if ("" === word || word.startsWith("#")) {
206 continue;
207 }
208 words.push(word);
209 }
210 const bucketStart = /* @__PURE__ */ new Map();
211 const bucketEnd = /* @__PURE__ */ new Map();
212 for (let i = 0; i < words.length; i++) {
213 const len = words[i].length;
214 if (!bucketStart.has(len)) {
215 bucketStart.set(len, i);
216 }
217 bucketEnd.set(len, i + 1);
218 }
219 const sliceFor = (minLen, maxLen) => {
220 let start = -1;
221 let end = -1;
222 for (let len = minLen; len <= maxLen; len++) {
223 const s = bucketStart.get(len);
224 if (s === void 0) {
225 continue;
226 }
227 if (start === -1) {
228 start = s;
229 }
230 end = bucketEnd.get(len);
231 }
232 if (start === -1) {
233 return { start: 0, end: words.length };
234 }
235 return { start, end };
236 };
237 const drawOne = (minLen, maxLen, rng) => {
238 const { start, end } = sliceFor(minLen, maxLen);
239 const span = end - start;
240 if (span <= 0) {
241 return "";
242 }
243 const offset = Math.floor(span * Math.pow(rng(), 1.4));
244 return words[start + Math.min(offset, span - 1)];
245 };
246 return {
247 size: words.length,
248 pick: (minLen, maxLen, rng, avoidInitials) => {
249 let word = drawOne(minLen, maxLen, rng);
250 if (avoidInitials && avoidInitials.size > 0) {
251 for (let attempt = 0; attempt < 3 && word !== "" && avoidInitials.has(word[0]); attempt++) {
252 word = drawOne(minLen, maxLen, rng);
253 }
254 }
255 return word;
256 }
257 };
258 }
259 async function loadDictionary(url, opts = {}) {
260 const res = await trackedFetch(
261 url,
262 { signal: opts.signal, credentials: "same-origin" },
263 {
264 windowId: opts.windowId,
265 source: opts.source ?? "desktop-mode/games-dictionary"
266 }
267 );
268 if (!res.ok) {
269 throw new Error(
270 `[desktop-mode] Games dictionary failed to load (${res.status}).`
271 );
272 }
273 const dictionary = parseDictionary(await res.text());
274 if (dictionary.size === 0) {
275 throw new Error("[desktop-mode] Games dictionary is empty.");
276 }
277 return dictionary;
278 }
279 const MAX_RAMP_SECONDS = 300;
280 const STARTING_LIVES = 3;
281 const REFERENCE_HEIGHT = 600;
282 const DIFFICULTY_MODES = [
283 "easy",
284 "medium",
285 "hard"
286 ];
287 const PRESETS = {
288 // The original tuning — genuinely gentle for the first minute.
289 easy: {
290 spawn: [3200, 900],
291 speed: [40, 170],
292 concurrentStart: 1,
293 concurrentSteps: [
294 [20, 2],
295 [60, 3],
296 [120, 4],
297 [200, 5]
298 ],
299 bandStart: [3, 4],
300 bandSteps: [
301 [30, 3, 5],
302 [75, 3, 6],
303 [150, 4, 8],
304 [225, 5, 10],
305 [300, 6, 12]
306 ]
307 },
308 // Brisk from the first word; two words on screen almost
309 // immediately, six by the end.
310 medium: {
311 spawn: [2400, 700],
312 speed: [75, 230],
313 concurrentStart: 1,
314 concurrentSteps: [
315 [10, 2],
316 [40, 3],
317 [90, 4],
318 [150, 5],
319 [240, 6]
320 ],
321 bandStart: [3, 5],
322 bandSteps: [
323 [20, 4, 6],
324 [60, 4, 8],
325 [120, 5, 10],
326 [200, 6, 12],
327 [300, 7, 12]
328 ]
329 },
330 // Opens near easy's mid-game and keeps going: fast ink, long
331 // words, up to seven at once.
332 hard: {
333 spawn: [1700, 550],
334 speed: [110, 300],
335 concurrentStart: 2,
336 concurrentSteps: [
337 [10, 3],
338 [30, 4],
339 [70, 5],
340 [120, 6],
341 [200, 7]
342 ],
343 bandStart: [4, 6],
344 bandSteps: [
345 [15, 5, 8],
346 [45, 6, 10],
347 [90, 7, 12],
348 [150, 8, 12]
349 ]
350 }
351 };
352 function clampT(t) {
353 if (!Number.isFinite(t) || t < 0) {
354 return 0;
355 }
356 return Math.min(t, MAX_RAMP_SECONDS);
357 }
358 function preset(mode) {
359 return PRESETS[mode] ?? PRESETS.easy;
360 }
361 function spawnIntervalMs(t, mode = "easy") {
362 const clamped = clampT(t);
363 const [start, floor] = preset(mode).spawn;
364 return Math.round(
365 start - (start - floor) * clamped / MAX_RAMP_SECONDS
366 );
367 }
368 function fallSpeed(t, mode = "easy") {
369 const clamped = clampT(t);
370 const [start, cap] = preset(mode).speed;
371 return start + (cap - start) * clamped / MAX_RAMP_SECONDS;
372 }
373 function maxConcurrent(t, mode = "easy") {
374 const clamped = clampT(t);
375 const { concurrentStart, concurrentSteps } = preset(mode);
376 let value = concurrentStart;
377 for (const [threshold, stepValue] of concurrentSteps) {
378 if (clamped >= threshold) {
379 value = stepValue;
380 }
381 }
382 return value;
383 }
384 function lengthBand(t, mode = "easy") {
385 const clamped = clampT(t);
386 const { bandStart, bandSteps } = preset(mode);
387 let band = {
388 min: bandStart[0],
389 max: bandStart[1]
390 };
391 for (const [threshold, min, max] of bandSteps) {
392 if (clamped >= threshold) {
393 band = { min, max };
394 }
395 }
396 return band;
397 }
398 function level(t) {
399 return Math.min(15, Math.floor(clampT(t) / 20));
400 }
401 function difficultyAt(t, mode = "easy") {
402 const band = lengthBand(t, mode);
403 return {
404 spawnIntervalMs: spawnIntervalMs(t, mode),
405 fallSpeed: fallSpeed(t, mode),
406 maxConcurrent: maxConcurrent(t, mode),
407 minLength: band.min,
408 maxLength: band.max,
409 level: level(t)
410 };
411 }
412 const SCATTER_GRAVITY = 900;
413 const SCATTER_LIFETIME = 0.9;
414 function scatterVelocities(count, rng) {
415 const particles = [];
416 for (let i = 0; i < count; i++) {
417 const lateral = count > 1 ? i / (count - 1) * 2 - 1 : 0;
418 particles.push({
419 vx: lateral * (80 + rng() * 60),
420 vy: -(120 + rng() * 120),
421 spin: (rng() * 2 - 1) * 6
422 });
423 }
424 return particles;
425 }
426 function integrateStep(particle, dt) {
427 const vyNext = particle.vy + SCATTER_GRAVITY * dt;
428 return {
429 dx: particle.vx * dt,
430 // Trapezoidal-ish: average the old and new vertical velocity
431 // over the step so coarse frames don't over-accelerate.
432 dy: (particle.vy + vyNext) / 2 * dt,
433 dRotation: particle.spin * dt,
434 vyNext
435 };
436 }
437 function scatterAlpha(age) {
438 return Math.max(0, 1 - age / SCATTER_LIFETIME);
439 }
440 const INK_COLOR = 2832981;
441 const ACCENT_COLOR = 9323693;
442 const PAPER_COLOR = 16249832;
443 const RULE_COLOR = 12375270;
444 const MARGIN_COLOR = 15245729;
445 const WORD_FONT = 'Georgia, "Times New Roman", serif';
446 const WORD_FONT_SIZE = 26;
447 const RULE_SPACING = 32;
448 function paintPaper(graphics, width, height) {
449 graphics.clear();
450 graphics.rect(0, 0, width, height).fill({ color: PAPER_COLOR });
451 for (let y = RULE_SPACING; y < height; y += RULE_SPACING) {
452 graphics.moveTo(0, y).lineTo(width, y).stroke({ color: RULE_COLOR, width: 1, alpha: 0.55 });
453 }
454 const marginX = Math.min(64, Math.round(width * 0.08));
455 graphics.moveTo(marginX, 0).lineTo(marginX, height).stroke({ color: MARGIN_COLOR, width: 2, alpha: 0.7 });
456 graphics.moveTo(0, height - 6).lineTo(width, height - 6).stroke({ color: INK_COLOR, width: 2, alpha: 0.25 });
457 }
458 function buildWordSprite(pixi, text) {
459 const container = new pixi.Container();
460 const style = {
461 fill: INK_COLOR,
462 fontSize: WORD_FONT_SIZE,
463 fontFamily: WORD_FONT
464 };
465 const matched = new pixi.Text({ text: "", style: { ...style, fill: ACCENT_COLOR } });
466 const rest = new pixi.Text({ text, style });
467 container.addChild(matched, rest);
468 return { container, matched, rest, text, width: rest.width };
469 }
470 function setMatchedCount(sprite, count) {
471 const clamped = Math.max(0, Math.min(count, sprite.text.length));
472 sprite.matched.text = sprite.text.slice(0, clamped);
473 sprite.rest.text = sprite.text.slice(clamped);
474 sprite.rest.x = clamped > 0 ? sprite.matched.width : 0;
475 }
476 const NOTE_GLYPHS = ["", "", "", ""];
477 const NOTE_FLIGHT_SECONDS = 0.18;
478 const BLOT_LIFETIME = 1.1;
479 function createFxLayer(pixi, stage, rng = Math.random) {
480 const effects = [];
481 const remove = (effect) => {
482 const idx = effects.indexOf(effect);
483 if (idx >= 0) {
484 effects.splice(idx, 1);
485 }
486 if ("scatter" === effect.kind) {
487 for (const char of effect.chars) {
488 stage.removeChild(char.node);
489 char.node.destroy();
490 }
491 return;
492 }
493 stage.removeChild(effect.node);
494 effect.node.destroy();
495 };
496 return {
497 launchNote(fromX, fromY, toX, toY, onArrive) {
498 const glyph = NOTE_GLYPHS[Math.floor(rng() * NOTE_GLYPHS.length)] ?? NOTE_GLYPHS[0];
499 const node = new pixi.Text({
500 text: glyph,
501 style: {
502 fill: ACCENT_COLOR,
503 fontSize: 30,
504 fontFamily: WORD_FONT
505 }
506 });
507 node.anchor.set(0.5);
508 node.x = fromX;
509 node.y = fromY;
510 node.zIndex = 30;
511 stage.addChild(node);
512 effects.push({
513 kind: "note",
514 node,
515 fromX,
516 fromY,
517 toX,
518 toY,
519 age: 0,
520 onArrive
521 });
522 },
523 tearWord(sprite) {
524 const scratch = new pixi.Text({
525 text: "",
526 style: {
527 fill: INK_COLOR,
528 fontSize: WORD_FONT_SIZE,
529 fontFamily: WORD_FONT
530 }
531 });
532 const offsets = [];
533 for (let i = 0; i < sprite.text.length; i++) {
534 scratch.text = sprite.text.slice(0, i);
535 offsets.push(scratch.width);
536 }
537 scratch.destroy();
538 const particles = scatterVelocities(sprite.text.length, rng);
539 const chars = [];
540 for (let i = 0; i < sprite.text.length; i++) {
541 const node = new pixi.Text({
542 text: sprite.text[i],
543 style: {
544 fill: ACCENT_COLOR,
545 fontSize: WORD_FONT_SIZE,
546 fontFamily: WORD_FONT
547 }
548 });
549 node.anchor.set(0.5);
550 node.x = sprite.container.x + offsets[i] + 7;
551 node.y = sprite.container.y + WORD_FONT_SIZE / 2;
552 node.zIndex = 20;
553 stage.addChild(node);
554 chars.push({ node, particle: particles[i] });
555 }
556 stage.removeChild(sprite.container);
557 sprite.container.destroy({ children: true });
558 effects.push({ kind: "scatter", chars, age: 0 });
559 },
560 splashBlot(x, y) {
561 const blot = new pixi.Graphics();
562 blot.circle(0, 0, 9).fill({ color: INK_COLOR, alpha: 0.8 });
563 blot.ellipse(-12, 3, 4, 2.5).fill({ color: INK_COLOR, alpha: 0.6 });
564 blot.ellipse(11, -2, 3, 2).fill({ color: INK_COLOR, alpha: 0.6 });
565 blot.circle(6, 7, 2.5).fill({ color: INK_COLOR, alpha: 0.5 });
566 blot.x = x;
567 blot.y = y;
568 blot.zIndex = 10;
569 stage.addChild(blot);
570 effects.push({ kind: "blot", node: blot, age: 0 });
571 },
572 update(dt) {
573 for (const effect of effects.slice()) {
574 effect.age += dt;
575 if ("note" === effect.kind) {
576 const progress = Math.min(
577 1,
578 effect.age / NOTE_FLIGHT_SECONDS
579 );
580 const eased = 1 - (1 - progress) * (1 - progress);
581 effect.node.x = effect.fromX + (effect.toX - effect.fromX) * eased;
582 effect.node.y = effect.fromY + (effect.toY - effect.fromY) * progress * progress;
583 effect.node.rotation = progress * 0.6;
584 if (progress >= 1) {
585 const arrive = effect.onArrive;
586 remove(effect);
587 arrive();
588 }
589 continue;
590 }
591 if ("scatter" === effect.kind) {
592 for (const char of effect.chars) {
593 const step = integrateStep(char.particle, dt);
594 char.node.x += step.dx;
595 char.node.y += step.dy;
596 char.node.rotation += step.dRotation;
597 char.particle.vy = step.vyNext;
598 char.node.alpha = scatterAlpha(effect.age);
599 }
600 if (effect.age >= SCATTER_LIFETIME) {
601 remove(effect);
602 }
603 continue;
604 }
605 const fadeStart = BLOT_LIFETIME * 0.4;
606 if (effect.age <= fadeStart) {
607 effect.node.alpha = 1;
608 } else {
609 const fade = (effect.age - fadeStart) / (BLOT_LIFETIME - fadeStart);
610 effect.node.alpha = Math.max(0, 1 - fade);
611 }
612 if (effect.age >= BLOT_LIFETIME) {
613 remove(effect);
614 }
615 }
616 },
617 busy() {
618 return effects.length > 0;
619 },
620 clear() {
621 for (const effect of effects.slice()) {
622 remove(effect);
623 }
624 }
625 };
626 }
627 function createGameInput(host, handlers) {
628 const input = document.createElement("input");
629 input.type = "text";
630 input.autocomplete = "off";
631 input.autocapitalize = "off";
632 input.spellcheck = false;
633 input.setAttribute("aria-hidden", "true");
634 input.tabIndex = -1;
635 input.className = "inkfall__key-capture";
636 host.appendChild(input);
637 const onKeyDown = (e) => {
638 if (e.metaKey || e.ctrlKey || e.altKey) {
639 return;
640 }
641 if ("Backspace" === e.key) {
642 e.preventDefault();
643 handlers.onBackspace();
644 return;
645 }
646 if ("Escape" === e.key) {
647 e.preventDefault();
648 handlers.onEscape();
649 return;
650 }
651 if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
652 e.preventDefault();
653 handlers.onLetter(e.key.toLowerCase());
654 }
655 };
656 const onInput = () => {
657 input.value = "";
658 };
659 input.addEventListener("keydown", onKeyDown);
660 input.addEventListener("input", onInput);
661 const onPointerDown = () => {
662 window.setTimeout(() => input.focus(), 0);
663 };
664 host.addEventListener("pointerdown", onPointerDown);
665 return {
666 focus: () => input.focus(),
667 dispose: () => {
668 input.removeEventListener("keydown", onKeyDown);
669 input.removeEventListener("input", onInput);
670 host.removeEventListener("pointerdown", onPointerDown);
671 input.remove();
672 }
673 };
674 }
675 function createMatcher() {
676 let targetId = null;
677 let matchedCount = 0;
678 const reset = () => {
679 targetId = null;
680 matchedCount = 0;
681 };
682 return {
683 handleKey(ch, live) {
684 const letter = ch.toLowerCase();
685 if (letter.length !== 1 || !/[a-z]/.test(letter)) {
686 return { kind: "ignored" };
687 }
688 if (targetId === null) {
689 let candidate = null;
690 for (const word of live) {
691 if (word.text[0] !== letter) {
692 continue;
693 }
694 if (!candidate || word.y > candidate.y) {
695 candidate = word;
696 }
697 }
698 if (!candidate) {
699 return { kind: "ignored" };
700 }
701 targetId = candidate.id;
702 matchedCount = 1;
703 if (candidate.text.length === 1) {
704 const completedId = targetId;
705 reset();
706 return { kind: "completed", targetId: completedId };
707 }
708 return { kind: "locked", targetId, matchedCount };
709 }
710 const target = live.find((word) => word.id === targetId);
711 if (!target) {
712 reset();
713 return this.handleKey(letter, live);
714 }
715 if (target.text[matchedCount] !== letter) {
716 return { kind: "typo", targetId: target.id };
717 }
718 matchedCount++;
719 if (matchedCount >= target.text.length) {
720 const completedId = target.id;
721 reset();
722 return { kind: "completed", targetId: completedId };
723 }
724 return { kind: "advanced", targetId: target.id, matchedCount };
725 },
726 handleBackspace() {
727 if (targetId === null) {
728 return;
729 }
730 matchedCount = Math.max(1, matchedCount - 1);
731 },
732 release: reset,
733 forget(wordId) {
734 if (targetId === wordId) {
735 reset();
736 }
737 },
738 state() {
739 return { targetId, matchedCount };
740 }
741 };
742 }
743 function createScoreState() {
744 return {
745 score: 0,
746 wordsCompleted: 0,
747 streak: 0,
748 correctKeys: 0,
749 totalKeys: 0,
750 typoInCurrentWord: false
751 };
752 }
753 function streakMultiplier(streak) {
754 return 1 + 0.1 * Math.min(Math.max(0, streak), 10);
755 }
756 function wordPoints(length, heightFraction, streak) {
757 const height = Math.min(1, Math.max(0, heightFraction));
758 return Math.round(
759 10 * length * (1 + 0.5 * height) * streakMultiplier(streak)
760 );
761 }
762 function recordCorrectKey(state) {
763 state.correctKeys++;
764 state.totalKeys++;
765 }
766 function recordTypo(state) {
767 state.totalKeys++;
768 state.typoInCurrentWord = true;
769 state.streak = 0;
770 }
771 function recordCompletion(state, length, heightFraction) {
772 const points = wordPoints(length, heightFraction, state.streak);
773 state.score += points;
774 state.wordsCompleted++;
775 if (state.typoInCurrentWord) {
776 state.streak = 0;
777 } else {
778 state.streak++;
779 }
780 state.typoInCurrentWord = false;
781 return points;
782 }
783 function recordMiss(state) {
784 state.streak = 0;
785 state.typoInCurrentWord = false;
786 }
787 function accuracyPercent(state) {
788 if (state.totalKeys === 0) {
789 return 100;
790 }
791 return Math.round(state.correctKeys / state.totalKeys * 100);
792 }
793 function wordsPerMinute(state, elapsedSeconds) {
794 if (elapsedSeconds <= 0) {
795 return 0;
796 }
797 return Math.round(state.correctKeys / 5 * (60 / elapsedSeconds));
798 }
799 function buildScoreRow(state, elapsedSeconds, level2, mode) {
800 const meta = {
801 words: state.wordsCompleted,
802 wpm: wordsPerMinute(state, elapsedSeconds),
803 accuracy: accuracyPercent(state),
804 time: Math.round(elapsedSeconds),
805 level: level2
806 };
807 if (mode) {
808 meta.mode = mode;
809 }
810 return { score: state.score, meta };
811 }
812 function getPixi() {
813 const pixi = window.PIXI;
814 return pixi ?? null;
815 }
816 const MODE_STORAGE_KEY = "desktop-mode/inkfall-mode";
817 function modeLabel(mode) {
818 switch (mode) {
819 case "medium":
820 return __("Medium");
821 case "hard":
822 return __("Hard");
823 default:
824 return __("Easy");
825 }
826 }
827 function modeHint(mode) {
828 switch (mode) {
829 case "medium":
830 return __("Brisk from the first word.");
831 case "hard":
832 return __("Fast ink, long words. Good luck.");
833 default:
834 return __("A gentle warm-up that builds.");
835 }
836 }
837 function readStoredMode() {
838 try {
839 const stored = window.localStorage.getItem(MODE_STORAGE_KEY);
840 if (stored && DIFFICULTY_MODES.includes(stored)) {
841 return stored;
842 }
843 } catch {
844 }
845 return "easy";
846 }
847 function storeMode(mode) {
848 try {
849 window.localStorage.setItem(MODE_STORAGE_KEY, mode);
850 } catch {
851 }
852 }
853 const MAX_FRAME_SECONDS = 0.05;
854 const EMPTY_FIELD_SPAWN_GAP_MS = 250;
855 function mountInkfall(ctx) {
856 const root = document.createElement("div");
857 root.className = "inkfall";
858 ctx.container.appendChild(root);
859 const audio = createGameAudio();
860 const hud = document.createElement("div");
861 hud.className = "inkfall__hud";
862 const scoreEl = document.createElement("span");
863 scoreEl.className = "inkfall__hud-score";
864 const streakEl = document.createElement("span");
865 streakEl.className = "inkfall__hud-streak";
866 const livesEl = document.createElement("span");
867 livesEl.className = "inkfall__hud-lives";
868 const levelEl = document.createElement("span");
869 levelEl.className = "inkfall__hud-level";
870 const soundToggle = document.createElement("button");
871 soundToggle.type = "button";
872 soundToggle.className = "inkfall__hud-sound";
873 const paintSoundToggle = () => {
874 soundToggle.textContent = audio.isEnabled() ? "🔊" : "🔇";
875 soundToggle.setAttribute(
876 "aria-label",
877 audio.isEnabled() ? __("Mute sound effects") : __("Unmute sound effects")
878 );
879 soundToggle.setAttribute(
880 "aria-pressed",
881 audio.isEnabled() ? "false" : "true"
882 );
883 };
884 paintSoundToggle();
885 soundToggle.addEventListener("click", () => {
886 audio.setEnabled(!audio.isEnabled());
887 paintSoundToggle();
888 });
889 hud.append(scoreEl, streakEl, livesEl, levelEl);
890 if (ctx.challenge) {
891 const ribbon = document.createElement("span");
892 ribbon.className = "inkfall__hud-ribbon";
893 ribbon.textContent = sprintf(
894 /* translators: 1: challenger display name, 2: score to beat. */
895 __("Beat %1$s: %2$s"),
896 ctx.challenge.challengerName,
897 String(ctx.challenge.scoreToBeat)
898 );
899 hud.appendChild(ribbon);
900 }
901 hud.appendChild(soundToggle);
902 root.appendChild(hud);
903 const stageEl = document.createElement("div");
904 stageEl.className = "inkfall__stage";
905 root.appendChild(stageEl);
906 const overlay = document.createElement("div");
907 overlay.className = "inkfall__overlay";
908 overlay.hidden = true;
909 root.appendChild(overlay);
910 const showMessage = (text) => {
911 overlay.hidden = false;
912 overlay.innerHTML = "";
913 const p = document.createElement("p");
914 p.className = "inkfall__overlay-message";
915 p.textContent = text;
916 overlay.appendChild(p);
917 };
918 showMessage(__("Loading the notebook…"));
919 let disposed = false;
920 let state = "loading";
921 let app = null;
922 let pixi = null;
923 let fx = null;
924 let paper = null;
925 let dictionary = null;
926 let input = null;
927 let resizeObserver = null;
928 let unsubscribeWindow = null;
929 let tickFn = null;
930 const matcher = createMatcher();
931 let scores = createScoreState();
932 let live = [];
933 let lives = STARTING_LIVES;
934 let clockSeconds = 0;
935 let spawnTimerMs = 0;
936 let lastSpawnAtMs = 0;
937 let elapsedTotalMs = 0;
938 let nextWordId = 1;
939 let mode = readStoredMode();
940 const paintHud = () => {
941 scoreEl.textContent = sprintf(
942 /* translators: %s: current score. */
943 __("Score %s"),
944 String(scores.score)
945 );
946 streakEl.textContent = scores.streak > 1 ? `×${scores.streak}` : "";
947 livesEl.textContent = "".repeat(lives) + "".repeat(
948 Math.max(0, STARTING_LIVES - lives)
949 );
950 levelEl.textContent = sprintf(
951 /* translators: 1: current level number, 2: difficulty label. */
952 __("Level %1$s · %2$s"),
953 String(level(clockSeconds)),
954 modeLabel(mode)
955 );
956 };
957 const fieldWidth = () => app?.renderer.width ?? 600;
958 const fieldHeight = () => app?.renderer.height ?? REFERENCE_HEIGHT;
959 const bottomY = () => fieldHeight() - 10;
960 const matchable = () => live.map((word) => ({
961 id: word.id,
962 text: word.text,
963 y: word.sprite.container.y
964 }));
965 const removeWord = (word, keepSprite) => {
966 live = live.filter((entry) => entry.id !== word.id);
967 matcher.forget(word.id);
968 if (!keepSprite && app) {
969 app.stage.removeChild(word.sprite.container);
970 word.sprite.container.destroy({ children: true });
971 }
972 };
973 const spawnWord = () => {
974 if (!app || !pixi || !dictionary) {
975 return;
976 }
977 const snapshot = difficultyAt(clockSeconds, mode);
978 const initials = new Set(live.map((word) => word.text[0]));
979 const text = dictionary.pick(
980 snapshot.minLength,
981 snapshot.maxLength,
982 Math.random,
983 initials
984 );
985 if ("" === text) {
986 return;
987 }
988 const sprite = buildWordSprite(pixi, text);
989 const margin = Math.min(64, Math.round(fieldWidth() * 0.08));
990 const maxX = Math.max(
991 margin + 8,
992 fieldWidth() - sprite.width - 16
993 );
994 sprite.container.x = margin + 8 + Math.random() * Math.max(1, maxX - margin - 8);
995 sprite.container.y = -WORD_FONT_SIZE - 4;
996 app.stage.addChild(sprite.container);
997 live.push({
998 id: nextWordId++,
999 text,
1000 sprite,
1001 jitter: 0.9 + Math.random() * 0.2
1002 });
1003 lastSpawnAtMs = elapsedTotalMs;
1004 };
1005 const applyHighlight = () => {
1006 const lock = matcher.state();
1007 for (const word of live) {
1008 setMatchedCount(
1009 word.sprite,
1010 word.id === lock.targetId ? lock.matchedCount : 0
1011 );
1012 }
1013 };
1014 const completeWord = (wordId) => {
1015 const word = live.find((entry) => entry.id === wordId);
1016 if (!word || !fx) {
1017 return;
1018 }
1019 const height = fieldHeight();
1020 const heightFraction = (bottomY() - word.sprite.container.y) / Math.max(1, height);
1021 recordCompletion(scores, word.text.length, heightFraction);
1022 const sprite = word.sprite;
1023 removeWord(word, true);
1024 setMatchedCount(sprite, sprite.text.length);
1025 const lastLetter = sprite.text[sprite.text.length - 1];
1026 const targetX = sprite.container.x + sprite.width / 2;
1027 const targetY = sprite.container.y + WORD_FONT_SIZE / 2;
1028 fx.launchNote(
1029 fieldWidth() / 2,
1030 fieldHeight() - 12,
1031 targetX,
1032 targetY,
1033 () => {
1034 fx?.tearWord(sprite);
1035 audio.wordBurst(lastLetter);
1036 }
1037 );
1038 paintHud();
1039 };
1040 const gameOver = () => {
1041 state = "over";
1042 matcher.release();
1043 applyHighlight();
1044 const elapsed = Math.min(clockSeconds, MAX_RAMP_SECONDS);
1045 const row = buildScoreRow(scores, elapsed, level(clockSeconds), mode);
1046 overlay.hidden = false;
1047 overlay.innerHTML = "";
1048 const panel = document.createElement("div");
1049 panel.className = "inkfall__over-panel";
1050 const heading = document.createElement("p");
1051 heading.className = "inkfall__over-heading";
1052 if (ctx.challenge) {
1053 heading.textContent = row.score > ctx.challenge.scoreToBeat ? __("Game Over — challenge beaten!") : __("Game Over — challenge missed.");
1054 } else {
1055 heading.textContent = __("Game Over");
1056 }
1057 panel.appendChild(heading);
1058 const stats = document.createElement("p");
1059 stats.className = "inkfall__over-stats";
1060 stats.textContent = sprintf(
1061 /* translators: 1: score, 2: words typed, 3: words per minute, 4: accuracy percent. */
1062 __("Score %1$s — %2$s words, %3$s WPM, %4$s%% accuracy."),
1063 String(row.score),
1064 String(scores.wordsCompleted),
1065 String(wordsPerMinute(scores, Math.max(1, elapsed))),
1066 String(accuracyPercent(scores))
1067 );
1068 panel.appendChild(stats);
1069 const saveNote = document.createElement("p");
1070 saveNote.className = "inkfall__over-save";
1071 saveNote.textContent = __("Saving your score…");
1072 panel.appendChild(saveNote);
1073 ctx.submitScore(row).then(
1074 () => {
1075 saveNote.textContent = __("Score saved to the scoreboard.");
1076 },
1077 () => {
1078 saveNote.textContent = __("Your score could not be saved.");
1079 }
1080 );
1081 const actions = document.createElement("div");
1082 actions.className = "inkfall__over-actions";
1083 const again = document.createElement("button");
1084 again.type = "button";
1085 again.className = "inkfall__button inkfall__button--primary";
1086 again.textContent = __("Play again");
1087 again.addEventListener("click", () => {
1088 startRun(mode);
1089 });
1090 actions.appendChild(again);
1091 const changeMode = document.createElement("button");
1092 changeMode.type = "button";
1093 changeMode.className = "inkfall__button";
1094 changeMode.textContent = __("Change difficulty");
1095 changeMode.addEventListener("click", () => {
1096 showMenu();
1097 });
1098 actions.appendChild(changeMode);
1099 const quit = document.createElement("button");
1100 quit.type = "button";
1101 quit.className = "inkfall__button";
1102 quit.textContent = __("Close");
1103 quit.addEventListener("click", () => ctx.close());
1104 actions.appendChild(quit);
1105 panel.appendChild(actions);
1106 overlay.appendChild(panel);
1107 };
1108 const clearField = () => {
1109 for (const word of live.slice()) {
1110 removeWord(word, false);
1111 }
1112 fx?.clear();
1113 scores = createScoreState();
1114 lives = STARTING_LIVES;
1115 clockSeconds = 0;
1116 spawnTimerMs = 0;
1117 matcher.release();
1118 };
1119 const startRun = (picked) => {
1120 mode = picked;
1121 storeMode(picked);
1122 clearField();
1123 overlay.hidden = true;
1124 overlay.innerHTML = "";
1125 state = "playing";
1126 app?.ticker.start();
1127 paintHud();
1128 input?.focus();
1129 };
1130 const showMenu = () => {
1131 clearField();
1132 state = "menu";
1133 paintHud();
1134 overlay.hidden = false;
1135 overlay.innerHTML = "";
1136 const panel = document.createElement("div");
1137 panel.className = "inkfall__over-panel inkfall__menu";
1138 const heading = document.createElement("p");
1139 heading.className = "inkfall__over-heading";
1140 heading.textContent = __("Choose your pace");
1141 panel.appendChild(heading);
1142 if (ctx.challenge) {
1143 const note = document.createElement("p");
1144 note.className = "inkfall__over-stats";
1145 note.textContent = sprintf(
1146 /* translators: 1: challenger display name, 2: score to beat. */
1147 __("Challenge from %1$s — beat %2$s."),
1148 ctx.challenge.challengerName,
1149 String(ctx.challenge.scoreToBeat)
1150 );
1151 panel.appendChild(note);
1152 }
1153 const options = document.createElement("div");
1154 options.className = "inkfall__menu-options";
1155 for (const option of DIFFICULTY_MODES) {
1156 const button = document.createElement("button");
1157 button.type = "button";
1158 button.className = "inkfall__menu-option";
1159 if (option === mode) {
1160 button.classList.add("inkfall__menu-option--current");
1161 }
1162 const label = document.createElement("span");
1163 label.className = "inkfall__menu-option-label";
1164 label.textContent = modeLabel(option);
1165 button.appendChild(label);
1166 const hint = document.createElement("span");
1167 hint.className = "inkfall__menu-option-hint";
1168 hint.textContent = modeHint(option);
1169 button.appendChild(hint);
1170 button.addEventListener("click", (e) => {
1171 e.stopPropagation();
1172 startRun(option);
1173 });
1174 options.appendChild(button);
1175 }
1176 panel.appendChild(options);
1177 overlay.appendChild(panel);
1178 };
1179 const pause = () => {
1180 if ("playing" !== state) {
1181 return;
1182 }
1183 state = "paused";
1184 showMessage(__("Paused — click to resume."));
1185 app?.ticker.stop();
1186 };
1187 const resume = () => {
1188 if ("paused" !== state) {
1189 return;
1190 }
1191 state = "playing";
1192 overlay.hidden = true;
1193 app?.ticker.start();
1194 input?.focus();
1195 };
1196 overlay.addEventListener("click", () => {
1197 if ("paused" === state) {
1198 resume();
1199 }
1200 });
1201 const tick = () => {
1202 if (!app || !fx) {
1203 return;
1204 }
1205 const dt = Math.min(MAX_FRAME_SECONDS, app.ticker.deltaMS / 1e3);
1206 elapsedTotalMs += app.ticker.deltaMS;
1207 fx.update(dt);
1208 if ("playing" !== state) {
1209 return;
1210 }
1211 clockSeconds += dt;
1212 const snapshot = difficultyAt(clockSeconds, mode);
1213 const speedScale = fieldHeight() / REFERENCE_HEIGHT;
1214 spawnTimerMs += dt * 1e3;
1215 const canSpawn = live.length < snapshot.maxConcurrent;
1216 if (canSpawn && spawnTimerMs >= snapshot.spawnIntervalMs) {
1217 spawnTimerMs = 0;
1218 spawnWord();
1219 } else if (live.length === 0 && elapsedTotalMs - lastSpawnAtMs > EMPTY_FIELD_SPAWN_GAP_MS) {
1220 spawnTimerMs = 0;
1221 spawnWord();
1222 }
1223 const floor = bottomY();
1224 for (const word of live.slice()) {
1225 word.sprite.container.y += snapshot.fallSpeed * speedScale * word.jitter * dt;
1226 if (word.sprite.container.y + WORD_FONT_SIZE >= floor) {
1227 const centerX = word.sprite.container.x + word.sprite.width / 2;
1228 removeWord(word, false);
1229 fx.splashBlot(centerX, floor);
1230 audio.miss();
1231 recordMiss(scores);
1232 lives--;
1233 applyHighlight();
1234 paintHud();
1235 if (lives <= 0) {
1236 gameOver();
1237 return;
1238 }
1239 }
1240 }
1241 };
1242 const onLetter = (letter) => {
1243 if ("playing" !== state) {
1244 return;
1245 }
1246 const result = matcher.handleKey(letter, matchable());
1247 switch (result.kind) {
1248 case "locked":
1249 case "advanced":
1250 recordCorrectKey(scores);
1251 audio.letter(letter);
1252 applyHighlight();
1253 break;
1254 case "completed":
1255 recordCorrectKey(scores);
1256 audio.letter(letter);
1257 completeWord(result.targetId);
1258 applyHighlight();
1259 break;
1260 case "typo": {
1261 recordTypo(scores);
1262 audio.typo();
1263 const word = live.find(
1264 (entry) => entry.id === result.targetId
1265 );
1266 if (word) {
1267 word.sprite.container.alpha = 0.4;
1268 window.setTimeout(() => {
1269 word.sprite.container.alpha = 1;
1270 }, 120);
1271 }
1272 paintHud();
1273 break;
1274 }
1275 }
1276 };
1277 const boot = async () => {
1278 const desktop = desktopGlobal();
1279 if (typeof desktop.loadModules !== "function") {
1280 throw new Error("[desktop-mode] wp.desktop.loadModules missing.");
1281 }
1282 const wordsUrl = String(ctx.config.wordsUrl || "");
1283 if ("" === wordsUrl) {
1284 throw new Error("[desktop-mode] Inkfall config lacks wordsUrl.");
1285 }
1286 const [, loadedDictionary] = await Promise.all([
1287 desktop.loadModules(["pixijs"]),
1288 loadDictionary(wordsUrl, {
1289 windowId: ctx.windowId,
1290 source: "desktop-mode/inkfall"
1291 })
1292 ]);
1293 if (disposed) {
1294 return;
1295 }
1296 dictionary = loadedDictionary;
1297 pixi = getPixi();
1298 if (!pixi) {
1299 throw new Error("[desktop-mode] PixiJS failed to load.");
1300 }
1301 const instance = new pixi.Application();
1302 await instance.init({
1303 resizeTo: stageEl,
1304 backgroundAlpha: 0,
1305 antialias: true,
1306 autoDensity: true,
1307 resolution: Math.min(window.devicePixelRatio || 1, 2),
1308 // Own ticker — sharing `Ticker.shared` across bundles
1309 // crashes `Batcher.break()` (see content-graph/scene.ts).
1310 sharedTicker: false
1311 });
1312 if (disposed) {
1313 instance.destroy({ removeView: true }, { children: true, texture: true });
1314 return;
1315 }
1316 app = instance;
1317 app.canvas.className = "inkfall__canvas";
1318 stageEl.appendChild(app.canvas);
1319 app.stage.sortableChildren = true;
1320 paper = new pixi.Graphics();
1321 paper.zIndex = 0;
1322 app.stage.addChild(paper);
1323 paintPaper(paper, fieldWidth(), fieldHeight());
1324 fx = createFxLayer(pixi, app.stage);
1325 resizeObserver = new ResizeObserver(() => {
1326 if (!app || !paper) {
1327 return;
1328 }
1329 app.resize();
1330 paintPaper(paper, fieldWidth(), fieldHeight());
1331 const margin = Math.min(64, Math.round(fieldWidth() * 0.08));
1332 for (const word of live) {
1333 const maxX = fieldWidth() - word.sprite.width - 16;
1334 if (word.sprite.container.x > maxX) {
1335 word.sprite.container.x = Math.max(margin + 8, maxX);
1336 }
1337 }
1338 });
1339 resizeObserver.observe(stageEl);
1340 input = createGameInput(root, {
1341 onLetter,
1342 onBackspace: () => {
1343 matcher.handleBackspace();
1344 applyHighlight();
1345 },
1346 onEscape: () => {
1347 matcher.release();
1348 applyHighlight();
1349 }
1350 });
1351 unsubscribeWindow = desktopGlobal().onWindow?.(ctx.windowId, {
1352 blurred: pause,
1353 focused: () => input?.focus()
1354 }) ?? null;
1355 tickFn = tick;
1356 app.ticker.add(tickFn);
1357 paintHud();
1358 showMenu();
1359 };
1360 void boot().catch((err) => {
1361 if (disposed) {
1362 return;
1363 }
1364 showMessage(
1365 err instanceof Error ? err.message : __("Inkfall could not start.")
1366 );
1367 if (typeof console !== "undefined") {
1368 console.error("[desktop-mode] Inkfall boot failed:", err);
1369 }
1370 });
1371 return () => {
1372 if (disposed) {
1373 return;
1374 }
1375 disposed = true;
1376 audio.dispose();
1377 unsubscribeWindow?.();
1378 resizeObserver?.disconnect();
1379 input?.dispose();
1380 if (app) {
1381 if (tickFn) {
1382 app.ticker.remove(tickFn);
1383 }
1384 app.ticker.stop();
1385 fx?.clear();
1386 app.destroy({ removeView: true }, { children: true, texture: true });
1387 app = null;
1388 }
1389 root.remove();
1390 };
1391 }
1392 const def = {
1393 id: "inkfall",
1394 title: __("Inkfall"),
1395 icon: "dashicons-edit",
1396 scoreColumns: [
1397 { key: "score", label: __("Score"), type: "number" },
1398 { key: "mode", label: __("Difficulty"), type: "text" },
1399 { key: "words", label: __("Words"), type: "number" },
1400 { key: "wpm", label: __("WPM"), type: "number" },
1401 { key: "accuracy", label: __("Accuracy"), type: "number" },
1402 { key: "time", label: __("Time"), type: "time" },
1403 { key: "level", label: __("Level"), type: "number" }
1404 ],
1405 window: {
1406 width: 820,
1407 height: 620,
1408 minWidth: 520,
1409 minHeight: 420
1410 },
1411 render: (ctx) => mountInkfall(ctx)
1412 };
1413 const globals = window;
1414 globals.desktopModeGames = globals.desktopModeGames || {};
1415 globals.desktopModeGames[def.id] = def;
1416 })();
1417