const { ccclass, property } = cc._decorator; export interface SpawnGateConfig { id?: string; x: number; y: number; width: number; height: number; spawnQueue: Array<{ members: any[] }>; } export interface SpawnGateBatchResult { nodes: cc.Node[]; opacities: number[]; } enum SpawnGateState { Waiting, Opening, Revealing, Closing, } @ccclass export default class SpawnGate extends cc.Component { @property(cc.SpriteFrame) leftTileFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) rightTileFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) middleFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) counterFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) innerEdgeFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) innerCornerFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) middleEdgeFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) outerEdgeFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) outerCornerFrame: cc.SpriteFrame = null; @property(sp.SkeletonData) revealSmokeData: sp.SkeletonData = null; private map: any = null; private config: SpawnGateConfig = null; private queueIndex = 0; private state = SpawnGateState.Waiting; private elapsed = 0; private finished = false; private batch: SpawnGateBatchResult = null; private leftDoor: cc.Node = null; private rightDoor: cc.Node = null; private cornerFrames: cc.SpriteFrame[] = []; private countLabel: cc.Label = null; init(map: any, config: SpawnGateConfig) { this.map = map; this.config = config; this.queueIndex = 0; this.state = SpawnGateState.Waiting; this.elapsed = 0; this.finished = false; this.batch = null; const left = this.node.getChildByName("left"); const right = this.node.getChildByName("right"); this.leftDoor = left; this.rightDoor = right; const count = this.node.getChildByName("count"); this.countLabel = count && count.getComponent(cc.Label); this.configureArtwork(left, right); this.updateRemainingCount(); if (this.countLabel) this.countLabel.node.active = true; const bottomLeft = map.mapBlocksWall[config.x][config.y]; this.node.setPosition( bottomLeft.x + (config.width - 1) * 60, bottomLeft.y + (config.height - 1) * 60, ); const gateWidth = config.width * 120; const gateHeight = config.height * 120; this.node.setContentSize(gateWidth, gateHeight); const bg = this.node.getChildByName("bg"); if (bg) bg.setContentSize(gateWidth, gateHeight); this.node.zIndex = -10; const line = this.node.getChildByName("line"); if (line) line.active = false; this.setClosedAmount(1); } hasOutstandingWork(): boolean { return !this.finished; } private configureArtwork(left: cc.Node, right: cc.Node) { const width = this.config.width * 120; const height = this.config.height * 120; const viewport = new cc.Node("doorViewport"); viewport.parent = this.node; viewport.setSiblingIndex(1); viewport.setContentSize(width, height); viewport.addComponent(cc.Mask).type = cc.Mask.Type.RECT; const innerCorner = this.createCornerFrame(this.innerCornerFrame); this.configureDoorPart(left, viewport, -1, width / 2, height, innerCorner); this.configureDoorPart(right, viewport, 1, width / 2, height, innerCorner); const frame = this.node.getChildByName("kuang"); const sprite = frame.getComponent(cc.Sprite); if (sprite) sprite.enabled = false; frame.setContentSize(width, height); this.drawFrame(frame, width, height, this.outerEdgeFrame, this.createCornerFrame(this.outerCornerFrame)); } private createCornerFrame(source: cc.SpriteFrame): cc.SpriteFrame { const frame = source.clone(); // These assets are top/right L corners, not complete rectangular frames. frame.insetTop = frame.insetRight = 16; frame.insetLeft = frame.insetBottom = 0; this.cornerFrames.push(frame); return frame; } private addArtwork(parent: cc.Node, name: string, frame: cc.SpriteFrame, x: number, y: number, width?: number, height?: number): cc.Node { const node = new cc.Node(name); node.parent = parent; node.setPosition(x, y); const sprite = node.addComponent(cc.Sprite); sprite.spriteFrame = frame; sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; const size = frame.getOriginalSize(); node.setContentSize(width == null ? size.width : width, height == null ? size.height : height); return node; } private configureDoorPart(node: cc.Node, viewport: cc.Node, side: number, width: number, height: number, corner: cc.SpriteFrame) { node.parent = viewport; node.setContentSize(width, height); const oldSprite = node.getComponent(cc.Sprite); if (oldSprite) oldSprite.enabled = false; const wood = new cc.Node("wood"); wood.parent = node; wood.setContentSize(width, height); wood.addComponent(cc.Mask).type = cc.Mask.Type.RECT; const seam = -side * width / 2; // Centre a 120px band on the circle; even heights crop half a row at each outer edge. for (let row = -Math.ceil(height / 240); row <= Math.ceil(height / 240); row++) { const y = row * 120; if (Math.abs(y) - 60 >= height / 2) continue; const tile = Math.abs(row) % 2 ? this.leftTileFrame : this.rightTileFrame; for (let col = 0; col * 120 < width; col++) { const plank = this.addArtwork(wood, "plank", tile, seam + side * (col * 120 + 60), y); plank.scaleX = side; } } const middle = this.addArtwork(wood, "middle", this.middleFrame, seam + side * 30, 0); middle.scaleX = side; // Each leaf clips its own half of the circle, so it follows the opening door. this.addArtwork(wood, "circle", this.counterFrame, seam, 0); const border = new cc.Node("innerFrame"); border.parent = node; const thickness = this.innerEdgeFrame.getOriginalSize().width; border.x = -side * thickness / 4; this.drawFrame(border, width + thickness / 2, height, this.innerEdgeFrame, corner, -side); const arc = this.addArtwork(node, "middleFrame", this.middleEdgeFrame, seam + side * (30 - thickness / 2), 0); arc.scaleX = -side; } private drawFrame(parent: cc.Node, width: number, height: number, edge: cc.SpriteFrame, corner: cc.SpriteFrame, gapSide = 0) { const thickness = edge.getOriginalSize().width; const cap = 16; // The inner strip's light/dark sides face opposite to its L-corner asset. const edgeDirection = edge === this.innerEdgeFrame ? -1 : 1; for (const xSide of [-1, 1]) { const x = xSide * (width - thickness) / 2; if (xSide === gapSide) { const length = Math.max(0, height / 2 - 60 - cap); if (length > 0) for (const ySide of [-1, 1]) { const line = this.addArtwork(parent, "innerSeam", edge, x, ySide * (60 + length / 2), thickness, length); line.scaleX = xSide * edgeDirection; } } else { const line = this.addArtwork(parent, "verticalEdge", edge, x, 0, thickness, height - cap * 2); line.scaleX = xSide * edgeDirection; } for (const ySide of [-1, 1]) { const node = this.addArtwork(parent, "corner", corner, xSide * (width - cap) / 2, ySide * (height - cap) / 2, cap, cap); node.getComponent(cc.Sprite).type = cc.Sprite.Type.SLICED; node.scaleX = xSide; node.scaleY = ySide; } } for (const ySide of [-1, 1]) { const line = this.addArtwork(parent, "horizontalEdge", edge, 0, ySide * (height - thickness) / 2, thickness, width - cap * 2); line.angle = 90 * ySide * edgeDirection; } } update(dt: number) { if (this.finished || !this.map || !cc.isValid(this.map, true)) return; if (this.map.gameOver || this.map.gameWin) return; if (this.state === SpawnGateState.Waiting) { if (this.queueIndex >= this.config.spawnQueue.length) { this.finishGate(); return; } // 冻结只暂停计时;真正的暂停或弹窗仍阻止下一批生成。 const scene = this.map.SceneManager; const paused = this.map.pause && (!this.map.iceTrue() || this.map.shopPause || (scene && scene.isSpawnGateBlocked())); if (paused || !this.map.isSpawnGateAreaEmpty(this.config)) return; this.beginBatch(); return; } this.elapsed += dt; if (this.state === SpawnGateState.Opening) { const progress = Math.min(1, this.elapsed / 0.3); this.setClosedAmount(1 - progress); if (progress >= 1) this.enterState(SpawnGateState.Revealing); } else if (this.state === SpawnGateState.Revealing) { const progress = Math.min(1, this.elapsed / 0.15); this.setBatchOpacity(progress); if (progress >= 1) this.enterState(SpawnGateState.Closing); } else if (this.state === SpawnGateState.Closing) { const progress = Math.min(1, this.elapsed / 0.2); this.setClosedAmount(progress); if (progress >= 1) this.completeBatch(); } } private beginBatch() { const queueItem = this.config.spawnQueue[this.queueIndex]; if (this.countLabel) this.countLabel.node.active = false; this.batch = this.map.createSpawnGateBatch( queueItem.members, this.config, this.queueIndex, ); this.queueIndex++; this.setBatchOpacity(0); this.enterState(SpawnGateState.Opening); } private completeBatch() { this.setClosedAmount(1); this.setBatchOpacity(1); this.map.unlockSpawnGateBatch(this.batch.nodes); this.batch = null; if (this.queueIndex >= this.config.spawnQueue.length) { this.finishGate(); } else { if (this.countLabel) this.countLabel.node.active = true; this.enterState(SpawnGateState.Waiting); } } private finishGate() { this.finished = true; const map = this.map; this.map = null; this.node.destroy(); if (map && cc.isValid(map, true)) map.onSpawnGateFinished(); } private enterState(state: SpawnGateState) { this.state = state; this.elapsed = 0; if (state === SpawnGateState.Revealing) { this.updateRemainingCount(); this.playRevealSmoke(); } } private updateRemainingCount() { if (!this.countLabel) return; const remaining = Math.max(0, this.config.spawnQueue.length - this.queueIndex); this.countLabel.string = remaining.toString(); } private playRevealSmoke() { if (!this.batch || !this.revealSmokeData) return; const smoke = new cc.Node("spawnGateSmoke"); smoke.parent = this.node.parent; smoke.zIndex = 1100; const gateWidth = this.config.width * 120; const gateHeight = this.config.height * 120; const scale = Math.max(gateWidth / 430.62, gateHeight / 373.6) * 0.8; smoke.scale = scale; smoke.setPosition( this.node.x - 8.78 * scale, this.node.y + 9.49 * scale, ); const skeleton = smoke.addComponent(sp.Skeleton); skeleton.skeletonData = this.revealSmokeData; skeleton.premultipliedAlpha = true; skeleton.loop = false; skeleton.timeScale = 1.5625; const track = skeleton.setAnimation(0, "animation", false); skeleton.setAttachment("锤子", null); if (track) track.trackTime = 0.78; skeleton.setCompleteListener(() => smoke.destroy()); } private setClosedAmount(value: number) { if (!this.leftDoor || !this.rightDoor) return; const halfWidth = this.config.width * 60; const offset = halfWidth * (1.5 - value); this.leftDoor.setPosition(-offset, 0); this.rightDoor.setPosition(offset, 0); this.leftDoor.active = this.rightDoor.active = value > 0; } private setBatchOpacity(progress: number) { if (!this.batch) return; for (let i = 0; i < this.batch.nodes.length; i++) { const node = this.batch.nodes[i]; if (node && cc.isValid(node)) { node.opacity = Math.round(this.batch.opacities[i] * progress); } } } onDestroy() { this.cornerFrames.forEach(frame => frame.destroy()); this.cornerFrames = []; this.map = null; this.batch = null; this.countLabel = null; this.leftDoor = this.rightDoor = null; } }