clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / main.js
main.js 26.0K
1import { createGame } from './engine.js';2import { loadMod } from './mods.js';3import { createCatalog } from './catalog.js';4import { BUNDLED_WORLD_SOURCES } from './bundled-worlds.js';5import { prepare, layout } from '@chenglou/pretext';6import { createStage } from './stage.js';7import { SCENE_MOTION_TIMING } from './stage/scene-motion.js';8import { createInput } from './input.js';9import { createPackService } from './pack-service.js';10import { createPackLibrary } from './pack-library.js';11import { createAudioSystem } from './audio.js';12import { createJuice } from './juice.js';13import { createIndexLayout, decorateIndex, createProgressStore, createIndexNavigator } from './scenes/map.js';14import { createGiftScene } from './scenes/gift.js';15import { createWorkshopScene } from './scenes/workshop.js';16import { createTitleScene } from './scenes/title.js';17import { createTurnActions } from './app/turns.js';18import { createDevView } from './app/dev-view.js';19import { createIntentRouter } from './app/intent-router.js';20import { createPresentationClock, parseSlowmo } from './presentation-clock.js';21import { winHandoffIsCurrent } from './app/level-identity.js';22import { playableLevelFromView } from './level-view.js';23import './style.css';24import './overrides.css';25import './font.css';2627const canvas = document.querySelector('#game-canvas');28const levelTitle = document.querySelector('#level-title');29const levelIndex = document.querySelector('#level-index');30const levelList = document.querySelector('#level-list');31const rulesList = document.querySelector('#rules-list');32const moveCount = document.querySelector('#move-count');33const hintCopy = document.querySelector('#hint-copy');34const winToast = document.querySelector('#win-toast');35const statusLabel = document.querySelector('#status-label');36const statusDot = document.querySelector('#status-dot');37const boardDescription = document.querySelector('#board-description');38const soundButton = document.querySelector('#sound-btn');39const undoButton = document.querySelector('#undo-btn');40const restartButton = document.querySelector('#restart-btn');41const continueButton = document.querySelector('#continue-btn');42const packList = document.querySelector('#pack-list');43const exportButton = document.querySelector('#export-pack');44const shareUrlInput = document.querySelector('#share-url-input');45const copyShareButton = document.querySelector('#copy-share');46const modStatus = document.querySelector('#mod-status');47if (!new URLSearchParams(location.search).has('dev')) document.body.classList.add('stage-player');48// Review-only visual time. Input repeat and engine actions stay on their raw49// clocks; only presentation effects and their intentional scene waits use this.50const presentationClock = createPresentationClock({ slowmo: parseSlowmo(location.search) });51let levelNo = 0;52let activeLevelId = null;53let game;54let lastState;55const catalog = createCatalog();56registerBundledPacks();57const library = createPackLibrary({ catalog });58const packService = createPackService({ catalog, library });59const audio = createAudioSystem(); audio.installGestureListeners(document);60const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, now: presentationClock.now });61const progress = createProgressStore();62const titleController = createTitleScene({ startTime: presentationClock.now(), onSolved: () => stage.show('index'), onSkip: () => stage.show('index'), onWorkshop: () => stage.show('workshop', { origin: 'title' }) });63const stage = createStage({ clock: presentationClock, levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });64let giftOrigin = 'url';65const giftController = createGiftScene({66 copyToClipboard: text => {67 const pending = navigator.clipboard?.writeText(text);68 if (pending?.then) pending.then(() => showModStatus('NOTE DETAILS COPIED')).catch(error => showModStatus('Could not copy note details', error.message));69 return pending;70 },71 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');} } }72});73const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.closeWorkshop(), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index', { origin: 'workshop', worldId: packId }); }, onRemove: handleWorkshopRemove, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => { stage.announce('LINK IS COPIED'); stage.showCopyConfirmation?.(); }).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) });74stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });75let soundOn = !audio.isMuted();76soundButton.textContent = soundOn ? '◖' : '○';77soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound');78let hintPrepared;79let feedback = '';80let feedbackTimer;8182const glyphs = { MIAU:'🐈', CAT:'🐈', BABA:'🧒', FLAG:'🚩', ROCK:'🪨', WALL:'🧱', WATER:'💧', GRASS:'🌿', STAR:'⭐', KEY:'🔑', DOOR:'🚪', FENN:'🦊', THORN:'🌵', GEM:'💎' };83const colors = { YOU:'#f06d5f', PUSH:'#efb34a', STOP:'#625e88', WIN:'#2d9c88', SINK:'#6f9ca8', DEFEAT:'#b45273' };84const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));85const errorText = errors => (errors || []).map(error => error?.message || error?.detail || String(error)).join('\n');8687function currentLevels() { return catalog.getLevels(); }88function activeLevel() { return activeLevelId ? catalog.getLevel(activeLevelId) : catalog.getActiveLevel(); }89function reconcileActiveLevel() {90 const resolved = catalog.getActiveLevel();91 activeLevelId = resolved?.id || null;92 const index = resolved ? currentLevels().findIndex(level => level.id === resolved.id) : -1;93 if (index >= 0) levelNo = index;94 return resolved;95}96function handleWorkshopRemove() {97 refreshIndex();98 if (activeLevelId && catalog.getLevel(activeLevelId)) return;99 const fallback = reconcileActiveLevel();100 if (fallback) loadLevel(fallback.id);101}102function displayState() {103 const list = currentLevels();104 const activeIndex = activeLevelId ? list.findIndex(level => level.id === activeLevelId) : levelNo;105 return {106 ...game.getState(),107 proofNumber: (activeIndex >= 0 ? activeIndex : levelNo) + 1,108 proofTotal: list.length,109 completedCount: list.filter(level => progress.isSolved(level.packId, level.localId)).length,110 };111}112let indexLayout;113let indexNavigator;114function refreshIndex(focusId) {115 const previous = focusId || indexNavigator?.current?.node?.id;116 const cursorByWorld = indexNavigator?.cursorSnapshot?.() || {};117 const currentCursor = indexNavigator?.current;118 if (currentCursor?.worldId && currentCursor.nodeId) cursorByWorld[currentCursor.worldId] = currentCursor.nodeId;119 indexLayout = decorateIndex(createIndexLayout(catalog), progress);120 indexNavigator = createIndexNavigator(indexLayout, {121 startNodeId: previous,122 cursorByWorld,123 locked: 'indicate',124 });125 stage.setIndex(indexLayout, indexNavigator);126}127function activePalette() { return activeLevel()?.palette || {}; }128function activePackId() { return activeLevel()?.packId || 'core'; }129function showModStatus(message, detail = '') { modStatus.textContent = message; modStatus.title = detail || message; }130function registerBundledPacks() { for (const { filename, source } of BUNDLED_WORLD_SOURCES) { try { loadAndRegister(source, filename); } catch (error) { showModStatus(`Could not load ${filename}`, error.message); } } }131function loadAndRegister(source, filename) { const mod = loadMod(source, { filename }); return catalog.register(mod, { origin: 'bundled' }); }132function loadLevel(index) {133 const list = currentLevels();134 const selected = catalog.getLevel(index);135 if (!selected) { showModStatus('Level unavailable'); return false; }136 const selectedIndex = list.findIndex(level => level.id === selected.id);137 if (selectedIndex < 0) { showModStatus('Level unavailable'); return false; }138 presentationClock.clearTimeout(winTimer);139 feedback = '';140 catalog.setActiveLevel(selected.id);141 activeLevelId = catalog.activeLevelId;142 levelNo = selectedIndex; game = createGame(playableLevelFromView(selected)); lastState = game.getState();143 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;144 hintCopy.textContent = lastState.hint || 'Find the sentence that changes the proof.';145 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');146 hintCopy.style.minHeight = `${Math.max(18, layout(hintPrepared, 270, 18).height)}px`;147 winToast.hidden = !lastState.won;148 // A new proof needs fresh positions, sentence animations, and win effects;149 // authored levels may reuse entity IDs from the previous proof.150 juice.resetLevel(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame()); animateBoard(650);151 return true;152}153function packLevelIndexes(packId = activePackId()) {154 return currentLevels().flatMap((level, index) => level.packId === packId ? [index] : []);155}156function navigateWithinPack(delta) {157 const indexes = packLevelIndexes();158 const position = indexes.indexOf(levelNo);159 if (position < 0 || !indexes.length) return;160 if (loadLevel(indexes[(position + delta + indexes.length) % indexes.length])) {161 stage.show('proof');162 scheduleInitialWin();163 }164}165function nextLevelInPack() {166 const indexes = packLevelIndexes();167 return indexes[indexes.indexOf(levelNo) + 1];168}169function renderLevelList() {170 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('');171 levelList.querySelectorAll('[data-level-id]').forEach(button => button.addEventListener('click', () => loadLevel(Number(button.dataset.levelIndex))));172}173function renderPackList() {174 if (!packList) return;175 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('');176 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); }));177}178function updateUI() {179 lastState = game.getState(); moveCount.textContent = lastState.moves; undoButton.disabled = !game.canUndo;180 continueButton.disabled = nextLevelInPack() === undefined;181 continueButton.textContent = continueButton.disabled ? 'PACK COMPLETE' : 'CONTINUE →';182 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>';183 statusLabel.textContent = feedback || (lastState.won ? 'PROOF COMPLETE' : (lastState.objects.some(object => game.properties(object).includes('YOU')) ? 'READY TO PROVE' : 'NO YOU RULE ACTIVE'));184 statusDot.classList.toggle('won', lastState.won); statusDot.classList.toggle('blocked', Boolean(feedback)); winToast.hidden = !lastState.won;185 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('; ')}.`;186}187const devView = createDevView({ canvas, getGame: () => game, getPalette: activePalette, glyphs, colors });188function render() {189 if (!game) return;190 lastState = game.getState();191 devView.render(lastState);192}193function playSound(name) { if (!soundOn) return; audio.play(name === 'rule' ? 'ruleFormed' : name); }194function playTurnSounds(result) {195 const cues = new Set();196 if (result.moved?.length) cues.add(result.moved.some(entry => entry.pushedBy) ? 'push' : 'move');197 const acting = (result.automated || []).filter(entry => entry.active && entry.id);198 if (acting.length) cues.add(acting.find(entry => entry.sound)?.sound || 'tick');199 for (const cue of cues) playSound(cue);200 if (result.rulesRemoved?.length) playSound('ruleBroken');201 if (result.rulesAdded?.length) playSound('ruleFormed');202 if (result.transformed?.length) playSound('transform');203 if (result.destroyed?.some(entry => entry.cause === 'sink')) playSound('sink');204 if (result.destroyed?.some(entry => entry.cause === 'defeat')) playSound('defeat');205}206function showFeedback(message) { feedback = message; presentationClock.clearTimeout(feedbackTimer); feedbackTimer = presentationClock.setTimeout(() => { feedback = ''; updateUI(); }, 1000); updateUI(); }207function accepted(result) { return typeof result === 'object' && result !== null ? result.accepted : result; }208function announceLive(message) { const copy = String(message || '').trim(); if (!copy) return; if (stage.announce) stage.announce(copy); else { const live = document.querySelector('.stage-sr'); if (live) live.textContent = copy; } }209function announceTurn(result) {210 if (!accepted(result) || !game) return;211 const state = game.getState();212 const formed = (result.rulesAdded || []).map(rule => `formed ${rule.text}`);213 const broken = (result.rulesRemoved || []).map(rule => `broken ${rule.text}`);214 const active = state.rules.length ? `Active rules: ${state.rules.map(rule => rule.text).join(', ')}.` : 'No active rules.';215 const beats = (state.automata || []).filter(system => system.active).map(system => `${system.subject} ${system.property} beat ${system.phase + 1} of ${system.pattern.length}.`);216 const status = result.won ? 'Proof complete.' : result.controlLost ? 'Nothing is YOU.' : '';217 announceLive([...formed, ...broken, active, ...beats, status].filter(Boolean).join(' '));218}219function announceBoardSummary() {220 let board;221 try { board = JSON.parse(window.render_game_to_text?.() || '{}'); } catch { board = {}; }222 const rules = board.rules?.map(rule => rule.text).filter(Boolean) || [];223 const objects = board.objects?.map(object => `${object.kind === 'word' ? object.text : object.noun} at column ${Number(object.x) + 1}, row ${Number(object.y) + 1}`) || [];224 const beats = board.automata?.map(system => `${system.subject} ${system.property}, ${system.active ? 'active' : 'inactive'}, beat ${system.phase + 1} of ${system.pattern.length}`) || [];225 const summary = `${board.level || 'Board'}: ${board.mode || 'play'}, ${board.moves || 0} steps. ${rules.length ? `Rules: ${rules.join(', ')}.` : 'No active rules.'} ${beats.length ? `Automata: ${beats.join('; ')}.` : ''} ${objects.length ? `Objects: ${objects.slice(0, 24).join('; ')}.` : ''}`.trim();226 announceLive(summary);227 if (globalThis.speechSynthesis && globalThis.SpeechSynthesisUtterance) { globalThis.speechSynthesis.cancel(); globalThis.speechSynthesis.speak(new globalThis.SpeechSynthesisUtterance(summary)); }228}229let animationRequest = 0;230let animationDeadline = 0;231let winTimer = 0;232function scheduleInitialWin() {233 presentationClock.clearTimeout(winTimer);234 if (!game?.getState().won) return;235 const level = activeLevel();236 const expectedGame = game;237 const expectedLevelId = level?.id;238 if (level?.packId && level?.localId) {239 progress.markSolved(level.packId, level.localId);240 refreshIndex(level.id);241 }242 const promote = () => {243 if (!winHandoffIsCurrent({ currentGame: game, expectedGame, expectedLevelId, activeLevel: catalog.getActiveLevel() })) return;244 if (stage.scene === 'proof') {245 stage.show('win', { steps: expectedGame.getState().moves, nodeId: expectedLevelId, instant: true });246 return;247 }248 // Retry after the INDEX exit on the presentation clock, preserving slowmo.249 if (stage.scene === 'index-exiting') winTimer = presentationClock.setTimeout(promote, SCENE_MOTION_TIMING.exit);250 };251 winTimer = presentationClock.setTimeout(promote, 0);252}253function paintBoardFrame(now = presentationClock.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }254function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,presentationClock.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=presentationClock.requestAnimationFrame(tick); }; animationRequest=presentationClock.requestAnimationFrame(tick); }255const turns = createTurnActions({256 getGame: () => game,257 getStage: () => stage,258 getJuice: () => juice,259 getNow: presentationClock.now,260 setFeedback: showFeedback,261 clearFeedback: () => { feedback = ''; },262 updateUI,263 render,264 paintBoardFrame,265 animateBoard,266 playSound,267 playTurnSounds,268 announceTurn,269 onWin: now => {270 const level = activeLevel();271 const expectedGame = game;272 const expectedLevelId = level?.id;273 if (level?.packId && level?.localId) {274 progress.markSolved(level.packId, level.localId);275 refreshIndex(level.id);276 }277 playSound('win');278 presentationClock.clearTimeout(winTimer);279 const beatStart = juice.frame(now).win?.beatStart ?? now;280 winTimer = presentationClock.setTimeout(() => {281 if (!winHandoffIsCurrent({ currentGame: game, expectedGame, expectedLevelId, activeLevel: catalog.getActiveLevel() })) return;282 stage.show('win', { steps: expectedGame.getState().moves, nodeId: expectedLevelId });283 }, Math.max(0, beatStart - now));284 },285 onRestart: () => {286 presentationClock.clearTimeout(winTimer);287 scheduleInitialWin();288 },289 onUndo: () => presentationClock.clearTimeout(winTimer),290});291function move(dx, dy) { turns.move(dx, dy); }292function waitTurn() { turns.wait(); }293function restart() { turns.restart(); }294function undo() { turns.undo(); }295const intentRouter = createIntentRouter({296 getStage: () => stage,297 getTitleController: () => titleController,298 move,299 wait: waitTurn,300 undo,301 restart,302 mute: () => soundButton.click(),303 onSummary: announceBoardSummary,304});305function handleIntent(intent) {306 stage.noteInput?.(intent);307 intentRouter.route(intent);308}309document.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])()));310document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));311function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}312document.querySelector('#fullscreen-btn').addEventListener('click',toggleFullscreen);313undoButton.addEventListener('click', () => { stage.noteInput?.({ type: 'undo', source: 'touch' }); undo(); });314restartButton.addEventListener('click', () => { stage.noteInput?.({ type: 'restart', source: 'touch' }); restart(); });315continueButton.addEventListener('click', () => { stage.noteInput?.({ type: 'confirm', source: 'touch' }); const next = nextLevelInPack(); if (next !== undefined && loadLevel(next)) { stage.show('proof'); scheduleInitialWin(); } });316soundButton.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()}); });317stage.setActions({ onWaitAction: waitTurn, onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });318stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});319function 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)); }320function 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,warnings:parsed.warnings}); stage.show('gift'); return parsed.pack.id; }321document.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=''; }});322window.addEventListener('dragover', event => { event.preventDefault(); document.body.classList.add('pack-drop'); });323window.addEventListener('dragleave', event => { if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= innerWidth || event.clientY >= innerHeight) document.body.classList.remove('pack-drop'); });324window.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); } });325function 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`); }326function makeShareUrl() { const shared = packService.packLink(activePackId()); if (!shared.ok) throw new Error(errorText(shared.errors)); return shared.url; }327exportButton?.addEventListener('click', downloadPack);328copyShareButton?.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); } });329function sharedPayload() { return location.hash.startsWith('#pack=') ? location.hash.slice(6) : ''; }330document.addEventListener('keydown', event => {331 if (event.target?.matches?.('input,textarea,select,[contenteditable]')) return;332 const key = event.key.toLowerCase();333 if (stage.scene === 'gift' && (key === 'i' || key === 'c')) {334 stage.noteInput?.({ type: key === 'i' ? 'details' : 'copy', source: 'keyboard' });335 const result = giftController.input(key);336 if (result.ok) {337 event.preventDefault();338 if (result.action === 'details') stage.announce(giftController.accessibilityText());339 stage.render();340 }341 } else if (stage.scene === 'workshop' && key === 'c' && workshopController.view().note) {342 stage.noteInput?.({ type: 'copy', source: 'keyboard' });343 event.preventDefault();344 Promise.resolve(workshopController.handleInput('copy')).finally(() => stage.render());345 }346});347window.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||[],automata:lastState?.automata||[],objects:lastState?.objects?.map(({id,kind,noun,text,x,y})=>({id,kind,noun,text,x,y}))||[]});348window.advanceTime=ms=>{render();return ms};349const input = createInput({ onIntent: handleIntent });350input.attach({ stage: stage.canvas });351// Canvas text is rasterized at draw time, so the first scene must wait for the352// self-hosted face instead of relying on CSS font-display to repaint it.353await document.fonts.load('800 20px Recursive');354await document.fonts.load('600 20px Recursive');355refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict,warnings:pending.warnings}); stage.show('gift',{origin:'title'}); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');