MatchMaster/assets/GameplayIntro/script/GuideCarousel.ts

298 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(); }
}
}