Главная > Apple, Coding, Творчество > MacMines: Делаем игру под Mac OS X

MacMines: Делаем игру под Mac OS X

Помимо FAR Manager, еще одной программой, которой мне очень не хватало в Mac OS X, была игра Сапер. Я долго искал по сети нормальную реализацию, но не нашел. Под нормальной реализацией я подразумеваю пример Windows-сапера, вероятно, потому что долгое время им пользовался, а привычка — вторая натура. Во многих блогах я встречал хныканья бывших виндоводов, на тему того, что им не хватает той или иной программы из мира Windows. Встречались и стенания по поводу Сапера. Надо сказать, что первое приложение, которое я предпочитаю писать в качестве разминки, изучая новую для себя платформу программирования — это Сапер. Он позволяет отработать основные навыки — работа с пользовательским интерфейсом, обработка событий системы, таймер, работа с графикой, работа с файлами. Ну и плюс, конечно, набивается рука на синтаксисе. Таким образом MacMines (такое имя я решил дать проекту) стала первым моим приложением, написанным для платформы Mac OS X. Сейчас я расскажу, что интересного я узнал в ходе ее создания.

И так, запускаем xCode, идем в меню File -> New Project, появляется окно визарда, выбираем в нем платформу Mac OS X, Cocoa Appliation, нажимаем Choose, вводим имя проекта HelloCocoaWorld.app — это будет заготовка для Сапера, который к слову выглядит так:


Пример из статьи можно скачать в конце. И так, открывается xCode-окно проекта. В левом сайдбаре находится дерево проекта. Здесь можно найти все, что имеет отношение к проекту — файлы классов, дополнительные ресурсы (картинки, звуки и т.д.), используемые фреймворки (можно подключать опционально в зависимости от нужд приложения), конфигурационные файлы свойств (properties list), лицензии, в общем все, и все это будет использовано при компиляции приложения. Cocoa-приложения билдятся (от слова build) в так называемый bundle (банлд). Это папка с расширением .app, в которой находятся исполняемый файл (executable), и рядом все необходимое (все, что находится в дереве проекта, ну кроме, пожалуй, исходников). После того, как визард создал нам шаблон приложения, его уже можно сбилдить и запустить, в итоге чего получим пустую форму, и больше ничего. Сейчас попробуем воспроизвести все те хитрости, которые мне встретились на пути создания Сапера для Mac в нашем тестовом приложении, заодно получится вроде как мануал для начинающих. Да и по сути для себя самого. Уже не раз мне доводилось сталкиваться с ситуацией, когда после длительного неиспользования платформы, сразу не можешь вспомнить элементарных вещей, например, как создать пустой проект и запустить его. И так. Для начала стоит упомянуть один мой крупный фейл. Дело в том, что Cocoa-Framework придерживается концепции Model-View-Controller, я уже упоминал в блоге этот паттерн программирования, но на всякий случай кратко опишу. Вся работа пользовательского интерфейса в Mac OS X построена на данной схеме. Суть в том, что весь код делится на модули трех типов — это модель-представление-контроллер. Представление никакого понятия не должно иметь о модели, оно просто должно заниматься отображением данных. Модель реализует структуру системы, бизнес-логику. Контроллер является промежуточным звеном, связывающим модель и представления, а также обрабатывая пользовательский ввод.

Таким образом модель и представление могут существовать независимо друг от друга. Можно от проекта оторвать представление и подключить в другой модели, или наоборот, подключить другую модель к проекту. Более того, допустимо использовать несколько представлений с одной моделью (например, разные типы чартов).

