Files
test/Documentation/SKILL_GFX_QUICK_START.md
2026-03-13 16:03:47 +07:00

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:

  1. Fly GFX — Projectile traveling from caster to target (e.g., fireball flying)
  2. 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:

  1. Add field (un-comment line ~205):

    protected CGfxMoveBase m_pMoveMethod;
    
  2. In constructor (replace line ~251):

    m_pMoveMethod = CGfxMoveBase.CreateMoveMethod(mode);
    
  3. 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())
  4. Un-comment delegating setters (lines 336-345) — these delegate to m_pMoveMethod which 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 (like CGfxMeteoricMove.SetParam() which also stores a radius).

  5. In AddOneSkillGfxEvent():

    • Un-comment line 150: pEvent.SetReverse(bReverse)
    • DELETE line 193: pComposer.SpawnGFX(nTargetID) ← temp hack

Step 3: Implement GFX Spawning in CECSkillGfxEvent (1-2 days)

In CECSkillGfxMan.csCECSkillGfxEvent:

  1. Add fields:

    private GameObject m_flyGfxInstance;
    private GameObject m_hitGfxInstance;
    
  2. Un-comment Tick() position updates (lines 125-195)

  3. 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
    }
    
  4. Update HitTarget():

    protected override void HitTarget(Vector3 vTarget)
    {
        base.HitTarget(vTarget);
        DestroyFlyGfx();
        SpawnHitGfx(vTarget);
    }
    
  5. Hook into base Tick() — call SpawnFlyGfx/UpdateFlyGfx from overridden Tick: The base A3DSkillGfxEvent.Tick() handles state transitions. In CECSkillGfxEvent.Tick(), after the position update and base.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.csA3DSkillGfxComposer:

  1. Add scale fields:

    private float m_fFlyGfxScale = 1.0f;
    private float m_fHitGfxScale = 1.0f;
    
  2. Add accessor methods:

    public GameObject GetFlyGFX() => flyGFX;
    public GameObject GetHitGFX() => hitGFX;
    public GameObject GetHitGrdGFX() => hitGrdGFX;
    
  3. Fix Play() method — replace empty strings with actual GFX info:

    string szFly = flyGFX != null ? flyGfxName : null;
    string szHit = hitGFX != null ? hitGfxName : null;
    
  4. Delete SpawnGFX() — remove the entire method (lines 540-551)

  5. Fix AddOneTarget() scale — un-comment scale calculation, use actual fields

Step 5: Enable NPC Skill GFX (0.5 day)

In CECAttacksMan.csDoFire() 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:

  1. Player attacks → DoFire()
  2. Composer loads: m_MoveMode = enumOnTarget, m_dwFlyTime = 0
  3. Movement: CGfxOnTargetMove.TickMove() → returns false (stays at target + offset), hit triggered by fly time timeout
  4. HitTarget() → spawns hit GFX at target
  5. m_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:

  1. Fly GFX spawns at caster
  2. Moves toward target linearly
  3. Hit GFX spawns at target when projectile arrives
  4. 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 / hitGFX not null on the composer?
  • Did you remove SpawnGFX() call from AddOneSkillGfxEvent()?
  • 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 base Tick()?
  • Is UpdateFlyGfxTransform() being called each frame?
  • Is m_dwFlyTimeSpan set (from m_dwFlyTime)?

Issue: Timing off (damage before/after GFX)

Check:

  • m_timeToDoDamage calculation in DoFire()
  • For m_dwFlyTime = 0 skills: m_timeToDoDamage should be 1
  • For flying skills: distance / 20.0f * 1000.0f should roughly match fly speed

Issue: SpawnGFX still being called

Check:

  • Did you delete pComposer.SpawnGFX(nTargetID) from AddOneSkillGfxEvent() (line 193)?
  • Did you delete the SpawnGFX() method from A3DSkillGfxComposer?

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.