298 lines
15 KiB
TypeScript
298 lines
15 KiB
TypeScript
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(); }
|
||
}
|
||
}
|