diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 3568bc2..afe3816 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -27,6 +27,11 @@ multiplayer are **v2**, already scaffolded and parked in-repo. ## Timeline +- **2026-08-11** — Prepared the iPhone/iPad beta for TestFlight transport with + versioned App Store metadata, a privacy manifest and public privacy surface, + clean simulator screenshot evidence, an archive-safe bundled web-game sync, + and a personal-team signed archive. App Store Connect record creation and + physical camera/ReplayKit verification remain separate manual gates. - **2026-08-09** — Pinned the landing release tool in the workspace lockfile and switched the deploy script to `pnpm exec`, removing npm/pnpm configuration deprecation warnings from the manual Cloudflare Pages release path. @@ -74,6 +79,10 @@ multiplayer are **v2**, already scaffolded and parked in-repo. ## Features (shipped) +- **iOS beta preparation** — App Store identity/copy, Apple-accepted iPhone and + iPad simulator screenshots, privacy metadata and support links, stable + version/build settings, a clean default game canvas, and reproducible bundled + web assets are ready for the existing manual TestFlight boundary. - **Public landing** — responsive static product story with the existing Motion logo, canonical/share metadata, `llms.txt`, `index.md`, `/api/ai`, and an owned `/changelog` source with semantic dates and internal discovery. diff --git a/artifacts/app-store/ipad-13/motion-maker-1.png b/artifacts/app-store/ipad-13/motion-maker-1.png new file mode 100644 index 0000000..df466d5 Binary files /dev/null and b/artifacts/app-store/ipad-13/motion-maker-1.png differ diff --git a/artifacts/app-store/ipad-13/motion-maker-clean.png b/artifacts/app-store/ipad-13/motion-maker-clean.png new file mode 100644 index 0000000..9a656d3 Binary files /dev/null and b/artifacts/app-store/ipad-13/motion-maker-clean.png differ diff --git a/artifacts/app-store/iphone-6.9/motion-maker-1.png b/artifacts/app-store/iphone-6.9/motion-maker-1.png new file mode 100644 index 0000000..4c82fbb Binary files /dev/null and b/artifacts/app-store/iphone-6.9/motion-maker-1.png differ diff --git a/artifacts/app-store/iphone-6.9/motion-maker-clean.png b/artifacts/app-store/iphone-6.9/motion-maker-clean.png new file mode 100644 index 0000000..04409ce Binary files /dev/null and b/artifacts/app-store/iphone-6.9/motion-maker-clean.png differ diff --git a/docs/app-store/APP_STORE_SUBMISSION.md b/docs/app-store/APP_STORE_SUBMISSION.md new file mode 100644 index 0000000..64ef641 --- /dev/null +++ b/docs/app-store/APP_STORE_SUBMISSION.md @@ -0,0 +1,87 @@ +# Motion App Store submission draft + +Preparation only. Do not create, upload, publish, or submit an App Store Connect record from this repository. + +## Identity + +- Name: Motion +- Bundle ID: `com.motion.controller` +- SKU: `motion-ios-1` +- Version: `1.0.0` +- Build: `1` +- Primary language: English (U.S.) +- Primary category: Games +- Secondary category: Health & Fitness +- Copyright: `2026 Sarthak Agrawal` +- License: Apple's standard EULA +- Content rights: Motion owns or is licensed to use the bundled game, artwork, model, and audio-visual assets. + +## Store copy + +**Subtitle** +Move your body. Play. + +**Promotional text** +Turn your iPhone camera into a private, on-device motion controller. Move, grab, dodge, and record the play you make. + +**Description** +Your body is the controller. + +Motion turns movement into play using your iPhone camera and on-device pose detection. Step into frame, follow the live framing guide, and start Motion Maker—a tactile playground where your hands grab, carry, and throw objects. + +The complete primary game runs on your iPhone with no account and no server required. Mirror your screen to a larger display when you want the room to become the play space. + +• Camera processing stays on device +• Motion Maker is bundled and works without a Mac +• Portrait full-body and landscape upper-body modes +• Front and wide-rear camera support +• Clap-to-start when you are standing away from the phone +• Optional ReplayKit recording saved directly to Photos +• Optional browser display for live normalized movement—never camera frames + +Motion is an experimental movement game, not a fitness or medical service. Use it in a clear space and move within your own comfort and ability. + +**Keywords** +movement,body,controller,game,pose,camera,play,motion,active,airplay + +## URLs + +- Support: `https://motion.significanthobbies.com` +- Marketing: `https://motion.significanthobbies.com` +- Privacy: `https://motion.significanthobbies.com/privacy` + +The privacy source is prepared at `landing/privacy.html`; the live route must return HTTP 200 before submission. + +## App privacy answers + +- Tracking: No +- Data collected: No +- Camera frames and pose inference: processed on device +- Optional relay: transient normalized movement and room messages are forwarded to service a live session and are not retained +- Advertising, attribution, and third-party analytics: none +- IDFA: not used + +## Age rating draft + +- Made for Kids: No +- Gambling, contests, simulated gambling, loot boxes: None +- Sexual content, profanity, horror, drugs, alcohol, tobacco: None +- Medical or wellness topics: None +- User-generated content, messaging, unrestricted web access: None +- Cartoon/fantasy violence: Infrequent/Mild, because Motion Maker includes stylized sword-like play objects + +Confirm the rating produced by App Store Connect's current questionnaire; do not manually claim a lower rating. + +## Review notes + +No login is required. Grant Camera access and stand where the on-screen framing guide can see the requested body area. The primary Motion Maker game is bundled in the app and does not need a server. Rotate to portrait for full-body controls or landscape for upper-body controls. Recording is optional and requests Photos add-only permission when used. The “Stream to website” setting is optional and is not required to review the primary experience. + +Physical camera, pose accuracy, rotation, mirroring, ReplayKit output, and Photos saving require a real iPhone and remain device-verification items. + +## Screenshots + +- iPhone 6.9-inch portrait: `artifacts/app-store/iphone-6.9/motion-maker-clean.png` (`1206 × 2622`) +- iPad 13-inch portrait: `artifacts/app-store/ipad-13/motion-maker-clean.png` (`2064 × 2752`) +- Both files use Apple-accepted dimensions and were captured from the Release simulator build with the camera-free synthetic-pose harness +- App previews: omit for version 1.0 +- Release: manual diff --git a/ios/Resources/PrivacyInfo.xcprivacy b/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1a15072 --- /dev/null +++ b/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + + diff --git a/ios/Resources/webgame/index.html b/ios/Resources/webgame/index.html index e442af2..90fb94f 100644 --- a/ios/Resources/webgame/index.html +++ b/ios/Resources/webgame/index.html @@ -71,7 +71,7 @@ }); `),ft=!0);const i=this._options.WebSocket||WebSocket;this._debug("connect",{url:s,protocols:o}),this._ws=o?new i(s,o):new i(s),this._ws.binaryType=this._binaryType,this._connectLock=!1,this._addListeners(),this._connectTimeout=setTimeout(()=>this._handleTimeout(),e)}).catch(s=>{this._connectLock=!1,this._handleError(new G.ErrorEvent(Error(s.message),this))})}_handleTimeout(){this._debug("timeout event"),this._handleError(new G.ErrorEvent(Error("TIMEOUT"),this))}_disconnect(t=1e3,e){if(this._clearTimeouts(),!!this._ws){this._removeListeners();try{(this._ws.readyState===this.OPEN||this._ws.readyState===this.CONNECTING)&&this._ws.close(t,e),this._handleClose(new G.CloseEvent(t,e,this))}catch{}}}_acceptOpen(){this._debug("accept open"),this._retryCount=0}_handleOpen=t=>{this._debug("open event");const{minUptime:e=T.minUptime}=this._options;clearTimeout(this._connectTimeout),this._uptimeTimeout=setTimeout(()=>this._acceptOpen(),e),ae(this._ws,"WebSocket is not defined"),this._ws.binaryType=this._binaryType,this._messageQueue.forEach(s=>{this._ws?.send(s)}),this._messageQueue=[],this.onopen&&this.onopen(t),this.dispatchEvent(N(t))};_handleMessage=t=>{this._debug("message event"),this.onmessage&&this.onmessage(t),this.dispatchEvent(N(t))};_handleError=t=>{this._debug("error event",t.message),this._disconnect(void 0,t.message==="TIMEOUT"?"timeout":void 0),this.onerror&&this.onerror(t),this._debug("exec error listeners"),this.dispatchEvent(N(t)),this._connect()};_handleClose=t=>{this._debug("close event"),this._clearTimeouts(),this._options.shouldReconnectOnClose&&!this._options.shouldReconnectOnClose(t)&&(this._shouldReconnect=!1),this._shouldReconnect&&this._connect(),this.onclose&&this.onclose(t),this.dispatchEvent(N(t))};_removeListeners(){this._ws&&(this._debug("removeListeners"),this._ws.removeEventListener("open",this._handleOpen),this._ws.removeEventListener("close",this._handleClose),this._ws.removeEventListener("message",this._handleMessage),this._ws.removeEventListener("error",this._handleError),this._ws.addEventListener("error",fe))}_addListeners(){this._ws&&(this._debug("addListeners"),this._ws.addEventListener("open",this._handleOpen),this._ws.addEventListener("close",this._handleClose),this._ws.addEventListener("message",this._handleMessage),this._ws.addEventListener("error",this._handleError))}_clearTimeouts(){clearTimeout(this._connectTimeout),clearTimeout(this._uptimeTimeout)}};const pe=n=>n[1]!==null&&n[1]!==void 0;function me(){if(crypto?.randomUUID)return crypto.randomUUID();let n=Date.now(),t=performance?.now&&performance.now()*1e3||0;return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(e){let s=Math.random()*16;return n>0?(s=(n+s)%16|0,n=Math.floor(n/16)):(s=(t+s)%16|0,t=Math.floor(t/16)),(e==="x"?s:s&3|8).toString(16)})}function jt(n,t,e={}){const{host:s,path:o,protocol:i,room:r,party:a,basePath:h,prefix:l,query:d}=n;let c=s.replace(/^(http|https|ws|wss):\/\//,"");if(c.endsWith("/")&&(c=c.slice(0,-1)),o?.startsWith("/"))throw new Error("path must not start with a slash");const f=a??"main",u=o?`/${o}`:"",y=i||(c.startsWith("localhost:")||c.startsWith("127.0.0.1:")||c.startsWith("192.168.")||c.startsWith("10.")||c.startsWith("172.")&&c.split(".")[1]>="16"&&c.split(".")[1]<="31"||c.startsWith("[::ffff:7f00:1]:")?t:`${t}s`),m=`${y}://${c}/${h||`${l||"parties"}/${f}/${r}`}${u}`,p=(v={})=>`${m}?${new URLSearchParams([...Object.entries(e),...Object.entries(v).filter(pe)])}`,g=typeof d=="function"?async()=>p(await d()):p(d);return{host:c,path:u,room:r,name:f,protocol:y,partyUrl:m,urlProvider:g}}var ge=class extends ue{_pk;_pkurl;name;room;host;path;basePath;constructor(n){const t=ut(n);if(super(t.urlProvider,t.protocols,t.socketOptions),this.partySocketOptions=n,this.setWSProperties(t),!n.startClosed&&!this.room&&!this.basePath)throw this.close(),new Error("Either room or basePath must be provided to connect. Use startClosed: true to create a socket and set them via updateProperties before calling reconnect().");n.disableNameValidation||(n.party?.includes("/")&&console.warn(`PartySocket: party name "${n.party}" contains forward slash which may cause routing issues. Consider using a name without forward slashes or set disableNameValidation: true to bypass this warning.`),n.room?.includes("/")&&console.warn(`PartySocket: room name "${n.room}" contains forward slash which may cause routing issues. Consider using a name without forward slashes or set disableNameValidation: true to bypass this warning.`))}updateProperties(n){const t=ut({...this.partySocketOptions,...n,host:n.host??this.host,room:n.room??this.room,path:n.path??this.path,basePath:n.basePath??this.basePath});this._url=t.urlProvider,this._protocols=t.protocols,this._options=t.socketOptions,this.setWSProperties(t)}setWSProperties(n){const{_pk:t,_pkurl:e,name:s,room:o,host:i,path:r,basePath:a}=n;this._pk=t,this._pkurl=e,this.name=s,this.room=o,this.host=i,this.path=r,this.basePath=a}reconnect(n,t){if(!this.host)throw new Error("The host must be set before connecting, use `updateProperties` method to set it or pass it to the constructor.");if(!this.room&&!this.basePath)throw new Error("The room (or basePath) must be set before connecting, use `updateProperties` method to set it or pass it to the constructor.");super.reconnect(n,t)}get id(){return this._pk}get roomUrl(){return this._pkurl}static async fetch(n,t){const e=jt(n,"http"),s=typeof e.urlProvider=="string"?e.urlProvider:await e.urlProvider();return(n.fetch??fetch)(s,t)}};function ut(n){const{id:t,host:e,path:s,party:o,room:i,protocol:r,query:a,protocols:h,...l}=n,d=t||me(),c=jt(n,"ws",{_pk:d});return{_pk:d,_pkurl:c.partyUrl,name:c.name,room:c.room,host:c.host,path:c.path,basePath:n.basePath,protocols:h,socketOptions:l,urlProvider:c.urlProvider}}function ye(n){return n.replace(/^wss?:\/\//,"").replace(/^https?:\/\//,"").replace(/\/$/,"")}class be{code;socket;listeners={pose:new Set,status:new Set,calib:new Set,peer:new Set,error:new Set,conn:new Set};pingTimer=null;connState="connecting";rtt=-1;clockSkew=0;peerConnected=!1;lastPose=null;constructor(t,e){this.code=e??Yt(),this.socket=new ge({host:ye(t),room:this.code}),this.wire()}wire(){this.socket.addEventListener("open",()=>{this.setConn("open"),this.join(),this.startPingLoop()}),this.socket.addEventListener("close",()=>{this.setConn("closed"),this.stopPingLoop()}),this.socket.addEventListener("error",()=>{this.setConn("connecting")}),this.socket.addEventListener("message",t=>{this.onRaw(typeof t.data=="string"?t.data:String(t.data))})}setConn(t){this.connState=t,this.emit("conn",t)}get connectionState(){return this.connState}onRaw(t){const e=Xt(t);if(e){if(Qt(e)){this.lastPose=e,this.emit("pose",e);return}switch(e.type){case"status":this.emit("status",e);break;case"calib":this.emit("calib",e);break;case"peer":{const s=e;s.role==="controller"&&(this.peerConnected=s.connected),this.emit("peer",s);break}case"pong":{const s=e,o=performance.now();this.rtt=Math.max(0,o-s.t);break}case"error":this.emit("error",e);break}}}sendRaw(t){this.socket.readyState===WebSocket.OPEN&&this.socket.send(JSON.stringify(t))}get isOpen(){return this.socket.readyState===WebSocket.OPEN}get bufferedAmount(){return this.socket.bufferedAmount??0}sendRec(t){this.sendRaw(t)}join(){const t={v:1,type:"join",role:"display"};this.sendRaw(t)}start(){const t={v:1,type:"start"};this.sendRaw(t)}startPingLoop(){this.stopPingLoop();const t=()=>{const e={v:1,type:"ping",t:performance.now()};this.sendRaw(e)};t(),this.pingTimer=window.setInterval(t,2e3)}stopPingLoop(){this.pingTimer!==null&&(clearInterval(this.pingTimer),this.pingTimer=null)}on(t,e){return this.listeners[t].add(e),()=>this.listeners[t].delete(e)}emit(t,...e){for(const s of this.listeners[t])s(...e)}close(){this.stopPingLoop(),this.socket.close()}}const pt=16/9;class xe{constructor(t){this.canvas=t;const e=t.getContext("2d",{alpha:!1});if(!e)throw new Error("2D canvas context unavailable");this.ctx=e,this.resize(),window.addEventListener("resize",this.resize)}ctx;play={x:0,y:0,w:0,h:0};dpr=1;resize=()=>{const t=Math.min(window.devicePixelRatio||1,3);this.dpr=t;const e=window.innerWidth,s=window.innerHeight;this.canvas.width=Math.round(e*t),this.canvas.height=Math.round(s*t),this.canvas.style.width=`${e}px`,this.canvas.style.height=`${s}px`;let o=e,i=e/pt;i>s&&(i=s,o=s*pt),this.play={x:(e-o)/2,y:(s-i)/2,w:o,h:i}};begin(){const{ctx:t,dpr:e}=this;t.setTransform(e,0,0,e,0,0),t.fillStyle="#05070d",t.fillRect(0,0,this.canvas.width/e,this.canvas.height/e);const s=this.play;t.fillStyle="#070b16",t.fillRect(s.x,s.y,s.w,s.h)}toPx(t,e){return[this.play.x+t*this.play.w,this.play.y+e*this.play.h]}sx(t){return t*this.play.w}sy(t){return t*this.play.h}dispose(){window.removeEventListener("resize",this.resize)}}const ve=[["head","torso"],["torso","leftKnee"],["torso","rightKnee"],["leftKnee","leftFoot"],["rightKnee","rightFoot"]],we=[{shoulder:"leftShoulder",elbow:"leftElbow",hand:"leftHand"},{shoulder:"rightShoulder",elbow:"rightElbow",hand:"rightHand"}];function nt(n,t,e={}){const{ctx:s}=n,o=t.joints,i=e.color??"#f4f7ff",r=e.handColor??"#35e0c8",a=e.alpha??1;s.save(),s.globalAlpha=a,s.lineWidth=Math.max(3,n.sx(.012)),s.lineCap="round",s.strokeStyle=i;for(const[d,c]of ve){const f=o[d],u=o[c];if(!f||!u)continue;const[y,m]=n.toPx(f[0],f[1]),[p,g]=n.toPx(u[0],u[1]);s.beginPath(),s.moveTo(y,m),s.lineTo(p,g),s.stroke()}for(const{shoulder:d,elbow:c,hand:f}of we){const u=o[d],y=o[c],m=o[f];if(s.beginPath(),u&&y){const[p,g]=n.toPx(u[0],u[1]),[v,w]=n.toPx(y[0],y[1]),[k,I]=n.toPx(m[0],m[1]);s.moveTo(p,g),s.lineTo(v,w),s.lineTo(k,I)}else{const p=u??o.torso,[g,v]=n.toPx(p[0],p[1]),[w,k]=n.toPx(m[0],m[1]);s.moveTo(g,v),s.lineTo(w,k)}s.stroke()}const[h,l]=n.toPx(o.head[0],o.head[1]);s.fillStyle=i,s.beginPath(),s.arc(h,l,Math.max(8,n.sx(.028)),0,Math.PI*2),s.fill();for(const d of[o.leftHand,o.rightHand]){const[c,f]=n.toPx(d[0],d[1]);s.fillStyle=r,s.beginPath(),s.arc(c,f,Math.max(10,n.sx(.03)),0,Math.PI*2),s.fill(),s.fillStyle="rgba(255,255,255,0.85)",s.beginPath(),s.arc(c,f,Math.max(4,n.sx(.012)),0,Math.PI*2),s.fill()}s.restore()}const S=.35,K=6;class P{constructor(t){this.target=t,this.target.addEventListener("mousemove",this.onMouse),this.target.addEventListener("touchmove",this.onTouch,{passive:!0}),this.target.addEventListener("mousedown",this.onMouseDown),window.addEventListener("mouseup",this.onMouseUp),window.addEventListener("keydown",this.onKeyDown),window.addEventListener("keyup",this.onKeyUp)}joints={head:[.5,.18],leftHand:[.4,.5],rightHand:[.6,.5],torso:[.5,.5],leftKnee:[.42,.72],rightKnee:[.58,.72],leftFoot:[.42,.95],rightFoot:[.58,.95]};squatAmount=0;leanAmount=0;leftHandOpen=1;rightHandOpen=1;leftFingertip=void 0;rightFingertip=void 0;trackingQuality=1;health="no_signal";hasRequiredJoints=!1;ageMs=1/0;mouseX=.5;mouseY=.5;moved=!1;leanKey=0;squatKey=!1;grabKey=!1;mouseDown=!1;lastTick=-1;held=new Set;onMouseDown=t=>{t.button===0&&(this.mouseDown=!0,this.recomputeKeys())};onMouseUp=t=>{t.button===0&&(this.mouseDown=!1,this.recomputeKeys())};onMouse=t=>{const e=this.target.getBoundingClientRect();this.mouseX=b((t.clientX-e.left)/Math.max(1,e.width)),this.mouseY=b((t.clientY-e.top)/Math.max(1,e.height)),this.moved=!0};onTouch=t=>{const e=t.touches[0];if(!e)return;const s=this.target.getBoundingClientRect();this.mouseX=b((e.clientX-s.left)/Math.max(1,s.width)),this.mouseY=b((e.clientY-s.top)/Math.max(1,s.height)),this.moved=!0};onKeyDown=t=>{this.held.add(t.key.toLowerCase()),this.recomputeKeys()};onKeyUp=t=>{this.held.delete(t.key.toLowerCase()),this.recomputeKeys()};recomputeKeys(){const t=this.held,e=t.has("arrowleft")||t.has("a"),s=t.has("arrowright")||t.has("d");this.leanKey=e&&!s?-1:s&&!e?1:0,this.squatKey=t.has("arrowdown")||t.has("s"),this.grabKey=this.mouseDown||t.has(" ")||t.has("spacebar")}tick(t){const e=this.lastTick<0?.016:Math.min(.05,(t-this.lastTick)/1e3);this.lastTick=t;const s=Ht(this.leanKey);this.leanAmount+=(s-this.leanAmount)*Math.min(1,K*e);const o=this.squatKey?1:0;this.squatAmount+=(o-this.squatAmount)*Math.min(1,K*e);const i=this.grabKey?0:1,r=Math.min(1,K*e);this.leftHandOpen+=(i-this.leftHandOpen)*r,this.rightHandOpen+=(i-this.rightHandOpen)*r;const a=[b(this.mouseX-.12),this.mouseY],h=[b(this.mouseX+.12),this.mouseY];this.joints.leftHand=M(this.joints.leftHand,a,S),this.joints.rightHand=M(this.joints.rightHand,h,S);const l=.5+this.leanAmount*.28,d=.5+this.squatAmount*.1;this.joints.torso=M(this.joints.torso,[l,d],S),this.joints.head=M(this.joints.head,[l,.18+this.squatAmount*.16],S),this.joints.leftKnee=M(this.joints.leftKnee,[l-.08,.72+this.squatAmount*.05],S),this.joints.rightKnee=M(this.joints.rightKnee,[l+.08,.72+this.squatAmount*.05],S),this.joints.leftFoot=M(this.joints.leftFoot,[l-.08,.95],S),this.joints.rightFoot=M(this.joints.rightFoot,[l+.08,.95],S),this.moved?(this.health="ok",this.hasRequiredJoints=!0,this.ageMs=0,this.trackingQuality=1,this.leftFingertip=[this.joints.leftHand[0],this.joints.leftHand[1]],this.rightFingertip=[this.joints.rightHand[0],this.joints.rightHand[1]]):(this.health="no_signal",this.hasRequiredJoints=!1,this.ageMs=1/0,this.trackingQuality=0,this.leftFingertip=void 0,this.rightFingertip=void 0)}dispose(){this.target.removeEventListener("mousemove",this.onMouse),this.target.removeEventListener("touchmove",this.onTouch),this.target.removeEventListener("mousedown",this.onMouseDown),window.removeEventListener("mouseup",this.onMouseUp),window.removeEventListener("keydown",this.onKeyDown),window.removeEventListener("keyup",this.onKeyUp)}}function M(n,t,e){return[n[0]+(t[0]-n[0])*e,n[1]+(t[1]-n[1])*e]}const Bt="0.10.14",Se=`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${Bt}/+esm`,Me=`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${Bt}/wasm`,_e="https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",ke=300,$=250,Te=15,mt=1.1,Ee=2,gt=.5,Y=1.75,z=1.55,Q=.5,X=.45;function C(n,t,e){return b(.5+(n-t)*e)}class yt{y=0;started=!1;get value(){return this.y}filter(t,e){return this.y=this.started?e*t+(1-e)*this.y:t,this.started=!0,this.y}}class D{constructor(t=1.2,e=1,s=1){this.minCutoff=t,this.beta=e,this.dCutoff=s}xLp=new yt;dxLp=new yt;tPrev=-1;alpha(t,e){return 1/(1+1/(2*Math.PI*e)/t)}filter(t,e){if(this.tPrev<0)return this.tPrev=e,this.xLp.filter(t,1);const s=Math.max(.001,e-this.tPrev);this.tPrev=e;const o=(t-this.xLp.value)/s,i=this.dxLp.filter(o,this.alpha(s,this.dCutoff)),r=this.minCutoff+this.beta*Math.abs(i);return this.xLp.filter(t,this.alpha(s,r))}}function Pe(){return{head:[.5,.18],leftHand:[.35,.5],rightHand:[.65,.5],torso:[.5,.5],leftKnee:[.42,.72],rightKnee:[.58,.72],leftFoot:[.42,.95],rightFoot:[.58,.95]}}class Ce{joints=Pe();squatAmount=0;leanAmount=0;leftHandOpen=1;rightHandOpen=1;leftFingertip=void 0;rightFingertip=void 0;leftHandActive=!1;rightHandActive=!1;trackingQuality=0;health="no_signal";hasRequiredJoints=!1;ageMs=1/0;video=null;landmarker=null;status;ready=!1;failed=!1;lastVideoTime=-1;lastDetectAt=-1/0;lastHandAt=-1/0;lastLeftAt=-1/0;lastRightAt=-1/0;filters={left:{x:new D,y:new D},right:{x:new D,y:new D}};constructor(){this.status=this.makeStatus("Starting camera… allow access when prompted."),this.init()}makeStatus(t){const e=document.createElement("div");return e.textContent=t,Object.assign(e.style,{position:"absolute",inset:"0",display:"flex",alignItems:"center",justifyContent:"center",textAlign:"center",padding:"0 32px",font:"500 18px/1.5 system-ui, sans-serif",color:"#cdd6f4",background:"#05070d",zIndex:"50",pointerEvents:"none"}),document.body.appendChild(e),e}async init(){try{const t=await import(Se),e=await t.FilesetResolver.forVisionTasks(Me),s=r=>t.HandLandmarker.createFromOptions(e,{baseOptions:{modelAssetPath:_e,delegate:r},runningMode:"VIDEO",numHands:2,minHandDetectionConfidence:.5,minHandPresenceConfidence:.3,minTrackingConfidence:.3});this.landmarker=await s("GPU").catch(()=>s("CPU"));const o=await navigator.mediaDevices.getUserMedia({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30},facingMode:"user"},audio:!1}),i=document.createElement("video");i.srcObject=o,i.playsInline=!0,i.muted=!0,await i.play(),this.video=i,this.showPreview(i),this.ready=!0,this.status.style.display="none"}catch(t){this.failed=!0;const e=t instanceof Error?t.message:String(t);this.status.textContent=`Camera unavailable: ${e}. Allow camera access (or pick a camera) and reload.`,console.error("[motion] webcam init failed:",t)}}tick(t){if(this.ready&&this.landmarker&&this.video&&!this.failed&&t-this.lastDetectAt>=Te&&this.video.currentTime!==this.lastVideoTime&&this.video.readyState>=2){this.lastDetectAt=t,this.lastVideoTime=this.video.currentTime;try{this.applyResult(this.landmarker.detectForVideo(this.video,t),t)}catch{}}this.leftHandActive=t-this.lastLeftAt<$,this.rightHandActive=t-this.lastRightAt<$,this.ageMs=t-this.lastHandAt,this.lastHandAt===-1/0?(this.health="no_signal",this.trackingQuality=0,this.hasRequiredJoints=!1):this.ageMs>ke?(this.health="stale",this.trackingQuality=0,this.hasRequiredJoints=!1):(this.health="ok",this.trackingQuality=1,this.hasRequiredJoints=!0)}applyResult(t,e){const s=t.landmarks??[];if(s.length===0)return;const o=s.map(h=>{const l=h[9]??h[0];return{x:1-(l?.x??.5),y:l?.y??.5,open:Re(h),indexTip:h[8]}}),i=this.joints.leftHand,r=this.joints.rightHand,a=(h,l)=>Math.hypot(h.x-l[0],h.y-l[1]);if(o.length===1){const h=o[0],l=e-this.lastLeftAt<$,d=e-this.lastRightAt<$;let c;l&&!d?c="left":d&&!l?c="right":c=a(h,i)<=a(h,r)?"left":"right",this.applyHand(c,h,e),c==="left"?this.lastLeftAt=e:this.lastRightAt=e}else{const h=o[0],l=o[1],d=a(h,i)+a(l,r),c=a(h,r)+a(l,i);d<=c?(this.applyHand("left",h,e),this.applyHand("right",l,e)):(this.applyHand("left",l,e),this.applyHand("right",h,e)),this.lastLeftAt=e,this.lastRightAt=e}this.lastHandAt=e}applyHand(t,e,s){const o=t==="left"?"leftHand":"rightHand",i=this.filters[t],r=s/1e3,a=i.x.filter(b(e.x),r),h=i.y.filter(b(e.y),r);this.joints[o]=[C(a,Q,Y),C(h,X,z)],t==="left"?(this.leftHandOpen+=(e.open-this.leftHandOpen)*gt,this.leftFingertip=e.indexTip?[C(1-e.indexTip.x,Q,Y),C(e.indexTip.y,X,z)]:void 0):(this.rightHandOpen+=(e.open-this.rightHandOpen)*gt,this.rightFingertip=e.indexTip?[C(1-e.indexTip.x,Q,Y),C(e.indexTip.y,X,z)]:void 0)}showPreview(t){Object.assign(t.style,{position:"fixed",right:"16px",bottom:"16px",width:"22vw",maxWidth:"260px",aspectRatio:"4 / 3",objectFit:"cover",transform:"scaleX(-1)",borderRadius:"12px",border:"2px solid rgba(120,200,255,0.5)",boxShadow:"0 6px 24px rgba(0,0,0,0.5)",zIndex:"40",pointerEvents:"none"}),document.body.appendChild(t)}dispose(){this.video?.srcObject?.getTracks().forEach(e=>e.stop()),this.video?.remove(),this.video=null,this.landmarker=null,this.status.remove()}}function Re(n){const t=n[0],e=n[9];if(!t||!e)return 1;const s=Math.hypot(t.x-e.x,t.y-e.y)||.001,o=[8,12,16,20];let i=0,r=0;for(const h of o){const l=n[h];l&&(i+=Math.hypot(l.x-t.x,l.y-t.y),r++)}if(r===0)return 1;const a=i/r/s;return b((a-mt)/(Ee-mt))}const Ae=.55,bt=1500;class Ie{constructor(t,e){this.room=t,this.debug=e,e||t.on("calib",s=>{s.stage==="done"&&(this.calibDone=!0)})}goodSince=-1;calibDone=!1;evaluate(t,e){const s=this.debug?!0:this.room.peerConnected,o=t.hasRequiredJoints;t.health==="ok"&&t.trackingQuality>=(this.debug?.1:Ae)&&o&&s?this.goodSince<0&&(this.goodSince=e):this.goodSince=-1;const r=this.goodSince>=0?e-this.goodSince:0,a=r>=bt,h=this.debug?!0:this.calibDone;return{peer:s,joints:o,quality:a,calibrated:h,ready:s&&o&&a&&h,qualityHold:Math.min(1,r/bt)}}}class xt{constructor(t,e,s){this.room=e,this.live=s,this.el=document.createElement("div"),Object.assign(this.el.style,{position:"absolute",top:"12px",left:"12px",padding:"10px 12px",background:"rgba(5,7,13,0.82)",border:"1px solid rgba(53,224,200,0.4)",borderRadius:"8px",font:"12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace",color:"#9ff5e6",whiteSpace:"pre",pointerEvents:"none",zIndex:"50"}),t.appendChild(this.el),this.live&&this.room.on("pose",o=>{this.poseCount++,this.lastSeq>=0&&o.seq>this.lastSeq+1&&(this.droppedSeq+=o.seq-this.lastSeq-1),this.lastSeq=o.seq;const i=performance.now()+this.room.clockSkew;this.lastLatency=Math.max(0,i-o.sentAt)})}el;frames=0;poseCount=0;lastPoseCountAt=performance.now();poseRate=0;lastSeq=-1;droppedSeq=0;staleCount=0;lastLatency=0;update(t){this.frames++;const e=performance.now(),s=(e-this.lastPoseCountAt)/1e3;s>=.5&&(this.poseRate=this.poseCount/s,this.poseCount=0,this.lastPoseCountAt=e),t.health==="stale"&&this.staleCount++;const o=this.room.rtt<0?"—":this.room.rtt.toFixed(0),i=this.live?`${this.lastLatency.toFixed(0)}ms`:"n/a (debug)",r=this.live?`${this.poseRate.toFixed(1)}/s`:"n/a (debug)",a=this.live?String(this.lastSeq):"n/a";this.el.textContent=[`Motion diag room ${this.room.code}`,`conn ${this.room.connectionState}`,`peer ${this.room.peerConnected?"controller ✓":"—"}`,`rtt ${o} ms`,`pose rate ${r}`,`last seq ${a}`,`dropped ${this.droppedSeq}`,`stale hit ${this.staleCount}`,`m→render ${i}`,`quality ${t.trackingQuality.toFixed(2)} (${t.health})`,`squat ${t.squatAmount.toFixed(2)}`,`lean ${t.leanAmount.toFixed(2)}`].join(` -`)}dispose(){this.el.remove()}}const Oe=["video/mp4;codecs=h264","video/mp4","video/webm;codecs=vp9","video/webm"],J=500*1024,Le=8,He=1e6;function vt(){if(typeof MediaRecorder>"u")return null;for(const n of Oe)try{if(MediaRecorder.isTypeSupported(n))return n}catch{}return null}function Fe(n){return!!n&&n.startsWith("video/mp4")}class wt{room;canvas;onPhase;fps;recorder=null;stream=null;chunks=[];mime=null;sessionId="";anchorMs=0;startWallMs=0;startOffsetMs=0;firstFrameSeen=!1;phase="idle";downloadUrl=null;lastMime=null;needsMp4Warning=!1;constructor(t){this.room=t.room,this.canvas=t.canvas,this.onPhase=t.onPhase??(()=>{}),this.fps=t.fps??30}static get supported(){return typeof MediaRecorder<"u"&&vt()!==null}setPhase(t,e){this.phase=t,this.onPhase(t,e)}start(){if(this.phase==="recording"||this.phase==="transferring")return this.sessionId;this.revokeUrl();const t=vt();if(this.mime=t,this.lastMime=t,!t)return this.setPhase("error","MediaRecorder unsupported in this browser"),null;this.needsMp4Warning=!Fe(t),this.needsMp4Warning&&console.warn(`[motion] Recording as ${t} — iOS cannot composite WebM. Use a browser with MP4/H.264 MediaRecorder support for on-device picture-in-picture; otherwise the phone saves two separate clips.`);let e;try{e=this.canvas.captureStream(this.fps)}catch(i){return this.setPhase("error",`captureStream failed: ${String(i)}`),null}this.stream=e;let s;try{s=new MediaRecorder(e,{mimeType:t})}catch(i){return this.setPhase("error",`MediaRecorder init failed: ${String(i)}`),null}this.recorder=s,this.chunks=[],this.firstFrameSeen=!1,this.sessionId=crypto.randomUUID(),this.anchorMs=Date.now(),this.startWallMs=0,this.startOffsetMs=0,s.ondataavailable=i=>{i.data&&i.data.size>0&&(this.firstFrameSeen||(this.firstFrameSeen=!0,this.startOffsetMs=Math.max(0,Date.now()-this.anchorMs)),this.chunks.push(i.data))};const o={v:1,type:"rec",action:"start",sessionId:this.sessionId,anchorMs:this.anchorMs};return this.room.sendRec(o),s.start(1e3),this.startWallMs=Date.now(),this.startOffsetMs=Math.max(0,this.startWallMs-this.anchorMs),this.setPhase("recording",t),this.sessionId}async stop(){const t=this.recorder;if(!t||this.phase!=="recording")return;const e=Math.max(0,Date.now()-this.startWallMs),s=await this.finalizeBlob(t);this.teardownStream();const o=this.mime??"video/webm",i={v:1,type:"rec",action:"stop",sessionId:this.sessionId,anchorMs:Date.now()};if(this.room.sendRec(i),this.downloadUrl=URL.createObjectURL(s),!this.room.peerConnected){this.setPhase("done",`No phone connected — clip available as browser download (${o}).`);return}await this.transfer(s,o,e)}cancel(){if(this.recorder&&this.recorder.state!=="inactive")try{this.recorder.stop()}catch{}if(this.teardownStream(),this.sessionId){const t={v:1,type:"rec",action:"cancel",sessionId:this.sessionId,anchorMs:Date.now()};this.room.sendRec(t)}this.recorder=null,this.chunks=[],this.setPhase("idle")}revokeUrl(){this.downloadUrl&&(URL.revokeObjectURL(this.downloadUrl),this.downloadUrl=null)}dispose(){this.cancel(),this.revokeUrl()}finalizeBlob(t){return new Promise(e=>{const s=()=>{const o=this.mime??"video/webm";e(new Blob(this.chunks,{type:o}))};if(t.onstop=s,t.state==="inactive")s();else try{t.stop()}catch{s()}})}teardownStream(){if(this.stream){for(const t of this.stream.getTracks())t.stop();this.stream=null}}async transfer(t,e,s){this.setPhase("transferring");const o=new Uint8Array(await t.arrayBuffer()),i=o.byteLength,r=Math.max(1,Math.ceil(i/J)),a={v:1,type:"recmeta",sessionId:this.sessionId,mime:e,totalBytes:i,chunks:r,durationMs:s,startOffsetMs:this.startOffsetMs};this.room.sendRec(a);for(let h=0;hsetTimeout(t,n))}async function $e(n){let t=0;for(;n.isOpen&&n.bufferedAmount>He&&t<200;)await qt(16),t++}class De{constructor(t){this.surf=t}get ctx(){return this.surf.ctx}get area(){const t=this.surf.play;return{x:t.x,y:t.y,w:t.w,h:t.h}}toPx(t,e){return this.surf.toPx(t,e)}sx(t){return this.surf.sx(t)}sy(t){return this.surf.sy(t)}drawSkeleton(t,e){nt(this.surf,t,e??{})}}const V=1e3/60,je=1500;class Be{room;surf;renderer;overlayEl;canvasEl;game;screens;options;transport;skipReadiness;poseController;body;debugMode;webcamController=null;bridgeController=null;nativeBridge=null;nativeStartRequested=!1;readiness;diagnostics=null;recorder;recordOn;recPhase="idle";screen;mirrorStableSince=-1;bridgeIdleSince=-1;paused=!1;lastResult=null;gameInited=!1;acc=0;last=performance.now();rafId=0;stopped=!1;lastError=null;lastErrorStack="";readyEmitted=!1;listeners={screen:new Set,result:new Set,recording:new Set};constructor(t){this.game=t.game,this.screens=t.screens,this.options=t.options,this.canvasEl=t.mount.canvas,this.overlayEl=t.mount.overlay,this.debugMode=!!t.options.debug,this.recordOn=!!t.options.record,this.transport=t.options.transport??"socket",this.skipReadiness=!!t.options.skipReadiness,this.surf=new xe(this.canvasEl),this.renderer=new De(this.surf),this.poseController=new Zt,this.transport==="bridge"?(this.room=null,this.readiness=null,this.recorder=null,this.recordOn=!1,this.bridgeController=new te,this.nativeBridge=new se(this.bridgeController,{onStart:()=>this.onNativeStart(),onStop:()=>this.onNativeStop(),onCalibrated:()=>{},onTracking:e=>this.onNativeTracking(e)}),this.body=this.debugMode?new P(this.canvasEl):this.bridgeController,this.screen="bridge-idle"):t.options.camera?(this.room=null,this.readiness=null,this.recorder=null,this.recordOn=!1,this.webcamController=new Ce,this.body=this.debugMode?new P(this.canvasEl):this.webcamController,this.screen="pairing"):(this.room=new be(t.options.partyHost,t.options.forcedRoom),this.room.on("pose",e=>this.poseController.ingest(e)),this.body=this.debugMode?new P(this.canvasEl):this.poseController,this.readiness=new Ie(this.room,this.debugMode),this.recorder=new wt({room:this.room,canvas:this.canvasEl,onPhase:(e,s)=>{this.recPhase=e,this.emit("recording",e,s)}}),this.debugMode&&(this.diagnostics=new xt(this.overlayEl,this.room,!1)),this.screen="pairing"),window.addEventListener("keydown",this.onKeyDown)}onNativeStart(){this.screen==="bridge-idle"||this.screen==="results"?(this.nativeStartRequested=!1,this.setScreen("game")):this.nativeStartRequested=!0}onNativeStop(){this.screen==="game"&&(this.lastResult=this.game.result(),this.lastResult&&this.emit("result",this.lastResult)),this.nativeStartRequested=!1,this.setScreen("bridge-idle")}onNativeTracking(t){}get code(){return this.room?.code??""}get controller(){return this.body}get recording(){return this.recordOn}get recordingPhase(){return this.recPhase}get recorderNeedsMp4Warning(){return this.recorder?.needsMp4Warning??!1}get lastClipUrl(){return this.recorder?.downloadUrl??null}setRecordEnabled(t){this.recordOn=t}on(t,e){return this.listeners[t].add(e),()=>this.listeners[t].delete(e)}emit(t,...e){for(const s of this.listeners[t])s(...e)}start(){this.stopped=!1,this.last=performance.now(),this.rafId=requestAnimationFrame(this.frame)}dispose(){this.stopped=!0,this.rafId&&cancelAnimationFrame(this.rafId),window.removeEventListener("keydown",this.onKeyDown),this.body instanceof P&&this.body.dispose(),this.poseController.dispose(),this.webcamController?.dispose(),this.bridgeController?.dispose(),this.nativeBridge?.dispose(),this.diagnostics?.dispose(),this.recorder?.dispose(),this.game.dispose?.(),this.surf.dispose(),this.room?.close()}onKeyDown=t=>{t.key.toLowerCase()==="d"&&!t.repeat&&this.toggleDebug()};toggleDebug(){this.debugMode=!this.debugMode,this.body instanceof P&&this.body.dispose();const t=this.transport==="bridge"&&this.bridgeController?this.bridgeController:this.webcamController??this.poseController;this.body=this.debugMode?new P(this.canvasEl):t,this.diagnostics?.dispose(),this.diagnostics=this.debugMode&&this.room?new xt(this.overlayEl,this.room,!1):null}setScreen(t){const e=this.screen==="results";if(this.screen=t,this.screens.clear(),t==="bridge-idle"&&(this.bridgeIdleSince=-1),t==="game"){const s=this.gameInited;this.gameInited?(this.game.reset(),this.game.init({width:this.surf.play.w,height:this.surf.play.h})):(this.game.init({width:this.surf.play.w,height:this.surf.play.h}),this.gameInited=!0),this.room?.start(),this.paused=!1,this.transport==="bridge"?((s||e)&&this.nativeBridge?.emit({event:"restart"}),this.nativeBridge?.emit({event:"gameStart"})):this.recordOn&&wt.supported&&this.recorder?.start()}this.emit("screen",t)}frame=t=>{if(!this.stopped){try{const e=Math.min(100,t-this.last);this.last=t,this.body.tick(t),this.acc+=e;let s=0;for(;this.acc>=V&&s<5;)this.step(V,t),this.acc-=V,s++;this.render(t),this.diagnostics?.update(this.body)}catch(e){this.lastError=e instanceof Error?e.message:String(e),this.lastErrorStack=e instanceof Error?e.stack??"":"",console.error("[motion] frame error:",e)}try{this.drawDiag()}catch{}this.rafId=requestAnimationFrame(this.frame)}};drawDiag(){const t=this.surf.ctx,e=this.surf.dpr;t.setTransform(e,0,0,e,0,0);const s=this.body,o=Number.isFinite(s.ageMs)?Math.round(s.ageMs):-1,i=`scr:${this.screen} h:${s.health} age:${o}ms q:${s.trackingQuality.toFixed(2)} L:${s.leftHandOpen.toFixed(2)} R:${s.rightHandOpen.toFixed(2)}`;t.save(),t.font="12px monospace",t.textAlign="left",t.textBaseline="bottom";const r=window.innerHeight-6,a=t.measureText(i).width+12;if(t.fillStyle="rgba(0,0,0,0.65)",t.fillRect(4,r-16,a,20),t.fillStyle="#37ff8b",t.fillText(i,10,r),t.restore(),this.lastError){t.save(),t.fillStyle="rgba(150,0,0,0.92)",t.fillRect(0,0,window.innerWidth,96),t.fillStyle="#fff",t.textAlign="left",t.textBaseline="top",t.font="bold 13px monospace",t.fillText("MOTION FRAME ERROR",10,8),t.font="12px monospace",t.fillText(this.lastError.slice(0,90),10,30);const h=(this.lastErrorStack.split(` +`)}dispose(){this.el.remove()}}const Oe=["video/mp4;codecs=h264","video/mp4","video/webm;codecs=vp9","video/webm"],J=500*1024,Le=8,He=1e6;function vt(){if(typeof MediaRecorder>"u")return null;for(const n of Oe)try{if(MediaRecorder.isTypeSupported(n))return n}catch{}return null}function Fe(n){return!!n&&n.startsWith("video/mp4")}class wt{room;canvas;onPhase;fps;recorder=null;stream=null;chunks=[];mime=null;sessionId="";anchorMs=0;startWallMs=0;startOffsetMs=0;firstFrameSeen=!1;phase="idle";downloadUrl=null;lastMime=null;needsMp4Warning=!1;constructor(t){this.room=t.room,this.canvas=t.canvas,this.onPhase=t.onPhase??(()=>{}),this.fps=t.fps??30}static get supported(){return typeof MediaRecorder<"u"&&vt()!==null}setPhase(t,e){this.phase=t,this.onPhase(t,e)}start(){if(this.phase==="recording"||this.phase==="transferring")return this.sessionId;this.revokeUrl();const t=vt();if(this.mime=t,this.lastMime=t,!t)return this.setPhase("error","MediaRecorder unsupported in this browser"),null;this.needsMp4Warning=!Fe(t),this.needsMp4Warning&&console.warn(`[motion] Recording as ${t} — iOS cannot composite WebM. Use a browser with MP4/H.264 MediaRecorder support for on-device picture-in-picture; otherwise the phone saves two separate clips.`);let e;try{e=this.canvas.captureStream(this.fps)}catch(i){return this.setPhase("error",`captureStream failed: ${String(i)}`),null}this.stream=e;let s;try{s=new MediaRecorder(e,{mimeType:t})}catch(i){return this.setPhase("error",`MediaRecorder init failed: ${String(i)}`),null}this.recorder=s,this.chunks=[],this.firstFrameSeen=!1,this.sessionId=crypto.randomUUID(),this.anchorMs=Date.now(),this.startWallMs=0,this.startOffsetMs=0,s.ondataavailable=i=>{i.data&&i.data.size>0&&(this.firstFrameSeen||(this.firstFrameSeen=!0,this.startOffsetMs=Math.max(0,Date.now()-this.anchorMs)),this.chunks.push(i.data))};const o={v:1,type:"rec",action:"start",sessionId:this.sessionId,anchorMs:this.anchorMs};return this.room.sendRec(o),s.start(1e3),this.startWallMs=Date.now(),this.startOffsetMs=Math.max(0,this.startWallMs-this.anchorMs),this.setPhase("recording",t),this.sessionId}async stop(){const t=this.recorder;if(!t||this.phase!=="recording")return;const e=Math.max(0,Date.now()-this.startWallMs),s=await this.finalizeBlob(t);this.teardownStream();const o=this.mime??"video/webm",i={v:1,type:"rec",action:"stop",sessionId:this.sessionId,anchorMs:Date.now()};if(this.room.sendRec(i),this.downloadUrl=URL.createObjectURL(s),!this.room.peerConnected){this.setPhase("done",`No phone connected — clip available as browser download (${o}).`);return}await this.transfer(s,o,e)}cancel(){if(this.recorder&&this.recorder.state!=="inactive")try{this.recorder.stop()}catch{}if(this.teardownStream(),this.sessionId){const t={v:1,type:"rec",action:"cancel",sessionId:this.sessionId,anchorMs:Date.now()};this.room.sendRec(t)}this.recorder=null,this.chunks=[],this.setPhase("idle")}revokeUrl(){this.downloadUrl&&(URL.revokeObjectURL(this.downloadUrl),this.downloadUrl=null)}dispose(){this.cancel(),this.revokeUrl()}finalizeBlob(t){return new Promise(e=>{const s=()=>{const o=this.mime??"video/webm";e(new Blob(this.chunks,{type:o}))};if(t.onstop=s,t.state==="inactive")s();else try{t.stop()}catch{s()}})}teardownStream(){if(this.stream){for(const t of this.stream.getTracks())t.stop();this.stream=null}}async transfer(t,e,s){this.setPhase("transferring");const o=new Uint8Array(await t.arrayBuffer()),i=o.byteLength,r=Math.max(1,Math.ceil(i/J)),a={v:1,type:"recmeta",sessionId:this.sessionId,mime:e,totalBytes:i,chunks:r,durationMs:s,startOffsetMs:this.startOffsetMs};this.room.sendRec(a);for(let h=0;hsetTimeout(t,n))}async function $e(n){let t=0;for(;n.isOpen&&n.bufferedAmount>He&&t<200;)await qt(16),t++}class De{constructor(t){this.surf=t}get ctx(){return this.surf.ctx}get area(){const t=this.surf.play;return{x:t.x,y:t.y,w:t.w,h:t.h}}toPx(t,e){return this.surf.toPx(t,e)}sx(t){return this.surf.sx(t)}sy(t){return this.surf.sy(t)}drawSkeleton(t,e){nt(this.surf,t,e??{})}}const V=1e3/60,je=1500;class Be{room;surf;renderer;overlayEl;canvasEl;game;screens;options;transport;skipReadiness;poseController;body;debugMode;webcamController=null;bridgeController=null;nativeBridge=null;nativeStartRequested=!1;readiness;diagnostics=null;recorder;recordOn;recPhase="idle";screen;mirrorStableSince=-1;bridgeIdleSince=-1;paused=!1;lastResult=null;gameInited=!1;acc=0;last=performance.now();rafId=0;stopped=!1;lastError=null;lastErrorStack="";readyEmitted=!1;listeners={screen:new Set,result:new Set,recording:new Set};constructor(t){this.game=t.game,this.screens=t.screens,this.options=t.options,this.canvasEl=t.mount.canvas,this.overlayEl=t.mount.overlay,this.debugMode=!!t.options.debug,this.recordOn=!!t.options.record,this.transport=t.options.transport??"socket",this.skipReadiness=!!t.options.skipReadiness,this.surf=new xe(this.canvasEl),this.renderer=new De(this.surf),this.poseController=new Zt,this.transport==="bridge"?(this.room=null,this.readiness=null,this.recorder=null,this.recordOn=!1,this.bridgeController=new te,this.nativeBridge=new se(this.bridgeController,{onStart:()=>this.onNativeStart(),onStop:()=>this.onNativeStop(),onCalibrated:()=>{},onTracking:e=>this.onNativeTracking(e)}),this.body=this.debugMode?new P(this.canvasEl):this.bridgeController,this.screen="bridge-idle"):t.options.camera?(this.room=null,this.readiness=null,this.recorder=null,this.recordOn=!1,this.webcamController=new Ce,this.body=this.debugMode?new P(this.canvasEl):this.webcamController,this.screen="pairing"):(this.room=new be(t.options.partyHost,t.options.forcedRoom),this.room.on("pose",e=>this.poseController.ingest(e)),this.body=this.debugMode?new P(this.canvasEl):this.poseController,this.readiness=new Ie(this.room,this.debugMode),this.recorder=new wt({room:this.room,canvas:this.canvasEl,onPhase:(e,s)=>{this.recPhase=e,this.emit("recording",e,s)}}),this.debugMode&&(this.diagnostics=new xt(this.overlayEl,this.room,!1)),this.screen="pairing"),window.addEventListener("keydown",this.onKeyDown)}onNativeStart(){this.screen==="bridge-idle"||this.screen==="results"?(this.nativeStartRequested=!1,this.setScreen("game")):this.nativeStartRequested=!0}onNativeStop(){this.screen==="game"&&(this.lastResult=this.game.result(),this.lastResult&&this.emit("result",this.lastResult)),this.nativeStartRequested=!1,this.setScreen("bridge-idle")}onNativeTracking(t){}get code(){return this.room?.code??""}get controller(){return this.body}get recording(){return this.recordOn}get recordingPhase(){return this.recPhase}get recorderNeedsMp4Warning(){return this.recorder?.needsMp4Warning??!1}get lastClipUrl(){return this.recorder?.downloadUrl??null}setRecordEnabled(t){this.recordOn=t}on(t,e){return this.listeners[t].add(e),()=>this.listeners[t].delete(e)}emit(t,...e){for(const s of this.listeners[t])s(...e)}start(){this.stopped=!1,this.last=performance.now(),this.rafId=requestAnimationFrame(this.frame)}dispose(){this.stopped=!0,this.rafId&&cancelAnimationFrame(this.rafId),window.removeEventListener("keydown",this.onKeyDown),this.body instanceof P&&this.body.dispose(),this.poseController.dispose(),this.webcamController?.dispose(),this.bridgeController?.dispose(),this.nativeBridge?.dispose(),this.diagnostics?.dispose(),this.recorder?.dispose(),this.game.dispose?.(),this.surf.dispose(),this.room?.close()}onKeyDown=t=>{t.key.toLowerCase()==="d"&&!t.repeat&&this.toggleDebug()};toggleDebug(){this.debugMode=!this.debugMode,this.body instanceof P&&this.body.dispose();const t=this.transport==="bridge"&&this.bridgeController?this.bridgeController:this.webcamController??this.poseController;this.body=this.debugMode?new P(this.canvasEl):t,this.diagnostics?.dispose(),this.diagnostics=this.debugMode&&this.room?new xt(this.overlayEl,this.room,!1):null}setScreen(t){const e=this.screen==="results";if(this.screen=t,this.screens.clear(),t==="bridge-idle"&&(this.bridgeIdleSince=-1),t==="game"){const s=this.gameInited;this.gameInited?(this.game.reset(),this.game.init({width:this.surf.play.w,height:this.surf.play.h})):(this.game.init({width:this.surf.play.w,height:this.surf.play.h}),this.gameInited=!0),this.room?.start(),this.paused=!1,this.transport==="bridge"?((s||e)&&this.nativeBridge?.emit({event:"restart"}),this.nativeBridge?.emit({event:"gameStart"})):this.recordOn&&wt.supported&&this.recorder?.start()}this.emit("screen",t)}frame=t=>{if(!this.stopped){try{const e=Math.min(100,t-this.last);this.last=t,this.body.tick(t),this.acc+=e;let s=0;for(;this.acc>=V&&s<5;)this.step(V,t),this.acc-=V,s++;this.render(t),this.diagnostics?.update(this.body)}catch(e){this.lastError=e instanceof Error?e.message:String(e),this.lastErrorStack=e instanceof Error?e.stack??"":"",console.error("[motion] frame error:",e)}if(this.debugMode||this.lastError)try{this.drawDiag()}catch{}this.rafId=requestAnimationFrame(this.frame)}};drawDiag(){const t=this.surf.ctx,e=this.surf.dpr;t.setTransform(e,0,0,e,0,0);const s=this.body,o=Number.isFinite(s.ageMs)?Math.round(s.ageMs):-1,i=`scr:${this.screen} h:${s.health} age:${o}ms q:${s.trackingQuality.toFixed(2)} L:${s.leftHandOpen.toFixed(2)} R:${s.rightHandOpen.toFixed(2)}`;t.save(),t.font="12px monospace",t.textAlign="left",t.textBaseline="bottom";const r=window.innerHeight-6,a=t.measureText(i).width+12;if(t.fillStyle="rgba(0,0,0,0.65)",t.fillRect(4,r-16,a,20),t.fillStyle="#37ff8b",t.fillText(i,10,r),t.restore(),this.lastError){t.save(),t.fillStyle="rgba(150,0,0,0.92)",t.fillRect(0,0,window.innerWidth,96),t.fillStyle="#fff",t.textAlign="left",t.textBaseline="top",t.font="bold 13px monospace",t.fillText("MOTION FRAME ERROR",10,8),t.font="12px monospace",t.fillText(this.lastError.slice(0,90),10,30);const h=(this.lastErrorStack.split(` `)[1]??"").trim();t.fillText(h.slice(0,90),10,50),t.restore()}}step(t,e){switch(this.screen){case"bridge-idle":{if(this.bridgeIdleSince<0&&(this.bridgeIdleSince=e),this.nativeStartRequested){this.nativeStartRequested=!1,this.setScreen("game");break}if(this.transport==="bridge"&&this.skipReadiness){const s=this.body.health!=="no_signal",o=e-this.bridgeIdleSince>je;(s||o)&&this.setScreen("game")}break}case"pairing":this.skipReadiness?this.body.health!=="no_signal"&&this.setScreen("game"):this.body.hasRequiredJoints&&this.body.health==="ok"&&(this.mirrorStableSince=-1,this.setScreen("mirror"));break;case"mirror":{this.body.health==="ok"&&this.body.hasRequiredJoints?(this.mirrorStableSince<0&&(this.mirrorStableSince=e),e-this.mirrorStableSince>2e3&&this.setScreen("readiness")):this.mirrorStableSince=-1;break}case"readiness":{if(!this.readiness)break;const s=this.readiness.evaluate(this.body,e);s.ready&&this.setScreen("game"),!s.peer&&!this.debugMode&&this.setScreen("pairing");break}case"game":{const s=this.body.health==="stale"||this.body.health==="no_signal";this.paused=s,this.paused||this.game.update(t,this.body),this.game.isOver()&&(this.lastResult=this.game.result(),this.finishGame());break}}}async finishGame(){this.transport==="bridge"?(this.lastResult&&this.nativeBridge?.emit({event:"score",value:this.lastResult.score}),this.nativeBridge?.emit({event:"gameOver",result:this.lastResult??null})):this.recordOn&&this.recorder?.phase==="recording"&&this.recorder.stop(),this.lastResult&&this.emit("result",this.lastResult),this.setScreen("results")}render(t){switch(this.surf.begin(),this.readyEmitted||(this.readyEmitted=!0,this.nativeBridge?.emit({event:"ready"})),this.screen){case"bridge-idle":this.overlayEl.childElementCount>0&&this.screens.clear();break;case"pairing":this.room&&this.screens.pairing({code:this.room.code,conn:this.room.connectionState,host:this.options.partyHost,debug:this.debugMode,recordOn:this.recordOn,onToggleRecord:e=>this.setRecordEnabled(e)});break;case"mirror":nt(this.surf,this.body),this.screens.mirror();break;case"readiness":{if(!this.readiness)break;nt(this.surf,this.body,{alpha:.4});const e=this.readiness.evaluate(this.body,t);this.screens.readiness(e,this.debugMode);break}case"game":this.game.render(this.renderer),this.paused?this.screens.trackingLost():this.room!==null&&this.room.connectionState!=="open"&&!this.debugMode?this.screens.reconnecting():this.overlayEl.childElementCount>0&&this.screens.clear();break;case"results":this.lastResult&&this.overlayEl.childElementCount===0&&this.screens.results(this.lastResult,()=>this.setScreen("game"));break}}}function qe(n){const t=new Be(n);return t.start(),t}const H=75e3,We=.075,Ue=.11,Ge={high:.32,mid:.5,duck:.72};class St{id="reach-dodge";name="Reach & Dodge";durationMs=H;elapsed=0;nextId=1;targets=[];obstacles=[];spawnTargetIn=.8;spawnObstacleIn=2.2;score=0;combo=0;bestCombo=0;hits=0;dodges=0;clipFlash=0;hitFlash=0;lastBody=null;init(t){}reset(){this.elapsed=0,this.nextId=1,this.targets=[],this.obstacles=[],this.spawnTargetIn=.8,this.spawnObstacleIn=2.2,this.score=0,this.combo=0,this.bestCombo=0,this.hits=0,this.dodges=0,this.clipFlash=0,this.hitFlash=0,this.lastBody=null}get remainingMs(){return Math.max(0,H-this.elapsed*1e3)}isOver(){return this.elapsed*1e3>=H}get difficulty(){return Math.min(1,this.elapsed*1e3/H)}result(){return{score:this.score,stats:[{label:"hits",value:String(this.hits)},{label:"dodges",value:String(this.dodges)},{label:"best combo",value:`x${this.bestCombo}`}]}}update(t,e){if(this.lastBody=e,this.isOver())return;const s=t/1e3;this.elapsed+=s,this.clipFlash=Math.max(0,this.clipFlash-s),this.hitFlash=Math.max(0,this.hitFlash-s);const o=this.difficulty;this.spawnTargetIn-=s,this.spawnTargetIn<=0&&(this.spawnTarget(),this.spawnTargetIn=Z(1.5,.7,o)+Math.random()*.5),this.spawnObstacleIn-=s,this.spawnObstacleIn<=0&&(this.spawnObstacle(o),this.spawnObstacleIn=Z(2.6,1.2,o)+Math.random()*.6),this.updateTargets(s,e),this.updateObstacles(s,e)}spawnTarget(){const t=Math.random()<.5?"left":"right",e=t==="left"?.16:.84,s=.28+Math.random()*.4;this.targets.push({id:this.nextId++,side:t,x:e,y:s,bornAt:this.elapsed,ttl:2.6,hit:!1,hitAnim:0})}spawnObstacle(t){const e=["high","mid","duck"],s=e[Math.floor(Math.random()*e.length)];this.obstacles.push({id:this.nextId++,lane:s,x:1.15,w:.14,speed:Z(.28,.5,t),scored:!1,clipped:!1})}updateTargets(t,e){const s=e.joints.leftHand,o=e.joints.rightHand;for(const i of this.targets){if(i.hit){i.hitAnim=Math.min(1,i.hitAnim+t*4);continue}i.ttl-=t;const r=i.side==="left"?s:o;Ke(r[0],r[1],i.x,i.y)!i.hit&&i.ttl>0||i.hit&&i.hitAnim<1)}updateObstacles(t,e){const s=e.joints.torso[0],o=e.squatAmount;for(const i of this.obstacles)i.x-=i.speed*t,!i.scored&&i.x<=.5&&(i.scored=!0,this.isDodged(i.lane,s,o)?(this.dodges++,this.combo++,this.bestCombo=Math.max(this.bestCombo,this.combo),this.score+=75+this.combo*5):(i.clipped=!0,this.combo=0,this.clipFlash=.4));this.obstacles=this.obstacles.filter(i=>i.x>-.25)}isDodged(t,e,s){return t==="duck"?s>.45:Math.abs(e-.5)>.16}render(t){const{ctx:e}=t,s=t.area,[o]=t.toPx(.5,0);e.save(),e.strokeStyle="rgba(255,255,255,0.08)",e.lineWidth=2,e.beginPath(),e.moveTo(o,s.y),e.lineTo(o,s.y+s.h),e.stroke(),e.restore(),this.renderObstacles(t),this.renderTargets(t),this.lastBody&&t.drawSkeleton(this.lastBody,{color:this.clipFlash>0?"#ff4d6d":"#f4f7ff",handColor:this.hitFlash>0?"#ffffff":"#35e0c8"}),this.renderHud(t),this.clipFlash>0&&(e.save(),e.fillStyle=`rgba(255,77,109,${.25*(this.clipFlash/.4)})`,e.fillRect(s.x,s.y,s.w,s.h),e.restore())}renderTargets(t){const{ctx:e}=t;for(const s of this.targets){const[o,i]=t.toPx(s.x,s.y),r=t.sx(We)*(1+s.hitAnim*.8),a=s.hit?1-s.hitAnim:Math.min(1,s.ttl/.6);e.save(),e.globalAlpha=a,e.fillStyle=s.side==="left"?"#5b8cff":"#ffb020",e.beginPath(),e.arc(o,i,r,0,Math.PI*2),e.fill(),e.strokeStyle="rgba(255,255,255,0.9)",e.lineWidth=4,e.stroke(),e.globalAlpha=a,e.fillStyle="#05070d",e.font=`bold ${Math.round(t.sx(.045))}px system-ui`,e.textAlign="center",e.textBaseline="middle",e.fillText(s.side==="left"?"L":"R",o,i),e.restore()}}renderObstacles(t){const{ctx:e}=t;for(const s of this.obstacles){const o=Ge[s.lane],[i,r]=t.toPx(s.x,o),a=t.sx(s.w),h=t.sy(s.lane==="duck"?.1:.14);e.save(),e.fillStyle=s.clipped?"#7a1f2c":s.lane==="duck"?"#b25cff":"#ff5a72",tt(e,i-a/2,r-h/2,a,h,10),e.fill(),e.fillStyle="rgba(255,255,255,0.9)",e.font=`bold ${Math.round(t.sx(.04))}px system-ui`,e.textAlign="center",e.textBaseline="middle",e.fillText(s.lane==="duck"?"DUCK":"LEAN",i,r),e.restore()}}renderHud(t){const{ctx:e}=t,s=t.area;e.save(),e.textBaseline="top",e.fillStyle="#f4f7ff",e.font=`bold ${Math.round(t.sx(.05))}px system-ui`,e.textAlign="left",e.fillText(String(this.score).padStart(5,"0"),s.x+t.sx(.03),s.y+t.sy(.03)),this.combo>1&&(e.fillStyle="#35e0c8",e.font=`bold ${Math.round(t.sx(.035))}px system-ui`,e.fillText(`x${this.combo}`,s.x+t.sx(.03),s.y+t.sy(.1)));const o=this.remainingMs/H,i=t.sx(.3),r=s.x+s.w-i-t.sx(.03),a=s.y+t.sy(.04);e.fillStyle="rgba(255,255,255,0.15)",tt(e,r,a,i,t.sy(.02),6),e.fill(),e.fillStyle=o>.25?"#35e0c8":"#ffcc33",tt(e,r,a,i*o,t.sy(.02),6),e.fill(),e.restore()}}function Z(n,t,e){return n+(t-n)*e}function Ke(n,t,e,s){return Math.hypot(n-e,t-s)}function tt(n,t,e,s,o,i){const r=Math.min(i,s/2,o/2);n.beginPath(),n.moveTo(t+r,e),n.arcTo(t+s,e,t+s,e+o,r),n.arcTo(t+s,e+o,t,e+o,r),n.arcTo(t,e+o,t,e,r),n.arcTo(t,e,t+s,e,r),n.closePath()}const Mt=.4,Ye=.6,ze=.6,Qe=.15,x=.055,_t=.16,kt=.13,R=.3,Xe=1,Je=x+.03,Ve=150,Tt=4,Ze=.018,ts=.006,Et=.4,j=.6,Pt=["#5b8cff","#35e0c8","#ffb020","#ff6b9d","#b25cff"];function B(){return{x:.5,y:.5,vx:0,vy:0,prevOpen:1,holding:null,armed:!1}}class es{id="motion-maker";name="Motion Maker";durationMs=Number.POSITIVE_INFINITY;nextId=1;objects=[];hands={left:B(),right:B()};score=0;dropped=0;sliced=0;binFlash=0;slashFlash={left:0,right:0};bin={x:.5,y:.82};lastBody=null;init(t){this.objects.length===0&&this.spawnInitial()}reset(){this.nextId=1,this.objects=[],this.hands={left:B(),right:B()},this.score=0,this.dropped=0,this.sliced=0,this.binFlash=0,this.slashFlash={left:0,right:0},this.lastBody=null,this.spawnInitial()}spawnInitial(){for(let t=0;t0){const h=(r-i.x)/t,l=(a-i.y)/t;i.vx+=(h-i.vx)*.5,i.vy+=(l-i.vy)*.5}i.x=r,i.y=a}}resolveGrabs(t,e){const s=this.hands[t];if(e>ze&&(s.armed=!0),s.holding!==null&&e>Ye&&(this.releaseHeld(s),s.armed=!1),s.holding===null&&s.armed&&es.id===t.holding);e&&(e.heldBy=null,e.vx=t.vx,e.vy=t.vy),t.holding=null}nearestGrabbable(t,e){let s=null,o=Qe;for(const i of this.objects){if(i.heldBy!==null||i.scoreAnim>0)continue;const r=Math.hypot(i.x-t,i.y-e);r0){s.scoreAnim=Math.min(1,s.scoreAnim+t*3);continue}if(s.heldBy!==null){const o=this.hands[s.heldBy];s.x=o.x,s.y=o.y,s.vx=o.vx,s.vy=o.vy,s.kind==="sword"&&this.updateSwordBlade(s,s.heldBy);continue}if(s.vy+=Ze*t,s.vx+=(Math.random()-.5)*ts*t,s.vx-=s.vx*Et*t,s.vy-=s.vy*Et*t,s.x+=s.vx*t,s.y+=s.vy*t,s.x1-x&&(s.x=1-x,s.vx=-Math.abs(s.vx)*j),s.y1-x&&(s.y=1-x,s.vy=-Math.abs(s.vy)*j),s.kind==="sword"){s.tipX=s.x+R*.4,s.tipY=s.y-R;continue}Math.abs(s.x-this.bin.x)<_t&&Math.abs(s.y-this.bin.y)s.scoreAnim<1);const e=this.objects.filter(s=>s.kind==="ball").length;for(let s=e;s.15&&(i=s.vx,r=s.vy);const a=Math.hypot(i,r)||1;t.tipX=s.x+i/a*R,t.tipY=s.y+r/a*R}sliceWithSwords(){for(const t of this.objects){if(t.kind!=="sword"||t.heldBy===null)continue;const e=this.hands[t.heldBy];if(!(Math.hypot(e.vx,e.vy)0)continue;ss(s.x,s.y,e.x,e.y,t.tipX,t.tipY)0?this.binFlash/.5:0;e.strokeStyle=a>0?`rgba(53,224,200,${.6+a*.4})`:"rgba(53,224,200,0.45)",e.lineWidth=Math.max(3,t.sx(.008)),e.setLineDash([t.sx(.02),t.sx(.014)]),is(e,s-i/2,o-r/2,i,r,t.sx(.02)),e.stroke(),e.setLineDash([]),a>0&&(e.fillStyle=`rgba(53,224,200,${.12*a})`,e.fill()),e.fillStyle="rgba(53,224,200,0.7)",e.font=`bold ${Math.round(t.sx(.028))}px system-ui`,e.textAlign="center",e.textBaseline="middle",e.fillText("DROP HERE",s,o),e.restore()}renderObjects(t){const{ctx:e}=t;for(const s of this.objects){if(s.kind==="sword")continue;const[o,i]=t.toPx(s.x,s.y),r=s.scoreAnim>0?1+s.scoreAnim*.8:1,a=t.sx(x)*r,h=s.scoreAnim>0?1-s.scoreAnim:1;e.save(),e.globalAlpha=h,s.heldBy!==null&&(e.strokeStyle="rgba(255,255,255,0.9)",e.lineWidth=Math.max(3,t.sx(.008)),e.beginPath(),e.arc(o,i,a+t.sx(.014),0,Math.PI*2),e.stroke()),e.fillStyle=s.color,e.beginPath(),e.arc(o,i,a,0,Math.PI*2),e.fill(),e.fillStyle="rgba(255,255,255,0.35)",e.beginPath(),e.arc(o-a*.3,i-a*.3,a*.35,0,Math.PI*2),e.fill(),e.restore()}}renderSwords(t){const{ctx:e}=t;for(const s of this.objects){if(s.kind!=="sword"||s.scoreAnim>0)continue;const[o,i]=this.pt(t,[s.x,s.y]),[r,a]=this.pt(t,[s.tipX,s.tipY]),h=Math.hypot(r-o,a-i)||1,l=(r-o)/h,d=(a-i)/h,c=-d,f=l,u=s.heldBy!==null,y=u?this.slashFlash[s.heldBy]:0,m=t.sx(.05),p=t.sx(.035),g=Math.max(5,t.sx(.014));e.save(),e.lineCap="round",e.strokeStyle="#6b4a2b",e.lineWidth=Math.max(6,t.sx(.018)),e.beginPath(),e.moveTo(o,i),e.lineTo(o-l*m,i-d*m),e.stroke(),e.fillStyle="#d9a441",e.beginPath(),e.arc(o-l*m,i-d*m,Math.max(4,t.sx(.012)),0,Math.PI*2),e.fill(),e.strokeStyle="#d9a441",e.lineWidth=Math.max(5,t.sx(.014)),e.beginPath(),e.moveTo(o-c*p,i-f*p),e.lineTo(o+c*p,i+f*p),e.stroke(),y>0&&(e.strokeStyle=`rgba(120,200,255,${.55*y})`,e.lineWidth=g*3,e.beginPath(),e.moveTo(o,i),e.lineTo(r,a),e.stroke()),e.strokeStyle="#b9c4de",e.lineWidth=g,e.beginPath(),e.moveTo(o,i),e.lineTo(r,a),e.stroke(),e.strokeStyle=u?"#ffffff":"#eef2ff",e.lineWidth=Math.max(2,g*.4),e.beginPath(),e.moveTo(o,i),e.lineTo(r,a),e.stroke(),e.restore()}}renderAvatar(t){const e=this.lastBody;if(!e)return;const{ctx:s}=t,o=e.joints,[i,r]=this.pt(t,o.head),[a,h]=this.pt(t,o.torso),[l,d]=this.pt(t,o.leftHand),[c,f]=this.pt(t,o.rightHand),u=i+(a-i)*.42,y=r+(h-r)*.42;s.save(),s.lineCap="round",s.lineJoin="round",s.strokeStyle="#f4f7ff",s.lineWidth=Math.max(6,t.sx(.02)),s.beginPath(),s.moveTo(i,r),s.lineTo(a,h),s.stroke();const m=[{elbow:o.leftElbow?this.pt(t,o.leftElbow):void 0,hand:[l,d]},{elbow:o.rightElbow?this.pt(t,o.rightElbow):void 0,hand:[c,f]}];for(const{elbow:p,hand:g}of m)s.beginPath(),s.moveTo(u,y),p&&s.lineTo(p[0],p[1]),s.lineTo(g[0],g[1]),s.stroke();s.fillStyle="#f4f7ff",s.beginPath(),s.arc(i,r,Math.max(10,t.sx(.032)),0,Math.PI*2),s.fill(),s.restore(),this.drawHand(t,l,d,e.leftHandOpen,this.hands.left.holding!==null),this.drawHand(t,c,f,e.rightHandOpen,this.hands.right.holding!==null)}pt(t,e){const s=e[0]<0?0:e[0]>1?1:e[0],o=e[1]<0?0:e[1]>1?1:e[1];return t.toPx(s,o)}drawHand(t,e,s,o,i){const{ctx:r}=t,a=Math.max(10,t.sx(.03));r.save();const h=o1?1:l,Math.hypot(n-(e+l*r),t-(s+l*a))}function is(n,t,e,s,o,i){const r=Math.min(i,s/2,o/2);n.beginPath(),n.moveTo(t+r,e),n.arcTo(t+s,e,t+s,e+o,r),n.arcTo(t+s,e+o,t,e+o,r),n.arcTo(t,e+o,t,e,r),n.arcTo(t,e,t+s,e,r),n.closePath()}const et=3,Ct=.8,os=.09,Rt=.45,st=.9,ns=10,At=["#ff5b6a","#ffb020","#35e0c8","#5b8cff","#b25cff","#ff8f3f"];function q(n){const t=Math.hypot(n,-1)||1;return{x:.5,y:.5,vx:0,vy:0,aimx:n/t,aimy:-1/t,active:!1,trail:[]}}function rs(n,t){return(t==="left"?n.leftHandActive:n.rightHandActive)!==!1}class It{id="slice";name="Slice";durationMs=Number.POSITIVE_INFINITY;nextId=1;fruits=[];halves=[];splats=[];popups=[];hands={left:q(-.5),right:q(.5)};score=0;lives=et;sliced=0;bestCombo=0;elapsed=0;spawnTimer=0;comboCount=0;lastSliceAt=-999;hitFlash=0;flashColor="255,90,106";shake=0;init(t){this.reset()}reset(){this.nextId=1,this.fruits=[],this.halves=[],this.splats=[],this.popups=[],this.hands={left:q(-.5),right:q(.5)},this.score=0,this.lives=et,this.sliced=0,this.bestCombo=0,this.elapsed=0,this.spawnTimer=.6,this.comboCount=0,this.lastSliceAt=-999,this.hitFlash=0,this.shake=0}isOver(){return this.lives<=0}result(){return{score:this.score,stats:[{label:"sliced",value:String(this.sliced)},{label:"best combo",value:`${this.bestCombo}×`}]}}update(t,e){const s=Math.min(.05,t/1e3);this.elapsed+=s,this.hitFlash=Math.max(0,this.hitFlash-s*2),this.shake=Math.max(0,this.shake-s*3),this.trackHands(s,e),this.spawn(s),this.integrate(s),this.slice(),this.expireCombo()}trackHands(t,e){const s={left:e.joints.leftHand,right:e.joints.rightHand};for(const o of["left","right"]){const i=this.hands[o],[r,a]=s[o];if(!rs(e,o)){i.active=!1,i.vx=0,i.vy=0,i.trail.length=0;continue}if(!i.active){i.active=!0,i.x=r,i.y=a,i.vx=0,i.vy=0,i.trail=[{x:r,y:a}];continue}t>0&&(i.vx=(r-i.x)/t,i.vy=(a-i.y)/t),i.x=r,i.y=a;const h=Math.hypot(i.vx,i.vy);if(h>.25){const l=i.vx/h,d=i.vy/h;i.aimx+=(l-i.aimx)*.35,i.aimy+=(d-i.aimy)*.35;const c=Math.hypot(i.aimx,i.aimy)||1;i.aimx/=c,i.aimy/=c}i.trail.push({x:r,y:a}),i.trail.length>ns&&i.trail.shift()}}spawn(t){if(this.spawnTimer-=t,this.spawnTimer>0)return;const e=Math.min(1,this.elapsed/90);this.spawnTimer=1.15-.65*e+Math.random()*.25;const s=1+Math.floor(Math.random()*(1+Math.round(e*2)));for(let o=0;o1.12&&e.vy>0&&(e.counted=!0,e.bomb||this.loseLife("120,140,180"));this.fruits=this.fruits.filter(e=>e.y-e.r<=1.15);for(const e of this.halves)e.vy+=st*t,e.x+=e.vx*t,e.y+=e.vy*t,e.ang+=e.va*t,e.life-=t*.8;this.halves=this.halves.filter(e=>e.life>0);for(const e of this.splats)e.vy+=st*.6*t,e.x+=e.vx*t,e.y+=e.vy*t,e.life-=t*1.6;this.splats=this.splats.filter(e=>e.life>0);for(const e of this.popups)e.y-=t*.06,e.life-=t*1.2;this.popups=this.popups.filter(e=>e.life>0)}slice(){for(const t of["left","right"]){const e=this.hands[t];if(!e.active||Math.hypot(e.vx,e.vy)a.id!==t.id),t.bomb){this.loseLife("255,70,70"),this.shake=1,this.spawnSplat(t.x,t.y,"255,120,60",22),this.comboCount=0;return}this.sliced++;const o=this.elapsed;this.comboCount=o-this.lastSliceAt<=Rt?this.comboCount+1:1,this.lastSliceAt=o,this.bestCombo=Math.max(this.bestCombo,this.comboCount);const r=10*this.comboCount;this.score+=r,this.comboCount>=2&&this.popups.push({x:t.x,y:t.y,text:`${this.comboCount}× +${r}`,color:"#ffd84d",life:1}),this.spawnHalves(t,e,s),this.spawnSplat(t.x,t.y,hs(t.color),12)}spawnHalves(t,e,s){const o=Math.hypot(e,s)||1,i=-s/o,r=e/o,a=.25;for(const h of[-1,1])this.halves.push({x:t.x,y:t.y,vx:t.vx+i*a*h,vy:t.vy*.5+r*a*h,r:t.r,color:t.color,ang:Math.atan2(s,e),va:h*(2+Math.random()*2),side:h,life:1})}spawnSplat(t,e,s,o){for(let i=0;iRt&&(this.comboCount=0)}render(t){const{ctx:e}=t;if(e.save(),this.shake>0){const s=this.shake*t.sx(.012);e.translate((Math.random()-.5)*s,(Math.random()-.5)*s)}this.renderSplats(t),this.renderFruits(t),this.renderHalves(t),this.renderBlades(t),this.renderPopups(t),e.restore(),this.renderHud(t),this.renderFlash(t)}renderFruits(t){const{ctx:e}=t;for(const s of this.fruits){const[o,i]=t.toPx(s.x,s.y),r=t.sx(s.r);s.bomb?(e.fillStyle="#181b22",e.strokeStyle="#ff5b5b",e.lineWidth=Math.max(2,t.sx(.006)),e.beginPath(),e.arc(o,i,r,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#ffcc33",e.beginPath(),e.arc(o+r*.5,i-r*.8,r*.22,0,Math.PI*2),e.fill(),e.fillStyle="#ff5b5b",e.font=`bold ${Math.round(r*1.1)}px system-ui`,e.textAlign="center",e.textBaseline="middle",e.fillText("!",o,i)):(e.fillStyle=s.color,e.beginPath(),e.arc(o,i,r,0,Math.PI*2),e.fill(),e.fillStyle="rgba(255,255,255,0.4)",e.beginPath(),e.arc(o-r*.32,i-r*.32,r*.3,0,Math.PI*2),e.fill())}}renderHalves(t){const{ctx:e}=t;for(const s of this.halves){const[o,i]=t.toPx(s.x,s.y),r=t.sx(s.r);e.save(),e.globalAlpha=Math.max(0,s.life),e.translate(o,i),e.rotate(s.ang),e.fillStyle=s.color,e.beginPath(),e.arc(0,0,r,s.side>0?0:Math.PI,s.side>0?Math.PI:Math.PI*2),e.closePath(),e.fill(),e.fillStyle="rgba(255,255,255,0.5)",e.fillRect(-r,-r*.12,r*2,r*.24),e.restore()}}renderSplats(t){const{ctx:e}=t;for(const s of this.splats){const[o,i]=t.toPx(s.x,s.y);e.globalAlpha=Math.max(0,s.life),e.fillStyle=`rgb(${s.color})`,e.beginPath(),e.arc(o,i,t.sx(s.r),0,Math.PI*2),e.fill()}e.globalAlpha=1}renderBlades(t){const{ctx:e}=t;for(const s of["left","right"]){const o=this.hands[s];if(!o.active)continue;const i=o.trail;if(i.length>=2)for(let w=1;wCt,a=r?.19:.15,[h,l]=t.toPx(o.x-o.aimx*.045,o.y-o.aimy*.045),[d,c]=t.toPx(o.x,o.y),[f,u]=t.toPx(o.x+o.aimx*a,o.y+o.aimy*a),y=Math.hypot(f-d,u-c)||1,m=-(u-c)/y,p=(f-d)/y;e.save(),e.lineCap="round",e.strokeStyle="#6b4a2b",e.lineWidth=Math.max(5,t.sx(.016)),e.beginPath(),e.moveTo(h,l),e.lineTo(d,c),e.stroke();const g=t.sx(.028);e.strokeStyle="#d9a441",e.lineWidth=Math.max(4,t.sx(.012)),e.beginPath(),e.moveTo(d-m*g,c-p*g),e.lineTo(d+m*g,c+p*g),e.stroke();const v=Math.max(4,t.sx(.013));r&&(e.strokeStyle="rgba(150,215,255,0.55)",e.lineWidth=v*3,e.beginPath(),e.moveTo(d,c),e.lineTo(f,u),e.stroke()),e.strokeStyle="#c3ccdf",e.lineWidth=v,e.beginPath(),e.moveTo(d,c),e.lineTo(f,u),e.stroke(),e.strokeStyle=r?"#ffffff":"#eef3ff",e.lineWidth=Math.max(2,v*.4),e.beginPath(),e.moveTo(d,c),e.lineTo(f,u),e.stroke(),e.restore()}}renderPopups(t){const{ctx:e}=t;e.textAlign="center",e.textBaseline="middle";for(const s of this.popups){const[o,i]=t.toPx(s.x,s.y);e.globalAlpha=Math.max(0,s.life),e.fillStyle=s.color,e.font=`bold ${Math.round(t.sx(.04))}px system-ui`,e.fillText(s.text,o,i)}e.globalAlpha=1}renderHud(t){const{ctx:e}=t,s=t.area;e.save(),e.textBaseline="top",e.fillStyle="#f4f7ff",e.font=`bold ${Math.round(t.sx(.05))}px system-ui`,e.textAlign="left",e.fillText(String(this.score).padStart(4,"0"),s.x+t.sx(.03),s.y+t.sy(.03)),e.textAlign="right",e.font=`${Math.round(t.sx(.04))}px system-ui`;let o="";for(let i=0;i=2&&(e.textAlign="left",e.fillStyle="#ffd84d",e.font=`bold ${Math.round(t.sx(.03))}px system-ui`,e.fillText(`${this.comboCount}× combo`,s.x+t.sx(.03),s.y+t.sy(.1))),e.restore()}renderFlash(t){if(this.hitFlash<=0)return;const{ctx:e}=t,s=t.area;e.save(),e.fillStyle=`rgba(${this.flashColor},${this.hitFlash*.35})`,e.fillRect(s.x,s.y,s.w,s.h),e.restore()}}function as(n,t,e,s,o,i){const r=o-e,a=i-s,h=r*r+a*a;if(h===0)return Math.hypot(n-e,t-s);let l=((n-e)*r+(t-s)*a)/h;return l=l<0?0:l>1?1:l,Math.hypot(n-(e+l*r),t-(s+l*a))}function hs(n){const t=/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(n);return t?`${parseInt(t[1],16)},${parseInt(t[2],16)},${parseInt(t[3],16)}`:"255,255,255"}class ls{constructor(t){this.root=t}clear(){this.root.innerHTML=""}panel(t){this.root.innerHTML=`
${t}
`}pairing(t){const{code:e,conn:s,host:o,debug:i,recordOn:r,onToggleRecord:a}=t,h=s==="open"?"#35e0c8":s==="connecting"?"#ffcc33":"#ff4d6d",l=s==="open"?"connected to relay":s==="connecting"?"connecting…":"reconnecting…";this.panel(`
Motion
@@ -123,7 +123,7 @@
`),this.root.querySelector("#play-again")?.addEventListener("click",e)}}function cs(n){return n.split("").join(" ")}function it(n){return n.replace(/[&<>"]/g,t=>t==="&"?"&":t==="<"?"<":t===">"?">":""")}const ds="text-align:center;background:rgba(9,13,24,0.92);border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:min(6vw,56px) min(8vw,80px);box-shadow:0 20px 80px rgba(0,0,0,0.6);max-width:90vw",W="font-size:min(2.2vw,18px);letter-spacing:0.3em;text-transform:uppercase;color:#35e0c8;margin-bottom:14px",fs="font-size:min(16vw,150px);font-weight:800;letter-spacing:0.08em;color:#f4f7ff;font-variant-numeric:tabular-nums;text-shadow:0 0 40px rgba(53,224,200,0.25)",us="margin-top:22px;font-size:min(2vw,16px);color:#ffcc33;border:1px dashed rgba(255,204,51,0.5);padding:8px 14px;border-radius:8px;display:inline-block",ps="display:inline-flex;align-items:center;gap:10px;margin-top:22px;font-size:min(2.2vw,18px);color:#8a95b5;cursor:pointer;user-select:none",ms="font-size:min(3vw,26px);font-weight:700;color:#05070d;background:#35e0c8;border:none;border-radius:12px;padding:14px 40px;cursor:pointer",gs=document.getElementById("game-canvas"),rt=document.getElementById("overlay"),ys=new ls(rt),bs=new URLSearchParams(location.search).get("game");function xs(){switch(bs){case"motion-maker":return{game:new es,live:!0};case"reach-dodge":return{game:new St,live:!1};case"slice":return{game:new It,live:!0};default:return re||Nt==="bridge"?{game:new It,live:!0}:{game:new St,live:!1}}}const{game:vs,live:ws}=xs(),A=qe({game:vs,mount:{canvas:gs,overlay:rt},screens:ys,options:{partyHost:ie,...ot?{forcedRoom:ot}:{},debug:oe,record:ne,transport:Nt,camera:dt,skipReadiness:ws||dt}});A.on("recording",n=>{n==="done"&&A.lastClipUrl&&Ss(A.lastClipUrl,A.recorderNeedsMp4Warning)});function Ss(n,t){document.getElementById("clip-download")?.remove();const s=document.createElement("a");s.id="clip-download",s.href=n,s.download=t?"motion-clip.webm":"motion-clip.mp4",s.textContent=t?"⬇ Download gameplay clip (WebM — MP4 needed for phone compositing)":"⬇ Download gameplay clip",Object.assign(s.style,{position:"absolute",bottom:"16px",left:"50%",transform:"translateX(-50%)",padding:"10px 16px",background:"rgba(53,224,200,0.14)",border:"1px solid rgba(53,224,200,0.5)",borderRadius:"10px",color:"#9ff5e6",font:"14px system-ui",textDecoration:"none",zIndex:"60",pointerEvents:"auto"}),rt.appendChild(s)}window.motion={session:A,get controller(){return A.controller}}; -//# sourceMappingURL=index-lbyU8noI.js.map +//# sourceMappingURL=index-D0C65tMG.js.map diff --git a/ios/Sources/Motion/Info.plist b/ios/Sources/Motion/Info.plist index dabb263..ed04901 100644 --- a/ios/Sources/Motion/Info.plist +++ b/ios/Sources/Motion/Info.plist @@ -15,9 +15,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/ios/Sources/Motion/Views/SetupView.swift b/ios/Sources/Motion/Views/SetupView.swift index 50040d3..6398288 100644 --- a/ios/Sources/Motion/Views/SetupView.swift +++ b/ios/Sources/Motion/Views/SetupView.swift @@ -169,6 +169,16 @@ struct SetupView: View { streamStatusPill } + Divider().overlay(.white.opacity(0.2)) + + HStack(spacing: 20) { + Link("Privacy", destination: URL(string: "https://motion.significanthobbies.com/privacy")!) + Link("Support", destination: URL(string: "https://motion.significanthobbies.com")!) + } + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + // Live hand-openness debug so the user can confirm open/close is detected. if let hands = model.hands { Text(String(format: "Hands L %.2f R %.2f", hands.left, hands.right)) diff --git a/ios/project.yml b/ios/project.yml index 583cc21..b1837a8 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -45,15 +45,20 @@ targets: # phase). See Vision/HandLandmarkerEstimator.swift for how it's loaded. - path: Resources/mediapipe/hand_landmarker.task buildPhase: resources + - path: Resources/PrivacyInfo.xcprivacy + buildPhase: resources info: # XcodeGen GENERATES this plist from the properties below on every `xcodegen # generate`, so the keys MUST live here (a hand-edited Info.plist would be # overwritten). Missing usage strings => hard TCC crash on first camera access. path: Sources/Motion/Info.plist properties: + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" NSCameraUsageDescription: "Motion uses the front camera on-device to turn your body movements into game controls. Camera video never leaves your phone." NSPhotoLibraryAddUsageDescription: "Motion saves your gameplay recordings (the game plus your camera) to Photos." NSLocalNetworkUsageDescription: "Motion connects to the game running on your computer over your local Wi-Fi." + ITSAppUsesNonExemptEncryption: false NSAppTransportSecurity: NSAllowsLocalNetworking: true UILaunchScreen: {} @@ -71,6 +76,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.motion.controller + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.games ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon GENERATE_INFOPLIST_FILE: NO TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad diff --git a/landing/privacy.html b/landing/privacy.html new file mode 100644 index 0000000..6622b0a --- /dev/null +++ b/landing/privacy.html @@ -0,0 +1,138 @@ + + + + + + Privacy — Motion + + + + + + + + + + + +
+
+
+

