Files
test/Documentation/AutoMoveImp/NAVIGATION_SYSTEM_GUIDE.md
T
2026-01-12 10:08:58 +07:00

32 KiB

Perfect World Navigation System - Complete Guide

Table of Contents

  1. System Overview
  2. Architecture
  3. Complete Flow: UI Click to Movement
  4. Force Navigate System (Bezier-Based)
  5. Normal Auto-Move System
  6. Map Loading from StreamingAssets
  7. Bezier Curve Loading (navigate.dat)
  8. Coordinate Resolution System
  9. Data Files Structure
  10. Key Components Deep Dive
  11. Troubleshooting

System Overview

The Perfect World navigation system provides two distinct movement modes:

  1. Force Navigate (Bezier-Based): Predefined cinematic paths using Bezier curves for scripted sequences
  2. Normal Auto-Move: Pathfinding-based movement for general navigation

When Each Mode is Used

  • Force Navigate: Tasks with enumTMSimpleClientTaskForceNavi flag OR entries in force_navigate.txt
  • Normal Auto-Move: All other cases (NPCs, monsters, items, general coordinates)

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    User Interface Layer                      │
│  DlgTask (Task UI) → OnEventLButtonDown_Txt_QuestItem      │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│              Coordinate Resolution Layer                     │
│  CECUIHelper.FollowCoord() → GetTaskObjectCoordinates()     │
│  - Live objects (NPCs/Players/Matters)                       │
│  - Template ID lookup (spawned NPCs)                        │
│  - task_npc.data table                                       │
│  - coord_data.txt                                            │
│  - Task regions (fallback)                                   │
└──────────────────────┬──────────────────────────────────────┘
                       │
         ┌─────────────┴─────────────┐
         │                           │
         ▼                           ▼
┌──────────────────┐      ┌──────────────────┐
│  Force Navigate   │      │  Normal Auto-Move │
│  (Bezier Path)    │      │  (Pathfinding)   │
└──────────────────┘      └──────────────────┘

Complete Flow: UI Click to Movement

Step 1: User Clicks NPC/Monster Name in Task UI

File: Assets/PerfectWorld/Scripts/Task/UI/DlgTask.cs

When a user clicks a link in the task description (e.g., NPC name, monster name, item name):

private void OnEventLButtonDown_Txt_QuestItem(PointerEventData eventData)
{
    // 1. Detect clicked link using TextMeshPro
    int linkIndex = TMP_TextUtilities.FindIntersectingLink(
        m_pTxt_QuestItem, eventData.position, camera);
    
    // 2. Extract entity ID from link (format: "coord_{entityID}")
    string linkID = m_pTxt_QuestItem.textInfo.linkInfo[linkIndex].GetLinkID();
    int entityID = int.Parse(linkID.Substring(6)); // Remove "coord_" prefix
    
    // 3. Trigger navigation
    CECUIHelper.FollowCoord(entityID, currentTaskID);
}

Key Points:

  • Links are formatted as <link="coord_{entityID}">{name}</link> in rich text
  • TMP_TextUtilities.FindIntersectingLink detects which link was clicked
  • Camera must be null for ScreenSpaceOverlay canvases

Step 2: Coordinate Resolution

File: Assets/PerfectWorld/Scripts/UI/EC_UIHelper.cs

The system attempts to find coordinates in this priority order:

public static A3DVECTOR3 GetTaskObjectCoordinates(int id, ref bool in_table)
{
    // Priority 1: Live runtime objects (spawned NPCs, players, matters)
    var world = EC_Game.GetGameRun()?.GetWorld();
    if (world != null && isLikelyRuntimeObjectId)
    {
        var obj = world.GetObject(id, 0);
        if (obj != null)
            return EC_Utility.ToA3DVECTOR3(obj.transform.position);
    }

    // Priority 2: Live NPC/Monster by template ID
    var npcMan = EC_ManMessageMono.Instance?.CECNPCMan;
    if (npcMan != null)
    {
        var npc = npcMan.FindNPCByTemplateID(id);
        if (npc != null)
            return EC_Utility.ToA3DVECTOR3(npc.transform.position);
    }

    // Priority 3: task_npc.data table
    ATaskTemplMan pMan = EC_Game.GetTaskTemplateMan();
    if (pMan != null && pMan.TryGetTaskNPCInfo((uint)id, out NPC_INFO info))
    {
        return new A3DVECTOR3(info.x, info.z, info.y); // Note: y/z swap
    }

    // Priority 4: coord_data.txt
    if (EC_Game.TryGetFirstObjectCoord(id.ToString(), out var pos, out var mapName))
    {
        return new A3DVECTOR3(pos.x, pos.y, pos.z);
    }

    // Priority 5: Task regions (fallback)
    // Uses delivery zone, reach-site, enter/leave regions
    return new A3DVECTOR3(0);
}

