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
@@ -14,14 +14,19 @@
14
14
  import CasePanel from './panels/CasePanel.svelte';
15
15
  import PublishPanel from './panels/PublishPanel.svelte';
16
16
  import LogPanel from './panels/LogPanel.svelte';
17
+ import AssetsPanel from './panels/AssetsPanel.svelte';
17
18
 
18
- type Tab = 'preview' | 'tuning' | 'table' | 'cases' | 'publish' | 'log';
19
+ type Tab = 'preview' | 'tuning' | 'table' | 'cases' | 'assets' | 'publish' | 'log';
19
20
  const TABS: { id: Tab; label: string }[] = [
20
21
  { id: 'preview', label: '状态' }, { id: 'tuning', label: '调参' }, { id: 'table', label: '建表' },
21
- { id: 'cases', label: 'Case' }, { id: 'publish', label: '发布' }, { id: 'log', label: '日志' },
22
+ { id: 'cases', label: 'Case' }, { id: 'assets', label: '资源库' }, { id: 'publish', label: '发布' }, { id: 'log', label: '日志' },
22
23
  ];
23
- let tab = $state<Tab>((location.hash.slice(1) as Tab) || 'preview');
24
- $effect(() => { location.hash = tab; });
24
+ /* hash 是 `#<tab>` 或 `#<tab>:<条目>`(资源库用后半段记当前打开的那条)。
25
+ 只在 tab 段对不上时改写,别把面板自己写的 `:<条目>` 抹掉 */
26
+ const TAB_IDS = new Set<string>(['preview', 'tuning', 'table', 'cases', 'assets', 'publish', 'log']);
27
+ const hashTab = (): Tab => { const t = location.hash.slice(1).split(':')[0] ?? ''; return (TAB_IDS.has(t) ? t : 'preview') as Tab; };
28
+ let tab = $state<Tab>(hashTab());
29
+ $effect(() => { if (hashTab() !== tab) location.hash = tab; });
25
30
 
26
31
  let info = $state<Info | null>(null);
27
32
  let err = $state<string | null>(null);
@@ -38,6 +43,20 @@
38
43
  const running = $derived(Object.values(bus.jobs).filter((j) => j.status === 'running').length);
39
44
  const money = (minor: number) =>
