create-cubeforge-game 0.4.14 → 0.5.1
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/bin/index.js +58 -3
- package/package.json +1 -1
- package/templates/__tests__/template.test.ts +27 -19
- package/templates/editor/.eslintrc.cjs.template +15 -0
- package/templates/editor/.prettierrc.template +7 -0
- package/templates/editor/index.html.template +13 -0
- package/templates/editor/package.json.template +29 -0
- package/templates/editor/src/App.tsx.template +144 -0
- package/templates/editor/src/main.tsx.template +8 -0
- package/templates/editor/tsconfig.json.template +11 -0
- package/templates/editor/vite.config.ts.template +3 -0
- package/templates/puzzle/.eslintrc.cjs.template +15 -0
- package/templates/puzzle/.prettierrc.template +7 -0
- package/templates/puzzle/index.html.template +13 -0
- package/templates/puzzle/package.json.template +29 -0
- package/templates/puzzle/src/App.tsx.template +171 -0
- package/templates/puzzle/src/main.tsx.template +8 -0
- package/templates/puzzle/tsconfig.json.template +11 -0
- package/templates/puzzle/vite.config.ts.template +3 -0
- package/templates/turn-based/.eslintrc.cjs.template +15 -0
- package/templates/turn-based/.prettierrc.template +7 -0
- package/templates/turn-based/index.html.template +13 -0
- package/templates/turn-based/package.json.template +29 -0
- package/templates/turn-based/src/App.tsx.template +144 -0
- package/templates/turn-based/src/main.tsx.template +8 -0
- package/templates/turn-based/tsconfig.json.template +11 -0
- package/templates/turn-based/vite.config.ts.template +3 -0
package/bin/index.js
CHANGED
|
@@ -59,8 +59,43 @@ function copyTemplateDir(src, dest, projectName) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
var TEMPLATES = ["default", "puzzle", "turn-based", "editor"];
|
|
63
|
+
var TEMPLATE_DESCRIPTIONS = {
|
|
64
|
+
default: "Action platformer with physics, coins, and save/load",
|
|
65
|
+
puzzle: "Grid-based sliding puzzle (onDemand loop, drag-and-snap, undo/redo)",
|
|
66
|
+
"turn-based": "Tic-tac-toe with turn manager, hover, and accessibility",
|
|
67
|
+
editor: "Scene editor with selection, transform handles, save/load"
|
|
68
|
+
};
|
|
69
|
+
function parseArgs() {
|
|
70
|
+
const args = process.argv.slice(2);
|
|
71
|
+
let projectName;
|
|
72
|
+
let template;
|
|
73
|
+
for (let i = 0; i < args.length; i++) {
|
|
74
|
+
const a = args[i];
|
|
75
|
+
if (a === "--template" || a === "-t") {
|
|
76
|
+
const next = args[++i];
|
|
77
|
+
if (!next || !TEMPLATES.includes(next)) {
|
|
78
|
+
process.stderr.write(`Error: --template must be one of: ${TEMPLATES.join(", ")}
|
|
79
|
+
`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
template = next;
|
|
83
|
+
} else if (a.startsWith("--template=")) {
|
|
84
|
+
const val = a.slice("--template=".length);
|
|
85
|
+
if (!TEMPLATES.includes(val)) {
|
|
86
|
+
process.stderr.write(`Error: --template must be one of: ${TEMPLATES.join(", ")}
|
|
87
|
+
`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
template = val;
|
|
91
|
+
} else if (!projectName) {
|
|
92
|
+
projectName = a;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { projectName, template };
|
|
96
|
+
}
|
|
62
97
|
async function main() {
|
|
63
|
-
let projectName =
|
|
98
|
+
let { projectName, template } = parseArgs();
|
|
64
99
|
if (!projectName) {
|
|
65
100
|
projectName = await prompt("Project name: ");
|
|
66
101
|
}
|
|
@@ -68,15 +103,35 @@ async function main() {
|
|
|
68
103
|
process.stderr.write("Error: project name is required.\n");
|
|
69
104
|
process.exit(1);
|
|
70
105
|
}
|
|
106
|
+
if (!template) {
|
|
107
|
+
process.stdout.write("\nAvailable templates:\n");
|
|
108
|
+
for (const t of TEMPLATES) {
|
|
109
|
+
process.stdout.write(` ${t.padEnd(12)} \u2014 ${TEMPLATE_DESCRIPTIONS[t]}
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
const answer = await prompt("\nTemplate (default): ");
|
|
113
|
+
const chosen = answer || "default";
|
|
114
|
+
if (!TEMPLATES.includes(chosen)) {
|
|
115
|
+
process.stderr.write(`Error: unknown template "${chosen}".
|
|
116
|
+
`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
template = chosen;
|
|
120
|
+
}
|
|
71
121
|
const targetDir = path.resolve(process.cwd(), projectName);
|
|
72
122
|
if (fs.existsSync(targetDir)) {
|
|
73
123
|
process.stderr.write(`Error: directory "${projectName}" already exists.
|
|
74
124
|
`);
|
|
75
125
|
process.exit(1);
|
|
76
126
|
}
|
|
77
|
-
const templatesDir = path.join(import_meta.dirname, "..", "templates",
|
|
127
|
+
const templatesDir = path.join(import_meta.dirname, "..", "templates", template);
|
|
128
|
+
if (!fs.existsSync(templatesDir)) {
|
|
129
|
+
process.stderr.write(`Error: template "${template}" not found at ${templatesDir}
|
|
130
|
+
`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
78
133
|
process.stdout.write(`
|
|
79
|
-
Creating new Cubeforge game in ${targetDir}...
|
|
134
|
+
Creating new Cubeforge game in ${targetDir} (template: ${template})...
|
|
80
135
|
`);
|
|
81
136
|
copyTemplateDir(templatesDir, targetDir, projectName);
|
|
82
137
|
process.stdout.write(`
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { readdirSync, readFileSync, existsSync } from 'fs'
|
|
2
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'fs'
|
|
3
3
|
import { join, resolve } from 'path'
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const TEMPLATES_ROOT = resolve(__dirname, '..')
|
|
6
|
+
const TEMPLATE_NAMES = ['default', 'puzzle', 'turn-based', 'editor']
|
|
6
7
|
|
|
7
8
|
function collectTemplateFiles(dir: string): string[] {
|
|
8
9
|
const results: string[] = []
|
|
@@ -17,48 +18,55 @@ function collectTemplateFiles(dir: string): string[] {
|
|
|
17
18
|
return results
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
describe('
|
|
21
|
-
const
|
|
21
|
+
describe.each(TEMPLATE_NAMES)('%s template', (name) => {
|
|
22
|
+
const dir = join(TEMPLATES_ROOT, name)
|
|
23
|
+
|
|
24
|
+
it('template directory exists', () => {
|
|
25
|
+
expect(existsSync(dir)).toBe(true)
|
|
26
|
+
expect(statSync(dir).isDirectory()).toBe(true)
|
|
27
|
+
})
|
|
22
28
|
|
|
23
29
|
it('contains at least one .template file', () => {
|
|
24
|
-
|
|
30
|
+
const files = collectTemplateFiles(dir)
|
|
31
|
+
expect(files.length).toBeGreaterThan(0)
|
|
25
32
|
})
|
|
26
33
|
|
|
27
|
-
it
|
|
28
|
-
|
|
29
|
-
(_name, filePath) => {
|
|
30
|
-
expect(existsSync(filePath)).toBe(true)
|
|
34
|
+
it('all .template files are non-empty', () => {
|
|
35
|
+
for (const filePath of collectTemplateFiles(dir)) {
|
|
31
36
|
const content = readFileSync(filePath, 'utf-8')
|
|
32
|
-
expect(content.trim().length).toBeGreaterThan(0)
|
|
33
|
-
}
|
|
34
|
-
)
|
|
37
|
+
expect(content.trim().length, `expected ${filePath} to be non-empty`).toBeGreaterThan(0)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
35
40
|
|
|
36
41
|
it('package.json.template has lint script', () => {
|
|
37
|
-
const pkgPath = join(
|
|
42
|
+
const pkgPath = join(dir, 'package.json.template')
|
|
38
43
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
39
44
|
expect(pkg.scripts.lint).toBeDefined()
|
|
40
45
|
expect(pkg.scripts.lint).toContain('eslint')
|
|
41
46
|
})
|
|
42
47
|
|
|
43
48
|
it('package.json.template has format script', () => {
|
|
44
|
-
const pkgPath = join(
|
|
49
|
+
const pkgPath = join(dir, 'package.json.template')
|
|
45
50
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
46
51
|
expect(pkg.scripts.format).toBeDefined()
|
|
47
52
|
expect(pkg.scripts.format).toContain('prettier')
|
|
48
53
|
})
|
|
49
54
|
|
|
50
55
|
it('includes eslint config template', () => {
|
|
51
|
-
expect(existsSync(join(
|
|
56
|
+
expect(existsSync(join(dir, '.eslintrc.cjs.template'))).toBe(true)
|
|
52
57
|
})
|
|
53
58
|
|
|
54
59
|
it('includes prettier config template', () => {
|
|
55
|
-
expect(existsSync(join(
|
|
60
|
+
expect(existsSync(join(dir, '.prettierrc.template'))).toBe(true)
|
|
56
61
|
})
|
|
57
62
|
|
|
58
63
|
it('tsconfig.json.template enables strict mode', () => {
|
|
59
|
-
const tsconfig = JSON.parse(
|
|
60
|
-
readFileSync(join(TEMPLATE_DIR, 'tsconfig.json.template'), 'utf-8'),
|
|
61
|
-
)
|
|
64
|
+
const tsconfig = JSON.parse(readFileSync(join(dir, 'tsconfig.json.template'), 'utf-8'))
|
|
62
65
|
expect(tsconfig.compilerOptions.strict).toBe(true)
|
|
63
66
|
})
|
|
67
|
+
|
|
68
|
+
it('has an App.tsx.template and main.tsx.template', () => {
|
|
69
|
+
expect(existsSync(join(dir, 'src', 'App.tsx.template'))).toBe(true)
|
|
70
|
+
expect(existsSync(join(dir, 'src', 'main.tsx.template'))).toBe(true)
|
|
71
|
+
})
|
|
64
72
|
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
env: { browser: true, es2020: true },
|
|
4
|
+
extends: [
|
|
5
|
+
'eslint:recommended',
|
|
6
|
+
'plugin:@typescript-eslint/recommended',
|
|
7
|
+
'plugin:react-hooks/recommended',
|
|
8
|
+
],
|
|
9
|
+
ignorePatterns: ['dist'],
|
|
10
|
+
parser: '@typescript-eslint/parser',
|
|
11
|
+
rules: {
|
|
12
|
+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
13
|
+
'react-hooks/exhaustive-deps': 'warn',
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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.0" />
|
|
6
|
+
<title>{{PROJECT_NAME}}</title>
|
|
7
|
+
<style>* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a0f; min-height: 100vh; font-family: system-ui, sans-serif; color: #e0e7f1; }</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"lint": "eslint src --ext ts,tsx",
|
|
10
|
+
"format": "prettier --write src"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"cubeforge": "latest",
|
|
14
|
+
"react": "^18.0.0",
|
|
15
|
+
"react-dom": "^18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.0",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
21
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
23
|
+
"eslint": "^8.57.0",
|
|
24
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
25
|
+
"prettier": "^3.2.0",
|
|
26
|
+
"typescript": "^5.4.0",
|
|
27
|
+
"vite": "^5.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { GameControls } from 'cubeforge'
|
|
3
|
+
import {
|
|
4
|
+
Stage,
|
|
5
|
+
Entity,
|
|
6
|
+
Transform,
|
|
7
|
+
Sprite,
|
|
8
|
+
Selection,
|
|
9
|
+
TransformHandles,
|
|
10
|
+
useSnap,
|
|
11
|
+
useHistory,
|
|
12
|
+
saveSceneToLocalStorage,
|
|
13
|
+
loadSceneFromLocalStorage,
|
|
14
|
+
downloadCanvas,
|
|
15
|
+
useGame,
|
|
16
|
+
} from 'cubeforge'
|
|
17
|
+
|
|
18
|
+
type Piece = { id: number; x: number; y: number; color: string }
|
|
19
|
+
|
|
20
|
+
const PALETTE = ['#4fc3f7', '#ef5350', '#8bc34a', '#ffc107', '#ab47bc']
|
|
21
|
+
const STORAGE_KEY = '{{PROJECT_NAME}}:scene'
|
|
22
|
+
|
|
23
|
+
function Piece({ piece }: { piece: Piece }) {
|
|
24
|
+
return (
|
|
25
|
+
<Entity>
|
|
26
|
+
<Transform x={piece.x} y={piece.y} />
|
|
27
|
+
<Sprite width={60} height={60} color={piece.color} borderRadius={6} />
|
|
28
|
+
</Entity>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Scene({ pieces }: { pieces: Piece[] }) {
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
{pieces.map((p) => (
|
|
36
|
+
<Piece key={p.id} piece={p} />
|
|
37
|
+
))}
|
|
38
|
+
</>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A tiny inline toolbar — talks to the engine via useGame().
|
|
43
|
+
function Toolbar({ onAdd }: { onAdd: (color: string) => void }) {
|
|
44
|
+
const engine = useGame()
|
|
45
|
+
const history = useHistory({ bindKeyboardShortcuts: true })
|
|
46
|
+
|
|
47
|
+
const save = () => {
|
|
48
|
+
if (saveSceneToLocalStorage(engine, STORAGE_KEY, { pretty: true })) {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.log('Saved to localStorage')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const load = () => {
|
|
54
|
+
if (!loadSceneFromLocalStorage(engine, STORAGE_KEY)) {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.warn('No saved scene found')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const exportPng = () => downloadCanvas(engine.canvas, '{{PROJECT_NAME}}.png')
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div style={toolbarStyle}>
|
|
63
|
+
<strong style={{ fontSize: 12, color: '#78909c', marginRight: 8 }}>Add:</strong>
|
|
64
|
+
{PALETTE.map((c) => (
|
|
65
|
+
<button
|
|
66
|
+
key={c}
|
|
67
|
+
onClick={() => onAdd(c)}
|
|
68
|
+
style={{ ...swatch, background: c }}
|
|
69
|
+
aria-label={`Add piece with color ${c}`}
|
|
70
|
+
/>
|
|
71
|
+
))}
|
|
72
|
+
<span style={{ flex: 1 }} />
|
|
73
|
+
<button onClick={history.undo} disabled={!history.canUndo} style={btn}>Undo</button>
|
|
74
|
+
<button onClick={history.redo} disabled={!history.canRedo} style={btn}>Redo</button>
|
|
75
|
+
<button onClick={save} style={btn}>Save</button>
|
|
76
|
+
<button onClick={load} style={btn}>Load</button>
|
|
77
|
+
<button onClick={exportPng} style={btn}>Export PNG</button>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function App() {
|
|
83
|
+
const nextId = useRef(1)
|
|
84
|
+
const [pieces, setPieces] = useState<Piece[]>([
|
|
85
|
+
{ id: nextId.current++, x: -80, y: 0, color: '#4fc3f7' },
|
|
86
|
+
{ id: nextId.current++, x: 80, y: 0, color: '#ef5350' },
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const addPiece = (color: string) => {
|
|
90
|
+
setPieces((prev) => [...prev, { id: nextId.current++, x: 0, y: 0, color }])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
95
|
+
<h1 style={{ fontSize: 18, fontWeight: 500 }}>{`{{PROJECT_NAME}}`}</h1>
|
|
96
|
+
<p style={{ fontSize: 13, color: '#78909c' }}>
|
|
97
|
+
Click a colored swatch to spawn a piece. Drag the handle box to move, corners to resize,
|
|
98
|
+
the round handle above to rotate. Ctrl+Z / Ctrl+Shift+Z to undo/redo. Snap to 10px grid.
|
|
99
|
+
</p>
|
|
100
|
+
<div style={{ position: 'relative', alignSelf: 'flex-start' }}>
|
|
101
|
+
<Stage width={800} height={500}>
|
|
102
|
+
<Selection>
|
|
103
|
+
<Scene pieces={pieces} />
|
|
104
|
+
<TransformHandles />
|
|
105
|
+
<Toolbar onAdd={addPiece} />
|
|
106
|
+
</Selection>
|
|
107
|
+
</Stage>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const toolbarStyle: React.CSSProperties = {
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
bottom: -48,
|
|
116
|
+
left: 0,
|
|
117
|
+
right: 0,
|
|
118
|
+
display: 'flex',
|
|
119
|
+
gap: 6,
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
background: 'rgba(255,255,255,0.04)',
|
|
122
|
+
border: '1px solid rgba(255,255,255,0.1)',
|
|
123
|
+
padding: 6,
|
|
124
|
+
borderRadius: 4,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const swatch: React.CSSProperties = {
|
|
128
|
+
width: 24,
|
|
129
|
+
height: 24,
|
|
130
|
+
border: '1px solid rgba(255,255,255,0.3)',
|
|
131
|
+
borderRadius: 3,
|
|
132
|
+
cursor: 'pointer',
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const btn: React.CSSProperties = {
|
|
136
|
+
background: 'rgba(255,255,255,0.08)',
|
|
137
|
+
border: '1px solid rgba(255,255,255,0.2)',
|
|
138
|
+
color: '#e0e7f1',
|
|
139
|
+
padding: '4px 10px',
|
|
140
|
+
borderRadius: 4,
|
|
141
|
+
fontSize: 12,
|
|
142
|
+
cursor: 'pointer',
|
|
143
|
+
fontFamily: 'monospace',
|
|
144
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
env: { browser: true, es2020: true },
|
|
4
|
+
extends: [
|
|
5
|
+
'eslint:recommended',
|
|
6
|
+
'plugin:@typescript-eslint/recommended',
|
|
7
|
+
'plugin:react-hooks/recommended',
|
|
8
|
+
],
|
|
9
|
+
ignorePatterns: ['dist'],
|
|
10
|
+
parser: '@typescript-eslint/parser',
|
|
11
|
+
rules: {
|
|
12
|
+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
13
|
+
'react-hooks/exhaustive-deps': 'warn',
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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.0" />
|
|
6
|
+
<title>{{PROJECT_NAME}}</title>
|
|
7
|
+
<style>* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a0f; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: system-ui, sans-serif; }</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"lint": "eslint src --ext ts,tsx",
|
|
10
|
+
"format": "prettier --write src"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"cubeforge": "latest",
|
|
14
|
+
"react": "^18.0.0",
|
|
15
|
+
"react-dom": "^18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.0",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
21
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
23
|
+
"eslint": "^8.57.0",
|
|
24
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
25
|
+
"prettier": "^3.2.0",
|
|
26
|
+
"typescript": "^5.4.0",
|
|
27
|
+
"vite": "^5.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Stage, Entity, Transform, Sprite, Text, useGrid, useHistory } from 'cubeforge'
|
|
2
|
+
|
|
3
|
+
const SIZE = 4
|
|
4
|
+
const TILE = 80
|
|
5
|
+
const BOARD_PX = SIZE * TILE
|
|
6
|
+
const EMPTY = 0
|
|
7
|
+
|
|
8
|
+
type Board = number[]
|
|
9
|
+
|
|
10
|
+
function isSolved(cells: readonly number[]): boolean {
|
|
11
|
+
for (let i = 0; i < cells.length - 1; i++) {
|
|
12
|
+
if (cells[i] !== i + 1) return false
|
|
13
|
+
}
|
|
14
|
+
return cells[cells.length - 1] === EMPTY
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function neighborsOfEmpty(cells: readonly number[]): number[] {
|
|
18
|
+
const empty = cells.indexOf(EMPTY)
|
|
19
|
+
const row = Math.floor(empty / SIZE)
|
|
20
|
+
const col = empty % SIZE
|
|
21
|
+
const out: number[] = []
|
|
22
|
+
if (row > 0) out.push(empty - SIZE)
|
|
23
|
+
if (row < SIZE - 1) out.push(empty + SIZE)
|
|
24
|
+
if (col > 0) out.push(empty - 1)
|
|
25
|
+
if (col < SIZE - 1) out.push(empty + 1)
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scramble(cells: number[], moves = 80): number[] {
|
|
30
|
+
const out = cells.slice()
|
|
31
|
+
for (let i = 0; i < moves; i++) {
|
|
32
|
+
const ns = neighborsOfEmpty(out)
|
|
33
|
+
const pick = ns[Math.floor(Math.random() * ns.length)]
|
|
34
|
+
const emptyIdx = out.indexOf(EMPTY)
|
|
35
|
+
;[out[emptyIdx], out[pick]] = [out[pick], out[emptyIdx]]
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Tile({ value, row, col, onClick }: { value: number; row: number; col: number; onClick: () => void }) {
|
|
41
|
+
if (value === EMPTY) return null
|
|
42
|
+
const x = col * TILE + TILE / 2 - BOARD_PX / 2
|
|
43
|
+
const y = row * TILE + TILE / 2 - BOARD_PX / 2
|
|
44
|
+
return (
|
|
45
|
+
<Entity>
|
|
46
|
+
<Transform x={x} y={y} />
|
|
47
|
+
<Sprite width={TILE - 6} height={TILE - 6} color="#4fc3f7" borderRadius={10} />
|
|
48
|
+
<Text text={String(value)} fontSize={28} color="#0a0a0f" />
|
|
49
|
+
<HitArea onClick={onClick} width={TILE} height={TILE} />
|
|
50
|
+
</Entity>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Tiny helper: an invisible sprite that catches pointer events.
|
|
55
|
+
function HitArea({ onClick, width, height }: { onClick: () => void; width: number; height: number }) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
onClick={onClick}
|
|
59
|
+
style={{
|
|
60
|
+
position: 'absolute',
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
transform: `translate(-${width / 2}px, -${height / 2}px)`,
|
|
64
|
+
cursor: 'pointer',
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function BoardUI({ board, history }: { board: ReturnType<typeof useGrid<number>>; history: ReturnType<typeof useHistory> }) {
|
|
71
|
+
const cells: number[] = []
|
|
72
|
+
board.forEach((c) => cells.push(c.value))
|
|
73
|
+
const solved = isSolved(cells)
|
|
74
|
+
|
|
75
|
+
const tryMove = (index: number) => {
|
|
76
|
+
const ns = neighborsOfEmpty(cells)
|
|
77
|
+
if (!ns.includes(index)) return
|
|
78
|
+
const emptyIdx = cells.indexOf(EMPTY)
|
|
79
|
+
const tileRow = Math.floor(index / SIZE)
|
|
80
|
+
const tileCol = index % SIZE
|
|
81
|
+
const emptyRow = Math.floor(emptyIdx / SIZE)
|
|
82
|
+
const emptyCol = emptyIdx % SIZE
|
|
83
|
+
board.swap(tileCol, tileRow, emptyCol, emptyRow)
|
|
84
|
+
history.push()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
{cells.map((value, i) => {
|
|
90
|
+
const row = Math.floor(i / SIZE)
|
|
91
|
+
const col = i % SIZE
|
|
92
|
+
return <Tile key={`${row}-${col}`} value={value} row={row} col={col} onClick={() => tryMove(i)} />
|
|
93
|
+
})}
|
|
94
|
+
{solved && (
|
|
95
|
+
<Entity>
|
|
96
|
+
<Transform x={0} y={-BOARD_PX / 2 - 40} />
|
|
97
|
+
<Text text="Solved!" fontSize={32} color="#8bc34a" />
|
|
98
|
+
</Entity>
|
|
99
|
+
)}
|
|
100
|
+
</>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function App() {
|
|
105
|
+
return (
|
|
106
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: 24 }}>
|
|
107
|
+
<h1 style={{ color: '#e0e7f1', fontSize: 18, fontWeight: 500 }}>{`{{PROJECT_NAME}}`}</h1>
|
|
108
|
+
<Stage width={BOARD_PX + 40} height={BOARD_PX + 40}>
|
|
109
|
+
<BoardMount />
|
|
110
|
+
</Stage>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function BoardMount() {
|
|
116
|
+
// Seed the board with a scrambled solved state (1..15, 0)
|
|
117
|
+
const solved: number[] = []
|
|
118
|
+
for (let i = 1; i < SIZE * SIZE; i++) solved.push(i)
|
|
119
|
+
solved.push(EMPTY)
|
|
120
|
+
const initial = scramble(solved)
|
|
121
|
+
|
|
122
|
+
const board = useGrid<number>({
|
|
123
|
+
width: SIZE,
|
|
124
|
+
height: SIZE,
|
|
125
|
+
fill: (x, y) => initial[y * SIZE + x],
|
|
126
|
+
})
|
|
127
|
+
const history = useHistory({ bindKeyboardShortcuts: true, capacity: 200 })
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<BoardUI board={board} history={history} />
|
|
132
|
+
<Controls history={history} />
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Controls({ history }: { history: ReturnType<typeof useHistory> }) {
|
|
138
|
+
return (
|
|
139
|
+
<div style={{ position: 'absolute', bottom: 8, left: 8, right: 8, display: 'flex', gap: 8, pointerEvents: 'auto' }}>
|
|
140
|
+
<button
|
|
141
|
+
onClick={history.undo}
|
|
142
|
+
disabled={!history.canUndo}
|
|
143
|
+
style={btn}
|
|
144
|
+
>
|
|
145
|
+
Undo (Ctrl+Z)
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
onClick={history.redo}
|
|
149
|
+
disabled={!history.canRedo}
|
|
150
|
+
style={btn}
|
|
151
|
+
>
|
|
152
|
+
Redo
|
|
153
|
+
</button>
|
|
154
|
+
<span style={{ flex: 1 }} />
|
|
155
|
+
<span style={{ color: '#78909c', fontSize: 12, alignSelf: 'center' }}>
|
|
156
|
+
Moves: {history.length}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const btn: React.CSSProperties = {
|
|
163
|
+
background: 'rgba(255,255,255,0.08)',
|
|
164
|
+
border: '1px solid rgba(255,255,255,0.2)',
|
|
165
|
+
color: '#e0e7f1',
|
|
166
|
+
padding: '4px 10px',
|
|
167
|
+
borderRadius: 4,
|
|
168
|
+
fontSize: 12,
|
|
169
|
+
cursor: 'pointer',
|
|
170
|
+
fontFamily: 'monospace',
|
|
171
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
root: true,
|
|
3
|
+
env: { browser: true, es2020: true },
|
|
4
|
+
extends: [
|
|
5
|
+
'eslint:recommended',
|
|
6
|
+
'plugin:@typescript-eslint/recommended',
|
|
7
|
+
'plugin:react-hooks/recommended',
|
|
8
|
+
],
|
|
9
|
+
ignorePatterns: ['dist'],
|
|
10
|
+
parser: '@typescript-eslint/parser',
|
|
11
|
+
rules: {
|
|
12
|
+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
13
|
+
'react-hooks/exhaustive-deps': 'warn',
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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.0" />
|
|
6
|
+
<title>{{PROJECT_NAME}}</title>
|
|
7
|
+
<style>* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a0f; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: system-ui, sans-serif; }</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"lint": "eslint src --ext ts,tsx",
|
|
10
|
+
"format": "prettier --write src"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"cubeforge": "latest",
|
|
14
|
+
"react": "^18.0.0",
|
|
15
|
+
"react-dom": "^18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.0",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
21
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
23
|
+
"eslint": "^8.57.0",
|
|
24
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
25
|
+
"prettier": "^3.2.0",
|
|
26
|
+
"typescript": "^5.4.0",
|
|
27
|
+
"vite": "^5.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Stage, Entity, Transform, Sprite, Text, useTurnSystem, useHistory } from 'cubeforge'
|
|
3
|
+
|
|
4
|
+
type Cell = '' | 'X' | 'O'
|
|
5
|
+
type Board = Cell[]
|
|
6
|
+
|
|
7
|
+
const CELL = 90
|
|
8
|
+
const GRID_PX = CELL * 3
|
|
9
|
+
const LINES = [
|
|
10
|
+
[0, 1, 2],
|
|
11
|
+
[3, 4, 5],
|
|
12
|
+
[6, 7, 8],
|
|
13
|
+
[0, 3, 6],
|
|
14
|
+
[1, 4, 7],
|
|
15
|
+
[2, 5, 8],
|
|
16
|
+
[0, 4, 8],
|
|
17
|
+
[2, 4, 6],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function winner(cells: Board): Cell | null {
|
|
21
|
+
for (const [a, b, c] of LINES) {
|
|
22
|
+
if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) return cells[a]
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function BoardCell({
|
|
28
|
+
value,
|
|
29
|
+
row,
|
|
30
|
+
col,
|
|
31
|
+
disabled,
|
|
32
|
+
onClick,
|
|
33
|
+
}: {
|
|
34
|
+
value: Cell
|
|
35
|
+
row: number
|
|
36
|
+
col: number
|
|
37
|
+
disabled: boolean
|
|
38
|
+
onClick: () => void
|
|
39
|
+
}) {
|
|
40
|
+
const x = col * CELL + CELL / 2 - GRID_PX / 2
|
|
41
|
+
const y = row * CELL + CELL / 2 - GRID_PX / 2
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<Entity>
|
|
46
|
+
<Transform x={x} y={y} />
|
|
47
|
+
<Sprite width={CELL - 6} height={CELL - 6} color="#1e2a3a" borderRadius={8} />
|
|
48
|
+
{value && <Text text={value} fontSize={52} color={value === 'X' ? '#4fc3f7' : '#ef5350'} />}
|
|
49
|
+
</Entity>
|
|
50
|
+
<div
|
|
51
|
+
onClick={disabled ? undefined : onClick}
|
|
52
|
+
aria-label={`Row ${row + 1} column ${col + 1}${value ? `, ${value}` : ', empty'}`}
|
|
53
|
+
style={{
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
left: '50%',
|
|
56
|
+
top: '50%',
|
|
57
|
+
width: CELL,
|
|
58
|
+
height: CELL,
|
|
59
|
+
transform: `translate(${x - CELL / 2}px, ${y - CELL / 2}px)`,
|
|
60
|
+
cursor: disabled || value ? 'default' : 'pointer',
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Game() {
|
|
68
|
+
const [cells, setCells] = useState<Board>(Array(9).fill(''))
|
|
69
|
+
const history = useHistory({ bindKeyboardShortcuts: true })
|
|
70
|
+
const turns = useTurnSystem<'X' | 'O'>({
|
|
71
|
+
players: ['X', 'O'],
|
|
72
|
+
onTurnStart: ({ player, turn }) => {
|
|
73
|
+
if (turn > 0) {
|
|
74
|
+
// eslint-disable-next-line no-console
|
|
75
|
+
console.log(`${player}'s turn`)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const win = winner(cells)
|
|
81
|
+
const full = cells.every((c) => c !== '')
|
|
82
|
+
const done = win !== null || full
|
|
83
|
+
|
|
84
|
+
const onCell = (index: number) => {
|
|
85
|
+
if (done || cells[index]) return
|
|
86
|
+
const next = cells.slice() as Board
|
|
87
|
+
next[index] = turns.activePlayer
|
|
88
|
+
setCells(next)
|
|
89
|
+
history.push()
|
|
90
|
+
if (!winner(next) && next.some((c) => c === '')) turns.nextTurn()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const reset = () => {
|
|
94
|
+
setCells(Array(9).fill(''))
|
|
95
|
+
turns.reset()
|
|
96
|
+
history.clear()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
|
101
|
+
<h1 style={{ color: '#e0e7f1', fontSize: 18, fontWeight: 500 }}>{`{{PROJECT_NAME}}`}</h1>
|
|
102
|
+
<div style={{ color: '#78909c', fontSize: 14, fontFamily: 'monospace', height: 18 }}>
|
|
103
|
+
{win ? `${win} wins!` : full ? 'Draw' : `Turn: ${turns.activePlayer}`}
|
|
104
|
+
</div>
|
|
105
|
+
<div style={{ position: 'relative' }}>
|
|
106
|
+
<Stage width={GRID_PX + 20} height={GRID_PX + 20}>
|
|
107
|
+
{cells.map((value, i) => (
|
|
108
|
+
<BoardCell
|
|
109
|
+
key={i}
|
|
110
|
+
value={value}
|
|
111
|
+
row={Math.floor(i / 3)}
|
|
112
|
+
col={i % 3}
|
|
113
|
+
disabled={done}
|
|
114
|
+
onClick={() => onCell(i)}
|
|
115
|
+
/>
|
|
116
|
+
))}
|
|
117
|
+
</Stage>
|
|
118
|
+
</div>
|
|
119
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
120
|
+
<button onClick={reset} style={btn}>Reset</button>
|
|
121
|
+
<button onClick={history.undo} disabled={!history.canUndo} style={btn}>Undo (Ctrl+Z)</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function App() {
|
|
128
|
+
return (
|
|
129
|
+
<div style={{ padding: 24 }}>
|
|
130
|
+
<Game />
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const btn: React.CSSProperties = {
|
|
136
|
+
background: 'rgba(255,255,255,0.08)',
|
|
137
|
+
border: '1px solid rgba(255,255,255,0.2)',
|
|
138
|
+
color: '#e0e7f1',
|
|
139
|
+
padding: '4px 10px',
|
|
140
|
+
borderRadius: 4,
|
|
141
|
+
fontSize: 12,
|
|
142
|
+
cursor: 'pointer',
|
|
143
|
+
fontFamily: 'monospace',
|
|
144
|
+
}
|