Step 3: Navigation Mode Decision

File: Assets/PerfectWorld/Scripts/UI/EC_UIHelper.cs

public static bool FollowCoord(int id, int taskId)
{
    // Check if task uses Force Navigate
    bool shouldForceNavigate = false;
    
    // Method 1: Task template flag
    var templ = taskMan.GetTaskTemplByID((uint)taskId);
    if (templ != null && 
        templ.m_FixedData.m_enumMethod == 
        (uint)TaskCompletionMethod.enumTMSimpleClientTaskForceNavi)
    {
        shouldForceNavigate = true;
    }
    
    // Method 2: force_navigate.txt mapping
    if (!shouldForceNavigate)
    {
        var ctrl = hostPlayer.GetNavigatePlayer()?.GetNavigateCtrl();
        if (ctrl != null && ctrl.GetNavigateInfo(taskId, ref tmp))
        {
            shouldForceNavigate = true;
        }
    }
    
    if (shouldForceNavigate)
    {
        // Use Bezier-based Force Navigate
        hostPlayer.OnNaviageEvent(taskId, EM_PREPARE);
        hostPlayer.OnNaviageEvent(taskId, EM_BEGIN);
        return true;
    }
    
    // Otherwise: Use normal auto-move with pathfinding
    CECHPWorkMove work = wm.CreateWork(WORK_MOVETOPOS);
    work.SetDestination(DEST_AUTOPF, vPos);
    wm.StartWork_p2(work);
    return true;
}

Force Navigate System (Bezier-Based)

Configuration File: force_navigate.txt

Location: Assets/Addressable/force_navigate.txt

Format: CSV with 6 fields per line

taskID,bezierID,speed,angle,dir,modelPath
32220,-1001,30,30.0,1,Models\NPC\xxx.ecm

Fields:

  • taskID: Task ID that triggers this navigation
  • bezierID: Global ID of Bezier curve in navigate.dat (negative = force navigate)
  • speed: Movement speed (units per second)
  • angle: Camera angle adjustment (degrees)
  • dir: 1 = use Bezier direction, 0 = fixed horizontal
  • modelPath: Model file path for navigation (optional)

Loading:

// Auto-loaded when CECNavigateCtrl is created
public CECNavigateCtrl(CECHostPlayer pHost)
{
    EnsureDefaultConfigLoaded(); // Loads from Addressables
}

Phase 1: Prepare (EM_PREPARE)

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

public void OnPrepareNavigate(int task)
{
    m_bForceNavigateState = true;
    m_taskID = task;
    
    // Load navigation config for this task
    INFO naviInfo = new INFO();
    if (GetNavigateInfo(task, ref naviInfo))
    {
        m_curNavigateInfo = naviInfo;
        
        // Set up navigate player with model file
        CECHostNavigatePlayer player = m_pHost.GetNavigatePlayer();
        if (player != null)
        {
            player.SetNavigateModelFile(naviInfo.strModelPath);
            player.Init();
        }
    }
    else
    {
        // No config = give up task
        m_pHost.GetTaskInterface().GiveUpTask((uint)m_taskID);
    }
}

What Happens:

  • Sets force navigate state flag
  • Loads navigation config from force_navigate.txt
  • Initializes navigate player with model file
  • If config missing → gives up task (strict C++ behavior)

Phase 2: Begin (EM_BEGIN)

