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

221 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.env.TYPESCRIPT_PATH || 'typescript');
class Node {
constructor(name) {
this.name = name; this.children = []; this.components = new Map();
this.x = this.y = this.zIndex = 0; this.scaleX = this.scaleY = 1;
this.opacity = 255; this.destroyCount = 0;
}
addChild(child) { child.removeFromParent(); child.parent = this; this.children.push(child); }
getChildByName(name) { return this.children.find(child => child.name === name); }
getSiblingIndex() { return this.parent.children.indexOf(this); }
setSiblingIndex(index) { const p = this.parent; this.removeFromParent(); this.parent = p; p.children.splice(index, 0, this); }
addComponent(Type) { const c = new Type(); this.components.set(Type, c); return c; }
getComponent(Type) { return this.components.get(Type); }
setContentSize(width, height) { this.width = width; this.height = height; }
setPosition(x, y) { this.x = x; this.y = y; }
convertToWorldSpaceAR(p) {
const result = { x: this.x + p.x * this.scaleX, y: this.y + p.y * this.scaleY };
return this.parent ? this.parent.convertToWorldSpaceAR(result) : result;
}
convertToNodeSpaceAR(p) {
const local = this.parent ? this.parent.convertToNodeSpaceAR(p) : p;
return { x: (local.x - this.x) / this.scaleX, y: (local.y - this.y) / this.scaleY };
}
removeFromParent() {
if (this.parent) this.parent.children.splice(this.parent.children.indexOf(this), 1);
this.parent = null;
}
destroy() { this.destroyed = true; this.destroyCount++; this.children.forEach(child => child.destroy()); }
}
class Sprite {}
Sprite.SizeMode = { CUSTOM: 0 };
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
function setup(random = () => 0.5) {
let now = 0;
const tweens = [], stopped = [];
const cc = {
Node, Sprite, Color: { WHITE: { r: 255, g: 255, b: 255 } },
color: (r, g, b) => ({ r, g, b }), v2: (x, y) => ({ x, y }),
isValid: node => !!node && !node.destroyed,
tween(target) {
const item = { target };
const builder = {
to(duration, props, options) { Object.assign(item, { duration, props, options }); return builder; },
call(callback) { item.callback = callback; return builder; },
start() { item.end = now + item.duration; tweens.push(item); return builder; }
};
return builder;
},
Tween: { stopAllByTarget(target) { stopped.push(target); tweens.filter(t => t.target === target).forEach(t => t.stopped = true); } }
};
const mod = { exports: {} };
const source = fs.readFileSync(path.join(__dirname, '../assets/Script/WashingEffect.ts'), 'utf8');
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 }
}).outputText, { cc, module: mod, exports: mod.exports, Math: Object.assign(Object.create(Math), { random }) });
const map = new Node('map'), block = new Node('block'), icon = new Node('icon'), key = new Node('key');
map.setPosition(90, -50); map.scaleX = map.scaleY = 0.8;
map.addChild(block); block.setPosition(180, 240);
block.addChild(icon); block.addChild(key);
const effect = new mod.exports.default(block);
const layer = block.getChildByName('washingEffect'), bubbles = effect.bubbles;
const frames = Array.from({ length: 4 }, (_, i) => Object.freeze({ name: `dirt${i}` }));
const bubbleFrame = Object.freeze({ name: 'paopao' });
function advance(seconds) {
const end = now + seconds;
for (;;) {
const next = tweens.filter(t => !t.stopped && !t.done && t.end <= end + 1e-10).sort((a, b) => a.end - b.end)[0];
if (!next) break;
now = next.end; next.done = true;
Object.assign(next.target, next.props);
if (next.callback) next.callback();
}
now = end;
}
return { cc, effect, layer, bubbles, block, map, icon, key, frames, bubbleFrame, tweens, stopped, advance,
play: cells => effect.play(cells, frames, bubbleFrame), Effect: mod.exports.default };
}
test('only requested local cells receive 120px dirt copies with their existing frames', () => {
const f = setup();
f.play([{ x: 1, y: -1, frame: 3 }, { x: 0, y: 0, frame: 1 }]);
assert.deepEqual(f.block.children.map(n => n.name), ['icon', 'washingEffect', 'key']);
assert.equal(f.layer.children.length, 2);
assert.deepEqual(f.layer.children.map(n => [n.x, n.y]), [[55, -60], [-65, 60]]);
f.layer.children.forEach((node, i) => {
assert.equal(node.width, 120); assert.equal(node.height, 120); assert.equal(node.opacity, 255);
const sprite = node.getComponent(Sprite);
assert.equal(sprite.spriteFrame, f.frames[i === 0 ? 3 : 1]);
assert.equal(sprite.sizeMode, Sprite.SizeMode.CUSTOM); assert.equal(sprite.trim, false);
});
});
test('mud takes 0.2s, bubbles use twelve small rising sprites and last 0.8–1.0s', () => {
let seed = 0;
const f = setup(() => (seed++ % 10) / 10);
f.play([{ x: 0, y: 0, frame: 2 }]);
assert.equal(f.bubbles.children.length, 12);
assert.equal(f.tweens[0].duration, 0.2); assert.equal(f.tweens[0].props.opacity, 0);
f.tweens.slice(1).forEach(t => {
assert.ok(t.duration >= 0.8 && t.duration <= 1.0);
assert.ok(t.target.width >= 18 && t.target.width <= 36);
assert.equal(t.target.height, t.target.width);
assert.equal(t.target.getComponent(Sprite).spriteFrame, f.bubbleFrame);
assert.ok(Math.abs(t.props.x - t.target.x) <= 14);
assert.ok(t.props.y - t.target.y >= 126 && t.props.y - t.target.y <= 198);
assert.equal(t.props.opacity, 0); assert.equal(t.options.easing, 'sineOut');
});
});
test('mud follows the block while bubble origins stay in map coordinates after block movement', () => {
const f = setup();
f.block.scaleX = f.block.scaleY = 0.9;
f.play([{ x: 1, y: -1, frame: 0 }]);
const dirt = f.layer.children[0], bubble = f.bubbles.children[1];
near(bubble.x, 180 + 55 * 0.9); near(bubble.y, 240 - 60 * 0.9 - 36);
const dirtBefore = dirt.convertToWorldSpaceAR({ x: 0, y: 0 });
const bubbleBefore = bubble.convertToWorldSpaceAR({ x: 0, y: 0 });
f.block.x += 120; f.block.y -= 120;
near(dirt.convertToWorldSpaceAR({ x: 0, y: 0 }).x - dirtBefore.x, 96);
assert.deepEqual(bubble.convertToWorldSpaceAR({ x: 0, y: 0 }), bubbleBefore);
assert.equal(f.bubbles.parent, f.map);
assert.equal(f.bubbles.zIndex, 1000);
});
test('whenClean is immediate at rest and waits for all dirt but never for bubble tails', () => {
const f = setup(); let calls = 0;
f.effect.whenClean(() => calls++); assert.equal(calls, 1);
f.play([{ x: 0, y: 0, frame: 0 }, { x: 1, y: 0, frame: 1 }]);
f.effect.whenClean(() => calls++);
assert.equal(f.effect.isCleaning, true);
f.advance(0.199); assert.equal(calls, 1);
f.advance(0.001); assert.equal(calls, 2); assert.equal(f.effect.isCleaning, false);
assert.equal(f.layer.children.length, 0); assert.equal(f.bubbles.children.length, 24);
f.advance(0.7); assert.equal(f.bubbles.children.length, 0); assert.equal(calls, 2);
});
test('overlapping play calls postpone waiting callbacks until the last dirt fade', () => {
const f = setup(); const calls = [];
f.play([{ x: 0, y: 0, frame: 0 }]);
f.effect.whenClean(() => calls.push('first'));
f.advance(0.1);
f.play([{ x: 1, y: 0, frame: 1 }]);
f.effect.whenClean(() => calls.push('second'));
f.advance(0.1); assert.equal(f.effect.isCleaning, true); assert.deepEqual(calls, []);
f.advance(0.1); assert.equal(f.effect.isCleaning, false); assert.deepEqual(calls, ['first', 'second']);
assert.equal(f.bubbles.children.length, 24);
});
test('bubble layer is attached on demand, removed after its tail, then reused on the next wash', () => {
const f = setup();
assert.equal(f.map.getChildByName('washingBubbles'), undefined);
f.effect.play([{ x: 0, y: 0, frame: 0 }], f.frames, null);
assert.equal(f.map.getChildByName('washingBubbles'), undefined);
f.play([{ x: 1, y: 0, frame: 1 }]);
assert.equal(f.map.getChildByName('washingBubbles'), f.bubbles);
f.advance(0.2); f.block.removeFromParent();
assert.equal(f.bubbles.parent, f.map);
f.advance(0.7);
assert.equal(f.map.getChildByName('washingBubbles'), undefined);
assert.equal(f.bubbles.parent, null); assert.equal(f.bubbles.destroyCount, 0);
f.map.addChild(f.block);
f.play([{ x: 2, y: 0, frame: 2 }]);
assert.equal(f.map.getChildByName('washingBubbles'), f.bubbles);
assert.equal(f.bubbles.children.length, 12);
f.advance(0.9);
assert.equal(f.map.getChildByName('washingBubbles'), undefined);
});
test('a callback starting another wash defers remaining callbacks until that wash completes', () => {
const f = setup(); const calls = [];
f.play([{ x: 0, y: 0, frame: 0 }]);
f.effect.whenClean(() => { calls.push('first'); f.play([{ x: 1, y: 0, frame: 1 }]); });
f.effect.whenClean(() => calls.push('second'));
f.advance(0.2); assert.deepEqual(calls, ['first']); assert.equal(f.effect.isCleaning, true);
f.advance(0.2); assert.deepEqual(calls, ['first', 'second']);
});
test('dispose stops only owned tweens, destroys both layers and discards pending callbacks', () => {
const f = setup(); let calls = 0;
const unrelated = new Node('unrelated'); f.map.addChild(unrelated);
f.cc.tween(unrelated).to(1, { opacity: 0 }).call(() => calls++).start();
f.play([{ x: 0, y: 0, frame: 0 }]);
const owned = [...f.layer.children, ...f.bubbles.children];
f.effect.whenClean(() => calls += 100);
f.advance(0.1); f.effect.dispose(); f.effect.dispose();
assert.equal(f.effect.isCleaning, false); assert.equal(f.stopped.length, 13);
owned.forEach(node => { assert.ok(f.stopped.includes(node)); assert.equal(node.destroyCount, 1); });
assert.equal(f.layer.parent, null); assert.equal(f.bubbles.parent, null);
assert.equal(f.layer.destroyCount, 1); assert.equal(f.bubbles.destroyCount, 1);
f.effect.whenClean(() => calls += 100); f.play([{ x: 1, y: 0, frame: 1 }]);
assert.equal(f.tweens.length, 14);
f.advance(1); assert.equal(calls, 1); assert.equal(unrelated.opacity, 0);
});
test('empty cells or missing dirt frames do not block; missing bubbles do not skip the dirt fade', () => {
const f = setup(); let calls = 0;
f.play([]); f.effect.whenClean(() => calls++);
f.effect.play([{ x: 0, y: 0, frame: 3 }], [], f.bubbleFrame);
f.effect.whenClean(() => calls++);
assert.equal(calls, 2); assert.equal(f.effect.isCleaning, false);
f.effect.play([{ x: 1, y: 0, frame: 2 }], f.frames, null);
f.effect.whenClean(() => calls++);
assert.equal(f.effect.isCleaning, true); assert.equal(f.bubbles.children.length, 12);
f.advance(0.2); assert.equal(calls, 3);
});
test('single-color simple tiles stay under dirt effects without raising existing accessories', () => {
const f = setup(); f.effect.dispose();
const simple = new Node('simple'); simple.zIndex = 999; f.block.addChild(simple);
const effect = new f.Effect(f.block), layer = f.block.getChildByName('washingEffect');
assert.equal(layer.zIndex, 1000); assert.equal(f.key.zIndex, 0);
effect.dispose();
});