Skip to content
Edit on GitHub

Author an enemy presentation prefab

verifiedAgainst e50826a · verifiedOn 2026-09-10.

Shambler and Ghoul are the primary nighttime hostile visual archetypes in Voxamine. They represent humanoid threats that roam the dark wilderness outside daylight safety. Rather than hand-editing third-party vendor assets or placing test instances inside the active Gameplay.unity scene, you will author a reproducible, project-owned presentation pipeline using EnemyLab (EnemyLabModule.cs).

This pipeline isolates vendor models (Assets/ThirdParty/Kenney_*) and animation takes (Assets/ThirdParty/Universal_Animation_Library_Standard/), enforces Humanoid rig retargeting, generates clean project-owned HDRP Lit materials, builds a non-root-motion AnimatorController featuring a 1D Speed locomotion blend tree with combat trigger transitions, configures a standard CapsuleCollider, and outputs production-ready prefabs to Assets/_Game/Resources/Prefabs/Enemies/.

Assets/_Game/Resources/Prefabs/Enemies/Shambler.prefab
// The project-owned prefab output destination:
// Assets/_Game/Resources/Prefabs/Enemies/Ghoul.prefab

Enemy presentation authoring enforces a strict boundary between raw vendor art, editor generation tooling, project-owned serialized assets, and runtime entity simulation:

  • ThirdParty (Vendor) contains pristine, unmodified source FBX models, textures, and animation clips.
  • Voxel Workshop (EnemyLab) reads the vendor sources, verifies their import settings, and constructs project-owned assets.
  • Resources (Prefabs/Enemies) holds the generated standalone prefabs, materials, and animator controllers.
  • World Entities (VoxelSandbox.World) deterministically plans spawn locations and tracks pure combat state (HostileMob, HostileSpawnPlanner) completely independent of scene markup.
flowchart TD
  subgraph VENDOR["Assets/ThirdParty/ (Quarantined)"]
    KENNEY_S["Kenney Survivors FBX\n(characterMedium.fbx + zombieA.png)"]
    KENNEY_R["Kenney Retro FBX\n(characterMedium.fbx + zombieFemaleA.png)"]
    UAL_ANIM["Universal Animation Library\n(UAL1_Standard.fbx)"]
  end

  subgraph BUILDER["Editor / Voxel Workshop"]
    LAB_MOD["EnemyLabModule\n(Workshop UI & validation)"]
    PRES_BLD["EnemyPresentationBuilder\n(Rig setup, material composition, blend tree authoring)"]
  end

  subgraph PROJECT_ASSETS["Assets/_Game/ (Project Owned)"]
    MATS["Art/Materials/Enemies/\n(Shambler.mat, Ghoul.mat)"]
    CTRLS["Art/Animation/Enemies/\n(1D Speed BlendTree, Attack, Hit, Death)"]
    PREFABS["Resources/Prefabs/Enemies/\n(Shambler.prefab, Ghoul.prefab)"]
  end

  subgraph SIMULATION["VoxelSandbox.World.Entities"]
    SPAWN_RULES["HostileSpawnRules\n(Daylight & weather dimming)"]
    PLANNER["HostileSpawnPlanner\n(Deterministic seed hashing & terrain gating)"]
    HOSTILE_MOB["HostileMob\n(WalkSpeed, AttackDamage, AttackReach, Cooldown)"]
  end

  KENNEY_S --> PRES_BLD
  KENNEY_R --> PRES_BLD
  UAL_ANIM --> PRES_BLD
  LAB_MOD --> PRES_BLD
  PRES_BLD --> MATS
  PRES_BLD --> CTRLS
  PRES_BLD --> PREFABS
  PLANNER --> PREFABS
  SPAWN_RULES --> PLANNER
  HOSTILE_MOB -.-> PREFABS

Gameplay.unity is never dirtied by mob composition. The spawner loads prefabs dynamically from Resources at runtime when HostileSpawnPlanner yields a valid candidate.

Read Docs/MASTERPLAN.md §31 (“The Lab is the Monster”) for hostile encounter philosophies and review:

Decide these design constraints before assembling enemy visuals:

Decision Specification Architectural Rationale
Rig import type ModelImporterAnimationType.Human Humanoid avatar retargeting decouples vendor skeletal bone naming differences from shared clips.
Root motion applyRootMotion = false Movement speed and collision displacement must be governed by simulation and nav/kinematics, never visual animation displacement.
Animator culling CullUpdateTransforms Disables bone transform updates when out of camera frustum while preserving state machine progression.
Material shader HDRP/Lit (with standard fallback) Matches project render pipeline; sets low smoothness ($0.12$) for cloth/flesh specular response.
Locomotion blending 1D Blend Tree on Speed ($[0, 1]$) Smooth interpolation between Idle_Loop ($0.0$), Walk_Loop ($0.5$), and Jog_Fwd_Loop ($1.0$).
Collider CapsuleCollider ($r=0.28\text{ m}, h=1.8\text{ m}, y_c=0.9\text{ m}$) Standard humanoid physical envelope conforming to $1\times 2$ block doorways.

1. Configure Humanoid rig imports for vendor models

Section titled “1. Configure Humanoid rig imports for vendor models”

Both the character mesh FBX files and the animation library FBX must be imported as Humanoid rigs so Unity generates an internal Avatar for clip retargeting. EnemyPresentationBuilder.ConfigureHumanoidImport automates this:

private static void ConfigureHumanoidImport(string path)
{
if (AssetImporter.GetAtPath(path) is not ModelImporter importer)
{
throw new InvalidOperationException($"Expected a model importer at {path}.");
}
if (importer.animationType == ModelImporterAnimationType.Human)
{
return;
}
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.SaveAndReimport();
}

This ensures that animations authored on standard skeletons can seamlessly drive the Kenney low-poly character meshes.

2. Isolate and resolve required animation takes

Section titled “2. Isolate and resolve required animation takes”

The vendor animation library (UAL1_Standard.fbx) bundles dozens of motion clips. Some import versions prefix clips with Armature|. The builder safely strips this prefix and extracts the exact six clips required for basic combat locomotion:

private static readonly string[] RequiredClipNames =
{
"Idle_Loop",
"Walk_Loop",
"Jog_Fwd_Loop",
"Punch_Jab",
"Hit_Chest",
"Death01"
};
private static Dictionary<string, AnimationClip> FindRequiredAnimationClips()
{
var clips = new Dictionary<string, AnimationClip>(StringComparer.Ordinal);
foreach (UnityEngine.Object asset in AssetDatabase.LoadAllAssetsAtPath(AnimationSourcePath))
{
if (asset is not AnimationClip clip) continue;
foreach (string requiredName in RequiredClipNames)
{
if (clip.name == requiredName || clip.name.EndsWith($"|{requiredName}", StringComparison.Ordinal))
{
clips[requiredName] = clip;
break;
}
}
}
return clips;
}

Never apply textures directly to vendor materials. Create dedicated HDRP materials in Assets/_Game/Art/Materials/Enemies/ with tailored albedo skins and species tinting:

private static Material LoadOrCreateMaterial(string displayName, string skinPath, Color tint)
{
string path = $"{MaterialDirectory}/{displayName}.mat";
Material material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
{
Shader shader = Shader.Find("HDRP/Lit") ?? Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
material = new Material(shader);
AssetDatabase.CreateAsset(material, path);
}
Texture2D skin = AssetDatabase.LoadAssetAtPath<Texture2D>(skinPath);
material.name = displayName;
SetTextureIfPresent(material, "_BaseColorMap", skin);
SetTextureIfPresent(material, "_MainTex", skin);
SetColorIfPresent(material, "_BaseColor", tint);
SetColorIfPresent(material, "_Color", tint);
if (material.HasProperty("_Smoothness"))
{
material.SetFloat("_Smoothness", 0.12f);
}
EditorUtility.SetDirty(material);
return material;
}