Privacy by architecture

+

The camera stays with you.

+
+
+

+ Motion turns camera frames into movement controls on your iPhone. + Camera frames are not transmitted to us or stored by us. +

+

Last updated 11 August 2026.

+
+
+ +
+
    +
  1. +
    +
    +

    On device

    +

    Camera and movement

    +

    + The app uses the camera to estimate body and hand positions on + device. In normal single-device play, those positions stay + inside the app and drive the bundled game directly. +

    +
    +
  2. +
  3. +
    +
    +

    Your choice

    +

    Recordings and Photos

    +

    + Motion records only when you start recording. The completed + gameplay clip is saved to your Photos library and temporary + working files are removed after use. Motion does not upload or + retain your recording. +

    +
    +
  4. +
  5. +
    +
    +

    Optional

    +

    Laptop and browser play

    +

    + If you explicitly enable “Stream to website,” Motion sends + normalized movement coordinates and a room code to the browser + session you choose. Camera frames are never included. The relay + forwards live session messages and does not persist movement or + gameplay content after the session. +

    +
    +
  6. +
  7. +
    +
    +

    No profiles

    +

    Accounts, ads, and tracking

    +

    + Motion has no account, advertising SDK, third-party analytics, + or cross-app tracking. We do not sell personal information. + Service providers may process ordinary network connection data + when you use an online relay, subject to their security and + privacy safeguards. +

    +
    +
  8. +
  9. +
    +
    +

    Control

    +

    Retention, deletion, and consent

    +

    + You can revoke Camera, Photos, and Local Network access in iOS + Settings. Delete saved clips in Photos. Because Motion does not + keep an account or server-side profile, there is no Motion + account data to request or delete. +

    +
    +
  10. +
