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

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:

initialize
super initialize.
fieldWidth := 480.
fieldHeight := 640.
playerWidth := 28.
playerHeight := 22.
playerSpeed := 6.
playerLives := 3.
shootTimer := 0.
invulnerableTimer := 0.
tickCount := 0.
highScore := 0.
paused := false.
self resetGame

Протокол game:

resetGame
score := 0.
waveNumber := 0.
waveState := #break.
waveBreakTimer := 60.
spawnIndex := 1.
spawnQueue := OrderedCollection new.
spawnDelayTimer := 0.
bullets := OrderedCollection new.
enemies := OrderedCollection new.
gameOver := false.
paused := false.
movingLeft := false.
movingRight := false.
shootTimer := 0.
invulnerableTimer := 0.
tickCount := 0.
self resetPlayer

resetPlayer
playerX := fieldWidth // 2 - (playerWidth // 2).
playerY := fieldHeight - playerHeight - 24.
playerLives := 3.
invulnerableTimer := 90

scoreText
^ 'Счёт: ', score asString, ' Волна: ', waveNumber asString, ' Жизни: ', playerLives asString

statusText
gameOver ifTrue: [ ^ 'Игра окончена! Нажмите "Новая игра" или R' ].
paused ifTrue: [ ^ 'Пауза (P — продолжить)' ].
waveState = #break ifTrue: [ ^ 'Готовься к волне ', (waveNumber + 1) asString, '!' ].
^ '←/→ или A/D — движение, пробел — огонь, P — пауза'

step
gameOver ifTrue: [ ^ self ].
paused ifTrue: [ ^ self ].
tickCount := tickCount + 1

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

fieldExtent
^ fieldWidth @ fieldHeight

playerOrigin
^ playerX @ playerY

playerSize
^ playerWidth @ playerHeight

bulletsList
^ bullets

enemiesList
^ enemies

score
^ score

lives
^ playerLives

wave
^ waveNumber

tickCount
^ tickCount

invulnerable
^ invulnerableTimer > 0

gameOver
^ gameOver

paused
^ paused

0.2 Класс ShooterPlayfieldMorph

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

initializeWithGame: aGame
super initialize.
game := aGame.
self buildStars

buildStars
| extent |
extent := game fieldExtent.
stars := OrderedCollection new.
80 timesRepeat: [
stars add: {
extent x atRandom.
extent y atRandom.
(0.2 + (0.8 atRandom)).
(1 + 2 atRandom)
} ]

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

drawOn: aCanvas
| star x y alpha size blink |
super drawOn: aCanvas.
aCanvas fillRectangle: self bounds color: (Color r: 0.02 g: 0.04 b: 0.12).
stars do: [ :each |
star := each.
x := star first.
y := (star second + (game score \\ 640)) \\ (game fieldExtent y).
alpha := star third.
size := star fourth.
aCanvas fillOval: (x @ y extent: size @ size) color: (Color white alpha: alpha) ].
x := game playerOrigin x.
y := game playerOrigin y.
blink := game invulnerable and: [ (game tickCount bitAnd: 4) = 0 ].
blink ifFalse: [
aCanvas fillRectangle: (x @ y extent: game playerSize) color: (Color r: 0.2 g: 0.9 b: 1.0).
aCanvas fillRectangle: ((x + 4) @ (y + 6) extent: 20 @ 8) color: (Color r: 0.05 g: 0.3 b: 0.5).
aCanvas fillRectangle: ((x + 10) @ (y - 4) extent: 8 @ 6) color: Color white ]

0.3 Класс ShooterGameMorph

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

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

open
^ self new openInWorld

Протокол initialization:

initialize
super initialize.
self color: Color black.
game := ShooterGame new.
pressedKeys := Set new.
spaceHeld := false.
playfieldOrigin := 10 @ 50.
self buildUI.
self startStepping

buildUI
| fieldExtent |
fieldExtent := game fieldExtent.
playfieldMorph := ShooterPlayfieldMorph new initializeWithGame: game.
playfieldMorph extent: fieldExtent.
playfieldMorph position: playfieldOrigin.
self addMorph: playfieldMorph.
self extent: (fieldExtent x + 20) @ (fieldExtent y + 60).
scoreMorph := StringMorph contents: game scoreText font: (TextStyle default fontOfSize: 16).
scoreMorph color: Color white.
self addMorph: scoreMorph.
scoreMorph position: 10 @ 10.
statusMorph := StringMorph contents: game statusText font: (TextStyle default fontOfSize: 12).
statusMorph color: Color lightGray.
self addMorph: statusMorph.
statusMorph position: 10 @ 28.
newGameButton := SimpleButtonMorph new.
newGameButton label: 'Новая игра'; font: (TextStyle default fontOfSize: 14).
newGameButton target: self; actionSelector: #newGame.
self addMorph: newGameButton.
newGameButton position: (fieldExtent x - 90) @ 8.
self refreshDisplay

