You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
195 lines
6.3 KiB
195 lines
6.3 KiB
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<Vec3>) {
|
|
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<Vec3>): { 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;
|
|
}
|
|
}
|
|
|