1. Чому в грі важливі правила
Коли ми пишемо навчальні приклади на 5 рядків, мозок спокійно тримає все одразу: «ось змінна, ось умова». Але щойно ми складаємо цілісну гру, починається магія іншого ґатунку: якщо правила не зафіксовані, код перетворюється на набір «ну тут наче так», «а тут — про всяк випадок — інакше». Тож сьогодні ми робимо не «гру», а її зрозумілу модель: що вважається спробою, коли гра закінчується, які в нас налаштування та що саме змінюється під час гри.
Скажімо чесно: гра «Вгадай число» здається простою рівно до моменту, коли ви намагаєтеся пояснити компʼютеру, що таке «спроба» й «некоректне введення». Компʼютер — істота прямолінійна. Якщо ви не сказали, що некоректне введення не витрачає спробу, він «рахуватиме» як вийде (зазвичай — неправильно, зате впевнено).
Щоб не лікувати симптоми пізніше, почнемо з найнуднішої (і найкориснішої) частини розроблення: перекладу вимог у змінні та перевірки.
2. Правила гри: формулюємо вимоги
Будь-яка програма починається з тексту людською мовою: «користувач робить те-то», «якщо сталося це — то-то». Цей текст ми маємо перетворити на правила, які неможливо трактувати двозначно. Сьогодні наше завдання — зібрати мінімальний набір правил, за якими гра буде чесною та передбачуваною: діапазон секрету, обмеження за спробами, підказки й умови завершення. Ми поки не пишемо весь цикл гри, а створюємо основу, на якій він не розвалиться.
Базовий набір правил
Уявімо, що вимоги до гри такі:
- Секретне число лежить у діапазоні від min до max (включно).
- У гравця є maxAttempts спроб.
- На кожній спробі гравець вводить число.
- Якщо число менше за секрет — виводимо «занадто мало».
- Якщо більше — «занадто багато».
- Якщо дорівнює — «вгадав!» і гра закінчується перемогою.
- Якщо спроби закінчилися — гра закінчується поразкою.
- Якщо введення некоректне (не число або поза діапазоном) — потрібно вирішити заздалегідь: витрачаємо спробу чи ні.
Зверніть увагу: останній пункт — це не «деталь реалізації», а справжнє правило гри. У різних версіях гри воно різне, і обидва варіанти нормальні. Погано лише одне: не вирішити цього заздалегідь.
Некоректне введення: витрачати спробу чи ні
Тому що від цього залежить місце, де у вас зʼявиться attempt++ у циклі. І це класичне джерело багів «на 1»: ви наче зробили все правильно, але гра раптом дає на одну спробу більше або менше, або зациклюється на «введіть число».
Сьогодні ми виберемо один варіант і будемо його дотримуватися. Для першої версії гри візьмемо дружню політику:
Некоректне введення не витрачає спробу.
Так простіше для початківців: менше «покарань» за друкарські помилки та легше налагоджувати.
3. Налаштування і стан: val проти var
Коли ви вперше вчитеся програмувати, дуже легко зробити все var. Мовляв, «нехай буде змінюваним — раптом знадобиться». Але в проєктах (навіть маленьких) це швидко призводить до хаосу: ви випадково змінили maxAttempts посеред гри — і логіка «чесно зламалася», без попереджень. Тому ми вводимо важливе розділення: налаштування і стан.
Налаштування — це те, що задає сценарій гри й упродовж гри не змінюється: діапазон секрету, ліміт спроб, можливо, текстові повідомлення. Стан — це те, що змінюється: номер поточної спроби, факт перемоги, поточна здогадка тощо.
У Kotlin це дуже зручно відображається через val і var.
Міні‑приклад: налаштування і стан
val min = 1
val max = 100
val maxAttempts = 7
var attempt = 1
var isWon = false
Сенс цього коду важливіший, ніж здається. val тут — не «заборона на зміну заради заборони», а спосіб підстрахуватися від себе ж за тиждень (або за дві години). А var — чесне зізнання: «це буде змінюватися».
Налаштування vs стан: шпаргалка
| Сутність | Приклад імені | val або var | Чому |
|---|---|---|---|
| Діапазон секрету | |
|
Правила гри не повинні «пливти» |
| Ліміт спроб | |
|
Інакше можна «випадково ввімкнути чити» |
| Поточна спроба | |
|
Зростає з кожною витраченою спробою |
| Перемога/поразка | |
|
Спочатку не виграли, потім (можливо) виграли |
4. Стан гри: що зберігати
Слово «стан» звучить як щось філософське: «стан духу програми». На практиці це дуже приземлена річ: набір змінних, які відповідають на запитання «де ми зараз?». Якщо ви не зберігаєте стан явно, він стає «розмазаним» по умовах і break, і зрештою ви самі перестаєте розуміти, чому гра закінчилася.
Почнемо з мінімального стану, якого достатньо для нормального сценарію:
- поточний номер спроби;
- прапорець перемоги (isWon);
- (трохи пізніше) секретне число;
- (усередині ітерації циклу) поточна здогадка.
Навіщо потрібен прапорець isWon
Можна спробувати визначати перемогу так: «якщо вийшли з циклу раніше — отже, виграли». Іноді це працює, доки гра маленька. Але щойно додаються додаткові причини вийти (наприклад, користувач вводить команду exit), «ранній вихід» перестає означати перемогу.
Прапорець isWon робить підсумковий висновок однозначним і читабельним: if (isWon) ... else ....
Міні‑приклад: тимчасово фіксуємо секрет
Важливо: у наступній лекції ми генеруватимемо секрет через Random правильно й без помилок «на 1». Зараз ми не про генерацію, а про структуру. Тому тимчасово зафіксуємо секрет константою — так ще й зручніше перевіряти логіку.
val min = 1
val max = 100
val maxAttempts = 7
val secret = 42 // тимчасово фіксуємо для передбачуваності
var attempt = 1
var isWon = false
5. Інваріанти та перевірки через require
Інваріант — це правило, яке має бути істинним не «в середньому», а завжди, інакше гра стає некоректною. Для «Вгадай число» інваріанти прості, але вкрай корисні: якщо їх порушити, програма поводитиметься дивно, а ви довго дивитиметеся на код із виразом обличчя «ну як так».
Ми введемо кілька інваріантів і вирішимо, де саме їх перевіряти, щоб ловити помилки якомога раніше.
Приклади інваріантів
Інваріант № 1: min <= max. Якщо діапазон «перевернутий», усі перевірки x in min..max працюватимуть несподівано.
Інваріант № 2: maxAttempts > 0. Інакше гра або програна одразу, або цикл узагалі не стартує.
Інваріант № 3: секрет має бути всередині діапазону. Сьогодні секрет фіксований, але правило все одно існує.
Інваріант № 4: attempt не повинен «злітати» до 0 або відʼємних значень. Для людини це «очевидно», для програми — лише якщо ви так написали.
Перевіряємо налаштування через require(...)
Ви вже зустрічали require у темі про передумови та fail-fast: якщо вхідні параметри неправильні, краще впасти одразу зі зрозумілим повідомленням, ніж продовжувати «жити» в некоректному стані.
Зробімо маленьку функцію-перевірку налаштувань:
fun validateGameSettings(min: Int, max: Int, maxAttempts: Int) {
require(min <= max) { "Некоректний діапазон: min=$min, max=$max" }
require(maxAttempts > 0) { "maxAttempts має бути > 0, зараз $maxAttempts" }
}
Ця функція нічого не повертає (тобто повертає Unit). Її завдання — або підтвердити коректність налаштувань, або зупинити програму зі зрозумілою причиною.
Використовуємо перевірку в main
fun main() {
val min = 1
val max = 100
val maxAttempts = 7
validateGameSettings(min, max, maxAttempts)
println("Налаштування гри коректні") // Налаштування гри коректні
}
Поки це виглядає як «забагато коду заради одного рядка», але в проєкті це окупається дуже швидко. Помилки в налаштуваннях — найприкріші, бо вони ламають усе одразу.
6. Правила як функції та контракти
Коли правило зібране в одному місці, воно зазвичай правильне. Коли ви копіюєте його в пʼять місць, в одному з них обовʼязково зʼявиться «майже те саме», але не зовсім. Тому навіть просте правило на кшталт «число має бути в діапазоні» корисно оформити окремою функцією, щоб читати код як історію: «перевірили діапазон → пішли далі».
У цьому розділі ми зафіксуємо кілька зручних «контрактів»: про діапазон, про результат порівняння та про те, де саме «живе» спроба.
Правило діапазону як функція
Зробімо функцію isInRange:
fun isInRange(x: Int, min: Int, max: Int): Boolean {
return x in min..max
}
Так, це всього один рядок. Але він дає два практичні плюси: єдиний зміст і єдине імʼя. Потім, коли ви читатимете код циклу, ви побачите if (!isInRange(...)), і мозок одразу зрозуміє, що відбувається, без розшифровки x in min..max серед інших умов.
Три наслідки порівняння: менше / більше / дорівнює
Коли людина грає, вона мислить текстом: «менше», «більше», «вгадав». Коли ми пишемо програму, нам потрібно подати ці три наслідки у вигляді чогось, що зручно перевіряти й передавати між функціями. У наступних лекціях ми винесемо порівняння та підказки в окремі функції, а сьогодні зафіксуємо саму ідею: результат порівняння завжди один із трьох.
Часто для цього використовують код результату: -1, 0, 1. Це не «магія чисел», якщо ми заздалегідь домовимося, що вони означають.
const val TOO_SMALL = -1
const val CORRECT = 0
const val TOO_BIG = 1
Тепер замість «чому тут -1?» буде «а, TOO_SMALL».
Поки без повного порівняння — просто покажемо ідею: за кодом результату можна вибрати текст.
fun hintText(result: Int): String {
return when (result) {
TOO_SMALL -> "Занадто мало"
TOO_BIG -> "Занадто багато"
else -> "Вгадав!"
}
}
У цій лекції важливо не те, що else ловить «вгадав», а те, що ми починаємо мислити контрактами: «функція отримує один із трьох кодів і повертає зрозумілий текст». Контракти — це як правила дорожнього руху для ваших функцій. Без них код починає «перебігати дорогу де завгодно».
Домовленість про спроби та attempt++
Якби існував чемпіонат світу з багів, то помилка «attempt++ стоїть не там» упевнено увійшла б у топ‑3. Проблема в тому, що в голові людини «спроба» — це абстрактний крок, а в коді «спроба» має бути конкретною точкою, де ви збільшили лічильник. Якщо ви збільшили лічильник завчасно — отримуєте перемогу «за 8 спроб із 7». Якщо не збільшили — отримуєте вічний цикл.
Ми вже вибрали правило: некоректне введення не витрачає спробу. Отже:
- коли введення некоректне — ми показуємо повідомлення й повторюємо ту саму спробу;
- коли введення коректне, але не вгадали — спробу витрачено, збільшуємо attempt.
Поки ми не пишемо повний цикл, але місце для attempt++ уже можемо позначити логічно: воно стоїть після того, як коректна спроба завершилася невдачею.
Текст статусу спроби
Коли ви робитимете основний цикл, там і так буде багато логіки: введення, перевірка null, порівняння, break/continue. Тому будь-які дрібні «косметичні» речі краще заздалегідь оформити так, щоб у циклі не було зайвого шуму. Один із таких «шумів» — формування тексту «Спроба N із M».
Зробімо маленьку функцію:
fun attemptStatusText(attempt: Int, maxAttempts: Int): String {
return "Спроба $attempt із $maxAttempts"
}
І покажемо, як вона виглядає під час виведення:
val attempt = 1
val maxAttempts = 7
println(attemptStatusText(attempt, maxAttempts)) // Спроба 1 із 7
Це не «архітектура», а елементарна турбота про мозок читача. Читач — це ви за кілька днів.
7. Скелет сценарію: ініціалізація → гра → підсумок
Велика програма лякає не тому, що вона складна, а тому, що незрозуміло, «де початок, де середина, де кінець». Тому ми намічаємо скелет сценарію вже зараз: які етапи будуть у main, у якому порядку та які змінні мають існувати, щоб кожен етап був можливим.
Ця структура — як план походу. Навіть якщо ви поки не вмієте розпалювати багаття (цикл спроб), ви принаймні знаєте, куди йдете і що берете із собою (налаштування та стан).
Блок‑схема сценарію
flowchart TD
A[Ініціалізація налаштувань] --> B[Перевірка налаштувань require]
B --> C[Створення початкового стану]
C --> D[Цикл спроб: введення → перевірка → підказка]
D --> E{Вгадав?}
E -->|так| F[Перемога: друкуємо підсумок]
E -->|ні| G{Спроби закінчилися?}
G -->|так| H[Поразка: друкуємо секрет]
G -->|ні| D
Сьогодні ми закриваємо перші три блоки: налаштування, перевірка, стан. Цикл і генерацію секрету розберемо в наступних лекціях.
Каркас main із тимчасовим секретом
fun main() {
val min = 1
val max = 100
val maxAttempts = 7
validateGameSettings(min, max, maxAttempts)
val secret = 42 // поки фіксуємо; генерація буде в наступній лекції
var attempt = 1
var isWon = false
println("Гра почалася! Я загадав число від $min до $max.") // Гра почалася! Я загадав число від 1 до 100.
}
Поки гра не «грає», але це чесно: ми будуємо фундамент. І важливо, що змінні вже названі «за змістом», а не «a, b, n». Інакше далі буде складно читати власний код.
8. Типові помилки в цій частині проєкту
Помилка № 1: робити налаштування var, бо «так звичніше».
У маленьких прикладах цього не видно, але в проєкті це призводить до підступних багів: ви десь випадково присвоїли max = 0, і діапазон став дивним. Налаштування — це правила гри, а правила зазвичай не змінюються під час матчу.
Помилка № 2: не домовитися, чи витрачає некоректне введення спробу.
Якщо ви не вирішили цього заздалегідь, то attempt++ починає зʼявлятися «десь поруч»: один раз збільшили, інший раз забули, третій раз збільшили двічі. У результаті логіка спроб розповзається, і ви ловите баги «на 1», які неприємно налагоджувати.
Помилка № 3: змішувати стан і правила в одному місці.
Коли змінні attempt, isWon, min, max оголошені впереміш і без сенсу, ви втрачаєте структуру. Хороша ознака порядку: спочатку налаштування (val), потім стан (var). Це не обовʼязкове правило мови Kotlin, але дуже корисне правило для вашої голови.
Помилка № 4: не перевіряти інваріанти через require, а сподіватися на удачу.
Якщо min > max, гра працюватиме «якось», і ви можете довго не розуміти, чому введення «в діапазоні» не проходить перевірку. require дозволяє впасти одразу, зі зрозумілим повідомленням, і не перетворювати налагодження на археологію.
Помилка № 5: використовувати «магічні числа» замість осмислених імен.
Якщо у вас у коді зʼявляються 7, 100, -1, 0, 1 без пояснень, то далі ви читатимете програму як ребус. Навіть прості константи на кшталт maxAttempts і TOO_SMALL різко підвищують читабельність, особливо коли логіки стає більше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