minimojs 1.0.0-alpha.33 → 1.0.0-alpha.35
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/dist/minimo-leaderboard.d.ts +199 -0
- package/dist/minimo-leaderboard.js +566 -0
- package/dist/minimo.d.ts +10 -4
- package/dist/minimo.js +19 -4
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Game } from "./minimo.js";
|
|
2
|
+
/**
|
|
3
|
+
* When a leaderboard starts again. Every period is a UTC calendar unit, and a
|
|
4
|
+
* week is an ISO week, starting on Monday, so every player sees the table
|
|
5
|
+
* reset at the same moment. Past periods stay readable.
|
|
6
|
+
*/
|
|
7
|
+
export type LeaderboardCycle = "never" | "daily" | "weekly" | "monthly" | "yearly";
|
|
8
|
+
/** Which score is better: `higher`, for points, or `lower`, for times. */
|
|
9
|
+
export type LeaderboardOrder = "higher" | "lower";
|
|
10
|
+
/**
|
|
11
|
+
* What a leaderboard's table calls each player: `player`, the name the player
|
|
12
|
+
* wrote, or `noname`; `hidden`, no names; or `assigned`, a name such as
|
|
13
|
+
* `Blue Fox 42`, given to each player.
|
|
14
|
+
*/
|
|
15
|
+
export type LeaderboardNames = "player" | "hidden" | "assigned";
|
|
16
|
+
/**
|
|
17
|
+
* Who can be in a leaderboard. `account`: players signed in to Minimo Games,
|
|
18
|
+
* in one table every player sees. `local`: light users, whose scores stay on
|
|
19
|
+
* their own device, so each player sees only their own.
|
|
20
|
+
*/
|
|
21
|
+
export type LeaderboardPlayer = "account" | "local";
|
|
22
|
+
/**
|
|
23
|
+
* How a leaderboard works. On Minimo Games the creator sets these on the
|
|
24
|
+
* game's Edit leaderboards page, or an AI agent sets them with the
|
|
25
|
+
* `saveGameLeaderboard` tool, and the game reads them; the settings passed to
|
|
26
|
+
* {@link Leaderboard} are used only when the game runs anywhere else.
|
|
27
|
+
*/
|
|
28
|
+
export interface LeaderboardSettings {
|
|
29
|
+
/** The title its table shows. */
|
|
30
|
+
title: string;
|
|
31
|
+
cycle: LeaderboardCycle;
|
|
32
|
+
order: LeaderboardOrder;
|
|
33
|
+
names: LeaderboardNames;
|
|
34
|
+
player: LeaderboardPlayer;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Which period of a leaderboard to read: `"current"` (the default),
|
|
38
|
+
* `"previous"`, such as yesterday's table of a daily leaderboard, or a period
|
|
39
|
+
* written out: `"2026-10-17"`, `"2026-W42"`, `"2026-10"`, or `"2026"`.
|
|
40
|
+
*/
|
|
41
|
+
export type LeaderboardPeriod = "current" | "previous" | string;
|
|
42
|
+
/** One row of a leaderboard's table. */
|
|
43
|
+
export interface LeaderboardEntry {
|
|
44
|
+
/** 1 for the best score. Equal scores share a rank. */
|
|
45
|
+
rank: number;
|
|
46
|
+
/** The player's name, or `null` when the leaderboard shows no names. */
|
|
47
|
+
name: string | null;
|
|
48
|
+
score: number;
|
|
49
|
+
/** Whether this is the player playing now. */
|
|
50
|
+
mine: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** One period of a leaderboard, best score first. */
|
|
53
|
+
export interface LeaderboardTable {
|
|
54
|
+
key: string;
|
|
55
|
+
settings: LeaderboardSettings;
|
|
56
|
+
/** The period read, or `null` for the period before a leaderboard that never resets. */
|
|
57
|
+
period: string | null;
|
|
58
|
+
entries: LeaderboardEntry[];
|
|
59
|
+
/** The player's own entry in the period, wherever it ranks, or `null`. */
|
|
60
|
+
mine: LeaderboardEntry | null;
|
|
61
|
+
/**
|
|
62
|
+
* `false` when the leaderboard does not exist, such as a key no leaderboard
|
|
63
|
+
* of the game has. The table is then empty.
|
|
64
|
+
*/
|
|
65
|
+
available: boolean;
|
|
66
|
+
/** Whether the scores are kept on this device rather than by Minimo Games. */
|
|
67
|
+
local: boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What became of a score:
|
|
71
|
+
*
|
|
72
|
+
* - `saved`: it is the player's best in the period, and the table shows it.
|
|
73
|
+
* - `not_better`: the player already has a better score in the period.
|
|
74
|
+
* - `pending_sign_in`: the leaderboard takes Minimo Games accounts and the
|
|
75
|
+
* player is not signed in. Minimo Games asks them to sign in, keeps the
|
|
76
|
+
* score, and saves it when they come back.
|
|
77
|
+
* - `unavailable`: the leaderboard does not exist, or the game is not taking
|
|
78
|
+
* new records now.
|
|
79
|
+
*/
|
|
80
|
+
export type LeaderboardSubmitStatus = "saved" | "not_better" | "pending_sign_in" | "unavailable";
|
|
81
|
+
export interface LeaderboardSubmitResult {
|
|
82
|
+
status: LeaderboardSubmitStatus;
|
|
83
|
+
/** The player's best score in the period, or `null` when nothing was saved. */
|
|
84
|
+
best: number | null;
|
|
85
|
+
/** The rank of that best score, or `null` when nothing was saved. */
|
|
86
|
+
rank: number | null;
|
|
87
|
+
period: string | null;
|
|
88
|
+
}
|
|
89
|
+
/** Options for {@link Leaderboard.top}. */
|
|
90
|
+
export interface LeaderboardTopOptions {
|
|
91
|
+
/** Which period to read. Default `"current"`. */
|
|
92
|
+
period?: LeaderboardPeriod;
|
|
93
|
+
}
|
|
94
|
+
/** Options for {@link Leaderboard.show}. Every field is optional. */
|
|
95
|
+
export interface LeaderboardShowOptions {
|
|
96
|
+
/** How many of the best scores the table shows. Default `10`, at most `100`. */
|
|
97
|
+
top?: number;
|
|
98
|
+
/** Which period the table opens on. Default `"current"`; the player can switch to the previous one. */
|
|
99
|
+
period?: LeaderboardPeriod;
|
|
100
|
+
/** A title instead of the leaderboard's own. */
|
|
101
|
+
title?: string;
|
|
102
|
+
/** How a score is written, such as `(s) => s.toFixed(2) + " s"`. Default: the number, with digit grouping. */
|
|
103
|
+
format?: (score: number) => string;
|
|
104
|
+
/** CSS font family, such as one of the game's fonts. Default: the system font. */
|
|
105
|
+
font?: string;
|
|
106
|
+
/** Background color of the table. Default `"#15151d"`. */
|
|
107
|
+
background?: string;
|
|
108
|
+
/** Text color. Default `"#ffffff"`. */
|
|
109
|
+
color?: string;
|
|
110
|
+
/** Color of the title and of the player's own row. Default `"#ffcc33"`. */
|
|
111
|
+
accent?: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A leaderboard of the game: a table of each player's best score, per period.
|
|
115
|
+
*
|
|
116
|
+
* On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
|
|
117
|
+
* game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
|
|
118
|
+
* tool; the game names one by its key. Their settings come from there, and a
|
|
119
|
+
* key that no leaderboard of the game has is unavailable. Leaderboards are part
|
|
120
|
+
* of Minimo Games Pro: while the creator is not on it, the game shows its
|
|
121
|
+
* tables but saves no new records.
|
|
122
|
+
*
|
|
123
|
+
* Scores of a leaderboard that takes Minimo Games accounts go to one table
|
|
124
|
+
* that every player sees. Minimo Games posts them for the signed-in player, and
|
|
125
|
+
* asks a player who is not signed in to sign in, keeping the score until they
|
|
126
|
+
* do. Scores of a leaderboard for light users stay on the device, and a game
|
|
127
|
+
* preview keeps every score on the device, so a working version never changes
|
|
128
|
+
* the published game's tables.
|
|
129
|
+
*
|
|
130
|
+
* Anywhere else, every leaderboard keeps its scores on the device, with the
|
|
131
|
+
* settings passed here.
|
|
132
|
+
*
|
|
133
|
+
* Nothing here pauses the game. Await {@link Leaderboard.show} to continue
|
|
134
|
+
* after the player closes the table.
|
|
135
|
+
*
|
|
136
|
+
* ```js
|
|
137
|
+
* import { Leaderboard } from "https://cdn.jsdelivr.net/npm/minimojs@<version>/dist/minimo-leaderboard.js";
|
|
138
|
+
*
|
|
139
|
+
* const best = new Leaderboard(game, "best-score");
|
|
140
|
+
* // When a run ends:
|
|
141
|
+
* await best.submit(score);
|
|
142
|
+
* await best.show({ top: 10 });
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export declare class Leaderboard {
|
|
146
|
+
/** The key the game names this leaderboard by. */
|
|
147
|
+
readonly key: string;
|
|
148
|
+
/**
|
|
149
|
+
* @param game - The game.
|
|
150
|
+
* @param key - The leaderboard's key, as set up on Minimo Games.
|
|
151
|
+
* @param settings - Used only when the game runs outside Minimo Games.
|
|
152
|
+
* Default: a leaderboard on the device that never resets, where higher is
|
|
153
|
+
* better and players are named by their own name.
|
|
154
|
+
*/
|
|
155
|
+
constructor(game: Game, key: string, settings?: Partial<LeaderboardSettings>);
|
|
156
|
+
/** Whether the table is showing. */
|
|
157
|
+
get isShowing(): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* This leaderboard's settings, and whether it exists and keeps its scores on
|
|
160
|
+
* the device.
|
|
161
|
+
*/
|
|
162
|
+
info(): Promise<{
|
|
163
|
+
settings: LeaderboardSettings;
|
|
164
|
+
available: boolean;
|
|
165
|
+
local: boolean;
|
|
166
|
+
}>;
|
|
167
|
+
/**
|
|
168
|
+
* Posts a score. Only the player's best score in the current period is
|
|
169
|
+
* kept, so posting every finished run is right.
|
|
170
|
+
*
|
|
171
|
+
* @param score - A finite number. Which is better, higher or lower, is the
|
|
172
|
+
* leaderboard's setting.
|
|
173
|
+
*/
|
|
174
|
+
submit(score: number): Promise<LeaderboardSubmitResult>;
|
|
175
|
+
/**
|
|
176
|
+
* Reads one period of the table, best score first.
|
|
177
|
+
*
|
|
178
|
+
* @param limit - How many of the best scores. Default `10`, at most `100`.
|
|
179
|
+
*/
|
|
180
|
+
top(limit?: number, options?: LeaderboardTopOptions): Promise<LeaderboardTable>;
|
|
181
|
+
/**
|
|
182
|
+
* The light user's name on this device, shared by every Minimo Games game,
|
|
183
|
+
* or `null` before the player writes one. The table asks for it on its own
|
|
184
|
+
* when a leaderboard for light users shows names.
|
|
185
|
+
*/
|
|
186
|
+
playerName(): Promise<string | null>;
|
|
187
|
+
/** Sets the light user's name on this device. Returns it cleaned, or `null` when it is empty or too long. */
|
|
188
|
+
setPlayerName(name: string): Promise<string | null>;
|
|
189
|
+
/**
|
|
190
|
+
* Shows the table over the game, in HTML: the best scores of a period, the
|
|
191
|
+
* player's own entry, and, for a leaderboard that resets, a switch to the
|
|
192
|
+
* previous period. Resolves when the player closes it.
|
|
193
|
+
*/
|
|
194
|
+
show(options?: LeaderboardShowOptions): Promise<void>;
|
|
195
|
+
/** Closes the table, if it is showing. */
|
|
196
|
+
close(): void;
|
|
197
|
+
/** Closes the table. The leaderboard's scores are kept. */
|
|
198
|
+
destroy(): void;
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/minimo.d.ts
CHANGED
|
@@ -2072,9 +2072,12 @@ export declare class Game {
|
|
|
2072
2072
|
/**
|
|
2073
2073
|
* Plays a square-wave beep using the Web Audio API.
|
|
2074
2074
|
*
|
|
2075
|
-
* **
|
|
2076
|
-
*
|
|
2077
|
-
*
|
|
2075
|
+
* **Retro tone, not a game's sound.** This synthesizes a square-wave tone and
|
|
2076
|
+
* needs no file, so it sounds like an old arcade machine. A game's music and
|
|
2077
|
+
* sound effects are audio files: declare them as `sound` entries in an asset
|
|
2078
|
+
* file, or load them with {@link Game.loadSound}, and play them with
|
|
2079
|
+
* {@link Game.playSound}. Use tones only for a deliberately retro game, or
|
|
2080
|
+
* when no audio file can be obtained.
|
|
2078
2081
|
*
|
|
2079
2082
|
* The AudioContext is created lazily on first call. Browsers require a user
|
|
2080
2083
|
* gesture (click, keypress) before audio can play — always call `sound()` in
|
|
@@ -2092,13 +2095,16 @@ export declare class Game {
|
|
|
2092
2095
|
* ```ts
|
|
2093
2096
|
* game.sound(440, 100); // 440 Hz beep for 100ms
|
|
2094
2097
|
* game.sound(261, 500); // middle C for 500ms
|
|
2095
|
-
* game.sound(880, 50); // high beep for 50ms
|
|
2098
|
+
* game.sound(880, 50); // high beep for 50ms
|
|
2096
2099
|
* ```
|
|
2097
2100
|
*/
|
|
2098
2101
|
sound(freq: number, durationMs: number): void;
|
|
2099
2102
|
/**
|
|
2100
2103
|
* Plays multiple square-wave tones in strict sequence.
|
|
2101
2104
|
*
|
|
2105
|
+
* Like {@link Game.sound}, this is a retro tone, not a game's sound: for
|
|
2106
|
+
* music and sound effects, play audio files with {@link Game.playSound}.
|
|
2107
|
+
*
|
|
2102
2108
|
* Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
|
|
2103
2109
|
* previous one ends.
|
|
2104
2110
|
*
|
package/dist/minimo.js
CHANGED
|
@@ -2216,6 +2216,15 @@ export class Game {
|
|
|
2216
2216
|
isPointerCancelled() {
|
|
2217
2217
|
return this._inputSystem.isPointerCancelled();
|
|
2218
2218
|
}
|
|
2219
|
+
/**
|
|
2220
|
+
* The canvas the game draws on, for a module in this package that lays HTML
|
|
2221
|
+
* over it, as `leaderboard` does with its table. Games never need it.
|
|
2222
|
+
*
|
|
2223
|
+
* @internal
|
|
2224
|
+
*/
|
|
2225
|
+
getCanvasElement() {
|
|
2226
|
+
return this._canvas;
|
|
2227
|
+
}
|
|
2219
2228
|
/**
|
|
2220
2229
|
* Returns `true` while any active pointer is held down over the target sprite.
|
|
2221
2230
|
* Works with both mouse input and multiple simultaneous touches.
|
|
@@ -2400,9 +2409,12 @@ export class Game {
|
|
|
2400
2409
|
/**
|
|
2401
2410
|
* Plays a square-wave beep using the Web Audio API.
|
|
2402
2411
|
*
|
|
2403
|
-
* **
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2412
|
+
* **Retro tone, not a game's sound.** This synthesizes a square-wave tone and
|
|
2413
|
+
* needs no file, so it sounds like an old arcade machine. A game's music and
|
|
2414
|
+
* sound effects are audio files: declare them as `sound` entries in an asset
|
|
2415
|
+
* file, or load them with {@link Game.loadSound}, and play them with
|
|
2416
|
+
* {@link Game.playSound}. Use tones only for a deliberately retro game, or
|
|
2417
|
+
* when no audio file can be obtained.
|
|
2406
2418
|
*
|
|
2407
2419
|
* The AudioContext is created lazily on first call. Browsers require a user
|
|
2408
2420
|
* gesture (click, keypress) before audio can play — always call `sound()` in
|
|
@@ -2420,7 +2432,7 @@ export class Game {
|
|
|
2420
2432
|
* ```ts
|
|
2421
2433
|
* game.sound(440, 100); // 440 Hz beep for 100ms
|
|
2422
2434
|
* game.sound(261, 500); // middle C for 500ms
|
|
2423
|
-
* game.sound(880, 50); // high beep for 50ms
|
|
2435
|
+
* game.sound(880, 50); // high beep for 50ms
|
|
2424
2436
|
* ```
|
|
2425
2437
|
*/
|
|
2426
2438
|
sound(freq, durationMs) {
|
|
@@ -2429,6 +2441,9 @@ export class Game {
|
|
|
2429
2441
|
/**
|
|
2430
2442
|
* Plays multiple square-wave tones in strict sequence.
|
|
2431
2443
|
*
|
|
2444
|
+
* Like {@link Game.sound}, this is a retro tone, not a game's sound: for
|
|
2445
|
+
* music and sound effects, play audio files with {@link Game.playSound}.
|
|
2446
|
+
*
|
|
2432
2447
|
* Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
|
|
2433
2448
|
* previous one ends.
|
|
2434
2449
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimojs",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.35",
|
|
4
4
|
"description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/minimo.js",
|