37 KiB
Skill GFX System Conversion Plan (C++ to Unity C#)
Executive Summary
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-04-08
Status: Phase 1 Complete — Movement system, state machine, per-event GFX flags all working
Complexity: Medium - Core structure exists, needs wiring up
Table of Contents
- System Overview
- Current State Analysis
- Architecture Comparison
- Implementation Phases
- File-by-File Status
- Key Technical Challenges
- Testing Strategy
1. System Overview
1.1 What is the Skill GFX System?
The Skill GFX system manages all visual effects when skills are cast in Perfect World:
- Fly GFX: Projectile/missile effects traveling from caster to target
- Hit GFX: Impact effects when skill hits the target
- Hit Ground GFX: Ground impact effects for area skills
1.2 Flow Diagram
Player Casts Skill
↓
CECAttacksMan.AddSkillAttack() - Creates attack event ✅
↓
CECAttackEvent.Tick() - Timing system ✅
↓
CECAttackEvent.DoFire() - Triggers when skill fires ✅
↓
A3DSkillGfxComposerMan.Play() - Finds composer by skill ID ✅
↓
A3DSkillGfxComposer.Play() - Iterates targets, calls AddOneTarget ✅
↓
A3DSkillGfxMan.AddSkillGfxEvent() - Creates GFX events with clustering ✅
↓
A3DSkillGfxEvent.Tick() - State machine (Wait → Flying → Hit → Finished) ✅ WORKING
↓
CGfxMoveBase - Movement calculation ✅ CREATED (Linear + OnTarget)
↓
GFX Spawning (fly/hit) - Instantiate prefabs ✅ IMPLEMENTED (CECSkillGfxEvent)
1.3 Key Components
| Component | File | Status |
|---|---|---|
CECAttacksMan |
CECAttacksMan.cs |
✅ Working (attack events, timing, DoFire, DoDamage) |
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 |
✅ Working (AddSkillGfxEvent, clustering, scale removed, per-event fly/hit flags) |
A3DSkillGfxEvent |
A3DSkillGfxMan.cs |
✅ State machine working (Wait→Flying→Hit→Finished), m_bShowFlyGfx/m_bShowHitGfx flags |
CECSkillGfxEvent |
CECSkillGfxMan.cs |
✅ Working (Tick, SpawnFlyGfx, SpawnHitGfx, HitTarget — respects per-event show flags) |
SkillGfxMan |
CECSkillGfxMan.cs |
✅ Event pool, Tick loop, GetEmptyEvent |
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 |
CECGFXCaster |
CECGFXCaster.cs |
✅ Async GFX loading |
2. Current State Analysis
2.1 What's Fully Working ✅
-
Attack Event Pipeline (
CECAttacksMan.cs)AddSkillAttack()/AddMeleeAttack()- creates eventsTick()- timing system (timeToBeFired → DoFire → timeToDoDamage → DoDamage)DoDamage()- applies damage to NPC/Player targetsSignal()/FindAttackByAttacker()- event signaling
-
DoFire() Implementation (
CECAttackEvent.DoFire())- Player skill branch: calls
composerMan.Play()→ GFX pipeline ✅ - Player weapon branch: loads weapon/projectile essence, spawns hit GFX via
ShowVfx()✅ - Player fist branch: uses default fist hit GFX ✅
- NPC skill branch (multi-section): calls
pMan.Play()✅ - NPC melee branch: loads monster/pet essence GFX (stub) ⚠️
- Fly time estimation based on distance ✅
- Player skill branch: calls
-
GFX Composer Loading (
A3DSkillGfxComposerMan.cs)LoadOneComposerAsync()- async loading with Addressables ✅- Parameters read from
SkillStub(MoveMode, TargetMode, FlyTime, clusters, etc.) ✅ - Placeholder fallback
Resources.Load("GFX/PlaceHolder")for missing fly GFX ✅
-
Composer Play Pipeline (
A3DSkillGfxComposer.Play())- Iterates targets, calls
AddOneTarget()✅ AddOneTarget()determines host/target based onTargetMode✅- Calls
m_pSkillGfxMan.AddSkillGfxEvent()with all parameters ✅
- Iterates targets, calls
-
Event Manager (
A3DSkillGfxMan.AddSkillGfxEvent())- Clustering support (multiple fly GFX per skill) ✅
AddOneSkillGfxEvent()sets up event properties ✅- Pushes event to
SkillGfxMan.m_EventLst✅
-
Event Pool (
SkillGfxMan)- Pre-allocated event pool per
GfxMoveMode✅ GetEmptyEvent()/Resume()reuse ✅Tick()loop iterates events, recycles finished ones ✅
- Pre-allocated event pool per
-
Position Helpers (
CECSkillGfxEvent)_get_pos_by_id()- Player/NPC position with hook stubs ✅_get_dir_and_up_by_id()✅_get_scale_by_id()✅_get_ecm_property_by_id()✅GetTargetCenter()with composer HitPos ✅
-
Data Layer (
SkillStub)- All GFX parameters per skill: MoveMode, TargetMode, FlyTime, clusters, etc. ✅
- GFX paths:
m_szFlyGfxPath,m_szHitGfxPath,m_szHitGrndGfxPath✅ - Example: Skill1Stub (虎击) has
m_MoveMode = enumOnTarget,m_dwFlyTime = 0✅
-
VFX System (
BaseVfxObject.cs,CECGFXCaster.cs)- ParticleSystem lifecycle management ✅
- Scale, position, rotation control ✅
- Lifetime tracking and auto-stop ✅
- Async prefab loading via Addressables ✅
2.2 What's Partially Done ⚠️ (Structure Exists, Logic Commented Out)
-
A3DSkillGfxEvent.Tick() State Machine (
A3DSkillGfxMan.cs:389-468)- State enum exists:
enumWait,enumFlying,enumHit,enumFinished✅ - State transitions coded but all movement and GFX logic commented out:
- Wait→Flying:
m_pMoveMethod.StartMove()commented out (line 434) - Flying:
m_pMoveMethod.TickMove()commented out (line 454) - Hit:
m_pHitGfxlifecycle commented out (lines 396-414) - Fly GFX
SetParentTM/TickAnimationcommented out (lines 437-448, 456-467)
- Wait→Flying:
- State enum exists:
-
CECSkillGfxEvent.Tick() (
CECSkillGfxMan.cs:123-198)- Position update logic fully written but commented out (lines 125-195)
- Currently just calls
base.Tick(dwDeltaTime)(line 197)
-
CECSkillGfxEvent.HitTarget() (
CECSkillGfxMan.cs:204-240)- Calls
base.HitTarget()✅ - Special hit effects (rune, magic rune) commented out (lines 210-239)
- Calls
-
A3DSkillGfxComposer.Play() GFX Strings (
CECAttacksMan.cs:565-610)- Currently passes
""(empty string) forszFlyandszHitinstead of actual GFX paths - Optimization check (
CECOptimize.CanShowFly/CanShowHit) commented out
- Currently passes
-
A3DSkillGfxComposer.AddOneTarget() Scale (
CECAttacksMan.cs:611-686)- Scale calculation commented out (lines 644-647)
m_fFlyGfxScaleandfScalehardcoded to0(lines 677-678)
-
SpawnGFX() Temp Hack (
CECAttacksMan.cs:540-551)- Currently just instantiates flyGFX at target position as child (wrong behavior)
- Should not exist - GFX spawning belongs in the event state machine
-
NPC Skill GFX (
CECAttacksMan.cs:980-986)- Regular NPC skill composerMan.Play() call is commented out
2.3 What's Completely Missing ❌
-
Movement System (CGfxMoveBase)
- No
CGfxMoveBaseabstract class - No movement implementations (Linear, Parabolic, Missile, etc.)
m_pMoveMethodreferenced in comments but never created- All movement-related calls are commented out
- No
-
GFX Instance Management in Events
- No
GameObject m_flyGfxInstance/m_hitGfxInstancefields - No fly GFX instantiation during Wait→Flying transition
- No fly GFX position/rotation updates during Flying state
- No hit GFX instantiation on HitTarget()
- No GFX cleanup on event finish
- No
-
Multi-Section Skill Config Loading (
CECMultiSectionSkillMan.LoadConfig())- Returns
falsewith TODO comment - Format documented but not parsed
- Returns
-
Hook System Implementation
_get_pos_by_id()has stubs for skeleton hooks- No actual bone lookups (TODOs only)
-
Scale Fields in
A3DSkillGfxComposerm_fFlyGfxScaleandm_fHitGfxScaleare not defined- Referenced in
AddOneTarget()as hardcoded0
3. Architecture Comparison
3.1 Current C# Architecture
CECAttacksMan (MonoSingleton, Update → Tick)
├── LinkedList<CECAttackEvent> ✅ Working
│ ├── DoFire() → Player skill / weapon / fist ✅ Working
│ ├── DoFire() → NPC skill / melee ⚠️ Partially
│ └── DoDamage() ✅ Working
│
├── A3DSkillGfxComposerMan ✅ Working
│ ├── LoadOneComposerAsync() ✅ Working
│ ├── Play() → composer.Play() ✅ Working
│ └── Dictionary<int, A3DSkillGfxComposer> ✅ Working
│ ├── Load(SkillStub, paths) ✅ Working
│ ├── Play() → AddOneTarget() ✅ Working (szFly/szHit empty ⚠️)
│ ├── AddOneTarget() → m_pSkillGfxMan.Add() ✅ Working (scale = 0 ⚠️)
│ └── SpawnGFX() temp hack ⚠️ Wrong behavior
│
├── A3DSkillGfxMan (singleton) ✅ Structure
│ ├── AddSkillGfxEvent() ✅ Working
│ └── AddOneSkillGfxEvent() ✅ Working
│
├── SkillGfxMan extends A3DSkillGfxMan ✅ Working
│ ├── Event pool (FreeLst[]) ✅ Working
│ ├── m_EventLst ✅ Working
│ ├── Tick() loop ✅ Working
│ └── GetEmptyEvent() / Resume() ✅ Working
│
├── CECSkillGfxEvent extends A3DSkillGfxEvent ⚠️ Partially
│ ├── Position helpers ✅ Working
│ ├── GetTargetCenter() ✅ Working
│ ├── Tick() override ⚠️ Commented out
│ └── HitTarget() override ⚠️ Commented out
│
├── A3DSkillGfxEvent (base class) ⚠️ Partially
│ ├── State machine skeleton ✅ Exists
│ ├── Movement (m_pMoveMethod) ❌ Not created
│ ├── Fly GFX instance ❌ Not created
│ ├── Hit GFX instance ❌ Not created
│ └── Tick() state machine logic ⚠️ Commented out
│
└── CECMultiSectionSkillMan ⚠️ Structure only
├── Play() / GetSkillGfxComposer() ✅ Working
└── LoadConfig() ❌ Not implemented
3.2 What Needs to Change
CURRENTLY: SHOULD BE:
═══════════════════ ═══════════════════
DoFire() → composerMan.Play() (same) ✅
↓ ↓
composer.Play(szFly="", szHit="") composer.Play(flyGFX, hitGFX objects)
↓ ↓
AddOneTarget(scale=0) AddOneTarget(scale from SkillStub)
↓ ↓
AddSkillGfxEvent() → push event (same) ✅
↓ ↓
composer.SpawnGFX() [WRONG!] Remove SpawnGFX hack
↓ ↓
event.Tick() → mostly no-op event.Tick() → full state machine:
Wait → StartMove, SpawnFlyGfx
Flying → TickMove, UpdateTransform
Hit → SpawnHitGfx, track target
Finished → cleanup
4. Implementation Phases
Phase 1: MVP - Wire Up Core Pipeline (3-5 days)
Goal: Get the fly GFX → move → hit GFX pipeline working end-to-end with linear movement
Task 1.1: Create Movement System (1 day)
New Files:
Assets/PerfectWorld/Scripts/Vfx/CGfxMoveBase.cs (same name as C++)
// Mirrors C++ CGfxMoveBase exactly:
// - StartMove / TickMove are pure virtual (= 0) → abstract
// - SetParam / SetIsCluster / SetReverse / UpdateGfxParam are virtual with default impl → virtual
// - GetMode / GetPos / GetMoveDir / IsReverse / SetMaxFlyTime are non-virtual → regular methods
public abstract class CGfxMoveBase
{
// Fields (same as C++)
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
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)
protected static float Normalize(ref Vector3 v)
{
float mag = v.magnitude;
if (mag < 1e-6f) { v = Vector3.zero; return 0f; }
v /= mag;
return mag;
}
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;
}
protected Vector3 GetRandOff()
{
// Mirrors C++ GetRandOff() with enumBox/enumSphere/enumCylinder
float x, y, z;
if (m_Shape == EmitShape.enumBox)
return SymRandom() * m_vXRange + SymRandom() * m_vYRange + SymRandom() * m_vZRange;
else if (m_Shape == EmitShape.enumSphere)
{
do { x = SymRandom(); y = SymRandom(); z = SymRandom(); }
while (x*x + y*y + z*z > 1f);
return x * m_vXRange + y * m_vYRange + z * m_vZRange;
}
else if (m_Shape == EmitShape.enumCylinder)
{
y = SymRandom();
do { x = SymRandom(); z = SymRandom(); }
while (x*x + z*z > 1f);
return x * m_vXRange + y * m_vYRange + z * m_vZRange;
}
return Vector3.zero;
}
private static float SymRandom() { return UnityEngine.Random.value * 2f - 1f; }
// Virtual with default implementation — subclasses CAN override, but don't have to
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);
}
public virtual void SetIsCluster(bool bCluster) { m_bOneOfCluser = bCluster; }
public virtual void SetReverse(bool bReverse) { m_bReverse = bReverse; }
public virtual void UpdateGfxParam(Vector3 vHost, Vector3 vTarget) { }
// Non-virtual — same as C++ (no override possible)
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 SetMaxFlyTime(uint dwTime) { m_dwMaxFlyTime = dwTime; if (m_dwMaxFlyTime == 0) m_dwMaxFlyTime = 1; }
public void SetIsArea(bool bArea) { m_bArea = bArea; }
public void SetShape(EmitShape shape) { m_Shape = shape; }
public void SetRange(Vector3 vSize) { m_vSize = vSize; }
// Factory method (same as C++ CGfxMoveBase::CreateMoveMethod)
public static CGfxMoveBase CreateMoveMethod(GfxMoveMode mode)
{
switch (mode)
{
case GfxMoveMode.enumLinearMove: return new CGfxLinearMove(mode);
case GfxMoveMode.enumOnTarget: return new CGfxOnTargetMove(mode);
// Phase 2: add more modes
default: return new CGfxOnTargetMove(mode);
}
}
}
Assets/PerfectWorld/Scripts/Vfx/CGfxLinearMove.cs (C++ ref: A3DSkillGfxEvent2.cpp:22-52)
// Mirrors C++ CGfxLinearMove exactly (A3DSkillGfxEvent2.cpp:22-52)
public class CGfxLinearMove : CGfxMoveBase
{
protected float m_fSpeed;
private const float _fly_speed = 20.0f / 1000.0f; // same as C++
public CGfxLinearMove(GfxMoveMode mode) : base(mode) { }
public override void StartMove(Vector3 vHost, Vector3 vTarget)
{
if (m_bArea)
{
CalcRange((vTarget - vHost).normalized);
m_vPos = vHost + GetRandOff();
}
else
m_vPos = vHost;
m_vMoveDir = vTarget - m_vPos;
float fDist = Normalize(ref m_vMoveDir);
float fMax = _fly_speed * m_dwMaxFlyTime;
if (fMax >= fDist)
m_fSpeed = _fly_speed;
else
m_fSpeed = fDist / m_dwMaxFlyTime;
}
public override bool TickMove(uint dwDeltaTime, Vector3 vHostPos, Vector3 vTargetPos)
{
Vector3 vFlyDir = vTargetPos - m_vPos;
float fDist = Normalize(ref vFlyDir);
float fFlyDist = m_fSpeed * dwDeltaTime;
if (fFlyDist >= fDist) return true; // target hit
m_vPos += vFlyDir * fFlyDist;
m_vMoveDir = vFlyDir;
return false;
}
}
Assets/PerfectWorld/Scripts/Vfx/CGfxOnTargetMove.cs (for instant-hit skills like 虎击)
// 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; }
public override void StartMove(Vector3 vHost, Vector3 vTarget)
{
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
if (m_bOneOfCluser)
{
float fRandAng = UnityEngine.Random.value * Mathf.PI * 2f;
float fRadius = UnityEngine.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;
}
else
m_vOffset = Vector3.zero;
}
public override bool TickMove(uint dwDeltaTime, Vector3 vHostPos, Vector3 vTargetPos)
{
m_vPos = vTargetPos + m_vOffset;
return false; // C++ returns false — hit is triggered by fly time timeout, NOT by TickMove
}
public override void SetParam(GFX_SKILL_PARAM param)
{
base.SetParam(param);
m_fRadius = param.value.fVal; // C# union access: param.value.fVal
}
}
(No separate factory file needed — CGfxMoveBase.CreateMoveMethod() handles creation, same as C++)
Task 1.2: Un-comment & Wire Up Event State Machine (1-2 days)
Changes to A3DSkillGfxMan.cs — A3DSkillGfxEvent class:
-
Add
m_pMoveMethodfield (un-comment):protected CGfxMoveBase m_pMoveMethod; -
In constructor, create movement:
m_pMoveMethod = CGfxMoveBase.CreateMoveMethod(mode); -
Un-comment the state machine in
Tick():- Wait→Flying: call
m_pMoveMethod.SetMaxFlyTime()+StartMove() - Flying: call
m_pMoveMethod.TickMove(), on true →HitTarget(GetTargetCenter()) - Hit: check hit GFX lifetime → Finished
- Wait→Flying: call
-
In
HitTarget(): set state to Hit (already done), spawning handled by derived class
Changes to CECSkillGfxMan.cs — CECSkillGfxEvent class:
-
Add GFX instance fields:
private GameObject m_flyGfxInstance; private GameObject m_hitGfxInstance; -
Un-comment
Tick()position update logic (lines 125-195) -
Override key methods for GFX spawning:
SpawnFlyGfx()— instantiatem_pComposer.flyGFXat move positionUpdateFlyGfxTransform()— update fly GFX position/rotation each tickHitTarget()— destroy fly GFX, instantiate hit GFX
Changes to A3DSkillGfxMan.cs — AddOneSkillGfxEvent():
- Remove the temporary
pComposer.SpawnGFX(nTargetID)call (line 193)
Task 1.3: Fix Composer Play() GFX References (0.5 day)
Changes to CECAttacksMan.cs — A3DSkillGfxComposer.Play():
-
Pass actual GFX names instead of empty strings:
string szFly = flyGFX != null ? flyGfxName : null; string szHit = hitGFX != null ? hitGfxName : null; -
AddREMOVED — Unity Particle Systems handle their own scale (seem_fFlyGfxScaleandm_fHitGfxScalefieldsagent-skills/11-gfx-to-particle-system.md) -
Un-comment scale calculation inREMOVED — not needed for Particle SystemsAddOneTarget() -
Make GFX prefab references accessible to events:
public GameObject GetFlyGFX() => flyGFX; public GameObject GetHitGFX() => hitGFX; public GameObject GetHitGrdGFX() => hitGrdGFX;
Task 1.4: Wire Up NPC Skill GFX (0.5 day)
Changes to CECAttacksMan.cs — DoFire() NPC branch (line 980-986):
- Un-comment the composerMan.Play() call for regular NPC skills
Task 1.5: Testing & Debug Logging (1 day)
- Test with instant-hit skills (虎击, m_MoveMode = enumOnTarget)
- Test with projectile skills (skills with m_dwFlyTime > 0)
- Add flow logging at each state transition
- Verify damage timing matches GFX arrival
Phase 1 Deliverables:
- ✅ Movement system (Linear + OnTarget)
- ✅ Fly GFX spawns at caster, moves toward target
- ✅ Hit GFX spawns when projectile arrives
- ✅ Instant-hit skills work (no fly, immediate hit GFX)
- ✅ Timing synchronized (DoDamage matches GFX hit)
Phase 2: Complete Movement & Visual Polish (1-2 weeks)
Goal: All 10 movement modes, proper GFX lifecycle, special effects
Task 2.1: Implement Remaining Movement Modes (3-5 days)
| Mode | Class | C++ Ref | Complexity | Priority |
|---|---|---|---|---|
| Linear | CGfxLinearMove |
lines 22-52 | ✅ Done in Phase 1 | — |
| OnTarget | CGfxOnTargetMove |
— | ✅ Done in Phase 1 | — |
| Parabolic | CGfxParabolicMove |
lines 54-92 | Medium | High |
| Missile | CGfxMissileMove |
lines 94-142 | High | High |
| Meteoric | CGfxMeteoricMove |
lines 144-188 | Low | Medium |
| Accelerated | CGfxAccMove |
lines 319-370 | Low | Medium |
| Helix | CGfxHelixMove |
lines 190-256 | Medium | Low |
| Curved | CGfxCurvedMove |
lines 258-317 | High | Low |
| Link | CGfxLinkMove |
lines 372-420 | Medium | Low |
| Random | CGfxRandMove |
lines 422-458 | Medium | Low |
Pattern: Each extends CGfxMoveBase, added to CGfxMoveBase.CreateMoveMethod()
Task 2.2: GFX Lifecycle Management (2-3 days)
-
Object Pooling — Replace
Instantiate/Destroywith pool:public class GfxPool : MonoSingleton<GfxPool> { public GameObject Spawn(GameObject prefab, Vector3 pos, Quaternion rot); public void Despawn(GameObject obj, float delay = 0f); } -
Fade-out Support — When
m_bFadeOutis true, fade fly GFX over 1s instead of instant destroy -
Hit GFX Infinite/Loop Detection — Check if ParticleSystem is looping, cap at 5s
-
GFX Scale — Apply
fFlyGfxScale/fHitGfxScaleto spawned instances -
Trace Target — When
m_bTraceTargetis true, update hit GFX position each frame
Task 2.3: Hook System (2-3 days)
Update _get_pos_by_id() in CECSkillGfxMan.cs:
// Replace the TODO skeleton hook stubs:
// 1. Get player/NPC model
// 2. Find bone Transform by hook name
// 3. Apply relative/absolute offset
// 4. Cache Transform lookups for performance
Requires:
CECModel.GetHook(string hookName)— find bone Transform by nameCECModel.GetChildModel(string hangerName)— for weapon/pet sub-models
Task 2.4: Special Hit Effects (1-2 days)
Un-comment in CECSkillGfxEvent.HitTarget():
// Rune effects (physical/magic attack/defense)
if ((m_dwModifier & MOD.MOD_PHYSIC_ATTACK_RUNE) != 0)
CECGameRun.Instance.ShowVfx("程序联入/符石/物攻符石特效", vTarget, null, 1f);
// Critical strike (scale up hit GFX)
if ((m_dwModifier & MOD.MOD_CRITICAL_STRIKE) != 0)
if (m_hitGfxInstance != null)
m_hitGfxInstance.transform.localScale *= 2.0f;
// Nullity (show miss effect instead)
if ((m_dwModifier & MOD.MOD_NULLITY) != 0)
CECGameRun.Instance.ShowVfx("程序联入/击中/无效攻击击中", vTarget, null, 1f);
Task 2.5: Optimization Checks (1 day)
Un-comment in A3DSkillGfxComposer.Play():
if (!CECOptimize.Instance.GetGFX().CanShowFly(nHostID))
szFly = null;
if (!CECOptimize.Instance.GetGFX().CanShowHit(nHostID))
szHit = null;
Implement CECOptimize.GFXOptimize:
- Distance-based culling (don't show GFX for far players)
- Per-player GFX count limit
- Total active GFX cap
Phase 2 Deliverables:
- ✅ All 10 movement modes
- ✅ Object pooling
- ✅ GFX scale and fade-out
- ✅ Hook system (skeleton bone attachment)
- ✅ Special effects (rune, critical, nullity)
- ✅ Distance/count optimization
Phase 3: Advanced Features & Production (1-2 weeks)
Goal: Multi-section skills, goblin skills, full production readiness
Task 3.1: Multi-Section Skill Config Loading (2-3 days)
Implement CECMultiSectionSkillMan.LoadConfig():
- Parse config file format (skill_id, section, suffix, sgc)
- Load or reuse
A3DSkillGfxComposerper section - Wire to
Play()which already works
Task 3.2: Goblin (Pet) Skill Position (1 day)
Update _get_pos_by_id() goblin handling:
if (bIsGoblinSkill)
{
CECGoblin goblin = pPlayer.GetGoblin();
if (goblin != null)
{
vPos = goblin.transform.position;
vPos.y += 0.5f;
return true;
}
}
Task 3.3: Skill State Action System (1-2 days)
// In CECAttacksMan:
private List<SkillStateAction> m_SkillStateActionVec;
public bool LoadSkillStateActionConfig(string szFile) { ... }
public bool GetSkillStateActionName(int skill, int state, out string name1, out string name2) { ... }
Task 3.4: Debug Visualization (1 day)
#if UNITY_EDITOR
public class GfxDebugVisualizer : MonoBehaviour
{
void OnDrawGizmos()
{
// Draw host → target lines
// Draw projectile positions
// Show state labels
}
}
#endif
Task 3.5: NPC Melee GFX Completion (1 day)
Complete the NPC melee branch in DoFire():
- Load monster/pet essence GFX paths
- Call
AddSkillGfxEvent()properly
Task 3.6: Production Testing & Polish (2-3 days)
- Test all skill types across all classes
- Edge cases: target dies mid-flight, caster dies, out of range
- Stress test: 50+ simultaneous skill casts
- Performance profiling (Unity Profiler)
- Memory leak check (object pool verification)
Phase 3 Deliverables:
- ✅ Multi-section skills (combo stages)
- ✅ Goblin/pet skill positioning
- ✅ Skill state actions
- ✅ Debug tools
- ✅ Production-ready performance
5. File-by-File Status
5.1 Files — No Changes Needed ✅
| File | Lines | Notes |
|---|---|---|
A3DSkillGfxComposerMan.cs |
97 | Complete: Load, Play, GetComposer |
BaseVfxObject.cs |
307 | Complete VFX lifecycle |
CECGFXCaster.cs |
89 | Async GFX loading |
skill.cs (SkillStub) |
369 | All GFX params defined |
SkillStubs1/*.cs (514 files) |
— | Per-skill GFX params set |
5.2 Files — Need Edits ⚠️
| File | Lines | What Needs Changing |
|---|---|---|
A3DSkillGfxMan.cs |
516 | Un-comment movement + state machine in Tick(), add CGfxMoveBase field, remove SpawnGFX call |
CECSkillGfxMan.cs |
878 | Un-comment Tick() position updates, implement GFX spawning, un-comment HitTarget() |
CECAttacksMan.cs |
1354 | Fix Play() GFX strings, add scale fields, un-comment NPC skill branch, remove SpawnGFX hack |
5.3 Files — Need Creation ❌
| File | Phase | Purpose |
|---|---|---|
CGfxMoveBase.cs |
Phase 1 | Movement abstract base class (same as C++) |
CGfxLinearMove.cs |
Phase 1 | Straight-line projectile |
CGfxOnTargetMove.cs |
Phase 1 | Instant-hit (no flight) |
| (no separate factory file) | — | Factory is CGfxMoveBase.CreateMoveMethod(), same as C++ |
CGfxParabolicMove.cs |
Phase 2 | Arc trajectory |
CGfxMissileMove.cs |
Phase 2 | Homing missile |
CGfxMeteoricMove.cs |
Phase 2 | Falls from sky |
CGfxAccMove.cs |
Phase 2 | Accelerated flight |
CGfxHelixMove.cs |
Phase 2 | Spiral path |
CGfxCurvedMove.cs |
Phase 2 | Bezier curve |
CGfxLinkMove.cs |
Phase 2 | Lightning chain |
CGfxRandMove.cs |
Phase 2 | Random walk |
GfxPool.cs |
Phase 2 | Object pooling |
6. Key Technical Challenges
6.1 Challenge: Movement Time Units
Issue: C++ uses milliseconds throughout. Unity typically uses seconds.
Solution: Keep milliseconds internally (matching C++ data), convert at boundaries:
// In CECSkillGfxEvent.Tick():
// dwDeltaTime is in milliseconds (from CECAttacksMan.Update: Time.deltaTime * 1000)
// Pass to movement as milliseconds to match C++ math
m_pMoveMethod.TickMove(dwDeltaTime, m_vHostPos, m_vTargetPos);
6.2 Challenge: GFX Prefab vs Path References
Issue: C++ used string paths to load GFX at runtime. Unity has prefab references.
Current State: A3DSkillGfxComposer stores both:
GameObject flyGFX/hitGFX/hitGrdGFX— loaded prefabsstring flyGfxName/hitGfxName/hitGrdGfxName— original paths
Solution: Events should reference the A3DSkillGfxComposer which holds the loaded prefabs. Access via m_pComposer.GetFlyGFX() etc.
6.3 Challenge: SpawnGFX Hack Removal
Issue: A3DSkillGfxComposer.SpawnGFX() (line 540) instantiates fly GFX at target position, which is wrong.
Solution: Remove SpawnGFX call from AddOneSkillGfxEvent(). GFX spawning should happen inside CECSkillGfxEvent.Tick() when transitioning from Wait→Flying state.
6.4 Challenge: GFX String vs Prefab in AddSkillGfxEvent ✅ RESOLVED
Issue: AddSkillGfxEvent receives string szFlyGfx / string szHitGfx, but the actual prefab is on the composer. All events shared the same composer, so SpawnFlyGfx() always called m_pComposer.GetFlyGFX() and spawned a fly GFX — even for secondary targets where AddOneTarget() had nulled the string via enumAttArea. This caused wide-area skills to spawn redundant fly GFX on every target instead of only the main target.
Solution (implemented 2026-04-08): Added per-event m_bShowFlyGfx / m_bShowHitGfx boolean flags on A3DSkillGfxEvent. In AddOneSkillGfxEvent(), flags are set based on whether szFlyGfx / szHitGfx are non-null. SpawnFlyGfx() and SpawnHitGfx() in CECSkillGfxEvent early-return when the flag is false. Flags reset to true in Resume() for event pool reuse.
6.5 Challenge: Timing Synchronization
Current Flow:
DoFire():
composerMan.Play() → creates GFX event (starts flying)
m_timeToDoDamage = estimated from distance
event.Tick():
Wait → Flying → Hit (when projectile arrives)
DoDamage():
Applied based on m_timeToDoDamage countdown
Issue: DoDamage timing is independent of GFX arrival. They may not match.
Acceptable for now: The estimation distance / 20.0f * 1000.0f roughly matches the default fly speed. Perfect sync would require the event to notify when it hits, which adds coupling.
7. Testing Strategy
7.1 Phase 1 Testing
Test Skills:
- 虎击 (Skill 1) —
enumOnTarget,m_dwFlyTime = 0→ instant hit, should show hit GFX only - Find a skill with
m_dwFlyTime > 0andenumLinearMove→ should show fly → hit
Verify:
- Fly GFX spawns at caster position
- Fly GFX moves toward target
- Hit GFX spawns at target when projectile arrives
- Fly GFX destroyed on hit
- Damage numbers appear around same time as hit GFX
- No GFX for instant skills without fly/hit paths
Debug Logging Points:
// CECAttackEvent.DoFire()
BMLogger.Log($"[GFX_FLOW] DoFire: skill={m_idSkill}, host={m_idHost}");
// A3DSkillGfxComposer.Play()
BMLogger.Log($"[GFX_FLOW] Composer.Play: targets={targets.Count}, flyGFX={flyGFX != null}");
// A3DSkillGfxEvent.Tick() state changes
BMLogger.Log($"[GFX_FLOW] Event state: {m_enumState} → {newState}");
// CECSkillGfxEvent — GFX spawn/destroy
BMLogger.Log($"[GFX_FLOW] SpawnFlyGfx at {position}");
BMLogger.Log($"[GFX_FLOW] HitTarget at {vTarget}");
7.2 Phase 2 Testing
- Test each movement mode with dedicated test skill
- Verify hook attachments (weapon tip, chest, etc.)
- Performance: 50+ active GFX events at 60 FPS
- Object pool: verify objects are reused, not leaked
7.3 Phase 3 Testing
- Multi-section skills (combo attack with different GFX per section)
- Goblin/pet skills
- Edge cases: target dies mid-flight, caster disconnects
- Stress test: 100+ players in PvP scenario
8. Estimated Timeline
| Phase | Duration | What's Done | What's Left |
|---|---|---|---|
| Phase 1 | 3-5 days | Core structure exists | Create movement, un-comment state machine, fix GFX refs |
| Phase 2 | 1-2 weeks | — | 8 more movement modes, pooling, hooks, effects |
| Phase 3 | 1-2 weeks | Structure exists | Config loading, goblin, polish, testing |
| Total | 3-5 weeks | ~60% structural | ~40% implementation + testing |
9. Quick Reference: What to Un-comment
A3DSkillGfxMan.cs — A3DSkillGfxEvent
| Line(s) | What | Action |
|---|---|---|
| 205-206 | m_pMoveMethod field |
Un-comment, use CGfxMoveBase |
| 251 | m_pMoveMethod = CGfxMoveBase.CreateMoveMethod(mode) |
Un-comment (same name as C++) |
| 336-337 | GetMoveMethod(), GetMode(), GetHitPos() |
Un-comment |
| 343 | SetReverse() |
Un-comment |
| 344-345 | SetParam(), SetIsCluster() |
Un-comment |
| 396-414 | Hit state: hit GFX lifecycle | Un-comment, adapt to Unity GFX |
| 420-421 | Flight timeout → HitTarget | Un-comment |
| 434-448 | Wait→Flying: StartMove + fly GFX start | Un-comment, adapt |
| 454-467 | Flying: TickMove + fly GFX update | Un-comment, adapt |
CECSkillGfxMan.cs — CECSkillGfxEvent
| Line(s) | What | Action |
|---|---|---|
| 37-53 | GetOriginalHost() / GetOriginalTarget() |
Un-comment |
| 125-195 | Tick() position update logic |
Un-comment |
| 210-239 | HitTarget() special effects |
Un-comment (Phase 2) |
CECAttacksMan.cs — A3DSkillGfxComposer
| Line(s) | What | Action |
|---|---|---|
| 540-551 | SpawnGFX() |
DELETE this temp hack |
| 569-583 | Play() szFly/szHit | Fix to use actual GFX names |
| 644-647 | Scale calculation | Un-comment |
| 677-678 | fFlyGfxScale / fScale | Use actual values |
CECAttacksMan.cs — A3DSkillGfxMan.AddOneSkillGfxEvent()
| Line | What | Action |
|---|---|---|
| 150 | SetReverse(bReverse) |
Un-comment |
| 193 | pComposer.SpawnGFX(nTargetID) |
DELETE |
CECAttacksMan.cs — CECAttackEvent.DoFire() NPC branch
| Line(s) | What | Action |
|---|---|---|
| 980-986 | NPC skill composerMan.Play() | Un-comment |
End of Document
This plan was last updated 2026-04-08. Phase 1 is now complete (100%):
- ✅ Movement system created (
CGfxMoveBase,CGfxLinearMove,CGfxOnTargetMove) - ✅ State machine working (Wait→Flying→Hit→Finished)
- ✅ GFX spawning via
CECSkillGfxEvent(SpawnFlyGfx, SpawnHitGfx) - ✅ Scale parameters removed — Unity Particle Systems handle their own scale
- ✅ SkillGfxMan.Tick() wired into Update loop
- ✅ NPC skill GFX path wired up
- ✅ Area skill (
enumAttArea) per-event fly/hit GFX suppression — secondary targets no longer spawn redundant fly GFX
Remaining Phase 2+ work:
- Additional movement modes (Parabolic, Missile, Helix, etc.)
- Hook/bone attachment for GFX positions
- Multi-section skill LoadConfig
- Optimization (LOD, culling)