40
45
  (minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
46
+
47
+ /* 预览 iframe 的高度跟着里面的内容走,让整页自然滚动,机台下面的开发面板才露得出来。
48
+ 同源,所以直接量 contentDocument;vite 整页刷新 iframe 时 load 会再触发一次,重新挂观察器 */
49
+ let ro: ResizeObserver | null = null;
50
+ function fitFrame(e: Event): void {
51
+ const f = e.currentTarget as HTMLIFrameElement;
52
+ const doc = f.contentDocument;
53
+ if (!doc) return;
54
+ ro?.disconnect();
55
+ const apply = () => { f.style.height = `${doc.documentElement.scrollHeight}px`; };
56
+ ro = new ResizeObserver(apply);
57
+ ro.observe(doc.documentElement);
58
+ apply();
59
+ }
41
60
  </script>
42
61
 
43
62
  <header>
@@ -49,13 +68,14 @@
49
68
  <span class="chip" class:on={bus.preview?.connected}>预览 {bus.preview?.connected ? '已连' : '未连'}</span>
50
69
  <span class="chip" class:on={bus.connected}>总线 {bus.connected ? '已连' : '断开'}</span>
51
70
  {#if running}<span class="chip busy">{running} 个任务运行中</span>{/if}
52
- {#if identity.me}<span class="chip on" title={identity.me.user.id}>{identity.me.user.isAnonymous ? '游客' : identity.me.user.name ?? '玩家'} {identity.me.user.id.slice(0, 6)}…</span>{/if}
53
71
  {/if}
54
72
  {#if err}<span class="chip bad">{err}</span>{/if}
55
73
  <div class="grow"></div>
56
74
  {#if bus.preview?.connected && bus.preview.balance !== null}
57
75
  <span class="bal">{money(bus.preview.balance)}</span>
58
- <span class="dim small">余额</span>
76
+ {/if}
77
+ {#if identity.me}
78
+ <span class="dim small" title={identity.me.user.id}>{identity.me.user.isAnonymous ? '游客' : identity.me.user.name ?? '玩家'}</span>
59
79
  {/if}
60
80
  <div class="nav" role="tablist">
61
81
  {#each TABS as t (t.id)}<button role="tab" aria-selected={tab === t.id} onclick={() => tab = t.id}>{t.label}</button>{/each}
@@ -65,7 +85,7 @@
65
85
  <main>
66
86
  <!-- 左:机台。和玩家站点同一台机器,宽度也照那边的 1200 内容宽 + 两侧 16 留白 -->
67
87
  <section class="machine">
68
- <iframe title="游戏预览" src="/preview"></iframe>
88
+ <iframe title="游戏预览" src="/preview" onload={fitFrame}></iframe>
69
89
  </section>
70
90
 
71
91
  <!-- 右:编辑区,拉满剩余宽度 -->
@@ -83,6 +103,7 @@
83
103
  {#if tab === 'tuning'}<TuningPanel {info} />{/if}
84
104
  {#if tab === 'table'}<TablePanel {info} />{/if}
85
105
  {#if tab === 'cases'}<CasePanel {info} />{/if}
106
+ {#if tab === 'assets'}<AssetsPanel {info} />{/if}
86
107
  {#if tab === 'publish'}<PublishPanel {info} />{/if}
87
108
  {#if tab === 'log'}<LogPanel />{/if}
88
109
  {:else if !err}
@@ -103,14 +124,22 @@
103
124
  .nav button{border:0;background:none;border-radius:9px;padding:5px 12px;font-weight:700;color:var(--dim);font-size:13px}
104
125
  .nav button[aria-selected="true"]{background:var(--ink);color:var(--cab)}
105
126
 
106
- /* 左固定右拉满。机台这一栏 = 1200 内容宽 + 两侧各 16 留白,和 .wrap 的算法一致;
107
- 屏幕不够宽时先缩机台(舞台按比例跟着缩,画面不变形),再不够就把编辑区推到下面 */
108
- main{display:flex;gap:16px;align-items:stretch;padding:14px 16px;flex-wrap:wrap}
109
- .machine{flex:0 1 1232px;min-width:720px;max-width:100%}
110
- .machine iframe{display:block;width:100%;height:calc(100vh - 78px);border:0;background:var(--page)}
111
- .editor{flex:1 1 420px;min-width:0}
127
+ /* 左边机台、右边编辑区。机台最宽照玩家站点的 1200 内容宽 + 两侧 16 留白,屏幕不够就先缩它
128
+ (舞台按比例跟着缩,画面不变形),缩到 880 以下再把编辑区推到下面。
129
+ iframe 的高度由 fitFrame 跟着内容走,整页滚动;编辑区粘在顶上,自己滚 */
130
+ /* 不能靠 flex-wrap 自动换行:换行看的是 flex-basis 之和,不看 min-width,1440 宽下会先把编辑区挤下去而不是缩机台 */
131
+ main{display:flex;gap:16px;align-items:flex-start;padding:14px 16px 32px}
132
+ .machine{flex:0 1 1232px;min-width:880px;max-width:100%}
133
+ .machine iframe{display:block;width:100%;min-height:640px;border:0;background:transparent}
134
+ .editor{flex:1 1 420px;min-width:420px;position:sticky;top:72px;max-height:calc(100vh - 88px);overflow-y:auto;
135
+ scrollbar-width:thin;scrollbar-color:var(--line) transparent;padding-bottom:12px}
136
+ @media (max-width:1347px){
137
+ main{flex-wrap:wrap}
138
+ .machine{flex-basis:100%;min-width:0}
139
+ .editor{position:static;max-height:none;overflow:visible}
140
+ }
112
141
 
113
- main :global(.card){background:var(--cab);border-radius:22px;box-shadow:var(--cab-shadow);padding:18px;display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
142
+ main :global(.card){background:var(--cab);border-radius:22px;box-shadow:var(--cab-shadow);padding:18px;display:flex;flex-direction:column;gap:10px;margin:0 2px 20px}
114
143
  main :global(h2){font-size:20px;margin:0}
115
144
  main :global(button.pri){border:0;border-radius:12px;padding:9px 14px;font-weight:700;background:var(--coco);color:#FFF6EA;box-shadow:0 4px 0 var(--coco-d)}
116
145
  main :global(button.pri:active:not(:disabled)){transform:translateY(3px);box-shadow:0 1px 0 var(--coco-d)}
@@ -1,7 +1,9 @@
1
1
  /* /studio/api 的类型化封装。页面和 node 侧同源,直接 fetch */
2
2
  import type { Artifact, GroupsSummary } from '../../src/build.ts';
3
3
  import type { CaseRecord, CaseResult } from '../../src/cases.ts';
4
- import type { Engine, PublishRequest } from '../../src/engine.ts';
4
+ import type { AssetShown, AssetSyncResult, UseResult } from '../../src/assets.ts';
5
+ import type { AssetIndexEntry } from 'gamekit777/protocol';
6
+ import type { Engine, PublishPreflight, PublishRequest, PublishResult } from '../../src/engine.ts';
5
7
  import type { Job } from '../../src/jobs.ts';
6
8
  import type { ProbeResult } from '../../src/probe.ts';
7
9
  import type { SolveReport, Targets } from '../../src/solver.ts';
@@ -32,6 +34,8 @@ export const api = {
32
34
  artifacts: () => call<Artifact[]>('GET', '/artifacts'),
33
35
  artifactTable: async (mode: string) => new Uint8Array(await (await fetch(`${BASE}/artifacts/${encodeURIComponent(mode)}/table`)).arrayBuffer()),
34
36
  artifactBooks: (mode: string) => call<{ simId: number; frames: string }[]>('GET', `/artifacts/${encodeURIComponent(mode)}/books`),
37
+ bundle: () => call<{ path: string; bytes: number; sha256: string }[]>('GET', '/bundle'),
38
+ bundleFile: async (path: string) => new Uint8Array(await (await fetch(`${BASE}/bundle/file?path=${encodeURIComponent(path)}`)).arrayBuffer()),
35
39
  jobs: () => call<Job[]>('GET', '/jobs'),
36
40
  job: <T>(id: string) => call<Job<T>>('GET', `/jobs/${id}`),
37
41
  task: (task: TaskName) => call<Job<TaskResult>>('POST', '/task', { task }),
@@ -40,9 +44,13 @@ export const api = {
40
44
  call<{ total: number; pass: number; fail: number; skipped: number; error: number; results: CaseResult[] }>('POST', '/cases/run', o),
41
45
  caseBless: (updates: { id: string; expect: CaseRecord['expect'] }[]) => call<{ blessed: number }>('POST', '/cases/bless', { updates }),
42
46
  caseRemove: (id: string) => call<{ removed: boolean }>('DELETE', `/cases/${id}`),
47
+ assetList: () => call<{ entries: AssetIndexEntry[]; syncedAt: number }>('GET', '/assets'),
48
+ assetShow: (name: string) => call<AssetShown>('GET', `/assets/${encodeURIComponent(name)}`),
49
+ assetUse: (name: string, force = false) => call<UseResult>('POST', `/assets/${encodeURIComponent(name)}/use`, { force }),
50
+ assetSync: () => call<{ result: AssetSyncResult | null; entries: AssetIndexEntry[] }>('POST', '/assets/sync'),
43
51
  publishList: () => call<PublishRequest[]>('GET', '/publish'),
44
- publishRequest: (modes: string[] | null, note: string | null) => call<PublishRequest>('POST', '/publish/request', { modes, note }),
45
- publishUpdate: (id: string, patch: Partial<PublishRequest>) => call<PublishRequest>('POST', `/publish/${id}`, patch),
52
+ publishPreflight: () => call<PublishPreflight>('GET', '/publish/preflight'),
53
+ publishRequest: (modes: string[] | null, note: string | null) => call<Job<PublishResult>>('POST', '/publish/request', { modes, note }),
46
54
  previewCommand: <T>(cmd: unknown) => call<T>('POST', '/preview/command', cmd),
47
55
  log: (msg: string, level: 'info' | 'warn' | 'error' = 'info') => call<unknown>('POST', '/log', { msg, level }),
48
56
 
@@ -14,6 +14,7 @@ class Bus {
14
14
  publish = $state<Record<string, PublishRequest>>({});
15
15
  casesVersion = $state(0);
16
16
  buildVersion = $state(0);
17
+ assetsVersion = $state(0);
17
18
 
18
19
  #es: EventSource | null = null;
19
20
 
@@ -35,6 +36,7 @@ class Bus {
35
36
  case 'cases': this.casesVersion++; break;
36
37
  case 'build': this.buildVersion++; break;
37
38
  case 'publish': { const r = m.request as PublishRequest; this.publish = { ...this.publish, [r.id]: r }; break; }
39
+ case 'assets': this.assetsVersion++; break;
38
40
  default: break;
39
41
  }
40
42
  };
@@ -3,8 +3,11 @@
3
3
  * 页面对 /v1 的请求同源,cookie 落在 127.0.0.1 下;第一次没有 cookie 时把整个窗口
4
4
  * 跳到站点的 #/connect 借身份,站点签一个一次性 code 带回 /connect,这里拿 code
5
5
  * 经反代换一个同一用户的新会话。之后发布、下注都用这份 cookie。 */
6
+ import { createClient, type GameKitClient } from 'gamekit777/client';
6
7
  import type { SessionView } from 'gamekit777/protocol';
7
- import { client } from './upload.ts';
8
+
9
+ /** 同源,cookie 由浏览器带。/v1 反代到平台后端;反代在 /v1/me 成功时把这份 cookie 抄给 node 侧,MCP 发布用 */
10
+ export const client: GameKitClient = createClient({ baseUrl: '' });
8
11
 
9
12
  export type IdentityPhase = 'checking' | 'ok' | 'redirecting' | 'claiming' | 'error';
10
13
 
@@ -0,0 +1,239 @@
1
+ <script lang="ts">
2
+ /* 资源库:上面选条目,下面舞台 + 旋钮 + NOTES + 源码。
3
+ 组件从 ~/.gamekit/assets/<hash>/ 经 vite 的 /@fs 现场编译——后台同步写进新文件,vite 自然认,
4
+ 这里只要在总线的 assets 事件上刷一次列表。动画挂组件本身(契约「挂载即播放」,重播 = {#key} 重挂);
5
+ 套路挂它的 Demo(一个可点的场景)。
6
+
7
+ 舞台按条目声明的逻辑尺寸画,再整体 scale 到面板能给的宽度——编辑区只有四五百像素宽,
8
+ 固定 450×385 会被裁掉一截,整体缩放画面不变形;想看原大就「放大」,开一个占满窗口的 dialog。 */
9
+ import type { Component } from 'svelte';
10
+ import type { AssetIndexEntry, AssetKind, AssetProp } from 'gamekit777/protocol';
11
+ import type { AssetShown } from '../../src/assets.ts';
12
+ import { api, type Info } from '../lib/api.ts';
13
+ import { bus } from '../lib/bus.svelte.ts';
14
+
15
+ let { info }: { info: Info } = $props();
16
+ let entries = $state<AssetIndexEntry[]>([]);
17
+ let syncedAt = $state(0);
18
+ let current = $state<string | null>(location.hash.split(':')[1] ?? null);
19
+ let shown = $state<AssetShown | null>(null);
20
+ let Comp = $state<Component<Record<string, unknown>> | null>(null);
21
+ let knobs = $state<Record<string, unknown>>({});
22
+ let token = $state(0);
23
+ let err = $state<string | null>(null);
24
+ let busy = $state(false);
25
+ let kind = $state<AssetKind | null>(null);
26
+ let tag = $state<string | null>(null);
27
+ let big = $state(false);
28
+ let dlg = $state<HTMLDialogElement | null>(null);
29
+ let slotW = $state(0);
30
+ let winW = $state(0);
31
+ let winH = $state(0);
32
+
33
+ const KIND_LABEL: Record<AssetKind, string> = { animation: '动画', pattern: '套路' };
34
+ const tags = $derived([...new Set(entries.filter((e) => !kind || e.kind === kind).flatMap((e) => e.tags))].sort());
35
+ const visible = $derived(entries.filter((e) => (!kind || e.kind === kind) && (!tag || e.tags.includes(tag))));
36
+ const stage = $derived({ w: shown?.entry.stage?.width ?? 520, h: shown?.entry.stage?.height ?? 420, bg: shown?.entry.stage?.background ?? '#1A0B11' });
37
+ /* 组件挂在留了边距的内框里:position:absolute;inset:0 的组件(奖牌、飘字)也就不会贴着舞台边 */
38
+ const PAD = 24;
39
+ /* 面板里按宽度缩;放大时按窗口的宽和高一起缩,留出对话框的边和标题条 */
40
+ const scale = $derived(big
41
+ ? Math.min(1, Math.max(0.2, (winW - 64) / stage.w), Math.max(0.2, (winH - 140) / stage.h))
42
+ : Math.min(1, slotW > 0 ? slotW / stage.w : 1));
43
+
44
+ async function refresh() {
45
+ try { const r = await api.assetList(); entries = r.entries; syncedAt = r.syncedAt; err = null; }
46
+ catch (e) { err = e instanceof Error ? e.message : String(e); }
47
+ }
48
+ $effect(() => { void bus.assetsVersion; void refresh(); });
49
+
50
+ const defaults = (p: Record<string, AssetProp>): Record<string, unknown> => Object.fromEntries(Object.entries(p).map(([k, v]) => [k, v.default]));
51
+
52
+ async function open(name: string) {
53
+ current = name; err = null; Comp = null;
54
+ location.hash = `assets:${name}`;
55
+ try {
56
+ const s = await api.assetShow(name);
57
+ shown = s;
58
+ knobs = defaults(s.entry.props);
59
+ /* /@fs/<绝对路径>:vite 编译 .svelte,和游戏、预览壳共用同一个 svelte 实例(dedupe) */
60
+ const mod = await import(/* @vite-ignore */ `/@fs${s.previewPath}`) as { default: Component<Record<string, unknown>> };
61
+ Comp = mod.default;
62
+ token++;
63
+ } catch (e) { err = e instanceof Error ? e.message : String(e); }
64
+ }
65
+
66
+ async function sync() {
67
+ busy = true;
68
+ try { const r = await api.assetSync(); entries = r.entries; if (!r.result) err = `平台拉不到资源库(${info.urls.backend}/a/index.json)`; }
69
+ catch (e) { err = e instanceof Error ? e.message : String(e); }
70
+ finally { busy = false; }
71
+ }
72
+
73
+ function enlarge() { big = true; winW = innerWidth; winH = innerHeight; dlg?.showModal(); token++; }
74
+ function shrink() { big = false; dlg?.close(); token++; }
75
+
76
+ const set = (k: string, v: unknown) => { knobs = { ...knobs, [k]: v }; token++; };
77
+ const num = (e: Event) => Number((e.currentTarget as HTMLInputElement).value);
78
+ const str = (e: Event) => (e.currentTarget as HTMLInputElement | HTMLSelectElement).value;
79
+ const chk = (e: Event) => (e.currentTarget as HTMLInputElement).checked;
80
+
81
+ $effect(() => { if (current && !shown && entries.some((e) => e.name === current)) void open(current); });
82
+ </script>
83
+
84
+ <svelte:window onresize={() => { winW = innerWidth; winH = innerHeight; }} />
85
+
86
+ {#snippet stageView()}
87
+ <!-- 外框占缩放后的尺寸,里面那层按逻辑尺寸画再 scale:组件里的 px 和它在游戏里一样 -->
88
+ <div class="asset-slot" style:width="{Math.round(stage.w * scale)}px" style:height="{Math.round(stage.h * scale)}px">
89
+ <div class="asset-stage" style:width="{stage.w}px" style:height="{stage.h}px" style:background={stage.bg} style:transform="scale({scale})" style:padding="{PAD}px">
90
+ <!-- gameframe 是 dev-host theme.css 里 @scope 的边界:平台的 .stage / .coin / .row 这些类名规则到这里止步,
91
+ 组件里同名的 class 才不会被机台样式污染(.stage 的 grid-area 曾把 dealer-talk 的网格挤歪) -->
92
+ <div class="area gameframe">
93
+ {#if Comp}
94
+ {#key token}<Comp {...knobs} />{/key}
95
+ {:else}<p class="hint">编译中…</p>{/if}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ {/snippet}
100
+
101
+ {#snippet knobsView()}
102
+ <div class="knobs">
103
+ {#each Object.entries(shown?.entry.props ?? {}) as [k, p] (k)}
104
+ <label class="knob">
105
+ <span class="k">{p.label ?? k}{#if p.kind === 'number'}<b class="mono">{knobs[k]}</b>{/if}</span>
106
+ {#if p.kind === 'boolean'}
107
+ <span class="ctl"><input type="checkbox" checked={!!knobs[k]} onchange={(e) => set(k, chk(e))} /></span>
108
+ {:else if p.kind === 'number'}
109
+ <span class="ctl">
110
+ <input type="range" min={p.min ?? 0} max={p.max ?? 100} step={p.step ?? 1} value={Number(knobs[k])} oninput={(e) => set(k, num(e))} />
111
+ <input type="number" min={p.min} max={p.max} step={p.step ?? 1} value={Number(knobs[k])} onchange={(e) => set(k, num(e))} />
112
+ </span>
113
+ {:else if p.kind === 'enum'}
114
+ <span class="ctl"><select value={String(knobs[k])} onchange={(e) => set(k, str(e))}>{#each p.options ?? [] as o (o)}<option value={o}>{o}</option>{/each}</select></span>
115
+ {:else}
116
+ <span class="ctl"><input type="text" value={String(knobs[k])} onchange={(e) => set(k, str(e))} /></span>
117
+ {/if}
118
+ </label>
119
+ {/each}
120
+ </div>
121
+ {/snippet}
122
+
123
+ <div class="wrap">
124
+ <section class="card pick">
125
+ <div class="row">
126
+ <h2 class="grow">资源库 <span class="hint">{entries.length} 条</span></h2>
127
+ <button class="sec" onclick={sync} disabled={busy}>{busy ? '同步中…' : '同步'}</button>
128
+ </div>
129
+ <p class="hint">历史演出经验的归档,来自平台,启动后后台同步{syncedAt ? `(${new Date(syncedAt).toLocaleTimeString()})` : ''}。动画是拿来即用的小组件;套路是一套机制加接线清单,复制进去还要照 NOTES 穿进时间轴,主要给 AI 用。</p>
130
+ <div class="filters">
131
+ <div class="nav" role="tablist">
132
+ <button role="tab" aria-selected={kind === null} onclick={() => { kind = null; tag = null; }}>全部 {entries.length}</button>
133
+ {#each ['animation', 'pattern'] as const as k (k)}
134
+ <button role="tab" aria-selected={kind === k} onclick={() => { kind = k; tag = null; }}>{KIND_LABEL[k]} {entries.filter((e) => e.kind === k).length}</button>
135
+ {/each}
136
+ </div>
137
+ {#if tags.length}
138
+ <div class="nav tags" role="tablist">
139
+ <button role="tab" aria-selected={tag === null} onclick={() => (tag = null)}>所有 tag</button>
140
+ {#each tags as t (t)}<button role="tab" aria-selected={tag === t} onclick={() => (tag = tag === t ? null : t)}>{t}</button>{/each}
141
+ </div>
142
+ {/if}
143
+ </div>
144
+ {#if !entries.length}<p class="hint">空。平台还没发过库(bun run assets:push),或者拉不到 {info.urls.backend}。</p>{/if}
145
+ <div class="items">
146
+ {#each visible as e (e.name)}
147
+ <button class="item" class:on={e.name === current} onclick={() => open(e.name)}>
148
+ <b>{e.kind === 'pattern' ? '⚙' : '✦'} {e.title}</b><span class="mono hint">{e.name}</span>
149
+ <small>{e.summary}</small>
150
+ </button>
151
+ {/each}
152
+ </div>
153
+ </section>
154
+
155
+ <section class="card main">
156
+ {#if err}<p class="bad">{err}</p>{/if}
157
+ {#if shown}
158
+ <div class="row">
159
+ <h2 class="grow">{shown.entry.title} <span class="mono hint">{shown.entry.name}@{shown.entry.hash.slice(0, 8)}</span> <span class="chip">{KIND_LABEL[shown.entry.kind]}</span></h2>
160
+ <button class="sec" onclick={() => token++}>重播</button>
161
+ <button class="sec" onclick={enlarge} title="占满窗口看原大">放大</button>
162
+ </div>
163
+ <p class="hint small">这里只看效果、找感觉。用进游戏交给 AI:<code class="mono">asset_show {shown.entry.name}</code> 读 NOTES,<code class="mono">asset_use {shown.entry.name}</code> 复制到 <span class="mono">{shown.target}</span>{shown.entry.kind === 'pattern' ? ',再按接线清单穿进时间轴' : ''}。</p>
164
+ <div class="slotwrap" bind:clientWidth={slotW}>
165
+ {#if !big}{@render stageView()}{/if}
166
+ {#if scale < 1 && !big}<p class="hint small">按 {Math.round(scale * 100)}% 显示(逻辑尺寸 {stage.w}×{stage.h}),「放大」看原大</p>{/if}
167
+ </div>
168
+ {#if !big}{@render knobsView()}{/if}
169
+ <details open>
170
+ <summary>NOTES — 为什么这么做{shown.entry.kind === 'pattern' ? '、怎么接线' : ''}</summary>
171
+ <pre class="notes">{shown.notes}</pre>
172
+ </details>
173
+ {#each shown.files as f (f.path)}
174
+ <details>
175
+ <summary>{f.path} <span class="hint">{f.copied ? '会复制进游戏' : f.path === (shown.entry.kind === 'pattern' ? shown.entry.demo : '') ? 'Demo,只在这里跑' : '范例,不复制'}</span></summary>
176
+ <pre class="code">{f.text}</pre>
177
+ </details>
178
+ {/each}
179
+ {:else}
180
+ <p class="hint">上面选一条。</p>
181
+ {/if}
182
+ </section>
183
+ </div>
184
+
185
+ <dialog bind:this={dlg} class="big" onclose={() => { if (big) shrink(); }}>
186
+ {#if big && shown}
187
+ <div class="row">
188
+ <h2 class="grow">{shown.entry.title}</h2>
189
+ <span class="hint">{Math.round(scale * 100)}% · {stage.w}×{stage.h}</span>
190
+ <button class="sec" onclick={() => token++}>重播</button>
191
+ <button class="sec" onclick={shrink}>关闭</button>
192
+ </div>
193
+ <div class="center">{@render stageView()}</div>
194
+ {@render knobsView()}
195
+ {/if}
196
+ </dialog>
197
+
198
+ <style>
199
+ .wrap{display:flex;flex-direction:column;gap:14px}
200
+ .pick{display:flex;flex-direction:column;gap:10px}
201
+ .filters{display:flex;flex-direction:column;gap:6px}
202
+ /* 和页眉的 tab 一副样子 */
203
+ .nav{display:flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--field);border:1px solid var(--line);border-radius:12px;align-self:flex-start;max-width:100%}
204
+ .nav button{border:0;background:none;border-radius:9px;padding:5px 12px;font-weight:700;color:var(--dim);font-size:13px;cursor:pointer}
205
+ .nav.tags button{font-weight:600;font-size:12px;padding:4px 10px}
206
+ .nav button[aria-selected="true"]{background:var(--ink);color:var(--cab)}
207
+ .items{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;max-height:260px;overflow:auto;padding-right:2px}
208
+ .item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;text-align:left;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:transparent;color:inherit;cursor:pointer}
209
+ .item.on{border-color:var(--accent-ink)}
210
+ .item small{color:var(--dim);font-size:12px}
211
+ .main{display:flex;flex-direction:column;gap:12px}
212
+ .slotwrap{width:100%}
213
+ .asset-slot{position:relative;overflow:hidden;border-radius:14px;border:1px solid var(--line);max-width:100%}
214
+ /* 舞台是游戏那一侧的深色画面,但它嵌在浅色主题的 studio 页面里:组件写的 var(--ink) 会取到页面的深色墨水,
215
+ 画在深色底上就没了。所以在这里把游戏侧的调色板整套定义一遍,和 dev-host theme.css 里机台内的取值一致 */
216
+ .asset-stage{position:absolute;left:0;top:0;transform-origin:0 0;box-sizing:border-box;overflow:hidden;color:var(--ink);
217
+ --ink:#FFF1DC;--dim:#B4959A;--s-ink:#FFF1DC;--s-dim:#A3858C;--cream:#FFF6EA;--txt-ink:#FFF1DC;
218
+ --gold:#FFD21F;--gold-d:#8A6A10;--banana:#FFD21F;--banana-d:#B98F00;--accent:#FF7A1A;--coco:#F0402E;--coco-d:#A8231A;
219
+ --tile:#3B1F2A;--stage:#1A0B11;--panel:rgba(26,11,17,.97);--win:#7CE0A0;--mark:#F2C94C;--skin-ring:#FFF1DC}
220
+ .area{position:relative;width:100%;height:100%;display:grid;place-items:center}
221
+ .small{font-size:12px;margin:6px 0 0}
222
+ /* 每个旋钮上下两行:标签一行、控件一行。同一行排的话面板一窄标签和数字就挤在一起 */
223
+ .knobs{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px 18px}
224
+ .knob{display:flex;flex-direction:column;gap:4px;min-width:0}
225
+ .knob .k{display:flex;justify-content:space-between;gap:8px;color:var(--dim);font-size:12px}
226
+ .knob .k b{font-weight:400;color:var(--ink)}
227
+ .knob .ctl{display:flex;align-items:center;gap:8px;min-width:0}
228
+ .knob .ctl input[type=range]{flex:1;min-width:0}
229
+ .knob .ctl input[type=number]{width:5.5em;flex:none}
230
+ .knob .ctl select,.knob .ctl input[type=text]{flex:1;min-width:0}
231
+ .notes{white-space:pre-wrap;font-family:inherit;font-size:13px;line-height:1.55;margin:0}
232
+ .code{font-size:12px;max-height:420px;overflow:auto;margin:0}
233
+ .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
234
+ dialog.big{width:calc(100vw - 32px);height:calc(100vh - 32px);max-width:none;max-height:none;margin:16px;padding:14px 18px;border:1px solid var(--line);border-radius:16px;
235
+ background:var(--cab);color:var(--ink);display:none;flex-direction:column;gap:12px}
236
+ dialog.big[open]{display:flex}
237
+ dialog.big::backdrop{background:rgba(0,0,0,.55)}
238
+ .center{flex:1;display:grid;place-items:center;min-height:0}
239
+ </style>
@@ -1,58 +1,43 @@
1
1
  <script lang="ts">
2
- /* 发布:身份是从站点借来的那个人(见 lib/identity.svelte.ts;/v1 同源反代,cookie 由浏览器带)。
3
- node 侧只排队和记录;MCP 发起的请求也落在这里,作者点确认才真传。发布不可逆。 */
2
+ /* 发布:node 侧一键跑完(vite build → 传包 → 传表 → 发布),页面只发起、看进度、看历史。
3
+ 身份是从站点借来的那个人(lib/identity.svelte.ts);页面一连上,反代就把 cookie 抄给了 node,
4
+ 所以 MCP 的 publish 和这里的按钮走的是同一条路、同一个身份。发布不可逆。 */
4
5
  import { onMount } from 'svelte';
5
- import type { GameModule } from 'gamekit777/sdk';
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<string | null>(null);
17
- let log = $state<string[]>([]);
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
- onMount(() => { void identity.refresh(); void api.publishList().then((l) => { for (const r of l) bus.publish = { ...bus.publish, [r.id]: r }; }); });
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
- const lowest = $derived(info.artifacts.reduce<number | null>((m, a) => (m === null || a.computedRtpPpm < m ? a.computedRtpPpm : m), null));
25
- const localIssues = $derived.by(() => {
26
- const out: string[] = [];
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 approve(id: string) {
36
- const req = bus.publish[id]; if (!req) return;
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 arts = req.modes ? info.artifacts.filter((a) => req.modes!.includes(a.mode)) : info.artifacts;
42
- if (!arts.length) throw new Error('没有可发布的表');
43
- say(`拼装 ${arts.length} 张表…`);
44
- const plan = await planFromArtifacts(game as GameModule<object, unknown>, arts);
45
- const r = await runUpload(plan, { publishModes: req.modes ?? undefined, onProgress: say });
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 reject = (id: string) => api.publishUpdate(id, { status: 'rejected' });
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} 上的身份,发布出来的游戏归它。{#if me.user.isAnonymous}匿名身份换浏览器就丢了——要长期用,先在站点上登录,再回来重新连接。{/if}</p>
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>本地体检</h2>
73
- {#if localIssues.length}{#each localIssues as i (i)}<p class="bad">✗ {i}</p>{/each}{:else}<p class="ok">✓ {info.artifacts.length} 张表就绪,最低实算 RTP {lowest !== null ? pct(lowest) : '-'}</p>{/if}
74
- <div class="row"><input placeholder="说明(可空)" bind:value={note} style="flex:1" /><button class="pri" onclick={request} disabled={!!localIssues.length}>发起发布请求</button></div>
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>发布请求</h2>
79
- {#if !reqs.length}<p class="hint">没有待处理的请求。AI 用 publish 工具发起的请求会出现在这里。</p>{/if}
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 === 'awaiting-approval'}>
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' || r.status === 'rejected'} class:warn={r.status === 'awaiting-approval'}>{r.status}</span>
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.status === 'awaiting-approval'}
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 running === r.id && log.length}<pre>{log.join('\n')}</pre>{:else if r.progress.length}<pre>{r.progress.join('\n')}</pre>{/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/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /* `gamekit777` 这个命令的入口:按子命令分派。
3
3
  *
4
4
  * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL] 起工作台(MCP 走 stdio)
5
- * gamekit777 new 在当前目录脚手架一个新游戏(my-game)并装依赖;改名改 game.meta.json
5
+ * gamekit777 new [名字] 新建游戏并装依赖:给名字 → 子目录 ./名字;不给 → 就地用当前(空)目录
6
6
  *
7
7
  * 只是薄薄一层分派,逻辑各在 bin.ts 和 src/scaffold.ts。 */
8
8
  const [sub, ...rest] = process.argv.slice(2);
@@ -10,7 +10,7 @@ const [sub, ...rest] = process.argv.slice(2);
10
10
  const usage = (): never => {
11
11
  process.stderr.write(`用法:
12
12
  gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL]
13
- gamekit777 new 在当前目录生成 my-game(占了加序号)并装依赖;名字之后改 game.meta.json
13
+ gamekit777 new [名字] 新建游戏并装依赖:给名字 → 生成 ./名字;不给 → 就地用当前目录(必须是空目录,id 取目录名)
14
14
 
15
15
  接 Claude Code:在游戏目录里
16
16
  claude mcp add gamekit -- bunx gamekit777 studio
@@ -25,12 +25,13 @@ switch (sub) {
25
25
  break;
26
26
  }
27
27
  case 'new': {
28
- // 刻意不收参数:名字、标题都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉
29
- if (rest.length > 0) usage();
28
+ /* 只收一个可选的名字。不给就用当前目录:AI 多半已经站在要建的目录里,再套一层 my-game 只会让它 cd 错地方。
29
+ 标题之类都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉 */
30
+ if (rest.length > 1 || rest[0]?.startsWith('-')) usage();
30
31
  const { scaffold } = await import('./src/scaffold.ts');
31
32
  try {
32
- const r = await scaffold({}, process.cwd());
33
- process.stdout.write(`✓ ${r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n cd ${r.dest} && bunx gamekit777 studio\n`);
33
+ const r = await scaffold({ name: rest[0] }, process.cwd());
34
+ process.stdout.write(`✓ ${r.inPlace ? `就地生成在 ${r.dest}` : r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n ${r.inPlace ? '' : `cd ${r.dest} && `}bunx gamekit777 studio\n`);
34
35
  } catch (e) {
35
36
  process.stderr.write(`✗ ${e instanceof Error ? e.message : String(e)}\n`);
36
37
  process.exit(1);