@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 +60 -0
- package/package.json +20 -0
- package/templates/clean/README.md +26 -0
- package/templates/clean/_gitignore +9 -0
- package/templates/clean/content/game.json +16 -0
- package/templates/clean/export/README.md +10 -0
- package/templates/clean/index.html +12 -0
- package/templates/clean/package.json +27 -0
- package/templates/clean/scripts/build-assets.mjs +138 -0
- package/templates/clean/src/main.tsx +14 -0
- package/templates/clean/tsconfig.json +14 -0
- package/templates/clean/vite.config.ts +8 -0
- package/templates/demo/content/game.json +3494 -0
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,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
|
+
})
|