import {installLanguages,localize} from './i18n.mjs'; installLanguages(); import {createGame,applyMove,undo,restart,isSolved,capacitiesOf,canDispatch,dispatchStage} from './core/index.mjs'; import {selectWagons,selectedCars,planTransfer,transferReason} from './interaction.mjs'; import {tracks,trackName,boardArt,missionArt,positionOf} from './arcade-scene.mjs'; import {motionForMove,motionForTransfer} from './motion.mjs'; import {modeName,missionType,goalText,goalCoach,backgroundFor,stationSummary,missionBrief} from './missions.mjs'; import {freshProgress,decodeProgress,encodeProgress,remember,resumeMission} from './campaign-progress.mjs'; const $ = id => document.getElementById(id); const key = '3xquest-yard-preview-v1' + (new URLSearchParams(location.search).has('qa') ? '-qa' : ''); const localPreview=['127.0.0.1','localhost','[::1]'].includes(location.hostname); let cheatOpen=false; let puzzles=[], index=0, game, selection=null, completed=new Set(), saveWarning='', message='', busy=false, gesture=null, hintOpen=false; let motionLabel='', activeAnimations=[]; let progress; function save() { try {remember(progress,game);localStorage.setItem(key,encodeProgress(progress));saveWarning='';} catch {saveWarning='Storage is unavailable. Keep this tab open to retain this session.';} } function restore() { try { progress=decodeProgress(localStorage.getItem(key),puzzles); index=puzzles.findIndex(p=>p.id===progress.activeId); game=resumeMission(progress,puzzles[index]);completed=progress.completed;saveWarning=progress.warning; } catch {progress=freshProgress(puzzles);completed=progress.completed;saveWarning='The saved campaign could not be loaded. Starting a fresh mission.';} } function clearChoice() {selection=null;message='';hintOpen=false;} function showBrief() { if(busy)return; const brief=missionBrief(game); $('brief-kicker').textContent=`MISSION ${String(index+1).padStart(2,'0')} · ${modeName(game).toUpperCase()}`; $('brief-title').textContent=brief.title;$('brief-where').textContent=brief.where; $('brief-rule').textContent=brief.rule;$('brief-finish').textContent=brief.finish; $('brief-order').innerHTML=missionArt(game);$('brief-order').setAttribute('aria-label',goalText(game)); $('brief-start').textContent=game.moves?'Continue mission':'Let’s play'; $('brief-dialog').showModal();localize(); } function selectOrder(next,{brief=true,replayCompleted=true}={}) { if(busy||!Number.isInteger(next)||next<0||next>=puzzles.length)return; endGesture();remember(progress,game);index=next; game=resumeMission(progress,puzzles[index],{replayCompleted});clearChoice();save();render(); $('menu-dialog').close();if(brief)showBrief(); } function skipMission(next) {if(localPreview)selectOrder(next,{brief:false,replayCompleted:false});} function renderBoard() { $('board').innerHTML=boardArt(game,selection,busy); $('board-summary').textContent=goalText(game)+'\n'+tracks.map(t=>`${trackName(t,game)}: ${game[t].join(', ')||'empty'}; ${game[t].length} of ${capacitiesOf(game)[t]} spaces occupied`).join('\n'); } function coachText() { if(busy)return motionLabel; if(isSolved(game))return 'Train ready. The holding track is clear.'; if(message)return message; if(selection) { const targets=tracks.filter(t=>planTransfer(game,selection,t).ok); if(!targets.length)return selection.track==='H'?'No track has room for this group. Select fewer wagons.':transferReason(game,selection,'H'); if(game.puzzleId==='yard-001'&&game.moves===0&&selection.track==='H'&&selection.index===0)return 'Send A + B to the delivery track above'; return selection.track==='H'?'Choose a glowing destination.':'Choose a glowing track. Holding track: 1 move; others: 2.'; } if(game.moves===0 && game.puzzleId==='yard-001')return 'Build on Track 1. Start by tapping wagon A.'; if(game.H.length===capacitiesOf(game).H)return 'Holding track full (3/3). Move its wagons to make room.'; return goalCoach(game); } function render() { const won=isSolved(game); $('order').innerHTML=puzzles.map((p,i)=>``).join(''); $('order').value=String(index);$('order').disabled=busy; $('cheat-toggle').hidden=!localPreview;$('cheat-toggle').disabled=busy; $('cheat-toggle').setAttribute('aria-expanded',String(cheatOpen)); $('cheat-panel').hidden=!localPreview||!cheatOpen; if(localPreview){ $('cheat-level').innerHTML=puzzles.map((p,i)=>``).join(''); $('cheat-level').value=String(index);$('cheat-level').disabled=busy; $('cheat-prev').disabled=busy||index===0;$('cheat-next').disabled=busy||index===puzzles.length-1; } $('order-label').textContent=`${String(index+1).padStart(2,'0')} · ${modeName(game).toUpperCase()}`; const background=document.querySelector('.scene-background'),source=backgroundFor(game); if(background.getAttribute('src')!==source)background.setAttribute('src',source); $('target').innerHTML=missionArt(game);$('target').setAttribute('aria-label',goalText(game)); $('lesson-title').textContent=puzzles[index].title; $('mission-info').textContent=stationSummary(game)+'\n'+goalText(game); $('coach').textContent=`HOLDING TRACK · ${game.H.length}/${capacitiesOf(game).H}\n${coachText()}`;$('coach').classList.toggle('warning',Boolean(message)); $('coach').hidden=(!busy&&won)||hintOpen; $('coach').classList.toggle('in-motion',busy); $('moves').textContent=String(game.moves);$('moves').setAttribute('aria-label',`${game.moves} ${game.moves===1?'move':'moves'}`); $('progress').textContent=`${completed.size}/${puzzles.length} completed${progress.best[game.puzzleId]?` · Personal best: ${progress.best[game.puzzleId]} moves`:''}`; $('undo').disabled=busy||game.history.length===0;$('restart').disabled=busy; $('pause').disabled=busy;$('hint').disabled=busy||won; $('brief-open').disabled=busy; $('dispatch').hidden=won||!canDispatch(game);$('dispatch').disabled=busy; $('dispatch').textContent=`Send train ${dispatchStage(game)+1} →`; if(canDispatch(game)&&!busy&&!hintOpen)$('coach').hidden=true; $('hint').setAttribute('aria-expanded',String(hintOpen)); $('hint-text').hidden=!hintOpen;$('hint-text').textContent=missionType(game)==='assemble'?puzzles[index].instructions:goalText(game); $('notice').textContent=saveWarning;$('notice').hidden=!saveWarning; $('success').hidden=!won||busy;$('next').hidden=index===puzzles.length-1; $('campaign-summary').textContent=`${puzzles.length} missions · 5 task types · 2 stations`; $('success-title').textContent=({delivery:'Deliveries complete!',rescue:'Wagon ready for dispatch!',clear:'Track clear for the crew!',dispatch:'Both trains sent!'})[missionType(game)]??'Ready for departure!'; $('success-text').textContent=index===puzzles.length-1&&completed.size===puzzles.length?`All ${puzzles.length} missions complete. Replay to improve your best moves.`:`Completed in ${game.moves} ${game.moves===1?'move':'moves'}.${progress.best[game.puzzleId]?` Personal best: ${progress.best[game.puzzleId]}.`:''}${index===puzzles.length-1?' Choose another mission from the menu.':''}`; $('next').textContent=index+1{ const p=positionOf(previous,id),element=$('board').querySelector(`[data-car="${id}"]`); return element.animate([{transform:`translate(${p.x}px,${p.y}px)`,opacity:1},{transform:`translate(${p.x+180}px,${p.y}px)`,opacity:.8,offset:.55},{transform:`translate(${p.x+400}px,${p.y}px)`,opacity:0}],{duration:850,easing:'ease-in'}); }); await Promise.all(activeAnimations.map(a=>a.finished.catch(()=>{}))); } }finally {activeAnimations=[];busy=false;motionLabel='';render();} } async function perform(destination) { if(busy||isSolved(game))return; const result=planTransfer(game,selection,destination); if(!result.ok){message=transferReason(game,selection,destination);hintOpen=false;render();return;} const steps=[]; try { let planned=game; for(const action of result.actions){planned=applyMove(planned,action);steps.push(planned);} }catch(error){message=error.message;hintOpen=false;render();return;} const startGame=game, finalGame=steps.at(-1); // Persist the whole accepted gesture before animation. Reloading mid-transfer // must never strand the player after only the first of its two manoeuvres. game=finalGame; if(isSolved(game))completed.add(game.puzzleId); clearChoice();save();busy=true; try { const stages=result.viaEngine?[{previous:startGame,next:finalGame,action:null}]: steps.map((next,i)=>({previous:i===0?startGame:steps[i-1],next,action:result.actions[i]})); for(const {previous,next,action} of stages){ motionLabel=result.viaEngine?`Moving to Track ${destination.slice(1)} · 2 moves`: action.type==='pull'?'Moving to holding track':`Moving to Track ${action.siding.slice(1)}`; game=next;render(); if(!document.hidden&&!matchMedia('(prefers-reduced-motion: reduce)').matches) { const motion=result.viaEngine?motionForTransfer(previous,next):motionForMove(previous,next,action); activeAnimations=motion.cars.map(car=>{ const element=$('board').querySelector(`[data-car="${car.id}"]`); return element.animate(car.frames,{duration:motion.duration,easing:'ease-in-out'}); }); await Promise.all(activeAnimations.map(animation=>animation.finished.catch(()=>{}))); activeAnimations=[]; } } } finally {game=finalGame;activeAnimations=[];busy=false;motionLabel='';render();} } function activate(track,wagonIndex=null) { if(busy||isSolved(game))return; // After a full-holding-track warning, tapping it offers its group for parking. // This changes only selection; the player still chooses the destination. if(selection&&selection.track!=='H'&&track==='H'&&game.H.length===capacitiesOf(game).H){ selection=selectWagons(game,'H',wagonIndex??0);message='Holding track full. Move the highlighted group.';hintOpen=false;render();return; } if(selection && selection.track!==track) {void perform(track);return;} if(wagonIndex!==null) { const next=selectWagons(game,track,wagonIndex); selection=selection?.track===next?.track&&selection?.index===next?.index?null:next; message='';hintOpen=false;render();return; } if(track==='H'&&game.H.length){selection=selectWagons(game,'H',0);message='';} else {message=selection&&selection.track!=='H'&&track!==selection.track?'Use the holding track between sidings.':'Tap a wagon to choose which group to move.';} render(); } function hit(element) { const node=element?.closest?.('[data-track]'); if(!node||!$('board').contains(node))return null; return {track:node.dataset.track,index:node.hasAttribute('data-wagon')?Number(node.dataset.wagon):null}; } function endGesture(restoreSelection=false) { const current=gesture;gesture=null; if(restoreSelection&¤t?.dragging){selection=current.before;message='';render();} if(current&&$('board').hasPointerCapture(current.id))$('board').releasePointerCapture(current.id); $('drag-preview').hidden=true; $('board').querySelectorAll('.drop-hover').forEach(n=>n.classList.remove('drop-hover')); } $('board').addEventListener('pointerdown',event=>{ if(busy||isSolved(game)||!event.isPrimary||event.button!==0)return; const target=hit(event.target); gesture={id:event.pointerId,startX:event.clientX,startY:event.clientY,track:null,index:null,...target,before:selection,dragging:false}; $('board').setPointerCapture(event.pointerId); }); $('board').addEventListener('pointermove',event=>{ if(!gesture||event.pointerId!==gesture.id)return; if(!gesture.dragging&&gesture.index!==null&&Math.hypot(event.clientX-gesture.startX,event.clientY-gesture.startY)>9) { gesture.dragging=true;selection=selectWagons(game,gesture.track,gesture.index);message='';hintOpen=false;render(); $('drag-preview').textContent=selectedCars(game,selection).join(' + '); $('drag-preview').hidden=false; } if(!gesture.dragging)return; $('drag-preview').style.left=`${event.clientX}px`;$('drag-preview').style.top=`${event.clientY-48}px`; const target=hit(document.elementFromPoint(event.clientX,event.clientY)); $('board').querySelectorAll('.track-zone').forEach(n=>n.classList.toggle('drop-hover',n.dataset.track===target?.track)); }); $('board').addEventListener('pointerup',event=>{ if(!gesture||event.pointerId!==gesture.id)return; const current=gesture; if(current.dragging){ const target=hit(document.elementFromPoint(event.clientX,event.clientY)); endGesture(!target||target.track===current.track); if(target&&target.track!==current.track)void perform(target.track); }else{ const within=hit(document.elementFromPoint(event.clientX,event.clientY)); endGesture(); if(Math.hypot(event.clientX-current.startX,event.clientY-current.startY)<10){ if(within&¤t.track)activate(current.track,current.index); else if(!current.track){clearChoice();render();} } } }); $('board').addEventListener('pointercancel',()=>endGesture(true)); $('board').addEventListener('lostpointercapture',()=>{if(gesture)endGesture(true);}); // Pointer gestures are handled on pointerup. Ignore their compatibility click, // even when a mobile browser delivers it late; detail=0 retains AT activation. $('board').addEventListener('click',event=>{if(event.detail===0){const target=hit(event.target);if(target)activate(target.track,target.index);else if(!busy){clearChoice();render();}}}); $('board').addEventListener('keydown',event=>{ if(['Enter',' '].includes(event.key)){const target=hit(event.target);if(target){event.preventDefault();activate(target.track,target.index);}} }); document.addEventListener('keydown',event=>{if(event.key==='Escape'&&game){endGesture(true);if(!busy){selection=null;message='';render();}}}); $('undo').addEventListener('click',()=>{if(!busy){game=undo(game);clearChoice();save();render();}}); $('restart').addEventListener('click',()=>{if(!busy){game=restart(game);clearChoice();save();render();$('menu-dialog').close();}}); $('next').addEventListener('click',()=>{if(index+1{selectOrder(Number(event.target.value));$('menu-dialog').close();}); $('hint').addEventListener('click',()=>{hintOpen=!hintOpen;render();}); $('dispatch').addEventListener('click',()=>{void sendTrain();}); $('brief-open').addEventListener('click',showBrief); $('cheat-toggle').addEventListener('click',()=>{if(!busy&&localPreview){endGesture();cheatOpen=!cheatOpen;render();}}); $('cheat-prev').addEventListener('click',()=>skipMission(index-1)); $('cheat-next').addEventListener('click',()=>skipMission(index+1)); $('cheat-level').addEventListener('change',event=>skipMission(Number(event.target.value))); $('pause').addEventListener('click',()=>{if(!busy){endGesture(true);$('menu-dialog').showModal();}}); $('help').addEventListener('click',()=>{$('menu-dialog').close();$('help-dialog').showModal();}); document.addEventListener('visibilitychange',()=>{if(document.hidden)for(const animation of activeAnimations)animation.finish();}); window.addEventListener('pagehide',()=>{for(const animation of activeAnimations)animation.finish();if(game)save();}); window.addEventListener('native-back',event=>{ const dialog=document.querySelector('dialog[open]'); if(dialog){event.preventDefault();dialog.close();return;} if(busy){event.preventDefault();return;} if(selection){event.preventDefault();endGesture(true);clearChoice();render();} }); $('world-maps').addEventListener('click',event=>{if(busy){event.preventDefault();return;}endGesture();save();if(new URLSearchParams(location.search).has('qa'))$('world-maps').href='routes/?qa=1';}); try { const response=await fetch('./content/campaign.json');if(!response.ok)throw Error('Campaign unavailable'); puzzles=(await response.json()).puzzles; game=createGame(puzzles[0]);restore();render();if(!isSolved(game)&&game.moves===0)showBrief(); }catch(error){$('notice').hidden=false;$('notice').textContent=`The prototype could not start: ${error.message}. Reload to retry.`;localize();}