gamekit777 0.1.1 → 0.1.3

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 CHANGED
@@ -16,7 +16,7 @@
16
16
  ```sh
17
17
  bunx gamekit777 new # 一个能跑的抛硬币(my-game)+ bun install;名字改 game.meta.json
18
18
  cd my-game
19
- bun run dev # 参考平台:本地表模型,就地建表、抽行、restore 还原
19
+ bun run dev # 起 studio:http://127.0.0.1:4301/ ,预览 / 调参 / 建表 / Case / 发布
20
20
  ```
21
21
 
22
22
  接 Claude Code:
@@ -157,7 +157,7 @@ mount(host: Host<Config>, el: HTMLElement): Promise<GameInstance<Outcome>>
157
157
  - 长任务返回 `jobId` 就用 `job_status` 轮询。
158
158
  - `case_run` 任何结果变化都是 `fail`。是有意改的规则且这个 mode 没发布过,`case_bless` 接受;已发布过的 mode 解码逻辑**不能改**,要改就换 slug。
159
159
  - `preview_set` 被否决会在 `rejected` 里说明——那是平台的否决权在起作用,不是 bug。
160
- - 手动:`bun run dev`(参考平台,5173)、`bun run verify`、`bun run test`、`bun run build`。
160
+ - 手动:`bun run dev` 起 studio(页面 `http://127.0.0.1:4301/`,左边是预览机台,右边是状态 / 调参 / 建表 / Case / 发布 / 日志);`bun run verify`、`bun run test`、`bun run build`。
161
161
 
162
162
  ## 不要做的事
163
163
 
@@ -4,7 +4,7 @@
4
4
  "version": "1.0.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite",
7
+ "dev": "gamekit777 studio --no-mcp",
8
8
  "build": "svelte-check --tsconfig ./tsconfig.json && vite build",
9
9
  "check": "svelte-check --tsconfig ./tsconfig.json",
10
10
  "test": "vitest run",
@@ -16,32 +16,41 @@
16
16
  const { game } = inst;
17
17
 
18
18
  const label = (s: 'heads' | 'tails') => (s === 'heads' ? '正' : '反');
19
+ const money = (minor: number) => (minor / 100).toFixed(2);
19
20
  </script>
20
21
 
21
- <div class="game" class:flash={game.phase === 'over' && game.last?.win}>
22
+ <div class="game">
22
23
  <p class="head">押 <b>{label(game.side)}</b> · 猜中赔 {MULT.toFixed(2)}×</p>
23
24
 
24
25
  <div class="coin" class:spin={game.live} aria-live="polite">
25
26
  <span>{label(game.face)}</span>
26
27
  </div>
27
28
 
