FingerHint.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { FillerData } from "./FillerData";
  2. import { FillerScene } from "./FillerScene";
  3. /**
  4. * DOM overlay 手指提示层
  5. * - 页面初始化后 800ms 自动指向当前颜色组第一个待填区块中心
  6. * - 用户每次交互(点击填色/点击失败)后重置 2s 闲置计时器
  7. * - 全图填完后停止
  8. */
  9. export class FingerHint {
  10. private el: HTMLDivElement;
  11. private idleTimer: number | null = null;
  12. private stopped = false;
  13. private static readonly IDLE_MS = 2000;
  14. /** 手指图片尺寸(CSS px) */
  15. private static readonly SIZE = 72;
  16. /**
  17. * 指尖在图片内的相对位置(0~1)。
  18. * finger.png 通常是食指斜向下指,指尖在图片左上角约 (25%, 12%)。
  19. */
  20. private static readonly TIP_X = 0.25;
  21. private static readonly TIP_Y = 0.12;
  22. constructor(
  23. private scene: FillerScene,
  24. private fillerData: FillerData,
  25. fingerUrl: string,
  26. ) {
  27. this.el = this.createEl(fingerUrl);
  28. document.body.appendChild(this.el);
  29. this.injectStyle();
  30. }
  31. // ─── 公开 API ──────────────────────────────────────────────────
  32. /** 初始化完成后调用,启动首次提示 */
  33. start() {
  34. this.scheduleHint(800);
  35. }
  36. /** 每次用户有交互动作(填色成功/失败)时调用 */
  37. onUserInteraction() {
  38. this.hide();
  39. if (!this.stopped) {
  40. this.scheduleHint(FingerHint.IDLE_MS);
  41. }
  42. }
  43. /** 全图填完时调用 */
  44. stop() {
  45. this.stopped = true;
  46. this.hide();
  47. if (this.idleTimer !== null) {
  48. clearTimeout(this.idleTimer);
  49. this.idleTimer = null;
  50. }
  51. }
  52. // ─── 私有实现 ──────────────────────────────────────────────────
  53. private scheduleHint(delay: number) {
  54. if (this.idleTimer !== null) clearTimeout(this.idleTimer);
  55. this.idleTimer = window.setTimeout(() => this.showHint(), delay);
  56. }
  57. private showHint() {
  58. if (this.stopped) return;
  59. const group = this.fillerData.currentGroup;
  60. if (!group) return;
  61. const area = group.firstUncoloredArea;
  62. if (!area) return;
  63. const [cssX, cssY] = this.contentToScreen(area.center.x, area.center.y);
  64. this.show(cssX, cssY);
  65. }
  66. /**
  67. * 将内容坐标(图片像素空间)转换为 CSS 页面坐标(视口 px)
  68. *
  69. * Scene 的 userMat 是列主序矩阵,对简单 scale+translate 变换:
  70. * physX = cx * mat[0] + cy * mat[4] + mat[12]
  71. * physY = cx * mat[1] + cy * mat[5] + mat[13]
  72. * 再除以 devicePixelRatio 得到 canvas 内 CSS px;
  73. * 最后加上 canvas 元素的视口偏移,以匹配 position:fixed 坐标系。
  74. */
  75. private contentToScreen(cx: number, cy: number): [number, number] {
  76. const mat = this.scene.userMat;
  77. const ratio = this.scene.ratio;
  78. const physX = cx * mat[0] + cy * mat[4] + mat[12];
  79. const physY = cx * mat[1] + cy * mat[5] + mat[13];
  80. // canvas 内 CSS 像素
  81. const canvasCssX = physX / ratio;
  82. const canvasCssY = physY / ratio;
  83. // 加上 canvas 在视口中的偏移,转换为 position:fixed 坐标
  84. const canvas = this.scene.gl.canvas as HTMLElement;
  85. const rect = canvas.getBoundingClientRect();
  86. return [canvasCssX + rect.left, canvasCssY + rect.top];
  87. }
  88. private show(cssX: number, cssY: number) {
  89. const size = FingerHint.SIZE;
  90. // 将指尖对齐到目标坐标
  91. this.el.style.left = `${cssX - size * FingerHint.TIP_X}px`;
  92. this.el.style.top = `${cssY - size * FingerHint.TIP_Y}px`;
  93. this.el.style.display = "block";
  94. this.el.classList.add("finger-tapping");
  95. }
  96. private hide() {
  97. this.el.style.display = "none";
  98. this.el.classList.remove("finger-tapping");
  99. }
  100. private createEl(fingerUrl: string): HTMLDivElement {
  101. const size = FingerHint.SIZE;
  102. const el = document.createElement("div");
  103. el.id = "finger-hint";
  104. el.style.cssText = `
  105. position: fixed;
  106. width: ${size}px;
  107. height: ${size}px;
  108. pointer-events: none;
  109. z-index: 999;
  110. display: none;
  111. transform-origin: ${FingerHint.TIP_X * 100}% ${FingerHint.TIP_Y * 100}%;
  112. `;
  113. const img = document.createElement("img");
  114. img.src = fingerUrl;
  115. img.style.cssText = "width:100%;height:100%;object-fit:contain;";
  116. img.draggable = false;
  117. el.appendChild(img);
  118. return el;
  119. }
  120. private injectStyle() {
  121. if (document.getElementById("finger-hint-style")) return;
  122. const style = document.createElement("style");
  123. style.id = "finger-hint-style";
  124. style.textContent = `
  125. @keyframes finger-tap {
  126. 0% { transform: scale(1) translateY(0); opacity: 1; }
  127. 35% { transform: scale(0.88) translateY(10px); opacity: 1; }
  128. 55% { transform: scale(0.88) translateY(10px); opacity: 1; }
  129. 80% { transform: scale(1) translateY(0); opacity: 1; }
  130. 100% { transform: scale(1) translateY(0); opacity: 1; }
  131. }
  132. #finger-hint.finger-tapping {
  133. animation: finger-tap 1s ease-in-out infinite;
  134. }
  135. `;
  136. document.head.appendChild(style);
  137. }
  138. }