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.
442 lines
15 KiB
442 lines
15 KiB
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;
|
|
}
|
|
}
|
|
|