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 попыток” | |
|
| “пока попытка меньше N” | |
|
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 в ветке ошибки ввода обычно достаточно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