221 lines
11 KiB
JavaScript
221 lines
11 KiB
JavaScript
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();
|
||
});
|