初始化仓库:友乐/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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
/**
* AudioManager.js - 音频管理器(框架层)
*
* 功能:
* 1. 统一管理游戏音效和背景音乐的播放/停止
* 2. 封装底层音频API (Utl.playSound/stopSound/playMusic/stopMusic)
* 3. 提供根据性别选择语音的通用基础设施
*
* ⚠️ 框架设计原则:
* - 本文件不包含任何具体游戏的资源常量(不引用 SoundResources)
* - 不包含任何游戏专属的 ID 映射(如麻将牌值、动作类型)
* - 游戏专属逻辑在子游戏侧封装(如 GameAudio.js)
* - 静音开关与音量由平台层(Utl / 平台设置)统一处理,本文件不维护其状态
*
* 核心 API:
* - AudioManager.playSound(soundFile) // 播放任意音效文件
* - AudioManager.playVoice(baseId, sex) // 播放语音(自动切换男/女音)
* - AudioManager.playVoiceBySeat(seat, baseId) // 播放语音(从座位自动获取性别)
* - AudioManager.playMusic() // 播放背景音乐
*
* 性别判断:
* - sex === 2 为女性,女音文件 ID = 男音 baseId + FEMALE_VOICE_OFFSET (100)
* - 可通过 Utl.getSexBySeat(seat) 获取座位上玩家的性别
*
* 注意:
* - 背景音乐需要在 Game_Config.soundList 中配置
* - 游戏专属的 ID 映射(牌值/动作类型等)由子游戏侧 GameAudio.js 维护
* - 是否静音 / 音量大小由平台层 Utl.playSound / Utl.playMusic 内部按用户设置决定
*
* ============================================================================
* @guide 开发指南 - AudioManager 使用规范
* ============================================================================
*
* 【初始化(UIBootstrap 或游戏入口调用一次)】
* AudioManager.init();
*
* 【播放任意音效文件(游戏层传文件名)】
* AudioManager.playSound('00101.mp3');
* // 通常配合游戏侧常量使用:
* AudioManager.playSound(SoundResources.EFFECTS.DISCARD);
*
* 【通用性别语音播放(框架层 API,游戏层控制 ID)】
*
* gameabc 引擎语音文件命名规则:
* - 文件名统一为 5位数字 + ".mp3",如 "00151.mp3"
* - 男音使用基础 ID,女音 = 基础 ID + FEMALE_VOICE_OFFSET(默认 100)
* - 例:男音 baseId=151 → "00151.mp3",女音 → "00251.mp3"
*
* // 指定 baseId 和性别(ID 含义由游戏层定义):
* AudioManager.playVoice(151, sex); // 自动切换男/女音
* AudioManager.playVoiceBySeat(seat, 151); // 从座位自动获取性别再播放
*
* 【游戏侧扩展模式(在子游戏中创建 GameAudio.js)】
*
* // 子游戏自行维护 ID 映射,调用框架的 playVoice/playVoiceBySeat
* var ACTION_VOICE_BASE_ID = {
* 'peng': 152, 'gang': 153, 'hu': 154, 'chi': 151
* };
* GameAudio.playActionBySeat = function(seat, action) {
* var baseId = ACTION_VOICE_BASE_ID[action];
* if (baseId) AudioManager.playVoiceBySeat(seat, baseId);
* };
* // 调用:GameAudio.playActionBySeat(seat, 'peng');
*
* 【背景音乐】
* AudioManager.playMusic(); // 播放(需在 Game_Config.soundList 中配置)
* AudioManager.stopMusic();
*
* @version 4.0.0
* @date 2026-06-08
*/
var AudioManager = AudioManager || {};
(function() {
'use strict';
// ============================================================================
// 私有变量
// ============================================================================
var _initialized = false;
var _currentMusic = null;
var _playingSounds = {}; // { soundFile: playCount }
// ============================================================================
// 框架常量 - gameabc 引擎语音规则
// ============================================================================
/**
* 女性语音 ID 偏移量
* gameabc 引擎约定:女音文件 ID = 男音基础 ID + FEMALE_VOICE_OFFSET
*/
var FEMALE_VOICE_OFFSET = 100;
/**
* 女性性别值(来自 Utl.getSexBySeat 返回值约定)
*/
var SEX_FEMALE = 2;
// ============================================================================
// 私有方法
// ============================================================================
/**
* 检查 Utl API 是否可用
* @private
*/
function _isUtlAvailable() {
return typeof Utl !== 'undefined';
}
/**
* 格式化语音文件名
* gameabc 引擎语音文件统一为 5位数字 + .mp3
* @private
* @param {number} fileNum
* @returns {string} 如 "00151.mp3"
*/
function _formatSoundFile(fileNum) {
var s = String(fileNum);
while (s.length < 5) { s = '0' + s; }
return s + '.mp3';
}
function _isFemale(sex) {
return sex === SEX_FEMALE;
}
/**
* 通过座位号获取玩家性别
* @private
* @param {number} seat
* @returns {number} 性别值(2=女,其他=男)
*/
function _getSexBySeat(seat) {
if (_isUtlAvailable() && typeof Utl.getSexBySeat === 'function') {
return Utl.getSexBySeat(seat);
}
return 1;
}
/**
* 根据性别获取语音 ID 偏移量
* @private
* @param {number} sex
* @returns {number} 0(男)或 FEMALE_VOICE_OFFSET(女)
*/
function _getVoiceOffset(sex) {
return _isFemale(sex) ? FEMALE_VOICE_OFFSET : 0;
}
// ============================================================================
// 公共 API - 初始化
// ============================================================================
/**
* 初始化音频管理器
* 静音/音量由平台层处理,本管理器无需配置项。
*/
AudioManager.init = function() {
if (_initialized) {
console.warn('[AudioManager] Already initialized');
return;
}
_initialized = true;
console.log('[AudioManager] Initialized (v4.0.0)');
};
// ============================================================================
// 公共 API - 音效播放
// ============================================================================
/**
* 播放音效
* @param {string} soundFile - 文件名,如 "00101.mp3" 或游戏侧常量
* @returns {boolean}
*/
AudioManager.playSound = function(soundFile) {
if (!soundFile) { return false; }
if (!_isUtlAvailable() || !Utl.playSound) {
console.warn('[AudioManager] Utl.playSound not available');
return false;
}
try {
Utl.playSound(soundFile);
_playingSounds[soundFile] = (_playingSounds[soundFile] || 0) + 1;
return true;
} catch (e) {
console.error('[AudioManager] playSound error:', soundFile, e);
return false;
}
};
/**
* 停止音效
* @param {string} soundFile
* @returns {boolean}
*/
AudioManager.stopSound = function(soundFile) {
if (!soundFile) { return false; }
if (!_isUtlAvailable() || !Utl.stopSound) {
console.warn('[AudioManager] Utl.stopSound not available');
return false;
}
try {
Utl.stopSound(soundFile);
if (_playingSounds[soundFile]) {
_playingSounds[soundFile]--;
if (_playingSounds[soundFile] <= 0) { delete _playingSounds[soundFile]; }
}
return true;
} catch (e) {
console.error('[AudioManager] stopSound error:', soundFile, e);
return false;
}
};
/** 停止所有音效 */
AudioManager.stopAllSounds = function() {
for (var f in _playingSounds) {
if (_playingSounds.hasOwnProperty(f)) { this.stopSound(f); }
}
_playingSounds = {};
};
// ============================================================================
// 公共 API - 性别语音(通用)
// ============================================================================
/**
* 根据 baseId 和性别播放语音
* 框架自动应用偏移:女音 = baseId + FEMALE_VOICE_OFFSET
* 游戏层负责维护 baseId 含义(哪个 ID 对应哪张牌/动作)
*
* @param {number} baseId - 男音基础 ID(如 151)
* @param {number} [sex=1] - 性别(2=女,其他=男)
* @returns {boolean}
*/
AudioManager.playVoice = function(baseId, sex) {
if (!baseId) { return false; }
return this.playSound(_formatSoundFile(baseId + _getVoiceOffset(sex)));
};
/**
* 从座位自动获取性别后播放语音(推荐用法)
* @param {number} seat - 座位号
* @param {number} baseId - 男音基础 ID
* @returns {boolean}
*/
AudioManager.playVoiceBySeat = function(seat, baseId) {
return this.playVoice(baseId, _getSexBySeat(seat));
};
/**
* 获取语音文件名(不播放,用于预加载或调试)
* @param {number} baseId
* @param {number} [sex=1]
* @returns {string} 如 "00151.mp3"
*/
AudioManager.getVoiceFile = function(baseId, sex) {
return _formatSoundFile(baseId + _getVoiceOffset(sex));
};
// ============================================================================
// 公共 API - 背景音乐
// ============================================================================
/**
* 播放背景音乐
* 注意:需在 Game_Config.soundList 中配置
* @returns {boolean}
*/
AudioManager.playMusic = function() {
if (!_isUtlAvailable() || !Utl.playMusic) {
console.warn('[AudioManager] Utl.playMusic not available');
return false;
}
try {
Utl.playMusic();
_currentMusic = true;
return true;
} catch (e) {
console.error('[AudioManager] playMusic error:', e);
return false;
}
};
/**
* 停止背景音乐
* @returns {boolean}
*/
AudioManager.stopMusic = function() {
if (!_isUtlAvailable() || !Utl.stopMusic) { return false; }
try {
Utl.stopMusic();
_currentMusic = null;
return true;
} catch (e) {
console.error('[AudioManager] stopMusic error:', e);
return false;
}
};
/** 暂停背景音乐(等同 stopMusic) */
AudioManager.pauseMusic = function() {
return this.stopMusic();
};
/** 恢复背景音乐 */
AudioManager.resumeMusic = function() {
if (_currentMusic) { return this.playMusic(); }
return false;
};
// ============================================================================
// 公共API - 状态
// ============================================================================
/**
* 获取音频管理器状态
* @returns {Object}
*/
AudioManager.getStatus = function() {
var playingCount = 0;
for (var key in _playingSounds) {
if (_playingSounds.hasOwnProperty(key)) {
playingCount += _playingSounds[key];
}
}
return {
initialized: _initialized,
playingSoundsCount: playingCount,
isMusicPlaying: !!_currentMusic,
version: '4.0.0'
};
};
/**
* 销毁音频管理器
*/
AudioManager.destroy = function() {
this.stopAllSounds();
this.stopMusic();
_playingSounds = {};
_currentMusic = null;
_initialized = false;
};
})();
@@ -0,0 +1,234 @@
/**
* 事件总线 (独立模块)
* 职责: 提供模块间的事件通信机制,解耦模块依赖
* 遵循原则: 发布-订阅模式,支持事件监听和触发
*
* ============================================================================
* @guide 开发指南 - EventBus 使用规范
* ============================================================================
*
* 【事件命名规范】
* 使用 "模块:动作" 两段式命名,所有名称定义在 EventBus.Events 常量中:
* 'game:started' 游戏开始
* 'game:finished' 游戏结束
* 'player:discarded' 玩家出牌
* 说明:框架只内置通用事件(game:/player:/room:/ui:/network: 等);具体玩法的
* 专属事件(如发牌/摸牌/吃碰杠)由子游戏自行追加到 EventBus.Events,框架不内置。
* ⚠️ 不要硬编码字符串,统一用 EventBus.Events.XXX 引用
*
* 【订阅事件(在组件 init 中注册)】
* // 在 BaseComponent.init 或 Object.create 的 init 方法中:
* var self = this;
* var unsubscribe = EventBus.on(EventBus.Events.GAME_STARTED, function(data) {
* self._onGameStarted(data);
* }, self);
* // 保存引用用于 destroy 时清理
* this.eventListeners['game:started'] = unsubscribe;
*
* 【发布事件】
* // 在控制器或业务逻辑中发布:
* EventBus.emit(EventBus.Events.GAME_STARTED, { playerCount: 4 });
* EventBus.emit('player:discarded', { seat: 0, card: cardData });
*
* 【一次性事件(触发后自动取消)】
* EventBus.once(EventBus.Events.GAME_FINISHED, function(data) {
* self._showSettleUI(data);
* });
*
* 【在 destroy 中清理(防内存泄漏)】
* // 如果用 BaseComponent 的 addEventListener,destroy 时自动清理
* // 如果手动保存了 unsubscribe 函数:
* if (this.eventListeners['game:started']) {
* this.eventListeners['game:started'](); // 调用取消函数
* }
* // 或者 BaseComponent.destroy() 会自动处理 this.eventListeners 中的所有项
*
* 【推荐:通过 BaseComponent.addEventListener 代替手动管理】
* // BaseComponent 子类中:
* this.addEventListener(EventBus.Events.PLAYER_DISCARDED, function(data) {
* self._onPlayerDiscarded(data);
* });
* // destroy() 时 BaseComponent 会自动调用所有 off
*/
var EventBus = {
/**
* 私有数据存储
* @private
*/
_listeners: {}, // 事件监听器列表 {eventName: [callbacks]}
/**
* 订阅事件
* @param {String} eventName - 事件名称
* @param {Function} callback - 回调函数
* @param {Object} context - 回调函数的上下文(this)
* @return {Function} 取消订阅函数
*/
on: function(eventName, callback, context) {
if (!eventName || typeof callback !== 'function') {
console.error('❌ EventBus.on: 无效的参数');
return null;
}
if (!this._listeners[eventName]) {
this._listeners[eventName] = [];
}
var listener = {
callback: callback,
context: context || null
};
this._listeners[eventName].push(listener);
console.log('✅ EventBus: 订阅事件', eventName);
// 返回取消订阅函数
var self = this;
return function() {
self.off(eventName, callback);
};
},
/**
* 订阅一次性事件(触发后自动取消订阅)
* @param {String} eventName - 事件名称
* @param {Function} callback - 回调函数
* @param {Object} context - 回调函数的上下文
* @return {Function} 取消订阅函数
*/
once: function(eventName, callback, context) {
var self = this;
var onceCallback = function() {
callback.apply(context || null, arguments);
self.off(eventName, onceCallback);
};
return this.on(eventName, onceCallback, context);
},
/**
* 取消订阅事件
* @param {String} eventName - 事件名称
* @param {Function} callback - 要取消的回调函数(可选,不传则取消所有)
* @return {Boolean} 是否取消成功
*/
off: function(eventName, callback) {
if (!this._listeners[eventName]) {
return false;
}
// 如果没有指定callback,取消该事件的所有监听器
if (!callback) {
delete this._listeners[eventName];
console.log('✅ EventBus: 取消所有', eventName, '监听器');
return true;
}
// 取消指定的callback
var listeners = this._listeners[eventName];
for (var i = listeners.length - 1; i >= 0; i--) {
if (listeners[i].callback === callback) {
listeners.splice(i, 1);
console.log('✅ EventBus: 取消', eventName, '监听器');
return true;
}
}
return false;
},
/**
* 触发事件
* @param {String} eventName - 事件名称
* @param {*} data - 事件数据(可选)
* @return {Number} 触发的监听器数量
*/
emit: function(eventName, data) {
if (!this._listeners[eventName]) {
return 0;
}
var listeners = this._listeners[eventName].slice(); // 复制数组,避免迭代中修改
var count = 0;
console.log('📡 EventBus: 触发事件', eventName, data);
for (var i = 0; i < listeners.length; i++) {
try {
var listener = listeners[i];
listener.callback.call(listener.context, data);
count++;
} catch (error) {
console.error('❌ EventBus: 事件回调异常', eventName, error);
}
}
return count;
},
/**
* 检查事件是否有监听器
* @param {String} eventName - 事件名称
* @return {Boolean} 是否有监听器
*/
hasListener: function(eventName) {
return !!(this._listeners[eventName] && this._listeners[eventName].length > 0);
},
/**
* 获取事件的监听器数量
* @param {String} eventName - 事件名称
* @return {Number} 监听器数量
*/
getListenerCount: function(eventName) {
return this._listeners[eventName] ? this._listeners[eventName].length : 0;
},
/**
* 获取所有事件名称
* @return {Array} 事件名称数组
*/
getEventNames: function() {
var names = [];
for (var name in this._listeners) {
if (this._listeners.hasOwnProperty(name)) {
names.push(name);
}
}
return names;
},
/**
* 清空所有事件监听器
*/
clear: function() {
this._listeners = {};
console.log('✅ EventBus: 已清空所有监听器');
},
/**
* 清空指定事件的所有监听器
* @param {String} eventName - 事件名称
*/
clearEvent: function(eventName) {
if (this._listeners[eventName]) {
delete this._listeners[eventName];
console.log('✅ EventBus: 已清空', eventName, '的所有监听器');
}
}
};
/**
* 事件名称常量容器(框架不预置任何具体事件名)
*
* 框架保持「游戏中立」:只提供发布-订阅机制与一个空的 EventBus.Events 容器,
* 不预定义任何事件名称常量。各子游戏在自身事件常量文件(如
* game/constants/GameEvents.js)中向 EventBus.Events 注册自己用到的【全部】事件,
* 业务侧统一通过 EventBus.Events.XXX 引用。
*
* 命名规范:两段式 "模块:动作"。例如通用语义 'game:started' / 'player:discarded' /
* 'room:updated' / 'ui:sceneChanged' / 'network:connected';玩法专属自定前缀(如
* 'mahjong:tilesDealt')。统一用常量引用,不在业务中硬编码字符串。
*/
EventBus.Events = {};
@@ -0,0 +1,370 @@
// ============================================================================
// 精灵事件控制器 (gameabc-framework / system, ES5标准)
// 基于 gameabc 引擎的精灵交互事件分发框架——游戏中立,可被任意子游戏复用
// ============================================================================
/**
* SpriteEventController —— 通用精灵交互事件分发器
*
* 职责:
* 接收平台引擎透传的精灵交互事件(按下/长按/松开/拖拽/绘制前/绘制),
* 按「精灵 ID」分发到注册的处理函数;并提供「全局绘制钩子」供需要对每个
* 绘制事件统一处理的子游戏能力(如牌面标记叠绘)挂载。
*
* 中立原则:
* 本控制器不含任何具体玩法逻辑。玩法专属的“每帧绘制”处理由子游戏通过
* registerGlobalDraw(handler) 注册,框架只负责遍历回调,不认识具体业务。
*
* 接入方式(由子游戏受限入口 Game_Modify.* 转调):
* Game_Modify.utlmousedown → SpriteEventController.handleMouseDown(...)
* Game_Modify.utlmousedown_nomove → handleMouseDownNoMove(...)
* Game_Modify.mouseup → handleMouseUp(...)
* Game_Modify.utlmousemove → handleMouseMove(...)
* Game_Modify.utlgamemydrawbegin → handleDrawBegin(...)
* Game_Modify.gamemydraw → handleDraw(...)
*
* 严格 ES5,确保浏览器/平台运行时兼容。
*/
var SpriteEventController = {
// ========================================================================
// 精灵交互事件处理器映射表(按精灵 ID 注册)
// ========================================================================
/** 精灵点击事件处理器 { spriteId: handler } */
mouseDownHandlers: {},
/** 精灵长按事件处理器(点击后未移动) */
mouseDownNoMoveHandlers: {},
/** 精灵松开事件处理器 */
mouseUpHandlers: {},
/** 精灵拖拽移动事件处理器 */
mouseMoveHandlers: {},
/** 精灵绘制前事件处理器 */
drawBeginHandlers: {},
/** 精灵绘制事件处理器 */
drawHandlers: {},
/**
* 全局绘制钩子列表:对【每个】绘制事件都会被调用(不按精灵 ID 过滤)。
* 供子游戏注册“需要扫描所有绘制精灵”的通用能力(如标记叠绘),保持框架中立。
*/
drawGlobalHandlers: [],
// ========================================================================
// 事件处理器注册方法
// ========================================================================
registerMouseDown: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.mouseDownHandlers[spriteId] = handler;
return true;
},
registerMouseDownNoMove: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.mouseDownNoMoveHandlers[spriteId] = handler;
return true;
},
registerMouseUp: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.mouseUpHandlers[spriteId] = handler;
return true;
},
registerMouseMove: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.mouseMoveHandlers[spriteId] = handler;
return true;
},
registerDrawBegin: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.drawBeginHandlers[spriteId] = handler;
return true;
},
registerDraw: function(spriteId, handler) {
if (typeof spriteId !== 'number' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的注册参数');
return false;
}
this.drawHandlers[spriteId] = handler;
return true;
},
/**
* 注册全局绘制钩子(每个绘制事件都会被回调,参数为精灵 ID)。
* 用于子游戏的“扫描所有绘制精灵”类通用处理;框架不认识其业务含义。
* @param {Function} handler - function(spriteId){}
*/
registerGlobalDraw: function(handler) {
if (typeof handler !== 'function') {
console.error('❌ SpriteEventController: 无效的全局绘制钩子');
return false;
}
this.drawGlobalHandlers.push(handler);
return true;
},
// ========================================================================
// 批量注册
// ========================================================================
registerBatch: function(spriteIds, eventType, handler) {
if (!Array.isArray(spriteIds) || typeof eventType !== 'string' || typeof handler !== 'function') {
console.error('❌ SpriteEventController: 批量注册参数错误');
return false;
}
var registerMethod = null;
switch (eventType) {
case 'mouseDown': registerMethod = this.registerMouseDown; break;
case 'mouseDownNoMove': registerMethod = this.registerMouseDownNoMove; break;
case 'mouseUp': registerMethod = this.registerMouseUp; break;
case 'mouseMove': registerMethod = this.registerMouseMove; break;
case 'draw': registerMethod = this.registerDraw; break;
case 'drawBegin': registerMethod = this.registerDrawBegin; break;
default:
console.error('❌ SpriteEventController: 未知的事件类型: ' + eventType);
return false;
}
var successCount = 0;
for (var i = 0; i < spriteIds.length; i++) {
if (registerMethod.call(this, spriteIds[i], handler)) {
successCount++;
}
}
// console.log('📊 批量注册完成: ' + successCount + '/' + spriteIds.length + ' (' + eventType + ')');
return successCount === spriteIds.length;
},
// ========================================================================
// 事件分发处理方法(由 Game_Modify 转调)
// ========================================================================
handleMouseDown: function(gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6) {
var handler = this.mouseDownHandlers[spid];
if (handler) {
try {
handler.call(this, {
gameid: gameid, spriteId: spid, x: downx, y: downy,
params: [no1, no2, no3, no4, no5, no6]
});
return true;
} catch (error) {
console.error('❌ 精灵点击事件处理失败:', error);
return false;
}
}
return false;
},
handleMouseDownNoMove: function(gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5) {
var handler = this.mouseDownNoMoveHandlers[spid];
if (handler) {
try {
handler.call(this, {
gameid: gameid, spriteId: spid, x: downx, y: downy, duration: timelong,
params: [no1, no2, no3, no4, no5]
});
return true;
} catch (error) {
console.error('❌ 精灵长按事件处理失败:', error);
return false;
}
}
return false;
},
handleMouseUp: function(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2) {
var downHandler = this.mouseUpHandlers[spid_down];
if (downHandler) {
try {
downHandler.call(this, {
gameid: gameid,
downSpriteId: spid_down,
upSpriteId: spid_up,
downPos: { x: downx, y: downy },
upPos: { x: upx, y: upy },
duration: timelong,
isClick: spid_down === spid_up,
params: [no1, no2]
});
return true;
} catch (error) {
console.error('❌ 精灵松开事件处理失败:', error);
return false;
}
}
return false;
},
handleMouseMove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1) {
var handler = this.mouseMoveHandlers[spid];
if (handler) {
try {
handler.call(this, {
gameid: gameid, spriteId: spid,
downPos: { x: downx, y: downy },
currentPos: { x: movex, y: movey },
offset: { x: offmovex, y: offmovey },
duration: timelong,
params: [no1]
});
return true;
} catch (error) {
console.error('❌ 精灵拖拽事件处理失败:', error);
return false;
}
}
return false;
},
handleDrawBegin: function(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
// 通用裁剪区域应用(DynamicSpriteList 等注册的裁剪)
if (typeof GameABCUtils !== 'undefined' && GameABCUtils.Clip) {
GameABCUtils.Clip.checkAndApply(spid);
}
var handler = this.drawBeginHandlers[spid];
if (handler) {
try {
handler.call(this, {
gameid: gameid, spriteId: spid, times: times, timelong: timelong,
params: [no2, no3, no4, no5, no6, no7]
});
return true;
} catch (error) {
console.error('❌ 精灵绘制前事件处理失败:', error);
return false;
}
}
return false;
},
handleDraw: function(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
// 先执行全局绘制钩子(子游戏注册的“扫描所有绘制精灵”类处理,框架不认识其业务)
for (var g = 0; g < this.drawGlobalHandlers.length; g++) {
try {
this.drawGlobalHandlers[g](spid);
} catch (gErr) {
console.error('❌ 全局绘制钩子执行失败:', gErr);
}
}
var handler = this.drawHandlers[spid];
if (handler) {
try {
handler.call(this, {
gameid: gameid, spriteId: spid, times: times, timelong: timelong,
params: [no2, no3, no4, no5, no6, no7]
});
return true;
} catch (error) {
console.error('❌ 精灵绘制事件处理失败:', error);
return false;
}
}
return false;
},
// ========================================================================
// 工具方法
// ========================================================================
unregister: function(spriteId, eventType) {
if (typeof spriteId !== 'number') {
console.error('❌ SpriteEventController: 无效的精灵ID');
return false;
}
if (eventType) {
switch (eventType) {
case 'mouseDown': delete this.mouseDownHandlers[spriteId]; break;
case 'mouseDownNoMove': delete this.mouseDownNoMoveHandlers[spriteId]; break;
case 'mouseUp': delete this.mouseUpHandlers[spriteId]; break;
case 'mouseMove': delete this.mouseMoveHandlers[spriteId]; break;
case 'draw': delete this.drawHandlers[spriteId]; break;
case 'drawBegin': delete this.drawBeginHandlers[spriteId]; break;
default:
console.error('❌ SpriteEventController: 未知的事件类型: ' + eventType);
return false;
}
} else {
delete this.mouseDownHandlers[spriteId];
delete this.mouseDownNoMoveHandlers[spriteId];
delete this.mouseUpHandlers[spriteId];
delete this.mouseMoveHandlers[spriteId];
delete this.drawHandlers[spriteId];
delete this.drawBeginHandlers[spriteId];
}
return true;
},
getStats: function() {
return {
mouseDown: Object.keys(this.mouseDownHandlers).length,
mouseDownNoMove: Object.keys(this.mouseDownNoMoveHandlers).length,
mouseUp: Object.keys(this.mouseUpHandlers).length,
mouseMove: Object.keys(this.mouseMoveHandlers).length,
draw: Object.keys(this.drawHandlers).length,
drawBegin: Object.keys(this.drawBeginHandlers).length,
drawGlobal: this.drawGlobalHandlers.length
};
},
clear: function() {
this.mouseDownHandlers = {};
this.mouseDownNoMoveHandlers = {};
this.mouseUpHandlers = {};
this.mouseMoveHandlers = {};
this.drawHandlers = {};
this.drawBeginHandlers = {};
this.drawGlobalHandlers = [];
// console.log('🧹 已清空所有精灵事件处理器');
},
init: function() {
// console.log('🚀 SpriteEventController 已初始化');
var dependencies = ['set_self', 'get_self', 'ifast_mydrawbmp'];
var missing = [];
for (var i = 0; i < dependencies.length; i++) {
if (typeof window[dependencies[i]] === 'undefined') {
missing.push(dependencies[i]);
}
}
if (missing.length > 0) {
console.warn('⚠️ 缺少依赖接口:', missing.join(', '));
console.warn('请确保已正确加载 gameabc.min.js');
}
return missing.length === 0;
}
};
// 自动初始化(浏览器/平台运行时)
if (typeof window !== 'undefined') {
SpriteEventController.init();
}
// Node 测试环境导出
if (typeof module !== 'undefined' && module.exports) {
module.exports = SpriteEventController;
}
@@ -0,0 +1,174 @@
// ============================================================================
// 精灵手势识别器 (gameabc-framework / system, ES5标准)
// 在 SpriteEventController 之上做高层手势识别——游戏中立,可被任意子游戏复用
// ============================================================================
/**
* SpriteGestureRecognizer —— 通用精灵手势识别器
*
* 职责:
* 把底层的「按下/移动/松开」原始事件,识别为高层手势并以语义回调通知:
* - press :按下(即时反馈,如元素站起)——双击的第二击不再触发 press
* - doubleTap :在 doubleTapInterval 内对【同一精灵】快速按下两次
* - swipe :沿指定方向移动超过阈值(不依赖松开,越阈即触发)
* - tap :一次「点击」(小位移按下→松开,且未被 swipe/doubleTap 消费)
*
* 中立原则:
* 本识别器只做手势分类,不含任何业务语义(选中/出牌/牌面等均由子游戏在回调里处理)。
* 阈值参数由调用方注入(doubleTapInterval / swipe.threshold / minMove),框架只给默认值。
*
* 依赖:SpriteEventController(须先加载)。
*
* 用法:
* var handle = SpriteGestureRecognizer.attach(spriteIds, {
* onPress: function (e) {}, // e: { spriteId, x, y, ... }
* onTap: function (e) {}, // e: 透传 mouseUp 事件(含 upSpriteId/downPos/upPos/isClick)
* onDoubleTap: function (e) {}, // e: 透传 mouseDown 事件
* onSwipe: function (e) {} // e: 透传 mouseMove 事件 + e.direction
* }, {
* doubleTapInterval: 300, // 双击间隔(ms)
* swipe: { direction: 'up', threshold: 50 }, // 滑动方向与阈值(像素,正值)
* minMove: 10 // 判定为点击的最大位移(像素)
* });
* // handle.resetDoubleTap() 清双击计时(防跨局误判)
* // handle.resetDrag() 取消进行中的拖动追踪
* // handle.detach() 注销该组精灵的手势识别
*
* 严格 ES5。
*/
var SpriteGestureRecognizer = {
/**
* 为一组精灵附加手势识别。
* @param {Array<number>} spriteIds - 精灵 ID 列表
* @param {Object} callbacks - { onPress, onTap, onDoubleTap, onSwipe }(均可选)
* @param {Object} [options] - { doubleTapInterval, swipe:{direction,threshold}, minMove }
* @returns {Object|null} handle: { detach, resetDoubleTap, resetDrag };参数非法返回 null
*/
attach: function (spriteIds, callbacks, options) {
if (typeof SpriteEventController === 'undefined') {
console.error('❌ SpriteGestureRecognizer: SpriteEventController 未加载');
return null;
}
if (!spriteIds || typeof spriteIds.length !== 'number') {
console.error('❌ SpriteGestureRecognizer: spriteIds 必须为数组');
return null;
}
callbacks = callbacks || {};
options = options || {};
var doubleTapInterval = (options.doubleTapInterval != null) ? options.doubleTapInterval : 300;
var minMove = (options.minMove != null) ? options.minMove : 10;
var swipe = options.swipe || null; // { direction, threshold }
// 本组精灵独立的手势状态(闭包隔离,支持多处 attach 互不干扰)
var state = {
dragActive: false,
lastTapTime: 0,
lastTapTarget: null
};
function _resetDrag() {
state.dragActive = false;
}
// ---- 越阈方向判定(仅在配置了 swipe 时启用)----
function _swipeCrossed(offset) {
if (!swipe || !swipe.direction) { return false; }
var th = (swipe.threshold != null) ? swipe.threshold : 50;
switch (swipe.direction) {
case 'up': return offset.y < -th;
case 'down': return offset.y > th;
case 'left': return offset.x < -th;
case 'right': return offset.x > th;
default: return false;
}
}
// ---- 原始事件处理(注册到 SpriteEventController)----
function _onDown(e) {
var now = Date.now();
var isDouble = state.lastTapTime > 0 &&
e.spriteId === state.lastTapTarget &&
(now - state.lastTapTime) < doubleTapInterval;
if (isDouble) {
// 消费双击:清计时,避免三连击;不再触发 press(与原手牌交互一致)
state.lastTapTime = 0;
state.lastTapTarget = null;
if (callbacks.onDoubleTap) { callbacks.onDoubleTap(e); }
return;
}
// 记录本次按下,开始拖动追踪
state.lastTapTime = now;
state.lastTapTarget = e.spriteId;
state.dragActive = true;
if (callbacks.onPress) { callbacks.onPress(e); }
}
function _onMove(e) {
if (!state.dragActive) { return; }
if (!e.offset) { return; }
if (_swipeCrossed(e.offset)) {
// 越阈即触发 swipe,并结束拖动追踪(后续 move/up 不再产生手势)
_resetDrag();
var se = e;
se.direction = swipe.direction;
if (callbacks.onSwipe) { callbacks.onSwipe(se); }
}
}
function _onUp(e) {
var wasDragging = state.dragActive;
_resetDrag();
if (!wasDragging) { return; } // 未在追踪(如双击第二击/已被 swipe 消费)→ 非 tap
if (!e.isClick) { return; } // 按下与松开不在同一精灵 → 非 tap
// 位移过大(非点击的拖动)→ 非 tap
if (e.upPos && e.downPos && Math.abs(e.upPos.y - e.downPos.y) > minMove) { return; }
if (callbacks.onTap) { callbacks.onTap(e); }
}
// 注册到底层事件控制器
var ids = [];
for (var i = 0; i < spriteIds.length; i++) {
var sid = spriteIds[i];
if (sid == null) { continue; }
SpriteEventController.registerMouseDown(sid, _onDown);
SpriteEventController.registerMouseMove(sid, _onMove);
SpriteEventController.registerMouseUp(sid, _onUp);
ids.push(sid);
}
return {
/** 注销该组精灵的全部手势事件 */
detach: function () {
for (var j = 0; j < ids.length; j++) {
SpriteEventController.unregister(ids[j], 'mouseDown');
SpriteEventController.unregister(ids[j], 'mouseMove');
SpriteEventController.unregister(ids[j], 'mouseUp');
}
_resetDrag();
},
/** 清空双击计时(游戏开始/重置时调用,防跨局误判) */
resetDoubleTap: function () {
state.lastTapTime = 0;
state.lastTapTarget = null;
},
/** 取消进行中的拖动追踪 */
resetDrag: function () {
_resetDrag();
}
};
}
};
// 全局暴露(浏览器/平台运行时)
if (typeof window !== 'undefined') {
window.SpriteGestureRecognizer = SpriteGestureRecognizer;
}
// Node 测试环境导出
if (typeof module !== 'undefined' && module.exports) {
module.exports = SpriteGestureRecognizer;
}