Вулиця дня
01 / БЕССАРАБКА0:00
0г
МАЛЕНЬКА ПРИГОДА НА ВЕЛИКІЙ ВУЛИЦІ
Велике місто.
Маленькі крила.
ХРЕЩАТИК БЕЗ КІНЦЯ
Тапни — підскоч. Тримай — пурхни.
Збирай крихти й бережи пір’їнки.
Три спроби на сьогодні
/* _core/rng.js */
(function (root) {
'use strict';
const C = root.GameCore || (root.GameCore = {});
// Детермінована випадковість. У моделі не має бути жодного Math.random():
// без відтворюваності неможливий ані тюнінг, ані спільна «зміна дня».
function mulberry32(seed) {
let a = seed | 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pad2(n) {
return (n < 10 ? '0' : '') + n;
}
// Локальна календарна дата гравця: доба міняється опівночі за його годинником.
function todayKey(date) {
const d = date instanceof Date ? date : new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// Сид доби — хеш ключа дати. Однаковий у всіх, у кого сьогодні той самий день.
function dailySeed(date) {
const key = typeof date === 'string' ? date : todayKey(date);
let h = 2166136261;
for (let i = 0; i < key.length; i += 1) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0);
}
function kyivDay(date) {
const parts = new Intl.DateTimeFormat('en-CA', {timeZone: 'Europe/Kyiv', year: 'numeric', month: '2-digit', day: '2-digit'}).formatToParts(date || new Date());
return ['year', 'month', 'day'].map(function (k) { return parts.find(function (p) { return p.type === k; }).value; }).join('-');
}
C.rng = {
kyivDay: kyivDay,
mulberry32: mulberry32,
dailySeed: dailySeed,
todayKey: todayKey
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
/* _core/store.js */
(function (root) {
'use strict';
const C = root.GameCore || (root.GameCore = {});
C.createStore = function (key, defaults, normalize) {
const clone = function (x) { return JSON.parse(JSON.stringify(x)); };
let memory = null;
function storage() { try { return root.localStorage || null; } catch (e) { return null; } }
function available() {
try { const s = storage(); if (!s) return false; s.setItem(key + '-probe', '1'); s.removeItem(key + '-probe'); return true; } catch (e) { return false; }
}
function load() {
if (!available()) return memory === null ? defaults() : clone(memory);
try {
const raw = storage().getItem(key);
if (!raw) return defaults();
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return defaults();
return normalize ? normalize(parsed) : parsed;
} catch (e) { return defaults(); }
}
function save(value) {
memory = clone(value);
try { const s = storage(); if (s) s.setItem(key, JSON.stringify(value)); } catch (e) { /* tab memory remains available */ }
return value;
}
return {load: load, save: save, available: available, patch: function (x) { return save(Object.assign(load(), x)); }};
};
})(globalThis);
/* _core/dom.js */
(function (root) {
'use strict';
const C = root.GameCore || (root.GameCore = {});
// Дрібний спільний хелпер для ui/*. Власний модуль, а не побічний продукт
// схеми: інакше ui/hud мовчки залежав би від того, що ui/schema завантажився
// раніше, і порядок тримав би тільки список ORDER у build.py.
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined && text !== null) node.textContent = String(text);
return node;
}
// Форматування числа й хвилини доби живе тут із тієї самої причини, з якої
// тут живе el(): його однаково потребують і звіт, і шер-картка. Якби воно
// лишалось у ui/report, ui/share залежав би від ui/report і від порядку
// склейки - рівно та форма, заради якої цей модуль і заводили.
//
// Число: нерозривний пробіл у розрядах і кома в дробовій частині. Картку
// людина кладе поруч зі звітом і звіряє очима, тому правило одне на обидва.
function num(value, digits) {
const v = Number(value);
if (!isFinite(v)) return String.fromCharCode(0x2014);
const d = digits === undefined ? 0 : digits;
const parts = Math.abs(v).toFixed(d).split('.');
parts[0] = parts[0].split('').map(function (ch, i, a) { return ch + (i < a.length - 1 && (a.length - i - 1) % 3 === 0 ? String.fromCharCode(0x00A0) : ''); }).join('');
const body = parts.length > 1 ? parts[0] + ',' + parts[1] : parts[0];
return (v < 0 ? '-' : '') + body;
}
function minutes(value) {
return num(value, 1) + ' хв';
}
// Хвилина доби -> ГГ:ХХ. Модель тримає свою копію всередині себе; сюди
// ходять тільки ui/*.
function clock(minute) {
const m = Math.max(0, Math.round(Number(minute) || 0));
const h = Math.floor(m / 60) % 24;
const mm = m % 60;
return (h < 10 ? '0' : '') + h + ':' + (mm < 10 ? '0' : '') + mm;
}
C.dom = { el: el, num: num, minutes: minutes, clock: clock };
})(typeof globalThis !== 'undefined' ? globalThis : this);
/* city.js */
(function (H) {
'use strict';
H.city = {
id: 'kyiv', name: 'Київ', duration: 300,
sections: [
{name: 'Бессарабка', sign: 'БЕССАРАБСЬКИЙ РИНОК', note: 'Починаємо з крихт', shape: 'market'},
{name: 'ЦУМ', sign: 'ЦУМ', note: 'У ритмі великого міста', shape: 'store'},
{name: 'Пасаж', sign: 'ПАСАЖ', note: 'Тут пахне круасанами', shape: 'passage'},
{name: 'Поштамт', sign: 'ГОЛОВПОШТАМТ', note: 'Перепочинок біля пошти', shape: 'post'},
{name: 'Майдан', sign: 'МАЙДАН НЕЗАЛЕЖНОСТІ', note: 'Маленькі крила, велика площа', shape: 'column'},
{name: 'Європейська', sign: 'ЄВРОПЕЙСЬКА ПЛОЩА', note: 'Ще трохи — і ти вдома', shape: 'square'}
],
weatherURL: 'https://my-kiev.com/pohoda/api/current.php?city=kyiv',
alertURL: 'https://my-kiev.com/alerts/api/alerts/31',
publicURL: 'https://my-kiev.com/games/holub-khreshchatyka.html'
};
H.city.maidanIndex = H.city.sections.findIndex(function (section) { return section.name === 'Майдан'; });
H.city.maidanAt = H.city.maidanIndex * H.city.duration / H.city.sections.length;
})(globalThis.Holub = globalThis.Holub || {});
/* model.js */
(function (H, C) {
'use strict';
const STEP = 1 / 60;
const kinds = {
foot: {lead: 0.68, height: 48, label: 'Крок попереду · підскоч'},
scooter: {lead: 0.8, height: 170, label: 'Самокат ззаду! Тримай'},
child: {lead: 2, height: 170, label: 'А-а-а! Дитина біжить · тримай'},
pigeon: {lead: 0.85, height: 45, label: 'Голуби біля їжі · підскоч'},
photographer: {lead: 0.85, height: 48, label: 'Фотограф · підскоч'},
chestnut: {lead: 0.9, height: 48, label: 'Каштан! Підскоч'},
umbrella: {lead: 0.8, height: 170, label: 'Парасоля · тримай'}
};
function conditions(day, weather) {
const month = Number(day.slice(5, 7));
return {day: day, weather: weather || 'dry', season: month >= 9 && month <= 11 ? 'autumn' : month === 12 || month < 3 ? 'winter' : month < 6 ? 'spring' : 'summer', weekend: [0, 6].includes(new Date(day + 'T12:00:00Z').getUTCDay())};
}
function generate(seed, env, leg) {
leg = leg || 0;
const duration = H.city.duration;
const random = C.rng.mulberry32(C.rng.dailySeed(String(seed) + ":" + leg)), events = [];
function add(time, type, extra) { events.push(Object.assign({time: time, type: type}, extra)); }
add(4, 'foot', {safe: true, variant: 0});
for (let t = 16; t < duration - 3;) {
const forwardSection = Math.min(5, Math.floor(t / (duration / 6)));
const section = leg % 2 ? 5 - forwardSection : forwardSection;
const pool = H.city.sections[section].shape === 'column' ? ['foot', 'scooter', 'child', 'photographer'] : ['foot', 'foot', 'pigeon', 'scooter'];
if (H.city.sections[section].shape === 'post') pool.splice(0, pool.length, 'foot', 'foot', 'pigeon');
if (env.season === 'autumn') pool.push('chestnut');
if (env.weather === 'rain') pool.push('umbrella');
const type = pool[Math.floor(random() * pool.length)];
add(t, type, {variant: Math.floor(random() * 6)});
// A complete flight, landing and full recharge fit between flight hazards.
t += 2.85 + random() * 1.35 + (H.city.sections[section].shape === 'post' ? 0.65 : 0) + (env.weather === 'rain' ? 0.6 : 0) - (env.weekend ? 0.25 : 0);
}
const foodGap = (env.weekend ? 0.55 : 1.05) * (env.weather === 'snow' ? 1.4 : 1);
for (let t = 1.3; t < duration - 1; t += foodGap + random() * 0.3) {
const roll = random();
add(t, 'food', {food: roll < 0.06 ? 'seed' : roll < 0.55 ? 'croissant' : 'bread', grams: roll < 0.06 ? 5 : roll < 0.55 ? 2 : 1});
}
Array.from({length: Math.floor(duration / 30)}, function (_, i) { return 10 + i * 30; }).forEach(function (t) {
add(t, 'granny');
for (let i = 0; i < 9; i++) add(t + i / 3, 'food', {food: i === 0 ? 'seed' : 'bread', grams: i === 0 ? 5 : 1});
});
Array.from({length: Math.floor(duration / 35)}, function (_, i) { return 25 + i * 35; }).forEach(function (t) { add(t, 'crow'); });
// Non-damaging interruptions never hide an active warning.
Array.from({length: Math.floor(duration / 50)}, function (_, i) { return 40 + i * 50; }).forEach(function (t) {
if (!events.some(function (e) { return kinds[e.type] && Math.abs(e.time - t) < 2.5; })) add(t, 'flyer');
});
if (env.weather === 'rain') Array.from({length: Math.floor(duration / 40)}, function (_, i) { return 22 + i * 40; }).forEach(function (t) { add(t, 'puddle'); });
if (env.weather === 'heat') for (let t = 8; t < duration - 1; t += 6) add(t, 'food', {food: 'icecream', grams: 3});
return events.sort(function (a, b) { return a.time - b.time; }).map(function (e, i) { return Object.assign(e, {id: leg + ":" + i, time: e.time + leg * duration}); });
}
function create(seed, env) {
return {seed: seed, env: env, events: generate(seed, env), cursor: 0, t: 0, ticks: 0, completedLegs: 0, direction: 1, maxSection: 0, y: 0, vy: 0, wings: 1, feathers: 3, grams: 0, held: 0, flightSpent: false, wasDown: false, flying: false, ended: false, won: false, section: 0, invulnerable: 0, freeze: 0, slow: 0, flyer: 0, crow: 0, feedback: '', feedbackUntil: 0, lastCause: '', collected: 0};
}
function say(s, text) { s.feedback = text; s.feedbackUntil = s.t + 1.1; }
function step(s, down) {
if (s.ended) return s;
s.ticks++;
s.t = s.ticks * STEP;
const leg = Math.floor(s.t / H.city.duration);
if (leg !== s.completedLegs) {
s.completedLegs = leg; s.direction = leg % 2 ? -1 : 1;
s.grams += s.feathers * 5;
s.events = generate(s.seed, s.env, leg); s.cursor = 0;
say(s, s.direction < 0 ? 'Європейська! Повертаємо до Бессарабки' : 'Бессарабка! Знову до Європейської');
s.feedbackUntil = s.t + 3;
}
const position = Math.min(5, Math.floor((s.t % H.city.duration) / (H.city.duration / 6)));
s.section = s.direction > 0 ? position : 5 - position;
s.maxSection = Math.max(s.maxSection, s.section);
s.invulnerable = Math.max(0, s.invulnerable - STEP);
s.freeze = Math.max(0, s.freeze - STEP);
s.slow = Math.max(0, s.slow - STEP);
s.flyer = Math.max(0, s.flyer - STEP);
s.crow = Math.max(0, s.crow - STEP);
const grounded = s.y === 0;
if (down && !s.wasDown && grounded && !s.freeze) s.vy = s.slow ? 365 : 440;
s.held = down ? s.held + STEP : 0;
if (!down) s.flightSpent = false;
s.flying = !!(down && s.held > 0.18 && s.wings > 0 && !s.freeze && !s.flightSpent);
if (s.flying) {
s.vy = Math.max(s.vy, 270);
s.wings = Math.max(0, s.wings - STEP / 1.5);
if (s.wings < 0.000001) s.flightSpent = true;
} else s.vy -= (envSnow(s) ? 1030 : 1120) * STEP;
s.y = Math.max(0, Math.min(220, s.y + s.vy * STEP));
if (s.y === 0) { s.vy = 0; s.wings = Math.min(1, s.wings + STEP / 0.8); }
if (s.y === 220) s.vy = Math.min(0, s.vy);
s.wasDown = down;
while (s.cursor < s.events.length && s.events[s.cursor].time <= s.t) {
const e = s.events[s.cursor++], hazard = kinds[e.type];
const clearance = hazard && (e.type === 'scooter' || e.type === 'umbrella') ? 153 + e.variant * 4 : hazard && hazard.height;
if (hazard && s.y < clearance && !s.invulnerable && !e.safe) {
if (e.type === 'photographer') { s.freeze = 1; say(s, 'Потрапив у кадр!'); }
else {
s.feathers--; s.invulnerable = 1.2; s.lastCause = e.type;
s.vy = 300; say(s, 'Ой! Мінус пір’їнка');
if (!s.feathers) { s.ended = true; break; }
}
} else if (e.type === 'food' && s.y < 22 && !s.flying && !s.freeze) {
if (s.crow > 0 && e.food !== 'seed') { say(s, 'Ворона встигла першою'); continue; }
s.grams += e.grams; s.collected++;
if (e.food === 'seed') { s.feathers = Math.min(3, s.feathers + 1); say(s, '+5 г · зерно додає пір’їнку'); }
else say(s, '+' + e.grams + ' г');
} else if (e.type === 'granny') say(s, 'Бабуся пригощає!');
else if (e.type === 'crow') s.crow = 1.4;
else if (e.type === 'flyer') s.flyer = 1;
else if (e.type === 'puddle' && s.y < 22) { s.slow = 1; say(s, 'Калюжа · крок повільніший'); }
}
return s;
}
function envSnow(s) { return s.env.weather === 'snow'; }
function warning(s) { return s.events.find(function (e) { return kinds[e.type] && e.time > s.t && e.time - s.t <= kinds[e.type].lead; }); }
H.model = {STEP: STEP, kinds: kinds, conditions: conditions, generate: generate, create: create, step: step, warning: warning};
})(globalThis.Holub, globalThis.GameCore);
/* progress.js */
(function (H, C) {
'use strict';
function blank() { return {days: {}, practiceBest: 0, lastVisit: null}; }
function normalize(p) {
const v = blank();
if (p.days && typeof p.days === 'object' && !Array.isArray(p.days)) Object.keys(p.days).sort().slice(-370).forEach(function (day) {
const r = p.days[day];
if (day.length !== 10 || !r || typeof r !== 'object') return;
v.days[day] = {attempts: Math.min(3, Math.max(0, Math.floor(Number(r.attempts) || 0))), best: Math.max(0, Number(r.best) || 0), reached: r.reached === true, weather: ['dry', 'rain', 'snow', 'heat'].includes(r.weather) ? r.weather : null, shared: r.shared === true, source: r.source === 'live' ? 'live' : 'fallback'};
});
v.practiceBest = Math.max(0, Number(p.practiceBest) || 0);
v.lastVisit = typeof p.lastVisit === 'string' ? p.lastVisit : null;
return v;
}
const store = C.createStore('holub-khreshchatyka-v1', blank, normalize);
function day(p, key) { return p.days[key] || (p.days[key] = {attempts: 0, best: 0, reached: false, weather: null, source: 'fallback'}); }
function previous(key) { return new Date(Date.parse(key + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10); }
function streak(p, key) {
let cursor = day(p, key).reached ? key : previous(key), count = 0;
while (p.days[cursor] && p.days[cursor].reached && count < 370) { count++; cursor = previous(cursor); }
return count;
}
H.progress = {store: store, blank: blank, normalize: normalize, day: day, previous: previous, streak: streak};
})(globalThis.Holub, globalThis.GameCore);
/* art.js */
(function (H) {
'use strict';
// Original site emblem, embedded for offline play and export-safe Canvas.
const brandLogo = new Image();
brandLogo.src = 'data:image/png;base64,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';
const paper = '#faf7f0', ink = '#0e0e0f', yellow = '#ffd400';
function path(c, d, fill, stroke, width) { const p = new Path2D(d); if (fill) { c.fillStyle = fill; c.fill(p); } if (stroke) { c.strokeStyle = stroke; c.lineWidth = width || 2; c.stroke(p); } }
function line(c, x, y, a, b, color, w) { c.strokeStyle = color || ink; c.lineWidth = w || 1; c.beginPath(); c.moveTo(x, y); c.lineTo(a, b); c.stroke(); }
function ellipse(c, x, y, rx, ry, color, stroke) { c.beginPath(); c.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); c.fillStyle = color; c.fill(); if (stroke) { c.strokeStyle = stroke; c.lineWidth = 2; c.stroke(); } }
function text(c, t, x, y, size, color, weight) { c.fillStyle = color || ink; c.font = (weight || '700') + ' ' + size + 'px Manrope, Arial, sans-serif'; if (c.getTransform().a < 0) { c.save(); c.translate(x, y); c.scale(-1, 1); c.fillText(t, 0, 0); c.restore(); } else c.fillText(t, x, y); }
function pigeon(c, x, y, size, pose, phase) {
c.save(); c.translate(x, y + (pose === 'walk' ? Math.sin(phase * 12) * 1.5 : 0)); c.scale(size, size); c.lineJoin = 'round'; c.lineCap = 'round';
const step = pose === 'walk' ? Math.sin(phase * 12) * 9 : 0;
path(c, 'M -18 -12 L -25 9 L -43 16 M -25 9 L -15 14 M 14 -10 L 12 13 L 33 16 M 12 13 L 5 20', null, ink, 6);
c.save(); c.translate(step, 0); path(c, 'M -18 -12 L -25 9 L -43 16 M -25 9 L -15 14 M 14 -10 L 12 13 L 33 16 M 12 13 L 5 20', null, '#c86b37', 3); c.restore();
path(c, 'M -56 -24 L -108 -13 L -91 -33 L -118 -26 L -66 -58 Z', '#34383a', ink, 3);
path(c, 'M -69 -56 C -39 -71 -8 -73 5 -95 C 9 -131 18 -150 43 -149 C 69 -150 82 -133 78 -116 C 69 -95 73 -80 62 -51 C 44 -9 -30 4 -66 -23 C -81 -33 -82 -46 -69 -56 Z', '#8a8b86', ink, 3.5);
path(c, 'M 9 -100 C 27 -107 52 -97 68 -99 L 62 -75 C 38 -84 26 -82 9 -86 Z', '#646f65');
path(c, 'M 13 -94 Q 38 -83 66 -89 M 15 -88 Q 36 -78 61 -81', null, '#b3b0a3', 2);
path(c, 'M 20 -143 C 40 -157 64 -143 71 -132 C 46 -144 30 -139 20 -131 Z', '#babbb5');
path(c, 'M 75 -126 L 93 -117 L 75 -112 L 70 -116 Z', '#dc9b42', ink, 2);
path(c, 'M 70 -125 Q 78 -128 79 -119 L 71 -117 Z', paper, ink, 1);
ellipse(c, 60, -130, 7.5, 8, '#dc8524', ink); ellipse(c, 61, -130, 3, 4, ink); ellipse(c, 62, -132, 1.3, 1.3, paper);
if (pose === 'fly') {
const flap = Math.sin(phase * 23) * 15;
c.save(); c.translate(-13, -64); c.rotate((-35 + flap) * Math.PI / 180);
path(c, 'M 0 0 C -34 -26 -57 -75 -59 -128 Q -48 -139 -37 -113 L -21 -76 L -29 -130 Q -16 -141 -10 -105 L -1 -69 L 3 -114 Q 20 -120 18 -89 L 18 -51 L 33 -83 Q 49 -80 33 -41 L 18 -9 Z', '#b5b6b0', ink, 3);
for (let i = 0; i < 6; i++) line(c, -36 + i * 9, -83 + i * 6, -15 + i * 6, -21, '#656865', 2);
c.restore();
} else {
path(c, 'M -66 -57 C -41 -85 8 -77 15 -54 C 15 -35 -32 -20 -89 -23 Z', '#b2b3ac', ink, 2.5);
path(c, 'M -78 -32 Q -32 -27 1 -52 L -4 -44 Q -38 -21 -84 -27 Z M -65 -44 Q -34 -39 -9 -61 L -6 -54 Q -32 -34 -69 -36 Z', '#3e4240');
for (let i = 0; i < 5; i++) path(c, 'M ' + (-47 + i * 9) + ' -61 q -10 7 -2 11 q 6 0 10 -5', null, '#dad9ce', 2);
}
for (let i = 0; i < 13; i++) { const xx = 22 + (i * 17 % 33), yy = -68 + (i * 13 % 37); line(c, xx, yy, xx - 3, yy + 5, '#636963', 1); }
if (pose === 'hurt') { path(c, 'M 53 -138 L 66 -140', null, ink, 3); ellipse(c, 65, -110, 9, 4, '#c58b76'); }
c.restore();
}
function building(c, x, base, w, h, variant, sign) {
c.save(); c.translate(x, base); c.fillStyle = '#ded7c8'; c.strokeStyle = '#807c70'; c.lineWidth = 1.5;
c.fillRect(0, -h, w, h); c.strokeRect(0, -h, w, h);
path(c, 'M -7 ' + (-h) + ' L ' + (w / 2) + ' ' + (-h - (variant === 'market' ? 32 : 14)) + ' L ' + (w + 7) + ' ' + (-h) + ' Z', '#d3cbbc', '#807c70');
for (let row = 0; row < Math.floor(h / 42) - 1; row++) {
const yy = -h + 24 + row * 42;
line(c, 0, yy + 29, w, yy + 29, '#a9a193', 2);
for (let col = 12; col < w - 15; col += 28) {
c.fillStyle = row % 2 ? '#b1aca0' : '#aaa69c'; c.fillRect(col, yy, 12, 23);
c.strokeStyle = '#79786e'; c.strokeRect(col, yy, 12, 23); line(c, col + 6, yy, col + 6, yy + 23, '#ded7c8');
line(c, col - 2, yy - 4, col + 15, yy - 4, '#827f72', 2);
}
}
for (let col = 8; col < w - 22; col += 33) {
path(c, 'M ' + col + ' 0 L ' + col + ' -33 Q ' + (col + 12) + ' -53 ' + (col + 24) + ' -33 L ' + (col + 24) + ' 0 Z', '#8c8b80', '#77756a');
}
if (variant === 'post' || variant === 'passage') {
for (let col = w * 0.28; col < w * 0.75; col += 18) { c.fillStyle = '#ece5d4'; c.fillRect(col, -h + 37, 8, h - 40); line(c, col + 8, -h + 37, col + 8, -3, '#827d70', 2); }
}
if (variant === 'market') {
path(c, 'M 30 ' + (-h) + ' Q ' + (w / 2) + ' ' + (-h - 83) + ' ' + (w - 30) + ' ' + (-h) + ' Z', '#cbc2af', '#807c70');
for (let i = 0; i < 9; i++) line(c, 49 + i * 21, -h - 2, w / 2 + (i - 4) * 8, -h - 37, '#9c9585', 1);
}
if (variant === 'passage') {
path(c, 'M ' + (w / 2 - 27) + ' 0 L ' + (w / 2 - 27) + ' -75 Q ' + (w / 2) + ' -119 ' + (w / 2 + 27) + ' -75 L ' + (w / 2 + 27) + ' 0 Z', '#6c6d62', '#a89d88', 8);
path(c, 'M ' + (w / 2 - 30) + ' ' + (-h) + ' L ' + (w / 2 - 20) + ' ' + (-h - 30) + ' L ' + (w / 2) + ' ' + (-h - 52) + ' L ' + (w / 2 + 20) + ' ' + (-h - 30) + ' L ' + (w / 2 + 30) + ' ' + (-h) + ' Z', '#d3cbbc', '#807c70');
}
if (variant === 'post') {
c.fillStyle = '#d3cbbc'; c.fillRect(w / 2 - 22, -h - 38, 44, 38); c.strokeRect(w / 2 - 22, -h - 38, 44, 38);
path(c, 'M ' + (w / 2 - 26) + ' ' + (-h - 38) + ' L ' + (w / 2) + ' ' + (-h - 65) + ' L ' + (w / 2 + 26) + ' ' + (-h - 38) + ' Z', '#b2ac9d', '#807c70');
line(c, w / 2, -h - 65, w / 2, -h - 97, '#807c70', 2);
}
if (sign) { c.fillStyle = '#eee7d8'; c.fillRect(4, -h + 4, w - 8, 21); c.textAlign = 'center'; text(c, sign, w / 2, -h + 19, Math.min(15, w / (sign.length * 0.63)), '#66645b'); }
for (let i = 0; i < 36; i++) { const xx = (i * 41) % w, yy = -(i * 29 % h); line(c, xx, yy, Math.min(w, xx + 9), yy - 3, '#c4bbaa', 0.6); }
if (sign === 'ЦУМ') {
// A painted facade banner, kept readable on both directions of the route.
c.save(); c.translate(w / 2, -h + 61);
if (c.getTransform().a < 0) c.scale(-1, 1);
c.fillStyle = paper; c.strokeStyle = ink; c.lineWidth = 2;
c.fillRect(-112, 0, 224, 186); c.strokeRect(-112, 0, 224, 186);
if (brandLogo.complete && brandLogo.naturalWidth) c.drawImage(brandLogo, -94, 16, 48, 41.25);
c.textAlign = 'left'; c.fillStyle = ink;
c.font = '800 23px Unbounded, Arial, sans-serif'; c.fillText('Мій Київ', -35, 44);
line(c, -94, 70, 94, 70, '#bab3a5');
c.textAlign = 'center';
text(c, 'Сайт «Мій Київ» бажає', 0, 95, 16, ink, '600');
text(c, 'вашому голубу', 0, 117, 17, ink, '600');
text(c, 'принести мир', 0, 142, 20, ink, '800');
text(c, 'в Україну', 0, 167, 22, ink, '800');
c.restore();
}
c.restore();
}
function lamp(c, x, y, scale) {
c.save(); c.translate(x, y); c.scale(scale, scale);
path(c, 'M -13 0 L -8 -15 L -4 -24 L -3 -153 L 3 -153 L 4 -24 L 8 -15 L 13 0 Z', '#393b35');
path(c, 'M 0 -145 Q -32 -178 -32 -143 M 0 -145 Q 32 -178 32 -143', null, '#393b35', 4);
[-32, 0, 32].forEach(function (a) { path(c, 'M ' + (a - 8) + ' -145 L ' + (a - 12) + ' -170 L ' + a + ' -180 L ' + (a + 12) + ' -170 L ' + (a + 8) + ' -145 Z', '#e9dec3', '#393b35', 3); line(c, a, -178, a, -145, '#393b35', 2); });
c.restore();
}
function tree(c, x, y, scale, season) {
c.save(); c.translate(x, y); c.scale(scale, scale);
line(c, 0, 0, 0, -90, '#676755', 6);
for (let i = 0; i < 19; i++) { const xx = Math.sin(i * 2.4) * (18 + i % 4 * 5), yy = -85 + Math.cos(i * 2.4) * 35; line(c, 0, -40, xx, yy, '#777360'); ellipse(c, xx, yy, 13 + i % 4 * 2, 18, season === 'autumn' ? ['#b5a139', '#d6bc36', '#c6b256'][i % 3] : season === 'winter' ? '#e2e0d5' : '#acb18b'); }
c.restore();
}
function shoe(c, x, ground, variant, progress) {
c.save(); c.translate(x, ground - Math.max(0, progress) * 320);
const dark = variant % 2 === 0;
path(c, 'M -11 -560 L 115 -560 L 95 -95 L 70 -54 L -21 -52 Z', dark ? '#292b2a' : '#a09b8f', ink, 3);
for (let i = 0; i < 10; i++) line(c, 1 + i * 10, -550, -12 + i * 9, -93, dark ? '#454641' : '#bab4a7', 1);
if (variant === 1 || variant === 4) {
path(c, 'M -21 -72 Q -49 -47 -90 -30 L -116 -13 L -98 -3 L 25 -3 L 64 -43 L 66 -4 L 78 -4 L 87 -74 Z', '#202220', ink, 3);
path(c, 'M -115 -12 L -98 -2 L 24 -2 L 61 -42', null, paper, 4);
} else {
path(c, 'M -25 -96 Q 13 -64 78 -81 L 108 -47 Q 120 -36 117 -15 L -114 -15 Q -128 -45 -83 -54 L -47 -62 Z', dark ? '#e9e6dc' : '#333631', ink, 4);
path(c, 'M -120 -24 Q -14 -10 119 -28 L 119 -8 Q -4 10 -120 -8 Z', paper, ink, 3);
path(c, 'M -89 -51 Q -32 -37 -14 -19 L 58 -22 L 16 -66 Z', '#4a4c47', ink, 2);
for (let i = 0; i < 5; i++) line(c, -48 + i * 14, -62 + i * 5, -32 + i * 15, -47 + i * 4, paper, 4);
for (let i = -106; i < 112; i += 10) line(c, i, -7, i + 3, -16, '#838477', 1.2);
}
c.restore();
}
function scooter(c, x, y) {
c.save(); c.translate(x, y);
[-62, 85].forEach(function (a) { ellipse(c, a, -31, 29, 32, '#222521', ink); ellipse(c, a, -31, 14, 17, '#73766b', ink); ellipse(c, a, -31, 5, 6, '#c1b8a8'); });
path(c, 'M -65 -29 L 39 -29 L 74 -89 L 62 -283 M 34 -280 L 92 -285', null, ink, 15);
path(c, 'M -62 -34 L 39 -34 L 74 -89 L 62 -267', null, '#e5322a', 8);
c.restore();
}
function food(c, x, y, type, scale) {
c.save(); c.translate(x, y); c.scale(scale || 1, scale || 1);
if (type === 'seed') { ellipse(c, 0, 0, 5, 9, '#ddd9bc', ink); line(c, -2, 5, 2, -5, ink); }
else { path(c, 'M -8 0 L -4 -7 L 2 -4 L 7 -6 L 9 2 L 2 7 L -5 5 Z', type === 'icecream' ? '#f3ddbf' : yellow, '#a87a24', 1); path(c, 'M -4 -2 L 0 -4 L 4 -1', null, '#fff8c9', 2); }
c.restore();
}
function viewport(width, height) {
const scale = Math.min(height / 650, width / 390);
return {scale: scale, worldWidth: width / scale, worldHeight: height / scale};
}
function scene(c, s, width, height, reduced) {
const camera = viewport(width, height);
const w = camera.worldWidth, h = camera.worldHeight, ground = h - 102, t = s.t, section = H.city.sections[s.section];
c.save(); c.clearRect(0, 0, width, height); c.scale(camera.scale, camera.scale);
if (s.direction < 0) { c.translate(w, 0); c.scale(-1, 1); }
c.fillStyle = '#f5f0e3'; c.fillRect(0, 0, w, h);
ellipse(c, 539, 87, 58, 58, '#f9e6a0');
// Background façades scroll slowly; low contrast keeps hazards legible.
const shift = reduced ? 0 : (t * 11) % 225;
for (let i = -1; i < Math.ceil(w / 225) + 1; i++) building(c, i * 225 - shift, 390, 220, 215 + (i % 3) * 26, i % 2 ? 'post' : 'store', null);
c.fillStyle = '#f5f0e3'; c.globalAlpha = 0.22; c.fillRect(0, 0, w, 405); c.globalAlpha = 1;
building(c, 31, 412, 272, section.shape === 'market' ? 200 : 304, section.shape, section.sign);
if (section.shape === 'column') {
path(c, 'M 495 411 L 495 383 L 507 372 L 513 198 L 526 198 L 531 372 L 545 383 L 545 411 Z', '#d2c9b5', '#8d8675');
path(c, 'M 509 197 L 502 186 L 519 177 L 536 186 L 528 197 Z M 515 177 L 515 159 L 501 145 L 507 140 L 520 152 L 534 140 L 538 145 L 525 159 L 525 177 Z', '#9c976f', '#797561');
}
tree(c, 370, 422, 1.35, s.env.season); tree(c, 650, 428, 0.85, s.env.season);
if (section.sign !== 'ЦУМ') lamp(c, 53, 442, 1.47); lamp(c, 624, 420, 0.78);
// Pedestrians remain scenery; the approaching legs form the foreground.
for (let i = 0; i < (s.env.weekend ? 22 : 11); i++) {
const x = ((i * 83 - t * (i % 2 ? 9 : -6)) % 790 + 790) % 790 - 30;
const yy = 404 + i % 3 * 8;
ellipse(c, x, yy - 40, 4, 5, '#888a7b');
path(c, 'M ' + (x - 4) + ' ' + (yy - 33) + ' l -4 21 l 4 0 l -1 20 l 4 0 l 2 -17 l 3 17 l 4 0 l -2 -21 l 3 -2 l -4 -18 Z', i % 5 === 0 ? '#b19e44' : '#878a7b');
}
c.fillStyle = '#dfd5c1'; c.fillRect(0, 442, w, h - 442);
[446, 458, 478, 510, 558, 628].forEach(function (y, i) {
line(c, 0, y, w, y, '#a99e89', 1.2);
const bw = 43 + i * 30;
for (let x = -(t * 90 % bw); x < w; x += bw) line(c, x + (i % 2) * bw / 2, y, x + (i % 2) * bw / 2 - 16, y + 12 + i * 10, '#b6aa93', 1);
});
for (let i = 0; i < 130; i++) { const xx = ((i * 137 - t * 28) % w + w) % w, yy = 447 + (i * 61 % 200); line(c, xx, yy, xx + 4 + i % 5, yy - 1, '#c5b8a0', 0.8); }
const px = Math.min(240, w * 0.3);
s.events.forEach(function (e) {
const delta = e.time - t;
if (delta > 3.3 || delta < -0.6) return;
let x = px + delta * (s.env.weather === 'snow' ? 135 : 175);
if (e.type === 'food') { if (delta > 0) food(c, x, ground - 2, e.food, e.food === 'seed' ? 1.1 : 0.9); return; }
if (e.type === 'foot') {
if (delta > 0 && delta < 1) ellipse(c, x, ground + 8, 82 * (1 - delta * 0.65), 14, '#ac9f85');
shoe(c, x + 40, ground + 2, e.variant, Math.max(0, delta - 0.08) * 1.5 + Math.max(0, -delta - 0.2));
} else if (e.type === 'scooter') { x = px - delta * 440; scooter(c, x, ground + 18); }
else if (e.type === 'child') { shoe(c, x, ground + 6, 3, Math.abs(Math.sin(delta * 9)) * 0.12); text(c, 'А-а-а!', x - 40, 243, 26, '#a42822'); }
else if (e.type === 'pigeon' || e.type === 'crow') { pigeon(c, x, ground - 4, e.type === 'crow' ? 0.52 : 0.4, 'walk', t); }
else if (e.type === 'chestnut') { ellipse(c, x, ground - Math.max(0, delta) * 280, 15, 13, '#896535', ink); ellipse(c, x + 4, ground - Math.max(0, delta) * 280 + 5, 7, 5, '#c6ad79'); }
else if (e.type === 'granny') {
path(c, 'M ' + (x - 40) + ' 70 L ' + (x + 35) + ' 70 L ' + (x + 70) + ' 440 L ' + (x - 80) + ' 440 Z', '#96907d', '#524f44', 3);
ellipse(c, x + 60, 352, 26, 39, '#c8b493', '#524f44'); text(c, 'ЗЕРНО', x + 38, 359, 12, '#524f44');
} else if (e.type === 'puddle') ellipse(c, x, ground + 14, 66, 14, '#a4b4b0', '#758e8a');
else if (e.type === 'photographer') { c.fillStyle = '#323730'; c.fillRect(x - 27, 352, 54, 40); ellipse(c, x, 370, 14, 14, '#8f9b91', ink); line(c, x, 392, x - 40, ground, ink, 6); line(c, x, 392, x + 40, ground, ink, 6); }
else if (e.type === 'umbrella') { path(c, 'M ' + (x - 95) + ' 365 Q ' + x + ' 215 ' + (x + 95) + ' 365 Z', '#827e6c', ink, 3); path(c, 'M ' + x + ' 363 l 0 118 q 0 25 21 10', null, ink, 4); }
});
ellipse(c, px - 6, ground + 10, 67 - s.y / 8, 11 - s.y / 40, '#ae9f86');
pigeon(c, px, ground - s.y, 0.77, s.invulnerable ? 'hurt' : s.flying || s.y > 85 ? 'fly' : 'walk', reduced ? 0 : t);
if (s.invulnerable && !reduced) { c.save(); c.translate(px - 45, ground - 90 - s.invulnerable * 90); c.rotate(s.t * 2); path(c, 'M 0 18 Q -15 -12 0 -24 Q 17 -12 0 18 Z', paper, ink); line(c, 0, -17, 0, 26); c.restore(); }
if (s.env.weather === 'rain' || s.env.weather === 'snow') for (let i = 0; i < 45; i++) {
const x = i * 91 % w, y = (i * 67 + (reduced ? 0 : t * 210)) % h;
if (s.env.weather === 'snow') ellipse(c, x, y, 2, 2, paper); else line(c, x, y, x - 4, y + 13, '#7c8985', 1);
}
if (s.env.season === 'spring') for (let i = 0; i < 15; i++) ellipse(c, i * 67 % w, (i * 87 + (reduced ? 0 : t * 26)) % 430, 4, 2, '#efe2d3');
if (s.flyer) { c.save(); c.translate(450, 300); c.rotate(-0.2); c.fillStyle = '#f7eed5'; c.strokeStyle = ink; c.fillRect(-180, -200, 340, 270); c.strokeRect(-180, -200, 340, 270); text(c, 'КАВА З СОБОЮ', -150, -100, 26); text(c, 'Листівка на крилі…', -150, -52, 19); c.restore(); }
if (s.feedbackUntil > t) { c.textAlign = 'center'; const label = s.feedback; c.font = 'bold 18px Manrope, Arial'; const fw = c.measureText(label).width + 26; c.fillStyle = paper; c.fillRect(px - fw / 2, 564, fw, 31); text(c, label, px, 586, 18); c.textAlign = 'left'; }
c.restore();
}
H.art = {viewport: viewport, scene: scene, pigeon: pigeon, text: text, line: line, food: food, paper: paper, ink: ink, yellow: yellow};
})(globalThis.Holub);
/* feeds.js */
(function (H) {
'use strict';
async function get(url) {
const controller = new AbortController();
const timer = setTimeout(function () { controller.abort(); }, 1800);
try { const r = await fetch(url, {signal: controller.signal, credentials: 'omit'}); return r.ok ? await r.json() : null; }
catch (e) { return null; } finally { clearTimeout(timer); }
}
function weather(json) {
if (!json || !Number.isFinite(json.wmoCode) || !Number.isFinite(json.tempC)) return null;
const code = json.wmoCode;
if ([71, 73, 75, 77, 85, 86].includes(code)) return 'snow';
if (code >= 51) return 'rain';
return json.tempC >= 28 ? 'heat' : 'dry';
}
function alert(json) {
if (!json || json.stale !== false || !Number.isFinite(json.at) || Date.now() - json.at > 180000) return null;
if (json.alert === null) return false;
return json.alert && Array.isArray(json.alert.activeAlerts) ? json.alert.activeAlerts.length > 0 : null;
}
H.feeds = {get: get, weather: weather, alert: alert, load: async function (day) {
const results = await Promise.all([get(H.city.weatherURL), get(H.city.alertURL), get('/wp-json/holub/v1/day')]);
const shared = results[2] && results[2].day === day && ['dry', 'rain', 'snow', 'heat'].includes(results[2].weather) ? results[2] : null;
return {weather: shared ? shared.weather : weather(results[0]), source: shared ? shared.source : weather(results[0]) ? 'live' : 'fallback', shared: !!shared, alert: alert(results[1])};
}};
})(globalThis.Holub);
/* share.js */
(function (H) {
'use strict';
const labels = {foot: 'кросівка', scooter: 'самоката', child: 'дитячого «А-а-а!»', pigeon: 'голубиної метушні', chestnut: 'каштана', umbrella: 'парасолі'};
function title(s) { return s.feathers > 0 ? 'Ситий і вільний' : 'На сьогодні досить'; }
function story(s) { return s.abandoned ? 'Завершив прогулянку. Повних проходів: ' + s.completedLegs + '.' : 'Злякався ' + (labels[s.lastCause] || 'міської метушні') + ' біля ' + ['Бессарабки', 'ЦУМу', 'Пасажу', 'Поштамту', 'Майдану', 'Європейської'][s.section] + '. Відпочиваю на даху.'; }
function rank(s) { if (s.completedLegs > 0 && s.feathers === 3 && s.grams >= 220) return 'Король Хрещатика'; return s.completedLegs > 0 ? 'Майданний старожил' : s.maxSection >= H.city.maidanIndex ? 'Голуб із Бессарабки' : s.section === 0 ? 'Голуб-турист' : 'Міський мандрівник'; }
function text(s, mode) {
return ['ГОЛУБ ХРЕЩАТИКА · ' + s.env.day + ' · ' + H.weatherNames[s.env.weather], mode === 'daily' ? 'Вулиця дня' : 'Вільна прогулянка', 'Бессарабка ' + Array.from({length: 6}, function (_, i) { return i <= s.maxSection ? '■' : '□'; }).join('') + ' Європейська', 'Проходів: ' + s.completedLegs + ' · ' + Math.floor(s.t / 60) + ' хв · ' + s.grams + ' г крихт · ' + s.feathers + ' з 3 пір’їн', story(s), H.city.publicURL].join(String.fromCharCode(10));
}
function card(canvas, s, mode) {
const c = canvas.getContext('2d'), A = H.art;
c.fillStyle = A.paper; c.fillRect(0, 0, 1080, 1080);
c.strokeStyle = A.ink; c.lineWidth = 5; c.strokeRect(27, 27, 1026, 1026);
A.text(c, 'Мій Київ', 66, 102, 32); A.text(c, 'МАЛЕНЬКІ КРИЛА. ВЕЛИКЕ МІСТО.', 535, 100, 19);
A.line(c, 66, 130, 1014, 130, A.ink, 2);
A.text(c, 'Голуб Хрещатика', 66, 217, 67);
A.text(c, s.env.day + ' · ' + H.weatherNames[s.env.weather] + ' · ' + (mode === 'daily' ? 'вулиця дня' : 'вільна прогулянка'), 69, 267, 22, '#605c52', '400');
c.save(); c.beginPath(); c.rect(52, 299, 976, 430); c.clip();
H.art.scene(c, Object.assign({}, s, {t: 0, events: [], y: 0, invulnerable: 0, feedbackUntil: 0}), 1080, 730, true); c.restore();
c.fillStyle = A.paper; c.fillRect(52, 681, 976, 340);
A.text(c, title(s), 70, 750, 54);
c.fillStyle = A.yellow; c.beginPath(); c.roundRect(750, 688, 247, 86, 43); c.fill(); c.strokeStyle = A.ink; c.lineWidth = 3; c.stroke();
A.text(c, s.grams + ' г', 780, 746, 48);
A.text(c, rank(s) + ' · ' + s.feathers + ' з 3 пір’їн', 70, 797, 25, '#625e54', '400');
for (let i = 0; i < 6; i++) { c.fillStyle = i <= s.maxSection ? A.yellow : '#ded9cd'; c.fillRect(70 + i * 156, 832, 139, 13); }
A.text(c, 'Бессарабка', 70, 878, 19); A.text(c, 'Європейська', 867, 878, 19);
const words = story(s).split(' '); let row = '', yy = 925;
c.font = '400 23px Arial';
words.forEach(function (word) { if (c.measureText(row + word).width > 920) { A.text(c, row, 70, yy, 23, A.ink, '400'); row = ''; yy += 31; } row += word + ' '; });
A.text(c, row, 70, yy, 23, A.ink, '400');
A.text(c, 'my-kiev.com/games/holub-khreshchatyka.html', 70, 1015, 22);
}
H.weatherNames = {dry: 'Без опадів', rain: 'Дощ', snow: 'Сніг', heat: 'Спека'};
H.share = {title: title, story: story, rank: rank, text: text, card: card};
})(globalThis.Holub);
/* leaderboard.js */
(function (H) {
'use strict';
const $ = function (id) { return document.getElementById('hk' + id); };
const base = 'https://my-kiev.com/wp-json/holub/v1/';
let current = null;
async function request(path, data) {
const controller = new AbortController(), timer = setTimeout(function () { controller.abort(); }, 10000);
try {
const response = await fetch(base + path, {method: data ? 'POST' : 'GET', credentials: 'omit', cache: 'no-store', signal: controller.signal, headers: data ? {'Content-Type': 'application/json'} : {}, body: data ? JSON.stringify(data) : undefined});
const body = await response.json();
if (!response.ok) throw new Error(body.message || 'Сервіс рекордів тимчасово недоступний.');
return body;
} finally { clearTimeout(timer); }
}
async function refresh() {
$('RefreshLeaders').disabled = true;
try {
const data = await request('leaderboard');
$('Leaders').replaceChildren();
data.scores.forEach(function (score) {
const row = document.createElement('li'), name = document.createElement('span'), detail = document.createElement('small'), grams = document.createElement('strong');
name.textContent = score.name; detail.textContent = Math.floor(score.seconds / 60) + ':' + String(score.seconds % 60).padStart(2, '0') + ' · повних проходів: ' + score.legs;
grams.textContent = score.grams + ' г'; name.append(detail); row.append(name, grams); $('Leaders').append(row);
});
$('LeadersStatus').textContent = data.scores.length ? 'Справжні результати гравців. Оновлено щойно.' : 'Рекордів ще немає. Стань першим!';
} catch (e) { $('LeadersStatus').textContent = 'Не вдалося оновити рекорди. Перевір з’єднання та спробуй ще раз.'; }
finally { $('RefreshLeaders').disabled = false; }
}
H.leaderboard = {
begin: function () { return request('session', {}).catch(function () { return null; }); },
result: function (state) {
current = {state: state, sent: false}; $('ScoreStatus').textContent = ''; $('SubmitScore').disabled = false;
}
};
$('ScoreForm').addEventListener('submit', async function (event) {
event.preventDefault(); const run = current, name = $('PlayerName').value.trim();
if (!run || run.sent || !name) return;
$('SubmitScore').disabled = true; $('ScoreStatus').textContent = 'Зберігаємо результат…';
try {
const session = await run.state.scoreSession;
if (!session) throw new Error('Ця прогулянка почалась без зв’язку із сервером рекордів. Спробуй нову прогулянку з інтернетом.');
const saved = await request('score', {token: session.token, name: name, grams: run.state.grams, seconds: Math.floor(run.state.t), legs: run.state.completedLegs});
run.sent = true;
if (current === run) $('ScoreStatus').textContent = 'Результат збережено! Твоє місце: ' + saved.rank + '.';
await refresh();
} catch (e) { if (current === run) $('ScoreStatus').textContent = e.message; }
finally { if (current === run) $('SubmitScore').disabled = run.sent; }
});
$('RefreshLeaders').addEventListener('click', refresh); refresh();
})(globalThis.Holub);
/* app.js */
(function (H, C) {
'use strict';
const root = document.getElementById('hkRoot');
if (!root || root.dataset.ready) return;
root.dataset.ready = 'true';
const $ = function (id) { return document.getElementById('hk' + id); };
const M = H.model, P = H.progress, store = P.store;
let dayKey = C.rng.kyivDay(), progress = store.load(), daily = P.day(progress, dayKey);
let state = M.create(C.rng.dailySeed(dayKey), M.conditions(dayKey, 'dry'));
let qualified = false;
let mode = 'daily', status = 'menu', down = false, pressed = false, pointer = null, accumulator = 0, lastTime = 0, sound = false, audioContext = null, previousWarning = null, lastFeedback = '', result = null, resultMode = null;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const ctx = $('Canvas').getContext('2d');
function emit(type, extra) {
const detail = Object.assign({game: 'holub-khreshchatyka', version: 1, event: type, day: dayKey, mode: mode, weather: state.env.weather}, extra);
root.dispatchEvent(new CustomEvent('holub:analytics', {bubbles: true, detail: detail}));
// Host analytics consumes this queue when available; no fabricated delivery claim.
if (Array.isArray(window.dataLayer)) window.dataLayer.push(Object.assign({event: 'holub_' + type}, detail, {event: 'holub_' + type}));
}
function refreshProgress() {
progress = store.load(); daily = P.day(progress, dayKey);
$('Best').textContent = daily.best ? daily.best + ' г' : '—';
$('Streak').textContent = P.streak(progress, dayKey);
$('Attempts').textContent = daily.attempts < 3 ? 'Спроба ' + (daily.attempts + 1) + ' з 3 · найкраща йде в залік' : 'Три спроби зіграно · вільні прогулянки без обмежень';
$('Storage').hidden = store.available();
}
H.city.sections.forEach(function (section) { $('Route').append(C.dom.el('li', '', section.name)); });
const feather = '<svg viewBox="0 0 28 36" aria-hidden="true"><path d="M4 31 C1 20 9 5 25 2 C28 17 16 30 7 29 L3 35 M7 29 L21 8 M10 23 L9 16 M15 17 L22 16" fill="currentColor" stroke="#0e0e0f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
$('Feathers').innerHTML = feather + feather + feather;
function resize() {
const box = $('Canvas').getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1);
$('Canvas').width = Math.round(box.width * dpr); $('Canvas').height = Math.round(box.height * dpr);
draw();
}
function draw() { H.art.scene(ctx, state, $('Canvas').width, $('Canvas').height, reduced.matches); }
function hud() {
$('Grams').textContent = state.grams;
$('Wings').value = state.wings;
$('Energy').textContent = Math.round(state.wings * 100) + '%';
$('Feathers').setAttribute('aria-label', state.feathers + ' з 3 пір’їн');
Array.from($('Feathers').children).forEach(function (el, i) { el.style.color = i < state.feathers ? '#ffd400' : '#faf7f0'; });
Array.from($('Route').children).forEach(function (el, i) {
el.className = i === state.section ? 'hk-current' : (state.direction > 0 ? i < state.section : i > state.section) ? 'hk-passed' : '';
if (i === state.section) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current');
});
const remaining = Math.floor(state.t);
$('Clock').textContent = Math.floor(remaining / 60) + ':' + String(remaining % 60).padStart(2, '0');
$('Place').textContent = (state.direction > 0 ? '→ ' : '← ') + H.city.sections[state.section].name.toUpperCase() + ' · ПРОХІД ' + (state.completedLegs + 1);
const w = status === 'running' ? M.warning(state) : null;
$('Warning').hidden = !w;
if (w) {
$('Warning').textContent = M.kinds[w.type].label;
if (w.id !== previousWarning) { previousWarning = w.id; tone(w.type === 'scooter' ? 920 : 540); }
}
if (state.feedback !== lastFeedback && state.feedbackUntil > state.t) { lastFeedback = state.feedback; if (state.feedback.length > 5) $('Live').textContent = state.feedback; }
}
function tone(frequency) {
if (!sound || !audioContext) return;
const osc = audioContext.createOscillator(), gain = audioContext.createGain();
osc.type = 'sine'; osc.frequency.value = frequency; gain.gain.value = 0.06;
osc.connect(gain); gain.connect(audioContext.destination); osc.start(); gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.15); osc.stop(audioContext.currentTime + 0.16);
}
const keyboardKeys = new Set();
function release() { down = false; pointer = null; }
async function loadDay() {
const requestedDay = dayKey;
$('Start').disabled = true;
const feeds = await H.feeds.load(dayKey);
if (dayKey !== requestedDay) return;
progress = store.load(); daily = P.day(progress, dayKey);
if (!daily.weather) { daily.weather = feeds.weather || 'dry'; daily.source = feeds.source; daily.shared = feeds.shared; store.save(progress); }
if (status === 'menu' || status === 'result') state = M.create(C.rng.dailySeed(dayKey + ':' + daily.weather), M.conditions(dayKey, daily.weather));
$('Day').textContent = new Intl.DateTimeFormat('uk-UA', {day: 'numeric', month: 'long', timeZone: 'Europe/Kyiv'}).format(new Date(dayKey + 'T12:00:00Z')) + ' · вулиця дня';
$('Weather').textContent = H.weatherNames[daily.weather] + ' · ' + {autumn: 'осінь', winter: 'зима', spring: 'весна', summer: 'літо'}[state.env.season];
$('Conditions').textContent = {dry: 'Суха бруківка, звичайний міський ритм.', rain: 'Менше перехожих. Парасолі й калюжі сповільнюють крок.', snow: 'Слизько, підскок трохи довший, крихт менше.', heat: 'Морозиво тане — лови додаткові 3 г.'}[daily.weather] + (state.env.weekend ? ' Вихідний: більше людей і вдвічі більше крихт.' : ' Будній день: місто поспішає.');
$('Source').textContent = daily.shared ? 'Спільна вулиця дня для всіх. ' + (daily.source === 'live' ? 'Погода міського сервісу.' : 'Резервна погода без опадів.') : daily.source === 'live' ? 'Погода міського сервісу, зафіксована для твоїх спроб на день. Порівнюй результати за однакової погоди.' : 'Погода недоступна: резервна вулиця без опадів, зафіксована на день.';
$('Alert').hidden = feeds.alert !== true;
$('AlertStatus').textContent = feeds.alert === null ? 'Статус тривоги недоступний. Стеж за офіційними сповіщеннями.' : '';
$('Start').disabled = false; refreshProgress(); hud(); draw();
}
function showOverlay(kicker, title, description, action) {
$('OverlayKicker').textContent = kicker; $('OverlayTitle').textContent = title;
$('OverlayText').textContent = description; $('Start').textContent = action;
$('Overlay').hidden = false;
}
async function start() {
if (navigator.locks) return navigator.locks.request('holub-start', beginRun);
return beginRun();
}
async function beginRun() {
if (status === 'running') return;
if (status === 'paused') { status = 'running'; $('Overlay').hidden = true; $('Restart').hidden = true; $('Pause').textContent = 'Ⅱ'; $('Pause').setAttribute('aria-label', 'Пауза'); lastTime = 0; accumulator = 0; $('Canvas').focus({preventScroll: true}); return; }
const today = C.rng.kyivDay();
if (today !== dayKey) { dayKey = today; status = 'menu'; await loadDay(); }
refreshProgress();
mode = daily.attempts < 3 ? 'daily' : 'practice';
const seed = mode === 'daily' ? C.rng.dailySeed(dayKey + ':' + daily.weather) : crypto.getRandomValues(new Uint32Array(1))[0];
state = M.create(seed, M.conditions(dayKey, daily.weather));
state.scoreSession = H.leaderboard.begin();
if (mode === 'daily') { daily.attempts++; store.save(progress); }
status = 'running'; qualified = false; release(); pressed = false; accumulator = 0; lastTime = 0; previousWarning = null;
$('Overlay').hidden = true; $('Restart').hidden = true; $('Pause').disabled = false;
$('Result').hidden = true; $('Canvas').focus({preventScroll: true});
$('Live').textContent = 'Прогулянку почато. Перші 15 секунд безпечні.';
emit('start', {attempt: mode === 'daily' ? daily.attempts : null}); hud();
}
function pause() {
if (status !== 'running') return;
status = 'paused'; keyboardKeys.clear(); release(); pressed = false; accumulator = 0;
showOverlay('НІКУДИ НЕ ПОСПІШАЄМО', 'Голуб перепочиває.', 'Пробіл — продовжити. Поточна спроба збережена до закриття вкладки.', 'Продовжити →');
$('Attempts').textContent = mode === 'daily' ? 'Поточна спроба вже врахована' : 'Вільна прогулянка';
$('Restart').hidden = false; $('Pause').textContent = '▶'; $('Pause').setAttribute('aria-label', 'Продовжити');
$('Start').focus({preventScroll: true}); hud();
}
function finish(abandoned) {
status = 'result'; release(); state.abandoned = !!abandoned; result = state; resultMode = mode;
progress = store.load(); daily = P.day(progress, dayKey);
if (mode === 'daily') { daily.best = Math.max(daily.best, state.grams); if (state.t >= H.city.maidanAt) daily.reached = true; }
else progress.practiceBest = Math.max(progress.practiceBest, state.grams);
store.save(progress); refreshProgress();
$('Pause').disabled = true; $('Pause').textContent = 'Ⅱ'; $('Pause').setAttribute('aria-label', 'Пауза'); $('Restart').hidden = true;
$('Result').hidden = false; $('ResultTitle').textContent = H.share.title(state);
$('ResultAdvice').hidden = false;
$('ResultText').textContent = state.grams + ' г · ' + H.share.rank(state) + '. ' + H.share.story(state) + (mode === 'practice' ? ' Рекорд вільних прогулянок: ' + progress.practiceBest + ' г.' : '');
H.share.card($('Card'), state, mode);
H.leaderboard.result(state);
$('ShareStatus').textContent = ''; $('ShareFallback').hidden = true;
showOverlay('ГОЛУБ У БЕЗПЕЦІ НА ДАХУ', H.share.title(state), state.grams + ' г крихт · ' + state.feathers + ' з 3 пір’їн. ' + 'Повних проходів: ' + state.completedLegs + '. Перепочинь — і знову на прогулянку.', daily.attempts < 3 ? 'Ще одна спроба →' : 'Вільна прогулянка →');
$('Live').textContent = $('ResultText').textContent;
$('Result').scrollIntoView({behavior: 'smooth', block: 'start'});
$('PlayerName').focus({preventScroll: true});
emit('finish', {grams: state.grams, seconds: Math.round(state.t), completedLegs: state.completedLegs, abandoned: !!abandoned, feathers: state.feathers});
}
function frame(time) {
if (status === 'running') {
const elapsed = lastTime ? Math.min(0.12, (time - lastTime) / 1000) : 0;
accumulator += elapsed;
while (accumulator >= M.STEP && !state.ended) { M.step(state, down || pressed); pressed = false; accumulator -= M.STEP; }
if (!qualified && mode === 'daily' && state.t >= H.city.maidanAt) {
qualified = true; progress = store.load(); daily = P.day(progress, dayKey); daily.reached = true; store.save(progress);
}
if (state.ended) finish(false);
hud(); draw();
}
lastTime = time; requestAnimationFrame(frame);
}
[$('Canvas'), $('Gesture')].forEach(function (surface) {
surface.addEventListener('pointerdown', function (e) {
if (status !== 'running' || pointer !== null || (e.pointerType === 'mouse' && e.button !== 0)) return;
e.preventDefault(); pointer = e.pointerId; down = true; pressed = true; surface.setPointerCapture(e.pointerId);
});
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (event) { surface.addEventListener(event, function (e) { if (e.pointerId === pointer) release(); }); });
surface.addEventListener('contextmenu', function (e) { e.preventDefault(); });
surface.addEventListener('blur', release);
});
// Keep keyboard control across window switches, without stealing keys from
// links, form fields or other buttons in the surrounding WordPress page.
function gameKeyTarget(target) {
if (!(target instanceof Element)) return false;
if (target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')) return false;
if (target === $('Canvas') || target === $('Gesture')) return true;
if (status === 'paused' && target === $('Start')) return true;
if (target.closest('button, a, [role="button"]')) return false;
return target === document.body || target === document.documentElement || root.contains(target);
}
document.addEventListener('keydown', function (e) {
if (!['Space', 'ArrowUp'].includes(e.code) || e.altKey || e.ctrlKey || e.metaKey) return;
if (!['running', 'paused'].includes(status) || !gameKeyTarget(e.target)) return;
e.preventDefault();
if (e.repeat) return;
keyboardKeys.add(e.code);
if (status === 'paused') { beginRun(); return; }
down = true; pressed = true;
});
document.addEventListener('keyup', function (e) {
if (!keyboardKeys.has(e.code)) return;
e.preventDefault(); keyboardKeys.delete(e.code);
if (!keyboardKeys.size) release();
});
window.addEventListener('focus', function () {
if (status === 'paused' && gameKeyTarget(document.activeElement)) $('Start').focus({preventScroll: true});
});
root.addEventListener('keydown', function (e) { if (e.code === 'Escape') { if (status === 'running') pause(); else if (status === 'paused') start(); } });
$('Start').addEventListener('click', start);
$('Pause').addEventListener('click', function () { if (status === 'paused') start(); else pause(); });
$('Restart').addEventListener('click', function () { if (status === 'paused') { state.ended = true; state.lastCause = ''; finish(true); } });
window.addEventListener('blur', pause);
document.addEventListener('visibilitychange', function () { if (document.hidden) pause(); });
$('Sound').addEventListener('click', function () {
sound = !sound;
try { if (sound) { audioContext = audioContext || new (window.AudioContext || window.webkitAudioContext)(); audioContext.resume(); } }
catch (e) { sound = false; }
$('Sound').textContent = sound ? 'Звук: так' : 'Звук: ні'; $('Sound').setAttribute('aria-pressed', String(sound)); $('Sound').setAttribute('aria-label', sound ? 'Вимкнути звук' : 'Увімкнути звук'); tone(650);
});
$('Fullscreen').addEventListener('click', async function () {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else if ($('Game').requestFullscreen) await $('Game').requestFullscreen();
else { $('Game').classList.toggle('hk-fullscreen'); $('Fullscreen').setAttribute('aria-label', $('Game').classList.contains('hk-fullscreen') ? 'Вийти з повного екрана' : 'На весь екран'); }
} catch (e) { $('Game').classList.toggle('hk-fullscreen'); }
resize();
});
document.addEventListener('fullscreenchange', function () { $('Fullscreen').setAttribute('aria-label', document.fullscreenElement ? 'Вийти з повного екрана' : 'На весь екран'); resize(); });
$('Share').addEventListener('click', async function () {
if (!result) return;
const payload = H.share.text(result, resultMode); emit('share_intent');
try {
if (navigator.share) { await navigator.share({title: 'Голуб Хрещатика', text: payload}); emit('share', {method: 'native'}); $('ShareStatus').textContent = 'Листівкою поділено.'; }
else if (navigator.clipboard) { await navigator.clipboard.writeText(payload); emit('share_copy'); $('ShareStatus').textContent = 'Текст скопійовано. Встав його в повідомлення.'; }
else throw new Error('No clipboard');
} catch (e) { if (e.name === 'AbortError') return; $('ShareFallback').hidden = false; $('ShareFallback').value = payload; $('ShareFallback').focus(); $('ShareFallback').select(); $('ShareStatus').textContent = 'Скопіюй цей текст для друзів.'; }
});
$('Download').addEventListener('click', function () {
if (!result) return;
$('Card').toBlob(function (blob) {
if (!blob) { $('ShareStatus').textContent = 'Не вдалося створити PNG. Спробуй ще раз.'; return; }
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = 'holub-' + result.env.day + '.png'; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
emit('share_download'); $('ShareStatus').textContent = 'Листівку PNG підготовлено до збереження.';
}, 'image/png');
});
if (progress.lastVisit === P.previous(dayKey)) emit('return_d1');
progress.lastVisit = dayKey; store.save(progress);
new ResizeObserver(resize).observe($('Canvas'));
refreshProgress(); hud(); resize(); loadDay(); requestAnimationFrame(frame);
})(globalThis.Holub, globalThis.GameCore);