JavaRush /Курси /Kotlin SELF /Правила гри та стан

Правила гри та стан

Kotlin SELF
Рівень 15 , Лекція 0
Відкрита

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 Чому
Діапазон секрету
min, max
val
Правила гри не повинні «пливти»
Ліміт спроб
maxAttempts
val
Інакше можна «випадково ввімкнути чити»
Поточна спроба
attempt
var
Зростає з кожною витраченою спробою
Перемога/поразка
isWon
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 різко підвищують читабельність, особливо коли логіки стає більше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