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

Smalltalk — SmallShooter

Разработчику Начальный уровень

О практикуме

SmallShooter — учебный вертикальный shoot'em up (шутер с видом сверху, корабль внизу, враги сверху). Жанр родом из аркад 1980-х (Galaga, 1942, Xevious) — короткие сессии, нарастающая сложность, счёт и жизни.

Соберём игру в Pharo на Morphic — без pip, без Pygame, без спрайтов. Всё рисуем примитивами (fillRectangle, fillOval), логику держим в обычных объектах Smalltalk, а окно и клавиатуру — в морфах.

Чем отличается от Python/Pygame

В разработке игр на Python вы сами крутите while running и зовёте pygame.display.flip(). В Pharo морф подписывается на startStepping, а образ периодически шлёт ему сообщение step. Близкий по архитектуре трек на Smalltalk — SmallPong; там те же три роли — модель, поле, окно.

Что получится после всех этапов

  • Окно 500×700 с полем 480×640 и звёздным фоном.
  • Корабль, стрельба, три типа врагов, бесконечные волны.
  • Счёт, жизни, пауза, game over и кнопка "Новая игра".

Оценка времени — 3–5 часов, если идти через Class Browser и проверять каждый этап.

МеханикаРеализация
Поле480×640 px, параллакс-звёзды
Игрок3 жизни, 90 тиков неуязвимости после урона
Враги#grunt, #zigzag, #shooter (2 HP)
Волны#break#spawning#fighting → снова #break
КадрыstepTime = 16 ms (~60 FPS)
Для кого материал

Нужны Pharo 10+ и пройденная первая программа — Playground, Class Browser, Accept (Ctrl+S). Желательно освежить ООП в Smalltalk, коллекции и символы и раздел Morphic в справочнике.


Теория перед кодом

Перед этапами полезно зафиксировать несколько понятий — они будут встречаться в каждом шаге.

Игровой цикл

Игровой цикл — повторяющаяся последовательность "прочитать ввод → обновить состояние → отрисовать кадр". В Pygame это явный while в main.py. В Morphic цикл встроен в образ: морф с startStepping получает step каждые stepTime миллисекунд.

Модель и представление

Модель (ShooterGame) — чистая логика — координаты, списки пуль, правила столкновений. Она не импортирует Morphic и не рисует.

Представление (ShooterPlayfieldMorph) — читает модель и рисует на Canvas. Метод drawOn: вызывается системой, когда морф помечен как changed.

Контроллер (в нашем случае часть ShooterGameMorph) — переводит события клавиатуры в сообщения модели (movingLeft:, tryShoot).

Такой раздел отражает классическую идею MVC, заложенную в Smalltalk-80; в Pharo UI строят на Morphic, но принцип тот же. Подробнее — в философии Smalltalk и десктопных приложениях.

Символы как метки состояния

