/**
* 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;
}
}