+
+ +
+ +

Privacy should be legible.

+ + Contact support + + +
+
+ + + + diff --git a/landing/sitemap.xml b/landing/sitemap.xml index 27b4d8a..9cfc699 100644 --- a/landing/sitemap.xml +++ b/landing/sitemap.xml @@ -6,4 +6,7 @@ https://motion.significanthobbies.com/changelog + + https://motion.significanthobbies.com/privacy + diff --git a/scripts/check-landing.mjs b/scripts/check-landing.mjs index 30bd4b9..1fac9a6 100644 --- a/scripts/check-landing.mjs +++ b/scripts/check-landing.mjs @@ -10,6 +10,7 @@ const landing = path.join(root, "landing"); const required = [ "index.html", "changelog.html", + "privacy.html", "404.html", "styles.css", "llms.txt", @@ -27,6 +28,7 @@ await Promise.all(required.map((file) => stat(path.join(landing, file)))); const [ html, changelog, + privacy, css, llms, markdown, @@ -39,6 +41,7 @@ const [ await Promise.all([ readFile(path.join(landing, "index.html"), "utf8"), readFile(path.join(landing, "changelog.html"), "utf8"), + readFile(path.join(landing, "privacy.html"), "utf8"), readFile(path.join(landing, "styles.css"), "utf8"), readFile(path.join(landing, "llms.txt"), "utf8"), readFile(path.join(landing, "index.md"), "utf8"), @@ -67,8 +70,11 @@ assert.equal((changelog.match(/