import type MapController from "./Map"; import type Wall from "./Wall"; import type Block from "./Block"; import { LQCollide } from "./lq_collide_system/lq_collide"; import { LQCollideSystem } from "./lq_collide_system/lq_collide_system"; import { LQCollideShape } from "./lq_base/data/lq_const"; import { LaserCollider, nearestLaserHit } from "./LaserRules"; import LaserView from "./prop/LaserView"; export interface LaserConfig { id: string; /** Artwork index (1..10), independent of the exit's normal color. */ color: number; emitter: { num: number }; receiver: { num: number }; } interface LaserGroup { config: LaserConfig; emitter: Wall; receiver: Wall; gates: Wall[]; source?: LaserView; target?: LaserView; beam?: LaserView; gate?: LaserView; connected?: boolean; } const DIRECTIONS = { // Retraction excludes the wall texture's transparent edge/shadow and the beam's 1px clearance. right: { x: 1, y: 0, opposite: "left", headRetraction: 8 }, left: { x: -1, y: 0, opposite: "right", headRetraction: 6 }, up: { x: 0, y: 1, opposite: "down", headRetraction: 1 }, down: { x: 0, y: -1, opposite: "up", headRetraction: 14 }, }; /** Keeps laser reception independent of door/beam animation. */ export default class LaserSystem { private groups: LaserGroup[] = []; private nodes: cc.Node[] = []; private layer: cc.Node; constructor(private map: MapController, slots: Wall[], configs: LaserConfig[], prefab: cc.Prefab) { this.groups = this.validate(slots, configs); if (!prefab) throw new Error("Map.Block_Prop[26] 缺少激光预制体"); try { this.layer = new cc.Node("LaserLayer"); this.layer.parent = map.node; this.layer.zIndex = 10; for (const group of this.groups) { group.source = this.attach(prefab, group.emitter.node.parent.getChildByName("laser")); group.source.initHead("emitter", group.config.color, group.emitter.direction, this.headInset(group.emitter), DIRECTIONS[group.emitter.direction].headRetraction); group.target = this.attach(prefab, group.receiver.node.parent.getChildByName("laser")); group.target.initHead("receiver", group.config.color, group.receiver.direction, this.headInset(group.receiver), DIRECTIONS[group.receiver.direction].headRetraction); const first = group.gates.find(wall => wall.wall_Info.length > 0); group.gate = this.attach(prefab, first.node.parent.getChildByName("laser")); const gateFace = first.node.getBoundingBox(); const socket = group.gate.node.parent; // The base door's bottom 13px are shadow, not part of its solid colored face. const gateCenterY = (gateFace.yMin + gateFace.yMax) / 2 + 6.5 - socket.y; // Align the native face/shadow boundary; color 8's boundary is one pixel earlier. const colorOffset = first.wall_Info.color === 8 ? 1 : 0; const shadowEdge = first.direction === "left" || first.direction === "right" ? gateFace.xMin + 43 - colorOffset - socket.x : gateFace.yMax - 38 + colorOffset - socket.y; group.gate.initGate(first.wall_Info.length, first.direction, group.config.color, gateCenterY, shadowEdge); group.beam = this.attach(prefab, this.layer); // Start the body at the colored tip, but keep its flare clear of the wall shadow. group.beam.initBeam(group.config.color, group.emitter.direction, DIRECTIONS[group.emitter.direction].headRetraction); } this.update(true); } catch (error) { this.dispose(); throw error; } } private validate(slots: Wall[], configs: LaserConfig[]): LaserGroup[] { const ids = new Set(); const heads = new Set(); const straight = (wall: Wall) => wall && DIRECTIONS[wall.direction] && this.map.mapBlocksWall[wall.posX][wall.posY].opacity === 250 && wall.node.parent.getChildByName("laser"); const groups = configs.map(config => { const fail = (message: string) => { throw new Error("激光组 " + config.id + ": " + message); }; if (typeof config.id !== "string" || !config.id || ids.has(config.id)) fail("编号缺失或重复"); ids.add(config.id); if (!Number.isInteger(config.color) || config.color < 1 || config.color > 10) fail("激光色必须为 1~10"); const emitter = slots[config.emitter && config.emitter.num]; const receiver = slots[config.receiver && config.receiver.num]; if (!straight(emitter) || !straight(receiver)) fail("发射和接收必须放在完整直墙段"); for (const endpoint of [config.emitter, config.receiver]) { if (!Number.isInteger(endpoint.num) || heads.has(endpoint.num) || slots[endpoint.num].wall_Info) { fail("发射或接收墙位已被占用"); } heads.add(endpoint.num); } const direction = DIRECTIONS[emitter.direction]; const dx = receiver.posX - emitter.posX, dy = receiver.posY - emitter.posY; if (receiver.direction !== direction.opposite || dx * direction.y !== dy * direction.x || dx * direction.x + dy * direction.y <= 0) fail("发射和接收必须同一直线且相向"); const gates = slots.filter(wall => wall && wall.wall_Info && wall.wall_Info.laserId === config.id); const starts = gates.filter(wall => wall.wall_Info.length > 0); if (starts.length !== 1 || starts[0].wall_Info.length !== gates.length || gates.length > 3) { fail("必须对应一扇完整的 1~3 格控制门"); } const first = starts[0]; const vertical = first.direction === "left" || first.direction === "right"; const sorted = gates.slice().sort((a, b) => vertical ? a.posY - b.posY : a.posX - b.posX); sorted.forEach((wall, index) => { const info = wall.wall_Info; if (!straight(wall) || wall.direction !== first.direction || info.color !== first.wall_Info.color || wall.posX !== first.posX + (vertical ? 0 : index) || wall.posY !== first.posY + (vertical ? index : 0) || info.special !== 0 || info.toggle != null || info.longAndShort || info.stickLength || info.colorArray || info.jump != null || info.lockTime != null) { fail("控制门必须是连续同色直门,不能叠加其他门机制"); } wall.open = false; }); return { config, emitter, receiver, gates }; }); if (slots.some(wall => wall && wall.wall_Info && wall.wall_Info.laserId != null && !ids.has(wall.wall_Info.laserId))) throw new Error("激光控制门缺少对应组配置"); return groups; } private attach(prefab: cc.Prefab, parent: cc.Node): LaserView { parent.active = true; const node = cc.instantiate(prefab); this.nodes.push(node); node.parent = parent; node.setPosition(0, 0); const view = node.getComponent(LaserView); if (!view) throw new Error("激光预制体缺少 LaserView 组件"); return view; } private headInset(wall: Wall): number { const face = wall.node.getBoundingBox(); const socket = wall.node.parent.getChildByName("laser"); switch (wall.direction) { case "right": return Math.max(0, face.xMax - socket.x) + 1; case "left": return Math.max(0, socket.x - face.xMin) + 1; case "up": return Math.max(0, face.yMax - socket.y) + 1; case "down": return Math.max(0, socket.y - face.yMin) + 1; } return 0; } update(immediate = false): void { const colliders = this.collectColliders(); for (const group of this.groups) { const start = group.source.getBeamOrigin(), end = group.target.getBeamOrigin(); const obstacles = colliders.filter(item => item.wall !== group.emitter.node.parent && item.wall !== group.receiver.node.parent).map(item => item.shape); const hit = nearestLaserHit(start, end, obstacles); const connected = !hit; // All segments change state before starting the visual tween. group.gates.forEach(wall => wall.open = connected); if (group.connected !== connected || immediate) { group.connected = connected; group.gate.setGateOpen(connected, immediate); } const localStart = this.layer.convertToNodeSpaceAR(group.source.getVisibleBeamOrigin()); const localEnd = this.layer.convertToNodeSpaceAR(hit ? cc.v2(hit.point.x, hit.point.y) : group.target.getVisibleBeamOrigin()); const direction = DIRECTIONS[group.emitter.direction]; const visibleLength = (localEnd.x - localStart.x) * direction.x + (localEnd.y - localStart.y) * direction.y; group.beam.node.setPosition(localStart); group.beam.setBeam(Math.max(0, visibleLength), connected); } } /** Prediction only: movable blockers may clear; actual reception still uses every solid. */ getPotentialExitIds(): Set { const fixed = this.collectColliders().filter(item => !this.canMoveBlocker(item.block)); const result = new Set(); for (const group of this.groups) { const obstacles = fixed.filter(item => item.wall !== group.emitter.node.parent && item.wall !== group.receiver.node.parent).map(item => item.shape); // Scan to the receiver, including blockers hidden behind the visible beam's first hit. if (!nearestLaserHit(group.source.getBeamOrigin(), group.target.getBeamOrigin(), obstacles)) { result.add(group.config.id); } } return result; } private canMoveBlocker(block: Block, visited = new Set()): boolean { if (!block || block.over || visited.has(block)) return false; visited.add(block); const info = block.block_Info || {}; // Exit locks, question blocks and petal covers do not prevent dragging. // Temporary input locks during spawning/opening also need no elimination to clear. const hasAxis = (block.type !== 8 && info.vertical == null) || (block.type !== 7 && info.horizontal == null); if ([3, 4, 10, 12].indexOf(block.type) < 0 && (!info.floor || info.floorMove === true) && hasAxis) return true; const drivers: cc.Node[] = []; if (block.type === 10 && info.node) drivers.push(info.node); if (info.floor && info.floorMove === true) drivers.push(...(block.teamBlocks || [])); // Adhesive followers can move even when they cannot be dragged directly. for (const node of this.map.node.children) { const driver = node.getComponent("Block") as Block; if (driver && driver.type === 9 && driver.block_Info && driver.block_Info.node === block.node) { drivers.push(node); } } return drivers.some(node => cc.isValid(node, true) && node.activeInHierarchy && this.canMoveBlocker(node.getComponent("Block"), visited)); } private collectColliders(): { shape: LaserCollider; wall: cc.Node; block: Block }[] { const result: { shape: LaserCollider; wall: cc.Node; block: Block }[] = []; for (const collider of LQCollideSystem.collide_arr) { const node = collider.node; if (!cc.isValid(node, true) || !node.activeInHierarchy || !collider.is_enable || !collider.enabled || collider.data_string === "-1" || collider.data_string === "-2") continue; if (node.name === "rise" && this.map.riseFallBlock.indexOf(node.parent) >= 0) continue; let ancestor = node, wall: cc.Node = null, owner: Block = null, transparent = false; while (ancestor && ancestor !== this.map.node) { const block = ancestor.getComponent("Block") as Block; if (block && !owner) owner = block; if (block && (block.type === 22 || block.over)) transparent = true; if (ancestor.getChildByName("wall") && ancestor.getChildByName("wall").getComponent("Wall")) wall = ancestor; ancestor = ancestor.parent; } if (ancestor !== this.map.node || transparent) continue; const shape = this.colliderShape(collider); if (shape) result.push({ shape, wall, block: owner }); } return result; } private colliderShape(collider: LQCollide): LaserCollider { // LQ scales dimensions/points at initialization. Transform only the center; // applying the node's world scale again would enlarge the collision shape. const center = collider.node.convertToWorldSpaceAR(collider.offset); if (collider.collide_shape === LQCollideShape.Rect) { return { type: "rect", minX: center.x - collider.size.width / 2, maxX: center.x + collider.size.width / 2, minY: center.y - collider.size.height / 2, maxY: center.y + collider.size.height / 2 }; } if (collider.collide_shape === LQCollideShape.Circle) { return { type: "circle", center, radius: collider.radius }; } if (collider.collide_shape === LQCollideShape.Polygon) { const angle = collider.node.angle * Math.PI / 180, cos = Math.cos(angle), sin = Math.sin(angle); return { type: "polygon", points: collider.polygon_points.map(point => ({ x: center.x + point.x * cos - point.y * sin, y: center.y + point.x * sin + point.y * cos, })) }; } return null; } dispose(): void { this.nodes.forEach(node => { if (cc.isValid(node)) node.destroy(); }); this.nodes.length = 0; if (this.layer && cc.isValid(this.layer)) this.layer.destroy(); this.groups.length = 0; } }