245 lines
14 KiB
JavaScript
245 lines
14 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 = this.angle = 0; this.width = this.height = 100;
|
|
this.anchorX = this.anchorY = 0.5; this.scaleX = this.scaleY = 1; this.opacity = 255;
|
|
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); }
|
|
getContentSize() { return { width: this.width, height: this.height }; }
|
|
getAnchorPoint() { return v2(this.anchorX, this.anchorY); }
|
|
setContentSize(width, height) { this.width = typeof width === 'object' ? width.width : width; this.height = typeof width === 'object' ? width.height : height; }
|
|
setAnchorPoint(point) { this.anchorX = point.x; this.anchorY = point.y; }
|
|
setScale(x, y = x) { this.scaleX = x; this.scaleY = y; }
|
|
setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }
|
|
convertToWorldSpaceAR(p) {
|
|
const r = this.angle * Math.PI / 180, x = p.x * this.scaleX, y = p.y * this.scaleY;
|
|
const q = v2(this.x + x * Math.cos(r) - y * Math.sin(r), this.y + x * Math.sin(r) + y * Math.cos(r));
|
|
return this.parent ? this.parent.convertToWorldSpaceAR(q) : q;
|
|
}
|
|
convertToNodeSpaceAR(p) {
|
|
const q = this.parent ? this.parent.convertToNodeSpaceAR(p) : p;
|
|
const r = -this.angle * Math.PI / 180, x = q.x - this.x, y = q.y - this.y;
|
|
return v2((x * Math.cos(r) - y * Math.sin(r)) / this.scaleX, (x * Math.sin(r) + y * Math.cos(r)) / this.scaleY);
|
|
}
|
|
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 {}
|
|
class Button {}
|
|
const enterTime = 0.5, holdTime = 0.5, castTime = 0.7, fadeTime = 0.15;
|
|
const releaseTime = enterTime + holdTime;
|
|
const boundaryEpsilon = 1e-10;
|
|
function setup(onLand = () => {}, transform = false) {
|
|
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 },
|
|
winSize: { width: 1080, height: 1920 }, 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 RealTrail = load('assets/Script/RainbowTrail.ts', cc).default;
|
|
const flights = [];
|
|
class Trail extends RealTrail { constructor(...args) { super(...args); flights.push(this); } }
|
|
const Opening = load('assets/Script/RainbowOldOpening.ts', cc, {
|
|
'./RainbowTrail': { default: Trail }, './RainbowBottleWater': { default: Water }
|
|
}).default;
|
|
const scene = new Node('scene'), parent = new Node('effects'), ui = new Node('ui'), bottle = new Node('bottle'), bottleBg = new Node('bottleBg');
|
|
scene.addChild(parent); scene.addChild(ui); ui.addChild(bottle); ui.addChild(bottleBg);
|
|
parent.setPosition(250, 40); ui.setPosition(-300, -500); bottle.setPosition(30, 20);
|
|
if (transform) { parent.setScale(0.7, 0.9); parent.angle = 12; }
|
|
bottleBg.setPosition(30, 40.111); bottle.setScale(0.7); bottleBg.setScale(0.7);
|
|
bottle.setContentSize(225, 169); bottleBg.setContentSize(237, 233);
|
|
const sprite = bottle.addComponent(Sprite); sprite.fillRange = 0; sprite.spriteFrame = { name: 'water' };
|
|
const bgSprite = bottleBg.addComponent(Sprite); bgSprite.spriteFrame = { name: 'outline' };
|
|
bottleBg.addComponent(Button);
|
|
const to = v2(160, 120), opening = new Opening(parent, bottle, to, onLand, bottleBg);
|
|
const particles = () => flights[0].node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean);
|
|
let frames;
|
|
return { opening, flights, bottleNode: opening.bottleNode, bottle, bottleBg, sprite, parent, to, cc,
|
|
get trail() { return flights[0]; }, get particles() { return particles(); },
|
|
// Capture before disposal because the actual Trail clears its private asset arrays.
|
|
frames: () => frames || (frames = particles().map(p => p.spriteFrame)),
|
|
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.bottleBg.active, false); assert.equal(f.listenerCount(), 0);
|
|
assert.equal(f.bottleNode.active, false); assert.equal(f.bottleNode.destroyCount, 1);
|
|
if (f.trail) {
|
|
assert.equal(f.trail.node.destroyCount, 1);
|
|
f.frames().forEach(frame => { assert.equal(frame.destroyCount, 1); assert.equal(frame.texture.destroyCount, 1); });
|
|
}
|
|
}
|
|
|
|
test('temporary bottle copies only sprites, starts offscreen, and leaves bottom controls hidden', () => {
|
|
const f = setup(), copies = f.bottleNode.children;
|
|
assert.equal(f.bottle.active, false); assert.equal(f.bottleBg.active, false);
|
|
assert.equal(f.sprite.fillRange, 0, 'do not modify bottom water sprite');
|
|
assert.equal(copies.length, 2);
|
|
assert.equal(copies[0].getComponent(Sprite).spriteFrame, f.sprite.spriteFrame);
|
|
assert.equal(copies[1].getComponent(Sprite).spriteFrame, f.bottleBg.getComponent(Sprite).spriteFrame);
|
|
assert.equal(copies[0].getComponent(Sprite).fillRange, 1);
|
|
assert.ok(copies[0].getComponent(Water)); assert.equal(copies[1].getComponent(Button), undefined);
|
|
copies.forEach((copy, i) => {
|
|
const original = [f.bottle, f.bottleBg][i];
|
|
assert.deepEqual(copy.getContentSize(), original.getContentSize());
|
|
assert.deepEqual(copy.getAnchorPoint(), original.getAnchorPoint());
|
|
assert.equal(copy.scaleX, original.scaleX); assert.equal(copy.scaleY, original.scaleY);
|
|
assert.equal(copy.color, f.cc.Color.WHITE);
|
|
});
|
|
near(copies[0].y - copies[1].y, f.bottle.y - f.bottleBg.y);
|
|
const start = f.bottleNode.convertToWorldSpaceAR(v2()); near(start.x, -200); near(start.y, 880);
|
|
assert.equal(f.flights.length, 0, 'do not create or emit particle systems before release');
|
|
assert.equal(f.bottleNode.active, false, 'wait for map transition readiness');
|
|
assert.equal(f.listenerCount(), 2);
|
|
f.opening.dispose(); expectReleased(f);
|
|
});
|
|
|
|
test('bottle enters along a curve and holds at screen center even with transformed effect parents', () => {
|
|
for (const transformed of [false, true]) {
|
|
const f = setup(() => {}, transformed);
|
|
f.opening.update(enterTime / 2);
|
|
const midway = f.bottleNode.convertToWorldSpaceAR(v2());
|
|
near(midway.x, (-200 + 2 * (540 * 0.35) + 540) / 4);
|
|
near(midway.y, (880 + 2 * 1110 + 960) / 4);
|
|
assert.equal(f.flights.length, 0);
|
|
f.opening.update(enterTime / 2);
|
|
const center = f.bottleNode.convertToWorldSpaceAR(v2()); near(center.x, 540); near(center.y, 960);
|
|
f.opening.update(holdTime / 4);
|
|
assert.ok(Math.abs(f.bottleNode.angle) < 7); assert.ok(f.bottleNode.scaleX > 1);
|
|
assert.equal(f.flights.length, 0, 'holding bottle must not emit');
|
|
f.opening.dispose(); expectReleased(f);
|
|
}
|
|
});
|
|
|
|
test('release starts original RainbowTrail at the bottle center, retains B arc, and fades bottle in 0.15 seconds', () => {
|
|
let lands = 0; const f = setup(() => lands++);
|
|
assert.equal(f.opening.update(releaseTime), false);
|
|
assert.equal(f.flights.length, 1);
|
|
const from = f.parent.convertToNodeSpaceAR(v2(540, 960));
|
|
near(f.trail.node.x, from.x); near(f.trail.node.y, from.y);
|
|
assert.equal(f.bottleNode.opacity, 255);
|
|
f.opening.update(fadeTime / 2); assert.ok(f.bottleNode.opacity >= 127 && f.bottleNode.opacity <= 128);
|
|
f.opening.update(castTime / 2 - fadeTime / 2);
|
|
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);
|
|
assert.equal(f.bottleNode.active, false);
|
|
f.to.x = 999; // The destination is fixed at launch.
|
|
assert.equal(f.opening.update(castTime / 2 + boundaryEpsilon), false);
|
|
assert.equal(lands, 1); near(f.trail.node.x, 160); near(f.trail.node.y, 120);
|
|
assert.equal(f.flights.length, 1);
|
|
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(releaseTime + castTime + boundaryEpsilon), false);
|
|
f.particles[0].particleCount = 0;
|
|
assert.equal(f.opening.update(5), false);
|
|
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.bottleNode.x, f.bottleNode.y);
|
|
assert.equal(f.opening.update(100, true), false);
|
|
assert.equal(f.bottleNode.active, false); assert.equal(f.bottleNode.x, start.x); assert.equal(f.bottleNode.y, start.y);
|
|
assert.equal(f.flights.length, 0); assert.equal(lands, 0);
|
|
assert.equal(f.opening.update(releaseTime + castTime + boundaryEpsilon, false), false); assert.equal(lands, 1);
|
|
f.particles.forEach(p => p.particleCount = 0);
|
|
assert.equal(f.opening.update(100, true), false);
|
|
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(0.3);
|
|
const before = v2(f.bottleNode.x, f.bottleNode.y);
|
|
f.emit('hide');
|
|
assert.equal(f.bottleNode.active, false);
|
|
assert.equal(f.opening.update(100), false);
|
|
assert.equal(f.bottleNode.x, before.x); assert.equal(f.bottleNode.y, before.y); assert.equal(lands, 0); assert.equal(f.flights.length, 0);
|
|
f.emit('show'); f.opening.update(releaseTime - 0.3 + castTime / 2);
|
|
const trailBefore = v2(f.trail.node.x, f.trail.node.y);
|
|
f.emit('hide'); assert.equal(f.trail.node.active, false); f.opening.update(100);
|
|
assert.equal(f.trail.node.x, trailBefore.x); assert.equal(f.trail.node.y, trailBefore.y);
|
|
f.emit('show'); f.opening.update(castTime / 2 + boundaryEpsilon);
|
|
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('disposal in entry, hold, or particle flight cancels reward and frees resources and event handlers', () => {
|
|
for (const time of [0.3, enterTime + holdTime / 2, releaseTime + castTime / 2]) {
|
|
let lands = 0; const f = setup(() => lands++);
|
|
f.opening.update(time);
|
|
f.opening.dispose(); f.opening.dispose(); expectReleased(f);
|
|
f.emit('show'); assert.equal(f.bottleNode.active, false);
|
|
assert.equal(f.opening.update(100), true); assert.equal(lands, 0);
|
|
}
|
|
});
|
|
|
|
test('large frame gaps cross entry, hold, and cast once without losing the elapsed remainder', () => {
|
|
let lands = 0; const f = setup(() => lands++);
|
|
f.opening.update(100);
|
|
assert.equal(f.flights.length, 1); assert.equal(lands, 1); assert.equal(f.bottleNode.opacity, 0);
|
|
near(f.trail.node.x, f.to.x); near(f.trail.node.y, f.to.y);
|
|
f.opening.update(100); assert.equal(lands, 1); assert.equal(f.flights.length, 1);
|
|
f.opening.dispose(); expectReleased(f);
|
|
});
|
|
|
|
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(releaseTime + castTime + boundaryEpsilon), 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);
|
|
});
|