# 梗中作乐 V1.0 - 源代码文档(第 11 部分) 软件名称:梗中作乐 版本号:V1.0 --- ## 第 11 部分:PK 详情与圆角渲染 本部分包含 PK 单场详情页与 UI 圆角遮罩、圆角材质工具。 ### 11.1 PK 详情页 (assets/prefabs/PagePKDetail.ts) ```typescript import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc'; import { BaseView } from 'db://assets/scripts/core/BaseView'; import { ViewManager } from 'db://assets/scripts/core/ViewManager'; import { CreatedShareItem, ShareDetailData, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes'; import { ShareManager } from 'db://assets/scripts/utils/ShareManager'; import { ToastManager } from 'db://assets/scripts/utils/ToastManager'; const { ccclass, property } = _decorator; interface PagePKDetailParams { share?: CreatedShareItem | null; shareCode?: string | null; detail?: ShareDetailData | null; } @ccclass('PagePKDetail') export class PagePKDetail extends BaseView { private static readonly RANK_ITEM_TOP_PADDING = 16; private static readonly RANK_ITEM_BOTTOM_PADDING = 16; private static readonly RANK_ITEM_SPACING = 16; @property({ type: Label, tooltip: '参与人数文案,例如:66 人参与' }) participateLabel: Label | null = null; private _backButton: Node | null = null; private _titleLabel: Label | null = null; private _championPanel: Node | null = null; private _rankListContent: Node | null = null; private _rankListItemTemplate: Node | null = null; private _rankItemNodes: Node[] = []; private _renderVersion: number = 0; onViewLoad(): void { this._resolveNodes(); this._bindEvents(); this._hideRankItemTemplate(); } onViewShow(): void { this._resolveNodes(); void this._loadAndRenderDetail(); } onViewHide(): void { this._renderVersion++; } onViewDestroy(): void { this._unbindEvents(); this._clearRankItems(); } private _resolveNodes(): void { if (!this._backButton || !this._backButton.isValid) { this._backButton = this.node.getChildByName('ButtonBack'); } this._titleLabel = this._titleLabel ?? this.node.getChildByName('Title')?.getChildByName('Label')?.getComponent(Label) ?? null; this.participateLabel = this.participateLabel ?? this._findLabelIn(this.node, 'ParticipateLabel'); this._championPanel = this._championPanel ?? this.node.getChildByName('ChampionPanel'); const rankList = this.node.getChildByName('RankList'); const view = rankList?.getChildByName('view'); this._rankListContent = this._rankListContent ?? view?.getChildByName('content') ?? null; this._rankListItemTemplate = this._rankListItemTemplate ?? this._rankListContent?.getChildByName('RankListItem') ?? null; if (!this._backButton) { console.warn('[PagePKDetail] 未找到 ButtonBack 节点'); } if (!this._rankListContent) { console.warn('[PagePKDetail] 未找到 RankList/content 节点'); } if (!this._rankListItemTemplate) { console.warn('[PagePKDetail] 未找到 RankListItem 模板节点'); } if (!this.participateLabel) { console.warn('[PagePKDetail] 未找到 ParticipateLabel 节点'); } } private _bindEvents(): void { if (this._backButton) { this._backButton.on(Button.EventType.CLICK, this._onBackClick, this); } } private _unbindEvents(): void { if (this._backButton?.isValid) { this._backButton.off(Button.EventType.CLICK, this._onBackClick, this); } } private _onBackClick(): void { ViewManager.instance.back(); } private async _loadAndRenderDetail(): Promise { const params = this.getParams() as PagePKDetailParams | null; const share = this._resolveShare(params); const passedDetail = params?.detail ?? null; const shareCode = passedDetail?.shareCode ?? params?.shareCode ?? share?.shareCode ?? null; const version = ++this._renderVersion; if (passedDetail) { this._renderShareDetail(passedDetail, version); return; } this._renderShareSummary(share, version); if (!shareCode) { ToastManager.instance.show('挑战数据异常,请稍后重试'); return; } const detail = await ShareManager.instance.fetchShareDetail(shareCode); if (version !== this._renderVersion || !this.isShowing) { return; } if (!detail) { ToastManager.instance.show('获取挑战详情失败,请稍后重试'); this._renderShareSummary(share, version); return; } this._renderShareDetail(detail, version); } private _resolveShare(params: PagePKDetailParams | null): CreatedShareItem | null { if (params?.share) { return params.share; } const shareCode = params?.shareCode ?? params?.detail?.shareCode; if (!shareCode) { return null; } return ShareManager.instance.createdShares.find((share) => share.shareCode === shareCode) ?? null; } private _renderShareSummary(share: CreatedShareItem | null, version: number): void { this._clearRankItems(); this._setLabel(this._titleLabel, share?.title || '挑战详情'); this._renderParticipateCount(share); this._renderChampion(share ? this._getFirstParticipant(share) : null, share, version); this._layoutRankContent(0); this._scrollRankListToTop(); } private _renderShareDetail(detail: ShareDetailData, version: number): void { this._clearRankItems(); const rankings = this._normalizeRankings(detail.rankings ?? []); const champion = rankings.find((participant) => participant.rank === 1) ?? rankings[0] ?? null; const restRankings = rankings.filter((participant) => participant !== champion); this._setLabel(this._titleLabel, detail.title || '挑战详情'); this._renderParticipateCount(detail); this._renderChampion(champion, detail, version); this._renderRankList(restRankings, version); } private _renderParticipateCount(shareInfo: CreatedShareItem | ShareDetailData | null): void { const participantCount = Math.max(0, shareInfo?.participantCount ?? 0); this._setLabel(this.participateLabel, `${participantCount} 人参与`); } private _normalizeRankings(rankings: ShareParticipantRankSummary[]): ShareParticipantRankSummary[] { return rankings .map((participant, index) => ({ ...participant, rank: participant.rank ?? index + 1, })) .sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)); } private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null { return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null; } private _renderChampion( champion: ShareParticipantRankSummary | null, shareInfo: CreatedShareItem | ShareDetailData | null, version: number, ): void { const panel = this._championPanel; if (!panel) { return; } this._setLabel(this._findLabelIn(panel, 'UserName'), this._getParticipantDisplayName(champion, '暂无参与')); this._setLabel(this._findLabelIn(panel, 'RightInfo'), this._formatCorrectText(champion, shareInfo)); this._setLabel(this._findLabelIn(panel, 'UsedTime'), this._formatTimeText(champion, shareInfo)); this._loadAvatar(champion?.avatarUrl ?? '', this._findAvatarSprite(panel), version); } private _renderRankList(participants: ShareParticipantRankSummary[], version: number): void { if (!this._rankListContent || !this._rankListItemTemplate) { return; } this._hideRankItemTemplate(); this._layoutRankContent(participants.length); participants.forEach((participant, index) => { const item = instantiate(this._rankListItemTemplate!); item.name = `RankListItem_${index + 1}`; item.active = true; this._rankListContent!.addChild(item); this._rankItemNodes.push(item); this._positionRankItem(item, index); this._applyRankItem(item, participant, version); }); this._scrollRankListToTop(); } private _applyRankItem(item: Node, participant: ShareParticipantRankSummary, version: number): void { const rank = participant.rank ?? 0; const rank2Badge = this._findChild(item, 'rank2badge'); const rank3Badge = this._findChild(item, 'rank3badge'); const rankNumberNode = this._findChild(item, 'RankNumber'); if (rank2Badge) { rank2Badge.active = rank === 2; } if (rank3Badge) { rank3Badge.active = rank === 3; } if (rankNumberNode) { rankNumberNode.active = rank !== 2 && rank !== 3; this._setLabel(rankNumberNode.getComponent(Label), rank > 0 ? `${rank}` : '-'); } this._setLabel(this._findLabelIn(item, 'UserName'), this._getParticipantDisplayName(participant, '微信用户')); this._setLabel(this._findLabelIn(item, 'RightInfo'), this._formatCorrectText(participant, null)); this._setLabel(this._findLabelIn(item, 'UsedTime'), this._formatTimeText(participant)); this._loadAvatar(participant.avatarUrl ?? '', this._findAvatarSprite(item), version); } private _layoutRankContent(itemCount: number): void { if (!this._rankListContent || !this._rankListItemTemplate) { return; } const contentTransform = this._rankListContent.getComponent(UITransform); const viewTransform = this._rankListContent.parent?.getComponent(UITransform) ?? null; const itemTransform = this._rankListItemTemplate.getComponent(UITransform); if (!contentTransform || !viewTransform || !itemTransform) { return; } const contentHeight = Math.max( viewTransform.height, PagePKDetail.RANK_ITEM_TOP_PADDING + PagePKDetail.RANK_ITEM_BOTTOM_PADDING + itemCount * itemTransform.height + Math.max(0, itemCount - 1) * PagePKDetail.RANK_ITEM_SPACING, ); contentTransform.setContentSize(contentTransform.width, contentHeight); this._rankListContent.setPosition( this._rankListContent.position.x, viewTransform.height / 2, this._rankListContent.position.z, ); } private _positionRankItem(item: Node, index: number): void { const itemTransform = item.getComponent(UITransform); if (!itemTransform) { return; } const y = -PagePKDetail.RANK_ITEM_TOP_PADDING - itemTransform.height / 2 - index * (itemTransform.height + PagePKDetail.RANK_ITEM_SPACING); item.setPosition(0, y, item.position.z); } private _scrollRankListToTop(): void { this.node.getChildByName('RankList')?.getComponent(ScrollView)?.scrollToTop(0); } private _formatCorrectText( participant: ShareParticipantRankSummary | null, shareInfo: CreatedShareItem | ShareDetailData | null, ): string { if (participant?.correctCount !== undefined && participant.correctCount !== null) { return `答对${participant.correctCount}道`; } if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) { return '答对0道'; } if (shareInfo) { return `共${shareInfo.participantCount ?? 0}人参与`; } return '暂无成绩'; } private _formatTimeText( participant: ShareParticipantRankSummary | null, shareInfo: CreatedShareItem | ShareDetailData | null = null, ): string { if (participant?.totalTimeSpent === undefined || participant.totalTimeSpent === null) { if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) { return '用时0秒'; } return '暂无用时'; } const totalSeconds = Math.max(0, Math.round(participant.totalTimeSpent)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `用时${minutes}:${seconds.toString().padStart(2, '0')}`; } private _getParticipantName(participant: ShareParticipantRankSummary | null): string { return participant?.nickname || participant?.nickName || ''; } private _getParticipantDisplayName( participant: ShareParticipantRankSummary | null, emptyParticipantFallback: string, ): string { if (!participant) { return emptyParticipantFallback; } return this._getParticipantName(participant) || '微信用户'; } private _loadAvatar(url: string, sprite: Sprite | null, version: number): void { if (!sprite) { return; } if (!url) { return; } assetManager.loadRemote(url, (err, imageAsset) => { if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) { if (err) { console.error('[PagePKDetail] 加载头像失败:', url, err); } return; } const texture = new Texture2D(); texture.image = imageAsset; const spriteFrame = new SpriteFrame(); spriteFrame.texture = texture; sprite.spriteFrame = spriteFrame; }); } private _findAvatarSprite(root: Node): Sprite | null { const avatarNode = this._findChild(root, 'Avatar'); return avatarNode?.getComponent(Sprite) ?? null; } private _clearRankItems(): void { for (const item of this._rankItemNodes) { if (item.isValid) { item.removeFromParent(); item.destroy(); } } this._rankItemNodes = []; this._hideRankItemTemplate(); } private _hideRankItemTemplate(): void { if (this._rankListItemTemplate?.isValid) { this._rankListItemTemplate.active = false; } } private _setLabel(label: Label | null, text: string): void { if (label) { label.string = text; } } private _findLabelIn(root: Node, nodeName: string): Label | null { return this._findChild(root, nodeName)?.getComponent(Label) ?? null; } private _findChild(root: Node, nodeName: string): Node | null { if (root.name === nodeName) { return root; } for (const child of root.children) { const found = this._findChild(child, nodeName); if (found) { return found; } } return null; } } ``` ### 11.2 圆角矩形遮罩 (assets/scripts/utils/RoundedRectMask.ts) ```typescript import { _decorator, Component, UITransform, Graphics, Color, Mask } from 'cc'; const { ccclass, property } = _decorator; /** * 圆角矩形遮罩组件 * 使用 Graphics + Mask 绘制圆角矩形作为遮罩 * * 使用方法: * 1. 将此组件添加到需要圆角的节点上 * 2. 设置圆角半径 * 3. 该节点及其子节点会被裁剪为圆角矩形 */ @ccclass('RoundedRectMask') export class RoundedRectMask extends Component { @property({ tooltip: '圆角半径(像素)' }) radius: number = 20; private _graphics: Graphics | null = null; private _mask: Mask | null = null; private _uiTransform: UITransform | null = null; onLoad() { this._uiTransform = this.getComponent(UITransform); this.setupComponents(); } /** * 设置组件 */ private setupComponents() { // 获取或添加 Graphics 组件 this._graphics = this.getComponent(Graphics); if (!this._graphics) { this._graphics = this.addComponent(Graphics); } // 获取或添加 Mask 组件 this._mask = this.getComponent(Mask); if (!this._mask) { this._mask = this.addComponent(Mask); } // 设置 Mask 使用 Graphics 类型 this._mask.type = Mask.Type.GRAPHICS_STENCIL; this.drawRoundedRect(); } /** * 绘制圆角矩形 */ private drawRoundedRect() { if (!this._graphics || !this._uiTransform) return; const width = this._uiTransform.width; const height = this._uiTransform.height; const r = Math.min(this.radius, Math.min(width, height) / 2); // 清除之前的绘制 this._graphics.clear(); // 设置填充颜色 this._graphics.fillColor = new Color(255, 255, 255, 255); // 绘制圆角矩形路径 const halfW = width / 2; const halfH = height / 2; // 使用 lineTo 和 arc 绘制圆角矩形 // 从左下角开始,逆时针绘制 this._graphics.moveTo(-halfW + r, -halfH); // 下边 this._graphics.lineTo(halfW - r, -halfH); // 右下角圆角 (90度弧,从 -90度 到 0度) this._graphics.arc(halfW - r, -halfH + r, r, -Math.PI / 2, 0, false); // 右边 this._graphics.lineTo(halfW, halfH - r); // 右上角圆角 (90度弧,从 0度 到 90度) this._graphics.arc(halfW - r, halfH - r, r, 0, Math.PI / 2, false); // 上边 this._graphics.lineTo(-halfW + r, halfH); // 左上角圆角 (90度弧,从 90度 到 180度) this._graphics.arc(-halfW + r, halfH - r, r, Math.PI / 2, Math.PI, false); // 左边 this._graphics.lineTo(-halfW, -halfH + r); // 左下角圆角 (90度弧,从 180度 到 270度) this._graphics.arc(-halfW + r, -halfH + r, r, Math.PI, Math.PI * 1.5, false); // 填充 this._graphics.fill(); } /** * 设置圆角半径 */ setRadius(radius: number) { this.radius = radius; this.drawRoundedRect(); } } ``` ### 11.3 圆角材质工具 (assets/scripts/utils/roundedMaterial.utils.ts) ```typescript import { EffectAsset, Material, Sprite, Vec4 } from 'cc'; /** * 圆角 Sprite 材质工具。 * * 基于 rounded-sprite.effect 的 SDF alpha 裁剪实现,直接作用于 Sprite 的自定义材质。 * 每个 Sprite 都会获得独立 Material,避免 roundedParams / uvRect 互相覆盖。 */ let cachedTemplate: Material | null = null; let cachedEffectRef: EffectAsset | null = null; const getOrCreateTemplate = (effectAsset: EffectAsset): Material => { if (cachedTemplate && cachedEffectRef === effectAsset) { return cachedTemplate; } const template = new Material(); template.initialize({ effectAsset, defines: { USE_TEXTURE: true }, }); cachedTemplate = template; cachedEffectRef = effectAsset; return template; }; const extractUvRect = (sprite: Sprite): Vec4 => { const spriteFrame = sprite.spriteFrame; if (!spriteFrame) { return new Vec4(0, 0, 1, 1); } const uv = spriteFrame.uv; if (!uv || uv.length < 8) { return new Vec4(0, 0, 1, 1); } const u0 = uv[0]; const v0 = uv[1]; const u1 = uv[2]; const v1 = uv[3]; const u2 = uv[4]; const v2 = uv[5]; const u3 = uv[6]; const v3 = uv[7]; const minU = Math.min(u0, u1, u2, u3); const maxU = Math.max(u0, u1, u2, u3); const minV = Math.min(v0, v1, v2, v3); const maxV = Math.max(v0, v1, v2, v3); const rangeU = maxU - minU; const rangeV = maxV - minV; if (rangeU <= 0 || rangeV <= 0) { return new Vec4(0, 0, 1, 1); } return new Vec4(minU, minV, rangeU, rangeV); }; export const applyRoundedCorner = ( sprite: Sprite, effectAsset: EffectAsset, width: number, height: number, cornerRadius = 0.1, grayscale = false, ): void => { if (!sprite || !sprite.isValid) { console.warn('[roundedMaterial] Invalid sprite, skipping'); return; } if (!effectAsset) { console.warn('[roundedMaterial] EffectAsset is null, skipping'); return; } const template = getOrCreateTemplate(effectAsset); const materialInstance = new Material(); const defines: Record = { USE_TEXTURE: true }; if (grayscale) { defines.IS_GRAY = true; } materialInstance.initialize({ effectAsset, defines, }); const params = new Vec4(cornerRadius, 0, width, height); materialInstance.setProperty('roundedParams', params); materialInstance.setProperty('uvRect', extractUvRect(sprite)); sprite.customMaterial = materialInstance; }; ```