ODRP开发日记-靠近NPC触发交互(二)

书接上文,我们已经搭建好了服务器和开发环境,也初始化了JavaScript项目,接下来我们将开始正式编码

引入全局声明

由于@citizenfx/client及@citizenfx/server并不是一个模块,我们需要使用一种新的方式来引入全局声明,即在代码文件顶部添加这样一行

/// <reference types="@citizenfx/client" />

添加后,我们在编写代码时即可使用代码补全,编写时会舒服很多。

声明全局常量

我们希望当NPC离角色只有2.5M的时候才可以触发交互,因此我们事先声明好一个常量用于存储此值

const INTERACT_DISTANCE = 2.5;

检测附近的NPC

方法一:遍历GamePool

GmaePool是FiveM提供的一个实体缓存池,可以通过调用GetGamePool来获取。

GamePool有以下几种类型:

  • CPed: Ped池包含已加载的行人、动物和玩家
  • CObject:Object池包含已加载的对象,比如门、发射物等
  • CNetObject:NetObject池是网络同步对象池
  • CVehicle:Vehicle池包含场景中已加载的载具
  • CPickup:Pickup池包含可拾取的物品

GamePool本质上是一个一维数组,元素类型为int,其元素代表的是对象句柄,在js中,我们可以用number[]类型的变量来承接。

// 此值没有修改的必要,使用const声明为常量以符合代码规范
const pedPool: number[] = GetGamePool('CPed');
const objectPool: number[] = GetGamePool('CObject');
const netObjectPool: number[] = GetGamePool('CNetObject');
const vehiclePool: number[] = GetGamePool('CVehicle');
const pickupPool: number[] = GetGamePool('CPickup');

在C#中,GetGamePool的返回类型为dynamic,实际类型为ushort数组,不可直接使用,必须在for loop中强制转换为int才可使用。

获取CPed池

本文要做的是靠近NPC触发交互提示,因此我们获取CPed池即可。

const pedPool: number[] = GetGamePool('CPed');

缓存距离计算结果

对于每个Ped,我们都需要计算其与玩家之间的距离,取距离最近且满足我们要求的Ped进行交互,因此我们需要创建一个变量用于缓存我们计算到的最近距离,以及其对应的ped

let result:number | undefined = undefined;
let minimalDist = INTERACT_DISTANCE;

获取玩家PedId及坐标

在客户端侧的代码中,可以直接使用PlayerPedId()来获取指向玩家Ped的句柄,获取到句柄后,使用GetEntityCoords来获取实体坐标

const playerPed = PlayerPedId();// 玩家句柄

const playerCoords = GetEntityCoords(playerPed);// 玩家坐标

遍历

  • 玩家判断

使用IsPedAPlayer来判断此句柄是否指向一名玩家,如果是玩家,则不满足我们的要求,跳过循环。

  • 坐标获取

与获取玩家坐标一样,使用GetEntityCoords获取实体坐标即可

  • 距离计算

FiveM提供了Vdist方法来快速计算坐标之间的距离,我们可以非常方便地调用它

我们只需要分别传入两个位置的x、y、z坐标即可,其函数声明为

/**
 * Calculates the distance between two points in 3D space. For performance reasons, consider using direct mathematical calculations for distance, as they can be more efficient than calling this native function.
 * ```
 * NativeDB Introduced: v323
 * ```
 * @param x1 X coordinate of the first point.
 * @param y1 Y coordinate of the first point.
 * @param z1 Z coordinate of the first point. Represents the height or elevation at the first point.
 * @param x2 X coordinate of the second point.
 * @param y2 Y coordinate of the second point.
 * @param z2 Z coordinate of the second point. Represents the height or elevation at the second point.
 * @return Returns the distance as a float between the two points (`x1`, `y1`, `z1`) and (`x2`, `y2`, `z2`) in the game world.
 */
declare function Vdist(x1: number, y1: number, z1: number, x2: number, y2: number, z2: number): number;

