19 KiB
Skill GFX System - Quick Start Guide
TL;DR - What You Need to Know
Current Status (Updated 2026-02-12)
- ✅ Core pipeline exists — Attack events, timing, DoFire, DoDamage, composer loading all work
- ✅ SkillStub has all GFX params — MoveMode, FlyTime, TargetMode, clusters per skill
- ✅ Composer Play() wired — Iterates targets, calls AddOneTarget → AddSkillGfxEvent
- ⚠️ State machine commented out — Tick() logic exists but is commented out
- ⚠️ GFX strings empty — Play() passes
""instead of actual GFX names - ⚠️ SpawnGFX temp hack — Instantiates flyGFX at target pos (wrong, needs removal)
- ❌ Movement system missing — No CGfxMoveBase, no CGfxLinearMove, etc.
- ❌ GFX spawning missing — No fly/hit GFX instantiation in event Tick()
What's the Skill GFX System?
When a player casts a skill, you see:
- Fly GFX — Projectile traveling from caster to target (e.g., fireball flying)
- Hit GFX — Impact effect when it hits (e.g., explosion)
The Flow (What's Working vs Not)
Player casts skill
↓
CECAttacksMan.AddSkillAttack() ✅ Working
↓
CECAttackEvent.Tick() → timing ✅ Working
↓
CECAttackEvent.DoFire() ✅ Working
↓
A3DSkillGfxComposerMan.Play() ✅ Working
↓
A3DSkillGfxComposer.Play() ⚠️ Passes empty GFX strings
↓
AddOneTarget() → AddSkillGfxEvent() ✅ Working (scale=0 ⚠️)
↓
SpawnGFX() temp hack ⚠️ WRONG - needs removal
↓
A3DSkillGfxEvent.Tick() ⚠️ State machine COMMENTED OUT
↓
CGfxMoveBase.TickMove() ❌ NOT CREATED
↓
Spawn/update fly GFX ❌ NOT IMPLEMENTED
↓
HitTarget() → spawn hit GFX ❌ NOT IMPLEMENTED
↓
CECAttackEvent.DoDamage() ✅ Working
Phase 1 Implementation Guide (3-5 days)
Step 1: Create Movement System (1 day)
Create 3 new files in Assets/PerfectWorld/Scripts/Vfx/:
CGfxMoveBase.cs — Movement base class (same name and pattern as C++)
using UnityEngine;
namespace BrewMonster
{
// Mirrors C++ CGfxMoveBase exactly:
// - StartMove / TickMove → pure virtual (= 0) → abstract
// - SetParam / SetIsCluster / SetReverse → virtual with default impl → virtual
// - GetMode / GetPos / GetMoveDir / IsReverse / SetMaxFlyTime → non-virtual → regular
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
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()
{
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
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++
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.enumOnTarget: return new CGfxOnTargetMove(mode);
case GfxMoveMode.enumLinearMove: return new CGfxLinearMove(mode);
default: return new CGfxLinearMove(mode);
}
}
}
}
CGfxLinearMove.cs — Straight-line projectile
using UnityEngine;
namespace BrewMonster
{
// 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;
}
}
}
CGfxOnTargetMove.cs — Instant hit (no flight)
using UnityEngine;
namespace BrewMonster
{
// 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 triggered by fly time timeout
}
public override void SetParam(GFX_SKILL_PARAM param)
{
base.SetParam(param);
m_fRadius = param.value.fVal; // C# union access: param.value.fVal
}
}
}
Step 2: Wire Up A3DSkillGfxEvent State Machine (1-2 days)
In A3DSkillGfxMan.cs:
-
Add field (un-comment line ~205):
protected CGfxMoveBase m_pMoveMethod; -
In constructor (replace line ~251):
m_pMoveMethod = CGfxMoveBase.CreateMoveMethod(mode); -
Un-comment in Tick():
- Line 420-421: Flight timeout →
HitTarget(GetTargetCenter()) - Line 434-435:
m_pMoveMethod.SetMaxFlyTime()+StartMove() - Line 454:
m_pMoveMethod.TickMove()→HitTarget(GetTargetCenter())
- Line 420-421: Flight timeout →
-
Un-comment delegating setters (lines 336-345) — these delegate to
m_pMoveMethodwhich has the correct default behavior:public CGfxMoveBase GetMoveMethod() { return m_pMoveMethod; } public GfxMoveMode GetMode() { return m_pMoveMethod.GetMode(); } public GfxHitPos GetHitPos() { return m_pMoveMethod.GetHitPos(); } public void SetReverse(bool bReverse) { m_pMoveMethod.SetReverse(bReverse); } public void SetParam(GFX_SKILL_PARAM param) { m_pMoveMethod.SetParam(param); } public void SetIsCluster(bool bCluster) { m_pMoveMethod.SetIsCluster(bCluster); }These work correctly because
CGfxMoveBase.SetIsCluster()/SetReverse()/SetParam()have default implementations that store the values. Subclasses inherit the defaults unless they need to do extra work (likeCGfxMeteoricMove.SetParam()which also stores a radius). -
In AddOneSkillGfxEvent():
- Un-comment line 150:
pEvent.SetReverse(bReverse) - DELETE line 193:
pComposer.SpawnGFX(nTargetID)← temp hack
- Un-comment line 150:
Step 3: Implement GFX Spawning in CECSkillGfxEvent (1-2 days)
In CECSkillGfxMan.cs — CECSkillGfxEvent:
-
Add fields:
private GameObject m_flyGfxInstance; private GameObject m_hitGfxInstance; -
Un-comment Tick() position updates (lines 125-195)
-
Override key behaviors — add to the class after
GetTargetCenter():private void SpawnFlyGfx() { if (m_pComposer == null) return; GameObject prefab = m_pComposer.GetFlyGFX(); if (prefab == null) return; Vector3 pos = m_pMoveMethod.GetPos(); Vector3 dir = m_pMoveMethod.GetMoveDir(); Quaternion rot = dir.magnitude > 1e-4f ? Quaternion.LookRotation(dir) : Quaternion.identity; m_flyGfxInstance = GameObject.Instantiate(prefab, pos, rot); } private void UpdateFlyGfxTransform() { if (m_flyGfxInstance == null) return; m_flyGfxInstance.transform.position = m_pMoveMethod.GetPos(); Vector3 dir = m_pMoveMethod.GetMoveDir(); if (dir.magnitude > 1e-4f) m_flyGfxInstance.transform.rotation = Quaternion.LookRotation(dir); } private void DestroyFlyGfx() { if (m_flyGfxInstance != null) { GameObject.Destroy(m_flyGfxInstance); m_flyGfxInstance = null; } } private void SpawnHitGfx(Vector3 vTarget) { if (m_pComposer == null) return; GameObject prefab = m_pComposer.GetHitGFX(); if (prefab == null) return; Quaternion rot = Quaternion.identity; if (m_bHostExist) { Vector3 dir = vTarget - m_vHostPos; dir.y = 0; if (dir.magnitude > 1e-3f) rot = Quaternion.LookRotation(dir); } m_hitGfxInstance = GameObject.Instantiate(prefab, vTarget, rot); GameObject.Destroy(m_hitGfxInstance, 3.0f); // auto-cleanup } -
Update HitTarget():
protected override void HitTarget(Vector3 vTarget) { base.HitTarget(vTarget); DestroyFlyGfx(); SpawnHitGfx(vTarget); } -
Hook into base Tick() — call SpawnFlyGfx/UpdateFlyGfx from overridden Tick: The base
A3DSkillGfxEvent.Tick()handles state transitions. InCECSkillGfxEvent.Tick(), after the position update andbase.Tick(), check for fly GFX updates:public override void Tick(uint dwDeltaTime) { // [un-commented position update code] GfxSkillEventState prevState = m_enumState; base.Tick(dwDeltaTime); // Spawn fly GFX when entering Flying state if (prevState == GfxSkillEventState.enumWait && m_enumState == GfxSkillEventState.enumFlying) SpawnFlyGfx(); // Update fly GFX transform during Flying if (m_enumState == GfxSkillEventState.enumFlying) UpdateFlyGfxTransform(); }
Step 4: Fix Composer Play() (0.5 day)
In CECAttacksMan.cs — A3DSkillGfxComposer:
-
Add scale fields:
private float m_fFlyGfxScale = 1.0f; private float m_fHitGfxScale = 1.0f; -
Add accessor methods:
public GameObject GetFlyGFX() => flyGFX; public GameObject GetHitGFX() => hitGFX; public GameObject GetHitGrdGFX() => hitGrdGFX; -
Fix Play() method — replace empty strings with actual GFX info:
string szFly = flyGFX != null ? flyGfxName : null; string szHit = hitGFX != null ? hitGfxName : null; -
Delete SpawnGFX() — remove the entire method (lines 540-551)
-
Fix AddOneTarget() scale — un-comment scale calculation, use actual fields
Step 5: Enable NPC Skill GFX (0.5 day)
In CECAttacksMan.cs — DoFire() NPC branch (around line 980):
Un-comment:
var composerMan = m_pManager.GetSkillGfxComposerMan();
if (composerMan != null)
{
composerMan.Play(m_idSkill, m_idHost, m_idCastTarget, m_targets);
pComposer = composerMan.GetSkillGfxComposer(m_idSkill);
}
Testing Your Implementation
Quick Test — Instant Skill (虎击, Skill 1)
Expected behavior:
- Player attacks → DoFire()
- Composer loads:
m_MoveMode = enumOnTarget,m_dwFlyTime = 0 - Movement:
CGfxOnTargetMove.TickMove()→ returnsfalse(stays at target + offset), hit triggered by fly time timeout HitTarget()→ spawns hit GFX at targetm_timeToDoDamage = 1→ damage immediately
What you should see:
- Hit GFX appears at target position
- Damage numbers pop up
- No fly GFX (it's instant)
Quick Test — Projectile Skill
Find a skill with m_dwFlyTime > 0 and m_szFlyGfxPath set.
Expected behavior:
- Fly GFX spawns at caster
- Moves toward target linearly
- Hit GFX spawns at target when projectile arrives
- Fly GFX destroyed
Debug Logging
Add these temporarily to trace the flow:
// In A3DSkillGfxEvent.Tick() state transitions:
BMLogger.Log($"[GFX] Event {m_nHostID}→{m_nTargetID}: {oldState} → {m_enumState}");
// In CECSkillGfxEvent.SpawnFlyGfx():
BMLogger.Log($"[GFX] Spawn fly at {m_pMoveMethod.GetPos()}, prefab={m_pComposer.GetFlyGFX()?.name}");
// In CECSkillGfxEvent.HitTarget():
BMLogger.Log($"[GFX] Hit target at {vTarget}, hitGFX={m_pComposer.GetHitGFX()?.name}");
Common Issues & Solutions
Issue: No GFX spawning at all
Check:
- Is the composer loaded? →
m_pSkillGfxComposerMan.GetSkillGfxComposer(skillId) != null - Is
flyGFX/hitGFXnot null on the composer? - Did you remove
SpawnGFX()call fromAddOneSkillGfxEvent()? - Are events in
SkillGfxMan.m_EventLst?
Issue: GFX spawns at wrong position
Check:
- Is
_get_pos_by_id()returning correct positions? - Is movement
StartMove()called with correct host/target? - For OnTarget mode: GFX should appear at target, not caster
Issue: Fly GFX doesn't move
Check:
- Is
m_pMoveMethod.TickMove()being called in baseTick()? - Is
UpdateFlyGfxTransform()being called each frame? - Is
m_dwFlyTimeSpanset (fromm_dwFlyTime)?
Issue: Timing off (damage before/after GFX)
Check:
m_timeToDoDamagecalculation inDoFire()- For
m_dwFlyTime = 0skills:m_timeToDoDamageshould be1 - For flying skills:
distance / 20.0f * 1000.0fshould roughly match fly speed
Issue: SpawnGFX still being called
Check:
- Did you delete
pComposer.SpawnGFX(nTargetID)fromAddOneSkillGfxEvent()(line 193)? - Did you delete the
SpawnGFX()method fromA3DSkillGfxComposer?
Key Files Reference
| File | What to Change | Phase |
|---|---|---|
CGfxMoveBase.cs |
CREATE — movement base class + factory (same as C++) | 1 |
CGfxLinearMove.cs |
CREATE — linear projectile | 1 |
CGfxOnTargetMove.cs |
CREATE — instant hit | 1 |
A3DSkillGfxMan.cs |
EDIT — un-comment state machine, add movement field | 1 |
CECSkillGfxMan.cs |
EDIT — un-comment Tick, add GFX spawning | 1 |
CECAttacksMan.cs |
EDIT — fix GFX strings, remove SpawnGFX, add scale | 1 |
Estimated Time: 3-5 days for Phase 1
- Day 1: Create movement system (3 files)
- Day 2: Un-comment + wire up state machine in A3DSkillGfxMan + CECSkillGfxMan
- Day 3: Fix composer Play() GFX refs, remove SpawnGFX hack, add accessor methods
- Day 4: Testing, debugging, logging
- Day 5: Polish, edge cases
For the full detailed plan including Phase 2 and Phase 3, see SKILL_GFX_CONVERSION_PLAN.md.