Если у вас не получается отделить модель от проекта, значит у вас где-то ошибка в архитектуре. Так вот, в сапере я допустил ряд архитектурных ошибок, но это заметил уже только в конце разработки. Ну да ладно, простим себе небольшой косяк, это же все-таки было первое приложение. В посте я буду писать, как нужно делать правильно, красиво, чтоб не стыдно было показать). По сути задача контроллера весьма примитивна — получить данные от пользователя и передать их в модель, получить данные от модели и передать их в представление (показать пользователю). Начиная с Mac OS X 10.3 Panther существует механизм Cocoa Bindigs, сущность которого как раз в этом и заключается. CocoaFramework содержит целый набор классов, упрощающих разработку в MVC-стиле, такие, как NSController, NSObjectController, NSArrayController, NSUserDefaultsController, NSTreeController. В рамках этой статьи, к сожалению их все не получится рассмотреть, но, надеюсь в будущем еще вернусь к этому вопросу. Пока что используем в качестве контроллера класс NSResponder. Добавляем в проект новый файл. Добавлять будем Objective-C класс, поэтому разумнее добавлять в группу Other Sources. Кликаем правой кнопкой Add->New File

Назовем новый класс HelloController (проверим, чтобы стояла галочка Also create HelloController.h). Теперь пора запустить Interface Builder. Как следует из названия — это программа для построения пользовательского интерфейса. Проще всего ее запустить, кликнув дважды на файле MainMenu.xib, который находится в дереве проекта. Следует заметить, что почти во всех средах с визуальной разработкой, где мне доводилось работать, схема инструментов для этой самой визуальной разработки примерно одинакова, что касается интерфейса. Вот и в Interface Builder имеется визуальное представление окна нашего приложения, палитра компонентов (Library), и инспектор объектов (Inspector). Эти инструменты доступны через меню Tools.

Я обычно в рабочем пространстве держу инспектора, библиотеку, и браузер объектов xib-файла (не знаю, если честно, как эта штука правильно называется — аналог Object TreeView в Delphi)

На скриншоте видно, что я уже добавил компонент типа NSButton (кнопка) и присвоил заголовок Start timer. Находим в библиотеке нужный компонент (можно воспользоваться формой быстрого поиска) и перетаскиваем его на форму. Размещаем в нужном месте и в инспекторе правим поле Title. Сохраняем  наш xib-файл, и можем посмотреть, как это будет выглядеть в реале. Для этого можно выбрать меню File ->Simulate Interface в интерфейс-билдере. По правде сказать, мне эта фича кажется совсем бесполезной. Кстати, ранее xib файлы назывались nib. В любом случае, последние две буквы означают Interface Builder. И так, кнопку налепили, она нажимается, но где отловить ее нажатие? Правильный ответ — в контроллере. Класс контроллера у нас в проекте уже есть, нужно добавить его представление в xib-файл. Для этого находим в в палитре компонент NSObject и перетягиваем его в документ (браузер объектов, как я назвал его в первый раз). Выделяем его там и переходим в окно инспектора. Там в выпадающем списке Сlass выбираем наш класс HelloController.

Теперь возвращаемся в xCode, открываем HelloController.h и добавляем строчку

 -(IBAction) btnTimerPressed :(id) sender;

Это объявление метода — обработчика события нажатия кнопки. В файле HelloController.m добавляем реализацию:

 -(IBAction) btnTimerPressed :(id) sender { NSLog(@"Timer button pressed"); }

Пока что она просто выводит отладочное сообщение в Debug Console — очень полезная вещь. Можно запустить общую консоль для всей операционной системы (/Application/System Tools/Console), либо консоль xCode‘а, которая показывает сообщения только для отлаживаемого приложения, то есть для нашего. Очень часто бывает так, что лениво делать классическую отладку — ставить точки останова, трейсить код. Вместо этого можно расставить контрольные точки по коду в виде вызовов NSLog, выводя отладочную информацию в консоль. На мой взгляд, порой это более наглядно, чем пошаговая отладка. И так, мы описали обработчик нажатия кнопки, но как его связать с xib-файлом? Вообще, любая связь между xib и программным кодом осуществляются через так называемые Outlet’ы — свойства или методы интерфейса, которые обозначаются специально отведенными зарезервированными словами. Например, метод btnTimerPressed возвращает тип данных IBAction. Interface Builder на основании этого может сделать вывод, что данный метод содержит сигнатуру обработчика нажатия кнопки. Поэтому данный метод виден в списке Outlet’ов объекта HelloController. Открываем IB, кликнем на нем в Doc Window, затем открываем Connections Inspector и видим, что в списке Received Actions есть наш метод btnTimerPressed.

