Не так давно на Хабре поднимался вопрос о проектировании системы достижений для игры. В комментах шло бурное и плодотворное обсуждение различных вариантов. Тогда мы уже тестировали нашу игру, готовились к релизу и поучаствовать в дебатах я не смог. Но увидев топик сразу же подумал: «У нас же есть как раз такая работающая система. Почему бы о ней не рассказать?». Подумал и записал в todo-list. Сегодня настало время рассказать, как это работает в нашем игровом проекте Linderdaum Puzzle.
Ачивмент — это такая прямоугольна медалька, которой награждается пользователь за выполнение каких-то действий. В Linderdaum Puzzle таких медалек около сотни. Вот пример, как это выглядит в UI:
Несколько мыслей:
- С каждой ачивкой связана либо последовательность действий (собрать сколько-то картинок, провести сколько-то времени и т.п), либо событие (собрать картинку быстрее чем за 5 секунд, сходит на фейсбук и т.п.).
- У некоторых ачивок есть поясняющий текст, показывающий текущий прогресс на пути к этой ачивке.
- Бывают секретные ачивки, которые не видны, пока их не получишь.
- Бывают ачивки, недоступные в бесплатной версии игры. :)
- Ачивки нужно беречь, чтобы пользователь не потерял свои достижения.
Начинаем кодить. Для начала заводим здоровенный enum, в котором перечислим всё, что у нас есть,
enum LAchievement
{
LA_SUPPORTER = 0,
LA_REVIEWER,
LA_MONTHLING,
LA_CASUAL,
LA_ENTHUSIAST,
LA_FANATIC,
LA_PUZZLENEWBIE3X3,
// ...
// много-много таких строк, они все здесь
};
В новых версиях игры можно будет свободно добавлять новые ачивки в конец списка. А вот менять порядок в уже зарелизенной версии нельзя. Думаю понятно почему.
Объявим два типа:
typedef bool (*HasAchievementProc)(void); // проверяет, получен ли ачивмент
typedef LString (*GetNoteProc)(void); // даёт инфо-текст, например, "собрано 99 картинок"
Для определения, секретный ачивмент или нет, определим вот такой тип:
enum AchievementVisibility
{
L_VIS,
L_HID,
};
Понятно, что можно было обойтись просто bool-ом (и так и было в самом начале), но в процессе разработки от bool-a отказались, потому что при заполнении констант в таблице ачивок от различных булов стало рябить в глазах.
Описание одного ачивмента в конечном итоге стало выглядить вот как:
struct sAchievement
{
int FID; // закастованный LAchievement
bool FPaidVersion; // доступен только в платной версии?
const char* FName; // текстовое название, которое видит пользователь
const char* FDescription; // описание, которое тоже видит пользователь
HasAchievementProc FProc;
AchievementVisibility FHidden;
const char* FProgressNote; // шаблон строка с подсказкой, например "%s solved"
GetNoteProc FNoteProc;
bool FShowNoteAfterAwarding; // показывать ли подсказку и после получений этой ачивки
// Дальше идут поля, в которых кэшируется всякая полезность.
// Чтобы не тратить время на постоянные поиски.
// generated at runtime
iGUIView* FViewPlate;
iGUIView* FViewNote;
clCVar* FAwarded;
};
Дальше начинается креативная работа придумывания самих ачивок и monkey-работа по заполнению огромной таблицы из элементов sAchievement. Это сердце всей нашей системы достижений. Вот несколько строк из неё:
static sAchievement Achievements[] =
{
{ LA_SUPPORTER, false, "Supporter", "Purchased Linderdaum Puzzle HD", &Check_Supporter, L_VIS, NULL },
{ LA_REVIEWER, false, "Reviewer", "Added a review on Google Play", &Check_Reviewer, L_VIS, NULL },
{ LA_MONTHLING, false, "Month's campaign", "Used the game for one month", &Check_Monthling, L_VIS, "%s days", &Get_DaysSinceFirstUse, true },
{ LA_CASUAL, false, "Casual", "Spent half an hour in game", &Check_Casual, L_VIS, "%s minutes", &Get_MinutesInGame, false },
{ LA_ENTHUSIAST, false, "Enthusiast", "Spent 2 hours in game", &Check_Enthusiast, L_VIS, "%s minutes", &Get_MinutesInGame, false },
{ LA_FANATIC, true, "Fanatic", "Spent 10 hours in game", &Check_Fanatic, L_VIS, "%s hours", &Get_HoursInGame, false },
// ...
// много-много таких строк, они все здесь
}
Функции Check_* выполняют проверку условий для получения ачивок типа «последовательность действий». Типичное содержаение такой функции:
bool Check_Monthling()
{
LDate FirstRun = LDate( FirstRunDate.GetString() );
LDate Today;
int Days = Today-FirstRun;
return Days >= 30;
}
Стоит обратить внимание, что для ачивок типа «одиночное событие» такие функции не нужны и в таблице для них стоит NULL. Постановка таких ачивок в очередь на награждение осуществляется прямо в игровом коде:
if ( Time < 5.0 ) g_Achievements->Award( LA_BLINKOFANEYE );
Ещё вы наверняка заметили, что есть FProgressNote и FNoteProc. Почему нельзя было обойтись только одной FNoteProc и возвращать из неё сразу фразу? Всё просто. Для того, чтобы сделать локализацию фразы на текущий язык. Шаблон локализуется, а потом в него подставляется строка-число, которая возвращается из FNoteProc.
Теперь всё готово, чтобы вдохнуть жизнь в статичные данные. Для этого нужно ещё чуток попрограммировать. Нам нужен менеджер ачивментов и менеджер UI для ачивментов. Давайте разберёмся, что они делают.
class clAchievementsManager: public iObject
{
public:
// тут немного поскипано
//
// clAchievementsManager
//
/// trigger the award for a one-time achievement
virtual void Award( LAchievement Achievement );
virtual void AwardName( const LString& AchievementName );
virtual bool IsAwarded( LAchievement Achievement ) const;
/// called automatically every 6 seconds or so to check new achievements
virtual void ProcessAchievements();
virtual void RecheckAchievements();
// тут ещё немного поскипано - код для сохранения ачивок
public:
std::deque<LAchievement> FPendingAwards;
iGUIView* FAchievementsText;
mlNode* FNode_Awarded;
};
ProcessAchievements() вызывается раз в 6 секунд и раздаёт слонов медальки. Достигается это вот таким вызовом:
Env->SendAsyncCapsule( BindCapsule( &clAchievementsManager::ProcessAchievements, this ), 6.0 );
Внутри примерно вот такой код (немного поскипано):
void clAchievementsManager::ProcessAchievements()
{
// save gamestate
// ...
RecheckAchievements();
// check achievements once in a while
Env->SendAsyncCapsule( BindCapsule( &clAchievementsManager::ProcessAchievements, this ), 6.0 );
// nothing new to award
if ( FPendingAwards.empty() ) return;
LAchievement A = FPendingAwards.front();
FPendingAwards.pop_front();
// this achievement had been awarded long time ago
if ( Achievements[ A ].FAwarded->GetBool() ) return;
Achievements[ A ].FAwarded->SetBool( true );
// don't lose achievements in case of crash
g_Game->SaveAchievements( g_SaveAchievementsFileName );
// show nice message here
Env->Renderer->GetCanvas()->AnnounceObject( Construct<clAchievementAnnouncer>( Env, A, FNode_Awarded ), 0.0, 5.0 );
clPuzzl_AchievementsContainer* C = Env->GUI->FindView<clPuzzl_AchievementsContainer>("AchievementsContainer");
// update UI
if ( C ) C->RecreateSubViews();
}
Ничего сложного. Просто проверка условий и раздача ачивок типа «событие» из очереди, в которую из ставит метод Award(). Класс clAchievementAnnouncer рисует красивую табличку поверх всего UI, наподобии вот такой:
Обращу внимание, что игра сохраняется тоже раз в 6 секунд — мы не хотим, чтобы пользователь потерял свой прогресс.
Метод RecheckAchievements() обновляет UI с таблицей всех ачивок, который был на первом скриншоте. Непосредственно управлением UI занимается класс clPuzzl_AchievementsContainer, который будет очень специфичен в зависимости от вашей системы UI. У нас он просто заполняет плашки с кубками (опять см. первой скриншот).
Postmortem
Игра релизнута, система ачивок работает хорошо. У нас есть возможность трэкать статистику авивок через Flurry и наблюдать сколько и каких ачивок получено. Это помогает оттачивать баланс. Для более сложной игры помощь от такого фидбэка будет сложно переоценить.
Из того, что хотелось сделать, но пока не успели:
- Повысить виральность ачивок, путём вывода сообщения в Твиттер пользователя. Например, как это делает Osmos, FourSquare.
- Сохранять ачивки не локально, а в облаке в аккаунте пользователя. Здесь стоит попробовать Google App Engine или какие-то подобные сервисы. Туда же можно сохранять и состояние игры. Это особенно важно при сборке паззлов большого размера, когда на одну картинку можно потратить пару часов.
P.S. Игра сделана на движке Linderdaum Engine.
Автор: CorporateShark