MatchMaster/tools/test-rainbow-old-opening.cjs
WIN-GKKD951VVMJ\Administrator 74cf4413b5 增加一版彩虹机制
2026-09-20 17:14:40 +08:00

158 lines
9.3 KiB
JavaScript

// TYPESCRIPT_PATH may point to the TypeScript bundled with the local editor.
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');
const root = path.resolve(__dirname, '..');
const v2 = (x = 0, y = 0) => ({ x, y });
function load(file, cc, deps = {}) {
const mod = { exports: {} };
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true }
}).outputText, { cc, module: mod, exports: mod.exports, require: name => { assert.ok(deps[name], name); return deps[name]; } });
return mod.exports;
}
class Node {
constructor(name) { this.name = name; this.x = this.y = 0; this.active = true; this.components = new Map(); this.children = []; this.destroyCount = 0; }
addComponent(type) { const c = new type(); c.node = this; this.components.set(type, c); return c; }
getComponent(type) { return this.components.get(type); }
addChild(child) { child.parent = this; this.children.push(child); }
setContentSize(width, height) { this.width = width; this.height = height; }
setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }
convertToWorldSpaceAR(p) { const q = v2(this.x + p.x, this.y + p.y); return this.parent ? this.parent.convertToWorldSpaceAR(q) : q; }
convertToNodeSpaceAR(p) { const world = this.convertToWorldSpaceAR(v2()); return v2(p.x - world.x, p.y - world.y); }
destroy() { this.destroyed = true; this.destroyCount++; }
}
class Sprite {}
Sprite.SizeMode = { CUSTOM: 0 };
class ParticleSystem {
constructor() { this.particleCount = 4; this.resetCount = 0; this.stopCount = 0; }
resetSystem() { this.resetCount++; }
stopSystem() { this.stopCount++; }
}
ParticleSystem.PositionType = { FREE: 0 }; ParticleSystem.EmitterMode = { GRAVITY: 0 };
class Texture2D {
constructor() { this.destroyCount = 0; }
initWithData(pixels, format, width, height) { this.width = width; this.height = height; }
destroy() { this.destroyCount++; }
}
Texture2D.PixelFormat = { RGBA8888: 0 };
class SpriteFrame {
constructor(texture) { this.texture = texture; this.destroyCount = 0; }
destroy() { this.destroyCount++; }
}
class Water {}
const rules = load('assets/Script/RainbowRules.ts', {});
function setup(onLand = () => {}) {
const events = new Map(), listeners = name => events.get(name) || new Set();
const cc = { Node, Sprite, ParticleSystem, Texture2D, SpriteFrame, v2, Color: { WHITE: { r: 255, g: 255, b: 255 } },
color: (r, g, b, a = 255) => ({ r, g, b, a }), macro: { MAX_ZINDEX: 1000, SRC_ALPHA: 1, ONE_MINUS_SRC_ALPHA: 2 },
isValid: node => !!node && !node.destroyed,
game: { EVENT_HIDE: 'hide', EVENT_SHOW: 'show',
on(name, callback) { const set = listeners(name); set.add(callback); events.set(name, set); },
off(name, callback) { listeners(name).delete(callback); } } };
const Trail = load('assets/Script/RainbowTrail.ts', cc).default;
const Opening = load('assets/Script/RainbowOldOpening.ts', cc, {
'./RainbowTrail': { default: Trail }, './RainbowBottleWater': { default: Water }, './RainbowRules': rules
}).default;
const scene = new Node('scene'), parent = new Node('effects'), ui = new Node('ui'), bottle = new Node('bottle');
scene.addChild(parent); scene.addChild(ui); ui.addChild(bottle);
parent.setPosition(250, 40); ui.setPosition(-300, -500); bottle.setPosition(30, 20);
const sprite = bottle.addComponent(Sprite); sprite.fillRange = 0;
bottle.active = false;
const to = v2(160, 120), opening = new Opening(parent, bottle, to, onLand), trail = opening.flight;
const particles = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean);
const frames = particles.map(p => p.spriteFrame), textures = frames.map(f => f.texture);
return { opening, trail, particles, frames, textures, bottle, sprite, parent, to, cc,
emit: name => [...listeners(name)].forEach(fn => fn()), listenerCount: () => [...events.values()].reduce((n, set) => n + set.size, 0) };
}
function near(actual, expected) { assert.ok(Math.abs(actual - expected) < 1e-8, `${actual} != ${expected}`); }
function expectReleased(f) {
assert.equal(f.bottle.active, false); assert.equal(f.listenerCount(), 0);
assert.equal(f.trail.node.destroyCount, 1);
[...f.frames, ...f.textures].forEach(asset => assert.equal(asset.destroyCount, 1));
}
test('C opening shows full white animated bottle and follows B flight path from bottle world coordinates', () => {
let lands = 0; const f = setup(() => lands++);
assert.equal(f.bottle.active, true); assert.equal(f.sprite.fillRange, 1);
assert.equal(f.bottle.color, f.cc.Color.WHITE); assert.ok(f.bottle.getComponent(Water));
assert.equal(f.listenerCount(), 2);
assert.equal(f.trail.node.active, false, 'await map transition readiness before emitting');
const from = f.parent.convertToNodeSpaceAR(f.bottle.convertToWorldSpaceAR(v2()));
assert.equal(f.trail.node.x, from.x); assert.equal(f.trail.node.y, from.y);
assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS / 2), false);
const top = Math.max(from.y, f.to.y) + 180;
const controlY = top + Math.sqrt((top - from.y) * (top - f.to.y));
near(f.trail.node.x, f.to.x + (from.x - f.to.x) / 4);
near(f.trail.node.y, from.y / 4 + controlY / 2 + f.to.y / 4);
assert.equal(lands, 0);
f.to.x = 999; // The destination is fixed at launch.
assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS / 2), false);
assert.equal(lands, 1); near(f.trail.node.x, 160); near(f.trail.node.y, 120);
assert.equal(f.bottle.active, true, 'tail still visible: map must remain locked');
assert.equal(f.trail.node.children.find(n => n.name === 'rainbowHead').active, false);
f.particles.forEach(p => assert.equal(p.stopCount, 1));
f.opening.dispose(); expectReleased(f);
});
test('completion waits for both real RainbowTrail particle systems, then cleans up once', () => {
let lands = 0; const f = setup(() => lands++);
assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS), false);
f.particles[0].particleCount = 0;
assert.equal(f.opening.update(5), false); assert.equal(f.bottle.active, true);
f.particles[1].particleCount = 0;
assert.equal(f.opening.update(0), true); expectReleased(f);
assert.equal(f.opening.update(100), true); f.opening.dispose();
assert.equal(lands, 1); expectReleased(f);
});
test('map transition pause holds launch and tail completion until explicitly resumed', () => {
let lands = 0; const f = setup(() => lands++);
const start = v2(f.trail.node.x, f.trail.node.y);
assert.equal(f.opening.update(100, true), false);
assert.equal(f.trail.node.active, false); assert.equal(f.trail.node.x, start.x); assert.equal(f.trail.node.y, start.y);
assert.equal(lands, 0); assert.equal(f.bottle.active, true);
assert.equal(f.opening.update(rules.RAINBOW_CAST_SECONDS, false), false); assert.equal(lands, 1);
f.particles.forEach(p => p.particleCount = 0);
assert.equal(f.opening.update(100, true), false);
assert.equal(f.bottle.active, true); assert.equal(f.trail.node.destroyCount, 0);
assert.equal(f.opening.update(0, false), true); expectReleased(f);
});
test('hide/show pauses opening and its fading trail without depending on gameStart or restarting particles', () => {
let lands = 0; const f = setup(() => lands++);
f.opening.update(rules.RAINBOW_CAST_SECONDS / 3);
const before = v2(f.trail.node.x, f.trail.node.y);
f.emit('hide');
assert.equal(f.trail.node.active, false); assert.equal(f.bottle.active, false);
assert.equal(f.opening.update(100), false);
assert.equal(f.trail.node.x, before.x); assert.equal(f.trail.node.y, before.y); assert.equal(lands, 0);
f.emit('show'); assert.equal(f.bottle.active, true);
f.opening.update(rules.RAINBOW_CAST_SECONDS);
assert.equal(lands, 1); assert.equal(f.trail.node.active, true);
f.particles.forEach(p => assert.equal(p.resetCount, 1));
f.emit('hide'); f.particles.forEach(p => p.particleCount = 0);
assert.equal(f.opening.update(10), false, 'do not end the animation while hidden');
f.emit('show'); assert.equal(f.opening.update(0), true); expectReleased(f);
});
test('dispose before landing cancels the reward and frees resources and event handlers', () => {
let lands = 0; const f = setup(() => lands++);
f.opening.update(rules.RAINBOW_CAST_SECONDS / 2);
f.opening.dispose(); f.opening.dispose(); expectReleased(f);
f.emit('show'); assert.equal(f.bottle.active, false);
assert.equal(f.opening.update(100), true); assert.equal(lands, 0);
});
test('a landing failure propagates once and remains safe for caller disposal or later updates', () => {
let lands = 0; const failure = new Error('apply failed');
const f = setup(() => { lands++; throw failure; });
assert.throws(() => f.opening.update(rules.RAINBOW_CAST_SECONDS), error => error === failure);
assert.equal(f.opening.update(1), false); assert.equal(lands, 1);
f.particles.forEach(p => assert.equal(p.stopCount, 1));
f.opening.dispose(); expectReleased(f);
});