const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { loadModule } = require('./new-player-ab-test-support.cjs'); const root = path.resolve(__dirname, '..'); const bindings = { leftTileFrame: '2left', rightTileFrame: '2right', middleFrame: 'zhong', counterFrame: 'yuan', innerEdgeFrame: 'neiKuang1', innerCornerFrame: 'neiKuang2', middleEdgeFrame: 'zhongKuang', outerEdgeFrame: 'waiKuang1', outerCornerFrame: 'waiKuang2', }; const prefab = JSON.parse(fs.readFileSync(path.join(root, 'assets/prefab/prop/openDoor.prefab'), 'utf8')); const serializedGate = prefab.find(item => 'middleFrame' in item); class SpriteFrame { constructor(name, meta) { this.name = name; this.uuid = meta.uuid; this.width = meta.rawWidth; this.height = meta.rawHeight; this.insetTop = meta.borderTop || 0; this.insetRight = meta.borderRight || 0; this.insetBottom = meta.borderBottom || 0; this.insetLeft = meta.borderLeft || 0; this.destroyCount = 0; } getOriginalSize() { return { width: this.width, height: this.height }; } clone() { return Object.assign(Object.create(SpriteFrame.prototype), this); } destroy() { this.destroyCount++; } } class Sprite { static Type = { SIMPLE: 0, SLICED: 1 }; static SizeMode = { CUSTOM: 0 }; enabled = true; } class Mask { static Type = { RECT: 0 }; } class Label {} class Node { constructor(name) { Object.assign(this, { name, children: [], components: new Map(), x: 0, y: 0, width: 0, height: 0, scaleX: 1, scaleY: 1, angle: 0, active: true, opacity: 255 }); } set parent(value) { if (this._parent) this._parent.children.splice(this._parent.children.indexOf(this), 1); this._parent = value; if (value) value.children.push(this); } get parent() { return this._parent; } setPosition(x, y) { this.x = x; this.y = y; } setContentSize(width, height) { Object.assign(this, typeof width === 'object' ? width : { width, height }); } setSiblingIndex(index) { const siblings = this.parent.children; siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this); } getChildByName(name) { return this.children.find(child => child.name === name); } addComponent(Type) { const component = new Type(); component.node = this; this.components.set(Type, component); return component; } getComponent(Type) { return this.components.get(Type) || null; } destroy() { this.destroyed = true; for (const component of this.components.values()) if (component.onDestroy) component.onDestroy(); for (const child of this.children) child.destroy(); } } const cc = { Node, Sprite, Mask, Label, SpriteFrame, Component: class {}, isValid: value => !!value && !value.destroyed, _decorator: { ccclass: type => type, property: () => () => {} }, }; const Gate = loadModule('assets/Script/SpawnGate.ts', {}, { cc, sp: { SkeletonData: class {} } }).default; function fixture(width, height, batches = 2) { const frames = {}; for (const [property, name] of Object.entries(bindings)) { const meta = JSON.parse(fs.readFileSync( path.join(root, 'assets/UI/prop/doorCreateBlock', `${name}.png.meta`), 'utf8')).subMetas[name]; assert.equal(serializedGate[property].__uuid__, meta.uuid, `${property} must bind ${name}`); frames[property] = new SpriteFrame(name, meta); } function deserialize(index) { const value = prefab[index]; const node = new Node(value._name); node.setContentSize(value._contentSize.width, value._contentSize.height); const transform = value._trs.array; node.setPosition(transform[0], transform[1]); node.scaleX = transform[7]; node.scaleY = transform[8]; node.active = value._active; for (const ref of value._components) { const data = prefab[ref.__id__]; const Type = { 'cc.Sprite': Sprite, 'cc.Label': Label }[data.__type__]; if (Type) { const component = node.addComponent(Type); component.enabled = data._enabled; } } for (const child of value._children) deserialize(child.__id__).parent = node; return node; } const node = deserialize(prefab[0].data.__id__); const gate = Object.assign(node.addComponent(Gate), frames); const created = [], unlocked = []; const map = { mapBlocksWall: Array.from({ length: 4 }, (_, x) => Array.from({ length: 5 }, (_, y) => ({ x: 41 + x * 120, y: 73 + y * 120 }))), gameOver: false, gameWin: false, pause: false, shopPause: false, isSpawnGateAreaEmpty: () => true, iceTrue: () => false, createSpawnGateBatch(members, config, index) { const result = { nodes: [new Node('spawned')], opacities: [200] }; created.push({ members, config, index, result }); return result; }, unlockSpawnGateBatch(nodes) { unlocked.push(nodes); }, onSpawnGateFinished() { this.finished = (this.finished || 0) + 1; }, }; const config = { x: 2, y: 3, width, height, spawnQueue: Array.from({ length: batches }, (_, i) => ({ members: [{ id: `batch-${i}` }] })) }; gate.init(map, config); return { gate, node, frames, map, config, created, unlocked }; } function descendants(node) { return [node, ...node.children.flatMap(descendants)]; } function bounds(node, relativeTo) { let corners = [[-node.width / 2, -node.height / 2], [node.width / 2, -node.height / 2], [-node.width / 2, node.height / 2], [node.width / 2, node.height / 2]]; for (let current = node; current && current !== relativeTo; current = current.parent) { const radians = current.angle * Math.PI / 180, sin = Math.sin(radians), cos = Math.cos(radians); corners = corners.map(([x, y]) => { x *= current.scaleX; y *= current.scaleY; return [x * cos - y * sin + current.x, x * sin + y * cos + current.y]; }); } return { left: Math.min(...corners.map(p => p[0])), right: Math.max(...corners.map(p => p[0])), bottom: Math.min(...corners.map(p => p[1])), top: Math.max(...corners.map(p => p[1])), }; } function close(actual, expected, message) { assert.ok(Math.abs(actual - expected) < 1e-7, message); } function clippedBounds(node, root) { const box = bounds(node, root); for (let ancestor = node; ancestor && ancestor !== root; ancestor = ancestor.parent) { if (!ancestor.active) return null; if (ancestor.getComponent(Mask)) { const mask = bounds(ancestor, root); box.left = Math.max(box.left, mask.left); box.right = Math.min(box.right, mask.right); box.bottom = Math.max(box.bottom, mask.bottom); box.top = Math.min(box.top, mask.top); } } return box.left < box.right && box.bottom < box.top ? box : null; } function checkFrameDirections(parent, inner) { const corners = parent.children.filter(node => node.name === 'corner'); assert.equal(corners.length, 4); for (const edge of parent.children.filter(node => ['verticalEdge', 'innerSeam'].includes(node.name))) { const corner = corners.find(node => Math.sign(node.x) === Math.sign(edge.x)); assert.equal(edge.scaleX, corner.scaleX * (inner ? -1 : 1), `${inner ? 'inner' : 'outer'} strip must join its L corner with matching light/dark sides`); } for (const edge of parent.children.filter(node => node.name === 'horizontalEdge')) { const corner = corners.find(node => Math.sign(node.y) === Math.sign(edge.y)); assert.equal(edge.angle, corner.scaleY * (inner ? -90 : 90), `${inner ? 'inner' : 'outer'} horizontal strip must preserve its corner shading direction`); } } for (const [width, height] of [[1, 1], [2, 2], [3, 3], [2, 5], [6, 3], [1, 10], [10, 1], [10, 10]]) { test(`${width}x${height} artwork covers its rectangle with centered, mirrored, alternating leaves`, () => { const { gate, node, config, map, frames } = fixture(width, height); const pixelWidth = width * 120, pixelHeight = height * 120; assert.equal(node.width, pixelWidth); assert.equal(node.height, pixelHeight); assert.equal(node.x, map.mapBlocksWall[config.x][config.y].x + (width - 1) * 60); assert.equal(node.y, map.mapBlocksWall[config.x][config.y].y + (height - 1) * 60); const viewport = node.getChildByName('doorViewport'); assert.equal(viewport.getComponent(Mask).type, Mask.Type.RECT); assert.equal(viewport.width, pixelWidth); assert.equal(viewport.height, pixelHeight); const left = viewport.getChildByName('left'), right = viewport.getChildByName('right'); close(bounds(left, node).right, 0, 'left leaf meets the center'); close(bounds(right, node).left, 0, 'right leaf meets the center'); close(bounds(left, node).left, -pixelWidth / 2); close(bounds(right, node).right, pixelWidth / 2); for (const [leaf, sign] of [[left, -1], [right, 1]]) { assert.equal(leaf.getComponent(Sprite).enabled, false, 'old whole-door art is disabled'); const wood = leaf.getChildByName('wood'); assert.equal(wood.getComponent(Mask).type, Mask.Type.RECT); assert.equal(wood.width, pixelWidth / 2); assert.equal(wood.height, pixelHeight); const rows = [...new Set(wood.children.filter(n => n.name === 'plank').map(n => n.y))] .sort((a, b) => a - b); let previous = null; for (const y of rows) { const planks = wood.children.filter(n => n.name === 'plank' && n.y === y); const frame = planks[0].getComponent(Sprite).spriteFrame; assert.ok(frame === frames.leftTileFrame || frame === frames.rightTileFrame); if (previous) assert.notEqual(frame, previous, 'adjacent rows alternate their source artwork'); previous = frame; const boxes = planks.map(n => clippedBounds(n, node)).filter(Boolean); close(Math.min(...boxes.map(b => b.left)), sign < 0 ? -pixelWidth / 2 : 0); close(Math.max(...boxes.map(b => b.right)), sign < 0 ? 0 : pixelWidth / 2); for (const plank of planks) { assert.equal(plank.scaleX, sign); assert.equal(plank.width, 120); assert.equal(plank.height, 120); } } const middle = wood.getChildByName('middle'), circle = wood.getChildByName('circle'); assert.equal(middle.getComponent(Sprite).spriteFrame, frames.middleFrame); assert.equal(middle.scaleX, sign); assert.equal(middle.width, 60); assert.equal(middle.height, 120); const circleBox = bounds(circle, node); close(circleBox.left + circleBox.right, 0, 'circle is horizontally centered'); close(circleBox.bottom + circleBox.top, 0, 'circle is vertically centered, including even heights'); assert.equal(circle.width, 70); assert.equal(circle.height, 70); assert.equal(circle.getComponent(Sprite).spriteFrame, frames.counterFrame); const arc = leaf.getChildByName('middleFrame'); assert.equal(arc.scaleX, -sign); assert.equal(arc.getComponent(Sprite).spriteFrame, frames.middleEdgeFrame); checkFrameDirections(leaf.getChildByName('innerFrame'), true); for (const seam of descendants(leaf).filter(n => n.name === 'innerSeam')) { assert.equal(seam.scaleX, -arc.scaleX, 'the inner strip must reverse the arc orientation to continue its light/dark sides'); const box = bounds(seam, node); close(box.left, -4.5, 'both leaves share the same 9px seam while closed'); close(box.right, 4.5); } } checkFrameDirections(node.getChildByName('kuang'), false); assert.ok(node.children.indexOf(viewport) < node.children.indexOf(node.getChildByName('kuang'))); for (const closed of [1, 0.5, 0]) { gate.setClosedAmount(closed); assert.equal(left.active, closed > 0); assert.equal(right.active, closed > 0); close(left.x, -right.x, 'opening remains symmetric'); if (closed < 1) { assert.ok(bounds(left, node).right < 0); assert.ok(bounds(right, node).left > 0); } for (const artwork of descendants(node)) { const sprite = artwork.getComponent(Sprite); if (!sprite || !sprite.enabled || !sprite.spriteFrame) continue; const box = clippedBounds(artwork, node); if (!box) continue; assert.ok(box.left >= -pixelWidth / 2 - 1e-7 && box.right <= pixelWidth / 2 + 1e-7, `${artwork.name} must not paint outside the region horizontally`); assert.ok(box.bottom >= -pixelHeight / 2 - 1e-7 && box.top <= pixelHeight / 2 + 1e-7, `${artwork.name} must not paint outside the region vertically`); } } gate.setClosedAmount(1); close(bounds(left, node).right, 0, 'closing returns to its original seam'); close(bounds(right, node).left, 0); }); } test('corner slicing uses owned copies and releases them without changing the nine source assets', () => { const { gate, frames } = fixture(6, 3); const copies = [...gate.cornerFrames]; assert.equal(copies.length, 2); for (const frame of Object.values(frames)) { assert.equal(frame.insetTop, 0); assert.equal(frame.insetRight, 0); assert.equal(frame.insetBottom, 0); assert.equal(frame.insetLeft, 0); } for (const frame of copies) { assert.ok(!Object.values(frames).includes(frame)); assert.equal(frame.insetTop, 16); assert.equal(frame.insetRight, 16); assert.equal(frame.insetBottom, 0); assert.equal(frame.insetLeft, 0); } gate.onDestroy(); assert.equal(gate.cornerFrames.length, 0); for (const frame of copies) assert.equal(frame.destroyCount, 1); for (const frame of Object.values(frames)) assert.equal(frame.destroyCount, 0); gate.onDestroy(); for (const frame of copies) assert.equal(frame.destroyCount, 1, 'destroy is safe to repeat'); }); test('rectangular artwork retains 0.3/0.15/0.2 second batches, counter, opacity and unlocking', () => { const { gate, node, map, config, created, unlocked } = fixture(2, 5); const count = node.getChildByName('count').getComponent(Label); assert.equal(count.string, '2'); assert.equal(count.node.active, true); gate.update(0); assert.equal(created.length, 1); assert.equal(created[0].members, config.spawnQueue[0].members); assert.equal(created[0].result.nodes[0].opacity, 0); assert.equal(count.node.active, false); assert.equal(count.string, '2'); gate.update(0.15); assert.equal(gate.leftDoor.active, true); assert.equal(count.string, '2'); assert.equal(unlocked.length, 0); gate.update(0.15); assert.equal(gate.leftDoor.active, false); assert.equal(count.string, '1'); gate.update(0.075); assert.equal(created[0].result.nodes[0].opacity, 100); assert.equal(unlocked.length, 0); gate.update(0.075); assert.equal(created[0].result.nodes[0].opacity, 200); gate.update(0.1); assert.equal(gate.leftDoor.active, true); assert.equal(count.node.active, false); assert.equal(unlocked.length, 0); gate.update(0.1); assert.equal(unlocked.length, 1); assert.equal(unlocked[0], created[0].result.nodes); assert.equal(count.node.active, true); assert.equal(count.string, '1'); assert.equal(gate.hasOutstandingWork(), true); gate.update(0); gate.update(0.3); gate.update(0.15); gate.update(0.2); assert.equal(created.length, 2); assert.equal(created[1].index, 1); assert.equal(unlocked.length, 2); assert.equal(map.finished, 1); assert.equal(gate.hasOutstandingWork(), false); assert.equal(node.destroyed, true); assert.equal(gate.cornerFrames.length, 0); });