gamekit777 0.1.12 → 0.1.14
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/create-game/template/base/src/components/App.svelte +2 -5
- package/create-game/template/base/src/effects.ts +11 -6
- package/create-game/template/base/src/game.svelte.ts +4 -4
- package/create-game/template/base/src/index.ts +1 -1
- package/create-game/template/base/src/present.ts +16 -20
- package/create-game/template/base/src/styles/global.css +2 -2
- package/create-game/template/base/src/tempo.ts +4 -0
- package/create-game/template/overlay/AGENTS.md +32 -23
- package/dev-host/DevShell.svelte +2 -2
- package/dev-host/controls/Hud.svelte +12 -7
- package/dev-host/controls/model.ts +2 -0
- package/dev-host/platform.svelte.ts +11 -4
- package/dev-host/settings.ts +6 -3
- package/dev-host/theme.css +8 -0
- package/docs/view.md +132 -75
- package/package.json +1 -1
- package/protocol/assets.ts +4 -2
- package/sdk/ease.ts +36 -2
- package/sdk/effect.ts +36 -20
- package/sdk/host.ts +10 -2
- package/sdk/index.ts +2 -3
- package/sdk/platform.ts +0 -1
- package/sdk/schema.ts +1 -1
- package/studio/app/lib/asset-preview/App.svelte +13 -0
- package/studio/app/lib/asset-preview/game.svelte.ts +44 -0
- package/studio/app/lib/asset-preview/mount.ts +37 -0
- package/studio/app/panels/AssetsPanel.svelte +22 -2
- package/studio/preview/entry.ts +7 -6
- package/studio/src/mcp.ts +5 -5
- package/studio/src/studio-plugin.ts +7 -2
- package/svelte/AnimLayer.svelte +1 -1
- package/svelte/Queue.svelte +2 -2
- package/svelte/Root.svelte +7 -2
- package/svelte/co.ts +8 -5
- package/svelte/conformance.ts +95 -19
- package/svelte/dom.ts +12 -1
- package/svelte/effects.svelte.ts +145 -35
- package/svelte/game.svelte.ts +29 -18
- package/svelte/keyframes.ts +37 -61
- package/svelte/loops.ts +74 -0
- package/svelte/mount.svelte.ts +2 -2
- package/svelte/particle.ts +22 -17
- package/svelte/particles.svelte.ts +4 -3
- package/svelte/present.svelte.ts +45 -37
- package/svelte/round-game.svelte.ts +3 -3
- package/svelte/rounds.svelte.ts +3 -2
- package/svelte/staged-game.svelte.ts +2 -2
- package/svelte/staged.ts +10 -11
- package/svelte/testing.ts +2 -2
- package/svelte/view.ts +1 -1
- package/vite-config/index.js +117 -136
- package/vite-config/index.ts +1 -0
- package/vite-config/namespace-css.ts +89 -119
- package/vite-config/svelte-subset.ts +3 -3
- package/sdk/tempo.ts +0 -9
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/* 游戏的根组件。平台化之后它只有画面——
|
|
3
3
|
注额、押哪一面、音效、极速都是平台该渲染的 UI。
|
|
4
|
+
抛出中硬币一鼓一缩是循环表的 spin(src/effects.ts),挂在 .coin 上、在演时播;不写 CSS animation,减弱动效时框架自己停。
|
|
4
5
|
|
|
5
6
|
实例经 context 往下传(gamekit777/svelte 的 composeGame 设好),子组件一律 useGame(),
|
|
6
7
|
不 import 任何状态对象,这样同页挂两个实例才不会互相污染。
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
<div class="game">
|
|
17
18
|
<p class="head">{@html t('head', { side: `<b>${label(game.config.side)}</b>`, mult: MULT.toFixed(2) })}</p>
|
|
18
19
|
|
|
19
|
-
<div class="coin"
|
|
20
|
+
<div class="coin" {@attach game.loops.spin.when(game.rounds.live)} aria-live="polite">
|
|
20
21
|
<span>{label(game.state.face)}</span>
|
|
21
22
|
</div>
|
|
22
23
|
|
|
@@ -44,11 +45,7 @@
|
|
|
44
45
|
.coin{display:grid;place-items:center;width:104px;height:104px;border-radius:50%;
|
|
45
46
|
background:var(--mark);color:#241A00;font-family:var(--num);font-size:38px;font-weight:700;
|
|
46
47
|
box-shadow:inset 0 -6px 0 rgba(0,0,0,.18)}
|
|
47
|
-
/* 时长与关键帧来自关键帧表(src/effects.ts 的 spin),框架注入成 --a-spin */
|
|
48
|
-
.coin.spin{animation:var(--a-spin) ease-in-out infinite}
|
|
49
48
|
.foot{margin:0;min-height:1.5em;font-size:13px;color:var(--dim)}
|
|
50
49
|
.foot.win{color:var(--win);font-weight:700}
|
|
51
50
|
.foot.lose{color:var(--lose)}
|
|
52
|
-
|
|
53
|
-
@media (prefers-reduced-motion:reduce){.coin.spin{animation:none}}
|
|
54
51
|
</style>
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* 效果表与循环表。翻动音是过程效果;输赢音与赢面牌子标 always,快进后也要听到、看到。
|
|
2
|
+
动画不要用 JS 逐帧驱动(跨源 iframe 里 iOS 会把 rAF 限到 30fps),都交给 WAAPI:
|
|
3
|
+
发生一次的(弹一下、翻开、飞过去)→ 效果表里的 effect.keyframes,位置演出时才知道就把 frames 写成 payload 的函数;
|
|
4
|
+
处在某个状态期间一直循环的(呼吸、旋转、闪烁)→ 循环表 defineLoops,模板里 {@attach game.loops.x.when(条件)};
|
|
5
|
+
只有数字读数、必须进 state 的量才用 ctx.tween。
|
|
2
6
|
改成你自己的游戏时:哪些东西该进哪张表、always 怎么定,见 node_modules/gamekit777/docs/view.md「先分清三类东西」。 */
|
|
3
|
-
import { sound, effect, defineEffects,
|
|
7
|
+
import { sound, effect, defineEffects, defineLoops } from 'gamekit777/svelte';
|
|
8
|
+
import { T } from './tempo.ts';
|
|
4
9
|
|
|
5
10
|
export const effects = defineEffects({
|
|
6
11
|
flip: effect.sound(sound.tone({ f: 880, dur: 0.05, type: 'triangle', gain: 0.08 })),
|
|
7
12
|
win: effect.sound(sound.chord({ notes: [659, 784, 988], step: 0.08, dur: 0.28, gain: 0.12 }), { always: true }),
|
|
8
13
|
lose: effect.sound(sound.tone({ f: 220, dur: 0.18, type: 'sine', gain: 0.1, slide: 140 }), { always: true }),
|
|
9
14
|
/** 赢了弹一下就走。tier 0 只有牌子,不遮画面、不用点;自动模式连赢也只是连弹两次 */
|
|
10
|
-
plate: effect.builtin('big-win', { always: true }),
|
|
15
|
+
plate: effect.builtin('big-win', { always: true, ms: T.linger }),
|
|
11
16
|
});
|
|
12
17
|
|
|
13
|
-
export const
|
|
14
|
-
/**
|
|
15
|
-
spin: { ms: 420, frames: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)', offset: 0.5 }, { transform: 'scale(1)' }] },
|
|
18
|
+
export const loops = defineLoops({
|
|
19
|
+
/** 抛出中:硬币一鼓一缩(App 里 {@attach game.loops.spin.when(game.rounds.live)}) */
|
|
20
|
+
spin: { ms: 420, easing: 'ease-in-out', frames: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)', offset: 0.5 }, { transform: 'scale(1)' }] },
|
|
16
21
|
});
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
做成可实例化的类而不是模块级单例:同页挂两个实例时各自一套状态。 */
|
|
6
6
|
import { RoundGame } from 'gamekit777/svelte';
|
|
7
7
|
import type { CoinFlip, CoinFlipState } from './rules/types.ts';
|
|
8
|
-
import { effects,
|
|
8
|
+
import { effects, loops } from './effects.ts';
|
|
9
9
|
import App from './components/App.svelte';
|
|
10
10
|
import { presenter } from './present.ts';
|
|
11
11
|
|
|
12
|
-
export class Game extends RoundGame<CoinFlip, typeof effects, typeof
|
|
13
|
-
/*
|
|
12
|
+
export class Game extends RoundGame<CoinFlip, typeof effects, typeof loops> {
|
|
13
|
+
/* 效果表与循环表交给基类:写成静态字段,不用写构造函数(框架每次 mount new 一个) */
|
|
14
14
|
static override App = App;
|
|
15
15
|
static override presenter = presenter;
|
|
16
16
|
static override effects = effects;
|
|
17
|
-
static override
|
|
17
|
+
static override loops = loops;
|
|
18
18
|
|
|
19
19
|
/** 挂载时的画面:正面朝上、没有上一局 */
|
|
20
20
|
static override initial(): CoinFlipState { return { face: 'heads', last: null }; }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* 演出模块:game.ts 的 view 惰性指向这里,浏览器入口由构建预设生成、经 gamekit777/svelte 的 composeGame 挂起来。
|
|
2
|
-
一个游戏在演出侧只声明一个类(game.svelte.ts):App
|
|
2
|
+
一个游戏在演出侧只声明一个类(game.svelte.ts):App、演出把手、效果表、循环表都是它的静态字段。
|
|
3
3
|
这个文件 import 了样式、间接 import 了 Svelte 组件,Node 侧永远不会执行它。 */
|
|
4
4
|
import './styles/global.css';
|
|
5
5
|
export { Game as default } from './game.svelte.ts';
|
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
/* 演出:把已经算好的结果播出来。
|
|
2
2
|
|
|
3
|
-
判定一行都不在这里——outcome
|
|
3
|
+
判定一行都不在这里——outcome 是传进来的;极速、减弱动效由框架施加在演出上,改不了结果,
|
|
4
4
|
因为它们根本不在 round() 的入参里。
|
|
5
|
-
run 是生成器:yield
|
|
5
|
+
run 是生成器:yield 出去的是「要等什么」,什么时候接着跑由框架定——快进、直接看结果、减弱动效时不等,局被作废时不再往下跑。
|
|
6
6
|
只写 g.state 和发效果。翻动中途的那一面也写在 state 里,演完一定落在开出的那面:正常演、快进、直接看结果,最后的 state 一样(一致性套件验)。
|
|
7
|
+
节拍是 tempo.ts 里的常数,这里不看 turbo / reducedMotion。
|
|
7
8
|
装饰性随机只能来自 ctx.rand(按这一局的种子播种,回放一模一样);这个游戏暂时没用到。 */
|
|
8
9
|
import { definePresent } from 'gamekit777/svelte';
|
|
9
10
|
import type { CoinFlip } from './rules/types.ts';
|
|
11
|
+
import { T } from './tempo.ts';
|
|
10
12
|
|
|
11
13
|
const money = (minor: number): string => (minor / 100).toFixed(2);
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
turbo: { flip: 90, total: 320, linger: 1000 },
|
|
18
|
-
reduced: { flip: 90, total: 0, linger: 900 },
|
|
19
|
-
},
|
|
15
|
+
/* 翻动几下再定格。次数只是节奏(总时长 / 翻一下多久),不占用可复现的随机数 */
|
|
16
|
+
const STEPS = Math.max(2, Math.round(T.total / T.flip));
|
|
17
|
+
|
|
18
|
+
export const presenter = definePresent<CoinFlip>({
|
|
20
19
|
*run(ctx, g, r) {
|
|
21
20
|
const o = r.outcome;
|
|
22
|
-
/*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
ctx.fx.flip();
|
|
28
|
-
yield ctx.delay(ctx.T.total / steps);
|
|
29
|
-
}
|
|
21
|
+
/* 翻面是离散的几下(每 90ms 换一次字),不是逐帧驱动的动画;硬币一鼓一缩是循环表的 spin(WAAPI) */
|
|
22
|
+
for (let i = 0; i < STEPS; i++) {
|
|
23
|
+
g.state.face = i % 2 === 0 ? 'tails' : 'heads';
|
|
24
|
+
ctx.fx.flip();
|
|
25
|
+
yield ctx.delay(T.total / STEPS);
|
|
30
26
|
}
|
|
31
27
|
g.state.face = o.landed;
|
|
32
28
|
g.state.last = o;
|
|
@@ -34,8 +30,8 @@ export const presenter = definePresent<CoinFlip, { flip: number; total: number;
|
|
|
34
30
|
settle(ctx, g, r) {
|
|
35
31
|
const o = r.outcome;
|
|
36
32
|
(o.win ? ctx.fx.win : ctx.fx.lose)();
|
|
37
|
-
/* 牌子是余韵,present
|
|
38
|
-
/* 游戏自己声明的设置在 g.settings 里(和平台内置的
|
|
39
|
-
if (o.win && g.settings.toast) ctx.fx.plate({ tier: 0, mult: `${r.multiplier.toFixed(2)}×`, amount: `+${money(r.payout)}` }
|
|
33
|
+
/* 牌子是余韵,present 不等它——弹窗不该拖慢下一注(自动模式尤其);停多久写在效果表里,下一局开始时框架会清掉 */
|
|
34
|
+
/* 游戏自己声明的设置在 g.settings 里(和平台内置的 sound / locale 合在一起) */
|
|
35
|
+
if (o.win && g.settings.toast) ctx.fx.plate({ tier: 0, mult: `${r.multiplier.toFixed(2)}×`, amount: `+${money(r.payout)}` });
|
|
40
36
|
},
|
|
41
37
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/* 游戏自己的设计变量和 reset。
|
|
2
2
|
|
|
3
3
|
构建时 @gamekit/vite-config 的 namespace-css 插件会把这里的 :root / html / body
|
|
4
|
-
收敛到游戏根元素 .gk-<id
|
|
5
|
-
|
|
4
|
+
收敛到游戏根元素 .gk-<id>:同页可能挂着两个游戏,变量不能互相覆盖。
|
|
5
|
+
这里不写 @keyframes(构建直接报错):一次性的动画进效果表 effect.keyframes,循环的进 defineLoops。
|
|
6
6
|
|
|
7
7
|
页面外框(body 的底色、外边距)不放这里:那是平台的地盘。 */
|
|
8
8
|
:root{
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
game.ts 唯一入口:export default { meta, schema, settings, modes, book, paytable, round, restore, view } satisfies GameManifest<CoinFlip>。纯对象、只有类型导入。Node 与浏览器都 import 它,不能碰 DOM / Svelte
|
|
29
29
|
src/rules/ 纯逻辑:schema、settings(as const,类型从它们反推)、restore、常量、types.ts(一个接口 CoinFlip 描述整个游戏)。零 DOM、零自取随机
|
|
30
30
|
src/index.ts 演出模块:import 全局样式、把游戏类 Game 作为默认导出,game.ts 的 view 惰性指向它
|
|
31
|
-
src/present.ts definePresent({
|
|
32
|
-
src/effects.ts 效果表 defineEffects(声音、keyframes
|
|
31
|
+
src/present.ts definePresent({ run, settle, concurrent?, interval? }):一局怎么演。run / settle 是 function*,要等什么就 yield 什么(ctx.delay / tween / 子时间线),只写 game.state 和发效果 ctx.fx.<名字>()
|
|
32
|
+
src/effects.ts 效果表 defineEffects(声音、keyframes、组件、粒子、台词)与循环表 defineLoops(处在某个状态期间一直循环的动画):每样演出只在这里声明一次
|
|
33
|
+
src/tempo.ts 节拍:export const T = { … } as const,效果表的 ms 和时间线都读它,两边的数字对得上
|
|
33
34
|
src/use-game.ts export const useGame: () => Instance<CoinFlip> = useGame(框架的泛型版钉上类型)
|
|
34
|
-
src/game.svelte.ts 游戏类 extends RoundGame<CoinFlip>(多阶段用 StagedGame、带台面用 BoardGame):App
|
|
35
|
+
src/game.svelte.ts 游戏类 extends RoundGame<CoinFlip>(多阶段用 StagedGame、带台面用 BoardGame):App、演出把手、效果表、循环表、initial(game.state 的初值)是它的静态字段;类上只放三样:声明(静态字段、锚点把手);编排和组件都要用的派生与改 state 的方法;玩家在画布上的选择与交互。state / config / settings 的字段不再包 getter(模板直接读 game.state.x),只有组件用的尺寸、颜色、格式化写在组件里。平台状态(game.config / settings / run / stage)直接读,要随它变的用 $derived,生命周期钩子 onMount / onReset / onDestroy 是它上面的方法
|
|
35
36
|
src/components/ 画面。子组件一律 useGame()(src/use-game.ts:框架的 useGame 钉上 CoinFlip 类型再导出),不 import 状态对象
|
|
36
37
|
src/locales/ 文案:zh-CN.json、en.json,扁平 key-value,组件用 t(key, params)
|
|
37
38
|
src/assets/ 图片、声音随意放,import 就进清单。加载、进度、预取见 node_modules/gamekit777/docs/assets.md
|
|
@@ -55,18 +56,25 @@ studio/cases.jsonl case 库(进版本控制),studio 维护
|
|
|
55
56
|
- 不写 `normalize` / `validate` / `modeOf`,不加版本号。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**,要改就换 slug。
|
|
56
57
|
- **编排是生成器**:`*run(ctx, g, r)`、`*settle`、`*idle`,要等什么就 `yield` 什么——`yield ctx.delay(ms)` / `ctx.tween(…)` / 子时间线;辅助函数也写 `function*`,调用处 `yield* helper(…)`(漏了 `yield*` 它一行都不跑)。不写 `async` / `await` / `setTimeout` / `sleep`:快进要不等、作废要不再往下跑,自己写的等做不到。
|
|
57
58
|
- 装饰随机只用 `rand`(`ctx.rand` / `game.ambient`),`Math.random` 构建期拦。
|
|
58
|
-
- **演出里的每样东西先分类**(跳过它终态会不会错?):画面上是什么写 `game.state
|
|
59
|
-
- **画面上是什么全在 `game.state
|
|
59
|
+
- **演出里的每样东西先分类**(跳过它终态会不会错?):画面上是什么写 `game.state`;处在某个状态期间一直循环的进循环表、模板 `{@attach game.loops.x.when(条件)}`;一次性事件进效果表、`ctx.fx.<名字>()`。详见 `node_modules/gamekit777/docs/view.md`「先分清三类东西」。
|
|
60
|
+
- **画面上是什么全在 `game.state`**(结果、翻到一半的那面、飞过去的牌的终点、滚到一半的数字、在落的球),**编排只写它和发效果**:直接给游戏类的字段赋值(`g.shown = 1`)开发期当场抛。
|
|
60
61
|
一致性套件会拿同一局按正常演完、演到一半快进、直接看结果各演一遍,比最后的 `game.state`,不一样就报字段。所以:结果无条件写(不包在 `if (!ctx.quiet())` 里);只在动画中途有意义的字段演完回到固定值;画面上的数字照抄 outcome 里现成的值,不在编排里重算;效果的钩子(`effect.data` 的 `end`)不写 state。
|
|
61
62
|
- **演出里触发效果只用 `ctx.fx`**:它过这一局的闸、按这一局派发随机流。`game.fx` 是局外的把手:模板里 `{@attach game.fx.x}` 绑目标、发呆与交互时触发。
|
|
62
63
|
- **一次演几局由 `definePresent({ concurrent, interval })` 定**:默认 1(新局开始时清场);连着下的游戏(Plinko)写 `concurrent: Infinity, interval: 120`,演出中也能下注。
|
|
63
64
|
这时每局自己的东西(球、尾迹)放进 `game.state` 里的数组、演完移除,共享的盘面状态(发光的格子)按局合并。详见 `node_modules/gamekit777/docs/view.md`「同时演几局」。
|
|
64
|
-
-
|
|
65
|
+
- **动画先定级别**:有没有别的东西要等它演完?有就是编排,写进演出把手的时间线;没有就是状态外观,演出把手只写状态,循环交给循环表。一排会挪动的格子(开奖历史、历史牌)用框架的 `Queue`,不用 Svelte 的 `animate:` / `transition:`。一个元素的一个属性只有一个主人,两样都要动就拆两层。详见 `node_modules/gamekit777/docs/view.md`「一个动画归哪一级」。
|
|
66
|
+
- **动画不要用 JS 逐帧驱动,除非确实做不到。** 游戏跑在跨源的沙箱 iframe 里,玩家没在 iframe 里点过时 iOS 把 rAF 限到 30fps:逐帧 JS 的动画在 iPhone 上只有 30fps,主线程一卡它也跟着卡。WAAPI 在合成线程上播,不受这些影响。按这个顺序选:
|
|
67
|
+
1. **发生一次的**(弹一下、翻开、飞过去、抖一下)→ 效果表里的 `effect.keyframes`。位置要演出时才知道的,把 `frames` 写成 payload 的函数,触发 `ctx.fx.x(k, payload)`。state 里直接写终态,效果只播过程。
|
|
68
|
+
2. **处在某个状态期间一直循环的**(呼吸、旋转、闪烁)→ `defineLoops`,模板里 `{@attach game.loops.x.when(条件)}`(一直播就 `{@attach game.loops.x}`)。
|
|
69
|
+
3. **框架表达不了的** → `effect.dom` 的 `commit` 里用 `x.animate(el, frames, timing)` 手写 WAAPI(倍率、极速、减弱动效、作废框架替你管)。别的地方不裸调 `el.animate`。
|
|
70
|
+
4. **只有数字读数、或由逻辑逐帧决定且必须进 state 的量**,才用 `ctx.tween` / 时间线 `.to()`。
|
|
71
|
+
不写全局 `@keyframes`(独立 .css 里的、组件里 `-global-` / `:global` 的,dev 与 build 直接报错;组件作用域的只在组件型效果内部用),不用 CSS `animation` / `transition` 做演出。
|
|
65
72
|
- 画布里只改演出状态、不经平台的交互(切视图、选面值、翻页)在处理函数里调 `game.touch()`,否则空闲编排可能刚好到点,把玩家刚打开的东西收回去。
|
|
66
|
-
-
|
|
73
|
+
- 声音、动画、粒子、台词只经效果表与循环表:不 `new AudioContext`、不碰 `<audio>`,手写 WAAPI 只用上面第 3 档的 `x.animate`。它们因此自动服从快进、追赶、直接看结果、倍率、极速与减弱动效。
|
|
74
|
+
- **节拍是游戏自己的一组常数**(`src/tempo.ts` 的 `T`),不按设置换数字。极速、减弱动效由框架统一处理:极速时这一局的等待与效果按平台给的倍率加速,减弱动效时每一段直接落到终态、只放 always 的效果,keyframes 与循环不播。编排里不读 `turbo`,也不写 `prefers-reduced-motion`。
|
|
67
75
|
- **效果不用判 `ctx.quiet()`**:快进、从后台回来追赶时框架丢掉非 always 的效果;结算要看到、听到的标 `always`(声明时或调用处)。粒子没有 always。`ctx.quiet()` 只用来跳过游戏自己的装饰(压牌前多停一下),结果不许看它决定写不写。
|
|
68
76
|
- 不为触发动画在状态里养计数器(`shakeToken++`):模板里 `{@attach game.fx.shake}` 绑目标,present 里 `ctx.fx.shake()` 就播。
|
|
69
|
-
- 要让框架知道一个元素,只有一种写法:`{@attach 把手}`。动画目标挂效果把手(`{@attach game.fx.shake}`、`{@attach game.fx.peg.at(k)}`);粒子、飘字的位置挂锚点把手(游戏类上 `readonly ticket = this.stage.anchor()` / `cells = this.stage.anchors()`,模板 `{@attach game.ticket}`,present 里 `at: game.ticket.or('center')`,`or` 必填)。不 `querySelector`、不在模块里存元素。
|
|
77
|
+
- 要让框架知道一个元素,只有一种写法:`{@attach 把手}`。动画目标挂效果把手(`{@attach game.fx.shake}`、`{@attach game.fx.peg.at(k)}`),循环挂循环把手(`{@attach game.loops.x.when(条件)}`);粒子、飘字的位置挂锚点把手(游戏类上 `readonly ticket = this.stage.anchor()` / `cells = this.stage.anchors()`,模板 `{@attach game.ticket}`,present 里 `at: game.ticket.or('center')`,`or` 必填)。不 `querySelector`、不在模块里存元素。
|
|
70
78
|
- 读 DOM 只经锚点把手:为 state 量坐标在协程里直接 `g.x.box()`(舞台逻辑像素)或 `g.x.el`,写完 state 马上量就是新的(框架先刷新),量不到(单测里没有 DOM)是 null 要容忍;装饰写成 `effect.dom`(框架在这一批刷新之后执行)。量出来的值只管画在哪,不决定 state 里有什么:state 里的列表按固定上限裁,一排摆得下几张是组件按量到的宽度决定的(摆不下的藏起来)。框架保证 keyframes / 粒子这类读 DOM 的效果在刷新之后执行,所以先写状态、再 fire 总是对的。
|
|
71
79
|
- 只写 Svelte 5 runes(`$state` / `$props` / `$derived` / `onclick`):编译器钉了 `runes: true`,`export let`、`$:`、`on:click` 直接编译报错。
|
|
72
80
|
- 所有 import 都写 `gamekit777/svelte`(测试多一个 `gamekit777/svelte/test`)。`game.ts` / `rules/` 在 Node 里跑,那里只能有 `import type`——清单是纯对象,`round` 收到的 `input.config` 已填满、`input.rng` 已播好,不需要任何函数。例外是 `scripts/verify.ts` 与测试(要 `defineGame` / `createRng` 的值),从 `gamekit777/sdk` 拿。`gamekit777/sdk/platform` 与 `gamekit777/runtime` 是平台和工具的面,游戏里不出现。
|
|
@@ -80,45 +88,47 @@ studio/cases.jsonl case 库(进版本控制),studio 维护
|
|
|
80
88
|
## 演出层(最小示例;三类东西、时间线、效果选项、反例清单见 `node_modules/gamekit777/docs/view.md`)
|
|
81
89
|
|
|
82
90
|
```ts
|
|
91
|
+
// src/tempo.ts —— 节拍只有这一份,效果表和时间线都读它
|
|
92
|
+
export const T = { flip: 100, pause: 90, spin: 420 } as const;
|
|
93
|
+
|
|
83
94
|
// src/effects.ts —— 每样演出只声明一次
|
|
84
95
|
export const effects = defineEffects({
|
|
85
96
|
flip: effect.sound(sound.tone({ f: 880, dur: 0.05 })), // 过程效果:快进、追赶时框架丢掉
|
|
86
97
|
win: effect.sound({ src: winMp3, fallback: sound.chord({ notes: [659, 784, 988], step: 0.08, dur: 0.28 }) }, { always: true }), // 结果效果
|
|
87
|
-
shake: effect.keyframes({ ms: 320, frames: [...] }), //
|
|
98
|
+
shake: effect.keyframes({ ms: 320, frames: [...] }), // 发生一次的:模板 {@attach g.fx.shake} 绑目标,ctx.fx.shake() 播
|
|
88
99
|
linein: effect.keyframes({ ms: 160, frames: [{ opacity: 0 }, { opacity: 1 }] }), // 或 {@attach g.fx.linein.on(token)}:token 变了就播
|
|
100
|
+
fly: effect.keyframes({ ms: 300, frames: (p: { dx: number; dy: number }) => [{ transform: `translate(${p.dx}px, ${p.dy}px)` }, { transform: 'none' }] }), // 动态的:state 已写在终点,从原处播过来;ctx.fx.fly(undefined, { dx, dy })
|
|
89
101
|
plate: effect.builtin('big-win', { always: true }), // 内置牌子,画在框架的覆盖层上
|
|
90
102
|
spark: effect.particle('burst', { count: 16 }), // 粒子:纯装饰,没有 always
|
|
91
103
|
say: effect.data<string>({ slots: 1, hold: 800 }), // 台词:模板读 game.fx.say.current
|
|
92
104
|
});
|
|
93
|
-
export const
|
|
94
|
-
spin: { ms:
|
|
105
|
+
export const loops = defineLoops({
|
|
106
|
+
spin: { ms: T.spin, frames: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)', offset: 0.5 }, { transform: 'scale(1)' }] }, // 处在某个状态期间一直循环
|
|
95
107
|
});
|
|
96
108
|
|
|
97
109
|
// src/game.svelte.ts —— 画面上是什么在 game.state(形状写在 rules/types.ts 的 state 键),模板直接读;类上只放声明、共用的派生、画布交互
|
|
98
|
-
export class Game extends RoundGame<CoinFlip, typeof effects, typeof
|
|
110
|
+
export class Game extends RoundGame<CoinFlip, typeof effects, typeof loops> {
|
|
99
111
|
static override App = App; // 演出侧只声明这一个类:App、演出把手、两张表都是静态字段
|
|
100
112
|
static override presenter = presenter;
|
|
101
113
|
static override effects = effects;
|
|
102
|
-
static override
|
|
114
|
+
static override loops = loops;
|
|
103
115
|
static override initial(): CoinFlip['state'] { return { face: 'heads', last: null }; } // game.state 的初值
|
|
104
116
|
readonly coin = this.stage.anchor(); // 锚点把手:模板 {@attach game.coin}
|
|
105
117
|
protected override onReset(): void { this.state.last = null; } // 收掉上一局:结果不作数了,硬币停在哪面还是哪面
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// src/present.ts —— 生成器;辅助协程也写 function*,调用处 yield*
|
|
109
|
-
function* celebrate(ctx: PresentCtx<CoinFlip
|
|
121
|
+
function* celebrate(ctx: PresentCtx<CoinFlip>) {
|
|
110
122
|
ctx.fx.say(ctx.rand.pick(['漂亮', '到手'])); // ctx.rand 回放一致
|
|
111
|
-
yield ctx.delay(
|
|
123
|
+
yield ctx.delay(T.pause);
|
|
112
124
|
ctx.fx.plate({ tier: 0, mult: '2.00×', amount: '+2.00' }); // 效果表里标了 always:快进也放
|
|
113
125
|
}
|
|
114
|
-
|
|
115
|
-
export const presenter = definePresent<CoinFlip, T>({
|
|
116
|
-
tempo: { normal: { flip: 100, pause: 90 }, turbo: { flip: 40, pause: 50 }, reduced: { flip: 0, pause: 90 } },
|
|
126
|
+
export const presenter = definePresent<CoinFlip>({
|
|
117
127
|
*run(ctx, g, r) {
|
|
118
128
|
for (let i = 0; i < 6; i++) {
|
|
119
129
|
g.state.face = i % 2 ? 'heads' : 'tails'; // 翻动中途的那一面也写 state;最后一定落在开出的那面
|
|
120
130
|
ctx.fx.flip(); // 效果:不判 quiet
|
|
121
|
-
yield ctx.delay(
|
|
131
|
+
yield ctx.delay(T.flip); // 要等就 yield:按实例时钟、跟随倍率与极速、后台时停住,快进不等,作废不再往下跑
|
|
122
132
|
}
|
|
123
133
|
g.state.face = r.outcome.landed; // 结果:无条件写
|
|
124
134
|
g.state.last = r.outcome;
|
|
@@ -135,14 +145,13 @@ export const presenter = definePresent<CoinFlip, T>({
|
|
|
135
145
|
```
|
|
136
146
|
|
|
137
147
|
```svelte
|
|
138
|
-
<!-- src/components/App.svelte -->
|
|
148
|
+
<!-- src/components/App.svelte:抖动和循环都动 transform,拆两层 -->
|
|
139
149
|
<script lang="ts">const { game } = useGame();</script>
|
|
140
|
-
<div class="coin"
|
|
150
|
+
<div {@attach game.fx.shake}><div class="coin" {@attach game.loops.spin.when(game.rounds.live)} {@attach game.coin}>{game.state.face}</div></div>
|
|
141
151
|
<p class="line" {@attach game.fx.linein.on(game.fx.say.current)}>{game.fx.say.current ?? ''}</p>
|
|
142
|
-
<style>.coin.spin{animation:var(--a-spin) ease-in-out infinite}</style>
|
|
143
152
|
```
|
|
144
153
|
|
|
145
|
-
`{@attach}` 只接受 keyframes
|
|
154
|
+
`{@attach}` 只接受 keyframes 效果的把手、循环把手(`game.loops.x` / `.when(条件)`)和锚点把手,挂到别的效果上是类型错误;动态 keyframes 没有 `.on`;把手是对象引用,名字写错也是类型错误。
|
|
146
155
|
`test/conformance.test.ts` 已带:`for (const c of conformanceCases(game)) test(c.name, c.run)`。挂了就是用了框架之外的等待 / 随机 / 定时器 / 声音动画,或者某条路演出来的 `game.state` 不一样(报错给出字段),每条用例挂了意味着什么见 `node_modules/gamekit777/docs/view.md`「一致性测试」。
|
|
147
156
|
`test/present.test.ts` 已带:`const g = headless(def, Game); await drain(g, r); expect(g.state)…`,改了编排就改它,写法见 `node_modules/gamekit777/docs/view.md`「编排的单测」。
|
|
148
157
|
|
package/dev-host/DevShell.svelte
CHANGED
|
@@ -336,8 +336,8 @@
|
|
|
336
336
|
{/snippet}
|
|
337
337
|
|
|
338
338
|
{#snippet toggles()}
|
|
339
|
-
<button type="button" aria-pressed={p.
|
|
340
|
-
onclick={() => p.
|
|
339
|
+
<button type="button" aria-pressed={p.turbo}
|
|
340
|
+
onclick={() => p.setTurbo(!p.turbo)}>
|
|
341
341
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z" /></svg>
|
|
342
342
|
快速模式
|
|
343
343
|
</button>
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
底栏是一个定高的槽,里面最多一样东西,谁进谁出、谁都不在,HUD 高度都不变,安全区不抖:
|
|
7
7
|
自动中 → 停止键(每发出一注闪一下还剩几局)
|
|
8
8
|
多阶段一局开着 → 动作栏(槽按它加高到 116px)
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
其余 → 下注条:假输入框(点开注额抽屉)/ 下注键 / 设置齿轮;
|
|
10
|
+
一局在演时下注键换成「直接看结果」,注额和齿轮禁用(同时演多局的游戏照旧能下)
|
|
11
11
|
错误提示绝对定位贴在底栏上方,不进文档流 */
|
|
12
12
|
import { untrack, type Snippet } from 'svelte';
|
|
13
13
|
import type { CoverPx } from '../../sdk/platform.ts';
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
let sheetTab = $state('settings');
|
|
48
48
|
const closeMenu = (): void => { menuOpen = false; };
|
|
49
49
|
|
|
50
|
-
const showBar = $derived(!betOpen && !menuOpen
|
|
50
|
+
const showBar = $derived(!betOpen && !menuOpen);
|
|
51
|
+
const running = $derived(!m.multi && (m.placing || m.game.busy));
|
|
51
52
|
const motion = $derived({ duration: m.settings.reducedMotion ? 0 : 160 });
|
|
52
53
|
|
|
53
54
|
/* 自动模式下每发出一注,停止键先淡成剩余局数、停一下再淡回停止图标。
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
<i class="sysprobe" bind:this={probe} aria-hidden="true"></i>
|
|
94
95
|
<div class="hud hud-top" bind:this={hudTop} style:padding-top={pad ? `${pad.top + 8}px` : undefined}>
|
|
95
96
|
<span class="grow"></span>
|
|
96
|
-
<Balance cents={m.balance} bigFrom={m.bet * 10 || undefined} reducedMotion={m.settings.reducedMotion} turbo={m.
|
|
97
|
+
<Balance cents={m.balance} bigFrom={m.bet * 10 || undefined} reducedMotion={m.settings.reducedMotion} turbo={m.turbo} {money} />
|
|
97
98
|
{@render topEnd?.()}
|
|
98
99
|
</div>
|
|
99
100
|
<div class="hud hud-bottom" bind:this={hudBottom} style:padding-bottom={pad ? `${pad.bottom + 12}px` : undefined}>
|
|
@@ -118,13 +119,17 @@
|
|
|
118
119
|
<div class="hud-acts" transition:lineIn={motion}><StageActions {m} {st} /></div>
|
|
119
120
|
{:else if showBar}
|
|
120
121
|
<div class="hud-bar" transition:lineIn={motion}>
|
|
121
|
-
<button type="button" class="fakeinp" aria-label={m.board ? '总注' : '注额'} disabled={m.board} onclick={() => (betOpen = true)}>
|
|
122
|
+
<button type="button" class="fakeinp" aria-label={m.board ? '总注' : '注额'} disabled={m.board || running} onclick={() => (betOpen = true)}>
|
|
122
123
|
<span class="coin" aria-hidden="true"></span>
|
|
123
124
|
<b>{money(m.cost)}</b>
|
|
124
125
|
{#if !m.board}<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L18 10l-4-4L4 16z" /></svg>{/if}
|
|
125
126
|
</button>
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
{#if running && m.skip}
|
|
128
|
+
<button type="button" class="playbtn" aria-label="直接看结果" disabled={!m.game.canSkip} onclick={() => m.skip?.()}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6v12l8-6zM12 6v12l8-6z" /></svg></button>
|
|
129
|
+
{:else}
|
|
130
|
+
<button type="button" class="playbtn" aria-label="下注" disabled={running || !m.canBet} onclick={() => void m.place()}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg></button>
|
|
131
|
+
{/if}
|
|
132
|
+
<button type="button" class="gearbtn" aria-label="设置" disabled={running} onclick={() => (menuOpen = true)}>
|
|
128
133
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" /></svg>
|
|
129
134
|
</button>
|
|
130
135
|
</div>
|
|
@@ -37,6 +37,8 @@ export interface ControlsModel {
|
|
|
37
37
|
readonly game: GameStatus;
|
|
38
38
|
readonly run: RunState;
|
|
39
39
|
readonly settings: PlatformSettings;
|
|
40
|
+
/** 极速开关(平台侧的;游戏看的是 run.turbo 倍率) */
|
|
41
|
+
readonly turbo: boolean;
|
|
40
42
|
readonly canBet: boolean;
|
|
41
43
|
/** 注发出去了、还没开演 */
|
|
42
44
|
readonly placing: boolean;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
状态用 runes 写(这是个 Svelte 宿主),交给游戏的是框架中立的 Readable。
|
|
7
7
|
换成 React 宿主时这一层换成 useSyncExternalStore,游戏侧零改动。 */
|
|
8
|
-
import { DESKTOP_STAGE, defaultsOf, writable,
|
|
8
|
+
import { DESKTOP_STAGE, TURBO_RATE, defaultsOf, writable,
|
|
9
9
|
type GameDefinition, type GameStatus, type Host, type Readable, type RoundResult, type RunState, type Settings, type StageInfo, type StageLayout,
|
|
10
10
|
type WalletView, type GameTypes, type GameTypesOf, type ConfigOf, type OutcomeOf, type SettingsOf, type PlatformSettings,
|
|
11
11
|
type GameInstance, type RoundInstance, type StageEnd, type BoardView, type Stakes, type StakeOp, stakesTotal } from '../sdk/platform.ts';
|
|
@@ -67,7 +67,7 @@ export function createPlatform<T extends GameTypes>(
|
|
|
67
67
|
const cfgStore = writable<{ v: C }>({ v: defaults });
|
|
68
68
|
/* 偏好(平台内置 + 游戏声明的,游戏那部分按 slug 存本机)和运行态分开:自动模式不是玩家的偏好 */
|
|
69
69
|
const settingsStore = writable<Settings<S>>({ ...platformDefaults(), ...(loadGameSettings(spec.slug, spec.settings) as object) } as Settings<S>);
|
|
70
|
-
const runStore = writable<RunState>({ auto: false, replay: false, rate: 1 });
|
|
70
|
+
const runStore = writable<RunState>({ auto: false, replay: false, rate: 1, turbo: 1 });
|
|
71
71
|
const walletStore = writable<WalletView>({ balance: 0, currency: 'CNY', decimals: 2 });
|
|
72
72
|
const stageStore = writable<StageInfo>(DESKTOP_STAGE);
|
|
73
73
|
const boardStore = writable<BoardView>({ stakes: {}, total: 0, limits: DEFAULT_LIMITS, undo: 0 });
|
|
@@ -76,6 +76,8 @@ export function createPlatform<T extends GameTypes>(
|
|
|
76
76
|
config = $state<C>(defaults);
|
|
77
77
|
settings = $state<Settings<S>>(settingsStore.get());
|
|
78
78
|
run = $state<RunState>(runStore.get());
|
|
79
|
+
/** 极速开关。只在平台这边:游戏拿到的是 run.turbo 倍率 */
|
|
80
|
+
turbo = $state(false);
|
|
79
81
|
balance = $state(0);
|
|
80
82
|
startBalance = $state(0);
|
|
81
83
|
currency = $state('CNY');
|
|
@@ -247,6 +249,11 @@ export function createPlatform<T extends GameTypes>(
|
|
|
247
249
|
saveGameSettings(spec.slug, spec.settings, next);
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
setTurbo(on: boolean): void {
|
|
253
|
+
this.turbo = on;
|
|
254
|
+
this.setRun({ turbo: on ? TURBO_RATE : 1 });
|
|
255
|
+
}
|
|
256
|
+
|
|
250
257
|
setRun(patch: Partial<RunState>): void {
|
|
251
258
|
const next = { ...runStore.get(), ...patch };
|
|
252
259
|
runStore.set(next);
|
|
@@ -506,7 +513,7 @@ export function createPlatform<T extends GameTypes>(
|
|
|
506
513
|
if (this.round) break;
|
|
507
514
|
left--;
|
|
508
515
|
this.autoLeft = isFinite(left) ? left : 0;
|
|
509
|
-
if (this.run.auto) await sleep(this.
|
|
516
|
+
if (this.run.auto) await sleep(this.turbo ? 220 : 600);
|
|
510
517
|
}
|
|
511
518
|
}
|
|
512
519
|
|
|
@@ -533,7 +540,7 @@ export function createPlatform<T extends GameTypes>(
|
|
|
533
540
|
/* 下一注等游戏说还能下:同时演多局的游戏演出中就放行(节奏由它的 interval 定);
|
|
534
541
|
一次只演一局的等这一局演完,再停一口气 */
|
|
535
542
|
await until(() => !this.run.auto || this.game.canBet);
|
|
536
|
-
if (this.run.auto && !this.game.busy) await sleep(this.
|
|
543
|
+
if (this.run.auto && !this.game.busy) await sleep(this.turbo ? 220 : 600);
|
|
537
544
|
}
|
|
538
545
|
|
|
539
546
|
this.setRun({ auto: false });
|
package/dev-host/settings.ts
CHANGED
|
@@ -14,7 +14,7 @@ const defaultStorage = (): SettingsStorage | null => {
|
|
|
14
14
|
export function platformDefaults(): PlatformSettings {
|
|
15
15
|
let reducedMotion = false;
|
|
16
16
|
try { reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* 无头环境没有 */ }
|
|
17
|
-
return { sound: true, volume: 1,
|
|
17
|
+
return { sound: true, volume: 1, reducedMotion, locale: 'zh-CN' };
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** 游戏声明的那部分:默认值盖上本机存过的值。存过的值按字段 kind 再夹一遍——它可能来自旧版 schema */
|
|
@@ -36,12 +36,15 @@ export function saveGameSettings<S>(slug: string, schema: SettingsSchema<S>, val
|
|
|
36
36
|
try { store.setItem(settingsKey(slug), JSON.stringify(out)); } catch { /* 配额满、隐私模式:不存就不存 */ }
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/** 下发给游戏的平台键。turbo 这个键归平台但不下发:开关留在平台,游戏只看 host.run.turbo 倍率 */
|
|
40
|
+
export const SENT_PLATFORM_KEYS: readonly string[] = PLATFORM_SETTING_KEYS.filter((k) => k !== 'turbo');
|
|
41
|
+
|
|
42
|
+
/** 合并一份改动:平台键原样收下,游戏键按 schema 夹取,不认识的键(含 turbo)丢掉 */
|
|
40
43
|
export function applySettingsPatch<S>(schema: SettingsSchema<S>, prev: Settings<S>, patch: Partial<Settings<S>>): Settings<S> {
|
|
41
44
|
const next = { ...prev } as Record<string, unknown>;
|
|
42
45
|
const gamePatch: Record<string, unknown> = {};
|
|
43
46
|
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
|
|
44
|
-
if (
|
|
47
|
+
if (SENT_PLATFORM_KEYS.includes(k)) next[k] = v;
|
|
45
48
|
else if (k in schema) gamePatch[k] = v;
|
|
46
49
|
}
|
|
47
50
|
if (Object.keys(gamePatch).length > 0) {
|
package/dev-host/theme.css
CHANGED
|
@@ -228,6 +228,11 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
|
|
|
228
228
|
background: var(--stage);
|
|
229
229
|
position: relative;
|
|
230
230
|
}
|
|
231
|
+
/* iOS 26 的主屏幕 web app(viewport-fit=cover + black-translucent)里 100dvh / 100svh / 100% 都少算一个顶部安全区,
|
|
232
|
+
机台底下露出一条空带;只有 100lvh 是整屏。standalone 没有会伸缩的地址栏,lvh 就是屏幕高 */
|
|
233
|
+
@media (display-mode: standalone) {
|
|
234
|
+
.cab.mobile { height: 100lvh; }
|
|
235
|
+
}
|
|
231
236
|
.cab.mobile .stage { aspect-ratio: auto; justify-self: center; align-self: center; }
|
|
232
237
|
/* 系统安全区的探针:机台读它的 computed padding 拿到 env() 的数值(JS 读不到 env)。
|
|
233
238
|
顶层文档里才有值,iframe 里恒为 0——所以只有平台量得到,再连同 HUD 的遮挡一起下发给游戏 */
|
|
@@ -264,6 +269,8 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
|
|
|
264
269
|
}
|
|
265
270
|
.cab.mobile .hud-bottom > * { pointer-events: auto; }
|
|
266
271
|
.cab.mobile .hud-bottom .ghostbtn { color: var(--s-ink); border-color: rgba(255, 255, 255, .35); }
|
|
272
|
+
/* 舞台上字是 --s-ink,通用的 hover 底色 --field 在亮色主题是白的,会把字吃掉 */
|
|
273
|
+
.cab.mobile .hud-bottom .ghostbtn:hover:not(:disabled) { background: rgba(255, 255, 255, .14); }
|
|
267
274
|
.hud-row { display: flex; gap: 8px; align-items: stretch; }
|
|
268
275
|
.hud-row > .tabs { flex: 1; min-width: 0; }
|
|
269
276
|
.hud-row > .inp { flex: 1; min-width: 0; }
|
|
@@ -304,6 +311,7 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
|
|
|
304
311
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6));
|
|
305
312
|
}
|
|
306
313
|
.gearbtn svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
314
|
+
.gearbtn:disabled { opacity: .5; }
|
|
307
315
|
|
|
308
316
|
/* ---------- 按钮 ---------- */
|
|
309
317
|
.cta {
|