Протокол 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

handlesKeyboard: evt
^ true

handleKeyDown: anEvent
| keyName |
keyName := anEvent keyName.
pressedKeys add: keyName.
self syncInput

handleKeyUp: anEvent
| keyName |
keyName := anEvent keyName.
pressedKeys remove: keyName ifAbsent: [].
self syncInput

keydown: anEvent
self handleKeyDown: anEvent

keyup: anEvent
self handleKeyUp: anEvent

syncInput
game
movingLeft: ((pressedKeys includes: 'ArrowLeft') or: [ pressedKeys includes: 'a' ] or: [ pressedKeys includes: 'A' ]);
movingRight: ((pressedKeys includes: 'ArrowRight') or: [ pressedKeys includes: 'd' ] or: [ pressedKeys includes: 'D' ])

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

  • ←/→ и 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

enemySpeed
^ 1.5 + (waveNumber * 0.15)

enemySizeFor: aType
aType = #grunt ifTrue: [ ^ 24 @ 20 ].
aType = #zigzag ifTrue: [ ^ 22 @ 18 ].
^ 26 @ 22

enemyHpFor: aType
aType = #shooter ifTrue: [ ^ 2 ].
^ 1

addEnemyAt: aPoint type: aType
enemies add: {
aPoint x.
aPoint y.
aType.
self enemyHpFor: aType.
(80 + 120 atRandom) - (waveNumber * 3 max: 0).
tickCount + (aPoint x asFloat / 8)
}

updateEnemies
| toRemove enemy type size speed x y |
toRemove := OrderedCollection new.
speed := self enemySpeed.
enemies do: [ :each |
enemy := each.
type := enemy third.
size := self enemySizeFor: type.
x := enemy first.
y := enemy second + speed.
enemy at: 1 put: x.
enemy at: 2 put: y.
(y > fieldHeight + 40) ifTrue: [ toRemove add: enemy ] ].
toRemove do: [ :each | enemies remove: each ifAbsent: [] ]

Добавьте в 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

