@theideaguards/create-pixin 0.1.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/index.mjs ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `create-pixin` — scaffold a Pixin game project.
4
+ *
5
+ * npm create @theideaguards/pixin <project-name> [--template clean|demo]
6
+ *
7
+ * `clean` is the base project (a blank game + the editor wired up); `demo` overlays the
8
+ * "Magický polibek" demo, whose art/audio load from the hosted Pages deployment (CDN refs),
9
+ * so the scaffold stays tiny.
10
+ */
11
+ import { cp, readFile, writeFile, rename, readdir } from 'node:fs/promises'
12
+ import { existsSync } from 'node:fs'
13
+ import { dirname, join, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+ const argv = process.argv.slice(2)
18
+ const name = argv.find((a) => !a.startsWith('--'))
19
+ const tplIdx = argv.indexOf('--template')
20
+ const template = tplIdx !== -1 ? argv[tplIdx + 1] : 'clean'
21
+
22
+ if (!name) {
23
+ console.error('Usage: npm create @theideaguards/pixin <project-name> [--template clean|demo]')
24
+ process.exit(1)
25
+ }
26
+ if (template !== 'clean' && template !== 'demo') {
27
+ console.error(`Unknown template "${template}" — use "clean" or "demo".`)
28
+ process.exit(1)
29
+ }
30
+
31
+ const target = resolve(process.cwd(), name)
32
+ if (existsSync(target) && (await readdir(target)).length) {
33
+ console.error(`Directory "${name}" already exists and is not empty.`)
34
+ process.exit(1)
35
+ }
36
+
37
+ // `clean` is the base; `demo` overlays its content/game.json onto it.
38
+ await cp(join(HERE, 'templates', 'clean'), target, { recursive: true })
39
+ if (template === 'demo') {
40
+ await cp(join(HERE, 'templates', 'demo'), target, { recursive: true })
41
+ }
42
+
43
+ // `_gitignore` → `.gitignore` (npm strips a real .gitignore from published packages).
44
+ if (existsSync(join(target, '_gitignore'))) {
45
+ await rename(join(target, '_gitignore'), join(target, '.gitignore'))
46
+ }
47
+
48
+ // Fill the project name into package.json + index.html.
49
+ for (const file of ['package.json', 'index.html']) {
50
+ const path = join(target, file)
51
+ if (existsSync(path))
52
+ await writeFile(path, (await readFile(path, 'utf8')).replaceAll('{{name}}', name))
53
+ }
54
+
55
+ console.log(`\n✓ Created ${name} (${template} template)\n`)
56
+ console.log('Next steps:')
57
+ console.log(` cd ${name}`)
58
+ console.log(' pnpm install # or npm install')
59
+ console.log(' pnpm dev # play the game; open ?edit for the visual editor')
60
+ console.log('')
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@theideaguards/create-pixin",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Pixin point-and-click game project (engine + no-code editor).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "create-pixin": "index.mjs"
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "templates"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ }
20
+ }
@@ -0,0 +1,26 @@
1
+ # {{name}}
2
+
3
+ A [Pixin](https://github.com/Martinsafka/point-and-click-pixin) point-and-click adventure.
4
+
5
+ ```bash
6
+ pnpm install
7
+ pnpm dev # play the game
8
+ ```
9
+
10
+ ## Authoring (the no-code editor)
11
+
12
+ Open the dev server with **`?edit`** (e.g. `http://localhost:5173/?edit`) for the visual
13
+ editor. It saves your work to an **IndexedDB draft**; **Test in game** reloads the page to
14
+ play it.
15
+
16
+ When you're happy, **Export** the document from the editor and ship it:
17
+
18
+ ```bash
19
+ # drop the exported game.json into export/, then:
20
+ pnpm assets # externalize embedded art/audio → public/assets/baked + lean content/game.json
21
+ pnpm build # production build → dist/
22
+ ```
23
+
24
+ The game is one serializable `GameDoc` (`content/game.json`); you can also hand-edit it.
25
+ See the [Pixin docs](https://github.com/Martinsafka/point-and-click-pixin) for the schema,
26
+ the editor guide, and the bundled Claude Code skills.
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ *.local
4
+ .vite
5
+ .DS_Store
6
+
7
+ # raw editor exports (drop game.json here, then run `pnpm assets`)
8
+ /export/*
9
+ !/export/README.md
@@ -0,0 +1,16 @@
1
+ {
2
+ "start": "scene1",
3
+ "referenceHeight": 1080,
4
+ "scenes": {
5
+ "scene1": {
6
+ "id": "scene1",
7
+ "name": "Scene 1",
8
+ "width": 1920,
9
+ "walkable": [0.08, 0.72, 0.92, 0.72, 0.92, 0.96, 0.08, 0.96],
10
+ "layers": [],
11
+ "interactables": []
12
+ }
13
+ },
14
+ "items": {},
15
+ "initialFlags": {}
16
+ }
@@ -0,0 +1,10 @@
1
+ # export/
2
+
3
+ Drop your editor **Export** here as `game.json`, then run:
4
+
5
+ ```bash
6
+ pnpm assets
7
+ ```
8
+
9
+ It externalizes the embedded art/audio into `public/assets/baked/` and writes a lean
10
+ `content/game.json`. Raw exports here are gitignored (they hold megabytes of inline base64).
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{name}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview",
10
+ "assets": "node scripts/build-assets.mjs"
11
+ },
12
+ "dependencies": {
13
+ "@xyflow/react": "^12.0.0",
14
+ "pixi.js": "^8.0.0",
15
+ "@theideaguards/pixin": "^0.1.0",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "^19.0.0",
21
+ "@types/react-dom": "^19.0.0",
22
+ "@vitejs/plugin-react": "^6.0.0",
23
+ "sharp": "^0.35.0",
24
+ "typescript": "^6.0.0",
25
+ "vite": "^8.0.0"
26
+ }
27
+ }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Externalize + compress a GameDoc's embedded art/audio.
4
+ *
5
+ * The editor bakes uploaded images and sounds into `content/game.json` as base64 `data:`
6
+ * URLs, which bloats the JSON (and the JS bundle that inlines it) to tens of MB. This
7
+ * walks the document, writes every `data:` blob out to `public/assets/baked/…` (images
8
+ * re-encoded to downscaled WebP via sharp, audio passed through), de-duplicates by content
9
+ * hash, and rewrites each ref to a relative path the runtime resolves via BASE_URL
10
+ * (src/data/asset-url.ts). Re-run any time after re-exporting the doc; it is idempotent
11
+ * (a doc with no `data:` strings is left unchanged) and skips files already on disk.
12
+ *
13
+ * pnpm assets # export/game.json → lean content/game.json + public/assets/baked/
14
+ * node scripts/build-assets.mjs --in export/game.json --max-height 1620 --quality 80
15
+ */
16
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
17
+ import { existsSync } from 'node:fs'
18
+ import { createHash } from 'node:crypto'
19
+ import { dirname, join } from 'node:path'
20
+ import sharp from 'sharp'
21
+
22
+ const args = parseArgs(process.argv.slice(2))
23
+ const IN = args.in ?? 'export/game.json' // raw editor export (gitignored — see export/README.md)
24
+ const OUT = args.out ?? 'content/game.json' // lean committed source of truth
25
+ const ASSET_DIR = args.assets ?? 'public/assets/baked' // files written here
26
+ const REF_BASE = args.base ?? 'assets/baked' // ref prefix stored in the doc (relative)
27
+ const MAX_HEIGHT = Number(args['max-height'] ?? 1620)
28
+ const QUALITY = Number(args.quality ?? 80)
29
+
30
+ if (!existsSync(IN)) {
31
+ console.error(
32
+ `Input not found: ${IN}\n` +
33
+ `Drop your editor "Export" game.json there (or pass --in <path>), then run \`pnpm assets\`.`,
34
+ )
35
+ process.exit(1)
36
+ }
37
+
38
+ const DATA_RE = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,(.*)$/i
39
+ const byHash = new Map() // content hash → relative ref (de-dupe identical blobs)
40
+ const stats = { images: 0, audio: 0, other: 0, reused: 0, srcBytes: 0, outBytes: 0 }
41
+
42
+ async function externalize(dataUrl) {
43
+ const m = DATA_RE.exec(dataUrl)
44
+ if (!m) return null
45
+ const [, mime, b64] = m
46
+ const input = Buffer.from(b64, 'base64')
47
+ const hash = createHash('sha1').update(input).digest('hex').slice(0, 16)
48
+ if (byHash.has(hash)) {
49
+ stats.reused += 1
50
+ return byHash.get(hash)
51
+ }
52
+ stats.srcBytes += dataUrl.length
53
+
54
+ let out = input
55
+ let sub = 'misc'
56
+ let ext = (mime.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '')
57
+
58
+ if (mime.startsWith('image/')) {
59
+ sub = 'img'
60
+ try {
61
+ out = await sharp(input)
62
+ .resize({ height: MAX_HEIGHT, withoutEnlargement: true })
63
+ .webp({ quality: QUALITY })
64
+ .toBuffer()
65
+ ext = 'webp'
66
+ stats.images += 1
67
+ } catch (err) {
68
+ // Unsupported/corrupt image → keep the original bytes + extension.
69
+ console.warn(` ! sharp failed on an image (${err.message}); keeping original`)
70
+ out = input
71
+ }
72
+ } else if (mime.startsWith('audio/')) {
73
+ sub = 'audio'
74
+ ext = mime === 'audio/mpeg' ? 'mp3' : ext
75
+ stats.audio += 1
76
+ } else {
77
+ stats.other += 1
78
+ }
79
+
80
+ const ref = `${REF_BASE}/${sub}/${hash}.${ext}`
81
+ const abs = join(ASSET_DIR, sub, `${hash}.${ext}`)
82
+ if (!existsSync(abs)) {
83
+ await mkdir(dirname(abs), { recursive: true })
84
+ await writeFile(abs, out)
85
+ }
86
+ stats.outBytes += out.length
87
+ byHash.set(hash, ref)
88
+ return ref
89
+ }
90
+
91
+ async function walk(node) {
92
+ if (typeof node === 'string') {
93
+ if (node.startsWith('data:')) return (await externalize(node)) ?? node
94
+ return node
95
+ }
96
+ if (Array.isArray(node)) {
97
+ for (let i = 0; i < node.length; i += 1) node[i] = await walk(node[i])
98
+ return node
99
+ }
100
+ if (node && typeof node === 'object') {
101
+ for (const k of Object.keys(node)) node[k] = await walk(node[k])
102
+ return node
103
+ }
104
+ return node
105
+ }
106
+
107
+ const raw = await readFile(IN, 'utf8')
108
+ const doc = JSON.parse(raw)
109
+ await walk(doc)
110
+ const lean = JSON.stringify(doc, null, 2) + '\n'
111
+ await writeFile(OUT, lean)
112
+
113
+ const mb = (n) => (n / 1e6).toFixed(1)
114
+ console.log(`✓ ${IN} → ${OUT}`)
115
+ console.log(` json: ${mb(raw.length)} MB → ${mb(lean.length)} MB`)
116
+ console.log(
117
+ ` art: ${stats.images} images, ${stats.audio} audio, ${stats.other} other` +
118
+ ` (${stats.reused} duplicates skipped)`,
119
+ )
120
+ console.log(
121
+ ` on disk: ${mb(stats.srcBytes)} MB base64 → ${mb(stats.outBytes)} MB files in ${ASSET_DIR}/`,
122
+ )
123
+
124
+ function parseArgs(argv) {
125
+ const out = {}
126
+ for (let i = 0; i < argv.length; i += 1) {
127
+ if (!argv[i].startsWith('--')) continue
128
+ const key = argv[i].slice(2)
129
+ const next = argv[i + 1]
130
+ if (next && !next.startsWith('--')) {
131
+ out[key] = next
132
+ i += 1
133
+ } else {
134
+ out[key] = true
135
+ }
136
+ }
137
+ return out
138
+ }
@@ -0,0 +1,14 @@
1
+ import { mountGame, loadDraft, type GameDoc } from '@theideaguards/pixin'
2
+ import '@theideaguards/pixin/styles.css'
3
+ import gameDoc from '../content/game.json'
4
+
5
+ const root = document.getElementById('root')!
6
+
7
+ if (import.meta.env.DEV && new URLSearchParams(location.search).has('edit')) {
8
+ // Visual editor (dev only). It edits an IndexedDB draft; "Test in game" reloads to play it.
9
+ void import('@theideaguards/pixin/editor').then(({ mountEditor }) => mountEditor(root))
10
+ } else {
11
+ // Play the editor draft (dev) over the committed game.json, else the committed game.
12
+ const draft = import.meta.env.DEV ? await loadDraft() : null
13
+ mountGame((draft ?? gameDoc) as GameDoc, root)
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "resolveJsonModule": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "types": ["vite/client"]
12
+ },
13
+ "include": ["src", "content"]
14
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // PixiJS v8 ships top-level await, which Vite's prod build rejects under the default target.
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ build: { target: 'esnext' },
8
+ })