gamekit777 0.1.3 → 0.1.6

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.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/client/http.ts +1 -1
  3. package/client/index.ts +26 -0
  4. package/create-game/scaffold.ts +9 -1
  5. package/create-game/template/CLAUDE.md.tmpl +38 -39
  6. package/create-game/template/src/index.ts.tmpl +5 -3
  7. package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
  8. package/create-game/template/vitest.config.ts +1 -1
  9. package/dev-host/DevShell.svelte +23 -2
  10. package/dev-host/platform.svelte.ts +4 -1
  11. package/dev-host/theme.css +5 -3
  12. package/package.json +1 -1
  13. package/protocol/assets.ts +108 -0
  14. package/protocol/bundles.ts +74 -0
  15. package/protocol/errors.ts +6 -0
  16. package/protocol/games.ts +5 -0
  17. package/protocol/index.ts +2 -0
  18. package/publish/upload-core.ts +95 -15
  19. package/publish/upload.ts +17 -1
  20. package/runtime/assets.ts +19 -15
  21. package/runtime/fonts.ts +1 -0
  22. package/runtime/index.ts +2 -0
  23. package/runtime/preload.ts +38 -0
  24. package/runtime/registry.ts +74 -0
  25. package/runtime/types.ts +3 -25
  26. package/sdk/assets.ts +24 -0
  27. package/sdk/host.ts +0 -2
  28. package/sdk/index.ts +1 -0
  29. package/sdk/spec.ts +3 -0
  30. package/studio/app/App.svelte +43 -14
  31. package/studio/app/lib/api.ts +11 -3
  32. package/studio/app/lib/bus.svelte.ts +2 -0
  33. package/studio/app/lib/identity.svelte.ts +4 -1
  34. package/studio/app/panels/AssetsPanel.svelte +239 -0
  35. package/studio/app/panels/PublishPanel.svelte +44 -51
  36. package/studio/bin.ts +2 -0
  37. package/studio/cli.ts +7 -6
  38. package/studio/src/api.ts +20 -7
  39. package/studio/src/assets.ts +160 -0
  40. package/studio/src/bus.ts +2 -1
  41. package/studio/src/engine.ts +237 -18
  42. package/studio/src/game-vite.ts +11 -2
  43. package/studio/src/mcp.ts +73 -33
  44. package/studio/src/scaffold.ts +38 -15
  45. package/studio/src/session.ts +37 -0
  46. package/studio/src/studio-plugin.ts +16 -0
  47. package/studio/src/tasks.ts +4 -1
  48. package/vite-config/index.js +160 -35
  49. package/vite-config/index.ts +5 -1
  50. package/vite-config/manifest.ts +16 -2
  51. package/vite-config/namespace-css.ts +130 -43
  52. package/create-game/template/src/assets/manifest.ts +0 -30
  53. package/create-game/template/src/assets/registry.ts +0 -23
  54. package/studio/app/lib/upload.ts +0 -34
