Repository navigation
achievements
Achievements are goals the player unlocks during a run, such as clearing a dungeon or defeating 100 enemies. They are defined in JSON, built at startup, and updated by game events. When one unlocks, a toast appears on screen and it moves to the Unlocked column of the inventory's Achievements page.
Game event (enemy dies, gold changes, dungeon cleared...)
-> AchievementContext built by the code that saw the event
-> AchievementService.update(ctx)
-> every Achievement checks its own condition
-> on unlock: "achievementUnlocked" event with the achievement's name
-> RoomManager forwards it to GameAreaDisplay -> toastUnlocks when the given dungeon is completed. Needs dungeonId.
{ "type": "dungeonClear", "dungeonId": "dungeonOne", "name": "First Steps" }A dungeon is completed when the player uses an exit marked "completesDungeon": true, usually the bookshelf in the dungeon's last room.
Unlocks the first time the player enters the dungeon. Needs dungeonId.
{ "type": "dungeonReached", "dungeonId": "finalDungeon", "name": "The Final Showdown" }Unlocks after target kills. If enemyType is set, only that type counts. If it is omitted, any enemy counts.
{ "type": "enemyKillCount", "enemyType": "FINAL_BOSS", "target": 1, "name": "Vanquisher" }
{ "type": "enemyKillCount", "target": 100, "name": "Exterminator" }Progress goes up by 1 per matching kill.
Unlocks after target kills in a row with no damage taken. Taking damage resets progress to 0.
{ "type": "killStreak", "target": 5, "name": "On a Roll" }Unlocks once at least one enemy of every type in enemyTypes has been killed. Needs a non-empty enemyTypes list.
{ "type": "enemySet", "enemyTypes": ["BEETLE", "CRAB", "WASP", "MUMMY", "SNAKE_MINI_BOSS"], "name": "Monster Hunter: Dungeon One" }It tracks which types it has seen internally. The progress field is not used.
Unlocks if the dungeon is completed within maxSeconds. Needs dungeonId and maxSeconds. The type name is case-sensitive.
{ "type": "speedRun", "dungeonId": "dungeonOne", "maxSeconds": 60, "name": "Hermes" }The time is recorded when the player uses the exit. The achievement unlocks when the completion event follows. Progress holds the recorded time and starts at Float.MAX_VALUE, meaning "no time yet". The UI shows no fraction for this type, because lower is better.
Unlocks after the player has earned target gold. The first reading becomes the baseline, so starting gold doesn't count.
{ "type": "gold", "target": 100, "name": "Hoarder" }Progress is the gold gained since that first reading.
- Add any fields it needs to
AchievementConfig. - Add a field to
AchievementContextif the event it reacts to isn't already there, and set it where the event happens. - Write one builder method in
AchievementsFactoryand add acaseto the switch. - Add tests to
AchievementsFactoryTest.
Stateless conditions are a one-liner:
private static Achievement dungeonReached(AchievementConfig c) {
return new Achievement(c.name, ctx -> c.dungeonId.equals(ctx.dungeonEnteredId));
}Conditions that need to remember something use setCondition and the shared progress field:
private static Achievement enemyKillCount(AchievementConfig c) {
Achievement a = new Achievement(c.name);
a.setTarget(c.target);
a.setCondition(ctx -> {
if (ctx.enemyKilled == null) return false;
if (c.enemyType != null && ctx.enemyKilled != c.enemyType) return false;
a.addProgress(1);
return a.getProgress() >= c.target;
});
return a;
}Use setCondition when the condition has to refer to the achievement itself, because this isn't available while the constructor call is still running.