gamekit777 0.1.2 → 0.1.5
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 +1 -1
- package/client/http.ts +1 -1
- package/client/index.ts +26 -0
- package/create-game/template/CLAUDE.md.tmpl +38 -39
- package/create-game/template/src/components/App.svelte.tmpl +25 -7
- package/create-game/template/src/index.ts.tmpl +5 -3
- package/create-game/template/src/state/game.svelte.ts.tmpl +2 -0
- package/create-game/template/src/styles/animations.css +10 -1
- package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
- package/create-game/template/src/view/present.ts.tmpl +9 -0
- package/create-game/template/vitest.config.ts +1 -1
- package/dev-host/DevShell.svelte +23 -2
- package/dev-host/platform.svelte.ts +4 -1
- package/dev-host/theme.css +5 -3
- package/package.json +1 -1
- package/protocol/assets.ts +108 -0
- package/protocol/bundles.ts +74 -0
- package/protocol/errors.ts +6 -0
- package/protocol/games.ts +5 -0
- package/protocol/index.ts +2 -0
- package/publish/upload-core.ts +95 -15
- package/publish/upload.ts +17 -1
- package/runtime/assets.ts +19 -15
- package/runtime/fonts.ts +1 -0
- package/runtime/index.ts +2 -0
- package/runtime/preload.ts +38 -0
- package/runtime/registry.ts +74 -0
- package/runtime/types.ts +3 -25
- package/sdk/assets.ts +24 -0
- package/sdk/host.ts +0 -2
- package/sdk/index.ts +1 -0
- package/sdk/spec.ts +3 -0
- package/studio/app/App.svelte +43 -14
- package/studio/app/lib/api.ts +11 -3
- package/studio/app/lib/bus.svelte.ts +2 -0
- package/studio/app/lib/identity.svelte.ts +4 -1
- package/studio/app/panels/AssetsPanel.svelte +239 -0
- package/studio/app/panels/PublishPanel.svelte +44 -51
- package/studio/bin.ts +2 -0
- package/studio/src/api.ts +20 -7
- package/studio/src/assets.ts +160 -0
- package/studio/src/bus.ts +2 -1
- package/studio/src/engine.ts +237 -18
- package/studio/src/game-vite.ts +11 -2
- package/studio/src/mcp.ts +68 -28
- package/studio/src/session.ts +37 -0
- package/studio/src/studio-plugin.ts +16 -0
- package/studio/src/tasks.ts +4 -1
- package/vite-config/index.js +160 -35
- package/vite-config/index.ts +5 -1
- package/vite-config/manifest.ts +16 -2
- package/vite-config/namespace-css.ts +130 -43
- package/create-game/template/src/assets/manifest.ts +0 -30
- package/create-game/template/src/assets/registry.ts +0 -23
- package/studio/app/lib/upload.ts +0 -34
|
@@ -1,58 +1,43 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
/*
|
|
3
|
-
|
|
2
|
+
/* 发布:node 侧一键跑完(vite build → 传包 → 传表 → 发布),页面只发起、看进度、看历史。
|
|
3
|
+
身份是从站点借来的那个人(lib/identity.svelte.ts);页面一连上,反代就把 cookie 抄给了 node,
|
|
4
|
+
所以 MCP 的 publish 和这里的按钮走的是同一条路、同一个身份。发布不可逆。 */
|
|
4
5
|
import { onMount } from 'svelte';
|
|
5
|
-
import type {
|
|
6
|
-
import game from 'virtual:studio/game';
|
|
6
|
+
import type { PublishPreflight, PublishRequest } from '../../src/engine.ts';
|
|
7
7
|
import { api, type Info } from '../lib/api.ts';
|
|
8
8
|
import { bus } from '../lib/bus.svelte.ts';
|
|
9
9
|
import { identity } from '../lib/identity.svelte.ts';
|
|
10
|
-
import { planFromArtifacts, runUpload } from '../lib/upload.ts';
|
|
11
10
|
|
|
12
11
|
let { info }: { info: Info } = $props();
|
|
13
|
-
const spec = $derived(info.snapshot.spec);
|
|
14
12
|
const me = $derived(identity.me);
|
|
15
13
|
let note = $state('');
|
|
16
|
-
let running = $state
|
|
17
|
-
let
|
|
14
|
+
let running = $state(false);
|
|
15
|
+
let pre = $state<PublishPreflight | null>(null);
|
|
16
|
+
let checking = $state(false);
|
|
18
17
|
|
|
19
18
|
const reqs = $derived(Object.values(bus.publish).sort((a, b) => b.createdAt - a.createdAt));
|
|
20
|
-
const pct = (ppm: number) => `${(ppm / 1e4).toFixed(4)}%`;
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
async function check() {
|
|
21
|
+
checking = true;
|
|
22
|
+
try { pre = await api.publishPreflight(); } catch (e) { pre = { ok: false, issues: [e instanceof Error ? e.message : String(e)], warnings: [], artifacts: [], session: false, remote: null }; }
|
|
23
|
+
finally { checking = false; }
|
|
24
|
+
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const declared = Math.round(spec.rtp * 1e6);
|
|
28
|
-
if (!info.artifacts.length) out.push('当前指纹下没有已建的表');
|
|
29
|
-
if (lowest !== null && lowest + 100 < declared) out.push(`最低实算 RTP ${pct(lowest)} 低于游戏声明 ${pct(declared)},平台会拒`);
|
|
30
|
-
const miss = spec.modes.filter((m) => !info.artifacts.some((a) => a.mode === m.name));
|
|
31
|
-
if (miss.length) out.push(`未建:${miss.map((m) => m.name).join('、')}`);
|
|
32
|
-
return out;
|
|
26
|
+
onMount(() => {
|
|
27
|
+
void identity.refresh().then(check);
|
|
28
|
+
void api.publishList().then((l) => { for (const r of l) bus.publish = { ...bus.publish, [r.id]: r }; });
|
|
33
29
|
});
|
|
34
30
|
|
|
35
|
-
async function
|
|
36
|
-
|
|
37
|
-
running = id; log = [];
|
|
38
|
-
const say = (m: string) => { log = [...log, m]; void api.publishUpdate(id, { progress: [...(bus.publish[id]?.progress ?? []), m] }); };
|
|
39
|
-
await api.publishUpdate(id, { status: 'running' });
|
|
31
|
+
async function publish() {
|
|
32
|
+
running = true;
|
|
40
33
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await api.publishUpdate(id, { status: 'published', result: { gameId: r.game.id, slug: r.game.slug, verifiedRtpPpm: r.game.verifiedRtpPpm, modes: r.tables.map((t) => t.mode) } });
|
|
47
|
-
say(`✅ 已发布 ${r.game.slug}(${r.game.id})`);
|
|
48
|
-
} catch (e) {
|
|
49
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
50
|
-
await api.publishUpdate(id, { status: 'failed', error: msg });
|
|
51
|
-
say(`✗ ${msg}`);
|
|
52
|
-
} finally { running = null; }
|
|
34
|
+
const job = await api.publishRequest(null, note || null);
|
|
35
|
+
note = '';
|
|
36
|
+
await api.waitJob(job.id);
|
|
37
|
+
await check();
|
|
38
|
+
} finally { running = false; }
|
|
53
39
|
}
|
|
54
|
-
const
|
|
55
|
-
const request = () => api.publishRequest(null, note || null).then(() => { note = ''; });
|
|
40
|
+
const label = (r: PublishRequest) => r.status === 'running' ? '进行中' : r.status === 'published' ? '已发布' : '失败';
|
|
56
41
|
</script>
|
|
57
42
|
|
|
58
43
|
<div class="grid">
|
|
@@ -60,7 +45,7 @@
|
|
|
60
45
|
<h2>身份</h2>
|
|
61
46
|
{#if me}
|
|
62
47
|
<p>{me.user.isAnonymous ? '游客' : me.user.name ?? '玩家'} <span class="mono hint">{me.user.id.slice(0, 8)}…</span> · 余额 {me.wallet.balanceCents}</p>
|
|
63
|
-
<p class="hint">这就是你在 {info.urls.site}
|
|
48
|
+
<p class="hint">这就是你在 {info.urls.site} 上的身份,发布出来的游戏归它。页面连上后 node 侧也记住了它,AI 用 publish 工具时不用再来点确认。{#if me.user.isAnonymous}匿名身份换浏览器就丢了——要长期用,先在站点上登录,再回来重新连接。{/if}</p>
|
|
64
49
|
<button class="sec" onclick={() => identity.redirect(info.urls.site)}>换个身份 / 重新连接</button>
|
|
65
50
|
{:else}
|
|
66
51
|
<p class="hint">{identity.error ?? '没有身份'}</p>
|
|
@@ -69,28 +54,36 @@
|
|
|
69
54
|
</div>
|
|
70
55
|
|
|
71
56
|
<div class="card">
|
|
72
|
-
<h2
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
<h2>体检</h2>
|
|
58
|
+
<p class="hint">游戏在平台上按 <span class="mono">game.meta.json</span> 的 gameId 绑定:{info.gameId ? `${info.gameId}` : '还没有,第一次发布会新建并写回'}。改名只改 id / title。</p>
|
|
59
|
+
{#if checking}<p class="hint">检查中…</p>
|
|
60
|
+
{:else if pre}
|
|
61
|
+
{#each pre.issues as i (i)}<p class="bad">✗ {i}</p>{/each}
|
|
62
|
+
{#each pre.warnings as w (w)}<p class="warn">⚠ {w}</p>{/each}
|
|
63
|
+
{#if pre.ok}<p class="ok">✓ {pre.artifacts.length} 张表就绪{#if pre.remote},这次会发布:{pre.remote.willPublish.join('、') || '(无新表,只换包)'}{/if}</p>{/if}
|
|
64
|
+
{#if pre.remote?.game}<p class="hint">平台上的游戏:{pre.remote.game.slug}({pre.remote.game.status}),已生效 mode:{pre.remote.game.liveModes.join('、') || '无'}</p>{/if}
|
|
65
|
+
{/if}
|
|
66
|
+
<div class="row">
|
|
67
|
+
<input placeholder="说明(可空,记进发布记录)" bind:value={note} style="flex:1" />
|
|
68
|
+
<button class="sec" onclick={check} disabled={checking || running}>重新体检</button>
|
|
69
|
+
<button class="pri" onclick={publish} disabled={running || !pre?.ok} title="不可逆:已生效的 mode 不能再换表">{running ? '发布中…' : '发布'}</button>
|
|
70
|
+
</div>
|
|
75
71
|
</div>
|
|
76
72
|
|
|
77
73
|
<div class="card">
|
|
78
|
-
<h2
|
|
79
|
-
{#if !reqs.length}<p class="hint"
|
|
74
|
+
<h2>发布记录</h2>
|
|
75
|
+
{#if !reqs.length}<p class="hint">还没有发布过。AI 用 publish 工具发的也会出现在这里。</p>{/if}
|
|
80
76
|
{#each reqs as r (r.id)}
|
|
81
|
-
<div class="req" class:active={r.status === '
|
|
77
|
+
<div class="req" class:active={r.status === 'running'}>
|
|
82
78
|
<div class="row">
|
|
83
79
|
<b class="mono">{r.id}</b><span class="hint">来自 {r.from} · {new Date(r.createdAt).toLocaleTimeString()}</span>
|
|
84
|
-
<span class:ok={r.status === 'published'} class:bad={r.status === 'failed'
|
|
80
|
+
<span class:ok={r.status === 'published'} class:bad={r.status === 'failed'} class:warn={r.status === 'running'}>{label(r)}</span>
|
|
85
81
|
<span style="flex:1"></span>
|
|
86
|
-
{#if r.
|
|
87
|
-
<button class="pri" onclick={() => approve(r.id)} disabled={!!running || !me || !!localIssues.length} title="不可逆:已生效的 mode 不能再换表">确认发布{r.modes ? `(${r.modes.join('、')})` : '(全部)'}</button>
|
|
88
|
-
<button class="sec" onclick={() => reject(r.id)}>拒绝</button>
|
|
89
|
-
{/if}
|
|
82
|
+
{#if r.result}<a href={r.result.playUrl} target="_blank" rel="noreferrer">试玩</a>{/if}
|
|
90
83
|
</div>
|
|
91
84
|
{#if r.note}<p class="hint">{r.note}</p>{/if}
|
|
92
85
|
{#if r.error}<p class="bad">{r.error}</p>{/if}
|
|
93
|
-
{#if
|
|
86
|
+
{#if r.progress.length}<pre>{r.progress.join('\n')}</pre>{/if}
|
|
94
87
|
</div>
|
|
95
88
|
{/each}
|
|
96
89
|
</div>
|
package/studio/bin.ts
CHANGED
|
@@ -37,11 +37,13 @@ const placeholder = { studio: '', preview: '', backend: cfg.backendUrl, site: cf
|
|
|
37
37
|
const engine = new Engine(cfg.gameDir, placeholder);
|
|
38
38
|
|
|
39
39
|
const server = await startGameServer({
|
|
40
|
+
onSession: (c) => engine.rememberSession(c),
|
|
40
41
|
gameDir: cfg.gameDir, studioRoot: cfg.studioRoot, port: cfg.gamePort, backendUrl, engine, quiet: mcpMode,
|
|
41
42
|
});
|
|
42
43
|
const base = (server.resolvedUrls?.local[0] ?? `http://127.0.0.1:${cfg.gamePort}/`).replace(/\/$/, '');
|
|
43
44
|
placeholder.studio = `${base}${PAGE_PATH}`;
|
|
44
45
|
placeholder.preview = `${base}${PREVIEW_PATH}`;
|
|
46
|
+
engine.syncAssetsInBackground();
|
|
45
47
|
|
|
46
48
|
let mcp: Awaited<ReturnType<typeof serveStdio>> | null = null;
|
|
47
49
|
if (mcpMode) mcp = await serveStdio(engine);
|
package/studio/src/api.ts
CHANGED
|
@@ -93,6 +93,17 @@ export function createApi(engine: Engine): (req: IncomingMessage, res: ServerRes
|
|
|
93
93
|
return undefined;
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
/* 游戏包:dist/ 里的文件清单与字节,页面用浏览器身份传到平台 */
|
|
97
|
+
on('GET', '/bundle', () => engine.bundleFiles());
|
|
98
|
+
on('GET', '/bundle/file', (req, res) => {
|
|
99
|
+
const path = new URL(req.url!, 'http://x').searchParams.get('path') ?? '';
|
|
100
|
+
const bytes = engine.bundleFile(path);
|
|
101
|
+
if (!bytes) { json(res, 404, { error: `dist/ 里没有 ${path}` }); return undefined; }
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Access-Control-Allow-Origin': '*' });
|
|
103
|
+
res.end(bytes);
|
|
104
|
+
return undefined;
|
|
105
|
+
});
|
|
106
|
+
|
|
96
107
|
/* 长任务 */
|
|
97
108
|
on('GET', '/jobs', () => engine.jobs.list());
|
|
98
109
|
on('GET', '/jobs/:id', (_r, res, p) => {
|
|
@@ -111,16 +122,18 @@ export function createApi(engine: Engine): (req: IncomingMessage, res: ServerRes
|
|
|
111
122
|
on('POST', '/cases/bless', (_r, _s, _p, body) => ({ blessed: engine.cases.bless((body as { updates: never[] }).updates) }));
|
|
112
123
|
on('DELETE', '/cases/:id', (_r, _s, p) => ({ removed: engine.cases.remove(p.id!) }));
|
|
113
124
|
|
|
114
|
-
/*
|
|
125
|
+
/* 动画库:列表 / 详情(源码 + NOTES + 预览用的绝对路径)/ 复制进游戏 / 手动同步 */
|
|
126
|
+
on('GET', '/assets', () => ({ entries: engine.assets.list(), syncedAt: engine.assets.syncedAt }));
|
|
127
|
+
on('GET', '/assets/:name', (_r, _s, p) => engine.assets.show(p.name!));
|
|
128
|
+
on('POST', '/assets/:name/use', (_r, _s, p, body) => engine.assets.use(p.name!, { force: !!(body as { force?: boolean } | null)?.force }));
|
|
129
|
+
on('POST', '/assets/sync', async () => ({ result: await engine.assets.sync(), entries: engine.assets.list() }));
|
|
130
|
+
|
|
131
|
+
/* 发布:node 侧一键跑完(身份是反代抄下来的站点 cookie),页面只发起和看进度 */
|
|
115
132
|
on('GET', '/publish', () => engine.publishRequests());
|
|
133
|
+
on('GET', '/publish/preflight', () => engine.publishPreflight(null));
|
|
116
134
|
on('POST', '/publish/request', (_r, _s, _p, body) => {
|
|
117
135
|
const b = (body ?? {}) as { modes?: unknown; note?: string };
|
|
118
|
-
return engine.
|
|
119
|
-
});
|
|
120
|
-
on('POST', '/publish/:id', (_r, res, p, body) => {
|
|
121
|
-
const r = engine.updatePublish(p.id!, body as never);
|
|
122
|
-
if (!r) { json(res, 404, { error: `没有发布请求 ${p.id}` }); return undefined; }
|
|
123
|
-
return r;
|
|
136
|
+
return engine.publish(list(b.modes), { note: b.note ?? null, from: 'page' });
|
|
124
137
|
});
|
|
125
138
|
|
|
126
139
|
return (req, res, next) => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* 资源库(动画 + 套路)在创作者机器上的那一半:后台拉、按哈希存、给页面预览、给 AI 查看、复制进游戏。
|
|
2
|
+
*
|
|
3
|
+
* 内容按内容寻址存在 ~/.gamekit/assets/<hash>/,跨项目共用、永不失效;index.json 的本地副本是目录。
|
|
4
|
+
* 拉下来的每个文件都按目录里的 sha256 核一遍,对不上就不落盘——目录走 TLS 从平台来,文件的完整性挂在它上面。
|
|
5
|
+
* 游戏用它是**复制**不是依赖:库里删一条,已经复制进游戏的副本照常构建。
|
|
6
|
+
* 两种条目只在 use() 上分叉:动画是一个文件,套路是一个目录(Demo 和范例不进游戏)。 */
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { Sha256 } from 'gamekit777/sdk/hash';
|
|
11
|
+
import {
|
|
12
|
+
ASSET_INDEX_PATH, AssetIndex, assetCopyFiles, assetFilePath, assetPreviewFile, assetTargetDir,
|
|
13
|
+
type AssetIndexEntry, type AssetKind,
|
|
14
|
+
} from 'gamekit777/protocol';
|
|
15
|
+
|
|
16
|
+
export const assetsDir = (): string => process.env.GAMEKIT_ASSETS_DIR ?? join(homedir(), '.gamekit', 'assets');
|
|
17
|
+
|
|
18
|
+
export interface AssetSyncResult { added: string[]; removed: string[]; total: number; at: number }
|
|
19
|
+
|
|
20
|
+
export interface AssetShown {
|
|
21
|
+
entry: AssetIndexEntry;
|
|
22
|
+
notes: string;
|
|
23
|
+
/** 除 asset.json / NOTES.md 之外的全部文件正文,按目录顺序。动画就一个,套路是机制 + Demo + 范例 */
|
|
24
|
+
files: { path: string; text: string; copied: boolean }[];
|
|
25
|
+
/** 预览要挂的组件(动画本身 / 套路的 Demo)的绝对路径,页面用 /@fs 让 vite 现场编译 */
|
|
26
|
+
previewPath: string;
|
|
27
|
+
/** 复制进游戏后落在哪(相对游戏目录) */
|
|
28
|
+
target: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseResult { path: string; written: boolean; reason?: string; files: string[] }
|
|
32
|
+
|
|
33
|
+
export class AssetStore {
|
|
34
|
+
readonly dir: string;
|
|
35
|
+
#index: AssetIndex | null = null;
|
|
36
|
+
#syncedAt = 0;
|
|
37
|
+
#syncing: Promise<AssetSyncResult | null> | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(readonly backendUrl: string, readonly gameDir: string, private readonly onChange: (r: AssetSyncResult) => void, dir = assetsDir()) {
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
this.#index = this.#readLocal();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get syncedAt(): number { return this.#syncedAt; }
|
|
46
|
+
|
|
47
|
+
#readLocal(): AssetIndex | null {
|
|
48
|
+
try { return AssetIndex.parse(JSON.parse(readFileSync(join(this.dir, 'index.json'), 'utf8'))); }
|
|
49
|
+
catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
list(kind?: AssetKind): AssetIndexEntry[] {
|
|
53
|
+
const all = this.#index?.entries ?? [];
|
|
54
|
+
return kind ? all.filter((e) => e.kind === kind) : all;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(name: string): AssetIndexEntry | null { return this.list().find((e) => e.name === name) ?? null; }
|
|
58
|
+
|
|
59
|
+
filePath(entry: AssetIndexEntry, path: string): string { return join(this.dir, entry.hash, path); }
|
|
60
|
+
|
|
61
|
+
/** 后台拉一次。并发调用共用同一次;失败(离线、平台没发过库)返回 null,本地目录照旧可用 */
|
|
62
|
+
sync(): Promise<AssetSyncResult | null> {
|
|
63
|
+
if (this.#syncing) return this.#syncing;
|
|
64
|
+
this.#syncing = this.#sync().finally(() => { this.#syncing = null; });
|
|
65
|
+
return this.#syncing;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async #sync(): Promise<AssetSyncResult | null> {
|
|
69
|
+
let remote: AssetIndex;
|
|
70
|
+
try {
|
|
71
|
+
const r = await fetch(`${this.backendUrl}${ASSET_INDEX_PATH}`);
|
|
72
|
+
if (!r.ok) return null;
|
|
73
|
+
remote = AssetIndex.parse(await r.json());
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
|
|
76
|
+
const before = new Map(this.list().map((e) => [e.name, e.hash]));
|
|
77
|
+
const added: string[] = [];
|
|
78
|
+
for (const e of remote.entries) {
|
|
79
|
+
if (this.#complete(e)) { if (before.get(e.name) !== e.hash) added.push(e.name); continue; }
|
|
80
|
+
const tmp = join(this.dir, `.${e.hash}.part`);
|
|
81
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
82
|
+
for (const f of e.files) {
|
|
83
|
+
const r = await fetch(`${this.backendUrl}${assetFilePath(e.hash, f.path)}`);
|
|
84
|
+
if (!r.ok) throw new Error(`拉 ${e.name}/${f.path} 失败:${r.status}`);
|
|
85
|
+
const bytes = new Uint8Array(await r.arrayBuffer());
|
|
86
|
+
const got = new Sha256().update(bytes).hex();
|
|
87
|
+
if (got !== f.sha256) throw new Error(`${e.name}/${f.path} 的 sha256 对不上目录(${got.slice(0, 8)} ≠ ${f.sha256.slice(0, 8)}),不落盘`);
|
|
88
|
+
mkdirSync(dirname(join(tmp, f.path)), { recursive: true });
|
|
89
|
+
writeFileSync(join(tmp, f.path), bytes);
|
|
90
|
+
}
|
|
91
|
+
rmSync(join(this.dir, e.hash), { recursive: true, force: true });
|
|
92
|
+
// 整条文件齐了再改名到位:vite 看到的目录要么完整要么没有
|
|
93
|
+
renameDir(tmp, join(this.dir, e.hash));
|
|
94
|
+
added.push(e.name);
|
|
95
|
+
}
|
|
96
|
+
const names = new Set(remote.entries.map((e) => e.name));
|
|
97
|
+
const removed = [...before.keys()].filter((n) => !names.has(n));
|
|
98
|
+
|
|
99
|
+
writeFileSync(join(this.dir, 'index.json'), `${JSON.stringify(remote, null, 2)}\n`);
|
|
100
|
+
this.#index = remote;
|
|
101
|
+
this.#syncedAt = Date.now();
|
|
102
|
+
const result = { added, removed, total: remote.entries.length, at: this.#syncedAt };
|
|
103
|
+
if (added.length || removed.length) this.onChange(result);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#complete(e: AssetIndexEntry): boolean {
|
|
108
|
+
const d = join(this.dir, e.hash);
|
|
109
|
+
if (!existsSync(d)) return false;
|
|
110
|
+
const have = new Set(readdirSync(d));
|
|
111
|
+
return e.files.every((f) => have.has(f.path));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
show(name: string): AssetShown {
|
|
115
|
+
const entry = this.get(name);
|
|
116
|
+
if (!entry) throw new Error(`资源库里没有 ${name}。有的是:${this.list().map((e) => `${e.name}(${e.kind})`).join('、') || '(空,先等后台同步或检查后端)'}`);
|
|
117
|
+
const read = (p: string): string => readFileSync(this.filePath(entry, p), 'utf8');
|
|
118
|
+
const paths = entry.files.map((f) => f.path);
|
|
119
|
+
const copied = new Set(assetCopyFiles(entry, paths));
|
|
120
|
+
return {
|
|
121
|
+
entry, notes: read('NOTES.md'),
|
|
122
|
+
files: paths.filter((p) => p !== 'asset.json' && p !== 'NOTES.md').map((p) => ({ path: p, text: read(p), copied: copied.has(p) })),
|
|
123
|
+
previewPath: this.filePath(entry, assetPreviewFile(entry)),
|
|
124
|
+
target: entry.kind === 'animation' ? `${assetTargetDir('animation')}/${entry.component}` : `${assetTargetDir('pattern')}/${entry.name}/`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 复制进游戏,每个文件头注来源与哈希。
|
|
130
|
+
* 动画 → src/animations/<Component>.svelte;套路 → src/patterns/<name>/ 下的机制文件(Demo、范例不进)。
|
|
131
|
+
* 目标已有且内容不同时拒绝,除非 force——那是创作者改过的副本,不能静默覆盖
|
|
132
|
+
*/
|
|
133
|
+
use(name: string, o: { force?: boolean } = {}): UseResult {
|
|
134
|
+
const s = this.show(name);
|
|
135
|
+
const e = s.entry;
|
|
136
|
+
const tag = `来自 GameKit 资源库 ${e.kind === 'animation' ? 'animations' : 'patterns'}/${e.name}@${e.hash.slice(0, 12)}。这是你的副本,随意改;库更新不会动它`;
|
|
137
|
+
const header = (p: string): string => p.endsWith('.svelte') ? `<!-- ${tag} -->\n` : p.endsWith('.ts') ? `// ${tag}\n` : '';
|
|
138
|
+
const destDir = e.kind === 'animation' ? join(this.gameDir, assetTargetDir('animation')) : join(this.gameDir, assetTargetDir('pattern'), e.name);
|
|
139
|
+
const plan = s.files.filter((f) => f.copied).map((f) => ({ path: f.path, dest: join(destDir, f.path), content: header(f.path) + f.text }));
|
|
140
|
+
const shownPath = e.kind === 'animation' ? plan[0]!.dest : destDir;
|
|
141
|
+
|
|
142
|
+
const existing = plan.filter((f) => existsSync(f.dest));
|
|
143
|
+
if (existing.length === plan.length && existing.every((f) => readFileSync(f.dest, 'utf8') === f.content)) {
|
|
144
|
+
return { path: shownPath, written: false, reason: '已有同一份', files: plan.map((f) => f.path) };
|
|
145
|
+
}
|
|
146
|
+
const changed = existing.filter((f) => readFileSync(f.dest, 'utf8') !== f.content);
|
|
147
|
+
if (changed.length && !o.force) {
|
|
148
|
+
return { path: shownPath, written: false, reason: `已有同名文件且内容不同(你改过?):${changed.map((f) => f.path).join('、')}。确定要覆盖就 force`, files: plan.map((f) => f.path) };
|
|
149
|
+
}
|
|
150
|
+
mkdirSync(destDir, { recursive: true });
|
|
151
|
+
for (const f of plan) writeFileSync(f.dest, f.content);
|
|
152
|
+
return { path: shownPath, written: true, files: plan.map((f) => f.path) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renameDir(from: string, to: string): void {
|
|
157
|
+
// 同一目录下 rename 不会跨设备;万一失败就复制再删
|
|
158
|
+
try { renameSync(from, to); }
|
|
159
|
+
catch { cpSync(from, to, { recursive: true }); rmSync(from, { recursive: true, force: true }); }
|
|
160
|
+
}
|
package/studio/src/bus.ts
CHANGED
|
@@ -14,7 +14,8 @@ export type BusEvent =
|
|
|
14
14
|
| { t: 'cmd'; id: string; cmd: unknown }
|
|
15
15
|
| { t: 'cases'; changed: true }
|
|
16
16
|
| { t: 'build'; artifacts: unknown }
|
|
17
|
-
| { t: 'publish'; request: unknown }
|
|
17
|
+
| { t: 'publish'; request: unknown }
|
|
18
|
+
| { t: 'assets'; added: string[]; removed: string[]; total: number };
|
|
18
19
|
|
|
19
20
|
interface Waiter { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
20
21
|
|