Делаем так, чтобы наша форма была рядом с окном инспектора связей. Кликаем кружочек рядом с btnTimerPressed, и, не отпуская кнопки, тянем связь до нашей кнопки на форме.

После этого кружочек справа от btnTimerPressed становится закрашенным — это значит, связь установлена. Проверим. Запускаем приложение, запускаем системную консоль, нажимаем Start timer и видим:

Видим куски запуска какого-то сборщика мусора и последней строчкой наше сообщение из обработчика. Усложним. Пусть при нажатии кнопка меняет текст с «Start timer» на «Stop timer» и обратно. Для этого задействуем свойство Tag у NSButton. Это свойство унаследовано от класса NSControl, от которого также унаследованы все элементы пользовательского интерфейса. Это свойство удобно использовать, если есть необходимость обрабатывать событие от разных контролов в одном методе. Классический пример — клавиатура калькулятора. В данном случае, разумно написать один обработчик на все кнопки, а у кнопок установить свойство Tag равным номиналу кнопки. Если вы заметили, у метода-обработчика есть параметр вызова (id) sender — в нем передается ссылка на объект, который послал сообщение. В данном случае этот объект — кнопка.

 -(IBAction) btnTimerPressed :(id) sender {
   NSLog(@"Timer button pressed. Tag=%d", [sender tag]); [sender setTag:abs([sender tag] - 1)];
 }

Запустим программу (Cmd-Return) и в этот раз консоль xCode (Cmd-Shift-R). Понажимаем кнопку Start timer несколько раз и увидим, значение свойства Tag отправителя меняется:

Как же нам поменять текст кнопки? Добавляем строчку

-(IBAction) btnTimerPressed :(id) sender {
    NSLog(@"Timer button pressed. Tag=%d", [sender tag]);
    [sender setTag:abs([sender tag] - 1)];
    [sender setTitle: ([sender tag] ==0)?@"Start timer":@"Stop timer"];
}

Запускаем — должно работать. То есть идея такая: если Tag == 0, устанавливаем значение «Start timer», если не ноль — «Stop timer». Стоит заметить, что концепция тегов встречается во многих платформах. В частности в моем любимом Delphi)) Все, с этим разобрались, едем дальше. Чтоб еще такого сделать? Добавим таймер — очень часто бывает нужен. Добавляем в HelloController.h объявление instance-свойства

 NSTimer * helloTimer;

а в реализацию

-(void) timerEvent: (NSTimer *) theTimer { NSLog(@"timer event"); }
-(void) awakeFromNib {
    NSLog(@"awakeFromNib");
    helloTimer = [NSTimer scheduledTimerWithTimeInterval: 0.1 target:self selector:@selector(timerEvent:) userInfo:nil repeats: YES];
}

Поскольку в xib-файле присутствует объект интерфейса HelloController, при загрузке этого xib’а обекту HelloController будет послано сообщение awakeFromNib. Если в интерфейсе реализован данный метод, он будет вызван. У нас этот метод реализован, и внутри мы создаем таймер с периодом в 0.1 секунды, повторяющийся с методом timerEvent в качестве callback. Запускаем — видим в консоли 2011-02-09 00:22:20.250 HelloCocoaWorld[3702:10b] timer event

2011-02-09 00:22:20.350 HelloCocoaWorld[3702:10b] timer event

2011-02-09 00:22:20.450 HelloCocoaWorld[3702:10b] timer event …

Работает. Но почему 0.1 секунды? Ведь в сапере таймер отмеряет секунды! Объясню чуть позже, а пока будем обрабатывать каждое десятое событие таймера, а в остальных случаях сразу выходим из сallback. Объявим instance-переменные int ticksCount, и int seconds, и перепишем два последних метода