public void OnBeginNavigate()
{
    // Load Bezier curve from navigate.dat by globalID
    CECBezier pBezier = CECBezierNavigateLoader.GetBezierObjectByGlobalID(
        m_curNavigateInfo.bezierID);
    
    if (pBezier == null)
    {
        // No Bezier = give up task (strict C++ behavior)
        m_pHost.GetTaskInterface().GiveUpTask((uint)m_taskID);
        return;
    }
    
    // Create Bezier walker
    if (m_pBezierWalker == null)
        m_pBezierWalker = new CECBezierWalker();
    
    // Bind Bezier curve and configure
    m_pBezierWalker.BindBezier(pBezier);
    m_pBezierWalker.SetSpeed(m_curNavigateInfo.speed);
    m_pBezierWalker.StartWalk(false, true); // no loop, forward
    
    // Create work system entry
    CECHPWorkNavigate pWork = m_pHost.GetWorkMan()
        .CreateWork(WORK_FORCENAVIGATEMOVE) as CECHPWorkNavigate;
    pWork.BeginNavigate();
    m_pHost.GetWorkMan().StartWork_p2(pWork);
}

What Happens:

  • Loads Bezier curve from navigate.dat using bezierID
  • Creates CECBezierWalker to follow the curve
  • Sets speed and starts walking
  • Creates CECHPWorkNavigate work to drive movement each frame

Phase 3: Movement Loop (Tick)

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Each frame, CECHPWorkNavigate.Tick() is called:

public override bool Tick(float dwDeltaTime)
{
    CECBezierWalker pBezierWalker = /* get from navigate ctrl */;
    
    if (!m_bMove || pBezierWalker == null) return true;
    
    // Get frame delta time in milliseconds
    int dwRealTime = (int)EC_Game.GetRealTickTime();
    
    if (pBezierWalker.IsWalking())
    {
        // Update Bezier walker position along curve
        pBezierWalker.Tick(dwRealTime);
        
        // Get position and direction from Bezier curve
        A3DVECTOR3 vCurPos = pBezierWalker.GetPos();
        A3DVECTOR3 vDir = pBezierWalker.GetDir();
        vDir.Normalize();
        
        // Calculate rotation vectors
        A3DVECTOR3 vRight = A3DVECTOR3.CrossProduct(g_vAxisY, vDir);
        A3DVECTOR3 vUp = A3DVECTOR3.CrossProduct(vDir, vRight);
        A3DVECTOR3 vDirH = A3DVECTOR3.CrossProduct(vRight, g_vAxisY);
        vUp.Normalize();
        
        // Apply to player
        CECHostNavigatePlayer pClone = m_pHost.GetNavigatePlayer();
        pClone.SetPos(vCurPos);
        
        // Use Bezier direction or fixed horizontal based on config
        if (naviInfo.bezierDir)
            pClone.ChangeModelMoveDirAndUp(vDir, vUp);
        else
            pClone.ChangeModelMoveDirAndUp(vDirH, g_vAxisY);
    }
    else
    {
        // Bezier walker finished
        m_bMove = false;
        m_pHost.GetTaskInterface().SetForceNavigateFinishFlag(true);
    }
    
    return true;
}

Key Points:

  • GetRealTickTime() returns delta milliseconds (not absolute time)
  • Bezier walker accumulates time and calculates position along curve
  • Position/direction come from Bezier curve calculations
  • Rotation can follow Bezier tangent or stay horizontal

Phase 4: End (EM_END)

public void OnEndNavigate()
{
    if (!m_bForceNavigateState) return;
    
    m_taskID = 0;
    
    // Finish work
    m_pHost.GetWorkMan()
        .FinishRunningWork(WORK_FORCENAVIGATEMOVE);
    
    m_bForceNavigateState = false;
}

Normal Auto-Move System

When not using Force Navigate, the system uses pathfinding:

// In CECUIHelper.FollowCoord()
CECHPWorkMove work = wm.CreateWork(WORK_MOVETOPOS);
work.SetDestination(CECHPWorkMove.DestTypes.DEST_AUTOPF, vPos);

// Store task NPC info for trace switching
if (taskId > 0)
{
    work.SetTaskNPCInfo(id, taskId);
}

wm.StartWork_p2(work);

What Happens:

  • Creates CECHPWorkMove work
  • Uses AutoPF (intelligent pathfinding) instead of straight-line
  • Can switch to WORK_TRACEOBJECT when near NPCs
  • Uses pathfinding maps from StreamingAssets/maps/{mapName}/movemap/

Map Loading from StreamingAssets

Directory Structure

Assets/StreamingAssets/maps/
├── a61/                    # Map name (from CECInstance.GetPath())
│   ├── movemap/            # Pathfinding data
│   │   ├── r1_1-c1_2-l0.cfg
│   │   ├── r1_1-c1_2-l0.prmap
│   │   ├── r1_1-c1_2-l0.pdhmap
│   │   └── r1_1-c1_2-l0.mlu
│   └── navigate.dat        # Bezier curves for force navigate (C++ loads this)
└── world/
    └── ...