The Shambler receives a desiccated pale green tint (#ABD194), while the Ghoul receives an ethereal bluish-green tint (#BDDCA8).

4. Build the Animator Controller state machine

Section titled “4. Build the Animator Controller state machine”

Author the AnimatorController with a single base layer containing:

  • Float parameter Speed
  • Trigger parameter Attack
  • Trigger parameter Hit
  • Bool parameter IsDead
private static AnimatorController CreateController(string displayName, IReadOnlyDictionary<string, AnimationClip> clips)
{
string path = $"{ControllerDirectory}/{displayName}.controller";
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(path) != null)
{
AssetDatabase.DeleteAsset(path);
}
AnimatorController controller = AnimatorController.CreateAnimatorControllerAtPath(path);
controller.AddParameter("Speed", AnimatorControllerParameterType.Float);
controller.AddParameter("Attack", AnimatorControllerParameterType.Trigger);
controller.AddParameter("Hit", AnimatorControllerParameterType.Trigger);
controller.AddParameter("IsDead", AnimatorControllerParameterType.Bool);
AnimatorStateMachine machine = controller.layers[0].stateMachine;
// Locomotion 1D Blend Tree
AnimatorState locomotion = machine.AddState("Locomotion");
locomotion.motion = CreateLocomotionBlendTree(controller, clips);
machine.defaultState = locomotion;
// Action states
AnimatorState attack = machine.AddState("Attack");
attack.motion = clips["Punch_Jab"];
AnimatorState hit = machine.AddState("Hit");
hit.motion = clips["Hit_Chest"];
AnimatorState death = machine.AddState("Death");
death.motion = clips["Death01"];
// Transitions from AnyState
AddTriggerTransition(machine, attack, "Attack");
AddTriggerTransition(machine, hit, "Hit");
AnimatorStateTransition deathTransition = machine.AddAnyStateTransition(death);
deathTransition.hasExitTime = false;
deathTransition.duration = 0.08f;
deathTransition.canTransitionToSelf = false;
deathTransition.AddCondition(AnimatorConditionMode.If, 0f, "IsDead");
AddReturnTransition(attack, locomotion);
AddReturnTransition(hit, locomotion);
EditorUtility.SetDirty(controller);
return controller;
}

The locomotion blend tree maps normalized speed thresholds:

private static BlendTree CreateLocomotionBlendTree(AnimatorController controller, IReadOnlyDictionary<string, AnimationClip> clips)
{
var tree = new BlendTree
{
name = "Locomotion Blend Tree",
blendType = BlendTreeType.Simple1D,
blendParameter = "Speed",
useAutomaticThresholds = false
};
tree.AddChild(clips["Idle_Loop"], 0f);
tree.AddChild(clips["Walk_Loop"], 0.5f);
tree.AddChild(clips["Jog_Fwd_Loop"], 1f);
AssetDatabase.AddObjectToAsset(tree, controller);
return tree;
}

5. Assemble and save the presentation prefab

Section titled “5. Assemble and save the presentation prefab”

Instantiate the base Kenney mesh in memory, reassign its renderers to the project material, attach and configure the Animator component, set up the CapsuleCollider, and save to Assets/_Game/Resources/Prefabs/Enemies/:

private static void BuildEnemy(
string displayName,
string modelPath,
string skinPath,
Color tint,
IReadOnlyDictionary<string, AnimationClip> clips)
{
Material material = LoadOrCreateMaterial(displayName, skinPath, tint);
AnimatorController controller = CreateController(displayName, clips);
GameObject sourceModel = AssetDatabase.LoadAssetAtPath<GameObject>(modelPath);
GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(sourceModel);
try
{
instance.name = displayName;
foreach (Renderer renderer in instance.GetComponentsInChildren<Renderer>(true))
{
renderer.sharedMaterial = material;
}
Animator animator = instance.GetComponent<Animator>() ?? instance.AddComponent<Animator>();
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
animator.cullingMode = AnimatorCullingMode.CullUpdateTransforms;
CapsuleCollider collider = instance.GetComponent<CapsuleCollider>() ?? instance.AddComponent<CapsuleCollider>();
collider.center = new Vector3(0f, 0.9f, 0f);
collider.height = 1.8f;
collider.radius = 0.28f;
PrefabUtility.SaveAsPrefabAsset(instance, $"{PrefabDirectory}/{displayName}.prefab");
}
finally
{
UnityEngine.Object.DestroyImmediate(instance);
}
}