-(void) timerEvent: (NSTimer *) theTimer {
    if (++ticksCount != 10) return;
    ticksCount = 0; NSLog(@"timer event. sec=%d", seconds++);
}
-(void) awakeFromNib {
    NSLog(@"awakeFromNib");
    ticksCount = 0;
    seconds = 0;
    helloTimer = [NSTimer scheduledTimerWithTimeInterval: 0.1 target:self selector:@selector(timerEvent:) userInfo:nil repeats: YES];
}

Запускаем — похоже на правду, тики проходят раз в секунду. Что дальше? Займемся отрисовкой таймера. Для этого возьмем png-файл с цифрами

и добавим его к проекту. Как обычно, в дереве проектов на группе Resources -> Add -> Existing File Далее хорошим тоном программирования будет создать отдельный класс представления для таймера. Кликаем правой кнопкой в дереве проекта на Other Classes, Add -> New File, выбираем группу Cocoa и в ней выбираем Objective-C NSView subview class. Назовем наш класс CounterView — это будет класс для отрисовки таймера. Переключимся в Interface Builder и перетащим из библиотеки компонент Custom View — в этом View будет производится отрисовка таймера.

Поскольку наш таймер трехразрядный, а размер одной цифры — 13х23 пикселя, то можно смело задать размер нашего компонента как 39х23. Для этого вызовем Tools -> Size Inspector и пропишем нужные значения в поля W и H.

Откроем Tools->Identity Inspector и назначим компоненту наш класс CounterView — его нужно выбрать в списке Class. Теперь все, что мы будем делать внутри класса CounterView, что касается отрисовки, будет отображаться в нашем компоненте. Рисуем. Нам понадобится переменная NSImage *symbols; Отрисовка будет производится при вызове метода setValue

 -(void) DrawSymbol:(int)x :(int)index {
    NSRect destRect = NSMakeRect(x * 13.0, 0, 13, 23);
    NSRect srcRect = NSMakeRect(index * 13.0, 0, 13, 23);
    [symbols setFlipped:NO];
    [symbols drawInRect: destRect fromRect: srcRect operation: NSCompositeSourceOver fraction: 1.0];
}
- (void)drawRect:(NSRect)rect {
   int digits[3];
   [[NSColor blackColor] set];
   NSRectFill (rect);
   int i;
   digits[0] = abs((int)(value / 100));
   digits[1] = abs((int)((value - (digits[0] * 100)) / 10));
   digits[2] = abs((int)(value - (digits[0] * 100) - (digits[1] * 10)));
   if (value < 0) digits[0] = -1;
   for (i = 0; i <= 2; i++)
     if (digits[i] < 0)
       [self DrawSymbol :i  :DIGIT_MINUS];
     else
       [self DrawSymbol :i  :DIGIT_EMPTY + (10 - digits[i])];
}
-(void) setValue :(int) newValue { value = newValue; [self setNeedsDisplay:YES]; }

Концепция отрисовки такова. У класса NSView есть два метода: setNeedsDisplay и setNeedsDisplayInRect. Их вызов влечет за собой вызов метода drawRect, и все графические функции, которые вызываются внутри этого метода, будут отображены на этом NSView. setNeedsDisplay и setNeedsDisplayInRect отличаются друг от друга как видно из названия тем, что первый вызывает отрисовку по всей площади NSView, а второй только какой-то части, если нам не нужно все отрисовывать. Например, в сапере редко нужно все отрисовывать, только отдельные клетки в разных состояниях. В случае с таймером разумнее отрисовать все. Для отрисовки изображения используем метод drawInRect класса NSImage — он отрисовывает фрагмент изображения, содержащегося в NSImage в текущем графическом контексте. Обратите внимание на метод setFlipped — переворачивает координатную ось Y, которая в Mac OS X начинтается не в левом вернем углу, а в левом нижнем. В SnowLeopard этот метод переведен в Deprecated. Можно сказать, уже половину игры написали. Что дальше? Обработаем события от мышки. Для этого в CocoaFramework существует класс NSResponder, он содержит методы бесчисленного количества событий. Чтобы перехватить событие системы, необходимо в приложении иметь объект такого класса. Наш HelloController унаследован от класса NSResponder, осталось добавить его в цепочку responder’ов (у NSWindow есть метод setNextResponder), и реализовать все необходимые методы-обработчики событий. Поначалу я даже так и сделал, но столкнулся с такой неприятной проблемой, что не все события системы передаются по цепочке респондеров. В частности нажатие правой кнопки мыши благодаря какому-то специальному назначению как раз таки и не попадало в мой контроллер. Мда… Немножко не вписывается в концепцию MVC, но да оставим это на совести Apple, и будем надеятся, что в Snow Leopard это противоречие устранено. И так, класс NSView является прямым наследником NSResponder, так используем же его! Добавим новый класс обертку для игрового поля (там, где клетки), назовем его FieldView. В Interface Builder добавим в наше окно компонент NSView и назначим ему класс FieldView. Реализуем перехват сообщений мыши:

