Сад поверх всех окон: прозрачное окно, клики насквозь и пиксельный ветер в Godot 4

—

от автора

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

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

В статье разберу, как это сделано в 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/