Перейти к основному содержимому

Крестики-нолики на Morphic — практикум

Разработчику Архитектору

О чём эта статья

Пошаговый практикум: вы соберёте графическую игру "крестики-нолики" в Pharo на Morphic — не один файл для копирования, а три класса по очереди в Class Browser, с разбором каждого метода.

Зачем именно такая задача:

Следующий шаг по сложности UI — SmallDesktop на Morphic (навигация, списки, темы).


Словарь терминов

ТерминПростыми словамиГде в практикуме
Морф (Morph)Объект на экране — рисует себя и принимает событияTTTCellMorph, TTTBoardMorph
ImageЖивая среда Pharo — классы и объекты в памяти VMClass Browser, Playground
Протокол методовГруппа методов в браузере (accessing, game, events)Вкладки в Class Browser
Символ (#X)Иммутабельный идентификатор, не строкаЗнаки игроков на поле
nilОбъект "пусто" — единственный экземпляр UndefinedObjectПустая клетка доски
Ключевое сообщениеСелектор с аргументами: moveAt:, at:put:Ход, запись в массив
Блок [ :x | ... ]Объект-замыкание для do:, ifTrue:Перебор линий, условия
MVCModel–View–Controller — разделение данных, экрана и реакцийTTTGame / морфы / cellClicked:
RefreshЯвное обновление UI после изменения моделиTTTBoardMorph>>refresh

Подробнее о сообщениях и блоках — синтаксис; о типах #symbol и nilтипы данных.


Что получится

ЭлементПоведение
Поле 3×3Клик по пустой клетке ставит X или O
СтатусСтрока "Ход игрока X", "Победил игрок O!" или "Ничья!"
Кнопка"Новая игра" сбрасывает поле
ЦветаX — красный, O — синий

Три класса в категории SmallTacToe (категория — группировка в браузере, аналог пакета):

КлассРоль в MVCНаследование
TTTGameModel — поле, ходы, победа, ничьяObject
TTTCellMorphView — одна клетка, знак, кликBorderedMorph
TTTBoardMorphView + 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.


Требования

Перед стартом желательно иметь:

Как устроена работа в Class Browser

Как и в статье про класс Fighter, вы не копируете один файл целиком. Для каждого метода — отдельная вкладка протокола, затем Accept (Ctrl+S). Без Accept метод не попадёт в image и Playground его "не увидит".


Шаг 1 — модель игры TTTGame

Начинаем с логики без UI. Так проще отлаживать: ошибка в правилах видна в Playground, а не в перерисовке морфов.

Состояние игры:

  • boardArray из девяти элементов (nil = пусто);
  • currentPlayer#X или #O;
  • winner — символ победителя или nil;
  • gameOvertrue, когда партия завершена.

Игроки — символы #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; в Pharo new обычно вызывает 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 по шагам

  1. СтражifFalse: [ ^ false ] отклоняет ход в занятую клетку или после конца партии; UI может игнорировать false.
  2. Записьat:put: ставит currentPlayer в ячейку.
  3. Литерал #(...) — массив; каждый (1 2 3) — подмассив из трёх индексов линии (строки, столбцы, диагонали).
  4. Переборlines do: [ :line | ... ] для каждой линии читает три клетки; line first, line second, line third — сообщения к подмассиву.
  5. Победа — три непустых и равных символа → winner, gameOver, ранний ^ true без смены игрока.
  6. НичьяallSatisfy: true для всех девяти клеток при winner = nil.
  7. Смена игрока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 gameOvertrue.

Шаг 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 — ссылка на родительскую доску (делегат клика).
  • labelMorphStringMorph с текстом 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 — строка состояния вверху.
  • cellMorphsOrderedCollection из девяти клеток для массового обновления.
  • newGameButtonSimpleButtonMorph с действием #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.
  • SimpleButtonMorphtarget: 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-basedArray>>at: в Pharo — с 1; центр — индекс 5
Метод "не существует" после правкиЗабыли AcceptCtrl+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 — боковая навигация, список заметок, темы, статусная строка с часами.

Теория и стиль