checkCollisions
| playerRect bulletRect enemyRect size points deadBullets deadEnemies killedEnemies |
playerRect := playerX @ playerY extent: playerWidth @ playerHeight.
deadBullets := OrderedCollection new.
deadEnemies := OrderedCollection new.
killedEnemies := Set new.
bullets do: [ :bullet |
bullet fifth ifTrue: [
bulletRect := bullet first @ bullet second extent: 4 @ 10.
enemies do: [ :enemy |
(killedEnemies includes: enemy) ifFalse: [
size := self enemySizeFor: enemy third.
enemyRect := enemy first @ enemy second extent: size.
(bulletRect intersects: enemyRect) ifTrue: [
deadBullets add: bullet.
enemy at: 4 put: (enemy fourth - 1).
(enemy fourth <= 0) ifTrue: [
killedEnemies add: enemy.
deadEnemies add: enemy.
points := enemy third = #shooter
ifTrue: [ 150 ]
ifFalse: [ enemy third = #zigzag ifTrue: [ 75 ] ifFalse: [ 50 ] ].
score := score + points ] ] ] ] ] ].
enemies do: [ :enemy |
size := self enemySizeFor: enemy third.
enemyRect := enemy first @ enemy second extent: size.
(invulnerableTimer <= 0 and: [ enemyRect intersects: playerRect ]) ifTrue: [
deadEnemies add: enemy.
self damagePlayer ] ].
deadBullets do: [ :each | bullets remove: each ifAbsent: [] ].
deadEnemies do: [ :each | enemies remove: each ifAbsent: [] ]

Добавьте в 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

updateWaves
waveState = #break ifTrue: [
waveBreakTimer := waveBreakTimer - 1.
waveBreakTimer <= 0 ifTrue: [
self beginWave ] ].
waveState = #spawning ifTrue: [
spawnDelayTimer := spawnDelayTimer - 1.
spawnDelayTimer <= 0 ifTrue: [
self spawnNextEnemy.
spawnDelayTimer := 5 ].
spawnIndex > spawnQueue size ifTrue: [
waveState := #fighting ] ]

beginWave
waveNumber := waveNumber + 1.
spawnQueue := self buildSpawnQueue.
spawnIndex := 1.
spawnDelayTimer := 0.
waveState := #spawning

buildSpawnQueue
| count queue spacing i pattern row |
queue := OrderedCollection new.
count := (waveNumber + 3) min: 18.
pattern := waveNumber \\ 4.
pattern = 0 ifTrue: [
spacing := fieldWidth // (count + 1).
1 to: count do: [ :n |
queue add: (spacing * n) @ -30 ] ].
pattern = 1 ifTrue: [
spacing := fieldWidth // (count + 1).
1 to: count do: [ :n |
row := (n - 1) \\ 3.
queue add: (spacing * n) @ (-30 - (row * 28)) ] ].
pattern = 2 ifTrue: [
1 to: count do: [ :n |
queue add: (40 + ((fieldWidth - 80) * (n - 1) / (count - 1 max: 1)) asInteger) @ -30 ] ].
pattern = 3 ifTrue: [
1 to: count do: [ :n |
i := n - 1.
queue add: ((fieldWidth // 2) + ((i - (count // 2)) * 36)) @ (-30 - (i abs * 12)) ] ].
^ queue

spawnNextEnemy
| point |
spawnIndex > spawnQueue size ifTrue: [ ^ self ].
point := spawnQueue at: spawnIndex.
self addEnemyAt: point type: self randomEnemyType.
spawnIndex := spawnIndex + 1

checkWaveComplete
(enemies isEmpty and: [ waveState = #fighting ]) ifTrue: [
waveState := #break.
waveBreakTimer := 90 ]

Добавьте в 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 целиком:

updateEnemies
| toRemove enemy type size speed shootTimer x y phase dx |
toRemove := OrderedCollection new.
speed := self enemySpeed.
enemies do: [ :each |
enemy := each.
type := enemy third.
size := self enemySizeFor: type.
x := enemy first.
y := enemy second + speed.
phase := enemy sixth.
type = #zigzag ifTrue: [
dx := (phase sin * 2.5) asInteger.
x := (x + dx) max: 0 min: fieldWidth - size x ].
enemy at: 1 put: x.
enemy at: 2 put: y.
(y > fieldHeight + 40) ifTrue: [
toRemove add: enemy.
self damagePlayer ].
type = #shooter ifTrue: [
shootTimer := enemy fifth - 1.
enemy at: 5 put: shootTimer.
shootTimer <= 0 ifTrue: [
self addEnemyBulletAt: (x + (size x // 2) - 2) @ (y + size y).
enemy at: 5 put: (100 + 80 atRandom) - (waveNumber * 2 max: 0) ] ] ].
toRemove do: [ :each | enemies remove: each ifAbsent: [] ]

Дополните 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
drawOn: aCanvas
| star x y alpha size enemy type enemySize bullet isPlayer blink |
super drawOn: aCanvas.
aCanvas fillRectangle: self bounds color: (Color r: 0.02 g: 0.04 b: 0.12).
stars do: [ :each |
star := each.
x := star first.
y := (star second + (game score \\ 640)) \\ (game fieldExtent y).
alpha := star third.
size := star fourth.
aCanvas fillOval: (x @ y extent: size @ size) color: (Color white alpha: alpha) ].
game enemiesList do: [ :enemy |
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 ].
type = #zigzag ifTrue: [
aCanvas fillRectangle: (x @ y extent: enemySize) color: (Color r: 1.0 g: 0.5 b: 0.1).
aCanvas fillRectangle: ((x + 4) @ (y + 4) extent: (enemySize x - 8) @ (enemySize y - 8)) color: (Color r: 0.15 g: 0.05 b: 0.05) ].
type = #shooter ifTrue: [
aCanvas fillRectangle: (x @ y extent: enemySize) color: (Color r: 0.7 g: 0.2 b: 1.0).
aCanvas fillRectangle: ((x + 6) @ (y + enemySize y - 6) extent: 14 @ 4) color: Color yellow ] ].
game bulletsList do: [ :bullet |
isPlayer := bullet fifth.
x := bullet first.
y := bullet second.
isPlayer
ifTrue: [ aCanvas fillRectangle: (x @ y extent: 4 @ 10) color: Color yellow ]
ifFalse: [ aCanvas fillOval: (x @ y extent: 6 @ 6) color: (Color orange red: 1.0 green: 0.4 blue: 0.1) ] ].
x := game playerOrigin x.
y := game playerOrigin y.
blink := game invulnerable and: [ (game tickCount bitAnd: 4) = 0 ].
blink ifFalse: [
aCanvas fillRectangle: (x @ y extent: game playerSize) color: (Color r: 0.2 g: 0.9 b: 1.0).
aCanvas fillRectangle: ((x + 4) @ (y + 6) extent: 20 @ 8) color: (Color r: 0.05 g: 0.3 b: 0.5).
aCanvas fillRectangle: ((x + 10) @ (y - 4) extent: 8 @ 6) color: Color white ]
КлассМетодов (ориентир)Назначение
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.


Содержание
Освоение главы0%