LegendHaven · Story Quest
'; } function longDate(){try{return new Date().toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'})}catch(e){return stamp()}} /* An in-app browser — the Instagram or Facebook webview a community link usually opens in — swallows a download with no error at all. Same lesson as the Lore Card (2026-09-05): where a file cannot be written, open the story instead so the table can save it themselves. */ var SQ_UA=navigator.userAgent||''; var SQ_INAPP=/FBAN|FBAV|FB_IAB|Instagram|Line\/|Twitter|Pinterest|TikTok|Snapchat|MicroMessenger|LinkedInApp/i.test(SQ_UA); var SQ_CANSAVE=('download' in document.createElement('a'))&&!SQ_INAPP; function downloadStory(){ var html,url; try{html=transcriptHTML()}catch(e){toast('Could not build the story.');return} var name='story-quest-'+slug(castNames().join('-'))+'-'+stamp()+'.html'; try{url=URL.createObjectURL(new Blob([html],{type:'text/html'}))}catch(e){url=null} if(SQ_CANSAVE&&url){ var a=document.createElement('a');a.href=url;a.download=name;a.rel='noopener'; document.body.appendChild(a);a.click();a.remove(); setTimeout(function(){URL.revokeObjectURL(url)},4000); toast('The story is saved.');return; } var w=window.open(url||'','_blank'); if(w&&!url){try{w.document.write(html);w.document.close()}catch(e){}} if(w)toast('Opened the story — save it from there.'); else toast('Allow pop-ups to save the story.'); if(url)setTimeout(function(){URL.revokeObjectURL(url)},120000); } function renderExport(){ S.phase='done';renderTop();renderRow();ensureCards();hint('');$('fightcap').classList.add('hidden');removeBeastCard();showDeck(false);sparkleStop(); // the finished quest: every card of the story laid out in the middle of the board, // the character cards still in their corners, so one screenshot holds the whole thing var cards=S.story.map(function(e){ if(e.kind==='seed')return '
'+cardHTML({name:e.text,back:false})+'
'; if(e.kind==='beat')return '
'+cardHTML({emblem:e.type,tint:TINT[e.type],name:e.card.name,kicker:LABEL[e.type]+' · '+S.players[e.by].name,back:false})+'
'; if(e.kind==='beast')return '
'+cardHTML({art:beastArt(e.id,e.art),name:e.name,kicker:(e.result==='won'?'slain by ':e.result==='died'?'felled ':'fled from ')+e.fighter,back:false})+'
'; return '
'+cardHTML({name:e.card.text,back:false})+'
'; }).join(''); stage('
The story is finished · '+esc(S.players.map(function(p){return p.name+(p.alive?'':' ✝')}).join(' · '))+'
'+cards+'
',false); hint(''); bar(''); $('btnSave').onclick=downloadStory; var seed=S.story[0]&&S.story[0].kind==='seed'?S.story[0].text:'',rows=S.story.filter(function(e){return e.kind!=='seed'}); $('btnShot').onclick=function(){ if(typeof html2canvas!=='function'){toast('Screenshot library missing.');return} var b=$('btnShot');b.disabled=true;b.textContent='Capturing…'; html2canvas($('sq'),{backgroundColor:'#101114',scale:2,logging:false}).then(function(cv){var a=document.createElement('a');a.download='story-quest.png';a.href=cv.toDataURL('image/png');document.body.appendChild(a);a.click();a.remove();b.disabled=false;b.textContent='⤓ Save screenshot';toast('Saved.')}).catch(function(){b.disabled=false;b.textContent='⤓ Save screenshot';toast('Could not capture.')}); }; $('btnCopy').onclick=function(){ var lines=['LEGENDHAVEN · STORY QUEST',longDate(),'',seed,'','THE CAST']; S.players.forEach(function(p){lines.push('· '+(p.name||'—')+' — '+playerKicker(p)+(p.motivation?', wants to '+p.motivation:'')+(p.alive?'':' ✝ fell in battle'))}); lines.push('','THE STORY'); rows.forEach(function(e){ if(e.kind==='beat'){lines.push('',LABEL[e.type]+' · '+S.players[e.by].name+' — '+e.card.name); if(e.said)lines.push(' “'+e.said+'”'); lines.push(' '+(e.note||'(no sentence written)'))} else if(e.kind==='beast')lines.push('','⚔ '+beastSentence(e)+' in '+e.rounds+' rounds.'+(e.said?' '+e.fighter+': “'+e.said+'”':'')); else if(e.kind==='ending')lines.push('','THE ENDING',e.card.text)}); lines.push('','Survivors: '+(S.players.filter(function(p){return p.alive}).map(function(p){return p.name}).join(', ')||'none')); var t=lines.join('\n');if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(function(){toast('Copied.')},function(){prompt('Copy:',t)});else prompt('Copy:',t)}; $('btnNew').onclick=function(){localStorage.removeItem('sq.v3');S.players.forEach(function(_,i){var c=$('pc'+i);if(c)c.remove()});S=fresh();save();renderWelcome()}; save(); } /* ---------- how to play (the rules, kept in data.json) ---------- */ $('btnHow').addEventListener('click',function(){ var H=DATA.howto; $('modalBox').classList.add('wide'); modal('
Story Quest

How it is played

'+esc(H.tagline)+'

'+ '
'+H.sections.map(function(sec){ return '

'+esc(sec.h)+'

'+sec.p.map(function(t){return '

'+esc(t)+'

'}).join('')+'
'}).join('')+ '

For the host

'+ '
'); $('mClose').onclick=closeModal; }); /* ---------- settings ---------- */ /* the modal fades in and out; opening another inside the fade-out cancels the close */ var modalT=0; function modal(html){clearTimeout(modalT);var m=$('modal');$('modalBox').innerHTML=html; /* `wide` is set by the caller before this */ m.classList.remove('hidden','leaving','enter');void m.offsetWidth;if(!CALM)m.classList.add('enter')} function closeModal(){var m=$('modal');if(m.classList.contains('hidden'))return; var done=function(){m.classList.add('hidden');m.classList.remove('leaving','enter');$('modalBox').classList.remove('wide')}; if(CALM){done();return}m.classList.add('leaving');clearTimeout(modalT);modalT=setTimeout(done,230)} $('modal').addEventListener('click',function(e){if(e.target===$('modal'))closeModal()}); document.addEventListener('keydown',function(e){if(e.key==='Escape')closeModal()}); $('btnSettings').addEventListener('click',function(){ modal('
Settings

Host menu

'+ '
'+ '
'); $('mSave').onclick=function(){S.timer=Math.max(5,Math.min(120,parseInt($('sTimer').value,10)||T.timerSeconds));save();closeModal();toast('Saved.')}; $('mUndo').onclick=function(){closeModal();undo()}; $('mRestart').onclick=function(){ modal('
New quest

Are you sure? You will lose all progress.

'); $('mNo').onclick=closeModal; $('mYes').onclick=function(){clearTimer();clearInterval(fightH);fightH=null;sparkleStop();F=null;localStorage.removeItem('sq.v3');S.players.forEach(function(_,i){var c=$('pc'+i);if(c)c.remove()});removeBeastCard();closeFly();S=fresh();closeModal();renderWelcome()}; }; }); /* ---------- fit: the board scales to whatever box the page gives it ---------- */ /* BASE is the board at 1.0 — 1200x1010 is the setup screen, the tallest state, with its air. Anything smaller scales down instead of scrolling; anything bigger scales up to MAXZ. */ var BASE_W=1200, BASE_H=1010, MINZ=.5, MAXZ=1.5; function fit(){ var box=$('sqEmbed'), sq=$('sq'); if(!box||!sq)return; var cs=getComputedStyle(box); var W=box.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight); var H=box.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom); if(W<=0||H<=0)return; var z=Math.min(W/BASE_W,H/BASE_H); z=Math.max(MINZ,Math.min(MAXZ,z)); sq.style.zoom=z; sq.style.width=(W/z)+'px'; sq.style.height=(H/z)+'px'; } window.addEventListener('resize',fit); window.addEventListener('load',fit); if(window.ResizeObserver)new ResizeObserver(fit).observe($('sqEmbed')); /* the belt to that braces: the host page can resize the box without a window resize, and ResizeObserver is not everywhere. 500ms, and only when the box actually moved. */ (function(){var lw=0,lh=0;setInterval(function(){var b=$('sqEmbed');if(!b)return; if(b.clientWidth!==lw||b.clientHeight!==lh){lw=b.clientWidth;lh=b.clientHeight;fit()}},500)})(); fit(); /* ---------- hover: tilt and parallax (Munk, 2026-09-18) ---------- Any card under the pointer leans toward it, up to MAXA degrees, and its painting drifts the other way by up to PAR px -- the art already carries 6px of bleed for exactly this. One pointer listener on the board, one rAF per frame; the card eases home when the pointer leaves. Mouse and pen only, and nothing at all under prefers-reduced-motion. */ (function(){ if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches)return; var root=$('sq'),MAXA=7,PAR=5,cur=null,raf=0,lx=0,ly=0; var VARS=['--tax','--tay','--tang','--tpx','--tpy']; function reset(n){if(!n)return;n.classList.remove('tilting');VARS.forEach(function(v){n.style.removeProperty(v)})} function apply(){ raf=0;if(!cur||!cur.isConnected){cur=null;return} var r=cur.getBoundingClientRect();if(!r.width||!r.height)return; var nx=Math.max(-1,Math.min(1,(lx-r.left)/r.width*2-1)),ny=Math.max(-1,Math.min(1,(ly-r.top)/r.height*2-1)); var d=Math.min(1,Math.sqrt(nx*nx+ny*ny)),ax=-ny,ay=nx; if(d<.02){ax=0;ay=1;d=0} /* a zero axis is invalid CSS; park it */ cur.style.setProperty('--tax',ax.toFixed(3));cur.style.setProperty('--tay',ay.toFixed(3)); cur.style.setProperty('--tang',(d*MAXA).toFixed(2)+'deg'); cur.style.setProperty('--tpx',(-nx*PAR).toFixed(1)+'px');cur.style.setProperty('--tpy',(-ny*PAR).toFixed(1)+'px'); } root.addEventListener('pointermove',function(e){ if(e.pointerType==='touch')return; var t=e.target.closest?e.target.closest('.tc,.modcard'):null; if(t&&t.classList.contains('gone'))t=null; if(t!==cur){reset(cur);cur=t;if(cur)cur.classList.add('tilting')} lx=e.clientX;ly=e.clientY;if(cur&&!raf)raf=requestAnimationFrame(apply); }); root.addEventListener('pointerleave',function(){reset(cur);cur=null}); })(); /* ---------- boot ---------- */ function resume(){ S.players.forEach(function(p,i){if(p.locked||S.phase!=='welcome')placeCard(i,false)}); if(S.phase==='welcome')renderWelcome(); else if(S.phase==='table'||S.phase==='battle'){if(S.phase==='battle')S.pendingFight=true;renderTable()} else if(S.phase==='ending')renderEnding(); else renderExport(); } window.SQ_DEBUG=function(){return {S:S,F:F,endFight:endFight}}; /* test hook: state + a way to end a fight without waiting on timers */ (function(){ var saved=load(); if(saved&&saved.phase&&saved.phase!=='done'&&saved.players.length&&saved.players[0].name){ S=saved; stage('
Story Quest

A game is in progress.

'+esc(saved.players.map(function(p){return p.name}).filter(Boolean).join(', '))+' · '+(saved.phase==='welcome'?'still setting the table':saved.phase==='table'?'beat '+(saved.story.filter(function(e){return e.kind==='beat'}).length+1):saved.phase)+'

',true); $('rResume').onclick=resume; $('rDiscard').onclick=function(){localStorage.removeItem('sq.v3');S=fresh();renderWelcome()}; renderTop();renderRow(); } else {S=fresh();renderWelcome()} })(); })();