for (const ped of pedPool) {
    if (IsPedAPlayer(ped)) continue;
    const pedCoords = GetEntityCoords(ped);
    const dist = Vdist(playerCoords[0]!, playerCoords[1]!, playerCoords[2]!, pedCoords[0]!, pedCoords[1]!, pedCoords[2]!);
    if (dist < minimalDist) {
      minimalDist = dist;
      result = ped;
    }
  }

返回结果

遍历完成后,result变量即我们所需的结果,如果存在符合要求的NPC,则result是离玩家最近的NPC,否则result为undefined

方法二:射线检测

2026/5/14复编:终于跑通了,不过此时我用的是C#

射线检测相较于遍历GamePool,即可以指定检测的方向,其原理是从一个坐标向另一个坐标发射一道虚拟射线,检测射线是否与其他实体存在交汇,并返回交汇处信息及实体信息。

这意味着我们需要有两个坐标,分别是射线起始坐标和射线终结坐标。

获取起始坐标

起始坐标很简单,因为我们要检测玩家附近的NPC,因此起始坐标就是玩家坐标。

var playerPedId = PlayerPedId();
var playerCoords = GetEntityCoords(playerPedId, true);

获取方向向量

我们需要使用方向向量来计算终结坐标,方向向量也很好获取

var playerVec = GetEntityForwardVector(playerPedId);

计算终结坐标

使用方向向量计算玩家面前的坐标,我们以前方INTERACT_DISTANCEM为例

var endX = playerCoords[0] + INTERACT_DISTANCE * playerVec.X;
var endY = playerCoords[1] + INTERACT_DISTANCE * playerVec.Y;
var endZ = playerCoords[2];

开始射线检测

var ret = StartShapeTestRay(
playerCoords[0], playerCoords[1],
playerCoords[2], playerCoords[0] + INTERACT_DISTANCE * playerVec.X,
playerCoords[1] + INTERACT_DISTANCE * playerVec.Y, playerCoords[2],
4, playerPedId, 0
);

获取检测结果

var hit = false;
var endCoords = new Vector3();
var surfaceNormal = new Vector3();
int entityHit = -1;
GetShapeTestResult(ret, ref hit, ref endCoords, ref surfaceNormal, ref entityHit);

entityHit即目标NPC

完整代码

private int? GetInteractablePedRay()
{
  int? result = null;
  var playerPedId = PlayerPedId();
  var playerCoords = GetEntityCoords(playerPedId, true);
  var playerVec = GetEntityForwardVector(playerPedId);
  var ret = StartShapeTestRay(
    playerCoords[0], playerCoords[1],
    playerCoords[2], playerCoords[0] + INTERACT_DISTANCE * playerVec.X,
    playerCoords[1] + INTERACT_DISTANCE * playerVec.Y, playerCoords[2],
    4, playerPedId, 0
  );
  var hit = false;
  var endCoords = new Vector3();
  var surfaceNormal = new Vector3();
  int entityHit = -1;
  GetShapeTestResult(ret, ref hit, ref endCoords, ref surfaceNormal, ref entityHit);
  if (entityHit != -1)
  {
    result = entityHit;
  }
  return result;
}

两种方法性能对比

由于不支持Stopwatch,我大致用DateTime.Tick来估计了一下两种方法的耗时,其实大差不差。如果你在检测时需要方向性,则可以选择射线检测,否则也可以使用遍历检测。

绘制提示GUI

这一部分没什么细节,总共就三行

BeginTextCommandDisplayHelp('STRING');
AddTextComponentString('按 ~INPUT_CONTEXT~ 与 AIC 交谈');
EndTextCommandDisplayHelp(0, false, false, -1);

第一行:创建了一个Help Display绘制事件
第二行:向绘制事件添加要绘制的文本,其中INPUT_CONTEXT是占位符,可以显示按键图标
第三行:结束绘制事件,并渲染绘制结果

这三个函数的文档在这里可以查看

posted @ 2026-05-14 12:28  梨猫先森  阅读(21)  评论(0)    收藏  举报