Commit 3aa049457a
Unsigned
Layout: unified · split
1mb/data.js +23 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Game data — edit this file to mod the run (floors, relics, hazards, balance). |
| 2 | 2 | // Loaded before the engine in index.html. |
| 3 | const W=12,H=12,FUEL=16 | |
| 3 | const W=12,H=12,FUEL=16,THIN_FUEL_BONUS=10 | |
| 4 | 4 | const BEAST_CALM_RADIUS=4,BEAST_ENRAGED_SPEED=2,TORCH_DRAIN_BASE=1,TORCH_DRAIN_HAZARD=2,RELIC_SLOTS_LOW=2,RELIC_SLOTS_MID=3,RELIC_SLOTS_HIGH=4,RELIC_SLOTS_RICH_BONUS=1 |
| 5 | 5 | const P={g:[5,2],b:[2,2],e:[2,9],h:[2,9],p:[2,2],i:[2,2],c:[2,2],v:[2,2],d:[2,2]} |
| 6 | 6 | const PITS=[[ |
| @@ -1022,6 +1022,28 @@ const HINT_TEXT={ | ||
| 1022 | 1022 | crack:"Cracks are fast but costly. Mask or ward makes the crossing safer.", |
| 1023 | 1023 | beast:"Fire slows pursuit. A dropped torch can buy one clean lane." |
| 1024 | 1024 | } |
| 1025 | // Side-panel legend: one line per interactable visible in the current room. | |
| 1026 | const LEGEND={ | |
| 1027 | O:"pit edge: kills unless beside a wall or carrying a torch", | |
| 1028 | T:"torch: light; stops lost steps in the dark, keeps pit edges safe, slows the beast", | |
| 1029 | M:"mask: eases dark, lets gold through the crack; fire stops slowing the beast", | |
| 1030 | S:"shrine: raises ward; from floor 3 ward stops the dark stealing steps", | |
| 1031 | r:"relic: a trade, one edge for one cost (named on pickup)", | |
| 1032 | effigy:"some relics in this room are false; a false one kills", | |
| 1033 | F:"brazier: relights or refuels your torch", | |
| 1034 | x:"crack: burns torch and gives the beast a free move", | |
| 1035 | z:"curse: raises dread (dark steals more steps) and chips ward", | |
| 1036 | n:"shade: drains torch and raises dread; in the den it draws the beast", | |
| 1037 | q:"watcher: beast pursuit jumps; costs torch", | |
| 1038 | l:"leech: eats ward and torch", | |
| 1039 | I:"idol: step on the idol, its gold, or its shrine to bow", | |
| 1040 | g:"gold: quickens the beast; fatal in the crack unless masked", | |
| 1041 | C:"crown: bow to the idol first or die; counts as gold", | |
| 1042 | K:"key: take it with the crown back to the gate", | |
| 1043 | B:"beast: kills on contact; fire and a dropped torch slow it", | |
| 1044 | dark:"dark: without a torch, dread builds and steps get lost" | |
| 1045 | } | |
| 1046 | const ROOM_NAMES={g:"gate",b:"bone room",e:"beast den",h:"hall",p:"pit room",i:"idol room",c:"crack",v:"vault",d:"black arch"} | |
| 1025 | 1047 | const RELICS={ |
| 1026 | 1048 | ember:{name:"ember heart",label:"ember",up:"slower torch drain",down:"beast ignores dropped flame"}, |
| 1027 | 1049 | tooth:{name:"gold tooth",label:"tooth",up:"gold slips the crack",down:"beast enrages sooner"}, |
1mb/index.html +44 −8
| @@ -39,7 +39,7 @@ linear-gradient(180deg,transparent,rgba(0,0,0,.12));mix-blend-mode:screen;opacit | ||
| 39 | 39 | .warded{box-shadow:inset 0 0 0 1px var(--wardGlow);background:radial-gradient(circle at 50% 50%,#ffffff08,transparent 72%)} |
| 40 | 40 | .haz{filter:brightness(1.14)} |
| 41 | 41 | .fog1{opacity:.9}.fog2{opacity:.72}.fog3{opacity:.55} |
| 42 | #g.hit{transform:translateX(2px);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px color-mix(in srgb,var(--hazard) 70%,#000)}#g.win{box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px var(--glow)}#g.shift{transform:scale(1.02)}#g.ward{animation:wardbreak .2s ease-out}#g.relic{animation:relicpulse .24s ease-out} | |
| 42 | #g.hit{transform:translateX(2px);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px color-mix(in srgb,var(--hazard) 70%,#000)}#g.win{box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px var(--glow)}#g.shift{transform:scale(1.02)}#g.ward{animation:wardbreak .2s ease-out}#g.relic{animation:relicpulse .24s ease-out}#g.dark{animation:darkstep .3s ease-out} | |
| 43 | 43 | #u.dead,#u.win,#u.camp{letter-spacing:.12em;text-transform:uppercase} |
| 44 | 44 | #s[data-floor="1"]{--bg0:#0a130d;--bg1:#020302;--panel:#08100bdd;--panelEdge:#29442e;--gridEdge:#213929;--accent:#9fe2ab;--accent2:#f0cc76;--fog:#dbf5df;--mist:#88c6911c;--glow:#9be8a055;--gridGlow:#9be8a020;--floor:#132217;--memory:#667f6a;--wall:#6c786f;--pit:#ad584a;--exit:#f3d487;--torch:#ffbf67;--shrine:#9df1df;--relic:#ffe28f;--hazard:#ff8b77;--shade:#a7c8ff;--watcher:#ffd298;--leech:#8ef0db;--beast:#ff81d9;--player:#fbfff8} |
| 45 | 45 | #s[data-floor="2"]{--bg0:#170d08;--bg1:#040202;--panel:#130b08e1;--panelEdge:#5c3520;--gridEdge:#4d2d1e;--accent:#ffb07a;--accent2:#ffd18f;--fog:#ffd9c0;--mist:#ff985415;--glow:#ff9d6260;--gridGlow:#ff9d6224;--floor:#24130d;--memory:#8a665b;--wall:#8f6d63;--pit:#dd6a55;--exit:#ffd48a;--torch:#ffaf59;--shrine:#ffd0a6;--relic:#ffe08e;--hazard:#ff7766;--shade:#d3c0ff;--watcher:#ffd0ad;--leech:#f1b68f;--beast:#ff7eb4;--player:#fff7f1} |
| @@ -53,13 +53,16 @@ linear-gradient(180deg,transparent,rgba(0,0,0,.12));mix-blend-mode:screen;opacit | ||
| 53 | 53 | #s[data-state="win"]{--accent:#f3dd8f;--panelEdge:#7a6930;--gridEdge:#6e5f2c;--mist:#f3dd8f18} |
| 54 | 54 | #s[data-state="camp"]{--accent:#c8f2dd;--panelEdge:#376050;--gridEdge:#2f5648;--mist:#8ff0cc18} |
| 55 | 55 | @media (max-width:780px){#s{justify-content:flex-start;padding:12px 10px 20px}#p{flex-direction:column;align-items:center;width:100%}#g,#i,#g.splash,#l,#u{width:min(92vw,420px);max-width:100%}#i,#g.splash{min-width:0}#g.splash{overflow:auto}#s[data-state="play"] #d{display:grid}} |
| 56 | #s.gfx{--cs:min(40px,calc((92vw - 26px)/12))}#s.gfx #g:not(.splash){grid-template-columns:repeat(12,var(--cs));gap:0}#s.gfx .t{width:var(--cs);height:var(--cs);font-size:0;text-shadow:none}.t>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.t>b{position:relative;font-size:calc(var(--cs)*.6)}.t>b.dl{font-size:calc(var(--cs)*.34);color:var(--exit);top:10%}.lg{white-space:normal;margin-bottom:10px;color:var(--muted)}.lr{display:flex;gap:6px;align-items:flex-start;color:var(--text);margin:1px 0}.lr i{flex:0 0 1.5em;height:1.5em;font-style:normal;text-align:center;color:var(--accent)}.lr i svg{width:100%;height:100%}.B.alert{--eye:#ffa8e4}.B.enraged{--eye:#ff4040} | |
| 56 | 57 | @keyframes flicker{0%,100%{opacity:1;transform:translateY(0)}25%{opacity:.75;transform:translateY(-.5px)}50%{opacity:.94}75%{opacity:.68;transform:translateY(.5px)}} |
| 57 | 58 | @keyframes pulse{0%,100%{opacity:.84;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} |
| 58 | 59 | @keyframes step{0%{transform:scale(.82)}100%{transform:scale(1)}} |
| 59 | 60 | @keyframes wardbreak{0%{filter:brightness(1.35)}100%{filter:brightness(1)}} |
| 61 | @keyframes darkstep{0%{filter:brightness(.3);transform:translateX(-3px)}35%{transform:translateX(3px)}70%{transform:translateX(-1px)}100%{filter:brightness(1);transform:none}} | |
| 60 | 62 | @keyframes relicpulse{0%{transform:scale(1)}50%{transform:scale(1.035)}100%{transform:scale(1)}} |
| 61 | 63 | </style> |
| 62 | 64 | <div id=s><div id=u></div><div id=p><div id=g></div><div id=i></div></div><div id=l></div><div id=d><button data-k=ArrowUp class=pu>^</button><button data-k=ArrowLeft class=pl><</button><button data-k=. class=pw>*</button><button data-k=ArrowRight class=pr>></button><button data-k=ArrowDown class=pd>v</button></div><button id=r hidden>restart</button></div> |
| 65 | <script src=sprites.js></script> | |
| 63 | 66 | <script src=data.js></script> |
| 64 | 67 | <script> |
| 65 | 68 | const SEl=document.getElementById("s"),U=document.getElementById("u"),G=document.getElementById("g"),I=document.getElementById("i"),L=document.getElementById("l"),X=document.getElementById("r"),D=document.getElementById("d") |
| @@ -550,6 +553,34 @@ function sidePanel(){ | ||
| 550 | 553 | return `${inv()}\n\nrun\n${statRow("title",buildTitle())}\n${statRow("route",z.route)}\n${statRow("style",z.style)}\n${statRow("floor",`${S.level} ${th.name}`)}\n${statRow("mood",th.tag)}\n${statRow("mode",MODES[S.mode||"standard"].label)}\n${statRow("mods",modSummary())}\n${statRow("turns",S.turn)}\n${statRow("beast",beastState())}` |
| 551 | 554 | } |
| 552 | 555 | |
| 556 | function exitNote(dest){ | |
| 557 | if(dest[0]=="d")return " (death)" | |
| 558 | if(dest[0]=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth")return S.level>=3&&S.flags.ward?" (gold: costs ward)":" (gold: death)" | |
| 559 | if(dest[0]=="g"&&S.flags.crown&&S.flags.key)return " (crown and key: go on)" | |
| 560 | return "" | |
| 561 | } | |
| 562 | function legendRows(){ | |
| 563 | const here={},rows=[] | |
| 564 | for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(visible(x,y)||seen(x,y))here[tile(x,y)]=1 | |
| 565 | if(S.r[0]=="e"&&visible(S.beast.x,S.beast.y))here.B=1 | |
| 566 | if(here.r&&O.effigies.some(o=>o.on&&o.r==S.r))here.effigy=1 | |
| 567 | if(dark()&&!S.flags.torch)here.dark=1 | |
| 568 | for(const k in LEGEND)if(here[k])rows.push([k,LEGEND[k]]) | |
| 569 | return rows | |
| 570 | } | |
| 571 | function exitRows(){ | |
| 572 | const ex=R[S.r].ex | |
| 573 | return Object.keys(ex).map(ch=>{const dest=ex[ch].replace("!","");return [ch,`${ROOM_NAMES[dest[0]]}${ex[ch][0]=="!"?" (one way)":""}${exitNote(dest)}`,dest]}) | |
| 574 | } | |
| 575 | function legendHTML(){ | |
| 576 | const esc=v=>v.replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"})[c]) | |
| 577 | const row=(icon,text)=>`<div class=lr><i>${icon}</i><span>${esc(text)}</span></div>` | |
| 578 | const glyph=k=>k=="effigy"?"*":k=="dark"?"":k=="B"?"B":tileGlyph(k,-1,-1,1) | |
| 579 | const here=legendRows().map(([k,t])=>row(GFX?gfxIcon(k):esc(glyph(k)),t)) | |
| 580 | const exits=exitRows().map(([ch,t,dest])=>row(GFX?gfxIcon(ch==">"?"i":dest[0]=="d"?"Dd":"D"):esc(ch),GFX&&ch!=">"?`${ch} ${t}`:t)) | |
| 581 | return `<div class=lg>${here.length?`here${here.join("")}`:""}exits${exits.join("")}<div class=lr><i></i><span>keys: t drop torch, . wait</span></div></div>` | |
| 582 | } | |
| 583 | ||
| 553 | 584 | function frame(lines,w=31){ |
| 554 | 585 | const body=lines.map(line=>{ |
| 555 | 586 | const s=(line||"").slice(0,w) |
| @@ -834,7 +865,7 @@ function touch(t,x,y){ | ||
| 834 | 865 | if(t=="O"&&!pitSafe(x,y))return die("You step into the dark and do not find the edge.") |
| 835 | 866 | if(S.r=="e"&&x==S.beast.x&&y==S.beast.y)return die("The beast tears you open.") |
| 836 | 867 | if(S.drop&&S.drop.r==S.r&&S.drop.x==x&&S.drop.y==y&&S.drop.fuel){S.flags.torch=S.drop.fuel;S.drop=null;S.flags.ash=0;return setMsg("Recover the torch.")} |
| 837 | if(t=="T"&&!S.flags.torch){S.flags.torch=FUEL;S.flags.ash=0;showHint("torch");setMsg("Take the torch.")} | |
| 868 | if(t=="T"&&!S.flags.torch){S.flags.torch=FUEL+(hasMod("thin")?THIN_FUEL_BONUS:0);S.flags.ash=0;showHint("torch");setMsg("Take the torch.")} | |
| 838 | 869 | if(t=="M"&&!S.flags.mask){S.flags.mask=1;setMsg("Take the funeral mask. Dark eases, but flame stops slowing the beast.")} |
| 839 | 870 | if(t=="S"){ |
| 840 | 871 | const wardBase=S.level>=3?8:4 |
| @@ -855,7 +886,7 @@ function touch(t,x,y){ | ||
| 855 | 886 | } |
| 856 | 887 | } |
| 857 | 888 | if(t=="F"){ |
| 858 | const cap=FUEL+(S.flags.relic=="wick"?4:0) | |
| 889 | const cap=FUEL+(S.flags.relic=="wick"?4:0)+(hasMod("thin")?THIN_FUEL_BONUS:0) | |
| 859 | 890 | const fill=S.flags.relic=="ash"?10:S.flags.relic=="wick"?8:6 |
| 860 | 891 | const gain=S.flags.relic=="ash"?6:4 |
| 861 | 892 | if(S.flags.ash||!S.flags.torch){S.flags.torch=fill;S.flags.ash=0;setMsg("Light the torch from the brazier.")} |
| @@ -923,7 +954,7 @@ function step(dx,dy){ | ||
| 923 | 954 | const nx=S.x+dx,ny=S.y+dy,t=tile(nx,ny) |
| 924 | 955 | if(t=="#")return setMsg(roomMsg()) |
| 925 | 956 | S.turn++ |
| 926 | if(shadow()){setMsg("Darkness twists the room around you.");burnTorch();beastTurn();return} | |
| 957 | if(shadow()){setMsg("Darkness twists the room around you.");S.fx="dark";burnTorch();beastTurn();return} | |
| 927 | 958 | let ex=R[S.r].ex[t] |
| 928 | 959 | if(ex){ |
| 929 | 960 | if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)} |
| @@ -969,6 +1000,7 @@ function testFloor(){ | ||
| 969 | 1000 | |
| 970 | 1001 | function key(e){ |
| 971 | 1002 | if(e.key=="m"){muted=!muted;render();return} |
| 1003 | if(e.key=="g"){gfxToggle();render();return} | |
| 972 | 1004 | if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return} |
| 973 | 1005 | if(S.gameState=="title"){ |
| 974 | 1006 | if("12345678".includes(e.key)){startRun(MODE_IDS[+e.key-1],1);render();return} |
| @@ -1020,6 +1052,7 @@ function cls(x,y){ | ||
| 1020 | 1052 | } |
| 1021 | 1053 | |
| 1022 | 1054 | function render(){ |
| 1055 | SEl.classList.toggle("gfx",!!GFX) | |
| 1023 | 1056 | U.className=S.gameState |
| 1024 | 1057 | SEl.dataset.state=S.gameState |
| 1025 | 1058 | SEl.dataset.floor=S.level||1 |
| @@ -1027,7 +1060,7 @@ function render(){ | ||
| 1027 | 1060 | if(S.gameState=="title"){ |
| 1028 | 1061 | delete G.dataset.intro |
| 1029 | 1062 | U.innerHTML=`1MB ROGUELIKE<small>single-file campaign | minimalist descent</small>` |
| 1030 | I.textContent=`modes\n1 standard ${MODES.standard.desc}\n2 iron ${MODES.iron.desc}\n3 greed ${MODES.greed.desc}\n4 dark ${MODES.dark.desc}\n5 daily ${MODES.daily.desc}\n6 heavy ${MODES.heavy.desc}\n7 swift ${MODES.swift.desc}\n8 warded ${MODES.warded.desc}\n\nkeys\n1-8 begin\nr quick run${hasSave?"\nc continue saved run":""}\nm mute sound\n\nglyphs\n@ you B beast * relic\nY shrine & brazier t torch\n! crack ^ curse Q watcher\nj leech $ gold > gate` | |
| 1063 | I.textContent=`modes\n1 standard ${MODES.standard.desc}\n2 iron ${MODES.iron.desc}\n3 greed ${MODES.greed.desc}\n4 dark ${MODES.dark.desc}\n5 daily ${MODES.daily.desc}\n6 heavy ${MODES.heavy.desc}\n7 swift ${MODES.swift.desc}\n8 warded ${MODES.warded.desc}\n\nkeys\n1-8 begin\nr quick run${hasSave?"\nc continue saved run":""}\nm mute sound\ng graphics / ascii\n\nglyphs\n@ you B beast * relic\nY shrine & brazier t torch\n! crack ^ curse Q watcher\nj leech $ gold > gate\n\nin play, the side panel explains\nevery item and exit in the room` | |
| 1031 | 1064 | L.textContent=`Pick a mode, then descend. Flame buys time; wards buy mistakes.${hasSave?" A saved run is waiting — press c to continue.":""}` |
| 1032 | 1065 | X.textContent="start standard" |
| 1033 | 1066 | X.onclick=()=>{startRun("standard",1);render()} |
| @@ -1064,8 +1097,9 @@ function render(){ | ||
| 1064 | 1097 | remember() |
| 1065 | 1098 | const th=floorTheme() |
| 1066 | 1099 | U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>` |
| 1067 | I.textContent=sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${R.e?escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail":"n/a"}\nfloor : ${R.i&&R.v&&R.e?floorSolve(seed,S.level)||"fail":"n/a"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K T`:"") | |
| 1068 | L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n") | |
| 1100 | I.innerHTML=legendHTML() | |
| 1101 | I.insertAdjacentText("beforeend",sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${R.e?escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail":"n/a"}\nfloor : ${R.i&&R.v&&R.e?floorSolve(seed,S.level)||"fail":"n/a"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K T`:"")) | |
| 1102 | L.textContent=[...new Set([S.msg,S.hint,roomMsg()])].filter(Boolean).join("\n") | |
| 1069 | 1103 | X.onclick=()=>{reset(1);render()} |
| 1070 | 1104 | X.hidden=true |
| 1071 | 1105 | if(S.intro)G.dataset.intro=INTRO_TEXT |
| @@ -1074,10 +1108,12 @@ function render(){ | ||
| 1074 | 1108 | G.className=S.fx=="hit"?"hit":S.fx=="win"?"win":S.fx=="shift"?"shift":"" |
| 1075 | 1109 | if(S.fx=="ward")G.className="ward" |
| 1076 | 1110 | if(S.fx=="relic")G.className="relic" |
| 1111 | if(S.fx=="dark"){G.className="";void G.offsetWidth;G.className="dark"} | |
| 1077 | 1112 | for(let y=0;y<H;y++)for(let x=0;x<W;x++){ |
| 1078 | 1113 | const d=document.createElement("div") |
| 1079 | 1114 | d.className="t "+cls(x,y) |
| 1080 | d.textContent=draw(x,y) | |
| 1115 | if(GFX)d.innerHTML=gfxCell(d.className,x,y,draw(x,y)) | |
| 1116 | else d.textContent=draw(x,y) | |
| 1081 | 1117 | G.appendChild(d) |
| 1082 | 1118 | } |
| 1083 | 1119 | SFX.play(S.fx) |
1mb/sprites.js added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | // Vector tile art. Symbols are 32x32 and pull colors from the floor theme's CSS variables. | |
| 2 | // Loaded after data.js; the engine calls gfxCell() per grid cell when graphics are on. | |
| 3 | const GFX_DEFS=` | |
| 4 | <linearGradient id=gF x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--floor) 62%,#46463e)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--floor) 65%,#2c2c27)"/></linearGradient> | |
| 5 | <linearGradient id=gW x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--wall) 92%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--wall) 62%,#000)"/></linearGradient> | |
| 6 | <linearGradient id=gWF x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--wall) 55%,#000)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--wall) 22%,#000)"/></linearGradient> | |
| 7 | <radialGradient id=gPit r=.75><stop offset=0 style="stop-color:color-mix(in srgb,var(--pit) 32%,#000)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--pit) 14%,#000)"/></radialGradient> | |
| 8 | <linearGradient id=gStep x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--exit) 55%,#555)"/><stop offset=1 stop-color=#050403 /></linearGradient> | |
| 9 | <radialGradient id=gFl cx=.5 cy=.7 r=.6><stop offset=0 stop-color=#fffbe0 /><stop offset=.35 stop-color=#ffd35a /><stop offset=.75 style="stop-color:var(--torch)"/><stop offset=1 stop-color=#c2381a /></radialGradient> | |
| 10 | <radialGradient id=gGlow><stop offset=0 style="stop-color:var(--torch);stop-opacity:.55"/><stop offset=1 style="stop-color:var(--torch);stop-opacity:0"/></radialGradient> | |
| 11 | <linearGradient id=gG x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#fff3b0 /><stop offset=.45 stop-color=#e8b43c /><stop offset=1 stop-color=#7d4f12 /></linearGradient> | |
| 12 | <linearGradient id=gP x1=0 y1=0 x2=1 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--player) 80%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--player) 45%,#000)"/></linearGradient> | |
| 13 | <radialGradient id=gB cx=.45 cy=.3 r=.8><stop offset=0 style="stop-color:color-mix(in srgb,var(--beast) 75%,#fff)"/><stop offset=.45 style="stop-color:color-mix(in srgb,var(--beast) 70%,#200)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--beast) 18%,#000)"/></radialGradient> | |
| 14 | <linearGradient id=gStone x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#b9b4a6 /><stop offset=1 stop-color=#4a463e /></linearGradient> | |
| 15 | <linearGradient id=gCry x1=0 y1=0 x2=1 y2=1><stop offset=0 stop-color=#fff /><stop offset=.4 style="stop-color:var(--shrine)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--shrine) 40%,#000)"/></linearGradient> | |
| 16 | <radialGradient id=gHalo><stop offset=0 style="stop-color:var(--shrine);stop-opacity:.6"/><stop offset=1 style="stop-color:var(--shrine);stop-opacity:0"/></radialGradient> | |
| 17 | <radialGradient id=gShade cx=.5 cy=.35 r=.65><stop offset=0 style="stop-color:color-mix(in srgb,var(--shade) 60%,#fff);stop-opacity:.95"/><stop offset=.7 style="stop-color:var(--shade);stop-opacity:.55"/><stop offset=1 style="stop-color:var(--shade);stop-opacity:0"/></radialGradient> | |
| 18 | <radialGradient id=gEye cx=.4 cy=.35 r=.7><stop offset=0 stop-color=#fff /><stop offset=.7 stop-color=#e8dccb /><stop offset=1 stop-color=#8d6f5e /></radialGradient> | |
| 19 | <radialGradient id=gIris><stop offset=0 style="stop-color:color-mix(in srgb,var(--watcher) 50%,#fff)"/><stop offset=.6 style="stop-color:var(--watcher)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--watcher) 35%,#000)"/></radialGradient> | |
| 20 | <linearGradient id=gL x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--leech) 75%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--leech) 30%,#000)"/></linearGradient> | |
| 21 | <linearGradient id=gMask x1=0 y1=0 x2=1 y2=1><stop offset=0 stop-color=#fbf6ea /><stop offset=.6 stop-color=#cfc6b3 /><stop offset=1 stop-color=#7d7465 /></linearGradient> | |
| 22 | <linearGradient id=gWood x1=0 y1=0 x2=1 y2=0><stop offset=0 stop-color=#8a5b33 /><stop offset=1 stop-color=#3d2412 /></linearGradient> | |
| 23 | <linearGradient id=gIron x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#8b8f94 /><stop offset=1 stop-color=#23262a /></linearGradient> | |
| 24 | <radialGradient id=gHaz><stop offset=0 style="stop-color:var(--hazard);stop-opacity:.7"/><stop offset=1 style="stop-color:var(--hazard);stop-opacity:0"/></radialGradient> | |
| 25 | <radialGradient id=gRelic><stop offset=0 stop-color=#fff /><stop offset=.5 style="stop-color:var(--relic)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--relic) 30%,#600)"/></radialGradient>` | |
| 26 | const SHADOW=`<ellipse cx=16 cy=28.5 rx=8 ry=2.5 fill=#000 opacity=.45 />` | |
| 27 | const FLAME=(x,y,s)=>`<circle cx=${x} cy=${y} r=${7*s} fill=url(#gGlow) /><path transform="translate(${x} ${y}) scale(${s})" d="M0-6C3-3 4 0 3 2.5 2 4.5-2 4.5-3 2.5-4 0-2-2 0-6Z" fill=url(#gFl) /><path transform="translate(${x} ${y+1}) scale(${s})" d="M0-2.5C1.3-1 1.6.6 1 1.6.4 2.4-.4 2.4-1 1.6-1.6.6-1.2-1 0-2.5Z" fill=#fffbe6 />` | |
| 28 | const STONES=(rs)=>rs.map(([x,y,w,h])=>`<rect x=${x} y=${y} width=${w} height=${h} rx=1.5 fill=url(#gF) stroke=#0004 stroke-width=.5 /><path d="M${x+1.5} ${y+.8}H${x+w-1.5}" stroke=#ffffff14 stroke-width=.7 />`).join("") | |
| 29 | const FLOOR_BASE=`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--floor) 70%,#16160f)"/>` | |
| 30 | const BRICKS=(y0,rows,grad)=>{let s="";for(let r=0;r<rows;r++){const y=y0+r*8,o=r%2?-6:0;for(let x=o;x<32;x+=12)s+=`<rect x=${x+.5} y=${y+.5} width=11 height=7 rx=1 fill=url(#${grad}) stroke=#0007 stroke-width=.6 /><path d="M${x+1.5} ${y+1.3}H${x+10.5}" stroke=#fff3 stroke-width=.7 />`}return s} | |
| 31 | const SPRITES={ | |
| 32 | f0:FLOOR_BASE+STONES([[1,1,14,9],[17,1,14,9],[1,12,9,8],[12,12,19,8],[1,22,18,9],[21,22,10,9]]), | |
| 33 | f1:FLOOR_BASE+STONES([[1,1,10,12],[13,1,18,6],[13,9,9,11],[24,9,7,11],[1,15,10,16],[13,22,18,9]]), | |
| 34 | f2:FLOOR_BASE+STONES([[1,1,19,8],[22,1,9,14],[1,11,9,9],[12,11,8,9],[22,17,9,14],[1,22,19,9]])+`<circle cx=11 cy=21 r=.9 fill=#0006 /><path d="M4 26l3 1 2-1.5" stroke=#0008 stroke-width=.6 fill=none />`, | |
| 35 | w:`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--wall) 30%,#000)"/>`+BRICKS(0,4,"gW"), | |
| 36 | wf:`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--wall) 20%,#000)"/>`+BRICKS(0,1,"gW")+BRICKS(8,3,"gWF")+`<rect y=8 width=32 height=2 fill=#0006 /><rect y=29 width=32 height=3 fill=#000 opacity=.55 />`, | |
| 37 | o:`<rect width=32 height=32 fill=url(#gPit) /><path d="M0 3Q8 1 16 3T32 2" stroke=#000 stroke-width=3 opacity=.5 fill=none /><circle cx=11 cy=14 r=.8 style="fill:var(--pit)" opacity=.9 /><circle cx=21 cy=19 r=.6 fill=#ffb070 opacity=.8 /><circle cx=17 cy=10 r=.5 style="fill:var(--pit)" opacity=.7 />`, | |
| 38 | i:`<rect x=4 y=3 width=24 height=26 rx=2 fill=#060504 style="stroke:var(--exit)" stroke-width=1.4 /><rect x=6 y=7 width=20 height=5 fill=url(#gStep) /><rect x=8 y=13 width=16 height=5 fill=url(#gStep) opacity=.8 /><rect x=10 y=19 width=12 height=4 fill=url(#gStep) opacity=.55 /><rect x=12 y=24 width=8 height=3 fill=url(#gStep) opacity=.3 /><path d="M5 4H27" style="stroke:var(--exit)" stroke-width=1 opacity=.8 /><rect x=4 y=3 width=24 height=26 rx=2 fill=none style="stroke:var(--exit)" stroke-width=3 opacity=.18 />`, | |
| 39 | p:SHADOW+`<path d="M22 20L26.5 13" stroke=url(#gWood) stroke-width=2 stroke-linecap=round />`+FLAME(26.8,11,.75)+`<path d="M9.5 28.5Q8.5 19 12 15H20Q23.5 19 22.5 28.5Z" fill=url(#gP) stroke=#0008 stroke-width=.7 /><path d="M16 15V28" stroke=#0004 stroke-width=.8 /><rect x=11 y=21 width=10 height=1.6 fill=#3a2716 /><rect x=15 y=20.7 width=2 height=2.2 fill=#d8b35a /><path d="M10 12.5Q10 4.5 16 4.5T22 12.5L20.5 16H11.5Z" fill=url(#gP) stroke=#0008 stroke-width=.7 /><ellipse cx=16 cy=11.5 rx=4 ry=4.2 fill=#e3b98f /><path d="M12 10Q16 5.5 20 10" style="fill:color-mix(in srgb,var(--player) 40%,#000)"/><rect x=13.8 y=11.2 width=1.3 height=1.8 rx=.5 fill=#1b1410 /><rect x=17 y=11.2 width=1.3 height=1.8 rx=.5 fill=#1b1410 /><path d="M22.5 20.5L20 22" stroke=#e3b98f stroke-width=2 stroke-linecap=round />`, | |
| 40 | B:SHADOW+`<path d="M11 11Q7 7 6 2Q10 5 13 9Z" fill=#dcd2bb stroke=#0008 stroke-width=.6 /><path d="M21 11Q25 7 26 2Q22 5 19 9Z" fill=#dcd2bb stroke=#0008 stroke-width=.6 /><path d="M4.5 28.5C3 20 7 9.5 16 9.5S29 20 27.5 28.5Z" fill=url(#gB) stroke=#000a stroke-width=.8 /><path d="M8 18Q11 16 13 19M24 18Q21 16 19 19" stroke=#0006 stroke-width=.8 fill=none /><path d="M9.5 14.5L14 16.5 13 17.8 9.5 16Z" style="fill:var(--eye,#ffe066)"/><path d="M22.5 14.5L18 16.5 19 17.8 22.5 16Z" style="fill:var(--eye,#ffe066)"/><circle cx=11.8 cy=16.2 r=3 style="fill:var(--eye,#ffe066)" opacity=.25 /><circle cx=20.2 cy=16.2 r=3 style="fill:var(--eye,#ffe066)" opacity=.25 /><path d="M10.5 22H21.5L20 25.5H12Z" fill=#1a0508 /><path d="M11 22L12.2 24 13.4 22 14.6 24 15.8 22 17 24 18.2 22 19.4 24 20.6 22" fill=#f4ecd8 /><path d="M5 28.5l1.5-2 1 2 1.5-2 1 2M22 28.5l1.5-2 1 2 1.5-2 1 2" stroke=#e8dfc8 stroke-width=.8 fill=none />`, | |
| 41 | S:`<circle cx=16 cy=13 r=13 fill=url(#gHalo) /><path d="M9 29L11 21H21L23 29Z" fill=url(#gStone) stroke=#0008 stroke-width=.6 /><rect x=10 y=19 width=12 height=2.5 rx=.5 fill=#a7a193 stroke=#0008 stroke-width=.5 /><path d="M16 3L21 11 16 19 11 11Z" fill=url(#gCry) stroke=#0006 stroke-width=.5 /><path d="M16 3L16 19M11 11H21" stroke=#fff5 stroke-width=.5 /><path d="M13.5 8L15 6" stroke=#fff stroke-width=.9 stroke-linecap=round />`, | |
| 42 | r:`<circle cx=16 cy=17 r=11 fill=url(#gGlow) opacity=.7 /><path d="M9 6Q16 14 23 6" stroke=url(#gG) stroke-width=1.2 fill=none stroke-dasharray="1.5 .8" /><circle cx=16 cy=18 r=7.5 fill=url(#gG) stroke=#5a360b stroke-width=.8 /><circle cx=16 cy=18 r=5.5 fill=none stroke=#fff4 stroke-width=.6 stroke-dasharray="1 1.2" /><circle cx=16 cy=18 r=3.6 fill=url(#gRelic) stroke=#4a2a08 stroke-width=.6 /><circle cx=14.8 cy=16.8 r=1 fill=#fff opacity=.9 /><path d="M25 7l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6Z" fill=#fff />`, | |
| 43 | x:`<circle cx=16 cy=16 r=13 fill=url(#gHaz) opacity=.6 /><path d="M4 7L10 12 8 16 14 18 13 23 19 26 22 30M14 18L20 15 24 17 28 12M10 12L7 11" stroke=#000 stroke-width=3.2 fill=none stroke-linejoin=round /><path d="M4 7L10 12 8 16 14 18 13 23 19 26 22 30M14 18L20 15 24 17 28 12" style="stroke:var(--hazard)" stroke-width=1 fill=none stroke-linejoin=round />`, | |
| 44 | z:`<circle cx=16 cy=16 r=13 fill=url(#gHaz) opacity=.5 /><circle cx=16 cy=16 r=11 fill=none style="stroke:var(--hazard)" stroke-width=1 opacity=.85 /><circle cx=16 cy=16 r=8.5 fill=none style="stroke:var(--hazard)" stroke-width=.5 stroke-dasharray="2 1.5" /><path d="M16 7.5L23.4 20.3H8.6Z" fill=none style="stroke:var(--hazard)" stroke-width=1.2 /><path d="M16 12V17M16 18.5V19.5" style="stroke:var(--hazard)" stroke-width=1.4 stroke-linecap=round />`, | |
| 45 | n:`<ellipse cx=16 cy=29 rx=6 ry=1.6 style="fill:var(--shade)" opacity=.2 /><path d="M7 26Q7 5 16 5T25 26Q23 23.5 21.5 26.5 19.5 23.5 17.5 27 16 24 14 27 12 23.5 10.5 26.5 9 23.5 7 26Z" fill=url(#gShade) /><ellipse cx=12.8 cy=13.5 rx=1.8 ry=2.6 fill=#0a0f1e /><ellipse cx=19.2 cy=13.5 rx=1.8 ry=2.6 fill=#0a0f1e /><ellipse cx=16 cy=19.5 rx=1.6 ry=2.2 fill=#0a0f1e opacity=.8 />`, | |
| 46 | q:`<ellipse cx=16 cy=29 rx=6 ry=1.8 fill=#000 opacity=.4 /><path d="M3 15Q16 1 29 15Q16 29 3 15Z" fill=url(#gEye) stroke=#2a1810 stroke-width=1 /><path d="M7 11.5Q10 13 11 15M25 11.5Q22 13 21 15M8 19Q11 17 12 16" stroke=#c0392b stroke-width=.4 fill=none opacity=.7 /><circle cx=16 cy=15 r=6.2 fill=url(#gIris) stroke=#1a0f08 stroke-width=.6 /><ellipse cx=16 cy=15 rx=1.4 ry=4.6 fill=#050303 /><circle cx=13.7 cy=12.6 r=1.4 fill=#fff opacity=.9 /><path d="M3 15Q16 1 29 15" fill=none stroke=#0006 stroke-width=2.5 />`, | |
| 47 | l:`<ellipse cx=16 cy=26 rx=11 ry=2.4 fill=#000 opacity=.4 /><path d="M4 22Q3 16 8 16Q10 13 14 14Q17 11 21 13Q26 12 28 17Q30 22 26 24H7Q4 24 4 22Z" fill=url(#gL) stroke=#0009 stroke-width=.7 /><path d="M10 16.5Q11 20 10 24M15 14.5Q16.5 19 15.5 24M21 13.5Q22 18.5 21 24" stroke=#0005 stroke-width=.8 fill=none /><path d="M9 17Q12 15 14 15.5M18 14Q21 13 24 14.5" stroke=#fff6 stroke-width=.8 fill=none stroke-linecap=round /><circle cx=26 cy=19.5 r=3.2 fill=#3a0e12 stroke=#0009 stroke-width=.5 /><path d="M24 18l.8 1.2.8-1.3.8 1.3.8-1.2M24 21l.8-1 .8 1.1.8-1.1.8 1" stroke=#f2e6cf stroke-width=.5 fill=none />`, | |
| 48 | g:`<ellipse cx=16 cy=27 rx=10 ry=2.2 fill=#000 opacity=.45 /><ellipse cx=10 cy=24 rx=5 ry=2 fill=#7d4f12 /><ellipse cx=10 cy=23 rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 /><ellipse cx=21 cy=24.5 rx=5 ry=2 fill=#7d4f12 /><ellipse cx=21 cy=23.5 rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 />`+[20,17,14,11].map(y=>`<ellipse cx=15.5 cy=${y+1} rx=5 ry=2 fill=#7d4f12 /><ellipse cx=15.5 cy=${y} rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 />`).join("")+`<ellipse cx=15.5 cy=11 rx=2.8 ry=.9 fill=none stroke=#fff7 stroke-width=.5 /><path d="M25 8l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3-1.3-.5 1.3-.5Z" fill=#fff />`, | |
| 49 | I:`<circle cx=16 cy=12 r=12 fill=url(#gGlow) opacity=.5 /><path d="M8 29H24L23 25H9Z" fill=url(#gStone) stroke=#0008 stroke-width=.6 /><path d="M11 25Q10 17 13 14H19Q22 17 21 25Z" fill=url(#gG) stroke=#5a360b stroke-width=.6 /><path d="M11.5 18Q8 17 8.5 13M20.5 18Q24 17 23.5 13" stroke=url(#gG) stroke-width=1.8 fill=none stroke-linecap=round /><circle cx=16 cy=9.5 r=5 fill=url(#gG) stroke=#5a360b stroke-width=.6 /><path d="M11 7L12.5 3 14.5 6 16 2.5 17.5 6 19.5 3 21 7" fill=url(#gG) stroke=#5a360b stroke-width=.5 /><circle cx=14.2 cy=9.8 r=.9 fill=#c4122a /><circle cx=17.8 cy=9.8 r=.9 fill=#c4122a /><path d="M14 20H18" stroke=#5a360b stroke-width=.6 />`, | |
| 50 | M:SHADOW+`<path d="M16 4C9 4 7.5 9 7.5 14 7.5 21 11.5 27 16 27S24.5 21 24.5 14C24.5 9 23 4 16 4Z" fill=url(#gMask) stroke=#3a342b stroke-width=.8 /><path d="M10 13.5Q12 11.5 14.2 13.5 12 15.3 10 13.5ZM22 13.5Q20 11.5 17.8 13.5 20 15.3 22 13.5Z" fill=#0b0907 /><path d="M16 14.5V19.5L14.8 20.5M13 23Q16 24.5 19 23" stroke=#5e564a stroke-width=.8 fill=none stroke-linecap=round /><path d="M9.5 9Q11 5.5 15 5" stroke=#fff stroke-width=1 fill=none opacity=.7 stroke-linecap=round /><path d="M7.5 13L4 12M24.5 13L28 12" stroke=#7a2e2e stroke-width=1 />`, | |
| 51 | T:`<ellipse cx=15 cy=27 rx=7 ry=1.8 fill=#000 opacity=.4 /><path d="M9 27L19 12" stroke=url(#gWood) stroke-width=3 stroke-linecap=round /><path d="M17 15.5L20.5 10.5M16 14.5L19.5 9.5" stroke=#b99b6c stroke-width=1.2 />`+FLAME(20.5,8,1), | |
| 52 | F:`<circle cx=16 cy=12 r=14 fill=url(#gGlow) /><path d="M10 28L12.5 20M22 28L19.5 20M16 29V21" stroke=url(#gIron) stroke-width=1.8 stroke-linecap=round /><path d="M6 15H26Q25 22 16 22T6 15Z" fill=url(#gIron) stroke=#0009 stroke-width=.7 /><ellipse cx=16 cy=15 rx=10 ry=2.2 fill=#1a0c05 stroke=#6a6e73 stroke-width=.7 /><ellipse cx=16 cy=15 rx=8 ry=1.4 fill=#ff7a2a opacity=.8 />`+FLAME(12.5,11,.9)+FLAME(19.5,11.5,.85)+FLAME(16,9,1.25), | |
| 53 | C:`<circle cx=16 cy=16 r=12 fill=url(#gGlow) opacity=.6 />${SHADOW}<path d="M6 24L5 10 11 15.5 16 7 21 15.5 27 10 26 24Z" fill=url(#gG) stroke=#5a360b stroke-width=.8 stroke-linejoin=round /><rect x=6 y=22 width=20 height=4 rx=1 fill=url(#gG) stroke=#5a360b stroke-width=.7 /><circle cx=5 cy=9.5 r=1.3 fill=#fff1b8 /><circle cx=16 cy=6.5 r=1.5 fill=#fff1b8 /><circle cx=27 cy=9.5 r=1.3 fill=#fff1b8 /><circle cx=11 cy=24 r=1.2 fill=#2e6bd6 /><circle cx=16 cy=24 r=1.4 fill=#c4122a /><circle cx=21 cy=24 r=1.2 fill=#1e9e5a /><path d="M8 13L8.6 20" stroke=#fff8 stroke-width=.8 />`, | |
| 54 | D:`<path d="M5 30V13Q5 3 16 3T27 13V30Z" fill=url(#gStone) stroke=#0009 stroke-width=.8 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=#050403 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=none style="stroke:var(--exit)" stroke-width=.8 opacity=.7 /><path d="M10 30H22" style="stroke:var(--exit)" stroke-width=2 opacity=.35 /><path d="M7 12Q8 6 13 4.5" stroke=#fff5 stroke-width=.8 fill=none />`, | |
| 55 | Dd:`<circle cx=16 cy=18 r=14 fill=url(#gHaz) opacity=.55 /><path d="M5 30V13Q5 3 16 3T27 13V30Z" fill=#15100f stroke=#000 stroke-width=.8 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=#000 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=none style="stroke:var(--hazard)" stroke-width=1 /><path d="M12.5 17.5a3.5 3.5 0 1 1 7 0c0 1.3-.7 2-1.2 2.4V21h-4.6v-1.1c-.5-.4-1.2-1.1-1.2-2.4Z" fill=#d8d0bf /><circle cx=14.6 cy=17.6 r=.9 fill=#000 /><circle cx=17.4 cy=17.6 r=.9 fill=#000 /><path d="M14.5 21v1M16 21v1M17.5 21v1" stroke=#000 stroke-width=.5 />`, | |
| 56 | K:`<circle cx=16 cy=16 r=11 fill=url(#gGlow) opacity=.5 /><circle cx=10 cy=12 r=5.5 fill=none stroke=#5a360b stroke-width=3.6 /><circle cx=10 cy=12 r=5.5 fill=none stroke=url(#gG) stroke-width=2.4 /><circle cx=10 cy=12 r=1.6 fill=url(#gG) /><path d="M14 16L25.5 27.5" stroke=#5a360b stroke-width=3.4 stroke-linecap=round /><path d="M14 16L25.5 27.5" stroke=url(#gG) stroke-width=2 stroke-linecap=round /><path d="M21.5 23.5L19 26M24 26L22 28" stroke=url(#gG) stroke-width=2.2 stroke-linecap=round /><path d="M7 9.5Q8.5 8 10.5 8" stroke=#fff stroke-width=.8 fill=none opacity=.8 />` | |
| 57 | } | |
| 58 | // Grid-cell kinds from cls() that sit on top of a floor tile. | |
| 59 | const GFX_ON_FLOOR="pBiSrxznqlgIMTFCK" | |
| 60 | let GFX=1 | |
| 61 | try{GFX=localStorage.getItem("mrl_gfx")!=="0"}catch(e){} | |
| 62 | document.getElementById("s").insertAdjacentHTML("afterbegin",`<svg width=0 height=0 style=position:absolute aria-hidden=true><defs>${GFX_DEFS}${Object.entries(SPRITES).map(([k,v])=>`<symbol id=s-${k} viewBox="0 0 32 32">${v}</symbol>`).join("")}</defs></svg>`) | |
| 63 | function gfxToggle(){GFX=!GFX;try{localStorage.setItem("mrl_gfx",GFX?"1":"0")}catch(e){}} | |
| 64 | function gfxIcon(k){return k=="dark"?"":`<svg viewBox="0 0 32 32"><use href="#s-${k=="effigy"?"r":k=="O"?"o":k}"/></svg>`} | |
| 65 | function gfxCell(c,x,y,glyph){ | |
| 66 | const k=c.split(" ")[1] | |
| 67 | if(!k||k=="u")return "" | |
| 68 | const use=id=>`<svg viewBox="0 0 32 32"><use href="#s-${id}"/></svg>` | |
| 69 | if(k=="w")return use(y==H-1||tile(x,y+1)=="#"?"w":"wf") | |
| 70 | if(k=="o")return use("o") | |
| 71 | const fl=use("f"+((x*7+y*13)%3)) | |
| 72 | if(k=="f")return fl | |
| 73 | if(k!="i"&&R[S.r]&&k in R[S.r].ex)return fl+use(R[S.r].ex[k].replace("!","")[0]=="d"?"Dd":"D")+`<b class=dl>${glyph}</b>` | |
| 74 | return fl+(GFX_ON_FLOOR.includes(k)?use(k):`<b>${glyph}</b>`) | |
| 75 | } | |
README.org +16 −4
| @@ -17,11 +17,13 @@ controls: | ||
| 17 | 17 | - space or . wait a turn |
| 18 | 18 | - t drop torch |
| 19 | 19 | - m mute / unmute |
| 20 | - g graphics / ascii tiles | |
| 20 | 21 | - r restart |
| 21 | 22 | - c continue a saved run (title screen) |
| 22 | 23 | #+end_example |
| 23 | 24 | |
| 24 | on mobile an on-screen d-pad appears. | |
| 25 | on mobile an on-screen d-pad appears. the side panel lists what | |
| 26 | every item and exit in the current room does. | |
| 25 | 27 | |
| 26 | 28 | #+begin_example |
| 27 | 29 | modes (press a number on the title screen): |
| @@ -31,17 +33,27 @@ modes (press a number on the title screen): | ||
| 31 | 33 | |
| 32 | 34 | win once and a hidden seventh floor opens. |
| 33 | 35 | |
| 34 | budget: the game is the two raw files in 1mb/, no build step, and | |
| 36 | budget: the game is the raw files in 1mb/, no build step, and | |
| 35 | 37 | together they stay under 1,048,576 bytes (1 MiB). tools/check.sh is |
| 36 | 38 | the gate; it prints the byte ledger and proves every escape room |
| 37 | stays winnable across hundreds of seeds. ledger history in | |
| 39 | stays winnable across hundreds of seeds, and that every item | |
| 40 | that can appear has a side-panel explanation. ledger history in | |
| 38 | 41 | docs/SIZES.txt. |
| 39 | 42 | |
| 40 | 43 | #+begin_src sh |
| 41 | 44 | tools/check.sh |
| 42 | 45 | #+end_src |
| 43 | 46 | |
| 44 | run it before each commit: | |
| 47 | tools/playthrough.js plays full runs (floors 1-7, camps, finale) | |
| 48 | through the real engine and replays each win through the key handler. | |
| 49 | it takes 1-3 minutes per seed, so it is not part of check.sh: | |
| 50 | ||
| 51 | #+begin_src sh | |
| 52 | node tools/playthrough.js 20 | |
| 53 | node tools/playthrough.js --seeds 8q10qn | |
| 54 | #+end_src | |
| 55 | ||
| 56 | run check.sh before each commit: | |
| 45 | 57 | |
| 46 | 58 | #+begin_src sh |
| 47 | 59 | ln -s ../../tools/pre-commit .git/hooks/pre-commit |
docs/SIZES.txt +5 −4
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | Raw bytes of everything in `1mb/`, against the 1,048,576-byte (1 MiB) budget. |
| 4 | 4 | Record a row after each phase of work; `tools/check-size.sh` prints the current numbers. |
| 5 | 5 | |
| 6 | | Phase | Date | index.html | data.js | Total | % of budget | | |
| 7 | |---|---|---:|---:|---:|---:| | |
| 8 | | Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | 82,240 | 7.8% | | |
| 9 | | Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | 90,354 | 8.6% | | |
| 6 | | Phase | Date | index.html | data.js | sprites.js | Total | % of budget | | |
| 7 | |---|---|---:|---:|---:|---:|---:| | |
| 8 | | Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | — | 82,240 | 7.8% | | |
| 9 | | Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | — | 90,354 | 8.6% | | |
| 10 | | Graphics — vector tiles, room legend, dark-step cue, thin-flame fuel | 2026-09-27 | 64,262 | 30,359 | 17,943 | 112,564 | 10.7% | | |
tools/engine.js added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | "use strict" | |
| 2 | // Loads the real, unmodified 1MB game into a stubbed browser environment: | |
| 3 | // sprites.js, data.js, then the inline engine script from index.html. | |
| 4 | // Returns the vm context; engine globals (S, R, O, step, build, ...) live in it. | |
| 5 | const fs = require("fs") | |
| 6 | const path = require("path") | |
| 7 | const vm = require("vm") | |
| 8 | ||
| 9 | function load(gameDir) { | |
| 10 | const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8") | |
| 11 | const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8") | |
| 12 | const spriteSrc = fs.readFileSync(path.join(gameDir, "sprites.js"), "utf8") | |
| 13 | ||
| 14 | // The engine is the inline <script> block that follows the data.js include. | |
| 15 | const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/) | |
| 16 | if (!m) { | |
| 17 | console.error("engine: could not find the inline engine <script> after the data.js include in index.html") | |
| 18 | process.exit(2) | |
| 19 | } | |
| 20 | const engineSrc = m[1] | |
| 21 | ||
| 22 | // Minimal inert DOM node — enough surface for the engine's load-time render(). | |
| 23 | function el() { | |
| 24 | return { | |
| 25 | dataset: {}, | |
| 26 | textContent: "", | |
| 27 | innerHTML: "", | |
| 28 | className: "", | |
| 29 | hidden: false, | |
| 30 | appendChild() {}, | |
| 31 | onclick: null, | |
| 32 | classList: { toggle() {} }, | |
| 33 | insertAdjacentHTML() {}, | |
| 34 | insertAdjacentText() {}, | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | const sandbox = { | |
| 39 | document: { | |
| 40 | getElementById: () => el(), | |
| 41 | createElement: () => el(), | |
| 42 | }, | |
| 43 | addEventListener() {}, | |
| 44 | localStorage: { | |
| 45 | _mem: Object.create(null), | |
| 46 | getItem(k) { return k in this._mem ? this._mem[k] : null }, | |
| 47 | setItem(k, v) { this._mem[k] = String(v) }, | |
| 48 | removeItem(k) { delete this._mem[k] }, | |
| 49 | }, | |
| 50 | window: { AudioContext: function () { throw new Error("SFX must not run headless") } }, | |
| 51 | console, | |
| 52 | } | |
| 53 | const ctx = vm.createContext(sandbox) | |
| 54 | ||
| 55 | try { | |
| 56 | vm.runInContext(spriteSrc, ctx, { filename: "1mb/sprites.js" }) | |
| 57 | vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" }) | |
| 58 | vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" }) | |
| 59 | } catch (e) { | |
| 60 | console.error("engine: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.") | |
| 61 | console.error(e && e.stack || e) | |
| 62 | process.exit(2) | |
| 63 | } | |
| 64 | return ctx | |
| 65 | } | |
| 66 | ||
| 67 | module.exports = { load } | |
tools/playthrough.js added +167
| @@ -0,0 +1,167 @@ | ||
| 1 | #!/usr/bin/env node | |
| 2 | // Headless playthrough for the 1MB roguelike. | |
| 3 | // Usage: node tools/playthrough.js [seedCount] [mode] [gameDir] | |
| 4 | // node tools/playthrough.js --seeds <base36,...> [mode] [gameDir] | |
| 5 | // | |
| 6 | // Plays full runs through the real engine: floors 1-5 with a camp choice | |
| 7 | // between each, the finale (floor 6), then the postgame floor 7. Each floor | |
| 8 | // is split into goals (torch, bow, crown + key into the beast den, leave) | |
| 9 | // and each goal is a best-first search over real game states (most torch | |
| 10 | // first), keeping the best end state per flag combination and backtracking | |
| 11 | // when a later goal fails. Every winning | |
| 12 | // input sequence is then replayed from a fresh run through key(), the real | |
| 13 | // input handler, and must end in a win on floor 7. | |
| 14 | "use strict" | |
| 15 | const path = require("path") | |
| 16 | const vm = require("vm") | |
| 17 | ||
| 18 | const only = process.argv[2] == "--seeds" ? process.argv.splice(2, 2)[1].split(",").map(v => parseInt(v, 36)) : null | |
| 19 | const seedCount = only ? only.length : Math.max(1, parseInt(process.argv[2], 10) || 50) | |
| 20 | const mode = process.argv[3] || "standard" | |
| 21 | const gameDir = process.argv[4] || path.join(__dirname, "..", "1mb") | |
| 22 | const ctx = require("./engine").load(gameDir) | |
| 23 | ||
| 24 | vm.runInContext(` | |
| 25 | const BOT_ACTS={U:()=>step(0,-1),D:()=>step(0,1),L:()=>step(-1,0),R:()=>step(1,0),".":()=>wait(),t:()=>dropTorch()} | |
| 26 | const BOT_KEYS={U:"ArrowUp",D:"ArrowDown",L:"ArrowLeft",R:"ArrowRight",".":".",t:"t"} | |
| 27 | const LEG_LIMIT=2000000 | |
| 28 | let botBudget=0,botDeep=[0,0] | |
| 29 | const GOAL_NAMES=["torch","bow","crown and key into the den","leave"] | |
| 30 | function botBits(){let b="";for(const k in O)for(const o of O[k])b+=o.on?1:0;return b} | |
| 31 | // Position, flags, overlays, and the turn parity and beast only where they act; torch, ward, dread and chase are compared by dominance. | |
| 32 | function botKey(){const f=S.flags,d=S.drop;return [S.r,S.x,S.y,dark()&&!f.torch?S.turn&3:"",S.gameState,S.level,!!f.torch,f.ash,f.idol,f.gold,f.crown,f.key,f.mask,f.relic,f.relicPlus,S.r=="e"?S.beast.x+","+S.beast.y:"",d?[d.r,d.x,d.y,d.fuel].join():"",botBits()].join("|")} | |
| 33 | function botRes(){return [S.flags.torch,S.flags.ward,-S.dread,-S.chase]} | |
| 34 | // Records the state; false when a recorded state with the same key is at least as good in every resource. | |
| 35 | function botNew(seen){ | |
| 36 | const k=botKey(),v=botRes(),list=seen.get(k) | |
| 37 | if(!list){seen.set(k,[v]);return true} | |
| 38 | if(list.some(o=>o.every((x,i)=>x>=v[i])))return false | |
| 39 | list.push(v);return true | |
| 40 | } | |
| 41 | function botSnap(){return JSON.stringify(S)+"\\u0000"+botBits()} | |
| 42 | function botRestore(p,world){ | |
| 43 | R=world.R;O=world.O;V=world.V | |
| 44 | const i=p.lastIndexOf("\\u0000"),b=p.slice(i+1) | |
| 45 | S=JSON.parse(p.slice(0,i)) | |
| 46 | let j=0;for(const k in O)for(const o of O[k])o.on=b[j++]=="1" | |
| 47 | } | |
| 48 | function botFull(){return JSON.stringify({S,R,O,V})} | |
| 49 | function botLoad(f){const x=JSON.parse(f);S=x.S;R=x.R;O=x.O;V=x.V} | |
| 50 | // Distinct end states by route-relevant flags, keeping the most torch for each. | |
| 51 | function botSig(){const f=S.flags;return [S.gameState,S.level,S.r,f.gold,f.mask,f.ward>0,f.relic].join()} | |
| 52 | function botLeg(goal,last){ | |
| 53 | if(goal())return [[botFull(),""]] | |
| 54 | const world={R,O,V},start=botSnap(),seen=new Map(),buckets=[],ends=new Map() | |
| 55 | const push=(p,path)=>{const t=Math.min(S.flags.torch,63);(buckets[t]=buckets[t]||{h:0,a:[]}).a.push([p,path])} | |
| 56 | botNew(seen);push(start,"") | |
| 57 | let n=0 | |
| 58 | for(;;){ | |
| 59 | let b=buckets.length-1;while(b>=0&&!(buckets[b]&&buckets[b].h<buckets[b].a.length))b-- | |
| 60 | if(b<0||n>=LEG_LIMIT)break | |
| 61 | const bk=buckets[b],[p,path]=bk.a[bk.h];bk.a[bk.h++]=null | |
| 62 | for(const a in BOT_ACTS){ | |
| 63 | botRestore(p,world) | |
| 64 | if((a=="."||a=="t")&&S.r!="e")continue | |
| 65 | BOT_ACTS[a]();n++ | |
| 66 | if(!S.alive||S.gameState=="dead")continue | |
| 67 | if(!botNew(seen))continue | |
| 68 | // Only the last goal (the escape) may wander the beast den. | |
| 69 | const hit=goal() | |
| 70 | if(!last&&!hit&&S.r=="e")continue | |
| 71 | if(hit){ | |
| 72 | const g=botSig(),e=ends.get(g) | |
| 73 | if(!e||S.flags.torch>e[2])ends.set(g,[botFull(),path+a,S.flags.torch]) | |
| 74 | continue | |
| 75 | } | |
| 76 | push(botSnap(),path+a) | |
| 77 | } | |
| 78 | } | |
| 79 | botBudget-=n | |
| 80 | botRestore(start,world) | |
| 81 | return [...ends.values()].sort((a,b)=>b[2]-a[2]) | |
| 82 | } | |
| 83 | function botGoals(L){ | |
| 84 | const done=()=>S.gameState!="play"||S.level!=L | |
| 85 | return L==6?[done]:[()=>S.flags.torch>0,()=>S.flags.idol,()=>S.flags.crown&&S.flags.key&&(S.r=="e"||!R.e),done] | |
| 86 | } | |
| 87 | function botSolve(goals,i,cont){ | |
| 88 | if(botBudget<0)return null | |
| 89 | if(i==goals.length)return cont() | |
| 90 | const at=[S.level,goals.length==1?3:i];if(at[0]>botDeep[0]||at[0]==botDeep[0]&&at[1]>botDeep[1])botDeep=at | |
| 91 | for(const [f,path] of botLeg(goals[i],i==goals.length-1)){ | |
| 92 | botLoad(f) | |
| 93 | const rest=botSolve(goals,i+1,cont) | |
| 94 | if(rest!=null)return path+rest | |
| 95 | } | |
| 96 | return null | |
| 97 | } | |
| 98 | function botRun(){ | |
| 99 | const L=S.level | |
| 100 | return botSolve(botGoals(L),0,()=>{ | |
| 101 | if(S.gameState=="win"){ | |
| 102 | if(S.level>=7)return "" | |
| 103 | startPostgame() | |
| 104 | const r=botRun() | |
| 105 | return r==null?null:"|p"+r | |
| 106 | } | |
| 107 | if(S.gameState=="camp"){ | |
| 108 | const f=botFull() | |
| 109 | for(const c of "1423"){ | |
| 110 | botLoad(f);applyCampChoice(c) | |
| 111 | const r=botRun() | |
| 112 | if(r!=null)return "|"+c+r | |
| 113 | } | |
| 114 | return null | |
| 115 | } | |
| 116 | if(S.level==6&&S.gameState=="play")return botRun() | |
| 117 | return null | |
| 118 | }) | |
| 119 | } | |
| 120 | function botPlay(s,mode,budget){ | |
| 121 | seed=s;startRun(mode,0);botBudget=budget;botDeep=[0,0] | |
| 122 | const moves=botRun() | |
| 123 | return {moves,left:botBudget,stuck:"floor "+botDeep[0]+" "+GOAL_NAMES[botDeep[1]]} | |
| 124 | } | |
| 125 | function botReplay(s,mode,moves){ | |
| 126 | muted=1 | |
| 127 | seed=s;startRun(mode,0) | |
| 128 | for(let i=0;i<moves.length;i++){ | |
| 129 | const c=moves[i] | |
| 130 | if(c=="|"){key({key:moves[++i]});continue} | |
| 131 | key({key:BOT_KEYS[c]}) | |
| 132 | } | |
| 133 | return {state:S.gameState,level:S.level,turns:moves.replace(/\\|./g,"").length} | |
| 134 | } | |
| 135 | `, ctx, { filename: "playthrough-bot" }) | |
| 136 | ||
| 137 | const seeds = only || JSON.parse(vm.runInContext( | |
| 138 | `JSON.stringify((() => { const r = rng(1), out = []; for (let i = 0; i < ${seedCount}; i++) out.push(Math.floor(r() * 1e9)); return out })())`, | |
| 139 | ctx, { filename: "playthrough-seeds" })) | |
| 140 | ||
| 141 | const t0 = Date.now() | |
| 142 | const fails = [] | |
| 143 | let totalMoves = 0 | |
| 144 | for (const s of seeds) { | |
| 145 | const res = JSON.parse(vm.runInContext(`JSON.stringify(botPlay(${s}, ${JSON.stringify(mode)}, 30000000))`, ctx)) | |
| 146 | if (res.moves == null) { | |
| 147 | fails.push({ seed: s, why: `${res.left < 0 ? "search budget exhausted" : "no winning line found"}, deepest: ${res.stuck}` }) | |
| 148 | console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`) | |
| 149 | continue | |
| 150 | } | |
| 151 | const rep = JSON.parse(vm.runInContext(`JSON.stringify(botReplay(${s}, ${JSON.stringify(mode)}, ${JSON.stringify(res.moves)}))`, ctx)) | |
| 152 | if (rep.state != "win" || rep.level != 7) { | |
| 153 | fails.push({ seed: s, why: `replay ended ${rep.state} on floor ${rep.level}` }) | |
| 154 | console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`) | |
| 155 | continue | |
| 156 | } | |
| 157 | totalMoves += rep.turns | |
| 158 | console.log(` seed ${s.toString(36)}: won in ${rep.turns} moves`) | |
| 159 | } | |
| 160 | const elapsed = ((Date.now() - t0) / 1000).toFixed(1) | |
| 161 | const won = seeds.length - fails.length | |
| 162 | if (fails.length) { | |
| 163 | console.error(`playthrough: ${fails.length}/${seeds.length} ${mode} runs NOT WON (${elapsed}s)`) | |
| 164 | for (const f of fails.slice(0, 20)) console.error(` seed ${f.seed.toString(36)}: ${f.why}`) | |
| 165 | process.exit(1) | |
| 166 | } | |
| 167 | console.log(`playthrough: OK — ${won}/${seeds.length} ${mode} runs won floors 1-7 and replayed through key() (avg ${Math.round(totalMoves / won)} moves, ${elapsed}s)`) | |
tools/validate.js +30 −50
| @@ -8,62 +8,13 @@ | ||
| 8 | 8 | // each full floor completable (gate -> torch -> bow -> vault -> escape |
| 9 | 9 | // room -> gate) — the same check the in-game validateSeeds() runs. |
| 10 | 10 | "use strict" |
| 11 | const fs = require("fs") | |
| 12 | 11 | const path = require("path") |
| 13 | 12 | const vm = require("vm") |
| 14 | 13 | |
| 15 | 14 | const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200) |
| 16 | 15 | const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb") |
| 17 | 16 | |
| 18 | const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8") | |
| 19 | const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8") | |
| 20 | ||
| 21 | // The engine is the inline <script> block that follows the data.js include. | |
| 22 | const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/) | |
| 23 | if (!m) { | |
| 24 | console.error("validate: could not find the inline engine <script> after the data.js include in index.html") | |
| 25 | process.exit(2) | |
| 26 | } | |
| 27 | const engineSrc = m[1] | |
| 28 | ||
| 29 | // Minimal inert DOM node — enough surface for the engine's load-time render(). | |
| 30 | function el() { | |
| 31 | return { | |
| 32 | dataset: {}, | |
| 33 | textContent: "", | |
| 34 | innerHTML: "", | |
| 35 | className: "", | |
| 36 | hidden: false, | |
| 37 | appendChild() {}, | |
| 38 | onclick: null, | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | const sandbox = { | |
| 43 | document: { | |
| 44 | getElementById: () => el(), | |
| 45 | createElement: () => el(), | |
| 46 | }, | |
| 47 | addEventListener() {}, | |
| 48 | localStorage: { | |
| 49 | _mem: Object.create(null), | |
| 50 | getItem(k) { return k in this._mem ? this._mem[k] : null }, | |
| 51 | setItem(k, v) { this._mem[k] = String(v) }, | |
| 52 | removeItem(k) { delete this._mem[k] }, | |
| 53 | }, | |
| 54 | window: { AudioContext: function () { throw new Error("SFX must not run headless") } }, | |
| 55 | console, | |
| 56 | } | |
| 57 | const ctx = vm.createContext(sandbox) | |
| 58 | ||
| 59 | try { | |
| 60 | vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" }) | |
| 61 | vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" }) | |
| 62 | } catch (e) { | |
| 63 | console.error("validate: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.") | |
| 64 | console.error(e && e.stack || e) | |
| 65 | process.exit(2) | |
| 66 | } | |
| 17 | const ctx = require("./engine").load(gameDir) | |
| 67 | 18 | |
| 68 | 19 | // Runs in the same context, so it sees the engine's top-level let/const |
| 69 | 20 | // bindings (S, V) and functions (build, pickMods, escapeSolve) directly. |
| @@ -102,3 +53,32 @@ if (fails.length) { | ||
| 102 | 53 | process.exit(1) |
| 103 | 54 | } |
| 104 | 55 | console.log(`validate: OK — ${checks} full floors solvable (${seedCount} seeds x floors 1-7, ${elapsed}s)`) |
| 56 | ||
| 57 | // Every interactable that can appear on floors 1-7 needs a LEGEND line and | |
| 58 | // every exit destination a ROOM_NAMES entry. Room d kills on entry, so its | |
| 59 | // contents are never shown. | |
| 60 | const missing = JSON.parse(vm.runInContext(`JSON.stringify((() => { | |
| 61 | const miss = {} | |
| 62 | for (const s0 of ${seedsJson}) { | |
| 63 | for (const level of [1, 2, 3, 4, 5, 6, 7]) { | |
| 64 | S = { level, mods: pickMods(s0), flags: {}, drop: null, seen: {} } | |
| 65 | build(s0) | |
| 66 | for (const r in R) { | |
| 67 | if (r[0] == "d") continue | |
| 68 | for (const ch in R[r].ex) if (!ROOM_NAMES[R[r].ex[ch].replace("!", "")[0]]) miss["exit " + r + ":" + ch] = level | |
| 69 | for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { | |
| 70 | const t = tile(x, y, r) | |
| 71 | if (t != "." && t != "#" && t != ">" && !(t in R[r].ex) && !LEGEND[t]) miss["tile " + t] = level | |
| 72 | } | |
| 73 | if (O.effigies.some(o => o.r == r) && !LEGEND.effigy) miss.effigy = level | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | return miss | |
| 78 | })())`, ctx, { filename: "validate-legend" })) | |
| 79 | const gaps = Object.keys(missing) | |
| 80 | if (gaps.length) { | |
| 81 | console.error(`validate: ${gaps.length} interactables without a legend entry: ${gaps.map(g => `${g} (floor ${missing[g]})`).join(", ")}`) | |
| 82 | process.exit(1) | |
| 83 | } | |
| 84 | console.log("validate: OK — every interactable has a legend entry") | |