@origingame/engine 0.0.0-stage → 0.1.0

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 (158) hide show
  1. package/bin/og.mjs +25 -0
  2. package/dist/web/origin-engine.d.ts +1233 -0
  3. package/dist/web/origin-engine.js +8628 -0
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
  6. package/dist/web-publish/origin-engine-lanes.js +8509 -0
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +1176 -0
  9. package/dist/web-publish/origin-engine.js +8313 -0
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
  12. package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  13. package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
  14. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  15. package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
  16. package/dist/web-publish-2d/origin-engine.js +8313 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  18. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
  19. package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  20. package/knowledge/README.md +31 -0
  21. package/knowledge/knowledge.json +13 -0
  22. package/knowledge/looks/catalogue.json +1871 -0
  23. package/knowledge/package.json +33 -0
  24. package/knowledge/rules/platform.json +329 -0
  25. package/knowledge/scaffold/README.md +27 -0
  26. package/knowledge/scaffold/distribution.json +48 -0
  27. package/knowledge/scaffold/origin-engine.json +19 -0
  28. package/knowledge/scaffold/template/.origin/setup +14 -0
  29. package/knowledge/scaffold/template/LICENSES.md +23 -0
  30. package/knowledge/scaffold/template/README.md +37 -0
  31. package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
  32. package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
  33. package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
  34. package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
  35. package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
  36. package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
  37. package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
  38. package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
  39. package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
  40. package/knowledge/scaffold/template/gitattributes +22 -0
  41. package/knowledge/scaffold/template/package.json +18 -0
  42. package/knowledge/scaffold/template/project.ogt +25 -0
  43. package/knowledge/scaffold/template/src/main.ts +246 -0
  44. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  45. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  46. package/knowledge/scaffold/template/tsconfig.json +17 -0
  47. package/knowledge/scripts/lib.ts +203 -0
  48. package/knowledge/scripts/mount.ts +210 -0
  49. package/knowledge/scripts/pack.ts +24 -0
  50. package/knowledge/scripts/scaffold.ts +72 -0
  51. package/knowledge/scripts/validate.ts +749 -0
  52. package/knowledge/skills/og-ai/SKILL.md +386 -0
  53. package/knowledge/skills/og-audio/SKILL.md +191 -0
  54. package/knowledge/skills/og-build/SKILL.md +330 -0
  55. package/knowledge/skills/og-core/SKILL.md +285 -0
  56. package/knowledge/skills/og-feel/SKILL.md +385 -0
  57. package/knowledge/skills/og-game/SKILL.md +1479 -0
  58. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  59. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  60. package/knowledge/skills/og-taste/SKILL.md +537 -0
  61. package/knowledge/skills/og-ui/SKILL.md +444 -0
  62. package/knowledge/wiki/ai.md +627 -0
  63. package/knowledge/wiki/anim.md +1187 -0
  64. package/knowledge/wiki/atmosphere.md +636 -0
  65. package/knowledge/wiki/audio.md +732 -0
  66. package/knowledge/wiki/camera.md +711 -0
  67. package/knowledge/wiki/commands.md +357 -0
  68. package/knowledge/wiki/game.md +220 -0
  69. package/knowledge/wiki/i18n.md +213 -0
  70. package/knowledge/wiki/index.md +1027 -0
  71. package/knowledge/wiki/input.md +1714 -0
  72. package/knowledge/wiki/load.md +1280 -0
  73. package/knowledge/wiki/math.md +166 -0
  74. package/knowledge/wiki/nav.md +1154 -0
  75. package/knowledge/wiki/physics.md +3142 -0
  76. package/knowledge/wiki/platform.md +857 -0
  77. package/knowledge/wiki/probe.md +1692 -0
  78. package/knowledge/wiki/reflect.md +285 -0
  79. package/knowledge/wiki/render.md +3511 -0
  80. package/knowledge/wiki/rng.md +393 -0
  81. package/knowledge/wiki/stream.md +376 -0
  82. package/knowledge/wiki/system.md +197 -0
  83. package/knowledge/wiki/systems.md +43 -0
  84. package/knowledge/wiki/terrain.md +727 -0
  85. package/knowledge/wiki/tilemap.md +422 -0
  86. package/knowledge/wiki/time.md +150 -0
  87. package/knowledge/wiki/transform.md +230 -0
  88. package/knowledge/wiki/tween.md +580 -0
  89. package/knowledge/wiki/ui.md +8000 -0
  90. package/knowledge/wiki/world.md +909 -0
  91. package/package.json +68 -4
  92. package/schema/api.schema.json +29675 -0
  93. package/shell/README.md +22 -0
  94. package/shell/brand/README.md +23 -0
  95. package/shell/brand/icon-192.png +0 -0
  96. package/shell/brand/icon-512.png +0 -0
  97. package/shell/brand/mark-on-paper.svg +6 -0
  98. package/shell/brand/origin-mark.svg +5 -0
  99. package/shell/brand/origin-motion.css +251 -0
  100. package/shell/brand/tokens.css +20 -0
  101. package/shell/brand.ts +132 -0
  102. package/shell/page.ts +715 -0
  103. package/shell/platform.ts +203 -0
  104. package/shell/player.ts +238 -0
  105. package/shell/shell.css +185 -0
  106. package/shell/sw.ts +177 -0
  107. package/shell/transport.ts +515 -0
  108. package/shell/wire.ts +369 -0
  109. package/src/e6.ts +69 -0
  110. package/src/env.d.ts +100 -0
  111. package/src/generated/abi.ts +980 -0
  112. package/src/index.ts +193 -0
  113. package/src/node/audio.ts +86 -0
  114. package/src/node/build.ts +109 -0
  115. package/src/node/device.ts +981 -0
  116. package/src/node/doctor.ts +60 -0
  117. package/src/node/index.ts +102 -0
  118. package/src/node/platform.ts +24 -0
  119. package/src/node/playtest-run.ts +249 -0
  120. package/src/node/png.ts +130 -0
  121. package/src/node/polish.ts +976 -0
  122. package/src/node/run.ts +373 -0
  123. package/src/node/web-build.ts +808 -0
  124. package/src/p2.ts +35 -0
  125. package/src/platform-local.ts +211 -0
  126. package/src/playtest.ts +131 -0
  127. package/src/runtime.ts +1509 -0
  128. package/src/systems/cameras.ts +247 -0
  129. package/src/ui-kit/bind.ts +141 -0
  130. package/src/ui-kit/huds.ts +303 -0
  131. package/src/ui-kit/index.ts +36 -0
  132. package/src/ui-kit/menus.ts +187 -0
  133. package/src/ui-kit/settings.ts +131 -0
  134. package/src/ui-kit/store.ts +222 -0
  135. package/src/ui-kit/story.ts +89 -0
  136. package/src/ui-kit/util.ts +106 -0
  137. package/src/web/a11y.ts +429 -0
  138. package/src/web/audio-worklet.ts +193 -0
  139. package/src/web/audio.ts +307 -0
  140. package/src/web/basis.ts +239 -0
  141. package/src/web/gamepad.ts +123 -0
  142. package/src/web/host.ts +166 -0
  143. package/src/web/input.ts +237 -0
  144. package/src/web/lanes.ts +21 -0
  145. package/src/web/play.ts +20 -0
  146. package/src/web/rpc-ring.ts +110 -0
  147. package/src/web/settle.ts +122 -0
  148. package/src/web/shell.ts +643 -0
  149. package/src/web/text-input.ts +248 -0
  150. package/src/web/worker.ts +1002 -0
  151. package/types/bindings.json +13654 -0
  152. package/types/index.d.ts +20090 -0
  153. package/vendor/basis/LICENSE +201 -0
  154. package/vendor/basis/NOTICE +15 -0
  155. package/vendor/basis/README.md +18 -0
  156. package/vendor/basis/basis_transcoder.js +19 -0
  157. package/vendor/basis/basis_transcoder.wasm +0 -0
  158. package/README.md +0 -3