- (void) mouseEvent  : (NSEvent *) event { [self setState:mouseButton]; }
- (void) rightMouseDown : (NSEvent *) event {
   NSLog(@"mouseRightDown");
   mouseRightDowned = YES;
   mouseButton = mouseLeftDowned?STATE_MIDDLE:STATE_RIGHT; [self mouseEvent:event];
}
- (void) rightMouseUp : (NSEvent *) event {
   NSLog(@"mouseRightUp");
   mouseRightDowned = NO;
   mouseButton = mouseLeftDowned?STATE_LEFT:STATE_NONE; [self mouseEvent:event];
}
-(void) mouseUp: (NSEvent *) event {
   NSLog(@"mouseUp");
   mouseLeftDowned = NO;
   mouseButton = mouseRightDowned?STATE_RIGHT:STATE_NONE; [self mouseEvent:event];
}
- (void) mouseDown : (NSEvent *) event {
   NSLog(@"mouseDown");
   mouseLeftDowned = YES;
   mouseButton = mouseRightDowned?STATE_MIDDLE:STATE_LEFT;
   [self mouseEvent:event];
}

Для чего так сложно? Просто в Mac OS X нет такого понятия, как средняя кнопка мыши, как в Windows, а в сапере без одновременного нажатия двух кнопок никак нельзя, поэтому пришлось немного потрюкачить. Кстати, здесь и понадобился такой короткий период таймера (0.1сек), я проверял интервал времени между нажатиями правой и левой кнопки, и, если он слишком большой, не расценивал это как клик двух кнопок. Рисовать в FieldView будем название нажатой мышиной кнопки:

- (void)drawRect:(NSRect)rect {
   [mouseBtn setFlipped:NO];
   NSLog(@"currentState:%d", state);
   [mouseBtn drawInRect: self.bounds fromRect: NSMakeRect(0, 512 - (state + 1) * 128, 128, 128) operation: NSCompositeSourceOver fraction: 1.0];
}
-(void) setState :(int) newState{
   state = newState;
   [self setNeedsDisplay:YES];
}

Весь код сапера я здесь выкладывать не буду, при наличии желания и фантазии примеров в приложении HelloCocoaWorld предостаточно, чтобы написать такого же сапера. Что еще интересного может понадобиться? Не совсем очевидной мне показалась концепция закрытия окна приложения. Когда кликаешь красный кругляшок в заколовке окна скомпиленного в xCode приложения, оно не закрывается, а прячется (hide). Между тем, нельзя сказать, что это общепринятый стандарт в Mac OS, есть множество приложений, которые закрываются по красной кнопке. И так, как сделать так, чтобы приложение закрывалось по красной кнопке? Прием очень прост: нужно реализовать метод applicationShouldTerminateAfterLastWindowClosed в классе контроллера

- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)theApplication {
   return YES;
}

Полезным будет навык работы с пользовательскими настройками, которые реализуются с помощью класса NSUserDefaults. Сделаем так, чтобы в программе при закрытии запоминалось состояние таймера, а при открытии восстанавливалось. Напишем два метода

