814 lines
32 KiB
C#
814 lines
32 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using BrewMonster.Scripts;
|
|
using BrewMonster.Scripts.Skills;
|
|
using BrewMonster.UI;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using BrewMonster.Managers;
|
|
using BrewMonster.Network;
|
|
|
|
namespace BrewMonster
|
|
{
|
|
/// <summary>
|
|
/// Debug skill trigger panel.
|
|
/// 调试技能触发面板。
|
|
///
|
|
/// Populates a UI GridLayoutGroup with one button per usable skill on the host player.
|
|
/// Clicking a button fires the skill locally: animation + VFX only, no network, no cooldown, no resource check.
|
|
/// 用主角可用技能填充 UI GridLayoutGroup,每个技能一个按钮。
|
|
/// 点击按钮本地触发技能:仅播放动画 + 特效,无网络、无冷却、无资源消耗。
|
|
///
|
|
/// Scene setup:
|
|
/// 1. Assign Player (CECHostPlayer reference).
|
|
/// 2. Assign Target Marker (a Transform ~5 units in front of the player — used for projectile/fly-VFX skills).
|
|
/// 3. Assign Skill Grid Container (RectTransform with GridLayoutGroup).
|
|
/// 4. Assign Skill Button Prefab (GameObject with a Button root, child Image for icon, child Text for label).
|
|
/// 5. Enter Play Mode; the grid is populated automatically. Call Refresh() again after role/weapon changes.
|
|
/// </summary>
|
|
public class SkillTriggerPanel : MonoBehaviour
|
|
{
|
|
/// <summary>
|
|
/// Sentinel entity ID used to represent the draggable target marker in the GFX system.
|
|
/// 用于在特效系统中表示可拖动目标标记的实体 ID。
|
|
/// </summary>
|
|
/// <summary>Local id for debug NPC marker; encoded with <see cref="EncodeDebugNpcId"/> for GFX lookups.</summary>
|
|
public const int DEBUG_TARGET_ID = 1200;
|
|
|
|
/// <summary>Encode a local index as a server-style NPC id (high bit set).</summary>
|
|
public static int EncodeDebugNpcId(int localId = DEBUG_TARGET_ID) =>
|
|
unchecked((int)(0x80000000u | (uint)localId));
|
|
|
|
public static SkillTriggerPanel Instance { get; private set; }
|
|
|
|
[Header("References")]
|
|
[Tooltip("The host player to read skills from and trigger cast on.")]
|
|
[SerializeField] private CECHostPlayer player;
|
|
|
|
[Tooltip("World-space Transform used as target for projectile (non-self) skills. Place a Sphere ~5 units in front of the player.")]
|
|
[SerializeField] public CECMonsterTest targetMarker;
|
|
|
|
[Tooltip("RectTransform that has a GridLayoutGroup component. Buttons are instantiated here.")]
|
|
[SerializeField] private RectTransform skillGridContainer;
|
|
|
|
[Tooltip("Prefab with: root Button, child Image (icon), child Text (skill name/ID).")]
|
|
[SerializeField] private GameObject skillButtonPrefab;
|
|
|
|
[Header("Filter")]
|
|
[Tooltip("When enabled, hides skills that fail weapon/form/move-env restrictions. Disable to show ALL positive skills regardless of restrictions.")]
|
|
[SerializeField] private bool filterByRestrictions = false;
|
|
|
|
[Tooltip("Match CDlgSkillSubList god/evil path: false = base+god ranks, true = base+evil ranks.")]
|
|
[SerializeField] private bool isEvilSkillPath;
|
|
|
|
[SerializeField] private int skillCatalogLevel = 1;
|
|
|
|
[SerializeField]
|
|
private LogPanelAnimeScene logPanelAnimeScene;
|
|
|
|
[Header("Channeling (time_type = 2)")]
|
|
[Tooltip("Stops durative/channeling skill repeat and allows other skills again.")]
|
|
[SerializeField] private Button channelCancelButton;
|
|
|
|
private Coroutine _channelingCoroutine;
|
|
private Coroutine _fadeChantGfxCoroutine;
|
|
private Coroutine _fadeAttackGfxCoroutine;
|
|
private int _channelingSkillId;
|
|
private bool _isChanneling;
|
|
|
|
private const float k_FadeChantGfxDelaySecs = 0.5f;
|
|
private const float k_DefaultAttackGfxDurationSecs = 2f;
|
|
private const float k_AttackGfxFadeBufferSecs = 0.25f;
|
|
|
|
/// <summary>
|
|
/// World position of the draggable target marker, read by CECSkillGfxMan.get_pos_by_id.
|
|
/// 可拖动目标标记的世界坐标,由 CECSkillGfxMan.get_pos_by_id 读取。
|
|
/// </summary>
|
|
public static Vector3 DebugTargetPosition =>
|
|
Instance != null && Instance.targetMarker != null
|
|
? Instance.targetMarker.transform.position
|
|
: Vector3.zero;
|
|
|
|
private void Awake()
|
|
{
|
|
Instance = this;
|
|
if (channelCancelButton != null)
|
|
channelCancelButton.onClick.AddListener(CancelChanneling);
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (channelCancelButton != null)
|
|
channelCancelButton.onClick.RemoveListener(CancelChanneling);
|
|
StopChanneling();
|
|
StopFadeGfxCoroutines();
|
|
if (Instance == this)
|
|
Instance = null;
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
RegisterSceneHostWithGameRun();
|
|
StartCoroutine(RefreshWhenReady());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Animation test scene: in-scene <see cref="CECHostPlayer"/> is not created via <see cref="CECGameRun.InitCharacter"/>.
|
|
/// GFX <c>get_pos_by_id</c> resolves host through <see cref="EC_ManPlayer.GetPlayer"/> → <see cref="CECGameRun.GetHostPlayer"/>.
|
|
/// </summary>
|
|
private void RegisterSceneHostWithGameRun()
|
|
{
|
|
if (player == null)
|
|
return;
|
|
|
|
var gameRun = EC_Game.GetGameRun();
|
|
if (gameRun == null)
|
|
return;
|
|
|
|
gameRun.RegisterAnimSceneHostPlayer(player);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anim scene host swap: keep panel player reference in sync with bootstrap.
|
|
/// 动画场景切换角色时同步面板上的主角引用。
|
|
/// </summary>
|
|
public void SetHostPlayer(CECHostPlayer host)
|
|
{
|
|
if (host == null)
|
|
return;
|
|
|
|
player = host;
|
|
RegisterSceneHostWithGameRun();
|
|
}
|
|
|
|
/// <summary>Target id for <see cref="LocalCastSkill"/> / GFX composer (self = host cid, else marker NPC id).</summary>
|
|
private int ResolveLocalCastTargetId(CECSkill skill)
|
|
{
|
|
if (skill.GetTargetType() == 0)
|
|
return player.GetCharacterID();
|
|
|
|
if (targetMarker != null)
|
|
return targetMarker.GetNPCID();
|
|
|
|
return EncodeDebugNpcId();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls until CECGameUIMan is ready and the host has skills (server, skill model, or config fallback).
|
|
///
|
|
/// 轮询直到 UI 图集与技能数据就绪(服务端、CECHostSkillModel 或配置回退)。
|
|
/// </summary>
|
|
private const float k_RefreshWaitTimeoutSecs = 30f;
|
|
private IEnumerator RefreshWhenReady()
|
|
{
|
|
var wait = new WaitForSeconds(0.5f);
|
|
float elapsed = 0f;
|
|
|
|
// Wait for UI atlas first — required for icons regardless of skill source.
|
|
// 先等待 UI 图集加载完毕,图标渲染必须。
|
|
while (CECUIManager.Instance?.GetInGameUIMan() == null)
|
|
{
|
|
elapsed += 0.5f;
|
|
yield return wait;
|
|
}
|
|
|
|
ResolveHostPlayerReference();
|
|
|
|
// Wait for skills: server list, CECHostSkillModel catalog (anim scene), or config fallback.
|
|
// 等待技能:服务端列表、CECHostSkillModel 目录(动画场景)或配置回退。
|
|
while (player != null && !HasSkillCatalogOrPositiveSkills())
|
|
{
|
|
if (TryInjectFromSkillModel())
|
|
break;
|
|
|
|
if (elapsed >= k_RefreshWaitTimeoutSecs)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] No skills after " +
|
|
$"{k_RefreshWaitTimeoutSecs}s — falling back to SkillStub.map injection.");
|
|
player.InjectDebugSkillsFromConfig();
|
|
break;
|
|
}
|
|
|
|
elapsed += 0.5f;
|
|
yield return wait;
|
|
}
|
|
|
|
Refresh();
|
|
}
|
|
|
|
private void ResolveHostPlayerReference()
|
|
{
|
|
if (player != null)
|
|
return;
|
|
|
|
player = EC_Game.GetGameRun()?.GetHostPlayer();
|
|
}
|
|
|
|
private bool HasSkillCatalogOrPositiveSkills()
|
|
{
|
|
if (player != null && player.GetPositiveSkillNum() > 0)
|
|
return true;
|
|
|
|
List<int> catalog = CECHostSkillModel.Instance?.CollectSkillSubListSkillIds(isEvilSkillPath);
|
|
return catalog != null && catalog.Count > 0;
|
|
}
|
|
|
|
private bool TryInjectFromSkillModel()
|
|
{
|
|
if (player == null)
|
|
return false;
|
|
|
|
List<int> catalog = CECHostSkillModel.Instance?.CollectSkillSubListSkillIds(isEvilSkillPath);
|
|
if (catalog == null || catalog.Count == 0)
|
|
return false;
|
|
|
|
player.InjectSkillsFromSkillModel(skillCatalogLevel, isEvilSkillPath);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads every skill defined in SkillStub.map (config) directly into the player's skill list,
|
|
/// bypassing the server skill message. Use this when testing locally without a server.
|
|
/// 将 SkillStub.map(配置文件)中所有技能直接注入玩家技能列表,绕过服务端消息。
|
|
/// 适用于无服务端的本地测试场景。
|
|
/// </summary>
|
|
[ContextMenu("Debug: Inject All Config Skills")]
|
|
public void InjectConfigSkills()
|
|
{
|
|
if (player == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] player is not assigned.");
|
|
return;
|
|
}
|
|
player.InjectDebugSkillsFromConfig();
|
|
Refresh();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuild host positive skills from <see cref="CECHostSkillModel"/> (same catalog as CDlgSkillSubList) and refresh the grid.
|
|
/// AnimScenePlayerBootstrap calls this after each model / role switch.
|
|
///
|
|
/// 从 CECHostSkillModel 重建主角正向技能并刷新网格(与 CDlgSkillSubList 相同目录)。
|
|
/// </summary>
|
|
[ContextMenu("Debug: Refresh From Skill Model")]
|
|
public void RefreshFromSkillModel(int level = -1)
|
|
{
|
|
ResolveHostPlayerReference();
|
|
if (player == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] RefreshFromSkillModel: no host player.");
|
|
return;
|
|
}
|
|
|
|
if (level > 0)
|
|
skillCatalogLevel = level;
|
|
|
|
player.InjectSkillsFromSkillModel(skillCatalogLevel, isEvilSkillPath);
|
|
|
|
List<CECSkill> catalogSkills = player.BuildSkillSubListSkills(skillCatalogLevel, isEvilSkillPath);
|
|
PopulateSkillGrid(catalogSkills, catalogSkills.Count);
|
|
}
|
|
|
|
public void SetEvilSkillPath(bool isEvil)
|
|
{
|
|
isEvilSkillPath = isEvil;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reload from CECHostSkillModel and optionally hide skills that fail weapon/form/env checks.
|
|
/// 从技能目录重新加载;applyRestrictions 为 true 时按武器/形态/环境过滤。
|
|
/// </summary>
|
|
public void ResetSkillsFromSkillModel(bool applyRestrictions, int level = -1)
|
|
{
|
|
filterByRestrictions = applyRestrictions;
|
|
RefreshFromSkillModel(level);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears and repopulates the skill grid from the host player's current positive skill list.
|
|
/// Call again after equipping a different weapon or changing shape/form.
|
|
/// Right-click this component in the Inspector to call Refresh manually.
|
|
/// 从主角当前正向技能列表清空并重新填充技能格。
|
|
/// 在更换武器或变身后再次调用;右键此组件可手动调用。
|
|
/// </summary>
|
|
[ContextMenu("Refresh")]
|
|
public void Refresh()
|
|
{
|
|
if (player == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] player is not assigned.");
|
|
return;
|
|
}
|
|
if (skillGridContainer == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] skillGridContainer is not assigned.");
|
|
return;
|
|
}
|
|
if (skillButtonPrefab == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] skillButtonPrefab is not assigned.");
|
|
return;
|
|
}
|
|
|
|
var catalogSkills = player.BuildSkillSubListSkills(skillCatalogLevel, isEvilSkillPath);
|
|
if (catalogSkills.Count > 0)
|
|
{
|
|
PopulateSkillGrid(catalogSkills, catalogSkills.Count);
|
|
return;
|
|
}
|
|
|
|
int total = player.GetPositiveSkillNum();
|
|
if (total == 0)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] GetPositiveSkillNum() = 0. " +
|
|
"Skills are loaded via OnMsgHstSkillData (server message). " +
|
|
"Make sure the host player has received skill data before calling Refresh.");
|
|
return;
|
|
}
|
|
|
|
var positiveSkills = new List<CECSkill>(total);
|
|
for (int i = 0; i < total; i++)
|
|
{
|
|
CECSkill skill = player.GetPositiveSkillByIndex(i);
|
|
if (skill != null)
|
|
positiveSkills.Add(skill);
|
|
}
|
|
|
|
PopulateSkillGrid(positiveSkills, total);
|
|
}
|
|
|
|
private void PopulateSkillGrid(IReadOnlyList<CECSkill> skills, int totalListed)
|
|
{
|
|
foreach (Transform child in skillGridContainer)
|
|
Destroy(child.gameObject);
|
|
|
|
CECGameUIMan uiMan = CECUIManager.Instance?.GetInGameUIMan();
|
|
if (uiMan == null)
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] CECGameUIMan not ready — icon atlas not loaded. " +
|
|
"Call Refresh() after the game UI finishes initializing.");
|
|
return;
|
|
}
|
|
|
|
int added = 0;
|
|
int filtered = 0;
|
|
for (int i = 0; i < skills.Count; i++)
|
|
{
|
|
CECSkill skill = skills[i];
|
|
if (skill == null)
|
|
continue;
|
|
|
|
if (filterByRestrictions && !IsSkillUsable(skill))
|
|
{
|
|
filtered++;
|
|
continue;
|
|
}
|
|
|
|
GameObject cell = Instantiate(skillButtonPrefab, skillGridContainer);
|
|
|
|
string iconName = skill.GetIconFile();
|
|
if (!string.IsNullOrEmpty(iconName))
|
|
{
|
|
Sprite icon = uiMan.GetIcon(iconName, EC_GAMEUI_ICONS.ICONS_SKILL);
|
|
Image cellImage = GetIconImage(cell);
|
|
if (cellImage != null && icon != null)
|
|
cellImage.sprite = icon;
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning("[SkillTriggerPanel] skill.GetIconFile() is empty for skill " + skill.GetSkillID());
|
|
}
|
|
|
|
Text cellText = cell.GetComponentInChildren<Text>();
|
|
if (cellText != null)
|
|
{
|
|
string skillName = skill.GetName();
|
|
cellText.text = string.IsNullOrEmpty(skillName)
|
|
? $"[{skill.GetSkillID()}]\nLv{skill.GetSkillLevel()}"
|
|
: $"{skillName}\nLv{skill.GetSkillLevel()}";
|
|
}
|
|
|
|
CECSkill captured = skill;
|
|
Button btn = cell.GetComponentInChildren<Button>();
|
|
if (btn != null)
|
|
btn.onClick.AddListener(() => LocalCastSkill(captured));
|
|
|
|
added++;
|
|
}
|
|
|
|
Debug.Log($"[SkillTriggerPanel] Refreshed: {added} buttons added " +
|
|
$"({filtered} filtered by restrictions, {totalListed} catalog/server skills).");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the skill passes weapon, form, and move-environment restrictions
|
|
/// with resources (MP/AP/HP/arrow) treated as unlimited.
|
|
/// Relies on the same SkillCore.Condition path as CheckSkillCastCondition but with faked resource values.
|
|
/// 当武器、形态、移动环境限制通过(资源视为无限)时返回 true。
|
|
/// </summary>
|
|
private bool IsSkillUsable(CECSkill skill)
|
|
{
|
|
if (skill == null || skill.SkillCore == null)
|
|
return true;
|
|
|
|
try
|
|
{
|
|
// Build real weapon major-type ID / 获取当前装备武器的主类型 ID
|
|
int weaponId = 0;
|
|
EC_Inventory equipPack = player.IvtrEquipPack;
|
|
if (equipPack != null)
|
|
{
|
|
var weapon = equipPack.GetItem((int)IndexOfIteminEquipmentInventory.EQUIPIVTR_WEAPON) as CECIvtrWeapon;
|
|
if (weapon != null && weapon.CurEndurance > 0)
|
|
weaponId = (int)weapon.GetDBMajorType().id;
|
|
}
|
|
|
|
// Fake unlimited resources but keep real weapon / form / env values
|
|
// 伪造无限资源,但保留真实的武器/形态/环境值
|
|
UseRequirement fakeInfo = new UseRequirement
|
|
{
|
|
mp = int.MaxValue, // ignore mana / 忽略法力
|
|
ap = int.MaxValue, // ignore AP / 忽略怒气
|
|
hp = int.MaxValue, // ignore HP / 忽略生命
|
|
max_hp = int.MaxValue,
|
|
freepackage = 999, // ignore inventory space / 忽略背包空间
|
|
arrow = 9999, // ignore arrow count / 忽略箭矢数量
|
|
weapon = weaponId, // real weapon type / 真实武器类型
|
|
form = player.GetOriginalShapeID(), // real shape/form / 真实形态
|
|
move_env = player.GetMoveEnv(), // real move env / 真实移动环境
|
|
is_combat = true,
|
|
combo_state = new ComboSkillState
|
|
{
|
|
skillid = 0,
|
|
arg = new int[ComboSkillState.MAX_COMBO_ARG],
|
|
},
|
|
};
|
|
|
|
int result = skill.SkillCore.Condition((uint)skill.GetSkillID(), fakeInfo, skill.GetSkillLevel());
|
|
return result == 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"[SkillTriggerPanel] IsSkillUsable exception for skill {skill.GetSkillID()}: {ex.Message} — treating as usable.");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fires the skill locally: plays the cast animation and triggers VFX directly.
|
|
/// No network packet is sent; cooldown and resources are not consumed.
|
|
/// 本地触发技能:播放施法动画并直接触发特效。
|
|
/// 不发送网络包,不消耗冷却时间和资源。
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Returns the first Image in the cell hierarchy that is NOT used as a Button's target graphic.
|
|
/// Button background Images sit on the same GameObject as a Button component, so we skip those.
|
|
/// The icon Image is a leaf child that has no Button sibling.
|
|
///
|
|
/// 返回层级中第一个不作为 Button 目标图的 Image。
|
|
/// Button 背景图与 Button 组件共存于同一 GameObject,故跳过;图标 Image 是没有 Button 兄弟组件的子节点。
|
|
/// </summary>
|
|
private static Image GetIconImage(GameObject root)
|
|
{
|
|
return root.GetComponentInChildren<Image>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops channeling repeat and clears pending attack events (wire to cancel button).
|
|
/// 停止引导重复并清理挂起的攻击事件(绑定到取消按钮)。
|
|
/// </summary>
|
|
public void CancelChanneling()
|
|
{
|
|
StopChanneling();
|
|
}
|
|
|
|
private void StopFadeGfxCoroutines()
|
|
{
|
|
if (_fadeChantGfxCoroutine != null)
|
|
{
|
|
StopCoroutine(_fadeChantGfxCoroutine);
|
|
_fadeChantGfxCoroutine = null;
|
|
}
|
|
|
|
if (_fadeAttackGfxCoroutine != null)
|
|
{
|
|
StopCoroutine(_fadeAttackGfxCoroutine);
|
|
_fadeAttackGfxCoroutine = null;
|
|
}
|
|
}
|
|
|
|
private int GetPlayerModelId() => player?.GetPlayerModel()?.GetId() ?? 0;
|
|
|
|
/// <summary>
|
|
/// CECModel path: release active channel FX into A3DGfxMan queue, then fade all pending GFX.
|
|
/// CECModel 路径:先把仍挂在通道上的特效 Release 进 A3DGfxMan,再统一淡出。
|
|
/// </summary>
|
|
private void FinishPlayerSkillGfx()
|
|
{
|
|
player?.GetPlayerModel()?.ClearComActFlagAllRankNodes(true);
|
|
}
|
|
|
|
private void FadeOutChantGfxUpTo(int maxCount, int fadeMs = A3DGfxMan.DefaultFadeOutMs)
|
|
{
|
|
int modelId = GetPlayerModelId();
|
|
if (modelId != 0)
|
|
A3DGfxMan.Instance.FadeOutReleaseUpTo(modelId, maxCount, fadeMs);
|
|
}
|
|
|
|
private void ScheduleFadeChantGfxAfterCast()
|
|
{
|
|
if (_fadeChantGfxCoroutine != null)
|
|
{
|
|
StopCoroutine(_fadeChantGfxCoroutine);
|
|
_fadeChantGfxCoroutine = null;
|
|
}
|
|
|
|
_fadeChantGfxCoroutine = StartCoroutine(FadeChantGfxAfterCastRoutine());
|
|
}
|
|
|
|
private void ScheduleFadeAllGfxWhenAttackStopped(int attackTimeMs)
|
|
{
|
|
if (_fadeAttackGfxCoroutine != null)
|
|
{
|
|
StopCoroutine(_fadeAttackGfxCoroutine);
|
|
_fadeAttackGfxCoroutine = null;
|
|
}
|
|
|
|
_fadeAttackGfxCoroutine = StartCoroutine(FadeOutAttackGfxAfterComplete(attackTimeMs));
|
|
}
|
|
|
|
private void StopChanneling()
|
|
{
|
|
_isChanneling = false;
|
|
_channelingSkillId = 0;
|
|
|
|
if (_channelingCoroutine != null)
|
|
{
|
|
StopCoroutine(_channelingCoroutine);
|
|
_channelingCoroutine = null;
|
|
}
|
|
|
|
StopFadeGfxCoroutines();
|
|
|
|
if (player == null)
|
|
return;
|
|
|
|
player.StopSkillAttackAction();
|
|
FinishPlayerSkillGfx();
|
|
|
|
CECAttackerEvents attackerEvents =
|
|
CECAttacksMan.Instance?.FindAttackByAttacker(player.GetPlayerInfo().cid);
|
|
if (attackerEvents)
|
|
attackerEvents.Signal();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Durative skill: block other skills while an attack event has not fired yet.
|
|
/// 引导技能:攻击事件尚未触发时,阻止其它技能。
|
|
/// </summary>
|
|
private bool ShouldBlockOtherSkillTrigger(CECSkill skill)
|
|
{
|
|
if (!_isChanneling || skill == null)
|
|
return false;
|
|
|
|
if (skill.GetSkillID() == _channelingSkillId)
|
|
return false;
|
|
|
|
return HasPendingChannelAttackEvent();
|
|
}
|
|
|
|
private bool HasPendingChannelAttackEvent()
|
|
{
|
|
if (!_isChanneling || player == null)
|
|
return false;
|
|
|
|
CECAttackerEvents attackerEvents =
|
|
CECAttacksMan.Instance?.FindAttackByAttacker(player.GetPlayerInfo().cid);
|
|
if (!attackerEvents)
|
|
return false;
|
|
|
|
CECAttackEvent pAttack = attackerEvents.Find(_channelingSkillId, 0);
|
|
return pAttack != null && !pAttack.m_bDoFired;
|
|
}
|
|
|
|
public void LocalCastSkill(CECSkill skill)
|
|
{
|
|
if (player == null || skill == null)
|
|
return;
|
|
|
|
if (ShouldBlockOtherSkillTrigger(skill))
|
|
{
|
|
Debug.LogWarning($"[SkillTriggerPanel] Blocked skill {skill.GetSkillID()} — channeling skill {_channelingSkillId} attack event pending.");
|
|
return;
|
|
}
|
|
|
|
if (_isChanneling)
|
|
StopChanneling();
|
|
|
|
StopFadeGfxCoroutines();
|
|
|
|
logPanelAnimeScene.Reset();
|
|
int skillId = skill.GetSkillID();
|
|
logPanelAnimeScene.AddCopyTextButton("ID", skillId.ToString());
|
|
|
|
logPanelAnimeScene.LogSkillCastGfx(player, skillId);
|
|
logPanelAnimeScene.LogPlayAttackEffectGfx(player, skillId, 0);
|
|
logPanelAnimeScene.LogSkillStateGfx(player, skillId);
|
|
|
|
// Self-cast skills target the host; all others target the draggable marker.
|
|
// 自身施法技能以主角为目标;其余技能以可拖动标记为目标。
|
|
int targetId = ResolveLocalCastTargetId(skill);
|
|
|
|
// 1. Trigger cast animation (PlaySkillCastAction resolves action name internally)
|
|
// 触发施法动画(PlaySkillCastAction 内部解析动作名)
|
|
bool animOk = player.PlaySkillCastAction(skillId);
|
|
|
|
if (!animOk)
|
|
Debug.LogWarning($"[SkillTriggerPanel] PlaySkillCastAction returned false for skill {skillId} — animation may not play.");
|
|
|
|
// 0.5s: fade chant GFX only (A3DGfxMan snapshot). Cast release + attack fade together when attack ends.
|
|
// 0.5 秒:仅淡出吟唱特效;施法释放与攻击特效在攻击结束时同时淡出。
|
|
ScheduleFadeChantGfxAfterCast();
|
|
|
|
// // 2. Trigger VFX via the GFX composer — bypasses network entirely
|
|
// // 通过特效组合器触发特效,完全绕过网络
|
|
// var composerMan = CECAttacksMan.Instance?.GetSkillGfxComposerMan();
|
|
// if (composerMan != null)
|
|
// {
|
|
// var targets = new List<TARGET_DATA>
|
|
// {
|
|
// new TARGET_DATA { idTarget = targetId, dwModifier = 0, nDamage = 0 }
|
|
// };
|
|
// composerMan.Play(skillId, player.GetCharacterID(), targetId, targets);
|
|
// }
|
|
// else
|
|
// {
|
|
// Debug.LogWarning($"[SkillTriggerPanel] composerMan is null — VFX skipped for skill {skillId}.");
|
|
// }
|
|
|
|
// 3. After delay, play the release / 施放起+落 attack animation chain (local-only).
|
|
// 延迟后播放施放攻击动画链(仅本地)
|
|
if (skill.IsDurative())
|
|
{
|
|
_isChanneling = true;
|
|
_channelingSkillId = skillId;
|
|
_channelingCoroutine = StartCoroutine(ChannelingSkillRoutine(player, skillId));
|
|
}
|
|
else
|
|
{
|
|
StartCoroutine(DelayedPlaySkillAttackAction(player, skillId, 2f, replayAttackAnim: false));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Durative (time_type = 2): first hit at 1s, then every 2s until cancel.
|
|
/// 引导技能:1 秒首次命中,之后每 2 秒重复,直到按下取消。
|
|
/// </summary>
|
|
private IEnumerator ChannelingSkillRoutine(CECHostPlayer hostPlayer, int skillId)
|
|
{
|
|
yield return new WaitForSeconds(1f);
|
|
if (!_isChanneling || hostPlayer == null)
|
|
yield break;
|
|
|
|
TriggerLocalSkillAttack(hostPlayer, skillId, replayAttackAnim: true);
|
|
|
|
while (_isChanneling && hostPlayer != null)
|
|
{
|
|
yield return new WaitForSeconds(5f);
|
|
if (!_isChanneling || hostPlayer == null)
|
|
yield break;
|
|
|
|
while (_isChanneling && HasPendingChannelAttackEvent())
|
|
yield return null;
|
|
|
|
if (!_isChanneling || hostPlayer == null)
|
|
yield break;
|
|
|
|
TriggerLocalSkillAttack(hostPlayer, skillId, replayAttackAnim: true);
|
|
}
|
|
}
|
|
|
|
private IEnumerator DelayedPlaySkillAttackAction(CECHostPlayer hostPlayer, int skillId, float delaySecs, bool replayAttackAnim)
|
|
{
|
|
yield return new WaitForSeconds(delaySecs);
|
|
if (hostPlayer == null)
|
|
yield break;
|
|
|
|
TriggerLocalSkillAttack(hostPlayer, skillId, replayAttackAnim);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 0.5s after cast: fade chant GFX only via <see cref="A3DGfxMan.FadeOutReleaseUpTo"/>.
|
|
/// 施法 0.5 秒后:仅淡出已注册的吟唱特效。
|
|
/// </summary>
|
|
private IEnumerator FadeChantGfxAfterCastRoutine()
|
|
{
|
|
yield return null;
|
|
|
|
int modelId = GetPlayerModelId();
|
|
if (modelId == 0)
|
|
yield break;
|
|
|
|
yield return new WaitForSeconds(k_FadeChantGfxDelaySecs);
|
|
_fadeChantGfxCoroutine = null;
|
|
|
|
if (player == null)
|
|
yield break;
|
|
|
|
modelId = GetPlayerModelId();
|
|
if (modelId == 0)
|
|
yield break;
|
|
|
|
int chantGfxCount = A3DGfxMan.Instance.GetPendingReleaseCount(modelId);
|
|
FadeOutChantGfxUpTo(chantGfxCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wait for attack duration, flush active FX via CECModel.Release, then A3DGfxMan fade-out.
|
|
/// IsActionStopped() alone is unreliable here (empty channel reads as stopped before attack starts).
|
|
/// 等待攻击时长,经 CECModel 释放仍活跃的特效,再由 A3DGfxMan 淡出。
|
|
/// </summary>
|
|
private IEnumerator FadeOutAttackGfxAfterComplete(int attackTimeMs)
|
|
{
|
|
yield return null;
|
|
|
|
float attackSecs = attackTimeMs > 0
|
|
? attackTimeMs / 1000f
|
|
: k_DefaultAttackGfxDurationSecs;
|
|
|
|
yield return new WaitForSeconds(attackSecs + k_AttackGfxFadeBufferSecs);
|
|
|
|
_fadeAttackGfxCoroutine = null;
|
|
if (player == null)
|
|
yield break;
|
|
|
|
// One frame for UpdateChannelActs → Stop() → Release() → AddReleaseGfx.
|
|
yield return null;
|
|
|
|
FinishPlayerSkillGfx();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Local-only skill hit: mirrors <see cref="CECPlayer.PlayAttackEffect"/> for debug panel.
|
|
/// When <paramref name="replayAttackAnim"/> is true (channeling tick), signal prior attack so anim replays.
|
|
/// 本地技能命中:与 PlayAttackEffect 一致;引导重复 tick 时先 Signal 再重播攻击动作。
|
|
/// </summary>
|
|
private void TriggerLocalSkillAttack(CECHostPlayer hostPlayer, int skillId, bool replayAttackAnim)
|
|
{
|
|
int attackTime = 0;
|
|
CECAttackEvent pAttack = null;
|
|
|
|
int targetId = targetMarker != null
|
|
? targetMarker.GetNPCID()
|
|
: EncodeDebugNpcId();
|
|
|
|
CECAttackerEvents attackerEvents = CECAttacksMan.Instance.FindAttackByAttacker(hostPlayer.GetPlayerInfo().cid);
|
|
if (attackerEvents)
|
|
{
|
|
pAttack = attackerEvents.Find(skillId, 0);
|
|
if (pAttack != null)
|
|
{
|
|
if (replayAttackAnim)
|
|
attackerEvents.Signal();
|
|
else
|
|
{
|
|
// 面攻击的非第一次伤害消息 / Non-first hit on same attack event
|
|
pAttack.AddTarget(targetId, 1, 1);
|
|
if (attackTime != 0)
|
|
attackTime = 0;
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
attackerEvents.Signal();
|
|
}
|
|
}
|
|
|
|
if (ElementSkill.IsGoblinSkill((uint)skillId) &&
|
|
ElementSkill.GetType((uint)skillId) == 2)
|
|
{
|
|
pAttack = CECAttacksMan.Instance.AddSkillAttack(
|
|
hostPlayer.GetPlayerInfo().cid, hostPlayer.GetPlayerInfo().cid, hostPlayer.GetPlayerInfo().cid,
|
|
hostPlayer.GetWeaponID(), skillId, 0, 0x0200, 0);
|
|
}
|
|
else
|
|
{
|
|
pAttack = CECAttacksMan.Instance.AddSkillAttack(
|
|
hostPlayer.GetPlayerInfo().cid, targetId, targetId,
|
|
hostPlayer.GetWeaponID(), skillId, 0, 0x0200, 0);
|
|
}
|
|
|
|
bool ok = hostPlayer.PlaySkillAttackAction(skillId, 0, ref attackTime, 0, pAttack);
|
|
if (!ok)
|
|
BMLogger.LogWarning($"[SkillTriggerPanel] PlaySkillAttackAction returned false for skill {skillId} — attack anim may not play.");
|
|
|
|
if(!replayAttackAnim)
|
|
ScheduleFadeAllGfxWhenAttackStopped(attackTime);
|
|
|
|
SkillVisibleStateResolver.TryApplyLocalExtStates(skillId, hostPlayer, targetMarker);
|
|
}
|
|
}
|
|
}
|