JavaRush /Курси /Kotlin SELF /Основний цикл спроб: стійке введення, підказки та підсумо...

Основний цикл спроб: стійке введення, підказки та підсумок

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

1. Основний цикл і чому він важливий

Коли ви пишете гру, майже завжди є фрагмент коду, який відповідає за «серцебиття» програми: повторюваний крок «запитали → обробили → відповіли → перейшли далі». У нашій грі цим кроком є спроби гравця. Якщо цикл написано акуратно, гра відчувається чесною й передбачуваною. Якщо ж цикл написано недбало — ви отримуєте «безсмертного» гравця, нескінченні спроби, перемогу «за 8 спроб із 7» та інші сюжетні повороти, яких не було в сценарії.

Якщо дивитися на структуру програми, порядок зазвичай такий: спочатку задаємо налаштування, потім генеруємо секрет, далі запускаємо цикл, а після нього друкуємо підсумок. Завдяки цьому main() читається як сценарій.

Міні-нарис (поки без деталей):


fun main() {
    // 1) налаштування
    // 2) секрет
    // 3) цикл спроб
    // 4) підсумок
}

Психологічно корисно памʼятати: цикл — це місце, де найчастіше народжуються помилки «на 1». Тому сьогодні ми будемо багато говорити не лише про те, «як написати», а й про те, «де поставити attempt++, щоб потім не плакати».

2. Лічильник спроб: attempt = 1 і умова attempt <= maxAttempts

Цикл спроб можна написати по-різному, але для новачка найпрозоріший варіант — явний лічильник attempt. Ми починаємо з attempt = 1, бо людям звичніше рахувати «перша спроба», а не «нульова». Далі повторюємо кроки, доки attempt <= maxAttempts. Цю умову легко читати, і зазвичай вона збігається з тим, як сформульовано правила.

У Kotlin цикл while працює так: спочатку перевіряємо умову, потім виконуємо тіло, а далі знову перевіряємо умову. Це базова поведінка while — рівно те, що нам потрібно для логіки «поки спроби ще є — граємо».

Міні-приклад «скелета циклу» без гри:

fun main() {
    val maxAttempts = 3
    var attempt = 1

    while (attempt <= maxAttempts) {
        println("Спроба $attempt із $maxAttempts")
        attempt++
    }
    // Спроба 1 із 3
    // Спроба 2 із 3
    // Спроба 3 із 3
}

Зверніть увагу: attempt++ стоїть наприкінці ітерації. На «сухому» прикладі це виглядає природно, але в реальній грі зʼявляться гілки continue і break. І саме тоді місце інкремента стане критичним.

Таблиця «<= чи <»

Іноді новачок пише attempt < maxAttempts — і несподівано отримує на одну спробу менше. Щоб не гадати, зручно тримати в голові просту відповідність:

Що хочемо сказати словами Звична математика Умова циклу за attempt = 1
«рівно N спроб»
1..N
attempt <= maxAttempts
«доки спроба менша за N»
1..N-1
attempt < maxAttempts

3. Стійке введення: Int?, null і правило про спроби

Користувацьке введення — як погода: можна сподіватися на сонце, але краще мати парасольку. У нашій грі «парасолька» — це toIntOrNull() і контракт функції readGuessOrNull(min, max): Int?. Тобто ми не падаємо з винятком, а отримуємо null, якщо введено не число або число поза діапазоном. Це ми зробили в минулій лекції, а сьогодні важливо правильно вбудувати цю функцію в цикл.

І тут зʼявляється питання дизайну гри: некоректне введення вважається спробою чи ні? Це не вибір «правильно/неправильно», а домовленість. Головне — визначитися й виразити правило в коді так, щоб його було складно випадково зламати.

Варіант A: некоректне введення НЕ витрачає спробу

У цьому варіанті, якщо ви ввели "abc" або 9999, ми друкуємо «Некоректне введення» і просимо спробувати ще раз, не збільшуючи attempt.

Ключовий трюк тут — continue. Він перериває поточну ітерацію циклу й переходить до наступної перевірки умови. Kotlin підтримує continue і break як стандартні оператори керування циклом.

Приклад (фрагмент циклу):

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Некоректне введення. Спробуйте ще раз.")
    continue
}

Зауважте «магію» (просту й чесну, без темних обрядів): після continue код нижче не виконується. Отже, ми ніколи не порівнюємо null із секретом і не друкуємо «занадто багато» для неіснуючого числа.

Варіант B: некоректне введення витрачає спробу

Іноді за правилами хочеться строгості: увів нісенітницю — спроба згоріла. Тоді в гілці guess == null ми робимо attempt++ — і лише потім continue.

Міні-приклад:

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Некоректне введення. Спроба втрачена.")
    attempt++ // важливо: спроба згорає саме тут
    continue
}

