158 lines
9.3 KiB
JavaScript
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);
|
|
});
|