初始化仓库:友乐/gameabc 房卡游戏平台脚手架(client + server + docs)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:13:26 +08:00
co-authored by Claude Opus 5
commit 594820d393
655 changed files with 310861 additions and 0 deletions
@@ -0,0 +1,849 @@
/**
* AlignmentUtils - 精灵对齐工具类
* 文件: client/js/01_SubGame/codes/game/utils/AlignmentUtils.js
*
* 🎯 设计目的:
* 提供统一的精灵对齐计算方法,简化UI布局代码
*
* 🔑 核心原则:
* 1. 精灵锚点永远在左上角,无法更改
* 2. 所有对齐计算都基于锚点位置
* 3. 支持常见的9种对齐方式(左上/中上/右上/左中/中中/右中/左下/中下/右下)
* 4. 支持偏移量微调
*
* 📐 坐标系统:
* - X轴:从左到右递增
* - Y轴:从上到下递增
* - 精灵锚点:左上角 (0, 0)
*
* @author JinXian Team
* @version 1.0.0
* @since 2025-12-30
*/
'use strict';
var AlignmentUtils = {
VERSION: '1.0.0',
/**
* 对齐常量
*/
ALIGN: {
// 水平对齐
LEFT: 'left', // 左对齐
CENTER: 'center', // 水平居中
RIGHT: 'right', // 右对齐
// 垂直对齐
TOP: 'top', // 顶部对齐
MIDDLE: 'middle', // 垂直居中
BOTTOM: 'bottom' // 底部对齐
},
/**
* 将精灵对齐到目标矩形区域
*
* 🔑 核心:精灵的锚点永远在左上角,此方法计算锚点应放置的位置
*
* @param {Object} target - 目标矩形区域
* @param {number} target.x - 目标区域左上角X坐标
* @param {number} target.y - 目标区域左上角Y坐标
* @param {number} target.width - 目标区域宽度
* @param {number} target.height - 目标区域高度
*
* @param {Object} sprite - 精灵信息(精灵锚点固定在左上角)
* @param {number} sprite.width - 精灵宽度
* @param {number} sprite.height - 精灵高度
*
* @param {string} horizontalAlign - 水平对齐方式: 'left'|'center'|'right'
* @param {string} verticalAlign - 垂直对齐方式: 'top'|'middle'|'bottom'
*
* @param {Object} [offset] - 偏移量(可选)
* @param {number} [offset.x=0] - X轴偏移
* @param {number} [offset.y=0] - Y轴偏移
*
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* // 将文本标签对齐到矩形框左下角
* var labelPos = AlignmentUtils.alignTo(
* { x: 10, y: 10, width: 60, height: 60 }, // 矩形框区域
* { width: 40, height: 20 }, // 标签大小
* 'left', // 左对齐
* 'bottom', // 底部对齐
* { x: 0, y: 5 } // 向下偏移5px
* );
* // 返回: { x: 10, y: 75 } (矩形底部 + 偏移,注意精灵锚点在左上角)
*
* @example
* // 将图标对齐到矩形框右下角
* var iconPos = AlignmentUtils.alignTo(
* { x: 10, y: 10, width: 60, height: 60 }, // 矩形框区域
* { width: 30, height: 30 }, // 图标大小
* 'right', // 右对齐
* 'bottom', // 底部对齐
* { x: -5, y: 5 } // 向左偏移5px,向下偏移5px
* );
* // 返回: { x: 35, y: 75 } (矩形右边界 - 图标宽度 - 5px,精灵锚点在左上角)
*/
alignTo: function(target, sprite, horizontalAlign, verticalAlign, offset) {
offset = offset || {};
var offsetX = offset.x || 0;
var offsetY = offset.y || 0;
var x, y;
// 计算水平位置
switch (horizontalAlign) {
case this.ALIGN.LEFT:
// 左对齐:精灵左边缘与目标左边缘对齐
x = target.x;
break;
case this.ALIGN.CENTER:
// 水平居中:精灵中心与目标中心对齐
// 🔑 精灵的锚点永远在左上角,所以需要减去精灵宽度的一半
x = target.x + (target.width - sprite.width) / 2;
break;
case this.ALIGN.RIGHT:
// 右对齐:精灵右边缘与目标右边缘对齐
// 🔑 精灵的锚点永远在左上角,所以需要减去精灵宽度
x = target.x + target.width - sprite.width;
break;
default:
console.warn('[AlignmentUtils] 未知的水平对齐方式:', horizontalAlign);
x = target.x;
}
// 计算垂直位置
switch (verticalAlign) {
case this.ALIGN.TOP:
// 顶部对齐:精灵顶边与目标顶边对齐
y = target.y;
break;
case this.ALIGN.MIDDLE:
// 垂直居中:精灵中心与目标中心对齐
// 🔑 精灵的锚点永远在左上角,所以需要减去精灵高度的一半
y = target.y + (target.height - sprite.height) / 2;
break;
case this.ALIGN.BOTTOM:
// 底部对齐:精灵底边与目标底边对齐
// 🔑 精灵的锚点永远在左上角,所以需要加上目标高度
y = target.y + target.height;
break;
default:
console.warn('[AlignmentUtils] 未知的垂直对齐方式:', verticalAlign);
y = target.y;
}
// 应用偏移
return {
x: x + offsetX,
y: y + offsetY
};
},
/**
* 将精灵对齐到目标矩形的左下角(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角
*
* @param {Object} target - 目标矩形区域
* @param {number} target.x - 目标区域左上角X坐标
* @param {number} target.y - 目标区域左上角Y坐标
* @param {number} target.width - 目标区域宽度
* @param {number} target.height - 目标区域高度
*
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {number} sprite.width - 精灵宽度
* @param {number} sprite.height - 精灵高度
*
* @param {Object} [offset] - 偏移量
* @param {number} [offset.x=0] - X偏移
* @param {number} [offset.y=0] - Y偏移
*
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* // 将文本标签对齐到容器左下角
* var pos = AlignmentUtils.alignBottomLeft(
* { x: 10, y: 10, width: 60, height: 60 }, // 容器区域
* { width: 40, height: 20 }, // 标签大小
* { x: 0, y: 5 } // 向下偏移5px
* );
*/
alignBottomLeft: function(target, sprite, offset) {
return this.alignTo(target, sprite, this.ALIGN.LEFT, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标矩形的右下角(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,右对齐时会自动减去精灵宽度
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* // 将图标对齐到容器右下角
* var pos = AlignmentUtils.alignBottomRight(
* { x: 10, y: 10, width: 60, height: 60 }, // 容器区域
* { width: 30, height: 30 }, // 图标大小
* { x: -5, y: 5 } // 向左偏移5px,向下偏移5px
* );
*/
alignBottomRight: function(target, sprite, offset) {
return this.alignTo(target, sprite, this.ALIGN.RIGHT, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标矩形的顶部居中(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度的一半
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*/
alignTopCenter: function(target, sprite, offset) {
return this.alignTo(target, sprite, this.ALIGN.CENTER, this.ALIGN.TOP, offset);
},
/**
* 将精灵对齐到目标矩形的底部居中(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度的一半
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*/
alignBottomCenter: function(target, sprite, offset) {
return this.alignTo(target, sprite, this.ALIGN.CENTER, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标矩形的中心(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度和高度的一半
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*/
alignCenter: function(target, sprite, offset) {
return this.alignTo(target, sprite, this.ALIGN.CENTER, this.ALIGN.MIDDLE, offset);
},
// ============================================================================
// 角落对齐方法 - 精灵的角与目标的角重合
// ============================================================================
/**
* 将精灵的左上角对齐到目标的左上角
*
* 🔑 注意:精灵的锚点永远在左上角
* 💡 角落对齐:精灵的角与目标的角重合(不是边缘对齐)
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* // 将子元素的左上角与容器的左上角重合
* var pos = AlignmentUtils.alignCornerTopLeft(
* { x: 10, y: 10, width: 100, height: 100 },
* { width: 30, height: 30 }
* );
* // 返回: { x: 10, y: 10 }
*/
alignCornerTopLeft: function(target, sprite, offset) {
offset = offset || {};
return {
x: target.x + (offset.x || 0),
y: target.y + (offset.y || 0)
};
},
/**
* 将精灵的右上角对齐到目标的右上角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵宽度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* var pos = AlignmentUtils.alignCornerTopRight(
* { x: 10, y: 10, width: 100, height: 100 },
* { width: 30, height: 30 }
* );
* // 返回: { x: 80, y: 10 } (10 + 100 - 30)
*/
alignCornerTopRight: function(target, sprite, offset) {
offset = offset || {};
return {
x: target.x + target.width - sprite.width + (offset.x || 0),
y: target.y + (offset.y || 0)
};
},
/**
* 将精灵的左下角对齐到目标的左下角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵高度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* var pos = AlignmentUtils.alignCornerBottomLeft(
* { x: 10, y: 10, width: 100, height: 100 },
* { width: 30, height: 30 }
* );
* // 返回: { x: 10, y: 80 } (10 + 100 - 30)
*/
alignCornerBottomLeft: function(target, sprite, offset) {
offset = offset || {};
return {
x: target.x + (offset.x || 0),
y: target.y + target.height - sprite.height + (offset.y || 0)
};
},
/**
* 将精灵的右下角对齐到目标的右下角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵宽度和高度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {Object} target - 目标矩形区域
* @param {Object} sprite - 精灵大小(锚点固定在左上角)
* @param {Object} [offset] - 偏移量
* @returns {Object} 精灵锚点位置(左上角) {x, y}
*
* @example
* var pos = AlignmentUtils.alignCornerBottomRight(
* { x: 10, y: 10, width: 100, height: 100 },
* { width: 30, height: 30 }
* );
* // 返回: { x: 80, y: 80 }
*/
alignCornerBottomRight: function(target, sprite, offset) {
offset = offset || {};
return {
x: target.x + target.width - sprite.width + (offset.x || 0),
y: target.y + target.height - sprite.height + (offset.y || 0)
};
},
/**
* 计算多个精灵水平居中的起始X坐标
*
* @param {number} containerWidth - 容器宽度
* @param {number} totalItemsWidth - 所有项目的总宽度(包括间距)
* @returns {number} 起始X坐标
*
* @example
* // 3张牌,每张42px,间距5px,容器宽度600px
* var startX = AlignmentUtils.centerMultipleItems(600, 42 * 3 + 5 * 2);
* // 返回: 258 ((600 - 136) / 2)
*/
centerMultipleItems: function(containerWidth, totalItemsWidth) {
return (containerWidth - totalItemsWidth) / 2;
},
/**
* 计算矩形区域的中心点
*
* @param {Object} rect - 矩形区域
* @param {number} rect.x - 左上角X
* @param {number} rect.y - 左上角Y
* @param {number} rect.width - 宽度
* @param {number} rect.height - 高度
* @returns {Object} 中心点 {x, y}
*/
getCenter: function(rect) {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2
};
},
// ============================================================================
// 精灵ID版本 - 自动获取精灵尺寸
// ============================================================================
/**
* 将精灵对齐到目标精灵(使用精灵ID,自动获取尺寸)
*
* 🔑 核心:精灵的锚点永远在左上角,此方法计算锚点应放置的位置
* 💡 优点:无需手动获取精灵尺寸,代码更简洁
*
* @param {number} targetSpriteId - 目标精灵ID(会自动获取位置和大小)
* @param {number} spriteId - 要对齐的精灵ID(会自动获取大小)
* @param {string} horizontalAlign - 水平对齐方式: 'left'|'center'|'right'
* @param {string} verticalAlign - 垂直对齐方式: 'top'|'middle'|'bottom'
* @param {Object} [offset] - 偏移量(可选)
* @param {number} [offset.x=0] - X轴偏移
* @param {number} [offset.y=0] - Y轴偏移
*
* @returns {Object|null} 精灵锚点位置(左上角) {x, y},失败返回null
*
* @example
* // 将座位标签(精灵5678)对齐到头像(精灵1234)左下角
* var pos = AlignmentUtils.alignSpriteToSprite(
* 1234, // 目标精灵ID(头像)
* 5678, // 要对齐的精灵ID(座位标签)
* 'left', // 左对齐
* 'bottom', // 底部对齐
* { x: 0, y: 5 } // 向下偏移5px
* );
* if (pos) {
* SpriteManager.setPosition(5678, pos.x, pos.y);
* }
*/
alignSpriteToSprite: function(targetSpriteId, spriteId, horizontalAlign, verticalAlign, offset) {
if (typeof SpriteManager === 'undefined') {
console.error('[AlignmentUtils] SpriteManager 未加载,无法使用 alignSpriteToSprite');
return null;
}
// 获取目标精灵的位置和大小
var targetPos = SpriteManager.getPosition(targetSpriteId);
var targetSize = SpriteManager.getSize(targetSpriteId);
if (!targetPos || !targetSize) {
console.error('[AlignmentUtils] 无法获取目标精灵 ' + targetSpriteId + ' 的位置或大小');
return null;
}
// 获取要对齐的精灵的大小
var spriteSize = SpriteManager.getSize(spriteId);
if (!spriteSize) {
console.error('[AlignmentUtils] 无法获取精灵 ' + spriteId + ' 的大小');
return null;
}
// 构建目标矩形
var target = {
x: targetPos.x,
y: targetPos.y,
width: targetSize.width,
height: targetSize.height
};
// 调用核心对齐方法
return this.alignTo(target, spriteSize, horizontalAlign, verticalAlign, offset);
},
/**
* 将精灵对齐到目标精灵的左下角(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角
* 💡 自动获取精灵尺寸,无需手动传入
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*
* @example
* var pos = AlignmentUtils.alignSpriteBottomLeft(1234, 5678, { x: 0, y: 5 });
* if (pos) SpriteManager.setPosition(5678, pos.x, pos.y);
*/
alignSpriteBottomLeft: function(targetSpriteId, spriteId, offset) {
return this.alignSpriteToSprite(targetSpriteId, spriteId, this.ALIGN.LEFT, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标精灵的右下角(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,右对齐时会自动减去精灵宽度
* 💡 自动获取精灵尺寸,无需手动传入
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*
* @example
* var pos = AlignmentUtils.alignSpriteBottomRight(1234, 5678, { x: -5, y: 5 });
* if (pos) SpriteManager.setPosition(5678, pos.x, pos.y);
*/
alignSpriteBottomRight: function(targetSpriteId, spriteId, offset) {
return this.alignSpriteToSprite(targetSpriteId, spriteId, this.ALIGN.RIGHT, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标精灵的顶部居中(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度的一半
* 💡 自动获取精灵尺寸,无需手动传入
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteTopCenter: function(targetSpriteId, spriteId, offset) {
return this.alignSpriteToSprite(targetSpriteId, spriteId, this.ALIGN.CENTER, this.ALIGN.TOP, offset);
},
/**
* 将精灵对齐到目标精灵的底部居中(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度的一半
* 💡 自动获取精灵尺寸,无需手动传入
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteBottomCenter: function(targetSpriteId, spriteId, offset) {
return this.alignSpriteToSprite(targetSpriteId, spriteId, this.ALIGN.CENTER, this.ALIGN.BOTTOM, offset);
},
/**
* 将精灵对齐到目标精灵的中心(快捷方法)
*
* 🔑 注意:精灵的锚点永远在左上角,居中时会自动减去精灵宽度和高度的一半
* 💡 自动获取精灵尺寸,无需手动传入
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteCenter: function(targetSpriteId, spriteId, offset) {
return this.alignSpriteToSprite(targetSpriteId, spriteId, this.ALIGN.CENTER, this.ALIGN.MIDDLE, offset);
},
// ============================================================================
// 精灵ID版本 - 角落对齐
// ============================================================================
/**
* 将精灵的左上角对齐到目标精灵的左上角
*
* 🔑 注意:精灵的锚点永远在左上角
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteCornerTopLeft: function(targetSpriteId, spriteId, offset) {
if (typeof SpriteManager === 'undefined') {
console.error('[AlignmentUtils] SpriteManager 未加载');
return null;
}
var targetPos = SpriteManager.getPosition(targetSpriteId);
var spriteSize = SpriteManager.getSize(spriteId);
if (!targetPos || !spriteSize) {
console.error('[AlignmentUtils] 无法获取精灵位置或大小');
return null;
}
offset = offset || {};
return {
x: targetPos.x + (offset.x || 0),
y: targetPos.y + (offset.y || 0)
};
},
/**
* 将精灵的右上角对齐到目标精灵的右上角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵宽度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteCornerTopRight: function(targetSpriteId, spriteId, offset) {
if (typeof SpriteManager === 'undefined') {
console.error('[AlignmentUtils] SpriteManager 未加载');
return null;
}
var targetPos = SpriteManager.getPosition(targetSpriteId);
var targetSize = SpriteManager.getSize(targetSpriteId);
var spriteSize = SpriteManager.getSize(spriteId);
if (!targetPos || !targetSize || !spriteSize) {
console.error('[AlignmentUtils] 无法获取精灵位置或大小');
return null;
}
offset = offset || {};
return {
x: targetPos.x + targetSize.width - spriteSize.width + (offset.x || 0),
y: targetPos.y + (offset.y || 0)
};
},
/**
* 将精灵的左下角对齐到目标精灵的左下角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵高度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteCornerBottomLeft: function(targetSpriteId, spriteId, offset) {
if (typeof SpriteManager === 'undefined') {
console.error('[AlignmentUtils] SpriteManager 未加载');
return null;
}
var targetPos = SpriteManager.getPosition(targetSpriteId);
var targetSize = SpriteManager.getSize(targetSpriteId);
var spriteSize = SpriteManager.getSize(spriteId);
if (!targetPos || !targetSize || !spriteSize) {
console.error('[AlignmentUtils] 无法获取精灵位置或大小');
return null;
}
offset = offset || {};
return {
x: targetPos.x + (offset.x || 0),
y: targetPos.y + targetSize.height - spriteSize.height + (offset.y || 0)
};
},
/**
* 将精灵的右下角对齐到目标精灵的右下角
*
* 🔑 注意:精灵的锚点永远在左上角,需要减去精灵宽度和高度
* 💡 角落对齐:精灵的角与目标的角重合
*
* @param {number} targetSpriteId - 目标精灵ID
* @param {number} spriteId - 要对齐的精灵ID
* @param {Object} [offset] - 偏移量
* @returns {Object|null} 精灵锚点位置(左上角) {x, y}
*/
alignSpriteCornerBottomRight: function(targetSpriteId, spriteId, offset) {
if (typeof SpriteManager === 'undefined') {
console.error('[AlignmentUtils] SpriteManager 未加载');
return null;
}
var targetPos = SpriteManager.getPosition(targetSpriteId);
var targetSize = SpriteManager.getSize(targetSpriteId);
var spriteSize = SpriteManager.getSize(spriteId);
if (!targetPos || !targetSize || !spriteSize) {
console.error('[AlignmentUtils] 无法获取精灵位置或大小');
return null;
}
offset = offset || {};
return {
x: targetPos.x + targetSize.width - spriteSize.width + (offset.x || 0),
y: targetPos.y + targetSize.height - spriteSize.height + (offset.y || 0)
};
},
// ============================================================================
// 批量精灵分布对齐
// ============================================================================
/**
* 计算多个精灵水平分布的位置
*
* 🔑 核心:精灵的锚点永远在左上角
* 💡 用途:将多个相同大小的精灵水平排列,支持居中/左对齐/右对齐
*
* @param {Object} options - 分布选项
* @param {number} options.anchorX - 锚点X坐标(对齐基准点)
* @param {number} options.anchorY - 锚点Y坐标(所有精灵的Y坐标)
* @param {number} options.count - 精灵数量
* @param {number} options.itemWidth - 每个精灵的宽度
* @param {number} options.itemHeight - 每个精灵的高度
* @param {number} [options.spacing=0] - 精灵之间的间距
* @param {string} [options.anchor='center'] - 对齐方式: 'left'|'center'|'right'
*
* @returns {Array<Object>} 每个精灵的位置数组 [{x, y}, ...]
*
* @example
* // 3张牌水平居中排列
* var positions = AlignmentUtils.distributeHorizontally({
* anchorX: 640, // 屏幕中心X
* anchorY: 500, // Y位置
* count: 3, // 3张牌
* itemWidth: 62, // 牌宽
* itemHeight: 86, // 牌高
* spacing: 0, // 无间距
* anchor: 'center' // 居中对齐
* });
* // 返回: [{x: 547, y: 500}, {x: 609, y: 500}, {x: 671, y: 500}]
*/
distributeHorizontally: function(options) {
var anchorX = options.anchorX || 0;
var anchorY = options.anchorY || 0;
var count = options.count || 0;
var itemWidth = options.itemWidth || 0;
var itemHeight = options.itemHeight || 0;
var spacing = options.spacing || 0;
var anchor = options.anchor || 'center';
if (count <= 0) return [];
// 计算总宽度
var totalWidth = count * itemWidth + (count - 1) * spacing;
// 计算起始X位置(精灵锚点在左上角)
var startX;
switch (anchor) {
case 'left':
// 左对齐:第一个精灵的左边缘与锚点对齐
startX = anchorX;
break;
case 'right':
// 右对齐:最后一个精灵的右边缘与锚点对齐
startX = anchorX - totalWidth;
break;
case 'center':
default:
// 居中:精灵组的中心与锚点对齐
startX = anchorX - totalWidth / 2;
break;
}
// 生成每个精灵的位置
var positions = [];
for (var i = 0; i < count; i++) {
positions.push({
x: startX + i * (itemWidth + spacing),
y: anchorY
});
}
return positions;
},
/**
* 计算多个精灵垂直分布的位置
*
* 🔑 核心:精灵的锚点永远在左上角
* 💡 用途:将多个相同大小的精灵垂直排列,支持居中/顶部对齐/底部对齐
*
* @param {Object} options - 分布选项
* @param {number} options.anchorX - 锚点X坐标(所有精灵的X坐标)
* @param {number} options.anchorY - 锚点Y坐标(对齐基准点)
* @param {number} options.count - 精灵数量
* @param {number} options.itemWidth - 每个精灵的宽度
* @param {number} options.itemHeight - 每个精灵的高度
* @param {number} [options.spacing=0] - 精灵之间的间距
* @param {string} [options.anchor='center'] - 对齐方式: 'top'|'center'|'bottom'
*
* @returns {Array<Object>} 每个精灵的位置数组 [{x, y}, ...]
*
* @example
* // 3张牌垂直居中排列
* var positions = AlignmentUtils.distributeVertically({
* anchorX: 100, // X位置
* anchorY: 360, // 屏幕中心Y
* count: 3, // 3张牌
* itemWidth: 62, // 牌宽
* itemHeight: 86, // 牌高
* spacing: 2, // 2px间距
* anchor: 'center' // 居中对齐
* });
*/
distributeVertically: function(options) {
var anchorX = options.anchorX || 0;
var anchorY = options.anchorY || 0;
var count = options.count || 0;
var itemWidth = options.itemWidth || 0;
var itemHeight = options.itemHeight || 0;
var spacing = options.spacing || 0;
var anchor = options.anchor || 'center';
if (count <= 0) return [];
// 计算总高度
var totalHeight = count * itemHeight + (count - 1) * spacing;
// 计算起始Y位置(精灵锚点在左上角)
var startY;
switch (anchor) {
case 'top':
// 顶部对齐:第一个精灵的顶边与锚点对齐
startY = anchorY;
break;
case 'bottom':
// 底部对齐:最后一个精灵的底边与锚点对齐
startY = anchorY - totalHeight;
break;
case 'center':
default:
// 居中:精灵组的中心与锚点对齐
startY = anchorY - totalHeight / 2;
break;
}
// 生成每个精灵的位置
var positions = [];
for (var i = 0; i < count; i++) {
positions.push({
x: anchorX,
y: startY + i * (itemHeight + spacing)
});
}
return positions;
},
/**
* 计算多个精灵分布的位置(自动选择水平或垂直)
*
* @param {Object} options - 分布选项
* @param {string} [options.direction='horizontal'] - 分布方向: 'horizontal'|'vertical'
* @param {number} options.anchorX - 锚点X坐标
* @param {number} options.anchorY - 锚点Y坐标
* @param {number} options.count - 精灵数量
* @param {number} options.itemWidth - 每个精灵的宽度
* @param {number} options.itemHeight - 每个精灵的高度
* @param {number} [options.spacing=0] - 精灵之间的间距
* @param {string} [options.anchor='center'] - 对齐方式
*
* @returns {Array<Object>} 每个精灵的位置数组 [{x, y}, ...]
*/
distribute: function(options) {
var direction = options.direction || 'horizontal';
if (direction === 'vertical') {
return this.distributeVertically(options);
} else {
return this.distributeHorizontally(options);
}
}
};
// ============================================================================
// 导出
// ============================================================================
if (typeof module !== 'undefined' && module.exports) {
module.exports = AlignmentUtils;
} else if (typeof window !== 'undefined') {
window.AlignmentUtils = AlignmentUtils;
}
@@ -0,0 +1,640 @@
/**
* BaseComponent - UI组件基类
*
* 职责:
* 1. 提供统一的组件生命周期管理 (init/show/hide/destroy)
* 2. 自动管理EventBus事件监听的注册和注销
* 3. 自动管理组件内精灵的创建和销毁
* 4. 提供钩子方法供子类覆盖
*
* 使用方式:
* ```javascript
* var MyComponent = Object.create(BaseComponent);
*
* MyComponent.init = function(config) {
* this.layer = config.layer;
* // 创建精灵
* var spriteId = SpriteManager.createSprite({...});
* this.addSprite(spriteId);
* // 注册事件
* this.addEventListener('eventName', this.onEvent);
* };
*
* var instance = BaseComponent.create({ name: 'MyComponent', layer: 101 });
* instance.show();
* ```
*
* 依赖:
* - EventBus (必须先加载)
* - SpriteManager (必须先加载)
*
* ============================================================================
* @guide 开发指南 - BaseComponent 两种继承模式
* ============================================================================
*
* 【模式一:Object.create(推荐用于复杂视图,多图层组件)】
*
* var GameView = Object.create(BaseComponent);
*
* GameView.init = function(config) {
* // ⚠️ 必须手动重新声明所有实例属性(避免原型污染)
* this.sprites = [];
* this.eventListeners = {};
* this.isVisible = false;
* this.isInitialized = false;
* this.isDestroyed = false;
*
* this.name = config.name || 'GameView';
* this.layer = config.layer || 102; // BaseComponent.show() 需要此属性
* this.baseLayers = [102, 103, 104, 105]; // 多图层组件:show/hide 会操作所有图层
*
* // 注册 EventBus 监听(使用 this.addEventListener 自动管理清理)
* var self = this;
* this.addEventListener(EventBus.Events.GAME_STARTED, function(data) {
* self._onGameStarted(data);
* });
*
* // 注册精灵交互事件(通过 SpriteEventController)
* SpriteEventController.registerMouseUp(sprites.CLOSE_BTN, function(event) {
* self.hide();
* });
*
* this.isInitialized = true;
* };
*
* // 在 UIBootstrap 中创建并使用(注意:直接调用 init,不用 create)
* GameView.init({ name: 'GameView', layer: 102 });
*
* 【模式二:BaseComponent.create(推荐用于简单单图层组件)】
*
* // create 内部会调用 Object.create(BaseComponent) + 调用 init
* var myComp = BaseComponent.create({ name: 'MyComp', layer: 101 });
* myComp.show();
*
* 【何时用 Object.create vs BaseComponent.create】
* - 需要多图层(baseLayers) → Object.create
* - 有大量自定义数据属性(this.data = {}) → Object.create
* - 需要自定义 show/hide 逻辑 → Object.create
* - 简单显隐控制,无额外逻辑 → BaseComponent.create
*
* 【生命周期钩子】
* GameView.onShow = function() { ... }; // show() 时调用
* GameView.onHide = function() { ... }; // hide() 时调用
* GameView.onDestroy = function() { ... }; // destroy() 时调用(清理timer等)
*
* 【destroy 行为说明】
* - 自动调用 EventBus.off 清理所有通过 addEventListener 注册的监听
* - 精灵只隐藏(hide),不删除(gameabc 不支持运行时删除静态精灵)
* - 动态复制的精灵(SpriteCopyUtils)需在 onDestroy 中手动 remove
*
* 【模式三:IIFE 包裹 + 自定义 create 工厂(RoomView 模式)】
*
* var RoomView = (function() {
* 'use strict';
* var RoomView = Object.create(BaseComponent);
*
* RoomView.init = function(config) {
* // 同样需要手动重声明实例属性
* this.sprites = [];
* this.eventListeners = {};
* this.isVisible = false;
* this.isInitialized = false;
* this.isDestroyed = false;
* this.name = config.name;
* this.layer = config.layer;
* };
*
* // 自定义工厂方法(代替 BaseComponent.create,用于多步配置前创建实例)
* RoomView.create = function(config) {
* var instance = Object.create(RoomView);
* config = config || {};
* config.layer = ROOM_VIEW.LAYER; // 固定图层,外部无需传
* // 显式调用 BaseComponent._init,内部会自动调用 instance.init(config)
* BaseComponent._init.call(instance, config);
* return instance;
* };
*
* return RoomView;
* })();
*
* // 使用:通过工厂创建实例
* var roomView = RoomView.create({ name: 'RoomView' });
* roomView.show();
*
* 【super 方法调用(扩展父类方法时)】
*
* RoomView.getState = function() {
* // 调用 BaseComponent.getState 获取基础状态,再添加子类特有字段
* var state = BaseComponent.getState.call(this);
* state.hasRoomData = !!this.roomData;
* state.playerCount = this._getPlayerCount();
* return state;
* };
*
* 【show/hide 钩子(GameView 中使用的扩展钩子)】
*
* // 除了 onShow/onHide/onDestroy,GameView 还使用了更细粒度的钩子:
* GameView.onBeforeShow = function() { ... }; // show() 执行群组操作之前
* GameView.onAfterShow = function() { ... }; // show() 执行群组操作之后
* GameView.onBeforeHide = function() { ... }; // hide() 执行群组操作之前
* GameView.onAfterHide = function() { ... }; // hide() 执行群组操作之后
* // 这些钩子在 show()/hide() 内部通过 if (this.onAfterShow) this.onAfterShow() 调用
*
* @version 1.0.0
* @date 2025-11-19
*/
var BaseComponent = (function() {
'use strict';
// ==================== 组件基类定义 ====================
var BaseComponentProto = {
/**
* 组件名称
* @type {String}
*/
name: '',
/**
* 组件所在图层
* @type {Number}
*/
layer: 0,
/**
* 组件拥有的精灵ID列表
* @type {Array<Number>}
*/
sprites: null,
/**
* 组件注册的事件监听器
* 结构: { eventName: wrappedHandler }
* @type {Object}
*/
eventListeners: null,
/**
* 组件是否可见
* @type {Boolean}
*/
isVisible: false,
/**
* 组件是否已初始化
* @type {Boolean}
*/
isInitialized: false,
/**
* 组件是否已销毁
* @type {Boolean}
*/
isDestroyed: false,
/**
* 组件配置
* @type {Object}
*/
config: null,
// ==================== 内部方法 ====================
/**
* 内部初始化方法
* @param {Object} config - 配置对象
* @param {String} config.name - 组件名称
* @param {Number} config.layer - 图层ID
* @private
*/
_init: function(config) {
if (this.isInitialized) {
console.warn('⚠️ BaseComponent: 组件已初始化,跳过重复初始化:', this.name);
return;
}
// 验证必需参数
if (!config || typeof config !== 'object') {
console.error('❌ BaseComponent._init: config必须是对象');
return;
}
if (!config.name) {
console.error('❌ BaseComponent._init: 缺少name参数');
return;
}
if (typeof config.layer !== 'number') {
console.error('❌ BaseComponent._init: 缺少layer参数或类型错误');
return;
}
// 初始化属性
this.name = config.name;
this.layer = config.layer;
this.config = config;
this.sprites = [];
this.eventListeners = {};
this.isVisible = false;
this.isDestroyed = false;
this.isInitialized = true;
console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 调用子类的init方法
try {
this.init(config);
} catch (error) {
console.error('❌ BaseComponent: 组件初始化时发生异常:', error);
this.isInitialized = false;
}
},
/**
* 移除所有事件监听器
* @private
*/
_removeAllEventListeners: function() {
for (var eventName in this.eventListeners) {
if (this.eventListeners.hasOwnProperty(eventName)) {
this.removeEventListener(eventName);
}
}
},
/**
* 隐藏所有精灵
* 注意: 精灵由编辑器创建,组件销毁时只隐藏精灵,不删除精灵
* @private
*/
_hideAllSprites: function() {
if (!this.sprites || this.sprites.length === 0) {
return;
}
console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" 的所有精灵 (数量: ' + this.sprites.length + ')');
for (var i = 0; i < this.sprites.length; i++) {
var spriteId = this.sprites[i];
SpriteManager.hide(spriteId);
}
this.sprites = [];
},
// ==================== 生命周期方法 ====================
/**
* 初始化方法 - 子类覆盖
* 在此方法中创建精灵、注册事件监听
* @param {Object} config - 配置对象
*/
init: function(config) {
// 子类覆盖此方法
console.log('📝 BaseComponent.init: 请在子类中覆盖此方法');
},
/**
* 显示组件
* @returns {Boolean} 是否成功
*/
show: function() {
if (this.isDestroyed) {
console.error('❌ BaseComponent.show: 组件已销毁,无法显示:', this.name);
return false;
}
if (!this.isInitialized) {
console.error('❌ BaseComponent.show: 组件未初始化,无法显示:', this.name);
return false;
}
if (this.isVisible) {
console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name);
return true;
}
console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 显示图层
SpriteManager.showGroup(this.layer);
this.isVisible = true;
// 调用钩子方法
try {
this.onShow();
} catch (error) {
console.error('❌ BaseComponent.show: onShow钩子执行时发生异常:', error);
}
return true;
},
/**
* 隐藏组件
* @returns {Boolean} 是否成功
*/
hide: function() {
if (this.isDestroyed) {
console.error('❌ BaseComponent.hide: 组件已销毁:', this.name);
return false;
}
if (!this.isVisible) {
console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name);
return true;
}
console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 隐藏图层
SpriteManager.hideGroup(this.layer);
this.isVisible = false;
// 调用钩子方法
try {
this.onHide();
} catch (error) {
console.error('❌ BaseComponent.hide: onHide钩子执行时发生异常:', error);
}
return true;
},
/**
* 销毁组件
* @returns {Boolean} 是否成功
*/
destroy: function() {
if (this.isDestroyed) {
console.warn('⚠️ BaseComponent.destroy: 组件已销毁,跳过:', this.name);
return false;
}
console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"');
// 移除所有事件监听
this._removeAllEventListeners();
// 隐藏所有精灵(精灵由编辑器创建,不删除)
this._hideAllSprites();
// 隐藏图层
if (this.isVisible) {
SpriteManager.hideGroup(this.layer);
}
// 调用钩子方法
try {
this.onDestroy();
} catch (error) {
console.error('❌ BaseComponent.destroy: onDestroy钩子执行时发生异常:', error);
}
// 标记为已销毁
this.isDestroyed = true;
this.isVisible = false;
this.isInitialized = false;
return true;
},
// ==================== 事件监听管理 ====================
/**
* 添加事件监听器
* @param {String} eventName - 事件名称
* @param {Function} handler - 事件处理函数
* @returns {Boolean} 是否成功
*/
addEventListener: function(eventName, handler) {
if (!eventName || typeof eventName !== 'string') {
console.error('❌ BaseComponent.addEventListener: eventName必须是字符串');
return false;
}
if (typeof handler !== 'function') {
console.error('❌ BaseComponent.addEventListener: handler必须是函数');
return false;
}
if (this.eventListeners[eventName]) {
console.warn('⚠️ BaseComponent.addEventListener: 事件已注册,将覆盖:', eventName);
this.removeEventListener(eventName);
}
// 包装处理函数,绑定this上下文
var self = this;
var wrappedHandler = function(data) {
if (self.isDestroyed) {
console.warn('⚠️ BaseComponent: 组件已销毁,忽略事件:', eventName);
return;
}
try {
handler.call(self, data);
} catch (error) {
console.error('❌ BaseComponent: 事件处理函数执行异常:', eventName, error);
}
};
// 注册到EventBus
EventBus.on(eventName, wrappedHandler);
// 记录监听器
this.eventListeners[eventName] = wrappedHandler;
console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName);
return true;
},
/**
* 移除事件监听器
* @param {String} eventName - 事件名称
* @returns {Boolean} 是否成功
*/
removeEventListener: function(eventName) {
if (!this.eventListeners[eventName]) {
console.warn('⚠️ BaseComponent.removeEventListener: 事件未注册:', eventName);
return false;
}
var handler = this.eventListeners[eventName];
EventBus.off(eventName, handler);
delete this.eventListeners[eventName];
console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName);
return true;
},
/**
* 触发事件(发送到EventBus)
* @param {String} eventName - 事件名称
* @param {*} data - 事件数据
*/
emit: function(eventName, data) {
if (!eventName || typeof eventName !== 'string') {
console.error('❌ BaseComponent.emit: eventName必须是字符串');
return;
}
console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName);
EventBus.emit(eventName, data);
},
// ==================== 精灵管理 ====================
/**
* 添加精灵到组件管理列表
* @param {Number} spriteId - 精灵ID
* @returns {Boolean} 是否成功
*/
addSprite: function(spriteId) {
if (typeof spriteId !== 'number') {
console.error('❌ BaseComponent.addSprite: spriteId必须是数字');
return false;
}
if (!SpriteManager.exists(spriteId)) {
console.error('❌ BaseComponent.addSprite: 精灵不存在:', spriteId);
return false;
}
if (this.sprites.indexOf(spriteId) !== -1) {
console.warn('⚠️ BaseComponent.addSprite: 精灵已在管理列表中:', spriteId);
return false;
}
this.sprites.push(spriteId);
// console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId);
return true;
},
/**
* 从组件管理列表移除精灵(不销毁)
* @param {Number} spriteId - 精灵ID
* @returns {Boolean} 是否成功
*/
removeSprite: function(spriteId) {
var index = this.sprites.indexOf(spriteId);
if (index === -1) {
console.warn('⚠️ BaseComponent.removeSprite: 精灵不在管理列表中:', spriteId);
return false;
}
this.sprites.splice(index, 1);
console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId);
return true;
},
/**
* 获取组件管理的所有精灵ID
* @returns {Array<Number>} 精灵ID数组
*/
getSprites: function() {
return this.sprites.slice(); // 返回副本
},
/**
* 获取组件管理的精灵数量
* @returns {Number} 精灵数量
*/
getSpriteCount: function() {
return this.sprites.length;
},
// ==================== 钩子方法 ====================
/**
* 显示时的钩子方法 - 子类可覆盖
*/
onShow: function() {
// 子类可以覆盖此方法
},
/**
* 隐藏时的钩子方法 - 子类可覆盖
*/
onHide: function() {
// 子类可以覆盖此方法
},
/**
* 销毁时的钩子方法 - 子类可覆盖
*/
onDestroy: function() {
// 子类可以覆盖此方法
},
// ==================== 工具方法 ====================
/**
* 获取组件状态信息
* @returns {Object} 状态对象
*/
getState: function() {
return {
name: this.name,
layer: this.layer,
isVisible: this.isVisible,
isInitialized: this.isInitialized,
isDestroyed: this.isDestroyed,
spriteCount: this.sprites.length,
eventCount: Object.keys(this.eventListeners).length
};
},
/**
* 打印组件状态(调试用)
*/
printState: function() {
var state = this.getState();
console.log('📊 组件状态:', JSON.stringify(state, null, 2));
}
};
// ==================== 工厂方法 ====================
/**
* 创建组件实例
* @param {Object} config - 配置对象
* @param {String} config.name - 组件名称
* @param {Number} config.layer - 图层ID
* @returns {Object} 组件实例
*/
BaseComponentProto.create = function(config) {
if (!config || typeof config !== 'object') {
console.error('❌ BaseComponent.create: config必须是对象');
return null;
}
// 创建实例
var instance = Object.create(this);
// 初始化实例
instance._init(config);
if (!instance.isInitialized) {
console.error('❌ BaseComponent.create: 组件初始化失败');
return null;
}
return instance;
};
// 返回基类原型
return BaseComponentProto;
})();
// 导出到全局(兼容ES5)
if (typeof module !== 'undefined' && module.exports) {
module.exports = BaseComponent;
}
@@ -0,0 +1,830 @@
/**
* ============================================================================
* DynamicSpriteList.js - 动态精灵列表工具
* ============================================================================
*
* 基于 gameabc 框架的精灵复制功能,提供动态列表创建、管理和交互的完整解决方案。
*
* 核心功能:
* - 动态创建/删除精灵列表
* - 自动滑动和边界回弹
* - 点击检测和事件分发
* - 裁剪区域管理
*
* 适用场景:
* - 小局结算列表
* - 大局结算列表
* - 听牌提示列表
* - 战绩列表
* - 排行榜
*
* 使用示例:
* ```javascript
* // 1. 创建列表实例
* var myList = new DynamicSpriteList({
* containerId: 100, // 父精灵ID(容器)
* clipArea: { x: 50, y: 100, width: 600, height: 400 },
* rowHeight: 80,
* templates: {
* background: { spriteId: 101 },
* title: { spriteId: 102, offset: { x: 20, y: 10 } },
* button: { spriteId: 103, offset: { x: 500, y: 20 } }
* }
* });
*
* // 2. 设置数据并渲染
* myList.setData([
* { title: '第1行', buttonText: '查看' },
* { title: '第2行', buttonText: '查看' }
* ]);
*
* // 3. 监听点击事件
* myList.onClick = function(type, index, data) {
* console.log('点击了', type, '第', index, '行');
* };
*
* // 4. 在事件处理函数中调用
* // mousedown: myList.handleMouseDown(spid, x, y);
* // mousemove: myList.handleMouseMove(spid, offsetY);
* // mouseup: myList.handleMouseUp(spid, x, y);
* // drawbegin: myList.handleDrawBegin(spid);
*
* // 5. 销毁
* myList.destroy();
* ```
*
* ============================================================================
* @guide 开发指南 - DynamicSpriteList 完整使用规范
* ============================================================================
*
* 【准备工作:在编辑器中预置精灵】
* 1. 在编辑器中放置一个容器精灵(作为 containerId)
* 2. 在容器精灵旁边放置各列模板精灵(通常隐藏):
* - 行背景模板(每行底色/分隔线)
* - 数据列模板(牌图、分数、数量等,每种一个)
* 3. 记录这些模板精灵的 ID,用于配置 templates
*
* 【初始化列表(听牌提示列表示例)】
* var tingList = new DynamicSpriteList({
* containerId: sprites.LIST_CONTAINER, // 容器精灵
* clipArea: {
* x: 0, y: 0, // 相对容器左上角
* width: 640, height: 480 // 可见区域尺寸
* },
* rowHeight: 80, // 每行高度(像素)
* templates: {
* // 每列都需要一个 spriteId(模板)
* rowBg: { spriteId: sprites.ROW_TEMPLATE },
* card: { spriteId: sprites.CARD_TEMPLATE, offset: { x: 20, y: 10 } },
* score: { spriteId: sprites.SCORE_TEMPLATE, offset: { x: 120, y: 10 } },
* jingBadge: { spriteId: sprites.JING_BADGE, offset: { x: 90, y: 5 } },
* remainBadge: { spriteId: sprites.REMAIN_BADGE, offset: { x: 200, y: 5 } },
* rowSeparator: { spriteId: sprites.ROW_SEPARATOR }
* },
* scrollSensitivity: 5, // 移动5px以上才触发滚动
* bounceSpeed: 1 // 回弹速度
* });
*
* 【设置数据(触发渲染)】
* // 数据数组中每个元素对应一行
* // setData 会清除旧行、重新创建新行
* tingList.setData(tingHints); // tingHints = [{ code, score, remain, isJing }, ...]
*
* 【监听点击(在 setData 之前或之后设置均可)】
* tingList.onClick = function(type, rowIndex, rowData) {
* // type: 模板键名,如 'card'、'score'
* // rowIndex: 第几行(0-based)
* // rowData: 该行对应的数据对象
* if (type === 'card') {
* self._onCardClick(rowData);
* }
* };
*
* 【接入 SpriteEventController 事件(必须接入,否则列表不响应交互)】
* var self = this;
* // 在 init 中注册容器精灵的鼠标事件
* SpriteEventController.registerMouseDown(sprites.LIST_CONTAINER, function(event) {
* tingList.handleMouseDown(event.spid, event.offsetX, event.offsetY);
* });
* SpriteEventController.registerMouseMove(sprites.LIST_CONTAINER, function(event) {
* tingList.handleMouseMove(event.spid, event.offsetY);
* });
* SpriteEventController.registerMouseUp(sprites.LIST_CONTAINER, function(event) {
* tingList.handleMouseUp(event.spid, event.offsetX, event.offsetY);
* });
* SpriteEventController.registerDrawBegin(sprites.LIST_CONTAINER, function(event) {
* tingList.handleDrawBegin(event.spid);
* });
*
* 【销毁(组件关闭/destroy时必须调用)】
* tingList.destroy(); // 清除所有复制精灵,解除事件绑定
*
* 【注意事项】
* - destroy() 必须在组件 onDestroy/hide 时调用,否则复制精灵会残留
* - 每次 setData 会完全重建行,不要频繁调用(建议数据变化时才调用)
* - templates 中的 spriteId 是编辑器中预置的精灵ID,不是复制出来的ID
* - 如果列表不需要滚动,设置 enableScroll: false 可提升性能
*
* @author JinXian Team
* @version 1.0.0
* @since 2025-12-13
*/
'use strict';
// ============================================================================
// DynamicSpriteList 类定义
// ============================================================================
/**
* 动态精灵列表构造函数
*
* @constructor
* @param {Object} config - 列表配置对象
* @param {number} config.containerId - 父精灵ID(容器精灵,所有复制精灵将附着在此精灵上)
* @param {Object} config.clipArea - 裁剪区域配置(可见区域)
* @param {number} config.clipArea.x - 裁剪区域左上角X坐标
* @param {number} config.clipArea.y - 裁剪区域左上角Y坐标
* @param {number} config.clipArea.width - 裁剪区域宽度
* @param {number} config.clipArea.height - 裁剪区域高度
* @param {number} config.rowHeight - 每行高度(像素)
* @param {Object} config.templates - 模板精灵配置
* @param {number} [config.scrollSensitivity=2] - 滑动灵敏度(移动多少像素触发滑动)
* @param {number} [config.bounceSpeed=1] - 回弹动画速度
* @param {boolean} [config.enableScroll=true] - 是否启用滚动
* @param {boolean} [config.enableClick=true] - 是否启用点击
*
* @example
* // 模板配置示例
* templates: {
* // 行背景 - 必须有
* background: {
* spriteId: 101, // 模板精灵ID
* offset: { x: 0, y: 0 } // 相对行起点的偏移(可选,默认0,0)
* },
* // 文字模板
* title: {
* spriteId: 102,
* offset: { x: 20, y: 15 },
* textProperty: 'title' // 对应数据中的属性名
* },
* // 按钮模板
* button: {
* spriteId: 103,
* offset: { x: 500, y: 20 },
* clickable: true // 标记为可点击
* }
* }
*/
function DynamicSpriteList(config) {
// ========================================================================
// 参数验证
// ========================================================================
if (!config) {
throw new Error('DynamicSpriteList: config is required');
}
if (typeof config.containerId !== 'number') {
throw new Error('DynamicSpriteList: containerId must be a number');
}
if (!config.clipArea) {
throw new Error('DynamicSpriteList: clipArea is required');
}
if (typeof config.rowHeight !== 'number' || config.rowHeight <= 0) {
throw new Error('DynamicSpriteList: rowHeight must be a positive number');
}
var hasTemplates = config.templates && typeof config.templates === 'object';
var hasRenderRow = typeof config.renderRow === 'function';
if (!hasTemplates && !hasRenderRow) {
throw new Error('DynamicSpriteList: templates or renderRow is required');
}
// ========================================================================
// 实例属性初始化
// ========================================================================
/**
* 容器精灵ID
* @type {number}
*/
this.containerId = config.containerId;
/**
* 裁剪区域配置
* @type {Object}
*/
this.clipArea = {
x: config.clipArea.x || 0,
y: config.clipArea.y || 0,
width: config.clipArea.width || 0,
height: config.clipArea.height || 0
};
/**
* 每行高度
* @type {number}
*/
this.rowHeight = config.rowHeight;
/**
* 模板精灵配置
* @type {Object}
*/
this.templates = config.templates || null;
this.renderRow = config.renderRow || null;
/**
* 滑动灵敏度
* @type {number}
*/
this.scrollSensitivity = config.scrollSensitivity || 2;
/**
* 回弹动画速度
* @type {number}
*/
this.bounceSpeed = config.bounceSpeed || 1;
/**
* 是否启用滚动
* @type {boolean}
*/
this.enableScroll = config.enableScroll !== false;
/**
* 是否启用点击
* @type {boolean}
*/
this.enableClick = config.enableClick !== false;
// ========================================================================
// 运行时状态
// ========================================================================
/**
* 当前数据
* @type {Array}
* @private
*/
this._data = [];
/**
* 是否正在滑动
* @type {boolean}
* @private
*/
this._isSliding = false;
/**
* 是否已渲染
* @type {boolean}
* @private
*/
this._isRendered = false;
/**
* 已创建的精灵记录 (用于删除)
* @type {Array}
* @private
*/
this._createdSprites = [];
this._tagIndex = {};
this._nextTag = 1;
// ========================================================================
// 事件回调
// ========================================================================
/**
* 点击事件回调
* @type {Function|null}
* @param {string} templateName - 点击的模板类型名称
* @param {number} rowIndex - 行索引(从0开始)
* @param {Object} rowData - 该行的数据对象
*
* @example
* list.onClick = function(templateName, rowIndex, rowData) {
* if (templateName === 'button') {
* console.log('点击了第', rowIndex, '行的按钮');
* console.log('数据:', rowData);
* }
* };
*/
this.onClick = null;
/**
* 滚动事件回调
* @type {Function|null}
* @param {number} scrollY - 当前滚动位置
* @param {number} maxScrollY - 最大滚动位置
*
* @example
* list.onScroll = function(scrollY, maxScrollY) {
* var progress = scrollY / maxScrollY;
* console.log('滚动进度:', progress);
* };
*/
this.onScroll = null;
}
// ============================================================================
// 公共方法
// ============================================================================
/**
* 设置数据并渲染列表
*
* @param {Array} data - 数据数组,每个元素对应一行
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*
* @example
* list.setData([
* { title: '战绩1', score: '+100', time: '12:30' },
* { title: '战绩2', score: '-50', time: '12:25' },
* { title: '战绩3', score: '+200', time: '12:20' }
* ]);
*/
DynamicSpriteList.prototype.setData = function(data) {
if (!Array.isArray(data)) {
console.error('DynamicSpriteList.setData: data must be an array');
return this;
}
// 先清除旧内容
this.clear();
// 保存数据
this._data = data;
// 渲染列表
this._render();
return this;
};
/**
* 获取当前数据
*
* @returns {Array} 当前数据数组的副本
*/
DynamicSpriteList.prototype.getData = function() {
return this._data.slice();
};
/**
* 获取数据行数
*
* @returns {number} 数据行数
*/
DynamicSpriteList.prototype.getRowCount = function() {
return this._data.length;
};
/**
* 显示列表
*
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.show = function() {
set_self(this.containerId, 37, 1, 0, 0);
return this;
};
/**
* 隐藏列表
*
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.hide = function() {
set_self(this.containerId, 37, 0, 0, 0);
return this;
};
/**
* 清除所有动态创建的精灵
*
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.clear = function() {
for (var i = 0; i < this._createdSprites.length; i++) {
ifast_dllpritefromspritecopy(this.containerId, this._createdSprites[i].tag);
}
this._createdSprites = [];
this._tagIndex = {};
this._nextTag = 1;
this._isRendered = false;
this._isSliding = false;
return this;
};
/**
* 销毁列表实例
* 释放所有资源,清除所有精灵
*/
DynamicSpriteList.prototype.destroy = function() {
this.clear();
this.hide();
this._data = [];
this.onClick = null;
this.onScroll = null;
};
/**
* 滚动到指定位置
*
* @param {number} y - 目标Y坐标
* @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollTo = function(y, animated) {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var targetY = this.clipArea.y - y;
// 边界限制
var contentHeight = this._data.length * this.rowHeight;
var minY = this.clipArea.y + this.clipArea.height - contentHeight;
if (targetY < minY) {
targetY = minY;
}
if (targetY > this.clipArea.y) {
targetY = this.clipArea.y;
}
if (animated !== false) {
play_ani(1, this.containerId, 19, currentY, targetY, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
} else {
set_self(this.containerId, 19, targetY, 0, 0, 0);
}
return this;
};
/**
* 滚动到顶部
*
* @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollToTop = function(animated) {
return this.scrollTo(0, animated);
};
/**
* 滚动到底部
*
* @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollToBottom = function(animated) {
var contentHeight = this._data.length * this.rowHeight;
var maxScroll = contentHeight - this.clipArea.height;
if (maxScroll < 0) {
maxScroll = 0;
}
return this.scrollTo(maxScroll, animated);
};
/**
* 滚动到指定行
*
* @param {number} rowIndex - 行索引(从0开始)
* @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollToRow = function(rowIndex, animated) {
var y = rowIndex * this.rowHeight;
return this.scrollTo(y, animated);
};
// ============================================================================
// 事件处理方法 - 需要在对应的事件回调中调用
// ============================================================================
/**
* 处理鼠标/触摸按下事件
* 需要在 utlmousedown 回调中调用
*
* @param {number} spid - 按下的精灵ID
* @param {number} x - 按下位置X坐标
* @param {number} y - 按下位置Y坐标
* @returns {boolean} 如果事件被处理返回 true
*
* @example
* // 在 SpriteEventController 或 Game_Modify 中
* utlmousedown: function(gameid, spid, downx, downy) {
* if (myList.handleMouseDown(spid, downx, downy)) {
* return; // 事件已处理
* }
* // 其他处理...
* }
*/
DynamicSpriteList.prototype.handleMouseDown = function(spid, x, y) {
if (spid === this.containerId) {
this._isSliding = false;
return true;
}
return false;
};
/**
* 处理鼠标/触摸移动事件
* 需要在 utlmousemove 回调中调用
*
* @param {number} spid - 移动的精灵ID
* @param {number} offsetY - Y方向移动偏移量
* @returns {boolean} 如果事件被处理返回 true
*
* @example
* // 在 SpriteEventController 或 Game_Modify 中
* utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
* if (myList.handleMouseMove(spid, offmovey)) {
* return; // 事件已处理
* }
* // 其他处理...
* }
*/
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetY) {
if (!this.enableScroll) {
return false;
}
if (spid === this.containerId) {
if (Math.abs(offsetY) > this.scrollSensitivity) {
// 增量移动容器
set_self(this.containerId, 19, offsetY, 1, 0, 0);
this._isSliding = true;
// 触发滚动回调
if (typeof this.onScroll === 'function') {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var scrollY = this.clipArea.y - currentY;
var contentHeight = this._data.length * this.rowHeight;
var maxScrollY = contentHeight - this.clipArea.height;
this.onScroll(scrollY, maxScrollY > 0 ? maxScrollY : 0);
}
}
return true;
}
return false;
};
/**
* 处理鼠标/触摸松开事件
* 需要在 mouseup 回调中调用
*
* @param {number} spidDown - 按下时的精灵ID
* @param {number} spidUp - 松开时的精灵ID
* @param {number} upX - 松开位置X坐标
* @param {number} upY - 松开位置Y坐标
* @returns {boolean} 如果事件被处理返回 true
*
* @example
* // 在 SpriteEventController 或 Game_Modify 中
* mouseup: function(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong) {
* if (myList.handleMouseUp(spid_down, spid_up, upx, upy)) {
* return; // 事件已处理
* }
* // 其他处理...
* }
*/
DynamicSpriteList.prototype.handleMouseUp = function(spidDown, spidUp, upX, upY) {
if (spidDown !== this.containerId) {
return false;
}
// 处理边界回弹
this._handleBounce();
// 处理点击(非滑动状态)
if (!this._isSliding && this.enableClick && spidDown === spidUp) {
this._handleClick(upX, upY);
}
return true;
};
/**
* 处理绘制开始事件
* 需要在 utlgamemydrawbegin 回调中调用,用于设置裁剪区域
*
* @param {number} spid - 正在绘制的精灵ID
* @returns {boolean} 如果事件被处理返回 true
*
* @example
* // 在 SpriteEventController 或 Game_Modify 中
* utlgamemydrawbegin: function(gameid, spid, times, timelong) {
* if (myList.handleDrawBegin(spid)) {
* return; // 已设置裁剪区域
* }
* // 其他处理...
* }
*/
DynamicSpriteList.prototype.handleDrawBegin = function(spid) {
if (spid === this.containerId) {
set_clip(0, 0,
this.clipArea.x,
this.clipArea.y,
this.clipArea.width,
this.clipArea.height
);
return true;
}
return false;
};
// ============================================================================
// 私有方法
// ============================================================================
/**
* 内部创建精灵并登记到 _tagIndex / _createdSprites
* @param {string} name - 精灵名称(模板键名或自定义名)
* @param {number} templateSpriteId - 模板精灵 ID
* @param {number} x - X 坐标
* @param {number} y - Y 坐标(已叠加 rowBaseY)
* @param {number} rowIndex - 所在行索引
* @param {boolean} clickable - 是否可点击
* @returns {*} 创建出的精灵 ID
* @private
*/
DynamicSpriteList.prototype._addSpriteInternal = function (name, templateSpriteId, x, y, rowIndex, clickable) {
var tag = this._nextTag++;
var spriteId = ifast_addtospritefromspritecopy(this.containerId, templateSpriteId, x, y, tag);
var rec = { tag: tag, spriteId: spriteId, rowIndex: rowIndex, name: name, clickable: (clickable !== false) };
this._createdSprites.push(rec);
this._tagIndex[tag] = rec;
return spriteId;
};
/**
* 渲染列表
* @private
*/
DynamicSpriteList.prototype._render = function() {
set_self(this.containerId, 18, this.clipArea.x, 0, 0);
set_self(this.containerId, 19, this.clipArea.y, 0, 0);
var contentHeight = this._data.length * this.rowHeight;
set_self(this.containerId, 21, contentHeight, 0, 0);
if (this.templates) {
var tnames = Object.keys(this.templates);
if (tnames.length > 0) {
var w = get_self(this.templates[tnames[0]].spriteId, 20, 0, 0, 0);
set_self(this.containerId, 20, w, 0, 0);
}
} else {
// renderRow 模式:容器宽度取裁剪区宽,保证整行可视宽都在容器 hit 区(点击/拖动检测)内。
// 否则容器保持编辑器预置窄宽度,超出部分点击/拖动报的 spid≠containerId 而失效(对账 bak set_self(fSpid,20,bg宽))。
set_self(this.containerId, 20, this.clipArea.width, 0, 0);
}
for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) {
var rowData = this._data[rowIndex];
var rowY = rowIndex * this.rowHeight;
if (this.renderRow) {
this.renderRow(this._makeRowContext(rowIndex, rowData, rowY));
} else {
var names = Object.keys(this.templates);
for (var i = 0; i < names.length; i++) {
var name = names[i];
var t = this.templates[name];
var ox = (t.offset && t.offset.x) || 0;
var oy = (t.offset && t.offset.y) || 0;
var sid = this._addSpriteInternal(name, t.spriteId, ox, rowY + oy, rowIndex, t.clickable !== false);
if (t.textProperty && rowData[t.textProperty] !== undefined) {
set_self(sid, 7, rowData[t.textProperty], 0, 0);
}
if (t.frameProperty && rowData[t.frameProperty] !== undefined) {
set_self(sid, 43, rowData[t.frameProperty], 0, 0);
}
}
}
}
this._isRendered = true;
};
/**
* 创建行上下文对象(传给 renderRow 回调)
* @param {number} rowIndex - 行索引
* @param {Object} rowData - 行数据
* @param {number} rowBaseY - 行基准 Y 坐标
* @returns {Object} 行上下文
* @private
*/
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBaseY) {
var self = this;
return {
rowIndex: rowIndex,
rowData: rowData,
rowBaseY: rowBaseY,
containerId: this.containerId,
addSprite: function (name, templateSpriteId, x, y) {
return self._addSpriteInternal(name, templateSpriteId, x, rowBaseY + y, rowIndex, true);
}
};
};
/**
* 处理边界回弹
* @private
*/
DynamicSpriteList.prototype._handleBounce = function() {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var contentHeight = get_self(this.containerId, 21, 0, 0, 0);
var clipY = this.clipArea.y;
var clipH = this.clipArea.height;
var targetY = currentY;
var needBounce = false;
if (contentHeight <= clipH) {
// 内容不足一屏,回弹到顶部
if (currentY !== clipY) {
targetY = clipY;
needBounce = true;
}
} else {
if (currentY > clipY) {
// 超出顶部
targetY = clipY;
needBounce = true;
} else {
var minY = clipY + clipH - contentHeight;
if (currentY < minY) {
// 超出底部
targetY = minY;
needBounce = true;
}
}
}
if (needBounce) {
play_ani(1, this.containerId, 19, currentY, targetY, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
}
};
/**
* 处理点击事件
* @param {number} x - 点击X坐标
* @param {number} y - 点击Y坐标
* @private
*/
DynamicSpriteList.prototype._handleClick = function(x, y) {
var clickedTag = ifast_check_add(this.containerId, x, y);
if (clickedTag === -99999999) { return; }
var rec = this._tagIndex[clickedTag];
if (!rec || rec.clickable === false) { return; }
if (typeof this.onClick === 'function' && rec.rowIndex < this._data.length) {
this.onClick(rec.name, rec.rowIndex, this._data[rec.rowIndex]);
}
};
// ============================================================================
// 静态工具方法
// ============================================================================
/**
* 创建简单列表的快捷方法
*
* @static
* @param {Object} options - 配置选项
* @param {number} options.containerId - 容器精灵ID
* @param {number} options.itemTemplateId - 列表项模板精灵ID
* @param {Object} options.clipArea - 裁剪区域
* @param {number} options.rowHeight - 行高
* @returns {DynamicSpriteList} 列表实例
*
* @example
* var simpleList = DynamicSpriteList.createSimple({
* containerId: 100,
* itemTemplateId: 101,
* clipArea: { x: 50, y: 100, width: 600, height: 400 },
* rowHeight: 60
* });
*/
DynamicSpriteList.createSimple = function(options) {
return new DynamicSpriteList({
containerId: options.containerId,
clipArea: options.clipArea,
rowHeight: options.rowHeight,
templates: {
item: {
spriteId: options.itemTemplateId,
offset: { x: 0, y: 0 }
}
}
});
};
// ============================================================================
// 导出
// ============================================================================
// 兼容多种模块系统
if (typeof module !== 'undefined' && module.exports) {
module.exports = DynamicSpriteList;
} else if (typeof window !== 'undefined') {
window.DynamicSpriteList = DynamicSpriteList;
}
@@ -0,0 +1,395 @@
// ============================================================================
// RecordView.js —— 通用平台战绩页组件(游戏中立;逻辑零硬编码 ID,全读 config)
// 用框架工具复刻 gameCombat:页一大局列表/页二小局明细/滚动/页面切换/选中进回放。
// 数据收发与回放由子游戏注入:setGradeData(...) / onSelectRound / onRequestData。
// 依赖:DynamicSpriteList, SpriteEventController, SpriteManager。严格 ES5。
// ============================================================================
//
// ---------------------------------------------------------------------------
// 战绩数据结构(setGradeData 传入的 gradeInfo —— 字段访问的权威说明)
// ---------------------------------------------------------------------------
// gradeInfo : CombatInfo[] 每个元素 = 一个「大局」战绩
//
// CombatInfo[i] = {
// gameinfo1 : Object|String, // 战绩主体(可能是 JSON 字符串,渲染时先 JSON.parse)
// roomcode : String|Number, // 房号 (页一显示)
// overtime : String, // 结束时间 (页一显示)
// idx : Number, // 战绩索引(翻页/拉取定位用)
// roomtype : String // 房间类型
// }
//
// gameinfo1 = {
// roundsum : Number, // 小局总数(= round.length) (页一“局数”)
// playerlist : Array, // 玩家列表,下标 j = 座位号
// // playerlist[j] = [ 昵称:String, 积分:Number, 头像url:String ] ← 3 元素
// // · 空座位可能为 falsy → 渲染时跳过(if(!pl[j]) continue)
// // · 注:服务端存储侧 o_gameinfo1 为 4 元素 [昵称,积分(String),头像,playerid],
// // 与此“战绩查询返回”格式不同;本组件只用 [0]昵称 / [1]积分,差异无影响。
// round : Array // 小局明细,round[i] = 第 i 个小局的逐座位计分
// // round[i] = [ [座位号:Number, 该小局得分:Number], ... ]
// // · 例:round[0] = [[0,-6],[1,-8],[2,-6],[3,20]] → 座位0得-6 … 座位3得+20
// }
//
// 页一(大局列表)用:overtime / roomcode / roundsum / playerlist(昵称+积分)
// 页二(小局明细)用:round(每行=一小局)+ playerlist(昵称) + round[i] 按座位匹配该局得分
// (roundData[k][0]==座位 j → roundData[k][1] 为该玩家该小局得分)
// ---------------------------------------------------------------------------
var RecordView = {
_cfg: null,
_gradeData: null,
_page: 0,
_gameIndex: -1,
_listOne: null,
_listTwo: null,
_bound: false,
_type: 0,
_gradeIdx1: 0,
_gradeIdx2: 0,
_gradeCount: 0, // 战绩总数(原全局 CombatCount,RecordView 自持)
_selectedRound: -1, // 当前选中的小局下标(配合 _gameIndex;选局数据由 RecordView 记录,子游戏只读)
onClose: null,
// 收战绩列表回包(平台通用协议):set 全局 CombatInfo/CombatCount + parse gameinfo1 + 记翻页边界 + 渲染打开。
// 子游戏经 SubGameHooks.get_player_grade1 转发到此。
onReceiveGrade1: function (_msg) {
if (typeof Utl !== 'undefined' && Utl.playSound && typeof Game_Config !== 'undefined') {
Utl.playSound(Game_Config.ClickButton.src_2);
}
var gradeinfo = _msg.data.gradeinfo || [];
this._gradeCount = _msg.data.asetcount;
for (var i = 0; i < gradeinfo.length; i++) {
if (typeof gradeinfo[i].gameinfo1 === 'string') {
gradeinfo[i].gameinfo1 = JSON.parse(gradeinfo[i].gameinfo1);
}
if (i === 0) { this._gradeIdx1 = gradeinfo[i].idx; }
if (i === gradeinfo.length - 1) { this._gradeIdx2 = gradeinfo[i].idx; }
}
this.setGradeData(gradeinfo);
this.open();
},
// 翻页/类型 → 发获取战绩请求(平台通用协议)。类型切换 _requestGrade(type,null);翻页 _requestGrade(_,direction)。
_requestGrade: function (arg1, direction) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
if (direction == null) {
this._type = arg1; // 类型切换
data.type = arg1;
} else {
data.type = this._type; // 翻页:type 用当前,direction + gradeidx
data.direction = direction;
data.gradeidx = (direction === 1) ? this._gradeIdx1 : this._gradeIdx2;
}
if (typeof Net !== 'undefined' && Net.Send_get_player_grade1) { Net.Send_get_player_grade1(data); }
},
// 选局 → 发获取该大局回放数据请求(平台通用协议)。gradeidx=选中大局的 idx(对账 bak newGoCombatPageThree 812-816)。
// 每次选局都发(不做同局缓存);回放数据接收与渲染由子游戏(onSelectRound + SubGameHooks.get_player_grade2)完成。
_requestReplayData: function (gameIndex) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
if (this._gradeData && this._gradeData[gameIndex] && typeof this._gradeData[gameIndex].idx !== 'undefined') {
data.gradeidx = this._gradeData[gameIndex].idx;
}
if (typeof Net !== 'undefined' && Net.Send_get_player_grade2) { Net.Send_get_player_grade2(data); }
},
configure: function (config) {
this._cfg = config || (typeof RecordViewDefaultConfig !== 'undefined' ? RecordViewDefaultConfig : null);
if (!this._cfg) { console.error('[RecordView] 缺少配置且无 DEFAULT_CONFIG'); return false; }
return true;
},
// 喂入平台战绩数据并渲染页一。
// @param gradeInfo CombatInfo[](结构见文件顶部「战绩数据结构」)
setGradeData: function (gradeInfo) {
this._gradeData = gradeInfo || [];
if (this._page === 1) { this._renderPageOne(); }
},
open: function () {
if (!this._cfg) { this.configure(null); }
if (!this._cfg) { return false; }
SpriteManager.showGroup(this._cfg.common.panelGroup);
this._bindEvents();
this.gotoPageOne();
return true;
},
close: function () {
this._clearPageOne();
this._clearPageTwo();
var rs = this._cfg ? this._cfg.common.resetGroups : [];
for (var i = 0; i < rs.length; i++) { SpriteManager.hideGroup(rs[i]); }
this._page = 0;
if (typeof this.onClose === 'function') { this.onClose(); }
},
gotoPageOne: function () {
this._clearPageTwo();
this._page = 1;
this._renderPageOne();
this._syncHeader();
this._syncPagingBtns();
},
gotoPageTwo: function (gameIndex) {
this._clearPageOne();
this._gameIndex = gameIndex;
this._page = 2;
this._renderPageTwo(gameIndex);
this._syncHeader();
},
// 同步头部 UI(总数 684/683、标题 82、类型标签 83),复刻 bak newGoCombatPageOne/Two 的 set_self。
_syncHeader: function () {
if (typeof set_self === 'undefined') { return; }
var c = this._cfg.common;
if (this._gradeCount) {
set_self(c.countSpid, 7, this._gradeCount, 0, 0);
set_self(c.countSpid, 20, String(this._gradeCount).length * 14, 0, 0);
} else {
set_self(c.countHideSpid, 37, 0, 0, 0);
set_self(c.countSpid, 37, 0, 0, 0);
}
set_self(c.titleSpid, 37, 1, 0, 0);
set_self(c.titleSpid, 43, this._type ? 2 : 1, 0, 0);
if (c.typeTagSpid != null) {
set_self(c.typeTagSpid, 43, this._type ? 2 : 1, 0, 0);
}
},
// 翻页按钮显隐:仅 type=1(分页视图)显示 prev/next,复刻 bak newGoCombatPageOne 753-759。
// 这保证 type=0 视图无翻页按钮,翻页时 _type 恒为 1,_requestGrade 取值正确。
_syncPagingBtns: function () {
if (typeof set_self === 'undefined') { return; }
var c = this._cfg.common;
var show = this._type ? 1 : 0;
if (c.prevBtn != null) { set_self(c.prevBtn, 37, show, 0, 0); }
if (c.nextBtn != null) { set_self(c.nextBtn, 37, show, 0, 0); }
},
// 供子游戏读取当前选局(回放定位):选中大局下标 / 小局下标。选局数据由 RecordView 记录,子游戏只读。
getSelectedGameIndex: function () { return this._gameIndex; },
getSelectedRound: function () { return this._selectedRound; },
getSelectedGameData: function () { return (this._gradeData && this._gameIndex >= 0) ? this._gradeData[this._gameIndex] : null; },
destroy: function () {
this._unbindEvents();
if (this._listOne) { this._listOne.destroy(); this._listOne = null; }
if (this._listTwo) { this._listTwo.destroy(); this._listTwo = null; }
this._gradeData = null; this._page = 0; this._gameIndex = -1;
},
_renderPageOne: function () {
var self = this;
var cfg = this._cfg.pageOne;
var L = cfg.layout;
var T = cfg.rowTemplate;
SpriteManager.showGroup(cfg.groupId);
if (this._listOne) { this._listOne.destroy(); }
this._listOne = new DynamicSpriteList({
containerId: cfg.containerSpid,
clipArea: { x: cfg.clip.x, y: cfg.clip.y, width: cfg.clip.w, height: cfg.clip.h },
rowHeight: L.rowSpace,
renderRow: function (row) {
var d = row.rowData;
var gi = (typeof d.gameinfo1 === 'string') ? JSON.parse(d.gameinfo1) : d.gameinfo1;
// ---- 行背景 ----
row.addSprite('bg', T.bg, 0, 0);
// ---- txt1:固定标签(时间:/房号:/局数:)----
var s;
s = row.addSprite('lbl0', T.txt1, L.txt1Pos[0][0], L.txt1Pos[0][1]);
SpriteManager.setText(s, '时间:');
s = row.addSprite('lbl1', T.txt1, L.txt1Pos[1][0], L.txt1Pos[1][1]);
SpriteManager.setText(s, '房号:');
s = row.addSprite('lbl2', T.txt1, L.txt1Pos[2][0], L.txt1Pos[2][1]);
SpriteManager.setText(s, '局数:');
// ---- txt2:数据值(overtime / roomcode / roundsum)----
s = row.addSprite('val0', T.txt2, L.txt2Pos[0][0], L.txt2Pos[0][1]);
SpriteManager.setText(s, d.overtime);
s = row.addSprite('val1', T.txt2, L.txt2Pos[1][0], L.txt2Pos[1][1]);
SpriteManager.setText(s, d.roomcode);
s = row.addSprite('val2', T.txt2, L.txt2Pos[2][0], L.txt2Pos[2][1]);
SpriteManager.setText(s, gi.roundsum);
// ---- 玩家信息(昵称 + 积分),>5 人双排 ----
var pl = gi.playerlist;
var pCount = 0, m;
for (m = 0; m < pl.length; m++) { if (pl[m]) { pCount++; } }
var pos3 = (pCount > 5) ? L.txt3Pos10 : L.txt3Pos5;
var nameLimit = (pCount > 5) ? L.nickLimit10 : L.nickLimit5;
if (pCount > 5) {
row.addSprite('split', T.split, L.splitPos[0], L.splitPos[1]);
}
var n = 0, j;
for (j = 0; j < pl.length; j++) {
if (!pl[j]) { continue; }
var nameTxt = Func.subString(pl[j][0], nameLimit, true);
var scoreTxt = pl[j][1];
var cx = pos3[n][0];
var cy = pos3[n][1];
var nx = cx - String(nameTxt).gblen() * L.txtWidth / 2;
var sx = cx - String(scoreTxt).gblen() * L.txtWidth / 2;
var sn = row.addSprite('name' + n, T.txt3, nx, cy);
SpriteManager.setText(sn, nameTxt);
var sc = row.addSprite('score' + n, T.txt3, sx, cy + L.nsSpace);
SpriteManager.setText(sc, scoreTxt);
n++;
}
// ---- 行按钮(可点击跳转页二)----
row.addSprite('btn', T.btn, L.btnPos[0], L.btnPos[1]);
}
});
this._listOne.onClick = function (name, rowIndex, rowData) {
self.gotoPageTwo(rowIndex);
};
this._listOne.setData(this._gradeData || []);
},
_clearPageOne: function () { if (this._listOne) { this._listOne.destroy(); this._listOne = null; } if (this._cfg) { SpriteManager.hideGroup(this._cfg.pageOne.groupId); } },
_renderPageTwo: function (gameIndex) {
var self = this;
var cfg = this._cfg.pageTwo;
var L = cfg.layout;
var T = cfg.rowTemplate;
SpriteManager.showGroup(cfg.groupId);
if (this._listTwo) { this._listTwo.destroy(); }
var entry = this._gradeData[gameIndex];
var gi = (typeof entry.gameinfo1 === 'string') ? JSON.parse(entry.gameinfo1) : entry.gameinfo1;
var pl = gi.playerlist || [];
var rounds = gi.round || [];
// 计算玩家人数与 nameLimit(对账 gameCombat 669:用 playerlist.length 判断)
var pCount = 0, m;
for (m = 0; m < pl.length; m++) { if (pl[m]) { pCount++; } }
var nameLimit = (pl.length > 5) ? L.nickLimit10 : L.nickLimit5;
this._listTwo = new DynamicSpriteList({
containerId: cfg.containerSpid,
clipArea: { x: cfg.clip.x, y: cfg.clip.y, width: cfg.clip.w, height: cfg.clip.h },
rowHeight: L.rowSpace,
renderRow: function (row) {
var i = row.rowIndex; // 第 i 小局(0 起)
var roundData = row.rowData; // round[i] = [[座位,分数],...]
// ---- 行背景 ----
row.addSprite('bg', T.bg, 0, 0);
// ---- 第几局(对账 645–648:< 10 带空格,>= 10 无空格)----
var rd = i + 1;
var txt = (rd < 10) ? ('第 ' + rd + ' 局') : ('第' + rd + '局');
var st = row.addSprite('lbl', T.txt1, L.txt1Pos[0][0], L.txt1Pos[0][1]);
SpriteManager.setText(st, txt);
// ---- 玩家昵称 + 该局得分(>5 人双排 + 分隔线)----
var pos = (pCount > 5) ? L.txt3Pos10 : L.txt3Pos5;
if (pCount > 5) {
row.addSprite('split', T.split, L.splitPos[0], L.splitPos[1]);
}
var n = 0, j;
for (j = 0; j < pl.length; j++) {
if (!pl[j]) { continue; }
var nameTxt = Func.subString(pl[j][0], nameLimit, true);
var sn = row.addSprite('name' + n, T.txt3, pos[n][0], pos[n][1]);
SpriteManager.setText(sn, nameTxt);
// score 默认 0,再按座位号匹配覆盖(对账 687–694)
var sc = row.addSprite('score' + n, T.txt3, pos[n][0], pos[n][1] + L.nsSpace);
SpriteManager.setText(sc, 0);
var k;
for (k = 0; k < roundData.length; k++) {
if (roundData[k][0] == j) { SpriteManager.setText(sc, roundData[k][1]); }
}
n++;
}
// ---- 行按钮(可点击触发 onSelectRound)----
row.addSprite('btn', T.btn, L.btnPos[0], L.btnPos[1]);
}
});
this._listTwo.onClick = function (name, rowIndex, rowData) {
self._selectedRound = rowIndex; // 记录选中小局(选局数据由 RecordView 记录)
self._requestReplayData(self._gameIndex); // 发 grade2 拉该大局回放数据(每次选局都发)
};
this._listTwo.setData(rounds);
},
_clearPageTwo: function () { if (this._listTwo) { this._listTwo.destroy(); this._listTwo = null; } if (this._cfg) { SpriteManager.hideGroup(this._cfg.pageTwo.groupId); } },
_bindEvents: function () {
if (this._bound) { return; }
var self = this, c = this._cfg;
// ---- 两个列表容器:将精灵事件转发给对应的 DynamicSpriteList ----
function bindContainer(containerSpid, getList) {
SpriteEventController.registerMouseDown(containerSpid, function (e) {
var l = getList(); if (l) { l.handleMouseDown(e.spriteId, e.x, e.y); }
});
SpriteEventController.registerMouseMove(containerSpid, function (e) {
var l = getList(); if (l) { l.handleMouseMove(e.spriteId, e.offset.y); }
});
SpriteEventController.registerMouseUp(containerSpid, function (e) {
var l = getList(); if (l) { l.handleMouseUp(e.downSpriteId, e.upSpriteId, e.upPos.x, e.upPos.y); }
});
SpriteEventController.registerDrawBegin(containerSpid, function (e) {
var l = getList(); if (l) { l.handleDrawBegin(e.spriteId); }
});
}
bindContainer(c.pageOne.containerSpid, function () { return self._listOne; });
bindContainer(c.pageTwo.containerSpid, function () { return self._listTwo; });
// ---- 返回按钮:页二时回页一,否则关闭 ----
SpriteEventController.registerMouseUp(c.common.backBtn, function () {
if (self._page === 2) { self.gotoPageOne(); } else { self.close(); }
});
// ---- 关闭按钮(可多个)----
var cb = c.common.closeBtn || [], i;
for (i = 0; i < cb.length; i++) {
(function (spid) {
SpriteEventController.registerMouseUp(spid, function () { self.close(); });
})(cb[i]);
}
// ---- 翻页:prev=direction 1(上一页),next=direction 2(下一页)→ 组件自己发请求 ----
if (c.common.prevBtn != null) {
SpriteEventController.registerMouseUp(c.common.prevBtn, function () { self._requestGrade(1, 1); });
}
if (c.common.nextBtn != null) {
SpriteEventController.registerMouseUp(c.common.nextBtn, function () { self._requestGrade(1, 2); });
}
// ---- 类型切换:typeBtns[k] -> type=k(IIFE 固定 typeVal 防循环变量捕获)→ 组件自己发请求 ----
var tb = c.common.typeBtns || [], k;
for (k = 0; k < tb.length; k++) {
(function (typeVal) {
SpriteEventController.registerMouseUp(tb[typeVal], function () { self._requestGrade(typeVal, null); });
})(k);
}
this._bound = true;
},
_unbindEvents: function () {
if (!this._bound) { return; }
var c = this._cfg;
var spids = [c.pageOne.containerSpid, c.pageTwo.containerSpid, c.common.backBtn, c.common.prevBtn, c.common.nextBtn];
var cb = c.common.closeBtn || [], tb = c.common.typeBtns || [], i;
for (i = 0; i < cb.length; i++) { spids.push(cb[i]); }
for (i = 0; i < tb.length; i++) { spids.push(tb[i]); }
for (i = 0; i < spids.length; i++) {
if (spids[i] != null) { SpriteEventController.unregister(spids[i]); }
}
this._bound = false;
}
};
if (typeof window !== 'undefined') { window.RecordView = RecordView; }
if (typeof module !== 'undefined' && module.exports) { module.exports = RecordView; }
@@ -0,0 +1,93 @@
// ============================================================================
// RecordViewDefaultConfig.js —— RecordView 默认配置(= 现有战绩界面群组/精灵 ID)
// 纯数据;RecordView 逻辑零硬编码 ID,不传 config 时用本默认。换游戏只换这份。
// 权威来源:01_SubGame_modify.js 的 gameCombat.combatPageOneConfig/combatPageTwoConfig 等。
// 严格 ES5。
// ============================================================================
var RecordViewDefaultConfig = {
common: {
panelGroup: 7, // openNewCombat: set_group(7,37,1,...) / closeNewCombat: set_group(7,37,0,...)
titleSpid: 82, // set_self(82,37,1,...) + set_self(82,43,1/2,...)
typeTagSpid: 83, // openNewCombat: set_self(83,43,1/2,...) 类型标签按 type
backBtn: 55, // case 55: 返回
closeBtn: [49, 61], // case 49 / case 61: 关闭
prevBtn: 104, // case 104: 上一页
nextBtn: 105, // case 105: 下一页
typeBtns: [84, 85], // case 84 / 85: 类型切换
reqBtn: 13, // case 13: 发送战绩请求
countSpid: 684, // set_self(684,7,CombatCount,...) 总数显示精灵
countHideSpid: 683, // set_self(683,37,0,...) 隐藏总数精灵
resetGroups: [7, 8, 9, 13, 14, 15, 16, 17] // closeNewCombat: set_group(7~9,13~17,37,0,...)
},
pageOne: {
groupId: 8, // newCreateCombatPageOne: set_group(8,37,1,...)
containerSpid: 63, // fSpid:63
rowTemplate: {
bg: 64, // bgSpid:64
split: 86, // splitSpid:86
btn: 51, // btnSpid:51
txt1: 65, // txt1Spid:65
txt2: 66, // txt2Spid:66
txt3: 67 // txt3Spid:67
},
tagBase: {
bg: 1, // bgTag:1
split: 500, // splitTag:500
btn: 1000, // btnTag:1000
txt1: 1500, // txt1Tag:1500
txt2: 3000, // txt2Tag:3000
txt3: 4500 // txt3Tag:4500
},
clip: { x: 64, y: 114, w: 1175, h: 515 }, // clip_x/y/w/h
layout: {
rowSpace: 167, // bgSpace:167
txt1Pos: [[35, 15], [35, 60], [35, 105]], // txt1Position
txt2Pos: [[130, 15], [130, 60], [130, 105]], // txt2Position
txt3Pos5: [[400, 45], [540, 45], [680, 45], [820, 45], [960, 45]], // txt3Position5
txt3Pos10: [[400, 10], [540, 10], [680, 10], [820, 10], [960, 10], // txt3Position10
[400, 80], [540, 80], [680, 80], [820, 80], [960, 80]],
splitPos: [338, 74], // splitPosition
btnPos: [1065, 0], // btnPosition
txtWidth: 10, // txt1Width/txt2Width/txt3Width
nickLimit5: 8, // nickNameLimit5
nickLimit10: 8, // nickNameLimit10
nsSpace: 30 // nsSpace
}
},
pageTwo: {
groupId: 9, // newCreateCombatPageTwo: set_group(9,37,1,...)
containerSpid: 87, // fSpid:87
rowTemplate: {
bg: 88, // bgSpid:88
split: 91, // splitSpid:91
btn: 54, // btnSpid:54
txt1: 89, // txt1Spid:89
// txt2 注释掉(源码 //txt2Spid:66)
txt3: 90 // txt3Spid:90
},
tagBase: {
bg: 1, // bgTag:1
split: 500, // splitTag:500
btn: 1000, // btnTag:1000
txt1: 1500, // txt1Tag:1500
txt2: 3000, // txt2Tag:3000(源码保留)
txt3: 4500 // txt3Tag:4500
},
clip: { x: 64, y: 114, w: 1175, h: 515 }, // clip_x/y/w/h
layout: {
rowSpace: 167, // bgSpace:167
txt1Pos: [[90, 50], [35, 60], [35, 105]], // txt1Position(3项,源码权威)
txt3Pos5: [[400, 45], [540, 45], [680, 45], [820, 45], [960, 45]], // txt3Position5
txt3Pos10: [[400, 10], [540, 10], [680, 10], [820, 10], [960, 10], // txt3Position10
[400, 80], [540, 80], [680, 80], [820, 80], [960, 80]],
splitPos: [338, 74], // splitPosition
btnPos: [1065, 0], // btnPosition
txtWidth: 10, // txt1Width/txt2Width/txt3Width
nickLimit5: 2, // nickNameLimit5
nickLimit10: 2, // nickNameLimit10
nsSpace: 30 // nsSpace
}
}
};
if (typeof window !== 'undefined') { window.RecordViewDefaultConfig = RecordViewDefaultConfig; }
if (typeof module !== 'undefined' && module.exports) { module.exports = RecordViewDefaultConfig; }
@@ -0,0 +1,650 @@
/**
* ============================================================================
* SpriteCopyUtils.js - 精灵复制底层工具
* ============================================================================
*
* 封装 gameabc 框架的精灵复制核心功能,提供更易用的 API。
* 这是一个底层工具类,DynamicSpriteList 基于此构建。
*
* 核心概念:
* - 父精灵 (Parent Sprite): 容器精灵,复制出的子精灵将附着其上
* - 模板精灵 (Template Sprite): 提供视觉样式的精灵模板
* - 子精灵 (Child Sprite): 运行时动态复制创建的精灵
* - Tag: 子精灵的唯一标识符,用于删除和点击识别
*
* ============================================================================
* @guide 开发指南 - SpriteCopyUtils 使用规范
* ============================================================================
*
* 【创建动态精灵(模板复制)】
* // 在编辑器中预先放置一个模板精灵(通常隐藏),用作样式来源
* // 运行时从该模板复制,附着到容器精灵上
*
* var parentId = sprites.LIST_CONTAINER; // 容器精灵ID(编辑器放置)
* var templateId = sprites.ROW_TEMPLATE; // 模板精灵ID(编辑器放置,通常隐藏)
* var tag = rowIndex; // 行索引作为tag(在父精灵内唯一)
*
* var spriteId = SpriteCopyUtils.create(parentId, templateId, 0, rowIndex * rowHeight, tag);
* // spriteId 是字符串格式:"parentIdaddtag",如 "2836add0"
*
* // 创建后可通过 SpriteManager 设置属性(spriteId 是字符串,SpriteManager 自动处理)
* SpriteManager.setFrame(spriteId, card.code - 1);
* SpriteManager.setText(spriteId, String(score));
* SpriteManager.show(spriteId);
*
* 【删除动态精灵】
* SpriteCopyUtils.remove(parentId, tag); // 删除单个
* SpriteCopyUtils.removeRange(parentId, 0, 10); // 删除tag 0-9
*
* 【hit-test 点击检测(在鼠标事件中使用)】
* // 在 SpriteEventController 的 mouseUp 回调中:
* var clickedTag = SpriteCopyUtils.hitTest(parentId, event.offsetX, event.offsetY);
* if (clickedTag !== SpriteCopyUtils.HIT_NONE) {
* // clickedTag 是被点击子精灵的 tag
* var rowIndex = clickedTag;
* self._onRowClick(rowIndex);
* }
*
* 【典型使用场景:听牌提示列表(TingHintView 模式)】
* // 1. 编辑器中放置:容器精灵 + 行背景模板 + 牌图模板 + 分数模板
* // 2. 运行时:为每条听牌提示创建一行
* for (var i = 0; i < tingHints.length; i++) {
* var hint = tingHints[i];
* var yOffset = i * ROW_HEIGHT;
*
* // 复制行背景
* var bgId = SpriteCopyUtils.create(containerId, bgTemplate, 0, yOffset, i);
*
* // 复制牌图
* var cardId = SpriteCopyUtils.create(containerId, cardTemplate, CARD_X, yOffset, i + 1000);
* SpriteManager.setFrame(cardId, hint.code - 1);
*
* // 复制分数标签
* var scoreId = SpriteCopyUtils.create(containerId, scoreTemplate, SCORE_X, yOffset, i + 2000);
* SpriteManager.setText(scoreId, String(hint.score));
* }
* // 3. 清理:游戏结束或弹窗关闭时
* SpriteCopyUtils.removeRange(containerId, 0, tingHints.length);
* SpriteCopyUtils.removeRange(containerId, 1000, 1000 + tingHints.length);
* SpriteCopyUtils.removeRange(containerId, 2000, 2000 + tingHints.length);
*
* 【注意事项】
* - tag 在同一个 parentId 下必须唯一;不同列使用不同的 tag 偏移(如+1000, +2000)
* - 复制精灵的 ID 格式 "parentIdaddtag" 是字符串,SpriteManager 已支持此格式
* - 如果使用 DynamicSpriteList,则无需手动调用 SpriteCopyUtils,列表内部已封装
*
* @author JinXian Team
* @version 1.0.0
* @since 2025-12-13
*/
'use strict';
/**
* 精灵复制工具类
* @namespace
*/
var SpriteCopyUtils = {};
// ============================================================================
// 精灵创建与删除
// ============================================================================
/**
* 从模板复制创建精灵
*
* @param {number} parentId - 父精灵ID(容器),子精灵将附着在此精灵上
* @param {number} templateId - 模板精灵ID,复制其视觉属性
* @param {number} x - 相对于父精灵的X坐标
* @param {number} y - 相对于父精灵的Y坐标
* @param {number} tag - 子精灵标识符(在父精灵内唯一)
* @returns {string} 复合精灵ID,格式为 "parentId + 'add' + tag"
*
* @example
* // 在父精灵100上,基于模板200,在位置(50,30)创建子精灵,tag为1
* var spriteId = SpriteCopyUtils.create(100, 200, 50, 30, 1);
*
* // 设置子精灵的文字内容
* set_self(spriteId, 7, '你好世界', 0, 0);
*
* // 设置子精灵的帧
* set_self(spriteId, 43, 2, 0, 0);
*/
SpriteCopyUtils.create = function(parentId, templateId, x, y, tag) {
return ifast_addtospritefromspritecopy(parentId, templateId, x, y, tag);
};
/**
* 删除复制创建的子精灵
*
* @param {number} parentId - 父精灵ID
* @param {number} tag - 要删除的子精灵标识符
*
* @example
* // 删除父精灵100上tag为1的子精灵
* SpriteCopyUtils.remove(100, 1);
*/
SpriteCopyUtils.remove = function(parentId, tag) {
ifast_dllpritefromspritecopy(parentId, tag);
};
/**
* 批量删除复制创建的子精灵
*
* @param {number} parentId - 父精灵ID
* @param {number} startTag - 起始tag(包含)
* @param {number} endTag - 结束tag(不包含)
*
* @example
* // 删除父精灵100上tag从1到10的所有子精灵
* SpriteCopyUtils.removeRange(100, 1, 11);
*/
SpriteCopyUtils.removeRange = function(parentId, startTag, endTag) {
for (var tag = startTag; tag < endTag; tag++) {
ifast_dllpritefromspritecopy(parentId, tag);
}
};
/**
* 批量删除指定tag数组的子精灵
*
* @param {number} parentId - 父精灵ID
* @param {Array<number>} tags - tag数组
*
* @example
* // 删除父精灵100上tag为1, 3, 5, 7的子精灵
* SpriteCopyUtils.removeByTags(100, [1, 3, 5, 7]);
*/
SpriteCopyUtils.removeByTags = function(parentId, tags) {
for (var i = 0; i < tags.length; i++) {
ifast_dllpritefromspritecopy(parentId, tags[i]);
}
};
// ============================================================================
// 点击检测
// ============================================================================
/**
* 检测点击了父精灵上的哪个子精灵
*
* @param {number} parentId - 父精灵ID
* @param {number} x - 点击位置X坐标(屏幕坐标)
* @param {number} y - 点击位置Y坐标(屏幕坐标)
* @returns {number} 被点击的子精灵tag,未命中返回 -99999999
*
* @example
* // 在mouseup事件中检测点击
* var clickedTag = SpriteCopyUtils.hitTest(100, upx, upy);
* if (clickedTag !== -99999999) {
* console.log('点击了tag为', clickedTag, '的子精灵');
* }
*/
SpriteCopyUtils.hitTest = function(parentId, x, y) {
return ifast_check_add(parentId, x, y);
};
/**
* 检测点击是否命中任何子精灵
*
* @param {number} parentId - 父精灵ID
* @param {number} x - 点击位置X坐标
* @param {number} y - 点击位置Y坐标
* @returns {boolean} 是否命中
*
* @example
* if (SpriteCopyUtils.isHit(100, upx, upy)) {
* console.log('点击到了子精灵');
* }
*/
SpriteCopyUtils.isHit = function(parentId, x, y) {
return ifast_check_add(parentId, x, y) !== -99999999;
};
/**
* 检测点击的tag是否在指定范围内
*
* @param {number} parentId - 父精灵ID
* @param {number} x - 点击位置X坐标
* @param {number} y - 点击位置Y坐标
* @param {number} startTag - 起始tag(包含)
* @param {number} endTag - 结束tag(不包含)
* @returns {number} 如果在范围内返回tag,否则返回 -1
*
* @example
* // 检测是否点击了按钮区域(tag 1000-1999)
* var btnTag = SpriteCopyUtils.hitTestInRange(100, upx, upy, 1000, 2000);
* if (btnTag !== -1) {
* var buttonIndex = btnTag - 1000;
* console.log('点击了第', buttonIndex, '个按钮');
* }
*/
SpriteCopyUtils.hitTestInRange = function(parentId, x, y, startTag, endTag) {
var tag = ifast_check_add(parentId, x, y);
if (tag >= startTag && tag < endTag) {
return tag;
}
return -1;
};
// ============================================================================
// 精灵ID工具
// ============================================================================
/**
* 生成复合精灵ID
*
* @param {number} parentId - 父精灵ID
* @param {number} tag - 子精灵tag
* @returns {string} 复合精灵ID
*
* @example
* var spriteId = SpriteCopyUtils.getSpriteId(100, 5);
* // 返回 "100add5"
* set_self(spriteId, 7, '文字内容', 0, 0);
*/
SpriteCopyUtils.getSpriteId = function(parentId, tag) {
return parentId + 'add' + tag;
};
/**
* 从复合精灵ID解析父精灵ID和tag
*
* @param {string} spriteId - 复合精灵ID
* @returns {Object|null} 包含parentId和tag的对象,解析失败返回null
*
* @example
* var info = SpriteCopyUtils.parseSpriteId('100add5');
* // 返回 { parentId: 100, tag: 5 }
*/
SpriteCopyUtils.parseSpriteId = function(spriteId) {
if (typeof spriteId !== 'string') {
return null;
}
var parts = spriteId.split('add');
if (parts.length !== 2) {
return null;
}
var parentId = parseInt(parts[0], 10);
var tag = parseInt(parts[1], 10);
if (isNaN(parentId) || isNaN(tag)) {
return null;
}
return {
parentId: parentId,
tag: tag
};
};
// ============================================================================
// 批量创建工具
// ============================================================================
/**
* 批量创建子精灵(网格布局)
*
* @param {Object} options - 配置选项
* @param {number} options.parentId - 父精灵ID
* @param {number} options.templateId - 模板精灵ID
* @param {number} options.count - 创建数量
* @param {number} options.startTag - 起始tag
* @param {number} options.startX - 起始X坐标
* @param {number} options.startY - 起始Y坐标
* @param {number} options.spacingX - X方向间距
* @param {number} options.spacingY - Y方向间距
* @param {number} [options.columns=0] - 每行列数,0表示不换行
* @returns {Array<string>} 创建的精灵ID数组
*
* @example
* // 创建3x3的网格(9个精灵)
* var sprites = SpriteCopyUtils.createGrid({
* parentId: 100,
* templateId: 200,
* count: 9,
* startTag: 1,
* startX: 50,
* startY: 50,
* spacingX: 80,
* spacingY: 80,
* columns: 3
* });
*/
SpriteCopyUtils.createGrid = function(options) {
var result = [];
var columns = options.columns || 0;
for (var i = 0; i < options.count; i++) {
var col = columns > 0 ? (i % columns) : i;
var row = columns > 0 ? Math.floor(i / columns) : 0;
var x = options.startX + col * options.spacingX;
var y = options.startY + row * options.spacingY;
var tag = options.startTag + i;
var spriteId = ifast_addtospritefromspritecopy(
options.parentId,
options.templateId,
x,
y,
tag
);
result.push(spriteId);
}
return result;
};
/**
* 批量创建子精灵(垂直列表布局)
*
* @param {Object} options - 配置选项
* @param {number} options.parentId - 父精灵ID
* @param {number} options.templateId - 模板精灵ID
* @param {number} options.count - 创建数量
* @param {number} options.startTag - 起始tag
* @param {number} options.x - X坐标
* @param {number} options.startY - 起始Y坐标
* @param {number} options.rowHeight - 行高
* @returns {Array<string>} 创建的精灵ID数组
*
* @example
* // 创建5行的垂直列表
* var sprites = SpriteCopyUtils.createVerticalList({
* parentId: 100,
* templateId: 200,
* count: 5,
* startTag: 1,
* x: 50,
* startY: 100,
* rowHeight: 60
* });
*/
SpriteCopyUtils.createVerticalList = function(options) {
return SpriteCopyUtils.createGrid({
parentId: options.parentId,
templateId: options.templateId,
count: options.count,
startTag: options.startTag,
startX: options.x,
startY: options.startY,
spacingX: 0,
spacingY: options.rowHeight,
columns: 1
});
};
/**
* 批量创建子精灵(水平列表布局)
*
* @param {Object} options - 配置选项
* @param {number} options.parentId - 父精灵ID
* @param {number} options.templateId - 模板精灵ID
* @param {number} options.count - 创建数量
* @param {number} options.startTag - 起始tag
* @param {number} options.startX - 起始X坐标
* @param {number} options.y - Y坐标
* @param {number} options.colWidth - 列宽
* @returns {Array<string>} 创建的精灵ID数组
*
* @example
* // 创建一行5个精灵
* var sprites = SpriteCopyUtils.createHorizontalList({
* parentId: 100,
* templateId: 200,
* count: 5,
* startTag: 1,
* startX: 50,
* y: 100,
* colWidth: 80
* });
*/
SpriteCopyUtils.createHorizontalList = function(options) {
return SpriteCopyUtils.createGrid({
parentId: options.parentId,
templateId: options.templateId,
count: options.count,
startTag: options.startTag,
startX: options.startX,
startY: options.y,
spacingX: options.colWidth,
spacingY: 0,
columns: 0
});
};
// ============================================================================
// 属性设置工具
// ============================================================================
/**
* 批量设置子精灵的文字内容
*
* @param {number} parentId - 父精灵ID
* @param {number} startTag - 起始tag
* @param {Array<string>} texts - 文字内容数组
*
* @example
* // 设置tag 1-5的文字内容
* SpriteCopyUtils.setTexts(100, 1, ['第1行', '第2行', '第3行', '第4行', '第5行']);
*/
SpriteCopyUtils.setTexts = function(parentId, startTag, texts) {
for (var i = 0; i < texts.length; i++) {
var spriteId = parentId + 'add' + (startTag + i);
set_self(spriteId, 7, texts[i], 0, 0);
}
};
/**
* 批量设置子精灵的帧
*
* @param {number} parentId - 父精灵ID
* @param {number} startTag - 起始tag
* @param {Array<number>} frames - 帧号数组
*
* @example
* // 设置tag 1-5的帧
* SpriteCopyUtils.setFrames(100, 1, [1, 2, 3, 4, 5]);
*/
SpriteCopyUtils.setFrames = function(parentId, startTag, frames) {
for (var i = 0; i < frames.length; i++) {
var spriteId = parentId + 'add' + (startTag + i);
set_self(spriteId, 43, frames[i], 0, 0);
}
};
/**
* 批量设置子精灵的可见性
*
* @param {number} parentId - 父精灵ID
* @param {number} startTag - 起始tag
* @param {number} count - 精灵数量
* @param {boolean} visible - 是否可见
*
* @example
* // 隐藏tag 1-10的所有精灵
* SpriteCopyUtils.setVisibility(100, 1, 10, false);
*/
SpriteCopyUtils.setVisibility = function(parentId, startTag, count, visible) {
for (var i = 0; i < count; i++) {
var spriteId = parentId + 'add' + (startTag + i);
set_self(spriteId, 37, visible ? 1 : 0, 0, 0);
}
};
// ============================================================================
// Tag 管理器
// ============================================================================
/**
* 创建 Tag 管理器
* 用于管理多种类型精灵的 tag 分配
*
* @param {Object} ranges - tag 范围配置
* @returns {Object} Tag 管理器实例
*
* @example
* // 创建tag管理器
* var tagManager = SpriteCopyUtils.createTagManager({
* background: { start: 1, size: 100 }, // 1-100
* title: { start: 101, size: 100 }, // 101-200
* button: { start: 201, size: 100 } // 201-300
* });
*
* // 获取下一个tag
* var bgTag = tagManager.next('background'); // 返回 1
* var bgTag2 = tagManager.next('background'); // 返回 2
*
* // 重置计数器
* tagManager.reset();
*
* // 获取某类型的tag范围
* var range = tagManager.getRange('button'); // { start: 201, end: 301 }
*/
SpriteCopyUtils.createTagManager = function(ranges) {
var counters = {};
var rangeConfig = {};
// 初始化
var types = Object.keys(ranges);
for (var i = 0; i < types.length; i++) {
var type = types[i];
var config = ranges[type];
rangeConfig[type] = {
start: config.start,
end: config.start + config.size
};
counters[type] = config.start;
}
return {
/**
* 获取下一个可用的tag
* @param {string} type - 类型名称
* @returns {number} tag值
*/
next: function(type) {
if (!counters.hasOwnProperty(type)) {
throw new Error('TagManager: unknown type ' + type);
}
return counters[type]++;
},
/**
* 获取当前计数器值(不递增)
* @param {string} type - 类型名称
* @returns {number} 当前计数器值
*/
current: function(type) {
return counters[type];
},
/**
* 重置所有计数器
*/
reset: function() {
for (var t = 0; t < types.length; t++) {
counters[types[t]] = rangeConfig[types[t]].start;
}
},
/**
* 重置指定类型的计数器
* @param {string} type - 类型名称
*/
resetType: function(type) {
if (rangeConfig.hasOwnProperty(type)) {
counters[type] = rangeConfig[type].start;
}
},
/**
* 获取某类型的tag范围
* @param {string} type - 类型名称
* @returns {Object} { start, end }
*/
getRange: function(type) {
return rangeConfig[type] ? {
start: rangeConfig[type].start,
end: rangeConfig[type].end
} : null;
},
/**
* 获取某类型已分配的tag数量
* @param {string} type - 类型名称
* @returns {number} 已分配数量
*/
getAllocatedCount: function(type) {
return counters[type] - rangeConfig[type].start;
},
/**
* 检测tag属于哪个类型
* @param {number} tag - tag值
* @returns {string|null} 类型名称,不属于任何类型返回null
*/
getTypeByTag: function(tag) {
for (var t = 0; t < types.length; t++) {
var type = types[t];
var range = rangeConfig[type];
if (tag >= range.start && tag < range.end) {
return type;
}
}
return null;
},
/**
* 计算tag在其类型中的索引
* @param {number} tag - tag值
* @returns {number} 索引,不属于任何类型返回-1
*/
getIndexByTag: function(tag) {
for (var t = 0; t < types.length; t++) {
var type = types[t];
var range = rangeConfig[type];
if (tag >= range.start && tag < range.end) {
return tag - range.start;
}
}
return -1;
}
};
};
// ============================================================================
// 精灵ID工具
// ============================================================================
/**
* 根据容器ID和tag生成复制精灵的实际ID
*
* 对于通过 SpriteCopyUtils.create() 复制创建的精灵,
* 其实际ID遵循格式:容器精灵ID + 'add' + tag
*
* @param {number} containerId - 容器精灵ID
* @param {number} tag - 子精灵标识符
* @returns {string} 精灵ID,格式为 "containerIdaddtag"
*
* @example
* // 容器ID为100,tag为20
* var spriteId = SpriteCopyUtils.getSpriteId(100, 20);
* // 返回: "100add20"
*
* // 可用于获取精灵尺寸、位置等
* var size = SpriteManager.getSize(spriteId);
* var pos = SpriteManager.getPosition(spriteId);
*/
SpriteCopyUtils.getSpriteId = function(containerId, tag) {
return containerId + 'add' + tag;
};
// ============================================================================
// 导出
// ============================================================================
if (typeof module !== 'undefined' && module.exports) {
module.exports = SpriteCopyUtils;
} else if (typeof window !== 'undefined') {
window.SpriteCopyUtils = SpriteCopyUtils;
}
+607
View File
@@ -0,0 +1,607 @@
/**
* ============================================================================
* UIManager.js - UI 管理器(gameabc 框架层)
* ============================================================================
*
* 职责:
* - UI 组件的注册、注销、查询和生命周期管理
* - 场景定义与切换(BaseComponent 组合的具名快照)
* - 全局公共 UI:Loading / Message / Confirm 对话框
*
* 设计原则:
* 1. 精灵由 gameabc 编辑器预先创建,UIManager 只控制显示/隐藏状态
* 2. 本文件不含任何游戏专属逻辑,可用于任何基于 gameabc 的子游戏
* 3. 游戏层通过挂载方法(UIManager.xxx = function(){})扩展专属功能
*
* 全局 UI 配置(由子游戏注入,框架不读取任何子游戏全局名):
* 子游戏在初始化时通过 init(config) 或 configureGlobalUI(config) 传入精灵常量:
* config.loadingUI = { LAYER, GROUP_ID, SPRITES: { BACKGROUND, LOADING_ICON, LOADING_TEXT } }
* config.messageUI = { LAYER, GROUP_ID, SPRITES: { BACKGROUND, TEXT } }
* config.confirmUI = { LAYER, GROUP_ID, SPRITES: { BACKGROUND, TITLE_TEXT, MESSAGE_TEXT, BTN_CONFIRM, BTN_CANCEL } }
* 不传则跳过 Loading/Message/Confirm 全局 UI(场景/组件等其余功能不受影响)。
*
* 依赖(必须在本文件之前加载):
* EventBus (gameabc-framework/system/EventBus.js)
* SpriteManager (gameabc-framework/core/SpriteManager.js)
*
* ============================================================================
* @guide 游戏侧扩展模式
* ============================================================================
*
* 游戏层通过独立 JS 文件向 UIManager 挂载专属方法:
*
* // CreateRoomUI.js(示例)
* (function() {
* 'use strict';
* var CREATE_ROOM_UI = SpriteConstants.CREATE_ROOM_UI;
*
* UIManager.showCreateRoom = function(config) {
* // 游戏专属逻辑...
* SpriteManager.showGroup(CREATE_ROOM_UI.GROUP_ID);
* };
* UIManager.hideCreateRoom = function() {
* SpriteManager.hideGroup(CREATE_ROOM_UI.GROUP_ID);
* };
* })();
*
* ============================================================================
* @guide 场景管理完整用法
* ============================================================================
*
* // 1. 初始化(在 UIBootstrap 中调用);若有全局 UI,注入其精灵常量(框架不读子游戏全局名):
* UIManager.init({ loadingUI: SpriteConstants.LOADING_UI, messageUI: SpriteConstants.MESSAGE_UI, confirmUI: SpriteConstants.CONFIRM_UI });
*
* // 2. 注册组件(BaseComponent 实例)
* UIManager.registerComponent('RoomView', roomView);
* UIManager.registerComponent('GameView', gameView);
*
* // 3. 注册场景(关联组件名列表)
* UIManager.registerScene(UIManager.SCENES.ROOM, ['RoomView']);
* UIManager.registerScene(UIManager.SCENES.GAME, ['GameView', 'JingCardView']);
*
* // 4. 切换场景(自动隐藏旧场景,显示新场景,发射 SCENE_CHANGED 事件)
* UIManager.switchToScene(UIManager.SCENES.GAME);
*
* // 5. 全局 UI
* UIManager.showLoading('正在加载...');
* UIManager.showMessage('操作成功', 2000);
* UIManager.showConfirm('确认', '是否退出?', onOK, onCancel);
*
* @version 1.0.0
*/
var UIManager = (function () {
'use strict';
// ==================== 私有变量 ====================
/**
* 全局 UI 精灵常量(由子游戏注入:init(config) / configureGlobalUI)
* 框架不读取任何子游戏全局名;未注入时三者为 null,对应全局 UI 不可用(优雅跳过)。
*/
var LOADING_UI = null;
var MESSAGE_UI = null;
var CONFIRM_UI = null;
/**
* 已注册的 UI 组件
* { componentName: BaseComponent 实例 }
*/
var _components = {};
/**
* 已注册的场景
* { sceneName: [componentName, ...] }
*/
var _scenes = {};
/**
* 当前活跃场景名
* @type {string|null}
*/
var _currentScene = null;
/**
* 初始化状态
*/
var _isInitialized = false;
// ==================== 场景枚举 ====================
var SCENES = {
ROOM: 'ROOM',
GAME: 'GAME',
LOADING: 'LOADING'
};
// ==================== 私有方法 ====================
/**
* 应用子游戏注入的全局 UI 配置(Loading/Message/Confirm),并隐藏所有全局弹层。
* 框架不认识任何子游戏全局名,配置由子游戏在 init()/configureGlobalUI() 时传入。
* @param {Object} config - { loadingUI, messageUI, confirmUI }
* @returns {boolean} 是否成功配置(缺省或不完整则跳过,返回 false)
* @private
*/
function _applyGlobalUIConfig(config) {
if (!config || !config.loadingUI || !config.messageUI || !config.confirmUI) {
console.warn('[UIManager] 未提供完整的全局 UI 配置(loadingUI/messageUI/confirmUI),跳过全局 UI 初始化');
return false;
}
LOADING_UI = config.loadingUI;
MESSAGE_UI = config.messageUI;
CONFIRM_UI = config.confirmUI;
_initLoadingUI();
_initMessageUI();
_initConfirmUI();
return true;
}
/** @private */
function _initLoadingUI() {
SpriteManager.hide(LOADING_UI.SPRITES.BACKGROUND);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_ICON);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT);
SpriteManager.hideGroup(LOADING_UI.GROUP_ID);
console.log('[UIManager] Loading UI 初始化 (Layer ' + LOADING_UI.LAYER + ')');
}
/** @private */
function _initMessageUI() {
SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND);
SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT);
SpriteManager.hideGroup(MESSAGE_UI.GROUP_ID);
console.log('[UIManager] Message UI 初始化 (Layer ' + MESSAGE_UI.LAYER + ')');
}
/** @private */
function _initConfirmUI() {
SpriteManager.hide(CONFIRM_UI.SPRITES.BACKGROUND);
SpriteManager.hide(CONFIRM_UI.SPRITES.TITLE_TEXT);
SpriteManager.hide(CONFIRM_UI.SPRITES.MESSAGE_TEXT);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL);
SpriteManager.hideGroup(CONFIRM_UI.GROUP_ID);
console.log('[UIManager] Confirm UI 初始化 (Layer ' + CONFIRM_UI.LAYER + ')');
}
/**
* 隐藏当前场景所有可见组件
* @private
*/
function _hideCurrentScene() {
if (!_currentScene || !_scenes[_currentScene]) { return; }
var names = _scenes[_currentScene];
for (var i = 0; i < names.length; i++) {
var c = _components[names[i]];
if (c && c.isVisible) { c.hide(); }
}
}
/**
* 显示指定场景的所有组件
* @private
*/
function _showScene(sceneName) {
if (!_scenes[sceneName]) {
console.error('[UIManager] 场景不存在:', sceneName);
return;
}
var names = _scenes[sceneName];
for (var i = 0; i < names.length; i++) {
var c = _components[names[i]];
if (c && !c.isVisible) { c.show(); }
}
}
// ==================== 公共 API ====================
var publicAPI = {
/**
* 场景枚举常量
*/
SCENES: SCENES,
/**
* 是否已初始化(外部只读)
*/
initialized: false,
// ============================================================
// 初始化 / 销毁
// ============================================================
/**
* 初始化 UIManager
* @param {Object} [globalUIConfig] - 可选的全局 UI 配置 { loadingUI, messageUI, confirmUI },
* 由子游戏传入(如各自的精灵常量);框架不主动读取任何子游戏全局名。
* 不传则跳过 Loading/Message/Confirm 全局 UI(可后续用 configureGlobalUI 配置)。
* @returns {boolean}
*/
init: function (globalUIConfig) {
if (_isInitialized) {
console.warn('[UIManager] 已初始化,跳过');
return true;
}
console.log('[UIManager] 初始化 (v1.0.0)');
try {
_components = {};
_scenes = {};
_currentScene = null;
// 全局 UI 为可选能力:未注入配置则跳过,不阻断 UIManager 初始化
if (globalUIConfig) { _applyGlobalUIConfig(globalUIConfig); }
_isInitialized = true;
this.initialized = true;
console.log('[UIManager] 初始化完成');
return true;
} catch (err) {
console.error('[UIManager] 初始化失败:', err);
return false;
}
},
/**
* 配置全局 UI(Loading/Message/Confirm)的精灵常量。供子游戏注入,可在 init 之后单独调用。
* @param {Object} config - { loadingUI, messageUI, confirmUI }
* @returns {boolean}
*/
configureGlobalUI: function (config) {
return _applyGlobalUIConfig(config);
},
// ============================================================
// 组件管理
// ============================================================
/**
* 注册 UI 组件
* @param {string} name - 组件名称
* @param {Object} component - BaseComponent 实例(必须已 init)
* @returns {boolean}
*/
registerComponent: function (name, component) {
if (!name || typeof name !== 'string') {
console.error('[UIManager] registerComponent: name 必须是字符串');
return false;
}
if (!component || !component.isInitialized) {
console.error('[UIManager] registerComponent: 组件未初始化:', name);
return false;
}
if (_components[name]) {
console.warn('[UIManager] registerComponent: 覆盖已存在组件:', name);
}
_components[name] = component;
console.log('[UIManager] 注册组件 "' + name + '"');
return true;
},
/**
* 获取已注册组件
* @param {string} name
* @returns {Object|null}
*/
getComponent: function (name) {
return _components[name] || null;
},
/**
* 注销组件(不销毁,只从注册表移除)
* @param {string} name
* @returns {boolean}
*/
unregisterComponent: function (name) {
if (!_components[name]) {
console.warn('[UIManager] unregisterComponent: 组件不存在:', name);
return false;
}
var c = _components[name];
if (c.isVisible) { c.hide(); }
delete _components[name];
console.log('[UIManager] 注销组件 "' + name + '"');
return true;
},
/**
* 销毁组件(调用 component.destroy() 并移除)
* @param {string} name
* @returns {boolean}
*/
destroyComponent: function (name) {
if (!_components[name]) {
console.warn('[UIManager] destroyComponent: 组件不存在:', name);
return false;
}
_components[name].destroy();
delete _components[name];
console.log('[UIManager] 销毁组件 "' + name + '"');
return true;
},
/**
* 显示指定视图(便捷方法)
* @param {string} name
*/
showView: function (name) {
var c = _components[name];
if (!c) { console.warn('[UIManager] showView: 组件不存在:', name); return; }
c.show();
},
/**
* 隐藏指定视图(便捷方法)
* @param {string} name
*/
hideView: function (name) {
var c = _components[name];
if (!c) { console.warn('[UIManager] hideView: 组件不存在:', name); return; }
c.hide();
},
// ============================================================
// 场景管理
// ============================================================
/**
* 注册场景
* @param {string} sceneName
* @param {string[]} componentNames - 该场景包含的组件名列表
* @returns {boolean}
*/
registerScene: function (sceneName, componentNames) {
if (!sceneName || typeof sceneName !== 'string') {
console.error('[UIManager] registerScene: sceneName 必须是字符串');
return false;
}
if (!Array.isArray(componentNames)) {
console.error('[UIManager] registerScene: componentNames 必须是数组');
return false;
}
for (var i = 0; i < componentNames.length; i++) {
if (!_components[componentNames[i]]) {
console.error('[UIManager] registerScene: 组件不存在:', componentNames[i]);
return false;
}
}
_scenes[sceneName] = componentNames;
console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
return true;
},
/**
* 切换到指定场景
* 自动隐藏当前场景,显示新场景,发射 EventBus.Events.SCENE_CHANGED
* @param {string} sceneName
* @returns {boolean}
*/
switchToScene: function (sceneName) {
if (!_scenes[sceneName]) {
console.error('[UIManager] switchToScene: 场景不存在:', sceneName);
return false;
}
if (_currentScene === sceneName) {
return true;
}
console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
_hideCurrentScene();
_showScene(sceneName);
var oldScene = _currentScene;
_currentScene = sceneName;
EventBus.emit(EventBus.Events.SCENE_CHANGED, { from: oldScene, to: sceneName });
return true;
},
/**
* 获取当前场景名
* @returns {string|null}
*/
getCurrentScene: function () {
return _currentScene;
},
// ============================================================
// 全局 UI - Loading
// ============================================================
/**
* 显示加载界面
* @param {string} [message='加载中...']
*/
showLoading: function (message) {
if (!LOADING_UI) { return; }
message = message || '加载中...';
SpriteManager.setText(LOADING_UI.SPRITES.LOADING_TEXT, message);
SpriteManager.show(LOADING_UI.SPRITES.BACKGROUND);
SpriteManager.show(LOADING_UI.SPRITES.LOADING_ICON);
SpriteManager.show(LOADING_UI.SPRITES.LOADING_TEXT);
SpriteManager.showGroup(LOADING_UI.GROUP_ID);
},
/**
* 隐藏加载界面
*/
hideLoading: function () {
if (!LOADING_UI) { return; }
SpriteManager.hide(LOADING_UI.SPRITES.BACKGROUND);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_ICON);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT);
SpriteManager.hideGroup(LOADING_UI.GROUP_ID);
},
// ============================================================
// 全局 UI - Message
// ============================================================
/**
* 显示轻提示(Toast,无精灵,仅 console)
* @param {string} message
*/
showToast: function (message) {
console.log('[UIManager] Toast:', message);
},
/**
* 显示一个带标签的提示效果(Toast 形式,通用,不含任何玩法语义)
* @param {string} tag - 效果标签(仅用于日志区分,由调用方自定义)
* @param {string} title - 提示标题
* @param {string} [description] - 补充说明
*/
showEffectToast: function (tag, title, description) {
var msg = description ? (title + ':' + description) : title;
console.log('[UIManager] EffectToast [' + tag + ']:', msg);
this.showToast(msg);
},
/**
* 显示消息提示(自动隐藏)
* @param {string} message
* @param {number} [duration=2000] 毫秒
*/
showMessage: function (message, duration) {
if (!MESSAGE_UI || !message) { return; }
duration = duration || 2000;
SpriteManager.setText(MESSAGE_UI.SPRITES.TEXT, message);
SpriteManager.show(MESSAGE_UI.SPRITES.BACKGROUND);
SpriteManager.show(MESSAGE_UI.SPRITES.TEXT);
SpriteManager.showGroup(MESSAGE_UI.GROUP_ID);
var self = this;
setTimeout(function () { self.hideMessage(); }, duration);
},
/**
* 隐藏消息提示
*/
hideMessage: function () {
if (!MESSAGE_UI) { return; }
SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND);
SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT);
SpriteManager.hideGroup(MESSAGE_UI.GROUP_ID);
},
// ============================================================
// 全局 UI - Confirm
// ============================================================
/**
* 显示确认对话框
* @param {string} title
* @param {string} message
* @param {Function} [onConfirm]
* @param {Function} [onCancel]
*/
showConfirm: function (title, message, onConfirm, onCancel) {
if (!CONFIRM_UI || !message) { return; }
title = title || '确认';
SpriteManager.setText(CONFIRM_UI.SPRITES.TITLE_TEXT, title);
SpriteManager.setText(CONFIRM_UI.SPRITES.MESSAGE_TEXT, message);
SpriteManager.show(CONFIRM_UI.SPRITES.BACKGROUND);
SpriteManager.show(CONFIRM_UI.SPRITES.TITLE_TEXT);
SpriteManager.show(CONFIRM_UI.SPRITES.MESSAGE_TEXT);
SpriteManager.show(CONFIRM_UI.SPRITES.BTN_CONFIRM);
SpriteManager.show(CONFIRM_UI.SPRITES.BTN_CANCEL);
SpriteManager.showGroup(CONFIRM_UI.GROUP_ID);
// 回调通过全局变量传递(gameabc 引擎通过点击事件触发)
window._confirmCallback = { onConfirm: onConfirm, onCancel: onCancel };
},
/**
* 隐藏确认对话框
*/
hideConfirm: function () {
if (!CONFIRM_UI) { return; }
SpriteManager.hide(CONFIRM_UI.SPRITES.BACKGROUND);
SpriteManager.hide(CONFIRM_UI.SPRITES.TITLE_TEXT);
SpriteManager.hide(CONFIRM_UI.SPRITES.MESSAGE_TEXT);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL);
SpriteManager.hideGroup(CONFIRM_UI.GROUP_ID);
if (window._confirmCallback) { delete window._confirmCallback; }
},
// ============================================================
// 工具方法
// ============================================================
/**
* 获取所有已注册组件名称
* @returns {string[]}
*/
getRegisteredComponents: function () {
var names = [];
for (var n in _components) {
if (_components.hasOwnProperty(n)) { names.push(n); }
}
return names;
},
/**
* 获取所有已注册场景名称
* @returns {string[]}
*/
getRegisteredScenes: function () {
var names = [];
for (var n in _scenes) {
if (_scenes.hasOwnProperty(n)) { names.push(n); }
}
return names;
},
/**
* 获取当前状态快照
* @returns {Object}
*/
getState: function () {
return {
isInitialized: _isInitialized,
currentScene: _currentScene,
componentCount: Object.keys(_components).length,
sceneCount: Object.keys(_scenes).length,
components: this.getRegisteredComponents(),
scenes: this.getRegisteredScenes()
};
},
/**
* 打印状态(调试用)
*/
printState: function () {
console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
},
/**
* 销毁 UIManager(销毁所有组件并重置状态)
*/
destroy: function () {
console.log('[UIManager] 开始销毁');
for (var name in _components) {
if (_components.hasOwnProperty(name)) { this.destroyComponent(name); }
}
this.hideLoading();
this.hideMessage();
this.hideConfirm();
_components = {};
_scenes = {};
_currentScene = null;
_isInitialized = false;
this.initialized = false;
console.log('[UIManager] 销毁完成');
}
};
return publicAPI;
})();
if (typeof module !== 'undefined' && module.exports) {
module.exports = UIManager;
}