Дуже поширена логічна помилка — забути attempt++ у цьому варіанті й отримати нескінченний цикл на некоректному введенні. Тоді гра перетворюється на «вічну»: можна вводити «кіт» без кінця, і спроби ніколи не закінчаться. Іноді це навіть кумедно, але зазвичай це баг.

4. Порівняння здогадки й секрету: підказка, перемога і break

Після того як ми отримали коректну здогадку guess: Int, починається найприємніше: порівнюємо її із секретом і друкуємо підказку. У минулій лекції ми вже заготовили функції:

  • compareGuess(guess, secret): Int, повертає -1, 0 або 1;
  • hintText(result): String, повертає «Занадто мало/багато/Вгадав!».

Сьогодні важливо вбудувати це так, щоб:

1) підказка друкувалася завжди для коректного введення;
2) перемога негайно завершувала цикл;
3) лічильник спроб збільшувався лише тоді, коли це потрібно.

Міні-фрагмент:

val result = compareGuess(guess, secret)
println(hintText(result))

if (result == 0) {
    isWon = true
    break
}

break миттєво виходить із циклу. Kotlin гарантує таку поведінку для break/continue у циклах.

Тут є тонкість, яку ви швидко відчуєте на практиці: якщо спочатку зробити attempt++, а вже потім перевірити перемогу, то в підсумковому повідомленні перемога може зʼявитися «на спробу пізніше». Тому перемогу зазвичай фіксують до збільшення лічильника.

5. Де ставити attempt++: головне джерело багів «на 1»

Лічильник спроб — маленька змінна, але з великим характером. Вона раз у раз намагається «поїхати» не туди: збільшитися зайвий раз, не збільшитися в одній із гілок або збільшитися до перевірки перемоги. Тому зараз ми спеціально розберемо «правильну геометрію» attempt++ для найпопулярнішого варіанта: некоректне введення не витрачає спробу.

Ідея проста: спроба вважається витраченою тоді, коли введення було коректним, ми порівняли його із секретом — і це не перемога. Отже, attempt++ логічно поставити наприкінці вдалої (але не переможної) ітерації.

Міні-приклад «правильного місця»:

// guess != null, порівняли, не вгадав
attempt++

А ось приклад «майже правильно, але потім буде боляче»:

attempt++          // збільшили заздалегідь
val guess = readGuessOrNull(min, max)

Чому боляче? Бо якщо введення виявилося некоректним і ми зробили continue, спроба вже збільшилася, хоча за обраною політикою вона не мала збільшуватися. Тобто ви випадково перемкнулися на правило «некоректне введення витрачає спробу» — але без явного наміру.

6. Підсумок після циклу: перемога чи поразка без плутанини

Після циклу в новачків часто виникає спокуса: «ну якщо цикл закінчився, значить програв». Але цикл може завершитися й через перемогу (через break). Тому нам потрібна явна ознака перемоги, наприклад var isWon = false.

Підсумковий блок має бути простим: якщо переміг — вітаємо; інакше — показуємо секрет. І тут важливо не переплутати, яка спроба вважається «кількістю спроб». Якщо attempt збільшується лише після невдалих спроб, то у разі перемоги attempt якраз указує на номер переможної спроби — і це зручно.

Приклад підсумкового виводу:

if (isWon) {
    println("Перемога! Ви вгадали за $attempt спроб(и).")
} else {
    println("Спроби закінчилися. Загадане число: $secret")
}

Якщо у вашій версії attempt збільшується в іншому місці (наприклад, на початку циклу), підсумок теж доведеться перебудувати. Це нормальна частина роботи програміста: код — це договір. Якщо ви змінюєте один пункт договору, змінюються й наслідки.

7. Збирання в єдиний main()

Зараз зберемо все в одну програму. Я навмисно триматиму фрагменти коду невеликими й «склеюваними», щоб ви могли переносити їх крок за кроком, а не копіювати величезний аркуш.

Налаштування і старт гри

Цей блок зазвичай іде першим:

import kotlin.random.Random

fun main() {
    val min = 1
    val max = 100
    val maxAttempts = 7

    val secret = generateSecret(min, max)
    var attempt = 1
    var isWon = false

    println("Я загадав число від $min до $max. У вас $maxAttempts спроб(и).")
}

Тут generateSecret(min, max) — функція з попередньої лекції (з require(min <= max) і Random.nextInt(from, until)).

Основний цикл

Це центральний фрагмент. Зверніть увагу на порядок: статус спроби → введення → обробка null → порівняння → підказка → перемога → збільшення лічильника спроб.

while (attempt <= maxAttempts) {
    println("Спроба $attempt із $maxAttempts")

    val guess = readGuessOrNull(min, max)
    if (guess == null) {
        println("Некоректне введення. Введіть ціле число в діапазоні.")
        continue
    }

    val result = compareGuess(guess, secret)
    println(hintText(result))

    if (result == 0) {
        isWon = true
        break
    }

    attempt++
}

