Files
MatchMaster/assets/Script/LaserSystem.ts
T
2026-10-20 11:24:09 +08:00

226 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type MapController from "./Map";
import type Wall from "./Wall";
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<string>();
const heads = new Set<number>();
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);
}
}
private collectColliders(): { shape: LaserCollider; wall: cc.Node }[] {
const result: { shape: LaserCollider; wall: cc.Node }[] = [];
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, transparent = false;
while (ancestor && ancestor !== this.map.node) {
const block = ancestor.getComponent("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 });
}
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;
}
}