Files
mp-xieyingeng/assets/prefabs/PageWriteLevels.ts
2026-04-30 16:35:08 +08:00

664 lines
24 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.
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
import { WxSDK, getUserProfile } from 'db://assets/scripts/utils/WxSDK';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
import { ApiEnvelope } from 'db://assets/scripts/types/ApiTypes';
const { ccclass, property } = _decorator;
/**
* 布局配置
* view (ScrollView 的可视窗口) 宽 900高 1000anchor (0.5, 1) 顶部中点
* ListTpl (item) 宽 300高 400
*
* 每页 2 行 × 2 列 = 4 个关卡
* PADDING 不再手写,由 VIEW / ITEM / SPACING 自动派生(见 _getHorizontalPadding / _getVerticalPadding
* 保证 item 网格在 view 内始终居中。改 SPACING 时不用再算 PADDING。
*/
const LAYOUT_CONFIG = {
COLS: 2,
ROWS: 2,
ITEM_WIDTH: 300,
ITEM_HEIGHT: 400,
SPACING_X: 160,
SPACING_Y: 180,
VIEW_WIDTH: 900,
VIEW_HEIGHT: 1000,
};
/** 必须选择的关卡数量 */
const MAX_SELECTION = 6;
const PAGE_CONFIG = {
/** 滑动距离超过页宽的这个比例就翻页 */
SWIPE_THRESHOLD: 0.2,
/** 快速滑动速度阈值px/s超过此值即使距离不够也翻页 */
VELOCITY_THRESHOLD: 500,
/** 翻页动画时长(秒) */
SNAP_DURATION: 0.3,
};
@ccclass('PageWriteLevels')
export class PageWriteLevels extends BaseView {
@property({ type: Node, tooltip: '返回按钮' })
backBtn: Node | null = null;
@property({ type: Node, tooltip: 'ScrollView可视区域节点' })
scrollView: Node | null = null;
@property({ type: Node, tooltip: '列表content节点' })
listContent: Node | null = null;
@property({ type: Node, tooltip: '列表项模板' })
listTemplate: Node | null = null;
@property({ type: Node, tooltip: '已选关卡提示Label节点' })
selectedLabel: Node | null = null;
@property({ type: Node, tooltip: '完成按钮节点' })
completeBtn: Node | null = null;
@property({ type: Node, tooltip: '预览按钮节点' })
previewBtn: Node | null = null;
@property({ type: Node, tooltip: '分享标题输入框节点' })
shareTitleEditBox: Node | null = null;
@property({ type: Node, tooltip: '挑战数据按钮节点' })
dataBtn: Node | null = null;
private _selectedIndices: Set<number> = new Set();
private _currentPage: number = 0;
private _totalPages: number = 0;
private _levelCount: number = 0;
private readonly ITEMS_PER_PAGE = LAYOUT_CONFIG.ROWS * LAYOUT_CONFIG.COLS;
private _itemNodes: Node[] = [];
private _scrollViewComp: ScrollView | null = null;
private _touchStartOffsetX: number = 0;
private _touchStartTime: number = 0;
/** 缓存 view 节点的 UITransform避免每次 _updateContentSize 重复查找 */
private _viewTransform: UITransform | null = null;
/** 防止重复提交 */
private _isSubmitting: boolean = false;
onViewLoad(): void {
console.log('[PageWriteLevels] onViewLoad');
this._initButtons();
this._initScrollView();
this._updateSelectionUI();
}
private _initButtons(): void {
if (this.backBtn) {
this.backBtn.on(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.previewBtn) {
this.previewBtn.on(Button.EventType.CLICK, this._onPreviewClick, this);
}
if (this.completeBtn) {
this.completeBtn.on(Button.EventType.CLICK, this._onCompleteClick, this);
}
if (this.dataBtn) {
this.dataBtn.on(Button.EventType.CLICK, this._onDataClick, this);
}
}
private _initScrollView(): void {
if (this.scrollView) {
this._scrollViewComp = this.scrollView.getComponent(ScrollView);
if (this._scrollViewComp) {
this._scrollViewComp.inertia = false;
}
this.scrollView.on(Node.EventType.TOUCH_START, this._onTouchStart, this);
this.scrollView.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this.scrollView.on(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
// 缓存 view 的 UITransform
const viewNode = this.scrollView.getChildByName('view');
if (viewNode) {
this._viewTransform = viewNode.getComponent(UITransform);
}
}
// content anchor 设为 (0, 1) 左上角,方便位置计算且符合 ScrollView 标准用法
if (this.listContent) {
const contentTransform = this.listContent.getComponent(UITransform);
if (contentTransform) {
contentTransform.setAnchorPoint(0, 1);
}
}
}
private _onTouchStart(_event: EventTouch): void {
if (!this._scrollViewComp) return;
this._touchStartOffsetX = this._scrollViewComp.getScrollOffset().x;
this._touchStartTime = Date.now();
}
/**
* 触摸结束:根据滑动距离和速度决定翻页方向
*/
private _onTouchEnd(_event: EventTouch): void {
if (!this._scrollViewComp || this._totalPages <= 1) return;
const currentOffsetX = this._scrollViewComp.getScrollOffset().x;
const deltaX = currentOffsetX - this._touchStartOffsetX;
const elapsedTime = (Date.now() - this._touchStartTime) / 1000;
const velocity = elapsedTime > 0 ? Math.abs(deltaX) / elapsedTime : 0;
let targetPage = this._currentPage;
const swipeDistance = Math.abs(deltaX);
const threshold = LAYOUT_CONFIG.VIEW_WIDTH * PAGE_CONFIG.SWIPE_THRESHOLD;
if (swipeDistance > threshold || velocity > PAGE_CONFIG.VELOCITY_THRESHOLD) {
if (deltaX < 0) {
targetPage = Math.min(this._currentPage + 1, this._totalPages - 1);
} else if (deltaX > 0) {
targetPage = Math.max(this._currentPage - 1, 0);
}
}
this._scrollToPage(targetPage);
}
onViewShow(): void {
console.log('[PageWriteLevels] onViewShow');
// 仅首次初始化列表,从预览页返回时保留选中状态
if (this._itemNodes.length === 0) {
void this._initLevelList();
}
}
private async _initLevelList(): Promise<void> {
this._clearList();
// 拉取当前用户所有已通关关卡
const levels = await CompletedLevelsManager.instance.fetch();
if (levels === null) {
console.warn('[PageWriteLevels] 获取已通关关卡失败');
ToastManager.instance.show('获取关卡列表失败,请稍后重试');
return;
}
this._levelCount = levels.length;
console.log('[PageWriteLevels] 已通关关卡总数:', this._levelCount);
if (this._levelCount === 0) {
console.warn('[PageWriteLevels] 用户尚未通关任何关卡');
ToastManager.instance.show('还没有已通关的关卡,快去玩几关吧');
return;
}
this._totalPages = Math.ceil(this._levelCount / this.ITEMS_PER_PAGE);
console.log('[PageWriteLevels] 总页数:', this._totalPages);
this._updateContentSize();
this._createItems();
if (this._scrollViewComp && this._totalPages > 0) {
this._scrollToPage(0, false);
}
}
private _clearList(): void {
for (const node of this._itemNodes) {
if (node && node.isValid) {
node.destroy();
}
}
this._itemNodes = [];
this._selectedIndices.clear();
}
/**
* 水平 padding让整行 item 在 view 宽度内居中
* padding_left = (VIEW_WIDTH - cols*ITEM_WIDTH - (cols-1)*SPACING_X) / 2
*/
private _getHorizontalPadding(): number {
const { VIEW_WIDTH, COLS, ITEM_WIDTH, SPACING_X } = LAYOUT_CONFIG;
const gridWidth = COLS * ITEM_WIDTH + (COLS - 1) * SPACING_X;
return (VIEW_WIDTH - gridWidth) / 2;
}
/**
* 垂直 padding让整列 item 在 view 高度内居中
*/
private _getVerticalPadding(): number {
const { VIEW_HEIGHT, ROWS, ITEM_HEIGHT, SPACING_Y } = LAYOUT_CONFIG;
const gridHeight = ROWS * ITEM_HEIGHT + (ROWS - 1) * SPACING_Y;
return (VIEW_HEIGHT - gridHeight) / 2;
}
private _updateContentSize(): void {
if (!this.listContent) return;
const contentTransform = this.listContent.getComponent(UITransform);
if (!contentTransform) return;
const totalWidth = this._totalPages * LAYOUT_CONFIG.VIEW_WIDTH;
contentTransform.setContentSize(totalWidth, LAYOUT_CONFIG.VIEW_HEIGHT);
// content anchor=(0,1)view anchor=(0.5,1)。
// view 本地坐标系下view 的左上角 = (-viewWidth/2, 0)。
// content 的 anchor 点(左上角)需要贴到 view 的左上角。
if (this._viewTransform) {
const viewWidth = this._viewTransform.contentSize.width;
this.listContent.setPosition(-viewWidth / 2, 0, 0);
}
}
private _createItems(): void {
if (!this.listTemplate || !this.listContent) return;
for (let i = 0; i < this._levelCount; i++) {
const itemNode = this._createItem(i);
if (itemNode) {
this.listContent.addChild(itemNode);
this._itemNodes.push(itemNode);
this._loadAndRefreshCover(itemNode, i);
}
}
}
private _createItem(index: number): Node | null {
if (!this.listTemplate) return null;
const item = instantiate(this.listTemplate);
item.active = true;
item.name = `item_${index}`;
const pos = this._calculateItemPosition(index);
item.setPosition(pos.x, pos.y, 0);
// 设置默认名称和选中状态(封面由 _loadAndRefreshCover 异步填充)
this._initItemState(item, index);
// 禁用 Button 组件,防止它拦截触摸事件导致 ScrollView 无法滑动
const button = item.getComponent(Button);
if (button) {
button.enabled = false;
}
this._setupItemClick(item, index);
return item;
}
/**
* content anchor=(0,1)原点在左上角x 正向右y 负向下
*/
private _calculateItemPosition(index: number): { x: number, y: number } {
const pageIndex = Math.floor(index / this.ITEMS_PER_PAGE);
const localIndex = index % this.ITEMS_PER_PAGE;
const col = localIndex % LAYOUT_CONFIG.COLS;
const row = Math.floor(localIndex / LAYOUT_CONFIG.COLS);
const paddingLeft = this._getHorizontalPadding();
const paddingTop = this._getVerticalPadding();
const x = pageIndex * LAYOUT_CONFIG.VIEW_WIDTH
+ paddingLeft
+ col * (LAYOUT_CONFIG.ITEM_WIDTH + LAYOUT_CONFIG.SPACING_X)
+ LAYOUT_CONFIG.ITEM_WIDTH / 2;
const y = -(paddingTop
+ row * (LAYOUT_CONFIG.ITEM_HEIGHT + LAYOUT_CONFIG.SPACING_Y)
+ LAYOUT_CONFIG.ITEM_HEIGHT / 2);
return { x, y };
}
/**
* 初始化 item 的默认名称和选中状态(不设置封面,由异步加载负责)
*/
private _initItemState(item: Node, index: number): void {
const level = CompletedLevelsManager.instance.getByIndex(index);
const levelName = item.getChildByName('LevelName');
if (levelName) {
const label = levelName.getComponent(Label);
if (label) {
label.string = level ? `${level.level}` : `${index + 1}`;
}
}
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = this._selectedIndices.has(index);
// 禁用 Toggle 交互,仅作为视觉指示器,选中逻辑由 item Button 统一处理
toggle.interactable = false;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = this._selectedIndices.has(index);
}
}
}
/**
* 异步加载关卡封面图并填充到 item
*/
private async _loadAndRefreshCover(item: Node, index: number): Promise<void> {
const level = CompletedLevelsManager.instance.getByIndex(index);
if (!level || !item.isValid) return;
const spriteFrame = await CompletedLevelsManager.instance.loadImage(level.image1Url);
if (!spriteFrame || !item.isValid) return;
const levelCover = item.getChildByName('LevelCover');
if (levelCover) {
const sprite = levelCover.getComponent(Sprite);
if (sprite) {
sprite.spriteFrame = spriteFrame;
}
}
}
private _setupItemClick(item: Node, index: number): void {
// 用触摸事件代替 Button区分点击和滑动
// 短距离松手 = 点击(切换选中),长距离 = 滑动(交给 ScrollView
let touchStartPos: Vec2 | null = null;
item.on(Node.EventType.TOUCH_START, (event: EventTouch) => {
touchStartPos = event.getUILocation();
}, this);
item.on(Node.EventType.TOUCH_END, (event: EventTouch) => {
if (!touchStartPos) return;
const endPos = event.getUILocation();
const dx = endPos.x - touchStartPos.x;
const dy = endPos.y - touchStartPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
touchStartPos = null;
// 滑动距离小于阈值才算点击
if (distance < 20) {
const isCurrentlySelected = this._selectedIndices.has(index);
this._onItemToggle(index, !isCurrentlySelected);
}
}, this);
item.on(Node.EventType.TOUCH_CANCEL, () => {
touchStartPos = null;
}, this);
}
/**
* 滚动/吸附到指定页
*/
private _scrollToPage(pageIndex: number, animated: boolean = true): void {
if (!this._scrollViewComp) return;
this._currentPage = pageIndex;
const offsetX = pageIndex * LAYOUT_CONFIG.VIEW_WIDTH;
this._scrollViewComp.scrollToOffset(
new Vec2(offsetX, 0),
animated ? PAGE_CONFIG.SNAP_DURATION : 0
);
}
private _onItemToggle(index: number, selected: boolean): void {
// 如果要选中但已达上限,阻止选中
if (selected && this._selectedIndices.size >= MAX_SELECTION) {
// 恢复 toggle 的视觉状态为未选中
const item = this._itemNodes[index];
if (item) {
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = false;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = false;
}
}
}
console.log(`[PageWriteLevels] 已达最大选择数量 ${MAX_SELECTION},无法继续选择`);
return;
}
if (selected) {
this._selectedIndices.add(index);
} else {
this._selectedIndices.delete(index);
}
console.log('[PageWriteLevels] item切换选中:', index, selected, '当前已选:', this._selectedIndices.size);
const item = this._itemNodes[index];
if (item) {
const isSelected = item.getChildByName('IsSelected');
if (isSelected) {
const toggle = isSelected.getComponent(Toggle);
if (toggle) {
toggle.isChecked = selected;
}
const checkmark = isSelected.getChildByName('Checkmark');
if (checkmark) {
checkmark.active = selected;
}
}
}
this._updateSelectionUI();
}
/**
* 根据当前选中数量更新 SelectedLabel 文本和按钮可用状态。
* - 未选择任何关卡时:显示 "请选择6关"
* - 已选但不足6关时显示 "已选 x 关,还差 y 关"
* - 恰好选满6关时显示 "已选满6关",启用按钮
*/
private _updateSelectionUI(): void {
const count = this._selectedIndices.size;
const remaining = MAX_SELECTION - count;
const isFull = remaining <= 0;
// 更新 SelectedLabel 文本
if (this.selectedLabel) {
const label = this.selectedLabel.getComponent(Label);
if (label) {
if (count === 0) {
label.string = `请选择${MAX_SELECTION}`;
} else if (isFull) {
label.string = `已选满${MAX_SELECTION}`;
} else {
label.string = `已选${count}关,还差${remaining}`;
}
}
}
// 更新 CompleteButton 和 PreviewButton 的可用状态
if (this.completeBtn) {
const btn = this.completeBtn.getComponent(Button);
if (btn) {
btn.interactable = isFull;
}
}
if (this.previewBtn) {
const btn = this.previewBtn.getComponent(Button);
if (btn) {
btn.interactable = true;
}
}
}
private _onBackClick(): void {
console.log('[PageWriteLevels] 返回按钮点击');
ViewManager.instance.back();
}
private _onDataClick(): void {
ViewManager.instance.open('PagePKData');
}
/**
* 校验是否已选满关卡,未满则 Toast 提示
* @returns true 表示校验通过
*/
private _validateSelection(): boolean {
if (this._selectedIndices.size < MAX_SELECTION) {
const remaining = MAX_SELECTION - this._selectedIndices.size;
ToastManager.instance.show(`还需选择${remaining}个关卡`);
return false;
}
return true;
}
private _onPreviewClick(): void {
if (!this._validateSelection()) return;
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
ViewManager.instance.open('PagePreviewLevels', {
params: {
selectedIndices: Array.from(this._selectedIndices),
shareTitle: shareTitle
}
});
}
private async _onCompleteClick(): Promise<void> {
if (!this._validateSelection()) return;
const shareTitle = this.shareTitleEditBox?.getComponent(EditBox)?.string?.trim() || '';
if (!shareTitle) {
ToastManager.instance.show('请输入分享标题');
return;
}
if (this._isSubmitting) return;
this._isSubmitting = true;
try {
const levelIds = this._getSelectedLevelIds();
if (levelIds.length !== MAX_SELECTION) {
ToastManager.instance.show('获取关卡数据失败,请重试');
return;
}
const shareCode = await ShareManager.instance.createShare(shareTitle, levelIds);
if (!shareCode) {
ToastManager.instance.show('创建分享失败,请重试');
return;
}
console.log('[PageWriteLevels] 创建分享成功, code:', shareCode);
// 获取用户头像昵称并上传
await this._uploadUserInfo();
ShareManager.instance.triggerWxShare(shareTitle, shareCode);
ToastManager.instance.show('分享创建成功!');
} catch (err) {
console.error('[PageWriteLevels] 完成按钮异常:', err);
ToastManager.instance.show('操作失败,请重试');
} finally {
this._isSubmitting = false;
}
}
/**
* 将选中的关卡索引转换为关卡 ID 数组
*/
private _getSelectedLevelIds(): string[] {
const ids: string[] = [];
const sortedIndices = Array.from(this._selectedIndices).sort((a, b) => a - b);
for (const index of sortedIndices) {
const level = CompletedLevelsManager.instance.getByIndex(index);
if (level) {
ids.push(level.id);
}
}
return ids;
}
/**
* 获取用户头像昵称并上传到服务端
*/
private async _uploadUserInfo(): Promise<void> {
// 先检查本地缓存
const cachedUserInfo = StorageManager.getUserInfo();
if (cachedUserInfo) {
console.log('[PageWriteLevels] 使用缓存的用户信息');
return;
}
if (!WxSDK.isWechat()) {
console.log('[PageWriteLevels] 非微信环境,跳过获取用户信息');
return;
}
// 获取当前登录用户的 ID
const userId = AuthManager.instance.userId;
if (!userId) {
console.warn('[PageWriteLevels] 用户未登录,跳过获取用户信息');
return;
}
try {
const userInfo = await getUserProfile();
// 本地缓存
StorageManager.setUserInfo(userInfo);
// 上传到服务端
const response = await HttpUtil.post<ApiEnvelope<unknown>>(
API_ENDPOINTS.USER_INFO,
{
userId: userId,
avatarUrl: userInfo.avatarUrl,
nickName: userInfo.nickName
},
API_TIMEOUT.DEFAULT
);
if (response.success) {
console.log('[PageWriteLevels] 用户信息上传成功');
} else {
console.warn('[PageWriteLevels] 用户信息上传失败:', response.message);
}
} catch (err) {
console.warn('[PageWriteLevels] 获取用户信息失败:', err);
// 不阻断主流程
}
}
onViewHide(): void {
console.log('[PageWriteLevels] onViewHide');
}
onViewDestroy(): void {
console.log('[PageWriteLevels] onViewDestroy');
if (this.backBtn) {
this.backBtn.off(Button.EventType.CLICK, this._onBackClick, this);
}
if (this.previewBtn) {
this.previewBtn.off(Button.EventType.CLICK, this._onPreviewClick, this);
}
if (this.completeBtn) {
this.completeBtn.off(Button.EventType.CLICK, this._onCompleteClick, this);
}
if (this.dataBtn) {
this.dataBtn.off(Button.EventType.CLICK, this._onDataClick, this);
}
if (this.scrollView) {
this.scrollView.off(Node.EventType.TOUCH_START, this._onTouchStart, this);
this.scrollView.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this.scrollView.off(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
}
this._clearList();
}
}