@@ -0,0 +1,303 @@
1
+ // HUDs: platformer, shooter, puzzle, idle/incremental and racing, each a
2
+ // set of anchored groups over the game in the safe area, plus the touch
3
+ // controls they show on phones and tablets. HUDs turn keyboard and gamepad
4
+ // navigation off (the keys move the player); Back still answers `paused`,
5
+ // except under an open dialog, which keeps it (og.ui.back).
6
+
7
+ import type * as Api from "@origingame/engine";
8
+ import { kit } from "./bind.ts";
9
+ import { all, capsule, clock, fit, fmt, frame, grow, iconOr, lapTime, px, token } from "./util.ts";
10
+
11
+ type Hud = { paused: boolean; action: string | null; moveX: number; moveY: number; held: string[] };
12
+
13
+ function hud(): Hud {
14
+ kit.setNavigation(false);
15
+ return { paused: kit.back(), action: null, moveX: 0, moveY: 0, held: [] };
16
+ }
17
+
18
+ function pauseButton(out: Hud, label = "Pause"): void {
19
+ const r = kit.actionButton("Pause", label, { size: 44, icon: "pause" });
20
+ if (r.clicked) out.paused = true;
21
+ }
22
+
23
+ /** A big number with a small caption under it. */
24
+ function stat(id: string, value: string, caption: string, step: Api.TypeStepName = "xl3", align: Api.TextAlign = "start"): void {
25
+ kit.column(id, { gap: 0, cross: align === "end" ? "end" : align === "center" ? "center" : "start" }, () => {
26
+ kit.label("Value", value, { step, font: "mono", nowrap: true, align });
27
+ kit.label("Caption", caption, { step: "meta", color: token("fgMuted"), nowrap: true, align });
28
+ });
29
+ }
30
+
31
+ /** Touch controls: a d-pad or a stick bottom left, action buttons bottom right. */
32
+ function touchControls(out: Hud, desc: { dpad?: boolean; buttons: ReadonlyArray<{ id: string; label: string; primary?: boolean; icon?: string }> }): void {
33
+ const { t } = frame();
34
+ kit.anchor("TouchMove", { anchor: "bottomLeft" }, () => {
35
+ if (desc.dpad) {
36
+ const d = kit.dpad("Dpad", {});
37
+ out.moveX = (d.right ? 1 : 0) - (d.left ? 1 : 0);
38
+ out.moveY = (d.down ? 1 : 0) - (d.up ? 1 : 0);
39
+ } else {
40
+ const j = kit.joystick("Stick", {});
41
+ out.moveX = j.x;
42
+ out.moveY = j.y;
43
+ }
44
+ });
45
+ kit.anchor("TouchActions", { anchor: "bottomRight", gap: t.md, cross: "end" }, () => {
46
+ for (const b of desc.buttons) {
47
+ const r = kit.actionButton(b.id, b.label, { primary: b.primary ?? false, icon: b.icon ?? "", size: b.primary ? 76 : 60 });
48
+ if (r.pressed) out.held.push(b.id);
49
+ if (r.clicked) out.action = b.id;
50
+ }
51
+ });
52
+ }
53
+
54
+ export function platformerHud(id: string, desc: Api.PlatformerHudDesc = {}): Api.HudResult {
55
+ const { t, touch } = frame();
56
+ const out = hud();
57
+ // What the game leaves out is not drawn: no hearts with maxLives 0 (and
58
+ // no health bar), no coins or score capsule without coins or score.
59
+ const health = desc.health ?? null;
60
+ const maxLives = desc.maxLives ?? 3;
61
+ const coins = desc.coins ?? null;
62
+ const score = desc.score ?? null;
63
+ if (health !== null || maxLives > 0 || desc.level) {
64
+ kit.anchor(`${id}.left`, { anchor: "topLeft", direction: "column", gap: t.sm }, () => {
65
+ if (health !== null) capsule("Lives", () => kit.meter("Health", health, 1, { width: px(120) }));
66
+ else if (maxLives > 0) {
67
+ capsule("Lives", () => {
68
+ for (let i = 0; i < maxLives; i++) iconOr(kit.idi("Life", i), "heart", 20, token(i < (desc.lives ?? 0) ? "danger" : "fillStrong"));
69
+ });
70
+ }
71
+ if (desc.level) kit.label("Level", desc.level, { step: "meta", color: token("fgMuted"), nowrap: true });
72
+ });
73
+ }
74
+ if (desc.timeLeft !== null && desc.timeLeft !== undefined) {
75
+ const low = desc.timeLeft <= 10;
76
+ kit.anchor(`${id}.top`, { anchor: "top" }, () => {
77
+ capsule("Timer", () => kit.label("Time", clock(desc.timeLeft!), { step: "lg", font: "mono", color: token(low ? "danger" : "fg"), nowrap: true }));
78
+ });
79
+ }
80
+ kit.anchor(`${id}.right`, { anchor: "topRight", gap: t.sm, cross: "center" }, () => {
81
+ if (coins !== null) {
82
+ capsule("Coins", () => {
83
+ iconOr("Coin", "coin", 20, token("accent"));
84
+ kit.label("Count", fmt(coins), { font: "mono", nowrap: true });
85
+ });
86
+ }
87
+ if (score !== null) capsule("Score", () => kit.label("Points", fmt(score), { font: "mono", nowrap: true }));
88
+ pauseButton(out);
89
+ });
90
+ if ((desc.touch ?? false) && touch) touchControls(out, { dpad: true, buttons: [{ id: "jump", label: "Jump", primary: true }] });
91
+ return out;
92
+ }
93
+
94
+ /** A crosshair at the centre: four short strokes and a gap. */
95
+ function crosshair(): void {
96
+ const ink = token("fg", 0.9);
97
+ const bar = (id: string, w: number, h: number): void => kit.image(id, { tint: ink, width: px(w), height: px(h) });
98
+ kit.anchor("Crosshair", { anchor: "center", direction: "column", cross: "center", gap: 6 }, () => {
99
+ bar("N", 2, 8);
100
+ kit.row("Mid", { gap: 14, cross: "center" }, () => {
101
+ bar("W", 8, 2);
102
+ bar("E", 8, 2);
103
+ });
104
+ bar("S", 2, 8);
105
+ });
106
+ }
107
+
108
+ export function shooterHud(id: string, desc: Api.ShooterHudDesc = {}): Api.HudResult {
109
+ const { t, touch } = frame();
110
+ const out = hud();
111
+ const showTouch = (desc.touch ?? false) && touch;
112
+ const maxHealth = desc.maxHealth ?? 100;
113
+ const health = desc.health ?? 0;
114
+ // Vitals sit bottom left, or top left when the stick takes that corner.
115
+ kit.anchor(`${id}.vitals`, { anchor: showTouch ? "topLeft" : "bottomLeft", direction: "column", gap: t.xs, width: px(240) }, () => {
116
+ kit.meter("Health", health, maxHealth, { kind: "health", label: `${Math.ceil(health)}`, width: grow() });
117
+ if ((desc.maxArmor ?? 0) > 0) kit.meter("Armor", desc.armor ?? 0, desc.maxArmor!, { color: token("info"), label: `${Math.ceil(desc.armor ?? 0)}`, width: grow() });
118
+ });
119
+ if (desc.map && !showTouch) kit.anchor(`${id}.map`, { anchor: "topLeft" }, () => kit.minimap("Map", desc.map!));
120
+ kit.anchor(`${id}.top`, { anchor: "topRight", gap: t.sm, cross: "center" }, () => {
121
+ capsule("Kills", () => {
122
+ kit.label("Count", fmt(desc.kills ?? 0), { font: "mono", nowrap: true });
123
+ kit.label("Unit", "kills", { step: "meta", color: token("fgMuted"), nowrap: true });
124
+ });
125
+ pauseButton(out);
126
+ });
127
+ kit.anchor(`${id}.ammo`, { anchor: showTouch ? "topRight" : "bottomRight", offsetY: showTouch ? 56 : 0, direction: "column", cross: "end", gap: t.xs }, () => {
128
+ const mag = desc.magazine ?? 0;
129
+ const ammo = desc.ammo ?? 0;
130
+ kit.row("Rounds", { gap: t.xs, cross: "end" }, () => {
131
+ kit.label("Ammo", `${ammo}`, { step: "xl5", font: "mono", color: token(mag > 0 && ammo <= Math.floor(mag / 4) ? "warn" : "fg"), nowrap: true });
132
+ kit.label("Reserve", `/ ${desc.reserve ?? 0}`, { step: "lg", font: "mono", color: token("fgMuted"), nowrap: true });
133
+ });
134
+ if (desc.weapon) kit.label("Weapon", desc.weapon, { step: "meta", color: token("fgMuted"), nowrap: true });
135
+ });
136
+ const abilities = desc.abilities ?? [];
137
+ if (abilities.length > 0) {
138
+ // Above the touch controls when they show, else along the bottom edge.
139
+ kit.anchor(`${id}.abilities`, showTouch ? { anchor: "right", direction: "column", gap: t.md, offsetY: 120 } : { anchor: "bottom", gap: t.md }, () => {
140
+ abilities.forEach((a, i) => {
141
+ const r = kit.cooldown(a.id ?? kit.idi("Ability", i), a.remaining ?? 0, { label: a.key ?? "", icon: a.icon ?? "", size: 56 });
142
+ if (r.clicked) out.action = a.id ?? `ability${i}`;
143
+ });
144
+ });
145
+ }
146
+ if (desc.crosshair ?? true) crosshair();
147
+ if (showTouch) touchControls(out, { buttons: [{ id: "fire", label: "Fire", primary: true }] });
148
+ return out;
149
+ }
150
+
151
+ export function puzzleHud(id: string, desc: Api.PuzzleHudDesc = {}): Api.HudResult {
152
+ const { t } = frame();
153
+ const out = hud();
154
+ const target = Math.max(1, desc.target ?? 1000);
155
+ const score = desc.score ?? 0;
156
+ const marks = desc.stars && desc.stars.length > 0 ? desc.stars : [Math.round(target / 3), Math.round((2 * target) / 3), target];
157
+ kit.anchor(`${id}.left`, { anchor: "topLeft", direction: "column", gap: t.xs }, () => {
158
+ if (desc.level) kit.label("Level", desc.level, { step: "meta", color: token("fgMuted"), nowrap: true });
159
+ if (desc.moves !== null && desc.moves !== undefined) stat("Moves", `${desc.moves}`, "moves left", "xl3");
160
+ });
161
+ kit.anchor(`${id}.score`, { anchor: "top", direction: "column", cross: "center", gap: t.xs, width: px(260) }, () => {
162
+ kit.label("Score", fmt(score), { step: "displaySm", font: "display", align: "center", width: grow() });
163
+ kit.meter("Progress", score, target, { kind: "accent", width: grow(), segments: 3 });
164
+ kit.row("Stars", { gap: t.sm, cross: "center" }, () => {
165
+ marks.slice(0, 3).forEach((m, i) => iconOr(kit.idi("Star", i), "star", 20, token(score >= m ? "accent" : "fillStrong")));
166
+ kit.label("Target", `Target ${fmt(target)}`, { step: "meta", color: token("fgMuted"), nowrap: true });
167
+ });
168
+ });
169
+ kit.anchor(`${id}.right`, { anchor: "topRight", gap: t.sm, cross: "center" }, () => {
170
+ if (desc.timeLeft !== null && desc.timeLeft !== undefined) {
171
+ capsule("Timer", () => kit.label("Time", clock(desc.timeLeft!), { font: "mono", color: token(desc.timeLeft! <= 10 ? "danger" : "fg"), nowrap: true }));
172
+ }
173
+ pauseButton(out);
174
+ });
175
+ return out;
176
+ }
177
+
178
+ export function idleHud(id: string, desc: Api.IdleHudDesc = {}): Api.HudResult {
179
+ const { t, phone } = frame();
180
+ const out = hud();
181
+ const currencies = desc.currencies ?? [];
182
+ const generators = desc.generators ?? [];
183
+ kit.anchor(`${id}.wallet`, { anchor: "top", gap: t.sm, cross: "center" }, () => {
184
+ currencies.forEach((c, i) => {
185
+ capsule(c.id ?? kit.idi("Currency", i), () => {
186
+ if (c.icon) iconOr("Icon", c.icon, 20, token("accent"));
187
+ kit.label("Amount", fmt(c.amount ?? 0), { step: i === 0 ? "lg" : "body", font: "mono", nowrap: true });
188
+ if ((c.perSecond ?? 0) !== 0) kit.label("Rate", `+${fmt(c.perSecond ?? 0)}/s`, { step: "meta", color: token("success"), nowrap: true });
189
+ }, { height: i === 0 ? t.controlLg : t.controlMd });
190
+ });
191
+ pauseButton(out);
192
+ });
193
+ if (desc.tapLabel !== "") {
194
+ kit.anchor(`${id}.tap`, { anchor: phone ? "top" : "center", offsetY: phone ? 96 : 0, offsetX: phone ? 0 : -150 }, () => {
195
+ const r = kit.actionButton("Tap", desc.tapLabel ?? "Tap", { primary: true, size: phone ? 120 : 160 });
196
+ if (r.clicked) out.action = "tap";
197
+ });
198
+ }
199
+ const listHeight = phone ? Math.min(320, frame().viewport.height * 0.45) : Math.min(420, frame().viewport.height - 160);
200
+ kit.anchor(`${id}.shop`, { anchor: phone ? "bottom" : "right", width: phone ? grow() : px(340) }, () => {
201
+ kit.panel("Generators", { width: grow(), padding: all(t.sm), gap: t.xs }, () => {
202
+ kit.list("List", { count: generators.length, rowHeight: 64, gap: t.xs, height: fit(0, listHeight), width: grow() }, (i) => {
203
+ const g = generators[i]!;
204
+ if (g.icon) iconOr("Icon", g.icon, 32, token("fg"));
205
+ kit.column("Text", { width: grow(), gap: t.xxs }, () => {
206
+ kit.row("Head", { gap: t.sm, cross: "center", width: grow() }, () => {
207
+ kit.label("Name", g.name ?? g.id ?? "", { step: "lg", nowrap: true, width: grow() });
208
+ kit.badge("Level", `Lv ${g.level ?? 0}`, "neutral");
209
+ });
210
+ kit.meter("Cycle", g.progress ?? 0, 1, { kind: "accent", height: 4, width: grow() });
211
+ });
212
+ const buy = kit.button("Buy", fmt(g.cost ?? 0), { size: "small", variant: g.affordable ? "primary" : "secondary", disabled: !g.affordable }) as Api.Response;
213
+ if (buy.clicked) out.action = `buy:${g.id ?? i}`;
214
+ });
215
+ });
216
+ });
217
+ return out;
218
+ }
219
+
220
+ /** A delta in seconds, signed with the ASCII minus every pack's charset holds: `-0.412`, `+1.050`. */
221
+ function deltaText(seconds: number): string {
222
+ const a = Math.abs(seconds);
223
+ const body = a >= 60 ? lapTime(a) : a.toFixed(3);
224
+ return `${seconds < 0 ? "-" : "+"}${body}`;
225
+ }
226
+
227
+ /** A delta's ink: ahead (negative) success, behind danger, even muted. */
228
+ function deltaInk(seconds: number): Api.Paint {
229
+ return token(seconds < 0 ? "success" : seconds > 0 ? "danger" : "fgMuted");
230
+ }
231
+
232
+ export function racingHud(id: string, desc: Api.RacingHudDesc = {}): Api.HudResult {
233
+ const { t, touch, phone } = frame();
234
+ const out = hud();
235
+ const showTouch = (desc.touch ?? false) && touch;
236
+ const racers = desc.racers ?? 8;
237
+ const boost = (desc.boostMeter ?? true) ? (desc.boost ?? 0) : null;
238
+ const boostLabel = desc.boostLabel ?? "Boost";
239
+ kit.anchor(`${id}.place`, { anchor: "topLeft", gap: t.lg }, () => {
240
+ // A time trial (racers 0) has no place.
241
+ if (racers > 0) {
242
+ kit.row("Position", { gap: t.xxs, cross: "end" }, () => {
243
+ kit.label("Place", `${desc.position ?? 1}`, { step: "displayXl", font: "display", nowrap: true });
244
+ kit.label("Of", `/${racers}`, { step: "xl", font: "mono", color: token("fgMuted"), nowrap: true });
245
+ });
246
+ }
247
+ stat("Lap", `${Math.min(desc.lap ?? 1, desc.laps ?? 3)}/${desc.laps ?? 3}`, desc.lapLabel ?? "lap", "xl3");
248
+ });
249
+ kit.anchor(`${id}.times`, { anchor: "topRight", direction: "column", cross: "end", gap: t.xxs }, () => {
250
+ kit.row("Top", { gap: t.sm, cross: "center" }, () => {
251
+ kit.label("Time", clock(desc.time ?? 0, true), { step: "xl", font: "mono", nowrap: true });
252
+ pauseButton(out, desc.pauseLabel ?? "Pause");
253
+ });
254
+ const delta = desc.delta ?? null;
255
+ if (delta !== null) kit.label("Delta", deltaText(delta), { step: "lg", font: "mono", color: deltaInk(delta), nowrap: true });
256
+ if (desc.best !== null && desc.best !== undefined) {
257
+ kit.label("Best", (desc.bestLabel ?? "Best {best}").replaceAll("{best}", clock(desc.best, true)), { step: "meta", font: "mono", color: token("fgMuted"), nowrap: true });
258
+ }
259
+ const splits = desc.splits ?? [];
260
+ if (splits.length > 0) {
261
+ kit.column("Splits", { gap: 0, cross: "end" }, () => {
262
+ splits.forEach((sp, i) => {
263
+ kit.row(kit.idi("Split", i), { gap: t.sm, cross: "center" }, () => {
264
+ kit.label("Label", sp.label ?? "", { step: "meta", color: token("fgMuted"), nowrap: true });
265
+ const time = sp.time ?? null;
266
+ kit.label("Time", time === null ? "-:--.---" : lapTime(time), { step: "meta", font: "mono", color: token(time === null ? "fgSubtle" : "fg"), nowrap: true });
267
+ const d = sp.delta ?? null;
268
+ if (d !== null) kit.label("Delta", deltaText(d), { step: "meta", font: "mono", color: deltaInk(d), nowrap: true });
269
+ });
270
+ });
271
+ });
272
+ }
273
+ if (desc.map) kit.minimap("Map", { ...desc.map, size: phone ? 112 : (desc.map.size ?? 140) });
274
+ });
275
+ kit.anchor(`${id}.dash`, { anchor: showTouch ? "bottom" : "bottomRight", offsetY: showTouch ? -120 : 0, direction: "column", cross: "end", gap: t.xs, width: px(220) }, () => {
276
+ kit.row("Speed", { gap: t.sm, cross: "end", width: grow(), align: "end" }, () => {
277
+ kit.label("Value", `${Math.round(desc.speed ?? 0)}`, { step: "displayXl2", font: "display", nowrap: true });
278
+ kit.column("Side", { cross: "end" }, () => {
279
+ kit.label("Gear", desc.gear ?? "N", { step: "xl", font: "mono", color: token("accentInk"), nowrap: true });
280
+ kit.label("Unit", desc.unit ?? "km/h", { step: "meta", color: token("fgMuted"), nowrap: true });
281
+ });
282
+ });
283
+ kit.meter("Rev", desc.speed ?? 0, desc.maxSpeed ?? 300, { kind: "accent", width: grow(), height: 6 });
284
+ if (boost !== null) kit.meter("Boost", boost, 1, { color: token("info"), label: boostLabel, width: grow(), segments: 4 });
285
+ });
286
+ if (showTouch) {
287
+ kit.anchor("Steer", { anchor: "bottomLeft", gap: t.md }, () => {
288
+ const l = kit.actionButton("left", desc.leftLabel ?? "Left", { size: 72 });
289
+ const r = kit.actionButton("right", desc.rightLabel ?? "Right", { size: 72 });
290
+ out.moveX = (r.pressed ? 1 : 0) - (l.pressed ? 1 : 0);
291
+ });
292
+ kit.anchor("Pedals", { anchor: "bottomRight", gap: t.md, cross: "end" }, () => {
293
+ const pedals = [{ id: "brake", label: desc.brakeLabel ?? "Brake", primary: false }];
294
+ if (boost !== null) pedals.push({ id: "boost", label: boostLabel, primary: true });
295
+ for (const b of pedals) {
296
+ const r = kit.actionButton(b.id, b.label, { primary: b.primary, size: b.primary ? 84 : 64 });
297
+ if (r.pressed) out.held.push(b.id);
298
+ if (r.clicked) out.action = b.id;
299
+ }
300
+ });
301
+ }
302
+ return out;
303
+ }
@@ -0,0 +1,36 @@
1
+ // The UI kit: og.ui's game widget set (host calls and their callback sugar)
2
+ // and the screen templates, merged into og.ui by the package entry. Every
3
+ // member is declared in crates/og-api/api.ogt (module `ui`); the templates
4
+ // are `host false` functions written here over the calls.
5
+
6
+ import { widgets } from "./bind.ts";
7
+ import { platformerHud, shooterHud, puzzleHud, idleHud, racingHud } from "./huds.ts";
8
+ import { alertDialog, confirmDialog, loadingScreen, mainMenu, pauseMenu, resultsScreen } from "./menus.ts";
9
+ import { settingsScreen } from "./settings.ts";
10
+ import { inventoryScreen, leaderboardScreen, levelSelect, shopScreen } from "./store.ts";
11
+ import { cutscene, tutorialOverlay } from "./story.ts";
12
+
13
+ export { settingsWithDefaults } from "./settings.ts";
14
+ export { fmt, clock } from "./util.ts";
15
+
16
+ export const uiKit = Object.freeze({
17
+ ...widgets,
18
+ mainMenu,
19
+ pauseMenu,
20
+ settingsScreen,
21
+ platformerHud,
22
+ shooterHud,
23
+ puzzleHud,
24
+ idleHud,
25
+ racingHud,
26
+ cutscene,
27
+ shopScreen,
28
+ inventoryScreen,
29
+ levelSelect,
30
+ resultsScreen,
31
+ leaderboardScreen,
32
+ tutorialOverlay,
33
+ loadingScreen,
34
+ confirmDialog,
35
+ alertDialog,
36
+ });
@@ -0,0 +1,187 @@
1
+ // Menus and dialogs: the title screen, pause, confirm, alert, loading and
2
+ // results. Each is one call per tick: data in, what happened out.
3
+
4
+ import type * as Api from "@origingame/engine";
5
+ import { kit } from "./bind.ts";
6
+ import { all, clock, fmt, frame, iconOr, lapTime, menuItems, px, grow, token } from "./util.ts";
7
+
8
+ /** The buttons of a menu in a column; answers the chosen id. With `autofocus` its first enabled item takes the focus when the menu appears (og-ui §4: what opens takes the focus). */
9
+ function menuColumn(id: string, items: ReturnType<typeof menuItems>, width: Api.Size, autofocus = false): string | null {
10
+ const { t } = frame();
11
+ let chosen: string | null = null;
12
+ const first = autofocus ? items.findIndex((i) => !i.disabled) : -1;
13
+ kit.column(id, { width, gap: t.sm, cross: "stretch" }, () => {
14
+ items.forEach((item, k) => {
15
+ const r = kit.button(item.id, item.label, {
16
+ variant: item.primary ? "primary" : "secondary",
17
+ size: "large",
18
+ disabled: item.disabled,
19
+ width: grow(),
20
+ autofocus: k === first,
21
+ }) as Api.Response;
22
+ if (r.clicked) chosen = item.id;
23
+ });
24
+ });
25
+ return chosen;
26
+ }
27
+
28
+ export function mainMenu(id: string, desc: Api.MainMenuDesc = {}): Api.MenuResult {
29
+ const { t, phone } = frame();
30
+ const items = menuItems(desc.items, [["play", "Play"]]);
31
+ let chosen: string | null = null;
32
+ kit.anchor(id, {
33
+ anchor: phone ? "bottom" : "center",
34
+ direction: "column",
35
+ gap: t.xl,
36
+ cross: "center",
37
+ width: phone ? grow() : px(380),
38
+ padding: all(phone ? t.lg : t.xl),
39
+ background: token("materialThick"),
40
+ radius: t.panel,
41
+ instant: desc.instant ?? false,
42
+ }, () => {
43
+ kit.column("Heading", { gap: t.xs, cross: "center", width: grow() }, () => {
44
+ kit.label("Title", desc.title ?? "", { step: phone ? "displayMd" : "displayLg", font: "display", align: "center", width: grow(), heading: true });
45
+ if (desc.subtitle) kit.label("Subtitle", desc.subtitle, { color: token("fgMuted"), align: "center", width: grow() });
46
+ });
47
+ // A title menu is not a dialog, so it takes the focus itself: Enter and the pad's A answer at once.
48
+ chosen = menuColumn("Items", items, grow(), true);
49
+ if (desc.footer) kit.label("Footer", desc.footer, { step: "meta", color: token("fgSubtle"), align: "center", width: grow() });
50
+ });
51
+ if (chosen !== null) desc.onSelect?.(chosen);
52
+ return { selected: chosen };
53
+ }
54
+
55
+ export function pauseMenu(id: string, desc: Api.PauseMenuDesc = {}): Api.MenuResult {
56
+ const items = menuItems(desc.items, [["resume", "Resume"], ["settings", "Settings"], ["quit", "Quit to menu"]]);
57
+ let chosen: string | null = null;
58
+ const m = kit.modal(id, desc.open ?? false, { title: desc.title ?? "Paused", width: px(Math.min(340, frame().viewport.width - 32)) }, () => {
59
+ chosen = menuColumn("Items", items, grow());
60
+ });
61
+ if (m.dismissed) chosen = "resume";
62
+ if (chosen !== null) desc.onSelect?.(chosen);
63
+ return { selected: chosen };
64
+ }
65
+
66
+ export function confirmDialog(id: string, desc: Api.ConfirmDesc = {}): Api.ConfirmResult {
67
+ const { t } = frame();
68
+ let confirmed = false;
69
+ let cancelled = false;
70
+ const m = kit.modal(id, desc.open ?? false, { title: desc.title ?? "" }, () => {
71
+ if (desc.message) kit.richText("Message", desc.message, { step: "reading", color: token("fgMuted"), width: grow() });
72
+ kit.row("Actions", { width: grow(), gap: t.sm, align: "end", padding: { left: 0, top: t.sm, right: 0, bottom: 0 } }, () => {
73
+ if ((kit.button("Cancel", desc.cancelLabel ?? "Cancel", {}) as Api.Response).clicked) cancelled = true;
74
+ if ((kit.button("Confirm", desc.confirmLabel ?? "Confirm", { variant: desc.danger ? "danger" : "primary" }) as Api.Response).clicked) confirmed = true;
75
+ });
76
+ });
77
+ if (m.dismissed) cancelled = true;
78
+ return { confirmed, cancelled };
79
+ }
80
+
81
+ export function alertDialog(id: string, desc: Api.AlertDesc = {}): boolean {
82
+ const { t } = frame();
83
+ let done = false;
84
+ const m = kit.modal(id, desc.open ?? false, { title: desc.title ?? "" }, () => {
85
+ if (desc.message) kit.richText("Message", desc.message, { step: "reading", color: token("fgMuted"), width: grow() });
86
+ kit.row("Actions", { width: grow(), align: "end", padding: { left: 0, top: t.sm, right: 0, bottom: 0 } }, () => {
87
+ if ((kit.button("Ok", desc.okLabel ?? "OK", { variant: "primary" }) as Api.Response).clicked) done = true;
88
+ });
89
+ });
90
+ return done || m.dismissed;
91
+ }
92
+
93
+ export function loadingScreen(id: string, desc: Api.LoadingDesc = {}): void {
94
+ const { t } = frame();
95
+ const tips = desc.tips ?? [];
96
+ const tip = tips.length > 0 ? tips[Math.floor(kit.seconds() / Math.max(1, desc.tipSeconds ?? 4)) % tips.length]! : "";
97
+ kit.modal(id, true, { locked: true, bare: true, backdrop: token("bg"), transition: "fade", width: px(Math.min(380, frame().viewport.width - 32)) }, () => {
98
+ kit.label("Title", desc.title ?? "Loading", { step: "xl", align: "center", width: grow(), heading: true });
99
+ const p = desc.progress;
100
+ if (p === null || p === undefined) {
101
+ kit.spinner("Spinner", 0);
102
+ } else {
103
+ // A real value only: the bar never runs ahead of what loaded.
104
+ kit.row("Bar", { width: grow(), gap: t.md, cross: "center" }, () => {
105
+ kit.progress("Progress", p, { width: grow() });
106
+ kit.label("Percent", `${Math.floor(Math.max(0, Math.min(1, p)) * 100)}%`, { step: "meta", font: "mono", color: token("fgMuted"), nowrap: true });
107
+ });
108
+ }
109
+ if (tip) kit.label("Tip", tip, { step: "reading", color: token("fgMuted"), align: "center", width: grow() });
110
+ });
111
+ }
112
+
113
+ /** Stars landing one by one: k appears 0.3 s after k-1, from the screen's opening; an earned one bursts. */
114
+ function stars(id: string, earned: number, max: number, since: number, size: number): void {
115
+ const { t } = frame();
116
+ kit.row(id, { gap: t.sm, cross: "center" }, () => {
117
+ for (let k = 0; k < max; k++) {
118
+ const shown = kit.seconds() - since >= 0.3 * (k + 1);
119
+ const on = k < earned;
120
+ kit.transition(kit.idi("Star", k), shown, { kind: "scale", instant: false }, () => {
121
+ iconOr("Mark", "star", size, token(on ? "accent" : "fillStrong"));
122
+ if (on) kit.burst({ delayMs: 120 });
123
+ });
124
+ if (!shown) kit.spacer(kit.idi("Gap", k), px(size));
125
+ }
126
+ });
127
+ }
128
+
129
+ export function resultsScreen(id: string, desc: Api.ResultsDesc = {}): Api.MenuResult {
130
+ const { t, phone } = frame();
131
+ const open = desc.open ?? false;
132
+ // When it opened, in UI seconds (-1 while closed): the count-up and the stars run from there.
133
+ let since = kit.memory(`${id}.since`, -1);
134
+ if (!open) since = -1;
135
+ else if (since < 0) since = kit.seconds();
136
+ kit.remember(`${id}.since`, since);
137
+ const items = menuItems(desc.actions, [["retry", "Retry"], ["next", "Next"], ["menu", "Menu"]]);
138
+ let chosen: string | null = null;
139
+ const score = desc.score ?? 0;
140
+ const time = desc.format === "time";
141
+ // The score counts up over 0.9 s from the opening; "New best" pops in when it lands.
142
+ const landed = open && kit.seconds() - since >= 0.9;
143
+ // A time is lower-is-better (a best of 0 is none); points higher-is-better.
144
+ const prior = desc.best ?? 0;
145
+ const best = time ? (desc.newBest ? (prior > 0 ? Math.min(prior, score) : score) : prior) : Math.max(prior, desc.newBest ? score : 0);
146
+ kit.modal(id, open, { title: desc.title ?? "Level complete", locked: true, width: px(Math.min(phone ? 360 : 420, frame().viewport.width - 32)) }, () => {
147
+ const max = desc.maxStars ?? 3;
148
+ if (max > 0) kit.row("StarRow", { width: grow(), align: "center" }, () => stars("Stars", desc.stars ?? 0, max, since, 44));
149
+ kit.column("ScoreBlock", { width: grow(), cross: "center", gap: t.xxs }, () => {
150
+ if (desc.scoreLabel) kit.label("ScoreLabel", desc.scoreLabel, { step: "meta", color: token("fgMuted"), align: "center", width: grow(), nowrap: true });
151
+ if (time) {
152
+ // A time counts up as a clock (ease-out cubic over the same 0.9 s), the headline in m:ss.mmm.
153
+ const k = open ? Math.min(1, Math.max(0, (kit.seconds() - since) / 0.9)) : 1;
154
+ const shown = score * (1 - (1 - k) ** 3);
155
+ kit.label("Score", k >= 1 ? lapTime(score) : clock(shown, true), { step: "displayLg", font: "display", align: "center", width: grow(), nowrap: true });
156
+ } else {
157
+ kit.counter("Score", score, { from: 0, durationMs: 900, step: "displayLg", font: "display", align: "center", width: grow() });
158
+ }
159
+ if (!time || best > 0 || desc.newBest) {
160
+ kit.row("BestRow", { gap: t.sm, cross: "center" }, () => {
161
+ if (!time || best > 0) kit.label("Best", (desc.bestLabel ?? "Best {best}").replaceAll("{best}", time ? lapTime(best) : fmt(best)), { step: "meta", color: token("fgMuted"), nowrap: true });
162
+ if (desc.newBest) kit.transition("Reveal", landed, { kind: "pop" }, () => kit.badge("NewBest", desc.newBestLabel ?? "New best", "accent"));
163
+ });
164
+ }
165
+ });
166
+ const stats = desc.stats ?? [];
167
+ if (stats.length > 0) {
168
+ kit.column("Stats", { width: grow(), gap: 0 }, () => {
169
+ stats.forEach((s, i) => kit.keyValue(kit.idi("Stat", i), s.label ?? "", s.value ?? "", true));
170
+ });
171
+ }
172
+ kit.row("Actions", { width: grow(), gap: t.sm, wrap: phone }, () => {
173
+ for (const item of items) {
174
+ const r = kit.button(item.id, item.label, { variant: item.primary ? "primary" : "secondary", width: grow(), disabled: item.disabled }) as Api.Response;
175
+ if (r.clicked) chosen = item.id;
176
+ }
177
+ });
178
+ if (desc.shareText) {
179
+ if ((kit.button("Share", desc.shareLabel ?? "Share", { variant: "quiet", width: grow() }) as Api.Response).clicked) {
180
+ chosen = "share";
181
+ desc.onShare?.(desc.shareText);
182
+ }
183
+ }
184
+ });
185
+ return { selected: chosen };
186
+ }
187
+
@@ -0,0 +1,131 @@
1
+ // The settings screen: Audio, Graphics, Controls and Language over the
2
+ // game's GameSettings. The game owns the values (a resource, a save); the
3
+ // screen answers the new ones every tick and calls the hooks: onChange on
4
+ // every change (apply it at once), onSave when it closes after changes.
5
+
6
+ import type * as Api from "@origingame/engine";
7
+ import { kit } from "./bind.ts";
8
+ import { frame, grow, px, token } from "./util.ts";
9
+
10
+ type Settings = {
11
+ -readonly [K in keyof Required<Api.GameSettings>]: K extends "bindings" ? Api.KeyBinding[] : Required<Api.GameSettings>[K];
12
+ };
13
+
14
+ /** Every field of the settings, defaults filled in. */
15
+ export function settingsWithDefaults(s: Api.GameSettings = {}): Settings {
16
+ return {
17
+ masterVolume: s.masterVolume ?? 1,
18
+ musicVolume: s.musicVolume ?? 0.8,
19
+ sfxVolume: s.sfxVolume ?? 1,
20
+ muted: s.muted ?? false,
21
+ quality: s.quality ?? "medium",
22
+ showFps: s.showFps ?? false,
23
+ reducedMotion: s.reducedMotion ?? false,
24
+ highContrast: s.highContrast ?? false,
25
+ vibration: s.vibration ?? true,
26
+ invertY: s.invertY ?? false,
27
+ sensitivity: s.sensitivity ?? 1,
28
+ language: s.language ?? "en",
29
+ bindings: [...(s.bindings ?? [])],
30
+ };
31
+ }
32
+
33
+ const QUALITIES: readonly Api.GraphicsQuality[] = ["low", "medium", "high"];
34
+
35
+ /** A labelled slider row with its value at the end. */
36
+ function sliderRow(id: string, label: string, value: number, desc: Api.SliderDesc, shown: string): number {
37
+ const { t, phone } = frame();
38
+ let out = value;
39
+ kit.row(id, { width: grow(), gap: t.md, cross: "center" }, () => {
40
+ kit.label("Label", label, { width: px(phone ? 96 : 140), nowrap: true });
41
+ const r = kit.slider("Slider", value, desc) as Api.SliderResult;
42
+ out = r.value;
43
+ kit.label("Value", shown, { width: px(48), align: "end", font: "mono", color: token("fgMuted"), nowrap: true });
44
+ });
45
+ return out;
46
+ }
47
+
48
+ export function settingsScreen(id: string, desc: Api.SettingsDesc = {}): Api.SettingsResult {
49
+ const { t, phone } = frame();
50
+ const before = settingsWithDefaults(desc.settings);
51
+ const s = settingsWithDefaults(desc.settings);
52
+ const languages = desc.languages ?? [];
53
+ // Every word is a field of the desc with its English default (og-i18n §2).
54
+ const tabs: Array<[key: string, label: string]> = [
55
+ ["audio", desc.audioLabel ?? "Audio"],
56
+ ["graphics", desc.graphicsLabel ?? "Graphics"],
57
+ ["controls", desc.controlsLabel ?? "Controls"],
58
+ ...(languages.length > 0 ? [["language", desc.languageLabel ?? "Language"] as [string, string]] : []),
59
+ ];
60
+ let tab = Math.min(kit.memory(`${id}.tab`, 0), tabs.length - 1);
61
+ let dirty = kit.memory(`${id}.dirty`, 0) !== 0;
62
+ let closed = false;
63
+ const width = phone ? grow() : px(Math.min(600, frame().viewport.width - 48));
64
+ const m = kit.modal(id, desc.open ?? false, { title: desc.title ?? "Settings", width, ignoreBackdrop: true }, () => {
65
+ // Tabs on a wide surface, a select on a phone (one selection grammar).
66
+ // The groups' names are the desc's too: a screen reader hears them in the game's language.
67
+ const labels = tabs.map(([, label]) => label);
68
+ const section = desc.sectionLabel ?? "Section";
69
+ const pick = phone
70
+ ? (kit.select("Section", labels, tab, { width: grow(), label: section }) as Api.ChoiceResult)
71
+ : (kit.tabs("Section", labels, tab, { fill: true, width: grow(), label: section }) as Api.ChoiceResult);
72
+ tab = pick.selected;
73
+ kit.column("Body", { width: grow(), gap: t.sm, height: px(phone ? 300 : 260) }, () => {
74
+ const pct = (v: number): string => `${Math.round(v * 100)}%`;
75
+ switch (tabs[tab]?.[0]) {
76
+ case "audio": {
77
+ s.masterVolume = sliderRow("Master", desc.masterLabel ?? "Master", s.masterVolume, { step: 0.05 }, pct(s.masterVolume));
78
+ s.musicVolume = sliderRow("Music", desc.musicLabel ?? "Music", s.musicVolume, { step: 0.05 }, pct(s.musicVolume));
79
+ s.sfxVolume = sliderRow("Effects", desc.effectsLabel ?? "Effects", s.sfxVolume, { step: 0.05 }, pct(s.sfxVolume));
80
+ s.muted = (kit.toggle("Mute", s.muted, desc.muteLabel ?? "Mute everything") as Api.ToggleResult).on;
81
+ break;
82
+ }
83
+ case "graphics": {
84
+ const quality = desc.qualityLabel ?? "Quality";
85
+ kit.label("QualityLabel", quality, { step: "meta", color: token("fgMuted") });
86
+ const levels = [desc.lowLabel ?? "Low", desc.mediumLabel ?? "Medium", desc.highLabel ?? "High"];
87
+ const q = kit.radio("Quality", levels, QUALITIES.indexOf(s.quality), { horizontal: !phone, label: quality });
88
+ s.quality = QUALITIES[q.selected] ?? "medium";
89
+ s.showFps = (kit.toggle("Fps", s.showFps, desc.fpsLabel ?? "Show frame rate") as Api.ToggleResult).on;
90
+ s.reducedMotion = (kit.toggle("Motion", s.reducedMotion, desc.motionLabel ?? "Reduce motion") as Api.ToggleResult).on;
91
+ s.highContrast = (kit.toggle("Contrast", s.highContrast, desc.contrastLabel ?? "High contrast") as Api.ToggleResult).on;
92
+ break;
93
+ }
94
+ case "controls": {
95
+ s.sensitivity = sliderRow("Sensitivity", desc.sensitivityLabel ?? "Sensitivity", s.sensitivity, { min: 0.1, max: 3, step: 0.1 }, s.sensitivity.toFixed(1));
96
+ s.invertY = kit.checkbox("Invert", s.invertY, desc.invertLabel ?? "Invert vertical look").on;
97
+ s.vibration = kit.checkbox("Vibration", s.vibration, desc.vibrationLabel ?? "Vibration").on;
98
+ if (s.bindings.length > 0) {
99
+ kit.divider("Rule", false);
100
+ kit.scroll("Bindings", { width: grow(), height: grow() }, () => {
101
+ s.bindings.forEach((b, i) => kit.keyValue(kit.idi("Binding", i), b.label ?? b.action ?? "", b.key ?? "", true));
102
+ });
103
+ }
104
+ break;
105
+ }
106
+ case "language": {
107
+ const at = Math.max(0, languages.findIndex((l) => l.code === s.language));
108
+ const r = kit.radio("Languages", languages.map((l) => l.label ?? l.code ?? ""), at, { label: desc.languagesLabel ?? "Languages" });
109
+ s.language = languages[r.selected]?.code ?? s.language;
110
+ break;
111
+ }
112
+ }
113
+ });
114
+ kit.row("Footer", { width: grow(), align: "end" }, () => {
115
+ if ((kit.button("Done", desc.doneLabel ?? "Done", { variant: "primary" }) as Api.Response).clicked) closed = true;
116
+ });
117
+ });
118
+ if (m.dismissed) closed = true;
119
+ const changed = JSON.stringify(s) !== JSON.stringify(before);
120
+ if (changed) {
121
+ dirty = true;
122
+ desc.onChange?.(s);
123
+ }
124
+ if (closed && dirty) {
125
+ desc.onSave?.(s);
126
+ dirty = false;
127
+ }
128
+ kit.remember(`${id}.tab`, tab);
129
+ kit.remember(`${id}.dirty`, dirty ? 1 : 0);
130
+ return { settings: s, changed, closed };
131
+ }