Files
mp-xieyingeng/软著源代码/11-PK详情与圆角.md
2026-06-29 19:55:40 +08:00

647 lines
21 KiB
Markdown
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.
# 梗中作乐 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<void> {
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<ImageAsset>(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<string, boolean> = { 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;
};
```