const { ccclass, property } = cc._decorator; /** * 通用横向滑动组件,挂在预制体的 IntroViewport 上。 * 只负责位移、手势和白点动画,不负责解锁配置及图片加载。 * 手势流程:begin 按下 → move 跟手 → end 松开选页 → update 每帧完成缓动。 * 实际页码 index 与白点位置 activeDot 是两套状态,介绍页数可以超过 3。 */ @ccclass export default class GuideCarousel extends cc.Component { @property(cc.SpriteFrame) dotFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) selectedDotFrame: cc.SpriteFrame = null; // 当前目标页索引,从 0 开始;pages 包含所有介绍页的占位节点。 index = 0; pages: cc.Node[] = []; private pageSlots: cc.Node[] = []; // 所有页面都挂到 track 下;移动这个父节点即可让整排页面一起移动。 private track: cc.Node = null; private dots: cc.Node[] = []; // 三个主圆点之外还有页面时,在对应侧显示使用未选中图片的小提示点。 private edgeDots: cc.Node[] = []; // 最多三个白点;打开时根据默认页定位。 private activeDot = 0; // 仅替换动画期间显示的辅助白点,平时隐藏,不表示额外页面。 private incomingDot: cc.Node = null; // 替换动画的移动方向:1 向右移动,-1 向左移动,0 不播放。 private dotDirection = 0; private dotElapsed = 0; // width 是一页宽度;offset 是整排页面的横向位移;origin 是按下时的位移基准。 private width = 880; private offset = 0; private origin = 0; // 拖动起点、最近位置和最近时间,用于计算拖动距离及甩动速度。 private startX = 0; private lastX = 0; private lastTime = 0; private speed = 0; // 记录本次拖动属于鼠标还是哪根手指,避免多个输入同时控制页面。 private pointer: string = null; // 松手缓动的起点、终点、已过时间;settling=true 时 update 会继续移动。 private from = 0; private destination = 0; private elapsed = 0; private settling = false; /** 传入所有页面、默认页索引和分页器容器;重复初始化会先清理旧内容。 */ initialize(count: number, selected: number, indicator: cc.Node) { this.clear(); this.width = this.node.width; this.track = this.node.getChildByName("Pages"); this.pageSlots = this.track.children.slice(); // 逻辑页共用预制体内的三个固定槽位,相邻三页对应不同槽位。 const pages = this.pages = Array.from({ length: count }, (_, index) => this.pageSlots[index % 3]); const dotCount = pages.length > 1 ? Math.min(3, pages.length) : 0; indicator.active = dotCount > 0; for (let i = 0; i < dotCount; i++) { const dot = this.createDot(indicator, "Dot" + i); dot.x = (i - (dotCount - 1) / 2) * 40; this.dots.push(dot); } if (pages.length > 3) { [-1, 1].forEach(direction => { const dot = this.createDot(indicator, direction < 0 ? "MoreLeftDot" : "MoreRightDot"); this.styleDot(dot, false); dot.scale = 0.6; dot.opacity = 255; dot.x = direction * 80; this.edgeDots.push(dot); }); this.incomingDot = this.createDot(indicator, "IncomingDot"); this.incomingDot.active = false; } this.index = Math.max(0, Math.min(pages.length - 1, selected)); this.activeDot = Math.min(Math.max(0, dotCount - 1), this.index); this.goTo(this.index, false); if (pages.length < 2) return; const type = cc.Node.EventType; this.node.on(type.TOUCH_START, this.touchStart, this); this.node.on(type.TOUCH_MOVE, this.touchMove, this); this.node.on(type.TOUCH_END, this.touchEnd, this); this.node.on(type.TOUCH_CANCEL, this.touchCancel, this); // 同时监听鼠标与触摸,兼容桌面预览中没有被转换成触摸事件的鼠标拖动。 this.node.on(type.MOUSE_DOWN, this.mouseDown, this); this.node.on(type.MOUSE_MOVE, this.mouseMove, this); this.node.on(type.MOUSE_UP, this.mouseUp, this); this.node.on(type.MOUSE_LEAVE, this.mouseLeave, this); } /** 清理本组件的监听、页面轨道和白点,防止重复打开后节点/事件越来越多。 */ clear() { this.node.targetOff(this); this.pointer = null; this.settling = false; this.dotDirection = 0; this.pageSlots.forEach(page => { if (cc.isValid(page, true)) page.active = false; }); this.dots.concat(this.edgeDots, this.incomingDot ? [this.incomingDot] : []).forEach(dot => { dot.removeFromParent(); dot.destroy(); }); this.track = null; this.incomingDot = null; this.dots = []; this.edgeDots = []; this.pages = []; this.offset = 0; } /** 切换目标页;animated=false 用于首次打开时直接定位,默认则平滑过渡。 */ goTo(index: number, animated = true) { if (!this.track || !this.pages.length) return; this.pointer = null; const previous = this.index; const previousDot = this.activeDot; this.resetDotAnimation(); // 限制在真实页码范围内,所以边界拖动不会进入不存在的页面。 this.index = Math.max(0, Math.min(this.pages.length - 1, index)); // 查看更早页:左点 → 中点 → 右点 → 一直保持右点;查看更新页则反向移动。 // 这里只根据实际页码变化量移动白点,并不要求已经到达介绍列表的两端。 // 如果只是边界回弹,页码未变,白点也不会改变。 this.activeDot = Math.max(0, Math.min(this.dots.length - 1, this.activeDot + this.index - previous)); this.updateEdgeDots(); this.from = this.offset; // 第 n 页原本在 n*width 处,让整排左移 n*width,就能把该页放到视口中心。 this.destination = -this.index * this.width; this.elapsed = 0; this.settling = animated; if (!animated) this.offset = this.destination; this.dots.forEach((dot, i) => this.styleDot(dot, i === this.activeDot)); const delta = this.index - previous; // 页码确实改变,但高亮点已经停在最左/最右时,播放白点从旁边补进的替换动画。 // 真正到达列表边界且页码未改变时不播放。 if (this.incomingDot && animated && delta !== 0 && previousDot === this.activeDot && ((delta < 0 && this.activeDot === 0) || (delta > 0 && this.activeDot === 2))) { this.dotDirection = delta < 0 ? 1 : -1; this.dotElapsed = 0; this.incomingDot.active = true; this.animateDots(0); } this.render(); this.node.emit("page-changed", this.index); } // 分页图片由预制体直接持有,重复打开无需异步加载。 private createDot(parent: cc.Node, name: string): cc.Node { const dot = new cc.Node(name); dot.parent = parent; dot.addComponent(cc.Sprite); this.styleDot(dot, false); return dot; } private styleDot(dot: cc.Node, selected: boolean) { dot.getComponent(cc.Sprite).spriteFrame = selected ? this.selectedDotFrame : this.dotFrame; dot.scale = 1; } private updateEdgeDots() { if (!this.edgeDots.length) return; const firstVisible = this.index - this.activeDot; this.edgeDots[0].active = firstVisible > 0; this.edgeDots[1].active = firstVisible + this.dots.length < this.pages.length; } // 动画结束或被打断时恢复三个固定位置,隐藏辅助点,选中点使用白色圆点图片。 private resetDotAnimation() { this.dotDirection = 0; if (this.incomingDot) this.incomingDot.active = false; this.edgeDots.forEach(dot => dot.opacity = 255); this.dots.forEach((dot, i) => { dot.x = (i - (this.dots.length - 1) / 2) * 40; dot.opacity = 255; this.styleDot(dot, i === this.activeDot); }); } /** dt 是这一帧经过的秒数;旧点移出并淡出,辅助点从另一边进入并放大。 */ private animateDots(dt: number) { if (!this.dotDirection) return; this.dotElapsed += dt; // 替换动画持续 0.28 秒,三次方缓动使运动先快后慢。 const t = Math.min(1, this.dotElapsed / 0.28); const eased = 1 - Math.pow(1 - t, 3); const direction = this.dotDirection; // 补入圆点从边缘经过时,提示点先让位再淡入,避免两点重叠。 this.edgeDots.forEach(dot => dot.opacity = Math.round(255 * eased)); this.dots.forEach((dot, i) => { const destination = i + direction; this.styleDot(dot, (eased < 0.5 ? i : destination) === this.activeDot); dot.x = (i - 1) * 40 + direction * 40 * eased; dot.opacity = destination < 0 || destination > 2 ? Math.round(255 * (1 - eased)) : 255; }); this.incomingDot.x = -direction * (80 - 40 * eased); this.incomingDot.opacity = Math.round(255 * eased); this.styleDot(this.incomingDot, true); this.incomingDot.scale = 0.5 + 0.5 * eased; if (t === 1) this.resetDotAnimation(); } // 把屏幕坐标转成视口局部坐标,面板有缩放时仍能正确计算拖动距离。 private x(event: cc.Event.EventTouch | cc.Event.EventMouse) { return this.node.convertToNodeSpaceAR(event.getLocation()).x; } /** 按下时接管当前位置,允许用户在回弹中途再次抓住页面。 */ private begin(pointer: string, x: number) { if (this.pointer !== null || this.pages.length < 2) return; this.pointer = pointer; this.settling = false; this.startX = this.lastX = x; this.lastTime = Date.now(); this.speed = 0; this.origin = this.offset; const min = -(this.pages.length - 1) * this.width; // 边界位移已经经过阻尼压缩;这里反算原始拖动量。 // 否则在回弹途中重新按下,第一次 move 会再次压缩位移,导致内容突然跳动。 if (this.offset > 0) this.origin = 240 * this.offset / (120 - this.offset); else if (this.offset < min) { const extra = min - this.offset; this.origin = min - 240 * extra / (120 - extra); } } /** 拖动中直接更新位移,实现跟手效果。 */ private move(pointer: string, x: number) { if (this.pointer !== pointer) return; const now = Date.now(); this.speed = (x - this.lastX) / Math.max(1, now - this.lastTime); this.lastX = x; this.lastTime = now; const raw = this.origin + x - this.startX; const min = -(this.pages.length - 1) * this.width; // 合法位移范围是 [min, 0]。超过任一端后使用阻尼:越拉越费劲,位移趋近 120。 // 240 控制阻力曲线;范围内则直接使用 raw,不额外减慢跟手移动。 this.offset = raw > 0 ? 120 * raw / (240 + raw) : raw < min ? min - 120 * (min - raw) / (240 + min - raw) : raw; this.render(); } /** 松手时根据距离/速度决定目标页,再交给 goTo 做吸附或回弹。 */ private end(pointer: string, cancel = false) { if (this.pointer !== pointer) return; const distance = this.lastX - this.startX; // 停住超过 100 毫秒再松手,就不再把之前的移动速度当作快速甩动。 const speed = Date.now() - this.lastTime < 100 ? this.speed : 0; let next = Math.round(-this.offset / this.width); // 拖过页宽的 18%,或速度超过每毫秒 0.45 个局部坐标单位,即按方向翻一页。 // 触摸被系统取消时,使用当前位置最近的页面,不额外按甩动方向翻页。 if (!cancel && (Math.abs(distance) > this.width * 0.18 || Math.abs(speed) > 0.45)) { next = this.index + ((Math.abs(speed) > 0.45 ? speed : distance) < 0 ? 1 : -1); } this.goTo(next); } // 触摸事件带手指 ID;鼠标只响应左键。离开视口也结束拖动,避免内容一直粘着鼠标。 private touchStart(event: cc.Event.EventTouch) { this.begin("touch" + event.getID(), this.x(event)); event.stopPropagation(); } private touchMove(event: cc.Event.EventTouch) { this.move("touch" + event.getID(), this.x(event)); event.stopPropagation(); } private touchEnd(event: cc.Event.EventTouch) { this.end("touch" + event.getID()); event.stopPropagation(); } private touchCancel(event: cc.Event.EventTouch) { this.end("touch" + event.getID(), true); event.stopPropagation(); } private mouseDown(event: cc.Event.EventMouse) { if (event.getButton() === 0) this.begin("mouse", this.x(event)); } private mouseMove(event: cc.Event.EventMouse) { this.move("mouse", this.x(event)); } private mouseUp(event: cc.Event.EventMouse) { if (event.getButton() === 0) this.end("mouse"); } private mouseLeave() { this.end("mouse"); } /** 应用当前位移;离视口中心越远的页面稍微缩小、变淡,让过渡更自然。 */ private render() { this.track.x = this.offset; this.pageSlots.forEach(page => page.active = false); for (let i = Math.max(0, this.index - 1); i <= Math.min(this.pages.length - 1, this.index + 1); i++) { const page = this.pages[i]; page.active = true; page.x = i * this.width; const distance = Math.min(1, Math.abs(i * this.width + this.offset) / this.width); page.scale = 1 - distance * 0.025; page.opacity = Math.round(255 - distance * 45); } } /** Cocos 每帧调用:推进白点动画,以及松手后的 0.32 秒页面缓动。 */ update(dt: number) { this.animateDots(dt); if (!this.settling) return; this.elapsed += dt; const t = Math.min(1, this.elapsed / 0.32); this.offset = this.from + (this.destination - this.from) * (1 - Math.pow(1 - t, 3)); this.render(); if (t === 1) this.settling = false; } // 面板隐藏时结束未完成的手势和动画,避免再次显示时残留半途状态。 onDisable() { this.resetDotAnimation(); this.pointer = null; this.settling = false; if (this.track && cc.isValid(this.track, true) && this.pageSlots.every(page => cc.isValid(page, true))) { this.offset = -this.index * this.width; this.render(); } } }