The Pages deployment is now the primary copy, so two things had to change: - Hosted pages fall back to steam-sync.py on 127.0.0.1:8765 instead of refusing to sync. steam-sync.py now answers Chrome's Private Network Access preflight (Access-Control-Allow-Private-Network), which is what a public https page needs before it may call into localhost. - The build sources lived in /tmp and would have been lost. They now live in build/, and build.py outputs to the repo root. deploy.sh rebuilds, commits and pushes in one step. Editing index.html directly is no longer the workflow - edit build/ and run ./deploy.sh.
1582 lines
85 KiB
HTML
1582 lines
85 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>la-li-lu-le-lo — MGS2 Master Collection 100% Companion</title>
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏷️</text></svg>">
|
||
<script>
|
||
/* apply the saved theme before first paint so there is no flash */
|
||
try{
|
||
var _t=localStorage.getItem('mgs2-theme');
|
||
if(_t && _t!=='codec') document.documentElement.setAttribute('data-theme',_t);
|
||
}catch(e){}
|
||
</script>
|
||
<style>
|
||
:root{
|
||
/* ---- default theme: Codec ---- */
|
||
--bg:#070c11; --bg2:#0b131a; --panel:#101c26; --panel2:#15242f; --panel3:#1a2c39;
|
||
--line:#1d3542; --line2:#2a4a5b;
|
||
--txt:#dbe9f2; --dim:#84a2b4; --dim2:#5d7b8c;
|
||
--acc:#2fe0c0; --acc-d:#0f8a76; --acc2:#4aa8ff; --warn:#ffc857; --miss:#ff6b81; --ok:#54e08a;
|
||
--on-acc:#04120f; --bright:#ffffff; --field:#0a141b;
|
||
--glow1:#10303a; --glow2:#14243d; --overlay:rgba(3,7,11,.96); --steam:#57cbde;
|
||
--k-tag:#2fe0c0; --k-item:#c79bff; --k-ach:#8fd8ee; --k-area:#7fb2d9; --k-extra:#ff9ecb;
|
||
--k-miss:var(--warn); --k-boss:var(--miss); --k-mode:var(--ok);
|
||
--map-bg:#ffffff;
|
||
--shadow:0 4px 18px rgba(0,0,0,.45);
|
||
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;
|
||
}
|
||
|
||
/* ================= THEMES ================= */html[data-theme="gruvbox"]{
|
||
--bg:#1d2021; --bg2:#282828; --panel:#282828; --panel2:#32302f; --panel3:#3c3836;
|
||
--line:#3c3836; --line2:#504945;
|
||
--txt:#ebdbb2; --dim:#a89984; --dim2:#7c6f64;
|
||
--acc:#b8bb26; --acc-d:#79740e; --acc2:#83a598; --warn:#fabd2f; --miss:#fb4934; --ok:#8ec07c;
|
||
--on-acc:#1d2021; --bright:#fbf1c7; --field:#1d2021;
|
||
--glow1:#3c3836; --glow2:#2b2b1f; --overlay:rgba(29,32,33,.96); --steam:#83a598;
|
||
--k-tag:#8ec07c; --k-item:#d3869b; --k-ach:#83a598; --k-area:#a89984; --k-extra:#d65d0e;
|
||
}
|
||
html[data-theme="monokai"]{
|
||
--bg:#1e1f1c; --bg2:#272822; --panel:#272822; --panel2:#2f3029; --panel3:#3b3c33;
|
||
--line:#3b3c33; --line2:#4e4f45;
|
||
--txt:#f8f8f2; --dim:#a6a28c; --dim2:#75715e;
|
||
--acc:#a6e22e; --acc-d:#6b8f1a; --acc2:#66d9ef; --warn:#e6db74; --miss:#f92672; --ok:#a6e22e;
|
||
--on-acc:#1e1f1c; --bright:#ffffff; --field:#1e1f1c;
|
||
--glow1:#2f3a1e; --glow2:#33202b; --overlay:rgba(30,31,28,.96); --steam:#66d9ef;
|
||
--k-tag:#a6e22e; --k-item:#ae81ff; --k-ach:#66d9ef; --k-area:#a6a28c; --k-extra:#fd971f;
|
||
}
|
||
html[data-theme="solarized-dark"]{
|
||
--bg:#00212b; --bg2:#002b36; --panel:#002b36; --panel2:#073642; --panel3:#0b4452;
|
||
--line:#0f4b57; --line2:#1a6472;
|
||
--txt:#93a1a1; --dim:#839496; --dim2:#657b83;
|
||
--acc:#2aa198; --acc-d:#1a6b64; --acc2:#268bd2; --warn:#b58900; --miss:#dc322f; --ok:#859900;
|
||
--on-acc:#002b36; --bright:#eee8d5; --field:#00252f;
|
||
--glow1:#073642; --glow2:#00283a; --overlay:rgba(0,33,43,.96); --steam:#268bd2;
|
||
--k-tag:#2aa198; --k-item:#6c71c4; --k-ach:#268bd2; --k-area:#839496; --k-extra:#d33682;
|
||
}
|
||
html[data-theme="dracula"]{
|
||
--bg:#191a21; --bg2:#21222c; --panel:#282a36; --panel2:#343746; --panel3:#3d4055;
|
||
--line:#3d4055; --line2:#535680;
|
||
--txt:#f8f8f2; --dim:#b8bcd0; --dim2:#6272a4;
|
||
--acc:#50fa7b; --acc-d:#2f9e4c; --acc2:#8be9fd; --warn:#f1fa8c; --miss:#ff5555; --ok:#50fa7b;
|
||
--on-acc:#191a21; --bright:#ffffff; --field:#1e1f29;
|
||
--glow1:#2b2d5c; --glow2:#3a1f36; --overlay:rgba(25,26,33,.96); --steam:#8be9fd;
|
||
--k-tag:#50fa7b; --k-item:#bd93f9; --k-ach:#8be9fd; --k-area:#b8bcd0; --k-extra:#ff79c6;
|
||
}
|
||
html[data-theme="nord"]{
|
||
--bg:#242933; --bg2:#2e3440; --panel:#2e3440; --panel2:#3b4252; --panel3:#434c5e;
|
||
--line:#434c5e; --line2:#4c566a;
|
||
--txt:#d8dee9; --dim:#9aa5b5; --dim2:#6d7a8c;
|
||
--acc:#88c0d0; --acc-d:#4e8a99; --acc2:#81a1c1; --warn:#ebcb8b; --miss:#bf616a; --ok:#a3be8c;
|
||
--on-acc:#2e3440; --bright:#eceff4; --field:#272c36;
|
||
--glow1:#3b4252; --glow2:#2b3a4a; --overlay:rgba(36,41,51,.96); --steam:#88c0d0;
|
||
--k-tag:#8fbcbb; --k-item:#b48ead; --k-ach:#81a1c1; --k-area:#9aa5b5; --k-extra:#d08770;
|
||
}
|
||
html[data-theme="tokyo-night"]{
|
||
--bg:#16161e; --bg2:#1a1b26; --panel:#1f2335; --panel2:#24283b; --panel3:#2f3549;
|
||
--line:#2f3549; --line2:#3b4261;
|
||
--txt:#c0caf5; --dim:#9aa5ce; --dim2:#565f89;
|
||
--acc:#7aa2f7; --acc-d:#3d5fa8; --acc2:#7dcfff; --warn:#e0af68; --miss:#f7768e; --ok:#9ece6a;
|
||
--on-acc:#16161e; --bright:#ffffff; --field:#1a1b26;
|
||
--glow1:#24283b; --glow2:#2a1f3d; --overlay:rgba(22,22,30,.96); --steam:#7dcfff;
|
||
--k-tag:#9ece6a; --k-item:#bb9af7; --k-ach:#7dcfff; --k-area:#9aa5ce; --k-extra:#ff9e64;
|
||
}
|
||
html[data-theme="catppuccin"]{
|
||
--bg:#181825; --bg2:#1e1e2e; --panel:#1e1e2e; --panel2:#313244; --panel3:#3b3d52;
|
||
--line:#313244; --line2:#45475a;
|
||
--txt:#cdd6f4; --dim:#a6adc8; --dim2:#6c7086;
|
||
--acc:#89b4fa; --acc-d:#4a7fd4; --acc2:#89dceb; --warn:#f9e2af; --miss:#f38ba8; --ok:#a6e3a1;
|
||
--on-acc:#1e1e2e; --bright:#ffffff; --field:#181825;
|
||
--glow1:#313244; --glow2:#2a2440; --overlay:rgba(24,24,37,.96); --steam:#89dceb;
|
||
--k-tag:#94e2d5; --k-item:#cba6f7; --k-ach:#89b4fa; --k-area:#a6adc8; --k-extra:#fab387;
|
||
}
|
||
html[data-theme="one-dark"]{
|
||
--bg:#21252b; --bg2:#282c34; --panel:#282c34; --panel2:#2c313a; --panel3:#353b45;
|
||
--line:#353b45; --line2:#3e4451;
|
||
--txt:#abb2bf; --dim:#8b93a1; --dim2:#5c6370;
|
||
--acc:#61afef; --acc-d:#2f6ea8; --acc2:#56b6c2; --warn:#e5c07b; --miss:#e06c75; --ok:#98c379;
|
||
--on-acc:#21252b; --bright:#ffffff; --field:#23272e;
|
||
--glow1:#2c313a; --glow2:#2b2f45; --overlay:rgba(33,37,43,.96); --steam:#56b6c2;
|
||
--k-tag:#98c379; --k-item:#c678dd; --k-ach:#61afef; --k-area:#8b93a1; --k-extra:#d19a66;
|
||
}
|
||
html[data-theme="rose-pine"]{
|
||
--bg:#191724; --bg2:#1f1d2e; --panel:#1f1d2e; --panel2:#26233a; --panel3:#302d44;
|
||
--line:#26233a; --line2:#403d52;
|
||
--txt:#e0def4; --dim:#908caa; --dim2:#6e6a86;
|
||
--acc:#ebbcba; --acc-d:#a86f6d; --acc2:#9ccfd8; --warn:#f6c177; --miss:#eb6f92; --ok:#31748f;
|
||
--on-acc:#191724; --bright:#ffffff; --field:#1f1d2e;
|
||
--glow1:#26233a; --glow2:#2a2033; --overlay:rgba(25,23,36,.96); --steam:#9ccfd8;
|
||
--k-tag:#9ccfd8; --k-item:#c4a7e7; --k-ach:#ebbcba; --k-area:#908caa; --k-extra:#f6c177;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;padding:0}
|
||
body{
|
||
background:
|
||
radial-gradient(1200px 600px at 15% -10%, var(--glow1) 0%, transparent 60%),
|
||
radial-gradient(900px 500px at 90% 0%, var(--glow2) 0%, transparent 55%),
|
||
var(--bg);
|
||
color:var(--txt);
|
||
font:15px/1.55 "Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
|
||
-webkit-font-smoothing:antialiased;
|
||
min-height:100vh;
|
||
}
|
||
a{color:var(--acc2)}
|
||
h1,h2,h3,h4{line-height:1.25;margin:0 0 .4em}
|
||
code,kbd{font-family:var(--mono);font-size:.86em;background:var(--field);border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--acc)}
|
||
|
||
/* ---------- HEADER ---------- */
|
||
header{
|
||
position:sticky;top:0;z-index:50;
|
||
background:linear-gradient(180deg,rgba(8,16,22,.98),rgba(8,16,22,.93));
|
||
backdrop-filter:blur(8px);
|
||
border-bottom:1px solid var(--line);
|
||
}
|
||
.hwrap{max-width:1400px;margin:0 auto;padding:10px 16px 0}
|
||
.htop{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
|
||
.brand{display:flex;align-items:baseline;gap:10px;flex:1 1 320px;min-width:0}
|
||
.brand .tag{
|
||
font:700 11px/1 var(--mono);letter-spacing:.16em;color:var(--on-acc);background:var(--acc);
|
||
padding:5px 7px;border-radius:4px;text-transform:uppercase;white-space:nowrap
|
||
}
|
||
.brand h1{font-size:17px;margin:0;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.brand small{color:var(--dim);font-size:11.5px;font-family:var(--mono)}
|
||
.hbtns{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||
select#themeSel{font:600 12px/1 var(--mono);background:var(--panel2);color:var(--txt);
|
||
border:1px solid var(--line2);padding:7px 8px;border-radius:6px;cursor:pointer}
|
||
select#themeSel:hover{border-color:var(--acc-d);color:var(--bright)}
|
||
button,.btn{
|
||
font:600 12px/1 var(--mono);letter-spacing:.03em;
|
||
background:var(--panel2);color:var(--txt);border:1px solid var(--line2);
|
||
padding:7px 10px;border-radius:6px;cursor:pointer;transition:.14s;
|
||
}
|
||
button:hover,.btn:hover{background:var(--panel3);border-color:var(--acc-d);color:var(--bright)}
|
||
button.primary{background:var(--acc);color:var(--on-acc);border-color:var(--acc)}
|
||
button.primary:hover{background:color-mix(in srgb,var(--acc) 82%,white)}
|
||
button.danger:hover{border-color:var(--miss);color:var(--miss)}
|
||
|
||
/* stat strip */
|
||
.stats{display:flex;gap:16px;flex-wrap:wrap;padding:8px 0 6px;align-items:center}
|
||
.stat{display:flex;align-items:center;gap:8px;font-size:12px;font-family:var(--mono);color:var(--dim);white-space:nowrap}
|
||
.stat b{color:var(--txt);font-size:13px}
|
||
.bar{width:130px;height:6px;background:var(--field);border:1px solid var(--line);border-radius:99px;overflow:hidden}
|
||
.bar>i{display:block;height:100%;background:linear-gradient(90deg,var(--acc-d),var(--acc));width:0;transition:width .3s}
|
||
.bar.blue>i{background:linear-gradient(90deg,color-mix(in srgb,var(--acc2) 55%,black),var(--acc2))}
|
||
.bar.gold>i{background:linear-gradient(90deg,color-mix(in srgb,var(--warn) 55%,black),var(--warn))}
|
||
#saveflag{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
|
||
|
||
/* tabs */
|
||
nav.tabs{display:flex;gap:2px;overflow-x:auto;padding:6px 0 0;scrollbar-width:thin}
|
||
nav.tabs button{
|
||
border:1px solid transparent;border-bottom:none;background:transparent;border-radius:7px 7px 0 0;
|
||
color:var(--dim);padding:9px 13px;font-size:12.5px;white-space:nowrap
|
||
}
|
||
nav.tabs button:hover{color:var(--txt);background:rgba(255,255,255,.04)}
|
||
nav.tabs button.on{background:var(--panel);border-color:var(--line);color:var(--acc);box-shadow:0 -2px 0 var(--acc) inset}
|
||
|
||
/* ---------- LAYOUT ---------- */
|
||
main{max-width:1400px;margin:0 auto;padding:18px 16px 80px}
|
||
section.tab{display:none;animation:fade .18s ease}
|
||
section.tab.on{display:block}
|
||
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
|
||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:0 0 16px;box-shadow:var(--shadow)}
|
||
.panel.tight{padding:12px 14px}
|
||
.panel h2{font-size:16px;color:var(--bright);display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
||
.panel h2 .pill{font:600 10px var(--mono)}
|
||
.panel h3{font-size:13.5px;color:var(--acc);font-family:var(--mono);letter-spacing:.05em;text-transform:uppercase;margin-top:16px}
|
||
.panel p{color:var(--txt);margin:.5em 0}
|
||
.muted{color:var(--dim)}
|
||
.grid{display:grid;gap:14px}
|
||
@media(min-width:900px){.grid.g2{grid-template-columns:1fr 1fr}.grid.g3{grid-template-columns:repeat(3,1fr)}}
|
||
.pill{display:inline-block;padding:2px 7px;border-radius:99px;border:1px solid var(--line2);font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
|
||
.pill.miss{color:var(--miss);border-color:color-mix(in srgb,var(--miss) 42%,transparent);background:color-mix(in srgb,var(--miss) 15%,transparent)}
|
||
.pill.story{color:var(--acc2);border-color:color-mix(in srgb,var(--acc2) 42%,transparent);background:color-mix(in srgb,var(--acc2) 15%,transparent)}
|
||
.pill.coll{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 42%,transparent);background:color-mix(in srgb,var(--warn) 15%,transparent)}
|
||
.pill.chal{color:var(--k-item);border-color:color-mix(in srgb,var(--k-item) 42%,transparent);background:color-mix(in srgb,var(--k-item) 15%,transparent)}
|
||
.pill.mode{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 42%,transparent);background:color-mix(in srgb,var(--ok) 15%,transparent)}
|
||
.pill.extra{color:var(--k-extra);border-color:color-mix(in srgb,var(--k-extra) 42%,transparent);background:color-mix(in srgb,var(--k-extra) 15%,transparent)}
|
||
.pill.steam{color:var(--steam);border-color:color-mix(in srgb,var(--steam) 42%,transparent);background:color-mix(in srgb,var(--steam) 15%,transparent);font-weight:700}
|
||
.ach.steam{border-left:3px solid var(--steam)}
|
||
.ach.steam .aname{color:var(--steam)}
|
||
#steamdot{display:inline-block;width:7px;height:7px;border-radius:99px;background:var(--dim2);margin-right:5px;vertical-align:1px}
|
||
#steamdot.on{background:var(--steam);box-shadow:0 0 7px var(--steam)}
|
||
#steamdot.err{background:var(--miss)}
|
||
.syncrow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0}
|
||
.syncrow input{flex:1 1 240px;max-width:340px}
|
||
.status{font:12px var(--mono);color:var(--dim);padding:8px 11px;border-radius:7px;background:var(--field);border:1px solid var(--line);margin:8px 0}
|
||
.status.ok{color:var(--txt);border-color:color-mix(in srgb,var(--steam) 45%,transparent)}
|
||
.status.bad{color:var(--miss);border-color:color-mix(in srgb,var(--miss) 45%,transparent);background:color-mix(in srgb,var(--miss) 12%,transparent)}
|
||
.steamgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px;margin-top:10px}
|
||
.smini{display:flex;gap:7px;align-items:center;padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);font-size:11.5px}
|
||
.smini img{width:22px;height:22px;border-radius:4px;flex:0 0 22px}
|
||
.smini.have{border-color:color-mix(in srgb,var(--steam) 45%,transparent);background:color-mix(in srgb,var(--steam) 12%,transparent)}
|
||
.smini.no{opacity:.5}
|
||
.smini .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.warnbox{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;color:color-mix(in srgb,var(--warn) 30%,var(--txt));font-size:13.5px}
|
||
.infobox{border-left:3px solid var(--acc2);background:color-mix(in srgb,var(--acc2) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;font-size:13.5px;color:color-mix(in srgb,var(--acc2) 30%,var(--txt))}
|
||
.okbox{border-left:3px solid var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;font-size:13.5px;color:color-mix(in srgb,var(--ok) 30%,var(--txt))}
|
||
ul.tight{margin:.4em 0;padding-left:20px}
|
||
ul.tight li{margin:.22em 0}
|
||
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:.6em 0}
|
||
th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--line)}
|
||
th{color:var(--dim);font:600 11px var(--mono);letter-spacing:.06em;text-transform:uppercase}
|
||
td.n{font-family:var(--mono);color:var(--acc)}
|
||
|
||
/* ---------- CONTROLS ---------- */
|
||
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 14px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||
.chip{
|
||
font:600 11.5px var(--mono);padding:6px 10px;border-radius:99px;cursor:pointer;
|
||
background:var(--panel2);border:1px solid var(--line);color:var(--dim)
|
||
}
|
||
.chip:hover{color:var(--txt);border-color:var(--line2)}
|
||
.chip.on{background:var(--acc);border-color:var(--acc);color:var(--on-acc)}
|
||
.chip .c{opacity:.75;font-size:10.5px}
|
||
input[type=search],input[type=text]{
|
||
background:var(--field);border:1px solid var(--line);color:var(--txt);border-radius:7px;
|
||
padding:8px 11px;font:13px var(--mono);min-width:210px;flex:1 1 210px;max-width:340px
|
||
}
|
||
input[type=search]:focus,input[type=text]:focus{outline:none;border-color:var(--acc-d)}
|
||
label.sw{display:flex;align-items:center;gap:7px;font:12px var(--mono);color:var(--dim);cursor:pointer;user-select:none}
|
||
label.sw input{accent-color:var(--acc);width:15px;height:15px}
|
||
|
||
/* ---------- ACHIEVEMENT CARDS ---------- */
|
||
.ach{
|
||
background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:9px;
|
||
transition:.16s;overflow:hidden
|
||
}
|
||
.ach:hover{border-color:var(--line2)}
|
||
.ach.done{opacity:.62;border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 8%,transparent)}
|
||
.ach.done .aname{text-decoration:line-through;text-decoration-color:var(--ok);color:var(--dim)}
|
||
.achhead{display:flex;gap:12px;align-items:flex-start;padding:11px 13px;cursor:pointer}
|
||
.achhead input[type=checkbox]{
|
||
appearance:none;width:21px;height:21px;flex:0 0 21px;margin-top:2px;border:1.5px solid var(--line2);
|
||
border-radius:5px;background:var(--field);cursor:pointer;position:relative;transition:.14s
|
||
}
|
||
.achhead input[type=checkbox]:checked{background:var(--ok);border-color:var(--ok)}
|
||
.achhead input[type=checkbox]:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 14px/1 sans-serif}
|
||
.achhead input[type=checkbox]:hover{border-color:var(--acc)}
|
||
.icon{width:42px;height:42px;flex:0 0 42px;border-radius:7px;object-fit:cover;border:1px solid var(--line);background:var(--field)}
|
||
.icon.ph{display:grid;place-items:center;font:700 17px var(--mono);color:var(--acc);background:linear-gradient(140deg,var(--panel3),var(--field))}
|
||
.ameta{flex:1;min-width:0}
|
||
.aname{font-weight:650;font-size:14.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.adesc{color:var(--dim);font-size:12.8px;margin-top:1px}
|
||
.aright{display:flex;flex-direction:column;align-items:flex-end;gap:5px;flex:0 0 auto}
|
||
.caret{color:var(--dim2);font-size:11px;font-family:var(--mono)}
|
||
.achbody{display:none;padding:2px 15px 15px 66px;border-top:1px solid var(--line);margin-top:0}
|
||
.ach.open .achbody{display:block}
|
||
.achbody>*:first-child{margin-top:10px}
|
||
.shots{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
|
||
.shots a{display:block;line-height:0}
|
||
.shots img{
|
||
width:200px;height:auto;border-radius:7px;border:1px solid var(--line2);
|
||
background:var(--field);transition:.15s;cursor:zoom-in
|
||
}
|
||
.shots img:hover{border-color:var(--acc);transform:translateY(-1px)}
|
||
.shots .cap{font:10.5px var(--mono);color:var(--dim2);margin-top:-6px}
|
||
.shots img{cursor:zoom-in}
|
||
|
||
/* ---------- LIGHTBOX ---------- */
|
||
#lightbox{position:fixed;inset:0;z-index:300;background:var(--overlay);
|
||
display:flex;flex-direction:column;backdrop-filter:blur(5px)}
|
||
#lightbox[hidden]{display:none}
|
||
.lbbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:9px 14px;
|
||
border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--panel) 92%,transparent)}
|
||
#lbCap{font:12px var(--mono);color:var(--txt);flex:1 1 260px;min-width:0;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
#lbStage{flex:1;overflow:auto;display:flex;align-items:center;justify-content:center;
|
||
padding:16px;scrollbar-width:thin}
|
||
#lbStage.zoomed{align-items:flex-start;justify-content:flex-start;cursor:grab}
|
||
#lbStage.zoomed.dragging{cursor:grabbing}
|
||
#lbImg{max-width:100%;max-height:100%;display:block;border-radius:4px;
|
||
box-shadow:0 14px 50px rgba(0,0,0,.75);background:var(--field)}
|
||
#lbStage.zoomed #lbImg{max-width:none;max-height:none;cursor:zoom-out}
|
||
.lbnav{position:absolute;top:50%;transform:translateY(-50%);z-index:2;
|
||
font:600 22px/1 var(--mono);padding:12px 15px;background:color-mix(in srgb,var(--panel2) 92%,transparent);
|
||
border:1px solid var(--line2);border-radius:8px}
|
||
.lbnav.prev{left:12px}.lbnav.next{right:12px}
|
||
#lbStage img:not([src]){display:none}
|
||
|
||
/* ---------- ROADMAP / WALKTHROUGH COLOUR CODE ---------- */
|
||
.sbody b,.achbody b{font-weight:650}
|
||
.k-tag{color:var(--k-tag);background:color-mix(in srgb,var(--k-tag) 13%,transparent);padding:0 3px;border-radius:3px}
|
||
.k-item{color:var(--k-item);background:color-mix(in srgb,var(--k-item) 13%,transparent);padding:0 3px;border-radius:3px}
|
||
.k-miss{color:var(--k-miss);background:color-mix(in srgb,var(--k-miss) 14%,transparent);border-bottom:1px solid color-mix(in srgb,var(--k-miss) 50%,transparent);padding:0 3px;border-radius:3px}
|
||
.k-ach{color:var(--k-ach)}
|
||
.k-boss{color:var(--k-boss);background:color-mix(in srgb,var(--k-boss) 13%,transparent);padding:0 3px;border-radius:3px}
|
||
.k-area{color:var(--k-area)}
|
||
.k-mode{color:var(--k-mode)}
|
||
.key{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;margin:12px 0 0;font:11.5px var(--mono);color:var(--dim)}
|
||
.key b{font-weight:650}
|
||
|
||
/* ---------- PER-STEP SUB-CHECKLISTS ---------- */
|
||
.subs{margin:15px 0 2px;border-top:1px dashed var(--line2);padding-top:11px}
|
||
.subhead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:9px}
|
||
.subtitle{font:600 11.5px var(--mono);color:var(--dim);letter-spacing:.06em;text-transform:uppercase}
|
||
.sublist{display:grid;gap:5px;grid-template-columns:repeat(auto-fill,minmax(255px,1fr))}
|
||
.sub{display:flex;gap:8px;align-items:flex-start;padding:5px 9px;border:1px solid var(--line);
|
||
border-radius:6px;background:var(--panel2);cursor:pointer;transition:.13s}
|
||
.sub:hover{border-color:var(--line2);background:var(--panel3)}
|
||
.sub input{appearance:none;width:16px;height:16px;flex:0 0 16px;margin-top:2px;border:1.5px solid var(--line2);
|
||
border-radius:4px;background:var(--field);cursor:pointer;position:relative}
|
||
.sub input:checked{background:var(--ok);border-color:var(--ok)}
|
||
.sub input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;
|
||
color:var(--on-acc);font:800 11px/1 sans-serif}
|
||
.sub span{display:flex;flex-direction:column;min-width:0}
|
||
.sub b{font-size:12.5px;font-weight:600;line-height:1.3}
|
||
.sub em{font:10.5px var(--mono);color:var(--dim2);font-style:normal;margin-top:1px}
|
||
.sub.on-done{opacity:.55}
|
||
.sub.on-done b{text-decoration:line-through;text-decoration-color:var(--ok)}
|
||
.sub.tag b{color:var(--k-tag)}
|
||
.sub.ach b{color:var(--k-ach)}
|
||
.sub.ach.miss b{color:var(--k-miss)}
|
||
.sub.item b{color:var(--k-item)}
|
||
|
||
/* ---------- DOG TAGS ---------- */
|
||
.dgrid{display:grid;gap:6px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}
|
||
.dt{
|
||
display:flex;gap:9px;align-items:flex-start;padding:7px 10px;border:1px solid var(--line);
|
||
border-radius:7px;background:var(--panel);cursor:pointer;transition:.13s
|
||
}
|
||
.dt:hover{border-color:var(--line2);background:var(--panel2)}
|
||
.dt.done{background:color-mix(in srgb,var(--ok) 8%,transparent);border-color:color-mix(in srgb,var(--ok) 40%,transparent);opacity:.65}
|
||
.dt.done .dname{text-decoration:line-through;text-decoration-color:var(--ok);color:var(--dim)}
|
||
.dt input{appearance:none;width:17px;height:17px;flex:0 0 17px;margin-top:2px;border:1.5px solid var(--line2);border-radius:4px;background:var(--field);cursor:pointer;position:relative}
|
||
.dt input:checked{background:var(--ok);border-color:var(--ok)}
|
||
.dt input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 11px/1 sans-serif}
|
||
.dname{font-size:13.2px;font-weight:600;line-height:1.3}
|
||
.darea{font:11px var(--mono);color:var(--acc2);margin-top:1px}
|
||
.dnote{font:11px var(--mono);color:var(--warn);margin-top:3px;line-height:1.35}
|
||
.cond{font:9px var(--mono);color:var(--on-acc);background:var(--warn);padding:1px 5px;border-radius:3px;margin-left:6px;vertical-align:1px}
|
||
|
||
/* threshold meter */
|
||
.thr{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
|
||
.thrcard{flex:1 1 190px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px}
|
||
.thrcard .lbl{font:11px var(--mono);color:var(--dim);display:flex;justify-content:space-between}
|
||
.thrcard .val{font:700 19px var(--mono);color:var(--txt);margin:2px 0 4px}
|
||
.thrcard .val small{font-size:12px;color:var(--dim);font-weight:400}
|
||
.thrcard.hit{border-color:var(--ok);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||
.thrcard.hit .val{color:var(--ok)}
|
||
|
||
/* ---------- MAPS ---------- */
|
||
.mapctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0 0 10px}
|
||
.mapctl button{padding:6px 9px}
|
||
.mapwrap{
|
||
overflow:auto;max-height:72vh;border:1px solid var(--line2);border-radius:9px;
|
||
background:var(--map-bg);position:relative;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)
|
||
}
|
||
#mapCanvas{display:block}
|
||
.legend{display:flex;flex-wrap:wrap;gap:9px 16px;margin-top:12px;font:11.5px var(--mono);color:var(--dim)}
|
||
.legend span{display:flex;align-items:center;gap:6px}
|
||
.swatch{width:13px;height:13px;border-radius:3px;border:1px solid rgba(255,255,255,.28);display:inline-block;flex:0 0 13px}
|
||
.maptip{font:11.5px var(--mono);color:var(--dim2);margin-top:8px}
|
||
|
||
/* ---------- ROADMAP ---------- */
|
||
.stop{border:1px solid var(--line);border-radius:9px;background:var(--panel);margin-bottom:10px;overflow:hidden}
|
||
.stop .sh{display:flex;gap:12px;align-items:flex-start;padding:11px 13px;cursor:pointer}
|
||
.stop.done{opacity:.62;border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 8%,transparent)}
|
||
.stop.done .stitle{text-decoration:line-through;text-decoration-color:var(--ok)}
|
||
.snum{
|
||
flex:0 0 30px;height:30px;border-radius:7px;background:var(--panel3);border:1px solid var(--line2);
|
||
display:grid;place-items:center;font:700 12px var(--mono);color:var(--acc)
|
||
}
|
||
.stop.done .snum{background:var(--ok);color:var(--on-acc);border-color:var(--ok)}
|
||
.stitle{font-weight:650;font-size:14px}
|
||
.sarea{font:11px var(--mono);color:var(--acc2);margin-top:1px}
|
||
.sbody{display:none;padding:2px 14px 14px 55px;border-top:1px solid var(--line)}
|
||
.stop.open .sbody{display:block}
|
||
.stop .sh input{appearance:none;width:20px;height:20px;flex:0 0 20px;margin-top:4px;border:1.5px solid var(--line2);border-radius:5px;background:var(--field);cursor:pointer;position:relative}
|
||
.stop .sh input:checked{background:var(--ok);border-color:var(--ok)}
|
||
.stop .sh input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 13px/1 sans-serif}
|
||
|
||
.phase{margin:22px 0 10px;display:flex;align-items:center;gap:12px}
|
||
.phase h2{margin:0}
|
||
.phase:after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
|
||
|
||
.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 14px;font-size:13.5px;margin:.5em 0}
|
||
.kv dt{font:600 11.5px var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.05em;padding-top:2px}
|
||
.kv dd{margin:0}
|
||
footer{max-width:1400px;margin:0 auto;padding:0 16px 50px;color:var(--dim2);font-size:12px}
|
||
.srcline{font-size:12.5px;color:var(--dim);margin:3px 0}
|
||
.toast{
|
||
position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);opacity:0;pointer-events:none;
|
||
background:var(--acc);color:var(--on-acc);font:600 12.5px var(--mono);padding:9px 16px;border-radius:8px;
|
||
transition:.25s;z-index:200;box-shadow:0 6px 24px rgba(0,0,0,.5)
|
||
}
|
||
.toast.on{opacity:1;transform:translate(-50%,0)}
|
||
::selection{background:var(--acc-d);color:var(--on-acc)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="hwrap">
|
||
<div class="htop">
|
||
<div class="brand">
|
||
<span class="tag">la-li-lu-le-lo</span>
|
||
<div style="min-width:0">
|
||
<h1>MGS2 Master Collection — 100% Companion</h1>
|
||
<small>METAL GEAR SOLID 2: Sons of Liberty — Master Collection · 51 achievements</small>
|
||
</div>
|
||
</div>
|
||
<div class="hbtns">
|
||
<button id="btnSteam"><span id="steamdot"></span>Steam</button>
|
||
<select id="themeSel" title="Colour theme" aria-label="Colour theme"></select>
|
||
<button id="btnHide">Hide done: off</button>
|
||
<button id="btnExport">Export</button>
|
||
<button id="btnImport">Import</button>
|
||
<button id="btnReset" class="danger">Reset</button>
|
||
<input type="file" id="fileIn" accept=".json" hidden>
|
||
</div>
|
||
</div>
|
||
<div class="stats">
|
||
<span class="stat">ACH <b id="sAch">0/51</b><span class="bar"><i id="bAch"></i></span></span>
|
||
<span class="stat">TAGS <b id="sTag">0/394</b><span class="bar blue"><i id="bTag"></i></span></span>
|
||
<span class="stat">ROADMAP <b id="sRoad">0/0</b><span class="bar gold"><i id="bRoad"></i></span></span>
|
||
<span id="saveflag">saved locally</span>
|
||
</div>
|
||
<nav class="tabs" id="tabs"></nav>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="tab" id="tab-roadmap"></section>
|
||
<section class="tab" id="tab-ach"></section>
|
||
<section class="tab" id="tab-tags"></section>
|
||
<section class="tab" id="tab-maps"></section>
|
||
<section class="tab" id="tab-boss"></section>
|
||
<section class="tab" id="tab-steam"></section>
|
||
<section class="tab" id="tab-miss"></section>
|
||
<section class="tab" id="tab-vr"></section>
|
||
<section class="tab" id="tab-src"></section>
|
||
</main>
|
||
|
||
<footer>
|
||
<p>Unofficial fan-made companion for personal use. Metal Gear Solid 2: Sons of Liberty — Master Collection Version © Konami.
|
||
Achievement icons and screenshots are hot-linked from their original sources (Steam Community, Video Chums, Dayngl's Guides);
|
||
progress is stored only in this browser's local storage.</p>
|
||
</footer>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<div id="lightbox" hidden>
|
||
<div class="lbbar">
|
||
<span id="lbCap"></span>
|
||
<button id="lbZoom" title="toggle between fit-to-screen and full resolution">zoom 100%</button>
|
||
<a id="lbOpen" class="btn" href="#" target="_blank" rel="noopener">open original ↗</a>
|
||
<button id="lbClose">✕ close (Esc)</button>
|
||
</div>
|
||
<div id="lbStage"><img id="lbImg" alt="" referrerpolicy="no-referrer"></div>
|
||
<button class="lbnav prev" id="lbPrev" aria-label="previous image">‹</button>
|
||
<button class="lbnav next" id="lbNext" aria-label="next image">›</button>
|
||
</div>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
DATA
|
||
============================================================ */
|
||
const ACHIEVEMENTS = /*__ACHIEVEMENTS__*/[];
|
||
const ROADMAP = /*__ROADMAP__*/[];
|
||
const DOGTAGS = /*__DOGTAGS__*/[];
|
||
const MAPS = /*__MAPS__*/[];
|
||
const TOTALS = /*__TOTALS__*/{};
|
||
const BIGBOSS = /*__BIGBOSS__*/{};
|
||
|
||
/* ============================================================
|
||
STATE
|
||
============================================================ */
|
||
const KEY = 'mgs2mc-guide-v1';
|
||
/* Optional: a Steam vanity name or SteamID64 to sync on first load.
|
||
Left empty in the public repo - type your own on the Steam Sync tab instead.
|
||
(Anyone still running a local copy keeps their saved profile in localStorage.) */
|
||
const DEFAULT_PROFILE = '';
|
||
/* Live Steam sync needs a server (Steam sends no CORS headers). Leave this empty when running
|
||
locally next to steam-sync.py. If you host this page on GitHub Pages / Netlify / Cloudflare,
|
||
deploy steam-proxy-worker.js and paste its URL here to get sync back. */
|
||
const STEAM_PROXY = '';
|
||
const IS_LOCAL = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
|
||
let S = {ach:{}, tags:{}, road:{}, items:{}, hideDone:false, tab:'roadmap', roadDiff:'Very Easy',
|
||
steam:{profile:'',id64:'',persona:'',unlocked:{},lastSync:0}};
|
||
|
||
function load(){
|
||
try{
|
||
const raw = localStorage.getItem(KEY);
|
||
if(raw){
|
||
const p = JSON.parse(raw);
|
||
S = Object.assign(S, p);
|
||
S.ach = S.ach||{}; S.tags = S.tags||{}; S.road = S.road||{}; S.items = S.items||{};
|
||
S.steam = Object.assign({profile:'',id64:'',persona:'',unlocked:{},lastSync:0}, S.steam||{});
|
||
S.tab = S.tab||'roadmap';
|
||
S.roadDiff = S.roadDiff||'Very Easy';
|
||
}
|
||
}catch(e){ console.warn('load failed', e); }
|
||
}
|
||
let saveTimer=null;
|
||
function save(){
|
||
try{
|
||
localStorage.setItem(KEY, JSON.stringify(S));
|
||
const f=document.getElementById('saveflag');
|
||
f.textContent='saved ✓'; f.style.color='var(--ok)';
|
||
clearTimeout(saveTimer);
|
||
saveTimer=setTimeout(()=>{f.textContent='saved locally'; f.style.color='';},1400);
|
||
}catch(e){
|
||
const f=document.getElementById('saveflag');
|
||
f.textContent='⚠ storage blocked — use Export'; f.style.color='var(--miss)';
|
||
}
|
||
}
|
||
function toast(msg){
|
||
const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('on');
|
||
clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('on'),1600);
|
||
}
|
||
|
||
/* ============================================================
|
||
PROGRESS
|
||
============================================================ */
|
||
const ACH_TOTAL = ACHIEVEMENTS.length;
|
||
const TAG_TOTAL = DOGTAGS.reduce((a,l)=>a+l.tags.length,0);
|
||
const ROAD_TOTAL= ROADMAP.reduce((a,p)=>a+p.steps.length,0);
|
||
const achDone = ()=>ACHIEVEMENTS.filter(a=>S.ach[a.id]).length;
|
||
const tagDone = ()=>DOGTAGS.reduce((a,l)=>a+l.tags.filter((_,i)=>S.tags[l.id+':'+i]).length,0);
|
||
const roadDone= ()=>ROADMAP.reduce((a,p)=>a+p.steps.filter(s=>S.road[s.id]).length,0);
|
||
|
||
function paintProgress(){
|
||
const a=achDone(), t=tagDone(), r=roadDone();
|
||
document.getElementById('sAch').textContent = a+'/'+ACH_TOTAL;
|
||
document.getElementById('bAch').style.width = (a/ACH_TOTAL*100)+'%';
|
||
document.getElementById('sTag').textContent = t+'/'+TAG_TOTAL;
|
||
document.getElementById('bTag').style.width = (t/TAG_TOTAL*100)+'%';
|
||
document.getElementById('sRoad').textContent = r+'/'+ROAD_TOTAL;
|
||
document.getElementById('bRoad').style.width = (r/ROAD_TOTAL*100)+'%';
|
||
}
|
||
|
||
/* ============================================================
|
||
TABS
|
||
============================================================ */
|
||
const TABS=[
|
||
['roadmap','🗺️ Roadmap'],
|
||
['ach','🏆 Achievements'],
|
||
['tags','🏷️ Dog Tags'],
|
||
['maps','🗺️ Area Maps'],
|
||
['steam','🔄 Steam Sync'],
|
||
['boss','💀 Big Boss Run'],
|
||
['miss','⚠️ Missables'],
|
||
['vr','🕹️ VR / Extra Modes'],
|
||
['src','📚 Sources & Notes'],
|
||
];
|
||
function buildTabs(){
|
||
const n=document.getElementById('tabs');
|
||
n.innerHTML = TABS.map(([id,label])=>`<button data-tab="${id}">${label}</button>`).join('');
|
||
n.addEventListener('click',e=>{
|
||
const b=e.target.closest('button[data-tab]'); if(!b) return;
|
||
go(b.dataset.tab);
|
||
});
|
||
}
|
||
function go(id){
|
||
S.tab=id; save();
|
||
document.querySelectorAll('section.tab').forEach(s=>s.classList.toggle('on', s.id==='tab-'+id));
|
||
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on', b.dataset.tab===id));
|
||
if(id==='maps') setTimeout(drawMap,0); // canvas is only painted while visible
|
||
window.scrollTo({top:0,behavior:'instant'});
|
||
}
|
||
const esc = s => String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
const normName = s => String(s).toLowerCase().replace(/[^a-z0-9]/g,'');
|
||
|
||
/* In-game screenshots, attached to the achievement whose location they show.
|
||
Roadmap steps reference them by achievement name; they are rendered inline
|
||
in the step so the pictures sit next to the instructions for that spot. */
|
||
function stepShots(photos){
|
||
if(!photos || !photos.length) return '';
|
||
const thumb = x => x.s==='dayngl' ? x.u+'?w=480' : x.u+'?imw=480&ima=fit&impolicy=Letterbox';
|
||
const src = x => x.s==='dayngl' ? "Dayngl's Guides" : 'Steam guide';
|
||
return `<div class="shots">${photos.map(x=>
|
||
`<a class="lb" href="${x.u}" target="_blank" rel="noopener"`+
|
||
` data-full="${x.u}" data-cap="${esc(x.cap)} — ${src(x)}"`+
|
||
` title="${esc(x.cap)} — click to zoom">`+
|
||
`<img src="${thumb(x)}" loading="lazy" referrerpolicy="no-referrer"`+
|
||
` alt="${esc(x.cap)} location screenshot" onerror="this.closest('a').remove()"></a>`
|
||
).join('')}</div>`;
|
||
}
|
||
|
||
/* ---------- lightbox ---------- */
|
||
let lbList=[], lbIdx=0, lbZoom=false;
|
||
function lbCollect(anchor){
|
||
const box=anchor.closest('.shots');
|
||
const all=box ? Array.from(box.querySelectorAll('a.lb')) : [anchor];
|
||
return {all, idx: Math.max(0, all.indexOf(anchor))};
|
||
}
|
||
function lbOpen(anchor){
|
||
const c=lbCollect(anchor);
|
||
lbList=c.all.map(a=>({full:a.dataset.full, cap:a.dataset.cap}));
|
||
lbIdx=c.idx;
|
||
document.getElementById('lightbox').hidden=false;
|
||
document.body.style.overflow='hidden';
|
||
lbShow();
|
||
}
|
||
function lbShow(){
|
||
const it=lbList[lbIdx]; if(!it) return;
|
||
const img=document.getElementById('lbImg');
|
||
img.src=it.full; img.alt=it.cap||'';
|
||
document.getElementById('lbCap').textContent=(lbList.length>1 ? (lbIdx+1)+' / '+lbList.length+' · ' : '')+(it.cap||'');
|
||
document.getElementById('lbOpen').href=it.full;
|
||
lbZoom=false;
|
||
const st=document.getElementById('lbStage');
|
||
st.classList.remove('zoomed'); st.scrollTop=0; st.scrollLeft=0;
|
||
document.getElementById('lbZoom').textContent='zoom 100%';
|
||
const many=lbList.length>1;
|
||
document.getElementById('lbPrev').style.display = many?'':'none';
|
||
document.getElementById('lbNext').style.display = many?'':'none';
|
||
}
|
||
function lbClose(){
|
||
document.getElementById('lightbox').hidden=true;
|
||
document.body.style.overflow='';
|
||
}
|
||
function lbToggleZoom(ev){
|
||
const st=document.getElementById('lbStage'), img=document.getElementById('lbImg');
|
||
lbZoom=!lbZoom;
|
||
if(lbZoom){
|
||
// keep whatever part of the shot you clicked on centred once it grows
|
||
const r=img.getBoundingClientRect();
|
||
const fx=ev ? (ev.clientX-r.left)/Math.max(1,r.width) : 0.5;
|
||
const fy=ev ? (ev.clientY-r.top)/Math.max(1,r.height) : 0.5;
|
||
st.classList.add('zoomed');
|
||
document.getElementById('lbZoom').textContent='zoom fit';
|
||
requestAnimationFrame(()=>{
|
||
st.scrollLeft = img.offsetWidth*fx - st.clientWidth/2;
|
||
st.scrollTop = img.offsetHeight*fy - st.clientHeight/2;
|
||
});
|
||
}else{
|
||
st.classList.remove('zoomed'); st.scrollTop=0; st.scrollLeft=0;
|
||
document.getElementById('lbZoom').textContent='zoom 100%';
|
||
}
|
||
}
|
||
function lbStep(d){
|
||
if(lbList.length<2) return;
|
||
lbIdx=(lbIdx+d+lbList.length)%lbList.length;
|
||
lbShow();
|
||
}
|
||
|
||
/* ============================================================
|
||
ROADMAP TAB
|
||
============================================================ */
|
||
const openSteps=new Set(); // survives re-renders (diff switch, hide-done, etc.)
|
||
|
||
/* area -> chapter, so a step can find its own dog-tag list */
|
||
const AREA2CH={};
|
||
DOGTAGS.forEach(l=>l.tags.forEach(t=>{ if(!AREA2CH[t.area]) AREA2CH[t.area]=l.chapter; }));
|
||
const slug = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
||
|
||
/* The chips, tick boxes and item labels for one walkthrough step. Dog tags reuse the exact
|
||
same keys as the Dog Tags tab (listId:index) so the two views are one checklist. */
|
||
function subsHTML(s){
|
||
const ach=(s.gets&&s.gets.ach)||[];
|
||
const items=(s.gets&&s.gets.item)||[];
|
||
const areas=s.areas||[];
|
||
const chapter=areas.length ? AREA2CH[areas[0]] : '';
|
||
const listId=chapter ? DOGTAGS.findIndex(l=>l.chapter===chapter && l.difficulty===S.roadDiff) : -1;
|
||
let tags=[];
|
||
if(listId>=0){
|
||
DOGTAGS[listId].tags.forEach((t,i)=>{
|
||
if(areas.indexOf(t.area)>=0) tags.push({t, i, key:DOGTAGS[listId].id+':'+i});
|
||
});
|
||
}
|
||
if(!ach.length && !items.length && !tags.length) return '';
|
||
const row=(kind,cls,key,attr,checked,label,sub)=>`<label class="sub ${cls}">
|
||
<input type="checkbox" ${attr}="${key}" data-sub="${kind}" ${checked?'checked':''}>
|
||
<span><b>${esc(label)}</b><em>${sub}</em></span></label>`;
|
||
return `<div class="subs" data-subs="${s.id}">
|
||
<div class="subhead">
|
||
<span class="subtitle">what to get here</span>
|
||
<span class="pill" data-subcount="${s.id}">0/0</span>
|
||
${tags.length?`<span class="pill">🏷️ ${tags.length} ${esc(S.roadDiff)} tags</span>`:''}
|
||
</div>
|
||
<div class="sublist">
|
||
${ach.map(id=>{const a=ACHIEVEMENTS.find(x=>x.id===id)||{name:id};
|
||
return row('ach','ach'+(a.miss?' miss':''), id, 'data-achbox', !!S.ach[id], a.name,
|
||
'achievement'+(a.miss?' · missable':''));}).join('')}
|
||
${tags.map(o=>row('tag','tag', o.key, 'data-tagbox', !!S.tags[o.key], o.t.name,
|
||
'dog tag · '+esc(o.t.area)+(o.t.note?' · ⚠ conditional':''))).join('')}
|
||
${items.map(l=>{const k=s.id+':'+slug(l);
|
||
return row('item','item', k, 'data-item', !!S.items[k], l, 'pick up');}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* recompute every visible "n/m" chip and strike through finished rows */
|
||
function subsRefresh(){
|
||
document.querySelectorAll('.subs').forEach(box=>{
|
||
const ins=box.querySelectorAll('input[type=checkbox]');
|
||
let done=0;
|
||
ins.forEach(i=>{
|
||
if(i.checked) done++;
|
||
const lab=i.closest('.sub');
|
||
if(lab) lab.classList.toggle('on-done', i.checked);
|
||
});
|
||
const pill=box.querySelector('[data-subcount]');
|
||
if(pill) pill.textContent=done+'/'+ins.length;
|
||
});
|
||
}
|
||
|
||
function renderRoad(){
|
||
const el=document.getElementById('tab-roadmap');
|
||
let h = `
|
||
<div class="panel">
|
||
<h2>How to 100% MGS2 Master Collection</h2>
|
||
<p>You need <b>51 achievements</b>. The smart order is <b>five passes</b> — three cheap dog-tag runs that also
|
||
mop up every miscellaneous/missable achievement, then two challenge runs. Do <b>not</b> attempt Big Boss first:
|
||
you want the Bandana, Stealth Camo and the Brown Wig unlocked before you try it.</p>
|
||
<div class="infobox"><b>The single most important thing:</b> after finishing a chapter you must make a
|
||
<b>completed save file</b> (save on the results screen / after the credits). Special items only carry over from a
|
||
<b>cleared</b> save, and several achievements (Yorkie, Poodle, Beagle, Shiba Inu) only pop once the chapter is finished.</div>
|
||
<table>
|
||
<tr><th>#</th><th>Run</th><th>Difficulty</th><th>Dog tags</th><th>Main purpose</th></tr>
|
||
<tr><td class="n">1</td><td>Everything Run</td><td>Very Easy</td><td>Tanker 24 + Plant 43 = 67</td><td>All misc/missable achievements, weapons, boxes, camera photos</td></tr>
|
||
<tr><td class="n">2</td><td>Tag Run II</td><td>Easy</td><td>Tanker 26 + Plant 44 = 70</td><td>Push Tanker to 50 (Bandana) & Plant to 80 (Brown Wig)</td></tr>
|
||
<tr><td class="n">3</td><td>Tag Run III</td><td>Normal</td><td>Tanker 33 + Plant 49 = 82</td><td>Push Tanker to 76 (Stealth) & Plant to 120+ (Plant Stealth)</td></tr>
|
||
<tr><td class="n">4</td><td>Clean Run</td><td>Very Easy + Stealth</td><td>—</td><td><b>Pacifist Run</b> + <b>Complete Stealth</b> together, trivial with camo</td></tr>
|
||
<tr><td class="n">5</td><td>Big Boss Run</td><td>Extreme</td><td>—</td><td><b>Big Boss</b> — save it for last</td></tr>
|
||
<tr><td class="n">+</td><td>Off-story</td><td>any</td><td>—</td><td>Snake Tales, Boss Survival, VR/Alternative missions, Casting Theater, Screenplay Book</td></tr>
|
||
</table>
|
||
<div class="warnbox"><b>29 of the 51 are missable.</b> The ones that most often get missed on a first run:
|
||
<b>Hurt Locker</b>, <b>Kissing Booth</b>, <b>Snake Beater</b>, <b>Down in Smoke</b>, <b>Vamp Eyer</b> (you must NOT
|
||
skip the final cutscene), <b>Sexting</b> & <b>Who Ya Gonna Call?</b> (Tanker Holds, one-shot areas),
|
||
<b>Johnny on the Spot</b> (two Directional Mic moments), and <b>To Catch a Predator</b>.</div>
|
||
<p class="muted">Tick the steps below as you go — the counters feed the ROADMAP bar at the top.
|
||
Steps are in story order for each chapter.</p>
|
||
<div class="key">
|
||
<span><b class="k-tag">guard name</b> whose dog tag it is</span>
|
||
<span><b class="k-item">weapon / box</b> something you pick up</span>
|
||
<span><b class="k-ach">achievement</b></span>
|
||
<span><b class="k-miss">missable achievement</b> do not walk past it</span>
|
||
<span><b class="k-boss">boss</b></span>
|
||
<span><b class="k-area">area</b></span>
|
||
</div>
|
||
<div class="controls" style="margin:14px 0 0">
|
||
<span class="muted" style="font:11.5px var(--mono)">Dog-tag checklist shown in each step for:</span>
|
||
<div class="chips">
|
||
${['Very Easy','Easy','Normal','Hard','Extreme'].map(d=>
|
||
`<span class="chip ${S.roadDiff===d?'on':''}" data-roaddiff="${d}">${d}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
<p class="muted" style="margin-top:8px">Open a step and its <b>sub-checkboxes</b> list the dog tags, achievements
|
||
and items for that spot. <b>Ticking a dog tag here ticks the same tag in the Dog Tags tab</b> (and vice versa) —
|
||
it is the same checklist, and items and achievements sync to their own tabs too.</p>
|
||
</div>`;
|
||
|
||
ROADMAP.forEach(ph=>{
|
||
const done = ph.steps.filter(s=>S.road[s.id]).length;
|
||
h += `<div class="phase"><h2>${esc(ph.phase)}</h2><span class="pill">${done}/${ph.steps.length}</span></div>`;
|
||
if(ph.intro) h += `<div class="panel tight"><p style="margin:0">${ph.intro}</p></div>`;
|
||
ph.steps.forEach((s,i)=>{
|
||
const on=!!S.road[s.id];
|
||
h += `<div class="stop ${on?'done':''} ${openSteps.has(s.id)?'open':''}" data-road="${s.id}">
|
||
<div class="sh">
|
||
<input type="checkbox" ${on?'checked':''} data-roadbox="${s.id}">
|
||
<div class="snum">${i+1}</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div class="stitle">${esc(s.title)}</div>
|
||
${s.area?`<div class="sarea">${esc(s.area)}</div>`:''}
|
||
</div>
|
||
<span class="caret">▾</span>
|
||
</div>
|
||
<div class="sbody">${stepShots(s.photos)}${s.body}${subsHTML(s)}</div>
|
||
</div>`;
|
||
});
|
||
});
|
||
el.innerHTML=h;
|
||
subsRefresh();
|
||
}
|
||
|
||
/* ============================================================
|
||
ACHIEVEMENTS TAB
|
||
============================================================ */
|
||
const CATS=[['all','All'],['story','Story'],['coll','Collectible'],['misc','Misc / Missable'],['mode','Extra Modes'],['chal','Challenge'],['extra','MC Extras'],['missing','⬇ Missing on Steam']];
|
||
let achFilter='all', achQuery='', achOpenOnly=false;
|
||
|
||
function renderAch(){
|
||
const el=document.getElementById('tab-ach');
|
||
const counts={all:ACHIEVEMENTS.length};
|
||
CATS.forEach(([c])=>{ if(c!=='all'&&c!=='missing') counts[c]=ACHIEVEMENTS.filter(a=>a.cat===c).length; });
|
||
counts['missing']=ACHIEVEMENTS.length;
|
||
const doneC={all:achDone()};
|
||
CATS.forEach(([c])=>{ if(c!=='all'&&c!=='missing') doneC[c]=ACHIEVEMENTS.filter(a=>a.cat===c&&S.ach[a.id]).length; });
|
||
doneC['missing']=steamCount();
|
||
|
||
let list = ACHIEVEMENTS.filter(a=>{
|
||
if(achFilter==='missing') return !(S.steam.unlocked||{})[a.api];
|
||
return achFilter==='all'||a.cat===achFilter;
|
||
});
|
||
if(achQuery){
|
||
const q=achQuery.toLowerCase();
|
||
list=list.filter(a=>(a.name+' '+a.desc+' '+a.guide).toLowerCase().includes(q));
|
||
}
|
||
if(S.hideDone) list=list.filter(a=>!S.ach[a.id]);
|
||
|
||
let h=`<div class="panel">
|
||
<h2>Achievement checklist <span class="pill">${achDone()}/${ACH_TOTAL}</span>${steamCount()?`<span class="pill steam">${steamCount()} on Steam</span>`:''}</h2>
|
||
<p class="muted">Click a card to open the full walkthrough for that achievement. Click the box (or the card) to tick it.
|
||
${S.steam.profile?`Syncing with <b>${esc(S.steam.persona||S.steam.profile)}</b>. `:''}Cards with a blue edge are already unlocked on your Steam profile.</p>
|
||
<div class="controls">
|
||
<div class="chips">
|
||
${CATS.map(([c,l])=>`<span class="chip ${achFilter===c?'on':''}" data-cat="${c}">${l} <span class="c">${doneC[c]}/${counts[c]}</span></span>`).join('')}
|
||
</div>
|
||
<input type="search" id="achQ" placeholder="search achievements…" value="${esc(achQuery)}">
|
||
</div>
|
||
${list.length?'':'<div class="warnbox">Nothing matches that filter.</div>'}
|
||
</div>`;
|
||
|
||
list.forEach(a=>{
|
||
const on=!!S.ach[a.id];
|
||
const st=!!(S.steam.unlocked||{})[a.api];
|
||
const catLabel=(CATS.find(c=>c[0]===a.cat)||['',''])[1];
|
||
h+=`<div class="ach ${on?'done':''} ${st?'steam':''}" data-ach="${a.id}">
|
||
<div class="achhead">
|
||
<input type="checkbox" ${on?'checked':''} data-achbox="${a.id}">
|
||
${a.icon?`<img class="icon" src="${a.icon}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'icon ph',textContent:'${esc(a.name[0])}'}))">`
|
||
:`<div class="icon ph">${esc(a.name[0])}</div>`}
|
||
<div class="ameta">
|
||
<div class="aname">${esc(a.name)} <span class="pill ${a.cat}">${catLabel}</span>${a.miss?'<span class="pill miss">missable</span>':''}${a.cum?'<span class="pill">cumulative</span>':''}${st?'<span class="pill steam">STEAM ✓</span>':''}</div>
|
||
<div class="adesc">${esc(a.desc)}</div>
|
||
</div>
|
||
<div class="aright"><span class="caret">▾ details</span></div>
|
||
</div>
|
||
<div class="achbody">${a.guide}</div>
|
||
</div>`;
|
||
});
|
||
el.innerHTML=h;
|
||
const q=document.getElementById('achQ');
|
||
if(q){
|
||
q.addEventListener('input',e=>{achQuery=e.target.value; const p=e.target.selectionStart; renderAch();
|
||
const n=document.getElementById('achQ'); if(n){n.focus(); n.setSelectionRange(p,p);} });
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
DOG TAGS TAB
|
||
============================================================ */
|
||
let tagList=0, tagQuery='';
|
||
const listDone = l => l.tags.filter((_,i)=>S.tags[l.id+':'+i]).length;
|
||
|
||
function renderTags(){
|
||
const el=document.getElementById('tab-tags');
|
||
const L=DOGTAGS[tagList];
|
||
const tDone=tagDone();
|
||
// thresholds
|
||
const tankerLists=DOGTAGS.filter(l=>l.chapter==='Tanker');
|
||
const plantLists =DOGTAGS.filter(l=>l.chapter==='Plant');
|
||
// Only Very Easy→Normal count toward the achievements per the guides, but we count all collected.
|
||
const tankerEasy3 = tankerLists.slice(0,3).reduce((a,l)=>a+listDone(l),0);
|
||
const plantEasy3 = plantLists.slice(0,3).reduce((a,l)=>a+listDone(l),0);
|
||
const plantAll = plantLists.reduce((a,l)=>a+listDone(l),0);
|
||
const tankerAll = tankerLists.reduce((a,l)=>a+listDone(l),0);
|
||
|
||
const thr=(label,val,need,extra)=>`<div class="thrcard ${val>=need?'hit':''}">
|
||
<div class="lbl"><span>${label}</span><span>${need}</span></div>
|
||
<div class="val">${val}<small> / ${need}</small></div>
|
||
<div class="bar" style="width:100%"><i style="width:${Math.min(100,val/need*100)}%"></i></div>
|
||
${extra?`<div class="lbl" style="margin-top:5px">${extra}</div>`:''}
|
||
</div>`;
|
||
|
||
let h=`<div class="panel">
|
||
<h2>Dog tags <span class="pill">${tDone}/${TAG_TOTAL}</span></h2>
|
||
<p>Hold up a guard (aim at their back, run in front, aim at their face), grab the tag, then tranquillise them.
|
||
If they refuse, shoot their hand or foot with a <b>lethal</b> weapon to scare them. Tags are saved per-difficulty,
|
||
but you must <b>finish the chapter</b> for a missed tag to be added to your total.</p>
|
||
<div class="thr">
|
||
${thr('Tanker — Bandana (Yorkie)',tankerEasy3,50,'Very Easy + Easy + Normal')}
|
||
${thr('Tanker — Stealth (Poodle)',tankerEasy3,76,'Very Easy + Easy + Normal')}
|
||
${thr('Plant — Brown Wig (Beagle)',plantEasy3,80,'Very Easy + Easy + Normal')}
|
||
${thr('Plant — Stealth (Shiba Inu)',plantEasy3,120,'Very Easy + Easy + Normal')}
|
||
${thr('Plant — Orange Wig',plantAll,160,'all difficulties')}
|
||
${thr('Plant — Blue Wig (O₂)',plantAll,200,'all difficulties')}
|
||
</div>
|
||
<div class="infobox" style="margin-top:12px">
|
||
<b>Tanker total: ${tankerAll}/${tankerLists.reduce((a,l)=>a+l.tags.length,0)}</b> ·
|
||
<b>Plant total: ${plantAll}/${plantLists.reduce((a,l)=>a+l.tags.length,0)}</b> ·
|
||
Grand total ${TAG_TOTAL}. The achievement only needs Very Easy, Easy and Normal tags (67 + 140).
|
||
Tags you tick here are remembered per list.
|
||
</div>
|
||
</div>`;
|
||
|
||
h+=`<div class="panel tight">
|
||
<div class="controls" style="margin:0">
|
||
<div class="chips">
|
||
${DOGTAGS.map((l,i)=>{const d=listDone(l);return `<span class="chip ${i===tagList?'on':''}" data-list="${i}">
|
||
${l.chapter==='Tanker'?'🚢':'🏭'} ${esc(l.difficulty)} <span class="c">${d}/${l.tags.length}</span></span>`}).join('')}
|
||
</div>
|
||
<input type="search" id="tagQ" placeholder="find a name / area…" value="${esc(tagQuery)}">
|
||
<button data-gomap="1">🗺️ Open ${esc(L.chapter)} ${esc(L.difficulty)} area map</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
let tags=L.tags.map((t,i)=>({t,i}));
|
||
if(tagQuery){
|
||
const q=tagQuery.toLowerCase();
|
||
tags=tags.filter(o=>(o.t.name+' '+o.t.area+' '+(o.t.note||'')).toLowerCase().includes(q));
|
||
}
|
||
if(S.hideDone) tags=tags.filter(o=>!S.tags[L.id+':'+o.i]);
|
||
|
||
h+=`<div class="panel">
|
||
<h2>${L.chapter} — ${esc(L.difficulty)} <span class="pill">${listDone(L)}/${L.tags.length}</span></h2>
|
||
<p class="muted">${esc(L.blurb||'')}</p>
|
||
<div class="dgrid">
|
||
${tags.map(o=>{
|
||
const on=!!S.tags[L.id+':'+o.i];
|
||
return `<div class="dt ${on?'done':''}" data-tag="${L.id}:${o.i}">
|
||
<input type="checkbox" ${on?'checked':''} data-tagbox="${L.id}:${o.i}">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="dname">${esc(o.t.name)}${o.t.note?'<span class="cond">CONDITIONAL</span>':''}</div>
|
||
<div class="darea">${esc(o.t.area)}</div>
|
||
${o.t.note?`<div class="dnote">⚠ ${esc(o.t.note)}</div>`:''}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
el.innerHTML=h;
|
||
const q=document.getElementById('tagQ');
|
||
if(q) q.addEventListener('input',e=>{tagQuery=e.target.value;const p=e.target.selectionStart;renderTags();
|
||
const n=document.getElementById('tagQ'); if(n){n.focus(); n.setSelectionRange(p,p);}});
|
||
}
|
||
|
||
/* ============================================================
|
||
AREA MAPS (rebuilt cell-by-cell from the tracking spreadsheet)
|
||
============================================================ */
|
||
let mapIdx=0, mapZoom=1.8, mapShowText=true, mapTextScale=1.35;
|
||
|
||
const MAPLEGEND=[
|
||
['#D9D9D9','walkway — level 1'],['#CCCCCC','walkway — level 2'],['#999999','walkway — level 3'],
|
||
['#76A5AF','trap / hazard'],
|
||
['#4A86E8','door / area change'],['#FFFF00','locked door'],['#434343','blocked entry'],['#00FFFF','ladder / lift'],
|
||
['#FF9900','guard (tag)'],['#FF00FF','tough guard'],['#FF0000','boss with tag'],['#00FF00','conditional guard'],
|
||
['#D9EAD3','Shell 1 area'],['#FFF2CC','Shell 2 area'],['#CFE2F3','Shell 1 core'],['#D9D2E9','Shell 2 core'],
|
||
];
|
||
|
||
function mapColOffsets(M){
|
||
const off=[], n=M.c1-M.c0+2;
|
||
let x=0, keys=Object.keys(M.widths).map(Number).sort((a,b)=>a-b);
|
||
for(let c=M.c0;c<=M.c1;c++){
|
||
off[c-M.c0]=x;
|
||
let w=null;
|
||
for(const k of keys){ const mm=M.widths[k]; if(c>=k && c<=mm[0]){ w=mm[1]; break; } }
|
||
if(w==null) w=12.63;
|
||
x += w*7+5;
|
||
}
|
||
off[n-1]=x;
|
||
return off;
|
||
}
|
||
function mapRowOffsets(M){
|
||
const off=[], n=M.r1-M.r0+2;
|
||
let y=0;
|
||
for(let r=M.r0;r<=M.r1;r++){
|
||
off[r-M.r0]=y;
|
||
const h=(M.heights && M.heights[r]) ? M.heights[r] : 15.75;
|
||
y += h*1.3333;
|
||
}
|
||
off[n-1]=y;
|
||
return off;
|
||
}
|
||
function mapRowH(M,r){ return ((M.heights && M.heights[r]) ? M.heights[r] : 15.75)*1.3333; }
|
||
|
||
function drawMap(){
|
||
const M=MAPS[mapIdx], cv=document.getElementById('mapCanvas');
|
||
if(!M || !cv) return;
|
||
const offX=mapColOffsets(M), offY=mapRowOffsets(M);
|
||
const W=offX[M.c1-M.c0+1], H=offY[M.r1-M.r0+1];
|
||
const z=mapZoom, cssW=Math.round(W*z), cssH=Math.round(H*z);
|
||
let dpr=Math.min(window.devicePixelRatio||1, 2);
|
||
const budget=10e6; // keep the backing store ~40 MB even on huge maps
|
||
if(cssW*cssH*dpr*dpr>budget) dpr=Math.max(1, Math.sqrt(budget/(cssW*cssH)));
|
||
cv.style.width=cssW+'px'; cv.style.height=cssH+'px';
|
||
cv.width=Math.round(cssW*dpr); cv.height=Math.round(cssH*dpr);
|
||
const g=cv.getContext('2d');
|
||
g.setTransform(dpr*z,0,0,dpr*z,0,0);
|
||
g.fillStyle='#ffffff'; g.fillRect(0,0,W,H);
|
||
|
||
const pal=M.palette;
|
||
for(const rowstr of M.fills){
|
||
const i=rowstr.indexOf(':'), r=+rowstr.slice(0,i);
|
||
const y=offY[r-M.r0], h=mapRowH(M,r);
|
||
for(const run of rowstr.slice(i+1).split(',')){
|
||
const p=run.split('+'), c=+p[0], len=+p[1], idx=+p[2];
|
||
const x=offX[c-M.c0], w=offX[c-M.c0+len]-x;
|
||
g.fillStyle='#'+pal[idx].slice(2);
|
||
g.fillRect(x,y,w,h);
|
||
}
|
||
}
|
||
if(mapShowText){
|
||
// Excel only clips overflowing text at the next cell that actually HAS content.
|
||
// A background fill alone does not stop it, so clip against text cells only.
|
||
const textCols={};
|
||
for(const t of M.texts){ (textCols[t[0]] || (textCols[t[0]]=[])).push(t[1]); }
|
||
for(const r in textCols) textCols[r].sort((a,b)=>a-b);
|
||
const mstart={};
|
||
for(const mm of M.merges) mstart[mm[0]+','+mm[1]]=mm[3];
|
||
|
||
const FS=Math.round(11*mapTextScale);
|
||
const FONT=px=>px+'px "Segoe UI",system-ui,-apple-system,sans-serif';
|
||
let curFont=FS;
|
||
const setF=f=>{ if(f!==curFont){ g.font=FONT(f); curFont=f; } };
|
||
g.font=FONT(FS);
|
||
g.textBaseline='middle';
|
||
for(const [r,c,t] of M.texts){
|
||
const rh=mapRowH(M,r);
|
||
const y=offY[r-M.r0]+rh/2;
|
||
const x0=offX[c-M.c0];
|
||
const mk=mstart[r+','+c];
|
||
let endC;
|
||
if(mk!=null) endC=mk+1;
|
||
else{ const arr=textCols[r], i=arr.indexOf(c); endC=(i>=0 && i<arr.length-1) ? arr[i+1] : M.c1+1; }
|
||
const endX=offX[Math.min(endC, M.c1+1)-M.c0];
|
||
let maxW=endX-x0-3;
|
||
if(maxW<4) continue; // genuinely overlapped by the next label
|
||
|
||
// Canvas condenses text horizontally to honour maxWidth, which turns long labels into
|
||
// an unreadable smear. Prefer shrinking the font a little, and if even that will not
|
||
// fit, let the label overflow its cell rather than crushing it.
|
||
let drawW=maxW;
|
||
setF(FS);
|
||
let w=g.measureText(t).width;
|
||
if(w>maxW){
|
||
let f=FS;
|
||
while(w>maxW && f>7){ f-=1; setF(f); w=g.measureText(t).width; }
|
||
if(w>maxW) drawW=undefined;
|
||
}
|
||
g.fillStyle='#000';
|
||
g.fillText(t, x0+2, y, drawW);
|
||
}
|
||
setF(FS);
|
||
}
|
||
const lbl=document.getElementById('zoomLbl');
|
||
if(lbl) lbl.textContent=Math.round(z*100)+'%';
|
||
const info=document.getElementById('mapInfo');
|
||
if(info) info.textContent=`${M.chapter} · ${M.difficulty} · ${M.r1-M.r0+1} rows × ${M.c1-M.c0+1} cols`;
|
||
}
|
||
|
||
function renderMaps(){
|
||
const el=document.getElementById('tab-maps');
|
||
const M=MAPS[mapIdx]||{};
|
||
el.innerHTML=`
|
||
<div class="panel">
|
||
<h2>Area maps <span class="pill">rebuilt from the dog-tag spreadsheet</span></h2>
|
||
<p>These are <b>u/Spikeyroxas' guard-position maps</b>, redrawn cell-by-cell from the original spreadsheet —
|
||
every coloured block, guard marker, number and label is the original data. Scroll the panel to pan;
|
||
nothing here needs an internet connection.</p>
|
||
<div class="controls" style="margin-bottom:10px">
|
||
<div class="chips">
|
||
${MAPS.map((m,i)=>`<span class="chip ${i===mapIdx?'on':''}" data-map="${i}">${m.chapter==='Tanker'?'🚢':'🏭'} ${esc(m.difficulty)}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="mapctl">
|
||
<button data-zoom="-">− zoom out</button>
|
||
<span class="pill" id="zoomLbl">150%</span>
|
||
<button data-zoom="+">+ zoom in</button>
|
||
<button data-zoom="1">100%</button>
|
||
<button data-zoom="2">200%</button>
|
||
<button data-zoom="fit">fit width</button>
|
||
<label class="sw"><input type="checkbox" id="mapText" ${mapShowText?'checked':''}> show labels</label>
|
||
<span class="pill">label size</span>
|
||
<button data-tscale="-">A−</button>
|
||
<span class="pill" id="tscaleLbl">${Math.round(mapTextScale*100)}%</span>
|
||
<button data-tscale="+">A+</button>
|
||
<span class="pill" id="mapInfo"></span>
|
||
</div>
|
||
<div class="mapwrap" id="mapWrap"><canvas id="mapCanvas"></canvas></div>
|
||
<div class="legend">
|
||
${MAPLEGEND.map(([c,n])=>`<span><i class="swatch" style="background:${c}"></i>${n}</span>`).join('')}
|
||
</div>
|
||
<p class="maptip">The original sheets also carry their own <b>Key</b>, <b>Hazard Key</b> and numbered
|
||
<b>Guards Key</b> in the top-left of each map — including the numbered markers (1.0, 2.0, 3.0 …) that identify
|
||
each guard by name. Those are rendered in the image itself, so scroll to the top of a map to read them.</p>
|
||
</div>`;
|
||
document.getElementById('mapText').addEventListener('change',e=>{ mapShowText=e.target.checked; drawMap(); });
|
||
if(document.getElementById('tab-maps').classList.contains('on')) drawMap();
|
||
}
|
||
|
||
/* ============================================================
|
||
BIG BOSS TAB (static)
|
||
============================================================ */
|
||
function renderBoss(){
|
||
const b=BIGBOSS;
|
||
let h=`<div class="panel">
|
||
<h2>Big Boss run — Extreme, Tanker→Plant <span class="pill chal">hardest achievement</span></h2>
|
||
<p>${b.intro}</p>
|
||
<div class="grid g2">
|
||
<div><h3>Requirements</h3><ul class="tight">${b.reqs.map(r=>`<li>${r}</li>`).join('')}</ul></div>
|
||
<div><h3>Where the game refills your health</h3><ul class="tight">${b.heals.map(r=>`<li>${r}</li>`).join('')}</ul></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
h+=`<div class="panel"><h2>Time checkpoints</h2>
|
||
<p class="muted">Be at roughly these times when the fight <b>starts</b>. If you're more than a couple of minutes behind, restart.</p>
|
||
<table><tr><th>Checkpoint</th><th>Target</th></tr>
|
||
${b.times.map(t=>`<tr><td>${t[0]}</td><td class="n">${t[1]}</td></tr>`).join('')}
|
||
</table></div>`;
|
||
|
||
h+=`<div class="panel"><h2>Recommended save points</h2>
|
||
<p class="muted">Saving is free (it does not cost you anything on the codename) but <b>reloading restarts you at the beginning of the area you saved in</b>, so save right after entering a new area.</p>
|
||
<ul class="tight">${b.saves.map(s=>`<li>${s}</li>`).join('')}</ul></div>`;
|
||
|
||
h+=`<div class="panel"><h2>Time-saving rules</h2><ul class="tight">${b.tips.map(s=>`<li>${s}</li>`).join('')}</ul></div>`;
|
||
|
||
b.bosses.forEach(x=>{
|
||
h+=`<div class="panel"><h2>${esc(x.name)}</h2>${x.body}</div>`;
|
||
});
|
||
document.getElementById('tab-boss').innerHTML=h;
|
||
}
|
||
|
||
/* ============================================================
|
||
STEAM SYNC
|
||
============================================================ */
|
||
const steamCount = ()=>ACHIEVEMENTS.filter(a=>(S.steam.unlocked||{})[a.api]).length;
|
||
|
||
const HELPER = 'http://127.0.0.1:8765/api/steam';
|
||
function syncEndpoint(){
|
||
// served locally by steam-sync.py -> same origin
|
||
if(IS_LOCAL && location.protocol.startsWith('http')) return '/api/steam';
|
||
// a deployed proxy Worker, if one is configured
|
||
if(STEAM_PROXY) return STEAM_PROXY.replace(/\/$/,'') + '/api/steam';
|
||
// hosted or opened off the disk -> use the local helper when it is running.
|
||
// An https page may reach 127.0.0.1 (it counts as a trustworthy origin), and the
|
||
// helper answers the Private Network Access preflight Chrome asks for.
|
||
return HELPER;
|
||
}
|
||
|
||
async function syncSteam(profile){
|
||
const el=document.getElementById('syncStatus');
|
||
const setStatus=(msg,cls)=>{ if(el){ el.className='status '+(cls||''); el.innerHTML=msg; } };
|
||
profile=(profile||S.steam.profile||'').trim().replace(/^https?:\/\/steamcommunity\.com\//,'').replace(/\/.*$/,'');
|
||
profile=profile.replace(/^(profiles|id)\//,'');
|
||
if(!profile){ setStatus('Enter a Steam profile first — a vanity name (e.g. <code>gaben</code>) or a 17-digit SteamID64.','bad'); return; }
|
||
S.steam.profile=profile; save();
|
||
const url=syncEndpoint();
|
||
setStatus('⏳ Contacting Steam for <b>'+esc(profile)+'</b>…');
|
||
try{
|
||
const r = await fetch(url+'?profile='+encodeURIComponent(profile), {cache:'no-store'});
|
||
const j = await r.json();
|
||
if(!j.ok){
|
||
setStatus('❌ '+esc(j.error||'Sync failed.')+(j.hint?'<br><span class="muted">'+esc(j.hint)+'</span>':''),'bad');
|
||
document.getElementById('steamdot').className='err';
|
||
return;
|
||
}
|
||
// mark what Steam says is unlocked; never un-tick manual ticks
|
||
let added=0;
|
||
(j.achievements||[]).forEach(a=>{
|
||
if(a.unlocked){ if(!S.steam.unlocked[a.api]) added++; S.steam.unlocked[a.api]=1; S.ach[a.api]=true; }
|
||
});
|
||
S.steam.id64=j.steamid64||''; S.steam.persona=j.persona||profile; S.steam.lastSync=Date.now();
|
||
save();
|
||
const total=(j.achievements||[]).filter(a=>a.unlocked).length;
|
||
setStatus('✅ Synced <b>'+esc(S.steam.persona)+'</b> — <b>'+total+'/'+((j.achievements||[]).length||51)+
|
||
'</b> unlocked on Steam'+(added?' ('+added+' newly ticked here)':'')+'.','ok');
|
||
document.getElementById('steamdot').className='on';
|
||
}catch(e){
|
||
setStatus('❌ Could not reach the sync helper.<br><span class="muted">Steam sends no CORS headers, so the page needs something to fetch your profile through. '+
|
||
'Run <code>python3 steam-sync.py</code> on this machine (it listens on <b>127.0.0.1:8765</b>) and try again — that is the easiest option, and it is what this page just tried. '+
|
||
'Alternatively deploy <code>steam-proxy-worker.js</code> and paste its URL into <code>STEAM_PROXY</code>.</span>','bad');
|
||
document.getElementById('steamdot').className='err';
|
||
} renderSteam(); renderAch(); paintProgress();
|
||
}
|
||
|
||
function renderSteam(){
|
||
const el=document.getElementById('tab-steam');
|
||
const unl=S.steam.unlocked||{};
|
||
const have=steamCount();
|
||
const when=S.steam.lastSync? new Date(S.steam.lastSync).toLocaleString() : 'never';
|
||
el.innerHTML=`
|
||
<div class="panel">
|
||
<h2>Steam sync <span class="pill">${have}/${ACH_TOTAL} owned</span></h2>
|
||
<p>Point this at any public Steam profile and it will tick off everything that profile has already unlocked —
|
||
those achievements get a <span class="pill steam">STEAM ✓</span> badge and a blue edge so you can tell them apart from
|
||
things you ticked by hand. Use the <b>⬇ Missing on Steam</b> filter on the Achievements tab to see only what is left.</p>
|
||
<div class="syncrow">
|
||
<input type="text" id="syncProfile" placeholder="vanity name or SteamID64" value="${esc(S.steam.profile||'')}">
|
||
<button class="primary" id="btnSync">Sync now</button>
|
||
<button id="btnSyncClear">Forget profile</button>
|
||
</div>
|
||
<div class="status ${have?'ok':''}" id="syncStatus">
|
||
${have?`Last synced <b>${esc(S.steam.persona||'')}</b> — ${when}`:'Not synced yet. Enter a profile and hit Sync.'}
|
||
</div>
|
||
<div class="steamgrid">
|
||
${ACHIEVEMENTS.map(a=>`<div class="smini ${unl[a.api]?'have':'no'}">
|
||
${a.icon?`<img src="${a.icon}" alt="" loading="lazy">`:''}
|
||
<span class="nm" title="${esc(a.name)}">${esc(a.name)}</span></div>`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>How the sync works</h2>
|
||
<p>Steam has no browser-accessible achievement API: the public XML endpoint
|
||
(<code>steamcommunity.com/<profile>/stats/2131640/?tab=achievements&xml=1</code>) sends no CORS headers, so a web page
|
||
cannot read it directly. <code>steam-sync.py</code> is a 60-line stdlib-only helper that serves this folder
|
||
<b>and</b> proxies that request for you.</p>
|
||
<ol class="tight">
|
||
<li>Keep <code>steam-sync.py</code> in the same folder as this page.</li>
|
||
<li>Run <code>python3 steam-sync.py</code> (no dependencies, no API key, nothing to install).</li>
|
||
<li>Open <b>http://127.0.0.1:8765/</b> instead of double-clicking the file — that also gives you reliable
|
||
<code>localStorage</code> saving.</li>
|
||
<li>Enter your vanity name or SteamID64 in the box above and hit <b>Sync now</b>. It re-syncs automatically every time you load the page.</li>
|
||
</ol>
|
||
<h3>If it fails</h3>
|
||
<table>
|
||
<tr><th>Message</th><th>Fix</th></tr>
|
||
<tr><td>Could not reach the sync helper</td><td>You have not started <code>steam-sync.py</code>, or you opened the page from a different folder. Start it and try again.</td></tr>
|
||
<tr><td>"This page is hosted…"</td><td>Static hosting has no server, and Steam blocks direct browser requests. Either run the page locally on <code>127.0.0.1:8765</code>, or deploy <code>steam-proxy-worker.js</code> and set <code>STEAM_PROXY</code>. Your ticks move between the two with <b>Export</b> / <b>Import</b>.</td></tr>
|
||
<tr><td>Profile could not be found</td><td>Check the spelling of the vanity name, or paste the full profile URL.</td></tr>
|
||
<tr><td>Profile is private</td><td>Steam → Profile → Edit Profile → Privacy Settings → set <b>Game details</b> to <b>Public</b>.</td></tr>
|
||
<tr><td>Game not in library / no stats</td><td>That account does not own MGS2 Master Collection (appid 2131640), or has never launched it.</td></tr>
|
||
</table>
|
||
<div class="infobox">The helper only ever makes one outbound request, to the public Steam Community page for the profile you name.
|
||
Nothing is uploaded anywhere, and your profile name is stored only in this page's local storage.</div>
|
||
</div>`;
|
||
const b=document.getElementById('btnSync');
|
||
if(b) b.addEventListener('click',()=>syncSteam(document.getElementById('syncProfile').value));
|
||
const c=document.getElementById('btnSyncClear');
|
||
if(c) c.addEventListener('click',()=>{
|
||
S.steam={profile:'',id64:'',persona:'',unlocked:{},lastSync:0}; save();
|
||
document.getElementById('steamdot').className='';
|
||
renderSteam(); renderAch(); paintProgress(); toast('Steam profile forgotten');
|
||
});
|
||
const inp=document.getElementById('syncProfile');
|
||
if(inp) inp.addEventListener('keydown',e=>{ if(e.key==='Enter') syncSteam(inp.value); });
|
||
document.getElementById('steamdot').className = have?'on':(S.steam.profile?'':'');
|
||
}
|
||
|
||
/* ============================================================
|
||
MISSABLES TAB
|
||
============================================================ */
|
||
function renderMiss(){
|
||
const miss=ACHIEVEMENTS.filter(a=>a.miss);
|
||
let h=`<div class="panel">
|
||
<h2>Every missable achievement, in story order</h2>
|
||
<p>${miss.length} of the 51 can be permanently missed inside a single playthrough — but <b>nothing is missable
|
||
across the whole save</b>: you will be replaying the game several times anyway, so a missed misc achievement just
|
||
means "do it on the next pass". Tick them here as you grab them.</p>
|
||
<div class="warnbox">The four that most often force an extra playthrough:
|
||
<b>Vamp Eyer</b> (don't skip the final cutscene), <b>Who Ya Gonna Call?</b> and <b>Sexting</b> (Tanker Holds — one-shot areas),
|
||
and <b>Kissing Booth → Snake Beater</b> (Deck-A lockers, easy to walk past).</div>
|
||
</div>
|
||
<div class="panel">
|
||
<table><tr><th>✓</th><th>Achievement</th><th>Where / when</th><th>Window</th></tr>
|
||
${miss.map(a=>`<tr>
|
||
<td><input type="checkbox" data-achbox="${a.id}" ${S.ach[a.id]?'checked':''} style="appearance:none;width:17px;height:17px;border:1.5px solid var(--line2);border-radius:4px;background:var(--field);cursor:pointer;position:relative" class="mchk ${S.ach[a.id]?'on':''}"></td>
|
||
<td><b>${esc(a.name)}</b><div class="muted" style="font-size:12px">${esc(a.desc)}</div></td>
|
||
<td>${a.where||'—'}</td>
|
||
<td class="muted" style="font-size:12.5px">${a.window||'—'}</td>
|
||
</tr>`).join('')}
|
||
</table></div>`;
|
||
document.getElementById('tab-miss').innerHTML=h;
|
||
document.querySelectorAll('#tab-miss input[data-achbox]').forEach(cb=>{
|
||
cb.addEventListener('change',()=>toggleAch(cb.dataset.achbox));
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
VR / EXTRA MODES TAB (static)
|
||
============================================================ */
|
||
function renderVR(){
|
||
document.getElementById('tab-vr').innerHTML=`
|
||
<div class="panel">
|
||
<h2>Off-story achievements</h2>
|
||
<p>These are all done from the main menu and are the easiest part of the 100%. Knock them out in a single sitting.</p>
|
||
<table>
|
||
<tr><th>Achievement</th><th>Mode</th><th>What to do</th></tr>
|
||
<tr><td><b>Piece of Cake</b></td><td>VR / Alternative</td><td>Complete any single VR or Alternative mission. One and done.</td></tr>
|
||
<tr><td><b>In It to Win It</b></td><td>VR / Alternative</td><td>Place 1st in 50 different missions (beat the target time). There are 500+, so pick the shortest Sneaking/Weapon missions. Do this while clearing missions for variety — <b>you do NOT need to clear them all</b> on Master Collection.</td></tr>
|
||
<tr><td><b>Tell Me a Tale</b></td><td>Snake Tales</td><td>Complete <b>any one</b> of the five Snake Tales: A Wrongdoing, Big Shell Evil, Confidential Legacy, Dead Man, External Gazer.</td></tr>
|
||
<tr><td><b>No Boss of Mine</b></td><td>Boss Survival</td><td>Beat Olga → Tanker ambush → Fatman → Harrier → Vamp → Tengu → Metal Gear RAYs → Solidus. <b>Pick Raiden on Easy</b> — he has the HF Blade for Solidus; Snake only gets melee.</td></tr>
|
||
<tr><td><b>Casting Theater</b></td><td>Casting Theater</td><td>Watch any scene. Pops together with Love Hurts.</td></tr>
|
||
<tr><td><b>Love Hurts</b></td><td>Casting Theater</td><td>After beating the game: Main Menu → Special → Casting Theater → <b>Demo 8</b> → swap <b>Raiden → Rose</b> and <b>Solidus → Raiden</b>, then watch.</td></tr>
|
||
<tr><td><b>Cover-to-Cover Solid 2</b></td><td>Screenplay Book</td><td>From the Master Collection launcher, open the MGS2 Screenplay Book and page all the way to the end.</td></tr>
|
||
</table>
|
||
<div class="okbox"><b>Fastest 100% path for the off-story stuff:</b> finish the game once (unlocks Boss Survival + Casting Theater) →
|
||
do the two Casting Theater clicks → do one Snake Tale → do 50 VR/Alt first places → read the Screenplay Book.</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>Special items you unlock (and what they do)</h2>
|
||
<table>
|
||
<tr><th>Item</th><th>Chapter</th><th>Requirement</th><th>Effect</th></tr>
|
||
<tr><td>Bandana</td><td>Tanker</td><td>50 Tanker tags</td><td>Infinite ammo</td></tr>
|
||
<tr><td>Stealth Camo</td><td>Tanker</td><td>76 Tanker tags</td><td>Invisible to enemies/cameras</td></tr>
|
||
<tr><td>Brown Wig</td><td>Plant</td><td>80 Plant tags</td><td>Infinite ammo</td></tr>
|
||
<tr><td>Stealth Camo</td><td>Plant</td><td>120 Plant tags</td><td>Invisible to enemies/cameras</td></tr>
|
||
<tr><td>Orange Wig</td><td>Plant</td><td>160 Plant tags</td><td>Infinite grip</td></tr>
|
||
<tr><td>Blue Wig</td><td>Plant</td><td>200 Plant tags</td><td>Infinite oxygen</td></tr>
|
||
</table>
|
||
<div class="warnbox">Special items only exist on a <b>cleared save</b>, and the game tracks Tanker and Plant unlocks separately
|
||
when you start a <b>Tanker-Plant</b> run. For the two stealth items to be available in the same playthrough you must have
|
||
earned them in a completed <b>Tanker-Plant</b> file and then started a new game loading that cleared data.</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ============================================================
|
||
SOURCES TAB
|
||
============================================================ */
|
||
function renderSrc(){
|
||
document.getElementById('tab-src').innerHTML=`
|
||
<div class="panel">
|
||
<h2>Sources this companion is built from</h2>
|
||
<p>Everything here is compiled from the following community work. All credit to the original authors.</p>
|
||
<p class="srcline">🏆 <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3065634746" target="_blank" rel="noopener">"🏆 Metal Gear Solid 2 Achievement Guide" by Cole ヴ Viper</a> — Steam Community guide. Source of the achievement write-ups, boss strategies, Big Boss criteria, save points, time checkpoints and achievement icons.</p>
|
||
<p class="srcline">🏷️ <a href="https://videochums.com/article/metal-gear-solid-2-all-dog-tags-checklist" target="_blank" rel="noopener">"Metal Gear Solid 2 - All Dog Tags Checklist" by Video Chums</a> — the complete 394-tag list with guard names and areas.</p>
|
||
<p class="srcline">📊 <a href="https://docs.google.com/spreadsheets/d/1tDpHI_G7du0fnnNdc8--zyoULuoneiBNJByxXrmixWU/edit" target="_blank" rel="noopener">Dog Tag Checklist spreadsheet by u/Spikeyroxas</a> — source of the per-guard <b>conditional/missable</b> notes (spawn windows, who replaces whom, who only appears after Fatman, etc.).</p>
|
||
<p class="srcline">📖 <a href="https://daynglsgameguides.com/2023/10/23/metal-gear-solid-2-trophy-achievement-guide-master-collection/" target="_blank" rel="noopener">Dayngl's Guides — MGS2 Trophy & Achievement Guide</a> — cross-check of unlock conditions, and the source of the second set of location screenshots used in the Roadmap.</p>
|
||
<p class="srcline">📷 <b>Location screenshots</b> in the Roadmap come from those two guides and are hot-linked from their CDNs (Steam Community and Dayngl's Guides). Click any thumbnail to open the full-size original on the source site. If you are the author of either guide and would rather they were not linked, say so and they can be dropped.</p>
|
||
<p class="srcline">🏅 <a href="https://truesteamachievements.com/game/METAL-GEAR-SOLID-2-Sons-of-Liberty-Master-Collection-Version/achievements" target="_blank" rel="noopener">TrueSteamAchievements — MGS2 MC achievement list</a> — authoritative list of all <b>51</b> achievements (the Steam guide's numbering skips one).</p>
|
||
<h3>Notes on version differences</h3>
|
||
<ul class="tight">
|
||
<li><b>Silence is Golden</b> is "shoot an enemy radio" (the 2011 HD version required 10).</li>
|
||
<li><b>My Rations…</b> requires getting sea lice all over your rations, not just finding one.</li>
|
||
<li><b>Tell Me a Tale</b> only needs <b>one</b> Snake Tale (the HD version required all five).</li>
|
||
<li><b>Virtually Impossible</b> (clear every VR mission) was <b>removed</b> from the Master Collection.</li>
|
||
<li>There is no platinum on Steam — 51 achievements, and <b>Big Boss</b> is the completion blocker.</li>
|
||
<li>The dog-tag achievements no longer require every tag on every difficulty — Very Easy + Easy + Normal is enough.</li>
|
||
</ul>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>About your saved progress</h2>
|
||
<p>Your ticks are stored in this browser's <code>localStorage</code> under the key <code>mgs2mc-guide-v1</code>.
|
||
They persist between sessions on the same browser, but are <b>per-browser and per-origin</b> — if you open the file from a
|
||
different path or a different browser you'll see a fresh checklist.</p>
|
||
<p>Use <b>Export</b> to download a <code>mgs2-progress.json</code> backup, and <b>Import</b> to restore it — handy for moving
|
||
from your desktop to the machine next to your console, or for backing up before a big run.</p>
|
||
<div class="infobox">Tip: keep this page open on a second screen and use the tabs. The header counters update live and the
|
||
page auto-saves on every tick.</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ============================================================
|
||
EVENTS
|
||
============================================================ */
|
||
function toggleAch(id){
|
||
S.ach[id]=!S.ach[id];
|
||
if(!S.ach[id]) delete S.ach[id];
|
||
save(); syncAch(id); subsRefresh();
|
||
}
|
||
function toggleItem(key){
|
||
S.items[key]=!S.items[key];
|
||
if(!S.items[key]) delete S.items[key];
|
||
save();
|
||
document.querySelectorAll(`[data-item="${key}"]`).forEach(cb=>{ cb.checked=!!S.items[key]; });
|
||
subsRefresh();
|
||
}
|
||
function syncAch(id){
|
||
// checkbox states in every tab that shows this achievement
|
||
document.querySelectorAll(`[data-achbox="${id}"]`).forEach(cb=>{cb.checked=!!S.ach[id]; cb.classList.toggle('on',!!S.ach[id]);});
|
||
document.querySelectorAll(`.ach[data-ach="${id}"]`).forEach(c=>c.classList.toggle('done',!!S.ach[id]));
|
||
document.querySelectorAll('.mchk').forEach(cb=>{ if(cb.dataset.achbox===id){cb.checked=!!S.ach[id];} });
|
||
paintProgress();
|
||
}
|
||
function toggleRoad(id){
|
||
S.road[id]=!S.road[id]; if(!S.road[id]) delete S.road[id];
|
||
save();
|
||
document.querySelectorAll(`[data-roadbox="${id}"]`).forEach(cb=>cb.checked=!!S.road[id]);
|
||
document.querySelectorAll(`.stop[data-road="${id}"]`).forEach(c=>c.classList.toggle('done',!!S.road[id]));
|
||
paintProgress();
|
||
}
|
||
function toggleTag(key){
|
||
S.tags[key]=!S.tags[key]; if(!S.tags[key]) delete S.tags[key];
|
||
save();
|
||
document.querySelectorAll(`[data-tagbox="${key}"]`).forEach(cb=>cb.checked=!!S.tags[key]);
|
||
document.querySelectorAll(`.dt[data-tag="${key}"]`).forEach(c=>c.classList.toggle('done',!!S.tags[key]));
|
||
paintProgress();
|
||
// refresh chips + threshold cards without losing scroll
|
||
const y=window.scrollY;
|
||
const chips=document.querySelectorAll('.chip[data-list]');
|
||
chips.forEach(ch=>{
|
||
const l=DOGTAGS[+ch.dataset.list];
|
||
ch.innerHTML=`${l.chapter==='Tanker'?'🚢':'🏭'} ${esc(l.difficulty)} <span class="c">${listDone(l)}/${l.tags.length}</span>`;
|
||
});
|
||
document.querySelectorAll('.thrcard').forEach(()=>{});
|
||
subsRefresh();
|
||
window.scrollTo({top:y,behavior:'instant'});
|
||
}
|
||
|
||
document.addEventListener('click',e=>{
|
||
// achievement card / checkbox
|
||
const ab=e.target.closest('[data-achbox]');
|
||
if(ab){ if(ab.closest('#tab-miss')||ab.closest('.subs')) return; e.stopPropagation(); toggleAch(ab.dataset.achbox); return; }
|
||
const ac=e.target.closest('.ach .achhead');
|
||
if(ac){ ac.parentElement.classList.toggle('open'); return; }
|
||
// roadmap
|
||
const rb=e.target.closest('[data-roadbox]');
|
||
if(rb){ e.stopPropagation(); toggleRoad(rb.dataset.roadbox); return; }
|
||
const rh=e.target.closest('.stop .sh');
|
||
if(rh){ const st=rh.parentElement; st.classList.toggle('open');
|
||
if(st.classList.contains('open')) openSteps.add(st.dataset.road); else openSteps.delete(st.dataset.road);
|
||
return; }
|
||
const rd=e.target.closest('[data-roaddiff]');
|
||
if(rd){ S.roadDiff=rd.dataset.roaddiff; save(); renderRoad(); return; }
|
||
// tags
|
||
const tb=e.target.closest('[data-tagbox]');
|
||
if(tb){ if(tb.closest('.subs')) return; e.stopPropagation(); toggleTag(tb.dataset.tagbox); return; }
|
||
const td=e.target.closest('.dt');
|
||
if(td){ toggleTag(td.dataset.tag); return; }
|
||
// chips
|
||
const c=e.target.closest('.chip[data-cat]');
|
||
if(c){ achFilter=c.dataset.cat; renderAch(); return; }
|
||
const cl=e.target.closest('.chip[data-list]');
|
||
if(cl){ tagList=+cl.dataset.list; tagQuery=''; renderTags(); return; }
|
||
// maps
|
||
const gm=e.target.closest('[data-gomap]');
|
||
if(gm){ mapIdx=tagList; renderMaps(); go('maps'); return; }
|
||
const cm=e.target.closest('.chip[data-map]');
|
||
if(cm){ mapIdx=+cm.dataset.map; renderMaps(); return; }
|
||
const zb=e.target.closest('[data-zoom]');
|
||
if(zb){
|
||
const v=zb.dataset.zoom;
|
||
if(v==='+') mapZoom=Math.min(4, Math.round((mapZoom+0.25)*100)/100);
|
||
else if(v==='-') mapZoom=Math.max(0.4, Math.round((mapZoom-0.25)*100)/100);
|
||
else if(v==='fit'){
|
||
const wrap=document.getElementById('mapWrap');
|
||
const M=MAPS[mapIdx], off=mapColOffsets(M);
|
||
if(wrap) mapZoom=Math.max(0.3, Math.min(4, (wrap.clientWidth-6)/off[M.c1-M.c0+1]));
|
||
}
|
||
else mapZoom=+v;
|
||
drawMap(); return;
|
||
}
|
||
const ts=e.target.closest('[data-tscale]');
|
||
if(ts){
|
||
mapTextScale = ts.dataset.tscale==='+' ? Math.min(3, mapTextScale+0.15)
|
||
: Math.max(0.6, mapTextScale-0.15);
|
||
mapTextScale=Math.round(mapTextScale*100)/100;
|
||
const l=document.getElementById('tscaleLbl');
|
||
if(l) l.textContent=Math.round(mapTextScale*100)+'%';
|
||
drawMap(); return;
|
||
}
|
||
});
|
||
|
||
document.getElementById('btnHide').addEventListener('click',()=>{
|
||
S.hideDone=!S.hideDone; save();
|
||
document.getElementById('btnHide').textContent='Hide done: '+(S.hideDone?'on':'off');
|
||
renderAch(); renderTags();
|
||
});
|
||
document.getElementById('btnExport').addEventListener('click',()=>{
|
||
const blob=new Blob([JSON.stringify(S,null,1)],{type:'application/json'});
|
||
const a=document.createElement('a');
|
||
a.href=URL.createObjectURL(blob); a.download='mgs2-progress.json'; a.click();
|
||
toast('Progress exported');
|
||
});
|
||
document.getElementById('btnImport').addEventListener('click',()=>document.getElementById('fileIn').click());
|
||
document.getElementById('fileIn').addEventListener('change',ev=>{
|
||
const f=ev.target.files[0]; if(!f) return;
|
||
const r=new FileReader();
|
||
r.onload=()=>{ try{
|
||
const p=JSON.parse(r.result);
|
||
S=Object.assign({ach:{},tags:{},road:{},hideDone:false,tab:S.tab},p);
|
||
save(); boot(true); toast('Progress imported');
|
||
}catch(e){ toast('That file could not be read'); } };
|
||
r.readAsText(f);
|
||
});
|
||
document.getElementById('btnReset').addEventListener('click',()=>{
|
||
if(!confirm('Erase every tick (achievements, dog tags and roadmap)? This cannot be undone.')) return;
|
||
S={ach:{},tags:{},road:{},hideDone:S.hideDone,tab:S.tab};
|
||
save(); boot(true); toast('Checklist reset');
|
||
});
|
||
|
||
document.getElementById('btnSteam').addEventListener('click',()=>go('steam'));
|
||
|
||
/* sub-checklist tick boxes: let the label toggle natively, then mirror the state */
|
||
document.addEventListener('change', e=>{
|
||
const t=e.target;
|
||
if(!t || t.tagName!=='INPUT') return;
|
||
const kind=t.dataset.sub;
|
||
if(!kind) return;
|
||
if(kind==='ach') toggleAch(t.dataset.achbox);
|
||
else if(kind==='tag') toggleTag(t.dataset.tagbox);
|
||
else if(kind==='item') toggleItem(t.dataset.item);
|
||
});
|
||
|
||
/* ---------- lightbox wiring ---------- */
|
||
let lbDragged=false;
|
||
document.addEventListener('click', e=>{
|
||
const a=e.target.closest('a.lb');
|
||
if(a){ e.preventDefault(); lbOpen(a); return; }
|
||
if(e.target.closest('#lbClose')){ lbClose(); return; }
|
||
if(e.target.closest('#lbZoom')){ lbToggleZoom(null); return; }
|
||
if(e.target.closest('#lbPrev')){ lbStep(-1); return; }
|
||
if(e.target.closest('#lbNext')){ lbStep(1); return; }
|
||
const stage=e.target.closest('#lbStage');
|
||
if(stage){
|
||
if(e.target.id==='lbImg'){
|
||
if(lbDragged){ lbDragged=false; return; } // that was a pan, not a click
|
||
lbToggleZoom(e);
|
||
} else if(!lbZoom){
|
||
lbClose(); // click the backdrop to close
|
||
}
|
||
}
|
||
});
|
||
document.addEventListener('keydown', e=>{
|
||
if(document.getElementById('lightbox').hidden) return;
|
||
if(e.key==='Escape'){ lbClose(); }
|
||
else if(e.key==='ArrowLeft'){ lbStep(-1); }
|
||
else if(e.key==='ArrowRight'){ lbStep(1); }
|
||
else if(e.key===' '){ e.preventDefault(); lbToggleZoom(null); }
|
||
});
|
||
(function lbDrag(){
|
||
const st=document.getElementById('lbStage');
|
||
let down=false, sx=0, sy=0, sl=0, sp=0;
|
||
st.addEventListener('mousedown', e=>{
|
||
if(!lbZoom || e.target.id!=='lbImg') return;
|
||
down=true; lbDragged=false; sx=e.clientX; sy=e.clientY;
|
||
sl=st.scrollLeft; sp=st.scrollTop;
|
||
st.classList.add('dragging'); e.preventDefault();
|
||
});
|
||
window.addEventListener('mousemove', e=>{
|
||
if(!down) return;
|
||
if(Math.abs(e.clientX-sx)+Math.abs(e.clientY-sy) > 3) lbDragged=true;
|
||
st.scrollLeft=sl-(e.clientX-sx);
|
||
st.scrollTop =sp-(e.clientY-sy);
|
||
});
|
||
window.addEventListener('mouseup', ()=>{ down=false; st.classList.remove('dragging'); });
|
||
})();
|
||
|
||
/* ============================================================
|
||
THEMES
|
||
============================================================ */
|
||
const THEMES=[
|
||
['codec','Codec (default)'],
|
||
['gruvbox','Gruvbox Dark'],
|
||
['monokai','Monokai'],
|
||
['solarized-dark','Solarized Dark'],
|
||
['dracula','Dracula'],
|
||
['nord','Nord'],
|
||
['tokyo-night','Tokyo Night'],
|
||
['catppuccin','Catppuccin Mocha'],
|
||
['one-dark','One Dark'],
|
||
['rose-pine','Rosé Pine'],
|
||
];
|
||
function setTheme(k){
|
||
if(!k || k==='codec') document.documentElement.removeAttribute('data-theme');
|
||
else document.documentElement.setAttribute('data-theme',k);
|
||
try{ localStorage.setItem('mgs2-theme', k||'codec'); }catch(e){}
|
||
}
|
||
function buildThemes(){
|
||
const sel=document.getElementById('themeSel');
|
||
if(!sel) return;
|
||
sel.innerHTML=THEMES.map(([k,l])=>`<option value="${k}">${l}</option>`).join('');
|
||
let cur='codec';
|
||
try{ cur=localStorage.getItem('mgs2-theme')||'codec'; }catch(e){}
|
||
if(!THEMES.some(t=>t[0]===cur)){ cur='codec'; setTheme('codec'); } // e.g. a theme that was removed
|
||
sel.value=cur;
|
||
sel.addEventListener('change',()=>setTheme(sel.value));
|
||
}
|
||
|
||
/* ============================================================
|
||
BOOT
|
||
============================================================ */
|
||
function boot(skipLoad){
|
||
if(!skipLoad) load();
|
||
if(!S.steam.profile && DEFAULT_PROFILE) S.steam.profile = DEFAULT_PROFILE;
|
||
document.getElementById('btnHide').textContent='Hide done: '+(S.hideDone?'on':'off');
|
||
buildThemes();
|
||
buildTabs();
|
||
renderRoad(); renderAch(); renderTags(); renderMaps(); renderSteam(); renderBoss(); renderMiss(); renderVR(); renderSrc();
|
||
go(S.tab||'roadmap');
|
||
paintProgress();
|
||
if(S.steam.profile) setTimeout(()=>syncSteam(S.steam.profile),350);
|
||
}
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|