1211 lines
72 KiB
JavaScript
1211 lines
72 KiB
JavaScript
// TYPESCRIPT_PATH may point to the TypeScript bundled with Cocos Creator.
|
|
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 read = file => fs.readFileSync(path.join(root, file), 'utf8').replace(/^\uFEFF/, '');
|
|
const json = file => JSON.parse(read(file));
|
|
const plain = value => JSON.parse(JSON.stringify(value));
|
|
const v2 = (x = 0, y = 0) => ({ x, y, clone() { return v2(this.x, this.y); }, mag() { return Math.hypot(this.x, this.y); } });
|
|
function load(file, cc = {}, dependencies = {}) {
|
|
const mod = { exports: {} };
|
|
vm.runInNewContext(ts.transpileModule(read(file), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText,
|
|
{ module: mod, exports: mod.exports, cc, require: name => { assert.ok(dependencies[name], name); return dependencies[name]; }, console, Set, Math });
|
|
return mod.exports;
|
|
}
|
|
function method(file, name, globals = {}) {
|
|
const source = ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true);
|
|
const cls = source.statements.find(node => ts.isClassDeclaration(node));
|
|
const member = cls.members.find(node => node.name && node.name.getText(source) === name);
|
|
assert.ok(member, name);
|
|
const code = ts.transpileModule('class Subject {' + member.getText(source) + '}\nSubject;',
|
|
{ compilerOptions: { target: ts.ScriptTarget.ES2017 } }).outputText;
|
|
const result = vm.runInNewContext(code, { cc: { v2 }, console, ...globals });
|
|
return result.prototype[name] || result[name];
|
|
}
|
|
const rules = load('assets/Script/RainbowRules.ts');
|
|
const blockFile = 'assets/Script/Block.ts', mapFile = 'assets/Script/Map.ts';
|
|
const initBlocks = method(blockFile, 'initBlocks');
|
|
const pointsKey = points => points.map(p => `${p.x},${p.y}`).sort().join(';');
|
|
|
|
test('all 23 shapes match Block.initBlocks; each legal cut preserves every cell and leaves one connected prefab', () => {
|
|
for (let shape = 0; shape < 23; shape++) {
|
|
const block = { block_Info: { block: shape } }; initBlocks.call(block);
|
|
assert.equal(pointsKey(rules.RAINBOW_SHAPES[shape]), pointsKey(block.allBlocks));
|
|
const cuts = rules.rainbowCuts(block.allBlocks);
|
|
assert.equal(cuts.length > 0, shape !== 0);
|
|
for (const cut of cuts) {
|
|
const remainder = rules.RAINBOW_SHAPES[cut.shape].map(p => ({ x: p.x + cut.offset.x, y: p.y + cut.offset.y }));
|
|
assert.equal(pointsKey([...remainder, cut.cell]), pointsKey(block.allBlocks));
|
|
assert.equal(new Set([...remainder, cut.cell].map(p => `${p.x},${p.y}`)).size, block.allBlocks.length);
|
|
const connected = new Set([0]);
|
|
for (let i = 0; i < remainder.length; i++) remainder.forEach((p, j) => {
|
|
if ([...connected].some(k => Math.abs(p.x - remainder[k].x) + Math.abs(p.y - remainder[k].y) === 1)) connected.add(j);
|
|
});
|
|
assert.equal(connected.size, remainder.length);
|
|
assert.ok(fs.existsSync(path.join(root, `assets/resources/prefab/block/block${cut.shape}.prefab`)));
|
|
}
|
|
}
|
|
assert.equal(rules.rainbowCuts(rules.RAINBOW_SHAPES[3]).some(cut => cut.cell.x === -1), false);
|
|
});
|
|
|
|
test('pool priority matches agreed order, including composite properties', () => {
|
|
const samples = [
|
|
[0, {}, 0], [2, {}, 1], [13, {}, 1], [14, {}, 1], [5, {}, 1], [21, {}, 1],
|
|
[7, {}, 2], [8, {}, 2], [20, {}, 2], [4, {}, 3], [16, {}, 3],
|
|
[3, {}, 4], [12, {}, 4], [0, { lock: false }, 4], [6, {}, 5], [17, {}, 5],
|
|
[0, { floor: 1 }, 6], [1, {}, 7], [9, {}, 8], [9, { floorMove: true }, 9],
|
|
[2, { floor: 1, lock: true }, 6], [1, { floorMove: true }, 9]
|
|
];
|
|
for (const [type, block_Info, expected] of samples) assert.equal(rules.rainbowPriority({ type, block_Info, teamBlocks: [{}, {}] }), expected);
|
|
assert.equal(rules.rainbowPriority({ type: 0, flowerType: 1 }), 1);
|
|
assert.equal(rules.rainbowPriority({ type: 0, flowerType: 2 }), 6);
|
|
});
|
|
|
|
test('random selection is uniform per block first, then per legal cell', () => {
|
|
const a = { priority: 0, cuts: rules.rainbowCuts(rules.RAINBOW_SHAPES[1]) };
|
|
const b = { priority: 0, cuts: rules.rainbowCuts(rules.RAINBOW_SHAPES[5]) };
|
|
const worse = { priority: 1, cuts: a.cuts };
|
|
const counts = new Map();
|
|
for (let i = 0; i < 100; i++) for (let j = 0; j < 100; j++) {
|
|
const randoms = [(i + .5) / 100, (j + .5) / 100];
|
|
const result = rules.chooseRainbowTarget([a, b, worse], () => randoms.shift());
|
|
const key = [a, b, worse].indexOf(result.target) + ':' + result.target.cuts.indexOf(result.cut);
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
}
|
|
assert.deepEqual([...counts.values()].sort((a, b) => a - b), [1250, 1250, 1250, 1250, 2500, 2500]);
|
|
});
|
|
|
|
test('energy capacity, pause, removal gain during freeze, discard overflow, and spawn recovery', () => {
|
|
const capacity = rules.RAINBOW_CAPACITY, blockEnergy = rules.RAINBOW_BLOCK_ENERGY[3];
|
|
const energy = new rules.RainbowEnergy();
|
|
assert.equal(energy.consume(), true);
|
|
energy.tick(20, false); assert.equal(energy.value, 0);
|
|
energy.eliminated(3); assert.equal(energy.value, blockEnergy);
|
|
energy.tick(capacity - blockEnergy - 1, true); assert.equal(energy.value, capacity - 1);
|
|
energy.eliminated(5); assert.equal(energy.value, capacity);
|
|
assert.equal(energy.consume(), true); assert.equal(energy.consume(), false);
|
|
energy.tick(5, true); energy.setAvailability(false, true);
|
|
energy.tick(100, true); energy.eliminated(5); assert.equal(energy.value, 5);
|
|
energy.setAvailability(true, false); energy.tick(capacity - 5, true); assert.equal(energy.value, capacity);
|
|
energy.setAvailability(false, false); energy.setAvailability(true, true);
|
|
assert.equal(energy.stopped, true); assert.equal(energy.consume(), false);
|
|
});
|
|
|
|
test('all reachable shape textures exist, including stack colors, question shells and switches', () => {
|
|
const defs = Array.from({ length: 23 }, (_, block) => ({ block, type: 1, color: 2, stacking: 7, colorArray: 34, lock: false }));
|
|
defs.push({ block: 18, type: 16, color: 8 });
|
|
const paths = rules.rainbowTexturePaths(defs);
|
|
for (const frame of paths) assert.ok(fs.existsSync(path.join(root, 'assets/Block', frame + '.png')), frame);
|
|
for (const frame of ['2color0', '7color0', '4color0', '5color0', 'question0', '11color0', '12color0']) assert.ok(paths.includes(frame), frame);
|
|
assert.equal(paths.some(frame => frame.startsWith('100color')), false);
|
|
});
|
|
|
|
test('rainbow passes any color only when door state allows, without color side effects', () => {
|
|
const passWall = method(mapFile, 'passWall');
|
|
const map = { checkColor() { throw Error('rainbow changed colors'); } };
|
|
const node = { getComponent: () => ({ color: 100, type: 0 }) };
|
|
function gate(overrides = {}, length = false) {
|
|
const wall = { wall_Info: { color: 3, length: 1, special: 0 }, color: 3, colorArray: [3, 4], special: 0, specialBackup: 0, toggleOpen: true,
|
|
open: true, changeColorWall() { throw Error('changed door color'); }, ...overrides };
|
|
return { getComponent: () => wall, parent: { getChildByName: () => ({ active: length }) } };
|
|
}
|
|
assert.equal(passWall.call(map, true, [gate()], node), true);
|
|
// createWall includes plain walls in each direction array; only configured doors may eliminate.
|
|
for (const wall_Info of [null, undefined, { color: 3, length: 1, stickLength: 2 }]) {
|
|
assert.equal(passWall.call(map, true, [gate({ wall_Info })], node), false);
|
|
assert.equal(passWall.call(map, true, [gate(), gate({ wall_Info })], node), false);
|
|
}
|
|
// Secondary cells of a multi-cell door legitimately have length 0.
|
|
assert.equal(passWall.call(map, true, [gate({ wall_Info: { color: 3, length: 0 } })], node), true);
|
|
for (const state of [{ special: 2, open: false }, { special: 3 }, { specialBackup: 3 }, { special: 4 },
|
|
{ special: 5 }, { special: 6 }, { specialBackup: 6 }, { toggleOpen: false }, { jump: true }]) {
|
|
assert.equal(passWall.call(map, true, [gate(state)], node), false, JSON.stringify(state));
|
|
}
|
|
assert.equal(passWall.call(map, true, [gate({}, true)], node), false);
|
|
assert.equal(passWall.call(map, true, [gate()], { getComponent: () => ({ color: 1, type: 0 }) }), false);
|
|
let changes = 0;
|
|
const ordinary = gate({ color: 1, colorArray: [], changeColorWall() {} });
|
|
assert.equal(passWall.call({ checkColor() { changes++; } }, true, [ordinary], { getComponent: () => ({ color: 1, type: 0 }) }), true);
|
|
assert.equal(changes, 1);
|
|
});
|
|
|
|
test('rainbow passes movable monochrome blocks in both collision directions without bypassing ordinary obstacles', () => {
|
|
const file = 'assets/Script/lq_collide_system/lq_collide.ts';
|
|
const disableCollider = method(file, 'disableCollider');
|
|
const onCollide = method(file, 'on_collide', { LQCollideConfig: { switch_print_log: true } });
|
|
const canPass = method(blockFile, 'canPassSameColorSimpleBlock');
|
|
let id = 0;
|
|
const make = (color, type = 0, info = {}) => {
|
|
const b = { color, type, block_Info: info, isTouch: true, canPassSameColorSimpleBlock: canPass };
|
|
const parent = { uuid: ++id, getComponent: () => b, x: 0, width: 120 };
|
|
return { node: { name: 'left', parent }, disableCollider, block: b };
|
|
};
|
|
for (let color = 1; color <= 10; color++) {
|
|
const rainbow = make(100), simple = make(color, 22);
|
|
for (const [moving, obstacle] of [[rainbow, simple], [simple, rainbow]]) {
|
|
assert.equal(disableCollider.call(moving, obstacle), true);
|
|
onCollide.call(moving, obstacle);
|
|
assert.equal(moving.block.checkCollision, undefined, 'the overlapping tile must not stop movement');
|
|
}
|
|
assert.equal(disableCollider.call(make(color), simple), true, 'ordinary same-color access');
|
|
assert.equal(disableCollider.call(make(color % 10 + 1), simple), false, 'ordinary wrong color still collides');
|
|
const floorGroup = make(color, 0, { floorMove: true });
|
|
assert.equal(disableCollider.call(floorGroup, simple), false, 'moving floor restrictions remain');
|
|
const flower = make(color); flower.block.flowerType = 2;
|
|
assert.equal(disableCollider.call(flower, simple), false, 'flower cover restrictions remain');
|
|
assert.equal(disableCollider.call(rainbow, make(color)), false, 'rainbow cannot pass through ordinary blocks');
|
|
assert.equal(disableCollider.call(simple, make(color, 22)), false, 'two monochrome tiles still collide');
|
|
}
|
|
const rainbow = make(100);
|
|
const wall = { node: { name: 'wall', parent: { uuid: ++id, getComponent: () => null } } };
|
|
assert.equal(disableCollider.call(rainbow, wall), false);
|
|
onCollide.call(rainbow, wall);
|
|
assert.equal(rainbow.block.checkCollision, true);
|
|
assert.equal(rainbow.block.moveLeft, false);
|
|
});
|
|
|
|
test('rainbow passes fixed monochrome rise tiles through their real-map whitelist', () => {
|
|
const file = 'assets/Script/lq_collide_system/lq_collide.ts';
|
|
const map = { riseFallBlock: [] };
|
|
const disableCollider = method(file, 'disableCollider', { MapConroler: { _instance: map } });
|
|
const onCollide = method(file, 'on_collide', { LQCollideConfig: { switch_print_log: true } });
|
|
const onEnter = method(file, 'on_enter', { cc: { fx: { AudioManager: { _instance: {
|
|
playEffect() { throw Error('allowed rainbow passage played blocked sound'); }
|
|
} } } } });
|
|
const b = { color: 100, type: 0, block_Info: {}, isTouch: true };
|
|
const rainbow = { node: { name: 'left', parent: { uuid: 'rainbow', getComponent: () => b } }, disableCollider };
|
|
for (let color = 1; color <= 10; color++) {
|
|
const tile = { uuid: 'rise' + color, getComponent: () => null,
|
|
getChildByName: () => ({ getChildByName: () => ({ getComponent: () => ({ string: String(color) }) }) }) };
|
|
map.riseFallBlock = [tile];
|
|
const fixed = { node: { name: 'rise', parent: tile } };
|
|
assert.equal(disableCollider.call(rainbow, fixed), true);
|
|
onEnter.call(rainbow, fixed);
|
|
onCollide.call(rainbow, fixed);
|
|
assert.equal(b.checkCollision, undefined);
|
|
// Vines and extendable poles reuse rise; they are not color-pass tiles.
|
|
map.riseFallBlock = [];
|
|
assert.equal(disableCollider.call(rainbow, fixed), false);
|
|
}
|
|
});
|
|
|
|
let uuid = 0;
|
|
class Node {
|
|
constructor(name = '') { this.name = name; this.uuid = String(++uuid); this.children = []; this.components = {}; this.active = true; this.x = 0; this.y = 0; this.width = 120; this.height = 120; this.anchorX = 1; this.anchorY = 0; this.scaleX = this.scaleY = 1; }
|
|
get position() { return v2(this.x, this.y); }
|
|
getPosition() { return this.position; }
|
|
get parent() { return this._parent; }
|
|
set parent(node) {
|
|
if (this._parent === node) return;
|
|
if (this._parent) this._parent.children = this._parent.children.filter(child => child !== this);
|
|
this._parent = node;
|
|
if (node && !node.children.includes(this)) node.children.push(this);
|
|
}
|
|
setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }
|
|
getContentSize() { return { width: this.width, height: this.height }; }
|
|
setContentSize(size, height) { Object.assign(this, typeof size === 'number' ? { width: size, height } : size); }
|
|
getAnchorPoint() { return v2(this.anchorX, this.anchorY); }
|
|
setAnchorPoint(p) { this.anchorX = p.x; this.anchorY = p.y; }
|
|
getChildByName(name) { return this.children.find(n => n.name === name); }
|
|
getComponent(name) { return this.components[typeof name === 'string' ? name : name.name] || null; }
|
|
addComponent(name) { const c = new name(); c.node = this; this.components[name.name] = c; return c; }
|
|
addChild(child) { child.parent = this; if (!this.children.includes(child)) this.children.push(child); }
|
|
setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this); }
|
|
removeFromParent() { if (this.parent) this.parent.children = this.parent.children.filter(n => n !== this); this.parent = null; }
|
|
destroy() { this.destroyed = true; }
|
|
getNumberOfRunningActions() { return 0; }
|
|
convertToNodeSpaceAR(p) { return p.clone(); }
|
|
convertToWorldSpaceAR(p) { return p.clone(); }
|
|
}
|
|
class Sprite { constructor() { this.fillRange = 1; } }
|
|
Sprite.SizeMode = { CUSTOM: 0 };
|
|
class Texture2D {
|
|
initWithData(pixels, format, width, height) { Object.assign(this, { pixels, format, width, height }); }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
Texture2D.PixelFormat = { RGBA8888: 6 };
|
|
class SpriteFrame {
|
|
constructor(texture) { this.texture = texture; }
|
|
destroy() { this.destroyed = true; }
|
|
}
|
|
class ParticleSystem {
|
|
constructor() { this.resets = 0; this.particleCount = 0; }
|
|
resetSystem() { this.resets++; this.active = true; }
|
|
stopSystem() { this.active = false; }
|
|
}
|
|
ParticleSystem.PositionType = { FREE: 0 };
|
|
ParticleSystem.EmitterMode = { GRAVITY: 0 };
|
|
class PolygonCollider {}
|
|
const engine = { Node, Sprite, Texture2D, SpriteFrame, ParticleSystem, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b, a = 255) => ({r,g,b,a}),
|
|
Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771 }, isValid: node => !!node && !node.destroyed,
|
|
game: { on() {}, off() {}, EVENT_HIDE: 'hide', EVENT_SHOW: 'show' }, find: () => new Node('Canvas') };
|
|
const trailModule = load('assets/Script/RainbowTrail.ts', engine);
|
|
function block(shape = 1, type = 0, info = {}) {
|
|
const node = new Node('block' + shape);
|
|
const b = { node, block_Info: { block: shape, type, color: 2, ...info }, type, color: 2, posX: 5, posY: 5,
|
|
teamBlocks: [], adhesiveNode: [], allBlocks: [], isTouch: false, blockId: Number(node.uuid), initBlocks,
|
|
ice_SpriteFrame: { getSpriteFrame: name => name }, flower_SpriteFrame: { getSpriteFrame: name => name },
|
|
setHitPosition() {}, setMapBlock() {}, cmupdate() {}, getStackingPos: () => v2(-21,22) };
|
|
initBlocks.call(b); node.components.Block = b;
|
|
return b;
|
|
}
|
|
function controllerFixture(blocks = [block(0), block()], applyCut = () => {}) {
|
|
const applied = [];
|
|
const Controller = load('assets/Script/RainbowStreak.ts', engine,
|
|
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => { applied.push(args); return applyCut(...args); } }, './RainbowTrail': trailModule,
|
|
'./RainbowBottleWater': { default: class RainbowBottleWater {} } }).default;
|
|
const map = { blocks: blocks.map(b => b.node), gameStart: true, scheduleCallback() {}, timeNumber: 100,
|
|
hasPendingSpawnGateWork: () => false, isPauseOpen: () => false, iceTrue: () => false, magicMask: { active: false } };
|
|
map.node = new Node('Map'); map.node.children = map.blocks;
|
|
const bottle = new Node('bottle'); bottle.addComponent(Sprite);
|
|
const controller = new Controller(map, bottle);
|
|
return { controller, map, bottle, applied, blocks };
|
|
}
|
|
|
|
function startCast(f) { f.controller.first = false; f.controller.update(0); }
|
|
|
|
test('elimination credits each block once by its current cell count, including cut remainders and rainbow cells', () => {
|
|
for (const [shape, seconds] of [[0, 2], [1, 2.5], [3, 2.8], [5, 3], [18, 3.2]]) {
|
|
const b = block(shape), f = controllerFixture([b, block()]);
|
|
f.controller.energy.consume();
|
|
f.controller.eliminated(b.node);
|
|
f.controller.eliminated(b.node);
|
|
assert.equal(f.controller.energy.value, seconds, 'shape ' + shape);
|
|
}
|
|
const f = cutFixture(18), c = controllerFixture([f.b]);
|
|
c.controller.energy.consume();
|
|
const rainbow = f.cut.applyRainbowCut(f.map, f.b, rules.rainbowCuts(f.b.allBlocks)[0]);
|
|
assert.equal(c.controller.energy.value, 0, 'cutting itself grants no energy');
|
|
assert.equal(f.b.allBlocks.length, 4);
|
|
c.controller.eliminated(f.b.node);
|
|
assert.equal(c.controller.energy.value, 3, 'remainder uses four cells, not the original five');
|
|
c.controller.eliminated(rainbow);
|
|
assert.equal(c.controller.energy.value, 5, 'rainbow adds the one-cell reward');
|
|
});
|
|
|
|
function inputMethods(f, blocks = f.blocks) {
|
|
const BlockType = { 普通块: 0, 叠加块下: 1, 钥匙块: 2, 上锁块: 3, 冻结块: 4, 粘合块: 9, 第二上锁块: 12, 单色地块: 22 };
|
|
const globals = { MapConroler: { _instance: f.map }, BlockType,
|
|
cc: { v2, Intersection: { pointInPolygon: () => true }, fx: { AudioManager: { _instance: { playEffect() {} } } } },
|
|
LQCollideSystem: { update_logic() {} } };
|
|
Object.assign(f.map, { rainbowStreak: f.controller, total_steps_count: 0, startUpdate() {}, changeRiseFall() {}, downDoor() {}, removeOneBlock() {} });
|
|
for (const b of blocks) {
|
|
b.node.parent = f.map.node;
|
|
Object.assign(b, { collider: { world: { points: [] } }, hit: b.hit || new Node('hit'), maxSpeed: 300,
|
|
setVibrate() {}, resetStartPos: () => false, blockFall() {}, touchDelta: v2() });
|
|
}
|
|
return Object.fromEntries(['touchStart', 'touchMove', 'touchEnd', 'update'].map(name => [name, method(blockFile, name, globals)]));
|
|
}
|
|
|
|
test('defeat greys the bottle, blocks both energy sources and full casts, and revival restores saved energy', () => {
|
|
for (const energy of [12, rules.RAINBOW_CAPACITY]) {
|
|
const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = energy;
|
|
f.map.gameOver = true;
|
|
f.controller.update(60);
|
|
f.controller.eliminated(block(3).node);
|
|
assert.equal(f.controller.energy.value, energy);
|
|
assert.equal(f.controller.casting, false);
|
|
assert.deepEqual(f.bottle.color, engine.color(110, 110, 110));
|
|
assert.equal(f.controller.energy.stopped, false, 'defeat must not permanently disable the bottle');
|
|
f.map.gameOver = false;
|
|
f.controller.update(0);
|
|
assert.equal(f.bottle.color, engine.Color.WHITE);
|
|
assert.equal(f.controller.casting, energy === rules.RAINBOW_CAPACITY);
|
|
if (energy < rules.RAINBOW_CAPACITY) {
|
|
assert.equal(f.bottle.getComponent(Sprite).fillRange, energy / rules.RAINBOW_CAPACITY);
|
|
f.controller.update(1);
|
|
assert.equal(f.controller.energy.value, energy + 1);
|
|
}
|
|
}
|
|
const f = controllerFixture(); startCast(f);
|
|
const target = f.controller.selection.block;
|
|
f.map.gameOver = true; f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0, 'a flight cannot finish cutting after defeat');
|
|
|
|
f.map.gameOver = false; f.controller.update(1);
|
|
assert.equal(f.controller.energy.value, 1);
|
|
assert.equal(f.controller.casting, false);
|
|
});
|
|
|
|
test('button colors protect all matching blocks and either stack layer, and color changes refresh the pool', () => {
|
|
const a = block(), b = block(), stack = block(1, 1), upper = block(1, 10);
|
|
a.color = 2; b.color = 3; stack.color = 4; upper.color = 2;
|
|
stack.block_Info.node = upper.node;
|
|
const f = controllerFixture([a, b, stack]);
|
|
const button = new Node('button'); button.components.MapBlock = { color: 2, nowColor: 2 };
|
|
f.map.buttonBlock = [button];
|
|
assert.deepEqual(Array.from(f.controller.candidates(), c => c.block), [b]);
|
|
upper.color = 5;
|
|
assert.deepEqual(Array.from(f.controller.candidates(), c => c.block), [b, stack]);
|
|
b.color = stack.color = 2;
|
|
f.controller.first = false; f.controller.energy.value = 15; f.controller.update(10);
|
|
assert.equal(f.controller.energy.unavailable, true);
|
|
assert.equal(f.controller.energy.stopped, false, 'protected colors may change later');
|
|
assert.equal(f.controller.energy.value, 15);
|
|
b.color = 3; f.controller.update(1);
|
|
assert.equal(f.controller.energy.unavailable, false);
|
|
assert.equal(f.controller.energy.value, 16);
|
|
f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(0);
|
|
assert.equal(f.controller.selection.block, b);
|
|
b.color = 2; f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0, 'a target that becomes a button color in flight must not be cut');
|
|
});
|
|
|
|
test('vine initialization uses a separate collision marker and still blocks ordinary and rainbow blocks', () => {
|
|
const tile = new Node('tile'); tile.components.MapBlock = { block_Id: '' };
|
|
const rise = new Node('rise'), fall = new Node('risefall'), label = new Node('color');
|
|
label.components.Label = { string: '' }; fall.addChild(label); tile.addChild(fall); tile.addChild(rise);
|
|
const cc = { ...engine, Label: 'Label' };
|
|
const map = { mapWidth: 1, mapHeight: 1, mapBlocksWall: [[tile]], riseFallBlock: [],
|
|
isVineInfo: method(mapFile, 'isVineInfo'), getVineIconName: () => 'single', vinePrefabWarningShown: true };
|
|
method(mapFile, 'initVine', { cc }).call(map, [{ x: 0, y: 0, vine: 1 }]);
|
|
assert.equal(label.components.Label.string, '101');
|
|
assert.equal(tile.components.MapBlock.block_Id, 'Vine');
|
|
assert.equal(map.vineBlock.length, 1);
|
|
const file = 'assets/Script/lq_collide_system/lq_collide.ts';
|
|
const disableCollider = method(file, 'disableCollider', { MapConroler: { _instance: map } });
|
|
const onCollide = method(file, 'on_collide', { cc, LQCollideConfig: { switch_print_log: true } });
|
|
for (const color of [1, 2, 10, 100]) {
|
|
const b = block(); b.color = color; b.isTouch = true; b.moveLeft = true;
|
|
const side = new Node('left'); b.node.addChild(side);
|
|
const moving = { node: side, disableCollider };
|
|
onCollide.call(moving, { node: rise });
|
|
assert.equal(b.moveLeft, false, 'vine must block color ' + color);
|
|
assert.equal(b.checkCollision, true);
|
|
}
|
|
});
|
|
|
|
test('rainbow passage hints lower every usable color and restore fixed tiles on release', () => {
|
|
const changeRiseFall = method(mapFile, 'changeRiseFall', { cc: { Label: 'Label' } });
|
|
const tiles = [1, 2, 3].map(color => {
|
|
const tile = new Node('tile'), fall = new Node('risefall'), up = new Node('riseup'), label = new Node('color');
|
|
label.components.Label = { string: String(color) }; fall.addChild(label); tile.addChild(fall); tile.addChild(up);
|
|
fall.active = false; return tile;
|
|
});
|
|
const map = { riseFallBlock: tiles };
|
|
changeRiseFall.call(map, 100, true);
|
|
assert.ok(tiles.every(tile => tile.getChildByName('risefall').active && !tile.getChildByName('riseup').active));
|
|
changeRiseFall.call(map, 100, false);
|
|
assert.ok(tiles.every(tile => !tile.getChildByName('risefall').active && tile.getChildByName('riseup').active));
|
|
changeRiseFall.call(map, 2, true);
|
|
assert.deepEqual(tiles.map(tile => tile.getChildByName('risefall').active), [false, true, false]);
|
|
|
|
const downDoor = method(mapFile, 'downDoor'), passWall = method(mapFile, 'passWall');
|
|
function gate(overrides = {}, length = false) {
|
|
const wall = { color: 3, special: 0, specialBackup: 0, colorArray: [], wall_Info: { color: 3, length: 1 },
|
|
downCount: 0, downDoor() { this.downCount++; }, ...overrides };
|
|
const node = { getComponent: () => wall };
|
|
const parent = { getChildByName: name => name === 'wall' ? node : { active: length } }; node.parent = parent;
|
|
return { wall, node, parent };
|
|
}
|
|
const gates = [gate({ color: 1 }), gate({ color: 2 }), gate(), gate({ special: 1 }), gate({ wall_Info: null }),
|
|
gate({ wall_Info: { stickLength: 2 } }), gate({}, true), gate({ special: 2, open: false }),
|
|
gate({ special: 2, open: true }), ...[3,4,5,6].map(special => gate({ special })),
|
|
gate({ specialBackup: 3 }), gate({ specialBackup: 6 }), gate({ toggleOpen: false }), gate({ jump: true })];
|
|
downDoor.call({ wallArray: gates.map(g => g.parent) }, 100, 0);
|
|
const rainbow = { getComponent: () => ({ color: 100, type: 0 }) };
|
|
for (const g of gates) assert.equal(g.wall.downCount > 0, passWall.call({}, true, [g.node], rainbow));
|
|
const ordinary = [gate({ color: 1 }), gate({ color: 2 })];
|
|
downDoor.call({ wallArray: ordinary.map(g => g.parent) }, 2, 0);
|
|
assert.deepEqual(ordinary.map(g => g.wall.downCount), [0, 1]);
|
|
});
|
|
|
|
test('bottle wave attaches at runtime and keeps current energy while its material loads', () => {
|
|
assert.ok(controllerFixture().bottle.getComponent('RainbowBottleWater'));
|
|
const texture = { width: 225, height: 169, packable: true };
|
|
const uniforms = {};
|
|
const material = { setProperty: (name, value) => { uniforms[name] = plain(value); },
|
|
define: (name, value) => { uniforms[name] = value; } };
|
|
const sprite = { type: 3, fillRange: .2,
|
|
spriteFrame: { getTexture: () => texture, getRect: () => ({ x: 0, y: 0, width: 225, height: 169 }), isRotated: () => false },
|
|
setMaterial(index, value) { assert.equal(index, 0); this.material = value; }, getMaterial() { return this.material; } };
|
|
let finish;
|
|
const cc = { Sprite: { Type: { SIMPLE: 0 } }, Material: class Material {}, isValid: value => !value.destroyed,
|
|
sys: { glExtension: () => true },
|
|
resources: { load(url, type, callback) { assert.equal(url, 'shader/rainbow_bottle'); assert.equal(type, cc.Material); finish = callback; } } };
|
|
const file = 'assets/Script/RainbowBottleWater.ts';
|
|
const water = { getComponent: () => sprite, node: { height: 169 }, elapsed: 0, waveHeight: 4, waveSpeed: 1,
|
|
lateUpdate: method(file, 'lateUpdate') };
|
|
const onLoad = method(file, 'onLoad', { cc });
|
|
onLoad.call(water);
|
|
assert.equal(texture.packable, false);
|
|
assert.equal(sprite.type, 3, 'retain normal energy rendering until material is ready');
|
|
water.lateUpdate(1);
|
|
sprite.fillRange = .5;
|
|
finish(null, material);
|
|
assert.equal(sprite.type, 0);
|
|
assert.equal(uniforms.CC_SUPPORT_standard_derivatives, true);
|
|
assert.deepEqual(uniforms.uvRect, [0, 0, 1, 1]);
|
|
assert.equal(uniforms.water[0], .5, 'use latest energy, not value from load start');
|
|
water.lateUpdate(1.2);
|
|
assert.equal(uniforms.water[1], 1.2, 'wave advances while energy stays still');
|
|
assert.equal(sprite.fillRange, .5);
|
|
sprite.type = 3;
|
|
onLoad.call(water);
|
|
water.destroyed = true;
|
|
finish(null, material);
|
|
assert.equal(sprite.type, 3, 'ignore material completion after scene exit');
|
|
});
|
|
|
|
test('the first click casts immediately; later casts wait for release and selected blocks remain movable', () => {
|
|
const f = controllerFixture(); const [first, target] = f.blocks;
|
|
const input = inputMethods(f);
|
|
const press = { getLocation: () => v2() };
|
|
assert.equal(input.touchStart.call(first, press), true);
|
|
assert.equal(first.isTouch, true);
|
|
assert.equal(f.controller.casting, true, 'first cast must start inside touchStart before release or update');
|
|
assert.equal(f.controller.energy.value, 0);
|
|
input.touchEnd.call(first, press); f.controller.update(0);
|
|
assert.equal(f.controller.selection.block, target);
|
|
assert.equal(f.controller.energy.value, 0);
|
|
assert.equal(input.touchStart.call(target, press), true);
|
|
input.touchMove.call(target, { getLocation: () => v2(60, 30), getDelta: () => v2() });
|
|
input.update.call(target, 1 / 60);
|
|
assert.ok(target.node.x > 0 && target.node.y > 0, 'normal drag updates the selected block position');
|
|
assert.equal(target.posX, f.controller.selection.x, 'grid coordinates have not caught up with dragging');
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0);
|
|
assert.equal(f.controller.casting, false);
|
|
assert.equal(target.block_Info.block, 1);
|
|
f.controller.energy.value = rules.RAINBOW_CAPACITY;
|
|
f.controller.update(1);
|
|
assert.equal(f.controller.casting, false, 'a later full bottle still waits while held');
|
|
input.touchEnd.call(target, press); f.controller.update(0);
|
|
assert.equal(f.controller.casting, true);
|
|
assert.equal(f.map.pause, undefined);
|
|
});
|
|
|
|
test('first cast avoids the held block; subsequent full casts wait for release, settling and other held blocks', () => {
|
|
const target = block(), f = controllerFixture([target]);
|
|
const input = inputMethods(f), press = { getLocation: () => v2() };
|
|
input.touchStart.call(target, press);
|
|
assert.equal(f.controller.energy.value, 0, 'only the held block remains, so the first cast is spent without a target');
|
|
f.controller.energy.value = rules.RAINBOW_CAPACITY;
|
|
f.controller.update(10);
|
|
assert.equal(f.controller.casting, false);
|
|
input.touchEnd.call(target, press);
|
|
target.node.getNumberOfRunningActions = () => 1;
|
|
f.controller.update(0); assert.equal(f.controller.casting, false);
|
|
target.node.getNumberOfRunningActions = () => 0;
|
|
// Movable obstacles can be outside the victory/candidate array, but their touch still delays a cast.
|
|
const obstacle = block(0); obstacle.color = 11; obstacle.isTouch = true;
|
|
f.map.node.children = [...f.map.blocks, obstacle.node];
|
|
f.controller.update(0); assert.equal(f.controller.casting, false);
|
|
obstacle.isTouch = false; f.controller.update(0);
|
|
assert.equal(f.controller.selection.block, target);
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 1);
|
|
});
|
|
|
|
test('a valid target press permanently misses even after a same-frame release or returning to the original position', () => {
|
|
for (const moved of [false, true]) {
|
|
const f = controllerFixture(), target = f.blocks[1];
|
|
const input = inputMethods(f), press = { getLocation: () => v2() };
|
|
startCast(f);
|
|
const origin = plain(target.node.position);
|
|
assert.equal(input.touchStart.call(target, press), true);
|
|
assert.equal(f.controller.selection.missed, true);
|
|
if (moved) { target.node.x += 120; target.node.setPosition(origin); }
|
|
input.touchEnd.call(target, press);
|
|
assert.equal(target.isTouch, false);
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0, 'release or returning cannot restore a spent cast');
|
|
assert.equal(f.controller.energy.value, rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(target.block_Info.block, 1);
|
|
f.controller.energy.value = rules.RAINBOW_CAPACITY;
|
|
f.controller.update(0);
|
|
assert.equal(f.controller.selection.missed, false, 'only the next cast starts with a clean flag');
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 1);
|
|
}
|
|
});
|
|
|
|
test('first-click exclusion and permanent misses cover forward, reverse, adhesive, stacked and chained links', () => {
|
|
for (const relation of ['team', 'reverse', 'adhesive', 'stack', 'chain']) {
|
|
const target = block(), member = block(0), bridge = block(0);
|
|
if (relation === 'team') target.teamBlocks = [target.node, member.node];
|
|
if (relation === 'reverse') member.teamBlocks = [target.node, member.node];
|
|
if (relation === 'adhesive') { target.type = 9; target.block_Info.node = member.node; }
|
|
if (relation === 'stack') { target.type = 1; member.type = 10; target.block_Info.node = member.node; }
|
|
if (relation === 'chain') { target.teamBlocks = [bridge.node]; member.type = 9; member.block_Info.node = bridge.node; }
|
|
const f = controllerFixture([target, member, bridge]);
|
|
member.isTouch = true; f.controller.onPress(member);
|
|
assert.equal(f.controller.casting, false, relation + ': first gesture excludes the whole group');
|
|
assert.equal(f.controller.energy.value, 0);
|
|
member.isTouch = false; f.controller.energy.value = rules.RAINBOW_CAPACITY; f.controller.update(0);
|
|
assert.equal(f.controller.selection.block, target);
|
|
member.isTouch = true; f.controller.onPress(member); member.isTouch = false;
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0, relation + ': indirect press is remembered after release');
|
|
}
|
|
});
|
|
|
|
test('unrelated interactions do not cancel a flight; group relinking or settling invalidates it permanently', () => {
|
|
for (const change of ['unrelated_press', 'unrelated_animation', 'links', 'settling']) {
|
|
const target = block(), member = block(0), other = block(0);
|
|
target.teamBlocks = [member.node];
|
|
const f = controllerFixture([other, target, member]);
|
|
startCast(f);
|
|
if (change === 'unrelated_press') { other.isTouch = true; f.controller.onPress(other); other.isTouch = false; }
|
|
if (change === 'unrelated_animation') other.node.getNumberOfRunningActions = () => 1;
|
|
if (change === 'links') { target.teamBlocks = []; member.teamBlocks = [target.node]; }
|
|
if (change === 'settling') member.node.getNumberOfRunningActions = () => 1;
|
|
f.controller.update(.1);
|
|
const missed = change === 'links' || change === 'settling';
|
|
assert.equal(f.controller.selection.missed, missed);
|
|
target.teamBlocks = [member.node]; member.teamBlocks = [];
|
|
member.node.getNumberOfRunningActions = () => 0;
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS - .1);
|
|
assert.equal(f.applied.length, missed ? 0 : 1, change);
|
|
}
|
|
});
|
|
|
|
test('flight keeps its original destination; positional fallback still protects non-input movement', () => {
|
|
for (const state of ['dragging', 'released_elsewhere', 'returned', 'replaced']) {
|
|
const f = controllerFixture(), target = f.blocks[1];
|
|
const parent = new Node('effects');
|
|
f.map.magics = new Node('magics'); parent.addChild(f.map.magics);
|
|
f.map.mapBlocksWall = Array.from({ length: 10 }, (_, x) => Array.from({ length: 10 }, (_, y) => {
|
|
const cell = new Node('cell'); cell.setPosition(x * 120, y * 120); parent.addChild(cell); return cell;
|
|
}));
|
|
startCast(f);
|
|
const destination = plain(f.controller.to), trail = f.controller.flight;
|
|
target.node.x += 120;
|
|
if (state === 'dragging') target.isTouch = true;
|
|
if (state === 'released_elsewhere') target.posX++;
|
|
if (state === 'returned') target.node.x -= 120;
|
|
if (state === 'replaced') f.map.blocks = [f.blocks[0].node, block().node];
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.deepEqual(plain(trail.node.position), destination, state);
|
|
assert.equal(f.applied.length, state === 'returned' ? 1 : 0, state);
|
|
assert.equal(f.controller.casting, false);
|
|
assert.equal(f.controller.energy.value, rules.RAINBOW_CAST_SECONDS, 'misses do not refund the cast');
|
|
}
|
|
});
|
|
|
|
test('a shifted three-cell bar cannot substitute its middle cell for the originally selected end cell', () => {
|
|
for (const moved of [false, true]) for (const released of [false, true]) {
|
|
const f = cutFixture(3);
|
|
const left = rules.rainbowCuts(f.b.allBlocks).find(cut => cut.cell.x === -2);
|
|
assert.ok(left);
|
|
const c = controllerFixture([block(0), f.b], (_map, target, cut) => f.cut.applyRainbowCut(f.map, target, cut));
|
|
c.controller.candidates = () => [{ block: f.b, priority: 0, cuts: [left] }];
|
|
startCast(c);
|
|
const originalCellX = f.b.node.x + left.cell.x * 120;
|
|
if (moved) {
|
|
f.b.node.x -= 120;
|
|
if (released) f.b.posX--;
|
|
else f.b.isTouch = true;
|
|
assert.equal(f.b.node.x - 120, originalCellX, 'the middle cell now covers the old destination');
|
|
}
|
|
c.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(c.applied.length, moved ? 0 : 1);
|
|
if (moved) {
|
|
assert.equal(f.b.block_Info.block, 3);
|
|
assert.equal(f.b.allBlocks.length, 3);
|
|
assert.equal(f.map.blocks.length, 1, 'a miss must create no rainbow or extra fragments');
|
|
} else {
|
|
assert.equal(f.b.allBlocks.length, 2);
|
|
assert.equal(f.map.blocks.length, 2, 'the unchanged end cell produces one rainbow and one remainder');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('linked floor drivers remain movable during flight; reverse-linked movement also makes the cast miss', () => {
|
|
const target = block(), driver = block(0);
|
|
driver.teamBlocks = [target.node, driver.node];
|
|
driver.moveFloorPd = true; driver.floorOffset = [v2(), v2()];
|
|
const f = controllerFixture([target, driver]);
|
|
const input = inputMethods(f);
|
|
startCast(f);
|
|
assert.equal(f.controller.selection.block, target);
|
|
assert.equal(input.touchStart.call(driver, { getLocation: () => v2() }), true);
|
|
driver.node.x = 20;
|
|
input.update.call(driver, 1 / 60);
|
|
assert.equal(target.node.x, 20, 'the original floor link still moves the target');
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0);
|
|
assert.equal(driver.moveFloorPd, true);
|
|
assert.deepEqual(driver.teamBlocks, [target.node, driver.node]);
|
|
});
|
|
|
|
test('rainbow trail pauses without restarting, fades after arrival even when bottle stops, and releases textures', () => {
|
|
const f = controllerFixture();
|
|
const parent = new Node('effects');
|
|
f.map.magics = new Node('magics'); parent.addChild(f.map.magics);
|
|
f.map.mapBlocksWall = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => {
|
|
const cell = new Node('cell'); parent.addChild(cell); return cell;
|
|
}));
|
|
startCast(f);
|
|
const trail = f.controller.flight;
|
|
const particles = trail.node.children.map(node => node.getComponent(ParticleSystem)).filter(Boolean);
|
|
const frames = particles.map(p => p.spriteFrame);
|
|
particles.forEach(p => p.particleCount = 8);
|
|
const elapsed = rules.RAINBOW_CAST_SECONDS * .4;
|
|
f.controller.update(elapsed);
|
|
const position = plain(trail.node.position);
|
|
f.map.pause = true; f.controller.update(10);
|
|
assert.equal(f.applied.length, 0);
|
|
assert.deepEqual(plain(trail.node.position), position);
|
|
assert.equal(trail.node.active, false);
|
|
f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS - elapsed);
|
|
assert.equal(f.applied.length, 1);
|
|
assert.equal(trail.node.active, true); assert.equal(trail.node.destroyed, undefined);
|
|
assert.ok(particles.every(p => !p.active && p.resets === 1));
|
|
assert.equal(trail.node.getChildByName('rainbowHead').active, false);
|
|
f.controller.energy.stopped = true;
|
|
f.controller.hide(); f.controller.update(10);
|
|
assert.equal(trail.node.destroyed, undefined);
|
|
f.controller.show(); f.controller.update(1);
|
|
assert.equal(trail.node.destroyed, undefined);
|
|
particles.forEach(p => p.particleCount = 0);
|
|
f.controller.update(.1);
|
|
assert.equal(trail.node.destroyed, true);
|
|
assert.ok(frames.every(frame => frame.destroyed && frame.texture.destroyed));
|
|
assert.equal(f.controller.fadingFlights.length, 0);
|
|
});
|
|
|
|
test('scene exit disposes an in-flight rainbow effect without applying a cut', () => {
|
|
const f = controllerFixture();
|
|
startCast(f);
|
|
const trail = f.controller.flight = new trailModule.default(new Node('effects'), rules.RAINBOW_CAST_SECONDS);
|
|
const textures = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean).map(p => p.spriteFrame.texture);
|
|
f.controller.dispose();
|
|
assert.equal(f.applied.length, 0);
|
|
assert.equal(trail.node.destroyed, true); assert.ok(textures.every(texture => texture.destroyed));
|
|
assert.equal(f.controller.casting, false);
|
|
});
|
|
|
|
test('a target eliminated during flight consumes the cast without changing another block', () => {
|
|
const f = controllerFixture();
|
|
startCast(f);
|
|
const target = f.controller.selection.block;
|
|
f.map.blocks = f.map.blocks.filter(node => node !== target.node);
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
assert.equal(f.applied.length, 0);
|
|
assert.equal(f.controller.casting, false);
|
|
assert.equal(f.controller.energy.value, 0, 'no remaining multi-cell block disables further charging');
|
|
});
|
|
|
|
test('ending a game during flight cancels the cut and preserves intrinsic floor restrictions', () => {
|
|
for (const state of ['gameOver', 'gameWin']) {
|
|
const target = block(5, 0, { floor: true, floorMove: false });
|
|
const f = controllerFixture([block(0), target]);
|
|
startCast(f);
|
|
|
|
f.map[state] = true; f.controller.update(.1);
|
|
assert.equal(f.controller.casting, false);
|
|
assert.equal(f.applied.length, 0); assert.equal(target.block_Info.floorMove, false);
|
|
}
|
|
});
|
|
|
|
test('natural floor and freeze state changes during flight are preserved', () => {
|
|
const target = block(5, 4, { floor: true, floorMove: false });
|
|
const f = controllerFixture([block(0), target]);
|
|
startCast(f);
|
|
assert.equal(target.type, 4); assert.equal(target.block_Info.floorMove, false);
|
|
// Another elimination resolves the original restrictions while the spell is in flight.
|
|
target.type = 0; target.block_Info.type = 0; target.block_Info.floorMove = true;
|
|
f.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
|
|
assert.equal(target.type, 0); assert.equal(target.block_Info.floorMove, true);
|
|
const input = inputMethods(f);
|
|
assert.equal(input.touchStart.call(target, { getLocation: () => v2() }), true);
|
|
});
|
|
|
|
test('full bottle waits for release and every countdown pause; freeze elimination adds energy once', () => {
|
|
const initial = rules.RAINBOW_CAPACITY - rules.RAINBOW_BLOCK_ENERGY[1];
|
|
const f = controllerFixture(); f.controller.first = false; f.controller.energy.value = initial;
|
|
f.map.iceTrue = () => true;
|
|
f.controller.update(10); assert.equal(f.controller.energy.value, initial);
|
|
f.controller.eliminated(f.blocks[0].node); f.controller.eliminated(f.blocks[0].node);
|
|
assert.equal(f.controller.energy.value, rules.RAINBOW_CAPACITY); assert.equal(f.controller.casting, false);
|
|
f.map.iceTrue = () => false;
|
|
for (const property of ['pause', 'shopPause', 'touchIng', 'ishammer', 'ismagic']) {
|
|
f.map[property] = true; f.controller.update(.1); assert.equal(f.controller.casting, false, property); f.map[property] = false;
|
|
}
|
|
f.controller.hide(); f.controller.update(10); assert.equal(f.controller.casting, false);
|
|
f.controller.show(); f.controller.update(.1); assert.equal(f.controller.casting, true);
|
|
});
|
|
|
|
test('single cell stacked pair permanently disables; production temporarily disables without losing energy', () => {
|
|
const lower = block(0, 1), upper = block(0, 10); lower.block_Info.node = upper.node; upper.block_Info.node = lower.node;
|
|
const f = controllerFixture([lower, upper]); assert.equal(f.controller.energy.stopped, true);
|
|
const g = controllerFixture(); g.controller.energy.value = 12; g.map.blocks = [block(0).node];
|
|
g.map.hasPendingSpawnGateWork = () => true; g.controller.update(2);
|
|
assert.equal(g.controller.energy.stopped, false); assert.equal(g.controller.energy.unavailable, true);
|
|
g.map.blocks.push(block().node); g.controller.update(0); assert.equal(g.controller.energy.unavailable, false); assert.equal(g.controller.energy.value, 12);
|
|
});
|
|
|
|
test('last rainbow remains required for victory; A retains existing timing behavior', () => {
|
|
const judgeWin = method(mapFile, 'judgeWin');
|
|
let stops = 0;
|
|
const map = { blocks: [{}], rainbowStreak: {}, hasPendingSpawnGateWork: () => false,
|
|
stopTimeCutDown() { stops++; }, stopBoom() {} };
|
|
judgeWin.call(map, 1); assert.equal(stops, 0);
|
|
map.rainbowStreak = null; judgeWin.call(map, 1); assert.equal(stops, 1);
|
|
map.rainbowStreak = {}; map.blocks = []; judgeWin.call(map, 1); assert.equal(stops, 2);
|
|
});
|
|
|
|
test('provided prefab slot, bottle, popup progress and settlement nodes are wired', () => {
|
|
const scene = json('assets/Scene/GameScene.fire');
|
|
const map = scene.find(o => o.Block_Prop);
|
|
assert.equal(map.Block_Prop[24].__uuid__, json('assets/prefab/prop/rainbow.prefab.meta').uuid);
|
|
assert.ok(scene.some(o => o._name === 'rainbow'));
|
|
for (const file of ['assets/action_bundle/prefab/winStreak2.prefab', 'assets/win/prefab/Win.prefab']) {
|
|
const nodes = json(file); assert.ok(nodes.some(o => o._name === 'progress'), file);
|
|
assert.ok(nodes.some(o => o.__type__ === 'cc.Sprite' && o._type === 3), file);
|
|
}
|
|
assert.ok(json('assets/win/prefab/Win.prefab').some(o => o._name === 'WinStreak2'));
|
|
});
|
|
|
|
test('pause exit and restart select rainbow confirmation only for B and preserve confirmation actions', () => {
|
|
const data = json('assets/pause/prefab/Pause.prefab');
|
|
const binding = data.find(o => o.win && o.exit);
|
|
assert.equal(data[binding.win2.__id__]._name, 'WinStreak2');
|
|
function inflate(index, nodes) {
|
|
const raw = data[index], node = new Node(raw._name);
|
|
nodes.set(index, node);
|
|
node.active = raw._active;
|
|
for (const r of raw._components) node.components[data[r.__id__].__type__] = data[r.__id__];
|
|
for (const r of raw._children) node.addChild(inflate(r.__id__, nodes));
|
|
return node;
|
|
}
|
|
for (const [group, forced] of [[1, false], [2, false], [3, false], [1, true]]) {
|
|
for (const power of [false, true]) {
|
|
const nodes = new Map(), root = inflate(1, nodes), calls = [];
|
|
const config = {
|
|
forceRainbowWinStreakB: forced, GM_INFO: { abTests: { layer_two: group }, winStreak: 10, otherLevel: 0 },
|
|
isRainbowWinStreak: method('assets/Script/module/Config/GameConfig.ts', 'isRainbowWinStreak'),
|
|
};
|
|
const map = { gameStart: true, trackFinishi() {}, returnHome: () => calls.push('home'), againLevel: () => calls.push('restart') };
|
|
const globals = { MapConroler: { _instance: map }, console: { log() {} }, cc: {
|
|
fx: { GameConfig: config, AudioManager: { _instance: { playEffect() {} } },
|
|
GameTool: { getUserPowerTime: () => power, setWinStreak: state => calls.push(state) } },
|
|
tween: () => ({ to() { return this; }, start() {} }),
|
|
} };
|
|
const setting = {};
|
|
for (const name of ['music', 'effect', 'vibrate', 'exit', 'win', 'win2']) setting[name] = nodes.get(binding[name].__id__);
|
|
for (const name of ['onLoad', 'clickExit', 'clickRestart', 'cancelExit', 'returnHome']) setting[name] = method('assets/Script/setUi.ts', name, globals);
|
|
setting.onLoad();
|
|
setting.clickExit();
|
|
const selected = root.getChildByName(group === 2 || forced ? 'WinStreak2' : 'pauseWin');
|
|
const other = root.getChildByName(group === 2 || forced ? 'pauseWin' : 'WinStreak2');
|
|
assert.equal(selected.active, true); assert.equal(other.active, false);
|
|
const health = selected.getChildByName('Health');
|
|
assert.equal(health.getChildByName('queding').active, power);
|
|
const click = name => {
|
|
const button = health.getChildByName(name).getComponent('cc.Button');
|
|
const event = data[button.clickEvents[0].__id__];
|
|
assert.equal(event.target.__id__, 1);
|
|
assert.equal(event._componentId, binding.__type__);
|
|
setting[event.handler]({}, event.customEventData);
|
|
};
|
|
click('timeBtn'); assert.equal(selected.active, false); assert.deepEqual(calls, []);
|
|
setting.clickExit(); click('return'); assert.deepEqual(calls, ['fail', 'home']);
|
|
setting.cancelExit(); calls.length = 0;
|
|
setting.clickRestart({}, '');
|
|
assert.equal(selected.active, true); assert.equal(health.getChildByName('queding').active, true);
|
|
click('queding'); assert.deepEqual(calls, ['fail', 'restart']);
|
|
setting.cancelExit(); config.GM_INFO.winStreak = 9;
|
|
calls.length = 0; setting.clickExit();
|
|
assert.equal(selected.active, false);
|
|
assert.equal(setting.exit.active, !power);
|
|
assert.deepEqual(calls, power ? ['home'] : []);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const [variant, group, forced] of [['A', 1, false], ['B', 2, false], ['C', 3, false], ['forced B', 1, true]]) {
|
|
test(`${variant} bottom layout keeps buttons, masks and hammer cancel aligned`, () => {
|
|
const scene = json('assets/Scene/GameScene.fire');
|
|
function copyNode(raw) {
|
|
const node = new Node(raw._name);
|
|
node.setPosition(raw._trs.array[0], raw._trs.array[1]);
|
|
node.active = raw._active;
|
|
for (const child of raw._children) node.addChild(copyNode(scene[child.__id__]));
|
|
return node;
|
|
}
|
|
const gameNode = copyNode(scene.find(o => o._name === 'GameNode'));
|
|
const bottom = gameNode.getChildByName('Bottom');
|
|
const find = (path, root) => path.split('/').reduce((node, name) => node.getChildByName(name), root);
|
|
const before = Object.fromEntries(bottom.children.map(node => [node.name, { x: node.x, y: node.y, active: node.active }]));
|
|
const config = {
|
|
GM_INFO: { abTests: { layer_two: group } }, forceRainbowWinStreakB: forced,
|
|
isRainbowWinStreak: method('assets/Script/module/Config/GameConfig.ts', 'isRainbowWinStreak'),
|
|
};
|
|
const layout = method(mapFile, 'initBottomLayout', { cc: { fx: { GameConfig: config }, find } });
|
|
const map = { node: gameNode.getChildByName('Map') };
|
|
layout.call(map);
|
|
const rainbow = group === 2 || forced;
|
|
const names = ['destroyBtn', 'timeBtn', 'magicBtn', 'returnBtn'];
|
|
const positions = names.map(name => bottom.getChildByName(name).x);
|
|
if (rainbow) assert.deepEqual(positions, names.map(name => before[name].x));
|
|
else {
|
|
assert.equal(positions.reduce((sum, x) => sum + x, 0), 0, 'four slots centered');
|
|
assert.equal(positions[1] - positions[0], 230);
|
|
assert.equal(positions[2] - positions[1], 230);
|
|
assert.equal(positions[3] - positions[2], 230);
|
|
}
|
|
for (const [follower, leader] of Object.entries({
|
|
winStreakBtn: 'destroyBtn', hammerMask: 'destroyBtn', freezeMask: 'timeBtn',
|
|
rippleShrink: 'timeBtn', magicMask: 'magicBtn', pauseBtn: 'returnBtn',
|
|
})) {
|
|
const expected = before[follower].x - before[leader].x;
|
|
const actual = bottom.getChildByName(follower).x - bottom.getChildByName(leader).x;
|
|
assert.ok(Math.abs(actual - expected) < 1e-9, follower);
|
|
}
|
|
for (const node of bottom.children) {
|
|
const pauseOffset = !rainbow && ['returnBtn', 'pauseBtn'].includes(node.name)
|
|
? before.magicBtn.y - before.returnBtn.y : 0;
|
|
assert.equal(node.y, before[node.name].y + pauseOffset, node.name + ' height');
|
|
if (!['rainbow', 'rainbowBg'].includes(node.name)) assert.equal(node.active, before[node.name].active);
|
|
if (rainbow) assert.equal(node.x, before[node.name].x, node.name + ' B position unchanged');
|
|
}
|
|
assert.equal(bottom.getChildByName('rainbow').active, false, 'water waits for eligible reward');
|
|
assert.equal(bottom.getChildByName('rainbowBg').active, rainbow);
|
|
const cancel = find('Mask/bottom/destroyBtn', gameNode);
|
|
assert.deepEqual(plain(cancel.position), plain(bottom.getChildByName('destroyBtn').position));
|
|
const after = bottom.children.map(node => [node.x, node.y]);
|
|
layout.call(map);
|
|
assert.deepEqual(bottom.children.map(node => [node.x, node.y]), after, 'reinitialization must not drift');
|
|
});
|
|
}
|
|
|
|
function geometry(shape) {
|
|
const data = json(`assets/resources/prefab/block/block${shape}.prefab`);
|
|
const b = block(shape);
|
|
const rootNode = data.find(o => o.__type__ === 'cc.Node');
|
|
function copyNode(raw) {
|
|
const node = new Node(raw._name);
|
|
node.setContentSize(raw._contentSize); node.setAnchorPoint(raw._anchorPoint);
|
|
node.active = raw._active;
|
|
if (raw._trs) node.setPosition(raw._trs.array[0], raw._trs.array[1]);
|
|
for (const reference of raw._components || []) {
|
|
const c = data[reference.__id__];
|
|
if (c.__type__ === 'cc.Sprite') node.addComponent(Sprite);
|
|
else if (c.__type__ === 'cc.PolygonCollider') node.components.PolygonCollider = { points: c.points.map(p => v2(p.x, p.y)), offset: v2((c._offset || c.offset || {}).x, (c._offset || c.offset || {}).y) };
|
|
else if (c.data_string !== undefined) node.components.lq_collide = { data_string: c.data_string };
|
|
else if (c.heng !== undefined) { b.heng = c.heng; b.shu = c.shu; }
|
|
}
|
|
for (const child of raw._children || []) node.addChild(copyNode(data[child.__id__]));
|
|
return node;
|
|
}
|
|
b.node = copyNode(rootNode); b.node.components.Block = b;
|
|
return b;
|
|
}
|
|
function cloneNode(source) {
|
|
const node = new Node(source.name); node.setPosition(source.position);
|
|
node.setContentSize(source.getContentSize()); node.setAnchorPoint(source.getAnchorPoint()); node.active = source.active;
|
|
for (const name of Object.keys(source.components)) node.components[name] = { ...source.components[name] };
|
|
source.children.forEach(child => node.addChild(cloneNode(child)));
|
|
return node;
|
|
}
|
|
function cutFixture(shape, type = 0) {
|
|
const map = { node: new Node('Map'), blocks: [], Block_Array: Array.from({length: 23}, (_, prefabShape) => ({ prefabShape })) };
|
|
map.mapBlocksWall = Array.from({length: 15}, (_, x) => Array.from({length: 15}, (_, y) => {
|
|
const cell = new Node('cell'); cell.setPosition(x * 120, y * 120);
|
|
cell.components.MapBlock = { block_Id: '', nowColor: -1 };
|
|
return cell;
|
|
}));
|
|
let effects = 0;
|
|
map.special_Treatment = map.shrinkVine = map.checkButton = () => { effects++; };
|
|
const config = Array.from({length: 23}, () => Object.fromEntries([1,2,3,4,5,6].map(n => ['pos'+n, {x:0,y:0}])));
|
|
const cc = { ...engine, fx: { GameConfig: { PROP_INFO: config } }, instantiate(source) {
|
|
if (source.prefabShape === undefined) return cloneNode(source);
|
|
const b = geometry(source.prefabShape);
|
|
b.init = function(info) {
|
|
assert.deepEqual(Object.keys(info).sort(), ['block','color','id','position','type']);
|
|
Object.assign(this, { block_Info: info, color: info.color, type: info.type });
|
|
initBlocks.call(this); this.posX = (this.node.x - 65) / 120; this.posY = (this.node.y + 60) / 120;
|
|
this.setMapBlock = place; place.call(this);
|
|
};
|
|
return b.node;
|
|
} };
|
|
const cut = load('assets/Script/RainbowCut.ts', cc, {
|
|
'./RainbowRules': rules,
|
|
'./module/Tool/BlockAssetManager': { default: { instance: { getFrame: frame => frame } } },
|
|
'./module/Tool/BlockTexturePaths': { blockTexturePath: (color, shape) => color + 'color' + shape }
|
|
});
|
|
function place() { this.allBlocks.forEach(p => Object.assign(map.mapBlocksWall[this.posX+p.x][this.posY+p.y].components.MapBlock,
|
|
{block_Id:this.node.uuid, nowColor:this.color})); }
|
|
const b = geometry(shape); b.type = type; b.block_Info.type = type;
|
|
b.node.setPosition(5*120+65, 5*120-60); map.node.addChild(b.node);
|
|
b.setMapBlock = place; place.call(b); map.blocks.push(b.node);
|
|
const hit = new Node('hit'); hit.addComponent(Sprite); b.node.addChild(hit); b.hit = hit;
|
|
return { map, b, cut, effects: () => effects };
|
|
}
|
|
|
|
test('real prefab geometry cuts keep node identity, connected occupancy and colliders before hit', () => {
|
|
let cases = 0;
|
|
for (let shape = 1; shape < 23; shape++) for (const choice of rules.rainbowCuts(rules.RAINBOW_SHAPES[shape])) {
|
|
const f = cutFixture(shape); const node = f.b.node;
|
|
const before = pointsKey(f.b.allBlocks.map(p => ({x:p.x+5,y:p.y+5})));
|
|
const rainbow = f.cut.applyRainbowCut(f.map, f.b, choice);
|
|
assert.equal(f.b.node, node); assert.equal(f.b.block_Info.block, choice.shape);
|
|
const after = f.map.blocks.flatMap(n => n.components.Block.allBlocks.map(p => ({x:n.components.Block.posX+p.x,y:n.components.Block.posY+p.y})));
|
|
assert.equal(pointsKey(after), before);
|
|
for (const n of f.map.blocks) n.components.Block.allBlocks.forEach(p => {
|
|
const b = n.components.Block;
|
|
assert.equal(f.map.mapBlocksWall[b.posX+p.x][b.posY+p.y].components.MapBlock.block_Id, n.uuid);
|
|
});
|
|
const sample = geometry(choice.shape);
|
|
assert.deepEqual(plain(node.components.PolygonCollider), plain(sample.node.components.PolygonCollider));
|
|
for (const child of node.children.filter(c => c.getComponent('lq_collide'))) assert.ok(node.children.indexOf(child) < node.children.indexOf(f.b.hit));
|
|
assert.equal(rainbow.components.Block.color, 100); assert.equal(rainbow.components.Block.type, 0);
|
|
assert.equal(f.effects(), 0); cases++;
|
|
}
|
|
assert.ok(cases > 40);
|
|
});
|
|
|
|
test('stationary floor and adhesive cuts rebuild linked offsets and collider proxies, including reverse-only floor links', () => {
|
|
const globals = { cc: { ...engine, instantiate: cloneNode }, setTimeout: callback => callback(),
|
|
BlockType: { 叠加块下: 1, 叠加块上: 10, 冻结块: 4, 粘合块: 9, 变色块: 6, 消除炸弹块: 17 },
|
|
LQCollideSystem: { update_logic() {} } };
|
|
const setMoveFloor = method(blockFile, 'setMoveFloor', globals);
|
|
const createNianHe = method(blockFile, 'createNianHe', globals);
|
|
const update = method(blockFile, 'update', globals);
|
|
const resetFloor = method(blockFile, 'resetFloor', globals);
|
|
for (const mode of ['reverse_floor', 'floor', 'adhesive']) {
|
|
const f = cutFixture(3), a = f.b, b = geometry(2);
|
|
b.node.setPosition(a.node.x + 360, a.node.y + 240);
|
|
f.map.node.addChild(b.node); f.map.blocks.push(b.node);
|
|
for (const member of [a, b]) Object.assign(member, {
|
|
setFloorSprite() {}, touchEnd() {}, setMoveFloor, createNianHe, touchDelta: v2()
|
|
});
|
|
if (mode === 'adhesive') {
|
|
a.type = b.type = 9;
|
|
a.block_Info.node = b.node; b.block_Info.node = a.node;
|
|
a.block_Info.lockTime = 7; b.block_Info.lockTime = 5;
|
|
a.createNianHe(false); b.createNianHe(false);
|
|
} else {
|
|
a.teamBlocks = b.teamBlocks = [a.node, b.node];
|
|
Object.assign(a.block_Info, { floor: 3, floorMove: true, floorTime: 2 });
|
|
Object.assign(b.block_Info, { floor: 3, floorMove: true, floorTime: 5 });
|
|
a.setMoveFloor(); b.setMoveFloor();
|
|
if (mode === 'reverse_floor') {
|
|
resetFloor.call(a);
|
|
assert.equal(a.teamBlocks.length, 0);
|
|
assert.deepEqual(b.teamBlocks, [a.node, b.node]);
|
|
}
|
|
}
|
|
const tag = mode === 'adhesive' ? '-1' : '-2';
|
|
const oldProxies = b.node.children.filter(n => n.getComponent('lq_collide')?.data_string === tag);
|
|
const originalX = a.node.x;
|
|
const cut = rules.rainbowCuts(a.allBlocks).find(c => c.offset.x !== 0);
|
|
assert.ok(cut);
|
|
const rainbow = f.cut.applyRainbowCut(f.map, a, cut);
|
|
assert.equal(a.node.x, originalX + cut.offset.x * 120);
|
|
const offset = mode === 'adhesive' ? b.adhesive : b.floorOffset[0];
|
|
assert.equal(b.node.x - offset.x, a.node.x, mode);
|
|
assert.equal(b.node.y - offset.y, a.node.y, mode);
|
|
assert.ok(oldProxies.every(n => n.destroyed), mode + ': old proxies removed');
|
|
const bases = a.node.children.filter(n => ['top','down','left','right'].includes(n.name)
|
|
&& !['-1','-2'].includes(n.getComponent('lq_collide')?.data_string));
|
|
const proxies = b.node.children.filter(n => !n.destroyed && n.getComponent('lq_collide')?.data_string === tag);
|
|
assert.equal(proxies.length, bases.length, mode + ': exactly one new proxy per base collider');
|
|
for (const base of bases) {
|
|
const proxy = proxies.find(n => n.name === base.name);
|
|
assert.equal(b.node.x + proxy.x, a.node.x + base.x);
|
|
assert.equal(b.node.y + proxy.y, a.node.y + base.y);
|
|
assert.deepEqual(proxy.getContentSize(), base.getContentSize());
|
|
}
|
|
if (mode === 'adhesive') {
|
|
assert.equal(a.block_Info.node, b.node); assert.equal(b.block_Info.node, a.node);
|
|
assert.equal(a.block_Info.lockTime, 7); assert.equal(b.block_Info.lockTime, 5);
|
|
} else {
|
|
assert.equal(b.block_Info.floorTime, 5); assert.equal(b.block_Info.floorMove, true);
|
|
assert.deepEqual(b.teamBlocks, [a.node, b.node]);
|
|
assert.equal(a.block_Info.floorTime, mode === 'reverse_floor' ? null : 2);
|
|
assert.deepEqual(Array.from(a.teamBlocks), mode === 'reverse_floor' ? [] : [a.node, b.node]);
|
|
}
|
|
const remainingPosition = a.node.position, rainbowPosition = rainbow.position;
|
|
b.isTouch = true; b.node.x += 10;
|
|
update.call(b, 1 / 60);
|
|
assert.deepEqual(plain(a.node.position), { x: remainingPosition.x + 10, y: remainingPosition.y }, mode + ': no jump to old origin');
|
|
assert.deepEqual(plain(rainbow.position), plain(rainbowPosition), 'rainbow stays independent of the group');
|
|
}
|
|
});
|
|
|
|
test('stack cuts both layers at the same cell, preserves relationships and yields exactly one rainbow', () => {
|
|
const f = cutFixture(1, 1); const upper = geometry(1); upper.type = 10;
|
|
upper.node.scaleX = upper.node.scaleY = .7; f.map.node.addChild(upper.node); f.map.blocks.push(upper.node);
|
|
f.b.block_Info.node = upper.node; upper.block_Info.node = f.b.node;
|
|
f.cut.applyRainbowCut(f.map, f.b, rules.rainbowCuts(f.b.allBlocks)[0]);
|
|
assert.equal(f.map.blocks.length, 3);
|
|
assert.equal(f.b.allBlocks.length + upper.allBlocks.length + 1, 3);
|
|
assert.equal(f.b.block_Info.node, upper.node); assert.equal(upper.block_Info.node, f.b.node);
|
|
assert.equal(upper.node.scaleX, .7); assert.equal(upper.type, 10); assert.equal(f.b.type, 1);
|
|
assert.equal(f.b.posX, upper.posX); assert.equal(f.b.posY, upper.posY);
|
|
assert.equal(f.effects(), 0);
|
|
});
|
|
|
|
test('cut retains floor and bomb component state and removes only the cut cell floor without effects', () => {
|
|
const f = cutFixture(3, 17); f.b.block_Info.floor = true;
|
|
const floors = f.b.allBlocks.map(p => {
|
|
const node = new Node('floor'); node.setPosition(-65+p.x*120,60+p.y*120);
|
|
node.components.Floor = {time:7, color:3, posX:5+p.x, posY:5+p.y}; f.b.node.addChild(node); return node;
|
|
});
|
|
const boom = new Node('boom2'); boom.components.Boom = {time:4, over:false}; f.b.node.addChild(boom);
|
|
const state = boom.components.Boom;
|
|
const choice = rules.rainbowCuts(f.b.allBlocks)[0];
|
|
f.cut.applyRainbowCut(f.map, f.b, choice);
|
|
assert.equal(boom.components.Boom, state); assert.equal(state.time, 4); assert.equal(f.b.type, 17);
|
|
assert.equal(f.b.block_Info.floor, true);
|
|
assert.equal(floors.filter(n => n.destroyed).length, 1);
|
|
floors.filter(n => !n.destroyed).forEach(n => {
|
|
assert.equal(n.components.Floor.time, 7); assert.equal(n.components.Floor.color, 3);
|
|
assert.equal(n.components.Floor.posX, f.b.posX + Math.round((n.x+65)/120));
|
|
});
|
|
assert.equal(f.effects(), 0);
|
|
});
|
|
|
|
test('finishing a cut keeps an immovable floor remainder immovable while its new rainbow accepts dragging', () => {
|
|
const f = cutFixture(5);
|
|
Object.assign(f.b.block_Info, { floor: true, floorMove: false, floorTime: 7 });
|
|
let rainbow;
|
|
const c = controllerFixture([block(0), f.b], (_map, target, choice) => {
|
|
rainbow = f.cut.applyRainbowCut(f.map, target, choice).getComponent('Block');
|
|
});
|
|
const input = inputMethods(c);
|
|
const press = { getLocation: () => v2() };
|
|
startCast(c);
|
|
|
|
assert.equal(input.touchStart.call(f.b, press), undefined, 'intrinsic immovable floor still rejects touch');
|
|
c.controller.update(rules.RAINBOW_CAST_SECONDS);
|
|
|
|
assert.equal(f.b.block_Info.floorMove, false); assert.equal(f.b.block_Info.floorTime, 7);
|
|
assert.equal(f.b.block_Info.floor, true);
|
|
const after = inputMethods(c, [f.b, rainbow]);
|
|
assert.equal(after.touchStart.call(f.b, press), undefined, 'original floor movement rule still rejects touch');
|
|
assert.notEqual(f.b.isTouch, true);
|
|
assert.equal(rainbow.type, 0); assert.equal(rainbow.color, 100);
|
|
assert.equal(rainbow.block_Info.floor, undefined); assert.equal(rainbow.block_Info.floorMove, undefined);
|
|
|
|
assert.equal(after.touchStart.call(rainbow, press), true);
|
|
const before = rainbow.node.position;
|
|
after.touchMove.call(rainbow, { getLocation: () => v2(10, 10), getDelta: () => v2() });
|
|
after.update.call(rainbow, 1 / 60);
|
|
assert.deepEqual(plain(rainbow.node.position), { x: before.x + 10, y: before.y + 10 });
|
|
});
|
|
|
|
test('rainbow elimination advances numeric counters and vines, never switches or count bombs', () => {
|
|
const f = cutFixture(1); const removed = block(0).node;
|
|
const amounts = {};
|
|
for (const name of ['Freeze','Question','Floor','Lock','Heng','Shu','Boom','Switchs']) {
|
|
const node = new Node(name); node.components[name] = { time:3, color:8, reduce(...args) { amounts[name]=args; } }; f.b.node.addChild(node);
|
|
}
|
|
let vines=0, walls=0;
|
|
f.map.blocks.push(removed); f.map.shrinkVine=()=>vines++;
|
|
f.map.changeFreeze=f.map.changeLock=f.map.changeAdhesive=()=>walls++;
|
|
f.cut.applyRainbowElimination(f.map, removed);
|
|
assert.equal(vines,1); assert.equal(walls,3); assert.equal(f.map.blocks.includes(removed),false);
|
|
assert.deepEqual(plain(amounts), {Freeze:[1,true],Question:[1,true],Floor:[1,8],Lock:[],Heng:[],Shu:[]});
|
|
const changeBoom=method(mapFile,'changeBoom'); changeBoom.call({}, {getComponent:()=>({color:100})});
|
|
});
|
|
|
|
test('only layer_two 2 replaces the reward, with original streak/mainline/unlock eligibility', () => {
|
|
const file = 'assets/Script/module/Config/GameConfig.ts';
|
|
const config = { isRainbowWinStreak: method(file,'isRainbowWinStreak'), isWinStreakUnlocked: () => true };
|
|
const active = method(file,'isRainbowRewardActive');
|
|
for (const group of [undefined, 1, 2, 3]) {
|
|
config.GM_INFO = { abTests: {layer_two:group}, winStreak:10, otherLevel:0 };
|
|
assert.equal(active.call(config), group === 2);
|
|
}
|
|
config.GM_INFO.abTests.layer_two = 2;
|
|
config.GM_INFO.winStreak = 9; assert.equal(active.call(config),false);
|
|
config.GM_INFO.winStreak = 10; config.GM_INFO.otherLevel = 5; assert.equal(active.call(config),false);
|
|
config.GM_INFO.otherLevel = 0; config.isWinStreakUnlocked=()=>false; assert.equal(active.call(config),false);
|
|
});
|
|
|
|
test('normal casts wait for release animation; a prior partial hammer hit does not exclude the remaining block', () => {
|
|
const f = controllerFixture([block(), block()]); f.controller.first = false;
|
|
f.blocks[0].isEliminatedByHammer = true; // Existing field also stays true after a partial lock hit.
|
|
assert.equal(f.controller.candidates().length,2);
|
|
const exit = block().node; exit.components.Block.over = true; f.map.node.children.push(exit);
|
|
f.controller.update(.1); assert.equal(f.controller.casting,false);
|
|
exit.active = false; f.controller.update(.1); assert.equal(f.controller.casting,true);
|
|
f.map.pause = true; f.controller.update(10); assert.equal(f.applied.length,0);
|
|
f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS); assert.equal(f.applied.length,1);
|
|
});
|
|
|
|
test('browser startup has ten wins before entering GameScene; disabling test mode restores normal defaults', () => {
|
|
for (const enabled of [true, false]) {
|
|
const config = { forceRainbowWinStreakB: enabled };
|
|
const cc = { resources: { load() {} }, fx: { GameConfig: config, GameTool: { getHealth() {} } } };
|
|
method('assets/Script/module/Config/GameConfig.ts', 'GM_INFO_init', { cc }).call(config);
|
|
const manager = {};
|
|
// No wx/tt globals: exercise the browser's actual startup branch.
|
|
method('assets/Script/GameManager.ts', 'readUserData', { cc }).call(manager);
|
|
assert.equal(manager.load3, true);
|
|
assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0);
|
|
assert.equal(config.GM_INFO.winState, enabled);
|
|
}
|
|
});
|
|
|
|
test('home restores test streak before rendering, even when login or a previous loss supplied zero', () => {
|
|
for (const enabled of [true, false]) {
|
|
const config = { forceRainbowWinStreakB: enabled, GM_INFO: { winStreak: 0, winState: false } };
|
|
const beforeRendering = new Error('UI boundary');
|
|
const onLoad = method('assets/Script/JiaZai.ts', 'onLoad', { cc: { fx: { GameConfig: config } } });
|
|
assert.throws(() => onLoad.call({ ensureLoadingOnTop() { throw beforeRendering; } }), error => error === beforeRendering);
|
|
assert.equal(config.GM_INFO.winStreak, enabled ? 10 : 0);
|
|
assert.equal(config.GM_INFO.winState, enabled);
|
|
}
|
|
});
|
|
|
|
test('rainbow elimination includes gray and monochrome obstacle counters without counting them as clear targets', () => {
|
|
const f = cutFixture(1); const removed = block(0).node;
|
|
f.map.blocks.push(removed); f.map.node.addChild(removed);
|
|
f.map.shrinkVine = f.map.changeFreeze = f.map.changeLock = f.map.changeAdhesive = () => {};
|
|
const targets = [block(1, 4), block(1, 16), block(1, 22), block(), block(), block()];
|
|
targets[0].color = targets[1].color = 11;
|
|
targets[3].spawnLocked = true; targets[4].over = true; targets[5].node.destroyed = true;
|
|
for (const target of [...targets, removed.getComponent('Block')]) {
|
|
for (const name of ['Freeze', 'Question', 'Floor', 'Lock', 'Heng', 'Shu', 'Boom']) {
|
|
const child = new Node(name); child.components[name] = { time: 5, color: 3, reduce() { this.time--; } };
|
|
target.node.addChild(child);
|
|
}
|
|
if (target.node !== removed) f.map.node.addChild(target.node);
|
|
}
|
|
f.cut.applyRainbowElimination(f.map, removed);
|
|
assert.deepEqual(f.map.blocks, [f.b.node]);
|
|
for (const [index, target] of [...targets, removed.getComponent('Block')].entries()) {
|
|
for (const child of target.node.children) {
|
|
const expected = index < 3 && child.name !== 'Boom' ? 4 : 5;
|
|
assert.equal(child.components[child.name].time, expected, index + ':' + child.name);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('test switch keeps ten wins through wins and losses; disabling restores normal settlement', () => {
|
|
const source = ts.createSourceFile('GameTool.ts', read('assets/Script/module/Tool/GameTool.ts'), ts.ScriptTarget.Latest, true);
|
|
const declaration = source.statements.filter(ts.isVariableStatement)
|
|
.flatMap(node => [...node.declarationList.declarations]).find(node => node.name.getText(source) === 'GameTool');
|
|
const member = declaration.initializer.properties.find(node => node.name.getText(source) === 'setWinStreak');
|
|
const config = { forceRainbowWinStreakB: true, GM_INFO: { winStreak: 0, winState: false } };
|
|
const saved = {}; let uploads = 0;
|
|
const settle = vm.runInNewContext('({' + member.getText(source) + '}).setWinStreak', {
|
|
cc: { fx: { GameConfig: config, StorageMessage: { setStorage: (key, value) => saved[key] = value } } },
|
|
Utils: { setWinStreak() { uploads++; } }
|
|
});
|
|
for (const result of ['fail', 'sucess', 'fail', 'fail', 'sucess']) {
|
|
settle(result);
|
|
assert.equal(config.GM_INFO.winStreak, 10); assert.equal(config.GM_INFO.winState, true);
|
|
assert.deepEqual(saved, { winStreak: 10, winState: true });
|
|
}
|
|
assert.equal(uploads, 0);
|
|
config.forceRainbowWinStreakB = false;
|
|
settle('fail');
|
|
assert.equal(config.GM_INFO.winStreak, 0); assert.equal(config.GM_INFO.winState, false);
|
|
assert.deepEqual(saved, { winStreak: 0, winState: false });
|
|
config.GM_INFO.winStreak = 9;
|
|
settle('sucess');
|
|
assert.equal(config.GM_INFO.winStreak, 10); assert.equal(config.GM_INFO.winStreakFirst, true);
|
|
assert.equal(uploads, 2);
|
|
});
|