28
- <p class="foot" class:win={game.last?.win}>
29
+ <p class="foot" class:win={game.last?.win} class:lose={game.last && !game.last.win}>
29
30
  {#if game.phase === 'over' && game.last}
30
- {game.last.win ? `猜中了,${MULT.toFixed(2)}× 到手` : '没猜中'}
31
+ {game.last.win ? '猜中了' : '没猜中'}
31
32
  {:else if game.live}
32
33
  抛出中…
33
34
  {:else}
34
35
  在左边选一面,下注开始
35
36
  {/if}
36
37
  </p>
38
+
39
+ <!-- 赢了弹一下就走:不遮画面、不用点。key 到局号,连赢两局是两次弹出而不是一次拉长 -->
40
+ {#if game.toast}
41
+ {#key game.toast.id}
42
+ <div class="toast" style:animation-duration="{game.toast.ms}ms" aria-hidden="true">
43
+ <b>{game.toast.mult.toFixed(2)}×</b><span>+{money(game.toast.payout)}</span>
44
+ </div>
45
+ {/key}
46
+ {/if}
37
47
  </div>
38
48
 
39
49
  <style>
50
+ /* 不写 border-radius:外框和裁切归平台容器,游戏只管自己那块内容 */
40
51
  .game{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
41
- height:100%;min-height:240px;padding:28px;border-radius:16px;
52
+ height:100%;min-height:240px;padding:28px;
42
53
  background:var(--bg);color:var(--ink);font-family:var(--txt)}
43
- .game.flash::after{content:"";position:absolute;inset:0;pointer-events:none;border-radius:16px;
44
- background:var(--win);animation:flash var(--d-flash,500ms) ease-out forwards}
45
54
  .head{margin:0;font-size:13px;color:var(--dim)}
46
55
  .head b{color:var(--ink);font-size:16px}
47
56
  .coin{display:grid;place-items:center;width:104px;height:104px;border-radius:50%;
@@ -50,5 +59,14 @@
50
59
  .coin.spin{animation:pulse 420ms ease-in-out infinite}
51
60
  .foot{margin:0;min-height:1.5em;font-size:13px;color:var(--dim)}
52
61
  .foot.win{color:var(--win);font-weight:700}
53
- @media (prefers-reduced-motion:reduce){.coin.spin{animation:none}.game.flash::after{animation:none}}
62
+ .foot.lose{color:var(--lose)}
63
+
64
+ .toast{position:absolute;left:50%;top:22%;transform:translate(-50%,0);pointer-events:none;
65
+ display:flex;align-items:baseline;gap:10px;padding:10px 22px 12px;border-radius:18px;
66
+ background:var(--panel);color:var(--ink);box-shadow:0 0 0 3px var(--win),0 10px 30px rgba(0,0,0,.45);
67
+ animation:toast 1400ms ease-out forwards}
68
+ .toast b{font-family:var(--num);font-size:34px;line-height:1;color:var(--win)}
69
+ .toast span{font-family:var(--num);font-size:16px;color:var(--ink)}
70
+
71
+ @media (prefers-reduced-motion:reduce){.coin.spin{animation:none}.toast{animation-name:toastStill}}
54
72
  </style>
@@ -34,6 +34,8 @@ export class Game {
34
34
  face = $state<'heads' | 'tails'>('heads');
35
35
  last = $state<__PASCAL__Outcome | null>(null);
36
36
  lastPayout = $state(0);
37
+ /** 赢面小弹窗。id 让连赢两局弹两次;ms 是它的存活时间,到点自己清空 */
38
+ toast = $state<{ id: number; mult: number; payout: number; ms: number } | null>(null);
37
39
 
38
40
  live = $derived(this.phase === 'playing');
39
41
 
@@ -1,4 +1,13 @@
1
1
  /* 名字起得通用没关系:namespace-css 插件会在构建时把它们改成 <name>-<游戏 id>,
2
2
  同页两个游戏才不会互相覆盖 */
3
- @keyframes flash{from{opacity:.5}to{opacity:0}}
4
3
  @keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.1)}}
4
+ /* 赢面小弹窗:弹出 → 停住 → 淡出。时长由演出按 turbo 决定,写在 style 上 */
5
+ @keyframes toast{
6
+ 0%{opacity:0;transform:translate(-50%,10px) scale(.86)}
7
+ 14%{opacity:1;transform:translate(-50%,0) scale(1.04)}
8
+ 22%{transform:translate(-50%,0) scale(1)}
9
+ 78%{opacity:1;transform:translate(-50%,0)}
10
+ 100%{opacity:0;transform:translate(-50%,-10px)}
11
+ }
12
+ /* 减少动态效果:不动,只是出现再消失 */
13
+ @keyframes toastStill{0%,85%{opacity:1}100%{opacity:0}}
@@ -33,4 +33,13 @@ export async function present(
33
33
  game.last = o;
34
34
  game.lastPayout = payout;
35
35
  game.phase = 'over';
36
+
37
+ /* 赢了弹一个小窗,到点自己消失。present 不等它——弹窗是余韵,不该拖慢下一注(自动模式尤其)。
38
+ 清空前核对 id:这期间可能已经又赢了一局,别把新的那个一起抹掉 */
39
+ if (o.win) {
40
+ const ms = instant ? 900 : game.turbo ? 1000 : 1600;
41
+ game.toast = { id, mult: o.mult, payout, ms };
42
+ // 比 id 不比引用:$state 存进去的是 Proxy,读出来和原对象不相等
43
+ setTimeout(() => { if (game.toast?.id === id) game.toast = null; }, ms);
44
+ }
36
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamekit777",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gamekit777": "./studio/cli.ts"
@@ -65,7 +65,7 @@
65
65
  <main>
66
66
  <!-- 左:机台。和玩家站点同一台机器,宽度也照那边的 1200 内容宽 + 两侧 16 留白 -->
67
67
  <section class="machine">
68
- <iframe title="游戏预览" src="/__studio/preview.html"></iframe>
68
+ <iframe title="游戏预览" src="/preview"></iframe>
69
69
  </section>
70
70
 
71
71
  <!-- 右:编辑区,拉满剩余宽度 -->
@@ -1,7 +1,7 @@
1
1
  /* studio 页面的身份:始终是**站点上的那个人**,不在这里另建。
2
2
  *
3
3
  * 页面对 /v1 的请求同源,cookie 落在 127.0.0.1 下;第一次没有 cookie 时把整个窗口
4
- * 跳到站点的 #/connect 借身份,站点签一个一次性 code 带回 /__studio/connect,这里拿 code
4
+ * 跳到站点的 #/connect 借身份,站点签一个一次性 code 带回 /connect,这里拿 code
5
5
  * 经反代换一个同一用户的新会话。之后发布、下注都用这份 cookie。 */
6
6
  import type { SessionView } from 'gamekit777/protocol';
7
7
  import { client } from './upload.ts';
@@ -14,11 +14,11 @@ class Identity {
14
14
  error = $state<string | null>(null);
15
15
 
16
16
  connectUrl(site: string): string {
17
- const back = `${location.origin}/__studio/connect`;
17
+ const back = `${location.origin}/connect`;
18
18
  return `${site.replace(/\/+$/, '')}/#/connect?return=${encodeURIComponent(back)}`;
19
19
  }
20
20
 
21
- /** 页面启动:/__studio/connect 带 code 就先换会话,然后一律 GET /v1/me */
21
+ /** 页面启动:/connect 带 code 就先换会话,然后一律 GET /v1/me */
22
22
  async boot(site: string): Promise<void> {
23
23
  const code = new URLSearchParams(location.search).get('code');
24
24
  if (location.pathname.endsWith('/connect') && code) {
@@ -26,7 +26,7 @@ class Identity {
26
26
  try {
27
27
  await client.pair.claim(code);
28
28
  // 去掉地址栏里的 code:刷新不该再换一次(code 已作废,会报错)
29
- history.replaceState(null, '', '/__studio/');
29
+ history.replaceState(null, '', '/');
30
30
  } catch (e) {
31
31
  this.phase = 'error';
32
32
  this.error = `换会话失败:${e instanceof Error ? e.message : String(e)}`;
@@ -4,6 +4,7 @@
4
4
  * 这样预览走的就是 `bun run dev` 那条路——同一份 defineGameConfig、同一套 HMR,
5
5
  * 不存在「studio 里演得好好的,真 build 出来不一样」的分叉空间。 */
6
6
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { createServer as createNetServer } from 'node:net';
7
8
  import { join, resolve } from 'node:path';
8
9
  import { createLogger, createServer, searchForWorkspaceRoot, type Logger, type ProxyOptions, type ViteDevServer } from 'vite';
9
10
  import type { Engine } from './engine.ts';
@@ -75,7 +76,28 @@ function proxyRule(backendUrl: string): ProxyOptions {
75
76
  };
76
77
  }
77
78
 
79
+ /**
80
+ * 自己挑端口,不用 vite 的 strictPort:false 重试。
81
+ *
82
+ * vite 的重试是在第一次 listen 报 EADDRINUSE 后再 listen(++port),指望第一次挂上的 'listening'
83
+ * 回调在第二次成功时触发。Bun 的 node:http 兼容层里这个回调在出错后就没了——服务其实起在了新端口,
84
+ * 但 listen() 的 Promise 永远不 resolve,于是 MCP 不启动、横幅不打印,看上去就是「卡住」。
85
+ */
86
+ async function freePort(start: number, host: string): Promise<number> {
87
+ for (let port = start; port < start + 100; port++) {
88
+ const ok = await new Promise<boolean>((resolve) => {
89
+ const probe = createNetServer();
90
+ probe.once('error', () => resolve(false));
91
+ probe.listen(port, host, () => probe.close(() => resolve(true)));
92
+ });
93
+ if (ok) return port;
94
+ }
95
+ throw new Error(`${host}:${start} 起连续 100 个端口全被占了`);
96
+ }
97
+
78
98
  export async function startGameServer(o: GameServerOptions): Promise<ViteDevServer> {
99
+ const port = await freePort(o.port, '127.0.0.1');
100
+ if (port !== o.port) process.stderr.write(`端口 ${o.port} 被占,改用 ${port}\n`);
79
101
  const server = await createServer({
80
102
  root: o.gameDir,
81
103
  configFile: join(o.gameDir, 'vite.config.ts'),
@@ -87,8 +109,8 @@ export async function startGameServer(o: GameServerOptions): Promise<ViteDevServ
87
109
  studioRoot: o.studioRoot,
88
110
  })],
89
111
  server: {
90
- port: o.port,
91
- strictPort: false,
112
+ port,
113
+ strictPort: true,
92
114
  host: '127.0.0.1',
93
115
  /* 预览页嵌在 studio 页面的 iframe 里;同源,但照抄 apps/web 的做法放开——
94
116
  sandbox 属性一加 Origin 就是 null,默认的 cors 会挡 */
@@ -4,7 +4,7 @@
4
4
  * 发布要用浏览器 cookie 打反代到 /v1。分开起第二个 server 这三件事全要绕。
5
5
  *
6
6
  * 不往游戏目录写任何文件:两个 html 从内存吐,入口用 /@fs 指回 studio 包。
7
- * 为什么不直接用游戏自己的 index.html:那是给 `bun run dev` 的,指向 src/dev.ts。 */
7
+ * 不依赖游戏目录里有 index.html:脚手架出来的项目没有它,`bun run dev` 就是起 studio。 */
8
8
  import type { Plugin } from 'vite';
9
9
  import { API_PREFIX, createApi } from './api.ts';
10
10
  import type { Engine } from './engine.ts';
@@ -18,10 +18,14 @@ export interface StudioPluginOptions {
18
18
  }
19
19
 
20
20
  export const VIRTUAL_GAME = 'virtual:studio/game';
21
- export const PREVIEW_PATH = '/__studio/preview.html';
22
- export const PAGE_PATH = '/__studio/';
21
+ /** 根路径就是工作台;游戏目录里没有 index.html,`/` 不会和 vite 的 html 服务打架 */
22
+ export const PAGE_PATH = '/';
23
+ /** 只要预览机台(嵌在页面左边的那个 iframe) */
24
+ export const PREVIEW_PATH = '/preview';
23
25
  /** 站点授权完带 code 回到这里,页面拿 code 换会话 */
24
- export const CONNECT_PATH = '/__studio/connect';
26
+ export const CONNECT_PATH = '/connect';
27
+ /** 早期版本的路径,留作别名 */
28
+ const LEGACY = { page: ['/__studio', '/__studio/', '/__studio/index.html'], preview: ['/__studio/preview.html'], connect: ['/__studio/connect'] };
25
29
 
26
30
  const page = (title: string, entry: string): string => `<!doctype html>
27
31
  <html lang="zh-CN">
@@ -52,9 +56,9 @@ export function studioPlugin(o: StudioPluginOptions): Plugin {
52
56
  configureServer(server) {
53
57
  server.middlewares.use(api);
54
58
  server.middlewares.use((req, res, next) => {
55
- const path = req.url?.split('?')[0];
56
- const html = path === PREVIEW_PATH ? previewHtml
57
- : path === PAGE_PATH || path === '/__studio' || path === '/__studio/index.html' || path === CONNECT_PATH ? appHtml
59
+ const path = req.url?.split('?')[0] ?? '/';
60
+ const html = path === PREVIEW_PATH || LEGACY.preview.includes(path) ? previewHtml
61
+ : path === PAGE_PATH || path === CONNECT_PATH || path === '/index.html' || LEGACY.page.includes(path) || LEGACY.connect.includes(path) ? appHtml
58
62
  : null;
59
63
  if (!html) return next();
60
64
  void server.transformIndexHtml(req.url!, html).then((out) => {
@@ -1,11 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <title>__TITLE__</title>
7
- <script type="module" src="/src/dev.ts"></script>
8
- </head>
9
- <body>
10
- </body>
11
- </html>
@@ -1,8 +0,0 @@
1
- /* 本地开发的宿主。index.html 指向它。
2
-
3
- 外壳来自 gamekit777/dev-host——一个**泛型**参考平台,驱动任何符合契约的游戏。
4
- 它不认识这个游戏:设置控件按 schema 渲染,赔率表来自清单的 paytable。 */
5
- import { runDevShell } from 'gamekit777/dev-host';
6
- import game from './index.ts';
7
-
8
- runDevShell(game);