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.
AdLike2048/assets/script/ui/PanelHome/PanelHomeStartVigourFlyPres...

443 lines
15 KiB

3 weeks ago
import {
instantiate,
Node,
Tween,
tween,
UIOpacity,
UITransform,
v3,
Vec3,
} from 'cc';
/** btnStart 下体力底与图标节点名 */
const START_POWER_BG_NODE = 'powerBg';
const START_POWER_ICON_NODE = 'power';
/** ui_vigour 下体力图标节点名 */
const VIGOUR_ICON_NODE = '体力';
/** 随机起点相对体力图标中心的最小距离(像素) */
const SPAWN_RADIUS_MIN = 50;
/** 随机起点相对体力图标中心的最大距离(像素) */
const SPAWN_RADIUS_MAX = 110;
/** 各散开起点彼此最小间距(像素) */
const SPAWN_GAP_MIN = 20;
/** 选取散开起点最大重试次数 */
const SPAWN_PICK_TRY_MAX = 24;
/** 抛物线顶点相对起终点中点的抬升高度(像素) */
const PEAK_HEIGHT = 280;
/** 体力 → 随机起点基准时长(秒) */
const GATHER_SEC = 0.22;
/** 随机起点飞行时长随机偏移(±秒) */
const GATHER_SEC_JITTER = 0.04;
/** 起点 → power 飞行基准时长(秒) */
const FLY_SEC = 0.38;
/** 起点 → power 飞行时长随机偏移(±秒) */
const FLY_SEC_JITTER = 0.06;
/** 弹出时长(秒) */
const POP_SEC = 0.14;
/** 到达缩小时长(秒) */
const SHRINK_SEC = 0.1;
/** 左右弧线侧向偏移上限(像素) */
const SIDE_ARC_PX = 96;
/** 弧线方向:左半弧 / 右半弧 */
type VigourArcSide = 'left' | 'right';
/** 抛物线插值进度 */
type ParabolaFlyState = { t: number };
/**
* 首页开始游戏:体力栏弹出影子,先飞向四周随机起点,再沿抛物线飞向 btnStart/power。
*/
export class PanelHomeStartVigourFlyPresenter {
private _flyLayer: Node | null = null;
private _vigourRoot: Node | null = null;
private _btnStart: Node | null = null;
private readonly _shadowNodes: Node[] = [];
private readonly _flyStates: ParabolaFlyState[] = [];
private _playing = false;
private _flyGen = 0;
private _pendingArrive = 0;
private _flyComplete: (() => void) | null = null;
private _targetIcon: Node | null = null;
private readonly _targetScaleHome = v3(1, 1, 1);
private readonly _shadowWorldScaleHome = v3(1, 1, 1);
/**
* 绑定飞行层与起终点容器。
* @param flyLayer 影子父节点(建议 PanelHome 根节点,避免被 ui_baseInfo 遮挡)
* @param vigourRoot ui_vigour
* @param btnStart 开始按钮
*/
bind(flyLayer: Node | null | undefined, vigourRoot: Node | null | undefined, btnStart: Node | null | undefined): void {
this._flyLayer = flyLayer?.isValid ? flyLayer : null;
this._vigourRoot = vigourRoot?.isValid ? vigourRoot : null;
this._btnStart = btnStart?.isValid ? btnStart : null;
}
/** 停止飞行并清理影子 */
unbind(): void {
this._flyGen++;
this._playing = false;
this._flyComplete = null;
this._resetTargetPulse();
this._stopShadows();
this._flyLayer = null;
this._vigourRoot = null;
this._btnStart = null;
this._targetIcon = null;
}
/** 是否正在播放飞行动画 */
isPlaying(): boolean {
return this._playing;
}
/**
* 播放体力消耗飞行(纯表现,不修改 gg.data 体力)。
* @param onComplete 全部到达
* @returns 是否已开始播放
*/
play(onComplete: () => void): boolean {
if (this._playing) return false;
this._playing = true;
const flyLayer = this._flyLayer;
const sourceIcon = this._resolveVigourIcon();
const targetIcon = this._resolveStartPowerIcon();
if (!flyLayer?.isValid || !sourceIcon?.isValid || !targetIcon?.isValid) {
console.log('[PanelHomeStartVigourFly] 飞行参数无效,跳过');
this._playing = false;
return false;
}
const cost = Math.max(1, gg.data.project.getBattleVigourCost());
if (!gg.data.project.checkVigourEnough()) {
console.log('[PanelHomeStartVigourFly] 体力不足', gg.data.getVigour(), cost);
this._playing = false;
return false;
}
gg.audio.playEffect({ name: '体力消耗' });
this._stopShadows();
this._flyGen++;
const flyGen = this._flyGen;
this._flyComplete = onComplete;
this._targetIcon = targetIcon;
this._targetIcon.getScale(this._targetScaleHome);
this._pendingArrive = cost;
const endPos = this._resolveWorldCenter(targetIcon);
const srcPos = this._resolveWorldCenter(sourceIcon);
this._shadowWorldScaleHome.set(targetIcon.worldScale);
const spawns = this._planSpawnRing(srcPos, cost);
for (let i = 0; i < cost; i++) {
const shadow = instantiate(targetIcon);
shadow.name = 'vigourFlyShadow';
flyLayer.addChild(shadow);
shadow.setSiblingIndex(flyLayer.children.length - 1);
const spawn = spawns[i];
shadow.setWorldPosition(srcPos);
shadow.setWorldScale(Vec3.ZERO);
shadow.angle = targetIcon.angle;
const opacity = shadow.getOrAddComponent(UIOpacity);
opacity.opacity = 0;
this._shadowNodes.push(shadow);
const arcSide = this._resolveArcSideBySpawn(spawn, srcPos);
Tween.stopAllByTarget(shadow);
Tween.stopAllByTarget(opacity);
this._scheduleShadowFly(
shadow,
opacity,
srcPos.clone(),
spawn,
endPos.clone(),
arcSide,
this._rollGatherSec(),
this._rollFlySec(),
flyGen,
);
}
return true;
}
/**
* 按生成点相对体力中心的左右侧决定弧线方向。
* @param spawn 生成点
* @param center 体力图标中心
*/
private _resolveArcSideBySpawn(spawn: Readonly<Vec3>, center: Readonly<Vec3>): VigourArcSide {
if (spawn.x < center.x) return 'left';
if (spawn.x > center.x) return 'right';
return spawn.y >= center.y ? 'left' : 'right';
}
/**
* 以体力为圆心,在圆环上均分扇区并随机散落起点。
* @param center 体力图标中心世界坐标
* @param count 散开数量
*/
private _planSpawnRing(center: Readonly<Vec3>, count: number): Vec3[] {
const out: Vec3[] = [];
if (count <= 0) return out;
const minGapSq = SPAWN_GAP_MIN * SPAWN_GAP_MIN;
const baseAngle = Math.random() * Math.PI * 2;
const sectorRad = (Math.PI * 2) / count;
const jitterHalf = sectorRad * 0.32;
for (let i = 0; i < count; i++) {
const angle = baseAngle + sectorRad * i + sectorRad * 0.5
+ (Math.random() * 2 - 1) * jitterHalf;
let radius = SPAWN_RADIUS_MIN + Math.random() * (SPAWN_RADIUS_MAX - SPAWN_RADIUS_MIN);
let pos = this._spawnOnRing(center, angle, radius);
let tries = 0;
while (tries < SPAWN_PICK_TRY_MAX && this._minGapSqToUsed(pos, out) < minGapSq) {
radius = Math.min(SPAWN_RADIUS_MAX, radius + 8);
pos = this._spawnOnRing(center, angle, radius);
tries++;
}
if (out.length > 0 && this._minGapSqToUsed(pos, out) < minGapSq) {
console.log(
'[PanelHomeStartVigourFly] 散开起点间距不足',
Math.sqrt(this._minGapSqToUsed(pos, out)).toFixed(1),
SPAWN_GAP_MIN,
count,
);
}
out.push(pos);
}
return out;
}
/** 圆环上一点(极坐标 → 世界坐标) */
private _spawnOnRing(center: Readonly<Vec3>, angleRad: number, radius: number): Vec3 {
return v3(
center.x + Math.cos(angleRad) * radius,
center.y + Math.sin(angleRad) * radius,
center.z,
);
}
/** 候选点与已占用散开点的最小间距平方 */
private _minGapSqToUsed(pos: Readonly<Vec3>, used: readonly Vec3[]): number {
if (used.length <= 0) return Number.POSITIVE_INFINITY;
let minSq = Number.POSITIVE_INFINITY;
for (let i = 0; i < used.length; i++) {
const dx = pos.x - used[i].x;
const dy = pos.y - used[i].y;
const sq = dx * dx + dy * dy;
if (sq < minSq) minSq = sq;
}
return minSq;
}
/** btnStart/powerBg/power */
private _resolveStartPowerIcon(): Node | null {
const btn = this._btnStart;
if (!btn?.isValid) return null;
const powerBg = btn.getChildByName(START_POWER_BG_NODE);
if (!powerBg?.isValid) {
console.log('[PanelHomeStartVigourFly] btnStart 缺少', START_POWER_BG_NODE);
return null;
}
const power = powerBg.getChildByName(START_POWER_ICON_NODE);
if (!power?.isValid) {
console.log('[PanelHomeStartVigourFly] powerBg 缺少', START_POWER_ICON_NODE);
return null;
}
return power;
}
/** ui_vigour/体力 */
private _resolveVigourIcon(): Node | null {
const root = this._vigourRoot;
if (!root?.isValid) return null;
const icon = root.getChildByName(VIGOUR_ICON_NODE);
if (!icon?.isValid) {
console.log('[PanelHomeStartVigourFly] ui_vigour 缺少', VIGOUR_ICON_NODE);
return null;
}
return icon;
}
/** 节点中心世界坐标 */
private _resolveWorldCenter(node: Node): Vec3 {
const ut = node.getComponent(UITransform);
if (ut) return ut.convertToWorldSpaceAR(v3(0, 0, 0));
return node.worldPosition.clone();
}
/** 体力 → 随机起点时长(基准 ± 随机偏移) */
private _rollGatherSec(): number {
const delta = (Math.random() * 2 - 1) * GATHER_SEC_JITTER;
return Math.max(0.14, GATHER_SEC + delta);
}
/** 随机起点 → power 时长(基准 ± 随机偏移) */
private _rollFlySec(): number {
const delta = (Math.random() * 2 - 1) * FLY_SEC_JITTER;
return Math.max(0.26, FLY_SEC + delta);
}
/** 直线插值世界坐标 */
private _applyLerpPos(shadow: Node, from: Readonly<Vec3>, to: Readonly<Vec3>, t: number): void {
shadow.setWorldPosition(
from.x + (to.x - from.x) * t,
from.y + (to.y - from.y) * t,
from.z,
);
}
/** 抛物线插值世界坐标(顶点在起终点中点上方) */
private _applyParabolaPos(
shadow: Node,
from: Readonly<Vec3>,
to: Readonly<Vec3>,
t: number,
arcSide: VigourArcSide,
): void {
const sideSign = arcSide === 'left' ? -1 : 1;
const midX = (from.x + to.x) * 0.5;
const midY = (from.y + to.y) * 0.5;
const apexY = midY + PEAK_HEIGHT;
const u = 1 - t;
const side = Math.sin(t * Math.PI) * SIDE_ARC_PX * sideSign;
const x = u * u * from.x + 2 * u * t * midX + t * t * to.x + side;
const y = u * u * from.y + 2 * u * t * apexY + t * t * to.y;
shadow.setWorldPosition(x, y, from.z);
}
/**
* 弹出 → 体力飞向随机起点 → 抛物线飞向 power → 缩小消失。
* @param shadow 影子节点
* @param opacity 透明度组件
* @param vigourPos 体力图标中心
* @param spawn 随机起点
* @param to power 终点
* @param arcSide 左/右弧
* @param gatherSec 体力 → 起点时长
* @param flySec 起点 → power 时长
* @param flyGen 世代号
*/
private _scheduleShadowFly(
shadow: Node,
opacity: UIOpacity,
vigourPos: Readonly<Vec3>,
spawn: Readonly<Vec3>,
to: Readonly<Vec3>,
arcSide: VigourArcSide,
gatherSec: number,
flySec: number,
flyGen: number,
): void {
const home = this._shadowWorldScaleHome;
const popScale = v3(home.x * 1.15, home.y * 1.15, home.z);
const gatherState: ParabolaFlyState = { t: 0 };
const flyState: ParabolaFlyState = { t: 0 };
this._flyStates.push(gatherState);
this._flyStates.push(flyState);
Tween.stopAllByTarget(gatherState);
Tween.stopAllByTarget(flyState);
tween(shadow)
.parallel(
tween().to(POP_SEC, { worldScale: popScale }, { easing: 'backOut' }),
tween(opacity).to(POP_SEC * 0.85, { opacity: 255 }),
)
.parallel(
tween(gatherState).to(gatherSec, { t: 1 }, {
easing: 'quadOut',
onUpdate: () => {
if (!shadow.isValid) return;
this._applyLerpPos(shadow, vigourPos, spawn, gatherState.t);
},
}),
)
.parallel(
tween(flyState).to(flySec, { t: 1 }, {
easing: 'sineIn',
onUpdate: () => {
if (!shadow.isValid) return;
this._applyParabolaPos(shadow, spawn, to, flyState.t, arcSide);
},
}),
tween().to(flySec * 0.55, { worldScale: home.clone() }),
)
.parallel(
tween().to(SHRINK_SEC, { worldScale: Vec3.ZERO }),
tween(opacity).to(SHRINK_SEC, { opacity: 0 }),
)
.call(() => this._onShadowArrive(shadow, flyGen))
.start();
}
/** 单枚到达:销毁并统计 */
private _onShadowArrive(shadow: Node, flyGen: number): void {
if (flyGen !== this._flyGen) return;
this._destroyShadow(shadow);
this._pulseTargetIcon();
this._pendingArrive--;
if (this._pendingArrive > 0) return;
this._playing = false;
const done = this._flyComplete;
this._flyComplete = null;
this._resetTargetPulse();
done?.();
}
/** 目标 btnStart/power 轻微缩放反馈 */
private _pulseTargetIcon(): void {
const icon = this._targetIcon;
if (!icon?.isValid) return;
Tween.stopAllByTarget(icon);
icon.setScale(this._targetScaleHome);
const pulse = v3(this._targetScaleHome.x * 1.18, this._targetScaleHome.y * 1.18, 1);
tween(icon)
.to(0.06, { scale: pulse })
.to(0.1, { scale: this._targetScaleHome.clone() })
.start();
}
/** 复位目标图标缩放 */
private _resetTargetPulse(): void {
const icon = this._targetIcon;
if (!icon?.isValid) return;
Tween.stopAllByTarget(icon);
icon.setScale(this._targetScaleHome);
}
/** 销毁单枚影子 */
private _destroyShadow(shadow: Node): void {
if (!shadow?.isValid) return;
Tween.stopAllByTarget(shadow);
const op = shadow.getComponent(UIOpacity);
if (op) Tween.stopAllByTarget(op);
shadow.destroy();
for (let i = this._shadowNodes.length - 1; i >= 0; i--) {
if (this._shadowNodes[i] === shadow) {
this._shadowNodes.splice(i, 1);
}
}
}
/** 销毁全部影子与 tween 状态 */
private _stopShadows(): void {
for (let i = this._flyStates.length - 1; i >= 0; i--) {
Tween.stopAllByTarget(this._flyStates[i]);
}
this._flyStates.length = 0;
for (let i = this._shadowNodes.length - 1; i >= 0; i--) {
this._destroyShadow(this._shadowNodes[i]);
}
this._shadowNodes.length = 0;
}
}