377 lines
21 KiB
JavaScript
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);
|
|
});
|