32 KiB
Perfect World Navigation System - Complete Guide
Table of Contents
- System Overview
- Architecture
- Complete Flow: UI Click to Movement
- Force Navigate System (Bezier-Based)
- Normal Auto-Move System
- Map Loading from StreamingAssets
- Bezier Curve Loading (navigate.dat)
- Coordinate Resolution System
- Data Files Structure
- Key Components Deep Dive
- Troubleshooting
System Overview
The Perfect World navigation system provides two distinct movement modes:
- Force Navigate (Bezier-Based): Predefined cinematic paths using Bezier curves for scripted sequences
- Normal Auto-Move: Pathfinding-based movement for general navigation
When Each Mode is Used
- Force Navigate: Tasks with
enumTMSimpleClientTaskForceNaviflag OR entries inforce_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.FindIntersectingLinkdetects which link was clicked- Camera must be
nullforScreenSpaceOverlaycanvases
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 navigationbezierID: Global ID of Bezier curve innavigate.dat(negative = force navigate)speed: Movement speed (units per second)angle: Camera angle adjustment (degrees)dir:1= use Bezier direction,0= fixed horizontalmodelPath: 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.datusingbezierID - Creates
CECBezierWalkerto follow the curve - Sets speed and starts walking
- Creates
CECHPWorkNavigatework 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
CECHPWorkMovework - Uses AutoPF (intelligent pathfinding) instead of straight-line
- Can switch to
WORK_TRACEOBJECTwhen 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 fromStreamingAssets
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 byGlobalID bezierIDfromforce_navigate.txtis theGlobalIDused for lookup- Negative
bezierIDvalues (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.datafile - 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 IDbezierID: Global ID innavigate.dat(negative = force navigate)speed: Movement speedangle: Camera angle (degrees)dir:1= Bezier dir,0= fixed horizontalmodelPath: 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- Configurationr{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.prmap- Pathfinding mapr{rowFrom}_{rowTo}-c{colFrom}_{colTo}-l0.pdhmap- Height mapr{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.txtconfig - Manages force navigate state
- Coordinates navigation events (PREPARE, BEGIN, END)
- Creates and manages
CECBezierWalker
Key Methods:
LoadConfig()/LoadConfigAddressable()- Load config fileGetNavigateInfo()- Lookup config by task IDOnPrepareNavigate()- Phase 1: SetupOnBeginNavigate()- Phase 2: Start movementOnEndNavigate()- 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 curveSetSpeed()- Set movement speedStartWalk()- Begin walking along curveTick()- Update position based on timeGetPos()- Get current position on curveGetDir()- 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 flagOnFirstTick()- Initial camera setupTick()- 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 playerChangeModelMoveDirAndUp()- Apply rotation to host playerGetNavigateCtrl()- Access navigation control
CECBezier / CECBezierSeg / CECBezierPoint
File: Assets/PerfectWorld/Scripts/Managers/EC_HPWorkNavigate.cs
Bezier Curve Structure:
CECBezier: Container for multiple segmentsCECBezierSeg: Single cubic Bezier segmentCECBezierPoint: 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 pointGetTaskObjectCoordinates()- Multi-source coordinate lookup
Troubleshooting
Player Not Moving
Check:
- Is coordinate resolution working? Check logs for
GetTaskObjectCoordinatesresults - Is Force Navigate config loaded? Check
force_navigate.txtparsing - Is Bezier curve found? Check
GetBezierObjectByGlobalIDreturns non-null - Is work system running? Check
CECHPWorkNavigate.Tick()is being called
Bezier Curve Not Found
Check:
- Is
navigate.datloaded? CheckCECBezierNavigateLoader.s_loaded - Does
bezierIDmatchGlobalIDinnavigate.dat? - Is
bezierIDnegative? (Force navigate routes use negative IDs)
Coordinates Not Found
Check:
- Is entity spawned? Check live object lookup
- Is template ID correct? Check
FindNPCByTemplateID - Is entry in
task_npc.data? CheckTryGetTaskNPCInfo - Is entry in
coord_data.txt? CheckTryGetFirstObjectCoord - Are task regions available? Check fallback region lookup
Link Click Not Detected
Check:
- Is
raycastTargetenabled onTMP_Text? - Is camera correct?
ScreenSpaceOverlayneedscamera = null - Are links formatted correctly? Must be
<link="coord_{id}">...</link>
Time Calculation Issues
Check:
- Is
GetRealTickTime()returning delta milliseconds? (not absolute seconds) - Is time accumulating correctly? Check
m_iTimeCntinCECBezierWalker - Are segment times calculated correctly?
segmentLength * (1000.0f / speed)
Summary
The navigation system provides two movement modes:
-
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
- Configured in
-
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:
- Live runtime objects
- Spawned NPCs by template ID
task_npc.datatablecoord_data.txtlookup- 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.