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