Я делаю небольшую игру «Балконный сад»: симулятор растений в реальном времени. Пять горшков на подоконнике, тридцать растений и никаких подсказок. Влажность видно только по цвету земли, болезнь — только по листьям. Время идёт и при закрытой игре.
После первых отзывов мне написали: «Умирание растений при закрытой игре отдаёт мобильной духотой. Вот если бы это было на рабочем столе…». Так появился режим «Подоконник»: игра сворачивается в маленькую прозрачную полку с горшками, которая висит поверх всех окон.
В статье разберу, как это сделано в Godot 4.3: прозрачное окно без рамки, клики сквозь пустые области, покачивание пиксель-арта без «мыла» и рост растений без анимаций. Кода немного, но в паре мест есть подводные камни.

В Godot прозрачность окна включается в двух местах. Сначала в настройках проекта:
[display]window/per_pixel_transparency/allowed=true
Без этого флага всё остальное молча не работает: окно остаётся с чёрным фоном.
Дальше окно переключается в «полочный» режим прямо во время игры:
func _enter_mini() -> void: var w := get_window() _saved_win = {"size": w.size, "pos": w.position, "mode": w.mode} w.mode = Window.MODE_WINDOWED w.transparent_bg = true # фон вьюпорта прозрачный w.transparent = true # само окно прозрачное w.borderless = true w.always_on_top = true w.content_scale_size = MINI_BASE # 448×184 — «внутреннее» разрешение полки _apply_mini_window()
Здесь два флага, и нужны оба: transparent_bg отвечает за фон вьюпорта, transparent — за само окно ОС. Если включить только один, получится либо чёрный прямоугольник, либо прозрачное окно, в котором всё равно рисуется серый фон.
content_scale_size позволяет не трогать саму сцену. Игра рисует полку в своём маленьком разрешении, а окно растягивает её целым множителем (×1 или ×2, кнопка «±»). Пиксель-арт при этом остаётся чётким.
Окно ставим в правый нижний угол рабочей области, чтобы не налезать на панель задач:
var area := DisplayServer.screen_get_usable_rect(w.current_screen)w.position = area.position + area.size - win_size - Vector2i(16, 0)
Окно прозрачное, но всё равно прямоугольное и перехватывает мышь целиком. Пользователь кликает «мимо» горшков по браузеру, а клик уходит в игру.
Решение — DisplayServer.window_set_mouse_passthrough(). В него передаётся многоугольник: внутри него мышь ловит окно, снаружи клики проходят в окна под ним.
# ловим только полосу с горшками и кнопками слева, остальное — насквозьvar y0 := float((MINI_BASE.y - 12 - 58) * s)DisplayServer.window_set_mouse_passthrough(PackedVector2Array([ Vector2(0, y0), Vector2(22 * s, y0), Vector2(22 * s, (MINI_BASE.y - 12 - 34) * s), Vector2(win_size.x, (MINI_BASE.y - 12 - 34) * s), Vector2(win_size.x, win_size.y), Vector2(0, win_size.y)]))
Подводные камни:
1Координаты задаются в пикселях окна, а не сцены. При масштабе ×2 их нужно умножить, поэтому в коде везде стоит * s.
2 При выходе из режима полигон надо сбросить пустым массивом, иначе развёрнутое окно тоже будет пропускать клики:
DisplayServer.window_set_mouse_passthrough(PackedVector2Array())
3 Листья, которые торчат выше полосы, клики не ловят. Для полки это даже удобно: высокий подсолнух не мешает работать с окном под ним.
Полку можно таскать мышью. Тут есть ловушка: координаты события (event.position) считаются относительно окна, а окно в это время само движется, поэтому полка начинает дрожать. Нужно брать экранную позицию мыши:
func _on_shelf_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _dragging = event.pressed _drag_offset = DisplayServer.mouse_get_position() - get_window().position elif event is InputEventMouseMotion and _dragging: get_window().position = DisplayServer.mouse_get_position() - _drag_offset
Клик по горшку разворачивает игру обратно, сразу на этом горшке.

