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,高 1000,anchor (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 = 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 { 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 { 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 { 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 { // 先检查本地缓存 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>( 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(); } }