Browse Source

add CLI publish/asset/delete commands, rewrite README with config-file support

LoganZ2 3 ngày trước cách đây
mục cha
commit
1050acd691
5 tập tin đã thay đổi với 615 bổ sung103 xóa
  1. 376 101
      README.md
  2. 223 0
      cli.js
  3. 9 2
      game-sdk.d.ts
  4. 3 0
      game-sdk.js
  5. 4 0
      package.json

+ 376 - 101
README.md

@@ -1,58 +1,65 @@
 # Game SDK
 
-平台在加载游戏前会自动向页面注入 `window.GameSDK`,游戏里直接调用即可,无需任何初始化。
+这个包同时提供两部分能力:
 
----
+1. 浏览器运行时的全局 `GameSDK` 类型定义。
+2. 发布到正式平台的 CLI 命令:`game-sdk publish`、`game-sdk asset`、`game-sdk delete`。
 
-## 安装
+目标用法很简单:
 
-```bash
-npm install git+https://gitee.com/personal-site-micro/game-sdk.git
-```
+1. 游戏项目安装这个包。
+2. 游戏代码里直接使用全局 `GameSDK`。
+3. `package.json` 里写 `npm run build` 和 `npm run publish`。
+4. 用 `npm run publish` 发布到正式平台。
 
-安装后 TypeScript 自动识别 `GameSDK` 全局类型,无需手动 import
+这个 SDK 只面向正式平台发布,不支持本地部署平台服务
 
----
+## 快速开始
 
-## API
+### 1. 安装
 
-```ts
-GameSDK.gameId                      // string — 当前游戏的 id
-GameSDK.getPlayerName()             // string — 当前玩家昵称(来自平台 localStorage)
-GameSDK.submit(score: number)       // Promise — 提交分数
+如果当前还没发到 npm,直接用 git 安装:
+
+```bash
+npm install git+https://gitee.com/personal-site-micro/game-sdk.git
 ```
 
-`submit` 返回值
+再安装打包工具,例如
 
-```ts
-{ ok: true;  rank: number  }   // 成功,rank 为当前排名
-{ ok: false; error: string }   // 失败
+```bash
+npm install --save-dev esbuild
 ```
 
----
-
-## 开发一个游戏
+### 2. 配置脚本
 
-### 1. 初始化项目
+`package.json`:
 
-```bash
-mkdir my-game && cd my-game
-npm init -y
-npm install git+https://gitee.com/personal-site-micro/game-sdk.git
-npm install --save-dev esbuild
+```json
+{
+  "scripts": {
+    "build": "esbuild src/game.js --bundle --outfile=bundle.js --platform=browser --format=iife",
+    "publish": "npm run build && game-sdk publish"
+  }
+}
 ```
 
-在 `package.json` 加 build 脚本:
+### 3. 配置发布信息
+
+在项目根目录创建 `game-sdk.config.json`:
 
 ```json
