| name | dota2-game-logic |
| description | DOTA2 自定义游戏服务端逻辑开发指南。触发词:游戏逻辑、服务端、server、game mode、游戏模式、GameRules、modifier、timer、lua、TSTL。Use when user asks to create or modify DOTA2 custom game server-side logic, game mode configuration, modules, game events, net tables, or data synchronization. |
为 DOTA2 自定义游戏项目编写服务端游戏逻辑。
核心原则
- 使用 TSTL 开发:TypeScript 编写,通过
tstl 编译为 Lua 运行在 Dota 2 服务端
- 严格使用 dota_ts_adapter:所有技能/物品/modifier 必须通过
@registerAbility() / @registerModifier() 注册,继承 BaseAbility / BaseItem / BaseModifier
- 前后端通信优先级:前端 API 可获取的数据不给后端加 → Game Events → NetTable → XNetTable
- 配置与代码分离:所有可能与前端共享或需要集中管理的配置、数值、常量(包括但不限于技能属性、物品数据、单位属性、游戏规则参数、界面配置等),都必须放在
game/scripts/npc/ 下的 KV 文件中。后端代码通过编译后的 JSON 读取,严禁在代码中硬编码任何可能变化的配置值。修改配置后需运行 npx gulp kv_2_js 刷新 JSON
- 编译验证:代码修改完成后,必须运行编译验证,确保无错误
文件结构
| 文件 | 路径 | 用途 |
|---|
| 服务端入口 | game/scripts/src/addon_game_mode.ts | 游戏模式初始化入口 |
| 客户端入口 | game/scripts/src/addon_game_mode_client.ts | 客户端逻辑入口 |
| 游戏模块 | game/scripts/src/modules/ | 游戏功能模块(GameConfig、Debug 等) |
| 模块激活 | game/scripts/src/modules/index.ts | 模块激活入口(ActivateModules()) |
| 技能源码 | game/scripts/src/abilities/<模块名>/ | 技能和物品的 TSTL 逻辑 |
| 独立 Modifier | game/scripts/src/modifiers/<模块名>/ | 独立的机制性 modifier |
| 工具函数 | game/scripts/src/utils/ | 通用工具库 |
| 事件类型 | shared/gameevents.d.ts | 前后端通信事件类型声明 |
| 网络表类型 | shared/net_tables.d.ts | CustomNetTable 类型声明 |
| X网络表类型 | shared/x-net-table.d.ts | XNetTable 类型声明 |
| 网络表注册 | game/scripts/custom_net_tables.txt | 注册 NetTable 表名(KV3 格式) |
| KV 配置 | game/scripts/npc/ | NPC 键值对定义(技能/英雄/物品/单位) |
| KV 编译 JSON | game/scripts/src/json/ | KV → JSON 编译产物,后端 TSTL 从这里读取数据 |
| 前端 JSON | content/panorama/src/json/ | KV → JSON 编译产物,前端 Panorama 从这里读取统一数据 |
| 本地化 | game/resource/addon.csv | 文本本地化 |
| TSTL 配置 | game/scripts/tsconfig.json | TSTL 开发环境编译配置 |
| TSTL 生产配置 | game/scripts/tsconfig.prod.json | TSTL 生产环境编译配置(加密) |
工作流
步骤 1:确认需求
- 明确功能需求、涉及的模块、需要修改的文件
- 确定是否需要前后端通信(Game Events 或 Net Table)
- 确定是否需要新建模块或修改现有模块
步骤 2:编写代码
- 模块代码放在
game/scripts/src/modules/ 下
- 在
modules/index.ts 的 ActivateModules() 中激活新模块
- 如需通信,在
shared/gameevents.d.ts 声明事件类型
步骤 2b:编译 KV 到 JSON(如修改了 KV 文件)
如果修改了 game/scripts/npc/ 下的 KV 文件,必须运行以下命令将 KV 编译为 JSON,保证前后端数据一致:
npx gulp kv_2_js
npx gulp jssync
前后端数据分离原则:
- 所有游戏数值配置写在 KV 文件中,前后端都从编译后的 JSON 读取同一份数据
- 禁止在后端代码中硬编码数值常量,所有值必须从 KV → JSON 获取
步骤 3:配置数据通信(如需要)
- Game Events:声明类型 → 后端发送/监听 → 前端接收/发送
- NetTable:注册表名 → 声明类型 → 后端写入 → 前端读取
- XNetTable:仅用于大数据量场景
步骤 4:编译验证(必须执行)
完成代码编写后,必须运行编译命令验证没有错误:
npx tstl --project game/scripts/tsconfig.json
- 如果编译成功(退出码 0),无错误输出,则验证通过
- 如果编译失败,根据错误信息修复代码后重新编译验证
- 必须确保编译通过后才能告知用户完成
游戏模式入口
addon_game_mode.ts
服务端入口文件,通过 Object.assign(getfenv(), {...}) 将函数注册到 Dota 2 引擎:
import 'utils/index';
import { ActivateModules } from './modules';
import Precache from './utils/precache';
Object.assign(getfenv(), {
Activate: () => {
ActivateModules();
},
Precache: Precache,
});
Activate():游戏模式激活时调用,初始化所有模块
Precache():预缓存资源
模块激活 (modules/index.ts)
import { XNetTable } from '../utils/xnet-table';
declare global {
interface CDOTAGameRules {
XNetTable: XNetTable;
}
}
export function ActivateModules() {
GameRules.XNetTable = new XNetTable();
new GameConfig();
new Debug();
}
模块开发
新建模块
- 在
game/scripts/src/modules/ 创建 <模块名>.ts
- 导出类并在
modules/index.ts 中激活
import { reloadable } from '../utils/tstl-utils';
@reloadable
export class MyModule {
constructor() {
ListenToGameEvent('entity_killed', event => this.OnEntityKilled(event), this);
}
OnEntityKilled(event: EntityKilledEvent): void {
}
}
import { MyModule } from './MyModule';
export function ActivateModules() {
new MyModule();
}
GameConfig 常用配置
export class GameConfig {
constructor() {
GameRules.SetCustomGameSetupAutoLaunchDelay(3);
GameRules.SetHeroSelectionTime(0);
GameRules.SetPreGameTime(0);
GameRules.SetStartingGold(0);
GameRules.SetSameHeroSelectionEnabled(true);
GameRules.SetHeroRespawnEnabled(false);
const game = GameRules.GetGameModeEntity();
game.SetCustomGameForceHero('npc_dota_hero_phoenix');
game.SetBuybackEnabled(false);
game.SetDaynightCycleDisabled(true);
GameRules.SetCustomGameTeamMaxPlayers(DotaTeam.GOODGUYS, 3);
GameRules.SetCustomGameTeamMaxPlayers(DotaTeam.BADGUYS, 3);
}
}
dota_ts_adapter 核心
装饰器与基类
| 装饰器/基类 | 用途 |
|---|
@registerAbility() | 注册技能/物品类,类名即为注册名 |
@registerModifier() | 注册 modifier 类,类名即为注册名,自动调用 LinkLuaModifier |
BaseAbility | 技能基类,super.* 可调用原生 CDOTA_Ability_Lua 方法 |
BaseItem | 物品基类,super.* 可调用原生 CDOTA_Item_Lua 方法 |
BaseModifier | modifier 基类,提供 apply/find_on/remove 静态方法 |
BaseModifierMotionHorizontal | 水平运动 modifier 基类 |
BaseModifierMotionVertical | 垂直运动 modifier 基类 |
BaseModifierMotionBoth | 双向运动 modifier 基类 |
Modifier 静态方法(类型安全)
const mod = MyModifier.apply(target, caster, ability, { duration: 5 });
const existing = MyModifier.find_on(target);
MyModifier.remove(target);
@reloadable 装饰器
标记支持热重载的类,在 Debug 模式下 -s 指令可重载脚本:
import { reloadable } from '../utils/tstl-utils';
@reloadable
export class MyModule { }
前后端通信
Game Events
声明(shared/gameevents.d.ts):
declare interface CustomGameEventDeclarations {
c2s_player_action: { action_type: number; target_entity: number };
s2c_sync_data: { data_key: string; data_value: number };
}
后端发送:
CustomGameEventManager.Send_ServerToAllClients('s2c_sync_data', {
data_key: 'score',
data_value: 100,
});
CustomGameEventManager.Send_ServerToPlayer(playerID, 's2c_sync_data', {
data_key: 'score',
data_value: 100,
});
后端监听:
CustomGameEventManager.RegisterListener('c2s_player_action', (userID, event) => {
const playerID = userID as PlayerID;
});
CustomNetTable
1. 注册表名(game/scripts/custom_net_tables.txt):
<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:generic:version{7412167c-06e9-4698-aff2-e63eb59037e7} -->
{
custom_net_tables =
[
"hero_list",
"game_timer",
"my_table"
]
}
2. 声明类型(shared/net_tables.d.ts):
declare interface CustomNetTableDeclarations {
my_table: {
my_key: {
value: number;
name: string;
};
};
}
3. 后端写入:
CustomNetTables.SetTableValue('my_table', 'my_key', {
value: 42,
name: 'hello',
});
限制:每个表不超过 2MB,超过请使用 XNetTable。
XNetTable
大数据量场景使用,通过 Game Events 分片传输。访问 GameRules.XNetTable:
GameRules.XNetTable.Set('large_table', 'some_key', data);
常用 API
游戏规则
| API | 用途 |
|---|
GameRules.GetGameTime() | 获取游戏时间 |
GameRules.GetGameModeEntity() | 获取游戏模式实体 |
GameRules.SetCustomGameTeamMaxPlayers(team, count) | 设置队伍人数上限 |
GameRules.IsDaytime() | 是否白天 |
GameRules.SetHeroSelectionTime(seconds) | 选英雄时间 |
GameRules.SetPreGameTime(seconds) | 准备时间 |
GameRules.SetStartingGold(gold) | 初始金钱 |
单位操作
| API | 用途 |
|---|
CreateUnitByName(name, origin, findClear, owner, playerOwner) | 创建单位 |
unit.Kill(ability, attacker) | 杀死单位 |
unit.RemoveSelf() | 移除单位 |
unit.AddAbility(name) | 添加技能 |
unit.FindAbilityByName(name) | 查找技能 |
unit.AddNewModifier(caster, ability, name, kv) | 添加 modifier |
unit.GetAbsOrigin() | 获取位置 |
unit.SetAbsOrigin(vec) | 设置位置 |
unit.GetHealth() / unit.SetHealth(v) | 生命值 |
unit.GetTeamNumber() | 获取队伍 |
unit.IsAlive() | 是否存活 |
粒子与音效
| API | 用途 |
|---|
ParticleManager.CreateParticle(path, attach, unit) | 创建粒子 |
ParticleManager.SetParticleControl(particle, cp, vec) | 设置控制点 |
ParticleManager.ReleaseParticleIndex(particle) | 释放粒子 |
EmitSoundOn(soundName, unit) | 在单位上播放音效 |
EmitGlobalSound(soundName) | 全局播放音效 |
StopSoundOn(soundName, unit) | 停止音效 |
定时器
Timers.CreateTimer(delay, () => {
});
Timers.CreateTimer(() => {
return 0.03;
});
import { registerTimerFunction } from '../utils/timer_utils';
向量操作
const pos = unit.GetAbsOrigin();
const direction = (target.GetAbsOrigin() - pos as Vector).Normalized();
const distance = (target.GetAbsOrigin() - pos as Vector).Length();
const newPos = pos + direction * 100 as Vector;
调试
聊天指令(工具模式自动启用)
| 指令 | 说明 |
|---|
-help | 显示所有测试指令 |
-s | 重载脚本(script_reload) |
-r | 重启游戏 |
-debug | 切换调试模式(线上需白名单) |
-testx [filter] | 运行测试用例 |
控制台输出
print('debug message');
Say(hero, 'message', true);
编译验证(必须)
代码编写完成后,必须执行以下命令验证编译无错误:
npx tstl --project game/scripts/tsconfig.json
- 编译成功:退出码 0,无错误输出
- 编译失败:输出错误信息(文件名、行号、错误描述),修复后重新编译
- 必须确保编译通过后才能认为任务完成
如果同时修改了前端代码,还需要验证前端编译:
npx webpack --config content/panorama/webpack.dev.js
完整验证命令
npx tstl --project game/scripts/tsconfig.json && npx webpack --config content/panorama/webpack.dev.js
常见陷阱
- 忘记注册 modifier:使用
@registerModifier() 装饰器,否则 LinkLuaModifier 不会被调用
- modifier 放错位置:技能/物品自己的 modifier 放在同一文件,独立机制性的才放
src/modifiers/
- NetTable 未注册:新表名必须加到
custom_net_tables.txt
- 事件类型未声明:自定义事件必须在
shared/gameevents.d.ts 声明,否则前后端类型不一致
- KV 文件未引用:新建的 KV 文件必须在对应的入口文件中
#base 引用
- ScriptFile 路径错误:KV 中的
ScriptFile 指向编译后的 .lua 文件路径(如 abilities/module/ability.lua)
- print vs Say:
print() 只在服务端控制台输出,Say() 在游戏内显示
- Vector 运算:向量运算结果需要
as Vector 类型断言
- 编译后才能测试:TSTL 代码修改后需要编译才能在游戏中生效,开发模式用
yarn dev 自动编译
- shared/ 类型声明:
shared/ 目录的修改会同时影响前后端编译,确保声明正确