6. Verify deterministic surface spawn proposals

Section titled “6. Verify deterministic surface spawn proposals”

At runtime, enemies are not baked into the scene; they are planned by HostileSpawnPlanner.TryPlanSurfaceSpawn. The planner ensures:

  1. Daylight conditions allow spawning via HostileSpawnRules.CanAttemptSurfaceSpawn.
  2. Candidate coordinates fall between $18\text{ m}$ and $32\text{ m}$ from the player column.
  3. Candidate terrain is dry land above sea level and not carved by an active river.
  4. Hash parity determines whether a Shambler or Ghoul is requested:
EntityKind kind = (hash & 0x80000000u) == 0u ? EntityKind.Shambler : EntityKind.Ghoul;
candidate = new HostileSpawnCandidate(kind, worldX, surfaceHeight + 1, worldZ);

Open Tools → Voxel Sandbox → Voxel Workshop → Enemy Lab in Unity.

  1. Confirm source assets show green status (“Sources are ready”).
  2. Click Build / Replace Enemy Prefabs.
  3. Inspect Assets/_Game/Resources/Prefabs/Enemies/:
    • Shambler.prefab exists with assigned material, animator, and capsule collider.
    • Ghoul.prefab exists with matching structure.
  4. Select Shambler.prefab and open the Animator window: confirm the Locomotion Blend Tree links Idle_Loop, Walk_Loop, and Jog_Fwd_Loop.

Run the EditMode test fixture headlessly via bash to verify spawn planning logic:

Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.HostileSpawnPlannerTests

Confirm all tests pass:

  • TryPlanSurfaceSpawn_RejectsFullDaylight
  • TryPlanSurfaceSpawn_RejectsMissingGenerationSettings
  • TryPlanSurfaceSpawn_IsDeterministicAndUsesDryLand
  • TryPlanSurfaceSpawn_DoesNotChooseThePlayerColumn

To introduce an additional hostile visual archetype (e.g. Wraith or Dredge):

  1. Place source art in vendor quarantine: Add the character mesh FBX and skin PNG into Assets/ThirdParty/<Vendor>/. Never modify these files directly.
  2. Declare constants: Add the source paths and output prefab name to EnemyPresentationBuilder.
  3. Register clip requirements: If the archetype requires special animation takes (e.g. Crawl_Loop or Cast_Spell), ensure they exist in RequiredClipNames.
  4. Call BuildEnemy: Provide the archetype name, model path, skin path, and distinctive tint color.
  5. Register entity kind: Add the archetype to EntityKind in VoxelSandbox.World.Entities and assign spawn weights in HostileSpawnPlanner.
  6. Rebuild via EnemyLab: Open the EnemyLab module, click Build, and verify the resulting prefab in the Inspector.
Symptom Cause Fix
Mob slides or drifts rapidly during walk cycles. Root motion is enabled on the Animator component. Ensure animator.applyRootMotion = false. Physical locomotion must be driven by simulation velocities, not animation displacement curves.
Enemy prefab appears pink in Play Mode. Material shader defaulted to Built-in Standard instead of HDRP Lit. Verify the shader lookup finds HDRP/Lit (Shader.Find("HDRP/Lit")) and sets the _BaseColorMap texture property.
Animation clips fail to play or T-pose occurs. FBX model importer was left on Generic or Legacy animation type. Force ModelImporterAnimationType.Human with ModelImporterAvatarSetup.CreateFromThisModel on all models and animation sources.
Git shows unexpected modifications in Gameplay.unity. Someone dragged an enemy prefab into the scene hierarchy while testing. Revert Gameplay.unity. Hostile entities must be spawned programmatically at runtime from Resources/Prefabs/Enemies/.
Mobs spawn underwater or inside river gorges. Spawn planner neglected terrain height or river carve checks. Gate spawn coordinates against TerrainGenerator.GetSurfaceHeight > SeaLevel and !RiverField.TryGetRiverCarve.
Missing animation clip runtime exception. The FBX animation take was renamed or prepended with Armature|. Use FindRequiredAnimationClips() suffix matching (`clip.name.EndsWith($”

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…