Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c08c16ae | ||
|
|
d8e3c1bb49 |
No files matched your search
+3
-2
@@ -23,8 +23,9 @@ MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry
|
||||
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
|
||||
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
|
||||
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
|
||||
- **VHS:** `shaders/vhs.gdshader` + `vhs_overlay.gd`, только главное меню,
|
||||
чекбокс в настройках, HEADLESS без рендера.
|
||||
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
|
||||
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
|
||||
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
|
||||
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
|
||||
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
|
||||
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
|
||||
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
|
||||
громкость; генератор `audio/generate_ambient.py`.
|
||||
- VHS-эффект только в главном меню (настройки → «ЭКРАН»); игровой UI остаётся
|
||||
чистым, без CRT на весь интерфейс.
|
||||
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
|
||||
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
|
||||
Игровой UI остаётся чистым, без CRT на весь интерфейс.
|
||||
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
|
||||
|
||||
Ограничения:
|
||||
@@ -143,7 +144,6 @@ peak-hour/
|
||||
├─ data/ # balance.json, strings_ru.json, names_ru.json
|
||||
├─ art/fonts/ # Handjet, Press Start 2P + OFL
|
||||
├─ audio/ # sfx/, music/, amb/, generate_*py
|
||||
├─ shaders/ # vhs.gdshader (только главное меню)
|
||||
├─ tests/ # headless-наборы
|
||||
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
|
||||
└─ build/ # артефакты экспорта (gitignored)
|
||||
|
||||
+7
-6
@@ -99,7 +99,7 @@
|
||||
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
|
||||
|
||||
## D14 — фон
|
||||
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
|
||||
**Решение:** фон — статичный градиент + слабый шум 320×180,
|
||||
без полноэкранного fbm; CRT-эффект убрать.
|
||||
**GDD:** §21.
|
||||
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
|
||||
@@ -230,11 +230,12 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
|
||||
|
||||
## D27 — VHS главного меню
|
||||
|
||||
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
|
||||
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
|
||||
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
|
||||
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
|
||||
фильтром, модалки — нет; это не CRT на весь UI.
|
||||
**Решение:** VHS-слой только на главном меню: процедурный
|
||||
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
|
||||
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
|
||||
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
|
||||
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
|
||||
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
|
||||
**GDD:** §21, §25.
|
||||
|
||||
## D28 — ambient machine room
|
||||
|
||||
+9
-6
@@ -188,9 +188,11 @@ SLA ≤ 0 (смена не отработана), экран итогов. Пе
|
||||
декоративных подписей. «Спокойный фон» — плоская `PAPER` без dither
|
||||
(D18, D24, D26).
|
||||
- Маскот **БАЙТ** ведёт короткий туториал первого часа (D24).
|
||||
- VHS-эффект **только на главном меню**: шейдер `shaders/vhs.gdshader` и
|
||||
`scripts/ui/vhs_overlay.gd`; скан-линии 2 физ. px, слабая хроматика, зерно,
|
||||
виньетка, полоса. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
|
||||
- VHS-эффект **только на главном меню**: процедурный слой
|
||||
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
|
||||
`MainMenu._draw` до текста; скан-линии 2 физ. px, слабая хроматика, зерно,
|
||||
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
|
||||
резкий. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
|
||||
Игровой UI без CRT; это не полноэкранный CRT (D14, D27).
|
||||
- Фон: тёплый бумажный дизеринг и светлые панели, без полноэкранного fbm;
|
||||
CRT убрать (D14, D18).
|
||||
@@ -241,9 +243,10 @@ RU-тексты, save/load и мета-открытия, HTML5-сборка ра
|
||||
- **D26 — фиксы аудита:** calm-фон без серого `#C0C0C0`, Esc на итогах →
|
||||
меню без soft-lock, реальное уничтожение папки, прогрессивная мета,
|
||||
час 1 без атак, честный резерв `minigame_advanced`.
|
||||
- **D27 — VHS главного меню:** `vhs.gdshader` + `vhs_overlay.gd`, screen
|
||||
texture 1920, скан-линии 2 физ. px, хроматика/зерно/виньетка; только меню,
|
||||
чекбокс в настройках, HEADLESS без рендера. Не CRT на весь UI.
|
||||
- **D27 — VHS главного меню:** процедурный `vhs_overlay.gd`
|
||||
(`extends RefCounted`) в `MainMenu._draw`; скан-линии, хроматика, зерно,
|
||||
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
|
||||
резкий; только меню, чекбокс, HEADLESS без рендера. Не CRT на весь UI.
|
||||
- **D28 — ambient machine room:** 4 WAV в `audio/amb`, генератор
|
||||
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость;
|
||||
тест `test_ambient` — 74/0.
|
||||
+3
-3
@@ -33,9 +33,9 @@
|
||||
|
||||
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
|
||||
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
|
||||
В главном меню — лёгкий VHS-эффект, который отключается в настройках; в игре
|
||||
UI остаётся чистым. Фон — ambient машинного зала, включается после первого
|
||||
жеста и регулируется вместе с музыкой.
|
||||
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
|
||||
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
|
||||
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
|
||||
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
|
||||
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
|
||||
проекта. Собрано на Godot 4.7.2 для Web без threads:
|
||||
|
||||
+26
-26
@@ -39,7 +39,7 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
|
||||
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
|
||||
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
|
||||
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
|
||||
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
|
||||
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
|
||||
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
|
||||
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
|
||||
|
||||
@@ -68,39 +68,39 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
|
||||
|
||||
## VHS-эффект главного меню
|
||||
|
||||
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
|
||||
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
|
||||
читает готовый кадр через `hint_screen_texture`.
|
||||
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
|
||||
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
|
||||
|
||||
Ключевое — **native-res**: эффект считается в физических пикселях окна
|
||||
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
|
||||
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
|
||||
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
|
||||
Порядок отрисовки:
|
||||
|
||||
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
|
||||
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
|
||||
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
|
||||
буквы не двоятся;
|
||||
- полоса меняет только яркость и не двигает изображение, поэтому текст
|
||||
под ней не «плывёт».
|
||||
1. бумага;
|
||||
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
|
||||
3. карточка, правая панель и декоративные фигуры;
|
||||
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
|
||||
5. текст и кнопки.
|
||||
|
||||
Параметры (`VhsOverlay` / шейдер):
|
||||
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
|
||||
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
|
||||
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
|
||||
и остаётся резким.
|
||||
|
||||
Параметры (`VhsOverlay`):
|
||||
|
||||
| Параметр | Значение | Смысл |
|
||||
| --- | --- | --- |
|
||||
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
|
||||
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
|
||||
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
|
||||
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
|
||||
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
|
||||
| `vignette` | 0.11 | затемнение к краям кадра |
|
||||
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
|
||||
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
|
||||
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
|
||||
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
|
||||
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
|
||||
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
|
||||
| виньетка | 0.13 | затемнение к краям кадра |
|
||||
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
|
||||
|
||||
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
|
||||
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
|
||||
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
|
||||
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
|
||||
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
|
||||
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
|
||||
|
||||
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
|
||||
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
|
||||
|
||||
**Как отключить:**
|
||||
|
||||
|
||||
+30
-8
@@ -31,6 +31,9 @@ var _btn_about: Button90
|
||||
|
||||
static var _card_style: StyleBoxFlat = null
|
||||
|
||||
## Накопитель для перерисовки VHS на 12 Гц.
|
||||
var _vhs_clock := 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
@@ -61,6 +64,18 @@ func is_vhs_enabled() -> bool:
|
||||
return vhs != null and vhs.enabled
|
||||
|
||||
|
||||
## Медленная анимация: полоса и зерно. Перерисовываем меню на 12 Гц —
|
||||
## чаще для такого эффекта не нужно, а текст остаётся тем же.
|
||||
func _process(delta: float) -> void:
|
||||
if vhs == null or not vhs.enabled or not vhs.is_available() or not visible:
|
||||
return
|
||||
vhs.advance(delta)
|
||||
_vhs_clock += delta
|
||||
if _vhs_clock >= 1.0 / 12.0:
|
||||
_vhs_clock = 0.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _build() -> void:
|
||||
_btn_continue = _make_button("Продолжить")
|
||||
_btn_continue.disabled = not has_save
|
||||
@@ -79,14 +94,11 @@ func _build() -> void:
|
||||
_btn_about.pressed.connect(func() -> void: about.emit())
|
||||
|
||||
|
||||
## VHS-слой — полноэкранный ColorRect поверх меню. Он фильтрует карточку,
|
||||
## текст и кнопки, но остаётся внутри MainMenu; модалки — отдельные соседи
|
||||
## в modal_root, они рисуются поверх и не фильтруются.
|
||||
## VHS-слой — процедурный помощник (RefCounted, без Node/Control).
|
||||
## Меню рисует его само в _draw(), поэтому ничто не перекрывает меню
|
||||
## и не перехватывает мышь. Модалки — отдельные соседи в modal_root.
|
||||
func _build_vhs() -> void:
|
||||
vhs = VHS_OVERLAY.new()
|
||||
vhs.name = "VhsOverlay"
|
||||
add_child(vhs)
|
||||
vhs.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func _tr(key: String, fallback: String) -> String:
|
||||
@@ -138,12 +150,22 @@ func _draw() -> void:
|
||||
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g, Theme90.ACCENT_LIGHT.b, 0.10))
|
||||
draw_circle(Vector2(full.size.x * 0.06, full.size.y * 0.96), 300.0,
|
||||
Color(0.96, 0.80, 0.72, 0.09))
|
||||
# 2. Карточка, панель, декоративные фигуры и текст. VHS — отдельный
|
||||
# full-rect слой-ребёнок, он фильтрует весь кадр меню в native-res.
|
||||
# 2. VHS на бумаге.
|
||||
if vhs != null:
|
||||
vhs.draw_layer(self, full)
|
||||
|
||||
var w := _window()
|
||||
var panel := _panel_rect(w)
|
||||
# 3. Поверхности: карточка, панель, декоративные фигуры.
|
||||
_draw_card(w)
|
||||
_draw_actions_surface(w)
|
||||
_draw_hero_shapes(w)
|
||||
# 4. Мягкий VHS-проход по карточке и панели — ДО текста, поэтому буквы
|
||||
# и кнопки остаются резкими.
|
||||
if vhs != null:
|
||||
vhs.draw_layer(self, w, true)
|
||||
vhs.draw_layer(self, panel, true)
|
||||
# 5. Текст поверх эффекта.
|
||||
_draw_hero_text(w)
|
||||
_draw_actions_text(w)
|
||||
|
||||
|
||||
+132
-57
@@ -1,79 +1,154 @@
|
||||
class_name VhsOverlay
|
||||
extends ColorRect
|
||||
## Нативный VHS/CRT-слой главного меню.
|
||||
extends RefCounted
|
||||
## Процедурный VHS-слой главного меню. НЕ создаёт Node/Control и не читает
|
||||
## screen texture.
|
||||
##
|
||||
## Полноэкранный ColorRect с canvas_item-шейдером: читает готовый кадр через
|
||||
## hint_screen_texture и добавляет скан-линии (2 физических px), очень слабое
|
||||
## зерно, хроматику ≤0.0003 UV, виньетку и редкую полосу. Считается в
|
||||
## разрешении фактического окна (1920×1080), а не в логических 1280×720,
|
||||
## поэтому линии ровные, без блоков и banding; текст остаётся резким.
|
||||
## Меню рисует его прямо в MainMenu._draw() в два прохода:
|
||||
## бумага → VHS фона → карточка/панель/фигуры → VHS по ним → текст.
|
||||
## Поэтому слой не перекрывает меню, не перехватывает мышь и не может
|
||||
## затереть кадр фоном подготовки. Кнопки — дети MainMenu, они поверх.
|
||||
##
|
||||
## Слой — ребёнок MainMenu и последний в дереве: он фильтрует меню (включая
|
||||
## кнопки), но соседние модалки в modal_root рисуются поверх и не фильтруются.
|
||||
## Состояние и параметры здесь; рисование вызывает меню:
|
||||
## vhs.draw_layer(self, full_rect)
|
||||
## vhs.advance(delta) + queue_redraw() при анимации
|
||||
##
|
||||
## В HEADLESS материал не создаётся и слой скрыт — тесты не тормозят.
|
||||
## В HEADLESS текстуры не создаются и draw_layer() ничего не рисует.
|
||||
|
||||
const SHADER_PATH := "res://shaders/vhs.gdshader"
|
||||
## Скан-линии: шаг 2 логических px (при 1920×1080 — 3 физ. px), 1 px затемнения.
|
||||
const SCANLINE_STEP := 2.0
|
||||
const SCANLINE_STRENGTH := 0.07
|
||||
const GRAIN_SIZE := 128
|
||||
const VIGNETTE_W := 320
|
||||
const VIGNETTE_H := 180
|
||||
|
||||
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false)
|
||||
## или напрямую overlay.set_enabled(false).
|
||||
var enabled := true:
|
||||
set(value):
|
||||
enabled = value
|
||||
_refresh()
|
||||
const DARK := Color(0.035, 0.045, 0.05)
|
||||
const LIGHT := Color(0.99, 0.99, 0.96)
|
||||
|
||||
## Общая сила слоя, 0.0..0.55. Выше 0.55 текст начинает терять контраст.
|
||||
var intensity := 0.40:
|
||||
set(value):
|
||||
intensity = clampf(value, 0.0, 0.55)
|
||||
if _mat != null:
|
||||
_mat.set_shader_parameter("intensity", intensity)
|
||||
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false).
|
||||
var enabled := true
|
||||
## Общая сила слоя, 0.0..0.55.
|
||||
var intensity := 0.42
|
||||
## Внутреннее время анимации (полоса и зерно).
|
||||
var time := 0.0
|
||||
|
||||
var _mat: ShaderMaterial = null
|
||||
var _grain: ImageTexture = null
|
||||
var _vignette: ImageTexture = null
|
||||
var _band: ImageTexture = null
|
||||
var _ready_ok := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
focus_mode = Control.FOCUS_NONE
|
||||
color = Color(1, 1, 1, 1)
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_material()
|
||||
func _init() -> void:
|
||||
# В headless не тратим время на генерацию текстур и анимацию.
|
||||
_ready_ok = DisplayServer.get_name() != "headless"
|
||||
if not _ready_ok:
|
||||
return
|
||||
_build_grain()
|
||||
_build_vignette()
|
||||
_build_band()
|
||||
|
||||
|
||||
## Публичный переключатель: true — эффект виден, false — слой полностью скрыт.
|
||||
func set_enabled(value: bool) -> void:
|
||||
enabled = value
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return _mat != null
|
||||
return _ready_ok
|
||||
|
||||
|
||||
func _build_material() -> void:
|
||||
# В headless экранный шейдер не нужен и только замедляет тесты.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
visible = false
|
||||
set_process(false)
|
||||
## Продвинуть время анимации. Вызывается меню из _process.
|
||||
func advance(delta: float) -> void:
|
||||
if enabled and _ready_ok:
|
||||
time += delta
|
||||
|
||||
|
||||
## Нарисовать VHS в rect. Canvas — обычно сам MainMenu.
|
||||
##
|
||||
## over_content=false — проход по бумажному фону: скан-линии, зерно,
|
||||
## виньетка и полоса. over_content=true — мягкий проход по карточке и панели
|
||||
## ДО отрисовки текста: чуть слабее, без виньетки, но с той же полосой.
|
||||
func draw_layer(canvas: CanvasItem, rect: Rect2, over_content: bool = false) -> void:
|
||||
if not enabled or not _ready_ok:
|
||||
return
|
||||
var shader := load(SHADER_PATH) as Shader
|
||||
if shader == null:
|
||||
push_warning("VHS: не удалось загрузить %s" % SHADER_PATH)
|
||||
visible = false
|
||||
return
|
||||
_mat = ShaderMaterial.new()
|
||||
_mat.shader = shader
|
||||
_mat.set_shader_parameter("intensity", intensity)
|
||||
material = _mat
|
||||
_refresh()
|
||||
var k := 0.72 if over_content else 1.0
|
||||
|
||||
# 1. Скан-линии: 1 px затемнения каждые 2 px.
|
||||
var scan := Color(DARK.r, DARK.g, DARK.b, SCANLINE_STRENGTH * intensity * k)
|
||||
var y := rect.position.y
|
||||
while y < rect.end.y:
|
||||
canvas.draw_rect(Rect2(rect.position.x, y, rect.size.x, 1.0), scan)
|
||||
y += SCANLINE_STEP
|
||||
|
||||
# 2. Зерно: тайл 128×128, обновляется 2 раза в секунду.
|
||||
var step := int(floor(time * 2.0))
|
||||
var off := Vector2(float((step * 37) % GRAIN_SIZE), float((step * 53) % GRAIN_SIZE))
|
||||
canvas.draw_texture_rect(_grain,
|
||||
Rect2(rect.position - off, rect.size + off), true,
|
||||
Color(1, 1, 1, intensity * k))
|
||||
|
||||
# 3. Виньетка — только у краёв кадра, не на карточке.
|
||||
if not over_content:
|
||||
canvas.draw_texture_rect(_vignette, rect, false, Color(1, 1, 1, intensity))
|
||||
|
||||
# 4. Редкая полоса прокрутки: медленно идёт сверху вниз с лёгким дрожанием.
|
||||
# Позиция считается по логической высоте кадра (720), поэтому полоса
|
||||
# совпадает на бумаге и на карточке; rect только обрезает её.
|
||||
var band_h := 96.0
|
||||
var span := 720.0 + band_h * 2.0
|
||||
var band_top := fposmod(time * 0.055, 1.0) * span - band_h
|
||||
var top := maxf(band_top, rect.position.y)
|
||||
var bottom := minf(band_top + band_h, rect.end.y)
|
||||
if bottom > top:
|
||||
var v0 := (top - band_top) / band_h
|
||||
var v1 := (bottom - band_top) / band_h
|
||||
var src := Rect2(0.0, v0 * _band.get_height(), _band.get_width(),
|
||||
(v1 - v0) * _band.get_height())
|
||||
var jitter := sin(time * 0.7) * 4.0
|
||||
canvas.draw_texture_rect_region(_band,
|
||||
Rect2(rect.position.x + jitter, top, rect.size.x, bottom - top),
|
||||
src, Color(1, 1, 1, intensity * k))
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
# Сеттер может сработать до _ready — тогда состоянием займётся _ready.
|
||||
if not is_node_ready():
|
||||
return
|
||||
var on: bool = enabled and _mat != null
|
||||
visible = on
|
||||
set_process(on)
|
||||
# TIME в canvas_item шейдере обновляется движком автоматически, пока
|
||||
# слой видим; ручных uniform-обновлений не нужно.
|
||||
func _build_grain() -> void:
|
||||
var img := Image.create(GRAIN_SIZE, GRAIN_SIZE, false, Image.FORMAT_RGBA8)
|
||||
for yy in GRAIN_SIZE:
|
||||
for xx in GRAIN_SIZE:
|
||||
var n := float((xx * 73 + yy * 151 + 31) % 97) / 97.0
|
||||
var c := DARK if n < 0.5 else LIGHT
|
||||
var a := absf(n - 0.5) * 2.0 * 0.05
|
||||
img.set_pixel(xx, yy, Color(c.r, c.g, c.b, a))
|
||||
_grain = ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func _build_vignette() -> void:
|
||||
var img := Image.create(VIGNETTE_W, VIGNETTE_H, false, Image.FORMAT_RGBA8)
|
||||
for yy in VIGNETTE_H:
|
||||
for xx in VIGNETTE_W:
|
||||
var q := Vector2(float(xx) / VIGNETTE_W - 0.5,
|
||||
float(yy) / VIGNETTE_H - 0.5)
|
||||
q.x *= 16.0 / 9.0
|
||||
var v := smoothstep(0.34, 0.94, q.length())
|
||||
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b, v * 0.13))
|
||||
_vignette = ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func _build_band() -> void:
|
||||
const BW := 128
|
||||
const BH := 32
|
||||
var img := Image.create(BW, BH, false, Image.FORMAT_RGBA8)
|
||||
for yy in BH:
|
||||
var cy := absf(float(yy) / (BH - 1) - 0.5) * 2.0
|
||||
var band_a := (1.0 - cy * cy) * 0.12
|
||||
for xx in BW:
|
||||
# слабые вертикальные штрихи — при дрожании полоса «плывёт»
|
||||
var streak := 0.85 + 0.15 * float((xx * 29) % 17) / 17.0
|
||||
if yy == 0:
|
||||
# светлая ведущая кромка — «трекинг» кассеты
|
||||
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
|
||||
0.45 * streak))
|
||||
elif yy == BH - 1:
|
||||
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
|
||||
0.28 * streak))
|
||||
else:
|
||||
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b,
|
||||
band_a * streak))
|
||||
_band = ImageTexture.create_from_image(img)
|
||||
@@ -1,82 +0,0 @@
|
||||
shader_type canvas_item;
|
||||
render_mode unshaded;
|
||||
|
||||
// Нативный VHS/CRT-слой главного меню.
|
||||
//
|
||||
// Считается в ФИЗИЧЕСКИХ пикселях окна: скан-линии идут по FRAGCOORD,
|
||||
// поэтому при 1920×1080 шаг ровно 2 px, без блоков и banding. Экранная
|
||||
// текстура читается с линейной фильтрацией 1:1 — буквы не размываются.
|
||||
// Хроматическая аберрация минимальна (0.0003 UV ≈ 0.6 px на краю кадра)
|
||||
// и не сдвигает зелёный канал, поэтому форма букв остаётся резкой.
|
||||
|
||||
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
|
||||
|
||||
uniform float intensity : hint_range(0.0, 0.55) = 0.40;
|
||||
|
||||
// Скан-линии: 2 физических px, затемнение до 6%.
|
||||
uniform float scanline_strength : hint_range(0.0, 0.12) = 0.06;
|
||||
uniform float scanline_step : hint_range(2.0, 3.0) = 2.0;
|
||||
|
||||
// Хроматика: очень мягкая, чтобы не двоить текст.
|
||||
uniform float aberration : hint_range(0.0, 0.0006) = 0.0003;
|
||||
|
||||
// Зерно: ±0.025, обновление 2 Гц.
|
||||
uniform float grain : hint_range(0.0, 0.04) = 0.025;
|
||||
|
||||
// Виньетка по краям кадра.
|
||||
uniform float vignette : hint_range(0.0, 0.18) = 0.11;
|
||||
|
||||
// Медленная полоса прокрутки: только яркость, без сдвига картинки.
|
||||
uniform float band_strength : hint_range(0.0, 0.05) = 0.04;
|
||||
uniform float band_speed : hint_range(0.01, 0.3) = 0.055;
|
||||
|
||||
const vec3 BAND_TINT = vec3(0.85, 1.0, 1.0);
|
||||
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
|
||||
void fragment() {
|
||||
vec2 res = 1.0 / max(SCREEN_PIXEL_SIZE, vec2(1e-6));
|
||||
vec2 uv = SCREEN_UV;
|
||||
|
||||
// Хроматика: R/B расходятся к краям, G сэмплится ровно по UV.
|
||||
float edge = 0.35 + abs(uv.x - 0.5) * 1.3;
|
||||
vec2 shift = vec2(aberration * edge, 0.0);
|
||||
vec3 col;
|
||||
col.r = texture(screen_texture, uv + shift).r;
|
||||
col.g = texture(screen_texture, uv).g;
|
||||
col.b = texture(screen_texture, uv - shift).b;
|
||||
|
||||
// Скан-линии по физическим строкам. FRAGCOORD — центр пикселя (n+0.5),
|
||||
// поэтому фазу берём от floor(y)+1, иначе cos((n+0.5)·PI) == 0.
|
||||
float row = floor(FRAGCOORD.y);
|
||||
float line = 0.5 + 0.5 * cos((row + 1.0) * (6.2831853 / scanline_step));
|
||||
col *= 1.0 - scanline_strength * line;
|
||||
|
||||
// Зерно: пиксельный шум, обновляется 2 раза в секунду.
|
||||
float gt = floor(TIME * 2.0);
|
||||
float n = hash21(floor(FRAGCOORD.xy) + vec2(gt * 31.7, gt * 17.3));
|
||||
col += (n - 0.5) * grain;
|
||||
|
||||
// Виньетка с поправкой на соотношение сторон.
|
||||
vec2 q = uv - 0.5;
|
||||
q.x *= res.x / max(res.y, 1.0);
|
||||
col *= 1.0 - vignette * smoothstep(0.35, 0.95, length(q));
|
||||
|
||||
// Полоса: медленно идёт сверху вниз. Меняет только яркость и не двигает
|
||||
// картинку, поэтому текст под ней не «плывёт».
|
||||
float band_y = fract(TIME * band_speed + 0.15);
|
||||
float d1 = abs(fract(uv.y - band_y + 0.5) - 0.5);
|
||||
float d2 = abs(fract(uv.y - band_y - 0.5 + 0.5) - 0.5);
|
||||
float band = exp(-pow(d1 / 0.045, 2.0))
|
||||
+ 0.45 * exp(-pow(d2 / 0.035, 2.0));
|
||||
col += band * band_strength * BAND_TINT;
|
||||
|
||||
vec3 base = texture(screen_texture, uv).rgb;
|
||||
COLOR = vec4(mix(base, col, intensity), 1.0);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://kqjc8uchsx0s
|
||||
@@ -254,13 +254,9 @@ func _test_vhs(main) -> void:
|
||||
_ok(overlay != null, "VHS-слой создан")
|
||||
if overlay == null:
|
||||
return
|
||||
_ok(overlay.get_parent() == main.main_menu, "VHS-слой — ребёнок главного меню")
|
||||
_ok(main.main_menu.get_child(main.main_menu.get_child_count() - 1) == overlay,
|
||||
"VHS-слой поверх карточки и кнопок меню")
|
||||
_eq(overlay.mouse_filter, Control.MOUSE_FILTER_IGNORE, "VHS не ловит мышь")
|
||||
_eq(overlay.process_mode, Node.PROCESS_MODE_ALWAYS, "VHS работает при паузе")
|
||||
_ok(not overlay.is_available(), "в headless VHS-материал не создаётся")
|
||||
_ok(not overlay.visible, "в headless VHS-слой не рендерится")
|
||||
_ok(overlay is RefCounted and not (overlay is Node),
|
||||
"VHS — RefCounted, не создаёт Node/Control")
|
||||
_ok(not overlay.is_available(), "в headless VHS-текстуры не создаются")
|
||||
_ok(main.main_menu.is_vhs_enabled(), "VHS включён по умолчанию")
|
||||
|
||||
main.main_menu.set_vhs_enabled(true)
|
||||
|
||||
Reference in new issue
Block a user