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