From 3b7c103387c46f85b5a56bbcaf53c912dfd39e99 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Tue, 11 Aug 2026 22:17:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=B7=A5=E7=A8=8B=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/index.html | 352 +- client/js/00_Surface/04_Data.js | 2 +- client/js/00_Surface/05_Func.js | 4 +- client/js/00_Surface/06_Player.js | 19 +- client/js/00_Surface/07_Desk.js | 2 + client/js/00_Surface/08_Utl_Output.js | 66 +- client/js/00_Surface/12_Logic.js | 55 +- client/js/01_SubGame/00_SubGame_Config.js | 10 +- client/js/01_SubGame/02_SubGame_Input.js | 5 + client/js/client_Event.js | 1 + client/js/docs/Spine动画集成手册.md | 59 +- .../js/gameabc-framework/core/GameABCUtils.js | 2 +- .../gameabc-framework/core/SpriteManager.js | 6 +- .../js/gameabc-framework/network/RpcHelper.js | 30 +- .../spine/SpineBackendCanvas.js | 69 + .../spine/SpineBackendWebGL.js | 315 + client/js/gameabc-framework/spine/SpineMgr.js | 278 +- .../spine/SpineOverlayTransform.js | 88 + .../system/AnimationManager.js | 2 +- .../gameabc-framework/system/AudioManager.js | 2 +- .../js/gameabc-framework/system/EventBus.js | 12 +- .../templates/UIBootstrap.template.js | 4 +- .../subgame-entry/SubGameHooks.template.js | 17 +- .../js/gameabc-framework/ui/BaseComponent.js | 28 +- .../gameabc-framework/ui/DynamicSpriteList.js | 240 +- client/js/gameabc-framework/ui/RecordView.js | 21 +- client/js/gameabc-framework/ui/UIManager.js | 30 +- client/js/vendor/README-spine-patches.md | 125 + client/js/vendor/gameabc.min.js | 79 +- client/js/vendor/spine-webgl.js | 14172 ++++++++++++++++ client/save/Project.xml | 1536 +- client/version.js | 17 +- .../development-guide/02-渲染与UI组件体系.md | 1 + .../04-网络对接与启动编排.md | 88 +- .../development-guide/05-开发规范与红线.md | 19 +- docs/client/development-guide/README.md | 6 +- .../03-数据收发与通信协议.md | 2 +- .../development-guide/04-开发规范与红线.md | 48 +- 38 files changed, 16607 insertions(+), 1205 deletions(-) create mode 100644 client/js/client_Event.js create mode 100644 client/js/gameabc-framework/spine/SpineBackendCanvas.js create mode 100644 client/js/gameabc-framework/spine/SpineBackendWebGL.js create mode 100644 client/js/gameabc-framework/spine/SpineOverlayTransform.js create mode 100644 client/js/vendor/README-spine-patches.md create mode 100644 client/js/vendor/spine-webgl.js diff --git a/client/index.html b/client/index.html index 57a770e..5c4814b 100644 --- a/client/index.html +++ b/client/index.html @@ -1,145 +1,265 @@ - - - - - - - erqiwang - + + + } + -
- -

Your browser does not support the canvas element.

-
+
+ +

Your browser does not support the canvas element.

+
-
- -

Your browser does not support the canvas element.

-
+
+ +

Your browser does not support the canvas element.

+
-
- -

Your browser does not support the canvas element.

-
+
+ +

Your browser does not support the canvas element.

+
- -

Your browser does not support the canvas element.

-
+ +

Your browser does not support the canvas element.

