Smalltalk — SmallShooter
О практикуме
SmallShooter — учебный вертикальный shoot'em up (шутер с видом сверху, корабль внизу, враги сверху). Жанр родом из аркад 1980-х (Galaga, 1942, Xevious): короткие сессии, нарастающая сложность, счёт и жизни.
Соберём игру в Pharo на Morphic — без pip, без Pygame, без спрайтов. Всё рисуем примитивами (fillRectangle, fillOval), логику держим в обычных объектах Smalltalk, а окно и клавиатуру — в морфах.
В разработке игр на 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 в отдельные классы — см. раздел "Что дальше".
Как проходить практикум
- Установите Pharo 10+ — инструкция в первой программе.
- Создайте категорию
SmallShooterв System Browser. - На каждом этапе добавляйте методы в указанные протоколы и жмите Accept (Ctrl+S).
- Запускайте
ShooterGameMorph openв Playground; кликните по окну, иначе клавиатура не дойдёт до морфа. - Пройдите самопроверку в конце этапа.
- Если что-то расходится — полная ревизия.
Управление в финальной версии
| Клавиша | Действие |
|---|---|
| ← / → или A / D | Движение корабля |
| Пробел | Огонь (можно удерживать) |
| P | Пауза / продолжить |
| R | Рестарт после game over |
| Кнопка "Новая игра" | Сброс счёта и волны |
Карта этапов
| Этап | Тема | Классы |
|---|---|---|
| 0 | Окно и модель | все три — каркас |
| 1 | Движение | ShooterGame, ShooterGameMorph |
| 2 | Стрельба | ShooterGame, отрисовка пуль |
| 3 | Враги Grunt | ShooterGame, ShooterPlayfieldMorph |
| 4 | Столкновения и счёт | ShooterGame |
| 5 | Волны | ShooterGame |
| 6 | Zigzag и Shooter | ShooterGame, отрисовка |
| 7 | UI и пауза | ShooterGameMorph |
Архитектура
Три класса разделяют ответственность — модель, холст и окно:
ShooterGameMorph ← окно, клавиатура, step / stepTime
├── StringMorph ← счёт, строка статуса
├── SimpleButtonMorph ← "Новая игра"
└── ShooterPlayfieldMorph ← drawOn — фон, звёзды, объекты
└── game: ShooterGame ← логика без Morphic
| Класс | Роль |
|---|---|
ShooterGame | Корабль, пули, враги, волны, столкновения |
ShooterPlayfieldMorph | Canvas — звёзды, прямоугольники и овалы |
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 | Один метод собирает начальное состояние партии |
startStepping | Morphic начнёт слать step этому морфу |
stepTime → 16 | 1000 / 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: 0 | max: не даёт уйти левее нуля |
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, y | 1, 2 | Позиция |
| type | 3 | #grunt, #zigzag, #shooter |
| hp | 4 | Жизни; у shooter будет 2 |
| shootTimer | 5 | Обратный отсчёт до выстрела |
| phase | 6 | Фаза синусоиды для 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 |
#zigzag | y + sin(phase) по x | 75 |
#shooter | Вниз + периодический выстрел, 2 HP | 150 |
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 | Модель |
ShooterPlayfieldMorph | 3 | Рисование |
ShooterGameMorph | ~15 | Окно и ввод |
Финальный чек-лист
-
ShooterGameMorph open— окно без ошибок в Transcript. - Движение, стрельба, пауза работают.
- Три типа врагов; волны не заканчиваются.
- Счёт и жизни обновляются в
scoreMorph. - Game over; "Новая игра" и R сбрасывают состояние.
- Image сохранён — классы останутся в образе.
Что дальше
Рефакторинг модели
- Классы
BulletиEnemyвместо массивов — инкапсуляция и читаемость (ООП). - Протокол
updateу сущностей — полиморфизм через сообщения.
Контент и polish
- Звук —
FMSoundили samples в Pharo. - Таблица рекордов — сериализация через STON.
- Боссы и power-up — новые символы-состояния в
ShooterGame.
Соседние материалы
- SmallPong — другой жанр, та же схема model + morph.
- SmallDesktop на Morphic — формы и кнопки без игрового цикла.
- Python-практикумы — Bubble Shooter, Match3 и др.; Battle City — на GitHub.
- Компьютерные игры — о разделе — жанры и базовая терминология.
В подборках
Статья входит в раздел Smalltalk и перекликается с практикумом игр на Python и разработкой игр на Python.