У цьому коді continue робить структуру рівною: якщо введення некоректне, ми не «провалюємося» далі й не розмножуємо вкладені if. Таке застосування continue — один із небагатьох випадків, коли він справді покращує читабельність.

Підсумок гри

Після циклу — лише підсумок:

if (isWon) {
    println("Перемога! Ви вгадали число за $attempt спроб(и).")
} else {
    println("Ви програли. Загадане число: $secret")
}

Якщо некоректне введення має витрачати спробу

Іноді корисно побачити, що «політика» — це не філософія, а буквально два рядки коду. Уся гра лишається тією самою, змінюється лише гілка guess == null: тепер ми збільшуємо лічильник спроб і продовжуємо.

Ось фрагмент, який відрізняється:

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Некоректне введення. Спроба втрачена.")
    attempt++
    continue
}

Важлива думка: не потрібно переписувати весь цикл, якщо ви змінюєте правило. Добре, коли правило локалізовано в одному місці — це і є «підтримуваність», тільки без гучних слів.

Схема потоку виконання

Коли ви вчитеся програмувати, мозок часто робить так: читає код і намагається уявити всі переходи «як фільм». Це працює на 5 рядках, але починає буксувати на 30. Тому корисно один раз намалювати схему, а далі звірятися з нею, коли щось не працює.

flowchart TD
    A[Початок гри: налаштування, secret, attempt=1] --> B{attempt <= maxAttempts?}
    B -- ні --> Z[Друкуємо підсумок: перемога/поразка]
    B -- так --> C["Друкуємо 'Спроба attempt із maxAttempts'"]
    C --> D["readGuessOrNull(min, max)"]
    D --> E{guess == null?}
    E -- так --> F[Повідомлення про помилку введення]
    F --> B
    E -- ні --> G["compareGuess(guess, secret)"]
    G --> H["Друкуємо hintText(result)"]
    H --> I{result == 0?}
    I -- так --> J[isWon = true; break]
    J --> Z
    I -- ні --> K[attempt++]
    K --> B

Якщо ви змінюєте політику «некоректне введення витрачає спробу», змінюється лише блок F: там зʼявляється attempt++ перед поверненням до перевірки умови.

8. Типові помилки в основному циклі спроб

Помилка № 1: attempt++ стоїть не там, де «витрачається спроба».
Найчастіший баг виглядає так: ви збільшили attempt на початку ітерації, потім отримали некоректне введення й зробили continue. У результаті спроба «згоріла», хоча за вашими правилами вона не мала згорати. А буває й навпаки: ви хотіли, щоб некоректне введення витрачало спробу, але забули збільшити attempt у цій гілці — і отримали нескінченний цикл на «поганих» рядках.

Помилка № 2: перемогу перевіряють після інкремента — і в підсумку спроба виходить на 1 більшою.
Якщо спочатку зробити attempt++, а потім виявити перемогу, підсумок «Перемога за 4 спроби» легко перетворюється на «за 5», хоча виграли ви на 4-й. Лікується просто: фіксуйте перемогу до збільшення лічильника й виходьте з циклу одразу через break.

Помилка № 3: забули обробку null, і логіка порівняння намагається працювати з неіснуючим числом.
Іноді це проявляється навіть не як падіння, а як дивна «архітектура»: студент починає вигадувати чарівне значення на кшталт -999, щоб позначити помилку введення. Набагато чистіше використовувати Int? і явну перевірку guess == null, а потім continue, щоб решта коду працювала лише з коректними даними.

Помилка № 4: цикл закінчується, а програма завжди друкує «поразка», навіть якщо був break.
Це стається, коли підсумковий вивід завʼязаний лише на факт виходу з циклу, а не на причину виходу. Якщо ви виходите через перемогу за допомогою break, цикл теж завершується. Тому потрібен прапорець isWon (або інший явний стан), за яким ви друкуєте підсумок.

Помилка № 5: неузгоджені межі діапазону.
Візуально це виглядає як «гра мене ненавидить»: ви вводите max, а вам кажуть «поза діапазоном». Або ви не можете вгадати число, бо секрет генерується, наприклад, 1..99, хоча ви думаєте, що це 1..100. Рішення просте: min/max мають бути єдиними налаштуваннями, і генерація секрету, і перевірка введення мають спиратися на них.

Помилка № 6: continue використовують так, що код стає важче читати.
continue хороший, коли він допомагає «раніше вийти» з ітерації через помилкове введення й тримає основний шлях рівним. Але якщо continue зʼявляється в пʼяти місцях, читати стає важко: погляд постійно «стрибає». У нашій грі одного continue в гілці помилки введення зазвичай достатньо.

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