@@ -0,0 +1,74 @@
1
+ /* 游戏包:vite build 出来的 index.js / manifest.json / meta.json / assets,按内容寻址存进 R2。
2
+ *
3
+ * 包不是审计对象——公平性的审计对象是表。包只是演出,所以已发布的游戏允许换包(修 UI、改演出),
4
+ * 表照旧不可变。「已发布 mode 的解码逻辑不能改」这条由 case 库和 restore 契约守,与包是否更新无关。
5
+ *
6
+ * 寻址:bundles/<gameId>/<bundleHash>/<path>。bundleHash 是文件清单(路径 + 各文件 sha256)的 sha256,
7
+ * 内容不变哈希就不变,所以对象永远不覆盖、可以 Cache-Control: immutable。 */
8
+ import { z } from 'zod';
9
+ import { Sha256Hex } from './money.ts';
10
+
11
+ /** 平台加载游戏时必须有的三个文件;其余(assets/…)随意 */
12
+ export const BUNDLE_REQUIRED = ['index.js', 'manifest.json', 'meta.json'] as const;
13
+ export const MAX_BUNDLE_FILE_BYTES = 20 * 1024 * 1024;
14
+ export const MAX_BUNDLE_FILES = 400;
15
+ export const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
16
+
17
+ /** 相对路径:每一段以字母数字开头(挡掉 . 和 ..),只允许字母数字 . _ - 和 / */
18
+ export const BundlePath = z.string().min(1).max(200)
19
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/, '路径只能是 a-z 0-9 . _ - 和 /,每段以字母数字开头');
20
+ export type BundlePath = z.infer<typeof BundlePath>;
21
+
22
+ export const BundleFile = z.object({
23
+ path: BundlePath,
24
+ sha256: Sha256Hex,
25
+ bytes: z.int().min(0).max(MAX_BUNDLE_FILE_BYTES),
26
+ });
27
+ export type BundleFile = z.infer<typeof BundleFile>;
28
+
29
+ export const BundleCommit = z.object({
30
+ files: z.array(BundleFile).min(1).max(MAX_BUNDLE_FILES),
31
+ });
32
+ export type BundleCommit = z.infer<typeof BundleCommit>;
33
+
34
+ /** 站点加载游戏要的全部信息:包哈希和入口 URL(同源相对路径,由平台流出) */
35
+ export const BundleRef = z.object({
36
+ hash: Sha256Hex,
37
+ /** game.meta.json 的 version,给人看的。缓存键是 hash:内容变了地址一定变,没变一定不变 */
38
+ version: z.string(),
39
+ entry: z.string(),
40
+ files: z.int(),
41
+ bytes: z.int(),
42
+ createdAt: z.int(),
43
+ });
44
+ export type BundleRef = z.infer<typeof BundleRef>;
45
+
46
+ export const BundleFileResult = z.object({ path: BundlePath, bytes: z.int(), sha256: Sha256Hex });
47
+ export type BundleFileResult = z.infer<typeof BundleFileResult>;
48
+
49
+ export const BundleCommitResult = z.object({ bundle: BundleRef });
50
+ export type BundleCommitResult = z.infer<typeof BundleCommitResult>;
51
+
52
+ /**
53
+ * 清单的规范文本:按路径排序,一行一个 `path sha256`。两端(上传方、服务端)都对它算 sha256 得到 bundleHash。
54
+ * 放在协议里而不是各写一份,是因为一个空格的差别就是两个哈希、永远对不上。
55
+ */
56
+ export function bundleManifestText(files: readonly { path: string; sha256: string }[]): string {
57
+ return [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
58
+ .map((f) => `${f.path} ${f.sha256}`).join('\n') + '\n';
59
+ }
60
+
61
+ export const bundleEntryPath = (gameId: string, hash: string): string => `/g/${gameId}/${hash}/index.js`;
62
+
63
+ /** R2 对象的 content-type 按扩展名给:模块脚本没有 JS 的 MIME 浏览器会拒绝 import */
64
+ export function bundleContentType(path: string): string {
65
+ const ext = path.split('.').pop()?.toLowerCase() ?? '';
66
+ return ({
67
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', json: 'application/json; charset=utf-8',
68
+ css: 'text/css; charset=utf-8', html: 'text/html; charset=utf-8', txt: 'text/plain; charset=utf-8', map: 'application/json',
69
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
70
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
71
+ mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', webm: 'video/webm', mp4: 'video/mp4',
72
+ wasm: 'application/wasm',
73
+ } as Record<string, string>)[ext] ?? 'application/octet-stream';
74
+ }
@@ -19,6 +19,9 @@ export const ErrorCode = z.enum([
19
19
  'RTP_MISMATCH',
20
20
  'RTP_OUT_OF_BOUNDS',
21
21
  'PAYLOAD_TOO_LARGE',
22
+ 'BUNDLE_MISSING',
23
+ 'BUNDLE_INVALID',
24
+ 'SLUG_TAKEN',
22
25
  'RATE_LIMITED',
23
26
  'INTERNAL',
24
27
  ]);
@@ -53,6 +56,9 @@ export const HTTP_STATUS: Record<ErrorCode, number> = {
53
56
  RTP_MISMATCH: 422,
54
57
  RTP_OUT_OF_BOUNDS: 422,
55
58
  PAYLOAD_TOO_LARGE: 413,
59
+ BUNDLE_MISSING: 409,
60
+ BUNDLE_INVALID: 422,
61
+ SLUG_TAKEN: 409,
56
62
  RATE_LIMITED: 429,
57
63
  INTERNAL: 500,
58
64
  };
package/protocol/games.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { BundleRef } from './bundles.ts';
2
3
  import { Cents, MAX_BET_CENTS, PayoutCenti, Ppm, RtpPpm, Sha256Hex } from './money.ts';
3
4
 
4
5
  export const GameStatus = z.enum([
@@ -63,6 +64,8 @@ export const CreateGame = z.object({
63
64
  export type CreateGame = z.infer<typeof CreateGame>;
64
65
 
65
66
  export const UpdateGame = z.object({
67
+ /** 只有草稿能改:已发布的 slug 是站点 URL 和历史局的一部分 */
68
+ slug: CreateGame.shape.slug.optional(),
66
69
  title: z.string().min(1).max(80).optional(),
67
70
  description: z.string().max(2000).nullable().optional(),
68
71
  declaredRtpPpm: RtpPpm.optional(),
@@ -105,6 +108,8 @@ export const Game = z.object({
105
108
  configHash: Sha256Hex,
106
109
  gameHash: Sha256Hex.nullable(),
107
110
  activeTableId: z.uuid().nullable(),
111
+ /** 站点从这里加载游戏代码。null = 还没上传包,大厅不列、进不去 */
112
+ bundle: BundleRef.nullable(),
108
113
  publishedAt: z.int().nullable(),
109
114
  createdAt: z.int(),
110
115
  updatedAt: z.int(),
package/protocol/index.ts CHANGED
@@ -8,3 +8,5 @@ export * from './seeds.ts';
8
8
  export * from './session.ts';
9
9
  export * from './verify.ts';
10
10
  export * from './pair.ts';
11
+ export * from './bundles.ts';
12
+ export * from './assets.ts';
@@ -8,7 +8,7 @@ import {
8
8
  } from '../protocol/index.ts';
9
9
  import type { GameKitClient } from '../client/index.ts';
10
10
  import { HEADER_BYTES, ROW_BYTES, decodeHeader, readRow, type Verdict } from '../lut/index.ts';
11
- import type { GameDefinition } from '../sdk/index.ts';
11
+ import type { GameDefinition, Paytable } from '../sdk/index.ts';
12
12
 
13
13
  /** 上传只需要 BuiltTable 的这几个字段。studio 从产物文件里拼出来的也能过 */
14
14
  export interface UploadTable {
@@ -19,10 +19,33 @@ export interface UploadTable {
19
19
  betCostCenti: number;
20
20
  books: { simId: number; frames: string }[] | null;
21
21
  verdict: Pick<Verdict, 'computedRtpPpm'>;
22
+ /** 这张表对应配置的赔率行。不给就用 spec.paytableFor(spec.configOf(mode)) 算——studio 的主进程没有游戏代码,只能预先算好带进来 */
23
+ paytable?: Paytable;
22
24
  }
23
25
 
26
+ /**
27
+ * 上传只需要清单里的这几项。GameDefinition 直接满足;studio 主进程从 worker 拿回的纯数据快照也满足
28
+ * (它没有 paytableFor / configOf,所以每张表要自带 paytable)
29
+ */
30
+ export type UploadSpec<C = unknown, O = unknown> =
31
+ Pick<GameDefinition<C, O>, 'slug' | 'title' | 'description' | 'rtp' | 'autoplay'>
32
+ & Partial<Pick<GameDefinition<C, O>, 'paytableFor' | 'configOf'>>;
33
+
34
+ export interface BundleFileInput { path: string; bytes: Uint8Array }
35
+
24
36
  export interface UploadOptions {
25
37
  client: GameKitClient;
38
+ /**
39
+ * 平台分配的游戏 id(game.meta.json 的 gameId)。给了就只认它:slug 只是它的一个属性,草稿阶段可以改。
40
+ * 不给就新建一个游戏——**不会**按 slug 去找旧的:同一台机器上两个目录都叫 my-game 时,按名字找会把
41
+ * 这个项目的表传进那个项目的游戏里。返回的 game.id 由调用方写回 game.meta.json,下次就是同一个游戏
42
+ */
43
+ gameId?: string | null;
44
+ /**
45
+ * 游戏包(vite build 的产物)。发布前必须有——没有包的游戏站点加载不了,服务端会拒绝发布。
46
+ * 不给就假定这个游戏已经传过包(只换表 / 新增 mode)
47
+ */
48
+ bundle?: readonly BundleFileInput[];
26
49
  /** 只发布这些 mode。不给就全发 */
27
50
  publishModes?: readonly string[];
28
51
  chunkRows?: number;
@@ -59,9 +82,11 @@ function histogramOf(bytes: Uint8Array): { weights: Map<number, number>; total:
59
82
  * betModes 和表的 mode 不是一回事:前者是「花多少钱买一注」,后者是「用哪张表」。
60
83
  * coco 的 22 个 mode 成本完全相同,塞进 betModes 只会撑爆它的 8 个上限。
61
84
  */
62
- export function describeConfig<C, O>(spec: GameDefinition<C, O>, tables: readonly UploadTable[]): GameConfig {
85
+ export function describeConfig<C, O>(spec: UploadSpec<C, O>, tables: readonly UploadTable[]): GameConfig {
63
86
  const paytables = tables.map((t) => {
64
- const rows = spec.paytableFor(spec.configOf(t.mode));
87
+ const rows = t.paytable ?? (spec.paytableFor && spec.configOf
88
+ ? spec.paytableFor(spec.configOf(t.mode))
89
+ : (() => { throw new GameKitError('VALIDATION_FAILED', `表 ${t.mode} 没带赔率行,清单也没有 paytableFor`); })());
65
90
  const { weights, total } = histogramOf(t.bytes);
66
91
  return {
67
92
  mode: t.mode,
@@ -80,7 +105,7 @@ export function describeConfig<C, O>(spec: GameDefinition<C, O>, tables: readonl
80
105
  }
81
106
 
82
107
  /** 注额区间是运营侧的事,不在游戏清单里——后端按默认值建,之后由 games.update 调 */
83
- const gameFields = <C, O>(spec: GameDefinition<C, O>): Omit<CreateGame, 'slug' | 'config'> => ({
108
+ const gameFields = <C, O>(spec: UploadSpec<C, O>): Omit<CreateGame, 'slug' | 'config'> => ({
84
109
  title: spec.title,
85
110
  description: spec.description,
86
111
  declaredRtpPpm: Math.round(spec.rtp * 1e6),
@@ -99,28 +124,83 @@ async function findBySlug(api: GameKitClient, slug: string): Promise<Game | null
99
124
  }
100
125
  }
101
126
 
127
+ /** 按 gameId 取回自己的游戏,把三种「不是你想的那个」都翻成能照着做的话 */
128
+ async function resolveGame<C, O>(api: GameKitClient, spec: UploadSpec<C, O>, gameId: string, config: GameConfig, say: (m: string) => void): Promise<Game> {
129
+ const me = await api.session.me().catch(() => {
130
+ throw new GameKitError('UNAUTHENTICATED', '没有身份。先在 studio 页面连接一次站点身份');
131
+ });
132
+ let game: Game;
133
+ try {
134
+ game = await api.games.get(gameId);
135
+ } catch (e) {
136
+ if (e instanceof GameKitError && e.code === 'NOT_FOUND') {
137
+ throw new GameKitError('NOT_FOUND',
138
+ `game.meta.json 里的 gameId ${gameId} 在这个平台上不存在(换了平台或库?)。删掉 gameId 字段会新建一个游戏`);
139
+ }
140
+ throw e;
141
+ }
142
+ if (game.ownerId !== me.user.id) {
143
+ throw new GameKitError('FORBIDDEN',
144
+ `游戏 ${gameId}(${game.slug})归另一个账号。换回那个身份,或删掉 game.meta.json 的 gameId 用当前身份新建`);
145
+ }
146
+ if (game.status === 'published') {
147
+ if (game.slug !== spec.slug) {
148
+ throw new GameKitError('GAME_IMMUTABLE',
149
+ `游戏 ${gameId} 已发布为 "${game.slug}",slug 不能改成 "${spec.slug}"——那是站点 URL 和历史局的一部分。要改名就删掉 gameId 另建一个`);
150
+ }
151
+ say(`${spec.slug} 已发布(${game.id.slice(0, 8)}…):已生效的 mode 不动`);
152
+ return game;
153
+ }
154
+ game = await api.games.update(game.id, { slug: spec.slug, ...gameFields(spec), config });
155
+ say(`更新草稿 ${game.id.slice(0, 8)}…`);
156
+ return game;
157
+ }
158
+
102
159
  export async function uploadTables<C, O>(
103
- spec: GameDefinition<C, O>, tables: readonly UploadTable[], opts: UploadOptions,
160
+ spec: UploadSpec<C, O>, tables: readonly UploadTable[], opts: UploadOptions,
104
161
  ): Promise<UploadResult> {
105
162
  const api = opts.client;
106
163
  const say = opts.onProgress ?? (() => {});
107
164
  const config = describeConfig(spec, tables);
108
165
 
109
- let game = await findBySlug(api, spec.slug);
110
- if (game?.status === 'published') {
111
- throw new GameKitError('GAME_IMMUTABLE',
112
- `slug "${spec.slug}" 已经发布过了。已发布的游戏不能换表——改个 slug 发一个新版本`);
113
- }
114
- if (game) {
115
- game = await api.games.update(game.id, { ...gameFields(spec), config });
116
- say(`复用草稿 ${game.id.slice(0, 8)}…`);
166
+ let game: Game;
167
+ if (opts.gameId) {
168
+ game = await resolveGame(api, spec, opts.gameId, config, say);
117
169
  } else {
170
+ /* 不按 slug 复用。但撞了要说清楚是哪种情况:同一个项目忘了写 gameId,还是另一个项目起了同样的名字 */
171
+ const dup = await findBySlug(api, spec.slug);
172
+ if (dup) {
173
+ throw new GameKitError('VALIDATION_FAILED',
174
+ `你已经有一个 slug 为 "${spec.slug}" 的游戏(${dup.id},${dup.status})。` +
175
+ `如果就是这个项目,把 "gameId": "${dup.id}" 写进 game.meta.json;如果是另一个项目,改 game.meta.json 的 id`);
176
+ }
118
177
  game = await api.games.create({ slug: spec.slug, config, ...gameFields(spec) });
119
- say(`新建游戏 ${game.id.slice(0, 8)}…`);
178
+ say(`新建游戏 ${game.id}(写进 game.meta.json 的 gameId,之后都认它)`);
179
+ }
180
+
181
+ if (opts.bundle) {
182
+ say(`上传游戏包:${opts.bundle.length} 个文件`);
183
+ await api.bundles.upload(game.id, opts.bundle, say);
184
+ } else if (!game.bundle) {
185
+ throw new GameKitError('BUNDLE_MISSING', `游戏 ${spec.slug} 还没有客户端包,发布会被拒。先 vite build,把 dist/ 一起传`);
186
+ }
187
+
188
+ /* 表按 mode 不可变:已经生效的 mode 一律跳过,只传新的。
189
+ 于是同一条命令既是首发也是「加 mode」也是「只换包」,不用调用方自己分辨游戏处在哪个状态 */
190
+ const live = new Set((await api.games.get(game.id)).modes.map((m) => m.mode));
191
+ const fresh = tables.filter((t) => !live.has(t.mode));
192
+ for (const t of tables) if (live.has(t.mode)) say(`[${t.mode}] 已生效,跳过(表不可变;要改就换 slug)`);
193
+ if (fresh.length === 0) {
194
+ if (!opts.bundle) {
195
+ throw new GameKitError('GAME_IMMUTABLE',
196
+ `slug "${spec.slug}" 的 ${tables.length} 个 mode 都已发布,没有新表也没有新包,无事可做。改表就换 slug;只想换包就把 dist/ 一起传`);
197
+ }
198
+ say(`只更新了客户端包`);
199
+ return { game: await api.games.get(game.id), tables: [], published: true };
120
200
  }
121
201
 
122
202
  const uploaded: GameTable[] = [];
123
- for (const t of tables) {
203
+ for (const t of fresh) {
124
204
  const { table, resumed } = await api.tables.declare(game.id, {
125
205
  mode: t.mode, format: 'GKLT1', rowCount: t.rowCount,
126
206
  byteLength: t.bytes.length, contentHash: t.contentHash, betCostCenti: t.betCostCenti,
package/publish/upload.ts CHANGED
@@ -4,13 +4,29 @@
4
4
  * 而 games 的唯一约束是 (owner_id, slug)——同一个 slug 会在库里堆出很多份,
5
5
  * `findBySlug` 也永远找不到上次传的那个草稿。
6
6
  * 纯逻辑在 upload-core.ts,浏览器里的 studio 直接用那一份。 */
7
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
7
9
  import { createClient } from '../client/index.ts';
8
10
  import { fileCookieStore } from '../client/node.ts';
9
11
  import type { BuiltTable } from './build.ts';
10
12
  import type { PublishSpec } from './spec.ts';
11
13
  import { uploadTables as uploadCore, type UploadOptions as CoreOptions, type UploadResult } from './upload-core.ts';
12
14
 
13
- export { describeConfig, type UploadResult, type UploadTable } from './upload-core.ts';
15
+ export { describeConfig, type BundleFileInput, type UploadResult, type UploadTable } from './upload-core.ts';
16
+
17
+ /** 把 vite build 出来的 dist/ 读成上传用的文件列表。路径用 /,和平台上的对象键一致 */
18
+ export function bundleFromDir(dir: string): { path: string; bytes: Uint8Array }[] {
19
+ const out: { path: string; bytes: Uint8Array }[] = [];
20
+ const walk = (d: string): void => {
21
+ for (const n of readdirSync(d).sort()) {
22
+ const p = join(d, n);
23
+ if (statSync(p).isDirectory()) walk(p);
24
+ else out.push({ path: relative(dir, p).split('\\').join('/'), bytes: new Uint8Array(readFileSync(p)) });
25
+ }
26
+ };
27
+ walk(dir);
28
+ return out;
29
+ }
14
30
 
15
31
  export interface UploadOptions extends Omit<CoreOptions, 'client'> {
16
32
  baseUrl?: string;
package/runtime/assets.ts CHANGED
@@ -5,15 +5,15 @@
5
5
  1. 用固定并发池,不用裸 Promise.all。五十张图同时 decode 会把主线程压死,
6
6
  进度条反而不动——看起来更慢。
7
7
  2. 进度按**字节**加权而不是按个数。按个数会出现「99% 卡住等那张 2MB 的图」。
8
+ 开发环境不知道字节数(bytes 为 null),退化成按个数。
8
9
  3. 所有图片都设 crossOrigin='anonymous'。游戏文件夹可能放在 CDN 上,
9
10
  而特效层迟早会把图画进 canvas;没有这一条,那时候画布会被污染,
10
11
  而且报错离原因很远。 */
11
- import type { AssetEntry, GameManifest, LoadProgress, LoadedAssets } from './types.ts';
12
+ import type { AssetBundle, AssetEntry } from '../sdk/index.ts';
13
+ import type { LoadProgress, LoadedAssets } from './types.ts';
12
14
 
13
15
  export interface LoadOptions {
14
- manifest: GameManifest;
15
- /** registry 给出的 key → 运行时 URL(已经过 import.meta.url 解析) */
16
- urls: Record<string, string>;
16
+ bundle: AssetBundle;
17
17
  onProgress?(p: LoadProgress): void;
18
18
  signal?: AbortSignal;
19
19
  audioContext?: AudioContext;
@@ -23,21 +23,24 @@ export interface LoadOptions {
23
23
  priority?: 'critical' | 'lazy';
24
24
  }
25
25
 
26
+ /* 有一个条目不知道大小,整组就按个数算:混着算会让那一个「零字节」瞬间完成,进度失真 */
27
+ const weightOf = (entries: AssetEntry[]): ((a: AssetEntry) => number) =>
28
+ entries.every(a => a.bytes !== null && a.bytes > 0) ? a => a.bytes! : () => 1;
29
+
26
30
  export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
27
- const { manifest, urls, onProgress, signal, concurrency = 6, priority = 'critical' } = opts;
28
- const entries = manifest.assets.filter(a => a.priority === priority);
31
+ const { bundle, onProgress, signal, concurrency = 6, priority = 'critical' } = opts;
32
+ const entries = bundle.entries.filter(a => a.priority === priority);
33
+ const weight = weightOf(entries);
29
34
 
30
35
  const images = new Map<string, HTMLImageElement>();
31
36
  const audios = new Map<string, AudioBuffer>();
32
37
  const failed: string[] = [];
33
38
 
34
- const total = entries.reduce((s, a) => s + a.bytes, 0) || 1;
39
+ const total = entries.reduce((s, a) => s + weight(a), 0) || 1;
35
40
  let loaded = 0, done = 0;
36
41
 
37
- const resolve = (a: AssetEntry): string => urls[a.key] ?? new URL(a.path, location.href).href;
38
-
39
42
  const bump = (a: AssetEntry): void => {
40
- loaded += a.bytes; done++;
43
+ loaded += weight(a); done++;
41
44
  onProgress?.({
42
45
  ratio: Math.min(1, loaded / total), loaded, total,
43
46
  done, count: entries.length, current: a.key
@@ -45,7 +48,7 @@ export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
45
48
  };
46
49
 
47
50
  async function one(a: AssetEntry): Promise<void> {
48
- const url = resolve(a);
51
+ const url = a.url;
49
52
  try {
50
53
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
51
54
 
@@ -99,19 +102,20 @@ export async function loadAssets(opts: LoadOptions): Promise<LoadedAssets> {
99
102
  return {
100
103
  image: k => images.get(k) ?? null,
101
104
  audio: k => audios.get(k) ?? null,
102
- url: k => urls[k] ?? k,
105
+ url: k => bundle.entries.find(a => a.key === k)?.url ?? k,
103
106
  failed
104
107
  };
105
108
  }
106
109
 
107
110
  /** 后台预取 lazy 资源。不阻塞、不报错、不影响开场 */
108
- export function prefetchLazy(manifest: GameManifest, urls: Record<string, string>): void {
111
+ export function prefetchLazy(bundle: AssetBundle): void {
112
+ if (!bundle.entries.some(a => a.priority === 'lazy')) return;
109
113
  const run = (): void => {
110
- for (const a of manifest.assets) {
114
+ for (const a of bundle.entries) {
111
115
  if (a.priority !== 'lazy') continue;
112
116
  const l = document.createElement('link');
113
117
  l.rel = 'prefetch';
114
- l.href = urls[a.key] ?? a.path;
118
+ l.href = a.url;
115
119
  l.as = a.type === 'image' ? 'image' : a.type === 'audio' ? 'audio' : 'fetch';
116
120
  l.crossOrigin = 'anonymous';
117
121
  document.head.appendChild(l);
package/runtime/fonts.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  const injected = new Set<string>();
7
7
 
8
8
  export function ensureFonts(hrefs: readonly string[]): Promise<unknown> {
9
+ if (hrefs.length === 0) return Promise.resolve();
9
10
  for (const href of hrefs) {
10
11
  if (injected.has(href)) continue;
11
12
  injected.add(href);
package/runtime/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './assets.ts';
2
2
  export * from './fonts.ts';
3
+ export * from './preload.ts';
4
+ export * from './registry.ts';
3
5
  export * from './types.ts';
@@ -0,0 +1,38 @@
1
+ /* 平台侧的预加载:拿到游戏模块之后、mount 之前调一次。
2
+
3
+ 玩家站点的 iframe、dev-host、studio 预览三处共用这一份——进度条的分母、字体的等待、
4
+ lazy 资源的预取时机,只在这里定义一次。游戏对此无感:它的 mount 假定 critical 资源已经到齐。 */
5
+ import type { AssetBundle } from '../sdk/index.ts';
6
+ import { loadAssets, prefetchLazy } from './assets.ts';
7
+ import { ensureFonts } from './fonts.ts';
8
+ import type { LoadProgress, LoadedAssets } from './types.ts';
9
+
10
+ export interface PreloadOptions {
11
+ onProgress?(p: LoadProgress): void;
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ export interface Preloaded {
16
+ assets: LoadedAssets;
17
+ /** mount 之后调:lazy 资源不该和开场抢带宽 */
18
+ prefetchLazy(): void;
19
+ }
20
+
21
+ const EMPTY: LoadedAssets = { image: () => null, audio: () => null, url: k => k, failed: [] };
22
+
23
+ export async function preloadGame(bundle: AssetBundle | undefined, opts: PreloadOptions = {}): Promise<Preloaded> {
24
+ // 老包或者没有资源的游戏:没有清单就没有可等的
25
+ if (!bundle) return { assets: EMPTY, prefetchLazy() {} };
26
+
27
+ const [assets] = await Promise.all([
28
+ loadAssets({ bundle, signal: opts.signal, onProgress: opts.onProgress }),
29
+ ensureFonts(bundle.fonts)
30
+ ]);
31
+ /* 字体和资源并行,谁后到都可能让最后一帧进度停在 <100%;
32
+ 收尾补一条满格,平台的条能走到底再切到画面 */
33
+ const count = bundle.entries.filter(a => a.priority === 'critical').length;
34
+ opts.onProgress?.({ ratio: 1, loaded: 1, total: 1, done: count, count });
35
+
36
+ // 图片句柄要活到游戏卸载:opaque origin 下 Chrome 不落盘缓存,内存里的这份就是唯一一份
37
+ return { assets, prefetchLazy: () => prefetchLazy(bundle) };
38
+ }
@@ -0,0 +1,74 @@
1
+ /* 资源注册表。目录即清单:
2
+ src/assets/critical/ 首屏必需,平台开场前加载完
3
+ src/assets/lazy/ 按需,挂载后后台预取
4
+
5
+ 游戏不写任何加载代码:组件用 assets.url(key) 取地址,index.ts 把 assets.bundle() 交给平台,
6
+ 平台在 mount 之前按清单预加载并画进度条。谁去加载、进度怎么算,游戏不知道也不该知道。
7
+
8
+ glob 写在这个库文件里而不是每个游戏一份:以 / 开头的模式按 vite 的 root 解析,
9
+ 而游戏的 vite root 就是游戏目录,所以从任何位置 import 这个模块看到的都是该游戏的 src/assets。
10
+ 代价是 root 不是游戏目录时(比如玩家站点自己 import 了 runtime)清单为空——那里也没人调 bundle()。
11
+
12
+ ?no-inline 不是可选项。Vite 的 lib 模式会**无条件**把资源 base64 内联进 JS
13
+ (见 vite/dist/node/chunks/config.js 的 shouldInline:build.lib 为真时直接
14
+ return true,assetsInlineLimit 完全失效),唯一的逃生口就是这个 query。
15
+ 漏掉的话构建照样成功,只是资源悄悄胖进 index.js、不在 manifest 里、
16
+ 平台预加载不到——所以 manifest 插件里还有一道 base64 告警兜底。 */
17
+ /// <reference types="vite/client" />
18
+ import type { AssetBundle, AssetEntry, AssetType } from '../sdk/index.ts';
19
+
20
+ const critical = import.meta.glob('/src/assets/critical/**/*', {
21
+ eager: true, query: '?no-inline', import: 'default'
22
+ }) as Record<string, string>;
23
+
24
+ const lazy = import.meta.glob('/src/assets/lazy/**/*', {
25
+ eager: true, query: '?no-inline', import: 'default'
26
+ }) as Record<string, string>;
27
+
28
+ /** key 形如 'critical/coco-classic.jpg',value 是带 hash 的运行时 URL */
29
+ const table: Record<string, string> = {};
30
+ for (const [k, v] of Object.entries({ ...critical, ...lazy })) {
31
+ table[k.replace(/^\/src\/assets\//, '')] = v;
32
+ }
33
+
34
+ /* 两个构建期才知道的值。
35
+ 字体列表由 vite-config 用 define 注入(dev 和 build 都有);
36
+ 字节数只有 generateBundle 之后才知道,manifest 插件把它替换进这个字符串字面量——
37
+ 开发环境没人替换,字面量原样留着,进度退化成按个数走 */
38
+ declare const __GAMEKIT_FONTS__: readonly string[] | undefined;
39
+ const BYTES_PLACEHOLDER = '__GAMEKIT_ASSET_BYTES__';
40
+ const bytesOf: Record<string, number> | null = BYTES_PLACEHOLDER.startsWith('{') ? JSON.parse(BYTES_PLACEHOLDER) as Record<string, number> : null;
41
+ const fonts: readonly string[] = typeof __GAMEKIT_FONTS__ !== 'undefined' ? __GAMEKIT_FONTS__ : [];
42
+
43
+ const TYPE_BY_EXT: Record<string, AssetType> = {
44
+ jpg: 'image', jpeg: 'image', png: 'image', webp: 'image', avif: 'image', svg: 'image', gif: 'image',
45
+ mp3: 'audio', ogg: 'audio', wav: 'audio', m4a: 'audio',
46
+ woff: 'font', woff2: 'font', ttf: 'font', otf: 'font',
47
+ json: 'json', mp4: 'video', webm: 'video'
48
+ };
49
+
50
+ const entryOf = (key: string): AssetEntry => ({
51
+ key,
52
+ url: table[key],
53
+ type: TYPE_BY_EXT[key.split('.').pop()!.toLowerCase()] ?? 'binary',
54
+ priority: key.startsWith('lazy/') ? 'lazy' : 'critical',
55
+ bytes: bytesOf?.[key] ?? null
56
+ });
57
+
58
+ export const assets = {
59
+ url(key: string): string {
60
+ const u = table[key];
61
+ if (!u) throw new Error(`[assets] 未知资源: ${key}(现有: ${Object.keys(table).join(', ')})`);
62
+ return u;
63
+ },
64
+ has: (key: string): boolean => key in table,
65
+ keys: Object.keys(table),
66
+ all: table as Readonly<Record<string, string>>,
67
+ /** 交给平台的清单。critical 在前,同优先级按名字排,和 manifest.json 一致 */
68
+ bundle(): AssetBundle {
69
+ const entries = Object.keys(table).map(entryOf);
70
+ entries.sort((x, y) =>
71
+ x.priority === y.priority ? x.key.localeCompare(y.key) : x.priority === 'critical' ? -1 : 1);
72
+ return { fonts: [...fonts], entries };
73
+ }
74
+ };
package/runtime/types.ts CHANGED
@@ -1,29 +1,7 @@
1
- /* 与 @gamekit/vite-config 的 manifest 插件产出的 manifest.json 对应。
2
- 两边各自声明而不是互相 import:runtime 会被打进游戏产物,
3
- 不该为了一个类型把构建期的包拖进运行时依赖图。 */
4
- export type AssetType = 'image' | 'audio' | 'font' | 'json' | 'video' | 'binary';
5
-
6
- export interface AssetEntry {
7
- key: string;
8
- path: string;
9
- type: AssetType;
10
- bytes: number;
11
- priority: 'critical' | 'lazy';
12
- }
13
-
14
- export interface GameManifest {
15
- id: string;
16
- version: string;
17
- generatedAt: string;
18
- entry: string;
19
- css: string | null;
20
- totalBytes: number;
21
- criticalBytes: number;
22
- assets: AssetEntry[];
23
- }
24
-
1
+ /* 资源条目和清单的形状在 @gamekit/sdk(它们是 GameModule 契约的一部分);
2
+ 这里只剩加载器自己的进度与结果类型 */
25
3
  export interface LoadProgress {
26
- /** 已加载字节 / 总字节 */
4
+ /** 已加载 / 总量。构建产物里按字节,开发环境按个数 */
27
5
  ratio: number;
28
6
  loaded: number;
29
7
  total: number;
package/sdk/assets.ts ADDED
@@ -0,0 +1,24 @@
1
+ /* 游戏包对平台声明的资源清单。纯数据,随 GameModule 一起交给平台侧的加载器。
2
+
3
+ 游戏不写加载代码:@gamekit/runtime 的注册表按 src/assets/{critical,lazy} 目录生成这份清单,
4
+ 平台(玩家站点的 iframe、dev-host、studio 预览)在 mount 之前按它预加载、把进度画在自己的机台上。
5
+ 类型放在 sdk 而不是 runtime:GameModule 是契约,runtime 只是它的一种实现。 */
6
+ export type AssetType = 'image' | 'audio' | 'font' | 'json' | 'video' | 'binary';
7
+
8
+ export interface AssetEntry {
9
+ /** 源码里稳定的名字,如 'critical/coco-classic.jpg' */
10
+ key: string;
11
+ /** 运行时 URL(构建产物里带 hash,已按 import.meta.url 解析) */
12
+ url: string;
13
+ type: AssetType;
14
+ /** critical 开场前必须到齐;lazy 挂载后后台预取 */
15
+ priority: 'critical' | 'lazy';
16
+ /** 构建期由 manifest 插件写入;开发环境不知道,进度退化成按个数走 */
17
+ bytes: number | null;
18
+ }
19
+
20
+ export interface AssetBundle {
21
+ /** 文档级字体样式表 URL(game.meta.json 的 fonts) */
22
+ fonts: string[];
23
+ entries: AssetEntry[];
24
+ }
package/sdk/host.ts CHANGED
@@ -60,8 +60,6 @@ export interface GameStatus {
60
60
  /** 现在能不能改影响结果的参数。平台据此把设置控件变灰——
61
61
  和游戏画布内的控件用的是同一个判断,两边不会打架 */
62
62
  canEditConfig: boolean;
63
- /** 资源加载进度 0..1,phase === 'loading' 时有意义 */
64
- progress?: number;
65
63
  lastPayout?: number;
66
64
  /** validate() 的结果,平台显示在设置面板上 */
67
65
  issues?: import('./schema.ts').ConfigIssue[];
package/sdk/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from './store.ts';
7
7
  export { sha256, hmacSha256, utf8, toHex, Sha256 } from './hash.ts';
8
8
  export * from './sample.ts';
9
9
  export * from './stage.ts';
10
+ export * from './assets.ts';
package/sdk/spec.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * defineGame 住在这个零依赖的包里,浏览器和 Node 侧的采样 worker import 的是同一份清单。
9
9
  * 它不是恒等函数:填默认值、挂推导出来的方法,消费方拿到的永远是填满的对象。 */
10
+ import type { AssetBundle } from './assets.ts';
10
11
  import type { RoundInput, RoundResult, Restore, RestoreInput } from './contract.ts';
11
12
  import type { GameInstance, Host } from './host.ts';
12
13
  import type { RngSeed } from './rng.ts';
@@ -90,6 +91,8 @@ export interface GameDefinition<C, O> extends GameSpec<C, O> {
90
91
  export interface GameModule<C, O> extends GameDefinition<C, O> {
91
92
  mount(host: Host<C>, el: HTMLElement): Promise<GameInstance<O>>;
92
93
  skins?: Skin[];
94
+ /** 资源清单。平台在 mount 之前按它预加载并显示进度;没有就直接挂 */
95
+ assets?: AssetBundle;
93
96
  }
94
97
 
95
98
  /* ── 采样种子 ────────────────────────────────────────────────────── */