Files
MatchMaster/tools/test-laser-system.cjs
2026-10-20 11:24:09 +08:00

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, []);
});