| 1 | using NewMoon.TF2Helpers; |
| 2 | |
| 3 | namespace 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.")] |
| 14 | public 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 |
screwdriver / gist:f99d5bf9d8f14fe2a9a3bf56d4ac0c66
Last active 1 hour ago
Revision 4052f86920af75b865c21b71cf960663e4db9c89
| 1 | using JetBrains.Annotations; |
| 2 | using Microsoft.Extensions.Logging; |
| 3 | |
| 4 | #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member |
| 5 | |
| 6 | namespace NewMoon.Samples.CustomNpc; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Source-generated log messages for <see cref="CustomNpcSample"/> and <see cref="SampleNpc"/>. |
| 10 | /// </summary> |
| 11 | [UsedImplicitly] |
| 12 | public 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 |
| 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 |
Custom NPC sample
A light gamemode that registers one custom NPC classname, nm_sample_npc, using the TF2Helpers friendly layer.
What it shows
SampleNpcderivesFCustomEntityand is declared[CustomEntity("nm_sample_npc", "base_boss")]. The classname is a factory alias ofbase_boss: spawning it gives a realbase_boss(same ServerClass and datamap) that carries the new classname. It is not a new engine class.- The source generator emits
NewMoon.Generated.CustomEntityTable.Allfor the assembly, andCustomNpcSample.OnPluginStartpasses it toRegisterCustomEntities. - The NPC precaches
models/bots/headless_hatman.mdlat registration and logsOnSpawn,OnActivateandOnRemoved. - 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_bossstands 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:
OnSpawnruns 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_bossdatamap, soent_textandent_dumpdo 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_npcis not created on that map.ent_createworks at any time.
| 1 | using Microsoft.Extensions.Logging; |
| 2 | using NewMoon.TF2Helpers; |
| 3 | using NewMoon.TF2Helpers.Entities; |
| 4 | using NewMoon.Utility; |
| 5 | |
| 6 | namespace 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"])] |
| 17 | internal 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 |