How Maps Are Loaded

File: Assets/PerfectWorld/Scripts/World/EC_Instance.cs

public class CECInstance
{
    string m_strPath;  // e.g., "a61"
    int m_iRowNum = 3;  // Number of map rows
    int m_iColNum = 4;  // Number of map columns
    List<string> m_routeFiles;  // e.g., ["r1_1-c1_2-l0"]
    
    public string GetPath() { return m_strPath; }
    public List<string> GetRouteFiles() { return m_routeFiles; }
}

File: Assets/PerfectWorld/Scripts/Move/CECIntelligentRoute.cs

When the world instance changes, pathfinding maps are loaded:

public void ChangeWorldInstance(int idInstance)
{
    CECInstance pInst = EC_Game.GetGameRun()?.GetInstance(idInstance);
    if (pInst == null) return;
    
    // Get route files from instance (e.g., ["r1_1-c1_2-l0"])
    List<string> files = pInst.GetRouteFiles();
    
    foreach (var fileBase in files)
    {
        // Parse range: "r1_1-c1_2-l0" → rowFrom=1, rowTo=1, colFrom=1, colTo=2
        int rowFrom, rowTo, colFrom, colTo;
        TryParseRouteFileRange(fileBase, out rowFrom, out rowTo, 
                              out colFrom, out colTo);
        
        // Build path: "maps/a61/movemap/r1_1-c1_2-l0"
        string basePath = $"maps/{pInst.GetPath()}/movemap/{fileBase}";
        
        // Load pathfinding agent
        var agent = new CMoveAgent();
        agent.Load(basePath, ResolveStreamingBytes, origin);
        
        m_moveAgents.Add(new RangedMoveAgent { agent = agent, ... });
    }
}

private static byte[] ResolveStreamingBytes(string key)
{
    // Resolves "maps/a61/movemap/r1_1-c1_2-l0.cfg" to:
    // Application.streamingAssetsPath + "/maps/a61/movemap/r1_1-c1_2-l0.cfg"
    string full = Path.Combine(
        Application.streamingAssetsPath, 
        key.Replace('/', Path.DirectorySeparatorChar));
    
    return File.Exists(full) ? File.ReadAllBytes(full) : null;
}

