909 lines
30 KiB
JavaScript
909 lines
30 KiB
JavaScript
/**
|
||
* ============================================================================
|
||
* 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 // 回弹速度
|
||
* });
|
||
*
|
||
* 【横向列表(左右滑动)】
|
||
* var themeList = new DynamicSpriteList({
|
||
* containerId: sprites.LIST_CONTAINER,
|
||
* clipArea: { x: 340, y: 240, width: 600, height: 160 },
|
||
* orientation: 'horizontal', // 默认 'vertical'
|
||
* itemWidth: 262, // 主轴步长(项宽 + 项间距),与纵向 rowHeight 对称
|
||
* renderRow: function (ctx) {
|
||
* // 横向下 ctx.addSprite 的 x 会叠加该项的主轴基准,y 原样
|
||
* var sid = ctx.addSprite('preview', TPL_ID, 0, 0);
|
||
* }
|
||
* });
|
||
* // 事件转发:横向传 offmovex(纵向传 offmovey)
|
||
* themeList.handleMouseMove(event.spriteId, event.offset.x);
|
||
*
|
||
* 【设置数据(触发渲染)】
|
||
* // 数据数组中每个元素对应一行
|
||
* // 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 // 标记为可点击
|
||
* }
|
||
* }
|
||
*/
|
||
/**
|
||
* 主轴/交叉轴的引擎操作码与 clipArea 字段映射
|
||
* 前缀 DSL_ 是必要的:友乐运行时所有 <script> 共享同一全局作用域,
|
||
* 通用名(如 LIST_AXIS)会与其它文件互相覆盖。
|
||
* 操作码:18=左右移动 19=上下移动 20=设宽 21=设高
|
||
* (docs/important/client/精灵操作详细指南.md:38-41)
|
||
*/
|
||
var DSL_LIST_AXIS = {
|
||
vertical: {
|
||
posOp: 19, sizeOp: 21, crossPosOp: 18, crossSizeOp: 20,
|
||
clipPos: 'y', clipSize: 'height', crossClipPos: 'x', crossClipSize: 'width',
|
||
stepField: 'rowHeight'
|
||
},
|
||
horizontal: {
|
||
posOp: 18, sizeOp: 20, crossPosOp: 19, crossSizeOp: 21,
|
||
clipPos: 'x', clipSize: 'width', crossClipPos: 'y', crossClipSize: 'height',
|
||
stepField: 'itemWidth'
|
||
}
|
||
};
|
||
|
||
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');
|
||
}
|
||
var orientation = (config.orientation === 'horizontal') ? 'horizontal' : 'vertical';
|
||
var axis = DSL_LIST_AXIS[orientation];
|
||
var step = config[axis.stepField];
|
||
if (typeof step !== 'number' || step <= 0) {
|
||
throw new Error('DynamicSpriteList: ' + axis.stepField + ' 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
|
||
};
|
||
|
||
/**
|
||
* 排列方向:'vertical'(默认) | 'horizontal'
|
||
* @type {string}
|
||
*/
|
||
this.orientation = orientation;
|
||
|
||
/**
|
||
* 轴映射(操作码 + clipArea 取用字段),内部所有方向相关操作一律经此表
|
||
* @type {Object}
|
||
* @private
|
||
*/
|
||
this._axis = axis;
|
||
|
||
/**
|
||
* 主轴步长:纵向=行高(rowHeight),横向=项宽含间距(itemWidth)
|
||
* @type {number}
|
||
* @private
|
||
*/
|
||
this._step = step;
|
||
|
||
/**
|
||
* 每行高度(纵向语义保留字段;横向下等于主轴步长)
|
||
* @type {number}
|
||
*/
|
||
this.rowHeight = step;
|
||
|
||
/**
|
||
* 模板精灵配置
|
||
* @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} offset - 目标主轴偏移量(纵向=Y方向,横向=X方向)
|
||
* @param {boolean} [animated=true] - 是否使用动画
|
||
* @returns {DynamicSpriteList} 返回自身,支持链式调用
|
||
*/
|
||
DynamicSpriteList.prototype.scrollTo = function(offset, animated) {
|
||
var axis = this._axis;
|
||
var clipStart = this.clipArea[axis.clipPos];
|
||
var clipSize = this.clipArea[axis.clipSize];
|
||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||
var target = clipStart - offset;
|
||
|
||
// 边界钳制(双向):先钳末端,再钳起点。
|
||
// 内容不足一屏时 min > clipStart,两条钳制叠加后恒为起点——这正是期望行为。
|
||
var contentSize = this._data.length * this._step;
|
||
var min = clipStart + clipSize - contentSize;
|
||
if (target < min) { target = min; }
|
||
if (target > clipStart) { target = clipStart; }
|
||
|
||
if (animated !== false) {
|
||
play_ani(1, this.containerId, axis.posOp, current, target, 0,
|
||
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
|
||
} else {
|
||
set_self(this.containerId, axis.posOp, target, 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 contentSize = this._data.length * this._step;
|
||
var maxScroll = contentSize - this.clipArea[this._axis.clipSize];
|
||
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._step;
|
||
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} offsetMain - 主轴方向移动偏移量(纵向传 Y 方向偏移,横向传 X 方向偏移)
|
||
* @returns {boolean} 如果事件被处理返回 true
|
||
*
|
||
* @example
|
||
* // 在 SpriteEventController 或 Game_Modify 中(纵向列表传 offmovey,横向列表传 offmovex)
|
||
* utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
|
||
* if (myList.handleMouseMove(spid, offmovey)) { // 横向列表改传 offmovex
|
||
* return; // 事件已处理
|
||
* }
|
||
* // 其他处理...
|
||
* }
|
||
*/
|
||
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetMain) {
|
||
if (!this.enableScroll) {
|
||
return false;
|
||
}
|
||
|
||
if (spid === this.containerId) {
|
||
if (Math.abs(offsetMain) > this.scrollSensitivity) {
|
||
var axis = this._axis;
|
||
// 增量移动容器(第4参 1 = 相对增量)
|
||
set_self(this.containerId, axis.posOp, offsetMain, 1, 0, 0);
|
||
this._isSliding = true;
|
||
|
||
if (typeof this.onScroll === 'function') {
|
||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||
var scrolled = this.clipArea[axis.clipPos] - current;
|
||
var maxScroll = this._data.length * this._step - this.clipArea[axis.clipSize];
|
||
this.onScroll(scrolled, maxScroll > 0 ? maxScroll : 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() {
|
||
var axis = this._axis;
|
||
set_self(this.containerId, axis.posOp, this.clipArea[axis.clipPos], 0, 0);
|
||
set_self(this.containerId, axis.crossPosOp, this.clipArea[axis.crossClipPos], 0, 0);
|
||
var contentSize = this._data.length * this._step;
|
||
set_self(this.containerId, axis.sizeOp, contentSize, 0, 0);
|
||
|
||
if (this.templates) {
|
||
var tnames = Object.keys(this.templates);
|
||
if (tnames.length > 0) {
|
||
// 交叉轴尺寸取首个模板的对应尺寸(纵向=宽 op20,横向=高 op21)
|
||
var crossFromTpl = get_self(this.templates[tnames[0]].spriteId, axis.crossSizeOp, 0, 0, 0);
|
||
set_self(this.containerId, axis.crossSizeOp, crossFromTpl, 0, 0);
|
||
}
|
||
} else {
|
||
// renderRow 模式:交叉轴尺寸取裁剪区对应边,保证整个可视区都在容器 hit 区(点击/拖动检测)内
|
||
set_self(this.containerId, axis.crossSizeOp, this.clipArea[axis.crossClipSize], 0, 0);
|
||
}
|
||
|
||
for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) {
|
||
var rowData = this._data[rowIndex];
|
||
var rowBase = rowIndex * this._step;
|
||
|
||
if (this.renderRow) {
|
||
this.renderRow(this._makeRowContext(rowIndex, rowData, rowBase));
|
||
} 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 px = (this.orientation === 'horizontal') ? (rowBase + ox) : ox;
|
||
var py = (this.orientation === 'horizontal') ? oy : (rowBase + oy);
|
||
var sid = this._addSpriteInternal(name, t.spriteId, px, py, 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} rowBase - 行基准坐标(纵向=Y,横向=X)
|
||
* @returns {Object} 行上下文
|
||
* @private
|
||
*/
|
||
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBase) {
|
||
var self = this;
|
||
var isH = (this.orientation === 'horizontal');
|
||
return {
|
||
rowIndex: rowIndex,
|
||
rowData: rowData,
|
||
rowBase: rowBase,
|
||
// 保留 rowBaseY:renderRow 回调的既有字段协议,不得破坏。
|
||
// 当前生产消费方为 RecordView.js(唯一使用本组件的调用方);
|
||
// 回归测试 DynamicSpriteList.test.js 对该字段有硬断言。
|
||
rowBaseY: rowBase,
|
||
orientation: this.orientation,
|
||
containerId: this.containerId,
|
||
addSprite: function (name, templateSpriteId, x, y) {
|
||
var px = isH ? (rowBase + x) : x;
|
||
var py = isH ? y : (rowBase + y);
|
||
return self._addSpriteInternal(name, templateSpriteId, px, py, rowIndex, true);
|
||
}
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 处理边界回弹
|
||
* @private
|
||
*/
|
||
DynamicSpriteList.prototype._handleBounce = function() {
|
||
var axis = this._axis;
|
||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||
var contentSize = get_self(this.containerId, axis.sizeOp, 0, 0, 0);
|
||
var clipStart = this.clipArea[axis.clipPos];
|
||
var clipSize = this.clipArea[axis.clipSize];
|
||
|
||
var target = current;
|
||
var needBounce = false;
|
||
|
||
if (contentSize <= clipSize) {
|
||
// 内容不足一屏,回弹到起点
|
||
if (current !== clipStart) {
|
||
target = clipStart;
|
||
needBounce = true;
|
||
}
|
||
} else {
|
||
if (current > clipStart) {
|
||
// 越过起点
|
||
target = clipStart;
|
||
needBounce = true;
|
||
} else {
|
||
var min = clipStart + clipSize - contentSize;
|
||
if (current < min) {
|
||
// 越过末端
|
||
target = min;
|
||
needBounce = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (needBounce) {
|
||
play_ani(1, this.containerId, axis.posOp, current, target, 0,
|
||
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理点击事件
|
||
* @param {number} x - 点击X坐标
|
||
* @param {number} y - 点击Y坐标
|
||
* @private
|
||
*/
|
||
DynamicSpriteList.prototype._handleClick = function(x, y) {
|
||
// 裁剪区外的点击一律不算数。
|
||
// 原因:引擎的 set_clip 只裁【绘制】、不裁【命中】,而 _render 会把容器的主轴尺寸
|
||
// 撑到内容全长(远超可视区)。于是滑出可视区、屏幕上已经看不见的那些项,其矩形
|
||
// 仍在容器内,ifast_check_add 照样判为命中 —— 用户点到列表旁的空白带,却选中了
|
||
// 一个根本看不见的项。这里按 clipArea 兜住,横纵通用。
|
||
var a = this.clipArea;
|
||
if (x < a.x || x >= a.x + a.width || y < a.y || y >= a.y + a.height) {
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|