Full Example
A step-by-step walkthrough that shows off everything the HyCitizens API can do. We'll build two citizens from scratch: a friendly townsfolk NPC packed with animations and interaction logic, and a fully configured boss enemy ready to make players sweat.
Before you start
All code below assumes you've already declared HyCitizens as a dependency and can access CitizensManager. If not, see the Developer API overview first.
CitizensManager manager = HyCitizensPlugin.get().getCitizensManager();
Part 1 — Amara the Merchant
Amara is a friendly shopkeeper who waves when players walk past, greets them when they interact, and hands them a starting item. She's passive, rotates to face nearby players, and plays idle and interact animations.
Step 1 — Core identity & position
Start by creating the CitizenData object and filling in the basics: name, world, position, and model.
CitizenData amara = new CitizenData();
// CitizenData creation with multi-line nametag — name on line 1, subtitle on line 2
CitizenData amara = new CitizenData(
"amara_merchant", // Unique ID
"Amara\nMerchant", // Name
"Player",
worldUuid,
new Vector3d(128, 64, 256), // Position
new Vector3f(0, 0, 0), // Rotation
1.0f, // scale
null,
new ArrayList<>(),
"",
"",
List.of(),
true,
false,
"tidy", // Player to use skin
null,
0L,
true // Rotate towards player
);
// Assign to a group for easy bulk queries later
amara.setGroup("merchants");
Step 2 — Equipped items
Give Amara something to hold so she looks the part.
// Merchant holds a potion in her hand
amara.setNpcHand("Potion_Regen_Health");
// Light traveling outfit
amara.setNpcHelmet("Armor_Leather_Light_Head");
amara.setNpcChest("Armor_Leather_Light_Chest");
Step 3 — Animations
We'll set up four animation rules: a continuous idle loop, a wave when a player walks nearby, and a jump when someone interacts, and a timed eat.
List<AnimationBehavior> animations = new ArrayList<>();
// 1. Looping idle base — always playing on slot 2
animations.add(new AnimationBehavior(
"DEFAULT",
"Idle", // continuous base pose
2, // Movement slot
0f, 0f
));
// 2. Wave when a player enters within 10 blocks — plays for 2.5 s then returns to Idle
animations.add(new AnimationBehavior(
"ON_PROXIMITY_ENTER",
"Wave", // full-body wave
2, // Action slot
0f,
10.0f, // proximity range: 10 blocks
true, // stop after time
"Idle", // return to Idle
2.5f
));
// 3. Jump on interact — plays for 1.8 s then returns to Idle
animations.add(new AnimationBehavior(
"ON_INTERACT",
"Jump",
2,
0f, 0f,
true,
"Idle",
1f
));
// 4. A periodic stretch every 25 seconds so she doesn't look frozen
animations.add(new AnimationBehavior(
"TIMED",
"Eat",
2,
25.0f, // every 25 seconds
0f,
true,
"Idle",
3.0f
));
amara.setAnimationBehaviors(animations);
Step 4 — Messages
Set up three messages that cycle through sequentially each time the player interacts — so she says something different on each visit.
List<CitizenMessage> messages = new ArrayList<>();
// Each message has a short delay so they stagger when selection mode is ALL,
// but in SEQUENTIAL mode only one fires per interaction.
messages.add(new CitizenMessage(
"{#FFD700}Hello, {PlayerName}! Looking for supplies?",
"BOTH", // fires on F key or left-click
0.0f
));
messages.add(new CitizenMessage(
"{WHITE}I just restocked — fresh goods from the eastern caravans.",
"BOTH",
0.0f
));
messages.add(new CitizenMessage(
"{#90EE90}Come back any time, {PlayerName}. Safe travels!",
"BOTH",
0.0f
));
MessagesConfig messagesConfig = new MessagesConfig();
messagesConfig.setMessages(messages);
messagesConfig.setSelectionMode("SEQUENTIAL"); // cycle through on each visit
messagesConfig.setEnabled(true);
amara.setMessagesConfig(messagesConfig);
Step 5 — Command actions
When a player interacts using the F key, Amara sends a warm follow-up message and then gives them a starter supply pack using a server command. The command runs 0.5 s after the message so the delivery feels natural.
List<CommandAction> actions = new ArrayList<>();
// A formatted chat message — no slash command needed, just the {SendMessage} prefix
// {SendMessage} is legacy since message actions were added, but it can still be used
actions.add(new CommandAction(
"{SendMessage}{GRAY}Here, take this — everyone needs a head start.",
false, // run as player (ignored for SendMessage)
0.3f, // 0.3 s delay
"F_KEY" // only on F key, not left-click
));
// Give the player a item via server command
actions.add(new CommandAction(
"give {PlayerName} Weapon_Sword_Steel",
true, // run as server
0.8f, // 0.8 s delay so it arrives after the message
"F_KEY"
));
amara.setCommandActions(actions);
Step 6 — Movement
Amara stays at her market stall — she uses IDLE movement so she won't wander off. The rotateTowardsPlayer flag we set earlier handles the head-turning.
MovementBehavior movement = new MovementBehavior();
movement.setType("IDLE");
amara.setMovementBehavior(movement);
Step 7 — Register & spawn
// addCitizen registers, spawns, and saves in one call
manager.addCitizen(amara, true);
Amara is live!
She'll idle at her stall, wave at players who come within 10 blocks, cycle through her greetings, and hand out a starter item on F key interaction.
Listening for Amara's interactions with an event
Let's also log every time a player talks to Amara, and block the interaction for players who have a "merchant-banned" flag — without touching the citizen config at all.
manager.addCitizenInteractListener(event -> {
CitizenData citizen = event.getCitizen();
// Only care about our merchant group
if (!"merchants".equals(citizen.getGroup())) return;
PlayerRef player = event.getPlayerRef();
// Example: block banned players
if (isMerchantBanned(player.getUuid())) {
event.setCancelled(true); // stops messages, commands, animations
player.sendMessage(Message.raw(
CitizenInteraction.parseColoredMessage(
"{RED}The merchants of this town will not deal with you."
)
));
return;
}
System.out.println("[HyCitizens] " + player.getUsername()
+ " spoke with " + citizen.getName());
});
Part 2 — Malgrath, the Ashen King (Boss)
Malgrath is a scaled-up boss enemy with full armor, an Adamantite sword, massive health and damage, wide detection, aggressive combat behavior, a patrol route through his lair, rich death drops, and a server-wide death broadcast hooked through an event listener.
Step 1 — Core identity
CitizenData malgrath = new CitizenData();
// Create CitizenData
CitizenData malgrath = new CitizenData(
"malgrath", // Unique ID
"Malgrath\nThe Ashen King", // Name
"Skeleton_Fighter", // Skeleton Figher model
worldUuid,
new Vector3d(512, 70, 512), // Spawn position
new Vector3f(0, 0, 0),
2.5f, // Scale
null,
new ArrayList<>(),
"",
"",
List.of(),
false,
false,
null,
null,
0L,
false
);
// Group tag for event filtering and bulk operations
malgrath.setGroup("world-bosses");
Step 2 — Equipped items
Full heavy armor set with an Adamantite sword. Item ID strings must match valid Hytale item IDs.
// Adamantite sword in main hand
malgrath.setNpcHand("Weapon_Longsword_Adamantite");
// Full Adamantite armor set
malgrath.setNpcHelmet("Armor_Adamantite_Head");
malgrath.setNpcChest("Armor_Adamantite_Chest");
malgrath.setNpcGloves("Armor_Adamantite_Hands");
malgrath.setNpcLeggings("Armor_Adamantite_Legs");
Step 3 — Attitude, health & damage
// Aggressive — attacks any player on sight
malgrath.setAttitude("AGGRESSIVE");
malgrath.setTakesDamage(true);
// 8,000 HP
malgrath.setOverrideHealth(true);
malgrath.setHealthAmount(8000f);
// 60 damage per hit
malgrath.setOverrideDamage(true);
malgrath.setDamageAmount(60f);
// Respawn 10 minutes after being killed
malgrath.setRespawnOnDeath(true);
malgrath.setRespawnDelaySeconds(600f);
Step 4 — Detection
Malgrath has an enormous detection radius — players can't sneak up on him.
DetectionConfig detection = new DetectionConfig(true); // start from hostile defaults
detection.setViewRange(50f); // sees players from 50 blocks away
detection.setViewSector(270f); // nearly 270° field of view
detection.setHearingRange(30f); // hears movement from 30 blocks
detection.setAbsoluteDetectionRange(5f); // always detects within 5 blocks, no line-of-sight needed
detection.setAlertedRange(60f); // alerts all nearby citizens within 60 blocks
// How long he stays on high alert and searches after losing a target
detection.setAlertedTimeMin(2.0f);
detection.setAlertedTimeMax(3.0f);
detection.setSearchTimeMin(20.0f);
detection.setSearchTimeMax(30.0f);
// 90% chance to respond to a call for help from other NPCs
detection.setChanceToBeAlertedWhenReceivingCallForHelp(90);
malgrath.setDetectionConfig(detection);
Step 5 — Combat
Aggressive pursuit, wide attack range, tight strafing, fast chasing, and a high block chance to make him feel dangerous.
CombatConfig combat = new CombatConfig();
// Attack
combat.setAttackType("Root_NPC_Attack_Melee");
combat.setAttackDistance(4.5f); // Increase attack distance
combat.setDesiredAttackDistanceMin(3.0f);
combat.setDesiredAttackDistanceMax(4.0f);
combat.setTargetRange(8.0f); // acquires targets within 8 blocks
// Attack timing — slow but devastating
combat.setAttackPauseMin(2.5f);
combat.setAttackPauseMax(3.5f);
combat.setCombatAttackPreDelayMin(0.4f); // longer wind-up telegraph
combat.setCombatAttackPreDelayMax(0.6f);
combat.setCombatAttackPostDelayMin(0.3f); // recovery after swing
combat.setCombatAttackPostDelayMax(0.5f);
// Chase — relentless
combat.setChaseSpeed(0.85f);
combat.setCombatBehaviorDistance(6.0f);
// Movement in combat — heavy strafing to make him feel weighty
combat.setCombatStrafeWeight(15);
combat.setCombatDirectWeight(8);
combat.setCombatStrafingDurationMin(1.5f);
combat.setCombatStrafingDurationMax(2.5f);
combat.setCombatStrafingFrequencyMin(1.0f);
combat.setCombatStrafingFrequencyMax(2.0f);
combat.setCombatRelativeTurnSpeed(1.2f);
combat.setCombatMovingRelativeSpeed(0.75f);
combat.setCombatBackwardsRelativeSpeed(0.35f);
// Back-off after each swing
combat.setBackOffAfterAttack(true);
combat.setBackOffDistance(5.0f);
combat.setBackOffDurationMin(1.5f);
combat.setBackOffDurationMax(2.5f);
// Blocking — 40% chance to block incoming player attacks
combat.setBlockAbility("Shield_Block_Heavy");
combat.setBlockProbability(40);
// Target switching — holds focus for 8–10 seconds before switching
combat.setTargetSwitchTimerMin(8.0f);
combat.setTargetSwitchTimerMax(10.0f);
// Enable advanced action evaluation for more dynamic AI
combat.setUseCombatActionEvaluator(true);
malgrath.setCombatConfig(combat);
Step 6 — Patrol path
Malgrath patrols a slow circuit around his throne room between fights. We build the path from code, register it, then assign it to the citizen via PathConfig so the assignment persists across restarts.
// Build the patrol route
PatrolPath throneCircuit = new PatrolPath("MalgrathThrone", PatrolPath.LoopMode.PING_PONG);
throneCircuit.addWaypoint(new PatrolWaypoint(512, 70, 512, 4.0f)); // throne — 4 s pause
throneCircuit.addWaypoint(new PatrolWaypoint(525, 70, 512, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(525, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(512, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(499, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(499, 70, 512, 1.0f));
// Register and save the path to disk
PatrolManager patrolManager = manager.getPatrolManager();
patrolManager.savePath(throneCircuit);
// Assign via PathConfig so the patrol persists after server restarts
PathConfig pathConfig = new PathConfig();
pathConfig.setFollowPath(true);
pathConfig.setPathName("MalgrathThrone");
pathConfig.setPatrol(true);
pathConfig.setLoopMode("PING_PONG");
malgrath.setPathConfig(pathConfig);
malgrath.setMovementBehavior(new MovementBehavior("PATROL", 6.0f, 0f, 0f, 0f));
Step 7 — Death configuration
On death, Malgrath drops legendary loot, sends a kill message to the player, and runs a broadcast command announcing the kill to the whole server.
DeathConfig deathConfig = new DeathConfig();
// Loot drops
deathConfig.setDropItems(List.of(
new DeathDropItem("Weapon_Longsword_Adamantite", 1),
new DeathDropItem("Ingredient_Bar_Adamantite", 25),
new DeathDropItem("Ingredient_Bone_Fragment", 50)
));
// Personal kill message — sent only to the killing player
deathConfig.setDeathMessages(List.of(
new CitizenMessage(
"{#FF8C00}You have slain {CitizenName}! The Ashen King falls.",
"BOTH",
0.0f
),
new CitizenMessage(
"{GRAY}Legendary loot has dropped at the kill site.",
"BOTH",
1.5f // stagger: second message arrives 1.5 s after the first
)
));
deathConfig.setMessageSelectionMode("ALL"); // send both messages
// Server-wide broadcast command
deathConfig.setDeathCommands(List.of(
new CommandAction(
"say {#FF2222}[World Boss] {PlayerName} has defeated {CitizenName} — claim the loot!",
true, // run as server
0.5f // slight delay so drops appear first
),
));
deathConfig.setCommandSelectionMode("ALL");
malgrath.setDeathConfig(deathConfig);
Step 8 — Register & spawn
manager.addCitizen(malgrath, true);
Malgrath is live!
He'll patrol his throne room, detect players from 50 blocks away, chase them down, and drop legendary loot on death — broadcasting the kill to the entire server.
Part 3 — Wiring Up Boss Events
Death configuration handles the standard drops and messages, but events let you go further — custom plugin logic, phase changes, or global game state changes — without touching the citizen's config at all.
Death event — awarding a title and triggering a server event
manager.addCitizenDeathListener(event -> {
CitizenData citizen = event.getCitizen();
// Only care about world bosses
if (!"world-bosses".equals(citizen.getGroup())) return;
PlayerRef killer = event.getKillerRef();
if (killer == null) return; // ignore non-player kills
// Award a persistent "Boss Slayer" achievement in your own plugin
YourPlugin.get().getAchievementManager()
.award(killer.getUuid(), "boss_slayer_" + citizen.getId());
// Trigger a server-wide event: open the boss chest room for 5 minutes
YourPlugin.get().getEventManager().startBossLootEvent(
citizen.getWorldUUID(),
citizen.getPosition(),
300 // seconds
);
System.out.println("[BossEvent] " + killer.getUsername()
+ " killed " + citizen.getName() + " — loot room opened for 5 minutes.");
});
Preventing the boss from dying below 20% health (phase trigger)
A classic boss mechanic: when Malgrath's health drops low, cancel the death, heal him partially, and trigger a rage phase instead.
// Track which bosses have already triggered their phase change
Set<String> phaseTriggered = new HashSet<>();
manager.addCitizenDeathListener(event -> {
// This must be ran in a world thread. For this example, it's not
CitizenData citizen = event.getCitizen();
if (!"world-bosses".equals(citizen.getGroup())) return;
if (phaseTriggered.contains(citizen.getId())) return; // phase already used
// Suppress the death — Malgrath doesn't go down that easily
event.setCancelled(true);
phaseTriggered.add(citizen.getId());
// Heal him to 20%
NPCEntity npcEntity = citizen.getNpcRef().getStore().getComponent(citizen.getNpcRef(), NPCEntity.getComponentType());
if (npcEntity != null) {
EntityStatMap statMap = npcEntity.getReference().getStore().getComponent(npcEntity.getReference(), EntityStatsModule.get().getEntityStatMapComponentType());
statMap.setStatValue(DefaultEntityStatTypes.getHealth(), 1600);
}
// Trigger a rage animation
manager.playAnimationForCitizen(citizen, "Emote_Rage", 2);
// Broadcast the phase change
CitizensManager mgr = HyCitizensPlugin.get().getCitizensManager();
// Update name to reflect the new phase
citizen.setName("{#FF2222}Malgrath \n{#FF0000}ENRAGED");
mgr.updateCitizenHologram(citizen, true);
System.out.println("[BossEvent] " + citizen.getName() + " entered rage phase!");
});
Gating Amara behind a quest requirement
Block interaction with the merchant until a player has completed a prerequisite quest — without changing her citizen config at all.
manager.addCitizenInteractListener(event -> {
if (!"merchants".equals(event.getCitizen().getGroup())) return;
PlayerRef player = event.getPlayerRef();
if (!YourPlugin.get().getQuestManager().hasCompleted(player.getUuid(), "prologue")) {
event.setCancelled(true);
player.sendMessage(CitizenInteraction.parseColoredMessage(
"{GRAY}She looks busy. Come back once you've spoken with the Village Elder."
));
}
});
Everything Combined
Here is everything from both citizens and all event listeners in a single ready-to-paste block, suitable for dropping into your plugin. Note: Spawn positions will need to be changed and Emotale from CurseForge is needed.
public void setupCitizens(UUID worldUUID) {
CitizensManager manager = HyCitizensPlugin.get().getCitizensManager();
// ──────────────────────────────────────────
// AMARA THE MERCHANT
// ──────────────────────────────────────────
CitizenData amara = new CitizenData(
"amara_merchant",
"Amara\nMerchant",
"Player",
worldUuid,
new Vector3d(128, 64, 256),
new Vector3f(0, 0, 0),
1.0f,
null,
new ArrayList<>(),
"",
"",
List.of(),
true,
false,
"tidy",
null,
0L,
true
);
amara.setGroup("merchants");
amara.setNpcHand("Potion_Regen_Health");
amara.setNpcHelmet("Armor_Leather_Light_Head");
amara.setNpcChest("Armor_Leather_Light_Chest");
amara.setAnimationBehaviors(List.of(
new AnimationBehavior("DEFAULT", "Idle", 0, 0f, 0f),
new AnimationBehavior("ON_PROXIMITY_ENTER", "Wave", 4, 0f, 10.0f, true, "Idle", 2.5f),
new AnimationBehavior("ON_INTERACT", "Jump", 4, 0f, 0f, true, "Idle", 1.8f),
new AnimationBehavior("TIMED", "Eat", 4, 25.0f, 0f, true, "Idle", 3.0f)
));
MessagesConfig amaraMessages = new MessagesConfig(List.of(
new CitizenMessage("{#FFD700}Hello, {PlayerName}! Looking for supplies?", "BOTH", 0f),
new CitizenMessage("{WHITE}I just restocked — fresh goods from the eastern caravans.", "BOTH", 0f),
new CitizenMessage("{#90EE90}Come back any time, {PlayerName}. Safe travels!", "BOTH", 0f)
), "SEQUENTIAL", true);
amara.setMessagesConfig(amaraMessages);
amara.setCommandActions(List.of(
new CommandAction("{SendMessage}{GRAY}Here, take this — everyone needs a head start.", false, 0.3f, "F_KEY"),
new CommandAction("give {PlayerName} Weapon_Sword_Steel", true, 0.8f, "F_KEY")
));
amara.setMovementBehavior(new MovementBehavior("IDLE", 0f, 0f, 0f, 0f));
manager.addCitizen(amara, true);
// ──────────────────────────────────────────
// MALGRATH THE ASHEN KING
// ──────────────────────────────────────────
CitizenData malgrath = new CitizenData(
"malgrath",
"Malgrath\nThe Ashen King",
"Skeleton_Fighter",
worldUuid,
new Vector3d(512, 70, 512),
new Vector3f(0, 0, 0),
2.5f,
null,
new ArrayList<>(),
"",
"",
List.of(),
false,
false,
null,
null,
0L,
false
);
malgrath.setGroup("world-bosses");
malgrath.setNpcHand("Weapon_Longsword_Adamantite");
malgrath.setNpcHelmet("Armor_Adamantite_Head");
malgrath.setNpcChest("Armor_Adamantite_Chest");
malgrath.setNpcGloves("Armor_Adamantite_Hands");
malgrath.setNpcLeggings("Armor_Adamantite_Legs");
malgrath.setAttitude("AGGRESSIVE");
malgrath.setTakesDamage(true);
malgrath.setOverrideHealth(true);
malgrath.setHealthAmount(8000f);
malgrath.setOverrideDamage(true);
malgrath.setDamageAmount(60f);
malgrath.setRespawnOnDeath(true);
malgrath.setRespawnDelaySeconds(600f);
DetectionConfig detection = new DetectionConfig(true);
detection.setViewRange(50f);
detection.setViewSector(270f);
detection.setHearingRange(30f);
detection.setAbsoluteDetectionRange(5f);
detection.setAlertedRange(60f);
detection.setAlertedTimeMin(2.0f); detection.setAlertedTimeMax(3.0f);
detection.setSearchTimeMin(20.0f); detection.setSearchTimeMax(30.0f);
detection.setChanceToBeAlertedWhenReceivingCallForHelp(90);
malgrath.setDetectionConfig(detection);
CombatConfig combat = new CombatConfig();
combat.setAttackType("Root_NPC_Attack_Melee");
combat.setAttackDistance(4.5f);
combat.setDesiredAttackDistanceMin(3.0f);
combat.setDesiredAttackDistanceMax(4.0f);
combat.setTargetRange(8.0f);
combat.setAttackPauseMin(2.5f);
ombat.setAttackPauseMax(3.5f);
combat.setCombatAttackPreDelayMin(0.4f);
combat.setCombatAttackPreDelayMax(0.6f);
combat.setCombatAttackPostDelayMin(0.3f);
combat.setCombatAttackPostDelayMax(0.5f);
combat.setChaseSpeed(0.85f);
combat.setCombatBehaviorDistance(6.0f);
combat.setCombatStrafeWeight(15);
combat.setCombatDirectWeight(8);
combat.setCombatStrafingDurationMin(1.5f);
combat.setCombatStrafingDurationMax(2.5f);
combat.setCombatStrafingFrequencyMin(1.0f);
combat.setCombatStrafingFrequencyMax(2.0f);
combat.setCombatRelativeTurnSpeed(1.2f);
combat.setCombatMovingRelativeSpeed(0.75f);
combat.setCombatBackwardsRelativeSpeed(0.35f);
combat.setBackOffAfterAttack(true);
combat.setBackOffDistance(5.0f);
combat.setBackOffDurationMin(1.5f);
combat.setBackOffDurationMax(2.5f);
combat.setBlockAbility("Shield_Block_Heavy");
combat.setBlockProbability(40);
combat.setTargetSwitchTimerMin(8.0f);
combat.setTargetSwitchTimerMax(10.0f);
combat.setUseCombatActionEvaluator(true);
malgrath.setCombatConfig(combat);
PatrolPath throneCircuit = new PatrolPath("MalgrathThrone", PatrolPath.LoopMode.PING_PONG);
throneCircuit.addWaypoint(new PatrolWaypoint(512, 70, 512, 4.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(525, 70, 512, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(525, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(512, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(499, 70, 525, 1.0f));
throneCircuit.addWaypoint(new PatrolWaypoint(499, 70, 512, 1.0f));
manager.getPatrolManager().savePath(throneCircuit);
PathConfig pathConfig = new PathConfig();
pathConfig.setFollowPath(true);
pathConfig.setPathName("MalgrathThrone");
pathConfig.setPatrol(true);
pathConfig.setLoopMode("PING_PONG");
malgrath.setPathConfig(pathConfig);
malgrath.setMovementBehavior(new MovementBehavior("PATROL", 6.0f, 0f, 0f, 0f));
DeathConfig deathConfig = new DeathConfig();
deathConfig.setDropItems(List.of(
new DeathDropItem("Weapon_Longsword_Adamantite", 1),
new DeathDropItem("Ingredient_Bar_Adamantite", 25),
new DeathDropItem("Ingredient_Bone_Fragment", 50)
));
deathConfig.setDeathMessages(List.of(
new CitizenMessage("{#FF8C00}You have slain {CitizenName}! The Ashen King falls.", "BOTH", 0.0f),
new CitizenMessage("{GRAY}Legendary loot has dropped at the kill site.", "BOTH", 1.5f)
));
deathConfig.setMessageSelectionMode("ALL");
deathConfig.setDeathCommands(List.of(
new CommandAction("say {#FF2222}[World Boss] {PlayerName} has defeated {CitizenName}!", true, 0.5f)
));
deathConfig.setCommandSelectionMode("ALL");
malgrath.setDeathConfig(deathConfig);
manager.addCitizen(malgrath, true);
// ──────────────────────────────────────────
// EVENT LISTENERS
// ──────────────────────────────────────────
// Gate Amara behind prologue quest completion
manager.addCitizenInteractListener(event -> {
if (!"merchants".equals(event.getCitizen().getGroup())) return;
PlayerRef player = event.getPlayerRef();
if (!YourPlugin.get().getQuestManager().hasCompleted(player.getUuid(), "prologue")) {
event.setCancelled(true);
player.sendMessage(CitizenInteraction.parseColoredMessage(
"{GRAY}She looks busy. Come back once you've spoken with the Village Elder."
));
}
});
// Boss death: award achievement, open loot room, phase-change logic
Set<String> phaseTriggered = new HashSet<>();
manager.addCitizenDeathListener(event -> {
CitizenData citizen = event.getCitizen();
if (!"world-bosses".equals(citizen.getGroup())) return;
PlayerRef killer = event.getKillerRef();
// Phase change: cancel first death and trigger rage
if (!phaseTriggered.contains(citizen.getId())) {
event.setCancelled(true);
phaseTriggered.add(citizen.getId());
manager.playAnimationForCitizen(citizen, "Emote_Rage", 2);
citizen.setName("{#FF2222}☠ Malgrath ☠\n{#FF0000}ENRAGED\n{GRAY}World Boss");
manager.updateCitizenHologram(citizen, true);
return;
}
// Second death — the real kill
phaseTriggered.remove(citizen.getId()); // reset for next spawn
if (killer == null) return;
YourPlugin.get().getAchievementManager()
.award(killer.getUuid(), "boss_slayer_" + citizen.getId());
YourPlugin.get().getEventManager().startBossLootEvent(
citizen.getWorldUUID(), citizen.getPosition(), 300
);
});
}