@peekling/runtime 0.1.0 → 0.1.2

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.
Files changed (52) hide show
  1. package/README.md +59 -8
  2. package/dist/canvas-renderer.d.ts +17 -0
  3. package/dist/canvas-renderer.d.ts.map +1 -0
  4. package/dist/canvas-renderer.js +192 -0
  5. package/dist/canvas.d.ts +5 -0
  6. package/dist/canvas.d.ts.map +1 -0
  7. package/dist/canvas.js +6 -0
  8. package/dist/configuration-validation.d.ts.map +1 -1
  9. package/dist/configuration-validation.js +176 -1
  10. package/dist/content.d.ts +3 -0
  11. package/dist/content.d.ts.map +1 -1
  12. package/dist/content.js +33 -7
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/input.d.ts.map +1 -1
  16. package/dist/input.js +4 -1
  17. package/dist/interaction.d.ts +37 -0
  18. package/dist/interaction.d.ts.map +1 -0
  19. package/dist/interaction.js +204 -0
  20. package/dist/overrides.d.ts +8 -0
  21. package/dist/overrides.d.ts.map +1 -1
  22. package/dist/overrides.js +18 -16
  23. package/dist/path-sampler.d.ts +9 -0
  24. package/dist/path-sampler.d.ts.map +1 -0
  25. package/dist/path-sampler.js +47 -0
  26. package/dist/peekling.css +1 -1
  27. package/dist/peekling.css.sri +1 -1
  28. package/dist/peekling.js +1365 -86
  29. package/dist/peekling.js.sri +1 -1
  30. package/dist/peekling.min.js +1 -1
  31. package/dist/peekling.min.js.sri +1 -1
  32. package/dist/plan-compiler.d.ts +2 -0
  33. package/dist/plan-compiler.d.ts.map +1 -1
  34. package/dist/plan-compiler.js +71 -14
  35. package/dist/plan.d.ts +27 -1
  36. package/dist/plan.d.ts.map +1 -1
  37. package/dist/plan.js +281 -22
  38. package/dist/registry.js +2 -2
  39. package/dist/renderer.d.ts +18 -1
  40. package/dist/renderer.d.ts.map +1 -1
  41. package/dist/renderer.js +1 -0
  42. package/dist/runtime-validation.d.ts.map +1 -1
  43. package/dist/runtime-validation.js +35 -0
  44. package/dist/runtime.d.ts +49 -3
  45. package/dist/runtime.d.ts.map +1 -1
  46. package/dist/runtime.js +365 -14
  47. package/dist/targets.d.ts +11 -0
  48. package/dist/targets.d.ts.map +1 -0
  49. package/dist/targets.js +106 -0
  50. package/dist/types.d.ts +61 -1
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +5 -1
package/dist/runtime.js CHANGED
@@ -1,21 +1,24 @@
1
1
  import { ContentRenderer } from "./content.js";
2
2
  import { InputCollector } from "./input.js";
3
+ import { CharacterInteractionController, } from "./interaction.js";
3
4
  import { browserCompletionEvent, validateEventPayload } from "./events.js";
4
5
  import { directionTo } from "./direction.js";
5
6
  import { DiagnosticChannel } from "./diagnostics.js";
6
- import { EVENT_NAME_PATTERN } from "./contracts.js";
7
+ import { EVENT_NAME_PATTERN, isSurfaceColor } from "./contracts.js";
7
8
  import { DEFAULT_MAX_DENSITY, DEFAULT_POSITION } from "./defaults.js";
8
9
  import { animationCycleMs, locomotionAdvance, locomotionSample, } from "./locomotion.js";
9
10
  import { loadNativePack, requiredAtlasDensity, } from "./loader.js";
10
11
  import { resolveCapabilityName } from "./normalized.js";
11
- import { PlanRuntime, compiledPlanSectionRequirements, createDefaultPlan, } from "./plan.js";
12
+ import { PlanRuntime, compiledPlanSectionRequirements, createPresetPlan, } from "./plan.js";
12
13
  import { compilePlan } from "./plan-compiler.js";
13
14
  import { validateRuntimeConfiguration, validRuntimeName, } from "./runtime-validation.js";
14
15
  import { runtimeMessage } from "./runtime-diagnostics.js";