Key Points:

  • Maps are loaded from StreamingAssets/maps/{mapName}/movemap/
  • Route files follow pattern: r{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0
  • Files: .cfg, .prmap, .pdhmap, .mlu
  • Loaded via CMoveAgent.Load() which reads from StreamingAssets

Bezier Curve Loading (navigate.dat)

File Location

Original C++: maps/{mapName}/navigate.dat (loaded per-map)
Unity Version: Assets/Addressable/navigate.dat (single global file)

Note: The Unity version currently loads a single global navigate.dat from Addressables. In the original C++, each map has its own navigate.dat file loaded when the map loads.

Binary File Format

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

public static class CECBezierNavigateLoader
{
    private struct NAVIGATEBEZIERFILEHEADER
    {
        public int iVersion;    // File version
        public int iNumBezier;  // Number of Bezier curves
    }
    
    public static bool LoadBezierNavigate(string address)
    {
        // Load from Addressables
        var ta = Addressables.LoadAssetAsync<TextAsset>(address)
            .WaitForCompletion();
        
        using var ms = new MemoryStream(ta.bytes, false);
        using var br = new BinaryReader(ms);
        
        // Read header
        NAVIGATEBEZIERFILEHEADER header;
        header.iVersion = br.ReadInt32();
        header.iNumBezier = br.ReadInt32();
        
        // Read each Bezier curve
        for (int i = 0; i < header.iNumBezier; i++)
        {
            var pBezier = new CECBezier();
            pBezier.Load(br);  // Loads curve data
            
            // Store by GlobalID
            s_beziers[pBezier.GetGlobalID()] = pBezier;
        }
    }
}

Bezier Curve Binary Format

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

public bool Load(BinaryReader br)
{
    // Header
    uint dwVersion = br.ReadUInt32();  // BEZIER_FILE_VERSION (3)
    m_nObjectID = br.ReadInt32();
    
    // Version 2+: Global IDs
    if (dwVersion >= 2)
    {
        m_iGlobalID = br.ReadInt32();      // Used for lookup
        m_iNextGlobalID = br.ReadInt32();   // Next curve in chain
    }
    
    // Version 3+: Action name
    if (dwVersion >= 3)
    {
        int len = br.ReadInt32();
        if (len > 0)
        {
            byte[] buf = br.ReadBytes(len);
            m_strActName = Encoding.GetEncoding(936).GetString(buf);
        }
    }
    
    // Points array
    int ptNum = br.ReadInt32();
    m_pListPoint = new CECBezierPoint[ptNum];
    for (int i = 0; i < ptNum; i++)
    {
        A3DVECTOR3 vPos = new A3DVECTOR3(
            br.ReadSingle(), br.ReadSingle(), br.ReadSingle());
        A3DVECTOR3 vDir = new A3DVECTOR3(
            br.ReadSingle(), br.ReadSingle(), br.ReadSingle());
        m_pListPoint[i] = new CECBezierPoint();
        m_pListPoint[i].SetPos(vPos);
        m_pListPoint[i].SetDir(vDir);
    }
    
    // Segments array
    int segNum = br.ReadInt32();
    m_pListSeg = new CECBezierSeg[segNum];
    for (int i = 0; i < segNum; i++)
    {
        A3DVECTOR3 vAnchorHead = new A3DVECTOR3(
            br.ReadSingle(), br.ReadSingle(), br.ReadSingle());
        A3DVECTOR3 vAnchorTail = new A3DVECTOR3(
            br.ReadSingle(), br.ReadSingle(), br.ReadSingle());
        int headIndex = br.ReadInt32();
        int tailIndex = br.ReadInt32();
        float length = br.ReadSingle();
        
        m_pListSeg[i] = new CECBezierSeg();
        m_pListSeg[i].Init(m_pListPoint);
        m_pListSeg[i].SetAnchorHead(vAnchorHead);
        m_pListSeg[i].SetAnchorTail(vAnchorTail);
        m_pListSeg[i].SetHeadPoint(headIndex);
        m_pListSeg[i].SetTailPoint(tailIndex);
        m_pListSeg[i].SetSegLength(length);
    }
    
    return true;
}

Binary Structure:

[Header]
  int32: version (3)
  int32: objectID
  
[Version 2+]
  int32: globalID
  int32: nextGlobalID
  
[Version 3+]
  int32: actionNameLength
  byte[]: actionName (CP936 encoding)
  
[Points Array]
  int32: pointCount
  for each point:
    float32[3]: position (x, y, z)
    float32[3]: direction (x, y, z)
  
[Segments Array]
  int32: segmentCount
  for each segment:
    float32[3]: anchorHead (x, y, z)
    float32[3]: anchorTail (x, y, z)
    int32: headPointIndex
    int32: tailPointIndex
    float32: segmentLength

How Bezier Curves Are Looked Up

// When OnBeginNavigate() is called:
CECBezier pBezier = CECBezierNavigateLoader.GetBezierObjectByGlobalID(
    m_curNavigateInfo.bezierID);

// GetBezierObjectByGlobalID:
public static CECBezier GetBezierObjectByGlobalID(int iGlobalID)
{
    if (!s_loaded)
    {
        LoadBezierNavigate("Assets/Addressable/navigate.dat");
    }
    
    return s_beziers.TryGetValue(iGlobalID, out var bezier) 
        ? bezier : null;
}

Key Points:

  • Bezier curves are stored in a Dictionary<int, CECBezier> keyed by GlobalID
  • bezierID from force_navigate.txt is the GlobalID used for lookup
  • Negative bezierID values (e.g., -1001) indicate force navigate routes
  • Loaded once on first access (lazy loading)

Coordinate Resolution System

Priority Order

File: Assets/PerfectWorld/Scripts/UI/EC_UIHelper.cs

public static A3DVECTOR3 GetTaskObjectCoordinates(int id, ref bool in_table)
{
    // Priority 1: Live runtime objects
    // Check if ID is a runtime object ID (NPC, Player, Matter)
    bool isLikelyRuntimeObjectId = 
        GPDataTypeHelper.ISNPCID(id) || 
        GPDataTypeHelper.ISMATTERID(id) || 
        (id > 100000000); // Player IDs are huge
    
    if (isLikelyRuntimeObjectId)
    {
        var obj = world.GetObject(id, 0);
        if (obj != null)
            return EC_Utility.ToA3DVECTOR3(obj.transform.position);
    }
    
    // Priority 2: Live NPC/Monster by template ID
    var npc = npcMan.FindNPCByTemplateID(id);
    if (npc != null)
        return EC_Utility.ToA3DVECTOR3(npc.transform.position);
    
    // Priority 3: task_npc.data table
    if (pMan.TryGetTaskNPCInfo((uint)id, out NPC_INFO info))
    {
        return new A3DVECTOR3(info.x, info.z, info.y); // Note: y/z swap
    }
    
    // Priority 4: coord_data.txt
    if (EC_Game.TryGetFirstObjectCoord(id.ToString(), out var pos, out var mapName))
    {
        return new A3DVECTOR3(pos.x, pos.y, pos.z);
    }
    
    // Priority 5: Task regions (fallback)
    // Uses delivery zone, reach-site, enter/leave regions
    return new A3DVECTOR3(0);
}

Data Sources

1. Live Runtime Objects

Source: CECWorld.GetObject(id, 0)

  • Spawned NPCs, players, matters in the current world
  • Uses runtime object IDs (not template IDs)
  • Fastest - direct world lookup

2. Live NPCs by Template ID

Source: CECNPCMan.FindNPCByTemplateID(id)

  • Searches spawned NPCs/monsters matching template ID
  • Checks both active NPCs and disappeared NPCs (may still have valid positions)
  • Good for clicking NPC names that are currently spawned

3. task_npc.data Table

Source: ATaskTemplMan.TryGetTaskNPCInfo(uint id, out NPC_INFO info)

  • Static NPC coordinates from task data
  • Loaded from binary task_npc.data file
  • Note: Coordinates use (x, z, y) mapping (y/z swapped)

4. coord_data.txt

Source: EC_Game.TryGetFirstObjectCoord(string targetId, out Vector3 pos, out string mapName)

File: Assets/Addressable/coord_data.txt

Format:

ID    MapName    X    Y    Z
44393 a61        33.37 25.08 244.49
44618 a61        -440.90 21.00 102.72

Loading:

// In EC_Game.Init()
LoadObjectCoord();  // Loads coord_data.txt from Addressables

// Parsing:
private static void ParseCoordDataText(string text)
{
    // Skip header line
    // Parse: key mapName x y z
    string key = parts[0];      // e.g., "44393"
    string map = parts[1];      // e.g., "a61"
    float x = float.Parse(parts[2]);
    float y = float.Parse(parts[3]);
    float z = float.Parse(parts[4]);
    
    m_CoordTab[key] = new List<OBJECT_COORD> { 
        new OBJECT_COORD { strMap = map, vPos = new Vector3(x, y, z) }
    };
}

Lookup:

public static bool TryGetFirstObjectCoord(string targetId, 
    out Vector3 pos, out string map)
{
    if (!m_CoordTab.TryGetValue(targetId, out var list) || 
        list == null || list.Count == 0)
    {
        return false;
    }
    
    pos = list[0].vPos;
    map = list[0].strMap;
    return true;
}

5. Task Regions (Fallback)

Source: ATaskTempl region data

If no specific coordinates found, uses task region centers:

  • Delivery zone: m_pDelvRegion (where quest giver is)
  • Reach-site: m_pReachSite (for reach-site tasks)
  • Enter region: m_pEnterRegion (where objectives happen)
  • Leave region: m_pLeaveRegion (exit zones)
// Calculate region center
float cx = (r.zvMin.x + r.zvMax.x) * 0.5f;
float cy = (r.zvMin.y + r.zvMax.y) * 0.5f;
float cz = (r.zvMin.z + r.zvMax.z) * 0.5f;

Data Files Structure

1. force_navigate.txt

Location: Assets/Addressable/force_navigate.txt

Format: CSV (comma-separated, may be quoted)

"32220,-1001,30,30.0,1,Models\NPC\xxx.ecm"
"32221,-1002,25,45.0,0,Models\NPC\yyy.ecm"

Fields:

  • taskID: Task ID
  • bezierID: Global ID in navigate.dat (negative = force navigate)
  • speed: Movement speed
  • angle: Camera angle (degrees)
  • dir: 1 = Bezier dir, 0 = fixed horizontal
  • modelPath: Model file path

Loading: Auto-loaded when CECNavigateCtrl is created

2. navigate.dat

Location: Assets/Addressable/navigate.dat (Unity)
Original: maps/{mapName}/navigate.dat (C++)

Format: Binary file with Bezier curves

Structure:

[Header: 8 bytes]
  int32: version (3)
  int32: numBezier

[For each Bezier curve:]
  [Bezier Header]
    uint32: version
    int32: objectID
    int32: globalID (version >= 2)
    int32: nextGlobalID (version >= 2)
    int32: actionNameLength (version >= 3)
    byte[]: actionName (CP936, if length > 0)
  
  [Points Array]
    int32: pointCount
    for each point:
      float32[3]: position
      float32[3]: direction
  
  [Segments Array]
    int32: segmentCount
    for each segment:
      float32[3]: anchorHead
      float32[3]: anchorTail
      int32: headPointIndex
      int32: tailPointIndex
      float32: segmentLength

Loading: Lazy-loaded on first GetBezierObjectByGlobalID() call

3. coord_data.txt

Location: Assets/Addressable/coord_data.txt

Format: Tab/space-separated text

ID    MapName    X    Y    Z
44393 a61        33.37 25.08 244.49
44618 a61        -440.90 21.00 102.72

Loading: Loaded in EC_Game.Init() via LoadObjectCoord()

Lookup: EC_Game.TryGetFirstObjectCoord(string id, ...)

4. task_npc.data

Location: Binary file loaded by ATaskTemplMan

Format: Binary (NPC_INFO structs)

Lookup: ATaskTemplMan.TryGetTaskNPCInfo(uint id, out NPC_INFO info)

Note: Coordinates use (x, z, y) mapping (y/z swapped from Unity)

5. Map Pathfinding Files

Location: Assets/StreamingAssets/maps/{mapName}/movemap/

Files:

  • r{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.cfg - Configuration
  • r{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.prmap - Pathfinding map
  • r{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.pdhmap - Height map
  • r{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.mlu - Movement lookup

Loading: Loaded by CECIntelligentRoute.ChangeWorldInstance() when map changes


Key Components Deep Dive

CECNavigateCtrl

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Responsibilities:

  • Loads force_navigate.txt config
  • Manages force navigate state
  • Coordinates navigation events (PREPARE, BEGIN, END)
  • Creates and manages CECBezierWalker

Key Methods:

  • LoadConfig() / LoadConfigAddressable() - Load config file
  • GetNavigateInfo() - Lookup config by task ID
  • OnPrepareNavigate() - Phase 1: Setup
  • OnBeginNavigate() - Phase 2: Start movement
  • OnEndNavigate() - Phase 3: Cleanup

CECBezierWalker

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Responsibilities:

  • Follows Bezier curves over time
  • Calculates position/direction along curve
  • Handles segment transitions and looping

Key Methods:

  • BindBezier() - Attach Bezier curve
  • SetSpeed() - Set movement speed
  • StartWalk() - Begin walking along curve
  • Tick() - Update position based on time
  • GetPos() - Get current position on curve
  • GetDir() - Get current direction on curve

Time Calculation:

// Time accumulates in milliseconds
m_iTimeCnt += iDeltaTime;

// Calculate parameter u [0,1] for current segment
float u = (float)(m_iTimeCnt - m_iPassSegTime) / m_iCurSegTime;

// Get position from Bezier curve
A3DVECTOR3 pos = segment.Bezier(u, bForward);

CECHPWorkNavigate

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Responsibilities:

  • Work system entry point for force navigate
  • Ticks each frame to update player position
  • Applies Bezier walker results to player

Key Methods:

  • BeginNavigate() - Start movement flag
  • OnFirstTick() - Initial camera setup
  • Tick() - Main movement loop

CECHostNavigatePlayer

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Responsibilities:

  • Wrapper that applies position/rotation to host player
  • Manages navigate control instance

Key Methods:

  • SetPos() - Apply position to host player
  • ChangeModelMoveDirAndUp() - Apply rotation to host player
  • GetNavigateCtrl() - Access navigation control

CECBezier / CECBezierSeg / CECBezierPoint

File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs

Bezier Curve Structure:

  • CECBezier: Container for multiple segments
  • CECBezierSeg: Single cubic Bezier segment
  • CECBezierPoint: Control point with position and direction

Bezier Calculation:

// Cubic Bezier: B(u) = (1-u)³P₀ + 3(1-u)²uP₁ + 3(1-u)u²P₂ + u³P₃
public A3DVECTOR3 Bezier(float u, bool bForward)
{
    A3DVECTOR3 pt1, pt2, control1, control2;
    
    if (bForward)
    {
        pt1 = m_pListPoint[m_nHeadPoint].GetPos();
        control1 = m_vAnchorHead;
        control2 = m_vAnchorTail;
        pt2 = m_pListPoint[m_nTailPoint].GetPos();
    }
    else
    {
        // Reverse direction
        pt1 = m_pListPoint[m_nTailPoint].GetPos();
        control1 = m_vAnchorTail;
        control2 = m_vAnchorHead;
        pt2 = m_pListPoint[m_nHeadPoint].GetPos();
    }
    
    // Calculate cubic Bezier
    float u2 = u * u;
    float u3 = u2 * u;
    float oneMinusU = 1.0f - u;
    float oneMinusU2 = oneMinusU * oneMinusU;
    float oneMinusU3 = oneMinusU2 * oneMinusU;
    
    A3DVECTOR3 pos;
    pos.x = oneMinusU3 * pt1.x + 3.0f * oneMinusU2 * u * control1.x + 
            3.0f * oneMinusU * u2 * control2.x + u3 * pt2.x;
    // ... (y, z similar)
    
    return pos;
}

CECUIHelper

File: Assets/PerfectWorld/Scripts/UI/EC_UIHelper.cs

Responsibilities:

  • Entry point for navigation
  • Coordinate resolution
  • Navigation mode decision

Key Methods:

  • FollowCoord() - Main entry point
  • GetTaskObjectCoordinates() - Multi-source coordinate lookup

Troubleshooting

Player Not Moving

Check:

  1. Is coordinate resolution working? Check logs for GetTaskObjectCoordinates results
  2. Is Force Navigate config loaded? Check force_navigate.txt parsing
  3. Is Bezier curve found? Check GetBezierObjectByGlobalID returns non-null
  4. Is work system running? Check CECHPWorkNavigate.Tick() is being called

Bezier Curve Not Found

Check:

  1. Is navigate.dat loaded? Check CECBezierNavigateLoader.s_loaded
  2. Does bezierID match GlobalID in navigate.dat?
  3. Is bezierID negative? (Force navigate routes use negative IDs)

Coordinates Not Found

Check:

  1. Is entity spawned? Check live object lookup
  2. Is template ID correct? Check FindNPCByTemplateID
  3. Is entry in task_npc.data? Check TryGetTaskNPCInfo
  4. Is entry in coord_data.txt? Check TryGetFirstObjectCoord
  5. Are task regions available? Check fallback region lookup

Check:

  1. Is raycastTarget enabled on TMP_Text?
  2. Is camera correct? ScreenSpaceOverlay needs camera = null
  3. Are links formatted correctly? Must be <link="coord_{id}">...</link>

Time Calculation Issues

Check:

  1. Is GetRealTickTime() returning delta milliseconds? (not absolute seconds)
  2. Is time accumulating correctly? Check m_iTimeCnt in CECBezierWalker
  3. Are segment times calculated correctly? segmentLength * (1000.0f / speed)

Summary

The navigation system provides two movement modes:

  1. Force Navigate: Bezier-based cinematic movement for scripted sequences

    • Configured in force_navigate.txt
    • Uses Bezier curves from navigate.dat
    • Time-based progression along curves
  2. Normal Auto-Move: Pathfinding-based movement for general navigation

    • Uses AutoPF pathfinding
    • Reads pathfinding maps from StreamingAssets/maps/{mapName}/movemap/
    • Can switch to trace mode near NPCs

Coordinate Resolution uses multiple fallback sources:

  1. Live runtime objects
  2. Spawned NPCs by template ID
  3. task_npc.data table
  4. coord_data.txt lookup
  5. Task region centers

Map Loading:

  • Maps loaded from StreamingAssets/maps/{mapName}/
  • Pathfinding maps: movemap/r{row}_{row}-c{col}_{col}-l0.*
  • Bezier curves: navigate.dat (currently global, originally per-map)

The system is designed to match the original C++ behavior exactly, with strict error handling (missing config = give up task) and time-based Bezier progression.