create-playcanvas 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/index.mjs +63 -45
- package/package.json +5 -5
- package/templates/_shared/web-lens/AGENTS.md +4 -3
- package/templates/_shared/web-lens/README.md +6 -5
- package/templates/_shared/web-lens/package.json +1 -1
- package/templates/_shared/web-lens/scripts/package-lens.mjs +15 -2
- package/templates/_shared/web-lens/src/dodge-game.ts +12 -5
- package/templates/_shared/web-lens/src/lens.ts +19 -14
- package/templates/engine/base/package.json +2 -2
- package/templates/engine/web-lens/index.html +2 -2
- package/templates/engine/web-lens/src/main.ts +3 -2
- package/templates/index.ts +7 -2
- package/templates/react/base/package.json +3 -3
- package/templates/react/web-lens/src/App.tsx +4 -3
- package/templates/web-components/base/package.json +3 -3
- package/templates/web-components/web-lens/index.html +2 -2
- package/templates/web-components/web-lens/src/main.ts +3 -2
- /package/{skills → dist/skills}/add-effects/SKILL.md +0 -0
- /package/{skills → dist/skills}/apply-conventions/SKILL.md +0 -0
- /package/{skills → dist/skills}/assemble-scene/SKILL.md +0 -0
- /package/{skills → dist/skills}/assemble-scene/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/assemble-scene/references/react.md +0 -0
- /package/{skills → dist/skills}/assemble-scene/references/web-components.md +0 -0
- /package/{skills → dist/skills}/bake-lighting/SKILL.md +0 -0
- /package/{skills → dist/skills}/build-app/SKILL.md +0 -0
- /package/{skills → dist/skills}/build-app/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/build-app/references/react.md +0 -0
- /package/{skills → dist/skills}/build-app/references/web-components.md +0 -0
- /package/{skills → dist/skills}/calibrate-model/SKILL.md +0 -0
- /package/{skills → dist/skills}/calibrate-model/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/calibrate-model/references/react.md +0 -0
- /package/{skills → dist/skills}/calibrate-model/references/web-components.md +0 -0
- /package/{skills → dist/skills}/configure-animation/SKILL.md +0 -0
- /package/{skills → dist/skills}/configure-animation/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/configure-animation/references/react.md +0 -0
- /package/{skills → dist/skills}/configure-animation/references/web-components.md +0 -0
- /package/{skills → dist/skills}/find-examples/SKILL.md +0 -0
- /package/{skills → dist/skills}/find-examples/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/find-examples/references/react.md +0 -0
- /package/{skills → dist/skills}/find-examples/references/web-components.md +0 -0
- /package/{skills → dist/skills}/inspect-glb/SKILL.md +0 -0
- /package/{skills → dist/skills}/inspect-glb/scripts/inspect.mjs +0 -0
- /package/{skills → dist/skills}/inspect-runtime/SKILL.md +0 -0
- /package/{skills → dist/skills}/inspect-runtime/scripts/devtools-hook.js +0 -0
- /package/{skills → dist/skills}/light-scene/SKILL.md +0 -0
- /package/{skills → dist/skills}/load-assets/SKILL.md +0 -0
- /package/{skills → dist/skills}/override-shader-chunks/SKILL.md +0 -0
- /package/{skills → dist/skills}/reduce-draw-calls/SKILL.md +0 -0
- /package/{skills → dist/skills}/reuse-scripts/SKILL.md +0 -0
- /package/{skills → dist/skills}/reuse-scripts/references/direct-engine.md +0 -0
- /package/{skills → dist/skills}/reuse-scripts/references/react.md +0 -0
- /package/{skills → dist/skills}/reuse-scripts/references/web-components.md +0 -0
- /package/{skills → dist/skills}/verify-pixels/SKILL.md +0 -0
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Every format uses TypeScript and includes Vite, ESLint, Prettier and a productio
|
|
|
38
38
|
|
|
39
39
|
## Starters
|
|
40
40
|
|
|
41
|
-
Choose from starter kits across basics, viewers, games, tools and XR. Run the creator to browse them; `spinning-cube` is the default.
|
|
41
|
+
Choose from starter kits across basics, viewers, games, tools and XR. Run the creator to browse them, or pass `--help` to list them; `spinning-cube` is the default.
|
|
42
42
|
|
|
43
43
|

