import { FillerData } from "./FillerData"; import { FillerScene } from "./FillerScene"; /** * DOM overlay 手指提示层 * - 页面初始化后 800ms 自动指向当前颜色组第一个待填区块中心 * - 用户每次交互(点击填色/点击失败)后重置 2s 闲置计时器 * - 全图填完后停止 */ export class FingerHint { private el: HTMLDivElement; private idleTimer: number | null = null; private stopped = false; private static readonly IDLE_MS = 2000; /** 手指图片尺寸(CSS px) */ private static readonly SIZE = 72; /** * 指尖在图片内的相对位置(0~1)。 * finger.png 通常是食指斜向下指,指尖在图片左上角约 (25%, 12%)。 */ private static readonly TIP_X = 0.25; private static readonly TIP_Y = 0.12; constructor( private scene: FillerScene, private fillerData: FillerData, fingerUrl: string, ) { this.el = this.createEl(fingerUrl); document.body.appendChild(this.el); this.injectStyle(); } // ─── 公开 API ────────────────────────────────────────────────── /** 初始化完成后调用,启动首次提示 */ start() { this.scheduleHint(800); } /** 每次用户有交互动作(填色成功/失败)时调用 */ onUserInteraction() { this.hide(); if (!this.stopped) { this.scheduleHint(FingerHint.IDLE_MS); } } /** 全图填完时调用 */ stop() { this.stopped = true; this.hide(); if (this.idleTimer !== null) { clearTimeout(this.idleTimer); this.idleTimer = null; } } // ─── 私有实现 ────────────────────────────────────────────────── private scheduleHint(delay: number) { if (this.idleTimer !== null) clearTimeout(this.idleTimer); this.idleTimer = window.setTimeout(() => this.showHint(), delay); } private showHint() { if (this.stopped) return; const group = this.fillerData.currentGroup; if (!group) return; const area = group.firstUncoloredArea; if (!area) return; const [cssX, cssY] = this.contentToScreen(area.center.x, area.center.y); this.show(cssX, cssY); } /** * 将内容坐标(图片像素空间)转换为 CSS 页面坐标(视口 px) * * Scene 的 userMat 是列主序矩阵,对简单 scale+translate 变换: * physX = cx * mat[0] + cy * mat[4] + mat[12] * physY = cx * mat[1] + cy * mat[5] + mat[13] * 再除以 devicePixelRatio 得到 canvas 内 CSS px; * 最后加上 canvas 元素的视口偏移,以匹配 position:fixed 坐标系。 */ private contentToScreen(cx: number, cy: number): [number, number] { const mat = this.scene.userMat; const ratio = this.scene.ratio; const physX = cx * mat[0] + cy * mat[4] + mat[12]; const physY = cx * mat[1] + cy * mat[5] + mat[13]; // canvas 内 CSS 像素 const canvasCssX = physX / ratio; const canvasCssY = physY / ratio; // 加上 canvas 在视口中的偏移,转换为 position:fixed 坐标 const canvas = this.scene.gl.canvas as HTMLElement; const rect = canvas.getBoundingClientRect(); return [canvasCssX + rect.left, canvasCssY + rect.top]; } private show(cssX: number, cssY: number) { const size = FingerHint.SIZE; // 将指尖对齐到目标坐标 this.el.style.left = `${cssX - size * FingerHint.TIP_X}px`; this.el.style.top = `${cssY - size * FingerHint.TIP_Y}px`; this.el.style.display = "block"; this.el.classList.add("finger-tapping"); } private hide() { this.el.style.display = "none"; this.el.classList.remove("finger-tapping"); } private createEl(fingerUrl: string): HTMLDivElement { const size = FingerHint.SIZE; const el = document.createElement("div"); el.id = "finger-hint"; el.style.cssText = ` position: fixed; width: ${size}px; height: ${size}px; pointer-events: none; z-index: 999; display: none; transform-origin: ${FingerHint.TIP_X * 100}% ${FingerHint.TIP_Y * 100}%; `; const img = document.createElement("img"); img.src = fingerUrl; img.style.cssText = "width:100%;height:100%;object-fit:contain;"; img.draggable = false; el.appendChild(img); return el; } private injectStyle() { if (document.getElementById("finger-hint-style")) return; const style = document.createElement("style"); style.id = "finger-hint-style"; style.textContent = ` @keyframes finger-tap { 0% { transform: scale(1) translateY(0); opacity: 1; } 35% { transform: scale(0.88) translateY(10px); opacity: 1; } 55% { transform: scale(0.88) translateY(10px); opacity: 1; } 80% { transform: scale(1) translateY(0); opacity: 1; } 100% { transform: scale(1) translateY(0); opacity: 1; } } #finger-hint.finger-tapping { animation: finger-tap 1s ease-in-out infinite; } `; document.head.appendChild(style); } }