create-cubeforge-game 0.5.0 → 0.5.2

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 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 = process.argv[2];
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", "default");
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,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cubeforge-game",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Scaffold a new Cubeforge game project",
5
5
  "bin": {
6
6
  "create-cubeforge-game": "./bin/index.js"
@@ -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 TEMPLATE_DIR = resolve(__dirname, '../default')
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('project template', () => {
21
- const templateFiles = collectTemplateFiles(TEMPLATE_DIR)
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
- expect(templateFiles.length).toBeGreaterThan(0)
30
+ const files = collectTemplateFiles(dir)
31
+ expect(files.length).toBeGreaterThan(0)
25
32
  })
26
33
 
27
- it.each(templateFiles.map((f) => [f.replace(TEMPLATE_DIR + '/', ''), f]))(
28
- '%s exists and is non-empty',
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(TEMPLATE_DIR, 'package.json.template')
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(TEMPLATE_DIR, 'package.json.template')
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(TEMPLATE_DIR, '.eslintrc.cjs.template'))).toBe(true)
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(TEMPLATE_DIR, '.prettierrc.template'))).toBe(true)
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,7 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "tabWidth": 2,
6
+ "printWidth": 100
7
+ }
@@ -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,8 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { App } from './App'
4
+ createRoot(document.getElementById('root')!).render(
5
+ <StrictMode>
6
+ <App />
7
+ </StrictMode>,
8
+ )
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "jsx": "react-jsx"
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ export default defineConfig({ plugins: [react()] })
@@ -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,7 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "tabWidth": 2,
6
+ "printWidth": 100
7
+ }
@@ -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,8 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { App } from './App'
4
+ createRoot(document.getElementById('root')!).render(
5
+ <StrictMode>
6
+ <App />
7
+ </StrictMode>,
8
+ )
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "jsx": "react-jsx"
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ export default defineConfig({ plugins: [react()] })
@@ -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,7 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "tabWidth": 2,
6
+ "printWidth": 100
7
+ }
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { App } from './App'
4
+ createRoot(document.getElementById('root')!).render(
5
+ <StrictMode>
6
+ <App />
7
+ </StrictMode>,
8
+ )
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "jsx": "react-jsx"
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ export default defineConfig({ plugins: [react()] })