// 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 json = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); const vec = (x = 0, y = 0) => ({ x, y }); class Node { constructor(data) { this.name = data._name; this.width = data._contentSize.width; this.height = data._contentSize.height; this.anchorX = data._anchorPoint.x; this.anchorY = data._anchorPoint.y; this.x = data._trs.array[0]; this.y = data._trs.array[1]; this.scaleX = data._trs.array[7]; this.scaleY = data._trs.array[8]; this.angle = data._eulerAngles.z; this.active = data._active; this.children = []; this.components = new Map(); } get position() { return vec(this.x, this.y); } addChild(node) { node.parent = this; this.children.push(node); } getChildByName(name) { return this.children.find(n => n.name === name); } getComponent(type) { return this.components.get(type); } getAnchorPoint() { return vec(this.anchorX, this.anchorY); } getContentSize() { return { width: this.width, height: this.height }; } getSiblingIndex() { return this.parent.children.indexOf(this); } setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(this.getSiblingIndex(), 1); siblings.splice(index, 0, this); } setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; } setAnchorPoint(x, y) { this.anchorX = typeof x === 'object' ? x.x : x; this.anchorY = typeof x === 'object' ? x.y : y; } setContentSize(x, y) { this.width = typeof x === 'object' ? x.width : x; this.height = typeof x === 'object' ? x.height : y; } setScale(x, y) { this.scaleX = x; this.scaleY = y; } convertToWorldSpaceAR(p) { const r = this.angle * Math.PI / 180, x = p.x * this.scaleX, y = p.y * this.scaleY; const q = vec(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 vec((x * Math.cos(r) - y * Math.sin(r)) / this.scaleX, (x * Math.sin(r) + y * Math.cos(r)) / this.scaleY); } } class Sprite {} Sprite.SizeMode = { CUSTOM: 0 }; class Mask {} const cc = { Component: class {}, Node, Sprite, Mask, SpriteFrame: class {}, v2: vec, _decorator: { ccclass: type => type, property: () => () => {} } }; function load(file, 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; } const Scroll = load('assets/Script/prop/RainbowScroll.ts').default; const Visual = load('assets/Script/RainbowOldVisual.ts', { './prop/RainbowScroll': { default: Scroll } }).default; const { BLOCK_TEXTURE_TRIMS } = load('assets/Script/module/Tool/BlockTexturePaths.ts', { './GameplayAtlasFrames': { GAMEPLAY_ATLAS_PATHS: [] } }); const oldPrefab = json('assets/prefab/prop/rainbow_old.prefab'); const frameSet = suffix => Array.from({ length: 23 }, (_, shape) => { const name = `100color${shape}${suffix}`; const meta = json(`assets/UI/prop/rainbow/${name}.png.meta`).subMetas[name]; return { uuid: meta.uuid, getRect: () => ({ width: meta.width, height: meta.height }), getOriginalSize: () => ({ width: meta.rawWidth, height: meta.rawHeight }), getOffset: () => vec(meta.offsetX, meta.offsetY) }; }); const shapeFrames = frameSet(''), maskFrames = frameSet('_mask'); function instantiate(data, index = 1) { const record = data[index], n = new Node(record); record._children.forEach(child => n.addChild(instantiate(data, child.__id__))); for (const ref of record._components) { const comp = data[ref.__id__], type = { 'cc.Sprite': Sprite, 'cc.Mask': Mask }[comp.__type__]; if (type) n.components.set(type, { node: n }); } return n; } function setup(shape, color = 5) { const node = instantiate(json(`assets/resources/prefab/block/block${shape}.prefab`)); const attachment = new Node(oldPrefab[11]); attachment.name = 'key'; node.addChild(attachment); const icon = node.getChildByName('icon'); // Match BlockAssetManager's runtime trim table (which can differ from editor .meta). const texture = fs.readFileSync(path.join(root, `assets/Block/${color}color${shape}.png`)); const width = texture.readUInt32BE(16), height = texture.readUInt32BE(20); const trim = BLOCK_TEXTURE_TRIMS[`${color}color${shape}`]; const sourceFrame = { getRect: () => ({ width: trim ? trim[2] : width, height: trim ? trim[3] : height }), getOriginalSize: () => ({ width, height }), getOffset: () => trim ? vec(trim[4], trim[5]) : vec() }; icon.getComponent(Sprite).spriteFrame = sourceFrame; icon.setContentSize(sourceFrame.getRect()); const block = { node, block_Info: { block: shape, key: true }, color, type: 2 }; const overlay = instantiate(oldPrefab); node.addChild(overlay); const scroll = new Scroll(); scroll.node = overlay; overlay.components.set(Scroll, scroll); const visual = new Visual(); visual.node = overlay; visual.shapeFrames = shapeFrames; visual.maskFrames = maskFrames; return { node, attachment, icon, block, overlay, scroll, visual }; } function assertNear(a, b, label) { assert.ok(Math.abs(a - b) < 1e-7, `${label}: ${a} vs ${b}`); } function frameBounds(node, frame) { const raw = frame.getOriginalSize(), rect = frame.getRect(), off = frame.getOffset(); return { x: node.x - node.width * node.anchorX + (off.x + (raw.width - rect.width) / 2) * node.width / raw.width, y: node.y - node.height * node.anchorY + (off.y + (raw.height - rect.height) / 2) * node.height / raw.height, width: rect.width * node.width / raw.width, height: rect.height * node.height / raw.height }; } test('prefab retains both supplied large textures and serializes all 23 outline and mask dependencies', () => { for (const name of ['color1', 'color2']) { const node = oldPrefab.find(o => o._name === name); const sprite = oldPrefab[node._components[0].__id__]; assert.equal(node._contentSize.width, 764); assert.equal(node._contentSize.height, 542); assert.equal(sprite._spriteFrame.__uuid__, '852480cd-725a-4d3f-af5e-b8dca166dbe2'); } const component = oldPrefab.find(o => o.shapeFrames); assert.equal(component.shapeFrames.length, 23); component.shapeFrames.forEach((ref, shape) => assert.equal(ref.__uuid__, shapeFrames[shape].uuid)); assert.equal(component.maskFrames.length, 23); component.maskFrames.forEach((ref, shape) => { const frame = maskFrames[shape], size = frame.getOriginalSize(); assert.deepEqual(size, shapeFrames[shape].getOriginalSize(), `shape ${shape} shared canvas`); assert.equal(ref.__uuid__, frame.uuid); assert.notEqual(frame.uuid, shapeFrames[shape].uuid); const png = fs.readFileSync(path.join(root, `assets/UI/prop/rainbow/100color${shape}_mask.png`)); assert.equal(png.readUInt32BE(16), size.width); assert.equal(png.readUInt32BE(20), size.height); assert.ok(frame.getRect().width > 0 && frame.getRect().height > 0); }); assert.ok(oldPrefab[1]._components.some(ref => oldPrefab[ref.__id__] === component)); }); test('all 23 shapes and 10 colors preserve the 4px mask inset and block data and attachments', () => { for (let shape = 0; shape < 23; shape++) for (let color = 1; color <= 10; color++) { const f = setup(shape, color), info = f.block.block_Info, iconPosition = f.icon.position; const originalFrame = f.icon.getComponent(Sprite).spriteFrame; f.visual.apply(f.block); assert.equal(f.block.color, color); assert.equal(f.block.type, 2); assert.equal(f.block.block_Info, info); assert.equal(f.icon.active, false); assert.equal(f.attachment.active, true); assert.ok(f.overlay.getSiblingIndex() < f.attachment.getSiblingIndex()); assert.deepEqual(f.overlay.position, iconPosition); assert.equal(f.overlay.anchorX, f.icon.anchorX); assert.equal(f.overlay.anchorY, f.icon.anchorY); assert.equal(f.icon.getComponent(Sprite).spriteFrame, originalFrame); const outlineRect = shapeFrames[shape].getRect(); for (const name of ['bg', 'mask']) { const node = f.overlay.getChildByName(name); const frame = name === 'bg' ? shapeFrames[shape] : maskFrames[shape]; assert.equal(node.getComponent(name === 'bg' ? Sprite : Mask).spriteFrame, frame); const bounds = frameBounds(node, frame); const insetX = name === 'mask' ? 4 * f.icon.width / outlineRect.width : 0; const insetY = name === 'mask' ? 4 * f.icon.height / outlineRect.height : 0; assertNear(bounds.x, -f.icon.width * f.icon.anchorX + insetX, `${shape}/${name} left`); assertNear(bounds.y, -f.icon.height * f.icon.anchorY + insetY, `${shape}/${name} bottom`); assertNear(bounds.width, f.icon.width - 2 * insetX, `${shape}/${name} width`); assertNear(bounds.height, f.icon.height - 2 * insetY, `${shape}/${name} height`); } } }); test('large mirrored textures cover every mask continuously through repeated wraps and long frame gaps', () => { for (let shape = 0; shape < 23; shape++) { const f = setup(shape); f.visual.apply(f.block); f.scroll.onLoad(); const mask = f.overlay.getChildByName('mask'); const colors = ['color1', 'color2'].map(name => mask.getChildByName(name)); const points = []; for (let x = 0; x <= 4; x++) for (let y = 0; y <= 4; y++) points.push(vec((x / 4 - 0.5) * mask.width, (y / 4 - 0.5) * mask.height)); for (let tick = 0; tick < 2400; tick++) { for (const point of points) { const world = mask.convertToWorldSpaceAR(point); assert.ok(colors.some(color => { const local = color.convertToNodeSpaceAR(world); return local.x >= -color.anchorX * color.width - 1e-6 && local.x <= (1 - color.anchorX) * color.width + 1e-6 && local.y >= -color.anchorY * color.height - 1e-6 && local.y <= (1 - color.anchorY) * color.height + 1e-6; }), `shape ${shape}, tick ${tick}, point ${JSON.stringify(point)} uncovered`); } f.scroll.update(tick === 200 ? 35 : tick === 800 ? 1000 : 1 / 30); } } }); test('existing B scroll initializes the same geometry through the new explicit refresh method', () => { const node = instantiate(json('assets/prefab/prop/rainbow.prefab')); const scroll = new Scroll(); scroll.node = node; scroll.onLoad(); const before = JSON.stringify({ direction: scroll.direction, exits: scroll.exits, length: scroll.loopLength }); scroll.refreshGeometry(); assert.equal(JSON.stringify({ direction: scroll.direction, exits: scroll.exits, length: scroll.loopLength }), before); assert.ok(scroll.loopLength > 0); });