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