-"scripts": {
-  "build": "esbuild src/game.js --bundle --outfile=bundle.js --platform=browser --format=iife"
+{
+  "game-id": "my-game",
+  "name": "我的游戏",
+  "bundle": "./bundle.js",
+  "description": "游戏描述",
+  "controls": "方向键移动"
 }
 ```
 
-### 2. 编写游戏
+### 4. 编写游戏入口
 
-游戏入口为 `src/game.js`(或 `.ts`)。平台会把它注入到页面里,游戏需要自己挂载到 `#game-container`:
+`src/game.js`:
 
 ```js
 ;(function () {
@@ -63,121 +70,389 @@ npm install --save-dev esbuild
   canvas.style.cssText = 'width:100%;height:100%;display:block;'
   container.appendChild(canvas)
 
-  // ... 游戏逻辑 ...
+  const ctx = canvas.getContext('2d')
+  let width = 0
+  let height = 0
+  let score = 0
+  let submitted = false
+  let startedAt = 0
+  let ended = false
+
+  function resize() {
+    const rect = container.getBoundingClientRect()
+    width = rect.width
+    height = rect.height
+
+    if (width <= 0 || height <= 0) return false
+
+    const dpr = window.devicePixelRatio || 1
+    canvas.width = width * dpr
+    canvas.height = height * dpr
+    ctx.setTransform(1, 0, 0, 1, 0, 0)
+    ctx.scale(dpr, dpr)
+    return true
+  }
+
+  async function endGame() {
+    if (submitted) return
+    submitted = true
+    ended = true
 
-  // 游戏结束时提交分数
-  async function gameOver(score) {
     const result = await GameSDK.submit(score)
     if (result.ok) {
-      console.log('第', result.rank, '名')
+      console.log('当前排名:', result.rank)
+    } else {
+      console.error('提交失败:', result.error)
     }
   }
-})()
-```
 
-> **注意**:整个游戏逻辑必须包在 IIFE(立即执行函数)里,避免污染全局作用域。
+  function update(now) {
+    if (ended) return
 
-### 3. 本地预览
+    const elapsed = now - startedAt
+    score = Math.floor(elapsed / 1000)
 
-写一个 `index.html`,stub 掉 GameSDK,方便离线调试:
+    if (elapsed >= 30000) {
+      endGame()
+    }
+  }
 
-```html
-<!DOCTYPE html>
-<html>
-<head>
-  <style>
-    * { margin: 0; padding: 0; box-sizing: border-box; }
-    html, body { width: 100%; height: 100%; background: #0a0a0a; }
-    #game-container { width: 100%; height: 100%; }
-  </style>
-</head>
-<body>
-  <div id="game-container"></div>
-  <script>
-    window.GameSDK = {
-      gameId: 'my-game',
-      getPlayerName: () => 'dev',
-      submit: async (score) => {
-        console.log('submit score:', score)
-        return { ok: true, rank: 1 }
-      }
+  function render() {
+    ctx.fillStyle = '#111'
+    ctx.fillRect(0, 0, width, height)
+
+    ctx.fillStyle = '#fff'
+    ctx.font = '24px sans-serif'
+    ctx.fillText(`score: ${score}`, 20, 40)
+  }
+
+  function loop(now) {
+    update(now)
+    render()
+    requestAnimationFrame(loop)
+  }
+
+  function boot() {
+    if (!resize()) {
+      requestAnimationFrame(boot)
+      return
     }
-  </script>
-  <script src="bundle.js"></script>
-</body>
-</html>
+
+    startedAt = performance.now()
+    requestAnimationFrame(loop)
+  }
+
+  window.addEventListener('resize', resize)
+  boot()
+})()
 ```
 
-然后打包并用任意静态服务器预览:
+### 5. 发布
 
 ```bash
-npm run build
-npx serve .
+npm run publish
 ```
 
-### 4. 打包
+发布成功后,游戏地址为:
 
-```bash
-npm run build
+```text
+https://loganz2.cn/games/<game-id>
 ```
 
-产物为根目录下的 `bundle.js`。
+## 你会用到的两个东西
 
----
+### 1. 浏览器里的 `GameSDK`
 
-## 上传到平台
+平台会在加载你的 bundle 前自动注入全局变量:
 
-### 上传游戏 bundle
+```ts
+GameSDK.gameId
+GameSDK.getPlayerName()
+GameSDK.submit(score)
+```
 
-```bash
-curl -X POST https://loganz2.cn/api/games/bundle \
-  -H "x-game-id: my-game" \
-  -H "x-game-name: 我的游戏" \
-  -H "x-game-description: 游戏描述" \
-  -H "x-game-controls: 方向键移动" \
-  -H "content-type: application/javascript" \
-  --data-binary "@bundle.js"
+类型:
+
+```ts
+type GameSDKSubmitResult =
+  | { ok: true; rank: number }
+  | { ok: false; error: string }
 ```
 
-| Header | 说明 |
-|---|---|
-| `x-game-id` | 游戏唯一 id,只允许字母、数字、`_`、`-` |
-| `x-game-name` | 显示名称,需 URL encode |
-| `x-game-description` | 简短描述(可选),需 URL encode |
-| `x-game-controls` | 操作说明(可选),需 URL encode |
+说明:
+
+1. `gameId` 是当前游戏 id。
+2. `getPlayerName()` 返回玩家昵称。
+3. `submit(score)` 提交一局游戏的最终分数。
+
+### 2. 终端里的 `game-sdk` 命令
 
-上传成功后游戏立即可用,访问 `https://loganz2.cn/games/<id>` 即可游玩
+这是发布命令,不是浏览器 API
 
-### 上传静态资源(可选)
+它负责:
 
-如果游戏需要图片、音频等资源:
+1. 上传 `bundle.js`
+2. 上传资源文件
+3. 删除游戏
+
+常用命令:
 
 ```bash
-curl -X POST https://loganz2.cn/api/games/asset \
-  -H "x-game-id: my-game" \
-  -H "x-filename: player.png" \
-  --data-binary "@player.png"
+game-sdk publish
+game-sdk asset --file ./player.png
+game-sdk delete --game-id my-game
+```
+
+如果命令参数没有写全,CLI 会继续从这些位置读取配置:
+
+1. 命令行参数
+2. `game-sdk.config.json`
+3. `game.config.json`
+4. `package.json` 里的 `gameSdk`
+
+## 平台运行规则
+
+这些规则必须满足,否则很容易出现空白页、无法开始、尺寸错误、重复提交分数等问题。
+
+### 1. 必须挂载到 `#game-container`
+
+平台页面只提供这个容器:
+
+```html
+<div id="game-container"></div>
+```
+
+你的游戏必须自己把 Canvas、DOM、WebGL 内容挂到这里。
+
+### 2. 推荐把入口包在 IIFE 里
+
+```js
+;(function () {
+  // game code
+})()
+```
+
+这样可以避免污染全局作用域。
+
+### 3. 不要假设容器一开始就有稳定尺寸
+
+平台页面由 React 渲染,容器虽然存在,但你的 bundle 执行时它的尺寸可能还没稳定。
+
+所以:
+
+1. Canvas 游戏必须处理 `resize`。
+2. 读取到 `0` 宽高时要等待下一帧重试。
+3. 每次重设 canvas 尺寸后,要先重置 transform,再重新缩放。
+
+推荐模式:
+
+```js
+function resize() {
+  const rect = container.getBoundingClientRect()
+  const width = rect.width
+  const height = rect.height
+  const dpr = window.devicePixelRatio || 1
+
+  if (width <= 0 || height <= 0) return false
+
+  canvas.width = width * dpr
+  canvas.height = height * dpr
+  ctx.setTransform(1, 0, 0, 1, 0, 0)
+  ctx.scale(dpr, dpr)
+  return true
+}
 ```
 
-资源上传后可通过以下路径访问:
+### 4. 游戏必须有明确结束条件
+
+平台不会替你判断一局是否结束。
+
+你必须自己定义,例如:
+
+1. 倒计时结束
+2. 生命耗尽
+3. 碰撞失败
+4. 关卡清空
+5. 无法继续操作
+
+结束时应该:
+
+1. 停止本局逻辑
+2. 固化最终分数
+3. 调用一次 `GameSDK.submit(finalScore)`
+
+### 5. `submit` 只在一局结束时调用一次
 
+不要这样用:
+
+1. 每得 1 分就提交
+2. 在渲染循环里提交
+3. 同一个结束条件反复触发时重复提交
+
+推荐加保护:
+
+```js
+let submitted = false
+
+async function endGame(score) {
+  if (submitted) return
+  submitted = true
+  await GameSDK.submit(score)
+}
 ```
-/api/games/<id>/assets/<filename>
+
+### 6. 资源必须走平台绝对路径
+
+资源上传后,运行时请用:
+
+```text
+/api/games/<game-id>/assets/<filename>
 ```
 
-在游戏代码里直接用绝对路径加载:
+例如
 
 ```js
 const img = new Image()
 img.src = '/api/games/my-game/assets/player.png'
 ```
 
-### 更新游戏
+不要依赖本地相对路径结构。
+
+## 发布命令
+
+### 发布 bundle
+
+如果配置文件已经写好,直接:
+
+```bash
+game-sdk publish
+```
+
+也可以显式传参:
+
+```bash
+game-sdk publish \
+  --game-id my-game \
+  --name "我的游戏" \
+  --bundle ./bundle.js \
+  --description "游戏描述" \
+  --controls "方向键移动"
+```
+
+参数:
+
+1. `--game-id`:必填。只允许字母、数字、`_`、`-`。
+2. `--name`:必填。平台展示名称。
+3. `--bundle`:必填。bundle 文件路径。
+4. `--description`:可选。简短描述。
+5. `--controls`:可选。操作说明。
+
+### 上传资源
+
+```bash
+game-sdk asset --game-id my-game --file ./player.png
+```
+
+或者:
+
+```bash
+game-sdk asset --file ./player.png
+```
+
+上面这个简写成立的前提是 `game-id` 已经写进配置文件。
 
-重新上传 bundle 即可,同一个 id 会直接覆盖。
+如果你想指定平台上的文件名:
+
+```bash
+game-sdk asset \
+  --game-id my-game \
+  --file ./assets/player-v2.png \
+  --name player.png
+```
 
 ### 删除游戏
 
 ```bash
-curl -X DELETE https://loganz2.cn/api/games/my-game
+game-sdk delete --game-id my-game
+```
+
+## 本地离线预览
+
+本地只能 stub `window.GameSDK` 来调试游戏逻辑,不代表本地部署平台。
+
+`index.html`:
+
+```html
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <style>
+      * { box-sizing: border-box; margin: 0; padding: 0; }
+      html, body { width: 100%; height: 100%; background: #0a0a0a; }
+      #game-container { width: 100%; height: 100%; }
+    </style>
+  </head>
+  <body>
+    <div id="game-container"></div>
+    <script>
+      window.GameSDK = {
+        gameId: 'my-game',
+        getPlayerName: function () { return 'dev' },
+        submit: async function (score) {
+          console.log('submit score:', score)
+          return { ok: true, rank: 1 }
+        }
+      }
+    </script>
+    <script src="./bundle.js"></script>
+  </body>
+</html>
+```
+
+预览:
+
+```bash
+npm run build
+npx serve .
 ```
+
+## 常见错误
+
+### 页面空白,但 bundle 已上传
+
+优先检查:
+
+1. 是否真的挂载到了 `#game-container`
+2. 是否读取到了 `0` 尺寸后就再也没重试
+3. 是否多次 resize 后重复缩放了 canvas 坐标系
+4. 是否有运行时错误导致第一帧前中断
+
+### 分数反复提交
+
+通常是因为:
+
+1. 在动画循环里调用了 `GameSDK.submit`
+2. 没有 `submitted` 保护
+3. 同一个结束条件被重复触发
+
+### 资源 404
+
+通常是因为:
+
+1. 资源没有上传
+2. 路径不是 `/api/games/<id>/assets/<filename>`
+3. 上传文件名和代码里使用的文件名不一致
+
+## 最后
+
+这个 SDK 故意保持很薄。
+
+平台只关心三件事:
+
+1. 你的游戏能在 `#game-container` 里正确运行
+2. 一局结束时能提交最终分数
+3. 你能稳定地发布 bundle 和资源
+
+其余渲染、输入、状态机、资源管理,都由游戏自己决定。

+ 223 - 0
cli.js

@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs')
+const path = require('node:path')
+
+const BASE_URL = 'https://loganz2.cn'
+const CONFIG_NAMES = ['game-sdk.config.json', 'game.config.json']
+
+function fail(message) {
+  console.error(`Error: ${message}`)
+  process.exit(1)
+}
+
+function printUsage() {
+  console.log(`game-sdk CLI
+
+Usage:
+  game-sdk publish --game-id <id> --name <name> --bundle <file> [--description <text>] [--controls <text>]
+  game-sdk asset --game-id <id> --file <file> [--name <filename>]
+  game-sdk delete --game-id <id>
+
+Config lookup order:
+  1. CLI flags
+  2. ./game-sdk.config.json
+  3. ./game.config.json
+  4. package.json#gameSdk
+
+Examples:
+  game-sdk publish --game-id reaction-click --name "反应力挑战" --bundle ./bundle.js --description "快速点击目标拿分" --controls "鼠标点击"
+  game-sdk asset --game-id reaction-click --file ./player.png
+  game-sdk delete --game-id reaction-click
+`)
+}
+
+function parseArgs(argv) {
+  const args = {}
+
+  for (let i = 0; i < argv.length; i++) {
+    const token = argv[i]
+    if (!token.startsWith('--')) {
+      fail(`unexpected argument: ${token}`)
+    }
+
+    const key = token.slice(2)
+    const value = argv[i + 1]
+    if (!value || value.startsWith('--')) {
+      fail(`missing value for --${key}`)
+    }
+
+    args[key] = value
+    i += 1
+  }
+
+  return args
+}
+
+function requireArg(args, key) {
+  const value = args[key]
+  if (!value) fail(`missing required argument --${key}`)
+  return value
+}
+
+function readJsonFile(filePath) {
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'))
+  } catch (error) {
+    fail(`invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
+  }
+}
+
+function loadConfig() {
+  for (const configName of CONFIG_NAMES) {
+    const configPath = path.resolve(process.cwd(), configName)
+    if (fs.existsSync(configPath)) {
+      return readJsonFile(configPath)
+    }
+  }
+
+  const packageJsonPath = path.resolve(process.cwd(), 'package.json')
+  if (!fs.existsSync(packageJsonPath)) {
+    return {}
+  }
+
+  const packageJson = readJsonFile(packageJsonPath)
+  return packageJson.gameSdk || {}
+}
+
+function mergeArgs(config, cliArgs) {
+  return { ...config, ...cliArgs }
+}
+
+function assertGameId(gameId) {
+  if (!/^[A-Za-z0-9_-]+$/.test(gameId)) {
+    fail('game id must match /^[A-Za-z0-9_-]+$/')
+  }
+}
+
+function readFile(filePath) {
+  const absolutePath = path.resolve(filePath)
+  if (!fs.existsSync(absolutePath)) {
+    fail(`file not found: ${absolutePath}`)
+  }
+  return {
+    absolutePath,
+    content: fs.readFileSync(absolutePath),
+  }
+}
+
+async function request(url, options) {
+  const response = await fetch(url, options)
+  const text = await response.text()
+
+  if (!response.ok) {
+    fail(`${response.status} ${response.statusText}${text ? `\n${text}` : ''}`)
+  }
+
+  if (!text) return null
+
+  try {
+    return JSON.parse(text)
+  } catch {
+    return text
+  }
+}
+
+async function publish(args) {
+  const gameId = requireArg(args, 'game-id')
+  const name = requireArg(args, 'name')
+  const bundle = requireArg(args, 'bundle')
+  const description = args.description || ''
+  const controls = args.controls || ''
+
+  assertGameId(gameId)
+  const { absolutePath, content } = readFile(bundle)
+
+  const result = await request(`${BASE_URL}/api/games/bundle`, {
+    method: 'POST',
+    headers: {
+      'x-game-id': gameId,
+      'x-game-name': encodeURIComponent(name),
+      'x-game-description': encodeURIComponent(description),
+      'x-game-controls': encodeURIComponent(controls),
+      'content-type': 'application/javascript',
+    },
+    body: content,
+  })
+
+  console.log(`Uploaded bundle: ${absolutePath}`)
+  console.log(`${BASE_URL}/games/${gameId}`)
+  if (result) {
+    console.log(JSON.stringify(result, null, 2))
+  }
+}
+
+async function uploadAsset(args) {
+  const gameId = requireArg(args, 'game-id')
+  const filePath = requireArg(args, 'file')
+  const { absolutePath, content } = readFile(filePath)
+  const filename = args.name || path.basename(absolutePath)
+
+  assertGameId(gameId)
+
+  const result = await request(`${BASE_URL}/api/games/asset`, {
+    method: 'POST',
+    headers: {
+      'x-game-id': gameId,
+      'x-filename': filename,
+    },
+    body: content,
+  })
+
+  console.log(`Uploaded asset: ${absolutePath}`)
+  console.log(`/api/games/${gameId}/assets/${filename}`)
+  if (result) {
+    console.log(JSON.stringify(result, null, 2))
+  }
+}
+
+async function removeGame(args) {
+  const gameId = requireArg(args, 'game-id')
+  assertGameId(gameId)
+
+  const result = await request(`${BASE_URL}/api/games/${gameId}`, {
+    method: 'DELETE',
+  })
+
+  console.log(`Deleted game: ${gameId}`)
+  if (result) {
+    console.log(JSON.stringify(result, null, 2))
+  }
+}
+
+async function main() {
+  const [command, ...rest] = process.argv.slice(2)
+
+  if (!command || command === '--help' || command === '-h') {
+    printUsage()
+    return
+  }
+
+  const args = mergeArgs(loadConfig(), parseArgs(rest))
+
+  if (command === 'publish') {
+    await publish(args)
+    return
+  }
+
+  if (command === 'asset') {
+    await uploadAsset(args)
+    return
+  }
+
+  if (command === 'delete') {
+    await removeGame(args)
+    return
+  }
+
+  fail(`unknown command: ${command}`)
+}
+
+main().catch((error) => {
+  fail(error instanceof Error ? error.message : String(error))
+})

+ 9 - 2
game-sdk.d.ts

@@ -8,13 +8,20 @@ interface GameSDKError {
   error: string
 }
 
+type GameSDKSubmitResult = GameSDKResult | GameSDKError
+
 interface IGameSDK {
   /** This game's id string */
   gameId: string
   /** Current player's display name */
   getPlayerName(): string
-  /** Submit a score. Returns rank on success. */
-  submit(score: number): Promise<GameSDKResult | GameSDKError>
+  /**
+   * Submit the final score for the current round.
+   *
+   * Call this when the game has clearly ended, not on every frame or every point gain.
+   * On success, rank is the player's current leaderboard rank.
+   */
+  submit(score: number): Promise<GameSDKSubmitResult>
 }
 
 declare const GameSDK: IGameSDK

+ 3 - 0
game-sdk.js

@@ -4,6 +4,9 @@
  * window.GameSDK is injected by the platform before your bundle loads.
  * Call it as a global — no import needed.
  *
+ * This package also ships a CLI for publishing:
+ *   game-sdk publish --game-id my-game --name "My Game" --bundle ./bundle.js
+ *
  *   const result = await GameSDK.submit(100)
  *   // { ok: true, rank: 3 }
  *

+ 4 - 0
package.json

@@ -2,8 +2,12 @@
   "name": "game-sdk",
   "version": "1.0.0",
   "description": "Platform SDK for games — window.GameSDK is injected at runtime",
+  "bin": {
+    "game-sdk": "./cli.js"
+  },
   "types": "game-sdk.d.ts",
   "files": [
+    "cli.js",
     "game-sdk.d.ts",
     "game-sdk.js"
   ]