Skip to content

NPC Implementation

Adi edited this page Oct 6, 2026 · 1 revision

NPCs (Sprint 3)

Ticket: #244 (NPC implementation), #317 (unit tests) Feature: #186

I replaced the old Traveler NPC with 4 new NPCs. They all walk around, talk to you, and stop and face you when you get close. Two of them also fight back if you hit them.

NPC Where Map symbol What they do
Hermes Level 1 dungeon * Shopkeeper, press F near him to open the shop. Can't be killed
Wizard Level 1 Nether 2 Throws a poison potion at you if you hit him
Philosopher Level 2 base 1 Quest giver, can be killed
Satyr Level 2 skies 3 Headbutts you if you hit him

How the NPCs are built

All 4 NPCs go through the same methods in NPCFactory so they act the same way:

  • createAnimatedNPC - adds the walk/idle animations. I reused SkeletonAnimationController since it already flips between left and right
  • addFriendlyNpcParts - physics, dialogue, wandering, and standing still when you're near
  • makeKillable - gives health (50) so they can be killed. Hermes doesn't use this one

Each NPC picks what to do based on task priority (highest wins):

Task Priority When
PlatformWanderTask 1 always
StandStillTask 5 player is close enough to talk
RetaliateTask 10 NPC got hit and player is in range

So if you hit an NPC while talking to it, it switches to attacking straight away since 10 > 5.

Hermes' shop

Walk up to Hermes and press F, the shop opens. Walk away and it closes by itself. This is done in ShopkeeperComponent, it triggers an openShop event on the player that ShopDisplay listens for. I only had to add one line to the shop team's ShopDisplay for this.

Wizard's poison potion

If you hit the wizard he throws a potion at you. When it smashes and you're close to it (within 1.5 tiles), you get poisoned and lose half a heart every 2 seconds, 5 times. When you kill him he drops a coin and a regen potion.

Some rules:

  • he only throws once per fight, he calms down 8 seconds after your last hit
  • shields block the poison
  • getting hit again resets the poison instead of stacking it
flowchart LR
    A([Normal<br/>wander / talk])
    B([Throw potion<br/>arcs from above his head])
    C([Potion smashes<br/>ground or player])
    D{Player within<br/>1.5 tiles<br/>and no shield?}
    E([Poisoned<br/><i>5 damage every 2s × 5</i>])
    F([No effect])

    A -->|hit by player| B
    B --> C
    C --> D
    D -->|yes| E
    D -->|no| F
    B -->|back to normal<br/><i>no 2nd throw until he calms down, 8s</i>| A
Loading

Satyr's headbutt

If you hit the satyr he lowers his head for half a second (that's the warning), then charges at you. If he hits you, you lose 1 heart and get knocked back. Unlike the wizard he keeps doing it every 1.5 seconds until he calms down. He won't run off his platform and you can dodge him by jumping over.

flowchart LR
    A([Normal<br/><i>wander / talk</i>])
    B([Wind-up<br/><i>0.5 s warning</i>])
    C([Charge<br/><i>runs at player</i>])
    D([Headbutt<br/><i>1 heart + knockback</i>])
    E([Recover<br/><i>0.4 s</i>])

    A -->|hit by player| B
    B -->|after 0.5 s| C
    C -->|touches player| D
    C -->|misses / edge| E
    D --> E
    E -->|still angry,<br/>1.5 s break| B
    E -->|calms down<br/><i>8 s after last hit</i>| A
Loading

Main classes

Class What it does
ShopkeeperComponent F opens shop, walking away closes it
StandStillTask NPC stops and faces you while talking
ProvokedComponent notices when the NPC loses health. Can do one attack per fight or keep attacking
RetaliateTask starts the attack when the NPC is provoked and you're in range
PotionThrowComponent works out the arc and throws the potion
SplashPotionComponent the flying potion
SplashEffectComponent plays the splash/impact animation once then removes it
PoisonEffectComponent the poison damage over time + icon above your head
HeadbuttAttackComponent wind up, charge, hit, recover
SpinningTextureRenderComponent makes the potion spin
classDiagram
    StandStillTask <|-- RetaliateTask

    RetaliateTask ..> ProvokedComponent : checks
    RetaliateTask ..> PotionThrowComponent : triggers
    RetaliateTask ..> HeadbuttAttackComponent : triggers

    PotionThrowComponent ..> SplashPotionComponent : creates
    PotionThrowComponent ..> PoisonEffectComponent : creates
    PotionThrowComponent ..> SplashEffectComponent : creates

    class StandStillTask {
        stops and faces player
    }

    class RetaliateTask {
        starts attack when provoked
    }

    class ProvokedComponent {
        tracks NPC provocation
    }

    class PotionThrowComponent {
        throws poison potion
    }

    class SplashPotionComponent {
        flying potion
    }

    class PoisonEffectComponent {
        applies damage over time
    }

    class SplashEffectComponent {
        shows potion impact
    }

    class HeadbuttAttackComponent {
        wind-up, charge and hit
    }
Loading

Adding on your own

If you want it to fight back, add a ProvokedComponent, your own attack component, and a RetaliateTask with your attack's event name:

npc.addComponent(new ProvokedComponent(8f))
   .addComponent(new MyAttackComponent(player));
npc.getComponent(AITaskComponent.class)
   .addTask(new RetaliateTask(player, 10, range, "myAttack"));

Testing

Unit tests (14 tests):

  • ProvokedComponentTest - gets provoked when hit, not when healed, only one attack per fight, calms down, repeat attacks work
  • PoisonEffectComponentTest - right damage per tick, nothing before first tick, restart works, no damage when paused
  • RetaliateTaskTest - only attacks when provoked and in range
  • NPCFactoryTest - all 4 NPC names are registered

I also play tested everything in game:

  • all NPCs walk around and stop to talk
  • F opens and closes Hermes' shop
  • wizard potion splashes and poisons for 2.5 hearts, shield blocks it, drops loot after dying
  • satyr keeps headbutting and calms down after 8s

What changed from the plan

  • Traveler to 4 NPCs - the Traveler was hardcoded to one spot so it spawned in every level (even in mid air in level 2).
  • Straight spell to a thrown potion - my first version was a straight spell but it never hit anything similar to the arrow by the enemy. Turned out its hitbox was on a layer with no collisions. Changed it to a potion thrown from above his head in an arc, which looks better anyway.
  • Poison made slower - one tick per second felt too fast so I changed it to every 2 seconds, kind of like minecraft.
  • Satyr attacks more than once - only attacking once didn't really make sense for him, so he keeps going now but I lowered the damage from 15 to 10 so it's not too hard.

Known issues

  • F is now also the special attack key so it clashes with the shop.
  • NPCs respawn when you reload, so you could farm the wizard's loot.

Clone this wiki locally