@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.
- package/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +203 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +24 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- 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
|
+
}
|