create-gamenative-app 0.1.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/README.md +116 -0
- package/bin/create.js +255 -0
- package/create-gamenative-app/README.md +33 -0
- package/create-gamenative-app/index.js +107 -0
- package/create-gamenative-app/package.json +33 -0
- package/create-gamenative-app/sync-template.js +177 -0
- package/create-gamenative-app/template/FRAMEWORK.md +14 -0
- package/create-gamenative-app/template/README.md +10 -0
- package/create-gamenative-app/template/assets/icon.png +0 -0
- package/create-gamenative-app/template/game.config.json +9 -0
- package/create-gamenative-app/template/package.json +39 -0
- package/create-gamenative-app/template/scripts/copy-config.js +8 -0
- package/create-gamenative-app/template/src/Game.ts +97 -0
- package/create-gamenative-app/template/src/Graphics.ts +93 -0
- package/create-gamenative-app/template/src/Input.ts +83 -0
- package/create-gamenative-app/template/src/config.ts +25 -0
- package/create-gamenative-app/template/src/games/Game.ts +32 -0
- package/create-gamenative-app/template/src/icon.ts +19 -0
- package/create-gamenative-app/template/src/index.ts +6 -0
- package/create-gamenative-app/template/src/main.ts +39 -0
- package/create-gamenative-app/template/src/pngjs.d.ts +5 -0
- package/create-gamenative-app/template/src/types.ts +35 -0
- package/create-gamenative-app/template/tsconfig.json +23 -0
- package/example.ts +45 -0
- package/game.config.json +9 -0
- package/package.json +47 -0
- package/scripts/copy-config.js +8 -0
- package/src/Game.ts +97 -0
- package/src/Graphics.ts +93 -0
- package/src/Input.ts +83 -0
- package/src/assets/icon.png +0 -0
- package/src/config.ts +25 -0
- package/src/games/ExampleGame.ts +49 -0
- package/src/icon.ts +19 -0
- package/src/index.ts +6 -0
- package/src/main.ts +39 -0
- package/src/pngjs.d.ts +5 -0
- package/src/types.ts +35 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sync template from parent GameNative repo. Run from create-gamenative-app/ or before publish.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, writeFile, copyFile } from 'fs/promises'
|
|
6
|
+
import { join, dirname } from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const ROOT = join(__dirname, '..')
|
|
11
|
+
const TEMPLATE = join(__dirname, 'template')
|
|
12
|
+
|
|
13
|
+
const FRAMEWORK_FILES = [
|
|
14
|
+
'src/main.ts',
|
|
15
|
+
'src/config.ts',
|
|
16
|
+
'src/Game.ts',
|
|
17
|
+
'src/Input.ts',
|
|
18
|
+
'src/Graphics.ts',
|
|
19
|
+
'src/types.ts',
|
|
20
|
+
'src/index.ts',
|
|
21
|
+
'src/icon.ts',
|
|
22
|
+
'src/pngjs.d.ts',
|
|
23
|
+
'scripts/copy-config.js',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
async function sync() {
|
|
27
|
+
await mkdir(join(TEMPLATE, 'src', 'games'), { recursive: true })
|
|
28
|
+
await mkdir(join(TEMPLATE, 'scripts'), { recursive: true })
|
|
29
|
+
await mkdir(join(TEMPLATE, 'assets'), { recursive: true })
|
|
30
|
+
|
|
31
|
+
for (const rel of FRAMEWORK_FILES) {
|
|
32
|
+
const src = join(ROOT, rel)
|
|
33
|
+
const dest = join(TEMPLATE, rel)
|
|
34
|
+
const content = await readFile(src, 'utf-8')
|
|
35
|
+
await writeFile(dest, content)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const gameTemplate = `import { createDraw2D, SCANCODE } from '../index.js'
|
|
39
|
+
import type { GameContext, IGame } from '../types.js'
|
|
40
|
+
|
|
41
|
+
interface Game extends IGame {
|
|
42
|
+
ctx: GameContext | null
|
|
43
|
+
draw2d: ReturnType<typeof createDraw2D> | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const game: Game = {
|
|
47
|
+
ctx: null,
|
|
48
|
+
draw2d: null,
|
|
49
|
+
|
|
50
|
+
async init(ctx: GameContext) {
|
|
51
|
+
this.ctx = ctx
|
|
52
|
+
this.draw2d = createDraw2D(ctx.gl)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
update(dt: number) {
|
|
56
|
+
if (this.ctx?.input.isKeyDown(SCANCODE.Escape)) process.exit(0)
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
draw() {
|
|
60
|
+
this.draw2d?.clear(0.08, 0.08, 0.12, 1)
|
|
61
|
+
this.draw2d?.fillRect(100, 100, 120, 80, 0.2, 0.5, 0.9, 1)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
dispose() {
|
|
65
|
+
this.draw2d?.dispose()
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default game
|
|
70
|
+
`
|
|
71
|
+
await writeFile(join(TEMPLATE, 'src', 'games', 'Game.ts'), gameTemplate)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await copyFile(join(ROOT, 'src', 'assets', 'icon.png'), join(TEMPLATE, 'assets', 'icon.png'))
|
|
75
|
+
} catch {}
|
|
76
|
+
|
|
77
|
+
const tsconfig = {
|
|
78
|
+
compilerOptions: {
|
|
79
|
+
target: 'ES2022',
|
|
80
|
+
module: 'NodeNext',
|
|
81
|
+
moduleResolution: 'NodeNext',
|
|
82
|
+
outDir: 'dist',
|
|
83
|
+
rootDir: '.',
|
|
84
|
+
strict: true,
|
|
85
|
+
declaration: true,
|
|
86
|
+
declarationMap: true,
|
|
87
|
+
sourceMap: true,
|
|
88
|
+
skipLibCheck: true,
|
|
89
|
+
esModuleInterop: true,
|
|
90
|
+
forceConsistentCasingInFileNames: true,
|
|
91
|
+
},
|
|
92
|
+
include: ['src/**/*.ts'],
|
|
93
|
+
exclude: ['node_modules', 'dist'],
|
|
94
|
+
}
|
|
95
|
+
await writeFile(join(TEMPLATE, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
|
|
96
|
+
|
|
97
|
+
const packageJson = {
|
|
98
|
+
name: '{{NAME}}',
|
|
99
|
+
version: '0.1.0',
|
|
100
|
+
description: 'Game built with GameNative',
|
|
101
|
+
type: 'module',
|
|
102
|
+
main: 'dist/main.js',
|
|
103
|
+
scripts: {
|
|
104
|
+
dev: 'cross-env NODE_ENV=development tsx src/main.ts',
|
|
105
|
+
build: 'tsc',
|
|
106
|
+
start: 'node dist/main.js',
|
|
107
|
+
'build:exe': 'npm run build && node scripts/copy-config.js && pkg . --output release/{{NAME}}.exe',
|
|
108
|
+
},
|
|
109
|
+
dependencies: {
|
|
110
|
+
'@kmamal/gl': '^9.1.0',
|
|
111
|
+
'@kmamal/sdl': '^0.11.13',
|
|
112
|
+
pngjs: '^7.0.0',
|
|
113
|
+
},
|
|
114
|
+
devDependencies: {
|
|
115
|
+
'@types/node': '^22.10.0',
|
|
116
|
+
'cross-env': '^7.0.3',
|
|
117
|
+
pkg: '^5.8.1',
|
|
118
|
+
tsx: '^4.19.2',
|
|
119
|
+
typescript: '^5.7.0',
|
|
120
|
+
},
|
|
121
|
+
engines: { node: '>=18' },
|
|
122
|
+
pkg: {
|
|
123
|
+
scripts: 'dist/main.js',
|
|
124
|
+
assets: ['node_modules/@kmamal/sdl/**', 'node_modules/@kmamal/gl/**', 'dist/games/**'],
|
|
125
|
+
targets: ['node18-win-x64'],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
await writeFile(join(TEMPLATE, 'package.json'), JSON.stringify(packageJson, null, 2))
|
|
129
|
+
|
|
130
|
+
const gameConfig = {
|
|
131
|
+
window: {
|
|
132
|
+
title: '{{TITLE}}',
|
|
133
|
+
width: 800,
|
|
134
|
+
height: 600,
|
|
135
|
+
icon: 'assets/icon.png',
|
|
136
|
+
},
|
|
137
|
+
game: 'Game',
|
|
138
|
+
}
|
|
139
|
+
await writeFile(join(TEMPLATE, 'game.config.json'), JSON.stringify(gameConfig, null, 2))
|
|
140
|
+
|
|
141
|
+
const readme = `# {{TITLE}}
|
|
142
|
+
|
|
143
|
+
Game built with [GameNative](https://github.com/your-org/GameNative).
|
|
144
|
+
|
|
145
|
+
- \`npm run dev\` — run from source
|
|
146
|
+
- \`npm run build\` then \`npm start\` — run built app
|
|
147
|
+
- Edit \`game.config.json\` for window title, size, icon
|
|
148
|
+
- Edit \`src/games/Game.ts\` to build your game
|
|
149
|
+
|
|
150
|
+
See FRAMEWORK.md for 2D, 3D, lighting, sound, UI, and camera.
|
|
151
|
+
`
|
|
152
|
+
await writeFile(join(TEMPLATE, 'README.md'), readme)
|
|
153
|
+
|
|
154
|
+
const frameworkMd = `# GameNative capabilities
|
|
155
|
+
|
|
156
|
+
Your game has access to:
|
|
157
|
+
|
|
158
|
+
- **2D**: \`createDraw2D(ctx.gl)\` — \`clear()\`, \`fillRect()\`. Use \`ctx.gl\` (WebGL 1) for textures, sprites, batching.
|
|
159
|
+
- **3D**: Use \`ctx.gl\` directly: buffers, shaders, matrices. Implement camera (view/projection), meshes, and lighting in shaders.
|
|
160
|
+
- **Lighting**: In 3D shaders use uniforms for light position/color; in 2D use tint or custom fragment shaders.
|
|
161
|
+
- **Assets**: Load images (decode PNG with pngjs or similar), upload to \`ctx.gl\` textures; load audio (see sound).
|
|
162
|
+
- **Sound**: \`ctx.sdl.audio\` (SDL audio) — open device, enqueue buffers. Or add a small wrapper in your game.
|
|
163
|
+
- **Visuals**: Full WebGL — post-process by rendering to framebuffer, then to screen; particles, blur, etc. in shaders.
|
|
164
|
+
- **UI**: Draw quads with \`fillRect\` or textured quads; track \`ctx.input.mouseX/Y\` and \`mouseLeft\` for clicks; implement panels/buttons in \`update\`/\`draw\`.
|
|
165
|
+
- **Camera**: 2D: store \`offsetX, offsetY, scale\` and pass to your draw calls or a uniform. 3D: \`view\` and \`projection\` matrices from position/target/up and perspective.
|
|
166
|
+
|
|
167
|
+
All of this is done in your \`Game.ts\` using \`ctx.gl\`, \`ctx.input\`, and optional helpers you add.
|
|
168
|
+
`
|
|
169
|
+
await writeFile(join(TEMPLATE, 'FRAMEWORK.md'), frameworkMd)
|
|
170
|
+
|
|
171
|
+
console.log('Template synced to create-gamenative-app/template/')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sync().catch((err) => {
|
|
175
|
+
console.error(err)
|
|
176
|
+
process.exit(1)
|
|
177
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# GameNative capabilities
|
|
2
|
+
|
|
3
|
+
Your game has access to:
|
|
4
|
+
|
|
5
|
+
- **2D**: `createDraw2D(ctx.gl)` — `clear()`, `fillRect()`. Use `ctx.gl` (WebGL 1) for textures, sprites, batching.
|
|
6
|
+
- **3D**: Use `ctx.gl` directly: buffers, shaders, matrices. Implement camera (view/projection), meshes, and lighting in shaders.
|
|
7
|
+
- **Lighting**: In 3D shaders use uniforms for light position/color; in 2D use tint or custom fragment shaders.
|
|
8
|
+
- **Assets**: Load images (decode PNG with pngjs or similar), upload to `ctx.gl` textures; load audio (see sound).
|
|
9
|
+
- **Sound**: `ctx.sdl.audio` (SDL audio) — open device, enqueue buffers. Or add a small wrapper in your game.
|
|
10
|
+
- **Visuals**: Full WebGL — post-process by rendering to framebuffer, then to screen; particles, blur, etc. in shaders.
|
|
11
|
+
- **UI**: Draw quads with `fillRect` or textured quads; track `ctx.input.mouseX/Y` and `mouseLeft` for clicks; implement panels/buttons in `update`/`draw`.
|
|
12
|
+
- **Camera**: 2D: store `offsetX, offsetY, scale` and pass to your draw calls or a uniform. 3D: `view` and `projection` matrices from position/target/up and perspective.
|
|
13
|
+
|
|
14
|
+
All of this is done in your `Game.ts` using `ctx.gl`, `ctx.input`, and optional helpers you add.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# {{TITLE}}
|
|
2
|
+
|
|
3
|
+
Game built with [GameNative](https://github.com/your-org/GameNative).
|
|
4
|
+
|
|
5
|
+
- `npm run dev` — run from source
|
|
6
|
+
- `npm run build` then `npm start` — run built app
|
|
7
|
+
- Edit `game.config.json` for window title, size, icon
|
|
8
|
+
- Edit `src/games/Game.ts` to build your game
|
|
9
|
+
|
|
10
|
+
See FRAMEWORK.md for 2D, 3D, lighting, sound, UI, and camera.
|
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Game built with GameNative",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/main.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "cross-env NODE_ENV=development tsx src/main.ts",
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"start": "node dist/main.js",
|
|
11
|
+
"build:exe": "npm run build && node scripts/copy-config.js && pkg . --output release/{{NAME}}.exe"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@kmamal/gl": "^9.1.0",
|
|
15
|
+
"@kmamal/sdl": "^0.11.13",
|
|
16
|
+
"pngjs": "^7.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.10.0",
|
|
20
|
+
"cross-env": "^7.0.3",
|
|
21
|
+
"pkg": "^5.8.1",
|
|
22
|
+
"tsx": "^4.19.2",
|
|
23
|
+
"typescript": "^5.7.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"pkg": {
|
|
29
|
+
"scripts": "dist/main.js",
|
|
30
|
+
"assets": [
|
|
31
|
+
"node_modules/@kmamal/sdl/**",
|
|
32
|
+
"node_modules/@kmamal/gl/**",
|
|
33
|
+
"dist/games/**"
|
|
34
|
+
],
|
|
35
|
+
"targets": [
|
|
36
|
+
"node18-win-x64"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { mkdirSync, copyFileSync, existsSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
const release = join(process.cwd(), 'release')
|
|
4
|
+
mkdirSync(release, { recursive: true })
|
|
5
|
+
const src = join(process.cwd(), 'game.config.json')
|
|
6
|
+
if (existsSync(src)) {
|
|
7
|
+
copyFileSync(src, join(release, 'game.config.json'))
|
|
8
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import sdl from '@kmamal/sdl'
|
|
2
|
+
import glPkg from '@kmamal/gl'
|
|
3
|
+
import type { WebGLRenderingContext as GLContext } from '@kmamal/gl'
|
|
4
|
+
import type { IGame, GameConfig, GameContext } from './types.js'
|
|
5
|
+
import { createInput } from './Input.js'
|
|
6
|
+
|
|
7
|
+
type CreateGL = (width: number, height: number, attrs?: { window?: unknown }) => GLContext
|
|
8
|
+
const createGLContext = (typeof glPkg === 'function' ? glPkg : (glPkg as { default: CreateGL }).default) as CreateGL
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG: Required<Omit<GameConfig, 'icon'>> & Pick<GameConfig, 'icon'> = {
|
|
11
|
+
title: 'Game',
|
|
12
|
+
width: 800,
|
|
13
|
+
height: 600,
|
|
14
|
+
vsync: true,
|
|
15
|
+
resizable: true,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs an IGame: creates SDL window + WebGL context, drives update/draw loop, handles resize and close.
|
|
20
|
+
*/
|
|
21
|
+
export async function run(game: IGame, config: GameConfig = {}): Promise<void> {
|
|
22
|
+
const opts = { ...DEFAULT_CONFIG, ...config }
|
|
23
|
+
const window = sdl.video.createWindow({
|
|
24
|
+
title: opts.title,
|
|
25
|
+
width: opts.width,
|
|
26
|
+
height: opts.height,
|
|
27
|
+
resizable: opts.resizable,
|
|
28
|
+
vsync: opts.vsync,
|
|
29
|
+
opengl: true,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if (opts.icon) {
|
|
33
|
+
try {
|
|
34
|
+
const { loadIcon } = await import('./icon.js')
|
|
35
|
+
await loadIcon(window as { setIcon: (w: number, h: number, stride: number, format: string, buffer: Buffer) => void }, opts.icon)
|
|
36
|
+
} catch {
|
|
37
|
+
// icon optional; ignore if loadIcon or file fails
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let width = window.pixelWidth
|
|
42
|
+
let height = window.pixelHeight
|
|
43
|
+
const gl = createGLContext(width, height, { window: window.native })
|
|
44
|
+
const input = createInput(sdl)
|
|
45
|
+
|
|
46
|
+
const ctx: GameContext = {
|
|
47
|
+
gl,
|
|
48
|
+
input,
|
|
49
|
+
get width() {
|
|
50
|
+
return width
|
|
51
|
+
},
|
|
52
|
+
get height() {
|
|
53
|
+
return height
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (game.init) {
|
|
58
|
+
await game.init(ctx, { ...opts, width, height })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
window.on('resize', (e: { pixelWidth: number; pixelHeight: number }) => {
|
|
62
|
+
width = e.pixelWidth
|
|
63
|
+
height = e.pixelHeight
|
|
64
|
+
gl.viewport(0, 0, width, height)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
let running = true
|
|
68
|
+
window.on('close', () => {
|
|
69
|
+
running = false
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
let lastTime = performance.now()
|
|
73
|
+
const maxDt = 0.1
|
|
74
|
+
|
|
75
|
+
function loop() {
|
|
76
|
+
if (!running) {
|
|
77
|
+
if (game.dispose) game.dispose()
|
|
78
|
+
window.destroy()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const now = performance.now()
|
|
82
|
+
let dt = (now - lastTime) / 1000
|
|
83
|
+
lastTime = now
|
|
84
|
+
if (dt > maxDt) dt = maxDt
|
|
85
|
+
|
|
86
|
+
game.update(dt)
|
|
87
|
+
game.draw()
|
|
88
|
+
gl.swap()
|
|
89
|
+
setImmediate(loop)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setImmediate(loop)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type { IGame, GameConfig }
|
|
96
|
+
export { sdl, createInput }
|
|
97
|
+
export { SCANCODE } from './Input.js'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { WebGLRenderingContext as GLContext } from '@kmamal/gl'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal 2D helpers: clear, and draw solid-color quads with a simple shader.
|
|
5
|
+
* For anything else use the raw WebGL context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const VERT = `
|
|
9
|
+
attribute vec2 a_position;
|
|
10
|
+
uniform vec2 u_resolution;
|
|
11
|
+
void main() {
|
|
12
|
+
vec2 clip = (a_position / u_resolution) * 2.0 - 1.0;
|
|
13
|
+
gl_Position = vec4(clip * vec2(1, -1), 0, 1);
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const FRAG = `
|
|
18
|
+
precision mediump float;
|
|
19
|
+
uniform vec4 u_color;
|
|
20
|
+
void main() {
|
|
21
|
+
gl_FragColor = u_color;
|
|
22
|
+
}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
export interface Draw2D {
|
|
26
|
+
clear(r: number, g: number, b: number, a?: number): void
|
|
27
|
+
fillRect(x: number, y: number, w: number, h: number, r: number, g: number, b: number, a?: number): void
|
|
28
|
+
dispose(): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createDraw2D(gl: GLContext): Draw2D {
|
|
32
|
+
const vs = gl.createShader(gl.VERTEX_SHADER)!
|
|
33
|
+
gl.shaderSource(vs, VERT)
|
|
34
|
+
gl.compileShader(vs)
|
|
35
|
+
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
|
|
36
|
+
throw new Error('vert: ' + gl.getShaderInfoLog(vs))
|
|
37
|
+
}
|
|
38
|
+
const fs = gl.createShader(gl.FRAGMENT_SHADER)!
|
|
39
|
+
gl.shaderSource(fs, FRAG)
|
|
40
|
+
gl.compileShader(fs)
|
|
41
|
+
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
|
|
42
|
+
throw new Error('frag: ' + gl.getShaderInfoLog(fs))
|
|
43
|
+
}
|
|
44
|
+
const program = gl.createProgram()!
|
|
45
|
+
gl.attachShader(program, vs)
|
|
46
|
+
gl.attachShader(program, fs)
|
|
47
|
+
gl.linkProgram(program)
|
|
48
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
49
|
+
throw new Error('link: ' + gl.getProgramInfoLog(program))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const aPosition = gl.getAttribLocation(program, 'a_position')
|
|
53
|
+
const uResolution = gl.getUniformLocation(program, 'u_resolution')
|
|
54
|
+
const uColor = gl.getUniformLocation(program, 'u_color')
|
|
55
|
+
|
|
56
|
+
const buffer = gl.createBuffer()
|
|
57
|
+
const verts = new Float32Array(8)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
clear(r: number, g: number, b: number, a = 1) {
|
|
61
|
+
gl.clearColor(r, g, b, a)
|
|
62
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
fillRect(x: number, y: number, w: number, h: number, r: number, g: number, b: number, a = 1) {
|
|
66
|
+
const x2 = x + w
|
|
67
|
+
const y2 = y + h
|
|
68
|
+
verts[0] = x
|
|
69
|
+
verts[1] = y
|
|
70
|
+
verts[2] = x2
|
|
71
|
+
verts[3] = y
|
|
72
|
+
verts[4] = x
|
|
73
|
+
verts[5] = y2
|
|
74
|
+
verts[6] = x2
|
|
75
|
+
verts[7] = y2
|
|
76
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
|
77
|
+
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.DYNAMIC_DRAW)
|
|
78
|
+
gl.useProgram(program)
|
|
79
|
+
gl.uniform2f(uResolution, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
|
80
|
+
gl.uniform4f(uColor, r, g, b, a)
|
|
81
|
+
gl.enableVertexAttribArray(aPosition)
|
|
82
|
+
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)
|
|
83
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
dispose() {
|
|
87
|
+
gl.deleteProgram(program)
|
|
88
|
+
gl.deleteShader(vs)
|
|
89
|
+
gl.deleteShader(fs)
|
|
90
|
+
gl.deleteBuffer(buffer)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input state: keyboard and mouse. Read each frame in update().
|
|
3
|
+
* Keys use SDL scancode names; common ones: W, A, S, D, Up, Down, Left, Right, Space, Escape, etc.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
type SDL = any
|
|
8
|
+
|
|
9
|
+
export interface InputState {
|
|
10
|
+
readonly mouseX: number
|
|
11
|
+
readonly mouseY: number
|
|
12
|
+
readonly mouseLeft: boolean
|
|
13
|
+
readonly mouseRight: boolean
|
|
14
|
+
readonly mouseMiddle: boolean
|
|
15
|
+
isKeyDown(scancode: number): boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createInput(sdl: SDL): InputState {
|
|
19
|
+
const keyboard = sdl.keyboard
|
|
20
|
+
const mouse = sdl.mouse
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
get mouseX() {
|
|
24
|
+
return mouse.position.x
|
|
25
|
+
},
|
|
26
|
+
get mouseY() {
|
|
27
|
+
return mouse.position.y
|
|
28
|
+
},
|
|
29
|
+
get mouseLeft() {
|
|
30
|
+
return mouse.getButton(1)
|
|
31
|
+
},
|
|
32
|
+
get mouseRight() {
|
|
33
|
+
return mouse.getButton(3)
|
|
34
|
+
},
|
|
35
|
+
get mouseMiddle() {
|
|
36
|
+
return mouse.getButton(2)
|
|
37
|
+
},
|
|
38
|
+
isKeyDown(scancode: number) {
|
|
39
|
+
const state = keyboard.getState()
|
|
40
|
+
return !!(Array.isArray(state) ? state[scancode] : (state as { [k: number]: boolean })[scancode])
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** SDL scancode constants for use with input.isKeyDown(sdl.SCANCODE.X). Use the sdl instance from run(). */
|
|
46
|
+
export const SCANCODE = {
|
|
47
|
+
A: 4,
|
|
48
|
+
B: 5,
|
|
49
|
+
C: 6,
|
|
50
|
+
D: 7,
|
|
51
|
+
E: 8,
|
|
52
|
+
F: 9,
|
|
53
|
+
G: 10,
|
|
54
|
+
H: 11,
|
|
55
|
+
I: 12,
|
|
56
|
+
J: 13,
|
|
57
|
+
K: 14,
|
|
58
|
+
L: 15,
|
|
59
|
+
M: 16,
|
|
60
|
+
N: 17,
|
|
61
|
+
O: 18,
|
|
62
|
+
P: 19,
|
|
63
|
+
Q: 20,
|
|
64
|
+
R: 21,
|
|
65
|
+
S: 22,
|
|
66
|
+
T: 23,
|
|
67
|
+
U: 24,
|
|
68
|
+
V: 25,
|
|
69
|
+
W: 26,
|
|
70
|
+
X: 27,
|
|
71
|
+
Y: 28,
|
|
72
|
+
Z: 29,
|
|
73
|
+
Space: 44,
|
|
74
|
+
Escape: 41,
|
|
75
|
+
Up: 82,
|
|
76
|
+
Down: 81,
|
|
77
|
+
Left: 80,
|
|
78
|
+
Right: 79,
|
|
79
|
+
Enter: 40,
|
|
80
|
+
Shift: 225,
|
|
81
|
+
Ctrl: 224,
|
|
82
|
+
Tab: 43,
|
|
83
|
+
} as const
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
export interface AppConfig {
|
|
5
|
+
window?: {
|
|
6
|
+
title?: string
|
|
7
|
+
width?: number
|
|
8
|
+
height?: number
|
|
9
|
+
icon?: string
|
|
10
|
+
}
|
|
11
|
+
/** Game name (no extension). Resolved to src/games/<game>.ts in dev, dist/games/<game>.js in prod. */
|
|
12
|
+
game?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CONFIG_FILENAME = 'game.config.json'
|
|
16
|
+
|
|
17
|
+
export async function loadAppConfig(): Promise<AppConfig> {
|
|
18
|
+
const path = join(process.cwd(), CONFIG_FILENAME)
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(path, 'utf-8')
|
|
21
|
+
return JSON.parse(raw) as AppConfig
|
|
22
|
+
} catch {
|
|
23
|
+
return {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createDraw2D, SCANCODE } from '../index.js'
|
|
2
|
+
import type { GameContext, IGame } from '../types.js'
|
|
3
|
+
|
|
4
|
+
interface Game extends IGame {
|
|
5
|
+
ctx: GameContext | null
|
|
6
|
+
draw2d: ReturnType<typeof createDraw2D> | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const game: Game = {
|
|
10
|
+
ctx: null,
|
|
11
|
+
draw2d: null,
|
|
12
|
+
|
|
13
|
+
async init(ctx: GameContext) {
|
|
14
|
+
this.ctx = ctx
|
|
15
|
+
this.draw2d = createDraw2D(ctx.gl)
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
update(dt: number) {
|
|
19
|
+
if (this.ctx?.input.isKeyDown(SCANCODE.Escape)) process.exit(0)
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
draw() {
|
|
23
|
+
this.draw2d?.clear(0.08, 0.08, 0.12, 1)
|
|
24
|
+
this.draw2d?.fillRect(100, 100, 120, 80, 0.2, 0.5, 0.9, 1)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
dispose() {
|
|
28
|
+
this.draw2d?.dispose()
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default game
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load a PNG from path and set it as the window icon. Path can be absolute or relative to cwd.
|
|
6
|
+
*/
|
|
7
|
+
export async function loadIcon(
|
|
8
|
+
window: { setIcon: (w: number, h: number, stride: number, format: string, buffer: Buffer) => void },
|
|
9
|
+
iconPath: string
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const pngjs = await import('pngjs')
|
|
12
|
+
const PNG = pngjs.default?.PNG ?? pngjs.PNG
|
|
13
|
+
const abs = join(process.cwd(), iconPath)
|
|
14
|
+
const buf = await readFile(abs)
|
|
15
|
+
const png = PNG.sync.read(buf)
|
|
16
|
+
const stride = png.width * 4
|
|
17
|
+
const buffer = Buffer.from(png.data)
|
|
18
|
+
window.setIcon(png.width, png.height, stride, 'rgba32', buffer)
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { run, sdl, createInput, SCANCODE } from './Game.js'
|
|
2
|
+
export type { IGame, GameConfig } from './Game.js'
|
|
3
|
+
export type { GameContext } from './types.js'
|
|
4
|
+
export type { InputState } from './Input.js'
|
|
5
|
+
export { createDraw2D } from './Graphics.js'
|
|
6
|
+
export type { Draw2D } from './Graphics.js'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { pathToFileURL } from 'url'
|
|
2
|
+
import { join, dirname } from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { run } from './Game.js'
|
|
5
|
+
import { loadAppConfig } from './config.js'
|
|
6
|
+
import type { IGame } from './types.js'
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const appConfig = await loadAppConfig()
|
|
12
|
+
const gameName = appConfig.game ?? 'ExampleGame'
|
|
13
|
+
const isDev = process.env.NODE_ENV === 'development'
|
|
14
|
+
const isPkg = typeof (process as { pkg?: unknown }).pkg !== 'undefined'
|
|
15
|
+
const gamePath =
|
|
16
|
+
isPkg || !isDev
|
|
17
|
+
? join(__dirname, 'games', `${gameName}.js`)
|
|
18
|
+
: join(process.cwd(), 'src', 'games', `${gameName}.ts`)
|
|
19
|
+
const href = pathToFileURL(gamePath).href
|
|
20
|
+
|
|
21
|
+
const mod = await import(/* @vite-ignore */ href)
|
|
22
|
+
const game: IGame = mod.default
|
|
23
|
+
if (!game || typeof game.update !== 'function' || typeof game.draw !== 'function') {
|
|
24
|
+
throw new Error(`Game "${gameName}" must export a default IGame (update/draw)`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const windowOpts = appConfig.window ?? {}
|
|
28
|
+
await run(game, {
|
|
29
|
+
title: windowOpts.title ?? 'Game',
|
|
30
|
+
width: windowOpts.width ?? 800,
|
|
31
|
+
height: windowOpts.height ?? 600,
|
|
32
|
+
icon: windowOpts.icon,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main().catch((err) => {
|
|
37
|
+
console.error(err)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
})
|