Це художня гра зі спрощеною моделлю. Її показники й тривалість життя не є статистикою київських дерев.
window.KashtanConfig = {"assetsBase":"https://my-kiev.com/wp-content/uploads/landings/ya-kashtan/","publicUrl":"https://my-kiev.com/games/ya-kashtan.html"};
(function (root) {
'use strict';
// Narrative dates and biological advice are separate from fictional game units.
const event = (id, title, text, hint, art, stress, effect = {}, minYear = 1956) =>
({ id, title, text, hint, art, stress, effect, minYear });
const seasons = ['Весна', 'Літо', 'Осінь', 'Зима'];
const events = [
[
event('spring-rain', 'Дощ, який дістався коріння', 'Сьогодні місто пахне мокрим каменем. А я нарешті можу пити.', 'Добра нагода розвинути крону й поповнити запас.', 'watering', 1, {ask: 'crown', root: 3, health: 5}),
event('spring-bloom', 'Мої білі свічки', 'Піднімаю квіти над тротуаром. Перехожий зупинився. Невже через мене?', 'Цвітіння потребує запасу; розвинена крона робить його пишнішим.', 'conkers', 5, {ask: 'reserve', reserve: -5, bloom: true}),
event('spring-cold', 'Весна передумала', 'Учора було тепло. Сьогодні бруньки слухають холодне повітря.', 'Резерв допоможе витримати повернення холоду.', 'salt', 9, {ask: 'reserve', crown: -3}),
event('spring-soil', 'Земля видихає', 'Біля стовбура розпушили ґрунт. Мені наче розстебнули тісний комір.', 'Коріння отримало більше простору для росту.', 'watering', 2, {ask: 'root', soil: 8, root: 3}),
event('spring-dry', 'Дощ обійшов Хрещатик', 'Хмара пройшла над дахами. До моєї лунки не долетіло нічого.', 'Водний стрес. Соки, вкладені в коріння саме цього сезону, працюють одразу.', 'drought', 12, {ask: 'root', water: true, crown: -2}),
event('spring-paving', 'Ще один ряд плитки', 'Місто стає рівнішим. Місця для моєї землі стає менше.', 'Простір для коріння звужується. Потрібен резерв.', 'pruning', 10, {ask: 'reserve', soil: -7, root: -4}),
event('spring-care', 'Лійка з сусіднього двору', 'Хтось приніс воду. У нього свої справи, але він зайшов і до мене.', 'Можна спрямувати більше соків на ріст.', 'watering', 0, {health: 7, reserve: 4}),
event('spring-wind', 'Вітер між будинками', 'Вулиця грає на моїх гілках. Хотілося б тихішої мелодії.', 'Збережіть запас: частину молодих пагонів можна втратити.', 'pruning', 9, {ask: 'reserve', crown: -5})
],
[
event('summer-heat', 'Камінь тримає спеку', 'Сонце зайшло, а тротуар і досі гарячий. У мене немає куди відступити.', 'Водний стрес. Соки в коріння цього сезону працюють одразу; велика крона потребує води.', 'drought', 18, {ask: 'root', water: true, crown: -3}),
event('summer-rain', 'Злива для всіх', 'Люди ховаються під моїм листям. Я стою під їхнім дощем.', 'Сприятливий сезон, щоб наростити крону.', 'watering', 2, {ask: 'crown', health: 5, root: 2}),
event('summer-miner', 'Липнева осінь', 'На листках проступають бурі віконця. Це ще не осінь. Це хтось оселився всередині.', 'Міль зменшить наступний бюджет соків: крона потребує підтримки. Буре листя не означає смерть.', 'miner', 5, {ask: 'crown', crown: -5, miner: 2}, 2003),
event('summer-asphalt', 'Асфальт під самим стовбуром', 'Дощ поруч. Вода поруч. Між нами — чорна тверда шкіра міста.', 'Водний стрес. Соки в коріння цього сезону працюють одразу — але й резерв знадобиться.', 'drought', 16, {ask: 'root', water: true, soil: -6}),
event('summer-shade', 'Місце зустрічі', 'Під моєю кроною домовляються почекати ще п’ять хвилин. І чекають довше.', 'Спокійна нагода збалансувати розвиток.', 'conkers', 3, {health: 3}),
event('summer-water', 'Вода приїхала', 'Спочатку я почув машину. Потім земля стала прохолодною.', 'Полив підтримує коріння та життєздатність.', 'watering', 1, {ask: 'root', root: 4, health: 6}),
event('summer-dust', 'Пил на долонях листя', 'Місто поспішає повз мене й залишає сіру пудру на кожному листку.', 'Кроні знадобиться підтримка, а запасу — поповнення.', 'miner', 11, {ask: 'crown', crown: -4}),
event('summer-trench', 'Траншея під боком', 'Під тротуаром щось лагодять. Моє коріння теж опинилося на їхньому шляху.', 'Втрата частини коріння робить наступні посухи небезпечнішими.', 'pruning', 14, {ask: 'root', root: -9, soil: -3})
],
[
event('autumn-conkers', 'Кишені, повні каштанів', 'Мої плоди забирають маленькі руки. Мабуть, з них знову зроблять їжачка.', 'Після плодоношення варто поповнити резерв.', 'conkers', 5, {ask: 'reserve', reserve: -3}),
event('autumn-rain', 'Довгий тихий дощ', 'Можна вже не поспішати. Земля набирає води, місто — парасольок.', 'Коріння відновлюється; підготуйте запас на зиму.', 'watering', 1, {ask: 'reserve', root: 3, health: 4}),
event('autumn-clean', 'Листя забрали', 'Мій рік склали у мішки. Якщо в листі зимував шкідник, це допоможе наступній весні.', 'Прибирання ураженого листя зменшує тиск молі.', 'miner', 3, {miner: -2}),
event('autumn-compaction', 'Стежка через мою землю', 'Один крок, ще один. Земля під ногами стає твердою, мов тротуар.', 'Ущільнення обмежує розвиток коріння.', 'drought', 11, {ask: 'root', soil: -5, root: -3}),
event('autumn-mulch', 'М’яка ковдра', 'Навколо мене залишили повітря й чисту мульчу. Не впритул до кори — якраз добре.', 'Ґрунт отримує захист, коріння — простір.', 'watering', 0, {ask: 'root', soil: 6, health: 5}),
event('autumn-gale', 'Гілка не витримала', 'Вітер пішов далі. Одна моя гілка — разом із ним.', 'Резерв пом’якшить втрату частини крони.', 'pruning', 13, {ask: 'reserve', crown: -8, scar: true}),
event('autumn-warm', 'Тепло затрималося', 'Листя ще тримається. Але я вже слухаю, як наближається зима.', 'Витратьте сезон на підготовку, а не лише на крону.', 'conkers', 6, {ask: 'reserve', reserve: -2}),
event('autumn-open', 'Лунку звільнили', 'Кілька плиток зняли. Для міста — дрібниця. Для мене — цілий новий простір.', 'Межа розвитку коріння зростає.', 'watering', 2, {ask: 'root', soil: 10, root: 3})
],
[
event('winter-salt', 'Біле на тротуарі', 'Це не весь сніг. Частина білого розчиниться й дістанеться моєї землі.', 'Сольовий стрес. Соки в коріння цього сезону працюють одразу, резерв добере решту.', 'salt', 17, {ask: 'root', water: true, root: -5}),
event('winter-pruning', 'Мене зробили коротшим', 'Учора я торкався повітря над дорогою. Сьогодні там порожньо.', 'Сильна обрізка забирає крону. Залиште сили на відновлення.', 'pruning', 15, {ask: 'crown', crown: -12, scar: true}),
event('winter-snow', 'Під снігом тихіше', 'Земля вкрита. Ніхто не просить мене рости просто зараз.', 'Спокійний сезон для поповнення резерву.', 'salt', 2, {ask: 'reserve', health: 4}),
event('winter-frost', 'Мороз у кожній тріщині', 'Стою нерухомо. Здається, навіть місто сьогодні розмовляє тихіше.', 'Важлива наявність запасу.', 'salt', 15, {ask: 'reserve', reserve: -3}),
event('winter-thaw', 'Відлига', 'З дахів капає. До весни ще далеко, але сьогодні трохи легше.', 'Відновіть те, чого бракує дереву.', 'watering', 4, {health: 3}),
event('winter-ice', 'Важке прозоре листя', 'На голих гілках виросла крига. Красиво, якби не так важко.', 'Крона може втратити частину гілок.', 'pruning', 12, {ask: 'crown', crown: -6}),
event('winter-protect', 'Мені залишили землю', 'Сніг із дороги цього разу склали далі від стовбура. Я помітив.', 'Зменшення навантаження підтримує коріння.', 'salt', 1, {root: 3, health: 5}),
event('winter-dry', 'Зима без ковдри', 'Мороз є, а снігу немає. Коріння слухає порожню землю.', 'Водний стрес. Соки в коріння цього сезону працюють одразу; запас пом’якшить решту.', 'drought', 13, {ask: 'root', water: true, root: -3})
]
];
const chapters = ['Пустити коріння', 'Дотягнутися до світла', 'Стати чиїмось місцем', 'Пам’ятати весни', 'Місто змінює голос', 'Тримати небо', 'Залишатися поруч', 'Місто, яке ще буде', 'Повільна сила', 'Сто років світла'];
const memories = [
{year: 1966, title: 'Місто росте разом зі мною', text: 'Я вже вищий за людей, які мене посадили. Повз проходять нові пальта, нові розмови, нові поспіхи.'},
{year: 1976, title: 'Фото на пам’ять', text: 'Пара зупинилася під моїми квітами. Їхній день залишиться на фотографії. Мій — у новому кільці.'},
{year: 1986, title: 'Тиха весна', text: 'Після Чорнобильської катастрофи місто проживало тривогу й невідомість. Це пам’ять про людей, а не випробування, за яке гра забирає соки.'},
{year: 1991, title: 'Інший голос вулиці', text: 'Україна стала незалежною. Я стою на своєму місці й чую, як по-новому звучить знайомий Хрещатик.'},
{year: 2004, title: 'Багато людей, одна вулиця', text: 'Місто збирається на Майдані. Я пам’ятаю голоси й те, як незнайомі люди ставали поруч.'},
{year: 2014, title: 'Пам’ять, з якою мовчать', text: 'Є роки, про які не говорять мовою очок. Я залишаю в цій історії місце для людської гідності й пам’яті.'},
{year: 2022, title: 'Залишатися поруч', text: 'Почалося повномасштабне вторгнення Росії. Київ тримається завдяки людям. Тут немає ігрового штрафу за їхню реальну біду.'},
{year: 2027, title: 'Відтепер — уявне майбутнє', text: 'Далі ми не знаємо, яким буде Хрещатик. Можемо уявити місто, де для коріння залишають місце.'},
{year: 2046, title: 'Ті самі маленькі руки', text: 'У нашому уявному майбутньому каштани знову збирає дитина. Може, колись тут зупинялися її дідусь і бабуся.'},
{year: 2056, title: 'Сто років світла', text: 'Я не побачив усього міста. Зате ціле століття місто проходило повз мене.'}
];
const tutorials = [
'У мене є соки. Поділіть їх між корінням, кроною й запасом. Пересуньте будь-який повзунок — решта підлаштується.',
'Щосезону місто просить щось одне. Виконана вимога — менше стресу; знехтувана — більше.',
'Резерв зберігається між ходами й витрачається автоматично, коли сезон важкий.',
'Останній навчальний сезон. Далі місто не підстраховуватиме: виконуйте прохання сезону.'
];
const data = {seasons, events, chapters, memories, tutorials};
if (typeof module !== 'undefined' && module.exports) module.exports = data;
else root.KashtanData = data;
})(typeof window !== 'undefined' ? window : globalThis);
(function (root) {
'use strict';
const D = typeof module !== 'undefined' && module.exports ? require('./data.js') : root.KashtanData;
const C = Object.freeze({version: 2, turns: 40, maxReserve: 60, initialHealth: 85, initialRoot: 22, initialCrown: 24, initialReserve: 15, initialSoil: 82,
rootGrowth: 1.35, crownGrowth: 1.45, reserveGrowth: 1.55, rootDecay: 3.5, crownDecay: 3.8,
pressure: 8.4, ageing: 0.05, reserveProtection: 0.85, healing: 0.065, waterResponse: 1.2,
demandShare: 0.45, demandPenalty: 3});
const ASK = {root: 0, crown: 1, reserve: 2};
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
const round = n => Math.round(n * 10) / 10;
function random(seed, turn) {
let n = (seed ^ Math.imul(turn + 1, 0x9e3779b9)) >>> 0;
n ^= n >>> 16; n = Math.imul(n, 0x21f0aaad); n ^= n >>> 15; n = Math.imul(n, 0x735a2d97); n ^= n >>> 15;
return (n >>> 0) / 4294967296;
}
function ageAt(turn) { return turn === 0 ? 0 : Math.floor((turn - 1) / 4) * 10 + [2, 5, 7, 10][(turn - 1) % 4]; }
function create(seed) { return {version: C.version, seed: seed >>> 0, turn: 0, age: 0, health: C.initialHealth, root: C.initialRoot, crown: C.initialCrown, reserve: C.initialReserve, soil: C.initialSoil, miner: 0, scars: 0, history: [], status: 'playing', last: null}; }
function season(s) { return s.turn % 4; }
function year(s) { return 1956 + s.age; }
function budget(s) { return clamp(12 + Math.floor(s.crown / 12) - s.miner, 10, 20); }
function currentEvent(s) {
const pool = D.events[season(s)].filter(e => e.minYear <= year(s));
// First spring is deliberately legible; subsequent draws are seed-stable.
return s.turn === 0 ? pool[0] : pool[Math.floor(random(s.seed, s.turn) * pool.length)];
}
// What this season asks for. The hint says it in words; this is the same thing in units.
function demand(s) {
const f = currentEvent(s).effect;
return f.ask ? {key: f.ask, index: ASK[f.ask], need: Math.ceil(budget(s) * C.demandShare)} : null;
}
function allocation(total, weights = [1, 1, 1]) {
const sum = weights.reduce((a, b) => a + Math.max(0, b), 0);
if (!sum) return allocation(total);
const raw = weights.map(n => total * Math.max(0, n) / sum);
const out = raw.map(Math.floor);
const order = [0, 1, 2].sort((a,b) => (raw[b] - out[b]) - (raw[a] - out[a]) || a-b);
for (let n = total - out.reduce((a,b) => a+b,0), i=0; i<n; i++) out[order[i % 3]]++;
return out;
}
function redistribute(values, index, value, total) {
const fixed = clamp(Math.round(value), 0, total);
const others = [0,1,2].filter(i => i !== index);
const weights = others.map(i => values[i]);
const sum = weights[0] + weights[1];
const first = Math.round((total-fixed) * (sum ? weights[0]/sum : 0.5));
const out = [0,0,0]; out[index] = fixed; out[others[0]]=first; out[others[1]]=total-fixed-first;
return out;
}
function step(s, a) {
if (s.status !== 'playing') return s;
if (!Array.isArray(a) || a.length !== 3 || a.some(n => !Number.isInteger(n) || n < 0) || a.reduce((x,y)=>x+y,0) !== budget(s)) throw new Error('Invalid allocation');
const e = currentEvent(s), f = e.effect, next = {...s, history: s.history.slice()};
next.soil = clamp(s.soil + (f.soil || 0), 35, 100);
next.root = clamp(s.root + a[0]*C.rootGrowth - C.rootDecay + (f.root || 0), 0, next.soil);
next.crown = clamp(s.crown + a[1]*C.crownGrowth - C.crownDecay - s.crown*0.025 + (f.crown || 0), 0, 100);
next.reserve = clamp(s.reserve + a[2]*C.reserveGrowth + (f.reserve || 0), 0, C.maxReserve);
const rootProtection = next.root*0.055 + (f.water ? a[0]*C.waterResponse + next.root*0.085 : 0);
const imbalance = Math.max(0, 22-next.root)*0.6 + Math.max(0, 20-next.crown)*0.7;
const waterCost = f.water ? next.crown*0.065 : 0;
// Ignoring what the season asked for is the main thing a player can do wrong.
const need = f.ask ? Math.ceil(budget(s)*C.demandShare) : 0;
const neglect = f.ask ? Math.max(0, need - a[ASK[f.ask]])*C.demandPenalty : 0;
const pressure = Math.max(0, C.pressure + s.age*C.ageing + e.stress + imbalance + waterCost + neglect - rootProtection);
const spent = Math.min(next.reserve, pressure);
next.reserve -= spent;
const damage = Math.max(0, pressure - spent*C.reserveProtection);
const recovery = Math.min(next.root,next.crown)*C.healing + (f.health || 0);
next.health = clamp(s.health - damage + recovery, s.turn < 4 ? 15 : 0, 100);
next.miner = clamp(s.miner + (f.miner || 0) - (season(s) === 3 ? 1 : 0), 0, 3);
next.scars = Math.min(6, s.scars + (f.scar ? 1 : 0));
['root','crown','reserve','health'].forEach(k => next[k]=round(next[k]));
next.turn++; next.age = ageAt(next.turn);
next.status = next.health <= 0 ? 'lost' : next.turn === C.turns ? 'won' : 'playing';
const memories = D.memories.filter(m => m.year > year(s) && m.year <= year(next));
const changes = Object.fromEntries(['health','root','crown','reserve'].map(k => [k,round(next[k]-s[k])]));
next.last = {eventId:e.id, title:e.title, text:e.text, art:e.art, season:season(s), year:year(s), changes, spent:round(spent), damage:round(damage), recovery:round(recovery), neglect:round(neglect), ask:f.ask || null, need, memories, allocation:a.slice()};
next.history.push({title:e.title, year:year(s), art:e.art});
return next;
}
function restore(raw) {
try {
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!s || s.version !== C.version || !Number.isInteger(s.seed) || s.seed < 0 || s.seed > 4294967295 || !Number.isInteger(s.turn) || s.turn<0 || s.turn>C.turns || s.age !== ageAt(s.turn)) return null;
for (const [k,max] of [['health',100],['root',100],['crown',100],['reserve',C.maxReserve],['soil',100],['miner',3],['scars',6]]) if (!Number.isFinite(s[k]) || s[k]<0 || s[k]>max) return null;
if (!Number.isInteger(s.miner) || !Number.isInteger(s.scars) || s.soil<35 || s.root>s.soil || !Array.isArray(s.history) || s.history.length!==s.turn) return null;
const expected = s.health<=0 ? 'lost' : s.turn===C.turns ? 'won' : 'playing';
if (s.status!==expected) return null;
// Reconstruct all derived text from trusted event data, not storage HTML.
let rebuilt = create(s.seed);
if (!Array.isArray(s.moves) || s.moves.length !== s.turn) return s.turn===0 ? rebuilt : null;
for (const a of s.moves) { if (rebuilt.status!=='playing') return null; rebuilt=step(rebuilt,a); }
for (const k of ['turn','age','health','root','crown','reserve','soil','miner','scars','status']) if (s[k] !== rebuilt[k]) return null;
rebuilt.moves=s.moves.map(a=>a.slice()); return rebuilt;
} catch { return null; }
}
function play(s,a) { const n=step(s,a); if(n===s)return s; n.moves=[...(s.moves || []),a.slice()]; return n; }
function character(s) { return s.root>s.crown+15 ? 'Той, хто тримався корінням' : s.crown>s.root+15 ? 'Той, хто дарував затінок' : s.scars>=3 ? 'Той, хто відростав знову' : 'Той, хто беріг рівновагу'; }
const api = {C,create,season,year,budget,currentEvent,demand,allocation,redistribute,step,play,restore,ageAt,character,random};
if (typeof module !== 'undefined' && module.exports) module.exports=api; else root.KashtanModel=api;
})(typeof window !== 'undefined' ? window : globalThis);
(function (root) {
'use strict';
const M=root.KashtanModel;
const images=new Map();
const ids=['cover','city-1956','city-1976','city-2004','city-2026','trunk-sapling','trunk-young','trunk-adult','trunk-veteran','canopy-green','canopy-bloom','canopy-autumn','canopy-mined','roots-small','roots-medium','roots-deep','scar-pruned','scar-broken','event-salt','event-drought','event-miner','event-pruning','event-watering','event-conkers'];
const config=root.KashtanConfig || {};
// Жодного зворотного слеша в коді гри: збереження поста з адмінки проходить
// через wp_unslash і зʼїдає його, після чого весь бандл перестає парситись.
const configured=config.assetsBase || 'assets/web/';
const base=configured.endsWith('/') ? configured : configured+'/';
const url=id=>base+id+'.webp';
const loading=new Map();
function load(id) {
if(loading.has(id))return loading.get(id);
const job=new Promise((resolve,reject)=>{
const img=new Image();if(location.protocol!=='file:')img.crossOrigin='anonymous';
img.onload=()=>{images.set(id,img);resolve(img);root.dispatchEvent(new CustomEvent('chestnut:assetloaded',{detail:{id}}));};
img.onerror=()=>{loading.delete(id);reject(new Error('Не вдалося завантажити ілюстрацію: '+id));};
img.src=url(id);
});loading.set(id,job);return job;
}
// The opening scene must be on screen before the game starts; everything else may arrive
// later or never — drawScene falls back to canvas painting for whatever is absent.
const essential=['city-1956','trunk-sapling','canopy-green'];
async function ready() {
const settled=await Promise.allSettled(essential.map(load));
const blocked=essential.filter((id,i)=>settled[i].status==='rejected');
ids.filter(id=>!essential.includes(id)).forEach(id=>load(id).catch(()=>{}));
// Background requests are pending, not missing; don't report normal loading as an error.
return {blocked, missing:blocked};
}
function has(id) {return images.has(id);}
function stage(s){return s.age<10?'sapling':s.age<30?'young':s.age<65?'adult':'veteran';}
function background(s){return s.age<20?'city-1956':s.age<40?'city-1976':s.age<65?'city-2004':'city-2026';}
function paint(ctx,id,x,y,w,h,opacity=1) {
const img=images.get(id);if(!img)return;
ctx.save();ctx.globalAlpha*=opacity;ctx.drawImage(img,x,y,w,h);ctx.restore();
}
// Painted stand-ins for sprites that have no file yet. Seeded, so a tree keeps its shape.
function branchRoot(ctx,x,y,angle,length,width,depth,next) {
if(width<.55 || depth>3)return;
const ex=x+Math.cos(angle)*length,ey=y+Math.sin(angle)*length;
ctx.lineWidth=width;ctx.beginPath();ctx.moveTo(x,y);
ctx.quadraticCurveTo(x+Math.cos(angle-.35)*length*.55,y+Math.sin(angle-.12)*length*.55,ex,ey);ctx.stroke();
for(let i=0;i<(depth<2?2:1);i++){const r=next();branchRoot(ctx,ex,ey,angle+(r-.5)*1.15,length*(.5+r*.25),width*.55,depth+1,next);}
}
function paintRoots(ctx,s,cx,ground,width,depth) {
ctx.save();ctx.strokeStyle='#8b5e39';ctx.lineCap='round';ctx.globalAlpha=.55;
let n=0;const next=()=>M.random(7,n++),count=5+Math.round(s.root/16);
for(let i=0;i<count;i++)branchRoot(ctx,cx,ground,Math.PI*(.14+.72*(i+.5)/count),depth*(.42+next()*.3),Math.max(1.6,width*.022),0,next);
ctx.restore();
}
function paintScar(ctx,broken,x,y,size) {
ctx.save();ctx.translate(x+size/2,y+size/2);ctx.rotate(broken?.45:0);
ctx.fillStyle='#6d4a2d';ctx.beginPath();ctx.ellipse(0,0,size*.4,size*.5,0,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#caa87b';ctx.beginPath();ctx.ellipse(0,0,size*.24,size*.32,0,0,Math.PI*2);ctx.fill();
ctx.restore();
}
function drawScene(ctx,s,w,h,options={}) {
const season=options.season===undefined?M.season(s):options.season;
const motion=options.motion || 0;
ctx.save();ctx.clearRect(0,0,w,h);ctx.fillStyle='#f5f0e3';ctx.fillRect(0,0,w,h);
paint(ctx,background(s),0,0,w,h*.94,.7);
if(season===3){ctx.fillStyle='rgba(236,239,230,.48)';ctx.fillRect(0,0,w,h*.74);}
if(season===2){ctx.fillStyle='rgba(201,151,70,.06)';ctx.fillRect(0,0,w,h*.74);}
const ground=h*.735;
ctx.fillStyle=season===3?'#e4dfd1':'#e7dcc4';ctx.fillRect(0,ground,w,h-ground);
ctx.strokeStyle='#b3a37d';ctx.lineWidth=.8;ctx.beginPath();ctx.moveTo(0,ground);ctx.bezierCurveTo(w*.2,ground-2,w*.7,ground+2,w,ground);ctx.stroke();
// Deterministic speckles are a light soil texture, not a substitute illustration.
for(let i=0;i<230;i++){const x=M.random(42,i)*w,y=ground+M.random(43,i)*(h-ground);ctx.fillStyle=i%2?'#cbb99544':'#b7a98833';ctx.fillRect(x,y,1.2,1.2);}
const rootW=w*(.28+s.root*.0042),rootH=h*(.12+s.root*.0009);
const rootId=s.root<35?'roots-small':s.root<65?'roots-medium':'roots-deep';
if(has(rootId))paint(ctx,rootId,w*.5-rootW*.5,ground-rootH*.08,rootW,rootH,.92);
else paintRoots(ctx,s,w*.5,ground,rootW,rootH);
const growth=Math.min(1,s.age/55),treeH=h*(.39+growth*.3),treeW=treeH*2/3;
const tx=w*.5-treeW*.5,ty=ground-treeH*.94;
paint(ctx,'trunk-'+stage(s),tx,ty,treeW,treeH);
if(season!==3 && s.crown>0){
const foliage=season===0 && s.age>=10?'canopy-bloom':season===2?'canopy-autumn':'canopy-green';
const anchors=[[.27,.35],[.71,.35],[.2,.49],[.79,.49],[.37,.2],[.65,.21],[.5,.12],[.47,.38],[.42,.53],[.64,.49]];
const count=Math.min(10,3+Math.floor(s.crown/13));
const healthy=Math.max(.25,s.health/100),cw=treeW*(.43+s.crown*.004),ch=cw*2/3;
for(let i=0;i<count;i++){
const a=anchors[(i+(s.seed%3))%anchors.length];
const dx=Math.sin(motion*.7+i)*w*.002*(i%2?1:-1);
const expansion=season===0?1+Math.sin(Math.min(motion,2)*.6)*.015:1;
paint(ctx,foliage,tx+treeW*a[0]-cw/2+dx,ty+treeH*a[1]-ch/2,cw*expansion,ch*expansion,.65+.35*healthy);
if(s.miner>0 && i%3===0)paint(ctx,'canopy-mined',tx+treeW*a[0]-cw/2+dx,ty+treeH*a[1]-ch/2,cw,ch,.35+s.miner*.18);
}
if(motion>0 && (season===0 || season===2))for(let i=0;i<9;i++){
const drift=(motion*.07+i*.13)%1,x=w*.35+M.random(s.seed,i)*w*.3+Math.sin(motion+i)*6,y=ty+treeH*.3+drift*treeH*.5;
ctx.save();ctx.translate(x,y);ctx.rotate(drift*5+i);ctx.fillStyle=season===0?'#fff9dfaa':'#ab843c99';ctx.beginPath();ctx.ellipse(0,0,season===0?2:4,season===0?3:2,0,0,Math.PI*2);ctx.fill();ctx.restore();
}
}
for(let i=0;i<s.scars;i++){
const sh=treeH*.065,sw=sh,id=i%2?'scar-broken':'scar-pruned';
const x=w*.5-sw*.5+(i%2?1:-1)*treeW*.06,y=ty+treeH*(.58+i*.043);
if(has(id))paint(ctx,id,x,y,sw,sh);else paintScar(ctx,i%2===1,x,y,sw);
}
if(season===3){ctx.fillStyle='#fffdf2bb';for(let i=0;i<28;i++){const x=M.random(31,i)*w,y=(M.random(35,i)*ground+motion*7)%ground;ctx.beginPath();ctx.arc(x,y,w*.0015,0,Math.PI*2);ctx.fill();}}
ctx.restore();
}
function mount(container){const c=document.createElement('canvas');c.setAttribute('aria-hidden','true');container.replaceChildren(c);return c;}
function render(canvas,s,options={}){
const box=canvas.parentElement;
const width=box.clientWidth || 600;
const dpr=Math.min(root.devicePixelRatio || 1,2);
// The frame is shorter on a phone while the player decides: follow the real box,
// otherwise the scene is drawn 1.13 and squashed by CSS into whatever is there.
const height=box.clientHeight || width/1.13;
const w=Math.round(width*dpr),h=Math.round(height*dpr);
if(canvas.width!==w || canvas.height!==h){canvas.width=w;canvas.height=h;}
drawScene(canvas.getContext('2d'),s,canvas.width,canvas.height,options);
}
function fitText(ctx,text,maxWidth,size,min=18){do{ctx.font=size+'px Georgia';if(ctx.measureText(text).width<=maxWidth)return size;size--;}while(size>min);return size;}
function portrait(s){
const canvas=document.createElement('canvas');canvas.width=1080;canvas.height=1350;
const ctx=canvas.getContext('2d');ctx.fillStyle='#f7f3e9';ctx.fillRect(0,0,1080,1350);
ctx.strokeStyle='#bac2a8';ctx.strokeRect(27,27,1026,1296);
ctx.fillStyle='#677658';ctx.font='17px Arial';ctx.textAlign='center';ctx.fillText('МІЙ КИЇВ / Я — КАШТАН',540,84);
ctx.fillStyle='#293f2b';ctx.font='72px Georgia';ctx.fillText('Мій каштан прожив',540,184);
const scene=document.createElement('canvas');scene.width=1000;scene.height=800;
drawScene(scene.getContext('2d'),s,1000,800,{season:0});ctx.drawImage(scene,40,220);
ctx.fillStyle='#293f2b';const ageLine=s.age+' '+ageWord(s.age)+' на Хрещатику';fitText(ctx,ageLine,940,65);ctx.fillText(ageLine,540,1092);
fitText(ctx,M.character(s),900,34);ctx.fillText(M.character(s),540,1148);
ctx.fillStyle='#7b806b';ctx.font='20px Arial';ctx.fillText('1956—'+M.year(s)+' · Хрещатик, Київ',540,1203);
ctx.font='16px Arial';ctx.fillText('Портрет навесні · '+(M.year(s)>2026?'Після 2026 — уявне майбутнє':'Художня історія одного дерева'),540,1250);
return canvas;
}
function ageWord(age){const m=age%100;if(m>=11 && m<=14)return 'років';return age%10===1?'рік':age%10>=2 && age%10<=4?'роки':'років';}
async function readyPortrait(s){
const layers=[background(s),'trunk-'+stage(s),s.root<35?'roots-small':s.root<65?'roots-medium':'roots-deep'];
if(s.crown>0)layers.push(s.age>=10?'canopy-bloom':'canopy-green');
if(s.miner>0)layers.push('canopy-mined');
if(s.scars>0)layers.push('scar-pruned');if(s.scars>1)layers.push('scar-broken');
await Promise.all(layers.map(load));return portrait(s);
}
async function blob(canvas){return new Promise((resolve,reject)=>{try{canvas.toBlob(b=>b?resolve(b):reject(new Error('Не вдалося зберегти портрет')),'image/png');}catch(e){reject(e);}});}
root.KashtanRenderer={ids,url,load,ready,has,drawScene,mount,render,portrait,readyPortrait,ageWord,blob,images};
})(window);
(function(){
'use strict';
const root=document.getElementById('chestnutGameRoot');if(!root)return;
const M=window.KashtanModel,D=window.KashtanData,R=window.KashtanRenderer;
const $=id=>root.querySelector('#'+id),KEY='mykyiv.ya-kashtan.v1';
const media=window.matchMedia('(prefers-reduced-motion: reduce)');
const inputs=['ckRootInput','ckCrownInput','ckReserveInput'].map($),outs=['ckRootOut','ckCrownOut','ckReserveOut'].map($);
const controls=['root','crown','reserve'].map(n=>root.querySelector('.ck-control-'+n));
// The hint says what the season wants in prose; ASK_NAME says it in the same words as the chip.
const ASK_NAME={root:'коріння',crown:'крону',reserve:'запас'},ASK_NOM={root:'коріння',crown:'крона',reserve:'запас'};
let state=null,values=[5,5,4],phase='welcome',frame=0,noticeTimer,storageOK=true,artReady=false,loadPromise=null,portraitCanvas=null;
const scene=R.mount($('ckScene'));let sceneSeason=0;
function metric(name,extra={}) {const detail={game:'ya-kashtan',age:state?state.age:0,...extra};root.dispatchEvent(new CustomEvent('chestnut:'+name,{bubbles:true,detail}));if(Array.isArray(window.dataLayer))window.dataLayer.push({event:'chestnut_'+name,...detail});}
function notice(text){$('ckNotice').textContent=text;$('ckNotice').hidden=false;clearTimeout(noticeTimer);noticeTimer=setTimeout(()=>$('ckNotice').hidden=true,6500);}
function readSave(){try{const raw=localStorage.getItem(KEY);if(!raw)return null;const saved=M.restore(raw);if(!saved)notice('Збереження не вдалося прочитати. Можна почати нову історію.');return saved;}catch{storageOK=false;return null;}}
function save(){try{localStorage.setItem(KEY,JSON.stringify(state));storageOK=true;}catch{storageOK=false;}$('ckSaveNote').textContent=storageOK?'Історія зберігається на цьому пристрої':'Сховище недоступне. Гра триватиме, поки відкрита сторінка.';}
async function ensureArt(){
if(artReady)return true;
if(!loadPromise)loadPromise=R.ready().then(report=>{
loadPromise=null;
// Only the opening scene blocks the start; missing decoration is drawn on the canvas.
if(report.blocked.length){notice('Ілюстрації не завантажилися. Перевірте з’єднання й натисніть ще раз.');return false;}
artReady=true;
if(report.missing.length && window.console)console.warn('ya-kashtan: без файлів — '+report.missing.join(', '));
return true;
}).catch(()=>{loadPromise=null;notice('Ілюстрації не завантажилися. Перевірте з’єднання й натисніть ще раз.');return false;});
return loadPromise;
}
function show(screen){root.querySelectorAll('[data-screen]').forEach(el=>el.hidden=el.dataset.screen!==screen);root.querySelectorAll('[data-welcome-extra]').forEach(el=>el.hidden=screen!=='welcome');cancelAnimationFrame(frame);phase=screen;}
const gameSection=root.querySelector('[data-screen="game"]');
const phone=()=>matchMedia('(max-width:760px)').matches;
function focus(id){$(id).focus({preventScroll:true});}
// Гра живе всередині сторінки сайту, у якої буває липка шапка. scrollIntoView
// про неї не знає й ховає під нею заголовок ходу, тож міряємо її щоразу самі.
function topInset(){
// Класи шапки вгадувати не можна: на my-kiev другий поверх — це div.quick.
// Тому перебираємо все, що реально прилипло до верху, крім самої гри.
let inset=0;
for(const el of document.body.querySelectorAll('*')){
if(root===el || root.contains(el))continue;
const r=el.getBoundingClientRect();
if(!r.height || r.height>=200 || r.top>=120 || r.bottom<=inset)continue;
const cs=getComputedStyle(el);
if(cs.position==='fixed' || cs.position==='sticky')inset=r.bottom;
}
return Math.min(inset,220);
}
function scrollTo(el){
const top=el.getBoundingClientRect().top+window.scrollY-topInset()-8;
window.scrollTo({top:Math.max(0,top),behavior:media.matches?'instant':'smooth'});
}
function scrollTop(){scrollTo(root);}
// On a phone every season starts at the same anchor: the masthead scrolls away and the
// frame, the numbers and the top of the card are on screen together. Without it the
// player is thrown to the page top after each decision and hunts for the sliders again.
function scrollGame(){scrollTo(phone()?root.querySelector('.ck-game-heading'):root);}
function newSeed(){try{const n=new Uint32Array(1);crypto.getRandomValues(n);return n[0];}catch{return Date.now()>>>0;}}
function updateSliders(){const b=M.budget(state);inputs.forEach((input,i)=>{input.max=b;input.value=values[i];input.style.setProperty('--fill',values[i]/b*100+'%');outs[i].textContent=values[i];input.setAttribute('aria-valuetext',values[i]+' із '+b+' одиниць');});
const d=M.demand(state),ask=$('ckAsk');
if(d){const given=values[d.index],met=given>=d.need;
ask.dataset.met=met?'true':'false';
ask.textContent='Цей сезон просить '+ASK_NAME[d.key]+': '+d.need+' — '+(met?'виконано':'бракує '+(d.need-given));
} else {ask.dataset.met='free';ask.textContent='Вільний сезон: вкладайте, куди вважаєте за потрібне.';}
$('ckAllocationSummary').textContent='Коріння: '+values[0]+', крона: '+values[1]+', резерв: '+values[2]+'. Разом '+b+'.'+(d?' Сезон просить '+ASK_NAME[d.key]+' '+d.need+': '+(values[d.index]>=d.need?'виконано':'бракує '+(d.need-values[d.index]))+'.':'');
$('ckBudget').textContent=b;}
function draw(){if(!state || phase!=='game')return;R.render(scene,state,{season:sceneSeason});}
function animate(){cancelAnimationFrame(frame);draw();if(media.matches || document.hidden || phase!=='game')return;const start=performance.now();let previous=0;function tick(now){if(phase!=='game' || document.hidden)return;if(now-previous>65){R.render(scene,state,{season:sceneSeason,motion:(now-start)/1000});previous=now;}frame=requestAnimationFrame(tick);}frame=requestAnimationFrame(tick);}
function stats(){
const names={health:'Health',root:'Root',crown:'Crown',reserve:'Reserve'};
for(const [key,label]of Object.entries(names)){const num=$( 'ck'+label);num.textContent=Math.round(state[key]);if(key==='health' || key==='reserve'){const small=document.createElement('small');small.textContent=key==='health'?'/100':'/60';num.append(small);}$('ck'+label+'Bar').style.width=state[key]/(key==='reserve'?60:100)*100+'%';}
$('ckAge').textContent=state.age;$('ckAgeLabel').textContent=R.ageWord(state.age)+' життя';$('ckProgress').style.width=state.age+'%';
$('ckTreeNote').textContent=state.health<30?'Я ще стою. Але сили майже закінчилися.':state.root<15?'Моїй кроні потрібна опора під землею.':state.crown<15?'Мало листя — мало світла, яке стає соками.':state.age<10?'Маленьке дерево. Велике місто.':state.scars>2?'Мої шрами теж частина цієї історії.':'Кожен сезон залишає в мені щось своє.';
$('ckScene').setAttribute('aria-label','Каштан, '+state.age+' '+R.ageWord(state.age)+'. Коріння '+Math.round(state.root)+', крона '+Math.round(state.crown)+', шрамів '+state.scars+'. '+D.seasons[sceneSeason]+'.');
}
function renderDecision(){
show('game');$('ckDecision').hidden=false;$('ckOutcome').hidden=true;
// Deciding gets the short frame; the intro chapter keeps the slider captions.
gameSection.classList.remove('ck-view-outcome');gameSection.classList.toggle('ck-taught',state.turn>=4);
const e=M.currentEvent(state),chapter=Math.min(9,Math.floor(state.turn/4));sceneSeason=M.season(state);
$('ckChapter').textContent='РОЗДІЛ '+String(chapter+1).padStart(2,'0')+' / 10';$('ck-game-title').textContent=D.chapters[chapter];
$('ckSceneSeason').textContent=D.seasons[sceneSeason]+' · '+M.year(state);$('ckFuture').hidden=M.year(state)<=2026;
$('ckTurn').textContent='Сезон '+(state.turn+1)+' із 40';$('ckEventTitle').textContent=e.title;$('ckEventText').textContent=e.text;$('ckEventHint').textContent=e.hint;
// The vignette stays out of the layout until its file actually loads.
const art=$('ckEventArt');art.hidden=true;art.src=R.url('event-'+e.art);
$('ckTutorial').hidden=state.turn>=4;$('ckTutorial').textContent=D.tutorials[state.turn] || '';
$('ckEventLabel').textContent=state.turn<4?'ЗНАЙОМСТВО · '+(state.turn+1)+' / 4':D.seasons[sceneSeason]+' · '+M.year(state)+(M.year(state)>2026?' · Уявне майбутнє':'');
const d=M.demand(state);controls.forEach((el,i)=>el.classList.toggle('ck-control-asked',!!d && d.index===i));
values=M.allocation(M.budget(state),values);updateSliders();stats();save();animate();
}
function renderOutcome(){
$('ckDecision').hidden=true;$('ckOutcome').hidden=false;gameSection.classList.add('ck-view-outcome');
$('ckOutcomeTitle').textContent=state.status==='lost'?'Мої сили закінчилися.':state.status==='won'?'Я дожив до ста.':state.health<25?'Цей сезон дався важко.':'Я прожив ще один сезон.';
$('ckOutcomeText').textContent=state.last.title+'. '+(state.last.changes.health>=0?'Цього разу вдалося трохи відновитися.':'Місто залишило свій слід.');
$('ckChanges').replaceChildren();for(const [key,label]of [['health','Життєздатність'],['root','Коріння'],['crown','Крона'],['reserve','Резерв']]){const row=document.createElement('div');row.className='ck-change';const name=document.createElement('span'),number=document.createElement('strong');name.textContent=label;const n=state.last.changes[key];number.textContent=(n>0?'+':'')+n.toLocaleString('uk-UA');number.className=n<0?'ck-negative':n>0?'ck-positive':'';row.append(name,number);$('ckChanges').append(row);}
const l=state.last;const askLine=!l.ask?'':l.neglect>0
? ' Сезон просив '+ASK_NAME[l.ask]+' '+l.need+', ви дали '+l.allocation[['root','crown','reserve'].indexOf(l.ask)]+' — стрес +'+l.neglect.toLocaleString('uk-UA')+'.'
: ' Прохання сезону виконано: '+ASK_NOM[l.ask]+' '+l.need+'.';
$('ckExplanation').textContent='Резерв витратив '+l.spent.toLocaleString('uk-UA')+' од. на захист. Стрес: −'+l.damage.toLocaleString('uk-UA')+' життєздатності; відновлення: +'+l.recovery.toLocaleString('uk-UA')+'.'+askLine+(state.turn<=4?' Навчальна підтримка не дає життєздатності впасти нижче 15.':'');
$('ckMemories').replaceChildren();for(const memory of l.memories){const el=document.createElement('article');el.className='ck-memory';const year=document.createElement('span'),title=document.createElement('h3'),text=document.createElement('p');year.textContent=memory.year+(memory.year>2026?' · УЯВНЕ МАЙБУТНЄ':'');title.textContent=memory.title;text.textContent=memory.text;el.append(year,title,text);$('ckMemories').append(el);}
$('ckTimeJump').textContent=state.status==='playing'?'Між цим і наступним рішенням минає '+(state.age-M.ageAt(state.turn-1))+' '+R.ageWord(state.age-M.ageAt(state.turn-1))+'. Далі — '+M.year(state)+'-й.':'Моя історія залишиться в портреті.';
$('ckNext').firstChild.textContent=state.status==='playing'?'Рости далі ':'Побачити мій портрет ';
// Retain the resolved season while showing its consequences.
sceneSeason=state.last.season;$('ckSceneSeason').textContent=D.seasons[sceneSeason]+' · '+state.last.year;$('ckFuture').hidden=M.year(state)<=2026;
stats();animate();focus('ckOutcomeTitle');if(phone())scrollGame();
}
function result(){
show('result');$('ckResultEyebrow').textContent=state.status==='won'?'СТОЛІТТЯ НА ХРЕЩАТИКУ':'МОЯ ІСТОРІЯ НА ХРЕЩАТИКУ';
$('ck-result-title').textContent=state.status==='won'?'Сто років. Я досі тут.':'Я залишив слід.';
$('ckResultSubtitle').textContent='Мій каштан прожив '+state.age+' '+R.ageWord(state.age)+' на Хрещатику.';
$('ckCharacter').textContent=M.character(state);
$('ckReason').textContent=state.status==='won'?'Ти втримав рівновагу між ростом і запасом. Місто змінювалося, а твоє дерево залишалося поруч.':state.age<40?'Молоде дерево на Хрещатику живе між спекою, сіллю й асфальтом — і не кожен саджанець доростає до свого першого цвітіння. Місто роздасть інші сезони: спробуй ще раз.':state.root<15?'Коріння ослабло, і дерево вже не могло витримувати міський стрес. Наступного разу підтримай його раніше.':state.crown<15?'Кроні забракло сил на відновлення. Без достатнього листя дерево втрачало джерело нових соків.':'Накопичений стрес вичерпав життєздатність. Останній важкий сезон — «'+state.last.title+'». Більший запас і підтримка росту могли змінити цю історію.';
if(state.status==='lost' && (state.age<40 || state.root<15 || state.crown<15))$('ckReason').prepend('Життєздатність вичерпалася після сезону «'+state.last.title+'». ');
const selected=[];for(const entry of state.history.slice().reverse()){if(!selected.some(m=>m.art===entry.art)){selected.push(entry);if(selected.length===3)break;}}
$('ckResultMemories').replaceChildren();for(const item of selected.reverse()){const el=document.createElement('p');el.className='ck-result-memory';const small=document.createElement('small');small.textContent=item.year+(item.year>2026?' · уявне майбутнє':'');el.append(small,document.createTextNode(item.title));$('ckResultMemories').append(el);}
portraitCanvas=R.portrait(state);portraitCanvas.setAttribute('aria-hidden','true');$('ckPortrait').replaceChildren(portraitCanvas);$('ckShareFallback').hidden=true;
focus('ck-result-title');scrollTop();
}
async function start(fresh){const button=fresh?$('ckStart'):$('ckContinue');button.disabled=true;const ok=await ensureArt();button.disabled=false;if(!ok)return;
if(fresh){const previous=state;state=M.create(newSeed());state.moves=[];values=M.allocation(M.budget(state));metric(previous?'restart':'start');}
if(!state)return;if(state.status==='playing'){renderDecision();focus('ck-game-title');scrollGame();}else result();
}
function downloadBlob(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);}
async function download(){try{const portrait=await R.readyPortrait(state);downloadBlob(await R.blob(portrait),'ya-kashtan-'+state.age+'-rokiv.png');metric('download');}catch{notice('Портрет не вдалося зберегти. Перевірте з’єднання та спробуйте ще раз.');}}
function shareText(){const url=(window.KashtanConfig || {}).publicUrl;return 'Мій каштан прожив '+state.age+' '+R.ageWord(state.age)+' на Хрещатику. Я — каштан. А скільки проживе твій?'+(url?' '+url:'');}
async function copyFallback(text){try{if(!navigator.clipboard)throw new Error('No clipboard');await navigator.clipboard.writeText(text);notice('Текст скопійовано. Портрет можна зберегти окремо.');metric('share',{method:'clipboard'});}catch{$('ckShareFallback').value=text;$('ckShareFallback').hidden=false;$('ckShareFallback').focus();$('ckShareFallback').select();notice('Скопіюйте виділений текст.');metric('share',{method:'manual'});}}
async function share(){const text=shareText();try{const portrait=await R.readyPortrait(state),file=new File([await R.blob(portrait)],'ya-kashtan.png',{type:'image/png'});if(navigator.canShare && navigator.canShare({files:[file]})){await navigator.share({files:[file],title:'Я — каштан',text});metric('share',{method:'file'});return;}if(navigator.share){await navigator.share({title:'Я — каштан',text});metric('share',{method:'text'});return;}}catch(e){if(e.name==='AbortError')return;}await copyFallback(text);}
$('ckEventArt').addEventListener('load',()=>{$('ckEventArt').hidden=false;});
inputs.forEach((input,index)=>input.addEventListener('input',()=>{values=M.redistribute(values,index,Number(input.value),M.budget(state));updateSliders();}));
$('ckStart').addEventListener('click',()=>start(true));$('ckContinue').addEventListener('click',()=>start(false));
$('ckAdvance').addEventListener('click',()=>{if(phase!=='game' || $('ckDecision').hidden)return;state=M.play(state,values);save();if(state.status!=='playing')metric('finish',{outcome:state.status});renderOutcome();});
$('ckNext').addEventListener('click',()=>{if(state.status==='playing'){renderDecision();focus('ck-game-title');if(phone())scrollGame();}else result();});
$('ckPause').addEventListener('click',()=>{save();show('welcome');$('ckContinue').hidden=false;$('ckContinue').textContent='Продовжити · '+state.age+' '+R.ageWord(state.age)+' →';scrollTop();});
$('ckRestart').addEventListener('click',async()=>{state=M.create(newSeed());state.moves=[];values=M.allocation(M.budget(state));metric('restart');renderDecision();focus('ck-game-title');scrollGame();});
$('ckDownload').addEventListener('click',download);$('ckShare').addEventListener('click',share);
window.addEventListener('resize',draw);document.addEventListener('visibilitychange',()=>{if(document.hidden)cancelAnimationFrame(frame);else animate();});media.addEventListener('change',animate);
// Reduced-motion scenes also repaint when a late image arrives.
window.addEventListener('chestnut:assetloaded',()=>{draw();if(phase==='result' && state){portraitCanvas=R.portrait(state);portraitCanvas.setAttribute('aria-hidden','true');$('ckPortrait').replaceChildren(portraitCanvas);}});
root.querySelector('.ck-hero-art').style.backgroundImage='url("'+R.url('cover')+'")';
state=readSave();if(state){$('ckContinue').hidden=false;$('ckContinue').textContent=state.status==='playing'?'Продовжити · '+state.age+' '+R.ageWord(state.age)+' →':'Відкрити мій портрет →';}
if(!storageOK)notice('Автозбереження недоступне. Не закривайте сторінку під час проходження.');
// Load sprites after the cover; nothing is fetched from third-party image services.
const warm=()=>ensureArt();if('requestIdleCallback' in window)requestIdleCallback(warm,{timeout:1800});else setTimeout(warm,1000);
})();