import { _decorator, Component, EffectAsset, Filter, Material, Sprite, Texture2D, UITransform, Vec3, } from 'cc'; const { ccclass, property } = _decorator; /** smoothstep(edge0, edge1, x),与 GLSL 一致 */ function smoothstep(edge0: number, edge1: number, x: number): number { const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); } /** * 飞弹命中城堡:在「与城堡同 UV」的损伤遮罩上挖洞,Shader 用遮罩.r 乘透明度。 * 使用:挂在城堡节点(需有 Sprite),将本目录下 CastleDamageMask.effect 拖到 effectAsset。 */ @ccclass('CastleDamageSprite') export class CastleDamageSprite extends Component { @property({ type: EffectAsset, tooltip: '拖入 assets/gameplay/CastleSiege/CastleDamageMask' }) effectAsset: EffectAsset = null; @property({ tooltip: '损伤图分辨率,越大边缘越细、CPU 更新越重' }) maskSize = 256; @property({ tooltip: '单次命中半径(相对较短边,约 0.02~0.15)' }) holeRadiusUv = 0.06; @property({ tooltip: '洞边缘软化(UV)' }) holeSoftnessUv = 0.015; @property({ tooltip: '边缘锯齿:越大轮廓越不规则' }) jaggedness = 0.12; @property({ tooltip: '若挖洞位置与命中点上下颠倒,可勾选' }) flipMaskV = true; private _sprite: Sprite = null; private _ui: UITransform = null; private _material: Material = null; private _maskTex: Texture2D = null; private _pixels: Uint8Array = null; private _dirty = false; private _hitSeed = 0; onLoad() { this._sprite = this.getComponent(Sprite); this._ui = this.node.getComponent(UITransform); if (!this._sprite || !this._ui) { console.warn('[CastleDamageSprite] 需要同节点上有 Sprite、UITransform'); return; } if (!this.effectAsset) { console.warn('[CastleDamageSprite] 请指定 effectAsset(CastleDamageMask)'); return; } this._buildMaskBuffer(); this._initMaterial(); } onDestroy() { if (this._sprite) { this._sprite.customMaterial = null; } this._maskTex?.destroy(); this._material?.destroy(); } lateUpdate() { if (!this._dirty || !this._maskTex || !this._pixels) { return; } this._maskTex.uploadData(this._pixels); this._maskTex.updateImage(); this._dirty = false; } /** 重置为完好(全白遮罩) */ resetDamage() { if (!this._pixels) { return; } this._pixels.fill(255); this._dirty = true; } /** * 在世界坐标处产生损伤(导弹爆炸中心等,需与城堡在同一 Canvas/UI 坐标系下) */ applyDamageAtWorld(worldPos: Vec3 | Readonly) { const uv = this.worldToCastleUV(worldPos); if (!uv) { return; } this.stampHoleUv(uv.u, uv.v, this.holeRadiusUv, this.holeSoftnessUv); } /** 直接用 UV(0~1)挖洞,便于调试 */ stampHoleUv(u: number, v: number, radiusUv?: number, softUv?: number) { if (!this._pixels) { return; } const r = radiusUv ?? this.holeRadiusUv; const soft = softUv ?? this.holeSoftnessUv; const w = this.maskSize; const h = this.maskSize; const px = Math.floor(u * (w - 1)); const py = this.flipMaskV ? Math.floor((1 - v) * (h - 1)) : Math.floor(v * (h - 1)); const rPx = Math.max(2, r * Math.min(w, h) * 0.5); const softPx = Math.max(1, soft * Math.min(w, h) * 0.5); const margin = rPx + softPx + 3; const seed = this._hitSeed++; const x0 = Math.max(0, Math.floor(px - margin)); const x1 = Math.min(w - 1, Math.ceil(px + margin)); const y0 = Math.max(0, Math.floor(py - margin)); const y1 = Math.min(h - 1, Math.ceil(py + margin)); for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) { const dx = x - px; const dy = y - py; const angle = Math.atan2(dy, dx); const j = 1 - this.jaggedness * (0.5 + 0.5 * Math.sin(angle * 9 + seed * 0.37) + 0.15 * Math.sin(angle * 17)); const rEff = rPx * j; const dist = Math.sqrt(dx * dx + dy * dy); const m = smoothstep(rEff - softPx, rEff + softPx, dist); const idx = (y * w + x) * 4; const newR = Math.min(this._pixels[idx], Math.floor(255 * m)); this._pixels[idx] = newR; this._pixels[idx + 1] = newR; this._pixels[idx + 2] = newR; this._pixels[idx + 3] = 255; } } this._dirty = true; } worldToCastleUV(worldPos: Vec3 | Readonly): { u: number; v: number } | null { if (!this._ui) { return null; } const lp = this._ui.convertToNodeSpaceAR(worldPos as Vec3); const w = this._ui.contentSize.width; const h = this._ui.contentSize.height; if (w <= 0 || h <= 0) { return null; } const ax = this._ui.anchorX; const ay = this._ui.anchorY; const left = -w * ax; const bottom = -h * ay; let u = (lp.x - left) / w; let v = (lp.y - bottom) / h; if (u < 0 || u > 1 || v < 0 || v > 1) { return null; } return { u, v }; } private _buildMaskBuffer() { const n = this.maskSize * this.maskSize * 4; this._pixels = new Uint8Array(n); this._pixels.fill(255); this._maskTex = new Texture2D(); this._maskTex.setFilters(Filter.LINEAR, Filter.LINEAR); this._maskTex.reset({ width: this.maskSize, height: this.maskSize, format: Texture2D.PixelFormat.RGBA8888, mipmapLevel: 0, }); this._maskTex.uploadData(this._pixels); this._maskTex.updateImage(); } private _initMaterial() { const mat = new Material(); mat.initialize({ effectAsset: this.effectAsset }); mat.setProperty('damageMask', this._maskTex); this._sprite.customMaterial = mat; this._material = mat; } }