15
16
  import { OverrideManager, rejectedOverrideHandle } from "./overrides.js";
16
- import { AtlasRenderer } from "./renderer.js";
17
+ import { createAtlasRenderer, } from "./renderer.js";
17
18
  import { resolveState } from "./resolver.js";
18
19
  import { SectionTracker } from "./sections.js";
20
+ import { SvgPathSampler } from "./path-sampler.js";
21
+ import { TargetTracker } from "./targets.js";
19
22
  import { registerSiteVisibility } from "./visibility.js";
20
23
  import { snapshotConfiguration } from "./own-data.js";
21
24
  import { NATIVE_DIRECTIONS, } from "./types.js";
@@ -23,6 +26,10 @@ const SUSPEND_HOST = 1;
23
26
  const SUSPEND_PAGE = 2;
24
27
  const SUSPEND_SITE = 4;
25
28
  const MAX_TIMER_DELAY = 2_147_483_647;
29
+ const DEFAULT_GRAVITY = 1_800;
30
+ const DEFAULT_THROW_SPEED = 1_800;
31
+ const DEFAULT_BOUNCE = 0.35;
32
+ const DEFAULT_FLOOR_INSET = 8;
26
33
  const EMPTY_CONTENT_SELECTIONS = Object.freeze([]);
27
34
  function browserSelectorIsValid(document, selector) {
28
35
  try {
@@ -86,10 +93,34 @@ function hatchOptions(input) {
86
93
  }
87
94
  return input;
88
95
  }
96
+ function checkedIndicator(input) {
97
+ if (input === null)
98
+ return;
99
+ const value = snapshotConfiguration(input);
100
+ if (!value ||
101
+ typeof value !== "object" ||
102
+ Array.isArray(value) ||
103
+ (value.kind !== undefined &&
104
+ value.kind !== "dot" &&
105
+ value.kind !== "count") ||
106
+ typeof value.label !== "string" ||
107
+ value.label.length < 1 ||
108
+ value.label.length > 120 ||
109
+ /[\u0000-\u001f\u007f]/.test(value.label) ||
110
+ (value.count !== undefined &&
111
+ (!Number.isInteger(value.count) ||
112
+ value.count < 0 ||
113
+ value.count > 999)) ||
114
+ (value.color !== undefined && !isSurfaceColor(value.color)) ||
115
+ (value.visible !== undefined && typeof value.visible !== "boolean")) {
116
+ throw new TypeError(runtimeMessage("indicator.invalid", "Indicator is invalid"));
117
+ }
118
+ return value;
119
+ }
89
120
  /** Package-internal implementation. It is not a supported import. */
90
121
  export class PeeklingRuntime {
91
- static hatch(input) {
92
- return new PeeklingRuntime(hatchOptions(input));
122
+ static hatch(input, rendererFactory = createAtlasRenderer) {
123
+ return new PeeklingRuntime(hatchOptions(input), rendererFactory);
93
124
  }
94
125
  /** Package-internal presentation update used only by the Web Component. */
95
126
  updateComponentName(value) {
@@ -128,7 +159,9 @@ export class PeeklingRuntime {
128
159
  #loaded;
129
160
  #renderer;
130
161
  #contentRenderer;
162
+ #interactionController;
131
163
  #sections;
164
+ #targets;
132
165
  #input;
133
166
  #raf = 0;
134
167
  #planTimer = 0;
@@ -164,10 +197,18 @@ export class PeeklingRuntime {
164
197
  #instanceId;
165
198
  #diagnostics;
166
199
  #styles;
200
+ #rendererFactory;
167
201
  #rejectedOverrideId = 0;
168
202
  #reportedNotReadyEvent = false;
169
203
  #reportedNotReadyOverride = false;
170
- constructor(options = {}) {
204
+ #trackTargets = false;
205
+ #pathSampler;
206
+ #indicator;
207
+ #dragging = false;
208
+ #throwing = false;
209
+ #throwVelocity = { x: 0, y: 0 };
210
+ #landUntil = 0;
211
+ constructor(options = {}, rendererFactory = createAtlasRenderer) {
171
212
  options = snapshotConfiguration(options);
172
213
  const document = options.document ?? globalThis.document;
173
214
  const window = options.window ?? globalThis.window;
@@ -175,7 +216,10 @@ export class PeeklingRuntime {
175
216
  throw new Error(runtimeMessage("browser.context", "Browser document/window required"));
176
217
  this.#document = document;
177
218
  this.#window = window;
219
+ this.#pathSampler = new SvgPathSampler(document);
178
220
  this.#options = options;
221
+ this.#indicator = options.indicator;
222
+ this.#rendererFactory = rendererFactory;
179
223
  this.#now = window.performance?.now?.bind(window.performance) ?? Date.now;
180
224
  this.#instanceId = `instance:${Math.floor(this.#now())}:${Math.random().toString(36).slice(2, 8)}`;
181
225
  this.#diagnostics = new DiagnosticChannel({
@@ -293,6 +337,31 @@ export class PeeklingRuntime {
293
337
  this.#setSuspended(SUSPEND_HOST, false);
294
338
  this.#notice("lifecycle.resumed", "info", "lifecycle");
295
339
  }
340
+ refreshTargets() {
341
+ this.#targets?.refresh();
342
+ }
343
+ setIndicator(indicator) {
344
+ if (this.#destroyed)
345
+ return;
346
+ this.#indicator = checkedIndicator(indicator);
347
+ this.#interactionController?.updateIndicator(this.#indicator);
348
+ }
349
+ setContentVisible(visible) {
350
+ if (this.#destroyed || !this.#contentRenderer)
351
+ return false;
352
+ const expanded = this.#contentRenderer.setUserHidden(!visible);
353
+ this.#interactionController?.setExpanded(expanded);
354
+ this.#wake();
355
+ return expanded;
356
+ }
357
+ toggleContent() {
358
+ if (this.#destroyed || !this.#contentRenderer)
359
+ return false;
360
+ const expanded = this.#contentRenderer.toggleUserHidden();
361
+ this.#interactionController?.setExpanded(expanded);
362
+ this.#wake();
363
+ return expanded;
364
+ }
296
365
  destroy() {
297
366
  this.#destroy("destroyed");
298
367
  }
@@ -307,7 +376,9 @@ export class PeeklingRuntime {
307
376
  this.#input?.destroy();
308
377
  this.#plan?.reset();
309
378
  this.#sections?.destroy();
379
+ this.#targets?.destroy();
310
380
  this.#contentRenderer?.destroy();
381
+ this.#interactionController?.destroy();
311
382
  this.#renderer?.destroy();
312
383
  this.#unregisterVisibility?.();
313
384
  this.#unregisterVisibility = undefined;
@@ -334,10 +405,18 @@ export class PeeklingRuntime {
334
405
  async #initialize() {
335
406
  try {
336
407
  const validateSelector = (selector) => browserSelectorIsValid(this.#document, selector);
408
+ const targetIds = new Set(Object.keys(this.#options.targets ?? {}));
409
+ for (const selector of Object.values(this.#options.targets ?? {})) {
410
+ if (!validateSelector(selector)) {
411
+ throw new TypeError(runtimeMessage("target.selector", "Target selector is invalid"));
412
+ }
413
+ }
337
414
  if (this.#options.plan !== undefined) {
338
415
  compilePlan(this.#options.plan, {
339
416
  contentIds: new Set(Object.keys(this.#content)),
417
+ targets: targetIds,
340
418
  validateSelector,
419
+ validatePath: (path) => this.#pathSampler.validate(path),
341
420
  });
342
421
  }
343
422
  const browserWindow = this.#window;
@@ -396,17 +475,22 @@ export class PeeklingRuntime {
396
475
  this.#loaded = loaded;
397
476
  const locomotion = hasLocomotion(loaded.content);
398
477
  const capabilities = new Set(locomotion ? ["locomotion"] : []);
399
- const compiled = compilePlan(this.#options.plan ?? createDefaultPlan(locomotion), {
478
+ const compiled = compilePlan(this.#options.plan ??
479
+ createPresetPlan(this.#options.preset ?? "companion", locomotion), {
400
480
  states: new Set(Object.keys(loaded.content.states)),
401
481
  capabilities,
402
482
  contentIds: new Set(Object.keys(this.#content)),
483
+ targets: targetIds,
403
484
  validateSelector,
485
+ validatePath: (path) => this.#pathSampler.validate(path),
404
486
  });
405
487
  this.#plan = new PlanRuntime(compiled);
406
488
  this.#overrides = new OverrideManager({
407
489
  states: new Set(Object.keys(loaded.content.states)),
408
490
  capabilities,
409
491
  contentIds: new Set(Object.keys(this.#content)),
492
+ targets: targetIds,
493
+ validatePath: (path) => this.#pathSampler.validate(path),
410
494
  }, this.#now, (message) => this.#diagnose(message, "override.rejected", "warning", "override"));
411
495
  if (this.#displayName === "") {
412
496
  this.#displayName = loaded.content.displayName;
@@ -425,16 +509,53 @@ export class PeeklingRuntime {
425
509
  this.#position = resolveInitialPosition(this.#options.position ?? DEFAULT_POSITION, this.#window.innerWidth, this.#window.innerHeight, (loaded.content.atlas.logicalWidth ?? loaded.content.atlas.cellWidth) *
426
510
  scale, (loaded.content.atlas.logicalHeight ??
427
511
  loaded.content.atlas.cellHeight) * scale);
428
- this.#renderer = new AtlasRenderer(this.#document, loaded.content, loaded.atlasObjectUrl, scale, this.#styles);
512
+ this.#renderer = this.#rendererFactory({
513
+ document: this.#document,
514
+ pack: loaded.content,
515
+ atlasUrl: loaded.atlasObjectUrl,
516
+ scale,
517
+ styles: this.#styles,
518
+ });
429
519
  if (Object.keys(this.#content).length ||
430
520
  this.#displayName !== undefined) {
431
521
  this.#ensureContentRenderer();
432
522
  }
523
+ if (this.#options.interaction !== false) {
524
+ const interaction = this.#options.interaction ?? {};
525
+ const pressAction = interaction.press ??
526
+ (interaction.pressEvent ? "emit" : "toggle-content");
527
+ const controlsContent = Boolean(this.#contentRenderer) &&
528
+ (pressAction === "toggle-content" ||
529
+ pressAction === "show-content" ||
530
+ pressAction === "hide-content");
531
+ if (interaction.contentInitiallyHidden) {
532
+ this.#contentRenderer?.setUserHidden(true);
533
+ }
534
+ this.#interactionController = new CharacterInteractionController({
535
+ document: this.#document,
536
+ styles: this.#styles,
537
+ label: interaction.label ??
538
+ `Interact with ${loaded.content.displayName || "Peekling"}`,
539
+ draggable: interaction.drag !== false,
540
+ ...(controlsContent
541
+ ? { expanded: !interaction.contentInitiallyHidden }
542
+ : {}),
543
+ ...(this.#indicator ? { indicator: this.#indicator } : {}),
544
+ onPress: () => this.#handleCharacterPress(),
545
+ onDragStart: () => this.#startCharacterDrag(),
546
+ onDragMove: (position, velocity) => this.#moveCharacterDrag(position, velocity),
547
+ onDragEnd: (velocity, moved, pointer) => this.#endCharacterDrag(velocity, moved, pointer),
548
+ });
549
+ if (interaction.contentInitiallyHidden) {
550
+ this.#interactionController.setExpanded(false);
551
+ }
552
+ }
433
553
  try {
434
554
  await Promise.all([
435
555
  this.#visibilityReady,
436
556
  this.#renderer.ready,
437
557
  this.#contentRenderer?.ready,
558
+ this.#interactionController?.ready,
438
559
  ]);
439
560
  }
440
561
  catch (cause) {
@@ -443,10 +564,14 @@ export class PeeklingRuntime {
443
564
  }
444
565
  const requirements = compiledPlanSectionRequirements(compiled);
445
566
  this.#createInput();
567
+ if (targetIds.size) {
568
+ this.#targets = new TargetTracker(this.#document, this.#window, this.#options.targets, () => this.#wake());
569
+ }
446
570
  if (requirements.selectors.length) {
447
571
  this.#sections = new SectionTracker(this.#document, this.#window, requirements.selectors, requirements.thresholds, () => this.#wake(), (fact) => this.#input?.captureReaction("section.visibility", fact, "browser"));
448
572
  }
449
573
  this.#renderer.setHidden(this.#siteHidden);
574
+ this.#interactionController?.setHidden(this.#siteHidden);
450
575
  if (this.#suspensions) {
451
576
  this.#input?.setSuspended(true);
452
577
  this.#sections?.setSuspended(true);
@@ -517,6 +642,122 @@ export class PeeklingRuntime {
517
642
  });
518
643
  return this.#contentRenderer;
519
644
  }
645
+ #handleCharacterPress() {
646
+ if (this.#destroyed)
647
+ return;
648
+ const interaction = this.#options.interaction || {};
649
+ if (interaction.clearIndicatorOnPress === true && this.#indicator) {
650
+ this.setIndicator(null);
651
+ }
652
+ const action = interaction.press ?? (interaction.pressEvent ? "emit" : "toggle-content");
653
+ if (action === "emit") {
654
+ if (interaction.pressEvent)
655
+ this.emit(interaction.pressEvent);
656
+ return;
657
+ }
658
+ if (action === "none")
659
+ return;
660
+ if (action === "show-content")
661
+ this.setContentVisible(true);
662
+ else if (action === "hide-content")
663
+ this.setContentVisible(false);
664
+ else
665
+ this.toggleContent();
666
+ }
667
+ #startCharacterDrag() {
668
+ if (this.#destroyed || this.#siteHidden)
669
+ return;
670
+ this.#dragging = true;
671
+ this.#throwing = false;
672
+ this.#landUntil = 0;
673
+ this.#throwVelocity = { x: 0, y: 0 };
674
+ this.#wake();
675
+ }
676
+ #moveCharacterDrag(position, velocity) {
677
+ if (!this.#dragging || this.#destroyed)
678
+ return;
679
+ const size = this.#characterSize();
680
+ this.#position = size
681
+ ? clampPosition(position, { width: this.#window.innerWidth, height: this.#window.innerHeight }, size.width, size.height)
682
+ : { ...position };
683
+ this.#throwVelocity = { ...velocity };
684
+ this.#wake();
685
+ }
686
+ #endCharacterDrag(velocity, moved, pointer) {
687
+ if (!this.#dragging || this.#destroyed)
688
+ return;
689
+ this.#dragging = false;
690
+ if (!moved) {
691
+ this.#throwVelocity = { x: 0, y: 0 };
692
+ this.#wake();
693
+ return;
694
+ }
695
+ const interaction = this.#options.interaction || {};
696
+ if (interaction.catchTarget &&
697
+ this.#targets?.contains(interaction.catchTarget, pointer, interaction.catchMargin ?? 24) &&
698
+ this.#catchCharacter(interaction.catchTarget)) {
699
+ return;
700
+ }
701
+ if (this.#reducedMotion) {
702
+ this.#landCharacter();
703
+ return;
704
+ }
705
+ if (interaction.throw === false) {
706
+ this.#throwVelocity = { x: 0, y: 0 };
707
+ this.#wake();
708
+ return;
709
+ }
710
+ const maximum = interaction.maxThrowSpeed ?? DEFAULT_THROW_SPEED;
711
+ const magnitude = Math.hypot(velocity.x, velocity.y);
712
+ const factor = magnitude > maximum && magnitude ? maximum / magnitude : 1;
713
+ this.#throwVelocity = {
714
+ x: velocity.x * factor,
715
+ y: velocity.y * factor,
716
+ };
717
+ this.#throwing = true;
718
+ this.#wake();
719
+ }
720
+ #characterSize() {
721
+ const loaded = this.#loaded?.content;
722
+ if (!loaded)
723
+ return;
724
+ const scale = this.#options.scale ?? loaded.defaultScale;
725
+ return {
726
+ width: (loaded.atlas.logicalWidth ?? loaded.atlas.cellWidth) * scale,
727
+ height: (loaded.atlas.logicalHeight ?? loaded.atlas.cellHeight) * scale,
728
+ };
729
+ }
730
+ #landCharacter() {
731
+ const size = this.#characterSize();
732
+ if (size) {
733
+ const floorInset = (this.#options.interaction || {}).floorInset ?? DEFAULT_FLOOR_INSET;
734
+ this.#position = clampPosition({
735
+ x: this.#position.x,
736
+ y: this.#window.innerHeight - size.height / 2 - floorInset,
737
+ }, { width: this.#window.innerWidth, height: this.#window.innerHeight }, size.width, size.height);
738
+ }
739
+ this.#dragging = false;
740
+ this.#throwing = false;
741
+ this.#throwVelocity = { x: 0, y: 0 };
742
+ this.#landUntil = this.#now() + 360;
743
+ this.#wake();
744
+ }
745
+ #catchCharacter(targetId) {
746
+ const target = this.#targets?.snapshot(true)[targetId];
747
+ const size = this.#characterSize();
748
+ if (!target || !size)
749
+ return false;
750
+ const interaction = this.#options.interaction || {};
751
+ const point = characterTargetPoint(target, interaction.catchAnchor ?? "center", size);
752
+ this.#position = clampPosition(point, { width: this.#window.innerWidth, height: this.#window.innerHeight }, size.width, size.height);
753
+ this.#throwing = false;
754
+ this.#throwVelocity = { x: 0, y: 0 };
755
+ this.#landUntil = this.#now() + 360;
756
+ if (interaction.catchEvent)
757
+ this.emit(interaction.catchEvent);
758
+ this.#wake();
759
+ return true;
760
+ }
520
761
  #diagnose(message, code = "runtime.notice", severity = "warning", phase = "internal", eventId, metadata, cause) {
521
762
  this.#diagnostics.emit({
522
763
  code,
@@ -591,6 +832,7 @@ export class PeeklingRuntime {
591
832
  return;
592
833
  this.#siteHidden = hidden;
593
834
  this.#renderer?.setHidden(hidden);
835
+ this.#interactionController?.setHidden(hidden);
594
836
  if (this.#destroyed)
595
837
  return;
596
838
  if (hidden && this.#contentRenderer) {
@@ -602,6 +844,9 @@ export class PeeklingRuntime {
602
844
  return;
603
845
  }
604
846
  if (hidden) {
847
+ this.#dragging = false;
848
+ this.#throwing = false;
849
+ this.#throwVelocity = { x: 0, y: 0 };
605
850
  this.#overrides?.clear("dismissed");
606
851
  this.#setSuspended(SUSPEND_SITE, true);
607
852
  this.#notice("lifecycle.dismissed", "info", "lifecycle");
@@ -717,8 +962,13 @@ export class PeeklingRuntime {
717
962
  .upgrade(required)
718
963
  .then((changed) => {
719
964
  if (changed && !this.#destroyed && this.#loaded === loaded) {
720
- this.#renderer?.swapAtlas(loaded.content, loaded.atlasObjectUrl);
721
- this.#wake();
965
+ const renderer = this.#renderer;
966
+ if (!renderer)
967
+ return;
968
+ return Promise.resolve(renderer.swapAtlas(loaded.content, loaded.atlasObjectUrl)).then(() => {
969
+ if (!this.#destroyed && this.#loaded === loaded)
970
+ this.#wake();
971
+ });
722
972
  }
723
973
  })
724
974
  .catch((error) => {
@@ -795,10 +1045,13 @@ export class PeeklingRuntime {
795
1045
  pointer: input.pointer,
796
1046
  position: this.#position,
797
1047
  viewport,
1048
+ characterSize,
798
1049
  lastActivityAt: input.lastActivityAt,
799
1050
  reducedMotion: this.#reducedMotion,
800
1051
  pageVisible: !this.#document.hidden,
801
1052
  sections,
1053
+ targets: this.#targets?.snapshot(this.#trackTargets),
1054
+ samplePath: (path, progress) => this.#pathSampler.sample(path, progress),
802
1055
  reaction,
803
1056
  };
804
1057
  return {
@@ -822,6 +1075,7 @@ export class PeeklingRuntime {
822
1075
  overrides.observeEvent(reaction.name, reaction.source);
823
1076
  }
824
1077
  const request = overrides.compose(ordinary, world);
1078
+ this.#trackTargets = request.motion?.trackTarget === true;
825
1079
  if (this.#reducedMotion) {
826
1080
  delete request.motion;
827
1081
  if (request.capability === "locomotion")
@@ -829,6 +1083,25 @@ export class PeeklingRuntime {
829
1083
  if (!request.state)
830
1084
  request.state = "idle";
831
1085
  }
1086
+ const interaction = this.#options.interaction || {};
1087
+ if (this.#dragging) {
1088
+ delete request.motion;
1089
+ delete request.capability;
1090
+ request.state = interaction.dragState ?? "scroll:fly";
1091
+ }
1092
+ else if (this.#throwing) {
1093
+ delete request.motion;
1094
+ delete request.capability;
1095
+ request.state =
1096
+ this.#throwVelocity.y < 0
1097
+ ? (interaction.riseState ?? "scroll:fly")
1098
+ : (interaction.fallState ?? "scroll:fall");
1099
+ }
1100
+ else if (this.#landUntil > world.now) {
1101
+ delete request.motion;
1102
+ delete request.capability;
1103
+ request.state = interaction.landState ?? "success";
1104
+ }
832
1105
  const diagnostics = [];
833
1106
  if (reaction) {
834
1107
  this.#seenReaction = reaction.id;
@@ -873,18 +1146,71 @@ export class PeeklingRuntime {
873
1146
  }
874
1147
  return this.#frameActive(frame);
875
1148
  }
1149
+ #advanceThrow(snapshot) {
1150
+ if (!this.#throwing)
1151
+ return;
1152
+ const interaction = this.#options.interaction || {};
1153
+ const dt = snapshot.frame.stepMs / 1_000;
1154
+ const bounce = interaction.bounce ?? DEFAULT_BOUNCE;
1155
+ const halfWidth = snapshot.characterSize.width / 2;
1156
+ const halfHeight = snapshot.characterSize.height / 2;
1157
+ const floor = snapshot.world.viewport.height -
1158
+ halfHeight -
1159
+ (interaction.floorInset ?? DEFAULT_FLOOR_INSET);
1160
+ let velocityX = this.#throwVelocity.x;
1161
+ let velocityY = this.#throwVelocity.y + (interaction.gravity ?? DEFAULT_GRAVITY) * dt;
1162
+ let x = this.#position.x + velocityX * dt;
1163
+ let y = this.#position.y + velocityY * dt;
1164
+ const right = snapshot.world.viewport.width - halfWidth;
1165
+ if (x < halfWidth) {
1166
+ x = halfWidth;
1167
+ velocityX = Math.abs(velocityX) * bounce;
1168
+ }
1169
+ else if (x > right) {
1170
+ x = right;
1171
+ velocityX = -Math.abs(velocityX) * bounce;
1172
+ }
1173
+ if (y < halfHeight) {
1174
+ y = halfHeight;
1175
+ velocityY = Math.abs(velocityY) * bounce;
1176
+ }
1177
+ const next = { x, y };
1178
+ if (interaction.catchTarget &&
1179
+ this.#targets?.contains(interaction.catchTarget, next, interaction.catchMargin ?? 24) &&
1180
+ this.#catchCharacter(interaction.catchTarget)) {
1181
+ return;
1182
+ }
1183
+ if (y >= floor) {
1184
+ this.#position = { x, y: floor };
1185
+ this.#landCharacter();
1186
+ return;
1187
+ }
1188
+ this.#position = next;
1189
+ this.#throwVelocity = { x: velocityX * 0.995, y: velocityY };
1190
+ }
876
1191
  #calculatePresentation(snapshot, evaluation) {
877
1192
  const { frame, characterSize, world } = snapshot;
878
1193
  const { request } = evaluation;
879
1194
  const loaded = frame.loaded.content;
880
1195
  if (!this.#reducedMotion)
881
1196
  this.#animationClock += Math.max(0, frame.elapsed);
1197
+ this.#advanceThrow(snapshot);
882
1198
  const requested = request.capability === "locomotion" && request.motion
883
1199
  ? loaded.directionalStates?.[directionTo({ x: 0, y: 0 }, { x: request.motion.x, y: request.motion.y })]
884
1200
  : resolveCapabilityName(request.state, loaded);
885
1201
  const resolved = resolveState(requested, loaded.states);
886
1202
  let lift = 0;
887
- if (request.motion) {
1203
+ if (request.motion?.direct) {
1204
+ const distance = request.motion.maxDistance ?? 0;
1205
+ this.#position = clampPosition({
1206
+ x: this.#position.x + request.motion.x * distance,
1207
+ y: this.#position.y + request.motion.y * distance,
1208
+ }, world.viewport, characterSize.width, characterSize.height);
1209
+ lift = request.motion.lift ?? 0;
1210
+ this.#locomotionState = "";
1211
+ this.#locomotionElapsed = 0;
1212
+ }
1213
+ else if (request.motion) {
888
1214
  if (this.#locomotionState !== resolved.name) {
889
1215
  this.#locomotionState = resolved.name;
890
1216
  this.#locomotionElapsed = 0;
@@ -904,8 +1230,9 @@ export class PeeklingRuntime {
904
1230
  y: this.#position.y + request.motion.y * distance,
905
1231
  }, world.viewport, characterSize.width, characterSize.height);
906
1232
  lift =
907
- locomotionSample(loaded.locomotionMotion, nextCycles).lift *
908
- characterSize.height;
1233
+ request.motion.lift ??
1234
+ locomotionSample(loaded.locomotionMotion, nextCycles).lift *
1235
+ characterSize.height;
909
1236
  }
910
1237
  else {
911
1238
  this.#locomotionState = "";
@@ -932,12 +1259,18 @@ export class PeeklingRuntime {
932
1259
  if (!this.#frameActive(frame))
933
1260
  return;
934
1261
  frame.renderer.render(presentation.state, this.#animationClock, this.#position, snapshot.devicePixelRatio, presentation.lift);
1262
+ if (!this.#frameActive(frame))
1263
+ return;
1264
+ this.#interactionController?.render(this.#position, snapshot.characterSize, presentation.lift);
935
1265
  if (!this.#frameActive(frame))
936
1266
  return;
937
1267
  this.#lastTick = frame.now;
938
1268
  this.#schedulePlan(frame);
939
1269
  if (this.#frameActive(frame) &&
940
- (evaluation.request.motion || frame.input.reaction))
1270
+ (evaluation.request.motion ||
1271
+ frame.input.reaction ||
1272
+ this.#throwing ||
1273
+ this.#dragging))
941
1274
  this.#wake();
942
1275
  }
943
1276
  #endPage() {
@@ -1013,3 +1346,21 @@ function clampPosition(point, viewport, width, height) {
1013
1346
  y: Math.max(height / 2, Math.min(viewport.height - height / 2, point.y)),
1014
1347
  };
1015
1348
  }
1349
+ function characterTargetPoint(target, anchor, character) {
1350
+ const center = {
1351
+ x: target.left + target.width / 2,
1352
+ y: target.top + target.height / 2,
1353
+ };
1354
+ switch (anchor) {
1355
+ case "top":
1356
+ return { x: center.x, y: target.top - character.height / 2 };
1357
+ case "right":
1358
+ return { x: target.right + character.width / 2, y: center.y };
1359
+ case "bottom":
1360
+ return { x: center.x, y: target.bottom + character.height / 2 };
1361
+ case "left":
1362
+ return { x: target.left - character.width / 2, y: center.y };
1363
+ default:
1364
+ return center;
1365
+ }
1366
+ }
@@ -0,0 +1,11 @@
1
+ import type { Point, TargetSnapshot } from "./types.js";
2
+ /** Tracks host-approved geometry without moving, styling, or reparenting host DOM. */
3
+ export declare class TargetTracker {
4
+ #private;
5
+ constructor(document: Document, window: Window, selectors: Readonly<Record<string, string>>, wake: () => void);
6
+ snapshot(force?: boolean): Readonly<Record<string, TargetSnapshot>>;
7
+ contains(id: string, point: Readonly<Point>, margin?: number): boolean;
8
+ refresh: () => void;
9
+ destroy(): void;
10
+ }
11
+ //# sourceMappingURL=targets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targets.d.ts","sourceRoot":"","sources":["../src/targets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAIxD,sFAAsF;AACtF,qBAAa,aAAa;;IAYxB,YACE,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC3C,IAAI,EAAE,MAAM,IAAI,EAwBjB;IAED,QAAQ,CAAC,KAAK,UAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAwChE;IAED,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,SAAI,GAAG,OAAO,CAShE;IAED,OAAO,QAAO,IAAI,CAIhB;IAEF,OAAO,IAAI,IAAI,CAQd;CAGF"}