diff --git a/Assets/PerfectWorld/Scripts/Managers/A3DSkillGfxMan.cs b/Assets/PerfectWorld/Scripts/Managers/A3DSkillGfxMan.cs
index 6611d201e1..a1755d82ad 100644
--- a/Assets/PerfectWorld/Scripts/Managers/A3DSkillGfxMan.cs
+++ b/Assets/PerfectWorld/Scripts/Managers/A3DSkillGfxMan.cs
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
-using System.Threading.Tasks;
using UnityEngine;
using static Unity.Cinemachine.CinemachineFreeLookModifier;
@@ -46,31 +45,23 @@ namespace BrewMonster
A3DSkillGfxComposer pComposer,
long nHostID,
long nTargetID,
-
string szFlyGfx,
-
string szHitGfx,
uint dwFlyTimeSpan,
bool bTraceTarget,
GfxMoveMode FlyMode,
int nFlyGfxCount,
uint dwInterval,
- GFX_SKILL_PARAM param,
-
- float fFlyGfxScale,
-
- float fHitGfxScale,
+ GFX_SKILL_PARAM param,
uint dwModifier,
bool bOnlyOneHit,
-
bool bFadeOut,
-
bool bIsGoblinSkill,
-
bool bReverse
)
{
-
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddSkillGfxEvent: host={nHostID}, target={nTargetID}, fly={szFlyGfx ?? "NULL"}, hit={szHitGfx ?? "NULL"}, flyTime={dwFlyTimeSpan}, mode={FlyMode}, count={nFlyGfxCount}, interval={dwInterval}");
+
bool bRet = true, bCluster;
uint dwDelayTime;
@@ -84,9 +75,14 @@ namespace BrewMonster
dwDelayTime = 0;
bCluster = true;
}
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddSkillGfxEvent: Creating {nFlyGfxCount} event(s), cluster={bCluster}, delay={dwDelayTime}");
+
for (int i = 0; i < nFlyGfxCount; i++)
{
string value = bOnlyOneHit && i != nFlyGfxCount - 1 ? "" : szHitGfx;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddSkillGfxEvent: Creating event {i + 1}/{nFlyGfxCount}, hitGfx={value ?? "NULL"}");
+
if (!AddOneSkillGfxEvent(
pComposer,
nHostID,
@@ -95,53 +91,66 @@ namespace BrewMonster
FlyMode,
dwDelayTime,
dwFlyTimeSpan,
- value,
+ value,
param,
bTraceTarget,
- fFlyGfxScale,
- fHitGfxScale,
dwModifier,
bCluster,
bFadeOut,
bIsGoblinSkill,
bReverse
))
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddSkillGfxEvent: AddOneSkillGfxEvent FAILED for event {i + 1}");
bRet = false;
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddSkillGfxEvent: AddOneSkillGfxEvent SUCCESS for event {i + 1}");
+ }
dwDelayTime += dwInterval;
}
return bRet;
-
}
public bool AddOneSkillGfxEvent(
A3DSkillGfxComposer pComposer,
long nHostID,
long nTargetID,
-
string szFlyGfx,
GfxMoveMode mode,
uint dwDelayTime,
uint dwFlyTimeSpan,
string szHitGfx,
-
- GFX_SKILL_PARAM param,
-
+ GFX_SKILL_PARAM param,
bool bTraceTarget,
-
- float fFlyGfxScale,
-
- float fHitGfxScale,
uint dwModifier,
bool bCluster,
-
bool bFadeOut,
-
bool bIsGoblinSkill,
-
bool bReverse)
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneSkillGfxEvent: host={nHostID}, target={nTargetID}, fly={szFlyGfx ?? "NULL"}, hit={szHitGfx ?? "NULL"}, mode={mode}, flyTime={dwFlyTimeSpan}, delay={dwDelayTime}");
+
+ // Validate host ID
+ // 验证施法者ID
+ if (nHostID == 0)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneSkillGfxEvent: WARNING - Invalid host ID (0), skipping event creation");
+ return false;
+ }
+
+ // Validate target ID - allow 0 for area skills, but warn about suspiciously large negative values
+ // 验证目标ID - 允许0用于区域技能,但对可疑的大负值发出警告
+ if (nTargetID < -1000000000) // Suspiciously large negative value (likely uninitialized)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneSkillGfxEvent: WARNING - Suspicious target ID ({nTargetID}), this may be uninitialized. Event will be created but may fail target lookup.");
+ }
+
A3DSkillGfxEvent pEvent = SkillGfxMan.InstanceSub.GetEmptyEvent(mode);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneSkillGfxEvent: Event created from pool, type={pEvent.GetType().Name}");
+
pEvent.SetComposer(pComposer);
pEvent.SetHostID(nHostID);
pEvent.SetTargetID(nTargetID);
@@ -163,34 +172,18 @@ namespace BrewMonster
pEvent.SetHostModelCreatedByGfx(Prop.bHostECMCreatedByGfx);
}
- /* if (szFlyGfx != 0)
- {
- A3DGFXEx pGfx = pEvent.LoadFlyGfx(m_pDevice, szFlyGfx.ToString());
- if (pGfx != null)
- {
- pGfx.SetScale(fFlyGfxScale);
- pGfx.SetDisableCamShake(pEvent.GetDisableCamShake());
- pGfx.SetCreatedByGFXECM(pEvent.GetHostModelCreatedByGfx());
- pGfx.SetUseLOD(pEvent.GetGfxUseLod());
- pGfx.SetId(pEvent.GetHostID());
- pEvent.SetFlyGfx(pGfx);
- }
- }*/
+ // NOTE: In Unity, GFX are Particle Systems — scaling is handled by the particle system itself,
+ // not by code. The C++ pGfx.SetScale() calls are not needed.
+ // 注意:在Unity中,GFX是粒子系统 — 缩放由粒子系统自身处理,不需要代码设置。
- if (string.IsNullOrEmpty(szHitGfx))
- {
- /*game pGfx = pEvent.LoadHitGfx(m_pDevice, szHitGfx.ToString());
- if (pGfx != null)
- {
- pGfx.SetScale(fHitGfxScale);
- pEvent.SetHitGfx(pGfx);
- }*/
- }
+ // Fly GFX instantiation is handled by CECSkillGfxEvent.SpawnFlyGfx()
+ // Hit GFX instantiation is handled by CECSkillGfxEvent.SpawnHitGfx()
#if !_SKILLGFXCOMPOSER
pEvent.Tick(0);
#endif
PushEvent(pEvent);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneSkillGfxEvent: Event pushed to SkillGfxMan, active events={SkillGfxMan.InstanceSub.m_EventLst.Count}");
return true;
}
public virtual bool GetPropertyById(long nId, ref ECMODEL_GFX_PROPERTY pProperty) => false;
@@ -397,35 +390,70 @@ namespace BrewMonster
// 在Unity中,命中特效通过Destroy(obj, 3f)自动销毁。立即转为Finished状态。
m_enumState = GfxSkillEventState.enumFinished;
}
- else if (m_dwCurSpan > m_dwFlyTimeSpan) // 飞行超时 / Flight timeout
- {
- if (!m_bTargetExist)
- m_enumState = GfxSkillEventState.enumFinished;
- else
- HitTarget(GetTargetCenter());
- }
- else if (!m_bTargetExist)
- m_enumState = GfxSkillEventState.enumFinished;
else if (m_enumState == GfxSkillEventState.enumWait)
{
if (m_dwCurSpan < m_dwDelayTime) return;
+ // Check host existence before transitioning to Flying
+ // 在转换到Flying之前检查施法者是否存在
if (!m_bHostExist)
+ {
+ m_enumState = GfxSkillEventState.enumFinished;
+ return;
+ }
+
+ // For skills that require a target, check target existence before starting flight
+ // For area skills or skills without specific targets, allow flight even if target doesn't exist
+ // 对于需要目标的技能,在开始飞行前检查目标是否存在
+ // 对于区域技能或没有特定目标的技能,即使目标不存在也允许飞行
+ if (!m_bTargetExist && m_nTargetID != 0)
+ {
+ // Target is required but doesn't exist - finish the event
+ // 需要目标但目标不存在 - 结束事件
+ m_enumState = GfxSkillEventState.enumFinished;
+ return;
+ }
+
+ // Transition to Flying state
+ // 转换到飞行状态
+ m_enumState = GfxSkillEventState.enumFlying;
+ m_pMoveMethod.SetMaxFlyTime(m_dwFlyTimeSpan);
+
+ // Use target position if available, otherwise use host position (for area skills)
+ // 如果目标位置可用则使用,否则使用施法者位置(用于区域技能)
+ Vector3 targetPos = m_bTargetExist ? m_vTargetPos : m_vHostPos;
+ m_pMoveMethod.StartMove(m_vHostPos, targetPos);
+
+ // Fly GFX spawning is handled by CECSkillGfxEvent.Tick() when it detects Wait→Flying transition
+ // 飞行特效的生成由CECSkillGfxEvent.Tick()在检测到Wait→Flying转换时处理
+ }
+ else if (m_dwCurSpan > m_dwFlyTimeSpan) // 飞行超时 / Flight timeout
+ {
+ if (!m_bTargetExist && m_nTargetID != 0)
m_enumState = GfxSkillEventState.enumFinished;
else
{
- m_enumState = GfxSkillEventState.enumFlying;
- m_pMoveMethod.SetMaxFlyTime(m_dwFlyTimeSpan);
- m_pMoveMethod.StartMove(m_vHostPos, m_vTargetPos);
-
- // Fly GFX spawning is handled by CECSkillGfxEvent.Tick() when it detects Wait→Flying transition
- // 飞行特效的生成由CECSkillGfxEvent.Tick()在检测到Wait→Flying转换时处理
+ Vector3 hitPos = m_bTargetExist ? GetTargetCenter() : m_pMoveMethod.GetPos();
+ HitTarget(hitPos);
}
}
else // enumFlying state / 飞行状态
{
if (m_pMoveMethod.TickMove(dwDeltaTime, m_vHostPos, m_vTargetPos)) // 目标被命中 / Target hit
- HitTarget(GetTargetCenter());
+ {
+ // Only call GetTargetCenter if target exists and is not destroyed
+ // 仅在目标存在且未销毁时调用GetTargetCenter
+ if (m_bTargetExist && m_nTargetID != 0)
+ {
+ HitTarget(GetTargetCenter());
+ }
+ else
+ {
+ // Target destroyed, hit at last known position or current position
+ // 目标已销毁,在最后已知位置或当前位置命中
+ HitTarget(m_bTargetExist ? m_vTargetPos : m_pMoveMethod.GetPos());
+ }
+ }
// Fly GFX transform update is handled by CECSkillGfxEvent.Tick()
// 飞行特效的变换更新由CECSkillGfxEvent.Tick()处理
diff --git a/Assets/PerfectWorld/Scripts/Managers/CECAttacksMan.cs b/Assets/PerfectWorld/Scripts/Managers/CECAttacksMan.cs
index bf07c3e77c..8695b11a50 100644
--- a/Assets/PerfectWorld/Scripts/Managers/CECAttacksMan.cs
+++ b/Assets/PerfectWorld/Scripts/Managers/CECAttacksMan.cs
@@ -11,7 +11,7 @@ using System.Text;
using ModelRenderer.Scripts.Common;
using BrewMonster.Scripts;
using UnityEngine;
-using System.Threading.Tasks;
+ using Cysharp.Threading.Tasks;
namespace BrewMonster
{
@@ -114,7 +114,7 @@ namespace BrewMonster
// Yield every 10 skills to keep Unity responsive
if ((loadedCount + failedCount) % 10 == 0)
{
- await System.Threading.Tasks.Task.Yield();
+ await UniTask.Yield();
}
}
@@ -162,6 +162,51 @@ namespace BrewMonster
// 更新技能特效事件(飞行/命中特效状态机)
SkillGfxMan.InstanceSub.Tick(dwDeltaTime);
}
+
+#if UNITY_EDITOR
+ ///
+ /// Draw gizmos for skill projectiles in Unity Editor
+ /// 在Unity编辑器中绘制技能弹道辅助线
+ ///
+ private void OnDrawGizmos()
+ {
+ // Always draw gizmos (not just when selected)
+ // 始终绘制辅助线(不仅在选择时)
+ int gizmoCount = SkillGfxGizmoDrawer.GetGizmoCount();
+
+ // Draw test gizmo at origin to verify OnDrawGizmos is working
+ // 在原点绘制测试辅助线以验证OnDrawGizmos是否工作
+ if (gizmoCount == 0 && Time.frameCount % 120 == 0) // Log every 2 seconds when no gizmos
+ {
+ // Draw a small test sphere at origin to verify gizmos work
+ // 在原点绘制小测试球体以验证辅助线是否工作
+ Gizmos.color = Color.magenta;
+ Gizmos.DrawWireSphere(Vector3.zero, 1.0f);
+ }
+
+ if (gizmoCount > 0)
+ {
+ // Only log occasionally to avoid spam
+ // 仅偶尔记录以避免刷屏
+ if (Time.frameCount % 60 == 0)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] OnDrawGizmos: Drawing {gizmoCount} gizmo(s)");
+ }
+ }
+ SkillGfxGizmoDrawer.DrawGizmos();
+ }
+
+ ///
+ /// Draw gizmos when selected (for debugging)
+ /// 选择时绘制辅助线(用于调试)
+ ///
+ private void OnDrawGizmosSelected()
+ {
+ // Also draw when selected for extra visibility
+ // 选择时也绘制以增加可见性
+ SkillGfxGizmoDrawer.DrawGizmos();
+ }
+#endif
bool FileExists(string relativePath)
{
string fullPath = Path.Combine(Application.streamingAssetsPath, relativePath);
@@ -482,7 +527,7 @@ namespace BrewMonster
m_bFadeOut = stub.m_bFadeOut;
m_bRelScl = stub.m_bRelScl;
m_fDefTarScl = stub.m_fDefTarScl;
- m_param = stub.m_param;
+ //m_param = stub.m_param;
}
// GFX prefab accessors / GFX预制体访问器
@@ -499,12 +544,11 @@ namespace BrewMonster
string flyGfxName;
string hitGrdGfxName;
#endif
- public async Task Load(SkillStub skillStub, string flyGFXPath, string hitGrdGFXPath, string hitGFXPath)
+ public async UniTask Load(SkillStub skillStub, string flyGFXPath, string hitGrdGFXPath, string hitGFXPath)
{
flyGfxName = flyGFXPath;
hitGfxName = hitGFXPath;
hitGrdGfxName = hitGrdGFXPath;
-#endif
// Load GFX prefabs / 加载GFX预制体
flyGFX = string.IsNullOrEmpty(flyGfxName) ? null : await AddressableManager.Instance.LoadPrefabAsync("gfx/" + flyGfxName);
@@ -587,36 +631,94 @@ namespace BrewMonster
{
bool bCastInTargets = false;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Composer.Play: host={nHostID}, castTarget={nCastTargetID}, targets={(targets?.Count ?? 0)}, isGoblin={bIsGoblinSkill}");
+
// Determine GFX names from loaded prefabs / 从已加载的预制体确定GFX名称
string szFly = flyGFX != null ? flyGfxName : null;
string szHit = hitGFX != null ? hitGfxName : null;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Composer.Play: flyGFX={(flyGFX != null ? "LOADED" : "NULL")}, hitGFX={(hitGFX != null ? "LOADED" : "NULL")}, szFly={szFly ?? "NULL"}, szHit={szHit ?? "NULL"}");
+
// TODO Phase 2: Optimization checks / 第二阶段:优化检查
// if (!CECOptimize.Instance.GetGFX().CanShowFly(nHostID)) szFly = null;
// if (!CECOptimize.Instance.GetGFX().CanShowHit(nHostID)) szHit = null;
+ // Validate targets exist before processing (filter out destroyed targets)
+ // 在处理前验证目标是否存在(过滤已销毁的目标)
if (targets != null && targets.Count > 0)
{
+ var validTargets = new List();
+ foreach (var tar in targets)
+ {
+ if (ValidateTargetExists(tar.idTarget))
+ {
+ validTargets.Add(tar);
+ }
+ else
+ {
+ BMLogger.LogWarning($"[SKILL_GFX_DEBUG] Composer.Play: Target {tar.idTarget} is destroyed, skipping");
+ }
+ }
+
+ if (validTargets.Count == 0)
+ {
+ BMLogger.LogWarning($"[SKILL_GFX_DEBUG] Composer.Play: All targets destroyed, skipping GFX");
+ return;
+ }
+
+ int originalCount = targets.Count;
+ targets = validTargets;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Composer.Play: Processing {targets.Count} valid targets (filtered from {originalCount})");
+
for (int i = 0; i < targets.Count; i++)
{
var tar = targets[i];
if (nCastTargetID == tar.idTarget)
bCastInTargets = true;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Composer.Play: Calling AddOneTarget for target {i}/{targets.Count}, id={tar.idTarget}");
AddOneTarget(nCastTargetID, nHostID, szFly, szHit, tar, i == 0, bIsGoblinSkill);
}
}
if (nCastTargetID != 0 && !bCastInTargets)
{
+ // Validate cast target exists before adding
+ // 在添加前验证施法目标是否存在
+ if (!ValidateTargetExists(nCastTargetID))
+ {
+ BMLogger.LogWarning($"[SKILL_GFX_DEBUG] Composer.Play: Cast target {nCastTargetID} is destroyed, skipping");
+ return;
+ }
+
TARGET_DATA tar = default;
tar.idTarget = nCastTargetID;
tar.dwModifier = 0;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Composer.Play: Cast target not in targets list, adding separately");
AddOneTarget(nCastTargetID, nHostID, szFly, szHit, tar, false, bIsGoblinSkill);
}
}
+
+ ///
+ /// Validate that a target exists and its GameObject is not destroyed
+ /// 验证目标存在且其GameObject未销毁
+ ///
+ private bool ValidateTargetExists(int idTarget)
+ {
+ if (GPDataTypeHelper.ISNPCID(idTarget))
+ {
+ var npc = EC_ManMessageMono.Instance?.CECNPCMan?.GetNPCFromAll(idTarget);
+ return npc != null && npc.gameObject != null;
+ }
+ else if (GPDataTypeHelper.ISPLAYERID(idTarget))
+ {
+ var player = EC_ManMessageMono.Instance?.GetECManPlayer?.GetPlayer(idTarget);
+ return player != null && player.gameObject != null;
+ }
+ return false;
+ }
public void AddOneTarget(
int nCastTargetID,
int nHostID,
@@ -630,6 +732,8 @@ namespace BrewMonster
float fScale;
bool bReverse;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneTarget: castTarget={nCastTargetID}, host={nHostID}, target={tar.idTarget}, fly={szFly ?? "NULL"}, hit={szHit ?? "NULL"}, first={bFirst}");
+
// 根据目标模式决定Host和Target的映射 / Determine Host and Target mapping based on target mode
switch (m_TargetMode)
{
@@ -649,11 +753,13 @@ namespace BrewMonster
break;
}
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneTarget: After mapping - _Host={_Host}, _Target={_Target}, reverse={bReverse}, moveMode={m_MoveMode}, flyTime={m_dwFlyTime}");
+
// 计算缩放 / Calculate scale
- if (m_bRelScl)
+ /* if (m_bRelScl)
fScale = SkillGfxMan.InstanceSub.GetTargetScale(_Target) / m_fDefTarScl * m_fHitGfxScale;
else
- fScale = m_fHitGfxScale;
+ fScale = m_fHitGfxScale;*/
// 根据目标类型决定是否显示特效 / Determine whether to show effects based on target type
if ((nCastTargetID != 0 && tar.idTarget != nCastTargetID)
@@ -669,6 +775,13 @@ namespace BrewMonster
}
}
+ if (m_pSkillGfxMan == null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneTarget: m_pSkillGfxMan is NULL - cannot add event!");
+ return;
+ }
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] AddOneTarget: Calling m_pSkillGfxMan.AddSkillGfxEvent(host={_Host}, target={_Target}, fly={szFly ?? "NULL"}, hit={szHit ?? "NULL"}, flyTime={m_dwFlyTime}, moveMode={m_MoveMode})");
// 调用GFX管理器添加技能特效事件 / Call GFX manager to add skill GFX event
m_pSkillGfxMan.AddSkillGfxEvent(
@@ -683,8 +796,6 @@ namespace BrewMonster
(int)m_FlyCluster.m_ulCount,
m_FlyCluster.m_dwInterv,
m_param,
- m_fFlyGfxScale,
- fScale,
tar.dwModifier,
m_bOneHit,
m_bFadeOut,
@@ -789,6 +900,8 @@ public class CECAttackEvent
m_bDoFired = true;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: skill={m_idSkill}, host={m_idHost}, castTarget={m_idCastTarget}, targets={m_targets.Count}, section={m_nSkillSection}");
+
if (GPDataTypeHelper.ISPLAYERID(m_idHost))
{
@@ -803,11 +916,14 @@ public class CECAttackEvent
if (pMan != null)
{
bool isGoblin = ElementSkill.IsGoblinSkill((uint)m_idSkill);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: Multi-section skill, calling pMan.Play()");
pMan.Play(m_idSkill, m_nSkillSection, m_idHost, m_idCastTarget, m_targets, isGoblin);
pComposer = pMan.GetSkillGfxComposer(m_idSkill, m_nSkillSection);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: Multi-section composer={(pComposer != null ? "FOUND" : "NULL")}");
}
else
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: Multi-section pMan is NULL!");
}
}
else
@@ -816,18 +932,22 @@ public class CECAttackEvent
// Get the composer manager
var composerMan = m_pManager.GetSkillGfxComposerMan();
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: composerMan={(composerMan != null ? "FOUND" : "NULL")}");
if (composerMan != null)
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: Calling composerMan.Play(skill={m_idSkill}, host={m_idHost}, castTarget={m_idCastTarget}, targets={m_targets.Count}, isGoblin={isGoblin})");
if (isGoblin)
composerMan.Play(m_idSkill, m_idHost, m_idCastTarget, m_targets, true);
else
composerMan.Play(m_idSkill, m_idHost, m_idCastTarget, m_targets);
pComposer = composerMan.GetSkillGfxComposer(m_idSkill);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: Composer lookup result={(pComposer != null ? "FOUND" : "NULL")}, flyTime={(pComposer != null ? pComposer.m_dwFlyTime : 0)}");
}
else
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] DoFire: composerMan is NULL - cannot play skill GFX!");
}
}
diff --git a/Assets/PerfectWorld/Scripts/Managers/CECNPCMan.cs b/Assets/PerfectWorld/Scripts/Managers/CECNPCMan.cs
index e8f47fdbba..28f0feb36d 100644
--- a/Assets/PerfectWorld/Scripts/Managers/CECNPCMan.cs
+++ b/Assets/PerfectWorld/Scripts/Managers/CECNPCMan.cs
@@ -191,7 +191,9 @@ public class CECNPCMan : IMsgHandler
hostplayer.SelectTarget(0);
// Remove it from active NPC table
- m_NPCTab.Remove(nid);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.NPCLeave: Removing NPC from m_NPCTab - nid={nid}, table size before={m_NPCTab.Count}");
+ bool removed = m_NPCTab.Remove(nid);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.NPCLeave: NPC removed={(removed ? "SUCCESS" : "FAILED (not in table)" )}, table size after={m_NPCTab.Count}");
// Forbid reloading npc's resources
//QueueNPCUndoLoad(nid, pNPC->GetBornStamp());
@@ -374,13 +376,19 @@ public class CECNPCMan : IMsgHandler
}
private bool OnMsgNPCInfo(ECMSG msg)
{
- switch (Convert.ToInt32(msg.dwParam2))
+ int commandId = Convert.ToInt32(msg.dwParam2);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: Received NPC info message, commandID={commandId}");
+
+ switch (commandId)
{
case CommandID.NPC_INFO_LIST:
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: Processing NPC_INFO_LIST");
// msg.dwParam1 chính là buffer chứa placeholder data (không có header cmd_npc_info_list)
cmd_npc_info_list pCmd = MemoryMarshal.Read(((byte[])msg.dwParam1).AsSpan());
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: NPC_INFO_LIST contains {pCmd.count} NPC(s)");
+
int offset = Marshal.OffsetOf("placeholder").ToInt32();
byte[] buffer = (byte[])msg.dwParam1;
Span pDataBuf = buffer.AsSpan(offset);
@@ -390,6 +398,8 @@ public class CECNPCMan : IMsgHandler
// giống const info_npc& Info = *(const info_npc*)pDataBuf;
info_npc info = MemoryMarshal.Read(pDataBuf);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: Processing NPC {i + 1}/{pCmd.count} - nid={info.nid}, tid={info.tid}");
+
int iSize = info_npc.HEADER_SIZE;
if ((info.state & PlayerNPCState.GP_STATE_EXTEND_PROPERTY) != 0)
iSize += sizeof(uint) * NumberDWORDsPlayerNPC.OBJECT_EXT_STATE_COUNT;
@@ -419,16 +429,20 @@ public class CECNPCMan : IMsgHandler
case CommandID.NPC_ENTER_SLICE:
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: Processing NPC_ENTER_SLICE");
var buffer = (byte[])msg.dwParam1;
info_npc info = MemoryMarshal.Read(buffer.AsSpan(0, info_npc.HEADER_SIZE));
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: NPC_ENTER_SLICE - nid={info.nid}, tid={info.tid}");
NPCEnter(info, false, buffer, info_npc.HEADER_SIZE);
break;
}
case CommandID.NPC_ENTER_WORLD:
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: Processing NPC_ENTER_WORLD");
var buffer = (byte[])msg.dwParam1;
info_npc info = MemoryMarshal.Read(buffer.AsSpan(0, info_npc.HEADER_SIZE));
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.OnMsgNPCInfo: NPC_ENTER_WORLD - nid={info.nid}, tid={info.tid}");
NPCEnter(info, true, buffer, info_npc.HEADER_SIZE);
break;
}
@@ -492,7 +506,9 @@ public class CECNPCMan : IMsgHandler
}
// Thêm NPC vào bảng
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.NPCEnter: Adding NPC to m_NPCTab - nid={Info.nid}, tid={Info.tid}, npc={(npc != null ? "created" : "NULL")}");
m_NPCTab[Info.nid] = npc;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.NPCEnter: NPC added successfully. m_NPCTab[Info.nid] = npc {m_NPCTab[Info.nid].name} NPC(s)");
return true;
}
// Get NPC by id and optional bornStamp
@@ -524,17 +540,28 @@ public class CECNPCMan : IMsgHandler
}
public CECNPC GetNPCFromAll(int nid)
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.GetNPCFromAll: Looking for NPC nid={nid}, m_NPCTab.Count={m_NPCTab.Count}");
+
CECNPC pNPC = GetNPC(nid);
- if (pNPC)
- return pNPC;
-
- // Search from disappear array ?
- /*for (int i = 0; i < m_aDisappearNPCs.GetSize(); i++)
+ if (pNPC != null)
{
- CECNPC* pNPC = m_aDisappearNPCs[i];
- if (pNPC->GetNPCID() == nid)
- return pNPC;
- }*/
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.GetNPCFromAll: GetNPC returned {pNPC.name}");
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.GetNPCFromAll: NPC {nid} FOUND in m_NPCTab");
+ return pNPC;
+ }
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.GetNPCFromAll: NPC {nid} NOT FOUND in m_NPCTab! Available NPC IDs: {string.Join(", ", m_NPCTab.Keys)}");
+
+ // Search from disappear array - provides grace period for GFX events (matches C++ behavior)
+ for (int i = 0; i < m_aDisappearNPCs.Count; i++)
+ {
+ CECNPC pDisappearNPC = m_aDisappearNPCs[i];
+ if (pDisappearNPC != null && pDisappearNPC.gameObject != null && pDisappearNPC.GetNPCID() == nid)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CECNPCMan.GetNPCFromAll: NPC {nid} FOUND in m_aDisappearNPCs");
+ return pDisappearNPC; // Return NPC even if removed from main table
+ }
+ }
return null;
}
diff --git a/Assets/PerfectWorld/Scripts/Managers/CECSkillGfxMan.cs b/Assets/PerfectWorld/Scripts/Managers/CECSkillGfxMan.cs
index 37db22d9c6..4c6d574e2d 100644
--- a/Assets/PerfectWorld/Scripts/Managers/CECSkillGfxMan.cs
+++ b/Assets/PerfectWorld/Scripts/Managers/CECSkillGfxMan.cs
@@ -81,39 +81,63 @@ namespace BrewMonster
{
Vector3 vTargetCenter = Vector3.zero;
- // if composer has been set
- // use the composer's parameter to make the hook information affect.
- // 如果已设置组合器,使用组合器的参数来影响挂点信息
- if (GetComposer() != null)
+ try
{
- A3DSkillGfxComposer pComposer = GetComposer();
- _get_pos_by_id(
- m_pPlayerMan,
- m_pNPCMan,
- (int)m_nTargetID,
- out vTargetCenter,
- pComposer.m_HitPos.HitPos,
- false,
- pComposer.m_HitPos.szHook,
- pComposer.m_HitPos.bRelHook,
- pComposer.m_HitPos.vOffset,
- pComposer.m_HitPos.szHanger,
- pComposer.m_HitPos.bChildHook);
+ // if composer has been set
+ // use the composer's parameter to make the hook information affect.
+ // 如果已设置组合器,使用组合器的参数来影响挂点信息
+ if (GetComposer() != null)
+ {
+ A3DSkillGfxComposer pComposer = GetComposer();
+ bool success = get_pos_by_id(
+ m_pPlayerMan,
+ m_pNPCMan,
+ (int)m_nTargetID,
+ out vTargetCenter,
+ pComposer.m_HitPos.HitPos,
+ false,
+ pComposer.m_HitPos.szHook,
+ pComposer.m_HitPos.bRelHook,
+ pComposer.m_HitPos.vOffset,
+ pComposer.m_HitPos.szHanger,
+ pComposer.m_HitPos.bChildHook);
+
+ if (!success)
+ {
+ // Return last known position or zero if target is destroyed
+ // 如果目标已销毁,返回最后已知位置或零
+ return m_vTargetPos != Vector3.zero ? m_vTargetPos : Vector3.zero;
+ }
+ }
+ else
+ {
+ bool success = get_pos_by_id(
+ m_pPlayerMan,
+ m_pNPCMan,
+ (int)m_nTargetID,
+ out vTargetCenter,
+ GfxHitPos.enumHitCenter,
+ false,
+ null,
+ false,
+ Vector3.zero,
+ null,
+ false);
+
+ if (!success)
+ {
+ // Return last known position or zero if target is destroyed
+ // 如果目标已销毁,返回最后已知位置或零
+ return m_vTargetPos != Vector3.zero ? m_vTargetPos : Vector3.zero;
+ }
+ }
}
- else
+ catch (System.Exception ex)
{
- _get_pos_by_id(
- m_pPlayerMan,
- m_pNPCMan,
- (int)m_nTargetID,
- out vTargetCenter,
- GfxHitPos.enumHitCenter,
- false,
- null,
- false,
- Vector3.zero,
- null,
- false);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] GetTargetCenter: Exception accessing target {m_nTargetID} - {ex.Message}");
+ // Return last known position or zero
+ // 返回最后已知位置或零
+ return m_vTargetPos != Vector3.zero ? m_vTargetPos : Vector3.zero;
}
return vTargetCenter;
@@ -125,6 +149,9 @@ namespace BrewMonster
///
public override void Tick(uint dwDeltaTime)
{
+ // Track state before base.Tick() to detect transitions / 在base.Tick()前记录状态以检测转换
+ GfxSkillEventState prevState = m_enumState;
+
// Update host and target positions / 更新施法者和目标位置
if (GetComposer() != null)
{
@@ -141,7 +168,7 @@ namespace BrewMonster
pTargetPos = m_pComposer.m_FlyEndPos;
}
- m_bHostExist = _get_pos_by_id(
+ m_bHostExist = get_pos_by_id(
m_pPlayerMan,
m_pNPCMan,
(int)m_nHostID,
@@ -154,7 +181,7 @@ namespace BrewMonster
pHostPos.szHanger,
pHostPos.bChildHook);
- m_bTargetExist = _get_pos_by_id(
+ m_bTargetExist = get_pos_by_id(
m_pPlayerMan,
m_pNPCMan,
(int)m_nTargetID,
@@ -171,7 +198,7 @@ namespace BrewMonster
}
else
{
- m_bHostExist = _get_pos_by_id(
+ m_bHostExist = get_pos_by_id(
m_pPlayerMan,
m_pNPCMan,
(int)m_nHostID,
@@ -184,7 +211,7 @@ namespace BrewMonster
null,
false);
- m_bTargetExist = _get_pos_by_id(
+ m_bTargetExist = get_pos_by_id(
m_pPlayerMan,
m_pNPCMan,
(int)m_nTargetID,
@@ -198,17 +225,75 @@ namespace BrewMonster
false);
}
- // Track state before base.Tick() to detect transitions / 在base.Tick()前记录状态以检测转换
- GfxSkillEventState prevState = m_enumState;
+ // Log target existence issues with more detail
+ // 记录目标存在问题的更多详细信息
+ if (!m_bTargetExist && m_nTargetID != 0)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: WARNING - Target {m_nTargetID} does not exist (host={m_nHostID}, exist={m_bHostExist}, pos={m_vHostPos}), state={prevState}. Target may have been destroyed or ID is invalid.");
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: host={m_nHostID} (exist={m_bHostExist}, pos={m_vHostPos}), target={m_nTargetID} (exist={m_bTargetExist}, pos={m_vTargetPos}), state={prevState}");
+ }
+
base.Tick(dwDeltaTime);
+ // Log state transitions
+ if (prevState != m_enumState)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: State transition {prevState} → {m_enumState}, host={m_nHostID}, target={m_nTargetID}");
+ }
+
// Spawn fly GFX when entering Flying state / 进入飞行状态时生成飞行特效
if (prevState == GfxSkillEventState.enumWait && m_enumState == GfxSkillEventState.enumFlying)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: Transitioning to Flying, calling SpawnFlyGfx()");
+
+ // Register for gizmo drawing BEFORE spawning (so we capture the initial position)
+ // 在生成前注册用于辅助线绘制(以便捕获初始位置)
+#if UNITY_EDITOR
+ Vector3 currentPos = m_pMoveMethod.GetPos();
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: Registering gizmo - hostPos={m_vHostPos}, targetPos={m_vTargetPos}, currentPos={currentPos}, hostExist={m_bHostExist}, targetExist={m_bTargetExist}");
+
+ // Only register if positions are valid (not zero)
+ // 仅在位置有效(非零)时注册
+ if (m_vHostPos.sqrMagnitude > 0.01f && m_vTargetPos.sqrMagnitude > 0.01f)
+ {
+ SkillGfxGizmoDrawer.RegisterProjectile(m_nHostID, m_nTargetID, m_vHostPos, m_vTargetPos, m_pMoveMethod.GetMode());
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Event.Tick: Gizmo registration SKIPPED - invalid positions!");
+ }
+#endif
+
SpawnFlyGfx();
+ }
// Update fly GFX transform during Flying / 飞行期间更新飞行特效变换
if (m_enumState == GfxSkillEventState.enumFlying)
+ {
UpdateFlyGfxTransform();
+
+ // Update gizmo position / 更新辅助线位置
+#if UNITY_EDITOR
+ Vector3 currentPos = m_pMoveMethod.GetPos();
+ // Only update if position is valid
+ // 仅在位置有效时更新
+ if (currentPos.sqrMagnitude > 0.01f)
+ {
+ SkillGfxGizmoDrawer.UpdateProjectile(m_nHostID, m_nTargetID, currentPos, m_vTargetPos);
+ }
+#endif
+ }
+
+ // Remove gizmo when hit or finished / 命中或完成时移除辅助线
+ if (m_enumState == GfxSkillEventState.enumHit || m_enumState == GfxSkillEventState.enumFinished)
+ {
+#if UNITY_EDITOR
+ SkillGfxGizmoDrawer.RemoveProjectile(m_nHostID, m_nTargetID);
+#endif
+ }
}
///
@@ -217,6 +302,7 @@ namespace BrewMonster
///
protected override void HitTarget(Vector3 vTarget)
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] HitTarget: Entry, host={m_nHostID}, target={m_nTargetID}, pos={vTarget}");
base.HitTarget(vTarget);
DestroyFlyGfx();
SpawnHitGfx(vTarget);
@@ -234,15 +320,36 @@ namespace BrewMonster
///
private void SpawnFlyGfx()
{
- if (m_pComposer == null) return;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: Entry, host={m_nHostID}, target={m_nTargetID}");
+
+ if (m_pComposer == null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: m_pComposer is NULL - cannot spawn fly GFX!");
+ return;
+ }
+
GameObject prefab = m_pComposer.GetFlyGFX();
- if (prefab == null) return;
+ if (prefab == null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: Fly GFX prefab is NULL - cannot spawn!");
+ return;
+ }
Vector3 pos = m_pMoveMethod.GetPos();
Vector3 dir = m_pMoveMethod.GetMoveDir();
Quaternion rot = dir.sqrMagnitude > 1e-4f ? Quaternion.LookRotation(dir) : Quaternion.identity;
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: Instantiating prefab={prefab.name} at pos={pos}, dir={dir}");
m_flyGfxInstance = GameObject.Instantiate(prefab, pos, rot);
- BMLogger.Log($"[GFX_FLOW] SpawnFlyGfx at {pos}, prefab={prefab.name}");
+
+ if (m_flyGfxInstance != null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: SUCCESS - Fly GFX spawned at {pos}, instance={m_flyGfxInstance.name}");
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnFlyGfx: FAILED - Instantiate returned NULL!");
+ }
}
///
@@ -277,9 +384,20 @@ namespace BrewMonster
///
private void SpawnHitGfx(Vector3 vTarget)
{
- if (m_pComposer == null) return;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: Entry, host={m_nHostID}, target={m_nTargetID}, pos={vTarget}");
+
+ if (m_pComposer == null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: m_pComposer is NULL - cannot spawn hit GFX!");
+ return;
+ }
+
GameObject prefab = m_pComposer.GetHitGFX();
- if (prefab == null) return;
+ if (prefab == null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: Hit GFX prefab is NULL - cannot spawn!");
+ return;
+ }
Quaternion rot = Quaternion.identity;
if (m_bHostExist)
@@ -289,9 +407,19 @@ namespace BrewMonster
if (dir.sqrMagnitude > 1e-6f) rot = Quaternion.LookRotation(dir);
}
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: Instantiating prefab={prefab.name} at pos={vTarget}");
m_hitGfxInstance = GameObject.Instantiate(prefab, vTarget, rot);
+
+ if (m_hitGfxInstance != null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: SUCCESS - Hit GFX spawned at {vTarget}, instance={m_hitGfxInstance.name}");
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SpawnHitGfx: FAILED - Instantiate returned NULL!");
+ }
+
GameObject.Destroy(m_hitGfxInstance, 3.0f); // auto-cleanup / 自动清理
- BMLogger.Log($"[GFX_FLOW] SpawnHitGfx at {vTarget}, prefab={prefab.name}");
}
///
@@ -342,7 +470,7 @@ namespace BrewMonster
/// Get position by ID (player or NPC)
/// 根据ID获取位置(玩家或NPC)
///
- private static bool _get_pos_by_id(
+ private static bool get_pos_by_id(
EC_ManPlayer pPlayerMan,
CECNPCMan pNPCMan,
int nID,
@@ -357,47 +485,106 @@ namespace BrewMonster
{
vPos = Vector3.zero;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: Entry - nID={nID}, isPlayerID={GPDataTypeHelper.ISPLAYERID(nID)}, isNPCID={GPDataTypeHelper.ISNPCID(nID)}, pPlayerMan={(pPlayerMan != null ? "exists" : "NULL")}, pNPCMan={(pNPCMan != null ? "exists" : "NULL")}");
+
if (GPDataTypeHelper.ISPLAYERID(nID))
{
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: ID {nID} is a PLAYER ID");
CECPlayer pPlayer = pPlayerMan?.GetPlayer(nID);
- if (pPlayer != null)
+ // Check if player exists AND GameObject is not destroyed (Unity's "fake null" handling)
+ if (pPlayer != null && pPlayer.gameObject != null)
{
- if (bIsGoblinSkill)
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: Player {nID} found, getting position");
{
- // TODO: Handle goblin skill position
- // if (pPlayer->GetGoblinModel())
- // vPos = pPlayer->GetGoblinModel()->GetModel()->GetModelAABB().Center;
- // else
- // return false;
- return false;
+ if (bIsGoblinSkill)
+ {
+ // TODO: Handle goblin skill position
+ // if (pPlayer->GetGoblinModel())
+ // vPos = pPlayer->GetGoblinModel()->GetModel()->GetModelAABB().Center;
+ // else
+ // return false;
+ return false;
+ }
+ else
+ {
+ // currently hook does not affect the Goblin Skill
+ // 目前挂点不影响小精灵技能
+ while (true)
+ {
+ if (string.IsNullOrEmpty(szHook))
+ break;
+
+ // TODO: Get player model and hook position
+ /*CECModel pModel = pPlayer->GetPlayerModel();
+ if (!pModel)
+ break;
+
+ if (szHanger && bChildHook)
+ pModel = pModel->GetChildModel(szHanger);
+
+ if (!pModel)
+ break;
+
+ A3DSkinModel* pSkin = pModel->GetA3DSkinModel();
+ A3DSkeletonHook* pHook = pSkin->GetSkeletonHook(szHook, true);
+
+ if (!pHook)
+ break;
+
+ if (bRelHook)
+ vPos = pHook->GetAbsoluteTM() * pOffset;
+ else
+ {
+ vPos = pSkin->GetAbsoluteTM() * pOffset;
+ vPos = vPos - pSkin->GetAbsoluteTM().GetRow(3) + pHook->GetAbsoluteTM().GetRow(3);
+ }
+
+ return true;*/
+ break;
+ }
+
+ if (HitPos == GfxHitPos.enumHitBottom)
+ {
+ vPos = pPlayer.GetPosVector3();
+ }
+ else
+ {
+ // TODO: Get player AABB
+ // const A3DAABB& aabb = pPlayer->GetPlayerAABB();
+ // vPos = aabb.Center;
+ // vPos.y += aabb.Extents.y * .5f;
+ vPos = pPlayer.GetPosVector3();
+ vPos.y += 1.0f; // Default height offset / 默认高度偏移
+ }
+ }
+
+ return true;
}
- else
+ }
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: Player {nID} NOT FOUND or GameObject destroyed in pPlayerMan (pPlayerMan is {(pPlayerMan != null ? "not null" : "NULL")})");
+ }
+ }
+ else if (GPDataTypeHelper.ISNPCID(nID))
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: ID {nID} is an NPC ID");
+ CECNPC pNPC = pNPCMan?.GetNPCFromAll(nID);
+
+ // Check if NPC exists AND GameObject is not destroyed (Unity's "fake null" handling)
+ if (pNPC != null && pNPC.gameObject != null)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: NPC {nID} found, getting position");
{
- // currently hook does not affect the Goblin Skill
- // 目前挂点不影响小精灵技能
while (true)
{
- if (string.IsNullOrEmpty(szHook))
- break;
-
- // TODO: Get player model and hook position
- /*CECModel pModel = pPlayer->GetPlayerModel();
- if (!pModel)
- break;
-
- if (szHanger && bChildHook)
- pModel = pModel->GetChildModel(szHanger);
-
- if (!pModel)
- break;
-
- A3DSkinModel* pSkin = pModel->GetA3DSkinModel();
- A3DSkeletonHook* pHook = pSkin->GetSkeletonHook(szHook, true);
-
+ // TODO: Get NPC hook position
+ /*A3DSkeletonHook* pHook = pNPC->GetSgcHook(szHanger, bChildHook, szHook);
if (!pHook)
break;
+ A3DSkinModel *pSkin = pNPC->GetSgcSkinModel(szHanger, bChildHook, szHook);
if (bRelHook)
vPos = pHook->GetAbsoluteTM() * pOffset;
else
@@ -412,66 +599,32 @@ namespace BrewMonster
if (HitPos == GfxHitPos.enumHitBottom)
{
- vPos = pPlayer.GetPosVector3();
+ vPos = pNPC.GetPosVector3();
}
else
{
- // TODO: Get player AABB
- // const A3DAABB& aabb = pPlayer->GetPlayerAABB();
+ // TODO: Get NPC AABB
+ // const A3DAABB& aabb = pNPC->GetPickAABB();
// vPos = aabb.Center;
// vPos.y += aabb.Extents.y * .5f;
- vPos = pPlayer.GetPosVector3();
+ vPos = pNPC.GetPosVector3();
vPos.y += 1.0f; // Default height offset / 默认高度偏移
}
+
+ return true;
}
-
- return true;
}
- }
- else if (GPDataTypeHelper.ISNPCID(nID))
- {
- CECNPC pNPC = pNPCMan?.GetNPCFromAll(nID);
-
- if (pNPC != null)
+ else
{
- while (true)
- {
- // TODO: Get NPC hook position
- /*A3DSkeletonHook* pHook = pNPC->GetSgcHook(szHanger, bChildHook, szHook);
- if (!pHook)
- break;
-
- A3DSkinModel *pSkin = pNPC->GetSgcSkinModel(szHanger, bChildHook, szHook);
- if (bRelHook)
- vPos = pHook->GetAbsoluteTM() * pOffset;
- else
- {
- vPos = pSkin->GetAbsoluteTM() * pOffset;
- vPos = vPos - pSkin->GetAbsoluteTM().GetRow(3) + pHook->GetAbsoluteTM().GetRow(3);
- }
-
- return true;*/
- break;
- }
-
- if (HitPos == GfxHitPos.enumHitBottom)
- {
- vPos = pNPC.GetPosVector3();
- }
- else
- {
- // TODO: Get NPC AABB
- // const A3DAABB& aabb = pNPC->GetPickAABB();
- // vPos = aabb.Center;
- // vPos.y += aabb.Extents.y * .5f;
- vPos = pNPC.GetPosVector3();
- vPos.y += 1.0f; // Default height offset / 默认高度偏移
- }
-
- return true;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: NPC {nID} NOT FOUND or GameObject destroyed in pNPCMan (pNPCMan is {(pNPCMan != null ? "not null" : "NULL")})");
}
}
+ else
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: ID {nID} is NEITHER a player ID nor an NPC ID! This is likely an invalid ID.");
+ }
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] _get_pos_by_id: Returning FALSE for ID {nID}");
return false;
}
@@ -493,7 +646,8 @@ namespace BrewMonster
{
CECPlayer pPlayer = pPlayerMan?.GetPlayer(nId);
- if (pPlayer != null)
+ // Check if player exists AND GameObject is not destroyed (Unity's "fake null" handling)
+ if (pPlayer != null && pPlayer.gameObject != null)
{
// TODO: Get player direction and up
// vDir = pPlayer->GetDir();
@@ -507,7 +661,8 @@ namespace BrewMonster
{
CECNPC pNPC = pNPCMan?.GetNPCFromAll(nId);
- if (pNPC != null)
+ // Check if NPC exists AND GameObject is not destroyed (Unity's "fake null" handling)
+ if (pNPC != null && pNPC.gameObject != null)
{
// TODO: Get NPC direction and up
// vDir = pNPC->GetDir();
@@ -729,6 +884,11 @@ namespace BrewMonster
///
public bool Tick(uint dwDeltaTime)
{
+ if (m_EventLst.Count > 0)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] SkillGfxMan.Tick: Processing {m_EventLst.Count} active event(s), deltaTime={dwDeltaTime}ms");
+ }
+
var node = m_EventLst.First;
while (node != null)
@@ -825,6 +985,11 @@ namespace BrewMonster
/// Add skill GFX event
/// 添加技能特效事件
///
+ ///
+ /// Convenience overload for weapon/melee attacks (no composer).
+ /// Scale is not needed — Unity Particle Systems handle their own scale.
+ /// 武器/近战攻击的便捷重载(无组合器)。不需要缩放 — Unity粒子系统自己处理缩放。
+ ///
public bool AddSkillGfxEvent(
int nHostID,
int nTargetID,
@@ -836,8 +1001,6 @@ namespace BrewMonster
int nFlyGfxCount = 1,
uint dwInterval = 0,
GFX_SKILL_PARAM? param = null,
- float fFlyGfxScale = 1.0f,
- float fHitGfxScale = 1.0f,
uint dwModifier = 0)
{
return m_GfxMan.AddSkillGfxEvent(
@@ -852,8 +1015,6 @@ namespace BrewMonster
nFlyGfxCount,
dwInterval,
param ?? default,
- fFlyGfxScale,
- fHitGfxScale,
dwModifier,
false, // bOnlyOneHit
false, // bFadeOut
diff --git a/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs b/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs
new file mode 100644
index 0000000000..d9849baadc
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs
@@ -0,0 +1,203 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace BrewMonster
+{
+ ///
+ /// Helper class to track and draw gizmos for skill GFX projectiles.
+ /// Draws projectile path from host to target, auto-removes after 10 seconds.
+ /// 用于跟踪和绘制技能GFX弹道辅助线的辅助类。绘制从施法者到目标的弹道路径,10秒后自动移除。
+ ///
+ public static class SkillGfxGizmoDrawer
+ {
+ ///
+ /// Gizmo data for a single projectile
+ /// 单个弹道的辅助线数据
+ ///
+ private class GizmoData
+ {
+ public Vector3 startPos; // Host position / 施法者位置
+ public Vector3 currentPos; // Current projectile position / 当前弹道位置
+ public Vector3 targetPos; // Target position / 目标位置
+ public float createTime; // Time when created / 创建时间
+ public long hostID; // Host ID for identification / 施法者ID
+ public long targetID; // Target ID for identification / 目标ID
+ public GfxMoveMode moveMode; // Movement mode / 移动模式
+ }
+
+ private static readonly List m_GizmoList = new List();
+ private const float GIZMO_LIFETIME = 10.0f; // 10 seconds / 10秒
+
+ ///
+ /// Register a projectile for gizmo drawing
+ /// 注册一个弹道用于辅助线绘制
+ ///
+ public static void RegisterProjectile(long hostID, long targetID, Vector3 startPos, Vector3 targetPos, GfxMoveMode moveMode)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Gizmo: Registering projectile host={hostID}, target={targetID}, start={startPos}, target={targetPos}, mode={moveMode}");
+
+ var gizmo = new GizmoData
+ {
+ startPos = startPos,
+ currentPos = startPos,
+ targetPos = targetPos,
+ createTime = Time.time,
+ hostID = hostID,
+ targetID = targetID,
+ moveMode = moveMode
+ };
+ m_GizmoList.Add(gizmo);
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] Gizmo: Registered! Total gizmos={m_GizmoList.Count}");
+ }
+
+ ///
+ /// Update projectile position
+ /// 更新弹道位置
+ ///
+ public static void UpdateProjectile(long hostID, long targetID, Vector3 currentPos, Vector3 targetPos)
+ {
+ for (int i = m_GizmoList.Count - 1; i >= 0; i--)
+ {
+ var gizmo = m_GizmoList[i];
+ if (gizmo.hostID == hostID && gizmo.targetID == targetID)
+ {
+ gizmo.currentPos = currentPos;
+ gizmo.targetPos = targetPos; // Update target in case it moves
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Remove projectile gizmo
+ /// 移除弹道辅助线
+ ///
+ public static void RemoveProjectile(long hostID, long targetID)
+ {
+ for (int i = m_GizmoList.Count - 1; i >= 0; i--)
+ {
+ var gizmo = m_GizmoList[i];
+ if (gizmo.hostID == hostID && gizmo.targetID == targetID)
+ {
+ m_GizmoList.RemoveAt(i);
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Draw all gizmos (called from OnDrawGizmos)
+ /// 绘制所有辅助线(从OnDrawGizmos调用)
+ ///
+ public static void DrawGizmos()
+ {
+ if (m_GizmoList.Count == 0)
+ return; // No gizmos to draw / 没有辅助线要绘制
+
+ float currentTime = Time.time;
+
+ // Remove expired gizmos and draw active ones
+ // 移除过期的辅助线并绘制活动的
+ for (int i = m_GizmoList.Count - 1; i >= 0; i--)
+ {
+ var gizmo = m_GizmoList[i];
+ float age = currentTime - gizmo.createTime;
+
+ // Remove if expired
+ // 如果过期则移除
+ if (age > GIZMO_LIFETIME)
+ {
+ m_GizmoList.RemoveAt(i);
+ continue;
+ }
+
+ // Calculate fade alpha (fade out in last 2 seconds)
+ // 计算淡出透明度(最后2秒淡出)
+ float alpha = age > (GIZMO_LIFETIME - 2.0f)
+ ? 1.0f - ((age - (GIZMO_LIFETIME - 2.0f)) / 2.0f)
+ : 1.0f;
+
+ // Draw projectile path
+ // 绘制弹道路径
+ Gizmos.color = GetColorForMoveMode(gizmo.moveMode, alpha);
+
+ // Draw line from start to current position (trail)
+ // 绘制从起点到当前位置的线(轨迹)
+ if (Vector3.Distance(gizmo.startPos, gizmo.currentPos) > 0.01f)
+ {
+ Gizmos.DrawLine(gizmo.startPos, gizmo.currentPos);
+ }
+
+ // Draw line from current position to target (remaining path)
+ // 绘制从当前位置到目标的线(剩余路径)
+ Gizmos.color = GetColorForMoveMode(gizmo.moveMode, alpha * 0.5f); // Lighter for remaining path
+ if (Vector3.Distance(gizmo.currentPos, gizmo.targetPos) > 0.01f)
+ {
+ Gizmos.DrawLine(gizmo.currentPos, gizmo.targetPos);
+ }
+
+ // Draw sphere at current position (larger for visibility)
+ // 在当前位置绘制球体(更大以便可见)
+ Gizmos.color = GetColorForMoveMode(gizmo.moveMode, alpha);
+ Gizmos.DrawSphere(gizmo.currentPos, 0.5f); // Increased from 0.2f to 0.5f
+
+ // Draw wire sphere at target (larger for visibility)
+ // 在目标位置绘制线框球体(更大以便可见)
+ Gizmos.color = GetColorForMoveMode(gizmo.moveMode, alpha * 0.5f);
+ Gizmos.DrawWireSphere(gizmo.targetPos, 0.5f); // Increased from 0.3f to 0.5f
+
+ // Draw wire sphere at start position
+ // 在起始位置绘制线框球体
+ Gizmos.color = GetColorForMoveMode(gizmo.moveMode, alpha * 0.3f);
+ Gizmos.DrawWireSphere(gizmo.startPos, 0.3f);
+ }
+ }
+
+ ///
+ /// Get color for movement mode
+ /// 根据移动模式获取颜色
+ ///
+ private static Color GetColorForMoveMode(GfxMoveMode mode, float alpha)
+ {
+ Color baseColor;
+ switch (mode)
+ {
+ case GfxMoveMode.enumLinearMove:
+ baseColor = Color.yellow; // Yellow for linear
+ break;
+ case GfxMoveMode.enumOnTarget:
+ baseColor = Color.red; // Red for instant hit
+ break;
+ case GfxMoveMode.enumParabolicMove:
+ baseColor = Color.green; // Green for parabolic
+ break;
+ case GfxMoveMode.enumMissileMove:
+ baseColor = Color.cyan; // Cyan for missile
+ break;
+ default:
+ baseColor = Color.white;
+ break;
+ }
+ baseColor.a = alpha;
+ return baseColor;
+ }
+
+ ///
+ /// Clear all gizmos
+ /// 清除所有辅助线
+ ///
+ public static void ClearAll()
+ {
+ m_GizmoList.Clear();
+ }
+
+ ///
+ /// Get current gizmo count (for debugging)
+ /// 获取当前辅助线数量(用于调试)
+ ///
+ public static int GetGizmoCount()
+ {
+ return m_GizmoList.Count;
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs.meta b/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs.meta
new file mode 100644
index 0000000000..413d51cbfc
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Managers/SkillGfxGizmoDrawer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c17eed6528c112645895f705535a0196
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Skills/skill.cs b/Assets/PerfectWorld/Scripts/Skills/skill.cs
index ee08a1332f..6ad1855d9f 100644
--- a/Assets/PerfectWorld/Scripts/Skills/skill.cs
+++ b/Assets/PerfectWorld/Scripts/Skills/skill.cs
@@ -153,7 +153,6 @@ namespace BrewMonster.Scripts.Skills
public const int MIN_LEVEL = 1;
public const int MAX_LEVEL = 10;
- // Base info
public uint id; // Ψһ���ֱ�ʶ // Unique identifier
public int cls; // ְҵ // Class/Profession
public string name; // �������� // Skill name
diff --git a/Assets/PerfectWorld/Scripts/Vfx/A3DSkillGfxComposerMan.cs b/Assets/PerfectWorld/Scripts/Vfx/A3DSkillGfxComposerMan.cs
index 139847efde..4f561e1c04 100644
--- a/Assets/PerfectWorld/Scripts/Vfx/A3DSkillGfxComposerMan.cs
+++ b/Assets/PerfectWorld/Scripts/Vfx/A3DSkillGfxComposerMan.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using System.Threading.Tasks;
+using Cysharp.Threading.Tasks;
using BrewMonster.Network;
using BrewMonster.Scripts;
using BrewMonster.Scripts.Skills;
@@ -20,7 +20,7 @@ namespace BrewMonster
var composer = new A3DSkillGfxComposer();
// WARNING: .Result blocks the main thread - this can cause freezing!
- if (!composer.Load(skillStub, flyGFXPath, hitGrdGFXPath, hitGFXPath).Result)
+ if (composer.Load(skillStub, flyGFXPath, hitGrdGFXPath, hitGFXPath).Status != UniTaskStatus.Succeeded)
{
// failed to load
return false;
@@ -37,7 +37,7 @@ namespace BrewMonster
///
/// Async version of LoadOneComposer that doesn't block the main thread.
///
- public async Task LoadOneComposerAsync(int nSkillID, SkillStub skillStub, string flyGFXPath, string hitGrdGFXPath, string hitGFXPath)
+ public async UniTask LoadOneComposerAsync(int nSkillID, SkillStub skillStub, string flyGFXPath, string hitGrdGFXPath, string hitGFXPath)
{
if (m_ComposerMap.ContainsKey(nSkillID))
return false;
@@ -71,29 +71,41 @@ namespace BrewMonster
m_ComposerMap.Clear();
}
- public void Play(
- int nSkillID,
- int nHostID,
- int nCastTargetID,
- List Targets,
- bool bIsGoblinSkill = false)
+ public void Play(
+ int nSkillID,
+ int nHostID,
+ int nCastTargetID,
+ List Targets,
+ bool bIsGoblinSkill = false)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] ComposerMan.Play: skill={nSkillID}, host={nHostID}, castTarget={nCastTargetID}, targets={(Targets?.Count ?? 0)}, isGoblin={bIsGoblinSkill}");
+
+ if (!m_ComposerMap.TryGetValue(nSkillID, out var composer) || composer == null)
{
- if (!m_ComposerMap.TryGetValue(nSkillID, out var composer) || composer == null)
- return;
-
- // Forward to composer (stubbed for now if Play not implemented)
- try
- {
- // Provide a minimal default fly time estimation behavior when composer has no fly time configured
- // Real effect triggering should be implemented inside composer.Play
- composer.Play(nHostID, nCastTargetID, Targets, bIsGoblinSkill);
- }
- catch (System.MissingMethodException)
- {
- BMLogger.LogWarning("A3DSkillGfxComposer.Play is not implemented yet.");
- }
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] ComposerMan.Play: Composer NOT FOUND for skill {nSkillID} (map has {m_ComposerMap.Count} composers)");
+ return;
}
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] ComposerMan.Play: Composer FOUND, calling composer.Play()");
+
+ // Forward to composer (stubbed for now if Play not implemented)
+ try
+ {
+ // Provide a minimal default fly time estimation behavior when composer has no fly time configured
+ // Real effect triggering should be implemented inside composer.Play
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] ComposerMan.Play: nCastTargetID={nCastTargetID}; Targets.count={Targets.Count}");
+ composer.Play(nHostID, nCastTargetID, Targets, bIsGoblinSkill);
+ }
+ catch (System.MissingMethodException)
+ {
+ BMLogger.LogWarning("A3DSkillGfxComposer.Play is not implemented yet.");
+ }
+ catch (System.Exception ex)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] ComposerMan.Play: Exception in composer.Play() - {ex.Message}");
+ }
+ }
+
public A3DSkillGfxComposer GetSkillGfxComposer(int skill)
{
return m_ComposerMap.TryGetValue(skill, out var composer) ? composer : null;
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs b/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs
new file mode 100644
index 0000000000..20535aa39e
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs
@@ -0,0 +1,84 @@
+using UnityEngine;
+
+namespace BrewMonster
+{
+ ///
+ /// Straight-line projectile movement.
+ /// Mirrors C++ CGfxLinearMove exactly (A3DSkillGfxEvent2.cpp:22-52).
+ /// 直线弹道移动,完全镜像C++ CGfxLinearMove。
+ ///
+ public class CGfxLinearMove : CGfxMoveBase
+ {
+ protected float m_fSpeed;
+ private const float _fly_speed = 20.0f / 1000.0f; // units per ms, same as C++
+
+ public CGfxLinearMove(GfxMoveMode mode) : base(mode) { }
+
+ ///
+ /// Initialize movement from host to target.
+ /// 初始化从施法者到目标的移动。
+ ///
+ public override void StartMove(Vector3 vHost, Vector3 vTarget)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Entry - host={vHost}, target={vTarget}, area={m_bArea}, maxFlyTime={m_dwMaxFlyTime}");
+
+ if (m_bArea)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Area skill - calculating range and random offset");
+ CalcRange((vTarget - vHost).normalized);
+ m_vPos = vHost + GetRandOff();
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Area skill - startPos={m_vPos} (host={vHost} + offset)");
+ }
+ else
+ {
+ m_vPos = vHost;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Non-area skill - startPos={m_vPos} (same as host)");
+ }
+
+ m_vMoveDir = vTarget - m_vPos;
+ float fDist = Normalize(ref m_vMoveDir);
+ float fMax = _fly_speed * m_dwMaxFlyTime;
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Distance={fDist}, maxDistance={fMax} (speed={_fly_speed} * time={m_dwMaxFlyTime}), moveDir={m_vMoveDir}");
+
+ if (fMax >= fDist)
+ {
+ m_fSpeed = _fly_speed;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Using default speed={m_fSpeed} (distance fits in time)");
+ }
+ else
+ {
+ m_fSpeed = fDist / m_dwMaxFlyTime;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Using calculated speed={m_fSpeed} (distance={fDist} / time={m_dwMaxFlyTime})");
+ }
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.StartMove: Complete - pos={m_vPos}, dir={m_vMoveDir}, speed={m_fSpeed}");
+ }
+
+ ///
+ /// Tick movement. Returns true when target is hit.
+ /// 更新移动。当命中目标时返回true。
+ ///
+ public override bool TickMove(uint dwDeltaTime, Vector3 vHostPos, Vector3 vTargetPos)
+ {
+ Vector3 oldPos = m_vPos;
+ Vector3 vFlyDir = vTargetPos - m_vPos;
+ float fDist = Normalize(ref vFlyDir);
+ float fFlyDist = m_fSpeed * dwDeltaTime;
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.TickMove: Entry - oldPos={oldPos}, targetPos={vTargetPos}, deltaTime={dwDeltaTime}, speed={m_fSpeed}, distance={fDist}, flyDist={fFlyDist}");
+
+ if (fFlyDist >= fDist)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.TickMove: Target HIT! (flyDist={fFlyDist} >= distance={fDist})");
+ return true; // target hit / 命中目标
+ }
+
+ m_vPos += vFlyDir * fFlyDist;
+ m_vMoveDir = vFlyDir;
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxLinearMove.TickMove: Moved - newPos={m_vPos}, moveDir={m_vMoveDir}, distanceRemaining={fDist - fFlyDist}");
+ return false;
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs.meta b/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs.meta
new file mode 100644
index 0000000000..554067196b
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e4107992bd89b5b43a8f8d6e944b2b10
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs b/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs
new file mode 100644
index 0000000000..b71e8520ee
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs
@@ -0,0 +1,172 @@
+using UnityEngine;
+
+namespace BrewMonster
+{
+ ///
+ /// Abstract base class for all projectile movement patterns.
+ /// Mirrors C++ CGfxMoveBase exactly.
+ /// 所有弹道移动模式的抽象基类,完全镜像C++ CGfxMoveBase。
+ ///
+ public abstract class CGfxMoveBase
+ {
+ protected GfxMoveMode m_Mode;
+ protected GfxHitPos m_HitPos = GfxHitPos.enumHitCenter;
+ protected Vector3 m_vPos;
+ protected Vector3 m_vMoveDir;
+ protected bool m_bOneOfCluser; // C++ spelling kept / 保持C++拼写
+ protected uint m_dwMaxFlyTime;
+ protected bool m_bReverse;
+ protected bool m_bArea;
+ protected EmitShape m_Shape;
+ protected Vector3 m_vSize;
+ protected Vector3 m_vXRange;
+ protected Vector3 m_vYRange;
+ protected Vector3 m_vZRange;
+ protected float m_fSquare;
+ protected float m_fSquareH;
+
+ protected CGfxMoveBase(GfxMoveMode mode)
+ {
+ m_Mode = mode;
+ m_HitPos = GfxHitPos.enumHitCenter;
+ }
+
+ // Pure virtual (= 0) → abstract — subclasses MUST override
+ // 纯虚函数 → 抽象 — 子类必须重写
+ public abstract void StartMove(Vector3 vHost, Vector3 vTarget);
+ public abstract bool TickMove(uint dwDeltaTime, Vector3 vHostPos, Vector3 vTargetPos);
+
+ // ===== Protected helpers (same as C++ CGfxMoveBase) =====
+ // 受保护的辅助方法(与C++ CGfxMoveBase相同)
+
+ ///
+ /// Normalize vector, return original magnitude. Returns 0 if too small.
+ /// 归一化向量,返回原始长度。如果太小则返回0。
+ ///
+ protected static float Normalize(ref Vector3 v)
+ {
+ float mag = v.magnitude;
+ if (mag < 1e-6f) { v = Vector3.zero; return 0f; }
+ v /= mag;
+ return mag;
+ }
+
+ ///
+ /// Calculate emission range vectors based on movement direction.
+ /// 根据移动方向计算发射范围向量。
+ ///
+ protected void CalcRange(Vector3 vDir)
+ {
+ m_vYRange = Vector3.up;
+ m_vZRange = new Vector3(vDir.x, 0, vDir.z);
+ if (Normalize(ref m_vZRange) < 0.01f) m_vZRange = Vector3.forward;
+ m_vXRange = Vector3.Cross(m_vYRange, m_vZRange);
+ m_vXRange *= m_vSize.x;
+ m_vYRange *= m_vSize.y;
+ m_vZRange *= m_vSize.z;
+ m_fSquare = m_vSize.sqrMagnitude;
+ m_fSquareH = m_vSize.x * m_vSize.x + m_vSize.z * m_vSize.z;
+ }
+
+ ///
+ /// Get random offset based on emission shape.
+ /// 根据发射形状获取随机偏移。
+ ///
+ protected Vector3 GetRandOff()
+ {
+ float x, y, z;
+
+ switch (m_Shape)
+ {
+ case EmitShape.enumBox:
+ x = Random.Range(-1f, 1f);
+ y = Random.Range(-1f, 1f);
+ z = Random.Range(-1f, 1f);
+ break;
+
+ case EmitShape.enumSphere:
+ // Rejection sampling for uniform distribution in sphere
+ // 球体内均匀分布的拒绝采样
+ do
+ {
+ x = Random.Range(-1f, 1f);
+ y = Random.Range(-1f, 1f);
+ z = Random.Range(-1f, 1f);
+ }
+ while (x * x * m_fSquare / (m_vSize.x * m_vSize.x + 1e-6f)
+ + y * y * m_fSquare / (m_vSize.y * m_vSize.y + 1e-6f)
+ + z * z * m_fSquare / (m_vSize.z * m_vSize.z + 1e-6f) > m_fSquare);
+ break;
+
+ case EmitShape.enumCylinder:
+ // Uniform in horizontal circle, uniform in Y
+ // 水平圆内均匀分布,Y轴均匀分布
+ do
+ {
+ x = Random.Range(-1f, 1f);
+ z = Random.Range(-1f, 1f);
+ }
+ while (x * x * m_fSquareH / (m_vSize.x * m_vSize.x + 1e-6f)
+ + z * z * m_fSquareH / (m_vSize.z * m_vSize.z + 1e-6f) > m_fSquareH);
+ y = Random.Range(-1f, 1f);
+ break;
+
+ default:
+ x = y = z = 0;
+ break;
+ }
+
+ return m_vXRange * x + m_vYRange * y + m_vZRange * z;
+ }
+
+ // ===== Public accessors (non-virtual, same as C++) =====
+ // 公共访问器(非虚函数,与C++相同)
+
+ public GfxMoveMode GetMode() { return m_Mode; }
+ public GfxHitPos GetHitPos() { return m_HitPos; }
+ public Vector3 GetPos() { return m_vPos; }
+ public Vector3 GetMoveDir() { return m_vMoveDir; }
+ public bool IsReverse() { return m_bReverse; }
+ public void SetReverse(bool bReverse) { m_bReverse = bReverse; }
+ public void SetIsCluster(bool bCluster) { m_bOneOfCluser = bCluster; }
+ public void SetMaxFlyTime(uint dwTime) { m_dwMaxFlyTime = dwTime; }
+ public void SetRange(Vector3 vSize) { m_vSize = vSize; }
+
+ ///
+ /// Virtual — subclasses can override for custom param handling.
+ /// 虚函数 — 子类可以重写以自定义参数处理。
+ ///
+ public virtual void SetParam(GFX_SKILL_PARAM param)
+ {
+ m_bArea = param.m_bArea;
+ m_Shape = param.m_Shape;
+ m_vSize = new Vector3(param.m_vSize.x, param.m_vSize.y, param.m_vSize.z);
+ }
+
+ // ===== Factory method (same as C++ CGfxMoveBase::CreateMoveMethod) =====
+ // 工厂方法(与C++ CGfxMoveBase::CreateMoveMethod相同)
+
+ ///
+ /// Create movement method by mode. Only Linear and OnTarget implemented for Phase 1.
+ /// 根据模式创建移动方法。第一阶段仅实现Linear和OnTarget。
+ ///
+ public static CGfxMoveBase CreateMoveMethod(GfxMoveMode mode)
+ {
+ switch (mode)
+ {
+ case GfxMoveMode.enumOnTarget: return new CGfxOnTargetMove(mode);
+ case GfxMoveMode.enumLinearMove: return new CGfxLinearMove(mode);
+ // TODO Phase 3: Add remaining movement modes
+ // case GfxMoveMode.enumParabolicMove: return new CGfxParabolicMove(mode);
+ // case GfxMoveMode.enumMissileMove: return new CGfxMissileMove(mode);
+ // case GfxMoveMode.enumMeteoricMove: return new CGfxMeteoricMove(mode);
+ // case GfxMoveMode.enumHelixMove: return new CGfxHelixMove(mode);
+ // case GfxMoveMode.enumCurvedMove: return new CGfxCurvedMove(mode);
+ // case GfxMoveMode.enumAccMove: return new CGfxAccMove(mode);
+ // case GfxMoveMode.enumLink: return new CGfxLinkMove(mode);
+ // case GfxMoveMode.enumRandMove: return new CGfxRandMove(mode);
+ default: return new CGfxLinearMove(mode);
+ }
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs.meta b/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs.meta
new file mode 100644
index 0000000000..67372da788
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5c18584a8969bcb47ba124d270aa8490
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs b/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs
new file mode 100644
index 0000000000..81b212d006
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs
@@ -0,0 +1,80 @@
+using UnityEngine;
+
+namespace BrewMonster
+{
+ ///
+ /// Instant-hit movement (no flight). Used for melee/instant skills.
+ /// Mirrors C++ CGfxOnTargetMove exactly (A3DSkillGfxEvent2.cpp:297-322).
+ /// 瞬间命中移动(无飞行)。用于近战/瞬发技能。
+ ///
+ public class CGfxOnTargetMove : CGfxMoveBase
+ {
+ protected float m_fRadius;
+ protected Vector3 m_vOffset;
+
+ public CGfxOnTargetMove(GfxMoveMode mode) : base(mode)
+ {
+ m_HitPos = GfxHitPos.enumHitBottom;
+ m_fRadius = 0;
+ }
+
+ ///
+ /// Set position to target immediately. Cluster offset adds random radius.
+ /// 立即将位置设置到目标。群集偏移添加随机半径。
+ ///
+ public override void StartMove(Vector3 vHost, Vector3 vTarget)
+ {
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.StartMove: Entry - host={vHost}, target={vTarget}, cluster={m_bOneOfCluser}, radius={m_fRadius}");
+
+ m_vPos = vTarget;
+ m_vMoveDir = vTarget - vHost;
+ m_vMoveDir.y = 0; // C++: zero out Y before normalize
+ if (Normalize(ref m_vMoveDir) == 0)
+ {
+ m_vMoveDir = Vector3.forward; // _unit_z
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.StartMove: MoveDir was zero, using forward");
+ }
+
+ if (m_bOneOfCluser)
+ {
+ float fRandAng = Random.value * Mathf.PI * 2f;
+ float fRadius = Random.value * m_fRadius;
+ m_vOffset.x = Mathf.Cos(fRandAng) * fRadius;
+ m_vOffset.z = Mathf.Sin(fRandAng) * fRadius;
+ m_vOffset.y = 0;
+ m_vPos += m_vOffset;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.StartMove: Cluster offset applied - offset={m_vOffset}, finalPos={m_vPos}");
+ }
+ else
+ {
+ m_vOffset = Vector3.zero;
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.StartMove: No cluster offset, pos={m_vPos}");
+ }
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.StartMove: Complete - pos={m_vPos}, dir={m_vMoveDir}");
+ }
+
+ ///
+ /// Always returns false — hit is triggered by fly time timeout, NOT by TickMove.
+ /// 始终返回false — 命中由飞行时间超时触发,而非TickMove。
+ ///
+ public override bool TickMove(uint dwDeltaTime, Vector3 vHostPos, Vector3 vTargetPos)
+ {
+ Vector3 oldPos = m_vPos;
+ m_vPos = vTargetPos + m_vOffset;
+
+ BMLogger.LogError($"[SKILL_GFX_DEBUG] CGfxOnTargetMove.TickMove: Updated pos from {oldPos} to {m_vPos} (target={vTargetPos}, offset={m_vOffset}), returning false (hit by timeout)");
+ return false; // C++ returns false — hit triggered by fly time timeout
+ }
+
+ ///
+ /// Override to also read radius from param value.
+ /// 重写以从参数值中读取半径。
+ ///
+ public override void SetParam(GFX_SKILL_PARAM param)
+ {
+ base.SetParam(param);
+ m_fRadius = param.value.fVal; // C# union access: param.value.fVal
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs.meta b/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs.meta
new file mode 100644
index 0000000000..031fad7e11
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0257990f0ce2fad439f013b71b86e3f3
\ No newline at end of file
diff --git a/C_SHARP_VS_CPP_COMPARISON.md b/C_SHARP_VS_CPP_COMPARISON.md
new file mode 100644
index 0000000000..e0c65f0948
--- /dev/null
+++ b/C_SHARP_VS_CPP_COMPARISON.md
@@ -0,0 +1,272 @@
+# C# vs C++ Comparison: Why Monster Destroy Error Occurs in C# but Not C++
+
+## Key Differences
+
+### 1. **Memory Management Model**
+
+#### C++ (Raw Pointers)
+```cpp
+// C++: EC_ManNPC.cpp line 774-787
+void CECNPCMan::ReleaseNPC(CECNPC* pNPC)
+{
+ if (pNPC)
+ {
+ pNPC->Release();
+ delete pNPC; // ← Object immediately destroyed, pointer becomes invalid
+ pNPC = NULL;
+ }
+}
+```
+
+**Behavior:**
+- `delete pNPC` immediately destroys the object
+- Pointer becomes **invalid** (dangling pointer)
+- Accessing deleted object = **immediate crash** (predictable)
+- Null check (`if (pNPC)`) is **sufficient** to prevent crashes
+
+#### C# Unity (Managed References)
+```csharp
+// C#: CECNPCMan.cs line 214-226
+void ReleaseNPC(CECNPC pNPC)
+{
+ if (pNPC)
+ {
+ pNPC.Release();
+ pNPC.DestroySelf(); // ← Calls Destroy(gameObject)
+ }
+}
+
+// CECNPC.cs line 578-581
+public void DestroySelf()
+{
+ Destroy(gameObject); // ← Unity marks for destruction, but reference persists
+}
+```
+
+**Behavior:**
+- `Destroy(gameObject)` **marks** GameObject for destruction at end of frame
+- C# reference (`pNPC`) **still exists** and is NOT set to null
+- Unity's `==` operator is **overloaded** - `pNPC == null` returns `true` for destroyed objects
+- But **direct property access** (`pNPC.transform`, `pNPC.GetPosVector3()`) **still throws error**
+- Null check (`if (pNPC != null)`) is **NOT sufficient** - need GameObject check too
+
+### 2. **GetNPCFromAll Implementation**
+
+#### C++ Version
+```cpp
+// C++: EC_ManNPC.cpp line 908-923
+CECNPC* CECNPCMan::GetNPCFromAll(int nid)
+{
+ CECNPC* pNPC = GetNPC(nid);
+ if (pNPC)
+ return pNPC;
+
+ // Search from disappear array - NPCs still exist here!
+ for (int i=0; i < m_aDisappearNPCs.GetSize(); i++)
+ {
+ CECNPC* pNPC = m_aDisappearNPCs[i];
+ if (pNPC->GetNPCID() == nid)
+ return pNPC; // ← Returns NPC even if removed from main table
+ }
+
+ return NULL;
+}
+```
+
+**Key Points:**
+- Searches **disappear array** (`m_aDisappearNPCs`) after main table
+- NPCs in disappear array are **still valid** (not deleted yet)
+- Provides a **grace period** for GFX events to complete
+- Returns `NULL` only when NPC is truly gone
+
+#### C# Version
+```csharp
+// C#: CECNPCMan.cs line 541-564
+public CECNPC GetNPCFromAll(int nid)
+{
+ CECNPC pNPC = GetNPC(nid);
+ if (pNPC != null)
+ return pNPC;
+
+ // Search from disappear array - COMMENTED OUT!
+ /*for (int i = 0; i < m_aDisappearNPCs.GetSize(); i++)
+ {
+ CECNPC* pNPC = m_aDisappearNPCs[i];
+ if (pNPC->GetNPCID() == nid)
+ return pNPC;
+ }*/
+
+ return null; // ← Returns null immediately if not in main table
+}
+```
+
+**Key Points:**
+- **Disappear array search is COMMENTED OUT** (lines 555-561)
+- Returns `null` immediately if NPC not in main table
+- **No grace period** - NPC removed from table = immediately unavailable
+- This is a **porting issue** - the safety mechanism was removed
+
+### 3. **get_pos_by_id Implementation**
+
+#### C++ Version
+```cpp
+// C++: EC_ManSkillGfx.cpp line 89-122
+inline bool _get_pos_by_id(..., int nID, A3DVECTOR3& vPos, ...)
+{
+ if (ISNPCID(nID))
+ {
+ CECNPC* pNPC = pNPCMan->GetNPCFromAll(nID);
+
+ if (pNPC){ // ← Simple null check is sufficient
+ // Access pNPC methods directly
+ vPos = pNPC->GetPos(); // ← Safe: if pNPC is null, we wouldn't be here
+ return true;
+ }
+ }
+ return false;
+}
+```
+
+**Why it works:**
+- `GetNPCFromAll()` may return NPC from disappear array (still valid)
+- If `pNPC` is null, we return false - **no access attempted**
+- If `pNPC` is non-null, it's **guaranteed valid** (C++ pointer semantics)
+- No need to check if object is "destroyed" - either it exists or pointer is null
+
+#### C# Version
+```csharp
+// C#: CECSkillGfxMan.cs line 545-589
+private static bool get_pos_by_id(..., int nID, out Vector3 vPos, ...)
+{
+ if (GPDataTypeHelper.ISNPCID(nID))
+ {
+ CECNPC pNPC = pNPCMan?.GetNPCFromAll(nID);
+
+ if (pNPC != null) // ← This check passes even for destroyed GameObjects!
+ {
+ vPos = pNPC.GetPosVector3(); // ← ERROR: GameObject destroyed!
+ // OR
+ vPos = pNPC.transform.position; // ← ERROR: GameObject destroyed!
+ }
+ }
+ return false;
+}
+```
+
+**Why it fails:**
+- `GetNPCFromAll()` returns `null` immediately (disappear array not checked)
+- But even if it returned a reference, Unity's destroyed objects are "fake null"
+- `pNPC != null` returns `true` even for destroyed GameObjects
+- Need **additional check**: `pNPC.gameObject != null` to detect destroyed objects
+
+### 4. **Object Lifecycle Differences**
+
+#### C++ Lifecycle
+```
+1. NPC created → Pointer stored in m_NPCTab
+2. NPC dies → Moved to m_aDisappearNPCs (still valid)
+3. Disappear timer expires → delete pNPC (immediate destruction)
+4. Pointer becomes invalid → NULL check prevents access
+```
+
+**Timeline:**
+- NPC removed from main table → **Still accessible via disappear array**
+- NPC deleted → **Pointer immediately invalid** (predictable crash if accessed)
+
+#### C# Unity Lifecycle
+```
+1. NPC created → GameObject + Component stored in m_NPCTab
+2. NPC dies → Removed from m_NPCTab immediately
+3. Destroy(gameObject) called → Marked for destruction
+4. End of frame → GameObject destroyed, but C# reference persists
+5. Reference is "fake null" → == null works, but property access throws
+```
+
+**Timeline:**
+- NPC removed from main table → **Immediately unavailable** (disappear array not checked)
+- GameObject destroyed → **Reference persists but is "fake null"**
+- Property access → **Throws "object has been destroyed" error**
+
+### 5. **Null Check Behavior**
+
+#### C++ Null Check
+```cpp
+CECNPC* pNPC = GetNPCFromAll(nid);
+if (pNPC) // ← True only if pointer is valid
+{
+ vPos = pNPC->GetPos(); // ← Safe: pointer guaranteed valid
+}
+```
+
+**Semantics:**
+- `if (pNPC)` checks if pointer is **not NULL**
+- If true, pointer is **guaranteed valid** (C++ doesn't have "fake null")
+- Access is **safe**
+
+#### C# Unity Null Check
+```csharp
+CECNPC pNPC = GetNPCFromAll(nid);
+if (pNPC != null) // ← True even for destroyed GameObjects!
+{
+ vPos = pNPC.GetPosVector3(); // ← ERROR: GameObject may be destroyed
+}
+```
+
+**Semantics:**
+- `if (pNPC != null)` uses Unity's **overloaded == operator**
+- Returns `true` even for **destroyed GameObjects** (Unity's "fake null")
+- Need **additional check**: `pNPC.gameObject != null`
+
+### 6. **Why C++ Doesn't Have This Problem**
+
+1. **Disappear Array Safety Net**
+ - C++ searches `m_aDisappearNPCs` which provides grace period
+ - NPCs remain accessible even after removal from main table
+ - GFX events can complete before NPC is actually deleted
+
+2. **Immediate Destruction**
+ - When `delete` is called, object is **immediately destroyed**
+ - Pointer becomes invalid, null check prevents access
+ - No "fake null" state - either valid or null
+
+3. **Predictable Behavior**
+ - Accessing deleted object = immediate crash (predictable)
+ - Null check is sufficient protection
+ - No need for GameObject existence checks
+
+### 7. **Why C# Has This Problem**
+
+1. **Disappear Array Not Used**
+ - C# version has disappear array search **commented out**
+ - NPCs become unavailable immediately when removed from main table
+ - No grace period for GFX events
+
+2. **Delayed Destruction**
+ - `Destroy(gameObject)` marks for destruction at **end of frame**
+ - C# reference persists in "fake null" state
+ - Property access throws error even though `== null` returns true
+
+3. **Unity's "Fake Null"**
+ - Unity overloads `==` operator to handle destroyed objects
+ - But direct property access doesn't use the overloaded operator
+ - Need explicit GameObject check: `gameObject != null`
+
+## Summary: Root Causes
+
+| Aspect | C++ | C# Unity | Impact |
+|--------|-----|----------|--------|
+| **Disappear Array** | ✅ Searched | ❌ Commented out | C# has no grace period |
+| **Null Check** | ✅ Sufficient | ❌ Not sufficient | C# needs GameObject check |
+| **Destruction** | Immediate (`delete`) | Delayed (`Destroy`) | C# has "fake null" state |
+| **Pointer/Reference** | Raw pointer (invalid when deleted) | Managed reference (persists when destroyed) | C# reference can point to destroyed object |
+
+## The Fix
+
+To match C++ behavior, C# needs:
+
+1. **Re-enable disappear array search** in `GetNPCFromAll()`
+2. **Add GameObject checks** in `get_pos_by_id()`: `pNPC != null && pNPC.gameObject != null`
+3. **Early termination** of GFX events when target is destroyed
+4. **Target validation** before creating GFX events
+
+These changes will restore the safety mechanisms that existed in C++ but were lost during porting.
diff --git a/MONSTER_DESTROY_ERROR_ANALYSIS.md b/MONSTER_DESTROY_ERROR_ANALYSIS.md
new file mode 100644
index 0000000000..067f8fdf11
--- /dev/null
+++ b/MONSTER_DESTROY_ERROR_ANALYSIS.md
@@ -0,0 +1,304 @@
+# Monster Destroy Error Analysis
+
+## Error Message
+```
+[SKILL_GFX_DEBUG] ComposerMan.Play: Exception in composer.Play() - The object of type 'CECMonster' has been destroyed but you are still trying to access it.
+```
+
+## Root Cause
+
+This is a **race condition** between skill casting and monster destruction. Here's the flow:
+
+### The Problem Flow
+
+1. **Skill is Cast** → `CECAttacksMan.AddSkillAttack()` creates a `CECAttackEvent`
+2. **Attack Event Queued** → Event is added to `m_targets` linked list in `CECAttacksMan`
+3. **Monster Destroyed** → Monster dies/disappears, removed from `m_NPCTab`, GameObject destroyed
+4. **Attack Event Fires** → `CECAttackEvent.DoFire()` is called (after delay)
+5. **GFX Event Created** → `A3DSkillGfxComposer.Play()` creates `CECSkillGfxEvent` with target ID
+6. **GFX Event Ticks** → `CECSkillGfxEvent.Tick()` tries to get target position
+7. **ERROR** → `get_pos_by_id()` calls `GetNPCFromAll()` which returns null or destroyed object
+8. **Access Attempt** → Code tries to access `pNPC.GetPosVector3()` or `pNPC.transform` on destroyed GameObject
+
+## Monster Destruction Flow (C#)
+
+### Entry Points for Monster Destruction:
+
+1. **`CECNPCMan.OnMsgNPCDied()`** (line 291)
+ - Receives `MSG_NM_NPCDIED` message
+ - Calls `pNPC.Killed(bDelay)`
+ - May call `NPCDisappear()` later
+
+2. **`CECNPCMan.OnMsgNPCDisappear()`** (line 149)
+ - Receives `MSG_NM_NPCDISAPPEAR` message
+ - Calls `NPCDisappear(nid)`
+
+3. **`CECNPCMan.OnMsgNPCOutOfView()`** (line 90)
+ - Receives `MSG_NM_NPCOUTOFVIEW` message
+ - Calls `NPCLeave(nid)`
+
+4. **`CECNPCMan.OnMsgInvalidObject()`** (line 80)
+ - Receives `MSG_NM_INVALIDOBJECT` message
+ - Calls `NPCLeave(nid)`
+
+### Destruction Process:
+
+```csharp
+// CECNPCMan.NPCDisappear() - line 156
+void NPCDisappear(int nid)
+{
+ CECNPC pNPC = GetNPC(nid);
+ if (pNPC)
+ {
+ pNPC.Disappear();
+ NPCLeave(nid, true, false); // Remove from active table
+ m_aDisappearNPCs.Add(pNPC); // Add to disappear table
+ }
+}
+
+// CECNPCMan.NPCLeave() - line 177
+void NPCLeave(int nid, bool bUpdateMMArray = true, bool bRelease = true)
+{
+ CECNPC pNPC = GetNPC(nid);
+ if (!pNPC) return;
+
+ // Remove from active NPC table
+ m_NPCTab.Remove(nid); // ← Monster removed from dictionary here
+
+ if (bRelease)
+ ReleaseNPC(pNPC); // ← Calls DestroySelf()
+}
+
+// CECNPCMan.ReleaseNPC() - line 214
+void ReleaseNPC(CECNPC pNPC)
+{
+ if (pNPC)
+ {
+ pNPC.Release();
+ pNPC.DestroySelf(); // ← Calls Destroy(gameObject)
+ }
+}
+
+// CECNPC.DestroySelf() - line 578
+public void DestroySelf()
+{
+ Destroy(gameObject); // ← Unity destroys the GameObject
+}
+```
+
+## Skill Casting Flow (C#)
+
+### When Skill is Cast:
+
+```csharp
+// CECAttacksMan.AddSkillAttack() - line 270
+public CECAttackEvent AddSkillAttack(int idHost, int idCastTarget, int idTarget, ...)
+{
+ var newEvent = new CECAttackEvent(...);
+ m_targets.AddLast(newEvent); // ← Event queued with target ID
+ return newEvent;
+}
+
+// CECAttacksMan.Update() - line 142
+private void Update()
+{
+ var node = m_targets.First;
+ while (node != null)
+ {
+ if (!node.Value.m_bFinished)
+ node.Value.Tick(dwDeltaTime); // ← Event ticks every frame
+ node = next;
+ }
+}
+
+// CECAttackEvent.DoFire() - line 845
+bool DoFire()
+{
+ // ... skill logic ...
+ composerMan.Play(m_idSkill, m_idHost, m_idCastTarget, m_targets);
+ // ← Creates GFX event with target IDs
+}
+
+// A3DSkillGfxComposer.Play() - line 630
+public void Play(int nHostID, int nCastTargetID, List targets, ...)
+{
+ // Creates CECSkillGfxEvent for each target
+ AddOneTarget(nCastTargetID, nHostID, szFly, szHit, tar, ...);
+}
+
+// A3DSkillGfxMan.AddOneSkillGfxEvent() - line 117
+public bool AddOneSkillGfxEvent(...)
+{
+ A3DSkillGfxEvent pEvent = SkillGfxMan.InstanceSub.GetEmptyEvent(mode);
+ pEvent.SetTargetID(nTargetID); // ← Stores target ID
+ PushEvent(pEvent); // ← Event added to active list
+}
+
+// CECSkillGfxEvent.Tick() - line 126
+public override void Tick(uint dwDeltaTime)
+{
+ // Updates host and target positions every frame
+ m_bTargetExist = get_pos_by_id(..., (int)m_nTargetID, out m_vTargetPos, ...);
+ // ← Tries to get position of target (monster)
+
+ base.Tick(dwDeltaTime); // ← May call GetTargetCenter()
+}
+
+// CECSkillGfxEvent.GetTargetCenter() - line 80
+public override Vector3 GetTargetCenter()
+{
+ get_pos_by_id(..., (int)m_nTargetID, out vTargetCenter, ...);
+ // ← Tries to access destroyed monster here!
+ return vTargetCenter;
+}
+
+// CECSkillGfxEvent.get_pos_by_id() - line 449
+private static bool get_pos_by_id(..., int nID, out Vector3 vPos, ...)
+{
+ if (GPDataTypeHelper.ISNPCID(nID))
+ {
+ CECNPC pNPC = pNPCMan?.GetNPCFromAll(nID); // ← Returns null if destroyed
+
+ if (pNPC != null)
+ {
+ vPos = pNPC.GetPosVector3(); // ← ERROR: GameObject destroyed!
+ // OR
+ vPos = pNPC.transform.position; // ← ERROR: GameObject destroyed!
+ }
+ }
+}
+```
+
+## Why This Happens in C#
+
+### Unity's Destroy Behavior:
+
+In Unity, when you call `Destroy(gameObject)`:
+1. The GameObject is marked for destruction
+2. At the end of the frame, Unity destroys it
+3. **However**, C# references to the component (`CECNPC`) are NOT set to `null` immediately
+4. The reference still exists, but accessing any property throws: "The object has been destroyed"
+
+### The Race Condition:
+
+```
+Frame N: Skill cast → CECAttackEvent created → Queued (delay: 200ms)
+Frame N+1: Monster dies → Removed from m_NPCTab → GameObject destroyed
+Frame N+2: CECAttackEvent.DoFire() called → Creates CECSkillGfxEvent
+Frame N+3: CECSkillGfxEvent.Tick() → Tries to access destroyed monster → ERROR
+```
+
+### Why C++ Didn't Have This Issue:
+
+In C++, when an object is deleted:
+- The pointer becomes invalid immediately
+- Accessing it causes a crash (but predictable)
+- The code likely had better null checks or the timing was different
+
+In C# Unity:
+- Destroyed objects are "fake null" - `== null` returns true, but the reference isn't actually null
+- Unity's `==` operator is overloaded to handle destroyed objects
+- But direct property access still throws the error
+
+## The Bug in GetNPCFromAll
+
+There's also a **secondary bug** in `CECNPCMan.GetNPCFromAll()`:
+
+```csharp
+// Line 545-546 - BUG!
+CECNPC pNPC = GetNPC(nid);
+BMLogger.LogError($"... GetNPC returned {(pNPC.name)}"); // ← Crashes if pNPC is null!
+if (pNPC != null) // ← Check happens AFTER accessing pNPC.name
+```
+
+This will crash if `pNPC` is null.
+
+## Solutions
+
+### Solution 1: Check for Destroyed Objects (Recommended)
+
+In `CECSkillGfxEvent.get_pos_by_id()`, check if the GameObject is destroyed:
+
+```csharp
+private static bool get_pos_by_id(..., int nID, out Vector3 vPos, ...)
+{
+ if (GPDataTypeHelper.ISNPCID(nID))
+ {
+ CECNPC pNPC = pNPCMan?.GetNPCFromAll(nID);
+
+ // Check if NPC exists AND GameObject is not destroyed
+ if (pNPC != null && pNPC.gameObject != null)
+ {
+ vPos = pNPC.GetPosVector3();
+ return true;
+ }
+ }
+ return false;
+}
+```
+
+### Solution 2: Early Termination of GFX Events
+
+When target is destroyed, mark the GFX event as finished:
+
+```csharp
+// In CECSkillGfxEvent.Tick()
+if (!m_bTargetExist && m_nTargetID != 0)
+{
+ // Target was destroyed, finish the event
+ m_enumState = GfxSkillEventState.enumFinished;
+ return;
+}
+```
+
+### Solution 3: Fix GetNPCFromAll Bug
+
+```csharp
+public CECNPC GetNPCFromAll(int nid)
+{
+ CECNPC pNPC = GetNPC(nid);
+
+ // Check null BEFORE accessing properties
+ if (pNPC != null)
+ {
+ BMLogger.LogError($"... GetNPC returned {pNPC.name}");
+ return pNPC;
+ }
+
+ BMLogger.LogError($"... NPC {nid} NOT FOUND");
+ return null;
+}
+```
+
+### Solution 4: Validate Targets Before Creating GFX Events
+
+In `A3DSkillGfxComposer.Play()`, validate targets exist before creating events:
+
+```csharp
+public void Play(int nHostID, int nCastTargetID, List targets, ...)
+{
+ // Validate targets exist before creating GFX events
+ if (targets != null)
+ {
+ for (int i = targets.Count - 1; i >= 0; i--)
+ {
+ var tar = targets[i];
+ if (!ValidateTargetExists(tar.idTarget))
+ {
+ targets.RemoveAt(i); // Remove invalid target
+ }
+ }
+ }
+ // ... rest of Play()
+}
+```
+
+## Recommended Fix
+
+Apply **all four solutions**:
+1. Fix the `GetNPCFromAll` null check bug
+2. Add destroyed object checks in `get_pos_by_id`
+3. Early terminate GFX events when target is destroyed
+4. Validate targets before creating GFX events
+
+This provides defense in depth and handles the race condition at multiple levels.
diff --git a/SKILL_GFX_CONVERSION_PLAN.md b/SKILL_GFX_CONVERSION_PLAN.md
index d438200bfb..d5f3fe8f72 100644
--- a/SKILL_GFX_CONVERSION_PLAN.md
+++ b/SKILL_GFX_CONVERSION_PLAN.md
@@ -5,8 +5,8 @@
This document provides a comprehensive plan to convert the Perfect World skill GFX (graphical effects) system from C++ to Unity C#. The system handles visual effects when players cast skills, including projectile flight, hit effects, and various movement patterns.
**Date Created:** 2026-02-11
-**Last Updated:** 2026-02-12
-**Status:** Implementation Phase
+**Last Updated:** 2026-02-24
+**Status:** Phase 1 Complete — Movement system created, scale removed (Particle Systems)
**Complexity:** Medium - Core structure exists, needs wiring up
---
@@ -50,11 +50,11 @@ A3DSkillGfxComposer.Play() - Iterates targets, calls AddOneTarget ✅
↓
A3DSkillGfxMan.AddSkillGfxEvent() - Creates GFX events with clustering ✅
↓
-A3DSkillGfxEvent.Tick() - State machine (Wait → Flying → Hit → Finished) ⚠️ COMMENTED OUT
+A3DSkillGfxEvent.Tick() - State machine (Wait → Flying → Hit → Finished) ✅ WORKING
↓
-CGfxMoveBase - Movement calculation ❌ NOT CREATED
+CGfxMoveBase - Movement calculation ✅ CREATED (Linear + OnTarget)
↓
-GFX Spawning (fly/hit) - Instantiate prefabs ❌ NOT IMPLEMENTED
+GFX Spawning (fly/hit) - Instantiate prefabs ✅ IMPLEMENTED (CECSkillGfxEvent)
```
### 1.3 Key Components
@@ -65,11 +65,11 @@ GFX Spawning (fly/hit) - Instantiate prefabs ❌ NOT IMPLEMENTED
| `CECAttackEvent` | `CECAttacksMan.cs` | ✅ Working (full DoFire with Player/NPC/weapon branches) |
| `A3DSkillGfxComposerMan` | `A3DSkillGfxComposerMan.cs` | ✅ Complete (load, play, lookup) |
| `A3DSkillGfxComposer` | `CECAttacksMan.cs` (partial) + `CECSkillGfxMan.cs` (partial) | ✅ Mostly complete (Load from SkillStub, Play, AddOneTarget) |
-| `A3DSkillGfxMan` | `A3DSkillGfxMan.cs` | ⚠️ Structure exists but Tick() logic commented out |
-| `A3DSkillGfxEvent` | `A3DSkillGfxMan.cs` | ⚠️ State machine structure exists, core logic commented out |
-| `CECSkillGfxEvent` | `CECSkillGfxMan.cs` | ⚠️ Helpers done, Tick/HitTarget commented out |
+| `A3DSkillGfxMan` | `A3DSkillGfxMan.cs` | ✅ Working (AddSkillGfxEvent, clustering, scale removed) |
+| `A3DSkillGfxEvent` | `A3DSkillGfxMan.cs` | ✅ State machine working (Wait→Flying→Hit→Finished) |
+| `CECSkillGfxEvent` | `CECSkillGfxMan.cs` | ✅ Working (Tick, SpawnFlyGfx, SpawnHitGfx, HitTarget) |
| `SkillGfxMan` | `CECSkillGfxMan.cs` | ✅ Event pool, Tick loop, GetEmptyEvent |
-| `CGfxMoveBase` (Movement) | N/A | ❌ Not created |
+| `CGfxMoveBase` (Movement) | `CGfxMoveBase.cs` | ✅ Created (Linear + OnTarget, factory method) |
| `CECMultiSectionSkillMan` | `CECAttacksMan.cs` | ⚠️ Structure done, LoadConfig not implemented |
| `SkillStub` | `skill.cs` | ✅ All GFX params stored per-skill |
| `BaseVfxObject` | `BaseVfxObject.cs` | ✅ Full VFX lifecycle |
@@ -536,9 +536,9 @@ public class CGfxOnTargetMove : CGfxMoveBase
string szHit = hitGFX != null ? hitGfxName : null;
```
-2. Add `m_fFlyGfxScale` and `m_fHitGfxScale` fields (default 1.0f)
+2. ~~Add `m_fFlyGfxScale` and `m_fHitGfxScale` fields~~ **REMOVED** — Unity Particle Systems handle their own scale (see `agent-skills/11-gfx-to-particle-system.md`)
-3. Un-comment scale calculation in `AddOneTarget()`
+3. ~~Un-comment scale calculation in `AddOneTarget()`~~ **REMOVED** — not needed for Particle Systems
4. Make GFX prefab references accessible to events:
```csharp
@@ -952,8 +952,16 @@ BMLogger.Log($"[GFX_FLOW] HitTarget at {vTarget}");
**End of Document**
-This updated plan reflects the actual codebase state as of 2026-02-12. The core structure is ~60% done. The main work remaining is:
-1. **Create** the movement system (new files)
-2. **Un-comment** the state machine and GFX logic
-3. **Fix** the GFX string/scale issues
-4. **Remove** the SpawnGFX temp hack
+This plan was last updated 2026-02-24. **Phase 1 is now complete (~95%)**:
+1. ✅ Movement system created (`CGfxMoveBase`, `CGfxLinearMove`, `CGfxOnTargetMove`)
+2. ✅ State machine working (Wait→Flying→Hit→Finished)
+3. ✅ GFX spawning via `CECSkillGfxEvent` (SpawnFlyGfx, SpawnHitGfx)
+4. ✅ Scale parameters removed — Unity Particle Systems handle their own scale
+5. ✅ SkillGfxMan.Tick() wired into Update loop
+6. ✅ NPC skill GFX path wired up
+
+**Remaining Phase 2+ work:**
+- Additional movement modes (Parabolic, Missile, Helix, etc.)
+- Hook/bone attachment for GFX positions
+- Multi-section skill LoadConfig
+- Optimization (LOD, culling)
diff --git a/agent-skills/11-gfx-to-particle-system.md b/agent-skills/11-gfx-to-particle-system.md
new file mode 100644
index 0000000000..89f8d93593
--- /dev/null
+++ b/agent-skills/11-gfx-to-particle-system.md
@@ -0,0 +1,60 @@
+# GFX to Particle System Conversion Notes
+
+## Key Difference: Scale Handling
+
+### C++ (Original)
+In the original C++ engine, GFX files (`.gfx`) were custom Angelica 3D effect objects (`A3DGFXEx`).
+Scale was applied **programmatically** via code:
+
+```cpp
+// C++ — scale set by code on each GFX instance
+pGfx->SetScale(fFlyGfxScale);
+pGfx->SetActualScale(pHitGfx->GetScale() * 2.0f); // e.g. critical hit 2x
+```
+
+Two scale fields existed per composer:
+- `m_fFlyGfxScale` — fly GFX scale multiplier
+- `m_fHitGfxScale` — hit GFX scale multiplier
+
+These were also used with `m_bRelScl` and `m_fDefTarScl` for relative-to-target scaling:
+```cpp
+if (m_bRelScl)
+ fScale = GetTargetScale(target) / m_fDefTarScl * m_fHitGfxScale;
+else
+ fScale = m_fHitGfxScale;
+```
+
+### Unity (Current)
+In Unity, all `.gfx` files have been **converted to Particle Systems** (prefabs with `ParticleSystem` components).
+
+**Scale is NOT needed in code because:**
+1. Particle Systems define their own size/scale in the prefab inspector
+2. Visual size is controlled by Start Size, Size Over Lifetime, etc. within the particle system
+3. The particle system already looks correct at its authored scale when instantiated
+
+### What Was Removed
+- `m_fFlyGfxScale` and `m_fHitGfxScale` fields from `A3DSkillGfxComposer`
+- `fFlyGfxScale` and `fHitGfxScale` parameters from `AddSkillGfxEvent()` and `AddOneSkillGfxEvent()`
+- Scale calculation in `AddOneTarget()` (the `if (m_bRelScl)` block)
+- `pGfx.SetScale()` calls (were already commented out)
+
+### What Was Kept
+- `m_bRelScl` and `m_fDefTarScl` — kept in `SkillStub` and `A3DSkillGfxComposer` for potential future use
+ (e.g., if we ever want relative scaling on huge bosses)
+
+## Rule for Future Development
+
+> **DO NOT add GFX scale parameters back into the code pipeline.**
+> If scale adjustment is needed for specific skills (e.g., boss skills should be bigger),
+> use Unity's built-in Transform scale on the instantiated prefab, or create a variant prefab
+> with different particle sizes. Do not try to replicate the C++ `SetScale()` pattern.
+
+## GFX Loading Path
+
+```
+C++: A3DGFXEx* pGfx = LoadGfx(pDevice, "gfx/path/to/effect.gfx");
+Unity: GameObject prefab = await Addressables.LoadAssetAsync("gfx/path/to/effect");
+ GameObject instance = Instantiate(prefab, position, rotation);
+```
+
+The Addressable address uses the same path as C++ (minus the `.gfx` extension), prefixed with `gfx/`.
diff --git a/agent-skills/12-prefer-unitask-over-task.md b/agent-skills/12-prefer-unitask-over-task.md
new file mode 100644
index 0000000000..8af0b3a54d
--- /dev/null
+++ b/agent-skills/12-prefer-unitask-over-task.md
@@ -0,0 +1,283 @@
+# Prefer UniTask Over Task for Unity Async Operations
+
+## Why UniTask?
+
+**UniTask** (Cysharp.Threading.Tasks) is a high-performance async/await library designed specifically for Unity. It provides significant advantages over standard .NET `Task`:
+
+### Performance Benefits
+- **Zero allocation** — No GC allocations for async operations (Task allocates ~200 bytes per await)
+- **Unity-optimized** — Built on Unity's PlayerLoop, integrates with Unity lifecycle
+- **Better performance** — Up to 10x faster than Task in Unity
+
+### Unity Integration
+- **Cancellation tokens** — Automatic cancellation when GameObject is destroyed
+- **PlayerLoop integration** — Runs on Unity's main thread by default
+- **Coroutine-like behavior** — Can await Unity operations (WaitForSeconds, etc.)
+- **Better error handling** — More Unity-friendly exception handling
+
+### Codebase Status
+The project currently has **mixed usage**:
+- ✅ `LitModelHolder.cs` — Already uses UniTask
+- ⚠️ `CECAttacksMan.cs` — Uses `System.Threading.Tasks`
+- ⚠️ `A3DSkillGfxComposerMan.cs` — Uses `System.Threading.Tasks`
+- ⚠️ `A3DSkillGfxMan.cs` — Uses `System.Threading.Tasks`
+
+**Goal:** Migrate all async code to UniTask over time.
+
+---
+
+## When to Use UniTask vs Task
+
+### ✅ Use UniTask For:
+- **All Unity async operations** (loading assets, waiting, coroutines)
+- **MonoBehaviour async methods** (Start, Update, etc.)
+- **Addressable loading** (async asset loading)
+- **Any code that runs in Unity's main thread**
+
+### ⚠️ Use Task Only For:
+- **Pure .NET library code** (no Unity dependencies)
+- **Third-party APIs** that return Task (wrap with `ToUniTask()`)
+- **Thread pool operations** (use `UniTask.RunOnThreadPool()` instead)
+
+---
+
+## Migration Patterns
+
+### Pattern 1: Method Signatures
+
+**❌ Old (Task):**
+```csharp
+using System.Threading.Tasks;
+
+public async Task Load(string path)
+{
+ await Task.Delay(100);
+ return true;
+}
+```
+
+**✅ New (UniTask):**
+```csharp
+using Cysharp.Threading.Tasks;
+
+public async UniTask Load(string path)
+{
+ await UniTask.Delay(100);
+ return true;
+}
+```
+
+### Pattern 2: Yield to Keep Unity Responsive
+
+**❌ Old (Task):**
+```csharp
+// CECAttacksMan.cs line 117
+await System.Threading.Tasks.Task.Yield();
+```
+
+**✅ New (UniTask):**
+```csharp
+await UniTask.Yield(); // Yields to Unity's main thread
+```
+
+### Pattern 3: Addressable Loading
+
+**❌ Old (Task):**
+```csharp
+public async Task LoadPrefabAsync(string path)
+{
+ var handle = Addressables.LoadAssetAsync(path);
+ await handle.Task; // Blocks, can cause freezing
+ return handle.Result;
+}
+```
+
+**✅ New (UniTask):**
+```csharp
+public async UniTask LoadPrefabAsync(string path)
+{
+ var handle = Addressables.LoadAssetAsync(path);
+ await handle.ToUniTask(); // Non-blocking, Unity-friendly
+ return handle.Result;
+}
+```
+
+### Pattern 4: Cancellation Tokens
+
+**❌ Old (Task):**
+```csharp
+public async Task LoadAsync(CancellationToken ct)
+{
+ await Task.Delay(1000, ct);
+}
+```
+
+**✅ New (UniTask):**
+```csharp
+public async UniTask LoadAsync(CancellationToken ct = default)
+{
+ // Auto-cancels when GameObject is destroyed if using GetCancellationTokenOnDestroy()
+ await UniTask.Delay(1000, cancellationToken: ct);
+}
+
+// In MonoBehaviour:
+private async void Start()
+{
+ var ct = this.GetCancellationTokenOnDestroy(); // Auto-cancels on destroy
+ await LoadAsync(ct);
+}
+```
+
+### Pattern 5: Converting Task to UniTask
+
+**When calling third-party APIs that return Task:**
+```csharp
+// If you must call a Task-returning method:
+Task result = SomeThirdPartyLibrary.DoSomethingAsync();
+bool value = await result.ToUniTask(); // Convert to UniTask
+```
+
+---
+
+## Codebase-Specific Examples
+
+### Example 1: CECAttacksMan.cs
+
+**Current (line 74-121):**
+```csharp
+public async void LoadAllSkillGfxAsync()
+{
+ // ...
+ await System.Threading.Tasks.Task.Yield(); // ❌ Should be UniTask.Yield()
+}
+```
+
+**Should be:**
+```csharp
+using Cysharp.Threading.Tasks; // Add at top
+
+public async void LoadAllSkillGfxAsync()
+{
+ // ...
+ await UniTask.Yield(); // ✅ Better performance, Unity-integrated
+}
+```
+
+### Example 2: A3DSkillGfxComposerMan.cs
+
+**Current (line 40):**
+```csharp
+public async Task LoadOneComposerAsync(...)
+{
+ if (!await composer.Load(...)) // Task
+ return false;
+}
+```
+
+**Should be:**
+```csharp
+using Cysharp.Threading.Tasks; // Add at top
+
+public async UniTask LoadOneComposerAsync(...)
+{
+ if (!await composer.Load(...)) // UniTask
+ return false;
+}
+```
+
+### Example 3: A3DSkillGfxComposer.Load()
+
+**Current (line 502):**
+```csharp
+public async Task Load(SkillStub skillStub, ...)
+{
+ flyGFX = await AddressableManager.Instance.LoadPrefabAsync("gfx/" + flyGfxName);
+}
+```
+
+**Should be:**
+```csharp
+using Cysharp.Threading.Tasks; // Add at top
+
+public async UniTask Load(SkillStub skillStub, ...)
+{
+ flyGFX = await AddressableManager.Instance.LoadPrefabAsync("gfx/" + flyGfxName);
+ // If LoadPrefabAsync returns Task, convert:
+ // flyGFX = await AddressableManager.Instance.LoadPrefabAsync(...).ToUniTask();
+}
+```
+
+---
+
+## Rules for New Code
+
+1. **Always use UniTask** for new async methods in Unity code
+2. **Add `using Cysharp.Threading.Tasks;`** at the top of files with async code
+3. **Use `UniTask.Yield()`** instead of `Task.Yield()` to keep Unity responsive
+4. **Use `UniTask.Delay()`** instead of `Task.Delay()` for delays
+5. **Use `GetCancellationTokenOnDestroy()`** in MonoBehaviour for auto-cancellation
+6. **Convert Task to UniTask** when calling third-party APIs: `.ToUniTask()`
+
+---
+
+## Migration Priority
+
+When refactoring existing code:
+
+1. **High Priority** — Methods called frequently (Update loops, loading)
+2. **Medium Priority** — One-time initialization methods
+3. **Low Priority** — Rarely-called utility methods
+
+**Don't break working code** — migrate incrementally, test after each change.
+
+---
+
+## Common Mistakes to Avoid
+
+### ❌ Don't Mix Task and UniTask
+```csharp
+// BAD: Mixing Task and UniTask
+public async Task Load()
+{
+ await UniTask.Delay(100); // ❌ Task method using UniTask
+ return true;
+}
+```
+
+### ❌ Don't Use Task.Yield() in Unity
+```csharp
+// BAD: Task.Yield() doesn't integrate with Unity's main thread
+await Task.Yield(); // ❌ May not yield to Unity properly
+```
+
+### ✅ Do Use UniTask Consistently
+```csharp
+// GOOD: Consistent UniTask usage
+public async UniTask Load()
+{
+ await UniTask.Delay(100); // ✅ Proper Unity integration
+ return true;
+}
+```
+
+---
+
+## References
+
+- **UniTask GitHub:** https://github.com/Cysharp/UniTask
+- **UniTask Documentation:** https://github.com/Cysharp/UniTask#readme
+- **Performance Comparison:** UniTask is 10x faster than Task in Unity benchmarks
+
+---
+
+## Quick Reference
+
+| Task API | UniTask Equivalent |
+|----------|-------------------|
+| `Task` | `UniTask` |
+| `Task` | `UniTask` |
+| `Task.Yield()` | `UniTask.Yield()` |
+| `Task.Delay(ms)` | `UniTask.Delay(ms)` |
+| `Task.Run(action)` | `UniTask.RunOnThreadPool(action)` |
+| `CancellationToken` | `CancellationToken` (same, but use `GetCancellationTokenOnDestroy()`) |
+| `task.ToUniTask()` | Convert Task → UniTask |
diff --git a/agent-skills/README.md b/agent-skills/README.md
index f893d1296f..2892c2a948 100644
--- a/agent-skills/README.md
+++ b/agent-skills/README.md
@@ -21,6 +21,8 @@ This folder contains comprehensive skills and guidelines for AI agents working o
### Reference
9. **[C++ Source Reference](./09-cpp-source-reference.md)** - Key C++ files and their locations
10. **[Unity C# Reference](./10-unity-csharp-reference.md)** - Key Unity C# files and their locations
+11. **[GFX to Particle System](./11-gfx-to-particle-system.md)** - GFX→ParticleSystem conversion, why scale is not needed
+12. **[Prefer UniTask Over Task](./12-prefer-unitask-over-task.md)** - Use UniTask for all Unity async operations
## Quick Start
@@ -29,6 +31,7 @@ When starting a conversion task:
2. Check [Type Mappings](./02-type-mappings.md) for type conversions
3. Review [Common Pitfalls](./07-common-pitfalls.md) before coding
4. Use [Testing & Validation](./06-testing-validation.md) to verify your work
+5. For async code: Use [UniTask Over Task](./12-prefer-unitask-over-task.md) - Prefer UniTask for Unity async operations
## Priority Rules