268 lines
14 KiB
TypeScript
268 lines
14 KiB
TypeScript
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<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);
|
||
}
|
||
}
|
||
|
||
/** Prediction only: movable blockers may clear; actual reception still uses every solid. */
|
||
getPotentialExitIds(): Set<string> {
|
||
const fixed = this.collectColliders().filter(item => !this.canMoveBlocker(item.block));
|
||
const result = new Set<string>();
|
||
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<Block>()): 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;
|
||
}
|
||
}
|