更新微信小游戏版:动画教学、分段高亮指路及发布包
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { solve, violations, assertStructure } from './src/upstream/rules.mjs';
|
||||
const root = path.dirname(fileURLToPath(import.meta.url)), pack = JSON.parse(fs.readFileSync(path.join(root, 'src/upstream/levels.json'))), manifest = JSON.parse(fs.readFileSync(path.join(root, 'source-manifest.json')));
|
||||
for (const file of manifest.files) {
|
||||
const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
assert.equal(hash(path.join(root, 'src/upstream', file.name)), file.sha256);
|
||||
assert.equal(hash(path.join(manifest.source, file.name)), file.sha256);
|
||||
}
|
||||
const ordered = pack.playOrder.map(id => pack.levels.find(l => l.id === id));
|
||||
assert.equal(ordered.length, 200);
|
||||
assert.equal(new Set(pack.playOrder).size, 200);
|
||||
function boot(storage = new Map(), width = 375, height = 812) {
|
||||
const handlers = {}, frames = new Map(), played=[];
|
||||
let frameId = 0,now=100000;
|
||||
class Clock extends Date {static now(){return now;}}
|
||||
const text = [],images=[];
|
||||
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(parseFloat(ctx.font)||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
|
||||
const wx = {setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
|
||||
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
|
||||
wx['on' + name] = cb => handlers[name] = cb;
|
||||
const global = {};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
|
||||
const tick = () => { const [id, fn] = frames.entries().next().value; frames.delete(id);text.length=0;images.length=0; fn(); };
|
||||
tick();
|
||||
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] });
|
||||
const tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
|
||||
const api = global.numberSwipeR2;
|
||||
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + label); tap(b.x + b.w / 2, b.y + b.h / 2); };
|
||||
const cell = id => { const [x, y] = api.snapshot().centers[id]; tap(x, y + 174); };
|
||||
const select = i => { if (api.snapshot().screen !== 'select')
|
||||
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
|
||||
button('下一页');
|
||||
assert.ok(++steps < 25);
|
||||
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏'))
|
||||
button('返回游戏'); assert.equal(api.snapshot().level, ordered[i].id); };
|
||||
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images };
|
||||
}
|
||||
const ui = boot(), rows = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const l = ordered[i];
|
||||
assertStructure(l);
|
||||
const paths = solve(l, { limit: 2, nodeLimit: 1500000 });
|
||||
assert.equal(paths.length, 1, '非唯一 ' + l.id);
|
||||
const solution = paths[0];
|
||||
assert.equal(violations(l, solution).length, 0);
|
||||
ui.select(i);
|
||||
ui.button('规则');
|
||||
assert.ok(!/共边|落格|访问步号/.test(ui.api.snapshot().rules),l.id);
|
||||
if(l.tutorial){const before=ui.api.snapshot();for(let j=0;j<25;j++)ui.advance(1300);const after=ui.api.snapshot();assert.deepEqual(after.path,before.path,'演示不能修改玩家路线');assert.deepEqual(after.centers,before.centers,'演示不能改变游戏布局');assert.deepEqual(after.violations,before.violations);assert.equal(after.cleared,before.cleared);assert.ok(after.lesson?.message);ui.button('重播');}
|
||||
let ruleTurns=0;
|
||||
while(ui.api.buttons().some(b=>b.label==='下一段'&&b.enabled)){ui.button('下一段');assert.ok(++ruleTurns<15);}
|
||||
assert.ok(ui.api.buttons().every(b=>b.y>=0&&b.y+b.h<=812),'规则按钮不能越界');
|
||||
ui.button('返回游戏');
|
||||
ui.cell(solution[0]);
|
||||
if (solution.length > 1)
|
||||
ui.cell(solution[1]);
|
||||
const originalFills=JSON.stringify(ui.api.snapshot().cellViews);
|
||||
ui.button('撤销');
|
||||
assert.equal(ui.api.snapshot().path.length, 1);
|
||||
ui.button('重开');
|
||||
assert.equal(ui.api.snapshot().path.length, 0);
|
||||
ui.button('指路');
|
||||
const hinted=ui.api.snapshot().hint;
|
||||
assert.ok(hinted?.route.length>=2);
|
||||
assert.equal(ui.api.snapshot().path.length,0);
|
||||
for (const [step,id] of solution.entries()) {
|
||||
ui.cell(id);
|
||||
if(step===1)assert.equal(JSON.stringify(ui.api.snapshot().cellViews),originalFills,'点击指路前后玩家填色必须一致 '+l.id);
|
||||
assert.equal(Boolean(ui.api.snapshot().hint),step<hinted.end,'到达段末后消失');
|
||||
}
|
||||
const s = ui.api.snapshot();
|
||||
assert.equal(s.cleared, true, '无法触摸通关 ' + l.id);
|
||||
assert.equal(s.violations.length, 0);
|
||||
const action=ui.api.buttons().find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
||||
assert.ok(action,'通关按钮必须存在');
|
||||
assert.ok(!ui.api.buttons().some(b=>['重开','撤销','指路'].includes(b.label)),'通关按钮独占操作栏');
|
||||
const board=ui.images.at(-1);assert.ok(action.y>=board.y+board.h+24,'下一关不能覆盖盘面');
|
||||
rows.push({ order: i + 1, id: l.id, geometry: l.geometry, cells: l.cells.length, unique: true, touchCleared: true });
|
||||
}
|
||||
ui.handlers.Hide();
|
||||
assert.equal(ui.frames.size, 0);
|
||||
ui.handlers.Show();
|
||||
assert.equal(ui.frames.size, 1);
|
||||
ui.tick();
|
||||
assert.ok(ui.played.length>500,'发布包必须实际调用原生音频播放');
|
||||
assert.ok(!ui.api.snapshot().rules.includes('共边'));
|
||||
const restored = boot(ui.storage, 320, 640);
|
||||
assert.equal(restored.api.snapshot().level, ordered[199].id);
|
||||
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
|
||||
restored.button('继续 · 20-10');restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
|
||||
restored.button('音效开');
|
||||
const reopen = boot(ui.storage);
|
||||
assert.equal(reopen.api.snapshot().soundEnabled, false);
|
||||
// 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。
|
||||
for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
|
||||
const phone=boot(new Map(),w,h);
|
||||
for(const i of [0,28,34,99,198,199]){
|
||||
phone.select(i);for(const id of solve(ordered[i],{limit:1})[0])phone.cell(id);
|
||||
const buttons=phone.api.buttons(),board=phone.images.at(-1),action=buttons.find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
||||
assert.ok(action.y>=board.y+board.h+24,w+'x'+h+' 不覆盖盘面');
|
||||
assert.ok(action.x>=0&&action.x+action.w<=w&&action.y+action.h<=h-34,'不能进入底部安全区');
|
||||
for(let a=0;a<buttons.length;a++)for(let b=a+1;b<buttons.length;b++){const x=buttons[a],y=buttons[b];assert.ok(x.x+x.w<=y.x||y.x+y.w<=x.x||x.y+x.h<=y.y||y.y+y.h<=x.y,'按钮不能相叠');}
|
||||
phone.button(action.label);
|
||||
if(i===199)assert.equal(phone.api.snapshot().screen,'select');else{assert.equal(phone.api.snapshot().displayOrder,i+2);assert.ok(phone.api.buttons().some(b=>b.label==='重开'||b.label==='返回游戏'));}
|
||||
}
|
||||
}
|
||||
const narrow=boot(new Map(),320,568);
|
||||
for(const l of ordered.filter(l=>l.tutorial)){narrow.select(ordered.indexOf(l));narrow.button('规则');let pages=0;do{assert.ok(narrow.api.buttons().every(b=>b.y>=0&&b.y+b.h<=568));const before=narrow.api.snapshot();narrow.advance(3900);const after=narrow.api.snapshot();for(const field of ['path','centers','doors','latchedDoors','records','lastSound'])assert.deepEqual(after[field],before[field],field);if(!narrow.api.buttons().some(b=>b.label==='下一段'&&b.enabled))break;narrow.button('下一段');assert.ok(++pages<30);}while(true);narrow.button('返回游戏');}
|
||||
const result = {segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
|
||||
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
|
||||
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');
|
||||
|
||||
Reference in New Issue
Block a user