--- 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 自定义游戏项目编写服务端游戏逻辑。 ## 核心原则 1. **使用 TSTL 开发**:TypeScript 编写,通过 `tstl` 编译为 Lua 运行在 Dota 2 服务端 2. **严格使用 dota_ts_adapter**:所有技能/物品/modifier 必须通过 `@registerAbility()` / `@registerModifier()` 注册,继承 `BaseAbility` / `BaseItem` / `BaseModifier` 3. **前后端通信优先级**:前端 API 可获取的数据不给后端加 → Game Events → NetTable → XNetTable 4. **配置与代码分离**:所有可能与前端共享或需要集中管理的配置、数值、常量(包括但不限于技能属性、物品数据、单位属性、游戏规则参数、界面配置等),都必须放在 `game/scripts/npc/` 下的 KV 文件中。后端代码通过编译后的 JSON 读取,严禁在代码中硬编码任何可能变化的配置值。修改配置后需运行 `npx gulp kv_2_js` 刷新 JSON 5. **编译验证**:代码修改完成后,必须运行编译验证,确保无错误 ## 文件结构 | 文件 | 路径 | 用途 | |---|---|---| | 服务端入口 | `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,保证前后端数据一致: ```bash # 编译所有 KV 文件为 JSON npx gulp kv_2_js # 或快捷命令(同时执行 sheet→kv 和 kv→js) npx gulp jssync ``` **前后端数据分离原则**: - 所有游戏数值配置写在 KV 文件中,前后端都从编译后的 JSON 读取同一份数据 - 禁止在后端代码中硬编码数值常量,所有值必须从 KV → JSON 获取 ### 步骤 3:配置数据通信(如需要) - Game Events:声明类型 → 后端发送/监听 → 前端接收/发送 - NetTable:注册表名 → 声明类型 → 后端写入 → 前端读取 - XNetTable:仅用于大数据量场景 ### 步骤 4:编译验证(必须执行) **完成代码编写后,必须运行编译命令验证没有错误:** ```bash # 验证服务端 TSTL 编译 npx tstl --project game/scripts/tsconfig.json ``` - 如果编译成功(退出码 0),无错误输出,则验证通过 - 如果编译失败,根据错误信息修复代码后重新编译验证 - **必须确保编译通过后才能告知用户完成** ## 游戏模式入口 ### addon_game_mode.ts 服务端入口文件,通过 `Object.assign(getfenv(), {...})` 将函数注册到 Dota 2 引擎: ```typescript import 'utils/index'; import { ActivateModules } from './modules'; import Precache from './utils/precache'; Object.assign(getfenv(), { Activate: () => { ActivateModules(); }, Precache: Precache, }); ``` - `Activate()`:游戏模式激活时调用,初始化所有模块 - `Precache()`:预缓存资源 ### 模块激活 (modules/index.ts) ```typescript import { XNetTable } from '../utils/xnet-table'; declare global { interface CDOTAGameRules { XNetTable: XNetTable; } } export function ActivateModules() { GameRules.XNetTable = new XNetTable(); new GameConfig(); new Debug(); } ``` ## 模块开发 ### 新建模块 1. 在 `game/scripts/src/modules/` 创建 `<模块名>.ts` 2. 导出类并在 `modules/index.ts` 中激活 ```typescript // modules/MyModule.ts import { reloadable } from '../utils/tstl-utils'; @reloadable export class MyModule { constructor() { // 初始化逻辑 ListenToGameEvent('entity_killed', event => this.OnEntityKilled(event), this); } OnEntityKilled(event: EntityKilledEvent): void { // 处理逻辑 } } ``` ```typescript // modules/index.ts - 添加激活 import { MyModule } from './MyModule'; export function ActivateModules() { // ...existing modules... new MyModule(); } ``` ### GameConfig 常用配置 ```typescript 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 静态方法(类型安全) ```typescript // 施加 modifier(参数类型与 OnCreated 参数自动关联) const mod = MyModifier.apply(target, caster, ability, { duration: 5 }); // 查找 modifier const existing = MyModifier.find_on(target); // 移除 modifier MyModifier.remove(target); ``` ### @reloadable 装饰器 标记支持热重载的类,在 Debug 模式下 `-s` 指令可重载脚本: ```typescript import { reloadable } from '../utils/tstl-utils'; @reloadable export class MyModule { /* ... */ } ``` ## 前后端通信 ### Game Events **声明**(`shared/gameevents.d.ts`): ```typescript declare interface CustomGameEventDeclarations { c2s_player_action: { action_type: number; target_entity: number }; s2c_sync_data: { data_key: string; data_value: number }; } ``` **后端发送**: ```typescript // 发送给所有客户端 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, }); ``` **后端监听**: ```typescript // 监听前端事件 CustomGameEventManager.RegisterListener('c2s_player_action', (userID, event) => { const playerID = userID as PlayerID; // 处理逻辑 }); ``` ### CustomNetTable **1. 注册表名**(`game/scripts/custom_net_tables.txt`): ``` { custom_net_tables = [ "hero_list", "game_timer", "my_table" ] } ``` **2. 声明类型**(`shared/net_tables.d.ts`): ```typescript declare interface CustomNetTableDeclarations { my_table: { my_key: { value: number; name: string; }; }; } ``` **3. 后端写入**: ```typescript CustomNetTables.SetTableValue('my_table', 'my_key', { value: 42, name: 'hello', }); ``` **限制**:每个表不超过 2MB,超过请使用 XNetTable。 ### XNetTable 大数据量场景使用,通过 Game Events 分片传输。访问 `GameRules.XNetTable`: ```typescript // 写入 GameRules.XNetTable.Set('large_table', 'some_key', data); // 需要在 shared/x-net-table.d.ts 声明类型 ``` ## 常用 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)` | 停止音效 | ### 定时器 ```typescript // 延迟执行 Timers.CreateTimer(delay, () => { // 回调 }); // 循环执行(返回间隔秒数继续循环,返回 null 停止) Timers.CreateTimer(() => { // 每帧执行 return 0.03; }); // 使用 timer_utils import { registerTimerFunction } from '../utils/timer_utils'; ``` ### 向量操作 ```typescript 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]` | 运行测试用例 | ### 控制台输出 ```typescript print('debug message'); // 服务端控制台输出 Say(hero, 'message', true); // 在游戏中显示消息 ``` ## 编译验证(必须) **代码编写完成后,必须执行以下命令验证编译无错误:** ```bash # 编译服务端 TSTL(非 watch 模式,仅编译一次检查错误) npx tstl --project game/scripts/tsconfig.json ``` - 编译成功:退出码 0,无错误输出 - 编译失败:输出错误信息(文件名、行号、错误描述),修复后重新编译 - **必须确保编译通过后才能认为任务完成** 如果同时修改了前端代码,还需要验证前端编译: ```bash # 编译前端 Panorama(非 watch 模式) npx webpack --config content/panorama/webpack.dev.js ``` ### 完整验证命令 ```bash # 同时验证服务端 + 前端 npx tstl --project game/scripts/tsconfig.json && npx webpack --config content/panorama/webpack.dev.js ``` ## 常见陷阱 1. **忘记注册 modifier**:使用 `@registerModifier()` 装饰器,否则 `LinkLuaModifier` 不会被调用 2. **modifier 放错位置**:技能/物品自己的 modifier 放在同一文件,独立机制性的才放 `src/modifiers/` 3. **NetTable 未注册**:新表名必须加到 `custom_net_tables.txt` 4. **事件类型未声明**:自定义事件必须在 `shared/gameevents.d.ts` 声明,否则前后端类型不一致 5. **KV 文件未引用**:新建的 KV 文件必须在对应的入口文件中 `#base` 引用 6. **ScriptFile 路径错误**:KV 中的 `ScriptFile` 指向编译后的 `.lua` 文件路径(如 `abilities/module/ability.lua`) 7. **print vs Say**:`print()` 只在服务端控制台输出,`Say()` 在游戏内显示 8. **Vector 运算**:向量运算结果需要 `as Vector` 类型断言 9. **编译后才能测试**:TSTL 代码修改后需要编译才能在游戏中生效,开发模式用 `yarn dev` 自动编译 10. **shared/ 类型声明**:`shared/` 目录的修改会同时影响前后端编译,确保声明正确