Система достижений (achievements) в Linderdaum Puzzle

в 8:32, , рубрики: game development, linderdaum, планшеты, Разработка под android, метки: ,

Не так давно на Хабре поднимался вопрос о проектировании системы достижений для игры. В комментах шло бурное и плодотворное обсуждение различных вариантов. Тогда мы уже тестировали нашу игру, готовились к релизу и поучаствовать в дебатах я не смог. Но увидев топик сразу же подумал: «У нас же есть как раз такая работающая система. Почему бы о ней не рассказать?». Подумал и записал в todo-list. Сегодня настало время рассказать, как это работает в нашем игровом проекте Linderdaum Puzzle.

Ачивмент — это такая прямоугольна медалька, которой награждается пользователь за выполнение каких-то действий. В Linderdaum Puzzle таких медалек около сотни. Вот пример, как это выглядит в UI:

image

Несколько мыслей:

  • С каждой ачивкой связана либо последовательность действий (собрать сколько-то картинок, провести сколько-то времени и т.п), либо событие (собрать картинку быстрее чем за 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, наподобии вот такой:

image

Обращу внимание, что игра сохраняется тоже раз в 6 секунд — мы не хотим, чтобы пользователь потерял свой прогресс.

Метод RecheckAchievements() обновляет UI с таблицей всех ачивок, который был на первом скриншоте. Непосредственно управлением UI занимается класс clPuzzl_AchievementsContainer, который будет очень специфичен в зависимости от вашей системы UI. У нас он просто заполняет плашки с кубками (опять см. первой скриншот).

Postmortem

Игра релизнута, система ачивок работает хорошо. У нас есть возможность трэкать статистику авивок через Flurry и наблюдать сколько и каких ачивок получено. Это помогает оттачивать баланс. Для более сложной игры помощь от такого фидбэка будет сложно переоценить.

Из того, что хотелось сделать, но пока не успели:

  • Повысить виральность ачивок, путём вывода сообщения в Твиттер пользователя. Например, как это делает Osmos, FourSquare.
  • Сохранять ачивки не локально, а в облаке в аккаунте пользователя. Здесь стоит попробовать Google App Engine или какие-то подобные сервисы. Туда же можно сохранять и состояние игры. Это особенно важно при сборке паззлов большого размера, когда на одну картинку можно потратить пару часов.

P.S. Игра сделана на движке Linderdaum Engine.

Автор: CorporateShark

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js