const { ccclass, property } = cc._decorator; @ccclass export default class Test extends cc.Component { @property({ tooltip: "整体动画倍速(1为原速,2为两倍速)" }) animationSpeed: number = 3; @property({ tooltip: "每段完整动画结束后的观察间隔(秒)" }) nextStepDelay: number = 1.5; private slotPositions: cc.Vec3[] = []; private path: cc.Node[] = []; private retractGlow: cc.Node = null; start(): void { // Test 下的藤蔓节点按 Hierarchy 顺序组成从起点到终点的路径。 this.path = this.node.children.filter(node => node.active && !!this.getBodySprite(node)); if (this.path.length < 2) { cc.warn("Test 节点下至少需要两个有效藤蔓节点"); return; } const lastIndex = this.path.length - 1; if (this.path[0].name.indexOf("end_") === 0 && this.path[lastIndex].name.indexOf("start_") === 0) { this.path.reverse(); } this.slotPositions = this.path.map(node => node.position.clone()); if (!this.validatePath() || !this.validateSprites()) return; for (let index = 0; index < lastIndex; index++) { const sprite = this.getFirstChildSprite(this.path[index]); if (sprite) { sprite.fillRange = 0; sprite.node.opacity = 0; } } const tailPosition = this.slotPositions[lastIndex]; const previousPosition = this.slotPositions[lastIndex - 1]; const glowStart = cc.v3( tailPosition.x + (tailPosition.x - previousPosition.x) / 2, tailPosition.y + (tailPosition.y - previousPosition.y) / 2 ); this.retractGlow = this.createRetractGlow(glowStart); this.playRound(lastIndex); } private playRound(tailIndex: number): void { const tail = this.path[tailIndex]; const lastIndex = this.path.length - 1; const duration = this.getStepDuration(); if (tailIndex < lastIndex) { this.retractGlow.setPosition(this.slotPositions[tailIndex]); } this.retractGlow.active = true; this.moveRetractGlowThroughRound(tailIndex); if (tailIndex === lastIndex) { this.animateFillRange(tail); } else { this.animateFirstChildFillRange(tail); } cc.tween(this.node) .delay(duration) .call(() => this.playNextSegment(tailIndex - 1)) .start(); } private playNextSegment(nextIndex: number): void { if (nextIndex >= 0) { const nextSegment = this.path[nextIndex]; const duration = this.getStepDuration(); this.animateFirstChildAppear(nextSegment); this.animateFillRange(nextSegment); cc.tween(this.node) .delay(duration) .call(() => this.finishRound(nextIndex)) .start(); return; } this.retractGlow.active = false; } private finishRound(nextTailIndex: number): void { this.retractGlow.active = false; this.scheduleOnce(() => this.playRound(nextTailIndex), this.nextStepDelay); } private animateFillRange(segment: cc.Node): void { const sprite = this.getBodySprite(segment); if (!sprite) { cc.warn(`藤蔓 ${segment.name} 缺少用于消失动画的 Sprite 子节点`); return; } const duration = this.getStepDuration(); const fullRange = sprite.fillRange < 0 ? -1 : 1; const targetRange = sprite.fillType === cc.Sprite.FillType.RADIAL ? fullRange * 0.8 : 0; sprite.fillRange = fullRange; cc.tween(sprite) .to(duration, { fillRange: targetRange }, { easing: "linear" }) .start(); } private animateFirstChildAppear(segment: cc.Node): void { const sprite = this.getFirstChildSprite(segment); if (!sprite) return; const duration = this.getStepDuration(); sprite.fillRange = 0; sprite.node.opacity = 0; cc.tween(sprite) .delay(duration / 3) .to(duration / 2, { fillRange: 1 }, { easing: "linear" }) .start(); cc.tween(sprite.node) .delay(duration / 3) .to(duration / 2, { opacity: 255 }, { easing: "linear" }) .start(); } private animateFirstChildFillRange(segment: cc.Node): void { const sprite = this.getFirstChildSprite(segment); if (!sprite) return; const duration = this.getStepDuration(); cc.tween(sprite) .to(duration, { fillRange: 0 }, { easing: "linear" }) .start(); } private getFirstChildSprite(segment: cc.Node): cc.Sprite { const firstChild = segment.children[0]; const sprite = firstChild && firstChild.getComponent(cc.Sprite); if (!sprite) { cc.warn(`藤蔓 ${segment.name} 缺少第一个 Sprite 子节点`); } return sprite; } private getBodyNode(segment: cc.Node): cc.Node { return segment.children[2] || segment.children[1]; } private getBodySprite(segment: cc.Node): cc.Sprite { const bodyNode = this.getBodyNode(segment); return bodyNode && bodyNode.getComponent(cc.Sprite); } private getStepDuration(): number { return 1 / Math.max(this.animationSpeed, 0.01); } private validatePath(): boolean { for (let index = 1; index < this.slotPositions.length; index++) { const previous = this.slotPositions[index - 1]; const current = this.slotPositions[index]; const deltaX = Math.abs(current.x - previous.x); const deltaY = Math.abs(current.y - previous.y); if ((deltaX < 0.01 && deltaY < 0.01) || (deltaX >= 0.01 && deltaY >= 0.01)) { cc.warn(`藤蔓路径 ${this.path[index - 1].name} -> ${this.path[index].name} 不是有效的上下左右相邻关系`); return false; } } return true; } private validateSprites(): boolean { const lastIndex = this.path.length - 1; for (let index = 0; index <= lastIndex; index++) { const segment = this.path[index]; const bodySprite = this.getBodySprite(segment); if (!bodySprite || bodySprite.type !== cc.Sprite.Type.FILLED) { cc.warn(`藤蔓 ${segment.name} 的主体 Sprite 必须使用 FILLED 类型`); return false; } if (this.isCorner(index) && bodySprite.fillType !== cc.Sprite.FillType.RADIAL) { cc.warn(`拐角藤蔓 ${segment.name} 的主体 Sprite 必须使用 RADIAL 填充`); return false; } if (index < lastIndex) { const firstSprite = this.getFirstChildSprite(segment); if (!firstSprite || firstSprite.type !== cc.Sprite.Type.FILLED) { cc.warn(`藤蔓 ${segment.name} 的第一个 Sprite 子节点必须使用 FILLED 类型`); return false; } } } return true; } private getTowardStartPosition(index: number): cc.Vec3 { if (index > 0) return this.slotPositions[index - 1]; const start = this.slotPositions[0]; const next = this.slotPositions[1]; return cc.v3(start.x + start.x - next.x, start.y + start.y - next.y); } private isCorner(index: number): boolean { if (index <= 0 || index >= this.slotPositions.length - 1) return false; const current = this.slotPositions[index]; const towardEnd = this.slotPositions[index + 1]; const towardStart = this.slotPositions[index - 1]; const endX = towardEnd.x - current.x; const endY = towardEnd.y - current.y; const startX = towardStart.x - current.x; const startY = towardStart.y - current.y; return Math.abs(endX * startY - endY * startX) > 0.01; } private moveRetractGlowThroughRound(tailIndex: number): void { const tween = cc.tween(this.retractGlow); const lastMovementIndex = Math.max(0, tailIndex - 1); const duration = this.getStepDuration(); for (let index = tailIndex; index >= lastMovementIndex; index--) { const segment = this.path[index]; const from = this.slotPositions[index]; const toward = this.getTowardStartPosition(index); const isCorner = this.isCorner(index); const fillNode = this.getBodyNode(segment); const stopsAtCenter = index !== tailIndex && fillNode && fillNode.name.indexOf("start_") === 0; if (stopsAtCenter) { tween.to(duration, { position: from }, { easing: "linear" }); } else if (isCorner && index !== tailIndex) { tween .to(duration / 2, { position: from }, { easing: "linear" }) .to(duration / 2, { position: this.getBoundaryPosition(from, toward) }, { easing: "linear" }); } else { tween.to(duration, { position: this.getBoundaryPosition(from, toward) }, { easing: "linear" }); } } tween.start(); } private getBoundaryPosition(from: cc.Vec3, toward: cc.Vec3): cc.Vec3 { return cc.v3((from.x + toward.x) / 2, (from.y + toward.y) / 2); } private createRetractGlow(position: cc.Vec3): cc.Node { const root = new cc.Node("retract_glow"); const pulse = new cc.Node("pulse"); const graphics = pulse.addComponent(cc.Graphics); const orbit = new cc.Node("orbit"); graphics.fillColor = cc.color(145, 255, 45, 45); graphics.circle(0, 0, 30); graphics.fill(); graphics.fillColor = cc.color(190, 255, 70, 95); graphics.circle(0, 0, 19); graphics.fill(); graphics.fillColor = cc.color(245, 255, 170, 240); graphics.circle(0, 0, 7); graphics.fill(); for (let index = 0; index < 4; index++) { const spark = new cc.Node("spark"); const sparkGraphics = spark.addComponent(cc.Graphics); const angle = Math.PI * index / 2; spark.setPosition(Math.cos(angle) * 24, Math.sin(angle) * 24); sparkGraphics.fillColor = cc.color(220, 255, 95, 210); sparkGraphics.circle(0, 0, 3); sparkGraphics.fill(); orbit.addChild(spark); } root.setPosition(position); root.zIndex = 2000; root.addChild(pulse); root.addChild(orbit); this.node.addChild(root); cc.tween(pulse) .repeatForever( cc.tween() .to(0.4, { scale: 1.18, opacity: 150 }, { easing: "sineInOut" }) .to(0.4, { scale: 0.9, opacity: 255 }, { easing: "sineInOut" }) ) .start(); cc.tween(orbit) .by(1.2, { angle: 360 }) .repeatForever() .start(); return root; } onDestroy(): void { this.unscheduleAllCallbacks(); cc.Tween.stopAllByTarget(this.node); if (this.retractGlow && cc.isValid(this.retractGlow)) { cc.Tween.stopAllByTarget(this.retractGlow); } } }