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

21 KiB
Raw Blame History

梗中作乐 V1.0 - 源代码文档(第 11 部分)

软件名称:梗中作乐 版本号V1.0


第 11 部分PK 详情与圆角渲染

本部分包含 PK 单场详情页与 UI 圆角遮罩、圆角材质工具。

11.1 PK 详情页 (assets/prefabs/PagePKDetail.ts)

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)

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)

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;
};