Like 0

Revision 4052f86920af75b865c21b71cf960663e4db9c89

CustomNpcSample.cs Raw
1using NewMoon.TF2Helpers;
2
3namespace NewMoon.Samples.CustomNpc;
4
5/// <summary>
6/// A light sample gamemode that registers one custom NPC classname, <c>nm_sample_npc</c>, which fronts <c>base_boss</c>.
7/// </summary>
8/// <remarks>
9/// The generator emits <c>NewMoon.Generated.CustomEntityTable.All</c> for this assembly, one descriptor per <c>[CustomEntity]</c>
10/// class. <see cref="FGamePlugin.RegisterCustomEntities"/> hands them to the registry, which installs the classname as a factory
11/// alias of <c>base_boss</c>. The plugin scope removes the alias, and despawns the live NPCs, when the plugin unloads.
12/// </remarks>
13[GameMode("Custom NPC Sample", "custom-npc-sample", Description = "Registers one custom NPC classname fronting base_boss.")]
14public sealed partial class CustomNpcSample : FGamePlugin
15{
16 /// <inheritdoc/>
17 protected override void OnPluginStart()
18 {
19 // The logger is set first: adoption inside the registration can raise OnSpawn for NPCs that already exist.
20 SampleNpc.Log = PLogger;
21 PLogger.LogRegistered(RegisterCustomEntities(NewMoon.Generated.CustomEntityTable.All));
22 }
23
24 /// <inheritdoc/>
25 protected override void OnPluginEnd() => PLogger.LogEnded();
26}
27
CustomNpcSample.logger.cs Raw
1using JetBrains.Annotations;
2using Microsoft.Extensions.Logging;
3
4#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
5
6namespace NewMoon.Samples.CustomNpc;
7
8/// <summary>
9/// Source-generated log messages for <see cref="CustomNpcSample"/> and <see cref="SampleNpc"/>.
10/// </summary>
11[UsedImplicitly]
12public static partial class CustomNpcSampleLogger
13{
14 [LoggerMessage(1, LogLevel.Information, "Custom NPC sample registered {Count} custom entity classes.")]
15 public static partial void LogRegistered(this ILogger logger, int count);
16
17 [LoggerMessage(2, LogLevel.Information, "Custom NPC sample ended: OnPluginEnd ran before the scope released.")]
18 public static partial void LogEnded(this ILogger logger);
19
20 [LoggerMessage(3, LogLevel.Information, "nm_sample_npc OnSpawn {Index}")]
21 public static partial void LogNpcSpawn(this ILogger logger, int index);
22
23 [LoggerMessage(4, LogLevel.Information, "nm_sample_npc OnActivate {Index}")]
24 public static partial void LogNpcActivate(this ILogger logger, int index);
25
26 [LoggerMessage(5, LogLevel.Information, "nm_sample_npc OnRemoved {Index}")]
27 public static partial void LogNpcRemoved(this ILogger logger, int index);
28}
29
NewMoon.Gamemodes.Sample.CustomNpc.csproj Raw
1<Project Sdk="Microsoft.NET.Sdk">
2 <PropertyGroup>
3 <RootNamespace>NewMoon.Samples.CustomNpc</RootNamespace>
4 <Version>1.0.0</Version>
5 <Platforms>AnyCPU</Platforms>
6 </PropertyGroup>
7
8 <PropertyGroup>
9 <Title>Custom NPC Sample Gamemode</Title>
10 <Authors>Jon S. (Serafeline)</Authors>
11 <PackageId>NewMoon.Gamemodes.Sample.CustomNpc</PackageId>
12 <PackageVersion>$(Version)</PackageVersion>
13 <IsPackable>false</IsPackable>
14 <PackageDescription>A light sample gamemode that registers one custom NPC classname (nm_sample_npc) fronting base_boss through the TF2Helpers custom entity API.</PackageDescription>
15 </PropertyGroup>
16
17 <ItemGroup>
18 <PackageReference Include="JetBrains.Annotations" />
19 <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
20 </ItemGroup>
21
22 <ItemGroup>
23 <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core\SourceNet.Core.csproj">
24 <Private>false</Private>
25 </ProjectReference>
26 <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Engine.Source\SourceNet.Engine.Source.csproj">
27 <Private>false</Private>
28 </ProjectReference>
29 <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Core.SourceGeneration\SourceNet.Core.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
30 <ProjectReference Include="..\ThirdParty\NewMoon.SourceNet\managed\SourceNet.Common\SourceNet.Common.csproj">
31 <Private>false</Private>
32 </ProjectReference>
33 <ProjectReference Include="..\NewMoon.Shared.Utility\NewMoon.Shared.Utility.csproj" />
34 </ItemGroup>
35
36 <!-- Task 20's shared game mode props: the [GameMode] startup generator and the NMGM analyzers as one analyzer reference
37 (analyzer references do not flow through project references), plus TeamFortress2.Api, SDKWrappers.Api, and
38 TF2Helpers.Api as Private=false references (the host supplies them through PluginUseAPIAssembly). The sample uses no
39 LoadoutSharp types, so that fourth API reference is left out. -->
40 <PropertyGroup>
41 <GameModeUsesLoadoutSharpApi>false</GameModeUsesLoadoutSharpApi>
42 </PropertyGroup>
43 <Import Project="..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props" />
44</Project>
45
README.md Raw

