minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -20
- package/dist/internal/AnimationSystem.js +345 -0
- package/dist/internal/AssetSystem.d.ts +6 -0
- package/dist/internal/AssetSystem.js +281 -0
- package/dist/internal/BackgroundSystem.d.ts +1 -0
- package/dist/internal/BackgroundSystem.js +26 -0
- package/dist/internal/CanvasSystem.d.ts +1 -0
- package/dist/internal/CanvasSystem.js +51 -0
- package/dist/internal/DesignNamingCheck.d.ts +1 -0
- package/dist/internal/DesignNamingCheck.js +7 -0
- package/dist/internal/ExplosionSystem.d.ts +1 -0
- package/dist/internal/ExplosionSystem.js +540 -0
- package/dist/internal/InputSystem.d.ts +1 -0
- package/dist/internal/InputSystem.js +292 -0
- package/dist/internal/LoopSystem.d.ts +1 -0
- package/dist/internal/LoopSystem.js +61 -0
- package/dist/internal/PhysicsSystem.d.ts +1 -0
- package/dist/internal/PhysicsSystem.js +174 -0
- package/dist/internal/RenderSystem.d.ts +1 -0
- package/dist/internal/RenderSystem.js +1021 -0
- package/dist/internal/SoundSystem.d.ts +39 -0
- package/dist/internal/SoundSystem.js +292 -0
- package/dist/internal/SpriteSystem.d.ts +1 -0
- package/dist/internal/SpriteSystem.js +32 -0
- package/dist/internal/TextSystem.d.ts +1 -0
- package/dist/internal/TextSystem.js +16 -0
- package/dist/internal/TimerSystem.d.ts +1 -0
- package/dist/internal/TimerSystem.js +43 -0
- package/dist/internal/TrailSystem.d.ts +1 -0
- package/dist/internal/TrailSystem.js +116 -0
- package/dist/internal/TransitionSystem.d.ts +1 -0
- package/dist/internal/TransitionSystem.js +74 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
- package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
- package/dist/minimo-ambient.d.ts +299 -0
- package/dist/minimo-ambient.js +1445 -0
- package/dist/minimo-arcaderacer.d.ts +1431 -0
- package/dist/minimo-arcaderacer.js +2060 -0
- package/dist/minimo-cards.d.ts +359 -0
- package/dist/minimo-cards.js +819 -0
- package/dist/minimo-dice.d.ts +206 -0
- package/dist/minimo-dice.js +719 -0
- package/dist/minimo-fire.d.ts +282 -0
- package/dist/minimo-fire.js +930 -0
- package/dist/minimo-format.d.ts +632 -0
- package/dist/minimo-format.js +669 -0
- package/dist/minimo-leaderboard.d.ts +199 -0
- package/dist/minimo-leaderboard.js +566 -0
- package/dist/minimo-multiplayer.d.ts +387 -0
- package/dist/minimo-multiplayer.js +1323 -0
- package/dist/minimo.d.ts +1690 -233
- package/dist/minimo.js +2587 -892
- package/package.json +10 -3
- package/dist/animations.js +0 -30
- package/dist/audio.js +0 -17
- package/dist/game.js +0 -1105
- package/dist/input.js +0 -185
- package/dist/internal-types.js +0 -4
- package/dist/physics.js +0 -10
- package/dist/render.js +0 -75
- package/dist/sprite.js +0 -149
- package/dist/timers.js +0 -23
- /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
const DEFAULT_SETTINGS = {
|
|
2
|
+
title: "Leaderboard",
|
|
3
|
+
cycle: "never",
|
|
4
|
+
order: "higher",
|
|
5
|
+
names: "player",
|
|
6
|
+
player: "local",
|
|
7
|
+
};
|
|
8
|
+
const NO_NAME = "noname";
|
|
9
|
+
const MAX_NAME_CHARS = 24;
|
|
10
|
+
const MAX_LOCAL_ENTRIES = 100;
|
|
11
|
+
const MAX_LOCAL_PERIODS = 60;
|
|
12
|
+
const MAX_READ = 100;
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Periods: the same as Minimo Games computes for the leaderboards it keeps.
|
|
15
|
+
function pad(value) {
|
|
16
|
+
return String(value).padStart(2, "0");
|
|
17
|
+
}
|
|
18
|
+
function isoWeek(date) {
|
|
19
|
+
const day = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
20
|
+
const weekday = day.getUTCDay() || 7;
|
|
21
|
+
day.setUTCDate(day.getUTCDate() + 4 - weekday);
|
|
22
|
+
const yearStart = Date.UTC(day.getUTCFullYear(), 0, 1);
|
|
23
|
+
return { year: day.getUTCFullYear(), week: Math.ceil(((day.getTime() - yearStart) / 86400000 + 1) / 7) };
|
|
24
|
+
}
|
|
25
|
+
/** @internal */
|
|
26
|
+
export function leaderboardPeriod(cycle, at) {
|
|
27
|
+
switch (cycle) {
|
|
28
|
+
case "daily":
|
|
29
|
+
return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}`;
|
|
30
|
+
case "weekly": {
|
|
31
|
+
const { year, week } = isoWeek(at);
|
|
32
|
+
return `${year}-W${pad(week)}`;
|
|
33
|
+
}
|
|
34
|
+
case "monthly":
|
|
35
|
+
return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}`;
|
|
36
|
+
case "yearly":
|
|
37
|
+
return String(at.getUTCFullYear());
|
|
38
|
+
default:
|
|
39
|
+
return "all";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** @internal */
|
|
43
|
+
export function resolveLeaderboardPeriod(cycle, requested, now) {
|
|
44
|
+
const value = (requested ?? "current").trim();
|
|
45
|
+
if (value === "" || value === "current")
|
|
46
|
+
return leaderboardPeriod(cycle, now);
|
|
47
|
+
if (value !== "previous")
|
|
48
|
+
return value;
|
|
49
|
+
const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
50
|
+
switch (cycle) {
|
|
51
|
+
case "daily":
|
|
52
|
+
return leaderboardPeriod(cycle, new Date(day - 86400000));
|
|
53
|
+
case "weekly":
|
|
54
|
+
return leaderboardPeriod(cycle, new Date(day - 7 * 86400000));
|
|
55
|
+
case "monthly":
|
|
56
|
+
return leaderboardPeriod(cycle, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)));
|
|
57
|
+
case "yearly":
|
|
58
|
+
return String(now.getUTCFullYear() - 1);
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const HOST_TIMEOUT_MS = 1500;
|
|
64
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
65
|
+
const pendingRequests = new Map();
|
|
66
|
+
let nextRequestId = 1;
|
|
67
|
+
let listening = false;
|
|
68
|
+
let hostPromise = null;
|
|
69
|
+
function parentWindow() {
|
|
70
|
+
try {
|
|
71
|
+
return typeof window !== "undefined" && window.parent && window.parent !== window ? window.parent : null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function listen() {
|
|
78
|
+
if (listening || typeof window === "undefined")
|
|
79
|
+
return;
|
|
80
|
+
listening = true;
|
|
81
|
+
window.addEventListener("message", (event) => {
|
|
82
|
+
if (event.source !== parentWindow())
|
|
83
|
+
return;
|
|
84
|
+
const message = event.data;
|
|
85
|
+
if (!message || message.type !== "minimo:leaderboard-response" || typeof message.id !== "string")
|
|
86
|
+
return;
|
|
87
|
+
const pending = pendingRequests.get(message.id);
|
|
88
|
+
if (!pending)
|
|
89
|
+
return;
|
|
90
|
+
pendingRequests.delete(message.id);
|
|
91
|
+
if (message.ok)
|
|
92
|
+
pending.resolve(message.result);
|
|
93
|
+
else
|
|
94
|
+
pending.reject(new Error(message.error || "Leaderboard request failed"));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function request(action, payload, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
98
|
+
const parent = parentWindow();
|
|
99
|
+
if (!parent)
|
|
100
|
+
return Promise.reject(new Error("No leaderboard host"));
|
|
101
|
+
listen();
|
|
102
|
+
const id = `leaderboard-${nextRequestId++}`;
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
pendingRequests.delete(id);
|
|
106
|
+
reject(new Error("The leaderboard did not answer"));
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
pendingRequests.set(id, {
|
|
109
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
110
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
111
|
+
});
|
|
112
|
+
parent.postMessage({ type: "minimo:leaderboard-request", id, action, ...payload }, "*");
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function connectHost() {
|
|
116
|
+
hostPromise ?? (hostPromise = request("hello", {}, HOST_TIMEOUT_MS)
|
|
117
|
+
.then((info) => (info && info.host === "minimo-games" ? info : null))
|
|
118
|
+
.catch(() => null));
|
|
119
|
+
return hostPromise;
|
|
120
|
+
}
|
|
121
|
+
const memoryStorage = new Map();
|
|
122
|
+
function readStored(key) {
|
|
123
|
+
try {
|
|
124
|
+
const value = window.localStorage.getItem(key);
|
|
125
|
+
if (value !== null)
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Fall back to memory.
|
|
130
|
+
}
|
|
131
|
+
return memoryStorage.get(key) ?? null;
|
|
132
|
+
}
|
|
133
|
+
function writeStored(key, value) {
|
|
134
|
+
memoryStorage.set(key, value);
|
|
135
|
+
try {
|
|
136
|
+
window.localStorage.setItem(key, value);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Kept in memory for this visit.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function cleanName(value) {
|
|
143
|
+
if (typeof value !== "string")
|
|
144
|
+
return null;
|
|
145
|
+
const name = value.replace(/\s+/g, " ").trim();
|
|
146
|
+
return name && Array.from(name).length <= MAX_NAME_CHARS ? name : null;
|
|
147
|
+
}
|
|
148
|
+
const ADJECTIVES = ["Blue", "Brave", "Bright", "Calm", "Clever", "Cosmic", "Golden", "Happy", "Jolly", "Lucky", "Mighty", "Quick",
|
|
149
|
+
"Quiet", "Red", "Silver", "Sleepy", "Sly", "Sunny", "Swift", "Tiny", "Wild", "Wise", "Witty", "Zesty"];
|
|
150
|
+
const ANIMALS = ["Badger", "Bear", "Cat", "Crab", "Deer", "Duck", "Eagle", "Fox", "Frog", "Gecko", "Goat", "Hare",
|
|
151
|
+
"Koala", "Lion", "Llama", "Mole", "Otter", "Owl", "Panda", "Seal", "Shark", "Tiger", "Turtle", "Wolf"];
|
|
152
|
+
function assignedLocalName() {
|
|
153
|
+
const key = "minimo-leaderboard-assigned-name";
|
|
154
|
+
const stored = cleanName(readStored(key));
|
|
155
|
+
if (stored)
|
|
156
|
+
return stored;
|
|
157
|
+
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
|
158
|
+
const name = `${pick(ADJECTIVES)} ${pick(ANIMALS)} ${Math.floor(Math.random() * 100)}`;
|
|
159
|
+
writeStored(key, name);
|
|
160
|
+
return name;
|
|
161
|
+
}
|
|
162
|
+
function isBetter(order, candidate, current) {
|
|
163
|
+
return order === "higher" ? candidate > current : candidate < current;
|
|
164
|
+
}
|
|
165
|
+
function rankEntries(scores) {
|
|
166
|
+
const ranks = [];
|
|
167
|
+
scores.forEach((score, index) => {
|
|
168
|
+
ranks.push(index > 0 && scores[index - 1] === score ? ranks[index - 1] : index + 1);
|
|
169
|
+
});
|
|
170
|
+
return ranks;
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// The table drawn over the game, in HTML.
|
|
174
|
+
const STYLE_ID = "minimo-leaderboard-style";
|
|
175
|
+
const STYLE = `
|
|
176
|
+
.minimo-leaderboard{position:fixed;z-index:2147483000;display:flex;align-items:center;justify-content:center;
|
|
177
|
+
box-sizing:border-box;background:rgba(0,0,0,.55);-webkit-user-select:none;user-select:none;touch-action:manipulation}
|
|
178
|
+
.minimo-leaderboard *{box-sizing:border-box}
|
|
179
|
+
.minimo-leaderboard-panel{display:flex;flex-direction:column;width:min(92%,30em);max-height:92%;padding:1em;border-radius:.9em;
|
|
180
|
+
background:var(--mlb-bg);color:var(--mlb-fg);font-family:var(--mlb-font);box-shadow:0 .6em 2em rgba(0,0,0,.5)}
|
|
181
|
+
.minimo-leaderboard-title{margin:0 0 .15em;color:var(--mlb-accent);font-size:1.35em;font-weight:700;text-align:center}
|
|
182
|
+
.minimo-leaderboard-period{margin:0 0 .7em;opacity:.7;font-size:.8em;text-align:center}
|
|
183
|
+
.minimo-leaderboard-tabs{display:flex;gap:.4em;justify-content:center;margin:0 0 .7em}
|
|
184
|
+
.minimo-leaderboard button{padding:.45em .9em;border:.08em solid currentColor;border-radius:999px;background:none;color:inherit;
|
|
185
|
+
font:inherit;font-size:.85em;cursor:pointer}
|
|
186
|
+
.minimo-leaderboard button[aria-pressed="true"],.minimo-leaderboard .minimo-leaderboard-primary{border-color:var(--mlb-accent);
|
|
187
|
+
background:var(--mlb-accent);color:var(--mlb-bg);font-weight:700}
|
|
188
|
+
.minimo-leaderboard-rows{flex:1 1 auto;min-height:0;margin:0;padding:0;overflow-y:auto;list-style:none}
|
|
189
|
+
.minimo-leaderboard-row{display:flex;align-items:baseline;gap:.6em;padding:.35em .5em;border-radius:.4em}
|
|
190
|
+
.minimo-leaderboard-row:nth-child(odd){background:rgba(255,255,255,.06)}
|
|
191
|
+
.minimo-leaderboard-row.is-mine{color:var(--mlb-accent);font-weight:700}
|
|
192
|
+
.minimo-leaderboard-rank{flex:0 0 2.2em;text-align:right;opacity:.8}
|
|
193
|
+
.minimo-leaderboard-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
194
|
+
.minimo-leaderboard-score{flex:0 0 auto;font-variant-numeric:tabular-nums}
|
|
195
|
+
.minimo-leaderboard-gap{padding:.1em .5em;opacity:.5;text-align:center;list-style:none}
|
|
196
|
+
.minimo-leaderboard-empty{margin:.8em 0;opacity:.75;text-align:center}
|
|
197
|
+
.minimo-leaderboard-name-form{display:flex;gap:.4em;margin:0 0 .7em}
|
|
198
|
+
.minimo-leaderboard-name-form input{flex:1 1 auto;min-width:0;padding:.45em .6em;border:.08em solid currentColor;border-radius:.4em;
|
|
199
|
+
background:rgba(255,255,255,.08);color:inherit;font:inherit;font-size:max(16px,.9em)}
|
|
200
|
+
.minimo-leaderboard-note{margin:.6em 0 0;opacity:.6;font-size:.75em;text-align:center}
|
|
201
|
+
.minimo-leaderboard-actions{display:flex;justify-content:center;gap:.5em;margin-top:.8em}
|
|
202
|
+
`;
|
|
203
|
+
function escapeHtml(value) {
|
|
204
|
+
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
|
205
|
+
}
|
|
206
|
+
function defaultFormat(score) {
|
|
207
|
+
return Number.isInteger(score) ? score.toLocaleString() : score.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
|
208
|
+
}
|
|
209
|
+
const CURRENT_LABELS = {
|
|
210
|
+
never: "All time", daily: "Today", weekly: "This week", monthly: "This month", yearly: "This year",
|
|
211
|
+
};
|
|
212
|
+
const PREVIOUS_LABELS = {
|
|
213
|
+
never: "", daily: "Yesterday", weekly: "Last week", monthly: "Last month", yearly: "Last year",
|
|
214
|
+
};
|
|
215
|
+
/** @internal The table's HTML, apart from the page, so it can be checked without a browser. */
|
|
216
|
+
export function renderLeaderboardHtml(table, view) {
|
|
217
|
+
const cycle = table.settings.cycle;
|
|
218
|
+
const parts = [];
|
|
219
|
+
parts.push(`<div class="minimo-leaderboard-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(view.title)}">`);
|
|
220
|
+
parts.push(`<h2 class="minimo-leaderboard-title">${escapeHtml(view.title)}</h2>`);
|
|
221
|
+
if (cycle !== "never") {
|
|
222
|
+
parts.push('<div class="minimo-leaderboard-tabs" role="group">');
|
|
223
|
+
parts.push(`<button type="button" data-period="current" aria-pressed="${view.previous ? "false" : "true"}">${CURRENT_LABELS[cycle]}</button>`);
|
|
224
|
+
parts.push(`<button type="button" data-period="previous" aria-pressed="${view.previous ? "true" : "false"}">${PREVIOUS_LABELS[cycle]}</button>`);
|
|
225
|
+
parts.push("</div>");
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
parts.push(`<p class="minimo-leaderboard-period">${CURRENT_LABELS.never}</p>`);
|
|
229
|
+
}
|
|
230
|
+
if (view.askName) {
|
|
231
|
+
// Not a <form>: a game's frame on Minimo Games is sandboxed without forms.
|
|
232
|
+
parts.push('<div class="minimo-leaderboard-name-form" data-name-form>');
|
|
233
|
+
parts.push(`<input name="name" maxlength="${MAX_NAME_CHARS}" placeholder="Your name" aria-label="Your name" autocomplete="nickname" value="${escapeHtml(view.playerName ?? "")}">`);
|
|
234
|
+
parts.push('<button type="button" class="minimo-leaderboard-primary" data-save-name>Save</button></div>');
|
|
235
|
+
}
|
|
236
|
+
if (!table.available) {
|
|
237
|
+
parts.push('<p class="minimo-leaderboard-empty">This leaderboard is not available.</p>');
|
|
238
|
+
}
|
|
239
|
+
else if (table.entries.length === 0) {
|
|
240
|
+
parts.push('<p class="minimo-leaderboard-empty">No scores yet.</p>');
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
parts.push('<ol class="minimo-leaderboard-rows">');
|
|
244
|
+
const row = (entry) => parts.push(`<li class="minimo-leaderboard-row${entry.mine ? " is-mine" : ""}">`
|
|
245
|
+
+ `<span class="minimo-leaderboard-rank">${entry.rank}</span>`
|
|
246
|
+
+ `<span class="minimo-leaderboard-name">${entry.name === null ? "" : escapeHtml(entry.name)}</span>`
|
|
247
|
+
+ `<span class="minimo-leaderboard-score">${escapeHtml(view.format(entry.score))}</span></li>`);
|
|
248
|
+
table.entries.forEach(row);
|
|
249
|
+
if (table.mine && !table.entries.some((entry) => entry.mine)) {
|
|
250
|
+
parts.push('<li class="minimo-leaderboard-gap" aria-hidden="true">…</li>');
|
|
251
|
+
row(table.mine);
|
|
252
|
+
}
|
|
253
|
+
parts.push("</ol>");
|
|
254
|
+
}
|
|
255
|
+
if (table.local && table.available) {
|
|
256
|
+
parts.push('<p class="minimo-leaderboard-note">Scores on this device</p>');
|
|
257
|
+
}
|
|
258
|
+
parts.push('<div class="minimo-leaderboard-actions"><button type="button" class="minimo-leaderboard-primary" data-close>Close</button></div>');
|
|
259
|
+
parts.push("</div>");
|
|
260
|
+
return parts.join("");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A leaderboard of the game: a table of each player's best score, per period.
|
|
264
|
+
*
|
|
265
|
+
* On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
|
|
266
|
+
* game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
|
|
267
|
+
* tool; the game names one by its key. Their settings come from there, and a
|
|
268
|
+
* key that no leaderboard of the game has is unavailable. Leaderboards are part
|
|
269
|
+
* of Minimo Games Pro: while the creator is not on it, the game shows its
|
|
270
|
+
* tables but saves no new records.
|
|
271
|
+
*
|
|
272
|
+
* Scores of a leaderboard that takes Minimo Games accounts go to one table
|
|
273
|
+
* that every player sees. Minimo Games posts them for the signed-in player, and
|
|
274
|
+
* asks a player who is not signed in to sign in, keeping the score until they
|
|
275
|
+
* do. Scores of a leaderboard for light users stay on the device, and a game
|
|
276
|
+
* preview keeps every score on the device, so a working version never changes
|
|
277
|
+
* the published game's tables.
|
|
278
|
+
*
|
|
279
|
+
* Anywhere else, every leaderboard keeps its scores on the device, with the
|
|
280
|
+
* settings passed here.
|
|
281
|
+
*
|
|
282
|
+
* Nothing here pauses the game. Await {@link Leaderboard.show} to continue
|
|
283
|
+
* after the player closes the table.
|
|
284
|
+
*
|
|
285
|
+
* ```js
|
|
286
|
+
* import { Leaderboard } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo-leaderboard.js";
|
|
287
|
+
*
|
|
288
|
+
* const best = new Leaderboard(game, "best-score");
|
|
289
|
+
* // When a run ends:
|
|
290
|
+
* await best.submit(score);
|
|
291
|
+
* await best.show({ top: 10 });
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export class Leaderboard {
|
|
295
|
+
/**
|
|
296
|
+
* @param game - The game.
|
|
297
|
+
* @param key - The leaderboard's key, as set up on Minimo Games.
|
|
298
|
+
* @param settings - Used only when the game runs outside Minimo Games.
|
|
299
|
+
* Default: a leaderboard on the device that never resets, where higher is
|
|
300
|
+
* better and players are named by their own name.
|
|
301
|
+
*/
|
|
302
|
+
constructor(game, key, settings = {}) {
|
|
303
|
+
/** @internal */ this._overlay = null;
|
|
304
|
+
/** @internal */ this._closeOverlay = null;
|
|
305
|
+
this._game = game;
|
|
306
|
+
this.key = String(key);
|
|
307
|
+
this._fallback = { ...DEFAULT_SETTINGS, title: this.key, ...settings };
|
|
308
|
+
}
|
|
309
|
+
/** Whether the table is showing. */
|
|
310
|
+
get isShowing() {
|
|
311
|
+
return this._overlay !== null;
|
|
312
|
+
}
|
|
313
|
+
/** @internal */
|
|
314
|
+
async _resolve() {
|
|
315
|
+
const host = await connectHost();
|
|
316
|
+
if (!host) {
|
|
317
|
+
return { settings: this._fallback, available: true, local: true, accepting: true, preview: false, host: false };
|
|
318
|
+
}
|
|
319
|
+
const settings = host.leaderboards[this.key];
|
|
320
|
+
if (!settings) {
|
|
321
|
+
return { settings: { ...this._fallback, title: this.key }, available: false, local: true, accepting: false, preview: host.preview, host: true };
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
settings,
|
|
325
|
+
available: true,
|
|
326
|
+
local: settings.player === "local" || host.preview,
|
|
327
|
+
accepting: host.accepting,
|
|
328
|
+
preview: host.preview,
|
|
329
|
+
host: true,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* This leaderboard's settings, and whether it exists and keeps its scores on
|
|
334
|
+
* the device.
|
|
335
|
+
*/
|
|
336
|
+
async info() {
|
|
337
|
+
const { settings, available, local } = await this._resolve();
|
|
338
|
+
return { settings, available, local };
|
|
339
|
+
}
|
|
340
|
+
/** @internal */
|
|
341
|
+
_storageKey(preview) {
|
|
342
|
+
return `${preview ? "minimo-leaderboard-preview:" : "minimo-leaderboard:"}${this.key}`;
|
|
343
|
+
}
|
|
344
|
+
/** @internal */
|
|
345
|
+
_readLocal(preview) {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(readStored(this._storageKey(preview)) ?? "null");
|
|
348
|
+
if (parsed && parsed.v === 1 && parsed.periods && typeof parsed.periods === "object")
|
|
349
|
+
return parsed;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// A damaged board starts again.
|
|
353
|
+
}
|
|
354
|
+
return { v: 1, periods: {} };
|
|
355
|
+
}
|
|
356
|
+
/** @internal */
|
|
357
|
+
_writeLocal(preview, board) {
|
|
358
|
+
const periods = Object.keys(board.periods);
|
|
359
|
+
// The oldest periods go first when there are too many to keep on a device.
|
|
360
|
+
for (const period of periods.sort().slice(0, Math.max(0, periods.length - MAX_LOCAL_PERIODS))) {
|
|
361
|
+
delete board.periods[period];
|
|
362
|
+
}
|
|
363
|
+
writeStored(this._storageKey(preview), JSON.stringify(board));
|
|
364
|
+
}
|
|
365
|
+
/** @internal */
|
|
366
|
+
async _localName(settings) {
|
|
367
|
+
if (settings.names === "hidden")
|
|
368
|
+
return null;
|
|
369
|
+
if (settings.names === "assigned")
|
|
370
|
+
return assignedLocalName();
|
|
371
|
+
return (await this.playerName()) ?? NO_NAME;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Posts a score. Only the player's best score in the current period is
|
|
375
|
+
* kept, so posting every finished run is right.
|
|
376
|
+
*
|
|
377
|
+
* @param score - A finite number. Which is better, higher or lower, is the
|
|
378
|
+
* leaderboard's setting.
|
|
379
|
+
*/
|
|
380
|
+
async submit(score) {
|
|
381
|
+
const value = Number(score);
|
|
382
|
+
if (!Number.isFinite(value))
|
|
383
|
+
throw new Error("Leaderboard.submit needs a finite number");
|
|
384
|
+
const resolved = await this._resolve();
|
|
385
|
+
if (!resolved.available || !resolved.accepting) {
|
|
386
|
+
return { status: "unavailable", best: null, rank: null, period: null };
|
|
387
|
+
}
|
|
388
|
+
if (!resolved.local) {
|
|
389
|
+
const result = await request("submit", { key: this.key, score: value });
|
|
390
|
+
return { status: result.status, best: result.best ?? null, rank: result.rank ?? null, period: result.period ?? null };
|
|
391
|
+
}
|
|
392
|
+
const period = leaderboardPeriod(resolved.settings.cycle, new Date());
|
|
393
|
+
const board = this._readLocal(resolved.preview);
|
|
394
|
+
const entries = board.periods[period] ?? [];
|
|
395
|
+
const order = resolved.settings.order;
|
|
396
|
+
const previousBest = entries.length > 0 ? entries[0].score : null;
|
|
397
|
+
entries.push({ score: value, at: new Date().toISOString() });
|
|
398
|
+
entries.sort((a, b) => (order === "higher" ? b.score - a.score : a.score - b.score));
|
|
399
|
+
board.periods[period] = entries.slice(0, MAX_LOCAL_ENTRIES);
|
|
400
|
+
this._writeLocal(resolved.preview, board);
|
|
401
|
+
const best = board.periods[period][0].score;
|
|
402
|
+
const improved = previousBest === null || isBetter(order, value, previousBest);
|
|
403
|
+
const rank = rankEntries(board.periods[period].map((entry) => entry.score))[board.periods[period].findIndex((entry) => entry.score === value)] ?? null;
|
|
404
|
+
return { status: improved ? "saved" : "not_better", best, rank: improved ? 1 : rank, period };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Reads one period of the table, best score first.
|
|
408
|
+
*
|
|
409
|
+
* @param limit - How many of the best scores. Default `10`, at most `100`.
|
|
410
|
+
*/
|
|
411
|
+
async top(limit = 10, options = {}) {
|
|
412
|
+
const count = Math.min(Math.max(Math.trunc(Number(limit) || 10), 1), MAX_READ);
|
|
413
|
+
const resolved = await this._resolve();
|
|
414
|
+
const base = { key: this.key, settings: resolved.settings, available: resolved.available, local: resolved.local };
|
|
415
|
+
if (!resolved.available) {
|
|
416
|
+
return { ...base, period: null, entries: [], mine: null };
|
|
417
|
+
}
|
|
418
|
+
if (!resolved.local) {
|
|
419
|
+
const table = await request("top", { key: this.key, limit: count, period: options.period ?? "current" });
|
|
420
|
+
return { ...base, period: table.period, entries: table.entries, mine: table.mine };
|
|
421
|
+
}
|
|
422
|
+
const period = resolveLeaderboardPeriod(resolved.settings.cycle, options.period, new Date());
|
|
423
|
+
if (period === null)
|
|
424
|
+
return { ...base, period: null, entries: [], mine: null };
|
|
425
|
+
const scores = (this._readLocal(resolved.preview).periods[period] ?? []).map((entry) => entry.score);
|
|
426
|
+
const ranks = rankEntries(scores);
|
|
427
|
+
const name = await this._localName(resolved.settings);
|
|
428
|
+
const entries = scores.slice(0, count).map((score, index) => ({ rank: ranks[index], name, score, mine: true }));
|
|
429
|
+
return { ...base, period, entries, mine: entries[0] ?? null };
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The light user's name on this device, shared by every Minimo Games game,
|
|
433
|
+
* or `null` before the player writes one. The table asks for it on its own
|
|
434
|
+
* when a leaderboard for light users shows names.
|
|
435
|
+
*/
|
|
436
|
+
async playerName() {
|
|
437
|
+
if (await connectHost()) {
|
|
438
|
+
try {
|
|
439
|
+
return cleanName((await request("getPlayerName", {})).name);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return cleanName(readStored("minimo-leaderboard-player-name"));
|
|
446
|
+
}
|
|
447
|
+
/** Sets the light user's name on this device. Returns it cleaned, or `null` when it is empty or too long. */
|
|
448
|
+
async setPlayerName(name) {
|
|
449
|
+
const clean = cleanName(name);
|
|
450
|
+
if (!clean)
|
|
451
|
+
return null;
|
|
452
|
+
if (await connectHost()) {
|
|
453
|
+
return cleanName((await request("setPlayerName", { name: clean })).name);
|
|
454
|
+
}
|
|
455
|
+
writeStored("minimo-leaderboard-player-name", clean);
|
|
456
|
+
return clean;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Shows the table over the game, in HTML: the best scores of a period, the
|
|
460
|
+
* player's own entry, and, for a leaderboard that resets, a switch to the
|
|
461
|
+
* previous period. Resolves when the player closes it.
|
|
462
|
+
*/
|
|
463
|
+
async show(options = {}) {
|
|
464
|
+
if (typeof document === "undefined" || !document.body)
|
|
465
|
+
return;
|
|
466
|
+
this.close();
|
|
467
|
+
const resolved = await this._resolve();
|
|
468
|
+
const format = options.format ?? defaultFormat;
|
|
469
|
+
const title = options.title ?? resolved.settings.title;
|
|
470
|
+
let previous = options.period === "previous";
|
|
471
|
+
const askName = resolved.available && resolved.local && resolved.settings.names === "player";
|
|
472
|
+
let playerName = askName ? await this.playerName() : null;
|
|
473
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
474
|
+
const style = document.createElement("style");
|
|
475
|
+
style.id = STYLE_ID;
|
|
476
|
+
style.textContent = STYLE;
|
|
477
|
+
document.head.appendChild(style);
|
|
478
|
+
}
|
|
479
|
+
const root = document.createElement("div");
|
|
480
|
+
root.className = "minimo-leaderboard";
|
|
481
|
+
root.style.setProperty("--mlb-bg", options.background ?? "#15151d");
|
|
482
|
+
root.style.setProperty("--mlb-fg", options.color ?? "#ffffff");
|
|
483
|
+
root.style.setProperty("--mlb-accent", options.accent ?? "#ffcc33");
|
|
484
|
+
root.style.setProperty("--mlb-font", options.font ?? 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif');
|
|
485
|
+
const canvas = this._game.getCanvasElement();
|
|
486
|
+
const place = () => {
|
|
487
|
+
const rect = canvas.getBoundingClientRect();
|
|
488
|
+
root.style.left = `${rect.left}px`;
|
|
489
|
+
root.style.top = `${rect.top}px`;
|
|
490
|
+
root.style.width = `${rect.width}px`;
|
|
491
|
+
root.style.height = `${rect.height}px`;
|
|
492
|
+
root.style.fontSize = `${Math.max(12, Math.min(22, rect.width / 22))}px`;
|
|
493
|
+
};
|
|
494
|
+
place();
|
|
495
|
+
let token = 0;
|
|
496
|
+
const render = async () => {
|
|
497
|
+
const mine = ++token;
|
|
498
|
+
const table = await this.top(options.top ?? 10, { period: previous ? "previous" : options.period && options.period !== "previous" ? options.period : "current" })
|
|
499
|
+
.catch(() => ({ key: this.key, settings: resolved.settings, period: null, entries: [], mine: null, available: false, local: resolved.local }));
|
|
500
|
+
if (mine !== token || this._overlay !== root)
|
|
501
|
+
return;
|
|
502
|
+
root.innerHTML = renderLeaderboardHtml(table, { title, previous, format, askName: askName && !previous, playerName });
|
|
503
|
+
};
|
|
504
|
+
// Keys typed into the table, such as a name, must not reach the game.
|
|
505
|
+
const stopKeys = (event) => event.stopPropagation();
|
|
506
|
+
return new Promise((resolve) => {
|
|
507
|
+
const finish = () => {
|
|
508
|
+
window.removeEventListener("resize", place);
|
|
509
|
+
root.removeEventListener("keydown", stopKeys);
|
|
510
|
+
root.removeEventListener("keyup", stopKeys);
|
|
511
|
+
root.remove();
|
|
512
|
+
if (this._overlay === root) {
|
|
513
|
+
this._overlay = null;
|
|
514
|
+
this._closeOverlay = null;
|
|
515
|
+
}
|
|
516
|
+
resolve();
|
|
517
|
+
};
|
|
518
|
+
const saveName = () => {
|
|
519
|
+
const input = root.querySelector("[data-name-form] input");
|
|
520
|
+
void this.setPlayerName(input?.value ?? "").then((name) => {
|
|
521
|
+
if (name) {
|
|
522
|
+
playerName = name;
|
|
523
|
+
void render();
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
root.addEventListener("keydown", (event) => {
|
|
528
|
+
if (event.key === "Escape")
|
|
529
|
+
finish();
|
|
530
|
+
else if (event.key === "Enter" && event.target?.closest?.("[data-name-form]"))
|
|
531
|
+
saveName();
|
|
532
|
+
});
|
|
533
|
+
root.addEventListener("keydown", stopKeys);
|
|
534
|
+
root.addEventListener("keyup", stopKeys);
|
|
535
|
+
root.addEventListener("click", (event) => {
|
|
536
|
+
const target = event.target;
|
|
537
|
+
if (target === root || target?.closest("[data-close]")) {
|
|
538
|
+
finish();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (target?.closest("[data-save-name]")) {
|
|
542
|
+
saveName();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const tab = target?.closest("[data-period]");
|
|
546
|
+
if (tab) {
|
|
547
|
+
previous = tab.getAttribute("data-period") === "previous";
|
|
548
|
+
void render();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
window.addEventListener("resize", place);
|
|
552
|
+
this._overlay = root;
|
|
553
|
+
this._closeOverlay = finish;
|
|
554
|
+
document.body.appendChild(root);
|
|
555
|
+
void render();
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/** Closes the table, if it is showing. */
|
|
559
|
+
close() {
|
|
560
|
+
this._closeOverlay?.();
|
|
561
|
+
}
|
|
562
|
+
/** Closes the table. The leaderboard's scores are kept. */
|
|
563
|
+
destroy() {
|
|
564
|
+
this.close();
|
|
565
|
+
}
|
|
566
|
+
}
|