312 lines
16 KiB
JavaScript
312 lines
16 KiB
JavaScript
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);
|
|
});
|