Something went wrong. Try again.
an npmx inspired game for PICO-8 npicomx.vinnymac.dev
games pico-8 adventure npmx
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" /> <meta name="theme-color" content="#000000" /> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <title>an npmx inspired game for PICO-8</title> <meta name="description" content="an npmx inspired game for PICO-8 - guide a butterfly to rescue npm packages from dependency hell, right in your browser." />
<!-- icons + PWA manifest --> <link rel="icon" href="/favicons/favicon.ico" sizes="48x48" /> <link rel="icon" href="/favicons/icon.svg" type="image/svg+xml" /> <link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png" /> <link rel="manifest" href="/manifest.webmanifest" /> <meta name="apple-mobile-web-app-title" content="npicomx" />
<!-- Open Graph --> <meta property="og:type" content="website" /> <meta property="og:site_name" content="npicomx" /> <meta property="og:title" content="./ npicomx" /> <meta property="og:description" content="an npmx inspired game for PICO-8" /> <meta property="og:url" content="https://npicomx.vinnymac.dev/" /> <meta property="og:image" content="https://npicomx.vinnymac.dev/og.png" /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta property="og:image:alt" content="The ./ npicomx wordmark in the PICO-8 font, slash in blue, on black" />
<!-- Twitter card --> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="./ npicomx" /> <meta name="twitter:description" content="an npmx inspired game for PICO-8" /> <meta name="twitter:image" content="https://npicomx.vinnymac.dev/og.png" />
<!-- custom player: npicomx.js in our canvas with touch controls; run `make web` first --> <style> /* dark by default, light on OS preference */ :root { --blue: #29adff; /* primary accent (the "/" + O button) */ --bsky-blue: #1185fe; /* mobile O/X controls */ --deep: #0a78c8; /* pressed-state accent */ --pink: #ff77a8; /* X button accent */ --ink: #fff1e8; --bg: #000000; --muted: #6c7a8a; --panel: #10151c; /* mobile controller surface */ --hdr-border: #1d2b53; /* mobile header divider */ --ctrl-border: #1d2b53; /* controller top border */ --ctrl-btn: #1a2433; /* d-pad / X fill */ } @media (prefers-color-scheme: light) { :root { --ink: #0b0f14; --bg: #f0ebe4; --muted: #5a6472; --panel: #ddd8d0; --hdr-border: #c8c0b0; --ctrl-border: #c8c0b0; --ctrl-btn: #c0b8ae; } }
/* kill text selection + touch callout everywhere */ *, *::before, *::after { box-sizing: border-box; -webkit-user-select: none; -moz-user-select: none; user-select: none; -webkit-touch-callout: none; }
html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: "Courier New", ui-monospace, system-ui, monospace; overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: none; /* no pan/zoom/double-tap-select anywhere */ } body { height: 100%; min-height: 100dvh; }
/* buttons: kill tap flash, block text selection, prevent double-tap zoom */ button { -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; touch-action: manipulation; cursor: pointer; }
/* ── Desktop layout ── */ #stage { position: absolute; inset: 0; margin: auto; width: 512px; height: 512px; display: flex; align-items: center; justify-content: center; } canvas#canvas { width: 512px; height: 512px; background: #000; image-rendering: pixelated; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
/* ── Fullscreen: canvas scales to fill viewport, centered, square aspect ratio ── */ :fullscreen #stage { position: fixed; inset: 0; width: 100vw; height: 100dvh; background: #000; } :fullscreen canvas#canvas { width: min(100vw, 100dvh) !important; height: min(100vw, 100dvh) !important; } :-webkit-full-screen #stage { position: fixed; inset: 0; width: 100vw; height: 100dvh; background: #000; } :-webkit-full-screen canvas#canvas { width: min(100vw, 100dvh) !important; height: min(100vw, 100dvh) !important; }
/* ── CRT shader overlay - hidden until TV toggle; crt.js positions it over #canvas ── */ #crt-canvas { position: absolute; display: none; background: #000; pointer-events: none; } #stage.crt-on #crt-canvas { display: block; } /* CRT on: hide raw canvas so curved corners read black, not the square frame */ #stage.crt-on canvas#canvas { visibility: hidden; }
/* ── Tap-to-play overlay - first user gesture unlocks audio + loads cart ── */ #play { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: var(--bg); color: var(--ink); cursor: pointer; } #play.hidden { display: none; } .butterfly-lg { width: 96px; height: 96px; } #play h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px; font-family: 'PICO-8', monospace; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; } #play h1 .s { color: var(--blue); } #play p { margin: 0; color: var(--muted); font-size: 13px; } #btn-play { padding: 12px 34px; border: 0; border-radius: 8px; cursor: pointer; font-size: 18px; font-weight: 600; color: #001019; background: var(--blue); -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; touch-action: manipulation; } #btn-play:active { transform: scale(0.96); }
/* Mobile-only elements - hidden on desktop */ #mobile-header { display: none; } #mobile-controls { display: none; }
/* ── Mobile layout - portrait phones ≤599px or landscape phones (keys off height) ── */ @media (width <= 599px), (orientation: landscape) and (height <= 599px) { html { height: 100dvh; } body { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
/* Header: small butterfly mark + title */ #mobile-header { display: flex; align-items: center; gap: 8px; /* safe-area insets clear iPhone status bar / notch in portrait + landscape */ padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); flex-shrink: 0; /* 50px of visible header below the reserved status-bar strip. */ height: calc(50px + env(safe-area-inset-top)); border-bottom: 1px solid var(--hdr-border); background: var(--bg); } .butterfly-sm { width: 26px; height: 26px; flex-shrink: 0; } #mobile-header .title { font-size: 17px; font-weight: 700; letter-spacing: -0.4px; color: var(--ink); font-family: 'PICO-8', monospace; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; /* button reset */ background: none; border: none; padding: 0; } #mobile-header .title .s { color: var(--blue); } #mobile-header-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; /* clear the fixed TV button that floats in the top-right corner */ margin-right: 44px; } #btn-info-mobile { width: 34px; height: 34px; border-radius: 50%; background: var(--ctrl-btn); color: rgba(255, 255, 255, 0.85); border: none; padding: 0; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(11, 15, 20, 0.25); touch-action: manipulation; cursor: pointer; font-family: 'PICO-8', monospace; font-size: 16px; line-height: 1; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; } #btn-info-mobile:active { transform: scale(0.9); }
/* Stage fills remaining vertical space */ #stage { position: relative; flex: 1 1 auto; width: 100%; min-height: 0; margin: 0; background: #000; display: flex; align-items: center; justify-content: center; }
/* scales to fit viewport minus header (50px), controls (170px), safe-area insets */ canvas#canvas { width: min(100vw, calc(100dvh - 50px - 170px - env(safe-area-inset-top) - env(safe-area-inset-bottom)), 512px) !important; height: min(100vw, calc(100dvh - 50px - 170px - env(safe-area-inset-top) - env(safe-area-inset-bottom)), 512px) !important; }
/* ── Mobile game controller ── */ #mobile-controls { position: relative; /* anchors the absolute pause pill */ flex-shrink: 0; display: flex; align-items: flex-end; justify-content: flex-start; gap: 30px; padding: 12px 12px 12px 6px; padding-bottom: max(12px, calc(12px + env(safe-area-inset-bottom))); /* extend bar by bottom inset, keeping inner control room constant */ height: calc(170px + env(safe-area-inset-bottom)); background: var(--panel); border-top: 1px solid var(--ctrl-border); /* block touch passthrough: user-select + touch-action none throughout */ user-select: none; -webkit-user-select: none; touch-action: none; } /* cover empty d-pad centre so taps there don't reach the canvas */ .dpad { user-select: none; -webkit-user-select: none; touch-action: none; } #mobile-controls.hidden { display: none; }
/* D-pad: 3×3 grid, centre cell empty */ .dpad { display: grid; grid-template-columns: repeat(3, 50px); grid-template-rows: repeat(3, 50px); gap: 3px; transform: translate(7px, 7px); } .dpad-btn { width: 50px; height: 50px; padding: 0; border: none; border-radius: 10px; font-size: 15px; line-height: 1; background: var(--ctrl-btn); color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; justify-content: center; transition: background 0.07s, transform 0.07s; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); touch-action: none; } .dpad-btn.pressed { background: var(--deep); transform: scale(0.89); } .dpad-btn-up { grid-column: 2; grid-row: 1; } .dpad-btn-left { grid-column: 1; grid-row: 2; } .dpad-btn-right { grid-column: 3; grid-row: 2; } .dpad-btn-down { grid-column: 2; grid-row: 3; } /* one arrow shape rotated per direction on the inner span */ .dpad-arrow { display: block; line-height: 1; pointer-events: none; } .dpad-btn-right .dpad-arrow { transform: rotate(90deg); } .dpad-btn-down .dpad-arrow { transform: rotate(180deg); } .dpad-btn-left .dpad-arrow { transform: rotate(270deg); }
/* Action buttons: diagonal - O upper-right, X lower-left (Nintendo-style) */ .action-btns { position: relative; width: 116px; height: 116px; } .action-btn { position: absolute; width: 58px; height: 58px; padding: 0; border: none; border-radius: 50%; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; transition: transform 0.07s, opacity 0.07s; letter-spacing: 0; touch-action: none; } .action-btn.pressed { transform: scale(0.88); opacity: 0.8; } .btn-o { top: 5px; right: 0; background: var(--bsky-blue); color: #fff; box-shadow: 0 2px 8px rgba(17, 133, 254, 0.4); } .btn-x { bottom: 5px; left: 0; background: var(--ctrl-btn); color: rgba(255, 255, 255, 0.9); box-shadow: 0 2px 4px rgba(11, 15, 20, 0.25); }
/* Pause button - thin pill, top-right corner of the controls bar */ #btn-pause { position: absolute; top: 10px; right: max(14px, env(safe-area-inset-right)); padding: 4px 12px; border-radius: 10px; background: var(--ctrl-btn); color: rgba(255, 255, 255, 0.7); font-size: 11px; font-weight: 500; letter-spacing: 0.03em; border: none; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); transition: background 0.07s, transform 0.07s, opacity 0.07s; touch-action: none; } #btn-pause.pressed { background: var(--deep); transform: scale(0.89); }
/* play cover spans full viewport incl. mobile header */ #play { position: fixed; height: 100dvh; } }
/* ── Mobile landscape: canvas sized by short axis, controls flank it in gutters ── */ @media (orientation: landscape) and (height <= 599px) { /* full viewport height; header title lives in side gutter */ canvas#canvas { width: min(100dvh, calc(100dvw - 360px), 512px) !important; height: min(100dvh, calc(100dvw - 360px), 512px) !important; }
/* transparent stage so page-bg gutters show around canvas */ #stage { position: fixed; inset: 0; width: 100dvw; height: 100dvh; background: transparent; z-index: 2; } /* No divider - its bg already matches the gutters; the line would cross them. */ #mobile-header { border-bottom: none; }
/* full-viewport overlay; only buttons take touches, canvas stays interactive */ #mobile-controls { position: fixed; inset: 0; height: auto; padding: 0; background: none; border-top: none; pointer-events: none; z-index: 5; } #mobile-controls > * { pointer-events: auto; }
/* d-pad: left gutter, vertically centred */ .dpad { position: absolute; top: 50%; left: max(14px, calc(env(safe-area-inset-left) + 8px)); transform: translateY(-50%); }
/* Action buttons: right gutter, vertically centred. */ .action-btns { position: absolute; top: 50%; right: max(14px, calc(env(safe-area-inset-right) + 8px)); transform: translateY(-50%); }
/* Pause button: bottom of the left gutter, aligned with the d-pad's left edge. Keeping left == d-pad-left (not d-pad-right + gap) ensures the button never overflows into the canvas regardless of safe-area size or phone width. top/right use auto (not unset) to reliably clear the portrait values on mobile Safari. */ #btn-pause { position: absolute; top: auto; right: auto; left: max(14px, calc(env(safe-area-inset-left) + 8px)); bottom: max(8px, calc(8px + env(safe-area-inset-bottom))); transform: none; } } /* ── PICO-8 font (MIT license, jacobpierce/pico-8-font) ── */ @font-face { font-family: 'PICO-8'; src: url('/fonts/PICO-8.eot'); src: url('/fonts/PICO-8.eot?#iefix') format('embedded-opentype'), url('/fonts/PICO-8.ttf') format('truetype'); font-weight: normal; font-style: normal; }
/* ── Persistent settings cluster (audio + help) - fixed bottom-right ── */ #settings { position: fixed; bottom: max(10px, calc(10px + env(safe-area-inset-bottom))); right: max(14px, env(safe-area-inset-right)); z-index: 11; display: flex; gap: 8px; } #settings button { width: 34px; height: 34px; border-radius: 50%; background: var(--ctrl-btn); color: rgba(255, 255, 255, 0.85); padding: 0; border: none; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(11, 15, 20, 0.25); transition: opacity 0.15s, transform 0.07s; touch-action: manipulation; } #settings button:active { transform: scale(0.9); } #settings button.muted { opacity: 0.45; }
/* CRT toggle: grey when off, white when on */ #settings #btn-tv { color: var(--muted); } #settings #btn-tv.on { color: #fff; }
/* Help and info buttons - PICO-8 font, desktop settings cluster only */ #btn-help, #btn-info { font-family: 'PICO-8', monospace; font-size: 16px; line-height: 1; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; } @media (width <= 599px), (orientation: landscape) and (height <= 599px) { #settings #btn-help { display: none; } } @media (width <= 599px), (orientation: landscape) and (height <= 599px) { #settings #btn-info { display: none; } } @media (width <= 599px), (orientation: landscape) and (height <= 599px) { #settings #btn-fullscreen { display: none; } }
/* hide tooltips; showPopover() overrides this on supported browsers */ .btn-tooltip { display: none; }
/* ── Button tooltips (desktop hover devices only) ── */ @media (hover: hover) { #btn-audio { anchor-name: --btn-audio; } #btn-fullscreen { anchor-name: --btn-fullscreen; } #btn-tv { anchor-name: --btn-tv; } #btn-help { anchor-name: --btn-help; } #btn-info { anchor-name: --btn-info; }
/* Dark theme (default): light chip on dark page (10:1 contrast) */ .btn-tooltip { margin: 0; padding: 3px 9px; border: 1px solid rgba(11, 15, 20, 0.15); border-radius: 6px; background: #c8d4e0; color: #0b0f14; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font-size: 11px; font-weight: 600; line-height: 1.4; white-space: nowrap; pointer-events: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); opacity: 0; transition: opacity 0.12s ease; } /* Light theme override: dark chip on warm-beige page (8:1 contrast) */ @media (prefers-color-scheme: light) { .btn-tooltip { background: #1a2433; color: rgba(255, 255, 255, 0.9); border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 2px 8px rgba(11, 15, 20, 0.25); } } @supports (anchor-name: --x) { .btn-tooltip { position: fixed; inset: unset; position-area: top center; margin-bottom: 7px; } #tip-audio { position-anchor: --btn-audio; } #tip-fullscreen { position-anchor: --btn-fullscreen; } #tip-tv { position-anchor: --btn-tv; } #tip-help { position-anchor: --btn-help; } #tip-info { position-anchor: --btn-info; } } .btn-tooltip:popover-open { display: block; opacity: 1; } }
/* mobile: TV toggle moves to top-right header corner, below play cover z-index */ @media (width <= 599px), (orientation: landscape) and (height <= 599px) { #settings #btn-tv { position: fixed; top: max(9px, calc(9px + env(safe-area-inset-top))); right: max(14px, env(safe-area-inset-right)); z-index: 11; } }
/* ── Keyboard shortcuts dialog ── */ #shortcuts-dialog { border: 3px solid var(--ctrl-border); background: var(--panel); color: var(--ink); font-family: 'PICO-8', monospace; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; font-size: 10px; padding: 0; max-width: 380px; width: calc(100vw - 32px); border-radius: 0; box-shadow: 4px 4px 0 rgba(0,0,0,0.35); } #shortcuts-dialog::backdrop { background: rgba(0, 0, 0, 0.55); } .shortcuts-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 8px; border-bottom: 2px solid var(--ctrl-border); background: var(--ctrl-btn); color: #fff1e8; } .shortcuts-header h2 { margin: 0; font-size: 12px; font-weight: normal; letter-spacing: 0.5px; text-transform: lowercase; } #shortcuts-close, #info-close { width: 20px; height: 20px; padding: 0; border: 2px solid rgba(255,255,255,0.3); border-radius: 0; background: transparent; color: #fff1e8; font-family: 'PICO-8', monospace; font-size: 12px; line-height: 1; display: flex; align-items: center; justify-content: center; cursor: pointer; -webkit-font-smoothing: none; flex-shrink: 0; } #shortcuts-close:hover, #info-close:hover { background: rgba(255,255,255,0.15); } #shortcuts-close:active, #info-close:active { transform: scale(0.9); } .shortcuts-section { padding: 0; } .shortcuts-section-label { font-size: 10px; text-transform: lowercase; color: var(--muted); padding: 6px 12px 4px; letter-spacing: 0.5px; border-top: 2px solid var(--ctrl-border); } .shortcuts-section-label:first-child { border-top: none; } .shortcuts-table { width: 100%; border-collapse: collapse; font-size: 10px; } .shortcuts-table tr:last-child td { padding-bottom: 8px; } .shortcuts-table td { padding: 3px 12px; vertical-align: middle; line-height: 1.4; white-space: nowrap; } .shortcuts-table td:first-child { width: 1%; padding-right: 16px; } .shortcuts-table td:last-child { color: var(--muted); } kbd { display: inline-block; background: var(--ctrl-btn); color: #fff1e8; font-family: 'PICO-8', monospace; font-size: 10px; -webkit-font-smoothing: none; padding: 2px 4px; border-radius: 0; border-bottom: 2px solid rgba(0,0,0,0.3); white-space: nowrap; line-height: 1; } .key-sep { color: var(--muted); padding: 0 2px; font-size: 10px; }
/* ── Credits dialog ── */ #info-dialog { border: 3px solid var(--ctrl-border); background: var(--panel); color: var(--ink); font-family: 'PICO-8', monospace; -webkit-font-smoothing: none; -moz-osx-font-smoothing: grayscale; font-smooth: never; font-size: 10px; padding: 0; max-width: 360px; width: calc(100vw - 32px); border-radius: 0; box-shadow: 4px 4px 0 rgba(0,0,0,0.35); } #info-dialog::backdrop { background: rgba(0, 0, 0, 0.55); } .info-body { padding: 12px 14px 14px; line-height: 1.8; display: flex; flex-direction: column; gap: 4px; } .info-body > p { margin: 0; } .info-thanks { color: var(--muted); font-size: 9px; padding-top: 8px; } .info-list { margin: 0; padding: 0; list-style: none; line-height: 2; } .info-list li::before { content: "- "; color: var(--muted); } .info-list a { color: var(--blue); text-decoration: none; } .info-list a:hover { text-decoration: underline; } .info-cross { color: var(--muted); font-size: 9px; padding-top: 8px; } .info-cross a { color: var(--blue); text-decoration: none; } .info-cross a:hover { text-decoration: underline; } </style> </head> <body>
<!-- Mobile-only header: logo title (tappable reset once the cart is running) --> <div id="mobile-header"> <button id="btn-logo" class="title" aria-label="Return to start screen">.<span class="s">/</span> npicomx</button> <div id="mobile-header-actions"> <button id="btn-info-mobile" aria-label="Info" aria-haspopup="dialog"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 8" shape-rendering="crispEdges" fill="currentColor" width="12" height="16" aria-hidden="true"><rect x="2" y="0" width="2" height="2"/><rect x="2" y="3" width="2" height="5"/></svg></button> </div> </div>
<!-- PICO-8 canvas stage --> <div id="stage"> <canvas id="canvas" class="emscripten" oncontextmenu="event.preventDefault()" tabindex="-1" ></canvas> <!-- CRT shader output, hidden until TV toggle --> <canvas id="crt-canvas" aria-hidden="true"></canvas> </div>
<!-- Mobile game controller (shown only on touch / small viewports) --> <div id="mobile-controls" class="hidden"> <div class="dpad" role="group" aria-label="Directional pad"> <button class="dpad-btn dpad-btn-up" data-pico8btn="2" aria-label="Up"><span class="dpad-arrow">▲</span></button> <button class="dpad-btn dpad-btn-left" data-pico8btn="0" aria-label="Left"><span class="dpad-arrow">▲</span></button> <button class="dpad-btn dpad-btn-right" data-pico8btn="1" aria-label="Right"><span class="dpad-arrow">▲</span></button> <button class="dpad-btn dpad-btn-down" data-pico8btn="3" aria-label="Down"><span class="dpad-arrow">▲</span></button> </div> <div class="action-btns" role="group" aria-label="Action buttons"> <button class="action-btn btn-x" data-pico8btn="5" aria-label="X (drop)">X</button> <button class="action-btn btn-o" data-pico8btn="4" aria-label="O (grab/deliver)">O</button> </div> <button id="btn-pause" aria-label="Pause menu">pause</button> </div>
<!-- Keyboard shortcuts dialog (desktop only; opened by #btn-help) --> <dialog id="shortcuts-dialog" aria-label="Keyboard shortcuts"> <div class="shortcuts-header"> <h2>keyboard shortcuts</h2> <button id="shortcuts-close" aria-label="Close">x</button> </div>
<!-- Gameplay --> <div class="shortcuts-section"> <div class="shortcuts-section-label">gameplay</div> <table class="shortcuts-table"> <tbody> <tr><td><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd></td><td>move</td></tr> <tr><td><kbd>Z</kbd><span class="key-sep">/</span><kbd>C</kbd></td><td>O - grab / deliver</td></tr> <tr><td><kbd>X</kbd><span class="key-sep">/</span><kbd>V</kbd></td><td>X - drop</td></tr> </tbody> </table> </div>
<!-- PICO-8 system --> <div class="shortcuts-section"> <div class="shortcuts-section-label">pico-8 system</div> <table class="shortcuts-table"> <tbody> <tr><td><kbd>Enter</kbd><span class="key-sep">/</span><kbd>P</kbd></td><td>pause menu</td></tr> <tr><td><kbd>Ctrl</kbd>+<kbd>M</kbd></td><td>mute / unmute</td></tr> <tr><td><kbd>Alt</kbd>+<kbd>Enter</kbd></td><td>fullscreen</td></tr> <tr><td><kbd>Ctrl</kbd>+<kbd>R</kbd></td><td>restart cart</td></tr> </tbody> </table> </div> </dialog>
<!-- Info dialog (opened by #btn-info on desktop and #btn-info-mobile in header) --> <dialog id="info-dialog" aria-label="Info"> <div class="shortcuts-header"> <h2>info</h2> <button id="info-close" aria-label="Close">x</button> </div> <div class="info-body"> <p>npicomx v0.2 made by <a href="https://tangled.org/vinnymac.dev">vinnymac</a> (<a href="https://tangled.org/vinnymac.dev/npicomx">source</a>)</p> <p class="info-thanks">special thanks to:</p> <ul class="info-list"> <li>zep for <a href="https://www.lexaloffle.com/pico-8.php">PICO-8</a></li><li>gingerbeardman for <a href="https://github.com/gingerbeardman/webgl-crt-shader">webgl-crt-shader</a></li> <li><a href="https://npmx.dev">npmx team</a></li> <li>maddy thorson & noel berry for inspiration from celeste classic</li> </ul> <p class="info-cross">want more? try <a href="https://picosky.vinnymac.dev">picosky</a></p> </div> </dialog>
<!-- Persistent audio + help buttons: fixed bottom-right, visible after play starts --> <div id="settings"> <button id="btn-audio" aria-label="Toggle audio" aria-pressed="false" aria-describedby="tip-audio"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 8" shape-rendering="crispEdges" fill="white" width="18" height="13" aria-hidden="true"> <!-- speaker body: horn + box --> <rect x="3" y="0" width="1" height="1"/><rect x="2" y="1" width="2" height="1"/> <rect x="1" y="2" width="3" height="1"/><rect x="0" y="3" width="4" height="2"/> <rect x="1" y="5" width="3" height="1"/><rect x="2" y="6" width="2" height="1"/> <rect x="3" y="7" width="1" height="1"/> <!-- waves: two ) arcs --> <g class="audio-waves"> <rect x="5" y="1" width="1" height="1"/><rect x="6" y="2" width="1" height="4"/><rect x="5" y="6" width="1" height="1"/> <rect x="7" y="0" width="1" height="1"/><rect x="8" y="1" width="1" height="6"/><rect x="7" y="7" width="1" height="1"/> </g> <!-- mute X: diagonal cross --> <g class="audio-mute" style="display:none"> <rect x="5" y="2" width="1" height="1"/><rect x="6" y="3" width="1" height="1"/> <rect x="7" y="4" width="1" height="1"/><rect x="8" y="5" width="1" height="1"/> <rect x="8" y="2" width="1" height="1"/><rect x="7" y="3" width="1" height="1"/> <rect x="6" y="4" width="1" height="1"/><rect x="5" y="5" width="1" height="1"/> </g> </svg> </button> <span id="tip-audio" class="btn-tooltip" role="tooltip" popover="manual">Audio</span> <button id="btn-fullscreen" aria-label="Enter fullscreen" aria-pressed="false" aria-describedby="tip-fullscreen"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" shape-rendering="crispEdges" fill="currentColor" width="16" height="16" aria-hidden="true"> <g class="fs-expand"> <rect x="0" y="0" width="3" height="1"/><rect x="0" y="1" width="1" height="2"/> <rect x="9" y="0" width="3" height="1"/><rect x="11" y="1" width="1" height="2"/> <rect x="0" y="11" width="3" height="1"/><rect x="0" y="9" width="1" height="2"/> <rect x="9" y="11" width="3" height="1"/><rect x="11" y="9" width="1" height="2"/> </g> <g class="fs-compress" style="display:none"> <rect x="0" y="3" width="3" height="1"/><rect x="3" y="0" width="1" height="3"/> <rect x="9" y="3" width="3" height="1"/><rect x="8" y="0" width="1" height="3"/> <rect x="0" y="8" width="3" height="1"/><rect x="3" y="9" width="1" height="3"/> <rect x="9" y="8" width="3" height="1"/><rect x="8" y="9" width="1" height="3"/> </g> </svg> </button> <span id="tip-fullscreen" class="btn-tooltip" role="tooltip" popover="manual">Full</span> <!-- CRT / TV toggle --> <button id="btn-tv" aria-label="Toggle CRT effect" aria-pressed="false" aria-describedby="tip-tv"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 11" shape-rendering="crispEdges" fill="currentColor" width="18" height="16" aria-hidden="true"> <rect x="2" y="0" width="1" height="1"/><rect x="3" y="1" width="1" height="1"/><rect x="4" y="2" width="1" height="1"/> <rect x="9" y="0" width="1" height="1"/><rect x="8" y="1" width="1" height="1"/><rect x="7" y="2" width="1" height="1"/> <rect x="5" y="3" width="2" height="1"/> <rect x="0" y="4" width="12" height="1"/> <rect x="0" y="9" width="12" height="1"/> <rect x="0" y="5" width="1" height="4"/> <rect x="11" y="5" width="1" height="4"/> <rect x="8" y="5" width="1" height="4"/> <rect x="9" y="6" width="1" height="1"/><rect x="9" y="8" width="1" height="1"/> <rect x="2" y="10" width="2" height="1"/><rect x="8" y="10" width="2" height="1"/> </svg> </button> <span id="tip-tv" class="btn-tooltip" role="tooltip" popover="manual">CRT</span> <!-- Desktop-only: opens the keyboard shortcuts dialog --> <button id="btn-help" aria-label="Keyboard shortcuts" aria-haspopup="dialog" aria-describedby="tip-help">?</button> <span id="tip-help" class="btn-tooltip" role="tooltip" popover="manual">Help</span> <button id="btn-info" aria-label="Info" aria-haspopup="dialog" aria-describedby="tip-info"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 8" shape-rendering="crispEdges" fill="currentColor" width="12" height="16" aria-hidden="true"><rect x="2" y="0" width="2" height="2"/><rect x="2" y="3" width="2" height="5"/></svg></button> <span id="tip-info" class="btn-tooltip" role="tooltip" popover="manual">Info</span> </div>
<!-- tap-to-play cover: first gesture unlocks audio + boots cart --> <div id="play"> <svg class="butterfly-lg" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"> <g fill="#29adff"> <rect x="2" y="0" width="2" height="1"/><rect x="12" y="0" width="2" height="1"/> <rect x="1" y="1" width="4" height="1"/><rect x="11" y="1" width="4" height="1"/> <rect x="0" y="2" width="6" height="1"/><rect x="10" y="2" width="6" height="1"/> <rect x="0" y="3" width="7" height="1"/><rect x="9" y="3" width="7" height="1"/> <rect x="0" y="4" width="6" height="1"/><rect x="10" y="4" width="6" height="1"/> <rect x="0" y="5" width="5" height="1"/><rect x="11" y="5" width="5" height="1"/> <rect x="1" y="6" width="4" height="1"/><rect x="7" y="6" width="2" height="1"/><rect x="11" y="6" width="4" height="1"/> <rect x="2" y="7" width="3" height="1"/><rect x="7" y="7" width="2" height="1"/><rect x="11" y="7" width="3" height="1"/> <rect x="3" y="8" width="2" height="1"/><rect x="7" y="8" width="2" height="1"/><rect x="11" y="8" width="2" height="1"/> <rect x="2" y="9" width="4" height="1"/><rect x="10" y="9" width="4" height="1"/> <rect x="1" y="10" width="5" height="1"/><rect x="10" y="10" width="5" height="1"/> <rect x="0" y="11" width="6" height="1"/><rect x="10" y="11" width="6" height="1"/> <rect x="0" y="12" width="6" height="1"/><rect x="10" y="12" width="6" height="1"/> <rect x="1" y="13" width="5" height="1"/><rect x="10" y="13" width="5" height="1"/> <rect x="2" y="14" width="4" height="1"/><rect x="10" y="14" width="4" height="1"/> <rect x="4" y="15" width="2" height="1"/><rect x="10" y="15" width="2" height="1"/> </g> <g fill="#0a78c8"> <rect x="6" y="4" width="1" height="1"/><rect x="9" y="4" width="1" height="1"/> <rect x="5" y="5" width="2" height="1"/><rect x="9" y="5" width="2" height="1"/> <rect x="5" y="6" width="2" height="1"/><rect x="9" y="6" width="2" height="1"/> <rect x="5" y="7" width="2" height="1"/><rect x="9" y="7" width="2" height="1"/> <rect x="5" y="8" width="2" height="1"/><rect x="9" y="8" width="2" height="1"/> <rect x="6" y="9" width="4" height="1"/><rect x="6" y="10" width="4" height="1"/> <rect x="6" y="11" width="4" height="1"/><rect x="6" y="12" width="4" height="1"/> <rect x="6" y="13" width="4" height="1"/> </g> </svg> <h1>.<span class="s">/</span> npicomx</h1> <p>tap to play</p> <button id="btn-play">play</button> </div>
<script> // ── PICO-8 / Emscripten setup ──────────────────────────────────────── // The cart export (npicomx.js) binds to these globals, so they MUST exist // BEFORE the script tag that loads it. pico8_buttons is read by PICO-8 // every frame (indexed by player) instead of scanning the keyboard, which // is how the on-screen controller drives the cart. pico8_state is written // by the runtime (.is_paused, .sound_volume) once the cart is running. window.pico8_buttons = [0, 0, 0, 0, 0, 0, 0, 0]; window.pico8_state = {}; var Module = { canvas: document.getElementById("canvas"), arguments: ["-width", "512", "-height", "512"], }; </script>
<script> // ── Mobile touch controller → window.pico8_buttons ─────────────────── // // pico8_buttons is indexed by PLAYER, and each element is a bitmask of the // buttons held for that player: bit0=left, bit1=right, bit2=up, bit3=down, // bit4=O, bit5=X. This single-player cart only reads player 0, so every // button OR/ANDs its bit into pico8_buttons[0]. The data-pico8btn values // (0=left … 5=X) are exactly the bit positions, so the mask is 1 << idx. function setupMobileControls() { // Action buttons (O / X): one bit each, simple per-button press/release. document.querySelectorAll(".action-btn").forEach((btn) => { const idx = parseInt(btn.dataset.pico8btn, 10); const bit = 1 << idx; btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.setPointerCapture(e.pointerId); btn.classList.add("pressed"); if (window.pico8_buttons) window.pico8_buttons[0] |= bit; }); const release = () => { btn.classList.remove("pressed"); if (window.pico8_buttons) window.pico8_buttons[0] &= ~bit; }; btn.addEventListener("pointerup", release); btn.addEventListener("pointercancel", release); btn.addEventListener("pointerleave", release); });
// D-pad: a SINGLE touch zone covering the whole 3×3 grid (arrow buttons, // gaps, AND the empty centre cell). Direction is derived from where the // thumb sits relative to the grid centre, with each axis evaluated // INDEPENDENTLY - so a corner/gap press lights TWO bits at once (true // 8-way: bottom-right ⇒ down+right). Bits: left=1<<0 right=1<<1 up=1<<2 // down=1<<3. Pointer capture keeps moves flowing as the thumb slides, so // you can roll from one direction into a diagonal without lifting. const dpad = document.querySelector(".dpad"); const DIRS = { left: { bit: 1 << 0, el: dpad.querySelector(".dpad-btn-left") }, right: { bit: 1 << 1, el: dpad.querySelector(".dpad-btn-right") }, up: { bit: 1 << 2, el: dpad.querySelector(".dpad-btn-up") }, down: { bit: 1 << 3, el: dpad.querySelector(".dpad-btn-down") }, }; const DPAD_MASK = DIRS.left.bit | DIRS.right.bit | DIRS.up.bit | DIRS.down.bit; let active = false;
function applyDir(clientX, clientY) { const r = dpad.getBoundingClientRect(); const dx = clientX - (r.left + r.width / 2); const dy = clientY - (r.top + r.height / 2); // Deadzone ≈12% of the pad per axis: a centred press on one arrow reads // as that single direction; a corner/gap press crosses both thresholds. const dzx = r.width * 0.12, dzy = r.height * 0.12; let mask = 0; if (dx < -dzx) mask |= DIRS.left.bit; else if (dx > dzx) mask |= DIRS.right.bit; if (dy < -dzy) mask |= DIRS.up.bit; else if (dy > dzy) mask |= DIRS.down.bit; if (window.pico8_buttons) window.pico8_buttons[0] = (window.pico8_buttons[0] & ~DPAD_MASK) | mask; for (const k in DIRS) DIRS[k].el.classList.toggle("pressed", (mask & DIRS[k].bit) !== 0); } function clearDir() { active = false; if (window.pico8_buttons) window.pico8_buttons[0] &= ~DPAD_MASK; for (const k in DIRS) DIRS[k].el.classList.remove("pressed"); } dpad.addEventListener("pointerdown", (e) => { e.preventDefault(); active = true; dpad.setPointerCapture(e.pointerId); applyDir(e.clientX, e.clientY); }); dpad.addEventListener("pointermove", (e) => { if (active) applyDir(e.clientX, e.clientY); }); dpad.addEventListener("pointerup", clearDir); dpad.addEventListener("pointercancel", clearDir); } setupMobileControls();
// ── Swallow taps on empty control space ────────────────────────────── // Tapping the controls bar background or the d-pad's empty centre cell // must NOT reach the canvas/document (where it could trigger text // selection or stray PICO-8 input). The per-button handlers above already // preventDefault on their own buttons; here we catch everything else. // // `closest("button")` is true when the tap lands on (or inside) a real // control - those we leave alone so their handlers run normally. For any // other target (empty space) we preventDefault so the browser performs no // default action (selection/focus) and nothing propagates to the canvas. // Kill selection globally (iOS triggers it on selectstart/long-press, which // a pointerdown preventDefault doesn't cancel), and block pinch-zoom. document.addEventListener("selectstart", (e) => e.preventDefault()); document.addEventListener("gesturestart", (e) => e.preventDefault()); { const controls = document.getElementById("mobile-controls"); // Swallow non-button taps on BOTH pointerdown and touchstart - iOS starts // a selection on touchstart, so the touchstart (passive:false) handler is // the one that actually stops the "console gets selected" bug. const swallow = (e) => { if (!e.target.closest("button")) e.preventDefault(); }; controls.addEventListener("pointerdown", swallow); controls.addEventListener("touchstart", swallow, { passive: false }); controls.addEventListener("contextmenu", (e) => e.preventDefault()); }
// ── Pause button ───────────────────────────────────────────────────── // Pause = PICO-8 button 6, which opens its official pause menu. The cart // reads pico8_buttons in mobile mode, so set bit 6 on press and clear it on // release (the keyboard path is unavailable while the on-screen controller // drives the cart). Holding the bit for the press duration is how PICO-8 // edge-detects the menu request. { const btnPause = document.getElementById("btn-pause"); const PAUSE_BIT = 1 << 6; btnPause.addEventListener("pointerdown", (e) => { e.preventDefault(); btnPause.setPointerCapture(e.pointerId); btnPause.classList.add("pressed"); if (window.pico8_buttons) window.pico8_buttons[0] |= PAUSE_BIT; }); const releasePause = () => { btnPause.classList.remove("pressed"); if (window.pico8_buttons) window.pico8_buttons[0] &= ~PAUSE_BIT; }; btnPause.addEventListener("pointerup", releasePause); btnPause.addEventListener("pointercancel", releasePause); }
// ── Reveal the controller on touch / mobile only ───────────────────── // Match phones in EITHER orientation: portrait keys off width, landscape // off a short viewport height (a phone on its side is wider than 599px). // Mirrors the mobile CSS breakpoints. Desktop keeps the controller hidden // and the cart's own keyboard handling (arrows + Z/X) intact. const isMobile = window.matchMedia( "(max-width: 599px), (orientation: landscape) and (max-height: 599px)" ).matches; if (isMobile) { document.getElementById("mobile-controls").classList.remove("hidden"); }
// Block arrow/space scrolling while the cart has focus (desktop keyboard). addEventListener("keydown", (e) => { const o = document.activeElement; if ((!o || o === document.body || o.id === "canvas") && [32, 37, 38, 39, 40].includes(e.keyCode)) e.preventDefault(); });
// ── Tap-to-play gate + audio unlock ────────────────────────────────── // // Mobile browsers (iOS/Android) keep every AudioContext suspended until a // real user gesture, so if the cart booted on page load its music/sfx // would be silent. const play = document.getElementById("play"); const btnPlay = document.getElementById("btn-play");
// Resume every audio context the Emscripten/PICO-8 export might own. function unlockAudio() { try { const ctxs = [ (typeof Module !== "undefined" && Module.SDL && Module.SDL.audioContext) || null, (typeof Module !== "undefined" && Module.SDL2 && Module.SDL2.audioContext) || null, window.pico8_audio_context || null, ]; for (const ctx of ctxs) { if (ctx && typeof ctx.resume === "function" && ctx.state !== "running") { ctx.resume().catch(() => {}); } } } catch (_) {} }
// Boot the cart runtime. Called ONLY from inside the Play gesture so the // AudioContext Emscripten creates is unlocked from the first frame. let booted = false; function bootCart() { if (booted) return; booted = true; const s = document.createElement("script"); s.async = true; s.src = "npicomx.js?v=" + Date.now(); s.onerror = () => { document.title = "npicomx.js missing - run `make web`"; }; document.body.appendChild(s); }
// First tap on the cover: unlock audio, hide the cover, boot the cart. // unlockAudio() runs both now (in case a context already exists) and again // shortly after boot, once the runtime has constructed its own context. function startPlay() { unlockAudio(); play.classList.add("hidden"); bootCart(); // The runtime creates its AudioContext during this gesture's call stack; // resume it again on the next ticks to catch the freshly-made context // while we're still within the user-activation window. unlockAudio(); setTimeout(unlockAudio, 100); setTimeout(unlockAudio, 500); }
// Tap anywhere on the cover (the ▶ button or its backdrop) starts play. // { once: true } so it can't double-fire; keyboard activation for a11y. play.addEventListener("click", startPlay, { once: true }); play.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); startPlay(); } }); btnPlay.focus();
// ── Audio mute toggle ──────────────────────────────────────────────── // Tracks state with a plain boolean; syncs to the cart via pico8ToggleSound // only after boot (pico8_state.sound_volume is undefined until then). let isMuted = false;
function setAudioVisual(muted) { const btn = document.getElementById("btn-audio"); if (!btn) return; btn.querySelector(".audio-waves").style.display = muted ? "none" : ""; btn.querySelector(".audio-mute").style.display = muted ? "" : "none"; btn.setAttribute("aria-pressed", String(muted)); btn.classList.toggle("muted", muted); }
function applyMuteToContext() { try { if (typeof Module !== "undefined" && typeof Module.pico8ToggleSound === "function") { const sv = window.pico8_state?.sound_volume; if (sv !== undefined) { const cartMuted = sv === 0; if (isMuted !== cartMuted) Module.pico8ToggleSound(); } } } catch(_) {} }
function toggleAudio() { isMuted = !isMuted; applyMuteToContext(); setAudioVisual(isMuted); }
document.getElementById("btn-audio")?.addEventListener("click", toggleAudio);
// ── Logo tap → reset ────────────────────────────────────────────────── // Tapping the ./ npicomx header logo while the cart is running reloads // the page, returning to a clean start screen. document.getElementById("btn-logo")?.addEventListener("click", () => { if (booted) window.location.reload(); });
// ── Keyboard shortcuts dialog (desktop only) ────────────────────────── { const helpBtn = document.getElementById("btn-help"); const shortcutsDlg = document.getElementById("shortcuts-dialog"); const shortcutsClose = document.getElementById("shortcuts-close"); helpBtn?.addEventListener("click", () => shortcutsDlg.showModal()); shortcutsClose?.addEventListener("click", () => shortcutsDlg.close()); shortcutsDlg?.addEventListener("click", e => { if (e.target === shortcutsDlg) shortcutsDlg.close(); }); // Escape closes the dialog; stopPropagation prevents PICO-8's SDL bridge // from consuming the key before the dialog can act on it. shortcutsDlg?.addEventListener("keydown", e => { if (e.key === "Escape") { e.stopPropagation(); shortcutsDlg.close(); } }); }
// ── Credits dialog (desktop btn-info + mobile btn-info-mobile) ──────── { const infoDlg = document.getElementById("info-dialog"); const infoClose = document.getElementById("info-close"); [document.getElementById("btn-info"), document.getElementById("btn-info-mobile")] .forEach(b => b?.addEventListener("click", () => infoDlg.showModal())); infoClose?.addEventListener("click", () => infoDlg.close()); infoDlg?.addEventListener("click", e => { if (e.target === infoDlg) infoDlg.close(); }); infoDlg?.addEventListener("keydown", e => { if (e.key === "Escape") { e.stopPropagation(); infoDlg.close(); } }); }
// ── Button tooltips ────────────────────────────────────────────────── if ("showPopover" in HTMLElement.prototype) { document.querySelectorAll("#settings button[aria-describedby]").forEach(btn => { const tip = document.getElementById(btn.getAttribute("aria-describedby")); if (!tip) return; btn.addEventListener("mouseenter", () => tip.showPopover()); btn.addEventListener("mouseleave", () => tip.hidePopover()); btn.addEventListener("focus", () => tip.showPopover()); btn.addEventListener("blur", () => tip.hidePopover()); }); }
// ── Fullscreen toggle ──────────────────────────────────────────────── { const btnFs = document.getElementById("btn-fullscreen"); if (btnFs) { function setFsVisual(isFs) { btnFs.querySelector(".fs-expand").style.display = isFs ? "none" : ""; btnFs.querySelector(".fs-compress").style.display = isFs ? "" : "none"; btnFs.setAttribute("aria-label", isFs ? "Exit fullscreen" : "Enter fullscreen"); btnFs.setAttribute("aria-pressed", String(isFs)); } btnFs.addEventListener("click", () => { if (!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); }); document.addEventListener("fullscreenchange", () => setFsVisual(!!document.fullscreenElement)); } } </script>
<!-- CRT shader overlay (TV toggle). Module so it runs at load. --> <script type="module" src="./crt.js"></script>
<!-- Register the service worker (installable PWA + offline shell). --> <script> if ("serviceWorker" in navigator) { addEventListener("load", () => { navigator.serviceWorker.register("/sw.js").catch((e) => { console.warn("npicomx: service worker registration failed", e); }); }); } </script> </body></html>