fix: preserve map previews and respect mini tool controls
This commit is contained in:
@@ -15,6 +15,7 @@ npm run build
|
||||
npm run test:browser
|
||||
npm run test:results
|
||||
npm run test:interactions
|
||||
npm run test:container
|
||||
```
|
||||
|
||||
浏览器测试目前默认使用 macOS 已安装的 Google Chrome,路径见 `tests/browser.mjs`。构建要求 Python 3,以及官方 [minitool-zip-builder 1.8.0 Skill](https://fe-static.xhscdn.com/minitool/20261015161653/minitool-zip-builder-1.8.0.skill)。克隆后需将 Skill 解压到本地 `.codex/skills/minitool-zip-builder/`,该目录应直接包含 `SKILL.md`、`references/` 和 `scripts/`。本地工具、依赖、构建输出和报告均已忽略,不推送到代码库。所有运行依赖与地图随最终 ZIP 提供,无联网运行逻辑。
|
||||
@@ -93,7 +94,7 @@ npm run test:interactions
|
||||
|
||||
## 碰撞与晃动手感
|
||||
|
||||
碰撞采用160ms轻微压缩回弹、淡轮廓与180ms小接触点,明显弱于合并时的1600ms反馈。渲染形变不修改真实碰撞几何。左右轻摇仅在有支撑时颠起,上晃对框内全部组合施加一次向上冲量,420ms 冷却限制快速连点。颠起目标与上抛速度随弹性档位变化,但各档均有上限。最大倾角约 ±9.2°,水平和下落速度也受限,避免无限加速或飞出框。
|
||||
碰撞采用160ms轻微压缩回弹、淡轮廓与180ms小接触点,明显弱于合并时的1600ms反馈。渲染形变不修改真实碰撞几何。左右晃动缓存300ms颠起请求,每个物理步在实际接触时处理,避免150ms输入采样错过55ms接触窗口;每组仍有500ms颠起间隔,过期请求不在空中反复加速。暂停/离开页面清理待处理请求。上晃对框内全部组合施加一次向上冲量,420ms 冷却限制快速连点。颠起目标与上抛速度随弹性档位变化,但各档均有上限。最大倾角约 ±9.2°,水平和下落速度也受限,避免无限加速或飞出框。
|
||||
|
||||
`tests/shake-preview.mjs` 用真实左右操作和虚拟时钟采集碰撞反馈、速度与截图;`tests/results.mjs` 检查上晃、暂停主动结束、曲线和图片生成、模拟 SDK 参数和失败降级、自然判负及小型关卡真实通关的结算流程。均为本地开发回归,不能替代平台模拟器或真机验收。
|
||||
|
||||
@@ -103,8 +104,14 @@ npm run test:interactions
|
||||
|
||||
## 手机布局与地图标签修正
|
||||
|
||||
顶部在状态栏安全区之外额外预留56px平台操作区。玩法说明移到入口页脚,游戏的旅程设置和暂停/结束移到底部,成绩页返回移入底部操作组;弹窗也避开平台顶部区域。
|
||||
|
||||
手机入口保留地图预览,并按设置面板剩余高度分配地图区域;省级展示全国、地级展示所选省区。短屏收紧间距,横屏地图与双栏设置并排,避免地图、面板和页脚互相挤压。
|
||||
|
||||
入口和游戏页均按可见视口及安全区排版:在320×568、375×667、390×844以及740×390横屏检查一屏显示;小屏会缩减装饰地图和冗余文案,保留所有主要选择与开局按钮。省区选择采用27项网格,不再使用原生长下拉框,弹窗注明无地级数据的范围。
|
||||
|
||||
四档名称为“佛系看山 / 说走就走 / 一路狂飙 / 山河起飞”,速度与倍率参数保持原值。三个晃动按钮居中排版,成绩页同样一屏显示。游戏地图先绘制全部形状,再统一绘制标签;名称采用独立布局、边距约束、避让,以及小地区标签底色 / 引线,防止名称贴底被裁或被其他省份覆盖。
|
||||
|
||||
构建补回源数据的小离岛外环及独立海域线层,34省级代码、名称及外环数量均与保存的原始数据对比。台湾省14个源外环、香港9个、澳门2个、海南133个全部保留。详见`artifacts/MAP_AUDIT.md`,这不是标准地图审图结论。
|
||||
|
||||
`npm run test:container`在现代浏览器模拟平台顶部按钮与四组手机尺寸/安全区,逐页点击并检查布局,不替代小红书真机验证。
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"test": "node --test tests/*.test.cjs",
|
||||
"test:browser": "node tests/browser.mjs",
|
||||
"test:results": "node tests/results.mjs",
|
||||
"test:interactions": "node tests/interactions.mjs"
|
||||
"test:interactions": "node tests/interactions.mjs",
|
||||
"test:container": "node tests/container-layout.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"matter-js": "0.20.0",
|
||||
|
||||
+9
-4
@@ -26,13 +26,18 @@ function sizeCanvas(c,ww,hh){c.width=Math.round(ww*dpr);c.height=Math.round(hh*d
|
||||
function layout(){
|
||||
dpr=Math.min(window.devicePixelRatio||1,2);
|
||||
var bodyStyle=window.getComputedStyle(document.body),padding=(parseFloat(bodyStyle.paddingTop)||0)+(parseFloat(bodyStyle.paddingBottom)||0);
|
||||
var viewport=window.visualViewport;var visibleHeight=viewport&&typeof viewport.height==='number'&&viewport.scale===1?Math.min(window.innerHeight,viewport.height):window.innerHeight;var available=Math.max(320,visibleHeight-padding);document.documentElement.style.setProperty('--game-height',available+'px');document.body.classList.toggle('compact-entry',available<700);document.body.classList.toggle('tiny-entry',available<540);
|
||||
if(screen==='entry'){drawReference();return;}if(screen==='result'){fitCard();return;}
|
||||
var viewport=window.visualViewport;var visibleHeight=viewport&&typeof viewport.height==='number'&&viewport.scale===1?Math.min(window.innerHeight,viewport.height):window.innerHeight;var available=Math.max(200,visibleHeight-padding);document.documentElement.style.setProperty('--content-top',(parseFloat(bodyStyle.paddingTop)||0)+'px');document.documentElement.style.setProperty('--game-height',available+'px');document.body.classList.toggle('compact-entry',available<700||window.innerWidth<=720);document.body.classList.toggle('tiny-entry',available<540);
|
||||
if(screen==='entry'){fitEntryMap();drawReference();return;}if(screen==='result'){fitCard();return;}
|
||||
var r=canvas.getBoundingClientRect(),nw=Math.round(r.width),nh=Math.round(r.height);
|
||||
if(nw<1||nh<1)return;
|
||||
var changed=w!==nw||h!==nh;w=nw;h=nh;sizeCanvas(canvas,w,h);labelObstacles=['board-name','board-status'].map(function(id){var box=$(id).getBoundingClientRect();return {x:box.left-r.left-3,y:box.top-r.top-2,w:box.width+6,h:box.height+4};});var danger=document.querySelector('.danger-label').getBoundingClientRect();labelObstacles.push({x:danger.left-r.left-3,y:danger.top-r.top-2,w:danger.width+6,h:danger.height+4});
|
||||
if(game&&changed)game.resize(w,h);drawNext();if(game)draw();
|
||||
}
|
||||
function fitEntryMap(){
|
||||
var landscape=window.innerWidth>=600&&window.innerHeight<=520;if(window.innerWidth>720&&!landscape){document.body.classList.remove('map-roomy');return;}
|
||||
var main=document.querySelector('.entry-main'),setup=document.querySelector('.setup'),style=window.getComputedStyle(main),inside=main.clientHeight-(parseFloat(style.paddingTop)||0)-(parseFloat(style.paddingBottom)||0);
|
||||
var space=landscape?inside:inside-setup.offsetHeight-10,preview=Math.max(44,Math.min(220,space));document.documentElement.style.setProperty('--entry-preview-height',Math.floor(preview)+'px');document.body.classList.toggle('map-roomy',!landscape&&preview>=180);
|
||||
}
|
||||
function openModal(kicker,html,actions){
|
||||
stopInput();if(game&&game.status==='playing'){game.status='paused';pausedByModal=true;cancelAnimationFrame(rafId);rafId=0;}
|
||||
$('modal').classList.remove('province-picker-modal');$('modal-kicker').textContent=kicker;$('modal-content').innerHTML=html;$('modal-actions').innerHTML='';
|
||||
@@ -108,7 +113,7 @@ function draw(){if(!game)return;ctx.clearRect(0,0,w,h);ctx.fillStyle='#f1f2e9';c
|
||||
}
|
||||
function schedule(){if(!rafId&&!document.hidden&&game)rafId=requestAnimationFrame(frame);}
|
||||
function frame(t){rafId=0;if(!game||screen!=='game')return;if(!last)last=t;game.tick(t-last);last=t;if(screen!=='game')return;$('tank').style.transform='translate('+game.shake.offset.toFixed(2)+'px,'+game.shake.y.toFixed(2)+'px)';$('tank').classList.toggle('impact',game.wallHitUntil>game.clock);draw();if(t-uiAt>120){uiAt=t;updateUI();}if(game.status==='playing')schedule();}
|
||||
function stopInput(){clearInterval(holdTimer);holdTimer=null;inputDirection=0;pointerDirection=0;keys.left=false;keys.right=false;$('left').classList.remove('pressed');$('right').classList.remove('pressed');}
|
||||
function stopInput(){if(game)game.groups.forEach(function(g){g.pendingHopUntil=-1;});clearInterval(holdTimer);holdTimer=null;inputDirection=0;pointerDirection=0;keys.left=false;keys.right=false;$('left').classList.remove('pressed');$('right').classList.remove('pressed');}
|
||||
function refreshInput(){var direction=pointerDirection||((keys.right?1:0)-(keys.left?1:0));if(!game||game.status!=='playing')direction=0;if(direction===inputDirection)return;clearInterval(holdTimer);holdTimer=null;inputDirection=direction;$('left').classList.toggle('pressed',direction<0);$('right').classList.toggle('pressed',direction>0);if(direction){game.nudge(direction);holdTimer=setInterval(function(){if(game&&game.status==='playing')game.nudge(direction);else stopInput();},150);}}
|
||||
function bindDirection(id,direction){var b=$(id);b.addEventListener('pointerdown',function(e){if(b.disabled)return;e.preventDefault();pointerDirection=direction;if(typeof b.setPointerCapture==='function')b.setPointerCapture(e.pointerId);refreshInput();});function release(){if(pointerDirection===direction){pointerDirection=0;refreshInput();}}['pointerup','pointercancel','lostpointercapture'].forEach(function(type){b.addEventListener(type,release);});b.addEventListener('click',function(e){if(e.detail===0&&game&&game.status==='playing')game.nudge(direction);});}
|
||||
bindDirection('left',-1);bindDirection('right',1);$('up').addEventListener('click',function(){if(game)game.nudgeUp();});
|
||||
@@ -153,5 +158,5 @@ window.addEventListener('resize',layout);if(window.visualViewport&&typeof window
|
||||
document.addEventListener('visibilitychange',function(){if(document.hidden){stopInput();pause();cancelAnimationFrame(rafId);rafId=0;}else{last=0;if(game&&game.status==='playing')schedule();}});
|
||||
layout();updateUI();storePromise=initStore();
|
||||
// Read-only development diagnostics: interaction is exclusively through the tank controls.
|
||||
window.shanheDebug={state:function(){return {screen:screen,status:game?game.status:'entry',score:game?game.score:0,level:level.id,speed:speed,bounce:bounce,bounceName:Engine.BOUNCES[bounce].name,history:game?game.history.map(function(p){return {ms:p.ms,score:p.score};}):[],elapsed:game?game.clock:0,result:result,shakeY:game?game.shake.y:0,dropped:game?game.dropped:0,remaining:game?game.queue.length:level.regions.length,groups:game?game.groups.length:0,shakeCount:game?game.shakeCount:0,shakeOffset:game?game.shake.offset:0,scale:game?game.scale:0,initialScale:game?game.initialScale:0,zoom:game?game.zoom:1,width:w,height:h,collisionCount:game?game.collisionCount:0,impactCount:game?game.impacts.length:0,contactPulse:game?game.groups.filter(function(g){return g.hitUntil>game.clock;}).length:0,labels:lastLabelBoxes.map(function(b){return {name:b.name,x:b.x,y:b.y,w:b.w,h:b.h};}),regions:game?game.groups.map(function(g){return {ids:g.ids.slice(),x:g.body.position.x,y:g.body.position.y,angle:g.body.angle,vx:g.body.velocity.x,vy:g.body.velocity.y};}):[]};}};
|
||||
window.shanheDebug={state:function(){return {screen:screen,status:game?game.status:'entry',score:game?game.score:0,level:level.id,speed:speed,bounce:bounce,bounceName:Engine.BOUNCES[bounce].name,history:game?game.history.map(function(p){return {ms:p.ms,score:p.score};}):[],elapsed:game?game.clock:0,result:result,shakeY:game?game.shake.y:0,dropped:game?game.dropped:0,remaining:game?game.queue.length:level.regions.length,groups:game?game.groups.length:0,shakeCount:game?game.shakeCount:0,hopCount:game?game.hopCount:0,shakeOffset:game?game.shake.offset:0,scale:game?game.scale:0,initialScale:game?game.initialScale:0,zoom:game?game.zoom:1,width:w,height:h,collisionCount:game?game.collisionCount:0,impactCount:game?game.impacts.length:0,contactPulse:game?game.groups.filter(function(g){return g.hitUntil>game.clock;}).length:0,labels:lastLabelBoxes.map(function(b){return {name:b.name,x:b.x,y:b.y,w:b.w,h:b.h};}),regions:game?game.groups.map(function(g){return {ids:g.ids.slice(),x:g.body.position.x,y:g.body.position.y,angle:g.body.angle,vx:g.body.velocity.x,vy:g.body.velocity.y};}):[]};}};
|
||||
})();
|
||||
|
||||
+16
-8
@@ -6,7 +6,7 @@ function baseScale(level,w,h){var maxw=0,maxh=0;level.regions.forEach(function(r
|
||||
var SPEEDS=[{name:'佛系看山',interval:8500,gravity:.32,multiplier:1},{name:'说走就走',interval:6000,gravity:.48,multiplier:1.5},{name:'一路狂飙',interval:4000,gravity:.7,multiplier:2},{name:'山河起飞',interval:2500,gravity:1,multiplier:3}];
|
||||
var BOUNCES=[{name:'轻轻弹',restitution:.65,hop:2.15,rise:90,air:.022,push:1,maxX:7.2,friction:.22,scatter:0},{name:'蹦蹦跳',restitution:.82,hop:6,rise:250,air:.012,push:1.22,maxX:9.5,friction:.16,scatter:2.6},{name:'弹力全开',restitution:.96,hop:14,rise:540,air:.006,push:1.7,maxX:13,friction:.10,scatter:5}];
|
||||
function Game(level,width,height,speed,emit,rng,bounce){
|
||||
this.bounceIndex=BOUNCES[bounce]?bounce:0;this.bounce=BOUNCES[this.bounceIndex];this.history=[{ms:0,score:0}];this.endReason=null;this.lastUp=-1000;this.level=level;this.w=width;this.h=height;this.speed=SPEEDS[speed];this.emit=emit||function(){};this.rng=rng||Math.random;this.scale=baseScale(level,width,height);this.initialScale=this.scale;this.zoom=1;this.shake={offset:0,velocity:0,y:0,vy:0};this.shakeCount=0;this.engine=M.Engine.create({enableSleeping:true});this.engine.gravity.y=this.speed.gravity;this.groups=[];this.queue=C.shuffle(level.regions.map(function(_,i){return i;}),this.rng);this.score=0;this.chain=0;this.maxChain=0;this.merges=0;this.dropped=0;this.clock=0;this.lastMerge=-99999;this.nextAt=0;this.overflow=0;this.status='ready';this.fx=[];this.impacts=[];this.collisionCount=0;this.wallHitUntil=0;
|
||||
this.bounceIndex=BOUNCES[bounce]?bounce:0;this.bounce=BOUNCES[this.bounceIndex];this.history=[{ms:0,score:0}];this.endReason=null;this.lastUp=-1000;this.level=level;this.w=width;this.h=height;this.speed=SPEEDS[speed];this.emit=emit||function(){};this.rng=rng||Math.random;this.scale=baseScale(level,width,height);this.initialScale=this.scale;this.zoom=1;this.shake={offset:0,velocity:0,y:0,vy:0};this.shakeCount=0;this.hopCount=0;this.engine=M.Engine.create({enableSleeping:true});this.engine.gravity.y=this.speed.gravity;this.groups=[];this.queue=C.shuffle(level.regions.map(function(_,i){return i;}),this.rng);this.score=0;this.chain=0;this.maxChain=0;this.merges=0;this.dropped=0;this.clock=0;this.lastMerge=-99999;this.nextAt=0;this.overflow=0;this.status='ready';this.fx=[];this.impacts=[];this.collisionCount=0;this.wallHitUntil=0;
|
||||
var self=this;M.Events.on(this.engine,'collisionStart',function(e){self.onContacts(e.pairs,true);});M.Events.on(this.engine,'collisionActive',function(e){self.onContacts(e.pairs,false);});
|
||||
this.walls=[M.Bodies.rectangle(width/2,height+25,width+100,50,{isStatic:true}),M.Bodies.rectangle(-25,height/2,50,height*4,{isStatic:true}),M.Bodies.rectangle(width+25,height/2,50,height*4,{isStatic:true})];M.Composite.add(this.engine.world,this.walls);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Game.prototype.worldPoint=function(g,p){var pose=this.pose(g),r=C.rotate({x:p[0]
|
||||
Game.prototype.createGroup=function(ids,pose){var self=this,parts=[];
|
||||
ids.forEach(function(id){var region=self.level.regions[id];region.rings.forEach(function(ring,ri){var vs=ring.map(function(p){return {x:p[0]*self.scale,y:p[1]*self.scale};});if(vs.length<3||M.Vertices.area(vs)<.8)return;var coords=vs.map(function(v){return [v.x,v.y];});decomp.removeDuplicatePoints(coords,.01);decomp.removeCollinearPoints(coords,.01);if(coords.length<3)return;vs=coords.map(function(p){return {x:p[0],y:p[1]};});if(!decomp.isSimple(coords))vs=M.Vertices.hull(vs);var center=M.Vertices.centre(vs);var b=M.Bodies.fromVertices(center.x,center.y,[vs],{friction:self.bounce.friction,frictionStatic:.38,restitution:self.bounce.restitution,slop:.1},true,.5,1,.01);if(b){if(b.parts.length>1)parts=parts.concat(b.parts.slice(1));else parts.push(b);}});
|
||||
if(!parts.length||region.rings.every(function(r){return M.Vertices.area(r.map(function(p){return {x:p[0]*self.scale,y:p[1]*self.scale};}))<.8;}))parts.push(M.Bodies.circle(region.center[0]*self.scale,region.center[1]*self.scale,4));});
|
||||
var body=M.Body.create({parts:parts,friction:this.bounce.friction,frictionStatic:.38,restitution:this.bounce.restitution,frictionAir:this.bounce.air,slop:.1,sleepThreshold:75});M.Body.setInertia(body,body.inertia*2);var g={ids:ids.slice(),body:body,center:{x:body.position.x,y:body.position.y},born:this.clock,supportedUntil:-1,lastHop:-1000,hitUntil:0,hitStrength:0,hitNormal:{x:0,y:1}};
|
||||
var body=M.Body.create({parts:parts,friction:this.bounce.friction,frictionStatic:.38,restitution:this.bounce.restitution,frictionAir:this.bounce.air,slop:.1,sleepThreshold:75});M.Body.setInertia(body,body.inertia*2);var g={ids:ids.slice(),body:body,center:{x:body.position.x,y:body.position.y},born:this.clock,supportedUntil:-1,lastHop:-1000,pendingHopUntil:-1,pendingHopHeight:0,hitUntil:0,hitStrength:0,hitNormal:{x:0,y:1}};
|
||||
M.Body.rotate(body,pose.angle,{x:0,y:0});M.Body.translate(body,{x:pose.tx,y:pose.ty});body.plugin.puzzleGroup=g;M.Composite.add(this.engine.world,body);this.groups.push(g);return g;};
|
||||
Game.prototype.start=function(){if(this.status!=='ready')return;this.status='playing';this.spawn();};
|
||||
Game.prototype.spawn=function(){if(!this.queue.length)return;var id=this.queue.shift(),r=this.level.regions[id],angle=0;var x=this.w*(.25+this.rng()*.5),y=20;var g=this.createGroup([id],{angle:angle,tx:x-r.center[0]*this.scale,ty:y-r.center[1]*this.scale});var b=g.body.bounds;if(b.min.x<10)M.Body.translate(g.body,{x:10-b.min.x,y:0});if(b.max.x>this.w-10)M.Body.translate(g.body,{x:this.w-10-b.max.x,y:0});if(b.min.y<8)M.Body.translate(g.body,{x:0,y:8-b.min.y});this.dropped++;this.nextAt=this.clock+this.speed.interval;this.emit('spawn',g);};
|
||||
@@ -23,7 +23,7 @@ Game.prototype.merge=function(a,b,edge){var pa=this.pose(a),pb=this.pose(b);var
|
||||
M.Composite.remove(this.engine.world,a.body);M.Composite.remove(this.engine.world,b.body);this.groups=this.groups.filter(function(g){return g!==a&&g!==b;});var g=this.createGroup(ids,pose);this.chain=this.clock-this.lastMerge<=8500?this.chain+1:1;this.maxChain=Math.max(this.maxChain,this.chain);this.lastMerge=this.clock;this.merges++;var points=C.mergePoints(ids.length,this.chain,this.speed.multiplier,edge[2]);this.score+=points;this.recordScore();this.fx.push({x:g.body.position.x,y:g.body.position.y,text:(edge[2]===1?'跨海连接 ':edge[2]===2?'隔区连接 ':'合并成功 ')+ '+'+points,until:this.clock+1600});this.fitGroup(g);this.emit('merge',{group:g,points:points,sea:edge[2]});
|
||||
if(ids.length===this.level.regions.length){this.score+=Math.round(1000*this.speed.multiplier);this.complete('won');}return g;};
|
||||
Game.prototype.tryMerges=function(){for(var i=0;i<this.groups.length;i++)for(var j=0;j<i;j++){var a=this.groups[i],b=this.groups[j];var plan=C.canSnap(this.pose(a),this.pose(b),this.level.edges,22);if(plan){this.merge(a,b,plan.edge);return true;}}return false;};
|
||||
Game.prototype.tick=function(dt){if(this.status!=='playing')return;dt=Math.min(dt,100);this.clock+=dt;this.accumulator=(this.accumulator||0)+dt;while(this.accumulator>=1000/60){this.stepShake(1000/60);M.Engine.update(this.engine,1000/60);this.limitMotion();this.accumulator-=1000/60;}for(var k=0;k<4;k++)if(!this.tryMerges())break;
|
||||
Game.prototype.tick=function(dt){if(this.status!=='playing')return;dt=Math.min(dt,100);this.clock+=dt;this.accumulator=(this.accumulator||0)+dt;while(this.accumulator>=1000/60){this.stepShake(1000/60);M.Engine.update(this.engine,1000/60);this.limitMotion();this.tryQueuedHops();this.accumulator-=1000/60;}for(var k=0;k<4;k++)if(!this.tryMerges())break;
|
||||
if(this.status!=='playing')return;
|
||||
if(this.queue.length&&this.clock>=this.nextAt)this.spawn();
|
||||
var crowded=this.groups.some(function(g){return this.clock-g.born>5000&&g.body.bounds.min.y<DANGER_Y&&g.body.speed<1.4;},this);this.overflow=crowded?this.overflow+dt:Math.max(0,this.overflow-dt*2);if(this.overflow>2400){this.complete('lost');}
|
||||
@@ -36,13 +36,21 @@ Game.prototype.nudge=function(direction){
|
||||
if(this.status!=='playing')return false;
|
||||
var previous=this.shake.velocity;this.shake.velocity=Math.max(-.6,Math.min(.6,this.shake.velocity+(direction<0?-.38:.38)));var impulse=(this.shake.velocity-previous)*1000/60;
|
||||
this.groups.forEach(function(g,index){
|
||||
var supported=g.body.isSleeping||g.supportedUntil>=this.clock;M.Sleeping.set(g.body,false);var variation=(Math.sin((g.ids[0]+1)*12.9898+(this.shakeCount+1)*7.23)+1)/2,weight=1/(1+(g.ids.length-1)*.12);
|
||||
if(g.body.isSleeping)g.supportedUntil=this.clock+80;M.Sleeping.set(g.body,false);var variation=(Math.sin((g.ids[0]+1)*12.9898+(this.shakeCount+1)*7.23)+1)/2,weight=1/(1+(g.ids.length-1)*.12);
|
||||
var vx=g.body.velocity.x-impulse*(.65+variation*.2)*weight*this.bounce.push,vy=g.body.velocity.y;
|
||||
// A short toss only on support/landing. Holding cannot repeatedly boost airborn pieces.
|
||||
if(supported&&this.clock-g.lastHop>500&&g.body.bounds.min.y>8){var height=Math.min((14+variation*14)*this.bounce.hop*weight,this.riseLimit()*.92),acceleration=this.speed.gravity*1000/60*1000/60*.001;vy=Math.min(vy,-Math.sqrt(2*acceleration*height));g.lastHop=this.clock;}
|
||||
// Hold the request through the next landing instead of sampling a 55ms
|
||||
// contact window only once per 150ms button repeat.
|
||||
g.pendingHopUntil=this.clock+300;g.pendingHopHeight=Math.min((14+variation*14)*this.bounce.hop*weight,this.riseLimit()*.92);
|
||||
M.Body.setVelocity(g.body,{x:Math.max(-this.bounce.maxX,Math.min(this.bounce.maxX,vx)),y:vy});
|
||||
var tilt=(variation-.5)*.014-direction*.005;M.Body.setAngularVelocity(g.body,Math.max(-.02,Math.min(.02,g.body.angularVelocity+tilt*weight)));
|
||||
},this);this.shakeCount++;return true;
|
||||
},this);this.tryQueuedHops();this.shakeCount++;return true;
|
||||
};
|
||||
Game.prototype.tryQueuedHops=function(){
|
||||
this.groups.forEach(function(g){if(g.pendingHopUntil<this.clock||this.clock-g.lastHop<500)return;
|
||||
var bounds=M.Bounds.create(g.body.vertices),supported=g.body.isSleeping||g.supportedUntil>=this.clock||(bounds.max.y>=this.h-3&&Math.abs(g.body.velocity.y)<.3);
|
||||
if(!supported)return;var height=Math.min(g.pendingHopHeight,Math.max(0,(bounds.min.y-4)*.85));if(height<2)return;
|
||||
M.Sleeping.set(g.body,false);var acceleration=this.speed.gravity*.001*Math.pow(1000/60,2);M.Body.setVelocity(g.body,{x:g.body.velocity.x,y:Math.min(g.body.velocity.y,-Math.sqrt(2*acceleration*height))});g.lastHop=this.clock;g.pendingHopUntil=-1;this.hopCount++;
|
||||
},this);
|
||||
};
|
||||
// Score events retain the exact active-play time; pauses never advance the clock.
|
||||
Game.prototype.recordScore=function(){var last=this.history[this.history.length-1];if(last.ms===this.clock){last.score=this.score;return;}this.history.push({ms:this.clock,score:this.score});};
|
||||
@@ -51,7 +59,7 @@ Game.prototype.finish=function(){if(this.status!=='playing'&&this.status!=='paus
|
||||
// The upward kick moves the whole tank and tosses every group, with a cooldown
|
||||
// and a fixed rise cap so repeated taps cannot lift pieces out of the container.
|
||||
Game.prototype.riseLimit=function(){return Math.min(this.bounce.rise,Math.max(32,this.h*.62));};
|
||||
Game.prototype.nudgeUp=function(){if(this.status!=='playing'||this.clock-this.lastUp<420)return false;this.lastUp=this.clock;this.shake.vy=-.48;this.groups.forEach(function(g){M.Sleeping.set(g.body,false);var weight=1/(1+(g.ids.length-1)*.12),supported=g.supportedUntil>=this.clock||g.body.speed<.2,acc=this.speed.gravity*.001*Math.pow(1000/60,2),height=Math.min((supported?24:10)*this.bounce.hop*weight,this.riseLimit()*.92),scatter=Math.sin((g.ids[0]+1)*7.7+this.shakeCount*2.3)*this.bounce.scatter*weight;M.Body.setVelocity(g.body,{x:Math.max(-this.bounce.maxX,Math.min(this.bounce.maxX,g.body.velocity.x+scatter)),y:Math.min(g.body.velocity.y,-Math.sqrt(2*acc*height))});g.lastHop=this.clock;},this);this.shakeCount++;return true;};
|
||||
Game.prototype.nudgeUp=function(){if(this.status!=='playing'||this.clock-this.lastUp<420)return false;this.lastUp=this.clock;this.shake.vy=-.48;this.groups.forEach(function(g){M.Sleeping.set(g.body,false);var weight=1/(1+(g.ids.length-1)*.12),supported=g.supportedUntil>=this.clock||g.body.speed<.2,acc=this.speed.gravity*.001*Math.pow(1000/60,2),height=Math.min((supported?24:10)*this.bounce.hop*weight,this.riseLimit()*.92),scatter=Math.sin((g.ids[0]+1)*7.7+this.shakeCount*2.3)*this.bounce.scatter*weight;M.Body.setVelocity(g.body,{x:Math.max(-this.bounce.maxX,Math.min(this.bounce.maxX,g.body.velocity.x+scatter)),y:Math.min(g.body.velocity.y,-Math.sqrt(2*acc*height))});g.lastHop=this.clock;g.pendingHopUntil=-1;},this);this.shakeCount++;return true;};
|
||||
Game.prototype.onContacts=function(pairs,started){
|
||||
pairs.forEach(function(pair){
|
||||
var a=pair.bodyA.parent||pair.bodyA,b=pair.bodyB.parent||pair.bodyB,ga=a.plugin&&a.plugin.puzzleGroup,gb=b.plugin&&b.plugin.puzzleGroup;
|
||||
|
||||
+6
-5
@@ -10,7 +10,7 @@
|
||||
<body>
|
||||
<div class="app">
|
||||
<section id="entry-screen" class="entry-screen" aria-label="选择旅程">
|
||||
<header class="header"><div class="brand"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 32 32"><path d="M3 24 12 9l6 10 4-6 7 11Z" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="23" cy="8" r="2" fill="currentColor"/></svg></span><span>山河拼图<small>SHAN HE · MAP PUZZLE</small></span></div><button class="text-button" id="rules-button">玩法说明 ↗</button></header>
|
||||
<header class="header"><div class="brand"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 32 32"><path d="M3 24 12 9l6 10 4-6 7 11Z" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="23" cy="8" r="2" fill="currentColor"/></svg></span><span>山河拼图<small>SHAN HE · MAP PUZZLE</small></span></div></header>
|
||||
<main class="entry-main">
|
||||
<div class="entry-story"><div class="eyebrow"><span></span> 一片一片,认识中国</div><h1>把山河,<br><em>拼在一起。</em></h1><p class="lead">晃动这个框,<br>让相邻的山河,在碰撞中相遇。</p><div class="entry-map"><canvas id="reference" width="960" height="700" aria-label="所选旅程的完整地图"></canvas><span id="map-caption">34 个省级行政区 · 等你拼完整</span></div><div class="entry-instruction"><span>←</span><p>左右轻摇,向上轻颠<br><strong>晃动整个容器,碎片自动吸合</strong></p><span>→</span></div></div>
|
||||
<section class="setup"><div class="section-title"><span class="eyebrow">CHOOSE YOUR JOURNEY</span><span>01</span></div><h2>选择你的旅程</h2><p class="setup-intro">从一片故乡,出发去看山河。</p>
|
||||
@@ -21,20 +21,21 @@
|
||||
<div class="journey-summary"><div><span>这次要拼</span><strong id="journey-count">34 块山河</strong></div><div><span>本地最佳</span><strong id="entry-best">—</strong></div></div>
|
||||
<button id="start" class="primary start-button">开启这局旅程 <span>→</span></button><p class="start-note">选好后直接开局 · 晃动不限次数</p>
|
||||
</section></main>
|
||||
<footer><span>让地理变成一次相遇</span><button id="data-button" class="text-button">离线可玩 · 数据说明</button></footer>
|
||||
<footer><span>让地理变成一次相遇</span><button class="text-button" id="rules-button">玩法说明 ↗</button><button id="data-button" class="text-button">离线可玩 · 数据说明</button></footer>
|
||||
</section>
|
||||
<section id="game-screen" class="game-screen" hidden aria-label="山河拼图游戏">
|
||||
<div class="game-topbar"><button id="back" class="nav-button" aria-label="返回旅程选择">←</button><div><strong id="game-title">中国 · 省级拼图</strong><span id="game-speed">佛系看山 · 得分 ×1</span></div><button id="pause" class="nav-button" aria-label="暂停游戏">Ⅱ</button></div>
|
||||
<div class="game-topbar"><div><strong id="game-title">中国 · 省级拼图</strong><span id="game-speed">佛系看山 · 得分 ×1</span></div></div>
|
||||
<div class="game-hud"><div class="score"><span class="stat-label">本局得分</span><strong id="score">0</strong></div><div><span class="stat-label">最大拼合</span><strong><span id="merged">0</span><small> / <span id="total">34</span></small></strong></div><div class="next-inline"><canvas id="next" width="150" height="110" aria-label="下一个地区预览"></canvas><div><span class="stat-label">下一块</span><strong id="next-name">—</strong></div></div></div>
|
||||
<div class="tank-stage"><div class="board-wrap" id="tank"><canvas id="board" width="840" height="1120" aria-label="晃动容器中的地图碎片。使用左晃、上晃、右晃按钮或键盘方向键操作整个框。"></canvas><div class="board-tag"><span class="live-dot"></span><span id="board-name">山河容器</span></div><div class="danger-label">堆叠警戒线</div><div id="board-status" class="board-status" aria-live="polite"></div></div></div>
|
||||
<div class="game-progress"><span id="progress-label">已落下 0 / 34 块</span><span>左右轻摇,向上轻颠</span></div>
|
||||
<div class="game-tools"><button id="back" class="utility-button" aria-label="返回旅程选择">旅程设置</button><span aria-hidden="true">·</span><button id="pause" class="utility-button" aria-label="暂停游戏">Ⅱ 暂停 / 结束</button></div>
|
||||
<div class="controls" role="group" aria-label="容器晃动操作"><button id="left" aria-label="向左晃动整个容器"><span class="control-arrow" aria-hidden="true">←</span><span class="control-copy"><strong>左晃</strong><small>按住持续晃动</small></span></button><button id="up" aria-label="向上晃动整个容器"><span class="control-copy"><strong>↑ 上晃</strong><small>点击轻颠</small></span></button><button id="right" aria-label="向右晃动整个容器"><span class="control-copy"><strong>右晃</strong><small>按住持续晃动</small></span><span class="control-arrow" aria-hidden="true">→</span></button></div>
|
||||
</section>
|
||||
<section id="result-screen" class="result-screen" hidden aria-label="旅程成绩">
|
||||
<div class="result-topbar"><button id="result-back" class="text-button">← 选择旅程</button><span class="eyebrow">YOUR JOURNEY</span></div>
|
||||
<div class="result-topbar"><span class="eyebrow">YOUR JOURNEY</span></div>
|
||||
<h2 id="result-title">这一程,山河相遇。</h2><p class="result-intro">结局已留在图中,带着这片山河去分享。</p>
|
||||
<div class="card-preview-wrap"><canvas id="result-card" width="750" height="1400" aria-label="成绩卡:得分、结局画面、速度与弹性、得分曲线"></canvas></div>
|
||||
<div class="result-actions"><button id="share-note" class="primary">发小红书 ↗</button><button id="save-card" class="secondary">保存成绩图</button><button id="replay" class="secondary">再来一局</button></div><p class="result-footnote">分享后可编辑笔记 · 确认后发布</p>
|
||||
<div class="result-actions"><button id="share-note" class="primary">发小红书 ↗</button><button id="save-card" class="secondary">保存成绩图</button><button id="replay" class="secondary">再来一局</button><button id="result-back" class="secondary">选择旅程</button></div><p class="result-footnote">分享后可编辑笔记 · 确认后发布</p>
|
||||
</section>
|
||||
</div>
|
||||
<div id="toast" role="status" class="toast" hidden></div>
|
||||
|
||||
@@ -42,3 +42,41 @@ button{transition:background-color .16s,border-color .16s,color .16s,box-shadow
|
||||
@keyframes screen-in{from{opacity:.4;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
|
||||
.board-wrap.impact{border-color:#8ca3a8;box-shadow:0 5px 0 #dce1d5,0 0 0 1px rgba(185,166,124,.10)}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.controls button.pressed{transform:none}}
|
||||
/* Reserve a separate row for the host's top-left/back and top-right actions. */
|
||||
body{padding-top:56px;padding-top:calc(56px + var(--safe-area-inset-top,env(safe-area-inset-top,0px)));-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
||||
.game-topbar{justify-content:center}.game-screen{min-height:200px}.game-tools{display:flex;align-items:center;justify-content:center;flex:0 0 34px;color:#b6beaa}.utility-button{min-height:32px;min-width:104px;border:0;background:transparent;color:#728566;font-size:11px;padding:3px 10px}.game-tools>span{margin:0 7px}.result-topbar{justify-content:center}
|
||||
footer>.text-button+button{margin-left:12px}
|
||||
dialog,dialog.modal-fallback{position:fixed;top:calc(var(--content-top,56px) + 12px);bottom:auto;left:0;right:0;margin:0 auto;transform:none;max-height:calc(var(--game-height,100vh) - 24px)}
|
||||
/* On phones the entrance is a settings page; decorative art cannot compete for height. */
|
||||
@media(max-width:720px){
|
||||
.entry-story{display:none!important}.entry-main{padding:10px 0;justify-content:center;align-items:center}.setup{margin:0;max-width:410px;flex:0 0 auto}.header{justify-content:center;flex:0 0 54px}.header .brand{font-size:19px}footer{flex:0 0 30px;flex-wrap:nowrap}footer>span{margin-right:auto}footer .text-button{font-size:8px!important;white-space:nowrap}
|
||||
.setup h2{font-size:22px}.result-actions button{font-size:10px}.game-tools{flex-basis:32px}
|
||||
body.compact-entry .header{flex-basis:44px}body.compact-entry .entry-main{padding:6px 0}body.compact-entry .setup{margin:0;padding:12px}body.compact-entry .mode-tabs{margin-bottom:8px}body.compact-entry .mode-tabs button{min-height:40px}body.compact-entry #city-select-panel{margin-bottom:8px}body.compact-entry .province-picker{min-height:44px;padding:5px 9px}body.compact-entry #city-note{display:none}body.compact-entry .speed-heading{margin-bottom:6px;line-height:1.2}body.compact-entry .speed-options button{min-height:40px}body.compact-entry .bounce-heading{margin-top:8px;margin-bottom:6px;line-height:1.2}body.compact-entry .bounce-options button{min-height:36px}body.compact-entry .start-button{min-height:40px;margin-top:12px}body.compact-entry footer{flex-basis:26px}
|
||||
body.tiny-entry .setup h2,body.tiny-entry .journey-summary{display:none}body.tiny-entry .header{flex-basis:38px}body.tiny-entry .entry-main{padding:4px 0}body.tiny-entry .setup{padding:10px 12px}body.tiny-entry .mode-tabs button{min-height:38px}body.tiny-entry .destination-mark{width:25px;height:25px}body.tiny-entry footer{flex-basis:24px}
|
||||
}
|
||||
@media(max-height:520px) and (min-width:600px){
|
||||
.entry-story{display:none!important}.entry-main{display:flex!important;justify-content:center;padding:3px 0!important}.entry-screen .header{flex-basis:32px!important;justify-content:center}.brand-mark{width:26px;height:26px;padding:4px}.brand small{display:none}.setup{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-column-gap:14px;grid-row-gap:6px;max-width:640px;padding:10px 12px!important;width:100%}.mode-tabs{grid-column:1;grid-row:1;margin:0!important}.mode-tabs button{min-height:38px!important}#city-select-panel{grid-column:1;grid-row:2;margin:0!important}.province-picker{min-height:40px!important}.speed-heading{grid-column:2;grid-row:1;align-self:end;margin:0 0 5px!important}.speed-options{grid-column:2;grid-row:2}.speed-options button{min-height:40px!important}.bounce-heading{grid-column:1;grid-row:3;margin:0!important}.bounce-options{grid-column:1;grid-row:4}.bounce-options button{min-height:35px!important}.start-button{grid-column:2;grid-row:3 / 5;align-self:end;margin:0!important;min-height:40px!important}.start-note,#speed-note,.setup h2,.journey-summary,#city-note,.section-title,.setup-intro{display:none!important}.entry-screen footer{flex-basis:24px!important}
|
||||
.game-topbar{flex-basis:23px}.game-hud{flex-basis:34px}.game-hud .score strong{font-size:21px}.game-hud strong{font-size:17px}.next-inline canvas{height:28px}.game-screen{padding:3px 0}.controls{flex-basis:42px}.controls button{min-height:40px}.controls small{display:none}.game-tools{flex-basis:26px}.utility-button{min-height:25px}.game-progress{flex-basis:18px;padding-bottom:2px}.tank-stage{padding-top:0;padding-bottom:4px}
|
||||
}
|
||||
@media(max-width:720px){.province-picker-modal{padding:16px}.province-grid{grid-template-columns:repeat(5,minmax(0,1fr));grid-gap:5px}.province-grid button{min-height:30px;padding:4px 0;font-size:10px}.province-picker-modal h2{font-size:21px;margin:9px 0}.picker-note{font-size:8px;margin-top:8px}}
|
||||
@media(max-height:520px) and (min-width:600px){.province-picker-modal{width:620px;padding:15px}.province-grid{grid-template-columns:repeat(9,minmax(0,1fr));grid-gap:5px}.province-grid button{min-height:30px;font-size:10px}.province-picker-modal h2{margin:5px 0;font-size:20px}.picker-note{font-size:8px;line-height:1.5;margin-top:7px}}
|
||||
|
||||
/* Keep the map visible; give it only the measured space above the settings. */
|
||||
@media(max-width:720px){
|
||||
body .entry-screen .entry-story{display:flex!important;flex:0 0 var(--entry-preview-height,120px)!important;height:var(--entry-preview-height,120px);min-height:0!important;max-height:none!important;justify-content:center;margin:0 0 10px;overflow:hidden}
|
||||
.entry-story>.eyebrow,.entry-story>h1,.entry-story>.lead,.entry-story>.entry-instruction{display:none}
|
||||
body.map-roomy .entry-story>h1{display:block;font-size:25px;line-height:1.25;margin:0 0 3px}body.map-roomy .entry-story>.lead{display:block;font-size:9px;line-height:1.5;margin:0 0 3px}
|
||||
body .entry-screen .entry-map{display:block!important;flex:1;height:auto;width:100%;min-height:0;max-height:none;max-width:340px;margin:0 auto}.entry-map>span{display:block;font-size:7px}
|
||||
}
|
||||
@media(max-height:520px) and (min-width:600px){
|
||||
.entry-main{flex-direction:row!important}.entry-main>.setup{flex:1;max-width:520px;min-width:0}
|
||||
body .entry-screen .entry-story{display:flex!important;flex:0 0 130px!important;height:var(--entry-preview-height,160px);min-height:0!important;max-height:100%!important;margin:0 12px 0 0;overflow:hidden;align-items:center;justify-content:center}
|
||||
.entry-story>.eyebrow,.entry-story>h1,.entry-story>.lead,.entry-story>.entry-instruction{display:none!important}
|
||||
body .entry-screen .entry-map{display:block!important;width:100%;height:100%;min-height:0;max-height:100%;margin:0}.entry-map>span{display:block;font-size:7px;line-height:1.4}
|
||||
}
|
||||
@media(max-width:720px){
|
||||
body.tiny-entry .header{flex-basis:32px}body.tiny-entry footer{flex-basis:22px}body.tiny-entry .setup{padding:8px 12px}
|
||||
body.tiny-entry .mode-tabs{margin-bottom:6px}body.tiny-entry .mode-tabs button{min-height:34px;padding:4px 0}body.tiny-entry #city-select-panel{margin-bottom:6px}body.tiny-entry .province-picker{min-height:36px;padding:3px 9px}body.tiny-entry .speed-heading{margin-bottom:4px}body.tiny-entry .speed-options button{min-height:34px;padding:3px 0}body.tiny-entry .bounce-heading{margin-top:6px;margin-bottom:4px}body.tiny-entry .bounce-options button{min-height:32px;padding:2px}body.tiny-entry .start-button{min-height:36px;margin-top:8px;padding:6px 12px}
|
||||
}
|
||||
@media(max-width:720px){.entry-screen footer{padding:0}.entry-screen footer .text-button{min-height:20px;padding:2px 0}body.tiny-entry .header .brand small{display:none}body.tiny-entry .header .brand-mark{width:28px;height:28px;padding:4px}body.tiny-entry .header .brand{font-size:18px}}
|
||||
@media(max-height:520px) and (min-width:600px){.entry-screen footer{padding:0}.entry-screen footer .text-button{min-height:20px;padding:2px 0}}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {chromium} from 'playwright';import assert from 'node:assert/strict';import fs from 'node:fs';
|
||||
const browser=await chromium.launch({executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',headless:true});const errors=[],results=[];
|
||||
for(const sample of [{width:360,height:800,top:24,bottom:48},{width:320,height:568,top:44,bottom:34},{width:390,height:844,top:44,bottom:34},{width:740,height:390,top:24,bottom:24}]){
|
||||
const context=await browser.newContext({viewport:{width:sample.width,height:sample.height},isMobile:true,hasTouch:true});const p=await context.newPage();p.on('pageerror',e=>errors.push(e.message));await p.goto('http://localhost:4173');await p.addStyleTag({content:':root{--safe-area-inset-top:'+sample.top+'px;--safe-area-inset-bottom:'+sample.bottom+'px}'});await p.evaluate(()=>dispatchEvent(new Event('resize')));
|
||||
await p.evaluate(top=>{const div=document.createElement('div');div.id='host-overlay';div.style.cssText='position:fixed;top:'+top+'px;left:0;right:0;height:56px;z-index:10000;pointer-events:none';div.innerHTML='<span style="position:absolute;left:12px;top:4px;width:42px;height:42px;border-radius:50%;background:#aab0a4;color:white;font:28px/42px sans-serif;text-align:center;pointer-events:auto">‹</span><span style="position:absolute;right:12px;top:4px;width:86px;height:42px;border-radius:24px;background:#aab0a4;color:white;font:22px/42px sans-serif;text-align:center;pointer-events:auto">♙ ↗</span>';document.body.appendChild(div);},sample.top);
|
||||
async function safe(){const bad=await p.locator('button:visible').evaluateAll((buttons,limit)=>buttons.filter(b=>b.getBoundingClientRect().top<limit-1).map(b=>({id:b.id,y:b.getBoundingClientRect().top})),sample.top+56);assert.deepEqual(bad,[],JSON.stringify(sample)+' button blocked by native toolbar');assert.ok(await p.evaluate(()=>document.documentElement.scrollHeight<=innerHeight+1),JSON.stringify(sample)+' page overflow');}
|
||||
for(const mode of ['province','city']){await p.locator('[data-mode="'+mode+'"]').tap();await p.waitForTimeout(400);await safe();const fit=await p.evaluate(()=>{const rect=s=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom};};return {header:rect('.header'),setup:rect('.setup'),map:rect('.entry-map'),footer:rect('footer')};});assert.ok(fit.header.bottom<=fit.setup.top+1,JSON.stringify({sample,fit}));assert.ok(fit.setup.bottom<=fit.footer.top+1,JSON.stringify({sample,fit}));const map=await p.locator('#reference').boundingBox();assert.ok(map&&map.height>=90&&map.width>=100,'两版地图预览必须可见');if(sample.width<600){assert.ok(fit.header.bottom<=fit.map.top+1,JSON.stringify({sample,fit}));assert.ok(fit.map.bottom<=fit.setup.top+1,JSON.stringify({sample,fit}));}assert.ok(await p.locator('#reference').evaluate(c=>{const data=c.getContext('2d').getImageData(0,0,c.width,c.height).data;let count=0;const colors=new Set([0xc3d4b4,0xe3c78f,0xd2b5a5,0xb5cbd0,0xd2c69d,0xb1c5a0,0xd9bca6,0xbdc7ac,0xd7c5c5,0xbfd0ba]);for(let i=0;i<data.length;i+=4)if(data[i+3]>200&&colors.has((data[i]<<16)|(data[i+1]<<8)|data[i+2]))count++;return count>300;}),'地图实际绘制像素而非空画布');}
|
||||
await p.screenshot({path:'artifacts/container-entry-'+sample.width+'.png'});await p.locator('#province-picker').tap();await safe();await p.locator('[data-province="330000"]').tap();await p.locator('#start').tap();await p.waitForTimeout(300);await safe();await p.screenshot({path:'artifacts/container-game-'+sample.width+'.png'});await p.locator('#pause').tap();await safe();await p.getByRole('button',{name:'结束旅程',exact:true}).tap();await safe();await p.screenshot({path:'artifacts/container-result-'+sample.width+'.png'});await p.locator('#result-back').tap();await safe();results.push({sample,passed:true});await context.close();
|
||||
}
|
||||
assert.deepEqual(errors,[]);fs.writeFileSync('artifacts/container-layout-report.json',JSON.stringify({scope:'Modern browser with simulated host toolbar and safe areas; real container verification pending',results,errors},null,2));console.log('四种手机尺寸:模拟平台角落按钮遮挡下,入口/地级选择/游戏/暂停/成绩均可操作、无页面溢出、面板与页脚不重叠');await browser.close();
|
||||
@@ -31,3 +31,6 @@ test('暂停也可主动结束,成绩曲线从零开始、通关含奖励、
|
||||
test('不同弹性的纪录分开存储,仍兼容旧版最低弹性纪录',()=>{assert.deepEqual(C.safeRecord({'100000:0':100,'100000:0:1':200,'100000:0:2':300,'100000:0:3':900}),{'100000:0':100,'100000:0:1':200,'100000:0:2':300});});
|
||||
|
||||
test('四档下落速度下,实测上晃分别为轻弹、正常和强弹,强弹可颠起至少200px',()=>{const fs=require('node:fs'),results=[];const l={id:'100000',name:'弹跳测试',width:1000,height:1000,regions:[square(0,0,0)],edges:[]};for(let speed=0;speed<4;speed++){let heights=[];for(let bounce=0;bounce<3;bounce++){const g=new Game(l,320,590,speed,()=>{},()=>.5,bounce);g.start();g.queue=[];const body=g.groups[0].body;M.Body.translate(body,{x:0,y:g.h-1-body.bounds.max.y});M.Body.setVelocity(body,{x:0,y:0});M.Sleeping.set(body,true);const start=body.position.y;assert.ok(g.nudgeUp());let highest=start;for(let i=0;i<180;i++){g.tick(16.67);highest=Math.min(highest,body.position.y);}const height=start-highest;heights.push(height);results.push({speed,bounce,height:Number(height.toFixed(1))});g.destroy();}assert.ok(heights[0]>=25&&heights[0]<=55,heights);assert.ok(heights[1]>=85&&heights[1]<=150,heights);assert.ok(heights[2]>=200&&heights[2]<=340,heights);}fs.writeFileSync('artifacts/bounce-probe.json',JSON.stringify({method:'320×590 logical pixels, one supported square, one upward shake, actual physics peak; not real-device performance',results},null,2));});
|
||||
|
||||
test('左右晃动会缓存请求,碎片在两次150ms按键之间落地也能颠起',()=>{const l={id:'100000',name:'落地时序',width:1000,height:1000,regions:[square(0,0,0)],edges:[]};for(const delay of [0,25,60,100]){const g=new Game(l,320,590,0,()=>{},()=>.5,1);g.start();g.queue=[];g.clock=delay;const p=g.groups[0],b=p.body;M.Body.translate(b,{x:0,y:g.h-6-b.bounds.max.y});M.Body.setVelocity(b,{x:0,y:1});assert.ok(g.nudge(1));assert.equal(g.hopCount,0,'尚未接触时不在空中加速');for(let i=0;i<9;i++)g.tick(1000/60);assert.equal(g.hopCount,1,'下一次按键前已在落地时触发');assert.ok(b.velocity.y<0);g.destroy();}});
|
||||
test('过期请求、空中长按和暂停不会凭空产生连续上抛',()=>{const l={id:'100000',name:'时序限制',width:1000,height:1000,regions:[square(0,0,0)],edges:[]},g=new Game(l,320,590,0,()=>{},()=>.5,1);g.start();g.queue=[];g.nudge(1);for(let i=0;i<180;i++)g.tick(1000/60);assert.equal(g.hopCount,0,'初始空中请求已过期');g.status='paused';assert.equal(g.nudge(1),false);const count=g.hopCount;g.tick(100);assert.equal(g.hopCount,count);g.destroy();});
|
||||
|
||||
Reference in New Issue
Block a user