Растения хотелось сделать живыми. Но пиксель-арт плохо переносит обычные трансформации: при повороте или растяжении пиксели режутся, и картинка «плывёт».
Поэтому покачивание сделано шейдером, который сдвигает каждую строку спрайта по горизонтали на целое число крупных пикселей. Чем выше строка, тем сильнее сдвиг: основание стоит на месте, макушка гуляет.
shader_type canvas_item;uniform float amp = 2.2; // максимальный сдвиг макушки, пикселей артаuniform float speed = 0.9;uniform float phase = 0.0; // у каждого горшка своя, чтобы не качались синхронноuniform float tex_w = 80.0;uniform float tex_h = 140.0;uniform float px = 2.0; // размер «крупного пикселя» растенияvarying vec4 vcol;void vertex() { vcol = COLOR; }void fragment() { // высота считается по крупным пикселям, чтобы сдвиг не резал пиксель пополам float h = 1.0 - floor(UV.y * tex_h / px) * px / tex_h; // 0 у основания, 1 наверху float wave = sin(TIME * speed + phase + h * 2.2) + 0.35 * sin(TIME * speed * 2.3 + phase * 1.7); float shift = round(amp * h * h * wave / px) * px; // целые пиксели! vec2 uv = UV; uv.x -= shift / tex_w; COLOR = texture(TEXTURE, uv) * vcol;}
Детали, которые оказались важными:
-
h * hвместоh: нижняя половина почти не двигается, и стебель не выглядит резиновым. -
Вторая синусоида с другой частотой убирает механическую ритмичность.
-
round(... / px) * px: сдвиг кратен размеру крупного пикселя арта (у меня 2 пикселя текстуры). Без этого строки съезжают на полпикселя, и на контуре появляется «лесенка». -
vcol: modulate спрайта (болезнь, заморозка, смерть) задаётся через цвет вершины, и его нужно протащить вfragmentвручную. Иначе тонировка пропадает.
Замороженным и мёртвым растениям я просто ставлю amp = 0. Это неожиданно работает как геймплейная подсказка: сразу видно, кто живой.
Каждое растение — это шесть PNG 80×140: стебель, листва, плоды и их «больные» версии. Все слои одного размера, растение стоит на нижнем крае.

Рост — это просто region_rect, который открывает спрайт снизу вверх:
func _set_layer(key: String, t: Texture2D, ry: int, rh: int) -> void: var s: Sprite2D = layers[key] s.texture = t s.region_rect = Rect2(0, ry, t.get_width(), rh) s.position = Vector2((ART_W - t.get_width()) / 2, ry - t.get_height()) s.visible = rh > 0# видимая высота = доля возраста × максимальная высота из таблицы_set_layer("foliage", tex, tex.get_height() - hgt, hgt)
Плюс подхода: художник рисует одну картинку взрослого растения, а все стадии роста получаются автоматически. Плоды проявляются так же, но начинают расти только после взросления.

Болезнь — это прозрачность больных слоёв поверх здоровых:
var a := clampf(p["disease"] / 100.0 * 1.15, 0.0, 1.0)for k in ["stem_sick", "foliage_sick", "fruit_sick"]: layers[k].modulate.a = a
Получается плавный переход «зелёное → жёлтое в пятнах», и для него не нужен ни один кадр анимации.
1 игровой час = 10 реальных минут. При сохранении пишется unix-время, при загрузке пропущенные часы досчитываются:
var elapsed := Time.get_unix_time_from_system() - float(cfg.get_value("game", "saved_at", 0.0))if elapsed > 0.0: _accum += elapsed var hours := int(_accum / REAL_SECONDS_PER_GAME_HOUR) _accum -= hours * REAL_SECONDS_PER_GAME_HOUR for i in mini(hours, MAX_OFFLINE_HOURS): tick_hour()
Остаток _accum тоже сохраняется. Иначе каждый перезапуск «съедает» до 10 минут игрового времени, и игроки, которые часто открывают и закрывают игру, растят растения медленнее.
Именно этот кусок и рождает главную драму игры. Огурцу нужна влажная земля, он быстро сохнет, и за ночь (это 6–8 игровых часов) успевает высохнуть и заболеть. Отсюда «заморозка» горшка и режим «Подоконник», где сад всегда на виду.
Godot по умолчанию пытается импортировать .csv как таблицу переводов. Чтобы он оставил файл как есть, рядом лежит plants_data.csv.import:
[remap]importer="keep"
А в пресете экспорта файл добавлен в include_filter, иначе он не попадёт в сборку.
Итог
Без режима «Подоконник» игра ощущалась как тамагочи, который упрекает тебя за то, что ты закрыл приложение. С ним сад стал фоновым соседом: работаешь, а краем глаза видишь, что у огурца посветлела земля.
Если будут вопросы по коду, отвечу в комментариях. Игра бесплатная, версия альфа 0.2: grag-hack.itch.io/balcony-garden. Арт пока сгенерирован процедурно скриптом на Python, в будущем буду искать художника
Над музыкой и звуками уже работает мой знакомый: в следующей версии вместо синтезированной петли будет живой саундтрек.
ссылка на оригинал статьи https://habr.com/ru/articles/1097884/