/* ========================================================= ui.js — управление DOM-интерфейсом: экраны, HUD, всплывашки. Игровой мир рисуется на canvas, а меню/кнопки/HUD — это DOM, что даёт чёткий текст и удобную адаптивность. ========================================================= */ 'use strict'; /* ========================================================= ScrollRail — своя полоса прокрутки для областей с overflow. Системная почти везде накладная (а на iOS ещё и не стилизуется), поэтому рисуем свою: заметный ползунок и стрелки сверху и снизу — сразу видно, что можно листать. ========================================================= */ class ScrollRail { /** Все созданные полосы — их ползунки синхронизирует игровой цикл. */ static all = []; /** * Подтянуть ползунки к текущей прокрутке. Вызывается каждый кадр: события * scroll приходят с задержкой (а при инерционной прокрутке на телефоне — * пачками), из-за чего ползунок отставал бы от содержимого. * Во время игры панели скрыты, поэтому работы здесь нет. */ static syncAll() { for (const rail of ScrollRail.all) { if (!rail.rail.classList.contains('is-hidden')) rail._syncThumb(); } } constructor(el) { this.el = el; ScrollRail.all.push(this); el.classList.add('has-rail'); // Полоса живёт рядом с блоком, а не внутри него: внутри её пришлось бы // сдвигать на scrollTop, а событие scroll приходит уже после отрисовки // кадра — полоса заметно дёргалась бы при прокрутке. this.host = el.parentElement; this.rail = document.createElement('div'); this.rail.className = 'scroll-rail is-hidden'; this.rail.innerHTML = '' + '
' + ''; this.host.appendChild(this.rail); this.track = this.rail.querySelector('.rail-track'); this.thumb = this.rail.querySelector('.rail-thumb'); this.rail.querySelector('.rail-arrow--up').addEventListener('click', () => this._step(-1)); this.rail.querySelector('.rail-arrow--down').addEventListener('click', () => this._step(1)); this._bindDrag(); // При прокрутке двигается только ползунок; сама полоса стоит на месте. el.addEventListener('scroll', () => this._syncThumb(), { passive: true }); const update = () => this.update(); window.addEventListener('resize', update); // Пересчитать нужно и когда экран показали: размеры блока до этого нулевые. const screen = el.closest('.screen'); const opts = { attributes: true, attributeFilter: ['class', 'hidden', 'style'] }; new MutationObserver(update).observe(screen || el, opts); if (screen && screen !== el) new MutationObserver(update).observe(el, opts); if (window.ResizeObserver) new ResizeObserver(update).observe(el); // Подгрузка шрифта переверстывает текст — высота меняется. if (document.fonts && document.fonts.ready) document.fonts.ready.then(update); update(); } /** Шаг прокрутки стрелкой (своя анимация: smooth-прокрутка есть не везде). */ _step(dir) { const el = this.el; const max = el.scrollHeight - el.clientHeight; const from = el.scrollTop; const to = Math.max(0, Math.min(max, from + dir * Math.max(48, el.clientHeight * 0.4))); if (to === from) return; const t0 = performance.now(), dur = 200; const tick = (now) => { const k = Math.min(1, (now - t0) / dur); el.scrollTop = from + (to - from) * k * (2 - k); // плавное замедление if (k < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } /** Перетаскивание ползунка. */ _bindDrag() { let startY = 0, startTop = 0, dragging = false; const onMove = (e) => { if (!dragging) return; const max = this.el.scrollHeight - this.el.clientHeight; const range = this.track.clientHeight - this.thumb.offsetHeight; if (range > 0) this.el.scrollTop = startTop + (e.clientY - startY) * (max / range); }; const onUp = () => { dragging = false; this.thumb.classList.remove('is-dragging'); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; this.thumb.addEventListener('pointerdown', (e) => { dragging = true; startY = e.clientY; startTop = this.el.scrollTop; this.thumb.classList.add('is-dragging'); e.preventDefault(); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); }); } /** Пересчитать видимость и положение полосы у правого края блока. */ update() { const el = this.el; const need = el.scrollHeight - el.clientHeight > 4; this.rail.classList.toggle('is-hidden', !need); el.classList.toggle('is-scrollable', need); // освобождает место под полосу if (!need) return; // Положение считаем от самого блока, а не от прокрутки. const r = el.getBoundingClientRect(); const h = this.host.getBoundingClientRect(); this.rail.style.top = (r.top - h.top + 5) + 'px'; this.rail.style.left = (r.left - h.left + r.width - 21) + 'px'; this.rail.style.height = (r.height - 10) + 'px'; this._syncThumb(); } /** Размер и положение ползунка внутри дорожки. */ _syncThumb() { const el = this.el; const max = el.scrollHeight - el.clientHeight; if (max <= 4) return; // Содержимое стало прокручиваемым, а полоса ещё скрыта — показать её. if (this.rail.classList.contains('is-hidden')) { this.update(); return; } const trackH = this.track.clientHeight; const thumbH = Math.max(24, trackH * (el.clientHeight / el.scrollHeight)); this.thumb.style.height = thumbH + 'px'; this.thumb.style.transform = 'translateY(' + (trackH - thumbH) * (el.scrollTop / max) + 'px)'; this.rail.classList.toggle('at-top', el.scrollTop <= 1); this.rail.classList.toggle('at-bottom', el.scrollTop >= max - 1); } } class UI { constructor(audio) { this.audio = audio; this.screens = { menu: document.getElementById('screen-menu'), settings: document.getElementById('screen-settings'), howto: document.getElementById('screen-howto'), pause: document.getElementById('screen-pause'), gameover: document.getElementById('screen-gameover'), win: document.getElementById('screen-win'), }; this.hud = document.getElementById('hud'); this.touchControls = document.getElementById('touch-controls'); this.controlHint = document.getElementById('control-hint'); this.districtPopup = document.getElementById('district-popup'); // HUD-элементы this.el = { score: document.getElementById('hud-score'), distance: document.getElementById('hud-distance'), districts: document.getElementById('hud-districts'), supportBar: document.getElementById('support-bar'), loseScore: document.getElementById('lose-score'), loseDistance: document.getElementById('lose-distance'), loseDistricts: document.getElementById('lose-districts'), gameoverCaption: document.getElementById('gameover-caption'), }; this._buildSupportBar(); this._isTouch = window.matchMedia('(hover: none)').matches || window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0 || 'ontouchstart' in window; // Панель экранных кнопок живёт под игровым окном и резервирует под себя место. document.documentElement.classList.toggle('is-touch', this._isTouch); this.menuLegal = document.querySelector('.menu-legal'); if (this._isTouch && this.menuLegal) this._moveLegalOutside(); // Заметные полосы прокрутки со стрелками во всех прокручиваемых блоках. document.querySelectorAll('.panel, .menu-legal').forEach(el => new ScrollRail(el)); } /** * На сенсорных устройствах выходные данные выносятся полосой под игровое * окно: внутри рамки они закрывали баннер. Полоса видна только в главном * меню — следим за экраном меню, чтобы не дублировать вызовы по всему коду. */ _moveLegalOutside() { document.getElementById('stage').appendChild(this.menuLegal); // из рамки игры — под неё this.menuLegal.classList.add('menu-legal--outside'); const sync = () => { const onMenu = this.screens.menu.classList.contains('active'); this.menuLegal.hidden = !onMenu; document.documentElement.classList.toggle('menu-open', onMenu); // Число строк зависит от ширины экрана и меняется при повороте, поэтому // место под текстом резервируем по фактической высоте блока: окно игры // подстраивается, и текст всегда лежит под ним, а не поверх. const h = onMenu ? Math.ceil(this.menuLegal.getBoundingClientRect().height) + 10 : 0; document.documentElement.style.setProperty('--legal-space', h + 'px'); }; new MutationObserver(sync).observe(this.screens.menu, { attributes: true, attributeFilter: ['class'], }); // Поворот экрана меняет и высоту текста, и размер окна игры. window.addEventListener('resize', () => { sync(); requestAnimationFrame(sync); }); window.addEventListener('orientationchange', () => requestAnimationFrame(sync)); sync(); } /* Создать 10 ячеек индикатора поддержки. */ _buildSupportBar() { this.el.supportBar.innerHTML = ''; this.supportCells = []; for (let i = 0; i < CONFIG.DISTRICTS_TOTAL; i++) { const cell = document.createElement('div'); cell.className = 'support-cell'; this.el.supportBar.appendChild(cell); this.supportCells.push(cell); } } /* -------- Переключение экранов -------- */ hideAllScreens() { for (const s of Object.values(this.screens)) s.classList.remove('active'); } showScreen(name) { this.hideAllScreens(); if (this.screens[name]) this.screens[name].classList.add('active'); } showMenu() { this.showScreen('menu'); this._setGameChrome(false); } showSettings() { this.showScreen('settings'); } showHowto() { this.showScreen('howto'); } showPause() { this.screens.pause.classList.add('active'); this.touchControls.classList.add('hidden'); } hidePause() { this.screens.pause.classList.remove('active'); this.touchControls.classList.toggle('hidden', !this._isTouch); } showGameplay() { this.hideAllScreens(); this._setGameChrome(true); } showGameOver(caption, stats) { this.el.gameoverCaption.textContent = caption; this.el.loseScore.textContent = stats.score; this.el.loseDistance.textContent = stats.distance + 'м'; this.el.loseDistricts.textContent = stats.districts + '/' + CONFIG.DISTRICTS_TOTAL; this.screens.gameover.classList.add('active'); this._hideControls(); } showWin() { this.screens.win.classList.add('active'); this._hideControls(); } /* Спрятать экранные кнопки и подсказку (на финальных экранах). */ _hideControls() { this.touchControls.classList.add('hidden'); this.controlHint.classList.add('hidden'); } /* Показать/скрыть HUD и экранные кнопки. Подсказку во время игры не выводим. */ _setGameChrome(on) { this.hud.classList.toggle('hidden', !on); this.controlHint.classList.add('hidden'); // инструкция вынесена из игры this.touchControls.classList.toggle('hidden', !(on && this._isTouch)); } /* -------- Обновление HUD -------- */ updateHUD(state) { this.el.score.textContent = state.score; this.el.distance.textContent = state.distance + 'м'; this.el.districts.textContent = state.districts + '/' + CONFIG.DISTRICTS_TOTAL; } /** Заполнить ячейку района. */ fillSupportCell(index) { if (this.supportCells[index]) this.supportCells[index].classList.add('filled'); } resetSupportBar() { for (const c of this.supportCells) c.classList.remove('filled'); } /** Всплывающее уведомление о поддержке района. */ showDistrictPopup(name) { const p = this.districtPopup; p.innerHTML = 'Поздравляем!
Район «' + name + '» поддержал вас!'; p.classList.remove('show'); // reflow для перезапуска анимации void p.offsetWidth; p.classList.add('show'); } }