import {installLanguages,localize} from '../i18n.mjs'; installLanguages(); import {startState,advance,replay,won,available} from './core.mjs'; import {restore,remember,fresh} from './progress.mjs'; import {scene,position,miniature,prop,sprite,PALETTES} from './scene.mjs'; import {MODE_NAMES} from './maps.mjs'; import {lessonPages,situation,firstTarget} from './coach.mjs'; const $=id=>document.getElementById(id),qa=new URLSearchParams(location.search).has('qa'); const storageKey='3xquest-yard-routes-v1'+(qa?'-qa':''); const lights=['day','dusk','night'],lightNames={day:'День',dusk:'Сумерки',night:'Ночь'},lightIcons={day:'☀',dusk:'◐',night:'☾'}; const color={A:'#ce7262',B:'#d2a243',C:'#668fae'}; const moveText=n=>`${n} ${n%100>=11&&n%100<=14?'ходов':n%10===1?'ход':n%10>=2&&n%10<=4?'хода':'ходов'}`; let catalog,missions,progress=fresh(),mission,state,light='day',busy=false,animation=null,warning=''; const coachKey=storageKey+'-luiza-v1'; let coachSeen=new Set(),coachPages=[],coachPage=0,coachTarget=null; try{const seen=JSON.parse(localStorage.getItem(coachKey));if(Array.isArray(seen))coachSeen=new Set(seen.filter(v=>typeof v==='string'));}catch{} function rememberCoach(){coachSeen.add(mission.mode);try{localStorage.setItem(coachKey,JSON.stringify([...coachSeen]));}catch{}} if(qa)$('classic').href='../?qa=1'; function save(){try{localStorage.setItem(storageKey,JSON.stringify(progress));}catch{warning='Сохранение недоступно в браузере. Оставь вкладку открытой.';}} function store(){if(mission&&state)remember(progress,mission,state,light);save();} function textFor(m,long=false){ let text={collect:'Забери все 3 вагона и привези состав к зелёному флагу.',ordered:`Забирай вагоны строго ${m.order.join(' → ')}. Затем приезжай к зелёному флагу.`,delivery:'В составе 2 места. Забирай вагоны и доставляй каждый на площадку ↓ с его буквой. Затем — к зелёному флагу.',gate:'Забери ключ K: он открывает мост K. Собери все вагоны и приезжай к зелёному флагу.',bridge:'Мосты 1× исчезают после одного проезда. Собери все вагоны и приезжай к зелёному флагу.'}[m.mode]; if(m.oneWay?.length)text+=' По стрелкам можно ехать только в одну сторону.'; if(m.limit)text+=` Уложись в ${m.limit} ходов.`; if(long&&m.mode==='delivery')text+=' На площадке груз разгружается автоматически; буквы станций рядом с вагонами показывают место отправки.'; return text; } function gallery(){ $('total').textContent=`Выполнено ${Object.keys(progress.best).length} из 100 заказов`; $('continue').textContent=progress.current?'Продолжить путешествие →':'Начать путешествие →'; $('maps').innerHTML=catalog.maps.map((map,i)=>{ const m=missions.find(m=>m.mapId===map.id),done=missions.filter(m=>m.mapId===map.id&&progress.best[m.id]).length; return ``; }).join('');localize(); } function render(message=''){ $('game').dataset.light=light;$('game').dataset.mission=mission.id;$('game').dataset.position=state.position; $('scene').innerHTML=scene(mission,state); $('chapter').textContent=`ЗАКАЗ ${String(mission.number).padStart(3,'0')} / 100`; $('map-name').textContent=mission.name;$('objective').textContent=MODE_NAMES[mission.mode]; $('lighting').textContent=lightIcons[light];$('lighting').setAttribute('aria-label',`Время суток: ${lightNames[light]}. Сменить фильтр`); const ids=mission.mode==='ordered'?mission.order:mission.jobs.map(j=>j.id); $('cargo-order').innerHTML=ids.map(id=>{const done=(mission.mode==='delivery'?state.delivered:state.picked).includes(id),job=mission.jobs.find(j=>j.id===id);return `${done?'✓ ':''}${id}${mission.mode==='delivery'?' → '+job.to.toUpperCase():''}`;}).join(mission.mode==='ordered'?'':''); $('instruction').textContent={collect:'Все вагоны → зелёный флаг',ordered:'Сначала собери в указанном порядке → флаг',delivery:'Груз → площадка ↓ с его буквой → флаг',gate:'Ключ K → мост K → все вагоны → флаг',bridge:'Мост 1× — только один проезд. Все вагоны → флаг'}[mission.mode]; if(coachTarget){ const stop=$('scene').querySelector(`[data-node="${coachTarget}"]`); if(stop){stop.classList.add('coach-target');stop.setAttribute('aria-label',stop.getAttribute('aria-label')+'. Первый ход Луизы');} $('instruction').textContent=`Луиза: начни со станции ${coachTarget.toUpperCase()} — я выделила её золотым.`; } $('moves').textContent=mission.limit?`${state.path.length} / ${moveText(mission.limit)}`:moveText(state.path.length); $('capacity').textContent=`В составе: ${state.cargo.join(' · ')||'пусто'} (${state.cargo.length}/${mission.capacity})`; $('formation').innerHTML=`${sprite('L',52,100,100)}${state.cargo.map((id,i)=>sprite(id,147+i*85,100,82)).join('')}`; $('undo').disabled=!state.path.length||busy;$('restart').disabled=busy; $('mission').value=mission.id;$('previous').disabled=mission.number===1||busy;$('forward').disabled=mission.number===100||busy; for(const id of ['mission','back','lighting','help'])$(id).disabled=busy; const complete=won(mission,state);$('completion').hidden=!complete; $('score').textContent=`${moveText(state.path.length)} · кратчайший путь: ${mission.par}. ${state.path.length===mission.par?'Идеально!':'Можно попробовать короче.'}`; $('next').textContent=mission.number===100?'Вернуться к картам':'Следующее задание →'; $('live').textContent=warning||message||(!complete&&!available(mission,state).length?'Путь закончился. Отмени ход: мост или запас ходов уже использован.':''); localize(); } function coachRender(){ const page=coachPages[coachPage]; $('brief-label').textContent=`${mission.name} · ${MODE_NAMES[mission.mode]}`; $('brief-title').textContent=page.title;$('brief-body').textContent=page.text; $('brief-control').textContent=page.focus??'На поле можно пробовать разные пути. ↶ бесплатно отменяет ход.'; $('coach-page').textContent=`${coachPage+1} / ${coachPages.length}`; $('coach-previous').hidden=coachPage===0; $('coach-next').textContent=coachPagem.id===id)??missions[0];coachTarget=null; const session=progress.sessions[mission.id];state=session?replay(mission,session.path):startState(mission); light=session?.light??lights[Math.floor(Math.random()*lights.length)]; $('atlas').hidden=true;$('game').hidden=false;document.title=`${mission.name} — Pocket Shunting Yard`; store();render();if(explain&&!state.path.length&&!coachSeen.has(mission.mode))brief({automatic:true});localize(); } function back(){if(busy)return;store();$('game').hidden=true;$('atlas').hidden=false;gallery();document.title='Pocket Shunting Yard — мир маршрутов';localize();} async function travel(node){ if(busy)return; let next;try{next=advance(mission,state,node);}catch(error){render(error.message);return;} const before=position(mission.nodes.find(n=>n.id===state.position)),after=position(mission.nodes.find(n=>n.id===node)); busy=true;render();$('game').classList.add('moving'); // Persist the accepted move immediately, so leaving mid-animation cannot lose it. state=next;coachTarget=null;store(); try{ if(!matchMedia('(prefers-reduced-motion: reduce)').matches){ animation=$('train').animate([{transform:`translate(${before.x}px,${before.y}px)`},{transform:`translate(${after.x}px,${after.y}px)`}],{duration:Math.min(600,230+Math.hypot(before.x-after.x,before.y-after.y)*.5),easing:'ease-in-out',fill:'forwards'}); await animation.finished; } }catch{/* A cancelled visual never cancels the already validated move. */} finally{animation=null;busy=false;$('game').classList.remove('moving');render();} } $('scene').addEventListener('click',e=>{const node=e.target.closest('[data-node]');if(node)void travel(node.dataset.node);}); $('scene').addEventListener('keydown',e=>{if(['Enter',' '].includes(e.key)){const node=e.target.closest('[data-node]');if(node){e.preventDefault();void travel(node.dataset.node);}}}); $('undo').onclick=()=>{if(!busy&&state.path.length){state=replay(mission,state.path.slice(0,-1));store();render();}}; function restart(){if(busy)return;coachTarget=null;state=startState(mission);store();render();} $('restart').onclick=restart;$('again').onclick=restart; $('back').onclick=back;$('help').onclick=brief; $('coach-next').onclick=()=>{if(coachPage{if(coachPage){coachPage--;coachRender();if(!coachPage)$('coach-next').focus();}}; $('coach-skip').onclick=()=>{$('brief').close();}; $('brief').addEventListener('close',rememberCoach); $('lighting').onclick=()=>{if(!busy){light=lights[(lights.indexOf(light)+1)%lights.length];store();render(`Время суток: ${lightNames[light]}`);}}; $('continue').onclick=()=>load(progress.current??missions[0].id); $('mission').onchange=e=>load(e.target.value,{explain:false}); $('previous').onclick=()=>{if(mission.number>1)load(missions[mission.number-2].id,{explain:false});}; $('forward').onclick=()=>{if(mission.number<100)load(missions[mission.number].id,{explain:false});}; $('next').onclick=()=>{if(mission.number===100)back();else load(missions[mission.number].id);}; $('maps').onclick=e=>{ const card=e.target.closest('[data-map]');if(!card)return;const map=catalog.maps.find(m=>m.id===card.dataset.map),items=missions.filter(m=>m.mapId===map.id); $('selection-title').textContent=map.name;$('selection-subtitle').textContent=map.subtitle; $('variants').innerHTML=items.map(m=>``).join(''); $('map-dialog').showModal();localize(); }; $('variants').onclick=e=>{const button=e.target.closest('[data-mission]');if(button){$('map-dialog').close();load(button.dataset.mission);}}; document.addEventListener('visibilitychange',()=>{if(document.hidden){animation?.finish();store();}}); window.addEventListener('pagehide',()=>{animation?.finish();store();}); window.addEventListener('native-back',event=>{ const dialog=document.querySelector('dialog[open]'); if(dialog){event.preventDefault();dialog.close();return;} if(!$('game').hidden){event.preventDefault();back();} }); try{ const response=await fetch('./campaign.json');if(!response.ok)throw Error('Не удалось загрузить задания.'); catalog=await response.json();missions=catalog.missions; let raw;try{raw=JSON.parse(localStorage.getItem(storageKey));}catch{}progress=restore(raw,missions); $('mission').innerHTML=missions.map(m=>``).join(''); $('skip').hidden=!['localhost','127.0.0.1','[::1]'].includes(location.hostname); gallery(); }catch(error){$('fatal').hidden=false;$('fatal').textContent=error.message;localize();}