Гра-розминка · ~2 хвилини
Розстав станції київського метро в правильному порядку. У тебе три спроби. Кияни тут мають перевагу.
Всі назви — за актуальною мапою КП «Київський метрополітен». Перейменування 2022–2024 враховано.
Перетягни картки в правильному порядку — від першої станції лінії до останньої.
Лінія:
🟩🟩🟩🟩🟩
Час: 0:00, спроб: 1 / 3
/*
script.js — Гра «Метро в порядку»
Vanilla JS, без імпортів. Розраховано на завантаження через
pre+loader pattern (див. README) — тому без import/export, IIFE,
без зовнішніх бібліотек.
*/
(function () {
'use strict';
const root = document.getElementById('metroGameRoot');
if (!root) return;
// ── Дані ─────────────────────────────────────────────────────────────
// Київський метрополітен — три діючі лінії.
// closed: true → станція виключається з пулу (наприклад, ділянка
// М2 «Деміївська–Теремки» закрита з грудня 2023).
const DATA = {
lines: {
m1: {
name: 'Святошинсько-Броварська',
color: '#cc0a36',
stations: [
{ id: 'akademmistechko', name: 'Академмістечко' },
{ id: 'zhytomyrska', name: 'Житомирська' },
{ id: 'svyatoshyn', name: 'Святошин' },
{ id: 'nyvky', name: 'Нивки' },
{ id: 'berestejska', name: 'Берестейська' },
{ id: 'shulyavska', name: 'Шулявська' },
{ id: 'polytehnichnyi', name: 'Політехнічний інститут' },
{ id: 'vokzalna', name: 'Вокзальна' },
{ id: 'universytet', name: 'Університет' },
{ id: 'teatralna', name: 'Театральна' },
{ id: 'khreshchatyk', name: 'Хрещатик' },
{ id: 'arsenalna', name: 'Арсенальна' },
{ id: 'dnipro', name: 'Дніпро' },
{ id: 'hidropark', name: 'Гідропарк' },
{ id: 'livoberezhna', name: 'Лівобережна' },
{ id: 'darnytsia', name: 'Дарниця' },
{ id: 'chernihivska', name: 'Чернігівська' },
{ id: 'lisova', name: 'Лісова' }
]
},
m2: {
name: 'Куренівсько-Червоноармійська',
color: '#0079c1',
stations: [
{ id: 'heroiv-dnipra', name: 'Героїв Дніпра' },
{ id: 'minska', name: 'Мінська' },
{ id: 'obolon', name: 'Оболонь' },
{ id: 'pochaina', name: 'Почайна' },
{ id: 'tarasa-shevchenka', name: 'Тараса Шевченка' },
{ id: 'kontraktova', name: 'Контрактова площа' },
{ id: 'poshtova', name: 'Поштова площа' },
{ id: 'maidan', name: 'Майдан Незалежності' },
{ id: 'ploshcha-ukrainskykh-heroiv', name: 'Площа Українських Героїв' },
{ id: 'olimpiiska', name: 'Олімпійська' },
{ id: 'palats-ukraina', name: 'Палац Україна' },
{ id: 'lybidska', name: 'Либідська' },
{ id: 'demiivska', name: 'Деміївська', closed: true },
{ id: 'holosiivska', name: 'Голосіївська', closed: true },
{ id: 'vasylkivska', name: 'Васильківська', closed: true },
{ id: 'vystavkovyi-tsentr', name: 'Виставковий центр', closed: true },
{ id: 'ipodrom', name: 'Іподром', closed: true },
{ id: 'teremky', name: 'Теремки', closed: true }
]
},
m3: {
name: 'Сирецько-Печерська',
color: '#00a650',
stations: [
{ id: 'syrets', name: 'Сирець' },
{ id: 'dorohozhychi', name: 'Дорогожичі' },
{ id: 'lukyanivska', name: 'Лук’янівська' },
{ id: 'zoloti-vorota', name: 'Золоті ворота' },
{ id: 'palats-sportu', name: 'Палац спорту' },
{ id: 'klovska', name: 'Кловська' },
{ id: 'pecherska', name: 'Печерська' },
{ id: 'zvirynetska', name: 'Звіринецька' },
{ id: 'vydubychi', name: 'Видубичі' },
{ id: 'slavutych', name: 'Славутич' },
{ id: 'osokorky', name: 'Осокорки' },
{ id: 'pozniaky', name: 'Позняки' },
{ id: 'kharkivska', name: 'Харківська' },
{ id: 'vyrlytsia', name: 'Вирлиця' },
{ id: 'boryspilska', name: 'Бориспільська' },
{ id: 'chervonyi-khutir', name: 'Червоний хутір' }
]
}
}
};
// URLs сервісу /transport/ — куди вести трафік з гри.
// Query `from=metro-game` дозволяє в GA4 відстежити source.
const LINE_SERVICE_URL = {
m1: 'https://my-kiev.com/transport/kyiv/metro/sviatoshynsko-brovarska/',
m2: 'https://my-kiev.com/transport/kyiv/metro/obolonsko-teremkivska/',
m3: 'https://my-kiev.com/transport/kyiv/metro/syretsko-pecherska/'
};
const ALL_METRO_URL = 'https://my-kiev.com/transport/kyiv/metro/';
const TRAFFIC_PARAM = '?from=metro-game';
const LS_KEY = 'metroGame_v1';
const MODES = {
easy: { count: 5, oneLine: true, showLineHint: true, showDot: true, attempts: 3 },
medium: { count: 7, oneLine: true, showLineHint: false, showDot: true, attempts: 3 },
hard: { count: 7, oneLine: false, showLineHint: false, showDot: false, attempts: 2 },
daily: { count: 7, oneLine: true, showLineHint: false, showDot: true, attempts: 3 }
};
// ── Стан гри ─────────────────────────────────────────────────────────
const state = {
mode: 'easy',
isDaily: false,
attemptsLeft: 3,
attemptsUsed: 0,
startTime: 0,
elapsedMs: 0,
timerId: 0,
set: [], // [{ id, name, lineKey }] правильний порядок
current: [], // індекси у `set` (поточна перестановка гравця)
lockMap: [], // bool[] на яких слотах картка вже зафіксована (правильна)
history: [], // рядки emoji-сітки по спробах
won: false
};
// ── DOM ──────────────────────────────────────────────────────────────
const $ = (sel) => root.querySelector(sel);
const $$ = (sel) => root.querySelectorAll(sel);
const els = {
welcome: $('.mg-welcome'),
game: $('.mg-game'),
result: $('.mg-result'),
modeBtns: $$('.mg-mode-btn'),
startBtn: $('.mg-start'),
dailyBtn: $('.mg-daily'),
stats: $('.mg-stats'),
streak: $('.mg-streak b'),
winrate: $('.mg-winrate b'),
hudAttempts: $('.mg-attempts b'),
hudTimer: $('.mg-timer'),
lineHint: $('.mg-line-hint'),
lineHintB: $('.mg-line-hint b'),
board: $('.mg-board'),
checkBtn: $('.mg-check'),
backBtn: $('.mg-back'),
resultTitle: $('.mg-result__title'),
resultLine: $('.mg-result__line'),
resultLineB: $('.mg-result__line b'),
resultGrid: $('.mg-result__grid'),
resultTimeAtt: $('.mg-result__time'),
countdown: $('.mg-result__countdown'),
countdownNum: $('.mg-result__countdown-num'),
countdownCancel: $('.mg-result__countdown-cancel'),
serviceBox: $('.mg-result__service'),
serviceEyebrow: $('.mg-result__service-eyebrow'),
serviceLink: $('.mg-result__service-link'),
serviceStripe: $('.mg-result__service-stripe'),
serviceTitle: $('.mg-result__service-title'),
serviceSub: $('.mg-result__service-sub'),
shareBtn: $('.mg-share'),
replayBtn: $('.mg-replay'),
homeBtn: $('.mg-home'),
toast: $('.mg-toast')
};
const AUTO_NEXT_SECONDS = 3;
let autoNextTimer = 0;
let autoNextRemaining = 0;
// ── Утиліти ──────────────────────────────────────────────────────────
function todayKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function dailySeed() {
const d = new Date();
return Number(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`);
}
function mulberry32(seed) {
let t = seed >>> 0;
return function () {
t = (t + 0x6D2B79F5) >>> 0;
let r = Math.imul(t ^ (t >>> 15), t | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pickRandom(arr, n, rng) {
return shuffle(arr, rng).slice(0, n);
}
function fmtTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
function lineKeyFor(stationId) {
for (const key in DATA.lines) {
if (DATA.lines[key].stations.some((s) => s.id === stationId)) return key;
}
return null;
}
function activeStations(lineKey) {
return DATA.lines[lineKey].stations.filter((s) => !s.closed);
}
// ── LocalStorage ─────────────────────────────────────────────────────
function loadStats() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return defaultStats();
const obj = JSON.parse(raw);
if (!obj || obj.version !== 1) return defaultStats();
return obj;
} catch (e) {
return defaultStats();
}
}
function defaultStats() {
return {
version: 1,
stats: { played: 0, won: 0, currentStreak: 0, maxStreak: 0, lastPlayed: null },
daily: null
};
}
function saveStats(data) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(data));
} catch (e) {}
}
function recordPlay(won) {
const data = loadStats();
data.stats.played += 1;
if (won) data.stats.won += 1;
const today = todayKey();
const prev = data.stats.lastPlayed;
if (won) {
if (prev === today) {
// не дублюємо стрік двічі за день
} else {
const y = new Date();
y.setDate(y.getDate() - 1);
const yKey = `${y.getFullYear()}-${String(y.getMonth() + 1).padStart(2, '0')}-${String(y.getDate()).padStart(2, '0')}`;
if (prev === yKey) {
data.stats.currentStreak += 1;
} else {
data.stats.currentStreak = 1;
}
if (data.stats.currentStreak > data.stats.maxStreak) {
data.stats.maxStreak = data.stats.currentStreak;
}
}
} else {
data.stats.currentStreak = 0;
}
data.stats.lastPlayed = today;
saveStats(data);
return data;
}
function recordDaily(won, grid) {
const data = loadStats();
data.daily = {
date: todayKey(),
completed: won,
attempts: state.attemptsUsed,
timeMs: state.elapsedMs,
shareGrid: grid
};
saveStats(data);
}
function dailyDoneToday() {
const d = loadStats().daily;
return d && d.date === todayKey() && d.completed;
}
// ── Welcome ──────────────────────────────────────────────────────────
function renderWelcomeStats() {
const d = loadStats();
if (d.stats.played > 0) {
els.stats.hidden = false;
els.streak.textContent = String(d.stats.currentStreak);
const rate = Math.round((d.stats.won / d.stats.played) * 100);
els.winrate.textContent = `${rate}%`;
} else {
els.stats.hidden = true;
}
// Якщо челендж сьогодні вже пройдено — кнопка стає інформаційною
if (dailyDoneToday()) {
els.dailyBtn.textContent = 'Челендж дня — пройдено ✓';
els.dailyBtn.disabled = true;
} else {
els.dailyBtn.textContent = 'Челендж дня';
els.dailyBtn.disabled = false;
}
}
function setScreen(name) {
state.screen = name;
root.setAttribute('data-state', name);
els.welcome.hidden = name !== 'welcome';
els.game.hidden = name !== 'game';
els.result.hidden = name !== 'result';
}
// ── Генерація набору ─────────────────────────────────────────────────
function generateSet(mode, isDaily) {
const cfg = MODES[mode];
const rng = isDaily ? mulberry32(dailySeed()) : mulberry32(Math.floor(Math.random() * 1e9));
let chosen = [];
if (cfg.oneLine) {
const lineKeys = Object.keys(DATA.lines);
const lineKey = lineKeys[Math.floor(rng() * lineKeys.length)];
const all = activeStations(lineKey);
// вибираємо випадкову суцільну підпослідовність довжини count,
// щоб порядок мав сенс (а не просто N випадкових станцій)
const max = all.length - cfg.count;
const start = Math.floor(rng() * (max + 1));
chosen = all.slice(start, start + cfg.count).map((s) => ({
id: s.id,
name: s.name,
lineKey
}));
} else {
// Hard: по 2-3 станції з кожної з 3 ліній
const buckets = ['m1', 'm2', 'm3'].map((k) => {
const all = activeStations(k);
const len = 2 + Math.floor(rng() * 2); // 2 або 3
const max = all.length - len;
const start = Math.floor(rng() * (max + 1));
return all.slice(start, start + len).map((s) => ({ id: s.id, name: s.name, lineKey: k }));
});
// Зливаємо. Правильний порядок: всі m1 у своєму напрямку,
// потім m2, потім m3 — таким чином кожна підгрупа лишається
// суцільною ділянкою лінії.
chosen = buckets.flat().slice(0, cfg.count);
}
return chosen;
}
// ── Старт гри ────────────────────────────────────────────────────────
function startGame(mode, isDaily) {
if (isDaily && dailyDoneToday()) {
flashToast('Сьогоднішній челендж уже пройдено — приходь завтра');
return;
}
state.mode = mode;
state.isDaily = !!isDaily;
state.attemptsLeft = MODES[mode].attempts;
state.attemptsUsed = 0;
state.set = generateSet(mode, !!isDaily);
state.current = shuffle(state.set.map((_, i) => i), Math.random);
// якщо випадково вгадалось — перетасуємо
while (state.current.every((v, i) => v === i)) {
state.current = shuffle(state.current, Math.random);
}
state.lockMap = state.set.map(() => false);
state.history = [];
state.won = false;
state.startTime = Date.now();
state.elapsedMs = 0;
renderHud();
renderBoard();
setScreen('game');
startTimer();
}
function startTimer() {
stopTimer();
state.timerId = setInterval(() => {
state.elapsedMs = Date.now() - state.startTime;
els.hudTimer.textContent = fmtTime(state.elapsedMs);
}, 250);
}
function stopTimer() {
if (state.timerId) {
clearInterval(state.timerId);
state.timerId = 0;
}
}
// ── HUD ──────────────────────────────────────────────────────────────
function renderHud() {
els.hudAttempts.textContent = String(state.attemptsLeft);
els.hudTimer.textContent = fmtTime(state.elapsedMs);
const cfg = MODES[state.mode];
if (cfg.showLineHint && !state.isDaily && cfg.oneLine) {
const lk = state.set[0].lineKey;
els.lineHint.hidden = false;
els.lineHintB.textContent = DATA.lines[lk].name;
} else {
els.lineHint.hidden = true;
}
}
// ── Дошка ────────────────────────────────────────────────────────────
function renderBoard() {
const cfg = MODES[state.mode];
els.board.innerHTML = '';
state.current.forEach((srcIdx, pos) => {
const st = state.set[srcIdx];
const li = document.createElement('li');
li.className = 'mg-card';
li.setAttribute('role', 'option');
li.setAttribute('tabindex', '0');
li.dataset.id = st.id;
li.dataset.pos = String(pos);
const locked = state.lockMap[pos];
if (locked) {
li.classList.add('is-correct', 'is-locked');
li.setAttribute('draggable', 'false');
} else {
li.setAttribute('draggable', 'true');
}
if (cfg.showDot) {
const dot = document.createElement('span');
dot.className = 'mg-card__dot';
dot.style.background = DATA.lines[st.lineKey].color;
li.appendChild(dot);
}
const num = document.createElement('span');
num.className = 'mg-card__num';
num.textContent = String(pos + 1);
li.appendChild(num);
const name = document.createElement('span');
name.className = 'mg-card__name';
name.textContent = st.name;
li.appendChild(name);
const nudge = document.createElement('div');
nudge.className = 'mg-card__nudge';
const up = document.createElement('button');
up.type = 'button';
up.className = 'mg-nudge mg-nudge--up';
up.setAttribute('aria-label', 'Перемістити вгору');
up.textContent = '↑';
const dn = document.createElement('button');
dn.type = 'button';
dn.className = 'mg-nudge mg-nudge--down';
dn.setAttribute('aria-label', 'Перемістити вниз');
dn.textContent = '↓';
nudge.appendChild(up);
nudge.appendChild(dn);
li.appendChild(nudge);
els.board.appendChild(li);
});
bindCardInteractions();
}
// ── Перестановка в state.current + ре-рендер ─────────────────────────
function movePos(from, to) {
if (from === to) return;
if (state.lockMap[from] || state.lockMap[to]) return;
const arr = state.current;
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
// після руху lockMap для незаблокованих індексів треба перебудувати
// (заблоковані картки лишаються на своїх позиціях, бо їх перетягти не можна)
renderBoard();
}
function nudgeAt(pos, dir) {
const target = pos + dir;
if (target < 0 || target >= state.current.length) return;
if (state.lockMap[pos] || state.lockMap[target]) return;
movePos(pos, target);
}
// ── Drag & drop + Pointer Events + клавіатура ────────────────────────
let dragSrcPos = null;
let pointerDrag = null; // { pos, startY, el, ghost, lastOver }
function bindCardInteractions() {
$$('.mg-card').forEach((card) => {
const pos = Number(card.dataset.pos);
// HTML5 drag (desktop)
card.addEventListener('dragstart', (e) => {
if (state.lockMap[pos]) { e.preventDefault(); return; }
dragSrcPos = pos;
card.classList.add('is-dragging');
try { e.dataTransfer.setData('text/plain', String(pos)); } catch (err) {}
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
card.classList.remove('is-dragging');
$$('.mg-card.is-over').forEach((el) => el.classList.remove('is-over'));
dragSrcPos = null;
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (dragSrcPos === null || dragSrcPos === pos) return;
e.dataTransfer.dropEffect = 'move';
$$('.mg-card.is-over').forEach((el) => el.classList.remove('is-over'));
card.classList.add('is-over');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
$$('.mg-card.is-over').forEach((el) => el.classList.remove('is-over'));
if (dragSrcPos === null) return;
movePos(dragSrcPos, pos);
dragSrcPos = null;
});
// Кнопки ↑/↓
const up = card.querySelector('.mg-nudge--up');
const dn = card.querySelector('.mg-nudge--down');
if (up) up.addEventListener('click', (e) => { e.stopPropagation(); nudgeAt(pos, -1); });
if (dn) dn.addEventListener('click', (e) => { e.stopPropagation(); nudgeAt(pos, +1); });
// Pointer Events (touch drag без бібліотек)
card.addEventListener('pointerdown', onPointerDown);
// Keyboard a11y: ↑/↓ переставляє, Space/Enter — нічого спеціального
card.addEventListener('keydown', (e) => {
if (state.lockMap[pos]) return;
if (e.key === 'ArrowUp') { e.preventDefault(); nudgeAt(pos, -1); focusPos(pos - 1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); nudgeAt(pos, +1); focusPos(pos + 1); }
});
});
}
function focusPos(pos) {
const el = root.querySelector(`.mg-card[data-pos="${pos}"]`);
if (el) el.focus();
}
function onPointerDown(e) {
if (e.pointerType === 'mouse') return; // mouse-drag вже обробляє HTML5 DnD
const card = e.currentTarget;
const pos = Number(card.dataset.pos);
if (state.lockMap[pos]) return;
// даємо натиснути кнопку нудж без перехоплення
if (e.target.closest('.mg-nudge')) return;
e.preventDefault();
card.setPointerCapture(e.pointerId);
pointerDrag = {
pos,
pointerId: e.pointerId,
startY: e.clientY,
el: card,
lastOverPos: pos
};
card.classList.add('is-dragging');
card.addEventListener('pointermove', onPointerMove);
card.addEventListener('pointerup', onPointerUp);
card.addEventListener('pointercancel', onPointerUp);
}
function onPointerMove(e) {
if (!pointerDrag) return;
e.preventDefault();
const cards = Array.from($$('.mg-card'));
let overPos = pointerDrag.pos;
for (const c of cards) {
const rect = c.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
overPos = Number(c.dataset.pos);
break;
}
}
$$('.mg-card.is-over').forEach((el) => el.classList.remove('is-over'));
if (overPos !== pointerDrag.pos) {
const o = root.querySelector(`.mg-card[data-pos="${overPos}"]`);
if (o) o.classList.add('is-over');
}
pointerDrag.lastOverPos = overPos;
}
function onPointerUp(e) {
if (!pointerDrag) return;
const { pos, lastOverPos, el } = pointerDrag;
el.classList.remove('is-dragging');
$$('.mg-card.is-over').forEach((x) => x.classList.remove('is-over'));
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
el.removeEventListener('pointercancel', onPointerUp);
try { el.releasePointerCapture(e.pointerId); } catch (err) {}
pointerDrag = null;
if (lastOverPos !== pos) movePos(pos, lastOverPos);
}
// ── Перевірка ────────────────────────────────────────────────────────
function checkAnswer() {
state.attemptsUsed += 1;
state.attemptsLeft -= 1;
const correctness = state.current.map((srcIdx, pos) => srcIdx === pos);
const allRight = correctness.every(Boolean);
// emoji-рядок для історії
const row = correctness.map((ok) => (ok ? String.fromCodePoint(0x1F7E9) : String.fromCodePoint(0x1F7E5))).join('');
state.history.push(row);
// підсвічуємо
$$('.mg-card').forEach((card) => {
const pos = Number(card.dataset.pos);
card.classList.remove('is-correct', 'is-wrong');
if (correctness[pos]) {
card.classList.add('is-correct');
// фіксуємо вже правильно стоячі картки
state.lockMap[pos] = true;
} else {
card.classList.add('is-wrong');
}
});
if (allRight) {
state.won = true;
stopTimer();
setTimeout(() => showResult(true), 700);
return;
}
if (state.attemptsLeft <= 0) {
state.won = false;
stopTimer();
setTimeout(() => showResult(false), 900);
return;
}
// інакше — даємо ще одну спробу. Через 700мс знімаємо червоне
// підсвічування з тих, що не зафіксовані, і дозволяємо тягати знову.
setTimeout(() => {
els.hudAttempts.textContent = String(state.attemptsLeft);
// перетасуємо лише незаблоковані картки, щоб дати «нову розкладку»
reshuffleUnlocked();
}, 900);
}
function reshuffleUnlocked() {
const unlockedIdxs = [];
state.current.forEach((_, pos) => { if (!state.lockMap[pos]) unlockedIdxs.push(pos); });
if (unlockedIdxs.length < 2) { renderBoard(); return; }
const vals = unlockedIdxs.map((pos) => state.current[pos]);
let shuffled = shuffle(vals, Math.random);
// переконуємось, що нова перестановка не дублює попередню
let guard = 0;
while (guard++ < 10 && shuffled.every((v, i) => v === vals[i])) {
shuffled = shuffle(vals, Math.random);
}
unlockedIdxs.forEach((pos, i) => { state.current[pos] = shuffled[i]; });
renderBoard();
}
// ── Result-reveal анімація ──────────────────────────────────────────
function animateResultTitle(text) {
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
els.resultTitle.innerHTML = '';
els.resultTitle.classList.remove('is-revealed');
text.split('').forEach((ch, i) => {
const s = document.createElement('span');
s.className = 'mg-char';
s.textContent = ch === ' ' ? ' ' : ch;
if (!reduced) s.style.transitionDelay = (60 + i * 32) + 'ms';
els.resultTitle.appendChild(s);
});
requestAnimationFrame(() => els.resultTitle.classList.add('is-revealed'));
}
function popGrid() {
els.resultGrid.classList.remove('is-revealed');
requestAnimationFrame(() => els.resultGrid.classList.add('is-revealed'));
}
// ── Результат ────────────────────────────────────────────────────────
function showResult(won) {
setScreen('result');
animateResultTitle(won ? 'Усе правильно!' : 'Не сьогодні');
// показуємо лінію тільки якщо одна
const cfg = MODES[state.mode];
if (cfg.oneLine) {
const lk = state.set[0].lineKey;
els.resultLine.hidden = false;
els.resultLineB.textContent = DATA.lines[lk].name;
} else {
els.resultLine.hidden = true;
}
// emoji-сітка з pop-анімацією
const grid = state.history.join('\n');
els.resultGrid.textContent = grid;
setTimeout(popGrid, 600);
els.resultTimeAtt.innerHTML = `Час: <b>${fmtTime(state.elapsedMs)}</b>, спроб: <b>${state.attemptsUsed} / ${MODES[state.mode].attempts}</b>`;
// якщо програв — показуємо правильний порядок під сіткою
let correctLine = els.result.querySelector('.mg-result__correct');
if (!correctLine) {
correctLine = document.createElement('div');
correctLine.className = 'mg-result__correct';
els.resultGrid.insertAdjacentElement('afterend', correctLine);
}
if (!won) {
correctLine.hidden = false;
correctLine.innerHTML = '<p class="mg-result__correct-label">Правильний порядок:</p>' +
'<ol class="mg-result__correct-list">' +
state.set.map((s) => `<li><span class="mg-result__correct-dot" style="background:${DATA.lines[s.lineKey].color}"></span>${escapeHtml(s.name)}</li>`).join('') +
'</ol>';
} else {
correctLine.hidden = true;
}
// Service link на result — переливає трафік на /transport/kyiv/metro/.
// Для easy/medium/daily (одна лінія) — посилання на сторінку конкретної лінії
// з її кольором у stripe. Для hard (3 лінії) — на загальну карту метро.
// Текст відрізняється для win / loss — на loss заохочуємо «подивись справжню лінію».
renderServiceLink(won);
// зберігаємо статистику
recordPlay(won);
if (state.isDaily) recordDaily(won, grid);
// Auto-advance: після перемоги через 3с старт нового раунду тієї ж складності
// (для daily — не запускаємо авто-перехід, бо daily один на день)
if (won && !state.isDaily) {
startAutoNext();
} else {
stopAutoNext();
}
}
function startAutoNext() {
stopAutoNext();
autoNextRemaining = AUTO_NEXT_SECONDS;
els.countdown.hidden = false;
els.countdownNum.textContent = String(autoNextRemaining);
autoNextTimer = setInterval(() => {
autoNextRemaining -= 1;
if (autoNextRemaining <= 0) {
stopAutoNext();
startGame(state.mode, false);
return;
}
els.countdownNum.textContent = String(autoNextRemaining);
}, 1000);
}
function stopAutoNext() {
if (autoNextTimer) {
clearInterval(autoNextTimer);
autoNextTimer = 0;
}
if (els.countdown) els.countdown.hidden = true;
}
function renderServiceLink(won) {
if (!els.serviceBox) return;
const cfg = MODES[state.mode];
let url, eyebrow, title, sub, color;
if (cfg.oneLine && state.set.length) {
const lk = state.set[0].lineKey;
url = LINE_SERVICE_URL[lk] + TRAFFIC_PARAM;
color = DATA.lines[lk].color;
if (won) {
eyebrow = 'Дізнайся більше';
title = DATA.lines[lk].name + ' — наживо';
sub = 'Розклад, кінцеві, інтервали руху';
} else {
eyebrow = 'Подивись реальну лінію';
title = DATA.lines[lk].name;
sub = 'Карта, поточний рух, кінцеві станції';
}
} else {
url = ALL_METRO_URL + TRAFFIC_PARAM;
// для hard / нерозпізнаного — градієнт з кольорів усіх трьох ліній на смузі
color = `linear-gradient(180deg, ${DATA.lines.m1.color} 0%, ${DATA.lines.m1.color} 33%, ${DATA.lines.m2.color} 33%, ${DATA.lines.m2.color} 66%, ${DATA.lines.m3.color} 66%, ${DATA.lines.m3.color} 100%)`;
if (won) {
eyebrow = 'Дізнайся більше';
title = 'Метро Києва — наживо';
sub = 'Маршрути усіх 3 ліній, схеми, розклади';
} else {
eyebrow = 'Подивись реальні лінії метро';
title = 'Метро Києва — карта і маршрути';
sub = 'Усі 3 лінії, схеми руху, кінцеві';
}
}
els.serviceLink.setAttribute('href', url);
els.serviceEyebrow.textContent = eyebrow;
els.serviceTitle.textContent = title;
els.serviceSub.textContent = sub;
els.serviceStripe.style.background = color;
els.serviceBox.hidden = false;
}
// ── Share ────────────────────────────────────────────────────────────
function buildShareText() {
const cfg = MODES[state.mode];
const modeLabel = state.isDaily
? `Челендж дня (${todayKey()})`
: { easy: 'Легкий', medium: 'Середній', hard: 'Складний' }[state.mode];
const lineLabel = cfg.oneLine
? DATA.lines[state.set[0].lineKey].name + ' · '
: '3 лінії · ';
const head = `Метро в порядку — ${modeLabel}`;
const sub = `${lineLabel}${cfg.count} станцій · ${state.attemptsUsed}/${cfg.attempts} · ${fmtTime(state.elapsedMs)}`;
const url = location.origin + location.pathname;
return `${head}\n${sub}\n\n${state.history.join('\n')}\n\n${url}`;
}
async function shareResult() {
const text = buildShareText();
if (navigator.share) {
try {
await navigator.share({ text, title: 'Метро в порядку — Мій Київ' });
return;
} catch (e) {
// юзер скасував або шеру не підтримує — падаємо в clipboard
}
}
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
flashToast('Скопійовано в буфер обміну');
return;
} catch (e) {}
}
// останній fallback — selection
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); flashToast('Скопійовано в буфер обміну'); }
catch (e) { flashToast('Не вдалось скопіювати'); }
document.body.removeChild(ta);
}
let toastTimer = 0;
function flashToast(msg) {
if (!els.toast) return;
els.toast.textContent = msg;
els.toast.classList.add('is-visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove('is-visible'), 1800);
}
// ── Утиліта ──────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ── Bootstrap ────────────────────────────────────────────────────────
function init() {
renderWelcomeStats();
els.modeBtns.forEach((btn) => {
btn.addEventListener('click', () => {
els.modeBtns.forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
state.mode = btn.dataset.mode;
});
});
// дефолтно — easy
els.modeBtns.forEach((b) => b.classList.toggle('is-active', b.dataset.mode === 'easy'));
els.startBtn.addEventListener('click', () => startGame(state.mode, false));
els.dailyBtn.addEventListener('click', () => startGame('daily', true));
els.checkBtn.addEventListener('click', checkAnswer);
els.backBtn.addEventListener('click', () => {
stopTimer();
setScreen('welcome');
renderWelcomeStats();
});
els.shareBtn.addEventListener('click', () => { stopAutoNext(); shareResult(); });
els.replayBtn.addEventListener('click', () => {
stopAutoNext();
startGame(state.mode === 'daily' ? 'medium' : state.mode, false);
});
els.homeBtn.addEventListener('click', () => {
stopAutoNext();
setScreen('welcome');
renderWelcomeStats();
});
if (els.countdownCancel) {
els.countdownCancel.addEventListener('click', stopAutoNext);
}
// Глобальний keyboard: Enter на game = перевірити
root.addEventListener('keydown', (e) => {
if (root.getAttribute('data-state') !== 'game') return;
if (e.key === 'Enter' && document.activeElement && document.activeElement.classList.contains('mg-card')) {
e.preventDefault();
checkAnswer();
}
});
setScreen('welcome');
}
init();
})();