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
package/runtime/types.ts
CHANGED
|
@@ -1,29 +1,7 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|
|
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
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
|
/* ── 采样种子 ────────────────────────────────────────────────────── */
|
package/studio/app/App.svelte
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.
|
|
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
|
|
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)}
|
package/studio/app/lib/api.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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>
|