Files
mp-xieyingeng/assets/prefabs/PageWriteLevels.ts
2026-04-07 21:51:29 +08:00

639 lines
22 KiB
TypeScript
Raw 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 { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager';
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';
const { ccclass, property } = _decorator;
/**
* 布局配置
* 基于实际 prefab 尺寸计算:
* ScrollView / view 宽 900高 1300
* ListTpl (item) 宽 300高 400
*
* 水平居中2 × 300 + 1 × 40 = 640, padding_left = (900 - 640) / 2 = 130
* 垂直居中3 × 400 + 2 × 25 = 1250, padding_top = (1300 - 1250) / 2 = 25
*/
const LAYOUT_CONFIG = {
COLS: 2,
ROWS: 3,
ITEM_WIDTH: 300,
ITEM_HEIGHT: 400,
SPACING_X: 40,
SPACING_Y: 25,
PADDING_LEFT: 130,
PADDING_TOP: 25,
VIEW_WIDTH: 900,
VIEW_HEIGHT: 1300,
};
/** 必须选择的关卡数量 */
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) {
this._initLevelList();
}
}
private _initLevelList(): void {
this._clearList();
this._levelCount = LevelDataManager.instance.getLevelCount();
console.log('[PageWriteLevels] 关卡总数:', this._levelCount);
if (this._levelCount === 0) {
console.warn('[PageWriteLevels] 没有关卡数据');
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();
}
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);
// anchor=(0,1),将 content 左上角对齐到 view 左上角
if (this._viewTransform) {
const viewWidth = this._viewTransform.contentSize.width;
const viewHeight = this._viewTransform.contentSize.height;
this.listContent.setPosition(-viewWidth / 2, viewHeight / 2, 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 x = pageIndex * LAYOUT_CONFIG.VIEW_WIDTH
+ LAYOUT_CONFIG.PADDING_LEFT
+ col * (LAYOUT_CONFIG.ITEM_WIDTH + LAYOUT_CONFIG.SPACING_X)
+ LAYOUT_CONFIG.ITEM_WIDTH / 2;
const y = -(LAYOUT_CONFIG.PADDING_TOP
+ 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 levelName = item.getChildByName('LevelName');
if (levelName) {
const label = levelName.getComponent(Label);
if (label) {
label.string = `${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);
}
}
}
/**
* 异步加载关卡资源并刷新封面图和名称。
* LevelDataManager 采用懒加载,初始化时只加载了第一关图片,
* 其余关卡通过 ensureLevelReady 按需加载。
*/
private async _loadAndRefreshCover(item: Node, index: number): Promise<void> {
const config = await LevelDataManager.instance.ensureLevelReady(index);
if (!config || !item.isValid) return;
const levelCover = item.getChildByName('LevelCover');
if (levelCover) {
const sprite = levelCover.getComponent(Sprite);
if (sprite && config.spriteFrame) {
sprite.spriteFrame = config.spriteFrame;
}
}
const levelName = item.getChildByName('LevelName');
if (levelName) {
const label = levelName.getComponent(Label);
if (label) {
label.string = config.name;
}
}
}
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 config = LevelDataManager.instance.getLevelConfig(index);
if (config) {
ids.push(config.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(
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();
}
}