461 lines
21 KiB
JavaScript
461 lines
21 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { loadMembers, loadModule } = require('./new-player-ab-test-support.cjs');
|
|
|
|
const v2 = (x = 0, y = 0) => ({ x, y,
|
|
sub(other) { return v2(this.x - other.x, this.y - other.y); },
|
|
mag() { return Math.hypot(this.x, this.y); },
|
|
});
|
|
class Node {
|
|
constructor(name = '') {
|
|
this.name = name;
|
|
this.children = [];
|
|
this.components = {};
|
|
this.active = true;
|
|
this.valid = true;
|
|
this.x = this.y = this.angle = 0;
|
|
this.scale = 1;
|
|
this.width = this.height = 0;
|
|
this.anchorX = this.anchorY = 0.5;
|
|
}
|
|
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; }
|
|
get activeInHierarchy() { return this.active && (!this.parent || this.parent.activeInHierarchy); }
|
|
getChildByName(name) { return this.children.find(child => child.name === name) || null; }
|
|
getComponent(type) { return typeof type === 'string' ? this.components[type] || null : this.view; }
|
|
setPosition(x, y) {
|
|
this.x = typeof x === 'object' ? x.x : x;
|
|
this.y = typeof x === 'object' ? x.y : y;
|
|
}
|
|
getBoundingBox() {
|
|
return { xMin: this.x - this.anchorX * this.width, xMax: this.x + (1 - this.anchorX) * this.width,
|
|
yMin: this.y - this.anchorY * this.height, yMax: this.y + (1 - this.anchorY) * this.height };
|
|
}
|
|
convertToWorldSpaceAR(point) {
|
|
const radians = this.angle * Math.PI / 180;
|
|
const local = v2(this.x + this.scale * (point.x * Math.cos(radians) - point.y * Math.sin(radians)),
|
|
this.y + this.scale * (point.x * Math.sin(radians) + point.y * Math.cos(radians)));
|
|
return this.parent ? this.parent.convertToWorldSpaceAR(local) : local;
|
|
}
|
|
convertToNodeSpaceAR(point) {
|
|
const local = this.parent ? this.parent.convertToNodeSpaceAR(point) : point;
|
|
const dx = (local.x - this.x) / this.scale, dy = (local.y - this.y) / this.scale;
|
|
const radians = -this.angle * Math.PI / 180;
|
|
return v2(dx * Math.cos(radians) - dy * Math.sin(radians), dx * Math.sin(radians) + dy * Math.cos(radians));
|
|
}
|
|
destroy() { this.valid = false; }
|
|
}
|
|
class View {
|
|
constructor(node) { this.node = node; this.gateCalls = []; }
|
|
initHead(role, color, direction, inset = 0, retraction = 0) { Object.assign(this, { role, color, direction, inset, retraction }); }
|
|
initGate(length, direction, color) { Object.assign(this, { length, direction, color }); }
|
|
initBeam(color, direction, startOffset = 0) { Object.assign(this, { color, direction, startOffset }); }
|
|
getBeamOrigin() { return this.node.convertToWorldSpaceAR(v2()); }
|
|
getVisibleBeamOrigin() {
|
|
const [dx, dy] = { right: [1, 0], left: [-1, 0], up: [0, 1], down: [0, -1] }[this.direction];
|
|
return this.node.convertToWorldSpaceAR(v2(dx * (this.inset - this.retraction), dy * (this.inset - this.retraction)));
|
|
}
|
|
setGateOpen(open, immediate) {
|
|
if (this.beforeAnimation) this.beforeAnimation(open);
|
|
this.gateCalls.push({ open, immediate });
|
|
}
|
|
setBeam(length, connected) { this.beam = { length, connected }; }
|
|
}
|
|
const cc = {
|
|
Node, v2, isValid: node => !!node && node.valid !== false,
|
|
fx: { AudioManager: { _instance: { playEffect() {} } } },
|
|
instantiate(prefab) {
|
|
if (typeof prefab === 'function') return prefab();
|
|
const node = new Node('laser');
|
|
node.view = new View(node);
|
|
return node;
|
|
},
|
|
};
|
|
const LQCollideShape = { Rect: 1, Circle: 2, Polygon: 3 };
|
|
const LQCollideSystem = { collide_arr: [] };
|
|
const rules = loadModule('assets/Script/LaserRules.ts');
|
|
const LaserSystem = loadModule('assets/Script/LaserSystem.ts', {
|
|
'./lq_collide_system/lq_collide': { LQCollide: class {} },
|
|
'./lq_collide_system/lq_collide_system': { LQCollideSystem },
|
|
'./lq_base/data/lq_const': { LQCollideShape },
|
|
'./LaserRules': rules,
|
|
'./prop/LaserView': { default: View },
|
|
}, { cc }).default;
|
|
const MapController = loadMembers('assets/Script/Map.ts',
|
|
['passWall', 'lateUpdate', 'createWall', 'changeLockWall', 'downDoor'], { cc, setTimeout: callback => callback() });
|
|
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-6, `${actual} != ${expected}`);
|
|
|
|
function child(parent, name, x = 0, y = 0) {
|
|
const node = new Node(name);
|
|
node.parent = parent;
|
|
node.setPosition(x, y);
|
|
return node;
|
|
}
|
|
function fixture(length = 2) {
|
|
LQCollideSystem.collide_arr = [];
|
|
const map = { node: new Node('Map'), mapBlocksWall: [], riseFallBlock: [] };
|
|
const slots = [], configs = [];
|
|
function wall(x, y, direction, info = null) {
|
|
const holder = child(map.node, direction, x * 100, y * 100);
|
|
const node = child(holder, 'wall');
|
|
node.width = node.height = 60;
|
|
child(holder, 'laser');
|
|
child(holder, 'length').active = false;
|
|
const component = { node, posX: x, posY: y, direction, wall_Info: info,
|
|
color: info ? info.color : 0, colorArray: [], special: 0 };
|
|
node.components.Wall = component;
|
|
if (!map.mapBlocksWall[x]) map.mapBlocksWall[x] = [];
|
|
map.mapBlocksWall[x][y] = { opacity: 250 };
|
|
slots.push(component);
|
|
return component;
|
|
}
|
|
function group(id, row, gateLength = length, color = 1) {
|
|
const emitter = slots.length;
|
|
wall(0, row, 'right');
|
|
const receiver = slots.length;
|
|
wall(10, row, 'left');
|
|
for (let i = 0; i < gateLength; i++) {
|
|
wall(2 + i, row + 1, 'down', { laserId: id, color: 3, length: i ? 0 : gateLength, special: 0 });
|
|
}
|
|
configs.push({ id, color, emitter: { num: emitter }, receiver: { num: receiver } });
|
|
}
|
|
group('first', 2);
|
|
return { map, slots, configs, wall, group, create: () => new LaserSystem(map, slots, configs, {}) };
|
|
}
|
|
function collider(parent, x, y, options = {}) {
|
|
const node = child(parent, options.name || 'edge', x, y);
|
|
const value = { node, is_enable: true, enabled: true, data_string: '',
|
|
collide_shape: LQCollideShape.Rect, offset: v2(), size: { width: 100, height: 100 }, ...options };
|
|
LQCollideSystem.collide_arr.push(value);
|
|
return value;
|
|
}
|
|
function block(parent, type = 0) {
|
|
const node = child(parent, 'block');
|
|
node.components.Block = { type, over: false };
|
|
return node;
|
|
}
|
|
|
|
test('only solid active colliders block; both monochrome types stay transparent and vines stay solid', () => {
|
|
const { map, create } = fixture();
|
|
const system = create();
|
|
const ordinary = collider(block(map.node), 400, 200);
|
|
const vineCell = child(map.node, 'vineCell');
|
|
const vine = collider(vineCell, 600, 200, { name: 'rise' });
|
|
collider(block(map.node, 22), 400, 200);
|
|
const riseCell = child(map.node, 'monochromeCell');
|
|
map.riseFallBlock.push(riseCell);
|
|
collider(riseCell, 400, 200, { name: 'rise' });
|
|
collider(block(map.node), 400, 200, { data_string: '-1' });
|
|
collider(block(map.node), 400, 200, { data_string: '-2' });
|
|
collider(block(map.node), 400, 200, { is_enable: false });
|
|
collider(block(map.node), 400, 200, { enabled: false });
|
|
collider(block(map.node), 400, 200).node.active = false;
|
|
const hiddenBlock = block(map.node);
|
|
hiddenBlock.active = false;
|
|
collider(hiddenBlock, 400, 200);
|
|
collider(block(map.node), 400, 200).node.valid = false;
|
|
const exiting = block(map.node);
|
|
exiting.components.Block.over = true;
|
|
collider(exiting, 400, 200);
|
|
collider(new Node('another map'), 400, 200);
|
|
const collected = system.collectColliders();
|
|
assert.equal(collected.length, 2);
|
|
close(collected[0].shape.minX, ordinary.node.x - 50);
|
|
close(collected[1].shape.minX, vine.node.x - 50);
|
|
ordinary.is_enable = false;
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, false, 'the remaining vine must block');
|
|
vine.is_enable = false;
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, true, 'logically removed vine must stop blocking');
|
|
});
|
|
|
|
test('moving before the collision cache refresh updates beam length and gate state immediately', () => {
|
|
const { map, create } = fixture();
|
|
const system = create();
|
|
const moving = collider(block(map.node), 400, 200);
|
|
Object.defineProperty(moving, 'world_rect', { get() { throw new Error('stale cache must not be read'); } });
|
|
system.update();
|
|
close(system.groups[0].beam.beam.length, 327);
|
|
moving.node.x = 425.125;
|
|
system.update();
|
|
close(system.groups[0].beam.beam.length, 352.125);
|
|
moving.node.y = 250.001;
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, true);
|
|
close(system.groups[0].beam.beam.length, 952);
|
|
});
|
|
|
|
test('all gate cells change before animation, including an immediate reversal during the previous tween', () => {
|
|
const { map, create } = fixture(3);
|
|
const system = create(), group = system.groups[0];
|
|
group.gate.gateCalls.length = 0;
|
|
group.gate.beforeAnimation = open => assert.ok(group.gates.every(wall => wall.open === open));
|
|
const moving = collider(block(map.node), 400, 200);
|
|
system.update();
|
|
assert.equal(group.gate.gateCalls.length, 1);
|
|
assert.equal(group.gate.gateCalls[0].open, false);
|
|
assert.equal(group.gate.gateCalls[0].immediate, false);
|
|
moving.node.y += 60;
|
|
system.update();
|
|
assert.equal(group.gate.gateCalls[1].open, true);
|
|
assert.equal(group.gate.gateCalls.length, 2);
|
|
system.update();
|
|
assert.equal(group.gate.gateCalls.length, 2, 'stable reception must not restart an animation');
|
|
});
|
|
|
|
test('a logically exited block is transparent while its visual node remains active', () => {
|
|
const { map, create } = fixture();
|
|
const system = create(), obstacle = block(map.node);
|
|
collider(obstacle, 400, 200);
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, false);
|
|
obstacle.components.Block.over = true;
|
|
system.update();
|
|
assert.equal(obstacle.activeInHierarchy, true);
|
|
assert.equal(system.groups[0].connected, true);
|
|
});
|
|
|
|
test('same-color groups are independent and each beam ends at its nearest solid', () => {
|
|
const setup = fixture(1);
|
|
setup.group('second', 6, 1, 1);
|
|
const system = setup.create();
|
|
const farther = collider(block(setup.map.node), 700, 200);
|
|
const nearer = collider(block(setup.map.node), 300, 200);
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, false);
|
|
close(system.groups[0].beam.beam.length, 227);
|
|
assert.equal(system.groups[1].connected, true);
|
|
close(system.groups[1].beam.beam.length, 952);
|
|
nearer.node.y = 600;
|
|
system.update();
|
|
close(system.groups[0].beam.beam.length, 627);
|
|
close(system.groups[1].beam.beam.length, 227);
|
|
farther.node.y = 400;
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, true);
|
|
assert.equal(system.groups[1].connected, false);
|
|
});
|
|
|
|
test('the source and target wall colliders do not obstruct their own optical path', () => {
|
|
const setup = fixture();
|
|
const system = setup.create();
|
|
collider(setup.slots[0].node.parent, 0, 0);
|
|
collider(setup.slots[1].node.parent, 0, 0);
|
|
system.update();
|
|
assert.equal(system.groups[0].connected, true);
|
|
});
|
|
|
|
for (const [direction, opposite, dx, dy, mouth, length] of [
|
|
['right', 'left', 1, 0, 23, 952], ['left', 'right', -1, 0, 25, 952],
|
|
['up', 'down', 0, 1, 30, 953], ['down', 'up', 0, -1, 17, 953],
|
|
]) {
|
|
test(`${direction} visible origins move inward while hidden-start blockers still close the logical gate`, () => {
|
|
const { map, slots, create } = fixture();
|
|
[direction, opposite].forEach((facing, index) => {
|
|
const wall = slots[index];
|
|
wall.direction = facing;
|
|
wall.posX = 10 + index * dx * 10;
|
|
wall.posY = 10 + index * dy * 10;
|
|
if (!map.mapBlocksWall[wall.posX]) map.mapBlocksWall[wall.posX] = [];
|
|
map.mapBlocksWall[wall.posX][wall.posY] = { opacity: 250 };
|
|
wall.node.parent.setPosition(index * dx * 1000, index * dy * 1000);
|
|
});
|
|
const system = create(), group = system.groups[0];
|
|
system.update();
|
|
close(group.source.getBeamOrigin().x, 0);
|
|
close(group.source.getBeamOrigin().y, 0);
|
|
close(group.target.getBeamOrigin().x, dx * 1000);
|
|
close(group.target.getBeamOrigin().y, dy * 1000);
|
|
close(group.beam.node.x, dx * mouth);
|
|
close(group.beam.node.y, dy * mouth);
|
|
close(group.beam.node.x + dx * group.beam.startOffset, dx * 31);
|
|
close(group.beam.node.y + dy * group.beam.startOffset, dy * 31);
|
|
close(group.beam.beam.length, length);
|
|
assert.equal(system.layer.zIndex, 10, 'do not raise the whole beam over blocks');
|
|
assert.equal(group.connected, true);
|
|
collider(block(map.node), dx * 10, dy * 10, { size: { width: 10, height: 10 } });
|
|
system.update();
|
|
assert.equal(group.connected, false, 'collision still starts at the original wall socket');
|
|
assert.ok(group.gates.every(wall => wall.open === false));
|
|
assert.equal(group.beam.beam.length, 0, 'a hit behind the visible start must not make a reversed beam');
|
|
});
|
|
}
|
|
|
|
test('head inset uses the wall face without moving the shared socket or gate attachment', () => {
|
|
const setup = fixture(), wall = setup.slots[0].node;
|
|
wall.setPosition(6.333, -73.465);
|
|
wall.anchorX = wall.anchorY = 0;
|
|
wall.width = 61;
|
|
wall.height = 146;
|
|
const socket = wall.parent.getChildByName('laser');
|
|
socket.x = 36.695;
|
|
const system = setup.create(), group = system.groups[0];
|
|
close(group.source.inset, 31.638);
|
|
close(group.source.getBeamOrigin().x, 36.695);
|
|
close(group.source.getVisibleBeamOrigin().x, 60.333);
|
|
close(group.beam.node.x + group.beam.startOffset, 68.333);
|
|
close(socket.x, 36.695);
|
|
close(group.gate.node.x, 0);
|
|
close(group.gate.node.parent.x, 0);
|
|
});
|
|
|
|
test('map scaling moves only the visible head coordinates and preserves beam length in map space', () => {
|
|
const setup = fixture();
|
|
setup.map.node.scale = 0.5;
|
|
const system = setup.create(), group = system.groups[0];
|
|
close(group.source.getVisibleBeamOrigin().x, 11.5);
|
|
close(group.target.getVisibleBeamOrigin().x, 487.5);
|
|
close(group.beam.node.x, 23);
|
|
close(group.beam.node.x + group.beam.startOffset, 31);
|
|
close(group.beam.beam.length, 952);
|
|
assert.equal(group.connected, true);
|
|
});
|
|
|
|
test('LQ pre-scaled dimensions and polygon points are not scaled a second time', () => {
|
|
const { map, create } = fixture();
|
|
const system = create();
|
|
map.node.scale = 0.5;
|
|
const rectangle = collider(block(map.node), 8, 4, { offset: v2(2, 1), size: { width: 3, height: 2 } });
|
|
const shape = system.colliderShape(rectangle);
|
|
close(shape.minX, 3.5);
|
|
close(shape.maxX, 6.5);
|
|
close(shape.minY, 1.5);
|
|
const circle = collider(block(map.node), 8, 4, { collide_shape: LQCollideShape.Circle, radius: 3 });
|
|
close(system.colliderShape(circle).radius, 3);
|
|
const polygon = collider(block(map.node), 8, 4, {
|
|
collide_shape: LQCollideShape.Polygon, polygon_points: [v2(2, 0), v2(0, 2), v2(-2, 0)],
|
|
});
|
|
polygon.node.angle = 90;
|
|
const points = system.colliderShape(polygon).points;
|
|
close(points[0].x, 4);
|
|
close(points[0].y, 4);
|
|
close(points[1].x, 2);
|
|
close(points[1].y, 2);
|
|
});
|
|
|
|
test('individual collision strips and corner circles preserve the empty part of a compound shape', () => {
|
|
const { map, create } = fixture();
|
|
const system = create(), compound = block(map.node);
|
|
collider(compound, 3, 5, { size: { width: 2, height: 10 } });
|
|
collider(compound, 6, 1, { size: { width: 8, height: 2 } });
|
|
collider(compound, 9, 9, { collide_shape: LQCollideShape.Circle, radius: 1 });
|
|
const shapes = system.collectColliders().map(item => item.shape);
|
|
assert.equal(shapes.length, 3);
|
|
assert.equal(rules.nearestLaserHit(v2(6, 6), v2(9, 6), shapes), null);
|
|
close(rules.nearestLaserHit(v2(6, 6), v2(0, 6), shapes).distance, 2);
|
|
});
|
|
|
|
for (const [name, change, error] of [
|
|
['missing receiver', setup => delete setup.configs[0].receiver, /完整直墙/],
|
|
['same-facing endpoints', setup => setup.slots[1].direction = 'right', /相向/],
|
|
['misaligned endpoints', setup => setup.slots[1].posY++, /相向/],
|
|
['corner wall', setup => setup.map.mapBlocksWall[0][2].opacity = 251, /完整直墙/],
|
|
['occupied head slot', setup => setup.slots[0].wall_Info = { color: 1 }, /已被占用/],
|
|
['missing gate segment', setup => setup.slots.pop(), /完整的/],
|
|
['noncontiguous gate', setup => setup.slots[3].posX++, /连续同色/],
|
|
['mixed gate colors', setup => setup.slots[3].wall_Info.color++, /连续同色/],
|
|
['other gate mechanics', setup => setup.slots[2].wall_Info.toggle = 1, /连续同色/],
|
|
['orphan gate', setup => setup.configs.length = 0, /缺少对应组/],
|
|
]) {
|
|
test(`invalid laser configuration rejects ${name}`, () => {
|
|
const setup = fixture();
|
|
// Keep a valid map cell at adjacent positions for coordinate-mutating cases.
|
|
setup.map.mapBlocksWall[10][3] = { opacity: 250 };
|
|
setup.map.mapBlocksWall[4] = [{}, {}, {}, { opacity: 250 }];
|
|
change(setup);
|
|
assert.throws(setup.create, error);
|
|
});
|
|
}
|
|
|
|
test('passWall refreshes reception before checking every segment and preserves color restrictions', () => {
|
|
const setup = fixture();
|
|
const system = setup.create();
|
|
const map = Object.assign(new MapController(), { laserSystem: system, checkColor() {} });
|
|
const piece = block(setup.map.node);
|
|
piece.components.Block.color = 3;
|
|
const gates = system.groups[0].gates.map(wall => wall.node);
|
|
const obstacle = collider(block(setup.map.node), 500, 200);
|
|
assert.equal(map.passWall(true, gates, piece), false);
|
|
obstacle.node.y += 60;
|
|
assert.equal(map.passWall(true, gates, piece), true);
|
|
piece.components.Block.color = 2;
|
|
assert.equal(map.passWall(true, gates, piece), false);
|
|
piece.components.Block.oldRainbow = true;
|
|
assert.equal(map.passWall(true, gates, piece), true);
|
|
obstacle.node.y -= 60;
|
|
assert.equal(map.passWall(true, gates, piece), false, 'rainbow cannot bypass a disconnected laser gate');
|
|
});
|
|
|
|
test('lateUpdate refreshes lasers and legacy gates continue to work with no laser system', () => {
|
|
let updates = 0;
|
|
const map = Object.assign(new MapController(), { laserSystem: { update() { updates++; } }, checkColor() {} });
|
|
map.lateUpdate();
|
|
assert.equal(updates, 1);
|
|
map.laserSystem = null;
|
|
map.lateUpdate();
|
|
const setup = fixture(1), wall = setup.slots[2];
|
|
delete wall.wall_Info.laserId;
|
|
const piece = block(setup.map.node);
|
|
piece.components.Block.color = wall.color;
|
|
wall.open = false;
|
|
assert.equal(map.passWall(true, [wall.node], piece), true);
|
|
wall.special = 2;
|
|
assert.equal(map.passWall(true, [wall.node], piece), false);
|
|
wall.open = true;
|
|
assert.equal(map.passWall(true, [wall.node], piece), true);
|
|
});
|
|
|
|
test('creating laser gates leaves them outside the global switch-door toggle list', () => {
|
|
const created = [];
|
|
const prefab = () => {
|
|
const holder = new Node('right'), wall = child(holder, 'wall');
|
|
wall.components.Wall = { special: 0, longAndShort: [], open: true,
|
|
init() {}, changeLock() { this.open = !this.open; } };
|
|
created.push(wall.components.Wall);
|
|
return holder;
|
|
};
|
|
const map = Object.assign(new MapController(), {
|
|
node: new Node('Map'), Wall_Prefab: Array(12).fill(prefab), wallNum: 0,
|
|
leftDoors: [], openWall: [], freezeWall: [], lockWall: [], longAndShortWall: [],
|
|
setDoorInfo(wall) {
|
|
const component = wall.getComponent('Wall');
|
|
component.special = this.wallNum === 0 ? 0 : 2;
|
|
component.wall_Info = this.wallNum === 0 ? { laserId: 'first', special: 0 } : { special: 2 };
|
|
},
|
|
});
|
|
const cell = new Node('cell');
|
|
cell.components.MapBlock = { posX: 0, posY: 0, setDiraction() {} };
|
|
map.createWall('right', cell);
|
|
map.createWall('right', cell);
|
|
assert.equal(map.openWall.length, 1);
|
|
map.changeLockWall();
|
|
assert.equal(created[0].open, true, 'laser gate must not be toggled by ordinary block exits');
|
|
assert.equal(created[1].open, false, 'the existing switch door still toggles');
|
|
});
|
|
|
|
test('door hints skip a closed laser gate and preserve ordinary door color hints', () => {
|
|
const setup = fixture(1), laserWall = setup.slots[2];
|
|
const ordinaryWall = setup.wall(5, 5, 'down', { special: 0, length: 1, color: 3 });
|
|
const calls = [];
|
|
laserWall.downDoor = () => calls.push('laser');
|
|
ordinaryWall.downDoor = () => calls.push('ordinary');
|
|
const map = Object.assign(new MapController(), {
|
|
wallArray: [laserWall.node.parent, ordinaryWall.node.parent],
|
|
});
|
|
laserWall.open = false;
|
|
map.downDoor(3, 0);
|
|
assert.deepEqual(calls, ['ordinary']);
|
|
calls.length = 0;
|
|
laserWall.open = true;
|
|
map.downDoor(3, 0);
|
|
assert.deepEqual(calls, ['laser', 'ordinary']);
|
|
calls.length = 0;
|
|
map.downDoor(2, 0);
|
|
assert.deepEqual(calls, []);
|
|
});
|