|
|
44
44
|
|
|
@@ -65,11 +65,14 @@ npm create playcanvas@latest my-game -- -f react -y
|
|
|
65
65
|
| `--format <name>` | `-f` | Use a format from the list above |
|
|
66
66
|
| `--starter <name>` | `-s` | Use a starter from the list above |
|
|
67
67
|
| `--overwrite` | | Remove existing files from a non-empty target directory |
|
|
68
|
+
| `--keep` | | Add the project to a non-empty target directory |
|
|
68
69
|
| `--no-skills` | | Omit the PlayCanvas agent skills (included by default) |
|
|
69
70
|
| `--yes` | `-y` | Skip the prompts and take the defaults |
|
|
70
|
-
| `--help` | `-h` | Show command help
|
|
71
|
+
| `--help` | `-h` | Show command help, with the formats and starters |
|
|
71
72
|
|
|
72
|
-
`--yes` takes `playcanvas-project`, the `engine` format and the `spinning-cube` starter for anything you don't pass. It never
|
|
73
|
+
`--yes` takes `playcanvas-project`, the `engine` format and the `spinning-cube` starter for anything you don't pass. It never touches files that are already there, so a non-empty target directory still needs `--keep` or `--overwrite`. A run without a terminal, such as one started by an AI agent or in CI, takes the same defaults, so it never waits on a prompt. The previous `--template` and `--boilerplate` long flags remain accepted for compatibility.
|
|
74
|
+
|
|
75
|
+
`--keep` adds the project to a directory that already holds files, such as a brief, assets or a README, and replaces none of them: where the project has a file of the same name, yours stays and the creator lists it. It stops at a directory with a `package.json`, which is a project already.
|
|
73
76
|
|
|
74
77
|
## Coding agents
|
|
75
78
|
|
|
@@ -102,6 +105,10 @@ To add a starter, list it in `STARTERS` in `templates/index.ts`, add `_shared/<s
|
|
|
102
105
|
|
|
103
106
|
Write commands in a README or `AGENTS.md` for npm, as `npm install`, `npm run <script>` and `npm run <script> -- <args>`. The scaffolder rewrites them for the package manager that created the project, and leaves out the `## Agent skills` section of both when run with `--no-skills`.
|
|
104
107
|
|
|
108
|
+
### Agent skills
|
|
109
|
+
|
|
110
|
+
The skills given to each project come from the `@playcanvas/skills` dev dependency, pinned to a release tag of [playcanvas/skills](https://github.com/playcanvas/skills). `npm run build` copies them into `dist/skills`, and Renovate proposes each new release. Change a skill in that repository, not here.
|
|
111
|
+
|
|
105
112
|
## License
|
|
106
113
|
|
|
107
114
|
[MIT](LICENSE)
|
package/dist/index.mjs
CHANGED
|
@@ -1,68 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{styleText as
|
|
3
|
-
`);let
|
|
4
|
-
`){
|
|
5
|
-
`&&(n&&
|
|
6
|
-
`)}
|
|
2
|
+
import{styleText as u}from"node:util";import L,{stdin as Ue,stdout as ye}from"node:process";import Ge from"node:readline";import"node:tty";import y from"node:fs";import b from"node:path";import Ke from"node:os";import{fileURLToPath as He}from"node:url";const ze=(()=>{const e=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g;return t=>{let r=0;for(e.lastIndex=0;e.test(t);)r+=1;return t.length-r}})(),Je=e=>e===12288||e>=65281&&e<=65376||e>=65504&&e<=65510,Ye=e=>e===8987||e===9001||e>=12272&&e<=12287||e>=12289&&e<=12350||e>=12441&&e<=12543||e>=12549&&e<=12591||e>=12593&&e<=12686||e>=12688&&e<=12771||e>=12783&&e<=12830||e>=12832&&e<=12871||e>=12880&&e<=19903||e>=65040&&e<=65049||e>=65072&&e<=65106||e>=65108&&e<=65126||e>=65128&&e<=65131||e>=127488&&e<=127490||e>=127504&&e<=127547||e>=127552&&e<=127560||e>=131072&&e<=196605||e>=196608&&e<=262141,qe=/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]|\u001b\]8;[^;]*;.*?(?:\u0007|\u001b\u005c)/y,Xe=/[\x00-\x08\x0A-\x1F\x7F-\x9F]{1,1000}/y,$e=/(?:(?![\uFF61-\uFF9F\uFF00-\uFFEF])[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Tangut}]){1,1000}/yu,Qe=/\t{1,1000}/y,we=new RegExp("[\\u{1F1E6}-\\u{1F1FF}]{2}|\\u{1F3F4}[\\u{E0061}-\\u{E007A}]{2}[\\u{E0030}-\\u{E0039}\\u{E0061}-\\u{E007A}]{1,3}\\u{E007F}|(?:\\p{Emoji}\\uFE0F\\u20E3?|\\p{Emoji_Modifier_Base}\\p{Emoji_Modifier}?|\\p{Emoji_Presentation})(?:\\u200D(?:\\p{Emoji_Modifier_Base}\\p{Emoji_Modifier}?|\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F\\u20E3?))*","yu"),Ze=/(?:[\x20-\x7E\xA0-\xFF](?!\uFE0F)){1,1000}/y,et=new RegExp("\\p{M}+","gu"),tt={limit:1/0,ellipsis:""},xe=(e,t={},r={})=>{const i=t.limit??1/0,n=t.ellipsis??"",o=t?.ellipsisWidth??(n?xe(n,tt,r).width:0),l=0,a=r.controlWidth??0,s=r.tabWidth??8,c=r.emojiWidth??2,d=2,h=r.regularWidth??1,m=r.wideWidth??d,f=[[Ze,h],[qe,l],[Xe,a],[Qe,s],[we,c],[$e,m]];let g=0,w=0,_=e.length,F=0,j=!1,M=_,O=Math.max(0,i-o),v=0,C=0,$=0,I=0;e:for(;;){if(C>v||w>=_&&w>g){const R=e.slice(v,C)||e.slice(g,w);F=0;for(const D of R.replaceAll(et,"")){const A=D.codePointAt(0)||0;if(Je(A)?I=d:Ye(A)?I=m:I=h,$+I>O&&(M=Math.min(M,Math.max(v,g)+F)),$+I>i){j=!0;break e}F+=D.length,$+=I}v=C=0}if(w>=_)break e;for(let R=0,D=f.length;R<D;R++){const[A,H]=f[R];if(A.lastIndex=w,A.test(e)){if(F=A===$e?ze(e.slice(w,A.lastIndex)):A===we?1:A.lastIndex-w,I=F*H,$+I>O&&(M=Math.min(M,w+Math.floor((O-$)/H))),$+I>i){j=!0;break e}$+=I,v=g,C=w,w=g=A.lastIndex;continue e}}w+=1}return{width:j?O:$,index:j?M:_,truncated:j,ellipsed:j&&i>=o}},rt={limit:1/0,ellipsis:"",ellipsisWidth:0},W=(e,t={})=>xe(e,rt,t).width,Z="\x1B",ve="\x9B",it=39,ce="\x07",Se="[",st="]",Ee="m",ue=`${st}8;;`,_e=new RegExp(`(?:\\${Se}(?<code>\\d+)m|\\${ue}(?<uri>.*)${ce})`,"y"),Ie=e=>{if(e>=30&&e<=37||e>=90&&e<=97)return 39;if(e>=40&&e<=47||e>=100&&e<=107)return 49;if(e===1||e===2)return 22;if(e===3)return 23;if(e===4)return 24;if(e===7)return 27;if(e===8)return 28;if(e===9)return 29;if(e===0)return 0},Fe=e=>`${Z}${Se}${e}${Ee}`,Ce=e=>`${Z}${ue}${e}${ce}`,he=(e,t,r)=>{const i=t[Symbol.iterator]();let n=!1,o=!1,l=e.at(-1),a=l===void 0?0:W(l),s=i.next(),c=i.next(),d=0;for(;!s.done;){const h=s.value,m=W(h);a+m<=r?e[e.length-1]+=h:(e.push(h),a=0),(h===Z||h===ve)&&(n=!0,o=t.startsWith(ue,d+1)),n?o?h===ce&&(n=!1,o=!1):h===Ee&&(n=!1):(a+=m,a===r&&!c.done&&(e.push(""),a=0)),s=c,c=i.next(),d+=h.length}l=e.at(-1),!a&&l!==void 0&&l.length&&e.length>1&&(e[e.length-2]+=e.pop())},nt=e=>{const t=e.split(" ");let r=t.length;for(;r&&!W(t[r-1]);)r--;return r===t.length?e:t.slice(0,r).join(" ")+t.slice(r).join("")},at=(e,t,r={})=>{if(r.trim!==!1&&e.trim()==="")return"";let i="",n,o;const l=e.split(" ");let a=[""],s=0;for(let h=0;h<l.length;h++){const m=l[h];if(r.trim!==!1){const g=a.at(-1)??"",w=g.trimStart();g.length!==w.length&&(a[a.length-1]=w,s=W(w))}h!==0&&(s>=t&&(r.wordWrap===!1||r.trim===!1)&&(a.push(""),s=0),(s||r.trim===!1)&&(a[a.length-1]+=" ",s++));const f=W(m);if(r.hard&&f>t){const g=t-s,w=1+Math.floor((f-g-1)/t);Math.floor((f-1)/t)<w&&a.push(""),he(a,m,t),s=W(a.at(-1)??"");continue}if(s+f>t&&s&&f){if(r.wordWrap===!1&&s<t){he(a,m,t),s=W(a.at(-1)??"");continue}a.push(""),s=0}if(s+f>t&&r.wordWrap===!1){he(a,m,t),s=W(a.at(-1)??"");continue}a[a.length-1]+=m,s+=f}r.trim!==!1&&(a=a.map(h=>nt(h)));const c=a.join(`
|
|
3
|
+
`);let d=!1;for(let h=0;h<c.length;h++){const m=c[h];if(i+=m,d)d=!1;else if(d=m>="\uD800"&&m<="\uDBFF",d)continue;if(m===Z||m===ve){_e.lastIndex=h+1;const g=_e.exec(c)?.groups;if(g?.code!==void 0){const w=Number.parseFloat(g.code);n=w===it?void 0:w}else g?.uri!==void 0&&(o=g.uri.length===0?void 0:g.uri)}if(c[h+1]===`
|
|
4
|
+
`){o&&(i+=Ce(""));const f=n?Ie(n):void 0;n&&f&&(i+=Fe(f))}else m===`
|
|
5
|
+
`&&(n&&Ie(n)&&(i+=Fe(n)),o&&(i+=Ce(o)))}return i},ot=/\r?\n/;function ee(e,t,r){return String(e).normalize().split(ot).map(i=>at(i,t,r)).join(`
|
|
6
|
+
`)}const me="\x1B",S=`${me}[`,V={to(e,t){return t?`${S}${t+1};${e+1}H`:`${S}${e+1}G`},move(e,t){let r="";return e<0?r+=`${S}${-e}D`:e>0&&(r+=`${S}${e}C`),t<0?r+=`${S}${-t}A`:t>0&&(r+=`${S}${t}B`),r},up:(e=1)=>`${S}${e}A`,down:(e=1)=>`${S}${e}B`,forward:(e=1)=>`${S}${e}C`,backward:(e=1)=>`${S}${e}D`,nextLine:(e=1)=>`${S}E`.repeat(e),prevLine:(e=1)=>`${S}F`.repeat(e),left:`${S}G`,hide:`${S}?25l`,show:`${S}?25h`,save:`${me}7`,restore:`${me}8`},de={screen:`${S}2J`,up:(e=1)=>`${S}1J`.repeat(e),down:(e=1)=>`${S}J`.repeat(e),line:`${S}2K`,lineEnd:`${S}K`,lineStart:`${S}1K`,lines(e){let t="";for(let r=0;r<e;r++)t+=this.line+(r<e-1?V.up():"");return e&&(t+=V.left),t}};function te(e,t,r){if(!r.some(l=>!l.disabled))return e;const i=e+t,n=Math.max(r.length-1,0),o=i<0?n:i>n?0:i;return r[o]?.disabled?te(o,t<0?-1:1,r):o}const lt=["up","down","left","right","space","enter","cancel"],U={actions:new Set(lt),aliases:new Map([["k","up"],["j","down"],["h","left"],["l","right"],["","cancel"],["escape","cancel"]]),withGuide:!0};function ct(e){if(e!==void 0)return e;const t=process.env.ACCESSIBLE;return t!==void 0&&t!==""&&t!=="0"&&t!=="false"}function ke(e,t){if(typeof e=="string")return U.aliases.get(e)===t;for(const r of e)if(r!==void 0&&ke(r,t))return!0;return!1}function ut(e,t){if(e===t)return;const r=e.split(`
|
|
7
7
|
`),i=t.split(`
|
|
8
|
-
`),n=Math.max(r.length,i.length),
|
|
9
|
-
`).map((
|
|
10
|
-
`)}function
|
|
11
|
-
`),
|
|
12
|
-
`).length-1;this.output.write(
|
|
13
|
-
`);this.output.write(
|
|
14
|
-
`).slice(
|
|
15
|
-
`)),this._prevFrame=t;return}}this.output.write(
|
|
16
|
-
`);
|
|
17
|
-
`);
|
|
8
|
+
`),n=Math.max(r.length,i.length),o=[];for(let l=0;l<n;l++)r[l]!==i[l]&&o.push(l);return{lines:o,numLinesBefore:r.length,numLinesAfter:i.length,numLines:n}}globalThis.process.platform.startsWith("win");const pe=Symbol("clack:cancel");function Y(e){return e===pe}function re(e,t){const r=e;r.isTTY&&r.setRawMode(t)}const ie=e=>"columns"in e&&typeof e.columns=="number"?e.columns:80,Te=e=>"rows"in e&&typeof e.rows=="number"?e.rows:20;function se(e,t,r,i=r,n=r,o){const l=ie(e??ye),a=W(r);return ee(t,l-a,{hard:!0,trim:!1}).split(`
|
|
9
|
+
`).map((s,c,d)=>{const h=s;return c===0?`${i}${h}`:c===d.length-1?`${n}${h}`:`${r}${h}`}).join(`
|
|
10
|
+
`)}function ht(e,t){if("~standard"in e){const r=e["~standard"].validate(t);return r instanceof Promise?r.then(i=>i.issues?.at(0)?.message):r.issues?.at(0)?.message}return e(t)}let Re=class{input;output;_abortSignal;rl;opts;_render;_track=!1;_prevFrame="";_subscribers=new Map;_cursor=0;state="initial";error="";value;userInput="";get accessible(){return ct(this.opts.accessible)}constructor(t,r=!0){const{input:i=Ue,output:n=ye,render:o,signal:l,...a}=t;this.opts=a,this.onKeypress=this.onKeypress.bind(this),this.close=this.close.bind(this),this.render=this.render.bind(this),this._render=o.bind(this),this._track=r,this._abortSignal=l,this.input=i,this.output=n}unsubscribe(){this._subscribers.clear()}setSubscriber(t,r){const i=this._subscribers.get(t)??[];i.push(r),this._subscribers.set(t,i)}on(t,r){this.setSubscriber(t,{cb:r})}once(t,r){this.setSubscriber(t,{cb:r,once:!0})}emit(t,...r){const i=this._subscribers.get(t)??[],n=[];for(const o of i)o.cb(...r),o.once&&n.push(()=>i.splice(i.indexOf(o),1));for(const o of n)o()}prompt(){return new Promise(t=>{if(this._abortSignal){if(this._abortSignal.aborted)return this.state="cancel",this.close(),t(pe);this._abortSignal.addEventListener("abort",()=>{this.state="cancel",this.close()},{once:!0})}this.rl=Ge.createInterface({input:this.input,tabSize:2,prompt:"",escapeCodeTimeout:50,terminal:!0}),this.rl.prompt(),this.opts.initialUserInput!==void 0&&this._setUserInput(this.opts.initialUserInput,!0),this.input.on("keypress",this.onKeypress),re(this.input,!0),this.output.on("resize",this.render),this.render(),this.once("submit",()=>{this.output.write(V.show),this.output.off("resize",this.render),re(this.input,!1),t(this.value)}),this.once("cancel",()=>{this.output.write(V.show),this.output.off("resize",this.render),re(this.input,!1),t(pe)})})}_isActionKey(t,r){return t===" "}_shouldSubmit(t,r){return!0}_setValue(t){this.value=t,this.emit("value",this.value)}_setUserInput(t,r){this.userInput=t??"",this.emit("userInput",this.userInput),r&&this._track&&this.rl&&(this.rl.write(this.userInput),this._cursor=this.rl.cursor)}_clearUserInput(){this.rl?.write(null,{ctrl:!0,name:"u"}),this._setUserInput("")}async onKeypress(t,r){if(this.state!=="validating"){if(this._track&&r.name!=="return"&&(r.name&&this._isActionKey(t,r)&&this.rl?.write(null,{ctrl:!0,name:"h"}),this._cursor=this.rl?.cursor??0,this._setUserInput(this.rl?.line)),this.state==="error"&&(this.state="active"),r?.name&&(!this._track&&U.aliases.has(r.name)&&this.emit("cursor",U.aliases.get(r.name)),U.actions.has(r.name)&&this.emit("cursor",r.name)),t&&(t.toLowerCase()==="y"||t.toLowerCase()==="n")&&this.emit("confirm",t.toLowerCase()==="y"),this.emit("key",t,r),r?.name==="return"&&this._shouldSubmit(t,r)){if(this.opts.validate){const i=ht(this.opts.validate,this.value);let n;i instanceof Promise?(this.state="validating",this.render(),n=await i):n=i,n&&(this.error=n instanceof Error?n.message:n,this.state="error",this.rl?.write(this.userInput))}this.state!=="error"&&(this.state="submit")}ke([t,r?.name,r?.sequence],"cancel")&&(this.state="cancel"),(this.state==="submit"||this.state==="cancel")&&this.emit("finalize"),this.render(),(this.state==="submit"||this.state==="cancel")&&this.close()}}close(){this.input.unpipe(),this.input.removeListener("keypress",this.onKeypress),this.output.write(`
|
|
11
|
+
`),re(this.input,!1),this.rl?.close(),this.rl=void 0,this.emit(`${this.state}`,this.value),this.unsubscribe()}restoreCursor(){const t=ee(this._prevFrame,ie(this.output),{hard:!0,trim:!1}).split(`
|
|
12
|
+
`).length-1;this.output.write(V.move(-999,t*-1))}render(){const t=ee(this._render(this)??"",ie(this.output),{hard:!0,trim:!1});if(t!==this._prevFrame){if(this.state==="initial")this.output.write(V.hide);else{const r=ut(this._prevFrame,t),i=Te(this.output);if(this.restoreCursor(),r){const n=Math.max(0,r.numLinesAfter-i),o=Math.max(0,r.numLinesBefore-i);let l=r.lines.find(a=>a>=n);if(l===void 0){this._prevFrame=t;return}if(r.lines.length===1){this.output.write(V.move(0,l-o)),this.output.write(de.lines(1));const a=t.split(`
|
|
13
|
+
`);this.output.write(a[l]),this._prevFrame=t,this.output.write(V.move(0,a.length-l-1));return}else if(r.lines.length>1){if(n<o)l=n;else{const s=l-o;s>0&&this.output.write(V.move(0,s))}this.output.write(de.down());const a=t.split(`
|
|
14
|
+
`).slice(l);this.output.write(a.join(`
|
|
15
|
+
`)),this._prevFrame=t;return}}this.output.write(de.down())}this.output.write(t),this.state==="initial"&&(this.state="active"),this._prevFrame=t}}},mt=class extends Re{options;cursor=0;get _selectedValue(){return this.options[this.cursor]}changeValue(){const t=this._selectedValue;this.value=t===void 0?void 0:t.value}constructor(t){super(t,!1),this.options=t.options;const r=this.options.findIndex(({value:n})=>n===t.initialValue),i=r===-1?0:r;this.cursor=this.options[i]?.disabled?te(i,1,this.options):i,this.changeValue(),this.on("cursor",n=>{switch(n){case"left":case"up":this.cursor=te(this.cursor,-1,this.options);break;case"down":case"right":this.cursor=te(this.cursor,1,this.options);break}this.changeValue()})}};class dt extends Re{get userInputWithCursor(){if(this.state==="submit")return this.userInput;const t=this.userInput;if(this.cursor>=t.length)return`${this.userInput}\u2588`;const r=t.slice(0,this.cursor),i=t.slice(this.cursor,this.cursor+1),n=t.slice(this.cursor+1);return`${r}${u("inverse",i)}${n}`}get cursor(){return this._cursor}constructor(t){super({...t,initialUserInput:t.initialUserInput??t.initialValue}),this.on("userInput",r=>{this._setValue(r)}),this.on("finalize",()=>{this.value||(this.value=t.defaultValue),this.value===void 0&&(this.value="")})}}function pt(){return L.platform!=="win32"?L.env.TERM!=="linux":!!L.env.CI||!!L.env.WT_SESSION||!!L.env.TERMINUS_SUBLIME||L.env.ConEmuTask==="{cmd::Cmder}"||L.env.TERM_PROGRAM==="Terminus-Sublime"||L.env.TERM_PROGRAM==="vscode"||L.env.TERM==="xterm-256color"||L.env.TERM==="alacritty"||L.env.TERMINAL_EMULATOR==="JetBrains-JediTerm"}const ft=pt(),P=(e,t)=>ft?e:t,je=P("\u25C6","*"),gt=P("\u25A0","x"),bt=P("\u25B2","x"),Ae=P("\u25C7","o"),E=P("\u2502","|"),G=P("\u2514","\u2014"),yt=P("\u25CF",">"),Oe=P("\u25CB"," "),$t=P("\u25CF","\u2022"),wt=P("\u25C6","*"),xt=P("\u25B2","!"),vt=P("\u25A0","x"),Pe=e=>{switch(e){case"initial":case"active":return u("cyan",je);case"cancel":return u("red",gt);case"error":return u("yellow",bt);case"submit":return u("green",Ae);case"validating":return u("dim",je)}},St=e=>{switch(e){case"initial":case"active":return u("cyan",E);case"cancel":return u("red",E);case"error":return u("yellow",E);case"submit":return u("green",E)}};function Et(e,t,r){const i=r?`${u("cyan",E)} `:"",n=se(e,t.join(" \u2022 "),i).split(`
|
|
16
|
+
`);return r&&n.push(u("cyan",G)),n}const Be=(e,t,r,i,n,o=!1)=>{let l=t,a=0;if(o)for(let s=i-1;s>=r;s--){const c=e[s];if(c&&(l-=c.length),a++,l<=n)break}else for(let s=r;s<i;s++){const c=e[s];if(c&&(l-=c.length),a++,l<=n)break}return{lineCount:l,removals:a}},_t=({cursor:e,options:t,style:r,output:i=process.stdout,maxItems:n=Number.POSITIVE_INFINITY,columnPadding:o=0,rowPadding:l=4})=>{const a=ie(i)-o,s=Te(i),c=u("dim","..."),d=Math.max(s-l,0),h=Math.max(Math.min(n,d),5);let m=0;e>=h-3&&(m=Math.max(Math.min(e-h+3,t.length-h),0));let f=h<t.length&&m>0,g=h<t.length&&m+h<t.length;const w=Math.min(m+h,t.length),_=[];let F=0;f&&F++,g&&F++;const j=m+(f?1:0),M=w-(g?1:0);for(let v=j;v<M;v++){const C=t[v],$=C!==void 0?r(C,v===e):"",I=ee($,a,{hard:!0,trim:!1}).split(`
|
|
17
|
+
`);_.push(I),F+=I.length}if(F>d){let v=0,C=0,$=F;const I=e-j;let R=d;const D=()=>Be(_,$,0,I,R),A=()=>Be(_,$,I+1,_.length,R,!0);f?({lineCount:$,removals:v}=D(),$>R&&(g||(R-=1),{lineCount:$,removals:C}=A())):(g||(R-=1),{lineCount:$,removals:C}=A(),$>R&&(R-=1,{lineCount:$,removals:v}=D())),v>0&&(f=!0,_.splice(0,v)),C>0&&(g=!0,_.splice(_.length-C,C))}const O=[];f&&O.push(c);for(const v of _)for(const C of v)O.push(C);return g&&O.push(c),O};`${u("dim","\u2191/\u2193")}`,`${u("dim","Space:")}`,`${u("dim","Enter:")}`;const N={message:(e=[],{symbol:t=u("gray",E),secondarySymbol:r=u("gray",E),output:i=process.stdout,spacing:n=1,withGuide:o}={})=>{const l=[],a=o??U.withGuide,s=a?r:"",c=a?`${t} `:"",d=a?`${r} `:"";for(let m=0;m<n;m++)l.push(s);const h=Array.isArray(e)?e:e.split(`
|
|
18
|
+
`);if(h.length>0){const[m,...f]=h;m.length>0?l.push(`${c}${m}`):l.push(a?t:"");for(const g of f)g.length>0?l.push(`${d}${g}`):l.push(a?r:"")}i.write(`${l.join(`
|
|
18
19
|
`)}
|
|
19
|
-
`)},info:(e,t)=>{
|
|
20
|
+
`)},info:(e,t)=>{N.message(e,{...t,symbol:u("blue",$t)})},success:(e,t)=>{N.message(e,{...t,symbol:u("green",wt)})},step:(e,t)=>{N.message(e,{...t,symbol:u("green",Ae)})},warn:(e,t)=>{N.message(e,{...t,symbol:u("yellow",xt)})},warning:(e,t)=>{N.warn(e,t)},error:(e,t)=>{N.message(e,{...t,symbol:u("red",vt)})}},It=(e="",t)=>{const r=process.stdout,i=`${u("gray",G)} `;r.write(`${i}${u("red",e)}
|
|
20
21
|
|
|
21
|
-
`)},
|
|
22
|
-
${
|
|
22
|
+
`)},Ft=(e="",t)=>{const r=process.stdout,i=`${u("gray",E)}
|
|
23
|
+
${u("gray",G)} `;r.write(`${i}${e}
|
|
23
24
|
|
|
24
|
-
`)}
|
|
25
|
+
`)},Ct=[`${u("dim","\u2191/\u2193")} to navigate`,`${u("dim","Enter:")} confirm`],ne=(e,t)=>e.includes(`
|
|
25
26
|
`)?e.split(`
|
|
26
27
|
`).map(r=>t(r)).join(`
|
|
27
|
-
`):t(e),
|
|
28
|
-
`:""}${
|
|
29
|
-
`;switch(this.state){case"submit":{const s=i?`${
|
|
30
|
-
${
|
|
31
|
-
`).length,
|
|
32
|
-
`),d
|
|
28
|
+
`):t(e),fe=e=>{const t=(i,n)=>{if(i===void 0)return"";const o=i.label??String(i.value);switch(n){case"disabled":return`${u("gray",Oe)} ${ne(o,l=>u("gray",l))}${i.hint?` ${u("dim",`(${i.hint??"disabled"})`)}`:""}`;case"selected":return`${ne(o,l=>u("dim",l))}`;case"active":return`${u("green",yt)} ${o}${i.hint?` ${u("dim",`(${i.hint})`)}`:""}`;case"cancelled":return`${ne(o,l=>u(["strikethrough","dim"],l))}`;default:return`${u("dim",Oe)} ${ne(o,l=>u("dim",l))}`}},r=e.showInstructions??!0;return new mt({options:e.options,signal:e.signal,input:e.input,output:e.output,initialValue:e.initialValue,render(){const i=e.withGuide??U.withGuide,n=`${Pe(this.state)} `,o=`${St(this.state)} `,l=se(e.output,e.message,o,n),a=`${i?`${u("gray",E)}
|
|
29
|
+
`:""}${l}
|
|
30
|
+
`;switch(this.state){case"submit":{const s=i?`${u("gray",E)} `:"",c=se(e.output,t(this.options[this.cursor],"selected"),s);return`${a}${c}`}case"cancel":{const s=i?`${u("gray",E)} `:"",c=se(e.output,t(this.options[this.cursor],"cancelled"),s);return`${a}${c}${i?`
|
|
31
|
+
${u("gray",E)}`:""}`}default:{const s=i?`${u("cyan",E)} `:"",c=a.split(`
|
|
32
|
+
`).length,d=r?Et(e.output,Ct,i):i?[u("cyan",G)]:[],h=d.join(`
|
|
33
|
+
`),m=d.length+1;return`${a}${s}${_t({output:e.output,cursor:this.cursor,options:this.options,maxItems:e.maxItems,columnPadding:s.length,rowPadding:c+m,style:(f,g)=>t(f,f.disabled?"disabled":g?"active":"inactive")}).join(`
|
|
33
34
|
${s}`)}
|
|
34
35
|
${h}
|
|
35
|
-
`}}}}).prompt()};`${
|
|
36
|
-
`:""}${
|
|
37
|
-
`,i=e.placeholder&&e.placeholder.length>0?
|
|
38
|
-
${
|
|
39
|
-
`}case"error":{const
|
|
40
|
-
${
|
|
41
|
-
${s}${
|
|
42
|
-
`}case"submit":{const
|
|
43
|
-
${
|
|
44
|
-
${
|
|
45
|
-
`}}}}).prompt();function
|
|
36
|
+
`}}}}).prompt()};`${u("gray",E)}`;const Le=e=>new dt({validate:e.validate,placeholder:e.placeholder,defaultValue:e.defaultValue,initialValue:e.initialValue,output:e.output,signal:e.signal,input:e.input,render(){const t=e?.withGuide??U.withGuide,r=`${`${t?`${u("gray",E)}
|
|
37
|
+
`:""}${Pe(this.state)} `}${e.message}
|
|
38
|
+
`,i=e.placeholder&&e.placeholder.length>0?u("inverse",e.placeholder[0])+u("dim",e.placeholder.slice(1)):u(["inverse","hidden"],"_"),n=this.userInput?this.userInputWithCursor:i,o=this.value??"";switch(this.state){case"validating":{const l=t?`${u("cyan",E)} `:"",a=t?u("cyan",G):"",s=u("dim",n),c=u("dim","Validating...");return`${r}${l}${s}
|
|
39
|
+
${a} ${c}
|
|
40
|
+
`}case"error":{const l=this.error?` ${u("yellow",this.error)}`:"",a=t?`${u("yellow",E)} `:"",s=t?u("yellow",G):"";return`${r.trim()}
|
|
41
|
+
${a}${n}
|
|
42
|
+
${s}${l}
|
|
43
|
+
`}case"submit":{const l=o?`${t?" ":""}${u("dim",o)}`:"",a=t?u("gray",E):"";return`${r}${a}${l}`}case"cancel":{const l=o?` ${u(["strikethrough","dim"],o)}`:"",a=t?u("gray",E):"";return`${r}${a}${l}${o.trim()?`
|
|
44
|
+
${a}`:""}`}default:{const l=t?`${u("cyan",E)} `:"",a=t?u("cyan",G):"";return`${r}${l}${n}
|
|
45
|
+
${a}
|
|
46
|
+
`}}}}).prompt();function ge(e){return e==null?[]:Array.isArray(e)?e:[e]}function kt(e,t,r,i){var n,o=e[t],l=~i.string.indexOf(t)?r==null||r===!0?"":String(r):typeof r=="boolean"?r:~i.boolean.indexOf(t)?r==="false"?!1:r==="true"||(e._.push((n=+r,n*0===0?n:r)),!!r):(n=+r,n*0===0?n:r);e[t]=o==null?l:Array.isArray(o)?o.concat(l):[o,l]}function Tt(e,t){e=e||[],t=t||{};var r,i,n,o,l,a={_:[]},s=0,c=0,d=0,h=e.length;const m=t.alias!==void 0,f=t.unknown!==void 0,g=t.default!==void 0;if(t.alias=t.alias||{},t.string=ge(t.string),t.boolean=ge(t.boolean),m)for(r in t.alias)for(i=t.alias[r]=ge(t.alias[r]),s=0;s<i.length;s++)(t.alias[i[s]]=i.concat(r)).splice(s,1);for(s=t.boolean.length;s-- >0;)for(i=t.alias[t.boolean[s]]||[],c=i.length;c-- >0;)t.boolean.push(i[c]);for(s=t.string.length;s-- >0;)for(i=t.alias[t.string[s]]||[],c=i.length;c-- >0;)t.string.push(i[c]);if(g){for(r in t.default)if(o=typeof t.default[r],i=t.alias[r]=t.alias[r]||[],t[o]!==void 0)for(t[o].push(r),s=0;s<i.length;s++)t[o].push(i[s])}const w=f?Object.keys(t.alias):[];for(s=0;s<h;s++){if(n=e[s],n==="--"){a._=a._.concat(e.slice(++s));break}for(c=0;c<n.length&&n.charCodeAt(c)===45;c++);if(c===0)a._.push(n);else if(n.substring(c,c+3)==="no-"){if(o=n.substring(c+3),f&&!~w.indexOf(o))return t.unknown(n);a[o]=!1}else{for(d=c+1;d<n.length&&n.charCodeAt(d)!==61;d++);for(o=n.substring(c,d),l=n.substring(++d)||s+1===h||(""+e[s+1]).charCodeAt(0)===45||e[++s],i=c===2?[o]:o,d=0;d<i.length;d++){if(o=i[d],f&&!~w.indexOf(o))return t.unknown("-".repeat(c)+o);kt(a,o,d+1<i.length||l,t)}}}if(g)for(r in t.default)a[r]===void 0&&(a[r]=t.default[r]);if(m)for(r in a)for(i=t.alias[r]||[];i.length>0;)a[i.shift()]=a[r];return a}function Rt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ae={exports:{}},Me;function jt(){if(Me)return ae.exports;Me=1;let e=process||{},t=e.argv||[],r=e.env||{},i=!(r.NO_COLOR||t.includes("--no-color"))&&(!!r.FORCE_COLOR||t.includes("--color")||e.platform==="win32"||(e.stdout||{}).isTTY&&r.TERM!=="dumb"||!!r.CI),n=(a,s,c=a)=>d=>{let h=""+d,m=h.indexOf(s,a.length);return~m?a+o(h,s,c,m)+s:a+h+s},o=(a,s,c,d)=>{let h="",m=0;do h+=a.substring(m,d)+c,m=d+s.length,d=a.indexOf(s,m);while(~d);return h+a.substring(m)},l=(a=i)=>{let s=a?n:()=>String;return{isColorSupported:a,reset:s("\x1B[0m","\x1B[0m"),bold:s("\x1B[1m","\x1B[22m","\x1B[22m\x1B[1m"),dim:s("\x1B[2m","\x1B[22m","\x1B[22m\x1B[2m"),italic:s("\x1B[3m","\x1B[23m"),underline:s("\x1B[4m","\x1B[24m"),inverse:s("\x1B[7m","\x1B[27m"),hidden:s("\x1B[8m","\x1B[28m"),strikethrough:s("\x1B[9m","\x1B[29m"),black:s("\x1B[30m","\x1B[39m"),red:s("\x1B[31m","\x1B[39m"),green:s("\x1B[32m","\x1B[39m"),yellow:s("\x1B[33m","\x1B[39m"),blue:s("\x1B[34m","\x1B[39m"),magenta:s("\x1B[35m","\x1B[39m"),cyan:s("\x1B[36m","\x1B[39m"),white:s("\x1B[37m","\x1B[39m"),gray:s("\x1B[90m","\x1B[39m"),bgBlack:s("\x1B[40m","\x1B[49m"),bgRed:s("\x1B[41m","\x1B[49m"),bgGreen:s("\x1B[42m","\x1B[49m"),bgYellow:s("\x1B[43m","\x1B[49m"),bgBlue:s("\x1B[44m","\x1B[49m"),bgMagenta:s("\x1B[45m","\x1B[49m"),bgCyan:s("\x1B[46m","\x1B[49m"),bgWhite:s("\x1B[47m","\x1B[49m"),blackBright:s("\x1B[90m","\x1B[39m"),redBright:s("\x1B[91m","\x1B[39m"),greenBright:s("\x1B[92m","\x1B[39m"),yellowBright:s("\x1B[93m","\x1B[39m"),blueBright:s("\x1B[94m","\x1B[39m"),magentaBright:s("\x1B[95m","\x1B[39m"),cyanBright:s("\x1B[96m","\x1B[39m"),whiteBright:s("\x1B[97m","\x1B[39m"),bgBlackBright:s("\x1B[100m","\x1B[49m"),bgRedBright:s("\x1B[101m","\x1B[49m"),bgGreenBright:s("\x1B[102m","\x1B[49m"),bgYellowBright:s("\x1B[103m","\x1B[49m"),bgBlueBright:s("\x1B[104m","\x1B[49m"),bgMagentaBright:s("\x1B[105m","\x1B[49m"),bgCyanBright:s("\x1B[106m","\x1B[49m"),bgWhiteBright:s("\x1B[107m","\x1B[49m")}};return ae.exports=l(),ae.exports.createColors=l,ae.exports}var At=jt();const Ot=Rt(At),{cyan:Pt,magenta:Bt,yellow:Lt}=Ot,q=[{name:"engine",display:"Engine",description:"with the PlayCanvas Engine API directly",color:Lt},{name:"react",display:"React",description:"with @playcanvas/react",color:Pt},{name:"web-components",display:"Web Components",description:"with @playcanvas/web-components",color:Bt}],Mt={physics:{engine:["assets"],react:["react"],"web-components":["assets"]}},K=[{name:"spinning-cube",display:"Spinning Cube",category:"Basics",description:"a rotating cube and a light"},{name:"interactive-sphere",display:"Interactive Sphere",category:"Basics",description:"orbit controls, a grid and pointer picking"},{name:"model-viewer",display:"Model Viewer",category:"Viewers",description:"a glTF model, orbit controls and a procedural sky"},{name:"splat-viewer",display:"Splat Viewer",category:"Viewers",description:"a Gaussian splat and orbit controls"},{name:"product-configurator",display:"Product Configurator",category:"Viewers",description:"product variants, materials and orbit controls"},{name:"physics-playground",display:"Physics Playground",category:"Games",description:"rigid bodies, collisions, spawning and reset",features:["physics"]},{name:"first-person-controller",display:"First-Person Controller",category:"Games",description:"first-person movement, mouse look and jumping",features:["physics"]},{name:"third-person-controller",display:"Third-Person Controller",category:"Games",description:"an animated character, follow camera and locomotion",features:["physics"]},{name:"sprite-game",display:"Sprite Game",category:"Games",description:"an orthographic camera, sprite animation and input"},{name:"web-lens",display:"Snapchat Web Lens",category:"Games",description:"a portrait game with Snapchat leaderboards, packaged for upload",omit:["src/starter.css","src/assets"],outro:`To publish it to Snapchat, run:
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
`),m!=="npm"&&(C=C.replace(/\bnpm run ([^\s`]+) -- /g,`${d} $1 `).replace(/\bnpm run\b/g,d).replace(/\bnpm install\b/g,h)),w.writeFileSync(g,C)}for(const[p,_]of Object.entries(o)){const g=$.join(s,p);w.existsSync(g)&&w.renameSync(g,$.join(s,_))}const v=L.map(p=>$.join(p,"package.json")).filter(p=>w.existsSync(p)).map(p=>JSON.parse(w.readFileSync(p,"utf-8"))),S=Object.assign({},...v);for(const p of Pt){const _=Object.assign({},...v.map(C=>C[p])),g=Object.entries(_).sort(([C],[U])=>C>U?1:-1);g.length?S[p]=Object.fromEntries(g):delete S[p]}if(S.name=l,w.writeFileSync($.join(s,"package.json"),JSON.stringify(S,null,4)+`
|
|
49
|
-
`),a){const p=[$.resolve(f,"../skills"),$.resolve(f,"../../skills")].find(g=>w.existsSync(g));if(!p)throw new Error("Skills directory not found");const _=w.readdirSync(p,{withFileTypes:!0}).filter(g=>g.isDirectory()).map(g=>g.name);for(const g of[".claude/skills",".agents/skills"])for(const C of _)w.cpSync($.join(p,C),$.join(s,g,C),{recursive:!0});B.step("Added PlayCanvas agent skills (opt out with --no-skills)")}let x=`Project created. Now run:
|
|
50
|
-
`;const E=$.isAbsolute(r)?s:$.relative(t,s);s!==t&&(x+=`
|
|
51
|
-
cd ${E.includes(" ")?`"${E}"`:E}`),x+=`
|
|
52
|
-
${h}`,x+=`
|
|
53
|
-
${d} dev`,yt(x)},O=wt(process.argv.slice(2),{alias:{b:"boilerplate",f:"format",h:"help",s:"starter",t:"template",y:"yes"},boolean:["help","overwrite","yes"],string:["format","starter","template","boilerplate"]}),Wt=process.cwd(),Vt=`Usage: npm create playcanvas@latest [OPTION]...
|
|
48
|
+
npm run package
|
|
54
49
|
|
|
55
|
-
|
|
50
|
+
and upload the zip it writes to dist/ at https://my-lenses.snapchat.com/`},{name:"scene-editor",display:"Scene Editor",category:"Tools",description:"selection and translate, rotate and scale gizmos"},{name:"vr-starter",display:"VR Starter",category:"XR",description:"immersive VR, controllers and locomotion"},{name:"ar-placement",display:"AR Placement",category:"XR",description:"mobile AR, hit testing and object placement"}],Nt=async({argFormat:e,yes:t,cancel:r})=>{if(e&&q.some(n=>n.name===e))return e;if(e&&t)throw new Error(`Unknown format: ${e}. Choose from: ${q.map(n=>n.name).join(", ")}`);if(t)return q[0].name;const i=await fe({message:e?`"${e}" isn't a valid format. Please choose from below: `:"Select a format:",options:q.map(n=>({label:n.color(n.display),value:n.name,hint:n.description}))});return Y(i)&&r(),i},Dt=async({argStarter:e,yes:t,cancel:r})=>{if(e&&K.some(n=>n.name===e))return e;if(t){if(!e)return K[0].name;throw new Error(`Unknown starter: ${e}. Choose from: ${K.map(n=>n.name).join(", ")}`)}const i=await fe({message:e?`"${e}" isn't a valid starter. Please choose from below: `:"Select a starter:",initialValue:K[0].name,options:K.map(n=>({label:n.display,value:n.name,hint:`${n.category} \xB7 ${n.description}`}))});return Y(i)&&r(),i},Ne=e=>/^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(e),be=e=>e.trim().toLowerCase().replace(/\s+/g,"-").replace(/^[._]/,"").replace(/[^a-z\d\-~]+/g,"-"),Wt=async({targetDir:e,yes:t,cancel:r})=>{let i=b.basename(b.resolve(e));if(!Ne(i)){if(t)return be(i);const n=await Le({message:"Package name:",defaultValue:be(i),placeholder:be(i),validate:o=>{if(!o||!Ne(o))return"Invalid package.json name"}});if(Y(n))return r();i=n}return i},oe=e=>e.trim().replace(/\/+$/g,""),Vt=e=>{const t=y.readdirSync(e);return t.length===0||t.length===1&&t[0]===".git"},Ut=e=>{if(y.existsSync(e))for(const t of y.readdirSync(e))t!==".git"&&y.rmSync(b.resolve(e,t),{recursive:!0,force:!0})},Gt=async({argTargetDir:e,defaultTargetDir:t,yes:r,cancel:i})=>{let n=e?oe(String(e)):void 0;if(!n&&r)return t;if(!n){const o=await Le({message:"Project name:",defaultValue:t,placeholder:t,validate:l=>{if(l&&!oe(l))return"Invalid project name"}});Y(o)&&i(),n=oe(o)}return n},Kt=async({targetDir:e,argOverwrite:t,argKeep:r,yes:i,cancel:n})=>{if(t&&r)throw new Error("Pass --keep or --overwrite, not both.");if(!y.existsSync(e)||Vt(e))return!1;let o="no";if(t)o="yes";else if(r)o="keep";else{if(i)throw new Error(`Target directory "${e}" is not empty. Pass --keep to add the project to its files, or --overwrite to remove them.`);o=await fe({message:(e==="."?"Current directory":`Target directory "${e}"`)+" is not empty. Please choose how to proceed:",options:[{label:"Cancel operation",value:"no"},{label:"Remove existing files and continue",value:"yes"},{label:"Keep existing files and continue",value:"keep"}]})}if(Y(o))return n();switch(o){case"yes":return Ut(e),!1;case"keep":if(y.existsSync(b.join(e,"package.json")))throw new Error(`Target directory "${e}" already has a package.json, so it can't keep its files. Pass --overwrite to remove them.`);return!0;default:return n()}},Ht=["scripts","dependencies","devDependencies","peerDependencies","engines"],De={"README.md":"## Prerequisites","AGENTS.md":"## Checking your work"},zt="## Agent skills",Jt=["npm","pnpm","yarn","bun"],Yt=e=>e==="yarn"?{install:"yarn",run:"yarn"}:{install:`${e} install`,run:`${e} run`},qt=e=>{const{cwd:t,targetDir:r,format:i,starter:n,packageName:o,skills:l,keep:a,renameFiles:s}=e,c=b.resolve(t,r);y.mkdirSync(c,{recursive:!0});const d=process.env.npm_config_user_agent?.split("/")[0]??"npm",h=Jt.includes(d)?d:"npm",{install:m,run:f}=Yt(h),g=p=>h==="npm"?p:p.replace(/\bnpm run ([^\s`]+) -- /g,`${f} $1 `).replace(/\bnpm run\b/g,f).replace(/\bnpm install\b/g,m);N.step(`Creating project in ${c}...`);const w=He(import.meta.url),_=b.dirname(w),F=e.templatesDir??[b.resolve(_,"../templates"),b.resolve(_,"../../templates")].find(p=>y.existsSync(b.join(p,i)));if(!F)throw new Error(`Format directory not found for: ${i}`);const j=K.find(p=>p.name===n);if(!j)throw new Error(`Starter not found: ${n}`);const M=b.join(F,"_common"),O=b.join(F,i,"base"),v=b.join(F,"_shared",n),C=b.join(F,i,n);if(!y.existsSync(C))throw new Error(`Starter directory not found: ${i}/${n}`);const $=a?y.mkdtempSync(b.join(Ke.tmpdir(),"create-playcanvas-")):c,I=[M,O,v,...(j.features??[]).flatMap(p=>Mt[p][i].map(k=>b.join(F,"_features",p,k))),C].filter(p=>y.existsSync(p)),R=j.omit??[],D=R.flatMap(p=>[M,O].map(k=>b.join(k,p))),A=Object.keys(De).map(p=>b.join(v,p));for(const p of I){const k=b.join(p,"package.json");y.cpSync(p,$,{recursive:!0,filter:x=>x!==k&&!A.includes(x)&&!D.includes(x)})}for(const[p,k]of Object.entries(De)){const x=b.join($,p);if(!y.existsSync(x))continue;let T=y.readFileSync(x,"utf8");const Q=b.join(v,p);if(y.existsSync(Q)){const J=`${y.readFileSync(Q,"utf8").trimEnd()}
|
|
51
|
+
|
|
52
|
+
${k}`;T=T.replace(k,()=>J)}l||(T=`${T.split(/^(?=## )/m).filter(J=>!J.startsWith(zt)).join("").trimEnd()}
|
|
53
|
+
`),T=T.split(`
|
|
54
|
+
`).filter(J=>!R.some(Ve=>J.startsWith(`- \`${Ve}\``))).join(`
|
|
55
|
+
`),y.writeFileSync(x,g(T))}for(const[p,k]of Object.entries(s)){const x=b.join($,p);y.existsSync(x)&&y.renameSync(x,b.join($,k))}const H=I.map(p=>b.join(p,"package.json")).filter(p=>y.existsSync(p)).map(p=>JSON.parse(y.readFileSync(p,"utf-8"))),X=Object.assign({},...H);for(const p of Ht){const k=Object.assign({},...H.map(T=>T[p])),x=Object.entries(k).sort(([T],[Q])=>T>Q?1:-1);x.length?X[p]=Object.fromEntries(x):delete X[p]}if(X.name=o,y.writeFileSync(b.join($,"package.json"),JSON.stringify(X,null,4)+`
|
|
56
|
+
`),l){const p=[b.resolve(_,"skills"),b.resolve(_,"../../node_modules/@playcanvas/skills/skills")].find(x=>y.existsSync(x));if(!p)throw new Error("Skills directory not found");const k=y.readdirSync(p,{withFileTypes:!0}).filter(x=>x.isDirectory()).map(x=>x.name);for(const x of[".claude/skills",".agents/skills"])for(const T of k)y.cpSync(b.join(p,T),b.join($,x,T),{recursive:!0});N.step("Added PlayCanvas agent skills (opt out with --no-skills)")}if(a){const p=[];try{for(const k of y.readdirSync($,{recursive:!0}).sort()){const x=b.join($,k);if(!y.statSync(x).isFile())continue;const T=b.join(c,k);y.existsSync(T)?p.push(k.split(b.sep).join("/")):(y.mkdirSync(b.dirname(T),{recursive:!0}),y.copyFileSync(x,T))}}finally{y.rmSync($,{recursive:!0,force:!0})}p.length&&N.warn(`Kept these existing files rather than the project's: ${p.join(", ")}`)}let z=`Project created. Now run:
|
|
57
|
+
`;const le=b.isAbsolute(r)?c:b.relative(t,c);c!==t&&(z+=`
|
|
58
|
+
cd ${le.includes(" ")?`"${le}"`:le}`),z+=`
|
|
59
|
+
${m}`,z+=`
|
|
60
|
+
${f} dev`,j.outro&&(z+=`
|
|
61
|
+
|
|
62
|
+
${g(j.outro)}`),Ft(z)},B=Tt(process.argv.slice(2),{alias:{b:"boilerplate",f:"format",h:"help",s:"starter",t:"template",y:"yes"},boolean:["help","overwrite","keep","yes"],string:["format","starter","template","boilerplate"]}),Xt=process.cwd(),We=e=>e.map((t,r)=>` ${t.name.padEnd(27)}${t.description}${r?"":" (default)"}`).join(`
|
|
63
|
+
`),Qt=`Usage: npm create playcanvas@latest [DIRECTORY] -- [OPTION]...
|
|
64
|
+
|
|
65
|
+
Create a new PlayCanvas project with TypeScript in DIRECTORY.
|
|
56
66
|
With no arguments, start the CLI in interactive mode.
|
|
67
|
+
npm needs the -- before the options. pnpm, yarn and bun take them without it.
|
|
57
68
|
|
|
58
69
|
Options:
|
|
59
70
|
-f, --format NAME use a specific format
|
|
60
71
|
-s, --starter NAME use a specific starter
|
|
61
72
|
--overwrite remove existing files from a non-empty target directory
|
|
73
|
+
--keep add the project to a non-empty target directory, replacing no files
|
|
62
74
|
--no-skills omit the PlayCanvas agent skills
|
|
63
75
|
-y, --yes skip the prompts and take the defaults
|
|
64
76
|
-h, --help show help
|
|
65
77
|
|
|
78
|
+
Formats:
|
|
79
|
+
${We(q)}
|
|
80
|
+
|
|
81
|
+
Starters:
|
|
82
|
+
${We(K)}
|
|
83
|
+
|
|
66
84
|
Without a terminal, as when an AI agent or CI runs it, the CLI takes the
|
|
67
85
|
defaults for any option not passed, as --yes does.
|
|
68
|
-
`,
|
|
86
|
+
`,Zt={_gitignore:".gitignore",_npmrc:".npmrc"},er="playcanvas-project",tr=async()=>{const e=B._[0]?oe(String(B._[0])):void 0,t=B.format??B.template,r=B.starter??B.boilerplate,i=B.overwrite,n=B.keep,o=!!(process.stdin.isTTY&&process.stdout.isTTY),l=B.yes||!o;if(B.help){console.log(Qt);return}!o&&!B.yes&&N.info("Not running in a terminal, so using the defaults for any option not passed");const a=()=>{It("Operation cancelled"),process.exit(0)},s=await Gt({argTargetDir:e,defaultTargetDir:er,yes:l,cancel:a}),c=await Kt({targetDir:s,argOverwrite:i,argKeep:n,yes:l,cancel:a}),d=await Wt({targetDir:s,yes:l,cancel:a}),h=await Nt({argFormat:t,yes:l,cancel:a}),m=await Dt({argStarter:r,yes:l,cancel:a}),f=B.skills??!0;qt({cwd:Xt,targetDir:s,format:h,starter:m,packageName:d,skills:f,keep:c,renameFiles:Zt})};tr().catch(e=>{console.error(e instanceof Error?e.message:e),process.exitCode=1});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-playcanvas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Scaffold a PlayCanvas project",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playcanvas",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"create-playcanvas": "dist/index.mjs"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
|
-
"skills",
|
|
32
31
|
"templates",
|
|
33
32
|
"dist"
|
|
34
33
|
],
|
|
@@ -43,15 +42,16 @@
|
|
|
43
42
|
"typecheck": "tsc --noEmit"
|
|
44
43
|
},
|
|
45
44
|
"devDependencies": {
|
|
46
|
-
"@clack/prompts": "^1.
|
|
45
|
+
"@clack/prompts": "^1.9.0",
|
|
47
46
|
"@playcanvas/eslint-config": "3.0.0",
|
|
47
|
+
"@playcanvas/skills": "github:playcanvas/skills#v0.7.0",
|
|
48
48
|
"@types/node": "22.20.5",
|
|
49
49
|
"eslint": "^9.39.5",
|
|
50
50
|
"mri": "^1.2.0",
|
|
51
51
|
"picocolors": "^1.1.1",
|
|
52
|
-
"prettier": "^3.
|
|
52
|
+
"prettier": "^3.10.0",
|
|
53
53
|
"unbuild": "^3.6.1",
|
|
54
|
-
"vite": "^8.3.
|
|
54
|
+
"vite": "^8.3.8",
|
|
55
55
|
"vitest": "^5.1.0"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
This project is a Snapchat Web Lens: Snapchat runs it in a sandbox, from an uploaded zip.
|
|
4
4
|
|
|
5
|
-
- Run `npm run package` after a change. It builds into `dist/lens/`, checks the build against the Web Lens upload rules and writes the zip to `dist/`, or reports why it wrote none.
|
|
5
|
+
- Run `npm run package` after a change. It builds into `dist/lens/`, checks the build against the Web Lens upload rules and writes the zip, named for the `package.json` version, to `dist/`, or reports why it wrote none.
|
|
6
6
|
- A Lens can't reach the network. Bundle every asset into the build, and load nothing from another host, such as a script from a CDN, a web font or a remote model.
|
|
7
|
+
- Use `.mp3` for audio and `.mp4` for video. Snapchat serves other media formats as generic binary data, which `<audio>` and `<video>` elements may not play.
|
|
7
8
|
- The zip must stay under 8 MB and `index.html` under 1 MB.
|
|
8
9
|
- Snapchat disables the camera and microphone for Web Lenses. Keep `getUserMedia`, `getDisplayMedia` and `ImageCapture` out of the build entirely.
|
|
9
10
|
- Motion sensors, geolocation, payments, MIDI, USB and WebRTC are off too, so design for pointer input.
|
|
10
11
|
- `localStorage` and IndexedDB start empty every time the Lens opens. To keep data between launches, use the Web Lens API's [storage](https://developers.snap.com/web-lenses/storage), `snapchat.storage`: check `snapchat.isSupported('storage')` after `init()` resolves, write nothing until the first read answers, and chain writes through one promise. Each value can be up to 32 KiB, and the whole store 100 KiB. `src/lens.ts` keeps the best score this way.
|
|
11
12
|
- `npm run start` serves the build under the Lens sandbox's rules, so a request that would fail in Snapchat also fails in the browser console.
|
|
12
|
-
- `src/lens.ts` is the only code that talks to Snapchat. Outside Snapchat, the Web Lens API answers from a stub with canned data, so the game runs in any browser. Call `recordScore(score)` when a run ends.
|
|
13
|
-
- Keep the layout portrait and the HUD inside the safe area, using
|
|
13
|
+
- `src/lens.ts` is the only code that talks to Snapchat. Outside Snapchat, the Web Lens API answers from a stub with canned data, so the game runs in any browser. Call `recordScore(score)` when a run ends, or `recordScore(score, 'ascending')` every time if a lower score wins.
|
|
14
|
+
- `src/lens.css` styles the page and the HUD. Keep the layout portrait and the HUD inside the safe area, using its `--inset-*` properties.
|
|
@@ -12,7 +12,7 @@ Drag left and right to steer the ball around the falling blocks. Tap to start, a
|
|
|
12
12
|
npm run package
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
This builds the game into `dist/lens/`, checks it against the [Web Lens upload rules](https://developers.snap.com/web-lenses/requirements) and writes a zip to `dist/` to upload through [My Lenses](https://my-lenses.snapchat.com/). It writes no zip if:
|
|
15
|
+
This builds the game into `dist/lens/`, checks it against the [Web Lens upload rules](https://developers.snap.com/web-lenses/requirements) and writes a zip to `dist/` to upload through [My Lenses](https://my-lenses.snapchat.com/). The zip is named for the project and its version, such as `my-lens-v0.0.0.zip`, as Snap recommends, so bump `version` in `package.json` before each upload. It writes no zip if:
|
|
16
16
|
|
|
17
17
|
- the zip is over 8 MB, or `index.html` is over 1 MB
|
|
18
18
|
- the build refers to one of its own files by an absolute path, such as `/assets/game.js`
|
|
@@ -20,7 +20,7 @@ This builds the game into `dist/lens/`, checks it against the [Web Lens upload r
|
|
|
20
20
|
- the build uses something the Lens sandbox blocks, such as an `<iframe>` or a web app manifest
|
|
21
21
|
- the build's code asks for the camera, microphone or screen (`getUserMedia`, `getDisplayMedia` or `ImageCapture`), which Snapchat disables for Web Lenses
|
|
22
22
|
|
|
23
|
-
It warns about any other host your scripts mention, since a Lens can't reach the network. It also warns about code that uses something Snapchat [turns off for Web Lenses](https://developers.snap.com/web-lenses/capabilities): motion sensors, geolocation, payments, MIDI, USB and WebRTC, and `localStorage` or IndexedDB, which start empty every time the Lens opens, so keep data between launches with the Web Lens API's [storage](https://developers.snap.com/web-lenses/storage) instead. To check the build itself, `npm run start` serves it under the same rules, so a request for anything outside the zip, or for a device such as the camera, fails in the browser console as it would in Snapchat.
|
|
23
|
+
It warns about any other host your scripts mention, since a Lens can't reach the network. It also warns about code that uses something Snapchat [turns off for Web Lenses](https://developers.snap.com/web-lenses/capabilities): motion sensors, geolocation, payments, MIDI, USB and WebRTC, and `localStorage` or IndexedDB, which start empty every time the Lens opens, so keep data between launches with the Web Lens API's [storage](https://developers.snap.com/web-lenses/storage) instead. And it warns about audio or video in any format but `.mp3` or `.mp4`, which Snapchat serves as generic binary data that an `<audio>` or `<video>` element may not play. To check the build itself, `npm run start` serves it under the same rules, so a request for anything outside the zip, or for a device such as the camera, fails in the browser console as it would in Snapchat.
|
|
24
24
|
|
|
25
25
|
### How it fits together
|
|
26
26
|
|
|
@@ -35,9 +35,10 @@ The game is the only part that's specific to Dodge, so replace it and keep the r
|
|
|
35
35
|
|
|
36
36
|
- **Replace** `src/dodge-game.ts` with your game. The HUD follows its `game:state` app event and shows the text in its `CARDS`, so fire the same event from your game, or rework the HUD in `index.html` and `src/main.ts` (`src/App.tsx` in React). Rename Dodge in the page `<title>` too.
|
|
37
37
|
- **Keep** `src/lens.ts`, `src/lens.css`, `vite.config.ts` and `scripts/package-lens.mjs`. None of them depends on the game.
|
|
38
|
-
- **Report each run to Snapchat** by calling `recordScore(score)` from `src/lens.ts` when it ends. That submits the score to the leaderboard, saves a new best to Snapchat storage and resolves the best score. Call `showLeaderboard()` from a button, and `loadBest()` and `loadAvatar()` at startup for the saved best and the player's Bitmoji.
|
|
38
|
+
- **Report each run to Snapchat** by calling `recordScore(score)` from `src/lens.ts` when it ends. That submits the score to the leaderboard, saves a new best to Snapchat storage and resolves the best score. If a lower score wins, as in a time trial, call `recordScore(score, 'ascending')` every time, so both rank it that way. Call `showLeaderboard()` from a button, and `loadBest()` and `loadAvatar()` at startup for the saved best and the player's Bitmoji.
|
|
39
|
+
- **Expect no leaderboard** until the Lens is set up with one: inside Snapchat, `hasLeaderboard()` is false for a Lens without one, so the Leaderboard button stays hidden. The stub outside Snapchat always has one.
|
|
39
40
|
- **Stick to what a Lens needs:** a portrait layout, a HUD kept inside the safe area with the `--inset-*` properties from `src/lens.css`, a pause when the page is hidden, audio that starts from a tap, and every asset bundled into the build, since a Lens can't reach the network.
|
|
40
41
|
|
|
41
|
-
Lenses run on phones, in portrait. To try the game on yours, run `npm run dev -- --host` and open the network URL it prints.
|
|
42
|
+
Lenses run on phones, in portrait. To try the game on yours, run `npm run dev -- --host` and open the network URL it prints. To try the build Snapchat would run, under the Lens rules, run `npm run build`, then `npm run start -- --host`. Only `npm run dev` marks the room Snapchat's controls take, so check the layout there.
|
|
42
43
|
|
|
43
|
-
When you're ready to publish, follow Snap's [publishing guide](https://developers.snap.com/web-lenses/publishing): you'll also need a 512 × 512 PNG icon and a 5-second, 720 × 1280 preview video.
|
|
44
|
+
When you're ready to publish, follow Snap's [publishing guide](https://developers.snap.com/web-lenses/publishing): you'll also need a 512 × 512 PNG icon and a 5-second, 720 × 1280 preview video. Snapchat can't open a Lens until it passes review, so try each change in the browser and on your phone first, and save changes up into fewer uploads.
|
|
@@ -23,6 +23,10 @@ const TEXT_HOSTS = ['www.w3.org', 'github.com', 'react.dev', 'api.playcanvas.com
|
|
|
23
23
|
const CAPTURE_APIS =
|
|
24
24
|
/\b(?:mediaDevices|getUserMedia|webkitGetUserMedia|mozGetUserMedia|getDisplayMedia|ImageCapture)\b/g;
|
|
25
25
|
|
|
26
|
+
// Snapchat picks each file's media type from its extension, and serves audio and video in any
|
|
27
|
+
// format but .mp3 and .mp4 as generic binary data, which <audio> and <video> elements may refuse
|
|
28
|
+
const UNTYPED_MEDIA = /\.(?:aac|flac|m4a|m4v|mov|oga|ogg|ogv|opus|wav|weba|webm)$/i;
|
|
29
|
+
|
|
26
30
|
// other features Snapchat turns off for Web Lenses: code that relies on them gets nothing, so it's
|
|
27
31
|
// worth a look rather than a failure
|
|
28
32
|
const UNAVAILABLE_APIS = [
|
|
@@ -46,8 +50,9 @@ const UNAVAILABLE_APIS = [
|
|
|
46
50
|
];
|
|
47
51
|
|
|
48
52
|
const buildDir = path.resolve(process.argv[2] ?? 'dist/lens');
|
|
49
|
-
|
|
50
|
-
const
|
|
53
|
+
// named for the version as well, as Snap recommends, so each upload can be told apart
|
|
54
|
+
const { name = 'web-lens', version = '0.0.0' } = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
55
|
+
const zipFile = path.join(path.dirname(buildDir), `${name.replace(/^@/, '').replace('/', '-')}-v${version}.zip`);
|
|
51
56
|
|
|
52
57
|
if (!fs.existsSync(buildDir)) {
|
|
53
58
|
console.error(`No build found at ${path.relative('.', buildDir)}. Run npm run package to build and package.`);
|
|
@@ -90,6 +95,13 @@ for (const file of files.filter((f) => f.startsWith('__snap/'))) {
|
|
|
90
95
|
errors.push(`${file}: __snap/ is reserved for Snapchat, so rename the folder`);
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
const media = files.filter((f) => UNTYPED_MEDIA.test(f));
|
|
99
|
+
if (media.length) {
|
|
100
|
+
warnings.push(
|
|
101
|
+
`${media.join(', ')}: Snapchat serves audio and video as generic binary data unless it's .mp3 or .mp4, so an <audio> or <video> element may not play it. Use .mp3 for audio and .mp4 for video`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
93
105
|
// URLs in markup and styles: anything on another host is blocked, and a root-absolute path
|
|
94
106
|
// resolves against Snapchat's origin rather than the Lens, so the game loads a blank screen
|
|
95
107
|
const checkUrl = (file, url) => {
|
|
@@ -281,3 +293,4 @@ console.log(` files ${files.length}`);
|
|
|
281
293
|
console.log('\nLargest files, before compression:');
|
|
282
294
|
for (const { file, size } of largest) console.log(` ${file.padEnd(width)} ${formatSize(size)}`);
|
|
283
295
|
console.log('\nUpload the zip at https://my-lenses.snapchat.com/');
|
|
296
|
+
console.log('Bump the version in package.json before the next upload, so its zip gets a new name.');
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
import type { CameraComponent } from 'playcanvas';
|
|
6
6
|
import { Color, Entity, Script, StandardMaterial, math } from 'playcanvas';
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Where a run is: loading, waiting for the first tap, in play, held while the Lens is hidden, or
|
|
10
|
+
* crashed. The page starts at `loading`, and the game moves to `ready` on its first frame.
|
|
11
|
+
*/
|
|
12
|
+
export type GameState = 'loading' | 'ready' | 'playing' | 'paused' | 'over';
|
|
10
13
|
|
|
11
14
|
/** The app event the game fires whenever its state or score changes, for the HUD to follow. */
|
|
12
15
|
export type GameEvents = {
|
|
@@ -15,6 +18,8 @@ export type GameEvents = {
|
|
|
15
18
|
|
|
16
19
|
/** What the HUD card says in each state. `null` hides the card. */
|
|
17
20
|
export const CARDS: Record<GameState, { title: string; hint: string } | null> = {
|
|
21
|
+
// so the first tap isn't asked for before anything can answer it
|
|
22
|
+
loading: { title: 'Dodge', hint: 'Loading…' },
|
|
18
23
|
ready: { title: 'Dodge', hint: 'Drag to steer. Tap to play' },
|
|
19
24
|
playing: null,
|
|
20
25
|
paused: { title: 'Paused', hint: 'Tap to resume' },
|
|
@@ -56,7 +61,7 @@ const createMaterial = (color: Color, glow: number) => {
|
|
|
56
61
|
class DodgeGame extends Script {
|
|
57
62
|
static scriptName = 'dodgeGame';
|
|
58
63
|
|
|
59
|
-
state: GameState = '
|
|
64
|
+
state: GameState = 'loading';
|
|
60
65
|
score = 0;
|
|
61
66
|
|
|
62
67
|
elapsed = 0;
|
|
@@ -122,8 +127,6 @@ class DodgeGame extends Script {
|
|
|
122
127
|
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
123
128
|
for (const material of [...blockMaterials, playerMaterial, starMaterial]) material.destroy();
|
|
124
129
|
});
|
|
125
|
-
|
|
126
|
-
this.setState('ready');
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
onPointerDown = (event: PointerEvent) => {
|
|
@@ -213,6 +216,10 @@ class DodgeGame extends Script {
|
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
update(dt: number) {
|
|
219
|
+
// say so from the first frame rather than from initialize(), where the page may not be
|
|
220
|
+
// listening yet: React subscribes to the app event only after it creates the script
|
|
221
|
+
if (this.state === 'loading') this.setState('ready');
|
|
222
|
+
|
|
216
223
|
this.fitField();
|
|
217
224
|
|
|
218
225
|
// blocks fall faster the longer a run lasts
|
|
@@ -7,7 +7,7 @@ import snapchat from '@snap/web-lens-api';
|
|
|
7
7
|
const BEST_KEY = 'best';
|
|
8
8
|
|
|
9
9
|
let ready: Promise<void> | undefined;
|
|
10
|
-
let best: Promise<number> | undefined;
|
|
10
|
+
let best: Promise<number | undefined> | undefined;
|
|
11
11
|
let avatar: Promise<string | undefined> | undefined;
|
|
12
12
|
let saving = Promise.resolve();
|
|
13
13
|
let submitting = Promise.resolve();
|
|
@@ -29,41 +29,46 @@ export const initLens = () => (ready ??= snapchat.init().then(() => undefined, w
|
|
|
29
29
|
export const hasLeaderboard = () => snapchat.isSupported('leaderboards');
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Resolves the player's best score. Snapchat storage keeps
|
|
33
|
-
* `localStorage` starts empty every time the Lens opens.
|
|
32
|
+
* Resolves the player's best score, or `undefined` until they finish a run. Snapchat storage keeps
|
|
33
|
+
* it between launches, where `localStorage` starts empty every time the Lens opens.
|
|
34
34
|
*/
|
|
35
35
|
export const loadBest = () =>
|
|
36
36
|
(best ??= initLens().then(async () => {
|
|
37
|
-
if (!snapchat.isSupported('storage')) return
|
|
37
|
+
if (!snapchat.isSupported('storage')) return undefined;
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
40
|
// the first read opens the store, which can take seconds on a cold start
|
|
41
41
|
const result = await snapchat.storage.get(BEST_KEY);
|
|
42
42
|
loaded = true;
|
|
43
|
-
return result.found && typeof result.value === 'number' ? result.value :
|
|
43
|
+
return result.found && typeof result.value === 'number' ? result.value : undefined;
|
|
44
44
|
} catch (err) {
|
|
45
45
|
warn('storage')(err as Error);
|
|
46
|
-
return
|
|
46
|
+
return undefined;
|
|
47
47
|
}
|
|
48
48
|
}));
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Submits a finished run to the leaderboard and keeps the player's best. Resolves the best score.
|
|
52
|
+
* Pass `'ascending'` for a game where a lower score wins, such as a time trial, every time.
|
|
53
|
+
*/
|
|
54
|
+
export const recordScore = (score: number, ordering: 'ascending' | 'descending' = 'descending') => {
|
|
55
|
+
// the leaderboard takes whole numbers, so keep the best the same way
|
|
56
|
+
const value = Math.round(score);
|
|
57
|
+
|
|
52
58
|
submitting = initLens().then(() => {
|
|
53
59
|
if (!snapchat.isSupported('leaderboards')) return;
|
|
54
|
-
return snapchat.leaderboards
|
|
55
|
-
.submitScore({ score: Math.round(score) })
|
|
56
|
-
.then(() => undefined, warn('leaderboard'));
|
|
60
|
+
return snapchat.leaderboards.submitScore({ score: value, ordering }).then(() => undefined, warn('leaderboard'));
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
// each run waits for the first read and for the runs before it, so two runs that end while the
|
|
60
64
|
// read is still loading can't both compare against the same old best
|
|
61
65
|
best = loadBest().then((previous) => {
|
|
62
|
-
|
|
66
|
+
const beaten = previous === undefined || (ordering === 'ascending' ? value < previous : value > previous);
|
|
67
|
+
if (!beaten) return previous;
|
|
63
68
|
|
|
64
69
|
// keep one write in flight at a time, and the chain going after a failed save
|
|
65
|
-
if (loaded) saving = saving.then(() => snapchat.storage.set(BEST_KEY,
|
|
66
|
-
return
|
|
70
|
+
if (loaded) saving = saving.then(() => snapchat.storage.set(BEST_KEY, value)).catch(warn('storage'));
|
|
71
|
+
return value;
|
|
67
72
|
});
|
|
68
73
|
return best;
|
|
69
74
|
};
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"@playcanvas/eslint-config": "3.0.0",
|
|
22
22
|
"@playcanvas/inspector": "^0.5.0",
|
|
23
23
|
"eslint": "^9.39.5",
|
|
24
|
-
"prettier": "^3.
|
|
24
|
+
"prettier": "^3.10.0",
|
|
25
25
|
"typescript": "~6.0.3",
|
|
26
|
-
"vite": "^8.3.
|
|
26
|
+
"vite": "^8.3.8"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=22.23.2"
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
<header class="lens-bar">
|
|
13
13
|
<img class="lens-avatar" alt="Your Bitmoji" hidden />
|
|
14
14
|
<span class="lens-score">0</span>
|
|
15
|
-
<span class="lens-best"
|
|
15
|
+
<span class="lens-best"></span>
|
|
16
16
|
</header>
|
|
17
17
|
<section class="lens-card">
|
|
18
18
|
<h1 class="lens-title">Dodge</h1>
|
|
19
19
|
<button class="lens-leaderboard" hidden>Leaderboard</button>
|
|
20
|
-
<p class="lens-hint">
|
|
20
|
+
<p class="lens-hint">Loading…</p>
|
|
21
21
|
</section>
|
|
22
22
|
</div>
|
|
23
23
|
</main>
|
|
@@ -59,8 +59,9 @@ const hud = {
|
|
|
59
59
|
hint: document.querySelector('.lens-hint')!,
|
|
60
60
|
leaderboard: document.querySelector<HTMLButtonElement>('.lens-leaderboard')!
|
|
61
61
|
};
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// there's no best until the player finishes a run
|
|
63
|
+
const showBest = (best: number | undefined) => {
|
|
64
|
+
hud.best.textContent = best === undefined ? '' : `Best ${best}`;
|
|
64
65
|
};
|
|
65
66
|
|
|
66
67
|
app.on('game:state', (state: GameState, score: number) => {
|
package/templates/index.ts
CHANGED
|
@@ -31,7 +31,9 @@ export const FEATURES: Record<string, Record<string, string[]>> = {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
// every format holds a base plus one directory per starter; the first starter is the default
|
|
34
|
+
// every format holds a base plus one directory per starter; the first starter is the default. A
|
|
35
|
+
// starter's `omit` lists files and folders every project gets that it has no use for, and its
|
|
36
|
+
// `outro` follows the commands in the closing message
|
|
35
37
|
export const STARTERS = [
|
|
36
38
|
{
|
|
37
39
|
name: 'spinning-cube',
|
|
@@ -94,7 +96,10 @@ export const STARTERS = [
|
|
|
94
96
|
name: 'web-lens',
|
|
95
97
|
display: 'Snapchat Web Lens',
|
|
96
98
|
category: 'Games',
|
|
97
|
-
description: 'a portrait game with Snapchat leaderboards, packaged for upload'
|
|
99
|
+
description: 'a portrait game with Snapchat leaderboards, packaged for upload',
|
|
100
|
+
// lens.css styles the page, and a Lens has no favicon
|
|
101
|
+
omit: ['src/starter.css', 'src/assets'],
|
|
102
|
+
outro: 'To publish it to Snapchat, run:\n\n npm run package\n\nand upload the zip it writes to dist/ at https://my-lenses.snapchat.com/'
|
|
98
103
|
},
|
|
99
104
|
{
|
|
100
105
|
name: 'scene-editor',
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"@playcanvas/inspector": "^0.5.0",
|
|
26
26
|
"@types/react": "^19.3.0",
|
|
27
27
|
"@types/react-dom": "^19.3.0",
|
|
28
|
-
"@vitejs/plugin-react": "^6.
|
|
28
|
+
"@vitejs/plugin-react": "^6.3.0",
|
|
29
29
|
"eslint": "^9.39.5",
|
|
30
|
-
"prettier": "^3.
|
|
30
|
+
"prettier": "^3.10.0",
|
|
31
31
|
"typescript": "~6.0.3",
|
|
32
|
-
"vite": "^8.3.
|
|
32
|
+
"vite": "^8.3.8"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.23.2"
|
|
@@ -35,9 +35,9 @@ function Scene({ onState }: { onState: GameEvents['game:state'] }) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function App() {
|
|
38
|
-
const [state, setState] = useState<GameState>('
|
|
38
|
+
const [state, setState] = useState<GameState>('loading');
|
|
39
39
|
const [score, setScore] = useState(0);
|
|
40
|
-
const [best, setBest] = useState(
|
|
40
|
+
const [best, setBest] = useState<number>();
|
|
41
41
|
const [avatar, setAvatar] = useState<string>();
|
|
42
42
|
|
|
43
43
|
useEffect(() => {
|
|
@@ -64,7 +64,8 @@ function App() {
|
|
|
64
64
|
<header className="lens-bar">
|
|
65
65
|
{avatar && <img className="lens-avatar" src={avatar} alt="Your Bitmoji" />}
|
|
66
66
|
<span className="lens-score">{score}</span>
|
|
67
|
-
|
|
67
|
+
{/* there's no best until the player finishes a run */}
|
|
68
|
+
{best !== undefined && <span className="lens-best">Best {best}</span>}
|
|
68
69
|
</header>
|
|
69
70
|
{card && (
|
|
70
71
|
<section className="lens-card">
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@playcanvas/web-components": "^0.
|
|
18
|
+
"@playcanvas/web-components": "^0.30.0",
|
|
19
19
|
"playcanvas": "^2.24.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@playcanvas/eslint-config": "3.0.0",
|
|
23
23
|
"@playcanvas/inspector": "^0.5.0",
|
|
24
24
|
"eslint": "^9.39.5",
|
|
25
|
-
"prettier": "^3.
|
|
25
|
+
"prettier": "^3.10.0",
|
|
26
26
|
"typescript": "~6.0.3",
|
|
27
|
-
"vite": "^8.3.
|
|
27
|
+
"vite": "^8.3.8"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=22.23.2"
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
<header class="lens-bar">
|
|
23
23
|
<img class="lens-avatar" alt="Your Bitmoji" hidden />
|
|
24
24
|
<span class="lens-score">0</span>
|
|
25
|
-
<span class="lens-best"
|
|
25
|
+
<span class="lens-best"></span>
|
|
26
26
|
</header>
|
|
27
27
|
<section class="lens-card">
|
|
28
28
|
<h1 class="lens-title">Dodge</h1>
|
|
29
29
|
<button class="lens-leaderboard" hidden>Leaderboard</button>
|
|
30
|
-
<p class="lens-hint">
|
|
30
|
+
<p class="lens-hint">Loading…</p>
|
|
31
31
|
</section>
|
|
32
32
|
</div>
|
|
33
33
|
</main>
|
|
@@ -16,8 +16,9 @@ const hud = {
|
|
|
16
16
|
hint: document.querySelector('.lens-hint')!,
|
|
17
17
|
leaderboard: document.querySelector<HTMLButtonElement>('.lens-leaderboard')!
|
|
18
18
|
};
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// there's no best until the player finishes a run
|
|
20
|
+
const showBest = (best: number | undefined) => {
|
|
21
|
+
hud.best.textContent = best === undefined ? '' : `Best ${best}`;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const { app } = await whenReady('pc-app');
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|