feat: 适配关卡内图片的圆角

This commit is contained in:
richarjiang
2026-05-03 22:28:22 +08:00
parent 7249df8c22
commit c04bde38a3
13 changed files with 346 additions and 41 deletions

View File

@@ -19,7 +19,7 @@ Git 历史采用 Conventional Commits且摘要多为中文例如 `feat:
<claude-mem-context>
# Memory Context
# $CMEM mp-xieyingeng 2026-05-03 10:02pm GMT+8
# $CMEM mp-xieyingeng 2026-05-03 10:24pm GMT+8
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖decision
Format: ID TIME TYPE TITLE

View File

@@ -743,8 +743,8 @@
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 8.006,
"y": 2.227,
"x": -4.854,
"y": -3.284,
"z": 0
},
"_lrot": {
@@ -756,9 +756,9 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1.0000000000000002,
"y": 1.0000000000000002,
"z": 0.759
"x": 0.92,
"y": 0.92,
"z": 0.698
},
"_mobility": 0,
"_layer": 1073741824,
@@ -1051,7 +1051,7 @@
"node": {
"__id__": 25
},
"_enabled": true,
"_enabled": false,
"__prefab": {
"__id__": 41
},
@@ -1069,7 +1069,7 @@
"__uuid__": "02687761-81ab-42cb-b552-0db291d9ddd5@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_type": 1,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
@@ -1318,7 +1318,7 @@
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": -5.11,
"y": 0.401,
"z": 0
},
"_lrot": {
@@ -1330,9 +1330,9 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1.0000000000000002,
"y": 1.0000000000000002,
"z": 0.759
"x": 0.92,
"y": 0.92,
"z": 0.698
},
"_mobility": 0,
"_layer": 1073741824,
@@ -1625,7 +1625,7 @@
"node": {
"__id__": 49
},
"_enabled": true,
"_enabled": false,
"__prefab": {
"__id__": 65
},
@@ -1643,7 +1643,7 @@
"__uuid__": "02687761-81ab-42cb-b552-0db291d9ddd5@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_type": 1,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
@@ -7939,7 +7939,6 @@
"titleLevelLabel": {
"__id__": 14
},
"currentLevelIndex": 0,
"clickAudio": {
"__uuid__": "a68a6314-fb7c-48a9-bd6c-0a65ef665d50",
"__expectedType__": "cc.AudioClip"
@@ -7964,6 +7963,11 @@
"__uuid__": "e41c722f-f605-47f7-9ce4-abff0ed2020f",
"__expectedType__": "cc.Prefab"
},
"roundedSpriteEffect": {
"__uuid__": "f0080a34-1786-4547-8d81-d89cc517b63e",
"__expectedType__": "cc.EffectAsset"
},
"mainImageCornerRadius": 0.1,
"_id": ""
},
{

View File

@@ -1,4 +1,4 @@
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab } from 'cc';
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
@@ -13,6 +13,7 @@ import { WrongModal } from 'db://assets/prefabs/WrongModal';
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
import { StaminaInfo, NextLevelData } from 'db://assets/scripts/types/ApiTypes';
import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager';
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
const { ccclass, property } = _decorator;
/**
@@ -37,6 +38,9 @@ export class PageLevel extends BaseView {
/** 答案正确后展示包袱答案的停留时间 */
private static readonly PASS_MODAL_DELAY_MS = 2000;
/** 图片2描述默认文案 */
private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?';
// ========== 节点引用 ==========
@property(Node)
inputLayout: Node | null = null;
@@ -116,6 +120,14 @@ export class PageLevel extends BaseView {
@property(Prefab)
timeoutModalPrefab: Prefab | null = null;
/** 主图圆角材质 EffectAsset */
@property(EffectAsset)
roundedSpriteEffect: EffectAsset | null = null;
/** 主图圆角半径比例相对于短边0-0.5 */
@property
mainImageCornerRadius: number = 0.1;
// ========== 内部状态 ==========
/** 当前创建的输入框节点数组 */
private _inputNodes: Node[] = [];
@@ -814,6 +826,7 @@ export class PageLevel extends BaseView {
const sprite = this.mainImage.getComponent(Sprite);
if (sprite && spriteFrame) {
sprite.spriteFrame = spriteFrame;
this.applyMainImageRoundedCorner(sprite);
console.log('[PageLevel] 设置主图1');
}
}
@@ -827,10 +840,30 @@ export class PageLevel extends BaseView {
const sprite = this.mainImage2.getComponent(Sprite);
if (sprite && spriteFrame) {
sprite.spriteFrame = spriteFrame;
this.applyMainImageRoundedCorner(sprite);
console.log('[PageLevel] 设置主图2');
}
}
private applyMainImageRoundedCorner(sprite: Sprite): void {
if (!this.roundedSpriteEffect) {
return;
}
const uiTransform = sprite.node.getComponent(UITransform);
if (!uiTransform) {
return;
}
applyRoundedCorner(
sprite,
this.roundedSpriteEffect,
uiTransform.width,
uiTransform.height,
this.mainImageCornerRadius
);
}
/**
* 设置图片描述文本
*/
@@ -839,7 +872,7 @@ export class PageLevel extends BaseView {
this.image1DescLabel.string = desc1 ?? '';
}
if (this.image2DescLabel) {
this.image2DescLabel.string = desc2 ?? '';
this.image2DescLabel.string = desc2?.trim() ? desc2 : PageLevel.DEFAULT_IMAGE2_DESCRIPTION;
}
}

View File

@@ -546,7 +546,7 @@
},
"_contentSize": {
"__type__": "cc.Size",
"width": 1297,
"width": 1299,
"height": 1004
},
"_anchorPoint": {
@@ -583,7 +583,7 @@
"a": 255
},
"_spriteFrame": {
"__uuid__": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400@f9941",
"__uuid__": "f8c208df-8e8c-456e-9f97-1d1c07518452@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,

View File

@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "5a2048d4-99ea-4f02-99b3-2ba9d1ec4fd5",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,140 @@
// Rounded Corner Sprite Effect
// 用于实现圆角矩形裁剪效果,性能优于 Mask 方案
CCEffect %{
techniques:
- passes:
- vert: sprite-vs:vert
frag: sprite-fs:frag
depthStencilState:
depthTest: false
depthWrite: false
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
blendSrcAlpha: src_alpha
blendDstAlpha: one_minus_src_alpha
rasterizerState:
cullMode: none
properties:
alphaThreshold: { value: 0.5 }
# 圆角参数: x=圆角半径比例(0-0.5), y=预留, z=节点宽度, w=节点高度
roundedParams: { value: [0.08, 0.0, 347.0, 200.0] }
# UV 区域: x=minU, y=minV, z=rangeU, w=rangeV (用于裁剪后的 SpriteFrame 归一化)
uvRect: { value: [0.0, 0.0, 1.0, 1.0] }
}%
CCProgram sprite-vs %{
precision highp float;
#include <builtin/uniforms/cc-global>
#if USE_LOCAL
#include <builtin/uniforms/cc-local>
#endif
#if SAMPLE_FROM_RT
#include <common/common-define>
#endif
in vec3 a_position;
in vec2 a_texCoord;
in vec4 a_color;
out vec4 color;
out vec2 uv0;
vec4 vert() {
vec4 pos = vec4(a_position, 1);
#if USE_LOCAL
pos = cc_matWorld * pos;
#endif
#if USE_PIXEL_ALIGNMENT
pos = cc_matView * pos;
pos.xyz = floor(pos.xyz);
pos = cc_matProj * pos;
#else
pos = cc_matViewProj * pos;
#endif
uv0 = a_texCoord;
#if SAMPLE_FROM_RT
CC_HANDLE_RT_SAMPLE_FLIP(uv0);
#endif
color = a_color;
return pos;
}
}%
CCProgram sprite-fs %{
precision highp float;
#include <builtin/internal/embedded-alpha>
#include <builtin/internal/alpha-test>
in vec4 color;
in vec2 uv0;
// roundedParams: x=圆角半径比例, y=预留, z=节点宽度, w=节点高度
// uvRect: x=minU, y=minV, z=rangeU, w=rangeV (裁剪后的 SpriteFrame UV 归一化)
uniform Custom {
vec4 roundedParams;
vec4 uvRect;
};
#if USE_TEXTURE
#pragma builtin(local)
layout(set = 2, binding = 12) uniform sampler2D cc_spriteTexture;
#endif
// 计算点到圆角矩形边界的有符号距离
float roundedBoxSDF(vec2 centerPos, vec2 halfSize, float radius) {
vec2 q = abs(centerPos) - halfSize + radius;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}
vec4 frag() {
vec4 o = vec4(1, 1, 1, 1);
#if USE_TEXTURE
o *= CCSampleWithAlphaSeparated(cc_spriteTexture, uv0);
#endif
o *= color;
// 解析参数
float cornerRadius = roundedParams.x;
vec2 nodeSize = vec2(roundedParams.z, roundedParams.w);
// 将实际 UV 归一化到 0→1 范围(兼容裁剪后的 SpriteFrame
// uvRect: (minU, minV, rangeU, rangeV)
vec2 normalizedUV = (uv0 - uvRect.xy) / uvRect.zw;
// 将归一化 UV 转换为以中心为原点的坐标系
vec2 centerUV = normalizedUV - 0.5;
// 计算实际的圆角半径(基于短边)
float minSize = min(nodeSize.x, nodeSize.y);
float radius = cornerRadius * minSize;
// 将 UV 坐标转换为像素坐标
vec2 pixelPos = centerUV * nodeSize;
vec2 halfSize = nodeSize * 0.5;
// 计算 SDF 距离
float dist = roundedBoxSDF(pixelPos, halfSize, radius);
// 使用平滑的抗锯齿边缘
float smoothEdge = 1.0 - smoothstep(-1.0, 1.0, dist);
o.a *= smoothEdge;
// 灰度转换(与 Cocos 内置 sprite shader 一致的 luminance 权重)
#if USE_GRAY_SCALE
float gray = dot(o.rgb, vec3(0.2126, 0.7152, 0.0722));
o.rgb = vec3(gray);
#endif
ALPHA_TEST(o);
return o;
}
}%

View File

@@ -0,0 +1,11 @@
{
"ver": "1.7.1",
"importer": "effect",
"imported": true,
"uuid": "f0080a34-1786-4547-8d81-d89cc517b63e",
"files": [
".json"
],
"subMetas": {},
"userData": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -2,7 +2,7 @@
"ver": "1.0.27",
"importer": "image",
"imported": true,
"uuid": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400",
"uuid": "f8c208df-8e8c-456e-9f97-1d1c07518452",
"files": [
".json",
".png"
@@ -10,14 +10,14 @@
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400@6c48a",
"displayName": "1_0022_Layer-19",
"uuid": "f8c208df-8e8c-456e-9f97-1d1c07518452@6c48a",
"displayName": "PicWin",
"id": "6c48a",
"name": "texture",
"userData": {
"wrapModeS": "clamp-to-edge",
"wrapModeT": "clamp-to-edge",
"imageUuidOrDatabaseUri": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400",
"imageUuidOrDatabaseUri": "f8c208df-8e8c-456e-9f97-1d1c07518452",
"isUuid": true,
"visible": false,
"minfilter": "linear",
@@ -34,8 +34,8 @@
},
"f9941": {
"importer": "sprite-frame",
"uuid": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400@f9941",
"displayName": "1_0022_Layer-19",
"uuid": "f8c208df-8e8c-456e-9f97-1d1c07518452@f9941",
"displayName": "PicWin",
"id": "f9941",
"name": "spriteFrame",
"userData": {
@@ -45,9 +45,9 @@
"offsetY": 0,
"trimX": 0,
"trimY": 0,
"width": 1297,
"width": 1299,
"height": 1004,
"rawWidth": 1297,
"rawWidth": 1299,
"rawHeight": 1004,
"borderTop": 0,
"borderBottom": 0,
@@ -60,16 +60,16 @@
"meshType": 0,
"vertices": {
"rawPosition": [
-648.5,
-649.5,
-502,
0,
648.5,
649.5,
-502,
0,
-648.5,
-649.5,
502,
0,
648.5,
649.5,
502,
0
],
@@ -84,11 +84,11 @@
"uv": [
0,
1004,
1297,
1299,
1004,
0,
0,
1297,
1299,
0
],
"nuv": [
@@ -102,18 +102,18 @@
1
],
"minPos": [
-648.5,
-649.5,
-502,
0
],
"maxPos": [
648.5,
649.5,
502,
0
]
},
"isUuid": true,
"imageUuidOrDatabaseUri": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400@6c48a",
"imageUuidOrDatabaseUri": "f8c208df-8e8c-456e-9f97-1d1c07518452@6c48a",
"atlasUuid": "",
"trimType": "auto"
},
@@ -129,6 +129,6 @@
"type": "sprite-frame",
"fixAlphaTransparencyArtifacts": false,
"hasAlpha": true,
"redirect": "3ef3f86f-bc1f-49b7-a50f-0e8683cd2400@6c48a"
"redirect": "f8c208df-8e8c-456e-9f97-1d1c07518452@6c48a"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -49,10 +49,10 @@
"height": 1189,
"rawWidth": 1190,
"rawHeight": 1189,
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"borderTop": 220,
"borderBottom": 220,
"borderLeft": 220,
"borderRight": 220,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,

View File

@@ -0,0 +1,99 @@
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;
};

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "216272d4-0b46-425b-be95-28e46f1417f4",
"files": [],
"subMetas": {},
"userData": {}
}