/* =========================================================
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');
}
}