+
+
+ +
+
+ + + - + + - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/client/js/00_Surface/04_Data.js b/client/js/00_Surface/04_Data.js index 8321e70..1c07a28 100644 --- a/client/js/00_Surface/04_Data.js +++ b/client/js/00_Surface/04_Data.js @@ -142,7 +142,7 @@ GameData.hallConfigName = ["enablePay", 'wareHouse',//是否开启仓库功能0->不开启1->开启保险箱不开启赠送2->开启赠送不开启保险箱3->开启星星赠送(开启保险箱)4->开启房卡赠送(开启保险箱)5->开启星星房卡赠送(开启保险箱) 'betaText',//版本号后的beta显示内容 'launchImgUrl',//启动页图片地址 - 'shareType:',//分享朋友圈类型0->链接分享 1->图片分享 + 'shareType',//分享朋友圈类型0->链接分享 1->图片分享 'shareImgUrl',//分享朋友圈图片链接 'launchWaitTime',//启动等待时间 'shareBgUrl',//分享背景图片 diff --git a/client/js/00_Surface/05_Func.js b/client/js/00_Surface/05_Func.js index 6689ccc..7951d34 100644 --- a/client/js/00_Surface/05_Func.js +++ b/client/js/00_Surface/05_Func.js @@ -1377,7 +1377,7 @@ Func.accreditlogin=function(type){ ]; var ram = ifast_random(100000); - sharelogin("fff23"+ram,headLoginList[ifast_random(headLoginList.length)],"测试"+ram,ifast_random(2)+1,"nanchang","jiangxi","ylgame"+ram); + sharelogin("fff23"+ram,headLoginList[ifast_random(headLoginList.length)],ram + "测试",ifast_random(2)+1,"nanchang","jiangxi","ylgame"+ram); // sharelogin("fff23"+ram,headLoginList[ifast_random(headLoginList.length)],"测试"+ram,ifast_random(2)+1,"nanchang","jiangxi","oLVKis6bj3_l8qspMybG60KV2GN4"); } @@ -2432,7 +2432,7 @@ Func.getmarketname = function(){// return window.settings.getmarketname(); }catch(e){ if(GameData.userAgent != ConstVal.userAgent.uAgent_3){ - return 4; + return 2; }else{ return app_market; } diff --git a/client/js/00_Surface/06_Player.js b/client/js/00_Surface/06_Player.js index df12185..d046528 100644 --- a/client/js/00_Surface/06_Player.js +++ b/client/js/00_Surface/06_Player.js @@ -17,6 +17,12 @@ function Player(seat){ //this.offline = 0;//玩家是否离线0->否 1->离线 this.canexit=1;//是否可以直接离开 this.onstate=0;//0:在线1:离线2:通话中 + /* + {"address":"江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)","city":"南昌市", + "cityCode":"0791","country":"中国","district":"青云谱区","latitude":28.623546,"longitude":115.900333, + "province":"江西省","street":"施尧路"} + */ + //定位信息 牌桌外 this.addr = null; this.invitecode = "";//邀请码 this.isStart = false;//能否点击按钮开始游戏 @@ -31,6 +37,14 @@ function Player(seat){ this.charm = undefined; this.sign = "";//签名 this.tel = "";//绑定手机号 + /* + {"address":"江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)","city":"南昌市", + "cityCode":"0791","country":"中国","district":"青云谱区","latitude":28.623546,"longitude":115.900333, + "province":"江西省","street":"施尧路"} + */ + //定位信息 牌桌内 + this.location = null; + } //玩家信息初始化 if(typeof(Player.prototype.Init) == "undefined"){ @@ -67,6 +81,7 @@ if(typeof(Player.prototype.Init) == "undefined"){ this.charm = undefined; this.sign = "";//签名 this.tel = "";//绑定手机号 + }; } //玩家微信信息初始化 @@ -107,6 +122,7 @@ if(typeof(Player.prototype.SetMyInfo) == "undefined"){ this.charm = object.charm; this.sign = object.sign; this.tel = object.tel; + //this.setTel(); //this.bankpwd = object.bank;//是否设置仓库密码 this.bankpwd = 0;//是否设置仓库密码 //this.paycode = _paycode; @@ -121,7 +137,7 @@ if(typeof(Player.prototype.SetLocationInfo) == "undefined"){ }else{ set_self(200, 7, "获取定位失败!", 0, 0); } - + this.location = _locationinfo; }; } @@ -279,6 +295,7 @@ if(typeof(Player.prototype.SetDeskInfo) == "undefined"){ }else{ this.paycode = ""; } + this.location = _data.location; this.charm = _data.charm; this.sign = _data.sign; }; diff --git a/client/js/00_Surface/07_Desk.js b/client/js/00_Surface/07_Desk.js index 0e4ba8c..42350a0 100644 --- a/client/js/00_Surface/07_Desk.js +++ b/client/js/00_Surface/07_Desk.js @@ -421,6 +421,8 @@ Desk.login=function(_msg){//登录 Func.createRoom(); } Game_Modify.Reconnect(_msg.data.deskinfo); + }else{ + Game_Modify.ReconnectNoMakewar(); } //} }else{ diff --git a/client/js/00_Surface/08_Utl_Output.js b/client/js/00_Surface/08_Utl_Output.js index fa0b494..391b830 100644 --- a/client/js/00_Surface/08_Utl_Output.js +++ b/client/js/00_Surface/08_Utl_Output.js @@ -545,43 +545,49 @@ Utl.openVideo = function (_playerList, _seat, _roomcode, roomtype) { set_self(152, 37, 0, 0, 0); set_self(153, 37, 0, 0, 0); set_self(107, 37, 0, 0, 0); - for (var i = 0; i < Desk.PlayerList.length; i++) { - if (Desk.PlayerList[i].nickname != "") { - var ind = Logic.ChangeToStatus(C_Player.seat, i); - var text = Func.subString(Desk.PlayerList[i].nickname, Game_Config.Info.Mainnickname, true); - set_self(406 + ind, 7, text, 0, 0); - if (ind == 0) { - if (Game_Config.Info.myPositionDefault) { - set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + // if (Game_Modify.updatePlayerInfoUI) { + // for (var i = 0; i < Desk.PlayerList.length; i++) { + // Game_Modify.updatePlayerInfoUI(i); + // } + // } else { + for (var i = 0; i < Desk.PlayerList.length; i++) { + if (Desk.PlayerList[i].nickname != "") { + var ind = Logic.ChangeToStatus(C_Player.seat, i); + var text = Func.subString(Desk.PlayerList[i].nickname, Game_Config.Info.Mainnickname, true); + set_self(406 + ind, 7, text, 0, 0); + if (ind == 0) { + if (Game_Config.Info.myPositionDefault) { + set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + } else { + set_self(406 + ind, 18, Game_Config.Info.myPosition - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + } } else { - set_self(406 + ind, 18, Game_Config.Info.myPosition - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + if (Game_Config.Info.otherPositionDefault) { + set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + } else { + set_self(406 + ind, 18, Game_Config.Info.position[ind - 1] - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); + } } - } else { - if (Game_Config.Info.otherPositionDefault) { - set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); - } else { - set_self(406 + ind, 18, Game_Config.Info.position[ind - 1] - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0); - } - } - set_self(436 + ind, 7, 0, 0, 0); - if (ind == 0) { - if (Game_Config.Info.myPositionDefault) { - set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0); + set_self(436 + ind, 7, 0, 0, 0); + if (ind == 0) { + if (Game_Config.Info.myPositionDefault) { + set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0); + } else { + set_self(436 + ind, 18, Game_Config.Info.myPosition - Game_Config.Info.textwidth_2 / 2, 0, 0); + } } else { - set_self(436 + ind, 18, Game_Config.Info.myPosition - Game_Config.Info.textwidth_2 / 2, 0, 0); - } - } else { - if (Game_Config.Info.otherPositionDefault) { - set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0); - } else { - set_self(436 + ind, 18, Game_Config.Info.position[ind - 1] - Game_Config.Info.textwidth_2 / 2, 0, 0); + if (Game_Config.Info.otherPositionDefault) { + set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0); + } else { + set_self(436 + ind, 18, Game_Config.Info.position[ind - 1] - Game_Config.Info.textwidth_2 / 2, 0, 0); + } } + Func.up_imgurl(116 + ind, Desk.PlayerList[i].avatar); + set_group(43 + ind, 37, 1, 0, 0); } - Func.up_imgurl(116 + ind, Desk.PlayerList[i].avatar); - set_group(43 + ind, 37, 1, 0, 0); } - } + // } play_ani123(1, 156, 35, 255, 100, 1000, 0, 0, 1); Utl.playMusic(); set_self(3141, 37, 0, 0, 0); diff --git a/client/js/00_Surface/12_Logic.js b/client/js/00_Surface/12_Logic.js index 3936276..6f93f1d 100644 --- a/client/js/00_Surface/12_Logic.js +++ b/client/js/00_Surface/12_Logic.js @@ -337,35 +337,35 @@ Logic.AppStart=function(){ // 解决:覆盖全局字体渲染函数,统一使用更粗的中文友好字体 // 保存原始的 Canvas fillText 方法 - var originalFillText = CanvasRenderingContext2D.prototype.fillText; - var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText; + // var originalFillText = CanvasRenderingContext2D.prototype.fillText; + // var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText; - // 覆盖 fillText 方法,在绘制前统一字体设置 - CanvasRenderingContext2D.prototype.fillText = function() { - // 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体 - if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { - // 提取字体大小 - var sizeMatch = this.font.match(/(\d+)px/); - if (sizeMatch) { - var size = sizeMatch[1]; - // 使用 500 字重(中等粗细)+ 中文友好字体 - this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; - } - } - return originalFillText.apply(this, arguments); - }; + // // 覆盖 fillText 方法,在绘制前统一字体设置 + // CanvasRenderingContext2D.prototype.fillText = function() { + // // 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体 + // if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { + // // 提取字体大小 + // var sizeMatch = this.font.match(/(\d+)px/); + // if (sizeMatch) { + // var size = sizeMatch[1]; + // // 使用 500 字重(中等粗细)+ 中文友好字体 + // this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; + // } + // } + // return originalFillText.apply(this, arguments); + // }; - // 同样覆盖 strokeText 方法 - CanvasRenderingContext2D.prototype.strokeText = function() { - if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { - var sizeMatch = this.font.match(/(\d+)px/); - if (sizeMatch) { - var size = sizeMatch[1]; - this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; - } - } - return originalStrokeText.apply(this, arguments); - }; + // // 同样覆盖 strokeText 方法 + // CanvasRenderingContext2D.prototype.strokeText = function() { + // if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { + // var sizeMatch = this.font.match(/(\d+)px/); + // if (sizeMatch) { + // var size = sizeMatch[1]; + // this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; + // } + // } + // return originalStrokeText.apply(this, arguments); + // }; // console.log('[字体优化] Canvas 文字渲染已优化,中英文粗细统一'); // ============================================================================ @@ -1796,6 +1796,7 @@ Logic.setConfigInfo = function(){ } for(var i=0;i正式服务器 1->本地服务器 http://ylyxservice1.0791ts.cn/config/update_json.txt - gameserver:"https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt"+"?"+ifast_random(100000) - // gameserver:"https://tsgames.daoqi88.cn/config_test/test_update_jsonv2.txt"+"?"+ifast_random(100000) + // gameserver:"https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt"+"?"+ifast_random(100000) + gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000) //gameserver:"https://gameotherwork.ld2sw.cn/update_json/update_json_haiwai.txt"+"?"+ifast_random(100000) // gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000) @@ -46,13 +46,13 @@ Game_Config.Info = { myPosition:130,//自己信息对齐点(根据游戏界面自行修改) position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始 //TextContent:["1","2","3","4","5","6","7"],//常用语内容 - TextContent:["你好","2","3","4","5","6","7"],//常用语内容 + TextContent:["不要吵了不要吵了,专心玩游戏吧。","不要走,决战到天亮!","大家好,很高兴见到各位。","各位真是不好意思,我要离开一会儿。","快点吧,我等的花儿都谢了!","再见了,我会想念大家的。","怎么又断线了,网络怎么这么差啊!"],//常用语内容 TextContentMp3:["","","","","","",""],//常用语对应音效 }; Game_Config.Share={//分享参数 appdownload:"",//下载链接(无需配置,从服务器获取) - title:"Test",//(分享标题) - description:"hello world",//分享描述 + title:"进贤麻将",//(分享标题) + description:"欢乐无限",//分享描述 gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里 gameDescription:""//游戏中分享描述 }; diff --git a/client/js/01_SubGame/02_SubGame_Input.js b/client/js/01_SubGame/02_SubGame_Input.js index 0e11ef2..abb16a0 100644 --- a/client/js/01_SubGame/02_SubGame_Input.js +++ b/client/js/01_SubGame/02_SubGame_Input.js @@ -93,6 +93,11 @@ Game_Modify.Reconnect = function (_deskinfo) { if (window.SubGameHooks && SubGameHooks.Reconnect) return SubGameHooks.Reconnect(_deskinfo); }; +// A 类:未开战重连,无 hook 即 no-op +Game_Modify.ReconnectNoMakewar = function () { + if (window.SubGameHooks && SubGameHooks.ReconnectNoMakewar) return SubGameHooks.ReconnectNoMakewar(); +}; + // A 类:关闭所有声音,无 hook 即 no-op Game_Modify.stopAllSounds = function () { if (window.SubGameHooks && SubGameHooks.stopAllSounds) return SubGameHooks.stopAllSounds(); diff --git a/client/js/client_Event.js b/client/js/client_Event.js new file mode 100644 index 0000000..ee44548 --- /dev/null +++ b/client/js/client_Event.js @@ -0,0 +1 @@ +//精灵事件单元... diff --git a/client/js/docs/Spine动画集成手册.md b/client/js/docs/Spine动画集成手册.md index 23c6f2a..ccc1052 100644 --- a/client/js/docs/Spine动画集成手册.md +++ b/client/js/docs/Spine动画集成手册.md @@ -2,7 +2,10 @@ > 本手册针对 **gameabc 引擎** 的 Spine 项目,说明如何在 Canvas 2D 游戏中加载、 > 控制和管理 Spine 骨骼动画。 -> 运行时版本:**spine-canvas 4.2** | 引擎:**gameabc** +> 运行时版本:**Spine 4.2**,`spine-webgl` 与 `spine-canvas` 二选一 —— `index.html` +> 会先探测 WebGL 能力,只加载其中一套(两者暴露同一个全局名 `spine`,同时加载会互相覆盖); +> WebGL 可用时走叠加层渲染,否则回落 Canvas 2D。上层调用方式两者完全一致。 +> 引擎:**gameabc** --- @@ -27,7 +30,8 @@ Projects/Spine/ ├── index.html ← 入口 HTML ├── js/ -│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库) +│ ├── spine-webgl.js ← Spine WebGL 运行时 (第三方库,探测后二选一) +│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库,探测后二选一) │ ├── gameabc.min.js ← gameabc 游戏引擎 │ ├── SpineMgr.js ← ★ Spine 动画管理器(独立文件,自动挂钩渲染) │ ├── gamemain.js ← 游戏主逻辑(无需修改) @@ -48,7 +52,7 @@ Projects/Spine/ | 文件 | 作用 | 需要修改 | |------|------|----------| -| `js/spine-canvas.js` | Spine 4.2 Canvas 渲染运行时 | ✗ 不要修改 | +| `js/spine-webgl.js` / `js/spine-canvas.js` | Spine 4.2 运行时,`index.html` 探测后**只加载一套** | ✗ 不要修改 | | `js/SpineMgr.js` | Spine 动画管理器,自动初始化+自动渲染 | ✗ 不需要修改 | | `js/gamemain.js` | 游戏主逻辑(保持原样) | ✗ 不需要修改 | | `js/Spine_Event.js` | Spine 动画完成/自定义事件回调 | ✓ 处理动画事件 | @@ -593,15 +597,19 @@ gameabc_face.mouseup = function(gameid, spid_down, downx, downy, spid_up, upx, u gameabc_face.spineMgr.setAnimation("hero", "idle", true); }; +// ⚠️ 不要直读 spineMgr._entries[...]:entry 表是 SpineMgr 的内部状态, +// 调用方自己维护业务坐标,并用 ensureEntry 保证 entry 存在即可。 +var heroX = 640, heroY = 360; + gameabc_face.mousemove = function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) { // 通过拖拽移动角色 var mgr = gameabc_face.spineMgr; - var entry = mgr._entries["hero"]; - if (entry) { - mgr.setPosition("hero", entry.x + offmovex, entry.y + offmovey); - // 根据移动方向翻转 - mgr.setFlip("hero", offmovex < 0, false); - } + mgr.ensureEntry("hero", "hero.json", "hero.atlas", { scale: 1 }); + heroX += offmovex; + heroY += offmovey; + mgr.setPosition("hero", heroX, heroY); + // 根据移动方向翻转 + mgr.setFlip("hero", offmovex < 0, false); }; ``` @@ -742,7 +750,7 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) { 3. **打开浏览器控制台(F12)看报错** - 404 错误:文件路径有误 - JSON 解析错误:`.json` 文件格式异常 - - `spine is not defined`:`spine-canvas.js` 未正确加载 + - `spine is not defined`:`spine-webgl.js` / `spine-canvas.js` 均未加载(探测脚本未执行或路径错) 4. **坐标是否在可见范围内?** - 项目设计尺寸为 1280×720,检查 `x` 和 `y` 是否在此范围 @@ -762,11 +770,17 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) { - 检查 `gameabc_Project` 中的 `fps` 设置(默认 30) - SpineMgr 内部是用 `Date.now()` 计算真实时间差的,不依赖帧率 -- 如果需要倍速播放,修改 `state.timeScale`: +- 如果需要倍速播放,用 `setAnimation` 的返回值(Spine 的 TrackEntry)改 `timeScale`: ```javascript - var entry = gameabc_face.spineMgr._entries["hero"]; - entry.state.timeScale = 2.0; // 2 倍速 + var mgr = gameabc_face.spineMgr; + mgr.ensureEntry("hero", "hero.json", "hero.atlas", { scale: 1 }); + var track = mgr.setAnimation("hero", "run", true); + if (track) { track.timeScale = 2.0; } // 2 倍速 + // 资源尚未就绪时 setAnimation 会把命令排队并返回 null,下一帧就绪后自动执行 ``` + ⚠️ 不要直读 `spineMgr._entries[...]` 拿 `state` —— entry 表是 SpineMgr 的内部状态, + 其生命周期(何时 load / 何时因 scale 变化重建)由 SpineMgr 独占管理, + 外部持有的引用随时可能失效(如 WebGL context 恢复后整表重建)。 ### Q4: 切换动画时有跳帧 @@ -778,9 +792,9 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) { - 确认没有同一个 id 加载两次 - 检查 `ctx.save()` / `ctx.restore()` 是否配对(SpineMgr 内部已处理) -### Q6: spine-canvas.js 版本与 Spine 编辑器版本不匹配 +### Q6: Spine 运行时版本与 Spine 编辑器版本不匹配 -- **spine-canvas.js 4.2** 需搭配 **Spine 编辑器 4.2.x** 导出的数据 +- **spine-webgl / spine-canvas 4.2** 需搭配 **Spine 编辑器 4.2.x** 导出的数据 - 如果使用 Spine 4.1 编辑器,请下载对应版本的运行时: ``` https://unpkg.com/@esotericsoftware/spine-canvas@4.1/dist/iife/spine-canvas.js @@ -793,13 +807,16 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) { `index.html` 中的 script 标签加载顺序至关重要: ``` -1. spine-canvas.js ← 先加载 Spine 运行时 (定义 window.spine) +1. spine-webgl.js 或 spine-canvas.js + ← 先加载 Spine 运行时 (定义 window.spine),探测后只加载一套 2. gameabc.min.js ← 再加载游戏引擎 -3. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染 -4. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可) -5. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face) -6. Project1_Event.js ← 精灵事件 -7. gameabc_data.min.js ← 项目配置数据 (引擎初始化) +3. SpineOverlayTransform.js / SpineBackendCanvas.js / SpineBackendWebGL.js + ← 渲染后端,必须在 SpineMgr.js 之前(SpineMgr 末尾会立即执行预加载块) +4. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染 +5. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可) +6. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face) +7. Project1_Event.js ← 精灵事件 +8. gameabc_data.min.js ← 项目配置数据 (引擎初始化) ``` > **不能调换顺序**,`SpineMgr.js` 必须在 `gamemain.js` 之前加载, diff --git a/client/js/gameabc-framework/core/GameABCUtils.js b/client/js/gameabc-framework/core/GameABCUtils.js index d60c560..1906f3b 100644 --- a/client/js/gameabc-framework/core/GameABCUtils.js +++ b/client/js/gameabc-framework/core/GameABCUtils.js @@ -832,7 +832,7 @@ var GameABCUtils = (function() { }; // console.log('📐 注册裁剪区域: 容器=' + containerId + - // ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')'); + // ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')'); return true; }, diff --git a/client/js/gameabc-framework/core/SpriteManager.js b/client/js/gameabc-framework/core/SpriteManager.js index 7b5b30a..4ed4188 100644 --- a/client/js/gameabc-framework/core/SpriteManager.js +++ b/client/js/gameabc-framework/core/SpriteManager.js @@ -283,7 +283,7 @@ var SpriteManager = (function() { return true; } - console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)'); + // console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)'); // 检查依赖 if (typeof GameABCUtils === 'undefined') { @@ -292,8 +292,8 @@ var SpriteManager = (function() { } _isInitialized = true; - console.log('✅ SpriteManager: 初始化完成'); - console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性'); + // console.log('✅ SpriteManager: 初始化完成'); + // console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性'); return true; }, diff --git a/client/js/gameabc-framework/network/RpcHelper.js b/client/js/gameabc-framework/network/RpcHelper.js index 3dd2b70..6c7c5ce 100644 --- a/client/js/gameabc-framework/network/RpcHelper.js +++ b/client/js/gameabc-framework/network/RpcHelper.js @@ -95,21 +95,21 @@ var RpcHelper = { } // 5. 调试信息输出 - if (debug || Game_Config.Debugger.isDebugger) { - console.log('RpcHelper发送RPC:', { - app: app, - route: route, - rpc: rpc, - platform_data: { - agentid: data.agentid, - gameid: data.gameid, - playerid: data.playerid, - roomcode: data.roomcode - }, - game_data: gameData, - custom_data: customData - }); - } + // if (debug || Game_Config.Debugger.isDebugger) { + // console.log('RpcHelper发送RPC:', { + // app: app, + // route: route, + // rpc: rpc, + // platform_data: { + // agentid: data.agentid, + // gameid: data.gameid, + // playerid: data.playerid, + // roomcode: data.roomcode + // }, + // game_data: gameData, + // custom_data: customData + // }); + // } // 6. 通过框架发送RPC请求 Utl.sendData(app, route, rpc, data); diff --git a/client/js/gameabc-framework/spine/SpineBackendCanvas.js b/client/js/gameabc-framework/spine/SpineBackendCanvas.js new file mode 100644 index 0000000..c48ccc7 --- /dev/null +++ b/client/js/gameabc-framework/spine/SpineBackendCanvas.js @@ -0,0 +1,69 @@ +// ============================================================ +// SpineBackendCanvas —— Canvas 2D 渲染后端 +// 由 SpineMgr 在 WebGL 不可用时使用。逻辑迁自 SpineMgr.updateAndDraw,行为不变。 +// +// 已知性能特征:开启 triangleRendering 后,spine-canvas 对每个三角形都执行一次 +// clip() 加一次整张图集的 drawImage。mesh 密集的骨架(如 qixingshisanlan 2784 +// 三角形)在 iOS WKWebView 上会因 clip 叠加非轴对齐变换失去 GPU 快路径而卡顿。 +// 故本后端仅作回退,主路径见 SpineBackendWebGL。 +// ============================================================ +(function(){ + +var SpineBackendCanvas = { + + name: "canvas", + + _renderer: null, + _ctx: null, + + // 是否可用:spine 运行时须提供 Canvas 2D 的 SkeletonRenderer + isAvailable: function() { + return !!(window.spine && window.spine.SkeletonRenderer && !window.spine.SceneRenderer); + }, + + createAssetManager: function(basePath) { + return new spine.AssetManager(basePath); + }, + + // Canvas 后端:骨骼位置归零,实际位置由 drawEntry 的 ctx.translate 控制。 + // 由 SpineMgr 在 updateWorldTransform 之前调用 —— 之后再改 skeleton.x/y 不会重算 + // 世界变换,本帧不生效。 + placeEntry: function(entry) { + entry.skeleton.x = 0; + entry.skeleton.y = 0; + }, + + // 每帧开始:取 gameabc 的绘制上下文 + beginFrame: function() { + var ctx = gameabc_face.dc; + if (!ctx) return false; + this._ctx = ctx; + if (!this._renderer) { + this._renderer = new spine.SkeletonRenderer(ctx); + this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件 + } + // 确保 renderer 用的是当前 ctx (gameabc可能重建) + this._renderer.ctx = ctx; + return true; + }, + + drawEntry: function(entry) { + var ctx = this._ctx; + ctx.save(); + ctx.translate(entry.x, entry.y); + ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale) + this._renderer.draw(entry.skeleton); + ctx.restore(); + }, + + endFrame: function() { + this._ctx = null; + } +}; + +if (typeof module !== "undefined" && module.exports) { + module.exports = SpineBackendCanvas; +} +window.SpineBackendCanvas = SpineBackendCanvas; + +})(); diff --git a/client/js/gameabc-framework/spine/SpineBackendWebGL.js b/client/js/gameabc-framework/spine/SpineBackendWebGL.js new file mode 100644 index 0000000..e196c7f --- /dev/null +++ b/client/js/gameabc-framework/spine/SpineBackendWebGL.js @@ -0,0 +1,315 @@ +// ============================================================ +// SpineBackendWebGL —— WebGL 渲染后端(主路径) +// +// 在主画布之上叠加一张透明 canvas 绘制 Spine。相比 Canvas 2D 后端,mesh 密集的 +// 骨架从"每三角形一次 clip + 整图 drawImage"变为一次 draw call。 +// +// 坐标标定:不逆向混淆的 gameabc.min.js,而是每帧从 ctx.getTransform() 读取 +// 权威变换矩阵,交由 SpineOverlayTransform 换算成 MVP。 +// +// 真机实测确认的完整链路(gameabc_face.dc 属于一张离屏画布,并非 DOM 中可见的 +// #canvas,两者是完全不同的两个对象): +// Spine 坐标 --ctx.getTransform()--> 离屏画布像素(gameabc_face.dc.canvas, +// 实测 backing 1280x720,不在 DOM,getBoundingClientRect() 恒为 0) +// --gameabc 内部 blit--> 显示画布 #canvas(DOM 中可见,实测 backing 1912x880) +// --CSS--> 屏幕(实测 rect 956x440) +// +// 因此本文件区分两个几何来源,各司其职、不可合并成一个: +// - NDC 分母(buildMvp 的 canvasW/canvasH):取 gameabc_face.dc.canvas 的 +// width/height —— Spine 坐标是喂给 dc 的,分母必须与其同源。 +// - 叠加层的定位/CSS 尺寸/drawing buffer:取显示画布 #canvas 的 +// getBoundingClientRect() 与 width/height(backing) —— 叠加层要盖在屏幕上 +// 可见的画面上,且 drawing buffer 尺寸须与主画布有效分辨率一致。 +// 历史教训:曾把两者当成同一个对象 —— 用 #canvas 的尺寸做 NDC 分母则缩放错 +// (X/Y 倍数不同、宽高比歪);后又改成全部从 dc.canvas 取,结果叠加层去对齐 +// 离屏画布拿到的 rect(0,0,0,0),_syncSize 每帧提前 return,特效完全不显示。 +// ============================================================ +(function(){ + +// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现), +// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。 +function spineLog(msg) { + if (typeof console !== "undefined" && console.log) { + console.log(msg); + } +} +function spineWarn(msg) { + if (typeof console !== "undefined" && console.warn) { + console.warn(msg); + } +} + +var SpineBackendWebGL = { + + name: "webgl", + + _canvas: null, + _gl: null, + _context: null, // spine.ManagedWebGLRenderingContext + _shader: null, + _batcher: null, + _renderer: null, + _mvp: null, // 复用的 16 元素数组,避免每帧分配 + _loggedCanvasIdentity: false, // 权威画布诊断日志只打一次,避免每帧刷屏 + _contextLost: false, + _hadVisible: false, // 上一帧是否有可见 entry(用于空闲帧跳过) + _assetManager: null, + _lossHandlersBound: false, // context lost 监听只绑一次 + + // 是否可用:spine 运行时须是 webgl 版(提供 SceneRenderer 等 GL 专有类) + isAvailable: function() { + return !!(window.spine && window.spine.SceneRenderer && window.SpineOverlayTransform); + }, + + // (内部) 惰性初始化 GL 资源。失败返回 false,SpineMgr 侧不再绘制。 + _ensureGL: function() { + if (this._gl) return true; + + this._canvas = document.getElementById("spine_gl"); + if (!this._canvas) { + spineWarn("[SpineBackendWebGL] 缺少 spine_gl 元素"); + return false; + } + + var gl = null; + try { + // alpha:true 让叠加层透出下方主画布;premultipliedAlpha 交由 spine 按图集 pma 标记处理 + var opts = { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false }; + gl = this._canvas.getContext("webgl", opts) || this._canvas.getContext("experimental-webgl", opts); + } catch (err) { + spineWarn("[SpineBackendWebGL] getContext 异常: " + err.message); + return false; + } + if (!gl) { + spineWarn("[SpineBackendWebGL] 无法取得 WebGL context"); + return false; + } + + this._gl = gl; + this._bindContextLossHandlers(); + + // context 恢复时复用同一个 ManagedWebGLRenderingContext: + // canvas.getContext() 恢复后返回的是同一个 WebGLRenderingContext 对象, + // 重新 new 会让新纹理注册到新 managed context,而 _assetManager 仍挂在旧的上, + // 造成新旧分叉。shader / batcher / renderer 持有失效的 GPU 资源,则必须重建。 + if (!this._context) { + this._context = new spine.ManagedWebGLRenderingContext(gl); + } + + // 复用 _context 的代价:Shader / Mesh 构造时都会 context.addRestorable(this), + // 只有 dispose() 才 removeRestorable。每次 context 恢复都会重走本段, + // 旧对象若不释放就永久堆在 restorables 里(旧 shader 程序 + batcher 顶点/索引缓冲), + // 恢复次数越多占用越大。context 已丢失时 gl.deleteShader/deleteBuffer 按规范是 no-op, + // 安全;仍加 try/catch 防御,释放失败不能挡住重建。 + // (SkeletonRenderer 不持有 GL 资源、无 dispose,不需处理。) + if (this._shader) { + try { + this._shader.dispose(); + } catch (errShader) { + spineWarn("[SpineBackendWebGL] 释放旧 shader 失败(已忽略): " + errShader.message); + } + this._shader = null; + } + if (this._batcher) { + try { + this._batcher.dispose(); + } catch (errBatcher) { + spineWarn("[SpineBackendWebGL] 释放旧 batcher 失败(已忽略): " + errBatcher.message); + } + this._batcher = null; + } + + this._shader = spine.Shader.newTwoColoredTextured(this._context); + this._batcher = new spine.PolygonBatcher(this._context); + // 中和 blend 模式:spine-canvas 的 drawTriangles 只跟踪 slot.data.blendMode 却从不应用 + // (该文件全文无 globalCompositeOperation),即迁移前所有槽都是按 source-over 画的。 + // spine-webgl 的 SkeletonRenderer 会真的调 batcher.setBlendMode(),在"清成透明黑"的 + // 叠加层上,screen 槽会输出非预乘颜色导致过亮/白边,multiply 槽会因 srcRgb=DST_COLOR + // 而 RGB 恒 0 变成纯黑剪影(本项目 start 有 9 个 screen 槽,而 game_start 每局必播)。 + // additive 在叠加层上恰好等价于 source-over,无差异。 + // 本次迁移只求性能、不引入视觉变化,故在此中和,保持 begin() 设定的 Normal 混合 + // (SkeletonRenderer.premultipliedAlpha 默认 false,与 PolygonBatcher 构造时的 + // SRC_ALPHA / ONE_MINUS_SRC_ALPHA 默认值一致,即"未中和时 Normal 槽会设成的那一组")。 + // 是否放开真实 blend,留作将来一次独立的、经美术确认的改动。 + // ⚠️ 必须紧跟每一次 new PolygonBatcher —— context 恢复会重建 batcher,写在别处即失效。 + this._batcher.setBlendMode = function() {}; + this._renderer = new spine.SkeletonRenderer(this._context); + this._mvp = new Array(16); + + gl.disable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + return true; + }, + + // (内部) WebGL context lost / restored。 + // iOS 在内存压力或切后台返回时会真实触发;不处理的表现是"特效突然全部消失且不再恢复"。 + // ⚠️ 只能绑一次:context restored 后 _gl 被置 null,_ensureGL 会重新走一遍, + // 若不加此闩,每恢复一次就多挂一对监听器,重复触发且泄漏。 + _bindContextLossHandlers: function() { + if (this._lossHandlersBound) return; + this._lossHandlersBound = true; + var self = this; + this._canvas.addEventListener("webglcontextlost", function(ev){ + ev.preventDefault(); // 必须 preventDefault,否则不会触发 restored + self._contextLost = true; + spineWarn("[SpineBackendWebGL] context lost"); + }, false); + this._canvas.addEventListener("webglcontextrestored", function(){ + spineLog("[SpineBackendWebGL] context restored,重建 GL 资源与纹理"); + self._contextLost = false; + self._gl = null; // 迫使 _ensureGL 重建 shader/batcher/renderer + self._hadVisible = false; // 复位空闲帧状态机,避免恢复后多发一次 clear + if (self._onContextRestored) self._onContextRestored(); + }, false); + }, + + // 由 SpineMgr 注入的纹理重建回调(Task 7 接入) + _onContextRestored: null, + + createAssetManager: function(basePath) { + if (!this._ensureGL()) { + // GL 不可用时不静默降级成"看似成功",直接抛出让 SpineMgr.init 失败, + // 由 index.html 的探测脚本保证不会走到这里。 + throw new Error("[SpineBackendWebGL] GL 初始化失败,无法创建 AssetManager"); + } + this._assetManager = new spine.AssetManager(this._context, basePath); + return this._assetManager; + }, + + // WebGL 后端:位移并入骨骼,使所有 entry 共用同一 MVP,可批量绘制。 + // y 取负是因为 MVP 中含 Spine Y-up → 画布 Y-down 的翻转(见 SpineOverlayTransform 推导)。 + placeEntry: function(entry) { + entry.skeleton.x = entry.x; + entry.skeleton.y = -entry.y; + }, + + beginFrame: function(visibleCount) { + if (this._contextLost) return false; + if (!this._ensureGL()) return false; + + // 绘制缓冲画布(NDC 分母来源):Spine 坐标是喂给 dc 的,分母必须与其同源, + // 只能每帧从 dc 派生(createAssetManager 阶段 dc 可能还不存在,故不能在 + // _ensureGL 里固定取一次)。数据源唯一,取不到即报错跳帧,禁止 || 兜底。 + var ctx = gameabc_face.dc; + if (!ctx) return false; + var drawCanvas = ctx.canvas; + if (!drawCanvas) { + spineWarn("[SpineBackendWebGL] gameabc_face.dc.canvas 不存在(非标准 CanvasRenderingContext2D 实现),本帧跳过"); + return false; + } + + // 显示画布(叠加层定位/CSS 尺寸/drawing buffer 来源):index.html 中 + //
是 gameabc 平台约定的固定 + // 显示画布契约,并非按 id 猜——但仍须校验存在性/在 DOM/rect 有效, + // 任一项不满足都不能静默继续绘制。 + var displayCanvas = (typeof document !== "undefined" && document.getElementById) ? document.getElementById("canvas") : null; + if (!displayCanvas) { + spineWarn("[SpineBackendWebGL] 找不到显示画布 document.getElementById(\"canvas\"),本帧跳过"); + return false; + } + if (!displayCanvas.parentNode) { + spineWarn("[SpineBackendWebGL] 显示画布 #canvas 不在 DOM 中,本帧跳过"); + return false; + } + var displayRect = displayCanvas.getBoundingClientRect(); + if (!displayRect || displayRect.width <= 0 || displayRect.height <= 0) { + spineWarn("[SpineBackendWebGL] 显示画布 #canvas 的 rect 尺寸无效,本帧跳过"); + return false; + } + + this._logCanvasIdentityOnce(drawCanvas, displayCanvas, displayRect); + + // 空闲帧跳过:麻将大部分时间无特效在播,不能因叠加层存在就每帧空 clear 全屏。 + // 仅在从"有"变"无"的那一帧 clear 一次收尾。 + if (visibleCount === 0) { + if (!this._hadVisible) return false; + this._hadVisible = false; + this._syncSize(displayCanvas); + this._gl.clearColor(0, 0, 0, 0); + this._gl.clear(this._gl.COLOR_BUFFER_BIT); + return false; + } + this._hadVisible = true; + + if (!this._syncSize(displayCanvas)) return false; + + var m = this._readTransform(ctx); + SpineOverlayTransform.buildMvp(m, drawCanvas.width, drawCanvas.height, this._mvp); + + var gl = this._gl; + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + this._shader.bind(); + this._shader.setUniformi(spine.Shader.SAMPLER, 0); + this._shader.setUniform4x4f(spine.Shader.MVP_MATRIX, this._mvp); + this._batcher.begin(this._shader); + return true; + }, + + // (内部) 读取 gameabc 绘制上下文的权威变换矩阵。 + // getTransform() 自 iOS Safari 11 起支持,远低于本项目已验证的 WebView 基线; + // 取不到时退化为单位矩阵(此时叠加层按绘制缓冲画布 dc.canvas 的 backing 尺寸直接映射)。 + _readTransform: function(ctx) { + if (typeof ctx.getTransform === "function") { + var t = ctx.getTransform(); + return { a: t.a, b: t.b, c: t.c, d: t.d, e: t.e, f: t.f }; + } + return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + }, + + // (内部) 一次性诊断日志:同时记录两个几何来源的身份与数值,便于后续核对 + // ——绘制缓冲画布(dc.canvas,NDC 分母)与显示画布(#canvas,叠加层定位/尺寸) + // 是否为同一个对象、各自的 backing 与显示画布的 rect。 + _logCanvasIdentityOnce: function(drawCanvas, displayCanvas, displayRect) { + if (this._loggedCanvasIdentity) return; + this._loggedCanvasIdentity = true; + var drawIdDesc = drawCanvas.id ? ("#" + drawCanvas.id) : "(无 id)"; + spineLog("[SpineBackendWebGL] 绘制缓冲画布(gameabc_face.dc.canvas) = " + drawIdDesc + + ",backing=" + drawCanvas.width + "x" + drawCanvas.height + + ";显示画布(#canvas) backing=" + displayCanvas.width + "x" + displayCanvas.height + + ",rect=(" + displayRect.left + "," + displayRect.top + "," + displayRect.width + "," + displayRect.height + ")" + + ",两者是否同一对象=" + (drawCanvas === displayCanvas)); + }, + + // (内部) 每帧把叠加层的位置与尺寸镜像到显示画布(#canvas)的实际显示区域。 + // drawing buffer 尺寸直接取显示画布的 backing(width/height),不再用 + // rect × devicePixelRatio 计算——与主画布有效分辨率一致即可,用 DPR 会开出 + // 比主画布 backing 还大的 buffer,白白多占显存。 + _syncSize: function(displayCanvas) { + var rect = displayCanvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + + var host = this._canvas.parentNode; + host.style.left = rect.left + "px"; + host.style.top = rect.top + "px"; + this._canvas.style.width = rect.width + "px"; + this._canvas.style.height = rect.height + "px"; + + var bw = displayCanvas.width; + var bh = displayCanvas.height; + if (bw <= 0 || bh <= 0) return false; + if (this._canvas.width !== bw || this._canvas.height !== bh) { + this._canvas.width = bw; + this._canvas.height = bh; + } + this._gl.viewport(0, 0, bw, bh); + return true; + }, + + drawEntry: function(entry) { + this._renderer.draw(this._batcher, entry.skeleton); + }, + + endFrame: function() { + this._batcher.end(); + this._shader.unbind(); + } +}; + +if (typeof module !== "undefined" && module.exports) { + module.exports = SpineBackendWebGL; +} +window.SpineBackendWebGL = SpineBackendWebGL; + +})(); diff --git a/client/js/gameabc-framework/spine/SpineMgr.js b/client/js/gameabc-framework/spine/SpineMgr.js index 7d97f37..6cec30d 100644 --- a/client/js/gameabc-framework/spine/SpineMgr.js +++ b/client/js/gameabc-framework/spine/SpineMgr.js @@ -5,12 +5,25 @@ // ============================================================ (function(){ +// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现), +// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。 +function spineLog(msg) { + if (typeof console !== "undefined" && console.log) { + console.log(msg); + } +} +function spineWarn(msg) { + if (typeof console !== "undefined" && console.warn) { + console.warn(msg); + } +} + var SpineMgr = { // --- 内部状态 --- _entries: {}, // {id: EntryObject} _assetManager: null, - _renderer: null, // spine.SkeletonRenderer (Canvas 2D) + _backend: null, // 渲染后端(SpineBackendWebGL / SpineBackendCanvas) _lastTime: 0, // 上一帧时间戳(ms) _inited: false, _loadedPaths: {}, // 已请求加载的资源路径,避免重复请求 @@ -27,15 +40,50 @@ var SpineMgr = { // ---------------------------------------------------------- init: function(basePath) { if (!window.spine) { - logmessage("[SpineMgr] spine-canvas.js 未加载,请检查引用"); + spineWarn("[SpineMgr] spine 运行时未加载,请检查 index.html 的探测脚本"); return; } + this._backend = this._selectBackend(); + if (!this._backend) { + spineWarn("[SpineMgr] 无可用渲染后端"); + return; + } + spineLog("[SpineMgr] 渲染后端: " + this._backend.name); this._basePath = basePath || this._basePath; - this._assetManager = new spine.AssetManager(this._basePath); + // 挂接 context lost 恢复回调(仅 WebGL 后端有此机制) + var self = this; + if (this._backend.name === "webgl") { + this._backend._onContextRestored = function() { + self._rebuildAfterContextRestored(); + }; + } + // 后端初始化失败必须显式记录并让 _inited 保持 false,但不能让异常向上抛: + // init 由文件末尾的预加载块经 _ensureInit() 调用,异常会中断整个 IIFE, + // 导致后面的 gameenddraw 拦截根本不安装 —— Spine 从此永不绘制且无迹可循。 + // _inited 保持 false 时,各 API 走既有的 if (!this._inited) return 静默路径。 + try { + this._assetManager = this._backend.createAssetManager(this._basePath); + } catch (err) { + spineWarn("[SpineMgr] 后端初始化失败: " + err.message); + this._backend = null; + return; + } this._lastTime = Date.now(); this._inited = true; }, + // (内部) 选定渲染后端。index.html 的探测脚本已决定加载哪套 spine 运行时, + // 此处按运行时实际提供的能力对应上后端实现。 + _selectBackend: function() { + if (window.SpineBackendWebGL && SpineBackendWebGL.isAvailable()) { + return SpineBackendWebGL; + } + if (window.SpineBackendCanvas && SpineBackendCanvas.isAvailable()) { + return SpineBackendCanvas; + } + return null; + }, + // (内部) 确保已初始化 _ensureInit: function() { if (!this._inited) { @@ -43,6 +91,42 @@ var SpineMgr = { } }, + // ---------------------------------------------------------- + // (内部) _preloadAll() + // 按 gameabc_face.spineAssets 清单,把全部 Spine 资源注入当前 AssetManager 并发起加载。 + // ⚠️ 必须整批注入,不能只补"当前已存在的 entry": + // 规避 file:// 下 XHR CORS 用的 rawDataUris 是 Downloader 实例私有的 + // (AssetManagerBase 构造时默认 new Downloader()),换一个新 AssetManager + // 就等于换了一份空 rawDataUris。若只补已存在的 entry,context 恢复之后 + // 首次触发的新特效(如天胡)会走真实 XHR 被 CORS 拦掉,_tryBuild 里 require() + // 抛异常 → ready 恒 false → 每帧重试且永不显示。 + // 返回本次注册的资源组数。 + // ---------------------------------------------------------- + _preloadAll: function() { + var list = gameabc_face.spineAssets; + if (!list || list.length === 0) return 0; + var textData = gameabc_face.spineTextData || {}; + for (var i = 0; i < list.length; i++) { + var name = list[i]; + var jsonKey = name + ".json"; + var atlasKey = name + ".atlas"; + // 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取 + if (textData[jsonKey]) { + this._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]); + } + if (textData[atlasKey]) { + this._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]); + } + this._assetManager.loadText(jsonKey); + this._loadedPaths[jsonKey] = true; + this._assetManager.loadTextureAtlas(atlasKey); + this._loadedPaths[atlasKey] = true; + } + spineLog("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" + + (Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)")); + return list.length; + }, + // (内部) 自动加载:根据 id 约定文件名 id.json / id.atlas // 主流 SkeletonJson.scale 方案:从 _scaleMap 读取目标缩放,传入 load() 在解析时烘焙 _autoLoad: function(id) { @@ -101,16 +185,45 @@ var SpineMgr = { visible: true, ready: false, _hideOnComplete: false, // 播放完成后是否自动隐藏 - _hideAfterCompletes: 0 // 剩余多少次 complete 后触发隐藏 + _hideAfterCompletes: 0, // 剩余多少次 complete 后触发隐藏 + _buildFailLogged: false // 构建失败日志只记一次(_tryBuild 每帧调用) }; }, + // ---------------------------------------------------------- + // ensureEntry(id, jsonFile, atlasFile, option) + // 确保 id 对应的 entry 存在并达到 option 指定的状态。 + // entry 生命周期归 SpineMgr 管,调用方无需掌握"不在就 load、在就 setScale"的内部规则, + // 也无需从 id 推导文件名(entryId 与资源文件名不一定同名,如 ting_0 -> ting.json)。 + // ---------------------------------------------------------- + ensureEntry: function(id, jsonFile, atlasFile, option) { + var e = this._entries[id]; + if (!e) { + this.load(id, jsonFile, atlasFile, option); + return; + } + if (option && option.scale !== undefined) { + this.setScale(id, option.scale); + } + }, + + // ---------------------------------------------------------- + // isReady() 后端是否已就绪、能真正播放 Spine + // 探测阶段的 canvas 兜底只覆盖"WebGL 不可用";若探测通过但 GL 初始化失败 + // (叠加层 DOM 缺失 / 并发 context 耗尽 / shader 编译失败),此时 canvas 运行时 + // 并未加载、回不去,调用方应据此走各自既有的帧动画降级路径, + // 而不是让 Spine 静默失效却以为播成功了。 + // ---------------------------------------------------------- + isReady: function() { + return !!this._inited; + }, + // ---------------------------------------------------------- // (内部) 资源加载完成后实例化骨骼 // 同一 jsonFile+scale 的 SkeletonData 只解析一次,多个 entry 共享 // ---------------------------------------------------------- _buildEntry: function(entry) { - logmessage("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale); + spineLog("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale); var cacheKey = entry.jsonFile + "_s" + entry.scale; var skelData = this._skelDataCache[cacheKey]; if (!skelData) { @@ -122,9 +235,9 @@ var SpineMgr = { this._assetManager.require(entry.jsonFile) ); this._skelDataCache[cacheKey] = skelData; - logmessage("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey); + spineLog("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey); } else { - logmessage("[SpineMgr] SkeletonData 命中缓存: " + cacheKey); + spineLog("[SpineMgr] SkeletonData 命中缓存: " + cacheKey); } entry.skeleton = new spine.Skeleton(skelData); @@ -179,10 +292,15 @@ var SpineMgr = { e._id = id; try { this._buildEntry(e); - logmessage("[SpineMgr] " + id + " 构建完成"); + spineLog("[SpineMgr] " + id + " 构建完成"); this._flushCmds(id); } catch(err) { - logmessage("[SpineMgr] " + id + " 构建失败: " + err.message); + // 重试本身必须保留(资源可能只是还没到齐),但日志只记一次: + // 本方法每帧调用,持续失败时会以约 60 条/秒刷屏并淹没其他日志。 + if (!e._buildFailLogged) { + e._buildFailLogged = true; + spineWarn("[SpineMgr] " + id + " 构建失败(仅记一次): " + err.message); + } e.ready = false; } } @@ -190,6 +308,79 @@ var SpineMgr = { return true; }, + // ---------------------------------------------------------- + // (内部) WebGL context 恢复后重建全部 GPU 资源 + // context lost 会让 AssetManager 中缓存的 GLTexture 与 shader 一并作废, + // 必须重新加载资源并重建所有 entry,否则表现为"特效再也不显示"。 + // ---------------------------------------------------------- + _rebuildAfterContextRestored: function() { + spineLog("[SpineMgr] 重建 Spine 资源(context restored)"); + + // 记录当前 entry 的声明式状态,重建后按原样恢复 + var saved = []; + for (var id in this._entries) { + var e = this._entries[id]; + saved.push({ + id: id, + jsonFile: e.jsonFile, + atlasFile: e.atlasFile, + x: e.x, + y: e.y, + scale: e.scale, + skin: e.skin, + mixDur: e.mixDur + }); + } + + // 丢弃全部缓存:SkeletonData 持有已作废的 GLTexture 引用,不能复用 + this._entries = {}; + this._pendingCmds = {}; + this._skelDataCache = {}; + this._loadedPaths = {}; + + // 先释放旧 AssetManager 再换新的:GLTexture 构造时会 context.addRestorable(this), + // 只有 dispose() 才 removeRestorable。本后端刻意复用同一个 ManagedWebGLRenderingContext + // (见 SpineBackendWebGL._ensureGL 注释),若不释放,每恢复一次就把 23 页图集的 + // GLTexture 永久留在 restorables 里(解码位图约 137MB)—— 而 context lost 的诱因 + // 正是内存压力,泄漏会把 lost/restore 变成越恢复越糟的死循环。 + // context 已丢失时 gl.deleteTexture 等按规范是 no-op,安全;仍加 try/catch 防御, + // 释放失败不能挡住后面的重建。 + if (this._assetManager) { + try { + this._assetManager.dispose(); + } catch (errDispose) { + spineWarn("[SpineMgr] 释放旧 AssetManager 失败(已忽略): " + errDispose.message); + } + this._assetManager = null; + } + + // 本方法在 webglcontextrestored 事件回调中执行,异常会逸出到事件循环, + // 故与 init 同样显式捕获:重建失败就停在不可用状态并留下日志。 + try { + this._assetManager = this._backend.createAssetManager(this._basePath); + } catch (err) { + spineWarn("[SpineMgr] context 恢复后重建 AssetManager 失败: " + err.message); + this._inited = false; + return; + } + + // 整批重注入 rawDataURI 并重新发起加载:新 AssetManager = 新 Downloader = 空 rawDataUris, + // 只补 saved 里的资源会让恢复后首次触发的新特效在 file:// 下被 CORS 拦死(见 _preloadAll 注释)。 + // _preloadAll 会把全部资源写进 _loadedPaths,故下面按 saved 重建 entry 时 + // load() 的 if (!this._loadedPaths[...]) 判断会正确跳过重复请求。 + this._preloadAll(); + + for (var i = 0; i < saved.length; i++) { + var s = saved[i]; + this.load(s.id, s.jsonFile, s.atlasFile, { + x: s.x, y: s.y, scale: s.scale, skin: s.skin, mixDuration: s.mixDur + }); + // 重建后默认不可见:正在播的动画已随 context 丢失, + // 强行续播会出现半截动画,交由下一次业务触发重新播放。 + this._entries[s.id].visible = false; + } + }, + // ---------------------------------------------------------- // updateAndDraw(ctx) 每帧自动调用 // ctx: gameabc_face.dc (Canvas 2D Context) @@ -203,29 +394,33 @@ var SpineMgr = { this._lastTime = now; if (dt <= 0 || dt > 0.5) dt = 1/30; - if (!this._renderer) { - this._renderer = new spine.SkeletonRenderer(ctx); - this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件 - } - // 确保 renderer 用的是当前 ctx (gameabc可能重建) - this._renderer.ctx = ctx; - + // 先推进动画状态:即使本帧无法绘制(后端未就绪 / context lost), + // 动画时间轴与 complete 事件也必须照常推进,否则回调链会卡死。 + var visibleList = []; for (var id in this._entries) { var e = this._entries[id]; if (!e.ready || !e.visible) continue; e.state.update(dt); e.state.apply(e.skeleton); - // 骨骼位置归零,由 ctx.translate 控制实际位置 - e.skeleton.x = 0; - e.skeleton.y = 0; + // 骨骼位置由后端决定:Canvas 后端归零(用 ctx.translate 定位), + // WebGL 后端写入 x/-y(位移并入骨骼,使所有 entry 共用同一 MVP)。 + // 必须在 updateWorldTransform 之前设置,否则不生效于本帧。 + this._backend.placeEntry(e); e.skeleton.updateWorldTransform(spine.Physics.update); + visibleList.push(e); + } - ctx.save(); - ctx.translate(e.x, e.y); - ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale) - this._renderer.draw(e.skeleton); - ctx.restore(); + if (!this._backend.beginFrame(visibleList.length)) return; + // endFrame 必须配对执行:WebGL 后端的 PolygonBatcher 只在 endFrame 里 end(), + // 若 drawEntry 抛出而漏了 end(),batcher.isDrawing 会永久停在 true, + // 下一帧 begin() 即抛 "PolygonBatch is already drawing" 并冒到 gameenddraw —— Spine 永久黑屏。 + try { + for (var i = 0; i < visibleList.length; i++) { + this._backend.drawEntry(visibleList[i]); + } + } finally { + this._backend.endFrame(); } }, @@ -281,10 +476,13 @@ var SpineMgr = { return; } if (e.scale === sx) return; // 已是目标 scale,无需重建 - // 目标 scale 与已烘焙 scale 不同 → 清除旧 entry,下次 _autoLoad 将以新 scale 重建 + // 目标 scale 与已烘焙 scale 不同 → 清除旧 entry 并以新 scale 重建。 + // 文件名必须取自 entry(load 时记录的权威值):entryId 与资源文件名不一定同名 + // (如 entryId=ting_0 对应 ting.json),按 id 推导会去加载不存在的文件。 var savedX = e.x, savedY = e.y; + var savedJson = e.jsonFile, savedAtlas = e.atlasFile; this.remove(id); - this.load(id, id + ".json", id + ".atlas", { scale: sx, x: savedX, y: savedY }); + this.load(id, savedJson, savedAtlas, { scale: sx, x: savedX, y: savedY }); }, setFlip: function(id, flipX, flipY) { @@ -423,26 +621,16 @@ gameabc_face.spineMgr = SpineMgr; // 彻底避免 file:// 协议下 XHR CORS 拦截问题 if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) { SpineMgr._ensureInit(); - var list = gameabc_face.spineAssets; - var textData = gameabc_face.spineTextData || {}; - for (var i = 0; i < list.length; i++) { - var name = list[i]; - var jsonKey = name + ".json"; - var atlasKey = name + ".atlas"; - // 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取 - if (textData[jsonKey]) { - SpineMgr._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]); - } - if (textData[atlasKey]) { - SpineMgr._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]); - } - SpineMgr._assetManager.loadText(jsonKey); - SpineMgr._loadedPaths[jsonKey] = true; - SpineMgr._assetManager.loadTextureAtlas(atlasKey); - SpineMgr._loadedPaths[atlasKey] = true; + if (!SpineMgr._inited) { + // 后端未就绪:跳过预加载,但必须继续往下走完 gameenddraw 拦截的安装。 + // 此处不能 return —— 预加载块在 IIFE 函数体内,return 会退出整个 IIFE, + // 反而导致拦截装不上,正是本守卫要避免的后果。 + // 回归锁:codes/test/node/spineMgr.gameEndDrawGuard.test.js + spineWarn("[SpineMgr] 后端未就绪,跳过 Spine 资源预加载"); + } else { + // 与 context 恢复后的重注入共用同一份实现,避免两处清单分叉 + SpineMgr._preloadAll(); } - logmessage("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" + - (Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)")); } // ★ 用 defineProperty 拦截 gameenddraw 赋值 diff --git a/client/js/gameabc-framework/spine/SpineOverlayTransform.js b/client/js/gameabc-framework/spine/SpineOverlayTransform.js new file mode 100644 index 0000000..d5cffd1 --- /dev/null +++ b/client/js/gameabc-framework/spine/SpineOverlayTransform.js @@ -0,0 +1,88 @@ +// ============================================================ +// SpineOverlayTransform —— WebGL 叠加层坐标标定(纯函数) +// +// 职责:把 gameabc 主画布的权威变换矩阵换算成 WebGL 的 MVP 矩阵。 +// 无副作用、无 DOM 依赖,可独立单测。 +// +// 为什么不逆向 gameabc.min.js:那是混淆代码(变量名全打乱),读不出它内部如何 +// 映射坐标。而 Spine 绘制发生在 gameenddraw(gameabc 绘制流程末尾),此刻 ctx +// 上已带着 gameabc 设好的全局变换 —— 直接向 ctx 要矩阵即可,权威、且 gameabc +// 后续改缩放/响应旋转时自动跟随。 +// +// ⚠️ 真机实测确认:Spine 实际画在 gameabc_face.dc.canvas 这张离屏画布上 +// (backing 约 1280x720,不在 DOM),gameabc 再把它 blit 到 DOM 中可见的显示 +// 画布 #canvas(backing 约 1912x880),最后经 CSS 缩放到屏幕。本函数的 +// canvasW/canvasH 必须传绘制缓冲画布(dc.canvas)的 backing 尺寸,因为 +// Spine 坐标正是喂给这张画布的,分母须与其同源;叠加层自身的定位/CSS 尺寸/ +// drawing buffer 则是另一个来源(显示画布 #canvas),由调用方 +// SpineBackendWebGL 负责,不在本函数职责内。 +// +// 推导(Spine 世界坐标 Y-up,画布 Y-down): +// 位移由调用方经 skeleton.x = e.x; skeleton.y = -e.y 承担,本矩阵只处理 +// scale(1,-1) 与投影,等价于 Canvas 后端的 ctx.translate(e.x,e.y)+ctx.scale(1,-1)。 +// +// 1) Spine 世界 → 逻辑坐标: Lx = vx, Ly = -vy +// 2) 逻辑 → 画布像素: Px = a*Lx + c*Ly + e, Py = b*Lx + d*Ly + f +// 3) 画布像素 → NDC: x = 2*Px/W - 1, y = 1 - 2*Py/H +// +// 合并: +// x_ndc = (2a/W)*vx + (-2c/W)*vy + (2e/W - 1) +// y_ndc = (-2b/H)*vx + ( 2d/H)*vy + (1 - 2f/H) +// +// 注意:叠加层 drawing buffer 尺寸在推导中被约掉(gl.viewport 覆盖整个 buffer, +// NDC 自动铺满),故本矩阵只依赖 ctx 变换与绘制缓冲画布 backing 尺寸。因此所有 +// entry 共用同一个 MVP,可一次 batcher.begin/end 批量绘制。 +// ============================================================ +(function(){ + +var SpineOverlayTransform = { + + // ---------------------------------------------------------- + // buildMvp(m, canvasW, canvasH, out) + // m : {a,b,c,d,e,f} —— ctx.getTransform() 的六元素 + // canvasW : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 宽(实测约 1280) + // canvasH : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 高(实测约 720) + // out : 可选,长度 16 的复用数组(避免每帧分配) + // 返回列主序 4x4 矩阵 + // ---------------------------------------------------------- + buildMvp: function(m, canvasW, canvasH, out) { + var r = out || new Array(16); + + var sx = 2 / canvasW; + var sy = 2 / canvasH; + + // 第 0 列:vx 的系数 + r[0] = sx * m.a; + r[1] = -sy * m.b; + r[2] = 0; + r[3] = 0; + + // 第 1 列:vy 的系数 + r[4] = -sx * m.c; + r[5] = sy * m.d; + r[6] = 0; + r[7] = 0; + + // 第 2 列:z 保持 + r[8] = 0; + r[9] = 0; + r[10] = 1; + r[11] = 0; + + // 第 3 列:平移 + r[12] = sx * m.e - 1; + r[13] = 1 - sy * m.f; + r[14] = 0; + r[15] = 1; + + return r; + } +}; + +if (typeof module !== "undefined" && module.exports) { + module.exports = SpineOverlayTransform; +} +// 友乐运行时有 module 无 require 且共享全局作用域,须无条件暴露全局名 +window.SpineOverlayTransform = SpineOverlayTransform; + +})(); diff --git a/client/js/gameabc-framework/system/AnimationManager.js b/client/js/gameabc-framework/system/AnimationManager.js index 0c2206b..c7e0812 100644 --- a/client/js/gameabc-framework/system/AnimationManager.js +++ b/client/js/gameabc-framework/system/AnimationManager.js @@ -810,7 +810,7 @@ var AnimationManager = AnimationManager || {}; } _initialized = true; - console.log('[AnimationManager] Initialized (v2.0.0 optimized)'); + // console.log('[AnimationManager] Initialized (v2.0.0 optimized)'); }; /** diff --git a/client/js/gameabc-framework/system/AudioManager.js b/client/js/gameabc-framework/system/AudioManager.js index bdba8e9..fca8d98 100644 --- a/client/js/gameabc-framework/system/AudioManager.js +++ b/client/js/gameabc-framework/system/AudioManager.js @@ -164,7 +164,7 @@ var AudioManager = AudioManager || {}; return; } _initialized = true; - console.log('[AudioManager] Initialized (v4.0.0)'); + // console.log('[AudioManager] Initialized (v4.0.0)'); }; // ============================================================================ diff --git a/client/js/gameabc-framework/system/EventBus.js b/client/js/gameabc-framework/system/EventBus.js index 9b343e3..e3fb2ff 100644 --- a/client/js/gameabc-framework/system/EventBus.js +++ b/client/js/gameabc-framework/system/EventBus.js @@ -80,7 +80,7 @@ var EventBus = { }; this._listeners[eventName].push(listener); - console.log('✅ EventBus: 订阅事件', eventName); + // console.log('✅ EventBus: 订阅事件', eventName); // 返回取消订阅函数 var self = this; @@ -121,7 +121,7 @@ var EventBus = { // 如果没有指定callback,取消该事件的所有监听器 if (!callback) { delete this._listeners[eventName]; - console.log('✅ EventBus: 取消所有', eventName, '监听器'); + // console.log('✅ EventBus: 取消所有', eventName, '监听器'); return true; } @@ -130,7 +130,7 @@ var EventBus = { for (var i = listeners.length - 1; i >= 0; i--) { if (listeners[i].callback === callback) { listeners.splice(i, 1); - console.log('✅ EventBus: 取消', eventName, '监听器'); + // console.log('✅ EventBus: 取消', eventName, '监听器'); return true; } } @@ -152,7 +152,7 @@ var EventBus = { var listeners = this._listeners[eventName].slice(); // 复制数组,避免迭代中修改 var count = 0; - console.log('📡 EventBus: 触发事件', eventName, data); + // console.log('📡 EventBus: 触发事件', eventName, data); for (var i = 0; i < listeners.length; i++) { try { @@ -204,7 +204,7 @@ var EventBus = { */ clear: function() { this._listeners = {}; - console.log('✅ EventBus: 已清空所有监听器'); + // console.log('✅ EventBus: 已清空所有监听器'); }, /** @@ -214,7 +214,7 @@ var EventBus = { clearEvent: function(eventName) { if (this._listeners[eventName]) { delete this._listeners[eventName]; - console.log('✅ EventBus: 已清空', eventName, '的所有监听器'); + // console.log('✅ EventBus: 已清空', eventName, '的所有监听器'); } } }; diff --git a/client/js/gameabc-framework/templates/UIBootstrap.template.js b/client/js/gameabc-framework/templates/UIBootstrap.template.js index dd92c38..73f0362 100644 --- a/client/js/gameabc-framework/templates/UIBootstrap.template.js +++ b/client/js/gameabc-framework/templates/UIBootstrap.template.js @@ -79,7 +79,7 @@ UIBootstrap.init = function () { return true; } - console.log('[UIBootstrap] 开始初始化 UI 系统...'); + // console.log('[UIBootstrap] 开始初始化 UI 系统...'); try { if (!this._checkDependencies()) { return false; } @@ -102,7 +102,7 @@ UIBootstrap.init = function () { this._registerViews(); this.initialized = true; - console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件'); + // console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件'); return true; } catch (err) { console.error('[UIBootstrap] 初始化失败:', err); diff --git a/client/js/gameabc-framework/templates/subgame-entry/SubGameHooks.template.js b/client/js/gameabc-framework/templates/subgame-entry/SubGameHooks.template.js index 9e24e23..cc9ea22 100644 --- a/client/js/gameabc-framework/templates/subgame-entry/SubGameHooks.template.js +++ b/client/js/gameabc-framework/templates/subgame-entry/SubGameHooks.template.js @@ -1,7 +1,7 @@ // ============================================================================ // SubGameHooks.template.js —— 子游戏接入入口(复制为 codes/SubGameHooks.js 后填实现) // 平台契约文件(00/01/02 转发壳)会把同名平台接口转发到这里。只需实现【本游戏需要的】hook, -// 未实现的接口由转发壳走平台默认(B)或空操作(A)。严格 ES5。 +// 未实现的接口由转发壳走空操作(A)/平台默认返回值(B)/模板默认 UI 渲染(D)。严格 ES5。 // 接口清单与默认值见 spec §9 / 文档 06。 // ============================================================================ @@ -199,8 +199,21 @@ var SubGameHooks = window.SubGameHooks || {}; // [B] getMaxPlayerCount(roomtype):获取房间最大玩家数量;无 hook 时默认:return Game_Config.Max.PlayerCnt || 4。 // SubGameHooks.getMaxPlayerCount = function (roomtype) { /* ... */ }; -// [B] updatePlayerInfoUI(targetSeat):更新玩家信息 UI;无 hook 时默认:return false(让平台默认处理)。 +// [D] updatePlayerInfoUI(targetSeat):更新指定座位玩家信息 UI;无 hook 时模板提供【可变人数】默认渲染 +// (头像/昵称/分数随人数 2/3/4 贴合各玩家,布局见 01 的 Game_Modify.PLAYER_INFO_LAYOUT)。子游戏可用 hook 完全接管。 // SubGameHooks.updatePlayerInfoUI = function (targetSeat) { /* ... */ }; +// ===== Game_Modify.*(桌面聊天/语音气泡,3) ===== +// 以下接口无 hook 时,模板提供【可变人数】默认渲染(气泡随人数贴合各玩家,布局见 01 的 Game_Modify.BUBBLE_LAYOUT)。 + +// [D] ShowChat(seat, text):显示桌面文字聊天气泡(seat 为绝对座位)。 +// SubGameHooks.ShowChat = function (seat, text) { /* ... */ }; + +// [D] gameui_play_voice(uiIndex):播放桌面语音气泡(uiIndex 为相对显示位序)。 +// SubGameHooks.gameui_play_voice = function (uiIndex) { /* ... */ }; + +// [D] gameui_stop_voice(uiIndex):停止桌面语音气泡。 +// SubGameHooks.gameui_stop_voice = function (uiIndex) { /* ... */ }; + window.SubGameHooks = SubGameHooks; if (typeof module !== 'undefined' && module.exports) { module.exports = SubGameHooks; } diff --git a/client/js/gameabc-framework/ui/BaseComponent.js b/client/js/gameabc-framework/ui/BaseComponent.js index b8fe9e0..99c1248 100644 --- a/client/js/gameabc-framework/ui/BaseComponent.js +++ b/client/js/gameabc-framework/ui/BaseComponent.js @@ -241,7 +241,7 @@ var BaseComponent = (function() { this.isDestroyed = false; this.isInitialized = true; - console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')'); + // // console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')'); // 调用子类的init方法 try { @@ -274,7 +274,7 @@ var BaseComponent = (function() { return; } - console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" 的所有精灵 (数量: ' + this.sprites.length + ')'); + // console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" 的所有精灵 (数量: ' + this.sprites.length + ')'); for (var i = 0; i < this.sprites.length; i++) { var spriteId = this.sprites[i]; @@ -293,7 +293,7 @@ var BaseComponent = (function() { */ init: function(config) { // 子类覆盖此方法 - console.log('📝 BaseComponent.init: 请在子类中覆盖此方法'); + // console.log('📝 BaseComponent.init: 请在子类中覆盖此方法'); }, /** @@ -312,11 +312,11 @@ var BaseComponent = (function() { } if (this.isVisible) { - console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name); + // console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name); return true; } - console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')'); + // console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')'); // 显示图层 SpriteManager.showGroup(this.layer); @@ -344,11 +344,11 @@ var BaseComponent = (function() { } if (!this.isVisible) { - console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name); + // console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name); return true; } - console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')'); + // console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')'); // 隐藏图层 SpriteManager.hideGroup(this.layer); @@ -375,7 +375,7 @@ var BaseComponent = (function() { return false; } - console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"'); + // console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"'); // 移除所有事件监听 this._removeAllEventListeners(); @@ -447,7 +447,7 @@ var BaseComponent = (function() { // 记录监听器 this.eventListeners[eventName] = wrappedHandler; - console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName); + // console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName); return true; }, @@ -467,7 +467,7 @@ var BaseComponent = (function() { EventBus.off(eventName, handler); delete this.eventListeners[eventName]; - console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName); + // console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName); return true; }, @@ -483,7 +483,7 @@ var BaseComponent = (function() { return; } - console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName); + // console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName); EventBus.emit(eventName, data); }, @@ -511,7 +511,7 @@ var BaseComponent = (function() { } this.sprites.push(spriteId); - // console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId); + // // console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId); return true; }, @@ -529,7 +529,7 @@ var BaseComponent = (function() { } this.sprites.splice(index, 1); - console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId); + // console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId); return true; }, @@ -596,7 +596,7 @@ var BaseComponent = (function() { */ printState: function() { var state = this.getState(); - console.log('📊 组件状态:', JSON.stringify(state, null, 2)); + // console.log('📊 组件状态:', JSON.stringify(state, null, 2)); } }; diff --git a/client/js/gameabc-framework/ui/DynamicSpriteList.js b/client/js/gameabc-framework/ui/DynamicSpriteList.js index 9bc3d2a..3c71ec8 100644 --- a/client/js/gameabc-framework/ui/DynamicSpriteList.js +++ b/client/js/gameabc-framework/ui/DynamicSpriteList.js @@ -84,7 +84,21 @@ * 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 会清除旧行、重新创建新行 @@ -176,6 +190,26 @@ * } * } */ +/** + * 主轴/交叉轴的引擎操作码与 clipArea 字段映射 + * 前缀 DSL_ 是必要的:友乐运行时所有