Крестики-нолики на Morphic — практикум
О чём эта статья
Пошаговый практикум: вы соберёте графическую игру "крестики-нолики" в Pharo на Morphic — не один файл для копирования, а три класса по очереди в Class Browser, с разбором каждого метода.
Зачем именно такая задача:
- закрепить ООП-модель на живом примере — классы, инстанс-переменные, протоколы;
- увидеть Morphic в действии — окно, кнопка, клики (§12 справочника);
- почувствовать MVC — модель без UI, интерфейс без правил игры (философия Smalltalk, десктопные приложения);
- отработать привычку Accept (Ctrl+S) и проверки в Playground — как в первой программе.
Следующий шаг по сложности UI — SmallDesktop на Morphic (навигация, списки, темы).
Словарь терминов
| Термин | Простыми словами | Где в практикуме |
|---|---|---|
| Морф (Morph) | Объект на экране — рисует себя и принимает события | TTTCellMorph, TTTBoardMorph |
| Image | Живая среда Pharo — классы и объекты в памяти VM | Class Browser, Playground |
| Протокол методов | Группа методов в браузере (accessing, game, events) | Вкладки в Class Browser |
Символ (#X) | Иммутабельный идентификатор, не строка | Знаки игроков на поле |
nil | Объект "пусто" — единственный экземпляр UndefinedObject | Пустая клетка доски |
| Ключевое сообщение | Селектор с аргументами: moveAt:, at:put: | Ход, запись в массив |
Блок [ :x | ... ] | Объект-замыкание для do:, ifTrue: | Перебор линий, условия |
| MVC | Model–View–Controller — разделение данных, экрана и реакций | TTTGame / морфы / cellClicked: |
| Refresh | Явное обновление UI после изменения модели | TTTBoardMorph>>refresh |
Подробнее о сообщениях и блоках — синтаксис; о типах #symbol и nil — типы данных.
Что получится
| Элемент | Поведение |
|---|---|
| Поле 3×3 | Клик по пустой клетке ставит X или O |
| Статус | Строка "Ход игрока X", "Победил игрок O!" или "Ничья!" |
| Кнопка | "Новая игра" сбрасывает поле |
| Цвета | X — красный, O — синий |
Три класса в категории SmallTacToe (категория — группировка в браузере, аналог пакета):
| Класс | Роль в MVC | Наследование |
|---|---|---|
TTTGame | Model — поле, ходы, победа, ничья | Object |
TTTCellMorph | View — одна клетка, знак, клик | BorderedMorph |
TTTBoardMorph | View + Controller — окно, кнопка, связь с моделью | Morph |
Индексация клеток на поле (как в массиве Pharo, с 1):
1 | 2 | 3
---+---+---
4 | 5 | 6
---+---+---
7 | 8 | 9
Зачем отделять модель от интерфейса
В Smalltalk GUI — тоже объекты, но правила игры не должны жить внутри кнопок и подписей.
Модель (TTTGame) знает:
- можно ли ходить в клетку;
- кто победил или наступила ничья;
- чей сейчас ход.
Представление (морфы) знает:
- где нарисовать X и O;
- какой цвет шрифта;
- куда поставить кнопку на экране.
Связующий слой (TTTBoardMorph) переводит клик мыши в сообщение moveAt: модели и затем вызывает refresh.
Плюсы такого разделения:
- модель проверяете в Playground без окна — быстрые эксперименты;
- позже можно заменить Morphic на другой UI, не переписывая
TTTGame; - один источник правды — нет дублирования "кто выиграл" в клетке и в модели.
Тот же приём — в рекомендациях по разработке и в SmallDesktop на Morphic.
Требования
Перед стартом желательно иметь:
- Pharo 10 или новее (рекомендуется 11/12);
- пройденную первую программу — Playground, Class Browser, Accept;
- базовое понимание классов и инстанс-переменных — ООП в Smalltalk;
- общее представление о десктопе — окно и цикл событий.
Как и в статье про класс Fighter, вы не копируете один файл целиком. Для каждого метода — отдельная вкладка протокола, затем Accept (Ctrl+S). Без Accept метод не попадёт в image и Playground его "не увидит".
Шаг 1 — модель игры TTTGame
Начинаем с логики без UI. Так проще отлаживать: ошибка в правилах видна в Playground, а не в перерисовке морфов.
Состояние игры:
board—Arrayиз девяти элементов (nil= пусто);currentPlayer—#Xили#O;winner— символ победителя илиnil;gameOver—true, когда партия завершена.
Игроки — символы #X и #O, не строки 'X'. Символ сравнивается по идентичности (=), занимает меньше памяти и идиоматичен для "ролей" в Smalltalk (типы).
1.1 Объявление класса
В Class Browser выберите New class и замените шаблон:
Object subclass: #TTTGame
instanceVariableNames: 'board currentPlayer winner gameOver'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallTacToe'!
Нажмите Accept.
Разбор объявления
Object subclass: #TTTGame— создаём подкласс корня иерархии (ООП).#TTTGame— символ-имя класса; восклицательный знак в конце строки — синтаксис file-out, в браузере допустим.instanceVariableNames:— четыре инстанс-переменные; у каждого экземпляраTTTGame newбудут свои значения.category: 'SmallTacToe'— категория в браузере; на поведение не влияет.
1.2 Инициализация и сброс
Протокол initialization, метод initialize:
initialize
super initialize.
self reset
Протокол game, метод reset:
reset
board := Array new: 9 withAll: nil.
currentPlayer := #X.
winner := nil.
gameOver := false
Разбор инициализации
super initialize— базовая инициализацияObject; в Pharonewобычно вызываетinitializeавтоматически.Array new: 9 withAll: nil— массив длины 9, все слоты —nil. Индексы 1..9, не 0..8 (частая ошибка у привыкших к C/Java).resetвынесен отдельно — им же воспользуется кнопка "Новая игра" в UI.
1.3 Доступ к состоянию
Протокол accessing — по соглашению здесь геттеры без побочных эффектов:
board
^ board
currentPlayer
^ currentPlayer
winner
^ winner
gameOver
^ gameOver
Разбор доступа
^ board— унарное сообщение с возвратом значения;^— return (синтаксис).- Сеттеры не нужны — состояние меняют только
resetиmoveAt:внутри класса (инкапсуляция).
1.4 Проверка хода
Протокол game, метод canMoveAt::
canMoveAt: anIndex
^ gameOver not and: [ (board at: anIndex) isNil ]
Разбор canMoveAt
gameOver not— булево сообщение; в Smalltalk нет оператора!(философия).and: [ ... ]— ленивый второй аргумент: блок не выполнится, если игра уже окончена.board at: anIndex— доступ к элементу массива;isNil— тест наnil.
1.5 Ход и проверка победы
Метод moveAt: — ядро модели:
moveAt: anIndex
| lines |
(self canMoveAt: anIndex) ifFalse: [ ^ false ].
board at: anIndex put: currentPlayer.
lines := #(
(1 2 3) (4 5 6) (7 8 9)
(1 4 7) (2 5 8) (3 6 9)
(1 5 9) (3 5 7)
).
lines do: [ :line |
| a b c |
a := board at: line first.
b := board at: line second.
c := board at: line third.
(a notNil and: [ a = b and: [ b = c ] ]) ifTrue: [
winner := a.
gameOver := true.
^ true ] ].
(board allSatisfy: [ :each | each notNil ]) ifTrue: [
gameOver := true ].
currentPlayer := currentPlayer = #X ifTrue: [ #O ] ifFalse: [ #X ].
^ true
Разбор moveAt по шагам
- Страж —
ifFalse: [ ^ false ]отклоняет ход в занятую клетку или после конца партии; UI может игнорироватьfalse. - Запись —
at:put:ставитcurrentPlayerв ячейку. - Литерал
#(...)— массив; каждый(1 2 3)— подмассив из трёх индексов линии (строки, столбцы, диагонали). - Перебор —
lines do: [ :line | ... ]для каждой линии читает три клетки;line first,line second,line third— сообщения к подмассиву. - Победа — три непустых и равных символа →
winner,gameOver, ранний^ trueбез смены игрока. - Ничья —
allSatisfy:true для всех девяти клеток приwinner = nil. - Смена игрока —
ifTrue:ifFalse:между#Xи#O; возвратtrue— ход принят.
1.6 Текст статуса
statusText
gameOver ifFalse: [ ^ 'Ход игрока ', currentPlayer asString ].
winner ifNotNil: [ ^ 'Победил игрок ', winner asString, '!' ].
^ 'Ничья!'
Разбор statusText
- Три ветки — игра идёт / есть победитель / ничья.
,у строк — конкатенация;asStringу символа даёт"X"для подписи.- Метод не знает о Morphic — только текст; View решит, куда его вывести.
1.7 Проверка в Playground
После Accept всех методов:
| g |
g := TTTGame new.
g moveAt: 1.
g moveAt: 5.
g board.
g statusText.
Ожидаемо:
g board→#( #X nil nil nil #O nil nil nil nil )— X в углу, O в центре;g statusText→'Ход игрока X'— после хода O в клетку 5 очередь снова у X.
Дополнительные проверки:
g moveAt: 1второй раз →false, поле без изменений;- симулируйте линию
1,2,3для X —g winner→#X,g gameOver→true.
Шаг 2 — клетка TTTCellMorph
Теперь один элемент UI. В Morphic всё на экране — объекты; клетка наследует BorderedMorph — прямоугольник с рамкой (справочник, §12.1).
Ответственность клетки:
- показать X или O нужным цветом;
- сообщить доске индекс при клике;
- не проверять победу и не менять
boardнапрямую.
2.1 Объявление класса
BorderedMorph subclass: #TTTCellMorph
instanceVariableNames: 'index boardMorph labelMorph'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallTacToe'!
Разбор полей клетки
index— номер клетки 1..9 на общей доске.boardMorph— ссылка на родительскую доску (делегат клика).labelMorph—StringMorphс текстом X/O внутри рамки.
2.2 Сборка клетки
Протокол initialization, метод initializeBoard:at::
initializeBoard: aBoardMorph at: anIndex
boardMorph := aBoardMorph.
index := anIndex.
self color: Color white.
self borderWidth: 2.
self borderColor: Color black.
labelMorph := StringMorph contents: '' font: (TextStyle default fontOfSize: 36).
labelMorph color: Color black.
self addMorph: labelMorph.
labelMorph position: 12 @ 8.
^ self
Разбор сборки
- Фабричная инициализация —
initializeBoard:at:вызывают послеnew; возврат^ selfпозволяет писатьTTTCellMorph new initializeBoard: self at: iв одну цепочку. self color:/borderWidth:— настройка самого морфа-клетки.StringMorph— дочерний морф только для текста; размер шрифта 36 — крупный знак в квадрате 80×80.addMorph:— вложенность; родитель отвечает за компоновку (§12.4).12 @ 8— литералPoint; смещение подписи внутри клетки.
2.3 Отрисовка знака
Протокол display, метод refreshFromGame::
refreshFromGame: aGame
| mark |
mark := aGame board at: index.
labelMorph contents: (mark ifNil: [ '' ] ifNotNil: [ mark asString ]).
labelMorph color: (mark = #X ifTrue: [ Color red ] ifFalse: [ Color blue ])
Разбор отрисовки
- Модель передаётся аргументом — клетка не держит ссылку на
TTTGame, только на доску. ifNil:ifNotNil:— пустая клетка → пустая строка.- Цвет по знаку — чисто презентация; правила игры не затрагиваются.
2.4 Обработка клика
Протокол events (§12.3):
handlesMouseDown: evt
^ true
mouseDown: evt
boardMorph cellClicked: index
Разбор событий
handlesMouseDown:→true— морф принимает нажатие; иначе клик "провалится" сквозь клетку.mouseDown:— callback Morphic; передаём индекс доске, не модели напрямую — контроллер решит, валиден ли ход.- Параметр
evtздесь не используем — достаточно факта клика; координаты понадобились бы для drag-and-drop.
Шаг 3 — доска TTTBoardMorph
Корневой морф собирает статус, кнопку и сетку 3×3. Он владеет единственным экземпляром TTTGame и оркестрирует refresh после каждого действия.
3.1 Объявление класса
Morph subclass: #TTTBoardMorph
instanceVariableNames: 'game statusMorph cellMorphs newGameButton'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallTacToe'!
Разбор полей доски
game— модель (одна на окно).statusMorph— строка состояния вверху.cellMorphs—OrderedCollectionиз девяти клеток для массового обновления.newGameButton—SimpleButtonMorphс действием#newGame.
3.2 Точка входа
Протокол класса instance creation:
open
^ self new openInWindowLabeled: 'Крестики-Нолики'
Разбор open
- Метод класса — вызывается как
TTTBoardMorph openбезnewснаружи. openInWindowLabeled:— стандарт Morphic: морф в отдельном окне с заголовком.- Возвращаемый морф можно сохранить в переменную Playground, если нужно закрыть программно.
3.3 Инициализация и построение UI
Протокол initialization:
initialize
super initialize.
self color: Color lightGray.
game := TTTGame new.
self buildUI
Метод buildUI:
buildUI
| cellSize gap origin |
statusMorph := StringMorph contents: game statusText font: (TextStyle default fontOfSize: 18).
statusMorph color: Color black.
self addMorph: statusMorph.
statusMorph position: 10 @ 10.
newGameButton := SimpleButtonMorph new.
newGameButton label: 'Новая игра'; font: (TextStyle default fontOfSize: 14).
newGameButton target: self; actionSelector: #newGame.
self addMorph: newGameButton.
newGameButton position: 220 @ 8.
cellMorphs := OrderedCollection new.
cellSize := 80.
gap := 4.
origin := 10 @ 45.
1 to: 9 do: [ :i |
| cell row col |
row := (i - 1) // 3.
col := (i - 1) \\ 3.
cell := TTTCellMorph new initializeBoard: self at: i.
cell extent: cellSize @ cellSize.
cell position: origin + ((col * (cellSize + gap)) @ (row * (cellSize + gap))).
self addMorph: cell.
cellMorphs add: cell ].
self extent: 270 @ 320
Разбор buildUI
Верхняя панель
statusMorphсразу читаетgame statusText— начальная подпись без отдельного вызоваrefresh.SimpleButtonMorph—target: self; actionSelector: #newGame— по клику посылается #newGame объекту-доске (паттерн target/action вместо блока).
Сетка клеток
1 to: 9 do: [ :i | ... ]— цикл Smalltalk через сообщение (синтаксис, циклы).row := (i - 1) // 3— целочисленное деление;col := (i - 1) \\ 3— остаток. Дляi = 5→ row = 1, col = 1 (центр).extent:— размер морфа;position:— левый верхний угол относительно родителя.origin + ((col * (cellSize + gap)) @ (row * (cellSize + gap)))— ручная компоновка без layout-менеджера; для учебного примера нагляднее, чемTableLayout.self extent: 270 @ 320— размер всего окна под заголовок и поле.
3.4 Действия и обновление
Протокол actions:
cellClicked: anIndex
game moveAt: anIndex.
self refresh
newGame
game reset.
self refresh
Протокол display:
refresh
statusMorph contents: game statusText.
cellMorphs do: [ :each | each refreshFromGame: game ]
Разбор действий
cellClicked:— контроллер: один ход в модели, затем синхронизация UI.moveAt:может вернутьfalse— мы всё равно вызываемrefresh; альтернатива — обновлять только приtrue(для экономии перерисовки).refresh— явное обновление; Morphic не "подписан" на измененияboardавтоматически. В больших приложениях используютchanged/ зависимости (MVC в 101); здесь достаточно прямого вызова.
Шаг 4 — запуск и проверка
Чек-лист перед запуском
- все три класса приняты (Accept) без ошибок компиляции;
- у
TTTCellMorphестьhandlesMouseDown:иinitializeBoard:at:; TTTBoardMorph>>initializeвызываетbuildUI;- в Playground нет старого экземпляра с устаревшими методами (при сомнении —
TTTBoardMorph openзаново).
Запуск
TTTBoardMorph open
Сценарии ручного теста
| Сценарий | Ожидание |
|---|---|
| Клик по пустой клетке | Появляется X, статус — "Ход игрока O" |
| Повторный клик по занятой | Ничего не меняется |
| Три X в ряд | "Победил игрок X!", клики не меняют поле |
| Заполнить поле без линии | "Ничья!" |
| "Новая игра" | Пустое поле, снова ход X |
Отладка
При ошибке Pharo открывает Debugger — можно:
- посмотреть стек вызовов (
mouseDown:→cellClicked:→moveAt:); - исправить метод в окне отладчика и нажать Proceed;
- инспектировать
game boardчерез Inspect.
Подробнее об отладке в image — рекомендации.
Поток событий
Клик мыши
→ TTTCellMorph>>mouseDown:
→ TTTBoardMorph>>cellClicked:
→ TTTGame>>moveAt:
→ TTTBoardMorph>>refresh
→ statusMorph contents:
→ TTTCellMorph>>refreshFromGame: (×9)
Сравнение с общей схемой десктопа (§ mainloop):
- Pharo/Morphic тоже обрабатывает очередь событий в процессе UI;
- мы не пишем
mainloopвручную — VM крутит цикл, морфы получаютmouseDown:; - после обработки клика мы сами обновляем подписи — как "invalidate + repaint" в других toolkit.
TTTGame не импортирует Morphic — зависимость только View → Model. Это упрощённый MVC, изобретённый в экосистеме Smalltalk (чек-лист, вопрос 39).
Частые ошибки
| Симптом | Вероятная причина | Что сделать |
|---|---|---|
UndefinedObject>>DNU при клике | labelMorph не создан или не вызван initializeBoard:at: | Проверьте цепочку TTTCellMorph new initializeBoard: self at: i |
| Клик не срабатывает | Нет handlesMouseDown: или клетка перекрыта другим морфом | Метод должен возвращать true; проверьте position и extent |
| Индексы "съехали" | Путаница 0-based и 1-based | Array>>at: в Pharo — с 1; центр — индекс 5 |
| Метод "не существует" после правки | Забыли Accept | Ctrl+S в панели метода |
| Окно пустое | buildUI не вызван из initialize | Добавьте self buildUI после создания game |
| Статус не обновляется | Нет refresh после хода | Вызовите self refresh в cellClicked: и newGame |
MessageNotUnderstood на open | Метод open не в протоколе класса | Переключите браузер на class side (кнопка class) |
Что попробовать дальше
Тесты и модель
- SUnit-тесты для
TTTGame— победа по диагонали, ничья, запрет хода послеgameOver(чек-лист, SUnit); - метод
winningLine— возвращает индексы тройки для подсветки.
Интерфейс
- подсветка выигрышной линии —
borderColor:у нужныхTTTCellMorph; - счётчик партий в
StringMorphрядом с кнопкой; - звук или
BounceAnimationпри победе — Morphic-анимации (справочник).
Логика
- простой ИИ —
bestMoveвTTTGame, вызов после хода человека; - режим "игрок против игрока" на одной клавиатуре уже есть; добавьте переключение на "против компьютера".
Следующий практикум
- SmallDesktop на Morphic — боковая навигация, список заметок, темы, статусная строка с часами.
Теория и стиль
- Справочник, Morphic §12;
- Рекомендации по разработке;
- Особенности десктопа — потоки, память, отзывчивость UI.