Files
MatchMaster/tools/test-washing-block.cjs

377 lines
21 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require(process.env.TYPESCRIPT_PATH || 'typescript');
const compile = source => ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
}).outputText;
const exportsRules = {};
vm.runInNewContext(compile(fs.readFileSync('assets/Script/WashingRules.ts', 'utf8')), { exports: exportsRules });
const rules = exportsRules;
const key = cells => cells.map(p => `${p.x},${p.y}:${p.frame}`).sort().join(';');
const BlockType = { 普通块: 0, 叠加块下: 1, 粘合块: 9, 叠加块上: 10, 冻结块: 4,
问号块: 16, 水平块: 7, 垂直块: 8, 单色地块: 22, 变色块: 20, 消除炸弹块: 17 };
const v2 = (x = 0, y = 0) => ({ x, y });
class Node {
constructor(name = '') { this.name = name; this.children = []; this.active = true; this.x = this.y = 0; this.zIndex = 0; this.opacity = 255; }
addChild(child) { child.parent = this; this.children.push(child); }
getChildByName(name) { return this.children.find(child => child.name === name) || null; }
getSiblingIndex() { return this.parent.children.indexOf(this); }
setSiblingIndex(index) { const list = this.parent.children; list.splice(list.indexOf(this), 1); list.splice(index, 0, this); }
addComponent() { return this.sprite = {}; }
getComponent() { return this.block || this.sprite; }
setPosition(x, y) { if (typeof x === 'object') ({ x, y } = x); this.x = x; this.y = y; }
get position() { return v2(this.x, this.y); }
set position(value) { this.setPosition(value); }
setContentSize(width, height) { this.width = width; this.height = height; }
setAnchorPoint(x, y) { this.anchorX = x; this.anchorY = y; }
removeFromParent() { const list = this.parent.children; list.splice(list.indexOf(this), 1); this.parent = null; }
destroy() { this.destroyed = true; }
on() {}
off() {}
getPosition() { return this.position; }
}
Node.EventType = {};
const cc = { Node, Sprite: { SizeMode: { CUSTOM: 0 } }, v2,
fx: { AudioManager: { _instance: { playEffect() {} } } } };
const afterDraw = [];
const timers = [];
cc.Director = { EVENT_AFTER_DRAW: 'after-draw' };
cc.director = { once(event, callback) { assert.equal(event, 'after-draw'); afterDraw.push(callback); } };
cc.isValid = node => !node.destroyed;
const controller = { _instance: null };
class WashingEffect {
constructor(parent) { this.parent = parent; this.plays = []; this.callbacks = []; this.isCleaning = false; }
play(cells, frames, bubble) { this.plays.push({ cells, frames, bubble }); this.isCleaning = true; }
whenClean(callback) { if (this.isCleaning) this.callbacks.push(callback); else callback(); }
finish() { this.isCleaning = false; this.callbacks.splice(0).forEach(callback => callback()); }
dispose() { this.disposed = true; this.isCleaning = false; this.callbacks = []; }
}
const source = ts.createSourceFile('Block.ts', fs.readFileSync('assets/Script/Block.ts', 'utf8'), ts.ScriptTarget.Latest, true);
const declaration = source.statements.find(ts.isClassDeclaration);
const names = ['dirtCells', 'washingEffect', 'isDirty', 'clearDirt', 'remapDirt', 'tryWashDirt', 'refreshDirtVisual',
'init', 'initBlocks', 'blockFall', 'blockFallSimulate', 'removeAction', 'touchEnd', 'resetFreeze', 'resetFloor', 'resetQuestionColor', 'removeFlower', 'onDestroy',
'removeSpecailAdhesive', 'restoreNomal'];
const members = names.map(name => declaration.members.find(member => member.name && member.name.getText(source) === name).getText(source));
const Block = vm.runInNewContext(compile(`class Block { ${members.join('\n')} }\nBlock;`), {
...rules, cc, BlockType, WashingEffect, MapConroler: controller, console: { log() {} },
setTimeout(callback, delay) { timers.push({ callback, delay }); },
blockTexturePath: (color, shape) => `${color}color${shape}`,
BlockAssetManager: { instance: { getFrame: name => name } },
});
function fixture(shape = 5) {
afterDraw.length = 0;
timers.length = 0;
const wet = new Set();
const frames = ['mud1', 'mud2', 'mud3', 'mud4'];
const map = { mapWidth: 8, mapHeight: 8, mapInfo: [], gameOver: false, total_steps_count: 0,
getDirtFrames: () => frames, getWashingBubble: () => 'bubble', isWashCell: (x, y) => wet.has(`${x},${y}`),
checkButton() {}, upDoor() {}, changeRiseFall() {}, removeOneBlock() {}, flowerBlock: [] };
map.mapBlocksWall = Array.from({ length: 8 }, (_, x) => Array.from({ length: 8 }, (_, y) => ({
x: -420 + x * 120, y: -420 + y * 120, opacity: x && y && x < 7 && y < 7 ? 255 : 254,
getBoundingBox: () => ({ contains: () => true }), getComponent: () => ({ posX: x, posY: y }),
})));
controller._instance = map;
const block = Object.assign(new Block(), { type: 0, color: 3, flowerType: 0, posX: 3, posY: 3,
block_Info: { block: shape, type: 0, color: 3 }, teamBlocks: [], adhesiveNode: [], isTouch: false,
node: new Node('block'), hit: new Node('hit'), oldRainbow: false,
resetStartPos() { return !!this.invalid; }, setVibrate() {}, setMapBlock() {}, removeMapBlock() {},
removeBoxCollider() {}, removeAction() {}, jsonDeepClone: value => JSON.parse(JSON.stringify(value)),
initColor() {}, initType() {}, setFloor() {}, setSimple() {}, createFlower() {}, setMapColor() {},
});
for (const name of ['icon', 'change_color', 'key']) { const node = new Node(name); node.addComponent(); block.node.addChild(node); }
block.node._touchListener = { setSwallowTouches() {} };
block.node.block = block;
block.node.setPosition(map.mapBlocksWall[3][3].x + 65, map.mapBlocksWall[3][3].y - 60);
block.startPos = block.node.position;
block.initBlocks();
block.dirtCells = block.allBlocks.map((p, frame) => ({ ...p, frame: frame % 4 }));
map.checkPass = node => node.block.isDirty ? -1 : 3;
return { block, map, wet, frames };
}
test('dirty configuration starts every local cell dirty, except stacking and rainbow', () => {
for (const type of [0, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 16, 17, 20, 21, 22, 1, 10]) {
const { block } = fixture();
block.init({ type, block: 5, color: 3, dirty: true, id: 1 });
assert.equal(block.dirtCells.length, [1, 10].includes(type) ? 0 : 4, String(type));
}
const { block } = fixture();
block.init({ type: 0, block: 5, color: 100, dirty: true });
assert.equal(block.isDirty, false);
block.init({ type: 0, block: 5, color: 3 });
assert.equal(block.isDirty, false);
});
test('one wet column cleans only matching cells permanently and preserves chosen textures', () => {
const { block, wet } = fixture();
wet.add('3,3'); wet.add('3,4');
block.tryWashDirt();
assert.equal(key(block.dirtCells), '-1,0:1;-1,1:2');
const effect = block.washingEffect;
assert.equal(effect.isCleaning, true, 'logical dirt clears before visual fade finishes');
assert.equal(key(effect.plays[0].cells), '0,0:0;0,1:3');
assert.equal(effect.plays[0].bubble, 'bubble');
wet.clear(); block.tryWashDirt();
assert.equal(key(block.dirtCells), '-1,0:1;-1,1:2');
assert.equal(effect.plays.length, 1, 'unchanged cells do not replay the effect');
wet.add('2,3'); wet.add('2,4'); block.tryWashDirt();
assert.equal(block.isDirty, false);
assert.equal(block.node.getChildByName('dirt'), null);
assert.equal(effect.plays.length, 2);
assert.equal(key(effect.plays[1].cells), '-1,0:1;-1,1:2');
});
test('coverage is logical and compositional; false floor fields and accessories do not block washing', () => {
const samples = [
[{ type: 4 }, true], [{ type: 16 }, true], [{ flowerType: 2 }, true],
[{ block_Info: { floor: 1, floorTime: 4, floorMove: true } }, true],
[{ block_Info: { floor: 0, floorTime: 4 } }, false],
[{ block_Info: { floor: false, floorTime: 4 } }, false],
[{ block_Info: { floor: 1, floorTime: 0 } }, false],
[{ type: 3, block_Info: { lock: true, flowerSend: 4 } }, false],
[{ type: 6 }, false], [{ type: 7 }, false], [{ type: 5 }, false],
];
for (const [state, covered] of samples) {
const { block, wet } = fixture(0); Object.assign(block, state); wet.add('3,3');
block.tryWashDirt(); assert.equal(block.isDirty, covered, JSON.stringify(state));
}
const { block, wet } = fixture(0); wet.add('3,3');
block.type = 4; block.flowerType = 2; block.block_Info.floor = 1; block.block_Info.floorTime = 5;
block.resetFreeze(); assert.equal(block.isDirty, true);
block.block_Info.floor = null; block.block_Info.floorTime = null;
block.tryWashDirt(); assert.equal(block.isDirty, true);
block.flowerType = 0; block.tryWashDirt(); assert.equal(block.isDirty, false);
});
test('question logical release washes immediately despite its fading sprite', () => {
const { block, wet } = fixture(0); wet.add('3,3'); block.type = 16;
block.node.addChild(new Node('question'));
block.resetQuestionColor();
assert.equal(block.type, 0); assert.equal(block.isDirty, false);
assert.equal(block.node.getChildByName('question').active, true);
});
test('actual floor, freeze and petal release hooks wash only after the final logical cover is removed', () => {
const { block, wet } = fixture(0); wet.add('3,3');
block.type = 4; block.flowerType = 2; block.block_Info.floor = 1; block.block_Info.floorTime = 3;
block.node.addChild(new Node('floor')); block.node.addChild(new Node('flowerFloor'));
block.refreshDirtVisual();
block.resetFloor(); assert.equal(block.isDirty, true); assert.equal(block.node.getChildByName('dirt').active, false);
block.resetFreeze(); assert.equal(block.isDirty, true); assert.equal(block.node.getChildByName('dirt').active, false);
block.removeFlower(); assert.equal(block.isDirty, false); assert.equal(block.node.getChildByName('dirt'), null);
});
test('releasing a floor team member does not reveal a still-covered peer or revive cleaned dirt', () => {
const first = fixture(0), second = fixture(0);
for (const block of [first.block, second.block]) {
block.block_Info.floor = 1; block.block_Info.floorTime = 3;
block.teamBlocks = [first.block.node, second.block.node];
block.refreshDirtVisual();
}
first.block.resetFloor();
assert.equal(first.block.node.getChildByName('dirt').active, true);
assert.equal(second.block.node.getChildByName('dirt').active, false);
first.block.clearDirt(); second.block.resetFloor();
assert.equal(first.block.node.getChildByName('dirt'), null);
});
test('dragging, linked dragging, unaligned position, illegal ground and pending spawn cannot wash', () => {
for (const alter of [b => b.isTouch = true, b => b.spawnLocked = true, b => b.over = true,
b => b.node.x += 2, b => b.invalid = true,
b => b.teamBlocks = [{ getComponent: () => ({ isTouch: true }) }],
b => { b.type = 9; b.block_Info.node = { getComponent: () => ({ isTouch: true }) }; }]) {
const { block, wet } = fixture(0); wet.add('3,3'); alter(block); block.tryWashDirt(); assert.equal(block.isDirty, true);
}
const { block, wet, map } = fixture(); wet.add('3,3'); map.mapBlocksWall[2][3].opacity = 254;
block.tryWashDirt(); assert.equal(block.dirtCells.length, 4);
});
test('real release washes before door evaluation; simulation and rejected return never wash', () => {
const { block, wet } = fixture(0); wet.add('3,3');
const point = v2(block.node.x - 50, block.node.y + 50);
assert.equal(block.blockFallSimulate(point).jg, false); assert.equal(block.isDirty, true);
block.blockFall(point, true, null, false); assert.equal(block.isDirty, true);
block.blockFall(point, true, null); assert.equal(block.isDirty, false); assert.equal(block.over, true);
const returned = fixture(0); returned.wet.add('3,3');
returned.block.invalid = true; returned.block.isTouch = true;
returned.block.touchEnd(); assert.equal(returned.block.isDirty, true);
});
test('washing the last cell removes mud before same-release door checks and exit animation', () => {
const { block, map, wet } = fixture(1);
wet.add('2,3'); block.tryWashDirt();
assert.equal(block.dirtCells.length, 1);
const dirt = block.node.getChildByName('dirt');
assert.equal(dirt.active, true);
wet.add('3,3');
const order = [];
map.checkPass = () => {
assert.equal(block.isDirty, false);
assert.equal(block.node.getChildByName('dirt'), null);
assert.equal(dirt.parent, null);
order.push('door');
return 0;
};
block.removeAction = (direction, type, afterCleaning) => {
assert.ok(block.node.children.every(child => child !== dirt));
assert.equal(afterCleaning, true);
order.push('exit-preparation');
};
block.blockFall(v2(block.node.x - 50, block.node.y + 50), true, null);
assert.equal(block.over, true, 'logical removal is immediate');
assert.deepEqual(order, ['door', 'exit-preparation']);
});
test('only exit visuals wait for the clean frame; adhesive release and gameplay stay synchronous', () => {
for (const [afterCleaning, fading] of [[true, false], [false, false], [true, true], [false, true]]) {
const { block, map } = fixture(1);
block.refreshDirtVisual(); block.clearDirt();
if (fading) {
block.washingEffect = new WashingEffect(block.node);
block.washingEffect.play([], [], 'bubble');
}
const delayed = afterCleaning || fading;
block.node.setContentSize(240, 120);
block.node.children[0].setContentSize(240, 120);
const collider = new Node('left'); block.node.addChild(collider);
const actions = [], logic = [];
cc.tween = target => ({ to(time) { this.time = time; return this; }, start() { actions.push({ target, time: this.time }); } });
cc.Mask = {};
block.node.addComponent = type => { assert.equal(type, cc.Mask); block.node.masked = true; };
const partner = { type: 9, block_Info: { node: block.node } };
block.type = 9; block.block_Info.node = partner;
block.removeAllAdhesive = () => { partner.type = 0; partner.block_Info.node = null; logic.push('unglue'); };
map.freezeVersion = 7; map.judgeWin = () => logic.push('win');
block.moveFlower = () => logic.push('flower');
Block.prototype.removeAction.call(block, 0, true, afterCleaning);
assert.deepEqual(logic, ['unglue', 'win', 'flower']);
assert.equal(partner.block_Info.node, null, 'partner cannot restore an already-exiting block');
assert.equal(actions.length, delayed ? 0 : block.node.children.length);
assert.equal(!!block.node.masked, !delayed);
assert.equal(timers.filter(t => t.delay === 330).length, delayed ? 0 : 1);
if (fading) {
assert.equal(afterDraw.length, 0, 'another release of a logically clean block still waits for fading mud');
block.washingEffect.finish();
assert.equal(actions.length, 0, 'one clean frame is still drawn after the fade');
}
collider.destroy();
afterDraw.splice(0).forEach(callback => callback());
assert.equal(actions.length, block.node.children.length - (delayed ? 1 : 0));
if (delayed) assert.ok(actions.every(a => a.target !== collider));
assert.ok(actions.every(a => a.target.name !== 'dirt' && a.time === 0.33));
assert.equal(block.node.masked, true);
assert.equal(timers.filter(t => t.delay === 330).length, 1);
}
});
test('rainbow conversion, cutting, reuse and destruction dispose washing visuals', () => {
for (const operation of [b => b.clearDirt(), b => b.remapDirt({ x: 0, y: 0 }, b.allBlocks),
b => b.init({ type: 0, block: 5, color: 3 }), b => b.onDestroy()]) {
const { block, wet } = fixture(); wet.add('3,3'); block.tryWashDirt();
const effect = block.washingEffect;
let called = false; effect.whenClean(() => called = true);
operation(block);
assert.equal(effect.disposed, true);
assert.equal(block.washingEffect, null);
effect.finish(); assert.equal(called, false);
}
});
test('a dirty glued member does not stop its clean partner washing or leaving', () => {
const { block, wet } = fixture(0); wet.add('3,3'); block.type = 9;
const partner = { isTouch: false, isDirty: true };
block.block_Info.node = { getComponent: () => partner };
block.tryWashDirt(); assert.equal(block.isDirty, false); assert.equal(partner.isDirty, true);
});
function gluedReleaseFixture(partnerShape = 0) {
const first = fixture(0).block;
const { block: partner, map, wet } = fixture(partnerShape);
partner.posX = 4;
partner.node.x = map.mapBlocksWall[4][3].x + 65;
partner.startPos = partner.node.position;
first.type = partner.type = 9;
first.block_Info.node = partner.node;
partner.block_Info.node = first.node;
for (const block of [first, partner]) {
block.removeAdhesive = () => {};
block.setHitPosition = () => {};
block.ice_SpriteFrame = { _spriteFrames: {} };
}
first.isTouch = true;
first.node.x += 120; partner.node.x += 120;
let adhesiveCount = 1;
map.checkPass = node => {
if (node.block.isDirty || node !== first.node) return -1;
// checkPass -> special_Treatment -> changeAdhesive synchronously expires the last link.
if (--adhesiveCount === 0) first.removeSpecailAdhesive();
return 3;
};
return { first, partner, map, wet };
}
test('last adhesive count expiring during the first exit still settles and washes its partner', () => {
const { first, partner, wet } = gluedReleaseFixture();
wet.add('4,3'); wet.add('5,3');
first.touchEnd();
assert.equal(first.isDirty, false); assert.equal(first.over, true);
assert.equal(first.washingEffect.isCleaning, true, 'settlement must not wait for the first mud fade');
assert.equal(first.type, 0); assert.equal(partner.type, 0);
assert.equal(first.block_Info.node, null);
assert.equal(partner.block_Info.node, null);
assert.equal(partner.posX, 5, 'the saved partner still needs its new grid coordinate after ungluing');
assert.equal(partner.posY, 3);
assert.equal(partner.isDirty, false);
assert.equal(key(partner.washingEffect.plays[0].cells), '0,0:0');
});
test('a partner unglued during the first exit washes only its final wet cells and retains other dirt', () => {
const { first, partner, wet } = gluedReleaseFixture(2);
wet.add('4,3'); wet.add('5,3');
first.touchEnd();
assert.equal(first.over, true); assert.equal(partner.type, 0);
assert.equal(partner.posX, 5);
assert.equal(key(partner.dirtCells), '0,1:1');
assert.equal(key(partner.washingEffect.plays[0].cells), '0,0:0');
assert.equal(!!partner.over, false, 'remaining dirt still prevents the partner exiting');
});
test('a rejected glued release returns both members without washing either old or attempted wet cells', () => {
const { first, partner, wet } = gluedReleaseFixture();
wet.add('3,3'); wet.add('4,3'); wet.add('5,3');
first.invalid = true;
first.touchEnd();
assert.equal(first.posX, 3); assert.equal(partner.posX, 4);
assert.deepEqual(first.node.position, first.startPos);
assert.deepEqual(partner.node.position, partner.startPos);
assert.equal(first.isDirty, true); assert.equal(partner.isDirty, true);
assert.equal(first.washingEffect, null); assert.equal(partner.washingEffect, null);
assert.equal(first.type, 9); assert.equal(partner.type, 9);
});
test('remapping after a cut preserves remaining dirt and pattern in the new local origin', () => {
const { block } = fixture();
block.dirtCells = [{ x: -1, y: 1, frame: 3 }, { x: 0, y: 0, frame: 2 }];
block.remapDirt({ x: -1, y: 0 }, [{ x: 0, y: 0 }, { x: 0, y: 1 }]);
assert.equal(key(block.dirtCells), '0,1:3');
block.clearDirt(); assert.equal(block.isDirty, false);
});
test('dirt uses stable 120px grid images above the base and below accessories, hidden under covers', () => {
const { block, frames } = fixture(); block.refreshDirtVisual();
const layer = block.node.getChildByName('dirt');
assert.ok(layer.getSiblingIndex() > block.node.getChildByName('icon').getSiblingIndex());
assert.ok(layer.getSiblingIndex() < block.node.getChildByName('key').getSiblingIndex());
const stain = layer.getChildByName('-1,1');
assert.deepEqual([stain.x, stain.y, stain.width, stain.height], [-185, 180, 120, 120]);
assert.equal(stain.sprite.trim, false); assert.equal(stain.sprite.spriteFrame, frames[2]);
block.refreshDirtVisual(); assert.equal(layer.getChildByName('-1,1'), stain);
block.type = 4; block.refreshDirtVisual(); assert.equal(layer.active, false);
block.type = 22; block.refreshDirtVisual(); assert.equal(layer.active, true); assert.equal(layer.zIndex, 1000);
frames.length = 0; block.refreshDirtVisual(); assert.equal(stain.sprite.spriteFrame, null); assert.equal(block.isDirty, true);
});