Символ (#break, #grunt) — именованная константа в Smalltalk. Для состояния волны и типа врага символ удобнее строки: сравнение waveState = #break быстрое и однозначное. См. типы данных.

Данные в массивах вместо классов

На учебном проекте пуля — { x. y. vx. vy. isPlayer }, враг — { x. y. type. hp. shootTimer. phase }. Это OrderedCollection массивов (#()). Так быстрее набрать прототип; позже можно вынести Bullet и Enemy в отдельные классы — см. раздел "Что дальше".


Как проходить практикум

  1. Установите Pharo 10+инструкция в первой программе.
  2. Создайте категорию SmallShooter в System Browser.
  3. На каждом этапе добавляйте методы в указанные протоколы и жмите Accept (Ctrl+S).
  4. Запускайте ShooterGameMorph open в Playground; кликните по окну, иначе клавиатура не дойдёт до морфа.
  5. Пройдите самопроверку в конце этапа.
  6. Если что-то расходится — полная ревизия.

Управление в финальной версии

КлавишаДействие
← / → или A / DДвижение корабля
ПробелОгонь (можно удерживать)
PПауза / продолжить
RРестарт после game over
Кнопка "Новая игра"Сброс счёта и волны

Карта этапов

ЭтапТемаКлассы
0Окно и модельвсе три — каркас
1ДвижениеShooterGame, ShooterGameMorph
2СтрельбаShooterGame, отрисовка пуль
3Враги GruntShooterGame, ShooterPlayfieldMorph
4Столкновения и счётShooterGame
5ВолныShooterGame
6Zigzag и ShooterShooterGame, отрисовка
7UI и паузаShooterGameMorph

Архитектура

Три класса разделяют ответственность — модель, холст и окно:

ShooterGameMorph ← окно, клавиатура, step / stepTime
├── StringMorph ← счёт, строка статуса
├── SimpleButtonMorph ← "Новая игра"
└── ShooterPlayfieldMorph ← drawOn — фон, звёзды, объекты
└── game: ShooterGame ← логика без Morphic
КлассРоль
ShooterGameКорабль, пули, враги, волны, столкновения
ShooterPlayfieldMorphCanvas — звёзды, прямоугольники и овалы
ShooterGameMorphКомпоновка UI, startStepping, клавиатура
ТерминЗначение в проекте
МорфОбъект UI, умеющий рисовать себя и ловить события
SteppingПериодический вызов step у морфа
ТикОдин проход ShooterGame>>step; счётчик tickCount
HUDСтроки счёта и подсказок над полем

Формат записей в коллекциях:

  • Пуля — { x. y. vx. vy. isPlayer } — пятый элемент true/false различает игрока и врага.
  • Враг — { x. y. type. hp. shootTimer. phase }type это #grunt, #zigzag или #shooter; phase нужен для синусоиды zigzag.

Зависимости

  • Pharo 10 или новее (рекомендуется 11/12).
  • Дополнительные пакеты не нужны — Morphic уже в образе.

В System Browser создайте категорию SmallShooter (имя пакета может совпадать). Все три класса живут в ней — так проще найти код в Browser и сохранить вместе с image.


Этап 0 — окно, модель и отрисовка корабля

Цель — три класса, окно со звёздным фоном и кораблём внизу. Игровой цикл уже крутится (tickCount растёт), но движения и врагов пока нет.

Теория этапа

  • Object subclass: — объявление класса в панели Browser; список instanceVariableNames задаёт поля каждого экземпляра.
  • initialize — вызывается после new; здесь задаём размеры поля и зовём resetGame.
  • ^ (caret) — возврат значения из метода; без него метод вернёт self.
  • @ — создаёт объект Point (координата или размер).

0.1 Класс ShooterGame

Объявление класса (панель определения, subclass of Object):

Object subclass: #ShooterGame
instanceVariableNames: 'fieldWidth fieldHeight playerX playerY playerWidth playerHeight playerSpeed playerLives invulnerableTimer bullets enemies score waveNumber waveState waveBreakTimer spawnIndex spawnQueue spawnDelayTimer shootTimer movingLeft movingRight paused gameOver highScore tickCount'
classVariableNames: ''
poolDictionaries: ''
category: 'SmallShooter'

Протокол initialization, метод initialize:

Код ITЗагрузка примера кода…

Протокол game:

Код ITЗагрузка примера кода…

Протокол accessing — геттеры для морфа (модель не отдаёт инстанс-переменные напрямую наружу, только через сообщения):

Код ITЗагрузка примера кода…

0.2 Класс ShooterPlayfieldMorph

Subclass of Morph. Инстанс-переменные: game, stars.

Код ITЗагрузка примера кода…

Протокол display, метод drawOn: (фон, звёзды, корабль):

Код ITЗагрузка примера кода…

0.3 Класс ShooterGameMorph

Subclass of Morph. Переменные: game playfieldOrigin playfieldMorph scoreMorph statusMorph newGameButton pressedKeys spaceHeld.

Классовый метод open:

open
^ self new openInWorld

Протокол initialization:

Код ITЗагрузка примера кода…

Протокол stepping:

step
game step.
self refreshDisplay

stepTime
^ 16

Протокол display:

refreshDisplay
scoreMorph contents: game scoreText.
statusMorph contents: game statusText.
playfieldMorph changed

Протокол actions (упрощённый newGame, доработаем на этапе 7):

newGame
game resetGame.
playfieldMorph buildStars.
self refreshDisplay

Протокол focus:

openInWorld
| window |
window := self openInWindowLabeled: 'SmallShooter'.
window activate.
self takeKeyboardFocus.
^ window

Запуск

ShooterGameMorph open

Самопроверка

  • Окно "SmallShooter" с тёмным полем и звёздами.
  • Голубой корабль внизу по центру.
  • Строка счёта: Счёт: 0 Волна: 0 Жизни: 3.
  • Кнопка "Новая игра" сбрасывает поле.

Разбор

Строка / конструкцияСмысл
super initializeСначала инициализация предка Object, потом наши поля
waveState := #breakВолна ещё не началась; позже добавим автоматический старт
self resetGame в initializeОдин метод собирает начальное состояние партии
startSteppingMorphic начнёт слать step этому морфу
stepTime161000 / 16 ≈ 62 кадра в секунду
playfieldMorph changedПометка "грязной" области — нужна перерисовка drawOn:
addMorph:Вложенный морф; дерево морфов = дерево виджетов

ShooterGame можно тестировать без UI в Playground:

g := ShooterGame new.
g step.
g scoreText

Этап 1 — движение корабля

Цель — корабль ездит влево и вправо, не выходя за края поля.

Теория этапа

Ввод в Morphic приходит событиями (keydown:, keyup:). Мы не опрашиваем клавиатуру в step — вместо этого храним множество pressedKeys и на каждое событие синхронизируем флаги модели. Так модель остаётся независимой от Morphic (удобно для тестов и SmallPong).

ShooterGame, протокол input

movingLeft: aBoolean
movingLeft := aBoolean

movingRight: aBoolean
movingRight := aBoolean

Ключевое слово movingLeft: с двоеточием — это ключевое сообщение с одним аргументом; см. синтаксис.

ShooterGame, протокол player

movePlayer
movingLeft ifTrue: [
playerX := (playerX - playerSpeed) max: 0 ].
movingRight ifTrue: [
playerX := (playerX + playerSpeed) min: fieldWidth - playerWidth ]

Обновите step — после таймеров добавьте движение (таймеры понадобятся на этапе 2):

step
gameOver ifTrue: [ ^ self ].
paused ifTrue: [ ^ self ].
tickCount := tickCount + 1.
shootTimer > 0 ifTrue: [ shootTimer := shootTimer - 1 ].
invulnerableTimer > 0 ifTrue: [ invulnerableTimer := invulnerableTimer - 1 ].
self movePlayer

ShooterGameMorph, протокол events

Код ITЗагрузка примера кода…

Самопроверка

  • ←/→ и A/D двигают корабль.
  • Корабль останавливается у левого и правого края.
  • После клика по окну клавиши доходят до игры.

Разбор

ФрагментЗачем
(playerX - playerSpeed) max: 0max: не даёт уйти левее нуля
min: fieldWidth - playerWidthПравый край с учётом ширины спрайта
pressedKeys includes:Проверка удерживаемой клавиши
ifAbsent: []Удаление из Set без ошибки, если клавиши уже нет
handlesKeyboard: ^ trueМорф заявляет, что хочет получать клавиатуру

Этап 2 — стрельба

Цель — жёлтые пули летят вверх; между выстрелами — короткая пауза (кулдаун).

Теория этапа

Кулдаун (shootTimer) считается в тиках, не в секундах: при 60 FPS 8 тиков ≈ 130 ms. Так проще — не нужен Time в модели.

Пуля хранится как массив с полями скорости vx, vy. У игрока vy = -9 (вверх по экрану, где y растёт вниз — типичная экранная система координат).

ShooterGame, протокол input

tryShoot
shootTimer <= 0 ifTrue: [
self addPlayerBullet.
shootTimer := 8 ]

ShooterGame, протокол player

addPlayerBullet
| bx by |
bx := playerX + (playerWidth // 2) - 2.
by := playerY - 6.
bullets add: { bx. by. 0. -9. true }

Пятый элемент true — метка своей пули; пули врагов будут с false.

ShooterGame, протокол bullets

updateBullets
| toRemove bullet x y |
toRemove := OrderedCollection new.
bullets do: [ :each |
bullet := each.
x := bullet first + bullet third.
y := bullet second + bullet fourth.
bullet at: 1 put: x.
bullet at: 2 put: y.
(y < -10 or: [ y > fieldHeight + 10 or: [ x < -10 or: [ x > fieldWidth + 10 ] ] ])
ifTrue: [ toRemove add: bullet ] ].
toRemove do: [ :each | bullets remove: each ifAbsent: [] ]

Добавьте в step: self updateBullets.

ShooterGameMorph

Обновите step:

step
spaceHeld ifTrue: [ game tryShoot ].
game step.
self refreshDisplay

В handleKeyDown: / handleKeyUp::

"в handleKeyDown:"
keyName = ' ' ifTrue: [
spaceHeld := true.
game tryShoot ].

"в handleKeyUp:"
keyName = ' ' ifTrue: [ spaceHeld := false ].

ShooterPlayfieldMorph — фрагмент drawOn

game bulletsList do: [ :bullet |
| isPlayer x y |
isPlayer := bullet fifth.
x := bullet first.
y := bullet second.
isPlayer ifTrue: [
aCanvas fillRectangle: (x @ y extent: 4 @ 10) color: Color yellow ] ]

Самопроверка

  • Пробел создаёт пулю вверх.
  • Удержание пробела даёт серию выстрелов с паузой.
  • Пули исчезают за краем экрана.

Разбор

Элемент массива пулиРоль
1-й (first)x
2-й (second)y
3-й (third)vx
4-й (fourth)vy
5-й (fifth)isPlayer

bullet at: 1 put: x мутирует массив на месте — коллекция хранит ссылку на тот же объект. Альтернатива — immutable объекты; для учебного шутера массивов достаточно.

spaceHeld ifTrue: в step морфа + tryShoot по keydown — двойной путь: и одиночное нажатие, и автоповтор при удержании.


Этап 3 — враги Grunt

Цель — красные блоки летят вниз. Волны пока вручную — один враг для проверки движения и отрисовки.

Теория этапа

Grunt — простейший тип: постоянная скорость вниз. Запись врага включает запасные поля под будущее: shootTimer для #shooter, phase для #zigzag.

enemySpeed зависит от waveNumber — даже на этом этапе заложена прогрессия сложности.

ShooterGame, протокол enemies

Код ITЗагрузка примера кода…

Добавьте в step: self updateEnemies.

Тест в Playground (до волн):

g := ShooterGame new.
g addEnemyAt: 200 @ -30 type: #grunt.
ShooterGameMorph open

ShooterPlayfieldMorph — фрагмент drawOn

game enemiesList do: [ :enemy |
| type enemySize x y |
type := enemy third.
enemySize := game enemySizeFor: type.
x := enemy first.
y := enemy second.
type = #grunt ifTrue: [
aCanvas fillRectangle: (x @ y extent: enemySize) color: Color red ] ]

Самопроверка

  • Grunt виден и движется вниз.
  • Враг исчезает за нижним краем (урон игроку добавим на этапе 4).

Разбор

Поле врагаИндексНазначение
x, y1, 2Позиция
type3#grunt, #zigzag, #shooter
hp4Жизни; у shooter будет 2
shootTimer5Обратный отсчёт до выстрела
phase6Фаза синусоиды для zigzag

(80 + 120 atRandom) — случайная задержка стрельбы в тиках; atRandom — сообщение интервала, см. справочник.


Этап 4 — столкновения, счёт и жизни

Цель — пули уничтожают врагов; касание врага или пропуск его за экран отнимает жизнь.

Теория этапа

AABB-столкновение — проверяем пересечение прямоугольников (Rectangle>>intersects:). Для аркады этого достаточно; точная pixel-perfect коллизия здесь не нужна.

Неуязвимость после урона — 90 тиков (~1,5 s), чтобы игрок не потерял все жизни за один кадр. Мигание корабля в drawOn: синхронизировано с invulnerable.

ShooterGame, протокол player

damagePlayer
invulnerableTimer > 0 ifTrue: [ ^ self ].
playerLives := playerLives - 1.
invulnerableTimer := 90.
playerLives <= 0 ifTrue: [
gameOver := true ]

Обновите updateEnemies — враг ушёл за экран:

(y > fieldHeight + 40) ifTrue: [
toRemove add: enemy.
self damagePlayer ]

ShooterGame, протокол collisions

Код ITЗагрузка примера кода…

Добавьте в step: self checkCollisions и score > highScore ifTrue: [ highScore := score ].

Самопроверка

  • Попадание — +50 и исчезновение grunt.
  • Столкновение с врагом — −1 жизнь, корабль мигает.
  • При 0 жизней — статус "Игра окончена…".

Разбор

Блок в checkCollisionsЛогика
Цикл по bullets с fifth ifTrueТолько свои пули бьют врагов
killedEnemies как SetОдин враг не получает два попадания в том же тике
enemy at: 4 put:Уменьшение HP; при 0 — очки и удаление
Второй цикл по enemiesКорабль врезается в живого врага
deadBullets / deadEnemiesСначала собираем, потом удаляем — иначе сломаем итерацию do:

Очки за типы заложены заранее — пригодятся на этапе 6.


Этап 5 — волны

Цель — автоматический спавн — пауза, очередь точек, бой, снова пауза.

Теория этапа

Конечный автомат волны — три состояния-символа:

  • #break — пауза между волнами (waveBreakTimer).
  • #spawning — враги появляются по очереди spawnQueue.
  • #fighting — все заспавнены; ждём, пока enemies опустеет.

buildSpawnQueue чередует четыре паттерна (waveNumber \\ 4) — линия, эшелоны, диагональ, "клин".

ShooterGame, протокол enemies

randomEnemyType
| roll |
roll := 100 atRandom.
waveNumber < 3 ifTrue: [ ^ #grunt ].
roll <= 50 ifTrue: [ ^ #grunt ].
roll <= 80 ifTrue: [ ^ #zigzag ].
^ #shooter

ShooterGame, протокол waves

Код ITЗагрузка примера кода…

Добавьте в step: self updateWaves. В конец checkCollisions: self checkWaveComplete.

Самопроверка

  • Статус "Готовься к волне 1!", затем появление врагов.
  • Номер волны растёт после зачистки.
  • До 18 врагов в волне на высоких номерах.

Разбор

#break ──(таймер)──► beginWave ──► #spawning

spawnNextEnemy ◄──┘

все точки очереди ─────► #fighting

enemies isEmpty ───────► #break

count := (waveNumber + 3) min: 18 — мягкий потолок, чтобы экран не превращался в "кашу". spawnDelayTimer := 5 — пауза между появлениями в одной волне.


Этап 6 — Zigzag, Shooter и пули врагов

Цель — полный набор противников; фиолетовый Shooter стреляет вниз.

Теория этапа

ТипПоведениеОчки
#gruntПрямо вниз50
#zigzagy + sin(phase) по x75
#shooterВниз + периодический выстрел, 2 HP150

Zigzag сдвигает x через (phase sin * 2.5)phase растёт с тиками и зависит от стартовой x, чтобы траектории не совпадали.

ShooterGame, протокол bullets

addEnemyBulletAt: aPoint
bullets add: { aPoint x. aPoint y. 0. 5. false }

Замените updateEnemies целиком:

Код ITЗагрузка примера кода…

Дополните checkCollisions — пули врага (вставьте после цикла "пуля игрока × враг", до цикла "корабль × враг"):

bullets do: [ :bullet |
(bullet fifth not and: [ invulnerableTimer <= 0 ]) ifTrue: [
bulletRect := bullet first @ bullet second extent: 4 @ 8.
(bulletRect intersects: playerRect) ifTrue: [
deadBullets add: bullet.
self damagePlayer ] ] ].

Дополните drawOn: — zigzag, shooter, оранжевые пули; полный текст — в ревизии.

Самопроверка

  • С 3-й волны — zigzag и shooter.
  • Shooter выдерживает два попадания.
  • Оранжевые пули наносят урон.

Разбор

bullet fifth not — отбор чужих пуль. Один массив bullets на все снаряды упрощает updateBullets, но требует дисциплины с fifth-флагом.

enemy at: 5 put: после выстрела — перезарядка; с ростом waveNumber интервал уменьшается (max: 0 не даёт уйти в минус).


Этап 7 — пауза и полный UI

Цель — клавиши P/R, полный сброс через "Новая игра", финальная отрисовка.

ShooterGame, протокол input

togglePause
paused := paused not

ShooterGameMorph

В handleKeyDown::

(keyName = 'p' or: [ keyName = 'P' ]) ifTrue: [ game togglePause ].
(keyName = 'r' or: [ keyName = 'R' ]) ifTrue: [
game gameOver ifTrue: [ self newGame ] ]

Обновите newGame:

newGame
game resetGame.
pressedKeys removeAll.
spaceHeld := false.
playfieldMorph buildStars.
self refreshDisplay

Замените drawOn: в ShooterPlayfieldMorph на версию из ревизии.

Самопроверка

  • P ставит на паузу; в step модели срабатывает ранний ^ self.
  • R после game over перезапускает партию.
  • "Новая игра" сбрасывает счёт и пересоздаёт звёзды.

Разбор

ДействиеЧто происходит
togglePauseФлаг paused; модель не обновляется, морф всё равно рисует HUD
pressedKeys removeAllПосле рестарта не "залипает" старое направление
buildStarsНовый случайный фон — визуально другая партия
takeKeyboardFocus в openInWorldБез фокуса морф не получит keydown:

Сохраните image (File → Save в Pharo) — иначе классы пропадут при закрытии без сохранения. См. рекомендации по разработке.


Полная ревизия

Сверьте финальные версии ключевых методов с листингами ниже. Если поведение расходится — откройте протокол в Class Browser и сравните построчно.

Запуск

ShooterGameMorph open
Полный drawOn для ShooterPlayfieldMorph

Код ITЗагрузка примера кода…

КлассМетодов (ориентир)Назначение
ShooterGame~35Модель
ShooterPlayfieldMorph3Рисование
ShooterGameMorph~15Окно и ввод

Финальный чек-лист

  • ShooterGameMorph open — окно без ошибок в Transcript.
  • Движение, стрельба, пауза работают.
  • Три типа врагов; волны не заканчиваются.
  • Счёт и жизни обновляются в scoreMorph.
  • Game over; "Новая игра" и R сбрасывают состояние.
  • Image сохранён — классы останутся в образе.

Что дальше

Рефакторинг модели

  • Классы Bullet и Enemy вместо массивов — инкапсуляция и читаемость (ООП).
  • Протокол update у сущностей — полиморфизм через сообщения.

Контент и polish

  • Звук — FMSound или samples в Pharo.
  • Таблица рекордов — сериализация через STON.
  • Боссы и power-up — новые символы-состояния в ShooterGame.

Соседние материалы


В подборках

Статья входит в раздел Smalltalk и перекликается с практикумом игр на Python и разработкой игр на Python.


Содержание