Custom NPC sample

A light gamemode that registers one custom NPC classname, nm_sample_npc, using the TF2Helpers friendly layer.

What it shows

  • SampleNpc derives FCustomEntity and is declared [CustomEntity("nm_sample_npc", "base_boss")]. The classname is a factory alias of base_boss: spawning it gives a real base_boss (same ServerClass and datamap) that carries the new classname. It is not a new engine class.
  • The source generator emits NewMoon.Generated.CustomEntityTable.All for the assembly, and CustomNpcSample.OnPluginStart passes it to RegisterCustomEntities.
  • The NPC precaches models/bots/headless_hatman.mdl at registration and logs OnSpawn, OnActivate and OnRemoved.
  • Unloading the plugin despawns the live NPCs and removes the alias.

Where it goes

Drop the folder at the monorepo root, beside NewMoon.Gamemodes.Sample.Bare, and add it to LoadoutSharp.slnx. The csproj uses relative paths (..\ThirdParty\..., ..\NewMoon.Plugins.TF2Helpers.Api\build\NewMoon.GameMode.props) that assume that location.

Build

.claude/scripts/build-gate.sh dotnet build NewMoon.Gamemodes.Sample.CustomNpc/NewMoon.Gamemodes.Sample.CustomNpc.csproj

The first build reports NMFG001: every game mode build checks its [CustomEntity] classes against the checked-in FGD (NewMoon.Plugins.TF2Helpers.Api/Fgd/newmoon_custom_entities.fgd), which has no block for nm_sample_npc yet. Regenerate the FGD, then build again:

.claude/scripts/build-gate.sh ./build.sh GenerateCustomEntityFgd

To try the sample without touching the FGD, build with -p:NewMoonCustomEntityFgdCheck=false.

Run the same command again whenever you change the class's keys, inputs or outputs.

Try it on a dev server

Load the gamemode, then from a console:

ent_create nm_sample_npc health 500 model models/bots/headless_hatman.mdl

ent_create writes the keyvalues before spawning, which matters: a base_boss spawns with 0 health unless health is set before spawn, and it needs a model. Expect the log to show nm_sample_npc OnSpawn <index>, then OnActivate <index> on the next tick, and OnRemoved <index> when the NPC is killed, removed or the plugin unloads. The NPC stands still.

From code, the equivalent is Map.CreateEntity("nm_sample_npc").KeyValue("health", 500).Model("...").At(origin).Spawn().

Limits

  • The NPC has no locomotion or AI. A bare base_boss stands still and has no animation driver, so it holds a pose. It also inherits the stock boss side effects (currency drop, crushing buildings, shoving players).
  • The class cannot set keyvalues or its model before spawn: OnSpawn runs after the native spawn. The spawner supplies them.
  • Keyvalue routing into [CustomKey] properties, [CustomInput] calls and [CustomOutput] firing are planned work (AB#29804 to AB#29807) and are not used here.
  • The engine sees only the base_boss datamap, so ent_text and ent_dump do not show managed state.
  • On a server's first map after start, plugins load after the map's entity lump is parsed, so a map-placed nm_sample_npc is not created on that map. ent_create works at any time.
SampleNpc.cs Raw
1using Microsoft.Extensions.Logging;
2using NewMoon.TF2Helpers;
3using NewMoon.TF2Helpers.Entities;
4using NewMoon.Utility;
5
6namespace NewMoon.Samples.CustomNpc;
7
8/// <summary>
9/// The custom NPC: a <c>nm_sample_npc</c> classname that is a factory alias of <c>base_boss</c>. Spawning it yields a real
10/// <c>base_boss</c> that carries the new classname, and the registry calls the lifecycle overrides below.
11/// </summary>
12/// <remarks>
13/// The class only logs. Reading keyvalues, inputs and outputs from a custom entity is not part of the landed API yet, so the
14/// spawner supplies <c>health</c> and <c>model</c> (see the README). The registry needs an accessible parameterless constructor.
15/// </remarks>
16[CustomEntity("nm_sample_npc", "base_boss", PrecacheModels = ["models/bots/headless_hatman.mdl"])]
17internal sealed class SampleNpc : FCustomEntity
18{
19 /// <summary>
20 /// The logger the callbacks write to; set by <see cref="CustomNpcSample"/> at start-up, because the registry creates this
21 /// object through its parameterless constructor.
22 /// </summary>
23 internal static ILogger? Log { get; set; }
24
25 // Captured at spawn so the remove log never depends on the view still resolving while the instance is torn down.
26 private int _index = -1;
27
28 /// <inheritdoc/>
29 protected override void OnSpawn()
30 {
31 _index = Entity.Index;
32 Log?.LogNpcSpawn(_index);
33 }
34
35 /// <inheritdoc/>
36 protected override void OnActivate() => Log?.LogNpcActivate(_index);
37
38 /// <inheritdoc/>
39 protected override void OnRemoved() => Log?.LogNpcRemoved(_index);
40}
41