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(); });