Back to A ping-pong game with powerups
SOURCE / PINNED RELEASE

Made of little things.

A ping-pong game with powerups

Release
3b7af72ed9f2…
Author-recorded commit
Not provided
License
Check the original source

The hash-verified playable HTML, which may contain bundled or minified code. This view does not execute it.

index.html
<!doctype html>
<html lang="en">
  <head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'">
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Napplet</title>
    <script type="module" crossorigin>function Me(e,n){return!!(e!=null&&e[n])}function Ae(e){return Me(window.napplet,e)}function ke(){const e=new Map;function n(t,o){if(e.has(t))throw new Error(`NAP domain "${t}" is already registered`);e.set(t,o)}function a(t){const o=t.type.indexOf(".");if(o<=0)return!1;const g=t.type.slice(0,o),f=e.get(g);return f?(f(t),!0):!1}function c(){return Array.from(e.keys())}return{registerNap:n,dispatch:a,getRegisteredDomains:c}}var Se=ke(),Pe=Se.registerNap,Te="theme";function fe(){var n;const e=window;if(!((n=e.napplet)!=null&&n.theme))throw new Error("window.napplet.theme is unavailable -- runtime did not inject this domain");return e.napplet.theme}function Ee(){return fe().get()}function Ce(e){return fe().onChanged(e)}Pe(Te,e=>{});const De=document.querySelector("#app");function m(e,n="",a=""){const c=document.createElement(e);return c.textContent=n,c.className=a,c}const Y=m("div","","game"),ue=m("div","","toolbar"),ne=m("output","","score");ne.setAttribute("aria-label","Score");const M=m("button","♪"),W=m("button","?");M.title="Mute sounds";M.setAttribute("aria-label","Mute sounds");M.setAttribute("aria-pressed","true");W.title="Controls and rules";W.setAttribute("aria-label","Show game rules");W.setAttribute("aria-expanded","false");const he=m("div","","arena"),d=m("canvas");d.tabIndex=0;d.setAttribute("aria-label","Two-player pong. P1 W and S, P2 arrow keys. Both players press their controls to connect. First to seven wins.");const b=m("div","","notice");b.setAttribute("aria-live","polite");const Re=m("div","P1 W / S · P2 ↑ / ↓ · First to 7","help"),_=m("output","","effects");_.setAttribute("aria-label","Active effects");const $=m("div","Each player presses a movement key to connect. Both paddles appear, then 3, 2, 1, GO! P1: W / S. P2: ↑ / ↓. First to 7 wins. Play again skips connection and countdown. Targets: + wide paddle, » fast shots, S one-save shield. Collect with the ball or paddle. Red − shrinks your paddle; red ~ slows your paddle. Red targets are ball-only and affect the last hitter; they spawn behind the ball after it crosses the middle. Timed effects last 8 seconds of play. A point winner keeps remaining effects; the loser loses theirs. Shields last until used or a point is lost. Leaving the game suspends play; click Play to continue. Sounds are generated locally; ♪ toggles mute.","instructions");$.hidden=!0;$.setAttribute("data-napplet-select","text");W.onclick=()=>{$.hidden=!$.hidden,W.setAttribute("aria-expanded",String(!$.hidden))};ue.append(ne,M,W);he.append(d,b);Y.append(ue,he,_,Re,$);const ie=m("div","","play-overlay"),q=m("button","Play","play-button");q.type="button";ie.append(q);De.replaceChildren(Y,ie);let ee="#101a25",K="#e5eef7",R="#67e8bb";function se(e){ee=e.colors.background,K=e.colors.text,R=e.colors.primary;for(const[n,a]of[["--bg",ee],["--fg",K],["--primary",R]])document.documentElement.style.setProperty(n,a)}let z;if(Ae("theme"))try{Ee().then(se).catch(()=>{});const e=Ce(se);z=()=>e.close()}catch{}let u,S=!1;function Q(){if(!S)try{u??(u=new AudioContext),u.state==="suspended"&&u.resume().catch(()=>{})}catch{}}function h(e,n=.08,a=0,c=e,t="sine"){if(!(S||!u||u.state!=="running"))try{const o=u.createOscillator(),g=u.createGain(),f=u.currentTime+a;o.type=t,o.frequency.setValueAtTime(e,f),o.frequency.exponentialRampToValueAtTime(c,f+n),g.gain.setValueAtTime(0,f),g.gain.linearRampToValueAtTime(.075,f+.005),g.gain.exponentialRampToValueAtTime(.001,f+n),o.connect(g),g.connect(u.destination),o.start(f),o.stop(f+n+.01),o.onended=()=>{o.disconnect(),g.disconnect()}}catch{}}function re(e=!1){[523,659,784,...e?[1047]:[]].forEach((n,a)=>h(n,.16,a*.09))}M.onclick=()=>{S=!S,M.textContent=S?"♪̸":"♪",M.title=S?"Enable sounds":"Mute sounds",M.setAttribute("aria-label",M.title),M.setAttribute("aria-pressed",String(!S)),S?u&&u.suspend().catch(()=>{}):(Q(),h(660))};const i=d.getContext("2d"),l=900,E=500;let P=[],T=[250,250],V=[0,0];const G=[0,0],N=[0,0],U=[0,0],j=[0,0],O=[!1,!1];let r=450,v=250,p=300,k=130,y=!1,B=-1,x=-1,F=[!1,!1],w=0,L=0,s=0,H=3,te=6,X=0,Z="",ae=0,le=0,ce="";const A=new Set,Le={wide:"+",fast:"»",shield:"S",small:"−",slow:"~"},$e={wide:"wide paddle",fast:"fast shots",shield:"shield",small:"small paddle",slow:"slow paddle"};function I(){return F.every(Boolean)}function J(e){return e==="small"||e==="slow"}function C(e){return(G[e]>s?142:92)*(U[e]>s?.6:1)}function D(){ne.textContent=`${V[0]} : ${V[1]}`,ie.hidden=y,Y.inert=!y,Y.style.visibility=y?"visible":"hidden",q.textContent=B>=0?`P${B+1} wins · Play again`:"Play";let e="",n="status";I()?w>0?(e=String(Math.ceil(w)),n="countdown"):L>0&&(e="GO!",n="go"):e=`P1 ${F[0]?"READY":"W / S"}
P2 ${F[1]?"READY":"↑ / ↓"}`,(b.textContent!==e||b.dataset.mode!==n)&&(b.textContent=e,b.dataset.mode=n,(n==="countdown"||n==="go")&&(b.classList.remove("pulse"),b.offsetWidth,b.classList.add("pulse"))),b.hidden=!e}function me(){const e=[0,1].map(n=>{const a=[["wide",G[n]],["fast",N[n]],["small",U[n]],["slow",j[n]]].filter(([,c])=>c>s).map(([c,t])=>`${c} ${Math.ceil(t-s)}s`);return O[n]&&a.push("shield"),`P${n+1}: ${a.join(", ")||"normal"}`}).join(" · ");e!==ce&&(_.textContent=e,ce=e),_.title=Z||"Effects count down only while playing."}function pe(e){for(const n of e===void 0?[0,1]:[e])G[n]=0,N[n]=0,U[n]=0,j[n]=0,O[n]=!1}function ye(e){r=l/2,v=E/2,p=e*300,k=(Math.random()>.5?1:-1)*(100+Math.random()*90),x=-1,X=.65}function ge(e=!0){V=[0,0],T=[250,250],pe(),P=[],s=0,H=3,te=6,Z="",B=-1,y=!1,F=e?[!1,!1]:[!0,!0],w=0,L=0,ye(Math.random()>.5?1:-1),A.clear(),D(),me()}q.onclick=()=>{Q(),B>=0&&ge(!1),y=!0,A.clear(),D(),d.focus({preventScroll:!0})};d.addEventListener("pointerdown",Q);d.addEventListener("keydown",e=>{if(!(!y||!["KeyW","KeyS","ArrowUp","ArrowDown"].includes(e.code))&&(e.preventDefault(),Q(),A.add(e.code),!I())){const n=e.code==="KeyW"||e.code==="KeyS"?0:1;F[n]||h(n===0?440:554,.12),F[n]=!0,I()&&(w=3,X=0,A.clear(),h(440,.14)),D()}});d.addEventListener("keyup",e=>A.delete(e.code));d.addEventListener("blur",()=>{A.clear()});function oe(){A.clear(),y&&(y=!1,D())}function we(){document.hidden&&oe()}document.addEventListener("visibilitychange",we);window.addEventListener("blur",oe);function de(e,n){e.kind==="shield"?O[n]=!0:{wide:G,fast:N,small:U,slow:j}[e.kind][n]=s+8,Z=`P${n+1} collected ${$e[e.kind]}`,J(e.kind)?h(250,.2,0,90,"triangle"):(h(660,.1),h(990,.14,.07))}function We(e){if(!I())return;if(w>0){const t=Math.ceil(w);w=Math.max(0,w-e),w===0?(L=.55,h(880,.28,0,1100)):Math.ceil(w)!==t&&h(440,.14),D();return}L>0&&(L=Math.max(0,L-e),D()),s+=e;for(let t=0;t<2;t++){const o=Number(A.has(t===0?"KeyS":"ArrowDown"))-Number(A.has(t===0?"KeyW":"ArrowUp"));T[t]+=o*(j[t]>s?235:370)*e,T[t]=Math.max(C(t)/2,Math.min(E-C(t)/2,T[t]))}if(X>0){X-=e;return}if(s>=H){const t=["wide","fast","shield"];P.push({x:280+Math.random()*340,y:65+Math.random()*370,kind:t[Math.floor(Math.random()*t.length)],drift:Math.random()<.5?-55:55,expires:s+12}),H=s+5}const n=r,a=x>=0&&N[x]>s?1.35:1;r+=p*e*a,v+=k*e*a,v<8&&(v=8,k=Math.abs(k),h(240,.045)),v>E-8&&(v=E-8,k=-Math.abs(k),h(240,.045)),(p>0?n<=l/2&&r>l/2:n>=l/2&&r<l/2)&&x>=0&&s>=te&&!P.some(t=>J(t.kind))&&(P.push({x:p>0?220+Math.random()*100:580+Math.random()*100,y:45+Math.random()*410,kind:Math.random()<.5?"small":"slow",drift:0,expires:s+10}),te=s+7);for(let t=0;t<2;t++){const o=t===0?46:l-46;if((t===0?p<0&&r<=o+8&&r>=23:p>0&&r>=o-8&&r<=l-23)&&Math.abs(v-T[t])<=C(t)/2+7){r=o+(t===0?9:-9);const g=Math.max(-1,Math.min(1,(v-T[t])/(C(t)/2)))*1.02,f=Math.min(580,Math.hypot(p,k)+22);p=Math.cos(g)*f*(t===0?1:-1),k=Math.sin(g)*f,x=t,h(t===0?420:560,.065,0,300,"triangle")}}P=P.filter(t=>{if(s>t.expires)return!1;if(t.x+=t.drift*e,x>=0&&Math.hypot(r-t.x,v-t.y)<(J(t.kind)?17:30))return de(t,x),!1;if(t.drift!==0&&(t.x<=55||t.x>=l-55)){const o=t.drift<0?0:1;if(Math.abs(t.y-T[o])<=C(o)/2+22)return de(t,o),!1}return t.x>15&&t.x<l-15});for(let t=0;t<2;t++)O[t]&&(t===0?r<17&&p<0:r>l-17&&p>0)&&(O[t]=!1,r=t===0?18:l-18,p=-p,x=t,h(180,.22,0,900));if(r<-15||r>l+15){const t=r<0?1:0;V[t]++,P=[],pe(1-t),H=s+3,Z=`P${t+1} won the point and keeps remaining effects`,V[t]>=7?(B=t,y=!1,re(!0)):(re(),ye(t===0?-1:1)),D(),B>=0&&q.focus({preventScroll:!0})}}function be(){if(i.setTransform(d.width/l,0,0,d.height/E,0,0),i.fillStyle=ee,i.fillRect(0,0,l,E),i.strokeStyle=K,i.globalAlpha=.15,i.lineWidth=2,i.setLineDash([8,12]),i.beginPath(),i.moveTo(l/2,0),i.lineTo(l/2,E),i.stroke(),i.setLineDash([]),i.globalAlpha=1,I())for(let e=0;e<2;e++)i.fillStyle=U[e]>s||j[e]>s?"#ee716a":G[e]>s||N[e]>s?R:K,i.fillRect(e===0?34:l-46,T[e]-C(e)/2,12,C(e)),O[e]&&(i.fillStyle=R,i.globalAlpha=.5,i.fillRect(e===0?10:l-14,0,4,E),i.globalAlpha=1);for(const e of P){const n=J(e.kind),a=n?10:22;i.strokeStyle=n?"#ee716a":R,i.fillStyle=i.strokeStyle,i.lineWidth=2,i.beginPath(),n?(i.moveTo(e.x,e.y-a),i.lineTo(e.x+a,e.y),i.lineTo(e.x,e.y+a),i.lineTo(e.x-a,e.y),i.closePath()):i.arc(e.x,e.y,a,0,Math.PI*2),i.stroke(),i.font=`bold ${n?17:25}px system-ui`,i.textAlign="center",i.textBaseline="middle",i.fillText(Le[e.kind],e.x,e.y)}I()&&w<=0&&(i.fillStyle=x>=0&&N[x]>s?R:K,i.beginPath(),i.arc(r,v,8,0,Math.PI*2),i.fill())}const ve=new ResizeObserver(()=>{const e=d.getBoundingClientRect(),n=Math.min(window.devicePixelRatio||1,2);d.width=Math.max(1,Math.round(e.width*n)),d.height=Math.max(1,Math.round(e.height*n)),be()});ve.observe(d);function xe(e){const n=Math.min((e-le)/1e3||0,.04);if(le=e,y){const a=Math.max(1,Math.ceil(n/.006));for(let c=0;c<a&&y;c++)We(n/a)}me(),y&&be(),ae=requestAnimationFrame(xe)}ge();ae=requestAnimationFrame(xe);window.addEventListener("pagehide",()=>{cancelAnimationFrame(ae),ve.disconnect(),z==null||z(),A.clear(),document.removeEventListener("visibilitychange",we),window.removeEventListener("blur",oe),u&&u.close().catch(()=>{})},{once:!0});
</script>
    <style>:root{--bg: #101a25;--fg: #e5eef7;--primary: #67e8bb;font:13px/1.4 system-ui,sans-serif;color-scheme:dark light;background:var(--bg);color:var(--fg)}*{box-sizing:border-box}html,body,#app{height:100%;margin:0;background:var(--bg);color:var(--fg)}body{-webkit-user-select:none;user-select:none;overflow:hidden}#app{position:relative;overflow:hidden}.game{height:100%;display:grid;position:relative;grid-template-rows:auto minmax(0,1fr) auto auto;padding:8px;gap:6px}.play-overlay{position:absolute;top:0;right:0;bottom:0;left:0;z-index:10;display:grid;place-items:center;padding:8px;background:var(--bg)}button{font:inherit;color:var(--fg);background:transparent;border:0;border-radius:4px;padding:3px 8px;min-height:30px;cursor:pointer}.play-button{color:var(--primary);font-weight:800;font-size:clamp(22px,min(7vw,16vh),64px);padding:8px 16px;max-width:100%;overflow-wrap:anywhere;text-align:center;border:1px solid color-mix(in srgb,var(--primary) 40%,transparent);border-radius:8px}button:hover{background:color-mix(in srgb,var(--primary) 12%,transparent)}button:focus-visible,canvas:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}button[aria-pressed=false]{opacity:.5}.toolbar{display:flex;align-items:center;gap:6px;min-width:0}.score{flex:1;min-width:0;font-size:24px;font-weight:900;font-variant-numeric:tabular-nums;letter-spacing:3px;white-space:nowrap}.arena{position:relative;min-height:0;min-width:0;overflow:hidden;container-type:size}canvas{display:block;width:100%;height:100%}.notice{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;pointer-events:none;text-align:center;white-space:pre-line;text-wrap:balance;font-weight:950;font-size:clamp(18px,6vw,68px);font-size:clamp(16px,min(7cqw,20cqh),100px);line-height:1.1;letter-spacing:-.035em;padding:4px;background:none;border:none}.notice[data-mode=countdown],.notice[data-mode=go]{color:var(--primary);font-size:clamp(32px,min(38cqw,65cqh),320px);font-variant-numeric:tabular-nums}.notice[data-mode=go]{font-style:italic}.pulse{animation:punch .32s ease-out both}@keyframes punch{0%{transform:scale(1.3);opacity:.4}to{transform:scale(1);opacity:1}}@media(prefers-reduced-motion:reduce){.pulse{animation:none}}[hidden]{display:none!important}.help,.effects{color:color-mix(in srgb,var(--fg) 65%,var(--bg));font-size:12px;text-align:center;min-width:0}.effects{color:var(--primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.instructions{position:absolute;z-index:2;top:46px;left:8px;right:8px;max-height:calc(100% - 54px);overflow:auto;padding:10px;background:var(--bg);border:1px solid var(--primary);border-radius:5px;-webkit-user-select:text;user-select:text}@media(max-width:239px){.game{padding:4px;gap:3px}.toolbar{gap:2px}.toolbar button{padding-inline:4px}.score{font-size:17px;letter-spacing:0}.help,.effects{font-size:10px}}@media(max-height:250px){.game{gap:2px;padding:4px}.score{font-size:16px}.toolbar button{min-height:26px}.help{display:none}.effects{font-size:10px}}@media(min-width:901px)and (min-height:450px){.toolbar{gap:10px}.score{font-size:30px}.help,.effects{text-align:left}}
</style>
    <meta name="napplet-type" content="my-napplet">
  </head>
  <body>
    <!--
      A napplet is an applet framed by a runtime: the runtime already shows this
      napplet's name, so there is no title header here. The frame is working
      surface from the first pixel, and the layout adapts to any frame size.
    -->
    <main id="app" class="app">
      <div class="toolbar" role="toolbar" aria-label="Actions">
        <button id="identityButton" type="button" title="Refresh identity">
          <span aria-hidden="true">◎</span><span class="label">Identity</span>
        </button>
        <button id="storageButton" type="button" title="Save draft">
          <span aria-hidden="true">▣</span><span class="label">Save</span>
        </button>
        <button id="outboxButton" type="button" title="Query outbox">
          <span aria-hidden="true">⇅</span><span class="label">Query</span>
        </button>
        <button id="resourceButton" type="button" title="Load resource">
          <span aria-hidden="true">⬇</span><span class="label">Resource</span>
        </button>
        <button id="notifyButton" type="button" title="Send notification">
          <span aria-hidden="true">🔔</span><span class="label">Notify</span>
        </button>
        <output id="status" class="status status-idle" aria-live="polite">Ready</output>
      </div>

      <dl id="capabilities" class="domains" aria-label="Injected NAP domains"></dl>

      <div class="workspace">
        <section class="pane pane-draft" aria-label="Draft">
          <label class="field">
            <span class="field-label">Draft note</span>
            <textarea id="noteInput" rows="3">Hello from my napplet.</textarea>
          </label>
          <div class="meta">
            <span class="meta-key">identity</span>
            <span id="identityValue" class="mono truncate">not loaded</span>
            <span class="meta-key">storage</span>
            <span id="storageValue" class="mono truncate">not saved</span>
          </div>
        </section>

        <section class="pane pane-output" aria-label="Output">
          <pre id="output" class="output" data-napplet-select="text">No events yet.</pre>
        </section>
      </div>
    </main>
  </body>
</html>