模板工程升级修改
This commit is contained in:
+236
-116
@@ -1,145 +1,265 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Cache-Control" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover" />
|
||||
<title>erqiwang</title>
|
||||
<style>
|
||||
body {
|
||||
padding-top: constant(safe-area-inset-top);
|
||||
padding-left: constant(safe-area-inset-left);
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Cache-Control" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover"/>
|
||||
<title>jxmahjong</title>
|
||||
<style>
|
||||
body {
|
||||
padding-top: constant(safe-area-inset-top);
|
||||
padding-left: constant(safe-area-inset-left);
|
||||
padding-right: constant(safe-area-inset-right);
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body style='position:fixed;bottom:0;'>
|
||||
|
||||
<div id="ifastgame2" style="position:absolute;left:0px;top:0px;z-index:10">
|
||||
<canvas id="bg1" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
<div id="ifastgame2" style="position:absolute;left:0px;top:0px;z-index:10">
|
||||
<canvas id="bg1" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div id="ifastgame_4" style="position:absolute;left:0px;top:0px;z-index:1">
|
||||
<canvas id="ifastgame_bg" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
<div id="ifastgame_4" style="position:absolute;left:0px;top:0px;z-index:1">
|
||||
<canvas id="ifastgame_bg" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div id="ifastgame3" style="position:absolute;left:0px;top:0px;z-index:11">
|
||||
<canvas id="bg2" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
<div id="ifastgame3" style="position:absolute;left:0px;top:0px;z-index:11">
|
||||
<canvas id="bg2" width="1" height="1">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div id="ifastgame" style="position:absolute;left:0px;top:0px;z-index:2">
|
||||
<canvas id="canvas" width="6000" height="3000">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
<canvas id="canvas" width="6000" height="3000">
|
||||
<p>Your browser does not support the canvas element.</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<!-- Spine WebGL 叠加层:z-index 3 夹在主画布(2)与 bg1(10)之间,
|
||||
精确复刻"Spine 画在主画布内部、被 bg1/bg2 遮挡"的既有层级关系。
|
||||
pointer-events:none 保证不抢主画布的触摸输入。-->
|
||||
<div id="ifastgame_spine" style="position:absolute;left:0px;top:0px;z-index:3;pointer-events:none">
|
||||
<canvas id="spine_gl" width="1" height="1"></canvas>
|
||||
</div>
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
* { margin: 0; padding: 0; }
|
||||
html, body { height: 100%; width: 100%;
|
||||
overflow-x:hidden;overflow-y:hidden;
|
||||
backgroundColor:"rab(255,0,0)";
|
||||
}
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
<script>
|
||||
|
||||
<style type="text/css">
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
backgroundColor: "rab(255,0,0)";
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
||||
function setupWebViewJavascriptBridge(callback) {
|
||||
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
|
||||
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
|
||||
window.WVJBCallbacks = [callback];
|
||||
var WVJBIframe = document.createElement('iframe');
|
||||
WVJBIframe.style.display = 'none';
|
||||
WVJBIframe.src = 'https://__bridge_loaded__';
|
||||
document.documentElement.appendChild(WVJBIframe);
|
||||
setTimeout(function () { document.documentElement.removeChild(WVJBIframe) }, 0)
|
||||
}
|
||||
setupWebViewJavascriptBridge(function (bridge) { });
|
||||
function setupWebViewJavascriptBridge(callback) {
|
||||
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
|
||||
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
|
||||
window.WVJBCallbacks = [callback];
|
||||
var WVJBIframe = document.createElement('iframe');
|
||||
WVJBIframe.style.display = 'none';
|
||||
WVJBIframe.src = 'https://__bridge_loaded__';
|
||||
document.documentElement.appendChild(WVJBIframe);
|
||||
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
|
||||
}
|
||||
setupWebViewJavascriptBridge(function(bridge){});
|
||||
</script>
|
||||
<script src="http://pv.sohu.com/cityjson?ie=utf-8"></script>
|
||||
<!-- <script>
|
||||
returnCitySN={
|
||||
ip:'127.1.1.1',
|
||||
province:'',
|
||||
city:''
|
||||
};
|
||||
|
||||
</script> -->
|
||||
<script src="http://47.98.203.17:8085/cityjson" ></script>
|
||||
<script type="text/javascript" src="js/vendor/jquery-2.1.1.min.js"></script>
|
||||
<script type="text/javascript" src="js/jweixin-1.2.0.js"></script>
|
||||
<script language='javascript'>var gameabc_face = gameabc_face || {};</script>
|
||||
<script language='javascript'>gameabc_face.path = "assets/bmp";</script>
|
||||
<!-- Spine Canvas Runtime -->
|
||||
<script type="text/javascript" src="js/vendor/spine-canvas.js"></script>
|
||||
<script language='javascript'>var gameabc_face = gameabc_face||{};</script>
|
||||
<script language='javascript'>gameabc_face.path="assets/bmp";</script>
|
||||
<!-- Spine 运行时按需加载。
|
||||
spine-canvas.js 与 spine-webgl.js 都以 var spine = (() => {...})() 暴露
|
||||
同一个全局名 spine,同时加载会互相覆盖,故只能二选一。
|
||||
强制切换(验证用):在本段之前设 window.__spineForceBackend = 'canvas' 或 'webgl'。-->
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
var forced = window.__spineForceBackend;
|
||||
var useWebGL;
|
||||
var reason;
|
||||
if (forced === "webgl" || forced === "canvas") {
|
||||
useWebGL = (forced === "webgl");
|
||||
reason = "被 window.__spineForceBackend 强制指定为 " + forced;
|
||||
} else if (location.protocol === "file:") {
|
||||
// file:// 下 <img> 会被标记 tainted,texImage2D 拒绝上传
|
||||
// (Canvas2D 的 drawImage 则允许,故 canvas 后端仍可工作)。
|
||||
// 项目文档要求 HTTP 环境调试;此处兜住误用 file:// 的情形,
|
||||
// 回退 canvas 保证特效可见,而不是抛一片 DOMException。
|
||||
useWebGL = false;
|
||||
reason = "file:// 协议下 WebGL 无法上传纹理,强制回退 canvas";
|
||||
} else {
|
||||
useWebGL = false;
|
||||
reason = "WebGL 探测失败(getContext 返回空)";
|
||||
try {
|
||||
var probe = document.createElement("canvas");
|
||||
var gl = probe.getContext("webgl") || probe.getContext("experimental-webgl");
|
||||
if (gl) {
|
||||
useWebGL = true;
|
||||
reason = "WebGL 探测通过";
|
||||
// 释放探测用 context:浏览器对并发 WebGL context 数量有限制,
|
||||
// 泄漏会导致叠加层拿不到 context。
|
||||
var lose = gl.getExtension("WEBGL_lose_context");
|
||||
if (lose) lose.loseContext();
|
||||
}
|
||||
} catch (err) {
|
||||
useWebGL = false;
|
||||
reason = "WebGL 探测抛异常: " + err.message;
|
||||
}
|
||||
}
|
||||
if (typeof console !== "undefined" && console.log) {
|
||||
console.log("[Spine] 运行时选择: " + (useWebGL ? "spine-webgl.js" : "spine-canvas.js") + "(" + reason + ")");
|
||||
}
|
||||
document.write('<script type="text/javascript" src="js/vendor/spine-'
|
||||
+ (useWebGL ? "webgl" : "canvas") + '.js"><\/script>');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="text/javascript" src="js/vendor/gameabc.min.js"></script>
|
||||
<!-- Spine 资源清单:列出需要预加载的 Spine 资源基础名 -->
|
||||
<script type="text/javascript" src="generated/spine_assets.js"></script>
|
||||
<!-- Spine 文本数据嵌入(解决 file:// 协议 CORS 问题) -->
|
||||
<script type="text/javascript" src="generated/spine_data.js"></script>
|
||||
|
||||
|
||||
<script type="text/javascript" src="app_data.js"></script>
|
||||
<script type="text/javascript" src="app_battery.js"></script>
|
||||
<script type="text/javascript" src="app_network.js"></script>
|
||||
<script type="text/javascript" src="app_gamesname.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript" src="js/gamemain.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/02_Const.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/04_Data.js"></script>
|
||||
<script type="text/javascript" src="version.js"></script>
|
||||
|
||||
<script type="text/javascript" src="js/00_Surface/08_Utl_Output.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/07_Desk.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/05_Func.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/10_Game.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/11_GameUI.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/12_Logic.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/00_minhttp.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/09_Net.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/06_Player.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/03_Banwords.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/02_SubGame_Input.js"></script>
|
||||
<!----------------------------------------------------------------------------->
|
||||
|
||||
<!-- gameabc-framework:通用框架层(core → system → ui),需在 gamemain.js 之前加载 -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/core/GameABCUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/core/SpriteManager.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/EventBus.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/SpriteEventController.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/SpriteGestureRecognizer.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/AnimationManager.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/AudioManager.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/network/RpcHelper.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/AlignmentUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/BaseComponent.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/SpriteCopyUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/DynamicSpriteList.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/UIManager.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/RecordViewDefaultConfig.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/RecordView.js"></script>
|
||||
<!-- Spine 动画管理器:须在 gameabc.min.js 之后、gamemain.js 之前加载 -->
|
||||
<!-- Spine 渲染后端:必须在 SpineMgr.js 之前加载(SpineMgr 末尾会立即执行预加载块)-->
|
||||
<script type="text/javascript" src="js/gameabc-framework/spine/SpineOverlayTransform.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendCanvas.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendWebGL.js"></script>
|
||||
<!-- SpineMgr: 自动初始化+自动渲染,必须在 gamemain.js 之前加载 -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/spine/SpineMgr.js"></script>
|
||||
|
||||
<script type="text/javascript" src="output/gameabc_data.min.js"></script>
|
||||
<script type="text/javascript" src="app_data.js"></script>
|
||||
<script type="text/javascript" src="app_battery.js"></script>
|
||||
<script type="text/javascript" src="app_network.js"></script>
|
||||
<script type="text/javascript" src="app_gamesname.js"></script>
|
||||
<script type="text/javascript" src="js/gamemain.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/02_Const.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/04_Data.js"></script>
|
||||
<script type="text/javascript" src="version.js"></script>
|
||||
|
||||
<script type="text/javascript" src="js/00_Surface/08_Utl_Output.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/07_Desk.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/05_Func.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/10_Game.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/11_GameUI.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/12_Logic.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/00_minhttp.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/09_Net.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/06_Player.js"></script>
|
||||
<script type="text/javascript" src="js/00_Surface/03_Banwords.js"></script>
|
||||
|
||||
|
||||
<!-- 阶段0: 强制工具类(必须最先加载) -->
|
||||
|
||||
<script type="text/javascript" src="js/gameabc-framework/core/GameABCUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/network/RpcHelper.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/EventBus.js"></script>
|
||||
<!-- 精灵事件控制器(框架 system):须早于任何使用精灵交互的组件/回放/JingMark 加载 -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/SpriteEventController.js"></script>
|
||||
<!-- 精灵手势识别器(框架 system):双击/滑动/点击判定,建立在 SpriteEventController 之上 -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/SpriteGestureRecognizer.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/AlignmentUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/SpriteCopyUtils.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/DynamicSpriteList.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- UI组件 -->
|
||||
|
||||
<script type="text/javascript" src="js/gameabc-framework/core/SpriteManager.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/BaseComponent.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/UIManager.js"></script>
|
||||
<!-- 通用战绩页组件(依赖 DynamicSpriteList/SpriteEventController/SpriteManager,均已在前加载) -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/RecordViewDefaultConfig.js"></script>
|
||||
<script type="text/javascript" src="js/gameabc-framework/ui/RecordView.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 动画系统 (Phase 5) -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/AnimationManager.js"></script>
|
||||
|
||||
|
||||
<!-- 音频系统 -->
|
||||
<script type="text/javascript" src="js/gameabc-framework/system/AudioManager.js"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 子游戏 Hooks 实现(转发壳三文件转发到此;须在依赖的 controllers/handlers 之后、三文件之前)-->
|
||||
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/02_SubGame_Input.js"></script>
|
||||
<!----------------------------------------------------------------------------->
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript" src="output/gameabc_data.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</html>
|
||||
@@ -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',//分享背景图片
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -421,6 +421,8 @@ Desk.login=function(_msg){//登录
|
||||
Func.createRoom();
|
||||
}
|
||||
Game_Modify.Reconnect(_msg.data.deskinfo);
|
||||
}else{
|
||||
Game_Modify.ReconnectNoMakewar();
|
||||
}
|
||||
//}
|
||||
}else{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<GameData.hallConfigName.length;i++){
|
||||
var val = get_paravalue(GameData.serverConfig,GameData.hallConfigName[i]);
|
||||
// console.log(GameData.hallConfigName[i] + val);
|
||||
if(val != null){
|
||||
GameData.hallConfig[GameData.hallConfigName[i]] = val;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ Game_Config.Debugger={//调试配置
|
||||
visitorLogin : true,//隐藏式游客登录
|
||||
visiblePay:true,//审核通过后是否显示支付按钮
|
||||
serverType:1,//0->正式服务器 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:""//游戏中分享描述
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
//精灵事件单元...
|
||||
@@ -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` 之前加载,
|
||||
|
||||
@@ -832,7 +832,7 @@ var GameABCUtils = (function() {
|
||||
};
|
||||
|
||||
// console.log('📐 注册裁剪区域: 容器=' + containerId +
|
||||
// ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')');
|
||||
// ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')');
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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 中
|
||||
// <div id="ifastgame"><canvas id="canvas"> 是 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;
|
||||
|
||||
})();
|
||||
@@ -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 赋值
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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)');
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,7 +164,7 @@ var AudioManager = AudioManager || {};
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
console.log('[AudioManager] Initialized (v4.0.0)');
|
||||
// console.log('[AudioManager] Initialized (v4.0.0)');
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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, '的所有监听器');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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_ 是必要的:友乐运行时所有 <script> 共享同一全局作用域,
|
||||
* 通用名(如 LIST_AXIS)会与其它文件互相覆盖。
|
||||
* 操作码:18=左右移动 19=上下移动 20=设宽 21=设高
|
||||
* (docs/important/client/精灵操作详细指南.md:38-41)
|
||||
*/
|
||||
var DSL_LIST_AXIS = {
|
||||
vertical: {
|
||||
posOp: 19, sizeOp: 21, crossPosOp: 18, crossSizeOp: 20,
|
||||
clipPos: 'y', clipSize: 'height', crossClipPos: 'x', crossClipSize: 'width',
|
||||
stepField: 'rowHeight'
|
||||
},
|
||||
horizontal: {
|
||||
posOp: 18, sizeOp: 20, crossPosOp: 19, crossSizeOp: 21,
|
||||
clipPos: 'x', clipSize: 'width', crossClipPos: 'y', crossClipSize: 'height',
|
||||
stepField: 'itemWidth'
|
||||
}
|
||||
};
|
||||
|
||||
function DynamicSpriteList(config) {
|
||||
// ========================================================================
|
||||
// 参数验证
|
||||
@@ -189,8 +223,11 @@ function DynamicSpriteList(config) {
|
||||
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 orientation = (config.orientation === 'horizontal') ? 'horizontal' : 'vertical';
|
||||
var axis = DSL_LIST_AXIS[orientation];
|
||||
var step = config[axis.stepField];
|
||||
if (typeof step !== 'number' || step <= 0) {
|
||||
throw new Error('DynamicSpriteList: ' + axis.stepField + ' must be a positive number');
|
||||
}
|
||||
var hasTemplates = config.templates && typeof config.templates === 'object';
|
||||
var hasRenderRow = typeof config.renderRow === 'function';
|
||||
@@ -220,11 +257,31 @@ function DynamicSpriteList(config) {
|
||||
};
|
||||
|
||||
/**
|
||||
* 每行高度
|
||||
* 排列方向:'vertical'(默认) | 'horizontal'
|
||||
* @type {string}
|
||||
*/
|
||||
this.orientation = orientation;
|
||||
|
||||
/**
|
||||
* 轴映射(操作码 + clipArea 取用字段),内部所有方向相关操作一律经此表
|
||||
* @type {Object}
|
||||
* @private
|
||||
*/
|
||||
this._axis = axis;
|
||||
|
||||
/**
|
||||
* 主轴步长:纵向=行高(rowHeight),横向=项宽含间距(itemWidth)
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._step = step;
|
||||
|
||||
/**
|
||||
* 每行高度(纵向语义保留字段;横向下等于主轴步长)
|
||||
* @type {number}
|
||||
*/
|
||||
this.rowHeight = config.rowHeight;
|
||||
|
||||
this.rowHeight = step;
|
||||
|
||||
/**
|
||||
* 模板精灵配置
|
||||
* @type {Object}
|
||||
@@ -431,31 +488,31 @@ DynamicSpriteList.prototype.destroy = function() {
|
||||
/**
|
||||
* 滚动到指定位置
|
||||
*
|
||||
* @param {number} y - 目标Y坐标
|
||||
* @param {number} offset - 目标主轴偏移量(纵向=Y方向,横向=X方向)
|
||||
* @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;
|
||||
}
|
||||
|
||||
DynamicSpriteList.prototype.scrollTo = function(offset, animated) {
|
||||
var axis = this._axis;
|
||||
var clipStart = this.clipArea[axis.clipPos];
|
||||
var clipSize = this.clipArea[axis.clipSize];
|
||||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||||
var target = clipStart - offset;
|
||||
|
||||
// 边界钳制(双向):先钳末端,再钳起点。
|
||||
// 内容不足一屏时 min > clipStart,两条钳制叠加后恒为起点——这正是期望行为。
|
||||
var contentSize = this._data.length * this._step;
|
||||
var min = clipStart + clipSize - contentSize;
|
||||
if (target < min) { target = min; }
|
||||
if (target > clipStart) { target = clipStart; }
|
||||
|
||||
if (animated !== false) {
|
||||
play_ani(1, this.containerId, 19, currentY, targetY, 0,
|
||||
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
|
||||
play_ani(1, this.containerId, axis.posOp, current, target, 0,
|
||||
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
|
||||
} else {
|
||||
set_self(this.containerId, 19, targetY, 0, 0, 0);
|
||||
set_self(this.containerId, axis.posOp, target, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -476,8 +533,8 @@ DynamicSpriteList.prototype.scrollToTop = function(animated) {
|
||||
* @returns {DynamicSpriteList} 返回自身,支持链式调用
|
||||
*/
|
||||
DynamicSpriteList.prototype.scrollToBottom = function(animated) {
|
||||
var contentHeight = this._data.length * this.rowHeight;
|
||||
var maxScroll = contentHeight - this.clipArea.height;
|
||||
var contentSize = this._data.length * this._step;
|
||||
var maxScroll = contentSize - this.clipArea[this._axis.clipSize];
|
||||
if (maxScroll < 0) {
|
||||
maxScroll = 0;
|
||||
}
|
||||
@@ -492,7 +549,7 @@ DynamicSpriteList.prototype.scrollToBottom = function(animated) {
|
||||
* @returns {DynamicSpriteList} 返回自身,支持链式调用
|
||||
*/
|
||||
DynamicSpriteList.prototype.scrollToRow = function(rowIndex, animated) {
|
||||
var y = rowIndex * this.rowHeight;
|
||||
var y = rowIndex * this._step;
|
||||
return this.scrollTo(y, animated);
|
||||
};
|
||||
|
||||
@@ -531,36 +588,35 @@ DynamicSpriteList.prototype.handleMouseDown = function(spid, x, y) {
|
||||
* 需要在 utlmousemove 回调中调用
|
||||
*
|
||||
* @param {number} spid - 移动的精灵ID
|
||||
* @param {number} offsetY - Y方向移动偏移量
|
||||
* @param {number} offsetMain - 主轴方向移动偏移量(纵向传 Y 方向偏移,横向传 X 方向偏移)
|
||||
* @returns {boolean} 如果事件被处理返回 true
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* // 在 SpriteEventController 或 Game_Modify 中
|
||||
* // 在 SpriteEventController 或 Game_Modify 中(纵向列表传 offmovey,横向列表传 offmovex)
|
||||
* utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
|
||||
* if (myList.handleMouseMove(spid, offmovey)) {
|
||||
* if (myList.handleMouseMove(spid, offmovey)) { // 横向列表改传 offmovex
|
||||
* return; // 事件已处理
|
||||
* }
|
||||
* // 其他处理...
|
||||
* }
|
||||
*/
|
||||
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetY) {
|
||||
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetMain) {
|
||||
if (!this.enableScroll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (spid === this.containerId) {
|
||||
if (Math.abs(offsetY) > this.scrollSensitivity) {
|
||||
// 增量移动容器
|
||||
set_self(this.containerId, 19, offsetY, 1, 0, 0);
|
||||
if (Math.abs(offsetMain) > this.scrollSensitivity) {
|
||||
var axis = this._axis;
|
||||
// 增量移动容器(第4参 1 = 相对增量)
|
||||
set_self(this.containerId, axis.posOp, offsetMain, 1, 0, 0);
|
||||
this._isSliding = true;
|
||||
|
||||
// 触发滚动回调
|
||||
|
||||
if (typeof this.onScroll === 'function') {
|
||||
var 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);
|
||||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||||
var scrolled = this.clipArea[axis.clipPos] - current;
|
||||
var maxScroll = this._data.length * this._step - this.clipArea[axis.clipSize];
|
||||
this.onScroll(scrolled, maxScroll > 0 ? maxScroll : 0);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -661,29 +717,30 @@ DynamicSpriteList.prototype._addSpriteInternal = function (name, templateSpriteI
|
||||
* @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);
|
||||
var axis = this._axis;
|
||||
set_self(this.containerId, axis.posOp, this.clipArea[axis.clipPos], 0, 0);
|
||||
set_self(this.containerId, axis.crossPosOp, this.clipArea[axis.crossClipPos], 0, 0);
|
||||
var contentSize = this._data.length * this._step;
|
||||
set_self(this.containerId, axis.sizeOp, contentSize, 0, 0);
|
||||
|
||||
if (this.templates) {
|
||||
var tnames = Object.keys(this.templates);
|
||||
if (tnames.length > 0) {
|
||||
var w = get_self(this.templates[tnames[0]].spriteId, 20, 0, 0, 0);
|
||||
set_self(this.containerId, 20, w, 0, 0);
|
||||
// 交叉轴尺寸取首个模板的对应尺寸(纵向=宽 op20,横向=高 op21)
|
||||
var crossFromTpl = get_self(this.templates[tnames[0]].spriteId, axis.crossSizeOp, 0, 0, 0);
|
||||
set_self(this.containerId, axis.crossSizeOp, crossFromTpl, 0, 0);
|
||||
}
|
||||
} else {
|
||||
// renderRow 模式:容器宽度取裁剪区宽,保证整行可视宽都在容器 hit 区(点击/拖动检测)内。
|
||||
// 否则容器保持编辑器预置窄宽度,超出部分点击/拖动报的 spid≠containerId 而失效(对账 bak set_self(fSpid,20,bg宽))。
|
||||
set_self(this.containerId, 20, this.clipArea.width, 0, 0);
|
||||
// renderRow 模式:交叉轴尺寸取裁剪区对应边,保证整个可视区都在容器 hit 区(点击/拖动检测)内
|
||||
set_self(this.containerId, axis.crossSizeOp, this.clipArea[axis.crossClipSize], 0, 0);
|
||||
}
|
||||
|
||||
for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) {
|
||||
var rowData = this._data[rowIndex];
|
||||
var rowY = rowIndex * this.rowHeight;
|
||||
var rowBase = rowIndex * this._step;
|
||||
|
||||
if (this.renderRow) {
|
||||
this.renderRow(this._makeRowContext(rowIndex, rowData, rowY));
|
||||
this.renderRow(this._makeRowContext(rowIndex, rowData, rowBase));
|
||||
} else {
|
||||
var names = Object.keys(this.templates);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
@@ -691,7 +748,9 @@ DynamicSpriteList.prototype._render = function() {
|
||||
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);
|
||||
var px = (this.orientation === 'horizontal') ? (rowBase + ox) : ox;
|
||||
var py = (this.orientation === 'horizontal') ? oy : (rowBase + oy);
|
||||
var sid = this._addSpriteInternal(name, t.spriteId, px, py, rowIndex, t.clickable !== false);
|
||||
if (t.textProperty && rowData[t.textProperty] !== undefined) {
|
||||
set_self(sid, 7, rowData[t.textProperty], 0, 0);
|
||||
}
|
||||
@@ -708,19 +767,27 @@ DynamicSpriteList.prototype._render = function() {
|
||||
* 创建行上下文对象(传给 renderRow 回调)
|
||||
* @param {number} rowIndex - 行索引
|
||||
* @param {Object} rowData - 行数据
|
||||
* @param {number} rowBaseY - 行基准 Y 坐标
|
||||
* @param {number} rowBase - 行基准坐标(纵向=Y,横向=X)
|
||||
* @returns {Object} 行上下文
|
||||
* @private
|
||||
*/
|
||||
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBaseY) {
|
||||
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBase) {
|
||||
var self = this;
|
||||
var isH = (this.orientation === 'horizontal');
|
||||
return {
|
||||
rowIndex: rowIndex,
|
||||
rowData: rowData,
|
||||
rowBaseY: rowBaseY,
|
||||
rowBase: rowBase,
|
||||
// 保留 rowBaseY:renderRow 回调的既有字段协议,不得破坏。
|
||||
// 当前生产消费方为 RecordView.js(唯一使用本组件的调用方);
|
||||
// 回归测试 DynamicSpriteList.test.js 对该字段有硬断言。
|
||||
rowBaseY: rowBase,
|
||||
orientation: this.orientation,
|
||||
containerId: this.containerId,
|
||||
addSprite: function (name, templateSpriteId, x, y) {
|
||||
return self._addSpriteInternal(name, templateSpriteId, x, rowBaseY + y, rowIndex, true);
|
||||
var px = isH ? (rowBase + x) : x;
|
||||
var py = isH ? y : (rowBase + y);
|
||||
return self._addSpriteInternal(name, templateSpriteId, px, py, rowIndex, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -730,38 +797,39 @@ DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBa
|
||||
* @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 axis = this._axis;
|
||||
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
|
||||
var contentSize = get_self(this.containerId, axis.sizeOp, 0, 0, 0);
|
||||
var clipStart = this.clipArea[axis.clipPos];
|
||||
var clipSize = this.clipArea[axis.clipSize];
|
||||
|
||||
var target = current;
|
||||
var needBounce = false;
|
||||
|
||||
if (contentHeight <= clipH) {
|
||||
// 内容不足一屏,回弹到顶部
|
||||
if (currentY !== clipY) {
|
||||
targetY = clipY;
|
||||
|
||||
if (contentSize <= clipSize) {
|
||||
// 内容不足一屏,回弹到起点
|
||||
if (current !== clipStart) {
|
||||
target = clipStart;
|
||||
needBounce = true;
|
||||
}
|
||||
} else {
|
||||
if (currentY > clipY) {
|
||||
// 超出顶部
|
||||
targetY = clipY;
|
||||
if (current > clipStart) {
|
||||
// 越过起点
|
||||
target = clipStart;
|
||||
needBounce = true;
|
||||
} else {
|
||||
var minY = clipY + clipH - contentHeight;
|
||||
if (currentY < minY) {
|
||||
// 超出底部
|
||||
targetY = minY;
|
||||
var min = clipStart + clipSize - contentSize;
|
||||
if (current < min) {
|
||||
// 越过末端
|
||||
target = min;
|
||||
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);
|
||||
play_ani(1, this.containerId, axis.posOp, current, target, 0,
|
||||
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -772,6 +840,16 @@ DynamicSpriteList.prototype._handleBounce = function() {
|
||||
* @private
|
||||
*/
|
||||
DynamicSpriteList.prototype._handleClick = function(x, y) {
|
||||
// 裁剪区外的点击一律不算数。
|
||||
// 原因:引擎的 set_clip 只裁【绘制】、不裁【命中】,而 _render 会把容器的主轴尺寸
|
||||
// 撑到内容全长(远超可视区)。于是滑出可视区、屏幕上已经看不见的那些项,其矩形
|
||||
// 仍在容器内,ifast_check_add 照样判为命中 —— 用户点到列表旁的空白带,却选中了
|
||||
// 一个根本看不见的项。这里按 clipArea 兜住,横纵通用。
|
||||
var a = this.clipArea;
|
||||
if (x < a.x || x >= a.x + a.width || y < a.y || y >= a.y + a.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
var clickedTag = ifast_check_add(this.containerId, x, y);
|
||||
if (clickedTag === -99999999) { return; }
|
||||
var rec = this._tagIndex[clickedTag];
|
||||
|
||||
@@ -63,6 +63,10 @@ var RecordView = {
|
||||
if (typeof gradeinfo[i].gameinfo1 === 'string') {
|
||||
gradeinfo[i].gameinfo1 = JSON.parse(gradeinfo[i].gameinfo1);
|
||||
}
|
||||
// roomtype 归一:平台战绩记录里 roomtype 可能被多做了一层 JSON 序列化,
|
||||
// 形如 '"11221000100003"'。下游权威 RoomConfigUtils.parse 严格要求 14 位裸字符串
|
||||
// (故意不做多格式兼容),故在此收包边界剥掉多余的 JSON 引号层。
|
||||
gradeinfo[i].roomtype = this._normalizeRoomtype(gradeinfo[i].roomtype);
|
||||
if (i === 0) { this._gradeIdx1 = gradeinfo[i].idx; }
|
||||
if (i === gradeinfo.length - 1) { this._gradeIdx2 = gradeinfo[i].idx; }
|
||||
}
|
||||
@@ -70,6 +74,17 @@ var RecordView = {
|
||||
this.open();
|
||||
},
|
||||
|
||||
// 剥掉 roomtype 上多余的 JSON 序列化层:'"11221000100003"' → '11221000100003'。
|
||||
// 多层引号(极端情况)循环剥;非字符串或无引号原样返回;JSON.parse 失败即停止不吞坏值。
|
||||
_normalizeRoomtype: function (rt) {
|
||||
var v = rt, guard = 0;
|
||||
while (typeof v === 'string' && v.length >= 2 && v.charAt(0) === '"' && guard < 5) {
|
||||
try { v = JSON.parse(v); } catch (e) { break; }
|
||||
guard++;
|
||||
}
|
||||
return v;
|
||||
},
|
||||
|
||||
// 翻页/类型 → 发获取战绩请求(平台通用协议)。类型切换 _requestGrade(type,null);翻页 _requestGrade(_,direction)。
|
||||
_requestGrade: function (arg1, direction) {
|
||||
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
|
||||
@@ -77,9 +92,9 @@ var RecordView = {
|
||||
this._type = arg1; // 类型切换
|
||||
data.type = arg1;
|
||||
} else {
|
||||
data.type = this._type; // 翻页:type 用当前,direction + gradeidx
|
||||
data.type = this._type; // 翻页:type 用当前,direction + idx
|
||||
data.direction = direction;
|
||||
data.gradeidx = (direction === 1) ? this._gradeIdx1 : this._gradeIdx2;
|
||||
data.idx = (direction === 1) ? this._gradeIdx1 : this._gradeIdx2;
|
||||
}
|
||||
if (typeof Net !== 'undefined' && Net.Send_get_player_grade1) { Net.Send_get_player_grade1(data); }
|
||||
},
|
||||
@@ -89,7 +104,7 @@ var RecordView = {
|
||||
_requestReplayData: function (gameIndex) {
|
||||
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
|
||||
if (this._gradeData && this._gradeData[gameIndex] && typeof this._gradeData[gameIndex].idx !== 'undefined') {
|
||||
data.gradeidx = this._gradeData[gameIndex].idx;
|
||||
data.idx = this._gradeData[gameIndex].idx;
|
||||
}
|
||||
if (typeof Net !== 'undefined' && Net.Send_get_player_grade2) { Net.Send_get_player_grade2(data); }
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ var UIManager = (function () {
|
||||
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_ICON);
|
||||
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT);
|
||||
SpriteManager.hideGroup(LOADING_UI.GROUP_ID);
|
||||
console.log('[UIManager] Loading UI 初始化 (Layer ' + LOADING_UI.LAYER + ')');
|
||||
// console.log('[UIManager] Loading UI 初始化 (Layer ' + LOADING_UI.LAYER + ')');
|
||||
}
|
||||
|
||||
/** @private */
|
||||
@@ -153,7 +153,7 @@ var UIManager = (function () {
|
||||
SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND);
|
||||
SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT);
|
||||
SpriteManager.hideGroup(MESSAGE_UI.GROUP_ID);
|
||||
console.log('[UIManager] Message UI 初始化 (Layer ' + MESSAGE_UI.LAYER + ')');
|
||||
// console.log('[UIManager] Message UI 初始化 (Layer ' + MESSAGE_UI.LAYER + ')');
|
||||
}
|
||||
|
||||
/** @private */
|
||||
@@ -164,7 +164,7 @@ var UIManager = (function () {
|
||||
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM);
|
||||
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL);
|
||||
SpriteManager.hideGroup(CONFIRM_UI.GROUP_ID);
|
||||
console.log('[UIManager] Confirm UI 初始化 (Layer ' + CONFIRM_UI.LAYER + ')');
|
||||
// console.log('[UIManager] Confirm UI 初始化 (Layer ' + CONFIRM_UI.LAYER + ')');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +227,7 @@ var UIManager = (function () {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[UIManager] 初始化 (v1.0.0)');
|
||||
// console.log('[UIManager] 初始化 (v1.0.0)');
|
||||
|
||||
try {
|
||||
_components = {};
|
||||
@@ -239,7 +239,7 @@ var UIManager = (function () {
|
||||
|
||||
_isInitialized = true;
|
||||
this.initialized = true;
|
||||
console.log('[UIManager] 初始化完成');
|
||||
// console.log('[UIManager] 初始化完成');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[UIManager] 初始化失败:', err);
|
||||
@@ -279,7 +279,7 @@ var UIManager = (function () {
|
||||
console.warn('[UIManager] registerComponent: 覆盖已存在组件:', name);
|
||||
}
|
||||
_components[name] = component;
|
||||
console.log('[UIManager] 注册组件 "' + name + '"');
|
||||
// console.log('[UIManager] 注册组件 "' + name + '"');
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -305,7 +305,7 @@ var UIManager = (function () {
|
||||
var c = _components[name];
|
||||
if (c.isVisible) { c.hide(); }
|
||||
delete _components[name];
|
||||
console.log('[UIManager] 注销组件 "' + name + '"');
|
||||
// console.log('[UIManager] 注销组件 "' + name + '"');
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -321,7 +321,7 @@ var UIManager = (function () {
|
||||
}
|
||||
_components[name].destroy();
|
||||
delete _components[name];
|
||||
console.log('[UIManager] 销毁组件 "' + name + '"');
|
||||
// console.log('[UIManager] 销毁组件 "' + name + '"');
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -371,7 +371,7 @@ var UIManager = (function () {
|
||||
}
|
||||
}
|
||||
_scenes[sceneName] = componentNames;
|
||||
console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
|
||||
// console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -390,7 +390,7 @@ var UIManager = (function () {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
|
||||
// console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
|
||||
_hideCurrentScene();
|
||||
_showScene(sceneName);
|
||||
|
||||
@@ -447,7 +447,7 @@ var UIManager = (function () {
|
||||
* @param {string} message
|
||||
*/
|
||||
showToast: function (message) {
|
||||
console.log('[UIManager] Toast:', message);
|
||||
// console.log('[UIManager] Toast:', message);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -458,7 +458,7 @@ var UIManager = (function () {
|
||||
*/
|
||||
showEffectToast: function (tag, title, description) {
|
||||
var msg = description ? (title + ':' + description) : title;
|
||||
console.log('[UIManager] EffectToast [' + tag + ']:', msg);
|
||||
// console.log('[UIManager] EffectToast [' + tag + ']:', msg);
|
||||
this.showToast(msg);
|
||||
},
|
||||
|
||||
@@ -575,14 +575,14 @@ var UIManager = (function () {
|
||||
* 打印状态(调试用)
|
||||
*/
|
||||
printState: function () {
|
||||
console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
|
||||
// console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
|
||||
},
|
||||
|
||||
/**
|
||||
* 销毁 UIManager(销毁所有组件并重置状态)
|
||||
*/
|
||||
destroy: function () {
|
||||
console.log('[UIManager] 开始销毁');
|
||||
// console.log('[UIManager] 开始销毁');
|
||||
for (var name in _components) {
|
||||
if (_components.hasOwnProperty(name)) { this.destroyComponent(name); }
|
||||
}
|
||||
@@ -594,7 +594,7 @@ var UIManager = (function () {
|
||||
_currentScene = null;
|
||||
_isInitialized = false;
|
||||
this.initialized = false;
|
||||
console.log('[UIManager] 销毁完成');
|
||||
// console.log('[UIManager] 销毁完成');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Spine 运行时 vendor 补丁记录
|
||||
|
||||
> ⚠️ **升级 `spine-canvas.js` / `spine-webgl.js` 前必读。**
|
||||
> 这两个文件**不是官方发行版原样**,含本地补丁。直接用官方文件覆盖会导致
|
||||
> `file://` 协议下资源加载全部失败(表现:所有 Spine 特效不显示)。
|
||||
|
||||
## 基线版本
|
||||
|
||||
| 文件 | npm 包 | 基线版本 | 字节数 | sha256 |
|
||||
|---|---|---|---|---|
|
||||
| `spine-canvas.js` | `@esotericsoftware/spine-canvas` | **4.2.113**(与 4.2.114 官方产物完全相同) | 455670 | `a19ad49c42490d4f41dbc805b0b91d72ccca4236957b2cdab8e57483c3aeb45c` |
|
||||
| `spine-webgl.js` | `@esotericsoftware/spine-webgl` | **4.2.113** | 553987 | `0c2a0d8cb833297168bac7bbb7821d6ebe87aa11c3708831ca6741468b3d4d25` |
|
||||
|
||||
骨架资源导出版本为 **4.2.43**(见 `client/assets/spine/*.json` 的 `skeleton.spine` 字段),
|
||||
运行时须保持在 **4.2.x** 系列。
|
||||
|
||||
官方产物获取地址:
|
||||
```
|
||||
https://cdn.jsdelivr.net/npm/@esotericsoftware/spine-canvas@4.2.113/dist/iife/spine-canvas.js
|
||||
https://cdn.jsdelivr.net/npm/@esotericsoftware/spine-webgl@4.2.113/dist/iife/spine-webgl.js
|
||||
```
|
||||
|
||||
官方原始产物 sha256(用于确认下载无误):
|
||||
- spine-canvas 4.2.113/114:`b706c28a…`(4.2.43 版,仅作对照,非本项目基线)
|
||||
- spine-webgl 4.2.113:`e737ac4e7caa9fae…`(打补丁前)
|
||||
|
||||
---
|
||||
|
||||
## 补丁清单
|
||||
|
||||
### 补丁 1 —— `file://` 下不设 `crossOrigin`
|
||||
|
||||
**应用于**:`spine-canvas.js`、`spine-webgl.js`
|
||||
|
||||
**位置**:`AssetManager.loadTexture` 中构造 `Image` 处(canvas 版约 5798 行 / webgl 版约 5838 行)。
|
||||
|
||||
**原因**:`file://` 协议下设置 `image.crossOrigin = "anonymous"` 会使图片被判为跨域而加载失败。
|
||||
这是 PNG 图集能在 `file://` 下加载的关键。
|
||||
|
||||
```js
|
||||
// 官方
|
||||
image.crossOrigin = "anonymous";
|
||||
|
||||
// 本地
|
||||
if (typeof location === "undefined" || location.protocol !== "file:")
|
||||
image.crossOrigin = "anonymous";
|
||||
```
|
||||
|
||||
⚠️ 只改 `loadTexture` 中的 `image`。webgl 版另有 `logoImage` / `spinnerImage`
|
||||
(约 14004 / 14008 行,属 loading spinner),**不要动**。
|
||||
|
||||
---
|
||||
|
||||
### 补丁 2 —— 删除 `toLoad === 0` 提前 resolve 分支
|
||||
|
||||
**应用于**:仅 `spine-canvas.js`(**`spine-webgl.js` 未移植,保留官方逻辑**)
|
||||
|
||||
**位置**:`AssetManager.loadTextureAtlas`(canvas 版官方约 5826-5830 行)。
|
||||
|
||||
```js
|
||||
// 官方(webgl 版保留此段)
|
||||
if (toLoad === 0) {
|
||||
this.success(success, path, atlas);
|
||||
resolve(atlas);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**行为差异**:官方在 `atlas.pages.length === 0` 时立即 resolve;删除后该情形下 Promise
|
||||
永不 resolve,`isLoadingComplete()` 恒为 false,全部 Spine 都不会显示。
|
||||
|
||||
**未移植到 webgl 版的理由**:本项目 20 个 atlas 文件实测**全部至少 1 页**(共 23 页),
|
||||
该分支在本项目不可达,删与不删行为完全相同;而官方逻辑在边界情形下更安全。
|
||||
|
||||
**注**:此补丁在 `spine-canvas.js` 中随 commit `1f48f4ea`(2026-04-20)打包引入,
|
||||
提交信息未提及,**原始意图在仓库中无记录可考**。上述为行为分析,非意图推定。
|
||||
|
||||
---
|
||||
|
||||
### 补丁 3 —— `rawDataUri` 判定改为 `startsWith("data:")`
|
||||
|
||||
**应用于**:`spine-canvas.js`(仅 `downloadText`)、`spine-webgl.js`(`downloadText` + `downloadBinary`)
|
||||
|
||||
**位置**:`Downloader.downloadText` / `Downloader.downloadBinary`
|
||||
(canvas 版约 6070 / 6096 行,webgl 版约 6110 / 6136 行)。
|
||||
|
||||
```js
|
||||
// 官方
|
||||
if (rawDataUri && !rawDataUri.includes(".")) {
|
||||
|
||||
// 本地
|
||||
if (rawDataUri && rawDataUri.startsWith("data:")) {
|
||||
```
|
||||
|
||||
**原因**:`SpineMgr.js` 用 `setRawDataURI(key, "data:," + textData[key])` 注入嵌入的
|
||||
json/atlas 文本(见 `client/generated/spine_data.js`),以规避 `file://` 下 XHR 的 CORS 拦截。
|
||||
而 json 文本中必然含小数点(坐标、版本号等),官方那个「不含点才算 data URI」的判定会误判为
|
||||
非 data URI,导致回退到 XHR 请求而失败。
|
||||
|
||||
**两版差异说明**:`spine-canvas.js` 只改了 `downloadText`,因为本项目只用 `loadText`
|
||||
(json/atlas 均为文本),不走二进制 `.skel` 路径。`spine-webgl.js` 两处都改了 ——
|
||||
该判定本身是官方的缺陷逻辑,改为 `startsWith("data:")` 在两处均为纯正确性提升、无副作用。
|
||||
|
||||
---
|
||||
|
||||
## 升级流程
|
||||
|
||||
1. 从上述 CDN 取目标版本的官方 iife 产物,记录其原始 sha256。
|
||||
2. 逐条比对本文件的补丁清单,确认每处补丁点在新版中仍存在且语境未变。
|
||||
3. 移植补丁时**必须校验匹配次数**(补丁 1 应命中 1 处、补丁 3 应命中 2 处),
|
||||
匹配数不符即中止并重新核对,不得盲替。
|
||||
4. 验证产物:`node --check` 语法检查;用 vm 加载并确认关键 API 齐备
|
||||
(`AssetManager` / `SkeletonJson` / `AtlasAttachmentLoader` / `Skeleton` /
|
||||
`AnimationState` / `AnimationStateData` / `Physics` / `TextureAtlas`,
|
||||
webgl 版另需 `SceneRenderer` / `PolygonBatcher` / `GLTexture` /
|
||||
`ManagedWebGLRenderingContext`)。
|
||||
5. 更新本文件的版本号与 sha256。
|
||||
6. 真机验证 `file://` 下资源加载正常(补丁 1、3 的实际验收点)。
|
||||
|
||||
## 已知约束
|
||||
|
||||
两个文件都以 `var spine = (() => {...})()` 形式暴露**同一个全局名 `spine`**。
|
||||
**同时加载两者会互相覆盖**,后加载的胜出。因此运行时只能加载其中一个,
|
||||
或在两次加载之间抢存引用。相关设计见
|
||||
`docs/superpowers/specs/2026-08-08-spine-webgl-migration-design.md`。
|
||||
Vendored
+59
-20
@@ -1,3 +1,44 @@
|
||||
/* ============================================================================
|
||||
* GAMEABC_TEXT —— 文字渲染统一层(本地补丁,见 js/vendor/README-gameabc-patches.md)
|
||||
*
|
||||
* 取代原先写在 js/00_Surface/12_Logic.js 里对 CanvasRenderingContext2D.prototype
|
||||
* 的猴子补丁,把「字体族 / 字重 / 测宽 / 竖直锚点」四件事收敛到引擎内一处。
|
||||
*
|
||||
* 1) 字体族与字重:原写法 'lighter Npx Arial',Arial 无中文字形,中文走回退字体又被
|
||||
* weight=100 拉细,导致中英粗细不一;另有三处写成 'Npx bold san-serif'(拼写错误,
|
||||
* 应为 sans-serif),实际落到默认衬线字体。统一为 500 字重 + 中文友好字体族。
|
||||
*
|
||||
* 2) 测宽同源:ifast_measureText 必须与实际绘制用同一 font 串,否则依赖测宽做居中 /
|
||||
* 换行的地方会有约 3% 的横向偏差。现在两者都走 GAMEABC_TEXT.font()。
|
||||
*
|
||||
* 3) 竖直锚点:Canvas 的 textBaseline="top" 锚点 = 基线上移「首个可用字体的 ascent」,
|
||||
* 但 WebKit(iOS 全部浏览器 + WKWebView)取 CoreText 原始 hhea ascent,
|
||||
* Blink(PC Chrome/Edge、安卓 WebView)取归一化 typo ascent —— 同字同号相差 0.125em;
|
||||
* iOS 上首个可用字体是 PingFang SC(ascent 1.06em)会把差距放大到 0.20em,
|
||||
* 表现为「iOS 文字整体偏下」。alphabetic 是唯一不依赖度量解释的锚点,
|
||||
* 因此这里改为固定用 alphabetic,由本层自算基线位置。
|
||||
*
|
||||
* TOP_TO_BASELINE = 0.845 是实测值:让 WebKit 的渲染结果与改造前 Blink 的外观
|
||||
* 在 24 / 40 / 64px 三档下逐像素对齐(最大偏差 1px),即「iOS 向网页/安卓看齐」,
|
||||
* 而不是三端一起位移。调整它会整体上下移动所有文字。
|
||||
* ========================================================================== */
|
||||
var GAMEABC_TEXT = {
|
||||
FAMILY: '"Microsoft YaHei", "PingFang SC", Arial, sans-serif',
|
||||
WEIGHT: '500',
|
||||
TOP_TO_BASELINE: 0.845,
|
||||
font: function(size) {
|
||||
return this.WEIGHT + ' ' + size + 'px ' + this.FAMILY;
|
||||
},
|
||||
/* 绘制前调用:设好字体并把锚点固定为 alphabetic */
|
||||
prepare: function(ctx, size) {
|
||||
ctx.font = this.font(size);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
},
|
||||
/* 把原先「以文字块顶部为锚点的 y」换算成 alphabetic 基线的 y */
|
||||
baseline: function(y, size) {
|
||||
return y + this.TOP_TO_BASELINE * size;
|
||||
}
|
||||
};
|
||||
var gameabc_senderror = 0;
|
||||
var gameabc = function() {
|
||||
function s(SIt2, L$qig3, Qf4) {
|
||||
@@ -455,7 +496,7 @@ function dfwtime200() {
|
||||
return 0;
|
||||
}
|
||||
;var dc = ifast_getdc();
|
||||
dc.font = 'lighter ' + obj.h + 'px Arial';
|
||||
dc.font = GAMEABC_TEXT.font(obj.h);
|
||||
if (!caption) {
|
||||
caption = obj.caption;
|
||||
}
|
||||
@@ -2120,10 +2161,9 @@ function GameObjectManager() {
|
||||
var yyyy;
|
||||
var uu = gameabc_face.loglist.length - 1;
|
||||
for (yyyy = uu; yyyy >= 0; yyyy--) {
|
||||
this.backBufferContext2D.textBaseline = "top";
|
||||
GAMEABC_TEXT.prepare(this.backBufferContext2D, 32);
|
||||
this.backBufferContext2D.fillStyle = "red";
|
||||
this.backBufferContext2D.font = 32 + 'px' + ' bold san-serif';
|
||||
this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, 80 + (uu - yyyy) * 32);
|
||||
this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, GAMEABC_TEXT.baseline(80 + (uu - yyyy) * 32, 32));
|
||||
}
|
||||
;
|
||||
}
|
||||
@@ -2135,7 +2175,10 @@ function GameObjectManager() {
|
||||
}
|
||||
;if (gameabc_face.obj.caption != "") {
|
||||
this.backBufferContext2D.fillStyle = "white";
|
||||
this.backBufferContext2D.font = parseInt(24 * this.backBuffer.width / 480) + "px" + ' bold san-serif';
|
||||
/* 这一处原本就没有设过 textBaseline,实际用的是默认 alphabetic;
|
||||
这里显式写死以免依赖上一次绘制在 context 上的残留状态,y 保持不变。 */
|
||||
this.backBufferContext2D.font = GAMEABC_TEXT.font(parseInt(24 * this.backBuffer.width / 480));
|
||||
this.backBufferContext2D.textBaseline = 'alphabetic';
|
||||
this.backBufferContext2D.fillText(gameabc_face.obj.caption, parseInt(this.backBuffer.width / 2) - 50, parseInt(this.backBuffer.height / 2) + 100);
|
||||
}
|
||||
;if (gameabc_face.checkmustdraw() == 0) {
|
||||
@@ -2641,17 +2684,16 @@ function ifast_float(f, r) {
|
||||
;
|
||||
}
|
||||
;function ifast_mydrawtext(spid, recid, sp_x, sp_y, sp_w, sp_h) {
|
||||
gameabc_face.dc.textBaseline = "top";
|
||||
GAMEABC_TEXT.prepare(gameabc_face.dc, sp_h);
|
||||
gameabc_face.dc.fillStyle = gameabc_GameTxt.GameTxtList[recid].Color;
|
||||
gameabc_face.dc.font = sp_h + 'px' + ' bold san-serif';
|
||||
var caption = gameabc_GameTxt.GameTxtList[recid].Text;
|
||||
if (spid <= 0) {
|
||||
gameabc_face.dc.fillText(caption, sp_x, sp_y, sp_w);
|
||||
gameabc_face.dc.fillText(caption, sp_x, GAMEABC_TEXT.baseline(sp_y, sp_h), sp_w);
|
||||
return;
|
||||
}
|
||||
;var obj = ifast_getobj(spid);
|
||||
if (typeof (obj) == "object") {
|
||||
gameabc_face.dc.fillText(caption, obj.x + sp_x, obj.y + sp_y, sp_w);
|
||||
gameabc_face.dc.fillText(caption, obj.x + sp_x, GAMEABC_TEXT.baseline(obj.y + sp_y, sp_h), sp_w);
|
||||
}
|
||||
;
|
||||
}
|
||||
@@ -5290,9 +5332,8 @@ function gameabc_imgtxt() {
|
||||
}
|
||||
;gameabc_save(this, context);
|
||||
gameabc_charge(this, context);
|
||||
context.textBaseline = "top";
|
||||
GAMEABC_TEXT.prepare(context, this.h);
|
||||
context.fillStyle = gameabc_GameTxt.GameTxtList[this.frame].Color;
|
||||
context.font = 'lighter ' + this.h + 'px Arial';
|
||||
this.caption = gameabc_GameTxt.GameTxtList[this.frame].Text;
|
||||
if (this.uidata.GameTxtStyle == 0) {
|
||||
gameabc_drawtext = this.caption;
|
||||
@@ -5300,18 +5341,17 @@ function gameabc_imgtxt() {
|
||||
gameabc_drawtext = gameabc_password.substring(0, this.caption.length + 1);
|
||||
}
|
||||
;if (this.w > 0) {
|
||||
context.fillText(gameabc_drawtext, this.x - xScroll, this.y - yScroll, this.w);
|
||||
context.fillText(gameabc_drawtext, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h), this.w);
|
||||
} else {
|
||||
context.fillText(gameabc_drawtext, this.x - xScroll, this.y - yScroll);
|
||||
context.fillText(gameabc_drawtext, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h));
|
||||
}
|
||||
;gameabc_restore(this, context);
|
||||
}
|
||||
}
|
||||
;gameabc_imgtxt.prototype = new VisualGameObject;
|
||||
function draw2(obj, context, ctx) {
|
||||
context.textBaseline = "top";
|
||||
var h = obj.h * iui.ratio;
|
||||
context.font = 'lighter ' + h + 'px Arial';
|
||||
GAMEABC_TEXT.prepare(context, h);
|
||||
var w = context.measureText(obj.caption).width;
|
||||
context.clearRect(0, 0, w, h);
|
||||
if (obj.uidata.BackColorA != 0) {
|
||||
@@ -5319,7 +5359,7 @@ function draw2(obj, context, ctx) {
|
||||
context.fillRect(0, 0, w, h);
|
||||
}
|
||||
;context.fillStyle = obj.uidata.FontColor;
|
||||
context.fillText(obj.caption, 0, 0);
|
||||
context.fillText(obj.caption, 0, GAMEABC_TEXT.baseline(0, h));
|
||||
ctx.drawImage(iui.test, 0, 0, w, h, obj.x, obj.y, w / iui.ratio, obj.h);
|
||||
}
|
||||
;function gameabc_txt() {
|
||||
@@ -5340,13 +5380,12 @@ function draw2(obj, context, ctx) {
|
||||
if (this.uidata.GameTxtStyle > 0) {
|
||||
return;
|
||||
}
|
||||
;context.textBaseline = "top";
|
||||
;GAMEABC_TEXT.prepare(context, this.h);
|
||||
if (this.uidata.BackColorA != 0) {
|
||||
context.fillStyle = this.uidata.BackColor;
|
||||
context.fillRect(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
;context.fillStyle = this.uidata.FontColor;
|
||||
context.font = 'lighter ' + this.h + 'px Arial';
|
||||
if (this.caption.indexOf("\n") > 0) {
|
||||
var strs = new Array();
|
||||
strs = this.caption.split("\n");
|
||||
@@ -5355,11 +5394,11 @@ function draw2(obj, context, ctx) {
|
||||
context.fillRect(this.x, this.y, this.w, this.h * strs.length);
|
||||
}
|
||||
;for (var i = 0; i < strs.length; i++) {
|
||||
context.fillText(strs[i], this.x - xScroll, this.y - yScroll + this.h * i);
|
||||
context.fillText(strs[i], this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll + this.h * i, this.h));
|
||||
}
|
||||
;return;
|
||||
}
|
||||
;context.fillText(this.caption, this.x - xScroll, this.y - yScroll);
|
||||
;context.fillText(this.caption, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h));
|
||||
}
|
||||
}
|
||||
;gameabc_txt.prototype = new VisualGameObject;
|
||||
|
||||
Vendored
+14172
File diff suppressed because one or more lines are too long
+768
-768
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -1,19 +1,20 @@
|
||||
//GameData.AgentId = "i33v0llvp0euhd1n9qo1fM2RV8vtog4y";
|
||||
//GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
|
||||
//GameData.ChannelId = "7N0e0z2u2098pf1M2fj0kyB1D4n4ylkA";
|
||||
GameData.GameId = "8x4l0rGjf026f60c48h0mbUAhK5vV16f";
|
||||
GameData.GameId = "jzWe0kzmN0tAcZ5MLks3rFWcxNecogyu";
|
||||
//GameData.GameId = "dQyb0dvzz0afdj65hsb08QjMhzt94E1v";
|
||||
|
||||
|
||||
GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
|
||||
GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS";
|
||||
|
||||
//GameData.ChannelId = "t1q802oz10p4rn0E99313xtu2sVi9Zru";
|
||||
|
||||
// GameData.AgentId = "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv";
|
||||
// GameData.ChannelId = "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK";
|
||||
// GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
|
||||
// GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS";
|
||||
GameData.AgentId = "1B2h0ccl205c390Y28m1Ajdplkuu4wgy";
|
||||
GameData.ChannelId = "aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG";
|
||||
//GameData.GameId = "Btke0urRy0cvPd5CIvD5yfhYhKhdSdex";
|
||||
|
||||
|
||||
GameData.Version = "1.1";//真实版本号1 代理商 天盛网络 游戏
|
||||
GameData.versionCode = 10000;//真实版本号
|
||||
GameData.versionCode = 1;//真实版本号
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ MyView.init = function (config) {
|
||||
1. **数据集中在 `this.data`**:组件及其下每个「零件 UI」的自有数据全部收拢到 `this.data.*`,**不散落**在实例其他字段或全局;界面能否重建只取决于 `this.data`。
|
||||
2. **每个 UI 都有 `setXxx` / `refreshXxx` 成对方法**:`setXxx(...)` **只写数据**到 `this.data`、不碰精灵;`refreshXxx()` **只据 `this.data` 画界面**、不改数据。两者职责单一、互不越界。
|
||||
3. **组件有一个总 `refresh()`**:顺序调用其下所有零件的 `refreshXxx()`,据 `this.data` 完整重画。**任何时候调 `refresh()` 都能无歧义重建正确界面**(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。
|
||||
4. **显隐走 `showXxx` / `hideXxx` 接口**:组件(及每个「零件 UI」)的精灵/群组显隐,由组件自身暴露的 `showXxx()`/`hideXxx()` 语义方法控制;外部**只调这些接口**,**禁止**在别处直接用该组件/零件的精灵 ID、群组 ID 去 `SpriteManager.show/hide`(或 `showGroup/hideGroup`)——那样绕过组件、状态分散,重连/刷新时不一致。
|
||||
|
||||
```js
|
||||
GameView.setScore = function (v) { this.data.score = v; }; // 只写数据
|
||||
|
||||
@@ -87,7 +87,12 @@ Game_Modify.StartWar = function (_msg) {
|
||||
|
||||
### 重连(Reconnect)= 重画
|
||||
|
||||
重连入口拿到服务端 `get_deskinfo` 的完整快照,交由新架构的重连处理**据本地数据重建整个界面**。重连与“切 app 重画”应复用同一条重画路径——**任何时候执行重画函数都能还原正确界面**(如网页刷新)。这与「数据优先、表现延后」一脉相承:界面永远能从数据无歧义地重建。
|
||||
前端**必须同时处理两种恢复场景**,二者本质相同、复用同一条重画路径:
|
||||
|
||||
- **断线重连**:`Game_Modify.Reconnect` 拿到服务端 `get_deskinfo` 的完整快照,交给新架构的重连处理。
|
||||
- **硬刷新 / 页面重载**:浏览器刷新后从零重启,同样要能还原到当前对局界面。
|
||||
|
||||
**重连处理的本质 = 恢复数据 → 恢复界面**:先据快照把各组件的 `this.data` 恢复齐,再逐个调用各 UI 组件的 `setXxx`/`refreshXxx`(或组件总 `refresh()`)据数据重建界面与交互状态。**绝不为重连单写一套渲染**——它复用「组件数据自持 + set-refresh」范式(见 02 §3、05 §6)的同一条重画路径,因此**任何时候执行重画都能从数据无歧义地还原正确界面**。这与「数据优先、表现延后」一脉相承。
|
||||
|
||||
**红线**:受限文件 `Game_Modify.*` 里**只接不写**,业务逻辑全部在新架构 handler 中。
|
||||
|
||||
@@ -111,7 +116,80 @@ function handleXxx(data) {
|
||||
|
||||
---
|
||||
|
||||
## 5. 启动编排
|
||||
## 5. 输入—渲染解耦:发包只请求,收包才表现
|
||||
|
||||
> 专业名:**服务端权威的悲观 UI 更新(Server-Authoritative Pessimistic Rendering)** / 单向数据流下的**输入-渲染解耦**。
|
||||
|
||||
**核心时序原则:用户点击只负责发出请求包,绝不直接改动任何对局状态界面;一切随对局状态变化的表现,只在收到服务端下发的结果/推送包后才更新。**
|
||||
|
||||
发包与表现是两条独立通道:**点击发「命令」,收包应「事件」,UI 只订阅事件**。界面永远是服务端已确认状态的投影,不做「点击即更新」的乐观预测。
|
||||
|
||||
### 5.1 点击回调的职责边界
|
||||
|
||||
点击回调**只做两件事**:①组织并发送请求包(走 §1 语义化发包封装);②(可选)纯本地物理反馈。**不得**在点击回调里改动任何对局状态界面。
|
||||
|
||||
| 归类 | 例子 | 点击时可否做 |
|
||||
|------|------|--------------|
|
||||
| 纯本地物理反馈(不碰领域状态) | 按钮按下高亮/缩放/音效 | ✅ 可即时 |
|
||||
| **响应/掷骰交互按钮的隐藏**(本玩家点击的碰/杠/过/胡按钮、手动掷骰按钮) | 点击发包即隐藏该按钮(兼作防连点) | ⚠️ **受控例外**允许乐观清除——**前提是服务端合法性验证 + 收包侧兜底**(见 5.6) |
|
||||
| 其余对局状态表现(领域状态) | 各类提示(等待听牌/报定等待/掷骰提示文字)、当前控制权高亮、启停倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露 | ❌ **禁止**乐观,一律等收包 |
|
||||
|
||||
判别标准:**这个变化服务端要不要确认?** 要 → 收包后更新(除 5.6 例外的交互按钮);纯本地物理反馈、服务端根本不关心 → 可即时。
|
||||
|
||||
### 5.2 为什么必须如此(与 AI 托管同源)
|
||||
|
||||
真人操作与 AI 托管/他人操作**共用同一后半段**:`服务端处理 → 下发结果包 → 前端更新`。
|
||||
|
||||
```
|
||||
真人: 点击 → 发请求包 →┐
|
||||
├→ 服务端处理 → 下发结果包 → 收包处理器(唯一更新点)
|
||||
AI 托管:服务端 AI 决策 →┘
|
||||
```
|
||||
|
||||
把界面更新一律挂在「**收包**」这个节点,则无论操作由真人点击还是服务端 AI 自动触发,前端表现都自动一致、**无需区分触发源**(渲染透明)。反之若挂在「点击」节点:AI 托管时**根本没有点击动作**,服务端自动发包后对应更新代码永不触发 → 界面卡死、提示不消失、按钮不刷新。
|
||||
|
||||
这正是根目录 CLAUDE.md「AI 托管数据一致性 / 对前端透明」在**前端时序维度**的必然要求,也与「前端不推测、当前玩家由服务端权威字段(如 `nextControlSeat`)给出」一脉相承。
|
||||
|
||||
### 5.3 收包处理器必须「触发源无关」
|
||||
|
||||
同一收包处理器既服务真人操作回包、也服务 AI 托管与他人操作广播。因此:
|
||||
|
||||
- **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。
|
||||
- **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。
|
||||
|
||||
### 5.4 适用范围
|
||||
|
||||
凡「随对局状态变化」的表现均适用,包括但不限于:交互提示(手动掷骰提示文字 / 请出牌 / 等待其他玩家听牌 / 报定等待)、当前控制权高亮(按包内 `nextControlSeat`)、倒计时(按包内剩余时间启停)、手牌/牌河/亮牌、他人副露、阶段与托管图标。
|
||||
|
||||
> **例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(见 5.6);但这些按钮的**显示**,以及一切**提示**(含掷骰提示文字、等待听牌、报定等待),仍严格收包驱动。
|
||||
|
||||
### 5.5 验收标准
|
||||
|
||||
每处改动须验**两条路径表现一致**:
|
||||
|
||||
1. **真人手动操作**:点击后界面在收到回包时才更新(点击瞬间不抢先变化)。
|
||||
2. **服务端自动发包**(AI 托管 / 他人操作广播):无任何点击,界面同样在收到推送包后正确更新,且与真人路径**表现完全一致**。
|
||||
|
||||
两条都验过且一致,方为合规。
|
||||
|
||||
### 5.6 受控例外:响应交互按钮乐观清除 + 服务端合法性验证
|
||||
|
||||
对**本玩家点击触发的响应交互按钮**(碰/杠/过/胡 操作按钮、手动掷骰按钮),**允许**在点击发包时**乐观隐藏**该按钮——它同时充当防连点(按钮没了就点不了第二次)与即时反馈。这是对 5.1 悲观 UI 的**受控例外**,成立必须**同时满足**下列三条,缺一不可:
|
||||
|
||||
1. **服务端合法性验证兜底(正确性根本)**:正确性**绝不依赖**前端乐观隐藏。服务端对每个操作请求做完整合法性验证(座位鉴权 / 阶段门 / 操作可用性 / 幂等去重),默认拒绝非法/重复/越权/乱序包并回 `success:false` 且**不改状态**。即便乐观隐藏被绕过、或非正规客户端狂发包,服务端也正确拒绝。详见服务端 [04 §8 操作请求合法性验证](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。
|
||||
2. **收包侧兜底同样成立(AI 托管一致)**:因 AI 托管/他人操作**无点击**、不触发乐观隐藏,该按钮的清除**必须**在收包侧同样能发生——碰/杠/吃后操作者收非空 `availableActions` 覆盖刷新、胡后收包隐藏操作按钮、掷骰结果收包 `hideManualDiceUI`、「过」由收包据服务端下发的空 `availableActions` 清(或"进入托管即隐藏交互"覆盖)。**不得因加了乐观清除就删除收包兜底**。
|
||||
3. **仅限交互按钮、不外扩**:例外只覆盖"玩家自己点击的响应/掷骰交互按钮"。其余一切表现(等待听牌/报定等待/掷骰提示文字等**提示**、当前控制权高亮、倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露)**仍严格收包驱动**。
|
||||
|
||||
一句话:**乐观清除是即时反馈 + 防连点,收包侧与服务端验证才是权威——三者并存,不是用乐观清除替代收包/服务端。**
|
||||
|
||||
**例外项验收**(5.5 两条路径一致仍成立,只是真人侧多了"乐观隐藏"一步):
|
||||
- 真人:点击 → 按钮立即隐藏(乐观);服务端验证通过走正常流程,若拒绝(`success:false`)则按钮由收包纠正(重显或按服务端权威保持隐藏)。
|
||||
- AI 托管:无点击 → 按钮由收包侧隐藏,表现与真人一致(**重点验「过」不残留**)。
|
||||
- 连点/非法包:服务端拒绝,状态不变。
|
||||
|
||||
---
|
||||
|
||||
## 6. 启动编排
|
||||
|
||||
一局前端的启动由一段**启动编排**一次性完成(经 `Game_Modify.appStart` 触发):
|
||||
|
||||
@@ -127,7 +205,7 @@ function handleXxx(data) {
|
||||
|
||||
---
|
||||
|
||||
## 6. controllers 与 managers 职责
|
||||
## 7. controllers 与 managers 职责
|
||||
|
||||
| 类别 | 角色 | 典型成员 |
|
||||
|------|------|----------|
|
||||
@@ -152,7 +230,7 @@ function handleXxx(data) {
|
||||
|
||||
---
|
||||
|
||||
## 7. 本篇 DO / DON'T
|
||||
## 8. 本篇 DO / DON'T
|
||||
|
||||
| DO ✅ | DON'T ❌ |
|
||||
|------|---------|
|
||||
@@ -160,6 +238,8 @@ function handleXxx(data) {
|
||||
| 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 |
|
||||
| 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 |
|
||||
| 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 |
|
||||
| 点击只发请求包,对局状态表现等收包后更新(响应/掷骰交互按钮乐观清除为 5.6 受控例外) | 提示/控制权/倒计时/落牌等点击即更新(乐观预测)——AI 托管无点击时界面卡死 |
|
||||
| 收包处理器触发源无关,数据从包字段读 | 依赖「点击时暂存的本地变量」渲染 |
|
||||
| 重连/重画复用同一路径,据数据重建界面 | 重连单写一套与正常对局不同的渲染 |
|
||||
| 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 |
|
||||
|
||||
|
||||
@@ -49,12 +49,13 @@
|
||||
- **精灵只走 `SpriteManager`**:UI 代码禁止直接调 `GameABCUtils`/引擎原生 API。
|
||||
- **ID 守范围**:精灵 1001–3000、群组 ≥201、普通图层 101–200、弹窗图层 301–400(另有 501–600、701+ 备用段);框架保留 1–1000 及 3001+(精灵)等交替段,不可占用;ID 必须与编辑器一致,不编造。
|
||||
- **检查返回值**:`SpriteManager.*` 返回 `false` 即 ID/范围有误,及时暴露。
|
||||
- **常量集中、禁硬编码**:精灵 ID / 图片资源 ID / 坐标尺寸 / 动画时长帧数 / 音效 ID / 事件名,一律定义在对应常量文件,业务代码只引用:
|
||||
- 精灵结构 → 精灵结构常量(主界面/弹窗分文件)
|
||||
- **常量集中、禁硬编码**:UI 代码**禁止出现任何硬编码的 id 或裸值**——精灵 ID / 群组 ID / 图层 ID / 图片资源 ID / 声音 ID / Spine 资源 / 坐标尺寸 / 动画时长帧数 / 事件名,一律定义在对应常量文件,业务代码**只引用常量**:
|
||||
- 精灵结构(含群组 / 图层 ID)→ 精灵结构常量(主界面/弹窗分文件)
|
||||
- 图片资源 → 图片资源常量
|
||||
- 布局坐标 → 布局常量
|
||||
- 动画参数 → 动画配置
|
||||
- 音效 ID → 音效资源常量
|
||||
- 音效 / 语音 ID → 音效资源常量
|
||||
- Spine 资源 → Spine 动作配置
|
||||
- 事件名 → `EventBus.Events`(专属在子游戏事件常量文件)
|
||||
- 整合入口 → 精灵常量整合入口(**最后加载**)
|
||||
|
||||
@@ -73,8 +74,9 @@
|
||||
|
||||
- **服务端权威**:所有核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为「渲染副本」,不做权威计算。
|
||||
- **组件数据自持 + set-refresh**:组件(及其下每个「零件 UI」)的自有数据集中在 `this.data`,**不散落**各处;每个 UI 都有 `setXxx`(只写数据)/`refreshXxx`(只据数据画界面)成对方法,组件另有总 `refresh()` 据 `this.data` 重建整块界面(见 02)。
|
||||
- **更新时机——发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,**绝不**在点击时改动任何对局状态界面(提示显隐、按钮增删、当前控制权、倒计时启停、落牌、阶段/托管图标);这些表现一律在**收到服务端结果/推送包后**更新。点击回调只做「发包 +(可选)纯本地物理反馈(按下高亮/音效、防连点 `disable`)」。收包处理器**触发源无关**——数据从包字段读、不依赖「点击时暂存的本地变量」,故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**(无点击时也正确更新);把更新挂在「点击」而非「收包」会导致 AI 托管时界面卡死。详见 [04 §5](./04-网络对接与启动编排.md#5-输入渲染解耦发包只请求收包才表现)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 [04 §5.6](./04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证) 与服务端 [04 §8](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端);其余提示/控制权/倒计时/落牌仍严格收包驱动。
|
||||
- **收包节奏**:**先 `setXxx` 写数据 →(必要时 `refresh` 刷静态界面)→ 再播动画 → 动画回调里只刷新界面**;动画的开始/结束/出错等生命周期回调里**绝不设置核心数据**,动画期间**不改数据**。
|
||||
- **重画随时可用**:任何时候调 `refresh` 都能据 `this.data` 重建正确界面(网页刷新/断线重连/切 app 复用同一路径,不为重连单写一套渲染)。
|
||||
- **重画随时可用(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 `setXxx`/`refreshXxx` 恢复数据与界面状态**;任何时候调 `refresh` 都能据 `this.data` 重建正确界面(断线重连、硬刷新/网页重载、切 app 复用**同一条**重画路径,不为重连单写一套渲染,详见 04 §3.3)。
|
||||
- **动画是体验层**:开发阶段可**先不做动画**只保证静态界面正确;即使动画缺失/卡住/播错,数据、逻辑与界面仍正确、互不影响。
|
||||
|
||||
---
|
||||
@@ -92,13 +94,15 @@
|
||||
- **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。
|
||||
- `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。
|
||||
- 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。
|
||||
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §8 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。
|
||||
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §9 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#9-shared-文件同步流程)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 模块职责边界
|
||||
|
||||
- 一个职能只在一个模块实现,其他模块**调用而非重造**:渲染找 `SpriteManager`、动画找 `AnimationManager`(及游戏动画封装)、音频找游戏音频管理、Spine 找 `SpineMgr`(及 Spine 回调分发)、发包找语义化发包封装、收包分发找收包分发器。
|
||||
- **UI 组件专职自己的界面**:每个界面的数据与渲染**只由其对应 UI 组件实现**,组件对外提供 `setXxx`/`refreshXxx` 与语义化公开方法。别的模块(controllers/managers/其他组件)要改动或刷新某界面,一律**调用该组件的公开接口**,**禁止**在别处重复实现重叠或类似的界面数据/渲染逻辑(否则同一界面出现多份状态源,重连/刷新时必然不一致)。
|
||||
- **显隐走 `showXxx`/`hideXxx` 接口**:UI 组件及其「零件 UI」的精灵/群组显隐,必须由组件自身暴露的 `showXxx()`/`hideXxx()` 语义接口控制;**禁止**在别处直接用该组件/零件的精灵 ID、群组 ID 去 `SpriteManager.show/hide`(或 `showGroup/hideGroup`)控制其显隐——绕过组件即状态分散,重连/刷新时必然不一致。
|
||||
- 写代码前先问“这段属于谁的职责”,属于别人就调用它,不在本模块复制一份近似实现。
|
||||
|
||||
---
|
||||
@@ -119,13 +123,16 @@
|
||||
| 语言 | 严格 ES5;新文件插对加载顺序 |
|
||||
| 框架中立 | 框架无玩法逻辑/专属常量;专属内容归子游戏 |
|
||||
| 渲染 | 只走 `SpriteManager`;ID 守范围、不编造;查返回值 |
|
||||
| 常量 | 精灵/资源/坐标/动画/音效/事件全集中,禁硬编码裸值 |
|
||||
| 常量 | 精灵/群组/图层/图片/声音/Spine/坐标/动画/事件全进常量;UI 代码禁硬编码 id 与裸值 |
|
||||
| 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 |
|
||||
| 数据 | 服务端权威(核心运算/裁定在服务端);组件自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对、组件有总 `refresh` |
|
||||
| 更新时机 | 点击只发请求包,对局状态表现等**收包后**更新(悲观 UI);收包处理器触发源无关、数据从包字段读;AI 托管/他人广播共用同一更新路径。**受控例外**:响应/掷骰交互按钮隐藏可乐观清除(防连点+即时反馈),前提是服务端合法性验证+收包兜底(04 §5.6、服务端 04 §8) |
|
||||
| 节奏 | 先写数据后表现;动画回调只刷界面、**不写核心数据**;重画可随时据数据还原界面 |
|
||||
| 成败 | 只认 `data.success`,禁 `status` 兜底 |
|
||||
| 收发包 | 发走语义化发包封装、收走收包分发器;业务不进 `Game_Modify` |
|
||||
| 职责 | 一职能一模块,调用不重造 |
|
||||
| UI 职责 | 每个界面的数据/渲染/显隐只在其 UI 组件实现;别处调组件接口(含 `showXxx`/`hideXxx`),不重叠重造、不直接用其精灵/群组 id 控显隐 |
|
||||
| 重连 | 断线重连 + 硬刷新都要处理;本质=恢复数据→各组件 set-refresh;复用同一重画路径 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -60,11 +60,15 @@ BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(
|
||||
- **严格 ES5**:用 `var`/`function`/`Object.create`,禁 `let`/`const`/箭头/模板串/`class`。
|
||||
- **框架游戏中立**:`gameabc-framework/` 内**不得**出现任何具体玩法逻辑或专属常量;玩法专属的事件/资源/配置一律定义在子游戏侧(如玩法事件在子游戏的事件常量文件里追加到 `EventBus.Events`)。
|
||||
- **精灵只走 SpriteManager**:UI 代码**禁止**直接调 `GameABCUtils` 或引擎原生 API;ID 必须落在规定范围(精灵 1001–3000、群组 ≥201、图层 101–200/弹窗 301–400,另有 501–600、701+ 备用段,框架保留 1–1000 及 3001+ 等交替段)。
|
||||
- **常量集中、禁硬编码**:精灵 ID/图片资源 ID/坐标尺寸/动画时长/音效 ID 一律定义在对应常量文件,业务代码引用,**不内联裸值**。
|
||||
- **常量集中、禁硬编码**:UI 代码**禁止任何硬编码 id / 裸值**——精灵 ID / 群组 ID / 图层 ID / 图片资源 ID / 声音 ID / Spine 资源 / 坐标尺寸 / 动画时长 / 事件名 一律定义在对应常量文件、只引用常量。
|
||||
- **服务端权威、前端只展示**:核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为渲染副本,不做权威计算。
|
||||
- **组件走 BaseComponent**:UI 组件继承 `BaseComponent`,事件用 `this.addEventListener` 注册(`destroy()` 自动清理,防泄漏);动态精灵/定时器在 `onDestroy` 清理。
|
||||
- **组件数据自持 + set-refresh**:组件自有数据集中 `this.data`;每个 UI/零件有 `setXxx`(只写数据)/`refreshXxx`(只据数据画)对,组件有总 `refresh()` 可据数据重建界面。
|
||||
- **UI 组件专职自己的界面**:每个界面的数据与渲染只由其对应 UI 组件实现;别的模块要改/刷该界面一律**调该组件的公开接口**(`setXxx`/`refreshXxx`/语义方法),**禁止**在别处重复实现重叠或类似的界面数据/渲染逻辑。
|
||||
- **显隐走 `showXxx`/`hideXxx`**:UI 组件/零件的精灵、群组显隐必须由组件暴露的 `showXxx`/`hideXxx` 接口控制;**禁止**别处直接用其精灵 ID/群组 ID 去 `SpriteManager.show/hide` 控显隐。
|
||||
- **发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,对局状态界面(提示/按钮/控制权/倒计时/落牌/阶段)一律**收到服务端结果或推送包后**才更新,点击时不做乐观预测;收包处理器**触发源无关**(数据从包字段读,不依赖点击时的本地变量),故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**——挂在「点击」而非「收包」会导致 AI 托管时界面卡死(详见 04 §5)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡、手动掷骰按钮)的隐藏允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 04 §5.6 与服务端 04 §8;其余提示/控制权/倒计时/落牌仍严格收包驱动。
|
||||
- **数据优先、表现延后**:收到推送先 `setXxx` 写数据、再播动画;动画的开始/结束/出错回调里**只刷界面、不写核心数据**。即使动画缺失/卡住/出错,数据、逻辑、界面仍正确、互不影响。
|
||||
- **重连即重画(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 set-refresh 恢复数据与界面状态**,复用同一条重画路径,不为重连单写一套渲染(详见 04 §3.3、05 §6)。
|
||||
- **成败只认 `data.success`**:前端一律 `if (!data.success)` 判成败,**不**用 `status`/`code`,**不**写 `status` 兼容兜底。
|
||||
- **收发包走统一通道**:发包经统一发送封装(底层 `RpcHelper` 自动注入平台字段),收包统一分发(一 rpc 一处理器);业务逻辑放处理器,**不**写进 `Game_Modify.*`。
|
||||
- **shared 只读**:`01_SubGame/codes/shared/` 是服务端 `shared/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
|
||||
|
||||
@@ -238,7 +238,7 @@ if (!data.success) { /* 失败处理 */ return; }
|
||||
|
||||
## 7. 服务端代替玩家操作时的透明性
|
||||
|
||||
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)
|
||||
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 [04 §7 自动操作复用真人链路](./04-开发规范与红线.md#7-服务端自动操作复用真人链路) 与 [§8 操作请求合法性验证](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -102,7 +102,44 @@ function foo() { GameStateManager.doSomething(); }
|
||||
|
||||
---
|
||||
|
||||
## 8. Shared 文件同步流程
|
||||
## 8. 操作请求合法性验证(不可信客户端)
|
||||
|
||||
> 与 §7 配套:§7 保证"自动操作走真人链路",本节保证"到达的每个请求都合法才被链路执行"。
|
||||
|
||||
**客户端完全不可信**:它可能连点、乱序、丢包重发、篡改包体、发送非当前阶段/越权/当前不允许的操作。服务端**必须对每个到达的请求独立判定合法性**,非法即拒绝且**绝不改动任何对局状态**——就当这个包没来过。这是正确性的**根本防线**;前端的乐观清除/防连点(见前端 [04 §5.6](../../client/development-guide/04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证))只是体验层,**不承担正确性**。
|
||||
|
||||
### 五层验证栈(进业务前依次过闸,任一不过即 `success:false` + 不改状态)
|
||||
|
||||
1. **座位鉴权(防越权)**:操作者座位**由连接身份反查**(连接绑定的 playerid→座位,如平台 `check_player` 以 `fromid/conmode` 绑定),**绝不信任包体 `seat` 字段**;包内 seat 只做一致性校验,不等即拒。否则 A 玩家发 `{seat: B}` 就能替 B 操作。
|
||||
2. **阶段/状态门(防非当前阶段包)**:校验当前游戏阶段/状态允许该操作——非出牌阶段发出牌、非掷骰窗口发掷骰、无响应窗口发碰/过 → 拒绝。门控读 `gameState.phase` / `pendingResponse.waiting` / 是否轮到该座位(`currentPlayer === seat`)。
|
||||
3. **操作可用性校验(防"当前不允许的操作")——核心闸**:校验该操作**确实在该座位当前权威 `availableActions[seat]` 里**(没可碰的牌却发碰、没有胡机会却发胡 → 拒绝)。
|
||||
> 关键:`availableActions[seat]`(听牌/胡牌检测/操作枚举产出的**权威合法操作集**)**既是自动操作模块的决策选项来源,也是校验真人请求的准入白名单**——同一份权威数据兼任"决策依据"与"准入校验",统一了数据源唯一(§4)、自动操作职责边界(§5)、自动操作复用真人链路(§7)三条线。
|
||||
4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。
|
||||
5. **参数 / 数据合法性**:牌 `uniqueId` 是否真在该玩家手里、`targetCard`/`fromSeat`/`choiceIndex` 是否与权威牌局一致 —— 全按**服务端权威数据**校验,不信客户端传的牌面。
|
||||
|
||||
### 设计形态:统一裁决网关,默认拒绝(fail-closed)
|
||||
|
||||
把五层收敛成**一道所有操作共用的准入网关**(而非每个 handler 各写一遍散点 if)。网关默认 **deny**,仅当请求显式**命中该座位 `availableActions`** 且通过座位/阶段/参数校验才 **allow**。好处:
|
||||
|
||||
- **单点权威**:合法性判定只有一处,各 handler 不会判得不一致(呼应 §5 职责边界);
|
||||
- **自动操作与真人同闸**:自动操作(AI 托管)本就从 `availableActions` 选,必过同一网关,真正做到 §7 "自动操作 ≈ 服务端模拟一次**合法**真人操作"。
|
||||
|
||||
> **反模式警示**:若"是否轮到你/是否允许"的通用时序校验只存在于某个**从未被调用**的方法里(死代码),等于没有验证——尤其**出牌入口最易漏掉回合门**,导致任一玩家可在非自己回合越权出牌。验证网关必须**在统一入口真实生效**,并有测试覆盖。
|
||||
|
||||
### 失败返回:安静且明确
|
||||
|
||||
- 一律 `success:false` +(可选)`reason` 码,**绝不静默改状态**;失败推送前端只做提示/日志、**不改对局界面**(与前端 `if(!data.success)` 闭环)。
|
||||
- 最危险的是**"非法包被误当合法处理"(fail-open)**——默认拒绝就是为杜绝它。
|
||||
|
||||
### 验收(测试构造非法包,硬断言拒绝)
|
||||
|
||||
按 §11 测试纪律补单测:**非自己回合出牌 / 同回合连发两张 / 非当前阶段各操作 / 响应窗口关闭后迟到碰杠 → 断言 `success:false` 且状态不变**;**合法出牌各时机(摸后/碰后/杠后/报定后)→ 断言通过**(防回合门误伤合法出牌)。
|
||||
|
||||
> 一句话:**服务端对每个操作请求默认拒绝,仅当命中该座位当前权威 `availableActions` 且通过座位/阶段/参数校验才执行;操作一经消费即推进状态使重复包天然失效。正确性完全由这道网关保证,前端乐观清除只是体验。**
|
||||
|
||||
---
|
||||
|
||||
## 9. Shared 文件同步流程
|
||||
|
||||
### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关
|
||||
|
||||
@@ -127,7 +164,7 @@ function foo() { GameStateManager.doSomething(); }
|
||||
|
||||
---
|
||||
|
||||
## 9. 硬编码常量准则
|
||||
## 10. 硬编码常量准则
|
||||
|
||||
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
|
||||
|
||||
@@ -135,7 +172,7 @@ function foo() { GameStateManager.doSomething(); }
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试纪律
|
||||
## 11. 测试纪律
|
||||
|
||||
- **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。
|
||||
- **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。
|
||||
@@ -151,7 +188,7 @@ function foo() { GameStateManager.doSomething(); }
|
||||
|
||||
---
|
||||
|
||||
## 11. Git 提交规范
|
||||
## 12. Git 提交规范
|
||||
|
||||
- **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。
|
||||
- **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。
|
||||
@@ -159,7 +196,7 @@ function foo() { GameStateManager.doSomething(); }
|
||||
|
||||
---
|
||||
|
||||
## 12. 审查速查表
|
||||
## 13. 审查速查表
|
||||
|
||||
| 维度 | 红线 |
|
||||
|------|------|
|
||||
@@ -170,6 +207,7 @@ function foo() { GameStateManager.doSomething(); }
|
||||
| 职责 | 一职能一模块,调用不重造 |
|
||||
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
|
||||
| 自动操作 | 复用真人链路,对前端透明 |
|
||||
| 请求验证 | 客户端不可信;每个操作请求过座位鉴权/阶段门/操作可用性/幂等去重/参数五层校验,统一网关默认拒绝、失败 `success:false` 不改状态;出牌回合门尤其不可漏(防死代码) |
|
||||
| Shared | 只改服务端 `shared/`,跑同步脚本 |
|
||||
| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 |
|
||||
| Git | 一事一提交、中文信息、及时提交 |
|
||||
|
||||
Reference in New Issue
Block a user