-(void) loadDefaults {
   NSUserDefaults *defaults;
   defaults = [NSUserDefaults standardUserDefaults];
   [defaults registerDefaults:[NSDictionary dictionaryWithObjectsAndKeys:
      @"0", @"timerValue", nil]];
   seconds = [defaults integerForKey: @"timerValue"];
}
-(void) saveDefaults {
   NSUserDefaults *defaults;
   defaults = [NSUserDefaults standardUserDefaults];
   [defaults setInteger:seconds forKey:@"timerValue"];
   [defaults synchronize];
}

Если приложение запускается впервые, то @»0″, @»timerValue», зарегистрирует дефолтное значение 0 для параметра timerValue. Кстати, опытным путем было установлено, что пользотвательские предпочтения хранятся в папке ~/Library/Preferences чаще всего в виде plist-файла. Добавим вызов loadDefaults в метод awakeFromNib контроллера, а saveDefaults в метод applicationWillTerminate — он вызывается фреймворком при закрытии приложения. Из интересного еще пожалуй можно рассказать, как сделать плавные переходы в окне приложения на манер, как сделано в моем сапере переход из игрового окна к таблице рекордов. Все эти красивости реализуются за счет концепции множественных представлений. То есть, имея один объект NSWindow, мы можем назначать ему различные представления (NSView), предварительно создав их в Interface Builder’е и добавив в Document Window. Идем в интерфейс билдер, открываем библиотеку, находим там компонент NSView и перетаскиваем его в Document Window. Дабл клик, открывается добавленное представление, на которое мы накидываем нужные компоненты. Я для примера кинул NSLabel и NSButton.

Добавим в HelloController аутлет для главного окна и для представления с таблицей рекордов:

 IBOutlet NSWindow * mainWindow;
 IBOutlet NSView * scoresView;

Не забудем подвязать соответствующие элементы в интерфейс билдере к этим элементам.

Пока не забыл, очень долго искал, как добавить в стандартный диалог About дополнительную информацию, например, копирайт. Строчка копирайта добавляется через параметр Copyright (human-readable) в Info.plist  — это файл со списком параметров проекта, его можно найти в дереве проекта и отредактировать. Также в проект можно добавить файл Credits.rtf и написать в нем все, что угодно, и все это тоже появится в диалоге About. Заворачиваем приложение в прикольный инсталлятор, и получаем нечто, похожее на продукт.

Все, пожалуй, будем закругляться. На последок скажу, мой рекорд в сапере на Профессионале — 82 секунды. По сравнению с чемпионом мира (35 сек) — это конечно ничто), однако, на скриншоте 146 сек. Это из-за инерции мышки в Mac OS X (модель ускорения), благодаря которой прицеливаться по таким мелким объектам, как клетка сапера, дело не очень простое — в Windows динамика мыши гораздо приятнее. Как я ни искал решение проблемы, все тщетно. Чтобы как-то компенсировать этот косяк, я сделал несколько размеров клетки, которые можно выбрать через меню.

  1. Cody
    5 января 2012 в 18:55 | #1

    Спасибо за полезную статью. Планирую повторить в качестве тренировки, только думаю модель написать на C++ и немного поиграться с отображением таймера, по хорошему там из картинок нужен только фон и две красные палочки :)

  2. 5 января 2012 в 22:14 | #2

    Пожалуйста.
    Я бы не советовал так заморачиваться с палочками для таймера (: Разве только из академических соображений.
    Правда, что вы с этого хотите выиграть? Уменьшить размер приложения на килобайт? Сэкономить память? Ваш алгоритм отрисовки будет усложнен настолько, что компенсирует сэкономленную память.
    Приведу в пользу растрового решения еще один довод: этот битмап с цифрами я выдрал из ресурсов виндовского сапера.

  3. Cody
    6 января 2012 в 11:29 | #3

    @heximal
    Как раз из академических соображений. :) Интересно повозиться с отрисовкой/поворотами изображений. В реальном проекте это конечно же ни к чему

Подписаться на комментарии по RSS