@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,222 @@
|
|
|
1
|
+
// Collections: the shop, the inventory (drag and drop between slots), the
|
|
2
|
+
// level select and the leaderboard. Each is a dialog-shaped screen with a
|
|
3
|
+
// focus scope; Back closes it.
|
|
4
|
+
|
|
5
|
+
import type * as Api from "@origingame/engine";
|
|
6
|
+
import { kit } from "./bind.ts";
|
|
7
|
+
import { capsule, fit, fmt, frame, grow, iconOr, px, token } from "./util.ts";
|
|
8
|
+
|
|
9
|
+
/** A screen panel: a dialog over the game, wide on desktop, full width on a phone. With `scrim` a press on the backdrop closes it too. */
|
|
10
|
+
function screen(id: string, title: string, width: number, build: () => void, scrim = false): boolean {
|
|
11
|
+
const { phone, viewport } = frame();
|
|
12
|
+
const m = kit.modal(id, true, { title, width: phone ? grow() : px(Math.min(width, viewport.width - 48)), ignoreBackdrop: !scrim }, build);
|
|
13
|
+
return m.dismissed;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** An item's picture: its icon, else its initial on the slot. */
|
|
17
|
+
function picture(id: string, icon: string | undefined, name: string, size: number): void {
|
|
18
|
+
if (icon && kit.hasIcon(icon)) kit.icon(id, icon, { size });
|
|
19
|
+
else kit.label(id, name.slice(0, 1).toUpperCase(), { step: "xl2", font: "display", color: token("fgMuted"), nowrap: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shopScreen(id: string, desc: Api.ShopDesc = {}): Api.ShopResult {
|
|
23
|
+
const { t, phone } = frame();
|
|
24
|
+
const out: { bought: string | null; refused: string | null; closed: boolean } = { bought: null, refused: null, closed: false };
|
|
25
|
+
const cats = desc.categories ?? [];
|
|
26
|
+
let cat = Math.min(kit.memory(`${id}.cat`, 0), Math.max(0, cats.length - 1));
|
|
27
|
+
const all = desc.items ?? [];
|
|
28
|
+
const items = cats.length === 0 ? all : all.filter((i) => (i.category || cats[0]) === cats[cat]);
|
|
29
|
+
let sel = Math.min(kit.memory(`${id}.sel`, 0), Math.max(0, items.length - 1));
|
|
30
|
+
const balance = desc.balance ?? 0;
|
|
31
|
+
out.closed = screen(id, desc.title ?? "Shop", 760, () => {
|
|
32
|
+
kit.row("Bar", { width: grow(), gap: t.sm, cross: "center", align: "spaceBetween" }, () => {
|
|
33
|
+
if (cats.length > 0) {
|
|
34
|
+
const r = kit.tabs("Categories", cats, cat, {});
|
|
35
|
+
if (r.response.changed) sel = 0;
|
|
36
|
+
cat = r.selected;
|
|
37
|
+
} else {
|
|
38
|
+
kit.spacer("Gap", null);
|
|
39
|
+
}
|
|
40
|
+
capsule("Balance", () => {
|
|
41
|
+
if (desc.currencyIcon) iconOr("Icon", desc.currencyIcon, 18, token("accent"));
|
|
42
|
+
kit.counter("Amount", balance, { font: "mono", step: "base" });
|
|
43
|
+
kit.label("Unit", desc.currency ?? "coins", { step: "base", color: token("fgMuted"), nowrap: true });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
const body = (): void => {
|
|
47
|
+
const g = kit.grid("Items", { count: items.length, cellWidth: phone ? 96 : 112, cellHeight: phone ? 112 : 124, height: fit(0, phone ? 260 : 320), width: grow(), selected: sel }, (i) => {
|
|
48
|
+
const it = items[i]!;
|
|
49
|
+
picture("Picture", it.icon, it.name ?? "", 36);
|
|
50
|
+
kit.label("Name", it.name ?? "", { step: "base", nowrap: true });
|
|
51
|
+
if (it.owned) kit.badge("Owned", desc.ownedLabel ?? "Owned", "success");
|
|
52
|
+
else kit.label("Price", fmt(it.price ?? 0), { step: "meta", font: "mono", color: token(balance >= (it.price ?? 0) ? "accentInk" : "fgSubtle"), nowrap: true });
|
|
53
|
+
});
|
|
54
|
+
if (g.clicked !== null && g.clicked !== undefined) sel = g.clicked;
|
|
55
|
+
const it = items[sel];
|
|
56
|
+
kit.column("Detail", { width: phone ? grow() : px(220), gap: t.sm }, () => {
|
|
57
|
+
if (!it) {
|
|
58
|
+
kit.label("Empty", desc.emptyLabel ?? "Nothing for sale here", { color: token("fgMuted") });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
kit.label("Name", it.name ?? "", { step: "lg", width: grow() });
|
|
62
|
+
if (it.description) kit.label("Description", it.description, { step: "meta", color: token("fgMuted"), width: grow() });
|
|
63
|
+
kit.keyValue("Price", desc.priceLabel ?? "Price", `${fmt(it.price ?? 0)} ${desc.currency ?? "coins"}`, true);
|
|
64
|
+
const short = balance < (it.price ?? 0);
|
|
65
|
+
// Short of the price, Buy still answers: it refuses, shaking with the error sound.
|
|
66
|
+
const r = kit.button("Buy", it.owned ? (desc.ownedLabel ?? "Owned") : short ? (desc.shortLabel ?? "Not enough") : (desc.buyLabel ?? "Buy"), { variant: short ? "secondary" : "primary", width: grow(), disabled: it.owned }) as Api.Response;
|
|
67
|
+
if (r.clicked && !it.owned && short) {
|
|
68
|
+
kit.shake({ sound: true });
|
|
69
|
+
out.refused = it.id ?? null;
|
|
70
|
+
}
|
|
71
|
+
if (r.clicked && !it.owned && !short) out.bought = it.id ?? null;
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
if (phone) kit.column("Body", { width: grow(), gap: t.md }, body);
|
|
75
|
+
else kit.row("Body", { width: grow(), gap: t.lg }, body);
|
|
76
|
+
});
|
|
77
|
+
kit.remember(`${id}.cat`, cat);
|
|
78
|
+
kit.remember(`${id}.sel`, sel);
|
|
79
|
+
if (out.bought !== null) desc.onBuy?.(out.bought);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function inventoryScreen(id: string, desc: Api.InventoryDesc = {}): Api.InventoryResult {
|
|
84
|
+
const { t, phone } = frame();
|
|
85
|
+
const slots = desc.slots ?? 24;
|
|
86
|
+
const bySlot = new Map<number, Api.InventoryItem>();
|
|
87
|
+
for (const it of desc.items ?? []) bySlot.set(it.slot ?? 0, it);
|
|
88
|
+
let sel = kit.memory(`${id}.sel`, -1);
|
|
89
|
+
const out: { selected: number | null; moved: Api.SlotMove | null; used: string | null; closed: boolean } = { selected: null, moved: null, used: null, closed: false };
|
|
90
|
+
const cell = phone ? 64 : 72;
|
|
91
|
+
const columns = desc.columns ?? 0;
|
|
92
|
+
// Wide enough for the stated columns at the slot size (the grid shares
|
|
93
|
+
// what is left between them if the screen is narrower): the dialog's
|
|
94
|
+
// padding, the detail panel and the gap beside the grid, and room for
|
|
95
|
+
// its scrollbar.
|
|
96
|
+
const chrome = 2 * t.xl + 200 + t.lg;
|
|
97
|
+
const width = columns > 0 ? Math.max(680, chrome + columns * (cell + t.sm) - t.sm + 2 * t.sm) : 680;
|
|
98
|
+
out.closed = screen(id, desc.title ?? "Inventory", width, () => {
|
|
99
|
+
const body = (): void => {
|
|
100
|
+
const g = kit.grid("Slots", { count: slots, cellWidth: cell, cellHeight: cell, columns, height: px(phone ? 300 : 340), width: grow(), selected: sel >= 0 ? sel : null }, (i) => {
|
|
101
|
+
const it = bySlot.get(i);
|
|
102
|
+
if (!it) return;
|
|
103
|
+
picture("Picture", it.icon, it.name ?? "", cell * 0.5);
|
|
104
|
+
if ((it.count ?? 0) > 1) kit.label("Count", `${it.count}`, { step: "xs", font: "mono", color: token("fgMuted"), nowrap: true });
|
|
105
|
+
});
|
|
106
|
+
if (g.clicked !== null && g.clicked !== undefined) sel = g.clicked;
|
|
107
|
+
if (g.dropFrom !== null && g.dropFrom !== undefined && g.dropTo !== null && g.dropTo !== undefined && bySlot.has(g.dropFrom)) {
|
|
108
|
+
out.moved = { from: g.dropFrom, to: g.dropTo };
|
|
109
|
+
sel = g.dropTo;
|
|
110
|
+
}
|
|
111
|
+
const it = sel >= 0 ? bySlot.get(sel) : undefined;
|
|
112
|
+
kit.column("Detail", { width: phone ? grow() : px(200), gap: t.sm }, () => {
|
|
113
|
+
if (!it) {
|
|
114
|
+
kit.label("Hint", desc.hintLabel ?? "Select an item. Drag it to another slot to move it.", { step: "meta", color: token("fgMuted"), width: grow() });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
kit.label("Name", it.name ?? "", { step: "lg", width: grow() });
|
|
118
|
+
if (it.description) kit.label("Description", it.description, { step: "meta", color: token("fgMuted"), width: grow() });
|
|
119
|
+
if ((it.count ?? 0) > 1) kit.keyValue("Count", desc.countLabel ?? "Count", `${it.count}`, true);
|
|
120
|
+
if (desc.useLabel !== "") {
|
|
121
|
+
if ((kit.button("Use", desc.useLabel ?? "Use", { variant: "primary", width: grow() }) as Api.Response).clicked) out.used = it.id ?? null;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
if (phone) kit.column("Body", { width: grow(), gap: t.md }, body);
|
|
126
|
+
else kit.row("Body", { width: grow(), gap: t.lg }, body);
|
|
127
|
+
});
|
|
128
|
+
kit.remember(`${id}.sel`, sel);
|
|
129
|
+
out.selected = sel >= 0 ? sel : null;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function levelSelect(id: string, desc: Api.LevelSelectDesc = {}): Api.LevelSelectResult {
|
|
134
|
+
const { t, phone } = frame();
|
|
135
|
+
const levels = desc.levels ?? [];
|
|
136
|
+
const earned = levels.reduce((n, l) => n + Math.min(3, l.stars ?? 0), 0);
|
|
137
|
+
const out: { chosen: number | null; closed: boolean } = { chosen: null, closed: false };
|
|
138
|
+
// A screen reader hears the stars as the desc words them ("2 of 3 stars"),
|
|
139
|
+
// never the icons' English names: the first star carries it, the others
|
|
140
|
+
// are decoration; a locked card is named by its level, not "lock".
|
|
141
|
+
const starsLabel = (stars: number, max: number): string => (desc.starsLabel ?? "{stars} of {max} stars").replaceAll("{stars}", `${stars}`).replaceAll("{max}", `${max}`);
|
|
142
|
+
let back = false;
|
|
143
|
+
// Back from every input: the button, a press on the scrim, Escape, B.
|
|
144
|
+
out.closed = screen(id, desc.title ?? "Levels", 720, () => {
|
|
145
|
+
kit.row("Total", { gap: t.xs, cross: "center" }, () => {
|
|
146
|
+
iconOr("Star", "star", 18, token("accent"), starsLabel(earned, levels.length * 3));
|
|
147
|
+
kit.label("Count", `${earned} / ${levels.length * 3}`, { font: "mono", color: token("fgMuted"), nowrap: true });
|
|
148
|
+
});
|
|
149
|
+
// A numeral is the card's face, in display type on a square card; a
|
|
150
|
+
// name takes a wider card and a smaller step, wrapped to two lines (cut
|
|
151
|
+
// with an ellipsis past them), so it is read, never clipped. With
|
|
152
|
+
// `columns` the cards share the screen's width, so two columns are two
|
|
153
|
+
// wide cards.
|
|
154
|
+
const named = levels.some((l) => [...(l.name ?? "")].length > 3);
|
|
155
|
+
const cell = phone ? 84 : 104;
|
|
156
|
+
const g = kit.grid("Levels", { count: levels.length, cellWidth: named ? (phone ? 136 : 168) : cell, cellHeight: cell, columns: desc.columns ?? 0, height: fit(0, phone ? 420 : 400), width: grow() }, (i) => {
|
|
157
|
+
const l = levels[i]!;
|
|
158
|
+
const name = l.name ?? `${i + 1}`;
|
|
159
|
+
if (l.locked) {
|
|
160
|
+
iconOr("Lock", "lock", 28, token("fgSubtle"), (desc.lockedLabel ?? "{name}, locked").replaceAll("{name}", name));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if ([...name].length > 3) kit.label("Name", name, { step: phone ? "base" : "lg", font: "display", align: "center", width: grow(), maxLines: 2, ellipsis: true });
|
|
164
|
+
else kit.label("Name", name, { step: "xl3", font: "display", nowrap: true });
|
|
165
|
+
const stars = Math.min(3, l.stars ?? 0);
|
|
166
|
+
kit.row("Stars", { gap: 2 }, () => {
|
|
167
|
+
for (let k = 0; k < 3; k++) iconOr(kit.idi("Star", k), "star", 14, token(k < stars ? "accent" : "fillStrong"), k === 0 ? starsLabel(stars, 3) : "");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
if (g.clicked !== null && g.clicked !== undefined && !levels[g.clicked]?.locked) out.chosen = g.clicked;
|
|
171
|
+
kit.row("Footer", { width: grow(), align: "end" }, () => {
|
|
172
|
+
if ((kit.button("Back", desc.backLabel ?? "Back", {}) as Api.Response).clicked) back = true;
|
|
173
|
+
});
|
|
174
|
+
}, true);
|
|
175
|
+
out.closed ||= back;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const MEDALS: readonly Api.ThemeColor[] = ["warn", "fgMuted", "accentInk"];
|
|
180
|
+
|
|
181
|
+
export function leaderboardScreen(id: string, desc: Api.LeaderboardDesc = {}): Api.LeaderboardResult {
|
|
182
|
+
const { t, phone } = frame();
|
|
183
|
+
const scopes = desc.scopes ?? [];
|
|
184
|
+
let scope = Math.min(kit.memory(`${id}.scope`, 0), Math.max(0, scopes.length - 1));
|
|
185
|
+
const out = { scope, changed: false, retry: false, closed: false };
|
|
186
|
+
const entries = desc.entries ?? [];
|
|
187
|
+
out.closed = screen(id, desc.title ?? "Leaderboard", 520, () => {
|
|
188
|
+
if (scopes.length > 0) {
|
|
189
|
+
const r = kit.tabs("Scopes", scopes, scope, { fill: true, width: grow() });
|
|
190
|
+
out.changed = r.response.changed;
|
|
191
|
+
scope = r.selected;
|
|
192
|
+
}
|
|
193
|
+
const height = px(phone ? 380 : 360);
|
|
194
|
+
if (desc.loading) {
|
|
195
|
+
// Honest loading: skeleton rows, never invented scores.
|
|
196
|
+
kit.column("Loading", { width: grow(), gap: t.md, height }, () => {
|
|
197
|
+
for (let i = 0; i < 6; i++) kit.skeleton(kit.idi("Row", i), { height: t.controlSm });
|
|
198
|
+
});
|
|
199
|
+
} else if (desc.error) {
|
|
200
|
+
kit.column("Error", { width: grow(), gap: t.md, height, align: "center", cross: "center" }, () => {
|
|
201
|
+
kit.label("Message", desc.error!, { color: token("danger"), align: "center", width: grow() });
|
|
202
|
+
if ((kit.button("Retry", desc.retryLabel ?? "Retry", {}) as Api.Response).clicked) out.retry = true;
|
|
203
|
+
});
|
|
204
|
+
} else if (entries.length === 0) {
|
|
205
|
+
kit.column("Empty", { width: grow(), height, align: "center", cross: "center" }, () => {
|
|
206
|
+
kit.label("Message", desc.empty ?? "No scores yet", { color: token("fgMuted"), align: "center", width: grow() });
|
|
207
|
+
});
|
|
208
|
+
} else {
|
|
209
|
+
const player = entries.findIndex((e) => e.player);
|
|
210
|
+
kit.list("Rows", { count: entries.length, rowHeight: t.row, height, width: grow(), selected: player >= 0 ? player : null }, (i) => {
|
|
211
|
+
const e = entries[i]!;
|
|
212
|
+
const rank = e.rank ?? i + 1;
|
|
213
|
+
kit.label("Rank", `${rank}`, { width: px(40), font: "mono", step: rank <= 3 ? "lg" : "body", color: token(rank <= 3 ? MEDALS[rank - 1]! : "fgMuted"), nowrap: true });
|
|
214
|
+
kit.label("Name", e.name ?? "", { width: grow(), nowrap: true });
|
|
215
|
+
kit.label("Score", e.score ?? "", { font: "mono", nowrap: true });
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
kit.remember(`${id}.scope`, scope);
|
|
220
|
+
out.scope = scope;
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Story and teaching: the cutscene (letterbox and dialogue box) and the
|
|
2
|
+
// onboarding overlay (a spotlight on each step's target and a callout).
|
|
3
|
+
|
|
4
|
+
import type * as Api from "@origingame/engine";
|
|
5
|
+
import { kit } from "./bind.ts";
|
|
6
|
+
import { all, frame, grow, pct, px, token } from "./util.ts";
|
|
7
|
+
|
|
8
|
+
export function cutscene(id: string, desc: Api.CutsceneDesc = {}): Api.CutsceneResult {
|
|
9
|
+
const { t, phone } = frame();
|
|
10
|
+
const lines = desc.lines ?? [];
|
|
11
|
+
let index = Math.min(desc.index ?? 0, Math.max(0, lines.length - 1));
|
|
12
|
+
const out = { index, finished: false, skipped: false };
|
|
13
|
+
const open = (desc.open ?? false) && lines.length > 0;
|
|
14
|
+
if (!open) return out;
|
|
15
|
+
kit.setNavigation(true);
|
|
16
|
+
const letterbox = desc.letterbox ?? true;
|
|
17
|
+
if (letterbox) {
|
|
18
|
+
for (const [name, anchor] of [["Top", "top"], ["Bottom", "bottom"]] as const) {
|
|
19
|
+
kit.anchor(`${id}.${name}`, { anchor, margin: 0, width: grow(), height: pct(0.1), background: token("stage") }, () => {});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const line = lines[index]!;
|
|
23
|
+
kit.anchor(`${id}.box`, { anchor: "bottom", width: phone ? grow() : px(Math.min(760, frame().viewport.width - 64)), offsetY: letterbox ? -frame().viewport.height * 0.04 : 0 }, () => {
|
|
24
|
+
const r = kit.dialogue("Dialogue", { speaker: line.speaker ?? "", text: line.text ?? "", portraitIcon: line.portrait ?? "" });
|
|
25
|
+
if (r.advanced) {
|
|
26
|
+
if (index + 1 >= lines.length) out.finished = true;
|
|
27
|
+
else index += 1;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
if (desc.skippable ?? true) {
|
|
31
|
+
kit.anchor(`${id}.skip`, { anchor: "topRight", offsetY: letterbox ? frame().viewport.height * 0.1 : 0 }, () => {
|
|
32
|
+
if ((kit.button("Skip", desc.skipLabel ?? "Skip", { variant: "quiet", size: "small" }) as Api.Response).clicked) out.skipped = true;
|
|
33
|
+
});
|
|
34
|
+
if (kit.back()) out.skipped = true;
|
|
35
|
+
}
|
|
36
|
+
out.index = index;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function tutorialOverlay(id: string, desc: Api.TutorialDesc = {}): Api.TutorialResult {
|
|
41
|
+
const { t, viewport } = frame();
|
|
42
|
+
const steps = desc.steps ?? [];
|
|
43
|
+
const index = Math.min(desc.index ?? 0, Math.max(0, steps.length - 1));
|
|
44
|
+
const out = { index, finished: false, skipped: false };
|
|
45
|
+
if (!(desc.open ?? false) || steps.length === 0) return out;
|
|
46
|
+
const step = steps[index]!;
|
|
47
|
+
const hole = step.target ? kit.bounds(step.target) : null;
|
|
48
|
+
kit.spotlight(id, hole, {});
|
|
49
|
+
// The callout beside the hole: under it when there is room, else over it.
|
|
50
|
+
const width = Math.min(320, viewport.width - 2 * t.gutter);
|
|
51
|
+
const last = kit.bounds(`${id}.callout`);
|
|
52
|
+
const height = last?.height ?? 160;
|
|
53
|
+
const safe = viewport.safeArea;
|
|
54
|
+
let x = viewport.width / 2 - width / 2;
|
|
55
|
+
let y = viewport.height / 2 - height / 2;
|
|
56
|
+
if (hole) {
|
|
57
|
+
x = Math.min(Math.max(hole.x, safe.left + t.gutter), viewport.width - safe.right - t.gutter - width);
|
|
58
|
+
const below = hole.y + hole.height + t.lg;
|
|
59
|
+
y = below + height <= viewport.height - safe.bottom - t.gutter ? below : Math.max(safe.top + t.gutter, hole.y - t.lg - height);
|
|
60
|
+
}
|
|
61
|
+
kit.anchor(`${id}.callout`, {
|
|
62
|
+
anchor: "topLeft",
|
|
63
|
+
over: true,
|
|
64
|
+
margin: 0,
|
|
65
|
+
offsetX: x - safe.left,
|
|
66
|
+
offsetY: y - safe.top,
|
|
67
|
+
width: px(width),
|
|
68
|
+
direction: "column",
|
|
69
|
+
gap: t.sm,
|
|
70
|
+
padding: all(t.lg),
|
|
71
|
+
background: token("surfaceRaised"),
|
|
72
|
+
radius: t.panel,
|
|
73
|
+
}, () => {
|
|
74
|
+
const count = (desc.stepLabel ?? "{step} of {count}").replaceAll("{step}", `${index + 1}`).replaceAll("{count}", `${steps.length}`);
|
|
75
|
+
kit.label("Count", count, { step: "meta", color: token("fgMuted") });
|
|
76
|
+
if (step.title) kit.label("Title", step.title, { step: "lg", width: grow() });
|
|
77
|
+
if (step.text) kit.label("Text", step.text, { step: "reading", color: token("fgMuted"), width: grow() });
|
|
78
|
+
kit.row("Actions", { width: grow(), gap: t.sm, align: "spaceBetween", cross: "center" }, () => {
|
|
79
|
+
if ((kit.button("Skip", desc.skipLabel ?? "Skip", { variant: "quiet", size: "small" }) as Api.Response).clicked) out.skipped = true;
|
|
80
|
+
const lastStep = index + 1 >= steps.length;
|
|
81
|
+
if ((kit.button("Next", lastStep ? (desc.doneLabel ?? "Done") : (desc.nextLabel ?? "Next"), { variant: "primary", size: "small" }) as Api.Response).clicked) {
|
|
82
|
+
if (lastStep) out.finished = true;
|
|
83
|
+
else out.index = index + 1;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
if (kit.back()) out.skipped = true;
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Small helpers the screen templates share: sizes, insets, the frame's
|
|
2
|
+
// tokens, number and time formats, and HUD capsules. Nothing here keeps
|
|
3
|
+
// state: what must persist lives in the UI's memory (og.ui.memory).
|
|
4
|
+
|
|
5
|
+
import type * as Api from "@origingame/engine";
|
|
6
|
+
import { kit } from "./bind.ts";
|
|
7
|
+
|
|
8
|
+
export const px = (n: number): Api.Size => ({ kind: "fixed", px: n });
|
|
9
|
+
export const grow = (min?: number, max?: number): Api.Size => ({ kind: "grow", ...(min === undefined ? {} : { min }), ...(max === undefined ? {} : { max }) });
|
|
10
|
+
export const fit = (min?: number, max?: number): Api.Size => ({ kind: "fit", ...(min === undefined ? {} : { min }), ...(max === undefined ? {} : { max }) });
|
|
11
|
+
export const pct = (fraction: number): Api.Size => ({ kind: "percent", fraction });
|
|
12
|
+
export const all = (n: number): Api.Insets => ({ left: n, top: n, right: n, bottom: n });
|
|
13
|
+
export const xy = (x: number, y: number): Api.Insets => ({ left: x, top: y, right: x, bottom: y });
|
|
14
|
+
export const token = (name: Api.ThemeColor, alpha?: number): Api.Paint => (alpha === undefined ? { token: name } : { token: name, alpha });
|
|
15
|
+
|
|
16
|
+
/** The tokens of the frame being built, read once per UI tick. */
|
|
17
|
+
let cached: { tick: number; theme: Api.UiTheme; viewport: Api.Viewport } | null = null;
|
|
18
|
+
export function frame(): { theme: Api.UiTheme; viewport: Api.Viewport; t: Tokens; phone: boolean; touch: boolean } {
|
|
19
|
+
const tick = kit.tick();
|
|
20
|
+
if (!cached || cached.tick !== tick) cached = { tick, theme: kit.frameTheme(), viewport: kit.frameViewport() };
|
|
21
|
+
const { theme, viewport } = cached;
|
|
22
|
+
const phone = viewport.breakpoint === "phonePortrait" || viewport.breakpoint === "phoneLandscape";
|
|
23
|
+
return { theme, viewport, t: tokens(theme), phone, touch: viewport.coarsePointer || phone || viewport.breakpoint === "tablet" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Tokens {
|
|
27
|
+
xxs: number; xs: number; sm: number; md: number; lg: number; xl: number; xxl: number; xl3: number;
|
|
28
|
+
card: number; panel: number; block: number; button: number;
|
|
29
|
+
controlSm: number; controlMd: number; controlLg: number; gutter: number; row: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tokens(t: Api.UiTheme): Tokens {
|
|
33
|
+
const s = (k: string): number => t.space[k] ?? 0;
|
|
34
|
+
const r = (k: string): number => t.radius[k] ?? 0;
|
|
35
|
+
return {
|
|
36
|
+
xxs: s("xxs"), xs: s("xs"), sm: s("sm"), md: s("md"), lg: s("lg"), xl: s("xl"), xxl: s("xxl"), xl3: s("xl3"),
|
|
37
|
+
card: r("card"), panel: r("panel"), block: r("block"), button: r("button"),
|
|
38
|
+
controlSm: t.sizes.controlSm, controlMd: t.sizes.controlMd, controlLg: t.sizes.controlLg, gutter: t.sizes.gutter, row: t.sizes.row,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A count for a HUD: 12,840 below a million, then 3.40M, 1.20B, 7.00T, then 1.2e15. */
|
|
43
|
+
export function fmt(n: number): string {
|
|
44
|
+
if (!Number.isFinite(n)) return "—";
|
|
45
|
+
const a = Math.abs(n);
|
|
46
|
+
const sign = n < 0 ? "-" : "";
|
|
47
|
+
if (a < 1_000_000) return sign + Math.floor(a).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
48
|
+
const units: Array<[number, string]> = [[1e12, "T"], [1e9, "B"], [1e6, "M"]];
|
|
49
|
+
for (const [v, u] of units) {
|
|
50
|
+
if (a >= v && a < v * 1000) {
|
|
51
|
+
const x = a / v;
|
|
52
|
+
return sign + (x < 100 ? x.toFixed(x < 10 ? 2 : 1) : Math.floor(x).toString()) + u;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return sign + a.toExponential(1).replace("+", "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Seconds as m:ss (or m:ss.mmm with `millis`); negative is 0:00. */
|
|
59
|
+
export function clock(seconds: number, millis = false): string {
|
|
60
|
+
const s = Math.max(0, seconds);
|
|
61
|
+
const m = Math.floor(s / 60);
|
|
62
|
+
const rest = s - m * 60;
|
|
63
|
+
const whole = Math.floor(rest);
|
|
64
|
+
const head = `${m}:${whole.toString().padStart(2, "0")}`;
|
|
65
|
+
return millis ? `${head}.${Math.floor((rest - whole) * 1000).toString().padStart(3, "0")}` : head;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A finished time as m:ss.mmm, cut to the millisecond as race timing is, but never a millisecond short by a float's last bit (96.233 s is 1:36.233, where 96.233 * 1000 is 96232.99999999999); negative is 0:00.000. */
|
|
69
|
+
export function lapTime(seconds: number): string {
|
|
70
|
+
const ms = Math.floor(Math.max(0, seconds) * 1000 + 1e-6);
|
|
71
|
+
const m = Math.floor(ms / 60000);
|
|
72
|
+
const s = Math.floor(ms / 1000) % 60;
|
|
73
|
+
return `${m}:${s.toString().padStart(2, "0")}.${(ms % 1000).toString().padStart(3, "0")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A HUD capsule: a translucent dark pill (material-thick) that keeps text legible over any art. */
|
|
77
|
+
export function capsule(id: string, build: () => void, desc: { gap?: number; padX?: number; height?: number } = {}): Api.Response {
|
|
78
|
+
const { t } = frame();
|
|
79
|
+
return kit.row(id, {
|
|
80
|
+
padding: xy(desc.padX ?? t.md, 0),
|
|
81
|
+
height: px(desc.height ?? t.controlMd),
|
|
82
|
+
gap: desc.gap ?? t.sm,
|
|
83
|
+
cross: "center",
|
|
84
|
+
background: token("materialThick"),
|
|
85
|
+
radius: t.button,
|
|
86
|
+
frame: "capsule",
|
|
87
|
+
}, build) as Api.Response;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An icon of the atlas, or a small solid mark of the same ink when the atlas lacks it; `label` names it for a screen reader (IconDesc.label: empty is decoration). */
|
|
91
|
+
export function iconOr(id: string, name: string, size: number, tint: Api.Paint, label?: string): void {
|
|
92
|
+
if (kit.hasIcon(name)) kit.icon(id, name, label === undefined ? { size, tint } : { size, tint, label });
|
|
93
|
+
else kit.image(id, { tint, width: px(size * 0.6), height: px(size * 0.6), label: label ?? "" });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Menu items with defaults filled in, the first primary unless one is. */
|
|
97
|
+
export function menuItems(items: readonly Api.MenuItem[] | undefined, fallback: ReadonlyArray<[string, string]>): Required<Pick<Api.MenuItem, "id" | "label" | "disabled" | "primary">>[] {
|
|
98
|
+
const list: readonly Api.MenuItem[] = items && items.length > 0 ? items : fallback.map(([id, label]) => ({ id, label }));
|
|
99
|
+
const anyPrimary = list.some((i) => i.primary);
|
|
100
|
+
return list.map((i, k) => ({
|
|
101
|
+
id: i.id ?? `item${k}`,
|
|
102
|
+
label: i.label ?? i.id ?? "",
|
|
103
|
+
disabled: i.disabled ?? false,
|
|
104
|
+
primary: anyPrimary ? (i.primary ?? false) : k === 0,
|
|
105
|
+
}));
|
|
106
|
+
}
|