/** * HTML Overlay 模式的篮子/托盘渲染器 * 使用 CSS + DOM 实现,固定在屏幕底部。 * 适用于 Three.js 3D 项目中不需要 3D 深度的 UI 组件。 * * 优点: * - 性能好(不占用 WebGL 渲染管线) * - CSS transition 动画流畅 * - 易于样式定制 * - 不受 3D 相机变换影响 */ export interface TrayItem { id: string; color: string; // 颜色标识 } export interface TrayConfig { capacity: number; matchDuration?: number; // 消除动画时长 ms,默认 300 rearrangeDuration?: number; // 重排动画时长 ms,默认 200 shakeAmount?: number; // 抖动幅度 px,默认 8 slotSize?: number; // 槽位大小 px,默认 48 trayPadding?: number; // 篮子内边距 px,默认 8 colorToHex: (color: string) => string; // 颜色标识 → CSS hex 颜色 } export class TrayRendererHtmlOverlay { private container!: HTMLDivElement; private slotsContainer!: HTMLDivElement; private items: Map = new Map(); private config: Required; private capacity: number; constructor(config: TrayConfig) { this.capacity = config.capacity; this.config = { capacity: config.capacity, matchDuration: config.matchDuration ?? 300, rearrangeDuration: config.rearrangeDuration ?? 200, shakeAmount: config.shakeAmount ?? 8, slotSize: config.slotSize ?? 48, trayPadding: config.trayPadding ?? 8, colorToHex: config.colorToHex, }; } /** * 创建 DOM 结构并注入到页面 * @param parentId 父容器 ID(通常是 'game-container') */ setup(parentId: string = "game-container"): void { const parent = document.getElementById(parentId) || document.body; // 创建外层容器 this.container = document.createElement("div"); this.container.id = "tray-overlay"; this.container.style.cssText = ` position: fixed; bottom: env(safe-area-inset-bottom, 16px); left: 50%; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; z-index: 1000; pointer-events: none; `; // 创建篮子背景 const bg = document.createElement("div"); bg.style.cssText = ` background: rgba(22, 33, 62, 0.9); border-radius: 12px; padding: ${this.config.trayPadding}px; backdrop-filter: blur(8px); border: 1px solid rgba(61, 61, 92, 0.5); `; // 创建槽位容器 this.slotsContainer = document.createElement("div"); this.slotsContainer.style.cssText = ` display: flex; gap: 4px; min-width: ${this.capacity * (this.config.slotSize + 4)}px; height: ${this.config.slotSize}px; position: relative; `; // 绘制槽位分隔线 for (let i = 0; i < this.capacity; i++) { const slot = document.createElement("div"); slot.style.cssText = ` width: ${this.config.slotSize}px; height: ${this.config.slotSize}px; border-right: ${i < this.capacity - 1 ? "1px solid rgba(61, 61, 92, 0.5)" : "none"}; flex-shrink: 0; `; this.slotsContainer.appendChild(slot); } bg.appendChild(this.slotsContainer); this.container.appendChild(bg); parent.appendChild(this.container); } /** * 获取指定槽位的屏幕位置(相对于视口) */ getSlotScreenPos(index: number): { x: number; y: number } { const slotEl = this.slotsContainer.children[index] as HTMLElement; if (!slotEl) return { x: 0, y: 0 }; const rect = slotEl.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }; } /** * 添加物品到指定槽位 */ addItem(item: TrayItem, index: number): HTMLDivElement { const el = document.createElement("div"); el.dataset.itemId = item.id; const size = this.config.slotSize * 0.75; el.style.cssText = ` position: absolute; width: ${size}px; height: ${size}px; border-radius: 50%; background: ${this.config.colorToHex(item.color)}; box-shadow: 0 2px 8px rgba(0,0,0,0.3), inset 0 -2px 4px rgba(0,0,0,0.2); transition: transform ${this.config.rearrangeDuration}ms ease, left ${this.config.rearrangeDuration}ms ease; transform: scale(0); top: ${(this.config.slotSize - size) / 2}px; left: ${this.getSlotLeft(index, size)}px; `; this.slotsContainer.appendChild(el); this.items.set(item.id, el); // 弹入动画 requestAnimationFrame(() => { el.style.transform = "scale(1)"; }); return el; } /** * 重新排列所有物品到正确位置 */ rearrangeItems(tray: TrayItem[]): void { const size = this.config.slotSize * 0.75; for (let i = 0; i < tray.length; i++) { const el = this.items.get(tray[i].id); if (!el) continue; el.style.left = `${this.getSlotLeft(i, size)}px`; } } /** * 消除匹配的物品(放大 + 淡出) */ removeMatchedItems(matchedIds: string[], onComplete: () => void): void { let completed = 0; const total = matchedIds.length; for (const id of matchedIds) { const el = this.items.get(id); if (!el) { completed++; if (completed >= total) onComplete(); continue; } el.style.transition = `transform ${this.config.matchDuration}ms ease, opacity ${this.config.matchDuration}ms ease`; el.style.transform = "scale(1.3)"; el.style.opacity = "0"; setTimeout(() => { el.remove(); this.items.delete(id); completed++; if (completed >= total) onComplete(); }, this.config.matchDuration); } } /** * 满溢抖动动画 */ shake(): void { const amount = this.config.shakeAmount; this.container.style.transition = "none"; let shakeCount = 0; const maxShakes = 6; const interval = 50; const doShake = () => { if (shakeCount >= maxShakes) { this.container.style.transform = "translateX(-50%)"; return; } const offset = shakeCount % 2 === 0 ? amount : -amount; this.container.style.transform = `translateX(calc(-50% + ${offset}px))`; shakeCount++; setTimeout(doShake, interval); }; doShake(); } /** * 销毁 DOM 元素 */ destroy(): void { this.container?.remove(); this.items.clear(); } // ── 内部方法 ── private getSlotLeft(index: number, itemSize: number): number { const slotWidth = this.config.slotSize + 4; // gap included return index * slotWidth + (this.config.slotSize - itemSize) / 2; } }