- commit
- 741f4c71650aa2265ea4f6e7f3b448fd749f1bc9
- parent
- e46610623ee9ca05e6da692ea57e22dc89f1b343
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 145 | +++++++++++++++++++++++++----- |
@@ -1,9 +1,23 @@
11 import { createGame } from './engine.js';2-import { levels } from './levels.js';32 import { loadMod } from './mods.js';3+import { createCatalog } from './catalog.js';4+import gardenBasicsSource from '../mods/garden-basics.cjs?raw';5+import wordLabSource from '../mods/word-lab.cjs?raw';46 import { prepare, layout } from '@chenglou/pretext';7+import { createStage } from './stage.js';8+import { drawBoard } from './render/board.js';9+import { createInput } from './input.js';10+import { createPackService } from './pack-service.js';11+import { createPackLibrary } from './pack-library.js';12+import { createAudioSystem } from './audio.js';13+import { createJuice } from './juice.js';14+import { createIndexLayout, decorateIndex, createProgressStore, createIndexNavigator } from './scenes/map.js';15+import { createGiftScene } from './scenes/gift.js';16+import { createWorkshopScene } from './scenes/workshop.js';17+import { createTitleScene } from './scenes/title.js';518 import './style.css';619 import './overrides.css';20+import './font.css';721 822 const canvas = document.querySelector('#game-canvas');923 const ctx = canvas.getContext('2d');@@ -21,12 +35,31 @@ const soundButton = document.querySelector('#sound-btn');
2135 const undoButton = document.querySelector('#undo-btn');2236 const restartButton = document.querySelector('#restart-btn');2337 const continueButton = document.querySelector('#continue-btn');38+const packList = document.querySelector('#pack-list');39+const exportButton = document.querySelector('#export-pack');40+const shareUrlInput = document.querySelector('#share-url-input');41+const copyShareButton = document.querySelector('#copy-share');42+const modStatus = document.querySelector('#mod-status');43+if (!new URLSearchParams(location.search).has('dev')) document.body.classList.add('stage-player');2444 let levelNo = 0;2545 let game;2646 let lastState;27-let modLevels = [];28-let modPalette = {};29-let soundOn = true;47+const catalog = createCatalog();48+registerBundledPacks();49+const library = createPackLibrary({ catalog });50+const packService = createPackService({ catalog, library });51+const audio = createAudioSystem(); audio.installGestureListeners(document);52+const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches });53+const progress = createProgressStore();54+const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index') });55+const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => loadLevel(currentLevels().findIndex(level => level.id === id)), onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });56+let giftOrigin = 'url';57+const giftController = createGiftScene({ onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } } });58+const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.show('pause'), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index'); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => showModStatus('LINK IS COPIED')).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });59+stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });60+let soundOn = !audio.isMuted();61+soundButton.textContent = soundOn ? 'โ' : 'โ';62+soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound');3063 let hintPrepared;3164 let feedback = '';3265 let feedbackTimer;@@ -34,35 +67,72 @@ let feedbackTimer;
3467 const glyphs = { MIAU:'๐ฑ', CAT:'๐ฑ', BABA:'๐ฑ', FLAG:'โ', ROCK:'โ', WALL:'โฆ', WATER:'โ', GRASS:'โฟ', STAR:'โฆ', KEY:'โฟ', DOOR:'โฃ', KEKE:'๐พ' };3568 const colors = { YOU:'#f06d5f', PUSH:'#efb34a', STOP:'#625e88', WIN:'#2d9c88', SINK:'#6f9ca8', DEFEAT:'#b45273' };3669 const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));70+const errorText = errors => (errors || []).map(error => error?.message || error?.detail || String(error)).join('\n');3771 38-function builtInLevels() { return Array.isArray(levels) ? levels : (levels?.levels || []); }39-function currentLevels() { return builtInLevels().concat(modLevels); }40-function activePalette() { return levelNo >= builtInLevels().length ? modPalette : {}; }72+function currentLevels() { return catalog.getLevels(); }73+let indexLayout;74+let indexNavigator;75+function refreshIndex(focusId) {76+ const previous = focusId || indexNavigator?.current?.node?.id;77+ indexLayout = decorateIndex(createIndexLayout(catalog), progress);78+ indexNavigator = createIndexNavigator(indexLayout, { startNodeId: previous, locked: 'indicate' });79+ stage.setIndex(indexLayout, indexNavigator);80+}81+function activePalette() { return currentLevels()[levelNo]?.palette || {}; }82+function activePackId() { return currentLevels()[levelNo]?.packId || 'core'; }83+function packData(packId = activePackId()) { return catalog.getPack(packId); }84+function showModStatus(message, detail = '') { modStatus.textContent = message; modStatus.title = detail || message; }85+function registerBundledPacks() { for (const [filename, source] of [['garden-basics.cjs', gardenBasicsSource], ['word-lab.cjs', wordLabSource]]) { try { loadAndRegister(source, filename); } catch (error) { showModStatus(`Could not load ${filename}`, error.message); } } }86+function loadAndRegister(source, filename) { const mod = loadMod(source, { filename }); return catalog.register(mod, { origin: 'bundled' }); }4187 function loadLevel(index) {42- const list = currentLevels(); if (!list.length) return;88+ const list = currentLevels();89+ if (typeof index === 'string') index = Math.max(0, list.findIndex(level => level.id === index));90+ if (!list[index]) { showModStatus('Level unavailable'); return false; }4391 feedback = '';44- levelNo = (index + list.length) % list.length; game = createGame(list[levelNo]); lastState = game.getState();92+ levelNo = index; catalog.setActiveLevel(list[levelNo].id); game = createGame(list[levelNo]); lastState = game.getState();4593 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;46- hintCopy.textContent = lastState.hint || 'Find the sentence hidden in the garden.';94+ hintCopy.textContent = lastState.hint || 'Find the sentence that changes the proof.';4795 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');4896 hintCopy.style.minHeight = `${Math.max(18, layout(hintPrepared, 270, 18).height)}px`;4997 winToast.hidden = !lastState.won;50- renderLevelList(); updateUI(); render();98+ juice.syncState(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(lastState, game, activePalette(), glyphs, juice.frame());99+ return true;100+}101+function packLevelIndexes(packId = activePackId()) {102+ return currentLevels().flatMap((level, index) => level.packId === packId ? [index] : []);103+}104+function navigateWithinPack(delta) {105+ const indexes = packLevelIndexes();106+ const position = indexes.indexOf(levelNo);107+ if (position < 0 || !indexes.length) return;108+ loadLevel(indexes[(position + delta + indexes.length) % indexes.length]);109+}110+function nextLevelInPack() {111+ const indexes = packLevelIndexes();112+ return indexes[indexes.indexOf(levelNo) + 1];51113 }52114 function renderLevelList() {53- levelList.innerHTML = currentLevels().map((level, i) => `<button class="level-choice ${i === levelNo ? 'active' : ''}" data-level="${i}"><span>${String(i + 1).padStart(2,'0')}</span><strong>${escapeHTML(level.title || `Garden ${i + 1}`)}</strong><small>${escapeHTML(level.hint || '')}</small></button>`).join('');54- levelList.querySelectorAll('[data-level]').forEach(button => button.addEventListener('click', () => loadLevel(Number(button.dataset.level))));115+ levelList.innerHTML = currentLevels().map((level, i) => `<button class="level-choice ${i === levelNo ? 'active' : ''}" data-level-id="${escapeHTML(level.id)}" data-level-index="${i}"><span>${String(i + 1).padStart(2,'0')}</span><strong>${escapeHTML(level.title || `Proof ${i + 1}`)}</strong><small>${escapeHTML(level.hint || '')}</small></button>`).join('');116+ levelList.querySelectorAll('[data-level-id]').forEach(button => button.addEventListener('click', () => loadLevel(Number(button.dataset.levelIndex))));117+}118+function renderPackList() {119+ if (!packList) return;120+ packList.innerHTML = catalog.getPacks().map(pack => `<button class="pack-choice ${pack.id === activePackId() ? 'active' : ''}" data-pack-id="${escapeHTML(pack.id)}"><strong>${escapeHTML(pack.manifest.name)}</strong><small>${pack.levelCount} proof${pack.levelCount === 1 ? '' : 's'}</small></button>`).join('');121+ packList.querySelectorAll('[data-pack-id]').forEach(button => button.addEventListener('click', () => { const index = currentLevels().findIndex(level => level.packId === button.dataset.packId); if (index >= 0) loadLevel(index); }));55122 }56123 function updateUI() {57124 lastState = game.getState(); moveCount.textContent = lastState.moves; undoButton.disabled = !game.canUndo;125+ continueButton.disabled = nextLevelInPack() === undefined;126+ continueButton.textContent = continueButton.disabled ? 'PACK COMPLETE' : 'CONTINUE โ';58127 rulesList.innerHTML = lastState.rules.length ? lastState.rules.map(rule => `<div class="rule-chip"><span class="rule-subject">${escapeHTML(rule.subject)}</span><b>IS</b><span class="rule-predicate" style="--rule-color:${colors[rule.predicate] || '#6e685e'}">${escapeHTML(rule.predicate)}</span></div>`).join('') : '<div class="empty-rules">Move the word tiles to wake a rule.</div>';59- statusLabel.textContent = feedback || (lastState.won ? 'GARDEN COMPLETE' : (lastState.objects.some(object => game.properties(object).includes('YOU')) ? 'READY TO EXPLORE' : 'NO YOU RULE ACTIVE'));128+ statusLabel.textContent = feedback || (lastState.won ? 'PROOF COMPLETE' : (lastState.objects.some(object => game.properties(object).includes('YOU')) ? 'READY TO PROVE' : 'NO YOU RULE ACTIVE'));60129 statusDot.classList.toggle('won', lastState.won); statusDot.classList.toggle('blocked', Boolean(feedback)); winToast.hidden = !lastState.won;61130 boardDescription.textContent = `${lastState.title}. ${lastState.width} columns by ${lastState.height} rows. ${lastState.moves} moves. ${lastState.won ? 'Won.' : 'Playing.'} Active rules: ${lastState.rules.map(rule => rule.text).join('; ') || 'none'}. Entities: ${lastState.objects.map(object => `${object.kind === 'word' ? `word ${object.text}` : object.noun} at column ${object.x + 1}, row ${object.y + 1}`).join('; ')}.`;62131 }63132 function drawRounded(x, y, w, h, r, fill, stroke) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fillStyle = fill; ctx.fill(); if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); } }64133 function render() {65134 if (!game) return; lastState = game.getState(); const W = canvas.width, H = canvas.height; const pad = 28; const cell = Math.min((W - pad * 2) / lastState.width, (H - pad * 2) / lastState.height); const ox = (W - cell * lastState.width) / 2, oy = (H - cell * lastState.height) / 2;135+ if (document.body.classList.contains('stage-player')) { ctx.fillStyle = '#0F0E0D'; ctx.fillRect(0, 0, W, H); drawBoard(ctx, lastState, { x: ox, y: oy, cell, game, palette: activePalette(), glyphs }); return; }66136 ctx.clearRect(0,0,W,H); ctx.fillStyle='#fbf7ef'; ctx.fillRect(0,0,W,H);67137 ctx.strokeStyle='rgba(102,91,72,.12)'; ctx.lineWidth=1;68138 for(let x=0;x<=lastState.width;x++){ctx.beginPath();ctx.moveTo(ox+x*cell,oy);ctx.lineTo(ox+x*cell,oy+lastState.height*cell);ctx.stroke()}@@ -74,20 +144,47 @@ function render() {
74144 } else { const emoji=activePalette()[name] || glyphs[name]; ctx.shadowColor=props.includes('YOU') ? 'rgba(240,109,95,.38)' : 'transparent'; ctx.shadowBlur=props.includes('YOU') ? 10 : 0; ctx.font=`${Math.max(24,cell*.52)}px "Apple Color Emoji","Segoe UI Emoji",sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle'; if (emoji) ctx.fillText(emoji,x+cell/2,y+cell*.52); else { ctx.fillStyle=accent; ctx.font=`700 ${Math.max(13,cell*.18)}px 'DM Mono',monospace`; ctx.fillText(name,x+cell/2,y+cell*.52); } ctx.shadowBlur=0; ctx.fillStyle=accent; ctx.font=`700 ${Math.max(8,cell*.1)}px ui-monospace,monospace`; ctx.fillText(name,x+cell/2,y+cell*.87); if(props.length){ctx.strokeStyle=accent;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x+cell/2,y+cell/2,cell*.36,0,Math.PI*2);ctx.stroke()} if(stack.length>1){ctx.fillStyle='#fffaf2';ctx.beginPath();ctx.arc(x+cell*.82,y+cell*.2,cell*.12,0,Math.PI*2);ctx.fill();ctx.fillStyle=accent;ctx.font=`700 ${Math.max(9,cell*.1)}px 'DM Mono',monospace`;ctx.fillText(String(stack.length),x+cell*.82,y+cell*.2)}}75145 });76146 }77-function playSound(name) { if (!soundOn) return; const audio = new Audio(`/audio/${name}.ogg`); audio.volume = name === 'move' ? .18 : .24; audio.play().catch(() => {}); }147+function playSound(name) { if (!soundOn) return; audio.play(name === 'rule' ? 'ruleFormed' : name); }148+function playTurnSounds(result) {149+ playSound(result.moved?.some(entry => entry.pushedBy) ? 'push' : 'move');150+ if (result.rulesRemoved?.length) playSound('ruleBroken');151+ if (result.rulesAdded?.length) playSound('ruleFormed');152+ if (result.transformed?.length) playSound('transform');153+ if (result.destroyed?.some(entry => entry.cause === 'sink')) playSound('sink');154+ if (result.destroyed?.some(entry => entry.cause === 'defeat')) playSound('defeat');155+}78156 function showFeedback(message) { feedback = message; clearTimeout(feedbackTimer); feedbackTimer = setTimeout(() => { feedback = ''; updateUI(); }, 1000); updateUI(); }79157 function accepted(result) { return typeof result === 'object' && result !== null ? result.accepted : result; }80-function move(dx,dy) { if (!game || game.getState().won) return; const result=game.step(dx,dy); if(accepted(result)){feedback=''; const changedRules=result && typeof result==='object' && (result.rulesAdded?.length || result.rulesRemoved?.length); playSound(changedRules ? 'rule' : 'move'); if(game.getState().won) playSound('win'); updateUI();render()} else showFeedback('BLOCKED ยท try another sentence'); }81-function restart(){game?.reset(); feedback=''; playSound('rule'); updateUI(); render()}82-function undo(){if(accepted(game?.undo())){feedback=''; playSound('rule'); updateUI();render()}}83-document.addEventListener('keydown', event => { if (['INPUT','TEXTAREA','SELECT'].includes(event.target?.tagName) || event.target?.isContentEditable) return; const key=event.key.toLowerCase(); if(['arrowup','arrowdown','arrowleft','arrowright','w','a','s','d','z','r','f'].includes(key)) event.preventDefault(); if(key==='arrowup'||key==='w')move(0,-1); if(key==='arrowdown'||key==='s')move(0,1); if(key==='arrowleft'||key==='a')move(-1,0); if(key==='arrowright'||key==='d')move(1,0); if(key==='z')undo(); if(key==='r')restart(); if(key==='f')toggleFullscreen();});158+let animationRequest = 0;159+let animationDeadline = 0;160+let winTimer = 0;161+function paintBoardFrame(now = performance.now()) { stage.setBoard(game.getState(),game,activePalette(),glyphs,juice.frame(now)); }162+function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,performance.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=requestAnimationFrame(tick); }; animationRequest=requestAnimationFrame(tick); }163+function move(dx,dy) { if (!game || game.getState().won) return; const now=performance.now(); juice.syncState(game.getState(),now); juice.beginInput(now); const result=game.step(dx,dy); juice.consume(result,now,game.getState()); if(accepted(result)){feedback=''; playTurnSounds(result); if(game.getState().won){ const level=currentLevels()[levelNo]; if(level?.packId && level?.localId) { progress.markSolved(level.packId,level.localId); refreshIndex(level.id); } playSound('win'); clearTimeout(winTimer); const cardAt=juice.frame(now).win?.cardAt ?? now; winTimer=setTimeout(()=>{if(game?.getState().won)stage.show('win',{steps:game.getState().moves});},Math.max(0,cardAt-now)); } updateUI();render();paintBoardFrame(now);animateBoard();} else {playSound(result.blocked?.reason==='bounds'?'blockedBounds':'blockedStop');showFeedback('BLOCKED / try another sentence');paintBoardFrame(now);animateBoard(180);} }164+function restart(){if(!game)return;clearTimeout(winTimer);const now=performance.now();juice.syncState(game.getState(),now);const result=game.reset();juice.consume(result,now,game.getState());juice.syncState(game.getState(),now);feedback='';playSound('restart');updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}165+function undo(){if(!game)return;const now=performance.now();juice.syncState(game.getState(),now);const result=game.undo();if(accepted(result)){clearTimeout(winTimer);juice.consume(result,now,game.getState());feedback='';playSound('undo');updateUI();render();stage.show('proof');paintBoardFrame(now);animateBoard(300);}else playSound('undoEmpty');}166+function handleIntent(intent) { if (stage.scene === 'title') { titleController.input(intent.type==='confirm'?'enter':intent.type==='undo'?'z':intent.type==='restart'?'r':intent.dir); stage.render(); return; } if (stage.scene === 'proof' || stage.scene === 'win') { if (intent.type === 'move' && stage.scene === 'proof') ({up: () => move(0,-1), down: () => move(0,1), left: () => move(-1,0), right: () => move(1,0)}[intent.dir])?.(); if (intent.type === 'undo') undo(); if (intent.type === 'restart') restart(); if (intent.type === 'menu' && stage.scene === 'proof') stage.show('pause'); if (intent.type === 'mute') soundButton.click(); if (intent.type === 'confirm' && stage.scene === 'win') stage.show('index'); return; } if(intent.source !== 'keyboard' && !(intent.source === 'touch' && intent.point)) stage.handleIntent?.(intent); }84167 document.querySelectorAll('[data-dir]').forEach(button=>button.addEventListener('click',()=>({up:move.bind(null,0,-1),down:move.bind(null,0,1),left:move.bind(null,-1,0),right:move.bind(null,1,0)}[button.dataset.dir])()));85-document.querySelector('#prev-level').addEventListener('click',()=>loadLevel(levelNo-1)); document.querySelector('#next-level').addEventListener('click',()=>loadLevel(levelNo+1));168+document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));86169 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}87170 document.querySelector('#fullscreen-btn').addEventListener('click',toggleFullscreen);88-undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => loadLevel(levelNo + 1));89-soundButton.addEventListener('click', () => { soundOn = !soundOn; soundButton.textContent = soundOn ? 'โ' : 'โ'; soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound'); });90-document.querySelector('#mod-input').addEventListener('change',async event=>{const file=event.target.files?.[0]; if(!file) return; const status=document.querySelector('#mod-status'); try { const mod=loadMod(await file.text(),{filename:file.name}); modLevels=mod.levels; modPalette=mod.palette || {}; status.textContent=`${mod.manifest.name} ยท ${mod.levels.length} garden${mod.levels.length===1?'':'s'} loaded`; status.title=''; loadLevel(currentLevels().length-modLevels.length); } catch(error) { status.textContent=error.errors?.[0] || error.message; status.title=error.message; }});171+undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => { const next = nextLevelInPack(); if (next !== undefined) loadLevel(next); });172+soundButton.addEventListener('click', () => { soundOn = !soundOn; audio.mute(!soundOn); soundButton.textContent = soundOn ? 'โ' : 'โ'; soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound'); stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()}); });173+stage.setActions({ onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });174+stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});175+function reportPackError(error) { const errors=Array.isArray(error?.errors)?error.errors:[{message:error?.message||String(error),detail:error?.message||String(error)}];workshopController.showErrors(errors);stage.show('workshop');showModStatus(`Invalid pack: ${errors[0]?.message||'Unknown error'}`,errorText(errors)); }176+function previewLocalText(source, filename) { const parsed=packService.importPackText(source, filename); if(!parsed.ok){const error=new Error(errorText(parsed.errors));error.errors=parsed.errors;throw error;} giftOrigin='local'; giftController.show(parsed.pack,{collision:parsed.conflict}); stage.show('gift'); return parsed.pack.id; }177+document.querySelector('#mod-input').addEventListener('change',async event=>{const file=event.target.files?.[0]; if(!file) return; try { previewLocalText(await file.text(),file.name); } catch(error) { reportPackError(error); } finally { event.target.value=''; }});178+window.addEventListener('dragover', event => { event.preventDefault(); document.body.classList.add('pack-drop'); });179+window.addEventListener('dragleave', event => { if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= innerWidth || event.clientY >= innerHeight) document.body.classList.remove('pack-drop'); });180+window.addEventListener('drop', async event => { event.preventDefault(); document.body.classList.remove('pack-drop'); const file=[...(event.dataTransfer?.files || [])].find(candidate => /\.cjs$|\.js$/i.test(candidate.name)); if (!file) return reportPackError(new Error('Drop a .cjs pack file.')); try { previewLocalText(await file.text(),file.name); } catch(error) { reportPackError(error); } });181+function downloadPack() { const exported = packService.exportPack(activePackId()); if (!exported.ok) { showModStatus('Export failed', errorText(exported.errors)); return; } const href = URL.createObjectURL(exported.blob); const anchor = document.createElement('a'); anchor.href = href; anchor.download = `${activePackId()}.cjs`; anchor.click(); setTimeout(() => URL.revokeObjectURL(href), 0); showModStatus(`Downloaded ${activePackId()}.cjs`); }182+function makeShareUrl() { const shared = packService.packLink(activePackId()); if (!shared.ok) throw new Error(errorText(shared.errors)); return shared.url; }183+exportButton?.addEventListener('click', downloadPack);184+copyShareButton?.addEventListener('click', async () => { let value; try { value = makeShareUrl(); shareUrlInput.value = value; } catch (error) { showModStatus(error instanceof RangeError ? 'Share URL too large / export .cjs instead' : 'Could not create share URL', error.message); return; } try { await navigator.clipboard.writeText(value); showModStatus('Share URL copied'); } catch (error) { showModStatus('Share URL ready / copy it manually', error.message); } });185+function sharedPayload() { return location.hash.startsWith('#pack=') ? location.hash.slice(6) : ''; }91186 window.render_game_to_text=()=>JSON.stringify({coordinateSystem:'origin top-left; x right, y down',level:lastState?.title||'',mode:lastState?.won?'won':'play',moves:lastState?.moves||0,grid:{width:lastState?.width||0,height:lastState?.height||0},rules:lastState?.rules||[],objects:lastState?.objects?.map(({id,kind,noun,text,x,y})=>({id,kind,noun,text,x,y}))||[]});92187 window.advanceTime=ms=>{render();return ms};93-loadLevel(0);188+const input = createInput({ onIntent: handleIntent });189+input.attach({ stage: stage.canvas });190+refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict}); stage.show('gift'); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');